179 lines
7.1 KiB
Bash
179 lines
7.1 KiB
Bash
|
|
#!/usr/bin/env bash
|
|||
|
|
#
|
|||
|
|
# PMS 数据库每日备份脚本(仓库源文件;部署位置 /usr/local/bin/pms-backup-db.sh)
|
|||
|
|
#
|
|||
|
|
# 背景(2026-09 运维审查):服务器上此前**没有任何数据库备份作业**,
|
|||
|
|
# `crontab -l` 只有 acme.sh,/etc/cron.daily|weekly 为空,全盘无 mysqldump/xtrabackup 痕迹,
|
|||
|
|
# 恢复能力实际为 0(完全依赖阿里云 RDS 自动备份,且该策略无法从 SQL 读取、无仓库记载)。
|
|||
|
|
# 本脚本 + /etc/cron.d/pms-backup 补上这一环。
|
|||
|
|
#
|
|||
|
|
# 设计要点:
|
|||
|
|
# 1. 备份 pms(生产)与 pms_dev(测试)两个库;--single-transaction 一致性快照,不锁表;
|
|||
|
|
# 2. --routines --events --triggers 显式带上:本项目当前只有生成列(mysqldump 自动处理),
|
|||
|
|
# 无存储过程/触发器/事件,但将来一旦引入必须能一并恢复,故现在就带上防漏;
|
|||
|
|
# 3. --default-character-set=utf8mb4 与库字符集一致,避免中文/emoji 变问号;
|
|||
|
|
# 4. 单库 dump(**不带 --databases**):dump 内不含 CREATE DATABASE / USE,
|
|||
|
|
# 因此可以安全地恢复到任意临时库做演练(`zcat x.sql.gz | mysql pms_restore_check`),
|
|||
|
|
# 绝不会因为 dump 里的 USE `pms` 而误写生产库;
|
|||
|
|
# 5. --set-gtid-purged=OFF:RDS 开了 GTID 时,dump 会带 SET @@GLOBAL.GTID_PURGED,
|
|||
|
|
# 恢复到非空实例会直接报 1840 失败;关掉它保证 dump 可移植;
|
|||
|
|
# 6. 密码**不落脚本、不落 cron 行**:统一走 /root/.my.cnf(权限 600,--defaults-extra-file);
|
|||
|
|
# 7. 落盘 /data/backup/mysql/<YYYY-MM-DD>/<db>_<日期>.sql.gz,
|
|||
|
|
# 每个文件记录大小与精确行数(SUM(COUNT(*)))到日志,并做 gzip -t 完整性校验;
|
|||
|
|
# 8. 保留最近 7 天:find ... -mtime +7 -delete(先删过期文件,再删变空的日期目录);
|
|||
|
|
# 9. 只有**全部库都备份成功**才执行保留策略清理,避免"新备份失败 + 删掉旧备份"= 无备份;
|
|||
|
|
# 10. flock 防止 cron 与手工执行重叠。
|
|||
|
|
#
|
|||
|
|
# 用法:
|
|||
|
|
# /usr/local/bin/pms-backup-db.sh # 正常备份(cron 每日 03:30 调用)
|
|||
|
|
# tail -n 100 /var/log/pms-backup-db.log # 查看备份日志
|
|||
|
|
#
|
|||
|
|
set -euo pipefail
|
|||
|
|
|
|||
|
|
# ---------- 可调参数 ----------
|
|||
|
|
BACKUP_ROOT="/data/backup/mysql"
|
|||
|
|
RETENTION_DAYS=7
|
|||
|
|
DATABASES=("pms" "pms_dev")
|
|||
|
|
MYSQL_HOST="127.0.0.1"
|
|||
|
|
MYSQL_PORT="3306"
|
|||
|
|
DEFAULTS_FILE="/root/.my.cnf"
|
|||
|
|
LOG_FILE="/var/log/pms-backup-db.log"
|
|||
|
|
LOCK_FILE="/var/lock/pms-backup-db.lock"
|
|||
|
|
|
|||
|
|
# ⚠️ --defaults-extra-file 必须是 mysqldump/mysql 的**第一个**参数,否则客户端报错退出
|
|||
|
|
MYSQL_OPTS=(--defaults-extra-file="$DEFAULTS_FILE" -h "$MYSQL_HOST" -P "$MYSQL_PORT")
|
|||
|
|
DUMP_OPTS=(
|
|||
|
|
--single-transaction
|
|||
|
|
--routines
|
|||
|
|
--events
|
|||
|
|
--triggers
|
|||
|
|
--default-character-set=utf8mb4
|
|||
|
|
--no-tablespaces
|
|||
|
|
--set-gtid-purged=OFF
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
DATE_DIR="$(date +%Y-%m-%d)"
|
|||
|
|
DEST_DIR="$BACKUP_ROOT/$DATE_DIR"
|
|||
|
|
|
|||
|
|
# ---------- 日志 ----------
|
|||
|
|
# 同时写日志文件与标准输出;cron 行把标准输出丢到 /dev/null,避免日志重复
|
|||
|
|
log() {
|
|||
|
|
local line
|
|||
|
|
line="$(date '+%Y-%m-%d %H:%M:%S') $*"
|
|||
|
|
printf '%s\n' "$line" >> "$LOG_FILE"
|
|||
|
|
printf '%s\n' "$line"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ---------- 辅助查询 ----------
|
|||
|
|
# 统计某库的基础表数量(失败返回 unknown,不中断备份)
|
|||
|
|
count_tables() {
|
|||
|
|
local db="$1" out
|
|||
|
|
if ! out="$(mysql "${MYSQL_OPTS[@]}" -N -B -e \
|
|||
|
|
"SELECT COUNT(*) FROM information_schema.tables \
|
|||
|
|
WHERE table_schema='${db}' AND table_type='BASE TABLE'" 2>&1)"; then
|
|||
|
|
log "[warn] 查询 ${db} 表数量失败: ${out}"
|
|||
|
|
echo "unknown"
|
|||
|
|
return 0
|
|||
|
|
fi
|
|||
|
|
echo "${out:-0}"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 统计某库所有基础表的**精确**总行数(information_schema.TABLE_ROWS 是估算值,不能用)
|
|||
|
|
#
|
|||
|
|
# ⚠️ mysql 客户端**没有指定默认库**(凭据文件里也不给库),因此每张表必须写成
|
|||
|
|
# `\`库名\`.\`表名\``;只写 `\`表名\`` 会报 1046 No database selected —— 曾经的实现
|
|||
|
|
# 把 stderr 丢进 /dev/null,结果每库都静默统计成 0 行。
|
|||
|
|
exact_row_count() {
|
|||
|
|
local db="$1" tables union_sql rows
|
|||
|
|
if ! tables="$(mysql "${MYSQL_OPTS[@]}" -N -B -e \
|
|||
|
|
"SELECT table_name FROM information_schema.tables \
|
|||
|
|
WHERE table_schema='${db}' AND table_type='BASE TABLE'" 2>&1)"; then
|
|||
|
|
log "[warn] 查询 ${db} 表清单失败: ${tables}"
|
|||
|
|
echo "unknown"
|
|||
|
|
return 0
|
|||
|
|
fi
|
|||
|
|
if [ -z "$tables" ]; then
|
|||
|
|
echo 0
|
|||
|
|
return 0
|
|||
|
|
fi
|
|||
|
|
union_sql=""
|
|||
|
|
while IFS= read -r t; do
|
|||
|
|
[ -n "$t" ] && union_sql+="SELECT COUNT(*) AS c FROM \`${db}\`.\`${t}\` UNION ALL "
|
|||
|
|
done <<< "$tables"
|
|||
|
|
if ! rows="$(mysql "${MYSQL_OPTS[@]}" -N -B -e \
|
|||
|
|
"SELECT SUM(c) FROM (${union_sql%UNION ALL }) AS t" 2>&1)"; then
|
|||
|
|
log "[warn] 统计 ${db} 行数失败: ${rows}"
|
|||
|
|
echo "unknown"
|
|||
|
|
return 0
|
|||
|
|
fi
|
|||
|
|
echo "${rows:-0}"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ---------- 单库备份 ----------
|
|||
|
|
# 备份 -> gzip 中间文件 -> 原子改名 -> 完整性校验 -> 记录大小/表数/行数
|
|||
|
|
dump_database() {
|
|||
|
|
local db="$1" out="$2" bytes size tables rows
|
|||
|
|
if ! mysqldump "${MYSQL_OPTS[@]}" "${DUMP_OPTS[@]}" "$db" | gzip > "${out}.part"; then
|
|||
|
|
log "[ERROR] ${db} 备份失败(mysqldump/gzip 非 0 退出)"
|
|||
|
|
rm -f "${out}.part"
|
|||
|
|
return 1
|
|||
|
|
fi
|
|||
|
|
mv -f "${out}.part" "$out"
|
|||
|
|
|
|||
|
|
if ! gzip -t "$out"; then
|
|||
|
|
log "[ERROR] ${db} 备份 gzip 完整性校验失败: ${out}"
|
|||
|
|
return 1
|
|||
|
|
fi
|
|||
|
|
|
|||
|
|
bytes="$(stat -c %s "$out")"
|
|||
|
|
size="$(du -h "$out" | cut -f1)"
|
|||
|
|
tables="$(count_tables "$db")"
|
|||
|
|
rows="$(exact_row_count "$db")"
|
|||
|
|
log "[OK] ${db} -> ${out} 大小=${size}(${bytes} 字节) 表数=${tables} 总行数=${rows} gzip -t=OK"
|
|||
|
|
return 0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ---------- 保留策略:只留最近 RETENTION_DAYS 天 ----------
|
|||
|
|
apply_retention() {
|
|||
|
|
local removed kept
|
|||
|
|
removed="$(find "$BACKUP_ROOT" -mindepth 2 -maxdepth 2 -type f -name '*.sql.gz' \
|
|||
|
|
-mtime +"${RETENTION_DAYS}" -print -delete | wc -l)"
|
|||
|
|
# 删掉因文件被清理而变空的日期目录
|
|||
|
|
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -empty -print -delete >> "$LOG_FILE"
|
|||
|
|
kept="$(find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d | sort | tr '\n' ' ')"
|
|||
|
|
log "[retention] 已删除 ${RETENTION_DAYS} 天前的备份文件 ${removed} 个;当前保留日期目录: ${kept}"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ---------- 主流程 ----------
|
|||
|
|
main() {
|
|||
|
|
exec 9>"$LOCK_FILE"
|
|||
|
|
if ! flock -n 9; then
|
|||
|
|
log "[skip] 已有备份任务在运行,本次跳过"
|
|||
|
|
exit 0
|
|||
|
|
fi
|
|||
|
|
|
|||
|
|
if [ ! -r "$DEFAULTS_FILE" ]; then
|
|||
|
|
log "[FATAL] 凭据文件 ${DEFAULTS_FILE} 不存在或不可读(应为 600 权限)"
|
|||
|
|
exit 1
|
|||
|
|
fi
|
|||
|
|
|
|||
|
|
mkdir -p "$DEST_DIR"
|
|||
|
|
log "[start] 备份开始,目标目录 ${DEST_DIR},库: ${DATABASES[*]}"
|
|||
|
|
|
|||
|
|
local failed=0 db
|
|||
|
|
for db in "${DATABASES[@]}"; do
|
|||
|
|
dump_database "$db" "$DEST_DIR/${db}_${DATE_DIR}.sql.gz" || failed=1
|
|||
|
|
done
|
|||
|
|
|
|||
|
|
if [ "$failed" -eq 0 ]; then
|
|||
|
|
apply_retention
|
|||
|
|
log "[done] 全部备份成功"
|
|||
|
|
else
|
|||
|
|
# 关键顺序:备份失败时**不能**执行清理,否则可能出现"新备份没有、旧备份被删"
|
|||
|
|
log "[done] 存在失败的备份,已跳过保留策略清理(避免误删仅存可用备份)"
|
|||
|
|
fi
|
|||
|
|
exit "$failed"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
main "$@"
|