前言
大家好,我是Billy,前段時間去面試被問到了如何設計一個搶紅包系統,由於當時沒有做好準備回的不是很好,經過這些天的研究包括找資料,今天給大家分享下如何設計一個搶紅包系統,希望對大家有所幫助。主要展示搶紅包系統的設計,紅包演算法不是重點,所以沒有二倍均值法之類的實現。下文描述的的方案已經進行了實現,程式碼在 github,歡迎討論。
需求分析
常見的紅包系統,由使用者指定金額、紅包總數來完成紅包的建立,然後通過某個入口將紅包下發至目標使用者,使用者看到紅包後,點選紅包,隨機獲取紅包,最後,使用者可以檢視自己搶到的紅包。整個業務流程不復雜,難點在於搶紅包這個行為可能有很高的併發。所以,系統設計的優化點主要關注在搶紅包這個行為上。
由於檢視紅包過於簡單,所以本文不討論。那麼系統用例就只剩下發、搶
兩種。
- 發紅包:使用者設定紅包總金額、總數量
- 搶紅包:使用者從總紅包中隨機獲得一定金額
沒什麼好說的,相信大家的微信紅包沒少搶,一想都明白。看起來業務很簡單,卻其實還有點小麻煩。首先,搶紅包必須保證高可用,不然使用者會很憤怒。其次,必須保證系統資料一致性不能超發,不然搶到紅包的使用者收不到錢,使用者會很憤怒。最後一點,系統可能會有很高的併發。
OK,分析完直接進行詳細設計。所以簡簡單單隻有兩個介面:
- 發紅包
- 搶紅包
表結構設計
這裡直接給出建表語句:
紅包活動表:
CREATE TABLE `t_redpack_activity` (
`id` BIGINT ( 20 ) NOT NULL COMMENT '主鍵',
`total_amount` DECIMAL ( 10, 2 ) NOT NULL DEFAULT '0.00' COMMENT '總金額',
`surplus_amount` DECIMAL ( 10, 2 ) NOT NULL DEFAULT '0.00' COMMENT '剩餘金額',
`total` BIGINT ( 20 ) NOT NULL DEFAULT '0' COMMENT '紅包總數',
`surplus_total` BIGINT ( 20 ) NOT NULL DEFAULT '0' COMMENT '紅包剩餘總數',
`user_id` BIGINT ( 20 ) NOT NULL DEFAULT '0' COMMENT '使用者編號',
`version` BIGINT ( 20 ) NOT NULL DEFAULT '0' COMMENT '版本號',
PRIMARY KEY ( `id` )
) ENGINE = INNODB DEFAULT CHARSET = utf8;
紅包表:
CREATE TABLE `t_redpack` (
`id` BIGINT ( 20 ) NOT NULL COMMENT '主鍵',
`activity_id` BIGINT ( 20 ) NOT NULL DEFAULT 0 COMMENT '紅包活動ID',
`amount` DECIMAL ( 10, 2 ) NOT NULL DEFAULT '0.00' COMMENT '金額',
`status` TINYINT ( 4 ) NOT NULL DEFAULT 0 COMMENT '紅包狀態 1可用 2不可用',
`version` BIGINT ( 20 ) NOT NULL DEFAULT '0' COMMENT '版本號',
PRIMARY KEY ( `id` )
) ENGINE = INNODB DEFAULT CHARSET = utf8;
明細表:
CREATE TABLE `t_redpack_detail` (
`id` BIGINT ( 20 ) NOT NULL COMMENT '主鍵',
`amount` DECIMAL ( 10, 2 ) NOT NULL DEFAULT '0.00' COMMENT '金額',
`user_id` BIGINT ( 20 ) NOT NULL DEFAULT '0' COMMENT '使用者編號',
`redpack_id` BIGINT ( 20 ) NOT NULL DEFAULT '0' COMMENT '紅包編號',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
PRIMARY KEY ( `id` )
) ENGINE = INNODB DEFAULT CHARSET = utf8;
活動表,就是你發了多少個紅包,並且需要維護剩餘金額。明細表是使用者搶到的紅包明細。紅包表是每一個具體的紅包資訊。為什麼需要三個表呢?事實上如果沒有紅包表也是可以的。但我們的方案預先分配紅包
需要使用一張表來記錄紅包的資訊,所以設計的時候才有此表。
OK,分析完表結構其實方案已經七七八八差不多了。請接著看下面的方案,從簡單到複雜的過度。
基於分散式鎖的實現
基於分散式鎖的實現最為簡單粗暴,整個搶紅包介面以activityId
作為key
進行加鎖,保證同一批紅包搶行為都是序列執行。分散式鎖的實現是由spring-integration-redis
工程提供,核心類是RedisLockRegistry
。鎖通過Redis
的lua
指令碼實現,且實現了阻塞式本地可重入。
基於樂觀鎖的實現
第二種方式,為紅包活動表增加樂觀鎖版本控制,當多個執行緒同時更新同一活動表時,只有一個 clien 會成功。其它失敗的 client 進行迴圈重試,設定一個最大迴圈次數即可。此種方案可以實現併發情況下的處理,但是衝突很大。因為每次只有一個人會成功,其他 client 需要進行重試,即使重試也只能保證一次只有一個人成功,因此 TPS 很低。當設定的失敗重試次數小於發放的紅包數時,可能導致最後有人沒搶到紅包,實際上還有剩餘紅包。
基於悲觀鎖的實現
由於紅包活動表增加樂觀鎖衝突很大,所以可以考慮使用使用悲觀鎖:select * from t_redpack_activity where id = #{id} for update
,注意悲觀鎖必須在事務中才能使用。此時,所有的搶紅包行為變成了序列。此種情況下,悲觀鎖的效率遠大於樂觀鎖。
預先分配紅包,基於樂觀鎖的實現
可以看到,如果我們將樂觀鎖的維度加在紅包明細上,那麼衝突又會降低。因為之前紅包明細是使用者搶到後才建立的,那麼現在需要預先分配紅包,即建立紅包活動時即生成 N 個紅包,通過狀態來控制可用/不可用。這樣,當多個 client 搶紅包時,獲取該活動下所有可用的紅包明細,隨機返回其中一條然後再去更新,更新成功則代表使用者搶到了該紅包,失敗則代表出現了衝突,可以迴圈進行重試。如此,衝突便被降低了。
基於 Redis 佇列的實現
和上一個方案類似,不過,使用者發放紅包時會建立相應數量的紅包,並且加入到 Redis 佇列中。搶紅包時會將其彈出。Redis
佇列很好的契合了我們的需求,每次彈出都不會出現重複的元素,用完即銷燬。缺陷:搶紅包時一旦從佇列彈出,此時系統崩潰,恢復後此佇列中的紅包明細資訊已丟失,需要人工補償。
基於 Redis 佇列,非同步入庫
這種方案的是搶到紅包後不運算元據庫,而是儲存持久化資訊到Redis
中,然後返回成功。通過另外一個執行緒UserRedpackPersistConsumer
,拉取持久化資訊進行入庫。需要注意的是,此時的拉取動作如果使用普通的pop
仍然會出現crash point
的問題,所以考慮到可用性,此處使用Redis
的BRPOPLPUSH
操作,彈出元素後加入備份到另外一個佇列,保證此處崩潰後可以通過備份佇列自動恢復。崩潰恢復執行緒CrashRecoveryThread
通過定時拉取備份資訊,去 DB 中查證是否持久化成功,如果成功則清除此元素,否則進行補償並清除此元素。如果在運算元據庫的過程中出現異常會記錄錯誤日誌redpack.persist.log
,此日誌使用單獨的檔案和格式,方便進行補償(一般不會觸發)。
後語
當然,一個健壯的系統可能還要考慮到方方面面。發紅包本身如果是資料量特別大的情況要還需要做多副本方案。本文只是演示各種方案的優缺點,僅供參考。另外,如果採用Redis
則需要做高可用。
作者:pleuvoir
連結:juejin.cn/post/6925947709517987848
來源:掘金
本作品採用《CC 協議》,轉載必須註明作者和本文連結