先说个真实的故事
去年双十一,我朋友在某电商平台抢购,点了支付按钮后页面卡住了。他心想"是不是没支付成功?"又点了一次。结果第二天发现,同一个订单被扣了两次钱。客服说要3-5个工作日才能退款,他当场炸了。
这就是典型的"幂等性"问题。今天咱们就用大白话聊聊,这个听起来很学术的词,到底是啥,怎么解决。
什么是幂等性?说人话!
数学定义我就不扯了,直接上人话版本:不管你重复操作多少次,结果都应该一样。
举几个例子你就懂了:
幂等的操作:
- 删除一个文件:删一次是删除,删100次还是删除,文件该没就没了
- 设置用户年龄为25岁:设置一次是25,设置100次还是25
- 查询订单详情:查多少次都是同一个结果
不幂等的操作:
- 余额减100:第一次减变900,第二次减变800,第三次减变700...
- 发送短信验证码:每次发送都会产生一条新的短信
- 创建订单:点一次创建一个订单,点10次就是10个订单
看出区别了吗?幂等的操作是"覆盖式"的,不幂等的操作是"累加式"的。
为什么分布式系统特别需要幂等性?
在单机系统里,用户点一下按钮,要么成功要么失败,清清楚楚。但在分布式系统里,事情就复杂了:
- 网络抖动:请求发出去了,但响应没回来,客户端不知道到底成功没
- 超时重试:为了容错,系统会自动重试,可能导致同一个请求执行多次
- 用户手抖:网页卡顿,用户连点好几次提交按钮
- 消息队列:同一条消息可能被消费多次
这些情况下,如果接口没做幂等性处理,就会出现重复扣款、重复下单、库存超卖等灾难。
HTTP方法的幂等性:别被误导了
很多文章会告诉你:
- GET、PUT、DELETE是幂等的
- POST不是幂等的
这是对的,但也是错的。关键看你怎么实现。
PUT的坑
假设你有个接口更新用户余额:
// 这样写,PUT不幂等!
PUT /user/balance
{ "amount": "+100" } // 每次加100
// 这样写,PUT才幂等
PUT /user/balance
{ "amount": 500 } // 直接设置为500
第一种写法,调用3次,余额就加了300。第二种写法,调用100次,余额还是500。
DELETE的争议
删除一个不存在的资源,应该返回什么?
- 返回404:精确反馈,但客户端要判断"是真的失败还是已经删除了"
- 返回204:表示"反正现在这个资源没了",客户端逻辑简单
现在主流做法是返回204,因为用户只关心结果:"这个东西删掉了没有?"至于它是刚删的还是早删了,谁在乎呢?
实战:5种幂等性实现方案
1. 唯一索引(最简单)
直接在数据库加唯一索引,比如订单号。
CREATE UNIQUE INDEX idx_order_no ON orders(order_no);
优点: 简单粗暴,数据库帮你保证唯一性
缺点: 高并发下数据库扛不住,分库分表后还得生成全局唯一ID
适合场景: 用户注册、订单创建这种低频操作
2. 幂等令牌(Token)
这是支付场景的标配方案。
流程:
- 客户端先请求一个Token(存到Redis,设置过期时间5分钟)
- 提交支付请求时带上这个Token
- 服务端用Lua脚本原子性校验Token,校验通过就删除Token并执行业务
- 如果Token不存在,说明已经处理过了,直接返回历史结果
// 伪代码示意
async function pay(orderId, token) {
// 用Lua脚本原子性操作
const exists = await redis.getAndDelete(token);
if (!exists) {
// Token不存在,查询历史支付结果
return await getPaymentResult(orderId);
}
// 执行支付逻辑
return await doPayment(orderId);
}
优点: 支持分布式,可靠性高
缺点: 多一次Token申请请求
适合场景: 支付、退款、第三方API回调
3. 乐观锁(Version字段)
在数据表加个version字段,更新时带上版本号。
UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE product_id = 123 AND version = 5;
如果version对不上,说明数据已经被别人改过了,更新失败,客户端重试。
优点: 无锁,性能好
缺点: 高并发下冲突多,重试压力大
适合场景: 商品库存扣减、账户余额更新
4. 分布式锁(Redis)
用Redis抢锁,谁抢到锁谁执行操作。
const lock = await redis.set(`lock:${orderId}`, 'locked', 'EX', 10, 'NX');
if (lock) {
try {
// 执行业务逻辑
await createOrder(orderId);
} finally {
await redis.del(`lock:${orderId}`);
}
} else {
// 没抢到锁,说明别人在处理,直接返回
return 'processing';
}
注意: 要用Redisson这种带"看门狗"的框架,防止锁过期了业务还没执行完。
优点: 支持分布式,强一致性
缺点: 有性能开销,要防死锁
适合场景: 秒杀、抢购
5. 防重表(独立去重)
专门建一张表记录请求ID,处理前先插入,插入成功才执行业务。
CREATE TABLE idempotent_record (
request_id VARCHAR(64) PRIMARY KEY,
create_time DATETIME
);
优点: 简单清晰,日志可追溯
缺点: 多一次数据库操作
适合场景: 消息队列消费、异步任务
方案怎么选?一张图搞定
需要支持分布式吗?
├─ 不需要 → 唯一索引/防重表(简单场景)
└─ 需要
├─ 并发量大吗(>1万QPS)?
│ ├─ 是 → 分布式锁 + 乐观锁组合(秒杀)
│ └─ 否 → 幂等令牌(支付场景)
└─ 能接受最终一致性吗?
├─ 是 → 乐观锁(库存扣减)
└─ 否 → 幂等令牌 + 分布式事务(资金操作)
常见的坑,千万别踩
坑1:主从同步延迟
你用唯一索引做幂等,主库插入成功了,但还没同步到从库。客户端重试,查从库发现没有数据,以为没处理,又插入一次。
解决办法: 查询和插入在同一个事务里执行,或者强制读主库。
坑2:分布式锁超时
你设置锁10秒过期,但业务逻辑执行了15秒。锁过期后被别人抢走了,结果两个请求同时在跑。
解决办法: 用Redisson的自动续期功能,或者把锁过期时间设长点。
坑3:响应内容有时间戳
接口响应里包含create_time这种字段,每次调用时间戳不一样,算不算幂等?
答案: 算!幂等性关注的是资源状态一致,不是响应内容完全一样。只要订单状态、金额这些关键数据不变就行。
真实案例:支付宝怎么做的
支付宝用的是"一锁二判三更新":
- 一锁: 先对用户账户加分布式锁
- 二判: 判断这笔支付是否已经处理过(查防重表)
- 三更新: 确认没处理过,才执行扣款和写入流水
据说他们全年因为重复请求导致的资金损失率低于0.001%。这就是幂等性做得好的价值。
不同业务场景的最佳实践
金融支付: 必须用幂等令牌 + 分布式事务,不能有一分钱的差错
内容发布: 用状态机 + 唯一索引就够了,文章从"草稿"变"已发布",重复点击直接返回"已发布"
消息队列: 用消息ID + 消费状态表,Kafka的exactly-once也是这么实现的
写在最后
幂等性听起来是个技术问题,但本质上是个用户信任问题。
想象一下,你在用支付宝转账,点了一次没反应,再点一次,结果扣了两次钱。你还敢用吗?
所以说,幂等性不是可选项,是分布式系统的生命线。尤其是涉及钱、涉及数据的操作,一定要做好幂等性。
记住一个原则:让用户多点几次按钮没关系,但绝对不能让他们的钱被扣两次。
思考题:如果你的系统需要支持每秒10万次写请求,你会选择哪种幂等方案?欢迎评论区讨论!