公司某业务出现严重的线上故障,复盘发现原因竟是某接口的Thrift IDL变更,未及时同步所有上游,导致上游某服务OOM引发Crash。看似操作不规范是本次故障的根因,但进一步思考:该接口非主流程接口,即使IDL版本不统一,带来“最坏”的后果不应该是“仅仅报错”吗,为什么会产生OOM导致整个服务Crash?
实际上这样的例子并不少见,很多公司内部RPC使用Thrift协议,IDL变更及版本不一致在所难免,极端情况下,安全团队扫描thrift端口也会出现类似故障。为了避免更多团队采坑,有必要研究Thrift何种情况下会触发,以及为什么会触发OOM。
一. 从IDL的字段变更说起
化繁为简,该故障的发生可以这样描述:某Thrift服务的返回值是一个数组,数组中每个元素本来包含5个字段,某次调整后,在中间位置新增1个字段,其余保持不变。服务上线后,某上游调用方未收到变更通知,仍使用旧版本的SDK。参照下图所示:
看到这里,有经验的同学已经瑟瑟发抖。在Thrift协议跨语言、高性能的背后,做了很多取舍,比如接收方在反序列化时,仅做了方法名等少量的校验,通过序号、字段类型认为该返回值属于哪个字段,而并未校验字段名,因此一旦上下游IDL版本不一致,极易产生字段错位的情况。以上述IDL为例,id、image字段正确解析,而bg_image被错误解析为link、link被错误解析为title:
字段增加时,考虑到字段位置及新旧版本不匹配的各种场景,我们枚举可能的后果:
1. 新增字段放到中间位置
A. 新Server、旧Client:容易字段错位,引发业务错误,不推荐
B. 旧Server、新Client:容易字段错位,引发业务错误,不推荐
2. 新增字段放到最后,并且是required
A. 新Server、旧Client:旧Client感知不到新增字段,会忽略
B. 旧Server、新Client:新Client获取不到新增字段,会报错
3. 新增字段放到最后,并且是optional
A. 新Server、旧Client:旧Client感知不到新增字段,会忽略
B. 旧Server、新Client:新Client获取不到新增Optional字段,会忽略
再进一步,如果是删除字段呢?从删除字段的位置、是否required,大家可自行思考,当然结果类似,轻则被忽略,重则字段错位,当然更严重的会导致服务OOM。
小结:Thrift IDL不要变更已有字段的序列号,上下游版本不一致极易发生错位现象。如需新增字段,应放到最后并设置为optional。
二. OOM问题复现
回到IDL变更上来,为什么会引发上游服务的OOM呢?我们用一段很短的IDL就可以重现。
namespace java com.didiglobal.thrift.sample struct Items{ 1:required i64 id; 2:required list<Item> items; } struct Item { 1:required string name; 2:required string image; // 新增字段 3:required list<string> contents; } service Sample { Items getItems(1:i64 id); }
如果你也想尝试,可以下载项目代码,在本地搭建环境。
1. Github下载项目代码:https://github.com/aqingsao/thrift-oom
2. 使用你喜欢的IDE导入工程,该项目基于thrift 0.11.0版本,依赖JDK1.8+以及Maven
3. 运行包com.didiglobal.thrift.sample1.sampleold中OldClientNewServerTest.java类的这个测试用例:oldclient_should_oom_at_concurrency_10
该测试用例会启动一个简单的Thrift服务,客户端使用10个并发,很快触发OOM(如果遇到问题,可联系作者)。
使用不同的Thrift版本,0.9.3到最新的0.13.0-snapshot均可以重现。使用jmap命令,可以看到应用创建了大量的字节数组。
小结:只需要10个并发就可以重现OOM,该问题广泛存在于Thrift 版本0.9.3到最新的0.13.0-snapshot。
三. 为什么会OOM?
本次故障的根因是新增String字段,并且客户端进程创建了大量的字节数组,首先怀疑字段错位后Thrift未正确处理,但查看了下源码,thrift对字段类型不匹配、多余字段均作了skip处理:
思考了几个其他方向,并做了多次尝试,均发现思路不对,而且始终无法解释的是,单个请求数据量只有上百字节,为什么只需要10个并发、几十个请求就会OOM?这些请求的数据量累加起来也不过几十K,一度陷入僵局。
下班的路上反复思考,是不是和Thrift抛出的异常有关,恰遇某个路口超长时间的红灯,每每感叹该路口浪费多少青春年华,此时却从容拿出电脑,对Thrift抛出的异常做了临时处理,运行测试,果然不再OOM了!
思路一下子清晰起来:虽然Thrift做了多余字段的skip处理,但由于抛出的异常,这些skip操作并未执行到,甚至,List中第一个元素校验抛出异常后,后面所有字段都未继续消费!
按该思路重新阅读相关代码,果然找到了可疑点:Thrift接受服务端响应时,会首先解析TMessage对象,前4个字节(I32)代表了某个字符串的长度,后面readStringBody()方法会分配该长度的字节数组(byte[] buf = new byte[size]),该字符串实际上是thrift的方法名,而debug发现,长度值是184549632,大约176M,这合理解释了为什么10个并发就会触发OOM。
小结:Thrift接收到请求后首先读取TMessage结构,IDL版本不一致的极端情况下,会分配176M的内存空间,导致10个并发就占用上G内存,触发OOM。



