HTTP、TLS与连接池:一次请求的时间、信任和提交边界
先修 TCP 字节流 与 DNS/连接生命周期。本页主讲协议与客户端运行库;Spring 如何接收、分发和传播上下文留在 Web 请求链,不重复框架课程。
协议依据:RFC 9110 HTTP 语义、RFC 9112 HTTP/1.1、RFC 9113 HTTP/2、RFC 8446 TLS 1.3。实现固定 OpenJDK jdk-17.0.12+7,不是当前生产安全配置推荐表。
deadline 约束用户意图,不是某一次读
展开一次 URL 调用:排队/借连接 → DNS → TCP → TLS/ALPN → 发头和 body → 首字节/响应头 → 完整 body → 解析/业务校验 → 释放资源。有池命中时可以跳过建连与解析;有重定向、认证、库重试时可能重复若干阶段。监控只有一个“HTTP 耗时”不足以区分它们。
500 ms 总预算,排队 200 ms、建连/TLS 100 ms,则响应与恢复只剩 200 ms,而不是每一步再获得 500 ms。用单调时间计算 remaining = deadline - now;接纳、等待和每次重试都消耗同一预算,预算不足就拒绝开始新尝试。跨进程传递剩余预算还要考虑时钟和网络消耗,不能直接交换不校准的本机 nanoTime。
socket read timeout 通常限制一次阻塞等待,不含排队、解析与所有重试;不断来一个字节的慢 body 可能持续拖延总完成。客户端库的 request/connect timeout 也有自己的覆盖范围。JDK HttpClient 的 connectTimeout 不是已有连接的池等待上限;流式 BodyHandler 的返回更不是总 body 完成时点。需要在应用边界把 body 消费/关闭及后续解析纳入 deadline,不能仅包住取得响应头的 Future。
TLS 验证的是传输身份,不是业务权限
TLS 将机密性/完整性与对端身份绑定到握手。应分别验证可信根到叶子的证书链、有效期/用途与目标主机名;信任链有效但 SAN 不匹配 URL 名称仍不该通过。SNI 用于告诉服务器目标名称,不代替主机名验证;ALPN 协商 h2/http/1.1,也不代表应用授权。信任某个根并非自动完成撤销策略,运维仍需明确自己的验证配置。
不能为连通性关闭 TrustManager 或 hostname verifier。双向 TLS 可验证客户端证书,但“有证书”不代表它有权读取某租户对象;应用仍需身份映射和对象授权。DNS 指向攻击者地址时,正常主机名和信任链检查是另一道独立边界,不能以 DNS 查询成功代替它。
会话恢复不等于复用同一 TCP 连接;两者减少的是不同阶段。TLS 1.3 协议允许的 0-RTT 早期数据存在重放风险,不能用它证明不可逆写操作安全;这也不意味着本页 JDK 客户端默认启用了 0-RTT。TLS 握手成功更不代表服务端已处理 HTTP 请求。
HTTP/1.1 与 HTTP/2:复用改变了资源单位
HTTP/1.1 的响应边界由方法/状态码、Content-Length、chunked 或关闭界定,不是“读到暂时没字节”。错误地少读一段就把连接放回池,会让下一个请求读到前一个响应残片;不完整或歧义消息通常必须丢弃连接。持久连接可串行服务多个请求;HTTP/1.1 pipelining 的有序响应会产生队头阻塞,常见客户端并不靠它实现任意并行。
HTTP/2 把多流复用到连接上,流有自己的状态、窗口与取消,但 TCP 丢包仍可能让同连接所有流等待,连接级窗口、带宽和服务端最大并发流仍有限。一个连接断开使很多流共同失败。连接池 100 不能无条件解释为 最多 100 个在途业务;要分别计连接、stream、待接纳任务、业务 worker 和下游配额。
200 是协议状态,不自动证明业务 body 校验成功;202 明确只是接受处理,需查询异步终态。304/HEAD 等特殊响应不能按普通 body 规则处理。网关返回 504 只表示它的等待边界,不能反推后端事务回滚。
响应头、响应体与归还资源
JDK 的 BodyHandlers.ofString/ofByteArray 聚合完整 body 后才完成相应结果;ofInputStream 可以在响应头可用时交付流,后续 read 仍会阻塞或失败。流返回后的 deadline、消费上限与 finally close 应由调用方明确负责。大 body 不应为“方便完成”而无限聚合到堆中。
读取完整 body 有利于复用,但遇到不可信超大/慢 body 可以主动关闭并放弃复用。close 的意义是释放该响应资源,是否保留底层连接取决于协议状态与库,不是“总能归还一个干净连接”。HTTP/2 取消 stream 也不是业务撤销,服务器可能已经提交。
调用链:HttpClientImpl.send/sendAsync → MultiExchange(过滤器、多个 Exchange、尝试/异常)→ Exchange → HTTP/1 或 HTTP/2 连接实现 → SSLFlowDelegate(TLS 数据流)→ body subscriber。定位失败先确认在哪一层及 body 是否完成,再读源码;不能把 Apache/OkHttp 的池参数直接套到 JDK 实现。JDK 17 HttpClient 本身不提供后续版本的 close API,实验应关闭响应体并限制进程寿命,而不是编造可调用的方法。
重试与不可判定结果
请求体已送到服务端 → 业务提交 → 回响应前断开:客户端失败而服务端成功是允许的组合。稳定业务键、请求摘要、持久终态与查询接口才能恢复;新键重发扣款可能重复效果。同键不同语义需要冲突裁决,事务方案见 业务幂等正文。
协议中的 safe/idempotent 与库自动重试规则不完全相同。GET 也可能昂贵;PUT 在 HTTP 语义上幂等,不保证任意客户端每次都会自动重发它。请求 body 是否可重放、是否已发送、异常分类、取消、尝试上限与剩余预算都影响实现。
连续原码:MultiExchange.java L477–L493,仅统一公共缩进。
/** Returns true is given request has an idempotent method. */
private static boolean isIdempotentRequest(HttpRequest request) {
String method = request.method();
return switch (method) {
case "GET", "HEAD" -> true;
default -> false;
};
}
/** Returns true if the given request can be automatically retried. */
private static boolean canRetryRequest(HttpRequest request) {
if (RETRY_ALWAYS)
return true;
if (isIdempotentRequest(request))
return true;
return false;
}该内部函数只选择 GET/HEAD,不能将其名字当成 RFC 幂等方法全集;RETRY_ALWAYS 是实现属性控制,不是业务去重承诺。光看到允许还不够,要看异常入口。
连续原码:同文件 L525–L554。
if (cancelled && !requestCancelled() && t instanceof IOException) {
if (!(t instanceof HttpTimeoutException)) {
t = toTimeoutException((IOException)t);
}
} else if (retryOnFailure(t)) {
Throwable cause = retryCause(t);
if (!(t instanceof ConnectException)) {
// we may need to start a new connection, and if so
// we want to start with a fresh connect timeout again.
if (connectTimeout != null) connectTimeout.reset();
if (!canRetryRequest(currentreq)) {
return failedFuture(cause); // fails with original cause
}
} // ConnectException: retry, but don't reset the connectTimeout.
// allow the retry mechanism to do its work
retryCause = cause;
if (!expiredOnce) {
if (debug.on())
debug.log(t.getClass().getSimpleName() + " (async): retrying...", t);
expiredOnce = true;
// The connection was abruptly closed.
// We return null to retry the same request a second time.
// The request filters have already been applied to the
// currentreq, so we set previousreq = currentreq to
// prevent them from being applied again.
previousreq = currentreq;
return null;
} else {前面的 retryOnFailure 只在 requestCancelled() 为 true 时停止重试;这个条件读取 interrupted 标记,不是查询返回给调用方的 Future 是否已取消。同文件的 cancel L274–L289 在交换尚未取消且收到 cancel(true) 时设置该标记并尝试取消交换。反例是 cancel(false):Future 可以进入取消状态,但不会由此设置该交换标记,底层工作及符合条件的重试仍可能继续。即使 cancel(true) 已传递,也不撤销服务端业务效果,更不保证所有底层工作立即停止。
未命中上述取消标记时,retryOnFailure 只接受 ConnectionExpiredException 或启用 RETRY_CONNECT 的 ConnectException。摘录中超时转换与重试是不同分支;非 ConnectException 才在这里检查方法并重置 connect timeout,ConnectException 不重置它。expiredOnce 限制这条恢复路径,返回 null 让外层重试;后续重复失败转异常。若删掉标记/次数检查,会把应结束的请求继续放大。
这不是全部重定向/认证的次数规则,也不是一次 send 只会产生一次网络尝试的证明。应用、代理、库各至多 3 次时,三层最坏可产生 27 次尝试;应记录应用意图 ID、网络尝试 ID 与实际提交次数,给恢复共享预算而非每层重新计时。
池容量与恢复:不能把排队转移到下游
池复用降低握手与端口开销,但等待借连接也占请求预算。已达下游承载极限时,只增池容量会把客户端排队搬到服务端,尾延迟和超时重复请求可能更严重。限总在途、按目的地/租户隔离、拒绝超预算任务,监控 pending、leased/idle、stream 数、body 未完成数与完整请求耗时。
架构师变化条件:下游平均 40 ms 变为 2 s,500 req/s 的平均在途按同边界 Little 定律从约 20 变 1000。不能先加到 1000 个连接而不校验服务端预算。流式 body 长期占位应单独限额;跨区切换还要错峰刷新旧 DNS/连接,不能全体同时重建 TLS 再重试。
实验:提交后丢响应与慢 body
公开 runner 的 http 模式用原始 loopback HTTP/1.1 服务和 JDK HttpClient:服务完整读到 POST body 后递增教学计数器并关闭、不发响应;断言客户端异常、计数器仍为 1。这个计数器是单进程提交标记,不是数据库持久性或完整幂等实现。
第二场景发出 Content-Length: 2 的头和首字节 A,在闩锁上等待;ofInputStream 已返回、A 可读,但完整 body 的 Future 在有界等待内未完成。主线程明确放行后才收到 B 与 EOF;finally 关闭流、socket、线程池。这是用同步控制制造慢 body,不靠测某个毫秒延迟断言网络性能。HTTP/2、真实连接池压测和 TLS 本次未运行。
TLS 负例的未运行协议:用临时专用 CA/truststore 与正确测试域名对照不可信、过期、SAN 不符证书;分别断言验证失败且无 HTTP 业务调用,不更改系统证书库、不关闭验证。链失败与主机名失败应分开定位。本页不把这份步骤写成实际握手记录。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。