ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

OkHttp 5.3升级致线上崩溃:连接池竞态与拦截器隐患复盘

OkHttp 5.3升级致线上崩溃:连接池竞态与拦截器隐患复盘 前几天下午我盯着崩溃平台上的曲线有点发懵OkHttp 从 5.2.0 升到 5.3.0 之后的第三天线上核心接口崩溃率从 0.12% 爬到 0.63%。数字不算夸张但堆栈清一色指向okhttp3.internal.http2.Http2ExchangeCodec.writeRequestHeaders异常信息只有一句话IllegalStateException: closed。线程名全部是OkHttp Dispatcherrelease 包偶发debug 包怎么压都复现不出来。团队当时正在做网络库版本收敛这次升级本来是顺带完成的结果变成了那周最头疼的事。这篇复盘记录了从第 1 条堆栈到根因确认的完整过程包括一次典型的“第三方库隐形变更 历史脏代码”互相叠加的线上事故。如果你也在用 OkHttp 5.x或者正准备从 4.x 升 5.x这篇应该能帮你少走几天弯路。1. 事故现场崩溃数据长什么样为什么容易被带偏1.1 崩溃平台上的两组数据先看第一手数据。崩溃平台一周内收到 253 条相关堆栈去重后其实是两种异常异常类型占比线程堆栈关键帧IllegalStateException: closed94%OkHttp Dispatcher / OkHttp TaskRunnerHttp2ExchangeCodec.writeRequestHeadersStreamResetException: stream was reset: CANCEL6%OkHttp DispatcherHttp2ExchangeCodec.takeHeaders设备分布上没有明显规律Android 8 到 Android 15 都有低端机和旗舰机的触发概率差不多。这个信息非常重要它直接排除了机型兼容性问题、厂商定制 ROM 问题也排除了 WebView 相关的干扰。再看接口维度。93% 的崩溃集中在/api/v3/feed/recommend这个信息流接口上。为什么不是全部因为还有 7% 散落在其他接口上这个“不干净”的分布也是后来才理解透彻的提前消费响应体的脏代码不止一处只是信息流接口的调用频率最高先炸的是它。1.2 为什么第一轮排查的思路是错的看到堆栈的第一反应大概率是“OkHttp 5.3 是不是有 Bug”。我们当时的操作也简单粗暴回滚版本。回滚到 5.2.0 之后崩溃率确实立刻降回去了这很容易得出“5.3 有 Bug先别升”的结论。但“回滚后消失”只能证明某个行为变化了不能证明是 OkHttp 单方面的故障。如果只做到这一步下次升 5.4、6.0 大概率还会炸。因为问题的真正土壤还在业务代码里版本只是一个放大因素。更典型的错误是把时间花在“构造必现用例”上。偶发崩溃如果靠压测就能跑出来就不叫偶发了。第一轮我们用 JMeter 跑 300 并发循环一整晚崩溃数是 0用 Charles 限速、断点模拟弱网也跑不出来。后来复盘时我意识到偶发问题的排查顺序应该是先做堆栈归因再形成根因假设最后才谈复现。复现是用来验证假设的不是用来“碰运气”的。1.3 一个关键信息为什么压测跑不出来任何网络库的偶发崩溃基本都跟“时序竞争”有关。信息流接口走的是 HTTP/2多路复用允许同一条 TCP 连接上同时跑多个 stream。崩溃触发的核心条件是一个旧 stream 正在结束时连接上的另一个新 stream 恰好要写请求头而连接池保洁线程在两者之间的极窄窗口里把连接回收了。正常压测工具的并发模型是“固定线程数 短连接”每次请求要么新建连接要么长期复用同一条连接根本构造不出“旧流结束、新流立即写入”的交错时序。所以压测跑不出来不是因为压测机不够强而是因为场景构造错了。2. 从表象到根因四条线索如何交叉锁定问题2.1 线索一崩溃线程和堆栈指向同一阶段Http2ExchangeCodec.writeRequestHeaders是向服务端写请求头的入口。在 HTTP/2 里多个请求复用在同一条 TCP 连接上每个请求对应一个 stream。写请求头时抛closed说明底层的连接 socket 已经被关闭或者连接被标记成了不可用状态。OkHttp 的连接池原本是由一套引用计数机制保护的一个 Exchange 还没结束连接不应该被清扫线程回收。所以摆在面前的问题只有两个方向要么 5.3.0 改了这套引用计数和清扫逻辑要么业务侧在某个环节提前触碰了连接底层状态导致连接异常关闭。两条线索都得查但查法完全不同。2.2 线索二接口聚合之后代码审查挖出一个“危险”拦截器把崩溃按接口维度聚合后焦点就到了信息流接口。这个接口的调用链上挂了一个全局 Interceptor功能是“缓存响应体”。需求本身没有错产品希望把每次返回的 JSON 写到本地下次启动能秒开页面。当时的实现方式很直接override fun intercept(chain: Interceptor.Chain): Response { val request chain.request() val response chain.proceed(request) try { val content response.body?.string() // 这里把整个流读完了 cacheManager.save(request.url.encodedPath, content) } catch (e: Exception) { // 埋点上报 } return response // 又把原始 response 往下传 }这段代码在 OkHttp 4.x 和 5.2.0 里“带病运行”了很久。在 5.2.0 里响应体的流已经在拦截器里被消费到 EOF业务层再次读取时只是拿到空字符串不会直接抛异常。但这里有一个致命问题response.body是一次性流底层封装的是 Exchange 的 source读完了之后 Exchange 立刻进入“可回收”状态。这个状态一旦被新版本的连接池保洁逻辑捕捉到就会引发连锁反应。2.3 线索三字节码 diff 定位连接池保洁策略变更这是全程最关键的转折点。我们把 5.2.0 和 5.3.0 的 jar 从 Gradle 缓存里找出来用javap反编译对okhttp3.internal.connection包里的RealConnection、ConnectionPool、Exchange三个类做差异对比./gradlew dependencies --configuration runtimeClasspath deps.txt # 找到 okhttp jar 路径后逐个反编译 javap -p -c -classpath ~/.gradle/caches/modules-2/files-2.1/com.squareup.okhttp3/okhttp/5.3.0/*/okhttp-5.3.0.jar okhttp3.internal.connection.RealConnection rc_530.txt javap -p -c -classpath ~/.gradle/caches/modules-2/files-2.1/com.squareup.okhttp3/okhttp/5.2.0/*/okhttp-5.2.0.jar okhttp3.internal.connection.RealConnection rc_520.txt diff rc_520.txt rc_530.txt | head -100diff 结果里最醒目的一处5.3.0 的ConnectionPool保洁任务从固定间隔扫描改成了由TaskRunner调度的事件循环同时RealConnection在被借出时新增了noNewExchanges标记并且多了一次对transmitters.isEmpty()的判断。字节码层面多出的几个调用对应到源码就是连接池在“连接借出”和“连接回收”两个动作之间多插入了一个状态位。这个状态位本身是为了“优雅关闭连接”设计的标记之后不允许新 Exchange 进来等现有 Exchange 全部结束时再真正回收。思路很好但它引入了一个更窄的竞争窗口。HTTP/2 连接上多个 stream 并发时如果保洁任务在新 stream 登记之前先扫描到“最后一个旧流刚结束”的中间态就会把这个连接直接置为不可复用并唤醒等待线程。等真正的新流去写请求头时socket 已经关闭。2.4 线索四定向复现验证假设成立有了“连接借用竞态”这个假设复现方案就清晰了。我们需要构造“旧流被 RST 新流马上写请求头”的极端场景。具体做法服务端在收到请求后随机延迟 30~80ms然后直接返回 RST_STREAM模拟服务端主动关闭流客户端开 60 并发每秒不断发起新请求尽量复用同一条 HTTP/2 连接信息流接口的拦截器保持线上逻辑不变确保每个响应体都会被提前消费一次。跑了 20 分钟左右信息流接口就复现了 3 次崩溃堆栈跟线上完全一致。然后用同一个脚本去跑 5.2.0跑了一个小时一次都没崩。到这里根因基本闭环5.3.0 的连接池保洁策略变化放大了“提前消费响应体”这个历史脏代码的副作用最终变成线上偶发崩溃。3. 隐形变更的本质连接借用竞态与“第二次读流”3.1 5.3.0 的连接池保洁逻辑到底改了什么很多人对 OkHttp 5.x 的认知还停留在“Kotlin 重写、API 没变”但底层行为已经变了很多。5.3.0 的 release note 只写了“修复连接池相关问题”“优化内部调度”具体的变更点不会逐条列出来。对我们业务开发影响最大的有两项TaskRunner 统一接管了所有内部调度任务。连接池清扫、超时管理、协程调度全部进入同一个事件循环。好处是线程数更少、调度更集中坏处是原本“每隔固定时间扫一次”的粗粒度逻辑被改成了“有事件就唤醒”的细粒度逻辑对状态中间态更加敏感。连接借出时新增noNewExchanges标记。这个标记的初衷是解决“连接正在优雅关闭时仍有新请求挤进来”的问题但它引入了额外的状态机转换。在 HTTP/2 多路复用的场景下一个连接上可能同时有多个流在借出状态状态位一多竞态窗口自然就变大了。3.2 “第二次读流”为什么突然变得致命理解这根导火索核心是理解 ResponseBody 的一次性语义。一次 HTTP/2 响应的 body底层是 Exchange 中 source 的封装它只能被消费一次。读到 EOF 后这个 Exchange 会立刻回到“可回收或可复用”的状态。业务拦截器在把 Response 往下游传之前读了body?.string()等于替下游把这次响应消费完了。下游业务层再次读取时旧版 OkHttp 返回空字符串不抛异常所以问题不会被注意到。但在新版 OkHttp 里这个“已消费完的 Exchange”会更早地被连接池保洁逻辑识别为可清理同一连接上有其他流并发时竞争就发生了。我后来在团队内部打了一个比方这就像火车站的站台本来一趟列车检完票之后站台要等所有乘客都上车了才会关闭。5.3.0 改成了“只要检完最后一趟车的票站台立刻关闭”结果旁边还有其他乘客正在冲过来直接撞在关上的闸机上。提前消费 body 的脏代码就等于把一个正常的“连续上车”过程硬生生截断了。3.3 一次崩溃的完整时序把整个崩溃过程拆成时序步骤方便对照检查自己的代码用户 A 请求信息流接口自定义拦截器提前执行body?.string()响应消费到 EOF消费完毕后Exchange 把 source 标记为结束但拦截器还没有关闭外层 ResponseBody同一 HTTP/2 连接上用户 B 的请求正好开始准备写入请求头连接池保洁任务被 TaskRunner 唤醒扫描到这条连接上的“旧流”已经结束连接进入可回收候选并置noNewExchanges用户 B 的请求在Http2ExchangeCodec.writeRequestHeaders发现连接已关闭抛IllegalStateException: closedDispatcher 线程在异步回调里捕获异常走到崩溃上报逻辑。这套时序里最关键的一步是第 4 步和第 5 步之间的窗口。旧版 OkHttp 里第 4 步会被引用计数挡住因为它发现还有活跃的 Exchange新版里新增的状态位让连接可以被提前回收窗口就此打开。4. 修复方案三份补丁分别堵住三个口子4.1 第一份补丁删掉拦截器里提前消费响应体的逻辑这是整个战役里最重要的一步。缓存响应体的需求本身没有错错的是实现方式。正确方案有两个方向用response.peekBody(maxByteCount)拿一小部分用于打点不在拦截器里消费完整 body用装饰者模式包一层 ResponseBody在业务层真正读取完并关闭 body 时再做缓存。如果需求是“整体缓存”推荐后者。peekBody受限于最大读取字节数满足不了完整缓存场景。装饰者模式可以参考这个写法override fun intercept(chain: Interceptor.Chain): Response { val request chain.request() val response chain.proceed(request) val body response.body ?: return response return response.newBuilder() .body(CachingResponseBody(body, request.url.encodedPath)) .build() } class CachingResponseBody( private val delegate: ResponseBody, private val path: String ) : ResponseBody() { private var cached false override fun contentType(): MediaType? delegate.contentType() override fun contentLength() delegate.contentLength() override fun source(): BufferedSource { return delegate.source().apply { if (!cached) { val all readUtf8() cacheManager.save(path, all) cached true } } } }注意这个装饰器依然有隐患source()可能被多次调用cached能避免重复消费但无法保证只有一个真正的消费者。所以我们后来做了一个更彻底的调整——全局拦截器只保留打点统计JSON 缓存统一挪到了 Repository 层在业务代码真正读取 response body 的公共方法里去处理。这样从架构上根除了“提前消费”的可能。4.2 第二份补丁统一请求出口规范 ResponseBody 关闭除了拦截器还要解决一个历史问题项目里有大量 OkHttp callback 用完后没有关闭 ResponseBody。在 Java/Kotlin 里不关流不一定立刻崩GC 之前都可能没事但连接池里的连接会一直保持“分配中”状态。日积月累连接池里全是僵尸连接保洁线程一扫描就可能误伤。我们统一封装了一个请求出口强制在use块中关闭 ResponseBodyfun enqueueWithClose( request: Request, onSuccess: (ResponseBody) - Unit, onError: (Exception) - Unit ) { client.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { onError(e) } override fun onResponse(call: Call, response: Response) { response.use { val body it.body if (it.isSuccessful body ! null) { onSuccess(body) } else { onError(IOException(http error: ${it.code})) } } } }) }response.use会自动调用close()最终把连接归还给连接池。这是 OkHttp 里最容易被忽略的细节不管 HTTP/1.1 还是 HTTP/2只要 ResponseBody 没有关闭连接就不会被真正释放。长期运行后会表现为连接池里出现大量空闲连接、连接建立耗时上涨、偶尔出现奇怪的读取异常。这些症状不一定崩但都是隐患。4.3 第三份补丁不降级版本的连接池参数与 Protocol 调整代码层修复完成之后我们做了一次关键决策不降级回 5.2.0。因为根因已经确认是“脏代码 新版本行为变化”叠加代码修复后 5.3.0 的整体稳定性反而更好。但灰度期间我们额外上了两道保险连接池保活时间从默认的 5 分钟调整到 2 分钟减少空闲连接被保洁线程扫描的频率对核心接口禁用 HTTP/2强制走 HTTP/1.1。HTTP/1.1 一次连接同时只有一个在途请求不存在多路复用流的并发交接问题竞态窗口直接归零。OkHttpClient.Builder() .connectionPool(ConnectionPool(8, 2, TimeUnit.MINUTES)) .protocols(listOf(Protocol.HTTP_1_1)) .build()这条保险要在独立构建的 Client 上生效不能直接改全局 Client否则会影响所有接口的 HTTP/2 多路复用。对信息流这种高并发接口多路复用带来的性能收益还是很可观的不能因为一次事故就废弃掉。4.4 灰度验证与崩溃率回看验证分两个阶段走第一阶段只上代码修复删除提前读 body 统一关闭保留 HTTP/2放量 10% 跑 48 小时崩溃率从 0.63% 降到 0.09%第二阶段对核心接口禁用 HTTP/2放量 30% 跑一周崩溃率降到 0.02% 以下基本回到升级前水平。这里我想多说一句第一阶段降到 0.09% 之后没有完全归零说明线上仍然存在其他读 body 不规范的历史代码。这些代码分布在各个业务模块里一时半会儿根本改不完。所以后来我们给工程加了一条 lint 规则直接禁止在 Interceptor 里调用body?.string()或body?.source()并放行原始 Response从编译期就把这条路堵死。这是整个复盘里我觉得最值得推广的措施——不光修线上问题还把问题“锁死”在编码阶段。5. 这次复盘沉淀下来的四件事5.1 升级网络库等于做一次全量代码债审计升级 OkHttp 这种底层网络库release note 通常只会写“修了什么”不会写“行为变了什么”。升级前应该把项目中所有自定义 Interceptor、所有使用 OkHttp 的调用点全部过一遍重点排查三类模式Interceptor 里提前读完整响应体body?.string()或body?.source()使用 callback 后没有在onResponse中关闭 ResponseBody拦截器里做缓存或打点同时又把原始 Response 继续往下传。这三类代码在 4.x 里属于带病运行在 5.x 里可能直接变成线上事故。排查成本远远低于线上崩溃成本。5.2 偶发崩溃先看线程再看接口不要上来就怀疑网络库这次排查最深的教训是偶发崩溃不能靠“跑复现”要先做归因分析。崩溃平台拿到数据后先把堆栈按三个维度聚合线程名、异常类型、接口路径。线程名直接告诉你崩溃发生在哪套异步模型里接口路径直接帮你缩小代码审查范围。我们一开始在“复现”上浪费了整整两天后来把堆栈按接口聚合后半小时就锁定了信息流接口的拦截器。排查顺序真的很重要。5.3 字节码 diff 是排查隐形变更的最笨也最可靠的手段升级第三方库后出现诡异问题第一件事就是把新旧版本的 jar 都拉下来做字节码 diff。不需要理解所有细节先看类名和方法名的差异锁定变更范围再用javap对比方法内的调用差异。这个方法不依赖官方文档是否写全也不依赖网上是否有人遇到过同样问题。我后来在团队里提倡一个习惯每次升级依赖库都把关键 diff 存档作为排障手册的一部分。5.4 给稳定性监控补两类埋点这次事故之后我们在崩溃平台之外补了两类埋点连接池状态埋点每隔 5 分钟上报当前连接数、空闲连接数、活跃 Exchange 数提前观察连接回收是否异常网络库内部异常埋点把所有IOException、StreamResetException、IllegalStateException的堆栈都先上报到后台而不只上报到崩溃平台。很多网络库错误不会直接崩但堆栈里隐藏着下一次崩溃的预告。这两类埋点上线后后续再做 OkHttp 升级我们就有异常预警机制而不是等用户崩了才被动响应。网络库是一切业务的底层对它的稳定性投入再多都不为过。
返回列表