目录
一、业务幂等设计思路(三层业务保障)
二、核心代码实现
1. MQ 消息体定义(必须携带业务唯一订单号)
2. 幂等工具类 Redis 操作
3. RocketMQ 消费监听 完整幂等逻辑
4. 数据库兜底方案(防止 Redis 丢失数据极端情况)
三、三层业务幂等完整流程(面试口述重点)
四、关键业务设计细节(面试官高频追问)
1. 为什么幂等 key 要组合 orderNo + msgType?
2. 为什么标记幂等要放在业务执行成功之后,不能前置?
3. Redis 过期时间为什么设 24 小时?
4. 和单纯数据库唯一索引的区别(业务层面优势)
5. 业务场景举例:重复结算消息的完整拦截链路
五、拓展:事务消息场景幂等兼容(充电资金业务)
场景背景:RocketMQ 推送订单结算、账单生成、设备状态更新消息;重复投递、重试会导致重复扣费、重复生成账单、重复推送通知,需要业务层幂等控制,同一订单消息只执行一次。
一、业务幂等设计思路(三层业务保障)
- 业务唯一标识:每条 MQ 消息携带业务唯一键
orderNo(订单号),作为幂等判断核心; - 前置状态校验(业务第一道拦截,成本最低):查询订单业务状态,已完成结算直接丢弃消息,不执行后续逻辑;
- 幂等记录缓存(Redis):处理成功后存入 Redis 标记该订单已消费,短时间内重复消息直接拦截;
- 数据库唯一约束兜底:账单表基于 orderNo 唯一索引,防止极端情况重复落库;
- 消费成功后置标记:只有完整业务执行完毕才记录幂等标识,失败不标记,支持正常重试。
二、核心代码实现
1. MQ 消息体定义(必须携带业务唯一订单号)
import lombok.Data; import java.io.Serializable; /** * 充电订单结算消息 */ @Data public class ChargeOrderMsg implements Serializable { // 业务唯一幂等键:订单号(核心) private String orderNo; // 场站ID private Long stationId; // 充电度数 private BigDecimal chargeDegree; // 消息业务类型:SETTLE-结算、NOTICE-通知、STAT-统计 private String msgType; }2. 幂等工具类 Redis 操作
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.util.concurrent.TimeUnit; @Component public class MqIdempotentUtil { // 幂等key前缀 private static final String IDEM_PREFIX = "mq:charge:idempotent:"; // 过期时间:24小时,覆盖消息重试最大周期 private static final long EXPIRE_HOUR = 24; @Resource private StringRedisTemplate stringRedisTemplate; /** * 判断该订单消息是否已处理过 * @param orderNo 业务唯一订单号 * @param msgType 消息类型(同一订单不同消息类型互不干扰) * @return true=已处理,直接丢弃;false=未处理,执行业务 */ public boolean isProcessed(String orderNo, String msgType) { String key = IDEM_PREFIX + msgType + ":" + orderNo; return Boolean.TRUE.equals(stringRedisTemplate.hasKey(key)); } /** * 业务执行成功后,标记当前订单消息已完成 */ public void markProcessed(String orderNo, String msgType) { String key = IDEM_PREFIX + msgType + ":" + orderNo; stringRedisTemplate.opsForValue().set(key, "1", EXPIRE_HOUR, TimeUnit.HOURS); } }3. RocketMQ 消费监听 完整幂等逻辑
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; import org.apache.rocketmq.spring.core.RocketMQListener; import org.springframework.stereotype.Service; import javax.annotation.Resource; @Service @RocketMQMessageListener( topic = "CHARGE_ORDER_SETTLE_TOPIC", consumerGroup = "charge-order-settle-consumer-group" ) public class ChargeOrderMqConsumer implements RocketMQListener<ChargeOrderMsg> { @Resource private MqIdempotentUtil mqIdempotentUtil; @Resource private ChargeOrderService chargeOrderService; @Resource private BillService billService; @Override public void onMessage(ChargeOrderMsg msg) { String orderNo = msg.getOrderNo(); String msgType = msg.getMsgType(); // 1. 参数校验,防止空消息 if (orderNo == null || msgType == null) { throw new RuntimeException("消息参数非法,丢弃"); } // ========== 第一层:Redis幂等标记拦截(最快,无DB查询)========== boolean processed = mqIdempotentUtil.isProcessed(orderNo, msgType); if (processed) { // 重复消息,直接消费成功,不再执行业务 System.out.printf("订单[%s]消息[%s]已处理,跳过\n", orderNo, msgType); return; } // ========== 第二层:业务状态前置校验(核心业务层面幂等)========== // 查询订单当前业务状态,已结算订单不重复执行结算逻辑 ChargeOrder order = chargeOrderService.getByOrderNo(orderNo); if (order == null) { throw new RuntimeException("订单不存在,重试"); } // 业务状态:已结算/已归档,直接拦截,不生成账单、不扣费 if ("SETTLED".equals(order.getOrderStatus()) || "ARCHIVED".equals(order.getOrderStatus())) { // 标记已处理,避免下次重复消息再查库 mqIdempotentUtil.markProcessed(orderNo, msgType); System.out.printf("订单[%s]已完成结算,跳过消费\n", orderNo); return; } try { // ========== 正常业务逻辑 ========== // 1. 计算费用、扣用户余额 chargeOrderService.settleOrder(order); // 2. 生成结算账单 billService.createBill(order); // 3. 推送用户扣费通知 chargeOrderService.sendUserNotice(order); // ========== 业务全部执行成功,再写入幂等标记 ========== // 关键点:必须后置标记!业务异常不标记,下次可以正常重试 mqIdempotentUtil.markProcessed(orderNo, msgType); } catch (Exception e) { // 业务异常,不打幂等标记,RocketMQ自动重试 throw new RuntimeException("订单消费业务异常,触发重试", e); } } }4. 数据库兜底方案(防止 Redis 丢失数据极端情况)
账单表增加唯一联合索引:UNIQUE uk_order_msg_type (order_no, msg_type)
CREATE TABLE charge_bill ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT '订单号', msg_type VARCHAR(16) NOT NULL COMMENT '消息类型', total_fee DECIMAL(10,2) NOT NULL COMMENT '结算金额', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, -- 业务唯一约束兜底,重复执行直接SQL报错 UNIQUE KEY uk_order_msg_type (order_no, msg_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;当 Redis 数据过期 / 丢失,重复消息走到入库逻辑时,唯一索引冲突抛出异常,消费抛出异常触发重试,但不会生成重复账单,不会产生脏数据。
三、三层业务幂等完整流程(面试口述重点)
- Redis 快速拦截(第一层)收到消息先查 Redis 是否存在该订单 + 消息类型的幂等 key,存在直接 return,确认消费成功,不访问数据库,性能最高。
- 业务状态校验(第二层,纯业务逻辑核心)即使 Rediskey 过期丢失,查询订单自身业务状态; 结算类消息只允许充电中状态执行,订单状态为已结算、已归档直接拦截,从业务规则上杜绝重复结算、重复扣费,这是最核心的业务幂等手段,不依赖中间件 / 缓存。
- 数据库唯一索引兜底(第三层)缓存失效 + 代码异常双重极端场景,数据库唯一约束拦截重复数据落地,杜绝脏账单、重复财务数据。
四、关键业务设计细节(面试官高频追问)
1. 为什么幂等 key 要组合 orderNo + msgType?
同一订单会下发多种消息:结算消息、统计消息、通知消息; 不同消息是独立业务,不能互相拦截,分开标记互不影响。
2. 为什么标记幂等要放在业务执行成功之后,不能前置?
如果先写入 Redis 再执行业务,中途代码异常、服务宕机,业务没做完但 key 已标记,消息直接丢失,造成账单不生成、用户收不到通知; 后置标记保证:只有完整业务执行成功,才记录已处理,失败不标记,RocketMQ 重试恢复。
3. Redis 过期时间为什么设 24 小时?
RocketMQ 重试周期一般不超过 12 小时,24 小时完全覆盖消息全部重试生命周期; 过期后少量重复消息会再次经过业务状态校验,第二层兜底拦截,不会产生业务问题。
4. 和单纯数据库唯一索引的区别(业务层面优势)
- 仅用唯一索引:重复消息每次都会走到 DB,频繁报唯一冲突,大量无效 DB 查询,性能差;
- 业务状态前置校验:纯业务规则判断,在更新数据库前直接阻断,不会产生 SQL 异常,友好无报错日志。
5. 业务场景举例:重复结算消息的完整拦截链路
- MQ 重复投递同一条结算消息;
- Redis 查到已标记,直接跳过;
- 若 Rediskey 过期,查询订单状态 = 已结算;
- 代码直接 return,不执行扣费、生成账单;
- 极端 Redis 丢失 + 状态判断 bug,入库账单表触发唯一索引报错,抛出异常,无重复账单。
五、拓展:事务消息场景幂等兼容(充电资金业务)
如果使用 RocketMQ 事务消息,幂等逻辑不变: 事务提交成功后才执行markProcessed;事务回滚不会写入幂等 key,下次正常消费,保证资金不会重复扣减。