如何設計一個優惠券系統

大家好, 我是 風哥

背景

部門爲一個租房房源平臺,爲各個商家提供房源發佈 & C 端曝光獲客的功能,現在要構建一個優惠券系統,用於各個節假日節點進行商家營銷活動。形式主要以商家在 B 端參與活動,對房源綁定優惠券,將租賃價格進行優惠,來在 C 端吸引用戶進行租房。

1. 業務梳理

在清楚了大致的業務背景後,下面來進行整體的業務流程梳理,大致如下圖所示。

首先,平臺建立好活動,在商家 B 端將可報名的活動展示出來,商家通過報名對應優惠力度的活動,來建立對應優惠的優惠券。

然後,通過將房源與對應的優惠券建立綁定關係,來對房源數據打上優惠券標識。這樣一來在 C 端展示房源時,就可以進行對應的優惠房源篩選,以及讓用戶在房源上進行領取某個類別的優惠券。

最後,用戶在 C 端領取優惠券後,可聯繫商家進行實地房源考察,如果雙方達成協議,即可在線上簽約。而在簽約時即可使用對應優惠券,實現相應的價格優惠。

至此,就是整個系統的完整正向流程了。

2. 技術設計

下面來對每個環節的進行相應的技術設計。

2.1 建立活動

2.1.1 數據表

活動信息需要如下數據項

下面就是活動信息數據表的具體設計

CREATE TABLE `t_activity` (
  `activeId` bigint(20) NOT NULL COMMENT '活動ID',
  `title` varchar(256) NOT NULL COMMENT '活動名稱',
  `applyStartTime` timestamp NULL DEFAULT NULL COMMENT '報名開始時間',
  `applyEndTime` timestamp NULL DEFAULT NULL COMMENT '報名停止時間',
  `activityStartTime` timestamp NULL DEFAULT NULL COMMENT '活動開始時間',
  `activityEndTime` timestamp NULL DEFAULT NULL COMMENT '活動結束時間',
  `cityIds` varchar(256) NOT NULL COMMENT '覆蓋城市,多個逗號分隔',
  `couponType` tinyint(4) NOT NULL DEFAULT '0' COMMENT '優惠類型,1 直減;2 折扣;3免費住N天;4免押金;5特價房',
  `lowerLimit` int NOT NULL DEFAULT 0 COMMENT '優惠數值下限',
  `upperLimit` int NOT NULL DEFAULT 0 COMMENT '優惠數值上限',
  `description` text COMMENT '活動描述',
  `cubeType` smallint(6) NOT NULL DEFAULT '1001' COMMENT '活動類型',
  `foreignId` bigint(20) NOT NULL DEFAULT '0' COMMENT '外部ID',
  `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '活動狀態',
  `createTime` timestamp NULL DEFAULT NULL COMMENT '創建時間',
  `updateTime` timestamp NULL DEFAULT NULL COMMENT '更新時間',
  `recordStatus` tinyint(4) NOT NULL DEFAULT '0' COMMENT '數據狀態',
  PRIMARY KEY (`activeId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='活動信息表';
2.1.2 數據讀取

在商家端可以通過 status 字段和 cityIds 字段來進行可報名活動的展示。

C 端讀取的展示代碼裏可以使用設計模式中的代理模式來加一層緩存,在進行中的活動會將數據推入到 cache 層中。

2.1.3 活動狀態流轉

通過 crontab 定時任務,每分鐘進行時間段的檢查,來更新對應的 status 字段,完成活動狀態的流轉。

2.1.4 數據讀取

可以使用設計模式中的代理模式來加一層緩存,非強實時的查詢走 cache,cache 中不存在或需實時數據的再走 db。

2.2 商家報名建券

2.2.1 數據表
CREATE TABLE `t_couponmeta` (
  `couponMetaId` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '券id',
  `appId` int(11) NOT NULL DEFAULT '1' COMMENT '區分建立來源',
  `activeId` bigint(20) NOT NULL DEFAULT '0' COMMENT '活動ID',
  `companyId` bigint(20) NOT NULL COMMENT '公司編號',
  `cityId` int(11) NOT NULL COMMENT '城市id',
  `companyName` varchar(255) DEFAULT NULL COMMENT '公司名稱',
  `companyShortName` varchar(255) DEFAULT NULL COMMENT '公司簡稱',
  `couponType` tinyint(4) NOT NULL COMMENT '優惠券類型',
  `title` varchar(256) NOT NULL COMMENT '優惠券名稱',
  `directDiscount` int(11) NOT NULL DEFAULT '0' COMMENT '直減券優惠力度',
  `discount` int(11) NOT NULL DEFAULT '0' COMMENT '折扣力度',
  `freeLive` int(11) NOT NULL DEFAULT '0' COMMENT '免費住n天券',
  `threshold` varchar(256) NOT NULL COMMENT '使用門檻',
  `deduction` tinyint(4) NOT NULL DEFAULT '1' COMMENT '抵扣說明 1首月抵扣,2 平攤到月',
  `totalAmount` int(11) NOT NULL DEFAULT '0' COMMENT '券總數',
  `applyAmount` int(11) NOT NULL DEFAULT '0' COMMENT '已領取總數',
  `activityStartTime` timestamp NULL DEFAULT NULL COMMENT '活動開始時間',
  `activityEndTime` timestamp NULL DEFAULT NULL COMMENT '活動結束時間',
  `startTime` timestamp NULL DEFAULT NULL COMMENT '券使用開始時間',
  `expireTime` timestamp NULL DEFAULT NULL COMMENT '券使用結束時間',
  `status` int(11) NOT NULL DEFAULT '10' COMMENT '10:新建未啓用,20:已啓用,30:過期, 40 已結束 50 已中止',
  `expireType` tinyint(4) NOT NULL DEFAULT '1' COMMENT '類型:1固定有效期類型,2浮動有效期類型',
  `validPeriod` tinyint(4) NOT NULL DEFAULT '0' COMMENT '浮動有效期(單位:天)',
  `tenantRange` tinyint(1) NOT NULL DEFAULT '1' COMMENT '租客範圍枚舉值',
  `customScope` varchar(256) NOT NULL DEFAULT '' COMMENT '自定義租客範圍',
  `comment` varchar(50) DEFAULT NULL COMMENT '備註',
  `cubeType` smallint(6) NOT NULL DEFAULT '1001' COMMENT '活動類型',
  `updateTime` timestamp NULL DEFAULT NULL COMMENT '更新時間',
  `createTime` timestamp NULL DEFAULT NULL COMMENT '創建時間',
  `recordStatus` tinyint(4) DEFAULT '0' COMMENT '狀態 0默認 -1刪除',
  PRIMARY KEY (`couponMetaId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='優惠券表';
2.2.2 數據讀取

同樣可以使用設計模式中的代理模式來加一層緩存,對於 C 端大量讀取優惠券信息的場景,需要讓讀請求儘量都在 cache 層處理完成,降低 db 的壓力。

2.2.3 狀態流轉

優惠券的狀態流轉如圖所示

大部分狀態取決於活動的狀態(除中止和過期),所以在活動狀態的 crontab 任務中,當發現活動狀態發生變更後,會將下面的優惠券狀態變更任務以 MQ 發送出來,異步來修改優惠券的狀態。 而優惠券狀態的變更又會涉及聯動數據的更新,舉例來說:

可以想到,在每個狀態流轉的時候,都有很多操作來做。爲了保證業務邏輯清晰,狀態流轉我採用了狀態模式來進行實現,類圖如下所示:

而狀態更新後,C 端房源索引數據 / b 端基礎房源數據 / 緩存中的數據,這些數據的更新使用了觀察者模式監聽狀態的變更,在觀察者中通過發送 mq 異步來進行數據更新,類圖如下所示:

通過將需要的觀察者註冊到 CouponStateMachine 中,在進行實際的 doChangeStatus 操作後,notify 所有的觀察者即可保證聯動數據的正確性。

2.3 綁定優惠券

2.3.1 數據表

將商家 b 端綁定了的優惠券全量(新建 / 啓用中 / 未過期)數據存於 MySQL 數據表中,表結構比較簡單,如下所示:

CREATE TABLE `t_bindcoupon` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `couponMetaId` int(11) NOT NULL COMMENT '券id',
  `companyId` bigint(20) NOT NULL COMMENT '公司編號',
  `activityStatus` tinyint(4) NOT NULL COMMENT '狀態 0 準備中 1 活動中 2 活動結束 券未失效 3活動結束券失效',
  `houseId` bigint(20) NOT NULL DEFAULT '0' COMMENT '房源id',
  `recordStatus` tinyint(4) NOT NULL COMMENT '數據狀態 0 有效,-1 失效',
  `createTime` timestamp NULL DEFAULT NULL COMMENT '創建時間',
  `updateTime` timestamp NULL DEFAULT NULL COMMENT '更新時間',
  PRIMARY KEY (`id`),
  KEY `idx_companyId_couponMetaId` (`companyId`,`couponMetaId`),
  KEY `idx_planeId` (`planeId`),
  KEY `idx_houseid_activitystatus` (`houseId`,`activityStatus`)
) ENGINE=InnoDB AUTO_INCREMENT=17379 DEFAULT CHARSET=utf8 COMMENT='優惠券綁定範圍表'

該表主要是記錄房源和優惠券之間的綁定關係,並記錄當前綁定的狀態,該狀態用於限制同一個房源不可以綁太多的有效優惠券,限制商家爲了增加 c 端曝光,胡亂操作。

操作房源綁定優惠券的時候,會使用分佈式鎖,避免併發綁定操作導致超限

2.3.2 數據展示

數據的展示依賴於 C 端的索引數據,索引數據中存儲的都是在生效狀態的優惠券信息,字段結合業務場景用於篩選和展示,具體如下:

  1. 優惠券 ID(多值)

  2. 活動 ID(多值)

  3. 參加的活動類型(多值)

通過索引篩選條件檢索出房源數據後,即可獲取房源的完整數據來獲取該房源綁定了哪些優惠券,進行優惠券數據的展示。

2.3.3 狀態流轉

對於綁定關係的狀態實時更新有兩個觸發動作,根據情況去更新數據的 status 和 recordStatus 兩個字段:

依託於 2.2.3 中的優惠券狀態的 MQ 消息 db 綁定數據發生變化時(改 / 增 / 減),同樣也會發送 MQ,異步更新索引中的數據。

2.4 C 端用戶領券

C 端用戶領券是一個比較重要的地方,核心要求就是絕對不能多領,儘可能避免少領。 具體設計流程圖如下:

流程大概分爲三步:

  1. 請求校驗

  2. redis 庫存扣減

  3. 領取記錄和更新庫存任務以事務形式寫入 MySQL

下面根據圖中所示逐步進行說明

2.4.1 數據讀取

由於 C 端瀏覽券詳情 & 領券可能會是一個併發量較高的操作,所以儘可能都從緩存中讀數據,包括以下數據:

  1. 活動信息

  2. 優惠券信息

  3. 優惠券庫存

當活動開始前五分鐘會禁止編輯活動信息和優惠券信息,活動開始時將上述數據推入緩存中,不設置過期時間,待活動狀態轉爲結束時再清理數據。

同時在 web 服務集羣中,對這 1 2 數據項做了一層短時間的本地緩存,減少請求 redis 集羣的網絡開銷。

2.4.2 校驗

首先,服務端收到用戶領券的請求後,會在 redis 中校驗是否存在領取記錄 cache。這裏可以使用布隆過濾器來實現,key 爲優惠券 id,若用戶 id 存在其中則直接返回。

然後,會進行優惠券狀態校驗,優惠券數據在活動開始時即推入了 redis 中,所以直接用 redis 中的數據校驗即可。

最後,會校驗是否存在該優惠券的存庫數據,爲了提高容錯性和可用性,若不存在則發送一個初始化庫存任務的 MQ,然後直接返回,由 MQ 異步來重新初始化庫存數據,避免緩存擊穿的問題。

2.4.3 庫存扣減

直接操作 db,以使用樂觀鎖形式扣減庫存,MySQL 的庫存更新操作會成併發熱點,請求都會在行鎖的爭搶中阻塞,支持的併發量有限,並且會給 db 帶來壓力。 由於 redis 可以保證操作的原子性,並且數據在內存中,適合高併發場景,所以通過 redis 來完成庫存的扣減。

將 db 的庫存更新操作通過 db 消息任務表,進行異步化,串行化,避免阻塞的同時也降低鎖的爭搶。

但是扣減庫存以 redis 爲準的話,就分爲幾種情況:

  1. redis 扣減成功,但是 db 領取記錄和更新庫存任務寫入失敗,執行回滾,incr 庫存數量。

  2. redis 扣減失敗(沒庫存 / redis 宕機), 不會執行 db 操作。

  3. redis 扣減成功,db 事務執行時,服務運行機器重啓或宕機,沒有回滾庫存,產生少領情況

  4. redis 扣減成功後 redis 主庫宕機,DB 寫入數據成功,但扣減數據未同步到從庫,使用從庫進行扣減時產生超領現象

1 2 屬於正常情況,3 4 屬於異常情況。

對於情況 3, 可以在 redis 中庫存扣減光時,觸發異步任務來對比庫存數據,若還有可領取庫存,則更新 redis 的庫存信息,達到避免少領的情況。

對於情況 4,由於 redis 無法保證主從強一致,在數據操作丟失的情況下,就有可能會產生超領情況。

我的想法是,有以下幾種方式:

  1. 若更新庫存操作已經存在超領的情況,將用戶領取優惠券的數據進行刪除或凍結,避免帶來損失。

  2. 監控剩餘庫存量每變動 5%,就執行異步任務將優惠券狀態進行凍結,讓 C 端無法領券。然後將當前消息事務表中 db 的扣減任務都處理完成後,進行 redis 和 db 的數據校驗同步,同步完成後將優惠券解凍,恢復正常領取。

  3. 由於 redis 屬於 AP,若要保證數據的強一致性則犧牲可用性,改爲使用 CP 的存儲。

  4. 優惠券覈銷時檢查覈銷數量是否超過總數量,若達到閾值,則提示優惠券不可用。

  5. 根據優惠券發放總數量,生成一批優惠券 id,同時存入 db 與 redis 隊列中,通過 pop id 生成優惠券領取記錄,根據 id 是否相同來限制重複領取,從而達到防止超領的情況。

第一種方式,用戶領券成功到更新 db 庫存任務的執行,在這中間時間窗口很小的情況下,可以儘可能的避免超領並使用的情況。

第二種方式,也不能完全解決超量的問題,只能以犧牲很少時間的領取功能,矯正領取請求非激增的情況下數據不一致的情況。

第三種方式不做贅述。

第四種方式,被動的進行數量的校驗,保證使用的優惠券不會超過發放的總數量,個人認爲是比較柔和的影響範圍最小的處理方式了,只不過可能會引起客訴,(爲什麼領了還是不能用!!!)

第五種方式,db 中根據優惠券發放總數量生成一批領取記錄,但是領取用戶 id 以及領取時間空着。將這批記錄的 id 存入 redis 隊列中,扣庫存的時候 pop 該隊列,獲取 id 以後,通過樂觀鎖方式更新對應 id 的數據行(where id = x and userId = 0),若更新失敗則領取失敗。這樣可以有效的防止第四種情況產生,需要考慮的是若發放數量過多,而實際領取很少,還需活動結束後清理佔用的數據表空間。事前需要預熱插入 db 中的數據,事後需要清理沒有綁定 userId 的數據。

在我的業務場景下,考量過後主要選用了第四種方式,第二種方式選擇了每天凌晨四點進行一次,第一種沒有刪除而僅僅是進行了報警。

若有更好的方式,希望大神能夠指點一下

2.5 優惠券覈銷

以微服務形式,提供用戶優惠券獲取接口,以及狀態變更接口給調用方使用,狀態流轉分爲未使用、鎖定、已使用三種狀態。

當客戶下了簽約訂單時,將對應使用的優惠券進行鎖定。若訂單完成則調用接口將狀態流轉爲已使用,若訂單取消則回滾優惠券的狀態爲未使用。

未來可優化方向

  1. 用戶的優惠券數據分庫分表,進行數據水平拆分,提升 db 讀寫能力。

  2. redis 集羣以分片形式部署,提升可用性及容量。

  3. 集羣拆分,每個集羣僅處理部分優惠券請求,通過網關打散請求到不同的 pod 中。

  4. 引入 jd-hotkey 組件,熱 key 實時同步到集羣本地緩存中,減少訪問分佈式緩存。

  5. 引入 canal 組件,通過 binlog 同步 db 更新的信息,更新緩存並進行數據聯動更新。

總結

至此,一個完整的優惠券系統就構建完畢了。

遵循讀多寫少用緩存,寫多讀少用隊列的原則。

對於展現的活動數據,代碼通過代理模式儘可能的通過緩存進行讀取。使用了多級緩存的同時,爲了避免產生緩存擊穿的場景,對於活動中的數據都採用了主動推數據到 redis 中的方式。

對於活動 -> 優惠券 -> 房源的聯動數據的寫操作,代碼通過狀態模式 + 觀察者模式實現,以及 MQ 控制併發量的異步更新。

對於庫存扣減採用了 redis,依託於它的原子性,但是 redis 不保證集羣內部數據強一致性。爲了避免超領帶來的損失問題,在覈銷優惠券時進行了數量閾值校驗。

對於熱點的 db 庫存更新則採用了 db 事務消息表,通過事務保證領取記錄插入成功的同時一定會落入更新庫存任務,從而異步串行的進行庫存更新。

來源:https://juejin.cn/post/7160643319612047367

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/FYNAd9UR03WiUhiwI05fmQ