☰
数据库自动化运维:Shell脚本实现MySQL与PostgreSQL备份恢复监控
2026/10/13 7:43:48 网站建设 项目流程

刚接手一批数据库实例,先聊聊为什么必须脚本化

上个月接手了一套“历史悠久”的数据库环境,MySQL 和 PostgreSQL 混部在十几台机器上。最初两天我还能靠手敲命令挨个备份,但第三天凌晨一个实例磁盘写满,备份直接失败,我才意识到:这不是工作量问题,而是可靠性问题。人不可能每天凌晨三点准时记得去执行备份,更不可能每次都用同一套标准完成校验。于是我把整套运维动作脚本化,用 Shell 统一接管 MySQL 和 PostgreSQL 的备份、恢复、监控。这篇文章就把我实际落地的脚本设计思路、完整代码、以及踩过的坑都拆开讲一遍。

这套脚本解决的核心问题有三个:备份动作标准化、恢复路径可验证、监控指标可预警。适合的人群是刚接手数据库运维的开发者、需要同时管理两种数据库的 DBA,以及想从手工操作进阶到自动化的小团队。我不打算写一个“一键完美工具”,而是给你一套可以自己改、自己能讲清楚原理的脚本框架。你照着思路搭,遇到具体环境差异时知道改哪里、为什么改。

1. 动手写脚本前,先把备份策略和恢复目标定清楚

很多人一上来就写mysqldump > backup.sql,然后定时任务一挂就算完事。这种做法不是备份,是安慰自己。真正合格的自动化备份脚本,首先要回答五个问题:备份什么、备份到哪里、保留多久、恢复时怎么验证、出问题时能不能快速找到正确的备份文件。

1.1 先分清物理备份与逻辑备份的适用场景

MySQL 和 PostgreSQL 都支持物理备份和逻辑备份,选错类型会导致恢复时才发现备份不可用。这里我先做一个对比,这也是你写脚本前必须做的选型判断。

对比维度逻辑备份(mysqldump / pg_dump)物理备份(XtraBackup / pg_basebackup)
备份内容SQL 语句或文本格式数据数据文件的二进制拷贝
备份速度较慢,数据量大时明显吃力快,接近文件拷贝速度
恢复粒度可以按表、按库恢复通常整实例恢复
适用场景中小数据量、跨版本迁移、定期抽样大数据量、全量恢复、主从搭建
对线上影响有锁表或事务压力相对较小

我的建议是:日常自动化脚本里优先用逻辑备份,因为它恢复灵活、可控性强,而且对新手最友好。数据量超过 50GB 之后,再考虑引入物理备份作为补充。逻辑备份还有一个隐藏优点:备份文件是文本,能直接 grep 关键内容,排查问题时非常方便。

1.2 用 RTO 和 RPO 反推备份频率与保留周期

RTO(恢复时间目标)和 RPO(恢复点目标)这俩词听着专业,其实大白话就是:宕机后你最多能忍受多久恢复,最多能丢多少数据。没有这两个值,备份频率和保留周期根本没法定。

比如核心业务库要求 RPO 不超过 1 小时,那你至少每小时做一次增量备份或者 binlog 连续归档;要求 RPO 是 24 小时,那每天全量备份就够。保留周期也一样,业务要求能找回 30 天内误删的数据,那保留策略就不能少于 30 天,而且最好不要只留一份。我在脚本里默认采用“保留最近 14 天,其中每周一的全量额外保留 8 周”的策略,这属于通用保守方案。你接手新环境时,第一件事就是找业务方确认这两个数值,然后再改脚本里的保留参数。

2. MySQL 自动化备份脚本:从单库到多实例的完整实现

MySQL 的备份脚本核心逻辑不复杂,但细节决定恢复时是否省心。下面这份脚本我在生产环境跑过,处理过单实例和同机多实例(多个端口)的情况。

2.1 基础备份命令的正确打开方式:不要只写 mysqldump

手写命令时大家都会写mysqldump -u root -p密码 库名 > 文件.sql,但自动化脚本里这么写会让你崩溃。必须显式指定字符集、单事务、扩展插入和锁表策略,否则恢复时要么中文乱码,要么大数据量恢复慢到怀疑人生。我常用的核心命令如下:

mysqldump \ --single-transaction \ --routines \ --triggers \ --events \ --set-gtid-purged=OFF \ --default-character-set=utf8mb4 \ --extended-insert \ -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASS}" \ "${DB_NAME}" > "${BACKUP_FILE}"

这里逐个解释为什么这么写:

  • --single-transaction:InnoDB 表下通过开启一个一致读快照来备份,不会锁住业务读写。如果你的表是 MyISAM,这个参数不生效,需要改成--lock-tables,但生产环境强烈建议迁到 InnoDB。
  • --routines和--triggers:存储过程和触发器默认不会被导出,不加这两个参数等于备份丢了半个数据库。
  • --set-gtid-purged=OFF:如果你开了 GTID,导出文件里会带着 GTID 信息,恢复到其他实例时容易冲突。OFF 之后恢复更干净。
  • --extended-insert:多条 insert 合并成一条,恢复速度能提升数倍。

2.2 多实例场景下的端口扫描与并行备份

同一个物理机上跑多个 MySQL 实例(不同端口)是常见的省钱部署方式。用脚本管理时不能只写一个固定端口,需要自动发现实例。我写了一个简单的端口列表 + 并发控制逻辑:

#!/bin/bash # 备份所有 MySQL 实例 MYSQL_PORTS=(3306 3307 3308) BACKUP_BASE="/data/backup/mysql" DATE_TAG=$(date +%Y%m%d_%H%M%S) retention_days=14 for port in "${MYSQL_PORTS[@]}"; do ( echo "[$(date '+%F %T')] 开始备份 MySQL 实例 ${port}" mkdir -p "${BACKUP_BASE}/${port}/${DATE_TAG}" || exit 1 # 获取该实例上的所有数据库名,排除系统库 databases=$(mysql -h 127.0.0.1 -P "${port}" -u backup_user -p"${BACKUP_PASS}" \ -N -e "SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','performance_schema','mysql','sys');") for db in ${databases}; do mysqldump \ --single-transaction \ --routines \ --triggers \ --events \ --set-gtid-purged=OFF \ --default-character-set=utf8mb4 \ --extended-insert \ -h 127.0.0.1 -P "${port}" -u backup_user -p"${BACKUP_PASS}" \ "${db}" | gzip > "${BACKUP_BASE}/${port}/${DATE_TAG}/${db}.sql.gz" echo "[$(date '+%F %T')] 完成库 ${db} 的备份,端口 ${port}" done # 清理超过保留天数的文件 find "${BACKUP_BASE}/${port}" -maxdepth 1 -type d -name "20*" -mtime +"${retention_days}" -exec rm -rf {} \; echo "[$(date '+%F %T')] 实例 ${port} 备份结束,已清理 ${retention_days} 天前旧文件" ) & done wait echo "全部 MySQL 实例备份任务完成"

这段脚本里有两个容易忽略的点。第一,用127.0.0.1而不是localhost,因为某些 MySQL 版本下 localhost 会走 socket 连接,脚本里环境变量 PATH 不一致时可能找不到 socket 文件,干脆走 TCP 更稳定。第二,用wait并行执行多个端口备份,但并行数不建议超过物理机 CPU 核数,我通常限制同一时刻最多 3 个备份任务,避免磁盘 IO 被拉满。

2.3 备份结果校验:没有校验的备份等于没有备份

脚本跑完只是第一步,文件写出来了不代表数据是好的。我每次备份后会顺手做三件事:

# 1. 检查文件大小小于 10KB 视为异常 file_size=$(stat -c%s "${BACKUP_FILE}") if [ "${file_size}" -lt 10240 ]; then echo "[ERROR] 备份文件异常偏小,疑似失败: ${BACKUP_FILE}" echo "${BACKUP_FILE}" >> /tmp/backup_failed_list.txt fi # 2. 检查文件头部是否包含 mysqldump 完整导出标志 head -n 20 "${BACKUP_FILE}" | grep -q "Dump completed" && echo "头部与尾部标记齐全" # 3. 统计表数量与源库对比 tables_count=$(mysql -h 127.0.0.1 -P ${port} -u backup_user -p"${BACKUP_PASS}" \ -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${db}';")

当然,最稳妥的校验办法是把备份文件导入到一个临时实例里,抽样查询几张表的数据条数,这是后文恢复演练部分会展开的内容。脚本里的文件大小阈值和表数量对比只能作为快速防线,能拦下 90% 的失败情况。

3. PostgreSQL 备份脚本:pg_dump 与 pg_basebackup 的取舍

PostgreSQL 的备份逻辑和 MySQL 相似,但细节差异很大,尤其是权限模型和 WAL 归档机制。混用 MySQL 那套思路直接搬过来,十有八九会翻车。

3.1 pg_dump 备份时的权限坑和连接参数

PostgreSQL 的pg_dump可以通过PGPASSWORD环境变量传密码,也可以在连接串里指定。脚本里我倾向于用连接串,因为它同时支持多主机、多端口、多库名,可读性更好。核心备份命令如下:

#!/bin/bash # PostgreSQL 逻辑备份脚本 PG_HOST="127.0.0.1" PG_PORT="5432" PG_USER="backup_user" PG_DB="appdb" BACKUP_BASE="/data/backup/postgresql" DATE_TAG=$(date +%Y%m%d_%H%M%S) BACKUP_DIR="${BACKUP_BASE}/${PG_DB}/${DATE_TAG}" mkdir -p "${BACKUP_DIR}" export PGPASSWORD="${PG_PASS}" # 使用自定义格式,不要用纯 SQL 格式做默认备份 pg_dump \ -h "${PG_HOST}" \ -p "${PG_PORT}" \ -U "${PG_USER}" \ -d "${PG_DB}" \ -Fc \ -f "${BACKUP_DIR}/${PG_DB}.dump" if [ $? -eq 0 ]; then echo "[$(date '+%F %T')] PostgreSQL 备份成功: ${BACKUP_DIR}/${PG_DB}.dump" # 记录备份元数据 pg_dump --version >> "${BACKUP_DIR}/backup_meta.txt" echo "备份数据库: ${PG_DB}" >> "${BACKUP_DIR}/backup_meta.txt" echo "备份时间: $(date '+%F %T')" >> "${BACKUP_DIR}/backup_meta.txt" else echo "[ERROR] PostgreSQL 备份失败,请检查错误日志" fi

为什么用-Fc自定义格式而不是默认的纯 SQL 格式?几条原因很实在:

  • 自定义格式支持pg_restore灵活恢复,可以只解出某张表,纯 SQL 格式做不到。
  • 支持压缩,文件体积直接小 3 到 5 倍。
  • 导入时支持并行恢复(-j参数),大库恢复时间能省一半以上。

需要提醒的是:pg_dump的权限要求比 MySQL 更严格,backup_user必须拥有数据库的CONNECT权限,如果是需要备份全局对象(角色、表空间),必须用pg_dumpall或用超级用户备份。我一般是这样分工:全库对象用pg_dumpall --globals-only每周备份一次,业务数据用pg_dump -Fc每天备份一次。两者分开,恢复时也清晰。

3.2 物理备份场景:pg_basebackup 和 WAL 归档的关系

当数据库超过 100GB,逻辑备份恢复时间太长,这时候需要物理备份。PostgreSQL 物理备份的标准姿势是pg_basebackup,它直接拷贝整个数据目录。但光有基础备份还不够,你必须配合 WAL 归档才能恢复到任意时间点。

# 使用 pg_basebackup 做基础备份 pg_basebackup \ -h "${PG_HOST}" \ -p "${PG_PORT}" \ -U "${PG_USER}" \ -D "${BACKUP_DIR}/pg_base" \ -Fp \ -P \ -X stream \ -R

命令里的核心参数说明:

  • -Fp:输出普通文件格式,比 tar 格式更适合后续直接启动实例,省去解包步骤。
  • -X stream:备份过程中同时流式获取 WAL 日志,而不是备份结束时单独拷贝,避免时间窗口内的数据不一致。
  • -R:自动生成恢复配置文件postgresql.auto.conf,这会为后续做热备或恢复提供极大便利。

但这里明确讲一句:pg_basebackup 的恢复并不能保证帮你找回备份点之后的所有数据,它只是基础备份,真正保证 RPO 的是连续的 WAL 归档。我通常是让 PostgreSQL 的archive_command把 WAL 上传到备份服务器或对象存储,再靠restore_command做 PITR 恢复。这套机制完整展开能写几千字,日常场景里逻辑备份已经够用,物理备份一般留给大数据量或主从架构。

3.3 备份元数据管理:统一起见,写个 info 文件

我管理几十个数据库实例后,最深刻的体会是:备份文件本身不稀有,稀有的是“哪个文件对应哪个时间点、用哪个版本工具导出的”。跨版本恢复时,如果pg_dump版本和 PostgreSQL 服务器版本相差过大,很可能恢复失败。

所以每次备份完成后,我一定会生成一个backup.info文件,记录这些信息:

cat > "${BACKUP_DIR}/backup.info" <<EOF db_name=${PG_DB} backup_time=$(date +%F_%T) pg_version=$(psql -h ${PG_HOST} -p ${PG_PORT} -U ${PG_USER} -d ${PG_DB} -tAc "SHOW server_version;") pg_dump_version=$(pg_dump --version) file_name=${PG_DB}.dump file_size=$(du -sb "${BACKUP_DIR}/${PG_DB}.dump" | awk '{print $1}') checksum=$(md5sum "${BACKUP_DIR}/${PG_DB}.dump" | awk '{print $1}') EOF

不要觉得这多余。等到哪一天要恢复三个月前的数据,面对几百个目录时,你就知道一个包含时间、版本、校验值的 info 文件能帮你省下多少排查时间。MySQL 那边同理,我在 MySQL 备份里也顺手加了一份元数据,虽然格式不同,但核心信息字段保持一致。

4. 恢复脚本设计:备份是手段,恢复才是目的

这一章是全文最关键的实操环节。很多备份脚本写得花团锦簇,但恢复脚本形同虚设。我见过太多人在恢复时才第一次尝试自己写的备份脚本,结果要么缺参数、要么路径写死、要么密码过期。恢复这件事,必须提前演练,而且恢复脚本必须和备份脚本配套开发。

4.1 MySQL 恢复脚本:支持单库恢复与多库恢复

MySQL 恢复相对简单,核心就是mysql < backup.sql或先建库再导入。但自动化恢复脚本要处理的细节比想象中多,比如导入前要不要建库、导入失败后要不要回滚、恢复时是否要临时关闭外键检查。

#!/bin/bash # MySQL 恢复脚本 RESTORE_FILE="$1" TARGET_DB="$2" MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}" MYSQL_PORT="${MYSQL_PORT:-3306}" if [ -z "${RESTORE_FILE}" ] || [ -z "${TARGET_DB}" ]; then echo "用法: $0 <备份文件.sql.gz> <目标数据库>" exit 1 fi # 如果备份是压缩文件,先解压到临时目录 if [[ "${RESTORE_FILE}" == *.gz ]]; then gunzip -c "${RESTORE_FILE}" > /tmp/restore_tmp.sql RESTORE_FILE="/tmp/restore_tmp.sql" fi # 检测备份文件里是否包含建库语句 has_create_db=$(grep -c "CREATE DATABASE" "${RESTORE_FILE}" || true) if [ "${has_create_db}" -eq 0 ]; then # 没有建库语句,需要手动建库 mysql -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" -u "${MYSQL_USER}" -p"${MYSQL_PASS}" \ -e "CREATE DATABASE IF NOT EXISTS \`${TARGET_DB}\` DEFAULT CHARACTER SET utf8mb4;" echo "已手动创建数据库 ${TARGET_DB}" fi # 正式导入 mysql -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" -u "${MYSQL_USER}" -p"${MYSQL_PASS}" \ --default-character-set=utf8mb4 \ "${TARGET_DB}" < "${RESTORE_FILE}" # 清理临时文件 rm -f /tmp/restore_tmp.sql

这里面有个实用技巧:先用grep判断备份文件是否已有CREATE DATABASE,决定是否需要手动建库。因为不同版本的mysqldump行为不一样,有些会带库名导出,有些不带,统一判断能避免恢复时报“Unknown database”错误。

4.2 PostgreSQL 恢复脚本:pg_restore 的并行与权限处理

PostgreSQL 恢复比 MySQL 麻烦,因为pg_restore需要连接到一个已经存在的数据库,而自定义格式的 dump 文件恢复时需要指定目标库。我在脚本里做了三层判断:目标库是否存在、是否允许删库重建、恢复后是否执行 ANALYZE。

#!/bin/bash # PostgreSQL 恢复脚本 RESTORE_FILE="$1" TARGET_DB="$2" PG_HOST="${PG_HOST:-127.0.0.1}" PG_PORT="${PG_PORT:-5432}" PG_USER="${PG_USER:-postgres}" export PGPASSWORD="${PG_PASS}" # 第一步:确认目标库是否存在 db_exists=$(psql -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_USER}" -d postgres \ -tAc "SELECT 1 FROM pg_database WHERE datname='${TARGET_DB}'") if [ "${db_exists}" != "1" ]; then echo "目标库 ${TARGET_DB} 不存在,开始创建" createdb -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_USER}" "${TARGET_DB}" fi # 第二步:并行恢复,按依赖排序 pg_restore \ -h "${PG_HOST}" \ -p "${PG_PORT}" \ -U "${PG_USER}" \ -d "${TARGET_DB}" \ --clean \ --if-exists \ --no-owner \ --no-privileges \ -j 4 \ "${RESTORE_FILE}" if [ $? -eq 0 ]; then # 第三步:恢复后更新统计信息,避免查询计划变差 psql -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_USER}" -d "${TARGET_DB}" \ -c "ANALYZE;" echo "[$(date '+%F %T')] PostgreSQL 恢复完成" else echo "[ERROR] PostgreSQL 恢复失败" exit 1 fi

几个参数的用意说清楚:--clean --if-exists表示恢复前先删除已存在的同名对象,适合完整恢复场景;--no-owner --no-privileges表示不要尝试恢复对象原来的属主和权限,因为备份时的用户和目标环境大概率不一致,硬套反而会失败;-j 4用 4 个并行任务恢复,速度能提升不少,但不要超过 CPU 核数。

4.3 恢复演练的标准流程:不要等灾难发生时才第一次操作

说句实在话,如果你只是写了备份脚本和恢复脚本,但从来没演练过,那这套自动化等于没完成。我强烈建议至少每季度做一次全量恢复演练,流程如下:

  1. 准备一台与生产环境隔离的恢复测试机,软硬件不必完全一致,但数据库版本必须一致。
  2. 从备份服务器随机抽一个最近的备份文件,执行恢复脚本。
  3. 恢复完成后,对比源库的表数量、关键表行数、最近写入时间戳。
  4. 如果恢复失败,立刻排查原因并修复脚本流程,同时记录到故障文档里。
  5. 演练结束后,删除测试机上恢复出来的数据,避免占用空间和混淆。

有一次演练真的帮我发现了一个隐藏很深的 bug:我们的备份脚本里把 MySQL 的events表和triggers都导出来了,但恢复时events是默认禁用状态,需要手动执行SET GLOBAL event_scheduler = ON,而恢复脚本没有这个步骤,导致生产环境的定时事件一直没跑。这类问题不演练根本发现不了,等真出事时再发现就晚了。

5. 监控脚本:盯住连接数、复制状态和磁盘余量

备份和恢复是底线,但数据库日常故障中,真正影响业务的往往是连接数暴增、复制中断、磁盘写满这类“慢性病”。监控脚本的价值在于尽早暴露苗头,把故障扼杀在膨胀之前。

5.1 MySQL 监控脚本:重点指标抓取与阈值告警

我写了一套轻量级监控脚本,直接 SSH 到数据库主机执行,不需要额外安装 agent。核心监控四个指标:连接数利用率、慢查询数(最近5分钟)、主从复制状态、磁盘剩余空间。命令如下:

#!/bin/bash # MySQL 监控核心逻辑 DB_HOST="${DB_HOST:-127.0.0.1}" DB_PORT="${DB_PORT:-3306}" DB_USER="${MONITOR_USER}" DB_PASS="${MONITOR_PASS}" # 1. 连接数利用率 max_connections=$(mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASS}" \ -N -e "SHOW VARIABLES LIKE 'max_connections';" | awk '{print $2}') current_connections=$(mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASS}" \ -N -e "SHOW STATUS LIKE 'Threads_connected';" | awk '{print $2}') usage_rate=$(echo "scale=2; ${current_connections} * 100 / ${max_connections}" | bc) echo "连接数使用率: ${usage_rate}%" # 2. 最近5分钟慢查询数 slow_queries=$(mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASS}" \ -N -e "SHOW GLOBAL STATUS LIKE 'Slow_queries';" | awk '{print $2}') echo "累计慢查询数: ${slow_queries}" # 3. 复制状态(只在从库上有效) replica_status=$(mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASS}" \ -e "SHOW REPLICA STATUS\G" | grep -E "Replica_IO_Running|Replica_SQL_Running" | awk '{print $2}') echo "复制IO线程: $(echo ${replica_status} | awk '{print $1}')" echo "复制SQL线程: $(echo ${replica_status} | awk '{print $2}')" # 4. 磁盘剩余空间 disk_free=$(df -P "${BACKUP_BASE}" | tail -1 | awk '{print $4}') disk_used=$(df -P "${BACKUP_BASE}" | tail -1 | awk '{print $5}' | tr -d '%') echo "备份盘剩余: ${disk_free} KB, 使用率: ${disk_used}%"

关于告警阈值,我的经验值如下:连接数使用率超过 80% 就要关注,超过 90% 必须告警;磁盘使用率超过 85% 就要开始考虑扩容或清理;复制线程任何一个不是Yes都要立刻告警,尤其 IO 线程断了的场景,中断时间越长,追赶主库的代价越大。

5.2 PostgreSQL 监控脚本:锁等待、活跃连接与 WAL 堆积

PostgreSQL 的监控侧重点和 MySQL 不同,最值得盯的有三块:活跃连接数(PostgreSQL 默认max_connections是 100,很容易触顶)、锁等待状态、以及 WAL 目录堆积。核心命令片段:

#!/bin/bash # PostgreSQL 监控核心逻辑 PG_HOST="${PG_HOST:-127.0.0.1}" PG_PORT="${PG_PORT:-5432}" PG_USER="${MONITOR_USER}" export PGPASSWORD="${MONITOR_PASS}" # 1. 活跃连接数与最大连接数 conn_info=$(psql -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_USER}" -d postgres -tAc \ "SELECT current_setting('max_connections'), count(*) FROM pg_stat_activity WHERE datname IS NOT NULL;") max_conn=$(echo "${conn_info}" | awk -F'|' '{print $1}') cur_conn=$(echo "${conn_info}" | awk -F'|' '{print $2}') echo "PostgreSQL 连接数: ${cur_conn} / ${max_conn}" # 2. 长时间运行的查询(超过 5 分钟的) long_queries=$(psql -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_USER}" -d postgres -tAc \ "SELECT pid, now() - query_start AS duration, state, left(query, 80) FROM pg_stat_activity WHERE state <> 'idle' AND now() - query_start > interval '5 minutes';") if [ -n "${long_queries}" ]; then echo "发现有长时间未完成查询:" echo "${long_queries}" fi # 3. WAL 目录大小,堆积过多往往是复制或归档出现问题 wal_size=$(du -sh "${PG_DATA_DIR}/pg_wal" 2>/dev/null | awk '{print $1}') echo "WAL 目录大小: ${wal_size}"

PostgreSQL 的锁等待是隐性杀手。我曾遇到过一个业务表被一个忘记了提交的事务锁住,所有读写全部卡住,表面看连接数不高、CPU 正常,但业务响应已经全挂。所以脚本里我会额外查pg_locks和pg_stat_activity里wait_event_type的分布。这个细节很容易被忽略,但生产事故中锁问题占比不低。

5.3 监控结果推送:不打扰人的告警才是好告警

监控数据抓出来之后,如果只写在日志里,基本等于没监控。我采用的方案是分层通知:正常信息写入本地日志;超过警告阈值但没到危险线,写入文件并每日汇总邮件发送;达到危险阈值才实时推送。

这里推荐一个轻量思路:直接用系统自带的mailx发送邮件,或者接入企业微信群机器人、钉钉机器人,用 curl 推送 JSON 消息即可。Shell 脚本里加几行判断就能实现:

# 推送示例:企业微信机器人 send_alert() { local message="$1" curl -s -X POST "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${WEBHOOK_KEY}" \ -H "Content-Type: application/json" \ -d "{\"msgtype\": \"text\", \"text\": {\"content\": \"${message}\"}}" } if [ "${usage_rate}" -gt 90 ]; then send_alert "[数据库告警] MySQL ${DB_HOST}:${DB_PORT} 连接数使用率 ${usage_rate}%" fi

这类 webhook 推送的好处是免安装、免运维、手机能直接收到,适合小团队。大型团队如果已经有完善的监控平台,那这些 Shell 脚本可以作为辅助脚本嵌入平台调度,不必单独推消息。

6. 定时调度与日志归档:crontab 的正确姿势

脚本写得再好,调度配错一切白搭。crontab 看起来简单,但凡是运维老手都在这里踩过坑。我用一节专门讲清楚调度配置里的隐藏问题。

6.1 一个干净的 crontab 配置示例

下面是我生产环境实际使用的 cron 配置片段:

# 数据库备份任务 # MySQL 逻辑备份:每天凌晨 1 点 0 1 * * * /usr/local/bin/mysql_backup.sh >> /var/log/db_backup/mysql_backup.log 2>&1 # PostgreSQL 逻辑备份:每天凌晨 2 点 0 2 * * * /usr/local/bin/postgresql_backup.sh >> /var/log/db_backup/postgresql_backup.log 2>&1 # 全局对象备份:每周日凌晨 3 点 0 3 * * 0 /usr/local/bin/pg_dumpall_globals.sh >> /var/log/db_backup/globals_backup.log 2>&1 # 数据库监控:每 5 分钟执行一次 */5 * * * * /usr/local/bin/db_monitor.sh >> /var/log/db_backup/monitor.log 2>&1 # 备份结果健康检查:每天早上 8 点检查昨天的备份是否成功 0 8 * * * /usr/local/bin/check_backup_result.sh >> /var/log/db_backup/check_result.log 2>&1

从时间分布上你可以看到我的设计考虑:MySQL 备份和 PostgreSQL 备份故意错开一小时,避免两台数据库同时备份导致磁盘 IO 和网络压力叠加。全局对象备份放每周日,因为这类数据变动极慢,不需要每天做。

6.2 调度中必须处理的三个环境陷阱

  • PATH 环境变量问题:cron 执行时 PATH 非常精简,经常找不到mysql、psql、pg_dump这些命令。我所有脚本第一行都会写死命令路径或者统一在脚本开头export PATH=/usr/local/mysql/bin:/usr/local/pgsql/bin:/usr/bin:/bin:$PATH。
  • 输出日志重定向:不重定向到文件的话,cron 跑出的任何报错信息都会以邮件形式发给 root 邮箱,而大多数人根本不会去查邮箱。坚决写成>> 日志文件 2>&1。
  • 脚本中不要使用相对路径:cron 的工作目录默认是用户家目录,你脚本里如果用了./或../,大概率会找不到文件。所有路径都写绝对路径,或者脚本开头先cd到固定目录。

6.3 备份日志的轮转与保留

日志文件不轮转会越来越大,最后写满磁盘,反而把正常的备份文件挤掉。我用一个logrotate配置来管理:

# /etc/logrotate.d/db_backup /var/log/db_backup/*.log { daily rotate 30 compress delaycompress missingok notifempty }

这里的关键参数是compress和delaycompress。delaycompress 让昨天的日志先不压缩,便于随时查阅;过一天再压缩保存,兼顾方便与空间。30 天的轮转周期足够排查问题时回溯日志。

7. 从一脚本到一整套:健壮性与异常恢复的边际细节

最后这一章,我分享一些脚本设计中很容易被忽视、但出问题时非常要命的边界细节。这些内容不是教科书上教的,是我在真实环境里被坑出来的。

7.1 脚本执行中的幂等性与锁冲突处理

备份脚本如果上一次任务还没结束,下一次 cron 又开始执行,两个 mysqldump 同时写同一目录,轻则文件互相覆盖,重则磁盘直接撑爆。解决办法是在脚本里加一个锁文件:

LOCK_FILE="/var/run/db_backup_${port}.lock" exec 9>"${LOCK_FILE}" if ! flock -n 9; then echo "[$(date '+%F %T')] 备份任务已在执行中,本次跳过" exit 0 fi # 正常执行任务 # ... 备份逻辑 ... # 结束后释放锁 flock -u 9 rm -f "${LOCK_FILE}"

flock -n 9是非阻塞获取文件锁,拿不到锁说明上一个任务还在跑,直接退出。这样即使 cron 频率设置错误,也不会出现两个备份进程打架的问题。这套锁机制同样适用于监控脚本和恢复脚本,尤其恢复脚本,绝对不能并发执行。

7.2 突然断电后的备份目录恢复与一致性检查

这里说一个极少有人意识到的问题:备份目录里如果残留了上一次中断任务的半截文件,下一轮 find 清理可能不会把它删掉,因为它日期可能是今天。如果恢复脚本又恰好选中了这个半截文件,恢复出来就是残缺库,表面没报错,实际数据缺失。

我在脚本里加了“半成品检测”逻辑:定期扫描备份目录,把那些只有文件没有对应backup.info或大小为 0 的文件标记为坏备份,并隔离到一个corrupt/子目录。恢复脚本读取备份列表时,也会自动过滤掉这些坏备份,避免误操作。

# 检测坏备份:没有 info 文件的目录视为坏备份 for dir in "${BACKUP_BASE}"/*/; do if [ ! -f "${dir}/backup.info" ]; then echo "[WARN] 发现缺失 info 文件的目录: ${dir}" mv "${dir}" "${BACKUP_BASE}/corrupt/" fi done

这个设计同样来源于一次真实事故:某次磁盘快满导致备份中途断掉,残留的 dump 文件大小正常(前面大量数据都写了),但缺少尾部;由于我当时的备份脚本没记录完成标志,恢复测试时一路导入到最后才报错,排查耗费了大半天。

7.3 跨版本恢复与字符集兼容问题

数据库版本升级后,旧备份恢复大概率会遇到“unknown character set”或者版本不识别等错误。脚本层面能做的兼容处理是:恢复前先检查备份文件头部信息,确认源库版本与目标库版本的差距在合理范围内。

# 检查备份文件里的 MySQL 版本信息 backup_version=$(head -n 3 "${RESTORE_FILE}" | grep -o "mysqldump Ver [0-9.]\." | head -1) current_version=$(mysql --version | grep -o "Distrib [0-9.]\.") echo "备份文件版本: ${backup_version}" echo "当前数据库版本: ${current_version}"

如果版本跨度超过一个大版本(比如 5.7 备份恢复到 8.0),我的建议是先恢复到中间版本再迁过去,一步跨恢复很容易因为默认字符集、排序规则的变化产生不可预料的乱码或索引问题。这个属于“能避则避”的经验,至少让脚本在检测到跨大版本时打印一条显眼警告。

7.4 给初次落地这套脚本的三条建议

如果你正准备在自己的环境里实践这套自动化方案,我给三条实操建议:

  • 先从单个实例的备份和恢复跑通开始,不要一上来就做多实例并行。单实例跑通过后,再加端口列表逻辑和并发控制。复杂系统里出了问题,排查链路越短越好,等到一切都跑顺了再逐渐加组件,是运维自动化最稳妥的路径。
  • 密码管理不要硬编码在脚本里。可以用一个独立的配置文件,权限设为 600,脚本通过 source 方式加载;或者接入系统的密钥管理服务。如果脚本不小心泄露,至少不会把数据库密码裸奔出去,这个底线要守住。
  • 每次备份后立刻做一次“轻量校验”,不要等第二天早上统一检查。压缩文件能解压、头部有内容、大小合理、info 文件已生成,这些动作加起来不到几秒钟,但能在备份失败后第一时间告警,而不是让你在第二天恢复现场时才发现昨晚没备份成功。

写在最后:这套脚本框架不是终点,而是起点

手头这套 Shell 自动化脚本经历了三次较大的重构:第一次加上多实例并发,第二次加恢复演练闭环,第三次把所有输出集中到统一日志和告警体系。每一次改动都来自真实故障的驱动,没有一次是因为“看着不顺眼”而改的。这也正是运维脚本和普通代码的最大区别——它永远为下一个故障窗口做预备。

如果你需要基于我这套思路落地自己的方案,建议从 MySQL 和 PostgreSQL 的备份脚本先开始,跑通后再把监控和恢复脚本加进来,最后才考虑调度与告警的整合。每加一个环节,就做一次恢复演练验证,保证新增组件不会影响已有能力。这样你手上这套脚本才能变成真正可信赖的运维基础设施。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询