Spring事務管理下加鎖,為啥還執行緒不安全?
前言
在單體架構的秒殺活動中,為了減輕DB層的壓力,這裡我們採用了Lock鎖來實現秒殺使用者排隊搶購。然而很不幸的是儘管使用了鎖,但是測試過程中仍然會超賣,執行了N多次發現依然有問題。
程式碼演示
先上程式碼:
@RestController @Slf4j public class SeckillDistributedController { @Resource private ISeckillDistributedService seckillDistributedService; @PostMapping("/startSeckilLock") public Result startSeckilLock(long seckillId) { final long killId = seckillId; log.info("開始秒殺"); for (int i = 0; i < 10000; i++) { final long userId = i; Runnable task = () -> { { Result result = seckillDistributedService.startSeckilLock(killId, userId); } }; executor.execute(task); } return Result.ok(); } } @Service public class SeckillServiceImpl implements ISeckillService { private Lock lock = new ReentrantLock(true);//互斥鎖 引數預設false,不公平鎖 @Resource private DynamicQuery dynamicQuery; @Override @Transactional public Result startSeckilLock(long seckillId, long userId) { try { lock.lock(); String nativeSql = "SELECT number FROM seckill WHERE seckill_id = ?"; Object object = dynamicQuery.nativeQueryObject(nativeSql, new Object[]{seckillId}); Long number = ((Number) object).longValue(); if(number > 0){ nativeSql = "UPDATE seckill SET number = number - 1 WHERE seckill_id = ?"; dynamicQuery.nativeExecuteUpdate(nativeSql, new Object[]{seckillId}); SuccessKilled killed = new SuccessKilled(); killed.setSeckillId(seckillId); killed.setUserId(userId); killed.setState(Short.parseShort(number + "")); killed.setCreateTime(new Timestamp(new Date().getTime())); dynamicQuery.save(killed); }else{ return Result.error(); } } catch (Exception e) { e.printStackTrace(); }finally { lock.unlock(); } return Result.ok(); } }
出現問題
多執行緒運行同一個事務管理下加鎖的方法,方法內操作的是資料庫,按正常邏輯得到最終的值應該是100,但經過多次測試,最終結果出現超賣101,如下圖所示:
這是為什麼呢?
問題分析
追蹤
事物未提交之前,鎖已經釋放(事物提交是在整個方法執行完),導致另一個事物讀取到了這個事物未提交的資料,出現了髒讀。
資料庫層面分析
資料庫預設的事務隔離級別為可重複讀(repeatable-read)[^腳註1],也就不可能出現髒讀[^腳註2],但會出現幻讀[^腳註3]。查詢資料庫可知,如下圖所示:
程式碼層面分析
程式碼寫在service層,bean預設是單例的,也就是說lock肯定是一個物件。
總結
這裡,總結一下為什麼會超賣101:秒殺開始後,某個事物在未提交之前,鎖已經釋放(事物提交是在整個方法執行完),導致下一個事物讀取到了上個事物未提交的資料,出現傳說中的髒讀。
解決方案
此處給出的建議是:鎖上移,上移到Controller層,包住整個事物單元。修改程式碼為:
public Result startSeckilLock(long seckillId) { final long killId = seckillId; log.info("開始秒殺"); for (int i = 0; i < 10000; i++) { final long userId = i; Runnable task = () -> { { try{ lock.lock(); Result result = seckillService.startSeckilLock(killId, userId); }finally { lock.unlock(); } } }; executor.execute(task); } return Result.ok(); }
修改完成後,重新測試一下程式碼。意料之中,再也沒有出現超賣的現象。
原始碼解析
@Transactional
切片是一種特殊情況
1)多 AOP 之間的執行順序在未指定時是 :undefined
,官方文件並沒有說一定會按照註解的順序進行執行,只會按照@ Order
的順序執行。
參考官方文件: 可在頁面裡搜尋 Control+F/Command+F「7.2.4.7 Advice ordering」
2)事務切面的 default Order
被設定為了 Ordered.LOWEST_PRECEDENCE
,所以預設情況下是屬於最內層的環切。
解析原始碼可知:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
@Documented
public @interface Order {
int value() default 2147483647;
}
public interface Ordered {
int HIGHEST_PRECEDENCE = -2147483648;
int LOWEST_PRECEDENCE = 2147483647;
int getOrder();
}
參考官方文件: 可在頁面裡搜尋 Control+F/Command+F「Table 10.2. tx:annotation-driven/ settings」
可重複讀: 每次讀取的都是當前事務的版本,即使被修改了,也只會讀取當前事務版本的資料。 髒讀: 一個事務讀取到另外一個事務未提交的資料 幻讀: 是指在一個事務內讀取到了別的事務插入的資料,導致前