伴随业务的增长,系统压力也在不断增加,再加上机房机架趋于饱和,无法更加有效应对各种突发事件。在这样的情况下,PC主站升级为PHP 7,有哪些技术细节可以分享?
新浪微博在2016年Q2季度公布月活跃用户(MAU)较上年同期增长33%,至2.82亿;日活跃用户(DAU)较上年同期增长36%,至1.26亿,总注册用户达8亿多。PC主站作为重要的流量入口,承载部分用户访问和流量落地,其中我们提供的部分服务(如:头条文章)承担全网所有流量。
随着业务的增长,系统压力也在不断的增加。峰值时,服务器Hits达10W+,CPU使用率也达到了80%,远超报警阈值。另外,当前机房的机架已趋于饱和,遇到突发事件,只能对非核心业务进行降低,挪用这些业务的服务器来进行临时扩容,这种方案只能算是一种临时方案,不能满足长久的业务增长需求。再加上一年一度的三节(圣诞、元旦、春节),系统需预留一定的冗余来应对,所以当前系统面临的问题非常严峻,解决系统压力的问题也迫在眉急。
面对当前的问题,我们内部也给出两套解决方案同步进行。
针对方案一,通过搭建与新机房之间的专线与之打通,高峰时,运用内部自研的混合云DCP平台,对所有资源进行调度管理,实现了真正意义上的弹性扩容。目前该方案已经在部分业务灰度运行,随时能对重点业务进行小流量测试。
针对方案二,系统层面,之前做过多次大范围的优化,比如:
优化效果非常明显,如果再从系统层面进行优化,性能可提升的空间非常有限。好在业界传出了两大福音,分别为HHVM和PHP7。
在PHP7还未正式发布时,我们也研究过HHVM(HipHop Virtual Machine),关于HHVM更多细节,这里就不再赘述,可参考官方说明。下面对它提升性能的方式进行一个简单的介绍。
默认情况下,Zend引擎先将PHP源码编译为opcode,然后Zend解析引擎逐条执行。这里的opcode码,可以理解成C语言级的函数。而HHVM提升性能方式为替代Zend引擎将PHP代码转换成中间字节码(HHVM自己的中间字节码,通常称为中间语言),然后在运行时通过即时(JIT)编译器将这些字节码转换成x64的机器码,类似于Java的JVM。
HHVM为了达到最佳优化效果,需要将PHP的变量类型固定下来,而不是让编译器去猜测。Facebook的工程师们就定义一种Hack写法,进而来达到编译器优化的目的,写法类似如下:
<?hh class point { public float $x, $y; function __construct(float $x, float $y) { $this->x = $x; $this->y = $y; } }
通过前期的调研,如果使用HHVM解析器来优化现有业务代码,为了达到最佳的性能提升,必须对代码进行大量修改。另外,服务部署也比较复杂,有一定的维护成本,综合评估后,该方案我们也就不再考虑。
当然,PHP7的开发进展我们也一直在关注,通过官方测试数据以及内部自己测试,性能提升非常明显。
令人兴奋的是,在去年年底(2015年12月04日),官方终于正式发布了PHP7,并且对原生的代码几乎可以做到完全兼容,性能方面与PHP5比较能提升达一倍左右,和HHVM相比已经是不相上下。
无论从优化成本、风险控制,还是从性能提升上来看,选择PHP7无疑是我们的最佳方案。
微博PC主站从2009年8月13日发布第一版开始,先后经历了6个大的版本,系统架构也随着需求的变化进行过多次重大调整。截止目前,系统部分架构如下。
从系统结构层面来看,系统分应用业务层、应用服务层,系统所依赖基础数据由平台服务层提供。
从服务部署层面来看,业务主要部署在三大服务集群,分别为Home池、Page池以及应用服务池。
为了提升系统性能,我们自研了一些PHP扩展,由于PHP5和PHP7底层差别太大,大部分Zend API接口都进行了调整,所有扩展都需要修改。
所以,将PHP5环境升级至PHP7过程中,主要面临如下风险:
对微博这种数亿用户级别的系统的基础组件进行升级,影响范围将非常之大,一旦某个环节考虑不周全,很有可能会出现比较严重的责任事故。
一些常用的扩展,在发布PHP7时,社区已经做了相应升级,如:Memcached、PHPRedis等。另外,微博使用的Yaf、Yar系列扩展,由于鸟哥(laruence)的支持,很早就全面支持了PHP7。对于这部分扩展,需要详细的测试以及现网灰度来进行保障。
PHP7中,很多常用的API接口都做了改变,例如HashTable API等。对于自研的PHP扩展,需要做升级,比如我们有个核心扩展,升级涉及到代码量达1500行左右。
新升级的扩展,刚开始也面临着各式各样的问题,我们主要通过官方给出的建议以及测试流程来保证其稳定可靠。
首先通过扩展所提供的单元测试来保证扩展功能的正确性。
其次通过大量的压力测试来验证其稳定性。
然后再通过业务代码的自动化测试来保证业务功能的可用性。
最后再通过现网流量灰度来确保最终的稳定可靠。
整体升级过程中,涉及到的修改比较多,以下只简单列举出一些参数变更的函数。
(1)addassocstringl参数4个改为了3个。
//PHP5 add_assoc_stringl(parray, key, value, value_len);//PHP7 add_assoc_stringl(parray, key, value);
(2)addnextindex_stringl 参数从3个改为了2个。
//PHP5 add_assoc_stringl(parray, key, value, value_len);//PHP7 add_assoc_stringl(parray, key, value);
(3)RETURN_STRINGL 参数从3个改为了2个。
//PHP5 RETURN_STRINGL(value, length,dup); //PHP7 RETURN_STRINGL(value, length);
(4)变量声明从堆上分配,改为栈上分配。
//PHP5 zval* sarray_l; ALLOC_INIT_ZVAL(sarray_l); array_init(sarray_l); //PHP7 zval sarray_l; array_init(&sarray_l);
(5)zendhashgetcurrentkey_ex参数从6个改为4个。
//PHP5 ZEND_API int ZEND_FASTCALL zend_hash_get_current_key_ex ( HashTable* ht, char** str_index, uint* str_length, ulong* num_index, zend_bool duplicate, HashPosition* pos); //PHP7 ZEND_API int ZEND_FASTCALL zend_hash_get_current_key_ex( const HashTable *ht, zend_string **str_index, zend_ulong *num_index, HashPosition *pos);
更详细的说明,可参考官方PHP7扩展迁移文档:https://wiki.PHP.net/PHPng-upgrading。
整体来讲,PHP7向前的兼容性正如官方所描述那样,能做到99%向前兼容,不需要做太多修改,但在整体迁移过程中,还是需要做一些兼容处理。
另外,在灰度期间,代码将同时运行于PHP5.4和PHP7环境,现网灰度前,我们首先对所有代码进行了兼容性修改,以便同一套代码能同时兼容两套环境,然后再按计划对相关服务进行现网灰度。
同时,对于PHP7的新特性,升级期间,也强调不允许被使用,否则代码与低版本环境的兼容性会存在问题。
接下来简单介绍下升级PHP7代码过程中,需要注意的地方。
(1)很多致命错误以及可恢复的致命错误,都被转换为异常来处理,这些异常继承自Error类,此类实现了 Throwable 接口。对未定义的函数进行调用,PHP5和PHP7环境下,都会出现致命错误。
undefine_function();
错误提示:
PHP Fatal error: Call to undefined function undefine_function() in /tmp/test.PHP on line 4
在PHP7环境下,这些致命的错误被转换为异常来处理,可以通过异常来进行捕获。
try { undefine_function(); } catch (Throwable $e) { echo $e; }
提示:
Error: Call to undefined function undefine_function() in /tmp/test.PHP:5 Stack trace: #0 {main}
(2)被0除,PHP 7 之前,被0除会导致一条 E_WARNING 并返回 false 。一个数字运算返回一个布尔值是没有意义的,PHP 7 会返回如下的 float 值之一。
如下:
var_dump(42/0); // float(INF) + E_WARNING var_dump(-42/0); // float(-INF) + E_WARNING var_dump(0/0); // float(NAN) + E_WARNING
当使用取模运算符( % )的时候,PHP7会抛出一个 DivisionByZeroError 异常,PHP7之前,则抛出的是警告。
echo 42 % 0;
PHP5输出:
PHP Warning: Division by zero in /tmp/test.PHP on line 4
PHP7输出:
PHP Fatal error: Uncaught DivisionByZeroError: Modulo by zero in /tmp/test.PHP:4 Stack trace: #0 {main} thrown in /tmp/test.PHP on line 4
PHP7环境下,可以捕获该异常:
try { echo 42 % 0; } catch (DivisionByZeroError $e) { echo $e->getMessage(); }
输出:
Modulo by zero
(3)pregreplace() 函数不再支持 "\e" (PREGREPLACEEVAL). 使用 pregreplace_callback() 替代。
$content = preg_replace("/#([^#]+)#/ies", "strip_tags('#\\1#')", $content);
PHP7:
$content = preg_replace_callback("/#([^#]+)#/is", "self::strip_str_tags", $content); public static function strip_str_tags($matches){ return "#".strip_tags($matches[1]).'#'; }
(4)以静态方式调用非静态方法。
class foo { function bar() { echo 'I am not static!'; } } foo::bar();
以上代码PHP7会输出:
PHP Deprecated: Non-static method foo::bar() should not be called statically in /tmp/test.PHP on line 10 I am not static!
(5)E_STRICT 警告级别变更。
原有的 ESTRICT 警告都被迁移到其他级别。 ESTRICT 常量会被保留,所以调用 errorreporting(EALL|E_STRICT) 不会引发错误。
关于代码兼容PHP7,基本上是对代码的规范要求更严谨。以前写的不规范的地方,解析引擎只是输出NOTICE或者WARNING进行提示,不影响对代码上下文的执行,而到了PHP7,很有可能会直接抛出异常,中断上下文的执行。
如:对0取模运行时,PHP7之前,解析引擎只抛出警告进行提示,但到了PHP7则会抛出一个DivisionByZeroError异常,会中断整个流程的执行。
对于警告级别的变更,在升级灰度期间,一定要关注相关NOTICE或WARNING报错。PHP7之前的一个NOTICE或者WARNING到了PHP7,一些报警级变成致命错误或者抛出异常,一旦没有对相关代码进行优化处理,逻辑被触发,业务系统很容易因为抛出的异常没处理而导致系统挂掉。
以上只列举了PHP7部分新特性,也是我们在迁移代码时重点关注的一些点,更多细节可参考官方文档http://PHP.net/manual/zh/migration70.PHP。
一个需求的开发到上线,首先我们会通过统一的开发环境来完成功能开发,其次经过内网测试、仿真测试,这两个环境测试通过后基本保证了数据逻辑与功能方面没有问题。然后合并至主干分支,并将代码部署至预发环境,再经过一轮简单回归,确保合并代码没有问题。最后将代码发布至生产环境。
为了确保新编写的代码能在两套环境(未灰度的PHP5.4环境以及灰度中的PHP7环境)中正常运行,代码在上线前,也需要在两套环境中分别进行测试,以达到完全兼容。
所以,在灰度期间,对每个环节的运行环境除了现有的PHP5.4环境外,我们还分别提供了一套PHP7环境,每个阶段的测试中,两套环境都需要进行验证。
之前有过简单的介绍,系统部署在三大服务池,分别为Home池、Page池以及应用服务池。
在准备好安装包后,先是在每个服务池分别部署了一台前端机来灰度。运行一段时间后,期间通过错误日志发现了不少问题,也有用户投诉过来的问题,在问题都基本解决的情况下,逐渐将各服务池的机器池增加至多台。
经过前期的灰度测试,主要的问题得到基本解决。接下是对应用服务池进行灰度,陆续又发现了不少问题。前后大概经历了一个月左右,完成了应用服务池的升级。然后再分别对Home池以及Page池进行灰度,经过漫长灰度,最终完成了PC主站全网PHP7的升级。
虽然很多问题基本上在测试或者灰度期间得到了解决,但依然有些问题是全量上线后一段时间才暴露出来,业务流程太多,很多逻辑需要一定条件才能被触发。为此BUG都要第一时间同步给PHP7升级项目组,对于升级PHP引起的问题,要求必须第一时间解决。
(1)启用Zend Opcache,启用Opcache非常简单, 在PHP.ini配置文件中加入:
zend_extension=opcache.so opcache.enable=1 opcache.enable_cli=1"
(2)使用GCC4.8以上的编译器来编译安装包,只有GCC4.8以上编译出的PHP才会开启Global Register for opline and execute_data支持。
(3)开启HugePage支持,首先在系统中开启HugePages, 然后开启Opcache的hugecodepages。
操作系统默认的内存是以4KB分页的,而虚拟地址和内存地址需要转换, 而这个转换要查表,CPU为了加速这个查表过程会内建TLB(Translation Lookaside Buffer)。 显然,如果虚拟页越小,表里的条目数也就越多,而TLB大小是有限的,条目数越多TLB的Cache Miss也就会越高, 所以如果我们能启用大内存页就能间接降低这个TLB Cache Miss。
PHP7开启HugePage支持后,会把自身的text段, 以及内存分配中的huge都采用大内存页来保存, 减少TLB miss, 从而提高性能。相关实现可参考Opcache实现中的accel_move_code_to_huge_pages()函数。
开启方法
以CentOS 6.5为例, 通过命令:
sudo sysctl vm.nr_hugepages=128
分配128个预留的大页内存。
$ cat /proc/meminfo | grep Huge AnonHugePages: 444416 kB HugePages_Total: 128 HugePages_Free: 128 HugePages_Rsvd: 0 HugePages_Surp: 0 Hugepagesize: 2048 kB
然后在PHP.ini中加入
opcache.huge_code_pages=1
当我们升级完第一个服务池时,感觉整个升级过程还是比较顺利,当灰度Page池,低峰时一切正常,但到了流量高峰,系统CPU占用非常高,如图:
系统CPU的使用远超用户程序CPU的使用,正常情况下,系统CPU与用户程序CPU占比应该在1/3左右。但我们的实际情况则是,系统CPU是用户CPU的2~3倍,很不正常。
对比了一下两个服务池的流量,发现Page池的流量正常比Home池高不少,在升级Home池时,没发现该问题,主要原因是流量没有达到一定级别,所以未触发该问题。当单机流量超过一定阈值,系统CPU的使用会出现一个直线的上升,此时系统性能会严重下降。
这个问题其实困扰了我们有一段时间,通过各种搜索资料,均未发现任何升级PHP7会引起系统CPU过高的线索。但我们发现了另外一个比较重要的线索,很多软件官方文档里非常明确的提出了可以通过关闭Transparent HugePages(透明大页)来解决系统负载过高的问题。后来我们也尝试对其进行了关闭,经过几天的观察,该问题得到解决,如图:
简单的讲,对于内存占用较大的程序,可以通过开启HugePage来提升系统性能。但这里会有个要求,就是在编写程序时,代码里需要显示的对HugePage进行支持。
而红帽企业版Linux为了减少程序开发的复杂性,并对HugePage进行支持,部署了Transparent HugePages。Transparent HugePages是一个使管理Huge Pages自动化的抽象层,实现方案为操作系统后台有一个叫做khugepaged的进程,它会一直扫描所有进程占用的内存,在可能的情况下会把4kPage交换为Huge Pages。
在khugepaged进行扫描进程占用内存,并将4kPage交换为Huge Pages的这个过程中,对于操作的内存的各种分配活动都需要各种内存锁,直接影响程序的内存访问性能。并且,这个过程对于应用是透明的,在应用层面不可控制,对于专门为4k page优化的程序来说,可能会造成随机的性能下降现象。
(1)查看是否启用透明大页。
[root@venus153 ~]# cat /sys/kernel/mm/transparent_hugepage/enabled [always] madvise never
使用命令查看时,如果输出结果为[always]表示透明大页启用了,[never]表示透明大页禁用。
(2)关闭透明大页。
echo never > /sys/kernel/mm/transparent_hugepage/enabled echo never > /sys/kernel/mm/transparent_hugepage/defrag
(3)启用透明大页。
echo always > /sys/kernel/mm/transparent_hugepage/enabled echo always > /sys/kernel/mm/transparent_hugepage/defrag
(4)设置开机关闭。
修改/etc/rc.local文件,添加如下行:
if test -f /sys/kernel/mm/redhat_transparent_hugepage/enabled; then echo never > /sys/kernel/mm/transparent_hugepage/enabled echo never > /sys/kernel/mm/transparent_hugepage/defrag fi
由于主站的业务比较复杂,项目较多,涉及服务池达多个,每个服务池所承担业务与流量也不一样,所以我们在对不同的服务池进行灰度升级,遇到的问题也不尽相同,导致整体升级前后达半年之久。庆幸的是,遇到的问题,最终都被解决掉了。最让人兴奋的是升级效果非常好,基本与官方一致,也为公司节省了不少成本。
以下简单地给大家展示下这次PHP7升级的成果。
(1)PHP5与PHP7环境下,分别对我们的某个核心接口进行压测(压测数据由QA团队提供),相关数据如下:
同样接口,分别在两个不现的环境中进行测试,平均TPS从95提升到220,提升达130%。
(2)升级前后,单机CPU使用率对比如下。
升级前后,1小时流量情况变化:
升级前后,1小时CPU使用率变化:
升级前后,在流量变化不大的情况下,CPU使用率从45%降至25%,CPU使用率降低44.44%。
(3)某服务集群升级前后,同一时间段1小时CPU使用对比如下。
PHP5环境下,集群近1小时CPU使用变化:
PHP7环境下,集群近1小时CPU使用变化:
升级前后,CPU变化对比:
升级前后,同一时段,集群CPU平均使用率从51.6%降低至22.9%,使用率降低56.88%。
以上只简单从三个维度列举了一些数据。为了让升级效果更加客观,我们实际的评估维度更多,如内存使用、接口响应时间占比等。最终综合得出的结论为,通过本次升级,PC主站整体性能提升在48.82%,效果非常好。团队今年的职能KPI就算是提前完成了。
整体升级从准备到最终PC主站全网升级完成,时间跨度达半年之久,无论是扩展编写、准备安装脚本、PHP代码升级还是全网灰度,期间一直会出现各式各样的问题。最终在团队的共同努力下,这些问题都彻底得到了解决。
一直以来,对社区的付出深怀敬畏之心,也是因为他们对PHP语言性能极限的追求,才能让大家的业务坐享数倍性能的提升。同时,也让我们更加相信,PHP一定会是一门越来越好的语言。
侯青龙,微博主站研发负责人。2010年加入新浪微博,先后参与过微博主站V2版至V6版的研发,主导过主站V6版以及多机房消息同步系统等重大项目的架构设计工作。致力于提升产品研发效率以及优化系统性能。
感谢韩婷对本文的审校。
给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ,@丁晓昀),微信(微信号: InfoQChina )关注我们。
联系客服