上一篇我们聊到一个现象:
Kafka 只发了一条消息,为什么业务侧消费了两次?
问题的关键在于:业务已经执行成功了,但 Offset 还没提交成功。只要这个窗口里发生宕机、重启、Rebalance,这条消息就可能重新被拉取。
所以很多人会很自然地想到一个方案:
那我加一张幂等表不就行了?
收到消息时先查幂等记录。
如果没处理过,就插入一条记录,然后执行业务。
如果处理过,就直接返回。
这个思路听起来非常合理。
但在真实业务里,它还有一个很隐蔽的问题:
幂等记录存在,不代表业务真的处理成功了。
如果没有处理好这个细节,幂等表不但挡住了重复消息,也可能把本来应该重试的消息一起挡掉。
最后你会看到一个更诡异的现象:
MQ 明明重试了,日志也显示"消息已处理过",但业务数据就是卡在中间态。
一、现场还原:订单进入分账中,但分账单没了
还是用支付成功后的分账链路举例。
用户支付成功后,交易系统发送一条 PAY_SUCCESS 消息。
分账服务消费消息后,正常要做几件事:
- 推进订单状态:
PAID -> SHARING; - 创建分账单;
- 发送分账执行消息;
- 后续调用渠道分账接口。
为了防止 MQ 重复消费,系统加了一张消费记录表:
sql
CREATE TABLE t_message_consume_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_id VARCHAR(128) NOT NULL,
consumer_name VARCHAR(128) NOT NULL,
create_time DATETIME NOT NULL,
UNIQUE KEY uk_event_consumer (event_id, consumer_name)
);
消费代码大概是这样:
ini
@KafkaListener(topics = "pay-success-topic", groupId = "profit-share-service")
public void consume(PaySuccessMessage message) {
String eventId = message.getEventId();
String consumerName = "profit-share-service";
if (consumeRecordMapper.exists(eventId, consumerName)) {
log.info("消息已处理过,忽略重复消息,eventId={}", eventId);
return;
}
consumeRecordMapper.insert(eventId, consumerName);
orderMapper.updateStatus(
message.getOrderNo(),
OrderStatus.PAID,
OrderStatus.SHARING
);
ProfitShareOrder shareOrder = buildShareOrder(message);
profitShareOrderMapper.insert(shareOrder);
kafkaTemplate.send("share-execute-topic", buildShareExecuteMessage(shareOrder));
log.info("支付成功消息处理完成,eventId={}", eventId);
}
这段代码看起来比之前安全多了。
它至少做了一件事:
同一个
eventId,同一个消费者,只允许处理一次。
但某次线上异常后,业务侧发现了一批奇怪的数据:
订单状态:SHARING
分账单:不存在
消费记录:已存在
MQ 消息:已经重试过
从日志看,消息第二次进来时确实被消费到了。
但很快就打印了一句:
消息已处理过,忽略重复消息
然后就没有然后了。
这时候问题就变成了:
幂等表明明是为了防止重复处理,为什么反而让失败消息没法重试了?
二、把时间线拉开看
这类问题不是出在"幂等表有没有加"。
而是出在:
幂等记录写入成功后,真正的业务动作还没有全部成功。
时间线大概是这样:
| 时间点 | Consumer A | MySQL 业务数据 | 消费记录 |
|---|---|---|---|
| T1 | 收到支付成功消息 | 订单状态 = PAID | 无记录 |
| T2 | 插入消费记录 | 订单状态 = PAID | 已存在 |
| T3 | 更新订单状态为 SHARING | 订单状态 = SHARING | 已存在 |
| T4 | 创建分账单时数据库异常 | 分账单不存在 | 已存在 |
| T5 | Consumer A 抛异常,消息等待重试 | 订单卡在 SHARING | 已存在 |
| T6 | Consumer B 再次收到同一条消息 | 订单状态 = SHARING,分账单不存在 | 已存在 |
| T7 | Consumer B 发现消费记录存在,直接返回 | 数据没有修复 | 已存在 |
这里最危险的是 T2。
消费记录已经插进去了。
但业务还没真正做完。
后面不管消息重试多少次,都会被这条消费记录挡住。
这就是一种很典型的"半成功"问题:
幂等表记录的是"我开始处理过",但代码却把它当成了"我已经处理成功"。
三、根因:幂等记录不能只有"存在"和"不存在"
很多消费幂等设计最大的问题,就是把幂等记录做成了一个布尔值:
存在 = 已处理
不存在 = 未处理
但业务处理过程不是瞬间完成的。
它可能经历:
刚收到消息
正在处理
部分业务成功
部分业务失败
全部处理成功
处理失败等待重试
所以一条消费记录至少不应该只表达"有没有"。
它更应该表达:
这条消息处理到了什么阶段。
也就是说,消费记录本身也需要状态。
例如:
PROCESSING:处理中
SUCCESS:处理成功
FAIL:处理失败
如果只靠"存在即返回",就会把下面这几种情况全部混在一起:
| 实际情况 | 是否应该直接返回 |
|---|---|
| 已经完整处理成功 | 可以返回 |
| 正在被另一个线程处理 | 可以等待、稍后重试,不能永远跳过 |
| 上一次处理失败 | 不应该直接返回,应该允许重试或补偿 |
| 上一次只处理了一半 | 不能直接返回,需要继续修复中间态 |
所以,幂等表不是一把简单的锁。
它更像是一张消息处理状态表。
四、先别急着建表,先想清楚哪些动作不能重复
在设计幂等之前,最容易犯的错误是:一上来就想技术方案。
比如:
用 Redis 还是 MySQL?
用唯一索引还是幂等表?
用 eventId 还是 orderNo?
要不要加分布式锁?
这些问题都重要,但不是第一步。
第一步应该是:
这条消息重复来之后,到底哪些业务动作不能重复发生?
以支付成功分账为例,一条 PAY_SUCCESS 消息可能触发多个动作:
| 业务动作 | 能不能重复 | 更合适的幂等边界 |
|---|---|---|
推进订单状态 PAID -> SHARING |
不能重复推进 | order_no + 前置状态 |
| 创建分账单 | 不能重复创建 | order_no + share_type |
| 创建资金流水 | 不能重复入账 | biz_no + flow_type |
| 发送分账执行消息 | 可以重复发,但下游要幂等 | share_order_no |
| 调用渠道分账接口 | 不能重复执行 | request_no |
这张表说明一个问题:
幂等不应该只围绕 MQ 消息设计,而应该围绕业务动作设计。
eventId 可以标识一次消息事件,但它不能天然代表所有业务动作的成功结果。
比如同一条支付成功消息里,订单状态推进成功了,但分账单创建失败了。
这时你不能简单说:
eventId 已经处理过了,直接跳过。
因为从业务结果看,它并没有处理完整。
五、第一道防线:状态推进要有前置条件
对于订单状态,不要直接 update。
错误写法:
ini
UPDATE t_order
SET status = 'SHARING'
WHERE order_no = #{orderNo}
这条 SQL 不关心订单当前是什么状态。
更稳的写法是:
ini
UPDATE t_order
SET status = 'SHARING'
WHERE order_no = #{orderNo}
AND status = 'PAID'
然后判断影响行数:
ini
int rows = orderMapper.updateStatus(
message.getOrderNo(),
OrderStatus.PAID,
OrderStatus.SHARING
);
if (rows == 0) {
log.info("订单状态已不是 PAID,orderNo={}", message.getOrderNo());
}
这一步解决的是:
只有订单还处于
PAID状态时,才能推进到SHARING。
如果重复消息再次进来,订单已经是 SHARING 或 SHARE_SUCCESS,这条 SQL 就不会再生效。
但这里不能立刻写:
ini
if (rows == 0) {
return;
}
因为 rows == 0 有多种可能:
订单已经完整处理成功了
订单正在处理中
订单卡在 SHARING 中间态
订单状态异常
所以,状态更新失败后,不能无脑返回。
至少要进一步判断当前状态,或者检查后续业务数据是否完整。
比如:
ini
Order current = orderMapper.selectByOrderNo(message.getOrderNo());
if (OrderStatus.SHARE_SUCCESS.equals(current.getStatus())) {
return;
}
if (OrderStatus.SHARING.equals(current.getStatus())) {
log.info("订单处于分账中,继续检查分账单是否存在,orderNo={}", message.getOrderNo());
}
这一步很关键。
否则你会把"已经处理成功"和"卡在中间态"混为一谈。
六、第二道防线:业务单据必须有唯一约束
分账单这种业务单据,不能只靠 Java 代码先查再插。
比如:
ini
ProfitShareOrder exists = profitShareOrderMapper.selectByOrderNo(orderNo);
if (exists == null) {
profitShareOrderMapper.insert(shareOrder);
}
这段逻辑在并发下仍然有窗口:
| 时间点 | Consumer A | Consumer B | MySQL |
|---|---|---|---|
| T1 | 查询分账单不存在 | - | 无分账单 |
| T2 | - | 查询分账单不存在 | 无分账单 |
| T3 | 插入 S001 | - | S001 |
| T4 | - | 插入 S002 | S001、S002 |
真正能兜底的是数据库唯一索引:
scss
CREATE UNIQUE INDEX uk_order_share_type
ON t_profit_share_order(order_no, share_type);
这表示:
同一个订单,同一种分账类型,只允许存在一条分账单。
插入时可以这样处理:
matlab
try {
profitShareOrderMapper.insert(buildShareOrder(message));
} catch (DuplicateKeyException e) {
log.info("分账单已存在,按幂等成功处理,orderNo={}", message.getOrderNo());
}
这里的重点不是"用异常控制流程优不优雅"。
而是:
这个业务规则必须被数据库兜住。
应用层可以提前判断,但最终防线应该放在唯一约束上。
七、消费记录应该怎么设计?
消费记录可以用,但不要只记录"处理过"。
更合理的结构应该至少包含状态和错误信息:
sql
CREATE TABLE t_message_consume_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_id VARCHAR(128) NOT NULL,
consumer_name VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
last_error VARCHAR(512),
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_event_consumer (event_id, consumer_name)
);
消费开始时,不是简单判断记录是否存在,而是判断状态。
可以按这个规则处理:
| 消费记录状态 | 处理策略 |
|---|---|
| 不存在 | 插入 PROCESSING,开始处理 |
| SUCCESS | 说明完整处理成功,可以直接返回 |
| FAIL | 允许重新处理,或交给补偿任务 |
| PROCESSING 未超时 | 说明可能有其他线程正在处理,稍后重试 |
| PROCESSING 已超时 | 可能是上次宕机遗留,可以尝试接管或进入补偿 |
伪代码可以这样写:
csharp
ConsumeRecord record = consumeRecordService.getOrCreate(
message.getEventId(),
"profit-share-service"
);
if (record.isSuccess()) {
return;
}
if (record.isProcessing() && !record.isTimeout()) {
throw new RetryLaterException("消息正在处理中,稍后重试");
}
consumeRecordService.markProcessing(message.getEventId(), "profit-share-service");
try {
doBusinessIdempotently(message);
consumeRecordService.markSuccess(message.getEventId(), "profit-share-service");
} catch (Exception e) {
consumeRecordService.markFail(message.getEventId(), "profit-share-service", e.getMessage());
throw e;
}
这段代码的核心是:
只有
SUCCESS才代表可以安全忽略重复消息。其他状态都不能简单跳过。
尤其是 PROCESSING 和 FAIL,一定要设计清楚后续怎么处理。
否则幂等表就会变成"消息黑洞"。
八、一个更稳的处理流程
回到支付成功创建分账单这个场景。
我更倾向于把流程设计成这样:
markdown
1. 收到 PAY_SUCCESS 消息
2. 获取或创建消费记录
3. 如果记录是 SUCCESS,直接返回
4. 如果记录是 PROCESSING 且未超时,稍后重试
5. 标记为 PROCESSING
6. 条件推进订单状态:PAID -> SHARING
7. 如果订单已经是 SHARING,不直接返回,继续检查分账单
8. 创建分账单,依靠 order_no + share_type 唯一索引兜底
9. 关键业务动作完成后,标记消费记录 SUCCESS
10. 失败时记录 FAIL,等待重试或补偿
对应代码大概是这样:
csharp
@KafkaListener(topics = "pay-success-topic", groupId = "profit-share-service")
public void consume(PaySuccessMessage message) {
String eventId = message.getEventId();
String consumerName = "profit-share-service";
ConsumeRecord record = consumeRecordService.getOrCreate(eventId, consumerName);
if (record.isSuccess()) {
log.info("消息已完整处理成功,忽略重复消息,eventId={}", eventId);
return;
}
if (record.isProcessing() && !record.isTimeout()) {
log.info("消息正在处理中,稍后重试,eventId={}", eventId);
throw new RetryLaterException("message processing");
}
consumeRecordService.markProcessing(eventId, consumerName);
try {
int rows = orderMapper.updateStatus(
message.getOrderNo(),
OrderStatus.PAID,
OrderStatus.SHARING
);
if (rows == 0) {
Order current = orderMapper.selectByOrderNo(message.getOrderNo());
if (OrderStatus.SHARE_SUCCESS.equals(current.getStatus())) {
consumeRecordService.markSuccess(eventId, consumerName);
return;
}
if (!OrderStatus.SHARING.equals(current.getStatus())) {
throw new BusinessException("订单状态不允许创建分账单");
}
}
try {
profitShareOrderMapper.insert(buildShareOrder(message));
} catch (DuplicateKeyException e) {
log.info("分账单已存在,按幂等成功处理,orderNo={}", message.getOrderNo());
}
consumeRecordService.markSuccess(eventId, consumerName);
} catch (Exception e) {
consumeRecordService.markFail(eventId, consumerName, e.getMessage());
throw e;
}
}
这段代码不是为了展示某种标准答案。
它只是想表达几个原则:
ini
只有 SUCCESS 才能直接忽略重复消息
PROCESSING 要考虑超时接管
FAIL 要允许重试或补偿
状态 rows = 0 不能无脑返回
业务单据必须靠唯一索引兜底
这样重复消息再进来时,不会重复创建分账单。
而上一次处理到一半失败时,也不会被幂等记录直接挡死。
九、外部调用是另一层幂等边界
如果消费消息后还要调用外部接口,比如渠道分账、退款、发券,就不能只考虑本地数据库幂等。
因为可能出现这样的情况:
| 时间点 | 动作 |
|---|---|
| T1 | 本地创建分账单成功 |
| T2 | 调用渠道分账接口 |
| T3 | 渠道实际处理成功 |
| T4 | 本地服务超时,没有拿到响应 |
| T5 | 消息重试 |
| T6 | 再次调用渠道分账接口 |
这时如果没有外部请求幂等,可能会造成重复分账。
所以这类请求必须带稳定的业务请求号:
ini
requestNo = SHARE_{orderNo}_{shareType}
并且外部系统需要保证:
同一个 requestNo 重复请求,只执行一次,后续返回同一处理结果。
如果外部系统不支持幂等,本地就不能盲目重试执行类接口。
更稳的策略是:
先落本地请求记录
调用失败或超时时,优先查外部结果
确认外部未处理后,再决定是否重试
无法确认时,进入人工或补偿流程
跨出本地数据库之后,幂等边界也必须跟着跨出去。
十、Redis 锁解决不了这个问题
有些人会想:
消费的时候加一把 Redis 分布式锁,不就不会重复了吗?
锁可以减少并发执行,但它不是幂等。
比如第一轮消费已经执行到一半,然后失败释放锁。
第二轮消息隔了 30 秒后重试,这时锁早就不存在了。
它照样会再次执行。
锁解决的是:
同一时刻不要有两个线程一起处理
幂等解决的是:
同一个业务动作重复执行时,结果仍然正确
这两个问题不一样。
所以在 MQ 消费场景里,分布式锁最多只能作为辅助,不能作为最后防线。
最后真正兜底的,还是这些东西:
消费记录状态
状态条件更新
唯一索引
外部 requestNo
补偿任务
十一、这套方案的边界
这套设计能解决"重复消费"和"失败消息被幂等表挡死"的问题,但它也不是银弹。
1. 消费记录和业务操作不一定在一个事务里
如果消费记录更新成功,但业务事务提交失败,或者业务成功后消费记录标记成功失败,仍然可能出现不一致。
所以关键业务动作必须自身幂等,不能只依赖消费记录。
2. 中间态必须有补偿
比如:
订单状态 = SHARING
分账单 = 不存在
或者:
分账单已创建
分账执行消息没发出去
这类问题不能指望 MQ 重试全部解决。
需要补偿任务定期扫描异常状态,重新推进或标记失败。
3. 幂等键不能选错
幂等键太粗,会误伤正常业务。
比如只用 orderNo 做幂等,就可能把同一个订单的支付成功、退款成功、分账成功混在一起。
幂等键太细,又挡不住重复。
比如每次重试都生成新的 requestId,那就完全失去了幂等意义。
幂等键应该来自稳定的业务语义:
eventId
eventType + bizNo
orderNo + shareType
refundNo
requestNo
4. 幂等记录不能无限增长
消费记录表会持续增长。
后面要考虑:
按业务周期保留
定期归档
历史数据清理
关键字段索引
否则幂等表本身也可能变成性能瓶颈。
十二、总结
MQ 重复消费不可避免,业务幂等也不是简单加一张表就结束。
真正要小心的是:
幂等记录存在,不代表业务已经完整成功。
如果把"处理过"简单理解成"记录存在",就很容易出现:
第一次处理到一半失败
第二次重试被幂等表挡住
业务永远卡在中间态
更稳的做法是:
只有 SUCCESS 才能跳过
PROCESSING 要考虑超时
FAIL 要允许重试或补偿
状态推进要带前置条件
业务单据要靠唯一索引
外部调用要带 requestNo
幂等的目标不是让消息不重复。
而是让消息重复来时,业务不会重复生效;
消息失败重试时,业务也不会被错误地挡死。