Skip to content
Go
net/http — HTTP 服务器与客户端

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(指标实现)。