接口幂等设计实战:从一次重复扣款事故说起

支付系统出过一次后背发凉的事故:用户被同一笔订单扣了两次款。根因是前端网络抖动时重试了提交请求,而下单接口压根没做幂等。一周治理:理清哪些写接口必须幂等、补数据库唯一索引兜底、幂等号前置查重、token 机制防表单重复提交、状态机幂等。同一请求并发重放 200 次只落 1 笔订单。

2024 年我们的支付系统出过一次让人后背发凉的事故:有用户被同一笔订单扣了两次款。复盘下来,根因是前端在网络抖动时重试了提交请求,而我们的下单接口压根没有做幂等控制 —— 同一个请求打进来两次,就实打实创建了两笔订单、扣了两次钱。投了一周给核心写接口补幂等,本文复盘接口幂等设计的完整实战。

问题背景

业务:支付下单,Spring Boot + MySQL,日均下单 200 万笔
事故现象:
- 用户投诉:一笔订单被扣了两次款
- 对账发现当天有 37 笔订单出现"一单两扣"
- 都集中在网络质量差的移动端用户

现场排查:
# 1. 翻接口访问日志,定位到一笔重复订单
14:22:01.310  POST /api/order/create  userId=88123  traceId=A  -> 200
14:22:04.880  POST /api/order/create  userId=88123  traceId=B  -> 200
# 两个请求,body 完全一样,间隔 3.5 秒,各自建了一笔订单

# 2. 前端代码:提交按钮点击后,3 秒无响应会自动重试
# 3. 那次后端 RT 抖到 4 秒 -> 前端以为失败 -> 自动重发
#    但第一次请求其实成功了 -> 两笔订单都落库

根因:
1. 下单这个"写接口"完全没有幂等控制
2. 任何重试(前端重试、网关重试、用户狂点)都会重复下单
3. 没有唯一约束兜底,数据库层面也拦不住
4. 团队对"哪些接口必须幂等"没有统一认知和规范

修复 1:先分清哪些接口需要幂等

幂等(Idempotent):同一个请求执行一次和执行多次,
                   对系统产生的影响完全相同。

=== 按 HTTP 方法的天然语义 ===
GET     天然幂等   —— 查询,查多少次结果都一样
PUT     天然幂等   —— 全量覆盖,覆盖几次结果一样
DELETE  天然幂等   —— 删除,删第二次还是"不存在"
POST    天然不幂等 —— 每次都"新建",最危险,必须重点防

=== 真正要关心的:写操作 ===
必须做幂等:
- 创建订单、发起支付、扣款、退款
- 账户加减余额、积分变动
- 发券、库存扣减
- 发送短信/通知(防重复轰炸)

可以不做(或天然幂等):
- 纯查询接口
- 按主键的全量更新(UPDATE ... SET ... WHERE id=?)
- 状态机的"幂等转移"(已支付再支付,直接返回成功)

=== 一个认知误区 ===
"重试不就是网络问题吗,概率很低" —— 错。
重试无处不在:前端重试、网关/LB 重试、RPC 框架重试、
MQ 消息重投、用户手抖狂点、定时任务重跑。
只要接口对外,就要假设它一定会被重复调用。

修复 2:方案一 —— 数据库唯一索引(最硬的兜底)

-- === 思路:让数据库帮你拦重复,这是最后一道防线 ===
-- 给"业务上不允许重复"的字段加唯一索引

-- 订单表:同一个幂等号只能有一条订单
ALTER TABLE orders
  ADD COLUMN idempotent_key VARCHAR(64) NOT NULL COMMENT '幂等号',
  ADD UNIQUE KEY uk_idempotent (idempotent_key);

-- 支付流水表:同一笔订单 + 同一种支付动作只能有一条
ALTER TABLE payment_flow
  ADD UNIQUE KEY uk_order_action (order_id, action_type);

-- 账户变动流水:同一个业务单号只能记一次账
ALTER TABLE account_flow
  ADD UNIQUE KEY uk_biz_no (biz_no);
-- 唯一索引的好处:无论上层逻辑多么混乱,只要重复数据
-- 想落库,数据库直接抛 DuplicateKeyException,从根上挡住。
// === 配合唯一索引的写法:捕获冲突异常,转成"幂等成功" ===
public OrderResult createOrder(OrderRequest req) {
    Order order = buildOrder(req);
    order.setIdempotentKey(req.getIdempotentKey());
    try {
        orderMapper.insert(order);              // 唯一索引保护
        return OrderResult.success(order);
    } catch (DuplicateKeyException e) {
        // 冲突 = 这个请求之前已经成功过了
        // 不是报错,而是把"已存在的那笔"查出来返回 —— 对调用方表现为幂等成功
        Order exist = orderMapper.selectByIdempotentKey(req.getIdempotentKey());
        return OrderResult.success(exist);
    }
}
// 关键:DuplicateKeyException 不该当成失败抛给用户,
// 它恰恰证明"上一次请求成功了",要转成幂等成功响应。

修复 3:方案二 —— 幂等号 + 前置查重

// === 唯一索引是兜底,但更主动的做法是请求进来先查重 ===
// 核心:每个写请求带一个全局唯一的"幂等号"(idempotentKey)

// 幂等号从哪来?—— 由"发起方"生成,不能由服务端生成
// 1. 下单页加载时,前端就向后端要一个 token(下面修复 4)
// 2. 或者由业务天然唯一键拼成:userId + 商品 + 客户端时间窗
// 3. 上游服务调用时,用上游的业务单号做幂等号
// 原则:同一个"业务意图"必须对应同一个幂等号,重试不变

@Transactional
public OrderResult createOrder(OrderRequest req) {
    String key = req.getIdempotentKey();
    if (key == null || key.isEmpty()) {
        throw new BizException("缺少幂等号");
    }

    // 1. 先查:这个幂等号处理过没有
    OrderIdempotent record = idempotentMapper.selectByKey(key);
    if (record != null) {
        // 处理过了,直接返回上次的结果(从 record 里取或回查订单)
        return OrderResult.success(
            orderMapper.selectById(record.getOrderId()));
    }

    // 2. 没处理过:正常下单
    Order order = buildOrder(req);
    orderMapper.insert(order);

    // 3. 落幂等记录(和订单在同一个事务里,要么都成功要么都回滚)
    OrderIdempotent idem = new OrderIdempotent();
    idem.setIdempotentKey(key);
    idem.setOrderId(order.getId());
    idempotentMapper.insert(idem);    // 这张表的 key 字段也有唯一索引

    return OrderResult.success(order);
}
// 注意:"查重 + 插入"之间有并发窗口 —— 两个请求同时查,都查到 null,
// 都往下走。所以幂等表的唯一索引不能少,它兜住并发漏网的那一个。
// 查重负责"快速命中已处理的",唯一索引负责"挡住并发竞争的"。

修复 4:方案三 —— Token 机制(防表单重复提交)

// === 适合"用户狂点提交按钮"这类场景 ===
// 两步:进页面先领 token,提交时带 token 来核销,核销只能成功一次

// 第一步:进入下单页时,发一个一次性 token
@GetMapping("/order/token")
public String getOrderToken() {
    String token = UUID.randomUUID().toString();
    // 存 Redis,带过期时间(下单页停留不会太久)
    redis.opsForValue().set("order:token:" + token, "1", Duration.ofMinutes(10));
    return token;
}

// 第二步:提交下单时校验并"核销"token
@PostMapping("/order/create")
public OrderResult create(@RequestBody OrderRequest req,
                          @RequestHeader("Order-Token") String token) {
    // 用 Lua 保证"判断存在 + 删除"是原子的,只有一个请求能删成功
    String lua =
        "if redis.call('get', KEYS[1]) then " +
        "  return redis.call('del', KEYS[1]) " +
        "else return 0 end";
    Long deleted = redis.execute(
        new DefaultRedisScript<>(lua, Long.class),
        List.of("order:token:" + token));

    if (deleted == null || deleted == 0) {
        // 删失败 = token 不存在或已被别的请求核销 = 重复提交
        throw new BizException("请勿重复提交");
    }
    // 核销成功,继续下单
    return orderService.createOrder(req);
}
// 为什么要用 Lua:如果先 get 再 del 分两步,
// 两个并发请求可能都 get 到 token、都觉得自己有效 -> 又重复了。
// "存在即删"必须是一个原子操作,这是 token 方案的命门。

修复 5:方案四 —— 状态机幂等(最优雅)

// === 很多业务本身就是状态流转,利用状态机天然防重 ===
// 订单状态:待支付 -> 已支付 -> 已发货 -> 已完成
// "支付"这个动作,本质是 待支付 -> 已支付 的一次状态转移

// 错误:不看当前状态,直接改
public void pay(Long orderId) {
    Order o = orderMapper.selectById(orderId);
    o.setStatus(PAID);
    accountService.deduct(o.getAmount());   // 重复调用 -> 重复扣款!
    orderMapper.update(o);
}

// 正确:用带状态条件的 UPDATE,靠"影响行数"判断是否真的转移了
public PayResult pay(Long orderId) {
    // UPDATE ... WHERE status=待支付 —— 只有当前是待支付才会更新成功
    int rows = orderMapper.updateStatusCAS(
        orderId, /*from*/ UNPAID, /*to*/ PAID);

    if (rows == 0) {
        // 影响 0 行 = 订单不是"待支付"状态了(已被支付过)
        // 这是重复支付,直接返回成功,不再扣款
        Order o = orderMapper.selectById(orderId);
        if (o.getStatus() == PAID) {
            return PayResult.success("已支付");   // 幂等成功
        }
        throw new BizException("订单状态异常");
    }
    // rows == 1 = 状态真的从待支付翻成了已支付,这是"第一次",才扣款
    accountService.deduct(orderId);
    return PayResult.success("支付成功");
}
// 对应 SQL:
// UPDATE orders SET status = #{to}, pay_time = NOW()
// WHERE id = #{id} AND status = #{from}
//
// 精髓:把"是否第一次执行"的判断,交给数据库行锁 + WHERE 条件,
// 影响行数 1 就是第一次、0 就是重复,天然并发安全,无需额外加锁。

修复 6:细节与监控

// === 细节 1:幂等不只是"挡住",还要"返回一致的结果" ===
// 重复请求不能简单抛异常,要返回和第一次"一样的成功结果",
// 否则调用方收到错误,可能又触发新一轮重试 -> 死循环。

// === 细节 2:幂等记录要不要存"完整响应" ===
// 简单场景:幂等表存 orderId,重复时回查订单即可
// 复杂场景:把第一次的响应体序列化存下来,重复时原样返回
public OrderResult handle(String key, Supplier action) {
    String cached = redis.opsForValue().get("idem:resp:" + key);
    if (cached != null) {
        return JSON.parseObject(cached, OrderResult.class);  // 原样返回
    }
    OrderResult result = action.get();
    redis.opsForValue().set("idem:resp:" + key,
        JSON.toJSONString(result), Duration.ofHours(24));
    return result;
}

// === 细节 3:幂等记录的过期与清理 ===
// Redis 方案设合理 TTL(覆盖业务重试窗口即可,如 24h)
// DB 幂等表要定期归档,否则无限膨胀
# 幂等相关监控:重复请求是有价值的信号,要可观测
groups:
- name: idempotent
  rules:
  # 1. 命中幂等拦截的请求量(突增说明上游在疯狂重试)
  - alert: IdempotentHitSurge
    expr: increase(idempotent_hit_total[5m]) > 1000
    for: 5m
    annotations:
      summary: "{{ $labels.api }} 幂等拦截 5min 超 1000 次,排查上游重试风暴"

  # 2. 唯一索引冲突异常(兜底被频繁触发 = 前置查重可能有漏洞)
  - alert: DuplicateKeySurge
    expr: increase(duplicate_key_exception_total[5m]) > 100
    for: 5m
    annotations:
      summary: "{{ $labels.table }} 唯一键冲突频发,检查幂等前置逻辑"

  # 3. 缺失幂等号的写请求(说明有调用方没按规范传)
  - alert: MissingIdempotentKey
    expr: increase(idempotent_key_missing_total[10m]) > 0
    annotations:
      summary: "{{ $labels.api }} 出现无幂等号的写请求,排查调用方"

优化效果

指标                      治理前              治理后
=============================================================
一单两扣事故              当天 37 笔           0
写接口幂等覆盖             0%                   核心写接口 100%
前端重试导致的重单         直接重复落库         幂等拦截,返回原结果
并发重复请求               靠运气               唯一索引 100% 兜住
幂等号规范                 无                   写接口强制带,缺失告警
重复请求的响应             报错(诱发再重试)   返回一致成功结果
幂等记录可观测性           完全不可见           命中/冲突全量监控

压测(同一请求并发重放 200 次):
- 治理前:并发重放 -> 创建 200 笔订单
- 治理后:并发重放 -> 仅 1 笔订单落库,其余 199 次返回同一结果

排查与改造:
- 定位重复扣款根因:0.5 天
- 梳理需要幂等的写接口清单(23 个):1 天
- 补唯一索引 + 幂等表:1 天
- token 机制 + 状态机改造:2 天
- 并发重放压测验证:0.5 天

避坑清单

  1. POST 这类"新建"接口天然不幂等,所有核心写接口都要假设会被重复调用
  2. 重试无处不在:前端、网关、RPC 框架、MQ 重投、用户狂点、定时任务重跑
  3. 数据库唯一索引是最硬的兜底,无论上层逻辑多乱都能挡住重复落库
  4. DuplicateKeyException 不是失败,它证明上次成功了,要转成幂等成功
  5. 幂等号必须由发起方生成、重试时保持不变,服务端生成的没有意义
  6. "前置查重 + 唯一索引"配合用:查重快速命中,唯一索引兜并发漏网
  7. token 机制的"判断存在 + 删除"必须用 Lua 原子执行,否则并发还会漏
  8. 状态机幂等最优雅:带状态条件的 UPDATE,靠影响行数判断是否首次
  9. 重复请求要返回与第一次一致的成功结果,报错会诱发新一轮重试
  10. 幂等命中量、唯一键冲突、缺失幂等号都要上监控,重复请求是有价值的信号

总结

这次重复扣款的事故,表面看是前端重试引发的,但本质暴露的是我们对"接口幂等"这件事根本没有体系化的认知。最深刻的一个教训是:不要把重复请求当成小概率的意外,它其实是系统里无处不在的常态 —— 前端会在超时后自动重试,网关和负载均衡会重试,RPC 框架默认就带重试,消息队列保证的是"至少一次"投递所以必然重投,用户在按钮没反应时会反复点,定时任务可能因为部署而重跑。只要一个写接口对外暴露,就必须假设它一定会被重复调用,这不是悲观,而是事实。想清楚这一点后,幂等设计就有了几套清晰的武器:最底层、最硬的一道防线永远是数据库的唯一索引,因为不管上层代码写得多混乱、并发控制有多少漏洞,只要重复数据想落库,数据库就会直接拒绝,这是不依赖任何应用层逻辑的终极兜底;在它之上,是"幂等号 + 前置查重",请求带着一个由发起方生成、重试时保持不变的唯一幂等号进来,服务端先查这个号处理过没有,处理过就直接返回上次的结果;对于用户狂点提交这种场景,token 机制很合适,但要记住它的命门是"判断 token 存在并删除"必须用 Lua 脚本原子完成,否则并发下照样漏;而我个人觉得最优雅的是状态机幂等,很多业务动作本质就是一次状态转移,用一条带 WHERE 状态条件的 UPDATE,靠数据库返回的影响行数来判断这到底是"第一次执行"还是"重复执行",把并发安全直接交给数据库的行锁,既干净又不需要额外的锁。还有一个容易被忽略的细节:幂等的目标不只是"挡住重复执行",更要"对重复请求返回和第一次一致的成功结果",如果你对重复请求返回错误,调用方收到失败可能又会触发新一轮重试,反而陷入死循环。幂等不是某个接口的小技巧,而是一道必须在设计阶段就想清楚、并形成团队规范的工程红线。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

ThreadLocal 内存泄漏:网关服务每三天重启一次的真相

2026-5-20 12:39:25

技术教程

Spring 循环依赖踩坑:加一个 @Async 注解就启动失败的复盘

2026-5-20 12:45:46

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索