大带宽服务器数据迁移跑不到线速的底层账:tcp_wmem 把 BDP 焊死,rsync 的滚动校验又让零拷贝成了摆设

现象:iperf3 能到 23Gbps,rsync 单进程只有 9.8Gbps

两台 25G 双口 bond 的大带宽服务器,NVMe RAID 顺序写 fio 能到 2.9GB/s,iperf3 -P 8 跑到 23.1Gbps,ip -s link 和 ethtool -S 全是零丢包、零 discard。但 rsync -a 单进程迁移大文件只有 9.8Gbps,开 4 个并发能到 19Gbps 左右,然后 CPU 先满,mpstat -P ALL 1 看到一个核 %soft 30%、一个核 %sys 40%。

这个场景里网卡不是瓶颈,磁盘也不是,瓶颈在三个地方:TCP 的带宽时延积被默认缓冲区锁死、rsync 的用户态拷贝和校验、接收端 page cache 的脏页回写反压。下面按数据路径逐层拆。

第一层:BDP 算账,tcp_wmem 的默认上限把发送窗口焊死了

大带宽长肥管道的吞吐上限不是看网卡型号,是看 BDP:

BDP(bytes) = 带宽(bps) × RTT(s) / 8
25Gbps × 1.8ms = 5,625,000 bytes ≈ 5.36MiB

这条链路 RTT 1.8ms,单流要跑满 25Gbps,发送窗口至少得能撑到 5.6MB。很多发行版默认 net.ipv4.tcp_wmem 第三值是 4MB,net.core.wmem_max 也是 4MB。发送端 socket 缓冲区封顶 4MB,理论吞吐:

4MiB × 8 / 1.8ms ≈ 18.6Gbps

再扣掉协议头、接收窗口通告、应用层写入延迟,单流掉到 9.8Gbps 一点都不冤。ss -tinm 能直接看到被谁限住:

ss -tinm 'dst 10.0.0.2' | grep -E 'cwnd|rtt|snd_wnd|rcv_wnd|skmem|limited'

如果输出里出现 sndbuf_limited,且 skmem 的 t 已经顶到 tb,就是发送缓冲区不够;出现 rwnd_limited 则是接收端窗口没放开。两边都要调:

sysctl -w net.core.rmem_max=67108864
sysctl -w net.core.wmem_max=67108864
sysctl -w net.ipv4.tcp_rmem="4096 262144 67108864"
sysctl -w net.ipv4.tcp_wmem="4096 262144 67108864"
sysctl -w net.ipv4.tcp_moderate_rcvbuf=1
sysctl -w net.ipv4.tcp_slow_start_after_idle=0
sysctl -w net.ipv4.tcp_congestion_control=bbr
sysctl -w net.core.default_qdisc=fq

注意 tcp_rmem/tcp_wmem 的第三值是 TCP 自动调优的上限,rmem_max/wmem_max 是 setsockopt 的上限,两者独立但通常一起抬。rsync 还支持直接设 socket 选项,可以绕过应用层默认值,但设的值仍受 wmem_max 约束:

rsync -a --whole-file --inplace --no-compress \
  --sockopts=SO_SNDBUF=16777216,SO_RCVBUF=16777216 \
  /data/ user@10.0.0.2:/data/

调完这一层,单流 rsync 能从 9.8Gbps 拉到 16Gbps 左右,但还到不了线速,因为下一层更硬。

第二层:rsync 根本用不上 sendfile,滚动校验让零拷贝成了伪命题

rsync 的核心是 delta 算法:发送端把文件切成块,对每块算弱校验(Adler-32)和强校验(MD5/SHA1/xxHash),接收端用滑动窗口匹配。这个路径必须把数据 read() 到用户态算校验,再 write() 到 socket,两次内存拷贝加校验 CPU,sendfile 的零拷贝在这里根本不成立。

即使加 --whole-file 跳过 delta 匹配,rsync 仍然走用户态缓冲区,只是不比较块。用 perf top -p $(pgrep rsync) 看,热点大概率在 adler32、md5 或 xxh3 上。对比一下裸流:用 socat 的 sendfile 选项走内核零拷贝,同样的盘和链路能稳在 22Gbps 左右。

# 接收端
socat -u TCP-LISTEN:9000,reuseaddr,rcvbuf=16777216 OPEN:/data/test.bin,creat,trunc

# 发送端
socat -u FILE:/data/test.bin TCP:10.0.0.2:9000,sendfile,sndbuf=16777216

如果确认是全量迁移、不需要 delta,优先换工具或换参数:

  • --whole-file:源和目的都是本地盘时跳过滚动校验,省 CPU。
  • --checksum-choice=xxh128:rsync 3.2+ 支持 xxHash,比 MD5 快一个数量级,先 rsync --checksum-choice=help 确认。
  • --inplace:直接写目标文件,避免临时文件 .filename.XXXXXX 带来的目录项和 journal 开销。
  • --no-compress:25G 链路上压缩就是自杀,CPU 压缩速度跟不上网卡。

SSH 加密也是单线程瓶颈。AES-NI 机器上用 aes128-gcm@openssh.com,别用默认的 chacha20;多流并行时用 ControlMaster 复用握手:

rsync -a --whole-file --inplace --no-compress \
  --checksum-choice=xxh128 \
  -e 'ssh -c aes128-gcm@openssh.com -o Compression=no -o ControlMaster=auto -o ControlPath=/tmp/ssh-%r@%h:%p' \
  /data/ user@10.0.0.2:/data/

如果只是搬全量数据、能接受自己处理断点,tar | mbuffer | ssh 也比 rsync 快,因为省掉了校验路径:

tar --xattrs --acls -cf - /data | \
  mbuffer -m 8G -s 1M | \
  ssh -c aes128-gcm@openssh.com -o Compression=no user@10.0.0.2 'tar --xattrs --acls -xf - -C /data'

第三层:接收端脏页回写把 TCP 窗口反压没了

数据到了接收端不是直接落盘:网卡 → 协议栈 → socket 接收缓冲区 → rsync read() → write() 到文件 → page cache → writeback。如果 writeback 速度低于网络接收速度,脏页涨到 vm.dirty_bytes 阈值,balance_dirty_pages 会让 rsync 进程睡眠,接收缓冲区随之填满,TCP 通告零窗口,发送端只能停。

观察三个地方:

grep -E 'nr_dirty|nr_writeback|nr_dirty_threshold|nr_dirty_background_threshold' /proc/vmstat
cat /proc/pressure/io
iostat -x 1

如果 nr_dirty 持续贴着阈值、/proc/pressure/io 的 some avg10 很高,就是回写反压。大内存机器不要用 dirty_ratio 百分比,512GB 内存 20% 就是 100GB 脏页,回写抖动会非常难看。改用绝对字节:

sysctl -w vm.dirty_background_bytes=2147483648
sysctl -w vm.dirty_bytes=8589934592
sysctl -w vm.dirty_expire_centisecs=1500
sysctl -w vm.dirty_writeback_centisecs=500

NVMe 可以适当放宽到 4GB/8GB,SATA SSD 或机械盘要收紧。另外迁移大文件时用 fallocate 预分配目标文件,XFS 还可以调 allocsize=1M 减少块分配锁竞争;不要用 echo 3 > /proc/sys/vm/drop_caches 这种暴力手段,会把正在回写的页也搅乱。

第四层:多流并行时,RSS 哈希和 NUMA 会把收益吃掉

单流被 BDP 和单核限制,多流是正解,但不是开越多越好。每个 TCP 流按四元组哈希到网卡的某个 RX 队列,队列中断绑在某个 CPU 上。如果几条流哈希碰撞到同一个队列,那个核的 %soft 先满,其他核闲着。

ethtool -l eth0
ethtool -L eth0 combined 32
ethtool -X eth0 equal 32
cat /proc/interrupts | grep -E 'eth0|mlx5'
mpstat -P ALL 1

用 ethtool -X 重设 indirection table 让队列均分,ethtool -L 把 combined 队列数拉到和迁移并发匹配。再看 NUMA:网卡挂在 node0,迁移进程跑在 node1,跨节点内存拷贝带宽直接腰斩。用 cat /sys/class/net/eth0/device/numa_node 确认,然后绑死:

numactl --cpunodebind=0 --membind=0 rsync -a --whole-file ...

RTT 1.8ms 下,单流调好能到 16-17Gbps,两条流就够摸 25Gbps 线速,没必要开 8 条、16 条。并发数超过磁盘顺序写和 CPU 校验能力,只会让 %sys 和 iowait 互相打架。

落地顺序:先裸流定上限,再逐层收

  • 先用 iperf3 -P 8 和 socat sendfile 确认网络与磁盘的真实上限,排除网卡丢包和链路问题。
  • 按 RTT 算 BDP,把 tcp_rmem/tcp_wmem 第三值抬到 BDP 的 2 倍以上,rmem_max/wmem_max 同步放开。
  • rsync 加 --whole-file --inplace --no-compress --checksum-choice=xxh128,SSH 换 aes128-gcm 并开 ControlMaster。
  • 接收端用 vm.dirty_bytes 绝对值压住脏页,盯 /proc/pressure/io 和 nr_writeback。
  • 多流按四元组哈希和 NUMA 绑核,不要盲目加并发。

我们这次两台机器都是轻云互联的 25G 大带宽机型,默认镜像里 tcp_wmem 上限给到了 16MB,比很多默认 4MB 的镜像省了第一刀;但跨可用区 1.8ms 的 RTT 下 16MB 也只够跑到 17Gbps 出头,BDP 这笔账还是得自己按链路 RTT 算一遍。迁移调优没有银弹,先把每一层的限制器找出来,再决定是改内核参数、换工具还是加并发。