
title: 履约回调被重投 5 次账户多扣了 4800 元Token、去重表、状态机的 4 个失效边界topic: 微服务幂等设计Token、去重表、状态机batch: 5round: 3去年双十一后的一次对账财务甩过来一张截图同一个用户的一笔 960 元话费充值账户里被扣了 5 次合计 4800 元。我们第一反应怀疑是支付网关重复通知查日志发现网关确实重投了 5 次但更扎心的是——我们的履约接口对这 5 次请求照单全收每次都执行了一次扣款。这不是孤例。那一周我们对账系统扫出 37 笔类似的多扣涉及金额 6 万多。根因只有一个履约接口没有幂等。这篇文章把那次事故后我们落地的三套幂等方案拆开讲——Token、去重表、状态机以及它们各自会在什么边界上失效。代码都是当时能直接上生产的版本不是教科书里的 demo。事故现场一个没有幂等的扣款接口下面这段是事故当时的简化版敏感信息已脱敏。它的问题不在逻辑错而在于「同样的请求进来几次就执行几次」。RestController RequestMapping(/fulfill) public class FulfillController { Autowired private AccountService accountService; // 支付网关异步回调同一个 notifyId 可能重投 PostMapping(/callback) public String handleCallback(RequestBody CallbackReq req) { // 只校验了签名没校验「这条通知是不是处理过」 if (!signValidator.valid(req)) { return sign_error; } // 直接扣款——重投几次就扣几次 accountService.charge(req.getUserId(), req.getAmount(), req.getOrderId()); return success; } }逐行看这段的坑- 第 12 行if (!signValidator.valid(req))只做了签名校验而签名每次都一样重投的请求当然每次都能通过。- 第 15 行accountService.charge(...)没有任何「是否已经处理过」的判断每次进来都实打实地扣一次。- 整个方法没把req.getNotifyId()网关保证唯一的通知 ID作为去重依据所以框架层完全不知道这是重复请求。我们补的第一道闸就是让「通知 ID」参与去重。下面三种方案各有适用面。方案一Token 机制——把去重前置到请求方Token 的思路是在处理「可能重复」的动作前先让调用方来领一个一次性 Token处理时用这个 Token 当锁用一次就作废。public class IdempotentTokenService { private final StringRedisTemplate redis; // 1. 业务开始前由客户端/上游来申请一个 token写入 Redis 并设置 10 分钟过期 public String acquireToken(String bizKey) { String token UUID.randomUUID().toString(); // 用 SET NX 保证同一个 bizKey 同时只有一个有效 token redis.opsForValue().setIfAbsent(idemp:t: bizKey, token, Duration.ofMinutes(10)); return token; } // 2. 处理时校验 tokenLua 保证「校验删除」原子避免并发下两个请求同时通过 public boolean consume(String bizKey, String token) { String script if redis.call(get,KEYS[1])ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end; Long r redis.execute(RedisScript.of(script, Long.class), List.of(idemp:t: bizKey), token); return r ! null r 1L; } }逐行解释- 第 8 行setIfAbsent(...)对应 Redis 的SET NX保证同一bizKey在 10 分钟内只存在一个 token过期自动失效避免 token 永久占坑。- 第 15 行 Lua 脚本把「读取 token 是否相等」和「删除 token」合成一个原子操作这一步很关键如果先GET再DEL高并发下两个相同请求可能都读到「相等」、都放行幂等就破了。- 第 16 行返回1表示「我是第一个消费这个 token 的」放行返回0表示 token 已被用掉或不存在直接拒绝。Token 的边界它要求调用方配合「先取 token 再请求」。对内部服务间调用好使但对支付网关这种「你不取 token 它也直接回调」的第三方Token 派不上用场。方案二去重表——用数据库唯一索引兜底对第三方回调我们更常用去重表建一张表把「业务唯一键」设成唯一索引插入成功才算处理过。Service public class CallbackService { Autowired private DedupMapper dedupMapper; Autowired private AccountService accountService; Transactional public String handle(CallbackReq req) { try { // 先插去重记录notifyId 上有唯一索引重复插入会抛 DuplicateKeyException dedupMapper.insert(new DedupRecord(req.getNotifyId(), req.getOrderId())); } catch (DuplicateKeyException e) { // 唯一索引拦截了重复请求直接返回已处理绝不再扣款 return already_handled; } accountService.charge(req.getUserId(), req.getAmount(), req.getOrderId()); return success; } }逐行解释- 第 11 行dedupMapper.insert(...)在notify_id列上有唯一索引数据库层保证「同一个 notifyId 只能插进去一次」。- 第 13 行catch (DuplicateKeyException e)捕获到重复插入异常说明这条通知之前处理过直接返回already_handled不再走扣款。- 整个判断下沉到数据库不依赖应用内存比「先查再插」更稳——后者在并发下两个请求可能都查到「没有」、都插唯一索引才是真正的兜底。去重表的边界它防的是「同一 notifyId 重复」但挡不住「不同 notifyId、同一笔业务」的重复比如网关换了通知 ID 重试。所以它通常要配合「业务单号唯一索引」或状态机一起用。方案三状态机——让「已处理」成为不可逆状态最稳的一层是把订单/履约单做成状态机只有「待处理」能转到「已处理」重复回调撞上「已处理」直接忽略。Transactional public String fulfill(long orderId, long userId, int amount) { Order order orderMapper.selectForUpdate(orderId); // 加行锁防并发两个事务同时改 if (order.getStatus() ! OrderStatus.PENDING) { return already_ order.getStatus(); // 非待处理态直接返回不重复扣款 } order.setStatus(OrderStatus.PROCESSING); // 先改状态再扣款缩小「已扣但状态没改」的窗口 orderMapper.update(order); accountService.charge(userId, amount, orderId); order.setStatus(OrderStatus.DONE); orderMapper.update(order); return success; }逐行解释- 第 3 行selectForUpdate给这行订单加数据库行锁保证同一时刻只有一个事务能改它避免两个并发回调同时读到PENDING。- 第 4 行判断如果状态不是PENDING说明已经处理过或正在处理直接返回不扣款。- 第 6 行「先改状态再扣款」是刻意的顺序——如果先扣款再改状态中间进程崩溃会导致「钱扣了但状态没动」下次回调又扣一次。三套方案怎么选一张对比表方案去重依据适合场景失效边界Token一次性 token需调用方配合内部服务间、表单重复提交调用方不配合取 token 时直接失效去重表notifyId 唯一索引第三方回调、MQ 消费不同 ID 同业务时漏防状态机业务单号状态流转核心资金、订单状态分支设计遗漏时退化我们线上最终是「去重表 状态机」双保险去重表挡掉绝大多数重复通知状态机兜住资金安全。复盘我们交的学费那 37 笔多扣平均发现延迟 6.5 小时因为有 11 笔是用户自己发现的——体验损失比金额更难补。事故当周我们给所有资金类接口加了去重表平均每个接口改动 23 行半天搞完远没有想象中重。Token 方案只在 2 个内部调用链上用因为第三方都不配合取 token。我的取舍别只靠一种我不建议只用 Token 或只用去重表就以为安全了。Token 在第三方回调面前直接失效去重表挡不住「换 ID 重试」。我更倾向于资金链路用「去重表 状态机」双层非资金的内部链路用 Token 就够了。状态机是唯一能在进程崩溃后还能自纠的方案但它要求你一开始就设计好状态流转后期补代价大——所以核心链路的状态机应该在第一次写接口时就画出来而不是事故后补。思考题你现在的项目里支付/扣款类接口用的是哪种幂等如果支付网关把 notifyId 换成了新 ID 重试业务单号不变你现在的方案挡得住吗