本文译自:http://jpauli.github.io/2015/03/05/opcache.html

由于本人C语言水平英文水平计算机水平等等各种水平有限,因此有些部分可能理解和翻译得不是特别到位,总之尽力啦。

OPCodes 缓存的起源

PHP是一个脚本语言,在通常情况下,PHP会编译任何你让它运行的文件。通过编译获得OPCodes,然后运行并且在结束之后立刻废弃掉这些OPCodes。可以说PHP是被设计成这个样子的:在上一个请求中做过的所有事情,到了下一个请求,它全都忘记了。

在生产环境的服务器上,PHP代码一般不会频繁变动,因此这个编译环节基本上都是在读取同样的源代码,生成并且运行同样的OPCodes。每次请求都需要调用PHP的编译器来编译每一个PHP脚本,这无疑浪费了大量的时间和资源。

undefined

因为编译确实太过花时间了,各种OPCode缓存扩展被设计了出来。它们的目标是让每一个PHP脚本都只编译一次,将编译出来的OPCodes缓存进共享内存,之后的每个PHP worker进程池(通常是PHP-FPM)中的worker都可以直接从内存中读取和执行OPCodes。

这样做给PHP带来的提升是十分巨大的,因为不需要一遍又一遍地编译PHP脚本,通常能够将运行一个PHP脚本的时间降低一半甚至更多(当然也需要看PHP脚本具体执行了什么代码)。

越是复杂的PHP应用,通过这样的方式获得的提升就越大。如果你的PHP应用在每次请求都需要运行成吨的脚本(比方说基于各种大型框架的应用或者类似wordpress这样的PHP应用),你能够体验到10到15倍的速度提升。这是因为PHP的编译器跟其他编译器一样,都是很慢的,毕竟编译器需要将高级语言的语法转换成机器能明白的语法,它要尝试理解代码到底需要机器做些什么,编译器通常还需要为生成后的代码进行优化处理,以便之后的运行性能可以更好;所以编译一个PHP脚本真的很慢,并且很吃内存。类似Blackfire这样的性能分析器可以让你知道编译时间。

undefined

OPCache介绍

OPCache自从在2013年开源以后,就被捆绑进了PHP 5.5 以及之后的版本的源代码中,它成为了PHP的标准OPcode缓存解决方案。业界也有其他几种解决方案,例如XCache,APC,Eaccelerator等等。毕竟我(此处指原作者,后同)除了APC以外对其他缓存方案并不熟悉,APC也已经被OPCache所取代,简单来说就是如果你之前在使用APC,请你现在开始使用OPCache吧。OPCache事实上已经成为了官方推荐的OPCode缓存解决方案。当然你也可以继续使用其他OPCode缓存解决方案,不过记住一点,不要同时启用超过一个OPCode缓存扩展,这会让PHP进程崩溃。

新开发的OPCache将不以PHP 5为首要支持目标,而是以目前的PHP稳定版本PHP7为首要目标。这篇文章会把关注点放在PHP5和PHP7的OPCache,你可以从中看到一些区别(虽然区别并不会很大)。

OPCache是一个扩展,准确来说是一个zend扩展(zend_extension)。从PHP 5.5.0开始就被捆绑进了PHP的源代码中,并且可以通过修改php.ini来启动。如果你使用的是发行版本的PHP,可以从手册当中看到PHP和OPCache已经被捆绑了。

OPCache的两个主要功能

OPCache扩展提供了两个主要的功能:

  • OPCodes缓存
  • OPCodes优化

因为OPCache通过调用PHP编译器来获得和缓存OPCodes,所以它能够在这一步对OPCodes进行优化。优化基本就是基于各种计算机科学原则概念的编译器优化。OPCache优化器是一个多道编译器优化器(multi-pass compiler optimizer)。

undefined

深入OPCACHE

让我们来看看OPCache的内部是怎么实现的。如果你想要读OPCache源代码,你可以在PHP的源代码里面找到,这里是PHP7的源代码

不想你所想象的,缓存OPCode并不是很难分析和理解。你只要对ZendEngine是如何设计以及运作的有比较好的理解,就可以明白缓存OPCode是如何做到的了。

共享内存模型

如你所知,不同的操作系统使用多种不同的共享内存模型。目前的类Unix系统拥有数种进程间共享内存的方式,常用的有以下几种:

  • System-V shm API
  • POSIX API
  • mmap API
  • Unix socket API

OPCache可以使用如上的前三种,只要你的系统支持。在php的INI设置中,opcache.preferred_memory_model 可以让你选择你想要的共享内存模型。如果不设置这个配置值,通过以下这段代码,OPCache会自动选择操作系统支持的第一种模型。

static const zend_shared_memory_handler_entry handler_table[] = {
#ifdef USE_MMAP
    { "mmap", &zend_alloc_mmap_handlers },
#endif
#ifdef USE_SHM
    { "shm", &zend_alloc_shm_handlers },
#endif
#ifdef USE_SHM_OPEN
    { "posix", &zend_alloc_posix_handlers },
#endif
#ifdef ZEND_WIN32
    { "win32", &zend_alloc_win32_handlers },
#endif
    { NULL, NULL}
};

所以默认情况下会使用mmap。这是个成熟完善的内存模型,不过它不能像SHM模型那样可以使用 ipcsipcrm 命令来提供更多的信息。

当OPCache启动(也就是PHP启动的时候),OPCache会尝试使用指定的内存模型去申请一段很大的内存空间,这段内存之后会由OPCache自己划分和管理,并且这段内存不会被释放也不可以调整大小。

PHP启动的时候OPCache申请一段共享内存,只申请这一次,并且不会释放,也不能调整大小。

可以通过INI配置中 opcache.memory_consumption 参数(单位为MB)来告诉OPCache申请多大的内存。对于这个值不能吝啬,要给足够大的空间。永远不要让OPCache需要的内存超过申请的共享内存,这会让进程锁死,这一点我们之后再来讲。

根据你的需要调整共享内存的大小,别忘了一般的生产环境服务器仅仅为了PHP进程就会安装许多G的内存。为OPCache配置超过1GB的共享内存并不稀奇,这主要还是看你的生产环境需要多少,如果你的应用是基于开发框架,需要依赖大量的类库,那么至少配置1GB的共享内存。

共享内存被用来缓存以下这些数据:

  • 脚本的数据结构缓存,包括但不仅限于OPCodes
  • 字符串缓存
  • 脚本文件哈希表
  • OPCache的全局数据

记住共享内存不仅仅包含OPCode源码,还有其他OPCache内部构件需要的数据,分配内存的时候一定要好好考虑。

undefined

OPCodes缓存

我们来看看缓存机制是如何实现的。

大思路就是将所有每个请求间不会变的数据都复制到共享内存中,这类数据其实还蛮多的。之后在同一个脚本加载第二次的时候,将跟这个脚本相关的数据从共享内存中恢复到进程中并且在当前的请求中使用。PHP的解释器工作的时候,使用的是Zend Memory Manager (ZMM) 来申请内存,这样申请的内存是根据请求隔离的,也就是说在请求结束后,ZMM会释放掉这些内存。而且这些内存是在当前的PHP进程中申请的,也无法共享给其他PHP进程使用。所以OPCache的工作就是将所有PHP解释器返回的数据截获,不让这些数据被直接放到PHP的进程内存中,而是将这些数据复制到共享内存里面。解释器每次申请内存存放的数据,都应该是不变的,会改变的数据一般是在运行时由Zend Virtual Machine(Zend虚拟机)创建的,所以保存所有Zend解释器创建的数据到共享内存里面是安全的。举个例子:函数以及类,这些数据其实就是函数名字符串的指针,函数的OPCode序列的指针,类常量,类成员甚至他们的默认内容…… PHP解释器在内存里面创建的不会改变的数据,真的挺多的。

这种内存模型在最大程度上防止了锁的产生,关于锁,在之后的话题中我们会仔细说。基本上,OPCache所有的工作在运行时(runtime)之前就已经一次做完了,运行时中OPCache没有什么可以做的事情,易变的数据会在正常的PHP进程堆中使用ZMM创建,不变的数据会通过共享内存恢复。

OPCache钩在了解释器上,用一个名为持久化脚本的结构体(persistent_script structure)替换了原本Zend Engine在编译中使用的table以及结构体。

以下就是 persistent_script 结构体的定义:

typedef struct _zend_persistent_script {
    ulong          hash_value;
    char          *full_path;              /* full real path with resolved symlinks */
    unsigned int   full_path_len;
    zend_op_array  main_op_array;
    HashTable      function_table;
    HashTable      class_table;
    long           compiler_halt_offset;   /* position of __HALT_COMPILER or -1 */
    int            ping_auto_globals_mask; /* which autoglobals are used by the script */
    accel_time_t   timestamp;              /* the script modification time */
    zend_bool      corrupted;
#if ZEND_EXTENSION_API_NO < PHP_5_3_X_API_NO
    zend_uint      early_binding;          /* the linked list of delayed declarations */
#endif

    void          *mem;                    /* shared memory area used by script structures */
    size_t         size;                   /* size of used shared memory */

    /* All entries that shouldn't be counted in the ADLER32
     * checksum must be declared in this struct
     */
    struct zend_persistent_script_dynamic_members {
        time_t       last_used;
        ulong        hits;
        unsigned int memory_consumption;
        unsigned int checksum;
        time_t       revalidate;
    } dynamic_members;
} zend_persistent_script;

这段代码演示OPCache如何将原本的数据结构替换成persistent_script结构体:

new_persistent_script = create_persistent_script();

/* Save the original values for the op_array, function table and class table */
orig_active_op_array = CG(active_op_array);
orig_function_table = CG(function_table);
orig_class_table = CG(class_table);
orig_user_error_handler = EG(user_error_handler);

/* Override them with ours */
CG(function_table) = &ZCG(function_table);
EG(class_table) = CG(class_table) = &new_persistent_script->class_table;
EG(user_error_handler) = NULL;

zend_try {
    orig_compiler_options = CG(compiler_options);
    /* Configure the compiler */
    CG(compiler_options) |= ZEND_COMPILE_HANDLE_OP_ARRAY;
    CG(compiler_options) |= ZEND_COMPILE_IGNORE_INTERNAL_CLASSES;
    CG(compiler_options) |= ZEND_COMPILE_DELAYED_BINDING;
    CG(compiler_options) |= ZEND_COMPILE_NO_CONSTANT_SUBSTITUTION;
    op_array = *op_array_p = accelerator_orig_compile_file(file_handle, type TSRMLS_CC); /* Trigger PHP compiler */
    CG(compiler_options) = orig_compiler_options;
} zend_catch {
    op_array = NULL;
    do_bailout = 1;
    CG(compiler_options) = orig_compiler_options;
} zend_end_try();

/* Restore originals */
CG(active_op_array) = orig_active_op_array;
CG(function_table) = orig_function_table;
EG(class_table) = CG(class_table) = orig_class_table;
EG(user_error_handler) = orig_user_error_handler;

我们可以看到,PHP解释器已经被隔离开来了,它现在会将输出填到persistent_script结构体中。之后OPCache会读取这些数据,并且将请求申请的指针替换为共享内存中对应的指针。OPCache对以下几部分数据感兴趣:

  • 脚本文件中的函数(functions)
  • 脚本文件中的类(classes)
  • 脚本的OPcode序列(OPArray)
  • 脚本路径
  • 脚本文件结构

undefined

另外解释器也会提供一些选项去禁用一些解释优化,例如 ZEND_COMPILE_NO_CONSTANT_SUBSTITUTIONZEND_COMPILE_DELAYED_BINDING 。这会增加OPCache的工作量。记住OPCache是钩在Zend Engine上的一个扩展,而不是Zend Engine的补丁。

现在我们有了persitent_script结构体,我们要缓存它的信息。PHP解释器已经填充了persitent_script结构体,不过之后他还是会使用ZMM来申请内存,这些内存会在请求后被释放,OPCache要做的事是读取这些内存,并且将它们全部复制到共享内存中。所以刚刚我们得到的数据就得以在多个请求间持久化,不需要每次都重新计算了。

流程如下:

  • 将PHP脚本缓存,计算每一个变量数据大小(每一个指针目标)
  • 在已经申请好的共享内存中,划分出一片内存空间(根据刚刚计算得出的大小)
  • 重新遍历一遍PHP的变量结构,并且将这些变量数据拷贝到刚刚划分的内存块中
  • 当加载脚本的时候,使用共享内存里面已经缓存好的数据

OPCache在使用共享内存的时候不会将它标记已经释放或者将它压缩,这一点很聪明。OPCache会为每一个脚本需要存放的信息计算出一个准确的大小,在共享内存中划分一个区间存放。因为共享内存不会被释放也不会被归还给操作系统,因此这些内存是整齐而且不零碎的。由于不进行复杂的内存管理,如利用链表或者B-tree那样数据结构来对共享内存实现类似malloc/free的管理功能,这样对性能的提升是巨大的。OPCache将数据保存在一段一段划分好的内存中,当数据失效的时候(根据脚本文件的再校验来决定),它不会标记失效数据所占用的内存为被释放,而是将失效数据占用的内存标记为“wasted”,并且不再使用。当“wasted”达到最大值时,OPCache会重启。这样的内存使用模式跟过去的(例如APC)完全不同,这样做最大的好处是避免了复杂的内存管理(如标记释放,压缩内存等等),共享内存在使用中能持续地保持高性能(不会碎片化),而且由于不去管理那些已经被划分成一段一段的内存,对于CPU的缓存命中率也有很大的提升(尤其在L1/L2层面)。OPCache可以说已经为PHP运行时提供了最好的性能。

缓存一个脚本的第一步是计算脚本数据所需要的内存空间,以下是算法:

uint zend_accel_script_persist_calc(zend_persistent_script *new_persistent_script, char *key, unsigned int key_length TSRMLS_DC)
{
    START_SIZE();

    ADD_SIZE(zend_hash_persist_calc(&new_persistent_script->function_table, (int (*)(void* TSRMLS_DC)) zend_persist_op_array_calc, sizeof(zend_op_array) TSRMLS_CC));
    ADD_SIZE(zend_accel_persist_class_table_calc(&new_persistent_script->class_table TSRMLS_CC));
    ADD_SIZE(zend_persist_op_array_calc(&new_persistent_script->main_op_array TSRMLS_CC));
    ADD_DUP_SIZE(key, key_length + 1);
    ADD_DUP_SIZE(new_persistent_script->full_path, new_persistent_script->full_path_len + 1);
    ADD_DUP_SIZE(new_persistent_script, sizeof(zend_persistent_script));

    RETURN_SIZE();
}

我重复一遍,我们要缓存的数据是:

  • 脚本文件中的函数(functions)
  • 脚本文件中的类(classes)
  • 脚本的OPcode序列(OPArray)
  • 脚本路径
  • 脚本文件结构

对于函数,类,OPcode序列,迭代的算法是深度优先搜索:它会缓存每一个指针数据。例如在PHP5中的函数,我们需要复制进共享内存中的数据包括如下部分:

  • The functions HashTable
    The functions HashTable buckets table (Bucket **)
    The functions HashTable buckets (Bucket *)
    The functions HashTable buckets' key (char *)
    The functions HashTable buckets' data pointer (void *)
    The functions HashTable buckets' data (*)
  • The functions OPArray
    The OPArray filename (char *)
    The OPArray literals (names (char ) and values (zval ))
    The OPArray OPCodes (zend_op *)
    The OPArray function name (char *)
    The OPArray arg_infos (zend_arg_info , and the name and class name as both char )
    The OPArray break-continue array (zend_brk_cont_element *)
    The OPArray static variables (Full deep HashTable and zval *)
    The OPArray doc comments (char *)
    The OPArray try-catch array (zend_try_catch_element *)
    The OPArray compiled variables (zend_compiled_variable *)

这里就不列出所有详细了,而且这些在PHP7中都有所改变。反正思路就是:复制每一个指针数据到共享内存中。深度复制需要递归结构,OPCache使用一个关联表来存储指针:每一次它从常规请求的内存中复制指针到共享内存中,它会将老的指针地址和新的指针地址关联起来。每次进行内存复制前,都在关联表中查看是否已经是已经复制过的数据,如果是的话就直接复用老的指针数据,这样就避免了重复复制:

void *_zend_shared_memdup(void *source, size_t size, zend_bool free_source TSRMLS_DC)
{
    void **old_p, *retval;

    if (zend_hash_index_find(&xlat_table, (ulong)source, (void **)&old_p) == SUCCESS) {
        /* we already duplicated this pointer */
        return *old_p;
    }
    retval = ZCG(mem);;
    ZCG(mem) = (void*)(((char*)ZCG(mem)) + ZEND_ALIGNED_SIZE(size));
    memcpy(retval, source, size);
    if (free_source) {
        interned_efree((char*)source);
    }
    zend_shared_alloc_register_xlat_entry(source, retval);
    return retval;
}

ZCG(mem) 表示固定大小的共享内存段,它会随着元素的添加逐渐被填满。它一开始就已经被申请了,所以每次复制就不再需要再去申请新的内存空间,只要简单地将数据填到内存里面,然后将边界指针地址往前移动即可。

我们详细分析了脚本缓存算法,它所做的事情是将以请求为边界的内存指针和数据复制到共享内存(前提是之前没有复制过)。加载算法则做了完全相反的事情:它将 persistent_script 结构体数据从共享内存中读取出来,并且复制到请求申请的内存指针上。之后脚本就已经可以准备让Zend Engine Executor执行了。在脚本执行前的指针替换,对于 Zend Engine 是透明的。

这个从常规内存复制数据到共享内存的过程(缓存脚本)或者相反的过程(加载脚本),都是被优化过的,就算这个过程中包含了许多有损性能的内存复制以及哈希查找,但依旧还是比每次都让解释器解释PHP要快得多。

共享字符串常量

从PHP5.4开始新增的字符串常量是一个不错的内存优化策略。它的工作场景大概是这样的:每次PHP遇到一个只读字符串(char*),PHP会将这个字符串存到一个特殊的缓冲区(BUFFER)中并且在下次再遇到这个字符串的时候直接重用缓冲区的指针。你或许会从这篇文章里面学到一些关于共享字符串的姿势。

字符串常量的工作原理如下:

undefined

相同的字符串实例被许多指针所共用。不过这样做存在一个问题:字符串常量的缓冲区(BUFFER)是进程独立的,主要由PHP解释器来管理。这就意味着,在PHP-FPM进程池里面,每一个PHP worker进程都会保存同样的BUFFER,如下图:

undefined

这导致大量内存的浪费,特别是如果PHP-FPM启用大量worker进程,并且你在代码当中使用大量字符串(tips:PHP的各种注解[annotations]都是字符串)。使用OPCache则会将这些BUFFER共享给所有的PHP worker进程,变成下图所示:

undefined

OPCache将字符串常量放在共享内存中提供给同一个PHP-FPM进程池中的所有worker共同使用,并且OPCache是使用之前申请的共享内存的其中一段来存储的。所以你在使用OPCache的时候也要根据你使用了多少字符串常量来衡量你需要多大的内存来放字符串常量。OPCache允许你使用INI设置中的 opcache.interned_strings_buffer 来调整字符串常量的内存。再次提醒:确保提供足够大的内存。如果字符串常量的缓冲区用完的话(opcache.interned_strings_buffer 设置过小),OPCache并不会重启,因为共享内存还有可用空间,只是字符串常量缓冲区用完并不会导致请求的堵塞,这种情况下,一部分字符串常量是使用OPCache的缓冲区,而不足的部分则由PHP的worker的内存补足。为了提高性能,我不太建议让这种情况出现。

当OPCache的字符串常量缓冲区用完的时候,OPCache会在日志中给出警告:

if (ZCSG(interned_strings_top) + ZEND_MM_ALIGNED_SIZE(sizeof(Bucket) + nKeyLength) >= ZCSG(interned_strings_end)) {
/* no memory, return the same non-interned string */
zend_accel_error(ACCEL_LOG_WARNING, "Interned string buffer overflow");
return arKey;
}
字符串常量是指PHP解释器遇到的所有不变的字符串,例如:变量名,函数名,类名,常量……  PHP的注释中使用的“注解”(annotations)同样也是字符串,并且通常来说它们都是大字符串,会吃掉你不少字符串常量缓冲。设置字符串缓冲大小的时候这些都要好好考虑。

锁机制

说到了共享内存,我们就必须说一下内存的锁机制。原则很简单:某一个PHP worker进程要对内存进行写操作的时候,就锁住所有其他进程的写操作。临界区(critical section)的写操作就是这么完成的,但是读操作则不是。在同一时间,你可能会有150个PHP进程在读取共享内存,而只有其中一个可以进行写操作,写操作之间虽然是互斥的,但写操作并不会阻塞读操作。

所以,做好缓存预热的话,OPCache理论上不会出现死锁。如果在你部署代码之后就将你的web服务器直接开放到生产环境,这个时候会有大量请求涌入,需要解释和缓存大量的脚本,而共享内存的写操作是在排它锁下进行的,当第一个写操作来临时会锁住所有其他进程,当写操作结束的时候,其他进程会先去检查自己刚刚解释完成的脚本是不是已经被其他进程存进共享内存了,如果已经存进去了的话,就会把自己的编译结果丢弃,转而使用共享内存的结果,这导致了大量资源的浪费。

/* exclusive lock */
zend_shared_alloc_lock(TSRMLS_C);

/* Check if we still need to put the file into the cache (may be it was
 * already stored by another process. This final check is done under
 * exclusive lock) */
bucket = zend_accel_hash_find_entry(&ZCSG(hash), new_persistent_script->full_path, new_persistent_script->full_path_len + 1);
if (bucket) {
    zend_persistent_script *existing_persistent_script = (zend_persistent_script *)bucket->data;

    if (!existing_persistent_script->corrupted) {
        if (!ZCG(accel_directives).revalidate_path &&
            (!ZCG(accel_directives).validate_timestamps ||
             (new_persistent_script->timestamp == existing_persistent_script->timestamp))) {
            zend_accel_add_key(key, key_length, bucket TSRMLS_CC);
        }
        zend_shared_alloc_unlock(TSRMLS_C);
        return new_persistent_script;
    }
}

为了避免上面所说的,你应该这么做:首先将要部署新代码的服务器从线上生产环境断开,之后再部署代码,部署代码完毕以后通过curl去访问那些访问量比较大的URL,通过这样的curl请求来平滑地预热共享内存。当你觉得你已经缓存了大部分脚本了,你就可以将这台服务器重新接到线上环境,接下来的基本上就都是不会引起内存锁的读操作了。当然这么做并不能将所有的脚本都预热了,但是那些脚本其实并不是那么常用,因此对它们的缓存不会引发锁的压力。

另外你还要避免这种做法:直接在runtime环境更新PHP脚本文件。理由同上:一旦你更新了线上生产环境的PHP文件, 就会有大量的PHP worker进程尝试去解释和缓存它:你会因此被锁。动态生成的PHP脚本文件应该通过配置INI选项 opcache.blacklist-filename 将它们加到OPCache的黑名单中(可以使用通配符)。

技术上来说,这个锁机制并不是那么强,不过它广泛应用在类Unix系统:它调用的是著名的 fcntl() 。

void zend_shared_alloc_lock(TSRMLS_D)
{
    while (1) {
        if (fcntl(lock_file, F_SETLKW, &mem_write_lock) == -1) {
            if (errno == EINTR) {
                continue;
            }
            zend_accel_error(ACCEL_LOG_ERROR, "Cannot create lock - %s (%d)", strerror(errno), errno);
        }
        break;
    }
    ZCG(locked) = 1;
    zend_hash_init(&xlat_table, 100, NULL, NULL, 1);
}

这里讨论了正常情况下的锁机制,如果你稍加注意的话,在同一时间应该只有一个PHP进程在写共享内存,一般不会出什么问题。

不过接下来我们要说的另外一种锁,你需要时刻避免: 内存耗尽锁。

理解OPCache的内存消耗

至今为止我提到了以下这几点:

  • OPCache在PHP启动的时候一口气申请了一大段独立的共享内存。
  • OPCache不会对这段内存进行释放,这段内存在启动的时候被申请,之后根据需要往里面填数据。
  • OPCache对于写操作存在锁机制。
  • 共享内存用于以下几点:
    • 脚本数据结构缓存,OPCode缓存等等
    • 字符串常量共享缓冲区
    • 已经被缓存的脚本的哈希表
    • OPCache全局共享内存状态数据

如果你对你的脚本使用校验,OPCache会在请求的时候检查脚本的修改日期(检查频率可以配置INI选项 opcache.revalidate_freq 设置),并且将脚本文件标记为“新的”或者“旧的”。检查结果会缓存起来,所以不会花费很多资源,而且有时候在OPCache检查文件之前,PHP进程就已经对脚本文件进行了stat(),OPCache只是复用了这个检查结果而已。

如果你配置了INI选项 opcache.validate_timestampsopcache.revalidate_freq ,对脚本进行时间戳校验的话,当你的文件被更改后,OPCache会简单地认为这个文件已经无效,并且将它占用的所有共享内存标记为无效,要注意标记为无效并不代表会释放这些内存,或者这么说,OPCache认为这部分内存已经无用(“wasted”)了。只有当OPCache用光了所有申请的内存或者被标记为“wasted”的内存达到了INI选项 opcache.max_wasted_percentage 的值时,OPCache才会触发完全重启,你必须要绝对避免这种情况的发生。

/* Calculate the required memory size */
memory_used = zend_accel_script_persist_calc(new_persistent_script, key, key_length TSRMLS_CC);

/* Allocate shared memory */
ZCG(mem) = zend_shared_alloc(memory_used);
if (!ZCG(mem)) {
    zend_accel_schedule_restart_if_necessary(ACCEL_RESTART_OOM TSRMLS_CC);
    zend_shared_alloc_unlock(TSRMLS_C);
    return new_persistent_script;
}

undefined

上图大概展示了OPCache的共享内存运行一段时间后的状态。脚本文件的改变导致这个脚本所占用的内存被标记为“wasted”,OPCache会简单地无视这些内存,它会重新编译新的脚本并且放到共享内存中还没使用的地方。

当无用内存达到一定程度后,会触发OPCache的重启,这个时候OPCache会锁住这整段共享内存,并且将所有内存段重置(也可以理解为清空),之后才解锁。这会让你的服务器像刚刚启动一样:所有的PHP worker进程都要去竞争读操作的锁,负载越重,性能就会越低,这会让你的服务器在一段时间内响应变慢。

绝对不要让共享内存耗尽

一般来说,你应该禁止随意更改生产服务器的脚本文件,这样就保证了你的OPCache永远不会因为耗尽共享内存而触发重启(不过当OPCache耗尽用于保存脚本key的空间时有可能发生例外情况,这一点待会再讨论)。典型的代码部署应该遵循以下原则:

  • 断开需要部署的服务器和负载均衡器的连接
  • 清空opcache(调用 opcache_reset() )或者直接关闭PHP-FPM(更加推荐,之后会细说)
  • 部署新的代码
  • 有需要的话就重启PHP-FPM并且使用curl去访问应用访问量大的URL来进行缓存预热
  • 将这台服务器放回生产环境

通过50行左右的shell脚本应该就可以做到上面的这些事情。

你甚至可以用一些GUI前端工具来在web上查看OPCache目前的运行状态(在Github上有这类项目),他们基本上都是使用 opcache_get_status() 函数来监控OPCache。

undefined

到这里事情还没算完,还有一件很重要的事情一定要记住 : cache keys

当OPCache缓存一个脚本到共享内存的时候,为了能够之后可以找到这个脚本存在哪,它使用了哈希表。它必须选择一个key来进行哈希表的索引,使用怎样的key和索引方式主要是根据配置以及你的应用程序的框架设计。

一般来说,OPCache用完全路径来定位脚本,不过要当心的是 PHP的realpath缓存(realpath-cache),你有可能会因为这个而踩坑。如果你在部署程序的时候使用软连接的话,将 opcache.revalidate_path 设置为1,并且清空你的realpath缓存。

如果你将 opcache.revalidate_path 配置为1的话,OPCache会去定位文件的真实路径,并且使用文件的真实路径字符串(realpath string)来作为这个脚本的缓存key,不是设置成1的话,OPCache就不会定位文件真实的路径就直接拿文件的路径来当作缓存的key,如果你是用软连接的方式来部署应用程序,会引发一些问题,因为OPCache不会察觉软连接的目标文件已经发生了改变,它会继续使用之前的文件路径作为key去找老的目标脚本文件。

在脚本文件中如果使用了相对路径(如 require_once "./foo.php"; ),将 opcache.use_cwd 设置为1来让OPCache将cwd(工作目录)加到所有key的前面。如果你使用了相对路径,并且在同一个PHP-FPM进程池中运行了好几个应用程序(一般来说你不应该这么做)的话,记得将 opcache.use_cwd 设置成1。另外,如果你使用软连接的话,记得要将 opcache.revalidate_path 设置成1。不过就算是这些都做了,你也还是可能会踩到realpath缓存(realpath-cache)的坑,并且有可能你改了软连接的目标路径,但是OPCache没有发现,即使你调用了 opcache_reset()

因为PHP的realpath缓存,你可能会在使用软连接发布的时候踩到一些坑。即使将 opcache.use_cwd 和 opcache.revalidate_path 设置成1,错误的软连接定位依旧有可能发生,这是因为PHP的realpath缓存机制,导致将错误的路径传给了OPCache。

如果你想在部署的时候更加安全一些,首选项是不要使用软连接方式来部署应用程序。如果一定要用的话,那就启用两个PHP-FPM,并且在发布的时候使用FastCGI负载均衡器来管理他们。我记得Lighttpd和Nginx都提供了类似的功能(博主注:说的应该是Nginx的upstream模块)。

发布的步骤参考如下:

  • 断开需要部署的服务器和负载均衡器的连接
  • 关闭PHP-FPM,这特别安全,因为这样就等于关掉了所有跟PHP有关的东西,比如会坑你的PHP realpath缓存
  • 立刻部署新的应用程序文件
  • 重启PHP-FPM,别忘了通过curl预热缓存
  • 将这台服务器放回生产环境

如果你不想将服务器从生产环境中下线,可以这么做:

  • 将新的应用程序代码部署到一个新的目录,这个时候你的PHP服务器有一个PHP-FPM进程池在正常工作和响应生产环境的请求
  • 启动第二个PHP-FPM进程池,监听另外一个端口,同时第一个PHP-FPM还在正常工作
  • 现在你有两个PHP-FPM,监听着不同的端口,一个在工作,另外一个空闲
  • 将应用程序代码的软连接目标改成新部署的代码目录,然后让第一个PHP-FPM停止工作。如果你已经在webserver配置了这两个PHP-FPM进程池,那么它应该会发现其中一个进程池正在关闭中,要将请求发送到新的PHP-FPM进程池中。没有失败请求和堵塞中断,两个PHP-FPM进程池就是这么优雅地平滑过渡到新部署的应用程序。写得好的话,一个80行左右的shell脚本就可以完成上述工作。

根据配置选项,同一个脚本文件可能被OPCache生成好几个key。但是key的存储空间不是无限的:它同样是保存在一开始申请的共享内存中,并且有可能会被填满,填满的时候就算共享内存中还有空间,OPCache依旧会因为保存脚本的key的表被填满而重启。

你必须时刻监控着key的存储,不要让key的存储空间耗尽。

使用 opcache_get_status() 函数可以获得OPCache的这些信息,各种GUI也是根据这个函数的返回来进行显示。这个函数返回的数据中有一个字段: num_cached_keys ,这个字段告诉你key的使用情况。你应该使用INI选项 opcache.max_accelerated_files 来预设key的最大值。注意OPCache对于同一个文件也是有可能计算出多个key的,因此要监控它,并且为key设置合适的数值。避免在require_once时使用相对路径,这会让OPCache生成更多的key。推荐使用autoloader,如果配置得好的话,就可以做到总是用include_once引用完整路径的文件,而不是相对路径。

OPCache在启动的时候就已经预先分配好了用来存储脚本信息的哈希表的内存大小,并且不会去改变这个大小,如果耗尽了就会重启。这么做的理由就跟之前提到的一样,是为了保证性能。

这就是为什么你会在OPCache状态信息中看到 num_cached_scripts 字段跟 num_cached_keys 字段的值不一样。只有 num_cached_keys 是可靠的,当它达到了 max_cached_keys ,你就会遇到重启坑。

别忘了你可以通过设置INI选项 opcache.log_verbosity_level 降低OPCache的日志等级(log level)来获得更多的信息。当内存耗尽的时候,它会提示你到底是什么内存耗尽了:是共享内存耗尽了,还是用来存放key的哈希表已经被填满了。

undefined

static void zend_accel_add_key(char *key, unsigned int key_length, zend_accel_hash_entry *bucket TSRMLS_DC)
{
    if (!zend_accel_hash_find(&ZCSG(hash), key, key_length + 1)) {
        if (zend_accel_hash_is_full(&ZCSG(hash))) {
            zend_accel_error(ACCEL_LOG_DEBUG, "No more entries in hash table!");
            ZSMMG(memory_exhausted) = 1;
            zend_accel_schedule_restart_if_necessary(ACCEL_RESTART_HASH TSRMLS_CC);
        } else {
            char *new_key = zend_shared_alloc(key_length + 1);
            if (new_key) {
                memcpy(new_key, key, key_length + 1);
                if (zend_accel_hash_update(&ZCSG(hash), new_key, key_length + 1, 1, bucket)) {
                    zend_accel_error(ACCEL_LOG_INFO, "Added key '%s'", new_key);
                }
            } else {
                zend_accel_schedule_restart_if_necessary(ACCEL_RESTART_OOM TSRMLS_CC);
            }
        }
    }
}

总结一下OPCache的内存使用,如下图所示:

undefined

当你启动PHP的同时,OPCache也会启动,并且立刻向OS申请 opcache.memory_consumption MB的共享内存。之后就会开始使用这片内存空间,其中一段用作字符串常量缓冲区( opcache.interned_strings_buffer ),完了以后会再拿出一段来创建一个哈希表用于保存脚本文件的key( opcache.max_accelerated_files )。

然后,一部分共享内存被OPCache内部自己使用,剩下的没有被占用的空间就被专门用来保存脚本的数据结构了。随着你的脚本被解释,这些未被使用的空间会慢慢被填满,然后文件被更改后就渐渐地会出现标记为“wasted”的内存,除非你的配置让OPCache不需要重复解释脚本文件(推荐)。

这时候的共享内存使用情况,如下图:

undefined

配置OPCache

如果你的应用程序是基于框架的(比如Symfony),我强烈建议:

  • 关闭脚本检验机制(设置 opcache.validate_timestamps 为0)
  • 基于Symfony的应用,部署的时候使用全新的runtime环境(也就是重启PHP-FPM或者启用新的PHP-FPM来过渡)
  • 设置合适的内存空间大小
    • opcache.memory_consumption 这个是最重要的,设置的是OPCache的内存空间总大小。
    • opcache.interned_strings_buffer 保存字符串常量的空间大小,这个大小需要通过监控来了解具体需要多少空间,尤其是如果你使用了PHP的注解"annotations"( opcache.save_comments = 1 ),注解字符串将占用很多空间。
    • opcache.max_accelerated_files 代表了最大可以索引的脚本文件的key的数量,要好好确认具体需要多少。
  • opcache.opcache.revalidate_pathopcache.use_cwd 都关闭的话,能够节省一些key空间。
  • 启用 opcache.enable_file_override 将可以对autoloader加速。
  • 将runtime中动态生成的脚本加入到 opcache.blacklist_filename 中,那些文件应该不会有很多。
  • opcache.consistency_checks 关闭,否则它会每隔N次请求校验缓存和,会影响性能。

通过这些配置,你的共享内存应该不会被设置成“wasted”状态,因此 opcache.max_wasted_percentage 基本上就没什么用了,但是在部署的时候需要关掉正在服务的PHP-FPM,你可以通过启用多个PHP-FPM来平滑过渡,就像前面解释的那样。

这样应该就够了。

-----------------------------------------------------------------------------

尾声 by 博主

断断续续好几个星期,终于翻译完了…

其实原文还有最后一段,是关于OPCache如何对OPCode进行优化的,那段基本都是代码,看一下就懂,我就不翻译了,有兴趣的同学可以自行到原文去阅读。

这篇文除了让我深入理解了OPCache的工作原理以及一些容易踩到的坑以外,也在部署PHP应用方面给了我一些启发,比如通过启用多个PHP-FPM来做FAST-CGI的负载均衡来平滑过渡,这一点其实也能用于灰度发布,果然多读读一些好的技术文章还是比较能够开阔视野的。