说明:本文发布于2024年8月。PHP 8.4正式版预计在2024年11月发布,目前处于RC(候选发布)阶段。本文基于RC版本的测试和升级经验撰写,正式版发布后部分细节可能有所变化。

上周,我们团队做了一件"胆大妄为"的事情:把生产环境的PHP从8.3升级到了8.4 RC版本。

结果,出大事了。

升级后不到半小时,线上服务开始出现大量500错误,CPU使用率飙升到100%,内存占用持续增长,差点把服务器搞崩。我们紧急回滚,折腾了两个多小时才恢复服务。

这篇文章,就来复盘这次惊心动魄的故障,从问题发现、排查定位到最终解决,以及从中总结的经验教训。希望能给准备升级PHP 8.4的朋友一些参考。

背景:为什么要升级

先说说我们为什么要升级PHP 8.4。

我们的线上服务,之前一直用的是PHP 8.3,运行得很稳定。但PHP 8.4带来了一些很有吸引力的新特性,让我们动了升级的念头。

第一个新特性,是属性钩子(Property Hooks)。这个特性允许在类的属性上定义get和set钩子,不用再写一堆getter和setter方法,代码会简洁很多。我们的项目里有大量的实体类,每个类都有一堆getter和setter,如果用属性钩子,能减少很多样板代码。

第二个新特性,是异步对象初始化(Asynchronous Object Initialization)。虽然这个特性还比较前沿,但我们对它很感兴趣,觉得未来可能会用到。

第三个新特性,是性能提升。PHP 8.4在性能上有一些优化,尤其是在数组操作和对象创建方面,官方基准测试显示有5%到10%的性能提升。对于我们这种高并发的服务来说,哪怕5%的提升也很有价值。

第四个原因,是想跟上社区的步伐。PHP的版本更新很快,旧版本的维护周期有限。我们觉得,早点升级到新版本,能更早地适应新特性,也能避免未来被迫紧急升级。

基于这些考虑,我们决定在测试环境充分验证之后,把生产环境升级到PHP 8.4 RC版本。现在回想起来,这个决定太草率了。

升级过程

升级的过程,一开始很顺利。

我们先在测试环境部署了PHP 8.4 RC1,跑了完整的自动化测试,所有测试都通过了。然后我们做了性能压测,结果显示性能确实有提升,响应时间平均下降了约7%。

测试环境跑了一周,没有发现任何问题。于是我们决定,在一个周五的晚上,把生产环境升级到PHP 8.4。

升级的步骤很标准:

  1. 准备PHP 8.4的编译环境,安装必要的扩展
  2. 在一台备用服务器上部署PHP 8.4,配置好php.ini
  3. 把流量切到备用服务器,观察一段时间
  4. 如果没问题,再逐台升级其他服务器

周五晚上十点,我们开始升级。第一步很顺利,备用服务器部署完成,服务启动正常。第二步,把10%的流量切到备用服务器,观察了十分钟,没有发现异常。第三步,把50%的流量切过去,又观察了十分钟,还是正常。

于是我们放心了,把全部流量切到了PHP 8.4的服务器上,然后开始逐台升级其他服务器。

升级完所有服务器,已经是晚上十一点半了。我们看了一下监控,各项指标正常,就放心地下班了。

故障发生

然而,我们没想到的是,问题在第二天早上爆发了。

周六早上八点,我被手机的告警声吵醒了。监控系统显示,线上服务的错误率飙升到了30%,CPU使用率超过了90%,内存使用率也在持续增长。

我一下子就清醒了,赶紧打开电脑查看情况。

日志里全是500错误,错误信息是"Allowed memory size of 256MB bytes exhausted"。也就是说,PHP进程的内存耗尽了。

但奇怪的是,不是所有请求都报错,而是某些特定的请求会触发内存耗尽。而且,这些请求在测试环境里都跑过,没有任何问题。

更糟糕的是,由于内存耗尽,PHP-FPM进程不断被OOM Killer杀掉,然后又被重启,导致CPU使用率飙升。整个服务处于半瘫痪状态。

我赶紧通知了团队成员,大家紧急上线处理。我们做的第一件事,就是把流量切回PHP 8.3的服务器(幸好我们保留了几台没有升级的服务器作为备份)。切回之后,错误率立刻下降了,CPU和内存也恢复了正常。

服务恢复了,但问题还没有找到。我们必须搞清楚,到底是什么导致了PHP 8.4下的内存泄漏。

排查过程

排查的过程,非常曲折。

第一步,我们尝试在测试环境复现问题。但奇怪的是,同样的代码、同样的请求,在测试环境跑了很多次,都没有出现内存泄漏。这让我们很困惑。

后来我们发现,问题只在高并发的情况下才会出现。测试环境的并发量很低,所以复现不了。于是我们在测试环境做了高并发压测,终于复现了内存泄漏。

第二步,我们用各种工具来定位内存泄漏的位置。我们用了memorygetusage()来打印内存使用情况,用了Xdebug来做内存分析,还用了Valgrind来检测C扩展的内存泄漏。

经过几个小时的排查,我们终于定位到了问题:一个第三方扩展,在PHP 8.4下存在内存泄漏。

这个扩展是我们用来做Redis连接池的,名字就不说了。它在PHP 8.3下运行正常,但在PHP 8.4下,由于PHP内部API的变化,导致每次请求结束后,有一块内存没有被正确释放。

在低并发的情况下,这块泄漏的内存很小,而且PHP-FPM会定期重启进程,所以问题不明显。但在高并发的情况下,每个请求都泄漏一点内存,积累起来就很可观了。而且,由于我们配置了PHP-FPM的进程最大请求数比较大(1000次请求才重启),内存泄漏的问题被放大了。

找到问题之后,解决方案就简单了:要么等这个扩展的作者修复,要么暂时不用这个扩展,换一个替代方案。

我们选择了后者。我们把Redis连接池的功能,换成了PHP官方的Redis扩展加上一个简单的连接池实现。虽然性能略有下降,但至少没有内存泄漏了。

修复之后,我们重新在测试环境做了高并发压测,确认没有内存泄漏了,然后在周日晚上重新把生产环境升级到了PHP 8.4。这次,一切正常。

为什么测试环境没发现

事后复盘,我们一直在想一个问题:为什么测试环境没有发现这个问题?

原因有几个。

第一,测试环境的并发量不够。我们的自动化测试和功能测试,都是单请求或者低并发的,内存泄漏的量很小,不会触发内存耗尽。而生产环境的高并发,让内存泄漏的问题被放大了。

第二,测试环境的数据量不够。我们的测试数据,只有几万条,而生产环境有几千万条。某些操作在大数据量下的行为,和小数据量下是不一样的。

第三,测试环境的运行时间不够。我们的测试环境,每次测试完就关机了,不会长时间运行。而内存泄漏是一个累积的过程,需要长时间运行才能显现出来。

第四,我们过于信任自动化测试。我们觉得,自动化测试都通过了,就应该没问题了。但自动化测试只能覆盖功能,很难覆盖性能和内存问题。

这些原因,导致了一个严重的问题:测试环境和生产环境的差异太大,测试环境不能真实反映生产环境的情况。

经验教训

这次故障,给了我们很多教训。

第一,不要在生产环境用RC版本。RC版本虽然功能已经冻结,但还可能有bug,尤其是第三方扩展的兼容性问题。生产环境应该用正式版,而且最好是已经发布了一两个月的正式版,让别人先踩坑。

第二,升级前要做充分的兼容性测试。不仅要测试自己的代码,还要测试所有依赖的第三方扩展和库。很多时候,问题不是出在自己的代码上,而是出在依赖上。

第三,要做高并发和长时间的压测。低并发的测试,很多问题发现不了。要模拟生产环境的并发量和数据量,长时间运行,才能发现内存泄漏、性能退化等问题。

第四,要有完善的回滚方案。升级之前,一定要准备好回滚方案,确保出了问题能快速回滚。我们这次能快速恢复,就是因为保留了几台PHP 8.3的服务器。如果没有备份,后果不堪设想。

第五,灰度发布要更谨慎。我们虽然做了灰度,但灰度的时间太短了,只有二十分钟。内存泄漏这种问题,需要更长时间才能显现。灰度发布至少要观察几个小时,甚至一天,才能确认没有问题。

第六,监控要完善。我们的监控系统,虽然能告警,但告警的阈值设置得太高了,错误率到了10%才告警。如果阈值设置得低一点,比如3%,我们就能更早发现问题,减少影响。

第七,要关注PHP的内部API变化。PHP的小版本升级,有时候会改变内部API,导致第三方扩展不兼容。升级之前,要仔细阅读升级指南,了解有哪些不兼容的变化。

PHP 8.4的其他坑

除了我们遇到的内存泄漏问题,在使用PHP 8.4的过程中,我们还发现了一些其他需要注意的地方。

第一,隐式可空类型的废弃。PHP 8.4废弃了隐式可空类型,也就是如果一个参数的默认值是null,但类型声明没有加问号,会报废弃警告。很多老代码里有这种写法,升级后会产生大量警告。虽然不影响运行,但如果日志级别设置得高,会把日志刷爆。

第二,某些字符串函数的行为变化。PHP 8.4对一些字符串函数做了优化,但也改变了某些边缘情况的行为。比如,str_repeat()在传入负数的时候,现在会抛出ValueError异常,而不是返回空字符串。如果代码里有这种边缘情况,需要注意。

第三,属性钩子的兼容性问题。属性钩子是PHP 8.4的新特性,用起来很方便,但和一些旧的代码模式不兼容。比如,如果一个类用了get()和set()魔术方法,就不能再用属性钩子了。

第四,性能提升不是普遍的。虽然官方说PHP 8.4有性能提升,但在我们的实际测试中,某些场景下性能反而下降了。尤其是在大量使用反射和动态属性的场景下,性能有明显下降。所以,升级前一定要做性能测试,不要盲目相信官方数据。

写在最后

这次PHP 8.4升级故障,是我们团队今年遇到的最严重的一次线上事故。虽然没有造成太大的经济损失,但给我们敲响了警钟。

技术升级是好事,能带来新特性和性能提升,但也伴随着风险。尤其是在生产环境,任何升级都要谨慎再谨慎。测试要充分,灰度要缓慢,回滚要及时,监控要完善。

PHP 8.4是一个不错的版本,新特性很实用,性能也有提升。但它还不够成熟,第三方扩展的兼容性还需要时间来完善。如果你也在考虑升级,建议等正式版发布一两个月之后,再在测试环境充分验证,然后谨慎地灰度到生产环境。

最后,想说一句:技术人要有探索新技术的勇气,但也要有敬畏生产环境的心。在追求新技术的同时,一定要把稳定性放在第一位。

愿每一次升级,都能顺顺利利。