QuanZhou's Wiki

分布式基础:RPC 和远程调用

~/ 分布式#RPC#网络#系统设计

背景

学习分布式系统时,一个非常重要的转变是:不能再把函数调用理解成本地调用。

在单机程序里,调用一个函数通常是:

参数已经在本地内存里。
函数要么返回结果,要么抛出异常。
调用成本很低。
失败边界相对清晰。

但在分布式系统里,调用另一个模块经常意味着通过网络访问另一台机器上的服务。这时一次“函数调用”会变成:

序列化请求
通过网络发送
远端排队
远端执行
序列化响应
通过网络返回
本地反序列化

因此,远程调用不是本地调用。它更慢,也更容易失败。

对训练-推理一体存储和 KV Cache 系统来说,RPC 会出现在很多关键路径上:

worker 查询 metadata server。
scheduler 调度 model worker。
cache manager 读取远端 block group。
cache manager 迁移 block group。
worker pin/unpin 某份 KV Cache。
后台任务淘汰或预取 cache。

这一篇重点学习第二组基础:RPC 和远程调用。

学习目标

这一组基础的目标是理解:

为什么分布式接口都要考虑 timeout、retry 和幂等。

需要掌握:

RPC 是什么
请求/响应模型
序列化和反序列化
timeout
retry
幂等
限流
熔断
连接池

最关键的一句话是:

远程调用失败,不代表服务端没有执行。

这句话是理解 RPC 失败语义的核心。

RPC 是什么?

RPC 是 Remote Procedure Call,远程过程调用。

它的目标是让调用远程服务像调用本地函数一样方便。例如本地代码看起来像:

metadata = get_block_group_metadata(block_group_id)

但实际执行时,可能发生了:

1. 客户端把 block_group_id 序列化成请求。
2. 请求通过网络发送到 metadata server。
3. metadata server 查找元数据。
4. metadata server 把结果序列化成响应。
5. 响应通过网络返回客户端。
6. 客户端反序列化得到 metadata。

RPC 框架屏蔽了很多细节,但不能消除远程调用的本质问题:

网络可能超时。
请求可能丢失。
响应可能丢失。
服务端可能崩溃。
服务端可能执行成功但客户端不知道。
服务端可能很慢。
客户端可能重试。
重试可能导致重复执行。

所以 RPC 的重点不是“怎么像本地调用一样写代码”,而是“知道它绝对不是本地调用”。

请求/响应模型

最常见的 RPC 是请求/响应模型:

client -> request -> server
client <- response <- server

例如:

get_block_group(block_group_id, epoch)

正常路径是:

1. client 发送请求。
2. server 收到请求。
3. server 执行读取。
4. server 返回响应。
5. client 得到结果。

但真实系统里,异常路径更多:

client 发不出去。
request 在网络中丢失。
server 收到了,但还没执行就崩溃。
server 执行成功了,但 response 丢了。
server 执行太慢,client 超时。
client 超时后重试,server 收到两次请求。

所以设计 RPC 接口时,不能只考虑正常返回,还要考虑调用方不知道服务端到底执行到哪一步。

序列化和反序列化

远程调用要跨进程、跨机器传输数据,所以内存里的对象不能直接发送,必须先编码成字节流。

这个过程叫序列化:

object -> bytes

接收方再把字节流恢复成对象:

bytes -> object

常见序列化方式包括:

JSON
Protocol Buffers
Thrift
FlatBuffers
MessagePack

不同方式有不同取舍:

JSON:可读性好,但体积大、解析慢。
Protobuf:体积小、速度快,适合服务间通信。
Thrift:常用于跨语言 RPC。
FlatBuffers:可减少反序列化开销,适合性能敏感场景。

在 KV Cache 场景中,一般不会把巨大的 K/V tensor 直接用普通 JSON 传输。RPC 请求里更常传:

block_group_id
location
epoch
token_range
size_bytes
checksum
metadata

真正的大块数据可能通过专门的数据通道传输,例如:

RDMA
对象存储
共享内存
文件
GPU Direct
专门的 streaming channel

因此要区分两类数据:

控制面数据:元数据、状态、命令,适合 RPC。
数据面数据:大块 KV Cache 内容,通常需要更高效的数据传输方式。

这个区分很重要。不要把所有东西都塞进普通 RPC,否则很容易让控制面被大数据传输拖垮。

远程调用为什么会失败?

本地函数调用失败时,通常能比较明确地知道发生了什么。

远程调用失败时,最大的问题是:客户端经常不知道服务端是否执行过。

例如:

evict_block_group(bg_1) 超时

可能发生了四种情况:

1. 请求没发到服务端。
2. 服务端执行了,但响应丢了。
3. 服务端正在执行,还没返回。
4. 服务端执行失败。

从客户端视角看,这些情况可能都表现为:

timeout

但它们的后果完全不同。

如果客户端简单重试:

evict_block_group(bg_1)
evict_block_group(bg_1)

服务端可能执行两次。对于查询类接口,这通常没问题;但对于修改状态的接口,重复执行可能带来错误。

所以分布式接口设计必须考虑:

这个接口能不能重试?
重复调用是否安全?
如果服务端已经执行成功,客户端重试应该返回什么?
如果请求过期了,服务端是否应该拒绝?

Timeout:超时

远程调用必须设置超时。

没有超时会导致:

请求一直等待。
线程或协程被占用。
连接池被耗尽。
上游请求堆积。
故障扩散到其他模块。

例如 worker 读取远端 block group:

get_block_group(block_group_id, epoch)

如果远端节点卡住,而本地没有 timeout,那么 decode 请求会一直等待,进而拖慢整个 batch。

超时时间怎么设置?

超时时间不能随便写一个固定值,要结合调用路径和业务目标。

需要考虑:

这个调用在不在在线请求关键路径上?
调用失败后是否可以重试?
重试是否会造成更大延迟?
下游服务正常 P99 是多少?
上游请求整体超时时间是多少?

一个基本原则是:

下游 timeout 不能超过上游剩余时间。

例如一次推理请求整体最多允许 2 秒,而某个远程 cache 读取已经消耗了 1.8 秒,那么这个 RPC 不应该再设置 1 秒超时。

超时不是取消

客户端 timeout 只代表客户端不等了,不代表服务端停止执行。

这点非常重要。

例如:

migrate(block_group_id=bg_1, src=A, dst=B, epoch=10)

客户端 500ms 后超时,但服务端可能还在迁移。客户端如果立刻发起新的迁移任务,就可能和旧任务冲突。

因此服务端需要能识别同一个操作,客户端也需要能查询操作状态。

Retry:重试

重试可以提高成功率,但也可能放大故障。

适合重试的情况:

临时网络抖动。
连接被对端关闭。
服务端短暂过载。
请求没有明显副作用。
接口是幂等的。

不适合盲目重试的情况:

非幂等写操作。
下游已经严重过载。
上游剩余时间不足。
请求体很大,重试成本高。
重试会触发重复迁移或重复释放。

重试风暴

如果大量客户端同时发现下游变慢,然后一起重试,就会形成重试风暴。

这会让本来已经变慢的服务更慢。

常见缓解方式:

限制最大重试次数。
指数退避。
增加随机抖动 jitter。
只对部分错误重试。
使用重试预算 retry budget。
下游过载时快速失败。

例如:

第一次失败后等待 10ms。
第二次失败后等待 20ms。
第三次失败后等待 40ms。
每次等待时间加一点随机抖动。

重试要带请求 ID

重试时最好带上唯一请求 ID 或操作 ID:

operation_id = "migrate-bg_1-epoch_10-A-to-B"

服务端可以用它识别重复请求:

如果操作已经成功,直接返回成功结果。
如果操作正在执行,返回 in_progress 或等待。
如果操作失败,返回失败原因。

这比每次都创建一个新操作安全得多。

幂等

幂等的意思是:同一个操作执行一次和执行多次,最终效果相同。

例如:

set_state(bg_1, EVICTED)

执行多次结果仍然是 EVICTED

但下面这种操作不是天然幂等:

ref_count += 1

如果客户端重试两次,ref_count 就可能多加一次。

查询类接口

查询类接口通常天然幂等:

get_metadata(block_group_id)
get_location(block_group_id)
get_state(block_group_id)

重复查询不会改变系统状态。

写入类接口

写入类接口需要特别设计。

例如淘汰接口不应该只写成:

evict(block_group_id)

更好的形式是:

evict(block_group_id, epoch, operation_id)

这样服务端可以判断:

epoch 是否仍然是当前版本?
operation_id 是否已经执行过?
block group 当前状态是否允许 evict?
如果已经是 EVICTED,是否可以直接返回成功?

pin/unpin 的幂等设计

pinunpin 很容易出错。

如果接口是:

pin(block_group_id)
unpin(block_group_id)

那么重试可能导致计数错误。

更好的方式是带上 request_id

pin(block_group_id, request_id)
unpin(block_group_id, request_id)

服务端维护:

block_group_id -> pinned_request_set

这样:

同一个 request_id pin 多次,只算一次。
同一个 request_id unpin 多次,只释放一次。

这种设计比简单的 pin_count += 1pin_count -= 1 更适合分布式重试场景。

migrate 的幂等设计

迁移接口也需要幂等:

migrate(block_group_id, src, dst, epoch, operation_id)

服务端处理时应该检查:

block_group_id 是否存在?
epoch 是否匹配?
当前 location 是否仍然是 src?
目标是否已经是 dst?
operation_id 是否已经执行过?
当前状态是否允许迁移?

如果客户端重试同一个 operation_id,服务端不应该启动两次迁移,而应该返回同一个操作的状态。

限流

限流是为了保护系统,避免请求量超过服务能力。

常见限流维度:

按服务限流。
按用户或租户限流。
按接口限流。
按 block group 迁移流量限流。
按远端节点限流。

在 KV Cache 系统里,限流尤其重要,因为某些操作很重:

远端读取大 block group。
跨节点迁移 cache。
从 SSD 拉取冷数据。
批量淘汰和释放。

如果不限制,后台迁移任务可能抢占在线 decode 的网络带宽和存储带宽。

常见限流算法

常见算法包括:

计数器:固定窗口内限制请求数。
滑动窗口:比固定窗口更平滑。
漏桶:以固定速率处理请求。
令牌桶:允许一定突发,但整体速率受限。

对于在线服务,令牌桶很常见:

系统按固定速率生成 token。
请求要先拿到 token 才能执行。
token 桶允许短时间突发。
桶空了就等待或拒绝。

KV Cache 迁移可以设计独立的 token:

migration_bytes_token
remote_read_qps_token
ssd_read_iops_token

这样可以限制后台任务,不让它们影响在线请求。

熔断

熔断是为了避免持续调用已经异常的下游。

如果某个服务持续超时,客户端继续打请求只会浪费资源,并加重下游压力。

熔断器通常有三种状态:

CLOSED:正常调用。
OPEN:熔断,直接失败。
HALF_OPEN:半开,放少量请求探测恢复情况。

例如 remote cache manager 连续超时,客户端可以进入 OPEN 状态:

后续请求不再访问这个远端节点。
调度器把请求转移到其他节点。
必要时走重新计算或降级路径。

一段时间后进入 HALF_OPEN:

只放少量请求访问远端。
如果成功率恢复,再切回 CLOSED。
如果仍然失败,继续 OPEN。

熔断的目标不是解决下游故障,而是限制故障扩散。

连接池

RPC 通常会复用连接,而不是每次请求都新建连接。

连接池的作用是:

减少 TCP 建连成本。
减少 TLS 握手成本。
控制并发连接数量。
复用已有连接提高吞吐。

但连接池也可能成为瓶颈。

需要关注:

连接池大小。
每条连接上的并发请求数。
连接是否健康。
空闲连接是否回收。
连接上的请求是否出现队头阻塞。

例如某个 worker 到 metadata server 的连接池太小,所有请求都排队等连接,即使 metadata server 本身很空,P99 也会变差。

队头阻塞

队头阻塞是指前面的慢请求挡住后面的请求。

如果多个 RPC 共用一条连接,而协议或实现不能很好地并发处理响应,就可能出现:

请求 A 很慢。
请求 B 本来很快。
但 B 排在 A 后面,必须等待。

这会显著影响尾延迟。

所以性能敏感服务要关注连接池、协议多路复用和请求排队情况。

控制面和数据面

在 KV Cache 系统里,需要区分控制面和数据面。

控制面负责:

元数据查询。
状态更新。
调度命令。
迁移任务创建。
pin/unpin。
evict。

数据面负责:

真实 KV Cache 数据传输。
大块 tensor 复制。
GPU HBM 和 CPU Memory 之间搬运。
跨节点传输 block group。
SSD 读写。

RPC 更适合控制面。数据面如果也完全依赖普通 RPC,很容易出现:

大响应占满连接。
控制请求被大数据传输阻塞。
序列化开销过高。
内存拷贝过多。
P99 变差。

所以一种常见设计是:

RPC 负责发命令和返回元数据。
数据传输走专门的数据通道。
RPC response 里返回数据位置、token、offset、checksum。
客户端再通过数据通道拉取或写入。

例如:

prepare_get_block_group(block_group_id, epoch)
-> 返回 remote_addr、size、checksum、transfer_token

transfer_data(remote_addr, size, transfer_token)
-> 传输真实 KV 数据

这样可以减少控制面被大数据传输拖慢的风险。

KV Cache 接口设计示例

下面用几个接口把前面的概念串起来。

查询元数据

get_metadata(block_group_id)

特点:

查询类接口,天然幂等。
需要 timeout。
可以短重试。
客户端可以缓存结果,但要关注 epoch。

返回值可以包括:

block_group_id
location
state
epoch
size_bytes
token_range

读取 block group

get_block_group(block_group_id, epoch, request_id)

需要考虑:

epoch 不匹配时拒绝或返回最新元数据。
block group 正在 MIGRATING 时如何处理。
远端读取是否超时。
是否允许重试。
大数据是否走数据面传输。

如果 block group 当前状态是 MIGRATING_OUT,服务端可以选择:

等待迁移完成。
返回 RETRY_LATER。
返回新 location。
拒绝读取并要求客户端刷新元数据。

淘汰 block group

evict(block_group_id, epoch, operation_id)

服务端应该检查:

epoch 是否匹配。
pin_count 是否为 0。
ref_count 是否为 0。
当前 state 是否允许 evict。
operation_id 是否已经执行过。

如果已经被淘汰:

state == EVICTED

那么重复调用可以直接返回成功。这就是幂等。

迁移 block group

migrate(block_group_id, src, dst, epoch, operation_id)

这个接口要特别小心,因为迁移是长操作。

可以把它拆成两类接口:

start_migrate(...)
get_migrate_status(operation_id)

这样客户端超时后,不必盲目重试迁移,而是先查询这个 operation_id 的状态。

可能的状态:

PENDING
RUNNING
SUCCEEDED
FAILED
CANCELLED

pin/unpin

pin(block_group_id, request_id, epoch)
unpin(block_group_id, request_id, epoch)

服务端可以维护:

pinned_request_set

这样 pin 和 unpin 都可以做到幂等:

同一个 request_id 重复 pin,不重复增加 pin_count。
同一个 request_id 重复 unpin,不重复减少 pin_count。

这对超时重试非常重要。

常见错误设计

把远程调用当成本地调用

错误做法:

result = remote_call()
use(result)

但没有考虑:

timeout 怎么办?
失败是否重试?
重试是否幂等?
下游过载怎么办?
调用是否会卡住线程?

写接口不带版本

错误做法:

evict(block_group_id)

问题是服务端不知道客户端看到的是不是旧状态。

更好的方式:

evict(block_group_id, epoch, operation_id)

pin/unpin 只操作计数

错误做法:

pin_count += 1
pin_count -= 1

问题是请求超时重试会导致计数不准。

更好的方式:

pin(block_group_id, request_id)
unpin(block_group_id, request_id)

由服务端维护请求集合,再计算 pin_count。

大数据走普通 RPC

错误做法:

get_block_group(...)
-> response 里直接塞几百 MB KV Cache

问题是:

序列化开销大。
内存拷贝多。
控制面连接被占满。
小请求被大响应阻塞。
P99 变差。

更好的方式是控制面和数据面分离。

如何排查 RPC 问题?

RPC 问题通常需要看指标和日志。

指标

常见指标包括:

rpc_qps
rpc_error_rate
rpc_timeout_count
rpc_retry_count
rpc_latency_p50
rpc_latency_p99
rpc_inflight_requests
rpc_queue_length
connection_pool_in_use
connection_pool_wait_time
circuit_breaker_state
rate_limited_count

KV Cache 相关 RPC 还可以看:

get_metadata_latency
get_block_group_latency
migrate_start_latency
migrate_status_latency
evict_latency
pin_latency
unpin_latency
remote_read_timeout_count

日志

日志里最好带上:

request_id
operation_id
block_group_id
src_node
dst_node
epoch
rpc_method
timeout_ms
retry_count
error_code
latency_ms

如果没有这些字段,排查问题时很难判断:

这是第一次请求还是重试?
服务端有没有执行过?
客户端看到的是哪个 epoch?
迁移操作是否重复提交?
超时发生在哪个节点?

面向 KV Cache 的 RPC 检查清单

看组内代码或设计文档时,可以用下面的问题检查:

1. 这个远程调用是否在在线请求关键路径上?
2. 是否设置了 timeout?
3. timeout 是否小于上游剩余时间?
4. 失败后是否 retry?
5. retry 是否有最大次数、退避和 jitter?
6. 被 retry 的接口是否幂等?
7. 写接口是否带 epoch/version?
8. 长操作是否有 operation_id?
9. pin/unpin 是否按 request_id 幂等?
10. 大块 KV 数据是否和控制面 RPC 分离?
11. 是否有限流保护下游?
12. 下游连续失败时是否熔断?
13. 连接池是否可能成为瓶颈?
14. 日志里是否包含 request_id、operation_id、block_group_id 和 epoch?

推荐学习顺序

这一组基础可以按下面顺序学:

1. RPC 是什么:理解远程调用和本地调用的区别。
2. 请求/响应模型:理解正常路径和异常路径。
3. 序列化:理解对象如何变成网络字节流。
4. timeout:理解为什么不能无限等待。
5. retry:理解为什么重试既有用也危险。
6. 幂等:理解为什么写接口要能承受重复请求。
7. operation_id:理解长操作如何去重和查询状态。
8. 限流:理解如何保护下游。
9. 熔断:理解如何限制故障扩散。
10. 连接池:理解 RPC 并发和排队。
11. 控制面/数据面分离:理解大块 KV 数据为什么不适合普通 RPC。
12. 结合 KV Cache 设计 evict/migrate/pin/unpin 接口。

最后一步最重要。不要只停留在 RPC 框架怎么用,而是要能设计出可靠的接口:

evict(block_group_id, epoch, operation_id)
migrate(block_group_id, src, dst, epoch, operation_id)
pin(block_group_id, request_id, epoch)
unpin(block_group_id, request_id, epoch)

然后逐个回答:

这个接口能不能重试?
重复请求会不会改变结果?
客户端超时后服务端是否可能还在执行?
服务端如何识别重复操作?
旧 epoch 的请求是否会被拒绝?
失败后客户端应该查状态、重试还是刷新元数据?

总结

RPC 是分布式系统里最基础、也最容易被低估的一层。

它看起来像函数调用,但本质上是跨网络、跨进程、跨故障边界的通信。

对 KV Cache 和 Block Group 管理来说,RPC 设计的重点不是“能调通”,而是:

1. 每个远程调用都必须有 timeout。
2. 不是所有失败都能安全 retry。
3. 能 retry 的接口必须尽量幂等。
4. 写接口要带 epoch/version,防止旧状态写入。
5. 长操作要带 operation_id,避免重复执行。
6. pin/unpin 要按 request_id 去重,避免计数错乱。
7. 大块 KV 数据最好和控制面 RPC 分离。
8. 限流和熔断是保护系统的必要机制。

掌握这些之后,再学习元数据、一致性、故障恢复和调度时,会更容易理解为什么分布式系统不能只写正常路径。