接口幂等性:那个让我在凌晨三点背锅的bug,终于被我说清楚了
先讲个故事。
那年我负责支付系统,有天晚上突然警报狂响——某个用户收到了两笔重复扣款。用户炸了,我也炸了。查了一圈,发现是客户端网络超时,用户重试了支付请求,结果后端处理了两次,扣了两次钱。
那一刻我意识到:我写的接口,不是「幂等」的。
这个问题,后来我发现,几乎每个后端开发都踩过。有人轻描淡写说「加个唯一键就好了」。说这话的人,大概没在生产环境里真正处理过复杂业务。
今天就把接口幂等性这个话题,彻底讲透。
什么是幂等性?说人话
幂等(Idempotent)这个概念来自数学,指的是 f(f(x)) = f(x)。套到接口上,意思是:同一个请求,你执行一次和执行多次,效果是一样的。
拿HTTP GET来说,它是天然幂等的——你请求一次用户信息,请求十次用户信息,不会改变任何东西。但POST呢?你提交一次订单,系统就多一笔订单;提交十次,就多了十笔。
所以「幂等性」本质上是在问:你的接口,是否能扛住重复调用?
在什么场景下会重复调用?
- 用户手抖,多点了一下提交按钮
- 网络超时,客户端自动重试
- 消息队列消费失败,消息被重复投递
- 前端 bug,导致请求发了两遍
- 别人的服务调用你,重试了你的接口
以上场景,我在生产环境全部遇到过。你呢?
六种幂等策略,从入门到精通
1. 唯一键/唯一索引:最简单的防御
这是大多数人想到的第一个方案。给表加个唯一索引,或者在业务层面用唯一键约束。
-- 订单表用业务ID作为唯一键
CREATE UNIQUE INDEX idx_biz_id ON orders(biz_id);
-- 插入时
INSERT INTO orders (biz_id, amount, status)
VALUES ('ORDER_20260101_001', 100, 'PAID')
ON DUPLICATE KEY UPDATE status = 'PAID';
优点:简单粗暴,数据库帮你兜底。
缺点:并发插入时可能抛异常,你需要捕获并正确处理。而且有些业务场景,根本没有天然的唯一键。
吐槽:很多人以为加了唯一索引就高枕无忧了,结果并发请求来了,数据库抛了个 DuplicateKeyException,他们又手忙脚乱地 catch 住——然后 catch 块里啥都没干,把错误吞了。这不叫处理,这叫埋雷。
2. 悲观锁:老老实实排队
在读数据之前加锁,把并发变成串行。
SELECT * FROM products WHERE id = ? FOR UPDATE;
这是Oracle/MySQL里常用的行锁语法。锁住了,别人就只能等着。
优点:简单,不会超卖。
缺点:性能差,高并发下数据库连接分分钟被打满。而且锁要尽量短,别在锁里面干 IO 操作。
我曾经见过一个接口,FOR UPDATE 之后去调第三方 API 等待响应,第三方接口慢了 3 秒——然后数据库连接池被耗光了,整个系统炸了。全公司开会到凌晨两点复盘。
3. 乐观锁 + 版本号:大多数场景的最优解
给表加个 version 字段,更新时检查版本号。
-- 读取
SELECT id, amount, version FROM account WHERE user_id = ?;
-- 更新时加条件
UPDATE account
SET amount = amount - 100, version = version + 1
WHERE user_id = ? AND version = 1;
如果 version 不匹配,说明有人改过了,更新 0 行,你自己去查最新数据重试。
优点:性能好,不阻塞读操作。
缺点:重试逻辑需要自己写,要控制好重试次数和熔断。
4. 分布式锁:跨服务的核武器
当请求分布在多台机器上,数据库锁就不够用了,你需要分布式锁。
// Redis 实现分布式锁
String lockKey = "pay:lock:" + orderId;
String lockValue = UUID.randomUUID().toString();
// 加锁,设置过期时间防止死锁
Boolean acquired = redis.set(lockKey, lockValue,
Duration.ofSeconds(30), SetOption.SET_IF_ABSENT);
if (Boolean.TRUE.equals(acquired)) {
try {
// 执行业务逻辑
doPayment(orderId);
} finally {
// 释放锁(只能释放自己的锁)
if (lockValue.equals(redis.get(lockKey))) {
redis.del(lockKey);
}
}
} else {
// 没拿到锁,说明正在处理中
throw new BusinessException("订单正在处理中");
}
这里有个坑:释放锁的时候,必须检查 value 是否匹配,否则会误删别人的锁。用 Redis 的话,建议用 Redisson,它封装好了。
我见过有人用 Redis 的 setnx 做分布式锁,然后压根没设置过期时间。结果服务挂了,锁永远不会被释放,整个系统就卡死了,直到运维手动 DEL。这事发生在凌晨三点。
5. 幂等Token:接口层面的防重放
这是最通用、最干净的方案,适合几乎所有场景。
流程是这样的:
- 客户端先申请一个幂等Token
- 服务端把这个 token 存进 Redis,过期时间设长一点
- 客户端带着这个 token 发业务请求
- 服务端检查 Redis 里这个 token 是否存在:
- 不存在——存入 token,执行业务逻辑,返回成功
- 存在——说明重复调用,直接返回上次的结果
// 第一步:申请 token
String idempotentKey = "idempotent:" + UUID.randomUUID().toString();
redis.setex(idempotentKey, 3600, "PROCESSING");
// 第二步:带着 token 请求业务接口
// 服务端伪代码
public Result pay(PayRequest request) {
String key = "idempotent:" + request.getIdempotentToken();
String status = redis.get(key);
if ("PROCESSING".equals(status)) {
return Result.retryLater(); // 正在处理,稍后重试
}
if ("SUCCESS".equals(status)) {
String cachedResult = redis.get("result:" + key);
return JSON.parse(cachedResult); // 直接返回缓存结果
}
// 标记处理中
redis.setex(key, 3600, "PROCESSING");
try {
Result result = doPayment(request);
// 存结果
redis.setex("result:" + key, 3600, JSON.stringify(result));
// 标记成功
redis.setex(key, 3600, "SUCCESS");
return result;
} catch (Exception e) {
// 失败了就删除 token,允许重试
redis.del(key);
throw e;
}
}
这套方案牛在哪儿?业务逻辑和幂等逻辑完全解耦。不管你是什么业务,支付、退款、订单创建,都可以套用这个模式。
6. 天然幂等设计:从源头消灭问题
最高级的做法,是让业务逻辑本身就具备幂等性。
比如:扣款接口不传「扣多少钱」,而是传「扣到哪个目标值」。
// 反面例子:传金额,容易重复扣
updateBalance(userId, -100);
// 正面例子:传目标值,不会扣多
updateBalanceTo(userId, 9000); // 不管你扣了多少次,最终余额是 9000
再比如:不是「增加库存」,而是「设置库存为某值」。当然,这需要对业务逻辑做比较大的改造。
我的实战经验总结
说了这么多,给个实战清单:
- 查询类接口:天然幂等,不用管
- 单条数据新增:优先用唯一索引 + ON DUPLICATE KEY UPDATE
- 更新操作:优先用乐观锁(版本号),并发不高可以用悲观锁
- 删除操作:天然幂等,删一次和删十次都是删
- 复杂业务(支付/订单):分布式锁 + 幂等Token双保险
- MQ 消费者:消息去重 + 消费记录表,缺一不可
还有一个血泪教训:幂等性一定要在设计阶段就定好,不要等项目上线了再打补丁。上线后再补救,代价是十倍起步。
写在最后
回到开头那个故事。那个重复扣款的 bug,最后怎么修的?分布式锁 + 幂等Token,凌晨四点才上线。
第二天用户客服打电话来道歉,我心想:早干嘛去了。
幂等性这个东西,面试能背八股文的人很多,真正能在生产环境写对的很少。希望看完这篇,你能是那个少数人。
有问题欢迎留言,我不一定回,但一定会看。