因為BitMap,白白搭進去8臺伺服器...

語言: CN / TW / HK

最近,因為增加了一些風控措施,導致新人拼團訂單介面的 QPS、TPS 下降了約 5%~10%,這還了得!

首先,快速解釋一下【新人拼團】活動:

業務簡介:顧名思義,新人拼團是由新使用者發起的拼團,如果拼團成功,系統會自動獎勵新使用者一張滿 15.1 元減 15 的平臺優惠券。

這相當於是無門檻優惠了。每個使用者僅有一次機會。新人拼團活動的最大目的主要是為了拉新。

新使用者判斷標準:是否有支付成功的訂單 ? 不是新使用者 : 是新使用者。

當前問題:由於像這種優惠力度較大的活動很容易被羊毛黨、黑產盯上。因此,我們完善了訂單風控系統,讓黑產無處遁形!

然而由於需要同步呼叫風控系統,導致整個下單介面的的 QPS、TPS 的指標皆有下降,從效能的角度來看,【新人拼團下單介面】無法滿足效能指標要求。因此 CTO 指名點姓讓我帶頭衝鋒……衝啊!

問題分析

風控系統的判斷一般分為兩種:線上同步分析和離線非同步分析。在實際業務中,這兩者都是必要的。

線上同步分析可以在下單入口處就攔截掉風險,而離線非同步分析可以提供更加全面的風險判斷基礎資料和風險監控能力。

最近我們對線上同步這塊的風控規則進行了加強和優化,導致整個新人拼團下單介面的執行鏈路更長,從而導致 TPS 和 QPS 這兩個關鍵指標下降。

解決思路

要提升效能,最簡單粗暴的方法是加伺服器!然而,無腦加伺服器無法展示出一個出色的程式設計師的能力。CTO 說了,要加伺服器可以,買伺服器的錢從我工資裡面扣……

在測試環境中,我們簡單的通過使用 StopWatch 來簡單分析,虛擬碼如下:

@Transactional(rollbackFor = Exception.class)
public CollageOrderResponseVO colleageOrder(CollageOrderRequestVO request) {
    StopWatch stopWatch = new StopWatch();

    stopWatch.start("呼叫風控系統介面");
    // 呼叫風控系統介面, http呼叫方式
    stopWatch.stop();

    stopWatch.start("獲取拼團活動資訊"); // 
    // 獲取拼團活動基本資訊. 查詢快取
    stopWatch.stop();

    stopWatch.start("獲取使用者基本資訊");
    // 獲取使用者基本資訊。http呼叫使用者服務
    stopWatch.stop();

    stopWatch.start("判斷是否是新使用者");
    // 判斷是否是新使用者。 查詢訂單資料庫
    stopWatch.stop();

    stopWatch.start("生成訂單併入庫");
    // 生成訂單併入庫
    stopWatch.stop();

    // 列印task報告
    stopWatch.prettyPrint();

   // 釋出訂單建立成功事件並構建響應資料
    return new CollageOrderResponseVO();
}

執行結果如下:

StopWatch '新人拼團訂單StopWatch': running time = 1195896800 ns
---------------------------------------------
ns         %     Task name
---------------------------------------------
014385000  021%  呼叫風控系統介面
010481800  010%  獲取拼團活動資訊
013989200  015%  獲取使用者基本資訊
028314600  030%  判斷是否是新使用者
028726200  024%  生成訂單併入庫

在測試環境整個介面的執行時間在 1.2s 左右。其中最耗時的步驟是【判斷是否是新使用者】邏輯。

這是我們重點優化的地方(實際上,也只能針對這點進行優化,因為其他步驟邏輯基本上無優化空間了)。

確定方案

在這個介面中,【判斷是否是新使用者】的標準是是使用者是否有支付成功的訂單。因此開發人員想當然的根據使用者 ID 去訂單資料庫中查詢。

我們的訂單主庫的配置如下: 在這裡插入圖片描述 這配置還算豪華吧。然而隨著業務的積累,訂單主庫的資料早就突破了千萬級別了,雖然會定時遷移資料,然而訂單量突破千萬大關的週期越來越短……(分庫分表方案是時候提上議程了,此次場景暫不討論分庫分表的內容)而使用者 ID 雖然是索引,但畢竟不是唯一索引。因此查詢效率相比於其他邏輯要更耗時。

通過簡單分析可以知道,其實只需要知道這個使用者是否有支付成功的訂單,至於支付成功了幾單我們並不關心。

因此此場景顯然適合使用 Redis 的 BitMap 資料結構來解決。在支付成功方法的邏輯中,我們簡單加一行程式碼來設定 BitMap:

// 說明:key表示使用者是否存在支付成功的訂單標記
// userId是long型別
String key = "order:f:paysucc"; 
redisTemplate.opsForValue().setBit(key, userId, true);

通過這一番改造,在下單時【判斷是否是新使用者】的核心程式碼就不需要查庫了,而是改為:

Boolean paySuccFlag = redisTemplate.opsForValue().getBit(key, userId);
if (paySuccFlag != null && paySuccFlag) {
    // 不是新使用者,業務異常
}

修改之後,在測試環境的測試結果如下:

StopWatch '新人拼團訂單StopWatch': running time = 82207200 ns
---------------------------------------------
ns         %     Task name
---------------------------------------------
014113100  017%  呼叫風控系統介面
010193800  012%  獲取拼團活動資訊
013965900  017%  獲取使用者基本資訊
014532800  018%  判斷是否是新使用者
029401600  036%  生成訂單併入庫

測試環境下單時間變成了 0.82s,主要效能損耗在生成訂單入庫步驟,這裡涉及到事務和資料庫插入資料,因此是合理的。介面響應時長縮短了 31%!相比生產環境的效能效果更明顯……接著舞!

晴天霹靂

這次的優化效果十分明顯,想著 CTO 該給我加點績效了吧,不然我工資要被扣完了呀~

一邊這樣想著,一邊準備生產環境灰度釋出。發完版之後,準備來個葛優躺好好休息一下,等著測試妹子驗證完就下班走人。

然而在我躺下不到 1 分鐘的時間,測試妹子過來緊張的跟我說:“介面報錯了,你快看看!”What?

當我開啟日誌一看,立馬傻眼了。報錯日誌如下:

io.lettuce.core.RedisCommandExecutionException: ERR bit offset is not an integer or out of range
at io.lettuce.core.ExceptionFactory.createExecutionException(ExceptionFactory.java:135) ~[lettuce-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
at io.lettuce.core.ExceptionFactory.createExecutionException(ExceptionFactory.java:108) ~[lettuce-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
at io.lettuce.core.protocol.AsyncCommand.completeResult(AsyncCommand.java:120) ~[lettuce-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
at io.lettuce.core.protocol.AsyncCommand.complete(AsyncCommand.java:111) ~[lettuce-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
at io.lettuce.core.protocol.CommandHandler.complete(CommandHandler.java:654) ~[lettuce-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
at io.lettuce.core.protocol.CommandHandler.decode(CommandHandler.java:614) ~[lettuce-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
…………

bit offset is not an integer or out of range。這個錯誤提示已經很明顯:我們的 offset 引數 out of range。

為什麼會這樣呢?我不禁開始思索起來:Redis BitMap 的底層資料結構實際上是 String 型別,Redis 對於 String 型別有最大值限制不得超過 512M,即 2^32 次方 byte…………我靠!!!

恍然大悟

由於測試環境歷史原因,userId 的長度都是 8 位的,最大值 99999999,假設 offset 就取這個最大值。

那麼在 Bitmap 中,bitarray=999999999=2^29byte。因此 setbit 沒有報錯。

而生產環境的 userId,經過排查發現使用者中心生成 ID 的規則變了,導致以前很老的使用者的 id 長度是 8 位的,新註冊的使用者 id 都是 18 位的。

以測試妹子的賬號 id 為例:652024209997893632=2^59byte,這顯然超出了 Redis 的最大值要求。不報錯才怪!

緊急回退版本,灰度釋出失敗~還好,CTO 念我不知道以前的這些業務規則,放了我一馬~該死,還想著加績效,沒有扣績效就是萬幸的了!

本次事件暴露出幾個非常值得注意的問題,值得反思:

①懂技術體系,還要懂業務體系

對於 BitMap 的使用,我們是非常熟悉的,對於多數高階開發人員而言,他們的技術水平也不差,但是因為不同業務體系的變遷而無法評估出精準的影響範圍,導致無形的安全隱患。

本次事件就是因為沒有了解到使用者中心的 ID 規則變化以及為什麼要變化從而導致問題發生。

②預生產環境的必要性和重要性

導致本次問題的另一個原因,就是因為沒有預生產環境,導致無法真正模擬生產環境的真實場景,如果能有預生產環境,那麼至少可以擁有生產環境的基礎資料:使用者資料、活動資料等。

很大程度上能夠提前暴露問題並解決。從而提升正式環境發版的效率和質量。

③敬畏心

要知道,對於一個大型的專案而言,任何一行程式碼其背後都有其存在的價值:正所謂存在即合理。

別人不會無緣無故這樣寫。如果你覺得不合理,那麼需要通過充分的調研和了解,確定每一個引數背後的意義和設計變更等。以儘可能降低犯錯的機率。

後記

通過此次事件,本來想著優化能夠提升介面效率,從而不需要加伺服器。這下好了,不僅生產環境要加 1 臺伺服器以臨時解決效能指標不達標的問題,還要另外加 7 臺伺服器用於預生產環境的搭建!

因為 BitMap,搭進去了 8 臺伺服器。痛並值得。接著奏樂,接著舞~~~

來源:r6a.cn/dNTk