Archive for the ‘php’ Category

在大型互联网项目中,服务、机器和网络的故障是很常见的。而由于一般使用普通服务器,硬件层面也没有什么可靠的容错机制,必须由软件应用层面予以解决。

这是一个典型的分层架构,PHP项目部署在多机房,并且对后端多个Services发起串行调用,这些services分属不同产品线,也都是分布式部署的。

以PHP项目的视角来看,网络交互的故障可能发生在service应用层、服务器、网络。service应用层面的故障可能由于对方代码、部署等问题造成,如果是功能性问题,我们是通过自动或手工的优雅降级解决。这里主要关注由于对方故障,导致的网络通信细节。

另外,需要注意的是,当前的多层架构,一般都引入了负载均衡中间层,或者zookeeper之类的可用性保障。zk不在本文讨论范围,如果使用中间层,在网络故障处理方面,可以简单地把它当成service。

以下的client端如无特殊说明,指PHP进程。

连接建立后的故障处理

service进程异常退出

如果仅是进程异常崩溃,所在服务器仍然可用的话,服务器内核会自动给所有已建立连接的peer端发送FIN分节,走类似正常关闭4步的流程。client端内核会响应以ACK,其read会读到EOF,一般会关闭连接以发送FIN,并等待ACK。

可以看到,如果client端没有等待到期望数据格式的响应,会摘除故障节点并retry或降级容错的话,那对client的业务流程是没有影响的。在retry的处理方式里,最多就是好使double了,也没有太大性能影响。

service服务器崩溃

假设这时的服务器崩溃,导致无法发出FIN分节。

如果client此时在write,是无法收到ACK的,内核会不断重试直至超时,可能有数分钟之久,然后返回ENETUNREACH之类的错误。

如果client此时在read,而且没有设置超时时间的话,那就永久block了。

所以,需要在PHP代码里合理的设置超时时间,例如通过stream_set_timeout等方法。

不论是否设置超时,可以看到这种服务器崩溃,对性能的影响还是比较大的,必须等到tcp或应用层的超时expire了之后,才可以继续下面的流程。

网络拥塞或路由器、交换机故障

这种情况与服务器崩溃的原理和影响都差不多。

但假如网络只是拥塞还不是完全不可用,而且client端又有超时、重试和故障摘除机制的话,可能影响更坏。想象下client端如果发现到某个service节点不可达了,它可能会重试几次,如果还不行就摘除并且切换到其他节点去,保障后面的交互是顺畅的。但假如内核层面或者应用层面的重试是断断续续成功的呢?那故障节点or网络路径还是会对外提供服务,却是质量受损的服务!

我厂hadoop client端里就是类似问题,是通过设置期望的网络读写速率来予以降低影响的。

连接建立过程中的故障处理

service服务器正常,但进程未启动

client发出SYN后,会很快接收到RST,从而得知故障。只要应用层有重试机制就没有影响。

service服务器故障或网络故障

client发出SYN后,很可能接收到的是路由不可达或主机故障,但协议规定的有重试机制,所以tcp层会持续重发几个SYN才会放弃,这时connect或fsockopen之类才会返回错误。所以,对服务质量有损,需要合理设置connect超时时间。

统筹故障处理

从上面可以看到,如果无法正常FIN或ACK,对服务质量是有损的,但从tcp协议层面又无法解决。而当前的多层服务器部署架构,经常是网状的,可以认为,故障服务、节点、链路会对上层多个节点和请求造成影响。

我们将client端的多个节点视为横向,多个请求视为纵向,期望是横纵都尽量降低影响。

所以,采用了:

  1. 单节点利用故障时间窗口,自动摘除和恢复底层故障节点
  2. 集群利用故障消息流,自动摘除底层故障service

这里是思路是,如果故障达到了阈值,那就从横纵两个方向扩散故障消息,告诉其他client端或其他请求不要使用故障服务。

在我们的项目里,需要实时采集web服务器产生的日志,进行分析。由于PV较高,且每个PV会产生多条日志,故系统整体吞吐量较大。由于第一手日志的读写都是由web服务器完成的,故本文主要关注PHP程序写日志,以及logagent进程实时读和网络推送日志,是否会对性能产生影响。

C函数与缓存

PHP内核是由C实现的,所以要理解其写日志的性能,首先需要了解相关的C函数。C函数与缓存的关系如下图,其中application memory和clib buffer都是在当前进程的用户内存空间,而page cache是内核内存空间,为多进程共享。

需要关注的几个Notes:

  • Note that fclose only flushes the user space buffers provided by the C library. To ensure that the data is physically  stored  on disk the kernel buffers must be flushed too, e.g. with sync(2) or fsync(2).
  • Note  that  fflush() only flushes the user space buffers provided by the C library.  To ensure that the data is physically stored on disk the kernel buffers must be flushed too, e.g. with sync(2) or fsync(2).
  • fsync  copies  all  in-core parts of a file to disk, and waits until the device reports that all parts are on stable storage.  It also updates metadata stat information. It does not necessarily ensure that the entry in the directory containing  the  file  has also reached disk.  For that an explicit fsync on the file descriptor of the directory is also needed.
  • In case the hard disk has write cache enabled, the data may not really be on permanent storage when fsync/fdatasync return.When an ext2 file system is mounted with the sync option, directory entries are also implicitly synced by fsync. On kernels before 2.4, fsync on big files can be inefficient.  An alternative might be to use the O_SYNC flag to open(2).
  • A successful return from write does not make any guarantee that data has been committed to disk.  In fact, on some  buggy  implementations(注:一种针对较大块内存分配算法),  it  does  not  even guarantee that space has successfully been reserved for the data(注:可能会被LRU掉).  The only way to be sure is to call fsync(2) after you are done writing all your data.
  • Opening a file with O_APPEND set causes each write on the file to be appended to the end. (注:对于单次写,有原子性保证)

 PHP对C函数调用

通过对PHP的fwrite源码和gdb跟入,确定针对普通本地文件的调用链如上图所示,即并未使用标准Lib库的IO函数,而是直接调用了系统函数open、write,每次调用都需要经历用户态与内核态的切换,有一定的资源消耗,而且也没用使用到C lib库的cache层。

同时,还需要注意的是,虽然系统write函数保证了原子性,但PHP对其进行了封装,根据stream的chunk_size拆分为多个write调用。故在buf字节数超过chunk_size的情况下,无法保证原子性。其中chunk_size在main/php_network.h中定义:#define PHP_SOCK_CHUNK_SIZE 8192。

再看下file_put_contents如下图,多次调用系统函数进行open、可选的flock、write、fflush和close操作。其中可能破坏原子性的地方有两处:

  • 若传入一维数组,会针对每一行调用php_stream_write方法
  • 每次php_stream_write方法,若待写入数据超过chunk_size,会分多次调用write()

另外,常用的error_log方法,在目标文件是普通本地文件时,也只是对php_stream_open_wrapper、php_stream_write、php_stream_close的封装。

写日志实现方式

基本思路见 封装php的Log类,考虑3种方式:

  • 每次直接调用file_put_contents
  • 每次直接调用fwrite
  • 每次先写入应用层缓存,触发条件或request结束时批量file_put_contents

完整实现代码已上传至:https://github.com/flykobe/php-log

性能测试

小块日志写入

3种调用方式,分别调用10w次,每行日志1024B,总体写入1GB数据。在mac本上,php 5.3.24,多次运行,平均测试结果:

_fwrite 2647.6ms

_write_with_buf 2659.9ms

_file_put_contents 10322.6ms

可以看到 耗时:_file_put_contents > _write_with_buf > _fwrite。

大块日志写入

3种调用方式,分别调用10w次,每行日志8192B,总体写入8GB数据。在mac本上,php 5.3.24,多次运行,平均测试结果:

_fwrite 4300

_write_with_buf 5575

_file_put_contents 11600

_fwrite和_write_with_buf的耗时上涨比例都较大。且两者差距拉大,主要由于此时_write_with_buf会加锁,以保证原子性。_file_put_contents反而变化比例不大。

优化_write_with_buf如下:

  • self::$_arrBuf[$strType][] = &$strMsg; // 将buf数组改为引用的方式,以避免PHP内核进行数据copy。没啥效果,甚至耗时还略微上涨
  • self::$_intBufMaxNum = $arrConfig[‘writebufmax’]; // 传入writebufmax = 100, 默认值是20行。略微有效果,下降200ms左右,但多次运行波动较大。增大该值,还会获得微小提升,但对于web系统而言,不太实用。

原子性测试

同时运行两个./atomic_test.php 100000 8196  脚本,分别写入8196个a或b。使用perl进行验证,a和b不能同时出现在一行里:

perl -ne ‘$ma = m#1234567\t.*a#; $mb = m#1234567\t.*b#; if ($ma && $mb){print;}’ log/20150317/notice.log | wc -l

发现有大量错行的情况!而如果将写入字符串大小改为1024、4096等,即使加上自动添加的日志信息,也远小于chunk_size,则没有错行情况。

使用_write_with_buf,写入大小为8196B的日志,没有错行!改为4096,同样没有错行。

使用_file_put_contents,写入大小为8196B的日志,也有错行!改为4096B,同样没有错行。

即,如果日志大小(包括填充的前后数据)超过chunk_size,则仅依赖PHP底层无法保证原子性!而_write_with_buf在应用层按需向file_put_contents传入LOCK_EX参数加锁,故保证了原子性。

持久化考虑

在默认情况下,系统调用write()仅是写入到内核的page cache,等待调度被刷到磁盘上。如果在此期间出现系统或硬件故障,可能会导致数据丢失。如果在单机情况下,要保证100%的可靠,则需要手工调用fsync(),或在open()时,设置O_DIRECT flag。但这两者,无疑都是较为耗时的,因为写入耗时里真正包含了到磁盘的时间。在log类里,一般不会达到这么高的要求,所以不予开启。

参考资料

http://www.ibm.com/developerworks/cn/linux/l-cn-directio/

http://www.dbabeta.com/2009/io-performence-02_cache-and-raid.html

http://php.net/manual/zh/function.stream-set-write-buffer.php

http://blog.chinaunix.net/uid-27105712-id-3270102.html

ZEND_MM_SMALL_FREE_BUCKET宏,虽然鸟哥等人也有描述,可能是我愚笨,确实没有看懂,又自行一点点地扣了一下,记录如下:

struct _zend_mm_heap {

zend_mm_free_block *free_buckets[ZEND_MM_NUM_BUCKETS*2]; // 指针的数组

};

#define ZEND_MM_SMALL_FREE_BUCKET(heap, index) \

(zend_mm_free_block*) ((char*)&heap->free_buckets[index * 2] + \

sizeof(zend_mm_free_block*) * 2 – \

sizeof(zend_mm_small_free_block))

如上图所示,首先,在为struct _zend_mm_heap * heap分配完内存之后,heap->free_buckets指向一块包含ZEND_MM_NUM_BUCKETS * 2个指针元素的数组。

假设使用index=0调用该宏,那么执行完(char*)&heap->free_buckets[index * 2]之后,指针指向free_bucket的第一个字节,且注意,已经被转换为(char*)了!

随后,当+sizeof(zend_mm_free_block*) * 2后,即向后偏移了两个pointer,在64位机上就是8*2=16个字节。这时,在我们的脑海里虚幻出来一个假的zend_mm_small_free_block,它以当前指针为结尾。并且,请注意zend_mm_small_free_block与zend_mm_free_block的区别如下图,不论debug宏等如何设置,它们前半段都是一样的

随后,再-sizeof(zend_mm_small_free_block)并显示转化为(zend_mm_free_block*)指针之后,返回值其实是指向一块偏移为负值的非法内存的。但那又怎样呢?只要我们不去访问非法区域就ok了啊!返回值仅会进行的操作就是取p->prev_free_block和next_free_block,而这两块确确实实是正确映射的!

引言

现在的编程语言,大多数有自己的内存管理机制,而不像C、C++需要程序员自己malloc、free内存。所谓内存管理,我的理解是,由虚拟机、编译器(编译原理有点忘记了,不太记得应该是属于哪一步了)等自动判断何时需要分配内存、怎样分配、何时需要释放内存。在PHP,这个工作是由Zend引擎来做的。

这样做的原因,一方面是简化了程序员的工作,另一方面,像PHP这种需要长时间运行的进程,一旦有内存泄漏,后果不堪设想。并且,malloc这种函数,是系统函数,会导致用户态与内核态的切换,通过预分配内存的方式,减少切换,也可以提高性能。

在写PHP扩展的时候,推荐使用的是pemalloc函数(其实是个宏),该函数可以申请persistent的内存,或者per-request的内存。前者可以在多个请求间共享,后者是每次请求结束就释放的。一般应用层面的PHP扩展,常用的都是per-request内存。

pmalloc的per-request方式,实际调用的是_emalloc函数。这条线,就涉及到PHP的内存管理了。

在pmalloc这些接口函数的下面,有heap和存储层。heap可以看作是PHP维护的一些分配好的内存,构造为堆、bucket、树等数据结构,等待接口层使用。而存储层,会真正的向操作系统请求和释放内存。

具体关注下heap层,分为小内存指针数组,和大内存指针数组,分别存储着多个不同尺寸的bucket列表(想像成二维的吧)。当需要内存时,首先根据真实size,计算一个合适的bucket大小(可能略大于实际需求),如果这个bucket列表里有可用内存,则直接使用;否则,向存储层请求一大块内存,初始化为buckets,取用其中一块。这里,怎样高效的定位到bucket列表,怎样快速找到可用bucket,freeed的bucket怎样回收,都是有趣的事情。结合到struct heap 中的bitmap字段,猜测应该使用到了位map。具体待看。

当然,heap不可能无限制的扩张,每个php进程都有memory_limit限制,如果使用的内存超过这个限制,php进程就会崩溃。

问题

以上的引言只是泛泛而谈,可能每个大型软件都会考虑这些。具体到PHP,又是怎样做的呢?

  • zend memory manager是如何管理内存的呢?具体而言,怎样分配内存、回收、碎片整理的呢?unset是否立刻触发内存释放?
  • 对PHP的内存使用,如何监控?包括VM、RSS,甚至heap、stack等?
  • 在实现层面,heap、storage层的数据结构如何?底层的malloc、mmap_xxx方法都如何使用?

基础知识与监控方法

一些内存的基础知识,强烈建议先阅读:http://www.slideshare.net/jpauli/understanding-php-memory

注意,使用cli模式启动php时,grep有两个进程:

其中第一个是bash的进程,第二个才是php进程,以下分析时,都是针对php进程而言的。

$ cat /proc/55082/status

Name: php

State: S (sleeping)

Tgid: 55082 // thread id, 必定属于某个进程

Pid: 55082  // 当前进程id

PPid: 40321  // 父进程id

TracerPid: 0 // 跟踪当前进程的进程ID,如果是0,表示没有跟踪。例如可以用strace -p跟踪进程

Uid: 11797 11797 11797 11797

Gid: 11797 11797 11797 11797

FDSize: 256 // 当前分配的文件描述符,按32、64对齐(由于fd号不能复用,所以关闭fd不会使该值降低)

Groups: 11797

VmPeak:   52836 kB // VM峰值

VmSize:   52836 kB // size of VM map

VmLck:       0 kB // 代表进程已经锁住的物理内存的大小.锁住的物理内存不能交换到硬盘

VmHWM:     1032 kB  // 程序分配到物理内存的峰值

VmRSS:     1032 kB // resident set size, 实际物理内存使用

VmAnon:     168 kB

VmFile:     864 kB

VmData:     188 kB // 数据存储空间,进程独占。注意,包含了进程内存段里的data segment和heap,即malloc动态分配的内存也在这儿

VmStk:       88 kB // size of stack segment in VM,进程独占。对应进程内存段的stack segment

VmExe:     688 kB // size of text segment in Vm,机器中运行同一程序的数个进程共享

VmLib:     1296 kB // 进程所使用lib的大小

VmPTE:       56 kB

VmSwap:       0 kB

Threads: 1

SigQ: 1/515319

SigPnd: 0000000000000000

从proc status文件能够看到内存消耗概况,例如总量、各segment用量。但到底有哪些东西在消耗内存呢?这时可以通过pmap -x <pid>命令,或者/proc/<pid>/maps ,  /proc/<pid>/smaps文件查看详情。

$man 5 proc // 可以查看proc下一些文件的说明

注意一下perms字段,其中可以显示出copy on write特性。

通过以上方法,能够看到PHP依赖的so包、PHP内核引擎和用户代码、动态数据、stack等的使用情况。但如果我们想具体看执行到某行代码时,所占用的内存大小,怎么办呢?

这时可以依赖PHP提供的memory_get_usage()方法。注意其唯一的参数,默认为false:

real_usage

Set this to TRUE to get the real size of memory allocated from system. If not set or FALSE only the memory used by emalloc() is reported.

如果设置为false,则仅显示通过emalloc分配的内存,而persistent的pemalloc分配的内存就没有包含在内了。

以一个最简单的php代码为例:

$arr = array();

for ($i = 0; $i < 100000; $i++) {

    $arr[] = str_pad($i, 'a', 10000);

}

var_dump(memory_get_usage(true));

var_dump(memory_get_usage(false));

echo "Sleep...\n";

sleep(1000);

  1. 其ps显示的vsz大小为109M,rss大小为23M,这里仅关注rss内存先,代表整个进程独立消耗的实际内存大小。
  2. cat /proc/<pid>/status,VmRss = 23236kB(与ps结论一致),VmData = 19836kB这里再关注VmData代表已初始化的全局变量、heap内存。
  3. memory_get_usage(true) = 17563648,real size of memory allocated from system
  4. memory_get_usage(false) = 17148992,only the memory used by emalloc()
但为什么memory_get_usage(true)返回的内存用量也小于VmData呢?首先,该方法度量的是通过PHP分配的内存,无论是调用emalloc族还是pemalloc族,虽然用户的php代码里所有的变量(包括“全局变量”),都包括在内。但PHP内核和Zend引擎必定有一些已初始化的全局变量,这些属于data segment,不会被memory_get_usage度量,却属于VmData。另外,PHP依赖的一些更底层的so包,如果需要内存,肯定是自行通过malloc获取的,虽然也属于heap,但也不会被memory_get_usage度量。还有不排除一些不标准的PHP扩展会绕过emalloc/pemalloc,如果自行通过malloc管理内存,那也不会被memory_get_usage度量了。所以,可以认为memory_get_usage统计的内存是VmData的一个子集。

内存管理

zval、refcount和is_ref相关基础知识,请参考,尤其是其中“变量赋值与引用计数”一段。

PHP的内存管理是由Zend Memory Manager实现的。为了整个掌管内存,Zend MM中有一个重要的zend_mm_heap结构体,其中包含了storage(存储层的各种函数指针)、可用内存buckets bitmap、可用内存buckets链表等。相关重要数据结构列举如下,其中注意zend_mm_heap的cache、free_buckets、large_free_buckets、rest_buckets都是指针的数组

heap初始化

heap一个重要属性是storage的实现,根据Zend/README.ZEND_MM说法:

The Zend MM can be tweaked using ZEND_MM_MEM_TYPE and ZEND_MM_SEG_SIZE environment

variables.  Default values are “malloc” and “256K”. Dependent on target system you

can also use “mmap_anon”, “mmap_zero” and “win32″ storage managers.

$ ZEND_MM_MEM_TYPE=mmap_anon ZEND_MM_SEG_SIZE=1M sapi/cli/php ..etc.

即Linux默认情况下使用”malloc”作为storage handler。注意,这里并不是指malloc(),而是zend_mm_mem_handlers.name字段,可选项有”win32″,”malloc”,”mmap_anon”,”mmap_zero”。

heap初始化过程如下流程图所示:

其中,zend_mm_init中对free_buckets的设置比较特殊,利用ZEND_MM_SMALL_FREE_BUCKETS宏操作内存偏移,可参考《PHP的ZEND_MM_SMALL_FREE_BUCKET》。zend_mm_init后,heap->free_buckets 如下图所示,即free_buckets里仅存储了prev和next指针,而非先存储zend_mm_free_block*指针,再开辟新内存,由它指向的区域保持prev和next以及其他信息。

heap->rest_bucket[0]和rest_bucket[1]这时都是指向与上面类似的dummy内存区域。

heap内存的使用

TODO: _zend_mm_alloc_int 

一些常用宏说明如下:

#define ZEND_MM_ALIGNED_HEADER_SIZE ZEND_MM_ALIGNED_SIZE(sizeof(zend_mm_block))

// 对齐后的 zend_mm_block 大小,包含了block最基本的信息:当前block的size,前一个block的size等

 

#define ZEND_MM_ALIGNED_FREE_HEADER_SIZE ZEND_MM_ALIGNED_SIZE(sizeof(zend_mm_small_free_block))

// 对齐后的 zend_mm_small_free_block 大小,即小块内存头信息的大小

 

#define ZEND_MM_MIN_ALLOC_BLOCK_SIZE ZEND_MM_ALIGNED_SIZE(ZEND_MM_ALIGNED_HEADER_SIZE + END_MAGIC_SIZE)

// 对齐后的 zend_mm_block 以及魔术码的大小,在第一个宏基础上添加了魔术码所需空间

 

#define ZEND_MM_ALIGNED_MIN_HEADER_SIZE (ZEND_MM_MIN_ALLOC_BLOCK_SIZE>ZEND_MM_ALIGNED_FREE_HEADER_SIZE?ZEND_MM_MIN_ALLOC_BLOCK_SIZE:ZEND_MM_ALIGNED_FREE_HEADER_SIZE)

// 对齐后的最小头信息,取第二个和第三个宏里大的那个值

 

#define ZEND_MM_ALIGNED_SEGMENT_SIZE ZEND_MM_ALIGNED_SIZE(sizeof(zend_mm_segment))

// 对齐后的zend_mm_segment大小

#define ZEND_MM_MIN_SIZE ((ZEND_MM_ALIGNED_MIN_HEADER_SIZE>(ZEND_MM_ALIGNED_HEADER_SIZE+END_MAGIC_SIZE))?(ZEND_MM_ALIGNE
D_MIN_HEADER_SIZE-(ZEND_MM_ALIGNED_HEADER_SIZE+END_MAGIC_SIZE)):0)

// 需分配的最小内存,即如果MIN_HEADER_SIZE取的是比MIN_ALLOC_BLOCK_SIZE大的值,那就是差值,否则为0

 

#define ZEND_MM_MAX_SMALL_SIZE ((ZEND_MM_NUM_BUCKETS<<ZEND_MM_ALIGNMENT_LOG2)+ZEND_MM_ALIGNED_MIN_HEADER_SIZE)

// 小块内存的上限,如果超过这个值,就得使用large_free_buckets了

#define ZEND_MM_TRUE_SIZE(size) ((size<ZEND_MM_MIN_SIZE)?(ZEND_MM_ALIGNED_MIN_HEADER_SIZE):(ZEND_MM_ALIGNED_SIZE(size+ZEND_MM_A
LIGNED_HEADER_SIZE+END_MAGIC_SIZE)))

// 如果申请内存比MIN_SIZE还小,那就直接申请MIN_HEADER_SIZE即可;否则除了size之外,还需申请头信息和魔术码所需内存

#define ZEND_MM_BUCKET_INDEX(true_size) ((true_size>>ZEND_MM_ALIGNMENT_LOG2)-(ZEND_MM_ALIGNED_MIN_HEADER_SIZE>>ZEND_MM_ALIGNMENT_LOG2))

// 计算真实数据的字节偏移量

#define ZEND_MM_SMALL_SIZE(true_size) (true_size < ZEND_MM_MAX_SMALL_SIZE)

// 是否应该使用小块内存

 

 

需注意的一个细节是heap内存的分配都是8位对齐的,通过ZEND_MM_ALIGNED_SIZE宏实现。

heap内存的回收

 

unset与内存

在启用USE_ZEND_ALLOC(默认启用)时,unset后可以看到RSS内存的明显下降,但仍大于分配这块内存前;而若关闭USE_ZEND_ALLOC,则unset后RSS内存仅略微下降。

TODO

参考资料

http://www.slideshare.net/jpauli/understanding-php-memory

http://www.laruence.com/2011/11/09/2277.html

https://wiki.php.net/internals/zend_mm

http://www.webreference.com/programming/php_mem/

http://www.ibm.com/developerworks/cn/opensource/os-php-v521/

http://blog.csdn.net/zjl410091917/article/details/8075691  proc status 字段含义介绍

http://www.cnblogs.com/jiayy/p/3458076.html proc下文件含义介绍

http://c.biancheng.net/cpp/html/476.html 指针数组和数组指针

 

PHP扩展接口的声明

我们经常使用PHP_FUNCTION(count)之类,来声明对外提供的count接口,经过一系列的宏转换之后,会变成:

void zif_count(int ht, zval *return_value, zval **return_value_ptr, zval *this_ptr, int return_value_used TSRMLS_DC)

php脚本的解析运行过程

  1. 使用lex进行词法解析,根据src/main/Zend/zend_language_scanner.l 将php代码解析为token
  2. 使用bison进行语法解析,根据src/main/Zend/zend_language_parser.y将token码,通过y文件里对应的zend compiler函数,填充其对应的struct opcode
  3. zend引擎 解析执行这些opcode

那么PHP语言层面的一些概念,如何实现呢?比如变量的作用域、弱类型?

PHP变量与内存使用

PHP虽然是弱类型语言,但底层基于C,故其底层的真正存储仍然需要区分类型。目前支持的类型有bool、long、double、string、array、object、resouce和NULL。存储对应的数据结构是zval,使用type标明变量的真实类型,使用union _zvalue_value存储变量的真实数据。

从zvalue的定义可以看到:

typedef union _zvalue_value {

        long lval;                                      /* long value */

        double dval;                            /* double value */

        struct {

                char *val;

                int len;

        } str;

        HashTable *ht;                          /* hash table value */

        zend_object_value obj;

} zvalue_value;

bool、long都是使用long类型来存储的,也包括看起来像是short、int、unsigned的数据,也就是说,你以为只需要一个byte的时候,光底层真实数据的存储,就会消耗掉8个byte!类似的情况也出现在float类型上。这也是PHP消耗内存过多的一个原因吧。

NULL型变量比较节省资源,zvalue_value是空指针,仅使用type标识即可。所以,在关注内存的情况下,对于不确定类型的初始化,使用NULL会比使用空字符串、0等要更节省空间。

与array、hash相关的实现是HashTable,其采用链表法解决hash冲突。这里存在的安全漏洞是用户可以通过构造特定的hash key,增大冲突率,从而是hash的O(1)操作退化为链表的O(n)操作,从而造成系统服务能力的退化。PHP目前仅是通过限制用户提交的数据个数的方式,来提高门槛,所以风险依然存在!

max_input_vars integer

How many input variables may be accepted (limit is applied to $_GET, $_POST and $_COOKIE superglobal separately). Use of this directive mitigates the possibility of denial of service attacks which use hash collisions. If there are more input variables than specified by this directive, an E_WARNING is issued, and further input variables are truncated from the request.

另外,在一个hash表里,数据量太大时,可能也会存在resize的情况,这也会在瞬间造成一些数据的copy。需要关注的是rehash函数zend_hash_rehash,在hash中存储的元素数 > hash bucket size的时候会被调用,因为元素过多,会导致冲突率增大。rehash以2x的速率进行,在此过程中确实会分配新的内存以增大buckets 大小,并调整两组双向指针,但不会涉及用户data的copy,所以一定程度上而言还是考虑了性能问题的。

变量赋值与引用计数

总体来说,PHP使用了基于引用计数的GC机制,和在内存使用方面使用写时复制。

在struct zval 里,有ref_count,和is_ref两个字段。前者记录该变量指向的内存,被引用的次数。后者是bool值,标记该内存是否是被引用的。

在不考虑其他变量的情况下,如果$b = $a,虽然这时候是copy by value,但由于此时b和a的值确实是一样的,所以基于写时复制的思想,a和b的refcount都是2,且两者的is_ref都是false。随后,如果修改b,例如$b=20,就需要将b和a指向的内存分裂开了,这时a和b的refcount都会变成1,is_ref维持为false。(修改a的道理是一样的)

而如果初始时是by reference的,即$b = &$a,则两者的refcount都是2,且is_ref为true。无论怎样修改a和b的,两个字段都不发生变化。

那么数组与对象的赋值是怎样的呢?

从表面看,跟标量是一样的,assign-by-value时,共享同样的zval array/object内存区。但需要注意的是,它们还含有子zval结构!

先来看array,数组变量和其内部字段,都遵循写时复制!所以,当$b = $a,这种赋值方式时,a和b的表现如同两个独立的变量,虽然PHP会尽量复用内存空间。

再来看object,可以将object的assign-by-value赋值视为指针,但其is_ref=false。PHP中对象是按引用传递的,即对象进行赋值和操作的时候是按引用(reference)传递的,而不是整 个对象的拷贝。

混合使用assign-by-value和assign-by-ref

ref_count和is_ref的设计,都是为了尽可能节省内存,并且正确处理变量的赋值和改变(基本跟C语言的传值、传引用差不多)。那么考虑下,当把assign-by-value和assign-by-ref混合使用会怎样呢?答案是,没法省内存了。

$a = ‘sample';

$b = $a;

$c = &$a;

上面这段代码,line2的时候,还遵循写时复制,a和b共享一块内存。到line3时,假设还共享内存,那么其对应的ref_count和is_ref怎样设置呢?如果ref_count=3,且is_ref=false,那修改c的时候,将导致开辟新内存给c,而a和b共享旧内存,而a的值这时是应该随之改变的;如果is_ref=true,那么a、b、c还共享这块内存,而b的值应该是不变的!所以,PHP的做法是,在line3时,就分配了新内存,a与c共享内存,ref_count=2,is_ref=true;b的ref_count=1,is_ref=false。颠倒line2和line3的情况差不多。

所以,最好切记,除非清楚知道自己在做什么,否则别把assign-by-value和assign-by-ref混合使用

另外,如果进行字符串连接,例如 $a = ‘a'; $b = ‘b'; $c = $a . $b; 那么c没有复用a和b的存储空间,所以它们的refcount都=0。

is_ref的用途

从上面可以推测出,is_ref如果为true,当值发生改变的时候,不发生写时复制导致的分裂,所有指向该内存的“指针”对应的值都自然随之改变。

另外,还需要注意的是,仅当refcount>1时,is_ref才可以为true。

When the reference count of a variable container reaches 1 and the is_ref value is set to 1, the is_ref value is reset to 0. The reason for this is that a variable container can only be marked as a referenced variable container when there is more than one variable pointing to the variable container.

用户函数调用与引用计数

用户自定义的php函数调用时,会初始化两个符号表(symbol tables),一个是存储函数参数的function stack,另一个是存储函数生命周期的变量的function symbol table。假设有如下方法:function foo($a),并使用foo($init)那么$a的ref_count=3,且is_ref=false。3次引用分别是init、function stack的arg0、function symbol table的变量a。之所以init和a这时会共享同样的内存,还是由于没有发生写时复制。

但需要注意的是,如果init被显示声明为global变量,那么init与arg0和a不会共享同样的内存。因为global会在建立一个指向local变量的引用!

global $intval;

$intval = 10;

xdebug_debug_zval(‘intval’); // refcount = 1, is_ref = 1

function test_pass_int($i)

{

xdebug_debug_zval(‘i’);  // refcount = 2, is_ref = 0  被function stack和function symbol table中的变量使用,没有与intval共享zval内存区,因为intval是is_ref=true的!

 

$i = 20;

xdebug_debug_zval(‘i’); // refcount = 1, is_ref = 1 发生了写时复制,function stack和function symbol table中的变量分裂了

}

test_pass_int($intval);

 

如果做一个小小的改变,改为pass-by-ref,function test_pass_int(&$i),则function stack、function symbol和intval会共享zval了, test_pass_init里print的结果都是refcount = 3, is_ref = 1。

可以通过传引用调用(Passing References to Functions)或返回引用(Returning by Reference)来模拟C里的指针,从而使函数体内可以修改传入的值。在需要交互大数据array或对象时,也可以提高效率。

function &find_node($key, &$tree)

{

$item = &$tree[$key];

return $item;
}

$tree = array(…);

$node = &find_node(3, $tree);

$node = ‘new value';

 

类静态方法与成员方法

有时会看到 采用静态调用的形式,调用成员方法,例如:

class A

{

function foo() {}

public $var = 1;  // should change to : public static $var = 1;

}

A::foo();

var_dump(A::$var); // error

而这时居然可以调用foo成功 ,且PHP不会报error或warn(只在E_STRICT时有提示)。除了语言设计的疏漏和兼容性之外,也是因为静态方法和成员方法都是存储在struct _zend_class_entry 的HashTable function_table字段里的。但想以类似的方式访问成员变量var就不行了,因为静态变量存储在HashTable default_static_members 里,而成员变量存储在default_properties里。

内存管理

参考资料

http://nikic.github.io/2012/03/28/Understanding-PHPs-internal-array-implementation.html

http://derickrethans.nl/talks/phparch-php-variables-article.pdf

http://php.net/language.references

http://php.net/manual/en/internals2.php

 

 

今年做的重要项目之一,就是对一个核心Web系统重构,使之达到了99.999% 的高可用性。在此过程中,积累了一些系统架构、自动故障发现与解决、代码健壮性等方面的经验,予以记录。

业务背景介绍

该web系统是一个大型互联网系统商业运营侧的核心web系统,PHP语言实现,之前的可用性方面存在一定问题,在历史上也出过不少事故。由于是商业系统,其PV仅是中等规模,但复杂度相对较高,体现在其所涉及的网络交互、DB调用、Cache交互较多。

需要解决的问题

  1. 所依赖的非核心上游服务不可用时,及时发现并自动降级
  2. 所依赖的上游服务部分节点不可用时,及时发现并自动摘除故障节点
  3. 通过Lib库,封装网络交互中的重试、连接和读写超时、异常日志和以上各种功能,使之对业务层透明

整体架构设计

该系统采用集中式+分布式相结合的异常发现和处理方案。由浅蓝色业务层和深蓝色基础服务设施一起,识别并处理异常攻击、网络、系统和上游服务问题。为了最大程度的解耦,它们交互的方式,就是规范化的文本日志和INI配置。

之所以采用集中+分布的混合方式,是由于所处系统环境的复杂度导致的:

  1. 对于异常流量,由于目前集群规模较大,单点不可能阈值配置过大(规则),也无法收集到全局信息(策略),只有集中式才可以综合判断;
  2. 对于上游服务的整体故障,同样的道理,单点也不可能阈值配置过小,否则很容易产生抖动,而集中式可以全局收集信息,全局联动;
  3. 但集中式的服务降级,无法很好解决以下两种问题,所以需要通过每个业务节点通过节点健康检查来处理。
    1. 不可降级的核心服务交互
    2. 点对点的非核心服务故障,例如上游服务部分机房故障,或某台服务器故障

全局故障监控与降级

故障采集:为了降低侵入性,采取了由业务模块打印warn、error log,各业务节点上的守护进程阻塞read日志文件,并发往多个(目前配置为2个)同构监控与调度服务的方式。之所以采用多写的方式,是由于一期实现时,复用了日志ETL流,走的是公司的消息队列和流式计算平台,而它们都是强一致性的,当网络出现抖动或拥塞时,故障日志甚至会延迟数小时之久!所以,最终我们决定采用最简单的方式,对于这种对实时性要求极高的消息交互,采用CS直接透传的方式。而交叉多写可以保证只要不是所有机房间链路都发生故障,就可以在秒级别完成消息传递。多个监控与调度服务完全同构,都会接收异常日志并予以计算和处理,由于重复降级不会产生副作用,所以没有做maser、slave角色的划分。

故障判别:出于扩展性和复用角度的考虑,故障判别的规则是基于Protobuf格式配置的,基本原理就是配置:某一种类型的异常日志在一段时间内到达指定阈值后,需要将什么配置项改为什么值。由于异常日志的数量一般不会太大,所以每个监控与调度服务都是独立运行的,不存在数据同步的问题,时间窗口、频率等信息都在内存中直接计算。当需要横向扩展时,可以与故障采集配合,根据业务模块、异常日志类型进行纵向划分,映射到不同的监控节点上。仅有一点需要注意的是,为了防止网络等问题导致消息延迟到达,在计算频率时,会过滤掉超时的消息。

故障处理:同样为了降低业务侵入性和耦合度,故障处理是通过修改业务模块的配置文件实现的。通过在PHP业务节点上部署我们的zk_agent,监听zk文件变化并修改业务模块的ini配置对应项,和复用C++进程的基于zk的热加载功能,监控与调度模块只需与zookeeper集群通信,修改指定配置项,而无需知晓业务模块的具体信息。上面提到的多实例方式,也可以保证只要有一个监控与调度模块与zk集群通信成功,就可以成功完成降级指令的发布。这里需要注意的是,由于机器规则总有其不完善处,引入了保险栓的方式,保证在开启保险栓的情况下,人工干预的优先级最高。

当然,这套流程也有其不完善处,例如为了避免网络抖动,我们是通过设置一个较大的阈值来实现的,而没有做更为精细化的处理。同时,其配置恢复是手工的,因为监控与调度模块为了降低耦合度和复杂度,没有主动去探测故障的恢复情况。

单点故障监控与摘除

作为自动降级的补充,该功能着眼于发现点对点的问题,并予以处理。其原理也是:该业务服务器上,在一段时间内,对某上游节点的调用若失败超过阈值,则屏蔽该上游节点。这里直接通过真实请求的失败进行计数,没有开启独立进程,也是为了降低复杂度,提升易用性。

采用策略模式,实现了APC和File两种存储介质的封装,并支持扩展。由于APC也是跨进程的,所以可以在单机所有PHP进程间共享失败次数、故障节点信息。经过测试,APC的性能优于File,所以一般推荐使用APC模式。但为了兼容那些未安装APC module的业务模块,所以也保留了File的方式。

由于大中型系统的点对点故障是频繁发生的,所以这里采用屏蔽超时自动恢复的方式,虽然可能会造成锯齿状的耗时波动,但无需人工干预,是综合而言最优的方式。

由于请求间可能有相互依赖关系,单点是无法handle降级的,所以一旦检查到某服务的上游节点全部不可用,则重新置位,设为全部可用。

PHP基础lib库

以上及其他未提及的稳定性保障,对于业务层而言略显复杂,且由每个业务开发者来保证这些也是不可靠的,所以必须有一套对业务透明的php Lib库,予以封装。我们实现了这样一套Lib,首先细致处理了Mysql、Memcache、Curl、Socket、Redis等网络交互时的重试、连接和读写超时。其次,以上网络组件又是基于代码层的负载均衡、节点健康检查、服务降级、配置组件,从而对上屏蔽稳定性细节。同时,该Lib充分考虑易用性,例如通过对多种Cache的封装,仅暴露一个简单的Call接口和规范化配置,就可以使业务层完成旁路cache的使用。

总结

通过以上方式,该系统在上线3个月内,自动处理了2000多次故障,实现了按请求成功率计算,99.999%以上的可用性。感触最深的是,很多做业务开发的PHPer,简单的认为稳定大多是运维同学的责任,但想要达到高稳定性,工作其实从架构设计就开始了,更深深渗透在每一行代码里!

在几个不是很繁忙的线上服务器发现php-fpm进程数达到500多,且存在一些运行时间长达几个月的进程。

进行了以下排查,以确定:phpfpm运行是否有问题,是否需要重启。

查看这些进程是否被正常启动

由于php-fpm.log中,会以notice级别打印worker进程的启动和回收时间,故可以通过以下语句检查有哪些php没有被记录到(ps axuf可以查看到进程的父子关系):

$cd /path/to/php-fpm.log

$for word in `ps axu | grep php | perl -ne 'chomp; @tmp=split " +", $_; print $tmp[1]."\n";'` ; do  if grep $word php-fpm.log >/dev/null ; then echo -n ''; else echo $word;  fi ; done

执行结果,发现有173个进程未被记录,一部分有可能是在php-fpm父进程启动时,直接fork出来的,所以未记录。但还剩余几十个,我也不知道啥原因了。

确认进程是否存活

通过root帐号登录上去,strace -p <pid>,其中pid是上面173个进程之一。发现正常滚动,并能看到对php方法的调用,故确定该进程存活,且可以提供服务。

为什么有这么多进程存在呢?

虽然对服务没有影响,但这么多进程存在,是否正常呢?

首先得看下php-fpm.conf:

pm = dynamic
pm.max_children = 8192
pm.start_servers = 128
pm.min_spare_servers = 128
pm.max_spare_servers = 1024
pm.max_requests = 500000

 

由于我们的空闲进程配置为128 ~ 1024,而max_request配置为50w,并且之前机器流量不大,所以可能很长一段时间,进程都不会因为处理完了max_request个请求、或者超过了spare_servers个数 而被回收。

而一个php-fpm进程占用12m左右非共享内存,根据机器配置,cpu、内存配置来看,支持1k个php-fpm进程是足够的。

可能出现什么问题?

为什么会对php进程数这么紧张呢?一个是假如php进程在进行网络连接时,遇到了丢包等问题,有可能会导致进程hang住,这样就相当于白白浪费机器资源了。另一个是,据说在某些情况下,php worker进程不会被正常回收,这也是需要避免的问题。另外,任何一个软件都不可能完全避免bug,我们也需要保持警惕,以免触发了某些未知的问题。

PHP有3种mysql访问API:mysql、mysqli、pdo。泛泛而言,mysql是最古老的访问方式,只提供面向过程的函数,对安全性支持一般,目前不建议使用了。mysqli和pdo都是官方建议的方式,稳定性、安全性都较好,且都提供面向对象的方式,其中mysqli也支持面向过程方式。性能3者差别不大,而且对于一般业务来说,这三种API本身的损耗都可以忽略不计。

那么,为什么官方会建议用mysqli和pdo,而逐步淘汰mysql呢?

对于这三者,官方给出了功能方面的比较:

ext/mysqli PDO_MySQL ext/mysql
PHP version introduced 5.0 5.1 2.0
Included with PHP 5.x Yes Yes Yes
Development status Active Active Maintenance only
Lifecycle Active Active Long term deprecation announced
Recommended for new projects Yes Yes No
OOP Interface Yes Yes No
Procedural Interface Yes No Yes
API supports non-blocking, asynchronous queries with mysqlnd Yes No No
Persistent Connections Yes Yes Yes
API supports Charsets Yes Yes Yes
API supports server-side Prepared Statements Yes Yes No
API supports client-side Prepared Statements No Yes No
API supports Stored Procedures Yes Yes No
API supports Multiple Statements Yes Most No
API supports Transactions Yes Yes No
Transactions can be controlled with SQL Yes Yes Yes
Supports all MySQL 5.1+ functionality Yes Most No

其中server-side prepared statements有助于安全性和性能的提升。由于sql句子和参数分开解析,降低了sql注入的风险。对于同一模板sql、不同参数多次执行的sql,由于sql句子本身的解析和优化仅需执行一次,所以有助于性能提升。但是,单次执行sql时,反而由于这种机制略微降低性能。并且,根据wiki上的阐述,某些mysql版本还会由于不cache query结果等原因,可能会降低性能。

PDO扩展还支持client-side prepared stat,从而可以对应用层提供一致性的prepare接口,并且由于扩展本身的安全性考虑,也可以有效防止sql注入。client-side的方式相当于在扩展层中拼装sql发送给mysql server端,在单次查询时性能可能还会略好于server-side方式,但是多次查询时会略差。(这里的性能区别都可以忽略)

个人感觉上表中其他区别都是编程习惯方面的,可以无视之。

其实除了API层面的区别,PHP官方还搞了mysql的client端C库mysqlnd。之前不论是mysql、mysqli还是PDO,其实都是在PHP扩展层面对mysql提供的libmysql C库的封装,而mysql被oracle收购后,据传闻是由于License的原因,PHP引入了自己的mysql客户端mysqlnd库,并且也作为PHP扩展存在。

这篇blog几乎跟什么深奥的技术没有关系,甚至可能略显小儿科。

核心是,程序员不应该浪费时间在重复的劳动上,除非这重复工作确实不可避免,并且意义足够重大,比如说拯救地球-,-。而最近的工作中,发现几个违反了懒惰规则的地方,写了几个小工具,记录一下。

  1. phpunit的testSuite.php入口文件
  2. api使用的参数检查checker类
  3. 新修改代码的批量语法检查
  4. 脚本替换

phpunit的testSuite.php入口文件

在一个项目里,发现phpunit的入口文件testSuite.php中,定义了一个继承自PHPUnit_Framework_TestSuite的类,该类的构造函数中,hard code了一些测试用例的声明。每次有新测试用例,都会把之前旧用例注释掉。

//require_once ‘libs/ATest.php';
//$this->addTestSuite(‘ATest’);

require_once ‘libs/BTest.php';
$this->addTestSuite(‘BTest’);

由于是多人开发,还会出现冲突。其实只要一个小小的改动:

      public function __construct() {
          $this->setName('TestsSuite');

          global $argv;
          if (($cnt=count($argv)) > 2){
              for($idx=2; $idxaddTestFile($argv[$idx]);
              }
          }
      }

使用的时候,传入用例的文件名称即可(还可以利用linux的文件名自动补完):

phpunit TestsSuite.php ‘cases/ATest.php’

api使用的参数检查checker类

严格意义上来说,每个方法都不应该信任调用者传来的参数,而进行严格的参数检查。但这真是个体力活!导致的结果,要不然就重复的编码,要不然就是直接省略,把参数检查的责任交给下一级方法或者引入安全问题。其实可以抽象一下。

写了个简单的checker类,调用者配置待检查的参数是否必须存在,若存在使用什么检查handler(比如_check_string检查是多长的字符串,_check_unsignedint检查必须是正整数等),如果不符合要求,返回什么错误码和错误提示。

于是,参数检查不但变得简单,而且该配置本身还起到了代码注释的作用!

新修改代码的批量语法检查

打字快了的时候,难免没有手误,在测试和提交前,可以利用php -l来检查文件里是否有语法错误。但是每次都手工执行,多麻烦啊!写了一个脚本,通过svn st命令获取到新修改文件的名称,再检查所有以”.php”结尾的文件(如果有其他后缀的php文件,也可以添加)。

#!/bin/bash

### 检查新修改的php文件,是否有语法错误(svn st)

svnmodified=$(svn st)

# 错误个数
errnum=0

for line in $svnmodified;do
        pos=${#line}-4
        tail=${line:$pos}

        if [ "$tail" == ".php" ];then
                php -l $line

                if [ $? -ne 0 ];then
                        ((errnum=errnum+1))
                fi
        fi
done

echo -e "\n\n####### Total errors: $errnum ######\n\n" >&2

我们还可以进一步,把它和svn想绑定,每次svn ci的时候,都自动执行一遍。

脚本替换

有时项目里,会有大量文件的内容替换工作,比如这次,我需要将某一类老的方法调用替换到新方法上去,共计500多处,如果手工修改,估计就直接放弃了。

通过分析新老调用的区别,其实用sed命令,可以很容易实现,最后,用了一行代码:

sed -i’.bak’ “s/OldLog::Log\s*(\s*\([^,]\{1,\},\)\{2\}/NewLog::notice(/g” `grep  “OldLog::Log” -rwl * | grep -v svn | grep -v tags`

完成之后,svn diff检查结果,再略微手工调整,工作量大大减少。

结语

程序员的工作,可以很快乐,但乐子是自己找的。

记录log,对于很多人而言是很简单或者低级的事情。但是,随着项目经验的增长,遇到生产环境中bug数的增多,至少对于我来说,日志的重要性日益增加。

这次,需要对项目中log类进行重构,主要希望实现4个目的:

  1. 建立日志监控机制,第一时间发现问题
  2. 协助定位问题,帮助快速解决问题
  3. 记录用户行为,协助解答客户疑问
  4. 记录用户行为,协助制定安全与个性化等策略

除了这些功能性的目的,由于log类在一次请求中的调用频率相对较高,且与基本业务无关,如果性能方面有问题的话,就太本末颠倒了,所以先从性能说起。

log一般记录在文件里,所以其本质上是写文件,使用php作为开发语言的话,主要考虑了3个方面:

  1. 选择fwrite还是file_put_contents?
  2. 是否使用debug_backtrace函数获取文件名和行号?
  3. 怎样保证并发写的原子性?

选择fwrite还是file_put_contents?

php有多种写文件的函数,fwrite需要先fopen获取到文件句柄,而file_put_contents直接使用文件名即可,且传入的data参数可以是字符串,也可以是数组,甚至stream,使用较简单。

zend框架的Zend_Log_Writer_Stream类,使用的是fwrite函数;而公司内部多个team的log封装都使用了file_put_contents函数。首先考虑,我们的log类,给谁用?内部使用,暂时没考虑开源。传入的参数,是否需要支持string,array or stream?记录log而已,支持string即可,而且log的基本样式是每次记录一行。所以,比较两者在写入多行数据之间的性能区别即可:

$str = "abc\n";
$n = 10000;
$filename = 'test_write.txt';

$start_time = mytime();
write_with_open($filename, $str, $n);
$used_time = mytime() - $start_time;
printf("%20s%s\n","fwrite","Time used: $used_time ");

$start_time = mytime();
write_without_open($filename, $str, $n);
$used_time = mytime() - $start_time;
printf("%20s%s\n","file_put_contents","Time used: $used_time ");

$start_time = mytime();
write_with_errorlog($filename, $str, $n);
$used_time = mytime() - $start_time;
printf("%20s%s\n","error_log","Time used: $used_time ");

function write_with_open($filename, $str, $n){
        $fp = fopen($filename, 'a') or die("can't open $filename for write");

        while($n--){
                fwrite($fp, $str);
        }

        fclose($fp);
}

function write_without_open($filename, $str, $n){
        while($n--){
                file_put_contents($filename, $str, FILE_APPEND);
        }
}

function write_with_errorlog($filename, $str, $n){
        while($n--){
                error_log($str, 3, $filename);
        }
}

执行该测试脚本的结果是:

fwriteTime used: 0.018175840377808
file_put_contentsTime used: 0.22816514968872
error_logTime used: 0.2338011264801

可见fwrite的性能要远远大于另外两者,直观上看,fwrite仅关注于写,而文件句柄的获取仅由fopen做一次,关闭操作也尽有一次。如果修改write_with_open函数,把fopen和fclose函数放置到while循环里,则3者的性能基本持平。

以上结论,也可以通过查看PHP源代码得知,fwrite和file_put_contents的源码都在php-5.3.10/ext/standard/file.c里,file_put_contents不但逻辑较为负载,还牵涉到open/锁/close操作,对于只做一次写操作的请求来说,file_put_contents可能较适合,因为其减少了函数调用,使用起来较为方便。而对于log操作来说,fwrite从性能角度来说,较为适合。

2012-06-22补充:

以上只是单纯从“速度”角度考虑,但是在web的生产环境里,如果并发数很高,导致系统的open files数目成为瓶颈的话,情况就不同了!fwrite胜在多次写操作只用打开一次文件,持有file handler的时间较长;而file_put_contents每次都在需要的时候打开文件,写完就立即关闭,持有file handler的时间较短。如果open files的值已无法调高,那么使用file_put_contents在这种情况下,就会是另外一种选择了。

是否使用debug_backtrace函数获取文件名和行号?

在gcc,标准php出错信息里,都会给出出错的文件名和行号,我们也希望在log里加上这两个信息,那么是否使用debug_backstrace函数呢?

class A1{
        public static $_use_debug=true;
        function run(){
                # without debug_backtrace
                if (!self::$_use_debug){
                        #echo "Quit\n";
                        return;
                }

                # with debug_backtrace
                $trace = debug_backtrace();
                $depth = count($trace) - 1;
                if ($depth > 1)
                        $depth = 1;
                $_file = $trace[$depth]['file'];
                $_line = $trace[$depth]['line'];

                #echo "file: $_file, line: $_line\n";
        }
}

class A2{
        function run(){
                $obj = new A1();
                $obj->run();
        }
}

class A3{
        function run(){
                $obj = new A2();
                $obj->run();
        }
}
class A4{
        function run(){
                $obj = new A3();
                $obj->run();
        }
}
class A5{
        function run(){
                $obj = new A4();
                $obj->run();
        }
}
class A6{
        function run(){
                $obj = new A5();
                $obj->run();
        }
}

$n = 1000;
$start_time = mytime();
A1::$_use_debug = true;
$obj = new A6();
while($n--){
        $obj->run();
}
$used_time = mytime() - $start_time;
printf("%30s:%s\n", "With Debug Time used", $used_time);

$n = 1000;
$start_time = mytime();
A1::$_use_debug = false;
$obj = new A6();
while($n--){
        $obj->run();
}
$used_time = mytime() - $start_time;
printf("%30s:%s\n", "Without Debug Time used", $used_time);

function mytime(){
        list($utime, $time) = explode(' ', microtime());
        return $time+$utime;
}

运行的结果是:

flykobe@desktop test $ php test_debug_trace.php
With Debug Time used:0.005681037902832
Without Debug Time used:0.0021991729736328

但是若多次运行,少数情况下,with debug版本甚至与快于without版本。综合考虑,还是决定不用debug_backtrace,而由调用者传入__FILE__和__LINE__值。

怎样保证并发写的原子性?

写文件时,大致有这3种情况:

  1. 一次写一行,中间被插入其他内容
  2. 一次写多行,行与行之间被插入其他内容
  3. 多次写多行,行与行之间被插入其他内容

对于web访问这种高并发的写日志而言,一条日志一般就是一行,中间绝不允许被截断,覆盖或插入其他数据。但行与行之间,是否被插入其他内容,并不care。既然之前是决定采用fwrite,php手册上说的很清楚:

If handle was fopen()ed in append mode, fwrite()s are atomic (unless the size of string exceeds the filesystem’s block size, on some platforms, and as long as the file is on a local filesystem). That is, there is no need to flock() a resource before callingfwrite(); all of the data will be written without interruption.

即当fwrite使用的handler是由fopen在append模式下打开时,其写操作是原子性的。不过也有例外,如果一次写操作的长度超过了文件系统的块大小,或者使用到NFS之类的非local存储时,则可能出问题。其中文件系统的块大小(在我的centos5虚拟机上是4096 bytes)可以通过以下命令查看:

sudo /sbin/tune2fs -l /dev/mapper/VolGroup00-LogVol00 | grep -i block

这同样可以通过模拟多种不同情况的fwrite操作来验证,由于比较简单不再赘述代码。

————————-

2012-07-03添加

《unix环境高级编程》里说:Unix系统提供了一种方法使这种操作成为原子操作,即打开文件时,设置O_APPEND操作,就使内核每次对这种文件进行读写之前,都将进程的当前偏移量设置到该文件的尾端处。

而php手册中,指的某些platform应该不包含*nix实现。故可认为,在我们的环境下,php的fwrite函数是原子性的。

在以上几种比较的基础上,初步完成了我们的Log类封装,进行了千行和万行级别的log写入测试,性能提高了3倍,但应该仍然有优化余地。