空闲超时会在一段时间没有符合定义的活动后关闭连接。它是一项资源保留策略,不是客户端已经消失的证明。健康客户端也可能暂时安静,关键是说清哪种活动重置哪只计时器,以及连接关闭之后,协议允许调用方如何恢复;不能仅凭“连接还在,但没有字节”就判断它应当被立即回收。
空闲属于某个明确的边界
TCP 连接会占用文件描述符、内核缓冲,以及应用或代理记录的连接状态,但通常不需要为每条连接建立独立的路由表项。回收暂时不用的连接,可以限制保留的资源;复用健康连接则能减少重新建连的成本。策略应服务于工作负载,而不是把所有沉默连接都理解成已经被遗弃的泄漏。
Go HTTP Server.IdleTimeout 约束的是持久连接上等待下一次请求的时间。处理器正在工作、但尚未发送任何字节时,并不会仅仅因为超过这个空闲值就被它终止。读取请求头、传输正文、写响应和执行应用逻辑,都需要各自适用的控制项。名字中出现 idle,并不意味着它已经覆盖完整请求的所有阶段。
Nginx 也把不同边界分开:客户端侧的 keepalive_timeout 管理保留的持久连接;proxy_read_timeout 则针对从上游响应读取数据时,两次相邻读取之间的等待,而不是整个响应的总耗时。它们观察代理两侧的不同活动。数值写成一样,并不能让含义也相同,更不能据此断言整条链路已经具有相同的截止点。
零值也不能跨实现直接套用。Nginx 的 keepalive_timeout 设为零,会禁用客户端持久连接;Go 的 IdleTimeout 为零时,则使用 ReadTimeout 的值。如果后者也没有正的限制,才没有建立空闲期限。需要按具体实现核对“零”的契约,不能统一理解成关闭功能,或理解成立即关闭套接字。
坏掉的样子
阈值短于正常静默间隔,会迫使客户端重新建连,或者中断这个计时器实际覆盖的流量;阈值过于宽松,则可能保留超出服务承受范围的套接字。应记录连接数量、关闭原因、复用情况和实际工作模式。安静只能说明被观察的活动没有发生,并不能独立识别坏客户端,也不能告诉你最合适的秒数是多少。
HTTP keep-alive 指的是复用连接,不是定期发送的应用心跳。TCP keepalive 探针又是另一种机制,不会变成服务器读取到的应用请求字节。应用心跳也只有在某个端点确实把它算作活动时,才会影响该端点的计时器。客户端、代理与上游不一定观察到同一批事件,流式传输和多路复用协议尤其需要逐层核对,不能用一个“保活间隔”解释全部行为。
关闭还可能与客户端发起下一次请求发生竞争。连接池中的旧连接需要恢复路径,但重新建连不代表任意请求都可以安全重放。HTTP 明确允许异步关闭连接,是否重试仍取决于请求语义,以及此前副作用是否可能已经发生。“服务器稍微早关一点没有关系”,对修改数据的请求并不是可靠的普遍结论。
快速验证
将代码保存为 idle_probe.go,执行 go run idle_probe.go。程序只使用 Go 标准库,本次在 macOS 的 Go 1.27.0 上验证。测试服务器监听本机回环地址,发送的是普通 HTTP/1.1 请求;没有访问公网边缘节点,也没有启动 Nginx、使用证书或修改任何生产配置。
package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"runtime"
"strconv"
"sync/atomic"
"time"
)
type connectionKey struct{}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
const idle = 200 * time.Millisecond
const headers = 500 * time.Millisecond
const work = 350 * time.Millisecond
var serial, handled atomic.Int64
closed := make(chan struct{}, 8)
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled.Add(1)
if r.URL.Path == "/slow" {
time.Sleep(work)
}
w.Header().Set("X-Connection-ID", strconv.FormatInt(r.Context().Value(connectionKey{}).(int64), 10))
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintln(w, "ok")
}))
server.Config.IdleTimeout = idle
server.Config.ReadHeaderTimeout = headers
server.Config.ConnContext = func(ctx context.Context, _ net.Conn) context.Context {
return context.WithValue(ctx, connectionKey{}, serial.Add(1))
}
server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
if state == http.StateClosed {
closed <- struct{}{}
}
}
server.Start()
defer server.Close()
transport := &http.Transport{MaxIdleConns: 1, MaxIdleConnsPerHost: 1}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
get := func(path string) int64 {
response, err := client.Get(server.URL + path)
check(err == nil, fmt.Sprint(err))
body, readErr := io.ReadAll(response.Body)
closeErr := response.Body.Close()
check(readErr == nil && closeErr == nil, "response body did not finish cleanly")
check(response.Proto == "HTTP/1.1" && response.StatusCode == 200, "unexpected protocol or status")
check(string(body) == "ok\n", "unexpected response body")
id, err := strconv.ParseInt(response.Header.Get("X-Connection-ID"), 10, 64)
check(err == nil, "missing connection identity")
return id
}
first := get("/ping")
check(get("/ping") == first, "the fully consumed responses should allow reuse")
started := time.Now()
check(get("/slow") == first, "the active request should keep its connection")
check(time.Since(started) >= work, "the handler did not complete its work")
fmt.Println("two requests reuse one HTTP/1.1 connection; active work outlasts IdleTimeout")
select {
case <-closed:
case <-time.After(2 * time.Second):
panic("the idle connection was not closed")
}
check(get("/ping") != first, "the request after idle closure needs a new connection")
fmt.Println("server reports idle closure; a later GET succeeds on a new connection")
before := handled.Load()
connection, err := net.DialTimeout("tcp", server.Listener.Addr().String(), time.Second)
check(err == nil, fmt.Sprint(err))
defer connection.Close()
check(connection.SetDeadline(time.Now().Add(2*time.Second)) == nil, "could not bound raw probe")
_, err = io.WriteString(connection, "GET /ping HTTP/1.1\r\nHost: probe\r\n")
check(err == nil, fmt.Sprint(err))
reply, err := io.ReadAll(connection)
check(err == nil && len(reply) == 0, "partial headers should end with EOF in this server")
check(handled.Load() == before, "incomplete headers must not invoke the handler")
fmt.Println("incomplete request headers close without reaching the handler; ReadHeaderTimeout is separate")
fmt.Println(runtime.Version())
}
客户端完整读完响应正文并关闭 Body,从而允许复用连接。前两次请求报告同一个由服务器分配的连接编号。第三次请求让处理器执行三百五十毫秒,超过二百毫秒的空闲额度,却仍然在同一连接上成功返回。这证明正在处理请求的时间,与两次请求之间等待下一次请求的空闲区间,并不是同一个计时范围。
响应完成后,客户端不再发送请求。程序等待服务器报告连接已关闭,再发一次 GET,新响应的连接编号必须改变。这里观察的是实际关闭事件和新的连接身份,不是睡过一个假定时长,就直接宣布资源应该已经被回收。新的 GET 能成功,也不意味着其他具有副作用的业务请求可以不经判断就照样重试。
另开一条原始连接,发送不完整的请求头,然后等待服务器关闭。本次运行中读到 EOF,处理器调用计数没有增加;五百毫秒的 ReadHeaderTimeout 与持久连接空闲策略分开起作用。客户端自身也设置等待期限,避免测试失败时无界挂起;延迟清理负责关闭本次创建的传输层连接和测试服务器,不依赖进程退出碰巧代为收尾。
这些很短的时间值只是让实验能快速看见状态变化,不是通用生产配置。验证范围是 Go 的 HTTP/1.1 行为,没有覆盖 HTTP/2 流、WebSocket、内核保活探针时序或所有中间代理。为真实链路协调空闲策略时,应逐段确认什么算活动、谁负责关闭,以及关闭后该如何安全恢复,而不是把一个库的参数语义推广到所有节点。
来源
- Go net/http,空闲与请求头超时、连接状态及响应正文复用,已核验 2026-09-09。
- Go net/http/httptest,本机回环测试服务器及其生命周期,已核验 2026-09-09。
- Nginx HTTP 核心模块,客户端持久连接超时语义,已核验 2026-09-09。
- Nginx 代理模块,上游响应逐次读取超时,已核验 2026-09-09。
- RFC 9112:HTTP/1.1,持久连接与异步关闭,已核验 2026-09-09。