net/http — HTTP 服务器与客户端
net/http 是 Go 标准库里最"生产就绪"的包:一个 ServeMux + 一个 Server + 一个 Transport 就能扛住线上流量。这一篇的重点不是 API 罗列,而是超时到底管到哪、连接池为什么不够用、Go 1.22 之后标准库路由能做什么、以及 Go 1.24~1.27 引入的协议选择(HTTP/3、h2c、优先级、body drain)带来的行为变化。
1. HTTP 服务器
1.1 最小服务器(以及为什么生产别这么写)
package main
import "net/http"
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, World!"))
})
http.ListenAndServe(":8080", nil) // nil → DefaultServeMux
}这段能跑,但生产环境有三个问题:ListenAndServe 的返回错误被丢弃(TLS 配错会"看起来在跑"其实秒退);DefaultServeMux 是全局可变状态,任何被 import 的包都能往里塞路由(net/http/pprof 就是这么被误暴露成公网接口的);没有读写超时,天然容易被 slowloris 打。
1.2 生产级配置
srv := &http.Server{
Addr: ":8080",
Handler: router,
ReadHeaderTimeout: 10 * time.Second, // 只读请求头:防 slowloris 的关键一项
ReadTimeout: 15 * time.Second, // 读整个请求(头 + 体)
WriteTimeout: 30 * time.Second, // 写响应(从读完请求体之后开始计时)
IdleTimeout: 120 * time.Second, // keep-alive 空闲连接的超时
MaxHeaderBytes: 1 << 20, // 请求头总大小上限,默认 1MB
MaxHeaderValueCount: 100, // Go 1.27:单个头名字最多多少个值(防 header flood)
BaseContext: func(net.Listener) context.Context {
return ctx // 所有请求 ctx 的父 ctx:关停时一起取消
},
ConnContext: func(c context.Context, cn net.Conn) context.Context {
return context.WithValue(c, connIDKey{}, cn.RemoteAddr()) // 每连接附加信息
},
}超时的真实语义(这段最容易理解错):
ReadTimeout:从连接建立后开始读请求计时,覆盖读头 + 读 body。设太短会让"大文件上传"失败——上传接口要么调大它,要么在读 body 前用ResponseController.SetReadDeadline逐次续期。WriteTimeout:从读完请求体之后开始计时,覆盖整个响应写出。对 SSE / 流式下载 / 长轮询它是致命的(30s 一到连接就被切)。常规做法是给这类 handler 单独设 0:srv.WriteTimeout保持全局值,流式接口用http.NewResponseController(w).SetWriteDeadline(time.Time{})取消限制。ReadHeaderTimeout:只用它也能防 slowloris;不读满 body 的 handler(比如只回 401)不会因ReadTimeout而受害。- Go 1.27 的
MaxHeaderValueCount:限制同一个 header key 出现的次数(MaxHeaderBytes管总字节数,管不住"几万个极小的同名头"这种攻击形态)。零值用DefaultMaxHeaderValueCount。
1.3 优雅关闭
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil { // 关闭监听、等活跃请求结束
log.Printf("shutdown: %v", err)
srv.Close() // 兜底:暴力关掉所有连接
}
}()
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err) // ErrServerClosed 是 Shutdown/Close 的正常返回值
}注意:
Shutdown不会取消正在执行的 handler——它只停止 accept 新连接、关闭空闲连接,然后等活跃请求自己结束。如果 handler 卡在数据库上,Shutdown会一直等到 ctx 超时,然后你被迫Close()。所以业务 handler 必须尊重r.Context():客户端断开或进程关停时它会取消。(os/signal+NotifyContext的完整写法见 os-exec.md。)
1.4 路由:Go 1.22 起标准库够用了
ServeMux 的模式语法扩展了方法和通配段:
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz) // 限定方法
mux.HandleFunc("POST /items", createItem) // 有方法的模式优先于同名无方法的
mux.HandleFunc("GET /items/{id}", itemByID) // 单段通配
mux.HandleFunc("GET /files/{path...}", serveFile) // 多段通配,必须在末尾
mux.HandleFunc("GET /api/v1/{$}", exactNoTrailingSlash) // {$} 精确匹配,不做斜杠重定向
mux.HandleFunc("/", fallback) // 前缀匹配(以 / 结尾)
// 取通配值
func itemByID(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
...
}几个规则值得记住:
- 带方法的模式优先级高于不带方法的同路径模式;
GET模式会同时注册HEAD(官方行为)。 - 两个模式匹配范围重叠时,更具体的胜出;无法判定谁更具体时,注册阶段直接 panic——所以路由错误在启动就暴露,不会拖到运行时。
- 以
/结尾的模式做前缀匹配并会重定向(Go 1.26 起这个"补斜杠重定向"用 307 Temporary Redirect,此前是 301;用 301 时浏览器会永久缓存跳转,换方法/临时调整就踩坑,这次改动正是为了纠偏)。 http.Request.Pattern(Go 1.23+)给出命中的模式,打点/日志按路由聚合指标就靠它,比r.URL.Path好——后者会让每个 ID 都变成一个独立的指标基数。
需要中间件、路由分组、开放 API 文档时才考虑 chi/gin 这类第三方库;“必须用第三方路由"这个流传很广的建议已经不成立。回退开关:GODEBUG=httpmuxgo121=1 恢复 1.21 的 ServeMux 行为。
1.5 请求体防护
func upload(w http.ResponseWriter, r *http.Request) {
// 1) 硬上限:超了 r.Body 会返回错误,且会自动把响应置为 413 关掉连接
r.Body = http.MaxBytesReader(w, r.Body, 10<<20) // 10MB
// 2) multipart:内存上限 + 磁盘临时文件
if err := r.ParseMultipartForm(32 << 10); err != nil {
http.Error(w, "too large or malformed form", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "missing file", http.StatusBadRequest)
return
}
defer file.Close()
...
}http.MaxBytesReader(w, r.Body, n)是唯一可靠的大小限制:r.ContentLength不可信(客户端可以谎报,或者用 chunked 编码时为 -1)。ParseMultipartForm(maxMemory)之前的r.ParseForm()会把 body 全读进内存——表单接口不设上限等于开了一个免费 DoS 开关。MaxBytesHandler(h, n)是同一个东西的中间件形态,可以在路由层统一包。
1.6 中间件
type Middleware func(http.Handler) http.Handler
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- { // 倒序包装:第一个中间件在最外层
h = mws[i](h)
}
return h
}
handler := Chain(actualHandler, Recovery, Logging, Timeout(5*time.Second), RequireAuth)ResponseWriter 的包装要注意一个真实的坑:内嵌结构体只实现了 Write/WriteHeader,会把 Flusher/Hijacker/Pusher 接口弄丢,导致 SSE 刷新失效、WebSocket 升级失败。
type statusWriter struct {
http.ResponseWriter
code int
sent bool
}
func (w *statusWriter) WriteHeader(code int) {
if !w.sent { // 只记第一次:二次 WriteHeader 本来就只会产生一条超日志
w.code, w.sent = code, true
}
w.ResponseWriter.WriteHeader(code)
}需要 Flush/Hijack/改 deadline 时,优先用 http.NewResponseController(w)(Go 1.20+)而不是自己转发四个接口:
rc := http.NewResponseController(w)
rc.Flush() // SSE
rc.SetWriteDeadline(time.Now().Add(time.Minute)) // 给单个请求放宽
rc.Hijack() // 协议升级
rc.EnableFullDuplex() // 边读边写(WebSocket/双向流)它会自动穿透包装层去找底层实现,并且不会误把 nil 响应当成成功(文档明确:返回错误表示该 ResponseWriter 不支持对应能力)。
Timeout 中间件里有一个必须知道的边界:context.WithTimeout(r.Context(), ...) 不会中断正在进行的 handler,它只是让下游愿意检查 ctx 时提前返回——数据库调用会取消,纯 CPU 循环不会。真要硬切请求,用 http.NewResponseController(w).SetWriteDeadline 或在 handler 里自己 select。
2. HTTP 客户端
2.1 Transport 连接池
http.Client 是并发安全的,整个进程共用一个是正确的默认;每次请求 &http.Client{} 才是事故来源(每个 Client 有自己的 Transport,连接不复用、fd 暴涨)。
tr := &http.Transport{
MaxIdleConns: 200, // 全局空闲连接上限(DefaultTransport 是 100)
MaxIdleConnsPerHost: 50, // 每 host 空闲上限;不设则用 DefaultMaxIdleConnsPerHost = 2
MaxConnsPerHost: 0, // 每 host 总连接上限(0 = 不限);超限的请求会排队等待
IdleConnTimeout: 90 * time.Second,
DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second, // 只等响应头,不含读 body
ExpectContinueTimeout: time.Second, // 只在请求带 Expect: 100-continue 时生效
DisableCompression: false,
ForceAttemptHTTP2: true,
}
// 需要精确控制协议时(Go 1.24+):Protocols 是 nil 时默认只有 HTTP/1,
// 靠 ForceAttemptHTTP2 / TLSNextProto 参与决定要不要 HTTP/2;
// 显式赋值就是把可选协议集合钉死。
p := &http.Protocols{}
p.SetHTTP1(true)
p.SetUnencryptedHTTP2(true) // 明文 HTTP/2(prior knowledge)
tr.Protocols = p
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}DefaultMaxIdleConnsPerHost 在 transport.go 里就是 2——并发打一个下游 50 QPS 时,连接池只能留住 2 个空闲连接,其余每次都要重新三次握手 + TLS。这是"Go 客户端明明复用了 Client 但还是慢"的头号原因,改一个数字就能解决。
各超时字段的分工要拎清:Client.Timeout 覆盖整个请求(含重定向、含读 body);ResponseHeaderTimeout 只管拿到响应头;DialContext 的 Timeout 只管建连;TLSHandshakeTimeout 只管握手。Client.Timeout 一到,body 读到一半也会报错,所以流式/大文件下载不要用 Client.Timeout,改用 ctx + ResponseHeaderTimeout。
2.2 协议选择:HTTP/2、HTTP/3、h2c
- Go 1.13 起:https 默认走 HTTP/2(通过 ALPN),无需配置。准确说,
Transport的默认(DefaultTransport)之所以能协商 h2,是因为它设了ForceAttemptHTTP2: true;Protocols为 nil 时文档写的是"默认通常只有 HTTP/1,若ForceAttemptHTTP2为 true 则 HTTP/1 + HTTP/2”。 - Go 1.24 起:
Server.Protocols/Transport.Protocols成为显式控制协议集合的手段,并带来 明文 HTTP/2(h2c):Server.Protocols含UnencryptedHTTP2时,同一个端口可以同时接受 HTTP/1 与明文 HTTP/2。老的TLSNextProto用途被官方文档明确取代——原文是 “Historically, TLSNextProto was used to disable HTTP/2 support. The … Protocols field now provides a simpler way to do this.” - 上面
Transport里的ForceAttemptHTTP2不是 h2c 开关:它只表示"即使我自己配了TLSClientConfig/DialContext,也照样尝试通过 ALPN 协商 HTTP/2"(文档:默认情况下用了这些字段会保守地禁用 HTTP/2)。想要明文 HTTP/2 客户端,得用Protocols里的UnencryptedHTTP2(Go 1.24+)——旧代码注释里把它当 h2c 用是误解。另外UnencryptedHTTP2用的是 “HTTP/2 with Prior Knowledge”(RFC 9113 §3.3),不支持已废弃的Upgrade: h2c头。 - HTTP/3 不在标准库里:
net/http的Protocols只有HTTP1/HTTP2/UnencryptedHTTP2三个成员,Go 1.24~1.27 的 release notes 也从没把 HTTP/3 列入net/http。要用 h3 得引golang.org/x/net/http3(官方实验实现)或quic-go;标准库这层提供的是底下的 QUIC 传输原语(crypto/tls的QUICConn,见 crypto-tls.md)。 - Go 1.27:HTTP/2 服务端开始按 RFC 9218 尊重客户端优先级(对 CSS/JS/首屏资源更有利)。要回到旧的 round-robin 行为,设
Server.DisableClientPriority = true。 - Go 1.26:
Transport.HTTP2 = &http.HTTP2Config{StrictMaxConcurrentRequests: true}控制"HTTP/2 连接达到 MAX_CONCURRENT_STREAMS 上限后是排队还是新建连接";Transport.NewClientConn可以拿到一条裸连接自己做连接管理(绝大多数人不该用它)。
2.3 必须带 ctx,必须 Close body
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
// err 通常是 *url.Error,里面包着真正的错误
if errors.Is(err, context.DeadlineExceeded) { ... } // ctx 超时
var ue *url.Error
if errors.As(err, &ue) && ue.Timeout() { ... } // Client.Timeout
return err
}
defer resp.Body.Close() // 不 Close = 连接不归还池,长期泄漏 fd
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) // 读也有上限
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status %s: %s", resp.Status, truncate(body, 256))
}Go 1.27 行为变化:
Response.Body在Close()时现在会自动排干(drain)未读内容(到保守上限为止),以便连接复用。对绝大多数程序是"无感或变快";但如果你的代码原本靠"不读完 + 立刻 Close"来快速放弃一个响应体很大的连接,现在会多花一点读取时间。文档还点出一个反直觉的副作用:把Transport.MaxIdleConns设成 0(无限)、或者每个请求新建 Client(等于绕过上限)的程序,因为空闲连接堆积,可能反而变慢——这类用法本来就是错的,要么设DisableKeepAlives = true,要么把上限恢复成正常值。
resp.Body 不 Close 的后果比"浪费一个连接"更具体:连接不回收 → 新请求继续 dial → 对端 TIME_WAIT/本地临时端口耗尽 → 表现为偶发 connect: cannot assign requested address。这条排查路径值得贴在工位上。
2.4 重试:先想清楚能不能重
func doWithRetry(ctx context.Context, c *http.Client, newReq func() (*http.Request, error), attempts int) (*http.Response, error) {
var lastErr error
for i := range attempts {
if i > 0 {
backoff := time.Duration(1<<min(i, 5)) * 100 * time.Millisecond
select {
case <-time.After(backoff):
case <-ctx.Done():
return nil, ctx.Err()
}
}
req, err := newReq() // 每次重新构造请求(body 不可复用!)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
resp, err := c.Do(req)
if err != nil {
lastErr = err // 网络层错误:可能是幂等安全问题所在
continue
}
if resp.StatusCode < 500 && resp.StatusCode != http.StatusTooManyRequests {
return resp, nil // 非重试类状态码直接返回,调用方负责 Close
}
resp.Body.Close() // 只有确定要重试时才丢弃响应
lastErr = fmt.Errorf("status %s", resp.Status)
}
return nil, fmt.Errorf("after %d attempts: %w", attempts, lastErr)
}要点:
- 有 body 的请求必须每次重建
*http.Request。Request.Body是一次性io.ReadCloser,重放会拿到已耗尽的 body(GetBody字段能缓存,但只在NewRequest传*bytes.Reader/bytes.Buffer/strings.Reader时自动填)。 - 只对幂等方法重试(GET/HEAD/DELETE/PUT + 带幂等键的 POST)。网络错误尤其危险:请求可能已经到达服务端并执行完,只是响应没回来——这类重试要靠服务端幂等兜底(见 uuid.md 的幂等键做法)。
- 尊重
Retry-After(429/503 里常见),以及加抖动(jitter)避免重试风暴同步。
2.5 断路器
标准库没有断路器,实现它没问题,但要认清手写版本的常见缺陷:状态字符串比较、半开状态没有并发上限(放开一个请求还是放开了全部)、failures 从不按时间窗衰减、以及把响应体 Close 的责任模糊掉。
type breakerState int
const (
stateClosed breakerState = iota
stateHalfOpen
stateOpen
)
type breaker struct {
mu sync.Mutex
state breakerState
fails int
thresh int // 连续失败多少次后打开
openUntil time.Time // open 状态的到期时间
}
func (b *breaker) allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
switch b.state {
case stateOpen:
if time.Now().After(b.openUntil) {
b.state = stateHalfOpen // 进入半开:只放探测流量
return true
}
return false
default:
return true
}
}
func (b *breaker) onResult(err error, status int) {
b.mu.Lock()
defer b.mu.Unlock()
if err != nil || status >= 500 {
b.fails++
if b.fails >= b.thresh {
b.state, b.openUntil = stateOpen, time.Now().Add(10*time.Second)
}
return
}
if b.state == stateHalfOpen {
b.state, b.fails = stateClosed, 0 // 探测成功,立即恢复
}
}这段代码的正确性依赖两个细节:状态迁移全在同一把锁里完成(半开只由"到期"触发,探测成功即回 closed),以及失败计数是阈值触发而非滑动累加。半开状态还有个常见漏洞:没有"同时只允许一个探测请求"的计数——上面的写法靠 state = stateHalfOpen 后仍返回 true,所以半开期间所有请求都会放行,直到有一次失败重新打开。要严格限流,得再加一个 halfOpenInflight 计数。
真要上生产,sony/gobreaker 这类库经过了实战校验,比自己维护省心;这段代码的价值主要是让你看懂自己那份手搓实现漏了什么。
3. 测试
func TestHandler(t *testing.T) {
// 不打真网络:httptest.NewRequest + ResponseRecorder
req := httptest.NewRequest(http.MethodGet, "/items/42", nil)
// Go 1.23+:httptest.NewRequestWithContext 带上 ctx
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body)
}
}
func TestIntegration(t *testing.T) {
srv := httptest.NewServer(router) // 真实监听 127.0.0.1:随机端口
defer srv.Close()
resp, err := srv.Client().Get(srv.URL + "/healthz")
...
}
func TestWithSynctest(t *testing.T) {
// Go 1.27:内存假网络,可与 testing/synctest 一起用,完全确定性
srv := httptest.NewTestServer(t, handler) // t 作为参数,自动清理
resp, err := srv.Client().Get("http://example.com/") // example.com 会被重定向到这个假服务器
...
}httptest.NewRecorder()只适合"能返回的 handler",它会缓冲输出,看不到流式效果;测超时行为要用真实 server。httptest.NewTLSServer返回的srv.Client()已经把自签证书信任好了——别自己写InsecureSkipVerify: true去连它(这是测试代码里最常见的安全隐患,也是govet/安全扫描会报的点)。- Go 1.26 起
srv.Client()会把example.com及其子域名的请求自动重定向到被测服务器,写"打到外部域名"的用例更省事。 - Go 1.27 的
httptest.NewTestServer(t, handler)用进程内假网络(in-memory network),配合testing/synctest可以得到确定性的时钟与调度——超时、重试这类逻辑第一次能被稳定单测覆盖(详见 testing.md)。handler传 nil 时所有请求返回 500,且不会碰DefaultServeMux(这两点是文档明确写的默认行为)。
4. 常见陷阱
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
忽略 ListenAndServe 的 error |
服务其实没起来,进程空转 | 判 http.ErrServerClosed,其余 log.Fatal |
每个请求 http.Get(默认 Client) |
全局 DefaultClient 被所有依赖共享,无法定制超时/池 |
显式持有配置好的 *http.Client |
DefaultServeMux + 导入 pprof |
调试端点暴露到公网 | 自建 mux;pprof 单独监听 127.0.0.1 |
不 Close resp.Body |
连接不归还 → fd/端口耗尽 | defer resp.Body.Close()(读完也 Close) |
只判 resp.StatusCode == 200 后才 Close |
非 200 分支泄漏 body | 拿到 resp 先 defer Close(err == nil 时) |
WriteTimeout 全局设死 |
SSE/流式/大文件被腰斩 | ResponseController.SetWriteDeadline 单请求调整 |
误读 ForceAttemptHTTP2 为 h2c |
明文 HTTP/2 连不上 | 用 Protocols + UnencryptedHTTP2(Go 1.24+) |
依赖 r.ContentLength 做大小限制 |
客户端可谎报或分块绕过 | http.MaxBytesReader |
在 goroutine 里继续用 ResponseWriter |
handler 返回后写的是已失效对象 | 要异步就先把结果写进通道/结构体;确需异步写用 EnableFullDuplex 并自己保证生命周期 |
| handler 里 panic | 该连接被断开(Go 会 recover 但请求失败) | Recover 中间件 + 记 debug.Stack() |
http.Error 之后再 WriteHeader |
多余状态行/超日志 | 一次写定;statusWriter 只记首次 |
用 r.URL.Path 做指标维度 |
高基数标签打爆 Prometheus | 用 r.Pattern(1.23+)或预定义路由名 |
测试里 InsecureSkipVerify 连 NewTLSServer |
掩盖真实证书问题 | 用 srv.Client() |
Shutdown 后进程立刻退出 |
在途请求被切 | Shutdown(ctx) 返回后再退出,handler 尊重 r.Context() |
5. 实战建议
- 服务端的默认三件套:
ReadHeaderTimeout+MaxHeaderBytes+MaxBytesReader。这三个字段管住的是"畸形/超大请求",比任何业务防护都基础。 - 客户端配置先看并发再抄数字:
MaxIdleConnsPerHost至少等于"对该 host 的峰值并发";不确定就先设 50,然后看/debug/pprof里getConn是否阻塞、或看指标里的连接建立耗时。 - 一个进程一个
http.Client(或每个下游服务一个),Transport 按需定制;Client.Timeout和 ctx 二选一即可,别叠加导致"看不出是谁超时"。错误判定时先看errors.Is(err, context.DeadlineExceeded),再errors.As(&url.Error)看Timeout()。 - 别默认上第三方路由。Go 1.22+ 的
ServeMux覆盖方法匹配、路径参数、{$}、优先级 panic,够用且少一个依赖;要中间件生态(限流、鉴权、可观测)再上 chi/gin,见 gin/ 手册。 - h2c 用标准库(1.24+ 的
Protocols),HTTP/3 老老实实引外部库。内部服务网格常用 h2c 省掉 TLS 开销(注意它绕过 ALPN 协商,前端网关必须明确支持);对外提供 h3 目前只能靠x/net/http3或quic-go,别期待net/http自带。 - 观测至少这三项:
Server层用r.Pattern聚合的状态码/延迟直方图、httptrace.ClientTrace拆出 DNS/连接/TLS/首字节的耗时、runtime/metrics或expvar里的连接数。排"偶发 5 秒延迟"时,ClientTrace的分段耗时是唯一能定位到"卡在握手还是排队"的证据。 - 相关手册:
net.md(socket/netip/DNS)、net-url.md(URL 解析与 SSRF)、crypto-tls.md(证书/TLS/PQC/QUIC)、gin/(框架层)、prometheus/04-客户端埋点.md(指标实现)。