首发于 樊浩柏科学院
2017 年是自如快速增加的一年,自如客突破 100 万,管理资产达到 50 万间,在年末成功得到了 40 亿 A 轮融资,而这些都要感谢广大的自如客,公司为了回馈自如客,在六周年活动时就发放了 6000 万租住基金,固然年末散币活动也够疯狂。php
既然公司对自如客这么阔,那对咱们员工也得够意思,因此年末咱们共准备了 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 的实现以下:
$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 的监控后台以下:
公司员工都用惟一一个系统号 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 上,这样回源的流量就很小了。
尽管作了不少准备,仍是没法确保万无一失,咱们在每一个关键节点都增长了开关,一点出现异常,经过配置中心能够人工介入作降级处理。