高併發環境下生成序列編碼重複問題分析

陳國利發表於2023-03-13

一、背景

有個業務系統(訂單系統),透過後臺日志和監控觀察,系統偶爾會出現重複唯一索引問題,例如:後臺日誌片段

 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、存在哪些坑?

坑1Jedis客戶端版本要注意一下,如果是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方法之前開啟事務,在這之後再加鎖,當鎖住的程式碼執行完成後,再提交事務,因此鎖程式碼塊執行是在事務之內執行的。

可以推斷在程式碼塊執行完時,事務還未提交,這時如果其他執行緒進入鎖程式碼塊後,讀取的庫存資料就不是最新的,就可能產生了不是你想要的結果資料。

高併發環境下生成序列編碼重複問題分析

 

這個問題我們驗證測試一下,寫一個測試用例

高併發環境下生成序列編碼重複問題分析

用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;
    }

 

相關文章