第七次作业,是我整个系列课程里做得最难受、也最赚的一次。前六次作业都是要求“新增功能”,接口能跑通、页面能显示、权限能拦住人就算过;唯独第七次作业反着来——题目只有一句话:把前六次做完的那个设备借用管理系统,在单机环境下压测出性能报告,找到瓶颈并优化,最后用数据说服别人“它变快了”。
说实话,看到题目的时候我还挺不以为然的。系统早就跑起来了,平时自己点两下也觉得挺流畅,能有什么性能问题?结果第一轮压测数据出来直接把我打醒:一个核心列表接口,压了30秒,平均延迟318毫秒,P99直接飙到780多毫秒,服务端日志里一堆数据库连接超时。那一刻我才意识到,前六次作业只验收“能不能用”,第七次作业验收的是“扛不扛得住”。
这篇复盘不是课堂笔记,是我从基线压测、慢查询定位、索引修复、缓存引入、连接池调参,到最终二次压测的全过程记录。如果你也在做类似的性能优化作业,或者手头有个“功能完工但性能未知”的后端项目,这篇应该能帮你少走不少弯路。
1. 为什么一次“不新增功能”的作业,反而让我改到怀疑人生
1.1 前六次作业的“欠账”,第七次一次性还清
我们的系统前六次分别做了环境搭建、数据库建模、RESTful API、用户登录鉴权、文件上传、前后端联调。每次验收的重点都是功能,代码风格自然也就长成了“能用就行”的样子:需要查数据就现查,循环里查数据库也没觉得不对;列表接口图省事,一次把全表数据塞给前端;日志还打了全量请求体,每来一个请求就是两行输出。
这些操作在数据量小的时候确实没感觉。系统里一共就几百台设备、几千条借用记录,自己点两下当然秒开。但压测的本质是模拟“一群人同时点”,当并发连接一上来,那些平时藏起来的坏味道全部变成一个个新请求,瞬间堆积到数据库和连接池上。
这也是我这次作业最大的体会:技术债不会消失,只会延迟偿还。前六次有多潇洒,第七次就有多狼狈。
1.2 作业验收标准:不要感觉,要曲线和数字
作业要求很清楚:选一个核心读接口,优化前后各做一轮压测,提交报告。报告里必须包含压测工具、压测命令、压测环境、优化前的慢日志或监控数据、问题分析、优化方案、优化后的对比数据。
带班导师有句话我记得很清楚:“不要告诉我你感觉快了,我要看曲线和数字。”性能优化这事,最怕的就是“我感觉好多了”。没有压测数据的优化都是玄学,有了压测数据才算工程。
所以整个作业的核心链路就变成了:先跑出基线,再找瓶颈,逐个解决,每个改动跑一次验证,最后用两轮数据的对比来证明自己做了什么。
2. 第一轮压测:先给自己泼一盆冷水
2.1 压测工具凭什么选 wrk,而不是 ab 或 JMeter
选压测工具我先纠结了一会儿。Apache Bench(ab)最老牌,但功能太简单,不支持自定义请求体,线程模型也比较老旧;JMeter功能全,可是配置重,GUI模式跑压测时压测机自己都在消耗资源,适合做复杂场景长期回归,不适合现在这种快速摸底。最后定了 wrk——单进程多线程,秒级出结果,还能用 Lua 脚本自定义请求,简直是压测摸底的首选。
| 工具 | 上手难度 | 扩展能力 | 资源占用 | 适合场景 |
|---|---|---|---|---|
| ab | 低 | 低 | 低 | 单URL快速压测 |
| wrk | 低 | 中(Lua脚本) | 低 | 快速摸底、短时压测 |
| JMeter | 中高 | 高(插件体系) | 高 | 复杂场景、长时间回归 |
这里有个容易踩的坑:wrk 是单进程多线程模型,但只要线程数给得低(比如默认单线程),它自己就可能先成为瓶颈。我用的命令是:
wrk -t4 -c100 -d30s http://127.0.0.1:8080/api/devices含义是开4个线程、维持100个并发连接、持续压30秒。测试目标选的是设备列表接口/api/devices,因为这个接口是系统里最核心的读接口,前端首页、借用页面、管理后台都靠它。
2.2 基线数据:平均延迟300毫秒,这系统确实该改
压测环境是我的笔记本:8核CPU、16G内存,后端 Spring Boot 跑在本机,MySQL 用容器起在本机。这个环境谈不上严谨,但做相对对比是够用的,因为优化前后用的是同一套环境,变量控制住了就行。
第一轮基线数据长这样:
| 指标 | 优化前基线 |
|---|---|
| Requests/sec | 187.62 |
| Average Latency | 318.02 ms |
| P95 Latency | 512.44 ms |
| P99 Latency | 783.16 ms |
| 异常响应 | 出现连接池超时、部分请求失败 |
平均每秒只有187个请求,平均延迟318毫秒,P99接近800毫秒。这个水平对一个所谓“管理系统”来说都是不能接受的。我去翻服务日志,看到大量的Connection is not available, request timed out,说明连接池已经满了,请求都在排队等数据库连接。
第一轮压测的结论很明确:这个接口是妥妥的性能瓶颈,而且瓶颈大概率在数据库层——要么SQL写得有问题,要么连接池配得太小。接下来就该上慢查询日志看真凶了。
3. 顺着慢查询日志,把数据库的“坑”一个个揪出来
3.1 从日志到代码:N+1 查询是怎么混进列表接口的
先打开 MySQL 的慢查询日志,把阈值调低,让所有稍微慢一点的SQL都现身:
SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 0.1;阈值的0.1秒是故意调低的,正常单表查询在毫秒级,超过100毫秒的基本都有问题。重新压一轮之后去看慢日志,发现/api/devices接口对应的SQL简直离谱——一条主查询先查出所有设备,然后每条设备又跟着一条借用记录的查询,慢日志里刷了几百条同类的单条查询。
这就是典型的 N+1 查询问题。代码逻辑大概是:
List<Device> devices = deviceMapper.selectAllDevices(); for (Device device : devices) { BorrowRecord record = borrowRecordMapper.selectByDeviceId(device.getId()); // 组装返回结果 }前六次作业就是这么写的。当时数据量小,没人觉得不对;压测一上并发,100个请求一来,设备表500条数据,每条再查一次借用记录,数据库瞬间要处理5万多次查询。这就是的的确确的“查询放大”。
修复思路有两个:一条大的 LEFT JOIN 用一次查询搞定所有数据,或者先查出设备列表,再用device_id IN (...)批量查借用记录,在内存里组装。我选的是第二种,因为大 JOIN 在多表数据量大时会让单条SQL变得很重,而“先取列表再批量查关联数据”对数据库更友好,代码也更清晰。
SELECT record.device_id, COUNT(*) AS borrow_count FROM borrow_record record WHERE record.device_id IN (...) GROUP BY record.device_id;这一个改动,直接把数据库交互次数从一个“设备数+1”降到“2次”。
3.2 EXPLAIN 看到的全表扫描,“看似聪明”的模糊查询
N+1解决之后重新压测,吞吐量明显上来了,但数据库监控里还是有一个操作拖后腿——设备名称的模糊搜索。当时列表接口支持按关键词过滤设备,我图省事,直接用了:
SELECT * FROM device WHERE device_name LIKE '%投影仪%' ORDER BY created_at DESC;对这个查询执行EXPLAIN,可以看到type是ALL,也就是全表扫描,要检查4021行。问题就出在LIKE '%关键词%'的前导百分号上。
很多同学对索引有个误解,以为“只要字段建了索引,查询就一定会用”。实际上 B+树索引的查找是从最左边开始匹配的,LIKE '%投影仪%'意味着目标可能出现在字符串任意位置,索引根本不知道怎么定位,只能放弃索引去扫全表。这是索引生效规则的基本功,但真到自己写代码的时候特别容易忘。
按业务实际情况,我把这个查询改成了前缀匹配LIKE '投影仪%',索引就能派上用场了。如果你的系统确实需要包含匹配,就要考虑全文索引或者搜索引擎,但那对我们这个规模的作业来说就过度设计了。
3.3 索引的加法与减法:不是建了索引就万事大吉
除了模糊查询,列表接口还有一个大问题:没有分页。当时前端展示全部设备,接口就把所有数据一次返回,数据少还行,数据量涨到几千上万就是灾难。我加了分页参数,又针对排序和筛选场景建了复合索引:
ALTER TABLE device ADD INDEX idx_status_created_at (status, created_at);为什么是(status, created_at)而不是单独给每个字段建索引?因为查询条件是status = 1 ORDER BY created_at DESC,复合索引能同时服务筛选和排序;如果分开建两个单列索引,MySQL 通常只能利用其中一个,另一个索引白白浪费存储和写入开销。
这里我还翻过一次车:最初拍脑袋在device_name、status、created_at上各建了一个单列索引,结果压测发现查询速度没有明显改善。用 EXPLAIN 一看,三个索引里只有一个被真正使用(key_len能看出来),另外两个纯属摆设,还增加了每次写入的负担。索引不是越多越好,而是越匹配越好:先去读懂查询,再为查询设计索引,顺序不能反。
4. 缓存和连接池:从“够用”到“扛压”的最后一脚
4.1 缓存加在哪一层、缓存什么,比“用不用Redis”更重要
数据库层优化做完,我再压了一轮,延迟已经降到120毫秒左右,但距离我的目标还差得远。这时候我开始考虑加缓存。设备列表属于典型的“读多写少”场景,大部分用户都在看同一批数据,非常适合加缓存。
但“用 Redis 加缓存”这个想法本身并不难,难的是缓存什么。一开始我脑子一热想把整个设备列表缓存起来,后来冷静想想不对:设备状态会变,借用记录会新增,缓存整张列表就意味着每次数据变化都要失效全部缓存,一致性很难保证。
最终方案是只缓存“热门设备列表的前几页数据”,并且给缓存设置60到120秒的随机过期时间。随机过期很有必要——如果所有缓存在同一秒过期,过期那一刻所有请求一起打到数据库,就人为制造了缓存雪崩。
实现上用 Spring Cache 很简单:
@Cacheable(cacheNames = "deviceList", key = "#page + '-' + #size") public List<DeviceVO> listDevices(int page, int size) { return deviceMapper.selectDevicePage(page, size); }这个注解的语义是:同一页、同一大小的请求,第二次开始直接从缓存拿数据,不再查数据库。
4.2 连接池不是越大越好:一次调参翻车实录
数据库SQL优化和缓存都上完之后,我以为没别的事了,结果压测时又看到了连接等待的报错。当时我第一反应是连接池不够大,于是把 HikariCP 的maximum-pool-size从默认的10直接调到了200,心里想着“连数越多越能扛并发”。
结果一压测,直接翻车。MySQL 的max_connections默认才151,200个连接请求直接把数据库连接数打爆,大量的Too many connections报错,比之前还惨。
后来查资料才算真正理解连接池大小的逻辑:连接不是越多越好,每一条连接都要占数据库内存,连接切换也要消耗 CPU。对一个8核机器、单一数据库的普通应用来说,50个连接绰绰有余。最终配置:
spring: datasource: hikari: minimum-idle: 10 maximum-pool-size: 50 connection-timeout: 2000connection-timeout我顺手调成了2000毫秒,这样只要连接等待超过2秒就快速失败,避免请求无限排队拖死服务。这个参数的调整思路是:不要让请求把时间耗在排队上,快速失败、快速反馈,比长时间等待更健康。
4.3 让缓存保持新鲜的几种姿势
加了缓存之后不可避免要面对缓存一致性问题。用户借了一台设备,设备状态从“可借”变成“借出”,如果缓存不更新,用户看到的列表还是旧的,这就闹笑话了。
我采用的方式是 Cache Aside Pattern:更新数据库之后,主动删除缓存;下次请求发现缓存没了,重新查数据库回填缓存。配合短TTL做双保险,就算某次删除失败,缓存也会在一两分钟内自动过期。
@CacheEvict(cacheNames = "deviceList", allEntries = true) public void updateDeviceStatus(Long id, Integer status) { // 更新数据库中的设备状态 }这个方案谈不上高级,但对这个作业体量来说足够可靠。如果你有消息队列基础设施,还可以用延迟删除、双写一致性这些更复杂的方案——不过对大多数中小系统来说,“更新后删缓存+短过期”是最实用的方案,没有之一。
5. 第二轮压测与验收:数据不会骗人,但也会骗人
5.1 优化前后对比:TPS、P95、P99 都发生了什么
所有改动完成之后,我用完全相同的压测命令跑了第二轮:
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| Requests/sec | 187.62 | 1243.85 | 提升约6.6倍 |
| Average Latency | 318.02 ms | 46.73 ms | 下降85% |
| P95 Latency | 512.44 ms | 79.15 ms | 下降85% |
| P99 Latency | 783.16 ms | 126.42 ms | 下降84% |
| 异常响应 | 连接超时 | 0 | — |
这个数据交上去,作业基本就稳了。吞吐量从每秒187涨到1243,平均延迟从318毫秒降到46毫秒,P99从接近800毫秒降到126毫秒,而且是同样的机器、同样的命令、同样的持续时间。对比数据一放,不需要多的解释,效果自己会说话。
5.2 压测数据里那几个容易被忽略的“脏数据”
不过如果你照着做压测,会发现第二轮数据一开始根本没有这么好看。我中间有几次压出来的结果波动特别大,后来才意识到是压测的变量没控制好。
第一个坑是压测机和服务器同机。wrk 本身也是吃CPU的,4个压测线程和Java进程抢CPU,结果自然是“测试工具干扰了被测系统”。我的处理方式比较简陋:把压测输出重定向到一个固定文件,同时给 Java 设置-Xmx4g,让系统尽量稳定。条件允许的话,压测机和服务分开才是最标准的做法。
第二个坑是 JIT 预热。JVM 不像脚本语言,类要逐步被 JIT 编译成机器码,前几秒的请求其实在“热身”。连着压30秒和“先压30秒再正式压60秒”得到的数据能差出20%以上。我后来每次压测都先跑30秒预热,再重新计时跑正式数据。
第三个坑更隐蔽:日志。前六次的 INFO 日志在并发下疯狂输出,每请求打两行,磁盘IO成了隐形瓶颈。压测前我把业务日志级别调到 WARN,拿掉了一部分无意义的访问日志,吞吐量立刻又涨了一截。
这三个坑没处理好,你交上去的报告虽然数字也能看,但别人一复现就对不上,那就尴尬了。压测报告里必须写清楚用了什么命令、什么环境、预热多久、日志级别是什么,让别人能完全复现,这才算合格的压测。
5.3 提交作业之后,我又做了一件事
作业报告交上去之后,我本来想直接关机休息。但第二天回看自己改的代码,发现有几处为了压测数据硬写的优化很丑,比如分页大小直接写死在SQL里,缓存切不掉,接口逻辑绕来绕去。我花了一个晚上重新整理:把列表查询和详情查询拆开,把分页大小抽成配置,把缓存逻辑封装到单独的服务类里。
这件事不在作业要求里,但我觉得比作业本身更重要。压测阶段写出来的很多代码本质上是“实验代码”,目标是拿到好看的数据;作业交完之后,这些实验代码要能变成可以长期维护的工程代码。把临时的方案收干净,才能算真正吃透了这次作业。
6. 这次作业留下的三个后遗症
6.1 从“能用就行”到“先问能不能扛住”
这次作业对我的改变是,写接口时不再只问“能不能跑通”,而是多问了一句“这个查询会被调多少次?有没有 N+1?要不要缓存?”。
很多性能问题不是架构多复杂造成的,就是普通代码写得不设防——循环里查库、列表不分页、日志全量输出、索引乱建或者不建。这些毛病在数据量小、并发低的时候毫无存在感,但一旦流量上来,就是雪崩的前奏。第七次作业没有教我怎么设计高并发系统,但它把“性能敏感”这件事刻进了我的日常编码习惯里。
6.2 作业之外延伸的小实验
作业做完之后我又顺手做了一个小实验:同一个列表接口,分别用“原始 N+1”“单条 JOIN”“批量 IN 查询”“缓存”四种方式各跑一次压测,把结果做成表格。
| 实现方式 | 平均延迟 | 数据库交互次数 |
|---|---|---|
| N+1 循环查询 | 318 ms | 设备数+1 |
| 单条 JOIN | 141 ms | 1 |
| 批量 IN 查询 | 96 ms | 2 |
| 批量 IN + 缓存 | 46 ms | 0(命中缓存) |
这个小实验让我彻底看懂了 ORM 工具里那些“看似方便”的关联查询到底在背后做了什么。以后写代码我会优先选择批量查询来替代循环内查询,这个习惯直接一直留用到现在。
6.3 给正在做类似作业的同学几句大实话
最后说一下,如果你也正在做性能优化类的作业或项目,有几件事值得提前重视:
压测之前先固定环境和命令,写清楚参数,报告里必须附上命令,方便别人复现;优化过程一次只改一个变量,改完SQL就压一轮,改完缓存再压一轮,不要攒一堆改动同时上,否则效果归哪步说不清;如果时间紧,优先查数据库层的 N+1 和缺失索引,收益最大,连接池和缓存是第二步的事。
还有一句经验之谈:不要迷信优化工具和框架特性,先用最简单的慢查询日志和 EXPLAIN 把问题定位清楚,再谈方案。很多性能问题,答案其实就是普通SQL写得不够仔细。
这次作业虽然只是系列课程里平平无奇的一次“第七次作业”,但对我来说,它的价值远超过最终报告里那几行压测数据。它让我第一次认真地怀疑自己代码里那些“看起来很快”的地方,也让我理解了性能优化真正的工作方式:从压测出发,用数据说话,让每一次改动都有依据。