前几天下午,我盯着崩溃平台上的曲线有点发懵:OkHttp 从 5.2.0 升到 5.3.0 之后的第三天,线上核心接口崩溃率从 0.12% 爬到 0.63%。数字不算夸张,但堆栈清一色指向okhttp3.internal.http2.Http2ExchangeCodec.writeRequestHeaders,异常信息只有一句话:IllegalStateException: closed。线程名全部是OkHttp Dispatcher,release 包偶发,debug 包怎么压都复现不出来。
团队当时正在做网络库版本收敛,这次升级本来是顺带完成的,结果变成了那周最头疼的事。这篇复盘记录了从第 1 条堆栈到根因确认的完整过程,包括一次典型的“第三方库隐形变更 + 历史脏代码”互相叠加的线上事故。如果你也在用 OkHttp 5.x,或者正准备从 4.x 升 5.x,这篇应该能帮你少走几天弯路。
1. 事故现场:崩溃数据长什么样,为什么容易被带偏
1.1 崩溃平台上的两组数据
先看第一手数据。崩溃平台一周内收到 253 条相关堆栈,去重后其实是两种异常:
| 异常类型 | 占比 | 线程 | 堆栈关键帧 |
|---|---|---|---|
IllegalStateException: closed | 94% | OkHttp Dispatcher / OkHttp TaskRunner | Http2ExchangeCodec.writeRequestHeaders |
StreamResetException: stream was reset: CANCEL | 6% | OkHttp Dispatcher | Http2ExchangeCodec.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: closed; - Dispatcher 线程在异步回调里捕获异常,走到崩溃上报逻辑。
这套时序里最关键的一步是第 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块中关闭 ResponseBody:
fun 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 升级,我们就有异常预警机制,而不是等用户崩了才被动响应。网络库是一切业务的底层,对它的稳定性投入再多都不为过。