接口幂等性实践指南
审校日期: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 的隔离级别或跨系统事务。运行方法和观察结果见 并发一致性实验。
-
RFC 9110:幂等方法:请求效果相同不要求响应相同。
-
Stripe 幂等请求:保存结果与校验同键参数的具体 API 设计,可作参考,不是所有系统通用的状态码约定。
-
RocketMQ 消费幂等建议:业务唯一标识应由应用定义。