你的服务每秒在做几千次加密握手,而 99% 的握手都是白干的:TLS 连接复用的真相
前几天我盯着自己一台小机器的 CPU 曲线发呆。业务逻辑几乎不耗 CPU,openssl 和 libssl 却长期霸占榜首。抓了个包一看更离谱:同一个 IP,几乎每隔几百毫秒就来一次新的 TCP 三次握手 + TLS 握手,一分钟能撞出几百个连接。
后来一查代码才发现,是某个爬虫脚本每次请求都 requests.get() 一把梭,底层连接池压根没复用;还有个更离谱的,客户端把 ALPN 关了,于是每次访问都从 HTTP/2 降级成 HTTP/1.1,一台机器能给你开几百条长连接。
这篇不聊「什么是 TLS」,那玩意儿你百度一下就有。我要聊的是一件更值钱的事:握手到底贵在哪,以及为什么你 99% 的握手根本不该发生。
一、握手贵在哪:不是「加密」贵,是「证明自己」贵
很多人以为 TLS 握手慢是因为加密算不完。实际上对称加密(AES-GCM、ChaCha20)现在的硬件指令集加持下,速度是GB/s 级的,加密本身几乎不构成瓶颈。
真正的开销在三块:
- 非对称运算:ECDHE 密钥交换要做一次椭圆曲线标量乘,一次大约几百微秒到几毫秒。这是单次握手里最贵的 CPU 项,也是为什么 ECDSA 证书签名比 RSA 便宜(但不是所有实现都默认这么选)。
- 证书链校验:服务端要把整条链发过去,客户端要逐级验签、还要查 CRL/OCSP。如果链上带了 4 个中间证书,每多一个就是一次额外的签名验证。
- 网络往返:这个才是真正的「时间成本」。TLS 1.2 完整握手是 2-RTT(客户端 hello → 服务端 hello+证书 → 客户端 key exchange → Finished),TLS 1.3 压到了 1-RTT,0-RTT 数据加密还能更进一步。
算笔账就明白了。假设你的接口处理时间 8ms,客户端和服务端在同一个城市(同机房 0.5ms RTT,跨城 30ms RTT):
不握手:1 次请求 = 1 次 RTT(0.5ms)+ 8ms ≈ 8.5ms
TLS 1.2 每次握手:额外 2-RTT ≈ 1ms,吞吐直接打七折
跨城 30ms RTT 时:额外 60ms 往返,业务 8ms 直接被淹没
所以「握手慢」的真相是:它慢在 RTT 上,不是慢在算力上。这解释了为什么很多人在内网压测时完全感觉不到,一旦用户从全国各处访问,网关机器 CPU 不高、数据库也不慢,但接口就是「慢得没道理」。
二、三层复用,别搞混
很多人以为「有 keep-alive 就是复用了」。其实这里有三种完全不同的复用,收益和坑都不一样:
第一层:TCP 连接复用(HTTP keep-alive)。省掉三次握手和 TCP 慢启动。绝大多数 HTTP 客户端默认都开,问题出在你是不是每次都新建 Client / 每次都 Connection: close。收益最大,也最容易做。
第二层:TLS 会话复用(session resumption)。第二次连接时,客户端带上 Session Ticket(或 Session ID),服务端认出老熟人,直接省掉证书传输和密钥协商,握手从 1-RTT 降到「几乎 0-RTT」(只有 1 个 RTT 的数据包交换,比完整握手少一半)。
在 Go 里这是显式配置的事,默认的 tls.Config 如果不设 ClientSessionCache,会话复用是完全不生效的——Go 出于安全考虑不会默认共享缓存,很多人在这里踩过坑还不自知:
// 没有这一行,TLS session resumption 永远不生效
tlsConfig := &tls.Config{
ServerName: "api.example.com",
MinVersion: tls.VersionTLS12,
ClientSessionCache: tls.NewLRUClientSessionCache(64),
RootCAs: rootCAs,
}
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
ForceAttemptHTTP2: true, // 关键:不开这个,可能一直用 HTTP/1.1
},
Timeout: 10 * time.Second,
}
注意 ForceAttemptHTTP2 这行。如果你在自定义了 TLSClientConfig 或 Transport,Go 就不会自动启用 HTTP/2 了——于是你每个请求都在新连接上跑 HTTP/1.1,握手次数直接翻十倍。这个坑非常隐蔽,因为代码看起来「完全正常」。
第三层:HTTP/2 多路复用。一条 TCP + 一条 TLS 连接上跑无数个并发请求,一次握手搞定所有。这是最彻底的解法,代价是必须用 HTTP/2、并且得小心它的队头阻塞(TCP 层丢包会拖住所有流)。
层次搞清楚后,收益排序很清晰:先做连接池,再开 HTTP/2,最后才折腾 session resumption。90% 的场景做到前两步就够了。
三、Session Ticket 的隐藏代价
Session 复用不是免费的,代价由服务端承担,两种实现方式:
- 无状态模式:服务端用主密钥加密会话状态,直接发给客户端(就是那个 Ticket)。优点是不用存、任意机器都能解密、扩容友好;缺点是密钥轮转麻烦,而且 Ticket 一旦泄露,在主密钥有效期内就能被重放。
- 有状态模式:服务端存会话 ID,加密 Ticket 只带 ID。安全性略好,但要求所有接入节点共享存储——你多台机器 + 负载均衡时,没共享存储就会出现「第一个请求打到了 A 机器,第二个请求到了 B 机器,B 说我不认识这个票」,于是复用率莫名其妙上不去。
更现实的一个坑:很多 CDN 和反向代理会在边缘完成 TLS 终止,你的源站看到的是「已经解密后的明文 HTTP」。这种情况下,源站压根不需要握手,但也意味着你如果想做端到端加密或 mTLS,就得把 TLS 往下沉,握手成本会重新出现,还得多扛一跳。
还有个细节值得单独提:证书链的体积会直接放大握手成本。TLS 握手的第三个飞行(server flight)要装下整条证书链,如果中间证书没配全,客户端还得额外做一次 AIA 拉取(更慢),或者干脆握手失败。见过太多「证书好好的就是慢」的案例,根因是服务器少发了中间证书,客户端要去 OCSP responder 现场查一次。
四、怎么把它测出来:别猜,去看指标
诊断握手问题靠的是几个很朴素的指标。
先看连接数。在服务端上:
ss -s
# 或按来源统计
ss -tn state established | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head
如果某个 IP 建立的连接数是其他 IP 的几十倍,基本可以确定它在做握手风暴。生产环境务必配好 keepalive_timeout 和连接数上限,别让单个客户端把你的 fd 吃光。
再看握手的 TLS 版本和复用情况。Nginx 日志里直接给你答案:
log_format perf '$remote_addr $ssl_protocol $ssl_cipher $ssl_session_reused '
'$request_time $upstream_response_time "$request"';
$ssl_session_reused 这个字段是金矿。它是 . 表示没复用,是 r 表示复用了。如果你的复用率长期在 0 附近,说明 session 缓存根本没生效(或者压根没有状态可复用)。
客户端侧用 curl 自测,这组指标比肉眼看时间靠谱多了:
curl -o /dev/null -s -w \
"dns:%{time_namelookup} conn:%{time_connect} tls:%{time_appconnect} \
total:%{time_total} http:%{http_version}\n" \
https://api.example.com/health
time_appconnect 减去 time_connect 就是 TLS 握手耗时。这个差值如果比 time_total 还大,那你的接口慢就慢在加密上,跟业务代码一点关系没有。
另外 Nginx 的 $ssl_handshake_time 也可以直接加进日志,异常慢的握手往往和客户端在链路上做了额外 RTT(比如跨洲、走了代理)有关。
五、我的实践清单(踩过坑之后的版本)
- 客户端一律用带连接池的 SDK,不要用裸
socket或每次 new 的 Client。Python 用requests.Session(),Go 用复用的http.Client,Java 用HttpClient+ 连接管理器。 - 显式开 HTTP/2。Go 里设
ForceAttemptHTTP2: true,Nginx 里确认http2 on;,并检查ALPN协商结果是不是 h2。 - 开启会话复用,同时确认跨节点共享(Redis 存 session ID 是常见做法),并给 Ticket 密钥设定轮转周期。
- 只发必要的证书链,配好中间证书与 OCSP stapling,能省掉一次客户端的额外网络请求。
- 服务端做握手限流:短时间大量新建连接的 IP,直接降级或限速。这既能防爬虫,也能防「握手放大」型 DDoS——攻击者用小包就能让你付出昂贵的非对称运算成本。
- 把
$ssl_session_reused之类的指标接进监控。复用率突然掉下来,通常意味着有人换成了不复用的客户端,或者负载均衡把会话打散了。
六、最后吐槽两句
每次看到有人手写 socket + ssl_connect() 逐条请求爬数据,我都替他的 CPU 心疼。每秒 1000 次 TLS 1.2 完整握手,服务器那边光做非对称运算就得冒烟,而对方压根不知道自己该复用连接。
更离谱的是「优化」的方向经常反着来:有人发现握手慢,就把 TLS 关了;有人发现 CPU 高,就去加机器;还有人在反向代理上把 keepalive 关了「以免连接占用资源」。
性能优化最讽刺的地方就在这儿:正确做法往往不是「加资源」,而是「别重复做已经做过的事」。握手这件事你已经做完了,客户端却因为懒得复用,让服务器又做了一遍。连接池、HTTP/2、会话复用——没有任何一项需要你多花钱,全靠少折腾。
先去看一眼你的服务端有多少条 established 连接,再去看日志里的复用率。数字会告诉你该改哪儿。