借鉴:https://www.imooc.com/article/49814
消息持久化(唯一ID),消息状态可查,补偿,有序,幂等,最终一致
生产者:
开放相应的查询接口API,方便消费者/补偿器确认业务状态。(状态可查)
-
Step 1: 首先把业务数据 保存到本地数据库中,紧接着我们再把这个 【消息记录+发送队列】 也存储到一张消息记录表里(或者另外一个同源数据库的消息记录表),每条消息有唯一ID,要保证【业务数据+消息记录】 在同一个本地事务中一起保存到本地数据库。(如果业务要求响应速度不写入Mysql。还有其他消息存储方案:1、javamap 性能好,但是没有持久化且存在OOM风险 ;2、redis )
-
Step 2:生产者发送消息到MQ Broker节点(采用异步confirm方式发送,会有异步的返回结果,判断失败重发/降级策略。confirm不会返回消息体,只会返回correlationData)。下游若有多个业务消费者可以选择广播\模糊匹配队列。
-
Step 3、4:生产者端接受MQ Broker节点返回的Confirm确认消息结果,true表示MQ成功接收,然后进行更新本地数据库消息记录表里的消息状态。比如默认Status = 0 当收到消息已进入mq成功后,更新为1即可!
-
Step 5:但是在消息确认这个过程中可能由于网络闪断、MQ Broker端异常等原因导致 回送消息失败或者异常。这个时候就需要发送方(生产者)对消息进行可靠性投递了,保障消息不丢失,100%的投递成功!(有一种极限情况是闪断,Broker返回的成功确认消息,但是生产端由于网络闪断没收到,这个时候重新投递可能会造成消息重复,需要消费端去做幂等处理)。
注意:
1、Confirm模式会降低性能,也可以不Confirm,全部由补偿机制完成。
最终一致性补偿机制:
-
Step 6:最大努力尝试。需要有一个定时任务,把Step 1中保存的消息表中,超时仍然处于中间状态的消息进行重新投递。(比如每5分钟拉取一下处于中间状态的消息,当然这个消息可以设置一个超时时间,比如超过1分钟 Status = 0 ,也就说明了1分钟这个时间窗口内,我们的消息没有被确认,那么会被定时任务拉取出来)
-
Step 7:重试多次还为成功,启动降级策略。可以在消费消息时先在redis中记入 消息ID:count重发次数 +TTL。 降级策略可以将最终状态设置为Status = 2 ,最后交由人工解决处理此类问题(或者把消息转储到失败表或失败MQ中)。
-
消费者手动ACK,失败/拒绝消息重入队保证最终一致性(注意重试次数)。
消费者 :
开放相应的查询接口API,方便补偿器确认业务状态。(可查性)
- 幂等设计:防止重复消费,悲观锁检查、乐观锁检查、主键冲突。推荐以组合方案的形式保证绝对幂等。
主键冲突:捕获异常。
悲观锁:分段锁Lock、Redis SetNx记录消息ID(原子性)。
乐观锁:update+where+状态/版本 判断影响行=0,或redis+lua完成cas操作。一定保证检查和执行是原子性的,避免并发问题。其还有一个重要作用是防止过期消息覆盖。例如:当前数据库中订单状态=2,而此消息的订单状态=1。说明消息已过期,此时就不应该再消费此消息了。
- 信息验证:验证信息合法性、NPE等,防止错误消费/恶意投递。
- 手动ACK:防止消息丢失,失败/拒绝消息重入队。(属于一种补偿机制)
- 更新状态:更新生产者消息表状态
- 重试策略:防止MQ阻塞/死循环。建议超次数拒绝重入队,放入死信队列,等待处理。
重试消费方案1:同一消息ID避免反复重试,在消费消息时先在redis中记入 消息ID:count消费次数 +TTL,下次再消费时判断redis中次数,超次则无论失败与否都不重入队,记录失败消息内容或转发其他MQ或进入DLX或等待补偿机制。这样保证在一定时间内同一消息不会反复消费失败,避免死循环阻塞,且避免消息丢失等待补偿机制。补偿机制也可以参考redis中 消息ID:count 判断是否再次补偿。
注意:
1、手动ACK模式重入队可能会造成死循环,也可以不重入队失败直接丢弃,全部由补偿机制完成或保存在死信队列
第三方调用幂等性
第三方系统调用更加复杂,因为可能无法控制第三方幂等消费,只能控制我方调用幂等执行。
例如:某第三方支付平台,支付结果通过异步回调方式通知的我方服务,且支持同一订单多次支付。假如我方支付信息已经推送到第三方支付系统支付成功,但是延迟返回回调通知给我方服务。用户暂时看到订单仍然是未支付状态,,但是实际上钱已经通过第三方支付平台转账,避免用户在回调通知返回前重复点击支付是有必要的。
方案1:增加中间状态"支付中",且使用悲观锁检查保护。状态修改/检查要在发起支付请求前完成,保证并发场景只有一次支付请求,也避免请求请求发送成功但是状态修改失败。存在问题:如果setnx成功但是支付请求可能没发出去,造成永久支付中状态。
支付API:
if redis.setnx(orderid,v){ //悲观锁。可以用数据库代替: update set state=1 where state=0 and id=@id 判断影响行。
try{
保证只有一个线程可以发起支付请求(orderid)。
}catch{
请求发起失败,删除悲观锁redis.del(orderid),再次开放支付。
}
}else{
return "支付中,稍后再试!"
}
异步回调API:
收到支付平台推送通知,根据orderid支付结果修改订单状态。
成功:更新订单状态。
失败:删除悲观锁redis.del(orderid),再次开放支付。