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