1. 线上偶发崩溃的事故现场1.1 崩溃画像IllegalStateException: closed 的不寻常之处如果你最近把项目里的 OkHttp 从 5.2.x 升到 5.3.0并且开始收到零星的IllegalStateException: closed崩溃——别急着甩锅给运营商或者弱网先看看是不是升级引发的隐形变更。这是我这段时间处理的一个真实线上问题。崩溃量不大高峰时也就占整体用户的万分之几但一旦出现Crash 堆栈全指向okhttp3.internal.http2.Http2ExchangeCodec.writeRequestHeaders给人的感觉就是网络层在乱来。最麻烦的是它没法稳定复现开发机很难跑出来只能在线上捞日志。先看一眼崩溃堆栈这类问题最典型的样子大概是java.lang.IllegalStateException: closed at okhttp3.internal.http2.Http2ExchangeCodec.writeRequestHeaders(Http2ExchangeCodec.kt:146) at okhttp3.internal.connection.Exchange.writeRequestHeaders(Exchange.kt:79) at okhttp3.internal.connection.CallServerInterceptor.intercept(CallServerInterceptor.kt:54) at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:142) ...注意这里是IllegalStateException不是IOException。这意味着它不会走 OkHttp 内部的重试机制也不满足上层业务里catch (IOException)的兜底逻辑最终直接冒泡到 Android 的 Crash 捕获层。所以哪怕量不大它也是一个不折不扣的崩溃而且容易躲过大部分研发的初步排查。我把它定义为“网络层状态机异常”而不是普通的超时、断网或者 DNS 失败。整个排查过程最核心的思路就是顺着状态机三个字去挖到底是哪个状态被打破了谁在什么时候改了它的状态。1.2 第一时间止血用配置开关隔离范围遇到线上偶发崩溃我的习惯是不要急着改代码先想办法把影响范围缩小哪怕是临时的。因为偶发问题一旦被改动干扰后续定位会难上很多。当时我们在崩溃监控平台确认了受影响用户量之后做了一件事把底层网络组件的 HTTP/2 协议强制关掉统一降级到 HTTP/1.1。这一步不是最终修复而是为了验证崩溃是否和 HTTP/2 多路复用相关。强制关闭 HTTP/2 的方式不复杂核心就是调整 OkHttp 客户端构建时的 ConnectionSpecval client OkHttpClient.Builder() .connectionSpecs(listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.RESTRICTED_TLS)) .build()这里我没有直接用ConnectionSpec.MODERN_TLS因为它默认是兼容 HTTP/2 的。换成ConnectionSpec.RESTRICTED_TLS之后TLS 握手阶段就不会再去协商 HTTP/2连接会走 HTTP/1.1 通道。灰度观察了大概 10 个小时崩溃率直接归零。这个结果很有信息量问题基本锁定在 HTTP/2 的某条链路上跟 DNS、IPv6、弱网调度这些通通没有关系。更重要的是这一步给我们争取到了后续排查的时间也让老板能安心等我们把根因找出来。2. 根因追踪OkHttp 5.3 到底改了什么2.1 升级链路回顾一次看似无害的版本更新事故对应的版本升级路径是5.2.4 - 5.3.0。当时之所以会升级是因为团队在推进 Kotlin 化改造想把 OkHttp 的 Kotlin 相关 API 统一用起来。看变更日志的时候5.3.0 给的 breaking change 列表很短核心是删掉了一些过时的 Java API、调整了部分方法签名没看到让人眼前一亮的风险点。但问题恰恰出在这里。对外公开的 breaking change 列表只能告诉你哪些 API 不能用了它不会告诉你内部的线程模型、锁粒度、调度器发生了多大变化。我们后来翻了 5.3.0 相对 5.2.x 的源码 diff真正值得注意的改动集中在两块一块是 Dispatcher 的状态管理逻辑被迁移到了协程作用域里另一块是 RealConnectionPool 的空闲连接清理任务不再使用独立的单线程调度器而是复用 Dispatcher 的协程调度器。这两处改动都没有改任何公开 API 的签名但实际运行时面对并发请求的行为完全变了。这类改动我把它叫做隐形变更。它不报错、不警告、不出现在迁移文档里却会在特定的流量模型和时序条件下把库里原本能自我保护的边界给打开。2.2 源码比对连接回收与请求出队之间的竞态窗口要讲清楚崩溃的根因得先理解 OkHttp 的连接池机制。正常情况下OkHttp 的连接池会持有若干条已经建立的长连接。当新请求进来时会优先从连接池里找可以复用的连接如果找不到才重新建立连接。与此同时连接池还有一个后台清理动作负责判断哪些连接已经空闲太久再决定是否关闭回收。OkHttp 5.3.0 的改动本质上是把清理动作和请求出队动作放在了同一个协程调度器的管理下。什么概念呢类比一下就是以前仓库门口有一个专门负责清货的保安他在固定时间巡逻现在保安的职责被并到前台接待员的职责里变成同一套人流调度规则来驱动。正常情况下没问题可一旦订单高峰来临前台可能会在同一个瞬间同时执行“接待新订单”和“把旧货扔掉”的动作悲剧就来了。具体到代码层面崩溃链路可以拆成四步RealConnectionPool 在空闲连接清理任务里判断某条连接的空闲时间已经超过阈值把它标记为待回收几乎同一时刻Dispatcher 把一个已经排队的异步请求出队这个请求从连接池里拿到了那条刚刚被标记回收的连接清理任务先执行了下一个动作关闭底层的 Socket 和 HTTP/2 流请求准备写 Header 的时候Http2ExchangeCodec 检查到连接已经关闭直接命中状态检查抛出IllegalStateException: closed。每一步拆开看都符合逻辑但放到一起就是一个不折不扣的竞态条件。而且这个竞态窗口非常窄只有几步指令的时间所以平时根本测不出来只有在高并发、长连接复用率很高、并且连接恰好处于“快要被回收”的临界状态下才会命中。2.3 为什么崩溃的是 HTTP/2 而不是普通连接很多人可能会问如果连接已经关闭为什么 OkHttp 不感知非要等到写 Header 的时候才爆发这里涉及 HTTP/2 和 HTTP/1.1 在实现上的差异。HTTP/1.1 的连接通常是一对一使用请求前会先检查连接是否存活如果发现失效上层重试的成本很低。但 HTTP/2 不一样一条连接上会承载多个并发的 Stream连接池在做复用判断时看的是连接池整体状态而不是单条 Stream 的状态。OkHttp 的内部设计里只有在真正写入请求头的那一瞬间才会去确认这条连接上的 HTTP/2 流是否还可用。如果连接刚好在“确认可用”和“实际写入”之间被关闭那么这个IllegalStateException就会成为漏网之鱼。这也是整个问题最隐蔽的地方它不是一个明显的空指针也不是一个可以靠超时参数解决的超时异常它本质上是多线程调度下连接生命周期状态管理出现了一个前后不一致的窗口。要想修复就必须想办法缩小这个窗口或者让其中一方能够容忍另一方的状态变化。3. 复现验证把偶发变成必然3.1 设计可复现的实验环境偶发问题最让人头疼的就是复现难。在开发环境里发几百个请求一点问题都不会有在测试环境里开 10 个线程跑半小时也没有崩溃。后来我反思了一下问题还是出在流量模型上。线上真实的 HTTP/2 流量有几个特点单条连接上的并发 Stream 会频繁增减、请求之间的空闲间隔分布不均匀、连接池里同时存在多条不同健康状态的连接。而我们平时的压测要么是全并发一拥而上要么是串行稳定性测试都没能卡中那个竞态窗口。我后来专门写了一个 Kotlin 压测程序核心思路是模拟混合流量val client OkHttpClient.Builder() .connectionPool( ConnectionPool( maxIdleConnections 2, keepAliveDuration 3, TimeUnit.SECONDS ) ) .dispatcher( Dispatcher().apply { maxRequests 64 maxRequestsPerHost 12 } ) .build()关键参数在于连接池的最大空闲连接数刻意调小keepAlive 时间调到极短同时把 maxRequestsPerHost 调大让单条 HTTP/2 连接上同时跑很多流。这样连接池里的连接就会被频繁地创建、复用、判定空闲、回收完全复刻线上的临界状态。然后我用协程同时起了多个任务每个任务随机等待 0~50 毫秒再发请求请求目标全部指向同一个域名。整个过程跑了几百轮之后终于顺利看到了IllegalStateException: closed的熟悉堆栈。3.2 线程转储与状态不一致的现场证据复现出崩溃之后还不能马上宣布胜利。我们需要确认这个异常确实是因为连接生命周期状态不一致导致的而不是压测程序自身写错了什么。这一步我用了两个手段。第一个手段是线程转储。在崩溃率比较高的压测阶段我连续抓了好几轮线程 dump重点观察 Dispatcher 线程池和连接池清理线程的状态。抓到的结果里能看到某一条工作线程已经持有了连接池的锁正在执行pruneAndGetAllocationCount同时另一条线程正在等待同一把锁而它后面的代码就是Http2ExchangeCodec写入请求头的路径。第二个手段是给 OkHttp 的内部状态加了临时日志。通过反射拿到 RealConnectionPool 内部的连接快照把每一轮清理动作前后连接状态打印出来。这时可以明显看到在某几次清理动作里连接确实在请求复用之前就被标记成了不可用状态。到这里问题从“偶发崩溃”变成了一个可以稳定复现的竞态 bug排查工作已经算完成了一大半。3.3 一个容易忽略的复现技巧放大竞态窗口这里分享一个压测的小技巧遇到竞态类问题纯粹靠提高并发不一定能提高复现率更有效的方式是放大竞态窗口。我在压测时故意限制了本机的 GC 行为制造了一些短暂的停顿让线程调度的随机性变大。具体做法是压测前先分配一批比较大的对象触发频繁的 Minor GC。GC 停顿会让一部分线程停在代码执行的中间位置另一部分线程则继续往前走天然地把竞态窗口拉宽。这个方法在我处理过的好几个偶发问题上都非常管用比单纯堆线程数量高效得多。另外网络条件也要刻意调得不稳定。我用弱网工具把数据包做了随机丢包和延迟让连接建立和关闭的时间更加不可预测。实际效果是本来要跑 1 个小时才能复现的问题调整到 10 分钟左右就能稳定复现。4. 修复方案与验证上线4.1 短期方案关闭 HTTP/2 或者回退版本在根因还没有完全定位清楚之前团队其实只有两个短期选择一个是维持 HTTP/1.1另一个是回退到 5.2.4。关闭 HTTP/2 的方案我们已经验证过崩溃归零代价是失去多路复用能力。对于大多数接口体量不算非常大的业务来说HTTP/1.1 的表现其实也能接受只不过在弱网和高并发场景下连接建立的开销会明显变大图片和批量请求的耗时会有上升。回退到 5.2.4 则是更理想的短期方案因为它让你保留 HTTP/2 的能力同时躲开了 5.3.0 引入的竞态问题。当时我们评估了一下5.3.0 带给业务的新特性并不多回退不会有功能损失。等后续官方补丁或者我们自己的定制方案成熟后再重新升级。这里我建议所有团队都要有版本回退预案特别是这种网络基础组件。线上出了问题最怕的就是手里只有一个方案被迫在“有崩溃”和“功能损失”之间二选一。4.2 中期方案自定义 Dispatcher 降低调度冲突短期止血之后我们把目光放到了怎么在保留 HTTP/2 的前提下降低连接回收和请求出队之间的竞争概率。先说 Dispatcher。OkHttp 的 Dispatcher 允许外部传入自定义的 ExecutorService所以我们可以不依赖它默认的协程调度行为而是强行塞一个独立的线程池进去val dispatcher Dispatcher( ThreadPoolExecutor( 8, 32, 60, TimeUnit.SECONDS, SynchronousQueue(), ThreadFactory { runnable - Thread(runnable, custom-okhttp-dispatcher).apply { isDaemon false } } ) ) val client OkHttpClient.Builder() .dispatcher(dispatcher) .connectionPool( ConnectionPool( maxIdleConnections 4, keepAliveDuration 30, TimeUnit.SECONDS ) ) .build()核心思路是把调度行为拉回我们可控的位置连接池空闲连接多保留一会儿减少被回收的频率连接池容量稍微大一点避免连接被频繁创建和销毁Dispatcher 的自定义线程池让请求调度和连接清理不再共享同一个调度上下文。还有一个调优项是maxRequestsPerHost。默认值是 5也就是同一个域名最多同时 5 个请求走一条 HTTP/2 连接。如果你把它调得太大单条连接承载的流数量太多连接池清理和请求写入的相遇概率也会上升。我当时的建议是先保守一点不要超过 8等观察一段时间再逐步增加。4.3 长期方案构建连接关闭异常的统一兜底策略中途方案只能降低概率真正的长期方案需要解决两类问题一是 OkHttp 自身的 bug 被官方修复后及时跟进二是在业务层建立一套针对连接关闭异常的统一兜底策略。很多人会问为什么不干脆在业务层catch (Exception e)全包了我的看法是乱捕不对但分层兜底是有必要的。具体来说网络层以上的业务代码只关注自己定义的业务异常和 IOException而对于 OkHttp 这个包路径下抛出的IllegalStateException应该在上层网络模块的统一出口做一次具名捕获判断是否属于连接关闭类异常如果是就走一次有限次数的重试。举个例子封装 API 请求的时候可以在统一回调处加一层过滤fun T executeSafely(block: () - T, maxRetries: Int 2): T { var retries 0 while (true) { try { return block() } catch (e: IllegalStateException) { if (e.message closed retries maxRetries) { retries continue } throw e } } }注意这里的重试不是无脑重试而是限定在“连接关闭”这一类异常上而且设置重试上限。这样即使 OkHttp 自己的修复还没有发布业务层的体验也不会受到明显影响。另一个值得做的长期工作是打点监控。给网络层加上连接复用率、连接池回收任务耗时、Dispatcher 等待队列长度这几个核心指标。之前我们完全没有这些数据整个排查过程花了大量时间靠 log 反推。有了监控之后再遇到类似问题几十分钟内就能判断是连接池的问题还是调度的问题。4.4 验证方案灰度对比与线上指标观测修复方案最终落地不是改完代码就结束的还要证明它有效。我当时采取的方法是灰度对比把用户分为两组一组跑老版本一组跑修复版本。老版本的数据作为背景噪音修复版本的数据用来验证效果。灰度持续了大概 3 天观测指标包括崩溃率、HTTP/2 请求占比、连接复用率、请求平均耗时。最终结果是修复版本的IllegalStateException: closed崩溃率为零HTTP/2 请求占比和连接复用率都维持在原水平请求耗时和失败率没有恶化。到这一步整个修复才算真正闭环。后面我们又持续观察了一周确认线上没有出现回归才把这个版本逐步放量到全量用户。5. 复盘收获网络库升级与偶发问题的排查经验5.1 隐形变更的本质线程模型是最危险的改动我一直强调隐形变更这个概念核心就一句话线程模型是无法从接口层面感知的改动也是升级时最容易被忽略的改动。OkHttp 5.3.0 的这次事故官方文档并没有把线程模型重排列为 breaking change但它对线上行为的影响比很多删 API 的变更都大。线程模型一改锁的粒度、调度顺序、任务优先级全部可能跟着变而这些变化不会在编译期报错只会在特定的流量模型下以偶发崩溃的形式冒出来。以后升级任何带内部线程池、调度器、锁的库比如网络库、任务队列、数据库驱动都要多留一个心眼。我现在的习惯是升级前做一次并发压测流量模型尽量模拟线上真实形态而不是用简单的并发循环去试。5.2 偶发崩溃排查的五步方法论这次排查整体走下来我总结成五步第一步告警和堆栈归因。先把崩溃堆栈收集全确认异常类型和触发位置不要急着猜原因。第二步用配置开关做隔离。能通过 HTTP/1.1、HTTP/2 切换或者功能开关快速缩小范围的话优先做。这一步往往能在几小时内给出明确方向。第三步源码 diff 和线程模型比对。重点看 Dispatcher、线程池、调度器、锁相关的内容这些地方最可能出现隐形变更。第四步构造竞态复现。通过调小连接池、拉长并发 window、引入 GC 压力和弱网把偶发问题变成稳定复现问题。第五步修复验证和灰度回归。修复后要保留老版本作为对照用数据说话而不是拍脑袋说问题解决了。这套流程在我自己处理线上疑难杂症的时候反复用到虽然不是每步都那么顺畅但至少能保证不会像个无头苍蝇一样乱撞。5.3 给团队留下的防御性改进清单最后说点落到实处的。这次事故之后我给团队定了几条硬性要求升级网络基础库之前必须有异步并发压测报告压测流量模型必须包含短连接、长连接复用、空闲连接回收这三种场景线上崩溃监控增加对IllegalStateException: closed和类似连接生命周期异常的专项看板网络层统一入口增加连接关闭类异常的有限重试逻辑每次升级 OkHttp 这类核心组件都要记录源码 diff 里的线程模型相关改动。我自己有一个习惯升级完网络库之后会把连接池参数默认值重新核对一遍。很多时候升级带来的问题不一定是新版本有问题而是新版本对线程模型的调整放大了旧参数的不合理性。像这次的教训就是连接池 keepAlive 时间设得太短连接被回收得太勤竞态窗口自然就被拉大了。把参数调得足够保守很多问题压根不会遇到。排查过程持续了接近一周压力确实不小。但回过头看这次事故给我们换来了对 OkHttp 内部线程模型更深入的理解也逼着我们补上了连接层监控和重试机制的短板。虽然过程狼狈但收获是可复用的。