如何保证接口幂等
如何保证接口幂等
先明确核心概念
接口幂等性:同一个请求调用一次和调用多次的效果完全相同,不会因为重复调用导致数据异常或业务错误 🎯
为什么必须解决:
- 网络抖动导致的重复请求
- 前端按钮重复点击
- 消息队列消息重复消费
- 服务重试机制(如 Feign、Dubbo 重试)
- 分布式系统中的数据一致性问题
主流解决方案(按实现复杂度排序)
1. 数据库唯一索引(最基础最常用)✅
- 原理:利用数据库唯一约束防止重复插入
- 实现:在业务表中添加request_id唯一索引
- 适用场景:插入类操作(订单创建、支付记录)
- 代码示例:
// 先查询是否已处理
Order order = orderMapper.selectByRequestId(requestId);
if (order != null) {
return Result.success("订单已处理");
}
// 执行业务逻辑
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
// 唯一索引冲突,说明已处理
return Result.success("订单已处理");
}2. Token 机制(前后端配合)🔑
- 关键:使用 Redis 的
DEL命令原子性判断并删除 Token - 优点:可以在请求进入业务逻辑前拦截
- 缺点:需要前端配合,增加一次交互
3. 分布式锁(Redis/ZooKeeper)🔒
- 原理:同一时间只有一个请求能获取到锁
- 实现:Redis SETNX+EXPIRE(推荐 Redisson 框架)
- 代码示例:
RLock lock = redissonClient.getLock("order:" + requestId);
try {
if (lock.tryLock(0, 10, TimeUnit.SECONDS)) {
// 执行业务逻辑
} else {
return Result.error("请求处理中,请稍后再试");
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}4. 状态机幂等(业务层控制)📊
- 原理:利用业务状态的单向流转特性
- 示例:订单状态流转:待支付 → 已支付 → 已发货 → 已完成
- 实现:更新时添加状态条件
UPDATE `order` SET status = 2 WHERE id = #{orderId} AND status = 1;5. 全局唯一 ID + 防重表 🆔
- 原理:单独建立一张防重表,存储已处理的请求 ID
- 适用场景:跨服务、跨数据库的复杂业务
- 优点:不侵入业务表结构
方案对比表 📋
| 方案 | 实现复杂度 | 性能 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|---|
| 唯一索引 | 低 ⭐ | 高 ⭐⭐⭐⭐⭐ | 单库单表插入操作 | 实现简单,可靠性高 | 只能防插入,无法防更新 |
| Token 机制 | 中 ⭐⭐ | 中 ⭐⭐⭐ | 前端提交类操作 | 提前拦截,不污染业务表 | 需要前端配合,有网络开销 |
| 分布式锁 | 中 ⭐⭐ | 中 ⭐⭐⭐ | 大部分业务场景 | 通用性强 | 存在死锁风险,性能一般 |
| 状态机 | 低 ⭐ | 高 ⭐⭐⭐⭐⭐ | 有状态流转的业务 | 性能最好,完全贴合业务 | 仅适用于状态明确的场景 |
| 防重表 | 高 ⭐⭐⭐ | 低 ⭐⭐ | 跨服务复杂业务 | 不侵入业务表 | 增加数据库压力 |
不同场景的最佳实践 💡
- 简单插入操作:优先使用数据库唯一索引
- 前端表单提交:使用Token 机制 + 按钮置灰
- 消息队列消费:使用唯一索引 + 消费幂等
- 更新操作:优先使用状态机 + 乐观锁
- 复杂分布式业务:使用全局唯一 ID + 分布式锁
核心总结与注意事项 🎯
- 幂等性是分布式系统的基础要求,必须在设计阶段考虑
- 没有万能方案,需要根据业务场景选择合适的组合
- 优先使用数据库层面的保证,可靠性最高
- 避免过度设计,简单场景不要用复杂方案
- 所有外部调用都必须考虑幂等性(第三方接口、消息队列)
核心代码与技术亮点实现 🚀
1 Redis Token 机制(原子操作版,技术亮点)
❌ 错误写法(90% 面试者会踩坑):先查后删存在竞态条件
// 错误:两个请求同时查到Token存在,都会通过校验
if (redisTemplate.hasKey(token)) {
redisTemplate.delete(token);
// 执行业务逻辑
}✅ 正确写法(Lua 脚本保证原子性)
/**
* 原子校验并删除Token
* @param token 前端传入的令牌
* @return true=校验通过,false=重复请求
*/
public boolean checkAndDeleteToken(String token) {
String luaScript = """
if redis.call('EXISTS', KEYS[1]) == 1 then
return redis.call('DEL', KEYS[1])
else
return 0
end
""";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(token)
);
return result != null && result == 1;
}技术亮点:Redis 单线程执行 Lua 脚本,彻底解决竞态问题,性能比事务高 10 倍以上。
2 Spring AOP 全局幂等切面(架构级亮点)
通过注解统一处理幂等性,零侵入业务代码
// 自定义幂等注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/** 幂等过期时间,默认5分钟 */
int expireTime() default 300;
/** 幂等Key前缀 */
String prefix() default "idempotent:";
}
// AOP切面实现
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private RedissonClient redissonClient;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint point, Idempotent idempotent) throws Throwable {
// 生成全局唯一幂等Key(从请求头获取requestId)
String requestId = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
.getRequest().getHeader("X-Request-Id");
String lockKey = idempotent.prefix() + requestId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁,获取失败直接返回重复请求
if (!lock.tryLock(0, idempotent.expireTime(), TimeUnit.SECONDS)) {
return Result.error("请勿重复提交");
}
// 执行业务逻辑
return point.proceed();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
// 业务层使用
@PostMapping("/createOrder")
@Idempotent(expireTime = 600) // 10分钟内防止重复提交
public Result createOrder(@RequestBody OrderDTO orderDTO) {
// 纯业务逻辑,无需关心幂等
orderService.create(orderDTO);
return Result.success();
}技术亮点:
- 业务代码零侵入,统一维护幂等逻辑
- 基于 Redisson 实现,自动续期解决锁过期问题
- 支持自定义过期时间和 Key 前缀,灵活性高
3 状态机 + 乐观锁(高并发更新最佳实践)
/**
* 订单支付幂等更新
*/
@Transactional(rollbackFor = Exception.class)
public boolean payOrder(Long orderId, String paySerialNo) {
// 1. 查询订单当前状态
Order order = orderMapper.selectById(orderId);
if (order == null) {
throw new BusinessException("订单不存在");
}
// 2. 状态校验(状态机前置判断)
if (order.getStatus() != OrderStatus.UNPAID.getCode()) {
log.warn("订单状态异常,无法支付: orderId={}, status={}", orderId, order.getStatus());
return true; // 幂等返回成功
}
// 3. 乐观锁更新(版本号+状态条件双重保证)
int rows = orderMapper.updateStatus(
orderId,
OrderStatus.PAID.getCode(),
order.getVersion(), // 版本号
OrderStatus.UNPAID.getCode() // 状态条件
);
return rows > 0;
}
// Mapper层SQL
@Update("""
UPDATE `order`
SET status = #{newStatus}, version = version + 1, pay_serial_no = #{paySerialNo}
WHERE id = #{orderId} AND version = #{oldVersion} AND status = #{oldStatus}
""")
int updateStatus(@Param("orderId") Long orderId,
@Param("newStatus") Integer newStatus,
@Param("oldVersion") Integer oldVersion,
@Param("oldStatus") Integer oldStatus,
@Param("paySerialNo") String paySerialNo);技术亮点:状态机 + 版本号双重保证,既防止重复更新,又解决 ABA 问题。
生产级技术难点与解决方案 🛠️
| 技术难点 | 问题描述 | 解决方案 | 技术要点 |
|---|---|---|---|
| Redis Token 竞态问题 | 高并发下两个请求同时通过hasKey校验,导致重复执行业务 | 使用 Lua 脚本原子执行 "存在则删除" 操作 | Redis 单线程特性,Lua 脚本一次性执行,无中间状态 |
| 分布式锁过期续期 | 业务执行时间超过锁过期时间,导致锁提前释放,并发问题 | 使用 Redisson 的看门狗机制 | 锁获取成功后,每隔 1/3 过期时间自动续期,直到锁释放 |
| 数据库唯一索引性能瓶颈 | 高并发下大量唯一索引冲突,数据库 CPU 飙升 | 1. 前置 Redis 缓存校验 2. 使用 INSERT ... ON DUPLICATE KEY UPDATE | 先查 Redis,不存在再写库;冲突时直接返回成功,避免抛出异常 |
| 重复请求返回值不一致 | 第一次请求处理成功但返回超时,第二次返回 "重复请求",前端显示失败 | 缓存第一次请求的返回结果,重复请求时直接返回 | Redis 存储 requestId 对应的返回值,过期时间与业务超时时间一致 |
| 消息队列重复消费 | MQ 重试机制导致消息被多次消费 | 1. 消息体携带全局唯一 messageId 2. 消费前先查防重表 3. 消费成功后更新防重表状态 | 防重表与业务表在同一事务,保证原子性 |
| 跨服务幂等性 | 分布式事务中,一个服务成功,另一个失败,重试时导致数据不一致 | 使用全局事务 ID + 各服务本地幂等 | 整个调用链传递同一个 XID,每个服务基于 XID 做幂等校验 |
| 高并发下防重表压力 | 防重表数据量过大,查询性能下降 | 1. 按时间分表 2. 定期归档历史数据 3. 冷热数据分离 | 保留最近 30 天数据,历史数据归档到离线存储 |
1.难点详解:重复请求返回值一致性问题(高频面试追问)
问题场景:
- 前端提交创建订单请求
- 后端处理成功,写入数据库
- 网络超时,前端未收到返回结果
- 用户点击重试,后端返回 "重复请求"
- 前端显示 "下单失败",但实际订单已创建
解决方案代码:
public Result createOrder(String requestId, OrderDTO orderDTO) {
// 1. 先查缓存是否有返回结果
String cacheKey = "result:" + requestId;
String cacheResult = redisTemplate.opsForValue().get(cacheKey);
if (cacheResult != null) {
return JSON.parseObject(cacheResult, Result.class);
}
// 2. 幂等校验
if (!idempotentService.check(requestId)) {
return Result.error("请勿重复提交");
}
try {
// 3. 执行业务逻辑
Order order = orderService.create(orderDTO);
Result result = Result.success(order);
// 4. 缓存返回结果(过期时间1小时)
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(result), 1, TimeUnit.HOURS);
return result;
} catch (Exception e) {
// 业务异常,不缓存结果,允许重试
throw e;
}
}真实面试模拟
真实面试模拟
好的,面试同学,咱们换个方式,干脆来个“真刀真枪”的模拟面试。我是今天的面试官,你是候选人,咱们就 “如何保证接口幂等” 这个问题,来一次高浓度的问答。放轻松,就像平常技术讨论一样 😎☕
🧊 第一问:概念对齐
面试官:
先来个开胃菜,用你自己的话说说,什么叫接口幂等?为什么咱们在分布式系统里老提这个?
候选人:
好的面试官。所谓幂等,我理解就是 同一个操作,请求一次和请求多次,产生的副作用完全一样。
举个例子,用户支付时网络抖动,客户端重试了两次支付请求,如果接口不幂等,用户就可能被扣两次钱,这是绝对不能接受的 💸❌。
在分布式环境下,网络是不可靠的,超时重试、消息重复投递非常常见,所以必须保证核心接口的幂等性,否则轻则数据错乱,重则资损。
面试官:
👍 例子举得不错。那 HTTP 方法里面,哪些是天然幂等的,哪些不是?
候选人:
GET、PUT、DELETE 按规范都是幂等的,POST 不是。
但现实业务里,咱们大部分写操作恰好用的都是 POST(创建订单、支付等),所以幂等方案基本都是给“非幂等的 POST 业务”打补丁。
🔑 第二问:方案深挖
面试官:
好,那实战里你会用哪些方案来保证幂等?一个一个说,并讲清楚适用场景和坑。
候选人:
我一般会按业务场景分层选型,主要有四招:
1️⃣ 数据库唯一索引 —— 最硬的兜底方案
怎么做:提前生成一个全局唯一的业务 ID(如订单号),在数据库里建唯一约束。
UNIQUE KEY `uk_order_id` (`order_id`)重复插入直接捕获 DuplicateKeyException,返回成功。
- 适用:新记录创建,比如下单、注册。
- 坑:只防插入,不防更新(比如累加余额)。分库分表时要保证全局唯一路由。
2️⃣ Token 机制 —— 防前端/客户端重复提交
- 流程:服务端先发一个 Token 存 Redis,客户端提交时带上。后端用原子操作(如 Lua 脚本)校验并删除 Token,删除成功才放行。
- 适用:表单重复提交、防止用户手抖连续点击。
- 坑:前后端需配合,Token 有过期时间,需要考虑删除操作的原子性。
面试官:
打断一下,你说的 Redis 原子校验具体怎么实现?如果只是先 get 再 del,并发会有问题。
候选人:
对,必须保证“判断+删除”是原子的。我会用 Lua 脚本:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end或者直接用 SET key value NX 的抢占方式,把存在性判断和写入合成一步。
3️⃣ 状态机约束 —— 最贴合业务的幂等
- 怎么做:在更新时带上状态前置条件。
UPDATE order SET status = 'PAID' WHERE order_id = 123 AND status = 'UNPAID';第一次影响行数为1,第二次为0,业务方根据影响行数判断是否重复。
- 适用:订单、工单等有生命周期流转的场景。
- 坑:并发更新同一状态时可能互相覆盖,通常需要配合乐观锁(version 字段)。
4️⃣ 分布式锁 —— 保护复杂业务块
- 怎么做:以业务唯一标识(如
lock:submit_order:userId+cartId)为 key 用 Redis 加锁,抢到锁才执行业务逻辑。 - 适用:包含多步查询、计算、多表写入的复杂操作,没法用唯一索引简单解决的场景。
- 坑:必须设过期防死锁,释放锁要校验身份(又是 Lua 脚本),而且锁会降低并发度,只适合并发不极高的业务聚合。
面试官:
不错,四种方案都很清晰。那你在真实项目中,会不会组合使用?
候选人:
会。比如支付回调,我会这样组合:
- 数据库里对
out_trade_no做唯一约束,兜底。 - 业务入口用 Redis 分布式锁,以
out_trade_no加锁,防止并发回调。 - 更新订单状态时用状态机约束,确保只能从“待支付”变“已支付”。
三重保险,万无一失。
📊 中场总结(面试官顺手画出对比表)
面试官:
来,我把你刚才说的整理成一张表,你看是不是这个意思? 🧾
| 方案 | 适用场景 | 幂等粒度 | 复杂度 | 可靠性 | 典型坑点 |
|---|---|---|---|---|---|
| 🗄️ 唯一索引 | 插入操作 | 行 | ⭐ | ⭐⭐⭐⭐⭐ | 分库分表全局唯一路由 |
| 🎫 Token 令牌 | 表单防抖 | 前端会话 | ⭐⭐ | ⭐⭐⭐ | 前后端配合 & 过期 |
| 🔄 状态机 | 状态流转 | 业务状态 | ⭐⭐ | ⭐⭐⭐⭐ | 需乐观锁防并发覆盖 |
| 🔒 分布式锁 | 复杂业务聚合 | 业务标识 | ⭐⭐⭐ | ⭐⭐⭐ | 死锁、性能瓶颈 |
候选人:
没错,面试官这张表总结得很到位 👍
🚀 第三问:深水区追问
面试官:
好,那再问你几个细节,看看你的思考深度。
- 全局唯一 ID 你怎么生成?雪花算法,如果时钟回拨了怎么办?
- 你先查后插(SELECT + INSERT)判断幂等,在并发场景下行不行?
候选人:
这俩都是好问题。
- ID 生成:常用雪花算法。时钟回拨的解决思路包括:① 抛异常等时钟追上;② 用历史最大时间戳续用;③ 引入外部发号器(如美团 Leaf)用号段模式,不依赖时间。
- 先查后插的并发问题:完全不行,存在 race condition。正确做法是 “直接插入,冲突了再查”——也就是乐观插入。利用唯一索引,插入失败就说明已存在,再走查询返回现有结果。这样才没有间隙。
面试官:
再问一个,你在对上游调用方提要求时,怎么保证他们重试时能幂等?
候选人:
我会强制要求调用方 每次重试都带同样的幂等键(比如 bizId),而且最好由调用方生成,必须保证全局唯一。并且这个键要贯穿整个调用链,落在我的唯一索引或者锁的 key 上。如果调用方不能保证幂等键一致,那我这边的幂等就形同虚设。
面试官:
非常好。最后一个问题,唯一索引在高并发写入下可能成为热点,你怎么破?
候选人:
可以考虑几个方向:
- 对唯一键做 分级拆分,比如订单号尾号分片,降低单索引热度。
- 引入 布隆过滤器 前置拦截绝大部分重复请求,挡掉80%的无效流量。
- 将强一致的唯一索引 降级 为配合 Redis 判重的最终一致方案(牺牲极小概率的重复,换性能),不过只适合非金融类业务。
🎯 终面评语
面试官:
可以,今天这个问题你答得很全面。从概念到落地,再从坑点到深度优化,都涉及到了,而且能根据不同场景灵活组合方案,思路很清晰。特别是你提到的“乐观插入”和“要求上游传递幂等键”,都是大厂实战里总结出的铁律 🛡️
👨💻 面试官:
来,先亮代码
我挑两个最经典的方案,你现场把核心代码写出来。注意要体现 原子性、并发安全 这些技术亮点。
🎫 场景一:Token 机制防重复提交(Redis + Lua 原子校验)
候选人:
(边写边说)这是前后端配合的典型方案。核心在于 校验和删除必须原子,所以我会用一个 Lua 脚本。
-- token_check.lua
-- KEYS[1]: token的key, 如 "submit_token:abc123"
-- ARGV[1]: 前端传过来的token值
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
endJava 调用层封装:
/**
* 亮点:利用 Lua 脚本保证 GET+DEL 原子性,避免并发误判
*/
public boolean tryReleaseToken(String tokenKey, String tokenValue) {
// 1. 加载 Lua 脚本(生产上会缓存脚本 SHA)
String lua = "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
"return redis.call('DEL', KEYS[1]) else return 0 end";
// 2. 执行原子操作
Long result = jedis.eval(lua, Collections.singletonList(tokenKey),
Collections.singletonList(tokenValue));
// 3. 返回 true 代表首次提交,false 代表重复
return result != null && result == 1L;
}💡 亮点解读:单次网络 IO 完成“比较-删除”,无并发窗口,完美实现幂等入口。
🔄 场景二:状态机 + 乐观锁 防重更新
候选人:
这种适用于订单、支付单等状态流转,更新时必须带上 状态前置条件 和 版本号。
MyBatis 实现:
<!-- OrderMapper.xml -->
<update id="updateOrderStatusPaid">
UPDATE `order`
SET status = 'PAID',
version = version + 1,
update_time = NOW()
WHERE order_id = #{orderId}
AND status = 'UNPAID'
AND version = #{version} <!-- 乐观锁,防并发覆盖 -->
</update>Service 层调用与判断:
public void payOrder(String orderId) {
Order order = orderMapper.selectByOrderId(orderId);
if (order == null) throw new BizException("订单不存在");
// 核心幂等逻辑:状态前置 + 版本号
int rows = orderMapper.updateOrderStatusPaid(orderId, order.getVersion());
if (rows == 0) {
// 影响行数为0,两种情况:1.已支付(幂等) 2.版本冲突(并发)
Order latest = orderMapper.selectByOrderId(orderId);
if ("PAID".equals(latest.getStatus())) {
// 幂等返回成功
return;
}
throw new BizException("订单状态异常,请刷新重试");
}
// rows == 1 正常支付成功
}💡 亮点解读:where 条件同时用 status 做业务幂等、version 做并发控制,一次数据库交互解决两个问题。
🧩 面试官:
好,代码很扎实。那结合这些代码,你帮我整理下 —— 保证接口幂等会遇到哪些 技术难点,以及对应的 解决方案?
候选人:(思考片刻,清晰列出)
| 难点 | 痛点描述 | 解决方案 | 对应代码/技术 |
|---|---|---|---|
| 🔴 并发原子性 | “查询再判断”存在时间窗口,并发时可能都判为“未操作” | 1. 数据库唯一索引“乐观插入” 2. Redis Lua 脚本原子执行 3. 状态更新带上条件 | 刚刚写的 Lua 脚本、WHERE 条件更新 |
| 🟠 全局唯一 ID 生成 | 高并发下要求快速、有序、无碰撞,雪花时钟回拨引发重复 | 1. 雪花算法 + 回拨容错(抛异常/等时钟/备用机器) 2. 号段模式(Leaf)独立于时间 3. 数据库自增 + 拼接业务前缀 | 订单号生成器,order_id 唯一索引 |
| 🟡 分库分表后唯一约束失效 | 唯一索引只能保证单库单表内唯一,跨分片可能重复 | 1. 幂等键必须包含分片键 2. 全局发号器统一分配 ID 3. 在入口层按分片键路由到固定库表 | 如 order_id 的前几位作为分片路由键 |
| 🟢 高并发下的性能瓶颈 | 唯一索引成为热点,分布式锁串行化降低吞吐 | 1. 对唯一键尾号分片,打散热点 2. 布隆过滤器前置过滤大部分重复请求 3. 非金融业务可降级为 Redis 判重(最终一致) | Redission 布隆过滤器、唯一索引拆分 |
| 🔵 上下游幂等键传递 | 调用方重试时没带同一个幂等键,我方幂等形同虚设 | 1. 接口协议强制要求 idempotent-key 头2. 网关层校验,无幂等键直接拒绝 3. 调用链透传,保证键一致 | 拦截器统一提取 X-Idempotent-Key |
| 🟣 业务复杂编排下的幂等 | 一个操作涉及多表、多服务,单一方案不够用 | 1. 入口分布式锁串联整个业务块 2. 各子服务内部再用唯一索引/状态机兜底 3. 异步情况借助事务消息表保证最终一致 | Redisson 锁 + 各表唯一键 |
