QQ 会员活动运营平台( AMS ),是 QQ 会员增值运营业务的重要载体之一,承担海量活动运营的 Web 系统。 AMS 是一个主要采用PHP语言实现的活动运营平台, CGI 日请求3亿左右,高峰期达到8亿。然而,在之前比较长的一段时间里,我们都采用了比较老旧的基础软件版本,就是 PHP5.2+Apache2.0(2008年的技术)。尤其从去年开始,随着 AMS 业务随着 QQ 会员增值业务的快速增长,性能压力日益变大。
于是,自2015年5月,我们就开始规划 PHP 底层升级,最终的目标是升级到 PHP 7。那时, PHP 7尚处于研发阶段,而我们讨论和预研就已经开始了。
2015年就 PHP 性能优化的方案,有另外一个比较重要的角色,就是由 Facebook 开源的 HHVM(HipHop Virtual Machine ,HHVM 是一个 Facebook 开源的 PHP 虚拟机)。HHVM 使用 JIT(Just In Time,即时编译是种软件优化技术,指在运行时才会去编译字节码为机器码)的编译方式以及其他技术,让 PHP 代码的执行性能大幅提升。据传,可以将 PHP5 版本的原生 PHP 代码提升5-10倍的执行性能。
HHVM 起源于 Facebook 公司,Facebook 早起的很多代码是使用 PHP 来开发的,但是,随着业务的快速发展, PHP 执行效率成为越来越明显的问题。为了优化执行效率,Facebook 在2008年就开始使用 HipHop,这是一种PHP执行引擎,最初是为了将 Fackbook 的大量 PHP 代码转成 C++,以提高性能和节约资源。使用 HipHop 的PHP 代码在性能上有数倍的提升。后来,Facebook 将 HipHop 平台开源,逐渐发展为现在的 HHVM。
HHVM 成为一个 PHP 性能优化解决方案时,PHP7还处于研发阶段。曾经看过部分同学对于 HHVM 的交流,性能可以获得可观的提升,但是服务运维和 PHP 语法兼容有一定成本。有一阵子,JIT 成为一个呼声很高的东西,很多技术同学建议 PHP 7也应该通过 JIT 来优化性能。
2015年7月,我参加了中国 PHPCON,听了惠新宸关于 PHP7 内核的技术分享。实际上,在2013年的时候,惠新宸( PHP7内核开发者)和 Dmitry (另一位PHP语言内核开发者之一)就曾经在 PHP5.5的版本上做过一个 JIT 的尝试(并没有发布)。PHP 5.5的原来的执行流程,是将 PHP 代码通过词法和语法分析,编译成 opcode 字节码(格式和汇编有点像),然后,Zend 引擎读取这些 opcode 指令,逐条解析执行。
而他们在opcode环节后引入了类型推断(TypeInf),然后通过JIT生成ByteCodes,然后再执行。
于是,在 benchmark (测试程序)中得到非常好的结果,实现JIT后性能比 PHP 5.5提升了8倍。然而,当他们把这个优化放入到实际的项目 WordPress (一个开源博客项目)中,却几乎看不见性能的提升。原因在于测试项目的代码量比较少,通过 JIT 产生的机器码也不大,而真实的 WordPress 项目生成的机器码太大,引起 CPU 缓存命中率下降(CPU Cache Miss)。
总而言之,JIT 并非在每个场景下都是点石成金的利器,而脱离业务场景的性能测试结果,并不一定具有代表性。
从官方放出Wordpress的 PHP 7和 HHVM 的性能对比可以看出,两者基本处于同一水平。
PHP 7是一个比较底层升级,比起 PHP 5.6的变化比较大,而就性能优化层面,大致可以汇总如下:
就提升PHP的性能而言,可以选择的是2015年就可直接使用的HHVM或者是2015年底才发布正式版的PHP7。会员AMS是一个访问量级比较大的一个Web系统,经过四年持续的升级和优化,积累了800多个业务功能组件,还有各种PHP编写的公共基础库和脚本,代码规模也比较大。
我们对于PHP版本对代码的向下兼容的需求是比较高的,因此,就我们业务场景而言,PHP7良好的语法向下兼容,正是我们所需要的。因此,我们选择以PHP7为升级的方案。
对于一个已经现网在线的大型公共Web服务来说,基础公共软件升级,通常是一件吃力不讨好的工作,做得好,不一定被大家感知到,但是,升级出了问题,则需要承担比较重的责任。为了尽量减少升级的风险,我们必须先弄清楚我们的升级存在挑战和风险。于是,我们整理了升级挑战和风险列表:
部分同学可能会建议采用Nginx会是更优的选择,的确,单纯比较Nginx和Apache在高并发方面的性能,Nginx的表现更优。但是就PHP的CGI而言,Nginx+php-ftpm和Apache+mod_php两者并没有很大的差距。另一方面,我们因为长期使用Apache,在技术熟悉和经验方面积累更多,因此,它可能不是最佳的选择,但是,具体到我们业务场景,算是比较合适的一个选择。
从一个2008年的Apache2.0直接升级到2016年的Apache2.4,这个跨度过于大,甚至使用的http.conf的配置文件都有很多的不同,这里的需要更新的地方比较多,未知的风险也是存在的。于是,我们的做法,是先尝试将Apache2.0升级到Apach2.2,调整配置、观察稳定性,然后再进一步尝试到Apach2.4。所幸的是,Apache(httpd)是一个比较特别的开源社区,他们之前一直同时维护这两个分支版本的Apache(2.2和2.4),因此,即使是Apache2.2也有比较新的版本。
于是,我们先升级了一个PHP5.2+Apache2.2,对兼容性进行了测试和观察,确认两者之间是可以比较平滑升级后,我们开始进行Apache2.4的升级方案。
PHP5.2的升级,我们也采用相同的思路,我们先将PHP5.2升级至PHP5.6(当时,PHP7还是beta版本),然后再将PHP5.6升级到PHP7,以更平滑的方式,逐步解决不同的问题。 于是,我们的升级计划变为:
Apache2.4编译为动态MPM的模式(支持通过httpd配置切换prefork/worker/event模式),根据现网风险等实时降级。
关于Event模式的官方介绍:
http://httpd.apache.org/docs/2.4/mod/event.html
(部分同学可能会有event模式不支持https的印象,那个说法其实是2年多以前的国内部分技术博客的说法,目前的版本是支持的,详情可以浏览官方介绍)
开启动态切换模式的方法,就是在编译httpd的时候加上:
--enable-mpms-shared=all
从PHP5.2升级到PHP5.6相对比较容易,我们主要的工作如下:
从PHP5.6升级到PHP7.0的工作量就比较多,也相对比较复杂,因此,我们制定了每一个阶段的升级计划:
因为从PHP5.2升级到PHP5.6的过程中,很多问题已经被我们提前解决了,所以,PHP7的升级主要难点在于tphplib扩展的编译升级。
涉及主要的工作包括:
在升级和重新编译PHP7扩展时,如果执行结果不符合预期或者进程core掉,很多错误都是无法从error日志里看见的,不利于分析问题。可以采用以下几种方法,可以用来定位和分析大部分的问题:
var_dump/exit
从PHP代码层逐步输出信息和执行exit,可以逐步定位到异常执行的PHP函数位置,然后再根据PHP函数名,反查扩展内的实现函数,找到问题。这种方法比较简单,但是效率不高。
gdb –p/gdb c
这种方法主要用于分析进程core的场景,我们采用的编译方式,是将mod_php
(PHP变成Apache的子或块的方式),使用gdb –p
来监控Apache的服务进程。命令:ps aux|grep httpd
gdb -p
./apachectl -k start -X -e debug
然后再通过gdb –p
来调试就更简单一些。gdb调试指定进程:
命令:gdb -p
使用c进行捕获,然后构造能够导致core的web请求:
Apache通常是多进程模式,为了让问题比较容易复现,可以在http.con里修改参数,将启动进程数修改为1个(下图中的多个参数都需要调整,以达到只启动单进程单线程的目的)。
当然还有一种更简单的方法,因为Apache本身就支持单进程调试模式的。
./apachectl -k start -X -e debug
然后再通过gdb –p
来调试就更简单一些。
strace -Ttt -v -s1024 -f -p pid
(进程id)
备注:执行这些命令,注意权限问题,很可能需要root权限。
php7的诞生始于zval
结构的变化,PHP7不再需要指针的指针,绝大部分zval**
需要修改成zval*
。如果PHP7直接操作zval
,那么zval*
也需要改成zval
,Z_*P()
也要改成Z_*()
,ZVAL_*(var, …)
需要改成ZVAL_*(&var, …)
,一定要谨慎使用&符号,因为PHP7几乎不要求使用zval*
,那么很多地方的&也是要去掉的。
ALLOC_ZVAL
,ALLOC_INIT_ZVAL
,MAKE_STD_ZVAL
这几个分配内存的宏已经被移除了。大多数情况下,zval*
应该修改为zval
,而INIT_PZVAL
宏也被移除了。
/* 7.0zval结构源码 */
/* value字段,仅占一个size_t长度,只有指针或double或者long */
typedef union _zend_value {
zend_long lval; /* long value */
double dval; /* double value */
zend_refcounted *counted;
zend_string *str;
zend_array *arr;
zend_object *obj;
zend_resource *res;
zend_reference *ref;
zend_ast_ref *ast;
zval *zv;
void *ptr;
zend_class_entry *ce;
zend_function *func;
struct {
uint32_t w1;
uint32_t w2;
} ww;
} zend_value;
struct _zval_struct {
zend_value value; /* value */
union {
。。。
} u1;/* 扩充字段,主要是类型信息 */
union {
… …
} u2;/* 扩充字段,保存辅助信息 */
};
直接切换即可:
long->zend_long
/* 定义 */
typedef int64_t zend_long;
/* else */
typedef int32_t zend_long;
PHP5.6版本中使用 char*
+ len的方式表示字符串,PHP7.0中做了封装,定义了zend_string类型:
struct _zend_string {
zend_refcounted_h gc;
zend_ulong h; /* hash value */
size_t len;
char val[1];
};
zend_string
和char*
的转换:
zend_string *str;
char *cstr = NULL;
size_t slen = 0;
//...
/* 从zend_string获取char* 和 len的方法如下 */
cstr = ZSTR_VAL(str);
slen = ZSTR_LEN(str);
/* char* 构造zend_string的方法 */
zend_string * zstr = zend_string_init("test",sizeof("test"), 0);
扩展方法,解析参数时,使用字符串的地方,将‘s’替换成‘S’:
/* 例如 */
`zend_string` `*zstr`;
if (zend_parse_parameters(ZEND_NUM_ARGS() , "S", &zstr) == FAILURE)
{
RETURN_LONG(-1);
}
源代码:
/* php7.0 zend_object 定义 */
struct _zend_object {
zend_refcounted_h gc;
uint32_t handle;
zend_class_entry *ce;
const zend_object_handlers *handlers;
HashTable *properties;
zval properties_table[1];
};
zendobject
是一个可变长度的结构。因此在自定义对象的结构中,zendobject
需要放在最后一项:
/* 例子 */
struct clogger_object {
CLogger *logger;
zend_object std;// 放在后面
};
/* 使用偏移量的方式获取对象 */
static inline clogger_object *php_clogger_object_from_obj(zend_object *obj) {
return (clogger_object*)((char*)(obj) - XtOffsetOf(clogger_object, std));
}
#define Z_USEROBJ_P(zv) php_clogger_object_from_obj(Z_OBJ_P((zv)))
/* 释放资源时 */
void tphp_clogger_free_storage(zend_object *object TSRMLS_DC)
{
clogger_object *intern = php_clogger_object_from_obj(object);
if (intern->logger)
{
delete intern->logger;
intern->logger = NULL;
}
zend_object_std_dtor(&intern->std);
}
7.0中的hash表定义如下,给出了一些注释:
/*7.0中的hash表结构 */
typedef struct _Bucket { /* hash表中的一个条目 */
zval val; /* 删除元素zval类型标记为IS_UNDEF */
zend_ulong h; /* hash value (or numeric index) */
zend_string *key; /* string key or NULL for numerics */
} Bucket;
typedef struct _zend_array HashTable;
struct _zend_array {
zend_refcounted_h gc;
union {
struct {
ZEND_ENDIAN_LOHI_4(
zend_uchar flags,
zend_uchar nApplyCount,
zend_uchar nIteratorsCount,
zend_uchar reserve)
} v;
uint32_t flags;
} u;
uint32_t nTableMask;
Bucket *arData; /* 保存所有数组元素 */
uint32_t nNumUsed; /* 当前用到了多少长度, */
uint32_t nNumOfElements; /* 数组中实际保存的元素的个数,一旦nNumUsed的值到达nTableSize,PHP就会尝试调整arData数组,让它更紧凑,具体方式就是抛弃类型为UDENF的条目 */
uint32_t nTableSize; /* 数组被分配的内存大小为2的幂次方(最小值为8) */
uint32_t nInternalPointer;
zend_long nNextFreeElement;
dtor_func_t pDestructor;
};
其中,PHP7在zend_hash.h中定义了一系列宏,用来操作数组,包括遍历key、遍历value、遍历key-value等,下面是一个简单例子:
/* 数组举例 */
zval *arr;
zend_parse_parameters(ZEND_NUM_ARGS() , "a", &arr_qos_req);
if (arr)
{
zval *item;
zend_string *key;
ZEND_HASH_FOREACH_STR_KEY_VAL(Z_ARRVAL_P(arr), key, item) {
/* ... */
}
}
/* 获取到item后,可以通过下面的api获取long、double、string值 */
zval_get_long(item)
zval_get_double(item)
zval_get_string(item)
PHP5.6版本中是通过zend_hash_find
查找key,然后将结果给到zval **
变量,并且查询不到时需要自己分配内存,初始化一个item,设置默认值。
duplicate
参数,表明一个变量是否需要复制一份,尤其是string类
的操作,PHP7.0中取消duplicate
参数,对于string相关操作,只要有duplicate
参数,直接删掉即可。因为PHP7.0中定义了zval_string
结构,对字符串的操作,不再需要duplicate
值,底层直接使用zend_string_init
初始化一个zend_string
即可,而在PHP5.6中string
是存放在zval
中的,而zval
的内存需要手动分配。涉及的API汇总如下:
add_index_string
、add_index_stringl
、add_assoc_string_ex
、add_assoc_stringl_ex
、add_assoc_string
、add_assoc_stringl
、add_next_index_string
、add_next_index_stringl
、add_get_assoc_string_ex
、add_get_assoc_stringl_ex
、add_get_assoc_string
、add_get_assoc_stringl
、add_get_index_string
、add_get_index_stringl
、add_property_string_ex
、add_property_stringl_ex
、add_property_string
、add_property_stringl
、ZVAL_STRING
、ZVAL_STRINGL
、RETVAL_STRING
、RETVAL_STRINGL
、RETURN_STRING
、RETURN_STRINGL
PHP5.6中,zval变量是在堆上分配的,创建一个zval变量需要先声明一个指针,然后使用MAKE_STD_ZVAL
进行分配空间。PHP7.0中,这个宏已经取消,变量在栈上分配,直接定义一个变量即可,不再需要MAKE_STD_ZVAL
,使用到的地方,直接去掉就好。
修改参数名rsrc为res
/* PHP5.6 */
typedef struct _zend_rsrc_list_entry {
void *ptr;
int type;
int refcount;
} zend_rsrc_list_entry;
typedef void (*rsrc_dtor_func_t)(zend_rsrc_list_entry *rsrc TSRMLS_DC);
#define ZEND_RSRC_DTOR_FUNC(name) void name(zend_rsrc_list_entry *rsrc TSRMLS_DC)
/* PHP7.0 */
struct _zend_resource {
zend_refcounted_h gc;/*7.0中对引用计数做了结构封装*/
int handle;
int type;
void *ptr;
};
typedef void (*rsrc_dtor_func_t)(zend_resource *res);
#define ZEND_RSRC_DTOR_FUNC(name) void name(zend_resource *res)
PHP7.0中,将zend_rsrc_list_entry
结构升级为zend_resource
,在新版本中只需要修改一下参数名称即可。
Z_*_PP
PHP7.0中取消了所有的PP宏,大部分情况直接使用对应的P宏即可。
根据官方wiki,可以定义如下宏,用来获取object,实际情况看,这个宏用的还是比较频繁的:
static inline user_object *user_fetch_object(zend_object *obj) {
return (user_object *)((char*)(obj) - XtOffsetOf(user_object, std));
}
/* }}} */
#define Z_USEROBJ_P(zv) user_fetch_object(Z_OBJ_P((zv)))
对所有需要字符串参数的函数,PHP5.6中的方式是传递两个参数(char* + len)
,而PHP7.0中定义了zend_string
,因此只需要一个zend_string
变量即可。
返回值变成了zend_bool类型:
/* 例子 */
zend_string * key;
key = zend_string_init("key",sizeof("key"), 0);
zend_bool res_key = zend_hash_exists(itmeArr, key);
现网服务是一个非常重要而又敏感的环境,轻则影响用户体验,重则产生现网事故。因此,我们4月下旬完成PHP7编译和测试工作之后,就在AMS其中一台机器进行了灰度上线,观察了几天后,然后逐步扩大灰度范围,在5月初完成升级。
这个是我们压测AMS一个查询多个活动计数器的压测结果,以及现网CGI机器,在高峰相同TGW流量场景下的CPU负载数据:
就我们的业务压测和现网结果来看,和官方所说的性能提升一倍,基本一致。
AMS平台拥有不少的CGI机器,PHP7的升级和应用给我们带来了性能的提升,可以有效节省硬件资源成本。并且,通过Apache2.4的Event模式,我们也增强了Apache在支持并发方面的能力。
我们PHP7升级研发项目组,在过去比较长的一个时间段里,经过持续地努力和推进,终于在2016年4月下旬现网灰度,5月初在集群中全量升级,为我们的AMS活动运营平台带来性能上大幅度的提升。
PHP7的革新,对于PHP语言本身而言,具有非凡的意义和价值,这让我更加确信一点,PHP会是一个越来越好的语言。同时,感谢PHP社区的开发者们,为我们业务带来的性能提升。
文章来源公众号:小时光茶社(Tech Teahouse)