Skip to content

libhv 大响应被关闭问题排查

背景

通用网关 POST /api/core/big-data/test 接口偶发返回 curl 错误 18:

* transfer closed with 18187026 bytes remaining to read
curl: (18) transfer closed with 18187026 bytes remaining to read

该接口由后端 DC 服务产出约 20MB 的 JSON 响应,经由 libhv 实现的 HTTP access 插件写回客户端。最初怀疑是跨线程写 socket 导致的竞态,但排查后证明这个假设完全错误,根因在 libhv 自身的写缓冲硬限制。

本次排查严格遵循"先建立可复现反馈环,再提出假设"的纪律,所有结论均有实验数据或源码支撑。

核心内容

建立反馈环

先用 curl 量化复现率,确认这不是真正的偶发:

bash
for i in $(seq 1 20); do
  curl -sS -o /dev/null \
    -X POST 'http://10.111.180.138:8200/api/core/big-data/test' \
    -H 'Content-Type: application/json' -d '{"a":22}' \
    -w 'HTTP=%{http_code} DL=%{size_download} T=%{time_total}\n' 2>&1
done

结果 20/20 全部失败,而且数据高度规整:

  • 每次返回 HTTP 200(响应头正常发出)
  • 每次实际收到 110801 字节,完全相同
  • 每次剩余 20860763 字节,完全相同
  • 总长 = 110801 + 20860763 = 20971564 ≈ 20MB + 44 字节 JSON 头
  • 故障在 ~0.23s 内发生

截断点完全固定的特征,本身就排除了竞态假设。竞态必然带来字节数波动,但这里连一个字节的偏差都没有。

三方证据交叉定位

strace 跟踪服务端

对运行中的 gateway 进程做 strace,只抓一个请求:

bash
strace -f -e trace=network,write,writev,sendto,sendmsg,close -p <PID> -o /tmp/strace.out

关键两行:

3168023 sendto(100, "HTTP/1.1 200 OK\r\nConnection: kee"..., 20971723, MSG_NOSIGNAL, NULL, 0) = 110960
3168024 close(100)                      = 0

服务端用单次 sendto 试图发出 20971723 字节,内核只接受了 110960 字节(socket 发送缓冲已满,短写),紧接着进程主动 close(fd)

libhv 自身日志

通过 /proc/<PID>/fd 反查 strace 中那条 ERROR 日志的目标文件:

bash
ls -la /proc/<PID>/fd/ | grep '\.log'
# fd 92 -> .../build/bin/libhv.20260707.log

libhv 自己写下的 ERROR,直接说出了根因:

2026-07-07 16:59:00.851 ERROR write bufsize > 16777216, close it! [nio.c:529:hio_write]

16777216 = 16MB,正是 libhv 的 max_write_bufsize 默认上限。

libhv 源码 hio_write4

event/nio.chio_write4 函数中找到关闭逻辑:

c
if (nwrite < len) {
    size_t unwritten_len = len - nwrite;
    if (io->write_bufsize + unwritten_len > io->max_write_bufsize) {
        hloge("write bufsize > %u, close it!", io->max_write_bufsize);
        io->error = ERR_OVER_LIMIT;
        goto write_error;
    }
    // ... 否则入队 write_queue,后续由 nio_write 异步发
}
...
write_error:
    if (io->io_type & HIO_TYPE_SOCK_STREAM) {
        hio_close_async(io);   // strace 中 close(fd) 的源头
    }

hio_write 标称线程安全,但其"安全"仅指用递归互斥锁保护 write_queue 入队操作。它不会在发送不下时阻塞等待,而是在累积未发送量超过 max_write_bufsize 时直接判定为 ERR_OVER_LIMIT,异步关闭连接。

完整因果链

控制变量实验:解释"偶发"

对同一个 20MB 接口,只改变客户端的 SO_RCVBUF,观察结果:

客户端 SO_RCVBUF 服务端 sendto 请求 服务端 sendto 返回 libhv 行为 客户端结果
~64KB(curl 默认) 20971723 110960(短写) enqueue 剩余 >16MB → close 失败,收到 110801
64KB(python) 20971723 短写 同上 → close 失败,收到 2881824
32MB(python) 20971718 20971718(全发) nwrite == len → done 成功,收到 20971718

32MB RCVBUF 的 strace:

sendto(100, "HTTP/1.1 200 OK...", 20971718, MSG_NOSIGNAL, NULL, 0) = 20971718

一次 sendto 全部成功返回。因为客户端抽得快,内核 send queue 始终有空位,send() 不短写,nwrite == len,libhv 走 write_done 分支,不进 enqueue,不触发 16MB 判定。

这同时证明了三件事:

  1. 响应体本身没问题(20MB 能完整产出)
  2. 网络链路没问题(大 RCVBUF 能完整接收)
  3. 故障完全由服务端 send() 短写量决定 —— 客户端抽得慢就短写严重,触发限流关闭

用户报告"偶发",与我的 curl loop 100% 失败的矛盾由此解释:真实环境里客户端接收窗口、网络拥塞、并发请求对发送缓冲的争用都会影响 send() 的短写量,偶发出现短写后剩余 < 16MB 的场景就能侥幸成功。

排除的竞争假设

排查初期基于代码阅读提出了几个假设,全部被实验否决:

假设 否决证据
跨线程写 socket 竞态 strace 显示单次 sendto,无竞态特征;截断点固定 110960
End() 被调用两次导致状态机跳转 libhv ERROR 在 hio_write 内部,早于任何 End() 逻辑
网络 MTU / 中间设备截断 32MB RCVBUF 能完整收到 20MB,排除链路问题
HTTP keepalive 复用旧连接 strace 显示是全新 accept

教训:在拥有反馈环之前,任何基于代码阅读的假设都属于"vibe"。本案例中,20 次 curl 的固定截断点在 5 分钟内就否决了"竞态"假设。

要点

libhv 写缓冲机制

  • hio_write 标称线程安全,但仅在 write_queue 入队层面加锁
  • 写不下时不阻塞等待,而是累计入队;累计量超过 max_write_bufsize(默认 16MB)直接关闭连接
  • max_write_bufsize 可通过 Channel::setMaxWriteBufsize()hio_set_max_write_bufsize() 修改
  • 关闭由 hio_close_async 触发,异步执行,客户端看到的是突然 FIN

触发条件

响应体满足以下两个条件即必现:

  1. 单次 WriteBody 写入量较大
  2. 客户端接收速率不足以让 send() 一次写完,导致短写后剩余量 > max_write_bufsize

阈值大致是:响应体大小 - 一次 send 能写出的量 > 16MB。慢客户端/大响应组合最容易踩中。

修复方向

方案 改动量 评价
提高 max_write_bufsize 一行,writer->setMaxWriteBufsize(64MB) 治标,单连接堆积大 buffer 占内存,但可立即止血
分块 WriteBody sendResponse 分块多次写,配合 onwrite 背压 治本,需改 access 插件
走 chunked / SSE 流式 代码已有 SSEvent 路径 治本,适合大响应

推荐先用提高 max_write_bufsize 快速止血,再用分块写入作为长期方案。

验证手段

修复后用三件套回归:

  • curl loop 跑 20 次,期望 0 失败
  • strace 确认 sendto 多次分批调用且最终无 close 提前
  • libhv 日志不再出现 write bufsize > 16777216

排查方法论

本次排查的关键转折是把"读代码提假设"换成"建立反馈环量化复现"。一旦拿到 20/20 全失败且截断点固定的数据,竞态假设瞬间不攻自破,strace 和 libhv 日志直接指明真因。工具组合是:curl loop(量化复现率) + strace -p(服务端实际行为) + /proc/<PID>/fd(反查日志归属) + 上游源码(机制确认)。

基于 VitePress 构建