#!/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 每行 `.` → 生成该库的 --ignore-table 参数 local db="$1" grep -E "^${db}\." "$EXCLUDE_FILE" | sed 's/^/--ignore-table=/' || true } build_struct_only() { # struct_only_tables.txt 每行 `.
` → 数据那一遍要 --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 <