一、💡 一句话理解
核心结论
MQ 通常采用 At Least Once(至少一次) 投递,所以工程上不强求消息绝对只来一次,而是通过业务唯一键、数据库唯一约束和正确的事务边界,让同一条业务消息即使重复到达,最终业务效果也只发生一次。
前置概念可以先看 1. MQ 基础 和 5. MQ 可靠性问题。
面试时先记住这条主线:
先查询再更新
↓ 有并发漏洞
悲观锁或乐观锁
↓ 性能、冲突处理和事务边界问题
幂等表 + 唯一约束 + 本地事务
↓ 无法直接覆盖跨库、Redis和外部接口
下游幂等键 + 事务消息/Outbox + 补偿对账二、🧭 理论:消息幂等是什么
2.1 为什么会出现重复消费
重复消息通常不是 MQ 无缘无故多发了一条,而是可靠投递和失败重试带来的结果。
典型过程:
1. 消费者收到“支付成功”消息
2. 数据库事务提交,积分已经增加
3. 消费者准备向 Broker 返回 ACK
4. 服务突然宕机,或者 ACK 在网络中丢失
5. Broker 认为消息没有处理成功,再次投递
6. 如果没有幂等控制,积分会再次增加RabbitMQ 官方可靠性文档明确说明:使用确认机制可以实现至少一次投递,但生产端重发和消费端重新投递都可能造成重复,因此消费者需要具备去重或幂等能力。
2.2 幂等不是“拒绝所有重复消息”
幂等的正式含义是:同一个操作执行一次或执行多次,最终结果相同。
| 操作 | 是否天然幂等 | 原因 |
|---|---|---|
| 把订单状态设置成“已支付” | 相对容易幂等 | 重复设置为同一个值,最终状态不变 |
| 把用户姓名修改成“张三” | 相对容易幂等 | 多次覆盖后的结果相同 |
| 给账户余额减 100 元 | 不幂等 | 每执行一次都会继续扣款 |
| 给用户增加 100 积分 | 不幂等 | 每执行一次都会继续增加 |
因此,消息幂等真正要保证的是:
同一个业务事件可以被投递多次,但只能有一次获得修改业务数据的资格。
2.3 应该使用业务唯一键
消息里应携带稳定的业务事件标识,例如:
{
"eventId": "payment-success-10001",
"orderId": "10001",
"points": 100
}优先使用 eventId、订单号、支付流水号等业务唯一键,不要盲目依赖 MQ 自动生成的消息 ID。
原因是生产者重新构造消息时,MQ 消息 ID 可能改变,但“订单 10001 支付成功”仍然是同一个业务事件。
三、⚙️ 理论:方案如何一步步演进
3.1 基础方案:先判断,再更新
最容易想到的方案是:先查询该消息是否消费过,如果没有,再执行业务并保存消费记录。
// exists:查询这条业务事件是否已经处理过。
if (!messageRecordRepository.exists(eventId)) {
// 第一次处理时执行业务,例如增加积分。
pointsRepository.addPoints(orderId, 100);
// 业务完成后保存消费记录。
messageRecordRepository.save(eventId);
}3.1.1 不足:检查和更新不是原子操作
两个消费者可能同时处理同一个事件:
消费者 A:查询记录,不存在
消费者 B:查询记录,也不存在
消费者 A:增加 100 积分
消费者 B:再次增加 100 积分问题不在于单条查询或更新有错,而在于“先查询、再更新”组合起来不是一个不可分割的原子操作。
3.2 第一次优化:悲观锁
悲观锁的思路是:我认为并发冲突很可能发生,所以先锁住目标数据,再判断和更新。
SELECT event_id, status
FROM mq_consume_record
WHERE event_id = ?
AND consumer_group = ?
FOR UPDATE;SELECT ... FOR UPDATE 必须在数据库事务中使用。查询到的目标行会被锁住,其他事务需要等待当前事务提交或回滚。
3.2.1 悲观锁的不足
- 相同业务数据会串行处理,并发度下降。
- 事务时间过长时,其他请求会长时间等待。
- 多行加锁顺序不一致时可能产生死锁。
- 消费记录尚不存在时,锁行为会受到索引和事务隔离级别影响。
- 它只能保护当前数据库事务里的数据,保护不了跨库和外部接口。
面试易错点
不能只回答“加
FOR UPDATE就行”。还要说明事务范围、目标索引、锁定对象和锁等待成本。
3.3 第二次优化:乐观锁或条件更新
乐观锁的思路是:允许大家并发执行,但真正更新时检查数据有没有被别人改过。
使用版本号:
UPDATE user_points
SET points = points + 100,
version = version + 1
WHERE user_id = ?
AND version = ?;使用业务状态:
UPDATE payment_task
SET status = 'PROCESSED'
WHERE order_id = ?
AND status = 'PENDING';通过 SQL 影响行数判断执行结果:
- 影响一行:当前请求成功修改了数据。
- 影响零行:数据已经变化,可能是重复消息或并发冲突。
3.3.1 乐观锁的不足
- 冲突后要决定重试、跳过还是告警,业务处理更复杂。
- 需要维护版本号或合法的状态流转。
- “积分增加 100”这类非幂等操作不能只靠版本号,还要识别具体业务事件。
- 多表更新、跨数据库和外部接口无法用单个版本号覆盖。
3.4 通用方案:幂等表加数据库唯一约束
为消息建立消费记录表,并对 (event_id, consumer_group) 建唯一约束:
CREATE TABLE mq_consume_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_id VARCHAR(64) NOT NULL,
consumer_group VARCHAR(64) NOT NULL,
consumed_at DATETIME NOT NULL,
UNIQUE KEY uk_event_consumer (event_id, consumer_group)
);唯一键中包含消费者组,是因为同一个业务事件可能需要由积分、库存和通知等不同消费者分别处理一次。
并发处理时:
消费者 A 插入消费记录 → 成功 → 获得业务处理权
消费者 B 插入消费记录 → 唯一键冲突 → 识别为重复消息唯一约束由数据库原子执行,因此比“先查询是否存在”更适合并发场景。
3.5 关键优化:消费记录和业务操作放在同一个事务
正确事务边界:
BEGIN
1. 插入消费记录
2. 修改业务数据
COMMIT如果第二步失败,第一步也必须回滚。这样消息重新投递时,消费者仍然可以再次尝试。
@Transactional(rollbackFor = Exception.class)
public void consume(PaymentSucceededEvent event) {
// insertIfAbsent:利用数据库唯一约束抢占当前事件的处理权。
// 返回 1 表示首次插入,返回 0 表示相同事件已经处理过。
int inserted = consumeRecordRepository.insertIfAbsent(
event.getEventId(),
"points-consumer-group"
);
if (inserted == 0) {
// 重复消息不再修改积分,方法正常返回后仍可向 Broker ACK。
return;
}
// 该操作与消费记录处于同一个数据库事务中。
// 如果增加积分失败,消费记录也会一起回滚。
pointsRepository.addPoints(
event.getOrderId(),
event.getPoints()
);
}3.5.1 为什么不能把两个操作拆开
先写消费记录,再执行业务:
消费记录写入成功
业务执行失败
消息重新投递时发现记录已经存在
业务被错误地跳过先执行业务,再写消费记录:
业务执行成功
消费记录写入失败
消息重新投递
业务再次执行因此,幂等表方案成立的关键不是“有一张表”,而是:
唯一约束负责并发裁决,消费记录和业务数据的同一本地事务负责失败回滚。
3.6 再次暴露不足:本地事务不能跨系统
当消费记录和业务数据位于同一个关系型数据库时,本地事务比较可靠。
但如果业务还涉及以下操作,本地事务就无法全部覆盖:
- 修改另一个数据库。
- 写入 Redis。
- 调用支付、短信或其他第三方接口。
- 通过 RPC 修改另一个微服务的数据。
这时需要把同一个幂等键继续传给下游:
Idempotency-Key: payment-success-10001下游服务也要使用唯一约束、条件更新或状态机保证同一个幂等键只生效一次。
可靠性要求更高时,可以结合:
- 本地消息表或 Transactional Outbox。
- MQ 事务消息。
- 状态机。
- 有限重试和死信队列。
- 定时补偿和人工补偿。
- 业务对账。
- Saga 或 TCC 等分布式事务方案。
3.7 Exactly Once 应该怎么回答
面试中不要轻易承诺端到端 Exactly Once。更稳妥的说法是:
系统采用 At Least Once 加业务幂等,实现 Exactly Once Effect:消息可以重复到达,但最终业务效果只发生一次。
| 概念 | 含义 | 常见边界 |
|---|---|---|
| Exactly Once Delivery | 消息只投递一次 | 分布式故障下很难端到端保证 |
| Exactly Once Processing | 消息只被处理一次 | 受到消费位点和事务范围限制 |
| Exactly Once Effect | 消息可以重复到达,但业务最终只生效一次 | 工程中更常见的目标 |
Kafka 提供幂等生产者和事务能力,可以在限定范围内把消费位点和输出 Topic 写入同一个 Kafka 事务。但最终结果写入普通数据库、Redis 或外部接口时,仍要处理事务边界和业务幂等。
3.8 幂等表越来越大怎么办
不能只根据“单次消费最长十分钟”,就删除十分钟前的幂等记录。
合理保留时间至少应覆盖:
MQ 最大重试周期
+ 消息最大保留时间
+ 人工重投时间
+ 故障恢复时间
+ 历史消息回放时间
+ 安全冗余时间删除幂等记录本身不会造成消息丢失,但会失去历史去重依据,让旧消息可能再次生效。
常见优化方法:
- 按日期或月份分区。
- 根据业务完成时间归档。
- 热表保存近期数据,历史数据转移到归档表。
- 为业务唯一键建立正确索引。
- 根据最大可重放窗口确定保留时间。
3.9 能不能使用 Redis 去重
Redis 可以使用 SET NX 做高性能去重:
SET mq:consume:{consumerGroup}:{eventId} 1 NX EX 86400NX:Key 不存在时才写入。- 写入成功:当前请求获得处理权。
- 写入失败:可能是重复消息。
EX:设置过期时间,避免 Key 永久占用内存。
Redis 的优点是性能高、实现简单,适合允许一定降级空间的场景。
它的不足包括:
- Key 过期后,旧消息可以再次处理。
- Key 可能因为内存淘汰而提前消失。
- Redis 故障或数据丢失后,去重依据可能不存在。
- Redis 操作和 MySQL 业务更新不能组成普通的本地事务。
- 先写 Redis 后业务失败,可能把未成功的业务误判为已消费。
- 先执行业务后写 Redis 失败,消息重试时可能重复执行业务。
Redis 事务的准确说法
Redis 支持
MULTI、EXEC和WATCH,但执行阶段某条命令失败时,不会像关系型数据库一样自动回滚已经执行的其他命令;它也不能直接和 MySQL 组成一个原子事务。因此不能简单地说“Redis 不支持事务”,应该说“Redis 的事务语义和关系型数据库不同,并且无法直接解决 Redis 与数据库之间的原子性”。
四、🚀 实践:从消息到幂等验证
4.1 前置准备
下面以“订单支付成功后增加积分”为例。示例重点是事务边界,不限定 RabbitMQ、RocketMQ、Kafka 或 SOFAMQ。
消息至少包含:
{
"eventId": "payment-success-10001",
"orderId": "10001",
"points": 100
}需要满足:
eventId在同一业务事件内保持不变。- 消费记录表和积分业务表位于同一个关系型数据库。
- 消费端关闭“收到消息立即成功”的错误确认方式。
- 只有业务事务提交成功后才能 ACK。
4.2 可以拿来干什么
4.2.1 防止重复发放积分或权益
支付成功消息发生重投时,通过 eventId 唯一约束保证积分只发放一次。
输入是同一个 eventId 的两条消息,处理结果应是消费记录只有一条、积分只增加一次。
4.2.2 防止重复创建业务数据
创建订单、创建账单、生成任务等场景,可以直接对订单号、请求号或业务流水号建立唯一约束。
如果“目标业务表中的唯一键”已经足够表达幂等,就不一定要额外创建通用消费记录表。
4.2.3 防止状态被重复推进
订单、审批和任务流可以采用状态机或条件更新:
UPDATE payment_task
SET status = 'PROCESSED'
WHERE order_id = ?
AND status = 'PENDING';第一次影响一行,重复消息影响零行。代码需要区分“已经成功处理”和“数据异常导致无法更新”。
4.3 完整实践:建表、消费、ACK 和验证
示例 SQL 说明
以下 DDL 和 DML 只作为学习示例展示,本笔记没有连接或修改任何数据库。实际执行前需要确认目标数据库、表名、字段类型、备份方案和影响范围。
4.3.1 数据表
消费记录表:
CREATE TABLE mq_consume_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_id VARCHAR(64) NOT NULL COMMENT '稳定的业务事件唯一标识',
consumer_group VARCHAR(64) NOT NULL COMMENT '消费者组,不同业务组可分别消费一次',
consumed_at DATETIME NOT NULL COMMENT '首次成功处理时间',
UNIQUE KEY uk_event_consumer (event_id, consumer_group)
);积分表:
CREATE TABLE user_points (
order_id VARCHAR(64) PRIMARY KEY,
points INT NOT NULL DEFAULT 0
);4.3.2 插入消费记录
INSERT IGNORE INTO mq_consume_record (
event_id,
consumer_group,
consumed_at
) VALUES (?, ?, NOW());使用插入影响行数判断:
- 返回
1:首次处理。 - 返回
0:唯一键已经存在,属于重复消息。
4.3.3 消费服务
@Service
public class PaymentPointsConsumeService {
private static final String CONSUMER_GROUP = "points-consumer-group";
private final ConsumeRecordRepository consumeRecordRepository;
private final PointsRepository pointsRepository;
public PaymentPointsConsumeService(
ConsumeRecordRepository consumeRecordRepository,
PointsRepository pointsRepository) {
// 构造方法注入:对象由 Spring 创建并传入,便于测试和保证依赖明确。
this.consumeRecordRepository = consumeRecordRepository;
this.pointsRepository = pointsRepository;
}
@Transactional(rollbackFor = Exception.class)
public void consume(PaymentSucceededEvent event) {
// insertIfAbsent:尝试写入消费记录。
// 数据库唯一约束保证并发情况下最多只有一个请求插入成功。
int inserted = consumeRecordRepository.insertIfAbsent(
event.getEventId(),
CONSUMER_GROUP
);
if (inserted == 0) {
// 相同消费者组已经处理过这个业务事件。
// 正常返回,让监听器 ACK,避免重复消息无限重试。
return;
}
// addPoints:执行真正的业务操作。
// 它和消费记录写入处于同一个本地事务中。
pointsRepository.addPoints(
event.getOrderId(),
event.getPoints()
);
}
}4.3.4 消息监听器
下面是与具体 MQ 客户端无关的结构化示例:
public void onMessage(Message message) {
try {
// deserialize:把消息体反序列化成业务事件对象。
PaymentSucceededEvent event = deserialize(message);
// consume:内部事务正常返回时,说明业务已成功或消息已被幂等拦截。
consumeService.consume(event);
// acknowledge:只有事务已经成功提交,才告诉 Broker 消费成功。
acknowledge(message);
} catch (Exception exception) {
// rejectForRetry:本次处理失败,不返回成功 ACK。
// 由 MQ 按策略有限重试,超过次数后进入死信队列并告警。
rejectForRetry(message, exception);
}
}4.3.5 验证步骤
- 初始化订单
10001的积分为0。 - 连续投递两次完全相同的
eventId=payment-success-10001。 - 查询
mq_consume_record,应该只有一条对应记录。 - 查询
user_points,积分应该只增加100,不能变成200。 - 在积分更新前主动抛出异常,验证消费记录和积分修改都会回滚。
- 再次投递同一消息,验证它仍然可以重新处理。
预期结果:
第一次投递:消费记录插入成功,积分增加 100,事务提交,ACK
第二次投递:唯一键已经存在,跳过积分更新,正常 ACK
业务异常:事务回滚,不 ACK,后续允许重试4.4 常见报错和排查
4.4.1 明明加了 @Transactional,记录仍然没有回滚
检查:
- 消费记录和业务表是否使用同一个事务管理器。
- 方法是否通过 Spring 代理调用,而不是类内部直接调用自身方法。
- 异常是否被捕获后吞掉。
- 异常类型是否满足回滚规则。
- 存储引擎是否支持事务。
4.4.2 唯一键已经存在,却一直重试
重复消息应该被识别后正常返回并 ACK。不要把“唯一键冲突”统一当作消费失败,否则同一条消息会持续重试。
4.4.3 MQ 消息 ID 不同,但业务仍然重复
检查生产者重试时是否重新创建了消息。幂等判断应该使用稳定的业务事件 ID,而不是只使用 MQ 自动生成的消息 ID。
五、🎤 面试回答模板
5.1 一分半回答
MQ 为了避免消息丢失,通常采用至少一次投递,所以生产重试、消费失败重投,以及业务成功但 ACK 丢失,都可能产生重复消息。消息幂等的目标不是让消息绝对只来一次,而是让同一业务消息重复到达时,最终业务效果只生效一次。
最简单的方案是先查询消费状态再更新,但查询和更新不是原子操作,并发情况下两个消费者都可能判断为未消费。可以使用悲观锁或者乐观锁解决并发问题,但悲观锁会降低并发度,乐观锁则需要处理冲突和业务状态。
更通用的方案是给消息设置稳定的业务唯一键,例如 eventId,建立消费记录表,并对 eventId 和 consumerGroup 建唯一约束。消费者先插入记录,插入成功才执行业务,唯一键冲突则说明已经消费。消费记录和业务修改必须放在同一个本地事务中,这样业务失败时消费记录也会回滚。
这个方案的边界是只能覆盖同一个数据库事务。如果业务涉及跨库、Redis 或外部接口,就要把幂等键传递给下游,并结合本地消息表、事务消息、状态机、补偿和对账。工程上一般不轻易承诺端到端 Exactly Once,而是采用 At Least Once 加业务幂等,实现 Exactly Once Effect。
如果幂等表数据量太大,可以分区或归档,但保留时间必须覆盖 MQ 的重试、重投和历史回放窗口。Redis 也可以做高性能去重,不过存在过期、淘汰以及无法和数据库组成同一事务的问题,核心业务一般仍以数据库唯一约束为准。
5.2 三十秒精简回答
MQ 通常是至少一次投递,因此消费者必须做幂等。简单的先查再更新有并发漏洞,悲观锁并发低,乐观锁需要处理冲突。通用方案是使用业务 eventId,在消费记录表上建立 eventId 和 consumerGroup 的唯一约束,并把消费记录和业务修改放在同一个本地事务里。跨库或调用外部接口时,本地事务就不够了,需要把幂等键传给下游,并结合事务消息、Outbox、补偿和对账。最终目标不是消息绝对只到一次,而是业务效果只发生一次。
六、❓ 高频追问
6.1 为什么不能只使用 MQ 的消息 ID
生产者重新构造消息后,消息 ID 可能变化。订单号、支付流水号或业务事件 ID 才能稳定代表“是不是同一件事”。
6.2 为什么唯一约束比先查询可靠
唯一约束由数据库原子执行。多个消费者并发插入时,最终只能有一个成功,不会出现大家都先查到“不存在”的时间窗口。
6.3 为什么唯一键要包含消费者组
同一条支付成功消息可能需要被积分、库存和通知等不同消费者分别处理一次。消费者组相同才表示同一份消费职责。
6.4 事务提交后、ACK 前宕机怎么办
Broker 会重新投递消息。消费者再次插入消费记录时命中唯一约束,从而跳过已经成功的业务,然后正常 ACK。
6.5 为什么不能先 ACK 再执行业务
先 ACK 后,Broker 会认为消息已经处理完成。如果随后业务失败,消息通常不会再自动投递,可能造成业务消息丢失。
6.6 Redis SET NX 能完全保证幂等吗
不能。Key 可能过期、淘汰或丢失,而且 Redis 写入和数据库业务更新无法组成一个普通本地事务。核心业务仍应由数据库唯一约束、状态条件或下游幂等兜底。
6.7 消费记录什么时候可以删除
至少要等超过 MQ 重试、消息保留、人工重投、故障恢复和历史回放的最大窗口。不能只按单次消费耗时决定。
6.8 MQ 能保证 Exactly Once 吗
部分 MQ 在限定范围内提供 Exactly Once 能力,但端到端链路还可能包含数据库、Redis 和外部接口。面试中更稳妥的结论是:使用至少一次投递,通过业务幂等实现业务效果只发生一次。
七、📌 总结
- 重复消费通常是可靠投递和失败重试带来的副作用。
- “先查询再更新”存在并发时间窗口。
- 悲观锁会降低并发度,乐观锁需要处理冲突和状态。
- 通用方案是业务唯一键、数据库唯一约束和正确的本地事务。
- 跨库、Redis 和外部接口需要继续传递幂等键,并增加补偿和对账。
- 幂等记录的保留时间必须覆盖消息可能被重新投递和回放的时间窗口。
- Redis 可以提升去重性能,但不能代替核心业务的一致性设计。
- 记忆句:至少一次负责防丢,业务幂等负责防重,最终追求业务效果只发生一次。