【ArchSummit架构师峰会】探讨数据与人工智能相互驱动的关系>>> 了解详情
写点什么

一起变更引发的惨案

  • 2020-04-24
  • 本文字数:5054 字

    阅读完需:约 17 分钟

一起变更引发的惨案

公司某业务出现严重的线上故障,复盘发现原因竟是某接口的 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. 新增字段放到中间位置

  2. A. 新 Server、旧 Client:容易字段错位,引发业务错误,不推荐

  3. B. 旧 Server、新 Client:容易字段错位,引发业务错误,不推荐

  4. 新增字段放到最后,并且是 required

  5. A. 新 Server、旧 Client:旧 Client 感知不到新增字段,会忽略

  6. B. 旧 Server、新 Client:新 Client 获取不到新增字段,会报错

  7. 新增字段放到最后,并且是 optional

  8. A. 新 Server、旧 Client:旧 Client 感知不到新增字段,会忽略

  9. B. 旧 Server、新 Client:新 Client 获取不到新增 Optional 字段,会忽略


再进一步,如果是删除字段呢?从删除字段的位置、是否 required,大家可自行思考,当然结果类似,轻则被忽略,重则字段错位,当然更严重的会导致服务 OOM。


小结:Thrift IDL 不要变更已有字段的序列号,上下游版本不一致极易发生错位现象。如需新增字段,应放到最后并设置为 optional。

二. OOM 问题复现

回到 IDL 变更上来,为什么会引发上游服务的 OOM 呢?我们用一段很短的 IDL 就可以重现。


namespace java  com.didiglobal.thrift.samplestruct 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

  4. 该测试用例会启动一个简单的 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 处理:



查看 skip 方法的实现,一度怀疑在类型为 String 时调用了错误的方法,但考虑 String 使用字节流传输,下图中直接调用 readBinary()方法也无不妥,测试后发现也不是该问题:



思考了几个其他方向,并做了多次尝试,均发现思路不对,而且始终无法解释的是,单个请求数据量只有上百字节,为什么只需要 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。

四. 为什么会分配 176 兆的内存空间?

解决这个问题,有助于了解哪些场景下会触发 OOM,从而更好地避免。


参考 Thrift TServiceClient 的 receiveBase()方法,详细介绍了返回值的解析过程,参考下图可以更好地理解,基本上是一个从前到后类似堆栈的反序列化:



前面已经介绍过,如果返回值中多了参数、或者参数类型不对,Thrift 可以通过 skip()操作对该字段进行忽略。但 thrift 对 struct 结构体有个额外的操作,就是解析完成后的调用 validate()方法,如果结构不合法,会抛出异常:



正常情况下,这里抛出异常,请求失败,应该关闭该连接,或者想重用连接的话,要先把底层 Socket 的输入流做清零处理。但 Thrift 未对输入流做任何处理,直接重用了该 CLient 实例及其底层的 Socket 连接,导致下一次解析响应数据时,读取的是上一次请求失败未处理完的数据。由于连接及底层 Socket 被重用,下一次发出请求,很快收到响应,Thrift 惯例开始读取消息头:readMessageBegin→readI32(),由于存在未清空的脏数据,根据上图的堆栈分析,readI32()时读取的这 4 个字节,分别是:


byte type = TType.STRING; // 字节 0:Item 第一个字段(name)的类型,String,值为 11


short id = 1; // 字节 1 和 2:Item 第一个字段(name)的位置,值为 1


int size = 6; // 字节 3:Item 第一个字段(name)的 size 的首字节,值为 6


byte(1 个字节)+short(2 个字节)+int(第 1 个字节),按 big endian 编码,其值恰好等于 184549632:


小结:184549632 的出现有其必然性,不恰当的 IDL 变更,Thrift 客户端抛出异常后未做输入流清理,下一个请求会把之前的残留数据,错误解析成字符串大小,很快导致 OOM。

五. 没遇到 OOM,我是不是很幸运?

当然依 IDL 不同、异常抛出的位置不同,读取消息头时 readI32()读取的数字不一定恰好是 184549632。项目中提供了一个叫做 sample2 的 IDL,旧 Client 访问新 server 时,该值的大小是 8388864,大约等于 8M,因此 10 个并发甚至 100 个并发也不一定会触发 OOM。


所以当有不恰当的 IDL 变更,你没遇到 OOM,只是幸运而已。


但进一步分析,这种情况虽不会触发 OOM,但客户端一直等待服务端返回 8388864 个字节的数据,久等而不得,于是该连接被阻塞了,一直到超时。


小结:不恰当的 IDL 变更,不会全部导致 OOM,也有可能导致大量连接被阻塞,直到超时错误。

六. 如何修复 OOM?

参考 Thrift themissing guide 文档,IDL 变更要有规范,并在团队内反复宣导。


讽刺的是,“通过规范、最佳实践来避免 Crash 类问题”,本身就不是一种最佳实践,至少使用 Http 协议不用遇到这种问题。


这可以认为是 Thrift 自身的一个缺陷,作为当前广泛使用的 RPC 协议,需要优雅地处理用户必须遇到的 IDL 变更的问题。


思路一:读取任何 struct 类型的字段时,catch 住异常,保障 thrift 把输入流的所有数据读完


Thrift 在反序列化阶段,遇到任何 struct 类型的字段,读取结束后都会调用 validate()方法,因此在 struct 类型字段的读取时,可以添加 try-catch 校验异常,保证 thrift 读完所有的数据。


该思路经测试验证可以,但需要修改的地方较多,不具备可操作性。


思路二:无论是否抛出异常,从协议层面保证清空输入流的残留数据


该思路具备较好的收敛性,只需要修改 Thrift 源码的 2 个文件:TServiceClient 修改如下图


1,TSocket 类的修改如下图 2:



经压测验证,在 Macbook Air 上,使用 100 个并发进行验证,持续 15 分钟,累计发送~160 万次请求,单次请求响应数据量~0.5K,除了正常报错,客户端、服务端内存无任何异常。


小结:Thrift IDL 变更不可避免,上下游版本不一致也不可避免,协议其实可以做到优雅地去处理,而不是 OOM 了事。

七. 有没有临时方案?

思路一:使用严格读模式?


Thrift 众多的配置项中,有严格读(strictRead)、严格写(strictWrite)两个选项,由于严格读默认值为 False,改为 true 是否可以呢?如果 OK 的话,只需要修改配置而无需修改源码,这将会是最轻量级的方案。查看 Thrift 源码,如果命中严格读会提前报错,避免下方 readStringBody()方法分配太大的内存空间:


严格读(strictRead)的修改方式如下:


// 很多情况下大家如此使用 TProtocol 创建连接,此时 strictWrite 值为 true,而、strictRead 值为 false;


TProtocol protocol = new TBinaryProtocol(transport);


// 严格读:直接创建 TBinaryProtocol,传入参数


TProtocol protocol = new TBinaryProtocol(transport, true, true);


// 严格读:使用 Protocol 工厂模式,传入参数


TBinaryProtocol.Factory protFactory = new TBinaryProtocol.Factory(true, true);


经测试验证,使用严格读之后,客户端还会报错,但 OOM 问题消失了!“貌似”这也是我们想要的结果,压测效果如何呢?


在 MacbookAir 上,使用 100 个并发进行验证,5 分钟之后,发送大约 56 万请求,客户端出现大量的 SocketException(Broken pipe),甚至客户端开始宕死。


究其原因,strictRead 虽然避免了创建大量字节数组,但抛异常时 thrift 也未对输入流做任何清理,会产生误读取残余数据,甚至引起连接阻塞。


所以使用严格读,并不能解决这一问题,同样 thrift 提供了另外一个配置项“读取字符串或字节的最大长度(stringLengthLimit_)”,也不能解决该问题。


思路二:使用短连接?


每个请求创建一个短连接,用完就关闭?这样虽可以避免请求之间的数据污染,但毫无疑问也会带来较大的性能损失,通常不建议。


思路三:使用 TFramedTransport?


TFramedTransport 对整帧数据使用了缓存,一次性把底层 Socket 中 Inputstream 中的数据完全读到了 readBuffer,理论上不会出现 OOM 了。


验证了一把,很可惜还是会 OOM,原因是抛异常之后,TFramedTransport 中的 readBuffer 中的数据未做清理,下次请求会错误读取 readBuffer 中的残余数据。


如果你想验证,可以运行 OldClientNewServerTest 中的测试用例:oldclient_should_oom_if_use_TFramedTransport_at_concurrency_10


小结:使用严格读、限制字符串长度等配置方式,使用 TFramedTransport 都不能完美解决问题,要么 OOM,要么连接被阻塞。而短连接对性能影响较大,在 Thrift 中也很少使用。

八. 总结

Thrift IDL 不恰当地变更,当上下游 IDL 版本不一致时,极易引发字段错位、连接阻塞、甚至 OOM。Java、Go 等语言实现中均存在该问题,并广泛存在于 Thrift 0.9 到最新的 0.13.0-snapshot 等各个版本。


该问题触发的根本原因是:客户端接收数据后,对 struct 类型做 validate()时抛出异常,并未对底层的 socket 输入流做清零处理,后续请求误把残留数据读作字节长度,极易引发 OOM,或者导致 Socket 连接阻塞超时。


Thrift IDL 变更不可避免,上下游版本不一致也不可避免,从协议层面提供优雅支持是完全可能的。已向 Thrift 提交 Issue,以及部分语言实现的 Pull Request,但争吵几轮之后,Thrift 维护人员拒绝合并,认为“遵守最佳实践就够了”,他们敷衍的态度太让人 piss off 了。


本文转载自技术锁话公众号。


原文链接:https://mp.weixin.qq.com/s/aqHoM7-hKQOFHRzWipfa8g


2020-04-24 15:473865

评论 1 条评论

发布
用户头像
毛蛋,这个文章是我写的...
2021-11-11 16:11
回复
没有更多了
发现更多内容

分布式锁的3种实现!附代码

王磊

Java

泄露个人信息的2300余名“内鬼”被抓?

极盾科技

数据安全

初露头角!Walrus入选服贸会“数智影响力”数字化转型创新案例

SEAL安全

企业数字化转型 数智化 企业号9月PK榜 中国国际服务贸易协会

蓝易云:如何在 Ubuntu 22.04 LTS 上安装分区编辑器 GParted?

百度搜索:蓝易云

云计算 Linux ubuntu 运维 GParted

高性能MySQL实战(三):性能优化 | 京东物流技术团队

京东科技开发者

京东云 企业号9月PK榜

SPI在Java中的实现与应用 | 京东物流技术团队

京东科技开发者

Java spi 京东云 企业号9月PK榜

分库表数据倾斜的处理让我联想到了AKF模型 | 京东云技术团队

京东科技开发者

数据库 京东云 企业号9月PK榜

MGR新节点RECOVERING状态的分析与解决:caching_sha2_password验证插件的影响

GreatSQL

greatsql mgr

使用代理IP可以解决哪些网络问题?代理ip是怎么优化网络游戏玩家的游戏体验的?

巨量HTTP

代理IP

Scrum Master,这九个问题你问了吗?

敏捷开发

项目管理 敏捷开发 团队协作 Scrum Master

星耀数字中国,先要存下宇宙山河

脑极体

存储

苹果电脑电量显示软件 Magic Battery中文最新版

mac大玩家j

Mac 软件 电池管理工具 电池软件

阿里云PAI-灵骏大模型训练工具Pai-Megatron-Patch正式开源!

阿里云大数据AI技术

机器学习 阿里云

Mac电脑最新2023 Xmind 激活中文版

胖墩儿不胖y

思维导图 Mac软件 mac思维导图 思维导图软件

“源聚一堂”开源技术沙龙济南站顺利举办

inBuilder低代码平台

开源 低代码

从AIxCC大赛看下一代AI漏洞挖掘

云起无垠

ARTS 打卡 第一周,初试ARTS

三掌柜

ARTS 打卡计划

2024第八届浙江智慧城市与智能建筑产品博览会

AIOTE智博会

智慧城市展 智能建筑展

使用 NGINX Unit 实施应用隔离

NGINX开源社区

Unit 应用隔离

2024第二十三届浙江国际智能楼宇技术与智慧安防产品展览会

AIOTE智博会

智慧楼宇展 安防展 智慧安防展

HarmonyOS Codelab 优秀样例——溪村小镇(ArkTS)

HarmonyOS开发者

HarmonyOS

软件测试/测试开发丨ChatGPT在测试计划中的应用策略

测试人

人工智能 软件测试 测试开发 ChatGPT

性能、安全和稳定,DataAPI 为企业 API 保驾护航

袋鼠云数栈

大数据 数据中台 API

奇点云对话顺丰科技、周大生:数据中台不是一次性项目

Geek_2d6073

面向OpenHarmony终端的密码安全关键技术

OpenHarmony开发者

OpenHarmony

跨平台混合应用:用户和开发者的新选择

没有用户名丶

企业综合信息化,人力资源管理,培训考学管理,电子采购(源码系统)

金陵老街

java;

如何实现MongoDB副本集实例间的数据迁移

NineData

数据库 mongodb 复制 迁移 NineData

一起变更引发的惨案_文化 & 方法_技术琐话_InfoQ精选文章