一、什么是opcache
Opcache是一种通过将PHP脚本预编译的字节码(Operate Code)存放在共享内存中,避免每次请求都要加载和解析PHP脚本,解析器可以直接从共享内存读取已经缓存的字节码(Operate Code)。
二、PHP脚本解释执行流程
1、php初始化执行环节,启动Zend引擎,加载注册的扩展模块;
2、初始化后读取脚本文件,Zend引擎对脚本文件进行词法分析(lex),语法分析(bison),生成语法树;
3、Zend 引擎编译语法树,生成Zend引擎要执行的代码opcode(即操作码);
4、Zend 引擎执行opcode,返回执行结果;
简洁来说就是:加载扩展、解析脚本、编译、执行opcode。
在PHP cli模式下,每次执行PHP脚本,四个步骤都会依次执行一遍;
在PHP-FPM模式下,步骤1在PHP-FPM启动时执行一次,后续的请求中不再执行;步骤2~4每个请求都要执行一遍;
三、基于共享内存
其实如果一个php脚本内容不发生改变,该脚本生成的语法树和opcode 每次请求时都是一样的。opcache模块可以在这种情况下通过将编译后的opcode缓存到一片共享内存,避免相同脚本重复编译,减少CPU和内存开销。缓存好的opcode可以供不同的php进程访问。
除了opcache之外,apc、xcache等扩展也可支持缓存脚本文件编译后的字节码,但是这些扩展大部分已经不再更新,因此官方推荐使用opcache。
四、缓存内容
opcache缓存的内容包括:
- opcode 操作码、字节码
- interned string 预留字符串 (可以理解为php请求生命周期中不需要释放的字符串,包括:变量名、类名、方法名、字符串、注释等)
缓存以key-value的形式存储,key是脚本文件名的真实路径。
例如一个index.php文件中引入了 a.php和b.php,b.php又引入了c.php。
当一个请求到达index.php时,php会分别编译 index.php、a.php、b.php、c.php,每个php文件分别作为一个key-value存储到opcache中。
需要注意的是,即使opcache可以缓存多个php脚本的操作码,避免每次请求都发生编译,这并不意味着这些缓存的脚本文件和.exe一样高效。因为即使消除了编译的开销,每次请求PHP还是需要从缓存获取文件,检查源文件是否被修改,对多个编译后的文件进行链接(如include和require),将用到的类或函数从opcache拷贝到进程的内存空间,这都需要开销。如果想要进一步节省链接和运行过程中从opcache缓存拷贝到php进程内存空间这两个过程,可以使用preloading功能。
五、opcache常见的配置
以下为默认值,阅读opcache的配置可以帮助你了解opcache的行为。
需要注意:
1、enable_cli:
如果项目框架里有PHP_SAPI === 'cli'类似的判断,请不要开启enable_cli=true。因为开启这个并且设置了file_cache,php-fpm和cli会共用file_cache,如果php-fpm先执行了含有PHP_SAPI === 'cli'的脚本,再用cli方式执行该脚本,那么此时PHP_SAPI的返回并不是’cli’,因为他之前在以fpm模式执行时已经被opcache缓存。
2、opcache.validate_timestamps 和 opcache.revalidate_freq:
每隔若干秒检查1次某个请求到的php脚本是否修改以更新opcache缓存。每次检测都是一次 stat 系统调用,众所周知,系统调用会消耗一些 CPU 时间,并且 stat 系统调用会进行磁盘 I/O,更加浪费性能。
在生产环境中,如果某一次需求迭代更新了大量代码,并且发布到正式环境,可能会出现这样的情况:文件A 的opcache更新了,但是文件B的还没更新,A include B,由于B的opcache没有更新导致报错。
如何解决这个问题:
a. 将opcache.revalidate_freq置为0;每次请求都检查要访问的脚本是否更新。
b. 将 validate_timestamps 置为0,这样即使文件更新,用户访问到的也是缓存中的旧脚本内容。当代码发布到正式环境后,通过http方式手动调用一个含有 opcache_invalidate() 的php脚本重新编译上线的脚本。
c. 将 validate_timestamps 置为0,当新代码发布到正式环境后,平滑重启php-fpm,并运行一个自定义的预执行php脚本将项目中所有php脚本进行 opcache_compile_file()操作,然后才开始接收用户请求。
d. composer安装cachetool清除opcache。
3、max_wasted_percentage:
对于浪费内存我是这样理解的,如果一个文件更新了,opcache中该脚本的缓存就是一个过期缓存,该脚本在opcache中占用的内存就是所谓的“浪费内存”。
如果设置了revalidate_freq和validate_timestamps,并且一次性更新的文件内容和数量太多,就会产生较多的current wasted memory。如果超过了 max_wasted_percentage 的限定值,就会导致opcache被动清空,重新编译和缓存脚本文件。如果在并发量较大的情况下,这会导致系统负载飙升。
如果要避免opcache被动清空,需要做好以下2件事:
a. 配置足够的opcache.memory_consumption和opcache.max_accelerated_files;
b. 合理的代码上线发布策略;
4、memory_consumption:
不合理的发布策略可能会导致实际的脚本缓存内容超过opcache的memory_consumption从而导致opcache内存溢出。
尤其是在使用软链接的发布策略时,具体是指nginx配置项目根目录是一个目录L(是个软链接),它真实指向的是目录A。而代码发布后,新迭代的更新代码不是同步到目录A,而是创建一个新项目目录B,B的代码是最新代码。之后通过shell脚本将软链接目录L指向目录B,于是用户请求就会全部走到目录B。
由于opcache缓存脚本文件是针对文件的真实目录,因此用户请求B的脚本文件时就会将目录B下的脚本也缓存到opcache中。这么一来,opcache中缓存的文件既包含A目录的文件,又包含B目录的文件,导致内存溢出,应用崩溃。
要避免这种情况,要么就不使用软链接的发布策略,要么是设置原先2倍大的memory_consumption。
六、opcache预加载(preloading)
从 PHP 7.4版本开始,可以将PHP配置为在 Zend 引擎启动时将脚本预加载到 opcache 中,使得这些脚本中的任何函数、类、接口或 trait 对所有请求全局可用,而无需显式include即可直接使用。
- opcache预加载的好处和坏处:
使用opcache预加载可以把要预加载的脚本字节码常驻到内存中,使程序性能提升,类库和函数的调用更方便。当然,这是以一定内存作为代价交换的。
修改预加载的脚本不方便,如果想要修改预加载的脚本,必须要重启整个PHP进程树以刷新opcache,因此opcache预加载只适用于生产环境,不适合开发环境。
- opcache的适用场景:
opcache预加载适用于生产环境,不适用于开发环境;
opcache预加载适用于fpm进程这样作为web服务、用来接收用户请求的守护进程,因为这样才会多次访问到相同的php脚本,才能命中缓存;对于用于一次性执行脚本任务的cli进程而言是不适用的(当然,对于使用swoole扩展构建的服务器cli进程池则另当别论)。
- 如何使用opcache的预加载功能:
需要在php.ini中设置 opcache.preload 配置,该配置指定一个自定义的php文件,该php文件需要调用 opcache_compile_file() 函数预加载指定的php脚本文件。
该 preload.php 在服务器启动时(如nginx + PHP-FPM、apache + mod_php 等)会自动执行一次,将preload.php 所include、 include_once、require、require_once或 opcache_compile_file()引用的任何PHP文件编译一遍放入opcache共享内存。
下面是一个 preload.php 的官方示例:
预加载文件中,include和 opcache_compile_file ()函数指定的文件都能被预加载,但对代码的处理方式有不同的影响。
a、include会执行指定的文件中的代码,而opcache_compile_file()不会。
b、因为include将执行代码,凡是执行到嵌套的 include 文件也将被解析并预加载它们,例如a.php内引入了一个b.php,则用include预加载a.php时也会预加载b.php。
c、opcache_compile_file()可以按任何顺序加载文件。也就是说,如果 a.php定义了 classA而b.php定义 B extends A,那么opcache_compile_file()可以按任意顺序加载这两个文件。但是,当使用include时,必须首先包含a.php 。
因此,哪种方法更好取决于所需的行为。对于使用自动加载器的代码,使用opcache_compile_file()更加灵活。对于手动加载的代码,使用include则更加健壮。
预加载的文件无法用opcache_reset()清除其在opcache中的缓存,如需更新预加载的文件只能重启php服务。
可以通过 opcache_get_status() 的 preload_statistics 查看所有有关预加载函数、类和脚本的信息。
最后需要注意:
在满足继承等依赖关系的情况下,class, funciton, trait, interface能够进行预加载。而预加载文件中的全局变量,define,const无法被预加载。
七、opcache的常见函数
该函数可以用于在不运行某个 PHP 脚本的情况下,编译该 PHP 脚本并将其添加到字节码缓存中去。 该函数可用于在 Web 服务器重启之后初始化opcache缓存,以供后续请求使用。
获取缓存的配置信息
-调用该函数并不会将某个脚本从缓存移除,而是让该脚本重新编译并更新该脚本的缓存。如果需要删除缓存请使用 opcache_reset(),可以使用opcache_get_status()验证。
-只有存在的脚本文件的缓存才可以被该函数废除,如果某个脚本在opcache有缓存但是后来该脚本文件的路径不存在了(例如你手动删除了该文件),则你无法opcache_invalidate一个被删除的文件。
-调用opcache_invalidate时,会获取 shm 锁。如果并发调用该函数,可能导致一部分调用结果失败。
-opcache基于共享内存或者文件存储,而PHP的共享内存只在一个进程家族(即一棵进程树中的所有进程)中才能共享(换句话说,opcache是进程树之间相互隔离的)。
这意味着,fpm进程和cli进程之间不能共享一个opcache缓存,它们的opcache缓存是相互独立的。两个fpm master进程下的fpm worker进程池之间也是两个独立的opcache缓存(例如 终端执行了两次php-fpm命令,一次监听9000,一次监听9001)。
因此我们无法使用一个调用 opcache_reset() 的cli脚本清除一个fpm进程池内的opcache缓存。
这个时候该怎么清除fpm进程中的opcache呢?
可以在某个脚本reset.php调用opcache_reset(),并以fpm进程的形式运行该reset.php脚本,也就是使用http请求该脚本。
在较高并发的情况下,如果使用opcache_reset()可能导致对所有请求涉及的脚本文件,zend都会对其编译和构建缓存,导致系统负载瞬间飙升。
为避免该情况,可以使用 opcache_invalidate() 而非 opcache_reset()。
顺带一提,如果希望cli和fpm这两个进程树共享一个opcache或者跨php进程生命周期使用opcache缓存,可以使用file_cache,即只开启opcache文件缓存(将编译后的opcode缓存到文件中),不使用opcache内存缓存。