从 200ms 到 20ms:我把一个列表页扒了三层皮才悟到的性能减法
上周有个老项目被用户吐槽"加载像拨号上网",我打开 Chrome 一看,一个文章列表页居然要 200ms+。这项目用 ThinkPHP6 + MySQL 5.7,数据量也就十几万,不至于啊。结果一层层扒下来,发现不是某个大招能解决的,是三个地方各慢一点点,叠在一起就炸了。
第一层:查询在"假装很忙"
先抓慢日志,排第一的是这个:
```sql SELECT * FROM article WHERE status=1 ORDER BY create_time DESC LIMIT 20 ```
看着人畜无害对吧?但 `EXPLAIN` 一看,`Using filesort`,`status` 和 `create_time` 各有一个单列索引,MySQL 选了 `status` 索引然后全表排序。我试了下 `FORCE INDEX`,还不如加联合索引:
```sql ALTER TABLE article ADD INDEX idx_status_time (status, create_time); ```
直接掉到 15ms。但这里有个坑——我一开始写成 `(create_time, status)`,因为脑子里想着"时间排序放前面",结果 `status=1` 的筛选条件用不上,filesort 还在。联合索引的顺序,真的是"等值查询在前,范围/排序在后",默念三遍。
还有个更隐蔽的:列表页要显示作者昵称,之前是 `with(['user'])` 预加载,但用户表里有十几个字段,列表页其实只需要 `nickname`。我加了个 `field` 限制,又砍掉一半内存占用。
第二层:缓存不是加了就完事
查询快了,但整体还是 80ms 左右。上 Blackfire 一抓,Redis 连接占了大头。原来每个请求要连三四次 Redis:配置缓存、会话、页面片段、计数器,每次 TCP 握手都要时间。
我把 ThinkPHP6 的缓存配置从默认的 `persistent=>false` 改成 `true`,复用连接,瞬间少了 30ms。但持久连接有个坑,PHP-FPM 子进程挂掉时连接可能脏,我加了个 `ping()` 检测兜底,目前稳了半个月。
另外之前为了"实时",列表页缓存设了 60 秒,其实内容更新频率没那么高,改成 5 分钟 + 后台清缓存触发,命中率从 40% 飙到 90%。别为了伪实时牺牲真金白银的性能。
第三层:静态资源在"排队等绿灯"
后端 20ms 了,F12 看整体还是 200ms+。问题在资源加载:CSS 和 JS 没压缩,图片没懒加载,更蠢的是我把 jQuery 和 Bootstrap 从本机 CDN 引的,结果那个 CDN 节点最近抽风。
改成本地合并压缩 + 异步加载非关键 JS,图片加了 `loading="lazy"`。但有个细节:我用了 Cloudflare 的 Auto Minify,结果和 ThinkPHP 模板里的内联 JS 冲突,变量名被压坏了。现在我是构建时自己用 esbuild 压,CDN 只负责边缘缓存,不碰内容。
还有字体文件,之前引了 Google Fonts,国内用户懂的。我换成了子集化后的本地 WOFF2,只包含用到的字符,从 200KB 压到 12KB。
最后算个账
200ms → 查询优化 15ms → 缓存连接复用 20ms → 静态资源优化 20ms。没有银弹,就是每个环节抠一点。我现在养了个习惯:任何页面超过 50ms 就要抓包看时间花哪了,不猜。
你们有没有那种"看着没问题,叠在一起就崩"的性能坑?欢迎扔出来一起扒。

