feat(deploy): 财务上线数据阶段编排与运维加固

- 新增 deploy/post-deploy.sh:回填 → 业务配置 → 同步 → 验收四阶段编排,支持 --only/--dry-run/窗口参数
- 新增 tools 镜像与 scripts/verify-finance-launch.sql(15 项上线验收)
- 验收口径:成本闸门二选一、要求已过账凭证、备用金支出关联、成本覆盖 WARN
- 加固:参数校验、工具镜像每次刷新、docker run 兜底超时、迁移前缀探测、验收失败退出码 2
- 工具镜像预建 /app/data,消除日志 EACCES
- 回填后与 restore-db 恢复后自动 VACUUM (ANALYZE),修复订单列表分页 COUNT 全表扫描(41 万单实测 27s → 0.17s)
- 文档:41 万单同步用 7 天窗 + 180s 超时;回填/恢复后刷新统计
This commit is contained in:
yml2213
2026-10-28 11:52:42 +08:00
parent 2ddc26091c
commit 08318ef8d9
8 changed files with 977 additions and 8 deletions
+49
View File
@@ -0,0 +1,49 @@
# 财务运维工具镜像(一次性/按需容器)
#
# 为什么需要它:生产 backend 镜像为了体积与安全做了两件事——
# 1) `npm prune --omit=dev`(删掉 tsx 等 devDependencies)
# 2) 只 `COPY dist`(tsconfig.build.json 的 rootDir=src,scripts/ 不进 dist)
# 因此 `docker compose exec backend npm run revenue:backfill` 在生产**必然失败**。
# 本镜像保留源码 + devDependencies,专门用来跑 scripts/ 下的回填/同步脚本。
#
# 构建(仓库根目录):
# docker compose --profile tools build tools
# 或:
# docker build -f deploy/docker/tools.Dockerfile -t order-site/tools:latest .
#
# 直接执行单个脚本(不需要 compose):
# docker run --rm --network order_site_default --env-file .env \
# order-site/tools:latest npm run revenue:backfill -- --dryRun
#
# 说明:脚本只读写数据库,不写文件;镜像不挂载业务数据卷。
ARG BASE_REGISTRY=docker.m.daocloud.io
FROM ${BASE_REGISTRY}/library/node:22-bookworm
ARG DEBIAN_MIRROR=mirrors.aliyun.com
ARG NPM_REGISTRY=https://registry.npmmirror.com
ENV DEBIAN_FRONTEND=noninteractive \
NPM_CONFIG_REGISTRY=${NPM_REGISTRY} \
TZ=Asia/Shanghai \
NODE_ENV=development
WORKDIR /app
# 先装依赖再拷源码,命中 Docker 层缓存:只改脚本时不必重装 node_modules。
COPY apps/backend/package.json apps/backend/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --include=dev
COPY --chown=node:node apps/backend/ ./
# 脚本运行时会加载 backend 的 logger,它会把日志写到 $DATA_ROOT/logs。
# 镜像以 node 用户运行,必须提前建好该目录并授权,否则每条日志都会刷
# EACCES: permission denied, mkdir '/app/data'。
RUN mkdir -p /app/data && chown -R node:node /app/data
USER node
# 不带命令运行时列出可用脚本,避免误用。
CMD ["npm", "run"]
+484
View File
@@ -0,0 +1,484 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# 财务上线「数据阶段」编排脚本
#
# 位置:在 deploy/ubuntu-deploy.sh 成功之后执行(代码与迁移已生效、backend 已 ready)。
#
# 它把以前散落在文档里的脚本步骤串成一条可重复执行的流水线:
# ① 存量订单项收入回填 revenue:backfill
# ② 履约方式回填 fulfillment:backfill
# ③ 财务历史同步(分窗口) finance:sync --mode=all
# ④ 上线验收(只读 SQL) scripts/verify-finance-launch.sql
#
# 设计原则:
# * 幂等:每一步都能重跑,中断后直接再跑一次即可;
# * 串行:回填与同步都会争 order_items 行锁,**不要并发**;
# * 只读验收:验收脚本不写业务表,随时可跑;
# * 不碰人工步骤:备用金/积分/商品目录/人工 SKU/总账同步仍需在后台完成,脚本末尾会打印清单。
#
# 常用:
# bash deploy/post-deploy.sh --dry-run # 只打印计划与待回填量
# bash deploy/post-deploy.sh --no-verify # 回填 + 同步,跳过验收(日常重跑)
# bash deploy/post-deploy.sh # 全流程(默认最近 90 天)
# bash deploy/post-deploy.sh --from=2026-07-01 --to=2026-10-27
# bash deploy/post-deploy.sh --only=backfill # 只跑 ①②
# bash deploy/post-deploy.sh --only=sync # 只跑 ③
# bash deploy/post-deploy.sh --only=verify # 只跑 ④
# bash deploy/post-deploy.sh --no-build --no-verify
#
# 依赖:docker compose(或 docker-compose)已在项目根目录可用,且 .env 存在。
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
TOOLS_IMAGE="${TOOLS_IMAGE:-order-site/tools:latest}"
TOOLS_DOCKERFILE="deploy/docker/tools.Dockerfile"
VERIFY_SQL="scripts/verify-finance-launch.sql"
WINDOW_DAYS="${WINDOW_DAYS:-30}"
BATCH_SIZE="${BATCH_SIZE:-5000}"
MAX_BATCHES="${MAX_BATCHES:-1000}"
MAX_BACKFILL_ROUNDS="${MAX_BACKFILL_ROUNDS:-5}"
DRY_RUN=false
SKIP_BACKFILL=false
SKIP_SYNC=false
NO_VERIFY=false
NO_BUILD=false
ONLY=""
FROM_DATE=""
TO_DATE=""
LAUNCH_AT=""
log() { printf '\033[1;34m[post-deploy]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[post-deploy]\033[0m %s\n' "$*"; }
fail() {
printf '\033[1;31m[post-deploy]\033[0m %s\n' "$*" >&2
exit 1
}
usage() {
cat <<'EOF'
用法:bash deploy/post-deploy.sh [选项]
选项:
--dry-run 只打印计划 + 只读统计,不写库、不构建镜像
--from=YYYY-MM-DD 财务同步起始日(默认:今天往前 90 天)
--to=YYYY-MM-DD 财务同步结束日(默认:今天)
--window-days=N 同步窗口天数(默认 30;41 万单规模建议 7)
--batch-size=N 回填批大小(默认 5000)
--max-batches=N 单次回填最大批数(默认 1000)
--launch-at=TIMESTAMP 验收基准时间(默认:本次执行时间),用于「上线后」类检查
--only=backfill|sync|verify 只跑指定阶段
--skip-backfill 跳过 ①②(回填)
--skip-sync 跳过 ③(财务历史同步)
--no-verify 跳过 ④(验收)
--no-build 不构建/刷新工具镜像(镜像不存在时直接失败)
-h, --help 显示本帮助
环境变量:
POST_DEPLOY_COMPOSE_FILE 指定 compose 文件(默认 docker-compose.yml)
TOOLS_IMAGE 工具镜像名(默认 order-site/tools:latest)
WINDOW_DAYS / BATCH_SIZE / MAX_BATCHES 同上对应选项的默认值
首次上线顺序(避免商品未归一先同步产生大量异常):
① bash deploy/post-deploy.sh --only=backfill
(结束后自动 VACUUM ANALYZE order_items/orders,约 30–60 秒,不阻塞读写)
② 后台完成业务配置 1)–4)(商品目录必须先于财务同步)
③ bash deploy/post-deploy.sh --only=sync
④ 后台完成业务配置 5)(总账同步)
⑤ bash deploy/post-deploy.sh --only=verify
业务配置(后台 UI):
1) 钱包管理 → 补建客服账户 → 拨付备用金
2) 商品与成本 → 积分钱包 → 发放三平台积分
3) 商品与成本 → 标准商品 → 模板初始化 / 按工单回填 / 归一人工商品
4) 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置
5) 财务 → 总账与对账 → 同步销售事实到总账 → 试算平衡 → 按需关账
详见 docs/财务上线初始化与验收-2026-10-26.md
退出码:0 = 完成且验收无 FAIL;1 = 执行失败;2 = 验收未通过。
存量初始化完成后,日常重跑可直接 `bash deploy/post-deploy.sh`(回填 + 同步 + 验收一条龙)。
EOF
}
# ---------- 参数解析 ----------
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true ;;
--skip-backfill) SKIP_BACKFILL=true ;;
--skip-sync) SKIP_SYNC=true ;;
--no-verify) NO_VERIFY=true ;;
--no-build) NO_BUILD=true ;;
--from=*) FROM_DATE="${1#*=}" ;;
--to=*) TO_DATE="${1#*=}" ;;
--launch-at=*) LAUNCH_AT="${1#*=}" ;;
--window-days=*) WINDOW_DAYS="${1#*=}" ;;
--batch-size=*) BATCH_SIZE="${1#*=}" ;;
--max-batches=*) MAX_BATCHES="${1#*=}" ;;
--only=*) ONLY="${1#*=}" ;;
-h | --help)
usage
exit 0
;;
*) fail "未知参数:$1(--help 查看用法)" ;;
esac
shift
done
case "$ONLY" in
'' | backfill | sync | verify) ;;
*) fail "--only 只能是 backfill / sync / verify(收到:${ONLY})" ;;
esac
# 数值参数必须在进入循环前校验:WINDOW_DAYS<=0 会让分窗循环原地打转。
validate_integer() {
local name="$1" value="$2" min="$3" max="$4"
[[ "$value" =~ ^[0-9]+$ ]] || fail "${name} 需要整数(收到:${value})"
local number=$((10#$value))
if ((number < min || number > max)); then
fail "${name} 需要在 ${min}–${max} 之间(收到:${value})"
fi
}
validate_integer "--window-days" "$WINDOW_DAYS" 1 400
validate_integer "--batch-size" "$BATCH_SIZE" 1 50000
validate_integer "--max-batches" "$MAX_BATCHES" 1 10000
validate_integer "MAX_BACKFILL_ROUNDS" "$MAX_BACKFILL_ROUNDS" 1 100
should_run() {
[[ -z "$ONLY" || "$ONLY" == "$1" ]]
}
# ---------- 日期工具(兼容 GNU date 与 macOS BSD date) ----------
if date -u -d '2026-01-01 + 1 day' +%F >/dev/null 2>&1; then
DATE_STYLE=gnu
else
DATE_STYLE=bsd
fi
date_today() { date +%F; }
date_add_days() {
local base="$1" days="$2" signed="$2"
if [[ "$days" == "0" ]]; then
printf '%s\n' "$base"
return 0
fi
# BSD date 必须显式带符号(-v+30d / -v-90d),且 -v 必须写在 -f 之前。
[[ "$days" != -* ]] && signed="+$days"
if [[ "$DATE_STYLE" == gnu ]]; then
date -d "$base $days days" +%F
else
date -j -v"${signed}d" -f '%F' "$base" +%F
fi
}
date_days_ago() { date_add_days "$(date_today)" "-$1"; }
is_iso_date() { [[ "$1" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; }
# ---------- compose / docker 封装 ----------
COMPOSE_FILE_OVERRIDE="${POST_DEPLOY_COMPOSE_FILE:-}"
if [[ -n "$COMPOSE_FILE_OVERRIDE" ]]; then
COMPOSE_CMD=(docker compose -f "$COMPOSE_FILE_OVERRIDE")
elif docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD=(docker-compose)
else
fail "找不到 docker compose(或 docker-compose)"
fi
compose() { "${COMPOSE_CMD[@]}" "$@"; }
HAS_TOOLS_SERVICE=false
PROFILE_SUPPORTED=false
TOOLS_IMAGE_READY=false
NETWORK=""
detect_capabilities() {
if compose --profile tools config --services 2>/dev/null | grep -qx 'tools'; then
HAS_TOOLS_SERVICE=true
fi
if compose --profile tools config >/dev/null 2>&1; then
PROFILE_SUPPORTED=true
fi
}
detect_network() {
local cid
cid="$(compose ps -q postgres 2>/dev/null | head -1 || true)"
if [[ -n "$cid" ]]; then
NETWORK="$(docker inspect "$cid" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' 2>/dev/null | head -1 || true)"
fi
if [[ -z "$NETWORK" ]]; then
NETWORK="$(basename "$ROOT_DIR" | tr '[:upper:]' '[:lower:]')_default"
fi
}
env_database_url() {
local value=""
if [[ -f .env ]]; then
value="$(grep -E '^DATABASE_URL=' .env | head -1 | cut -d= -f2- || true)"
fi
if [[ -z "$value" ]]; then
value="postgres://postgres:postgres@postgres:5432/order_site"
fi
printf '%s' "$value"
}
# ---------- psql 封装(全部在 postgres 容器内执行) ----------
psql_scalar() {
local sql="$1"
compose exec -T -e SQL_QUERY="$sql" postgres sh -lc \
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAX -v ON_ERROR_STOP=1 -c "$SQL_QUERY"'
}
# ---------- 工具镜像 ----------
ensure_tools_image() {
# 全流程里 backfill 和 sync 各调用一次,同一次运行只构建一遍。
[[ "$TOOLS_IMAGE_READY" == true ]] && return 0
local image_exists=false
if docker image inspect "$TOOLS_IMAGE" >/dev/null 2>&1; then
image_exists=true
fi
if [[ "$DRY_RUN" == true ]]; then
[[ "$image_exists" == true ]] || warn "工具镜像 $TOOLS_IMAGE 不存在(dry-run 不构建);正式执行时会自动构建"
TOOLS_IMAGE_READY=true
return 0
fi
if [[ "$NO_BUILD" == true ]]; then
[[ "$image_exists" == true ]] || fail "工具镜像 $TOOLS_IMAGE 不存在,且指定了 --no-build"
log "沿用现有工具镜像 ${TOOLS_IMAGE}(--no-build)"
TOOLS_IMAGE_READY=true
return 0
fi
# 每次都重建:有层缓存时通常只要几秒,但能保证 git pull 后跑的是当前脚本,
# 而不是上次部署留在服务器上的旧 tools:latest。
log "刷新工具镜像 ${TOOLS_IMAGE}(首次约 2–4 分钟,之后走层缓存)"
if [[ "$HAS_TOOLS_SERVICE" == true && "$PROFILE_SUPPORTED" == true ]]; then
compose --profile tools build tools
else
docker build -f "$TOOLS_DOCKERFILE" -t "$TOOLS_IMAGE" .
fi
TOOLS_IMAGE_READY=true
}
run_tool() {
local script="$1"
shift
if [[ "$HAS_TOOLS_SERVICE" == true && "$PROFILE_SUPPORTED" == true ]]; then
compose --profile tools run --rm --no-deps -T tools npm run "$script" -- "$@"
else
docker run --rm \
--network "$NETWORK" \
--env-file .env \
-e DATABASE_URL="$(env_database_url)" \
-e DATABASE_STATEMENT_TIMEOUT_MS="${DATABASE_STATEMENT_TIMEOUT_MS:-60000}" \
-e TZ="${TZ:-Asia/Shanghai}" \
"$TOOLS_IMAGE" \
npm run "$script" -- "$@"
fi
}
# 回填脚本在「还有剩余」时返回 2,语义是“继续跑下一轮”,不是失败。
run_backfill() {
local script="$1"
shift
local round=1 code=0
while :; do
log " 第 ${round} 轮:npm run ${script} -- $*"
set +e
run_tool "$script" "$@"
code=$?
set -e
if [[ $code -eq 0 ]]; then
return 0
fi
if [[ $code -eq 2 && $round -lt $MAX_BACKFILL_ROUNDS ]]; then
warn " 仍有剩余(退出码 2),继续下一轮"
round=$((round + 1))
continue
fi
if [[ $code -eq 2 ]]; then
fail "${script} 跑满 ${MAX_BACKFILL_ROUNDS} 轮仍有剩余;调大 --max-batches 后重跑"
fi
fail "${script} 失败(退出码 ${code})"
done
}
# ---------- 各阶段 ----------
PENDING_REVENUE="?"
PENDING_FULFILLMENT="?"
preflight() {
log "预检"
[[ -f .env ]] || fail "项目根目录缺少 .env(生产由 ubuntu-deploy.sh 检查)"
[[ -f "$VERIFY_SQL" ]] || fail "缺少验收脚本 $VERIFY_SQL"
local running
running="$(compose ps -q postgres 2>/dev/null | head -1 || true)"
[[ -n "$running" ]] || fail "postgres 容器未运行:先执行 bash deploy/ubuntu-deploy.sh"
detect_capabilities
detect_network
local migrations
migrations="$(psql_scalar "SELECT (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^103_'))::int + (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^104_'))::int + (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^105_'))::int" | tr -d '[:space:]')"
if [[ "$migrations" != "3" ]]; then
fail "迁移 103–105 未全部应用(当前 ${migrations}/3):先确认 deploy/ubuntu-deploy.sh 成功、backend 日志无迁移错误"
fi
PENDING_REVENUE="$(psql_scalar "SELECT count(*) FROM order_items WHERE revenue_basis = 'unknown'" | tr -d '[:space:]')"
PENDING_FULFILLMENT="$(psql_scalar "SELECT count(*) FROM order_items WHERE fulfillment_mode IS NULL" | tr -d '[:space:]')"
log " postgres 运行中 / 迁移 103–105 已应用"
log " 待回填收入项:${PENDING_REVENUE} 行;待回填履约方式:${PENDING_FULFILLMENT} 行"
log " 工具镜像:${TOOLS_IMAGE}(services=${HAS_TOOLS_SERVICE}, network=${NETWORK})"
}
step_backfill() {
log "① 回填存量订单项收入(revenue:backfill)"
if [[ "$DRY_RUN" == true ]]; then
if docker image inspect "$TOOLS_IMAGE" >/dev/null 2>&1; then
run_tool revenue:backfill --dryRun || warn "dry-run 预览失败(不影响后续)"
else
log " [dry-run] 跳过执行(工具镜像未构建)"
fi
else
run_backfill revenue:backfill --batchSize="$BATCH_SIZE" --maxBatches="$MAX_BATCHES"
fi
log "② 回填履约方式(fulfillment:backfill,只填 NULL)"
if [[ "$DRY_RUN" == true ]]; then
log " [dry-run] 跳过执行"
else
run_backfill fulfillment:backfill --batchSize="$BATCH_SIZE" --maxBatches="$MAX_BATCHES"
fi
if [[ "$DRY_RUN" != true ]]; then
refresh_stats
fi
}
# 回填会 UPDATE 40 万行 order_items:既产生大量死元组,又给新列 fulfillment_mode
# 留下空统计。autovacuum 追不上时,订单列表的 COUNT 会被估算成 orders 全表扫描,
# 冷缓存实测 20 秒级(测试库曾因此 500)。这里在回填后立刻补一次,VACUUM 只拿
# SHARE UPDATE EXCLUSIVE,不阻塞读写。
refresh_stats() {
log "②' 刷新统计信息与可见性(VACUUM ANALYZE order_items/orders)"
if ! compose exec -T postgres sh -lc \
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -c "VACUUM (ANALYZE) order_items" -c "VACUUM (ANALYZE) orders"'; then
warn "VACUUM ANALYZE 失败:统计信息未刷新,订单列表 COUNT 可能变慢;请手动补跑后再放开后台"
fi
}
step_sync() {
local from="$FROM_DATE" to="$TO_DATE" win_to
local products
products="$(psql_scalar 'SELECT count(*) FROM product_catalog' | tr -d '[:space:]')"
if [[ "$products" == "0" ]]; then
warn "商品目录为空:finance:sync 会为未归一订单项生成大量 product_mapping_missing 异常;建议先完成商品初始化再跑 --only=sync"
fi
log "③ 财务历史同步(finance:sync --mode=all),区间 ${from} → ${to},每窗 ${WINDOW_DAYS} 天"
while :; do
win_to="$(date_add_days "$from" $((WINDOW_DAYS - 1)))"
if [[ "$win_to" > "$to" ]]; then
win_to="$to"
fi
if [[ "$DRY_RUN" == true ]]; then
log " [dry-run] npm run finance:sync -- --mode=all --from=${from} --to=${win_to}"
else
log " 窗口 ${from} → ${win_to}"
run_tool finance:sync --mode=all --from="$from" --to="$win_to"
fi
[[ "$win_to" == "$to" ]] && break
from="$(date_add_days "$win_to" 1)"
done
}
step_verify() {
log "④ 上线验收(只读)"
local code=0
set +e
compose exec -T -e LAUNCH_AT="$LAUNCH_AT" postgres sh -lc \
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v launch_at="$LAUNCH_AT" -f -' \
<"$VERIFY_SQL"
code=$?
set -e
if [[ $code -ne 0 ]]; then
warn "验收未通过(psql 退出码 ${code}):按上面 FAIL 项的「处理建议」逐条处理后,重跑 --only=verify"
return 1
fi
log "验收全部通过"
}
manual_checklist() {
cat <<'EOF'
────────────────────────────────────────────────────────────
剩余人工步骤(后台 UI,脚本无法代替):
1) 钱包管理 → 补建客服账户 → 拨付备用金
2) 商品与成本 → 积分钱包 → 发放三平台积分
3) 商品与成本 → 标准商品 →「从接单模板初始化」→「按工单回填商品」→「归一人工商品」
4) 商品与成本 → 人工 SKU 配置 →「扫描」并逐项配置
5) 财务 → 总账与对账 →「同步销售事实到总账」→ 试算平衡 → 按需关账
首次上线顺序:--only=backfill → 完成 1)–4) → --only=sync → 完成 5) → --only=verify
手册:docs/财务上线初始化与验收-2026-10-26.md
────────────────────────────────────────────────────────────
EOF
}
# ---------- 主流程 ----------
main() {
if [[ -z "$FROM_DATE" ]]; then FROM_DATE="$(date_days_ago 90)"; fi
if [[ -z "$TO_DATE" ]]; then TO_DATE="$(date_today)"; fi
if [[ -z "$LAUNCH_AT" ]]; then LAUNCH_AT="$(date '+%Y-%m-%d %H:%M:%S%z')"; fi
is_iso_date "$FROM_DATE" || fail "--from 需要 YYYY-MM-DD(收到:${FROM_DATE})"
is_iso_date "$TO_DATE" || fail "--to 需要 YYYY-MM-DD(收到:${TO_DATE})"
[[ "$FROM_DATE" < "$TO_DATE" || "$FROM_DATE" == "$TO_DATE" ]] || fail "--from 不能晚于 --to"
log "计划:dry-run=${DRY_RUN} only=${ONLY:-all} from=${FROM_DATE} to=${TO_DATE} window=${WINDOW_DAYS}d launch-at=${LAUNCH_AT}"
preflight
if should_run backfill && [[ "$SKIP_BACKFILL" != true ]]; then
ensure_tools_image
step_backfill
fi
if should_run sync && [[ "$SKIP_SYNC" != true ]]; then
ensure_tools_image
step_sync
fi
local verify_code=0
if should_run verify && [[ "$NO_VERIFY" != true ]]; then
step_verify || verify_code=1
fi
manual_checklist
if [[ $verify_code -ne 0 && "$DRY_RUN" != true ]]; then
warn "数据阶段已完成,但验收未通过:按上面的 FAIL 项处理后重跑 bash deploy/post-deploy.sh --only=verify"
exit 2
fi
log "完成"
}
main "$@"
+22
View File
@@ -199,6 +199,26 @@ restore_configs() {
rm -rf -- "${staging}"
}
# pg_restore 不会带 pg_statistic,恢复后 planner 会按默认选择率选计划
# (41 万单实测会把订单列表 COUNT 走成 orders 全表扫描,冷缓存 20 秒以上)。
# 同时 VACUUM 重建可见性映射,让 index-only scan 真正只读索引。
refresh_stats() {
local database_url
database_url="$(read_env_value DATABASE_URL)"
log "刷新统计信息与可见性(VACUUM ANALYZE),恢复后首次查询才不会全表扫描"
if [[ -z "${database_url}" || "${database_url}" == *"@postgres:"* ]]; then
if ! docker compose exec -T postgres sh -c \
'psql -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-order_site}" -v ON_ERROR_STOP=1 -c "VACUUM (ANALYZE)"'; then
warn "VACUUM ANALYZE 失败。恢复已完成,但请手动执行后再开放后台,否则列表查询可能很慢。"
fi
else
if ! docker compose exec -T -e DB_RESTORE_URL="${database_url}" postgres sh -c \
'psql "$DB_RESTORE_URL" -v ON_ERROR_STOP=1 -c "VACUUM (ANALYZE)"'; then
warn "VACUUM ANALYZE 失败。恢复已完成,但请手动执行后再开放后台,否则列表查询可能很慢。"
fi
fi
}
restore_live_database() {
local key="$1" target="$2" config_target="${3:-}"
@@ -235,6 +255,8 @@ restore_live_database() {
fail "迁移失败。请查看日志;数据已恢复到备份时点。"
fi
refresh_stats
if [[ -n "${config_target}" ]]; then
restore_configs "${config_target}"
fi