【1】MQ之如何做到消息必达
① MQ简单架构
MQ要想尽量消息必达,架构上有两个核心设计点:
- 消息落地
- 消息超时、重传、确认
上图是一个MQ的核心架构图,基本可以分为三大块:
- 发送方–左侧粉色部分
- MQ核心集群–中间蓝色部分
- 接收方–右侧黄色部分
粉色发送方也就是发送方
又由两部分构成:业务调用方与MQ-client-sender。其中后者向前者提供了两个核心API:
//发送消息
SendMsg(bytes[] msg);
//发送回调
SendCallback();
蓝色MQ核心集群
又分为四个部分:MQ-server,zk,db,管理后台web。
黄色接收方
也由两部分构成:业务接收方与MQ-client-receiver。其中后者向前者提供了两个核心API:
RecvCallback(bytes[] msg)
SendAck()
MQ是一个系统间解耦的利器,它能够很好的解除发布订阅者之间的耦合,它将上下游的消息投递解耦成两个部分,如上述架构图中的1箭头和2箭头:
- 发送方将消息投递给MQ,上半场
- MQ将消息投递给接收方,下半场
② MQ消息可靠投递核心流程
MQ既然将消息投递拆成了上下半场,为了保证消息的可靠投递,上下半场都必须尽量保证消息必达。
MQ消息投递上半场
MQ-client-sender到MQ-server流程见上图1-3:
- MQ-client将消息发送给MQ-server(此时业务方调用的是API:SendMsg)
- MQ-server将消息落地,落地后即为发送成功
- MQ-server将应答发送给MQ-client(此时回调业务方是API:SendCallback)
MQ消息投递下半场
MQ-server到MQ-client-receiver流程见上图4-6:
- MQ-server将消息发送给MQ-client(此时回调业务方是API:RecvCallback)
- MQ-client回复应答给MQ-server(此时业务方主动调用API:SendAck)
- MQ-server收到ack,将之前已经落地的消息删除,完成消息的可靠投递
如果消息丢了怎么办?
MQ消息投递的上下半场,都可以出现消息丢失,为了降低消息丢失的概率,MQ需要进行超时和重传。
上半场的超时与重传
MQ上半场的1或者2或者3如果丢失或者超时,MQ-client-sender内的timer
会重发消息,直到期望收到3,如果重传N次后还未收到,则SendCallback
回调发送失败,需要注意的是,这个过程中MQ-server可能会收到同一条消息的多次重发
。
下半场的超时与重传
MQ下半场的4或者5或者6如果丢失或者超时,MQ-server内的timer会重发消息
,直到收到5并且成功执行6。这个过程可能会重发很多次消息,一般采用指数退避的策略
,先隔x秒重发,2x秒重发,4x秒重发,以此类推。需要注意的是,这个过程中MQ-client-receiver
也可能会收到同一条消息的多次重发。
MQ-client
与MQ-server
如何进行消息去重,如何进行架构幂等性设计,下面讲述,此处暂且认为为了保证消息必达,可能收到重复的消息。
【2】MQ之如何做到消息幂等
① 上半场的幂等性设计
MQ消息发送上半场,即上图中的1-3
- 1,发送端
MQ-client
将消息发给服务端MQ-server
- 2,服务端
MQ-server
将消息落地 - 3,服务端
MQ-server
回ACK
给发送端MQ-client
如果3丢失,发送端MQ-client超时后会重发消息,可能导致服务端MQ-server收到重复消息。
此时重发是MQ-client
发起的,消息的处理是MQ-server
,为了避免步骤2落地重复的消息,对每条消息,MQ系统内部必须生成一个inner-msg-id
,作为去重和幂等的依据,这个内部消息ID的特性是:
- 全局唯一
- MQ生成,具备业务无关性,对消息发送方和消息接收方屏蔽
有了这个inner-msg-id
,就能保证上半场重发,也只有1条消息落到MQ-server的DB中,实现上半场幂等。
② 下半场的幂等性设计
MQ消息发送下半场,即上图中的4-6
-
4,服务端
MQ-server
将消息发给接收端MQ-client
-
5,接收端
MQ-client
回ACK
给服务端 -
6,服务端
MQ-serve
r将落地消息删除
需要强调的是,接收端MQ-client
回ACK
给服务端MQ-server
,是消息消费业务方的主动调用行为,不能由MQ-client自动发起,因为MQ系统不知道消费方什么时候真正消费成功。
如果5丢失,服务端MQ-server
超时后会重发消息,可能导致MQ-client
收到重复的消息。
此时重发是MQ-server
发起的,消息的处理是消息消费业务方,消息重发势必导致业务方重复消费。为了保证业务幂等性,业务消息体中,必须有一个biz-id
,作为去重和幂等的依据,这个业务ID的特性是:
- 对于同一个业务场景,全局唯一
- 由业务消息发送方生成,业务相关,对MQ透明
- 由业务消息消费方负责判重,以保证幂等
最常见的业务ID有:支付ID,订单ID,帖子ID等。具体到支付购卡场景,发送方必须将支付ID放到消息体中,消费方必须对同一个支付ID进行判重,保证购卡的幂等。
有了这个业务ID,才能够保证下半场消息消费业务方即使收到重复消息,也只有1条消息被消费,保证了幂等。
③ 总结
MQ为了保证消息必达,消息上下半场均可能发送重复消息,如何保证消息的幂等性呢?
上半场
MQ生成inner-msg-id
,保证上半场幂等。这个ID全局唯一,业务无关,由MQ保证。
下半场
业务发送方带入biz-id
,业务接收方去重保证幂等。这个ID对单业务唯一,业务相关,对MQ透明。
结论:幂等性,不仅对MQ有要求,对业务上下游也有要求。
【3】MQ之如何做到消息延时
很多时候,业务有“在一段时间之后,完成一个工作任务”的需求。
例如:滴滴打车订单完成后,如果用户一直不评价,48小时后会将自动评价为5星。一般来说怎么实现这类“48小时后自动评价为5星”需求呢?
常见方案:
启动一个cron
定时任务,每小时跑一次,将完成时间超过48小时的订单取出,置为5星,并把评价状态置为已评价。假设订单表的结构为:t_order(oid, finish_time, stars, status, …)
,更具体的,定时任务每隔一个小时会这么做一次:select oid from t_order where finish_time > 48hours and status=0;update t_order set stars=5 and status=1 where oid in[…];
如果数据量很大,需要分页查询,分页update,这将会是一个for循环。
方案的不足:
- 轮询效率比较低
- 每次扫库,已经被执行过记录,仍然会被扫描(只是不会出现在结果集中),有重复计算的嫌疑
- 时效性不够好,如果每小时轮询一次,最差的情况下,时间误差会达到1小时
- 如果通过增加cron轮询频率来减少③中的时间误差,①中轮询低效和②中重复计算的问题会进一步凸显。
高效延时消息设计与实现
高效延时消息,包含两个重要的数据结构:
- 环形队列,例如可以创建一个包含3600个slot的环形队列(本质是个数组)
- 任务集合,环上每一个slot是一个Set
同时,启动一个timer
,这个timer
每隔1s,在上述环形队列中移动一格,有一个Current Index
指针来标识正在检测的slot。
Task
结构中有两个很重要的属性:
Cycle-Num
:当Current Index
第几圈扫描到这个Slot时,执行任务Task-Function
:需要执行的任务指针
假设当前Current Index
指向第一格,当有延时消息到达之后,例如希望3610秒之后,触发一个延时消息任务,只需:
- 计算这个
Task应该放在哪一个slot
,现在指向1,3610秒之后,应该是第11格,所以这个Task
应该放在第11个slot的Set
中 - 计算这个
Task的Cycle-Num
,由于环形队列是3600
格(每秒移动一格,正好1小时),这个任务是3610秒后执行,所以应该绕3610/3600=1圈之后再执行,于是Cycle-Num=1
Current Index
不停的移动,每秒移动到一个新slot,这个slot中对应的Set,每个Task看Cycle-Num
是不是0:
- 如果不是0,说明还需要多移动几圈,将
Cycle-Num
减1 - 如果是0,说明马上要执行这个
Task
了,取出Task-Funciton
执行(可以用单独的线程来执行Task),并把这个Task从Set中删除
使用了“延时消息”
方案之后,“订单48小时后关闭评价”的需求,只需将在订单关闭时,触发一个48小时之后的延时消息即可:
- 无需再轮询全部订单,效率高
- 一个订单,任务只执行一次
- 时效性好,精确到秒(控制timer移动频率可以控制精度)
总结
环形队列是一个实现“延时消息”的好方
法,开源的MQ好像都不支持延迟消息,不妨自己实现一个简易的“延时消息队列”,能解决很多业务问题,并减少很多低效扫库的cron任务。
参考博文:
MQ之如何做到消息延时