当前位置: 首页 > 后端技术 > PHP

自如2018新年活动系统——抢红包

时间:2023-03-29 22:18:15 PHP

首发于范浩博科学院。2017年是自如快速成长的一年。这一切都得益于广大的自如客户。为回馈自如客户,公司在六周年活动期间发放了6000万租金资金。当然,年末的散币已经够疯狂的了。活动规模既然公司对子如客这么大方,想必我们的员工也很感兴趣,所以我们在年底一共准备了3场活动。1、子如客服务费减免活动;2、子如客1000万现金礼包;3、25万员工红包活动;活动2、3以微信红包的形式进行。想散币就散,但是微信告诉我们,散币是要交税的(>﹏<)。在给员工发红包方面,我25万要交10万多税。这时,我觉得对不起我的钱。好吧,让我们言归正传。技术方案说到红包,我们肯定会想到两种场景:分红包和抢红包。红包拆分是指将指定数量的红包拆分成指定数量的红包的过程,用于确定每个红包的数量;抢红包是典型的高并发场景,需要避免出现超发红包的情况。红包拆分的备选方案拆分方式1、实时拆分实时拆分是指在抢红包时实时计算每个红包的数量,实现红包的拆分过程,对系统性能和拆分算法要求较高,比如在拆分过程中,需要始终保证后续要拆分的红包数量不能为空,要让拆分后的红包数量服从于正态分布规律。2.预生成预生成是指在红包被打开之前,红包的拆分已经完成。抢红包时,拆分的红包只按顺序取出。对拆分算法要求低,随机性好,可以拆分出数量可观的红包,通常结合一个队列。拆分算法我没有找到业界通用的算法,但是红包拆分算法应该是拆分的量看起来是随机的,最好服从正态分布。可以参考微信和@lcode提供的红包拆分算法。微信拆分算法的优点是算法比较简单,拆分效率高。同时,由于算法的天然特性,可以保证后续红包的金额一定不能为空。特别适用于实时分红的场景,缺点是会导致大红包更容易出现在分红结束的时候。@lcode拆分算法的优点是拆分量基本符合正态分布,适用于随机性要求高的拆分场景。我们的解决方案我们这次的业务对红包金额的随机性要求不高,但是对系统的可靠性要求高,所以我们选择了预算生成方式,采用双均值法的红包拆分算法作为我们的红包拆分方案。采用预算生成方式,我们预先生成红包,放入RedisList中。抢红包的时候,就是一个PopList。具体实现将在抢红包章节介绍。拆分算法可以描述为:假设剩余拆分金额为M,剩余要拆分的红包数量为N,红包最小金额为1元,红包最小单位为元,那么当前红包的数量定义为:$$m=rand(1,floor(M/N*2))$$其中,floor表示向下舍入,rand(min,max)表示随机来自[min,max]区间的值。$M/N\ast2$表示剩余金额的平均数的两倍,因为N>=2,所以$M/N\ast2<=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股红包加上之前的平均数额就是最终的红包数额。可选的抢红包方案限流1.前端限流前端限制用户在n秒内只能提交一次请求。这种方法虽然只能屏蔽小白,但是是99%的用户,所以一定要获取Do。2、后端限流常见的后端限流方法有漏桶算法和令牌桶算法。漏桶算法的主要目的是控制请求数据注入的速率。如果此时漏桶溢出,则后续的请求数据将被丢弃。令牌桶算法将令牌以恒定的速度放入桶中,如果请求数据需要处理,需要先从桶中获取令牌。当桶中没有令牌时,处理这些请求。令牌桶算法的好处之一是它可以很容易地改变应用程序接受请求的速率。防过载1.库存锁定可以通过加锁解决资源抢占的问题,但是加锁会增加系统开销,大流量时更容易拖累系统,但是可以尝试基于版本号的乐观加锁。2、通过高速队列序列化请求时会出现超发问题,因为并发时多个进程会同时获取同一个资源。如果使用高速队列对并行请求进行序列化,则问题不存在。使用Redis缓存服务器可以实现高速队列。当然,仅仅使用队列是不够的。需要保证整个流程调用链短而快,否则会导致队列严重积压,甚至会拖累整个服务。在我们方案的限流方面,由于我们预估的请求量还在系统的承受范围内,所以我们没有考虑引入后端限流方案。我们的抢红包系统流程图如下:我们将抢红包分为两个过程:红包持有(过程①,同步)和红包分发(过程②,异步)。它由一组Worker异步完成。高速队列只是完成持有红包的过程,实现库存控制,而Worker处理发红包的耗时过程。当然,在实际应用中,需要在红包占用过程中加入一些前置规则检查,比如用户是否已经收到,红包数量是否达到上限等?红包持有流程图如下:其中,red::list是一个List结构,存放的是预先生成的红包数量(流程①中的红包队列);red::task也是一个List结构,在队列中异步发放红包(进程②队列中的task);red::draw是一个Hash结构,里面存放的是红包领取记录,field是用户的openid,value是序列化后的红包信息;red::draw_count:u:openid是一个k-v结构体,用户收到红包的计数器。接下来我将围绕以下三个问题来谈谈我们设计的抢红包系统。1、如何保证不超发?我们需要注意的是红包持有过程。从红包持有流程图可以看出,这个过程是很多Key操作的组合,那么如何保证原子性呢?可以使用Redis事务,但是我们选择了Lua方案。一方面是因为首先要保证性能,在Redis中嵌入Lua脚本是没有性能瓶颈的。另一方面,Lua脚本执行本身是原子的,符合要求。红包持有Lua脚本实现如下:--收件人openid为xxxxxxxxxxxlocalopenid='xxxxxxxxxxxx'localisDraw=redis.call('HEXISTS','red::draw',openid)--alreadyreceivedifisDraw~=0thenreturntrueend--receivedtoomanytimeslocaltimes=redis.call('INCR','red::draw_count:u:'..openid)iftimesandtonumber(times)>9然后返回0endlocalnumber=redis.call('RPOP','red::list')--没有红包如果不是number则返回{}end--收件人昵称是Fhb,头像是https://xxxxxxxlocalred={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))returntrue需要注意的是Lua脚本执行过程不是事务性的,里面的操作命令脚本是顺序执行的,当一个操作失败时,成功的操作不会回滚,其原子性是通过单线程模型实现的。2、如何提高系统的响应速度如抢红包流程图所示,当用户发起抢红包请求时,如果有红包,则直接完成抢红包操作??,红包是否被抢到,会同步通知用户。此过程需要快速响应。但是由于微信红包支付是第三方调用,如果抢到红包后同步调用红包支付,系统调用链又长又慢,所以红包持有和红包的异步拆分分布是必然的。拆分后红包占有只需要Redis,响应性能不再是问题。3、如何提高系统的处理能力从上面的分析我们可以看出,目前系统的压力会集中在红包的发放上,因为当用户抢到红包的时候,我们只是通知用户红包已经抢到,然后异步发放红包,所以用户不会立即收到红包(受红包发放Worker处理能力和微信服务压力限制)。如果发红包Worker的处理能力较弱,发红包的延迟会很高,体验会很差。如抢红包流程图所示,我们使用一组worker消费任务队列,调用红包支付API,进行数据持久化操作(后续对账)。虽然发红包的调用链又长又慢,但注意到这些worker是无状态的,所以可以通过增加worker的数量来横向扩展来提高系统的处理能力。4、如何保证数据的一致性其实我们可以让用户察觉不到红包发放延迟,但是如果红包发放(流程②)失败,用户已经被告知要抢红包,但是他没有发送它。我猜他有杀人之心。根据CAP原则,我们不能同时满足数据一致性、数据可用性和分区容错性,通常只需要实现数据的最终一致性。为了实现数据的最终一致性,我们引入了重试机制,生成一个全球唯一的外部订单号。当某个红包发放失败时,将其放回任务队列中,以便有机会重试发放。当然,所有这些API都需要是幂等的。Worker可靠性保证Worker可靠性在这里必须单独提到,因为它太重要了。Worker的实现如下:$maxTask=1000;$睡眠时间=1000;while(true){while($red=RedLogic::getTask()){RedLogic::doTask($red);//有多少任务要处理并主动退出$maxTask--;如果($maxTask<0){返回EXIT_CODE_NORMAL;}}//等待任务usleep($sleepTime);}这里使用LPOP命令获取任务,所以使用了while结构,没有任务时需要等待,可以用阻塞命令BLPOP。由于Worker需要运行在常驻内存中,难免会出现异常退出(以及主动退出),所以需要让Worker保持运行状态。我们使用进程管理工具Supervisor来监控Worker的运行状态,同时管理Worker的数量。当任务队列堆积时,只需增加Worker的数量即可。supervisor监控后台如下:员工系统号hash公司员工通过唯一的系统号emp_code(自增字段)标识,登录成功后返回emp_code。系统后续所有的交互过程都是基于emp_code,分享的红包也会携带emp_code,为了保护员工的敏感信息,防止恶意碰撞攻击,我们不能直接将emp_code暴露给前端,需要使用完成交互的令牌(不规则)中介。可选方案1、存储映射关系,总是查询预先生成一个随机字符串token,然后绑定到emp_code上,每次根据token查询emp_code。优点是可以定时更新,比较安全。缺点是性能不高。2、建立映射关系函数,实时计算建立映射关系函数,如hashhash或加解密算法,可以根据emp_code生成不规则字符串token,可以根据token反向映射emp_code.优点是需要存储介质来存储关系,性能高。缺点是很难定期出故障和更新。我们的方案由于我们的红包活动只有几天,所以我们选择了方案2,emp_code应用了hashidshash算法,暴露的只是一串不规则的hash字符串。hashids是一个开源的轻量级唯一id生成器,支持Java、PHP、C/C++、Python等主流语言,如果想在PHP中使用hashids,只需要配合composerrequirehashids安装即可/哈希命令。然后,按如下方式使用它:useHashids\Hashids;$hashids=newHashids('salt',6,'abcdefghijk1234567890');$hashids->encode(11002);//994k2kk$hashids->decode('994k2kk');//[11002]需要注意的是salt是一个很重要的hash加密salt字符串,6表示hash值的最小长度,abcde...7890是hash字典,太长影响效率,太短不安全.由于默认的hash字典比较长,解码效率不高,所以这里去掉大写字母。语音点赞语音点赞是用户以语音的形式帮助朋友。核心技术其实就是语音识别,我们一般使用第三方的语音识别服务。可选方案1、客户端调用第三方服务识别客户端直接调用第三方语音识别服务。比如微信提供了JS-SDK的语音识别API,返回识别到的语音文本信息,已经语义化。优点是识别比较快,不用担心语音存储的问题。缺点是不安全,识别结果在提交给服务器前可能被恶意篡改。2、服务端调用第三方服务识别,先将录制的语音上传到存储平台,然后服务端调用第三方语音识别服务,获取语音信息并进行识别,返回识别后的语音文本信息。优点是识别结果更安全,缺点是系统交互较多,识别效率不高。我们的方案由于我们业务场景的特殊性,用户可以帮助的次数是有限制的,所以不用担心恶意刷赞,所以我们可以选择方案一。语音识别的交互过程是如下:此时,整个语音识别过程如下:汉字当然博大精深。在匹配语音识别文本时,需要考虑容错处理。可以将文字转成拼音再进行拼音匹配,也可以设置匹配百分比。如果达到匹配值,则认为语音密码正确。需要注意的是,微信只提供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"){重写^(.*)$http://wx.t.ziroom.com/index.phplast;}if($auth_redirect~"prod"){rewrite^(.*)$http://wx.ziroom.com/index.phplast;}CDNCache由于本次活动强度大,估计流量会比过去增加很多(机房带宽不能再满了,不然>﹏<),静态页面占了很大一部分流量,所以静态页面时不时的发布一个副本将放在CDN上,这样回源的流量就很小了。虽然灾备计划已经做了很多准备,但仍然无法保证万无一失。我们在每个关键节点都添加了开关。如果出现异常,配置中心可以手动介入进行降级处理。