thrift-php暴内存深坑填坑
Posted 尹少爷的平凡乐趣
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了thrift-php暴内存深坑填坑相关的知识,希望对你有一定的参考价值。
thrift-php暴内存深坑填坑
起因
项目中一次偶然的端口错误配置,导致thrift-php的客户端请求到了http的端口上,然后fpm进程内存超限异常退出。
至于如何查到这个问题,在一个大项目中定位也不容易。不过再此不多展开,多看nginx日志,php日志还是管用的。
复现
在更短的代码中复现问题能够更容易的定位问题。
拿git.apache.org/thrift.git/tutorial/php/PhpClient.php示例代码稍微改一下即可。
修改的部分:
use Thrift\Transport\TFramedTransport; // 开始位置添加一行
$socket = new TSocket('10.88.128.15', 8000);
// 在原$socket之后直接添加一行,这里的端口是http协议的
$transport = new TFramedTransport($socket, true,true);
// 在原$transport之后直接添加一行
这时候执行就等着进程暴内存崩溃吧。php PhpClient.php:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 1213486192 bytes) in git.apache.org/thrift.git/lib/php/lib/Thrift/Transport/TSocket.php on line 278
初步定位
TSocket.php:278行的代码:
$data = fread($this->handle_, $len);
这行是个php内置函数,应该没有大问题,最有可能的问题是$len。输出一下就能确定。
另一个问题,当前函数是从哪个调用过来的,需要检查上层调用函数是谁,为什么传递了错误的$len参数过来。
if ($len > 123456) {
throw new \Exception('wtf');
}
在278行之前,加这么一行,追溯到出现异常$len参数的整个调用栈。
从抓图中能够看到首次出现是在TFrameTransport.php(110) readFrame() 函数中调用TTransport->readAll()时出现的。
查看代码,添加打印log,可以发现这个$len的来源是传输流最开始4个字节转为整数的值。
$buf = $this->transport_->readAll(4);
$val = unpack('N', $buf);
$sz = $val[1];
$this->rBuf_ = $this->transport_->readAll($sz);
$buf 变量的4个字节,是从:8000端口读取到的。我们知道:8000端口是HTTP的情况下,首先返回 HTTP 200 OK一行响应。也就是$buf == "HTTP",转换为$sz之后是一个比较大的数,换算一下约是1.2G。这就是导致php进程暴内存退出的原因了,在php-fpm进程上应该是一样的。
修复初步考虑
判断 $buf 是否是 "HTTP"字符串
判断如果 $sz 超过某个值则报错
由于之前对thrift协议并不太熟悉,总觉得需要再看下文档。在有了这个大概的指导思考之后,又翻了翻thrift的协议文档,果然发现这个坑的来历。
实际上thrift-rpc.md中说明了这一点,关于frame的大小的限制问题。可惜thrift-php代码中并未发现这个协议标准要求的frame大小的相关实现。后来搜索代码,发现thrift-go/thrift-java是实现了这个协议标准的。那么说thrift-php到底是忘记(填坑)实现这个协议标准了吗?
https://github.com/apache/thrift/blob/master/doc/specs/thrift-rpc.md#framed-vs-unframed-transport
参考实现
既然thrift-xxx已经实现了这个功能,不妨参考一下,所以需要看看其源代码。
为TFrameTransport类添加一个常量,以及一个变量,分别表示规范默认的frame最大长度,以及应用设置的frame最大长度。
const DEFAULT_MAX_LENGTH = 16384000; private $maxLength_ = TFramedTransport::DEFAULT_MAX_LENGTH;
以及在readFrame方法中,添加对读取到的长度变量的判断:
if ($sz < 0) {
throw new TTransportException("Read a negative frame size ({$sz})!",
TTransportException::CORRUPTED_FRAME);
}
if ($sz > $this->maxLength_) {
throw new TTransportException("Frame size ({$sz}) larger
than max length ({$this->maxLength_})!",
TTransportException::CORRUPTED_FRAME);
}
这样TFrameTransport类的实现就符合thrift spec规范了。并且能够有效处理frame无效的情况。
其中,TTransportException::CORRUPTED_FRAME 定义为一个错误常量编号。
const CORRUPTED_FRAME = 5;
这个修复,能够防止thrift客户端连接到http服务端导致的暴内存导致php-fpm进程异常退出问题。
非TFrameTransport的问题
在处理上面TFrameTransport的问题时,想到可能其他*Transport会不会有问题,试了一下果然也是有同样问题的,比如TBufferedTransport这个类。
而在thrift spec规范中并没有这个类读取长度的限制,而且从原理上来说,也无法确定读取的最大长度限制,所以直接添加长度限制这种修复方式并不太适用,虽然有用但不是最好的处理方式。
碰到这个问题,差点没过去坎,太难找到一种很好的修复方法了。还好,在摸索了差不多2天,找到了自认为修复thrift-php这个问题的方法。请读者君继续阅读一下节。
针对TSocket的进一步的修复
再回头看最开始的问题,是php/fpm进程内存超限后异常退出。那么这一步修复的主要思路就出来,在读取数据长度大于php设置的内存上限之前就提前计算出来,并在预计会超过php内存上限提前报出错误,就不会产生进入到php核心代码之后出现的Fatal错误了。
以下是修复代码实现:TSocket.php
添加私有类成员变量:
private $memoryLimit_ = -1;
构造函数:__construct()中:
$this->memoryLimit_ = TSocket::return_bytes(ini_get('memory_limit'));
read()方法中:
$curmem = memory_get_usage(true);
if ($this->memoryLimit_ > 0 && ($curmem + $len) > $this->memoryLimit_) {
throw new TTransportException('TSocket: Allowed memory size of '.
$this->memoryLimit_.'bytes will be exhausted
(tried to allocate '. $len. ' bytes)');
}
这种修复方法适用于所有的Transport,应该还是比较准确的,毕竟不会超出内存上限,并且能够友好的报告错误。
通过以上两个修复步骤,实现针对所遇问题的完整的修复。并能够在其他未知情况下做出的最好的处理,防止php进程超出内存上限而崩溃,并报告有效错误信息。
关于这个问题的深入考虑
实际上server端并没有这么大的数据响应,fread为啥会导致暴内存?
https://github.com/php-src/php/blob/0eb3c377d49a331282b943dba165b4b9df56fad2/ext/standard/file.c#L1813
看这一行,会发现php首先按照参数分配内存,并不关心是否真有这么大的数据。
这种写法也有php实现的考虑吧,毕竟简单直接,速度快。
除了这种实现方式之外,还考虑到一种实现方式,采用分块读取的方式,分配一个比较小的读取buf char[5120],每读取一次把临时buf中的数据追加到要返回的buffer中,这要在多数情况下,由于服务端返回的数据长度也是有限的,也能够防止一些异常情况。
当然这种方式,对程序执行效率有影响,需要一次内存拷贝,以及多次的内存分配操作。
问题总是要修的,可能出现的问题一定会出现的,根据情况判断,尽量高效还没有bug更好。
在当前这个问题上,也不能说是php全错的,因为php就是提供了安全内存分配并报错的机制,这是php本身允许的。我的认为是问题出在thrift-php的实现上,代码实现并不够严谨。
对于php设置为内存无限使用的情况,修复后的代码会如何执行?
对于最大frame size的规范的修复方式,这种假期没有影响,依旧如前的有效。
对于第二种修复方式,导致结果是,程序尽力在可用的内存资源范围内让程序不崩溃并完成任务。
比如,上面遇到的问题是要读取一个1.2G的数据,如果这时php设置的内存无限制,实现上也就只能够读取到大概600字节的数据(但瞬时内存的确会用掉1.2G),并且由后面的协议解析时发现这个错误。所以资源很重要,还是会影响程序的运行的。
这过程中还尝试过的方法
使用ob_start, ob_end抓取输出,但是对Fatal信息不起作用。
使用register_shutdown_function功能,这个从逻辑上比较复杂,而且在命令行执行php和fcgi执行时实现并不相同,所以没有采用。
第一次写小楷。。真心的好难写
以上是关于thrift-php暴内存深坑填坑的主要内容,如果未能解决你的问题,请参考以下文章