生产环境平滑升级实战-Nginx维护页数据库迁移与安全回滚


title: 一次可回滚的生产环境升级:Nginx 维护页、数据库迁移与安全开闸

date: 2026-07-14

description: 一套适用于前后端分离项目的生产环境升级方法,覆盖文件开关式维护页、公开 API 停写保护、MySQL 新库迁移、版本化 DDL、验收、回滚与操作留痕。

tags:

  • Nginx
  • MySQL
  • Jenkins
  • DevOps
  • 生产环境
  • 数据库迁移
    categories:
  • 运维实践

一次可回滚的生产环境升级:Nginx 维护页、数据库迁移与安全开闸

生产环境升级最危险的时刻,通常不是复制前端文件或替换后端 JAR,而是数据库结构已经改变、用户请求仍在写入、旧库和新库开始产生数据分叉,却没有明确的停止条件和回滚边界。

本文整理一套我在前后端分离项目中实际采用过的上线方法。它适用于下面这类场景:

  • 前端是 Vue、React 等单页应用,由 Nginx 托管静态文件;
  • 后端是 Java、Go、Node.js 等常驻服务,由 Nginx 反向代理;
  • 数据库是 MySQL,需要从旧版本结构升级到新版本结构;
  • 项目通过 Jenkins 或其他 CI/CD 工具构建;
  • 上线时允许一个可控的短维护窗口;
  • 希望旧系统始终保留,出现问题可以快速恢复。

这套方案的核心不是"把服务停掉再启动",而是建立一条完整的控制链:

text 复制代码
用户访问
   │
   ▼
Nginx 公开入口 ── maintenance.enable 存在 ──► 统一返回 HTTP 503 维护页
   │
   └────────── maintenance.enable 不存在 ──► 前端页面和公开 API 正常访问

维护窗口内:
旧数据库 ── 停写后的最终一致性备份 ──► 新数据库 ── 版本化 SQL 升级
    │                                      │
    └────────────── 只读对比验证 ◄────────┘

内部验收入口 ──► 新后端 ──► 新数据库
                         │
                         └── 全部通过后才删除 maintenance.enable

一、先明确几个原则

1. 维护页不只是一个漂亮页面

很多项目只在前端 location / 中增加维护判断。这样虽然浏览器刷新后会看到维护页,但已经打开的旧页面仍然可能继续调用 /api/,定时任务、报表、上传接口也可能绕过前端页面直接写数据库。

真正的维护模式至少要同时保护:

  • 前端首页和所有 SPA 路由;
  • 公开 API;
  • 报表、文件上传、WebSocket 等其他公开代理路径;
  • 任何可能绕过页面直接发起写操作的入口。

静态 JS、CSS、图片是否继续提供不是关键,关键是公开业务 API 必须被阻断。

2. 停止后端不等于数据库已经停写

除了主后端,还要盘点所有可能的写入源:

  • 定时任务;
  • 消息队列消费者;
  • 数据采集程序;
  • 独立同步脚本;
  • 报表或低代码平台;
  • 其他共用数据库的服务;
  • 运维人员正在使用的数据库客户端。

只有这些写入源全部停止,并且 MySQL 中没有未结束的业务事务,才能生成最终上线备份。

3. 不直接在旧正式库上做大版本升级

更稳妥的方式是:

  1. 保留旧正式库,不执行 DDL,不删除;
  2. 新建目标库;
  3. 停写后把旧库完整恢复到目标库;
  4. 只在目标库上执行新版本结构升级;
  5. 应用验证通过后切到目标库;
  6. 旧库保留一段观察期。

这样做占用更多磁盘,但换来了非常清晰的回滚边界。只要还没有解除维护页、目标库没有产生新的正式业务写入,就可以把应用重新指向旧库,做到 RPO 为 0 的回滚。

4. 测试库只能作为结构参考,不能覆盖正式数据

测试库和正式库通常存在这些差异:

  • 业务数据量不同;
  • 测试账号、演示设备和模拟数据不同;
  • AUTO_INCREMENT 水位不同;
  • 菜单、权限和字典数据不同;
  • MySQL 版本不同,例如测试环境是 5.7,正式环境是 8.0;
  • 测试库中可能保留临时表、备份表和手工补数。

因此不要使用"同步结构并同步数据"、数据库客户端全量传输或测试库完整导入来升级正式环境。正确做法是从测试库和代码中提取结构差异,整理成经过审查的正式增量 SQL,只把确实需要的 DDL 和系统元数据变更应用到目标正式库。

二、准备一个文件开关式维护页

维护页使用两个文件:

text 复制代码
/www/wwwroot/project-maintenance/maintenance.html
/www/wwwroot/project-maintenance/maintenance.enable

它们的职责不同:

  • maintenance.html 是页面内容;
  • maintenance.enable 是开关文件;
  • 开关文件存在时进入维护;
  • 删除开关文件后恢复访问。

1. 创建完整维护页

下面的命令可以直接复制。它会创建目录并写入一个不依赖外部图片、字体和 CSS 的维护页,因此即使静态资源服务正在调整,页面仍可独立显示。

bash 复制代码
install -d -m 755 /www/wwwroot/project-maintenance

tee /www/wwwroot/project-maintenance/maintenance.html > /dev/null <<'HTML'
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <meta http-equiv="Cache-Control" content="no-store">
  <title>系统维护中</title>
  <style>
    * { box-sizing: border-box; }
    body {
      margin: 0;
      min-height: 100vh;
      display: grid;
      place-items: center;
      padding: 24px;
      color: #1f2937;
      background: linear-gradient(135deg, #eff6ff, #f8fafc);
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
        "Microsoft YaHei", sans-serif;
    }
    main {
      width: min(560px, 100%);
      padding: 44px 36px;
      text-align: center;
      background: #fff;
      border: 1px solid #dbeafe;
      border-radius: 18px;
      box-shadow: 0 18px 50px rgba(30, 64, 175, .12);
    }
    h1 {
      margin: 0 0 16px;
      color: #1d4ed8;
      font-size: 30px;
    }
    p {
      margin: 8px 0;
      line-height: 1.75;
    }
    .sub {
      color: #64748b;
      font-size: 14px;
    }
  </style>
</head>
<body>
  <main>
    <h1>系统维护中</h1>
    <p>系统正在升级,请稍后再试。</p>
    <p class="sub">维护完成后刷新页面即可,无需修改客户端配置。</p>
  </main>
</body>
</html>
HTML

chmod 644 /www/wwwroot/project-maintenance/maintenance.html

注意 CSS 第一行是:

css 复制代码
* { box-sizing: border-box; }

不要在复制时误写成 - { ... }

2. 开启和关闭维护模式

开启维护:

bash 复制代码
touch /www/wwwroot/project-maintenance/maintenance.enable

关闭维护:

bash 复制代码
rm -f /www/wwwroot/project-maintenance/maintenance.enable

最容易出现的误解是:创建了 maintenance.html,页面就会自动生效。实际上 Nginx 判断的是 maintenance.enablechmod 只是修改文件权限,rm -f 是关闭维护,只有 touch 才是开启维护。

三、完整的 Nginx 公开入口模板

下面是一份可复用的前端公开入口配置。使用前替换域名、前端目录、日志路径和后端端口,并把现有 SSL 证书、宝塔 include 等现场配置合并进来。

nginx 复制代码
server
{
    listen 80;
    server_name app.example.com;

    root /www/wwwroot/example-web/dist;
    index index.html;
    client_max_body_size 32m;

    error_page 503 /maintenance.html;

    # 维护页只能被 error_page 内部跳转访问。
    location = /maintenance.html
    {
        internal;
        root /www/wwwroot/project-maintenance;
        add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
        add_header Retry-After "300" always;
    }

    # 公开 API 必须和页面使用同一个维护开关。
    location ^~ /admin-api/
    {
        if (-f /www/wwwroot/project-maintenance/maintenance.enable)
        {
            return 503;
        }

        proxy_pass http://127.0.0.1:48080/admin-api/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_connect_timeout 30s;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
    }

    # 如果还有报表、上传或其他代理路径,每一个都要加入相同判断。
    location ^~ /reports/
    {
        if (-f /www/wwwroot/project-maintenance/maintenance.enable)
        {
            return 503;
        }

        proxy_pass http://127.0.0.1:48080/reports/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # 入口 HTML 不长期缓存,避免发版后仍加载旧资源清单。
    location = /index.html
    {
        if (-f /www/wwwroot/project-maintenance/maintenance.enable)
        {
            return 503;
        }

        try_files $uri =404;
        expires -1;
        add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
    }

    # 带内容哈希的静态资源可以缓存。
    location ~* \.(?:gif|jpe?g|png|bmp|ico|webp|svg)$
    {
        expires 30d;
        access_log off;
    }

    location ~* \.(?:js|css)$
    {
        expires 12h;
        access_log off;
    }

    # Vue、React 等 SPA 的 history 路由回退。
    location /
    {
        if (-f /www/wwwroot/project-maintenance/maintenance.enable)
        {
            return 503;
        }

        try_files $uri $uri/ /index.html;
    }

    access_log /www/wwwlogs/example-web.access.log;
    error_log  /www/wwwlogs/example-web.error.log;
}

这里在 if 中只执行 return,用途单一,便于排查。不要在这个判断里加入复杂 rewrite 或代理逻辑。

为什么 API 的 location 也必须判断维护开关

Nginx 会优先匹配 location ^~ /admin-api/。请求一旦进入这个代理块,就不会再经过 location / 的维护判断。

如果只改 location /,会出现一种危险状态:

text 复制代码
刷新页面            -> 看到维护页
已经打开的旧页面    -> 仍然可以请求 API
脚本直接调用 API    -> 仍然可以写数据库

因此所有公开代理块都要显式加入同一个文件判断。

为什么维护页要返回 HTTP 503

维护期间返回 200 OK 会让监控、搜索引擎和上游代理误以为业务正常。503 Service Unavailable 更符合真实语义,配合下面两个响应头效果更好:

http 复制代码
Cache-Control: no-store
Retry-After: 300
  • Cache-Control: no-store 防止浏览器和中间代理缓存维护页;
  • Retry-After: 300 表示建议 300 秒后重试。

四、保留一个内部验收入口

维护期间,公开入口应该全部返回 503,但运维人员仍要验证新后端。最安全的方式是直接在服务器本机访问:

bash 复制代码
curl -fsS http://127.0.0.1:48080/actuator/health

如果团队确实需要独立的后端验收域名,可以使用下面的代理模板,但应通过防火墙、VPN、IP 白名单或身份认证限制访问,不要把它当作新的公开业务入口。

nginx 复制代码
server
{
    listen 9001;
    server_name backend-internal.example.com;
    client_max_body_size 32m;

    # 内部验收入口不使用前端维护页。
    location /
    {
        proxy_pass http://127.0.0.1:48080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_connect_timeout 30s;
        proxy_read_timeout 86400s;
        proxy_send_timeout 30s;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    access_log /www/wwwlogs/example-backend.access.log;
    error_log  /www/wwwlogs/example-backend.error.log;
}

推荐的验收路径是:

text 复制代码
公网用户 ──► 公开域名 ──► HTTP 503 维护页

运维人员 ──► 服务器本机、SSH 隧道或受限内部域名 ──► 新后端 ──► 新数据库

五、先部署并演练 Nginx,再进入数据库窗口

不要等数据库已经停写后才第一次修改 Nginx。永久配置应该提前完成,维护开关先保持关闭。

1. 备份当前有效配置

bash 复制代码
NGINX_BIN=/www/server/nginx/sbin/nginx
RUN_ID=$(date +%Y%m%d-%H%M%S)
NGINX_BAK=/www/backup/project-upgrade/$RUN_ID/nginx

install -d -m 700 "$NGINX_BAK"
"$NGINX_BIN" -T > "$NGINX_BAK/nginx-T-before.conf" 2>&1

# 通过 nginx-T-before.conf 确认真实 vhost 路径后再备份。
cp -a /www/server/panel/vhost/nginx/example-web.conf \
  "$NGINX_BAK/example-web.conf.before"

宝塔面板环境尤其要先执行 nginx -T。站点名称、域名和实际 vhost 文件名不一定一致,直接凭印象修改很容易改错文件。

2. 语法检查和 reload

bash 复制代码
"$NGINX_BIN" -t
"$NGINX_BIN" -s reload
"$NGINX_BIN" -T > "$NGINX_BAK/nginx-T-after.conf" 2>&1

nginx -t 没有成功时不要 reload,更不要进入数据库维护窗口。

3. 关闭状态验收

bash 复制代码
rm -f /www/wwwroot/project-maintenance/maintenance.enable

curl -I http://app.example.com/
curl -I http://app.example.com/login
curl -I http://app.example.com/process
curl -I http://app.example.com/admin-api/actuator/health

SPA 子路由直接访问或刷新返回 404 时,检查:

nginx 复制代码
try_files $uri $uri/ /index.html;

4. 开启状态验收

bash 复制代码
touch /www/wwwroot/project-maintenance/maintenance.enable

curl -i http://app.example.com/
curl -i http://app.example.com/process
curl -i http://app.example.com/admin-api/actuator/health
curl -i http://app.example.com/reports/

这些公开入口应全部满足:

  • 状态码是 503
  • 响应体包含"系统维护中";
  • 存在 Cache-Control: no-store
  • 存在 Retry-After
  • API 不会因为后端已停止而暴露 502 Bad Gateway

演练完成后可以先关闭维护开关:

bash 复制代码
rm -f /www/wwwroot/project-maintenance/maintenance.enable

如果语法或页面验收失败,立即恢复备份配置:

bash 复制代码
cp -a "$NGINX_BAK/example-web.conf.before" \
  /www/server/panel/vhost/nginx/example-web.conf
"$NGINX_BIN" -t
"$NGINX_BIN" -s reload

六、数据库升级为什么采用"旧库保留、新库迁移"

假设当前正式库为 legacy_app,新版本目标库为 next_app

text 复制代码
legacy_app  ── 停写后备份和恢复 ──►  next_app
   只读保留                              执行新版本 DDL
      │                                      │
      └────────── 数据与结构对比 ◄───────────┘

这种方式有四个明显优点:

  1. 旧库不做 DDL,失败时仍是完整的旧版本数据源;
  2. 可以反复删除失败的目标库并从最终备份重建;
  3. 升级前后都能做跨库对比;
  4. 回滚动作是切回旧制品和旧数据源,而不是尝试反向执行复杂 DDL。

代价是需要更多磁盘空间。开始前至少要容纳:

  • 一份最终 SQL 备份;
  • 一份完整目标数据库;
  • MySQL 临时空间、binlog 和日志;
  • 足够的安全余量。

一个便于执行的最低估算是:

text 复制代码
可用空间 >= 源数据库实际占用 × 2 + 最终备份文件大小 + 安全余量

七、把数据库升级拆成五类脚本

不要把所有操作塞进一个巨大的 SQL 文件。推荐按职责拆分:

text 复制代码
00_capture_source_baseline.sql
01_precheck_target.sql
02_upgrade_schema.sql
03_verify_target.sql
04_compare_source_and_target.sql

00_capture_source_baseline.sql

只在旧正式库上执行只读查询:

  • 强制校验 DATABASE() 是源库;
  • 输出 MySQL 版本、字符集和排序规则;
  • 输出表、引擎、索引、外键、存储过程、触发器和事件;
  • 对所有表执行精确 COUNT(*)
  • 输出核心表最大主键、最大创建时间和更新时间;
  • 输出唯一成功标志。

它不能创建表、写数据或修改任何业务记录。

01_precheck_target.sql

在目标库恢复完成、升级开始前执行:

  • 强制校验 DATABASE() 是目标库;
  • 校验 MySQL 主版本;
  • 校验源表是否完整恢复;
  • 校验新增表和新增字段是否处于预期状态;
  • 校验菜单 ID、字典键、唯一键等是否冲突;
  • 校验应用账号和采集账号权限;
  • 任一条件失败时通过 SIGNAL SQLSTATE '45000' 终止。

预检查失败不是"跳过去继续执行"的提示,而是明确的停止条件。

02_upgrade_schema.sql

只包含正式需要的增量结构和系统元数据:

  • 新增表、字段和索引;
  • 使用生产数据库对应版本的明确语法;
  • 尽可能幂等;
  • 插入菜单和权限时同时校验 ID、路径和权限标识;
  • 正确记录已存在时跳过,ID 被其他数据占用时终止;
  • 不使用 REPLACE INTO 覆盖正式元数据;
  • 不包含测试业务数据和演示补数;
  • 不包含 DROP DATABASETRUNCATE 或正式数据删除。

如果测试环境是 MySQL 5.7、正式环境是 MySQL 8.0,不要直接复制数据库客户端导出的 5.7 DDL。应删除整数显示宽度等历史写法,重新确认字符集、默认值、索引和关键字兼容性。

03_verify_target.sql

升级后、应用启动前执行:

  • 校验目标总表数和原表集合;
  • 校验新增表、字段、索引、唯一键和默认值;
  • 校验新增业务表初始数据量;
  • 校验旧业务表原数据未变化;
  • 校验存储过程、触发器和事件;
  • 校验系统菜单和角色授权;
  • 输出唯一成功标志。

没有看到唯一成功标志,就不能认为升级成功。

04_compare_source_and_target.sql

同时以只读方式比较两个库:

  • 原表集合;
  • 原表精确行数;
  • AUTO_INCREMENT
  • 核心表最大主键;
  • 核心表最大创建、更新时间;
  • 存储过程数量;
  • 允许差异白名单。

出现白名单之外的差异时,应输出具体表名并终止发布,而不是把检查条件放宽到"能通过为止"。

MySQL DDL 失败后为什么不建议手工接着补

MySQL DDL 会产生隐式提交。一个升级脚本执行到一半失败时,数据库往往已经处于"部分成功、部分失败"的状态,不能指望简单执行 ROLLBACK 恢复。

更可控的处理方式是:

  1. 保留错误日志;
  2. 确认目标库没有正式新增数据;
  3. 删除本次失败的目标库;
  4. 从停写后的最终备份重新创建干净目标库;
  5. 修复版本化 SQL;
  6. 从预检查开始完整重跑。

不要在半升级数据库上连续手敲 DDL,最后连"哪些语句已经执行过"都无法准确回答。

八、最终备份与恢复命令

1. 凭据文件

不要把数据库密码写进命令行、部署文档或 Jenkins Console。使用独立凭据文件:

ini 复制代码
[client]
host=127.0.0.1
port=3306
user=BACKUP_USER
password=REPLACE_WITH_SECRET
default-character-set=utf8mb4
bash 复制代码
chmod 600 /secure/project-mysql.cnf

--defaults-extra-file 必须作为 mysqlmysqldump 的第一个选项。

2. 停写后的最终一致性备份

bash 复制代码
MYSQL_CNF=/secure/project-mysql.cnf
RUN_ID=$(date +%Y%m%d-%H%M%S)
BACKUP_DIR=/www/backup/project-upgrade/$RUN_ID
LOG_DIR=$BACKUP_DIR/logs

install -d -m 700 "$BACKUP_DIR" "$LOG_DIR"

FINAL_DUMP=$BACKUP_DIR/legacy_app-final-$RUN_ID.sql
DUMP_START=$(date '+%F %T %z')

mysqldump --defaults-extra-file="$MYSQL_CNF" \
  --single-transaction \
  --quick \
  --routines \
  --triggers \
  --events \
  --hex-blob \
  --set-gtid-purged=OFF \
  --no-tablespaces \
  --skip-add-drop-table \
  --default-character-set=utf8mb4 \
  legacy_app > "$FINAL_DUMP" 2> "$LOG_DIR/mysqldump.stderr.log"

DUMP_RC=$?
DUMP_END=$(date '+%F %T %z')

printf 'start=%s\nend=%s\nrc=%s\n' \
  "$DUMP_START" "$DUMP_END" "$DUMP_RC" \
  > "$LOG_DIR/mysqldump.result"

test "$DUMP_RC" -eq 0
test -s "$FINAL_DUMP"

if grep -nE '(^|[[:space:]])(CREATE DATABASE|USE)[[:space:]]+`?legacy_app`?' \
  "$FINAL_DUMP"; then
  echo 'STOP: 备份中出现源数据库创建或 USE 语句' >&2
  exit 1
fi

sha256sum "$FINAL_DUMP" | tee "$FINAL_DUMP.sha256"

这里故意不使用 --databases,避免备份文件中写入 CREATE DATABASE legacy_appUSE legacy_app。恢复时再显式指定目标库,降低误写回源库的风险。

3. 按源库真实字符集创建目标库

bash 复制代码
read DB_CHARSET DB_COLLATION <<EOF
$(mysql --defaults-extra-file="$MYSQL_CNF" --batch --skip-column-names \
  -e "SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME
      FROM information_schema.SCHEMATA
      WHERE SCHEMA_NAME='legacy_app'")
EOF

test -n "$DB_CHARSET"
test -n "$DB_COLLATION"

mysql --defaults-extra-file="$MYSQL_CNF" \
  -e "CREATE DATABASE \`next_app\` CHARACTER SET $DB_CHARSET COLLATE $DB_COLLATION;"

不要在文档中猜测字符集和排序规则,应该从源库元数据读取。

4. 显式恢复到目标库

bash 复制代码
RESTORE_START=$(date '+%F %T %z')

mysql --defaults-extra-file="$MYSQL_CNF" --database=next_app \
  < "$FINAL_DUMP" \
  > "$LOG_DIR/restore.stdout.log" \
  2> "$LOG_DIR/restore.stderr.log"

RESTORE_RC=$?
RESTORE_END=$(date '+%F %T %z')

printf 'start=%s\nend=%s\nrc=%s\n' \
  "$RESTORE_START" "$RESTORE_END" "$RESTORE_RC" \
  > "$LOG_DIR/restore.result"

test "$RESTORE_RC" -eq 0

恢复完成后先执行源库与目标库比较,再执行增量结构升级。这样能够区分"备份恢复问题"和"升级脚本问题"。

九、正式切换前必须演练

建议使用独立演练库,例如:

text 复制代码
next_app_rehearsal

完整演练至少包含:

  1. 恢复一份最近的正式备份;
  2. 捕获源库基线;
  3. 执行目标预检查;
  4. 执行正式增量 SQL;
  5. 执行目标验证;
  6. 执行跨库比较;
  7. 使用候选后端制品连接演练库启动;
  8. 验证旧功能、历史数据和新版本空数据状态;
  9. 记录备份、恢复、升级、比对和启动耗时;
  10. 故意连接错误数据库运行预检查,确认保护逻辑会阻止误操作;
  11. 故意重复运行升级脚本,确认它能够幂等跳过或明确终止;
  12. 删除并重建演练库,从零再跑一次。

演练失败后不能只修改手册,必须修复 SQL 并重新跑完整流程。

十、构建制品也要可追溯

数据库很重要,但如果上线的制品无法追溯,回滚仍然会失控。每个前后端构建至少记录:

  • Jenkins 项目名;
  • 构建号;
  • 仓库地址;
  • 分支;
  • 实际 GIT_COMMIT
  • 构建时间;
  • 制品路径;
  • 制品 SHA-256。

正式构建前应冻结候选提交。如果 Jenkins 构建时分支头已经变化,应停止发布并重新审查差异,不要默认部署新的分支头。

上线前还要归档:

  • 当前线上后端制品;
  • 当前前端构建目录;
  • 当前 Nginx 配置;
  • 当前外置配置;
  • 上一次成功构建记录。

数据库、消息队列、对象存储等凭据应通过 Jenkins Credentials、环境变量或服务器外置配置注入。构建日志只能检查变量是否存在,不能打印变量值。

十一、推荐的正式切换顺序

下面这份清单可以直接复制到上线工单中:

  1. 创建 maintenance.enable
  2. 验证所有公开页面和公开 API 都返回自定义 503;
  3. 停止旧后端;
  4. 停止定时任务、消费者、采集程序和其他写入源;
  5. 查询 information_schema.innodb_trx,确认没有未结束业务事务;
  6. 执行 SHOW FULL PROCESSLIST,确认没有持续写入连接;
  7. 记录 binlog 位置或 GTID 水位;
  8. 执行最终源库基线脚本;
  9. 导出最终一致性备份;
  10. 检查备份退出码、非空、内容和 SHA-256;
  11. 按源库真实字符集创建目标库;
  12. 配置恢复账号、应用账号和采集账号的最小权限;
  13. 显式恢复到目标库;
  14. 执行目标预检查;
  15. 执行升级前跨库比较;
  16. 执行版本化增量 SQL;
  17. 执行目标库验证;
  18. 执行升级后跨库比较;
  19. 确认唯一成功标志;
  20. 将候选后端的数据源切到目标库;
  21. 启动新后端;
  22. 通过服务器本机或受限内部入口检查健康状态;
  23. 验证旧业务、历史数据、新接口和权限;
  24. 部署管理端和其他前端制品;
  25. 验证 SPA 子路由直接访问和刷新;
  26. 启动采集或同步程序并确认目标库;
  27. 完成 Go/No-Go 复核;
  28. 只有全部通过才删除 maintenance.enable
  29. 开放正式访问;
  30. 观察错误率、接口延迟、数据库连接、慢 SQL 和业务数据至少 60 分钟;
  31. 回填实际上线记录。

维护窗口应预留明确时长。窗口内无法完成恢复、升级和验收时,应保持维护页并执行回滚,而不是在缺乏控制的情况下不断延长。

十二、Go/No-Go 验收表

数据库

验收项 通过标准
旧库保护 旧库未执行 DDL、未删除、未覆盖
目标库恢复 所有原表、存储过程、触发器和事件恢复成功
原表数据 升级前原表精确行数一致
核心水位 最大 ID、最大创建时间、最大更新时间一致
自增信息 原业务表 AUTO_INCREMENT 一致
增量结构 新表、字段、索引、唯一键和默认值符合代码契约
排除内容 测试数据、演示数据和临时备份表未进入正式库
系统元数据 菜单、权限、字典等没有覆盖已有正式记录
验证标志 验证脚本输出唯一成功标志
非预期差异 允许差异白名单之外没有任何变化

应用

验收项 通过标准
后端健康 健康检查为 UP,无缺表、缺字段和权限错误
旧业务 登录、查询、新增、修改、导出等核心流程正常
历史数据 旧版本产生的历史数据能够正常查询和解析
新接口 空数据返回空列表或零值,不返回 500
文件上传 小于后端限制的文件不被 Nginx 413 拦截
SPA 路由 首页及子路由直接访问、刷新均不返回 404
公开维护保护 开闸前所有公开页面和 API 均保持 503
内部验收 本机或受限入口可以访问新后端进行测试

数据采集和异步任务

验收项 通过标准
目标数据库 配置明确指向新库
空配置行为 没有正式配置时允许空数据,但不能出现 SQL 错误
写入水位 时间字段和最大 ID 按预期推进
幂等性 重复数据按业务键更新或忽略,不产生无界重复
下游读取 后端和页面可以读取新写入数据

任何核心项不通过,结论都应该是 No-Go。

十三、回滚边界必须在上线前写清楚

解除维护页以前

如果公开入口仍处于维护状态,并且目标库还没有接收新的正式业务写入,可以执行:

  1. 保持维护开关;
  2. 停止新后端和所有写入程序;
  3. 外置数据源恢复为旧库;
  4. 部署归档的旧后端和旧前端制品;
  5. 必要时恢复 Nginx 备份并执行 nginx -t、reload;
  6. 验证旧后端健康检查、登录和核心业务;
  7. 删除维护开关;
  8. 保留目标库用于问题分析,不回写旧库;
  9. 记录回滚原因和结果。

这一阶段可以做到 RPO 为 0,因为所有正式业务数据都还停留在旧库的最终水位。

解除维护页以后

一旦用户已经在目标库产生新订单、新上传记录或其他业务数据,就不能直接把应用指回旧库。否则这部分增量会丢失。

正确做法是:

  1. 重新开启维护模式;
  2. 停止所有写入;
  3. 对比目标库相对旧库的新增、修改和关联数据;
  4. 评估回迁、补偿或原地修复方案;
  5. 经业务和技术复核后再执行。

所以"删除维护开关"不是一个普通命令,而是数据分叉边界。执行它之前必须完成正式 Go/No-Go 复核。

十四、操作记录应该记录什么

建议每次上线都创建独立 Markdown 记录,至少包含:

markdown 复制代码
# 生产环境上线实际执行记录

- 上线日期:
- 维护窗口:
- 执行人:
- 复核人:
- Jenkins 项目与构建号:
- 实际提交 SHA:
- 制品 SHA-256:
- 最终备份文件名:
- 最终备份大小:
- 最终备份 SHA-256:
- 源库停写 binlog/GTID 水位:
- 基线脚本结果与日志 SHA-256:
- 数据库恢复耗时:
- SQL 升级耗时:
- 跨库比较结果:
- 数据库验证成功标志:
- Nginx 配置备份路径:
- nginx -t 结果:
- 页面与接口验收结果:
- 异步任务或采集程序结果:
- Go/No-Go 结论:
- 是否回滚:
- 遗留问题、负责人和期限:

命令输出可以保存到带时间戳的日志目录,再为关键日志计算 SHA-256。日志中不要记录数据库密码、令牌或完整凭据。

十五、常见问题排查

1. 创建了维护页,但刷新仍然是正常页面

依次检查:

bash 复制代码
test -f /www/wwwroot/project-maintenance/maintenance.html && echo HTML_OK
test -f /www/wwwroot/project-maintenance/maintenance.enable && echo SWITCH_ON
/www/server/nginx/sbin/nginx -T | grep -n 'maintenance.enable'

常见原因:

  • 只创建了 maintenance.html,没有执行 touch maintenance.enable
  • 执行了 rm -f maintenance.enable,实际是关闭维护;
  • 修改的不是当前域名真正生效的 vhost;
  • 修改后没有通过 nginx -t 并 reload;
  • Nginx 配置中的路径和实际文件路径不同。

2. 页面是维护页,但 API 仍然能访问

原因通常是只在 location / 中加入了判断。需要在每一个 ^~ /api/、报表、上传和其他代理块中加入同一个判断。

3. 后端停止后,公开 API 返回 502

这说明公开 API 没有被维护开关保护,请求仍然被转发到了已经停止的后端。维护模式下公开 API 应返回自定义 503,而不是 502。

4. 前端子路由刷新返回 404

确认 SPA 入口配置包含:

nginx 复制代码
try_files $uri $uri/ /index.html;

如果已经包含但仍显示旧页面,应检查线上 index.html 的更新时间和资源文件名,确认部署的是新构建产物,而不是继续修改 Nginx。

5. 上传返回 413

在公开前端域名和后端代理上确认:

nginx 复制代码
client_max_body_size 32m;

同时核对应用自身的上传限制。Nginx 限制和后端限制必须都允许目标文件大小。

6. 后端启动提示缺表或缺字段

保持维护状态,不要在数据库客户端中临时手敲 DDL。先确认:

  • 代码依赖的结构是否漏进正式升级脚本;
  • 测试库是否也落后于代码;
  • 恢复过程是否漏掉对象;
  • 数据源是否真的指向目标库。

修复应形成新的版本化 SQL,包含目标库保护、基线保护、幂等校验和独立验证,然后从干净目标库重新演练。

7. 应用开放写入后,跨库比较失败

这是正常且应该保留的严格行为。新库开始接收业务后,行数和水位必然与旧库分叉。不要为了让脚本变绿而把这些业务差异加入升级白名单。

此时应重新停写,并使用专门的只读诊断脚本确认:

  • 新增数据是否只存在于目标库;
  • 原有共有记录是否被意外修改;
  • 主从表或父子表是否完整;
  • 是否存在孤儿记录;
  • 两次停写水位是否稳定。

8. Nginx 的 if 是否可靠

本文只使用这种形式:

nginx 复制代码
if (-f /path/to/maintenance.enable) {
    return 503;
}

它只做文件存在性判断并立即返回,不在 if 中执行复杂代理、变量拼接或多阶段 rewrite,行为简单明确。无论如何,正式应用前都必须执行 nginx -t 和开关两种状态的真实请求验收。

十六、最后总结

一套安全的生产升级方案,至少应该回答下面十个问题:

  1. 用户什么时候被阻止继续写入?
  2. 除了主后端,还有哪些程序可能写数据库?
  3. 最终备份是否来自完全停写后的数据水位?
  4. 旧数据库是否保持原样?
  5. 新数据库升级失败后如何从干净状态重来?
  6. 如何证明原业务数据没有丢失或被覆盖?
  7. 如何在公开维护期间验证新服务?
  8. 哪些验收项失败必须 No-Go?
  9. 删除维护开关前后,回滚边界有什么不同?
  10. 下一位执行人能否只看文档和日志复现整个过程?

如果这些问题都能在上线前得到明确答案,那么维护页就不再只是一张友好的提示页面,而是整个发布控制面的第一道闸门;备份也不再只是"我已经导出过一次",而是可校验、可恢复、可审计的上线依据。

真正可靠的上线,不是要求每一步永远不出错,而是让错误发生时能够立即停止、准确定位,并且有一条经过演练的安全退路。

相关推荐
心静自然凉8001 小时前
MySQL主从同步配置(一主一从)
数据库
haidy ahmed1 小时前
企微复杂审批流反序列化:动态表单解析引擎与EAV存储模型
数据库·人工智能·自动化·企业微信
Lihua奏2 小时前
MVCC:为什么多人同时读写数据,数据库还能不乱
数据库
青山木2 小时前
一把 Redis 分布式锁,踩透四个坑:锁争抢、僵尸锁、锁过期、锁丢失
java·数据库·redis·后端
数智化管理手记2 小时前
智能财务如何减少财务加班?智能财务落地需要哪些工具支撑?
大数据·网络·数据库·数据挖掘·精益工程
味悲2 小时前
搭建WAF+宝塔反向代理
安全
IvorySQL2 小时前
深度拆解 IvorySQL 去 O 核心解决方案
数据库·人工智能·postgresql
A15362553 小时前
国内好用的WMS仓储管理系统有哪些?万里牛WMS深度评测
大数据·数据库·人工智能
SelectDB3 小时前
快手从 ClickHouse 到 Apache Doris 的百 PB 数据、200+集群迁移实践
数据库·性能优化·开源