# 六库合一:把 `zt_sys / zt_park / zt_order / zt_order_n / zt_bill / zt_charge` 并成 `parking_cloud` > **哪张表保留哪一份**见 [`../merge-conflict-plan.md`](../merge-conflict-plan.md), > **每张表的实测结论**见 [`../legacy-db-actual.md`](../legacy-db-actual.md)。 > 本文件只讲**怎么搬**。 ## 0. 一句话 六个源库都在**同一台实例**上,所以能直接跨库 `LEFT JOIN` 数清"哪份副本可丢、丢几行"。 这件事已经数完了,结论固化成 `CHECKS`([`../tools/check_subset.py`](../tools/check_subset.py)), 本目录的产物**全部从它生成**,不存在第二份人工清单。 **另有一类"不搬"**:§8 逐张确认后拍板的 7 张(2026-10-10,见 [`../legacy-db-actual.md`](../legacy-db-actual.md) §8.3 / §10.1)。它的事实来源是 [`../tools/audit_junk_tables.py`](../tools/audit_junk_tables.py) 的 `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)。然后重跑: ```bash 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. 前置条件 1. **只读账号**已在 `parking-cloud/私密/legacy-ro.env` 配好(该目录 gitignore,勿提交) 2. 目标实例上有 `parking_cloud` 库(`00_create_database.sql` 建),**默认 `utf8mb4`** 3. 目标磁盘 **≥ 源库 1.3 倍**(约 1 TB)——见 §3 4. `mysql` / `mysqldump` 客户端可用 - ⚠️ **老库账号用 `mysql_native_password`,很多客户端认不了它**。 本机实测:Homebrew 的 `mysql` / `mysql@26.7`(**26.7.0**)**连不上**—— `mysqldump` 退出码 2、报 `ERROR 2059`;而 **`mysql-client` 9.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` 一直可用 5. 口令走 `MYSQL_PWD` 传给客户端,**不在 argv 里**(否则同机 `ps` 就能读到生产口令) 6. ⚠️ **源库账号需要 `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. 执行顺序(不能变) ```bash 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): ```bash 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 那条「**一个能通过一切的守卫等于没有守卫**」的另一面: **一个每次都喊"狼来了"的守卫,一样等于没有守卫**。两类毛病同源, 见 [`../AGENTS.md`](../AGENTS.md) §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 文件里的字符集声明统一改写。 ### 三条不能"顺手简化"的规则 1. **`utf8_bin` 必须映射成 `utf8mb4_bin`,不能一律拍成 `utf8mb4_general_ci`。** `_bin` 是**大小写敏感**、`_ci` 不敏感,改了会让 `pl_no='P15040225z'` 这类查询 开始命中它以前命中不到的行——**静默改变业务结果**。 已实测 `_bin` 列上**没有唯一索引**(不会撞键),所以语义可保留、风险可控。 2. **`utf8mb3` 要单独一条规则,且必须排在那个朴素的 `s/utf8/utf8mb4/g` 前面。** MySQL 8.0.29+ 的 `mysqldump` 会把 `utf8` 输出成 `utf8mb3`;不先处理它, 朴素规则会把它变成 **`utf8mb4mb3`**(建表报错)。 本机实测的客户端(`mysql-client` 9.6 对 **5.7.28** 服务端)**不会**输出 `utf8mb3` ——但操作者可能换客户端,所以按"会输出"来防,不赌。 3. **不要顺手补 `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 通过 ✅"。 **一个能通过一切输出的守卫等于没有守卫**——与 [`../AGENTS.md`](../AGENTS.md) §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`,不发一条写语句; 只读账号本身也没有写权限)。所以回滚就是: ```sql 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。两个脚本都会校验标签合法性。 ```bash 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 行); > `备份` 层是"那批历史行只留在老库"——老库只读保留、**没有被销毁**,但新库里确实没有它们。