亮点五 · 订单存进去了,但消息没发出去 = 一张死单
业务问题: 客户下单成功,页面显示"下单成功"。 但如果此刻负责触发调度的 MQ 发失败了, 这个订单就会永远躺在数据库里,没有任何快递员知道它存在。 客户等不到快递员,投诉,客服查后台看到订单状态是"待取件", 研发查日志发现消息根本没发出去——这就是一张死单。
这是分布式系统里最典型的"数据成功但动作失败",也是最常见的线上事故之一。
一、先理解业务:为什么这个场景真的会发生
不是"理论上可能",而是每天都在发生:
| 失败场景 | 频率 | 表现 |
|---|---|---|
发送时 convertAndSend 抛异常(交换机不存在、连接断了) | 低(配置变更时集中出现) | 立即失败,可捕获 |
| Broker 收到了但没有路由到任何队列(routingKey 写错、队列未绑定) | 中(发版时集中出现) | 最难发现——不报错,消息凭空消失 |
| Broker 收到但返回 nack(磁盘满、内部错误) | 低 | 异步回调才能知道 |
| 发送成功后进程崩溃,回调没来得及执行 | 低 | 消息状态未知 |
| 消费者处理成功但 ack 丢失 | 中(网络抖动时) | 重复消费 |
这五种场景需要五种不同的应对。 只有一个方案必然是漏的, 所以 chain-cloud-mq 设计了五层。
二、五层兜底设计
第 1 层:交换机/队列声明 + 持久化
// 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);
}消息本身也是持久化的:
var message = MessageBuilder.withBody(StrUtil.bytes(jsonMsg, CharsetUtil.CHARSET_UTF_8))
.setDeliveryMode(MessageDeliveryMode.PERSISTENT) // 持久化
.build();持久化消息 ≠ 绝对不丢。 持久化只保证 broker 重启后消息还在, 但 RabbitMQ 默认把持久化消息写内存再落盘, 断电时仍有丢失窗口。生产环境要配
queue的lazy模式或用 quorum queue。
第 2 层:Publisher Confirm(发送确认)
// 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 回调不触发):
yamlspring: rabbitmq: publisher-confirm-type: correlated
第 3 层:发送异常重试(Spring Retry)
@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 次也没用)。
// MsgException
@Data @Builder
public class MsgException extends RuntimeException {
private FailMsgEntity failMsgEntity; // 直接把待落库的数据挂在异常上
}这个设计很巧妙,值得在面试里讲: 异常不只是"控制流",它还携带了数据。
第 4 层:失败消息落库 + XXL-Job 补偿
表结构:
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;补偿任务:
// 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);
}
}
}
}设计要点:
- 先按
created ASC再 limit 100 —— 保证先进先出,
避免老消息被新消息挤占而饿死;
- 删表而不是标记状态 —— 简单直接;
- 逐条 try-catch —— 一条失败不影响后面的。
第 5 层:消费侧死信队列(DLQ)
// ErrorMessageConfig
@Value("${spring.application.name}")
private String appName;
@Bean
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate) {
return new RepublishMessageRecoverer(rabbitTemplate, Constants.MQ.Exchanges.ERROR, appName);
}配合消费端重试配置:
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: auto # 异常时 nack,消息回滚
retry:
enabled: true # 开启消费者失败重试
initial-interval: 1000 # 初始等待 1 秒
multiplier: 1
max-attempts: 3 # 最大重试 3 次
stateless: true # 无状态重试完整流程:
消费失败
→ 间隔 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 里:
} catch (Exception e) {
throw MsgException.builder().failMsgEntity(...).build();
}这个方案的问题(也值得讲): 它是"catch 住所有异常再统一包装", 所以分不清哪种是哪种——交换机名写错也会被重试 3 次。 更精确的做法是按异常类型分级:
// 可以重试:网络类
if (e instanceof AmqpConnectException || e instanceof AmqpIOException) {
throw new MsgRetryableException(...);
}
// 不可重试:配置/路由类,直接落库不重试
if (e instanceof AmqpIllegalStateException) {
this.failMsgService.save(...); // 直接落库
return true;
}难点 2:消息"路由失败"是最隐蔽的一类
难点在哪: 前面五层里,publisher confirm 和异常重试都覆盖不到这一类。
convertAndSend 成功
→ broker 收到,exchange 存在
→ 但 routingKey 没有匹配的 binding
→ 消息被 broker 丢弃
→ confirm 仍然返回 ack(因为 exchange 确实收到了)这是 RabbitMQ 的一个重要语义: confirm 只保证"exchange 收到了",不保证"某个 queue 收到了"。
怎么解: RabbitMQ 提供了另一个机制 —— Publisher Returns:
// 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());
});
}必须配两个开关,否则回调不触发:
spring:
rabbitmq:
publisher-returns: true # 开启 publisher-return
template:
mandatory: true # 必须显式声明!否则不路由的消息会被静默丢弃代码里还有一个很有意思的处理:
if (StrUtil.contains(message.getExchange(), Constants.MQ.DELAYED_KEYWORD)) {
return; // 延迟消息没有发到队列是正常情况
}因为延迟消息在延迟期间本来就"还没到目标队列", Returns 回调会被误触发。所以按交换机名带 delayed 关键字过滤掉。
这个细节说明作者踩过这个坑。 面试时讲出来很能体现"我读得懂代码"。
难点 3:消费侧的幂等(这里其实是空白)
难点在哪: 重试机制保证了"最终会处理",但也保证了"可能处理多次"。 Spring AMQP 的 stateless 重试是"退回到队列头部重投", 消息原样重发,消费者必须自己保证幂等。
当前项目的现实:
| 服务 | 消费者 | 幂等处理 |
|---|---|---|
oms | MQListener(4 个) | 无 |
work | CourierMQListener / TransportTaskMQListener | 无 |
search | CourierTaskMQListener | 无 |
track | MQListener | 无 |
dispatch | OrderMQListener | 无 |
transport-info | TransportInfoMQListener | 无 |
唯一的"幂等"痕迹是代码注释:
// TransportOrderDispatchMQListener 的 Javadoc
* 核心职责:
* 1. 消费MQ中的运单消息
* 2. 做幂等控制(防止重复消费) ← 写了,没做
* 3. 调用AI服务计算优先级
* 4. 将运单写入Redis ZSet,实现优先级排序现有代码里"看起来像幂等"的三处,其实都不够:
// 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);
}如果重做(分层):
// 第 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 说明状态已经变了,直接忽略
}// 第 2 层:唯一索引兜底
// 消费时如果插入遇到唯一键冲突,识别为"已处理过",直接 ack
try {
this.taskService.save(task);
} catch (DuplicateKeyException e) {
log.info("任务已存在,跳过:{}", task.getId());
}// 第 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()。但是——
// 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())
// ↑ 同样没有// 实体定义
@TableName("cc_fail_msg")
public class FailMsgEntity extends BaseEntity {
private String msgId;
private String exchange;
private String routingKey; // ← 字段是有的,就是不赋值
private String msg;
private String reason;
}后果链条:
发送失败 → 落库 cc_fail_msg,routing_key = NULL
→ failMsgJob 读出来
→ sendMsg(exchange, null, msg)
→ TOPIC 交换机用 null 路由 → 匹配不到任何 binding
→ 消息再次被丢弃
→ 但 sendMsg 返回 true(乐观返回)
→ failMsgJob 执行 removeById,**把这条记录删掉了**
→ 消息永久丢失,且没有任何痕迹也就是说:不仅补偿失败,失败记录还被删了 —— 这是"静默丢失"。
修复(一行 × 三处):
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 在自调用时不生效
事实:
@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 次 ✅
所以行为上碰巧是对的,但这是巧合,不是设计。 一旦以后有人改了重试次数或加了逻辑,就会出错。
修复:
@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
事实:
try {
this.rabbitTemplate.convertAndSend(exchange, routingKey, message, ...);
} catch (Exception e) {
throw MsgException.builder()...;
}
return true; // ← 没有等 confirm 就返回 trueconvertAndSend 只是把消息交给客户端缓冲区,不代表 broker 收到了。 真正的结果是异步回调里才知道。
结合坑 1 一起看,FailMsgJob 的逻辑就变成:
this.mqService.sendMsg(...); // 乐观返回 true
this.failMsgService.removeById(...); // 删掉失败记录即使 routingKey 正确,这条逻辑也是危险的—— 发送其实是失败的,但因为返回 true,记录照样被删。
修复:
// 方案 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 在跑补偿任务。
但这里有个更深的结构性问题:
oms-service 发送消息 → 通过 MQFeign(Feign 调 base-service 的 /mq 接口)
↓
base-service 执行发送 + 重试 + 落库 cc_fail_msg
↓
base-service 的 failMsgJob 做补偿OMS 自己不发消息,所有发送都代理给 base-service。 这个设计的初衷可能是"把 MQ 能力收敛到一个服务,避免各服务都要配 MQ", 但代价是:
- base-service 成为 MQ 的单点 —— 它挂了,全项目发不出消息;
- 它俩耦合 —— base-service 要知道自己被用来发什么消息;
- 配置分散 ——
@ConditionalOnProperty(prefix = "cc.mq", value = "enable")
意味着每个引入方都要在 Nacos 里配这个开关,漏配一个就静默失效。
如果重做,我会把 MQ 能力做成一个独立的基础服务(或者 SDK + 各自独立配置):
chain-cloud-ms-mq —— 独立的"消息网关"服务
├── 提供 HTTP/SDK 接口
├── 自己持有 cc_fail_msg 表
├── 自己注册 XXL-Job 执行器
└── 其它服务通过它发消息,但账号/队列按发送方隔离或者更轻量:不代理,各服务直接用 SDK,SDK 里的补偿任务用本地定时器 (@Scheduled + 分布式锁),而不是依赖 XXL-Job 中心。
坑 5:异步 confirm 回调里的 DB 写入不在任何事务里
事实:
correlationData.getFuture().addCallback(result -> {
...
this.failMsgService.save(failMsgEntity); // 在 Rabbit 客户端线程上执行
}, ...);问题:
- 不在业务事务里 —— 业务事务早就提交了(或者根本没开始);
- 不在
@Retryable代理里 —— 这里的save失败没有任何重试保护; - 不在任何线程池里 —— 直接跑在 Rabbit 客户端的 IO 线程上,
DB 慢会阻塞整个 Rabbit 客户端的 IO 线程, 严重时会拖垮这个应用的所有 MQ 操作。
修复:
// 把 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;
}@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 | 消费者加幂等(状态机条件更新 + 唯一索引) | 消除重复消费 |
| P1 | self 注入修掉自调用问题 | 重试行为可预期 |
| P1 | confirm 回调交给独立线程池 | 不阻塞 Rabbit IO 线程 |
| P1 | cc_fail_msg 加 retry_count + status + 告警 | 消除僵尸消息无限重试 |
| P1 | 给 cc.queue.error.* 加消费端,人工处理可视化 | 死信不再无人问津 |
| P2 | MQ 能力独立成服务或 SDK 自带调度 | 消除 base-service 单点 |
| P2 | 引入 transactional outbox | 彻底解决"DB 成功但消息失败" |
P2 的 transactional outbox 值得单独说
这是分布式消息可靠性的"终极答案",也是最高频的面试追问之一。
当前方案("发消息 + 失败补偿"):
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=DEADOutbox 的核心优势:
- 原子性 —— 订单和消息在同一个本地事务里,不存在窗口;
- 可观测 —— outbox 表就是一份"待发消息清单",运维随时能查;
- 有界重试 ——
retry_count有上限,不会无限重试; - 天然有序 —— 可以按
id顺序发,保证同一订单的消息不乱序。
它的代价:
- 多一张表 + 多一次 DB 写入;
- 消息延迟增加(取决于轮询间隔,或者要用 binlog CDC 把延迟压到毫秒级);
- 需要处理重复消费(因为至少一次投递是必然的)——
所以幂等还是躲不掉,只是从"可选"变成"必需"。
关于"消息延迟": 轮询方案(1 秒一次)会让消息延迟 0~1 秒; 对"5 秒延迟消息"这个场景无所谓。 但如果要求毫秒级,要上 Debezium + Kafka Connect / Canal 订阅 MySQL binlog, 延迟可以压到 100ms 以内。这是要不要上 CDC 的判断标准。
六、面试问答
Q1:消息发送失败怎么保证不丢?
A: 我们做了五层,从发送到消费:
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: 我们当时的时间不够,选了"发送 + 失败落库 + 定时补偿"这条更快的路。 它的优势是改造成本低——业务代码不用改,只在发送的地方加一层。
但它的缺陷也很明确:
1. DB 提交和消息发送之间有窗口(我们用"延迟 5 秒"来缓解,但这只是缓解)
2. 补偿任务可能无限重试(没有 retry_count)
3. 没有"待发消息"的可观测视图(出问题时只能看日志)如果重做我会选 outbox,因为它的核心优势是原子性: 订单和消息写在同一个本地事务里,不存在中间窗口。 代价是多一张表、消息延迟增加一个轮询周期。 如果对延迟敏感(要毫秒级),就得上 binlog CDC。
Q3:怎么保证消费者不重复处理?
A: 坦白说,我们大部分消费者没有做幂等,这是个缺口。
正确的做法我认为应该分三层:
第 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.5 | 3 次,间隔 1s |
| 失败后 | 落库 cc_fail_msg | 投到 cc.queue.error.<服务名> |
| 处理方 | XXL-Job 自动补偿 | 人工(当前无自动处理) |
两套都要,因为故障面不同: 生产者的故障是"发不出去",消费者的故障是"处理不了"。 只有生产者重试 → 处理失败的消息没人管; 只有消费者重试 → 压根没送到的消息没人管。
追问:为什么生产者重试的退避是 3s×1.5,消费者的只有 1s×1? 生产者的故障通常是基础设施级的(broker 挂了、网络抖了), 需要给基础设施恢复时间,所以退避要长; 消费者的重试是业务逻辑问题(比如下游数据还没到), 等太久没意义,反而拖慢了正常消息的处理。
Q5:延迟消息是怎么实现的?可靠吗?
A: 用的是 RabbitMQ 的 delayed-message 插件:
messagePostProcessor.getMessageProperties().setDelay(delay);配合交换机声明:
@Exchange(name = "...", type = ExchangeTypes.TOPIC, delayed = Constants.MQ.DELAYED)原理: 消息先进入一个内部按延迟时间分桶的队列(每毫秒一个队列), 到期后被"死信路由"机制转发到目标队列。
它的局限值得说:
- 精度是毫秒级,但实际会晚几十毫秒到几百毫秒(过期扫描周期);
- 依赖插件 —— 不是 RabbitMQ 官方核心功能,需要单独安装;
- 积压风险 —— 如果有百万级延迟消息,插件的定时队列会占大量内存;
- 无法取消 —— 一旦发出就不能撤回(业务上我们其实需要能撤回)。
替代方案:
| 方案 | 精度 | 是否需要插件 | 能取消 |
|---|---|---|---|
| delayed-message 插件 | ms 级 | 是 | 否 |
| TTL + 死信队列 | 精度差(队头阻塞) | 否 | 否 |
| 延迟队列插件(Quorum Queue) | ms 级 | 否 | 否 |
| 业务表时间字段 + 定时扫描 | 秒/分钟级 | 否 | 是 |
如果业务需要"能取消",就得用最后一种: 订单表加一个 dispatch_at 字段,定时任务扫 dispatch_at <= now() 的记录, 这样改时间只要 update 一个字段,不用重发消息。
七、源码定位速查
| 文件 | 行 | 作用 |
|---|---|---|
chain-cloud-mq/.../service/impl/MQServiceImpl.java | 60-120 | sendMsg 主流程(confirm + 重试 + 延迟) |
| 同上 | 76-80, 88-92, 111-115 | ⚠️ 三处 FailMsgEntity 均缺 .routingKey() |
| 同上 | 127-132 | @Recover saveFailMag |
chain-cloud-mq/.../job/FailMsgJob.java | 31-45 | failMsgJob 补偿任务 |
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.java | 33-340 | 17 个交换机 / 21 个队列 / 18 个路由键 |
| 同上 | 38-48 | DEFAULT_DELAY=-1 / LOW_DELAY=5000 / NORMAL_DELAY=10000 |
常量体系速查(背下来很有用)
交换机(全部 TOPIC,延迟队列以 .delayed 结尾):
| 常量 | 实际名称 | 生产方 | 消费方 |
|---|---|---|---|
ORDER_DELAYED | cc.exchange.topic.order.delayed | oms, work | dispatch |
PICKUP_DISPATCH_TASK_DELAYED | cc.exchange.topic.pickupDispatchTask.delayed | dispatch | work |
TRANSPORT_ORDER_DELAYED | cc.exchange.topic.transportOrder.delayed | work, web-courier | oms, track, search, dispatch |
TRANSPORT_TASK | cc.exchange.topic.transportTask | work | work |
COURIER | cc.exchange.topic.courier | web-courier | work |
COURIER_TASK | cc.exchange.topic.courierTask | work | search |
TRADE | cc.exchange.topic.trade | trade | oms |
TRANSPORT_INFO | cc.exchange.topic.transportInfo | work, web-courier | transport-info |
DRIVER_JOB | cc.exchange.topic.driverJob | work | (未消费) |
TRUCK_PLAN | cc.exchange.topic.truckPlan | — | base |
ERROR | cc.exchange.topic.error | RepublishMessageRecoverer | 人工 |
AIGC | cc.exchange.topic.aigc | (未使用) | — |
注意 TRANSPORT、DRIVER、USER、TRUCK、SYSTEM、AIGC 六个交换机声明了但全仓库无任何生产者/消费者——这是可以直接说出来的细节。