1. 为什么需要滚动更新:从一次事故说起
先讲个真实场景。某公司的核心业务系统采用 PHP 编写,平时部署流程很简单:把代码通过 Git 拉到服务器,清掉 OpCache 缓存,就算发布完成。问题是这个系统在线用户量大,每次发布期间总有一部分请求打到正在被替换的旧代码文件上,轻则页面报 500,重则出现半个页面是旧逻辑、半个页面是新逻辑的诡异状态。最头疼的一次,发布脚本执行到一半时数据库字段已被新代码使用,而线上运行的旧代码还在按老字段名读写,结果整整二十分钟用户操作全部报错,舆情险些失控。
事后复盘,问题的本质不是代码质量,而是发布方式的缺陷。你改了代码,线上服务却是旧的,新老代码交替期间没有隔离机制。类似问题在单体 PHP 应用里特别常见,因为大家习惯性认为 PHP 是解释型语言,文件替换完就是新版本,完全没有意识到 HTTP 请求和 PHP-FPM 进程之间还有一层微妙的关系。
滚动更新(Rolling Update)就是为了解决这类零停机发布问题而生的方案。它的核心思路很简单:不一次性替换所有运行实例,而是分批、逐个地把旧版本替换成新版本,每一批替换完成之后都先验证健康状态,再继续下一批。整个过程对外提供服务的能力始终保持,用户请求始终打到能正常响应的实例上。
这套思路在 Kubernetes 和 Java 微服务里早就玩得很熟了,但到了 PHP 单体应用这边,很多人还停留在“上传、清缓存、完事”的阶段,或者觉得滚动更新是容器编排才会用到的东西。这篇文章想说的是:PHP 项目同样可以做滚动更新,而且不一定需要引入 Kubernetes 这么重的方案。我会从原理拆解、方案设计、工具选型到实操细节全部过一遍,把我在生产环境里踩过的坑和验证过的做法都写出来。
适合谁看?主要有三类读者:一是被发布事故折磨过的 PHP 后端开发者,二是正在从单机部署走向集群部署的运维工程师,三是接手的项目还在用最原始的 rsync 发布方式、想往正规化方向推一步的技术负责人。
2. 先搞明白:PHP 滚动更新的底层原理
2.1 为什么普通发布会导致请求失败
要理解滚动更新,先得理解 PHP 应用的请求生命周期。假设你用 Nginx + PHP-FPM 的经典组合,一个 HTTP 请求进来以后,Nginx 根据 location 规则把.php结尾的请求转发给 PHP-FPM 处理,PHP-FPM 里的 worker 进程去磁盘上读取对应的 PHP 文件,执行完返回结果。
问题就出在“读取文件”这一步。如果你用 rsync 或者 Git 直接覆盖线上代码,同一时刻可能出现这样的情况:worker A 正在执行UserService.php,但这个文件刚被 rsync 替换成了新版本;worker A 的 opcode 缓存里可能还拿着旧版本的字节码。更糟的是,如果你的发布是多个文件逐一覆盖,那么某一瞬间线上代码可能是“半个新、半个旧”的混合状态,两个版本如果依赖不同的函数签名或数据库字段,请求就会直接报致命错误。
还有一类隐藏问题:PHP-FPM 的 OpCache。如果你不清缓存,旧进程可能一直用旧字节码执行很久,导致新代码根本没生效;如果清了缓存,所有 worker 又会同时重新编译所有 PHP 文件,造成短暂的 CPU 飙高和响应变慢。
2.2 滚动更新的核心机制
滚动更新的模型可以抽象成三个关键词:副本、批次、健康检查。
副本指的是同一套应用代码跑在多个可互换的实例上。这些实例可以是一台台独立的服务器,也可以是同一台机器上的多个 PHP-FPM 池子,甚至可以是一组 Docker 容器。副本的意义在于:任何一个副本被替换时,其他副本仍然在对外提供服务,这就是“不中断服务”的基本保障。
批次是滚动更新的节奏控制。比如你有 5 个副本,更新策略是最多同时替换 1 个,那么每次只有一个副本处于“旧代码下线、新代码上线”的状态,替换完成后等健康检查通过,再换下一个。这个“同时替换数量”就是滚动更新的关键参数,控制得越保守,发布过程越安全,但耗时也越长;控制得激进,发布快,但风险也高。
健康检查则是整个流程的阀门。每一次替换完一个副本,必须通过某种方式确认新副本真的能正常处理请求,才算这一批完成。健康检查不是简单 ping 一下服务器 IP 就算完事,应该尽可能模拟真实请求,比如请求一个特定的内部接口,检查返回的 JSON 是否包含预期字段,或者请求一个需要连接数据库、Redis 的接口,验证全链路都是通的。
2.3 PHP 场景的三个特殊关注点
PHP 应用做滚动更新,跟 Java、Go 这类常驻进程服务有三个显著差别。
第一,PHP 没有常驻业务进程,每个请求的生命周期都是“加载执行、用完即弃”。这对滚动更新其实是有利的,因为没有长连接状态需要迁移。Java 应用做滚动更新时,需要处理旧实例上还在跑的任务、等待优雅停机;PHP 只要保证新请求不进入旧实例,旧实例处理完手头请求后自然就可以下线了。
第二,PHP 共享代码目录。很多 PHP 部署是把代码放在 NFS 或者服务器本地共享目录里,多个 Web 节点读同一份代码。这时“滚动”的对象是 Web 服务器,而不是代码本身。但如果你滚动的是代码目录符号链接,就需要小心 PHP-FPM 的当前工作目录问题,尤其是在代码里用了__DIR__或相对路径的场景。
第三,PHP 的状态存储。PHP 默认会话文件存在本地磁盘,如果你做多副本部署,Session 必须外置到 Redis 或数据库,否则用户请求被负载均衡路由到不同副本时,登录状态就丢了。这个问题在我接触过的很多 PHP 项目里都存在,属于滚动更新前必须先行解决的前置条件。
3. 方案选型:不引入 Kubernetes 的轻量滚动更新
3.1 方案一:基于 PHP-FPM 多池的本地滚动
如果你的应用只跑在一台服务器上,但 PHP-FPM 配置了多个运行实例,可以在同一台机器上做“伪滚动更新”。思路是配置两个 PHP-FPM 池,分别监听不同的端口,比如 pool_a 监听 9000 端口,pool_b 监听 9001 端口,Nginx 里把同一个域名的 PHP 请求分流到两个 upstream 组。
发布流程是这样:假设当前流量全部打到 pool_a,你要发布新版本。先启动 pool_b,把新代码准备好,让 pool_b 以新代码运行。然后修改 Nginx 配置,把 upstream 的优先级逐渐从 pool_a 切到 pool_b。你可以在 Nginx 里配一个 upstream 块,里面写两个 server 条目,用 weight 参数控制流量比例,先让 10% 的请求打到 pool_b,观察一段时间没有报错,再逐步提高到 50%、100%。全部切完之后,清理 pool_a 的旧进程和旧代码。
这个方案的优点是轻量,不需要额外的编排系统,一台机器就能操作。缺点是单机单点,服务器本身部署不了,而且切换过程中如果 Nginx reload 失败,可能导致所有请求瞬间断开。所以这个方案适合早期规模不大、但对发布连续性有要求的场景,算是一个过渡形态。
3.2 方案二:符号链接版本切换
这是很多 PHP 项目采用的一种简化滚动更新:代码目录用符号链接指向一个带版本号的目录。比如/var/www/htdocs/current是一个软链接,指向/var/www/releases/20241012_rel_001,发布新版本时先创建/var/www/releases/20241012_rel_002,在里面把代码放好,安装依赖、跑完迁移脚本,然后把软链接原子性地切换到新目录。
这个方案的巧妙之处在于,软链接切换是原子操作,一瞬间完成,不存在半新半旧的文件混合状态。配合 PHP-FPM 的 OpCache 清理,基本可以实现秒级切换。
但这里有个隐藏细节:Nginx 的root指令和 PHP-FPM 的open_basedir可能引用的是旧的软链接路径,你在切换软链接之后还要 reload Nginx 和 PHP-FPM。reload 不是原子性的,期间可能有请求使用旧配置、部分 worker 用新配置,不过只要旧目录在短时间内不被删除,这个问题影响不大。实操上我们可以让旧版本目录保留一段时间,比如保留 5 个历史版本,切换后等待 15 分钟确认无回滚需求,再清理过期目录。
3.3 方案三:多服务器 + 负载均衡滚动
当业务量上到多台服务器,我们就需要真正意义上的多副本滚动更新了。典型架构是:多台 Web 服务器跑相同的 PHP 代码,前面挂一个负载均衡器(Nginx、HAProxy 或者云厂商的 SLB),负载均衡器后端挂多个 Web 服务器节点。
滚动更新流程变成:从负载均衡器摘掉节点 A → 节点 A 上更新代码 → 节点 A 本地验证 → 重新挂回节点 A → 摘掉节点 B → 重复操作。这个流程可以用脚本半自动化,也可以借助 Ansible 这类运维工具实现。
摘掉节点后,负载均衡器不会再向该节点转发新请求。由于 PHP 请求都是短生命周期,节点上正在处理的请求最多几秒钟就会结束,所以不需要像 Java 那样等很长时间做优雅停机。你在摘除节点后稍等几秒,再执行代码更新,基本不会丢失任何未完成的请求。
这个方案最大的价值是:它把“发布”从一种高风险操作变成了可逆操作。如果新代码在节点 A 上有问题,直接在负载均衡器层面把节点 A 摘掉,问题就被隔离在单个节点上,不会影响全局。等修复后再重新挂回来,或者直接触发回滚恢复旧代码。
3.4 方案四:借助容器和编排系统
如果你的团队已经在推进容器化,PHP 应用打包成 Docker 镜像后,滚动更新可以直接依托编排系统实现。比如用 Docker Compose 的--scale参数也可以做出简化版的滚动更新效果,或者用 Kubernetes 的 Deployment 机制,天然支持滚动更新策略,定义好maxSurge和maxUnavailable参数即可。
这个方向的滚动更新最标准、最自动化,但学习成本和运维成本也最高。对于中小团队,如果只是 PHP 应用发布,我不建议一上来就上 Kubernetes。先把手动滚动流程跑顺,理解清楚健康检查、流量切换、回滚机制这些基本概念,再考虑迁移到更重的平台,是更务实的路径。
我个人的建议是,大多数 PHP 团队先做方案二或方案三,也就是符号链接切换或者多服务器滚动,这两个方案已经能覆盖 90% 的零停机发布需求。方案一作为过渡,方案四留到团队规模确实需要容器化时再走。
4. 实操:多服务器 + 符号链接的滚动更新完整落地
4.1 目标架构
下面是我在实际项目中验证过的一套做法,结合了“符号链接版本切换”和“多服务器负载均衡滚动”两种思路。假设有两台 Web 服务器,即节点 A 和节点 B,前置一台 Nginx 做负载均衡。
目录结构统一如下:
/var/www/app/ releases/ release_20250101_1000/ release_20250101_1200/ current -> release_20250101_1000current是指向当前线上版本的软链接。Nginx 配置里的root指向/var/www/app/current/public,PHP-FPM 的chdir也可以配置为/var/www/app/current。
4.2 前置条件准备
Session 外置。这是最容易被忽略的一步。如果你的 PHP 应用还在用默认的session.save_path存 Session 文件,那么滚动更新后用户会话必然丢失。解决办法是把 Session 存到 Redis,PHP 配置改成:
session.save_handler = redis session.save_path = "tcp://127.0.0.1:6379?prefix=app_session:"数据库、Redis 等中间件建议单独部署,不要和应用代码混在同一个发布单元里。它们属于“状态层”,不应该随代码一起滚动,否则新代码和旧代码会同时访问结构不一致的数据。
共享代码仓库。两台 Web 服务器建议从同一个 Git 仓库拉取代码,保证源码一致。代码包通过 CI 流水线打包成压缩文件,或者用 Git Tag 管理版本。发布时不要在服务器上手动改代码,一切以仓库内容为准。
健康检查接口。每个节点需要提供一个健康检查 URL,比如/health_check.php,内容要模拟真实业务链路:
<?php /** * Health check endpoint. * Checks database connection, redis connection, and app version. */ try { $db = new PDO('mysql:host=...;dbname=...', 'user', 'pass'); $db->query('SELECT 1'); $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $redis->ping(); echo json_encode([ 'status' => 'ok', 'version' => $version, 'timestamp' => time(), ]); } catch (Throwable $e) { http_response_code(500); echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); }4.3 发布脚本核心逻辑
我写过一个 Bash 脚本,核心流程如下,可以供你参考改造:
#!/usr/bin/env bash set -euo pipefail # Configuration APP_NAME="my_php_app" RELEASE_DIR="/var/www/${APP_NAME}/releases" CURRENT_LINK="/var/www/${APP_NAME}/current" WEB_USER="www-data" HEALTH_URL="http://127.0.0.1/health_check.php" NEW_RELEASE="${RELEASE_DIR}/release_$(date +%Y%m%d_%H%M%S)" # Step 1: Create new release directory mkdir -p "${NEW_RELEASE}" # Copy code from git git archive --format=tar HEAD | tar -x -C "${NEW_RELEASE}" # Step 2: Install dependencies cd "${NEW_RELEASE}" composer install --no-dev --optimize-autoloader --no-interaction # Step 3: Switch symlink ln -sfn "${NEW_RELEASE}" "${CURRENT_LINK}" # Step 4: FPM restart systemctl reload php8.1-fpm先别直接用,这份脚本只是骨架,实际生产里还需要加上健康检查和旧版本清理两个环节。完整的流程应该是:
- 构建新版本目录,拉取代码,安装依赖,跑好数据库迁移脚本(注意迁移脚本要在切换软链接之前执行,这样新代码一上来数据库结构就是匹配的)。
- 在节点 A 上执行软链接切换。
- 清空 PHP-FPM 的 OpCache。可以用
cachetool工具,或者直接 reload PHP-FPM。 - 本地请求一遍健康检查接口,确认版本号已经是新版本,数据库连接正常。
- 把节点 A 挂回负载均衡,同时摘掉节点 B。
- 在节点 B 上重复步骤 2 到 4。
- 确认节点 B 健康后挂回,全部节点完成更新。
4.4 Nginx 负载均衡配置
Nginx 作为负载均衡器的配置示例:
upstream php_backend { server 192.168.1.10:80 weight=1 max_fails=2 fail_timeout=60s; server 192.168.1.11:80 weight=1 max_fails=2 fail_timeout=60s; } server { listen 80; server_name example.com; location / { proxy_pass http://php_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }摘除节点时,把对应的server行注释掉,然后执行nginx -s reload。需要注意,reload 之前一定要先nginx -t测试配置语法,避免配置文件写错导致 reload 失败,造成服务中断。
我在实际操作中习惯用weight=0而不是注释掉节点,因为注释代码有风险,回滚时不方便。更常见的做法是直接把上游节点从 upstream 块临时移除,但要记得保存备份。
4.5 健康检查的完整状态机
健康的滚动更新不应该靠人眼观察,也不应该靠“感觉差不多就行”。建议把健康检查设计成三个层次:
第一层是基础探活:检查服务器端口是否响应,进程是否存活。这一层只能发现机器挂了,发现不了业务异常。
第二层是应用探活:请求健康检查接口,检查 HTTP 状态码是否为 200,响应内容里的版本号是否符合预期。这一层能发现数据库连接失败、依赖缺失等问题。
第三层是链路探活:模拟真实用户的完整操作链路,比如登录、查询列表、提交表单。这一层最能发现问题,但实现成本也最高。对于关键应用,建议至少保留一个核心链路探活脚本。
发布脚本里的健康检查可以写成:
#!/usr/bin/env bash check_health() { local url="$1" local expected_version="$2" for i in $(seq 1 10); do response=$(curl -s -o /dev/null -w "%{http_code}" "$url") if [ "$response" = "200" ]; then version=$(curl -s "$url" | python3 -c "import sys,json; print(json.load(sys.stdin)['version'])") if [ "$version" = "$expected_version" ]; then echo "Health check passed" return 0 fi fi echo "Health check attempt $i failed, retrying..." sleep 3 done echo "Health check failed after 10 attempts" return 1 }这里有个细节:健康检查的版本号必须和实际部署的代码版本对应,才能在检查时确认当前服务的确实是新版本,而不是之前的旧版本。发布时把新版本号作为参数传给脚本,检查返回的版本号是否匹配来实现。
5. 回滚:滚动更新的安全气囊
5.1 回滚的正确时机
滚动更新做得再仔细,也难免有线上问题漏网。回滚能力不是“出了事以后再说”的备选项,而是滚动更新方案的必备组成部分。你可以不做滚动更新,但只要做了自动化发布,就必须有自动回滚机制,否则排查问题的压力全堆在午夜改代码上。
什么时候该回滚?我给一个可操作的判断标准:新版本上线的健康检查没通过,或者上线后监控发现错误率超过基线 20% 以上、持续 5 分钟没有回落,就应该启动回滚。这里的基线需要根据业务波动提前算好,不能拍脑袋定阈值。
5.2 符号链接回滚操作
由于我们的部署是符号链接结构,回滚变得异常简单:只需要把current软链接指回上一个版本目录,然后 reload PHP-FPM 即可。整个操作跟发布一模一样,区别只是目标版本变了。
ls -la /var/www/app/releases/ ln -sfn /var/www/app/releases/release_20250101_1000 /var/www/app/current systemctl reload php8.1-fpm需要注意的是,回滚前新版本的日志和临时文件可能还在,这些不会影响回滚后的运行,但会占磁盘空间。回滚后不要急着删除新版本目录,留几天,等确认旧版本稳定了再清理。
5.3 数据库回滚问题
这是 PHP 滚动更新里最棘手的问题。如果新版本代码里加了数据库字段,或者改了表结构,旧版本代码可能无法正常工作。比如新版本在users表加了nickname字段,旧版本代码的INSERT语句没有这个字段,但数据库约束要求必须有值,就会导致回滚后数据库写入失败。
解决思路有几种。一是数据库变更向前兼容,比如新增字段允许为空,删除字段改为软保留;二是回滚脚本里同时执行反向迁移,把表结构恢复到旧版本状态。反向迁移需要额外维护一套脚本,在回滚时执行。我的经验是:数据迁移一旦执行,尽量不要做结构回滚,更多是回滚代码、保留新表结构,然后通过代码兼容层处理**。这要求新代码在访问新字段时做好空值兜底,旧代码不会因为新表结构而直接报错。
6. 常见问题与排查技巧实录
6.1 OpCache 不生效的问题
明明已经切换了代码目录,但线上跑的还是旧逻辑。这个问题的元凶基本都是 OpCache。PHP-FPM 的 OpCache 默认开启了opcache.validate_timestamps=1,但很多人为了性能会把它关掉。一旦关掉,PHP-FPM 进程会永久缓存第一次加载的字节码,即使文件内容变了也不会重新编译。
解决办法是发布后执行 OpCache 清理。清理工具有很多,比如cachetool opcache:reset,或者你自己写一个内部接口调用opcache_reset()。但要特别注意,如果你有多个 PHP-FPM 池,需要每个池的 master 进程都执行一次清理;如果服务器不止一台,每台都要执行。
6.2 软链接切换后 PHP 报错找不到目录
这是因为 PHP-FPM 的chdir或者 Nginx 的root指向了旧路径。排查这一类问题的做法是:
- 查看 PHP 配置文件,确认
chdir是否指向current软链接,而不是具体的版本目录。 - 如果代码里用了
__DIR__,注意它解析的是当前执行文件的真实路径,不是软链接路径。如果你的代码把缓存文件写到了基于__DIR__的路径,那么切换版本后缓存目录也跟着变了,理论上没问题,但要注意目录权限。
6.3 负载均衡摘除节点后仍有请求进入
存在两种情况。一种是云负载均衡器的会话保持机制,摘除节点后已有连接可能还会维持一段时间;另一种是客户端长连接,比如 WebSocket 或者 keep-alive 连接。解决方式是在健康检查层面做更精细的控制,或者发布窗口选择在低峰期执行。
6.4 Session 频繁掉线的排查
滚动更新后用户登录状态频繁丢失,大概率是 Session 存储在本地文件。多服务器 + 负载均衡场景下,用户第一次请求打到节点 A,登录状态存在节点 A 的/var/lib/php/sessions里。第二次请求被负载均衡转发到节点 B,节点 B 没有这个 Session 文件,于是认为用户未登录,强制跳回登录页。
处理方法就是前面说的,把 Session 存到 Redis。改完配置后要确认php.ini里的session.save_path正确、Redis 服务正常,并在健康检查接口里包含 Redis 连接测试。
6.5 发布过程中错误率变高的定位技巧
如果滚动更新过程中监控发现错误率异常,先不要急着把剩下的节点继续更新,而要先查看当前正在更新的节点上的日志。利用 PHP-FPM 的 slow log 和error_log结合请求 ID 追踪具体的失败链路。如果是框架类应用,比如 Laravel 或 ThinkPHP,日志里通常有完整的请求上下文,定位会快很多。
一个实用的定位方法是:在负载均衡器层面配置一个临时规则,把测试流量只指向刚更新完的节点,直接对该节点做真实业务验证。这样既不影响线上全局,又能快速复现问题。等排查完,重新把该节点挂回负载均衡。
7. 落地滚动的几条实战心得
滚动更新听上去是个运维技术,但真正落地时,决定成败的往往是发布流程和工程规范。
第一,版本号必须贯穿整个发布链路。代码仓库要用 Tag 标识版本,发布目录用版本号命名,健康检查接口返回版本号,监控系统以版本号做维度。没有版本号,你连“当前线上跑的是哪一版代码”都说不清楚,回滚时更抓瞎。
第二,发布和回滚都要自动化。手动发布偶尔成功是运气,自动化发布是常态。把发布脚本写进 CI/CD 流水线,把回滚命令录成文档加执行脚本。人只做决策——确认发布、确认回滚,不执行重复操作。
第三,每次发布前先演练回滚。我个人的习惯是,每次重大版本发布前,先在测试环境完整跑一遍发布脚本和回滚脚本,确认新版本在测试环境通过、旧版本在回滚脚本下也能正常运行。这能提前暴露很多“到生产才会现形”的问题。
第四,关注数据库变更的前向兼容。这是 PHP 滚动更新里最大的技术债。每次上线数据库变更,我会专门花时间检查旧代码在“新结构”下是否存在读写失败的可能。如果存在,就要先规划好兼容层,再上线新代码。
第五,监控要有版本维度。光有服务器层面的 CPU、内存监控还不够,要把应用错误率、慢请求数量、接口响应时间这些指标按版本聚合。这样发布后只要看一眼监控面板,立刻知道新版本有没有引入性能退化或功能异常。
最后再说一个经常被忽略的细节:发布窗口要安排在业务低峰期,但回滚演练要在高峰期模拟。低峰期发布是因为滚动更新虽然能保证服务不中断,但不能保证新代码业务逻辑一定正确,低峰期问题暴露时影响面最小。而回滚演练选择高峰期模拟,是因为高峰期流量大,最接近真实故障场景,你才能真正验证负载均衡摘除节点、健康检查、流量切换这些机制在压力下是否可靠。