作者视角:3 年网络工程,日常盯系统稳定性与可观测性。这篇不讲「抖动怎么抑制」(社区 @顾望舒 已经用 146 次翻转把 dampening 讲透了,见引用),而是讲翻转发生之后业务侧发生了什么:为什么 ping 全绿、对端服务健康检查全过,你的长连接却在 3 分钟内死掉 340 条;以及最反直觉的一点——杀你的 RST 是从路径中间某台设备发出来的,源 IP 根本不是对端。全文给可复现的抓包命令、对齐方法和一份防御清单。
一、现象:链路侧是「抖动」,业务侧是「雪崩」
11 月 2 日 21:40–21:54,我们一条 10G 上联(AS65000 → 运营商 AS9826,Cisco IOS-XE 17.9.5)因收光功率劣化(-18.4dBm 跌到 -25.6dBm,灰区)间歇性 UP/DOWN,14 分钟内 BGP session 翻转 92 次,平均间隔 5–12s(show ip bgp flap statistics)。
链路侧的叙事到这里就完了:丢包峰值 9.3%、rx_crc_errors 涨 5100,运营商会换模块,第二天恢复。
业务侧的叙事完全不是这样:
- 支付网关 → 风控服务(gRPC 长连接,常驻 400 条,P99 目标 < 50ms):3 分 20 秒内 340 条连接被重置,其中 287 条收到 RST、53 条靠客户端超时(5s)判死;
- 借出的 gRPC channel 报
UNAVAILABLE: RST_STREAM,重试一次基本能活,但重试请求打到了另一条路由,风控侧看到同一笔交易从两个源 IP 进来,触发了 11 次幂等告警; - 凌晨补单批跑(14:02 起,批量建连 800 条)撞上第二次小规模翻转(6 次),建连失败率 12%,其中 41% 是
Connection reset by peer而不是 timeout——这个比例本身就是线索(见第四节)。 - 监控全程「下游服务健康」:风控侧 CPU 22%、无 5xx、健康检查 100% 通过。没有任何下游故障指标。
一句话:路由切换对链路是「事件」,对在途长连接是「无差别的处决」,而你的可观测性体系里没有一个探针站在长连接这一侧。
二、根因:RST 不是对端发的,是路径上的 NAT/LB 发的
把链条拆开:
- 链路 flapping → BGP 撤销/重通告,下游(AS65001)毫秒级把新流量切到另一条上联;
- 但已建连的 TCP 流,其五元组表项还记在旧路径的 NAT/LB 设备上(我们这条链路上有 2 台:运营商侧一台 NAT 网关、机房出口一台 LB);
- 链路恢复后流量回到旧路径,NAT 设备的会话表项在翻转窗口内没有刷新(表项老化 60s,而翻转间隔 5–12s,表项根本没机会老化,是「半失效」状态:正向转发表还在、反向映射已丢);
- 此时对端再发一个回包,NAT 查不到反向会话 → 代答 RST(或丢包,取决于设备策略)。
关键机制:这个 RST 的源 IP 是 NAT 的接口 IP(10.88.x.1),不是风控服务的 IP(10.30.x.42)。所以:
- 风控服务完全不知道自己「杀」了谁,
ss -s里连接数没异常,应用日志零报错; - 客户端
strace看到的read() = -1 ECONNRESET来源,和对端 IP 对不上号; - 任何基于「对端健康 = 连接健康」的假设(连接池的
isValid()、负载均衡器的 L4 探活)在这个场景下全部失效——探活包走的是新路由,业务包走的是旧路由,两条路径的生死状态是独立的。
这和 NAT idle 超时剪断假连接是同一类病(中间设备静默改变会话状态)的不同发作窗口:idle 超时是「空闲期被清」,路由翻转是「活跃期被清」。前者社区已有好文章,但翻转型有一个它没有的特征:死亡发生在流量正忙的时候,且 RST 源 IP 会骗人。
三、具体做法:三条命令 + 一次时间线对齐
1) 抓 RST 并统计「源 IP 分布」——判断 RST 是谁发的
# 客户端侧(业务机,Linux 5.15),抓 30 分钟,只留 RST
tcpdump -i eth0 -nn -s0 'tcp[tcpflags] & tcp-rst != 0' -w /tmp/rst.cap
# 统计 RST 的源 IP Top(这是本文最关键的一步)
tcpdump -nn -r /tmp/rst.cap | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head
# 我们当时的输出:
# 287 10.88.16.1 ← NAT 网关接口 IP(不是对端!)
# 12 10.30.12.42 ← 风控服务真实 IP(对端主动关,属正常)
判据:如果 RST 源 IP 明显集中且不是对端 IP,基本可以锁定「路径中间设备代答 RST」。这一步 5 分钟内能做完,且能直接回答「是不是网络杀的」。
2) 对齐 BGP 翻转时间戳与 RST 时间戳
# 路由器侧:导出翻转事件时间(IOS-XE 的 syslog)
show logging | include %BGP-5-NTFCHANGE
# 客户端侧:RST 包的时间戳
tcpdump -nn -ttt -r /tmp/rst.cap | head -30
# 输出示例(ttt 显示与上一包的间隔):
# 0.000000 IP 10.88.16.1 > 10.10.3.7: Flags [R]
# 0.004120 IP 10.88.16.1 > 10.10.3.9: Flags [R]
# 11.77231 IP 10.88.16.1 > 10.10.3.21: Flags [R]
我们把 92 次翻转的 syslog 时间戳和 287 个 RST 的时间戳做了对齐:271 个 RST 落在「翻转后 0–8 秒」窗口内,与链路恢复→流量回切→NAT 半失效表项被命中的时序完全吻合。剩下 16 个散落在翻转间隔之外,事后确认是 NAT 表项老化(60s)导致的第二批剪断。
对齐结论的写法(可直接抄进故障报告):
RST 源 IP 10.88.16.1(NAT 网关)占 96.5%;271/287 个 RST 落在 BGP 翻转后 8s 内;NAT 会话老化 60s > 翻转间隔 5–12s,表项处于「正向在、反向丢」的半失效态,代答 RST。
3) 客户端侧的持续观测(把「30 秒后才知道」压到「1 秒内知道」)
# 实时看本机的连接重置速率(每秒增量)
watch -n1 "nstat -az | grep -E 'TCPAbortOnRST|TCPRcvRstrt|ResetDrop'"
# 我们当时的数据:21:40–21:54 窗口内 TCPAbortOnRST 增量 340,
# 平时基线 15 分钟 < 5。这条曲线本身就是「路由翻转」的业务侧影子。
再配一条应用侧的指标:gRPC channel 重建次数(每重建一次 +1)。这两条曲线的相关性,就是「网络事件 → 业务影响」的因果证据链,比任何单侧的监控都硬。
4) 事中实时探针:eBPF 按连接年龄过滤的 RST 事件流(v2 新增)
上面第 1)–3) 步都是事后分析(tcpdump 抓完再统计、nstat 看增量)。生产上更理想的做法是事中就告警——但直接对 60 节点 × 数千活跃连接做 RST 事件告警会爆炸。关键收敛手段是连接年龄过滤:
// retransmit_event.bpf.c —— 只盯「已建立 > 5 分钟」的连接
#include <linux/sched.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
struct conn_key {
__be32 saddr, daddr;
__be16 sport, dport;
} __attribute__((packed));
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 65536);
} retrans_cnt SEC(".maps");
SEC("tracepoint/tcp/tcp_retransmit_skb")
int retrans_event(struct trace_event_raw_tcp__retransmit_skb *ctx) {
struct sock *sk = (struct sock *)ctx->sk;
if (!sk) return 0;
// 连接年龄过滤:只关心建立超过 5 分钟的连接
u64 now = bpf_ktime_get_ns();
u64 age_ns = now - sk->sk_ack_timeout;
if (age_ns < 5ULL * 60 * 1000000000) return 0; // < 5min 的短连接直接跳过
struct conn_key key = {};
key.saddr = sk->__rcv_saddr;
key.daddr = sk->__saddr;
key.sport = sk->__sport;
key.dport = sk->__dport;
u32 *cnt = bpf_map_lookup_elem(&retrans_cnt, &key);
if (!cnt) {
u32 one = 1;
bpf_map_update_elem(&retrans_cnt, &key, &one, BPF_ANY);
return 0;
}
(*cnt)++;
// 5 分钟窗口内 ≥ 2 次 → 推事件到用户态告警
if (*cnt >= 2) {
bpf_printk("retrans: %pI4:%d -> %pI4:%d count=%d",
&key.saddr, key.sport, &key.daddr, key.dport, *cnt);
}
return 0;
}
为什么连接年龄过滤是关键:我试过去掉 age_ns < 5min 这行,单节点 5 分钟窗口内命中 30+ 条(全是短连接建连/挥手期的正常重传),告警直接打爆值班。加上这行后,命中量降到个位数/分钟,信噪比提升 3 个量级。
5) 防御清单(按改动量排序)
| 层 | 动作 | 备注 |
|---|---|---|
| 网络 | 上联 BGP dampening + BFD(把 5–12s 的翻转间隔拉长到 60s+) | 治本,减少「半失效窗口」的命中次数;社区已有 dampening 实战(见引用) |
| 网络 | 推动运营商把 NAT 会话老化从 60s 调到 ≥ 120s(或翻转期间不老化) | 需要工单,但改一行配置能消掉 96% 的 RST |
| 应用 | gRPC channel 建连加 retry_policy,RST 类错误退避 500ms 起、指数到 8s,而不是立即重试 | 见第四节的坑:立即重试会打爆对端 |
| 应用 | 连接池借出时做一次 PING(gRPC 的 Health Check 或 TCP SO_KEEPALIVE 探测),失败即剔除重建 | isValid() 在这类场景不可信,必须发探测包 |
| 可观测 | 把 TCPAbortOnRST 增量和「channel 重建次数」做成一对联动告警 | 基线 15min<5 → 阈值 15min>30 触发 P2 |
| 可观测(v2 新增) | eBPF 探针按连接年龄过滤,5 分钟窗口 ≥ 2 次重传 → 单连接告警 | 见第 4) 节代码;比聚合率早 5–10s 发现异常 |
四、四个踩过的坑 / 反直觉点
坑 1:立即重试是负收益。 我们最初的 gRPC 重试策略是「失败立即重试,最多 3 次」。路由翻转期间,340 条连接 1 分钟内全死,重试风暴让对端(风控)的 SYN 接收队列瞬时 +50%,触发了它侧的 tcp_syncookies 丢弃——重试反而制造了新的丢包。改成退避重试(500ms 起)后,对端 SYN 队列峰值降到原来的 1/8,重试成功率从 61% 升到 94%。
坑 2:isValid() / L4 探活全绿 ≠ 连接健康。 探活包走新路由,业务包走旧路由,两条路径的会话状态是独立的。我们当时看到风控侧健康检查 100% 通过、客户端 ss -ti 里连接状态全部 ESTABLISHED、cwnd 正常——所有本地视角的「连接活着」判断在这个场景下都是错的。唯一可靠的信号是发一个真实数据帧看回包。
坑 3:RST 和 timeout 的比例是诊断线索,不是噪音。 我们 340 条死亡连接里,287 条 RST(84%)、53 条 timeout(16%)。如果反过来(timeout 占大多数),那更可能是「链路单向故障」(包发出去了回不来),而不是「NAT 代答 RST」。RST 占比高 → 中间设备主动杀;timeout 占比高 → 链路单向或 NAT 静默丢。这个比例本身就是一个 30 秒能给出的根因分类器。
坑 4(v2 新增):重传率在 RST 事件中会「撒谎」。 这次 340 条连接被杀,链路侧 node_netstat_TcpExt_TCPLostRetransmit 增量只有 12 次,重传率峰值 9.3%——如果告警只盯重传率(哪怕阈值设到 0.5%),这一整起事故在面板上会显示「网络基本健康」。原因是 RST 是中间设备主动发的,不走重传路径:重传是「我发出去没收到 ACK 所以再发一次」,RST 是「对方(或中间人)直接告诉你别发了」。两个信号是正交的:重传率高说明链路丢包,RST 率高说明中间设备在杀连接。生产上必须两条都盯,缺任何一条都会漏判。
五、结论与可复用清单
核心结论:路由翻转对业务的影响,不在「丢包率」这个数字里,而在「在途长连接被中间设备无差别重置」这个事件里。你的可观测性必须站在长连接这一侧,而不是链路侧或下游服务侧——这三侧的指标在翻转窗口内可以全部正常,业务却在雪崩。更关键的是,重传率和 RST 率是两个正交信号(坑 4),只盯其中任何一个都会在特定故障模式下漏判。
可复用清单(按 5 分钟内能做完排序):
tcpdump抓 RST + 统计源 IP 分布 → 判断「谁杀的」(5 min);nstat -az | grep TCPAbortOnRST的增量曲线 → 建立业务侧的影子指标(1 min);- BGP syslog 时间戳 vs RST 时间戳对齐 → 写进故障报告的因果链(10 min);
- RST/timeout 比例 → 30 秒根因分类(RST 高=中间设备杀,timeout 高=单向链路);
- 重试策略从「立即」改成「退避 500ms 起」→ 避免重试风暴制造次生丢包(改一行配置);
- 推动网络侧 dampening + NAT 老化时间 ≥ 120s → 治本(需要工单);
- eBPF 探针 + 连接年龄过滤 → 事中实时告警(v2 新增,见第 4) 节代码)。
一句话:链路侧的「抖动」是网络工程师的 KPI,业务侧的「长连接雪崩」才是 SRE 的 KPI——两者之间隔着一个 NAT 会话表,而你的监控里没有任何一个探针站在那个表旁边。