红包方案设计
文章目录
社交红包
设计目标
一般来说,抢红包场景下,至少要考虑下面几点——
-
高性能:主要是为了保证用户体验,即用户能尽快看到结果,尽快把抢到金额加到账户。
-
高可靠:不能超发,红包超发或者促销活动超卖,都会给企业带来损失,所以这点是肯定要保证的。
-
高可用:活动期间保证服务不挂。
背景
微信作为一款国民应用,已经进入每个互联网用户手中,微信支付作为其杀手级功能,在每一次佳节期间都会产生巨大流量,以2017年除夕为例,峰值QPS在76w左右,整个系统核心功能和金融相关,需要做好高可用。
我们先了解下微信红包支付的流程:
一个发红包的流程经过抽象可以得到如下路径:包 -> 发 -> 抢 -> 拆
微信红包的核心知识如下:
- 包红包:系统给每个红包分配一个唯一ID,也就是发红包的订单号,然后将红包发送给用户,红包的个数,红包金额写入到存储。
- 发红包:用户使用微信支付完成付款,微信红包后台收到微信支付成功的通知。红包系统将红包发送订单状态更新,更新为用户已支付,并写入用户发红包记录表,这样用户可以在钱包中找到用户的发红包流水和收发红包的记录,之后微信红包系统调用微信通知,将微信红包信息发送到微信群。
- 抢红包:微信群中的用户收到红包消息之后,点开红包,开始抢红包,这个过程微信红包系统会检查红包是否已经被抢完,是否已经过期,是否已经抢过等验证逻辑。
- 拆红包:拆红包是整个发红包流程最复杂的一个操作,需要查询这个红包的红包订单,判断用户是否可以拆包,计算本次可拆到的红包金额。记录抢红包流水。
包红包:支付前订单落cache,同时利用cache的原子incr操作顺序生成红包订单号。优点是cache的轻量操作,以及减少DB废单。在用户请求发红包与真正支付之间,存在一定的转化率,部分用户请求发红包后,并不会真正去付款。
拆红包:包括查询这个红包发送订单,判断用户是否可拆,然后计算本次可拆到的红包金额。然后写入一条抢红包记录。如果把拆红包过程,类比为一个秒杀活动的过程,相当于扣库存与写入秒杀记录的过程。更新库存对应于更新红包发送订单,写入秒杀记录对应于写入这个红包的领取红包记录。另外,还要写入用户整体的红包领取记录。最后请求微信支付系统给拆到红包用户转入零钱,成功后更新抢红包的订单状态为已转账成功。
为什么要分离抢和拆?
总思路是设置多层过滤网,层层筛选,层层减少流量和压力。这个设计最初是因为抢操作是业务层,拆是入账操作,一个操作太重了,而且中断率高。 从接口层面看,第一个接口纯缓存操作,抗压能力强,一个简单查询Cache挡住了绝大部分用户,做了第一道筛选,所以大部分人会看到已经抢完了的提示。
业务特点
微信红包(尤其是发在微信群里的红包,即群红包)业务形态上很类似网上的普通商品“秒杀”活动。
红包还具备特点:
首先,微信红包业务比普通商品“秒杀”有更海量的并发要求。
微信红包用户在微信群里发一个红包,等同于在网上发布一次商品“秒杀”活动。假设同一时间有10万个群里的用户同时在发红包,那就相当于同一时间有10万个“秒杀”活动发布出去。10万个微信群里的用户同时抢红包,将产生海量的并发请求。
其次,微信红包业务要求更严格的安全级别。
微信红包业务本质上是资金交易。微信红包是微信支付的一个商户,提供资金流转服务。
普通的商品“秒杀”商品由商户提供,库存是商户预设的,“秒杀”时可以允许存在“超卖”(即实际被抢的商品数量比计划的库存多)、“少卖”(即实际被抢的商户数量比计划的库存少)的情况。但是对于微信红包,不能多也不能少。
典型秒杀系统的架构设计
秒杀系统的设计
该系统由接入层、逻辑服务层、存储层与缓存构成。Proxy处理请求接入,Server承载主要的业务逻辑,Cache用于缓存库存数量、DB则用于数据持久化。
一个“秒杀”活动,对应DB中的一条库存记录。当用户进行商品“秒杀”时,系统的主要逻辑在于DB中库存的操作上。
一般来说,对DB的操作流程有以下三步:(同时要求这三步操作需要在一个事务中完成)
- 锁库存
- 插入“秒杀”记录
- 更新库存
“秒杀”系统的设计难点就在这个事务操作上。商品库存在DB中记为一行,大量用户同时“秒杀”同一商品时,第一个到达DB的请求锁住了这行库存记录。在第一个事务完成提交之前这个锁一直被第一个请求占用,后面的所有请求需要排队等待。同时参与“秒杀”的用户越多,并发进DB的请求越多,请求排队越严重。因此,并发请求抢锁,是典型的商品“秒杀”系统的设计难点。
红包业务还有两个突出的难点:
首先,事务级操作量级大。普遍情况下同时会有数以万计的微信群在发红包,这个业务特点映射到微信红包系统设计上,就是有数以万计的“并发请求抢锁”同时在进行。这使得DB的压力比普通单个商品“库存”被锁要大很多倍。
其次,事务性要求严格。微信红包系统本质上是一个资金交易系统,相比普通商品“秒杀”系统有更高的事务级别要求。
秒杀系统的高并发解决方案
使用内存操作替代实时的DB事务操作
将“实时扣库存”的行为上移到内存Cache中操作,内存Cache操作成功直接给Server返回成功,然后异步落DB持久化。
缺点也很明显,在内存操作成功但DB持久化失败,或者内存Cache故障的情况下,DB持久化会丢数据,不适合微信红包这种资金交易系统。
使用乐观锁替代悲观锁
所谓悲观锁,是关系数据库管理系统里的一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作对某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。
所谓乐观锁,它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。
商品“秒杀”系统中,乐观锁的具体应用方法,是在DB的“库存”记录中维护一个版本号。在更新“库存”的操作进行前,先去DB获取当前版本号。在更新库存的事务提交时,检查该版本号是否已被其他事务修改。如果版本没被修改,则提交事务,且版本号加1;如果版本号已经被其他事务修改,则回滚事务,并给上层报错。
这个方案解决了“并发请求抢锁”的问题,可以提高DB的并发处理能力。
但是如果应用于微信红包系统,则会存在下面三个问题:
- 如果拆红包采用乐观锁,那么在并发抢到相同版本号的拆红包请求中,只有一个能拆红包成功,其他的请求将事务回滚并返回失败,给用户报错,用户体验完全不可接受。
- 如果采用乐观锁,将会导致第一时间同时拆红包的用户有一部分直接返回失败,反而那些“手慢”的用户,有可能因为并发减小后拆红包成功,这会带来用户体验上的负面影响。
- 如果采用乐观锁的方式,会带来大数量的无效更新请求、事务回滚,给DB造成不必要的额外压力。
基于以上原因,微信红包系统不能采用乐观锁的方式解决并发抢锁问题。
红包系统的系统架构
架构包括微信统一接入层,下面是微信红包系统API,包括发、抢、拆、查红包详情、查红包用户列表。再下面是封装微信红包关键业务的逻辑服务;最下面一层是数据存储层,微信红包最主要的数据是订单数据,包括发红包订单和拆红包订单两部分。业务逻辑和存储服务器之间是数据接入层,它最重要的作用是封装数据库操作的领域逻辑,使得业务逻辑服务不需要感知对MySQL的连接管理、性能、容灾等问题。
微信红包数据的访问热度,随着时间流逝会急剧降低,也就是数据的访问时间段非常集中,一般红包发出三天后,99%的用户不会再去点开这个红包了。因此微信红包系统采取按时间做冷热数据分离,降低数据的存储成本,同时提升了热数据的访问性能。
数据平台用于对红包数据的分析计算,比如朋友圈的文章,统计从2016年1月1日到2017年1月一个用户总共抢红包的金额,在全国的排名情况,发红包数最多的城市等。
红包系统的高并发解决方案
异地多活单元化
看题目的性能指标,百万级的 TPS。如果流量入口是同一个的话那么肯定压力很大,用 Nginx 做负载均衡是顶不住的,要考虑 F5 这种商业设备。
Nginx 的性能是万级,一般的 Linux 服务器上装个 Nginx 大概能到 5 万/秒;LVS 的性能是十万级,据说可达到 80万/秒;F5 性能是百万级,从 200 万/秒到 800 万/秒都有。
如果我们不想用这种商业负载均衡设备,或者想尽量减少网络延迟,那么可以这样设计。
直接把红包根据某种规则拆分好,放在不同的机房。不同地区的用户,在活动开始前就已经分配好了机房,可以是用 HTTPDNS 或者不同的域名来实现机房流量调度。
当用户抢红包或者查询红包结果时,只需在本地机房做处理就可以,最后再把结果通过 MQ 异步/同步到账户服务,完成最后一步。
当然,这里对业务是做出了一定牺牲的,红包金额同步到用户账户有一定延迟,用户红包没办法马上入账。
这样处理,我们就可以把 TPS 的数量级降下来。如果机房足够多,甚至以后边缘计算发展起来,后端抢红包服务基本上都不需要太关注大流量的问题了。
微信用户在国内有深圳、上海两个接入点,习惯性称之为南、北(即深圳为南,上海为北)。用户请求接入后,不同业务根据业务特性选择部署方式。微信红包在信息流上可以分为订单纬度与用户纬度。其中订单是贯穿红包发、抢、拆、详情列表等业务的关键信息,属于交易类信息;而用户纬度指的是红包用户的收红包列表、发红包列表,属于展示类信息。红包系统在架构上,有以下几个方面:
- 订单层南北独立体系,数据不同步:用户就近接入,请求发红包时分配订单南北,并在单号打上南北标识。抢红包、拆红包、查红包详情列表时,接入层根据红包单号上的南北标识将流量分别引到南北系统闭环。根据发红包用户和抢红包用户的所属地不同,有以下四种情况:
- 深圳用户发红包,深圳用户抢:订单落在深圳,深圳用户抢红包时不需要跨城,在深圳完成闭环。
- 深圳用户发红包,上海用户抢:订单落在深圳,上海用户抢红包,在上海接入后通过专线跨城到深圳,最后在深圳闭环完成抢红包。
- 上海用户发红包,上海用户抢:订单落在上海,上海用户抢红包时不需要跨城,在上海完成闭环。
- 上海用户发红包,深圳用户抢:订单落在上海,深圳用户抢红包,从深圳接入后通过专线跨城到上海,最后在上海闭环完成抢红包。
系统这样设计,好处是南北系统分摊流量,降低系统风险。
- 用户数据写多读少,全量存深圳,异步队列写入,查时一边跨城
- 用户数据的查询入口,在微信钱包中,隐藏的很深。这决定了用户数据的访问量不会太大,而且也被视为可旁路的非关键信息,实时性要求不高。因此,只需要在发红包、拆红包时,从订单纬度拆分出用户数据写入请求,由MQ异步写入深圳。后台将订单与用户进行定时对账保证数据完整性即可。
- 支持南北流量灵活调控
- 红包系统南北分布后,订单落地到深圳还是上海,是可以灵活分配的,只需要在接入层上做逻辑。例如,可以在接入层中,实现让所有红包请求,都落地到深圳(无论用户从上海接入,还是深圳接入),这样上海的红包业务系统将不会有请求量。提升了红包系统的容灾能力。同时,实现了接入层上的后台管理系统,实现了秒级容量调控能力。可根据南北请求量的实时监控,做出对应的调配。
- DB故障时流量转移能力基于南北流量的调控能力,当发现DB故障时,可将红包业务流量调到另外一边,实现DB故障的容灾。
红包订单存储SET化
下图是2014年微信红包存储层的模型。业务逻辑层请求数据层操作时,使用订单号hash路由到订单 SERVER。订单 SERVER与每一组 MYSQL数据库连接。
微信红包的订单号是在发红包时系统生成唯一标识,使用序列号服务生成唯一ID,后面拼接三位微信红包的订单分库表的标识。所以,总共可以分一百个逻辑库,每个逻辑库含有十张表。一百个逻辑库均匀地分布到十组物理DB,每组DB存十个逻辑库。
这个架构的最大问题是,一组DB故障时,会影响其他DB。2014-2015年期间,微信红包量张得特别快,扩容速度跟不上业务增长速度。一组DB的性能出现瓶颈时,数据操作变慢,拆红包的事务操作在 MYSQL排队等待。由于所有十组DB机器与所有的订单 SERVER连接,导致所有的订单 SERVER都被拖住,从而影响红包整体的可用性。这个架构的另一个问题是扩容不方便,后面会介绍。
为解決DB间的相互影响,需要将DB间相互隔离,订单存储层SET化。SET化指订单DB和订单接入 SERVER垂直 stick起。业务逻辑层访问订单时,根据订单倒数第二、三位数字找到所属订单SET,一个SET的请求不能路由到其他SET。
找到对应的订单接入服务器之后,在服务器内的多个进程中找到指定进程,让同个红包的所有拆请求串行化。当一组DB出现故障,只会影响该组DB对应的 SERVER。
微信红包用户发一个红包时,微信红包系统生成一个ID作为这个红包的唯一标识。接下来这个红包的所有发红包、抢红包、拆红包、查询红包详情等操作,都根据这个ID关联。
一个红包一条数据,数据上有一个计数器字段。
红包系统根据这个红包ID,按一定的规则(如按ID尾号取模等),垂直上下切分。切分后,一个垂直链条上的逻辑Server服务器、DB统称为一个SET。
各个SET之间相互独立,互相解耦。并且同一个红包ID的所有请求,包括发红包、抢红包、拆红包、查详情详情等,垂直stick到同一个SET内处理,高度内聚。通过这样的方式,系统将所有红包请求这个巨大的洪流分散为多股小流,互不影响,分而治之,如下图所示。
这个方案解决了同时存在海量事务级操作的问题,将海量化为小量。
这里有一个问题,DB故障拖住某些订单 SERVER,会不会也拖住更上层业务逻辑服务?业务逻辑层为什么不ー起SET化?业务逻辑层承载了用户维度相关的业务操作,不可以按照订单的维度分业务逻辑,例如务逻辑层会请求用户的头像、昵称等,如果继续按照订单分业务逻辑,会导致跨地域调用。
一组DB故障不会影响整个系统的可用性。有影响的,只有十分之一,若扩成100组,影响便只有一百分之ー。所以通过SET化得到的好处是,控制DB连接数、隔离故障影响和分流并发
单机请求排队
红包系统是资金交易系统,DB操作的事务性无法避免,所以会存在“并发抢锁”问题。但是如果到达DB的事务操作(也即拆红包行为)不是并发的,而是串行的,就不会存在“并发抢锁”的问题了。
由于红包活动表增加乐观锁冲突很大,所以可以考虑使用使用悲观锁:select * from t_redpack_activity where id = #{id} for update
,注意悲观锁必须在事务中才能使用。此时,所有的抢红包行为变成了串行。此种情况下,悲观锁的效率远大于乐观锁。
按这个思路,为了使拆红包的事务操作串行地进入DB,只需要将请求在Server层以FIFO(先进先出)的方式排队,就可以达到这个效果。从而问题就集中到Server的FIFO队列设计上。
微信红包系统设计了分布式的、轻巧的、灵活的FIFO队列方案。其具体实现如下:
首先,将同一个红包ID的所有请求stick到同一台Server。
上面SET化方案已经介绍,同个红包ID的所有请求,按红包ID stick到同个SET中。不过在同个SET中,会存在多台Server服务器同时连接同一台DB(基于容灾、性能考虑,需要多台Server互备、均衡压力)。
为了使同一个红包ID的所有请求,stick到同一台Server服务器上,在SET化的设计之外,微信红包系统添加了一层基于红包ID hash值的分流,如下图所示。
其次,设计单机请求排队方案。
将stick到同一台Server上的所有请求在被接收进程接收后,按红包ID进行排队。然后串行地进入worker进程(执行业务逻辑)进行处理,从而达到排队的效果,如下图所示。
异步化
信息流与资金流分离。拆红包时,DB中记下拆红包凭证,然后异步队列请求入账。入账失败通过补偿队列补偿,最终通过红包凭证与用户账户入账流水对账,保证最终一致性。
这个架构设计,理论基础是快慢分离。红包的入账是一个分布事务,属于慢接口。而拆红包凭证落地则速度快。实际应用场景中,用户抢完红包,只关心详情列表中谁是“最佳手气”,很少关心抢到的零是否已经到账。因为只需要展示用户的拆红包凭证即可。
如上图所示,微信红包的某些步骤不实时完成也不会影响用户对红包业务可用性的体验。比如拆红包,正常的业务流程很长,但关键步骤只有订单相关的几步。至于转零钱、写红包记录等操作不需要实时。用户抢到红包时,一般不会实时去钱包查看微信零钱,而是在微信群中点开消息查看本次抢到的金额和他人抢红包金额。所以拆红包时只需要从cache查询用户是否拆过红包,然后写入拆红包的订单记录,更新发红包订单,其他的操作都可以优化。当然,不是每个业务都可以进行异步优化,需要进行业务分析,判断是否存在非关键步骤之外的事情可以将其异步化,并通过异步对账保证最终一致。
拆红包在数据库完成,通过数据库的事务操作累加已经领取的个数和金额,插入一条领取流水,入账为异步操作,这也解释了为啥在春节期间红包领取后在余额中看不到。拆的时候会实时计算金额,其金额为1分到剩余平均值2倍之间随机数,一个总金额为M元的红包,最大的红包为 M * 2 /N(且不会超过M),当拆了红包后会更新剩余金额和个数。财付通按20万笔每秒入账准备,实际只到8万每秒。
发拆落地,其他操作双层Cache
- Cache住所有查询,两层cache
- 除了使用ckv做全量缓存,还在数据访问层dao中增加本机内存cache做二级缓存,cache住所有读请求。
- 查询失败或者查询不存在时,降级内存Cache;内存Cache查询失败或记录不存在时降级DB。
- DB本身不做读写分离。
- DB写同步cache,容忍少量不一致
- DB写操作完成后,dao中同步内存cache,业务服务层同步ckv,失败由异步队列补偿,定时的ckv与DB备机对账,保证最终数据一致。
抢红包分为抢和拆,抢操作在Cache层完成,通过原子减操作进行红包数递减,到0就说明抢光了,最终实际进入后台拆操作的量不大,通过操作的分离将无效请求直接挡在Cache层外面。这里的原子减操作并不是真正意义上的原子减操作,是其Cache层提供的CAS,通过比较版本号不断尝试,存在一定程度上的冲突,冲突的用户会放行,让其进入下一步拆的操作,这也解释了为啥有用户抢到了拆开发现领完了的情况。
cache会抵抗无效请求,将无效的请求过滤掉,实际进入到后台的量不大。cache记录红包个数,原子操作进行个数递减,到0表示被抢光。财付通按照20万笔每秒入账准备,但实际还不到8万每秒。抢到红包的人数和红包都在一条cache记录上,没有太大的查询压力。
说到Cache的选择,第一反应,会想到 Redis 计数,红包个数减到0 表示红包抢完。但是有个问题,Redis 有 decr 原子递减,redis 原子递减会变成负数,还是有超抢问题。
Redis 内嵌了lua 支持,解决了长久以来多个命令组合的问题。有点类似事务,有一定原子性,可以用完成一定事务性操作,可以将扣减操作写在lua脚本里,然后 Redis 去执行,扣减到0 返回 false。这样避免超抢超卖。
但是随之而来又有一个问题? Redis 并发很高么,上亿用户同时请求怎么办 ,QPS 怎么也要到10w。
那就得 对 Redis 做文章:
Redis 集群,主从同步,读写分离。
为了防止Server中的请求队列过载导致队列被降级,从而所有请求拥进DB,系统增加了与Server服务器同机部署的memcached,用于控制拆同一个红包的请求并发数。
Dao搭建本机Memcache内存cache,控制同一红包并发个数,具体来说,利用memcached的CAS原子累增操作,控制同时进入DB执行拆红包事务的请求数,超过预先设定数值则直接拒绝服务。用于DB负载升高时的降级体验。
在DB的接入机dao中,搭建本机内存cache。以红包订单号为key,对同一个红包的拆请求做原子计数,控制同一时刻能进DB中拆红包的并发请求数。
这个策略的实施,依赖于请求路由按红包订单hash值走,确保同一红包的所有请求路由到同一逻辑层机器。
DB简化
订单表只存关键字段,其他字段只在cache中存储,可柔性。
红包详情的展示中,除了订单关键信息(用户、单号、金额、时间、状态)外,还有用户头像、昵称、祝福语等字段。这些字段对交易来说不是关键信息,却占据大量的存储空间。
将这些非关键信息拆出来,只存在cache,用户查询展示,而订单中不落地。这样可以维持订单的轻量高效,同时cache不命中时,又可从实时接口中查询补偿,达到优化订单DB容量的效果。
冷热数据分离
红包系统的分库表规则,初期是根据红包ID的hash值分为多库多表。随着红包数据量逐渐增大,单表数据量也逐渐增加。而DB的性能与单表数据量有一定相关性。当单表数据量达到一定程度时,DB性能会有大幅度下降,影响系统性能稳定性。采用冷热分离,将历史冷数据与当前热数据分开存储,可以解决这个问题。
处理微信红包数据的冷热分离时,系统在以红包ID维度分库表的基础上,增加了以循环天分表的维度,形成了双维度分库表的特色。
具体来说,就是分库表规则像db_xx.t_y_dd(.前面是库,后面是表)设计,其中,xx/y是红包ID的hash值后三位,dd的取值范围在01~31,代表一个月天数最多31天。
通过这种双维度分库表方式,解决了DB单表数据量膨胀导致性能下降的问题,保障了系统性能的稳定性。同时,在热冷分离的问题上,又使得数据搬迁变得简单而优雅。
综上所述,微信红包系统在解决高并发问题上的设计,主要采用了SET化分治、请求排队、双维度分库表等方案,使得单组DB的并发性能提升了8倍左右,取得了很好的效果。
可用性保证
系统可用性影响因素
系统的可用性影响因素可分成两类,一类计划外,一类计划内。计划外包含很多因素,系统用到的所有东西都可能产生故障,都可能成功影响可用性的因素。从这个角度上来讲,可以说故障是无法避免的,系统的运作一定会产生故障,尤其是服务器有成干上万个的时候。计划内的影响因素,主要有与升级相关、运维相关的操作,以及日常的备份等。这一类影响因素,通过精细地设计方案,是可以避免对可用性造成影响的。
设计方向
基于上面两个分析结论,可以总结出微信红包后台系统的可用性的设计方向。就是在不能避免意外故障的情况下,尽可能降低出现意外故障时对可用性的影响。另一方面,绝大多数计划内的日常维护可以通过方案的设计避免影响可用性,其中平行扩容特指关于存储层的平行扩容。
下面从降低故障影响和信红包系统的平行扩容两方面进行分析。首先是降低意外故障的影响,重点讲解订单存储层在订单DB故障的情况下如何降低对红包系统可用性的影响。
部署方案
首先是业务逻辑层的部署方案。业务逻辑层是无状态的,微信红包系统的业务逻辑层,部署在两个城市,即两地部署,每一个城市部署至少三个园区,即三个IDC。并且每个服务需要保证三个IDC的部署均衡。另外,三个IDC总服务能力需要冗余三分之一,当一个IDC出现故障时,服务能力仍然足够。从而达到IDC故障不会对可用性产生影响。
存储层自愈
微信红包系统采取的方案是,在订单 SERVER服务端增加快速拒绝服务的能力。 SERVER主动监控DB的性能情况,DB性能下降、自身的CPU使用升高,或者发现其他的监控维度超标时,订单 SERVER直接向上层报错,不再去访问DB,以此保证业务逻辑层的可用性。
如果一个SET发生了故障,会导致业务层写发红包订单失败,这时生成另一个SET订单号重试。同时统计DB失败次数,达到某个阈值时报警。监控单位时间内每个逻辑表的错误数,超过阈值后,通知订单生成系统屏蔽该号段,业务逻辑层重新生成红包id重试,对于已发的红包,没有增量,需要等机器恢复后超时退款。
如上图所示,所设尾号90-99的SET故障时,如果业务逻辑服务后续不再生成属于这SET的订单,那后续的业务就可以逐渐恢复。也就是在发生故障时,业务逻辑层发布一个版本,屏蔽故障号段的单号生成,就可以恢复业务。
进一步想,除了人为发版本,有没有方法可以让DB故障时自动恢复?在DB故障导致业务失败时,业务逻辑层可获取到故障DB的号段,在发红包时,将这些故障的号段,換一个可用的号段就可恢复业务。订单号除了最后三位,前面的部分已能保证该红包唯一性,后面的数字只代表着分库表信息,故障时只需要将最后三位換另外一个SET便可自动恢复。
完成这个设计后,即使DB出现故障,业务的可用性也不会有影响。这里还有一点,新的发红包请求可避免DB故障的影响,但那些故障之前已发出未被领取的红包,红包消息已发送到信群,单号已确定,拆红包时还是失败。对这种情况,由于不会有增量,采用正常的主备切換解決即可。
扩缩容设计
下图是微信红包早期的扩缩容方式。这个扩容方式,对扩容的机器数有限制。前面讲到,红包是按红包单号后面两个数字分多SET,为了使扩容后数据保持均衡,扩容只能由10组DB扩容到20组、50组或者100组。另外,这个扩容方式,过程也比较复杂。首先,数据要先从旧数据库同步复制到新扩容的DB,然后部署DB的接入 SERVER,最后在凌晨业务低峰时停服扩容。
这个扩容方式的复杂性,根本原因是数据需要从旧SET迁到新SET。如果新产生数据与旧数据没关系,那么就可以省掉这部分的迁移动作,不需停服。
分析发现,需要把旧数据迁出来的原因是订单号段00-99已全部被用,每个物理数据库包含了10个逻辑库。如果将订单号重新设计,预留三位空间,三位数字每一个代表独立的物理DB,原来10组DB分别为000-009号段。这种设计,缩容时,比如要缩掉000这组,只需在业务逻辑服务上不生成订单号为000的红包订单。扩容时,比如扩为11组,只需多生成010的订单号,这个数据便自动写入新DB。当然,缩容需要一个前提条件,也就是冷热分离,缩容后数据变为冷数据,可下线冷数据机器。以上就是红包的平行扩缩容方案。
降级方案
系统到处存在发生异常的可能,需要对所有的环节做好应对的预案。下面列举微信红包对系统异常的主要降级考虑。
下单cache故障降级DB
下单cache有两个作用,生成红包订单与订单缓存。缓存故障情况下,降级为直接落地DB,并使用id生成器独立生成订单号。
抢时cache故障降级DB
抢红包时,查询cache,拦截红包已经抢完、用户已经抢过、红包已经过期等无效请求。当cache故障时,降级DB查询,同时打开DB限流保护开关,防止DB压力过大导致服务不可用。
另外,cache故障降级DB时,DB不存储用户头像、用户昵称等(上文提到的优化),此时一并降级为实时接口查询。查询失败,继续降级为展示默认头像与昵称。
拆时资金入账多级柔性
拆红包时,DB记录拆红包单据,然后执行资金转账。单据需要实时落地,而资金转账,这里做了多个层级的柔性降级方案:
大额红包实时转账,小额红包入队列异步转账 所有红包进队列异步转账 实时流程不执行转账,事后凭单据批量入账。
总之,单据落地后,真实入账可实时、可异步,最终保证一致即可。
用户列表降级
用户列表数据在微信红包系统中,属于非关键路径信息,属于可被降级部分。
首先,写入时通过MQ异步写,通过定时对账保证一致性。
其次,cache中只缓存两屏,用户查询超过两屏则查用户列表DB。在系统压力大的情况下,可以限制用户只查两屏。
调整后的系统经过了16年春节的实践检验,平稳地度过了除夕业务高峰,保障了红包用户的体验。
异步对账
数据平台的一个作用就是对账,红包的订单和微信支付的订单需要对账,以保证最终资金的一致性;订单的数据和订单的cache需要做对账,以保证数据的完整性;订单数据和用户的收发记录需要对账,以保证用户列表完整性。
流水系统设计
流水系统用于保存活动过程中的抽奖流水记录,在活动后对奖品发放和领用进行统计和对账。该系统还定时对领用失败的请求进行重做和对账,确保奖品发放到用户账户里。
流水系统架构如下:
由于流水需要记录用户中奖的信息和领用的的情况,数据量巨大,所以抽奖逻辑层本地采用顺序写文件的方式进行记录。抽奖逻辑层会定期的把本地的流水文件同步到远程流水系统进行汇总和备份,同时,流水系统会对领用失败的流水进行重做,发送请求到抽奖逻辑层,抽奖逻辑层会调用发货系统的接口完成发货操作。
红包拆分算法
在线拆分,实时效率更高,预算才效率低下。预算还要占额外存储。因为红包只占一条记录而且有效期就几天,所以不需要多大空间。就算压力大时,水平扩展机器。
拆分算法是:
- 每次拆分金额是 random(0.01, min(2*(total/n), total-(n-1)*0.01))
- 最后一个人,take all
首先,如果红包只有一个,本轮直接使用全部金额,确保红包发完。
然后,计算出本轮红包最少要领取多少,才能保证红包领完,即本轮下水位;轮最多领取多少,才能保证每个人都领到,即本轮上水位。主要方式如下:
- 计算本轮红包金额下水位:假设本轮领到最小值1分,那接下来每次都领到200元红包能领完,那下水位为1分;如果不能领完,那按接下来每次都领200元,剩下的本轮应全部领走,是本轮的下水位。
- 计算本轮红包上水位:假设本轮领200元,剩下的钱还足够接下来每轮领1分钱,那本轮上水位为200元;如果已经不够领,那按接下来每轮领1分,计算本轮的上水位。
- 为了使红包金额不要太悬殊,使用红包均值调整上水位。如果上水位金额大于两倍红包均值,那么使用两倍红包均值作为上水位。换句话说,每一轮抢到的红包金额,最高为两倍剩下红包的均值。
- 最后,获取随机数并用上水位取余,如果结果比下水位还小,则直接使用下水位,否则使用随机金额为本轮拆到金额。
举例:随机,额度在0.01和剩余平均值2之间。例如:发100块钱,总共10个红包,那么平均值是10块钱一个,那么发出来的红包的额度在0.01元~20元之间波动。
当前面3个红包总共被领了40块钱时,剩下60块钱,总共7个红包,那么这7个红包的额度在:0.01~((60/7) *2)=17.14之间。
注意:这里的算法是每被抢一个后,剩下的会再次执行上面的这样的算法。
这样算下去,会超过最开始的全部金额,因此到了最后面如果不够这么算,那么会采取如下算法:保证剩余用户能拿到最低1分钱即可。
如果前面的人手气不好,那么后面的余额越多,红包额度也就越多,因此实际概率一样的。
为了保证每次操作的原子性,拆包过程中使用了CAS,确保每次只有一个并发用户拆包成功。拆包CAS失败的用户可以由系统自动进行重试。但也有可能在重试过程中被别的用户抢得先机而空手而归,因此严格意义拆包的调用也未能保证用户先到先得。
每抢到一个红包:
- 更新红包剩余金额和剩余个数
- 插入一条领取记录
- 异步进行入账
微信从财付通拉取金额数据过来,生成个数/红包类型/金额放到redis集群里,app端将红包ID的请求放入请求队列中,如果发现超过红包的个数,直接返回。根据红包的逻辑处理成功得到令牌请求,则由财付通进行一致性调用,通过像比特币一样,两边保存交易记录,交易后交给第三方服务审计,如果交易过程中出现不一致就强制回归。
为了防止红包个数没了,但余额还有的情况,最后会有一个take all操作.
单元化的优势
在前一家公司工作的时候,碰到过 A 家来的人说稳定性问题可以用单元化来解决,所以进了 A 家,单元化也是我重点关注的问题。
单元化的方案原理比较容易理解,用户 id 按照常数做 hash,比如 100,那么在逻辑上将所有业务系统都拆成 100 份(部署单元和逻辑单元有个映射关系,可以根据业务规模再做调整),后续使用 user id 的后两位对 100 求模,决定用户的流量发往哪个单元。
A 家应该是比较早做单元化的,他们会去帮一些比较传统的金融机构做架构改造和咨询,所以本身方案是对外开放的,在阿里云上可以找得到相关的单元化介绍。
单元化本身也被称为 set 化架构,或 bulkheads 模式,其本质是通过架构上的设计,消灭了很多高并发场景下难以解决的问题。
我可以举个例子,无状态的系统研发经常会说横向扩展很容易,只要加机器就好了。但他们忽略了一个比较重要的问题,服务粒度的变化和拆分会导致线上的实例数膨胀 -> 进而连接数膨胀,这意味着你不可能无穷无尽地把你的服务拆分下去。如果读者是写 Go 的,你可以认为在优化过的系统中,一条长连接至少也对应一个 goroutine,这个是要占内存的(数量越多,GC、调度的成本也越大)。即使无状态的服务不需要考虑这个问题,在数据库、数据库的 proxy 层也不可能抗得住无穷无尽膨胀下去的长连接。
这些巨头的服务规模一般都比较大,按照之前 A 家的 registry 和 mesh 的公开宣传数据,线上至少有百万级别的 pod。即使除去非核心服务,主链路上的服务有几十万实例也是很正常的。如果不做单元化,每个服务都需要处理海量连接,高并发这种业务无关的问题。
游戏领域的分区和这种单元化稍微有一点类似,不过分区对用户来说并不透明,基本都需要玩家手动选择进入的“大厅”。
单元化的架构改造其实没那么容易,A 家这里能够比较顺利的原因我认为主要有下面这一些:
- 统一的 api 框架:虽然技术人员众多,但他们内部的框架相对来说是统一的,至少在同一个 bu 内一定是统一的,单元化的 sharding 规则需要在各个模块中都能保持一致,如果每个组一个框架,那这个前提就不太成立了。
- 稳定性的强需求:每一家互联网公司都在叫唤高可用,但每一家的高可用需求是不一样的,比如很多公司都在讲异地多活,但他们的跨机房专线断掉的话,业务就直接瘫痪了。这种就是典型的假多活。金融相关的公司,国家有很高的稳定性需求,这个糊弄不了。据说服务中断半小时就要去主管部门喝茶了。
- 基础设施的高投入:这个可以拿上一家公司来做对比,同样的一个比较细分的 inf 方向,在 A 家的人数可能能达到我前一家公司的 4 倍以上。人数优势可以让他们更多地做一些平台上的工作或者更前沿的探索。
这三点对于单元化来说都是必须的,中小规模的公司想要做同样的事情就很难了:
- 很多公司的技术栈分裂很严重,即使是一点很小的全局改动也要拉上全链路的人撕逼出方案,这种需求三个月到半年看不到产出也是很正常的。不同的组之间可能用的都不是同一个框架,如果是写 Go 的地方,因为造一套自己的框架也不是很难,很多人就倾向于不使用公司的框架,而是自己造。给后续的架构改造留下了巨大的隐含成本。
- 嘴上都在说稳定性,没出故障的时候大家都是 99.99%,出了故障就变成 3 个 9 了。如果没有强制力来约束,稳定性是个薛定谔问题。比如没有碰到过跨机房专线被挖断这种事情,这些公司不会把处理这种问题当作架构设计的必选项。
- 有些公司的业务模型是低毛利模型,即使都是对基础设施投入 10%,不同的公司的 10% 差距也很大,现代的软件研发是团队工作,研发人员又不傻,1 个人不可能干得了 10 个人的活儿。
全局的架构改造需要自上而下推动,需要有人主导,有人背锅,整个改造过程也需要不小的成本。如果只是一线的技术人员,看看就好了。
总结
红包系统业务特点:海量的并发要求和更严格的安全级别。
设计要点:
- 对于高并发,采用系统SET化设计,分而治之;逻辑Server层将请求分组、排队串行化、并发控制,解决DB并发问题;采用冷热数据分离,保障系统性能稳定。
- 对于高可用:采用多IDC分地域的部署设计;异步化设计将拆红包和金额到账等业务进行异步分离;通过版本控制来屏蔽故障号段的订单生成,来实现DB故障自愈能力建设。
- 对于可扩展性:平行缩扩容设计,尽量避免迁移SET。
春节红包
零RPC调用
如上图所示,摇那个手机的时候会通过客户端发出一个请求,接入服务器,然后摇一摇服务,进行等级判断,判断以后把结果给到后端,可能摇到拜年或红包,假设摇到红包,上面有LOGO和背景图,客户端把这个LOGO和背景图拉回去,用户及时拆开红包,拆的请求会来到红包系统,红包系统进行处理之后会到支付系统,到财富通的转帐系统,最终用户拿到红包。
请求量很大,1000万每秒,如何转到摇一摇服务,摇一摇服务也面临一千万请求量,我们系统要同时面对两个一千万请求量,这不是靠机器的,大家都有分布式的经验,这么大请求量的时候任何一点波动都会带来问题,这是一个很大的挑战。
所有用户的请求都会进入到接入服务器,我们建立了18个接入集群,保证如果一个出现问题的时候用户可以通过其它的接入。
但是在我们内部怎么把请求转给摇一摇服务,摇一摇处理完还要转到后端,怎么解决呢?
解决这个问题代价非常大,需要很多资源,最终我们选择把摇一摇服务去掉,把一千万每秒的请求干掉了,把这个服务挪入到接入服务。除了处理摇一摇请求之外,所有微信收消息和发消息都需要中转,因为这个接入服务本身,摇一摇的逻辑,因为时间比较短,如果发消息也受影响就得不偿失了。
不过,这恰好有一个好处,我们的接入服务的架构是有利于我们解决这个问题的。
在这个接入节点里分为几个部分。一个是负责网络IO的,提供长链接,用户可以通过长链接发消息,回头可以把请求中转到另外一个模块,就是接入到逻辑模块,平时提供转发这样的功能,现在可以把接入逻辑插入。这样做还不够,比方说现在做一点修改,还需要上线更新,摇一摇的活动形式没有怎么确定下来,中间还需要修改,但是上线这个模块也不大对,我们就把接入的逻辑这一块再做一次拆分,把逻辑比较固定、比较轻量可以在本地完成的东西,不需要做网络交互的东西放到了接入服务里。
另外一个涉及到网络交互的,需要经常变更的,处理起来也比较复杂的,做了个Agent,通过这种方式基本上实现了让接入能够内置摇一摇的逻辑,而且接入服务本身的逻辑性不会受到太大的损伤。解决这个问题之后就解决了接入的稳定性问题,
零数据库存储
如果在除夕当天摇的过程中按前边提到的超级复杂的配置方案即时生成随机红包,这显然是风险齐高逻辑奇复杂的。对待只许成功不许失败的项目,主流程必须极简高效,所以这里全部的资金和红包数量都需要按方案规则提前切分和准备好。
将预生成好的红包数据(预红包数据)进行准确的部署,摇红包的资金和红包准备的整体流程方案有两个选择。
方案一:预红包数据提供部署给微信的接入机和写入红包 DB,摇红包过程由红包接入机控制红包的发放,拆红包时修改红包 DB 中的红包数据;
方案二:预红包数据只提供部署给微信接入机,摇红包过程由红包接入机控制红包的发放,拆红包直接 Insert 到红包 DB。
第二个方案减少一次 DB 操作,如果是百亿量级的红包数据,可以极大减少数据导入、对账等活动准备时间,特别是方案需要变更时。
因为超高并发场景下动态生成金额是有风险的,而且是风险极高的。若是超发红包,以 3kw QPS 的情况,这个金额是不堪想象的。按一般的系统实现,用户看到的红包在系统中是数据库中的数据记录,抢红包就是找出可用的红包记录,将该记录标识为属于某个用户。在这种实现里,数据库是系统的瓶颈和主要成本开销。我们在这一过程完全不使用数据库,可以达到几个数量级的性能提升,同时可靠性有了更好的保障。
- 支付系统将所有需要下发的红包生成红包票据文件 ;
- 将红包票据文件拆分后放到每一个接入服务实例中;
- 接收到客户端发起摇一摇请求后,接入服务里的摇一摇逻辑拿出一个红包票据,在本地生成一个跟用户绑定的加密票据,下发给客户端;
- 客户端拿加密票据到后台拆红包,后台的红包简化服务通过本地计算即可验证红包,完成抢红包过程。
因此,我们使用空间换时间,预先生成红包的金额与数量,不动态生成。其好处,不仅可以提前对红包的金额的分布有所了解,也可以根据运营特殊化定制特殊红包。
红包大量的资金就意味着如此大量的诱惑,会不会出问题呢?
- 方案预红包数据未提前落地 DB,导致拆红包时缺少一次红包数据有效性的检验;
- 预红包数据存放在微信接入机上,存在被攻陷获取或篡改的可能;
- 红包数据在传输的过程中存在系统异常或恶意攻击,导致数据错误特别是金额错误的可能;
- 系统内部可能存在恶意人员直接调用拆红包的接口写入不存在的红包。
墨菲定律要求我们必须重视以上安全隐患,解决方案就是加密——对预红包数据进行加密,加密库和解密库独立维护保证密钥不被泄漏,通过工具生成预红包数据时用密钥进行加密,预红包数据在部署存储和传输的过程中保持加密状态,仅在拆红包的逻辑中编译二进制加密库进行解密。
同时,鸡蛋也不能放在一个篮子里,为了控制密钥泄漏导致的影响,确保资金风险可控,整个预生成的红包数据可能分别使用了几百到几千个密钥进行加密,一个密钥加密的红包资金量在 20~30 万。解密库还需要能设置密钥 ID 的白名单和黑名单,确保未被使用的密钥不能被利用,确认泄漏的密钥可以进行拦截。
如果是百亿个红包,那么产生预红包数据文件的大小不经过压缩是非常恐怖的,传输部署几百 GB 或几 TB 的数据是很艰苦的,一旦发生调整变更会变得非常艰难,所以需要对数据进行极限的压缩。
数据进行极限的压缩的实施内容:
- 对于支付单号、商户号、红包账户等信息,由工具导成配置文件,配置到拆红包逻辑中,加密的红包数据中仅用一个批次 ID 表达;
- 拆分红包 ID,部分分段同样转为 ID,解密库解密后利用配置进行还原;
- 加密部分(Ticket):红包 ID、金额、批次 ID、密钥 ID,压缩到 16 字节;
- 单条红包记录二进制表达,压缩到 26 字节。
然后这些信息通过压缩、AES(对称)加密后,变成一个 60 位的 Token。
|
|
为什么要将红包的信息存放在 token 里面呢?客户端和服务端进行双端冗余,而且解密这个 Token 不强依赖于数据库或者缓存,直接在 API 层就可以将红包进行拆包。
红包发放是在接入那里发出去的,红包不参与发放。用户拆红包的动作是要进入红包系统的,我们这里放过去的量在每秒钟五万,就是放出去,让用户摇到红包五万,红包系统本身他们预设处理十秒以上。
我们知道,当并发量大的时候,如果代码处理的不好,那么就有很大的几率出现「一号多人」的情况。有几率发生的事情,总是会发生。所以我们就要想一个办法,让这件事情不可能发生。于是我们就想到了 Golang 的 Channel 的机制,原生就可以帮助我们实现 exactly once 的效果。这个号一旦从 channel 取出,那么就不会再被第二个人获取。业务上基于 Golang 的 Channel 机制,保证每个号的使用是唯一的。
我们在「发」的同时,将获取红包的设备 ID 异步写到「发红包」实例的日志中。这样就保证我们可以知道红包发给了哪个设备,若是有用户反馈,我们就可以根据这个记录反查到红包路径。因为 Token 中存有着红包的信息,所以「拆红包」的时候只要 API 层将根据 AES 的密钥进行解密即可,不依赖于任何存储。于是,我们只需要将拆包的用户 ID 异步写到实例的日志中,并将这个用户 ID 和红包 Token 塞入队列中,异步入账及核对。
但是你需要注意一点,复杂风控规则跟高并发是矛盾的,每次请求都要走一遍是很耗性能的。那要如何权衡呢?
这里我们可以把复杂的风控规则后置。因为 抢红包——>金额加入账户——>使用账户金额,整个链路需要经过一段时间,所以可以利用这段时间来跑异步风控规则(例如跑风控模型等),实时规则只取简单的即可(例如控制频率、黑白名单等)。
再就是怎么样保证红包不被多领或恶意领取,每个客户领三个红包,这是要做限制的,但这是有代价的,就是存储的代价。
我们在我们的协议里后台服务接入的摇一摇文件里下发红包的时候写一个用户领取的情况,客户端发再次摇一摇请求的时候带上来,我们检查就行了,这是一个小技巧,这种方式解决用户最多只能领三个、企业只能领一个限制的问题。但这个只能解决正版客户端的问题,恶意用户可能不用正版,绕过你的限制,这是有可能的。
怎么办呢?一个办法是在Agent里面,通过检查本机的内存缓存能够达到目的,前提是使用一致性哈希让用户的领取操作路由到同一台机器。如果出现误差,有后续的异步入账校验兜底.
还有一个问题是人海战术,有些人拿着几万、几十万的号抢,抢到都是你的,那怎么办呢?这个没有太好的办法,用大数据分析看用户的行为,你平时养号的吗,正常养号的话,都会登记出来。
配额管理模块
手机 QQ 春节红包是通过很多场定时“活动”来发放红包的。每场活动里面能发放多少现金,能发放多少虚拟物品,发放的比例如何,这些都是配额数据。
更进一步,我们要做到精确控制现金和虚拟物品的发放速度,使得无论何时用户来参加活动,都有机会获得红包,而不是所有红包在前几分钟就被用户横扫一空。
配额信息由配额管理工具负责检查和修改,每次修改都会生成新的 SeqNo。一旦配额 agent 发现 SeqNo 发生变化,则会更新本地共享内存。由于 agent 采用双 buffer 设计,所以更新完成前不会影响当前业务进程。只有构建或同步完成后才切换到最新配置。
红包生成算法
二倍均值法
剩余红包金额为 M,剩余人数为 N,那么有如下公式:
每次抢到的金额=随机区间(0,M/N X 2)
这个公式,保证了每次随机金额的平均值是相等的,不会因为抢红包的先后顺序而造成不公平。
假设有 10 个人,红包总额 100 元。100/10X2=20,所以第一个人的随机范围是(0,20),平均可以抢到 10 元。
如果第一个人随机抢到 10 元,那么剩余金额是 100-10=90 元。90/9X2=20 元,所以第二个人的随机范围同样是(0,20),平均可以抢到 10 元。
如果第二个人随机抢到 10 元,那么剩余金额是 90-10=80 元。80/8X2=20 元,所以第三个人的随机范围同样是(0,20),平均可以抢到 10 元。
以此类推,每一次随机范围的均值是相等的。
线段切割法
把红包总金额想象成一条很长的线段,而每个人抢到的金额,则是这条主线段所拆分出的若干子线段。
当 N 个人一起抢红包的时候,就需要确定 N-1 个切割点。
因此,当 N 个人一起抢总金额为 M 的红包时,我们需要做 N-1 次随机运算,以此确定 N-1 个切割点。
随机的范围区间是(1,100*M)。当所有切割点确定以后,子线段的长度也随之确定。这样每个人来抢红包的时候,只需要顺次领取与子线段长度等价的红包金额即可。
两种算法优缺点
二倍均值法除了最后一次,任何一次抢到的金额都不会超过人均金额的两倍,相对来说不会给用户太多惊喜,它的优点是实现简单,空间复杂度低;线段切割法则相反。
红包总个数过大,那么可以考虑先分段,再用多个线程来拆分,可以提高效率。
对账
上面所有都做到就安全了么?真的就有人写了一个不存在红包进来会怎样?是否还有其他未考虑到的潜在风险?所以我们需要一个兜底——对账,把一切都要对清楚才放心。
对账后再入账的时效在 30~60 分钟,会造成不好的用户体验。为了提升用户体验,将大额的预红包数据(占比 10% 左右)导入 KV(高速缓存),拆红包时进行即时校验,即时进行转账入账,未命中 KV 的红包则等待对账后异步完成入账。
主要要点有:
- 资金配置与资金预算要总分对账;
- 红包数据文件与资金剧本进行总分对账;
- 红包数据进行全局去重验证;
- 红包数据进行解密验证和金额验证;
- 如果密钥泄漏红包金额等被篡改,兜底要进行红包 DB 中已拆红包数据与预红包数据的对账后,才能实际进行转账入账。
与移动端协同
普通用户不会关心 QQ 红包的后台有多复杂,他们在手机QQ移动端抢红包时的体验直接决定着用户对 QQ 红包的评价。对用户来说,看到红包后能否顺畅的抢和刷,是最直接的体验痛点,因此需要尽可能降低延迟以消除卡顿体验,甚至在弱网环境下,也要能有较好的体验。为了实现该目标,手机QQ移动端采取了以下优化策略:
资源预加载
QQ 红包中用到的不经常变化的静态资源,如页面,图片,JS 等,会分发到各地 CDN 以提高访问速度,只有动态变化的内容,才实时从后台拉取。然而即使所有的静态资源都采用了 CDN 分发,如果按实际流量评估,CDN 的压力仍然无法绝对削峰。因为同时访问红包页面的人数比较多,按 83 万 / 秒的峰值,一个页面按 200K 评估,约需要 158.3G 的 CDN 带宽,会给 CDN 带来瞬间很大的压力。为减轻 CDN 压力,QQ 红包使用了手机 QQ 离线包机制提前把红包相关静态资源预加载到手机QQ移动端,这样可大大降低 CDN 压力。
目前手机 QQ 离线包有两种预加载方式:
a. 将静态资源放入预加载列表:用户重新登录手机 QQ 时监测离线包是否有更新并按需加载(1 天能覆盖 60%,2 天能覆盖 80%,适合预热放量情况); b. 主动推送离线包:向当前在线用户推送离线包。(2 个小时可以完成推送,覆盖总量的 40% 左右,适合紧急情况)通过离线包预加载后,除夕当天的 CDN 流量并没有出现异常峰值,比较平稳。
缓存和延时
2.59 亿用户同时在线,用户刷一刷时的峰值高达 83 万 / 秒,如果这些用户的操作请求全部同时拥向后台,即使后台能抗得住,需要的带宽、设备资源成本也是天文数字。为了尽可能减轻后台服务器压力,根据用户刷一刷的体验,用户每次刷的操作都向后台发起请求是没有必要的,因此手机 QQ 在移动端对用户刷一刷的操作进行计数,定时(1~3 秒)异步将汇总数据提交到后台抽奖,再将抽奖结果回传到手机 QQ 移动端显示。这样既保证了“刷”的畅快体验,也大大减轻后台压力,抽奖结果也在不经意间生产,用户体验完全无损。
错峰
对用户进行分组,不同组的用户刷一刷红包(企业明星红包、AR 红包等)的开始时间并不相同,而是错开一段时间(1~5 分钟),这样通过错开每一轮刷红包的开始时间,可以有效平滑用户刷一刷的请求峰值。
动态调整
手机 QQ 移动端和后台并不是两个孤立的系统,而是一个整体。手机 QQ 系统搭建有一整套的负载监控体系,当后台负载升高到警戒线时,手机 QQ 移动端可以根据后台负载情况,动态减少发向后台的请求,以防止后台出现超载而雪崩。
- 一个是在客户端埋入一个逻辑,每次摇变成一个请求,摇每十秒钟或五秒钟发送一个请求,这样可以大幅度降低服务器的压力,这只会发生到几个点;
- 一个是服务访问不了、服务访问超时和服务限速。实时计算接入负载,看CPU的负载,在衔接点给这台服务器的用户返回一个东西,就是你要限速了,你使用哪一档的限速,通过这种方式,当时有四千万用户在摇我们也能扛得住。
5)总量限制和清理:
在刷一刷红包和 AR 红包过程中,当用户已经抽中的奖品数达到一个限值(例如 5 个),用户不能再中奖,这时用户的抽奖请求不再向后台发送,而是移动端直接告知用户“未中奖,请稍后再试”,和清除 AR 红包地图中的红包显示。
功能开关
用户是否设置个性红包,选择的个性红包贴图样式,是否启用个性红包等信息,如果每次判断都从后台拉取,势必增加后台压力。用户对个性红包的设置信息,其实变化不大,并且访问红包商场实时设置的状态的结果在手机 QQ 移动端是存在的。因此我们设计将这些用户状态 FLAG 在手机 QQ 登录时,从后台拉取一次后保存在手机 QQ 移动端,在发红包的过程中将 FLAG 信息传递到下游服务中,通过红包商城设置的个性化红包标志,实时更新手机 QQ本地配置。
这样的设计有几个好处:
- 用户的个性化设置不再依赖于后台,发红包过程完全本地操作,没有任何延时,不影响红包的发放;
- FLAG 标志可以作为容灾开关,如果临时取消个性红包,或后台故障,可以临时屏蔽个性红包功能,恢复为默认红包样式,保障任何时刻红包功能正常可用;
- FLAG 标志可支持扩展,在红包后台可以根据扩展,支持付费红包样式(付费购买)、特权红包样式(如超会专享)等,支持红包商城扩展各种各样的个性化红包;
- 除了从后台拉取 FLAG,当业务有调整导致 FLAG 变化,红包后台可以向手机 QQ 移动端主动 push FLAG 状态,使得用户及时感知变化,进一步增强用户使用体验。
订单系统
性能需求
红包活动具有时间短(单场 5~30 分钟)、大用户量参与(1.5 亿+)参与的特性,请求并发高,游戏红包入口流量设计为 80k/s,流经各个模块有衰减也有增幅,最终用户领取礼包请求预估为 96k/s,而游戏方提供的十款游戏总发货能力只有 5k/s(单款游戏最大为王者荣耀 3k/s),请求峰值接近处理能力的 20 倍,同步调用会导致游戏方发货接口过载,造成大面积发货失败,这个问题如何处理?
使用一个缓冲队列来解决生产消费能力不对等的问题。用户领取请求到达 AMS 进行基础的资格校验后将请求放入 MQ 中,返回用户成功并告知会在 48 小时内到账。再由后台发货 Daemon 从 MQ 中读取请求,通过限速组件控制保证以不超过游戏方发货能力的速率进行发货操作。使用的 MQ 是部门近来建设的 RocketMQ.
容错需求
核心问题:安全发货
三场活动发放的礼包总数预计将近 4 亿,如何保障这些礼包对于合法用户能都发货到账,不少发也不多发?如何防范高价值道具被恶意用户刷走?有没有可能内部开发人员自己调用接口给自己发礼包?
解决方案:对账补送/订单号/安全打击/权限控制
订单号解决不多发
用户领取礼包的接口{4.1 AMS 外网发货新 OP}调用成功,会为这个请求附带一个 UUID 生成的一个全局唯一的订单号,再放进 MQ 中,{4.3 AMS 内网发货 OP}从 MQ 中取出消息,调用游戏方发货接口前都会先校验这个订单号是否用过,没用过则将订单号以 key 的形式写入 CMEM,再进行发货操作。如果出现对同一个发货消息进行重复发货,则会发现订单号已经用过了不会进行实际的发货操作,保证以订单号为标识的同一个发货请求只会进行一次发货操作。
对账补送解决不少发
发货失败是不可避免的,诸如网络波动/游戏方发货接口故障之类的问题都可能导致调用发货接口失败。在同步领取环境下,用户可以通过重试在一定程度上解决这个问题。但是对于异步发货,用户点击领取后发货请求由{4.1 AMS 外网发货新 OP}放入 MQ 中就算成功了,即使后台调用游戏的实际发货接口失败了没有实际到账,用户对此也无感知不能进行重试但是会投诉,后台发货系统必须通过自身的容错保证即使游戏方的发货接口不稳定偶尔会失败,用户所领的礼包能最终到。这里我们使用了对账补送方案。
对账:用户领取礼包调用的接口{4.1 AMS 外网发货新 OP}成功写应发流水,{4.3 AMS 内网发货 OP}调用游戏方发货接口的写实发流水,由于部分消息会堆积在消息队列中,这部分称为队列堆积流水。故实际要进行补发操作的流水由以下公式可得:
失败补发流水= 应发流水 - 实发流水 - 队列堆积流水。
由于订单号的存在,可以保证同一个发货请求重复发送也不会多发,对队列中堆积的消息提前进行补发操作也不会导致多发。故当队列中堆积的流水较少的时候,采用应发流水与实发流水的差集作为失败补发流水是合理,只是每个对账周期会对队列中堆积的消息进行两次发货操作,对性能略有损耗。
后台每个小时运行一次增量对账功能,检测 MQ 消息堆积量量低于某个阈值,则进行对账操作,截取上次对账到此时的应发流水/实发流水,两者相减得到补发流水。
补送:对对账操作得到的补发流水调用游戏方发货接口进行发货补送操作。
安全打击解决高价值道具防刷
对于领奖的请求,都要求都要求带上登录态,对用户进行身份验证,同时对于高价值的道具开启安全打击,上报安全中心进行恶意用户校验,防止被恶意用户刷走。
权限控制解决内部人员监守自盗
对于发货的机器都要安装铁将军,用户需要使用 RTX 名和 token 才能登录机器,审计用户在机器上的操作行为;
发货模块对于调用方是需要严格授权,调用方需要申请 key,包含程序路径、程序 MD5、部署模块等信息,保证发货功能不被随意调用。
柔性可用
柔性可用,柔性是为了保护系统,保证系统整体的稳定性,可用性。可用是为了用户,尽最大努力为用户提供优质的体验(可能是部分服务体验)。一个是从系统本身角度出发,一个是从用户角度看,在真正实施过程中只有将两者分析清,并融合在一起才能真正做到系统的柔性可用。关键问题是找准用户的核心诉求,找出符合求场景的核心诉求作为关键路径,出现异常可以放弃的诉求作为非关键路径。
找准用户的核心诉求:
春节游戏红包用户的核心诉求有三个:
- 看到礼包列表;
- 选择区服角色;
- 领取礼包到账。
其他的都可以作为非关键路径,有可以提高用户体验,没有也不影响用户的核心诉求。
保障关键路径的可用:
看到礼包列表:作为页面关键模块的礼包列表,在红包活动前,十种游戏的礼包内容作为前端静态数据已经预先通过离线包/CDN下发。红包活动时,后台接口根据用户偏好返回的游戏礼包列表,只是提供前端礼包内容进行过滤和排序,失败了也有前端默认的游戏礼包列表,用户依旧能看到礼包列表,只是排序不够智能化。
选择区服角色:除夕前一周游戏中心的主站页面和运营活动增加一个后台接口请求,预先请求用户的区服角色信息缓存到本地,既降低了除夕当天的区服接口请求量又保证了游戏中心核心用户的区服信息是有效的。
领取礼包到账:RocketMQ对于领取操作是关键路径服务,用户领取礼包后需要写入RocketMQ才能算成功。故业务对消息队列做了逻辑层面的容灾,当RocketMQ出现故障时,可以打开容灾开关,领取操作写完应发流水后直接返回成功,不再往RocketMQ写入消息,采用分时段对账的方法替代实时发货,达到消息队列容灾效果,
放弃异常的非关键路径:
- 前端页面展示模块化,对于请求数据不成功的非关键模块进行隐藏;
- 红包页面导流到游戏中心,游戏中心展示按红点逻辑展示,只显示第一屏的数据,默认不加载第二屏数据,用户往下滚动时再加载,牺牲用户往下滚动会短暂卡顿的体验减少后台的请求压力;
- 后台读取算法接口返回的推荐排序失败时使用默认的礼包排序;
- 后台读取CMEM接口返回的礼包是拉活跃还是拉新失败的时每款游戏默认返回低价值的拉活跃礼包。
参考
文章作者 Forz
上次更新 2022-01-18