首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 綜合教學

MyBatis 一級緩存導致無限遞歸實戰排查與解決方案

     編輯:Java 綜合教學

MyBatis 一級緩存引發的「無限遞歸」生產事故復盤

高併發搶券場景下,MyBatis 默認開啟的 SqlSession 級一級緩存導致「讀到舊數據 → 遞歸重試 → 栈溢出」。本文還原現場、分析根因、對比三種解決方案,並給出生產環境推薦姿勢。

一、事故現場還原

業務邏輯

  1. 用戶搶券 → 查詢可用券列表(getTicketCodeList)
  2. 隨機選一張 → 查詳情校驗狀態(getByCode)
  3. 若已被用 → 遞歸重試 randExtractOneTicketCode()
  4. 樂觀鎖更新狀態(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 結果被緩存,後續查詢不走數據庫,導致業務讀到「臟讀」數據。

三、三種解決方案對比

方案 1:編程式事務 + 手動清緩存(徹底但侵入性強)

@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

方案 2:拋棄遞歸,改「拋異常 + 上層重試」(推薦,低侵入)

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,返回前端「系統繁忙,請重試」

✅ 代碼簡潔、符合「失敗快速」原則、前端可引導用戶重試 | ⚠️ 極高併發下用戶體驗略降(需重試)

方案 3:分佈式鎖 + Lua 腳本原子化(架構層面徹底治理)

  • Redis 分佈式鎖(Redisson)保護「查詢-校驗-更新」整個流程
  • 或 Redis Lua 腳本原子執行「扣減庫存 + 寫入兌換記錄」
  • 杜絕緩存不一致、遞歸重試、樂觀鎖失效等所有併發問題

✅ 根治 | ❌ 架構複雜度上升、運維成本高,適合核心高併發場景

四、生產環境最佳實踐清單

  1. 默認拒絕遞歸重試:數據庫併發場景下,遞歸 + 緩存 = 死循環隱患
  2. 一級緩存認知:同一事務/ SqlSession 內 SELECT 自動緩存,commit()、clearCache()、close() 才會清空
  3. 只讀事務優化:@Transactional(readOnly = true) 可提示數據庫/連接池優化,但不影響 MyBatis 一級緩存行為
  4. 高併發寫入場景:優先考慮 Redis 原子操作或分佈式鎖,MyBatis 只做單條寫入
  5. 監控告警:方法調用深度、異常率、緩存命中率納入觀測指標

五、總結

MyBatis 一級緩存是「事務級別的數據一致性視圖」,而非「實時數據庫鏡像」。在併發寫入場景下,不要在同一 SqlSession 內「查 → 改 → 再查」並期望看到最新數據。將遞歸重試改為「拋異常 + 外層重試」是成本最低、收益最高的修復姿勢;核心業務長期演進請引入 Redis 原子化方案。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved