接口幂等性怎么保证

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

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

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

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

java 复制代码
// ❌ 锁在事务内部释放
@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 更新被行锁挡住了;副作用跑了两遍。这就是"订单状态更新成功了,可重复业务还是执行了"的完整解释。

修法有两处,缺一不可。

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

java 复制代码
// ✅ 锁包住整个事务,提交完成后才释放
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 再判断。

sql 复制代码
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 + 状态机 CAS orders 表条件更新
发优惠券 order_id + coupon_template_id user_coupon.uk_order_tpl
加积分 order_id + point_type point_log.uk_order_type
sql 复制代码
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 一遍,几分钟内就能圈定影响面:

    sql 复制代码
    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 说话。

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

相关推荐
YHL1 小时前
🚀 NestJS 后端开发实战:从设计模式到 CRUD 全栈
后端
风中的小熊生气1 小时前
Spring Boot 面试知识(一):从启动流程到 AOP 与事务失效
spring boot·后端·面试
神秘的猪头1 小时前
Gin 项目为什么要分 Handler、Service、Repository?顺便讲透接口、依赖注入与指针
后端·go
程序员cxuan1 小时前
DeepSeek V4.1 Flash 正式发布!
人工智能·后端·程序员
特立独行的猫A1 小时前
AtomMQTT Broker — 用 Rust 实现的轻量级高性能 MQTT 消息代理
前端·后端·架构
the局外人1 小时前
学习 FastAPI 的 Day 3:企业级目录与数据库迁移
后端·python·fastapi
mldong1 小时前
AI Agent 不能自己签字:用 Python 工作流引擎给 AI 加一道人类审批闸门
后端·python·agent
她的男孩1 小时前
多租户隔离怎么落地?拆完这1600行Starter源码,我把5个坑全踩明白了
java·后端·架构
宫水三叶的刷题日记1 小时前
铁打的影视飓风,流水的新 iPhone
后端