1. 开头:为什么我们要关心 cronet 的连接复用
我在做移动端网络优化时,最常被问的一个问题就是:“为什么我们用了 cronet,感觉请求还是那么慢?” 排查一圈之后,往往发现根子不在 DNS 解析、也不在 TLS 握手,而在连接复用没真正生效。cronet 作为 Chromium 网络栈的移动端封装,最大的卖点之一就是多路复用和连接池管理,尤其是对 HTTP/2 和 QUIC 的支持,能做到多个请求共享同一条物理连接。但“支持”不等于“默认一定复用”,很多开发者以为引入了 cronet 就自动获得连接复用收益,结果测试发现每条请求都在重新建连,白交了握手开销。
这篇文章想聊的就是一件事:怎么在项目里准确判断 cronet 是否真的复用了连接。我结合自己接入和调优 cronet 的实操经验,把判断方法、抓日志技巧、代码侧辅助验证,以及那些坑爹的“假复用”情况都整理出来。无论你是刚把 cronet 接进来,还是已经在生产环境跑了一阵子,这篇文章应该都能给你一些有价值的排查思路。
2. 连接复用是什么,cronet 为什么值得我们关注
2.1 从一次网络请求的生命周期看复用
在没有复用的情况下,一次 HTTPS 请求大致要经历这些阶段:DNS 解析、TCP 三次握手、TLS 握手(可能还包含证书校验和密钥协商),然后才是发送 HTTP 请求和接收响应。如果服务端和客户端都支持 TLS 1.3,握手可以压缩到 1-RTT,但不管怎样,这几步绕不开。
连接复用的思路很简单:既然我手头已经有一条和服务器协商好的连接,为什么不直接在这条连接上发送下一次请求?在 HTTP/1.1 时代,Keep-Alive 已经实现了这个能力,但它是串行的——同一时刻只能处理一个请求。到了 HTTP/2,连接上可以跑多个并发流,这就是多路复用(Multiplexing)。而 QUIC 更进一步,连接标识采用 Connection ID,即便客户端 IP 或端口变了,只要 Connection ID 不变,连接还可以继续用。
cronet 之所以高效,是因为它把 DNS 缓存、连接池、QUIC 会话管理、HTTP/2 流调度整合在了一套完整的网络栈里。不过连接池不是无限大的,每个服务器(按 host:port 区分)会维护一组连接,当请求到达时,调度器会优先复用闲置的、健康的连接。如果连接已经断开、超时或者被对端关闭,就必须新建连接。
2.2 连接复用能解决哪些实际痛点
移动端网络环境比桌面端恶劣得多:网络切换频繁,Wi-Fi 和蜂窝数据之间跳跃,弱网条件下丢包严重,NAT 超时导致连接被静默销毁。这些问题都会让连接建立成本变得非常高。如果不能合理复用连接,每一次请求都会重复支付这些成本,尤其在页面初始化时同时发出 5-10 个请求的场景下,性能差距会被急剧放大。
复用连接带来的收益是显而易见的:
- 省掉重复的 DNS 解析和 TCP/TLS 握手,整体请求时延能降低 30%-50%。
- 减少握手阶段的网络交互次数,在 RTT 较高的网络环境下收益更明显。
- 减少因频繁建连导致的端口资源消耗和 TIME_WAIT 堆积。
- 对 QUIC 来说,0-RTT 重连能力建立在连接复用的基础上,如果每次都重新握手,0-RTT 就无从谈起。
2.3 cronet 的连接管理机制
cronet 内部维护了一个连接池,这个连接池的容量、空闲超时时间与原生 Chromium 保持一致。默认情况下,连接池会按 HostResolver 解析出的 IP 建立连接,每个连接都能承载多个并发的 HTTP/2 Stream。对于 QUIC,cronet 还维护了服务器支持度缓存,如果一个服务器之前成功建立了 QUIC 连接,后续的请求会优先尝试 QUIC。
但“优先尝试”不等于“一定复用”。cronet 有一套复杂的健康检查和降级机制,比如连接空闲超过一定时间(一般按 Chromium 的默认值),连接会被关闭;网络切换导致路径变化时,TCP 连接可能仍然存活,但 QUIC 会根据 Connection ID 判断是否继续复用;如果服务器返回特定的错误码或 GOAWAY 帧,连接也会被主动释放。
正因如此,我们不能想当然地认为“已经用了 cronet,优化就到位了”。需要通过客观手段去验证,连接是否真的被复用了。
3. 在 cronet 中判断连接是否复用的三种主流方法
3.1 方法一:从请求回调中获取连接信息
cronet 提供了 RequestFinishedInfo 接口,通过 RequestFinishedInfo.Listener 可以拿到每次请求完成后的详细信息。这个信息里包含了连接类型(是否通过 QUIC、是否复用连接等关键标志)。
用起来大概是这样:
CronetEngine engine = new CronetEngine.Builder(context) .enableQuic(true) .build(); RequestFinishedInfo.Listener listener = new RequestFinishedInfo.Listener(executor) { @Override public void onRequestFinished(RequestFinishedInfo requestInfo) { RequestFinishedInfo.Metrics metrics = requestInfo.getMetrics(); if (metrics != null) { // 连接复用相关信息经常藏在 connectionInfo 中 // 不同版本 API 略有差异,建议先打印整个 Metrics Log.d("CronetReuse", "connInfo=" + metrics.toString()); } } }; engine.addRequestFinishedListener(listener);这里有一个需要留意的点:RequestFinishedInfo.Metrics里可能包含connectionInfo,其中有connectTiming、connectionType等信息。不同 cronet 版本,字段暴露程度不一样,有的版本并没有直接暴露“是否复用连接”的布尔值,这时候就需要配合下面的方法二来综合判断。
另外,Metrics中有一个connectionType字段,它的取值大致有QUIC、HTTP/2、HTTP/1.1。如果请求走了 HTTP/2 或 QUIC,且该连接之前已经建立过,那基本可以认定存在复用的可能。当然,只看连接类型还不够,因为即使走了 HTTP/2,也可能是新建立了一条连接,而不是复用了旧连接。
结合官方文档和实际测试,判断复用最直接的依据是connectTiming是否存在。如果connectTiming为空或耗时接近 0,说明没有经过完整的 TCP/TLS 握手,大概率是复用了已有连接。这个思路和方法二可以相互印证。
3.2 方法二:开启 cronet 的 NetLog 抓取连接事件
NetLog 是 Chromium 网络栈内置的日志系统,cronet 也开放了对应的接口:CronetEngine.startNetLogToFile。它会把内部网络事件序列化成 JSON 格式,其中包含了连接创建、连接池分配、Stream 打开、QUIC 会话建立等关键事件。通过分析 NetLog,我们能非常清楚地看到每一条连接从建立到复用的全过程。
先看一段开启 NetLog 的示例:
CronetEngine engine = new CronetEngine.Builder(context).build(); String logPath = context.getCacheDir().getAbsolutePath() + "/cronet_netlog.json"; boolean started = engine.startNetLogToFile(logPath, true); if (started) { // 执行若干请求,然后停止 } // 注意:一定要测试完成后停止抓取 engine.stopNetLog();startNetLogToFile第二个参数是logAll,设为 true 会把所有网络事件都写进去。这个方法有个坑,如果 logAll 为 false,很多细节事件会被过滤掉,导致看不到连接复用的完整链路。所以判断连接复用时,务必设置 true。
NetLog 文件生成后,需要用 Chrome 自带的chrome://net-export/在线查看器或netlog_viewer工具打开。工具会解析 JSON,并展示事件时间线。
3.3 方法三:在代码层面主动探测连接池状态
前两种方法都是在“请求完成后”去事后分析。如果你想在请求发出前就知道“这条连接是否会被复用”,cronet 官方 API 没有直接开放“查询连接池是否有可用连接”的方法。不过我们有变通方案。
思路是利用 cronet 的UrlRequest回调顺序。UrlRequest.Callback中有一个onConnected回调,它在连接建立完成后被调用。这个回调足以区分“新建立连接”还是“复用已有连接”:如果请求触发onConnected回调时,耗时接近 0,并且紧跟在另一个请求的回调之后,没有明显的跨网络交互间隔,我们可以推断是复用了连接。
此外,也可以通过CronetEngine的getVersionString等接口确认当前使用的 cronet 版本,方便排查版本差异导致的连接复用特性变化。
不过坦白说,最准确、最可靠的还是抓 NetLog,因为它是 Chromium 网络栈的“黑盒探针”,能够把底层事件一五一十暴露出来。代码层面的回调判断只能作为补充验证。
4. 实操:用 NetLog 精确验证连接是否复用
4.1 搭建一个可复现的测试场景
判断连接复用的前提条件,是你能在一个可控环境里发起多组请求,确保它们都指向同一台服务器。我自己常用的方式是写一个简单的 Demo,用同一个CronetEngine实例,依次发起 3 次对同一 API 的 GET 请求,请求之间间隔几百毫秒,然后抓日志分析。
这里一个容易忽略的细节:不要用CronetEngine的两个不同实例去发请求,因为连接池是 engine 级别的,跨 engine 不存在连接复用,这一点必须注意。
示例代码:
CronetEngine engine = new CronetEngine.Builder(context) .enableHttp2(true) .enableQuic(true) .build(); engine.startNetLogToFile(logPath, true); for (int i = 0; i < 3; i++) { UrlRequest.Builder builder = engine.newUrlRequestBuilder( "https://your-api.example.com/data", callback, executor); builder.build().start(); Thread.sleep(500); } engine.stopNetLog();4.2 抓下来的 NetLog 里应该看什么
把生成的 JSON 文件丢进 netlog_viewer 后,我们需要关注的事件类型大致有下面几类:
URL_REQUEST_START_JOB:标识一个请求开始进入网络栈。HTTP_STREAM_REQUEST_BOUND_TO_JOB:请求绑定到了一个 Stream Job。HTTP2_SESSION_WINDOW_UPDATE/HTTP2_SESSION_SEND_HEADERS:说明请求通过 HTTP/2 Session 发送。QUIC_SESSION_CONNECTED/QUIC_SESSION_STREAM_FRAME_SENT:说明走的是 QUIC 会话。SOCKET_POOL_STALE_JOB/SOCKET_POOL_CLOSED_SOCKET:连接池相关状态变化。
当同一个连接被复用时,第二个请求开始后,你不会看到新的HOST_RESOLVER_IMPL_JOB或TRANSPORT_CONNECT_JOB事件。在时间线上,请求 1 会触发 TCP 连接和 TLS 握手,然后出现HTTP2_SESSION_SEND_HEADERS;请求 2 和请求 3 只会在同一HTTP2_SESSION上发送 HEADERS 和 DATA,而不会再次出现TRANSPORT_CONNECT_JOB。
如果是 QUIC,连接复用的标志就更明显了:第二个请求发送时,日志里不会出现QUIC_SESSION_CONNECTED,而只会出现QUIC_SESSION_STREAM_FRAME_SENT。这意味着请求附着在了已有 QUIC session 上。
4.3 判断复用与未复用的事件序列对照
我整理了一份自己实测时的对照表,方便你快速判断:
| 复用场景 | 首次请求日志 | 第二次请求日志 |
|---|---|---|
| HTTP/2 连接复用 | DNS解析、TCP连接、TLS握手、HTTP2_SESSION_CONNECTED | 仅 HTTP2_SESSION_SEND_HEADERS,无 TRANSPORT_CONNECT_JOB |
| QUIC 连接复用 | DNS解析(或使用缓存)、QUIC_SESSION_CONNECTED | 仅 QUIC_SESSION_STREAM_FRAME_SENT,无新会话连接 |
| 未复用(新建连接) | 同首次请求 | 再次出现 DNS解析、TCP连接、TLS握手、QUIC_SESSION_CONNECTED |
如果第二次请求依然出现了完整的TRANSPORT_CONNECT_JOB和HTTP2_SESSION_CONNECTED,那几乎可以断定连接没有复用。这个时候不要急着改代码,先检查是不是请求参数或网络环境导致的连接重建。
4.4 关注连接关闭事件
除了关注连接建立,也要关注连接关闭。NetLog 中常见的关闭事件包括:
SOCKET_POOL_CLOSED_SOCKET:连接池主动关闭了连接。HTTP2_SESSION_CLOSE:HTTP/2 会话关闭。QUIC_SESSION_CLOSE:QUIC 会话关闭。
如果每次请求之间,连接都被关闭了,那自然就没有复用。导致这种情况的原因可能是服务端主动断开、Keep-Alive timeout 太短、连接池最大空闲时间策略等。此时,日志中会显示HTTP2_SESSION_CLOSE之后紧跟一个新连接的建立。排查思路就变成了:谁在关连接?为什么关?
5. 常见问题与排查技巧实录
5.1 为什么 NetLog 里看不到连接复用
这是最高频的问题。我遇到过很多次,日志里两次请求都有完整的握手事件。逐一排查下来,通常原因是:
- 服务器不支持 HTTP/2 或 QUIC,cronet 自动降级到了 HTTP/1.1。HTTP/1.1 虽然支持 Keep-Alive,但 cronet 默认可能不会在多个请求之间复用同一条连接,或者只在一个请求结束后短暂保持连接。这种情况在日志里表现为每次都出现 TCP 连接和 TLS 握手。解决方法是确认服务器协议支持情况。
- 请求之间的间隔过长,连接空闲超时被关闭。Chromium 的默认连接空闲超时比较长,但如果你用的是私有服务器且配置了较短的 Keep-Alive Timeout,就可能出现连接被服务端先关闭的情况。
- 网络切换或者代理类应用导致连接路径改变。移动端操作系统在某些场景下会触发网络栈重置,cronet 监测到网络变化后,会主动销毁旧连接。
- 被测服务器返回了 GOAWAY,导致客户端被迫重建连接。
遇到这种情况,先别怀疑 cronet,而应该顺着日志逐层找,看看是哪一方主动关掉了连接。
5.2 为什么代码回调里看到的连接类型是 HTTP/1.1
如果你在RequestFinishedInfo.Metrics里看到connectionType是HTTP/1.1,并行请求的性能瓶颈就很明显了。HTTP/1.1 复用连接时不能并发多路传输,cronet 通常会为并行请求建立多连接。此时“判断是否复用”的意义不大,因为 1.1 本身无法享受多路复用红利。
排查思路:先确认服务器是否开启 ALPN 中的 h2 支持。可以通过 openssl 命令验证:
openssl s_client -connect your-api.example.com:443 -alpn h2 -servername your-api.example.com如果输出里没有 ALPN 协议 h2,那就说明服务端不支持 HTTP/2。cronet 再强大,也只能退回到 HTTP/1.1。
5.3 为什么第二次请求还是建立了新 QUIC 会话
QUIC 会话复用有个前提:服务端必须支持 QUIC,且客户端配置了有效的 QUIC 提示。cronet 默认的enableQuic(true)只是打开了 QUIC 功能,但能否建立 QUIC 连接还取决于服务器是否在 UDP 443 端口响应 QUIC 探测包。
如果日志显示第一次请求成功建立了 QUIC 会话,第二次却重新建连,多半是第一次会话很快被关闭了。一个常见原因是服务器配置的 idle_timeout 太短,比如某些 CDN 厂商默认设置只有几十秒,一旦请求间隔超过该值,服务端就会关闭 QUIC 连接。另一个原因是客户端网络切换后,旧连接无法恢复,必须重建。
从实践角度看,QUIC 复用的判断逻辑和 HTTP/2 一致,核心都在于“第二次请求是否绑定了已有会话”。如果是新会话,日志里一定存在QUIC_SESSION_CONNECTED事件。
5.4 真伪复用的区分:别把 Keep-Alive 当成复用
我们要特别区分一下“连接没有断开”和“连接被复用”这两个概念。
如果第二次请求和第一次请求之间间隔非常短(比如 50ms),即使没有复用,连接也可能还没来得及被关闭,日志里会出现“连接仍然存活,但没有数据流”的中间态。这种情况下,第二次请求虽然可以直接在已有 TCP 连接上发送请求,但该连接可能不是从连接池中分配的,而是上层直接绑定的。严格来说,这算连接复用。但如果中间隔了很久,连接已经半关闭,则不会被复用。
我自己的习惯是,在日志里同时关注HTTP_STREAM_REQUEST_BOUND_TO_JOB事件对应的 job 是否指向同一个 session。如果两个请求绑定的 session id 一致,那就肯定是复用;如果 session id 不同,说明不是复用。
5.5 几个提升连接复用率的实战建议
经过多轮调优,我总结了以下几个比较实用的操作,能明显提高 cronet 的连接复用率:
- 确保所有请求使用同一个
CronetEngine实例,不要每次请求都创建新的 engine。 - 对同一 host 的请求尽量使用相同的协议栈配置,不要在运行期动态修改
CronetEngine参数。 - 合理设置请求并发。HTTP/2 支持并发多流,但如果并发数超过服务端设置的
max_concurrent_streams,调度器会排队,影响体验。 - 为 QUIC 配置好 alternative service 或使用支持 QUIC 的域名。如果服务器不支持 QUIC,强行开启
enableQuic(true)只会增加无谓的探测开销。 - 关注系统网络切换广播。如果 app 自己处理了网络变化,并重新创建了 engine,那必然丢失连接池。建议保留 engine,让 cronet 内部自行处理网络变化。
6. 结语:用数据验证,而不是用感觉
在实际项目里,判断连接是否复用并不是一个一次性的任务,而是一个持续监控的过程。我通常会在灰度版本里保留 NetLog 开关和RequestFinishedInfo监听,把关键指标(连接类型、连接建立耗时、复用标志)上报到后台,对比不同网络环境下的复用率。如果某一天发现某个网络运营商下的复用率骤降,多半是网络路径或 NAT 行为发生了异常,这时候再拉日志分析,定位效率会高很多。
最后再分享一个小技巧:在调试 cronet 连接复用时,别只盯着客户端日志,也记得看一下服务端的访问日志。客户端显示“连接已复用”,但服务端如果因为种种原因过早关闭连接,客户端下次请求依然会尝到连接不可用的苦果。把两端日志时间戳对齐,能更清晰地还原整个连接生命周期。这个习惯,帮我解决过好几次“看起来复用了但实际上连接已经失效”的诡异问题。