1. 程式人生 > >記一次線上問題 → 事務去哪了

記一次線上問題 → 事務去哪了

開心一刻

  小羊:哎呀,前面有奶喝

  狗媽:這誰呀,走開

  小羊:我就喝點,能怎麼的嘛

  狗媽:你喝就喝,咋還上頭了呢?

  小羊:真香!

  狗媽:這羊犢子,真硬核!

問題背景

  一天早上,樓主興致勃勃的逛著園子的時候,右下角的 QQ 頭像嘀嘀嘀的閃了起來,定睛一看,哎我去,腎要開始疼了,不是,頭要開始疼了

  客服 MM:太躺,有個客戶充值成功後,贈送的積分沒有到賬

  樓主:是不是客戶等級不夠,不滿足資格 ?

  客服 MM:客戶等級是夠的,他之前的積分都正常到賬了

  樓主:之前的積分都到賬了 ? 哪個客戶,我去看看

  客服 MM:客戶名是:xxx,對應的單號是:xxx,找到原因了跟我說下

  樓主:好的,找到原因了第一時間通知你

泰坦是樓主在公司內的花名,也是樓主的 LOL 本命英雄,慢慢的被傳成太躺了,樓主也很無奈;
有小夥伴問樓主,你和客服 MM 什麼關係,光看到頭像閃動就腎疼了 ?
  這個問題問得好,改天樓主給你加雞腿,其實樓主和客服確實挺熟悉的,工作交流挺多的,但是僅限於同事關係! 吾乃心繫天下之人,豈能被兒女情長所困 ? 只可惜客服 MM 已名花有主,不然就,嘿嘿嘿,你們懂的(是那姓吾的小子心繫天下,樓主不姓吾!)

問題解決

  積分贈送是最近新上的一個功能,上了也有一個多星期了,到目前為止,也就這個客戶反饋了這個問題,另外這個客戶之前的積分都是贈送到賬了的,應該是觸發了某些未考慮到的邊界條件,產生了異常,導致積分未寫入成功,照理來說,這應該是一個事務,要麼都成功,要麼都不成功呀

  由於這個功能不是樓主開發的,出於快速解決問題的考慮,樓主就找到了對應的開發同事小李,跟他說明了下情況,讓他去排查下什麼原因

  過了一會,小李找到了樓主,開始了他的排查分享

  小李:太躺,我看了下日誌,由於 xxx 情況未考慮到,導致加積分記錄的時候拋異常了

  樓主:xxx 情況確實比較特殊,一般很難考慮到,但是為什麼存款成功了,積分卻沒加成功,你用了非同步不 care 結果的處理 ?

  小李:我是同步處理的,照理來說,應該要回滾的

  樓主:那就奇了怪了,你把寫入積分的方法給我下,我去看看程式碼

  幾分鐘過後,樓主找到了小李,跟他說了下怎麼改,並且讓他把邊界限制的處理也加上,走緊急流程升到了線上

  問題解決後,小李又找到了樓主

  小李:太躺啊,為什麼之前事務未回滾,而按你說的那麼改之後事務就會回滾了 ?

  樓主:你去把你的椅子拿過來,我跟你好好講講!

問題復現

  注意啊,這不是說升級了之後線上又出現了同樣的問題,而是樓主為了讓大家更好的瞭解這個問題,模擬下當時的場景

  資料庫版本 5.7.21 、儲存引擎 InnoDB 、隔離級別 RR 、spring的傳播機制 REQUIRED 、宣告式事務 @Transactional 

  完整程式碼:data-init,裡面的 TransactionMissTest ,關鍵程式碼如下

/**
 * 存款
 * 引入積分之前的處理
 * @param loginName
 * @param amount
 * @return
 */
@Override
@Transactional(rollbackFor = Exception.class)
public TranMissCredit deposit(String loginName, BigDecimal amount) {
    TranMissCredit credit = creditMapper.getByLoginName(loginName);
    BigDecimal creditAfter = credit.getCredit().add(amount);

    TranMissCreditLog creditLog = new TranMissCreditLog(loginName, credit.getCredit(),
            amount, creditAfter, "充值: " + amount);
    credit.setCredit(creditAfter);

    creditMapper.update(credit);
    int count = creditLogMapper.insert(creditLog);

    return credit;
}

/**
 * 存款
 * 引入積分後的新增的方法
 * @param loginName
 * @param amount
 * @param integration
 * @return
 */
@Override
public TranMissCredit deposit(String loginName, BigDecimal amount, int integration) {
    TranMissCredit credit = deposit(loginName, amount);             // 複用之前的存款邏輯

    // 下面是新增的積分業務
    int integrationAfter = credit.getIntegration() + integration;
    TranMissIntegrationLog log = new TranMissIntegrationLog(loginName, credit.getIntegration(),
            integration, integrationAfter, "充值贈送積分: " + integration);
    credit.setIntegration(integrationAfter);

    creditMapper.update(credit);
    integrationLogMapper.insert(log);
    return credit;
}


// 呼叫的地方,相當於Controller
@Autowired
private IDepositService depositService;

@Test
public void deposit() {

    // 積分引入前的呼叫
    // TranMissCredit credit = depositService.deposit("zhangsan", new BigDecimal(100));

    // 積分引入後的呼叫
    TranMissCredit credit = depositService.deposit("zhangsan", new BigDecimal(100), 10);

}
View Code

  看上去好像沒毛病吧,樓主你不是蒙我了把 ? 蒙沒蒙你,咱們找焦點訪談

  我們先看下初始狀態,目前只有客戶 zhangsan ,其額度 100 ,積分 10 

  我們來手動造個異常,模擬邊界條件的觸發,修改新增的 deposit 方法

/**
 * 存款
 * 引入積分後的新增的方法
 * @param loginName
 * @param amount
 * @param integration
 * @return
 */
@Override
public TranMissCredit deposit(String loginName, BigDecimal amount, int integration) {
    TranMissCredit credit = deposit(loginName, amount);             // 複用之前的存款邏輯

    // 下面是新增的積分業務
    int integrationAfter = credit.getIntegration() + integration;
    TranMissIntegrationLog log = new TranMissIntegrationLog(loginName, credit.getIntegration(),
            integration, integrationAfter, "充值贈送積分: " + integration);
    credit.setIntegration(integrationAfter);

    // 模擬異常丟擲
    if ("zhangsan".equals(loginName)) {
        throw new RuntimeException("觸發邊界條件");
    }
    creditMapper.update(credit);
    integrationLogMapper.insert(log);
    return credit;
}
View Code

  我們來看看結果

  喲嚯,額度加成功了,積分卻沒加成功,事務沒生效!是不是有點懵 ?

問題分析

  我們仔細觀察下 deposit 方法,一個有 @Transactional 修飾,一個沒有,就這一個差別;雖說只有這一個差別,但 Spring 卻在幕後替我們完成了很多事情

  Spring 事務原理

    關於這個,我相信大家都能答上來一點,底層實現就是動態代理(你還不知道動態代理 ?那還不趕緊去看:設計模式之代理,手動實現動態代理,揭祕原理實現)

    當 Spring 檢查到 @Transactional ,會給目標物件建立一個代理物件,然後在代理物件中給目標物件中被 @Transactional 修飾的方法織入事務增強處理,類似這樣

    如果目標物件中沒有被 @Transactional 修飾的方法,在代理類中是怎樣的了 ? 既然沒有被 @Transactional ,說明不需要事務增強處理嘛,那就直調唄

    回到我們的案例,代理物件與被代理物件之間的呼叫如下

    可以看出來,目標物件新增的方法 TranMissCredit deposit(String loginName, BigDecimal amount, int integration) 在代理物件內是沒有織入事務的,也就是預設的自動提交,那麼異常丟擲之前的資料庫操作都是自動提交的,不會因後面的異常而回滾

    其實不是事務丟失了,而是根本就不在一個事務中

  再次校驗

    不只是 Spring 事務,很多的 AOP 也都一樣,程式碼中直接操作的往往不是目標物件,而是目標物件的代理,通過代理物件來間接操作目標物件,而在代理物件中我們可以做一些前置或者後置的增強處理,不信 ? 我們再次找焦點訪談

    打個斷點,看看就知道了

    注入到 TransactionMissTest 的確實是代理物件

    我們在 TranMissCredit deposit(String loginName, BigDecimal amount) 上打個斷點,然後兩種方式各呼叫一次,來看看呼叫鏈有什麼不一樣

    以 depositService.deposit("zhangsan", new BigDecimal(100)); 方式呼叫時

    此時呼叫鏈中有事務攔截器,有事務的呼叫鏈

    以 depositService.deposit("zhangsan", new BigDecimal(100), 10) 方式呼叫時

    此時呼叫鏈中沒有事務攔截器,沒有事務的呼叫鏈

    是不是很明瞭了,so easy

總結

  1、正常上線流程

    線上問題 → 問題定位 → 問題復現 → 問題修復 → 轉測試 → 測試通過升線上

    而不是像文中說的那麼輕描淡寫

  2、事務去哪了

    Spring 事務的底層實現就是動態代理,是通過代理的方式對目標物件做前後的增強處理,前置開啟事務、後置提交(回滾)事務;

    增強處理在代理物件內,而不是在目標物件內,若目標物件的方法沒有被 @Transactional 修飾,則在代理物件的代理方法內不會有關於事務的增強處理,而是直接呼叫目標物件的方法,那麼後續的資料庫操作就不是在一個事務中了

    不是事務消失了,而是不在同一個事務了