文章目录
- 前言
- 一、什么是幂等性
- 二、方案总览
- 三、方案一:数据库唯一索引(最可靠的兜底)
- [四、方案二:业务唯一键 + 去重表(推荐)](#四、方案二:业务唯一键 + 去重表(推荐))
- [五、方案三:幂等 Token(通用对外 API)](#五、方案三:幂等 Token(通用对外 API))
- 六、方案四:请求指纹(内容哈希)
-
- 思路
- [和"申请 Token"的本质区别](#和"申请 Token"的本质区别)
- [Lua 脚本(注意分支相反)](#Lua 脚本(注意分支相反))
- 请求体格式化(成败关键)
- 格式化规则
- 三个致命坑
- 七、方案五:分布式锁兜底
- 八、上传表格场景的特殊处理
-
- [推荐方案:文件指纹 + 批次号 + 行唯一键](#推荐方案:文件指纹 + 批次号 + 行唯一键)
- [九、失败重试 + 死信队列兜底](#九、失败重试 + 死信队列兜底)
- 十、完整落地方案(三层防护)
- 十一、方案对比与选型
- 十二、几个容易踩的坑
- 十三、总结
前言
做对外 API 时,最头疼的问题之一就是重复请求:
- 用户手抖点了两次提交按钮
- 网络超时,客户端自动重试
- 消息队列重投
- 网关 / Nginx 重试
- 攻击者恶意重复调用
如果接口不做幂等处理,轻则产生脏数据,重则造成资金损失。
本文系统汇总对外 API 保证幂等性的所有主流方案,并结合插入数据、上传表格两大典型场景,给出可落地的选型建议。
一、什么是幂等性
幂等性(Idempotency)指:同一个请求执行一次和执行多次,对系统产生的影响是一样的。
核心思想:
让服务端能识别"这是同一个请求",从而只处理一次。
注意区分几个概念:
| 概念 | 含义 |
|---|---|
| 幂等 | 多次执行结果一致,副作用只发生一次 |
| 防重 | 防止重复提交,通常指短时间内的重复 |
| 去重 | 识别并剔除重复数据 |
二、方案总览
| 方案 | 适用场景 | 核心思想 | 推荐度 |
|---|---|---|---|
| 数据库唯一索引 | 插入数据 | 约束兜底 | ⭐⭐⭐⭐⭐ |
| 业务唯一键 + 去重表 | 通用 | 记录已处理请求 | ⭐⭐⭐⭐⭐ |
| 幂等 Token | 通用对外 API | 一次性凭证 | ⭐⭐⭐⭐ |
| 请求指纹(内容哈希) | 内容固定场景 | 相同内容=同一请求 | ⭐⭐⭐ |
| 分布式锁 | 高并发 | 串行化处理 | ⭐⭐⭐ |
| 乐观锁 / 状态机 | 更新类 | 版本控制 | ⭐⭐⭐ |
核心原则 :无论上层用什么方案,底层最好都加唯一索引兜底。
三、方案一:数据库唯一索引(最可靠的兜底)
思路
给业务上天然唯一的字段加唯一约束,重复插入直接报错。
sql
ALTER TABLE `order`
ADD UNIQUE KEY `uk_biz_no` (`biz_no`);
插入时捕获异常:
java
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
// 已存在,视为成功(幂等)
log.warn("重复插入,bizNo={}", order.getBizNo());
return queryByBizNo(order.getBizNo());
}
优点
- 数据库层面保证,绝对不会重复,是最可靠的兜底
- 实现简单,无额外组件依赖
缺点
- 需要业务字段天然唯一(如订单号、流水号)
- 分库分表时唯一索引难做
- 异常处理需谨慎,避免误吞其它错误
关键点
无论上层用什么方案,底层唯一索引都是必须的。 因为应用层的判断(Redis、锁)可能因宕机、网络、bug 失效,只有数据库唯一索引是最后一道防线。
四、方案二:业务唯一键 + 去重表(推荐)
思路
客户端生成一个全局唯一的请求号(requestId / 幂等号),服务端记录已处理的请求号。
流程
① 客户端每次请求生成唯一 requestId(UUID),同一请求重试时复用同一个
② 服务端先查去重表
- 已存在 → 直接返回上次结果
- 不存在 → 执行业务 + 记录 requestId(同一事务)
去重表设计
sql
CREATE TABLE `idempotent_record` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`request_id` VARCHAR(64) NOT NULL,
`biz_type` VARCHAR(32) NOT NULL,
`result` TEXT,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_req` (`request_id`, `biz_type`)
);
业务与去重记录在同一事务
java
@Transactional
public Result handle(Request req) {
// 1. 尝试插入去重记录(唯一索引兜底)
try {
idempotentMapper.insert(req.getRequestId(), req.getBizType());
} catch (DuplicateKeyException e) {
// 2. 已处理过,返回上次结果
return queryPreviousResult(req.getRequestId());
}
// 3. 执行业务
return doBiz(req);
}
关键点
requestId 必须由客户端生成并在重试时复用。 如果服务端每次生成新 ID,就无法识别重试。
五、方案三:幂等 Token(通用对外 API)
思路
客户端先申请一个一次性 Token,提交时带上,服务端用 Redis 的原子操作消费 Token。
完整流程
① 客户端请求获取 token
GET /api/idempotent/token
→ 服务端生成 UUID,存入 Redis
→ 返回 token 给客户端
② 客户端提交业务请求,Header 带上 token
POST /api/order
Header: Idempotent-Token: xxxx
③ 服务端用 Lua 脚本原子消费 Token
Token 存储设计
用 String 类型,每个 Token 一个独立 key:
key: dml:{token} ← 前缀 + token 值
value: 0 / 1 ← 整型状态字典
TTL: 30min ~ 2h ← key 级过期,自动清理
状态字典:
java
public enum IdempotentStatus {
UNUSED(0), // 未使用
USED(1); // 已使用
}
申请 Token
java
@GetMapping("/api/idempotent/token")
public String getToken() {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
"dml:" + token, // key = dml:abc-123
"0", // value = 未使用
30, TimeUnit.MINUTES // TTL
);
return token;
}
消费 Token(Lua 原子脚本)
lua
-- KEYS[1] : 要操作的 Redis key,格式 "dml:{token}"
-- ARGV[1] : "已使用"状态标志值,由 Java 传入
--
-- 返回值:
-- 0 = 第一次请求(放行)
-- 1 = 重复提交(拒绝)
-- 2 = Token 已过期或不存在(拒绝)
-- ① 用 key 取出 value,存到 status
-- 可能值:"0"(未使用)/ "1"(已使用)/ false(key 不存在)
local status = redis.call('GET', KEYS[1])
-- ② key 不存在 → 已过期
if status == false then
return 2
-- ③ value 是"已使用"标志 → 重复提交
elseif status == ARGV[1] then
return 1
-- ④ 其它 → 第一次,标记已用并放行
else
-- KEEPTTL:保留原有过期时间,避免 SET 清掉 TTL
redis.call('SET', KEYS[1], ARGV[1], 'KEEPTTL')
return 0
end
Java 调用
java
private static final String LUA =
"local status = redis.call('GET', KEYS[1]) " +
"if status == false then " +
" return 2 " +
"elseif status == ARGV[1] then " +
" return 1 " +
"else " +
" redis.call('SET', KEYS[1], ARGV[1], 'KEEPTTL') " +
" return 0 " +
"end";
/**
* @return 0=第一次 1=重复提交 2=已过期
*/
public int consumeToken(String token) {
Long r = redisTemplate.execute(
new DefaultRedisScript<>(LUA, Long.class),
Collections.singletonList("dml:" + token), // KEYS[1]
"1" // ARGV[1]
);
return r == null ? 2 : r.intValue();
}
关键理解 :
KEYS[1]是要操作的 Redis key,ARGV[1]是 value(状态标志值)。两者必须分开传,因为 Redis 集群需要靠KEYS路由。
业务层
java
@PostMapping("/api/order")
public Result createOrder(@RequestHeader("Idempotent-Token") String token,
@RequestBody OrderDTO dto) {
int result = idempotentService.consumeToken(token);
switch (result) {
case 0:
orderService.insert(dto); // 第一次,放行
return Result.success();
case 1:
throw new BizException("请勿重复提交");
case 2:
default:
throw new BizException("请求已过期,请重新发起");
}
}
为什么用 Lua?
"判断 + 修改"必须是一步完成,否则并发下:
请求A: GET → "0"(未使用)
请求B: GET → "0"(未使用)
请求A: SET → "1"
请求B: SET → "1" ← 两个都放行了!重复插入!
Lua 脚本在 Redis 里单线程原子执行,执行期间不会被打断。
失败恢复 Token
java
try {
orderService.insert(dto);
} catch (Exception e) {
// 业务失败,恢复为未使用,允许客户端重试
redisTemplate.opsForValue().set(
"dml:" + token, "0", 30, TimeUnit.MINUTES);
throw e;
}
关于 TTL 的选型
| TTL | 适用 |
|---|---|
| 5 分钟 | 只防手抖连点 |
| 30 分钟 ~ 2 小时 | 推荐,覆盖用户正常操作时长 |
| 12 小时 | 偏长,需配合严格限流 |
TTL 的目标是"覆盖用户从申请到提交的最长时间",不是越长越好。
申请 Token 接口要不要幂等?
不需要,也不应该。
- 申请接口的语义就是"给我一个新的凭证",每次都应返回新 Token
- 如果做成幂等(返回同一个 Token),用户就无法连续下两单
- 担心被刷 → 用限流,而不是幂等
java
// 限流:每用户每分钟最多申请 60 个 Token
String limitKey = "token_limit:" + userId;
Long count = redisTemplate.opsForValue().increment(limitKey);
if (count == 1) redisTemplate.expire(limitKey, 1, TimeUnit.MINUTES);
if (count > 60) throw new BizException("请求过于频繁");
六、方案四:请求指纹(内容哈希)
思路
不用客户端申请 Token,而是服务端根据请求内容算一个哈希值,作为唯一标识。
Token = hash(用户ID + 接口路径 + 请求体关键字段)
和"申请 Token"的本质区别
| 申请 Token | 请求指纹 | |
|---|---|---|
| Token 来源 | 服务端生成 | 请求体计算 |
| key 不存在 | 拒绝(已过期) | 放行(第一次) |
| key 存在 | 判断 value | 拒绝(重复) |
| 能否区分"合法重复" | ✅ 能 | ❌ 不能 |
| TTL 建议 | 30min~2h | 1~5min |
关键差异:一个"存在就放行",一个"存在就拒绝",逻辑正好相反。
Lua 脚本(注意分支相反)
lua
local status = redis.call('GET', KEYS[1])
if status == false then
-- 不存在 → 第一次 → 写入并放行
redis.call('SET', KEYS[1], ARGV[1], 'EX', 300)
return 0
else
-- 已存在 → 重复 → 拒绝
return 1
end
请求体格式化(成败关键)
java
public String buildFingerprint(Object requestBody, String userId) {
// ① 转成 Map
Map<String, Object> map = JSON.parseObject(
JSON.toJSONString(requestBody),
new TypeReference<Map<String, Object>>() {});
// ② 排除"每次都变"的字段
map.remove("timestamp");
map.remove("traceId");
map.remove("nonce");
// ③ 用 TreeMap 排序,保证字段顺序一致
Map<String, Object> sorted = new TreeMap<>(map);
// ④ 加入用户维度
sorted.put("_userId", userId);
// ⑤ 序列化成规范字符串
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, Object> e : sorted.entrySet()) {
sb.append(e.getKey()).append("=").append(e.getValue()).append("&");
}
// ⑥ 算 hash
return DigestUtils.md5Hex(sb.toString());
}
格式化规则
| 规则 | 原因 |
|---|---|
| 字段排序 | 避免顺序不同导致 hash 不同 |
| 排除变化字段 | timestamp、traceId 每次都变 |
| 统一数字格式 | 99 vs 99.0 要统一 |
| 加入用户维度 | 不同用户相同请求体不干扰 |
| 统一编码 | UTF-8 |
| null 处理 | 忽略还是算空串,要统一 |
三个致命坑
坑 1:相同内容 ≠ 相同意图
用户想下两单:都是"买 2 个商品 A"
↓
指纹相同 → 第二次被当成重复 → 拒绝
↓
但用户确实想买两次!
解决 :改用申请 Token,或在请求体加客户端唯一标识 clientOrderNo。
坑 2:TTL 过长误伤合法重复
TTL 12 小时意味着半天内相同操作都做不了。请求指纹的 TTL 应短,1~5 分钟。
坑 3:无关字段变化导致失效
第一次:{"productId":1, "remark":"快点发货"}
第二次:{"productId":1, "remark":""} ← 备注改了
解决:只取业务关键字段算指纹。
七、方案五:分布式锁兜底
极端并发下(两个请求几乎同时到达),上面方案可能都通过检查。此时加锁串行化:
java
String lockKey = "lock:order:" + bizNo;
RLock lock = redissonClient.getLock(lockKey);
try {
if (!lock.tryLock(0, 10, TimeUnit.SECONDS)) {
throw new BizException("请求处理中,请勿重复提交");
}
// 二次检查 + 业务处理
if (exists(bizNo)) return query(bizNo);
return doInsert();
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
配合唯一索引,形成双重保险。
八、上传表格场景的特殊处理
上传表格比单条插入更复杂:
- 一次请求包含多行数据
- 文件可能被重复上传
- 部分行可能重复
推荐方案:文件指纹 + 批次号 + 行唯一键
① 计算文件指纹(MD5)
java
String fileHash = DigestUtils.md5Hex(file.getBytes());
String idemKey = "upload:" + userId + ":" + fileHash;
if (!redisTemplate.opsForValue().setIfAbsent(idemKey, "1", 1, TimeUnit.HOURS)) {
throw new BizException("文件已上传,请勿重复提交");
}
② 每行数据生成业务唯一键
sql
ALTER TABLE `import_detail`
ADD UNIQUE KEY `uk_row` (`batch_no`, `row_key`);
批量插入用 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE:
sql
INSERT IGNORE INTO import_detail (batch_no, row_key, ...) VALUES (...);
③ 异步 + 批次状态机
上传 → 落库(批次状态=待处理)→ 返回批次号
→ 异步任务消费 → 更新状态=成功/失败
客户端拿批次号轮询结果,重复上传同一文件返回同一个批次号。
九、失败重试 + 死信队列兜底
问题
① 客户端提交,Token 被消费
② 服务端入库失败
③ 客户端重试,Token 已是 USED → 被拒
④ 业务没成功 → 用户数据丢了
解决
方案 A:业务失败时恢复 Token
java
try {
orderService.insert(dto);
} catch (Exception e) {
redisTemplate.opsForValue().set("dml:" + token, "0", 30, TimeUnit.MINUTES);
throw e;
}
方案 B:Token 用多态(更严谨)
0 → 未使用
1 → 处理中(PROCESSING)
2 → 成功(SUCCESS)
3 → 失败(FAILED)
死信队列兜底 :业务最终失败 → 重试 N 次 → 仍失败 → 进死信队列 → 人工处理。注意消费端也要幂等。
十、完整落地方案(三层防护)
【客户端】
生成 requestId(同一操作重试时复用)
↓
【服务端入口】
① 限流(防刷 Token 接口)
② Token 去重 / requestId 去重(Redis Lua 原子)
③ 分布式锁(高并发场景)
↓
【业务层】
④ 业务处理(失败恢复 Token)
⑤ MySQL 唯一键兜底(最后防线)
⑥ 失败进死信队列
↓
【运维】
Token 靠 TTL 自动清理
删 key 必须审计 + 二次确认
十一、方案对比与选型
| 场景 | 推荐方案 |
|---|---|
| 单条插入,有天然唯一键 | 唯一索引(必加)+ 业务判重 |
| 通用对外 API | 幂等 Token 或 requestId + 去重表 |
| 内容固定(表单/消息) | 请求指纹(TTL 1~5 分钟) |
| 上传表格 | 文件指纹 + 行唯一键 + 唯一索引 |
| 高并发 | 上述 + 分布式锁 |
| 更新类 | 乐观锁(version)+ 状态机 |
两种幂等模型对比
| 申请 Token | 请求指纹 | |
|---|---|---|
| Token 来源 | 服务端生成 UUID | 请求体 hash |
| key 不存在 | 拒绝 | 放行 |
| key 存在 | 判断 value | 拒绝 |
| 区分合法重复 | ✅ | ❌ |
| TTL | 30min~2h | 1~5min |
| 适用 | 下单、支付、开放 API | 表单、消息、文件 |
选型口诀:
内容即身份 → 用指纹;操作即身份 → 用 Token。
用户可能"想做两次一样的事",就绝不能只靠指纹。
十二、几个容易踩的坑
坑 1:只用 Redis 判重,不用唯一索引
Redis 可能丢数据、可能被清空,不能作为唯一保证。唯一索引才是最后一道防线。
坑 2:判重和业务不在同一事务
判重通过后,业务插入失败,去重记录却已写入,导致重试永远被拦截。
坑 3:requestId 由服务端生成
服务端每次生成新 ID,就无法识别重试。requestId 必须由客户端生成并复用。
坑 4:忽略返回值语义
重复请求应返回与首次一致的结果,而不是简单报"重复提交"。
坑 5:去重记录无限增长
要给去重表 / Redis key 设置过期时间或定期清理。
坑 6:管理员随意删 Token key
删 key 等于抹掉"已使用"记录,幂等防线就破了。应靠 TTL 自动清理,删除要审计。
坑 7:Lua 里字符串和数字比较混用
lua
status == '1' -- ✅ GET 返回字符串
status == 1 -- ❌ 永远 false
坑 8:SET 不加 KEEPTTL
SET key value 会清掉 TTL,导致 key 永不过期,内存泄漏。
十三、总结
保证幂等性的核心思路:
让服务端能识别"同一个请求",并保证副作用只发生一次。
落地时的黄金组合:
- 客户端:生成并复用唯一 requestId
- 服务端入口:requestId / Token / 文件指纹去重
- 并发控制:分布式锁
- 数据层:唯一索引兜底
- 兜底策略:失败恢复 + 死信队列
不同场景的选择:
- 插入数据 → 唯一索引 + requestId 去重
- 上传表格 → 文件指纹 + 行唯一键 + 唯一索引
- 内容固定 → 请求指纹(TTL 短)
- 操作型接口 → 幂等 Token(TTL 长)
记住一句话:
上层方案可以优化体验,底层唯一索引才是最后的底线。
Token 是"存在即放行,用完标记",指纹是"不存在即放行,存在即拒绝",别搞混。