支持每秒上萬單的秒殺扣庫存事務
該架構能夠支持每秒超萬單精準扣庫存,并且在應用crash等情況下,也能保證創建訂單和扣減庫存的數據最終嚴格一致。
現有秒殺系統的問題
現有的秒殺架構,為了支持高并發,通常把庫存放在Redis中,收到訂單請求時,在Redis中進行庫存扣減。這種的設計,導致創建訂單和庫存扣減不是原子操作,如果兩個操作中間,遇到進程crash等問題,就會導致數據不一致。
即使庫存扣減不放在Redis中,而是放在數據庫,不一致問題也通常是存在的。業務系統為了模塊化,減少耦合,會將庫存服務與訂單服務分開。只要是分開的服務,那么數據不一致的情況就是無法避免的。
進程crash等問題,雖然發生的概率不高,但即使占比百分之一,甚至千分之一,都會產生數據不一致,例如扣減的庫存量和創建成功的訂單不一致。
庫存與訂單數據不一致是必須解決的難題,常見做法是,開發人員通過訂單數據,去校準庫存數據,這部分的工作非常繁瑣復雜,耗費大量的開發工作,而且很多時候需要人工介入,對數據進行人工校驗和修復。
下面我們來看看新架構如何優雅解決這個問題
整體架構
我們明確業務場景,我們把秒殺系統的核心要點提取出來,為以下幾點:
- 用戶進行秒殺,會在某個時間點發送大量的請求到后端,請求量會大大高于庫存數量
- 后端需要保證庫存扣減和訂單創建是最終嚴格一致的,即使中間發生進程crash,最終數據不會受到影響
本架構基于 https://github.com/dtm-labs/dtm,這是一個分布式事務框架,提供跨服務,跨庫的數據一致性解決方案。
上述的場景下,絕大部分扣減庫的描述請求,都會失敗,時序圖如下:
在這個架構中,使用了分布式事務框架dtm。上述的時序圖中,扣減庫存是在Redis中進行的,與dtm相關的注冊全局事務和取消全局事務也是在Redis中處理的,全程依賴Redis,與數據庫無關,因此能夠支持極高的并發,從后面的測試數據中可以看到,該架構可以輕易處理每秒上萬單的秒殺請求。
雖然大部分請求因為扣減庫存失敗而結束,但是會有一定數量的請求,扣減庫存成功,這種情況的時序圖如下:
在這個時序圖中,扣減庫存成功后,會進入到訂單服務,進行訂單相關的創建,以及后續的支付。在這個新架構中,訂單服務僅需要處理有效訂單,此時并發量已經大幅下降,只需要通過常規的方法,例如訂單分庫分表、消息隊列削峰處理,就可以輕松解決問題了。
原子操作
在上述的架構中,如果在Redis中扣減庫存后,在提交全局事務前,發生進程crash,就會導致兩個操作沒有同時完成,那么這種情況后續會怎么樣?新架構如何保證數據最終嚴格一致?這種情況的整個的時序圖如下:
一旦發生這類進程crash,導致兩個操作過程中斷,那么dtm服務器會輪詢超時未完成的事務,如果出現已Prepare、未Submit的全局任務,那么他會調用反查接口,詢問應用,庫存扣減是否成功扣減。如果已扣減,則將全局事務提交,并進行后續的調用;如果未扣減,則將全局事務標記為失敗,不再處理。
保證原子操作的原理,以及發生各種情況dtm的處理策略,可以參考二階段消息,這里不做詳細的描述。
核心代碼#
秒殺接口的核心代碼如下:
gid := "{a}flash-sale-" + activityID + "-" + uid
msg := dtmcli.NewMsg(DtmServer, gid).
Add(busi.Busi+"/createOrder", gin.H{activity_id: activityID, UID: uid})err := msg.DoAndSubmit(busi.Busi+"/QueryPreparedRedis", func(bb *BranchBarrier) error {
return bb.RedisCheckAdjustAmount(rds, "{a}stock-"+stockID, -1, 86400)})
}
- 行1: 一般的秒殺活動,一個用戶僅能購買一次,因此按照活動id+用戶id作為全局事務ID,能夠保證用戶最多生成一個全局事務,最多創建一個訂單
- 行2: 創建一個二階段消息的對象,填入dtm服務器地址,以及全局事務id
- 行3: 給二階段消息添加一個分支事務,該事務分支為創建訂單服務
- 行4: 調用二階段消息的DoAndSubmit,該函數第一個參數為反查的URL(見上圖中的反查);第二個參數為一個回調函數,里面會包含業務邏輯。該函數會執行業務,并在成功后提交全局事務,保證執行業務和全局事務的提交是“原子的”
- 行5: 調用RedisCheckAdjustAmount,該函數會進行庫存扣減,這個函數進行庫存扣減時,如果庫存不夠,則會返回錯誤;如果庫存足夠,則會扣減庫存,并記錄庫存已扣減成功,這樣可以保證這個操作冪等,并且保證后續的反查能夠獲得正確的結果
反查的核心代碼如下:
app.GET(BusiAPI+"/QueryPreparedRedis", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
return MustBarrierFromGin(c).RedisQueryPrepared(rds)
}))
開發人員編寫反查的邏輯很簡單,對于Redis里面的數據,只需要復制粘貼這上面的代碼就行。反查的詳細原理參考二階段消息,二階段消息的文檔里介紹的是數據庫中如何做,而這里則是用Redis來完成類似的反查邏輯,就不詳細說明了。
性能#
從上面的介紹中,可以看到,對于大部分扣減庫存失敗的請求,只需要進行三個Redis操作,1. 注冊全局事務;2. 扣減庫存;3. 修改全局事務為以失敗。這個三個操作都是lua腳本實現。一個普通的redis,每秒大約能夠支持6w個lua腳本操作,照此分析,我們的新架構,理論上每秒能夠支持2w個秒殺請求。我做的性能測試報告顯示,當dtm與扣庫存共享一個redis時,每秒可以輕松完成1.2w個秒殺訂單,達到理論極限值的60%,詳情可以參考后面的性能測試報告
更進一步分析,扣減庫存與全局事務可以使用不同的Redis,那么
- 扣減庫存:若由單獨一個Redis來支持,那么扣庫存的理論上限值為6w/s,預估的實際值為6*0.6=3.6w/s,如果更進一步,采用 Redis6 的多線程IO,可以獲得更高的性能,大約達到6 * 2.5 * 0.6=9w/s。
- 全局事務操作:而這里面的dtm只需要部署多組,或者未來使用集群版,就可以提供遠超9w/s的支持。
- 所以采用新架構的情況下,預計可以達到9w/s的秒殺請求流量
上述的分析還僅僅限于普通云廠商虛擬機上的自己安裝Redis,假如通過簡單的硬件升級,或者使用云廠商提供的Redis,那么Redis能提供更強勁的性能,上述的9w/s還能夠再提高一個臺階。
參考一下阿里雙十一的峰值訂單:58.3萬筆/秒,那么上述預估的9w/s,幾乎足以應對所有的秒殺活動
代碼示例#
完整的可運行的代碼示例,可以參考https://github.com/dtm-labs/dtm-cases/flash
秒殺性能測試詳情
測試的環境,兩臺阿里云主機,類型為:ecs.hfc5.3xlarge 12核 CPU 3.1GHz/3.4GHz PPS 130萬
- 一臺機器運行Redis
- 另一臺機器運行測試程序
測試過程:
準備Redis#
選擇虛擬機 A 安裝 Redis
apt-get install -y redis# 修改 /etc/redis/redis.conf# bind 127.0.0.1 => 0.0.0.0systemctl redis restart
準備dtm
選擇虛擬機 B 安裝 dtm
apt update
apt install -y git
wget https://golang.org/dl/go1.17.1.linux-amd64.tar.gz
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.17.1.linux-amd64.tar.gz && cp -f /usr/local/go/bin/go /usr/local/bin/go
git clone https://github.com/dtm-labs/dtm.git && cd dtm && git checkout v1.11.0 && cd bench && make
# 修改 dtm/bench/test-flash-sales.sh
# export BUSI_REDIS=localhost:6379 => 虛擬機A 的私網ip
運行測試
sh test-flash-sales.sh
獲取結果
我的結果顯示,每秒大約能夠完成1.2w個秒殺請求:
Requests per second: 11970.21 [#/sec] (mean)
小結
我們提出了一個全新的秒殺架構,可以保證創建訂單和扣減庫存的原子性,并且預估可以支撐9w/s的秒殺請求流量。幫助大家更好更快的解決秒殺的業務需求。
歡迎訪問我們的項目,并star支持我們:
https://github.com/dtm-labs/dtm