目录
- 1. 引言:请求明明成功了,连接为什么还在增加?
- 2. 业务背景:一轮上报,其实有几个结束点
- 3. 从代码入手:不用正文,是否就可以不处理?
- 4. 实战演示:先看连接,再看请求停在哪里
- 第一步:同样拿到 16 个 200,连接结果却不同
- 第二步:确认复用,不能只凭“好像快了一点”
- 第三步:把总连接数限制为四条,观察第五次
- 5. 只 Close 与读完再 Close,差别在哪里?
- 6. 对比结果:连接少了,还要看工作有没有做完
- 7. 修复接回 Agent:让每一轮都能完整收尾
- 把关闭放在一轮上报的范围内
- 成功与失败分支,都要交代响应体
- 给“读完”规定可以接受的边界
- 8. 再看 30 秒与超时:先把等待阶段说清楚
- 9. 小结:把“上报成功”补成完整的一轮
1. 引言:请求明明成功了,连接为什么还在增加?
业务上有个 Agent,工作并不复杂:每隔 30 秒采集一次状态,再向服务端发一个 HTTP 请求(当时为了简单考虑),上报心跳或指标。返回内容没有后续用途,代码只检查状态码。看到 200,就记一次成功,等下一轮继续。
这样的程序很容易让人放心。请求频率不高,也没有复杂并发,看起来不像会把连接占满。可一旦遇到“运行越久,连接越多,后来上报开始超时”,排查就有点绕了:服务端能收到请求,客户端也打印过成功,到底是哪一步没有结束?
我会先把注意力放到收到响应之后。状态码判断完了,那份 Response 去了哪里?它的 Body 有没有读取、关闭?已经不需要的正文,会不会仍然占着上一轮使用的连接?
本文就用这个定时上报场景,把问题拆开验证。业务背景是便于理解的设定,连接数、错误输出和配图则来自已归档的本地实验。目标是弄清一种值得排查的原因,而不是仅凭相似现象,就认定某次历史事故已经找到根因。
2. 业务背景:一轮上报,其实有几个结束点
先把正常流程说清楚。Agent 收集好当前状态,通过共享的 HTTP Client 发出请求;服务端处理后,返回状态码和一段确认信息。Agent 不需要保存这段确认内容,但下一次上报仍然要继续。
这里有个容易忽略的细节:Go 的 Client 可以在收到响应头后返回 Response,正文随后通过 Body 读取。200 在响应头里,它能告诉我们服务端给了什么状态,却不能证明调用方已经处理完正文。Go 1.25.4 Response 说明
所以,一轮请求至少要分开看三件事:是否收到了响应头,是否完成了需要的正文处理,以及是否结束了对响应体的使用。如果业务日志只覆盖第一件事,打印“上报成功”时,后面两件事可能还没有人负责。
图里先看连接 A。第一轮拿到 200 后,Agent 就继续往下走,但正文没有读,也没有关闭。下一轮到来时,如果没有其他可用连接,Client 就需要再建连接 B。业务轮次已经推进,上一份响应却还留在原处。
这是 HTTP/1.1、非空响应体未处理时的业务时序示意。30 秒表示上报节奏,本地验证没有实际等待这个间隔。
还有一点需要先放下:超时比 30 秒长,并不能单独解释资源为什么越积越多。顺序执行的循环,与每次定时触发都启动一个新任务,行为并不相同。我们先不引入任务重叠,只看一份已经返回的响应怎样收尾,后面再回到调度与超时。
3. 从代码入手:不用正文,是否就可以不处理?
真实 Agent 里有采集、序列化、鉴权和重试。把这些一起带进来,很容易查了半天,还分不清是哪里改变了连接状态。这里先拿掉它们,用本地服务返回固定正文,客户端串行执行 16 次请求。
整个过程共用一个 Client 和一个 Transport。可以先把 Client 理解成发请求的入口,Transport 负责底层连接等工作,连接池也在这一层。这样固定下来,后续新建连接就不能简单归咎于“每轮换了一个池”。
服务端每次返回 256 KiB 正文,Content-Length 为 262144,协议核对为 HTTP/1.1。选择非空且固定的响应,是为了稳定观察没有消费正文的后果;这不是在建议心跳接口应该返回这么大的内容。TLS、代理和压缩也暂时移开,避免混入额外变量。
比较的核心代码只有下面三个函数,完整实现见 body.go:
funcbadUnclosed(resp*http.Response,held*[]*http.Response)(int64,error){*held=append(*held,resp)return0,nil}funccloseOnly(resp*http.Response)(int64,error){return0,resp.Body.Close()}funcgoodDrainClose(resp*http.Response)(int64,error){n,readErr:=io.Copy(io.Discard,resp.Body)closeErr:=resp.Body.Close()returnn,errors.Join(readErr,closeErr)}第一种做法只把响应保留下来,既不读,也不关。保留在切片里是为了明确观察尚未结束的响应,不把对象是否被回收当作清理办法。真实代码不一定有这个切片,但收到响应后直接忽略 Body,同样值得检查。
第二种做法只调用 Close。它回答一个更细的问题:补上关闭以后,资源是否能释放?第三种做法先把正文复制到 io.Discard,再关闭,并保留两步可能出现的错误。它继续回答:正文完整读到结束后,下一次能不能用同一条连接?
io.Discard 表示不保留读到的内容,读取动作仍然存在。数据还是要从 Body 经过,慢响应也仍然需要等待。这里固定了响应大小和整体执行时间,才适合拿这种方式做对照。
把半分钟的间隔换成连续串行请求,是为了缩短观察过程,隔离响应体处理这一个变量。它能够证明:即使没有同时跑多个上报任务,这条遗漏也可能让连接持续被占用。它不负责验证真实 Agent 的定时器实现。
4. 实战演示:先看连接,再看请求停在哪里
第一步:同样拿到 16 个 200,连接结果却不同
先运行不读不关的版本。16 次都拿到了 200,服务端累计接受了 16 条 TCP 连接,工作结束时仍有 16 条打开。这里没有出现“某次返回失败后忘了清理”的前提,正常状态分支本身就能留下资源。
接着只补 Close。同样得到 16 个 200,工作结束时活连接降到 0,累计接受数却仍然是 16。看到这里,至少可以确认两件事:关闭确实改变了资源占用,但连接还没有被后面的请求复用。
最后读完再关闭。仍然执行 16 次请求,累计只建了一条连接,后面 15 次复用它。成功次数相同,连接的使用方式变了,这才是修复前后值得比较的差别。
第二步:确认复用,不能只凭“好像快了一点”
客户端用 httptrace.GotConn 留下取得连接时的事件,服务端则为连接分配编号,并统计接受和关闭状态。两端各回答自己知道的事实,再把同一次请求对应起来。回调可能并发发生,记录器也有锁保护,避免观察代码自己引入竞争。
| 请求 | 状态码 | 读取字节 | 服务端连接编号 | Reused | WasIdle |
|---|---|---|---|---|---|
| 1 | 200 | 262144 | 1 | false | false |
| 2 | 200 | 262144 | 1 | true | true |
第一次使用编号 1,第二次仍是编号 1,同时客户端明确报告复用。请求 3 到 16 也沿用这条连接,服务端累计接受数保持为 1。这样比单看耗时更直接,也不需要用猜测补足证据。
不读不关的记录里,服务端也可能把连接标为 idle。这个词容易误导:服务端已经处理完请求,在等下一次;客户端这边却还没完成正文处理,不能据此把它当成可借出的连接。排查时要先问清楚,这是哪一端记录的状态。
第三步:把总连接数限制为四条,观察第五次
如果只是连接太多,一个自然的想法是先设上限。于是继续保留不读不关的逻辑,把 MaxConnsPerHost 设为 4。前四次都拿到 200,第五次却等到了自己的上下文截止。真实的 Windows 错误输出(本地归档)是:
Get "http://127.0.0.1:62095": context deadline exceeded看到 context deadline exceeded,先别急着判断服务端处理慢。我们还要看第五次有没有拿到连接,以及服务端有没有收到第五个请求。
左边是第四次请求:状态码 200,Body 尚未关闭,服务端 live 为 4。右边是第五次:GotConn 调用次数为 0,服务端 requests 仍为 4。它还没有拿到连接,也没有进入服务端 Handler,等待发生在发送业务请求之前。
图片是 2026-10-03 同一份 stdout 的字段摘录,在真实浏览器页面中展示后截图;不是原始终端窗口。完整原文、字段与校验信息见截图来源。
这就把错误缩小了:前四份响应占着连接,总量上限挡住了继续拨号,第五次等不到资源。增加上限可能让出错更晚,减少上报频率可能让增长更慢,都没有替前四个调用完成响应体处理。
5. 只 Close 与读完再 Close,差别在哪里?
从应用角度看,“我不用这份正文了”和“正文已经完整结束”是两件事。前者需要及时关闭,结束当前使用;后者还关系到 HTTP/1.1 连接能否继续承接下一次请求。
EOF 可以先理解为“这一份正文已经读到结尾”。这里不是要求远端把整条 TCP 连接断掉,而是确认当前响应的数据边界。连接想继续服务下一次调用,就需要在协议允许的前提下,正确跨过这份响应的结尾。
图中的三个分支分别对应刚才的代码。没有读取和关闭,当前响应继续持有资源;提前关闭能够结束占用,但本次没有复用;读到 EOF 再关闭,才在这组条件下观察到后续复用。
顺着 Go 1.25.4 transport.go 看,HTTP/1 的响应体经过 bodyEOFSignal。读取循环要等待正文读取结果;提前关闭会走 earlyCloseFn,通知它没有读到 EOF。即使读到了 EOF,也还要满足连接没有损坏、请求写入正常、能够交还等条件。
因此,读完并关闭是在正确处理响应,不是给“必定复用”开一张保证单。对端要求关闭、网络出错或池不接受连接时,仍可能新建。这里也不能把某个版本的提前关闭路径,推广成所有 HTTP 实现都会自动读完,或者永远不会帮忙处理剩余内容。
同样,不要反过来认为“没复用,所以 Close 没用”。只关闭组已经把活连接清到了 0,它完成了释放占用的工作。如果业务决定放弃大响应,及时关闭往往正是合理选择;是否继续复用,需要结合大小、等待预算和协议条件判断。
6. 对比结果:连接少了,还要看工作有没有做完
历史验证在 Windows 和 Linux 各运行四种模式,每种模式各十个独立进程,共八十次。下表给出两平台一致的计数结果,live 表示当前仍打开的连接数。
| 响应体处理方式 | 取得 200 的次数 | 累计新连接 | 复用请求数 | 工作结束 live | 清理空闲后 live | 完整清理后 live |
|---|---|---|---|---|---|---|
| 不读不关 | 16 | 16 | 0 | 16 | 16 | 0 |
| 只 Close | 16 | 16 | 0 | 0 | 0 | 0 |
| 读到 EOF 后 Close | 16 | 1 | 15 | 1 | 0 | 0 |
| 不读不关,总上限 4 | 4;第 5 次截止 | 4 | 0 | 4 | 4 | 0 |
图左侧比较两平台实际接受的连接数,右侧展示生命周期快照;每组十次的计数完全重合。图的数据来自 2026-10-03 历史运行,和前面的 2026-10-19 复跑报告分别保留,没有混用各次运行的端口、PID 或时间。Windows 清单(本地归档)、Linux 清单(本地归档)及图表数据可继续核对。
第一处值得看的是“清理空闲后”。不读不关组仍有 16 条,说明 CloseIdleConnections 没有替调用方关闭这些 Body。它们尚未成为客户端可用的空闲连接。直到显式关闭所保留的响应体,live 才降为 0。Linux 原始记录(本地归档)保存了这几个采样点。
第二处是修复组工作结束时的 1。这个数不需要强行降成 0,因为留下的是可以继续复用的连接。它和错误组被未结束响应占着的 16 条,性质不同。判断资源管理是否合理,要看它还能做什么,而不只是看数字是否清零。
第三处是上限为 4 的组。它的柱子矮,却只取得四次成功响应,第五次已经截止。少完成工作不能算相同工作量下的改进。读完再关闭组完成全部十六次,每次实际读取 262144 字节,总计 4194304 字节,并关闭十六次 Body。
这些结果支持连接释放与复用的判断,没有测出吞吐提升。QPS、P99、CPU、RSS、TLS 握手或 TIME_WAIT 都不在这组测量里。若要评价真实 Agent 的性能,还需要保持相同上报内容和完成量,再单独测量。
7. 修复接回 Agent:让每一轮都能完整收尾
最小程序里的第三个函数很短,接回项目时却不能只粘贴一行 io.Copy。真正要改的是一轮上报的责任范围:谁取得 Response,谁处理正文,谁负责在所有返回路径结束前关闭。
把关闭放在一轮上报的范围内
我会先把一次上报整理成会及时返回的小函数。调度循环只负责决定何时调用,HTTP 响应的处理在这一轮里完成。这样拿到响应后登记的 defer,会随着本轮返回执行,读代码时也容易确认清理位置。
如果直接在长期运行的循环里不断 defer Body.Close,关闭就可能拖到整个 Agent 函数退出。形式上写了关闭,执行时机却不符合每轮收尾的要求。第四篇里的文件句柄问题,也是在类似的位置上把作用范围放大了。
这里还有个细节:连接在满足条件、读到 EOF 后,就可能被 Transport 交还,并不一定要等 Close 才第一次变得可复用。因此,“defer 晚执行”和“每轮一定新建连接”不能直接画等号。我们仍然应及时关闭 Body,只是解释时不能把所有状态变化都归到同一个时间点。
成功与失败分支,都要交代响应体
上报遇到 401、429 或 500,业务通常会返回失败或安排重试,但 HTTP 层仍可能正常返回 Response。状态码不符合预期,不等于调用过程一定返回了 error。只在 200 分支关闭,失败时直接 return,同样会留下遗漏。Client.Do 约定
我会沿着正常返回、非成功状态、正文读取失败、解析失败和提前停止这几条路,把响应体的去向逐个看一遍。尤其是重试:上一份响应没有收尾就开始下一次,相当于一轮业务里重复积累占用,日志却可能只留下最后一次结果。
错误处理也别覆盖掉关键线索。比如正文已经读取失败,关闭又失败,至少要保留主要错误与必要的补充信息。示例用 errors.Join 同时带回两个结果,是为了不让一次关闭把前面的读取问题悄悄抹掉。
给“读完”规定可以接受的边界
如果接口约定只返回一小段确认信息,完整处理通常很自然。但真实响应可能异常变大,服务端也可能很慢。不能为了保住一条连接,把一轮上报拖成没有期限的读取。
因此,要先约定可接受的响应大小和完成时间。达到人工读取上限,只能说明读够了允许的数量,不能证明已经到 EOF;解析出一份 JSON,也不自动证明响应剩余内容都结束了。如果决定丢弃剩余正文,就及时关闭,并接受这一条 HTTP/1.1 连接可能不再复用。
最后再确认 Client 和 Transport 确实按预期共享。共享连接池是基础,处理响应体是另一项责任。本次错误组已经共享实例,仍然每轮新建连接,恰好说明只把 Client 放成全局变量,还不足以完成修复。
8. 再看 30 秒与超时:先把等待阶段说清楚
回到开头那个疑问:周期 30 秒,超时比它长,是否就会把连接耗光?这里至少需要补齐两个信息:每轮任务会不会重叠,以及旧请求究竟什么时候结束。
如果一个循环同步调用上报函数,上一轮没有返回时,不会仅因时钟到了下一个刻度,就凭空多出一条执行路径。如果每次触发都启动 goroutine,慢请求才可能与后续轮次同时存在。两种实现都可能有问题,但要从实际调度代码判断。
允许重叠时,还要给任务规定最大同时执行数,想清楚上一轮未结束时是等待、跳过,还是继续启动。这是调度策略。响应体是否被读取、关闭,是每次调用自己的收尾责任;两件事都要看,不能用其中一项替代另一项。
超时参数也要问清管的是哪段时间。请求总预算、等待响应头、空闲连接保留时间,针对的阶段不同。一次请求在池里等连接,与已经拿到连接后等服务端返回,也需要分别定位。Client 与 Transport 参数说明
本次第五次请求用的是独立的 250 ms context,GotConn 没有发生;它不是响应头迟迟不回。MaxIdleConnsPerHost 为 2,也挡不住前面错误组持有十六条连接,因为它限制的是空闲连接。MaxConnsPerHost 才限制该主机拨号、活跃和空闲连接的总量,满额后新的拨号需要等待。
所以,真实排查时我会把一次调用分成能对应的几个阶段:开始调用、取得连接、收到响应头、结束正文处理、关闭响应体。先确定最后出现在哪个阶段,再去看相应的上下游。这样不会只拿一个“超时”标签,把压力全部推给服务端。
至于“服务端端口占完了”这样的描述,还需要找原始错误和发生进程。客户端无法取得本地端口、进程无法再打开描述符、Transport 池内等待,是不同层面的现象。本文只实际复现了连接被持有以及受总量限制后的等待,没有制造系统端口耗尽或 EMFILE。
9. 小结:把“上报成功”补成完整的一轮
这次拆解最有用的地方,是发现状态码成功与资源收尾之间还有一段工作。不需要响应内容,可以选择不保存它;调用方依然需要安排这份响应怎样结束。只 Close 与读完再 Close 的实测差别,也让“释放占用”和“后续复用”有了分别检查的依据。
回到项目验收,我会让相同数量的上报真正完成,再观察停止新增工作后的连接状态。同时覆盖正常、非成功状态和读取失败的分支,确认没有靠减少任务或隐藏错误,让资源曲线看起来平稳。这个检查方向比只盯着活连接是否为零更可靠。
完整程序在 Lab 005。它适合复查响应体处理与连接状态的关系;需要继续研究调度重叠、HTTP/2 或真实端口耗尽时,再为相应问题单独准备验证。
公开代码:实验目录 · 运行入口。固定提交
c3d8b36a;原始日志和协议等本地归档没有上传 GitHub。