1. 问题&分析
1.1. 案例
深夜,小艾接到了一通突如其来的电话,是物流系统的负责人曹工焦急的声音。他火急火燎地反馈了一个严重的问题——大批用户投诉物流信息异常,订单状态与实际情况不符,用户已完成支付,但物流单还是待支付状态。
小艾立刻警觉起来,意识到这个问题可能对公司的业务以及用户体验造成重大影响。她一边安抚曹工的情绪,一边迅速启动紧急响应机制,通知QA对线上变更进行回滚。
随着回滚进程的推进,系统逐步恢复正常。紧接着,他手工导出上线以来的全部订单,并与曹工一起进行数据核对,对问题数据进行修复。终于忙完了,天空已经微微发亮……
1.2. 问题分析
上午稍微补了个觉,小艾洗漱完毕后对这件事进行分析:订单已支付,物流单待支付。
现在订单和物流的系统交互如下:
图片
在正常的业务流程中,订单发布事件和物流监听事件紧密相连。
- 订单系统发布一个“订单已创建”事件时,物流系统会立即响应并在其内部创建一条对应的物流单据。
- 当支付环节完成并触发“订单已支付”事件时,物流系统会找到关联的物流单据并更新其为待发货状态。
在正常情况下,没有出现不一致的情况。小艾想到了最近的系统变更:
最近上线的一项新功能——礼品赠送。为了降低对下游系统的影响,小艾通过在应用层对流程进行编排的方式实现该功能,简单来说,就是系统先创建订单,然后模拟支付成功,这样既能满足礼品赠送的需求,又能保障下游契约消息没有变化。新流程如下所示:
图片
整个流程与原来的方案没有差别,问题究竟出现在哪呢?无奈的小艾只好打开 idea 查看源码,终于发现问题所在:
@Service
public class RocketMQProducer {
@Autowired
private RocketMQTemplate rocketMQTemplate;
@TransactionalEventListener
public void handle(OrderCreatedEvent event){
rocketMQTemplate.convertAndSend("order_created_event", event);
}
@TransactionalEventListener
public void handle(OrderPaidEvent event){
rocketMQTemplate.convertAndSend("order_paid_event", event);
}
}
下单和支付成功使用两个不同的 topic,两个 topic 相互独立,根本就无法保障投递顺序。在手动支付场景下,由于用户从订单创建到支付完成通常会有 5 秒以上的延迟,在这种情况下该实现可以保障逻辑的执行顺序。然而在礼品赠送这个场景,系统先创建订单,然后模拟支付成功,导致“订单已创建”和“订单已支付”两个事件几乎同时发出,在接收端就有可能先收到支付成功事件,再收到订单已创建事件,从而导致订单状态和物流单状态不一致,具体流程如下:
图片
如果顺序错了,就会导致业务状态不一致:
- 物流先接到支付成功事件,在查询物流单时由于找不到物流单所以更新失败。
- 随后物流接到订单创建事件,根据逻辑创建一条待支付的物流单,但由于该订单的支付成功事件在上一步已经错过,所以物流一直停留在待支付状态。
问题终于找到了!!!
2. 解决方案
2.1. 方案一:主动延时
既然是顺序问题,那最简的方法就是对支付成功消息进行延时发送。
方案如下:
图片
中间增加一个延时组件便能解决这个问题,但不同的方案影响巨大:
- sleep 方案,会导致大量线程处于阻塞状态,增加接口响应时间,同时降低系统的吞吐。在线上绝对不允许这种方案的出现!
- 定时器方案,下单完成后,注册一个定时调度任务,时间到达时调度器将自动执行任务。
定时器方案,核心代码如下:
@TransactionalEventListener
public void handle(OrderPaidEvent event){
// 创建Runnable任务
Runnable task = () -> {
rocketMQTemplate.convertAndSend("order_paid_event", event);
};
// 使用ScheduledExecutorService schedule方法在5秒后执行任务
executor.schedule(task, 5, TimeUnit.SECONDS);
}
该方案存在几个比较严重的问题:
- 全内存操作,容易操作任务的丢失。当遇到非优雅关机时,内存中的 task 就会丢失,从而导致业务逻辑不完整;
- 异步执行,容易造成错觉。用户完成任务提交并不代表任务肯定会成功运行
- 资源管理困难,如果任务量太大会大量消耗内存资源,甚至引起整个服务 OOM
2.2. 方案二:顺序消息
现在不少 MQ 提供顺序消息的支持,比如常见的 RocketMQ 提供了两种类型的顺序消息:全局顺序消息和分区顺序消息。
- 全局顺序消息要求所有的消息都在一个队列上发送和消费,因此只适用于少量队列(通常是1个队列,否则就无法做到全局顺序)。
- 分区顺序消息则允许基于(分片键)进行分区,相同的消息会被发送到同一队列中,从而在每个分区内部实现顺序。
分区顺序消息整体设计如下:
图片
核心代码如下:
@TransactionalEventListener
public void handle(OrderCreatedEvent event){
Long orderId = event.getOrderId();
Message<OrderCreatedEvent> message = MessageBuilder.withPayload(event)
.setHeader(RocketMQHeaders.KEYS, orderId) // 设置 Sharding Key,即订单ID
.setHeader(RocketMQHeaders.TAGS, "OrderCreatedEvent") // 设置 Tag
.build();
// 发送至统一的 order_event_topic
rocketMQTemplate.send("order_event_topic", message);
}
@TransactionalEventListener
public void handle(OrderPaidEvent event){
Long orderId = event.getOrderId();
Message<OrderPaidEvent> message = MessageBuilder.withPayload(event)
.setHeader(RocketMQHeaders.KEYS, orderId) // 设置 Sharding Key,即订单ID
.setHeader(RocketMQHeaders.TAGS, "OrderCreatedEvent") // 设置 Tag
.build();
// 发送至统一的 order_event_topic
rocketMQTemplate.send("order_event_topic", message);
}
3. 示例&源码
代码仓库:https://gitee.com/litao851025/learnFromBug
代码地址:https://gitee.com/litao851025/learnFromBug/tree/master/src/main/java/com/geekhalo/demo/mq/disorder