幂等性:分布式系统里"做两次"为什么不等于"做一次"

30 阅读 2921 字 · 约 10 分钟

你点了"提交订单"按钮,页面没反应,你又点了一次。然后你发现银行卡被扣了两次。这个场景单机上不会发生——同一台机器,同一个进程,第一次点完状态就变了,第二次点不会重复扣款。但到了分布式环境里,"做两次"和"做一次"产生的效果可能不一样,这件事需要专门处理。这就是幂等性。

什么是幂等性

幂等性(Idempotency)的定义:同一个操作执行一次和执行多次,产生相同的结果。

f(f(x)) = f(x)——执行第二次不会改变第一次执行后的状态。

加法是幂等的:x + 0 = x,加多少次 0 结果都一样。但 x + 1 不是:第一次 x+1,第二次 x+2,每次都不一样。

在系统里:

  • GET /user/123 是幂等的——读多少次,用户信息不变
  • PUT /user/123 {name: "张三"} 是幂等的——把名字设为"张三",设多少次都还是"张三"
  • POST /order 创建订单——不是幂等的,每 POST 一次创建一单
  • DELETE /user/123 是幂等的——删一次和删十次,结果都是"用户 123 不存在"

HTTP 规范里 GET、PUT、DELETE 天然幂等,POST 不幂等。但"天然幂等"只是语义上的承诺,具体实现成不成,还得看代码。

为什么分布式离不开幂等

单机上不需要刻意做幂等——调用一次就是一次,没有重复。分布式环境下,重复几乎不可避免:

网络超时。 请求发出去了,但响应没回来——是请求丢了,还是处理完了但响应丢了?你不知道。唯一安全的选择是重试。一重试,同一个请求就发了两次。如果操作不幂等,第二次的副作用就来了。

至少一次投递。 消息队列(Kafka、RabbitMQ)的常见承诺是"至少一次"——消息不会丢,但可能重复。消费者收到同一条消息两次,如果处理逻辑不幂等,就处理了两次。下游就多了一笔操作。

生产者重试。 调下游接口超时了,框架自动重试。下游可能第一次已经处理完了,第二次又来一个一模一样的请求。下游如果不做幂等,就处理了两次。

这三个场景的共同点:不是你想不想重复,是分布式环境会逼着你重复。 网络不可靠、消息会重投、超时要重试——这些都是分布式的基本事实。幂等性不是"最好有",是"没有就出事"。

怎么实现幂等

核心思路都一样:记住"这个操作做过没有"。

唯一请求 ID + 去重表。 最通用的方案。调用方生成一个全局唯一的 request_id,带着请求过来。处理方在执行前查去重表——这个 request_id 见过没有?见过就返回上次的结果,没见过就执行并存入去重表。

这个方案能覆盖所有场景——创建订单、转账、状态变更——但代价是多一张表、多一次查、多一次写。去重表本身还可能成为热点。如果请求量很大,去重表会变成瓶颈。

唯一约束。 利用数据库的唯一索引。创建订单时带一个 client_order_id,数据库建唯一索引。如果重复插入同一个 client_order_id,数据库报唯一冲突,catch 住后直接返回"已创建"。这个方案更轻——不用单独建去重表,靠数据库的约束兜底。但只适用于"插入"类操作,更新操作用不上。

状态机。 如果操作有明确的状态流转,可以用状态约束。订单状态是"待支付",支付成功后变成"已支付"。重复的支付回调来了,发现状态已经是"已支付",直接返回成功——不会再改一次。这个方案的前提是状态流转清晰、用数据库的事务和行锁保证状态变更的原子性。

乐观锁。 更新操作带版本号。UPDATE order SET status='paid', version=version+1 WHERE id=? AND version=?。第一次更新成功,version 从 1 变 2。重复请求带着旧 version=1 来,匹配不上——不会重复更新。乐观锁不只防重复,还防并发冲突,是一举两得的方案。

两种幂等的区别

请求级别幂等。 同一个请求(同一个 request_id)执行多次,结果一样。去重表方案做的就是这个——不管你把同一个请求发几遍,我只执行一次。

操作级别幂等。 不同的请求,但操作本身幂等。PUT {name: "张三"} 不管发几次,结果都是名字变成"张三"。DELETE /user/123 不管发几次,结果都是用户不存在。

两者的区别在于:请求级别幂等认的是"这个请求来过没有",操作级别幂等认的是"这个操作做出来的效果是不是确定的"。实践中经常混用——先靠操作级别的幂等性(PUT、DELETE)兜底,对不幂等的操作(POST 创建)再靠请求级别的去重(request_id)补上。

实现上的坑

存储和操作不在一个事务里。 去重表写入成功了,但业务操作还没执行就挂了——下次同一个 request_id 来,去重表里说"做过了",但业务没做。去重表的写入和业务操作必须在同一个事务里:要么都成功,要么都回滚。

只去重、不返回缓存结果。 第一次请求处理了 3 秒才返回,第二次重复请求来了,发现做过,立刻返回——但返回什么?如果只是说"成功"但不带第一次的结果,调用方拿不到数据。去重表不只要存"做过",还得存"上次的结果",重复请求直接返回上次的响应。

部分失败后的重试不是幂等的。 第一次请求:第一步扣款成功了,第二步发券失败了。重试时整个操作再来一遍——扣款又扣了一次。幂等不能只看整个请求的维度,要保证每一步都能从中间状态安全重试。如果做不到,就得引入补偿/回滚机制(TCC、Saga),把部分失败的状态拉回来再重试。

幂等和并发是两件事。 两个不同的请求同时来,各带各的 request_id,都通过了去重检查,同时执行——数据可能冲突。幂等保证的是"同一个请求重复执行没问题",不保证"不同请求并发执行没问题"。并发安全要靠锁、靠隔离级别、靠乐观锁,和幂等是正交的两件事。

怎么用

  • 接口层面能幂等就幂等:查询用 GET,更新用 PUT,删除用 DELETE——尽量不依赖 POST 做本可以幂等的操作
  • 非幂等的操作(创建、转账)用 request_id 去重,调用方生成、服务端检查
  • 去重表和业务操作放同一个事务里,保证原子性
  • 去重表存上次的响应,重复请求直接返回缓存结果,不重新执行
  • 用乐观锁(版本号)做更新操作的幂等,顺便解决并发冲突
  • 多步操作考虑部分失败:要么每一步可安全重试(幂等步骤),要么有补偿机制

常见误区

  • HTTP GET 一定幂等:语义上是,但如果你的 GET 有副作用(比如记日志、写缓存、触发副作用),就不幂等了。幂等是实现的承诺,不是方法名的保证
  • 去重表万能:去重表解决"同一个请求重复执行",但解决不了"不同请求同时执行"的并发问题——那要靠锁和隔离级别
  • 幂等等于性能好:幂等操作可能更慢——要查去重表、要检查版本号、要加约束。幂等换来的是正确性,不是速度
  • 做了幂等就万事大吉:幂等保证的是"重复执行不出错",不保证"单次执行就对"。业务逻辑本身的正确性,幂等兜不了
  • 重试天然安全:只有被调方的操作幂等,调用方重试才安全。如果被调方不幂等,调用方怎么重试都可能出问题——幂等是双方的事

小结

幂等性是分布式系统的基本功:同一操作执行一次和多次,结果一样。网络超时、消息重投、自动重试这些基本事实逼着操作必须幂等。实现核心是"记住做过没有"——request_id 去重、唯一约束、状态机、乐观锁各有场景。但幂等管"重复不出错",并发管"同时不出错",是两件正交的事。