对外 API 如何保证幂等性?插入数据 / 上传表格场景全方案汇总

文章目录

前言

做对外 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 IGNOREON 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 幂等 TokenrequestId + 去重表
内容固定(表单/消息) 请求指纹(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 永不过期,内存泄漏。


十三、总结

保证幂等性的核心思路:

让服务端能识别"同一个请求",并保证副作用只发生一次。

落地时的黄金组合:

  1. 客户端:生成并复用唯一 requestId
  2. 服务端入口:requestId / Token / 文件指纹去重
  3. 并发控制:分布式锁
  4. 数据层:唯一索引兜底
  5. 兜底策略:失败恢复 + 死信队列

不同场景的选择:

  • 插入数据 → 唯一索引 + requestId 去重
  • 上传表格 → 文件指纹 + 行唯一键 + 唯一索引
  • 内容固定 → 请求指纹(TTL 短)
  • 操作型接口 → 幂等 Token(TTL 长)

记住一句话:

上层方案可以优化体验,底层唯一索引才是最后的底线。

Token 是"存在即放行,用完标记",指纹是"不存在即放行,存在即拒绝",别搞混。

相关推荐
tryxr2 小时前
Chat2Excel 文件服务模块上传文件功能开发
java·项目·oss·easyexcel·文件服务
Tangyuewei2 小时前
Java 写 Agent:模型只是组件
java·开发语言
彧azz3 小时前
Java学习语法篇:变量
java·学习
信誓旦旦的程序猿4 小时前
【量化系统从零构建 #04】存储设计:选型·建库·交易日历
java·人工智能·python·股票数据api·股票数据·股票数据api接口·股票api数据接口
yurenpai(27届找实习中)4 小时前
Java 10 的 var 为什么不能随便用?从订单汇总看懂类型推断与泛型陷阱
java·开发语言·java18
Flynt5 小时前
把公司项目迁到 Spring Boot 4.0:编译通过只是开始
java·spring boot·后端
Tangyuewei5 小时前
给老 Spring 项目装个 AI Agent
java·人工智能·spring
她的男孩6 小时前
多租户和数据权限怎么共存?扒完拦截器注册链路,我找到 4 个隐蔽的坑
java·后端·架构
小溪学编程6 小时前
Java BufferedReader 详解:从基础用法到性能优化
java·python·性能优化