我的服务要高频调用一个 HTTPS 接口、每次请求都老老实实新建一个连接发完就关,功能没问题可延迟一直降不下来、CPU 还莫名其妙偏高,抓包一看才发现每一次请求前都在完整地做一遍 TLS 握手——多轮往返加上一堆非对称加密运算,这笔昂贵的建立成本被我每个请求都重新付了一遍的深度复盘
这次踩的坑,问题不在"功能对不对"——它一直好好的;问题在"我为每一次请求,都重复支付了一笔本该只付一次的昂贵成本",而这笔成本藏在 TLS,在 HTTP 之上加一层 TLS 加密,防止中间人窃听和篡改。">HTTPS 那个我习以为常、从没细想的"s"里。
故障现场:功能正常,可延迟降不下、CPU 偏高
我有个服务需要高频地去调用一个第三方 HTTPS 接口。我的客户端代码写得很朴素:每次要调用,就新建一个 HTTPS 连接、发请求、拿到响应、关掉连接。功能完全正常,但性能一直不对劲:
- 单次延迟降不下来:每次调用的耗时,明显比"纯网络往返 + 服务端处理"该有的时间长出一截,而且这一截稳定地存在,优化业务逻辑怎么都压不下去。
- CPU 莫名偏高:这个服务本身没什么重计算,可调用量一大,CPU 占用就明显升高,和它"就是个调接口的"的定位完全不符。
- 调用量越大越明显:低频时还好,一到高频、密集调用,延迟和 CPU 的问题就成倍放大。
- 抓包发现每次都在握手:我用抓包工具一看,傻眼了——每一次请求之前,都有一整套 TLS 握手的来回报文(ClientHello、ServerHello、证书、密钥交换……),也就是说,我每次调用都在从头建立一次 TLS 加密连接。
"稳定多出一截延迟、CPU 偏高、每次请求前都在 TLS 握手"——这几条合起来,把矛头指向了一个我从没正眼瞧过的东西:HTTPS 的那个"S"(TLS 加密)不是免费的,建立一个 TLS 连接要做一套握手;而我每次请求都新建连接,就等于每次都把这套握手从头做一遍。我得去搞清楚,这套 TLS 握手到底有多贵。
第一件事:搞懂 TLS 握手是一笔昂贵的"一次性建立成本"
带着"每次都在握手"这条线去翻 HTTPS 的原理,我才算真正理解了一件天天在用却从没掂量过分量的事——HTTPS = HTTP over TLS;在能用这个加密通道收发数据之前,客户端和服务端必须先完成一套 TLS 握手,而这套握手是一笔相当昂贵的成本。
这笔成本贵在两处:
- 多轮网络往返(延迟):传统 TLS 握手需要多个 RTT(往返)来协商加密套件、交换密钥、验证证书——在能发出第一个字节的业务数据之前,光握手就要等好几个来回,这就是那"稳定多出的一截延迟"。
- 非对称加密运算(CPU):握手阶段要做非对称加密(验证证书签名、密钥交换),这类运算计算量很大、很吃 CPU;而握手完成后,真正传数据用的是对称加密(快得多)。所以 CPU 的开销,绝大部分压在握手这一下。
关键在于:这套昂贵的握手,是建立连接时的一次性成本——它只在"建立 TLS 连接"那一刻发生;一旦连接建好,之后在这条连接上收发再多请求,都不需要再握手了,直接用已经协商好的对称密钥高速传输。换句话说,TLS 握手是"开门"的成本,开一次门进去后可以来回走很多趟,不必每走一趟都重新开一次门。
而我的错,就在这里:我每次请求都新建连接、用完就关,等于每来一个人就重新开一次门、进去办一件事就把门焊死,下一个人来再重新开门。于是这笔本该整条连接只付一次的握手成本,被我每个请求都重新付了一遍——高频调用下,延迟里堆满了重复的握手往返,CPU 上堆满了重复的非对称加密运算。我把这个对照验证清楚:
# 我的做法:每次请求新建连接 -> 每次都完整 TLS 握手
请求1: [TCP握手][TLS完整握手 多RTT+非对称加密][发数据][关连接]
请求2: [TCP握手][TLS完整握手 多RTT+非对称加密][发数据][关连接] <- 又付一遍!
请求3: [TCP握手][TLS完整握手 多RTT+非对称加密][发数据][关连接] <- 再付一遍!
...每个请求都重复支付昂贵的握手成本
# 正确:复用连接 -> 握手只做一次, 后续请求直接走
建连: [TCP握手][TLS完整握手 一次]
请求1: [发数据] 请求2: [发数据] 请求3: [发数据] ... <- 都不再握手, 飞快
真相大白:不是 HTTPS 慢,也不是网络差,而是我把 TLS 握手这笔"建立连接时只需付一次"的昂贵一次性成本,因为每次请求都新建连接,变成了"每个请求都重付一遍"的高频重复成本。解法的核心,就是别每次都重新建连接、把握手的成果复用起来。
第二件事:正解——复用连接 + 会话复用,让握手只付一次
根因是"每次请求重付握手成本",那正解的核心就一句话:别让每个请求都新建连接,而要复用已经握手好的连接,把那笔昂贵的 TLS 握手成本摊薄到一次。几个层次:
// 反例:每次请求都 new 一个 client / 新建连接, 每次都完整握手
// HttpClient c = HttpClient.newHttpClient(); // 放在方法里、每次新建 -> 每次握手
// 正解 1:用带连接池的客户端, 全局复用(连接 keep-alive, 握手只做一次)
// HttpClient 是可复用的, 创建一次、全程共享; 底层连接池自动复用 TLS 连接
private static final HttpClient CLIENT = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2) // HTTP/2: 单连接多路复用
.connectTimeout(Duration.ofSeconds(5))
.build();
// 之后所有请求都用 CLIENT.send(...), 复用底层已握手的连接
// 用 OkHttp/Apache HttpClient 同理: 复用同一个 client + 连接池, 别每次 new
// OkHttpClient 全局单例, 内部 ConnectionPool 自动保持并复用长连接
除了"复用连接",还有几个专门削减 TLS 握手成本的手段:
- 开启 HTTP keep-alive / 连接池:让连接发完一个请求别关,留着给后续请求复用,这是最直接的——握手只在连接首次建立时做一次。
- TLS 会话复用(session resumption):即便连接断了要重连,也可以用 session ID / session ticket 复用之前协商的密钥,跳过完整握手,做一个轻量的简化握手。
- 升级 HTTP/2:多个请求多路复用一条连接,天然只握一次手。
- 升级 TLS 1.3:把握手从传统的 2-RTT 砍到 1-RTT,会话复用时甚至 0-RTT,大幅降低握手延迟。
核心就一条:TLS 握手是建立连接的一次性成本,要靠连接复用 + 会话复用把它摊薄,而不是每个请求都重新支付一遍。
第三件事:同一类"把一次性建立成本当成每次都要重付的成本"的坑,我后来又撞见好几个
这次踩坑让我对一类成本格外警觉:很多东西有一笔固定的"一次性建立/准备成本"——建立连接、初始化、预热、加载、编译;这笔成本只要建立一次然后复用就被摊薄得微不足道,可一旦你每次用都重新建立一遍,它就会被高频重复支付,累积成巨大的浪费。这种坑到处都是:
- 数据库/Redis 连接每次新建:不用连接池、每次操作都新建连接,每次都付 TCP+认证握手的成本,高频下慢且耗资源。
- 线程/进程每次创建:每个任务都 new 一个线程,创建销毁线程的成本被反复支付,该用线程池复用。
- 重量级对象反复初始化:每次用都 new 一个昂贵对象(如 Jackson ObjectMapper、正则 Pattern.compile),该一次性创建并复用。
- 每次请求重新加载配置/证书/模型:把"加载一次缓存住"的东西放进了每次请求的路径里,反复 IO/解析。
- 缺乏预热的冷启动:每次冷启动都付 JIT 编译、缓存填充的成本,该预热并保持热。
它们的内核是同一个:成本有"一次性的"和"每次都有的"之分;"一次性建立成本"的特点是建立一次就能反复使用——它的设计意图就是"付一次、用很多次";如果你不去复用那个建立好的东西,而是每次都从头建立一遍,就等于把一个本该被摊薄的固定成本,变成了一个随使用频率线性叠加的变动成本,频率越高,浪费越惊人。所以,面对任何有"建立/初始化/准备"环节的东西,都要识别出那笔一次性成本,并想办法建立一次、反复复用,而不是每次都重来。我把这套判断画成了一张图(见后文)。
| 一次性建立成本 | 每次重建的恶果 | 复用方式 |
|---|---|---|
| TLS 握手 | 每请求多 RTT + 非对称加密 | 连接池/keep-alive/会话复用 |
| DB/Redis 连接 | 每次付连接+认证成本 | 连接池 |
| 线程/进程创建 | 反复创建销毁开销 | 线程池 |
| 重对象初始化 | 每次 new 昂贵对象 | 单例/缓存复用 |
| 配置/证书/模型加载 | 每请求重新 IO 解析 | 启动时加载并缓存 |
第四件事:每次新建连接 vs 复用连接——一张对照表
这次事故逼我把"每次新建 HTTPS 连接"和"复用连接"摆成一张表,以后写调用代码前先对照:
| 维度 | 每次新建连接 | 复用连接(连接池/keep-alive) |
|---|---|---|
| TLS 握手 | 每个请求都完整握手一遍 | 整条连接只握一次 |
| 延迟 | 每次多几个 RTT 的握手往返 | 后续请求省掉握手往返 |
| CPU | 每次重做非对称加密 | 握手一次、后续走对称加密 |
| 连接资源 | 反复建/关、TIME_WAIT 堆积 | 少量长连接复用 |
| 高频调用 | 成本成倍放大 | 成本摊薄、近乎恒定 |
看清这张表,做法就明确了:高频调用同一个 HTTPS 服务,一定要复用连接(全局共享带连接池的客户端、开 keep-alive),让 TLS 握手这笔成本整条连接只付一次;别每次请求都 new 客户端、新建连接、用完就关。复用连接,不只是省了建连,更省下了 HTTPS 那笔最贵的握手账。
第五件事:我曾经对 HTTPS 和连接想当然的几个误区
这场"延迟降不下、CPU 偏高"的事故,把我对 HTTPS 和连接的一堆想当然照得清清楚楚:
| 我以为 | 实际上 |
|---|---|
| HTTPS 就是 HTTP 加个密、没多少额外开销 | TLS 握手有多 RTT 往返+非对称加密、很贵 |
| 每次新建连接发完就关很干净 | 每次都重付昂贵的 TLS 握手成本 |
| 加密开销均匀分布在每次传输 | 主要压在握手、之后对称加密很轻 |
| 延迟降不下是网络或对端慢 | 可能是每次握手堆出来的固定延迟 |
| 调接口的服务不该吃 CPU | 反复 TLS 握手的非对称加密很吃 CPU |
| 连接复用只是省点建连时间 | 更省下 TLS 握手这笔最贵的一次性成本 |
这些误区的根子是同一个:我把 HTTPS 的开销想象成了一个均匀摊在每次数据传输上的、薄薄的一层加密税,完全没意识到它的开销极不均匀——绝大部分集中在"建立连接时的那一次握手",而握手是个一次性、可复用的成本。正因为我没把"一次性的建立成本"和"每次都有的传输成本"分开看,我才会用"每次新建连接"这种方式,把那笔本该只付一次的、最贵的握手账,生生付成了每个请求一遍。把一笔"一次性建立、可反复复用"的固定成本,误当成"每次使用都不可避免"的成本,从而不去复用、每次重建,是这类性能浪费的共同根源。
第六件事:写网络调用、排查"延迟降不下/CPU 偏高"时,我现在的自检习惯
现在每当我写高频网络调用、或排查"延迟有个降不下去的固定底",我都会先盯住"连接有没有被复用"。先看清新建 vs 复用的成本差:
然后用这张自检图决定网络调用怎么写:
配套地,我把"全局复用客户端"固化成了规范,杜绝在请求路径里新建:
// 规范:HTTP 客户端全局单例, 创建一次、全程复用, 内部连接池自动复用 TLS 连接
public final class Http {
public static final HttpClient CLIENT = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2) // 多路复用
.connectTimeout(Duration.ofSeconds(5))
.build();
private Http() {}
}
// 调用处: Http.CLIENT.send(req, ...); 千万别在方法里 newHttpClient() 每次新建
而排查一个"延迟有固定底/CPU 偏高"的网络调用时,我固定先确认是不是没复用连接:
# 排查清单:网络调用延迟降不下/CPU 偏高, 先怀疑每请求都在握手
# 1. 抓包看每个请求前是不是都有 ClientHello/ServerHello(完整 TLS 握手)
tcpdump -i any port 443 -w cap.pcap # 用 Wireshark 看 TLS 握手频率
# 2. 看目标服务的连接是长连接还是大量短连接(频繁新建/关闭)
ss -tn dst :443 | head # 同一目标是否反复新建连接
# 3. 看本机到该服务的 TIME_WAIT 是否大量堆积(每次关连接的副作用)
ss -tan | grep TIME-WAIT | wc -l
# 若每请求都握手/大量短连接/TIME_WAIT 堆积 -> 没复用连接, 改全局复用客户端
这套习惯的精髓,是"高频调用全局复用客户端开 keep-alive 连接池、再用 HTTP2/TLS1.3/会话复用进一步省握手、抓包确认不再每请求握手"。它让我从"每次新建连接发完就关",变成了"复用连接把握手摊薄到一次"——核心始终是:HTTPS 是 HTTP over TLS,在能用这个加密通道收发业务数据之前客户端和服务端必须先完成一套 TLS 握手,而这套握手是一笔相当昂贵的成本——它需要多个 RTT 往返来协商加密套件交换密钥验证证书(带来稳定的握手延迟),还要做计算量很大很吃 CPU 的非对称加密运算(验证证书签名和密钥交换),但这笔昂贵成本是建立连接时的一次性成本只在建立 TLS 连接那一刻发生、一旦连接建好后续在这条连接上收发再多请求都不需要再握手而是直接用已协商好的对称密钥高速传输(对称加密轻得多);所以如果客户端每次请求都新建连接用完就关(比如在请求路径里每次 new 一个 HTTP 客户端、没用连接池没开 keep-alive),就等于把这笔本该整条连接只付一次的握手成本变成了每个请求都重新支付一遍、高频调用下延迟里堆满重复握手往返 CPU 上堆满重复非对称加密运算;正解是别让每个请求都新建连接而要复用已经握手好的连接把 TLS 握手成本摊薄到一次——用带连接池的客户端全局共享(创建一次全程复用、底层连接池自动保持并复用长连接)、开启 HTTP keep-alive 让连接发完一个请求别关留给后续复用,还可以用 TLS 会话复用 session resumption(session ID/ticket 在重连时复用之前协商的密钥跳过完整握手)、升级 HTTP/2 让多个请求多路复用一条连接天然只握一次手、升级 TLS 1.3 把握手从 2-RTT 砍到 1-RTT 会话复用甚至 0-RTT;更一般地很多东西都有一笔固定的一次性建立或准备成本(建立连接、初始化、预热、加载、编译),这类成本的设计意图就是付一次用很多次、只要建立一次然后复用就被摊薄得微不足道,可一旦每次用都重新建立一遍(数据库 Redis 连接每次新建、每个任务都新建线程、每次都 new 昂贵对象如 ObjectMapper 或 Pattern.compile、每请求重新加载配置证书模型、缺乏预热的冷启动)就会被高频重复支付把一个本该被摊薄的固定成本变成随使用频率线性叠加的变动成本频率越高浪费越惊人,所以面对任何有建立初始化准备环节的东西都要识别出那笔一次性成本并想办法建立一次反复复用(连接池线程池单例缓存预热)而不是每次都从头重来。
我立下的几条规矩
这场"每次请求重付 TLS 握手"的事故,换来了我写网络调用时,刻进骨子里的几条铁律:
- HTTPS 的 TLS 握手很贵:多 RTT 往返 + 吃 CPU 的非对称加密。
- 握手是建立连接的一次性成本,连接建好后续请求不再握手。
- 每次新建连接 = 每个请求重付一遍最贵的握手账。
- HTTP 客户端全局复用、开 keep-alive 连接池,别每次 new。
- 用 HTTP/2、TLS 1.3、会话复用进一步削减握手开销。
- 延迟有固定底/CPU 偏高,先抓包看是不是每请求都在握手。
- 一切一次性建立成本(连接/线程/重对象/加载)都要复用别重建。
附:一段 HTTP 客户端复用与握手优化的对照清单
最后留一段我自己优化 HTTPS 高频调用时照着用的对照清单:
// ❌ 危险:在请求路径里每次新建客户端/连接, 每个请求都完整 TLS 握手
public String callBad() {
HttpClient c = HttpClient.newHttpClient(); // 每次 new! 每次握手!
return c.send(req, BodyHandlers.ofString()).body();
}
// ✅ 正确:客户端全局单例, 连接池复用, 握手只在首次建连时一次
private static final HttpClient CLIENT = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2) // 多路复用单连接
.connectTimeout(Duration.ofSeconds(5))
.build();
public String callGood() {
return CLIENT.send(req, BodyHandlers.ofString()).body(); // 复用底层连接
}
// OkHttp 同理:全局一个 OkHttpClient, 内置 ConnectionPool 自动保活复用
// static final OkHttpClient OK = new OkHttpClient(); // 别每次 new!
/* 进一步削减握手:
* - keep-alive: 默认开, 别主动 Connection: close
* - HTTP/2: 多个请求复用一条连接
* - TLS 1.3: 握手 1-RTT, 会话复用 0-RTT
* - 会话复用: session ticket, 重连跳过完整握手
* 验证: 抓包看请求前是否还有完整 ClientHello/ServerHello;
* ss -tan | grep TIME-WAIT | wc -l 看短连接是否还在大量堆积
*/
这段清单的核心就一句:HTTP 客户端全局复用、开 keep-alive 连接池,让 TLS 握手整条连接只付一次;再用 HTTP/2 + TLS 1.3 + 会话复用进一步压握手;最后抓包验证不再每请求握手。把"每次 new、每次握手"换成"复用连接、握手一次",那条降不下来的延迟和偏高的 CPU 就一起回落了。
写在最后
回头看,这场由"每次请求重做 TLS 握手"引发的"延迟降不下、CPU 偏高"事故,真正教给我的,远不止"复用连接"这一个技巧。它让我对"成本有'一次性的建立成本'和'每次都有的使用成本'之分;前者的设计本意是'付一次、用很多次',而我们却常常因为不去复用,把它生生付成了'每次都付一遍'",有了一次刻骨的体会。我栽跟头,是因为我把 HTTPS 那笔开销,想象成了一层均匀地、薄薄地摊在每一次数据传输上的加密税——在我的想象里,加密的成本是分散的、线性的,每传一点数据就交一点税;我完全没意识到,它的开销其实极不均匀、高度集中:绝大部分都压在"建立连接时那一次握手"上,而那一次握手,是一个付过一次就能反复受用的一次性投入;我用"每次请求新建连接、用完就关"这种最朴素的写法,等于把这笔一次性的、本该被千百次复用摊薄的投入,在每一次请求里都从零重新投入了一遍。这让我领悟到一个关于"一次性成本与复用"的深刻认知:系统里的成本,从来不是均质的;有一类成本是"建立成本"——建立一个连接、初始化一个对象、预热一个缓存、编译一段代码——它们的共同特征是"建立"很贵,但"建立之后的使用"很便宜,而且建立的成果可以被反复使用;这类成本的正确用法,是把它当成一笔投资:付出一次昂贵的建立,然后让尽可能多的后续使用去分摊、复用它,使得平均到每次使用的成本趋近于零;而错误的用法,恰恰是意识不到它是一次性的、可复用的,于是把"建立"和"使用"捆在一起每次都做一遍——这就把一笔可被无限摊薄的固定投资,异化成了一笔与使用频率成正比、永远摊不薄的纯消耗,用得越多,亏得越狠;所以,识别出系统里那些"贵在建立、可被复用"的成本,并刻意地建立一次、复用到底(连接池、线程池、单例、缓存、预热、长连接),是性能优化里最朴素也最高杠杆的一招。这给了我一种面对"一切'反复做某件有固定准备成本的事'之事"时的本能:每当我发现自己在高频地重复做某件事,我都会问"这件事里,有没有一笔'建立/准备'的成本,是我每次都在重付、但其实只需付一次就能复用的?我能不能把它建立一次、然后复用"——识别一次性建立成本、建立一次复用到底、别每次重建;"把贵在建立的一次性成本复用摊薄",是优化 HTTPS 调用、也是榨干一切高频操作性能的关键。认清 TLS 握手贵且是一次性成本、要复用连接和会话来摊薄——这,是我用一次"调接口延迟降不下、CPU 莫名偏高"的事故,换来的、关于网络、也关于如何把一次性成本复用到底的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写高频 HTTP 调用时,把客户端提成全局单例、开上 keep-alive,而不是在每个方法里新建一个连接,那我对着那条降不下来的延迟曲线和抓包里一遍遍重复的握手熬的那个下午,就值了。
—— 别看了 · 2026