MQ 消费端 充电订单业务幂等完整实现(业务层面,非单纯数据库唯一键)
目录一、业务幂等设计思路三层业务保障二、核心代码实现1. MQ 消息体定义必须携带业务唯一订单号2. 幂等工具类 Redis 操作3. RocketMQ 消费监听 完整幂等逻辑4. 数据库兜底方案防止 Redis 丢失数据极端情况三、三层业务幂等完整流程面试口述重点四、关键业务设计细节面试官高频追问1. 为什么幂等 key 要组合 orderNo msgType2. 为什么标记幂等要放在业务执行成功之后不能前置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 RocketMQListenerChargeOrderMsg { 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;当 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下次正常消费保证资金不会重复扣减。