HTTP Client:那个你以为配好了但随时炸掉的东西

2026-08-28 6 0

HTTP Client:那个你以为配好了但随时炸掉的东西

写后端服务的,谁没用过 HTTP Client 调个外部接口?

大部分人的用法是这样的:

http.Get(url)

然后祈祷。运气好,上线三个月没事;运气不好,某天凌晨三点报警让你起来。

你以为这是玄学?不,这是你不懂 HTTP Client 的底层原理。


一、默认连接池:藏在水面下的定时炸弹

Python 开发者最爱用 requests 库,调接口跟喝水一样:

requests.get("https://api.example.com/users")

看起来没问题。但你知道吗?requests 的默认 session 模式下,连接池上限是 10 个连接。

如果你的服务是个多线程/多协程的 Web 应用(Flask + gunicorn 多 worker,或者 FastAPI + uvicorn),每个进程都会创建自己的连接池。当并发量上来,你就发现:

HTTPConnectionPool(host='api.example.com', port=443): Max retries exceeded

这不是对方服务商的锅,是你自己的锅。你的连接不够用了。

正确做法:使用连接池,复用连接,控制并发数。

import requests

# 创建一个全局的 session,复用连接池
session = requests.Session()
adapter = requests.adapters.HTTPAdapter(
    pool_connections=20,   # 最多缓存多少个连接池
    pool_maxsize=50,        # 每个池最多多少个连接
    max_retries=3
)
session.mount('http://', adapter)
session.mount('https://', adapter)

Go 开发者也别笑,你们的 http.DefaultClient 一样有坑:

// 这个 client 默认没有 timeout,而且是全局共享的
resp, err := http.Get("https://api.example.com/users")

http.DefaultClient 底层transport的MaxIdleConns默认是无穷大,但 IdleConnTimeout 默认是 90 秒。什么概念?你的连接在空闲 90 秒后被服务器关闭了,但你的 client 还不知道,下次请求直接 Reset。

正确做法:

client := &http.Client{
    Timeout: 10 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,  // 关键!每个 host 最多保持多少空闲连接
        IdleConnTimeout:     90 * time.Second,
    },
}

二、Timeout:最容易忽略也最要命的配置

我见过最离谱的生产事故是这样的:

某服务调用外部支付网关,正常情况下响应时间 200ms。但某天支付网关开始变慢,响应时间变成了 5 秒。你的服务没有设置 timeout,于是——所有协程都在等。

一个请求占一个协程。协程耗尽。新请求进不来。服务假死。

这不是 DDoS,这是你代码里的一个 nil check 缺失。

HTTP Client 的 timeout 分几层,你得都配上:

  • DialTimeout:建立 TCP 连接的 timeout
  • TLSHandshakeTimeout:TLS 握手的 timeout
  • ResponseHeaderTimeout:服务端响应 headers 的 timeout(不含 body)
  • IdleConnTimeout:空闲连接的 timeout(前面说过了)
  • TotalTimeout / RequestTimeout:整个请求的 timeout(含 body 读取)

大多数语言的 client 库都有统一的 Timeout 配置,但 Go 里面你得自己组装:

client := &http.Client{
    Timeout: 30 * time.Second,  // 这个是总 timeout,会覆盖所有子 timeout
    Transport: &http.Transport{
        DialContext: (&net.Dialer{
            Timeout: 5 * time.Second,
        }).DialContext,
        TLSHandshakeTimeout: 10 * time.Second,
        ResponseHeaderTimeout: 15 * time.Second,
        IdleConnTimeout: 90 * time.Second,
    },
}

Python requests 库就简单多了,但一样要记得配:

requests.get(url, timeout=(3.05, 10))
# 3.05 是连接 timeout,10 是读取 timeout

什么,你说你们的接口很快,永远不会超过 200ms?那你至少配个 5 秒的 timeout。宁可让用户看到报错,也不要让整个服务卡死。


三、重试机制:写对了是保险,写错了是灾难

外部接口调用失败要重试,这个道理大家都懂。但重试机制里有个经典坑:

幂等性。

你发了一个 POST 请求创建订单,服务器处理了,但你的网络超时了。你不知道订单到底创建成功没有。你选择重试——结果用户收到了两笔扣费。

恭喜你,你刚刚创造了一个线上 bug,而这个 bug 来自"好心"的自动重试。

正确做法:

  1. GET 请求可以随便重试,反正不会改变状态
  2. POST/PUT/DELETE 请求重试前,必须确保接口是幂等的(服务器支持 idempotency-key)
  3. 或者,用更靠谱的方案:业务侧做去重

另外还有个坑:重试次数和退避策略。

很多新手这样写:

for i := range 5 {
    resp, err := client.Do(req)
    if err == nil {
        break
    }
    time.Sleep(time.Millisecond * 100) // 固定 100ms 等待
}

这在低并发场景没问题。但如果你所有失败请求都在重试,会形成"惊群效应"——同一时间所有请求一起重试,把对方服务打垮。

正确做法:用指数退避(Exponential Backoff)加上抖动(Jitter):

// Go 例子
backoff := time.Duration(math.Pow(2, float64(i))) * 100 * time.Millisecond
jitter := time.Duration(rand.Int63n(int64(backoff / 2)))
time.Sleep(backoff + jitter)

这样每次重试的等待时间是:100ms、200ms+随机抖动、400ms+随机抖动……不会所有请求都在同一个时间点重试。


四、TLS/SSL 证书验证:生产别关,测试别懒

我见过两个极端:

极端一:生产环境为了"方便调试",关闭了证书验证(verify=FalseInsecureSkipVerify=true)。然后被中间人攻击,用户数据泄露。

极端二:测试环境证书过期了,服务启动失败,一堆人不知道怎么办,直接把证书验证关了。

两种都不可取。

正确姿势:

  • 生产环境:永远开启证书验证,永远
  • 测试/预发环境:使用真实的证书(Let's Encrypt 三个月免费),或者在内网 PKI 体系中正确配置
  • CI/CD 环境:挂载测试专用证书

如果你真的遇到了证书问题(内网环境、自签名证书),正确的做法是:

# Python: 挂载自定义 CA 证书,而不是关闭验证
session = requests.Session()
session.verify = '/path/to/your/custom-ca-bundle.crt'

# Go: 自定义 TLS 配置
cert, _ := tls.LoadX509KeyPair("client.crt", "client.key")
tlsConfig := &tls.Config{
    Certificates: []tls.Certificate{cert},
}
transport := &http.Transport{TLSClientConfig: tlsConfig}

五、实战建议:把你代码里的 http.Get 全换掉

说了这么多,其实就一句话:

不要用默认配置去生产环境跑高并发。

每个语言的 HTTP Client 都有这些问题。问题的根源都一样:默认配置是为了"能用",不是为了"好用"和"稳用"。

我现在的做法是这样的:项目一初始化,先写一个 HTTP Client 的配置模板,所有服务都复用这个模板:

// Go
func NewHTTPClient() *http.Client {
    return &http.Client{
        Timeout: 30 * time.Second,
        Transport: &http.Transport{
            MaxIdleConns:        200,
            MaxIdleConnsPerHost: 50,
            IdleConnTimeout:     90 * time.Second,
        },
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            return http.ErrUseLastResponse  // 不跟随重定向,自己控制
        },
    }
}

别小看这一步。我见过太多团队,项目做到一半才发现 HTTP Client 的问题,然后一边上线一边改 bug,一边祈祷。

提前配置好,不香吗?


最后

HTTP Client 配置这事儿,属于"不做不知道,一做吓一跳"的类型。你以为它只是个调接口的工具?它其实是你的服务能不能稳定跑在生产环境的守门员。

连接池太小 → 并发高了就超时
没有 timeout → 服务端卡住你就一起卡
乱重试 → 数据重复 or 惊群效应
关闭证书验证 → 安全漏洞

四个坑,每个都有人踩过。你现在有两个选择:

一,继续用默认配置,祈祷不会出事。
二,现在就去检查你的代码。

你选哪个?

相关文章

RESTful API版本控制:三个流派的对决
数据库连接池:一个你从不关心直到它炸了的玩意儿
RESTful API设计:我发现大家都在犯同样的错误
连池都不会配,你的服务不炸算我输
我写了三年API,才发现这些坑全踩过一遍
你的HTTP客户端正在偷偷”饿死”你的服务——一个被忽视的性能杀手

发布评论