自如2018新年活動系統 — 搶紅包
來自我的博客 自如2018新年活動系統 — 搶紅包
2017 年是自如快速增長的一年,自如客突破 100 萬,管理資產達到 50 萬間,在年底成功獲得了 40 億 A 輪融資,而這些都要感謝廣大的自如客,公司為了回饋自如客,在六周年活動時就發放了 6000 萬租住基金,當然年底散幣活動也夠瘋狂。
活動規模
既然公司對自如客這麼闊,那對我們員工也得夠意思,所以年底我們共準備了 3 個活動。
1、針對 自如客 的服務費減免活動;
2、針對 自如客 的 1000 萬現金禮包;3、25 萬的 員工 紅包活動;
散幣活動 2 和 3 是通過微信紅包形式進行,想散幣就散吧,可微信告訴我們,想散幣還得交稅(>﹏<)。員工紅包來說,25 萬要交掉 10 多萬稅,此時心疼我的錢。好了,下面開始說點正事。
技術方案
說到紅包,我們肯定會想到紅包拆分和搶紅包兩個場景。紅包拆分是指將指定金額拆分為指定數目紅包的過程,即是用來確定每個紅包的金額數;而搶紅包就是典型的高並發場景,需要避免紅包超發的情況。
紅包拆分
可選的方案
拆分方式
1、實時拆分
實時拆分,指的是在搶紅包時實時計算每個紅包的金額,以實現紅包的拆分過程,對系統性能和拆分演算法要求較高,例如拆分過程要一直保證後續待拆分紅包的金額不能為空,不容易做到拆分紅包的金額服從正態分布規律。2、預先生成
預先生成,指的是在紅包開搶之前已經完成了紅包的拆分,搶紅包時只是依次取出拆分好的紅包金額,對拆分演算法要求較低,可以拆分出隨機性很好的紅包金額,通常需要結合隊列使用。拆分演算法
我並沒有找到業界的通用演算法,但紅包拆分演算法應該是拆分金額要看起來隨機,最好能夠服從正態分布,可以參考 微信 和 @lcode 提供的紅包拆分演算法。
微信拆分演算法的優點是演算法較簡單,拆分效率高,同時,由於該演算法天然的特性,可以保證後續紅包金額一定不為空,特別適合實時拆分場景,但缺點是會導致大額紅包較大概率地在拆分的最後出現。 @lcode 拆分演算法的優點是拆分金額基本符合正態分布,適合隨機性要求較高的拆分場景。
我們的方案
我們這次的業務對紅包金額的隨機性要求不高,但是對系統可靠性要求較高,所以我們選用了預算生成方式,並借鑒 微信 的紅包拆分演算法,作為我們的紅包拆分方案。
採用預算生成方式,我們預先生成紅包並放入 Redis 的 List 中,當搶紅包時只是 Pop List 即可,具體實現將在 搶紅包 部分介紹。
拆分演算法可以描述為:假設剩餘拆分金額為 M,剩餘待拆分紅包個數為 N,紅包最小金額為 1 元,紅包最小單位為元,那麼定義當前紅包的金額為:
$$m = rand(1, floor(M/N*2))$$
其中,floor 表示向下取整,rand(min, max) 表示從 [min, max] 區間隨機一個值。$M/N ast 2$ 表示剩餘待拆分金額平均金額的 2 倍,因為 N >= 2,所以 $M/N ast 2 <= M$,表示一定能保證後續紅包能拆分到金額。
代碼實現為:
for ($i = 0; $i < $N - 1; $i++) { $max = (int)floor($M / ($N - $i)) * 2; $m[$i] = $max ? mt_rand(1, $max) : 0; $M -= $m[$i];}$m[] = $M;
值得一提的是,我們為了保證紅包金額差異盡量小,先將總金額平均拆分成 N+1 份,將第 N+1 份紅包按照上述的紅包拆分演算法拆分成 N 份,這 N 份紅包加上之前的平均金額才作為最終的紅包金額。
搶紅包
可選的方案
限流
1、前端限流
前端限制用戶在 n 秒之內只能提交一次請求,雖然這種方式只能擋住小白,不過這是 99% 的用戶喲,所以也必須得做。2、後端限流
常用的後端限流方法有 漏桶演算法 和 令牌桶演算法。漏桶演算法 主要目的是控制請求數據注入的速率,如果此時漏桶溢出,後續的請求數據會被丟棄。而 令牌桶演算法 是以一個恆定的速度往桶里放入令牌,而如果請求數據需要被處理,則需要先從桶里獲取一個令牌,當桶里沒有令牌時,這些請求才被丟棄,令牌桶演算法的一個好處是可以方便地改變應用接受請求的速率。防超發
1、庫存加鎖
可以通過加鎖的方式解決資源搶佔問題,但是加鎖會增加系統開銷,大流量下更容易拖垮系統,不過可以嘗試一下基於版本號的樂觀鎖。2、通過高速隊列串列化請求
之所會出現超發問題,是因為並發時會出現多個進程同時獲取同一資源的現象,如果使用高速隊列將並行請求串列化,那麼問題就不存在了。高速隊列可以使用 Redis 緩存伺服器來實現,當然光使用隊列還不夠,必要保證整個流程調用鏈要短、要快,否則隊列會積壓嚴重,甚至會拖垮整個服務。我們的方案
在限流方面,由於我們預估的請求量還在系統承受範圍,所以沒有考慮引入後端限流方案。我們的搶紅包系統流程圖如下:
我們將搶紅包拆分為 紅包佔有(流程①,同步) 和 紅包發放 (流程②,非同步)這兩個過程,首先採用高速隊列串列化請求,紅包發放邏輯由一組 Worker 非同步去完成。高速隊列只是完成紅包佔有的過程,實現庫存的控制,Worker 則處理耗時較長的紅包發放過程。
當然,在實際應用中,紅包佔用過程還需要加上一些前置規則校驗,比如用戶是否已經領取過,領取次數是否已經達到上限等?紅包佔有流程圖如下:
其中,red::list
為 List 結構,存放預先生成的紅包金額(流程①中的紅包隊列);red::task
也為 List 結構,紅包非同步發放隊列(流程②中的任務隊列);red::draw
為 Hash 結構,存放紅包領取記錄,field
為用戶的 openid,value
為序列化的紅包信息;red::draw_count:u:openid
為 K-V 結構,用戶領取紅包計數器。
下面,我將以以下 3 個問題為中心,來說說我們設計出的搶紅包系統。
1、怎麼保證不超發
我們需要關注的是紅包佔有過程,從紅包佔有流程圖可看出,這個過程是很多 Key 操作的組合,那怎麼保證原子性?可以使用 Redis 事務,但我們選用了 Lua 方案,一方面是因為首先要保證性能,而 Lua 腳本嵌入 Redis 執行不存在性能瓶頸,另一方面 Lua 腳本執行時本身就是原子性的,滿足需求。紅包佔有的 Lua 腳本實現如下:
-- 領取人的openid為xxxxxxxxxxxlocal openid = xxxxxxxxxxxlocal isDraw = redis.call(HEXISTS, red::draw, openid)-- 已經領取if isDraw ~= 0 then return trueend-- 領取太多次了local times = redis.call(INCR, red::draw_count:u:..openid)if times and tonumber(times) > 9 then return 0endlocal number = redis.call(RPOP, red::list)-- 沒有紅包if not number then return {}end-- 領取人昵稱為Fhb,頭像為https://xxxxxxxlocal red = {money=number,name=Fhb,pic=https://xxxxxxx}-- 領取記錄redis.call(HSET, red::draw, openid, cjson.encode(red))-- 處理隊列red[openid] = openidredis.call(RPUSH, red::task, cjson.encode(red))return true
需要注意 Lua 腳本執行過程並不是事務的,腳本中的操作命令在執行時是有先後順序的,當某個操作執行失敗時不會回滾已經執行成功的操作,它的原子性是通過單線程模型實現。
2、怎麼提高系統響應速度
如紅包佔有流程圖所示,當用戶發起搶紅包請求時,若有紅包則直接完成紅包佔有操作,同步告知用戶是否搶到紅包,這個過程要求快速響應。但由於微信紅包支付屬於第三方調用,若搶到紅包後同步調用紅包支付,系統調用鏈又長又慢,所以紅包佔有和紅包發放非同步拆分是必然。拆分後,紅包佔有只需操作 Redis,響應性能已不是問題。
3、怎麼提高系統處理能力
從上述分析可知,目前系統的壓力都會集中在紅包發放這個環節,因為用戶搶到紅包時,我們只是同步告知用戶已搶到紅包,然後非同步去發放紅包,因此用戶並不會立即收到紅包(受紅包發放 Worker 處理能力和微信服務壓力制約)。若紅包發放的 Worker 處理能力較弱,那麼紅包發放的延遲就會很高,體驗較差。如搶紅包流程圖中所示,我們採用一組 Worker 去消費任務隊列,並調用紅包支付 API,以及數據持久化操作(後續對賬)。儘管紅包發放調用鏈又長又慢,但是注意到這些 Worker 是 無狀態 的,所以可以通過增加 Worker 數量,以橫向擴展提高系統的處理能力。
4、怎麼保證數據一致性
其實,紅包發放延時我們可以做到用戶無感知,但是若紅包發放(流程②)失敗了,已經告知用戶搶到紅包,但是卻木有發,估計他殺人的心都有了。根據 CAP 原理,我們無法同時滿足數據一致性、數據可用性、分區耐受性,通常只需做到數據最終一致性。為了達到數據最終一致性,我們就引入了重試機制,生成一個全局唯一的外部訂單號,當某單紅包發放失敗,就會放回任務隊列,使得有機會進行發放重試,當然這一切都需要 API 做冪等處理。
Worker可靠性保障
這裡必須將 Worker 可靠性單獨說,因為它實在太重要了。Worker 的實現如下:
$maxTask = 1000;$sleepTime = 1000;while (true) { while ($red = RedLogic::getTask()) { RedLogic::doTask($red); //處理多少個任務主動退出 $maxTask--; if ($maxTask < 0) { return EXIT_CODE_NORMAL; } } //等待任務 usleep($sleepTime);}
由於 Worker 需要常駐內存運行,難免會出現異常退出的情況(也有主動退出), 所以需要保持 Worker 一直處於運行狀態。我們使用進程管理工具 Supervisor 來監控 Worker 的運行狀態,同時管理 Worker 的數量,當任務隊列出現堆積時,增加 Worker 數量即可。Supervisor 的監控後台如下:
員工系統號散列
公司員工都用唯一一個系統號 empcode(自增欄位)標識,登錄成功後返回 empcode,系統後續所有交互流程都基於 empcode,分享出去的紅包也會攜帶 empcode,為了保護員工敏感信息和防止惡意碰撞攻擊,我們不能直接將 emp_code 暴露給前端,需要藉助一個 token(無規律)的中間者來完成交互。
可選的方案
1、儲存映射關係,時時查詢
預先生成一個隨機串 token,然後跟 empcode 綁定,每次請求都根據 token 時時查詢 empcode。優點是可以定期更新,相對安全,缺點是性能不高。2、建立映射關係函數,實時計算
建立一個映射關係函數,如 hash 散列或者加密解密演算法,能夠根據 empcode 生成一個無規律的字元串 token,並且要能夠根據 token 反映射出 empcode。優點是需要存儲介質存儲關係,性能較高,缺點是很難做到定期失效並更新。我們的方案
由於我們的紅包活動只進行幾天,所以我們選用了方案 2。對 emp_code 做了 hashids 散列演算法,暴露的只是一串無規律的散列字元串。
hashids 是一個開源且輕量的唯一 id 生成器,支持 Java、PHP、C/C++、Python 等主流語言,PHP 想使用 hashids,只需composer require hashids/hashids
命令安裝即可。
然後,如下方式使用:
use HashidsHashids;$hashids = new Hashids(salt, 6, abcdefghijk1234567890);$hashids->encode(11002); //994k2kk$hashids->decode(994k2kk); //[11002]
需要說明的是,其中salt
是非常重要的散列加密鹽串,6
表示散列值最小長度,abcde...7890
為散列字典,太長影響效率,太短不安全。由於默認的散列字典比較長,decode 效率並不高,所以這裡移除了大寫字母部分。
語音點贊
語音點贊就是用戶以語音的形式助力好友,核心技術其實是語音識別,而我們一般都會使用第三方語音識別服務。
可選的方案
1、客戶端調用第三方服務識別
客戶端直接調用第三方語音識別服務,如微信提供了 JS-SDK 的語音識別 API ,返回識別的語音文本的信息,並且已經經過語義化。優點是識別較快,且不許關注語音存儲問題,缺點是不安全,識別結果提交到服務端之前可能被惡意篡改。2、服務端調用第三方服務識別
先將錄製的語音上傳至存儲平台,然後服務端調用第三方語音識別服務,第三方語音識別服務去獲取語音信息並識別,返回識別的語音文本的信息。優點是識別結果較安全,缺點是系統交互較多,識別效率不高。我們的方案
我們業務場景的特殊性,存在用戶可助力次數的限制,所以無需擔心惡意刷贊的情況,因此可以選用方案 1,語音識別的交互流程如下:
此時,整個語音識別流程如下:
當然中國文字博大精深,語音識別的文本在匹配時,需要考慮容錯處理,可以將文本轉化為拼音,然後匹配拼音,或者設置一個匹配百分比,達到匹配值則認為語音口令正確。
需要注意的是,微信只提供 3 天的語音存儲服務,若語音播放周期較長,則要考慮實現語音的存儲。
其他
紅包發放測試
我們使用了線上公賬號進行紅包發放測試,為了讓線上公眾號能夠授權到測試環境,在線上的微信授權回調地址新增一個參數,將帶有to=feature
參數的請求引流到測試環境,其他線上流量還是保持不變,匹配規則如下:
# Nginx不支持if嵌套,所以就這樣變通實現set $auth_redirect "";if ($args ~* "r=auth/redirect") { set $auth_redirect "prod";}if ($args ~* "to=feature") { set $auth_redirect "feature";}if ($auth_redirect ~ "feature") { rewrite ^(.*)$ http://wx.t.ziroom.com/index.php last;}if ($auth_redirect ~ "prod") { rewrite ^(.*)$ http://wx.ziroom.com/index.php last;}
CDN緩存
由於本次活動力度較大,預估流量會比以往增加不少(不能再出現機房帶寬打滿的情況了,不然 >﹏<),靜態頁面占流量的很大一部分,所以靜態頁面在發布時都會放置一份在 CDN 上,這樣回源的流量就很小了。
災備方案
儘管做了很多準備,還是無法確保萬無一失,我們在每個關鍵節點都增加了開關,一點出現異常,通過配置中心可以人工介入做降級處理。
推薦閱讀:
※裝裱店的活動設想
※2017城市青年節丨遇見2999位與你一樣不甘平凡的青年。
※【聚在一起,才是年味】來知吾煮學做年夜飯,大獎抱回家
※繕錦堂字畫修復店慶活動