上周三晚上十一点,我正在翻运营群里的反馈,突然看到告警群里连刷了三条崩溃率超标:Android端从平时的0.02%悄悄爬到了0.3%左右,而且趋势完全没有回落的意思。我第一反应是又是哪次发版埋了雷,结果查了版本记录,当天唯一的上线变更就是一次不起眼的依赖升级——把OkHttp从4.12.0升到了5.3.0。当时觉得5.x和4.x是大版本跳跃,心里其实打了个突,但想着5.0正式版都出这么久了,网上也没看到什么特别严重的兼容性反馈,就放过去了。
等我把崩溃堆栈捞出来一看,彻底没睡意了:崩溃点全部指向okhttp3.internal.http2.Http2Connection,更深一层是Http2Reader.readHeaders,抛的全是IllegalArgumentException。再往后翻,就是ReaderRunnable和TaskRunner线程。这个调用链不在任何一个业务线程里,也不是我们能try-catch住的调用栈。更诡异的是,这崩溃是偶发的,线上几百万人只有极少数请求会踩中,本地怎么压测都复现不出来。
这篇文章就完整复盘一下这次事故:从崩溃表象、堆栈取证、源码比对,到最后复现和修复的全过程。如果你现在也依赖OkHttp,尤其是准备升级5.x或者已经升了5.x但线上偶发崩溃的,这篇应该能帮你省下好几天排查时间。
1. 崩溃现场与初步定位
1.1 崩溃堆栈长什么样
先贴一份我们当时的真实堆栈,已经脱敏过包名和类名:
java.lang.IllegalArgumentException: Unexpected char 0x00 at 3 in header value: 8fjsd.... at okhttp3.internal.http2.Header.<init>(Header.kt:98) at okhttp3.internal.http2.Http2Reader.readHeaders(Http2Reader.kt:218) at okhttp3.internal.http2.Http2Reader.nextFrame(Http2Reader.kt:112) at okhttp3.internal.http2.Http2Connection$ReaderRunnable.execute(Http2Connection.kt:203) at okhttp3.internal.concurrent.TaskRunner.runTask(TaskRunner.kt:178) at okhttp3.internal.concurrent.TaskRunner$RealThread.run(TaskRunner.kt:146)关键信息有两个:
- 第一,异常类型是
IllegalArgumentException,不是IOException。也就是说,这不是网络超时、连接重置之类的“可预期异常”,而是代码内部对某个输入做了合法性检查,检查不通过直接抛出来的崩溃型异常。 - 第二,抛异常的线程是
TaskRunner$RealThread。OkHttp内部有自己的一套线程调度,专门跑连接维护、HTTP/2帧读取这种后台任务。这个线程不是我们业务发起请求的线程。
这两个特征叠加起来,有一个很麻烦的后果:我们在业务层给OkHttpClient包再多的try-catch都没有用,因为异常根本不在业务线程里抛。默认的UncaughtExceptionHandler会直接把这个未捕获异常当成致命错误,进程当场挂掉。这就是为什么线上表现为崩溃,而不是请求失败重试一下就能自愈。
1.2 为什么“偶发”是最难啃的骨头
这类偶发崩溃最让人头疼的地方在于:你不能靠“复现”来入门。
刚开始我们尝试在测试环境复现,请求正常的接口没有崩溃,请求旧的代理商接口也没有崩溃,压测工具来回跑半小时依然没有崩溃。试了一整晚之后我才慢慢意识到,这个崩溃的触发条件一定非常特殊,不是接口固定返回什么内容,而是某个响应头在特定条件下携带了某种特殊字符。
后来我养成了个习惯:遇到偶发崩溃,先不急着本地复现,而是回到崩溃后台去做“特征聚类”。我们当时把所有崩溃日志拉出来,按机型、系统版本、网络类型、接口路径、请求时段做了交叉统计,发现三个规律:
- 崩溃集中在开启了HTTP/2的连接上,HTTP/1.1请求一条都没有。
- 请求的目标集中在某个老网关域名,基本都是业务侧主动加了自定义响应头的接口。
- 系统版本、机型没有明显倾向,但从崩溃占比来看,Android 7~9的存量机型偏多,这部分用户更容易走移动网络。
这三条线索一出来,排查范围一下就缩小了:大概率是某个老服务端在HTTP/2响应里返回了一个“不合规但平时没人管”的响应头,而OkHttp 5.3这次升级,把这个“没人管”变成了“直接抛异常”。
1.3 一步一步缩小嫌疑范围
缩小到“HTTP/2 + 自定义响应头”之后,我们的排查路径大概是这样的:
第一步,给OkHttpClient挂上EventListener,在responseHeadersEnd和requestFailed里把请求URL、响应头字节、异常信息都记录下来。这一步不用改业务代码,只需要新增一个监听器即可。
第二步,在灰度包上把HttpLoggingInterceptor的级别开到BODY。注意这里有个坑:很多非法字符在日志里显示为空白、问号或者被替换字符,肉眼根本看不出问题。后来我们直接用ByteString把header value转成了十六进制,这才发现崩溃请求的响应头里藏着一个0x00字节。
第三步,对比OkHttp 5.2.1和5.3.0两个版本在同一请求下的行为。5.2.1能正常返回,5.3.0直接崩溃。到这一步,基本可以锁定是版本升级引入的行为差异了。
整个过程花了大概一个下午加一个晚上。真正麻烦的其实不是定位到版本差异,而是搞清楚“为什么5.3.0会把一个非法字符当成致命错误”。
2. 根因:OkHttp 5.3 的一次“隐形变更”
2.1 源码差异到底差在哪里
我们拉了两个版本的源码做diff,重点看okhttp3.internal.http2包底下的Header.kt和Http2Reader.kt。
5.3.0的Header构造方法里,明显多了一段校验逻辑,示意代码大概长这样:
class Header( val name: ByteString, val value: ByteString ) { init { require(!value.contains(0)) { "Unexpected char 0x00 in header value: $value" } } }也就是说,当HTTP/2帧里解析出一个value中带0x00字节的header字段时,Header构造函数会直接抛IllegalArgumentException。按照HTTP/2的RFC规范,header value确实不应该包含NUL这类控制字符,这个校验本身不算错。
问题在于这个异常抛出来的位置。我们顺着堆栈往下看,Header是由Http2Reader.readHeaders调用的,而readHeaders是在Http2Connection$ReaderRunnable的线程循环里执行的。也就是说,OkHttp在读取对端发来的HEADERS帧时,一旦发现非法header,它不是在业务线程里返回一个异常结果让你处理,而是在后台读线程里直接炸掉。
这个后台读线程一旦因为未捕获异常退出,整个HTTP/2连接就直接废了。更糟糕的是,对于已经在等待该连接响应的业务请求来说,它们可能永远等不到结果,只能等到超时。但崩溃本身已经被默认异常处理器截胡,最终表现成一个无差别闪退。
2.2 为什么早期版本没崩
那为什么同样的响应头在4.12.0和5.2.1里没事?
这里就要说到“隐形变更”的本质了。在旧版本里,Header接收的value是原始字节数组,遇到0x00这种控制字符,它不会主动判负,而是继续往下传。至于传下去之后用不用,取决于上层业务。如果业务逻辑只是把这个header存起来或者打日志,那就相安无事。
事实上,很多老服务端都会出这种“脏头”:可能是网关拼接时出了问题,可能是某个历史系统直接塞了二进制字节进来。以前大家约定俗成“宽松解析”,也就一直没问题。
到了5.3.0,OkHttp把这块逻辑从“懒校验”改成了“严格校验”。从协议合规的角度看,这绝对是一个正确改动,应该点赞。但对那些依赖旧行为的调用方来说,这就是一次不折不扣的破坏性变更。
更隐蔽的是,官方release note里对这次改动只写了一句话,大意是“改进HTTP/2合规性和错误处理”。没有单独的CHANGELOG条目,没有Migration Guide,没有醒目的Breaking changes标记。如果不逐行看源码,你根本不会想到升级一个Patch版本,会把一个偶发非法头从“可容忍”变成“必崩”。
2.3 触发条件到底有多隐蔽
我们后来把线上那个崩溃请求的响应头抓了出来,用Wireshark看原始帧,情况是这样的:
00000000 58 2d 52 65 71 2d 49 64 3a 20 38 66 6a 73 64 00 |X-Req-Id: 8fjsd.|这个响应头的名字是X-Req-Id,value是8fjsd加一个0x00,后面其实还有几个不可见字符。从语义上看,这个字段本身就是服务端用来做请求追踪的,正常情况下根本不会有人解析它的内容,更不会有人想到它会带着一个NUL字节。
服务端为什么会产生这种头?问了那边同事之后才知道,老网关在生成追踪ID时会拼接一个二进制字段,某次发布之后拼接逻辑出了点偏差,追ID里混进了0x00。这个脏数据已经存在了一两个月,但客户端一直是4.x,解析时睁一只眼闭一只眼,所以一直没人发现。
这就解释了为什么崩溃是“偶发”的:只有命中了那台异常网关、并且走了HTTP/2、并且恰好返回了这段脏header的请求才会崩。正常接口一百次里可能也就一两次会命中,本地想复现就得精确复刻这整条链路。
3. 完整复现与修复方案
3.1 本地复现的一种可行姿势
前面说了,MockWebServer大概率没法直接构造这种非法header,因为你设置header时,MockWebServer底层用的也是OkHttp的Headers,它自己就带校验。我们最后是用原始TCP + HTTP/2帧的方式绕过去的,这里给一个最小化思路:
- 本地起一个TCP Server,接收客户端升级到HTTP/2的preface。
- 收到客户端的SETTINGS帧后,回复一个空SETTINGS帧和确认帧。
- 直接构造一个HEADERS帧,用HPACK编码块写入
:status: 200和x-debug: abc\u0000def。 - 客户端使用OkHttp请求这个本地服务,协议指定为HTTP_1_1和HTTP_2,观察是否触发同样的崩溃。
这个方案需要一点HTTP/2帧格式基础,直接写代码比较啰嗦。如果你不想手撸帧,也可以借助Netty的Http2FrameCodec,核心片段大概是这么个意思:
DefaultHttp2Headers headers = new DefaultHttp2Headers(); headers.add(":status", "200"); headers.add("x-debug", "abc\u0000def"); DefaultHttp2HeadersFrame headersFrame = new DefaultHttp2HeadersFrame(headers, true).streamId(3); channel.writeInbound(headersFrame);这只是一种取巧的模拟方式,关键是把一个带0x00的header塞进HTTP/2响应流里。只要能构造出这一步,后续崩溃就是稳定复现了。
3.2 客户端临时止血方案
复现成功之后,我们先把线上版本回滚到了5.2.1,崩溃率立刻恢复正常。但回滚只是止血,治标不治本。
如果想在不降级的情况下先撑着,还有一个更轻量的临时手段:对受影响域名强制走HTTP/1.1。给OkHttpClient配置protocols(Collections.singletonList(Protocol.HTTP_1_1)),请求就会跳过HTTP/2帧解析,也就绕开了那个抛异常的代码路径。
这里有个额外的好处:HTTP/1.1是逐行读取响应头的,即使遇到带控制字符的异常头,通常也只是被当作普通文本读入,不会在后台线程里直接判负。就算真的出问题,异常也更容易暴露在调用线程中,业务层至少能catch住转成一次请求失败,而不是整个进程闪退。
当然,强制HTTP/1.1有明显的性能代价,尤其是并发请求多的场景,队头阻塞和连接复用效率都会下降,所以这个方案只适合故障期间的应急,不适合长期挂在线上。
3.3 服务端清洗与整改
真正的根治方案,必须是让服务端不再产出非法响应头。
我们当时的做法是三步走:
- 第一,通知网关团队排查
X-Req-Id生成逻辑,找到混入二进制字节的位置并修复。 - 第二,在网关层加了一道通用清洗逻辑,用正则把响应头value中的控制字符直接剔除。
- 第三,建立监控告警,如果网关再次吐出包含
0x00的响应头,立刻报警。
这一步看着像是“别人家的事”,但其实对客户端团队特别重要。OkHttp从5.x开始已经明显在向严格协议合规靠拢,以后再出现类似脏数据,崩溃只会越来越多。与其在客户端一遍一遍打补丁,不如推动服务端把协议合规性管起来。
3.4 线上验证效果
修复之后,我们把OkHttp升回5.3.0,不过这次没有直接全量,而是先在5%的流量上观察了大半天,确认崩溃率没有再起来,才逐步放量到100%。
同时我们把EventListener的异常上报保留了小半年,专门盯HTTP/2帧解析异常。真实结果是:网关修复后,0x00头再也没出现过,崩溃率长期稳定在0.01%以下。
这里还想多说一句:灰度观察时间不能太短,尤其这种偶发触发型的崩溃,最短也要覆盖一个完整的业务低峰到高峰周期,否则很容易在灰度阶段“看起来没问题”,一放量又炸了。
4. 从这次事故中沉淀的排查清单
4.1 遇到三方库内部崩溃的通用排查路线
这次事故之后,我整理了一套适合自己的排查流程,遇到类似情况基本照着走:
- 第一步,先看线程名。崩溃在线程里的位置比崩溃类型更关键。如果是
OkHttp的TaskRunner、OkHttp ConnectionPool这类内部线程,基本可以放弃“业务层try-catch”的幻想了。 - 第二步,对比最近一次版本升级。不要只看版本号是Pacth还是Minor,只要改了三方库依赖,就要列入嫌疑对象。
- 第三步,找特征。把崩溃样本按域名、接口、机型、系统、网络类型做交叉统计,特征越明显,后边复现越容易。
- 第四步,构造特殊输入。正常输入跑不出问题,就往非法输入方向想:非法字符、超大header、异常响应码、畸形帧,往往就是这类问题的高发区。
- 第五步,源码diff。直接拉新旧版本对应类做比对,看到新增的校验逻辑,基本上就离根因不远了。
这套流程不一定每次都能快速命中,但至少能避免一上来就在错误的方向上死磕。
4.2 升级OkHttp时应该重点盯哪些地方
如果你正准备从4.x升到5.x,或者已经在5.x上但没出过问题,这几个关注点值得提前看一眼:
- HTTP/2相关行为变更。OkHttp 5.x对HTTP/2的合规性要求明显更严格,响应头校验、帧处理、GOAWAY处理都可能比旧版本敏感。
- header拼写与字符。业务侧自定义header的时候,尽量只用可见ASCII字符,不要传非ASCII、控制字符或二进制内容。
- 线程模型变化。5.x把更多连接维护工作放到了内部线程池里,一旦出现未捕获异常,崩溃会从内部线程直接引爆进程,业务层很难拦截。
- 默认参数变化。比如
retryOnConnectionFailure、maxRequestsPerHost、连接池大小这些,虽然表面看只是数值调整,但会间接改变连接复用行为,放大其他问题。
4.3 常见OkHttp崩溃堆栈速查表
最后整理一张速查表,这几类堆栈都是我们自己或朋友项目里真实遇到过的,排查方向可以按这个来:
| 崩溃堆栈特征 | 可能原因 | 排查建议 |
|---|---|---|
Http2Connection$ReaderRunnable+IllegalArgumentException | 响应头含非法字符,或HTTP/2帧异常 | 抓包看响应头原始字节,对比升级版本 |
Http2Stream$FramingSource+StreamResetException | 对端发送RST_STREAM或GOAWAY | 检查服务端负载均衡和连接空闲策略 |
Http1ExchangeCodec+EOFException | 服务端提前关闭连接,且无Content-Length | 检查代理、网关、服务端读写超时配置 |
RealCall+IOException: closed | 请求超时或取消时序不对 | 排查超时参数,检查协程取消逻辑 |
ConnectionPool线程中异常 | 连接清理与复用并发问题 | 关注OkHttp版本更新,必要时换最新补丁 |
这张表不是标准答案,但能帮你快速定一个起点。真正排查的时候,还是要把堆栈和线上特征结合起来看。
最后说几句实际的体会
这次事故让我对“第三方库升级”这件事彻底改了态度。以前我也觉得Patch版本升级无脑上就行,现在不管release note写得多轻描淡写,都要在灰度环境里跑够时间,最好还要配上EventListener类的事件监控。OkHttp 5.3这次“隐形变更”,说到底是一次正确的协议合规修复,但对用户来说,任何行为变化都可能变成线上事故,尤其是当服务端存在脏数据时。
另外也想给所有客户端同学提个醒:线上偶发崩溃,不要只盯业务代码,三方库内部线程崩溃往往更隐蔽,也更致命。遇到崩溃堆栈指向okhttp3.internal的,先别急着骂用户环境,多往“非法输入 + 严格校验 + 后台线程未捕获异常”这个组合上想想,大概率会有收获。