主题
net/http:把一个请求从接收到收尾讲清楚
写一个能返回 200 的 Go HTTP 服务只需要几行。接下来真正会影响服务稳定性的,是另外几个问题:处理函数什么时候结束?响应写出去以后还能改吗?请求已经超时,为什么业务还在跑?每次调用都建一个 Client,会不会不断新建连接?进程准备退出时,正在上传的文件怎么办?
这篇沿着一个请求的生命周期回答这些问题。示例使用 Go 1.27.2、Linux/amd64,全部可运行代码放在文末。路由实验、HTTP/1.1 连接复用、响应体读取超时和优雅关闭都在本次环境实际执行;它们不代表真实代理、HTTP/2 和高并发部署的全部行为。
先认清谁负责哪一段
| 对象 | 负责什么 | 常用入口 |
|---|---|---|
http.Server | 接受连接、解析请求、调用 Handler、管理连接生命周期 | Serve、ListenAndServe、Shutdown |
http.Handler | 读取请求,写响应 | ServeHTTP(w, r) |
http.HandlerFunc | 让普通函数满足 Handler 接口 | http.HandlerFunc(fn) |
http.ServeMux | 按方法、主机和路径选择 Handler | Handle、HandleFunc、r.PathValue |
http.Client | 处理重定向、Cookie、整次请求超时,调用 Transport | Do、Get |
http.Transport | 建连接、TLS、发送请求、管理可复用连接 | Clone、连接池参数、阶段超时 |
客户端和服务端是两套独立的对象。调整 Client.Timeout 不会替接收请求的 Server 设置防护;调整 Server.ReadTimeout 也不会限制它向数据库或其他服务发出的调用。各对象的边界见官方 Client 与 Server 文档。
正在绘制图示…
查看图源代码
sequenceDiagram
participant C as HTTP Client
participant T as Transport / 连接池
participant S as Server
participant M as 中间件与 ServeMux
participant H as 业务 Handler
C->>T: Do(request)
T->>S: 复用或新建连接,发送请求
S->>M: ServeHTTP(w, r)
M->>H: 检查请求后继续调用
H->>H: 读 Body、校验、调用下游
H-->>S: 写响应头、写 Body、返回
S-->>C: 响应数据
C->>C: 读取并关闭 Response.Body
T->>T: 条件允许时保留连接供后续使用
图里最后一段经常被漏掉:Do 返回不等于响应已经读完。返回的 Response.Body 仍是一条流,读取它可能继续等待网络,也可能失败。
Handler 的边界就是一次请求的边界
接口只有一个方法:
go
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}普通函数的签名恰好匹配时,用 http.HandlerFunc(fn) 包起来即可。它不是另起一个工作线程,只是一个适配器,调用 ServeHTTP 时会执行原函数。服务会并发处理请求,所以 Handler 引用的共享 map、计数器或缓存需要自己同步;每次调用的局部变量独立,但其中的指针、slice、map 仍可能引用共享对象,不能只看变量声明在函数内就认定没有竞态。
ServeHTTP 返回意味着本次处理结束。返回后不要继续写 ResponseWriter,也不要让后台 goroutine 再读 Request.Body。如果要把工作交给后台,先复制需要的数据,把任务存进明确的队列,再返回任务 ID。直接把 w 留给 goroutine 会越过接口规定的生命周期。Handler 文档
服务端请求的 Context 会在连接关闭、HTTP/2 请求取消或 ServeHTTP 返回时取消。下游请求应从 r.Context() 派生,业务循环也要检查 Done()。这是一条合作式停止信号,无法强行终止一个忽略 Context 的函数。Request.Context
谁来关闭 Body
| 场景 | 关闭责任 | 还需要注意什么 |
|---|---|---|
服务端收到 r.Body | Server 会关闭;Handler 也可在适当位置提前关闭 | 先限制大小,通常先读完必要内容再写响应 |
客户端拿到 resp.Body | 调用方必须关闭 | 要复用 HTTP/1.x 连接,通常还需读到 EOF |
| 客户端发出的请求体 | Transport 负责关闭,包括出错路径 | 不要在请求仍在发送时复用或修改底层数据 |
不要为了“保住连接”无上限地读一个不可信的大响应。若超出应用允许的体积,关闭 Body、放弃这次连接复用更合理。资源上限优先于省一次握手。Client.Do
ServeMux:方法和路径一起决定处理函数
Go 1.22 起,标准路由器能直接写方法和路径参数:
go
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, r.PathValue("id"))
})同一个路由用三种方法访问,本次实验得到:
text
GET status=200 allow=""
HEAD status=200 allow=""
POST status=405 allow="GET, HEAD"GET 模式也匹配 HEAD。真实 HTTP 服务不会把 HEAD 响应体发送给客户端;测试里的 ResponseRecorder 只是记录处理函数的行为,不能拿它记录的 Body 推断线上的 HEAD 报文。POST 路径虽然存在,方法却不匹配,所以返回 405,并给出 Allow。
| 模式 | 含义 | 容易误读的地方 |
|---|---|---|
GET /users/{id} | 单段参数 | /users/a/b 不匹配 |
/files/{rest...} | 匹配剩余路径 | 多段通配符必须在最后 |
/assets/ | 子树模式 | 通常还会为缺少尾斜线的请求重定向 |
/{$} | 只匹配根路径 | / 本身是兜底子树模式 |
example.com/ | 带 Host 条件 | 可与不带 Host 的模式共同使用 |
ServeMux 选取更具体的模式,不能简单理解为“先注册的先赢”。例如 /a/{x} 和 /{x}/b 都能匹配 /a/b,但互相都不包含对方,注册时会 panic。本次反例测试确实捕获了这个 panic。把冲突在启动时暴露出来,比运行中随机选一个更容易排查。
Go 1.22 还改变了路径通配、逐段反转义与冲突判断;迁移旧服务要跑路由回归测试。GODEBUG=httpmuxgo121=1 是兼容旧规则的开关,进程启动时读取,不宜把它当长期路由设计方案。ServeMux 的 Patterns、Precedence、Compatibility
中间件就是把 Handler 再包一层
假设组合是 log(auth(handler)),执行顺序并不是三个函数各自独立跑一遍:
text
log before
auth before
handler
auth after
log after进入时由外向内,返回时由内向外。鉴权失败时可以直接写 401 并返回,内层业务就不会执行。外层日志仍能在返回后记录这次失败,所以日志通常放在能够覆盖拒绝路径的位置。完整的组合实验见文末 Example_chain。
还有一条比顺序更重要:普通最终响应头只提交一次。 调用 WriteHeader(200) 会提交;第一次 Write 没有显式状态码时会隐式提交 200。提交后再改 Header 或再写 500,无法改写客户端已经看到的状态。1xx 临时响应和 Trailer 另有规则,不能套用这个简化结论。ResponseWriter
下面先写数据,再试图改状态与 Header:
go
w.Header().Set("X-Phase", "before")
io.WriteString(w, "first")
w.Header().Set("X-Phase", "after")
w.WriteHeader(500)测试读取已提交的响应,结果是:
text
status=200 header=before body=first因此,可能失败的序列化最好在提交前完成;流式接口则要事先约定流里的错误事件。不能发了一半 CSV 或 SSE 后,再接一段普通 JSON 错误,假装整次响应变成了失败。
Client 要复用,Transport 更要复用
Client 和 Transport 都可供多个 goroutine 并发使用。连接池实际在 Transport 内。每次创建新的 &http.Client{} 若 Transport 为 nil,仍共享 DefaultTransport;每次创建全新的 &http.Transport{},才会反复制造独立连接池。更容易管理的做法是:启动时创建一套 Client 与 Transport,配置完成后共享使用,不在请求期间并发改配置。Transport
go
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.MaxIdleConns = 100
tr.MaxIdleConnsPerHost = 10
tr.MaxConnsPerHost = 50
tr.ResponseHeaderTimeout = 2 * time.Second
client := &http.Client{Transport: tr, Timeout: 5 * time.Second}这些数值只是解释配置位置的示例,不是通用生产默认值。Clone 能保留默认拨号、TLS、代理和 HTTP/2 等设置,再改所需字段。直接写空 Transport 很容易同时改变与本次问题无关的默认行为。
| 参数 | 控制什么 | 不控制什么 |
|---|---|---|
MaxIdleConns | 全部主机的空闲连接数 | 正在使用的连接总量 |
MaxIdleConnsPerHost | 每个主机的空闲连接数 | 每个主机的并发请求数 |
MaxConnsPerHost | 每主机拨号中、活跃、空闲连接总数 | HTTP/2 单连接上的流数量不能据此一一推算 |
IdleConnTimeout | 空闲连接保留多久 | 活跃请求最长耗时 |
TLSHandshakeTimeout | TLS 握手阶段上限 | 响应体读取时间 |
ResponseHeaderTimeout | 请求(包括请求体)写完后,等待响应头多久 | 收到响应头后读取 Body 的时间 |
Client.Timeout | 建连、重定向、读取响应体在内的整次时间 | 服务端业务一定停止 |
连接限制过小会让请求排队,过大可能压垮对端或耗尽文件描述符。先看排队、握手、复用率和下游容量,再调整。httptrace.ClientTrace.GotConn 可以看到某次请求是否复用了连接。本次测试使用独立 Transport,串行请求两次,均读完并关闭 Body,断言第二次 HTTP/1.1 请求 Reused=true;这只验证可复用路径,不保证服务器、代理或网络永远保留连接。
Do 成功以后,也可能超时
实验服务先写出 first\n 并 Flush,然后一直等请求取消。客户端设置 Timeout: 200*time.Millisecond。Do 已经拿到响应头,能够返回;继续 io.ReadAll(resp.Body) 时才到期:
text
Do succeeded; body prefix="first\n"; deadline=true这里的 deadline=true 来自 errors.Is(err, context.DeadlineExceeded)。说明检查 Do 的错误还不够,读取 Body 的错误也必须处理。Client.Timeout 设成零则不提供整次时间上限;请求自己的 Context 仍然可以设置 deadline。长时间 SSE 等流式消费通常不适合一个短的全局整次超时,需要单独设计握手、读写停滞和取消策略。
Transport 在满足文档条件时会对部分网络失败自动重试,例如已成功使用过的连接上的幂等请求,并且请求体可重建。不要把这个机制理解为所有 POST 都可安全重复。应用级重试要考虑服务器可能已经完成副作用、只是响应丢失;需要稳定的业务幂等键和服务端去重,而不只是增加循环次数。Transport 的 retry 说明
服务端超时保护的是不同阶段
| Server 字段 | 主要保护范围 | 设置时的取舍 |
|---|---|---|
ReadHeaderTimeout | 读取请求头 | 防止慢速发头长期占连接;常作为基本配置 |
ReadTimeout | 读取整个请求,包括 Body | 上传大文件可能需要不同预算 |
WriteTimeout | 写响应的时限 | 长连接、SSE 不能照搬短请求设置 |
IdleTimeout | keep-alive 等下一个请求 | 控制空闲连接占用 |
MaxHeaderBytes | 请求头解析上限 | 不限制请求体大小 |
这些是 I/O 和连接阶段的约束,不能替代业务 deadline。WriteTimeout 不是“到时间自动返回一份 504”,也不能把执行中的 CPU 循环强制停掉。需要请求体上限时用 MaxBytesReader;需要下游取消时传播 Context;需要超时后的特定响应,还要设计响应是否已经提交。三件事要分别处理。Server 字段说明
Shutdown:先不接新请求,再等旧请求结束
Shutdown(ctx) 会关闭监听器、关闭空闲连接,等待活跃连接变为空闲。它不主动打断正在处理的普通请求。等待期限到了,返回 ctx.Err(),也不等于剩余处理已被强行结束。Shutdown
正在绘制图示…
查看图源代码
flowchart LR
A[收到退出信号] --> B[停止接入新流量]
B --> C[调用 Shutdown]
C --> D[Serve 返回 ErrServerClosed]
C --> E{活跃请求是否结束}
E -- 是 --> F[Shutdown 返回 nil]
E -- 等待超时 --> G[Shutdown 返回 Context 错误]
F --> H[清理资源后退出进程]
G --> I[按业务策略强关或记录未完成任务]
Serve 很快返回 ErrServerClosed,不表示 Shutdown 也完成了。如果主 goroutine 此时直接退出,正在收尾的请求就被进程杀掉。应该让主流程等待 Shutdown 的结果,再清理数据库连接、日志等业务资源。
文末测试把一个请求停在 Handler 内,启动 Shutdown,观察 Serve 已返回,而 Shutdown 仍在等待;释放 Handler 后,两者才全部结束。输出:
text
Serve returned ErrServerClosed while Shutdown waited for the active requestWebSocket 等被劫持的连接不在 Shutdown 的自动关闭和等待范围里,需要自己登记、通知并等待。RegisterOnShutdown 可以触发协议特定的关闭过程,但文档明确它的回调不应在里面等待,Server 也不会替应用完成全部连接收尾。优雅退出的最后责任仍在应用。
资料与验证边界
- Go 1.27.2 net/http 文档:文中每段链接对应具体 API。
- 固定版本源码:server.go、client.go、transport.go、routing_index.go。本次使用安装目录内同版本
go doc核对说明。 - Go 1.22 路由增强说明:方法、通配符、冲突规则的设计背景。
- RFC 9110 §9.2:安全性与幂等性,支持重试边界的讨论。
实际运行 go test -v -count=1 ./...、go test -race -count=1 ./...、go vet ./... 全部通过。冲突路由和重复提交响应是被测试捕获的反例,没有让测试进程异常退出。未运行公网压测、TLS 失败注入、真实反向代理或 HTTP/2 复用实验。
完整复现代码
把以下文件放在同一目录,使用 Go 1.27.2 执行:
sh
go test -v -count=1 ./...
go test -race -count=1 ./...
go vet ./...go.mod
go.mod
module example.com/webapi/01_http
go 1.27.2http_test.go
代码块 · 130 行 · 点击收起 / 展开
go
package httpdemo
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httptrace"
"testing"
"time"
)
func router() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(w, r.PathValue("id"))
})
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Demo", "middleware")
mux.ServeHTTP(w, r)
})
}
func TestRoutesAndReuse(t *testing.T) {
s := httptest.NewServer(router())
defer s.Close()
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.Proxy = nil
tr.MaxIdleConnsPerHost = 2
tr.MaxConnsPerHost = 4
tr.ResponseHeaderTimeout = time.Second
defer tr.CloseIdleConnections()
client := &http.Client{Transport: tr, Timeout: 2 * time.Second}
for i := 0; i < 2; i++ {
reused := make(chan bool, 1)
trace := &httptrace.ClientTrace{GotConn: func(c httptrace.GotConnInfo) { reused <- c.Reused }}
ctx := httptrace.WithClientTrace(context.Background(), trace)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.URL+"/users/42", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(resp.Body)
closeErr := resp.Body.Close()
if err != nil || closeErr != nil {
t.Fatalf("read=%v close=%v", err, closeErr)
}
if string(body) != "42" || resp.Header.Get("X-Demo") != "middleware" {
t.Fatalf("body=%q", body)
}
if got := <-reused; i == 1 && !got {
t.Fatal("second sequential HTTP/1.1 request did not reuse connection")
}
}
resp, err := client.Post(s.URL+"/users/42", "text/plain", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 405 || resp.Header.Get("Allow") != "GET, HEAD" {
t.Fatalf("status=%d allow=%q", resp.StatusCode, resp.Header.Get("Allow"))
}
t.Log("path parameter, middleware, 405/Allow, and sequential connection reuse verified")
}
func TestShutdownWaits(t *testing.T) {
entered, release := make(chan struct{}), make(chan struct{})
srv := &http.Server{ReadHeaderTimeout: time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(entered)
select {
case <-release:
_, _ = io.WriteString(w, "finished")
case <-r.Context().Done():
}
})}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer srv.Close()
serveDone := make(chan error, 1)
go func() { serveDone <- srv.Serve(ln) }()
client := &http.Client{Timeout: 3 * time.Second}
defer client.CloseIdleConnections()
requestDone := make(chan error, 1)
go func() {
resp, e := client.Get("http://" + ln.Addr().String())
if e == nil {
_, e = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
requestDone <- e
}()
select {
case <-entered:
case <-time.After(2 * time.Second):
t.Fatal("handler did not enter")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
shutdownDone := make(chan error, 1)
go func() { shutdownDone <- srv.Shutdown(ctx) }()
select {
case e := <-serveDone:
if !errors.Is(e, http.ErrServerClosed) {
t.Fatal(e)
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
select {
case e := <-shutdownDone:
t.Fatalf("Shutdown returned before active handler finished: %v", e)
default:
}
close(release)
if e := <-requestDone; e != nil {
t.Fatal(e)
}
if e := <-shutdownDone; e != nil {
t.Fatal(e)
}
t.Log("Serve returned ErrServerClosed while Shutdown waited for the active request")
}teaching_test.go
代码块 · 103 行 · 点击收起 / 展开
go
package httpdemo
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func Example_routes() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, r.PathValue("id"))
})
for _, method := range []string{"GET", "HEAD", "POST"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(method, "/users/42", nil))
fmt.Printf("%s status=%d allow=%q\n", method, w.Code, w.Header().Get("Allow"))
}
// Output:
// GET status=200 allow=""
// HEAD status=200 allow=""
// POST status=405 allow="GET, HEAD"
}
func Example_chain() {
wrap := func(name string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println(name, "before")
next.ServeHTTP(w, r)
fmt.Println(name, "after")
})
}
h := wrap("log", wrap("auth", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Println("handler") })))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
// Output:
// log before
// auth before
// handler
// auth after
// log after
}
func TestCommittedResponse(t *testing.T) {
w := httptest.NewRecorder()
w.Header().Set("X-Phase", "before")
_, _ = io.WriteString(w, "first")
w.Header().Set("X-Phase", "after")
w.WriteHeader(500)
result := w.Result()
defer result.Body.Close()
if result.StatusCode != 200 || result.Header.Get("X-Phase") != "before" {
t.Fatal(result)
}
t.Logf("status=%d header=%s body=%s", result.StatusCode, result.Header.Get("X-Phase"), w.Body.String())
}
func TestClientTimeoutIncludesBody(t *testing.T) {
done := make(chan struct{})
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer close(done)
_, _ = io.WriteString(w, "first\n")
if err := http.NewResponseController(w).Flush(); err != nil {
return
}
<-r.Context().Done()
}))
defer s.Close()
c := &http.Client{Timeout: 200 * time.Millisecond}
defer c.CloseIdleConnections()
resp, err := c.Get(s.URL)
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.HasPrefix(string(body), "first\n") || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("body=%q err=%v", body, err)
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("server did not observe cancellation")
}
t.Logf("Do succeeded; body prefix=%q; deadline=%v", body, errors.Is(err, context.DeadlineExceeded))
}
func TestConflictingPatterns(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("conflicting routes must panic")
}
t.Log("conflict rejected at registration")
}()
mux := http.NewServeMux()
mux.HandleFunc("/a/{x}", func(http.ResponseWriter, *http.Request) {})
mux.HandleFunc("/{x}/b", func(http.ResponseWriter, *http.Request) {})
}