六库合一:把 zt_sys / zt_park / zt_order / zt_order_n / zt_bill / zt_charge 并成 parking_cloud
哪张表保留哪一份见 <code>../merge-conflict-plan.md</code>, 每张表的实测结论见 <code>../legacy-db-actual.md</code>。 本文件只讲怎么搬。
0. 一句话
六个源库都在同一台实例上,所以能直接跨库 LEFT JOIN 数清"哪份副本可丢、丢几行"。
这件事已经数完了,结论固化成 CHECKS(<code>../tools/check_subset.py</code>),
本目录的产物全部从它生成,不存在第二份人工清单。
另有一类"不搬":§8 逐张确认后拍板的 7 张(2026-10-10,见
<code>../legacy-db-actual.md</code> §8.3 / §10.1)。它的事实来源是
<code>../tools/audit_junk_tables.py</code> 的 DO_NOT_MIGRATE,
同样是 import 现算、不手抄第二份。两类合起来才是完整的排除集。
⚠️ 这 7 张不是一个强度,别当成同一种:
| 层级 | 张数 | 判据来源 | 有新数据吗 |
|---|---|---|---|
空表 |
4 | 数据自证(0 行 + 无表位置引用) | 没有,本来就 0 行 |
备份 |
3 | 外部拍板(业务方 2026-10-10「备份表,无需理会」) | 有,约 133 GB |
备份 那 3 张的守卫只能守前提(仍存在、仍无表位置引用),证明不了决定本身对不对。
0.1 两种模式:全量迁移 vs 抽样开发库
SAMPLE=N 这个环境变量把 10_dump_legacy.sh 切成两种模式。两者的主张不同,
所以判据必须跟着主张换——沿用同一套判据,得到的就是一份永远报红的校验:
全量迁移(默认,SAMPLE 未设) |
抽样开发库(SAMPLE=N) |
|
|---|---|---|
| 主张 | "一行不丢" | "每张表都到齐、结构一致、每张表都有样例数据" |
| 数据量 | 709 张表 / ~19 亿行 / ~747 GB(见 §3) | 每表最多 N 行(本机跑的是 SAMPLE=500) |
| 校验产物 | 99_verify.sql(§4 ⑤)+ 98_count_reconcile.sql(§4 ⑥) |
97_verify_sample.sql(§4 ⑦) |
| 通过判据 | ⑤ 全 0 行;⑥ 逐表行数与源库相等 | mysql --force -N < 97_verify_sample.sql §1~§5 全部 0 行 |
| 用途 | 生产切换 | 本机开发:库里有数据、能点页面,但不搬生产全量 |
⚠️ 98 / 99 / 20 只对"全量迁移"有意义,别在抽样镜像上跑(这三份文件头都加了告示)。 它们判的是"行数与源库相等",而抽样镜像故意每表只搬 N 行——跑起来必然全红, 且红得毫无信息量。这就是 AGENTS.md 推论 5:"永远报红的守卫和永不报红的守卫一样没用"。
⚠️ 97_verify_sample.sql 的 §2 比的是生成时冻结的源库列结构(文件里嵌了一个 _cols
临时表),不是当场去读源库——开发机上没有 zt_park / zt_bill 这些源库,当场 join
会让每一列都报"目标库没有这一列"(同样是永远全红)。代价是源库改结构后要重跑生成器,
否则快照过期;那是要报出来的信号,不是假阳性(推论 3)。
🔒 抽样模式会把真实源库的数据行写到开发机上,所以含密钥/口令的表只搬结构、一行不抽:
struct_only_tables.txt(24 张)里的表一律走 --no-data。10_dump_legacy.sh 在该文件
缺失时直接 die(不降级、不猜),§3 再断言这些表在目标库里必须 0 行——两道闸,
缺一道就得靠人记得。它同时也是 §7「密钥不落盘」在抽样模式下的承接物。
1. 产物
| 文件 | 作用 | 模式 | 谁生成 |
|---|---|---|---|
exclude_tables.txt |
不搬的表:重名表副本 23 张 + 拍板不搬 7 张(空表 4 + 备份 3)= 30 行(喂 mysqldump --ignore-table) |
两模式共用 | tools/gen_migration_sql.py |
10_dump_legacy.sh |
顺序驱动:dump → 改写字符集 → load 进目标库 |
两模式共用 | 手写 |
97_verify_sample.sql |
抽样校验(覆盖率 / 列结构 / 只搬结构的表 0 行 / 抽样是否落地 / 字符集)——秒级 | 抽样 | tools/gen_migration_sql.py |
struct_only_tables.txt |
只搬结构、不抽样的 24 张表(含密钥/口令),喂 --no-data,缺失即 die |
抽样 | tools/gen_migration_sql.py |
20_union_lost_rows.sql |
唯一的搬运动作:只存在于副本、必须补进保留侧的 32 行 | 全量 | tools/gen_migration_sql.py |
99_verify.sql |
合库后校验(覆盖率 / 字符集 / 行数 / 并集)——快,适合反复跑 | 全量 | tools/gen_migration_sql.py |
98_count_reconcile.sql |
逐表行数对账(677 张表,6 段)——"零丢失"的正面证据,小时级 | 全量 | tools/gen_migration_sql.py |
tests/charset_rewrite_test.sh |
10_dump_legacy.sh 里字符集改写的自测(含反向用例) |
两模式共用 | 手写 |
tests/shell_compat_test.sh |
脚本在 macOS 自带的 bash 3.2 上跑不跑得起来(mapfile、$VAR 吞多字节字符) |
两模式共用 | 手写 |
tests/97_negative_test.sh |
97_verify_sample.sql 的反向用例:逐段证明它真的会报红(§4 ⑦) |
抽样 | 手写 |
⚠️ exclude_tables.txt / struct_only_tables.txt / 97_*.sql / 20_*.sql /
98_*.sql / 99_verify.sql 都是生成物,不要手改——
改了下次重跑生成器就被覆盖。要改判定:重名表副本改 check_subset.py 的 CHECKS;
废弃/备份表改 audit_junk_tables.py 的 DO_NOT_MIGRATE(改完先重跑 audit 脚本,
它会逐条复核那几张表是否仍无表位置引用、以及 空表 层是否仍是 0 行,
不成立就 exit 2)。然后重跑:
python3 -I ../tools/gen_migration_sql.py # 从本目录(sql/migration/)
python3 -I sql/tools/gen_migration_sql.py # 或从 parking-cloud/ 根目录
--env 不用传:脚本默认按自身位置去解析 parking-cloud/私密/legacy-ro.env,
所以从哪个目录跑都对。要指到别的 env 文件才用 --env,此时它是相对当前目录的
(以前这里写的 --env ../../../私密/legacy-ro.env 是错的,多了一层——照抄会
FileNotFoundError,实测踩过)。
产物是 CHECKS 的确定性函数:连跑两次生成器,六个产物 md5 必须完全一致;
不一致说明产物里混进了随运行变化的东西(曾经就有:源库 TABLE_ROWS 估算值被原样印进去,
源库在线写入 ⇒ 每次差几十行 ⇒ "重跑看 diff"这个检查永远不可能干净)。重跑后有 diff,
就当成"有人改了产物或 CHECKS 没重跑"来查,别当成正常现象。
2. 前置条件
- 只读账号已在
parking-cloud/私密/legacy-ro.env配好(该目录 gitignore,勿提交) - 目标实例上有
parking_cloud库(00_create_database.sql建),默认utf8mb4 - 目标磁盘 ≥ 源库 1.3 倍(约 1 TB)——见 §3
mysql/mysqldump客户端可用- ⚠️ 老库账号用
mysql_native_password,很多客户端认不了它。 本机实测:Homebrew 的mysql/mysql@26.7(26.7.0)连不上——mysqldump退出码 2、报ERROR 2059;而mysql-client9.6.0 可以。 指给脚本即可,不用改脚本:bash export DUMP_BIN=/opt/homebrew/opt/mysql-client/bin/mysqldump export MYSQL_BIN=/opt/homebrew/opt/mysql-client/bin/mysql dump会在动第一张表之前做连通性预检,遇到这个问题立刻给出上面这段提示并退出, 不会搬到一半才失败(见 §4)- ⚠️ 换 MariaDB 客户端的话注意:
--set-gtid-purged是 MySQL 特有开关,mariadb-dump 未必认(本机没装,未实测)。认不了就换回 mysql-client - Python 侧走 PyMySQL,不受影响,
../tools/*.py一直可用
- ⚠️ 老库账号用
- 口令走
MYSQL_PWD传给客户端,不在 argv 里(否则同机ps就能读到生产口令) - ⚠️ 源库账号需要
RELOAD权限(做一致性快照的前提,见下)--single-transaction在 dump 前要先发一条FLUSH TABLES,它要RELOAD。 当前只读账号没有它(实测SHOW GRANTS:只有SELECT, LOCK TABLES, SHOW VIEW- 全局
PROCESS, REPLICATION SLAVE/CLIENT),于是--single-transaction一律 rc=2、 报ERROR 1227 ... need the RELOAD privilege(s)
- 全局
RELOAD是管理权限、不是数据写权限:加了它账号依然只读(本脚本 dump 全程不发写语句)- 加不了、又确认源库已静默时,才用
SNAPSHOT=0显式接受非一致 dump——它会在库在线时撕裂
3. ⚠️ 规模:709 张表 / ~19 亿行 / ~747 GB
其中 16 亿行在 zt_park。这不是"跑一下就完"的量级:
💡 只想在开发机上有个能跑的库?那就不该搬全量 —— 用
SAMPLE=N(§0.1)。 本机实测SAMPLE=500跑完全部六个库约 7 分钟、产物 65 MB,每张表都有数据; 下面的体量与工期只在生产切换时才适用。
10_dump_legacy.sh dump按库分段,可中断续跑:ONLY=zt_park、ONLY=zt_order- 中间产物在
sql/.dump/(已 gitignore),单库文件可能上百 GB,别放在空间紧张的盘上 load阶段逐库灌入,期间目标库不可用- ✅ 已先拿
ONLY=zt_sys走通过全流程(2026-10-09 彩排,见 §4.1)。换目标实例 (尤其换 MySQL 大版本)后仍建议再走一遍——彩排是在 MySQL 26.7 上做的
3.1 实测速率与工期待排(彩排实测,不是估算)
| 步骤 | zt_sys(38 表 / 593 MB)实测 |
|---|---|
mysqldump |
54~98 秒(首跑 98 秒、二跑 54 秒,首跑冷)→ 约 6~11 MB/s |
字符集改写(sed 全文件过一遍) |
50 秒 → 约 12 MB/s |
load(目标在本地 SSD) |
18 秒 |
外推:747 GB 的 dump 约 19~35 小时,改写再加约 17 小时 → 整个 "搬完 + 校验"窗口按 2~3 天 排。
⚠️ 这是从 593 MB 样本线性外推的,不是实测:dump 速率受网络与服务端缓冲影响, 大表不会和 593 MB 同速。别拿它当承诺,只用它判断"要不要留维护窗口"。
4. 执行顺序(不能变)
cd parking-cloud/sql/migration
# ① 两道离线自测(`dump` 前也会自动跑,不通就中止)
bash tests/shell_compat_test.sh # 脚本在 macOS 自带 bash 3.2 上跑得起来吗
bash tests/charset_rewrite_test.sh # 字符集改写对不对(含反向用例)
# ② dump 六个源库的"保留侧",改写字符集声明,落到 sql/.dump/*.sql
bash 10_dump_legacy.sh dump # 可加 ONLY=zt_sys 分段;默认要求一致性快照,见 §2 第 6 条
# ---- 到这里为止都不影响线上;下面开始动目标库 ----
# ③ 灌进 parking_cloud
bash 10_dump_legacy.sh load
# ④ 补进只存在于副本的行(32 行 = 29 客户 + 3 微信支付配置)
# ⚠️ 必须在 ③ 之后、源库同实例上执行(本文件用 INSERT ... SELECT 跨库取数)
# ⚠️⚠️ 时点:本文件的判据是"丢弃侧里目标库还没有的行",所以**越贴近切换时刻越好**。
# 正确顺序是 停写/切流 → ③ → ④ → ⑤⑥ → 放流量到新库。
# 先跑④、之后再往丢弃侧写入的行,永远不会进目标库。
mysql -h$TARGET_HOST -u$TARGET_USER -p parking_cloud < 20_union_lost_rows.sql
# ⑤ 快速校验(秒级,可反复跑)
mysql -h$TARGET_HOST -u$TARGET_USER -p < 99_verify.sql
# ⑥ 逐表对账(677 张表,小时级;这是"零丢失"的正面证据,别省)
# 可分段:sed -n '/^-- @@DB zt_sys$/,/^-- @@END zt_sys$/p' 98_count_reconcile.sql | mysql ...
mysql -h$TARGET_HOST -u$TARGET_USER -p < 98_count_reconcile.sql
bash 10_dump_legacy.sh all = ② + ③(不含 ④⑤⑥,因为 ④ 需要显式确认)。
⑤ 和 ⑥ 的区别:⑤ 只数 17 张重名表的保留侧(快);⑥ 数所有被搬的表。
非重名表(如 zt_sys 的 38 张)只有 ⑥ 才数到行数,⑤ 只查"表在不在"。
4.0 抽样开发库(SAMPLE=N):只到 ③ 为止,然后换 ⑦
本机开发库走这条。②③ 相同,只是环境变量不同;④⑤⑥ 全部不跑(理由见 §0.1):
cd parking-cloud/sql/migration
# ②③ 每表最多抽 N 行;含密钥/口令的 24 张表只搬结构
SAMPLE=500 bash 10_dump_legacy.sh dump
bash 10_dump_legacy.sh load # ← 不带 SAMPLE,load 也不读它
# ℹ️ `SAMPLE` **只作用于 dump**(截断发生在 mysqldump 的 `--where`)。`load` 就是把
# `sql/.dump/*.sql` 灌进去,读不读 SAMPLE 结果完全一样——带了只是会多打印一遍
# 抽样模式的"下一步"。别以为两处都写才对称,那是对称错了。
# (`10_dump_legacy.sh all` = dump + load,命令行上的 SAMPLE 自然被 dump 那一遍用掉。)
# ⑦ 抽样校验(秒级)
mysql --force -N -h$TARGET_HOST -u$TARGET_USER -p < 97_verify_sample.sql
# 期望:**完全没有任何输出**(`-N` 去掉列名,所以"干净"= 空)。
# ⚠️ `--force` 别省:§3/§4 那段是硬编码表名的 `UNION ALL`,撞到一张不存在的表
# 就是 `ERROR 1146`,不加它 mysql 会**从这里直接退出、§5 永远不会执行**——
# 一份校验只报出前半截(实测踩到)。
# ⚠️ 文件头有 `USE parking_cloud;`(临时表需要默认库,否则 ERROR 1046),
# 所以**不用**再传库名参数。
# 想证明这五段真会报红、不是空转:
MYSQL_BIN=... DUMP_BIN=... TARGET_HOST=127.0.0.1 TARGET_USER=root TARGET_PASSWORD= \
bash tests/97_negative_test.sh # 8/8 通过,且会改表 → **只能对本地开发库跑**
| 段 | 内容 | 期望 |
|---|---|---|
| §1 | 源库有的 679 张表都在目标库 | 0 行 |
| §2a/§2b | 列名 + 列类型 + 列序与生成时的源库快照一致(5.7 的 int(11) vs 8 的 int 已双向归一化) |
各 0 行 |
| §3/§4 | 逐表 COUNT(*),按 kind 判:0 只搬结构 → 必须 0 行;2 源库本来就空 → 必须 0 行;1 抽样 → 必须 ≥ 1 行且 ≤ @sample_n |
0 行 |
| §5 | 所有表都是 utf8mb4(表级 + 列级) |
0 行 |
📌 kind 是这张表的分类,也是抽样模式唯一"允许为空"的判据来源:
1 抽样 590 张 / 2 源库本来就空 68 张 / 0 只搬结构 21 张(合计 679)。
少了 2 这一类,那 68 张源库本来就空的表会被当成"抽样没落地"永远报红(推论 5)。
struct_only_tables.txt是 24 行而这里kind=0是 21 张,不是笔误: 其中 3 张(zt_order.td_b_wx_config、zt_order_n.td_c_cust_person、zt_order_n.tp_e_employee_charge)本身就在exclude_tables.txt的 30 张里(重名表副本), 根本不搬,所以不会出现在_kept。两个数字各管各的,都对。
⑤ 的期望结果
| 段 | 内容 | 期望 |
|---|---|---|
| §1 | 源库里有、目标库里没有的表 | 0 行 |
| §2 | 非 utf8mb4 的表 / 列 | 各 0 行 |
| §3 | 17 张重名表:目标行数 vs 保留侧行数 | 没有 🔴;⚠️ 见下 |
| §4 | 并集后丢弃侧"仍未进目标库"的行数 | 全 0 |
| §5 | 目标库默认字符集 | utf8mb4 / utf8mb4_general_ci |
§3 的判据是不对称的,别把它读成"必须完全相等":
- 🔴 目标 > 保留侧 —— 一定是问题(新增只会在源侧,目标不可能凭空多)
- ⚠️ 目标 —— 需要解释:是 dump 之后的在线新增,还是真漏搬?
彩排实测:在线源库上
sys_op_log就少了 2 行,那是 dump 完成后源库又写进去的 (用 ⑥ 的水位线核对过:id > 目标 MAX(id)恰好 2 行 = 差值 ⇒ 未丢) - 源库静默(维护窗口内)时,§3 应当全 OK;在线跑必然出现 ⚠️,这是设计如此
一个每次都在高写入表上喊红的校验会训练人忽略红色,所以这里把"多"和"少"分开判。 这是 §5 那条「一个能通过一切的守卫等于没有守卫」的另一面: 一个每次都喊"狼来了"的守卫,一样等于没有守卫。两类毛病同源, 见 <code>../AGENTS.md</code> §1.1。
任何一条不符 → 先别删源库。
99_verify.sql头部列了 §3/§4 每张表的实测体量。除park_in_out_record(约 9700 万行 / 32 GB,那两条COUNT(*)要数分钟)之外都是瞬间返回。 慢是正常的,别以为卡死——这个数字是生成时现读的,不靠估计。
4.1 彩排记录(2026-10-09,zt_sys)
用 zt_sys(38 表 / 593 MB)在本地 MySQL 26.7 上走通了 dump → 改写 → load → 逐表对账:
- 38/38 张表进库;逐表
COUNT(*)对账 37 张精确相等 - 唯一不一致的是
sys_op_log(源 448435 / 目标 448433)。用水位线判定: 目标id∈[187152, 635584]、源id∈[187152, 635586],id > 635584恰好 2 行 = 差值 ⇒ 全是 dump 之后源库新增的行,未丢数据 - 反向用例:人为从目标表中间删 5 行后重跑,判据报
⚠️ 少 7 行(5 真丢 + 2 新增) —— 说明这条判据不是恒真的("能通过一切的守卫等于没有守卫",见 §5)
彩排抓到的 4 个真问题(都已修,详见 §6):mapfile、$VAR 吞全角字符、
--single-transaction 需要 RELOAD、dump 头里的 GTID_PURGED。
5. ⚠️ 字符集:本目录最需要小心的部分
实测 666/709 张表不是 utf8mb4(表级 utf8_general_ci 635 / utf8_bin 30 / latin1_swedish_ci 1;
列级 utf8_bin 324 列 / latin1_swedish_ci 23 列)。10_dump_legacy.sh 的
rewrite_charset() 把 dump 文件里的字符集声明统一改写。
三条不能"顺手简化"的规则
utf8_bin必须映射成utf8mb4_bin,不能一律拍成utf8mb4_general_ci。_bin是大小写敏感、_ci不敏感,改了会让pl_no='P15040225z'这类查询 开始命中它以前命中不到的行——静默改变业务结果。 已实测_bin列上没有唯一索引(不会撞键),所以语义可保留、风险可控。utf8mb3要单独一条规则,且必须排在那个朴素的s/utf8/utf8mb4/g前面。 MySQL 8.0.29+ 的mysqldump会把utf8输出成utf8mb3;不先处理它, 朴素规则会把它变成utf8mb4mb3(建表报错)。 本机实测的客户端(mysql-client9.6 对 5.7.28 服务端)不会输出utf8mb3——但操作者可能换客户端,所以按"会输出"来防,不赌。- 不要顺手补
COLLATE。 MySQL 里 charset 为utf8mb4时默认排序规则就是utf8mb4_general_ci;补了会和原本带COLLATE的表撞成COLLATE=utf8mb4_general_ci COLLATE=utf8mb4_bin(重复 COLLATE,建表直接失败)。
这个地方栽过两次,所以有 tests/
| 事故 | 表现 | 根因 |
|---|---|---|
| 第一次 | COLLATE=…ci COLLATE=…bin |
见上第 2 条 |
| 第二次 | utf8mb4mb4_bin |
utf8_bin → utf8mb4_bin 写进去的字面量,又被最后那条 s/utf8/utf8mb4/g 二次替换。同一个 token 不能被两条规则都碰到 |
| 第三次(预防) | utf8mb4mb3_bin |
MySQL 8.0.29+ 的客户端把 utf8 输出成 utf8mb3,同样会被朴素规则二次替换。本轮未实际发生(本机客户端不输出 utf8mb3),是按「操作者可能换客户端」提前防的:已单独加规则 + 加进 guard + 加进自测反向用例 |
第二次更值得记的是守卫失职:guard_charset() 的 grep 里没有 mb4mb4,
于是它对着一份坏输出打印了"guard 通过 ✅"。
一个能通过一切输出的守卫等于没有守卫——与 <code>../AGENTS.md</code> §1.1 记的
「审计脚本自己开出的方子自己不认」是同一类病。
所以 tests/charset_rewrite_test.sh 里必须有反向用例(guard 必须拦下坏输出):
只测正向的话,把 guard 改成 true 也能全绿。10_dump_legacy.sh 在 dump 前会先跑它,
不通就中止——709 张表搬到一半才发现字符集问题是灾难。
唯一一张
latin1表是zt_order_n.td_b_pay_blncac_wx(23 列全是金额/订单号/交易状态, 纯 ASCII),latin1 → utf8mb4无损。
6. 彩排实测踩到的坑(非字符集)
这三条都不是字符集问题,但每一条都会让 dump 或 load 失败, 而且在同一台机器上第二次跑不一定复现——所以单独记下来。
6.1 macOS 自带的 bash 是 3.2,不是 4/5
/bin/bash --version → GNU bash, version 3.2.57。后果(两条都实测踩中):
| 坑 | 表现 | 为什么阴险 |
|---|---|---|
mapfile 是 bash 4.0 才有的内建 |
mapfile: command not found,dump 在第一个库就中止 |
bash -n 语法检查过不了它(语法合法) |
$VAR 后面紧跟中文/全角字符 |
bash 3.2 把多字节字符吞进变量名:"SNAPSHOT=$SNAPSHOT)" 变成读一个叫 SNAPSHOT) 的变量 → set -u 下报 unbound variable |
在 set +u 下不报错,静默取到空值;本目录脚本满屏中文提示,几乎必然踩 |
修法:追加数组用 ign[${#ign[@]}]=,变量一律写 ${VAR}(加花括号)。
防法:tests/shell_compat_test.sh(静态扫 + /bin/bash -n),dump 前自动跑。
这两条在 Linux(bash 4/5)上全绿。别因为"我这边能跑"就跳过这个自测。
6.2 一致性快照要 RELOAD 权限(只读账号没有)
--single-transaction 在 dump 前先发 FLUSH TABLES,要 RELOAD。
实测只读账号(SHOW GRANTS:只有 SELECT, LOCK TABLES, SHOW VIEW)下它一律 rc=2:
ERROR 1227 (42000): Access denied; you need (at least one of) the RELOAD privilege(s)
--no-data、--skip-lock-tables 都救不了(试过 6 种组合,只有去掉
--single-transaction 才行)。两条出路见 §2 第 6 条;脚本默认要求快照,
加不了权限又确认源库静默时才 SNAPSHOT=0。
6.3 dump 头里的 GTID_PURGED 会打断 load(实测 ERROR 3546)
源库开了 GTID,不加 --set-gtid-purged=OFF 时,dump 文件头会写一大段
SET @@GLOBAL.GTID_PURGED='<几十个 uuid:区间>'(zt_sys 一个库就是 500+ 行)。
两种炸法,都实测过:
- 目标库
gtid_mode=OFF(MySQL 8 的默认值)→ 整段被拒,load 从第一屏就失败 - 目标库
gtid_mode=ON(本机 26.7 就是)→ 第一次 load 能过,第二次同实例再灌 就报ERROR 3546: @@GLOBAL.GTID_PURGED cannot be changed: the added gtid set must not overlap with @@GLOBAL.GTID_EXECUTED
第二种最坑:重跑一次才炸,而迁移恰恰是个会重跑的活。GTID_PURGED 只在
"拿这份 dump 搭从库"时有意义,本次是并入独立目标库,所以脚本固定加 --set-gtid-purged=OFF。
7. 🔑 密钥不落盘
td_b_wx_config 含 app_secret / encoding_aes_key / pay_api_secret。
20_union_lost_rows.sql 是 INSERT ... SELECT(只有列名,没有任何值)——
密钥值在数据库之间直接流动,不经过文件、不经过终端、不经过日志。
check_subset.py也只投影键列,从不SELECT *(见其文件头)- 别"为了方便"把
20_union_lost_rows.sql改成带值的 INSERT(会写进文件、可能进 git)
⚠️ 全量模式不碰密钥,抽样模式会碰——两者的威胁模型不同,别混谈。 全量迁移里数据从源库直接流进目标库,密钥全程不落文件。但抽样模式是
--where="1=1 LIMIT N",它会把被抽中的真实行原样写进 dump 文件, 而 dump 就落在开发机上。所以抽样模式由struct_only_tables.txt(24 张) 把含密钥/口令的表整张改成--no-data,脚本在该文件缺失时直接 die(§0.1)。 判据是列名正则(secret|password|_aes|api_key|id_card|…)——从严: 多判进来只是少几张表的样例行,漏判是生产凭据落盘。
自检怎么跑:拿 私密/legacy-ro.env 里的真实口令值,去 sql/ 与 AGENTS.md 里搜,
命中就是泄露。2026-10-09 实测结论:未命中(地址与只读账号名出现在
sql/legacy-db-actual.md,那是"实测对象"的标识,刻意保留;口令一处都没有)。
⚠️ 跑这个自检时别被自己的说明绊倒:10_dump_legacy.sh 注释里写着
"别用 --password=xxx"——那行含 --password= 但正是反例。判违规要跳过注释行,
否则检查恒报红(grep -rn -- '--password' 就会命中它,实测踩过)。
8. 回滚
源库全程只读,不做任何修改(10_dump_legacy.sh 只 mysqldump,不发一条写语句;
只读账号本身也没有写权限)。所以回滚就是:
DROP DATABASE parking_cloud; -- 重新 00_create_database.sql 再来一遍
因此在校验通过并观察一段生产稳定期之前,不要删、不要改六个源库—— 它们就是回滚路径。
9. 重新生成产物
改了任一判定源之后,必须重跑生成器,否则 exclude_tables.txt 会漏表、
99_verify.sql 会校验错的对象:
| 改了什么 | 跑什么 |
|---|---|
check_subset.py 的 CHECKS(新增重名表、改判定键) |
audit_junk_tables.py(见下)→ gen_migration_sql.py |
audit_junk_tables.py 的 DO_NOT_MIGRATE("不搬"名单,含层级标签) |
先 audit_junk_tables.py,再 gen_migration_sql.py |
顺序不能反:audit 脚本会逐条复核那几张表是否仍存在、仍无表位置引用
(空表 层另有"仍是 0 行"),任一条不成立就 exit 2——这是"不搬"这个结论
当下是否还成立的唯一证据。拿一份过了期的名单去排除,等于把可能已经有数据的表静默丢掉。
层级标签(
空表/备份)是字符串比较的判据:写错一个字不会报错, 只会让按层级的统计——以及生成物头部的那个数字——静默变 0。两个脚本都会校验标签合法性。
cd parking-cloud
python3 -I sql/tools/audit_junk_tables.py --emit docs/废弃表逐张确认.md # 先验:exit 0 才继续
python3 -I sql/tools/gen_migration_sql.py # 再生成
python3 -I sql/tools/check_subset.py # 顺带确认判定仍成立
# 重生成后自检:连跑两次,六个产物 md5 应完全一致(不一致=产物里有随运行变化的东西)
md5 -q sql/migration/exclude_tables.txt sql/migration/20_union_lost_rows.sql \
sql/migration/98_count_reconcile.sql sql/migration/99_verify.sql \
sql/migration/97_verify_sample.sql sql/migration/struct_only_tables.txt
生成器只读源库(
SELECT/information_schema),全程不发写语句;跑一遍约 3 秒。曾经是 44 秒——瓶颈全是"每张表一条 SELECT"的往返延迟(源库在张北, 单次约 30ms × 679 张)。现在三处热点都改成了按库一次问完(source_columns的TABLE_NAME IN (...)、source_empty的一条UNION ALL、emit_reconcile的EXTRA LIKE '%auto_increment%'),输出逐字节不变。这与项目红线第 10 条 「禁止在循环中执行数据库查询」是同一个道理,只是那条管业务代码、这条管工具代码—— 而工具代码恰恰是最容易被"反正跑一次"放过的地方。
AGENTS.md§1.1 的规律在这里同样适用:生成器的判据必须和判定表同源 (CHECKS/DO_NOT_MIGRATE都 import 现算),否则"判定改了、清单没改", 而漏排一张表就是静默丢数据。⚠️ 反过来也要防:多排一张表同样是静默丢数据。所以清单的默认是只收空表—— 有数据的(哪怕代码确实不引用)一律按"先全搬"走,因为"没引用"只是当前代码的结论, 外部报表/BI 有没有在读,本工具证明不了。
备份层是这条默认的唯一例外, 且必须外部拍板才准进(这层守卫守不住决定本身,只能守前提)。 于是排除一张表的代价要分两问:空表层是零(本来就 0 行);备份层是"那批历史行只留在老库"——老库只读保留、没有被销毁,但新库里确实没有它们。