公司动态

RDMA 从入门到落地(一)

📅 2026/8/14 15:58:45
RDMA 从入门到落地(一)
引言写完 eBPF 那篇之后我想继续把项目里另一条高性能主从复制的技术线讲清楚RDMA。它和 eBPF 恰好是两种相反的思路——eBPF 是在内核里偷看数据RDMA 是干脆绕过内核让网卡直接把数据写进对端内存。我的自研 KV 存储kvstore在做主从全量同步时要把整份快照文件从主库传给从库。传统 TCP 路径要过内核协议栈、反复拷贝慢且耗 CPU。于是项目在标准 RDMA 原语之上自定义了一套完整的传文件协议项目里叫BLK3。我要强调的是BLK3 是项目自定义的应用层协议不是 RDMA 标准规定的——RDMA 标准只提供网卡能远程写对端内存这种能力怎么把这个能力组织成一次可靠、可校验、可测速的全量同步完全是项目自己设计的第 6 章会展开整套握手时序。这篇博客同样以真实代码为教材分三部分、九章第一部分是 RDMA 理论第 14 章是什么、verbs 核心对象、CM 连接、Send/Recv 与 RDMA Write第二部分是项目实战第 58 章载波选择、BLK3 握手、主库零拷贝发送、从库零拷贝接收与 CRC第三部分是可靠性与工程第 9 章容错与踩坑。第 1 章RDMA 是什么为什么快1.1 传统网络路径的痛点先把一次普通 TCP 收发在心里走一遍。进程 A 要把一块数据发给进程 B数据要经过应用把数据放进用户态缓冲系统调用send/recv陷入内核内核拷贝数据到内核态缓冲一次拷贝内核协议栈处理TCP 分段、校验、拥塞控制数据包交给网卡驱动发出对端网卡收包 → 中断/轮询通知内核 →再拷一次到内核缓冲 → 应用recv取走又一次拷贝。这条路径里有几次明显的浪费数据要反复进出内核、反复拷贝而且每次收发都要 CPU 参与系统调用、中断、协议栈处理。数据量越大CPU 越忙吞吐越上不去。1.2 RDMA 的核心网卡直接访问应用内存RDMARemote Direct Memory Access彻底换了个玩法它把收发数据这件事从 CPU 手里接走交给网卡硬件。核心思想一句话发送方网卡直接从应用进程的内存里把数据读走接收方网卡直接把数据写进对端应用进程的内存——全程绕过内核协议栈CPU 基本不参与。这带来了三个零零系统调用数据路径上不再有 send/recv 陷入内核零内核拷贝数据不进内核缓冲用户内存 ↔ 网卡 DMA 直通零 CPU 干预收发、重传、乱序整理都由网卡硬件完成在可靠连接模式下。所以 RDMA 特别适合大数据块传输——比如项目的主从已有数据同步一次要传几百 MB 到数 GB 的快照文件TCP 路径下 CPU 得盯着每一次分段和拷贝RDMA 下数据像水管一样从主库内存直灌从库内存。1.3 RDMA 的硬件形态直读直写应用内存靠的是网卡硬件能力这台网卡叫RNICRDMA-capable Network Interface Card。它有三种形态InfiniBandRDMA 的原生网络网卡和交换机都是专用硬件延迟最低、带宽最高但贵RoCERDMA over Converged Ethernet跑在普通以太网上用 RDMA 协议是现在的工业主流软件 RDMArxeLinux 用软件模拟 RDMA 协议跑在普通网卡上没有硬件加速性能差很多只用来开发和联调。这对项目有个直接后果必须先探测机器上有没有真网卡。项目里repl_rdma_probe()就是干这个的——它遍历系统里的 RDMA 设备ibv_get_device_list还要检查设备对应的网口是否活跃、能否解析到 IP。有意思的是项目不排斥软 RDMA探测到 rxe 也照样走 RDMA 通道但会给它套一个运行时软上限块更小、流水线更浅因为软件模拟扛不住大参数这块第 9 章展开。而对没有 RDMA 设备的环境配置repl_full_transport选auto时会直接走 TCP——RDMA 是加速选项不是硬依赖这和 eBPF 篇的永远留退路是同一个设计哲学。1.4 RDMA 能干什么两种基本操作RDMA 提供两种最常用的数据操作它们是理解后面 BLK3 协议的两块基石Send / Recv消息式收发类似发一封信、收一封信。发送方调post_send接收方必须提前调post_recv准备好接收缓冲两边配对才能完成一次收发。数据是一段一段消息传递的。RDMA Write远程直写发送方不需要接收方参与直接把数据写进接收方事先指定好的一块内存里。它像远程 memcpy——前提是接收方要把我允许你写哪块内存、权限令牌、多长告诉发送方。RDMA Read远程读也是标准原语之一但本项目没用到知道有这回事即可。先记住一个关键区别Send/Recv 需要双方配对、一段一段来RDMA Write 是单方面直写、可以大块连续灌。项目自定义的 BLK3 协议核心正是用 RDMA Write 把整份快照文件按大块直接灌进从库内存只在建连和收尾的短消息上用 Send/Recv第 4 章会细讲为什么这样分工。1.5 回到项目RDMA 在主从复制里的位置kvstore 的主从复制分成两条线第 5 章展开已有数据从库断线重连或首次同步需要主库把整份快照文件拉过去——这是 RDMA 的用武之地大块、一次性、追求带宽新增数据断线之后补的命令流命令小、频率高——那是 eBPF/tcp 的领域和全量 RDMA 是两码事。所以准确地说项目里RDMA 只负责主从已有数据同步这一段而且只在这份快照文件是真网卡传还是TCP 兜底传之间做选择。带着这个定位下一章进入 RDMA 的编程模型——当你ibv_reg_mr、ibv_create_qp的时候你究竟在创建什么。第 2 章verbs 核心对象——PD、QP、CQ、MRRDMA 编程模型里你要先创建几个硬件对象它们共同组成一条能干活的数据通路。这一章讲四个最核心的保护域 PD、队列对 QP、完成队列 CQ、内存区域 MR。它们在项目里都对应repl_rdma.c的真实代码我边讲边对。2.1 保护域 PDProtection DomainPD 可以理解成一套内存与资源的归属集合。你创建的所有 MR、QP 都要归属于某个 PD硬件只允许同一个 PD 内的对象互相访问。它的作用是隔离不同 PD 的资源互不可见防止一个应用误碰另一个应用的内存。项目里有个很细的优化进程级共享 PDrepl_rdma_shared_pd_get。因为主库可能同时有多个从库在做已有数据同步每个连接都新建一个 PD 是浪费共享同一个 PD各连接注册的 MR 就能复用同一套资源也方便多连接共享同一块快照内存第 7 章展开。2.2 队列对 QPQueue PairQP 是收发数据的执行单元一收一发成对存在。它有两个队列发送队列 SQ你往里post_send发送请求接收队列 RQ你往里post_recv投递接收请求。QP 有三种类型项目用的是RC可靠连接Reliable Connection——两端一一对应硬件保证有序、不丢、不重出错还会自动重传。这是理解第 9 章传输层可靠的关键在 RC 下你不需要自己处理丢包重传那是网卡的事。创建 QP 时要指定它的能力发送/接收队列各能容纳多少条未完成的请求max_send_wr/max_recv_wr。这个深度直接决定了后面流水线能开多大第 7 章讲大块并发时会用到。项目里 QP 深度是跟着流水线参数动态算的unsigned qp_wr rdma_eff_pipeline() 64; // pipeline 余量 if (rdma_soft_path()) { if (qp_wr 512) qp_wr 512; } // 软 RDMA 限制 else { if (qp_wr 512) qp_wr 512; if (qp_wr 1024) qp_wr 1024; } // 真网卡加深2.3 完成队列 CQCompletion Queue你往 QP 里post_send/post_recv请求后怎么知道请求做完了答案是 CQ。硬件每完成一个操作就往 CQ 里塞一条完成记录Work CompletionWC。你调ibv_poll_cq从 CQ 里取出来看int n ibv_poll_cq(x-cq, 1, wc); if (n 0) { if (wc.status ! IBV_WC_SUCCESS) { /* 出错如 WR_FLUSH_ERR */ } // 成功wc.byte_len 是实际传输长度 }WC 里带状态码和字节数——所以项目里几乎所有发完等确认的逻辑都是post_send之后轮询 CQ 等对应的 WC。CQ 的深度能容纳多少条 WC要跟 QP 深度匹配否则完成记录塞不下硬件就停了。项目里 CQ 深度取qp_wr * 4还带三级降档2048→1024→512的创建函数因为不同设备能支持的 CQ 深度上限不同static const unsigned tiers[] { 2048, 1024, 512 }; for (i...) { d depth tiers[i] ? depth : tiers[i]; cq ibv_create_cq(verbs, d, NULL, cc, 0); if (cq) return cq; }CQ 还有两种等完成的方式一是忙轮询ibv_poll_cq自旋延迟最低但占 CPU二是配一个完成通道 comp_channelibv_req_notify_cq后阻塞在poll上等内核唤醒。项目里两者都用按场景切换第 9 章讲轮询策略。2.4 内存区域 MRMemory Region——RDMA 的命门RDMA 直写直读的是应用进程的内存但网卡是硬件它不认识你的虚拟地址。所以你必须先把一块内存注册给网卡拿到两个通行证struct ibv_mr *mr ibv_reg_mr(pd, buf, len, IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE); // mr-lkey 本地键本地 post_send 时告诉网卡读/写这块内存 // mr-rkey 远程键告诉对端你可以远程写我这块内存这里有几个必须讲透的细节全是项目踩过的坑第一MR 必须页对齐。网卡 DMA 操作的对象是物理页。项目专门有个函数把长度补齐到整页static size_t repl_rdma_mr_map_len(size_t data_len) { size_t n (data_len page - 1) / page; // 向上取整到整页 return n * page; }注册时用的长度是map_len页对齐后而逻辑文件长度另算——第 7、8 章你会看到MR 对齐至多少字节、快照实际多少字节分两行打印这就是为什么。第二权限位决定能不能被对端碰。只有声明了IBV_ACCESS_REMOTE_WRITE的内存对端才允许远程写进来。注意看项目里两种 MR 权限的差异从库接收缓冲要容对端 RDMA Write 进来用LOCAL_WRITE | REMOTE_WRITE而主库发快照用的发送 MR 只需要本地读权限更小第 7、8 章对应。第三lkey 和 rkey 是两套键。lkey 只在本地 post_send 时用rkey 要连同内存起始地址、长度一起发给对端对端才能远程写。这个地址 rkey 长度的三人组就是 BLK3 协议里MRINFO消息的内容第 6 章会看到它的真实字节布局8 字节地址 4 字节 rkey 4 字节长度。rkey 相当于一把远程内存钥匙发给谁、给多大范围都必须精确——这也是为什么 MRINFO 之后主库要校验对端给出的长度和自己要发的文件长度完全一致才动手。2.5 一张图串起来把四个对象摆到一起一次 RDMA 收发的完整画面是应用内存 ──注册── MRlkey/rkey──归入── PD post_send / post_recv ──提交── QPSQ/RQRC 可靠连接 硬件执行完 ──写一条 WC── CQibv_poll_cq 轮询读取其中收发数据只发生在 QP 上执行完没只看 CQ你的内存被不被允许碰看 MR这些资源属不属于一套看 PD。整个模型非常像提交作业 → 硬件执行 → 查成绩单而这套对象在setup_conn里被一次性建好——项目里每个 RDMA 连接都要过一遍create_comp_channel → create_cq → create_qp → reg_mr这条流水线。第 3 章CM 连接管理——建立一条 RDMA 连接上一章那些硬件对象PD/QP/CQ/MR都是准备好了等干活的零件但怎么让两台机器把这些零件真正连起来是另一件事。RDMA 的连接建立比 TCP 复杂得多要解析对端 IP 对应到哪张 RDMA 网卡、要走哪条路由、要不要协商端口。好在有librdmacmrdma_cm这个库它把这一整套封装成一组很像 socket 的 API。这一章讲清楚这条路。3.1 两个核心概念事件通道与连接标识事件通道rdma_event_channelCM 事件的通知渠道。RDMA 的连接是异步的——你发起一个动作后结果要通过事件告诉你事件通道就是收这些事件的信箱。它带一个 fd可以用poll等它项目里rdma_get_cm_event_timed就是这么实现的。连接标识rdma_cm_id可以把它想成一个待连接对象。它比 TCP 的 socket fd 多了一层状态——要先绑定地址、解析路由最后才能连接。它也承载了 verbs 句柄id-verbs、id-qp第 2 章那些对象最后都挂在它上面。3.2 从库发起方怎么建连看rdma_client_xfer的建连代码流程是固定的一串struct rdma_event_channel *ch rdma_create_event_channel(); // 建信箱 struct rdma_cm_id *id NULL; rdma_create_id(ch, id, NULL, RDMA_PS_TCP); // 建连接对象 // 绑定本地地址 rdma_bind_addr(id, src_addr); // 绑定本地网卡 IP // 解析 rdma_resolve_addr(id, NULL, peer_addr, 5000); // 把对端 IP 解析到 RDMA 设备 rdma_wait_cm_event(ch, RDMA_CM_EVENT_ADDR_RESOLVED, ...); // 等地址已解析 // 拿 PD此刻 id-verbs 才就绪 struct ibv_pd *pd repl_rdma_shared_pd_get(id-verbs); rdma_resolve_route(id, 5000); // 确定路由 rdma_wait_cm_event(ch, RDMA_CM_EVENT_ROUTE_RESOLVED, ...); // 等路由已解析 setup_conn(id, x, pd); // 第 2 章那些对象都在这一步建 rdma_connect(id, conn_param); // 真正发起连接 rdma_wait_cm_event(ch, RDMA_CM_EVENT_ESTABLISHED, ...); // 等连接已建立每一步都值得一句话解释create_idRDMA_PS_TCP这个参数先记住3.5 节专门讲它决定了你能用熟悉的 IP:port 寻址。bind_addr绑定本机的哪个网卡 IP。这里有两个场景项目都处理了——同机联调时配置写的是127.0.0.1但 RDMA 不能走 loopback所以repl_rdma_effective_peer_host会把对端127.0.0.1映射成本机网卡的真实 IP双机时则要挑一个与对端同网段的本地 IPrepl_rdma_pick_ipv4_for_peer否则 rxe 这种软栈会走错网卡。绑定这块还有个实际原因iWARP/rxe 必须 bind 到 RNIC 网卡 IPverbs 句柄才就绪第 9 章还会提到listen PD 为空就是这个造成的。resolve_addr→ ADDR_RESOLVED把对端的 IP:port解析到具体哪张 RDMA 设备、路径怎么走。这是 RDMA 特有的——TCP 只需要路由RDMA 还要知道对端落在哪个 RNIC。resolve_route→ ROUTE_RESOLVED确定数据走的路径。setup_conn到这步id-verbs已就绪才创建第 2 章那一串对象。注意项目里PD 是在 ADDR_RESOLVED 之后才取的——因为 PD 属于某个具体的 verbs 设备没解析完你根本不知道对端落在哪个设备上。connect→ ESTABLISHED真正的握手完成连接可用。3.3 主库接收方怎么建连主库是被动等待的一方流程是另一串repl_rdma_listen_begin_on_portch rdma_create_event_channel(); rdma_create_id(ch, listen_id, NULL, RDMA_PS_TCP); rdma_bind_addr(listen_id, addr); // 绑定一个端口 client_port 200 rdma_listen(listen_id, 8); // 开始监听 // 等从库连上来 rdma_get_cm_event_timed(ch, ev, timeout); // 等 CONNECT_REQUEST rdma_accept(child, conn_param); // 接受 rdma_get_cm_event_timed(ch, ev, timeout); // 等 ESTABLISHED一个关键细节RDMA 的已有数据同步监听口是独立于 TCP 复制口的——用复制端口 200代码里REPL_RDMA_PORT_OFF 200。也就是说主库一边在 35001 上服务客户端、在复制口上做新增数据同步一边在 35201 上专门等 RDMA 已有数据同步的连接。三条通道互不干扰。3.4 反直觉细节为什么是RDMA_PS_TCP这是新手最容易懵的地方建 RDMA 连接create_id的协议参数竟然是TCP其实RDMA_PS_TCP的意思不是走 TCP 协议传输而是使用 TCP 风格的端口语义来做地址解析。它规定地址用我们熟悉的IPv4:端口来写端口号空间也复用 TCP/UDP 的端口体系。好处是寻址方式对你完全透明——对端写192.168.1.20:35201就能连上不用去理解 InfiniBand 那套 GID/LID 寻址。这只是地址怎么解析真正的数据路径还是 RDMA。3.5 踩坑事件要排空超时要用墙钟建连过程里的事件是异步、可能掺杂的。rdma_wait_cm_event做的事不是等到指定事件就返回而是循环地收事件、把不关心的事件 ack 掉、直到等到目标事件。为什么必须这样因为软 RDMArxe在connect之后可能重复投递ADDR_RESOLVED——如果代码只收一次ADDR_RESOLVED就往下走后面再收到这个事件就会被当成状态错乱处理。所以for (;;) { if (get_cm_event_timed(ch, ev, left_ms) ! 0) return -1; if (ev-event want) { ack(ev); return 0; } // 等到目标 if (ev-event CONNECT_ERROR || REJECTED || UNREACHABLE) return -1; // 致命错误 ack(ev); // 其它事件排空继续等 }项目还特别把超时做成墙钟超时整个等待过程设一个 60 秒上限每次循环都从单调时钟算已耗时间。这背后有个历史教训——早期版本在rdma_cm_wait_ms()返回值上又乘了 1000导致超时被放大成几小时rxe 上单步轮询要等 4 小时才报错。改成先算还剩多少毫秒再 poll 那么久时间就正常了。3.6 小结到这一章一台机器的 RDMA 连接就能和另一台建立起来了发起方走建 id → 绑地址 → 解析地址/路由 → 建对象 → connect接收方走建 id → 绑端口 → listen → accept中间靠事件通道异步通知双方各自等各自的ESTABLISHED。连接建立之后真正的数据怎么传下一章讲 RDMA 的两种数据通道——Send/Recv消息式和 RDMA Write远程直写以及为什么 BLK3 这个自定义协议要把它们分工用。第 4 章两种数据通道——Send/Recv 与 RDMA Write连接建好了第 3 章数据怎么传RDMA 提供两种最基本的操作项目自定义的 BLK3 协议正是按哪种数据用哪种通道来分工的。这一章把它们讲透。4.1 Send/Recv消息式收发需要双方配对Send/Recv是最直观的一种可以想成发一封信、收一封信发送方调post_send把一段数据作为一条消息发出接收方必须提前调post_recv准备好一块接收缓冲等这条消息投进来两边配对成功一次收发才算完成。项目里的辅助函数长这样static int post_send(struct rdma_xfer *x, size_t len) { struct ibv_sge sge { .addr (uintptr_t)x-send_buf, .length (uint32_t)len, .lkey x-send_mr-lkey }; struct ibv_send_wr wr { .opcode IBV_WR_SEND, .sg_list sge, .num_sge 1, .send_flags IBV_SEND_SIGNALED }, *bad; return ibv_post_send(x-conn_id-qp, wr, bad); }post_recv类似投到 RQ 上。注意几个点第一提前 post_recv是硬性要求。接收方如果不提前把接收缓冲挂到 RQ 上发送方的消息来了没地方放硬件会返回RNRReceiver Not Ready接收方未就绪错误——这是 RDMA 编程最经典的坑之一。项目里几乎每个等对端消息的步骤前都有一句post_recv注释反复强调必须在 XX 之前 post_recv否则 rxe 上易 RNR。第 8 章你会看到一段专门为此设计的时序。第二IBV_SEND_SIGNALED决定这条请求要不要在 CQ 里产生完成记录。只有标记了 SIGNALED 的请求完成后 CQ 里才会出现一条 WC。没标记unsignaled的请求完成后静默不出 WC。这个机制是流水线的关键大批请求里挑一部分标 SIGNALED你就每若干条确认一次而不是每条都查第 7 章 signal_batch 就是干这个。4.2 RDMA Write远程直写不需要对方参与RDMA Write是另一类操作发送方不需要接收方参与直接把数据写进接收方事先约定好的一块内存。它像一次远程 memcpy——前提是接收方要把我允许你写哪块内存、钥匙rkey、多长先告诉发送方。发送侧代码这是 BLK3 大块传输的核心第 7 章会细拆wrs[batch] (struct ibv_send_wr){ .opcode IBV_WR_RDMA_WRITE, .send_flags sig ? IBV_SEND_SIGNALED : 0, // 隔几条 signal 一次 .wr.rdma { .remote_addr remote_addr off, .rkey remote_rkey }, .sg_list sges[batch], .num_sge 1, }; ibv_post_send(qp, wrs[0], bad); // 一次可提交一整条链表注意.wr.rdma.remote_addr和.rkey——这就是写进对端哪块内存完全由发送方在请求里指定。接收方此刻什么都没做数据已经被网卡写进它的内存了。接收方怎么允许这件事靠两块东西一是注册 MR 时声明IBV_ACCESS_REMOTE_WRITE第 2 章二是把 MR 的起始地址 rkey 长度通过一条消息发给发送方。这个三人组在项目里就是MRINFO消息——它的字节布局非常清晰// 从库发给主库的 16 字节 MRINFO uint64_t addr_be htobe64((uint64_t)(uintptr_t)rx_buf.map); // 8 字节接收内存地址 uint32_t rkey_be htonl(rx_buf.mr-rkey); // 4 字节远程写钥匙 uint32_t len_be htonl((uint32_t)total); // 4 字节允许写多长主库收到 MRINFO 后会校验长度——你允许我写 500MB但我要发的文件是 480MB不一致就报错。这把远程直写这个危险能力圈定在一个精确的范围内。也注意那些htobe64/htonl——RDMA 报文里整数都用网络字节序大端收发双方必须一致这也是协议实现里容易出错又必须钉死的地方。4.3 两种通道怎么分工现在回到项目自定义的 BLK3 协议。它把一条全量同步分成两类数据用两种通道分别处理短控制消息 → 用 Send/Recv。建连握手、状态确认这些又小又需要对方明确收到的消息用消息式天然合适。BLK3 握手里的 SREADY、CREAY、RDPR、RDGO、BLK3 头、BDONE、RDOK、RDFL、REPLMETA——全是一方发、另一方 post_recv 等着收的成对短消息4 字节令牌或一行文本。大块文件数据 → 用 RDMA Write。整份快照文件按 1–4MB 切成大块用 RDMA Write一条条连续灌进从库内存接收方全程不用参与。这正是大文件要带宽的答案如果文件也用 Send/Recv每一块都要先 post_recv 配对、还可能等对端串行等待会拖死吞吐而 RDMA Write 是单方面直灌还能多条并发第 7 章的流水线。一句话总结这套分工喊话用 Send/Recv要对方接灌货用 RDMA Write直接写。BLK3 的自定义之处就在这里——RDMA 标准只给了这两种能力外加本项目没用到的 RDMA Read而先喊话再灌货、中间怎么对表、最后怎么验货这一整套流程是项目自己设计出来的协议。4.4 收尾可靠性凭什么还有一个必须澄清的点也是新手最容易混淆的RDMA Write 没有每块确认是不是不可靠不是。RC 可靠连接模式下硬件本身就保证有序、不丢、不重RDMA Write 的可靠性由网卡负责失败会通过 CQ 报错比如WR_FLUSH_ERR、REM_ACCESS_ERR。项目里去掉每块 ACK不等于放弃校验——而是把校验从每一小块上移到整份文件传完发一条 BDONE 通知货齐了从库对整份文件跑一遍KVR1 尾 CRC再回 RDOK成功或 RDFL失败。第 9 章会专门讲传输层可靠 vs 业务层校验这层区别那是理解整套容错设计的总钥匙。