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. 不直接在旧正式库上做大版本升级
更稳妥的方式是:
- 保留旧正式库,不执行 DDL,不删除;
- 新建目标库;
- 停写后把旧库完整恢复到目标库;
- 只在目标库上执行新版本结构升级;
- 应用验证通过后切到目标库;
- 旧库保留一段观察期。
这样做占用更多磁盘,但换来了非常清晰的回滚边界。只要还没有解除维护页、目标库没有产生新的正式业务写入,就可以把应用重新指向旧库,做到 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.enable。chmod 只是修改文件权限,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
│ │
└────────── 数据与结构对比 ◄───────────┘
这种方式有四个明显优点:
- 旧库不做 DDL,失败时仍是完整的旧版本数据源;
- 可以反复删除失败的目标库并从最终备份重建;
- 升级前后都能做跨库对比;
- 回滚动作是切回旧制品和旧数据源,而不是尝试反向执行复杂 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 DATABASE、TRUNCATE或正式数据删除。
如果测试环境是 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 恢复。
更可控的处理方式是:
- 保留错误日志;
- 确认目标库没有正式新增数据;
- 删除本次失败的目标库;
- 从停写后的最终备份重新创建干净目标库;
- 修复版本化 SQL;
- 从预检查开始完整重跑。
不要在半升级数据库上连续手敲 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 必须作为 mysql 或 mysqldump 的第一个选项。
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_app 或 USE 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
完整演练至少包含:
- 恢复一份最近的正式备份;
- 捕获源库基线;
- 执行目标预检查;
- 执行正式增量 SQL;
- 执行目标验证;
- 执行跨库比较;
- 使用候选后端制品连接演练库启动;
- 验证旧功能、历史数据和新版本空数据状态;
- 记录备份、恢复、升级、比对和启动耗时;
- 故意连接错误数据库运行预检查,确认保护逻辑会阻止误操作;
- 故意重复运行升级脚本,确认它能够幂等跳过或明确终止;
- 删除并重建演练库,从零再跑一次。
演练失败后不能只修改手册,必须修复 SQL 并重新跑完整流程。
十、构建制品也要可追溯
数据库很重要,但如果上线的制品无法追溯,回滚仍然会失控。每个前后端构建至少记录:
- Jenkins 项目名;
- 构建号;
- 仓库地址;
- 分支;
- 实际
GIT_COMMIT; - 构建时间;
- 制品路径;
- 制品 SHA-256。
正式构建前应冻结候选提交。如果 Jenkins 构建时分支头已经变化,应停止发布并重新审查差异,不要默认部署新的分支头。
上线前还要归档:
- 当前线上后端制品;
- 当前前端构建目录;
- 当前 Nginx 配置;
- 当前外置配置;
- 上一次成功构建记录。
数据库、消息队列、对象存储等凭据应通过 Jenkins Credentials、环境变量或服务器外置配置注入。构建日志只能检查变量是否存在,不能打印变量值。
十一、推荐的正式切换顺序
下面这份清单可以直接复制到上线工单中:
- 创建
maintenance.enable; - 验证所有公开页面和公开 API 都返回自定义 503;
- 停止旧后端;
- 停止定时任务、消费者、采集程序和其他写入源;
- 查询
information_schema.innodb_trx,确认没有未结束业务事务; - 执行
SHOW FULL PROCESSLIST,确认没有持续写入连接; - 记录 binlog 位置或 GTID 水位;
- 执行最终源库基线脚本;
- 导出最终一致性备份;
- 检查备份退出码、非空、内容和 SHA-256;
- 按源库真实字符集创建目标库;
- 配置恢复账号、应用账号和采集账号的最小权限;
- 显式恢复到目标库;
- 执行目标预检查;
- 执行升级前跨库比较;
- 执行版本化增量 SQL;
- 执行目标库验证;
- 执行升级后跨库比较;
- 确认唯一成功标志;
- 将候选后端的数据源切到目标库;
- 启动新后端;
- 通过服务器本机或受限内部入口检查健康状态;
- 验证旧业务、历史数据、新接口和权限;
- 部署管理端和其他前端制品;
- 验证 SPA 子路由直接访问和刷新;
- 启动采集或同步程序并确认目标库;
- 完成 Go/No-Go 复核;
- 只有全部通过才删除
maintenance.enable; - 开放正式访问;
- 观察错误率、接口延迟、数据库连接、慢 SQL 和业务数据至少 60 分钟;
- 回填实际上线记录。
维护窗口应预留明确时长。窗口内无法完成恢复、升级和验收时,应保持维护页并执行回滚,而不是在缺乏控制的情况下不断延长。
十二、Go/No-Go 验收表
数据库
| 验收项 | 通过标准 |
|---|---|
| 旧库保护 | 旧库未执行 DDL、未删除、未覆盖 |
| 目标库恢复 | 所有原表、存储过程、触发器和事件恢复成功 |
| 原表数据 | 升级前原表精确行数一致 |
| 核心水位 | 最大 ID、最大创建时间、最大更新时间一致 |
| 自增信息 | 原业务表 AUTO_INCREMENT 一致 |
| 增量结构 | 新表、字段、索引、唯一键和默认值符合代码契约 |
| 排除内容 | 测试数据、演示数据和临时备份表未进入正式库 |
| 系统元数据 | 菜单、权限、字典等没有覆盖已有正式记录 |
| 验证标志 | 验证脚本输出唯一成功标志 |
| 非预期差异 | 允许差异白名单之外没有任何变化 |
应用
| 验收项 | 通过标准 |
|---|---|
| 后端健康 | 健康检查为 UP,无缺表、缺字段和权限错误 |
| 旧业务 | 登录、查询、新增、修改、导出等核心流程正常 |
| 历史数据 | 旧版本产生的历史数据能够正常查询和解析 |
| 新接口 | 空数据返回空列表或零值,不返回 500 |
| 文件上传 | 小于后端限制的文件不被 Nginx 413 拦截 |
| SPA 路由 | 首页及子路由直接访问、刷新均不返回 404 |
| 公开维护保护 | 开闸前所有公开页面和 API 均保持 503 |
| 内部验收 | 本机或受限入口可以访问新后端进行测试 |
数据采集和异步任务
| 验收项 | 通过标准 |
|---|---|
| 目标数据库 | 配置明确指向新库 |
| 空配置行为 | 没有正式配置时允许空数据,但不能出现 SQL 错误 |
| 写入水位 | 时间字段和最大 ID 按预期推进 |
| 幂等性 | 重复数据按业务键更新或忽略,不产生无界重复 |
| 下游读取 | 后端和页面可以读取新写入数据 |
任何核心项不通过,结论都应该是 No-Go。
十三、回滚边界必须在上线前写清楚
解除维护页以前
如果公开入口仍处于维护状态,并且目标库还没有接收新的正式业务写入,可以执行:
- 保持维护开关;
- 停止新后端和所有写入程序;
- 外置数据源恢复为旧库;
- 部署归档的旧后端和旧前端制品;
- 必要时恢复 Nginx 备份并执行
nginx -t、reload; - 验证旧后端健康检查、登录和核心业务;
- 删除维护开关;
- 保留目标库用于问题分析,不回写旧库;
- 记录回滚原因和结果。
这一阶段可以做到 RPO 为 0,因为所有正式业务数据都还停留在旧库的最终水位。
解除维护页以后
一旦用户已经在目标库产生新订单、新上传记录或其他业务数据,就不能直接把应用指回旧库。否则这部分增量会丢失。
正确做法是:
- 重新开启维护模式;
- 停止所有写入;
- 对比目标库相对旧库的新增、修改和关联数据;
- 评估回迁、补偿或原地修复方案;
- 经业务和技术复核后再执行。
所以"删除维护开关"不是一个普通命令,而是数据分叉边界。执行它之前必须完成正式 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 和开关两种状态的真实请求验收。
十六、最后总结
一套安全的生产升级方案,至少应该回答下面十个问题:
- 用户什么时候被阻止继续写入?
- 除了主后端,还有哪些程序可能写数据库?
- 最终备份是否来自完全停写后的数据水位?
- 旧数据库是否保持原样?
- 新数据库升级失败后如何从干净状态重来?
- 如何证明原业务数据没有丢失或被覆盖?
- 如何在公开维护期间验证新服务?
- 哪些验收项失败必须 No-Go?
- 删除维护开关前后,回滚边界有什么不同?
- 下一位执行人能否只看文档和日志复现整个过程?
如果这些问题都能在上线前得到明确答案,那么维护页就不再只是一张友好的提示页面,而是整个发布控制面的第一道闸门;备份也不再只是"我已经导出过一次",而是可校验、可恢复、可审计的上线依据。
真正可靠的上线,不是要求每一步永远不出错,而是让错误发生时能够立即停止、准确定位,并且有一条经过演练的安全退路。