第 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_host127.0.0.1就把对端地址映射成这个真实 IPconst 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开头的最优先ibInfiniBand次之docker0/veth这些虚拟网卡直接排除还要跳过回环、跳过127.x和私有网段最后选得分最高的。双机场景还会进一步挑和对方同网段的本地 IPrepl_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── 从库 主库先发我准备好了 从库 ──CREAYRDPR── 主库 从库回创建好了 准备好接收 主库 ──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从库回 CREAYRDPR。建连之后主库立刻post_recv准备收从库的 CREAYRDPR然后发 SREADY。从库那边post_recv要提前投——它连 SREADY 和后面一串消息的接收缓冲在发 CREAY 之前就全部post_recv好了。这里有一个先投递接收缓冲再发自己的消息的铁律接收方如果不提前 post_recv发送方的消息到了会撞上RNRReceiver Not Ready错误。所以代码里从库在发 CREAY 之前已经把 RDGO、BLK3 头的接收缓冲都预投了// 从库在发 CREAY 前预投递 BLK3 recv避免主库 RDGO 后立刻发 BLK3 时 rxe RNR/丢包 post_recv(x); // 收 SRDY post_recv(x); // 收 RDGO post_recv(x); // 收 BLK3 头 // 然后才发 CREAYRDPR第二主库先发 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 未返回前已完成 bulkBDONE if (post_recv(x) 0) { ... }第四空快照文件长度 0也要走完整握手。主库可能没有可同步的数据从库首次连上但主库是空的。这时 BLK3 头里file_len0从库识别后不会走 bulk而是直接走一条空快照的简化分支发一个空文件、等 BDONE、回 RDOK、收 REPLMETA。为什么连空快照都要完整走因为协议的一致性——无论有没有数据从库都要拿到这次同步的序号 MREPLMETA才能接上后面的新增数据同步。空快照也得保证这个交接完整。6.4 MRINFO把远程直写圈定在精确范围内BLK3 里最关键的一条消息是MRINFO——它从库发给主库内容就是第 4 章讲过的三人组接收内存起始地址8 字节 rkey4 字节 允许写多长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/RDFLbulk 传完之后主库发BDONE通知货齐了。从库接下来做三件事对整份文件跑一遍KVR1 尾 CRC第 8 章细讲校验通过 → 发RDOK成功校验失败 → 发RDFL失败并删除临时文件主库收到 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→CREAYRDPR→RDGO→BLK3头→MRINFO灌货与验货分离大块数据用 RDMA Write 单方面灌不每块确认传完用 BDONE 整文件 CRC RDOK/RDFL 一次性验阶段衔接REPLMETA 把已有数据同步的终点和新增数据同步的起点精确接上。但握手只是流程框架真正把 GB 级文件灌过去的高性能细节——大块、流水线、共享快照——在数据收发两侧。下一章进入主库侧怎么把快照文件零拷贝地喂给网卡以及多从库同时全量时怎么只读一次。第 7 章主库侧 BLK3——零拷贝发送与流水线握手协议讲完了本章进入数据面主库怎么把整份 GB 级快照文件高效喂给网卡。核心就两件事——怎么读快照零拷贝和怎么发大块 流水线外加一个多从库共享的优化。7.1 快照怎么读从 mmap 到堆缓冲 pread一个直觉的做法是把快照文件mmap进内存让网卡直接 DMA 读这块映射。项目早期确实这么干过但最终统一改成了堆缓冲 preadstatic 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 缓冲refN就是这条路在走。这样多从库并行全量时磁盘只读一次内存只占一份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_addroff */; 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让每条写落在从库内存的不同偏移正好把文件按序填满。第三层批量 signalsignal_batch。第 4 章讲过请求标了IBV_SEND_SIGNALED完成后才会在 CQ 出记录。如果每条都标每完成一条就要 poll 一次 CQ吞吐被拖死。项目每 N 条才标一次signal_batch默认 8这样确认的成本被摊薄到每 8 条一次since_signal; int sig last || (since_signal sig_batch) || (batch1 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在真网卡上没问题但在软件 RDMArxe上会出问题——软件模拟扛不住。项目做了个运行时软上限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 GbpsmodeBLK3 ...\n, file_len, bulk_sec*1000.0, bulk_gbps, ...);注意带宽只算 bulk 那一段bulk_t0到t1不含握手——这样才能真实反映灌数据的速度。对端地址、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方案一优先文件 mmapRDMA 直写落盘文件。从库先ftruncate把临时文件扩到目标大小再mmap(MAP_SHARED)映射进内存然后把这个映射注册成 MRif (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 失败比如设备不支持对这种映射做远程写退回堆缓冲 收完pwriteout-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 未返回前已完成 bulkBDONE if (post_recv(x) 0) { ... }为什么因为从库发 MRINFO 之前就得先把收 BDONE的接收缓冲挂在 RQ 上。主库可能在这之前就已经把 bulk 写完、BDONE 都发出去了——如果从库等 MRINFO 发出去才 post_recvBDONE 到了没地方放撞 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); // 文件 mmapCRC 在内存完成不阻塞 } 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(... 收到 BDONECRC落盘 %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 还是 RDFLCRC 跑完两条路通过→ 发RDOK成功主库收到后发 REPLMETA从库拿到序号 M本次已有数据同步成功失败→ 发RDFL失败删除临时文件unlink(out_path)主库收到 RDFL 后在同一条连接上走 TCP 兜底。为什么失败要删文件因为这份快照是脏的绝不能留下来被当成有效数据加载。这也是业务层校验的意义——RDMA 硬件保证字节按序送到了但送的是不是那份对的快照必须靠这份 CRC RDOK/RDFL 来把关。8.6 空快照的完整走法第 6 章提过空快照文件长度 0。从库识别到 BLK3 头里file_len0后走一条简化分支不准备 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 全部踩坑清单都有代码依据坑 1RNRReceiver Not Ready接收方未就绪。RDMA 编程最经典的错误。发送方的消息到了接收方却没提前 post_recv 挂缓冲返回RNR_RETRY_EXC_ERR状态码 13。项目里几乎每个等对端消息步骤前都有 post_recv专门防这个。第 6、8 章那两处先 post_recv的时序本质都是防 RNR。坑 2WR_FLUSH_ERR队列被冲掉。出现场景很多QP 出错被 flush、对端断开、请求的缓冲区没注册 MR、或者一次性提交太多请求导致 CQ 记录溢出。它往往是上游某个错误的连锁反应排错要看是哪个操作先报的错。坑 3MR 必须页对齐且权限位要够。网卡 DMA 以页为单位。长度要补到整页repl_rdma_mr_map_len否则注册失败或行为异常。而要让对端能远程写必须声明IBV_ACCESS_REMOTE_WRITE第 2 章。从库接收缓冲带这个权限主库发送缓冲不需要——权限最小化。坑 4REM_ACCESS_ERR远程访问错误。对端给的 rkey 无效、地址越界、或者写进了没声明 REMOTE_WRITE 的内存。MRINFO 的长度校验第 6 章就是为了把这类错误拦在动手之前。坑 5同机 iWARP/rxe 上disconnect/destroy_id可能挂死。这是项目特意处理过的一个深坑在某些软 RDMA 实现上rdma_disconnect和rdma_destroy_id会阻塞D 状态很久如果复制线程同步等它整个复制就卡死连 TCP 兜底都走不了。项目解法是异步释放——把销毁动作丢给一个分离线程不在关键路径上等static void rdma_destroy_id_nonblocking(struct rdma_cm_id *id) { rdma_id_drop_later(id, 0); // 起一个 detached 线程去 disconnectdestroy }另一个相关的修复是去掉重复的rdma_destroy_qp——早期版本在释放路径上销毁了两次 QP造成 double free。这类释放路径的坑在 RDMA 里尤其多因为对象多、归属复杂PD/QP/CQ/MR/CM id。坑 6CM 事件要排空超时要用墙钟。第 3 章讲过rxe 在 connect 后可能重复投递 ADDR_RESOLVED必须循环排空而超时计算曾有一个把毫秒当秒再乘 1000的 bug导致 rxe 上单步轮询要等 4 小时才报错改成墙钟后正常。这是异步事件模型最容易翻车的两个点。坑 7listen 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 超时都用它避免大文件还没传完就超时。坑 9CQ 轮询策略要分场景。项目里两种等完成的方式都用了wait_cq_op在软 RDMA 上先自旋 4096 次再 poll真网卡则 poll 50ms usleep 让出 CPUrdma_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 篇和这篇连起来看会发现项目对高性能的态度是一致的能用硬件加速就用但永远准备一套不依赖它的慢速兜底。这不是保守而是对生产系统负责——快是加分项不坏才是底线。