Cronet连接复用判断实战:从TCP握手到代码探针的完整方案
发布时间:2026/9/8 20:15:56 作者:尧图编辑部 阅读量:1,286

先说个真实场景之前做移动端网络专项优化线上埋点里有个链路一直不对劲域名解析很快但 connect 耗时动不动就上百毫秒。当时第一反应就是 Cronet 的连接复用没生效每个请求都在重新做TCP握手和TLS握手Push 到端上的新版本还没覆盖到全量用户没法直接看线上日志只能靠抓包和代码侧的数据一层层扒。折腾了几天把“cronet 判断是否连接复用”这件事彻底搞明白了——怎么判断、怎么精确定位、怎么从业务代码里拿证据、以及一堆文档里看不到的坑。这篇就把整个排查思路和实操方法完整写出来给同样被连接复用问题折磨的人一个参考。Cronet 作为 Chromium 网络栈的移动端封装本身是支持连接复用的而且在不同协议栈下机制还不一样。判断连接是否复用这件事最核心的价值在于如果复用生效第二次请求能省掉 TCP 握手和 TLS 握手的时间TTFB 能砍掉一大截如果没生效那就是白白浪费 RTT等于是把油门踩到底却在挂空挡。更关键的是连接复用率本身就是一个非常好的网络健康度指标能直接反映客户端和服务端之间的连接治理水平。下面从原理、手段、代码实现到踩坑一层层说清楚。1. 先搞清楚一件事Cronet 的连接复用到底指什么搞清楚问题边界永远比动手更重要。很多人在“判断是否连接复用”这件事上卡住不是因为不会写代码而是因为脑子里对“复用”的定义是模糊的。这里必须先理清几层概念。1.1 三种“复用”别混淆连接复用在不同的协议版本里含义差别很大。很多人一上来就说“Cronet 支持连接复用”但 HTTP/1.1、HTTP/2、QUIC 这三者的连接复用机制各有侧重不能混为一谈。协议连接复用机制特点从外部观察的迹象HTTP/1.1keep-alive 连接池一条连接在同一时间只能处理一个请求请求串行复用多次请求共用同一个 TCP 连接的源端口HTTP/2多路复用一条连接并发承载多个 stream连接被多个请求共享同样的源端口上多个请求并发收发数据QUIC (HTTP/3)连接迁移 多路复用基于 UDP连接标识不再依赖四元组连接可以跨网络迁移同一连接 ID 上持续传输数据注意HTTP/1.1 的“复用”更多指的是连接池里把已建立的 keep-alive 连接捞出来再用HTTP/2 的“复用”则更彻底同一个 host 下所有请求愿意的话都可以跑在同一条连接上通过 stream 区分不同请求QUIC 的复用还要加上连接迁移这一层移动端切换 Wi-Fi 和蜂窝网络时连接可以被“续上”而不是重新建连。所以判断连接复用之前先问自己一句当前线上跑的是哪套协议如果是 HTTP/1.1就按源端口和握手次数判断如果是 HTTP/2 或 QUIC就要找连接 ID 或者 Cronet 暴露的连接标识。1.2 为什么业务层要关心连接复用有人会说连接复用不是 Cronet 自己管理的事吗应用层管那么多干嘛。实际上一旦线上出现性能问题连接复用率低往往就是罪魁祸首之一而这时候你必须有办法判断它到底有没有生效。一个真实数字正常情况下一次 TCP 握手加 TLS 握手在高延迟网络下可能消耗 2 到 3 个 RTT。如果网络 RTT 是 50ms每次请求都重新建连光握手就是 150ms 左右的额外开销。对于首页这种动辄十几个接口并发的场景如果连接不复用光是建立连接的耗时就可能把首屏拖慢几百毫秒。这不是小问题。另外每条连接不只是建连耗时有成本连接本身在客户端和服务端都要占内存、占文件描述符。如果连接复用率高同一时间活跃连接数量就少系统负载自然低。出现连接泄漏、连接数打满、局部网络异常时能不能快速判断连接是否复用直接决定了排查方向。所以连接复用判断这不是一个学术层面的技术话题而是实打实的性能工程问题。2. 判断连接复用的三种核心手段要判断连接复用最直接的无非三个层面网络报文层面、Cronet 内部日志层面、业务代码回调层面。每个层面各有优劣适用场景完全不同。2.1 抓包分析最权威但是慢工出细活抓包是判断连接复用的终极手段因为它看到的不是代码里封装过的指标而是真实网络上的报文。判断依据非常朴素如果第二个请求没有触发新的 TCP 握手SYN/SYN-ACK/ACK 三个包没有触发新的 TLS ClientHello那它就是复用了连接。实际抓包时可以这样操作# 抓取目标域名的443端口流量保存为pcap文件 tcpdump -i any -s 0 -w cronet_test.pcap host www.example.com and port 443然后把 pcap 文件拖到 Wireshark 里分析。重点看两个地方一是 “Follow TCP Stream” 里每个请求对应的 TCP 源端口是否变化二是过滤tls.handshake.type 1看 ClientHello 出现了几次。如果连续多个请求只有一个 ClientHello说明握手只发生过一次后续请求都在复用连接如果每个请求前都有 ClientHello那就是每次都在新建连接。这里有个实战技巧判断连接复用只看“第二包”的位置就行。在 Wireshark 里第一个请求的包序列里能找到 TCP 三次握手第二个请求如果还是 SMTP? 不是普通的 HTTP 请求你在请求开头往下找如果找不到握手和 ClientHello直接就说明复用了。不需要去看什么复杂的时间戳。抓包这种方法唯一的缺点是重线下压测和问题复现场景好用线上没法大规模部署而且碰上 QUIC 这类基于 UDP 的协议Wireshark 解起来会麻烦一些需要抓 UDP 443 并且看 QUIC 的 Initial 包。2.2 NetLog JSONCronet 自己的“行车记录仪”Cronet 本身是 Chromium 网络栈的封装Chromium 里有一套非常成熟的网络事件日志系统在 Cronet 里就是 NetLog。开启 NetLog 后Cronet 会把每个网络请求的事件全部写到一个 JSON 文件里包括连接建立、连接复用、代理选择、DNS 解析等所有细节。注意这里说的是标准网络日志不是别的什么。Cronet 的使用方式大致如下CronetNetLog.startLogToFile(context.getCacheDir().getAbsolutePath() /cronet_netlog.json, true); // 执行你要测试的请求 CronetNetLog.stopLogToFile();跑完测试后打开 JSON 文件搜索URL_REQUEST事件或者CONNECT_JOB里面会有source_dependency、net_error、connection_info、port_id这类字段。如果多次请求对应的port_id是同一个值那基本可以确认连接被复用了如果每次都新建port_id会一直不同或者压根没有这个字段。NetLog 里还记录了耗时时间轴可以非常精确地看到连接阶段耗时为 0 还是不为 0。不过要提醒一句NetLog 的文件很大一次简单的测试就可能产生几十 MB 的 JSON而且打开时会很卡。我建议抓 NetLog 之前先把场景缩小只跑需要验证的那几个接口不要跑完整回归。另外不同 Cronet 版本的 NetLog 字段名字可能不太一样但思路是一致的找连接标识看多个请求是否共享同一个。2.3 代码层 ConnectionMetrics面向业务的可编程方案抓包和 NetLog 都是开发调试阶段的手段生产环境里怎么判断答案是 Cronet 自身提供的RequestFinishedInfo回调。Cronet 在每一个请求结束时都会回调RequestFinishedListener里面有完整的请求和连接指标包括 DNS、连接、TLS 各阶段耗时甚至连接身份信息。这段代码是判断连接复用的核心入口后面我会单独拿出一整节来讲怎么用这里先给出一个最基础的版本让大家建立直观印象CronetEngine.Builder builder new CronetEngine.Builder(context); builder.enableHttp2(true); builder.enableQuic(true); builder.setRequestFinishedListener(new CronetEngine.RequestFinishedListener() { Override public void onRequestFinished(RequestFinishedInfo requestInfo) { RequestFinishedInfo.Metrics metrics requestInfo.getMetrics(); if (metrics null) return; RequestFinishedInfo.ConnectionInfo conn metrics.getConnectionInfo(); if (conn ! null) { // portId 是这条请求对应的连接身份的化简标识 int portId conn.getPortId(); // 根据 portId 变化可以判断是否复用 } } });这个 API 是线上可用的开销不大而且能跟业务埋点打通。我一直觉得这是 Cronet 对比其他网络库的一大优势它把连接级别的精细指标直接暴露给了业务层只要你愿意接就能拿到一套完整的网络健康数据。不过有一个关键点要记住不同 Cronet 版本的ConnectionInfo字段会有差异比如有的版本直接有getPortId()有的版本放在Metrics里有的版本还包含了协议类型写代码前先去 Android Studio 里看一眼库源码确认。3. 业务代码里如何编写可靠的“连接复用”判断逻辑既然ConnectionMetrics是最适合线上使用的判断手段那这一节就把代码写透。一个可靠的判断逻辑不能只读一个字段要先把边界情况处理干净否则统计出来的复用率会失真。3.1 前置条件请求必须真的走了网络层很多请求虽然返回成功了但根本没走网络连接——比如命中了 HTTP 缓存比如请求被上层拦截直接返回了构造的响应。这种请求的Metrics往往是 null或者缺少连接阶段的指标。如果把这种情况统计进连接复用率分母一变大复用率就会被稀释得很假。判断请求是否真的建立过网络连接可以分两步看requestInfo.getFinishedReason()一般SUCCEEDED不代表一定走网因为缓存命中也可能算成功所以这一步只能用来过滤明显失败和取消的请求。看metrics.getConnectStartMs()和metrics.getConnectEndMs()。如果这两个值有效且不为 -1说明本次请求经历了 TCP 连接建立阶段如果为 -1 或者为空说明没有新建连接那要么复用了已有连接要么根本没走网络。实操时我的习惯是先用缓存策略把“只走缓存”的场景排除再按协议区分判断口径最后才谈复用率。具体到代码上可以给请求设置WITHOUT_CACHE或直接禁用缓存这样能保证回调里的连接指标都来自真实网络请求。3.2 用“连接身份”判断复用而不是只看耗时最直观的误判方式就是拿耗时判断第二个请求快就说连接复用了。这在实验室环境可能成立但生产环境经不起推敲。即使没有复用连接如果服务端就在本地机房、网络延迟只有几毫秒TCP 加 TLS 握手也可能在 20ms 内完成反过来即使复用了连接服务端处理慢也一样会导致总耗时长。所以耗时只能作为辅助信号不能作为判断依据。正确的姿势是拿连接身份做对比。所谓连接身份在各协议下表现为不同的东西HTTP/1.1 下是 TCP 连接的源端口HTTP/2 下是连接 IDQUIC 下是连接标识而 Cronet 的ConnectionInfo帮我们把这些统一成了一个portId这是 Cronet 内部的连接化简记号并不是简单的 TCP 端口。判断逻辑如下请求 A 完成记录 A 的 host 和 portId。请求 B 完成记录 B 的 host 和 portId。如果 A 和 B 的 host 相同且 portId 相同判定为连接复用。如果 B 的 portId 无效比如 -1但 B 的连接阶段耗时为 0那么有可能是复用也可能是协议栈没暴露连接信息这时候只能降级用抓包或 NetLog 确认。这里有一个容易混淆的地方HTTP/1.1 下 Cronet 返回的 portId 行为可能和 HTTP/2 不一样有的版本在 HTTP/1.1 下压根返回 -1。所以判断之前先确认协议版本至少要知道自己线上的服务端支持不支持 HTTP/2否则代码写得再漂亮拿到的字段也是脏的。3.3 一套可落地的“连接复用探针”实现下面给一套我一直在用的探针实现Kotlin 版支持按 host 维度记录最近一次连接身份避免并发请求互相覆盖。核心思路是用一个 Map 缓存每个 host 最近一次的连接身份然后对比下一次请求过来时是否命中。class ConnectionReuseProbe { data class ConnectionIdentity( val host: String, val portId: Int, val connectionTimeMs: Long ) private val hostIdentityMap ConcurrentHashMapString, ConnectionIdentity() fun onRequestFinished(requestInfo: RequestFinishedInfo, url: String) { val metrics requestInfo.metrics ?: return val host Uri.parse(url).host ?: return // 过滤没有真正走完整网络请求的情况 val connectStart metrics.connectStartMs if (connectStart 0) return val conn try { metrics.connectionInfo } catch (t: Throwable) { null } val portId conn?.portId ?: -1 val identity ConnectionIdentity(host, portId, System.currentTimeMillis() - metrics.requestStartMs) val previous hostIdentityMap.put(host, identity) val reused previous ! null previous.portId ! -1 previous.portId portId if (reused) { // 复用成功后可以在这里埋点或者只打日志 Log.d(ReuseProbe, connection reused, host$host, portId$portId) } else { Log.d(ReuseProbe, new connection, host$host, portId$portId) } } }这段代码有两个设计细节值得说明。第一用 ConcurrentHashMap 而不是单变量缓存是因为 Cronet 是多线程并发回调的同一个 host 下可能同时有多个请求在跑如果只保存一个“上一次请求”并发场景下会被后完成的请求覆盖导致误判。第二过滤条件是connectStart 0也就是如果这个请求压根没进入连接建立阶段就不参与判断避免缓存命中情况污染统计。connectionTimeMs字段我保留了虽然没直接参与判断但在日志里和总耗时对照着看很有用。这段探针可以直接挂到 CronetEngine 的RequestFinishedListener里。注意探针本身只记录数据不要做耗时操作比如写数据库、上报网络请求都要异步否则会影响 Cronet 的回调性能。3.4 把“判断”变成“指标”复用率的统计口径单看一次请求是否复用意义有限真正有价值的是把复用率变成一个稳定的线上指标。这时候统计口径非常重要我踩过坑先说结论分母只统计“确实走了网络连接阶段”的请求也就是 connectStart 有效、未被缓存命中、未被取消的请求。分子只统计“明确判定为复用”的请求只有 protocol 和连接身份都对上才算。要不要把 HTTP/1.1 场景单独拆分一定要。要么你确认服务端全部支持 HTTP/2要么就在指标里加一个 protocol 维度。不然 HTTP/1.1 和 HTTP/2 混在一起统计复用率会被协议差异干扰。实现上很简单就是在探针回调里加两个计数器按 host 或 protocol 分组后上传埋点系统。我个人建议至少按 host 维度统计因为不同 host 的服务端 keep-alive 配置不一样复用率天差地别只有拆分才能暴露问题。4. 实操记录从“判断不准”到“定位根因”的一次完整排查光讲理论没意思我分享一个真实排查过程。当时问题表现是线上埋点里某个核心接口的 connect 耗时 P90 从 20ms 暴涨到 150ms页面首屏直接劣化。第一反应是连接复用失效但代码里没有任何连接复用的统计只能从抓包开始一层层查。4.1 场景描述和第一步验证复现环境是 Android 模拟器 公司测试环境用 tcpdump 抓无线网卡流量只保留目标域名 443 端口的数据。第一步验证非常朴素连续请求两次目标接口第二次请求如果出现 ClientHello说明连接没复用。实测结果第一次请求有完整的 TCP 三次握手和 TLS 握手第二次请求只有 TCP 握手没有新的 ClientHello。表面上看好像“复用了一部分”但地址栏? 不Wireshark 里第二次请求的客户端源端口已经变了。不懂的人看到“没有 ClientHello”可能会判断成复用成功实际上源端口发生了变化说明之前的 TCP 连接在第一次请求结束后就被服务端关闭了第二次请求重新建立了 TCP 连接只是 TLS 用 session resumption 恢复了会话跳过了完整握手。这种情况比完全不复用强一点但每次都要多一次 RTT 的 TCP 握手依然有优化空间。这个阶段得到的结论是连接没有真正复用只是 TLS 会话缓存生效了。如果只看 TLS 握手次数很容易误判为复用成功——这也是很多人排查了半天还说“没问题”的原因。4.2 抓包发现真相服务端在偷偷断开连接继续深挖用 Wireshark 的 TCP 分析功能看连接关闭过程。结果发现服务端在第一次请求返回后大约 1 秒就发送了 FIN 包主动关闭连接根本没有等 keep-alive 超时。为什么查下来是中间的网络网关配置了空闲连接超时阈值比应用层的 keep-alive 超时更短。客户端以为自己还能用连接实际对端早就把连接关了等下一次请求进来再检测到连接异常然后重新建连白白多一次 RTT。这是连接复用率低最常见的坑之一不是 Cronet 不想复用而是服务端/网关的 keep-alive 超时设置太短或者负载均衡策略把空闲连接踢掉。Cronet 的连接池里存了缓存连接但这条连接已经无法使用必须在请求时通过探测机制发现并丢弃这个流程本身就会带来额外开销。代码层面也做了确认在探针里打印 connectStart 和 connectEnd第一次请求耗时正常第二次请求的 connectEnd 比 connectStart 大得多说明确实又重新建连了。两次请求的 portId 也不一样。这进一步验证了抓包结论。4.3 修复后的确认修复分两步。第一步和网关团队协调把空闲连接超时阈值调大至少要大于客户端请求间隔。第二步在客户端加了一层防守逻辑监听网络状态变化Wi-Fi/蜂窝切换网络变化后主动把 CronetEngine 里的连接池清掉避免复用跨越网络类型。网络切换是连接被强制失效的高发场景因为四元组变了。代码上用了 ConnectivityManager 监听网络变化回调里执行了 engine 的关闭和重建private void recreateCronetEngine() { // 注意这一步要放在后台线程shutdown 会有耗时 cronetEngine.shutdown(); cronetEngine buildCronetEngine(context); }修复后再次抓包验证连续请求源端口不再变化第二次请求连 TCP 握手都没有探针里 portId 连续相同复用率统计从原来的不到 60% 提升到接近 95%。这个实验清楚地说明判断连接复用不是一句“支持”就完事必须能拿到证据并且能从证据里找到问题根源。5. 常见问题与避坑清单实操中最常见的几个问题我整理成一个清单按频率排序供大家排查时对照。5.1 portId 一直是 -1 有哪些原因情况可能原因建议处理服务端不支持 HTTP/2/QUIC回退到 HTTP/1.1连接标识 API 可能不返回有效值先确认线上协议重点看 ALPN 协商结果Cronet 版本过低早期版本的 RequestFinishedInfo 没有 ConnectionInfo更新 Cronet 依赖到较新版本请求没有真正走网络命中缓存、被拦截、被取消用 connectStart 是否大于 0 过滤host 或者端口不同不同 host 之间本来就不隔离复用连接池按 host 维度分别记录再对比请求在连接建立前就失败metrics 不完整优先过滤 finishedReason 非成功状态遇到 portId 为 -1 的情况别着急下结论说“没复用”先按表格逐条排除尤其是协议版本这个原因。有些场景下服务端只支持 HTTP/1.1但抓包看连接其实复用了只是 Cronet 没有暴露连接标识而已这时候用抓包确认更靠谱。5.2 复用成功但还是很慢是怎么回事连接复用的收益集中在“省握手 RTT”上它不能解决所有慢的问题。如果复用已经生效但请求还是慢重点排查这几个方向服务端业务处理慢客户端发送队列拥塞网络带宽受限服务端将 HTTP/2 stream 处理成了串行。另外 QUIC 场景下即使连接复用网络切换也可能触发连接迁移迁移过程本身可能有额外延迟。还有一种是协议层面的“伪复用”连接复用了但每次请求前发现连接不可用重新建立连接并触发 TCP slow start拥塞窗口回到初始值传输效率会比长连接慢很多。这种问题的表现是总耗时高但 connect 耗时为 0乍看不像是连接问题但抓包能看到重复的 SYN 包。5.3 Cronet 参数对连接复用的影响Cronet 的连接复用行为不是全部默认打开的有些参数会直接影响最终效果。简单列几个关键点enableHttp2(true)HTTP/2 开关打开了才有 HTTP/2 连接复用。enableQuic(true)QUIC 开关打开了才可能用到基于 UDP 的 QUIC 连接连接迁移能力也依赖这个开关。addQuicHint(host, port, port)告诉 Cronet 哪些 host 可能会启用 QUIC让它在首次请求前就做 QUIC 探测间接提高复用机会。连接池空闲超时和 QUIC 相关的空闲超时可以通过实验参数调整。不同版本参数名经常变建议直接查你使用的 Cronet 版本源码里的配置项。HTTP/2 的最大并发流限制由服务端通过 SETTINGS 帧下发客户端无法直接覆盖但它影响的是“一条连接能并发多少请求”不是“是否复用”。提醒一点实验参数不要乱抄网上的老配置Cronet 版本迭代快很多参数名已经变了用错了会静默失效。我在项目里升级 Cronet 后就遇到过老参数完全无效的情况排查半天才发现是参数名变了。5.4 多并发场景下的探针误判问题前面提到过用 ConcurrentHashMap 按 host 缓存连接身份核心原因就是并发场景下不能用一个变量保存“上一次请求”。但即使用了 Map如果 CronetEngine 同时处理多个 host 的大量请求Map 的 key 如果只按 host 维度也会出现同 host 下两个连接并存的情况比如 HTTP/2 建立了多条连接或者一条连接正在关闭而另一条刚建立。工程上这是可以接受的近似统计但如果要更高精度建议通过抓包确认一次统计结果让探针的实际准确率心里有数。另外还有一个小坑Cronet 的回调发生在 native 线程池回调里不能做耗时操作也不能直接更新 UI。日志和埋点操作如果同步执行会拖慢整个请求流程严重时甚至导致请求超时。我见过有人在回调里写文件导致 Cronet 卡死的例子一定要用异步方式上报指标。6. 补充几个我的实操小习惯最后再分享几个个人摸索出来的习惯不一定都写在文档里但对排查连接复用问题很有帮助。第一个习惯代码判断和抓包永远交叉验证。任何新接手的 Cronet 版本第一次上线前我都会先跑一个双请求脚本用抓包确认 portId 判断的结果是对的。如果两者对不上优先怀疑是 API 字段语义理解错了而不是怀疑网络。这套交叉验证做过一次之后后续用探针就很有底气。第二个习惯把“连接复用率”和“TTFB”放进同一个报表系统。单独看任何一个指标都可能误判比如复用率低但请求快可能是局域网环境掩盖了握手成本复用率高但 TTFB 高可能是服务端问题。两个指标放在一起能直接把问题切成“连接层问题”还是“服务端业务问题”对定位线上故障非常有帮助。第三个习惯每次 Cronet 升级后把连接复用探针的日志打开跑一轮回归。Cronet 版本升级经常带来 API 变动和行为变化连接复用判断逻辑如果没跟着适配数据就会失真。回归时不一定要多复杂的场景连续请求同一接口 50 次观察 portId 的变化和复用率是否稳定即可。连接复用的判断本身不复杂复杂的是把它放到真实网络环境下还能不能给你准确答案。抓包、NetLog、代码探针三个手段各有分工生产环境靠探针疑难杂症靠抓包版本适配靠 NetLog。把这套组合用起来连接方面的性能问题基本就能稳住了。