BGP多线主机跑备份,一条 rsync 流永远只走一条线:FIB 多路径哈希、rt_genid 与 --append 假续传的三层真相

先说一个很多人做了三年运维都没想明白的事:你买了一台标着「BGP 多线」的主机,以为备份数据会自动分摊到各条线路上跑满带宽。不会。一条 rsync 流,从 SYN 到 FIN,永远只从一块网卡出去。

更糟的是另一件事——BGP 会话抖了,上游黑洞了,内核压根不知道,你的 rsync 进程还在那儿 ESTAB 着呢,`ss` 看连接活得好好的,`retrans` 慢慢往上爬,半小时后你去看备份窗口,已经炸了。

这篇不讲概念,讲三层真实机制,以及它们怎么联手把「备份成功」变成一个谎言。

第一层:多线是入向的,不是出向的

BGP 多线主机的「多线」通常指:你有多个上游 AS,向它们都宣告了同一个 /24 或 /23 前缀。这叫 inbound multi-homing。别人访问你,数据从哪条线进来,由上游的 BGP best path 决定——这是「多线」。

但备份是出向流量。你的数据要出去到异地机房 / 对象存储。出向走哪条线,跟 BGP 没半点关系,只看一件事:内核的 FIB 怎么选下一跳

# 先看默认路由到底有几条
ip route show default
default via 10.0.0.1 dev eth0 proto static metric 100

# 再看你的备份目标实际从哪儿出去
ip route get 203.0.113.10 from 198.51.100.5
203.0.113.10 from 198.51.100.5 via 10.0.0.1 dev eth0 src 198.51.100.5 uid 0
    cache

如果 `ip route show default` 只有一行,那你的「BGP 多线主机」在出向就是单线机。备份、拉镜像、调 API,全挤在 eth0 上。这是最常见的配置——BGP 只负责宣告,出口走一条静态默认路由。多线的价值全给了入向。

就算真有 ECMP,也救不了你

有些机房会给你配 ECMP 默认路由,内核里是这样:

ip route show 0.0.0.0/0
default proto bgp metric 20
	nexthop via 10.0.0.1 dev eth0 weight 1
	nexthop via 10.0.1.1 dev eth1 weight 1

这时候选路由多路径哈希决定。关键在哈希策略:

sysctl net.ipv4.fib_multipath_hash_policy
net.ipv4.fib_multipath_hash_policy = 0
  • 0 = L3 哈希(源 IP + 目的 IP + flow label),内核默认值
  • 1 = L4 哈希(标准五元组)
  • 2 = L3 + L4
  • 3 = 自定义,需要配 fib_multipath_hash_fields(5.12+ 才有)

划重点:无论策略是 0 还是 1,同一对 (src, dst, sport, dport) 算出来的哈希值恒定。一条 rsync 连接就是一个固定的五元组,从建立那一刻起就被钉死在某一条链路上,永远不变。多线对它的吞吐增益是零。

想榨出多线带宽?得让内核看到不同的源 IP。主机上多挂几个 IP,然后多进程 rsync,每个进程绑定不同源地址:

# 先确认这些 IP 真的在本机接口上,否则 SYN 直接被丢
ip -4 addr show eth0 | grep inet

for i in 1 2 3 4; do
  rsync -aHAX --numeric-ids --no-inplace --append-verify \
        --address=198.51.100.$i \
        --partial-dir=.rsync-partial \
        /data/ backup@203.0.113.10:/backup/ &
done
wait

四个源 IP → 四种哈希 → 大概率落到不同 nexthop。这是唯一能真正吃掉多线出口的手段。注意:这些 IP 必须事先被上游宣告且真的在接口上,否则 SYN 发出去对端回不来。

第二层:BGP 收敛时,已建立的 TCP 连接不会迁移

这是最要命的一层。很多人的心智模型是「我有两条线,一条断了自动切另一条」。这个模型对新建连接成立,对已建立的连接完全不成立。

内核 IPv4 的 socket 上挂着一个缓存的 dst_entrysk_dst_cache)。每次发送走 __sk_dst_check(),它会比较缓存的 rt_genid 和当前的 net->ipv4.rt_genid。路由表发生变更(add/del/replace)时 genid 会 bump,socket 就能感知到并重新查表。

但这套机制有个致命前提:内核路由表得变。

而 BGP 多线主机的典型形态是——BGP 只做宣告,出口是运维手写的静态默认路由。上游那条线 BGP 会话断了,物理口还 up,你的内核路由表纹丝不动,rt_genid 不变,socket 检查通过,数据包继续往那个下一跳发,进入一个已经不转发你流量的网络。

这就是「静默黑洞」。诊断起来长这样:

# 连接明明是 ESTAB,但它其实在往虚空里灌数据
ss -tinmo state established dport = :873

ESTAB 0 0  198.51.100.5:51234  203.0.113.10:873
	 skmem:(r0,rb131072,t0,tb2626560,f0,w0,o0,bl0,d0)
	 cubic wscale:7,7 rto:120000 rtt:0.9/0.3 ato:40 mss:1448
	 retrans:42/87 ...

# 全局看丢包和超时
nstat -az | grep -E 'TCPTimeouts|TCPLostRetransmit|TCPRetransFail'

rto:120000 —— RTO 已经退避到内核上限 120 秒。retrans:42/87 —— 当前未确认重传 42 次,累计 87 次。这就是「rsync 卡在 99%」的真相:不是 rsync 慢,是 TCP 在一个谁也到不了的黑洞里指数退避重传。

默认 tcp_retries2 = 15,配合 RTO 退避,内核文档给的上限是大约 13~30 分钟才放弃并返回错误。你的备份窗口就这么被吃干净了。

# 让备份连接快速失败,而不是静默挂死
sysctl -w net.ipv4.tcp_retries2=7
# 7 次大约 100~160 秒就放弃,取决于当时的 RTO 基线

改这个要权衡:丢包率高的跨境线路会更早断连。所以更稳的做法是把备份流绑到一条质量可控的出口上。我们后来把这批备份源机迁到轻云互联的 BGP 多线,他们默认给的是 ECMP + L4 哈希策略,出向路由在面板里能直接看到 nexthop 列表,至少不用再靠猜「现在到底走的哪条线」——这一点在排查这种静默黑洞时省了太多时间。

第三层:rsync 的「续传成功」是假的

假设你做对了前两层,BGP 还是抖了一下,TCP 被 RST 掉了。rsync 会重连继续传。这里藏着最阴的一刀。

--append--append-verify 的区别:

  • --append假定目标端已有文件是源文件的一个完整前缀,直接从目标文件的当前长度处开始追加。它不校验任何已有内容
  • --append-verify:会对目标端已有部分做滚动校验,慢,但正确。

现在把 --inplace 加进来。默认的 rsync 是写临时文件再 rename 的(原子操作,中断了目标文件不受影响)。但 --inplace 直接往目标文件上写,图的是省 IO。

于是灾难链条闭合了:

  • --inplace 让中断的写入直接落在目标文件上
  • TCP RST 时,对端已经落盘了一部分「看起来接上了但其实是错的」数据块
  • 重连后 --append 从文件的当前长度续写,跳过校验
  • 最终文件大小完全正确,rsync 退出码 0,报告 success
  • 中间那一段是垃圾,只有 restore 的时候才发现

还有一个细节会骗过所有人:--timeout 拦不住这个场景。rsync 的 timeout 监控的是「IO 静默」,而在 TCP 重传期间,rsync 卡在 write() 系统调用里等返回,从 rsync 视角看 IO 是「忙碌」的,计时器压根不触发。

正确的备份命令长这样:

rsync -aHAX --numeric-ids \
      --no-inplace \
      --append-verify \
      --partial-dir=.rsync-partial \
      --timeout=300 \
      --address=198.51.100.5 \
      --bwlimit=80000 \
      /data/ backup@203.0.113.10:/backup/

并且记住 rsync 的退出码语义,别只看 0 和 1:23 = 部分文件未传输,24 = 部分源文件在传输中消失,30 = 超时。0 只代表「进程跑完了」,不代表「内容对了」。

第四层:把备份从「逐文件比对」换成「内容寻址」

rsync 的根本问题在于它的模型是「文件 = 路径 + 字节流」,续传的锚点是文件偏移量,而偏移量在网络中断面前是不可信的。

restic / borg 这类工具换了个模型:内容定义分块(CDC,Buzhash / Rabin 指纹)+ 每块独立哈希。每块在写入前就算好了 hash,落盘后能独立验证,天然幂等。续传的锚点是「哪块还没传」,而不是「传到第几个字节」。

restic -r s3:https://s3.example.com/backup-bucket backup /data \
  --pack-size 64 \
  --read-concurrency 2 \
  --exclude-caches

# 关键:定期做真实数据校验,而不是只查索引
restic check --read-data-subset=5%
restic forget --keep-daily 7 --keep-weekly 4 --prune

check --read-data-subset 会真的把 5% 的 pack 文件拉回来重算哈希。这一步才叫「验证备份」,前面那些「任务成功」都只是「进程退出正常」。

收尾:三个必须加的监控点

  • 出口一致性:每次备份前后跑一次 ip route get <backup_target> from <src>,把结果记进日志。路由变了,你要在第一时间知道,而不是从备份时长里猜。
  • TCP 层重传:用 nstat -az/proc/net/snmpTcpExtTCPTimeouts / TcpExtTCPLostRetransmit 的增量,跑成时序指标。备份窗口内的异常重传是黑洞的唯一早期信号。
  • 尾部校验:备份完成后对目标端做一次抽样哈希(或者直接依赖 restic 的 check)。别信退出码,信哈希。

多线主机不是万能的。理解哪一段是 BGP 在管(入向宣告)、哪一段是内核 FIB 在管(出向选路)、哪一段是 TCP 在管(连接生命周期),你才能知道备份这件事上,冗余到底存在不存在。多数情况下,答案是:不存在,你得自己造。