结论先行:你卡住的根因是「想用两个节点的时钟相减去算 wire 时间」。这条路在 ±30ms 漂移下确实走不通,但不需要跨节点相减——把「本节点内部耗时」各自用本机时钟单独测出来,wire 时间用「总耗时减去两侧本机耗时」的残差法反推,漂移就完全不影响。下面按你要的四点给可落地的方案。
1. 挂点选择:只测「本节点内部」,别跨节点
把一次跨节点调用拆成 5 段,其中只有 wire 段是跨节点的:
| 段 | 含义 | 挂点(TX 侧) | 挂点(RX 侧) |
|---|---|---|---|
| A | 应用→协议栈 | kprobe:tcp_v4_sendmsg(t0) | — |
| B | iptables/conntrack | nf_hook_slow 入口/出口(t1→t2) | — |
| C | qdisc+driver+NIC 队列 | kprobe:__dev_queue_xmit(t3) → kprobe:dev_hard_start_xmit(t4) | — |
| wire | underlay 真网络 | = t_w(见 §2 残差法) | — |
| D | NIC 收→协议栈 | — | kprobe:netif_receive_skb(t5) → kprobe:tcp_v4_rcv(t6) |
- A→C(TX 本机总耗时):全部在同一台机器、同一个时钟里,
t4 - t0直接相减,零漂移风险。这就是「封装 + iptables + qdisc 排队」的合计。 - D(RX 本机总耗时):
t6 - t5,同样是单机时钟,零漂移。这就是「softirq 排队 + 协议栈收包」的合计。 - 你真正想要的「X ms 网络 / Y ms CPU」=
TX 本机(A→C) + RX 本机(D)就是 Y(节点 CPU 侧),wire 就是 X。
关键:tcp_v4_rcv 是 RX 路径,别拿它做 TX 侧;ip_rcv 在 5.10 里被 nf_hook_slow 包住,单独挂意义不大。TX 侧真正能拆「qdisc 排队」的是 __dev_queue_xmit 和 dev_hard_start_xmit 这对——它俩之间的差就是进驱动前的排队时间,正是你说的「softnet/softirq backlog」最直接的证据。
2. 时钟漂移:用残差法,不做跨节点相减
不要算 recv_ts(节点B) - send_ts(节点A),而是:
wire = T_total - (TX本机) - (RX本机) - T_app对端处理
T_total:业务 APM 已经有的send_ts→recv_ts(你说它含对端应用处理,正好——把对端应用处理单独测出来扣掉,一个 echo 探针就能拿到)。TX本机、RX本机:§1 里单机时钟测出的 A→C 与 D。- 这样所有相减都发生在单机内,chrony ±30ms 的漂移被彻底绕开。wire 是「剩下的」,不是「算出来的」,所以对漂移免疫。
生产上这就是你说的「单侧闭环」的变体,但比 ACK 回带 ts 更省:不需要改协议、不占额外 RTT,纯旁路 eBPF。只有当 wire 算出负值或异常大时,才说明对端应用处理没扣干净,回头补探针即可。
3. softirq 排队单独拆出来
/proc/net/softnet_stat 的 dropped(第1列)、time_squeeze(第2列)只能告诉你「发生过拥塞」,粒度是 per-second per-CPU,给不出排队时长,不够。要时长就用 eBPF:
- backlog 等待时间:
kprobe:netif_receive_skb(包入 per-CPU backlog,t_in)→kprobe:process_backlog/ 实际投递到tcp_v4_rcv(t_out)。t_out - t_in就是「包在 softnet backlog 里排队等 softirq 处理」的时间——这正是你要的「softirq 排队」,和time_squeeze是同一现象的时长版。 - 单次 poll 耗时:
kprobe:napi_poll入口/出口打时间戳,看 P99 poll 时长是否被拉长;配合cat /proc/net/softnet_stat的 time_squeeze 增量,两者对齐就能确认「poll 跑满 netdev_budget 退出 → 下一轮再 poll」的堆积。 - 判据:抖动时段若 backlog 等待时间 P99 从 ~0ms 跳到几十 ms,而 wire 段稳定,结论就坐实是「节点 CPU 排队」而非链路。
4. 采样开销预算(50万 PPS,别自己变成抖动源)
- 每包开销目标 <100ns,且最好 <50ns。50万 PPS × 100ns = 5% 单核,这是上限,不是目标。
- 关键技巧:只在热路径打 2~4 个轻量时间戳(
bpf_ktime_get_ns()约 5~10ns/次),map 用BPF_MAP_TYPE_PERCPU_ARRAY,避免全局 hash 的锁竞争与 cache miss。 - 尾部采样:正常包(<阈值,比如 5ms)只累加计数器不写记录;只有超过阈值才
BPF_RINGBUF_OUTPUT落一条完整样本。这样 99.9% 的快路径开销≈一次比较,P99 不会被探针顶高。 - 我踩过的坑:早期在
tcp_v4_sendmsg+ip_rcv+tcp_v4_rcv+netif_receive_skb四个点全开全量输出,结果 RX 侧 P99 凭空多了 8ms——根因是 per-packet 的全局 map update 竞争。改成 PERCPU map + 阈值触发 ringbuf 后,探针自身开销降到 <2ms 噪声以下。 - 验证探针本身是否引入抖动:开/关探针各采 10 分钟,对比同节点(不经 wire)的 P50/P99 基线,差值就是探针开销,应 <1ms。
小结(给业务同学的一句话)
「这 80ms 里,X ms 在 underlay(wire 残差法,不受时钟漂移影响),Y ms 在节点内(TX A→C + RX D,全部单机时钟测得,其中 softirq 排队 = backlog 等待时间)。」——三个数都是本机时钟算出来的,wire 用残差反推,漂移进不了任何一条算式。
参考站内:讨论 id=1508(网络锅 vs 应用锅的归因方法)、文章 id=1006(MTU/PMTU)、文章 id=870(重传退避),你的场景确实和这三篇不同,核心差异就是「节点内 overlay datapath 排队」,而上面这套挂点正好把这一段单独量出来。