三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

RDMA 从入门到落地(二)

RDMA 从入门到落地(二)

第 5 章:载波选择——为什么已有数据同步用 RDMA

进入项目实战。先讲一个总前提:kvstore 的主从同步分两条线,RDMA 只负责"已有数据同步"(全量)这一段——把主库整份快照文件同步给从库。而"新增数据同步"(增量,主库新增的写命令实时同步)走的是 eBPF/tcp,跟 RDMA 是两码事。本章回答三个问题:这段为什么值得用 RDMA、怎么判断机器能不能用、配置上的三档怎么选。

5.1 为什么"已有数据同步"值得用 RDMA

从库断线重连或首次同步时,需要把主库的整份快照文件拉过去。这个场景有三个特点,正好是 RDMA 的强项:

  • 单次数据量极大:几百 MB 到数 GB 的快照文件,一次传完;
  • 追求带宽:同步期间从库不可用,传得越快,恢复越快;
  • 路径简单:就是"传一个文件",没有复杂交互。

TCP 在这个场景下的开销是实打实的:数据过内核协议栈、多次拷贝、CPU 全程参与分段和校验。RDMA 用网卡 DMA 直写对端内存,CPU 几乎不参与,带宽能高一大截。项目的实测也印证了这一点:RDMA 已经很接近链路极限了。

5.2 "载波":先决定走哪条路

但 RDMA 不是想用就能用——它依赖硬件网卡。所以项目在配置层面先回答一个前置问题:这次已有数据同步,走 RDMA 还是走 TCP?这个选路,项目里叫"载波"。

配置项repl_full_transport有三档:

  • tcp:始终走 TCP,不探测 RDMA,最保守;
  • rdma:强制走 RDMA,如果环境不行就报错或走兜底;
  • auto(推荐)先探测机器上有没有可用的 RDMA 网卡,有就 RDMA,没有就自动落到 TCP。

auto是默认推荐的,它的价值在于"开发机没网卡也能跑主从"——程序不依赖特定硬件,真网卡环境自动提速,没有环境自动退化为 TCP。

5.3 探测逻辑:repl_rdma_probe()怎么判断"能不能用"

auto落到实处的是repl_rdma_probe()这个函数。它做的事,逻辑上分三步:

第一步,遍历系统里的 RDMA 设备。ibv_get_device_list拿到所有设备列表。如果一台都没有,直接返回"没有",载波走 TCP。

第二步,挑一个"可用"的设备。项目不只看"有没有设备",还要求设备对应的网口处于活跃状态ibv_query_port查端口状态是不是IBV_PORT_ACTIVE)。因为 RDMA 设备可能存在但链路没起来,那样没法用。挑设备时还有个细节:如果之前已经缓存了本机网卡 IP(第 3 章提过),就优先选和这个网卡关联的 RDMA 设备(repl_rdma_ibdev_has_netif),避免挑到和实际网卡对不上的设备。

第三步,做一次"地址解析"冒烟。建一个连接标识,绑定本机 IP,调rdma_resolve_addr解析本机地址自己,能等到ADDR_RESOLVED事件就算通过。这等于验证"这块网卡的路径真的走得通",而不只是"设备存在"。

探测通过后,项目还会做一件聪明的事:检查这个设备的名字是不是以rxe开头(软件 RDMA)。如果是,就标记为软设备,后续给它套一个运行时软上限(第 9 章专讲)。这体现了项目对软 RDMA 的态度:能用,但知道它性能差,不按真网卡的大参数跑

5.4 探测通过之后:监听口与"同机映射"

载波定下来走 RDMA 之后,主库就要开一个RDMA 专用的监听口,端口号是复制端口 + 200REPL_RDMA_PORT_OFF 200)。也就是说,主库在 35001 上服务客户端、在复制口上做新增数据同步,同时在 35201 上专门等 RDMA 已有数据同步的连接——三条通道互不干扰。

这里有一个联调必踩的坑,项目专门处理了:配置里写127.0.0.1,但 RDMA 不能走 loopback。RDMA 依赖真实网卡,127.0.0.1是虚拟回环,没有对应的 RDMA 设备。所以项目在探测时就把本机网卡的真实 IP 缓存下来,一旦发现master_host=127.0.0.1,就把对端地址映射成这个真实 IP:

const char *repl_rdma_effective_peer_host(const char *configured, ...) { if (strcmp(configured, "127.0.0.1") != 0) return configured; // 不是回环,直接用 if (repl_rdma_pick_local_ipv4(buf, buflen) == 0) return buf; // 映射成本机网卡 IP ... }

同样,repl_rdma_pick_local_ipv4负责选本机的哪块网卡来绑地址。它会遍历所有网卡,给每块打分en/eth开头的最优先,ib(InfiniBand)次之,docker0/veth这些虚拟网卡直接排除;还要跳过回环、跳过127.x和私有网段,最后选得分最高的。双机场景还会进一步挑和对方同网段的本地 IP(repl_rdma_pick_ipv4_for_peer),避免软 RDMA 走错网卡。

5.5 小结:载波就绪,接下来是协议本身

到这一步,"走 RDMA 还是 TCP"已经定了:探测通过 → 走 RDMA 专用口;没通过 → 自动 TCP。但"能走 RDMA"离"能把文件传过去"还有距离——接下来需要在 RDMA 连接上跑一套完整的握手和数据收发协议。这套协议就是项目自定义的BLK3(强调一下:这是项目自定义的应用层协议,不是 RDMA 标准规定的,第 4 章已经讲过它怎么把 Send/Recv 和 RDMA Write 两种标准能力组合起来)。下一章就逐行拆 BLK3 的握手时序——一条全量连接从建好到传完文件,中间每一步谁发谁收、谁等谁、为什么要那个顺序。

第 6 章:BLK3 握手时序——一条已有数据同步连接的生命周期

上一章定了"走 RDMA"这条载波,接下来要在 RDMA 连接上跑一套完整的协议,把整份快照文件从主库传到从库。这套协议就是项目自定义的BLK3——再次强调,BLK3 是项目自定义的应用层协议,不是 RDMA 标准规定的。RDMA 标准只提供了 Send/Recv 和 RDMA Write 这两种能力(第 4 章),而"怎么用它们组织成一次可靠、可校验、可测速的全量同步",完全是项目自己设计的。本章把这条全量连接从建好到传完的完整时序拆开。

6.1 一个核心概念:先"对表"再"灌货"

整套 BLK3 的握手,可以用一句话概括它的设计意图:

先用 Send/Recv 把"我们要开始同步了、文件多大、写进哪块内存"这些短消息一对一对清楚(对表),再用 RDMA Write 把整份文件灌过去(灌货),最后再验证一次(验货)。

为什么需要这么啰嗦的握手?因为 RDMA Write 是"单方面直写对方内存"的危险操作。主库拿着从库给的"地址 + rkey + 长度"就能把数据直接写进从库内存,所以在动手写之前,双方必须把所有细节确认清楚——否则写错地址、写超长度,后果是内存破坏,而不是 TCP 那种"丢一个包重传一下"。

6.2 完整的令牌序列

BLK3 的连接建立之后(第 3 章的 ESTABLISHED),会走这样一串令牌。我按时间顺序列出来,每个都标上"谁发、谁收、干什么":

主库 ──SREADY──> 从库 主库先发"我准备好了" 从库 ──CREAY+RDPR──> 主库 从库回"创建好了 + 准备好接收" 主库 ──RDGO──> 从库 主库发"开始" 主库 ──BLK3头──> 从库 主库发"模式=BLK3,文件长 X 字节" 从库 ──MRINFO──> 主库 从库发"写进这块内存:地址+rkey+长度" 主库 ──RDMA Write──> 从库 主库按大块直接写进从库内存(第 7 章) 主库 ──BDONE──> 从库 主库发"货齐了" 从库 ──RDOK / RDFL──> 主库 从库验 CRC 后回"成功 / 失败" 主库 ──REPLMETA──> 从库 主库发"这次快照对应的序号 M"

这九个令牌在repl_rdma.c里都有对应的常量定义:

#define REPL_RDMA_SREADY 0x53524459u /* "SRDY" */ #define REPL_RDMA_CREAY 0x43524541u /* "CREA" */ #define REPL_RDMA_RDPR 0x52445052u /* "RDPR" */ #define REPL_RDMA_RDGO 0x5244474Fu /* "RDGO" */ #define REPL_RDMA_BLK3 0x424C4B33u /* "BLK3" */ #define REPL_RDMA_BDONE 0x42444F4Eu /* "BDON" */ #define REPL_RDMA_RDOK 0x52444F4Bu /* "RDOK" */ #define REPL_RDMA_RDFL 0x5244464Cu /* "RDFL" */

注意这些 4 字节魔数——它们就是这套自定义协议的"身份证明"。主库和从库每收到一个令牌都要校验它是不是预期的那个(ntohl(...) != REPL_RDMA_XXX就报错退出)。任何一端如果版本不同、魔数对不上,直接明确失败,绝不 silent 错传。这也是自定义协议的一个好处:协议的每一步都能被验证

6.3 每个令牌背后:一个"谁等谁"的问题

握手顺序看着是流水账,但每一步的顺序都是踩坑踩出来的。挑几个关键点讲"为什么是这个顺序"。

第一,主库先发 SREADY,从库回 CREAY+RDPR。建连之后主库立刻post_recv(准备收从库的 CREAY+RDPR),然后发 SREADY。从库那边,post_recv要提前投——它连 SREADY 和后面一串消息的接收缓冲,在发 CREAY 之前就全部post_recv好了。这里有一个"先投递接收缓冲,再发自己的消息"的铁律:接收方如果不提前 post_recv,发送方的消息到了会撞上RNR(Receiver Not Ready)错误。所以代码里从库在发 CREAY 之前,已经把 RDGO、BLK3 头的接收缓冲都预投了:

// 从库:在发 CREAY 前预投递 BLK3 recv,避免主库 RDGO 后立刻发 BLK3 时 rxe RNR/丢包 post_recv(&x); // 收 SRDY post_recv(&x); // 收 RDGO post_recv(&x); // 收 BLK3 头 // 然后才发 CREAY+RDPR

第二,主库先发 BLK3 头,再准备快照。这个顺序很反直觉——你可能会想"主库应该先把快照文件读好、映射好,再告诉从库我开始了"。但代码刻意先发 BLK3 头

// ① 立即发 BLK3,绝不在此前 mmap/reg_mr(失败或慢会导致从库「等待 BLK3 头失败」) post_recv(&x); hdr[0] = htonl(REPL_RDMA_BLK3); hdr[1] = htonl((uint32_t)file_len); memcpy(x.send_buf, hdr, 8); post_send(&x, 8);

为什么?因为从库收到 RDGO 之后,就阻塞在"等 BLK3 头"上。如果主库先把快照文件 mmap、reg_mr(对几百 MB 到 GB 级文件可能花很久,甚至失败)再做,从库就一直干等,一旦失败会"等待 BLK3 头失败"。所以 BLK3 头必须先发——先用 8 字节告诉从库"我这边要开始了,文件多大",把从库从等待里解开;然后主库再去慢慢准备快照、和从库发 MRINFO 并行进行(第 7 章)。

第三,从库在发 MRINFO 之前,先 post_recv 了 BDONE。原因很细:主库可能在"发送完成的 CQ 还没处理"的时候,就已经把 bulk 写完并且发 BDONE 了。如果从库等 MRINFO 发出去之后才 post_recv BDONE,主库的 BDONE 可能已经到了而接收缓冲还没挂上去,又撞 RNR。所以代码注释专门写了:

// 必须在 MRINFO 之前投递 BDONE recv:主库可能在 SEND CQ 未返回前已完成 bulk+BDONE if (post_recv(&x) < 0) { ... }

第四,空快照(文件长度 0)也要走完整握手。主库可能没有可同步的数据(从库首次连上但主库是空的)。这时 BLK3 头里file_len=0,从库识别后不会走 bulk,而是直接走一条"空快照"的简化分支:发一个空文件、等 BDONE、回 RDOK、收 REPLMETA。为什么连空快照都要完整走?因为协议的一致性——无论有没有数据,从库都要拿到"这次同步的序号 M"(REPLMETA),才能接上后面的新增数据同步。空快照也得保证这个交接完整。

6.4 MRINFO:把"远程直写"圈定在精确范围内

BLK3 里最关键的一条消息是MRINFO——它从库发给主库,内容就是第 4 章讲过的三人组:接收内存起始地址(8 字节)+ rkey(4 字节)+ 允许写多长(4 字节),共 16 字节。

主库收到 MRINFO 后,第一件事是校验长度

uint32_t remote_len = ntohl(*(uint32_t *)(x.recv_buf + 12)); if (remote_len != (uint32_t)file_len) { // 你允许我写 X,但我要发的文件是 Y,对不上 → 报错 }

这个校验不是可选的——它把"单方面直写"这个能力,从"危险裸奔"变成"精确可控"。主库只会在"从库明确定义过的这块内存、这个长度"范围内写,越界直接拒绝。这也是为什么 MRINFO 一定要用网络字节序(htobe64/htonl)——地址和长度数字发错了,后果是写到内存错误位置。

6.5 验货:BDONE → CRC → RDOK/RDFL

bulk 传完之后,主库发BDONE通知"货齐了"。从库接下来做三件事:

  1. 对整份文件跑一遍KVR1 尾 CRC(第 8 章细讲);
  2. 校验通过 → 发RDOK(成功);校验失败 → 发RDFL(失败),并删除临时文件;
  3. 主库收到 RDOK 才算本次已有数据同步成功;收到 RDFL 或中途出错,就在同一条连接上走 TCP 兜底(第 9 章)。

主库等 RDOK 有个细节:它要先post_recv挂好接收缓冲,再发 BDONE,再等 RDOK。从库那边则有个"先 post_recv REPLMETA,再跑 CRC"的时序——因为 50k 级快照的 CRC 可能很慢,如果等 CRC 完再 post_recv,主库收 RDOK 后会立刻发 REPLMETA,又会撞 RNR。所以从库在 BDONE 之后、跑 CRC 之前,就把 REPLMETA 的接收缓冲预投好了(第 8 章会看到这段)。

6.6 收尾:REPLMETA,一次同步的"序号交接"

最后一条消息是REPLMETA。主库把它发给从库,内容是"这次快照对应的序号 M"。它是一行文本:+REPLMETA <M>

这条消息的意义在于把"已有数据同步"和"新增数据同步"衔接起来。从库加载完快照后,就知道"主库的写操作到了序号 M 为止都在这份快照里了",于是接下来接收新增数据同步时,只处理序号大于 M 的命令——不多不少,正好接上。没有这一条,从库不知道快照和新增命令的分界点,要么重复、要么漏掉。所以 REPLMETA 不是可选的"彩蛋",而是两个同步阶段的分界线

6.7 小结

到这一步,一次完整的 BLK3 已有数据同步的握手就讲完了。它的设计哲学可以总结成三点:

  • 对表优先:动手直写内存之前,先用 Send/Recv 把模式、长度、接收地址全部确认清楚(SREADY→CREAY+RDPR→RDGO→BLK3头→MRINFO);
  • 灌货与验货分离:大块数据用 RDMA Write 单方面灌(不每块确认),传完用 BDONE + 整文件 CRC + RDOK/RDFL 一次性验;
  • 阶段衔接:REPLMETA 把已有数据同步的终点和新增数据同步的起点精确接上。

但握手只是"流程框架",真正把 GB 级文件灌过去的高性能细节——大块、流水线、共享快照——在数据收发两侧。下一章进入主库侧:怎么把快照文件零拷贝地喂给网卡,以及多从库同时全量时怎么只读一次。

第 7 章:主库侧 BLK3——零拷贝发送与流水线

握手协议讲完了,本章进入数据面:主库怎么把整份 GB 级快照文件高效喂给网卡。核心就两件事——怎么读快照(零拷贝)和怎么发(大块 + 流水线),外加一个多从库共享的优化。

7.1 快照怎么读:从 mmap 到"堆缓冲 + pread"

一个直觉的做法是把快照文件mmap进内存,让网卡直接 DMA 读这块映射。项目早期确实这么干过,但最终统一改成了"堆缓冲 + pread"

static int snap_cache_load_heap(const char *path, size_t fl, size_t map_len, void **map_out) { int rf = open(path, O_RDONLY); map = repl_rdma_alloc_mr_buf(map_len); // 页对齐的堆缓冲 size_t got = 0; while (got < fl) { // pread 分次把文件读进堆缓冲 ssize_t n = pread(rf, map + got, fl - got, (off_t)got); ... } return 0; }

为什么放弃 mmap?主要有两个原因,都是真实踩坑换来的:

  • 百 MB 级文件的 mmap 有段错误风险。在某些网卡驱动(尤其 iWARP)下,直接对 mmap 出来的内存ibv_reg_mr注册远程写会段错误。堆缓冲 + pread 是更稳的路径,真网卡、软 RDMA 都能用。
  • 避免"一次把整个文件映射进去"的隐式开销。堆缓冲可以精确控制分配、对齐、清零,错误路径也更好清理。

但别误会"零拷贝"这三个字——快照从磁盘读进内存是不可避免的一次 I/O。这里的"零拷贝"指的是内存到网卡这一段:堆缓冲注册成 MR 之后,网卡 DMA 直接从这块内存读走数据,中间不再有第二次拷贝(对比 TCP 的"进内核缓冲再拷一次")。

分配这块缓冲还有个细节:不能对整块百 MB 内存 memset,那会拖慢握手。注释里明确写了"不在此处 memset 整块,RDMA 会写满有效区"——只需要把对齐补齐后多出来的尾巴清掉。

7.2 快照共享:多从库同时全量只读一次

主库可能同时面对多个从库做已有数据同步。最傻的做法是每个从库都重新读一遍快照、各注册一个 MR——对 GB 级文件是巨大的浪费。项目用了一个快照共享缓存

static struct { char path[512]; // 快照文件路径 off_t size; // 文件大小 time_t mtime; // 修改时间 void *map; // 读出来的堆缓冲 size_t len; // 页对齐后长度 int refcount; // 当前多少个从库在用 } g_snap_cache;

逻辑很清晰:snap_cache_acquire先看缓存里有没有同路径、同大小、同 mtime的快照;有就直接refcount++复用同一块内存,每个从库各自注册自己的 MR(因为 MR 属于各自的 PD),但底层内存只读一次;没有就重新读一遍并存进缓存。用完refcount--,归零才真正释放。日志里那句"复用快照 MR 缓冲(ref=N)"就是这条路在走。

这样多从库并行全量时,磁盘只读一次,内存只占一份,CPU 和内存都省。

7.3 怎么发:大块 + 流水线 + 批量 signal

现在数据在内存里、MR 注册好了,开始发。这是 BLK3 性能的核心,分三层:

第一层,大块(chunk)。文件按1–4MB 一块切分(配置repl_rdma_chunk_mb,默认 4MB)。一次 RDMA Write 写一块。为什么 1–4MB?因为每发一块都有一个"提交请求 + 硬件处理"的开销,块太小(比如早期那种几百字节的微段)会把时间都耗在琐碎请求上;块太大又超出单次写的能力。4MB 是在"请求次数少"和"单次写不超限"之间的平衡。

第二层,流水线(pipeline)。如果发一块、等一块完成,网卡大部分时间在空等。项目把多条写请求同时提交在途。

while (off < file_len && batch < pipeline) { cl = min(file_len - off, chunk); // 一块的长度 wrs[batch] = /* RDMA Write,写对端 remote_addr+off */; if (batch > 0) wrs[batch-1].next = &wrs[batch]; // 链表串起来 off += cl; batch++; } ibv_post_send(qp, &wrs[0], &bad); // 一次提交整个链表

看两个细节:wrs[batch-1].next把一批请求串成链表ibv_post_send一次就能提交整条链——不用每条发一次系统调用;remote_addr + off让每条写落在从库内存的不同偏移,正好把文件按序填满。

第三层,批量 signal(signal_batch)。第 4 章讲过,请求标了IBV_SEND_SIGNALED完成后才会在 CQ 出记录。如果每条都标,每完成一条就要 poll 一次 CQ,吞吐被拖死。项目每 N 条才标一次signal_batch,默认 8),这样"确认"的成本被摊薄到每 8 条一次:

since_signal++; int sig = last || (since_signal >= sig_batch) || (batch+1 >= pipeline); wrs[batch].send_flags = sig ? IBV_SEND_SIGNALED : 0; if (sig) { signaled++; since_signal = 0; }

但问题来了:没标 signal 的请求完成了,CQ 里没有记录,你怎么知道它完了?答案在 RDMA 的语义里——RDMA Write 是顺序提交的,硬件按序执行。所以只要"第 N 条"(标了 signal 的那条)完成,就说明它前面的所有未 signal 请求也都完成了。于是rdma_drain_writes只需等signaled条 signal 记录全部出现,就等于整批完成了。这也是流水线能大规模铺开的关键——确认成本不随请求数线性增长

7.4 软 RDMA 的降参

这套大参数(大块、深流水线、稀疏 signal)在真网卡上没问题,但在软件 RDMA(rxe)上会出问题——软件模拟扛不住。项目做了个运行时软上限:

static unsigned g_rdma_soft_chunk_cap = 32; // rxe: chunk ≤ 32MB static unsigned g_rdma_soft_pipeline_cap = 64; // rxe: pipeline ≤ 64 static unsigned g_rdma_soft_signal_cap = 64; // rxe: signal ≤ 64

探测到 rxe(设备名以rxe开头)时,这些"有效值"就被夹到软上限以下。注意它不改配置,只是运行时把 chunk/pipeline/signal 的实际值压小——配置可以写得很大,但软设备实际用不了那么大。第 9 章还会讲它更细的作用。

7.5 发完与测速

bulk 全部提交并 drain 完成后,主库发 BDONE(第 6 章)。然后打一行 Gbps 测速日志:

double bulk_sec = (t1.tv_sec - bulk_t0.tv_sec) + (t1.tv_nsec - bulk_t0.tv_nsec)/1e9; double bulk_gbps = bulk_sec > 0 ? (file_len * 8.0 / bulk_sec / 1e9) : 0; fprintf(stderr, "repl: RDMA 全量完成 %zu 字节,耗时 %.0f ms,约 %.2f Gbps(mode=BLK3 ...)\n", file_len, bulk_sec*1000.0, bulk_gbps, ...);

注意带宽只算 bulk 那一段(bulk_t0t1),不含握手——这样才能真实反映"灌数据"的速度。对端地址、chunk、pipeline、signal 全打进去,方便对比调参。

7.6 小结

主库侧就三件事:读快照(堆缓冲 + pread,多从库共享只读一次)、发数据(大块 + 流水线 + 批量 signal,让网卡满载)、测速(只算 bulk 段的 Gbps)。这套设计把"单方面直写"这个 RDMA Write 的能力发挥到极致——但"写进从库的哪块内存、怎么验"是另一半。下一章翻到从库侧:怎么准备接收内存、怎么让网卡直接写进文件、怎么边收边算 CRC 验货。

第 8 章:从库侧 BLK3——零拷贝接收与 CRC 验货

主库把文件灌过来了(第 7 章),从库这边要做三件事:准备接收内存、让数据落进文件、验 CRC 确认。这是 BLK3 里最讲究时序的一章,也是第 6 章埋下的所有"先 post_recv 防 RNR"的真正落点。

8.1 从库怎么"允许"主库直写:准备 MR 缓冲

第 6 章讲过,从库要通过 MRINFO 把"写进哪块内存、钥匙、多长"告诉主库。那这块内存怎么准备?rdma_slave_rx_buf_setup给出了两套方案,优先用文件 mmap:

方案一(优先):文件 mmap,RDMA 直写落盘文件。从库先ftruncate把临时文件扩到目标大小,再mmap(MAP_SHARED)映射进内存,然后把这个映射注册成 MR:

if (ftruncate(tfd, (off_t)map_len) == 0) { fm = mmap(NULL, map_len, PROT_READ|PROT_WRITE, MAP_SHARED, tfd, 0); if (fm != MAP_FAILED) { mr = ibv_reg_mr(pd, fm, map_len, LOCAL_WRITE|REMOTE_WRITE); if (mr) { rx_buf.map = fm; rx_buf.is_file_mmap = 1; return 0; } } }

这个方案的妙处:主库的 RDMA Write 直接把数据写进和磁盘文件共享的映射内存——数据到了,文件内容也就到了,收完之后根本不需要再把整份内存pwrite回磁盘。这是"零拷贝接收"的最彻底形态:网卡 DMA 写内存 = 写文件。

方案二(兜底):堆缓冲,收完再落盘。如果文件 mmap 失败(比如设备不支持对这种映射做远程写),退回堆缓冲 + 收完pwrite

out->map = repl_rdma_alloc_mr_buf(map_len); // 页对齐堆缓冲 out->mr = ibv_reg_mr(pd, out->map, map_len, acc);

两套方案,日志各打一句区分("接收缓冲=文件 mmap" / "接收缓冲=堆内存"),方便排错。不管哪套,注册 MR 都要LOCAL_WRITE|REMOTE_WRITE权限——REMOTE_WRITE 是主库能直写进来的前提(第 2 章)。

8.2 一个关键时序:先发 MRINFO,再等 BDONE

从库准备好 MR 缓冲后,把地址 + rkey + 长度封装成 16 字节 MRINFO 发给主库。然后阻塞等 BDONE(主库传完文件后的通知)。

但这里有个非常细的时序陷阱,代码注释点得清清楚楚:

// 必须在 MRINFO 之前投递 BDONE recv:主库可能在 SEND CQ 未返回前已完成 bulk+BDONE if (post_recv(&x) < 0) { ... }

为什么?因为从库发 MRINFO 之前,就得先把"收 BDONE"的接收缓冲挂在 RQ 上。主库可能在这之前就已经把 bulk 写完、BDONE 都发出去了——如果从库等 MRINFO 发出去才 post_recv,BDONE 到了没地方放,撞 RNR。所以先 post_recv,再发 MRINFO,是硬性顺序。

8.3 边收边验:CRC 怎么算

收到 BDONE 后,从库对整份文件验 CRC。这里的"边收边算"指的是:数据是 RDMA 直写进来的,在内存里就是完整的,所以 CRC 直接对内存跑一遍流式累加:

static int rdma_slave_kvr1_crc_persist(int tfd, const rdma_slave_rx_buf_t *buf, size_t total) { kvs_kvr1_crc_stream_t crc_st; kvs_kvr1_crc_init(&crc_st); kvs_kvr1_crc_feed(&crc_st, buf->map, total); // 对内存跑 CRC if (buf->is_file_mmap) { ftruncate(tfd, total); // 文件 mmap:CRC 在内存完成,不阻塞 } else { rdma_slave_heap_pwrite(tfd, buf->map, total); // 堆缓冲:先落盘 } return kvs_kvr1_crc_verify(&crc_st); // 与文件尾 4 字节比对 }

注意文件 mmap 路径的优雅之处:CRC 完全在内存里算,不碰磁盘 I/O,算完再 ftruncate 把文件长度修正——persist_load之后读页缓存即可,不会因为收完后再全盘读一遍来验 CRC。这正是设计文档里"边收边验,避免收完再全盘读一遍"的落地。

8.4 又一个时序:先 post_recv REPLMETA,再跑 CRC

第 6 章埋过一个伏笔:从库要先 post_recv REPLMETA,再跑 CRC。这里兑现:

if (post_recv(&x) < 0) { /* post_recv(REPLMETA) 失败 */ } REPL_VERBOSE("... 收到 BDONE,CRC+落盘 %zu 字节…", total); ibv_dereg_mr(rx_buf.mr); int crc_ok = rdma_slave_kvr1_crc_persist(tfd, &rx_buf, total) == 0;

原因在注释里:50k 级快照的 CRC 可能很慢。如果等 CRC 跑完再 post_recv REPLMETA,主库收 RDOK 后会立刻发 REPLMETA,接收缓冲还没挂上,又撞 RNR。所以从库在 BDONE 之后、CRC 之前,就把 REPLMETA 的接收缓冲预投了——用"预投"来吞掉"我这边还在算 CRC,你那边随时可能发"的时间差。

8.5 验货结果:RDOK 还是 RDFL

CRC 跑完,两条路:

  • 通过→ 发RDOK(成功),主库收到后发 REPLMETA,从库拿到序号 M,本次已有数据同步成功;
  • 失败→ 发RDFL(失败),删除临时文件unlink(out_path)),主库收到 RDFL 后在同一条连接上走 TCP 兜底。

为什么失败要删文件?因为这份快照是脏的,绝不能留下来被当成有效数据加载。这也是"业务层校验"的意义——RDMA 硬件保证"字节按序送到了",但"送的是不是那份对的快照"必须靠这份 CRC + RDOK/RDFL 来把关。

8.6 空快照的完整走法

第 6 章提过空快照(文件长度 0)。从库识别到 BLK3 头里file_len=0后,走一条简化分支:不准备 MR 缓冲(没有数据),只创建空文件、等 BDONE、回 RDOK、收 REPLMETA。连空快照都完整握手,是为了保证"序号交接"这条链路在任何情况下都走通——从库总能拿到 M,接上后续的新增数据同步。

8.7 小结

从库侧就四件事:准备 MR 缓冲(优先文件 mmap 让 RDMA 直写落盘,兜底堆缓冲)、发 MRINFO(先 post_recv BDONE 再发,防 RNR)、验 CRC(内存流式算,文件 mmap 路径不碰磁盘)、回 RDOK/RDFL(先 post_recv REPLMETA 再跑 CRC,也是防 RNR)。两处"先 post_recv"的时序,是整条从库路径最容易写错、也最容易在软 RDMA 上翻车的地方。

到这里,BLK3 的完整数据流就通了:主库读快照零拷贝发送(第 7 章)→ 从库零拷贝接收 + CRC 验货(本章)。下一章把镜头拉远,讲这套系统的可靠性设计——为什么"传输层可靠"不等于"数据正确",失败怎么兜底,以及软 RDMA 和握手里那些坑。

第 9 章:可靠性分层、容错与全部踩坑

前面六章把 BLK3 的"正常路径"讲完了。但生产系统的另一半是"出了错怎么办"。这一章先讲清楚可靠性的两个层次,再讲容错设计,最后把所有真实踩过的坑汇总成清单——这些坑几乎每一个都对应一次联调失败。

9.1 可靠性分两层,别混

这是理解整套容错的总钥匙。RDMA 系统的可靠性由两个完全不同的层次承担:

传输层(内核/网卡负责):在 RC 可靠连接下,硬件保证字节按序送达、不丢、不重,出错自动重传。它保证的是"字节到了"。

业务层(应用自己负责):RDMA 不保证"送到的是不是那份对的快照"。发错文件、长度头写错、磁盘读出来已经坏了——传输层都不知道。所以 BLK3 用KVR1 文件尾 CRC + BDONE + RDOK/RDFL这套业务层校验来把关(第 8 章)。

一个类比:传输层保证"快递准时送到",业务层保证"送的是你要的那个包裹"。去掉每块 ACK 不等于放弃校验——只是把校验从"每一小块"上移到"整份文件"(第 4 章已埋过这个点)。RDMA 可靠连接已经处理了"丢包重传",所以项目不需要每块 ACK;但文件对不对,必须自己验。

9.2 容错的第一道保险:TCP 兜底

RDMA 整段传输失败时,项目不会让同步卡死,而是在同一条控制连接上走 TCP 兜底——把同一份快照文件用 TCP 再传一遍。这是"已有数据同步"的最后防线:RDMA 是加速选项,不是唯一路径;没有真网卡、RDMA 中途失败、RDFL,都能退到 TCP 把数据传完。这和 eBPF 篇"永远留退路"是同一个设计哲学。

9.3 容错的第二道保险:软 RDMA 运行时降参

第 7 章提过,探测到 rxe(软件 RDMA)会套运行时软上限。这里讲完整:

static unsigned g_rdma_soft_chunk_cap = 32; // chunk ≤ 32MB static unsigned g_rdma_soft_pipeline_cap = 64; // pipeline ≤ 64 static unsigned g_rdma_soft_signal_cap = 64; // signal ≤ 64

为什么软 RDMA 要大参数会出问题?因为 rxe 是软件模拟RDMA 协议,所有"硬件该干的活"(DMA、重传、乱序整理)都退化成 CPU 软件处理。大 chunk、深流水线在真网卡上让硬件满载,在 rxe 上就是让 CPU 满载 + 触发各种软件栈极限(RNR、WR_FLUSH_ERR)。所以项目对 rxe 设了三重软上限,并且不改配置——配置可以写很大,运行时把实际值压下来。这套"软设备降参"让 rxe 既能用于联调,又不会把程序拖崩。

9.4 全部踩坑清单(都有代码依据)

坑 1:RNR(Receiver Not Ready,接收方未就绪)。RDMA 编程最经典的错误。发送方的消息到了,接收方却没提前 post_recv 挂缓冲,返回RNR_RETRY_EXC_ERR(状态码 13)。项目里几乎每个"等对端消息"步骤前都有 post_recv,专门防这个。第 6、8 章那两处"先 post_recv"的时序,本质都是防 RNR。

坑 2:WR_FLUSH_ERR(队列被冲掉)。出现场景很多:QP 出错被 flush、对端断开、请求的缓冲区没注册 MR、或者一次性提交太多请求导致 CQ 记录溢出。它往往是"上游某个错误"的连锁反应,排错要看是哪个操作先报的错。

坑 3:MR 必须页对齐,且权限位要够。网卡 DMA 以页为单位。长度要补到整页(repl_rdma_mr_map_len),否则注册失败或行为异常。而要让对端能远程写,必须声明IBV_ACCESS_REMOTE_WRITE(第 2 章)。从库接收缓冲带这个权限,主库发送缓冲不需要——权限最小化。

坑 4:REM_ACCESS_ERR(远程访问错误)。对端给的 rkey 无效、地址越界、或者写进了没声明 REMOTE_WRITE 的内存。MRINFO 的长度校验(第 6 章)就是为了把这类错误拦在动手之前。

坑 5:同机 iWARP/rxe 上disconnect/destroy_id可能挂死。这是项目特意处理过的一个深坑:在某些软 RDMA 实现上,rdma_disconnectrdma_destroy_id会阻塞(D 状态)很久,如果复制线程同步等它,整个复制就卡死,连 TCP 兜底都走不了。项目解法是异步释放——把销毁动作丢给一个分离线程,不在关键路径上等:

static void rdma_destroy_id_nonblocking(struct rdma_cm_id *id) { rdma_id_drop_later(id, 0); // 起一个 detached 线程去 disconnect+destroy }

另一个相关的修复是去掉重复的rdma_destroy_qp——早期版本在释放路径上销毁了两次 QP,造成 double free。这类"释放路径"的坑在 RDMA 里尤其多,因为对象多、归属复杂(PD/QP/CQ/MR/CM id)。

坑 6:CM 事件要排空,超时要用墙钟。第 3 章讲过:rxe 在 connect 后可能重复投递 ADDR_RESOLVED,必须循环排空;而超时计算曾有一个"把毫秒当秒再乘 1000"的 bug,导致 rxe 上单步轮询要等 4 小时才报错,改成墙钟后正常。这是"异步事件模型"最容易翻车的两个点。

坑 7:listen PD 为空。主库 listen 时如果 bind 到0.0.0.0,在 iWARP/rxe 上 verbs 句柄可能没就绪,导致 accept 时拿不到 PD。项目因此必须 bind 到 RNIC 网卡的真实 IPrepl_rdma_fill_listen_addr),第 3 章那套"挑本地网卡 IP"的铺垫就是为这个。

坑 8:超时要按快照体积放大。几百 MB 到数 GB 的传输,固定的短超时必然误报。项目用rdma_timeout_sec_for_bytes按快照大小动态算超时:

static unsigned long long rdma_timeout_sec_for_bytes(size_t snap_bytes) { unsigned long long sec = 300; unsigned long long extra = snap_bytes / (16ULL*1024*1024); // 每 16MB 加 1 秒 if (extra > 3300) extra = 3300; return sec + extra; }

等 BDONE、等 RDOK、bulk 超时都用它,避免"大文件还没传完就超时"。

坑 9:CQ 轮询策略要分场景。项目里两种等完成的方式都用了:wait_cq_op在软 RDMA 上先自旋 4096 次再 poll,真网卡则 poll 50ms + usleep 让出 CPU;rdma_drain_writes(第 7 章)在软 RDMA 上自旋上限 65536。原则是:软 RDMA 上多自旋少睡(因为软件完成很快但 poll 唤醒慢),真网卡上多睡少自旋(避免占满 CPU)。CQ 轮询没有银弹,得按设备特性调。

9.5 小结

这一章把可靠性设计串成了一条链:传输层 RC 保证有序不丢 → 业务层 CRC + RDOK/RDFL 保证数据正确 → TCP 兜底保证"即使 RDMA 彻底不行也能传完" → 软 RDMA 降参保证"没有真网卡也能联调"。每一层都在说"出错别怕,我有退路"——而退路之间又互相独立,任何一个坏掉都不会拖垮整个同步。

结语

回头看,从 RDMA 的理论底座(第 1 章为什么快、第 2 章四个 verbs 对象、第 3 章 CM 连接、第 4 章两种数据通道),到项目自定义的 BLK3 协议(第 5 章载波、第 6 章握手时序、第 7 章主库零拷贝发送、第 8 章从库零拷贝接收),再到可靠性工程(第 9 章容错与踩坑)。

贯穿始终的,其实是三句话:

第一,自定义协议建立在标准原语之上。BLK3 是项目自定义的应用层协议——RDMA 标准只提供 Send/Recv、RDMA Write 这些能力,而"怎么用它们把整份快照可靠地传过去"是项目自己设计的。这是理解本项目的关键:标准的归标准,业务的归业务

第二,可靠传输不等于数据正确。传输层(RC)只保证"字节按序到了",数据对不对必须靠业务层(KVR1 尾 CRC + RDOK/RDFL)自己把关。两个层次别混,是 RDMA 编程最重要的一课。

第三,永远留退路。RDMA 是加速选项,不是唯一路径——TCP 兜底让"RDMA 彻底不行也能传完",软 RDMA 降参让"没有真网卡也能联调",条件编译让"没有 RDMA 库也能编过"。每一项都在说:新技术是优化项,正确性永远有兜底。

如果你把 eBPF 篇和这篇连起来看,会发现项目对"高性能"的态度是一致的:能用硬件加速就用,但永远准备一套不依赖它的慢速兜底。这不是保守,而是对生产系统负责——快是加分项,不坏才是底线。

← 返回列表