接口幂等性:同一个请求调用一次和调用多次的效果完全相同,不会因为重复调用导致数据异常或业务错误 🎯
// 先查询是否已处理
Order order = orderMapper.selectByRequestId(requestId);
if (order != null) {
return Result.success("订单已处理");
}
// 执行业务逻辑
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
// 唯一索引冲突,说明已处理
return Result.success("订单已处理");
}
DEL命令原子性判断并删除 TokenRLock lock = redissonClient.getLock("order:" + requestId);
try {
if (lock.tryLock(0, 10, TimeUnit.SECONDS)) {
// 执行业务逻辑
} else {
return Result.error("请求处理中,请稍后再试");
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
UPDATE `order` SET status = 2 WHERE id = #{orderId} AND status = 1;
| 方案 | 实现复杂度 | 性能 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|---|
| 唯一索引 | 低 ⭐ | 高 ⭐⭐⭐⭐⭐ | 单库单表插入操作 | 实现简单,可靠性高 | 只能防插入,无法防更新 |
| 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 倍以上。
通过注解统一处理幂等性,零侵入业务代码
// 自定义幂等注解
@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();
}
技术亮点:
/**
* 订单支付幂等更新
*/
@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 天数据,历史数据归档到离线存储 |
问题场景:
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 业务”打补丁。
好,那实战里你会用哪些方案来保证幂等?一个一个说,并讲清楚适用场景和坑。
我一般会按业务场景分层选型,主要有四招:
怎么做:提前生成一个全局唯一的业务 ID(如订单号),在数据库里建唯一约束。
UNIQUE KEY `uk_order_id` (`order_id`)
重复插入直接捕获 DuplicateKeyException,返回成功。
打断一下,你说的 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 的抢占方式,把存在性判断和写入合成一步。
UPDATE order SET status = 'PAID' WHERE order_id = 123 AND status = 'UNPAID';
第一次影响行数为1,第二次为0,业务方根据影响行数判断是否重复。
lock:submit_order:userId+cartId)为 key 用 Redis 加锁,抢到锁才执行业务逻辑。不错,四种方案都很清晰。那你在真实项目中,会不会组合使用?
会。比如支付回调,我会这样组合:
out_trade_no 做唯一约束,兜底。out_trade_no 加锁,防止并发回调。三重保险,万无一失。
来,我把你刚才说的整理成一张表,你看是不是这个意思? 🧾
| 方案 | 适用场景 | 幂等粒度 | 复杂度 | 可靠性 | 典型坑点 |
|---|---|---|---|---|---|
| 🗄️ 唯一索引 | 插入操作 | 行 | ⭐ | ⭐⭐⭐⭐⭐ | 分库分表全局唯一路由 |
| 🎫 Token 令牌 | 表单防抖 | 前端会话 | ⭐⭐ | ⭐⭐⭐ | 前后端配合 & 过期 |
| 🔄 状态机 | 状态流转 | 业务状态 | ⭐⭐ | ⭐⭐⭐⭐ | 需乐观锁防并发覆盖 |
| 🔒 分布式锁 | 复杂业务聚合 | 业务标识 | ⭐⭐⭐ | ⭐⭐⭐ | 死锁、性能瓶颈 |
没错,面试官这张表总结得很到位 👍
好,那再问你几个细节,看看你的思考深度。
这俩都是好问题。
再问一个,你在对上游调用方提要求时,怎么保证他们重试时能幂等?
我会强制要求调用方 每次重试都带同样的幂等键(比如 bizId),而且最好由调用方生成,必须保证全局唯一。并且这个键要贯穿整个调用链,落在我的唯一索引或者锁的 key 上。如果调用方不能保证幂等键一致,那我这边的幂等就形同虚设。
非常好。最后一个问题,唯一索引在高并发写入下可能成为热点,你怎么破?
可以考虑几个方向:
可以,今天这个问题你答得很全面。从概念到落地,再从坑点到深度优化,都涉及到了,而且能根据不同场景灵活组合方案,思路很清晰。特别是你提到的“乐观插入”和“要求上游传递幂等键”,都是大厂实战里总结出的铁律 🛡️
来,先亮代码
我挑两个最经典的方案,你现场把核心代码写出来。注意要体现 原子性、并发安全 这些技术亮点。
(边写边说)这是前后端配合的典型方案。核心在于 校验和删除必须原子,所以我会用一个 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 调用层封装:
/**
* 亮点:利用 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. 全局发号器统一分配 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”