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 量化复现率,确认这不是真正的偶发:
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,只抓一个请求:
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 日志的目标文件:
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.c 的 hio_write4 函数中找到关闭逻辑:
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 判定。
这同时证明了三件事:
- 响应体本身没问题(20MB 能完整产出)
- 网络链路没问题(大 RCVBUF 能完整接收)
- 故障完全由服务端
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
触发条件
响应体满足以下两个条件即必现:
- 单次
WriteBody写入量较大 - 客户端接收速率不足以让
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(反查日志归属) + 上游源码(机制确认)。