如何保证接口幂等

如何保证接口幂等

先明确核心概念

接口幂等性:同一个请求调用一次和调用多次的效果完全相同,不会因为重复调用导致数据异常或业务错误 🎯

为什么必须解决:
  • 网络抖动导致的重复请求
  • 前端按钮重复点击
  • 消息队列消息重复消费
  • 服务重试机制(如 Feign、Dubbo 重试)
  • 分布式系统中的数据一致性问题

主流解决方案(按实现复杂度排序)

1. 数据库唯一索引(最基础最常用)✅
  • 原理:利用数据库唯一约束防止重复插入
  • 实现:在业务表中添加request_id唯一索引
  • 适用场景:插入类操作(订单创建、支付记录)
  • 代码示例
java 复制代码
// 先查询是否已处理
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 框架)
  • 代码示例
java 复制代码
RLock lock = redissonClient.getLock("order:" + requestId);
try {
    if (lock.tryLock(0, 10, TimeUnit.SECONDS)) {
        // 执行业务逻辑
    } else {
        return Result.error("请求处理中,请稍后再试");
    }
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

整理了面试真题、每日技术知识点、系统学习路线,√X搜「Rain的Java 大神之路」

每天拆一个知识点,陪你悄悄变强,有空来坐坐。


4. 状态机幂等(业务层控制)📊
  • 原理:利用业务状态的单向流转特性
  • 示例:订单状态流转:待支付 → 已支付 → 已发货 → 已完成
  • 实现:更新时添加状态条件
sql 复制代码
UPDATE `order` SET status = 2 WHERE id = #{orderId} AND status = 1;
5. 全局唯一 ID + 防重表 🆔
  • 原理:单独建立一张防重表,存储已处理的请求 ID
  • 适用场景:跨服务、跨数据库的复杂业务
  • 优点:不侵入业务表结构

方案对比表 📋

方案 实现复杂度 性能 适用场景 优点 缺点
唯一索引 低 ⭐ 高 ⭐⭐⭐⭐⭐ 单库单表插入操作 实现简单,可靠性高 只能防插入,无法防更新
Token 机制 中 ⭐⭐ 中 ⭐⭐⭐ 前端提交类操作 提前拦截,不污染业务表 需要前端配合,有网络开销
分布式锁 中 ⭐⭐ 中 ⭐⭐⭐ 大部分业务场景 通用性强 存在死锁风险,性能一般
状态机 低 ⭐ 高 ⭐⭐⭐⭐⭐ 有状态流转的业务 性能最好,完全贴合业务 仅适用于状态明确的场景
防重表 高 ⭐⭐⭐ 低 ⭐⭐ 跨服务复杂业务 不侵入业务表 增加数据库压力

不同场景的最佳实践 💡

  1. 简单插入操作 :优先使用数据库唯一索引
  2. 前端表单提交 :使用Token 机制 + 按钮置灰
  3. 消息队列消费 :使用唯一索引 + 消费幂等
  4. 更新操作 :优先使用状态机 + 乐观锁
  5. 复杂分布式业务 :使用全局唯一 ID + 分布式锁

核心总结与注意事项 🎯

  1. 幂等性是分布式系统的基础要求,必须在设计阶段考虑
  2. 没有万能方案,需要根据业务场景选择合适的组合
  3. 优先使用数据库层面的保证,可靠性最高
  4. 避免过度设计,简单场景不要用复杂方案
  5. 所有外部调用都必须考虑幂等性(第三方接口、消息队列)

核心代码与技术亮点实现 🚀

1 Redis Token 机制(原子操作版,技术亮点)

错误写法(90% 面试者会踩坑):先查后删存在竞态条件

java 复制代码
// 错误:两个请求同时查到Token存在,都会通过校验
if (redisTemplate.hasKey(token)) {
    redisTemplate.delete(token);
    // 执行业务逻辑
}

正确写法(Lua 脚本保证原子性)

java 复制代码
/**
 * 原子校验并删除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 全局幂等切面(架构级亮点)

通过注解统一处理幂等性,零侵入业务代码

java 复制代码
// 自定义幂等注解
@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 状态机 + 乐观锁(高并发更新最佳实践)
java 复制代码
/**
 * 订单支付幂等更新
 */
@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. 消息体携带全局唯一 messageId2. 消费前先查防重表3. 消费成功后更新防重表状态 防重表与业务表在同一事务,保证原子性
跨服务幂等性 分布式事务中,一个服务成功,另一个失败,重试时导致数据不一致 使用全局事务 ID + 各服务本地幂等 整个调用链传递同一个 XID,每个服务基于 XID 做幂等校验
高并发下防重表压力 防重表数据量过大,查询性能下降 1. 按时间分表2. 定期归档历史数据3. 冷热数据分离 保留最近 30 天数据,历史数据归档到离线存储
1.难点详解:重复请求返回值一致性问题(高频面试追问)

问题场景

  1. 前端提交创建订单请求
  2. 后端处理成功,写入数据库
  3. 网络超时,前端未收到返回结果
  4. 用户点击重试,后端返回 "重复请求"
  5. 前端显示 "下单失败",但实际订单已创建
解决方案代码:
java 复制代码
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 方法里面,哪些是天然幂等的,哪些不是?

候选人:

GETPUTDELETE 按规范都是幂等的,POST 不是。

但现实业务里,咱们大部分写操作恰好用的都是 POST(创建订单、支付等),所以幂等方案基本都是给"非幂等的 POST 业务"打补丁。

🔑 第二问:方案深挖

面试官:

好,那实战里你会用哪些方案来保证幂等?一个一个说,并讲清楚适用场景和坑。

候选人:

我一般会按业务场景分层选型,主要有四招:

1️⃣ 数据库唯一索引 ------ 最硬的兜底方案

怎么做:提前生成一个全局唯一的业务 ID(如订单号),在数据库里建唯一约束。

sql 复制代码
UNIQUE KEY `uk_order_id` (`order_id`)

重复插入直接捕获 DuplicateKeyException,返回成功。

  • 适用:新记录创建,比如下单、注册。
  • :只防插入,不防更新(比如累加余额)。分库分表时要保证全局唯一路由。

2️⃣ Token 机制 ------ 防前端/客户端重复提交

  • 流程:服务端先发一个 Token 存 Redis,客户端提交时带上。后端用原子操作(如 Lua 脚本)校验并删除 Token,删除成功才放行。
  • 适用:表单重复提交、防止用户手抖连续点击。
  • :前后端需配合,Token 有过期时间,需要考虑删除操作的原子性。
面试官:

打断一下,你说的 Redis 原子校验具体怎么实现?如果只是先 getdel,并发会有问题。

候选人:

对,必须保证"判断+删除"是原子的。我会用 Lua 脚本:

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️⃣ 状态机约束 ------ 最贴合业务的幂等

  • 怎么做:在更新时带上状态前置条件。
sql 复制代码
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 上。如果调用方不能保证幂等键一致,那我这边的幂等就形同虚设。

面试官:

非常好。最后一个问题,唯一索引在高并发写入下可能成为热点,你怎么破?

候选人:

可以考虑几个方向:

  1. 对唯一键做 分级拆分,比如订单号尾号分片,降低单索引热度。
  2. 引入 布隆过滤器 前置拦截绝大部分重复请求,挡掉80%的无效流量。
  3. 将强一致的唯一索引 降级 为配合 Redis 判重的最终一致方案(牺牲极小概率的重复,换性能),不过只适合非金融类业务。

🎯 终面评语

面试官:

可以,今天这个问题你答得很全面。从概念到落地,再从坑点到深度优化,都涉及到了,而且能根据不同场景灵活组合方案,思路很清晰。特别是你提到的"乐观插入"和"要求上游传递幂等键",都是大厂实战里总结出的铁律 🛡️

👨‍💻 面试官:

来,先亮代码

我挑两个最经典的方案,你现场把核心代码写出来。注意要体现 原子性、并发安全 这些技术亮点。

🎫 场景一:Token 机制防重复提交(Redis + Lua 原子校验)

候选人:

(边写边说)这是前后端配合的典型方案。核心在于 校验和删除必须原子,所以我会用一个 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
end

Java 调用层封装:

java 复制代码
/**
 * 亮点:利用 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 实现:

xml 复制代码
<!-- 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 层调用与判断:

java 复制代码
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. 全局发号器统一分配 ID3. 在入口层按分片键路由到固定库表 order_id 的前几位作为分片路由键
🟢 高并发下的性能瓶颈 唯一索引成为热点,分布式锁串行化降低吞吐 1. 对唯一键尾号分片,打散热点2. 布隆过滤器前置过滤大部分重复请求3. 非金融业务可降级为 Redis 判重(最终一致) Redission 布隆过滤器、唯一索引拆分
🔵 上下游幂等键传递 调用方重试时没带同一个幂等键,我方幂等形同虚设 1. 接口协议强制要求 idempotent-key 头2. 网关层校验,无幂等键直接拒绝3. 调用链透传,保证键一致 拦截器统一提取 X-Idempotent-Key
🟣 业务复杂编排下的幂等 一个操作涉及多表、多服务,单一方案不够用 1. 入口分布式锁串联整个业务块2. 各子服务内部再用唯一索引/状态机兜底3. 异步情况借助事务消息表保证最终一致 Redisson 锁 + 各表唯一键

整理了面试真题、每日技术知识点、系统学习路线,√X搜「Rain的Java 大神之路」

每天拆一个知识点,陪你悄悄变强,有空来坐坐。

相关推荐
木木爱研究15 分钟前
elpis-里程碑四-基于Vue完成动态组件库建设
前端·后端
一水行15 分钟前
博客评论系统搭建回顾
javascript·后端
吴声子夜歌15 分钟前
Java——数组和集合(一)
java·开发语言
Jesse_EC17 分钟前
一个看不见的字符——CRLF 如何让我的 Docker 初始化脚本"静默失踪"
java·后端
深念Y18 分钟前
Nuxt 项目 Docker 构建:从 pnpm+node 迁移到全 bun
java·前端·docker
2601_9620762518 分钟前
大数据-267 实时数仓 - ODS Lambda架构 Kappa架构 核心思想
大数据·架构
学长毕业设计22 分钟前
基于SpringBoot的社区鲜奶订购系统的设计与实现(源码+文档+讲解视频)
java·spring boot·后端
2601_9621741722 分钟前
小试牛刀-SpringBoot集成SOL链
数据库·spring boot·后端
郝学胜_神的一滴26 分钟前
C++11 工程级应用 06:自己造一个类似Python的Range迭代器
c++·后端