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 来自"好心"的自动重试。
正确做法:
- GET 请求可以随便重试,反正不会改变状态
- POST/PUT/DELETE 请求重试前,必须确保接口是幂等的(服务器支持 idempotency-key)
- 或者,用更靠谱的方案:业务侧做去重
另外还有个坑:重试次数和退避策略。
很多新手这样写:
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=False、InsecureSkipVerify=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 惊群效应
关闭证书验证 → 安全漏洞
四个坑,每个都有人踩过。你现在有两个选择:
一,继续用默认配置,祈祷不会出事。
二,现在就去检查你的代码。
你选哪个?