#!/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] 完成 ✓"
|