一、背景
有個業務系統(訂單系統),透過後臺日志和監控觀察,系統偶爾會出現重複唯一索引問題,例如:後臺日誌片段
Duplicate entry 'service_no' for key 'idx_service_no' ....
也就是說寫入資料與資料庫已有資料發生重複。
下面我們分析一下問題出現在哪裡:
這個欄位就是業務編碼 :service_no
這個取號規則是:要固定長度13位數,由首寫大寫字母 F+年月日(201201)+6 位有序數字組成。
6位有序數字,是當天資料庫增量已有資料最大編碼序列依次累加。
首先說明一下:每天6位數字(最高是999999)一天最多99萬個序列號絕對夠當前業務使用的,(實際上一天最多也就幾萬個單號),所以號量是滿足業務需求的。
業務規則沒有問題,那說明是程式程式碼邏輯有問題了,我們來看一下程式碼情況:
二、分析程式碼邏輯結構
1、首先取號
獲取生成下一個序列號取號方法如下:
/**
* 建立編碼內部自帶加鎖
* @param rule 編碼規則引數
* @param params 引數陣列
* @return 取號結果字串
*/
@Transactional
public String create(String rule, String... params) {
String code = "";
String lockName = rule;
if (params.length > 0 && StringUtils.isNotEmpty(params[0])) {
lockName = rule + ":" + params[0];
}
if (params.length > 1) {
lockName = rule + ":" + params[0] + ":" + params[1];
}
try {
//加jedis客戶端工具分散式鎖
RedisLocker.lock(lockName, 10);
code = getNext(rule, params);
} finally {
//釋放鎖
RedisLocker.unlock(lockName);
}
return code;
}
/**
* 建立序列編碼,無事務控制,需要依賴外層加redis鎖!
* @param rule 生成規則
* @param params 引數列表
* @return 取號結果字串
*/
private String getNext(String rule, String... params) {
String[] rules = rule.split("-");
CodeCondition condition = new CodeCondition();
Code code = new Code();
String prefix = "";
String num = "";
String digit = "";
int i = 0;
for (String str : rules) {
if (str.equals("r")) {
// 型別
code.setType(params[i]);
condition.setType(params[i]);
prefix += params[i];
i++;
} else if (str.equals("c")) {
// 商家編碼
code.setShopCode(params[i]);
condition.setShopCode(params[i]);
prefix += params[i];
i++;
} else if (str.contains("yy") || str.contains("MM")) {
// 日期
//SimpleDateFormat formatter = new SimpleDateFormat(str);
String t = DateUtils.getCurrentDate(str);
code.setTime(t);
prefix += t;
} else if (str.contains("N")) {
// 數字
digit = str.substring(1);
} else {
// 其它
prefix += str;
}
}
code.setPrefix(prefix);
condition.setPrefix(prefix);
if (digit.length() > 0) {
int n = 1;
Integer serialNumber = codeDao.findNewOneByManual(condition);
if (serialNumber != null) {
n = serialNumber + 1;
}
code.setSerialNumber(n);
num = String.format("%0" + digit + "d", n);
}
code.setCode(prefix + num);
// 新增3次重試機制
boolean success = codeDao.getNextCode(code);
if (success) {
return prefix + num;
}
return null;
}
2、資料操作層事務
提交到DAO層準備交給執行JDBC去執行提交到資料庫,主要使用了手動控制事務提交程式碼如下:
/**
* 手動控制事務
* @param param 提交新的物件值更新
* @return
*/
public boolean getNextCode(final Code param){
//手動控制事務
transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
return transactionTemplate.execute(new TransactionCallback<Boolean>() {
public Boolean doInTransaction(final TransactionStatus status) {
try {
int i = 0;
CodeCondition con = new CodeCondition();
con.setType(param.getType());
con.setTime(param.getTime());
con.setShopCode(param.getShopCode());
//根據入參條件查詢是否存在編碼物件資料
Code c = findNewOne(con);
if(c == null){
i = jdbcTemplate.update("INSERT INTO xx_code(id,code,type,time,shop_code,serial_number,prefix,remarks) VALUES(?,?,?,?,?,?,?,?)",
UUID.randomUUID().toString().replaceAll("-", ""),
param.getCode(),
param.getType(),
param.getTime(),
param.getShopCode(),
param.getSerialNumber(),
param.getPrefix(),
param.getRemarks());
} else {
List<Object> params = new ArrayList<Object>();
params.add(param.getCode());
params.add(param.getPrefix());
params.add(param.getSerialNumber());
params.add(c.getTime());
params.add(c.getId());
//使用舊值做匹配條件
String sql = "UPDATE xx_code SET code=?, prefix=?,serial_number=?,time=? WHERE id=? ";
i = jdbcTemplate.update(sql, params.toArray());
}
return i > 0;
} catch (Exception ex) {
status.setRollbackOnly();
logger.error(ex.getMessage(),ex);
}
return false;
}
});
}
這裡備註說明一下幾個事務屬於引數:
PROPAGATION_REQUIRED-- 支援當前事務,如果當前沒有事務,就新建一個事務。這是最常見的選擇。
假如當前正要執行的事務不在另外一個事務裡,那麼就起一個新的事務 。
ServiceA {
void methodA() {
ServiceB.methodB();
}
}
ServiceB {
void methodB() {
}
}
比如說,ServiceB.methodB的事務級別定義為PROPAGATION_REQUIRED, 那麼由於執行ServiceA.methodA的時候
1、如果ServiceA.methodA已經起了事務,這時呼叫ServiceB.methodB,ServiceB.methodB看到自己已經執行在ServiceA.methodA的事務內部,就不再起新的事務。這時只有外部事務並且他們是共用的,所以這時ServiceA.methodA或者ServiceB.methodB無論哪個發生異常methodA和methodB作為一個整體都將一起回滾。
2、如果ServiceA.methodA沒有事務,ServiceB.methodB就會為自己分配一個事務。這樣,在ServiceA.methodA中是沒有事務控制的。只是在ServiceB.methodB內的任何地方出現異常,ServiceB.methodB將會被回滾,不會引起ServiceA.methodA的回滾。
在 spring的 TransactionDefinition介面中一共定義了六種事務傳播屬性:
PROPAGATION_REQUIRED -- 支援當前事務,如果當前沒有事務,就新建一個事務。這是最常見的選擇。
PROPAGATION_SUPPORTS -- 支援當前事務,如果當前沒有事務,就以非事務方式執行。
PROPAGATION_MANDATORY -- 支援當前事務,如果當前沒有事務,就丟擲異常。
PROPAGATION_REQUIRES_NEW -- 新建事務,如果當前存在事務,把當前事務掛起。
PROPAGATION_NOT_SUPPORTED -- 以非事務方式執行操作,如果當前存在事務,就把當前事務掛起。
PROPAGATION_NEVER -- 以非事務方式執行,如果當前存在事務,則丟擲異常。
PROPAGATION_NESTED -- 如果當前存在事務,則在巢狀事務內執行。如果當前沒有事務,則進行與PROPAGATION_REQUIRED類似的操作。
前六個策略類似於EJB CMT,第七個(PROPAGATION_NESTED)是Spring所提供的一個特殊變數。
它要求事務管理器或者使用JDBC 3.0 Savepoint API提供巢狀事務行為(如Spring的DataSourceTransactionManager)。
3、分散式鎖工具
使用jedis工具包作為分散式鎖程式碼如下:
/**
* 在指定時間內等待獲取鎖
* @param waitTime 等待鎖的時間
*
*/
public boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException {
// 系統當前時間,以毫微秒為單位。
long nano = System.nanoTime();
do {
if(tryLock()){
logger.debug(this.lockValue + "獲取鎖");
return Boolean.TRUE;
}
Thread.sleep(new Random().nextInt(100) + 1);
} while ((System.nanoTime() - nano) < unit.toNanos(waitTime));
return Boolean.FALSE;
}
/**
* 阻塞式加鎖
*/
public void lock() {
while(!tryLock()){
try {
// 睡眠,降低搶鎖頻率,緩解redis壓力
Thread.sleep(new Random().nextInt(100) + 1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/**
* 獲取鎖的執行緒解鎖
* 不足:當時阿里雲redis叢集版本暫不支援執行lua指令碼eval函式。
* 參考:https://help.aliyun.com/document_detail/26356.html?spm=5176.11065259.1996646101.searchclickresult.3dd24026cWCgRN
*
*/
public void unlock() {
// 檢查當前執行緒是否持有鎖
if (this.lockValue.equals(jedis.get(this.lockName))) {
logger.debug(this.lockValue + "釋放鎖");
try {
jedis.del(this.lockName);
} finally {
// Jedis 客戶端版本是使用 Jedis-2.7.2版本;如果是2.9以上本的版注意這裡不是關閉連線,在JedisPool模式下,Jedis會被歸還給資源池。
if (jedis != null) {
jedis.close();
}
}
} else {
logger.debug(Thread.currentThread().getName() + "並非持有鎖的執行緒,未能解鎖");
}
}
上面展示程式碼展示分散式銷和事務一些使用,但請注意這裡有坑!!!
4、存在哪些坑?
坑1:Jedis客戶端版本要注意一下,如果是3.0.1及以上的話 jedis.close();已經被重寫,可以去翻看官方原始碼。
2.9 版本以前連線池使用有returnResource介面方法,3.0.1之後版本被去掉了。
官方重寫了close方法,jedis.close不能直接呼叫。
try {
jedis = pool.getResource();
} finally {
if (jedis != null) {
jedis.close();
}
}
記得某一次升級了jedis client版本還導致生產環境redis服務被打暴,引發重大事故,所以如果使用jedis client建議使用2.9以下版本還靠譜一些。
其實這種寫法,還有一些問題的,需要進一步改進。如何改進?我們往下一步分析。
改進版本1(使用lua指令碼替代):
/**
* 嘗試獲取分散式鎖
* @param jedis Redis客戶端
* @param lockKey 鎖
* @param requestId 請求標識
* @param expireTime 超期時間
* @return 是否獲取成功
*/
public static boolean tryLock(Jedis jedis, String lockKey, String requestId, long expireTime) {
//jedis 3.0之後寫法
/* SetParams params = new SetParams();
params.px(expireTime);
params.nx();*/
String result = jedis.set(lockKey, requestId,"NX", "PX", expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
/**
* 釋放分散式鎖(LUA指令碼實現)
* @param jedis Redis客戶端
* @param lockKey 鎖
* @param requestId 請求標識
* @return
*/
public static boolean unLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return " +
"0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
if (jedis != null) {
jedis.close();
}
return false;
}
當時阿里雲redis叢集版本暫不支援執行lua指令碼eval函式。所以當時lua指令碼方式的無發揮之地!
參考:https://help.aliyun.com/document_detail/26356.html?spm=5176.11065259.1996646101.searchclickresult.3dd24026cWCgRN
改進版本2(使用redisson替代):
/**
* 利用redisson client 實現分散式鎖
* @author cgli
*/
public final class RedisLocker {
private final static String LOCKER_PREFIX = "lock:";
private static final Logger logger = LoggerFactory.getLogger(RedisLocker.class);
private RedisLocker() {
}
/**
* 獲取連線配置例項
* @return
*/
private static RedissonClient getClient() {
return SingletonHolder.client;
}
/**
* 根據name對進行上鎖操作,redissonLock 阻塞式的,採用的機制釋出/訂閱
* timeout結束強制解鎖,防止死鎖 :1分鐘
* @param lockName 鎖名稱
*/
public static void lock(String lockName){
lock(lockName,60);
}
/**
* 根據name對進行上鎖操作採用redisson RLOCK加鎖方式
* @param lockName 鎖名稱
* @param leaseTime 結束強制解鎖,防止死鎖 :單位秒
*/
public static void lock(String lockName,long leaseTime){
String key = LOCKER_PREFIX + lockName;
RLock lock = getClient().getLock(key);
//lock提供帶timeout引數,timeout結束強制解鎖,防止死鎖 :1分鐘
lock.lock(leaseTime, TimeUnit.SECONDS);
}
/**
* 根據name對進行解鎖操作
* @param lockName
*/
public static void unlock(String lockName){
String key = LOCKER_PREFIX + lockName;
RLock lock = getClient().getLock(key);
if(lock.isLocked()){
if(lock.isHeldByCurrentThread()){
lock.unlock();
}
}
}
/**
*
* @param resourceName 鎖的KEY名稱
* @param worker 回撥外部工作任務
* @param lockTime 鎖定超時時間,預設acquireTimeout=100second 獲取鎖的超時時間
* @param <T>
* @return
* @throws Exception
*/
public static <T> T lock(String resourceName, AquiredLockWorker<T> worker,
long lockTime) throws Exception {
return lock(resourceName, worker, 100, lockTime);
}
/**
*
* @param resourceName 鎖的KEY名稱
* @param worker 回撥外部工作任務
* @param acquireTimeout 獲取鎖的超時時間
* @param lockTime 鎖定超時時間
* @param <T>
* @return
* @throws Exception
*/
public static <T> T lock(String resourceName, AquiredLockWorker<T> worker, long acquireTimeout,
long lockTime) throws Exception {
RLock lock = getClient().getLock(LOCKER_PREFIX + resourceName);
// Acquire lock and release it automatically after 10 seconds
// if unlock method hasn't been invoked
//lock.lock(10, TimeUnit.SECONDS);
try {
// Wait for acquireTimeout seconds and automatically unlock it after lockTime seconds
boolean res = lock.tryLock(acquireTimeout, lockTime, TimeUnit.SECONDS);
if (res) {
return worker.execute();
}
} finally {
if (lock != null) {
lock.unlock();
}
}
return null;
}
/**
* 內部類實現單例模式
*/
static class SingletonHolder {
private static RedissonClient client = init();
private static RedissonClient init() {
RedisProperties properties = ApplicationContextHolder.getApplicationContext().getBean(RedisProperties.class);
String host = properties.getRedisHost();
int port = Integer.parseInt(properties.getRedisPort());
String password = properties.getRedisPassword();
if (StringUtils.isEmpty(password)) {
password = null;
}
int database = Integer.parseInt(properties.getRedisDataBase());
try {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + host + ":" + port)
.setPassword(password)
.setDatabase(database)
//同任何節點建立連線時的等待超時。時間單位是毫秒。預設:10000
.setConnectTimeout(30000)
//當與某個節點的連線斷開時,等待與其重新建立連線的時間間隔。時間單位是毫秒。預設:3000
//等待節點回覆命令的時間。該時間從命令傳送成功時開始計時。預設:3000
.setTimeout(10000)
//如果嘗試達到 retryAttempts(命令失敗重試次數) 仍然不能將命令傳送至某個指定的節點時,將丟擲錯誤。如果嘗試在此限制之內傳送成功,則開始啟用 timeout(命令等待超時)
// 計時。預設值:3
.setRetryAttempts(5)
//在一條命令傳送失敗以後,等待重試傳送的時間間隔。時間單位是毫秒。 預設值:1500
.setRetryInterval(3000);
return Redisson.create(config);
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
return null;
}
}
}
坑2:使用@Transactional 註解巢狀事務的問題,會導致一些事務混亂,達不到最終的資料效果。
回去最開始程式碼處
@Transactional //這裡加了事務註解
public String create(String rule, String... params) {
try {
//加jedis客戶端工具分散式鎖
RedisLocker.lock(lockName, 10);
code = getNext(rule, params);
} finally {
//釋放鎖
RedisLocker.unlock(lockName);
}
}
這種結果達不到預期的結果,就是會出現有時會產生重複的編碼,如本文提出的問題。這是為什麼呢?
由於Spring的AOP機制,會在update/save方法之前開啟事務,在這之後再加鎖,當鎖住的程式碼執行完成後,再提交事務,因此鎖程式碼塊執行是在事務之內執行的。
可以推斷在程式碼塊執行完時,事務還未提交,這時如果其他執行緒進入鎖程式碼塊後,讀取的庫存資料就不是最新的,就可能產生了不是你想要的結果資料。
這個問題我們驗證測試一下,寫一個測試用例
測試用例程式碼
@Test
public void productionCode(){
for(int j=0;j<100;j++) {
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
String code = codeService.productionOrderCodeDesign( "S000044");
System.out.println(Thread.currentThread().getName()+ "產生的ID====" + code);
}
}
}).start();
}
}
@Test
public void serviceNoCode() {
Set<String> hs = new HashSet<>();
CompletableFuture f = null;
for (int j = 0; j < 100; j++) {
f = CompletableFuture.supplyAsync(() -> {
for (int i = 0; i < 100; i++) {
String code = codeService.serviceCode();
hs.add(code);
System.out.println(Thread.currentThread().getName() + "產生的ID====" + code);
}
return "OK";
});
}
try {
f.get();
} catch (Exception ex) {
ex.printStackTrace();
}
System.out.println("------>total:" + hs.size());
}
@Test
public void serviceContactCode() {
Set<String> hs = new HashSet<>();
CompletableFuture f = null;
for (int j = 0; j < 10; j++) {
f = CompletableFuture.supplyAsync(() -> {
for (int i = 0; i < 100; i++) {
String code = codeService.contractCode("S000044");
hs.add(code);
System.out.println(Thread.currentThread().getName() + "產生的ID====" + code);
}
return "OK";
});
}
try {
f.get();
} catch (Exception ex) {
ex.printStackTrace();
}
System.out.println("------>total:" + hs.size());
}
用jmeter壓力測試跑一下,取序列號的結果
在日誌分析中,經常是有兩個執行緒查到上一個相同的序列號,拿到的不是更新之後的資料結果,如下圖:
產生重複編碼,說明jedis分散式鎖並沒有真實鎖住。問題就是出現在最外層的 @Transactional註解上,最外層呼叫程式碼完全沒有必要加了@Transactional。
因為最內層已經加了手動控制事務的控制。拿掉外層的@Transactional再跑壓力測試一切正常。
三、總結
1、Redis java客戶端不推薦使用jedis,特別是2.9以上的版本,程式碼沒有處理好很容易搞把redis服務搞死(連線數打滿),推薦使用redisson代替,效能高且內建實現連線池。
如果真的一定要用jedis使用2.9以下版本並使用 lua指令碼來控制,才能實現真正原子性操作。
2、事務註解@Transactional事務控制,巢狀使用時要注意,儘量控制在最小單元的最內層使用,在最外層(大方法)使用有風險,特別是跟鎖一起使用時要注意控制兩者順序。
另外@Transactional在分散式環境下,遠端呼叫無效的,並不能當作分散式事務來對待,這個業內有成熟其他方案替代。
3、其實取號生成序列號服務,沒有必要使用資料庫當作序列計數,完全可以使用redis計數器做實現(內部版本2年前就用redis版本來取號,連續執行兩2年多沒有發現有啥問題)。
示例程式碼如下:
private String generateByRedis(BusinessCodeCondition condition) {
String time = "";
String prefix = "";
String type = condition.getType();
StringBuilder sb = new StringBuilder();
sb.append(type);
if (StringUtils.isNotEmpty(condition.getShopCode())) {
sb.append(condition.getShopCode());
}
if (StringUtils.isNotEmpty(condition.getDateFormat())) {
time = DateFormatUtil.formatDate(condition.getDateFormat(), new Date());
sb.append(time);
}
prefix = sb.toString();
sb.setLength(0);
String key = KEY_PREFIX + prefix;
//long n = redisTemplate.opsForValue().increment(key, 1L);
//redisTemplate.expire(key, EXPIRE_TIME, TimeUnit.DAYS);
long n = getIncrementNum(key, condition.getDateFormat());
return String.format("%s%0" + condition.getDigitCount() + "d", prefix, n);
}
//使用redis計數器取序列,每天過期前一天的key值回收。
private Long getIncrementNum(String key, String dateFormat) {
RedisAtomicLong entityIdCounter = new RedisAtomicLong(key, redisTemplate.getConnectionFactory());
Long counter = entityIdCounter.incrementAndGet();
if ((null == counter || counter.longValue() == 1)) {
if (StringUtils.isNotEmpty(dateFormat) && dateFormat.indexOf("yyMMdd") > -1) {
entityIdCounter.expire(EXPIRE_TIME, TimeUnit.DAYS);
}
}
return counter;
}