
小项目发布之后线上第一个请求卡了 8 秒排查下来发现是路由缓存冷启动的问题。那次之后我把路由缓存预热写进了部署 SOP再也没在流量高峰时被压垮过。这篇文章就把 PHP 场景下路由缓存的原理、为什么需要 warmup、以及我实际怎么落地的方案完整整理出来希望对搞部署上线和性能调优的同学有帮助。1. 路由缓存冷启动一次上线引发的线上事故先交代一下背景。去年我在负责一个基于 Laravel 的 B 端 API 服务QPS 不算特别高但接口响应要求很严格线上压了 P99 在 300ms 以内。项目发布流程用的是 GitLab CI 宝塔面板常规操作是代码拉取、composer install、php artisan migrate 然后重启 php-fpm。看起来每一步都合规但那次事故给了我们一个深刻教训。那天上线的是一个新增了三十多条路由的模块代码合并之后照常发布。结果版本刚切完监控平台上立刻出现了告警线上好几个核心接口的耗时从平均 180ms 直接冲到了 6 到 10 秒而且越压越慢紧接着 php-fpm 的进程池被占满整个服务一度不可用。第一反应是看慢查询日志和 php-fpm 状态发现 CPU 并没有被数据库拖垮SQL 都是索引命中慢在应用层自身。后来单独手动 curl 一个接口第一次请求耗时 7.6 秒第二次同样的请求只要 210ms。这就很能说明问题了不是代码逻辑问题而是第一次请求触发了某种构建动作后面为什么慢是因为没有预热编译结果在请求时才生成。我打开日志目录检查 runtime 下的文件发现一个关键现象路由缓存文件在线上环境根本不存在。也就是说新代码安装后 Laravel 默认没有把路由表序列化到磁盘每个请求都走一遍完整的 Route 编译流程而路由数量从原来的 400 多条暴增到 700 多条单次编译的耗时被放大了。这个问题的本质就是所谓的冷启动。在 PHP 这种每次请求结束就释放进程内资源的模型下如果找不到缓存HttpKernel 就会重新路由匹配、控制器解析、中间件注册。流量一上来所有进程同时执行这一套逻辑CPU 瞬间打满响应时间自然就崩了。那次事故直接促使我把路由缓存 warmup提上了日程。我意识到路由缓存不只是优化手段它是一项稳定性保障措施必须在流量进来之前就提前完成。后面我们接入了预热机制类似的事故再没有发生过。2. 路由缓存的底层机制为什么 PHP 框架每次请求都要重新解析路由要理解 warmup 为什么重要得先搞清楚 Laravel 这类主流 PHP 框架在路由处理上到底做了什么。这不是一个简单的查表动作而是包含多个阶段的完整编译流程下面把关键步骤拆开讲。2.1 从路由收集到匹配一次请求背后发生了什么当 PHP-FPM 接收到一个 HTTP 请求框架的入口文件比如 public/index.php开始引导应用随后 HttpKernel 将请求对象传给 Router。Router 要做的就是两件事找出请求的 URI 对应哪个路由以及这个路由需要哪些中间件。在 Laravel 里RouteCollection 中保存着已注册的路由数组。普通运行时框架会先通过 RouteRegistrar 将各个路由文件如 routes/web.php、routes/api.php中定义的 Route 对象逐条加入集合。每一条 Route 都包含了 URI 模板、正则表达式、控制器闭包或方法、middleware 配置、name 等元数据。在匹配阶段Router 会对请求的 URI 遍历整个集合调用每个 Route 的 matches 方法。简单的固定路径匹配很快但带参数的路径比如/api/user/{id}或带正则约束的where(id, [0-9])就必须生成对应的正则表达式并执行 preg_match。一个项目里几百上千条路由这些正则拼装和匹配动作是实打实的 CPU 开销。框架虽然会做一些短路优化比如先用静态前缀做快速排除但整体成本依然不可忽视。根据我本地的压测数据700 条路由状态下不做缓存时单次请求路由匹配耗时大约在 18 到 30ms而做了缓存后可以降到 1ms 以内。这个差距在高并发下会被成百上千个进程放大。2.2 路由缓存的本质把编译结果序列化到磁盘所谓路由缓存本质上就是提前把路由集合编译成可以直接加载的结构化数据比如 PHP 数组然后通过 var_export 写入文件放在 bootstrap/cache/routes-v7.php不同版本文件后缀可能不同。下次请求加载时框架不再遍历注册路由而是直接用 array_unshift 引入缓存文件然后通过 RouteCollection 的反序列化方式直接重建路由表。这个思路其实和 opcache 缓存字节码类似用一个代价较高的初始化动作换取后续请求的低成本复用。区别是 opcache 缓存的是 PHP 解析后的 opcode而路由缓存省掉的是路由注册与预编译整条链路。具体来说Laravel 的 route:cache 命令会执行以下主要步骤引导应用并触达所有必要的 ServiceProvider收集所有路由文件中的路由定义对每条路由完成正则表达式预编译将整个集合序列化写入缓存文件同时生成一份编译后的路由列表供快速查找缓存文件被加载后routes 的注册逻辑被跳过框架直接从一个静态结构中进行查找。这个方法对提升 TTFB首字节时间效果非常显著尤其是路由数量过百、嵌套中间件较多的中大型项目。2.3 为什么生成了缓存不等于完成了预热这里要引出 warmup 与生成缓存的区别。很多团队也做了路由缓存但只在本地或构建机执行了 php artisan route:cache然后通过 CI 把文件一并打包上传。表面上缓存文件存在可问题在于线上环境不一定能读取到或者生效时间不确定。还有更隐蔽的情况部署脚本顺序不对代码更新后先重启了 php-fpm而缓存文件是在重启之后才生成的。这中间的空窗期所有请求都会命中冷启动逻辑流量一大照样雪崩。真正的 warmup 应该是一个可验证的动作。生成缓存文件只是第一步要确保线上实际运行的进程加载到了这个缓存才算完成预热。此外Laravel 在 route:cache 后会自动进入路由缓存已启用状态后续再修改路由文件不会生效这一点部署时也要做好衔接。2.4 路由缓存和 OpCache 的区别与关系不少初学者会把路由缓存和 OpCache 搞混。这里说清楚一下OpCache 缓存的是 PHP 脚本编译后的字节码属于语言层面的优化它解决的是 include/require 文件重复解析 的成本而路由缓存解决的是应用层路由数据结构重建的成本两者是互补关系。实际部署中我建议两者都要做。开启 OpCache 后include 一个 100KB 的类文件从 5ms 降到 0.5ms但路由匹配的正则计算并不会被 OpCache 加速。所以路由缓存负责把集合构建正则预编译中间件注册这些高成本步骤一次性做完OpCache 负责把加载缓存文件本身这个过程加速。两者配合才能达到最佳效果。3. 不同 PHP 框架下的路由缓存 warmup 实现方案不同框架的路由机制差异很大warmup 的具体实现也不一样。我分别梳理一下 Laravel、ThinkPHP、Hyperf 这三个我实际用过的框架给出对应的方案和注意点。3.1 Laravelroute:cache 的正确打开方式Laravel 是最典型的代表。官方提供了 php artisan route:cache 命令用法很简单但坑也不少。先说标准操作php artisan route:cache执行成功后可以看到 bootstrap/cache 目录下多了 routes-v7.php同时在 storage/logs 里可能有一行日志标明路由已缓存。我建议在部署脚本中使用以下完整流程# 先清理旧缓存避免数据残留 php artisan route:clear # 生成新的路由缓存 php artisan route:cache # 验证缓存文件是否实际存在并可用 php -r require bootstrap/cache/routes-v7.php; echo route cache ok;注意顺序先 clear 再 cache。如果之前缓存文件锁定了旧路由直接执行 route:cache 可能不会更新。另外route:cache 在 Laravel 5.8 之后不再支持缓存闭包型路由如果你的路由文件里有非控制器闭包执行时会直接报错。所以定契约时尽量让所有业务路由都指向控制器方法把闭包保留在极少数固定格式的壳层面否则每次上线都要处理异常。验证文件可用这一步很多人会省略但我建议加上。它能在请求进来之前就发现写文件权限问题而不是上线后才暴露。3.2 ThinkPHP路由缓存和路由模式的选择ThinkPHP 的路由缓存策略和 Laravel 不太一样。TP 有一个 route_check_cache也写作 route_check_cache它保存的是路由检测结果而不是整张路由表。这个差异很重要它缓存的是某个路径匹配了哪条规则而不是全部路由的正则集合。在 ThinkPHP 6.x 中可以在 config/route.php 里开启route_check_cache true,配合php think route:cache命令可以生成完整的路由缓存。不过 TP 的路由缓存对动态参数的支持要小心如果你大量使用了闭包路由或者在中间件里动态注册路由缓存结果可能会不准确。我在 TP 项目里的 warmup 部署策略是这样的构建阶段执行php think route:cache发布时把 runtime/route_cache.php 一并部署重启服务前先 curl 一下健康检查接口确认路由解析正常和 Laravel 不同TP 的路由缓存文件如果出现脏数据清理起来比较麻烦所以我习惯用php think route:clear做前置清理。另外 TP 里给路由设置了域名绑定的情况比较常见预热时一定要带上 Host 头去访问避免生成错误的缓存条目。3.3 Hyperf 和 Swoole 常驻进程预热从文件缓存变成了内存预热Hyperf 这类基于 Swoole 常驻内存的框架路由缓存的实现方式更激进。它是在服务启动时一次性将路由表加载并常驻内存理论上比 PHP-FPM 方案的解析成本更低。但常驻进程也带来了新问题路由修改后必须重启服务进程才能生效而且如果使用了注解路由annotation route首次扫描注解的开销也很大。在 Hyperf 中warmup 的动作通常伴随服务启动过程。可以用以下方式主动预热php bin/hyperf.php start服务启动后框架会自动扫描注解并生成路由如果你需要确保路由注册成功再切入流量建议在启动脚本中增加健康检查# 等待服务起来 sleep 1 # 主动请求一个最核心的接口逼使框架完成初始化 curl -s -o /dev/null -w %{http_code} http://127.0.0.1:9501/health_check对于 Hyperf 项目我还会用php bin/hyperf.php gen:route之类的辅助命令生成路由缓存避免每次重启都重复扫描注解。3.4 不同框架 warmup 方案对比为了更直观地对比下表列出框架差异框架缓存核心对象预热命令缓存文件位置关键坑点Laravel编译后的路由集合route:cachebootstrap/cache/routes-v7.php闭包路由不支持写权限问题常见ThinkPHP路由检测结果 路由表think route:cacheruntime/route_cache.php动态参数可能导致脏缓存Hyperf注解生成的路由表启动时自动生成运行时内存/编译代理修改路由必须重启服务Symfony路由匹配器编译结果cache:warmupvar/cache/prod环境变量影响缓存生效此外还遇到一些使用自定义路由器的小框架或老系统这类项目没有现成的 cache 命令。我的通用做法是写一个 CLI 脚本模拟一个真实的请求对象调用框架的 URL 生成或路由匹配逻辑循环生成 10 到 20 个核心路由的匹配结果将结果写入 runtime 缓存目录。这样虽然没有完整缓存动静但能让运行时的高成本结构提前构建一部分。4. 生产环境 warmup 实操部署脚本里的完整接入方法光说原理不够这里给出我目前在用的部署脚本并解释每一步的意图。4.1 部署脚本模板以下脚本适用于 Laravel 项目的 PHP-FPM 部署场景核心思路是在重启服务之前完成路由缓存生成和预热然后用健康检查确认缓存生效。#!/bin/bash set -e # 切换到项目目录 cd /www/wwwroot/api.example.com # 1. 进入维护模式暂停非核心流量 php artisan down # 2. 拉取最新代码 git pull origin master # 3. 更新依赖 composer install --no-dev --prefer-dist --optimize-autoloader # 4. 清理旧缓存 php artisan optimize:clear # 5. 生成新缓存包括路由、配置、事件 php artisan route:cache php artisan config:cache # 6. 预热检查确保路由缓存文件可加载 php -r require bootstrap/cache/routes-v7.php; echo route cache compiled; # 7. 退出维护模式开始接收流量 php artisan up # 8. 请求核心接口确认线上可用 curl -s -o /dev/null -w %{http_code}\n http://127.0.0.1/api/health_check脚本的精髓在步骤 4、5、6。先清理再生成避免脏数据残留route:cache 之后立刻用 PHP 直接 include 缓存文件这个过程会触发框架的编译加载逻辑如果文件有语法错误或格式异常这个步骤会直接暴露问题。这条语句跑通了再切流量路由基本就是热的状态。4.2 为什么预热要赶在重启之前完成这里想强调一个很多人忽略的细节。如果先重启 php-fpm 再生成路由缓存那么从重启到缓存文件生成之间所有请求都会走冷启动流程RPS 一旦上来就会出状况。反过来先生成路由缓存再重启或 reload那么 php-fpm 的子进程从出生第一天起就是热的拉到的请求不会触发编译逻辑。另外在 PHP-FPM 模式下每个 worker 是独立进程如果你用的是 reload 而不是 restart老进程可能还在运行新老进程的 opcode 缓存和路由缓存可能不一致。我通常是在生成缓存后直接平滑重启 php-fpmsystemctl reload php-fpm或者用宝塔的命令行工具/etc/init.d/php-fpm-74 reload。这样做才能保证所有 worker 都加载到新的路由缓存。4.3 容器化部署下的 warmup 策略项目转向 Docker 后warmup 的方式也需要调整。容器是无状态的每次发布都会创建新的容器实例文件系统中的缓存文件必须重建。我在 Dockerfile 里的处理方式是把路由缓存生成放在构建阶段FROM php:8.2-fpm WORKDIR /var/www COPY . . RUN composer install --no-dev \ php artisan route:cache \ php artisan config:cache CMD [php-fpm]这种做法的好处是镜像内已经包含了完整的路由缓存文件运行时不需要再执行生成命令。坏处是构建时间和产物体积会增大而且如果运行环境需要在启动时注入环境变量比如数据库配置config:cache 可能读到的是构建期的值这一点需要警惕。如果在启动阶段才生成缓存推荐用一个轻量的 entrypoint 脚本#!/bin/sh set -e php artisan route:clear php artisan route:cache php artisan config:cache exec php-fpm这样每次容器启动都会生成新的缓存避免因为镜像缓存导致旧路由残留。同时要注意容器环境中的文件系统如果配置为只读那就要把 bootstrap/cache 挂载为可写卷否则生成缓存会直接失败。5. 缓存失效和订阅通知让 warmup 更加健壮路由缓存项目的坑点往往不在于生成而在于更新。尤其在一个系统迭代频繁时路由增删是常态。每次改动都手工执行 route:cache 显然不现实所以需要一套失效与重建机制。5.1 文件监听触发自动重建我实现过一种实用方案使用 inotify 监听路由文件目录的变更。当 routes/ 目录下的文件发生增删改自动触发一个重建调度。这对小型团队非常友好改动路由文件后不再需要手动跑命令。在部署机或开发机上可以使用 Python 脚本配合 inotifywaitwhile inotifywait -r -e modify,create,delete /www/wwwroot/api/routes; do cd /www/wwwroot/api php artisan route:cache done写成 systemd 服务最佳。但生产环境我不建议完全依赖这个因为 redis 之类外部服务的继续刷新也可能需要缓存失效联动最好还是结合 CI 流程统一处理。5.2 灰度发布时的分批预热如果你有用到负载均衡或网关做灰度发布时最好分批 warmup。以 Nginx 为入口的架构是先对一部分节点执行生成缓存与健康检查确认新节点没问题后再切权重而不是一次性让整个集群都进入冷启动状态。具体做法用脚本实现一个节点循环for node in node1 node2 node3; do ssh $node cd /var/www/api php artisan down git pull php artisan route:cache php artisan up # 这里要做健康检查确认该节点路由正常后再操作下一个 curl -s -o /dev/null -w %{http_code} http://$node/health_check echo node $node warmed done分段预热能显著缩小故障影响面。即使某一次路由缓存生成出的文件有问题最多影响一个节点其他节点的流量不受波及。5.3 定时轮询核心接口保持热度除了部署时的主动预热运行时还可以做周期性的保活请求。做法很简单在 crontab 里加一条*/5 * * * * curl -s -o /dev/null http://127.0.0.1/api/health_check这样能保证缓存即使被误删或过期也会在 5 分钟内被重新触发构建而不是等到用户流量来了才发现冷启动。对于 PHP-FPM 模式保活请求的意义比 Swoole 常驻进程模式更大一些。6. 你可能会遇到的故障场景与排查思路缓存本身看起来简单但故障场景五花八门。我把遇到的真实问题和对应的排查链路整理成清单方便直接对照。6.1 路由缓存文件在但接口返回 404这类问题我遇到过三次原因是缓存文件里的路由与线上实际访问的域名不匹配。比如 Laravel 里缓存了包含 domain 约束的路由线上用 www 域名访问缓存里记录的却是 api 域名。排查链路确认路由文件的域名配置检查是否开启了 config:cache站点配置是否被缓存用命令php artisan route:list查看实际生效的路由清掉路由和配置缓存后重新生成注意如果同时启用了 config:cache 和 route:cache两者之间有时候会互相干扰。配置变化后必须先清 config 缓存再生成路由缓存。6.2 缓存生成失败闭包路由与序列化异常Laravel 的旧版本对闭包路由的缓存支持不完整执行 route:cache 会抛Unable to prepare route ... for serialization. Uses Closure.之类的异常。解决办法就是上文说的业务代码中不使用闭包路由全部改为控制器方法。如果是历史遗留代码可以用一个简单命令先扫描出闭包路由列表然后逐个改造。还可以使用预处理钩子来打断闭包式路由注册但这很麻烦不如直接在团队规范层面约定。6.3 缓存文件写不进去权限与磁盘问题LNMP 环境下最常见的是目录权限问题。Nginx 和 php-fpm 进程以 www 用户运行但项目目录所有者是 root导致写入失败。解决办法chown -R www:www /www/wwwroot/api/bootstrap/cache chmod -R 755 /www/wwwroot/api/bootstrap/cache在容器环境里还要注意挂载卷的属主设置默认挂载目录的 uid/gid 和容器内用户可能不匹配。6.4 预热后内存占用反而飙升少数情况下生成了路由缓存但 opcache 没配置好每次请求还要读取大体积的数组文件。如果路由数量巨多内存占用会上升。解决思路是开启 OpCache 的opcache.validate_timestamps0或opcache.revalidate_freq调大避免重复验证文件变化。还有个思路是把路由缓存文件拆分到不同文件中利用 PHP 的 opcache.preload 特性提前加载。这个特性在 PHP 7.4 可用适合大项目。6.5 高并发瞬间触发缓存构建导致雪崩如果冷启动无法完全避免那就要在入口做流量控制。我采取的办法是在 Nginx 层对首次请求做熔断或限流limit_req_zone $binary_remote_addr zonewarmup_limit:10m rate10r/s;同时在 Laravel 层面用一个中间件判断缓存文件是否存在如果不存在则快速返回一个静态占位响应而不是让请求烧 CPU。这种兜底策略能有效防止所有 worker 同时进入路由编译逻辑。7. 数据分析预热前后的性能指标变化光说有作用没有说服力我把我整理过的一组真实数据放出来供参考。测试环境为单机 4 核 8GPHP 8.1Laravel 9路由数量 620 条压测工具使用 wrk持续 1 分钟并发 50 连接。选用的是一个带 5 个中间件的 GET 接口。指标未开启路由缓存开启路由缓存并预热平均响应时间141ms78msP95 响应时间178ms92msP99 响应时间240ms115ms吞吐量req/s238423PHP-FPM 活跃进程数峰值98%67%可见开启预热后平均响应时间降低了 44.7%吞吐量接近翻倍。尤其是 P99 的下降非常明显说明尾部延迟被大幅压缩。冷启动不再抢 CPU进程池也不再频繁被占满。第二次测试是将路由数量从 300 条增加到 800 条未开启缓存时平均响应时间直接上升了 60%而开启缓存的场景只上升了 8%。这也验证了路由数量越大缓存收益越明显。需要说明的是响应时间中有一部分是框架其他环节的开销比如 Eloquent、Session、中间件逻辑路由缓存的收益并不会完全消除所有延迟但足以让系统在高负载下保持稳定输出。8. 关于 warmup 你还可以做的几件进阶事如果项目已经把路由缓存 warmup 做得很扎实了还有一些进阶方向可以参考能让整体性能再上一个台阶。8.1 配置缓存、事件缓存、路由缓存三件套一起做Laravel 里除了路由配置加载和事件注册也是有成本的。建议部署时同步执行php artisan config:cache php artisan event:cache php artisan route:cache三条命令互相配合把应用初始化阶段最重的几个动作全部前置完成。event:cache 对事件较多的项目收益明显它可以避免每次请求都去扫描监听器列表。8.2 将路由预热的时间点提前到构建而非发布理想状态下路由缓存的生成应该发生在 CI 构建阶段而不是发布阶段。这样发布出去的就是一个已经包含完整缓存的产物。对于使用 GitLab CI 或 Jenkins 的团队尤其合适。具体做法是在 CI 的 artifact 阶段执行php artisan route:cache --pathroutes/cache/routes.php然后把生成的文件打包进发布包。这样线上只需要加载文件不需要执行生成逻辑发布速度更快风险也更低。8.3 使用 APM 工具验证预热效果不要凭感觉判断预热生效了还是没生效。我建议接入 APM 工具比如 SkyWalking、Pinpoint 或者云厂商的 APM在链路追踪中单独看router.match这个 Span 的耗时。如果预热成功这个 Span 应该在 1ms 以下如果还能看到 10ms 以上的耗时那大概率缓存没生效。这个方法能够快速定位是路由缓存挂了还是其他组件挂了避免盲目排障。8.4 缓存版本号与路由版本强绑定最后一个建议在缓存文件名或缓存元数据中加入版本号。我的做法是在路由缓存文件头部写入一个简单的版本标识// routes-v7.php return [ version 20240115, routes [...], ];部署时对比当前代码版本的哈希值如果一致直接跳过生成步骤不一致才重新生成。这样能避免代码更新了缓存没更新的隐患也方便回滚检查。最后分享一点个人习惯我在写运维脚本时始终保留一条准则涉及缓存的脚本步骤必须可验证、可回滚。route:cache 的执行结果不只是生成一个文件而是让整个路由系统从高成本编译模式切换到低成本查找模式。所以每次我执行完 warmup都会在日志里输出文件大小、生成耗时和健康检查结果。这些数据不仅方便复盘也能在出问题时快速定位是缓存没生成、生成错了还是加载失败。如果你所在团队的项目已经出现上线后首次请求变慢的情况不妨先从路由缓存预热这个点入手。改动量不大回报却非常直接。