一:業務背景
優惠券是電商常見的營銷手段,具有靈活的特點,既可以作為促銷活動的載體,也是重要的引流入口。優惠券系統是vivo商城營銷模塊中一個重要組成部分,早在15年vivo商城還是單體應用時,優惠券就是其中核心模塊之一。隨著商城的發展及用戶量的提升,優惠券做了服務拆分,成立了獨立的優惠券系統,提供通用的優惠券服務。目前,優惠券系統覆蓋了優惠券的4個核心要點:創、發、用、計。
- “創”指優惠券的創建,包含各種券規則和使用門檻的配置。
- “發”指優惠券的發放,優惠券系統提供了多種發放優惠券的方式,滿足針對不同人群的主動發放和被動發放。
- “用”指優惠券的使用,包括正向購買商品及反向退款后的優惠券回退。
- “計”指優惠券的統計,包括優惠券的發放數量、使用數量、使用商品等數據匯總。
vivo商城優惠券系統除了提供常見的優惠券促銷玩法外,還以優惠券的形式作為其他一些活動或資產的載體,比如手機類商品的保值換新、內購福利、與外部廣告商合作發放優惠券等。
以下為vivo商城優惠券部分場景的展示:
二:系統架構及變遷
優惠券最早和商城耦合在一個系統中。隨著vivo商城的不斷發展,營銷活動力度加大,優惠券使用場景增多,優惠券系統逐漸開始“力不從心”,暴露了很多問題:
- 海量優惠券的發放,達到優惠券單庫、單表存儲瓶頸。
- 與商城系統的高耦合,直接影響了商城整站接口性能。
- 優惠券的迭代更新受限于商城的版本安排。
- 針對多品類優惠券,技術層面沒有沉淀通用優惠券能力。
為了解決以上問題,19年優惠券系統進行了系統獨立,提供通用的優惠券服務,獨立后的系統架構如下:
優惠券系統獨立遷移方案
如何將優惠券從商城系統遷移出來,并兼容已對接的業務方和歷史數據,也是一大技術挑戰。系統遷移有兩種方案:停機遷移和不停機遷移。
我們采用的是不停機遷移方案:
- 遷移前,運營停止與優惠券相關的后臺操作,避免產生優惠券靜態數據。
靜態數據:優惠券后臺生成的數據,與用戶無關。
動態數據:與用戶有關的優惠券數據,含用戶領取的券、券和訂單的關系數據等。
- 配置當前數據庫開關為單寫,即優惠券數據寫入商城庫(舊庫)。
- 優惠券系統上線,通過腳本遷移靜態數據。遷完后,驗證靜態數據遷移準確性。
- 配置當前數據庫開關為雙寫,即線上數據同時寫入商城庫和優惠券新庫。此時服務提供的數據源依舊是商城庫。
- 遷移動態數據。遷完后,驗證動態數據遷移準確性。
- 切換數據源,服務提供的數據源切換到新庫。驗證服務是否正確,出現問題時,切換回商城數據源。
- 關閉雙寫,優惠券系統遷移完成。
遷移后優惠券系統請求拓撲圖如下:
三、系統設計
3.1 優惠券分庫分表
隨著優惠券發放量越來越大,單表已經達到瓶頸。為了支撐業務的發展,綜合考慮,對用戶優惠券數據進行分庫分表。
關鍵字:技術選型、分庫分表因子
分庫分表有成熟的開源方案,這里不做過多介紹。參考之前項目經驗,采用了公司中間件團隊提供的自研框架。原理是引入自研的MyBatis的插件,根據自定義的路由策略計算不同的庫表后綴,定位至相應的庫表。
用戶優惠券與用戶id關聯,并且用戶id是貫穿整個系統的重要字段,因此使用用戶id作為分庫分表的路由因子。這樣可以保證同一個用戶路由至相同的庫表,既有利于數據的聚合,也方便用戶數據的查詢。
假設共分N個庫M個表,分庫分表的路由策略為:
庫后綴databaseSuffix = hash(userId) / M %N
表后綴tableSuffix = hash(userId) % M
3.2 優惠券發放方式設計
為滿足各種不同場景的發券需求,優惠券系統提供三種發券方式:統一領券接口、后臺定向發券、券碼兌換發放。
3.2.1 統一領券接口
保證領券校驗的準確性
領券時,需要嚴格校驗優惠券的各種屬性是否滿足:比如領取對象、各種限制條件等。其中,比較關鍵的是庫存和領取數量的校驗。因為在高并發的情況下,需保證數量校驗的準確性,不然很容易造成用戶超領。
存在這樣的場景:A用戶連續發起兩次領取券C的請求,券C限制每個用戶領取一張。第一次請求通過了領券數量的校驗,在用戶優惠券未落庫的情況下,如果不做限制,第二次請求也會通過領券數量的校驗。這樣A用戶會成功領取兩張券C,造成超領。
為了解決這個問題,優惠券采用的是分布式鎖方案,分布式鎖的實現依賴于redis。在校驗用戶領券數量前先嘗試獲取分布式鎖,優惠券發放成功后釋放鎖,保證用戶領取同一張券時不會出現超領。上面這種場景,用戶第一次請求成功獲取分布式鎖后,直至第一次請求成功釋放已獲取的分布式鎖或超時釋放,不然用戶第二次請求會獲取分布式鎖失敗,這樣保證A用戶只會成功領取一張。
庫存扣減
領券要進行庫存扣減,常見庫存扣減方案有兩種:
方案一:數據庫扣減。
扣減庫存時,直接更新數據庫中庫存字段。
該方案的優點是簡單便捷,查驗庫存時直接查庫即可獲取到實時庫存。且有數據庫事務保證,不用考慮數據丟失和不一致的問題。
缺點也很明顯,主要有兩點:
1)庫存是數據庫中的單個字段,在更新庫存時,所有的請求需要等待行鎖。一旦并發量大了,就會有很多請求阻塞在這里,導致請求超時,進而系統雪崩。
2)頻繁請求數據庫,比較耗時,且會大量占用數據庫連接資源。
方案二:基于redis實現庫存扣減操作。
將庫存放到緩存中,利用redis的incrby特性來扣減庫存。
該方案的優點是突破數據庫的瓶頸,速度快,性能高。
缺點是系統流程會比較復雜,而且需要考慮緩存丟失或宕機數據恢復的問題,容易造成庫存數據不一致。
從優惠券系統當前及可預見未來的流量峰值、系統維護性、實用性上綜合考慮,優惠券系統采用了方案一的改進方案。改進方案是將單庫存字段分散成多庫存字段,分散數據庫的行鎖,減少并發量大的情況數據庫的行鎖瓶頸。
庫存數更新后,會將庫存平均分配成M份,初始化更新到庫存記錄表中。用戶領券,隨機選取庫存記錄表中已分配的某一庫存字段(共M個)進行更新,更新成功即為庫存扣減成功。同時,定時任務會定期同步已領取的庫存數。相比方案一,該方案突破了數據庫單行鎖的瓶頸限制,且實現簡單,不用考慮數據丟失和不一致的問題。
一鍵領取多張券
在對接的業務方的領券場景中,存在用戶一鍵領取多張券的情形。因此統一領券接口需要支持用戶一鍵領券,除了領取同一券模板的多張,也支持領取不同券模板的多張。一般來說,一鍵領取多張券指領取不同券模板的多張。在實現過程中,需要注意以下幾點:
1)如何保證性能
領取多張券,如果每張券分別進行校驗、庫存扣減、入庫,那么接口性能的瓶頸卡在券的數量上,數量越多,性能直線下降。那么在券數量多的情況下,怎么保證高性能呢?主要采取兩個措施:
a. 批量操作。
從發券流程來看,瓶頸在于券的入庫。領券是實時的(異步的話,不能實時將券發到用戶賬戶下,影響到用戶的體驗還有券的轉化率),券越多,入庫時與數據庫的IO次數越多,性能越差。批量入庫可以保證與數據庫的IO的次數只有一次,不受券的數量影響。如上所述,用戶優惠券數據做了分庫分表,同一用戶的優惠券資產保存在同一庫表中,因此同一用戶可實現批量入庫。
b. 限制單次領券數量。
設置閥值,超出數量后,直接返回,保證系統在安全范圍內。
2)保證高并發情況下,用戶不會超領
假如用戶在商城發起請求,一鍵領取A/B/C/D四張券,同時活動系統給用戶發放券A,這兩個領券請求是同時的。其中,券A限制了每個用戶只能領取一張。按照前述采用分布式鎖保證校驗的準確性,兩次請求的分布式鎖的key分別為:
用戶id+A_id+B_id+C_id+D_id
用戶id+A_id
這種情況下,兩次請求的分布式鎖并沒有發揮作用,因為鎖key是不同,數量校驗依舊存在錯誤的可能性。為避免批量領券過程中用戶超領現象的發生,在批量領券過程中,對分布鎖的獲取進行了改造。上例一鍵領取A/B/C/D四張券,需要批量獲取4個分布式鎖,鎖key為:
用戶id+A_id
用戶id+B_id
用戶id+C_id
用戶id+D_id
獲取其中任何一個鎖失敗,即表明此時該用戶正在領取其中某一張券,需要自旋等待(在超時時間內)。獲取所有的分布式鎖成功,才可以進行下一步。
接口冪等性
統一領券接口需保證冪等性(冪等性:用戶對于同一操作發起的一次請求或者多次請求的結果是一致的)。在網絡超時、異常情況下,領券結果沒有及時返回,業務方會進行領券重試。如果接口不保證冪等性,會造成超發。冪等性的實現有多種方案,優惠券系統利用數據庫的唯一索引來保證冪等。
領券最早是不支持冪等性的,表設計沒有考慮冪等性。
那么第一個需要考慮的問題:在哪個表來添加唯一索引呢?
無非兩種方案:現有的表或者新建表。
- 采用現有的表,不需要增加表的關聯。但如上所述,因為做了分庫分表,大量的表需要添加唯一字段,并且需要兼容歷史數據,需要保證歷史數據新增字段的唯一性。
- 采用新建表這種方式,不需要兼容歷史數據,但缺陷也很明顯,增加了一層表的關聯,對性能和現有邏輯都有很大影響。綜合考慮,我們選取了在現有表添加唯一字段這種方式,這樣更利于保證性能和后續的維護性。
第二個考慮的問題:怎么兼容歷史數據和業務方?歷史數據增加了唯一字段,需要填入唯一值,不然無法添加唯一索引。我們采用腳本刷數據的方式,構造唯一值并刷新到每一行歷史數據中。優惠券已對接的業務方沒有傳入唯一編碼,針對這種情況,優惠券則生成唯一編碼作為替代,保證兼容性。
3.2.2 定向發券
定向發券用于運營在后臺針對特定人群進行發券。定向發券可以彌補用戶主動領券,人群覆蓋不精準、覆蓋面不廣的問題。通過定向發券,可以精準覆蓋特定人群,提高下單轉化率。在大促期間,大范圍人群的定向發券還可以承載活動push和降價促銷雙重任務。
定向發券主要在于人群的圈選和發券流程的設計,整體流程如下:
定向發券不同于用戶主動領券,定向發券的量通常會很大(億級)。為了支撐大批量的定向發券,定向發券做了一些優化:
1)去除事務。事務邏輯過重,對于定向發券來說沒必要。發券失敗,記錄失敗的券,保證失敗可以重試。
2)輕量化校驗。定向發券限制了券類型,通過限制配置的方式規避需嚴格校驗屬性的配置。不同于用戶主動領券校驗邏輯的冗長,定向發券的校驗非常輕量,大大提升發券性能。
3)批量插入。批量券插入減少數據庫IO次數,消除數據庫瓶頸,提升發券速度。定向發券是針對不同的用戶,用戶優惠券做了分庫分表,為了實現批量插入,需要在內存中先計算出不同用戶對應的庫表后綴,數據歸集后再批量插入,最多插入M次,M為庫表總個數。
4)核心參數可動態配置。比如單次發券數量,單次讀庫數量,發給消息中心的消息體包含的用戶數量等,可以控制定向發券的峰值速度和平均速度。
3.2.3 券碼兌換
站外營銷券的發放方式與其他券不同,通過券碼進行兌換。券碼由后臺導出,通過短信或者活動的方式發放到用戶,用戶根據券碼兌換后獲取相應的券。券碼的組成有一定的規則,在規則的基礎上要保證安全性,這種安全性主要是券碼校驗的準確性,防止已兌換券碼的再次兌換和無效券碼的惡意兌換。
3.3 精細化營銷能力設計
通過標簽組合配置的方式,優惠券提供精細化營銷的能力,以實現優惠券的千人千面。標簽可分為準實時和實時,值得注意的是,一些實時的標簽的處理需要前提條件,比如地區屬性需要用戶授權。
優惠券的精準觸達:
3.4 券和商品之間的關系
優惠券的使用需要和商品關聯,可關聯所有商品,也可以關聯部分商品。為了靈活性地滿足運營對于券關聯商品的配置,優惠券系統有兩種關聯方式:
a. 黑名單。
可用商品 = 全部商品 - 黑名單商品。
黑名單適用于券的可使用商品范圍比較廣這種情況,全部商品排除掉黑名單商品就是券的可使用范圍。
b. 白名單。
可用商品 = 白名單商品。
白名單適用于券的可使用商品范圍比較小這種情況,直接配置券的可使用商品。
除此以外,還有超級黑名單的配置,黑名單和白名單只對單個券有效,超級黑名單對所有券有效。當前優惠券系統提供商品級的關聯,后續優惠券會支持商品分類維度的關聯,分類維度 + 商品維度可以更靈活地關聯優惠券和商品。
3.5 高性能保證
優惠券對接系統多,存在高流量場景,優惠券對外提供接口需保證高性能和高穩定性。
多級緩存
為了提升查詢速度,減輕數據庫的壓力,同時為了應對瞬時高流量帶來熱點key的場景(比如發布會直播結束切換流量至特定商品商詳頁、熱點活動商品商詳頁都會給優惠券系統帶來瞬時高流量),優惠券采用了多級緩存的方式。
數據庫讀寫分離
優惠券除了上述所說的分庫分表外,在此基礎上還做了讀寫分離操作。主庫負責執行數據更新請求,然后將數據變更實時同步到所有從庫,用從庫來分擔查詢請求,解決數據庫寫入影響查詢的問題。主從同步存在延遲,正常情況下延遲不超過1ms,優惠券的領取或狀態變更存在一個耗時的過程,主從延遲對于用戶來說無感知。
依賴外部接口隔離熔斷
優惠券內部依賴了第三方的系統,為了防止因為依賴方服務不可用,產生連鎖效應,最終導致優惠券服務雪崩的事情發生,優惠券對依賴外部接口做了隔離和熔斷。
用戶維度優惠券字段冗余
查詢用戶相關的優惠券數據是優惠券最頻繁的查詢操作之一,用戶優惠券數據做了分庫分表,在查詢時無法關聯券規則表進行查詢,為了減少IO次數,用戶優惠券表中冗余了部分券規則的字段。優惠券規則表字段較多,冗余的字段不能很多,要在性能和字段數之間做好平衡。
四:總結
最后對優惠券系統進行一個總結:
- 不停機遷移,平穩過渡。自獨立后已穩定運行2年,性能足以支撐vivo商城未來3-5年的高速發展。
- 系統解耦,迭代效率大幅提升。
- 針對業務問題,原則是選擇合適實用的方案。
- 具備完善的優惠券業務能力。