聊聊幂等性:5种幂等性实现方案,别让用户的钱扣了两次

137 阅读7分钟

先说个真实的故事

去年双十一,我朋友在某电商平台抢购,点了支付按钮后页面卡住了。他心想"是不是没支付成功?"又点了一次。结果第二天发现,同一个订单被扣了两次钱。客服说要3-5个工作日才能退款,他当场炸了。

这就是典型的"幂等性"问题。今天咱们就用大白话聊聊,这个听起来很学术的词,到底是啥,怎么解决。

什么是幂等性?说人话!

数学定义我就不扯了,直接上人话版本:不管你重复操作多少次,结果都应该一样

举几个例子你就懂了:

幂等的操作:

  • 删除一个文件:删一次是删除,删100次还是删除,文件该没就没了
  • 设置用户年龄为25岁:设置一次是25,设置100次还是25
  • 查询订单详情:查多少次都是同一个结果

不幂等的操作:

  • 余额减100:第一次减变900,第二次减变800,第三次减变700...
  • 发送短信验证码:每次发送都会产生一条新的短信
  • 创建订单:点一次创建一个订单,点10次就是10个订单

看出区别了吗?幂等的操作是"覆盖式"的,不幂等的操作是"累加式"的

为什么分布式系统特别需要幂等性?

在单机系统里,用户点一下按钮,要么成功要么失败,清清楚楚。但在分布式系统里,事情就复杂了:

  1. 网络抖动:请求发出去了,但响应没回来,客户端不知道到底成功没
  2. 超时重试:为了容错,系统会自动重试,可能导致同一个请求执行多次
  3. 用户手抖:网页卡顿,用户连点好几次提交按钮
  4. 消息队列:同一条消息可能被消费多次

这些情况下,如果接口没做幂等性处理,就会出现重复扣款、重复下单、库存超卖等灾难。

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)

这是支付场景的标配方案。

流程:

  1. 客户端先请求一个Token(存到Redis,设置过期时间5分钟)
  2. 提交支付请求时带上这个Token
  3. 服务端用Lua脚本原子性校验Token,校验通过就删除Token并执行业务
  4. 如果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这种字段,每次调用时间戳不一样,算不算幂等?

答案: 算!幂等性关注的是资源状态一致,不是响应内容完全一样。只要订单状态、金额这些关键数据不变就行。

真实案例:支付宝怎么做的

支付宝用的是"一锁二判三更新":

  1. 一锁: 先对用户账户加分布式锁
  2. 二判: 判断这笔支付是否已经处理过(查防重表)
  3. 三更新: 确认没处理过,才执行扣款和写入流水

据说他们全年因为重复请求导致的资金损失率低于0.001%。这就是幂等性做得好的价值。

不同业务场景的最佳实践

金融支付: 必须用幂等令牌 + 分布式事务,不能有一分钱的差错
内容发布: 用状态机 + 唯一索引就够了,文章从"草稿"变"已发布",重复点击直接返回"已发布"
消息队列: 用消息ID + 消费状态表,Kafka的exactly-once也是这么实现的

写在最后

幂等性听起来是个技术问题,但本质上是个用户信任问题

想象一下,你在用支付宝转账,点了一次没反应,再点一次,结果扣了两次钱。你还敢用吗?

所以说,幂等性不是可选项,是分布式系统的生命线。尤其是涉及钱、涉及数据的操作,一定要做好幂等性。

记住一个原则:让用户多点几次按钮没关系,但绝对不能让他们的钱被扣两次


思考题:如果你的系统需要支持每秒10万次写请求,你会选择哪种幂等方案?欢迎评论区讨论!