CC 咖啡猫的工作空间 Coding Space

接口幂等性实践指南

审校日期:2026-09-05。本篇 Java 片段是设计示意,未在完整 Spring 项目中编译;可运行验证见文末 SQLite 案例。

一、什么是幂等性

幂等(Idempotence)指对同一个接口发起多次相同的请求,其结果与只执行一次相同,不会因重试重复执行同一业务效果;并不要求每次响应码、响应体或访问日志完全相同。

多次 f(x) = 一次 f(x)

请求1: POST /order { "skuId": 1, "qty": 2 } → 创建一个订单 a
请求2: POST /order { "skuId": 1, "qty": 2 } → 不应再创建第二个订单

HTTP 方法的幂等语义

方法 是否幂等 原因
GET 请求语义为读取;服务端仍可能写访问日志
PUT 完整替换,多次覆盖结果一致
DELETE 多次删除同一个资源,结果都是"已删除"
PATCH 不保证 取决于补丁语义;赋固定值可设计为幂等,递增通常不是
POST 每次调用都可能创建新资源

核心关注点:POST 本身不幂等,但业务上必须设计成幂等的场景极多 — 支付、下单、扣库存等。

需要幂等的典型场景

场景 重复请求来源 后果(无幂等)
用户重复点击"提交"按钮 前端未做防抖 创建多条相同订单
客户端超时自动重试 OkHttp/Feign 重试策略 重复扣款/扣库存
MQ 消费端重投 消费失败 + requeue 重复处理同一消息
定时任务回调重入 任务调度器无状态 重复执行同一批次
网关/负载均衡重试 网络抖动 同一请求被多个节点处理

二、前端幂等 vs 后端幂等

前端能做的:UI 层面拦截,降低重复请求概率(用户体验层面)
后端必须做的:数据层面兜底,保证最终一致性(安全底线)

前端幂等 ≠ 后端可以不幂等
前端不是安全边界,任何客户端都是不可信的

前端常见做法(辅助手段,不能作为唯一防线)

  • 按钮置灰/loading:点击后立即禁用按钮,防止短时间内重复点击
  • 防抖节流debounce(合并频繁操作)/ throttle(限制触发频率)
  • 页面重定向:提交成功后 302 跳转到结果页,避免用户刷新表单页
  • Post-Redirect-Get 模式:POST 完成后 redirect 到 GET 页面,浏览器刷新只重放 GET

后端为什么必须做

  • 前端代码可以被绕过(postman/curl 直接调用)
  • 网络层重试前端无法感知(网关/代理层超时自动重试)
  • 跨系统调用方不可控(第三方回调、开放平台对接)

三、后端幂等实现方案

方案对比总览

方案 幂等范围 性能 实现复杂度 适用场景
一次性 Token 入口防重,不保证业务结果 降低重复进入概率
数据库唯一约束 持久化 有天然唯一键的业务
状态机 业务状态内 订单/工单等状态流转
分布式锁 租约有效期内互斥,不独立保证幂等 竞争资源协调
乐观锁(版本号) 检测并发修改,不证明请求已执行 并发更新同一记录

3.1 一次性 Token 只能拦截重复进入

原子消费 Token 能让一个请求通过入口,但不能把 Redis 状态与数据库业务提交变成同一事务。

A 删除 token 成功
A 写数据库之前失败
B 使用相同 token 重试,发现 token 不存在
如果 B 直接返回成功,实际上业务没有完成

缺失 Token 也可能表示过期、处理中或未知状态,不能据此返回成功。捕获异常后立即恢复 Token 也不可靠:数据库可能已提交,只是响应发送失败,恢复后会再次执行。

可靠实现应保存操作身份、请求指纹与结果,并明确状态:

已知状态 重试行为
已完成且指纹相同 返回保存的业务结果
相同键但指纹不同 拒绝复用,向调用方报告冲突
正在处理 等待或返回处理中,不能伪造成功
已确认事务回滚 按约定允许重试
提交结果未知 查询业务记录或对账,先确认结果

3.2 数据库唯一约束(最简单的持久化方案)

原理:用数据库唯一约束标识一次业务操作,将业务写入与幂等结果放在同一事务边界。遇到唯一键冲突时,先确认冲突来自幂等键,再读取并比较原请求,不能把任意唯一键冲突当成重复成功。

-- 订单表增加幂等键字段
ALTER TABLE `order` ADD COLUMN `idempotent_key` VARCHAR(64) NOT NULL;
ALTER TABLE `order` ADD UNIQUE INDEX `uk_idempotent_key` (`idempotent_key`);
事务内:
  尝试插入 (调用方, 操作类型, 幂等键, 请求指纹)
  执行业务写入
  保存结果并提交
重复请求:
  按数据库的冲突处理和隔离规则读取已提交记录
  验证调用方、操作类型与指纹,再返回结果

不要假定所有数据库都能在唯一键异常后继续同一事务;处理方式取决于数据库和驱动。下方的 SQLite 案例使用显式事务与唯一约束,验证一个具体可运行方案。已有订单表的 SQL 仅为字段示意,实际迁移需先处理存量记录与键作用域。

幂等键的生成策略:

策略 示例 适用场景
客户端生成 UUID 550e8400-e29b-... 通用方案,客户端需保证唯一
业务字段组合 order:20240101:userId:001 有明确业务唯一性的场景
上游流水号 pay:alipay:tradeNo:xxx 对接外部系统
请求体哈希 SHA256(JSON.stringify(body)) 内容完全相同的请求

推荐做法:为每次新的业务意图生成独立随机键,在该次操作的全部重试中复用。相同内容可能是两笔合法订单,因此请求体哈希适合做指纹校验,不应单独充当通用业务身份。幂等键应同时受用户或租户、操作类型约束。


3.3 状态机(订单/工单等流程防重)

原理:利用数据库行锁 + 状态前置判断,在事务中检查并执行合法状态转换;是否允许逆向转换由业务定义。

@Service
public class OrderStateService {

    @Transactional
    public void payOrder(Long orderId) {
        // SELECT ... FOR UPDATE 行级锁
        Order order = orderMapper.selectForUpdate(orderId);

        // 状态判断:已支付则直接返回
        if (OrderStatus.PAID.equals(order.getStatus())) {
            log.info("订单已支付,幂等返回, orderId={}", orderId);
            return;
        }

        if (!OrderStatus.PENDING.equals(order.getStatus())) {
            throw new BizException("订单状态异常,无法支付");
        }

        // 更新状态(带版本号/状态条件,双重保险)
        int rows = orderMapper.updateStatus(orderId,
            OrderStatus.PENDING, OrderStatus.PAID);
        if (rows != 1) {
            throw new BizException("订单状态已变更,请刷新重试");
        }

        // 后续扣款/记账等逻辑...
    }
}
<!-- MyBatis 带前置状态的更新 SQL -->
<update id="updateStatus">
    UPDATE `order`
    SET status = #{newStatus}, update_time = NOW()
    WHERE id = #{id} AND status = #{expectedStatus}
</update>

状态机方案的核心WHERE status = expected 条件更新 + 行锁,合起来保证状态流转的原子性。


3.4 Redis 分布式锁解决并发互斥,不独立保证幂等

原理:同一资源同一时刻只有一个线程能获取锁,其他线程等待或返回已有结果。

两个重复请求可以先后获得同一把锁并扣减两次。锁释放后仍需要持久化业务键或状态判断。库存条件更新防止超卖,与“同一订单只扣一次”是两个不同约束。

锁使用方法见 分布式锁实践。固定租约过期不等于业务线程停止,不能把锁作为数据库约束的替代品。


3.5 乐观锁(版本号机制)

// 基于版本号的乐观锁更新
int rows = jdbcTemplate.update(
    "UPDATE account SET balance = balance - ?, version = version + 1 " +
    "WHERE id = ? AND version = ?",
    amount, accountId, currentVersion
);
if (rows != 1) {
    // 版本号冲突只说明状态变化,不能证明本次操作已经成功
    throw new BizException("数据已被修改,请刷新后重试");
}

四、MQ 消息幂等

消息队列的"至少一次投递"语义决定了消费者必须处理重复消息。

4.1 消费端通用方案

方案1(推荐):消息唯一键 + 消费记录表
  - 生产者携带全局唯一 messageId
  - 消费者用 messageId 去重表判断是否已处理
  - 通过数据库唯一约束 + 业务处理在同一事务中保证原子性

方案2:乐观锁 / 状态机
  - 消息体内包含业务版本号/预期状态
  - 消费者用 WHERE version = ? 条件更新

方案3:Redis Setnx
  - 仅作入口抑制,不能独立作为业务已完成的凭据

4.2 先写 Redis 成功标记的失败窗口

SET consumed:event-123 1 NX EX 86400 成功
业务处理失败或进程退出
消息重新投递,消费者看到标记便跳过
结果:消息存在“已处理”标记,业务却没有完成

将消费记录与同库业务更新放在一个事务里,事务提交后再确认消息。提交后确认消息失败会造成重复投递,此时读取已提交结果即可去重。数据库事务不包含外部 HTTP 请求;跨系统副作用需要下游幂等、outbox 或对账补偿。

消息去重键应标识业务事件。同一次业务事件被重新发送时,不能假定中间件分配的消息 ID 总保持不变。Kafka 生产者幂等也不意味着任意消费者写入外部数据库都自动幂等。


五、幂等键的最佳实践

5.1 客户端如何生成幂等键

推荐:由客户端在每次"写操作"前生成唯一幂等键,穿透到后端

前端生成策略:
  const idempotentKey = crypto.randomUUID();
  // 或使用 nanoid / uuid 库

服务端间调用策略:
  String idempotentKey = UUID.randomUUID().toString();
  // 在首次业务调用前持久化,后续重试复用;不要依赖可能变化的 traceId

5.2 幂等键的生命周期

客户端:首次请求生成 → 内存暂存 → 重试时复用 → 成功后丢弃
服务端:首次处理时记录 → 保留时间至少覆盖约定重试窗口,并考虑迟到消息和长期业务唯一性 → 到期清理

5.3 幂等键的存储选型

存储 优势 劣势 适用
Redis 高性能、自带 TTL 不保证持久化 Token 机制、实时防重
MySQL 持久化、强一致 查询开销 需要审计追溯的场景
两者结合 Redis 热数据 + MySQL 持久化 架构复杂 高并发 + 长期追溯

六、生产环境检查清单

设计阶段

  • 所有写操作(POST/PATCH/DELETE)是否都已评估幂等需求?
  • 幂等键的生成规则是否文档化,明确由调用方还是服务端生成?
  • 是否选定了幂等方案的存储(Redis / MySQL / 两者结合)?
  • MQ 消费者是否考虑了消息去重?

实现阶段

  • 幂等记录与业务写入是否在同一事务内?Redis Lua 不包含数据库事务。
  • 并发场景下幂等是否仍然生效(两个相同请求同时到达)?
  • 失败是否区分已回滚、已提交和提交结果未知?
  • 幂等返回时,是否携带了合理的结果(上次处理成功的返回值)?

测试阶段

  • 模拟网络层重试(同一请求发送 2 次间隔 < 100ms)是否只生效一次?
  • 模拟 MQ 重复投递(同一消息消费 3 次)是否只处理一次?
  • 模拟极端并发(JMeter 同一幂等键并发 100 个请求)是否只处理一次?
  • Token/幂等键过期后重新发送是否被正确处理?

运维阶段

  • Redis/DB 中幂等数据是否有合理的过期/清理策略,避免无限堆积?
  • 幂等键相关的监控告警是否到位(重复请求量、token 缺失量)?

七、常见错误与避坑

7.1 只在 Controller 层做幂等,不在 Service 层做

错误姿势:
  @Idempotent  // 只加了 Controller 注解
  @PostMapping("/order")
  public Result createOrder(@RequestBody CreateOrderReq req) {
      return orderService.createOrder(req);  // Service 内部无幂等
  }

问题:如果 Service 方法被定时任务、MQ 消费者、其他内部服务调用,幂等完全失效。

正确姿势:幂等逻辑放在 Service 层,Controller 层可叠加但不依赖。

7.2 Token 的"读-删"不是原子的

// 错误:并发下两个请求都读到 token,都执行业务
String val = redisTemplate.opsForValue().get(key);
if (val != null) {
    redisTemplate.delete(key);  // 非原子!
    doBusiness();
}

// 仅保证 Redis 查询与删除原子,不包含 doBusiness 的数据库事务
String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

7.3 重复请求与提交结果未知

只有查到已提交、属于该调用方且指纹匹配的业务结果,才能重放成功结果。结果暂时查不到时不能返回空成功;需要区分处理中、事务回滚和结果未知。

7.4 过期并不会撤销业务结果

幂等记录保留时间由重试窗口、迟到消息和业务唯一性决定。记录清理后同键再来可能被当成新请求;订单号等长期唯一约束应独立保留。不能套用固定的 10–30 分钟作为所有业务的规则。

7.5 唯一键冲突不是通用事务恢复机制

冲突等待、回滚和读可见性取决于数据库隔离级别与事务状态。不要在 catch 中无期限重查,也不要捕获冲突后提交此前已经发生的扣库存动作。

原来的完整 Java 下单示例先扣库存、再插订单,捕获唯一键异常后直接返回已有订单;这可能把重复扣减一起提交。本轮移除该示例,改用文末真实事务测试。先查询可以是快速路径,唯一约束与原子提交才是并发正确性的边界。

八、方案选择

场景 核心约束 不能替代它的机制
新建订单 调用方作用域的操作键、指纹、同事务业务结果 一次性 Token
扣库存 正数数量、库存条件更新、订单扣减唯一性 单独的分布式锁
状态转换 合法的前置状态、同事务副作用 仅检查当前状态
外部回调 来源身份、业务流水号与本地唯一约束 易变的链路 traceId
MQ 消费 业务事件键、消费记录与业务同事务、提交后确认 先写 Redis 成功标记

幂等键可以通过 Header 或请求体传输,关键是约定一致并确认网关转发规则,不能一概禁止 Header。

九、可复现验证与来源

在仓库根目录运行 python3 examples/idempotency.py,需要 Python 3 与标准库 SQLite,不需要安装数据库服务。四个测试验证并发重试、重新连接后结果重放、不同请求体冲突和失败回滚;每个测试使用临时数据库并清理。

这是订单创建的事务示例,不是通用支付组件,也未验证 MySQL/PostgreSQL 的隔离级别或跨系统事务。运行方法和观察结果见 并发一致性实验