Distributed System Blog 01 – Exactly-Once RPC

在不可靠网络上实现 Exactly-Once RPC

0 前言

0.1 分布式系统

我们每天都在用分布式系统:

分布式系统 = 多个独立计算节点通过网络协作实现共同的目标

为什么需要分布式系统?

但多台机器带来了新的问题:它们之间如何通过网络通信协作?

0.2 Remote Procedure Call (RPC)

你平时用 app:

点击朋友头像 → 看到个人资料
刷新朋友圈 → 看到最新动态
发送消息 → 对方收到

这些操作背后就像调用了一个远程函数。

想象你在写代码:

# 本地函数调用
profile = get_profile(user_id)

这个函数在你的程序里执行,读取本地内存的数据。

但如果数据在另一台机器上呢?

# 远程函数调用(RPC)
profile = remote_get_profile(user_id)

这个函数:

  1. 把请求通过网络发给服务器
  2. 服务器执行函数
  3. 把结果通过网络发回来

看起来只是加了个 “remote”,但加入了网络,一切都变了。

0.3 一个转账的故事

想象这个场景:

你打开手机 app,给朋友转账 100 元
点击"确认转账"
loading...
loading...
10 秒过去了,还在 loading

你会怎么做?

这就是分布式系统的核心困境:网络是不可靠的

0.4 网络有多不可靠?

发送"转账 100 元"后,没收到回复。发生了什么?

情况1:请求在路上丢了
Client ----X---> Server
(Server 根本没收到)

情况2:Server 执行了,但回复丢了
Client --------> Server ✔️
Client <---X---- Server
(钱转了,但你不知道)

情况3:网络很慢
Client ----😴---> Server 🕒
(还在路上,看起来像丢了)

关键困境:你无法区分这些情况,也无法保证消息到达的顺序。

0.5 我们的目标:Exactly-Once RPC

不管网络环境如何,我们想要保证:每个操作恰好执行一次(Exactly-Once)。

“Exactly-Once”怎么实现?

Exactly-Once = At-Least-Once + At-Most-Once

拆开来看:

1 At-Least-Once RPC

我们先来实现 At-Least-Once RPC,也就是先保证每个操作被 Server 至少执行一次,并且 Client 总会收到执行结果。

At-Least-Once 在 Client 层级上实现。

1.1 重试机制

我们先来解决第一种情况:请求可能丢失,也就是 Server 没有收到请求:

  Client    ----X--->    Server
转账 100 元  (网络丢包)  (根本没收到)

结果: 0 次执行

怎么办?

一个很直觉的想法是:重试

我们每间隔一段时间,就重新发送一次请求,直到成功收到回复为止。

  Client    ----X--->    Server
转账 100 元  (网络丢包)  (根本没收到)

  Client    -------->    Server
转账 100 元    (重试)    (成功执行)

  Client    <--------    Server
 (收到回复)              (回复结果)

结果: 1 次执行

看起来很合理,但是,这样会带来一个问题。

考虑这个场景:

时刻 T1: Client 发送 "转账 100"
时刻 T2: (超时,没回复)
时刻 T3: Client 重试,再次发送 "转账 100"
时刻 T4: Server 回复 "成功"
时刻 T5: Client 发送新命令 "查询余额"
时刻 T6: Server 回复 "成功"

问题: 这个 "成功" 是哪个命令的回复?
- 转账的回复?
- 查询余额的回复?

如果 Client 误以为"成功"是"查询余额"的回复,但其实是第一次"转账"的延迟回复,系统就乱了。

问题本质:回复和请求失去了对应关系。

1.2 RequestID 请求标识

怎么建立对应关系?给每个请求打标签。

标签需要满足:

自然的选择:递增的 request_id

Client 维护一个计数器:
request_id = 0

发送新命令:
  request_id += 1
  发送 (request_id, command)

重试时:
  发送相同的 (request_id, command)
  (request_id 不变!)

Server 的回复也必须带上 request_id,否则 Client 无法匹配。

具体场景:

T1: Client 发送 (request_id=1, "转账 100")
T2: (超时,准备重试) 
T3: Client 发送 (request_id=1, "转账 100")  ← ID 不变
T4: Server 回复 (request_id=1, "成功")
T5: Client 发送 (request_id=2, "查询余额") ← ID 递增
T6: Server 回复 (request_id=1, "成功")

Client 检查: request_id=1 ≠ 当前的 request_id=2
→ 这是旧回复,忽略
→ 继续等待 request_id=2 的回复

完整的消息格式:

Request:
  request_id: 第几个请求
  command:    要执行什么

Reply:
  request_id: 回复哪个请求
  result:     执行结果

1.3 At-Least-Once 的完整逻辑:

Client 的状态:
- current_request_id: 当前请求的 ID
- current_command:    当前请求的命令
- timer:              重试定时器

发送新命令:
  current_request_id += 1
  current_command = command
  发送 Request(current_request_id, command)
  启动 timer

收到回复 Reply(reply_request_id, result):
  if reply_request_id == current_request_id:
      停止 timer
      返回 result
  else:
      忽略 (这是旧请求的回复)

Timer 超时:
  重发 Request(current_request_id, current_command)
  重启 timer

至此,我们实现了 At-Least-Once:只要 Client 不停重试,请求最终会到达并执行,Client 最终会收到回复。

2 At-Most-Once RPC

Client 重试会导致 Server 收到多个相同的请求。我们即将实现的 At-Most-Once 需要保证每个请求只被执行一次。

At-Most-Once 在 Server 层级上实现。

2.1 去重机制

Server 怎么知道这是重复请求?

用我们已有的东西:request_id

Server 维护一个记录:
seen = {1, 2, 5, 7, ...}

收到请求 Request(request_id, command):
  if request_id in seen:
      这是重复请求
      ...
  else:
      这是新请求
      ...

对于多个 Client 的情况:

Server 的记录的 Key 变成 (client_id, request_id):

在网络通信中,每个消息天然就带有发送者的地址(address)。我们直接用这个地址作为 client_id 即可。

所以一个请求的完整的唯一标识是:(client_id, request_id),这也是在 Server 层要维护的记录。
其中:

2.2 请求 → 结果 映射表

在接收到重复请求后,要怎么做?

T1: Client 发送 Request(request_id=1, command)
    Server 执行,回复丢失

T2: Client 重试,发送 Request(request_id=1, command)
    Server 发现: 之前见过 (client_id, 1)
    然后呢?

Server 不能:

Server 必须:返回之前的结果

对于每个请求 (client_id, request_id),保存对应的结果。

HashMap: (client_id, request_id) -> Result

使用一个“请求 → 结果”映射表,收到旧请求时返回即可。

Server 不能无限保存历史。

2.3 单请求假设下的垃圾回收机制

Client 单请求假设:Client 在未收到当前请求的回复之前不会请求新命令。

Client 的行为:
发送 req=1 → 等回复 → 收到
发送 req=2 → 等回复 → 收到
发送 req=3 → ...

在此情况下:

Client 发送了 request_id=N+1
→ 说明它已经收到了 request_id≤N 的回复
→ Server 可以丢弃 request_id≤N 的记录

所以只需保存最后一个:

history = {
  client_A: (last_id, last_result),
  client_B: (last_id, last_result),
}

收到新请求 N:
  history[client_id] = (N, result)
  // 自动覆盖旧的,只保留最新

HashMap: client_id -> (request_id, Result)

2.4 At-Most-Once 的完整逻辑

Server 的状态:
- history: map[client_id → (last_request_id, last_result)]

收到请求 Request(request_id, command) 来自 client_id:
  
  record = history.get(client_id)
  
  if record 不存在:
      // 第一次见到这个 Client
      result = execute(command)
      history[client_id] = (request_id, result)
      发送 Reply(request_id, result)
      return
  
  if request_id <= record.last_request_id:
      // 重复或乱序的旧请求
      发送 Reply(request_id, record.last_result)
      return
  
  // 新请求 (request_id > last_request_id)
  result = execute(command)
  history[client_id] = (request_id, result)  // 覆盖旧记录
  发送 Reply(request_id, result)

3 Exactly-Once RPC 完整架构

Client 节点                    Server 节点
┌──────────────┐              ┌────────────────┐
│ Retry Logic  │   Command    │ AMOApplication │
│    (ALO)     │  ─────────→  │     (AMO)      │
│              │    Result    │       ↓        │
│              │  ←─────────  │  Application   │
│              │              │   (business)   │
└──────────────┘              └────────────────┘

详细版本:

Client Node                              Server Node
┌─────────────────────────┐             ┌──────────────────────────┐
│ Retry Logic (ALO impl)  │             │ AMOApplication (AMO impl)│
│                         │             │                          │
│ • request_id: 1,2,3...  │  Command    │ • Dedup check:           │
│ • Timer: retry timeout  │  +request_id│   req_id <= last_req_id? │
│ • Match reply: ID match?│             │                          │
│                         │ ─────────→  │ • Save history:          │
│                         │             │   map[client_id→         │
│                         │             │       (req_id, result)]  │
│                         │  Result     │                          │
│                         │  +request_id│   ↓ if new request       │
│ Match → stop retry      │ ←─────────  │                          │
│                         │             │ Application (Business)   │
│                         │             │ • Get/Put/Append         │
│                         │             │ • Transfer/Query         │
│                         │             │ • ... (any business)     │
└─────────────────────────┘             └──────────────────────────┘
         ↑                                         ↑
client_id provided by network          Get client_id from message
framework (sender address)             (sender address)

Server 内部分层(怎么做)

Server 内部:
┌─────────────────────┐
│  AMOApplication     │ ← 去重层
│      (包装)          │   只关心 request_id
│                     │   不关心业务逻辑
│        ↓            │
│  Application        │ ← 业务层
│   (具体业务)          │   只关心业务逻辑
│  • KVStore          │   不关心去重
│  • GameServer       │
│  • ShoppingCart     │
└─────────────────────┘

4 拓展延伸

不是所有操作都怕重复

4.1 Side Effect 和 Idempotence

Side Effect = 改变系统状态的操作

无副作用 (读操作):
- GET(key)          - 只读,不改变状态
- QUERY_BALANCE()   - 只读,不改变状态

有副作用 (写操作):
- PUT(key, value)   - 修改状态
- TRANSFER(amount)  - 修改状态
- APPEND(key, val)  - 修改状态

如果操作有副作用,重复执行可能产生错误的结果。

Idempotence(幂等性)= 执行多次和执行一次的效果相同

幂等操作:
- GET(key)            - 读多次结果一样
- PUT(key, "value")   - 设置成固定值,重复设置还是那个值
- DELETE(key)         - 删除(删除已删除的 = 还是删除)

非幂等操作:
- APPEND(key, value)  - 每次追加,越来越长
- INCREMENT(counter)  - 每次加 1,越来越大
- TRANSFER(amount)    - 每次扣钱,越来越少

为什么重要?

如果操作是幂等的,系统可以简化:

Client: 不确定是否成功?直接重试就行
Server: 重复执行也没事
→ 不需要复杂的 AMO 机制,at-least-once 就够了

这就是为什么 DNS、NFS 等系统能用简单的 at-least-once。

4.2 我们的方案适用范围

适用于:

5 结语

Lab 1 看起来很简单:实现一个 RPC。

但它揭示了分布式系统的核心挑战:如何在不确定性中构建确定性?

我们的答案不是消除不确定性(做不到),而是:

这不仅仅是一个 Lab 的解法,更是分布式系统设计的基本哲学。

下一步:Lab 2 会引入 Primary-Backup,在不可靠的服务器上实现可靠的服务。
Distributed System Blog 02 – Primary-Backup