接口幂等性设计:从原理到落地,一篇讲透

一个真实的线上事故

上个月,我们线上的支付回调接口出了一个 P1 事故。

原因是这样的:第三方支付平台的回调通知机制是"如果没收到 200 响应,就间隔重试"。有一次我们的服务在处理回调时,业务逻辑执行成功了(订单状态已更新、库存已扣减),但在返回响应前发生了 GC 停顿,第三方支付平台没收到响应,于是 30 秒后重发了一次回调。

第二次回调到达时,接口没有做幂等校验,又执行了一遍业务逻辑------库存被重复扣减,用户被多扣了一次钱。

事后排查,光对账和退款就花了两天。

这个事故的根因很简单:接口不具备幂等性。


什么是幂等性

幂等(Idempotent)这个概念来自数学,定义是:对同一个操作执行一次和执行多次,产生的结果相同。

映射到接口设计中:同一个请求,无论被服务端处理一次还是多次,对系统状态的影响是一致的。

用伪代码表达:

scss 复制代码
// 幂等的接口
process(request)  →  系统状态变为 S
process(request)  →  系统状态仍为 S(不会因为重复调用而改变)

// 非幂等的接口
process(request)  →  系统状态变为 S1
process(request)  →  系统状态变为 S2(S2 ≠ S1,状态被重复修改)

哪些场景必须做幂等

不是所有接口都需要幂等。以下场景是幂等设计的重灾区:

  • 支付回调:第三方支付平台可能重复推送同一笔支付成功的通知;
  • 消息队列消费:MQ 的 at-least-once 语义保证消息至少被消费一次,但不保证只消费一次;
  • 用户重复提交:用户快速双击"提交"按钮、网络超时后用户手动重试;
  • 接口超时重试:调用方在超时后自动重试,但服务端其实已经处理成功;
  • 分布式事务补偿:补偿操作可能被多次触发。

简单来说,只要存在"同一个请求可能被执行多次"的可能性,接口就必须具备幂等性。


幂等 vs 防重:两个容易混淆的概念

在讨论方案之前,需要先厘清两个经常被混用的概念:

维度 幂等(Idempotent) 防重(Deduplication)
关注点 多次执行结果一致 阻止重复执行
实现层面 服务端保证 可在客户端或服务端
典型手段 状态机、唯一约束 Token 机制、按钮置灰
可靠性 高(服务端兜底) 低(前端可被绕过)

防重是优化用户体验的手段,幂等是保障数据正确的底线。 前端按钮置灰、loading 状态可以防止用户重复点击,但这只是防重,不是幂等------恶意请求或网络重试仍然可以绕过前端限制。

真正可靠的幂等必须在服务端实现。


方案一:数据库唯一约束

原理

利用数据库的唯一索引(Unique Index)来保证同一条业务数据不会被重复插入。

sql 复制代码
-- 以支付回调为例,用第三方支付流水号作为唯一键
CREATE TABLE payment_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    trade_no VARCHAR(64) NOT NULL,       -- 第三方支付流水号
    order_id BIGINT NOT NULL,
    amount DECIMAL(10,2) NOT NULL,
    status TINYINT NOT NULL,
    UNIQUE KEY uk_trade_no (trade_no)    -- 唯一约束
);

当重复的回调到达时,第二次插入会因为 trade_no 重复而触发唯一约束冲突,数据库层面直接拒绝。

代码实现
csharp 复制代码
@Transactional
public void handlePayCallback(PayCallbackRequest request) {
    try {
        PaymentRecord record = new PaymentRecord();
        record.setTradeNo(request.getTradeNo());
        record.setOrderId(request.getOrderId());
        record.setAmount(request.getAmount());
        record.setStatus(1);
        
        paymentRecordMapper.insert(record);
        
        // 插入成功 = 首次处理,执行业务逻辑
        orderService.completeOrder(request.getOrderId());
        
    } catch (DuplicateKeyException e) {
        // 唯一约束冲突 = 重复请求,直接返回成功
        log.info("重复回调,tradeNo={}", request.getTradeNo());
    }
}
适用场景
  • 业务天然有唯一标识(如支付流水号、订单号);
  • 幂等逻辑可以通过"插入一条记录"来表达;
  • 并发量不极端高的场景。
局限性
  • 只适用于"插入"类操作,对于"更新"类操作(如状态变更),唯一约束无法直接保证幂等;
  • 高并发下大量重复请求会导致大量唯一约束冲突,数据库压力增大。

方案二:状态机 + 乐观锁

原理

对于"更新"类操作,通过状态机约束状态流转方向,配合乐观锁(版本号)保证每次状态变更只被执行一次。

sql 复制代码
-- 订单表,带版本号
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    status TINYINT NOT NULL,         -- 0:待支付 1:已支付 2:已发货 3:已完成
    version INT NOT NULL DEFAULT 0,  -- 乐观锁版本号
    updated_at DATETIME NOT NULL
);

状态流转规则:待支付(0) → 已支付(1) → 已发货(2) → 已完成(3),不允许逆向流转。

代码实现
java 复制代码
@Transactional
public void handlePayCallback(Long orderId) {
    // 1. 查询当前订单
    Order order = orderMapper.selectById(orderId);
    
    // 2. 状态机校验:只有"待支付"状态才能处理支付回调
    if (order.getStatus() != OrderStatus.PENDING) {
        log.info("订单状态不是待支付,跳过处理,orderId={}, status={}", 
                 orderId, order.getStatus());
        return;  // 重复请求,直接返回
    }
    
    // 3. 乐观锁更新:带上版本号条件
    int rows = orderMapper.updateStatus(
        orderId, 
        OrderStatus.PAID,        // 目标状态
        OrderStatus.PENDING,     // 前置状态
        order.getVersion()       // 当前版本号
    );
    
    // 4. 更新成功 = 首次处理;更新失败 = 并发冲突或重复请求
    if (rows == 0) {
        log.info("乐观锁冲突或状态已变更,orderId={}", orderId);
        return;
    }
    
    // 5. 执行后续业务逻辑
    inventoryService.deduct(orderId);
    notificationService.sendPaySuccess(orderId);
}

对应的 SQL:

ini 复制代码
UPDATE orders 
SET status = #{targetStatus}, 
    version = version + 1,
    updated_at = NOW()
WHERE id = #{orderId} 
  AND status = #{expectedStatus}   -- 状态机约束
  AND version = #{version}         -- 乐观锁约束
适用场景
  • 业务有明确的状态流转(如订单状态、审批流程);
  • 需要防止并发修改同一资源;
  • "更新"类操作的幂等保证。
局限性
  • 需要业务本身具备可定义的状态机;
  • 高并发下乐观锁冲突率较高时,需要配合重试机制。

方案三:Token 机制(防重令牌)

原理

服务端在请求执行前先生成一个唯一 Token 下发给客户端,客户端在提交请求时携带该 Token。服务端通过 Redis 的原子操作校验 Token 是否已被使用:

markdown 复制代码
1. 客户端请求 Token  →  服务端生成 Token 写入 Redis  →  返回 Token
2. 客户端携带 Token 提交请求  →  服务端用 Redis DEL 原子删除 Token
   → 删除成功 = 首次请求,执行业务
   → 删除失败 = Token 已不存在,重复请求,拒绝
代码实现
less 复制代码
// 生成 Token
@GetMapping("/token")
public String generateToken() {
    String token = UUID.randomUUID().toString().replace("-", "");
    // 设置 30 分钟过期
    redisTemplate.opsForValue().set(
        "idempotent:token:" + token, "1", 30, TimeUnit.MINUTES
    );
    return token;
}

// 业务接口
@PostMapping("/order/create")
public Result createOrder(@RequestBody OrderRequest request,
                          @RequestHeader("X-Idempotent-Token") String token) {
    // 原子删除 Token:如果 key 存在则删除并返回 true,不存在则返回 false
    Boolean deleted = redisTemplate.delete("idempotent:token:" + token);
    
    if (Boolean.FALSE.equals(deleted)) {
        // Token 不存在或已被使用 → 重复请求
        return Result.fail("请勿重复提交");
    }
    
    // Token 首次使用 → 执行业务逻辑
    return orderService.createOrder(request);
}
适用场景
  • 用户主动提交的表单类操作(如创建订单、提交审批);
  • 需要防止用户重复点击提交的场景;
  • 请求没有天然的业务唯一标识时。
局限性
  • 需要客户端配合,先获取 Token 再提交请求,增加了交互步骤;
  • Token 有过期时间,需要合理设置;
  • 对于服务端之间的调用(如 MQ 消费、回调通知),Token 机制不太适用。

方案四:基于业务唯一键的去重表

原理

创建一张独立的去重表,用业务唯一标识作为主键或唯一键。每次处理请求前先尝试插入去重记录,利用唯一约束判断是否为重复请求:

sql 复制代码
CREATE TABLE idempotent_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    biz_type VARCHAR(32) NOT NULL,     -- 业务类型
    biz_key VARCHAR(128) NOT NULL,     -- 业务唯一键
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_biz (biz_type, biz_key)
);
代码实现
csharp 复制代码
@Transactional
public void handleMessage(Message msg) {
    String bizKey = msg.getMsgId();  // 消息ID作为业务唯一键
    
    try {
        // 尝试插入去重记录
        IdempotentRecord record = new IdempotentRecord();
        record.setBizType("ORDER_PAY_CALLBACK");
        record.setBizKey(bizKey);
        idempotentRecordMapper.insert(record);
        
    } catch (DuplicateKeyException e) {
        // 重复消息,直接返回
        log.info("重复消息,bizKey={}", bizKey);
        return;
    }
    
    // 首次处理,执行业务逻辑
    processPayCallback(msg);
}
适用场景
  • MQ 消费场景,消息 ID 天然作为去重键;
  • 多种业务类型共用一套幂等机制;
  • 需要长期保留幂等记录用于审计。
局限性
  • 去重表会持续增长,需要定期清理过期数据;
  • 每次请求都多一次数据库写入,有一定性能开销。

方案五:分布式锁

原理

利用 Redis 分布式锁(如 Redisson)对同一业务资源加锁,确保同一时刻只有一个请求能执行临界区逻辑:

csharp 复制代码
public void handlePayCallback(String tradeNo) {
    String lockKey = "lock:pay:callback:" + tradeNo;
    RLock lock = redissonClient.getLock(lockKey);
    
    try {
        // 尝试加锁,等待 0 秒,锁定 30 秒自动释放
        if (lock.tryLock(0, 30, TimeUnit.SECONDS)) {
            try {
                // 加锁成功,检查是否已处理
                if (isAlreadyProcessed(tradeNo)) {
                    log.info("已处理过,tradeNo={}", tradeNo);
                    return;
                }
                
                // 执行业务逻辑
                processPayCallback(tradeNo);
                
                // 标记为已处理
                markAsProcessed(tradeNo);
                
            } finally {
                lock.unlock();
            }
        } else {
            // 未获取到锁,说明有相同请求正在处理
            log.info("获取锁失败,可能有重复请求正在处理,tradeNo={}", tradeNo);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}
适用场景
  • 需要严格控制并发执行的场景;
  • 业务逻辑复杂,无法通过简单的唯一约束或状态机保证幂等;
  • 作为其他方案的补充手段。
局限性
  • 分布式锁本身有性能开销;
  • 锁的超时时间需要合理设置,太短可能导致业务未执行完锁就释放,太长可能导致故障时锁无法释放;
  • 单独使用分布式锁不能保证幂等(锁释放后重复请求仍可执行),需要配合"是否已处理"的判断逻辑。

方案选型决策指南

arduino 复制代码
你的请求有天然的业务唯一标识吗?(如支付流水号、消息ID)
│
├── 有
│   ├── 操作是"插入"类 → 方案一:数据库唯一约束
│   ├── 操作是"更新"类,有状态流转 → 方案二:状态机 + 乐观锁
│   └── 需要长期保留去重记录 → 方案四:去重表
│
└── 没有
    ├── 是用户主动提交 → 方案三:Token 机制
    └── 是服务端调用(回调/MQ消费) → 方案五:分布式锁 + 处理标记

实际工程中,往往不是只用一种方案,而是组合使用。例如:

  • 状态机 + 唯一约束:状态机保证流转正确性,唯一约束兜底防重;
  • 分布式锁 + 去重表:分布式锁控制并发,去重表保证最终幂等;
  • Token + 唯一约束:Token 防前端重复提交,唯一约束防服务端重复处理。

几个容易踩的坑

1. 不要用 SELECT + INSERT 代替唯一约束

ini 复制代码
// 错误示范:存在并发漏洞
PaymentRecord existing = mapper.selectByTradeNo(tradeNo);
if (existing == null) {
    mapper.insert(newRecord);  // 两个线程可能同时通过 null 检查
}

在高并发下,两个线程可能同时执行 SELECT 都返回 null,然后都执行 INSERT,导致重复插入。必须依赖数据库的唯一约束来保证原子性。

2. 幂等校验必须在业务逻辑之前

kotlin 复制代码
// 错误:先执行业务再校验幂等
orderService.completeOrder(orderId);
if (isDuplicate(request)) { return; }  // 太晚了,业务已经执行

// 正确:先校验幂等再执行业务
if (isDuplicate(request)) { return; }
orderService.completeOrder(orderId);

3. 注意幂等记录的过期与清理

去重表、Redis 中的幂等标记都不能无限增长。需要根据业务特点设置合理的过期时间------例如支付回调的去重记录保留 7 天即可(第三方支付平台的重试不会超过 24 小时)。

4. 幂等不等于"返回相同响应"

幂等保证的是"系统状态不变",不要求返回完全相同的响应。例如第一次请求返回"创建成功",重复请求返回"已处理",两者响应不同,但系统状态是一致的------这仍然是幂等的。


小结

接口幂等性设计的核心思路可以总结为一句话:找到一种方式,让服务端能够识别"这是同一个请求的第 N 次到达",并对第 2 次及以后的到达做无害化处理。

具体的技术手段------唯一约束、状态机、Token、去重表、分布式锁------都是这个思路的不同实现。选哪种,取决于你的业务场景、数据特征和性能要求。

但有一条原则是通用的:不要信任调用方会只发一次请求。 无论是第三方平台的回调重试、MQ 的消息重投、还是用户的手抖双击,重复请求是分布式系统中必然会出现的情况。幂等设计不是"锦上添花",而是"必备防线"。

相关推荐
柚yuzumi17 分钟前
别再猜 this:先看它属于谁,再看它指向谁
前端·javascript
YIAN20 分钟前
React + Zustand + JWT 前端权限体系完整实现:从登录鉴权到路由守卫全流程拆解
前端·react.js·架构
汉堡大王952724 分钟前
面试必考:手写代码 new 做了什么?从原理到实现全解析
前端·javascript·面试
BillKu1 小时前
TypeScript中,字符串字面量联合类型(Union Type)、enum的用法说明
前端·javascript·typescript
jayson.h1 小时前
PDF 合并+添加页码 相关库、类、函数
开发语言·前端·python
慧一居士2 小时前
Sass和Less功能、使用场景、用法对比
前端·css·less·sass
leoZ2312 小时前
AI+前端提效-09 AI赋能前端测试:单元测试、E2E测试自动生成,提升覆盖率
前端·人工智能·opencv·目标检测·数据挖掘·单元测试·语音识别
计算机魔术师2 小时前
OpenAI 评定 Astra 达到网络安全 Critical 能力阈值,将受限发布
前端
JavaGuide2 小时前
SpaceX 工程师的 AI Coding 玩法太牛了, 200 多个 Agent 并行!
前端·后端