看自某跳動(dòng)團(tuán)隊(duì)文章,文章我覺(jué)的很好戚长,這里記錄下盗冷。
需求背景
春節(jié)活動(dòng)中,多個(gè)業(yè)務(wù)方都有發(fā)放優(yōu)惠券的需求同廉,且對(duì)發(fā)券的 QPS 量級(jí)有明確的需求仪糖。所有的優(yōu)惠券發(fā)放柑司、核銷、查詢都需要一個(gè)新系統(tǒng)來(lái)承載乓诽。因此帜羊,我們需要設(shè)計(jì)、開(kāi)發(fā)一個(gè)能夠支持十萬(wàn)級(jí) QPS 的券系統(tǒng)鸠天,并且對(duì)優(yōu)惠券完整的生命周期進(jìn)行維護(hù)讼育。
需求拆解及技術(shù)選型
需求拆解
- 要配置券,會(huì)涉及到券批次(券模板)創(chuàng)建稠集,券模板的有效期以及券的庫(kù)存信息
-
要發(fā)券奶段,會(huì)涉及到券記錄的創(chuàng)建和管理(過(guò)期時(shí)間,狀態(tài))
因此剥纷,我們可以將需求先簡(jiǎn)單拆解為兩部分:
同時(shí)痹籍,無(wú)論是券模板還是券記錄,都需要開(kāi)放查詢接口晦鞋,支持券模板/券記錄的查詢蹲缠。
系統(tǒng)選型及中間件
確定了基本的需求,我們根據(jù)需求悠垛,進(jìn)一步分析可能會(huì)用到的中間件线定,以及系統(tǒng)整體的組織方式。
存儲(chǔ)
由于券模板确买、券記錄這些都是需要持久化的數(shù)據(jù)斤讥,同時(shí)還需要支持條件查詢,所以我們選用通用的結(jié)構(gòu)化存儲(chǔ)MySQL
作為存儲(chǔ)中間件湾趾。
緩存
由于發(fā)券時(shí)需要券模板信息芭商,大流量情況下,不可能每次都從MySQL
獲取券模板信息搀缠,因此考慮引入緩存
同理铛楣,券的庫(kù)存管理,或者叫庫(kù)存扣減艺普,也是一個(gè)高頻簸州、實(shí)時(shí)的操作,因此也考慮放入緩存中
主流的緩存Redis
可以滿足我們的需求衷敌,因此我們選用 Redis
作為緩存中間件勿侯。
消息隊(duì)列
由于券模板/券記錄都需要展示過(guò)期狀態(tài)拓瞪,并且根據(jù)不同的狀態(tài)進(jìn)行業(yè)務(wù)邏輯處理缴罗,因此有必要引入延遲消息隊(duì)列來(lái)對(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)開(kāi)發(fā)語(yǔ)言使用的是 golang反砌,因此我們使用 golang 服務(wù)的 RPC 框架 kitex 進(jìn)行代碼編寫(xiě)。
我們采用 kitex+MySQL+Redis+RocketMQ
來(lái)實(shí)現(xiàn)發(fā)券系統(tǒng)葱轩,RPC
服務(wù)部署在公司的 docker
容器中。
系統(tǒng)開(kāi)發(fā)與實(shí)踐
系統(tǒng)設(shè)計(jì)實(shí)現(xiàn)
系統(tǒng)整體架構(gòu)
從需求拆解部分我們對(duì)大致要開(kāi)發(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)初家、庫(kù)存扣減偎窘。
冪等操作用于保證發(fā)券請(qǐng)求不正確的情況下,業(yè)務(wù)方通過(guò)重試溜在、補(bǔ)償?shù)姆绞皆俅握?qǐng)求陌知,可以最終只發(fā)出一張券,防止資金損失炕泳。
券過(guò)期:
券過(guò)期是一個(gè)狀態(tài)推進(jìn)的過(guò)程纵诞,這里我們使用 RocketMQ
來(lái)實(shí)現(xiàn)。
- 由于 RocketMQ 支持的延時(shí)消息有最大限制培遵,而卡券的有效期不固定浙芙,有可能會(huì)超過(guò)限制,所以我們將卡券過(guò)期消息循環(huán)處理籽腕,直到卡券過(guò)期嗡呼。
大流量、高并發(fā)場(chǎng)景下的問(wèn)題及解決方案
實(shí)現(xiàn)了系統(tǒng)的基本功能后皇耗,我們來(lái)討論一下南窗,如果在大流量、高并發(fā)的場(chǎng)景下郎楼,系統(tǒng)可能會(huì)遇到的一些問(wèn)題及解決方案万伤。
存儲(chǔ)瓶頸及解決方案
瓶頸:
在系統(tǒng)架構(gòu)中,我們使用了MySQL
呜袁、Redis
作為存儲(chǔ)組件敌买。我們知道,單個(gè)服務(wù)器的I/O
能力終是有限的阶界,在實(shí)際測(cè)試過(guò)程中虹钮,能夠得到如下的數(shù)據(jù):
單個(gè)
MySQL
的每秒寫(xiě)入在4000 QPS
左右聋庵,超過(guò)這個(gè)數(shù)字,MySQL
的 I/O 時(shí)延會(huì)劇量增長(zhǎng)芙粱。MySQL
單表記錄到達(dá)了千萬(wàn)級(jí)別祭玉,查詢效率會(huì)大大降低,如果過(guò)億的話春畔,數(shù)據(jù)查詢會(huì)成為一個(gè)問(wèn)題脱货。Redis
單分片的寫(xiě)入瓶頸在2w
左右,讀瓶頸在10w
左右
解決方案:
1律姨、 讀寫(xiě)分離蹭劈。在查詢?nèi)0濉⒉樵內(nèi)涗浀葓?chǎng)景下线召,我們可以將MySQL
進(jìn)行讀寫(xiě)分離铺韧,讓這部分查詢流量走 MySQL
的讀庫(kù),從而減輕 MySQL
寫(xiě)庫(kù)的查詢壓力缓淹。
2哈打、分治。在軟件設(shè)計(jì)中讯壶,有一種分治的思想料仗,對(duì)于存儲(chǔ)瓶頸的問(wèn)題,業(yè)界常用的方案就是分而治之:流量分散伏蚊、存儲(chǔ)分散立轧,即:分庫(kù)分表。
發(fā)券躏吊,歸根結(jié)底是要對(duì)用戶的領(lǐng)券記錄做持久化存儲(chǔ)氛改。對(duì)于 MySQL 本身
I/O
瓶頸來(lái)說(shuō),我們可以在不同服務(wù)器上部署MySQL
的不同分片比伏,對(duì)MySQL
做水平擴(kuò)容胜卤,這樣一來(lái),寫(xiě)請(qǐng)求就會(huì)分布在不同的MySQL
主機(jī)上赁项,這樣就能夠大幅提升MySQL
整體的吞吐量葛躏。給用戶發(fā)了券,那么用戶肯定需要查詢自己獲得的券悠菜〗⒃埽基于這個(gè)邏輯,我們以
user_id
后四位為分片鍵悔醋,對(duì)用戶領(lǐng)取的記錄表做水平拆分摩窃,以支持用戶維度的領(lǐng)券記錄的查詢。每種券都有對(duì)應(yīng)的數(shù)量篙顺,在給用戶發(fā)券的過(guò)程中偶芍,我們是將發(fā)券數(shù)記錄在
Redis
中的,大流量的情況下德玫,我們也需要對(duì)Redis
做水平擴(kuò)容匪蟀,減輕Redis
單機(jī)的壓力。
容量預(yù)估:
基于上述思路宰僧,在要滿足發(fā)券 12w QPS
的需求下材彪,我們預(yù)估一下存儲(chǔ)資源。
a. MySQL
資源
在實(shí)際測(cè)試中琴儿,單次發(fā)券對(duì) MySQL
有一次非事務(wù)性寫(xiě)入段化,MySQL
的單機(jī)的寫(xiě)入瓶頸為 4000
,據(jù)此可以計(jì)算我們需要的 MySQL
主庫(kù)資源為:
120000/4000 = 30
b. Redis
資源
假設(shè) 12w
的發(fā)券QPS
造成,均為同一券模板显熏,單分片的寫(xiě)入瓶頸為 2w,則需要的最少 Redis 分片為:
120000/20000 = 6
熱點(diǎn)庫(kù)存問(wèn)題及解決方案
問(wèn)題
大流量發(fā)券場(chǎng)景下晒屎,如果我們使用的券模板為一個(gè)喘蟆,那么每次扣減庫(kù)存時(shí)
,訪問(wèn)到的 Redis
必然是特定的一個(gè)分片鼓鲁,因此蕴轨,一定會(huì)達(dá)到這個(gè)分片的寫(xiě)入瓶頸
,更嚴(yán)重的骇吭,可能會(huì)導(dǎo)致整個(gè) Redis 集群不可用橙弱。
解決方案
熱點(diǎn)庫(kù)存的問(wèn)題,業(yè)界有通用的方案:即燥狰,扣減的庫(kù)存 key 不要集中在某一個(gè)分片上棘脐。如何保證這一個(gè)券模板的 key
不集中在某一個(gè)分片上呢,我們拆key
(拆庫(kù)存)即可龙致。如圖:
在業(yè)務(wù)邏輯中荆残,我們?cè)诮ㄈ0宓臅r(shí)候,就將這種熱點(diǎn)券模板做庫(kù)存拆分净当,后續(xù)扣減庫(kù)存時(shí)内斯,也扣減相應(yīng)的子庫(kù)存即可。
建券
庫(kù)存扣減
這里還剩下一個(gè)問(wèn)題像啼,即:扣減子庫(kù)存俘闯,每次都是從
1
開(kāi)始進(jìn)行的話,那對(duì) Redis
對(duì)應(yīng)分片的壓力其實(shí)并沒(méi)有減輕忽冻,因此真朗,我們需要做到:每次請(qǐng)求,隨機(jī)不重復(fù)的輪詢子庫(kù)存僧诚。以下是本項(xiàng)目采取的一個(gè)具體思路:
Redis
子庫(kù)存的key
的最后一位是分片的編號(hào)遮婶,如:xxx_stock_key1
蝗碎、xxx_stock_key2
……,在扣減子庫(kù)存時(shí)旗扑,我們先生成對(duì)應(yīng)分片總數(shù)的隨機(jī)不重復(fù)數(shù)組蹦骑,如第一次是[1,2,3]
,第二次可能是[3,1,2]
臀防,這樣眠菇,每次扣減子庫(kù)存的請(qǐng)求,就會(huì)分布到不同的 Redis 分片
上袱衷,緩輕 Redis 單分片壓力
的同時(shí)捎废,也能支持更高 QPS
的扣減請(qǐng)求。
這種思路的一個(gè)問(wèn)題是致燥,當(dāng)我們庫(kù)存接近耗盡的情況下登疗,很多分片子庫(kù)存的輪詢將變得毫無(wú)意義,因此我們可以在每次請(qǐng)求的時(shí)候嫌蚤,將子庫(kù)存的剩余量記錄下來(lái)谜叹,當(dāng)某一個(gè)券模板的子庫(kù)存耗盡后,隨機(jī)不重復(fù)的輪詢操作直接跳過(guò)這個(gè)子庫(kù)存分片搬葬,這樣能夠優(yōu)化系統(tǒng)在庫(kù)存即將耗盡情況下的響應(yīng)速度荷腊。
業(yè)界針對(duì) Redis
熱點(diǎn) key
的處理,除了分 key
以外急凰,還有一種 key
備份的思路:即女仰,將相同的 key
,用某種策略備份到不同的 Redis 分片
上去抡锈,這樣就能將熱點(diǎn)打散疾忍。這種思路適用于那種讀多寫(xiě)少
的場(chǎng)景,不適合應(yīng)對(duì)發(fā)券這種大流量寫(xiě)的場(chǎng)景床三。在面對(duì)具體的業(yè)務(wù)場(chǎng)景時(shí)一罩,我們需要根據(jù)業(yè)務(wù)需求,選用恰當(dāng)?shù)姆桨?/code>來(lái)解決問(wèn)題撇簿。
券模板獲取失敗問(wèn)題及解決方案
問(wèn)題
高 QPS聂渊,高并發(fā)的場(chǎng)景下,即使我們能將接口的成功率提升 0.01%
四瘫,實(shí)際表現(xiàn)也是可觀的『核裕現(xiàn)在回過(guò)頭來(lái)看下整個(gè)發(fā)券的流程:查券模板(Redis
)-->校驗(yàn)-->冪等(MySQL
)--> 發(fā)券(MySQL
)。在查券模板信息時(shí)找蜜,我們會(huì)請(qǐng)求 Redis
饼暑,這是強(qiáng)依賴,在實(shí)際的觀測(cè)中,我們會(huì)發(fā)現(xiàn)弓叛,Redis
超時(shí)的概率大概在萬(wàn)分之 2彰居、3
。因此撰筷,這部分發(fā)券請(qǐng)求是必然失敗的陈惰。
解決方案
為了提高這部分請(qǐng)求的成功率,我們有兩種方案闭专。
一是從 Redis
獲取券模板失敗時(shí),內(nèi)部進(jìn)行重試旧烧;二是將券模板信息緩存到實(shí)例的本地內(nèi)存中影钉,即引入二級(jí)緩存。
內(nèi)部重試可以提高一部分請(qǐng)求的成功率掘剪,但無(wú)法從根本上解決 Redis
存在超時(shí)的問(wèn)題平委,同時(shí)重試的次數(shù)也和接口響應(yīng)的時(shí)長(zhǎng)成正比。二級(jí)緩存的引入夺谁,可以從根本上避免 Redis 超時(shí)造成的發(fā)券請(qǐng)求失敗廉赔。因此我們選用二級(jí)緩存方案:
當(dāng)然,引入了本地緩存匾鸥,我們還需要在每個(gè)服務(wù)實(shí)例中啟動(dòng)一個(gè)定時(shí)任務(wù)來(lái)將最新的券模板信息刷入到本地緩存和 Redis
中蜡塌,將模板信息刷入 Redis
中時(shí),要加分布式鎖勿负,防止多個(gè)實(shí)例同時(shí)寫(xiě) Redis
給 Redis
造成不必要的壓力馏艾。
服務(wù)治理
系統(tǒng)開(kāi)發(fā)完成后,還需要通過(guò)一系列操作保障系統(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í)間不超過(guò)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í)觀測(cè)Grafana
儀表盤吼渡,以保證能夠最快觀測(cè)到系統(tǒng)異常。同時(shí)乓序,對(duì)于一些異常情況寺酪,我們還有完善的報(bào)警機(jī)制,從而能夠第一時(shí)間感知到系統(tǒng)的異常替劈。限流寄雀。優(yōu)惠券系統(tǒng)是一個(gè)底層服務(wù),實(shí)際業(yè)務(wù)場(chǎng)景下會(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)壓測(cè)及實(shí)際表現(xiàn)
做完了上述一系列的工作后聘殖,是時(shí)候檢驗(yàn)我們服務(wù)在生產(chǎn)環(huán)境中的表現(xiàn)了命黔。當(dāng)然,新服務(wù)上線前就斤,首先需要對(duì)服務(wù)進(jìn)行壓測(cè)悍募。這里總結(jié)一下壓測(cè)可能需要注意的一些問(wèn)題及壓測(cè)結(jié)論。
注意事項(xiàng)
首先是壓測(cè)思路洋机,由于我們一開(kāi)始無(wú)法確定 docker 的瓶頸
坠宴、存儲(chǔ)組件的瓶頸
等。所以我們的壓測(cè)思路一般是:
找到
單實(shí)例瓶頸
找到
MySQL
一主的寫(xiě)瓶頸绷旗、讀瓶頸找到
Redis 單分片
寫(xiě)瓶頸喜鼓、讀瓶頸
得到了上述數(shù)據(jù)后,我們就可以粗略估算所需要的資源數(shù)衔肢,進(jìn)行服務(wù)整體的壓測(cè)了庄岖。
- 壓測(cè)資源也很重要,提前申請(qǐng)到足量的壓測(cè)資源角骤,才能合理制定壓測(cè)計(jì)劃隅忿。
- 壓測(cè)過(guò)程中心剥,要注意服務(wù)和資源的監(jiān)控,對(duì)不符合預(yù)期的部分要深入思考背桐,優(yōu)化代碼优烧。
- 適時(shí)記錄壓測(cè)數(shù)據(jù),才能更好的復(fù)盤链峭。
- 實(shí)際的使用資源畦娄,一般是壓測(cè)數(shù)據(jù)的
1.5 倍
,我們需要保證線上有部分資源冗余以應(yīng)對(duì)突發(fā)的流量增長(zhǎng)弊仪。
結(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),可以適配各種場(chǎng)景麦备,后續(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ì)列填物,來(lái)完成優(yōu)惠券的存儲(chǔ)纹腌、查詢、過(guò)期操作滞磺;
在系統(tǒng)開(kāi)發(fā)實(shí)現(xiàn)過(guò)程中升薯,對(duì)核心的發(fā)券、券過(guò)期實(shí)現(xiàn)流程進(jìn)行了闡述击困,并針對(duì)大流量涎劈、高并發(fā)場(chǎng)景下可能遇到的存儲(chǔ)瓶頸、熱點(diǎn)庫(kù)存、券模板緩存獲取超時(shí)的問(wèn)題提出了對(duì)應(yīng)的解決方案责语。其中炮障,我們使用了分治的思想
,對(duì)存儲(chǔ)中間件進(jìn)行水平擴(kuò)容
以解決存儲(chǔ)瓶頸坤候;采取庫(kù)存拆分子庫(kù)存
思路解決熱點(diǎn)庫(kù)存問(wèn)題胁赢;引入本地緩存
解決券模板從Redis
獲取超時(shí)的問(wèn)題。最終保證了優(yōu)惠券系統(tǒng)在大流量高并發(fā)
的情景下穩(wěn)定可用白筹;
除開(kāi)服務(wù)本身智末,我們還從服務(wù)超時(shí)設(shè)置、監(jiān)控報(bào)警徒河、限流系馆、資源隔離等方面對(duì)服務(wù)進(jìn)行了治理,保障服務(wù)的高可用顽照;
壓測(cè)是一個(gè)新服務(wù)不可避免的一個(gè)環(huán)節(jié)由蘑,通過(guò)壓測(cè)我們能夠?qū)Ψ?wù)的整體情況有個(gè)明確的了解,并且壓測(cè)期間暴露的問(wèn)題也會(huì)是線上可能遇到的代兵,通過(guò)壓測(cè)尼酿,我們能夠?qū)π路?wù)的整體情況做到心里有數(shù),對(duì)服務(wù)上線正式投產(chǎn)就更有信心了植影。