
1. 这起线上崩溃是哪个版本带来的事情发生在一次再普通不过的版本发布日。我们 App 的每周例行版本把okhttp从5.2.0升到了5.3.0其余业务代码几乎没有动唯一值得注意的是同时顺手清理了几个依赖的传递引用。本来这种依赖升级在 CI 上已经跑了全量回归功能测试、接口测试、兼容性测试全绿连灰度小流量也跑了 3 天线上崩溃率曲线基本是平的所以发布当天大家都在忙别的。结果第二天早上打开崩溃平台看到一个异常量级直接把我看清醒了IOException: unexpected end of stream这个异常从原本每天几十次一夜之间涨到了上千次。准确一点说升级前一周该异常日均 37 次升级后当天 586 次第二天继续涨到 1300 多次。按活跃用户量折算大概 0.3% 左右不是那种一下子打爆全网的致命 bug但也绝对到了必须立刻处理的水平。更麻烦的是这个异常分布极其零散分散在七八个不同的业务线上有的在首页信息流有的在视频上传有的在日志上报看起来像是各家业务各自踩了坑互不相干。发布前所有例行检查都做了Release Notes 也都翻过没有任何一条提到这类行为变化。等真正定位完我才理解这类问题并不是 OkHttp 5.3 新增了某个显眼的 API 或者删除了什么东西而是它悄无声息地改变了一个内部判断分支的语义把线上原本被容忍的错误响应变成了异常再配合低概率的网络条件就造成了“偶发崩溃”这种最难查的现场。2. 崩溃画像堆栈交代得清清楚楚问题却藏得很深2.1 崩溃堆栈高度统一先看崩溃平台聚出来的主堆栈几百个不同设备、不同业务的崩溃样本基本都长这样java.io.IOException: unexpected end of stream on https://api.example.com/v1/feed/recommend at okhttp3.internal.http1.Http1ExchangeCodec$FixedLengthSource.read(Http1ExchangeCodec.kt:248) at okhttp3.internal.http1.Http1ExchangeCodec$FixedLengthSource.read(OkHttp.kt) at okhttp3.internal.connection.Exchange$ResponseBodySource.read(Exchange.kt) at okio.ForwardingSource.read(...) at okio.RealBufferedSource.read(...) at okhttp3.ResponseBody$BoundedSource.read(ResponseBody.kt) ...堆栈本身是很经典的 OkHttp 在读Content-Length固定长度响应体的过程中遇到了 EOF说明服务器在响应头里宣告了响应体长度但实际发出的字节数不够。按这个线索第一反应大概率是“服务端或者某个 CDN 节点把响应截断了”。但让我起疑的是两点。第一这个错误之前就有频率不高为什么一个客户端版本升级之后会放大了三四十倍如果单纯是服务端截断那么服务端不改、CDN 不改客户端版本升级怎么会让截断面瞬间变大第二Crash 详情里的设备分布没有规律Android 8 到 Android 15 都有网络环境从 Wi-Fi 到 5G 都有看不出明显的机型或系统版本偏好。这说明大概率不是某个系统版本兼容问题而是业务代码在某个通用路径上触发了同一种异常。2.2 本地和灰度都复现不了的坑我尝试在本地复现开了抓包代理、限速、断点续传、弱网模拟甚至直接用 Charles 把响应改写为“Content-Length 比实际 body 大 1 个字节”跑了一个下午也没有复现出崩溃。原因很好理解异常触发点在 OkHttp 读取响应体的FixedLengthSource.read它取决于 TCP 层真实收包情况弱网模拟器能模拟延迟和丢包但很难精确模拟出“响应体先到一半然后连接被对端 reset/close而且 reset 正好发生在 keep-alive 连接复用的节点上”。在本地跑一万次能遇到一次就算运气好。这也就是“线上偶发”最难搞的地方你明知道它是概率性的而且大概率是网络条件触发但缺少线上真实环境里的关键变量。后来我复盘时反思这个阶段最大的失误是太早接受了“服务端截断”这个结论。它当然是一个必要条件但如果没有找到客户端升级前后的行为差异就算服务端真的有截断也无法解释为何崩度量级在同一个版本瞬间抬升。2.3 偶发问题的关键不是“量”而是“新增量”排查这类问题有一个必须养成的习惯不要只看崩溃总量要看新增量是从哪个版本开始出现的。我对比了最近 6 个线上版本的异常曲线后确认5.2.0 时代也有这个异常但基线大概每天几十次唯一一个突变点就是 5.3.0 版本。也就是说服务端截断这个现象一直存在但旧版本 OkHttp 对这个现象的处理方式并不会让 App 崩溃或者崩溃路径没有暴露给业务线程。这就把问题从“网络故障”拉回到了“客户端版本行为差异”。于是我把排查重点从服务端转移到了 OkHttp 5.3.0 的源码 diff 上。3. 三次假设、三次被推翻有人怀疑过的方向排查过程其实没有想象中那么顺利中间走了三条弯路。我把它们列出来一是因为这三个方向很有代表性二是它们能帮你快速排除我不想让你再踩的坑。3.1 假设一CDN 或网关截断了长响应早期网络排查快而广我们先查了受影响接口的 Nginx、CDN 回源日志和错误率。结论是服务端并没有任何变更CDN 的截断率也基本平稳。唯一值得留意的是崩溃的 URL 背景复杂有不少走的是海外 CDN 节点而海外节点历史丢包率本身就比国内高。但这个解释和客户端版本升级没有因果关系只是背景噪音。排除。3.2 假设二自定义 Dispatcher 线程池被回收第二个假设来自另外一个同学。我们某个上报 SDK 自己构造了Dispatcher并往里面塞了自建的ExecutorService代码里有一个在 App 退到后台时会shutdown()的逻辑。我们怀疑是不是 OkHttp 5.3 改了Dispatcher的内部调度时序导致任务在 executor 关闭之后还没提交完从而抛RejectedExecutionException或者回调里的 IO 异常。我们把所有自定义Dispatcher.executor的调用点翻了一遍并且用线程 dump 和日志埋点确认崩溃时刻线程状态结果没有一条崩溃样本的线程名或堆栈和这个自定义线程池相关排除。3.3 假设三R8 混淆和 Kotlin 元数据不兼容第三个假设是 5.3.0 用新版本 Kotlin 编译而项目里的 R8 版本偏老导致混淆摘除了一些只在运行时通过反射调用的方法最终在ResponseBody.string()的某个内部调用点抛NoSuchMethodError。这个方向在原理上说得通我们也确实见过不少 Kotlin SDK 升级后配合老 R8 出的隐性崩溃。但排查后不成立异常类型始终是IOException没有任何NoSuchMethodError或NoClassDefFoundError而且在我们把 OkHttp 5.3.0 的相关 keep 规则完整加进 R8 配置后崩溃率纹丝不动。排除。假设验证方法结论CDN/网关截断对比服务端日志与崩溃 URL检查回源错误率服务器与 CDN无变更截断基线平稳无法解释版本突变Dispatcher 线程池回收全量检索自定义 Executor线程 dump 对比崩溃时间崩溃线程与自定义线程无关R8/混淆兼容加 keep 规则、升级 R8、本地反编译核对字节码异常类型不匹配无 NoSuchMethodError三次假设全部推翻后我只好开始做最笨的事把 OkHttp 5.2.0 和 5.3.0 针对 HTTP/1.1 响应体读取的相关源码逐段对比找到那个“隐形变更”的入口。4. 定位过程从线上数据回捞到源码差异对比4.1 在线上请求里注入响应元数据埋点靠脑子猜不靠谱关键是拿到线上出问题请求的完整元数据。我们快速提了一个热修版本在Interceptor里给所有请求包了一层 try-catch一旦捕获到IOException就把当时的request.urlresponse.code如果还能拿到response.headerscontentLength期望值已读到的实际字节数Connection头服务器 IP / 协议版本HTTP/1.1 还是 HTTP/2.0线程是否在主线程这些信息全部打点上报。热修版本发布后半天内收到 200 多条有效的崩溃前元数据。整理后共性非常明显100% 走的是 HTTP/1.1没有一个 HTTP/2.0。响应头里都带着Content-Length但实际读到的字段不到声明值。一部分响应头里同时带着Connection: keep-alive。大量请求发生在 App 处于前台的空闲阶段看起来像是一次普通的后台刷新或预加载。服务器 IP 集中在两个 CDN 段但没有更细的规律。这基本说明问题与 HTTP/1.1 长连接复用的场景强相关。上线一个独立的本地复现实验就有方向了。4.2 本地复现用一个只发“半个响应体”的测试服务器我在本地写了一个极简的ServerSocket测试脚本故意模拟这种服务端行为先返回一个带Content-Length: 4096的 HTTP/1.1 响应头然后只发送 2048 字节的 body紧接着调用Socket.close()。分别在 5.2.0 和 5.3.0 两个依赖版本下疯狂循环请求同一个测试接口。结果非常有意思。5.2.0 下反复跑了 500 次有大概 200 次能正常读到 JSON 并且不崩另外 50 次会抛出但堆栈不稳定还有 250 次因为连接无法建起来而失败5.3.0 下几乎在 200 次以内就会抛出一模一样的IOException: unexpected end of stream而且堆栈和线上完全一致。虽然 5.2.0 不是“100% 不报错”但这个复现实验证实了一件事5.3.0 对“Content-Length 声明大于实际 body”的处理响应是确定性的它一定会走异常分支。4.3 代码差异里藏着关键修改拿到这个结论后核心搜索范围缩小到了Http1ExchangeCodec以及它调用的 body source 实现。在 5.2.0 版本里FixedLengthSource.read的流程大致是这样先比较剩余字节数然后从底层 socket 读取如果读到了-1就抛异常。貌似新旧版本看起来没什么区别但真正变化不在read本身而在Http1ExchangeCodec的finishRequest和responseBodyComplete的调用路径上。旧版本里当FixedLengthSource读到 EOF 的时候检查的是“bytesRemaining 0”也就是说它对“短了几个字节”这件事并不苛刻只要底层连接已经达到了“流结束”状态就会把当前套接字直接当作“连接已关闭”放过去然后把异常吞掉甚至在部分场景下会把已读到的残缺 body 返回给上层。这种宽松处理显然不严谨但对很多服务端不规范的响应反而是“能跑就行”。新版本 5.3.0 把这条路径重构成了更严格的“长度校验”每一个字节都必须严格对齐Content-Length一旦在未读完 body 的任意位置遇到 EOF立刻抛出ProtocolException或者IOException: unexpected end of stream而且这个异常不会走降级读取逻辑。这在代码 review 里看起来是一个非常“正确”的修复——严格符合 HTTP/1.1 规范也在 OkHttp 官方 Issue 里有对应讨论对应的一行注释大概意思是“我们认为短响应体必须被显式处理”。这就是典型的“文档没写、Release Notes 没提、但对线上行为影响巨大”的隐形变更。5. 根因分析OkHttp 5.3 把服务端短响应从“容忍”改成了“必现”5.1 HTTP/1.1 灰色地带Content-Length 和 EOF 并不总是可信这里展开说一下 HTTP/1.1 的底层机制。响应体有两种主流长度确定方式响应头带Content-Length表示响应体精确字节数响应头不带Content-Length而以连接关闭EOF为 body 结束标志。按 RFC 规范来说带Content-Length的响应当读取到 EOF 且字节数不足时客户端应该主动报错因为这是一个不完整的响应。但在真实互联网环境下代理、负载均衡、网关、老旧服务器、边缘节点、防火墙都可能在任何一层悄悄把这个响应截断。服务端觉得“我发完了”客户端收到的 TCP 流却缺了几十甚至几千字节。传统的可靠性设计会倾向于把这个判断放宽认为 EOF 也是一个可以接受的终止信号这是许多 HTTP 客户端历史上能够容忍不规范服务器行为的原因。旧版 OkHttp 确实没有把这个场景做强一致性校验所以即使服务端有问题客户端也往往是“半好半坏”地返回数据。5.2 5.3 变更了什么从“流握手”变成“长度强制对齐”5.3.0 在重构内部 body 读取链时把 EOF 当成了硬错误。具体落在Http1ExchangeCodec$FixedLengthSource这段逻辑// 旧版本 5.2.0 的读取逻辑伪代码 override fun read(sink: Buffer, byteCount: Long): Long { if (bytesRemaining 0L) return -1L val bytesRead source.read(sink, minOf(bytesRemaining, byteCount)) if (bytesRead -1L) return -1L // 看直接返回 -1把 EOF 当成正常结束 bytesRemaining - bytesRead return bytesRead } // 新版本 5.3.0 的读取逻辑伪代码 override fun read(sink: Buffer, byteCount: Long): Long { if (bytesRemaining 0L) return -1L val bytesRead source.read(sink, minOf(bytesRemaining, byteCount)) if (bytesRead -1L) { throw ProtocolException(unexpected end of stream) // 强制校验长度 } bytesRemaining - bytesRead return bytesRead }虽然伪代码抹掉了很多细节但核心语义变化是对的一个错误地被当成了“关闭”的 EOF变成了一个必须被上层感知的协议异常。换到业务视角就是服务端如果返回了Content-Length: 10240的 JSON 响应但因为网络或网关原因实际只到了10239字节旧版本可能还会让解析器走到一半然后交出截断的数据应用层也许能成功 parse新版本则直接在读取阶段抛异常。对一个 App 来说接口数据丢了几个字节本来就是服务端或传输链路的问题现在客户端把问题从“静默异常”变成“显性崩溃”互联网的脏数据一下就全部转化成了崩溃量。5.3 为什么只在线上偶发这个就很好解释了。因为服务端并不是每一次响应都被截断CDN 的某个边缘节点故障、后端某台旧服务器的 GC 停顿、负载均衡器的连接超时都可能导致“偶发”的短响应。这样的概率平时很低旧客户端又把它吞掉大家当然感知不到。一旦新的 OkHttp 把这些不完整的响应全部转成异常那么就相当于把原本散落在网络各处的“暗坑”一次性全部点亮。每个请求只要命中一次就是一个崩溃样本单用户一天可能触发 3 到 4 次这在崩溃指标上就是“偶发、分散、无规律”的典型画像。6. 修复方案客户端兜底、服务端对齐、线上观测三管齐下6.1 客户端加一层“响应完整性守护拦截器”因为 OkHttp 5.3.0 的严格校验是符合规范的正确行为我们并不想去改 OkHttp 内部行为也不想简单地降级回 5.2.0。工程上最直接的办法是在业务层加一个全局拦截器对所有请求的响应做一次拦截。在读取ResponseBody之前先取出contentLength()在真正把 body 消费完毕之后再对比contentLength()和实际读取字节数。如果发现实际字节数小于声明值就主动把这个响应判定为失败并且重新发起一次请求。一个简化版的拦截器思路class ResponseIntegrityInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request chain.request() val response chain.proceed(request) val contentType response.header(Content-Length) if (contentType.isNullOrBlank()) return response val expectedLength contentType.toLongOrNull() ?: return response val body response.body ?: return response // 读取 body 时记录真实字节数 val countingSource CountingSource(body.source()) val newBody body.newSource(countingSource).buffer() val newResponse response.newBuilder() .body(object : ResponseBody() { override fun contentType() body.contentType() override fun contentLength(): Long body.contentLength() override fun source(): BufferedSource newBody }) .build() // 让上层正常调用 string()后续通过计数判断 return newResponse } }实际代码比这个要复杂需要处理contentLength()为 -1 的情况、gzip 解压后的长度差异、以及避免影响正常接口性能。我们做了白名单策略只对核心接口加这个守卫非核心接口允许直接失败。这种方案能保证如果服务端偶发短响应客户端会快速重试一次而不是崩溃如果重试两次仍然短响应才会返回错误给业务层由业务层降级展示。6.2 服务端修复上游 CDN 和源站的短写问题但在拦截器发布之前我们同步把线上崩溃数据里涉及的服务端 URL 清单交给了运维。最终定位到几个海外边缘节点在负载高时会提前中断响应体上游源站本身没有短写这基本上是边缘节点上的一次连接中断。运维那边做了两件事一是把受影响 URL 的缓存策略从“缓存 10 分钟”调成“不缓存”让回源比例增加绕过问题边缘节点二是把边缘节点的lingering_close超时从 2 秒调大一些降低上游连接被中断的概率。这些服务端处理能减少触发条件但并不能消灭所有节点上的偶发问题所以在客户端兜底仍然是必要的。6.3 线上观测增加“短响应计数器”而不是只盯崩溃这次事故带给我们一个教训很多故障在崩溃平台上显示的只是冰山一角真正被旧版本吞掉的脏数据可能比崩溃量高一个数量级。我们在拦截器里增加了一个自定义指标short_response_total记录所有“Content-Length 声明值和实际读取值不一致”的次数。上线后第一天看到线上这类短响应事件的量级大约是崩溃量的 40 倍也就是 OkHttp 5.3 只是把其中 1/40 的异常变成了崩溃剩余大部分数据恰好读到了完整长度或者异常被其它逻辑吞掉了。用这个指标做基线监控后续再出现服务端截断我们能在崩溃发生前提前感知。6.4 版本策略升级 OkHttp 这类底层库不要在“发布前一周”做最后说版本策略。OkHttp 不像普通业务 SDK它几乎被所有网络请求路径引用任何细小的语义变化都会被放大成线上崩溃。所以现在我们的规则是底层网络库升级至少要提前两个迭代并且至少要有一个完整的小流量版本跑满 3 天以上不能用一周例行发布去赌线上概率。尤其是这种“文档变更”不在显眼位置、源码 diff 也不大的版本必须通过真实线上数据来验证行为差异不能在灰度阶段因为“崩溃率没涨”就认为安全。7. 如果重来一次我会更早做这几件事复盘这件事我最想分享的不是那个具体的 OkHttp 变更而是排查“偶发崩溃”时的一些方法论。如果重来一次我会在崩溃平台数据出来后第一时间就排查“这个异常的新增量来自哪个版本”而不是被堆栈里的unexpected end of stream带着往服务端跑。很多看起来一模一样的异常在不同版本里的实际触发概率可能差几十倍版本对比永远是最快缩小范围的手段。另外每次升级网络库或者任何底层组件我都会把响应体读取这类“看起来不可能改”的代码路径单独拉出来做一次 diff。底层库的规范修正往往都发生在这些容易被忽略的地方而它们对线上稳定性的影响往往比新增一个 API 大得多。最后是个人实战体会网络请求的偶发崩溃本质是“客户端把故障暴露了出来”而不是“客户端制造了故障”。真正要修的不是让客户端重新掩盖故障而是让整个链路有能力识别脏数据、快速恢复并且打点。我们最后加上拦截器后并没有让服务端短响应消失但崩溃率从 1300 次直接降到了个位数而短响应计数指标仍然保持在较高水平说明这条“显性检测 客户端兜底 服务端治理”的路子是走得通的。