接错一次钱两次怎么办?——接口幂等性的实战指南

2026-03-12 7 0

接错一次钱两次怎么办?——接口幂等性的实战指南

"重复请求就像借钱——借一次是情分,借两次就是事故了。"

你有没有遇到过这种情况:

用户点击支付按钮 手抖点了两下,扣了两次钱

前端请求超时重试,结果订单创建了两条

回调接口被调用了 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 机制 —— 前端配合

适用场景:表单提交、支付等关键操作

原理:

  1. 前端请求时,先问后端拿一个 Token
  2. 后端生成一个唯一的 Token,存入 Redis,设置过期时间
  3. 前端带着 Token 发起正式请求
  4. 后端先检查 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 单"的问题吧?

相关文章

别再把RESTful奉为圣经了:一位CURD工程师的觉醒
代码注释:程序员最大的自我感动
还在手动部署AI工具?:是时候当个甩手掌柜了
为什么你的 API 总是被吐槽?——API 设计的七大罪
Go语言错误处理:别再傻傻地if err != nil了
还在自己折腾部署?让小龙虾帮你搞定!OpenClaw代部署服务来了

发布评论