凌晨两点,心跳脚本先于网站发现了数据库的连接风暴
我给自己那台单机跑的站写过一个很土的心跳脚本:每分钟用 curl 打一次首页,超过三秒就写一行到日志。它原本只用来判断「站挂没挂」,直到有一天它在凌晨两点连续记了四十行超时,而我早上看到日志时的第一反应是「网络抖了一下」。
后来查出来的是数据库侧的问题:一个定时统计脚本每十分钟跑一次,一次跑四分半,每次都开着一个长事务。它本身不慢,但它把连接池里的一部分连接占住了,而 FPM 又有几十个 worker 在等数据库,于是 Threads_running 一路爬到 40 多,首页从 180 毫秒变成 6 秒。整个过程 CPU 使用率没超过 20%,内存空闲,慢查询日志里只有那条统计 SQL,而且它自己「只跑了 4.5 秒」,压根不觉得有问题。
这件事之后我才认真去做容量规划。这篇写的就是我实际用的方法:怎么从「峰值那一刻到底发生了什么」倒推出这台机器该配多少 CPU、多少内存、多少连接数,以及哪些数字是真的约束、哪些只是看着像约束。所有数字都来自一台 2 核 4G 的单机,规模不大,但方法对大机器同样成立。
先明确一件事:容量规划不是算平均值
绝大多数「服务器配置建议」都在算平均值:日 PV 两万,平均每个请求 50 毫秒,每秒 0.23 个请求,一个核能扛几百 QPS,结论是「1 核够用」。这个算法错在两个地方。
第一,流量不是均匀的。日 PV 两万的站,白天每小时 800,晚上八点到十点可能每小时 3000,某篇文章上了首页推荐时能到 8000。容量必须按峰值设计,而不是按均值。
第二,请求的成本不是均匀的。一个首页可能触发 30 次数据库查询和一个模板渲染,一个静态图片几乎不消耗 CPU。按「请求数」算出来的结论没有意义,要按资源消耗算。
所以我的做法是先量出三组数:峰值时段的请求速率、单个请求的资源成本、以及资源里最紧张的那一项是什么。第三项是关键——扩容总是受限于某个特定资源,找到它比加机器更重要。
找约束:CPU、内存、IO、连接,哪个先见底
在峰值时段跑一遍下面这条,看哪个指标最先接近上限:
echo "--- load / cpu"
uptime
echo "--- 内存(看 available 不是 free)"
free -m
echo "--- CPU 各态占比,5 秒一次,共三次"
vmstat 5 3
echo "--- 进程 CPU 前五"
ps -eo pcpu,pmem,args --sort=-pcpu | head -6
echo "--- 磁盘 IO 等待"
iostat -xz 5 2
echo "--- 连接数分布"
ss -ant | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn怎么读这些数,有几条实用的判据:
load 必须除以核数。2 核机器上 load 4 就是满载。但 load 高不等于 CPU 满,vmstat 的 r 列(可运行队列)和 b 列(不可中断睡眠,通常是等 IO)能区分开。我那次事故里 load 到了 6,但 us 只有 15%,b 列常年 2~3,说明不是 CPU 不够,是等数据库。
内存看 available。free 列在 Linux 上几乎永远很小,因为 page cache 会尽量用满。真正该看的是 available 列,它是「还能给新进程分配多少」。available 低于 10% 才算紧张。
CPU 的四个态各自指向不同问题。us 高是应用计算(PHP 渲染、压缩);sy 高是系统调用频繁(大量小文件读写、网络包处理);wa 高是等磁盘;st 高(steal)是虚拟机被宿主机抢了 CPU,这个数字高于 5 就说明你的云主机本身超卖,加配置也没用,该换机房。
连接数要分状态看。大量 TIME-WAIT 是正常的(主动关闭方会保留 60 秒),大量 CLOSE-WAIT 是应用没关连接(这是 bug,不是容量问题),大量 ESTAB 且 QPS 不高是慢连接。这三种情况的处理方法完全不同,混在一起看就会误判成「需要加内存」。
在我那台机器上,这五条测出来最紧张的是连接数这条线:不是 TCP 连接,是 MySQL 的 Threads_running。
从数据库往回算:这才是真正的瓶颈位置
对绝大多数动态站,容量上限由数据库决定,因为 PHP 可以轻松加进程,而数据库的连接数、CPU 都有硬上限。所以倒推的顺序应该是数据库 → PHP-FPM → Web 服务器 → 系统,不是反过来。
第一步,量 MySQL 的真实并发。
SHOW GLOBAL STATUS WHERE Variable_name IN (
'Threads_running','Threads_connected','Max_used_connections',
'Aborted_connects','Queries','Uptime');
SHOW GLOBAL VARIABLES WHERE Variable_name IN (
'max_connections','innodb_buffer_pool_size','sort_buffer_size','join_buffer_size');三个数的区别必须分清:Threads_running 是正在执行的(真并发),Threads_connected 包含空闲连接(睡着了但也占着一个线程槽),Max_used_connections 是历史峰值——这个才是定容量的关键。如果 Max_used_connections 是 23,那 max_connections = 30 就够了,配 500 只是给自己一个虚假的安全感。
为什么是虚假的安全感?因为每个连接都是有成本的。sort_buffer_size、join_buffer_size、read_buffer_size 这些是每连接分配的(在需要时按需分配,但峰值时同时生效)。如果 sort_buffer_size = 4M、max_connections = 500,理论上最坏情况是 2GB 内存被排序缓冲区吃掉,而服务器总共只有 4G。把 max_connections 调大,有时不是解决连接不够,而是制造一个内存耗尽的新问题。
第二步,用真实并发反推应用侧的池大小。PHP-FPM 的每个 worker 在需要数据库时会占用一个连接。pm.max_children = 40、每个 worker 最多一个连接,那最坏情况是 40 个连接;但如果代码里用了 pconnect(持久连接),worker 之间的连接不能复用,实际占用会更高。
我的配置最后定成这样:
pm = dynamic
pm.max_children = 25
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 8
pm.max_requests = 500
request_terminate_timeout = 45s
request_slowlog_timeout = 2smax_children = 25 是怎么来的?不是拍脑袋,是按内存算的:一个 PHP worker 的常驻内存 PSS 约 35MB(这个是实测值,不是 RSS,RSS 会把共享的 OPcache 重复计算),25 × 35MB ≈ 875MB,加上 MySQL 的 buffer pool 1G、系统 600M,4G 内存还剩一半余量。这是内存约束下的上限。
为什么不是 40?因为 25 个 worker 已经足够处理峰值并发,再多的 worker 只会加深对数据库的压力,而不是提高吞吐。当 max_children 被打满时,正确的做法是查「为什么每个请求这么慢」,而不是调大数字。
第三步,request_terminate_timeout 让容量可计算。这个参数特别重要,因为它决定了一个 worker 最多被占用多久。如果它不设(默认为 0,不限),那么一个卡在慢查询上的请求可以占用一个 worker 十分钟,25 个 worker 被打满只需要 25 个这样的请求。
设成 45 秒之后,一个 worker 的最坏处理能力就是「45 秒一个请求」,理论最低吞吐是 25 / 45 ≈ 0.55 QPS。这个数字听起来很低,但它给了你一个可以计算的底线:如果你知道峰值是 8 QPS,那至少需要 8 × 平均响应时间(秒)× 安全系数 的 worker 数。
这里必须提醒一个容易搞错的点:request_terminate_timeout 管的是「整个请求的墙钟时间」,而 PHP 的 max_execution_time 在 Linux 上只计算 CPU 时间,不包括 sleep() 和数据库阻塞等待的时间。所以一个卡在 SELECT SLEEP(100) 上的请求,max_execution_time = 30 是拦不住的,只有 request_terminate_timeout 能杀。这两个都要设,而且 request_terminate_timeout 应该小于 Nginx 的 fastcgi_read_timeout,让 PHP 先自己死掉,Nginx 收到的是一个干净的 502,而不是超时错误。
内存账:把每个大块都摊开算一遍
4G 的机器上,实际能用的内存账大致是这样(数字来自我自己的机器,实测):
MySQL buffer pool 1024 MB
MySQL 其他(每连接缓冲、
undo、临时表、字典) 400 MB
PHP-FPM 25 × 35MB 875 MB
Nginx worker + 缓冲 80 MB
系统(sshd/cron/journal) 250 MB
page cache(可回收) ———
合计 约 2.6 GB / 4 GB剩下 1.4G 是余量,用来吸收突发和 page cache。这个「余量」不是浪费,它是系统在遇到临时峰值时不 OOM 的唯一依靠。如果你把账算到 3.9G,那么任何一次稍微大一点的查询都会触发 OOM killer,而 OOM killer 通常会挑内存最大的进程下手——往往是 MySQL。
这里有两个真正常见的误算:
误算一,用 RSS 相加。30 个 PHP worker 每个 RSS 90MB,加起来 2.7G,看起来 4G 内存根本不够。但这些进程共享着 OPcache 的共享内存段(可能几百 MB),RSS 会把它重复计算 30 次。正确的度量是 PSS(通过 /proc/PID/smaps_rollup 看 Pss:),它把共享内存按进程数均摊。我第一次对比的时候,30 个 worker RSS 加起来 2.7G,PSS 加起来只有 960MB,差了将近三倍。
误算二,把 buffer pool 当成「最多用到这么多」。innodb_buffer_pool_size 是启动时预留的,不是按需增长。设 2G 就是一开始就占 2G(现代版本有惰性分配,但最终会占满)。所以在 4G 的机器上把 buffer pool 设成 3G,等于给系统只留 1G 给其他所有东西,这是很多「MySQL 总是被 OOM」的根本原因。
经验值:单机数据库专用机可以给到物理内存的 60~70%;和应用同机时给 25~30%,剩下的留给 PHP 和系统。我 1G 的 buffer pool 在数据量 2.3G 的库上,命中率是 99.1%,够用。
CPU 账:什么时候才真的需要加核
CPU 通常是最后才见底的资源,但一旦见底就没办法用配置解决。判断是否需要加核,我只看两个信号:
信号一,us + sy 在峰值时段持续高于 75%,且 wa 和 st 都低。这是真 CPU 瓶颈。注意要「持续」,偶发的一两秒尖峰不影响。
信号二,可运行队列 r 列持续大于核数。2 核机器上 r 长期是 4~6,说明有进程在排队等 CPU。
如果这两个都满足,先别急着加核,先找热点的来源。三种最常见的 CPU 消耗,处理方式完全不同:
一是正则表达式回溯。这个在 PHP 里特别常见,一个看起来无害的 preg_match 匹配长文本时能吃掉一个核。用 perf top 或者慢请求的调用栈能看到 pcre_exec 占大头。
二是图片处理。GD 或 Imagick 生成缩略图时 CPU 会瞬间打满。这类任务应该移到请求之外:上传时先返回,异步生成缩略图;或者干脆在 Nginx/CDN 层做实时缩放。
三是加密/压缩。HTTPS 握手(非 keepalive 场景下每个新连接一次)和动态 gzip 都是 CPU 密集的。启用 HTTP/2、延长 keepalive_timeout、把静态资源预压缩成 .gz(gzip_static on),能省掉相当一部分。
我那次事故的 CPU 并不紧张,所以没有加核,只是把统计脚本改成了分批跑(每批 2000 行,跑完 sleep 0.5),把 4.5 秒的长事务拆成了 20 个短事务。改完之后峰值时段的 Threads_running 从 40 降到 7,首页回到 200 毫秒以内。
带宽与连接数:小站最容易忽略的两个上限
带宽。按峰值时段算:峰值每秒 8 个请求,平均每个响应 120KB(含图片和 CSS),峰值带宽需求约 1 MB/s ≈ 8Mbps。看起来很小,但注意图片:一个详情页如果有 10 张未优化的图片,总大小可能到 3MB,那么 8 个请求就是 24MB/s,接近 200Mbps。这就是为什么「图片压缩 + CDN」对成本的影响比换服务器大得多。
Nginx 的连接数上限。worker_connections 决定每个 worker 能同时处理多少连接,但一个代理场景下的请求会占两个连接(客户端 + 上游)。所以 512 的 worker_connections 实际只能服务约 256 个并发客户端。
更隐蔽的是 worker_rlimit_nofile:如果它小于 worker_connections × 2,那么连接还没到上限就先撞到文件描述符限制,日志里会出现 worker_connections are not enough 或者干脆静默的 Too many open files。而 systemd 服务的 LimitNOFILE 又会在更底层限制住它,这三层是「取最小值」生效的。
# 三层都要看
grep -E 'LimitNOFILE' /etc/systemd/system/nginx.service.d/*.conf
grep worker_rlimit_nofile /etc/nginx/nginx.conf
cat /proc/$(pgrep -o nginx)/limits | grep 'open files'压测:不要把压测当成「证明能扛」,要当成「找出拐点」
容量规划里最容易做错的一步是压测的目标。很多人做压测是为了得到一个「能扛多少 QPS」的数字,然后写进文档。但实际上压测真正有价值的是找到拐点:在哪个并发数上,响应时间的增长开始不再是线性的。
# 分别用 10 / 25 / 50 / 100 并发跑,看 p99 怎么变
ab -n 3000 -c 25 -k https://example.com/
# 或者用 wrk,更接近真实浏览器
wrk -t2 -c50 -d30s --latency https://example.com/读结果的方式:如果 10 并发时 p99 是 200ms、25 并发时 350ms、50 并发时 1200ms,那拐点在 25 到 50 之间,实际容量应该定在 25 附近并留 30% 余量。如果 100 并发时 p99 突然跳到 10 秒,那说明有队列在积压,去查 FPM 的 listen queue 和 MySQL 的 Threads_running。
压测时有三条纪律:必须在目标机同时采集 vmstat、ss、SHOW PROCESSLIST,否则看到拐点也不知道拐点在哪一层;压测机本身不能成为瓶颈(先确认压测机的 CPU 和端口范围够);不要在生产环境的公开域名上压测,CDN 会把你当成攻击,而且你测的是 CDN 不是源站(用 --resolve 或直连 IP)。
留白:容量规划的终点是「知道什么情况下会坏」
做完这一轮之后,我得到的不只是几个配置数字,而是一张「什么情况下会坏」的表:
峰值 QPS 超过 12,FPM 的 max_children 会打满,表现为排队超时;Threads_running 持续超过 15,首页响应会到 1 秒以上;available 内存低于 400MB,OOM killer 可能杀掉 MySQL;单张图超过 500KB 且并发超过 20,带宽会打满。
这四条里,只有第三条是「加内存」能解决的,其余三条都要靠改代码、压缩图片、加缓存解决。这就是容量规划的实际价值:它把「服务器够不够」这个模糊的问题变成了四个具体的预警线,每条预警线后面都对应一个动作。
给单机站的两个务实建议:一是永远按峰值的 1.5 倍配置,而不是按均值;二是有余量但别用满——4G 的机器配 2 核是常见的失衡(内存够 CPU 不够),而 2G 2 核反而是均衡的。真正的浪费不是配置买大了,是为了省几十块钱把自己逼到每条线都在边缘上,然后每周花几个小时排查「为什么又慢了」。