10_dump_legacy.sh
20 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
#!/usr/bin/env bash
#
# 六库合一:把六个源库的"保留侧"灌进 parking_cloud
#
# 判定依据见 ../merge-conflict-plan.md;不搬的表由 exclude_tables.txt 给出
# (由 ../tools/gen_migration_sql.py 从 check_subset.py 的 CHECKS 生成,别手改)。
#
# 全程**只读源库**:只 mysqldump,不写源库。源库保持可回滚。
#
# 两种模式,**校验方式完全不同,别无脑混用**:
#
# · 默认(全量迁移):709 张表 / ~19 亿行 / ~747 GB。主张是"零丢失",
# 配套校验 99_verify.sql + 98_count_reconcile.sql(那边断言"行数相等")。
# 磁盘按 1.3× 预留;按库分段,可中断续跑(见 ONLY=)。
#
# · SAMPLE=N(抽样开发库):每张表最多 N 行,给开发用的镜像。主张是
# "每张表都在、列结构一致、每张表都落了样例行",配套校验 97_verify_sample.sql。
# ⚠️ 抽样镜像上跑 98/99 §3§4 会**必然全红**——那两个断言的是行数相等。
# 另外抽样模式**默认不做一致性快照**(SNAPSHOT=0):抽几百行不在乎撕裂,
# 而不做快照就不需要 RELOAD 权限(全量迁移正是卡在这上面)。
#
# ⚠️ 抽样模式会把**源库的真实数据**写到开发机的 dump 文件里。含密钥/口令的表
# 由 struct_only_tables.txt 给出(由 gen_migration_sql.py 从列名判据生成),
# 这些表**只搬结构**——生产的支付/短信/云账号凭据不进开发环境。
#
# ⚠️ 字符集是本脚本最需要小心的部分,见 §字符集。
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${ENV_FILE:-$HERE/../../私密/legacy-ro.env}"
WORK="${WORK:-$HERE/../.dump}" # 中间产物目录(.gitignore,勿提交)
TARGET_DB="${TARGET_DB:-parking_cloud}"
ONLY="${ONLY:-}" # 逗号分隔,只跑这些库,如 ONLY=zt_park
SAMPLE="${SAMPLE:-}" # 抽样模式每张表最多几行;空/0 = 全量迁移
# 六个源库(库名与业务域错位是历史事实,见 ../legacy-db-actual.md §1)
DBS=(zt_sys zt_park zt_order zt_order_n zt_bill zt_charge)
log() { printf '\033[36m[%s]\033[0m %s\n' "$(date +%H:%M:%S)" "$*"; }
die() { printf '\033[31m错误:\033[0m %s\n' "$*" >&2; exit 1; }
[[ -f "$ENV_FILE" ]] || die "找不到 env 文件:$ENV_FILE"
# 只读凭据:本脚本用它 dump,不用它写任何源库
set -a; . "$ENV_FILE"; set +a
: "${LEGACY_DB_HOST:?}"; : "${LEGACY_DB_USER:?}"; : "${LEGACY_DB_PASSWORD:?}"
PORT="${LEGACY_DB_PORT:-3306}"
# 目标库的连接方式由调用者给(本脚本不假定目标与源同实例)
: "${TARGET_HOST:=$LEGACY_DB_HOST}"
: "${TARGET_USER:=$LEGACY_DB_USER}"
# ⚠️ 这里用 `-` 而不是 `:-`:`:-` 把"显式传了空口令"也当成没传,
# 于是本地无口令的目标库(root 无密码)会被悄悄替换成**源库口令**,
# 连不上还算好的——它同时意味着"只要 TARGET_HOST 指到别处,
# 源库口令就被发到那台机器上"。`-` 只在**没设过**时才取默认值:
# 想用源库口令就别设 TARGET_PASSWORD;想用空口令就显式 `TARGET_PASSWORD=`。
: "${TARGET_PASSWORD-$LEGACY_DB_PASSWORD}"
: "${TARGET_PORT:=$PORT}"
# 客户端可覆盖:本机 Homebrew 的 mysql/mysqldump 26.x 连不上老库(见 preflight_dump),
# 指到能用的客户端即可,不必改脚本:
# export DUMP_BIN=/opt/homebrew/opt/mysql-client/bin/mysqldump
# export MYSQL_BIN=/opt/homebrew/opt/mysql-client/bin/mysql
# ⚠️ 用 MariaDB 客户端要留意:`--set-gtid-purged` 是 MySQL 特有的开关,
# mariadb-dump 未必认(本机没装 MariaDB,未实测)。真要用就先单独试一条
# `mariadb-dump --set-gtid-purged=OFF --no-data zt_sys sys_org`;认不了就换 mysql-client。
MYSQL_BIN="${MYSQL_BIN:-mysql}"
DUMP_BIN="${DUMP_BIN:-mysqldump}"
command -v "$MYSQL_BIN" >/dev/null || die "缺少 mysql 客户端(${MYSQL_BIN})"
command -v "$DUMP_BIN" >/dev/null || die "缺少 mysqldump(${DUMP_BIN})"
EXCLUDE_FILE="$HERE/exclude_tables.txt"
[[ -f "$EXCLUDE_FILE" ]] || die "缺少 ${EXCLUDE_FILE}(先跑 tools/gen_migration_sql.py)"
# 抽样模式:含密钥/口令的表清单(只搬结构)。缺了**必须停下来**——
# 没有它就会把生产的支付/短信/云账号凭据 dump 到开发机上(见 §抽样模式)。
STRUCT_ONLY_FILE="$HERE/struct_only_tables.txt"
IS_SAMPLE=0
if [[ -n "$SAMPLE" && "$SAMPLE" != "0" ]]; then
IS_SAMPLE=1
[[ "$SAMPLE" =~ ^[0-9]+$ ]] || die "SAMPLE 必须是整数(当前:${SAMPLE})"
[[ -f "$STRUCT_ONLY_FILE" ]] \
|| die "抽样模式缺 ${STRUCT_ONLY_FILE}(先跑 tools/gen_migration_sql.py)
—— 这份清单决定哪些表**只搬结构不搬数据**(含密钥/口令的表)。
没有它,生产凭据会被 dump 到开发机上,所以宁可不跑。"
fi
# ⚠️ 口令**不放进 argv**:`--password=xxx` 会让同机任何进程从 `ps` 读到生产口令,
# 客户端也会打 "Using a password on the command line interface can be insecure"。
# 改走 MYSQL_PWD,且**按命令前缀**给(dump 用源口令、load 用目标口令,不能混)。
SRC=(--host="$LEGACY_DB_HOST" --port="$PORT" --user="$LEGACY_DB_USER")
DST=(--host="$TARGET_HOST" --port="$TARGET_PORT" --user="$TARGET_USER")
mkdir -p "$WORK"
# 一致性快照:`--single-transaction` 让 709 张表落在**同一个一致性读视图**里。
# ⚠️ 它需要 **RELOAD** 权限——客户端在 dump 前会先发一条
# `FLUSH /*!40101 LOCAL */ TABLES`。本项目的源库只读账号**没有**它,
# 实测:`--single-transaction` 无论怎么组合都是 rc=2
# ERROR 1227: Access denied; you need the RELOAD privilege(s)
# (`--no-data`、`--skip-lock-tables` 都救不了;不加它则 dump 正常。)
# 两条出路,**都要显式**,不能默认偷偷降级:
# · DBA 给只读账号加 RELOAD —— 推荐。RELOAD 是管理权限、**不是数据写权限**,
# 加了它账号依然只读(本项目 dump 全程不发写语句)
# · SNAPSHOT=0 接受「每张表各自一个时间点」的非一致 dump——
# 库在线写入时可能撕裂(订单表与明细表对不上)。本次的硬要求是零数据丢失,
# 所以只在你确认源库已静默时才这么用
# 抽样模式默认**不开**快照:只抽几百行,撕裂与否不影响开发用途;而不做快照
# 就不需要 RELOAD 权限——那正是全量迁移卡住的地方(见上)。显式 SNAPSHOT=1 仍可覆盖。
if [[ "$IS_SAMPLE" == "1" ]]; then
SNAPSHOT="${SNAPSHOT:-0}"
else
SNAPSHOT="${SNAPSHOT:-1}"
fi
# 客户端**真能连**吗?只 `command -v` 不够——Homebrew 的 MySQL 26.x 移除了
# mysql_native_password,而老库账号用的正是它,于是连第一条 SQL 都发不出去
# (实测:exit 2、`ERROR 2059`)。若不预检,会在 dump 到一半(可能几十分钟后)才炸。
#
# 顺带在这一步就探出 RELOAD 权限问题:`--single-transaction` 的失败在**动第一张表之前**
# 就能看见(它是 dump 的第一条语句),别等搬了半小时才发现要改权限。
preflight_dump() {
local err
err=$(MYSQL_PWD="$LEGACY_DB_PASSWORD" "$DUMP_BIN" "${SRC[@]}" \
--no-tablespaces --skip-comments \
--default-character-set=utf8mb4 --no-data zt_sys sys_org 2>&1 >/dev/null) && {
if [[ "$SNAPSHOT" == "1" ]]; then
err=$(MYSQL_PWD="$LEGACY_DB_PASSWORD" "$DUMP_BIN" "${SRC[@]}" \
--single-transaction --no-tablespaces --skip-comments \
--default-character-set=utf8mb4 --no-data zt_sys sys_org 2>&1 >/dev/null) && return 0
if [[ "$err" == *RELOAD* || "$err" == *"1227"* ]]; then
die "源库账号没有 RELOAD 权限,做不了一致性快照
$err
—— \`--single-transaction\` 在 dump 前要先发 \`FLUSH TABLES\`,它要 RELOAD 权限。
两条出路(**别静默降级**,非一致快照在写库上会撕裂数据):
① 让 DBA 给只读账号加 RELOAD —— 推荐。RELOAD 是管理权限、不是数据写权限,
加了账号依然只读(本脚本 dump 全程不发写语句)
② 确认源库已静默后,显式接受非一致 dump:SNAPSHOT=0 bash $0 dump"
fi
die "mysqldump 一致性快照预检失败:$err"
fi
printf '\033[33m⚠️ SNAPSHOT=0:本次 dump 不是一致性快照,库在写入时可能撕裂\033[0m\n' >&2
return 0
}
if [[ "$err" == *mysql_native_password* ]]; then
die "mysqldump 连不上老库:认证插件缺失
$err
—— 当前客户端($(basename "$DUMP_BIN"))无法识别老库账号用的 mysql_native_password。
⚠️ Homebrew 的 mysql / mysql@26.7(26.7.0)**不行**,实测退出码 2。
本机实测**可用**的是 mysql-client 9.6:
export DUMP_BIN=/opt/homebrew/opt/mysql-client/bin/mysqldump
export MYSQL_BIN=/opt/homebrew/opt/mysql-client/bin/mysql
(MariaDB 客户端也能连,但 `--set-gtid-purged` 是 MySQL 特有开关、未必支持;
Python 侧的 tools/*.py 走 PyMySQL,不受影响)"
fi
die "mysqldump 预检失败:$err"
}
# ---------------------------------------------------------------------------
# 字符集:709 张表里非 utf8mb4 的有 670 张(实测)
# 表级:utf8_general_ci 635 / utf8_bin 30 / latin1_swedish_ci 1 / utf8mb4_* 43
# 列级:utf8_bin 324 列 / latin1_swedish_ci 23 列
#
# ⚠️ utf8_bin 必须映射到 **utf8mb4_bin**,不能一律拍成 utf8mb4_general_ci:
# _bin 是**大小写敏感**、_ci 是**大小写不敏感**,换了以后
# `pl_no='P15040225z'` 这类查询会开始命中它以前命中不到的行。
# 实测 _bin 列上**没有**唯一索引(所以不会撞键),但比较语义会变——
# 这是"静默改变业务结果"的那一类,不能图省事。
#
# utf8mb3 → utf8mb4 是安全的(utf8mb3 存不下 4 字节字符,无损)。
# 唯一的 latin1 表 zt_order_n.td_b_pay_blncac_wx 的 23 列装的都是
# 金额/订单号/交易状态(纯 ASCII),latin1→utf8mb4 同样无损。
# ---------------------------------------------------------------------------
rewrite_charset() {
# 写法要点(踩过坑,别"简化"回去):
# ① **所有** utf8mb4 都得先变成 @@MB4@@ 占位——不只是"已经存在的",
# 连后面**即将写进去的**也是。第一版没做这一点,于是
# `utf8_bin → utf8mb4_bin` 写进去的字面量又被最后那条
# `s/utf8/utf8mb4/g` 二次替换成 `utf8mb4mb4_bin`(建表直接报错)。
# 一句话:**同一个 token 不能被两条规则都碰到**。
# ② 占位之后,`s/utf8/@@MB4@@/g` 这种朴素全量替换才是安全的。
# ③ **`utf8mb3` 必须单独一条,且排在朴素 `s/utf8/…/g` 前面**:
# MySQL 8.0.29+ 的 mysqldump 会把 `utf8` 输出成 `utf8mb3`。
# 不先处理它,`s/utf8/@@MB4@@/g` 会把它变成 `utf8mb4mb3`(建表报错)。
# 本机实测的客户端(mysql-client 9.6 对 5.7 服务端)**不会**输出 utf8mb3,
# 但操作者可能换客户端——所以按"会输出"来防,不赌。
# ④ **不要顺手补 COLLATE**。utf8mb4 的默认排序规则本来就是 utf8mb4_general_ci,
# 补了会和原本带 COLLATE 的表撞成
# `COLLATE=utf8mb4_general_ci COLLATE=utf8mb4_bin`(重复 COLLATE,建表报错)。
# 实测踩中:`DEFAULT CHARSET=utf8 COLLATE=utf8_bin`(正是那 30 张 utf8_bin 表)
sed -E \
-e 's/utf8mb4/@@MB4@@/g' \
-e 's/utf8mb3/@@MB4@@/g' \
-e 's/utf8_bin/@@MB4@@_bin/g' \
-e 's/utf8_general_ci/@@MB4@@_general_ci/g' \
-e 's/utf8_unicode_ci/@@MB4@@_unicode_ci/g' \
-e 's/latin1_swedish_ci/@@MB4@@_general_ci/g' \
-e 's/utf8/@@MB4@@/g' \
-e 's/latin1/@@MB4@@/g' \
-e 's/@@MB4@@/utf8mb4/g'
}
# 改完必须自查:残留的 utf8_ / charset=utf8 / latin1 / **mb4mb4** / **mb4mb3** 都算失败。
# `mb4mb4` 是第一版漏判的写法——当时 guard 放行了坏输出("审计说 OK,东西是坏的",
# AGENTS.md §1.1 记的正是这一类);`mb4mb3` 是同一个错法的另一半(utf8mb3 版本)。
# 两者都别删:**守卫的价值全在它拦得住什么**。
guard_charset() {
local f="$1" bad
# utf8mb4_general_ci 不会误匹配 utf8_general_ci(前者含 "4",后者不是它的子串)
bad=$(grep -cE 'mb4mb4|mb4mb3|utf8mb3|utf8_general_ci|utf8_bin|utf8_unicode_ci|charset=utf8([^m]|$)|CHARSET=utf8([^m]|$)|COLLATE=utf8 |latin1' "$f" || true)
[[ "$bad" == "0" ]] || die "$f 里还有 $bad 处未转换/被重复替换的字符集声明,请检查 rewrite_charset 的规则"
}
build_excludes() {
# exclude_tables.txt 每行 `<db>.<table>` → 生成该库的 --ignore-table 参数
local db="$1"
grep -E "^${db}\." "$EXCLUDE_FILE" | sed 's/^/--ignore-table=/' || true
}
build_struct_only() {
# struct_only_tables.txt 每行 `<db>.<table>` → 数据那一遍要 --ignore-table 的表
local db="$1"
grep -E "^${db}\." "$STRUCT_ONLY_FILE" | sed 's/^/--ignore-table=/' || true
}
dump_one() {
local db="$1" sql="$WORK/$1.sql"
# ⚠️ 别用 `mapfile`:macOS 自带的 bash 是 **3.2**(无 mapfile/readarray),
# 原版这行直接 `mapfile: command not found` 中止——彩排时第一个踩到的就是它。
# `ign[${#ign[@]}]=` 是 bash 3.2 也能用的追加写法。
# ⚠️ 空数组也要能展开:bash 3.2 下 `set -u` 让 `"${ign[@]}"` 在数组为空时
# 报 unbound variable,所以下面用的是 `${ign[@]+"${ign[@]}"}`。
# zt_sys / zt_charge 就是 0 张排除表,走的就是这条空分支。
local -a ign=()
while IFS= read -r t; do
ign[${#ign[@]}]="$t"
done < <(build_excludes "$db")
# 一致性快照开关(默认开;只读账号缺 RELOAD 时由 preflight_dump 拦下,见上)
local -a snap=()
[[ "$SNAPSHOT" == "1" ]] && snap=(--single-transaction)
# ⚠️ 不要为"省一次 mysqldump 调用"把这两遍并成一遍:`--where` 是**整个 dump 一把梭**
# 的开关,没法"这几张表取 N 行、那几张表一行不取"。能按表区分的只有 --ignore-table,
# 所以只能:① 全部表的结构 ② 除结构表外的数据。
local -a only=()
if [[ "$IS_SAMPLE" == "1" ]]; then
while IFS= read -r t; do
only[${#only[@]}]="$t"
done < <(build_struct_only "$db")
fi
log "dump $db → $sql (忽略 ${#ign[@]} 张表,SNAPSHOT=${SNAPSHOT},SAMPLE=${SAMPLE:-全量},只搬结构 ${#only[@]} 张)"
# --single-transaction:InnoDB 一致性快照,不锁表(源库在线,不能用 --lock-all-tables)
# --set-gtid-purged=OFF:⚠️ **必须有**。源库开了 GTID(实测),不加这个开关,
# dump 头里会写一大段 `SET @@GLOBAL.GTID_PURGED='<几十个 uuid:区间>'`,
# 而目标库若 gtid_mode=OFF(MySQL 8 的默认值)就整段被拒——load 从第一屏就失败。
# GTID_PURGED 只在"拿这份 dump 搭从库"时有意义,本次是并入独立目标库,不需要。
# ⚠️ 本机目标库恰好 gtid_mode=ON,**不加也能过**——"我这儿能跑"最会掩盖这类问题。
# --default-character-set=utf8mb4:以 utf8mb4 出线,服务端会把 utf8mb3/latin1 列转过来
# --no-tablespaces / --skip-add-locks / --skip-comments:省得需要额外权限、减小体积
local -a nodata=()
[[ "$IS_SAMPLE" == "1" ]] && nodata=(--no-data) # 抽样:第一遍只出结构
MYSQL_PWD="$LEGACY_DB_PASSWORD" "$DUMP_BIN" "${SRC[@]}" \
${snap[@]+"${snap[@]}"} ${nodata[@]+"${nodata[@]}"} --quick --skip-lock-tables \
--set-gtid-purged=OFF \
--default-character-set=utf8mb4 \
--no-tablespaces --skip-add-locks --skip-comments \
--hex-blob --routines=false --events=false --triggers=true \
${ign[@]+"${ign[@]}"} \
"$db" > "$sql.raw"
# 抽样:第二遍只出数据,`--where="1=1 LIMIT N"` 按表截断(实测有效:
# 同一张 242 行的表,带它出 3 行)。含密钥/口令的表在这遍被排除掉,
# 于是它们**有结构、0 行**——这正是 97_verify_sample.sql §3 断言的东西。
if [[ "$IS_SAMPLE" == "1" ]]; then
log "抽样数据(每张表 ≤ ${SAMPLE} 行,跳过 ${#only[@]} 张含密钥/口令的表)"
MYSQL_PWD="$LEGACY_DB_PASSWORD" "$DUMP_BIN" "${SRC[@]}" \
--no-create-info --triggers=false --where="1=1 LIMIT ${SAMPLE}" \
--quick --skip-lock-tables --set-gtid-purged=OFF \
--default-character-set=utf8mb4 \
--no-tablespaces --skip-add-locks --skip-comments --hex-blob \
--routines=false --events=false \
${ign[@]+"${ign[@]}"} ${only[@]+"${only[@]}"} \
"$db" >> "$sql.raw"
fi
log "改写字符集声明(utf8_bin→utf8mb4_bin)"
rewrite_charset < "$sql.raw" > "$sql"
guard_charset "$sql"
local raw_mb; raw_mb=$(du -m "$sql.raw" | cut -f1)
rm -f "$sql.raw"
log "$db 完成:$sql (${raw_mb} MB)"
}
load_one() {
local db="$1" sql="$WORK/$1.sql"
[[ -f "$sql" ]] || die "$sql 不存在,先 dump"
log "load $sql → $TARGET_DB"
# 源库里带 `CREATE DATABASE`/`USE` 时,统一落到目标库
sed -e "s/^USE \`.*\`;/USE \`$TARGET_DB\`;/" "$sql" \
| MYSQL_PWD="$TARGET_PASSWORD" "$MYSQL_BIN" "${DST[@]}" \
--default-character-set=utf8mb4 "$TARGET_DB"
log "$db 灌入完成"
}
case "${1:-}" in
dump) # dump 前三道预检,都要在**动第一张表之前**给出结论:
# ① 本脚本在 bash 3.2 上跑得起来吗(离线、秒级;macOS 自带的就是 3.2)
# ② 字符集改写自测(离线、秒级;含"guard 必须拦下坏输出"的反向用例)
# ③ 客户端真能连老库吗(26.x 的 mysql_native_password;只读账号的 RELOAD)
# 前两道是离线自测,放在联网预检之前:没有可用客户端的人也该能验证脚本逻辑。
# 三件都别拖到搬了半小时才发现。
bash "$HERE/tests/shell_compat_test.sh" \
|| die "bash 兼容性检查未通过,已中止(见 sql/migration/tests/)"
bash "$HERE/tests/charset_rewrite_test.sh" \
|| die "字符集改写自测未通过,已中止(见 sql/migration/tests/)"
preflight_dump
for db in "${DBS[@]}"; do
[[ -z "$ONLY" || ",$ONLY," == *",$db,"* ]] && dump_one "$db"
done ;;
load) for db in "${DBS[@]}"; do
[[ -z "$ONLY" || ",$ONLY," == *",$db,"* ]] && load_one "$db"
done ;;
all) "$0" dump; "$0" load ;;
*) die "用法:$0 {dump|load|all} [ONLY=zt_park] [WORK=dir] [SNAPSHOT=0] [SAMPLE=500]" ;;
esac
if [[ "$IS_SAMPLE" == "1" ]]; then
cat <<EOF
下一步(**抽样开发库**;判据与全量迁移相反,别跑 98/99 §3§4):
1. mysql --force -N -h\$TARGET_HOST -u\$TARGET_USER -p < 97_verify_sample.sql
—— §1~§5 期望**全部 0 行**。它会查:表都在、列结构一致、
含密钥/口令的表 0 行、其余每张表都落了样例行。
⚠️ `--force` 别省:§4 逐表 COUNT(*) 撞到不存在的表会报 ERROR 1146,
不加它 mysql 就**从这里退出、§5 不会执行**(实测踩到,见文件里 §跑法)。
想证明这五段真会报红(不是空转),跑 tests/97_negative_test.sh。
2. ⚠️ 不要跑 98_count_reconcile.sql / 99_verify.sql §3§4:那两份断言
"目标行数 == 源库行数",在只抽了 ${SAMPLE} 行的镜像上**必然全红**。
3. 源库全程只读,保持可回滚,别删。
EOF
else
cat <<EOF
下一步(**全量迁移**,顺序不能变):
1. mysql -h\$TARGET_HOST -u\$TARGET_USER -p $TARGET_DB < 20_union_lost_rows.sql
—— 补进只存在于副本的 32 行(29 客户 + 3 微信支付配置)
2. mysql -h\$TARGET_HOST -u\$TARGET_USER -p $TARGET_DB < 99_verify.sql
3. mysql -h\$TARGET_HOST -u\$TARGET_USER -p $TARGET_DB < 98_count_reconcile.sql
4. 校验通过后,源库仍保持只读保留(回滚路径),别删。
EOF
fi
# 注:不要在 load 阶段开 FOREIGN_KEY_CHECKS=0 —— 存量库没有外键约束(实测
# information_schema.REFERENTIAL_CONSTRAINTS 为空),开了只会掩盖问题。