作者 | 吳海濤
需求背景
春節(jié)活動中,多個業(yè)務方都有發(fā)放優(yōu)惠券的需求,且對發(fā)券的 QPS 量級有明確的需求。所有的優(yōu)惠券發(fā)放、核銷、查詢都需要一個新系統(tǒng)來承載。因此,我們需要設計、開發(fā)一個能夠支持十萬級 QPS 的券系統(tǒng),并且對優(yōu)惠券完整的生命周期進行維護。
需求拆解及技術選型
需求拆解
- 要配置券,會涉及到券批次(券模板)創(chuàng)建,券模板的有效期以及券的庫存信息
- 要發(fā)券,會涉及到券記錄的創(chuàng)建和管理(過期時間,狀態(tài))
因此,我們可以將需求先簡單拆解為兩部分:
同時,無論是券模板還是券記錄,都需要開放查詢接口,支持券模板/券記錄的查詢。
系統(tǒng)選型及中間件
確定了基本的需求,我們根據(jù)需求,進一步分析可能會用到的中間件,以及系統(tǒng)整體的組織方式。
存儲
由于券模板、券記錄這些都是需要持久化的數(shù)據(jù),同時還需要支持條件查詢,所以我們選用通用的結(jié)構(gòu)化存儲 MySQL 作為存儲中間件。
緩存
- 由于發(fā)券時需要券模板信息,大流量情況下,不可能每次都從 MySQL 獲取券模板信息,因此考慮引入緩存
- 同理,券的庫存管理,或者叫庫存扣減,也是一個高頻、實時的操作,因此也考慮放入緩存中
主流的緩存 Redis 可以滿足我們的需求,因此我們選用 Redis 作為緩存中間件。
消息隊列
由于券模板/券記錄都需要展示過期狀態(tài),并且根據(jù)不同的狀態(tài)進行業(yè)務邏輯處理,因此有必要引入延遲消息隊列來對券模板/券狀態(tài)進行處理。RocketMQ 支持延時消息,因此我們選用 RocketMQ 作為消息隊列。
系統(tǒng)框架
發(fā)券系統(tǒng)作為下游服務,是需要被上游服務所調(diào)用的。公司內(nèi)部服務之間,采用的都是 RPC 服務調(diào)用,系統(tǒng)開發(fā)語言使用的是 golang,因此我們使用 golang 服務的 RPC 框架 kitex 進行代碼編寫。
我們采用 kitex+MySQL+Redis+RocketMQ 來實現(xiàn)發(fā)券系統(tǒng),RPC 服務部署在公司的 docker 容器中。
系統(tǒng)開發(fā)與實踐
系統(tǒng)設計實現(xiàn)
系統(tǒng)整體架構(gòu)
從需求拆解部分我們對大致要開發(fā)的系統(tǒng)有了一個了解,下面給出整體的一個系統(tǒng)架構(gòu),包含了一些具體的功能。
數(shù)據(jù)結(jié)構(gòu) ER 圖
與系統(tǒng)架構(gòu)對應的,我們需要建立對應的 MySQL 數(shù)據(jù)存儲表。
核心邏輯實現(xiàn)
發(fā)券:
發(fā)券流程分為三部分:參數(shù)校驗、冪等校驗、庫存扣減。
冪等操作用于保證發(fā)券請求不正確的情況下,業(yè)務方通過重試、補償?shù)姆绞皆俅握埱螅梢宰罱K只發(fā)出一張券,防止資金損失。
券過期:
券過期是一個狀態(tài)推進的過程,這里我們使用 RocketMQ 來實現(xiàn)。
由于 RocketMQ 支持的延時消息有最大限制,而卡券的有效期不固定,有可能會超過限制,所以我們將卡券過期消息循環(huán)處理,直到卡券過期。
大流量、高并發(fā)場景下的問題及解決方案
實現(xiàn)了系統(tǒng)的基本功能后,我們來討論一下,如果在大流量、高并發(fā)的場景下,系統(tǒng)可能會遇到的一些問題及解決方案。
存儲瓶頸及解決方案
瓶頸:
在系統(tǒng)架構(gòu)中,我們使用了 MySQL、Redis 作為存儲組件。我們知道,單個服務器的 I/O 能力終是有限的,在實際測試過程中,能夠得到如下的數(shù)據(jù):
- 單個 MySQL 的每秒寫入在 4000 QPS 左右,超過這個數(shù)字,MySQL 的 I/O 時延會劇量增長。
- MySQL 單表記錄到達了千萬級別,查詢效率會大大降低,如果過億的話,數(shù)據(jù)查詢會成為一個問題。
- Redis 單分片的寫入瓶頸在 2w 左右,讀瓶頸在 10w 左右
解決方案:
讀寫分離。在查詢?nèi)0濉⒉樵內(nèi)涗浀葓鼍跋拢覀兛梢詫?MySQL 進行讀寫分離,讓這部分查詢流量走 MySQL 的讀庫,從而減輕 MySQL 寫庫的查詢壓力。
分治。在軟件設計中,有一種分治的思想,對于存儲瓶頸的問題,業(yè)界常用的方案就是分而治之:流量分散、存儲分散,即:分庫分表。
發(fā)券,歸根結(jié)底是要對用戶的領券記錄做持久化存儲。對于 MySQL 本身 I/O 瓶頸來說,我們可以在不同服務器上部署 MySQL 的不同分片,對 MySQL 做水平擴容,這樣一來,寫請求就會分布在不同的 MySQL 主機上,這樣就能夠大幅提升 MySQL 整體的吞吐量。
給用戶發(fā)了券,那么用戶肯定需要查詢自己獲得的券。基于這個邏輯,我們以 user_id 后四位為分片鍵,對用戶領取的記錄表做水平拆分,以支持用戶維度的領券記錄的查詢。
每種券都有對應的數(shù)量,在給用戶發(fā)券的過程中,我們是將發(fā)券數(shù)記錄在 Redis 中的,大流量的情況下,我們也需要對 Redis 做水平擴容,減輕 Redis 單機的壓力。
容量預估:
基于上述思路,在要滿足發(fā)券 12w QPS 的需求下,我們預估一下存儲資源。
a. MySQL 資源
在實際測試中,單次發(fā)券對 MySQL 有一次非事務性寫入,MySQL 的單機的寫入瓶頸為 4000,據(jù)此可以計算我們需要的 MySQL 主庫資源為:
120000/4000 = 30
b. Redis 資源
假設 12w 的發(fā)券 QPS,均為同一券模板,單分片的寫入瓶頸為 2w,則需要的最少 Redis 分片為:
120000/20000 = 6
熱點庫存問題及解決方案
問題
大流量發(fā)券場景下,如果我們使用的券模板為一個,那么每次扣減庫存時,訪問到的 Redis 必然是特定的一個分片,因此,一定會達到這個分片的寫入瓶頸,更嚴重的,可能會導致整個 Redis 集群不可用。
解決方案
熱點庫存的問題,業(yè)界有通用的方案:即,扣減的庫存 key 不要集中在某一個分片上。如何保證這一個券模板的 key 不集中在某一個分片上呢,我們拆 key(拆庫存)即可。如圖:
在業(yè)務邏輯中,我們在建券模板的時候,就將這種熱點券模板做庫存拆分,后續(xù)扣減庫存時,也扣減相應的子庫存即可。
建券
庫存扣減
這里還剩下一個問題,即:扣減子庫存,每次都是從 1 開始進行的話,那對 Redis 對應分片的壓力其實并沒有減輕,因此,我們需要做到:每次請求,隨機不重復的輪詢子庫存。以下是本項目采取的一個具體思路:
Redis 子庫存的 key 的最后一位是分片的編號,如:xxx_stock_key1、xxx_stock_key2……,在扣減子庫存時,我們先生成對應分片總數(shù)的隨機不重復數(shù)組,如第一次是[1,2,3],第二次可能是[3,1,2],這樣,每次扣減子庫存的請求,就會分布到不同的 Redis 分片上,緩輕 Redis 單分片壓力的同時,也能支持更高 QPS 的扣減請求。
這種思路的一個問題是,當我們庫存接近耗盡的情況下,很多分片子庫存的輪詢將變得毫無意義,因此我們可以在每次請求的時候,將子庫存的剩余量記錄下來,當某一個券模板的子庫存耗盡后,隨機不重復的輪詢操作直接跳過這個子庫存分片,這樣能夠優(yōu)化系統(tǒng)在庫存即將耗盡情況下的響應速度。
業(yè)界針對 Redis 熱點 key 的處理,除了分 key 以外,還有一種 key 備份的思路:即,將相同的 key,用某種策略備份到不同的 Redis 分片上去,這樣就能將熱點打散。這種思路適用于那種讀多寫少的場景,不適合應對發(fā)券這種大流量寫的場景。在面對具體的業(yè)務場景時,我們需要根據(jù)業(yè)務需求,選用恰當?shù)姆桨竵斫鉀Q問題。
券模板獲取失敗問題及解決方案
問題
高 QPS,高并發(fā)的場景下,即使我們能將接口的成功率提升 0.01%,實際表現(xiàn)也是可觀的。現(xiàn)在回過頭來看下整個發(fā)券的流程:查券模板(Redis)-->校驗-->冪等(MySQL)--> 發(fā)券(MySQL)。在查券模板信息時,我們會請求 Redis,這是強依賴,在實際的觀測中,我們會發(fā)現(xiàn),Redis 超時的概率大概在萬分之 2、3。因此,這部分發(fā)券請求是必然失敗的。
解決方案
為了提高這部分請求的成功率,我們有兩種方案。
一是從 Redis 獲取券模板失敗時,內(nèi)部進行重試;二是將券模板信息緩存到實例的本地內(nèi)存中,即引入二級緩存。
內(nèi)部重試可以提高一部分請求的成功率,但無法從根本上解決 Redis 存在超時的問題,同時重試的次數(shù)也和接口響應的時長成正比。二級緩存的引入,可以從根本上避免 Redis 超時造成的發(fā)券請求失敗。因此我們選用二級緩存方案:
當然,引入了本地緩存,我們還需要在每個服務實例中啟動一個定時任務來將最新的券模板信息刷入到本地緩存和 Redis 中,將模板信息刷入 Redis 中時,要加分布式鎖,防止多個實例同時寫 Redis 給 Redis 造成不必要的壓力。
服務治理
系統(tǒng)開發(fā)完成后,還需要通過一系列操作保障系統(tǒng)的可靠運行。
超時設置。優(yōu)惠券系統(tǒng)是一個 RPC 服務,因此我們需要設置合理的 RPC 超時時間,保證系統(tǒng)不會因為上游系統(tǒng)的故障而被拖垮。例如發(fā)券的接口,我們內(nèi)部執(zhí)行時間不超過 100ms,因此接口超時我們可以設置為 500ms,如果有異常請求,在 500ms 后,就會被拒絕,從而保障我們服務穩(wěn)定的運行。
監(jiān)控與報警。對于一些核心接口的監(jiān)控、穩(wěn)定性、重要數(shù)據(jù),以及系統(tǒng) CPU、內(nèi)存等的監(jiān)控,我們會在 Grafana 上建立對應的可視化圖表,在春節(jié)活動期間,實時觀測 Grafana 儀表盤,以保證能夠最快觀測到系統(tǒng)異常。同時,對于一些異常情況,我們還有完善的報警機制,從而能夠第一時間感知到系統(tǒng)的異常。
限流。優(yōu)惠券系統(tǒng)是一個底層服務,實際業(yè)務場景下會被多個上游服務所調(diào)用,因此,合理的對這些上游服務進行限流,也是保證優(yōu)惠券系統(tǒng)本身穩(wěn)定性必不可少的一環(huán)。
資源隔離。因為我們服務都是部署在 docker 集群中的,因此為了保證服務的高可用,服務部署的集群資源盡量分布在不同的物理區(qū)域上,以避免由集群導致的服務不可用。
系統(tǒng)壓測及實際表現(xiàn)
做完了上述一系列的工作后,是時候檢驗我們服務在生產(chǎn)環(huán)境中的表現(xiàn)了。當然,新服務上線前,首先需要對服務進行壓測。這里總結(jié)一下壓測可能需要注意的一些問題及壓測結(jié)論。
注意事項
1、首先是壓測思路,由于我們一開始無法確定 docker 的瓶頸、存儲組件的瓶頸等。所以我們的壓測思路一般是:
- 找到單實例瓶頸
- 找到 MySQL 一主的寫瓶頸、讀瓶頸
- 找到 Redis 單分片寫瓶頸、讀瓶頸
得到了上述數(shù)據(jù)后,我們就可以粗略估算所需要的資源數(shù),進行服務整體的壓測了。
2、壓測資源也很重要,提前申請到足量的壓測資源,才能合理制定壓測計劃。
3、壓測過程中,要注意服務和資源的監(jiān)控,對不符合預期的部分要深入思考,優(yōu)化代碼。
4、適時記錄壓測數(shù)據(jù),才能更好的復盤。
5、實際的使用資源,一般是壓測數(shù)據(jù)的 1.5 倍,我們需要保證線上有部分資源冗余以應對突發(fā)的流量增長。
結(jié)論
系統(tǒng)在 13w QPS 的發(fā)券請求下,請求成功率達到 99.9%以上,系統(tǒng)監(jiān)控正常。春節(jié)紅包雨期間,該優(yōu)惠券系統(tǒng)承載了兩次紅包雨的全部流量,期間未出現(xiàn)異常,圓滿完成了發(fā)放優(yōu)惠券的任務。
系統(tǒng)的業(yè)務思考
- 目前的系統(tǒng),只是單純支持了高并發(fā)的發(fā)券功能,對于券的業(yè)務探索并不足夠。后續(xù)需要結(jié)合業(yè)務,嘗試批量發(fā)券(券包)、批量核銷等功能
- 發(fā)券系統(tǒng)只是一個最底層的業(yè)務中臺,可以適配各種場景,后續(xù)可以探索支持更多業(yè)務。
總結(jié)
從零搭建一個大流量、高并發(fā)的優(yōu)惠券系統(tǒng),首先應該充分理解業(yè)務需求,然后對需求進行拆解,根據(jù)拆解后的需求,合理選用各種中間件;本文主要是要建設一套優(yōu)惠券系統(tǒng),因此會使用各類存儲組件和消息隊列,來完成優(yōu)惠券的存儲、查詢、過期操作;
在系統(tǒng)開發(fā)實現(xiàn)過程中,對核心的發(fā)券、券過期實現(xiàn)流程進行了闡述,并針對大流量、高并發(fā)場景下可能遇到的存儲瓶頸、熱點庫存、券模板緩存獲取超時的問題提出了對應的解決方案。其中,我們使用了分治的思想,對存儲中間件進行水平擴容以解決存儲瓶頸;采取庫存拆分子庫存思路解決熱點庫存問題;引入本地緩存解決券模板從 Redis 獲取超時的問題。最終保證了優(yōu)惠券系統(tǒng)在大流量高并發(fā)的情景下穩(wěn)定可用;
除開服務本身,我們還從服務超時設置、監(jiān)控報警、限流、資源隔離等方面對服務進行了治理,保障服務的高可用;
壓測是一個新服務不可避免的一個環(huán)節(jié),通過壓測我們能夠?qū)Ψ盏恼w情況有個明確的了解,并且壓測期間暴露的問題也會是線上可能遇到的,通過壓測,我們能夠?qū)π路盏恼w情況做到心里有數(shù),對服務上線正式投產(chǎn)就更有信心了。