高并发下如何保证接口的幂等性

引言

如何在高并发的情况下,保证各个接口的幂等性,是C端业务的必做逻辑,同时这也是面试中重要的场景题。那么下面介绍一下什么是幂等性

在高并发场景下,幂等性(Idempotency)是确保系统稳定性、防止数据因重试或并发请求而产生脏数据的核心机制。简单来说,幂等性要求对于同一个请求,执行一次和执行多次的效果必须是一致的。

作为后端开发人员(Java/Go),处理这个问题通常需要从"业务设计"和"技术手段"两个维度切入

Token 机制(前端防重)

这是应对"表单重复提交"最常用的方案。其核心思想是将"请求意图"与"执行动作"分离。

  • 流程:

    1. 获取 Token:客户端在执行操作前,先从服务端申请一个全局唯一的 Token(通常存入 Redis,并设置过期时间)。

    2. 携带 Token 提交:客户端在业务请求的 Header 或 Body 中携带该 Token。

    3. 校验与删除 :服务端接收请求后,利用 Redis 的原子性操作(如 DEL 或 Lua 脚本)判断 Token 是否存在。

      • 如果删除成功:说明是第一次请求,执行业务逻辑。
      • 如果删除失败:说明是重复请求,直接返回。

这种防重复机制适用于表单提交、支付等前端可控场景,下面是示例代码

java 复制代码
// 申请 token
String token = UUID.randomUUID().toString();
redis.set("idempotent:" + token, "1", 5, TimeUnit.MINUTES);

// 执行接口时校验(原子操作,防并发)
Boolean success = redis.delete("idempotent:" + token); // 只有一个并发能删成功
if (!success) {
    throw new RepeatSubmitException("请求重复或token无效");
}
// 执行业务逻辑...

数据库唯一索引去重(兜底)

利用数据库层面的 Unique Key 约束,这是防止产生重复数据最简单且最可靠的手段。

  • 场景:比如订单号、支付流水号。
  • 实现 :在数据库中建立唯一索引。当并发请求到达时,只有第一个插入请求会成功,后续请求会触发 Duplicate Key 异常。
  • 技巧 :在 Java 中可以捕获 DuplicateKeyException;在 Go 中判断错误码
java 复制代码
// 订单表建唯一索引
// UNIQUE KEY uk_order (biz_id, request_id)
try {
    orderMapper.insert(order); // 包含 request_id
} catch (DuplicateKeyException e) {
    // 幂等:查询已有结果返回
    return orderMapper.selectByRequestId(requestId);
}

分布式锁

我们可以使用分布式锁来实现去重,分布式锁的实现方式有很多,比如redis、zookeeper、Etcd等,下面我们以redis来演示。

通过 Redis 的 SETNX 或 Redlock 来实现。

  • 逻辑

    1. 进入业务逻辑前,先尝试以业务唯一标识(如 order_id)作为 Key 加锁。
    2. 如果加锁成功,执行业务逻辑。
    3. 如果加锁失败,说明已有相同请求在处理中,直接返回。
  • 注意:锁的释放时间需要根据业务耗时合理评估,防止业务没跑完锁就过期了

java 复制代码
String lockKey = "idempotent:" + bizId + ":" + requestId;
// SETNX + 过期时间,原子操作
Boolean locked = redis.setIfAbsent(lockKey, "PROCESSING", 10, TimeUnit.SECONDS);

if (!locked) {
    // 并发请求:等待或查询已有结果
    return queryResult(requestId);
}

try {
    // 1. 再次查询是否已处理(double check)
    Result existing = queryResult(requestId);
    if (existing != null) return existing;
    
    // 2. 执行业务
    Result result = doBusiness();
    
    // 3. 持久化结果
    saveResult(requestId, result);
    return result;
} finally {
    redis.delete(lockKey);
}

状态机幂等(业务逻辑层面)

对于有状态流转的业务(如订单:待支付 -> 已支付 -> 已出库),可以通过状态约束实现幂等。

在下面的SQL中,我们就使用了 UPDATE ... WHERE status = 'UNPAID' 的行级锁天然保证幂等,这是最推荐的方式之一,无额外中间件依赖,利用数据库行锁天然防并发。这条 SQL 无论执行多少次,只有第一次能更新成功(影响行数为 1),后续执行由于状态已改变,影响行数均为 0

sql 复制代码
UPDATE orders SET status = 'PAID' WHERE id = 123 AND status = 'UNPAID';

那么相关的业务代码可以这样来写,通过数据库的反馈来决定代码走向

java 复制代码
int affected = orderMapper.casUpdateStatus(orderId, "UNPAID", "PAID");
if (affected == 0) {
    // 说明已被处理,直接返回当前状态
    return orderMapper.selectById(orderId);
}

乐观锁(适用于更新操作)

通过版本号(version)来控制。

  • 实现 :在表里加一个 version 字段。
  • 逻辑:每次更新时,要求版本号必须匹配
sql 复制代码
UPDATE account SET balance = balance - 100, version = version + 1 
WHERE id = 1 AND version = 5;

在并发冲突时,只有一个请求能命中版本号

方案比对

我们可以根据自己的业务场景和性能容忍度来决定使用什么方案

方案 适用场景 优点 缺点
Token 机制 前端提交、防重点击 逻辑通用,不依赖数据库类型 需要多一次网络交互获取 Token
唯一索引 插入新数据(新增用户/订单) 实现最简单,可靠性最高 无法处理逻辑复杂的更新操作
分布式锁 极高并发下的写操作 性能高,能有效拦截重复请求 增加了对 Redis 的强依赖
状态机 业务流程流转 无额外性能损耗,逻辑优雅 仅适用于有状态变化的场景
乐观锁 账户余额、库存扣减 简单易行,无锁竞争 高竞争下重试率高,性能下降

下面还有一些要注意避坑的点:

  • 先查后改(Check-then-Act)的陷阱 :在高并发下,if (not exists) { insert } 这种逻辑在没有锁的情况下是失效的,一定要利用数据库原子性或分布式锁。

  • Token 的删除时机 :建议先删除 Token 再执行业务(或者使用 Lua 脚本保证原子性)。如果执行完业务再删 Token,在高并发瞬间可能有两个请求都通过了"查 Token"的校验。

  • 返回一致性:当识别出是重复请求时,通常应该返回"处理中"或"执行成功(返回上次的结果)",而不是直接报错,以提升用户体验。

总结

经过上面各种方案的讨论,保证接口的幂等性是我们后端必须要注意的地方,如果业务和代码没有保障这一块,那么很可能造成严重后果和资损,那么我们只能卷铺盖跑路了(Doge)

相关推荐
爱勇宝2 小时前
2026一人公司生存指南:用AI大模型,90天跑出你的第一条现金流
前端·后端·架构
golang学习记2 小时前
Go 并发编程:原子操作(Atomics)完全指南
后端
哈里谢顿3 小时前
`127.0.0.1` 和 `0.0.0.0` 有何区别?通过验证 demo来展示
后端
树獭叔叔3 小时前
08-大模型后训练的指令微调SFT:LoRA让大模型微调成本降低99%
后端·aigc·openai
苏三说技术3 小时前
我终于遇到一台真正懂程序员的显示器!
后端
Re_zero3 小时前
线上日志被清空?这段仅10行的 IO 代码里竟然藏着3个毒瘤
java·后端
花落人散处4 小时前
流式输出——解决 HITL 难题 (SpringAIAlibaba)
后端
BingoGo5 小时前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php
JaguarJack5 小时前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php·服务端