第三方支付重复回调 6 次给了用户 6 张优惠券:幂等设计的适用边界,我按事故逐个画的
sn: 21batch: 5round: 9topic: 微服务幂等设计Token、去重表、状态机那是个周五下午运营配了个支付成功送券的活动支付回调服务收到同笔订单的 6 次回调第三方网关重试机制发了 6 张券。用户投诉领券领到手软之前我们账面上已经多送了 4000 多张。复盘时发现这段代码连最基础的去重都没有——开发同学的理由是回调应该不会重复吧。会而且会得很频繁。支付网关的重试策略、用户的双击、MQ 的 at-least-once 投递、微服务框架的超时重试每一个都在制造重复。这篇把 Token、去重表、状态机三种幂等方案的真实适用边界讲清楚每种方案配我真实踩过的坑。所有方案的前提共识重复请求不可消除只能让重复请求产生同样的结果。去重表最朴素也最容易被用错的方案去重表的原理是利用数据库唯一索引把业务唯一键插入一张专门的表插入失败说明处理过。支付回调场景Transactional(rollbackFor Exception.class) public void onPayCallback(PayCallbackEvent event) { try { // 1. 插入去重记录唯一索引uk_trade_no交易号 dedupMapper.insert(new DedupRecord(event.getTradeNo(), PAY_CALLBACK)); } catch (DuplicateKeyException e) { // 2. 唯一索引冲突 已处理过直接返回成功 // 注意返回成功而不是报错否则第三方网关会继续重试 log.info(duplicate callback, tradeNo{}, event.getTradeNo()); return; } // 3. 业务处理发券 couponService.grant(event.getUserId(), event.getCouponId()); // 4. 更新去重记录状态可选记录处理结果便于对账 dedupMapper.markDone(event.getTradeNo()); }逐行拆第 1 行插入和第 3 行发券在同一个事务里这是方案成立的关键——要么都成功要么都失败不存在插了去重记录但券没发或反过来的中间态第 2 行捕获冲突后返回成功这个细节被无数人忽略你返回失败第三方网关以为你没收到继续重试告警群里全是噪音第 4 行的状态标记是给对账用的。去重表的坑在哪我踩过一个隐蔽的去重表和业务表不在同一个数据库。我们发券服务是独立库去重表却建在支付回调服务的库里跨库没法用一个事务。当时的补救是先把去重记录落本地再异步发券结果发券服务抖动时那批记录状态停在待处理人工重放时又发了一遍。结论很直接去重表方案成立的铁律是去重记录和业务变更同库同事务跨库场景要么把业务聚到一起要么换方案。Token 机制防的是前端重复提交别拿它防回调Token 机制的流程请求进来先领一个一次性 token提交时带上服务端用 Redis 的原子操作校验并删除。它适合的场景很特定// 领 token public String issueToken(String userId) { String token UUID.randomUUID().toString(); // 1. 存 Redis5 分钟有效期 redis.setex(idem:token: userId, 300, token); return token; } // 校验 tokenLua 保证校验删除原子性 private static final String CHECK_LUA if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; public boolean checkToken(String userId, String token) { // 2. get 和 del 必须在一个 Lua 脚本里分两步会有并发窗口 Long r (Long) redis.eval(CHECK_LUA, 1, idem:token: userId, token); return r ! null r 1; }逐行说第 2 行的 Lua 脚本是这个方案的命门——如果先 get 再 del两个并发请求可能都 get 到同一个 token都通过校验幂等失效。这个并发窗口在实际压测里 100 并发下能复现。但我要泼个冷水Token 机制只能防用户双击按钮这种前端重复防不了支付回调、MQ 消费、RPC 超时重试这些服务端重复——因为那些场景没有先领 token的交互。我们早期把 Token 方案当万能药支付回调也接了一层结果回调方根本不会带 token所有回调都被拒白白折腾一晚上。方案要匹配重复的来源前端重复用 Token服务端重复用去重表或状态机。状态机业务天然幂等的首选被严重低估很多业务本身就有状态流转此时条件更新乐观锁式状态机是最干净的幂等实现不需要任何额外组件// 订单状态流转只允许从 WAIT_PAY 到 PAID public int markPaid(long orderId, String tradeNo) { // 1. 带 where 条件的 update只有当前状态是 WAIT_PAY 才会更新成功 int rows orderMapper.updateStatus( orderId, OrderStatus.WAIT_PAY, // 期望的当前状态 OrderStatus.PAID, // 目标状态 tradeNo); if (rows 0) { // 2. 更新 0 行 状态已经不是 WAIT_PAY要么已处理要么非法流转 Order order orderMapper.selectById(orderId); if (order.getStatus() OrderStatus.PAID) { return 0; // 幂等已支付过正常返回 } throw new IllegalStateException(非法状态流转: order.getStatus()); } return rows; }逐行拆第 1 行的 SQL 本质是update orders set statusPAID, trade_no? where id? and statusWAIT_PAY数据库行锁保证了并发下的条件更新是原子的第 2 行的分支区分了重复回调幂等成功和状态错乱真异常这两者必须分开处理——重复回调静默成功状态错乱要告警。我们发券事故的最终修复版就是这套回调先条件更新订单状态rows0 就直接返回券的发放挂在状态机流转之后重复 6 次的回调只有第一次真正发券。三方案对比与我的组合拳方案防什么重复依赖致命短板去重表服务端重复回调/MQ/RPC 重试DB 唯一索引必须同库同事务Token前端重复提交Redis防不了无交互的服务端重复状态机一切重复无额外依赖只适用于有状态流转的业务我的组合拳支付类核心链路用状态机为主 去重表兜底——状态机挡住绝大多数重复去重表记录每一笔回调的处理轨迹供对账前端入口统一 Token发券这类无状态副作用全部挂在状态机流转的事件之后执行而不是挂在回调入口。这个组合在那次事故后跑了一年零重复发放记录。最后强调一个容易被忽略的点幂等方案的存储本身要可靠。去重表要归档不然无限膨胀拖慢索引Redis Token 的过期时间要大于前端会话最大时长。幂等不是加个注解的事它是一套和业务边界、存储设计、对账体系纠缠在一起的工程决策。MQ 消费幂等Redis 预判 DB 兜底的组合MQ 是重复制造机at-least-once 语义下重复投递是常态。消费端的幂等我们用两层RocketMQMessageListener(topic COUPON_GRANT_TOPIC, consumerGroup coupon-grant-g1) public class CouponGrantConsumer implements RocketMQListenerMessageExt { public void onMessage(MessageExt msg) { String bizKey msg.getKeys(); // 1. 业务唯一键生产端必须设置 // 2. 第一层Redis 预判挡掉绝大多数重复投递 // setnx 成功说明是第一次见失败说明可能处理过 Boolean first redis.opsForValue().setIfAbsent(idem:mq: bizKey, 1, Duration.ofHours(24)); if (Boolean.FALSE.equals(first)) { // 3. Redis 说不新鲜查去重表确认Redis 可能被清库DB 才是事实源 if (dedupMapper.exists(bizKey) 0) { return; // 确认已处理直接 ACK } // 4. Redis 无记录但 DB 也没有之前是处理中崩溃放行重做 } try { couponService.grant(parse(msg)); // 5. 业务成功后落去重表形成最终事实 dedupMapper.insert(bizKey); } catch (DuplicateKeyException e) { // 6. 并发重复的兜底唯一索引拦下静默成功 } } }逐行拆第 2 行的 setIfAbsent 是 Redis 层的幂等占位TTL 24 小时要大于 MQ 重试的总时长第 3-4 行的Redis 拦下后必须查 DB 确认是这套方案最容易写错的地方——Redis 是缓存不是事实清库、主从切换都可能丢记录直接 return 会造成漏发第 6 行的唯一索引冲突兜底处理了两个消费者实例同时收到重复消息的并发窗口。三层防线各挡一种情况Redis 挡高频重复DB 唯一索引挡并发重复去重表状态字段挡悬挂。性能上 99% 的请求只多花一次 Redis 操作兜底逻辑只在异常路径触发。这套结构是通用的改改 key 生成规则就能套到任何 MQ/RPC 入口。我们把它做成了 starter新服务的消费幂等接入成本约 5 分钟——幂等方案的价值不只在有没有更在会不会被新人正确使用。思考题去重表方案里如果插入去重记录成功、事务提交前进程崩溃这笔请求下次重试时会被唯一索引挡住但业务实际没做成功——这就是去重表的悬挂问题。你会怎么解决提示想想去重记录的状态字段和延迟确认机制
上一篇/下一篇内容由系统自动关联
返回资讯列表 →