商品發佈超時問題排查

起因

一天下午,線上監控日誌報警突然提示商品發佈失敗次數過多,緊接着前線運營小二就找上門來,XX,商家反饋某幾個商品發佈一直提示“發佈失敗,請重新試試”,編輯其他商品都沒有問題。

過程

菜鳥: 打開監控平臺發現調用商品發佈服務超時,目前商品發佈超時時間配置的是3S,什麼情況導致商品發佈超時呢?看看商品發佈服務找找原因吧
菜鳥”: 哦,商品服務集羣平均CPU使用率80%以上;好吧,應該是定時任務搞的鬼;搜索有個同步全量商品數據的任務,每隔一段時間就會執行一次,目前商品數據量是X級(數量不少,就不透露了哈),多臺搜索任務同步數據時會採用多線程方式,導致商品服務壓力過大;手動擴幾臺機器不就解決了嘛,我可真是一個小機靈鬼;

幾分鐘後,機器擴容完畢。。。

菜鳥”: 運營小二,再讓商家試下
運營小二:還是不行啊!
菜鳥”: 不會吧,我再看看;

於是菜鳥抓取了商家提示失敗的商品數據模擬線上發佈流程,確實提示失敗;看線上日誌,每天這種情況也會發生幾起,使用arthas看下導致慢在哪裏;由於當時沒有截圖,這裏簡單寫了個僞代碼來介紹吧;
發現在更新SKU時,執行updateAttributes方法超過1分鐘

// 更新商品
@Transactional(rollbackFor = Exception.class)
public void updateProduct(product){
    // 更新商品信息
    product.update(product);
    // 更新商品屬性
    updateAttributes(product.getAttributes(), updateSku.getProductId(), updateSku.getProductId(),RefTypeEnum.PRODUCT.getType());

    // 更新sku信息
    if (!CollectionUtils.isEmpty(updateSKUList)) {
        updateSKUList.parallelStream().forEach(updateSku -> {
            // 更新sku信息
            sku.update(updateSku);
            // 更新SKU屬性
            updateAttributes(updateSku.getAttributes(), updateSku.getSkuId(), updateSku.getProductId(),null);
        });
    }
}

// 更新屬性
private void updateAttributes(List<AttributeModel> attributes, String refId, String productId, Integer refType) {
    attr.deleteByRef(refId,refType);
    if (!CollectionUtils.isEmpty(attributes)) {
        for (AttributeModel entity : attributes) {
            entity.setRefId(refId);
            entity.setProductId(productId);
            entity.setRefType(refType);
        }
        List<AttributeEntity> entities = attributes.stream().map(this::getAttrEntity).collect(Collectors.toList());
        attr.insertBatch(entities);
    }
}

以上操作執行的SQL列表爲:

update product set xx = xx where product_id = xx;
update attribute set is_delete = 1 where ref_id = xx and ref_type = 'product'; 
insert attribute(ref_id,ref_type,product_id,value) values(product_id,'product',product_id,value1);

# 這裏模擬併發執行SQL順序
update sku set xx = xx where sku_id = xx;
update attribute set is_delete = 1 where ref_id = xx; 
update sku set xx = xx where sku_id = xx;
update attribute set is_delete = 1 where ref_id = xx; 
insert attribute(ref_id,ref_type,product_id,value) values(sku_id,'sku',product_id,value1);
insert attribute(ref_id,ref_type,product_id,value) values(sku_id,'sku',product_id,value1);

線上拋出的部分錯誤日誌信息

Error updating database. Cause: java.sql.SQLException: Lock wait timeout exceeded; try restarting transaction
The error may involve defaultParameterMap
The error occurred while setting parameters

菜鳥: 噫,鎖等待超時?一個事務不可能出現鎖等待問題,MySQL讀與讀之間不會發生鎖操作;讀與寫之間也不會發生鎖操作,使用MVVC來解決;只有寫與寫之間纔會發生行級鎖;,那就看看哪裏會同時修改一行數據吧。

猜想與驗證:

猜想:

  1. 多個用戶同時在修改同一個商品,但是這個商品由於某種情況倒是更新比較慢導致另外一個事務更新這個數據時只能等待;
  2. 商品及相關信息更新時,不在同一個事務。

驗證
驗證猜想一: 抓取了商家提示失敗的商品數據模擬線上發佈流程,點擊一次提示失敗,再點擊一次,還是提示失敗,這個商品必復現啊,可以直接排除猜想一了。
驗證猜想二: 額,這代碼不是我寫的,不是很熟悉,但是看代碼中已經加上了Transactional,使用的事務傳播行爲是默認的,調用其他的service更新時,也都在同一個事務內,故此也排除猜想二;
陷入窘境!!
這,這可如何是好?好奇怪呀?旁邊的同事也深入沉思。。。
我又仔細看了下前人寫的代碼,似乎發現了問題,updateSKUList.parallelStream(),這裏使用的是並行流,會導致事務丟失,應該就是這裏的問題;但是心中有一個問題出現了,那爲什麼其他商品更新沒有問題呢?目前就不得而知了。

臨時方案

此時商家那裏催的着急,排查過程並不順利,根據目前掌握的內容先來個死馬當活馬醫?
把並行流換成普通for循環來更新SKU信息,其他代碼不變,接着發佈預發環境,模擬數據發佈下商品試下,奇蹟般的事情發生了,商品發佈成功!由於改動了不大,經過簡單測試就上線了
上線兩天後,查詢日誌未再發現此種情況,說明確實是事務問題,臨時方案也就變成了永久方案,但是問題根本原因卻沒有找到,這是一個難得提升自己的機會,先留着這個問題,等有時間再來排查;

再次排查

在發現問題後,與DBA聯繫備份了一下這個商家的數據到仿真環境,該環境與完全複製線上一套環境,展示所有服務只有一臺,平時僅用於壓測,本次用來排查問題;
事情已經過去一個周了,今天下午抽時間再次排查下,本地連接仿真環境數據庫,直接跑junit調試下。
然而事情併爲想象那麼順利,一步一步調試也沒有結果;心裏有點失望,轉頭髮現水杯沒有水了,在接水路上忽然想起一位大佬的話,“越奇怪的問題,越要看日誌,日誌纔是最真實的,個人經驗有時候反而成爲排查問題的阻礙”

找到原因

回到工位,仔仔細細的看了下SQL執行的順序、入參(可以通過本地的SQL日誌,也可以通過監控平臺中的日誌,這裏使用的是監控平臺的日誌執行,主要是因爲監控平臺中的SQL參數都已經與SQL拼接在一起了),終於找到了原因!,我們先看下SQL執行日誌:

# 在這裏開啓執行,更新商品信息時都在一個事務內
update product set name = '商品名稱' where product_id = 12345;
update attribute set is_delete = 1 where ref_id = 12345 and ref_type = 'product'; 
insert attribute(ref_id,ref_type,product_id,value) values(12345,'product',12345,value1);

# 這裏模擬併發執行SQL順序(注意以下SQL全部沒有事務,每執行一個SQL執行完畢後都會自動提交)
update sku set update_date = now() where sku_id = 12345;
update attribute set is_delete = 1 where ref_id = 12345; 
update sku set update_date = now() where sku_id = 12346;
update attribute set is_delete = 1 where ref_id = 12346; 
insert attribute(ref_id,ref_type,product_id,value) values(12345,'sku',12345,value1);
insert attribute(ref_id,ref_type,product_id,value) values(12346,'sku',12346,value1);

看完這個SQL日誌後,相信聰明的你已經發現了問題,我這裏來獻獻醜吧:
更新商品信息時開啓了事務A,有更新SKU時使用的是並行流,一個SQL執行完事務就會自動提交,按照SQL執行順序依次開啓事務B、C、D、E、F、G;但事務A只有SKU信息全部更新完畢纔會提交事務,這是spring的事務機制導致的;
其中事務A與事務C更新的數據集合中是有交集的,A事務更新完屬性後並未提交事務,事務C更新時發現數據已經被A事務加鎖所以只能等待,C事務的等待時間超過MySQL的最大鎖等待超時時間後就會拋出異常,A事務回滾,B、D、E、G都會執行成功,C執行失敗,F在C後,在事務C拋出異常後就不在執行了;
把並行流換成for循環後,所有的操作都在一個事務,SQL執行都是有順序的,所以也不存在鎖等待的問題;至此,問題的根本原因就找到了。

總結

  1. 對於多線程使用應當小心謹慎,spring的事務是與線程綁定的,新開啓線程會導致事務丟失。
  2. 儘量減少事務的範圍,尤其涉及到更新操作時。innodb在更新某個數據時會先持有該行的鎖,其他事務更新該數據時只能等待,這也是MySQL高併發更新同一個數據時性能急劇下降的根本原因;去年雙十一商品詳情打開慢就是因爲個操作開啓事務後持續幾十秒才提交,流量上來後導致營銷服務出現問題,從而影響商品詳情頁。
  3. 在將並行流修改爲普通for循環後其實是有BUG的,相信聰明的你已經發現了(提示:請查看更新商品屬性與更新SKU屬性的SQL),這裏要總結的是,排查到了問題之後不要忘記迴歸測試,總有一些隱藏的坑是你意想不到的;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章