
别把性能优化当成玄学。我见过太多人一上来就调参数、改配置结果瓶颈没找着系统反而被搞得更慢。做性能优化这么多年我最深的体会是性能优化不是靠感觉而是靠测量和数据驱动的决策。这篇文章我想用一台真实服务器的压测经历完整复盘一次从规划、执行到定位、优化的实战过程。这次的主角是一台用于在线考试系统的应用服务器8核16GCentOS 7Nginx PHP-FPM MySQL部署在同一台机器后续会做读写分离和负载均衡。我们面临的问题是大促期间几千人同时在线答题系统响应变慢甚至出现了超时。目标很明确——找出瓶颈并验证优化方案是否有效。适合所有正在做Web应用压测、性能调优的开发者、运维同学也适合想系统学习压测思路的测试工程师参考。先说结论通过压测定位到PHP-FPM进程数配置不合理、数据库连接数打满、以及慢查询堆积三个核心问题经过针对性调优系统吞吐量TPS从优化前的132提升到优化后的381p95响应时间从2.8s降低到0.6s效果非常显著。一般我会建议把应用服务器和数据库分开部署但前期资源有限压测正好可以验证单体架构的极限在哪里。1. 压测前的完整规划1.1 明确压测目标和关键指标压测不是随便拿个工具打到服务器冒烟就完了。第一步永远是定义清楚测什么和怎么衡量。抛开目标谈压测就是耍流氓。对于这个在线考试系统目标拆成两层估算系统当前能支撑的最大并发在线答题人数找到在达到上限时最先撑不住的环节。衡量指标必须可量化我这次锁定了四个核心指标指标含义参考阈值TPS每秒处理的事务/请求数越高越好但需结合硬件资源响应时间RT请求发出到收到响应的时间p99 1sp95 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%查询历史数据压测过程分为三个阶段阶段并发数持续时长说明预热505分钟让框架缓存、数据库连接池、JIT编译充分热起来拉锯逐级增加100/200/300/500每级10分钟观察各项指标随并发的变化趋势持续性30030分钟验证系统长时间运行是否有内存泄漏等问题这里要特别强调预热阶段的必要性。如果你一上来就高并发压测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: [rate0.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 jsonperformance_test.json script.js压测完成后除了终端输出的汇总报告我还用Grafana Prometheus监控服务器CPU、内存、磁盘IO、网络和MySQL关键状态把应用层指标和操作系统层指标一一对应起来看。2.2 从瓶颈现象到根因定位第一轮压测跑到并发300时系统开始出现大量超时错误率飙到3.7%。看汇总数据并发TPSp95响应时间错误率501850.8s0%1001721.2s0%2001562.1s0.4%3001322.8s3.7%500895.2s12%很典型的吞吐量随并发先升后降曲线到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进程平均占用内存我实测每个进程大约消耗120MB8G内存减去操作系统和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验证执行计划typeref, keyidx_paper_status_sort, rows从12万降到152Extra中不再有Using filesort。3.3 优化数据库连接管理方案分两步一方面把MySQL的max_connections从500提升到800做好清理工作的兜底另一方面也是最关键的在PHP-FPM中开启MySQL持久连接。在PHP的mysqli或PDO中将连接参数设置为pconnecttrue或者使用mysqli_pconnect。这样每个PHP-FPM进程可以复用同一个数据库连接极大地减少重复建连的开销。同时检查应用代码确认提交答卷接口是否自动开启了事务。发现问题提交答卷逻辑中先更新答题记录再插入成绩单最后更新考试状态中间还有一次远程调用发邮件通知这个远程调用被包裹在事务里导致事务持有数据库锁的时间长达数百毫秒。修改方案远程调用移到事务之外并且所有涉及考试的查询强制走主库避免主从延迟导致的读写不一致压测期间从库及时同步也跟不上先保证一致性再说。3.4 二次压测结果对比优化完成后等待一段时间让慢查询日志和监控指标平稳按相同场景和并发重新跑一轮压测并发优化前TPS优化后TPS优化前p95优化后p95优化前错误率优化后错误率501852120.8s0.4s0%0%1001722261.2s0.5s0%0%2001563182.1s0.6s0.4%0%3001323812.8s0.6s3.7%0%500893585.2s0.9s12%1.1%效果很明显并发300时TPS从132提升到381p95从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看具体哪个接口慢MySQLSHOW PROCESSLIST; 慢查询日志定位慢SQLsar -n DEV 1看网络流量有没有打满。压测是系统性的排查工程必须同时盯住应用日志、中间件状态和操作系统指标三个层面。4.4 验证优化效果时忽略环境变量优化前测一遍优化后测一遍但中间没注意测试环境还有别的任务在跑数据库备份导致优化后的结果反而更差差点误判优化方案无效。现在我的习惯是压测前检查环境干净程度关闭定时任务、确认没有其他测试并行、记录数据库和应用的基线状态。5. 一些额外的小建议到今天我依然坚持这个观点性能优化没有银弹每次压测都是在验证假设。你在测试环境做出来的优化一定要在上线后观察真实流量下的指标因为真实用户的请求混合度、网络延迟、第三方依赖的波动比任何压测脚本都复杂。另外分享一下我们后续的扩展方向如果这套测试继续做下去下一步会在Nginx层做限流和降级策略再往后就是拆库拆表、引入Redis缓存试卷元数据以及针对核心链路做全链路追踪。每一层改动之前都要再跑一轮压测验证保证没有引入新的性能回退。压测不是一次性的活动而应该成为发布流程的一部分。把压测脚本纳入CI/CD流水线每次发版前跑一个轻量级的冒烟压测对比历史基线一旦发现性能回退就能及时拦截越早发现问题修复成本越低。这也是我踩过这么多坑之后最想提醒大家的一件事。