分布式系统-再思考接口幂等性

最近在整理 Raft 项目,突然发现我对接口的幂等性理解还是不够深刻。因此再次单独整理一份笔记。

什么是幂等性:

幂等性(Idempotence)指的是一个操作无论执行一次还是执行多次,其产生的影响和结果都是相同的。用数学语言来说,如果一个函数 f 是幂等的,那么 f(f(x)) = f(x)。在我们的场景中,客户端的一次写请求(如 SET a = 1),因为网络超时重试,可能被服务端接收多次,幂等性保证这个 SET 操作只会被真正执行一次。

Raft 接口的幂等性

在开始分析之前,我们首先需要确认 Raft 接口的幂等性行为:

  1. 一个已经提交的日志,客户端再次发起请求时 Reft 不应该重做,而是直接返回结果。

通常,为了实现上述功能,我们会使用 clientID+requestID 来建立一个日志系统,实现请求的去重操作。那么这个细节可以被分为两部分:

  • 当一个客户端发起一个 SET a = 10 的写请求时,这个 ClientIDRequestID 是如何生成、传递、并被服务端处理的?
  • 当 Leader 节点收到这个请求后,它是如何利用这两个 ID 来保证幂等性的?请描述一下它需要维护哪些状态。

ClientIDRequestID 的生成、传递、服务端处理

生成很好处理,ClientID 可以使用 UUID 或雪花算法甚至机器硬件编号计算得到的 Hash,总之根据业务需求方式多种多样。RequestID 基本上是一个单调递增的整形变量,客户端每生成一次操作就进行 RequestID+=1,这样标识了是“客户端的哪一次操作”。

客户端将 ClientIDRequestID 连同需要执行的操作一起打包发给 Leader。Leader 节点维护的状态,作为其状态机的一部分也通过 raft 协议在集群中维持一致性:

节点维护的 (ClientID, RequestID) 记录本身就是状态机的一部分。

当领导者收到一个写请求时,它不会立即执行操作并更新本地的 map。正确的流程是:

  • 领导者将这个请求(包含 ClientID, RequestID 和具体操作 Operation)作为一个日志条目(Log Entry)。
  • 领导者通过 Raft 协议将这个日志条目复制到大多数跟随者(Follower)节点上。
  • 当日志条目被确认为“已提交”(Committed)后,所有节点(包括领导者自己) 都会按照相同的顺序将这个日志条目“应用”(Apply)到自己的状态机上。

关键在“应用”这个动作实际上有两部:

  1. 执行操作:比如,执行 SET a = 1,修改键值数据。
  2. 记录请求:将 (ClientID, RequestID) 以及操作结果存入状态机中的“已处理请求表”中。

因此,在节点内部额外维护一个 {client_id: {request_id: result}} 状态,从而实现请求的去重操作。

客户端 ID 与请求 ID 的清理: 为了避免 {client_id: {request_id: result}} 会无限增大,可以设置一个基于 RequestID 的滑动窗口来控制保存的记录。

提交和应用

我们来思考一个场景:

假设一个 Leader (Node A) 在把一条日志复制到多数 Follower 后,但在应用到自己的状态机(Apply to State Machine)之前,崩溃了。随后,一个新的 Leader (Node B) 被选举出来。

  • 此时,那个持有相同 ClientIDRequestID 的客户端因为超时而重试,把同样的 SET a = 10 请求发送给了新的 Leader (Node B)。
  • Node B 会如何处理这个重试请求? 它如何知道这个请求实际上已经被“处理过”(尽管没有被应用)了?

在这里有一个微妙的细节:leader 在广播 commitIndex 之前还是之后崩溃,对问题有影响吗?

如果 leader 在确认日志已多数复制之后,但在广播 commitIndex 之前崩溃,新的 leader 只拥有一个过期的 commitIndex,会影响提交……吧?

我们来厘清提交和应用的差别:

  • 提交 (Commit): 这是共识模块(Raft)的保证。一旦一条日志条目被 Leader 标记为 Committed,就意味着它已经被持久化存储在多数节点上。这条日志是系统历史中一个不可撤销的部分。所有节点最终都会看到这条日志。
  • 应用 (Apply): 这是状态机(Application)的动作。每个节点独立地、按顺序地读取自己本地已提交的日志,并将其中的命令执行在自己的状态机上。lastApplied 指针记录了状态机“消化”到了哪条日志。

所以,过期的 commitIndex 将导致新 leader 不知道日志是否提交?答案是对,但 commitIndex 不会影响新 leader,commitIndex 是提交的结果,而不是提交的原因

我们回忆新 leader 当选后的动作:leader 不会直接通过 commitIndex 提交日志,而是通过提交本任期(term)的日志来间接提交之前的日志。因此,新 leader 往往在当选后会立刻提交一个包含最新任期的空日志。

  • 如果旧 leader 崩溃之前,日志就被复制到大多数节点,这个空日志会快速被集群接受,新 leader 自然而然地更新 commitIndex。
  • 如果之前的日志未同步至大多数节点,新 leader 只需要重新同步日志即可,最终 commitIndex 还是会更新。

回到问题,此时 一条日志复制到多数 Follower 后,即无论 commitIndex 是否在集群中传播,日志实际上已经提交了!新 leader 接到 Node B 的重试请求,检查自身的 {client_id: {request_id: result}},返回处理完成的结果。

读请求

我们已经完成了写请求的幂等性理解,现在看看读请求的幂等性。

首先,我们需要明白,线性一致性提供了什么保障?比如,一个客户端发出写请求,然后在时间上不久后发出读请求,线性一致性要求我们返回什么结果?

线性一致性的精确定义:线性一致性要求所有操作看起来像是在一个单一的、全局的时间线上,以某种顺序原子地发生。关键在于,这个顺序必须尊重真实世界的时间(real-time order)。

所以,只有这个写请求在读请求之前,发送给正确的 leader,后续的操作都应该以写请求生效为准进行。为此,最安全也是最慢的解决方案是,读请求和写请求一视同仁,也走一遍日志。

updatedupdated2025-12-222025-12-22