Skip to content

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按方法、主机和路径选择 HandlerHandle、HandleFunc、r.PathValue
http.Client处理重定向、Cookie、整次请求超时,调用 TransportDo、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: 条件允许时保留连接供后续使用
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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.BodyServer 会关闭;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空闲连接保留多久活跃请求最长耗时
TLSHandshakeTimeoutTLS 握手阶段上限响应体读取时间
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 不能照搬短请求设置
IdleTimeoutkeep-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[按业务策略强关或记录未完成任务]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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 request

WebSocket 等被劫持的连接不在 Shutdown 的自动关闭和等待范围里,需要自己登记、通知并等待。RegisterOnShutdown 可以触发协议特定的关闭过程,但文档明确它的回调不应在里面等待,Server 也不会替应用完成全部连接收尾。优雅退出的最后责任仍在应用。

资料与验证边界 ​

实际运行 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.2

http_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) {})
}

保留各书来源与许可 · 示例运行条件以正文为准