ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Hyperf平滑重启与热更新实战:常驻内存下的进程管理策略

Hyperf平滑重启与热更新实战:常驻内存下的进程管理策略 1. 常驻内存成了“热更新”的坎Hyperf 平滑重启到底怎么解如果你是从 PHP-FPM 时代转过来的一定经历过那种“改几行代码刷新浏览器就能看到效果”的爽快感。但把项目迁到 Hyperf 这类基于 Swoole 常驻内存的框架之后第一次遇到“我明明改了代码为什么服务没反应”时整个人是懵的。这背后不是 Hyperf 的缺陷而是运行模型发生了根本变化PHP-FPM 模式下每个请求都会重新加载文件、重新解释执行代码修改天然是“热”的而 Swoole 让 PHP 进程常驻内存业务代码在服务启动时就已经加载到内存里了磁盘上的文件变动并不会自动同步进去。所以“Hyperf 热更新和平滑重启”这个问题几乎所有从传统 PHP 转过来的团队都会碰到。它本质上是两件事热更新追求的是“开发体验”改完代码不用手动重启进程平滑重启追求的是“发布体验”线上更新代码时请求不能中断、连接不能重断。这两者看着相似落地的路径完全不同。这篇文章我会从运行机制讲起重点分享我在生产中验证过的平滑重启方案、一键脚本、配置中心联动以及那些文档里不会写清楚的大坑。2. Hyperf 里到底“谁不热”先搞清楚模型再说操作2.1 从 PHP-FPM 到 Swoole代码何时被“固化”进内存传统 PHP-FPM 的运行方式可以理解为“每次请求都是一次全新的执行”Nginx 收到请求后把 PHP 文件路径交给 PHP-FPM 进程PHP 从磁盘读取文件、编译成 opcode、执行完就释放。所以当你修改一个 PHP 文件时下一个请求自然会读到新代码。这个过程没有额外的开销但代价是每个请求都要重复编译性能天花板很低。Hyperf 基于 Swoole 常驻内存后服务启动阶段会一次性把框架代码、业务代码、配置加载到内存中Worker 进程不断复用这份内存来处理请求。这就相当于公司门口挂了一本《员工手册》所有人入职那天已经背熟了HR 改了手册内容员工不会自动同步到新版本必须等下一次“全员培训”才能生效。这个“全员培训”对应到 Hyperf 里就是重启 Worker 进程。理解了这一点你就能明白为什么网上有人说“PHP 是天然热更新的”这句话只在 PHP-FPM 时代成立。进入常驻内存时代代码从“磁盘文件”变成了“内存镜像”按文件路径修改代码的惯性思维必须更新。2.2 热更新与平滑重启两个词难度完全不同很多团队把这两个词混着用导致运维方案设计得一塌糊涂我必须把它们严格拆开。热更新强调“代码变更后服务自动感知、自动应用”它要求进程能够在不重启的前提下重新加载新的代码逻辑。在 PHP 生态里除了传统的文件监听 reload还有一类做法是通过共享内存、opcache 重载等手段实现“真正意义上的热替换”。但客观讲PHP 在这一点上不如 Java 的 JVM 那么顺畅能做到的所谓“热更新”大多数仍是快速重启的变体。平滑重启则完全不同它不追求“代码自动替换”而是追求“发布过程中服务不中断”。它的核心逻辑是主进程Master先拉起一批新的 Worker让新 Worker 处理新请求等旧 Worker 把当前正在处理的请求都处理完了再让它优雅退出。整个过程对客户端不可见所以叫“平滑”。对比项热更新平滑重启主动程度系统自动感知文件变化人为触发、有明确步骤核心要求代码逻辑替换不中断请求处理不受影响PHP 实现难度高容易有状态残留中Swoole 原生支持适用场景开发环境生产发布失败后果调试困难短时间连接中断或请求失败2.3 注解缓存、代理类、自定义进程哪些代码 reload 根本不生效这是我在服务 Hyperf 项目后最常见的误区。很多开发者以为只要执行了平滑重启任何代码改动都能生效但实际远非如此。Hyperf 大量依赖注解包括路由注解、依赖注入注解、AOP 切面注解等。服务启动时框架会扫描这些代码生成对应的代理类和缓存文件一般存放在runtime/container/和runtime/container/proxy/目录。如果你只是往 Controller 里加了一个#[GetMapping]注解的方法通知 Worker reloadWorker 虽然重启了但注解扫描结果和代理类缓存可能仍然是旧的新增路由根本不会注册进去。这种情况下必须执行完整重启或者先清理 runtime 缓存再重启。同类的问题还出现在自定义进程上。Hyperf 里用Process注册的自定义进程常用来做队列消费、多进程任务等这类进程并不归 Swoole 的 Worker 池管理reload 信号不会重启它们。如果改了自定义进程的代码原地 reload 是无效的必须把整个服务 stop 后再 start。还有静态属性、单例对象、运行中建立的连接池状态一旦在进程内存里被修改重启是唯一出路。所以在动手执行任何重启前先给自己提一个问题这行改动是普通的业务逻辑还是进程拓扑级别的变更这个答案决定你用 reload 还是 restart。3. 基于 Swoole 信号机制的平滑重启实操流程3.1 搞懂 Master / Manager / Worker 与信号的协作关系Swoole 的服务进程模型分为三层Master 进程负责管理网络端口、接收外部连接Manager 进程负责管理 Worker 进程的创建、销毁和监控Worker 进程实际处理业务逻辑。我们平时说的“平滑重启”本质上是向 Master 进程发送特定信号由 Master 协调 Manager 来完成 Worker 的有序替换。Swoole 官方文档里定义的几个关键信号如下信号作用生产环境常用度SIGTERM安全关闭整个服务杀死 Master较低SIGUSR1平滑重启所有 Worker 进程最高SIGUSR2平滑重启所有 Task 进程中协程风格下用不到发信号时目标必须是 Master 进程的 PID而不是任意一个 Worker 的 PID。你可以通过runtime/hyperf.pid获取 Master 进程 ID也可以使用pgrep -f来查找。执行kill -USR1 master_pid后Master 会告诉 Manager 逐个停止旧的 Worker每个旧 Worker 处理完当前正在执行的请求后自动退出Manager 再拉起新的 Worker 接替。如果你用的是 Hyperf 3.x 且开启了reload_async配置这个流程还会更安全。Swoole 在 4.5 版本引入了异步安全重启机制开启后 Worker 收到退出指令不会立即中断正在处理的协程而是等待当前事件循环处理完毕再退出。这个配置直接放在config/autoload/server.php的settings里?php return [ mode SWOOLE_PROCESS, servers [ [ name http, host 0.0.0.0, port 9501, sock_type SWOOLE_SOCK_TCP, callbacks [], ], ], settings [ enable_coroutine true, worker_num 4, reload_async true, max_wait_time 60, ], ];这里有个很容易被忽视的细节max_wait_time表示 Worker 收到退出信号后最多等多久。如果某个请求长期占着协程不放超过这个时间后 Swoole 会强制杀掉进程。所以平滑重启能“平滑”的前提是业务请求都能在合理时间内结束。如果你的代码里有长时间运行的阻塞任务光靠 reload_async 是兜不住的。3.2 一份可直接抄作业的平滑重启脚本网上关于 Hyperf 重启的说法很多但我实测下来最稳妥的方式还是写一个部署脚本统一管理。下面这个脚本我在多个生产环境里用过核心思路是先做语法检查再发信号最后做健康检查。#!/usr/bin/env bash set -euo pipefail APP_DIR/www/wwwroot/your-hyperf-app PID_FILE${APP_DIR}/runtime/hyperf.pid HEALTH_URLhttp://127.0.0.1:9501/health MASTER_PID cd $APP_DIR if [[ ! -f $PID_FILE ]]; then MASTER_PID$(pgrep -f hyperf.php start | head -n 1 || true) if [[ -z $MASTER_PID ]]; then echo ERROR: hyperf master process not found exit 1 fi else MASTER_PID$(cat $PID_FILE) fi find app config -name *.php -print0 | xargs -0 -n1 php -l /dev/null echo Send SIGUSR1 to master process: $MASTER_PID kill -USR1 $MASTER_PID for i in $(seq 1 30); do sleep 1 if curl -fsS $HEALTH_URL /dev/null 21; then echo Reload success, health check passed after ${i}s exit 0 fi done echo ERROR: health check failed after reload, please check manually exit 1这个脚本里的健康检查接口建议你写到 Hyperf 里简单返回一个 JSON 即可不要在里面做复杂的数据库查询。健康检查的目的只是确认服务活着并能响应请求如果探活逻辑太重部署时反而容易误报。3.3 进程视角看 reload旧 Worker 到底怎么退出的我经常遇到有人发完kill -USR1后马上用日志确认结果发现服务没有任何变化于是怀疑命令执行失败。其实平滑重启不是瞬间完成的新旧 Worker 有一个重叠期。观察这个过程的正确姿势是查看进程的启动时间ps -eo pid,ppid,etime,lstart,cmd | grep hyperf重启前你看到的是一批启动时间一致的 Worker执行 reload 后旧的 Worker 会继续运行一段时间直到把当前请求处理完之后新的 Worker 启动etime会重新从 00:00 开始计时。如果 10 秒后 Worker 的启动时间没有任何变化才说明 reload 没有真正发生。另外需要提醒一个点如果你额外开启了 Task 进程SIGUSR1 重启 Worker 并不会让 Task 进程退出。但 Hyperf 的协程化模型里绝大多数任务都通过协程在 Worker 内完成Task 进程已经很少用了这一块大多数项目不需要关心。3.4 哪些代码变了必须 restart 而非 reloadreload只是让 Worker 重新执行代码路径但 Hyperf 的注解收集、代理类生成、配置绑定很多是在启动阶段完成的单纯 reload Worker 并不能完全重放这些过程。根据我的经验以下几类改动需要执行完整服务重启不要打信号硬扛修改了config/autoload/server.php改了监听端口、Worker 数量或启用了新 Server新增了注解类、新路由或新的中间件扫描结果修改了config/container.php、依赖关系有结构性变化修改了composer.json中需要 autoload 的命名空间结构升级了vendor/目录下的第三方依赖执行 restart 时如果要确保注解缓存不残留建议先清理 runtime 下的代理目录。清理时要格外小心只删除runtime/container和runtime/container/proxy内的文件不要顺手把日志或 PID 文件删了否则你可能连进程都找不到了。php bin/hyperf.php start # 确认服务正常后再执行 stop 重启或者用进程管理器来管理4. 开发环境自动热更新与配置中心动态刷新的真正边界4.1 用 inotify 实现本地开发的“改完即生效”很多人在开发 Hyperf 项目时还是手动 restart这很影响效率。我习惯在本地环境用 Linux 的 inotify 工具监听文件变化一旦发现 PHP 文件被修改就自动触发 reload。下面这段脚本的完整思路已经过验证#!/usr/bin/env bash set -euo pipefail APP_DIR/home/user/code/my-hyperf-app PID_FILE${APP_DIR}/runtime/hyperf.pid cd $APP_DIR while true; do inotifywait -r -e modify,create,delete,move app config 2/dev/null find app config -name *.php -print0 | xargs -0 -n1 php -l /dev/null 21 if [[ -f $PID_FILE ]]; then MASTER_PID$(cat $PID_FILE) kill -USR1 $MASTER_PID echo $(date %H:%M:%S) reload triggered fi done注意这段脚本只适合开发环境。原因有两个一是生产环境的代码变更应该走发布流程由 CI/CD 控制节奏而不是在服务器上被文件监听随机触发二是 inotifywait 监听到文件事件后立即做语法检查和 reload如果同事正在服务器上编辑文件很可能会导致半成品代码被执行。在 macOS 上跑这段脚本需要把inotifywait换成fswatch逻辑是一样的核心思路都是“文件事件触发信号”。我更推荐在开发环境里用 Docker 挂载目录 宿主机脚本的方式这样既能实时同步代码又能统一管理重启动作不用每个开发者各写一套工具。4.2 Nacos 集成后“热更新”的真相配置能刷新进程不一定有些团队把配置中心视为热更新的银弹接入了 Nacos 就觉得以后改配置不用重启了。这句话前半对、后半错关键要分清“配置数据热”和“业务逻辑热”的区别。Hyperf 官方提供的 Nacos 组件支持配置拉取和监听当 Nacos 服务端配置变更后Hyperf 进程能收到通知并更新 Config 仓库里的值。也就是说你执行config(database.default)时新配置在代码层面已经生效了。但真实业务场景里连接池、Redis 连接、HTTP 客户端等对象在进程启动时就已经被实例化它们的参数不会因为 Config 值的变化而自动重建。数据库连接池的连接数改了不代表连接池对象会立刻扩容。要让这类配置真正生效通常需要额外处理资源重建逻辑。以自定义配置监听为例可以监听配置更新事件后主动清理连接池?php declare(strict_types1); namespace App\Listener; use Hyperf\Event\Annotation\Listener; use Hyperf\Event\Contract\ListenerInterface; use Hyperf\Process\ProcessCollector; use Psr\Container\ContainerInterface; #[Listener] class ConfigChangeListener implements ListenerInterface { public function __construct(protected ContainerInterface $container) { } public function listen(): array { return [ \Hyperf\ConfigCenter\Event\ConfigChanged::class, ]; } public function process(object $event): void { // 这里不能简单打印日志就完事 // 需要根据变更的配置 key 决定是否重建连接池等资源 } }所以在设计“配置热更新”方案时建议把配置分成两类一类是常规的业务开关和阈值比如“列表页每页条数”“活动开关”这类改完配置立即生效体验极好另一类是基础设施参数比如 Redis 地址、数据库连接数、消息队列驱动等这类变更应当走重启流程不要强行做热更新否则容易引发半连接状态。4.3 发布窗口与网关摘流量平滑重启的另一种解法服务进程层的平滑重启解决的是“Worker 替换请求不断”但如果你在 Nginx / 负载均衡后面挂了多个 Hyperf 实例还可以用更粗暴但更安全的发布方式先摘流量、再重启、再挂回流量。具体做法是在 Nginx 的 upstream 里临时注释掉要发布的节点并执行nginx -s reload让网关不再往该节点转发新请求。此时节点上正在处理的旧请求会自然跑完等待连接都结束后再手动重启服务。这种做法对代码状态的要求最低任何形式的改动都能被安全应用因为你给了进程彻底的清理时间。这里延展到一台常见问题某些业务确实包含“无法中断”的长任务比如录音文件上传后需要写 WAV 文件的任务。如果 Worker 正处在写文件的中间状态被 reload 强制打断很容易破坏文件完整性出现 0 字节文件或头部信息丢失虽然 Swoole 的 reload_async 提升了安全性但在写文件场景中更稳妥的做法是先暂停接收新任务、等待存量任务落盘完成再执行重启。# 在管理机上摘除节点后执行 kill -SIGUSR1 master_pid # 等待一段时间确认旧进程退出后再做完整启动这种方式下单实例的平滑重启变成了“队列排空 进程替换”。把进程的退出节奏交给业务完整性约束而不是一味依赖框架层的强制等待。5. 高频问题排查实录与生产经验补全5.1 reload 后代码不生效按这个表格逐项排查这是我整理成速查表的问题清单每次“代码没生效”我都先跑一遍这些命令能省下至少半小时的排查时间。排查项排查命令问题说明Worker 是否真的重启过ps -eo pid,etime,lstart,cmd | grep hyperf启动时间没变化说明信号没生效或打错了 PID是否更新对了目录pwd git log -1经常有人在 A 环境部署、去 B 环境确认是否清理了注解缓存ls runtime/container/proxy/新增注解必须清缓存后 restartOPcache 是否开启了php --ri opcacheCLI 模式下 opcache.enable_cli 可能影响用的是 reload 还是 restart检查操作记录自定义进程等改动必须 restart健康检查接口是否正常curl 127.0.0.1:9501/health服务挂了但进程还在也可能每次我写部署总结都会强调不要在 reload 后只看业务日志一定要先看进程描述表。5.2 平滑重启时连接断掉的 3 个原因与规避方法平滑重启最怕的现象是“reload 之后客户端批量报错”。基于我遇到过的生产问题原因基本集中在三个地方。第一没有开启reload_async导致旧 Worker 被强制退出正在处理的请求直接中断。旧版本 Swoole 或没配置此选项时Worker 收到 SIGUSR1 后会中断当前协程马上退出高并发场景下就会出现一批 502。第二WebSocket 或 TCP 长连接被切断。reload 只保证 HTTP 短连接请求的自然结束对长连接并不友好因为长连接理论上可以一直保持旧 Worker 不会自行退出。Swoole 的做法是等待该 Worker 上的连接全部关闭或者超过max_wait_time后强制结束这个过程中新连接可能会被分配到其他 Worker导致业务上下文丢失。第三使用 Swoole Table 或进程内缓存存储状态的场景。平滑重启后内存数据没了业务如果按“进程内存里有数据”来设计逻辑就很容易出问题。规避方法很明确把跨进程状态统一放到 Redis给每个请求设计无状态逻辑。5.3 自定义进程、队列任务与写文件类业务的重启策略这是我看过最多人踩坑的地方。Hyperf 的自定义进程和队列消费进程不归 Master 的 Worker 池管理你的kill -USR1对它们无效。如果队列消费进程正在从 MQ 中拉消息处理代码更新了但消费进程还是老代码消息处理结果自然就不符合预期。我自己维护的服务里有一个专门处理音频转码的队列进程它的任务是接收 WAV 文件并转码。最开始做发布时我直接对整个服务执行 reload结果出现了一半文件转码成功、一半文件损坏的情况。后来我把队列进程改为独立管理版本发布前先暂停队列消费等待正在运行的转码任务全部落盘完成再停止进程、启动新代码、恢复消费。这个流程明显比“强行 reload 所有进程”更可靠。之所以单独强调这一点是因为“维护 WAV 文件零损坏”这类需求无法只靠框架层的平滑重启保证。框架能保证的是“进程优雅退出”而文件完整性还依赖业务代码里对写文件事务的控制比如先写临时文件再原子重命名、写完后 fsync、落盘完成后才确认消息消费等。你在设计平滑重启方案时一定要把这类强一致性的业务需求挑出来单独设计流程不能一刀切套用 reload。5.4 生产环境重启脚本的“失败回滚”设计原则最后谈一个脚本设计的经验。很多团队把平滑重启脚本写得像一键毁灭工具逻辑无非是“reload 后 sleep 3 秒就提示成功”。如果健康检查失败怎么办有的脚本里甚至会继续执行 restart以为重启能解决一切结果往往是雪上加霜。我的原则是自动化的边界到“健康检查失败就止步”。脚本一旦发现 reload 失败或健康检查不通过不应该自动回滚业务代码或反复重启服务而是立即告警让值班的人介入查看。原因是在进程语义不明确的情况下连续强制重启可能把正常的节点也拖下水。相比之下人工介入可以根据日志和进程状态做精准处理。可能有人会问那要不要在脚本里备份旧版本代码我的做法是代码版本交给 Git 管理部署前保证工作区干净回滚时直接git checkout previous_tag并重新执行一次发布脚本即可不需要把多个 tar 包堆在服务器上。5.5 我的实战心得把“热更新”提进开发流程把“平滑重启”留给发布用这么久 Hyperf我的最终体会是不要勉强自己追求“线上代码热更新”这种看似炫酷的能力。线上追求热更新往往是因为发布流程太重、太慢想省去重启的麻烦。但与其在 PHP 进程里做不安全的热替换不如把发布链路做轻让每次改动都能快速、自动、安全地走完。开发环境一定要热更新文件监听 reload 能极大提升调试效率测试环境建议每次代码合并触发 CI自动构建后 restart 一次保证测试环境代码永远是干净的生产环境则老老实实做灰度发布重实例不行就 reload切队列、摘流量、滚动更新都可以但每个动作都要配合健康检查。如果说有什么最需要记住的一点那就是热更新只是一个工具而平滑重启是你在生产环境维护服务底线的手段。真正专业的做法是明确什么能热、什么不能热并为“不能热”的部分设计可靠的重启路径。仅这一点想通你在 Hyperf 的运维上就不会再走弯路。
返回列表