消息可靠性与补偿

05 · 核心亮点

亮点五 · 订单存进去了,但消息没发出去 = 一张死单

publisher confirm + Spring Retry + 失败落库 + XXL-Job 补偿 + 死信队列,五层兜底。设计很完整,但有一个致命缺陷让它形同虚设。

涉及模块:chain-cloud-mq(被 base-service 与 oms-service 引用)

亮点五 · 订单存进去了,但消息没发出去 = 一张死单

业务问题: 客户下单成功,页面显示"下单成功"。 但如果此刻负责触发调度的 MQ 发失败了, 这个订单就会永远躺在数据库里,没有任何快递员知道它存在。 客户等不到快递员,投诉,客服查后台看到订单状态是"待取件", 研发查日志发现消息根本没发出去——这就是一张死单。

这是分布式系统里最典型的"数据成功但动作失败",也是最常见的线上事故之一。


一、先理解业务:为什么这个场景真的会发生

不是"理论上可能",而是每天都在发生:

失败场景频率表现
发送时 convertAndSend 抛异常(交换机不存在、连接断了)低(配置变更时集中出现)立即失败,可捕获
Broker 收到了但没有路由到任何队列(routingKey 写错、队列未绑定)中(发版时集中出现)最难发现——不报错,消息凭空消失
Broker 收到但返回 nack(磁盘满、内部错误)低异步回调才能知道
发送成功后进程崩溃,回调没来得及执行低消息状态未知
消费者处理成功但 ack 丢失中(网络抖动时)重复消费

这五种场景需要五种不同的应对。 只有一个方案必然是漏的, 所以 chain-cloud-mq 设计了五层。


二、五层兜底设计

第 1 层:交换机/队列声明 + 持久化

java
// ErrorMessageConfig(死信交换机与队列)
@Bean
public TopicExchange errorMessageExchange() {
    return new TopicExchange(Constants.MQ.Exchanges.ERROR, true, false);  // durable
}
@Bean
public Queue errorQueue() {
    //【前缀+微服务】作为队列名,开启持久化
    return new Queue(Constants.MQ.Queues.ERROR_PREFIX + appName, true);
}

消息本身也是持久化的:

java
var message = MessageBuilder.withBody(StrUtil.bytes(jsonMsg, CharsetUtil.CHARSET_UTF_8))
        .setDeliveryMode(MessageDeliveryMode.PERSISTENT)    // 持久化
        .build();

持久化消息 ≠ 绝对不丢。 持久化只保证 broker 重启后消息还在, 但 RabbitMQ 默认把持久化消息写内存再落盘, 断电时仍有丢失窗口。生产环境要配 queue 的 lazy 模式或用 quorum queue。

第 2 层:Publisher Confirm(发送确认)

java
// MQServiceImpl#sendMsg
var msgId = IdUtil.simpleUUID();
var correlationData = new CorrelationData(msgId);         // 关联数据

// 添加 callback
correlationData.getFuture().addCallback(
        result -> {
            if (result.isAck()) {
                log.debug("消息发送成功, ID:{}", correlationData.getId());
            } else {
                // 3.2 nack,消息失败 → 落库等待补偿
                log.error("消息发送失败, ID:{}, 原因:{}", correlationData.getId(), result.getReason());
                var failMsgEntity = FailMsgEntity.builder()
                        .exchange(exchange)
                        .msgId(correlationData.getId())
                        .msg(jsonMsg)
                        .reason(result.getReason()).build();
                this.failMsgService.save(failMsgEntity);
            }
        },
        ex -> {
            // 发送后无法连接到 broker 等未知异常
            var exceptionInfo = ExceptionUtil.getMessage(ex);
            var failMsgEntity = FailMsgEntity.builder()
                    .exchange(exchange)
                    .msgId(correlationData.getId())
                    .msg(jsonMsg)
                    .reason(exceptionInfo).build();
            this.failMsgService.save(failMsgEntity);
        }
);

CorrelationData 的机制: 它携带一个消息 ID,broker 返回 confirm 时会带回这个 ID, Spring 通过 CompletableFuture 把结果回调给发送方。 这是 publisher-confirm-type: correlated(关联模式)配置的产物。

必配项(否则 confirm 回调不触发):

yaml
spring:
  rabbitmq:
    publisher-confirm-type: correlated

第 3 层:发送异常重试(Spring Retry)

java
@Retryable(value = MsgException.class, maxAttempts = 3,
           backoff = @Backoff(value = 3000, multiplier = 1.5))
@Override
public Boolean sendMsg(String exchange, String routingKey, Object msg, int delay) {
    ...
    try {
        this.rabbitTemplate.convertAndSend(exchange, routingKey, message, mpp -> {
            mpp.getMessageProperties().setDelay(delay);
            return mpp;
        }, correlationData);
    } catch (Exception e) {
        // 转化为自定义异常 MsgException,主要是为了触发重试机制
        throw MsgException.builder()
                .failMsgEntity(FailMsgEntity.builder()...)
                .build();
    }
    return true;
}

/**
 * 3次失败后 存入数据库
 */
@Recover
public Boolean saveFailMag(MsgException msgException) {
    this.failMsgService.save(msgException.getFailMsgEntity());
    return true;
}

重试参数:

参数值说明
触发异常MsgException only业务异常不重试
最大次数3含第一次
初始退避3000 ms
退避倍数1.5实际间隔 ≈ 3s → 4.5s
最终处理@Recover落库 cc_fail_msg

设计要点:自定义一个 MsgException 只为了"让 Spring Retry 认得" ——因为默认 @Retryable 会把所有 Exception 都重试, 但有些异常重试没意义(比如"交换机不存在"是配置错误,重试 100 次也没用)。

java
// MsgException
@Data @Builder
public class MsgException extends RuntimeException {
    private FailMsgEntity failMsgEntity;   // 直接把待落库的数据挂在异常上
}

这个设计很巧妙,值得在面试里讲: 异常不只是"控制流",它还携带了数据。

第 4 层:失败消息落库 + XXL-Job 补偿

表结构:

sql
CREATE TABLE `cc_fail_msg` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `msg_id`      varchar(32)  DEFAULT NULL COMMENT '消息ID',
  `exchange`    varchar(100) DEFAULT NULL COMMENT '交换机',
  `routing_key` varchar(50)  DEFAULT NULL COMMENT '路由键',
  `msg`         text         COMMENT '消息内容',
  `reason`      text         COMMENT '失败原因',
  `created`     datetime     DEFAULT CURRENT_TIMESTAMP,
  `updated`     datetime     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

补偿任务:

java
// FailMsgJob
@Slf4j
@Component
@ConditionalOnBean({MQService.class, FailMsgService.class})
public class FailMsgJob {

    @XxlJob("failMsgJob")
    public void execute() {
        // 查询失败的数据,每次最多处理100条
        var queryWrapper = new LambdaQueryWrapper<FailMsgEntity>()
                .orderByAsc(FailMsgEntity::getCreated)     // 先进先出
                .last("limit 100");
        var failMsgEntityList = this.failMsgService.list(queryWrapper);
        if (CollUtil.isEmpty(failMsgEntityList)) {
            return;
        }
        for (var failMsgEntity : failMsgEntityList) {
            try {
                this.mqService.sendMsg(failMsgEntity.getExchange(),
                        failMsgEntity.getRoutingKey(), failMsgEntity.getMsg());
                this.failMsgService.removeById(failMsgEntity.getId());   // 发成功就删
            } catch (Exception e) {
                log.error("处理错误消息失败, failMsgEntity = {}", failMsgEntity, e);
            }
        }
    }
}

设计要点:

  1. 先按 created ASC 再 limit 100 —— 保证先进先出,

避免老消息被新消息挤占而饿死;

  1. 删表而不是标记状态 —— 简单直接;
  2. 逐条 try-catch —— 一条失败不影响后面的。

第 5 层:消费侧死信队列(DLQ)

java
// ErrorMessageConfig
@Value("${spring.application.name}")
private String appName;

@Bean
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate) {
    return new RepublishMessageRecoverer(rabbitTemplate, Constants.MQ.Exchanges.ERROR, appName);
}

配合消费端重试配置:

yaml
spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: auto        # 异常时 nack,消息回滚
        retry:
          enabled: true               # 开启消费者失败重试
          initial-interval: 1000      # 初始等待 1 秒
          multiplier: 1
          max-attempts: 3             # 最大重试 3 次
          stateless: true             # 无状态重试

完整流程:

text
消费失败
  → 间隔 1s 重试(第 1 次)
  → 间隔 1s 重试(第 2 次)
  → 间隔 1s 重试(第 3 次)
  → MessageRecoverer 触发
  → 重新发到 cc.exchange.topic.error,routingKey = ${spring.application.name}
  → 路由到 cc.queue.error.chain-cloud-ms-oms
  → 等待人工处理 / 告警

队列名带服务名的好处: 每个服务有自己的死信队列,互不干扰, 排障时一看队列名就知道是哪个服务出的问题。

⚠️ 这里有一个断链: FailMsgJob 只读 cc_fail_msg 表, 从不消费 cc.queue.error.*。 也就是说死信队列里的消息没有任何自动处理机制, 必须人工去 RabbitMQ 控制台捞出来看。


三、难点拆解

难点 1:怎么区分"可重试失败"和"不可重试失败"

难点在哪: 所有失败都被包成 Exception,但它们的性质完全不同:

失败重试有没有意义
网络抖动、连接超时有意义,重试就能好
Broker 磁盘满有意义,运维介入后能好
交换机名写错没意义,重试 1000 次也是错
消息体超过 maxMessageSize没意义
消费者业务逻辑 bug不该重试(消费侧的 DLQ 处理了)

怎么解(现有方案): 自定义一个 MsgException,只有它才触发 @Retryable。 包装逻辑在 catch 里:

java
} catch (Exception e) {
    throw MsgException.builder().failMsgEntity(...).build();
}

这个方案的问题(也值得讲): 它是"catch 住所有异常再统一包装", 所以分不清哪种是哪种——交换机名写错也会被重试 3 次。 更精确的做法是按异常类型分级:

java
// 可以重试:网络类
if (e instanceof AmqpConnectException || e instanceof AmqpIOException) {
    throw new MsgRetryableException(...);
}
// 不可重试:配置/路由类,直接落库不重试
if (e instanceof AmqpIllegalStateException) {
    this.failMsgService.save(...);     // 直接落库
    return true;
}

难点 2:消息"路由失败"是最隐蔽的一类

难点在哪: 前面五层里,publisher confirm 和异常重试都覆盖不到这一类。

text
convertAndSend 成功
  → broker 收到,exchange 存在
  → 但 routingKey 没有匹配的 binding
  → 消息被 broker 丢弃
  → confirm 仍然返回 ack(因为 exchange 确实收到了)

这是 RabbitMQ 的一个重要语义: confirm 只保证"exchange 收到了",不保证"某个 queue 收到了"。

怎么解: RabbitMQ 提供了另一个机制 —— Publisher Returns:

java
// MessageConfig
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
    var rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
    rabbitTemplate.setReturnsCallback(message -> {
        // 延迟消息没有发到队列是正常情况,无需记录日志
        if (StrUtil.contains(message.getExchange(), Constants.MQ.DELAYED_KEYWORD)) {
            return;
        }
        // 投递失败,记录日志
        log.error("消息没有投递到队列,应答码:{},原因:{},交换机:{},路由键:{},消息:{}",
                message.getReplyCode(), message.getReplyText(),
                message.getExchange(), message.getRoutingKey(), message.getMessage());
    });
}

必须配两个开关,否则回调不触发:

yaml
spring:
  rabbitmq:
    publisher-returns: true        # 开启 publisher-return
    template:
      mandatory: true              # 必须显式声明!否则不路由的消息会被静默丢弃

代码里还有一个很有意思的处理:

java
if (StrUtil.contains(message.getExchange(), Constants.MQ.DELAYED_KEYWORD)) {
    return;    // 延迟消息没有发到队列是正常情况
}

因为延迟消息在延迟期间本来就"还没到目标队列", Returns 回调会被误触发。所以按交换机名带 delayed 关键字过滤掉。

这个细节说明作者踩过这个坑。 面试时讲出来很能体现"我读得懂代码"。

难点 3:消费侧的幂等(这里其实是空白)

难点在哪: 重试机制保证了"最终会处理",但也保证了"可能处理多次"。 Spring AMQP 的 stateless 重试是"退回到队列头部重投", 消息原样重发,消费者必须自己保证幂等。

当前项目的现实:

服务消费者幂等处理
omsMQListener(4 个)无
workCourierMQListener / TransportTaskMQListener无
searchCourierTaskMQListener无
trackMQListener无
dispatchOrderMQListener无
transport-infoTransportInfoMQListener无

唯一的"幂等"痕迹是代码注释:

java
// TransportOrderDispatchMQListener 的 Javadoc
 * 核心职责:
 * 1. 消费MQ中的运单消息
 * 2. 做幂等控制(防止重复消费)      ← 写了,没做
 * 3. 调用AI服务计算优先级
 * 4. 将运单写入Redis ZSet,实现优先级排序

现有代码里"看起来像幂等"的三处,其实都不够:

java
// OMS:只是过滤掉没有状态的消息,不是幂等
if (ObjectUtil.isEmpty(transportOrderStatusMsg.getStatusCode())) { return; }

// OMS 支付:过滤出"已结算"的,重复消息会重复 update(虽然 update 是幂等写)
var msgList = tradeStatusMsgList.stream()
        .filter(v -> v.getStatusCode().equals(TradingStateEnum.YJS.getCode()))
        .collect(Collectors.toList());

// track:先查后插,并发下不安全
if (ObjectUtil.isNotEmpty(trackEntity)) {
    throw new CCException(TrackExceptionEnum.TRACK_ALREADY_EXISTS);
}

如果重做(分层):

java
// 第 1 层:业务状态机天然幂等(最优)
// 状态流转只允许"从 A 到 B",重复到达 B 时不产生新动作
public boolean updateStatus(Long id, DriverJobStatusEnum target) {
    LambdaUpdateWrapper<DriverJobEntity> w = Wrappers.lambdaUpdate();
    w.eq(DriverJobEntity::getId, id)
     .eq(DriverJobEntity::getStatus, DriverJobStatusEnum.PROCESSING.getCode())  // ← 乐观锁式的条件更新
     .set(DriverJobEntity::getStatus, target.getCode());
    return super.update(w);   // 返回 false 说明状态已经变了,直接忽略
}
java
// 第 2 层:唯一索引兜底
// 消费时如果插入遇到唯一键冲突,识别为"已处理过",直接 ack
try {
    this.taskService.save(task);
} catch (DuplicateKeyException e) {
    log.info("任务已存在,跳过:{}", task.getId());
}
java
// 第 3 层:消息去重表(跨服务通用)
// INSERT INTO cc_msg_consume(msg_id, consumer) VALUES (?, ?)
//   → DuplicateKeyException = 重复消费 → 直接返回

四、实际遇到的困难与踩坑

坑 1(致命):routingKey 从来没有落库,补偿任务发不出去

这是整个 chain-cloud-mq 模块最严重的缺陷, 它让第 4 层补偿完全失效。

事实: FailMsgEntity 有 routingKey 字段,cc_fail_msg 表有 routing_key 列, FailMsgJob 重发时也用了 failMsgEntity.getRoutingKey()。但是——

java
// MQServiceImpl 三处 FailMsgEntity.builder()
// 第 1 处:nack 回调(约 76-80 行)
var failMsgEntity = FailMsgEntity.builder()
        .exchange(exchange)
        .msgId(correlationData.getId())
        .msg(jsonMsg)
        .reason(result.getReason()).build();
//        ↑ 没有 .routingKey(...)

// 第 2 处:发送异常回调(约 88-92 行)
var failMsgEntity = FailMsgEntity.builder()
        .exchange(exchange)
        .msgId(correlationData.getId())
        .msg(jsonMsg)
        .reason(exceptionInfo).build();
//        ↑ 也没有

// 第 3 处:MsgException 里(约 111-115 行)
.failMsgEntity(FailMsgEntity.builder()
        .exchange(exchange)
        .msgId(correlationData.getId())
        .msg(jsonMsg)
        .reason(ExceptionUtil.getMessage(e)).build())
//        ↑ 同样没有
java
// 实体定义
@TableName("cc_fail_msg")
public class FailMsgEntity extends BaseEntity {
    private String msgId;
    private String exchange;
    private String routingKey;      // ← 字段是有的,就是不赋值
    private String msg;
    private String reason;
}

后果链条:

text
发送失败 → 落库 cc_fail_msg,routing_key = NULL
  → failMsgJob 读出来
  → sendMsg(exchange, null, msg)
  → TOPIC 交换机用 null 路由 → 匹配不到任何 binding
  → 消息再次被丢弃
  → 但 sendMsg 返回 true(乐观返回)
  → failMsgJob 执行 removeById,**把这条记录删掉了**
  → 消息永久丢失,且没有任何痕迹

也就是说:不仅补偿失败,失败记录还被删了 —— 这是"静默丢失"。

修复(一行 × 三处):

java
var failMsgEntity = FailMsgEntity.builder()
        .exchange(exchange)
        .routingKey(routingKey)          // ← 加这一行
        .msgId(correlationData.getId())
        .msg(jsonMsg)
        .reason(result.getReason()).build();

这个坑为什么必须讲: 它是"设计完备但实现漏了一个字段"的典型。 五层兜底设计得非常好,但第 4 层因为一个字段没赋值而完全无效, 而且失效方式是静默的——测试会通过,演示会成功,只有在真正丢消息时才会暴露。

面试话术: "我们做消息可靠性的时候设计了五层兜底:publisher confirm、发送重试、失败落库、 定时补偿、消费死信。上线后代码 review 我自己发现的: 落库的时候没把 routingKey 存进去。 所以补偿任务重发的时候路由键是 null,消息发不出去, 但因为发送方法是乐观返回 true,补偿任务还会把这条记录删掉—— 等于补偿机制本身制造了消息丢失。 修复是一行代码,但我认为这件事真正教我的是: 可靠性设计的每一层都要独立验证,不能假设上一层是对的。"

坑 2:@Retryable 在自调用时不生效

事实:

java
@Retryable(value = MsgException.class, maxAttempts = 3, backoff = @Backoff(value = 3000, multiplier = 1.5))
@Override
public Boolean sendMsg(String exchange, String routingKey, Object msg) {
    // 实时发送
    return this.sendMsg(exchange, routingKey, msg, Constants.MQ.DEFAULT_DELAY);   // ← this. 自调用
}

@Retryable(value = MsgException.class, maxAttempts = 3, backoff = @Backoff(value = 3000, multiplier = 1.5))
@Override
public Boolean sendMsg(String exchange, String routingKey, Object msg, int delay) {
    ...
}

this.sendMsg(...) 是同类自调用, Spring 的 @Retryable 依赖 AOP 代理,自调用不会重新走代理, 所以内层的 @Retryable 完全不生效。

实际影响:

  • 从 3 参数版本进来 → 外层代理重试 3 次(每次内部调 4 参版本,无重试)→ 总共 3 次 ✅
  • 从 4 参版本进来 → 代理重试 3 次 ✅

所以行为上碰巧是对的,但这是巧合,不是设计。 一旦以后有人改了重试次数或加了逻辑,就会出错。

修复:

java
@Autowired @Lazy private MQService self;   // 注入代理而不是自己

@Override
public Boolean sendMsg(String exchange, String routingKey, Object msg) {
    return self.sendMsg(exchange, routingKey, msg, Constants.MQ.DEFAULT_DELAY);
}

这题是 Spring AOP 的经典考点,几乎必问。 答"自调用不走代理所以注解失效"是及格, 能说出"用 @Lazy 注入自己 / 用 AopContext.currentProxy()"是加分。

坑 3:sendMsg 乐观返回 true

事实:

java
try {
    this.rabbitTemplate.convertAndSend(exchange, routingKey, message, ...);
} catch (Exception e) {
    throw MsgException.builder()...;
}
return true;      // ← 没有等 confirm 就返回 true

convertAndSend 只是把消息交给客户端缓冲区,不代表 broker 收到了。 真正的结果是异步回调里才知道。

结合坑 1 一起看,FailMsgJob 的逻辑就变成:

java
this.mqService.sendMsg(...);              // 乐观返回 true
this.failMsgService.removeById(...);      // 删掉失败记录

即使 routingKey 正确,这条逻辑也是危险的—— 发送其实是失败的,但因为返回 true,记录照样被删。

修复:

java
// 方案 1:补偿任务里等 confirm 结果(需要改 API 语义)
// 方案 2:补偿任务不删记录,改为标记状态,靠下一个轮次重试
//         发送成功后由 confirm 回调把记录标记为 SUCCESS
// 方案 3(最简单、最可靠):给 cc_fail_msg 加 retry_count,
//   超过阈值就不再重试但保留记录,同时告警

方案 3 顺带解决另一个问题: 现在 cc_fail_msg 没有 retry_count 列, 所以一个永久失败的消息(比如已删除的交换机) 会被无限重试,每次 XXL-Job 都捞 100 条重试。 如果队列里堆积了 1 万条永久失败的消息, 补偿任务会一直空转,而且永远处理不完前面的僵尸消息。

坑 4:补偿任务在哪个服务跑,取决于哪个服务引了这个包

事实:

chain-cloud-mq 被两个模块依赖:

依赖方有 XXL-Job Executor?failMsgJob 会跑吗
chain-cloud-ms-base-service✅ 有 XxlJobSpringExecutor✅ 会跑
chain-cloud-ms-oms-service❌ 没有❌ 不会跑

@XxlJob 处理器只有在注册了 XxlJobSpringExecutor 的应用里才会被调度。 oms 虽然有 chain-cloud-mq 依赖、也有 @EnableRetry, 但没有 XXL-Job 执行器,所以它里面定义的 failMsgJob 处理器永远不会被触发。

所以现状是:只有 base-service 在跑补偿任务。

但这里有个更深的结构性问题:

text
oms-service 发送消息 → 通过 MQFeign(Feign 调 base-service 的 /mq 接口)
                                    ↓
                        base-service 执行发送 + 重试 + 落库 cc_fail_msg
                                    ↓
                        base-service 的 failMsgJob 做补偿

OMS 自己不发消息,所有发送都代理给 base-service。 这个设计的初衷可能是"把 MQ 能力收敛到一个服务,避免各服务都要配 MQ", 但代价是:

  1. base-service 成为 MQ 的单点 —— 它挂了,全项目发不出消息;
  2. 它俩耦合 —— base-service 要知道自己被用来发什么消息;
  3. 配置分散 —— @ConditionalOnProperty(prefix = "cc.mq", value = "enable")

意味着每个引入方都要在 Nacos 里配这个开关,漏配一个就静默失效。

如果重做,我会把 MQ 能力做成一个独立的基础服务(或者 SDK + 各自独立配置):

text
chain-cloud-ms-mq  ——  独立的"消息网关"服务
  ├── 提供 HTTP/SDK 接口
  ├── 自己持有 cc_fail_msg 表
  ├── 自己注册 XXL-Job 执行器
  └── 其它服务通过它发消息,但账号/队列按发送方隔离

或者更轻量:不代理,各服务直接用 SDK,SDK 里的补偿任务用本地定时器 (@Scheduled + 分布式锁),而不是依赖 XXL-Job 中心。

坑 5:异步 confirm 回调里的 DB 写入不在任何事务里

事实:

java
correlationData.getFuture().addCallback(result -> {
    ...
    this.failMsgService.save(failMsgEntity);    // 在 Rabbit 客户端线程上执行
}, ...);

问题:

  1. 不在业务事务里 —— 业务事务早就提交了(或者根本没开始);
  2. 不在 @Retryable 代理里 —— 这里的 save 失败没有任何重试保护;
  3. 不在任何线程池里 —— 直接跑在 Rabbit 客户端的 IO 线程上,

DB 慢会阻塞整个 Rabbit 客户端的 IO 线程, 严重时会拖垮这个应用的所有 MQ 操作。

修复:

java
// 把 confirm 回调交给独立的线程池,不要占用 Rabbit IO 线程
@Bean("mqConfirmExecutor")
public Executor mqConfirmExecutor() {
    ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
    e.setCorePoolSize(2);
    e.setMaxPoolSize(8);
    e.setQueueCapacity(1000);
    e.setThreadNamePrefix("mq-confirm-");
    e.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    return e;
}
java
@Resource(name = "mqConfirmExecutor")
private Executor mqConfirmExecutor;
...
correlationData.getFuture().addCallback(result -> {
    mqConfirmExecutor.execute(() -> { /* 落库 */ });
}, ...);

更好的做法:异步确认本身也不可靠 (回调可能因为进程崩溃而丢失),所以真正彻底的方案是 transactional outbox:业务事务里把消息写进 cc_outbox 表, 由独立的轮询任务读 outbox 发消息。这样"业务提交"和"消息一定发出去" 就是原子性的。


五、如果重做,我会怎么改

优先级改动收益
P0三处 FailMsgEntity 补 .routingKey(routingKey)让补偿机制真正生效
P0补偿任务改成"发送确认后再删",或加 retry_count + 状态标记消除"补偿机制制造消息丢失"
P0消费者加幂等(状态机条件更新 + 唯一索引)消除重复消费
P1self 注入修掉自调用问题重试行为可预期
P1confirm 回调交给独立线程池不阻塞 Rabbit IO 线程
P1cc_fail_msg 加 retry_count + status + 告警消除僵尸消息无限重试
P1给 cc.queue.error.* 加消费端,人工处理可视化死信不再无人问津
P2MQ 能力独立成服务或 SDK 自带调度消除 base-service 单点
P2引入 transactional outbox彻底解决"DB 成功但消息失败"

P2 的 transactional outbox 值得单独说

这是分布式消息可靠性的"终极答案",也是最高频的面试追问之一。

text
当前方案("发消息 + 失败补偿"):
  BEGIN
    INSERT cc_order           ← 事务 1
  COMMIT
  sendMsg(...)                ← 事务外
       ↓ 失败 → cc_fail_msg → XXL-Job 重发
  问题:① 两个事务之间有窗口  ② 补偿可能无限重试  ③ 需要额外表

Outbox 方案("消息也进事务"):
  BEGIN
    INSERT cc_order
    INSERT cc_outbox(msg, exchange, routingKey, status=PENDING)   ← 同一个事务!
  COMMIT
  ↓
  轮询任务(或 CDC/binlog 订阅):
     SELECT * FROM cc_outbox WHERE status=PENDING LIMIT 100
     → 发消息 → 等 confirm
     → ack: UPDATE status=SUCCESS
     → nack: UPDATE retry_count++, 超阈值则 status=DEAD

Outbox 的核心优势:

  1. 原子性 —— 订单和消息在同一个本地事务里,不存在窗口;
  2. 可观测 —— outbox 表就是一份"待发消息清单",运维随时能查;
  3. 有界重试 —— retry_count 有上限,不会无限重试;
  4. 天然有序 —— 可以按 id 顺序发,保证同一订单的消息不乱序。

它的代价:

  • 多一张表 + 多一次 DB 写入;
  • 消息延迟增加(取决于轮询间隔,或者要用 binlog CDC 把延迟压到毫秒级);
  • 需要处理重复消费(因为至少一次投递是必然的)——

所以幂等还是躲不掉,只是从"可选"变成"必需"。

关于"消息延迟": 轮询方案(1 秒一次)会让消息延迟 0~1 秒; 对"5 秒延迟消息"这个场景无所谓。 但如果要求毫秒级,要上 Debezium + Kafka Connect / Canal 订阅 MySQL binlog, 延迟可以压到 100ms 以内。这是要不要上 CDC 的判断标准。


六、面试问答

Q1:消息发送失败怎么保证不丢?

A: 我们做了五层,从发送到消费:

text
1. 交换机/队列持久化 + 消息 PERSISTENT
2. Publisher Confirm(CorrelationData 异步回调)
3. Publisher Returns(mandatory + returnsCallback,捕捉"路由不到队列")
4. 发送异常 Spring Retry(3 次,退避 3s×1.5)+ @Recover 落库 cc_fail_msg
5. XXL-Job failMsgJob 定时补偿(100 条/次,先进先出)
消费侧:3 次重试 + RepublishMessageRecoverer 进 cc.queue.error.<服务名>

但我要主动说一个我们 review 出来的 bug: 落库的时候没存 routingKey,导致第 5 层补偿发不出去, 而且因为发送方法乐观返回 true,补偿任务还会把记录删掉—— 等于补偿机制本身制造了消息丢失。 这是一行代码的疏漏,但它说明可靠性设计的每一层都必须独立验证。

Q2:为什么不用 transactional outbox?

A: 我们当时的时间不够,选了"发送 + 失败落库 + 定时补偿"这条更快的路。 它的优势是改造成本低——业务代码不用改,只在发送的地方加一层。

但它的缺陷也很明确:

text
1. DB 提交和消息发送之间有窗口(我们用"延迟 5 秒"来缓解,但这只是缓解)
2. 补偿任务可能无限重试(没有 retry_count)
3. 没有"待发消息"的可观测视图(出问题时只能看日志)

如果重做我会选 outbox,因为它的核心优势是原子性: 订单和消息写在同一个本地事务里,不存在中间窗口。 代价是多一张表、消息延迟增加一个轮询周期。 如果对延迟敏感(要毫秒级),就得上 binlog CDC。

Q3:怎么保证消费者不重复处理?

A: 坦白说,我们大部分消费者没有做幂等,这是个缺口。

正确的做法我认为应该分三层:

text
第 1 层(最优):业务状态机幂等
   状态流转用"条件更新"—— UPDATE ... WHERE id=? AND status=?
   重复消息到达时 WHERE 不匹配,affected rows = 0,直接忽略
   这层是免费的,而且符合业务语义

第 2 层:唯一索引兜底
   依赖数据库保证。比如运单唯一、任务 (order_id, task_type) 唯一
   插入时捕获 DuplicateKeyException,识别为"已处理"直接 ack

第 3 层:消息去重表
   INSERT INTO cc_msg_consume(msg_id, consumer) —— 唯一键冲突即重复
   通用中间件方案,但多一次 IO

关键是第 1 层,因为它零成本且天然防重。 项目里 driver 服务的 intoStorage 实际上就是这个模式—— 它加了 Redisson 公平锁 + 状态检查,是全项目幂等做得最完整的地方。

Q4:@Retryable 和消费者重试有什么区别?为什么两套都要?

A: 它们在不同的位置,解决不同的问题:

@Retryable(Spring Retry)listener.simple.retry(Spring AMQP)
位置生产者侧消费者侧
触发发送时抛异常消费方法抛异常
次数3 次,退避 3s×1.53 次,间隔 1s
失败后落库 cc_fail_msg投到 cc.queue.error.<服务名>
处理方XXL-Job 自动补偿人工(当前无自动处理)

两套都要,因为故障面不同: 生产者的故障是"发不出去",消费者的故障是"处理不了"。 只有生产者重试 → 处理失败的消息没人管; 只有消费者重试 → 压根没送到的消息没人管。

追问:为什么生产者重试的退避是 3s×1.5,消费者的只有 1s×1? 生产者的故障通常是基础设施级的(broker 挂了、网络抖了), 需要给基础设施恢复时间,所以退避要长; 消费者的重试是业务逻辑问题(比如下游数据还没到), 等太久没意义,反而拖慢了正常消息的处理。

Q5:延迟消息是怎么实现的?可靠吗?

A: 用的是 RabbitMQ 的 delayed-message 插件:

java
messagePostProcessor.getMessageProperties().setDelay(delay);

配合交换机声明:

java
@Exchange(name = "...", type = ExchangeTypes.TOPIC, delayed = Constants.MQ.DELAYED)

原理: 消息先进入一个内部按延迟时间分桶的队列(每毫秒一个队列), 到期后被"死信路由"机制转发到目标队列。

它的局限值得说:

  1. 精度是毫秒级,但实际会晚几十毫秒到几百毫秒(过期扫描周期);
  2. 依赖插件 —— 不是 RabbitMQ 官方核心功能,需要单独安装;
  3. 积压风险 —— 如果有百万级延迟消息,插件的定时队列会占大量内存;
  4. 无法取消 —— 一旦发出就不能撤回(业务上我们其实需要能撤回)。

替代方案:

方案精度是否需要插件能取消
delayed-message 插件ms 级是否
TTL + 死信队列精度差(队头阻塞)否否
延迟队列插件(Quorum Queue)ms 级否否
业务表时间字段 + 定时扫描秒/分钟级否是

如果业务需要"能取消",就得用最后一种: 订单表加一个 dispatch_at 字段,定时任务扫 dispatch_at <= now() 的记录, 这样改时间只要 update 一个字段,不用重发消息。


七、源码定位速查

文件行作用
chain-cloud-mq/.../service/impl/MQServiceImpl.java60-120sendMsg 主流程(confirm + 重试 + 延迟)
同上76-80, 88-92, 111-115⚠️ 三处 FailMsgEntity 均缺 .routingKey()
同上127-132@Recover saveFailMag
chain-cloud-mq/.../job/FailMsgJob.java31-45failMsgJob 补偿任务
chain-cloud-mq/.../config/ErrorMessageConfig.java—死信交换机/队列 + RepublishMessageRecoverer
chain-cloud-mq/.../config/MessageConfig.java—ReturnsCallback(routing 失败监控)
chain-cloud-mq/.../entity/FailMsgEntity.java—cc_fail_msg 实体
chain-cloud-mq/.../exception/MsgException.java—携带数据的重试触发异常
chain-cloud-common/.../Constants.java33-34017 个交换机 / 21 个队列 / 18 个路由键
同上38-48DEFAULT_DELAY=-1 / LOW_DELAY=5000 / NORMAL_DELAY=10000

常量体系速查(背下来很有用)

交换机(全部 TOPIC,延迟队列以 .delayed 结尾):

常量实际名称生产方消费方
ORDER_DELAYEDcc.exchange.topic.order.delayedoms, workdispatch
PICKUP_DISPATCH_TASK_DELAYEDcc.exchange.topic.pickupDispatchTask.delayeddispatchwork
TRANSPORT_ORDER_DELAYEDcc.exchange.topic.transportOrder.delayedwork, web-courieroms, track, search, dispatch
TRANSPORT_TASKcc.exchange.topic.transportTaskworkwork
COURIERcc.exchange.topic.courierweb-courierwork
COURIER_TASKcc.exchange.topic.courierTaskworksearch
TRADEcc.exchange.topic.tradetradeoms
TRANSPORT_INFOcc.exchange.topic.transportInfowork, web-couriertransport-info
DRIVER_JOBcc.exchange.topic.driverJobwork(未消费)
TRUCK_PLANcc.exchange.topic.truckPlan—base
ERRORcc.exchange.topic.errorRepublishMessageRecoverer人工
AIGCcc.exchange.topic.aigc(未使用)—

注意 TRANSPORT、DRIVER、USER、TRUCK、SYSTEM、AIGC 六个交换机声明了但全仓库无任何生产者/消费者——这是可以直接说出来的细节。