接错一次钱两次怎么办?——接口幂等性的实战指南
"重复请求就像借钱——借一次是情分,借两次就是事故了。"
你有没有遇到过这种情况:
用户点击支付按钮 手抖点了两下,扣了两次钱
前端请求超时重试,结果订单创建了两条
回调接口被调用了 N 次,钱多退少补
这就是典型的幂等性问题。作为后端工程师,如果你的接口不支持幂等,那简直就是给产品和客服同事埋雷。 今天我们就来聊聊,接口幂等性到底怎么做,以及那些年我们踩过的坑。
什么是幂等性?
简单来说,同一个接口,调用一次和调用 N 次,结果是一样的。这就是幂等性。
举个例子:
GET /users/123—— 查多少次都返回同一个用户,天然幂等POST /orders—— 创建订单,调用两次创建两条,不幂等PUT /users/123/name—— 更新用户名,调用 N 次结果一样,幂等DELETE /users/123—— 删除用户,删一次和删 N 次结果一样,幂等
简单记:查询读操作天然幂等,增删改要看具体实现。
为什么幂等性这么重要?
场景一:网络超时重试
用户提交表单,前端发送请求。结果网络抖动,请求超时了。
用户一看:哎,没反应?再点一次。
结果后端收到了两个请求,创建了两条订单。
客服电话被打爆。
场景二:第三方回调
支付成功,第三方平台回调你的接口。结果你的服务正在重启,没来得及处理。
第三方:"哎呀,没收到响应,再调一次!"
你的服务:"收到辽~"
用户钱扣了两次。
场景三:消息队列消费
你用 MQ 处理异步任务。结果消费者重启了,同一条消息被消费了两次。
数据重复、积分重复发放、优惠券重复领取...
然后你就会在群里看到运营的消息:"为什么这个用户有 999999 积分???"
幂等性的五大实战方案
方案一:唯一索引 —— 最简单的防重复
适用场景:创建类操作
原理:利用数据库唯一索引,防止重复数据入库。
比如创建订单,可以用一个唯一的业务字段做索引:
-- 业务唯一号 + 业务类型 的联合唯一索引
UNIQUE INDEX idx_biz_no (biz_type, biz_no)
插入数据时:
INSERT INTO orders (order_no, user_id, amount, status)
VALUES (ORDER_123, 456, 100, PENDING)
ON DUPLICATE KEY UPDATE id = id;
如果重复插入,数据库会报错或者直接忽略,不会产生重复数据。
优点:简单直接,数据库保证
缺点:需要业务层面有一个唯一的标识符
方案二:Token 机制 —— 前端配合
适用场景:表单提交、支付等关键操作
原理:
- 前端请求时,先问后端拿一个 Token
- 后端生成一个唯一的 Token,存入 Redis,设置过期时间
- 前端带着 Token 发起正式请求
- 后端先检查 Token 是否存在,存在则删除 Token,执行业务;不存在则直接返回
代码示例:
// 获取 Token
async function getToken() {
const token = uuidv4();
await redis.setex(` idempotent: ${token}`, 300, 1);
return token;
}
// 业务请求
async function createOrder(ctx) {
const { token, ...orderData } = ctx.request.body;
// 检查并删除 Token(原子操作)
const result = await redis.del(`idem token: ${token}`);
if (result === 0) {
throw new Error(请勿重复提交);
}
// 执行业务
return await orderService.create(orderData);
}
优点:前端可控,体验好
缺点:需要前端配合改代码,而且用户快速连续点击时,可能出现 Token 已经被删除但请求还在发的情况
方案三:乐观锁 —— 适合更新场景
适用场景:更新操作
原理:在数据表中加一个 version 字段,更新时检查版本号是否变化。
-- 更新语句
UPDATE orders
SET status = "PAID", version = version + 1
WHERE order_no = "ORDER_123" AND version = 1;
如果返回影响行数为 0,说明已经被其他人更新过了,提示用户数据已变更。
这种方案特别适合:
- 库存扣减
- 状态变更
- 余额更新
优点:不额外占用资源,性能好
缺点:需要改表结构,而且业务逻辑要配合调整
方案四:去重表 / 消息幂等 —— 异步任务的救星
适用场景:异步消息、回调通知、MQ 消费
原理:建立一个去重表,记录已经处理过的消息 ID。
-- 去重表
CREATE TABLE message_dedup (
message_id VARCHAR(64) PRIMARY KEY,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 处理消息前
async function processMessage(msg) {
const msgId = msg.id;
// 尝试插入,重复则忽略
const result = await db.query(
`INSERT IGNORE INTO message_dedup (message_id) VALUES (?)`,
[msgId]
);
if (result.affectedRows === 0) {
// 已经被处理过了,跳过
return;
}
// 执行真正的业务逻辑
await handleMessage(msg);
}
优点:简单可靠,适合所有异步场景
缺点:去重表会逐渐变大,需要定期清理历史数据
方案五:分布式锁 —— 最后的防线
适用场景:高并发、复杂业务场景
原理:用 Redis 或 ZooKeeper 加锁,把并发请求串行化。
async function createOrder(orderNo) {
const lockKey = `lock:order:${orderNo}`;
const lock = await redis.setnx(lockKey, 1);
if (!lock) {
throw new Error(请求处理中,请稍后重试);
}
try {
// 执行业务
return await doCreateOrder(orderNo);
} finally {
await redis.del(lockKey);
}
}
注意:一定要设置过期时间,防止锁死;一定要放在 finally 里释放,防止异常导致死锁。
优点:并发安全,适合极端场景
缺点:有性能损耗,而且分布式锁本身也有坑
我的实战建议
幂等性方案没有银弹,要根据具体场景选择:
1. 查询类操作 —— 不用管,天然幂等
2. 简单的创建类 —— 优先用唯一索引,这是最靠谱的方案
3. 表单提交类 —— 考虑 Token 机制,前端配合成本不高
4. 更新类操作 —— 优先考虑乐观锁,加个 version 字段
5. 异步消息、回调 —— 去重表是标配,别偷懒
6. 极端高并发 —— 分布式锁 + 其他方案组合使用
那些年我踩过的幂等性坑
坑一:Token 被重复使用
曾经有个需求,用 Token 防重复提交。结果前端在请求返回之前又发了一个请求,导致第二个请求用了第一个的 Token。
解决方案:Token 和用户会话绑定,并且在前端做按钮禁用。
坑二:数据库主从延迟导致重复
用了主从架构,插入数据后立即查询,结果没查到以为是失败了,又重试了一次。
解决方案:读写分离场景下,插入后用主库查询确认;或者直接根据插入返回值判断。
坑三:MQ 消费顺序导致幂等失效
同一条消息因为重试发了两次,结果第二条先到,第一条后到,顺序乱了。
解决方案:消息加序号,业务处理时按序号顺序执行;或者用消息分区确保同一业务的消息路由到同一分区。
写在最后
幂等性这个事儿,说难不难,说简单也不简单。
关键是要在设计阶段就考虑清楚,而不是等到出问题了再想办法补救。
就像借钱一样——
第一次是帮忙,第二次是事故,第三次就是笑话了。
接口也是同理。第一次请求是正常,第二次就是 bug,第三次就可以写总结了。
所以,从现在开始,把幂等性纳入你的接口设计 checklist 吧。
毕竟,谁也不想在凌晨三点接电话处理"用户下了 999 单"的问题吧?