跳到主要内容

幂等性设计

问题

什么是接口幂等性?为什么需要幂等性?有哪些通用的幂等方案?

答案

什么是幂等性

幂等性是指:同一个操作执行一次和执行多次的效果完全一样

f(f(x)) = f(x)
HTTP 方法是否幂等说明
GET✅ 幂等查询操作,不修改数据
PUT✅ 幂等替换资源(全量更新)
DELETE✅ 幂等删除资源(删除已删除的资源无副作用)
POST❌ 非幂等创建资源(每次创建新的)
PATCH❌ 非幂等部分更新(取决于实现)

为什么需要幂等性

场景说明
前端重复提交用户快速点击按钮多次
网络超时重试请求成功但响应超时,客户端重试
MQ 重复消费消息队列 at-least-once 投递
微服务调用重试Feign/RestTemplate 失败重试

幂等方案

方案一:唯一请求 ID(Token 机制)

Token 幂等拦截器
// 获取 Token
@GetMapping("/token")
public String getToken() {
String token = UUID.randomUUID().toString();
redis.opsForValue().set("idempotent:" + token, "1", 10, TimeUnit.MINUTES);
return token;
}

// 校验 Token(Lua 脚本保证原子性)
public boolean checkToken(String token) {
String script = """
if redis.call('get', KEYS[1]) then
return redis.call('del', KEYS[1])
else
return 0
end
""";
Long result = redis.execute(
new DefaultRedisScript<>(script, Long.class),
List.of("idempotent:" + token));
return result != null && result > 0;
}

方案二:数据库唯一约束

唯一索引防重复
// 订单表 order_no 字段唯一索引
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
log.info("重复订单,忽略: {}", order.getOrderNo());
return existingOrder; // 返回已有订单
}

方案三:乐观锁(版本号)

带版本号更新
UPDATE account 
SET balance = balance - 100, version = version + 1
WHERE user_id = 1001 AND version = 5;
-- 影响行数为 0 说明已被其他请求更新

方案四:状态机

状态流转幂等
// 只有 "待支付" 才能更新为 "已支付"
int rows = orderMapper.updateStatus(orderId, "PAID", "UNPAID");
if (rows == 0) {
// 订单不是"待支付"状态,说明已处理过
log.info("订单已处理,跳过: {}", orderId);
}

方案五:防重表

独立防重表
// 在同一个事务中插入防重表和执行业务
@Transactional
public void process(String bizId) {
// 插入防重记录(bizId 唯一索引)
idempotentMapper.insert(new IdempotentRecord(bizId));
// 执行业务逻辑
doBusiness();
}
// 重复请求会因唯一索引冲突而回滚整个事务

方案选择

场景推荐方案
创建类接口(下单)Token + 数据库唯一约束
更新类接口(扣款)乐观锁 / 状态机
消息消费唯一消息ID + Redis/DB 去重
通用防重表(最稳妥)

常见面试问题

Q1: 如何设计一个幂等接口?

答案

以"创建订单"为例:

  1. 前端进入下单页面时,先请求获取唯一 Token
  2. 提交订单时携带 Token,后端通过 Redis 原子删除校验
  3. 数据库订单表的 order_no 加唯一索引兜底
  4. 返回结果给前端(成功或重复提示)

两层防护:Redis Token 做快速判断,数据库唯一约束做兜底。

Q2: Token 方案和数据库唯一约束有什么区别?

答案

对比Token 方案数据库唯一约束
时机请求开始前拦截写入数据时拦截
性能高(Redis 内存)低(数据库 IO)
可靠性依赖 Redis强一致(数据库事务)
适用所有接口有唯一业务字段的场景

最佳实践是两者结合:Token 快速拦截 + 唯一约束兜底。

Q3: 乐观锁适合高并发吗?

答案

高并发场景下乐观锁冲突率高,大量请求返回"更新失败",体验差。

优化方案:

  1. 重试机制:冲突后重新读取最新版本再试(重试次数有限)
  2. 分段锁:将热点数据拆分(如库存分 10 个桶),降低冲突概率
  3. 降级到悲观锁:重试多次失败后使用 SELECT ... FOR UPDATE

相关链接