服务器之家:专注于VPS、云服务器配置技术及软件下载分享
分类导航

PHP教程|ASP.NET教程|Java教程|ASP教程|编程技术|正则表达式|C/C++|IOS|C#|Swift|Android|VB|R语言|JavaScript|易语言|vb.net|

服务器之家 - 编程语言 - 编程技术 - 故障现场 | MQ消息乱序造成的业务事故

故障现场 | MQ消息乱序造成的业务事故

2024-04-01 15:38geekhalo 编程技术

深夜,小艾接到了一通突如其来的电话,是物流系统的负责人曹工焦急的声音。他火急火燎地反馈了一个严重的问题——大批用户投诉物流信息异常,订单状态与实际情况不符,用户已完成支付,但物流单还是待支付状态。

1. 问题&分析

1.1. 案例

深夜,小艾接到了一通突如其来的电话,是物流系统的负责人曹工焦急的声音。他火急火燎地反馈了一个严重的问题——大批用户投诉物流信息异常,订单状态与实际情况不符,用户已完成支付,但物流单还是待支付状态。

小艾立刻警觉起来,意识到这个问题可能对公司的业务以及用户体验造成重大影响。她一边安抚曹工的情绪,一边迅速启动紧急响应机制,通知QA对线上变更进行回滚。

随着回滚进程的推进,系统逐步恢复正常。紧接着,他手工导出上线以来的全部订单,并与曹工一起进行数据核对,对问题数据进行修复。终于忙完了,天空已经微微发亮……

1.2. 问题分析

上午稍微补了个觉,小艾洗漱完毕后对这件事进行分析:订单已支付,物流单待支付。

现在订单和物流的系统交互如下:

故障现场 | MQ消息乱序造成的业务事故图片

在正常的业务流程中,订单发布事件和物流监听事件紧密相连。

  • 订单系统发布一个“订单已创建”事件时,物流系统会立即响应并在其内部创建一条对应的物流单据。
  • 当支付环节完成并触发“订单已支付”事件时,物流系统会找到关联的物流单据并更新其为待发货状态。

在正常情况下,没有出现不一致的情况。小艾想到了最近的系统变更:

最近上线的一项新功能——礼品赠送。为了降低对下游系统的影响,小艾通过在应用层对流程进行编排的方式实现该功能,简单来说,就是系统先创建订单,然后模拟支付成功,这样既能满足礼品赠送的需求,又能保障下游契约消息没有变化。新流程如下所示:

故障现场 | MQ消息乱序造成的业务事故图片

整个流程与原来的方案没有差别,问题究竟出现在哪呢?无奈的小艾只好打开 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 秒以上的延迟,在这种情况下该实现可以保障逻辑的执行顺序。然而在礼品赠送这个场景,系统先创建订单,然后模拟支付成功,导致“订单已创建”和“订单已支付”两个事件几乎同时发出,在接收端就有可能先收到支付成功事件,再收到订单已创建事件,从而导致订单状态和物流单状态不一致,具体流程如下:

故障现场 | MQ消息乱序造成的业务事故图片

如果顺序错了,就会导致业务状态不一致:

  • 物流先接到支付成功事件,在查询物流单时由于找不到物流单所以更新失败。
  • 随后物流接到订单创建事件,根据逻辑创建一条待支付的物流单,但由于该订单的支付成功事件在上一步已经错过,所以物流一直停留在待支付状态。

问题终于找到了!!!

2. 解决方案

2.1. 方案一:主动延时

既然是顺序问题,那最简的方法就是对支付成功消息进行延时发送。

方案如下:

故障现场 | MQ消息乱序造成的业务事故图片

中间增加一个延时组件便能解决这个问题,但不同的方案影响巨大:

  • 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个队列,否则就无法做到全局顺序)。
  • 分区顺序消息则允许基于(分片键)进行分区,相同的消息会被发送到同一队列中,从而在每个分区内部实现顺序。

分区顺序消息整体设计如下:

故障现场 | MQ消息乱序造成的业务事故图片

核心代码如下:

@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

原文地址:https://mp.weixin.qq.com/s/0mO1s1_uWqqe5lTy520WnQ

延伸 · 阅读

精彩推荐