上周三晚上,我经历了入职以来最难忘的一次线上故障排查。
事情是这样的:我们团队最近把线上的PHP版本从8.2升级到了8.4,升级之后一切正常,跑了一周都没什么问题。结果周三晚上八点多,运维突然在群里说,有几台服务器的PHP-FPM进程CPU占用率飙升到了100%,服务响应变慢,部分用户请求超时。
我本来准备下班了,看到消息之后立刻打开电脑,开始排查。这一查,就是一整夜。这篇文章,我想分享一下这次排查的完整过程,以及从中总结的经验。
问题初现
晚上八点十五分,监控系统发出告警:API接口的平均响应时间从正常的200ms飙升到了2秒,错误率也开始上升。
运维同学登录服务器查看,发现PHP-FPM的worker进程CPU占用率很高,几个进程都跑到了100%。重启PHP-FPM之后,暂时恢复正常,但过了十几分钟,又有进程CPU飙升。
我当时的第一反应是:是不是有死循环?因为CPU 100%通常意味着进程在不停地计算,而不是在等待IO。但我们的代码最近没有大的改动,而且升级PHP 8.4已经一周了,为什么现在才出问题?
我先做了几件事:
第一,查看错误日志。PHP的error_log和FPM的slow log,看看有没有报错或者慢请求。
第二,查看访问日志。看看是哪些URL的请求变慢了,是不是有特定的接口触发了问题。
第三,用strace跟踪CPU高的进程,看看它在做什么系统调用。
第四,查看服务器的资源使用情况,CPU、内存、磁盘IO、网络,排除是其他原因导致的。
初步排查
错误日志里没有明显的Fatal Error,但有一些Warning,都是之前就有的,不是新问题。slow log里记录了一些慢请求,但都是正常的复杂查询,不是死循环。
访问日志显示,出问题的请求比较分散,不是某个特定的接口。各种URL都有,这说明不是某个具体业务逻辑的问题,而是更底层的问题。
strace的结果让我有点意外。CPU高的进程,大部分时间在做内存相关的系统调用,比如mmap、munmap、brk。这说明进程在频繁地分配和释放内存。
这时候我开始怀疑:是不是PHP 8.4的内存管理有什么变化,导致了内存分配的问题?
我又做了一个实验:在测试环境用PHP 8.4跑同样的代码,用压力测试工具模拟并发,看看能不能复现。结果,测试环境跑了半个小时,一切正常,CPU没有飙升。
这就奇怪了。线上能复现,测试环境复现不了,说明问题和线上的特定条件有关,比如数据量、并发量、或者某个特定的请求组合。
深入排查
这时候已经晚上十点了,问题还在间歇性地出现。我决定换个思路。
我用gdb attach到一个CPU高的PHP-FPM进程上,查看它的调用栈。gdb是Linux下的调试工具,可以查看进程当前在执行什么函数。
attach上去之后,用bt命令查看调用栈,发现进程卡在了PHP的内存分配函数里,具体是zendmmalloc_heap。这个函数是PHP Zend引擎的内存分配器,用来分配堆内存。
调用栈显示,这个函数是在处理一个数组操作的时候被调用的,而且是在一个循环里。这说明,有一段代码在循环里反复分配大量内存,导致内存分配器成为瓶颈。
但问题是,这段代码在PHP 8.2上跑得好好的,为什么到了8.4就出问题了?
我开始查PHP 8.4的更新日志,看看内存管理方面有什么变化。查了之后发现,PHP 8.4对内存分配器做了一些优化,引入了新的内存池机制。这个优化在大多数情况下能提升性能,但在某些特定的内存分配模式下,可能会导致性能退化。
具体来说,PHP 8.4的内存分配器在处理大量小对象的频繁分配和释放时,可能会出现内存碎片,导致分配速度变慢。而我们的代码里,恰好有一个地方在循环里创建大量的小对象。
定位根因
找到了方向之后,我开始在代码里找哪里有大量小对象的频繁分配。
根据gdb的调用栈,问题出在一个数据处理的函数里。这个函数的作用是把数据库查询出来的大量数据,转换成特定格式的数组,然后返回给前端。
代码大概是这样的:
function formatData(array $rows): array {
$result = [];
foreach ($rows as $row) {
$item = new DataItem();
$item->id = $row['id'];
$item->name = $row['name'];
// ... 还有很多字段
$result[] = $item;
}
return $result;
}这个函数在数据量大的时候,会创建几千甚至上万个DataItem对象。在PHP 8.2上,这没什么问题,因为PHP 8.2的内存分配器能很好地处理这种模式。但在PHP 8.4上,新的内存池机制在这种大量小对象的场景下,出现了性能退化。
但为什么升级一周之后才出问题呢?因为这个函数只有在处理大量数据的时候才会被调用,而大量数据的请求不是每天都有。周三晚上,刚好有一个定时任务触发了大量数据的处理,同时又有用户的请求调用了这个函数,两者叠加,就触发了问题。
为了验证这个判断,我在测试环境构造了一个大数据量的请求,同时用压力测试模拟并发,果然复现了CPU飙升的问题。用gdb查看,调用栈和线上一模一样。
根因找到了:PHP 8.4的内存分配器在大量小对象频繁分配的场景下有性能退化,我们的代码恰好触发了这个问题。
修复方案
找到根因之后,就开始想修复方案。有几个选择:
第一个方案是,回滚到PHP 8.2。这是最稳妥的,但我们升级到8.4是为了新特性和性能提升,回滚太可惜了,而且只是临时方案。
第二个方案是,修改代码,避免大量小对象的创建。比如,不用对象,改用数组;或者用对象池,复用对象,减少创建和销毁的次数。
第三个方案是,调整PHP 8.4的内存分配器参数,看看能不能缓解这个问题。
我先试了第三个方案,查了PHP 8.4的配置项,发现有一个zendmmheap_size参数可以调整内存池的大小。把这个值调大之后,测试环境的问题有所缓解,但没有完全解决。
然后我试了第二个方案,把代码里的对象改成了数组。修改之后,测试环境的CPU飙升问题完全消失了,响应时间也恢复了正常。
但改代码涉及的地方比较多,这个函数在好几个地方都有调用,而且对象改数组之后,其他地方的代码也要跟着改。当晚全部改完风险太大。
所以,我决定采取组合方案:当晚先回滚有问题的那几台服务器到PHP 8.2,保证服务稳定;然后第二天白天修改代码,把大量小对象的创建优化掉,测试通过之后再重新升级到8.4。
连夜回滚
决定了方案之后,已经是凌晨两点了。我和运维同学一起,把出问题的几台服务器的PHP版本回滚到了8.2。回滚之后,CPU立刻降了下来,服务恢复正常。
回滚之后,我没有立刻睡觉,而是又观察了一个小时,确认服务稳定,没有再出现问题。然后写了一份详细的故障报告,包括问题现象、排查过程、根因分析、临时解决方案、长期修复计划。
等这一切做完,已经是凌晨四点了。我在公司的沙发上眯了两个小时,早上六点多起来,继续处理后续的修复工作。
长期修复
第二天白天,我开始优化代码。主要做了几件事:
第一,把数据处理函数里的对象改成了数组。对于这种纯数据载体,用数组比用对象更轻量,内存分配更少,性能也更好。
第二,对于确实需要用对象的地方,引入了对象池。对象预先创建好,循环里复用,用完之后重置属性,而不是每次都new一个新对象。这样大大减少了对象创建和销毁的次数。
第三,优化了循环里的数组操作。避免在循环里反复扩容数组,预先分配好数组的大小。
第四,加了监控。对这个数据处理函数的执行时间和内存使用做了监控,如果出现异常,立刻告警。
改完之后,在测试环境做了充分的压力测试,确认没有问题。然后灰度发布到线上,先切一小部分流量,观察了一天,没有问题,再全量发布。
全量发布之后,我们重新把PHP版本升级到了8.4,这次没有再出现问题。而且因为代码做了优化,整体性能比升级之前还好了一些。
经验总结
这次通宵排查,让我总结了一些经验。
第一,线上问题排查要有系统的方法。不要一上来就瞎猜,要先收集信息:日志、监控、调用栈、系统状态。信息收集得越全面,定位问题就越快。
第二,善用调试工具。strace、gdb、tcpdump这些工具,在排查线上问题的时候非常有用。尤其是gdb,能直接看到进程卡在哪个函数里,对于定位性能问题和死锁非常有效。
第三,版本升级要谨慎。大版本升级之前,要做充分的测试,包括压力测试、边界测试、异常场景测试。不要只测正常流程,还要测极端情况,比如大数据量、高并发、异常输入。
第四,关注底层变化。升级语言版本或者框架版本的时候,要仔细看更新日志,尤其是内存管理、垃圾回收、并发模型这些底层的变化。这些变化在大多数情况下是优化,但在某些特定场景下可能会导致性能退化。
第五,代码要考虑性能。虽然现在的硬件越来越强,语言的性能也越来越好,但写代码的时候还是要考虑性能。尤其是在循环里、在大数据量的场景下,一个小的性能问题,放大之后就可能变成线上故障。
第六,要有回滚预案。任何变更,都要有回滚方案。出了问题,能快速回滚,把影响降到最低。回滚不是失败,而是保障服务稳定的重要手段。
第七,故障复盘很重要。每次线上故障之后,都要做详细的复盘,分析根因,总结经验,制定改进措施。避免同样的问题再次发生。
关于PHP 8.4/8.5
最后说说PHP 8.4和即将到来的PHP 8.5。
PHP 8.4是2024年底发布的版本,带来了很多新特性,比如属性钩子、新的数组函数、性能优化等。整体来说,8.4是一个很好的版本,性能比8.2有提升,新特性也很实用。
但正如我们这次遇到的,大版本升级可能会有一些边缘场景的问题。这不是PHP独有的,任何语言、任何框架的大版本升级都可能有。关键是要做好测试和监控,发现问题及时修复。
PHP 8.5预计会在2025年底发布,按照PHP的发布周期,8.5应该是一个创新版本(因为8.4是LTS)。预计会带来更多的新特性和性能优化。对于8.5,我的建议是:关注但不急着升级,等稳定了、测试充分了再上。
对于PHP的版本升级,我的建议是:
- 升级前仔细阅读更新日志,了解所有变化。
- 在测试环境做充分的测试,包括单元测试、集成测试、压力测试。
- 灰度发布,先切一小部分流量,观察没有问题再全量。
- 升级后加强监控,尤其是性能和错误率。
- 保留回滚方案,出问题能快速回退。
写在最后
那次通宵排查,虽然很累,但收获很大。不仅解决了问题,还对PHP的底层内存管理有了更深入的理解,也总结了一套线上问题排查的方法论。
做后端开发,线上故障是不可避免的。重要的不是不出问题,而是出了问题之后能快速定位、快速修复,并且从中学到东西,避免再犯。
希望我的这次排查经历,能给大家一些参考。如果你也遇到过类似的线上问题,或者有更好的排查方法,欢迎在评论区交流。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录