别把性能优化当成玄学。我见过太多人一上来就调参数、改配置,结果瓶颈没找着,系统反而被搞得更慢。做性能优化这么多年,我最深的体会是:性能优化不是靠感觉,而是靠测量和数据驱动的决策。这篇文章,我想用一台真实服务器的压测经历,完整复盘一次从规划、执行到定位、优化的实战过程。
这次的主角是一台用于在线考试系统的应用服务器(8核16G,CentOS 7,Nginx + PHP-FPM + MySQL部署在同一台机器,后续会做读写分离和负载均衡)。我们面临的问题是:大促期间几千人同时在线答题,系统响应变慢,甚至出现了超时。目标很明确——找出瓶颈,并验证优化方案是否有效。适合所有正在做Web应用压测、性能调优的开发者、运维同学,也适合想系统学习压测思路的测试工程师参考。
先说结论:通过压测定位到PHP-FPM进程数配置不合理、数据库连接数打满、以及慢查询堆积三个核心问题,经过针对性调优,系统吞吐量(TPS)从优化前的132提升到优化后的381,p95响应时间从2.8s降低到0.6s,效果非常显著。
一般我会建议把应用服务器和数据库分开部署,但前期资源有限,压测正好可以验证单体架构的极限在哪里。
1. 压测前的完整规划
1.1 明确压测目标和关键指标
压测不是随便拿个工具打到服务器冒烟就完了。第一步永远是定义清楚"测什么"和"怎么衡量"。抛开目标谈压测,就是耍流氓。
对于这个在线考试系统,目标拆成两层:
- 估算系统当前能支撑的最大并发在线答题人数;
- 找到在达到上限时,最先"撑不住"的环节。
衡量指标必须可量化,我这次锁定了四个核心指标:
| 指标 | 含义 | 参考阈值 |
|---|---|---|
| TPS | 每秒处理的事务/请求数 | 越高越好,但需结合硬件资源 |
| 响应时间(RT) | 请求发出到收到响应的时间 | p99 < 1s,p95 < 0.8s 较合理 |
| 错误率 | 请求失败比例 | 理想状态 < 0.1%,最大容忍 < 1% |
| 资源利用率 | CPU、内存、磁盘IO、网络 | CPU < 70%,内存不持续上涨 |
为什么重点关注p95和p99?平均响应时间掩盖了长尾问题。比如某个慢查询导致2%的请求需要8秒,其余98%只需要200ms,平均值看起来很好,但真实用户体验极差。p95/p99是压测报告里最能反映用户体感的指标。
1.2 搭建压测环境与准备测试数据
压测环境尽量和线上保持一致,但通常会使用独立的测试服务器或容器,避免影响线上业务。我们这次在测试环境申请了一台和线上配置一样的机器,部署相同的代码版本和数据库结构。
测试数据准备是很多人忽略的坑。千万不要只压测空库,否则SQL执行计划会和线上完全不一样。我给数据库插入了10万条用户数据、5万条考试记录、3万条答题明细,并且尽量模拟线上数据的分布特征——比如某个热门考试的答题记录量是其他考试的几十倍,这样索引选择和表连接方式才接近真实场景。
压测工具选型上,我选择了开源的k6(也可以用Apache JMeter,看个人习惯)。k6支持脚本化、可编程,生成的压测报告直观,而且对资源占用比JMeter小。压测机使用一台独立的4核8G机器,避免压测工具自身成为瓶颈。
1.3 场景设计:模拟真实用户行为
压测场景必须贴合业务。单纯打首页接口没有意义,考试系统的核心链路是:登录 → 获取考试列表 → 开始考试(生成试卷)→ 提交答案 → 查看成绩。
我按用户行为比例设计了一个混合场景:
| 操作 | 占比 | 说明 |
|---|---|---|
| 登录接口 | 10% | 获取token,是后续链路的基础 |
| 获取考试列表 | 20% | 大部分用户进入系统后的首次操作 |
| 开始考试(生成试卷) | 30% | 核心高负载接口,涉及大量读写 |
| 提交答案 | 30% | 高频写入,数据库压力最大 |
| 查看成绩 | 10% | 查询历史数据 |
压测过程分为三个阶段:
| 阶段 | 并发数 | 持续时长 | 说明 |
|---|---|---|---|
| 预热 | 50 | 5分钟 | 让框架缓存、数据库连接池、JIT编译充分"热"起来 |
| 拉锯 | 逐级增加(100/200/300/500) | 每级10分钟 | 观察各项指标随并发的变化趋势 |
| 持续性 | 300 | 30分钟 | 验证系统长时间运行是否有内存泄漏等问题 |
这里要特别强调预热阶段的必要性。如果你一上来就高并发压测,PHP的OPcache还没完全生成、MySQL的Buffer Pool还没装满热数据,测出来的结果会比真实情况差很多,这会误导优化方向。
2. 压测执行与瓶颈定位实战
2.1 压测工具脚本的编写与执行
我用k6编写了一个模拟核心链路混合场景的压测脚本。以下是一个简化后的脚本示例(完整逻辑还包括断言、阈值设置和参数化随机用户):
import http from 'k6/http'; import { check, sleep } from 'k6'; import { randomItem } from 'https://jslib.k6.io/k6-utils/1.2.2/index.js'; // 从文件读取用户列表,模拟不同用户登录 const users = JSON.parse(open('./users.json')); export const options = { scenarios: { // 核心链路混合场景 core_business: { executor: 'ramping-vus', stages: [ { duration: '5m', target: 50 }, // 预热 { duration: '10m', target: 100 }, // 阶梯加压 { duration: '10m', target: 200 }, { duration: '10m', target: 300 }, { duration: '10m', target: 500 }, { duration: '30m', target: 300 }, // 持续压测 ], gracefulRampDown: '30s', }, }, // 设置性能阈值,超过则压测失败标记 thresholds: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<800', 'p(99)<1200'], }, }; // 模拟真实用户随机选择操作路径 const actions = [ { path: 'login', weight: 0.1 }, { path: 'exam_list', weight: 0.2 }, { path: 'start_exam', weight: 0.3 }, { path: 'submit_answer', weight: 0.3 }, { path: 'view_score', weight: 0.1 }, ]; function callLogin() { const user = randomItem(users); const res = http.post('http://test-api.example.com/api/login', JSON.stringify({ username: user.username, password: user.password }), { headers: { 'Content-Type': 'application/json' } }); check(res, { 'login status is 200': (r) => r.status === 200 }); return res.json() && res.json().token; } // 其余接口函数类似,这里只展示整体框架思路 function runAction(path, token) { const headers = { 'Authorization': `Bearer ${token}` }; const examId = randomItem([101, 102, 103, 104, 105]); switch (path) { case 'login': callLogin(); break; case 'exam_list': http.get('http://test-api.example.com/api/exams', { headers }); break; case 'start_exam': http.get(`http://test-api.example.com/api/exams/${examId}/start`, { headers }); break; case 'submit_answer': http.post(`http://test-api.example.com/api/exams/${examId}/submit`, JSON.stringify({ answer: '{"q1":"A","q2":"B"}' }), { headers: { 'Content-Type': 'application/json' } }); break; case 'view_score': http.get(`http://test-api.example.com/api/exams/${examId}/score`, { headers }); break; } } export default function () { // 先登录获取token,然后随机执行一个业务操作 const token = callLogin(); const action = randomItem(actions).path; runAction(action, token); sleep(1); // 模拟操作间隔 }运行命令很简单:
k6 run --out json=performance_test.json script.js压测完成后除了终端输出的汇总报告,我还用Grafana + Prometheus监控服务器CPU、内存、磁盘IO、网络和MySQL关键状态,把应用层指标和操作系统层指标一一对应起来看。
2.2 从瓶颈现象到根因定位
第一轮压测跑到并发300时,系统开始出现大量超时,错误率飙到3.7%。看汇总数据:
| 并发 | TPS | p95响应时间 | 错误率 |
|---|---|---|---|
| 50 | 185 | 0.8s | 0% |
| 100 | 172 | 1.2s | 0% |
| 200 | 156 | 2.1s | 0.4% |
| 300 | 132 | 2.8s | 3.7% |
| 500 | 89 | 5.2s | 12% |
很典型的吞吐量随并发先升后降曲线,到300以后TPS不升反降,说明系统已经出现拥塞。
结合监控面板逐层排查:
第一层:看CPU和内存。CPU总体使用率只有55%,但每个PHP-FPM进程的CPU时间片分布不均——部分进程CPU占用率达90%以上,另外一些进程却几乎空闲。内存没有异常增长。这初步排除纯硬件资源不足的问题。
第二层:看Nginx日志和PHP-FPM状态。通过nginx -s reload后查看access.log,大量请求的处理时间超过2秒,集中在/api/exams/{id}/start这个接口。接着用php-fpm的pm.status_path查看进程状态,发现active processes已经达到pm.max_children的上限200,而且idle processes接近0,说明PHP-FPM进程池被打满,请求在排队等待空闲进程。
第三层:看MySQL慢查询日志。开启slow_query_log后,发现大量慢查询集中在两张表——exam_paper(试卷表)和answer_record(答题记录表)。单次查询时间最长的达到9秒。典型的慢查询语句是:
SELECT * FROM paper_questions WHERE paper_id = 12345 AND status = 1 ORDER BY sort_order ASC;这条SQL的问题很明显:paper_questions表数据量超过百万,paper_id和status虽然都有单列索引,但MySQL优化器实际选择的是paper_id索引,然后回表过滤status,并且对结果做了文件排序(Using filesort)。
第四层:检查数据库连接数。使用SHOW STATUS LIKE 'Threads_connected';发现连接数已经超过300,而MySQL的max_connections设置为500,结合PHP-FPM有200个子进程峰值同时连接数据库,连接数接近打满,数据库开始拒绝新连接请求。
到这里,瓶颈链路清晰了:高并发请求 → PHP-FPM进程全部占用 → 每个进程都去MySQL建立连接 → MySQL连接数告急 → 请求排队时间变长 → 前端等待超时 → 产生大量重试 → 进一步加剧资源竞争。这是一个典型的雪崩连锁反应,切割成三个独立问题:
- PHP-FPM的
pm.max_children配置偏大(200),但服务器只有8核,进程切换成本远大于计算收益; paper_questions表缺少有效复合索引,慢查询拖垮数据库;- 数据库连接数规划不足,且PHP-FPM没有启用连接复用(持久连接没开)。
3. 针对性优化与二次压测验证
3.1 优化PHP-FPM进程管理策略
第一处改动:把pm.max_children从200调整到64。这个数字是怎么来的?取决于每个PHP-FPM进程平均占用内存,我实测每个进程大约消耗120MB,8G内存减去操作系统和MySQL占用,留给PHP-FPM的安全内存范围是5G左右,5000MB / 120MB ≈ 41,再留出20%的余量,最终定在64。
进程数不是越大越好。每个PHP-FPM进程都会占用内存,且进程间切换需要消耗CPU。进程数超过CPU核心数的4-5倍后,收益微乎其微,反而会增加上下文切换开销。
第二处改动:将进程管理模式从dynamic调整为ondemand。在dynamic模式下,即使空闲进程也会保留,占用内存;改成ondemand后,只有请求来了才启动子进程,空闲时间超过pm.process_idle_timeout(设置10s)就杀掉,更适合我们这种有明显波峰波谷的考试场景。
修改后的关键配置:
pm = ondemand pm.max_children = 64 pm.process_idle_timeout = 10s pm.max_requests = 5000pm.max_requests = 5000是防止PHP进程内内存碎片累积导致的缓慢内存泄漏,让每个进程处理5000个请求后自动回收重启。
3.2 优化SQL查询与索引设计
针对那条拖垮数据库的慢查询,我添加了复合索引:
ALTER TABLE paper_questions ADD INDEX idx_paper_status_sort (paper_id, status, sort_order);这个索引背后的原理是覆盖索引。查询条件是paper_id和status,排序字段是sort_order,这三个字段刚好组成一个复合索引,MySQL可以直接通过索引B+树完成查询和排序,避免回表和文件排序。
紧接着把原来的单列索引idx_paper_id和idx_status保留吗?不需要了,因为复合索引最左前缀原则可以覆盖paper_id单条件查询的场景。删除冗余索引可以减少写入开销,但删除前务必确认没有其他SQL单独依赖这些索引。我在测试环境执行了EXPLAIN验证执行计划:type=ref, key=idx_paper_status_sort, rows从12万降到152,Extra中不再有Using filesort。
3.3 优化数据库连接管理
方案分两步:一方面把MySQL的max_connections从500提升到800,做好清理工作的兜底;另一方面,也是最关键的,在PHP-FPM中开启MySQL持久连接。在PHP的mysqli或PDO中,将连接参数设置为pconnect=true,或者使用mysqli_pconnect。这样每个PHP-FPM进程可以复用同一个数据库连接,极大地减少重复建连的开销。
同时检查应用代码,确认提交答卷接口是否自动开启了事务。发现问题:提交答卷逻辑中,先更新答题记录,再插入成绩单,最后更新考试状态,中间还有一次远程调用(发邮件通知),这个远程调用被包裹在事务里,导致事务持有数据库锁的时间长达数百毫秒。修改方案:远程调用移到事务之外,并且所有涉及考试的查询强制走主库,避免主从延迟导致的读写不一致(压测期间从库及时同步也跟不上,先保证一致性再说)。
3.4 二次压测结果对比
优化完成后等待一段时间(让慢查询日志和监控指标平稳),按相同场景和并发重新跑一轮压测:
| 并发 | 优化前TPS | 优化后TPS | 优化前p95 | 优化后p95 | 优化前错误率 | 优化后错误率 |
|---|---|---|---|---|---|---|
| 50 | 185 | 212 | 0.8s | 0.4s | 0% | 0% |
| 100 | 172 | 226 | 1.2s | 0.5s | 0% | 0% |
| 200 | 156 | 318 | 2.1s | 0.6s | 0.4% | 0% |
| 300 | 132 | 381 | 2.8s | 0.6s | 3.7% | 0% |
| 500 | 89 | 358 | 5.2s | 0.9s | 12% | 1.1% |
效果很明显:并发300时TPS从132提升到381,p95从2.8s降到0.6s;并发500时虽然TPS有所回落,但错误率从12%降到1.1%,说明系统还在尽力处理而不是直接崩溃。MySQL的Threads_connected稳定在200以下,CPU使用率在并发300时达到70%左右,符合预期负载特征。
4. 压测中常见的坑与避坑经验
4.1 没有随机参数导致缓存命中失真
第一个坑就是压测数据太"假"。很多人在脚本里硬编码同一个用户、同一个试卷ID,这样压测时所有请求都落在一两个数据行上,MySQL的Buffer Pool正好覆盖这些热点页,查询全走内存,速度飞快。一旦真实用户分散访问不同试卷,性能会骤降一个数量级。
我这次特意准备了users.json,包含500个不同用户和不同试卷ID,并在k6脚本中用randomItem()随机选取。尽量真实,压测结果才有参考价值。
4.2 忽略预热直接加压,测出"假瓶颈"
另外一次压测中,我因为赶时间,跳过预热直接上300并发。结果Nginx报错一大片,但看CPU和内存占用都很低。排查了半天才发现是OPcache缓存还没生成,每次请求都要重新编译PHP文件,CPU全花在编译上了,而不是业务逻辑。浪费了大半天。
正确做法:无论用什么工具压测,至少预留5-10分钟的低并发预热,让应用框架、数据库连接池、各类缓存都填充到位再开始正式加压。
4.3 只测应用层不看底层指标,定位太难
压测报告显示响应时间升高,但到底是应用问题还是数据库问题?如果只看应用层数据,你可能会去调Nginx参数,改半天没效果。我当时同步打开了以下监控项,才快速定位:
top/htop:看CPU、内存、负载均值;iostat -x 1:看磁盘等待(%util),排除慢盘问题;nginxaccess log + PHP-FPM slow log:看具体哪个接口慢;- MySQL
SHOW PROCESSLIST;+ 慢查询日志:定位慢SQL; sar -n DEV 1:看网络流量有没有打满。
压测是系统性的排查工程,必须同时盯住应用日志、中间件状态和操作系统指标三个层面。
4.4 验证优化效果时忽略环境变量
优化前测一遍,优化后测一遍,但中间没注意测试环境还有别的任务在跑数据库备份,导致优化后的结果反而更差,差点误判优化方案无效。现在我的习惯是压测前检查环境干净程度:关闭定时任务、确认没有其他测试并行、记录数据库和应用的基线状态。
5. 一些额外的小建议
到今天,我依然坚持这个观点:性能优化没有银弹,每次压测都是在验证假设。你在测试环境做出来的优化,一定要在上线后观察真实流量下的指标,因为真实用户的请求混合度、网络延迟、第三方依赖的波动,比任何压测脚本都复杂。
另外分享一下我们后续的扩展方向:如果这套测试继续做下去,下一步会在Nginx层做限流和降级策略,再往后就是拆库拆表、引入Redis缓存试卷元数据,以及针对核心链路做全链路追踪。每一层改动之前,都要再跑一轮压测验证,保证没有引入新的性能回退。
压测不是一次性的活动,而应该成为发布流程的一部分。把压测脚本纳入CI/CD流水线,每次发版前跑一个轻量级的冒烟压测,对比历史基线,一旦发现性能回退就能及时拦截,越早发现问题,修复成本越低。这也是我踩过这么多坑之后,最想提醒大家的一件事。