长肥网络中日志断流:从TCP窗口饿死到Gzip压缩优化
1. 事故现场告警、拓扑与 413 的误导那天下午两点监控面板突然被红色刷屏LiteLLM 网关到日志链路的所有写入指标都在往下掉。我们这套系统本身不复杂法兰克福机房部署 LiteLLM 代理统一转发 OpenAI、AWS Bedrock 等上游大模型请求同时通过回调把完整请求和响应体回传到另一个区域机房的 VictoriaLogs做审计和成本分析。接收日志的是一台 RISC-V 架构服务器单机部署数据量不算大本来一直跑得很安静。直到某次业务方接了一个超大上下文任务单次请求带上多轮历史、工具调用结果和文档片段LiteLLM 侧的 Payload 规模直接冲到 40 万 Token 级。从那一刻开始日志写入就开始大面积失败超时、连接重置、传输到一半被服务端掐断。这篇文章就把这次排查的完整链路写出来。从 TCP 层抓包到 VictoriaLogs 的接收端配置再到 RISC-V 平台的性能短板最后用 Gzip 压缩把问题按住。整个过程中最有价值的不是某个单独参数而是一条排查思路在跨国长肥网络里传超大日志瓶颈往往不在带宽而在必须跨链路的字节数和接收端消费报文的速度。1.1 先看清楚这套架构的数据流LiteLLM 网关这层本质是个 LLM 网关负责鉴权、限流、负载均衡、失败重试以及对上游请求的归一化。它的 callback 机制非常灵活我们当时选它就是看中这一点可以在 success callback 里拿到完整的请求 messages 和响应内容再把这些原文结构化成 JSON通过 HTTP POST 推给日志库。日志库用的是 VictoriaLogs跑在一台 RISC-V 架构服务器上。为什么选 RISC-V这里不多展开核心是这批次服务器是性能验证平台CPU 指令集和 x86 有两套生态Go runtime 对 RISC-V 的支持已经比较成熟VictoriaLogs 作为单机日志库能直接在 riscv64 上跑起来。两个机房之间走的是企业专线物理距离横跨大半个地球RTT 稳定在 150ms 左右带宽 1Gbps。单独看数字都还行但高带宽 × 高延迟组合在一起恰恰是最折磨人的网络形态专业叫法是 Long Fat Network长肥网络。小报文日志在这条链路上完全没问题几百 KB 的请求回传一次 RTT 内就能把数据推完。问题只出现在超大 payload 上。1.2 两种大报文失败不能混为一谈排查过程中最先被翻出来的错误是这条unexpected status 413 payload too large: upstream provider rejected the request这很容易让人误判成日志链路的问题。实际上它发生在业务链路上LiteLLM 把请求转发给上游模型 Provider 时大模型服务商对单请求体大小有硬限制超了就会返回 413 Payload Too Large。LiteLLM 会把上游的 413 原样透传回来业务侧看到的就是这个错误。这个和日志落库是两条完全不同的链路先分开别混在一起查。真正让我们头疼的是另一类错误集中在日志回传链路错误类型出现位置说明context deadline exceededLiteLLM 回调客户端发送日志时 HTTP 请求超过客户端超时connection reset by peerLiteLLM 回调客户端TCP 连接被接收端 RST 中断unexpected EOF/transfer closed发送端报文传了一段连接被对端关闭剩余数据丢失也就是说业务侧虽然偶发 413但真正的日志断流和 413 没有因果关系。我们在排查记录里专门建了两个任务一个去和上游服务商确认单请求大小上限另一个处理日志链路断流。1.3 40 万 Token 到底等于多少字节对 Token 没有直觉的人很难理解为什么日志会写到超时。先算一笔账业界一般按 1 Token 约 4 字节估算40 万 Token 的纯文本大约 1.6MB。但我们的日志不是纯文本要把请求 messages、响应 completion、tool_calls、usage 统计、时间戳、metadata 全部序列化成 JSON。JSON 里字符串转义、键名重复、换行符和 Unicode 编码都会放大体积。实测下来一个 40 万 Token 的请求请求体和响应体合起来序列化后常见是 5MB 到 8MB。如果请求里还带图片或文档的 base64那直接就奔着 10MB 以上去了。后面所有的排查和优化都以单条日志 8.2MB 这个实测数据为基准。2. 长肥网络下的 TCP 行为抓包看到的不是丢包是窗口饿死2.1 长肥网络的 BDP 直觉长肥网络这个名词听起来文绉绉其实就是一个非常具体的特征带宽大、延迟也大。衡量一条链路能装下多少在途数据有个物理概念叫带宽时延积BDP公式是BDP 带宽 × RTT我们这条专线 1GbpsRTT 150ms算下来 BDP 大约是 18.75MB。什么意思这条链路像一条非常宽但非常长的高架桥理想状态下桥上应该能同时跑 18.75MB 的车。可 TCP 的发送窗口如果小于这个值就相当于每个红绿灯周期只放几辆车进桥桥上大部分路段是空的。带宽再高实际吞吐也上不去。传统 TCP 栈的默认 buffer 非常小系统默认的tcp_wmem通常只有几十 KB 起步就算有窗口缩放Window Scale如果应用没有显式设置大缓冲区依然容易变成管道空转。对于 8.2MB 的单条报文这已经是一个需要认真对待的量级了。2.2 tcpdump 抓包的关键证据我直接在 LiteLLM 所在主机上抓包命令很简单tcpdump -i eth0 -s 0 -w /tmp/litellm_vlogs.pcap host victorialogs-ip and port 94289428 是 VictoriaLogs 的默认 HTTP 服务端口。抓完一次失败样本后用 Wireshark 打开重点看三个东西TCP 序号走势、窗口大小、RST 标志位。结果很有意思。8.2MB 的报文按 1460 字节的 MSS 拆分大约要拆成 5700 个 TCP 分段。从头 3000 多个分段传输都比较顺畅没有严重的丢包重传。问题出在传输中后段接收端通告窗口开始逐步变小从 2MB 一路跌到 0出现 Zero Window然后紧接着是 RST连接被直接重置。客户端这边收到 RST 后尝试重连重发然后又重复一遍传一半、窗口饿死、RST的循环直到客户端自己的超时触发报context deadline exceeded。这个现象说明网络本身不是瓶颈丢包率并不高。真正的瓶颈在接收端内核缓冲区里的数据没有被应用层及时读走缓冲区满了窗口就收缩到零等了一段时间应用还是没消费完HTTP 服务端判断这个连接已经读不下去了直接 RST。换句话说这是一次应用层消费能力不足引发的传输层断流不是广域网丢包。2.3 调大 socket buffer 为什么治标不治本定位到窗口问题后第一反应肯定是调大系统缓冲区。我们当时做了两组调整# 临时调大内核缓冲上限 sysctl -w net.core.wmem_max33554432 sysctl -w net.core.rmem_max33554432 sysctl -w net.ipv4.tcp_wmem4096 65536 33554432 sysctl -w net.ipv4.tcp_rmem4096 87380 33554432调完之后确实有效果5MB 以下的报文能稳定写入了8.2MB 的报文从必失败变成了看运气失败。这说明内核 buffer 的限制占一部分原因但不是全部。因为即使我们给了 32MB 的缓冲区接收端应用如果消费速度跟不上窗口照样会收缩。缓冲区只是把问题往后推并没有解决VictoriaLogs 处理超大 JSON 慢这个核心矛盾。从这一刻开始排查方向从网络层转到了接收端应用层。既然 RISC-V 服务器上的 VictoriaLogs 消费大报文吃力那就得看看它到底卡在哪。3. 接收端 VictoriaLogs 的硬门槛读超时、解压开关与平台性能3.1 从反代到直连逐层排除如果日志链路前面挂了 Nginx 或 Caddy 之类的反向代理第一个怀疑对象是client_max_body_size。Nginx 默认限制只有 1MB超过直接返回 413。这是非常常见的大报文落库失败原因一定要先排除。我们当时确实在 VictoriaLogs 前面挂了一个 Nginx 做 TLS 终止和简单路由查看配置发现client_max_body_size被设成了 2m。不过把 2m 改成 128m 之后问题依旧。为了彻底排除反代干扰我直接用 curl 绕开 Nginx把 8.2MB 的日志 POST 到 VictoriaLogs 的 9428 端口实测curl -v -X POST http://victorialogs-ip:9428/insert/elasticsearch/_bulk \ -H Content-Type: application/x-ndjson \ --data-binary large_payload.txt直连仍然复现超时和 RST。这说明问题不在 Nginx而是 VictoriaLogs 自己处理不过来。接着又用 1MB、2MB、5MB、8MB 四档报文做梯度测试结果是一个很典型的软极限曲线1MB 完全没问题2MB 偶发超时5MB 成功率降到一半8MB 基本失败。这不是配置里写死了某个体积上限而是处理时长超过了服务端的读超时阈值。VictoriaLogs 底层是 Go 写的 HTTP server接收 body 时有超时控制。8.2MB 的 JSON NDJSON 报文到达后服务端要分配内存、做 gzip 解压如果有、解析 JSON、建立索引、刷盘。这个过程如果耗时过长超过了 HTTP 层的读超时服务端就会主动断开连接。发送端看到的 RST 就是这么来的。3.2 RISC-V 平台在日志接收链路里的软肋到这里RISC-V 平台的性能短板开始浮出水面。我们用同一份 8.2MB 报文在 x86 机器和 RISC-V 机器上分别做直连压力测试差异非常明显平台单条 8.2MB 报文入库耗时5MB 报文成功率x86 服务器约 1.1s接近 100%RISC-V 服务器约 6.8s约 50%RISC-V 服务器CPU 负载 60% 以上时约 11s基本失败RISC-V 平台的单核性能和内存带宽目前和主流 x86 还是有不小差距尤其是 VictoriaLogs 这种对 CPU 敏感的日志处理任务。JSON 解析本身是纯内存操作指令集架构的差异在这里被放大了。更要命的是日志服务器上还跑了其他采集任务CPU 负载一上来VictoriaLogs 读 body 的速度更慢超时断流就变成大概率事件。其实到这一步问题的本质已经清楚了不是网络连不通也不是配置写错而是单次传输的 Payload 太大加上接收端消费慢在长肥网络上把应用层超时逼到了极限。3.3 VictoriaLogs 对 gzip 请求体的原生支持VictoriaLogs 的插入 API 原生支持带Content-Encoding: gzip的请求体。也就是说我们可以在发送端先把日志压缩再 POST 过去服务端会自动解压后处理。这个支持非常关键等于直接把优化点放在减少必须跨网的字节数上。官方文档对于 bulk 接口的说明是请求体可以是纯文本 NDJSON也可以是 gzip 压缩格式。也就是说只要 HTTP 头里带上Content-Encoding: gzip服务端就会先解压再进入解析流程。最开始我们直接用 gzip 压缩后的文件做了一次直连测试gzip -k large_payload.jsonl curl -X POST http://victorialogs-ip:9428/insert/elasticsearch/_bulk \ -H Content-Type: application/x-ndjson \ -H Content-Encoding: gzip \ --data-binary large_payload.jsonl.gz单个 8.2MB 的日志在 gzip -6 压缩下变成了大约 870KB传输体积直接少了一个数量级。跨网络的传输时间从几秒压缩到几百毫秒接收端压力骤减。虽然 RISC-V 上还要做一次解压但解压 870KB 和解析 8.2MB JSON 的开销完全不是一个量级。不过手动 curl 验证和落地到 LiteLLM 回调链路还差一步得在发送端把 gzip 压缩变成自动行为。4. Gzip 优化实战在发送端把带宽需求打下去4.1 压缩收益先算账做任何优化之前先量化收益别凭感觉。我们当时拿了三种场景对比原始 8.2MB、gzip -1最快压缩、gzip -6默认平衡档结果如下压缩档位压缩后体积跨链路传输耗时实测CPU 压缩耗时RISC-V不压缩8.2MB平均 7.2s常超时0gzip -11.31MB约 1.4s约 0.9sgzip -60.87MB约 0.9s约 2.6sgzip -1 和 gzip -6 在压缩率上差距只有 34%但压缩耗时差了接近三倍。对于日志回传场景接收端是弱性能的 RISC-V 平台发送端也是要靠 CPU 做其他转发工作的 LiteLLM 网关选 gzip -1 是性价比最高的选择。体积从 8.2MB 降到 1.31MB已经足够让连接稳定写完没必要为了多压那 400KB 去消耗大量 CPU。这个账想明白之后我们定了策略小日志不压缩超过 1MB 的 payload 才走 gzip压缩级别固定为 1如果网络条件后续继续变差再考虑升级到 6。4.2 LiteLLM 侧接入 gzip 回调LiteLLM 的 callback 体系里可以在success_callback注册自定义函数。我们在回调里拿到完整的请求和响应对象后先序列化成 JSON 字符串紧接着做体积判断和压缩最后通过 HTTP 发送。核心逻辑大概长这样import gzip import json import http.client import zlib def flush_log_to_vlogs(log_bytes: bytes, endpoint: str, timeout: float 30.0): # 超过 1MB 的日志做 gzip 压缩低于阈值直接明文发送 should_compress len(log_bytes) 1_000_000 headers { Content-Type: application/x-ndjson, Connection: keep-alive, } if should_compress: payload gzip.compress(log_bytes, compresslevel1) headers[Content-Encoding] gzip else: payload log_bytes # 用 http.client 自持连接池避免每次新建 TCP 连接吃满 RTT conn http.client.HTTPConnection(endpoint, timeouttimeout) conn.request(POST, /insert/elasticsearch/_bulk, bodypayload, headersheaders) resp conn.getresponse() resp.read() conn.close()几个关键选择值得说清楚压缩放在序列化之后是因为 gzip 对字节序列操作不关心你上层是 JSON 还是 NDJSON。Connection: keep-alive在长肥网络下非常重要。跨国新建一个 TCP 连接至少要一个 RTT 的握手时间150ms 在这种场景下很奢侈。连接池复用能把握手开销摊薄。超时时间单独调大。日志不是业务请求可以容忍慢但不能因为慢就无限等。我们设为 30s超过就异步重试。gzip 压缩的副作用是发送端 CPU 上升。实测在 LiteLLM 所在 x86 机器上gzip -1 压缩 8.2MB JSON 大约耗时 0.3s完全在可接受范围内。RISC-V 接收端解压同样大小的数据大约 0.4s对比解析原始 8.2MB JSON 的 6.8s还是快得多的。4.3 压缩级别与数据特征的三角权衡Gzip 优化里最容易踩的坑就是把压缩级别拉到 9。压缩级别每高一档CPU 耗时几乎是线性增长但体积下降越来越平缓典型的收益递减。在日志场景里数据组成常常不是纯文本40 万 Token 的响应里会有大段 base64 编码的图片或文档base64 本身就是已经编码过的数据随机性高gzip 对它的压缩率很差。如果日志里大部分是 base64那么压缩级别再高也榨不出多少空间反而白白消耗 CPU。所以一个实用的经验是先做一次数据特征抽样看看 JSON 里的主要成分。如果以自然语言文本为主gzip -1 到 -6 都能有 5 到 10 倍的压缩率如果混杂大量 base64压缩率会掉到 2 到 3 倍这时不如放弃极致压缩把精力放在字段裁剪和采样上。另外gzip 压缩不建议一条日志压一次。如果日志是几百条小报文各自独立 gzip每个 gzip 流都有固定的头部和尾部开销压缩率和 CPU 开销都很不划算。更好的做法是在 LiteLLM 回调里攒批等积攒到 2MB 到 5MB 的原始数据量后一次性压缩、一次性发送。4.4 别忽视 gzip 的缺点既然标题里带了 Gzip就必须把它不好的一面也说清楚。gzip 不是银弹至少有三个缺点在这类场景里要盯住。第一是 CPU 开销。压缩和解压都不是免费的。发送端压缩是额外工作接收端解压也是额外工作。RISC-V 平台上的解压速度不如 x86如果日志流量极大解压本身也可能成为新瓶颈。我们的策略是只在报文超过阈值时才压缩低于 1MB 的报文直接明文发送避免为了省几毫秒网络时间多烧 CPU。第二是压缩炸弹风险。服务端解压时会把整个内容展开到内存一个很小的 gzip 文件解压后可能膨胀非常多倍。对于 VictoriaLogs 这类日志接收端虽然有内存保护机制但客户端还是应该设置单条压缩后报文的大小上限超过就截断或拒发。别把几十 MB 的压缩包直接扔给服务端。第三是中间链路兼容性。如果你的日志链路中间还有别的代理比如 Nginx、负载均衡或者日志采集器它们可能会对 body 二次处理。有的代理会自动解压再重新压缩有的则会直接透传这会导致Content-Length和实际 body 不匹配出现411 Length Required或者unexpected EOF。上线前一定要做一次端到端验证最好抓一下服务端收到的 body 是什么编码。5. 上线验证、回归压测与后续演进5.1 压测数据对比Gzip 逻辑上线后我们做了一轮为期 12 小时的回归测试。对比指标围绕三个单条大报文入库成功率、P95 延迟、接收端 CPU 占用。指标优化前优化后gzip -1 阈值压缩8.2MB 单条日志成功率约 30%99.9%P95 入库延迟超过 12s经常超时约 1.3sVictoriaLogs CPU 平均负载68%55%网络入向流量同批日志8.2MB/条1.31MB/条接收端 CPU 不升反降这个结果一开始有点反直觉。细想就明白了RISC-V 平台最重的开销是解析 8.2MB JSON 字符串而压缩后解压只需要处理 1.31MB 数据虽然解压消耗一定 CPU但相比省下的 JSON 解析和内存分配开销整体负担是下降的。对弱 CPU 平台来说减少内存分配往往比减少算力消耗更重要。5.2 几个容易反复的坑第一坑Content-Length 对不上。用现成 HTTP 库发送时如果手动设置 headers 又写了错误的 Content-Length服务端会一直等 body。建议不要手算让 HTTP client 根据字节流自动设置。第二坑NDJSON 批量里的单条损坏。Elasticsearch bulk 格式是以行为单位的如果一条日志里混入了换行符没有转义整个批次解析会错乱。我们当时就遇到过响应文本里自带\n序列化时没有转义导致发送端能发出去、接收端解析失败但不报错。解决方法是发送前强制做一次 JSON 格式校验确保每条记录是合法 JSON 行。第三坑连接被中间设备静默回收。跨国专线中间的防火墙或负载均衡设备会对长时间空闲的 TCP 连接发 RST。即使连接池 keep-alive如果日志频率不高连接空闲几分钟就可能被回收。需要在客户端池子里加空闲检测和自动重建别让一次偶发的 RST 变成整批日志重试。第四坑重试风暴。回调链路加了失败重试之后如果 VLogs 短暂不可用多条日志同时进入指数退避重试恢复后瞬间涌入大量压缩报文又可能把 RISC-V 平台打满。重试必须加抖动和限流这个坑我们踩过一次最后靠一个简单的信号量控制并发重试解决。5.3 后续还能怎么改Gzip -1 是成本最低的一刀但不是终点。如果后面日志量继续涨有几个方向值得探索。一个是用 Zstandard 替换 gzip。zstd 在相同压缩率下通常比 gzip 快很多如果 VictoriaLogs 的接收端版本支持对应压缩格式跨链路的 CPU 开销会更低。不过要确认服务端版本支持不能只看客户端。另一个是流式压缩。目前实现里还是先把完整日志字符串存在内存里再整体压缩。40 万 Token 的响应如果变成 200 万 Token单条日志可能到 40MB这时候内存就不够优雅了。改用流式 gzip一边读取回调数据一边压缩写入请求体能显著降低峰值内存。还有一个更实用的做法是字段裁剪。我们审计日志真正必需的字段其实只有请求摘要、响应摘要、Token 用量和关键元数据。最开始全量落库是为了方便排查但全量 JSON 里大量字段是重复键名和无效 null。上线后我们做了一个白名单模式只保留真正要查询的字段日志体积直接降到原来的四分之一。字段裁剪和 gzip 叠加之后40 万 Token 的完整审计从 8.2MB 降到了 1MB 以内RISC-V 平台跑得非常轻松。6. 收尾一点个人经验这次攻坚结束后我们定了一条规矩凡是跨长肥链路的日志回传先回答有多少字节必须过网这个问题。很多表面上的网络故障最后都会收敛到应用层没有控制好单次传输的量级。调 TCP 窗口、改内核参数、扩大 buffer这些操作在长肥网络下当然有意义但它们解决的是管道能不能装满的问题而当接收端在弱 CPU 平台上消费不动时最有效的办法永远是让管子里少走一些字节。Gzip 不是所有场景的银弹但在这个组合里它是投入产出比最高的第一刀。压缩级别、数据特征、CPU 开销三个变量要一起权衡别无脑上 -9。另外就是任何跨链路的传输优化上线前一定要从发送端一路抓到接收端确认中间没有任何环节悄悄改动了你的 Content-Encoding 和 Content-Length。最后分享一个小技巧在调试这类大报文问题时可以先在发送端用curl -w观察时间分布输出里time_starttransfer和time_total的差值能帮你判断是传输中花的时间还是服务端处理花的时间。我们当时就是靠这个差值很快锁定了问题不在广域网丢包而在接收端应用消费能力。希望这套排查链路能帮你下次少熬几个夜。