自如2018新年活動系統 — 搶紅包

首發於 樊浩柏科學院

2017 年是自如快速增加的一年,自如客突破 100 萬,管理資產達到 50 萬間,在年末成功得到了 40 億 A 輪融資,而這些都要感謝廣大的自如客,公司爲了回饋自如客,在六週年活動時就發放了 6000 萬租住基金,固然年末散幣活動也夠瘋狂。php

2018口碑年

活動規模

既然公司對自如客這麼闊,那對咱們員工也得夠意思,因此年末咱們共準備了 3 個活動。html

一、針對 自如客 的服務費減免活動;
二、針對 自如客 的 1000 萬現金禮包;
三、25 萬的 員工 紅包活動;前端

員工紅包活動

散幣活動 2 和 3 是經過微信紅包形式進行,想散幣就散吧,可微信告訴咱們,想散幣還得交稅(>﹏<)。員工紅包來講,25 萬要交掉 10 多萬稅,此時心疼個人錢。好了,下面開始說點正事。redis

技術方案

說到紅包,咱們確定會想到紅包拆分和搶紅包兩個場景。紅包拆分是指將指定金額拆分爲指定數目紅包的過程,便是用來肯定每一個紅包的金額數;而搶紅包就是典型的高併發場景,須要避免紅包超發的狀況。算法

紅包拆分

可選的方案

拆分方式json

一、實時拆分
實時拆分,指的是在搶紅包時實時計算每一個紅包的金額,以實現紅包的拆分過程,對系統性能和拆分算法要求較高,例如拆分過程要一直保證後續待拆分成包的金額不能爲空,不容易作到拆分成包的金額服從正態分佈規律。後端

二、預先生成
預先生成,指的是在紅包開搶以前已經完成了紅包的拆分,搶紅包時只是依次取出拆分好的紅包金額,對拆分算法要求較低,能夠拆分出隨機性很好的紅包金額,一般須要結合隊列使用。緩存

拆分算法安全

我並無找到業界的通用算法,但紅包拆分算法應該是拆分金額要看起來隨機,最好可以服從正態分佈,能夠參考 微信@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 份紅包加上以前的平均金額才做爲最終的紅包金額。

搶紅包

可選的方案

限流

一、前端限流
前端限制用戶在 n 秒以內只能提交一次請求,雖然這種方式只能擋住小白,不過這是 99% 的用戶喲,因此也必須得作。

二、後端限流
經常使用的後端限流方法有 漏桶算法令牌桶算法漏桶算法 主要目的是控制請求數據注入的速率,若是此時漏桶溢出,後續的請求數據會被丟棄。而 令牌桶算法 是以一個恆定的速度往桶裏放入令牌,而若是請求數據須要被處理,則須要先從桶裏獲取一個令牌,當桶裏沒有令牌時,這些請求才被丟棄,令牌桶算法的一個好處是能夠方便地改變應用接受請求的速率。

防超發

一、庫存加鎖
能夠經過加鎖的方式解決資源搶佔問題,可是加鎖會增長系統開銷,大流量下更容易拖垮系統,不過能夠嘗試一下基於版本號的樂觀鎖。

二、經過高速隊列串行化請求
之所會出現超發問題,是由於併發時會出現多個進程同時獲取同一資源的現象,若是使用高速隊列將並行請求串行化,那麼問題就不存在了。高速隊列可使用 Redis 緩存服務器來實現,固然光使用隊列還不夠,必要保證整個流程調用鏈要短、要快,不然隊列會積壓嚴重,甚至會拖垮整個服務。

咱們的方案

在限流方面,因爲咱們預估的請求量還在系統承受範圍,因此沒有考慮引入後端限流方案。咱們的搶紅包系統流程圖以下:

搶紅包流程圖

咱們將搶紅包拆分爲 紅包占有(流程①,同步) 和 紅包發放 (流程②,異步)這兩個過程,首先採用高速隊列串行化請求,紅包發放邏輯由一組 Worker 異步去完成。高速隊列只是完成紅包占有的過程,實現庫存的控制,Worker 則處理耗時較長的紅包發放過程。

固然,在實際應用中,紅包占用過程還須要加上一些前置規則校驗,好比用戶是否已經領取過,領取次數是否已經達到上限等?紅包占有流程圖以下:

紅包占有流程圖

其中,red::list爲 List 結構,存放預先生成的紅包金額(流程①中的紅包隊列);red::task 也爲 List 結構,紅包異步發放隊列(流程②中的任務隊列);red::draw爲 Hash 結構,存放紅包領取記錄,field爲用戶的 openid,value爲序列化的紅包信息;red::draw_count:u:openid爲 k-v 結構,用戶領取紅包計數器。

下面,我將以如下 3 個問題爲中心,來講說咱們設計出的搶紅包系統。

一、怎麼保證不超發
咱們須要關注的是紅包占有過程,從紅包占有流程圖可看出,這個過程是不少 Key 操做的組合,那怎麼保證原子性?可使用 Redis 事務,但咱們選用了 Lua 方案,一方面是由於首先要保證性能,而 Lua 腳本嵌入 Redis 執行不存在性能瓶頸,另外一方面 Lua 腳本執行時自己就是原子性的,知足需求。

紅包占有的 Lua 腳本實現以下:

-- 領取人的openid爲xxxxxxxxxxx
local openid = 'xxxxxxxxxxx'
local isDraw = redis.call('HEXISTS', 'red::draw', openid)
-- 已經領取
if isDraw ~= 0 then
    return true
end
-- 領取太屢次了
local times = redis.call('INCR', 'red::draw_count:u:'..openid)
if times and tonumber(times) > 9 then
    return 0
end

local number = redis.call('RPOP', 'red::list')
-- 沒有紅包
if not number then
    return {}
end
-- 領取人暱稱爲Fhb,頭像爲https://xxxxxxx
local red = {money=number,name='Fhb',pic='https://xxxxxxx'}
-- 領取記錄
redis.call('HSET', 'red::draw', openid, cjson.encode(red))
-- 處理隊列
red['openid'] = openid
redis.call('RPUSH', 'red::task', cjson.encode(red))

return true
須要注意 Lua 腳本執行過程並非事務的,腳本中的操做命令在執行時是有前後順序的,當某個操做執行失敗時不會回滾已經執行成功的操做,它的原子性是經過單線程模型實現。

二、怎麼提升系統響應速度
如紅包占有流程圖所示,當用戶發起搶紅包請求時,如有紅包則直接完成紅包占有操做,同步告知用戶是否搶到紅包,這個過程要求快速響應。

但因爲微信紅包支付屬於第三方調用,若搶到紅包後同步調用紅包支付,系統調用鏈又長又慢,因此紅包占有和紅包發放異步拆分是必然。拆分後,紅包占有隻需操做 Redis,響應性能已不是問題。

三、怎麼提升系統處理能力
從上述分析可知,目前系統的壓力都會集中在紅包發放這個環節,由於用戶搶到紅包時,咱們只是同步告知用戶已搶到紅包,而後異步去發放紅包,所以用戶並不會當即收到紅包(受紅包發放 Worker 處理能力和微信服務壓力制約)。若紅包發放的 Worker 處理能力較弱,那麼紅包發放的延遲就會很高,體驗較差。

如搶紅包流程圖中所示,咱們採用一組 Worker 去消費任務隊列,並調用紅包支付 API,以及數據持久化操做(後續對帳)。儘管紅包發放調用鏈又長又慢,可是注意到這些 Worker 是 無狀態 的,因此能夠經過增長 Worker 數量,以橫向擴展提升系統的處理能力。

四、怎麼保證數據一致性
其實,紅包發放延時咱們能夠作到用戶無感知,可是若紅包發放(流程②)失敗了,已經告知用戶搶到紅包,可是卻木有發,估計他殺人的心都有了。根據 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);
}
這裏使用 LPOP 命令獲取任務,因此使用了 while 結構,而且無任務時須要等待,能夠用阻塞命令 BLPOP 來改進。

因爲 Worker 須要常駐內存運行,不免會出現異常退出的狀況(也有主動退出), 因此須要保持 Worker 一直處於運行狀態。咱們使用進程管理工具 Supervisor 來監控 Worker 的運行狀態,同時管理 Worker 的數量,當任務隊列出現堆積時,增長 Worker 數量便可。Supervisor 的監控後臺以下:

Supervisor進程管理

員工系統號散列

公司員工都用惟一一個系統號 emp_code(自增字段)標識,登陸成功後返回 emp_code,系統後續全部交互流程都基於 emp_code,分享出去的紅包也會攜帶 emp_code,爲了保護員工敏感信息和防止惡意碰撞攻擊,咱們不能直接將 emp_code 暴露給前端,須要藉助一個 token(無規律)的中間者來完成交互。

可選的方案

一、儲存映射關係,時時查詢
預先生成一個隨機串 token,而後跟 emp_code 綁定,每次請求都根據 token 時時查詢 emp_code。優勢是能夠按期更新,相對安全,缺點是性能不高。

二、創建映射關係函數,實時計算
創建一個映射關係函數,如 hash 散列或者加密解密算法,可以根據 emp_code 生成一個無規律的字符串 token,而且要可以根據 token 反映射出 emp_code。優勢是須要存儲介質存儲關係,性能較高,缺點是很難作到按期失效並更新。

咱們的方案

因爲咱們的紅包活動只進行幾天,因此咱們選用了方案 2。對 emp_code 作了 hashids 散列算法,暴露的只是一串無規律的散列字符串。

hashids 是一個開源且輕量的惟一 id 生成器,支持 Java、PHP、C/C++、Python 等主流語言,PHP 想使用 hashids,只需composer require hashids/hashids命令安裝便可。

而後,以下方式使用:

use Hashids\Hashids;

$hashids = new Hashids('salt', 6, 'abcdefghijk1234567890');

$hashids->encode(11002);    //994k2kk
$hashids->decode('994k2kk');  //[11002]

須要說明的是,其中salt是很是重要的散列加密鹽串,6表示散列值最小長度,abcde...7890爲散列字典,太長影響效率,過短不安全。因爲默認的散列字典比較長,decode 效率並不高,因此這裏移除了大寫字母部分。

語音點贊

語音點贊就是用戶以語音的形式助力好友,核心技術實際上是語音識別,而咱們通常都會使用第三方語音識別服務。

可選的方案

一、客戶端調用第三方服務識別
客戶端直接調用第三方語音識別服務,如微信提供了 JS-SDK 的語音識別 API ,返回識別的語音文本的信息,而且已經通過語義化。優勢是識別較快,且不準關注語音存儲問題,缺點是不安全,識別結果提交到服務端以前可能被惡意篡改。

二、服務端調用第三方服務識別
先將錄製的語音上傳至存儲平臺,而後服務端調用第三方語音識別服務,第三方語音識別服務去獲取語音信息並識別,返回識別的語音文本的信息。優勢是識別結果較安全,缺點是系統交互較多,識別效率不高。

咱們的方案

咱們業務場景的特殊性,存在用戶可助力次數的限制,因此無需擔憂惡意刷讚的狀況,所以能夠選用方案 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 上,這樣回源的流量就很小了。

災備方案

儘管作了不少準備,仍是沒法確保萬無一失,咱們在每一個關鍵節點都增長了開關,一點出現異常,經過配置中心能夠人工介入作降級處理。

相關文章
相關標籤/搜索