高并发场景下:如何保证消息只被消费一次
大家好,我是一名摸爬滚打 8 年的 Java 开发。这些年从电商秒杀到金融对账,踩过最多的坑就是 “消息重复消费”—— 比如订单明明只下了一次,却发了两回物流通知;更要命的是金融场景,一笔转账重复扣了两次钱,排查到凌晨三点才搞定。今天就结合实战经验,聊聊高并发下怎么让消息 “只吃一次饭”。
先搞懂:为啥消息会 “重复上桌”?
在聊解决方案前,得先明白重复消费不是 “bug”,而是分布式系统的 “必然现象”—— 网络不可靠、服务会重启,这些都会导致消息被多投一次。我总结了 3 个最常见的场景:
-
消费者 Ack 超时:比如用 RabbitMQ 时,消费者拿到消息后还没处理完就宕机了(比如 JVM OOM),没来得及发 Ack。Broker 以为消费者没收到,会把消息重新投给其他消费者;
-
网络波动导致重试:生产者发消息给 Broker 时,网络卡了一下,没收到 Broker 的 “接收成功” 响应,生产者以为发送失败,就重试了一次,结果 Broker 其实收到了两条一样的消息;
-
Broker 自身重试:比如 Kafka 的分区副本同步时,Leader 挂了,Follower 升级成新 Leader,可能会导致部分消息重复投递;或者用 RocketMQ 时,开启了 “重试队列”,消费失败的消息会被重新投递。
举个我 19 年的真实坑:当时做电商订单系统,用 RabbitMQ 发 “订单支付成功” 的消息,消费者是库存服务。有次线上突然断电,库存服务重启后,Broker 把之前没 Ack 的消息重新投了一次,结果同个订单扣了两次库存 —— 用户没投诉,但运营查库存时发现对不上,排查了半天才发现是 Ack 机制没处理好。
核心思路:让消费逻辑 “幂等”
其实解决重复消费的本质,不是 “阻止消息重复投递”(因为你阻止不了),而是让 “重复的消息执行多次,结果和执行一次一样”—— 这就是幂等性设计。
我这些年实战里,常用的有 4 种方案,各有优劣,得结合业务选:
方案 1:用 “业务唯一 ID” 做防重 —— 最通用的方案
这是我用得最多的方案,核心就是给每条消息打一个 “唯一身份证”,消费前先查一下这个身份证有没有被处理过,有就跳过,没有就处理。
怎么生成唯一 ID? 有两种常见方式:
-
业务自带 ID:比如订单消息用 “订单号”,支付消息用 “交易流水号”—— 好处是不用额外生成 ID,业务语义强;
-
消息中间件自带 ID:比如 Kafka 的
record.offset(但要注意分区,同个分区内 offset 唯一)、RabbitMQ 的messageId(需要生产者自己设置),或者用雪花算法生成全局唯一 ID。
实战代码示例(Spring Boot + RabbitMQ):
比如处理订单支付消息,用 “订单号” 做唯一 ID,我会用 Redis 做防重(比数据库快,适合高并发):
@RabbitListener(queues = "order.pay.success.queue", ackMode = "MANUAL") // 手动Ack
public void handlePaySuccessMsg(Message message, Channel channel) throws IOException {
// 1. 解析消息体,拿到订单号(业务唯一ID)
String msgBody = new String(message.getBody(), StandardCharsets.UTF_8);
JSONObject msgJson = JSON.parseObject(msgBody);
String orderNo = msgJson.getString("orderNo"); // 核心:业务唯一ID
// 2. Redis防重:key=order:pay:success:{orderNo},value随便,过期时间30分钟(业务高峰期过了就行)
String redisKey = "order:pay:success:" + orderNo;
Boolean isExist = redisTemplate.opsForValue().setIfAbsent(redisKey, "1", 30, TimeUnit.MINUTES);
if (Boolean.TRUE.equals(isExist)) {
// 3. 防重通过,执行核心业务(比如扣库存、发通知)
try {
orderService.handlePaySuccess(orderNo);
// 4. 业务成功,手动Ack(告诉Broker消息处理完了,别再投了)
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (Exception e) {
// 5. 业务失败,根据情况处理:是重试还是丢死信队列
log.error("处理订单支付消息失败,orderNo={}", orderNo, e);
// 比如:如果是数据库临时不可用,就拒绝消息让Broker重试;如果是业务错误(比如订单不存在),就直接Ack丢了
channel.basicReject(message.getMessageProperties().getDeliveryTag(), false);
}
} else {
// 6. 防重命中,说明已经处理过了,直接Ack
log.warn("订单消息已重复消费,orderNo={}", orderNo);
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
}
}
踩坑提醒⚠️:
- 别用 UUID 做业务唯一 ID!我之前有个同事图方便用 UUID,结果后面查问题时,根本没法关联到具体业务,排查起来巨麻烦;
- Redis 过期时间别设太短!比如秒杀场景,消息处理可能有延迟,设太短会导致 “刚处理完,Redis key 就过期了,又重复消费”;也别设太长,占内存。
方案 2:数据库防重表 —— 适合对一致性要求极高的场景
如果业务对数据一致性要求特别高(比如金融转账、对账),Redis 可能会有 “缓存穿透 / 击穿” 的风险,这时候我会用数据库防重表—— 本质就是用数据库的唯一索引来保证 “同一消息只处理一次”。
实战思路:
-
建一张防重表,比如
msg_process_record,字段至少包含msg_unique_id(唯一索引)、msg_content(消息内容)、process_status(处理状态)、create_time; -
消费消息前,先往这张表插一条记录:如果插入成功(说明没处理过),就执行业务;如果报 “唯一约束冲突”(说明处理过了),就直接跳过;
-
好处是靠数据库事务保证一致性,坏处是会多一次数据库插入操作,性能比 Redis 差,适合低 QPS 但高一致性的场景。
防重表 SQL 示例:
CREATE TABLE `msg_process_record` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`msg_unique_id` varchar(64) NOT NULL COMMENT '消息唯一ID(订单号/流水号)',
`msg_content` text NOT NULL COMMENT '消息内容',
`process_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '处理状态:0-待处理,1-处理成功,2-处理失败',
`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_msg_unique_id` (`msg_unique_id`) COMMENT '唯一索引:保证同一消息只插一次'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '消息处理防重表';
代码关键逻辑:
@Transactional // 用事务保证“插防重表”和“业务处理”原子性
public void handleTransferMsg(String msgUniqueId, String msgContent) {
try {
// 1. 插入防重表:如果报唯一约束异常,说明已处理
MsgProcessRecord record = new MsgProcessRecord();
record.setMsgUniqueId(msgUniqueId);
record.setMsgContent(msgContent);
msgProcessMapper.insert(record);
// 2. 执行转账业务(核心逻辑)
transferService.doTransfer(msgContent);
// 3. 更新防重表状态为“处理成功”
record.setProcessStatus(1);
msgProcessMapper.updateById(record);
} catch (DuplicateKeyException e) {
// 4. 唯一约束冲突,说明已处理过
log.warn("转账消息已重复消费,msgUniqueId={}", msgUniqueId);
} catch (Exception e) {
// 5. 业务失败,更新状态为“处理失败”,后续人工排查
log.error("处理转账消息失败,msgUniqueId={}", msgUniqueId, e);
record.setProcessStatus(2);
msgProcessMapper.updateById(record);
}
}
方案 3:基于业务状态机 —— 最 “优雅” 的方案
如果业务本身有明确的状态流转(比如订单:待支付→支付中→已支付→已完成),那根本不用额外搞防重表或 Redis,直接用状态机判断就行 —— 重复的消息会因为 “状态不匹配” 被拒绝。
比如我之前做的订单系统,“支付成功” 的消息只能从 “待支付” 或 “支付中” 状态流转到 “已支付”。如果收到重复消息时,订单已经是 “已支付” 了,就直接跳过。
代码示例:
public void handleOrderPaySuccess(String orderNo) {
// 1. 查询订单当前状态
Order order = orderMapper.selectByOrderNo(orderNo);
if (order == null) {
log.error("订单不存在,orderNo={}", orderNo);
return;
}
// 2. 状态判断:只有“待支付”或“支付中”才能处理
if (order.getStatus() != OrderStatus.PENDING_PAY && order.getStatus() != OrderStatus.PAYING) {
log.warn("订单状态不允许处理,orderNo={},当前状态={}", orderNo, order.getStatus().getName());
return;
}
// 3. 执行业务逻辑(扣库存、生成物流单)
orderService.updateOrderStatus(orderNo, OrderStatus.PAID);
inventoryService.deductStock(orderNo);
logisticsService.createLogisticsOrder(orderNo);
log.info("订单支付成功处理完成,orderNo={}", orderNo);
}
这个方案的好处:不用额外的存储(Redis / 数据库),完全靠业务逻辑自身保证幂等,性能最好;但前提是业务有清晰的状态流转,比如订单、工单这类场景。
方案 4:利用消息中间件自身机制 —— 减少重复投递概率
前面的方案都是 “消费端” 做防重,其实 “投递端” 也能做一些优化,减少重复消息的产生,比如:
-
RabbitMQ:手动 Ack + 持久化:
- 必须开启手动 Ack(
ackMode=MANUAL),别用自动 Ack—— 自动 Ack 会导致 “消息刚拿到就 Ack 了,结果业务处理失败,消息丢了”,或者 “业务没处理完就 Ack 了,宕机后重复消费”; - 队列和消息都要设为持久化(
durable=true),防止 Broker 宕机后消息丢失,不得不重新投递。
- 必须开启手动 Ack(
-
Kafka:用 Consumer Group + Offset 提交:
- 消费者组(Consumer Group)会保证同个分区的消息只被组内一个消费者消费;
- 开启 “手动提交 Offset”(
enable.auto.commit=false),只有业务处理成功后才提交 Offset—— 如果处理失败,下次重启会从上次没提交的 Offset 开始消费,避免重复。
-
RocketMQ:消息轨迹 + 重试次数限制:
- 开启消息轨迹,能快速排查重复消息的来源;
- 给消费组设置 “最大重试次数”(比如 5 次),超过次数的消息丢到死信队列,别一直重试导致重复消费。
实战案例:秒杀系统怎么防重复消费?
去年做过一个峰值 10 万 QPS 的秒杀系统,消息重复消费是重点优化项,当时的方案是 “Redis 防重 + 业务状态机 + RabbitMQ 手动 Ack”,具体流程:
-
生产者:用户秒杀成功后,生成 “秒杀订单号”(比如
seckill_20240520_10086),作为消息的唯一 ID,发往 RabbitMQ; -
Broker:队列设置持久化,消息持久化,避免宕机丢失;
-
消费者:
-
第一步:用秒杀订单号作为 Redis key,
setIfAbsent防重,过期时间 1 小时(秒杀活动一般 1 小时内结束); -
第二步:如果防重通过,查询订单状态 —— 只有 “待确认” 状态才处理;
-
第三步:处理业务(扣库存、生成正式订单),处理成功后手动 Ack;
-
第四步:如果处理失败,丢到死信队列,后续人工排查(秒杀场景失败的消息不多,人工处理来得及)。
-
上线后,峰值期间没出现过一次重复消费,Redis 的 QPS 也才 1 万多,完全扛得住。
总结:没有银弹,只有 “合适”
8 年经验告诉我,没有哪种方案能解决所有场景,关键是根据业务选:
-
高并发、一致性要求一般(比如通知、日志):选 Redis 防重;
-
低并发、一致性要求极高(比如金融、对账):选数据库防重表;
-
业务有清晰状态流转(比如订单、工单):选状态机;
-
不管选哪种,一定要手动 Ack + 记录日志 + 监控重复次数(比如用 Prometheus 监控 Redis 防重命中次数,超过阈值报警)。
最后想问下大家:你们有没有遇到过奇葩的重复消费场景?比如消息重复了十几次?欢迎评论区交流,一起避坑~