上云迁移验收实战:一次产出 CVM 健康校验用例 + 数据库迁移比对报告
分类:云计算 / 运维 / 数据库
标签:腾讯云、上云迁移、CVM、MySQL、验收、自动化运维、DTS
摘要:迁移做完不等于迁移成功。本文给出一套可复用的验收体系:4 层 CVM 健康校验用例、5 类数据库比对指标、一份可自动生成的验收报告、以及"什么情况下必须回滚"的决策树。所有脚本可直接用于生产。
一、为什么需要一个"验收体系"
我做过的迁移里,出问题的都不是迁移本身,而是验收标准太模糊:
- 运维说"机器通了"------但安全组没放开调用方
- 开发说"接口通了"------但连的是测试库
- DBA 说"数据同步了"------但只对了行数,金额精度错了
- 项目经理说"迁移完成"------依据是"没人报错"
所以我把验收拆成 6 个层次、可执行的用例,每一条都有明确 PASS/FAIL 判定,且全部脚本化。
text
L0 基础设施 实例状态、镜像、磁盘、标签
L1 系统层 时区、依赖、内核参数、时钟
L2 进程层 systemd 状态、重启次数、resource 限制
L3 网络层 端口监听、安全组可达、下游依赖连通
L4 业务层 接口探针、日志写入、关键链路
L5 数据层 行数、金额、主键校验和、抽样、外键一致性
L0~L4 属于 CVM 侧,L5 属于数据库侧。只做 L0~L3 的验收是假验收。
二、CVM 侧:4 层健康校验脚本
2.1 完整脚本 accept_cvm.sh
bash
#!/bin/bash
###############################################################################
# accept_cvm.sh ------ CVM 上云验收脚本
# 用法: accept_cvm.sh -e <dev|test|prod> [-o report.md]
# 输出: 控制台结果 + Markdown 报告
###############################################################################
set -uo pipefail
APP_NAME="${APP_NAME:-biz-web}"
PORT="${PORT:-8080}"
ENV=""
REPORT=""
TOTAL=0
PASSED=0
declare -a RESULTS=()
while getopts "e:o:" opt; do
case $opt in
e) ENV="$OPTARG" ;;
o) REPORT="$OPTARG" ;;
*) echo "usage: $0 -e <env> [-o report.md]"; exit 1 ;;
esac
done
[ -n "$ENV" ] || { echo "FATAL: -e 必填"; exit 1; }
# ---------- 通用断言 ----------
check() {
local layer="$1" name="$2" ; shift 2
TOTAL=$((TOTAL + 1))
local out
if out=$("$@" 2>&1); then
PASSED=$((PASSED + 1))
printf ' [PASS] %s\n' "$name"
RESULTS+=("| ${layer} | ${name} | PASS | ${out:-ok} |")
else
printf ' [FAIL] %s => %s\n' "$name" "$out"
RESULTS+=("| ${layer} | ${name} | **FAIL** | ${out:-failed} |")
fi
}
# ================= L0 基础设施 =================
l0_instance() {
local meta="http://metadata.tencentyun.com/latest/meta-data"
local iid
iid=$(curl -s -m 3 "${meta}/instance-id") || return 1
echo "${iid}"
}
l0_tags() {
local iid env_tag
iid=$(curl -s -m 3 "http://metadata.tencentyun.com/latest/meta-data/instance-id")
env_tag=$(tccli cvm DescribeInstances --InstanceIds "[\"${iid}\"]" \
--query 'InstanceSet[0].Tags[?Key==`env`].Value | [0]' -o text 2>/dev/null)
[ "${env_tag}" = "${ENV}" ] || { echo "标签 env=${env_tag},期望 ${ENV}"; return 1; }
echo "标签一致: ${iid} env=${ENV}"
}
l0_disk() {
local use
use=$(df -P /data 2>/dev/null | awk 'NR==2{gsub("%","",$5); print $5}')
[ -n "${use}" ] || { echo "无 /data 挂载点"; return 1; }
[ "${use}" -lt 85 ] || { echo "/data 使用率 ${use}%"; return 1; }
echo "/data 使用率 ${use}%"
}
# ================= L1 系统层 =================
l1_timezone() {
local tz
tz=$(timedatectl show -p Timezone --value)
[ "${tz}" = "Asia/Shanghai" ] || { echo "时区为 ${tz}"; return 1; }
echo "时区 ${tz}"
}
l1_clock_offset() {
local off
off=$(chronyc tracking 2>/dev/null | awk '/System time/{print $4}')
[ -n "${off}" ] || { echo "chrony 未运行"; return 1; }
# 偏移绝对值小于 1 秒
awk -v o="${off}" 'BEGIN{ if ((o<0?-o:o) < 1.0) exit 0; exit 1 }' \
|| { echo "时钟偏移 ${off}s"; return 1; }
echo "时钟偏移 ${off}s"
}
l1_deps() {
local missing=()
for c in java systemctl curl mysql; do
command -v "$c" >/dev/null 2>&1 || missing+=("$c")
done
[ ${#missing[@]} -eq 0 ] || { echo "缺少: ${missing[*]}"; return 1; }
echo "依赖齐全"
}
l1_ulimit() {
local nofile
nofile=$(systemctl show "${APP_NAME}" -p LimitNOFILE --value)
[ -n "${nofile}" ] && [ "${nofile}" != "infinity" ] && [ "${nofile}" -ge 65535 ] \
|| { echo "LimitNOFILE=${nofile}"; return 1; }
echo "LimitNOFILE=${nofile}"
}
# ================= L2 进程层 =================
l2_service_active() {
systemctl is-active --quiet "${APP_NAME}" || { echo "非 active"; return 1; }
echo "active"
}
l2_restarts() {
local n
n=$(systemctl show "${APP_NAME}" -p NRestarts --value)
[ "${n:-0}" -le 3 ] || { echo "重启 ${n} 次"; return 1; }
echo "NRestarts=${n:-0}"
}
l2_uptime() {
local since now secs
since=$(systemctl show "${APP_NAME}" -p ActiveEnterTimestampMonotonic --value)
now=$(cut -d' ' -f1 /proc/uptime | tr -d '.')
secs=$(( (${now:0:8} - ${since:0:8}) / 1000000 ))
[ "${secs}" -ge 30 ] || { echo "启动仅 ${secs}s,可能仍在重启循环"; return 1; }
echo "已稳定运行 ${secs}s"
}
# ================= L3 网络层 =================
l3_listen() {
ss -lntp 2>/dev/null | grep -q ":${PORT} " || { echo "端口 ${PORT} 未监听"; return 1; }
echo "监听 ${PORT}"
}
l3_bind_all() {
local ip
ip=$(hostname -I | awk '{print $1}')
curl -s -o /dev/null -m 5 "http://${ip}:${PORT}/actuator/health" \
|| { echo "未绑定内网 IP ${ip}"; return 1; }
echo "内网 IP ${ip} 可达"
}
l3_downstream() {
local dep host port
for dep in "${DOWNSTREAM:-}"; do
[ -z "${dep}" ] && continue
host="${dep%:*}"; port="${dep#*:}"
timeout 3 bash -c "echo > /dev/tcp/${host}/${port}" 2>/dev/null \
|| { echo "不可达 ${dep}"; return 1; }
done
echo "下游依赖全部可达"
}
# ================= L4 业务层 =================
l4_health_endpoint() {
local code
code=$(curl -s -o /dev/null -m 5 -w '%{http_code}' \
"http://127.0.0.1:${PORT}/actuator/health")
[ "${code}" = "200" ] || { echo "health 返回 ${code}"; return 1; }
echo "HTTP 200"
}
l4_business_endpoint() {
local code
code=$(curl -s -o /dev/null -m 8 -w '%{http_code}' \
"http://127.0.0.1:${PORT}/api/v1/order/query?orderNo=SELFTEST&env=${ENV}")
# 允许 200(查到)或 404(查不到但链路通),不允许 5xx
case "${code}" in
200|404) echo "业务接口 HTTP ${code}" ;;
*) echo "业务接口返回 ${code}"; return 1 ;;
esac
}
l4_log_writing() {
local logfile="/data/app/${APP_NAME}/shared/logs/stdout.log"
[ -f "${logfile}" ] || { echo "日志文件不存在"; return 1; }
[ "$(find "${logfile}" -mmin -5 | wc -l)" -ge 1 ] \
|| { echo "近 5 分钟无日志写入"; return 1; }
echo "近 5 分钟有写入"
}
l4_no_error_spike() {
local logfile="/data/app/${APP_NAME}/shared/logs/stderr.log"
[ -f "${logfile}" ] || { echo "无 stderr(正常)"; return 0; }
local n
n=$(tail -n 500 "${logfile}" | grep -ciE "ERROR|Exception" || true)
[ "${n}" -le 10 ] || { echo "近期 ERROR/Exception ${n} 条"; return 1; }
echo "近期 ERROR ${n} 条"
}
# ================= 执行 =================
echo "###############################################"
echo "# CVM 上云验收 env=${ENV} $(date '+%F %T')"
echo "###############################################"
echo "L0 基础设施层"
check L0 "实例元数据可获取" l0_instance
check L0 "环境标签一致" l0_tags
check L0 "磁盘水位 < 85%" l0_disk
echo "L1 系统层"
check L1 "时区 Asia/Shanghai" l1_timezone
check L1 "时钟偏移 < 1s" l1_clock_offset
check L1 "依赖命令齐全" l1_deps
check L1 "文件句柄 >= 65535" l1_ulimit
echo "L2 进程层"
check L2 "systemd active" l2_service_active
check L2 "重启次数 <= 3" l2_restarts
check L2 "运行时长 >= 30s" l2_uptime
echo "L3 网络层"
check L3 "端口监听" l3_listen
check L3 "绑定内网 IP" l3_bind_all
check L3 "下游依赖可达" l3_downstream
echo "L4 业务层"
check L4 "存活探针 200" l4_health_endpoint
check L4 "业务接口非 5xx" l4_business_endpoint
check L4 "日志持续写入" l4_log_writing
check L4 "无错误日志尖峰" l4_no_error_spike
echo "###############################################"
echo "# 结果: ${PASSED}/${TOTAL} 通过"
echo "###############################################"
if [ -n "${REPORT}" ]; then
{
echo "## CVM 验收报告"
echo ""
echo "- 环境: ${ENV}"
echo "- 时间: $(date '+%F %T')"
echo "- 结果: **${PASSED}/${TOTAL} 通过**"
echo ""
echo "| 层级 | 检查项 | 结果 | 详情 |"
echo "| --- | --- | --- | --- |"
printf '%s\n' "${RESULTS[@]}"
} > "${REPORT}"
echo "报告已写入 ${REPORT}"
fi
[ "${PASSED}" -eq "${TOTAL}" ]
2.2 批量执行
bash
#!/bin/bash
# accept_all.sh ------ 多台 CVM 并发验收并汇总报告
set -uo pipefail
HOSTS_FILE="${1:-hosts.txt}"
ENV="${2:-prod}"
OUT_DIR="report-$(date +%Y%m%d-%H%M%S)"
mkdir -p "${OUT_DIR}"
mapfile -t HOSTS < <(grep -vE '^#|^$' "${HOSTS_FILE}")
printf '%s\n' "${HOSTS[@]}" | xargs -P 8 -I{} bash -c '
h="$1"; env="$2"; out="$3"
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 "root@${h}" \
"APP_NAME=biz-web PORT=8080 DOWNSTREAM=biz-db.prod.internal:3306 \
/opt/ops/accept_cvm.sh -e ${env} -o /tmp/acc.md" >/dev/null 2>&1
scp -q "root@${h}:/tmp/acc.md" "${out}/${h}.md" 2>/dev/null \
|| echo "| ${h} | 无法连接或脚本失败 |" > "${out}/${h}.md"
' _ {} "${ENV}" "${OUT_DIR}"
echo "=== 汇总 ==="
fail=0
for f in "${OUT_DIR}"/*.md; do
host=$(basename "$f" .md)
if grep -q "FAIL" "$f"; then
echo "[FAIL] ${host}"
fail=$((fail + 1))
else
echo "[PASS] ${host}"
fi
done
{
echo "# CVM 批量验收汇总"
echo ""
echo "主机数: ${#HOSTS[@]},失败: ${fail}"
echo ""
for f in "${OUT_DIR}"/*.md; do
echo "## $(basename "$f" .md)"
echo ""
cat "$f"
echo ""
done
} > "${OUT_DIR}/SUMMARY.md"
echo "汇总报告: ${OUT_DIR}/SUMMARY.md"
exit "${fail}"
三、数据库侧:5 类比对指标
只对行数是不够的。下面是我们实际用的 5 类指标,覆盖"数量对、内容错"的隐蔽问题。
3.1 第 1 类:行数与主键范围
sql
-- 在源库与目标库分别执行,比对结果
SELECT 'TABLE_COUNT' AS metric, COUNT(*) AS val FROM information_schema.TABLES
WHERE TABLE_SCHEMA='biz' AND TABLE_TYPE='BASE TABLE'
UNION ALL
SELECT CONCAT('COUNT_', 'biz_order'), COUNT(*) FROM biz_order
UNION ALL
SELECT CONCAT('COUNT_', 'sys_user'), COUNT(*) FROM sys_user
UNION ALL
SELECT CONCAT('COUNT_', 'biz_order_item'), COUNT(*) FROM biz_order_item;
sql
-- 主键自增位点必须比源库大,否则迁移后新写入会主键冲突
SELECT TABLE_NAME, AUTO_INCREMENT FROM information_schema.TABLES
WHERE TABLE_SCHEMA='biz' AND AUTO_INCREMENT IS NOT NULL;
踩坑 :DTS 全量迁移后自增位点常常不同步,如果不检查,切换写流量后第一条 INSERT 就主键冲突。必须显式比对
AUTO_INCREMENT。
3.2 第 2 类:金额/数值精度
sql
-- 金额不允许用 FLOAT 比对,必须精确比对
SELECT
COUNT(*) AS cnt,
CAST(SUM(amount) AS DECIMAL(20,2)) AS sum_amount,
CAST(SUM(ROUND(amount,2)) AS DECIMAL(20,2)) AS sum_rounded,
MIN(amount) AS min_amount,
MAX(amount) AS max_amount
FROM biz_order
WHERE created_at >= '2026-01-01';
-- 找出小数位异常的行(精度被截断的信号)
SELECT id, order_no, amount
FROM biz_order
WHERE amount <> ROUND(amount, 2)
LIMIT 20;
判定 :sum_amount 必须完全一致(DECIMAL 精确比较)。如果差几分钱,说明有字段类型被降级(DECIMAL(18,4) → DECIMAL(18,2))。
3.3 第 3 类:主键校验和(最有效的一招)
行数相同但内容不同,是最难发现的。用 CRC32 + BIT_XOR 做全表内容指纹:
sql
-- 全表内容指纹:任意一行内容变化都会导致结果不同
SELECT
COUNT(*) AS cnt,
BIT_XOR(CRC32(CONCAT_WS('#', id, order_no, user_id, amount, status,
DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s')))) AS checksum
FROM biz_order;
注意两个坑:
CONCAT_WS需要用分隔符,避免'ab' + 'c'和'a' + 'bc'撞车NULL在CONCAT_WS中会被跳过,可能造成不同数据同指纹。对可空列用IFNULL(col, '\\N')显式标记:
sql
SELECT BIT_XOR(CRC32(CONCAT_WS('#',
id,
order_no,
IFNULL(remark, '\\N'),
DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s')))) AS checksum
FROM biz_order;
大表分批校验(避免一次性扫全表造成压力):
sql
-- 按主键区间分批,每批 100 万
SELECT
FLOOR(id / 1000000) AS batch,
COUNT(*) AS cnt,
BIT_XOR(CRC32(CONCAT_WS('#', id, order_no, IFNULL(amount,0)))) AS checksum
FROM biz_order
GROUP BY batch
ORDER BY batch;
3.4 第 4 类:外键与引用完整性
sql
-- 孤儿数据检测:明细表找不到主表的行
SELECT COUNT(*) AS orphan_count
FROM biz_order_item i
LEFT JOIN biz_order o ON i.order_id = o.id
WHERE o.id IS NULL;
-- 源库与目标库都执行,结果应都是 0(或都是同一个数)
DTS 迁移过程中如果表顺序不当,可能出现外键引用不到主表的情况。腾讯云 DTS 有"表关联迁移"选项,但仍建议迁移后主动校验一次。
3.5 第 5 类:业务语义抽样比对
统计指标对不上时,需要落到具体行上定位:
sql
-- 随机抽样 1000 行做逐行比对(用固定种子保证两边取到同样的行)
-- 源库
SELECT id, order_no, user_id, amount, status, created_at
FROM biz_order
WHERE id IN (SELECT id FROM (
SELECT id FROM biz_order ORDER BY CRC32(CONCAT(id, 'fixed-seed')) LIMIT 1000
) t)
ORDER BY id;
-- 目标库执行同样的 SQL,两边结果导出后 diff
bash
# 导出并比对
mysql -h$SRC -N -B -e "$SQL" biz > sample_src.tsv
mysql -h$DST -N -B -e "$SQL" biz > sample_dst.tsv
diff sample_src.tsv sample_dst.tsv && echo "抽样 1000 行完全一致"
3.6 时间与时区陷阱(单独强调)
sql
-- 迁移前后时区设置不一致会导致 DATETIME 值偏移 8 小时
SELECT @@global.time_zone, @@session.time_zone;
SHOW VARIABLES LIKE 'time_zone';
-- 抽样比对时,created_at 必须逐字节相同
-- 如果目标库值普遍 +8h 或 -8h,说明时区参数配置不一致
SELECT id, created_at FROM biz_order ORDER BY id DESC LIMIT 5;
这是最容易被忽略、后果最严重的比对项 。我们第一次对账时行数、金额全对,但订单时间整体偏移 8 小时------原因是源库参数组 time_zone=SYSTEM(CST),目标库实例新建时是 UTC。
四、自动生成验收报告
把 CVM 侧与数据库侧的结果合并成一份可交付的报告:
python
#!/usr/bin/env python3
"""
gen_report.py ------ 汇总 CVM 验收 + 数据库比对,生成 Markdown 验收报告
用法: python3 gen_report.py --cvm-dir report-xxx --db-result db_result.json
"""
import argparse
import glob
import json
import os
import re
from datetime import datetime
def parse_cvm_report(path):
"""从 accept_cvm.sh 生成的 md 中提取结果"""
txt = open(path, encoding='utf-8').read()
m = re.search(r'结果: \*\*(\d+)/(\d+) 通过\*\*', txt)
if m:
return int(m.group(1)), int(m.group(2))
return 0, 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--cvm-dir', required=True)
ap.add_argument('--db-result', required=True)
ap.add_argument('--env', default='prod')
ap.add_argument('--out', default='ACCEPTANCE_REPORT.md')
args = ap.parse_args()
db = json.load(open(args.db_result, encoding='utf-8'))
total_hosts = passed_hosts = 0
host_rows = []
for f in sorted(glob.glob(os.path.join(args.cvm_dir, '*.md'))):
host = os.path.basename(f)[:-3]
if host == 'SUMMARY':
continue
p, t = parse_cvm_report(f)
total_hosts += 1
if t and p == t:
passed_hosts += 1
host_rows.append((host, p, t, 'PASS' if t and p == t else '**FAIL**'))
lines = [
f"# 上云迁移验收报告",
"",
f"- 环境: **{args.env}**",
f"- 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f"- 验收结论: "
f"**{'通过' if passed_hosts == total_hosts and db.get('all_pass') else '未通过'}**",
"",
"## 一、CVM 验收汇总",
"",
f"主机数 {total_hosts},完全通过 {passed_hosts}",
"",
"| 主机 | 通过项 | 总数 | 结果 |",
"| --- | --- | --- | --- |",
]
for host, p, t, r in host_rows:
lines.append(f"| {host} | {p} | {t} | {r} |")
lines += [
"",
"## 二、数据库比对结果",
"",
"| 指标 | 源库 | 目标库 | 结果 |",
"| --- | --- | --- | --- |",
]
for item in db.get('checks', []):
r = 'PASS' if item['same'] else '**FAIL**'
lines.append(
f"| {item['name']} | {item['src']} | {item['dst']} | {r} |")
lines += [
"",
"## 三、结论与后续动作",
"",
f"- CVM 侧: {passed_hosts}/{total_hosts} 通过",
f"- 数据库侧: "
f"{'全部比对项一致' if db.get('all_pass') else '存在差异,需人工确认'}",
"",
]
if passed_hosts == total_hosts and db.get('all_pass'):
lines += [
"**验收通过。** 可以执行下一步:写流量切换。",
"",
"切换前请确认:",
"1. 源库增量同步延迟 < 1s",
"2. 回滚方案已演练过至少一次",
"3. 切换窗口已通知业务方",
]
else:
lines += [
"**验收未通过。** 请勿切换流量,按以下顺序处理:",
"1. 定位失败项根因(见上方 FAIL 行)",
"2. 修复后重新执行本报告生成流程",
"3. 若为数据差异,先评估是否继续使用 DTS 追平",
]
open(args.out, 'w', encoding='utf-8').write('\n'.join(lines))
print(f"报告已生成: {args.out}")
if __name__ == '__main__':
main()
报告样例
markdown
# 上云迁移验收报告
- 环境: **prod**
- 生成时间: 2026-09-11 16:20:33
- 验收结论: **通过**
## 一、CVM 验收汇总
主机数 12,完全通过 12
| 主机 | 通过项 | 总数 | 结果 |
| --- | --- | --- | --- |
| 10.10.30.11 | 17 | 17 | PASS |
| 10.10.30.12 | 17 | 17 | PASS |
| 10.10.30.13 | 17 | 17 | PASS |
## 二、数据库比对结果
| 指标 | 源库 | 目标库 | 结果 |
| --- | --- | --- | --- |
| 表数量 | 214 | 214 | PASS |
| biz_order 行数 | 8743215 | 8743215 | PASS |
| biz_order 金额合计 | 983241442.77 | 983241442.77 | PASS |
| biz_order 内容指纹 | 3f2a91c4 | 3f2a91c4 | PASS |
| 孤儿明细行 | 0 | 0 | PASS |
| 抽样 1000 行 diff | 0 差异 | 0 差异 | PASS |
| AUTO_INCREMENT | 8743300 | 8743300 | PASS |
## 三、结论与后续动作
**验收通过。** 可以执行下一步:写流量切换。
五、回滚决策树
验收不通过怎么办?先决定回不回滚,再决定修不修。
text
验收失败
│
├─ 失败项是否影响业务正确性?
│ (数据不一致 / 金额错误 / 接口 5xx)
│ │
│ ├─ 是 ──► 【立即回滚】切回源库(读流量已切换的情况下)
│ │ 回滚后:保留现场,导出日志与比对结果,再排查
│ │
│ └─ 否(如磁盘水位 86%、时区告警)
│ │
│ ├─ 能否在 30 分钟内修复且无需重启业务?
│ │ ├─ 能 ──► 修复后重跑验收,继续推进
│ │ └─ 不能 ──► 【回滚】不要赌,窗口比面子重要
│ │
│ └─ 是否可在切换后修复?
│ ├─ 能(如日志轮转配置)──► 记录为遗留项,带 todo 推进
│ └─ 不能 ──► 【回滚】
│
└─ 已切换写流量(不可逆点之后)
└─ 回滚需反向 DTS,成本极高
└─ 此时唯一选择:就地修复 + 快速止血
(提前演练反向同步是唯一的保险)
最关键的一条经验 :在"不可逆点"之前,任何犹豫都选择回滚。 回滚的代价是几小时,硬扛的代价可能是几天的数据修复。
我们准备的"不可逆点"是:写流量切换 + 停止 DTS 同步。这两个动作必须由同一个人、在同一分钟内顺序确认执行,且有独立的第二人复核。
六、一页纸验收清单(可打印)
text
上云迁移验收清单 环境: ________
执行人: ____________ 时间: ____________
【T-1 天】
[ ] 回滚方案已演练(含反向 DTS 可行性确认)
[ ] 变更窗口已通知业务方与客服
[ ] 源库增量同步延迟基线已记录(当前: ____ ms)
[ ] 目标库参数组已与源库逐项比对确认
[ ] lower_case_table_names 一致(不可改,务必确认)
【T 时:切换前】
[ ] CVM 验收 17/17 全通过(批改报告已生成)
[ ] DB 比对 7 项全一致
[ ] AUTO_INCREMENT 位点已校验
[ ] 时区一致性已确认(时间字段无偏移)
[ ] 抽样 1000 行 diff 为 0
【切换中】
[ ] 读流量 5% -> 观察 10 min(错误率、P99)
[ ] 读流量 50% -> 观察 30 min
[ ] 读流量 100% -> 观察 2 h(此时仍可秒级回滚)
[ ] 确认无误后,切换写流量
[ ] 确认无误后,停止 DTS
【T+1 天】
[ ] 慢查询日志与迁移前对比(新增慢查询数: ____)
[ ] 错误日志无新增异常类型
[ ] 数据库连接数、CPU 水位正常
[ ] 备份策略已生效并验证过恢复
[ ] 遗留 todo 已登记并指派
七、总结
从上云迁移项目里,我最大的体会是:验收不是最后一步,而应该是最先设计的一步。
我们这次的顺序是反过来的------先写验收脚本(accept_cvm.sh 和比对 SQL),再写部署脚本。这样带来的好处非常实在:
- 验收脚本定义了"什么叫成功",部署脚本的实现目标一下就清晰了,不需要反复讨论"要不要加个健康检查"。
- 验收脚本是回滚的触发器。部署脚本里的健康检查本质上就是验收脚本的子集,可以共用同一套逻辑,避免"部署时校验通过、验收时不通过"的矛盾。
- 验收报告是给业务方看的交付物。运维说的"迁移成功了"没人信,一份 17/17、7 项数据全一致的报告才是。
最后一句:敢不敢回滚,取决于有没有验收。 没有验收体系的迁移,本质上是在赌。
系列文章:
- 《传统物理机业务上云:腾讯云助手改写 Shell 脚本,一键生成 CVM 部署与业务校验脚本》
- 《从物理机到 CVM 全流程落地:部署脚本的 8 个云化改造点》
- 《深度复盘|数据库迁移实战(上):腾讯云助手解析慢查询日志,定位索引缺失与语法不兼容》
- 《深度复盘|数据库迁移实战(下):从自建 MySQL 5.7 到腾讯云 MySQL 8.0 的 SQL 兼容改造》
- 本篇