写在前面:我之前做过一个用户注册送优惠券的功能,看似简单------注册成功发张券嘛。结果上线第二天就出事了:有的用户注册成功了但没收到券,客服投诉一堆。排查发现是发MQ的时候网络抖了一下,消息丢了。从那以后我才真正重视"消息可靠性"这件事。这篇文章把本地消息表、RocketMQ事务消息、Outbox模式三种方案都讲透,代码都是能直接跑的,希望帮你少踩坑。

文章目录
-
- 一、为什么需要消息最终一致性?
- 二、消息一致性三大问题
-
- [1. 消息丢失](#1. 消息丢失)
- [2. 消息重复](#2. 消息重复)
- [3. 消息乱序](#3. 消息乱序)
- 三、方案1:本地消息表
- 四、方案2:RocketMQ事务消息
- [五、方案3:Outbox Pattern(发件箱模式)](#五、方案3:Outbox Pattern(发件箱模式))
- 六、三种方案对比表格
- 七、消费端幂等性设计
- 八、踩坑指南
- 九、问题与解答
-
- Q1:本地消息表的消息一直发送失败怎么办?
- Q2:RocketMQ事务消息的回查机制是怎么工作的?
- [Q3:Outbox Pattern中CDC工具挂了怎么办?](#Q3:Outbox Pattern中CDC工具挂了怎么办?)
- 十、面试高频考点汇总
-
- 考点1:如何保证消息不丢失?
- 考点2:如何保证消息不重复消费?
- 考点3:本地消息表和RocketMQ事务消息怎么选?
- [考点4:什么是Outbox Pattern?和本地消息表有什么区别?](#考点4:什么是Outbox Pattern?和本地消息表有什么区别?)
- 考点5:消费端幂等性有哪些实现方式?各有什么优缺点?
- 十一、模拟面试官提问和参考答案
- 互动话题
- 参考资料
一、为什么需要消息最终一致性?
场景引入
微服务架构中有大量"不需要强一致,但最终必须一致"的场景:
- 用户注册后 → 发优惠券
- 下单成功后 → 发短信通知
- 支付成功后 → 通知库存发货
- 评价商品后 → 增加积分
这些操作如果用分布式事务(2PC、TCC),太重了,性能也差。用消息队列来做异步解耦,才是正解。
生活类比
发快递:你下单后快递公司不需要立刻送达,但最终一定会送到。中间你可以查物流追踪(消息状态),如果丢了还能投诉(补偿机制)。消息最终一致性就是这个道理------不要求立刻一致,但最终必须一致。
二、消息一致性三大问题
在用MQ做最终一致性之前,先要搞清楚可能出什么问题。
1. 消息丢失
消息丢失的三个环节:
| 环节 | 原因 | 解决方案 |
|---|---|---|
| 生产者→MQ | 发送失败、网络抖动 | 同步发送 + 重试 / 事务消息 |
| MQ存储 | MQ宕机、磁盘故障 | 消息持久化 + 多副本 |
| MQ→消费者 | 消费失败、消费者宕机 | 手动ACK + 重试 |
2. 消息重复
- 生产者发送成功但没收到ACK,重试发送 → 消息重复
- 消费者消费成功但ACK失败,MQ重新投递 → 消息重复
解决方案:消费端幂等性设计(后面专门讲)
3. 消息乱序
- 多分区/多队列场景下,消息可能不按发送顺序到达
- 比如先发"扣库存"后发"发通知",结果消费者先收到"发通知"
解决方案:单一分区 / 消息携带序列号 / 业务上保证幂等无序执行
三、方案1:本地消息表
原理
把"发消息"这件事和业务操作放在同一个本地事务中。业务操作成功的同时,把消息记录写到本地数据库的一张"消息表"里。然后由定时任务扫描消息表,把未发送的消息投递到MQ。
┌──────────────────────────────────────┐
│ 本地数据库(同一事务) │
│ ┌─────────────┐ ┌──────────────┐ │
│ │ 业务表 │ │ 消息表 │ │
│ │ (user表) │ │ (msg表) │ │
│ └─────────────┘ └──────────────┘ │
└──────────────────────────────────────┘
│ 定时扫描
▼
┌──────────┐
│ MQ │
└──────────┘
│ 消费
▼
┌──────────┐
│ 消费方 │
└──────────┘
完整Java代码示例
消息表DDL
sql
CREATE TABLE `local_message` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`biz_id` varchar(64) NOT NULL COMMENT '业务ID(如订单号)',
`biz_type` varchar(32) NOT NULL COMMENT '业务类型',
`topic` varchar(128) NOT NULL COMMENT 'MQ主题',
`tag` varchar(64) DEFAULT NULL COMMENT 'MQ标签',
`message_key` varchar(64) DEFAULT NULL COMMENT '消息Key',
`message_body` text NOT NULL COMMENT '消息体(JSON)',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0-待发送 1-已发送 2-发送失败',
`retry_count` int(11) NOT NULL DEFAULT '0' COMMENT '重试次数',
`max_retry` int(11) NOT NULL DEFAULT '5' COMMENT '最大重试次数',
`next_retry_time` datetime DEFAULT NULL COMMENT '下次重试时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz` (`biz_id`, `biz_type`),
KEY `idx_status_retry` (`status`, `next_retry_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地消息表';
业务操作 + 消息记录(同一事务)
java
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private LocalMessageMapper messageMapper;
/**
* 创建订单 + 写入本地消息(同一事务)
*/
@Transactional(rollbackFor = Exception.class)
public String createOrder(CreateOrderRequest request) {
// 1. 创建订单
Order order = new Order();
order.setOrderId(IdGenerator.nextId());
order.setUserId(request.getUserId());
order.setProductId(request.getProductId());
order.setQuantity(request.getQuantity());
order.setAmount(request.getAmount());
order.setStatus(OrderStatus.PAID.getStatus());
order.setCreateTime(new Date());
orderMapper.insert(order);
// 2. 在同一事务中写入本地消息表
LocalMessage message = new LocalMessage();
message.setBizId(order.getOrderId().toString());
message.setBizType("ORDER_CREATE");
message.setTopic("order-topic");
message.setTag("order-created");
message.setMessageKey(order.getOrderId().toString());
// 消息体:包含订单信息,供消费方使用
OrderCreatedEvent event = new OrderCreatedEvent();
event.setOrderId(order.getOrderId());
event.setUserId(order.getUserId());
event.setProductId(order.getProductId());
event.setQuantity(order.getQuantity());
message.setMessageBody(JSON.toJSONString(event));
message.setStatus(0); // 待发送
message.setRetryCount(0);
message.setMaxRetry(5);
message.setNextRetryTime(new Date()); // 立即可发送
messageMapper.insert(message);
return order.getOrderId().toString();
}
}
定时任务扫描消息表
java
@Component
public class MessageRelayTask {
private static final Logger log = LoggerFactory.getLogger(MessageRelayTask.class);
@Autowired
private LocalMessageMapper messageMapper;
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 每10秒扫描一次待发送的消息
*/
@Scheduled(fixedDelay = 10000)
public void relayMessages() {
// 查询待发送且到达重试时间的消息
List<LocalMessage> messages = messageMapper.selectPendingMessages(
new Date(), 100 // 一次最多处理100条
);
for (LocalMessage msg : messages) {
try {
// 发送消息到MQ
rocketMQTemplate.convertAndSend(
msg.getTopic() + ":" + msg.getTag(),
msg.getMessageBody()
);
// 更新消息状态为已发送
msg.setStatus(1);
msg.setUpdateTime(new Date());
messageMapper.updateStatus(msg);
log.info("消息发送成功,bizId={}, topic={}", msg.getBizId(), msg.getTopic());
} catch (Exception e) {
log.error("消息发送失败,bizId={}", msg.getBizId(), e);
// 更新重试信息(指数退避)
msg.setRetryCount(msg.getRetryCount() + 1);
if (msg.getRetryCount() >= msg.getMaxRetry()) {
msg.setStatus(2); // 发送失败,需要人工介入
} else {
// 指数退避:10s, 30s, 90s, 270s, 810s
long delaySeconds = (long) (10 * Math.pow(3, msg.getRetryCount()));
msg.setNextRetryTime(
new Date(System.currentTimeMillis() + delaySeconds * 1000)
);
}
messageMapper.updateRetryInfo(msg);
}
}
}
}
消费端幂等消费
java
@Component
@RocketMQMessageListener(
topic = "order-topic",
consumerGroup = "inventory-consumer-group",
selectorExpression = "order-created"
)
public class InventoryConsumer implements RocketMQListener<String> {
private static final Logger log = LoggerFactory.getLogger(InventoryConsumer.class);
@Autowired
private InventoryMapper inventoryMapper;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Override
public void onMessage(String messageBody) {
OrderCreatedEvent event = JSON.parseObject(messageBody, OrderCreatedEvent.class);
// 幂等校验:用bizId作为去重Key
String idempotentKey = "msg:consumed:" + event.getOrderId();
Boolean isFirst = redisTemplate.opsForValue()
.setIfAbsent(idempotentKey, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(isFirst)) {
log.info("重复消息,跳过消费,orderId={}", event.getOrderId());
return;
}
// 执行业务逻辑:扣减库存
Inventory inventory = inventoryMapper.selectByProductId(event.getProductId());
if (inventory == null || inventory.getStock() < event.getQuantity()) {
log.error("库存不足,productId={}", event.getProductId());
throw new RuntimeException("库存不足"); // 抛异常触发MQ重试
}
inventory.setStock(inventory.getStock() - event.getQuantity());
inventoryMapper.updateStock(inventory);
log.info("库存扣减成功,orderId={}, productId={}", event.getOrderId(), event.getProductId());
}
}
优缺点分析
| 优点 | 缺点 |
|---|---|
| 实现简单,容易理解 | 业务侵入性强(要建消息表) |
| 可靠性高(本地事务保证) | 定时扫描有延迟(非实时) |
| 与具体MQ解耦 | 消息表数据量会增长,需要定期清理 |
| 适合任何MQ | 需要额外的定时任务 |
四、方案2:RocketMQ事务消息
原理
RocketMQ原生支持事务消息,流程如下:
生产者 RocketMQ Broker 消费者
│ │ │
│──1.发送半消息──────────────>│ │
│ │(半消息对消费者不可见) │
│<──2.返回半消息发送结果──────│ │
│ │ │
│──3.执行本地事务 │ │
│ (如:创建订单) │ │
│ │ │
│──4a.提交/回滚──────────────>│ │
│ │ │
│ (如果4a丢失) │ │
│<──5.回查事务状态────────────│ │
│──6.返回事务状态────────────>│ │
│ │──7.投递消息──────────>│
│ │ │──8.消费消息
关键概念:
- 半消息:消息先发到Broker,但对消费者不可见。相当于"暂存"
- 本地事务:生产者执行自己的业务逻辑(如创建订单)
- 提交/回滚:本地事务成功则提交消息(消费者可见),失败则回滚(消息删除)
- 回查:如果Broker长时间没收到提交/回滚,会主动回查生产者的事务状态
完整Java代码示例
TransactionListener实现
java
@Component
public class OrderTransactionListener implements TransactionListener {
private static final Logger log = LoggerFactory.getLogger(OrderTransactionListener.class);
@Autowired
private OrderService orderService;
@Autowired
private TransactionLogMapper transactionLogMapper;
/**
* 执行本地事务
* 半消息发送成功后,Broker会回调这个方法
*/
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
// 从消息中解析业务参数
String bizData = new String(msg.getBody(), RemotingHelper.DEFAULT_CHARSET);
CreateOrderRequest request = JSON.parseObject(bizData, CreateOrderRequest.class);
// 执行本地事务:创建订单
String orderId = orderService.createOrder(request);
// 记录事务日志(用于回查)
TransactionLog txLog = new TransactionLog();
txLog.setTransactionId(msg.getTransactionId());
txLog.setBizId(orderId);
txLog.setBizType("ORDER_CREATE");
txLog.setStatus(TransactionStatus.COMMITTED.getStatus());
txLog.setCreateTime(new Date());
transactionLogMapper.insert(txLog);
log.info("本地事务执行成功,提交消息,orderId={}", orderId);
return LocalTransactionState.COMMIT_MESSAGE;
} catch (Exception e) {
log.error("本地事务执行失败,回滚消息", e);
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
/**
* 回查本地事务状态
* 如果Broker长时间没收到提交/回滚,会回调这个方法
*/
@Override
public LocalTransactionState checkLocalTransaction(Message msg) {
String transactionId = msg.getTransactionId();
log.info("回查事务状态,transactionId={}", transactionId);
// 查询事务日志
TransactionLog txLog = transactionLogMapper.selectByTransactionId(transactionId);
if (txLog == null) {
// 事务日志不存在,说明本地事务可能还在执行中
log.warn("事务日志不存在,未知状态,transactionId={}", transactionId);
return LocalTransactionState.UNKNOW;
}
if (TransactionStatus.COMMITTED.getStatus().equals(txLog.getStatus())) {
return LocalTransactionState.COMMIT_MESSAGE;
} else if (TransactionStatus.ROLLED_BACK.getStatus().equals(txLog.getStatus())) {
return LocalTransactionState.ROLLBACK_MESSAGE;
} else {
return LocalTransactionState.UNKNOW;
}
}
}
发送事务消息
java
@Service
public class OrderTransactionProducer {
private static final Logger log = LoggerFactory.getLogger(OrderTransactionProducer.class);
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 发送事务消息
*/
public void sendOrderTransactionMessage(CreateOrderRequest request) {
String topic = "order-topic";
String tag = "order-created";
String bizData = JSON.toJSONString(request);
// 发送事务消息
rocketMQTemplate.sendMessageInTransaction(
topic + ":" + tag,
MessageBuilder.withPayload(bizData).build(),
request // 传递给TransactionListener的arg参数
);
log.info("事务消息已发送,等待本地事务执行,userId={}", request.getUserId());
}
}
半消息、提交消息、回滚消息的细节
| 阶段 | 说明 | 消费者可见? |
|---|---|---|
| 半消息 | 消息存入Broker的半消息队列 | 不可见 |
| 提交消息 | 半消息转移到目标Topic | 可见,开始消费 |
| 回滚消息 | 半消息被删除 | 不可见 |
| 回查 | Broker主动查询生产者事务状态 | 取决于回查结果 |
优缺点分析
| 优点 | 缺点 |
|---|---|
| 不需要额外的消息表 | 强依赖RocketMQ |
| 原生支持,侵入性低 | 只适用于RocketMQ生态 |
| 实时性好(无定时扫描延迟) | 回查机制增加复杂度 |
| 半消息机制天然防消息丢失 | 需要实现TransactionListener |
五、方案3:Outbox Pattern(发件箱模式)
原理
Outbox Pattern和本地消息表类似,但消息投递方式不同。不是用定时任务扫描,而是通过**CDC(Change Data Capture)**工具(如Canal、Debezium)监听数据库的binlog变化,自动将Outbox表中的消息同步到MQ。
┌─────────────────────┐
│ 应用服务 │
│ ┌───────┐ ┌──────┐│
│ │业务表 │ │Outbox││ ← 同一事务写入
│ └───────┘ └──────┘│
└─────────────────────┘
│ binlog
▼
┌─────────────────────┐
│ CDC工具(Canal) │ ← 监听binlog变化
└─────────────────────┘
│
▼
┌─────────────────────┐
│ MQ │ ← 自动投递到MQ
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 消费方 │
└─────────────────────┘
Outbox表设计
sql
CREATE TABLE `outbox` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`aggregate_type` varchar(64) NOT NULL COMMENT '聚合根类型(如Order)',
`aggregate_id` varchar(64) NOT NULL COMMENT '聚合根ID',
`event_type` varchar(64) NOT NULL COMMENT '事件类型',
`payload` json NOT NULL COMMENT '事件内容',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`processed` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已处理:0-未处理 1-已处理',
`processed_at` datetime DEFAULT NULL COMMENT '处理时间',
PRIMARY KEY (`id`),
KEY `idx_aggregate` (`aggregate_type`, `aggregate_id`),
KEY `idx_processed` (`processed`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发件箱表';
与本地消息表的区别
| 维度 | 本地消息表 | Outbox Pattern |
|---|---|---|
| 消息投递方式 | 定时任务扫描 | CDC监听binlog |
| 实时性 | 有延迟(取决于扫描频率) | 接近实时(binlog同步) |
| 额外组件 | 定时任务 | CDC工具(Canal/Debezium) |
| 数据库依赖 | 依赖数据库 | 依赖数据库binlog |
| 耦合度 | 应用自己扫描 | 由独立组件投递 |
| 适用场景 | 简单项目 | 大型微服务架构 |
优缺点分析
| 优点 | 缺点 |
|---|---|
| 应用层完全解耦,不需要定时任务 | 需要部署和维护CDC工具 |
| 接近实时(binlog同步延迟很低) | 依赖数据库binlog(MySQL才有) |
| 天然可靠(binlog不会丢) | 架构复杂度高,组件多 |
| 适合事件驱动架构(EDA) | 调试排查相对困难 |
六、三种方案对比表格
| 维度 | 本地消息表 | RocketMQ事务消息 | Outbox Pattern |
|---|---|---|---|
| 实现复杂度 | 低 | 中 | 高 |
| 性能 | 中(定时扫描有延迟) | 高(实时投递) | 高(binlog实时同步) |
| 可靠性 | 高 | 高 | 高 |
| 侵入性 | 高(建表+定时任务) | 中(实现Listener) | 中(建表) |
| MQ依赖 | 不依赖特定MQ | 强依赖RocketMQ | 不依赖特定MQ |
| 实时性 | 秒级延迟 | 毫秒级 | 毫秒级 |
| 适用场景 | 简单项目、任意MQ | RocketMQ生态 | 大型微服务、EDA架构 |
| 维护成本 | 低 | 中 | 高(CDC组件) |
选型建议:小项目用本地消息表,RocketMQ生态用事务消息,大型微服务架构用Outbox Pattern。别纠结哪个"最好",适合你的才是最好的。
七、消费端幂等性设计
不管用哪种方案,消费端都必须做幂等。因为消息重复投递是不可避免的。
方案1:数据库唯一索引
sql
-- 消费记录表,用唯一索引保证幂等
CREATE TABLE `consume_record` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`message_id` varchar(64) NOT NULL COMMENT '消息唯一ID',
`consumer_group` varchar(64) NOT NULL COMMENT '消费组',
`consume_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_msg_group` (`message_id`, `consumer_group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
java
// 消费时先插入消费记录
public void consume(String messageId, String consumerGroup, String body) {
try {
ConsumeRecord record = new ConsumeRecord();
record.setMessageId(messageId);
record.setConsumerGroup(consumerGroup);
consumeRecordMapper.insert(record);
} catch (DuplicateKeyException e) {
log.info("重复消费,跳过,messageId={}", messageId);
return; // 幂等返回
}
// 执行业务逻辑...
}
方案2:Redis去重(SETNX)
java
public boolean isDuplicate(String messageId) {
String key = "consume:" + messageId;
// SETNX:key不存在才设置成功
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(key, "1", 7, TimeUnit.DAYS);
return Boolean.FALSE.equals(result); // false表示已存在,即重复
}
方案3:全局唯一消息ID
每条消息携带一个全局唯一的ID(如UUID、雪花算法ID),消费端用这个ID做去重。
java
// 生产者发送时设置唯一ID
Message message = MessageBuilder.withPayload(body)
.setHeader("MESSAGE_ID", UUID.randomUUID().toString())
.build();
// 消费者消费时获取ID做幂等
String messageId = (String) message.getHeaders().get("MESSAGE_ID");
完整幂等消费代码示例
java
@Component
@RocketMQMessageListener(
topic = "order-topic",
consumerGroup = "inventory-consumer-group"
)
public class IdempotentInventoryConsumer implements RocketMQListener<MessageExt> {
private static final Logger log = LoggerFactory.getLogger(IdempotentInventoryConsumer.class);
@Autowired
private InventoryService inventoryService;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ConsumeRecordMapper consumeRecordMapper;
@Override
public void onMessage(MessageExt messageExt) {
// 1. 获取消息唯一ID
String messageId = messageExt.getMsgId();
String body = new String(messageExt.getBody(), StandardCharsets.UTF_8);
// 2. Redis快速去重(第一道防线)
String redisKey = "idempotent:consume:" + messageId;
Boolean isFirst = redisTemplate.opsForValue()
.setIfAbsent(redisKey, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(isFirst)) {
log.info("Redis去重:重复消息,跳过,messageId={}", messageId);
return;
}
try {
// 3. 数据库唯一索引去重(第二道防线)
ConsumeRecord record = new ConsumeRecord();
record.setMessageId(messageId);
record.setConsumerGroup("inventory-consumer-group");
consumeRecordMapper.insert(record);
// 4. 执行业务逻辑
OrderCreatedEvent event = JSON.parseObject(body, OrderCreatedEvent.class);
inventoryService.deductInventory(event.getProductId(), event.getQuantity());
log.info("消费成功,messageId={}, orderId={}", messageId, event.getOrderId());
} catch (DuplicateKeyException e) {
// 数据库唯一索引冲突,说明已消费过
log.info("DB去重:重复消息,跳过,messageId={}", messageId);
} catch (Exception e) {
// 消费失败,删除Redis Key,让下次重试能进来
redisTemplate.delete(redisKey);
log.error("消费失败,messageId={}", messageId, e);
throw e; // 抛异常触发MQ重试
}
}
}
八、踩坑指南
踩坑提醒:消息最终一致性说起来简单,但生产环境坑多得离谱。下面这些是我踩过的坑和总结的经验。
消息积压处理
消息积压是MQ最常见的运维问题。
常见原因:
- 消费者处理太慢
- 消费者宕机
- 突发流量激增
处理方案:
- 临时扩容:增加消费者实例数(前提是消息分区数足够)
- 快速消费:写一个临时消费者,跳过非核心逻辑,快速消费积压消息
- 消息转发:把积压消息转发到新的Topic,用专门的消费者处理
- 限流保护:上游限流,避免继续积压
java
// 临时快速消费示例(跳过校验,只做核心逻辑)
@Component
public class FastInventoryConsumer implements RocketMQListener<String> {
@Override
public void onMessage(String body) {
OrderCreatedEvent event = JSON.parseObject(body, OrderCreatedEvent.class);
// 跳过所有校验,直接扣库存
inventoryMapper.fastDeduct(event.getProductId(), event.getQuantity());
}
}
消费失败重试策略(指数退避)
这个坑我踩过:消费失败后无限重试,结果MQ被无效重试消息打满了。
java
// RocketMQ消费失败重试配置
@RocketMQMessageListener(
topic = "order-topic",
consumerGroup = "inventory-consumer-group",
maxReconsumeTimes = 5, // 最大重试5次
delayLevel = 3 // 重试级别(对应10s延迟)
)
RocketMQ重试级别和延迟时间:
| delayLevel | 延迟时间 |
|---|---|
| 1 | 1s |
| 2 | 5s |
| 3 | 10s |
| 4 | 30s |
| 5 | 1min |
| 6 | 2min |
| 7 | 3min |
| 8 | 4min |
| 9 | 5min |
| 10 | 6min |
| 11 | 7min |
| 12 | 8min |
| 13 | 9min |
| 14 | 10min |
| 15 | 30min |
| 16 | 1h |
死信队列处理
重试超过上限的消息会进入死信队列(DLQ),需要人工处理。
java
// 死信队列消费者
@RocketMQMessageListener(
topic = "%DLQ%inventory-consumer-group",
consumerGroup = "dlq-handler-group"
)
public class DlqConsumer implements RocketMQListener<MessageExt> {
@Override
public void onMessage(MessageExt messageExt) {
String body = new String(messageExt.getBody(), StandardCharsets.UTF_8);
String messageId = messageExt.getMsgId();
// 1. 记录到死信表,方便后续人工处理
DeadLetter deadLetter = new DeadLetter();
deadLetter.setMessageId(messageId);
deadLetter.setTopic(messageExt.getTopic());
deadLetter.setMessageBody(body);
deadLetter.setReconsumeTimes(messageExt.getReconsumeTimes());
deadLetter.setCreateTime(new Date());
deadLetterMapper.insert(deadLetter);
// 2. 发送告警通知
alertService.sendAlert("消息进入死信队列,messageId=" + messageId);
}
}
消息顺序性保证
如果业务要求消息按顺序消费(比如"先扣库存再发通知"):
- 同一业务的消息发到同一队列/分区
- 消费端用单线程消费
java
// 发送时指定同一个MessageQueue的selector
rocketMQTemplate.sendOrderly(
"order-topic:order-created",
message,
orderId.toString() // 用orderId作为hashKey,同一订单的消息进同一队列
);
九、问题与解答
Q1:本地消息表的消息一直发送失败怎么办?
A:重试超过上限后,消息状态会被标记为"发送失败"。这时候需要:
- 告警通知:第一时间通知开发人员
- 人工处理:通过管理后台查看失败消息,手动重新发送或跳过
- 根因分析:是MQ挂了?网络问题?还是消息体有问题?
- 定期清理:已处理的消息要定期归档,避免消息表无限增长
Q2:RocketMQ事务消息的回查机制是怎么工作的?
A :Broker在发送半消息后,如果超过一定时间(默认60秒)没收到生产者的提交/回滚,就会主动回调生产者的checkLocalTransaction方法。生产者需要在这个方法里查询本地事务状态并返回。Broker最多回查15次,如果都返回UNKNOW,则回滚消息。所以checkLocalTransaction方法一定要实现好,不能一直返回UNKNOW。
Q3:Outbox Pattern中CDC工具挂了怎么办?
A:CDC工具(如Canal)一般会做高可用部署。如果整个CDC集群挂了:
- Outbox表中的消息不会被投递,但数据不会丢(存在数据库里)
- CDC恢复后会从断点继续同步(基于binlog位点)
- 建议对Outbox表做监控,如果
processed=0的记录超过阈值就告警 - 可以配合本地消息表的定时扫描作为兜底方案
十、面试高频考点汇总
考点1:如何保证消息不丢失?
答案:三个环节分别保证:
- 生产者端:使用同步发送 + 重试机制,或者用RocketMQ事务消息/本地消息表
- MQ端:消息持久化到磁盘 + 多副本同步(RocketMQ的同步刷盘+主从同步)
- 消费者端:手动ACK(消费成功才确认),消费失败不ACK触发重试
考点2:如何保证消息不重复消费?
答案 :消费端做幂等性设计,常用三种方案:
- 数据库唯一索引:用消息ID建唯一索引,插入失败说明已消费
- Redis SETNX:用消息ID做Key,SETNX成功才消费
- 全局唯一消息ID :每条消息携带唯一ID,消费端用ID去重
生产环境中建议Redis + 数据库双重去重,Redis做快速过滤,数据库做最终保障。
考点3:本地消息表和RocketMQ事务消息怎么选?
答案:
- 如果项目已经用了RocketMQ,优先用事务消息,侵入性更低,实时性更好
- 如果用的是其他MQ(如RabbitMQ、Kafka),用本地消息表
- 如果是大型微服务架构,考虑Outbox Pattern + CDC
- 小项目快速实现,本地消息表最简单
考点4:什么是Outbox Pattern?和本地消息表有什么区别?
答案 :Outbox Pattern是一种通过数据库发件箱表保证消息可靠投递的模式。和本地消息表的区别在于消息投递方式:本地消息表用定时任务扫描投递,Outbox Pattern用CDC工具监听binlog自动投递。Outbox Pattern实时性更好,但需要额外部署CDC组件,架构复杂度更高。
考点5:消费端幂等性有哪些实现方式?各有什么优缺点?
答案:
| 方式 | 优点 | 缺点 |
|---|---|---|
| 数据库唯一索引 | 可靠,持久化 | 性能较差(写库) |
| Redis SETNX | 性能好,快速 | Redis宕机可能丢数据 |
| 全局唯一消息ID | 简单直接 | 需要配合存储做去重 |
推荐组合:Redis做第一道快速过滤 + 数据库唯一索引做最终保障。
十一、模拟面试官提问和参考答案
场景题1:用户注册后需要发优惠券、发短信、发邮件,怎么保证这三个操作最终都执行成功?
参考答案:这是典型的"一写多读"场景。用户注册成功后,将消息写入本地消息表(或使用RocketMQ事务消息),消息体包含用户ID。然后由MQ分别投递给优惠券服务、短信服务、邮件服务。每个消费端独立消费、独立重试。一个消费失败不影响其他消费。关键是每个消费端都要做幂等,防止重复发券/重复发短信。
场景题2:订单系统用RocketMQ事务消息,但消费端处理很慢导致消息积压,怎么处理?
参考答案:分几步处理:
- 紧急扩容:增加消费者实例,加快消费速度(注意分区数要够)
- 临时方案:写一个快速消费者,跳过非核心逻辑(如日志记录、统计),只做核心业务
- 消息迁移:如果积压太严重,把积压消息转到临时Topic,用专用消费者处理
- 上游限流:通知订单系统降低发送速率
- 根因分析:查明消费慢的原因(慢SQL?外部接口超时?),从根本上解决
场景题3:如果MQ宕机了,本地消息表里的消息发不出去,业务还能正常进行吗?
参考答案 :业务操作本身是正常的(本地事务已经提交了),只是消息投递会延迟。MQ恢复后,定时任务会继续扫描消息表,把积压的消息发出去。所以业务不会中断,只是异步操作会延迟。这就是本地消息表的优势------不依赖MQ的可用性。但要注意:如果延迟时间超过业务允许的范围(比如优惠券超过24小时才发到),就需要告警和人工介入。
场景题4:设计一个消费端重试框架,你会怎么设计?
参考答案:核心设计要点:
- 指数退避:重试间隔逐渐增大(1s→5s→10s→30s→1min),避免短时间内大量重试
- 最大重试次数:超过上限进入死信队列,不再自动重试
- 重试分类:区分可重试异常(网络超时、锁冲突)和不可重试异常(参数错误、数据不存在)
- 死信处理:死信消息持久化到数据库,提供管理界面支持手动重试和跳过
- 监控告警:重试次数、死信数量、消费延迟等指标实时监控
- 幂等保证:每次重试都要做幂等校验
场景题5:在金融场景下(如转账通知),对消息可靠性要求极高,你会选择哪种方案?为什么?
参考答案 :金融场景我会选择RocketMQ事务消息 + 消费端双重幂等 + 死信人工处理的组合方案:
- RocketMQ事务消息:保证"本地事务"和"消息发送"的原子性,要么都成功要么都失败
- 同步刷盘 + 主从同步:保证MQ端消息不丢失
- 消费端双重幂等:Redis + 数据库唯一索引,绝对防止重复消费
- 手动ACK + 重试:消费成功才确认,失败自动重试(指数退避)
- 死信队列 + 人工处理:重试超过上限的消息进入死信队列,触发告警,人工确认
- 全链路追踪:每条消息都有唯一ID,从生产到消费全链路可追踪
互动话题
你在项目中用过哪种消息一致性方案?遇到过消息丢失或重复消费的问题吗?是怎么排查和解决的?欢迎在评论区分享你的实战经验,一起讨论。