RocketMQ消息防重实战:MySQL唯一索引与Redisson锁的深度博弈
电商订单支付回调系统面临的核心挑战之一,是如何在高并发环境下确保每笔订单只被处理一次。想象这样一个场景:凌晨大促期间,支付网关每秒推送上千条回调通知,而某笔订单因网络抖动导致RocketMQ重复投递了三条相同消息。如果系统未能有效识别重复消息,用户可能被多次扣款或重复发货——这种事故在电商大促期间造成的损失往往是灾难性的。
1. 消息重复的本质与业务影响
消息中间件的"至少一次"投递机制决定了重复消费无法完全避免。从技术视角看,重复主要发生在三个环节:
- 生产者重试:消息成功写入Broker但ACK响应丢失时,客户端自动重试
- Broker投递:消费者处理成功但确认消息未送达服务端
- 负载均衡:消费者实例扩容或重启触发Rebalance,导致分区消息重新分配
在订单支付场景中,重复消费可能引发以下连锁反应:
- 财务对账异常,需人工介入核查
- 库存超额扣减,影响其他订单履约
- 用户收到重复发货,引发客诉
- 营销活动预算被超额消耗
// 典型支付回调消息结构示例 { "orderNo": "PO2023051898765", "paymentAmount": 29900, "transactionId": "WX202305187654321", "payTime": "2023-05-18 14:23:45" }2. MySQL唯一索引方案的精妙与局限
利用数据库唯一约束实现幂等是经典方案,其核心在于将业务唯一标识(如订单号)作为防重依据。相比依赖RocketMQ的MessageID,这种方式更能应对跨系统的消息去重需求。
2.1 完整实现方案
-- 幂等表设计要点 CREATE TABLE `payment_idempotent` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '订单编号', `payment_id` varchar(128) NOT NULL COMMENT '支付流水号', `status` tinyint NOT NULL DEFAULT '0' COMMENT '处理状态', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order` (`order_no`) USING BTREE, UNIQUE KEY `uk_payment` (`payment_id`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;Spring Boot中的消费者实现需要特别注意事务边界:
@Transactional(rollbackFor = Exception.class) public void processPaymentMessage(MessageExt message) { PaymentCallbackDTO dto = parseMessage(message); try { // 先尝试插入防重记录 jdbcTemplate.update( "INSERT INTO payment_idempotent(order_no, payment_id) VALUES(?, ?)", dto.getOrderNo(), dto.getTransactionId()); // 真正的业务处理 orderService.confirmPayment(dto); } catch (DuplicateKeyException e) { log.warn("重复支付消息 orderNo={}", dto.getOrderNo()); // 可查询当前状态决定是否要更新 return; } }2.2 性能优化实践
当QPS超过500时,单纯依赖数据库插入会面临瓶颈。我们通过以下策略提升性能:
- 内存缓冲队列:先用ConcurrentHashMap做短时间内的去重
- 批量插入:每100ms批量写入一次防重记录
- 索引优化:对order_no字段使用前缀索引(前12位)
| 优化策略 | TPS提升 | 平均延迟降低 |
|---|---|---|
| 无优化 | 基准值 | 基准值 |
| 内存缓冲 | 3.2倍 | 68% |
| 批量插入 | 1.8倍 | 42% |
| 组合方案 | 5.7倍 | 83% |
注意:内存方案需配合本地持久化机制,防止应用重启导致防重失效
3. Redisson分布式锁的攻守之道
Redis方案更适合需要维护处理状态的场景。相比MySQL方案,它具有两大优势:
- 锁自动续期机制避免死锁
- 可设置灵活的过期时间适应不同业务
3.1 生产级实现方案
public class PaymentProcessor { private static final String LOCK_PREFIX = "pay:lock:"; private static final String PROCESSED_FLAG = "pay:processed:"; @Autowired private RedissonClient redisson; public void handleMessage(MessageExt message) { PaymentCallbackDTO dto = parseMessage(message); String lockKey = LOCK_PREFIX + dto.getOrderNo(); RLock lock = redisson.getLock(lockKey); try { // 尝试获取锁,等待3秒,持有30秒 if (lock.tryLock(3, 30, TimeUnit.SECONDS)) { if (redisTemplate.opsForValue().get(PROCESSED_FLAG + dto.getOrderNo()) != null) { log.info("订单已处理 orderNo={}", dto.getOrderNo()); return; } processPayment(dto); redisTemplate.opsForValue().set( PROCESSED_FLAG + dto.getOrderNo(), "1", 2, TimeUnit.HOURS); } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }3.2 高可用设计要点
多级降级策略:
- 优先使用Redis Cluster
- 故障时切换至本地Guava Cache
- 极端情况启用数据库兜底
锁竞争优化:
- 对订单号取模实现分段锁
- 设置合理的锁等待超时时间
- 避免在锁内执行耗时操作
// 分段锁实现示例 public String getSegmentLockKey(String orderNo) { int segment = Math.abs(orderNo.hashCode()) % 32; return "pay:segment:" + segment + ":" + orderNo; }4. 混合方案的架构设计
综合两种方案的优缺点,我们设计出分层防御体系:
第一层:Bloom Filter
- 使用RedisBloom模块
- 快速过滤绝对重复消息
- 误差率设置为0.1%
第二层:本地缓存
- Caffeine缓存近期处理记录
- 有效期5分钟
- 最大条目10万
第三层:分布式锁
- 处理疑似重复消息
- 锁持有时间与业务超时对齐
第四层:数据库唯一索引
- 最终一致性保障
- 配合定时任务修复异常状态
# 伪代码展示多级校验流程 def process_message(message): if bloom_filter.check(message.id): return if local_cache.get(message.order_no): return with distributed_lock(message.order_no): if db.query("SELECT status FROM orders WHERE order_no = ?", message.order_no): return execute_business(message) bloom_filter.add(message.id) local_cache.set(message.order_no, True)实际测试数据显示,这种混合方案在10万QPS压力下:
- 平均处理延迟:8ms
- Redis CPU利用率:35%
- MySQL写入量降低72%
- 错误率为0
5. 特殊场景的应对策略
分库分表环境下,唯一索引方案需要调整:
- 基因法分片:将订单号哈希值融入分片键
- 全局索引表:单独维护幂等记录表
- 分布式事务:配合Seata保证防重记录与业务一致性
对于跨境支付等长事务场景,建议:
- 延长Redis锁过期时间(最少2倍于平均处理时长)
- 实现锁续约心跳机制
- 增加人工干预接口处理僵死订单
在秒杀系统中,我们进一步优化:
- 将库存预扣减与订单创建解耦
- 使用Redis原子操作保证防重
- 异步落库采用批量合并写入
// 秒杀场景的Redis Lua脚本示例 String script = "if redis.call('exists', KEYS[1]) == 1 then\n" + " return 0\n" + "else\n" + " redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2])\n" + " return 1\n" + "end";经过三年双十一验证,这套方案成功将支付回调系统的重复处理率控制在0.0001%以下。关键经验是:没有银弹方案,必须根据业务特征组合多种技术,并在可靠性和性能之间找到最佳平衡点。