项目上线了一个多月,用户量上来之后发现首页接口的平均响应时间到了200ms,偶尔能飙到400ms。用火焰图一看,大部分时间没有花在数据库查询上,反而让路由解析占了不少。当时挺意外的,ThinkPHP8的路由解析已经很成熟了,但仍然有优化空间。后来把路由缓存打开,再顺手优化了两处查询,平均响应直接降到30ms左右。这篇文章把这个过程写出来,包含具体的配置和代码改动,你可以直接参考。
先聊聊路由解析为什么会慢
ThinkPHP8的路由解析分两步:第一步是从URL中找到对应的路由规则,第二步是根据路由规则绑定到控制器和方法。如果项目里定义了大量的路由(尤其是用了路由分组、资源路由、动态参数),每次请求都需要去匹配这些规则。默认情况下规则是从文件里加载,然后逐个进行正则匹配。小项目感觉不到,路由几百条之后,这部分开销就上来了。
另外,路由加载还涉及闭包处理,如果某个路由定义里用了回调函数,那这个闭包也没法被缓存。所以想要用路由缓存,最好全部使用字符串声明控制器和方法,不要使用闭包。
开启路由缓存前的准备
ThinkPHP8自带了一个命令:php think route:cache。它会编译生成路由缓存文件,下次请求直接加载编译后的数据,省去正则匹配的过程。但如果你在路由定义文件里用了Route::get之类的闭包,缓存会直接报错。所以先检查一下你项目里有没有类似写法:
// 这种写法没法缓存
Route::get('hello/:name', function($name){
return 'Hello '.$name;
});
// 改成控制器方式
Route::get('hello/:name', 'index/Hello/read');
如果你的项目里大量使用了闭包,那建议还是先别开路由缓存。硬开的话,ThinkPHP会提示你“路由无法缓存”,不会帮你生成。
我的项目是怎么写的路由
我接手这个项目的时候,所有接口都写在了一个route/route.php文件里,大概有一百多个路由,用的是分组定义,像这样:
use thinkfacadeRoute;
Route::group('api/v1', function () {
Route::get('product/:id', 'api.v1.Product/detail');
Route::post('order/create', 'api.v1.Order/create');
// 后面还有几十条
})->middleware([appmiddlewareAuthCheck::class]);
这种分组写法OK,里面没有用闭包,所以可以直接生成路由缓存。我直接执行:
php think route:cache
它会提示Route cache created successfully。然后我刷新页面测试,接口响应时间确实下来了一些,从200ms降到了100ms左右。但其实没有达到我预期的30ms,于是继续排查。
路由缓存不是银弹,还得补充查询优化
后来发现,路由解析时间降下来了,但数据库查询时间仍然占大头。尤其是首页这个接口,要查一个商品列表,每个商品又要查对应的SKU、品牌、评价数量,等于是一条主查询外加N条关联查询。我反手用select()查了所有商品,然后在循环里去查SKU。循环里查库这种事情,在流量小的时候无所谓,稍微有点并发就会爆炸。
我先用ThinkPHP的关联预载入解决了循环查询问题。比如商品模型里定义好了关联SKU和评价,这样写:
// 原来的代码 - 循环查库
$products = Product::select();
foreach ($products as $product) {
$skus = $product->skus->cache(10)->select();
}
// 改成预载入
$products = Product::with(['skus', 'brand'])->select();
就这么一个改动,接口响应时间从100ms直接降到了50ms。看起来预载入把N+1问题解决了。但我还是觉得有点慢,继续看。
一个更隐蔽的坑:路由缓存可能触发额外解析
有次我修改了某个中间件,然后清路由缓存重新生成,却发现有的路由访问后报错,说找不到路由。排查了半天发现是路由分组用了可变参数,然后又在另一个地方定义了相同模式的路由。路由缓存会严格匹配规则,但如果你在运行时修改了路由定义(比如在代码里动态添加路由),那么缓存反而会失效。这种情况很少见,但在二开项目里容易发生。解决办法就是:不要动态生成路由,不要用Route::alias这种花活。老老实实把全部路由写在routes目录下。
另外,ThinkPHP8的route:cache会缓存所有路由定义,包括中间件的配置。如果你的某个路由使用了很重的中间件,缓存不会优化掉中间件本身的开销。所以我顺便把中间件里的重复逻辑优化了一下——原来每个请求的表单验证、签名验证什么的,可以提前到路由解析之前做吗?不行,中间件就是设计在路由之后执行的。但你可以把一些纯静态的验证换成内存缓存,比如签名密钥直接写成常量,不要每次读取配置。
最后把Redis缓存加上,才到30ms
路由缓存虽然有用,但真正让接口快起来的还是数据缓存。首页商品列表几乎一小时才更新一次,完全可以放到Redis里。我用了ThinkPHP8的缓存门面,给接口加了一个简单的时间缓存:
public function index()
{
$cacheKey = 'home_product_list';
$data = cache($cacheKey);
if (!$data) {
$products = Product::with(['skus', 'brand'])->select();
$data = json_encode($products);
cache($cacheKey, $data, 3600);
}
return json(json_decode($data));
}
这样一来,接口第一次查库,后面直接读Redis,响应时间稳定在30ms以下。注意,我这里用json_decode后再返回,避免数据格式有问题。如果你用json()返回一个字符串,客户端拿到的是JSON字符串的转义,体验不好。
你以为结束了?其实还有一个坑。因为路由缓存是生成到runtime目录下的,如果你更新了路由定义但没有清缓存,旧缓存会一直生效。所以部署脚本里我加了一行:
php think route:cache
每次发版都重新生成一次,避免出现新旧路由不匹配的问题。
一些可以复述的经验
- 能用字符串控制器,绝不用闭包。这不仅是为了路由缓存,也是为了代码可维护。
- 路由分组比单条路由更好缓存。分组内的规则可以整体优化,TP8官方手册也推荐路由分组。
- 检查你是否开了debug模式。开发环境开了debug,路由缓存是自动无效的,只有部署环境才会启用。
- 用
php think route:list检查路由列表。它会把当前路由规则全部打印出来,如果太多重复,缓存效果就会打折。 - 数据库预载入+Redis缓存才是王道。路由缓存只是减少基础解析时间,数据层优化才是大头。
简单的性能对比
我在本地模拟了1000次请求,平均耗时大概是这样:
未开路由缓存 + N+1查询:207ms
开路由缓存 + N+1查询:125ms
开路由缓存 + 预载入:58ms
开路由缓存 + 预载入 + Redis:27ms
从表格可以看到,路由缓存能带来的收益大约是40%左右,而数据层的优化能把时间缩到原来的五分之一。如果你正在为接口慢苦恼,建议先打开路由缓存,然后再去优化查询,最后加Redis。顺序不要搞反了。
写在最后
这次调优给了我一个教训:框架提供的功能要搞清楚适用场景,不要为了用而用。路由缓存确实有效,但前提是你得遵从其限制。而且如果代码里大量使用了闭包,开启前一定要权衡。同时,性能优化的重点还是要放在数据库和缓存设计上,这才是成本最低收益最高的部分。
以上是我项目里的实际调优过程,所有数据都是本地压测得来的,不保证一定跟你的一样,但思路是通用的。欢迎交流,也欢迎指出错误。这篇文章没有别的意思,就是记录一下干活过程。

