Java后端面试常见题:接口幂等性设计

总结摘要
接口幂等性设计常见解决方案

什么是幂等性?

简单来说:无论一个操作执行一次还是多次,其产生的副作用和结果都是一样的。

  • 查询:天然幂等。
  • 删除:通常幂等(删除同一个ID多次结果一样)。
  • 新增/修改:通常不幂等(需要处理)。

核心解决方案

1. 数据库唯一索引 —— 最稳健、最常用

这是解决插入操作幂等性最简单且最有效的方式。

  • 原理:利用数据库的 ACID 特性,对业务上的唯一标识(如 order_id,或者 user_id + activity_id 的组合)建立唯一索引。
  • 流程
    1. 客户端请求接口。
    2. 服务端执行 INSERT 操作。
    3. 如果数据库中已存在该数据,数据库会抛出 DuplicateKeyException
    4. 服务端捕获该异常,直接返回“重复请求”或“成功”给客户端。
  • 优点:实现简单,完全依赖数据库,可靠性高。
  • 缺点:依赖数据库存储,如果是分库分表场景,需要确保唯一索引包含分片键。
  • 适用场景:防止产生重复数据(如:下单、支付回调)。

2. token 机制(防重复提交) —— 通用性强

这是解决前端重复提交(如用户快速点击多次按钮)最主流的方案。

  • 原理一锁二删三执行
    1. 获取 Token:客户端在点击修改或新增信息时进入表单填写界面,此时客户端会调用服务端接口获取一个 Token(服务端将 Token 存入 Redis,设置短过期时间)。
    2. 携带 Token 请求:客户端在点击提交时,表单或header中携带该 Token。
    3. 校验并删除:服务端收到请求后,去 Redis 中删除该 Token。
      • 如果删除成功(Redis 返回 1),说明是第一次请求,执行业务逻辑。
      • 如果删除失败(Redis 返回 0,因为 Token 不存在或已被删),说明是重复请求,直接报错。
  • 关键点获取和删除必须是原子操作(虽然 Redis 的单线程特性保证了 DEL 是原子的,但更严谨的做法是用 Lua 脚本保证“验证+删除”的原子性,防止高并发下两个线程同时验证通过)。
  • 适用场景:表单重复提交、按钮重复点击。

3. 乐观锁 —— 适合更新场景

利用版本号机制,不阻塞线程,通过 CAS 思想解决并发。

  • 原理:在数据库表中增加一个 version 字段。
  • SQL 示例
    1
    2
    3
    
    UPDATE account 
    SET balance = balance - 100, version = version + 1 
    WHERE id = 1 AND version = old_version;
  • 流程
    1. 查询数据,得到 old_version
    2. 执行更新时,带上 version 条件。
    3. 检查影响行数。如果为 1,成功;如果为 0,说明数据已被修改,本次请求失败(或进行重试)。
  • 优点:不需要加锁,吞吐量高。
  • 缺点:高并发场景下,大量请求会失败,需要配合重试机制。
  • 适用场景:扣减库存、更新余额。

4. 状态机幂等 —— 利用业务状态

利用业务状态流转的逻辑约束来保证幂等。

  • 原理:更新数据时,带上当前状态作为条件。
  • SQL 示例: 订单状态流转:待支付 -> 已支付 -> 发货
    1
    2
    3
    
    UPDATE orders 
    SET status = 'PAID' 
    WHERE id = 123 AND status = 'UNPAID';
  • 流程
    1. 第一次请求:状态是 UNPAID,SQL 执行成功,影响行数 1。
    2. 第二次请求(重复):此时状态已经是 PAID,SQL 条件 status='UNPAID' 不满足,影响行数 0,操作失败。
  • 优点:结合业务紧密,不仅解决了幂等,也保证了业务逻辑的严谨性。
  • 适用场景:订单状态流转、任务处理状态更新。

5. 分布式锁 —— 最通用的并发控制

当上述方法都不适用,或者业务逻辑非常复杂(涉及多个微服务、多张表)时,可以使用分布式锁。

  • 原理:请求进来先去抢锁,抢到锁的执行业务,没抢到的直接返回或等待。
  • 实现:Redis (SETNX + 过期时间) 或 Zookeeper。
  • 流程
    1. 请求 ID 作为 Key。
    2. SETNX Key UniqueValue EX 30
    3. 如果设置成功,执行业务逻辑,最后释放锁(Lua 脚本保证对比 Value 删除)。
    4. 如果设置失败,说明请求正在处理中,返回“重复请求”。
  • 注意分布式锁虽然能解决并发问题,但它不能完全解决“网络超时重试”导致重复执行的问题,除非配合 Token 或唯一索引。通常用于防并发(防止多个请求同时操作),而防重复提交(同一个请求发两次)通常用 Token 或唯一索引更好。

6. 下游传递唯一序列号

在微服务调用链中,上游生成唯一的 BizID,透传给下游。

  • 原理
    1. 上游请求生成全局唯一 ID。
    2. 调用下游时,将 ID 放在 Header 或 Body 中。
    3. 下游服务在执行业务前,先去 Redis(或 DB)检查该 ID 是否已处理。
    4. 如果未处理,执行业务并标记 ID 为“已处理”。
  • 适用场景:微服务链路较长,涉及 MQ 或 RPC 调用的幂等控制(如支付回调通知)。

面试加分项:对比与总结

在回答时,如果能总结出选型标准,会让面试官觉得你非常有经验:

方案适用场景实现复杂度性能特点
数据库唯一索引插入数据、产生新记录最强兜底方案,利用DB特性
Token 机制表单提交、按钮防抖需要两次交互,适合前端防重
乐观锁更新数据、库存扣减适合读多写少,无锁并发
状态机订单状态流转、任务状态业务强相关,最自然
分布式锁高并发抢购、复杂业务逻辑防止并发冲突,解决“超卖”

面试回答话术模板

“关于接口幂等性,我在项目中通常根据业务场景分层处理。

首先是预防,在前端层面,点击后按钮置灰,但这不可靠,所以后端必须做。

针对表单提交这种新增操作,我最常用的是 Token 机制,用户先获取 Token 再提交,服务端通过 Redis 校验并原子删除 Token,防止重复提交。

针对插入数据库的核心数据(如订单),我会配合数据库唯一索引做兜底,即使 Redis 挂了或者代码逻辑有 bug,数据库也能拦截重复数据。

针对库存扣减这种更新操作,我通常采用乐观锁(版本号)或者状态机(条件更新),利用 SQL 的 WHERE version = old_version 来保证只有一条能更新成功。

如果涉及到微服务间调用,我会将上游生成的 RequestID 传递下去,下游在 Redis 中记录该 ID 是否已处理,来实现全链路幂等。”