如何保证接口幂等

如何保证接口幂等

先明确核心概念

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

为什么必须解决:

  • 网络抖动导致的重复请求
  • 前端按钮重复点击
  • 消息队列消息重复消费
  • 服务重试机制(如 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();
    }
}

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 锁 + 各表唯一键

公众号"Rain的Java大神之路"

个人博客"www.javadashen.com"

相关推荐
Rain的Java大神实战圈20 小时前
PC版网站被狂刷怎么处理
场景设计题
Rain的Java大神实战圈2 天前
接口被狂刷怎么处理
场景设计题
Rain的Java大神实战圈3 天前
线上在Docker中跑MySQL会怎样
场景设计题
Rain的Java大神实战圈4 天前
Docker搭建Redis集群完全指南
场景设计题
Rain的Java大神实战圈7 天前
短信接口被狂刷怎么处理
场景设计题
Rain的Java大神实战圈8 天前
如何避免订单重复提交
场景设计题
Rain的Java大神实战圈9 天前
MySQL误删数据如何恢复
场景设计题
Rain的Java大神实战圈10 天前
介绍一下分布式事务
场景设计题
Rain的Java大神实战圈11 天前
频繁FullGC如何优化
场景设计题