#!/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//_<日期>.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 "$@"