幂等性设计
问题
什么是接口幂等性?为什么需要幂等性?有哪些通用的幂等方案?
答案
什么是幂等性
幂等性是指:同一个操作执行一次和执行多次的效果完全一样。
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: 如何设计一个幂等接口?
答案:
以"创建订单"为例:
- 前端进入下单页面时,先请求获取唯一 Token
- 提交订单时携带 Token,后端通过 Redis 原子删除校验
- 数据库订单表的 order_no 加唯一索引兜底
- 返回结果给前端(成功或重复提示)
两层防护:Redis Token 做快速判断,数据库唯一约束做兜底。
Q2: Token 方案和数据库唯一约束有什么区别?
答案:
| 对比 | Token 方案 | 数据库唯一约束 |
|---|---|---|
| 时机 | 请求开始前拦截 | 写入数据时拦截 |
| 性能 | 高(Redis 内存) | 低(数据库 IO) |
| 可靠性 | 依赖 Redis | 强一致(数据库事务) |
| 适用 | 所有接口 | 有唯一业务字段的场景 |
最佳实践是两者结合:Token 快速拦截 + 唯一约束兜底。
Q3: 乐观锁适合高并发吗?
答案:
高并发场景下乐观锁冲突率高,大量请求返回"更新失败",体验差。
优化方案:
- 重试机制:冲突后重新读取最新版本再试(重试次数有限)
- 分段锁:将热点数据拆分(如库存分 10 个桶),降低冲突概率
- 降级到悲观锁:重试多次失败后使用
SELECT ... FOR UPDATE