如果观看抽奖或秒杀系统的请求监控曲线,你就会发现这类系统在活动开放的时间段内会出现一个波峰,而在活动未开放时,系统的请求量、机器负载一般都是比较平稳的。为了节省机器资源,我们不可能时时都提供最大化的资源能力来支持短时间的高峰请求。所以需要使用一些技术手段,来削弱瞬时的请求高峰,让系统吞吐量在高峰请求下保持可控。
最近在做一个小型的抽奖系统,用户中奖之后需要调用转账接口进行虚拟金的转账。转账接口有频控的逻辑,因此不能把抽奖瞬间的大量请求都发往转账系统,必须对请求进行削峰。削峰的方式有很多种,下面就来简单地聊一下。
请求排队
削峰最常用的一种方式是请求排队。瞬时的请求量太大,那么就把这些请求先排队存起来,再依据系统所能提供的消费能力按需消费。在量小的时候,抽奖与发货这两个动作可以是同步的(如下左图),这是一种紧耦合系统,SVR B的处理能力必须跟得上SVR A的处理能力。当SVR A 与SVR B 存在处理能力差异时,可以引入消息队列,把对服务的同步调用转化成对队列的异步消费。
可以用来作为队列的工具有很多,典型的如Message Queue消息队列,也可以利用数据库Mysql或是Redis来实现分布式队列,跟进业务场景来自行进行选择。例如,我在实现抽奖系统的时候,使用的是Mysql,原因是SVR A已经把用户的抽奖信息落地到的数据库,那么SVR B就可以利用Mysql作为一个队列,来达到按能力消费的需求。
Mysql
用户中奖的时候,SVR A 会将用户中奖信息写到数据库中。SVR B按照自己的消费能力,从数据库中把数据select出来执行转账的逻辑。数据库表中的每一行记录,都可以看作是一个等待被消费的消息。如何保证消息按序(正序或倒序)消费?可以利用update_time 来标记消息入队时间,设定update_time字段:
update_time timestamp NOT NULL ON UPDATE CURRENT_TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间'
必须使用一个字段来标记某行记录的消费状态。消费过的消息不必再select出来处理。另外,在有多个消息消费者的时候(比如有多个线程来消费数据库中的这些中奖信息时),需要保证消息不会重复被消费。可以使用二段式提交的方式来保证。以字段present_flag来表示消费状态,present_flag有三个取值:
0:中奖,未转账
1:一阶段提交(即准备转账)
2:二阶段提交(转账完成)
对于SVR B ,需要进行如下的操作:
步骤一:将数据库中present_flag 为0 的记录按序捞取出来,这里可以批量拉取,比如一次拉取100条记录
步骤二:按序处理每笔中奖记录的转账逻辑,调用转账接口之前,将present_flag设置为1,sql中的条件是present_flag为0;
步骤三:执行转账逻辑
步骤四:转账成功,将present_flag设置为2,sql中条件是present_flag为1。
这样即使同一行记录被多个消费者拉取出来,也能保证只有一个能够成功执行步骤三。转账失败(消费失败)
的记录如何处理?可以使用一个定时脚本将present_flag为1的update成present_flag为0,再次进行消费。
通过这种异步消费的方式,来保证中奖记录慢慢被消费完。这种方式在极端的情况下,比如刚刚执行完步骤三
机器就挂掉了,那么可能会出现重复消费的情况。根据业务对重复消费的容忍度来进行选择。
Redis
Redis的list数据结构提供了BLPOP和BRPOP,表示列表的阻塞式弹出。BLPOP的BRPOP的区别仅仅在取元素的位置不同。使用方式为:
BRPOP key timeout
当给定的列表内没有任何元素可供弹出的时候,连接将被阻塞,直到等待超时或发现可弹出的元素为止,超时参数 timeout 接受一个以秒为单位的数字作为值。超时参数设为 0 表示阻塞时间可以无限期延长。相同的key可以被多个客户端同时阻塞,不同的客户端会被放进一个队列中,按照【先阻塞先服务】的顺序为key执行BRPOP 命令。利用这个特点,可以来实现一个轻量级的消息队列服务。
消息队列组件
例如kafka、ActiveMQ、RabbitMQ、ZeroMQ、Kafka、MetaMQ、RocketMQ等消息队列,本就是为异步化消息消费、应用解耦、流量消费而设计。业务根据需求加以选型即可。