#!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # JNPF 数据迁移(停机窗口一次性):现场 MySQL → 中转 mirror → pgloader → PG # # dump :从现场 MySQL mysqldump(跳 base_sys_log 日志)到本地 dump/ # mirror :起临时 mysql:8.0 中转容器,灌入 dump(pgloader 的 qmynd 不支持 MySQL8 握手,故中转) # load :pgloader 灌 jnpf_init/jnpf_flow + blade_visual_map 走 --tab→COPY # + 数据接口修正 + log_seq setval + 抽样校验 # cleanup :删除临时 mirror 容器与 dump/ # all :dump → mirror → load(不含 cleanup) # # 用法: # ./migrate-data.sh all # 现场停机窗口全流程 # ./migrate-data.sh load # 只跑 PG 侧(mirror 已就绪时,或隔离测试) # ./migrate-data.sh cleanup # 迁移验收后清理临时资源 # # 参数(环境变量): # 源库 SRC_MYSQL_HOST/PORT/USER/PWD **无软默认**:HOST 与 PWD 必须显式传入(dump 步校验) # 中转 MIRROR(容器名) MIRROR_PWD 默认 cx-mysql-mirror / mirror_2026(临时容器,随 cleanup 销毁) # 目标 CONTAINER PGUSER 默认 cx-postgres / postgres # PG_PASSWORD 默认读仓库根/交付包根 .env 的 JNPF_PG_PASSWORD(load 步校验; # pgloader 走 TCP scram 真验密码,值必须正确,L030) # PG_INIT_DB PG_FLOW_DB 默认 jnpf_init / jnpf_flow(隔离测试可改 *_t8) # PG_INT_PORT 容器内 PG 端口,默认 5432 # NET docker 网络 默认自动取 CONTAINER 所在网络 # DUMP_DIR / PGLOADER_IMAGE / MYSQL_IMAGE # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail # ── 退役护栏(2026-07-25)───────────────────────────────────────────────────── # 本脚本的前提「腾讯云 MySQL = 事实源」自 2026-07-20 起已消失:现事实源是腾讯云 CVM 共享 PG # (YOUR_DB_HOST:15432,开发与测试共用一库),MySQL 数据停在 07-16,仅作冷备回退底牌保留。 # 本脚本会 truncate 重灌目标库,误跑 = 用废弃旧快照覆盖当前业务数据,是真实数据事故。 # 背景与当前拓扑:README-迁移操作手册.md 文首 / docs/db-connection-pool-guide.md if [ "${I_KNOW_MIGRATION_IS_RETIRED:-}" != "1" ]; then printf '\033[1;31m[已退役·拒绝执行]\033[0m %s\n' "migrate-data.sh 自 2026-07-21 迁移完成后已退役。" >&2 printf ' 前提已不成立:腾讯云 MySQL 不再是事实源(现为 CVM 共享 PG,MySQL 停在 07-16)。\n' >&2 printf ' 误跑后果:用废弃快照 truncate 重灌目标库 —— 会抹掉当前业务数据。\n' >&2 printf ' 若确在全新环境做一次性历史迁移,显式声明后重试:\n' >&2 printf ' I_KNOW_MIGRATION_IS_RETIRED=1 %s %s\n' "$0" "${*:-}" >&2 exit 1 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SQL_DIR="$SCRIPT_DIR/sql" # 凭据不设软默认(codex review 2026-07-17);.env 从脚本目录向上探测(仓库根或交付包根) env_file() { local d="$SCRIPT_DIR" while [ "$d" != "/" ]; do if [ -f "$d/.env" ]; then printf '%s\n' "$d/.env"; return 0; fi d="$(dirname "$d")" done return 1 } envval() { local f f="$(env_file)" || return 0 grep -E "^$1=" "$f" | tail -1 | cut -d= -f2- || true } SRC_MYSQL_HOST="${SRC_MYSQL_HOST:-}" SRC_MYSQL_PORT="${SRC_MYSQL_PORT:-3306}" SRC_MYSQL_USER="${SRC_MYSQL_USER:-root}" SRC_MYSQL_PWD="${SRC_MYSQL_PWD:-}" MIRROR="${MIRROR:-cx-mysql-mirror}" MIRROR_USER="${MIRROR_USER:-root}" MIRROR_PWD="${MIRROR_PWD:-mirror_2026}" CONTAINER="${CONTAINER:-cx-postgres}" PGUSER="${PGUSER:-postgres}" PG_PASSWORD="${PG_PASSWORD:-$(envval JNPF_PG_PASSWORD)}" PG_INIT_DB="${PG_INIT_DB:-jnpf_init}" PG_FLOW_DB="${PG_FLOW_DB:-jnpf_flow}" PG_INT_PORT="${PG_INT_PORT:-5432}" DUMP_DIR="${DUMP_DIR:-$SCRIPT_DIR/dump}" PGLOADER_IMAGE="${PGLOADER_IMAGE:-dimitri/pgloader:latest}" MYSQL_IMAGE="${MYSQL_IMAGE:-mysql:8.0}" STEP="${1:-all}" log() { printf '\033[1;34m[migrate]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[migrate][warn]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[migrate][err]\033[0m %s\n' "$*" >&2; exit 1; } discover_net() { docker inspect "$CONTAINER" >/dev/null 2>&1 || die "目标容器 $CONTAINER 不在——先 docker compose up -d cx-postgres 再跑 install.sh" docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$CONTAINER" | awk '{print $1}' } NET="${NET:-$(discover_net)}" mirror_sql() { docker exec -i -e MYSQL_PWD="$MIRROR_PWD" "$MIRROR" mysql --default-character-set=utf8mb4 -u"$MIRROR_USER" "$@"; } pg_sql() { docker exec -i "$CONTAINER" psql -U "$PGUSER" "$@"; } mirror_count(){ mirror_sql -N -e "SELECT COUNT(*) FROM \`$2\`" "$1" 2>/dev/null; } pg_count() { pg_sql -tAc "SELECT count(*) FROM $2" -d "$1"; } # ── dump:现场 MySQL → dump/ ───────────────────────────────────────────────── do_dump() { [ -n "$SRC_MYSQL_HOST" ] || die "缺 SRC_MYSQL_HOST(源 MySQL 地址须显式传入,如 SRC_MYSQL_HOST=127.0.0.1)" [ -n "$SRC_MYSQL_PWD" ] || die "缺 SRC_MYSQL_PWD(源 MySQL 密码须显式传入,不设软默认)" mkdir -p "$DUMP_DIR" local common="--single-transaction --hex-blob --set-gtid-purged=OFF --default-character-set=utf8mb4 --column-statistics=0" log "mysqldump jnpf_init(跳 base_sys_log)… 源 $SRC_MYSQL_HOST:$SRC_MYSQL_PORT" docker run --rm -e MYSQL_PWD="$SRC_MYSQL_PWD" -v "$DUMP_DIR":/dump "$MYSQL_IMAGE" \ sh -c "mysqldump -h$SRC_MYSQL_HOST -P$SRC_MYSQL_PORT -u$SRC_MYSQL_USER $common --ignore-table=jnpf_init.base_sys_log jnpf_init > /dump/jnpf_init.sql" log "mysqldump jnpf_flow …" docker run --rm -e MYSQL_PWD="$SRC_MYSQL_PWD" -v "$DUMP_DIR":/dump "$MYSQL_IMAGE" \ sh -c "mysqldump -h$SRC_MYSQL_HOST -P$SRC_MYSQL_PORT -u$SRC_MYSQL_USER $common jnpf_flow > /dump/jnpf_flow.sql" log "dump 完成:$(du -sh "$DUMP_DIR" | cut -f1) @ $DUMP_DIR" } # ── mirror:起临时 mysql8 中转 + 灌 dump ───────────────────────────────────── do_mirror() { [ -f "$DUMP_DIR/jnpf_init.sql" ] || die "缺 $DUMP_DIR/jnpf_init.sql,先跑 ./migrate-data.sh dump" # 就绪探测走真实认证:mysqladmin ping 在"认证被拒"时也返回 0,会在镜像首次初始化的 # 临时服务器窗口(root 密码尚未生效)假通过,随后灌入撞 Access denied(L038) wait_mirror_ready() { log "等待 mirror 就绪(真实认证探测)…" for i in $(seq 1 60); do docker exec -e MYSQL_PWD="$MIRROR_PWD" "$MIRROR" mysql -u"$MIRROR_USER" -e "SELECT 1" >/dev/null 2>&1 && return 0 sleep 3 done die "mirror 60×3s 未就绪(docker logs $MIRROR 查看初始化进度)" } if docker inspect "$MIRROR" >/dev/null 2>&1; then if [ "$(docker inspect -f '{{.State.Running}}' "$MIRROR")" = "true" ]; then warn "mirror 容器 $MIRROR 运行中,复用(如需重灌先 ./migrate-data.sh cleanup)" else log "mirror 容器 $MIRROR 存在但已停止,启动(L035:不再盲目复用停止态容器)…" docker start "$MIRROR" >/dev/null wait_mirror_ready fi else log "起临时 mirror(mysql_native_password + skip-log-bin + innodb_strict_mode=OFF)于网络 $NET" docker run -d --name "$MIRROR" --network "$NET" -e MYSQL_ROOT_PASSWORD="$MIRROR_PWD" "$MYSQL_IMAGE" \ --default-authentication-plugin=mysql_native_password --skip-log-bin --innodb-strict-mode=OFF >/dev/null wait_mirror_ready fi log "建库并灌入 dump(大表较慢)…" mirror_sql -e "CREATE DATABASE IF NOT EXISTS jnpf_init DEFAULT CHARSET utf8mb4; CREATE DATABASE IF NOT EXISTS jnpf_flow DEFAULT CHARSET utf8mb4;" mirror_sql jnpf_init < "$DUMP_DIR/jnpf_init.sql" mirror_sql jnpf_flow < "$DUMP_DIR/jnpf_flow.sql" log "mirror 灌入完成:jnpf_init blade_visual_map=$(mirror_count jnpf_init blade_visual_map) 行" } # ── pgloader 模板渲染 ──────────────────────────────────────────────────────── render_pgloader() { local tpl="$1" out="$2" pgdb="$3" [ -f "$tpl" ] || die "缺模板 $tpl" sed -e "s#{{MYSQL_USER}}#$MIRROR_USER#g" \ -e "s#{{MYSQL_PASSWORD}}#$MIRROR_PWD#g" \ -e "s#{{MYSQL_HOST}}#$MIRROR#g" \ -e "s#{{MYSQL_PORT}}#3306#g" \ -e "s#{{PG_USER}}#$PGUSER#g" \ -e "s#{{PG_PASSWORD}}#$PG_PASSWORD#g" \ -e "s#{{PG_HOST}}#$CONTAINER#g" \ -e "s#{{PG_PORT}}#$PG_INT_PORT#g" \ -e "s#{{PG_DB}}#$pgdb#g" \ "$tpl" > "$out" } run_pgloader() { local loadfile="$1" docker run --rm --network "$NET" -v "$(dirname "$loadfile")":/loads "$PGLOADER_IMAGE" \ pgloader "/loads/$(basename "$loadfile")" } # ── blade_visual_map:mysqldump --tab(NULL=\N,天然兼容)→ PG COPY ─────────── load_blade_visual_map() { log "blade_visual_map:mysqldump --tab → PG COPY(pgloader 对该巨行表会堆爆,故走 COPY)" docker exec -e MYSQL_PWD="$MIRROR_PWD" "$MIRROR" sh -c \ "rm -f /var/lib/mysql-files/blade_visual_map.* && mysqldump --tab=/var/lib/mysql-files --default-character-set=utf8mb4 -u$MIRROR_USER jnpf_init blade_visual_map" pg_sql -d "$PG_INIT_DB" -c "TRUNCATE blade_visual_map" docker exec "$MIRROR" cat /var/lib/mysql-files/blade_visual_map.txt \ | pg_sql -d "$PG_INIT_DB" -c "COPY blade_visual_map FROM STDIN" docker exec "$MIRROR" rm -f /var/lib/mysql-files/blade_visual_map.* log "blade_visual_map → PG $PG_INIT_DB = $(pg_count "$PG_INIT_DB" blade_visual_map) 行" } # ── load:pgloader + blade + 业务数据回填 + 数据接口修正 + setval + 抽样 ────── do_load() { docker inspect "$MIRROR" >/dev/null 2>&1 || die "mirror $MIRROR 不在,先跑 ./migrate-data.sh mirror" [ -n "$PG_PASSWORD" ] || die "缺 PG_PASSWORD(显式传入,或在仓库根/交付包根 .env 设 JNPF_PG_PASSWORD)" # 渲染目录放 DUMP_DIR 下而非 mktemp:Git Bash 的 /tmp 是 MSYS 虚拟路径, # docker -v 挂载时 daemon 只会建空目录(L036/L038 同族);DUMP_DIR 的盘符形态已被 dump 步实证可挂载。 # 注意 .loads 内含渲染后的连接密码——函数返回即删,cleanup 步兜底 local rdir; rdir="$DUMP_DIR/.loads"; rm -rf "$rdir"; mkdir -p "$rdir"; trap 'rm -rf "$rdir"' RETURN log "===== pgloader jnpf_init → PG ${PG_INIT_DB}(排除 base_sys_log/blade_visual_map)=====" render_pgloader "$SQL_DIR/pgloader_jnpf_init.load.tpl" "$rdir/init.load" "$PG_INIT_DB" run_pgloader "$rdir/init.load" load_blade_visual_map log "===== pgloader jnpf_flow → PG ${PG_FLOW_DB}(大写 ACT/FLW 表)=====" render_pgloader "$SQL_DIR/pgloader_jnpf_flow.load.tpl" "$rdir/flow.load" "$PG_FLOW_DB" run_pgloader "$rdir/flow.load" log "===== 水系统计划流程状态回填(历史数据导入后执行)=====" pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/update_lims_sxt_flow_biz_status_20260719.sql" pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/update_lims_sxt_quyang_zhongzhi_shenqing_biz_status_20260723.sql" pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/update_lims_sxt_quyang_zhongzhi_shenqing_qingyandan_link_20260723.sql" pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/update_lims_sxt_plan_status_20260720.sql" pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/create_lims_sxt_quyang_jihua_renwu_20260724.sql" pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/alter_lims_sxt_quyang_jihua_renwu_jianyan_xiang_kuaizhao_20260724.sql" log "===== 数据接口修正(含 4 条 UPDATE + 残留检查,应 0 行)=====" pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/data_interface_pg_fixes.sql" log "===== lims_biz_log.log_seq setval =====" pg_sql -d "$PG_INIT_DB" -tAc \ "SELECT setval(pg_get_serial_sequence('lims_biz_log','log_seq'), COALESCE((SELECT MAX(log_seq) FROM lims_biz_log),1), true)" log "===== 抽样行数校验(mirror vs PG)=====" local bad=0 for t in base_user zhiliang_biaozhun_guanli lims_qingyandan lims_jianyan_xiang lims_biz_log blade_visual_map; do local m p; m="$(mirror_count jnpf_init "$t")"; p="$(pg_count "$PG_INIT_DB" "$t")" if [ "$m" = "$p" ]; then printf ' %-30s mirror=%-8s pg=%-8s ✓\n' "$t" "$m" "$p" else printf ' %-30s mirror=%-8s pg=%-8s ✗\n' "$t" "$m" "$p"; bad=$((bad+1)); fi done [ "$bad" = 0 ] && log "抽样一致 ✓" || warn "抽样有 $bad 处不一致,跑 sql/rowcount_check.sh 全量核对" log "数据迁移完成。全量逐表核对:sql/rowcount_check.sh(详见 README)。" } # ── cleanup:删临时 mirror + dump ──────────────────────────────────────────── do_cleanup() { if docker inspect "$MIRROR" >/dev/null 2>&1; then log "删除临时 mirror 容器 $MIRROR"; docker rm -f "$MIRROR" >/dev/null fi [ -d "$DUMP_DIR" ] && { log "删除 dump 目录 $DUMP_DIR"; rm -rf "$DUMP_DIR"; } log "cleanup 完成 ✓" } case "$STEP" in dump) do_dump ;; mirror) do_mirror ;; load) do_load ;; cleanup) do_cleanup ;; all) do_dump; do_mirror; do_load ;; *) die "未知步骤 '$STEP'(可选 dump|mirror|load|cleanup|all)" ;; esac log "migrate-data.sh [$STEP] 完成 ✓"