协议栈仿真做到后面,代码能不能跑已经不是最让人头疼的了。真正麻烦的是,仿真任务跑完、日志文件也生成了,面对满屏的时间戳和状态字段,却说不清楚协议栈到底表现得怎么样。TCP/IP 协议栈仿真里的数据分析,恰好是整个系列里最容易被低估、坑又最多的一块。这一期我不打算讲大而全的数据分析理论,而是聚焦在仿真这个特定场景下:日志怎么设计、指标怎么还原、工具怎么选、图表里的异常信号怎么解读,以及一个我从数据反推出来的真实瓶颈案例。适合正在做协议栈仿真、或者写完仿真模块不知道怎么验证结果的读者参考。
1. 先想清楚一个事情:仿真数据到底在记录“什么时间、谁的什么状态”
很多人跑完仿真就开始对着日志写 Python 脚本,但第一版脚本往往写得极其痛苦。原因很统一:日志里存的东西压根就不是为分析准备的。所以我想把数据分析这一步往前拨,先说记录层应该长什么样。
1.1 事件记录、状态快照、统计计数器:三种数据各管一摊事
仿真运行过程中会产生多种性质完全不同的数据,粗暴地混在一个文件里会让后续分析很难受。我习惯把它们分成三类。
第一类是事件记录(event log),用来描述“某一时刻发生了什么”。发送一个数据包、收到一个 ACK、定时器超时、重传队列弹出,这些都是事件。每条事件至少要包含时间戳、事件类型、连接标识、相关序列号或长度字段。事件记录是所有后续分析的骨干,吞吐、时延、重传率都靠它推算。
第二类是状态快照(state snapshot),用来描述“某一时刻协议变量处于什么值”。发送端的拥塞窗口、慢启动阈值、flight bytes、接收端通告窗口,这些不是事件,而是不断变化的状态。状态快照的价值在于事后复现发送窗口是怎么涨跌的,缺失这类数据会导致很多现象只能靠猜。
第三类是统计计数器(counter),用来累积汇总一段时间的数值,比如每个流的总重传次数、累计收到的字节数、超时次数。仿真结束直接聚合计数器就能拿到结论,不用再去翻全量事件日志。
这三类数据最好分开落盘。事件记录按行追加,状态快照按固定周期或关键变化点采样,计数器按连接维度累计。很多入门项目只打了事件日志,分析时发现 cwnd 曲线还原不出来,就是因为中间没有采样状态。
1.2 时间戳的语义决定了后面所有指标的对错
网络仿真里的时间是绕不开的第一道坎。写日志时一定要明确时间戳到底是谁的时间:是宿主机墙钟时间,还是仿真虚拟时钟的时间。这两者的差别不是精度问题,而是本质不同。
离散事件仿真器里,每个事件都带一个虚拟时间戳,由仿真内核的时钟推进机制产生。分析端到端延迟、RTO 推算、吞吐计算,必须使用虚拟时间戳,因为只有它反映了协议栈进程中的因果关系。如果混入墙钟时间,一旦仿真速度受宿主机负载影响发生波动,时间序列会出现乱七八糟的抖动,RTT 曲线会失真到无法使用。
实时网络仿真则不同,它跑在真实时间里,但如果把日志 I/O 直接放在事件处理路径里,写盘耗时会影响仿真时序。低频率做一次批量 flush 或者走异步日志队列是比较稳妥的做法。
另外还要警惕时间戳的精度。我曾经用毫秒级精度记录事件,结果想分析微秒级的 RTT 抖动,发现数据全部被量化在同一个毫秒点位上,根本没法做。记录层的时间戳精度至少要高于你分析目标一个数量级,这在仿真日志设计初期就要定下来,不然后面只能改代码重跑。
1.3 日志内容要带哪些字段,才能支撑“事后归因”
写日志前先列一下你想回答的问题清单,然后倒推字段。这样比我一开始那种“想到什么记什么”的做法高效得多。
至少要有这些字段:虚拟时间戳、方向(发送还是接收)、协议状态机当前阶段、seq/ack 编号、负载长度、inflight 字节数、当前 cwnd、当前 snd_wnd、是否重传、是否触发快速重传或超时。特别注意要把 cwnd 和 snd_wnd 这两个值打在发送日志上,没有这两个字段,你很难区分吞吐瓶颈到底出在拥塞窗口还是接收窗口。
字段越多日志越大,但仿真切到 debug 模式时可以只保留必要字段。性能模式跑吞吐压力测试时,把 hex dump 和详细状态输出关掉,只留统计计数器。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 指标还原方法论:先把口径定下来,再去写计算脚本
日志齐了,下一步是把原始记录换算成通行的网络指标。这一步最怕的不是不会算,而是每个人算出来的口径不一样,同一个 trace 有人算出 80 Mbps,有人算出 50 Mbps,最后争论半天发现是字节统计范围和窗口对齐方式不同。
2.1 吞吐量 vs 有效吞吐:这个问题绕不开
吞吐量(throughput)的口径差异主要来自“哪些字节被算进去了”。如果统计 TCP payload 的字节数,IP 头和 TCP 头是不计入的;如果你从 IP 层统计总长度,那结果会偏高约 3% 到 5%。更麻烦的是重传字节算不算。
我推荐的做法是区分两个指标:链路吞吐和有效吞吐。链路吞吐统计所有 IP 层发送字节,包括首部与重传;有效吞吐只统计应用层被对端确认的新数据字节。前者反映链路占用,后者反映协议栈的真实效率。同一份日志同时给出这两个值,往往能一眼看出协议栈是不是在疯狂重传空转。
计算时注意窗口边界。截取一段时间的吞吐,不要只看第一个包和最后一个包的时间差,因为流开始前的慢启动有静默期,流结束时又有等待 FIN 的尾巴。建议去掉最前面的握手阶段和最后面的连接拆除阶段,从第一个包含 payload 的包开始计数,到最后一个 ACK 确认完成作为结束,这样算出来的平均数更有代表性。
code复制def throughput_from_events(df, start_us, end_us):
window = df[(df["event_time"] >= start_us) &
(df["event_time"] <= end_us)]
app_bytes = window.loc[
(window["event"] == "TX") & (window["retrans"] == 0),
"payload_len"
].sum()
duration_s = (end_us - start_us) / 1_000_000
return app_bytes * 8 / duration_s
2.2 时延不是“收到时间减去发出时间”就完事
单纯把对端收到包的时间减去本端发送时间,得到的 RTT 只是一个粗略的样本。它会混入对端处理延迟、ACK 聚合延迟、以及排队延迟。协议栈仿真的分析工作里,我更建议把时延拆开看,拆开之后才能定位问题。
单向延迟(one-way delay)最干净,但前提是两端时钟必须对齐。这在仿真环境里反而好办:同一台宿主机上的虚拟时钟天然是同一个源,离散事件仿真器里更是全局统一。你在记录层只要确保接收端事件的时间戳用同一个虚拟时钟就行,不需要额外做 NTP 同步。
RTT 的还原建议用 TCP 的时间戳选项或者 ACK 序列对应关系来推算,不要让接收端自己和发送端各记一笔再相减,那样对时钟偏差太敏感。比较稳的还原方法是:发送端记录某个 seq 的发出时间,等收到 ACK 覆盖该 seq 时,用 ACK 到达时间减去发送时间得到一个 RTT 样本。如果代码里没有显式记录,也可以按“每个 ACK 到达时间 - 该 ACK 确认序号最后一个字节对应的发送时间”回溯,但实现略繁琐。所以我在日志设计阶段就坚持把发送时刻打在每个数据包事件上,分析时少很多事。
2.3 重传与乱序判定:用序列号序列做判断比看标志位更可靠
很多人在日志里打一个 retrans=1 标记,分析时直接统计这个标记。这个做法在简单场景够用,但一旦涉及快速重传、选择性确认、乱序到达,光靠标志位很难判断真实发生了多少次有效重传。
更可靠的思路是先还原序列号流。发送日志里每个数据包都带有 seq 和 payload 长度,把它们按 seq 排序后检查是否有重叠区间。重叠部分说明存在重传。然后结合 ACK 的到达顺序和重复 ACK 的数量,区分是超时重传还是快速重传。这个过程虽然脚本麻烦一点,但结果扎实,不会漏判。
我通常会把数据包事件转成一个 seq 区间的列表,然后做一次区间合并,凡是后面插入的区间和历史区间发生重叠,就计一次重传样本。这个方法对乱序和 SACK 场景也适用。分析完成后再和日志中的 retrans 计数对一下账,数值差太多就要检查记录逻辑了。
3. 案例复盘:从仿真曲线反推一个隐藏的窗口限制
理论说太多容易飘,下面用一个实际排查过程来串一遍。这个案例来自我自己的仿真实验,现象很典型,数据链路齐全,正好可以演示整个分析链路怎么走。
3.1 现象:吞吐曲线到不了链路档位
链路配置是 100 Mbps 带宽、RTT 100 ms、丢包率接近零。发送端跑一个持续的 TCP 流,仿真时间 20 秒。跑完以后看吞吐曲线,稳定在 5 Mbps 上下,链路利用率只有 5%。这是典型的“链路看着很闲,流量上不去”的异常场景。
按照常见排查顺序,我先检查是不是发送端没有数据可发,也就是应用层 pacing 限制。再检查是不是链路拥塞导致频繁丢包,触发拥塞控制把 cwnd 压下去了。结果这两个猜测都没有直接证据支撑:发送队列没有空过,cwr 事件和丢包记录少得可以忽略。
这时候就必须进入数据层做细粒度比对了,光看聚合曲线无法区分瓶颈在哪个窗口。
3.2 把三段日志拼到同一个时间轴
我手里有发送端事件日志、接收端 ACK 日志和发送端状态快照。先做一步数据规整,把三段日志按虚拟时间戳合并,然后补上每个时刻的 inflight、cwnd、snd_wnd 字段。
合并之后我看三样东西:第一个是在 20 秒里各时刻的发送窗口值;第二个是 inflight 的分布区间;第三个是 cwnd 与 rwnd 的动态关系。看前 10 秒的数据采样,能直接看到问题苗头:
| time_us | inflight (B) | cwnd (B) | snd_wnd (B) | 事件 |
|---|---|---|---|---|
| 120000100 | 64288 | 72800 | 65536 | TX |
| 120000260 | 64792 | 72944 | 65536 | RX_ACK |
| 120000430 | 64160 | 72944 | 65536 | TX |
| 120000590 | 64800 | 73100 | 65536 | RX_ACK |
cwnd 在持续增长,说明拥塞控制没有把发送窗口压住;snd_wnd 恒定为 65536,说明接收端通告窗口被限制住了;inflight 一直贴着 64 KB 上下波动,重传又几乎没有。到这里基本可以确定,限制吞吐的不是网络也不是拥塞窗口,而是接收窗口。
3.3 用窗口和 RTT 的关系验证瓶颈公式
TCP 单流吞吐受窗口限制时,理论上限可以近似写成:吞吐 = 窗口 / RTT。把数据代进去,窗口取 65536 字节,RTT 取 0.1 秒,65536 × 8 / 0.1 ≈ 5.24 Mbps。实际曲线稳定在 5 Mbps 左右,和这个上限几乎吻合,说明发送端已经被滑动窗口机制限制住了,根本没有机会填满 100 Mbps 的链路。
同时我计算了带宽时延积,100 Mbps × 0.1 s 大约是 1.25 MB。比较下来更明显:接收窗口只有 64 KB,远小于带宽时延积,链路当然喂不饱。这是典型的“窗口太小,BDP 太大”问题。
定位到问题后我重新配置接收缓存,把 rwnd 调到 2 MB,同一份代码重新仿真。吞吐曲线很快就冲到了 95 Mbps 附近,接近链路物理上限。这个案例说明,分析时如果只看平均吞吐,可能只会得出一个“链路利用率低”的模糊结论,但配合 inflight、cwnd、snd_wnd 的时间序列,马上就能识别出瓶颈发生在哪一层。
这里也提醒一点,分析窗口类瓶颈时不能只看瞬时值。窗口字段在丢包后会有快速下降和恢复,必须按时间窗口去看趋势,而不是取头尾两端的值算平均。
4. 数据量变大以后的工具选择与分析工程化
仿真规模一大,日志量增长是很快的。一个几十秒的离散事件仿真,如果每个包都记录事件,几十万行日志很正常;跑几十条流的对比实验时,上千万行也不奇怪。这个量级已经没法靠 Excel 硬扛了,选型问题就摆上桌面。
4.1 小数据量继续用 pandas,大数据量可以引入 DuckDB
刚开始做单流分析时,我最常用的工具是 pandas,直接在 DataFrame 上做筛选聚合非常顺手。日志文件在几个 GB 以内、几十列字段、千万级行数时,pandas 只要内存够大也能处理,适合做探索性分析。
但仿真实验往往要跑多组对比,日志动辄叠加到十几 GB。此时再用 pandas 反复 read_csv 会明显卡顿,而且一旦内存紧张,整个分析脚本就崩了。这时候我改用 DuckDB。它不需要部署独立服务,是一个嵌入式列式数据库,可以直接对 CSV 或 Parquet 文件执行 SQL,过滤和聚合下推到引擎层处理,内存占用比 pandas 低很多。
比如现在有一份 sender_side.log,我想快速统计不同 flow 的总发送字节和吞吐,一条 SQL 就够:
code复制SELECT flow_id,
count(*) AS packet_cnt,
sum(payload_len) * 8.0 /
(max(event_time) - min(event_time)) * 1e6 AS throughput_mbps
FROM read_csv_auto('sender_side.log', header=true)
WHERE event = 'TX'
GROUP BY flow_id;
用 DuckDB 的一个额外好处是,它能直接读 Parquet 文件,所以我会把仿真日志先转成 Parquet 存好,后续反复分析时不需要每次重新解析 CSV。
4.2 Parquet 格式值得早点引入
日志文件如果只是自己临时看一下,CSV 当然没问题。但仿真实验一旦多起来,CSV 的劣势就很明显:没有类型信息,每次读取都要推断;没有压缩,磁盘和 I/O 压力大。我现在的习惯是把原始日志落地成 Parquet,用 pyarrow 或者 DuckDB 统一写。
转换成本其实非常低,在生成日志后加一个读 CSV 写 Parquet 的小步骤就行。后续分析产生的中间表也可以直接存 Parquet,分析完删除临时文件,源数据保持只读。这个习惯能省下大量重复解析的时间。
如果想更省事,有一种常见的做法是写一个统一的数据导入脚本,把各层日志文件路径、schema 版本、导出的 Parquet 文件路径集中管理,再在配置里维护一套字段映射。这个工程化铺垫做完之后,后续分析每个实验的运行数据只需调用一个函数。
4.3 多轮实验一定要维护指标口径清单
做协议栈仿真免不了反复调参、反复重跑。麻烦的是,每轮实验之间如果记录格式变了,或者字段含义调整了,老日志的分析逻辑就会静默作废。所以我把每次实验的指标口径存成一个清单,记录清楚:吞吐统计里是否包含重传、时间窗口怎么切的、空转阶段是否剔除、窗口字段是字节还是报文数。
口径清单的价值要到做横向对比时才体现出来。比如你想对比慢启动阈值调整前后的表现,如果两轮实验的日志里吞吐统计口径不一致,得出的结论很可能失真。数据分析的复现性,往往不是代码复现,而是口径复现。
我还会把每一次实验的仿真配置文件和日志 schema 一起存档,保证任何一张分析图都能追溯到对应的实验版本。这听起来很繁琐,但等你需要回头核对半年前跑出的一个异常数据点时,就知道这个存档有多救急。
5. 可视化验证时最容易误判的几个信号
我见过不少人在分析阶段把图画得非常漂亮,最后结论却站不住脚,问题出在图的形态被过度解读了。这里挑几个协议栈仿真里常见的误读信号说一下。
5.1 周期性锯齿未必是 bug,可能是多流之间的时钟同步效应
多 TCP 流并发仿真时,吞吐曲线如果呈现很规则的锯齿形,很容易被当成拥塞控制振荡。但这其实可能只是多个流因为共享同一个丢包事件或定时器边界,产生了同步效应,也就是经典的多流全局同步问题。它们同时进入拥塞避免,同时降窗,又同时恢复,导致总吞吐出现周期性锯齿。
遇到这种图形,不要急着去改拥塞控制参数。先把各条流的丢包时间点对齐,如果丢包都集中在很短的相同时间段内,说明是同步效应而不是某个流的独立问题。这时的调整方向往往是给不同流引入随机相位,或者让丢包事件的触发条件带上随机抖动。
5.2 曲线平滑也不一定是好消息,要结合丢包和重传看
平滑的吞吐曲线给很多人一种“网络稳定”的错觉。但如果你在做路由器缓冲区很小的仿真实验,平滑曲线可能只是掩盖了丢包事件,因为发送端的拥塞窗口在每次丢包后迅速恢复,吞吐看上去没有波动。
正确做法是把吞吐曲线和丢包事件叠加在同一张图里。任何时候看到吞吐平稳、丢包率却不低,就要怀疑是不是重传效率过高,协议栈把大量资源用在重复发送上。平滑处理适合做趋势观察,不适合做健康判断。
5.3 单点尖峰常常对应超时重传,不要直接归类为瞬时拥塞
很多人在吞吐曲线上看到一个尖峰就本能地认为是拥塞导致。但如果你把尖峰发生的时间点和 TCP 超时重传的定时器对齐,会发现尖峰往往对应某个数据段的超时重传。超时重传会让发送端一次性把累积的数据重新打入网络,形成瞬时过冲,然后又因为窗口收缩迅速掉下来。
判断思路很简单:把日志里的 timeout 事件单独标记出来,看看尖峰的波峰时间点是否落在某个 timeout 之后的一小段时间里。如果是,那重点排查的应该是重传定时器的计算逻辑,或者触发超时的原因,而不是链路容量。
动手做这个分析的时候,我会把 timeout 事件和 cwnd 变化曲线放到同一张图上,因为 timeout 一发生,cwnd 会立刻塌缩到初始值,随后才开始慢启动,整个过程会在曲线上留下清晰的“V 形”。如果你能熟练识别这种形态,很多协议栈问题看一眼图就能锁定大方向。
做协议栈仿真分析这么久,我给自己立了一个规矩:任何一张用于说明协议行为的图,提交之前都要把同一条数据里至少两个独立字段拉出来交叉验证一遍。图能自圆其说还不够,数据和现象对齐了,分析才算真正闭环。
