因爲 BitMap,白白搭進去 8 臺服務器---

最近,因爲增加了一些風控措施,導致新人拼團訂單接口的 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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5BLfiGe5U6PxbvIPexGWOA