接口幂等性怎么保证

12 阅读8分钟

锁在工作,唯一索引在工作,订单状态也确实只流转了一次。券还是发了两遍,积分还是加了两遍。

一个五年后端在面试里被问"接口幂等怎么保证",答的是"唯一主键约束 + 前端传幂等号 + 分布式锁"——挑不出错的标准答案。面试官随后甩出一个真实支付事故:两次成功回调间隔几十毫秒,订单状态只更新一次,优惠券和积分各落了两遍,财务对不上账。

病根不在方案选型。是锁释放的时机早于事务提交,叠加下游副作用(发券、加积分)没有自己粒度的幂等键。幂等从来不是"在某处加把锁",它是一条从接入层贯穿到 DB 的时序契约。

sequenceDiagram
    participant P as 支付平台
    participant A as 回调实例A
    participant B as 回调实例B
    participant R as Redis 锁
    participant DB as MySQL

    P->>A: 第1次成功回调
    A->>R: SETNX lock:order:1001
    R-->>A: OK
    A->>DB: BEGIN
    A->>DB: UPDATE orders SET status=PAID WHERE id=1001 AND status=PENDING
    DB-->>A: 1 row affected
    A->>DB: 发券 / 加积分
    A->>R: DEL lock:order:1001 (finally)
    Note over A,DB: ⚠️ 事务还没 COMMIT
    P->>B: 第2次成功回调(间隔 40ms)
    B->>R: SETNX lock:order:1001
    R-->>B: OK(锁已释放)
    B->>DB: SELECT status WHERE id=1001
    DB-->>B: PENDING(A 的事务未提交,不可见)
    B->>DB: 校验通过 → 再次发券 / 再加积分

这张图就是"幽灵"本尊。锁在生效,唯一索引在生效,状态也确实只翻转了一次,第二笔请求照样把两个副作用完整跑了一遍。

标准答案只覆盖了一种场景

面试官的问题是"接口幂等性怎么保证"。候选人的回答没错,但它只覆盖了并发同时到达这一种形态。线上真正跑出来的重复请求比这野得多:

  • 第三方支付平台的重试推送,间隔几十毫秒到几秒——不是并发,是串行错位
  • 网关层超时重放,客户端没收到响应就重发
  • MQ 的 at-least-once 投递,同一条消息消费两次

面试官继续往下压:锁加了、唯一索引建了、订单状态也更新成功了,重复业务为什么还能执行?候选人猜"锁没生效"。这是最典型的错误归因方向——事故现场里,锁恰恰是生效的。

问题出在时序,不在组件。

根因:finally 里的 unlock 抢在 commit 前面

看这段代码。它几乎是每个人第一次写支付回调时都会写出来的形状:

// ❌ 锁在事务内部释放
@Transactional(rollbackFor = Exception.class)
public void onPaySuccess(PayCallbackDto dto) {
    RLock lock = redisson.getLock("lock:order:" + dto.getOrderId());
    lock.lock();
    try {
        Order order = orderMapper.selectById(dto.getOrderId());
        if (order.getStatus() != OrderStatus.PENDING) {
            return;                                 // 检查点 1:内存态判断
        }

        orderMapper.updateStatus(dto.getOrderId(), OrderStatus.PAID);  // 不关心 affected rows

        couponService.issue(dto.getOrderId());       // 副作用 A
        pointService.add(dto.getOrderId(), 100);     // 副作用 B
    } finally {
        lock.unlock();   // ← 这里执行完,方法才返回,代理才去 commit
    }
}

Spring 的事务代理包在方法外面:tx.begin() → 执行业务方法体(含 finally 块)→ tx.commit()。所以 lock.unlock() 铁定发生在 commit 之前,中间空出一个几十毫秒的窗口。

第二笔回调钻的就是这个窗口:

  1. 拿到了已经释放的锁
  2. selectById 读到的是已提交数据——A 的事务还没提交,状态仍然是 PENDING
  3. 检查点 1 通过,进入业务逻辑
  4. UPDATE ... WHERE status = PENDING 被 A 持有的行锁挡住,等 A 提交后返回 0 行受影响
  5. 代码没校验受影响行数,继续往下发券、加积分

订单状态只变了一次,因为第二步的 CAS 更新被行锁挡住了;副作用跑了两遍。这就是"订单状态更新成功了,可重复业务还是执行了"的完整解释。

修法有两处,缺一不可。

第一,把锁挪到事务外面,让提交先于解锁:

// ✅ 锁包住整个事务,提交完成后才释放
public void onPaySuccess(PayCallbackDto dto) {
    RLock lock = redisson.getLock("lock:pay:" + dto.getPayNo());
    lock.lock();
    try {
        transactionTemplate.execute(status -> doHandle(dto));
    } finally {
        lock.unlock();
    }
}

private Boolean doHandle(PayCallbackDto dto) {
    int affected = orderMapper.casToPaid(dto.getOrderId());  // UPDATE ... AND status = PENDING
    if (affected == 0) {
        log.info("订单已流转,忽略重复回调, payNo={}", dto.getPayNo());
        return false;
    }
    couponService.issue(dto.getOrderId());
    pointService.add(dto.getOrderId(), 100);
    return true;
}

第二,状态流转必须用 CAS 并校验 affected rows,而不是先 select 再判断。

UPDATE orders
   SET status = 'PAID', paid_at = NOW()
 WHERE id = 1001
   AND status = 'PENDING';
-- affected = 0 → 说明状态已经流转过,直接短路返回

select 后判断是"读-判断-写",中间有 gap;UPDATE ... WHERE status = 旧值 把判断下沉到数据库的行锁里,天然原子。两者在单机低并发下表现一模一样,在分布式并发下差一个资损。

第二个幽灵:幂等键的粒度对不上

时序修好了,还有一层问题:幂等只做在订单状态上,副作用各自没有幂等键

订单状态用 order_id 做幂等,那发券呢?加积分呢?它们如果位于另一个事务、另一段代码,共用同一个幂等键,或者干脆没有幂等键——只要链路里任何一处被绕过,副作用就会重复落库。

正确做法是按业务动作拆开幂等键,每个副作用在自己的落库点建唯一约束:

业务动作幂等键落点
支付回调受理pay_no(支付平台流水号)pay_callback_log.uk_pay_no
订单状态流转order_id + 状态机 CASorders 表条件更新
发优惠券order_id + coupon_template_iduser_coupon.uk_order_tpl
加积分order_id + point_typepoint_log.uk_order_type
CREATE TABLE pay_callback_log (
  id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  pay_no     VARCHAR(64)     NOT NULL COMMENT '支付平台流水号',
  order_id   BIGINT UNSIGNED NOT NULL,
  raw_body   JSON            NOT NULL,
  created_at DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_pay_no (pay_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

锁的 key 也得跟着换:用 pay_no,不用 order_id。同一个订单可能先走微信支付失败、再走支付宝成功,两张流水号落在同一个 order_id 上。拿订单号当锁键,会把两笔彼此独立的支付事件错误地串行化。

状态机:把不可逆写进代码

订单状态流转必须有明确的合法边。前置状态对不上,直接拒绝,不依赖调用方传什么参数。

stateDiagram-v2
    [*] --> PENDING: 下单
    PENDING --> PAID: 支付成功回调(唯一合法入口)
    PENDING --> CLOSED: 超时关单
    PAID --> REFUNDING: 发起退款
    REFUNDING --> REFUNDED: 退款成功
    PAID --> PAID: 重复回调必须在此被拒绝
    CLOSED --> [*]
    REFUNDED --> [*]

状态不可逆,是从根源上锁死重复执行最便宜的手段。代价是每加一个状态都得想清楚边从哪来、到哪去。支付链路上,这点设计成本远比资损便宜。

三层防御,不是三层装饰

视频里那个面试官最后总结的三层防御,我按自己的理解重画了一遍:

flowchart LR
    A[支付平台/客户端] --> B{接入层<br/>幂等号拦截}
    B -->|命中缓存| C[直接返回首次结果]
    B -->|未命中| D[MQ 削峰 + 消费端去重]
    D --> E{业务层<br/>状态机前置校验}
    E -->|前置状态非法| F[拒绝并记录]
    E -->|合法| G[DB 唯一约束 + CAS 条件更新]
    G --> H[扣库存 / 发券 / 加积分]
    H --> I[事务 COMMIT]
    I --> J[释放分布式锁]
    I --> K[定时幂等巡检对账]

第一层:业务分级。 资金强相关的操作(支付、库存扣减)用 DB 唯一约束 + 状态机双重校验;非核心操作(消息通知、日志记录)允许短暂重复,靠消费端去重兜底。别给所有接口套同一个模板,它们的成本根本不是一个量级。

第二层:时序防护。 先加锁、再开事务、提交完再释放。这条规则说起来一句话,写错的人一抓一大把——因为 @Transactional 和业务代码挂在同一个方法上时,光看代码根本意识不到 finally 跑在 commit 前面。

第三层:架构层。 接入层先做一次幂等拦截,相同幂等号的请求在网关直接返回缓存结果,落不到业务层。大促场景开强校验模式,全链路强制走 DB 唯一约束兜底——宁可牺牲一点性能,也不允许资金类业务重复执行。

几个方案的边界,值得单独摆出来看:

方案挡得住挡不住
前端幂等号 + Redis SETNX用户重复点击、网关重放第三方平台自己重推、Redis 击穿
分布式锁并发同时到达时间错位的重复请求、锁先于事务释放
DB 唯一索引已落库的重复记录业务代码不写这条记录就白搭
状态机 CAS + 校验 affected rows状态已流转的重复请求不依赖状态的副作用(发券/积分)

没有银弹,所以是"三层",不是"一层"。

出事了:先止血,再找根因

面试官问"线上出现资损怎么快速止损和排查",候选人卡住了。这题的姿势是先止血,再定位,顺序反了,损失会持续扩大:

  1. 入口降级。 回调接口先切成"只落盘不处理",把 pay_no 和原始报文写进 pay_callback_log,业务处理转异步补偿。重复请求在这一步就被唯一索引挡住。
  2. 拉重复数据。 按业务维度 group 一遍,几分钟内就能圈定影响面:
    SELECT order_id, COUNT(*) AS cnt
      FROM user_coupon
     WHERE created_at >= '2025-08-14 00:00:00'
     GROUP BY order_id
    HAVING cnt > 1;
    
  3. 补偿要留痕,不要 DELETE。 重复发放的券做冻结、积分做负数冲正,每条补偿都落审计日志。直接物理删除,T+1 对账会把你对到怀疑人生。
  4. 跟支付平台对账。pay_no 全量比对当日流水,确认没有漏单和多单。
  5. 上巡检。 定时核对核心业务的操作次数与订单数量,发现重复自动告警,而不是等财务来问。

写在最后

幂等不是"在某个方法上加把锁",而是一条从网关到 DB 的时序契约——锁必须在事务提交之后释放,每个副作用必须有自己粒度的幂等键,状态流转必须靠 CAS 的 affected rows 说话。

普通开发和高级工程师的分水岭就在这:前者以为加了唯一索引就搞定了幂等,后者知道在分布式并发下,一个几十毫秒的时序缝隙就能换来真金白银的资损。