前言
绝大多数团队第一次上线 PHP 应用时,部署脚本是这样的:git pull && composer install && chmod -R 777 storage。它能工作,但一旦出事,回滚就只有一条路可走——git revert再跑一次完整流程。于是你会见到这些症状:
- 出事时手忙脚乱,回滚一次要重新构建、重新装依赖,五到十五分钟过去,故障还在扩大;
- 回滚完成、代码确实回到旧版本了,但页面还是报新代码的错——opcache 里还留着上一版的字节码;
- 回滚脚本跑到一半失败,代码目录处于「一半旧、一半新」的状态,比不回滚还糟;
- 代码回滚了,可数据库迁移已经把字段删掉了,旧代码一读就
Unknown column; - 回滚完老用户开始报
__PHP_Incomplete_Class——session 里存的是新版本类的序列化对象。
这些症状指向同一个结论:回滚不能是「反向执行一遍部署」,而必须是一次原子切换。也就是发布时把新版本挂上去、出问题时把指针切回旧版本,整个过程只有一个瞬间在变,不涉及任何重新构建。
本文以PHP 8.5+ php-fpm 为例,搭一套「不可变产物 + 软链接原子切换 + 健康检查自动回滚」的机制。它对 PHP 版本本身没有依赖,8.0 到 8.5 都适用;文中会顺带指出几个 PHP 独有的回滚陷阱。
一、回滚的本质:把「切换」做成原子的
传统部署是「就地覆盖」:把文件rsync到同一个目录。这个过程必然有一段不完整的时间窗口,而且是不可逆的——旧文件已经被覆盖了。
改用版本化发布目录 + 符号链接:
/srv/app/ ├── releases/ │ ├── 20260920-3f9a1c08b2d4/ ← 每次发布一个新目录,永不修改 │ ├── 20260924-8c1b7e02aa91/ │ └── 20260929-a41d9f3c7e55/ ← 当前线上版本 ├── shared/ │ └── storage/ ← 上传文件、日志,所有版本共享 ├── state/ │ └── previous ← 记录上一个版本,供回滚使用 └── current -> releases/20260929-a41d9f3c7e55nginx / php-fpm 的root指向/srv/app/current/public,回滚就是把current这个软链接指回旧目录。旧目录还在原地、内容一字未改,所以回滚不存在「构建失败」这种可能。
有一个细节必须小心:ln -sfn不是原子操作。它会先 unlink 再 symlink,中间存在一个current不存在的瞬间,正好被请求撞上就是 500。正确做法是利用同文件系统下的rename(2)原子性:
ln -sfn "$TARGET" /srv/app/.current.tmp mv -T /srv/app/.current.tmp /srv/app/current # GNU coreutils 的 -T:直接替换链接本身mv -T把目标当成普通文件替换,底层就是rename(),内核保证原子性。前提是.current.tmp和current在同一文件系统,所以临时链接必须放在/srv/app/里,不能放/tmp。
| 部署方式 | 切换是否原子 | 回滚耗时 | 回滚是否依赖重新构建 |
|---|---|---|---|
就地覆盖(git pull) | 否,有明显窗口 | 一次完整构建 | 是 |
| 双目录互切(手动) | 否,容易漏配 | 快 | 否 |
版本目录 +ln -sfn | 有极短窗口 | 快 | 否 |
版本目录 +mv -T | 是 | 秒级 | 否 |
| 蓝绿 / 容器滚动 | 是 | 取决于流量切换 | 否 |
二、让 PHP 真的「换掉代码」:opcache、fpm 与 session
这是 PHP 部署和 Java/Go 最大的不同:文件换了,进程不一定换。php-fpm 是常驻进程,opcache.validate_timestamps=0时它根本不会去 stat 文件,所以你切完软链接,它还在跑旧字节码。
生产环境的推荐组合是:
; 生产:字节码只在进程启动时读一次,性能最好 opcache.validate_timestamps=0 opcache.enable=1 opcache.memory_consumption=192 opcache.max_accelerated_files=20000配上它,每次发布和回滚都必须让 php-fpm 重新加载,否则回滚等于没做:
# 优雅重启:先接新连接,处理完存量请求再退出旧 worker kill -USR2 "$(cat /run/php/php8.5-fpm.pid)"如果进程是用 systemd 或容器管理的,用对应手段:
systemctl reload php8.5-fpm # 等价于发 SIGUSR2 docker compose exec php kill -USR2 1 # 容器内 PID 1 是 php-fpm 主进程时注意opcache_reset()不能替代重启。它清的是共享内存里的字节码,而每个 fpm worker 进程还有自己独立的realpath_cache,缓存着「文件路径 → 真实路径」的映射。软链接切换后,旧 worker 的 realpath 缓存可能仍指向旧目录,于是出现「一半请求新、一半请求旧」的诡异现象。
session 是第二个 PHP 特有的坑。如果用了文件或 Redis 存 session,session 里往往是序列化后的对象。代码回滚后类结构变了,反序列化出来会变成__PHP_Incomplete_Class或者属性缺失的幽灵对象,报错信息极其隐晦。对策是:
- session 里只存标量和数组,不存对象;
- 或者让 session 数据结构向后兼容:新字段用
??兜底,回滚后旧代码读得到但不会崩。
三、代码能回滚,数据和队列不一定能
| 变更类型 | 能否回滚 | 正确做法 |
|---|---|---|
| PHP 代码 | 能,秒级 | 版本目录 + 软链接切换 |
配置(.env) | 能 | 配置随 release 目录一起版本化,别放成全局单份 |
| 静态资源 | 能 | 文件名带 hash,或用独立 CDN 目录 |
| 数据库新增列/表 | 能 | 只加不删,旧代码忽略新列即可 |
| 数据库删列/改类型 | 不能 | 用「扩展—收缩」两阶段:本次只加新列、双写,下个版本再删旧列 |
| 队列里的任务 | 视情况 | payload 里带schema_version,消费者按版本分派 |
| 已推送给第三方的事件 | 不能 | 回滚不了,只能靠幂等 + 补偿 |
数据库这里的关键词是expand/contract(扩展—收缩):任何一次发布都只能做「兼容新旧两版代码」的迁移。加字段可以立刻加;删字段必须等到「确认没有任何一版代码还在用它」之后,单独找一个发布做。这条纪律比任何回滚脚本都重要,因为再好的脚本也回滚不了一次DROP COLUMN。
四、完整可用的部署与回滚脚本
/srv/app/bin/deploy.sh,负责接收产物、原子切换、健康检查、失败自动回滚:
#!/usr/bin/env bash set -Eeuo pipefail BASE=/srv/app RELEASES="$BASE/releases" CURRENT="$BASE/current" STATE="$BASE/state" HEALTH_URL="http://127.0.0.1:8080/healthz" KEEP=5 RELEASE_ID="${1:?用法: deploy.sh <release-id> <artifact.tar.gz>}" ARTIFACT="${2:?缺少产物包路径}" TARGET="$RELEASES/$RELEASE_ID" [[ -e "$TARGET" ]] && { echo "release 已存在: $RELEASE_ID" >&2; exit 1; } mkdir -p "$RELEASES" "$STATE" "$BASE/shared/storage" # 1) 解到临时目录,解包失败不会污染 releases STAGE="$RELEASES/.staging-$RELEASE_ID" rm -rf "$STAGE" mkdir -p "$STAGE" tar -xzf "$ARTIFACT" -C "$STAGE" # 2) 挂上共享目录(上传文件、日志跨版本共用) ln -sfn "$BASE/shared/storage" "$STAGE/storage" # 3) 解包完成后再改名到最终目录,保证 releases 下只有完整版本 mv -T "$STAGE" "$TARGET" # 4) 记下当前版本,作为回滚目标 CUR="$(readlink -f "$CURRENT" 2>/dev/null || true)" [[ -n "${CUR:-}" && "$CUR" != "$TARGET" ]] && printf '%s\n' "$CUR" > "$STATE/previous" # 5) 原子切换 ln -sfn "$TARGET" "$BASE/.current.tmp" mv -T "$BASE/.current.tmp" "$CURRENT" reload_fpm # 6) 健康检查失败自动回滚 if ! wait_healthy 10; then echo "健康检查失败,自动回滚" >&2 "$BASE/bin/rollback.sh" || true exit 1 fi prune_old_releases echo "部署完成: $RELEASE_ID"配套的函数与回滚脚本:
reload_fpm() { local pidfile=/run/php/php8.5-fpm.pid if [[ -f "$pidfile" ]]; then kill -USR2 "$(cat "$pidfile")" else systemctl reload php8.5-fpm fi } wait_healthy() { local tries="${1:-10}" for ((i = 0; i < tries; i++)); do if curl -fsS --max-time 2 "$HEALTH_URL" >/dev/null; then return 0 fi sleep 2 done return 1 } prune_old_releases() { # 保留最新 KEEP 个版本,其余删除(current 指向的那个必然在最新的里面) ls -1dt "$RELEASES"/*/ 2>/dev/null | tail -n +$((KEEP + 1)) | xargs -r rm -rf }rollback.sh只做一件事:把指针切回去,然后重载。
#!/usr/bin/env bash set -Eeuo pipefail BASE=/srv/app PREV="$(cat "$BASE/state/previous" 2>/dev/null || true)" if [[ -z "${PREV:-}" || ! -d "$PREV" ]]; then echo "没有可回滚的版本" >&2 exit 1 fi CUR="$(readlink -f "$BASE/current")" # 交换:当前版本变成下一次的回滚目标,回滚可以来回切 printf '%s\n' "$CUR" > "$BASE/state/previous" ln -sfn "$PREV" "$BASE/.current.tmp" mv -T "$BASE/.current.tmp" "$BASE/current" kill -USR2 "$(cat /run/php/php8.5-fpm.pid)" 2>/dev/null || systemctl reload php8.5-fpm echo "已回滚到: $PREV"健康检查接口本身要够「实」——只返回{"ok":true}是没用的,它应该至少验证「能连上数据库」和「能读到当前版本号」:
<?php declare(strict_types=1); // 最低 PHP 8.0;建议 PHP 8.5 header('Content-Type: application/json; charset=utf-8'); $version = trim(@file_get_contents(__DIR__ . '/../RELEASE') ?: ''); try { $pdo = new PDO( dsn: 'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4', username: 'app', password: getenv('DB_PASSWORD') ?: '', options: [PDO::ATTR_TIMEOUT => 2, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], ); $pdo->query('SELECT 1'); } catch (Throwable $e) { http_response_code(503); echo json_encode(['ok' => false, 'error' => 'db'], JSON_UNESCAPED_UNICODE); exit; } echo json_encode(['ok' => true, 'version' => $version], JSON_UNESCAPED_UNICODE);CI 侧的 GitHub Actions 写成「构建」和「部署」两个 job,产物在构建阶段生成一次,部署阶段只是搬运:
name: ci-cd on: push: branches: [main] # 同一时间只允许一个部署,避免两个发布互相踩脚 concurrency: group: production-deploy cancel-in-progress: false jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: '8.5' extensions: mbstring, intl, pdo_mysql, opcache coverage: none - name: 安装依赖 run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader - name: 平台需求自检(缺扩展时这里就会红) run: composer check-platform-reqs - name: 语法检查 run: find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 -P4 php -l - name: 单元测试 run: vendor/bin/phpunit - name: 写入版本号并打包 run: | echo "${GITHUB_SHA}" > RELEASE tar -czf release.tar.gz --exclude=.git --exclude=tests --exclude=node_modules . - uses: actions/upload-artifact@v4 with: name: release path: release.tar.gz retention-days: 7 deploy: needs: build runs-on: ubuntu-latest environment: production # 可在这里配置人工审批 steps: - uses: actions/download-artifact@v4 with: name: release - name: 推送到服务器并原子切换 run: | scp release.tar.gz deploy@app-prod:/srv/app/incoming/ ssh deploy@app-prod "/srv/app/bin/deploy.sh '${GITHUB_SHA:0:12}' /srv/app/incoming/release.tar.gz"常见坑点
- ❌ 用
ln -sfn 新目录 current直接切
✅ln -sfn "$T" .current.tmp && mv -T .current.tmp current,靠rename(2)保证原子性
- ❌ 回滚脚本只切软链接,不重启 php-fpm
✅ 生产开了opcache.validate_timestamps=0时,必须kill -USR2重载,否则跑的还是旧字节码
- ❌ 以为
opcache_reset()就够了
✅ 每个 worker 还有独立 realpath cache,跨软链接的场景必须重启进程才干净
- ❌ 把所有发布解的包都留在服务器上也从不清理,磁盘最终被写满
✅ 保留最近 N 个版本,ls -1dt | tail -n +$((N+1)) | xargs -r rm -rf,且务必确认 current 指向的版本不在删除列表里
- ❌ 迁移和代码一起发,这次
DROP COLUMN、下次ADD COLUMN
✅ 一律「扩展—收缩」:先双写兼容、下一版再收缩;DROP永远单独发一次
- ❌ 把
.env放在/srv/app/.env这种全局单份位置
✅ 配置随 release 目录版本化,回滚时代码和配置一起回;密钥用环境变量注入的,回滚流程里也要把它一起切
- ❌ 健康检查只判断进程存活或返回 200
✅ 检查项要覆盖数据库连接、缓存连接、以及当前版本号,否则「切过去了但没生效」这种情况发现不了
- ❌ 部署脚本没有
set -Eeuo pipefail,中途失败后残留半截目录,脚本继续往下跑
✅ 加上set -Eeuo pipefail,并让解包先落到.staging-*临时目录,成功后才mv -T到最终目录
- ❌ session 里存了完整对象,回滚后满屏
__PHP_Incomplete_Class
✅ session 只放标量与数组;必须放对象时保证结构向后兼容
总结
| 环节 | 关键手段 | 为什么必须这样 |
|---|---|---|
| 产物 | 不可变 release 目录,一次构建多处使用 | 回滚不需要重新构建,没有失败可能 |
| 切换 | ln -sfn+mv -T | 借助rename(2)做到零窗口 |
| 生效 | kill -USR2重载 php-fpm | opcache 与 realpath cache 不随文件变化 |
| 兜底 | 健康检查失败自动调 rollback | 人反应不过来,脚本可以 |
| 数据 | expand/contract,删列单独发 | 代码能回滚,DROP COLUMN不能 |
| 并发 | concurrency串行化 + 部署锁 | 两次发布交叉执行会互相覆盖指针 |
| 验证 | 健康检查返回版本号 | 确认「切过去的」真的是「在跑的」 |
回滚机制的价值不在回滚本身,而在于它让「发布」这件事从不可逆变成可逆。做到这一点只需要三件事:产物不可变、切换原子化、进程必须重载。其余的全是围绕这三点的工程细节;而唯一真正无法回滚的是已经落地的数据变更,所以迁移策略必须比部署脚本更保守。