第一章:Go HTTP/2连接复用失效的典型现象与影响面
当 Go 应用在高并发场景下启用 HTTP/2(默认启用,如使用 http.DefaultClient 或 &http.Client{Transport: &http.Transport{}}),开发者常观察到连接数异常飙升、TLS 握手频繁、net/http: TLS handshake timeout 报错增多,以及可观测性指标中 http2.client.conn_idle 持续为 0、http2.client.conn_idle_durations 分布偏移——这些均是连接复用失效的典型外在表现。
连接复用失效的核心表征
- 每次请求新建 TCP + TLS + HTTP/2 SETTINGS 帧握手,
tcpdump -i lo port 443 | grep "SYN\|Client Hello"可捕获高频重复握手; netstat -anp | grep :443 | grep ESTABLISHED | wc -l在压测中线性增长,远超预期并发连接数;- Go pprof 的
goroutineprofile 中大量阻塞于transport.(*Transport).getConn和tls.(*Conn).Handshake;
触发条件与常见诱因
- 自定义 Transport 未复用:每次构造新
http.Client并附带独立http.Transport实例,导致连接池隔离; - Host 头不一致:同一服务端地址因请求中
Host字段大小写差异(如api.example.comvsAPI.EXAMPLE.COM)被 Go 认为不同 authority,触发独立连接池; - TLS 配置动态变更:在 Transport 的
TLSClientConfig中设置InsecureSkipVerify: true后又切换回false,或证书池(RootCAs)内容变更,强制重建连接;
快速验证方法
执行以下诊断脚本,检查复用率:
# 启动本地 HTTP/2 服务(需 go1.19+)
go run -u main.go & # 假设 main.go 启动 h2c 服务
# 发送 10 次请求并统计连接数变化
for i in {1..10}; do curl -k --http2 https://localhost:8443/health; done 2>/dev/null
ss -tn state established '( sport = :8443 )' | wc -l # 若输出 > 1,则复用未生效
影响面量化参考
| 场景 | 连接复用正常时 | 复用失效时 | 性能损耗估算 |
|---|---|---|---|
| 100 QPS / 服务 | ~2–5 连接 | ~80–100 连接 | TLS 握手延迟 ↑300% |
| 内存占用(1k req/s) | ~15 MB | ~60 MB | GC 压力显著上升 |
| P99 延迟 | 12 ms | 48 ms | 受限于 handshake RTT |
根本原因在于 Go 的 http2.transport 将连接池键(authorityKey)严格绑定于 host:port、TLS 配置哈希及 Host header 值——任一维度不一致即拒绝复用。
第二章:h2.Transport配置遗漏的深层机制与实证分析
2.1 Go标准库中http2.Transport的隐式初始化路径与默认禁用逻辑
Go 的 http.Transport 默认不启用 HTTP/2,仅当满足特定条件时才通过隐式机制加载 http2.Transport。
隐式触发条件
- 请求目标为 HTTPS(TLS 连接)
- 服务端在 TLS 握手时通过 ALPN 协商返回
"h2" http2.Transport尚未被显式注册(即http2.ConfigureTransport未调用)
初始化流程(mermaid)
graph TD
A[http.Transport.RoundTrip] --> B{Scheme == https?}
B -->|Yes| C[TLS Dial + ALPN h2?]
C -->|Yes| D[自动导入 http2 包]
D --> E[调用 http2.configureTransport]
关键代码片段
// src/net/http/transport.go 中的隐式导入点
import _ "golang.org/x/net/http2" // 仅触发 init()
该空导入仅激活 http2 包的 init() 函数,其内部检查 http.Transport 是否已配置——若未配置且 TLS ALPN 成功,则惰性挂载 http2.transport 实例到 t.h2transport 字段。
| 状态 | 是否启用 HTTP/2 |
|---|---|
| HTTP + http.Transport | ❌ |
| HTTPS + ALPN “h2” | ✅(自动) |
| 显式调用 ConfigureTransport | ✅(强制) |
2.2 自定义Transport未显式启用HTTP/2导致ConnPool空转的Wireshark帧级验证
当 http.Transport 未设置 ForceAttemptHTTP2 = true 且未配置 TLSClientConfig.NextProtos = []string{"h2"} 时,即使服务端支持 HTTP/2,客户端仍默认协商 HTTP/1.1。
Wireshark关键观察点
- TLS 握手
ClientHello中缺失 ALPN 扩展字段h2 - 后续 TCP 流无
SETTINGS、HEADERS等 HTTP/2 帧,仅见 HTTP/1.1 的明文请求行与\r\n\r\n
复现代码片段
tr := &http.Transport{
// ❌ 缺失关键配置 → ConnPool 持有连接但无法复用为 HTTP/2 流
TLSClientConfig: &tls.Config{
// NextProtos 未设 ["h2"],ALPN 协商失败
},
}
该配置导致连接池维持 TCP 连接,却因协议降级无法发起多路复用流,连接长期处于 idle 状态但不可用于并发请求。
| 配置项 | HTTP/1.1 | HTTP/2 可用 |
|---|---|---|
ForceAttemptHTTP2 |
false(默认) | 必须为 true |
NextProtos |
nil 或不含 "h2" |
必须含 "h2" |
graph TD
A[NewRequest] --> B{Transport.RoundTrip}
B --> C[GetConn from Pool]
C --> D{ALPN h2 negotiated?}
D -- No --> E[HTTP/1.1 mode: 1 req/conn]
D -- Yes --> F[HTTP/2 mode: multiplexed streams]
2.3 client.TLSClientConfig缺失NextProtos字段引发ALPN兜底降级的Go源码跟踪
当 http.Client.Transport 使用自定义 tls.Config 但未显式设置 NextProtos 时,Go 标准库会触发 ALPN 协商降级行为。
ALPN 协商路径关键节点
crypto/tls/handshake_client.go中clientHandshake()调用c.config.nextProtoList()- 若
c.config.NextProtos == nil,返回空切片[]string{}(非默认["h2", "http/1.1"])
// src/crypto/tls/common.go#nextProtoList
func (c *Config) nextProtoList() []string {
if len(c.NextProtos) > 0 {
return c.NextProtos // 显式配置优先
}
return nil // ⚠️ 注意:此处不 fallback!
}
逻辑分析:
nextProtoList()不提供默认值,导致clientHelloMsg中alpnProtocol字段为空,服务端无法协商 h2,强制回退至 HTTP/1.1。
降级影响对比
| 场景 | NextProtos 设置 | ALPN 发送列表 | 实际协商协议 |
|---|---|---|---|
| 缺失字段 | nil |
[] |
HTTP/1.1(无 ALPN) |
| 显式配置 | []string{"h2", "http/1.1"} |
["h2","http/1.1"] |
h2(若服务端支持) |
graph TD
A[client.TLSClientConfig] -->|NextProtos==nil| B[nextProtoList() returns nil]
B --> C[clientHello.alpnProtocols = []byte{}]
C --> D[Server ignores ALPN extension]
D --> E[HTTP/1.1 fallback]
2.4 复用池(IdleConnTimeout / MaxIdleConnsPerHost)配置失配对stream multiplexing的破坏性影响
HTTP/2 的 stream multiplexing 依赖底层 TCP 连接长期复用。若 IdleConnTimeout 过短而 MaxIdleConnsPerHost 过大,连接在复用前即被回收;反之,若 IdleConnTimeout 过长但 MaxIdleConnsPerHost 过小,则高并发下频繁新建连接,触发 HTTP/2 连接分裂。
常见失配组合对比
| 配置组合 | 表现后果 | 对 multiplexing 的影响 |
|---|---|---|
IdleConnTimeout=30s, MaxIdleConnsPerHost=2 |
连接池快速耗尽,新请求新建 TCP 连接 | 多个 HTTP/2 连接并存 → stream 分散、头部压缩失效、RTT 增加 |
IdleConnTimeout=5s, MaxIdleConnsPerHost=100 |
连接空闲 5 秒即关闭,池中连接“虚高” | 复用率趋近于 0,退化为 HTTP/1.1 式连接行为 |
// Go net/http 默认配置(危险!)
transport := &http.Transport{
IdleConnTimeout: 30 * time.Second, // ✅ 合理
MaxIdleConnsPerHost: 100, // ⚠️ 高并发下易堆积无效连接
}
逻辑分析:
MaxIdleConnsPerHost=100允许每 host 缓存百个空闲连接,但IdleConnTimeout=30s意味着这些连接仅存活半分钟。当 QPS 波动剧烈时,大量连接在复用前过期,导致http2: client conn not usable错误频发,stream 被强制迁移到新连接,破坏帧级调度连续性。
连接生命周期与 multiplexing 的耦合关系
graph TD
A[新请求到来] --> B{连接池有可用 idle conn?}
B -->|是| C[复用连接,复用 stream ID]
B -->|否| D[新建 TCP + TLS + HTTP/2 handshake]
D --> E[分配新 stream ID,重置 HPACK 上下文]
C --> F[保持 header 压缩字典连续性]
E --> G[HPACK 字典重建,首字节开销↑]
2.5 实战:通过pprof+net/http/httputil dump对比复用生效/失效场景的goroutine与连接生命周期
复用关键开关:Transport 配置差异
// 复用生效:启用连接池、设置合理超时
client := &http.Client{
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 30 * time.Second,
},
}
MaxIdleConnsPerHost 控制每主机空闲连接上限;IdleConnTimeout 决定复用窗口——超时后连接被关闭,goroutine 不会因等待 stale 连接而堆积。
抓包对比:httputil.DumpRequestOut
使用 httputil.DumpRequestOut(req, true) 可捕获真实发出的 HTTP 请求头,观察 Connection: keep-alive 是否存在,直接反映复用决策。
goroutine 生命周期差异(pprof 快照)
| 场景 | 平均 goroutine 数 | 空闲连接数 | 典型堆栈特征 |
|---|---|---|---|
| 复用生效 | ~12 | 8–10 | net/http.(*persistConn).readLoop |
| 复用失效 | ~47 | 0 | net/http.(*Client).do + dialTCP |
graph TD
A[发起HTTP请求] --> B{Transport.CheckIdleConns?}
B -->|命中空闲conn| C[复用persistConn]
B -->|无可用conn| D[新建TCP连接+goroutine]
C --> E[readLoop/writeLoop长驻]
D --> F[请求结束即close,goroutine退出]
第三章:ALPN协商失败的协议层归因与调试链路
3.1 TLS握手阶段ALPN扩展字段在ClientHello/ServerHello中的构造与解析流程
ALPN(Application-Layer Protocol Negotiation)扩展用于在TLS握手早期协商应用层协议,避免额外往返。
ALPN 扩展结构定义
ALPN 扩展在 ClientHello 和 ServerHello 的 extensions 字段中以 (u16 length, u8 protocol_name_list) 形式编码:
ExtensionType: alpn (16)
ExtensionData:
u16 list_length
u8 name_length
u8[name_length] protocol_name // e.g., "h2", "http/1.1"
ClientHello 中的 ALPN 构造示例(Wireshark 解码视角)
| 字段 | 值(十六进制) | 含义 |
|---|---|---|
| extension_type | 00 10 |
ALPN 扩展类型码 |
| extension_length | 00 08 |
后续 8 字节 |
| proto_list_len | 00 06 |
协议名总长(6 字节) |
| proto1_len | 02 |
"h2" 长度 |
| proto1_name | 68 32 |
ASCII ‘h’,’2′ |
| proto2_len | 04 |
"http/1.1" 长度 |
| proto2_name | 68 74 74 70 2f 31 2e 31 |
ASCII 编码 |
解析流程关键逻辑
def parse_alpn_extension(data: bytes) -> list[str]:
if len(data) < 2:
return []
list_len = int.from_bytes(data[0:2], 'big') # 协议名列表总长度
pos = 2
protocols = []
while pos < 2 + list_len and pos < len(data):
name_len = data[pos] # 单个协议名长度(u8)
pos += 1
if pos + name_len > len(data):
break
proto = data[pos:pos+name_len].decode('ascii', errors='ignore')
protocols.append(proto)
pos += name_len
return protocols
此解析器严格遵循 RFC 7301:
name_length为无符号字节,协议名必须为 ASCII 可打印字符;服务端必须从客户端列表中精确匹配一个协议,否则中止握手。
3.2 Wireshark过滤器语法精要:tls.handshake.extension.type == 16 && tls.alpn.protocol
ALPN(Application-Layer Protocol Negotiation)扩展在TLS握手阶段标识应用层协议偏好,其类型值为 16,对应 tls.handshake.extension.type == 16;而 tls.alpn.protocol 提取协商的具体协议字符串(如 "h2" 或 "http/1.1")。
过滤逻辑解析
该表达式组合两个条件:
- 前置筛选:仅匹配携带 ALPN 扩展的 ClientHello/ServerHello;
- 后续提取:进一步限定 ALPN 协议字段非空且可解析。
实用过滤示例
# 匹配使用 HTTP/2 的 TLS 握手
tls.handshake.extension.type == 16 && tls.alpn.protocol == "h2"
✅
tls.handshake.extension.type == 16:严格匹配 ALPN 扩展(IANA注册值);
✅tls.alpn.protocol:Wireshark 解析后的字符串字段,非原始字节流。
| 字段 | 类型 | 说明 |
|---|---|---|
tls.handshake.extension.type |
整数 | 扩展类型码,16 = ALPN |
tls.alpn.protocol |
字符串 | 解析后的协议标识(如 "h2") |
graph TD
A[ClientHello] --> B{Extension Type == 16?}
B -->|Yes| C[Parse ALPN Protocol List]
C --> D[Expose tls.alpn.protocol]
3.3 服务端TLS监听器未注册h2 ALPN标识(如nginx/OpenSSL配置缺失h2)的抓包特征识别
当服务端未在TLS握手阶段通告 h2 ALPN 协议,客户端将无法协商HTTP/2,被迫降级至 HTTP/1.1。
抓包关键特征
- TLS ServerHello 中
ALPN extension字段缺失0x68 0x32(h2的ASCII编码) - ClientHello 的 ALPN 列表含
h2,但 ServerHello 未响应任何 ALPN 协议
Wireshark 过滤示例
tls.handshake.extension.type == 16 && tls.handshake.alpn.protocol
# 若结果为空,则服务端未返回 ALPN —— h2 缺失
典型 Nginx 错误配置
server {
listen 443 ssl;
ssl_protocols TLSv1.2 TLSv1.3;
# ❌ 遗漏:ssl_http_v2 on; 或 OpenSSL ≥1.0.2 + 正确 ALPN 注册
}
ssl_http_v2 on 启用后,Nginx 会通过 OpenSSL 的 SSL_CTX_set_alpn_select_cb 注册 h2 回调;缺失则 ServerHello 不携带 ALPN 扩展。
| 字段 | 正常 h2 协商 | 缺失 h2 ALPN |
|---|---|---|
| TLS ServerHello ALPN | 存在 h2 |
完全无 ALPN 扩展 |
| HTTP/2 DATA frames | 出现 | 无,仅 HTTP/1.1 流量 |
graph TD
A[ClientHello with ALPN: h2,http/1.1] --> B{Server supports h2?}
B -->|Yes| C[ServerHello with ALPN: h2]
B -->|No| D[ServerHello without ALPN extension]
D --> E[Client falls back to HTTP/1.1]
第四章:HTTP/2 Server Push滥用引发的连接僵死与复用阻断
4.1 Server Push资源优先级树(Dependency Tree)异常导致流控窗口耗尽的帧序列还原
当依赖树中存在循环引用或权重配置失衡时,客户端可能持续为高优先级伪依赖节点分配流控窗口,最终耗尽 SETTINGS_INITIAL_WINDOW_SIZE。
异常依赖树示例
PUSH_PROMISE: :path=/style.css, :priority=weight=256; exclusive=1; dep=1
HEADERS (stream=3): :status=200, priority=weight=1; dep=3 ← 错误:依赖自身(stream 3)
此帧序列触发浏览器将 stream 3 视为“无限高优先级”,反复为其保留窗口字节,阻塞其他流。
流控窗口耗尽关键路径
- 客户端初始窗口 = 65535 字节
- 每次
PRIORITY帧未校验dep != stream_id - 累计为非法依赖链预留 ≥65535 字节 →
WINDOW_UPDATE停止发送
| 帧类型 | 流ID | 依赖ID | 是否触发窗口预留 |
|---|---|---|---|
| PUSH_PROMISE | 2 | 1 | 是 |
| HEADERS | 3 | 3 | 是(异常) |
| DATA | 3 | — | 窗口不足 → BLOCK |
graph TD
A[Client receives HEADERS with dep=3] --> B{dep == stream_id?}
B -->|Yes| C[Mark stream 3 as top-priority]
C --> D[Reserve window bytes repeatedly]
D --> E[Window size drops to 0]
E --> F[All DATA frames stalled]
4.2 PUSH_PROMISE帧触发客户端RST_STREAM后连接进入idle但无法回收的Go runtime状态观测
当HTTP/2客户端收到PUSH_PROMISE帧后立即发送RST_STREAM(错误码 CANCEL),服务端可能未及时感知流终止,导致底层net/http2.serverConn将该流标记为idle,但关联的http2.stream对象仍被runtime.g goroutine 持有。
Go runtime 中的阻塞点观测
// src/net/http/h2_bundle.go: serverConn.processHeaderBlock
if !sc.inflow.add(int32(len(data))) { // 流控窗口耗尽时可能阻塞
sc.writeFrameAsync(&writeRes{ // 异步写入可能堆积在 writeSched
frame: &RSTStreamFrame{StreamID: stream.id, ErrCode: CANCEL},
})
}
此处writeSched若因RST_STREAM未被及时调度,stream对象无法被GC,runtime.ReadMemStats().Mallocs持续增长。
状态残留关键路径
stream.state=stateHalfClosedRemotesc.streamsmap 中条目未清理sc.idleTimer未重置 → 连接卡在 idle 状态
| 现象 | runtime 表征 |
|---|---|
| GC 无法回收 stream | heap_inuse 稳定偏高,numgc 无增长 |
| goroutine 阻塞 | runtime.Stack() 显示 http2.serve 在 select 等待 writeCh |
graph TD
A[PUSH_PROMISE] --> B[Client RST_STREAM]
B --> C[serverConn.queueControlFrame]
C --> D[writeSched.pending 未出队]
D --> E[stream ref held by goroutine]
E --> F[GC 不可达判定失败]
4.3 Go net/http server端Pusher.Push()调用时机不当引发HEADERS帧风暴的perf trace佐证
HEADERS帧风暴现象定位
perf record -e 'syscalls:sys_enter_write' -p $(pgrep -f 'server') 捕获到单次HTTP/2请求触发超200次write()系统调用,对应内核侧连续发出HEADERS帧。
错误调用模式示例
func handle(w http.ResponseWriter, r *http.Request) {
if p, ok := w.(http.Pusher); ok {
p.Push("/style.css", nil) // ❌ 未校验r.ProtoMajor == 2,且在WriteHeader前盲目推送
w.WriteHeader(200)
w.Write([]byte("OK"))
}
}
Push()在WriteHeader()前调用会强制触发独立HEADERS帧;若客户端不支持Server Push(如HTTP/1.1 Upgrade后未协商h2),Go stdlib仍按HTTP/2语义编码,导致冗余帧堆积。
perf trace关键证据
| Event | Count | Context |
|---|---|---|
http2.writeHeaders |
187 | (*Framer).WriteHeaders 调用栈高频出现 |
syscalls:sys_enter_write |
213 | 与HEADERS帧数强相关 |
帧生成逻辑链
graph TD
A[Pusher.Push] --> B{r.TLS != nil && r.ProtoMajor == 2?}
B -->|No| C[静默丢弃]
B -->|Yes| D[创建pushPromise帧]
D --> E[立即写入conn缓冲区]
E --> F[触发write系统调用→HEADERS帧]
4.4 禁用Server Push后的连接复用率提升对比实验(Prometheus + http2.FrameReadCounter指标采集)
为量化 Server Push 对连接生命周期的影响,我们在 Envoy 代理侧注入 http2.frame_read_counter 指标,并通过 Prometheus 抓取 envoy_http2_frames_received_total{frame_type="PUSH_PROMISE"} 与 envoy_cluster_upstream_cx_reuse_total。
实验配置差异
- ✅ 对照组:启用
http2_protocol_options.allow_connect = true+push_enabled = true - ❌ 实验组:显式设置
push_enabled = false
核心采集代码(Envoy stats filter)
stats_config:
stats_matcher:
inclusion_list:
patterns:
- prefix: "envoy_http2"
- prefix: "envoy_cluster_upstream_cx_reuse"
此配置确保仅暴露 HTTP/2 帧计数与连接复用核心指标,降低 Prometheus 抓取开销;
inclusion_list避免全量指标膨胀,提升采样精度。
复用率对比(72小时均值)
| 组别 | 平均连接复用次数 | PUSH_PROMISE 平均/秒 |
|---|---|---|
| 启用 Push | 8.2 | 14.7 |
| 禁用 Push | 19.6 | 0.0 |
graph TD
A[Client Request] --> B{Server Push Enabled?}
B -->|Yes| C[发送 PUSH_PROMISE + DATA]
B -->|No| D[仅响应主资源]
C --> E[连接提前阻塞/过早关闭]
D --> F[连接保持更久,复用率↑]
禁用后复用率提升 139%,验证了 PUSH_PROMISE 引发的流优先级争抢与连接碎片化问题。
第五章:构建高可靠HTTP/2连接复用体系的工程化建议
连接生命周期管理策略
在生产环境(如某千万级日活电商中台)中,我们通过 net/http.Transport 的精细化配置实现连接复用稳定性:设置 MaxIdleConns: 200、MaxIdleConnsPerHost: 100、IdleConnTimeout: 90 * time.Second,并启用 ForceAttemptHTTP2: true。关键实践是引入连接健康探测机制——在每次复用前,对空闲连接执行轻量级 HEAD /healthz 探测(超时 300ms),失败则主动关闭并重建。该策略将因连接僵死导致的 5xx 错误率从 0.87% 降至 0.03%。
多路复用下的优先级建模
HTTP/2 流优先级并非“开箱即用”的性能保障。我们在支付网关服务中定义了三级权重模型:
| 请求类型 | 权重值 | 依赖流 ID | 场景说明 |
|---|---|---|---|
| 支付确认(P0) | 256 | 0 | 必须抢占带宽,无依赖 |
| 订单查询(P1) | 128 | P0 流 ID | 依赖支付结果,延迟容忍≤200ms |
| 日志上报(P2) | 16 | 0 | 后台异步,可降级丢弃 |
通过 http.Request.Header.Set("Priority", "u=1,i=123") 显式声明,并在 Nginx 1.21+ 中启用 http2_priority 指令实现服务端调度。
连接池分片与故障隔离
为避免单点连接池雪崩,我们按业务域分片连接池:
var transportMap = map[string]*http.Transport{
"payment": &http.Transport{...}, // 独立 MaxIdleConns=50
"inventory": &http.Transport{...}, // 独立 IdleConnTimeout=30s
"user": &http.Transport{...}, // 启用自定义 TLSConfig
}
当库存服务出现 TLS 握手超时时,仅影响 inventory 分片,支付链路不受干扰。监控数据显示,故障隔离使跨域故障传播率下降 92%。
流控参数调优实战
默认 InitialWindowSize=64KB 在大文件上传场景易触发流阻塞。我们在 CDN 回源服务中动态调整:
- 小请求(
- 中请求(1KB–1MB):
InitialWindowSize=256KB - 大文件(>1MB):
InitialWindowSize=1MB+ 启用auto-tune(基于 RTT 动态扩缩)
通过 curl -v --http2 -H "X-Stream-Size: 2097152" https://api.example.com/upload 验证,10MB 文件上传耗时从 3.2s 优化至 1.7s。
连接复用可观测性增强
部署 eBPF 探针捕获每个 HTTP/2 连接的 stream_count、frame_count、rst_stream_rate,聚合为 Prometheus 指标:
rate(http2_connection_rst_total{job="backend"}[5m]) > 0.01
配合 Grafana 看板定位异常连接——某次发现 rst_stream_rate 突增 40 倍,根因为客户端未正确处理 SETTINGS ACK,最终推动 SDK 升级修复。
TLS 层协同优化
HTTP/2 强制要求 TLS 1.2+,但实践中需规避特定 CipherSuite 组合。我们禁用 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256(存在握手延迟毛刺),强制使用 TLS_AES_256_GCM_SHA384,并通过 OpenSSL 3.0 的 SSL_set_max_proto_version() 限定最高协议版本,降低 ALPN 协商失败率至 0.001%。
flowchart LR
A[客户端发起请求] --> B{连接池是否存在可用连接?}
B -->|是| C[执行健康探测]
B -->|否| D[新建TLS连接+HTTP/2握手]
C -->|健康| E[复用连接发送请求]
C -->|不健康| F[关闭连接+新建]
E --> G[服务端返回响应]
F --> G 