高併發搶券場景下,MyBatis 默認開啟的 SqlSession 級一級緩存導致「讀到舊數據 → 遞歸重試 → 栈溢出」。本文還原現場、分析根因、對比三種解決方案,並給出生產環境推薦姿勢。
getTicketCodeList)getByCode)randExtractOneTicketCode()update4Receive),失敗則遞歸重試private String randExtractOneTicketCode(String mobile, String codePrefix) {
// 1. 查可用列表 —— 這裡命中一級緩存!
List<String> list = yzTicketCodeDaoExt.getTicketCodeList(codePrefix, NOT_EXCHANGE);
if (CollectionUtils.isEmpty(list)) throw new YzRuntimeException(NO_REMAINDER);
String code = list.get(random.nextInt(list.size()));
YzTicketCode ticket = yzTicketCodeDaoExt.getByCode(code);
// 2. 狀態校驗失敗 → 遞歸
if (ticket == null || ticket.getStatus() != NOT_EXCHANGE) {
return randExtractOneTicketCode(mobile, codePrefix); // 無限遞歸入口
}
// 3. 樂觀鎖更新
ticket.setStatus(HAD_EXCHANGED);
ticket.setMobile(mobile);
if (yzTicketCodeDaoExt.update4Receive(ticket) <= 0) {
return randExtractOneTicketCode(mobile, codePrefix); // 二次遞歸入口
}
return code;
}
| 階段 | 實際數據庫狀態 | MyBatis 一級緩存內容 | 業務感知 |
|---|---|---|---|
| 線程 A 查列表 | 剩 1 張券 | 緩存:[券 1001] | 拿到 1001 |
| 線程 A 更新成功 | 券 1001 → 已兌換 | 緩存未刷新(未 commit/clearCache) | 以為成功 |
| 線程 B 查列表 | 剩 0 張 | 命中緩存,仍返回 [券 1001] | 誤以為還有券 |
| 線程 B 校驗詳情 | 券 1001 已兌換 | 緩存命中,返回舊對象(狀態仍 NOT_EXCHANGE) | 進入遞歸重試 |
| 線程 B 遞歸 | 無可用券 | 緩存不變,死循環 | StackOverflowError |
核心結論:同一 SqlSession(Spring 聲明式事務默認綁定 ThreadLocal)內,SELECT 結果被緩存,後續查詢不走數據庫,導致業務讀到「臟讀」數據。
@Autowired PlatformTransactionManager txManager;
public String randExtract(...) {
TransactionTemplate template = new TransactionTemplate(txManager);
return template.execute(status -> {
SqlSession sqlSession = sqlSessionFactory.openSession(); // 新會話
try {
// 業務邏輯...
sqlSession.clearCache(); // 關鍵:每次遞歸前清緩存
return doExtract(...);
} finally {
sqlSession.close();
}
});
}
✅ 徹底解決 | ❌ 代碼侵入大、事務管理複雜、易出 Bug
private String randExtractOneTicketCode(String mobile, String codePrefix) {
List<String> list = yzTicketCodeDaoExt.getTicketCodeList(codePrefix, NOT_EXCHANGE);
if (CollectionUtils.isEmpty(list)) throw new YzRuntimeException(NO_REMAINDER);
String code = list.get(random.nextInt(list.size()));
YzTicketCode ticket = yzTicketCodeDaoExt.getByCode(code);
if (ticket == null || ticket.getStatus() != NOT_EXCHANGE) {
throw new YzRuntimeException(SYSTEM_BUSY); // 不再遞歸,拋業務異常
}
ticket.setStatus(HAD_EXCHANGED);
ticket.setMobile(mobile);
if (yzTicketCodeDaoExt.update4Receive(ticket) <= 0) {
throw new YzRuntimeException(SYSTEM_BUSY);
}
return code;
}
// Controller/Service 上層捕獲 SYSTEM_BUSY,返回前端「系統繁忙,請重試」
✅ 代碼簡潔、符合「失敗快速」原則、前端可引導用戶重試 | ⚠️ 極高併發下用戶體驗略降(需重試)
✅ 根治 | ❌ 架構複雜度上升、運維成本高,適合核心高併發場景
commit()、clearCache()、close() 才會清空@Transactional(readOnly = true) 可提示數據庫/連接池優化,但不影響 MyBatis 一級緩存行為MyBatis 一級緩存是「事務級別的數據一致性視圖」,而非「實時數據庫鏡像」。在併發寫入場景下,不要在同一 SqlSession 內「查 → 改 → 再查」並期望看到最新數據。將遞歸重試改為「拋異常 + 外層重試」是成本最低、收益最高的修復姿勢;核心業務長期演進請引入 Redis 原子化方案。