如何設計一個搶紅包系統

CleverBilly發表於2021-08-25

前言

大家好,我是Billy,前段時間去面試被問到了如何設計一個搶紅包系統,由於當時沒有做好準備回的不是很好,經過這些天的研究包括找資料,今天給大家分享下如何設計一個搶紅包系統,希望對大家有所幫助。主要展示搶紅包系統的設計,紅包演算法不是重點,所以沒有二倍均值法之類的實現。下文描述的的方案已經進行了實現,程式碼在 github,歡迎討論。

需求分析

常見的紅包系統,由使用者指定金額、紅包總數來完成紅包的建立,然後通過某個入口將紅包下發至目標使用者,使用者看到紅包後,點選紅包,隨機獲取紅包,最後,使用者可以檢視自己搶到的紅包。整個業務流程不復雜,難點在於搶紅包這個行為可能有很高的併發。所以,系統設計的優化點主要關注在搶紅包這個行為上。

由於檢視紅包過於簡單,所以本文不討論。那麼系統用例就只剩下發、搶兩種。

  1. 發紅包:使用者設定紅包總金額、總數量
  2. 搶紅包:使用者從總紅包中隨機獲得一定金額

沒什麼好說的,相信大家的微信紅包沒少搶,一想都明白。看起來業務很簡單,卻其實還有點小麻煩。首先,搶紅包必須保證高可用,不然使用者會很憤怒。其次,必須保證系統資料一致性不能超發,不然搶到紅包的使用者收不到錢,使用者會很憤怒。最後一點,系統可能會有很高的併發。

OK,分析完直接進行詳細設計。所以簡簡單單隻有兩個介面:

  1. 發紅包
  2. 搶紅包

表結構設計

這裡直接給出建表語句:

紅包活動表:

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。鎖通過Redislua指令碼實現,且實現了阻塞式本地可重入。

基於樂觀鎖的實現

第二種方式,為紅包活動表增加樂觀鎖版本控制,當多個執行緒同時更新同一活動表時,只有一個 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的問題,所以考慮到可用性,此處使用RedisBRPOPLPUSH操作,彈出元素後加入備份到另外一個佇列,保證此處崩潰後可以通過備份佇列自動恢復。崩潰恢復執行緒CrashRecoveryThread通過定時拉取備份資訊,去 DB 中查證是否持久化成功,如果成功則清除此元素,否則進行補償並清除此元素。如果在運算元據庫的過程中出現異常會記錄錯誤日誌redpack.persist.log,此日誌使用單獨的檔案和格式,方便進行補償(一般不會觸發)。

後語

當然,一個健壯的系統可能還要考慮到方方面面。發紅包本身如果是資料量特別大的情況要還需要做多副本方案。本文只是演示各種方案的優缺點,僅供參考。另外,如果採用Redis則需要做高可用。

作者:pleuvoir
連結:juejin.cn/post/6925947709517987848
來源:掘金

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章