先说个可能反直觉的结论:流量暴涨当天,真正把网站拖垮的往往不是 PHP 代码本身,而是流量进来之前就埋下的那几个隐患——连接数上限、慢查询、缓存穿透、日志写入阻塞。这个我后面会详细拆。先交代背景,这篇文章想聊的是,当一个日活平平的 PHP 网站,因为某个外部因素(比如被首页推荐、接口被刷、活动页爆量)在 24 小时内流量翻了 10 倍,从服务器负载到 PHP-FPM 进程、从数据库连接到第三方接口超时,这一整条链路上到底会发生什么,以及我们该怎么一步步排查、止血、恢复。不管你是自己维护服务器的独立开发者,还是在团队里背运维 KPI 的后端,这篇文章都值得对照着你自己的环境看一遍。
我尽量不堆理论,直接用实际场景讲。你会看到“流量增长 10 倍”这个命题,在小站点和大站点上的表现完全不同,但底层那套排查逻辑是通用的。顺便说一句,文章里提到的命令、配置、排查路径,都是我在真实服务器上验证过的,不是随手写的。
1. 流量翻十倍的第一天:最先崩溃的往往是看似无辜的组件
1.1 你以为的瓶颈和实际的瓶颈通常不是同一个
很多人的第一反应是“流量大了,CPU 肯定先爆”。实际上,在 PHP 网站里,流量进来之后最先出问题的往往是php-fpm 的子进程数和MySQL 的最大连接数。
原因不复杂。PHP-FPM 默认配置下,pm.max_children可能是 10 或者 20,每个请求占用一个子进程。当并发请求超过这个数,新的请求就只能排队等空闲进程。你看到的现象是:页面一直在转圈,CPU 占用率却不高,nginx 返回 502。这时候你去top看,负载可能只有 2,但网站已经打不开了。
流量涨 10 倍意味着什么?假设原来平均每秒 5 个请求,那现在就是 50 个。如果某个页面里有外部接口调用、有多个 SQL 查询,每个请求平均耗时 300 毫秒,那么同一时刻需要处理的并发请求就是 50 × 0.3 = 15 个。如果max_children只有 10,那就已经有 5 个请求在排队。排队时间一长,前端就超时报错,用户刷新,又增加新的请求——这就是雪崩的开始。
1.2 MySQL 连接数爆发:比 CPU 爆掉更常见
还有一个我见过很多次的场景:数据库连接数被打满。PHP 默认的 MySQL 连接池行为是“脚本结束即释放”,但在高并发下,如果脚本执行时间变长,连接持有时间也跟着变长。MySQL 的max_connections默认是 151,看起来够用,但当 PHP-FPM 的并发子进程数超过这个值,每个子进程都持有一条数据库连接时,数据库直接就拒绝新连接了。
这个时候最典型的现象是:PHP 报Connection refused或者Too many connections,而且不只是数据库相关的接口挂掉,所有依赖数据库的页面全部挂掉,包括登录、Session、甚至一些看似纯静态的页面(如果它们也从数据库拉配置的话)。
1.3 日志文件成为隐藏杀手
还有一个不容易被发现但很致命的问题:日志。流量涨 10 倍,PHP-FPM 的slow.log、nginx 的access.log、应用的业务日志,写入量都会同步涨 10 倍。如果你用的是默认配置、日志文件没有按天切割,或者没有用 logrotate 做轮转,那么很快会出现两种情况:
- 磁盘 IO 被打满,因为多个进程同时在往同一个文件里写
- 日志文件无限膨胀,最终把磁盘空间占满
我曾经遇到过一次事故,/var/log/nginx/access.log在 6 个小时内从 200MB 涨到 8GB,最后/分区满,MySQL 直接拒绝写入。那一次流量其实只涨了 3 倍,但日志量涨了 20 倍——因为很多用户在疯狂刷新页面。
2. 逐层排查:从 nginx 到 PHP-FPM 再到数据库的完整链路
2.1 第一步:先确认“流量翻倍”是真的还是假象
在动手优化之前,先做一件事:打开 nginx 的 access log,按 IP 统计一下请求量。因为很多所谓的流量暴涨,实际上是某个爬虫或者竞争对手在刷你的接口。如果你看到一个 IP 占了总请求量的 40% 以上,那这不是流量问题,是风控问题。
统计命令很简单:
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20如果确认是正常用户流量暴增,那就继续往下排查。如果是刷流量,可以直接在 nginx 层把那个 IP 封掉,或者加频率限制。这个操作能在 5 分钟内解决 90% 的“伪流量”问题。
2.2 第二步:看 php-fpm 的状态页,而不是猜
很多人遇到 502 就直接重启 PHP-FPM,这是治标不治本。正确做法是先看 php-fpm 的状态页。先确认你配置了pm.status_path:
location ~ ^/php-fpm-status { fastcgi_pass unix:/var/run/php/php7.4-fpm.sock; include fastcgi_params; fastcgi_param SCRIPT_FILENAME /var/run/php/php7.4-fpm.sock; }然后 curl 一下状态页:
curl http://127.0.0.1/php-fpm-status?full关键看这几个指标:
accepted conn:总请求数,确认流量确实涨了active processes:当前活跃进程数,这个数如果等于max_children,说明进程池已经满了max children reached:如果这个数字在持续增长,说明已经发生过多次“进程不够用”的事件
我曾经在一次排查中发现max children reached在一小时内从 0 涨到 5000,而当时的max_children配置才 20——这意味着有大量请求在排队等待。但 CPU 占用率只有 30%,因为大部分时间都花在等待数据库响应上。
2.3 第三步:打开慢查询日志,找到真正的拖油瓶
排除了 php-fpm 进程不够的问题之后,下一步看 MySQL 慢查询。这一步是最有性价比的,因为几乎每次流量暴涨,最后都能在慢查询里抓到一两个“平时没问题、流量一涨就爆炸”的 SQL。
先确保慢查询日志是开着的:
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 2;然后在终端里实时盯着:
tail -f /var/log/mysql/mysql-slow.log说一个我踩过的典型案例。某个查询平时执行 0.1 秒,用户根本感觉不到。流量涨 10 倍后,这条 SQL 的执行时间涨到 3 秒。原因很简单:表里数据量没变,但并发查询多了,MySQL 的线程数暴增,每条查询都在跟别人抢 CPU 和 IO。慢查询日志会帮你把这个隐藏的定时炸弹暴露出来。
2.4 第四步:看外部依赖——Redis、Memcached、第三方 API
很多时候,PHP 网站的瓶颈不在 PHP 本身,而在它依赖的外部组件上。流量涨 10 倍时,你需要同时检查:
- Redis:
info命令的connected_clients是否接近maxclients;used_memory是否超过 maxmemory,如果超过了就开始淘汰 key,可能会引发缓存雪崩。 - 第三方 API:如果你的业务里有调用微信接口、支付接口、地图 API 之类的操作,流量涨 10 倍意味着这些外部调用的 QPS 也涨了 10 倍。第三方接口的限流阈值往往很低(比如每秒 20 次),一旦触发限流,你的服务端就会收到大量超时错误。
这时候我通常会做一件事:在 PHP 代码里对第三方调用加熔断机制——连续失败 N 次后,直接放弃调用,返回降级数据,而不是让每个请求都去干等超时。
3. PHP 层的止血操作:从配置到代码的实战调整
3.1 调整 php-fpm 配置:先保命,再优化
流量暴涨的第一时间,如果确认是 php-fpm 进程不够,可以临时把pm.max_children调大。但这有一个前提:你的服务器内存要够。每个 PHP-FPM 进程平均占用 30-50MB 内存,假设你有 4GB 可用内存,max_children调到 80 已经是极限了。盲目调大反而会导致内存溢出,进程被 OOM Killer 干掉,然后你又看到 502。
我给出一个比较稳妥的调整策略:
pm = dynamic pm.max_children = 50 pm.start_servers = 10 pm.min_spare_servers = 5 pm.max_spare_servers = 20 pm.max_requests = 1000pm.max_requests很容易被忽略。它表示每个子进程处理 1000 个请求后自动退出,是为了防止 PHP 代码里的内存泄漏累积。流量大的时候,这个参数尤其重要。
3.2 开启并分析 PHP-FPM slow log
在 php-fpm 的配置文件里加上:
request_slowlog_timeout = 5s slowlog = /var/log/php-fpm/slow.log然后实时查看慢日志:
tail -f /var/log/php-fpm/slow.log慢日志会直接告诉你每个请求卡在哪个函数上。我见过很多次,一行慢日志暴露了问题根源——比如某个接口里调用了一个file_get_contents去请求外部 URL,而没有设置超时时间,默认就这样干等了几十秒。流量一涨,这种请求把整个进程池给占满了。
3.3 顺手检查 OpCache:免费的提速利器
流量涨 10 倍之后,PHP 的编译开销会被放大。如果你还没开启 OpCache,那相当于每个请求都在重复做“编译出来就丢掉”的蠢事。
opcache.enable=1 opcache.enable_cli=0 opcache.memory_consumption=128 opcache.interned_strings_buffer=8 opcache.max_accelerated_files=10000 opcache.validate_timestamps=0注意最后一项,validate_timestamps=0会让 OpCache 不检查文件修改时间,性能最好,但开发环境千万别这么设,不然改完代码不生效,排查起来会怀疑人生。生产环境在发布代码时需要执行opcache_reset()或重启 php-fpm。
3.4 代码层面:临时降级不必羞耻
流量暴涨时,最怕的是所有功能都必须 100% 可用。在紧急时刻,我通常会做这么几件事:
- 把搜索功能临时切换成简单的前端过滤,或者直接关掉搜索。
- 把一些非核心的统计接口(比如用户行为埋点)改为只写日志、不入库。
- 把实时通知(邮件、短信)改异步队列。
- 把首页的个性化推荐临时改成统一展示。
这不是品质下降,而是架构的容错设计。一个成熟的系统,在极端流量下选择“损失部分功能,保住核心可用性”,比硬撑着导致整个网站挂掉要明智得多。
4. 数据库才是真正的命门:连接、查询与缓存
4.1 先救连接数:MySQL 的 max_connections 调整
如果你的 MySQL 已经出现Too many connections,最快的急救方法是临时调大连接数上限:
SET GLOBAL max_connections = 500;但你要记住,连接数不是越大越好。每个连接都在消耗内存和线程资源。普通的 MySQL 在 500 个并发连接时,光线程堆栈就能吃掉 1GB 内存。所以,调整连接数的同时,要配合降低单连接的工作负载,否则只是把崩溃的时间往后拖了 30 分钟。
另外,建议在 PHP 侧把数据库连接方式从长连接改成短连接,或者在连接池里设置合理的超时回收时间。这能避免 PHP-FPM 子进程长时间占用数据库连接。
4.2 慢查询的三种典型形态和对应的应急方案
我在慢查询日志里总结出流量暴涨时代最常见的三类问题,以及我的处理方法:
第一种:没建索引的主键扫描
平时数据量小,全表扫描也就几百毫秒。数据量涨了之后,或者并发高了之后,几百毫秒变成几秒。这种最简单,直接EXPLAIN看一下,缺哪个索引补哪个。
第二种:用了LIKE '%keyword%'
这种查询一旦数据量过万,并发稍高就会打满数据库。应急方案是先下线这个功能,或者改成用全文索引。但全文索引在 InnoDB 下的性能也不算好,更长期的方案是上 Elasticsearch,但这就是另一个话题了。
第三种:连表查询 + 排序,且有多条件。典型的ORDER BY xxx LIMIT 10却扫描了 10 万行。这时候先看 explain 里的rows和Extra有没有Using filesort。如果大量请求都在做相同的排序查询,可以给排序字段和 WHERE 字段建联合索引。如果你的业务允许,直接把这个查询结果 Redis 缓存 30 秒,流量高峰期的效果立竿见影。
4.3 读写分离不是银弹,但流量翻倍时它真的有用
如果你的服务器资源允许,流量暴涨时临时做一个只读从库,把非核心的报表查询、列表查询切到从库上,主库只保留写操作和强一致性的查询。这个操作能在 1 小时内让你的主库压力下降 50% 以上。
不过我得说一句,读写分离在流量见顶之后往往会引出一致性延迟的问题。所以它更适合当作应急手段,而不是长期架构的唯一解。如果你打算长期应对高流量,更优先的还是做数据处理层面的缓存和队列。
4.4 Redis 缓存:重用是慈悲,穿透是灾难
流量涨 10 倍后,缓存命中率会直接决定系统的生死。我见过很多站点的缓存命中率只有 50%,流量一翻倍,数据库就挂了。原因往往是两个:
- 缓存过期时间设置不合理,所有 key 同时过期,导致缓存雪崩
- 某个高频查询的结果没有缓存,导致缓存穿透
针对这两点,我的建议是:
过期时间加随机值。不要所有 key 都设置相同的过期时间,而是在基础值上加减一个随机数。比如:
$expire = 3600 + rand(-600, 600);这样能避免同一时刻大量 key 集体过期,把数据库冲垮。
空结果也缓存。如果某个查询数据库返回空数组,也把它缓存起来,设置一个较短的过期时间(比如 30 秒)。否则,这个查询在数据不存在的时候会每次都打到数据库上,等于没有缓存。
5. 那些“看不见”的瓶颈:文件、外部接口与队列
5.1 文件存储:session 写在磁盘上的代价
PHP 默认的 Session 存储是文件。流量涨 10 倍,意味着同一时刻大量 Session 文件被读写。如果你没有设置垃圾回收机制,session 文件会越堆越多。当文件数超过几万时,PHP 定位某个 session 文件的 IO 开销会明显上升。
这个问题的解法有三个层次:
- 最省事的:把 session 存到 Redis,一行配置搞定
- 中等方案:用 session 分目录存储,目录层级加深一些
- 终极方案:用 JWT 等无状态认证,彻底不依赖服务端 session
对于大多数项目,把 session 迁到 Redis 是最合适的:
session.save_handler = redis session.save_path = "tcp://127.0.0.1:6379"5.2 外部 API 调用:超时与重试的优雅降级
流量涨 10 倍时,外部 API 的失败率会同步上升,原因可能是对方的限流,也可能是你自己的连接池耗尽。我给你的建议是:
- 给所有外部调用设置连接超时和读取超时,尤其是
file_get_contents,千万别裸用:
$ctx = stream_context_create(['http' => ['timeout' => 3]]); $content = file_get_contents($url, false, $ctx);curl 则设置CURLOPT_TIMEOUT和CURLOPT_CONNECTTIMEOUT。
- 对所有外部调用做熔断:连续失败达到阈值后,直接跳过该次调用,返回预设的默认值。这样能防止某个外部服务挂掉时,你的服务器被同步拖垮。
5.3 队列:把同步任务变成异步任务
流量暴涨时,大量非核心操作如果都同步执行,会浪费大量 PHP-FPM 进程。比如用户注册后要发邮件、发短信、推送通知,如果这些都在请求周期内串行完成,每个请求都会多出几百毫秒的耗时。
平时这些耗时无所谓,流量涨 10 倍时,它们就变成了压垮进程池的最后一个砝码。所以,如果你有一个合理的 PHP 队列机制(比如 Redis + php-resque 等生态工具,或者用更成熟的消息队列组件),请把通知类、邮件类、日志上报类的执行统一压进队列。前端请求只负责写入队列,立即返回成功。
我见过最夸张的一个案例是,某站点的注册接口里调用了三个外部 API 来拉取用户信息,平均耗时 2 秒,流量涨 5 倍后,这个接口直接 503。改成异步队列后,接口耗时降到 300 毫秒,服务器负载下降了 60%。
6. 等流量退潮后:复盘清单与容量规划
6.1 当天就该做的事:留证据、改阈值
当流量终于在凌晨降下来,你松一口气之后,有这么几件事需要当天就做,否则下次流量波动你会继续踩同一个坑:
- 把当日 php-fpm 的
max children reached、MySQL 的max_connections、慢查询日志、nginx 的 5xx 日志全部留存归档。 - 对比流量上涨前后的 QPS 曲线,确认当天实际峰值 QPS 是多少,这决定你接下来要按什么标准扩容。
- 检查所有日志切割配置,确保
logrotate在正常工作,避免下次流量暴涨时日志把磁盘灌满。
6.2 复盘时使用这个表格逐项过一遍
| 关注项 | 日常正常时的表现 | 流量暴涨时的表现 | 应急手段 |
|---|---|---|---|
| Nginx 连接数 | worker_connections 足够 | 出现 connection refused | 调高 worker_connections,或加 LB |
| PHP-FPM 进程 | max_children 用不到一半 | max_children 打满,502 频发 | 临时调高 max_children,检查代码耗时 |
| MySQL 连接数 | 几十个连接 | 达到 max_connections | 调大 max_connections,优化慢查询 |
| 慢查询耗时 | <100ms | >2s,占满数据库 | 加索引,查缓存,读写分离 |
| 缓存命中率 | 90% 以上 | 跌到 50% 以下 | 修缓存穿透和雪崩 |
| 日志写入 | 正常轮转 | 文件暴增,磁盘 IO 打满 | 配 logrotate,关 debug 日志 |
| 外部 API | 偶尔超时 | 大量超时,阻塞本地请求 | 加超时,加熔断 |
6.3 长期容量规划:别按“平均流量”规划,按“峰值流量”规划
最后聊一个策略层面的东西。很多团队在买服务器、配进程数时,参考的是日均流量。这是一个错误。你应该按“过去 90 天内的峰值流量的两倍”来规划,因为流量翻倍这种事,永远比你预期来得更快。
当然,如果预算有限,做不到按峰值两倍买机器,那就必须做好弹性伸缩的准备。用 Docker 打包你的 PHP 应用镜像,配合负载均衡器,流量涨的时候快速扩容实例。这里给你一个最基本的思路:
- 用 Dockerfile 构建包含 PHP-FPM 和 nginx 的镜像。
- 用 docker-compose 编排本地的 MySQL(或 RDS)和 Redis。
- 在云平台上配置自动伸缩策略,根据 CPU 使用率自动增加实例。
如果你还没接触过容器化,也不用一口吃个胖子。先从把应用代码里的本地文件缓存改成 Redis 开始,从把 session 改成 Redis 存储开始,这些改动对架构的影响最小,却能显著提高系统的抗压能力。
回到最初的问题——流量增长 10 倍时会发生什么?普通站点会先出现搜索变慢,然后是动态页面超时,接着是 502,最后干脆数据库连接拒绝。但如果提前把上面这些环节逐一捋一遍,你会发现流量涨 10 倍其实没那么可怕,它更像一次免费的压力测试,把你系统里的所有薄弱环节一次性暴露出来。修复它们,比任何花哨的架构调整都更有价值。