登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go HTTP/2 流并发限制导致请求排队的调参思路

来源:17golang原创

时间:2026-09-28 23:02:27 188浏览 收藏

Go 客户端出现“并发一上来,部分 HTTP/2 请求就开始排队”的现象时,先不要直接把 MaxConcurrentStreams 调大。更可靠的处理顺序是:确认实际协议与等待位置,再决定客户端是等待单连接的流槽位还是扩展连接,最后用业务并发上限保护数据库、RPC 和线程池等下游资源。流上限只是每条 HTTP/2 连接的多路复用边界,并不等于整个服务的安全并发量。

Go net/http 文档:https://pkg.go.dev/net/http

HTTP/2 规范:https://www.rfc-editor.org/rfc/rfc9113.html

先确认请求是在流配额上排队

一个常见现场是:客户端同时发出数百个请求,服务端 CPU 和连接数看起来都不高,但高分位延迟突然拉长。此时至少有四种等待点需要区分:

  • 流槽位等待:单条 HTTP/2 连接已有足够多的 open 或 half-closed 流,新的请求等待对端允许的并发流名额。
  • 连接策略等待:客户端启用了严格并发策略,连接达到流上限后不再新建连接。
  • 业务信号量等待:应用自己在 HTTP 调用之前限制了 goroutine 或任务并发。
  • 流控阻塞:请求已占用流,但连接级或流级窗口影响 DATA 帧传输;这与“没有流槽位”不是一回事。

先在响应上记录 resp.Proto,确认请求确实走的是 HTTP/2;然后把总耗时拆成“等待业务许可、发出请求、收到响应头、读取响应体”几段。连接数稳定、活动请求贴近已知流上限、等待时间随并发增加而上升,才更像流配额排队。若响应体读取慢或上传大对象,优先检查流控和下游处理速度。

package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
)

func doRequest(ctx context.Context, client *http.Client, url string) error {
    // 为单次请求设置明确截止时间,避免排队无限延长。
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return err
    }

    started := time.Now()
    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("请求失败,耗时 %s: %w", time.Since(started), err)
    }
    defer resp.Body.Close() // 及时关闭响应体,让连接和流资源可复用。

    fmt.Printf("proto=%s status=%d total=%s\n", resp.Proto, resp.StatusCode, time.Since(started))
    return nil
}

这段代码只提供埋点位置,不代表真实压测结果。生产环境应把协议、目标主机、超时类型和阶段耗时写入指标,而不是只打印一条总耗时日志。

Go HTTP/2 调用协程、客户端、连接池、活动流和等待请求之间的静态关系
图1:Go HTTP/2 客户端从调用侧到对端流配额的静态关系图。等待请求只有在连接获得可用流槽位后才能继续;这是原创说明图,不是运行截图。

理解每连接流上限,避免把参数方向调反

SETTINGS_MAX_CONCURRENT_STREAMS 是有方向的:发送该设置的一端,用它限制对端可以创建的并发流数量。服务端公布的值限制客户端在这条连接上同时打开的请求流;它不是全局 QPS,也不是进程级 goroutine 上限。规范中 open 和两种 half-closed 状态都会计入并发流数量,reserved 状态不计入。

Go 的 http.HTTP2Config.MaxConcurrentStreams 只作用于服务端;零值会使用至少 100 的默认值。RFC 9113 建议不要把该设置配得低于 100,以免无谓限制并行度,但这不是要求所有服务都盲目增大。数据库连接池只有 40、处理器包含长轮询,或者每个请求内存占用较大时,服务端仍需要根据真实资源预算控制并发。

参数或限制作用范围调大后的主要代价
Server.HTTP2.MaxConcurrentStreams每个客户端连接更多 Handler、内存和下游请求同时活跃
Transport.HTTP2.StrictMaxConcurrentRequests客户端到同一服务器的连接管理开启后更容易排队,关闭后可能增加连接
业务信号量进程、租户或目标服务过小增加等待,过大可能压垮下游
请求超时单次调用过短产生误取消,过长放大排队堆积

选择客户端策略:等待槽位还是扩连接

Go 1.26 为标准库 net/http 增加了 HTTP2Config.StrictMaxConcurrentRequests。设为 true 时,现有 HTTP/2 连接达到流上限后,新请求等待已有请求完成;设为 false 时,如果所有现有连接都满了,传输层可以再打开连接。前者便于限制连接成本和形成背压,后者更偏向降低突发请求的排队延迟。

protocols := new(http.Protocols)
protocols.SetHTTP1(true) // 保留 HTTP/1.1 回退能力。
protocols.SetHTTP2(true) // 显式允许 HTTP/2。

transport := &http.Transport{
    Protocols: protocols,
    HTTP2: &http.HTTP2Config{
        // 严格遵守同一服务的流并发额度,额度满时等待而不是扩连接。
        StrictMaxConcurrentRequests: true,
    },
}

client := &http.Client{
    Transport: transport,
    Timeout:   5 * time.Second, // 为排队与网络处理设置总时间预算。
}

如果目标服务按连接分摊资源、连接建立昂贵,或网关明确限制连接数,优先从 true 开始;如果业务延迟目标严格、上游允许多连接且连接成本可控,可以保留 false,但要同时观察 TLS 握手、文件描述符、NAT 端口和上游连接数。旧版 Go 使用 golang.org/x/net/http2.Transport 时,对应字段名是 StrictMaxConcurrentStreams;升级后不要把新旧字段混用。

把服务端上限、客户端策略和业务并发一起调

服务端参数应从下游预算反推,而不是从客户端峰值直接抄一个更大的数字。可以先估算单实例能够稳定承担的活跃请求数,再为管理接口、健康检查和抖动留出余量。例如,下游数据库池有 80 个连接,但每个请求可能并行发起两次查询,则 HTTP/2 流上限不能简单设成 80;业务层仍应使用独立信号量约束真正昂贵的处理。

protocols := new(http.Protocols)
protocols.SetHTTP1(true) // 同时接受 HTTP/1.1。
protocols.SetHTTP2(true) // TLS 监听上启用 HTTP/2。

srv := &http.Server{
    Addr:      ":8443",
    Handler:   mux,
    Protocols: protocols,
    HTTP2: &http.HTTP2Config{
        // 这是每条客户端连接可同时打开的流数量,不是全局并发上限。
        MaxConcurrentStreams: 128,
    },
    ReadHeaderTimeout: 5 * time.Second, // 限制请求头读取占用时间。
}

// 证书和密钥应由部署系统注入;错误必须记录并触发退出或重启策略。
if err := srv.ListenAndServeTLS("server.crt", "server.key"); err != nil && err != http.ErrServerClosed {
    log.Fatal(err)
}

128 只是演示值。实际调整建议每次只改变一个维度:先固定业务并发保护,再调整客户端严格策略,最后小步修改服务端流上限。否则延迟改善后,很难判断究竟是流槽位增加、连接数增加,还是下游容量刚好没有被压穿。

Go HTTP/2 服务端流上限、客户端连接策略、业务信号量和下游容量的静态边界
图2:服务端、客户端与业务保护三类调参边界。提高流上限并不会扩大下游容量,客户端是否扩连接也会改变资源成本;这是原创结构图。

上线时按小步变更和回滚条件执行

把调参当作一次可回滚的运行操作,而不是永久常量修改。先挑单个实例或少量流量,保持请求负载与下游容量可对比,再按下面的检查点推进:

  1. 建立基线:记录目标主机维度的请求并发、连接数、超时率、P95/P99、下游池等待时间和实例资源。
  2. 只改一项:客户端先切换严格策略,或服务端把流上限小幅调整;不要同时改超时、重试和连接池。
  3. 观察一个完整峰值窗口:确认排队时间下降时,下游等待、错误率和连接成本没有同步恶化。
  4. 达到阈值就回滚:若超时率、数据库池等待、内存、文件描述符或上游连接数超过预设阈值,恢复原配置。
  5. 再决定下一步:若严格模式导致排队但资源稳定,可评估扩连接;若扩连接使下游过载,应收紧业务信号量而不是继续抬高流上限。

排障期间可短时使用 GODEBUG=http2debug=1 查看 HTTP/2 调试日志,但日志量可能很大,也可能包含请求元数据,不适合长期在线开启。它只能帮助识别连接和帧活动,不能代替应用阶段耗时与资源指标。

# 仅在受控排障窗口临时开启,结束后立即移除该环境变量。
GODEBUG=http2debug=1 ./client

# 回滚时恢复原部署配置,不保留高噪声调试日志。
unset GODEBUG

告警与复盘要覆盖真正的容量边界

长期告警不要只盯 HTTP 状态码。流槽位耗尽往往先表现为客户端等待和超时,服务端甚至还没收到请求。建议至少保留以下观察项:

  • 按目标服务区分的在途请求数、排队耗时和请求总耗时;
  • 客户端连接数、连接建立速率、TLS 握手耗时和空闲连接回收;
  • 服务端活跃 Handler、请求取消、超时、内存与文件描述符;
  • 数据库连接池、RPC 并发、任务队列和外部服务限额;
  • 配置变更前后的高分位延迟与错误预算消耗。

复盘时要回答三个问题:排队发生在哪一层;客户端为什么选择等待或扩连接;服务端提高并发后,真正的瓶颈是否转移到了下游。只有这三点都清楚,MaxConcurrentStreams 才是容量设计的一部分,而不是碰运气的性能旋钮。

常见问题

把 MaxConcurrentStreams 调得越大越好吗?

不是。它会允许每条连接上同时存在更多活动请求,可能增加 Handler、内存、响应缓冲和下游调用压力。应以稳定下游容量为上限,并由业务并发保护兜底。

StrictMaxConcurrentRequests 开启后为什么延迟更高?

因为连接达到对端流上限后,新请求会等待已有请求完成,不再用新增连接换取并行度。它适合需要限制连接成本和形成背压的场景,但必须配合合理的超时与业务并发预算。

请求慢但并发流没有打满,还要调这个参数吗?

通常不要。应继续检查业务信号量、DNS/TLS、连接建立、响应头等待、响应体读写、HTTP/2 流控和下游处理耗时。只有证据指向流槽位等待,才调整流并发相关参数。

重试能缓解流排队吗?

盲目重试通常会放大排队。先给请求设置截止时间,再限制重试次数并加入退避;对非幂等请求不要自动重试。容量不足时,限流、削峰或扩容通常比增加重试更有效。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>