ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL运维自动化实战:Shell脚本搞定查询备份与批量任务

MySQL运维自动化实战:Shell脚本搞定查询备份与批量任务

1. 为什么值得把 MySQL 操作交给 Shell 脚本

干运维这行,天天和 MySQL 打交道,你是不是也经常碰到这种场景:凌晨两点被电话叫醒,说某个库的连接数满了,你得赶紧上去查processlist,然后手动 kill 掉几个阻塞会话;或者每周都要定时导出线上数据,用mysqldump跑一遍,再把备份文件同步到远程机器;甚至有时候要跨几十台实例批量执行一条ALTER TABLE,只能一台一台登上去敲。

这些都是 Shell 脚本的用武之地。对于 MySQL 运维来说,Shell 脚本的价值不在于写得有多炫酷,而在于三个字:可复用。你把一次手动排障的过程固化成一个脚本,下次再遇到同样问题,一行命令就能搞定;你把每天都要做的备份动作写成脚本配上定时任务,就不用再担心“今天忘了备份”这种低级事故。说白了,脚本是把你的运维经验沉淀下来的载体。

这篇分享基于我一个真实项目的实践——一个管理大约 30 台 MySQL 实例的模拟环境。从一开始靠人肉登服务器执行命令,到后来整理出一整套 Shell 脚本工具集,排障时间从平均二十分钟缩短到三分钟以内,日常巡检也基本自动化了。我踩过的坑、试出来的经验,都会写出来,直接抄作业就行。

但这篇文章不是只给运维 DBA 看的。只要你的工作里需要跟 MySQL 打交道——后端开发环境要建库建表、测试环境要刷数据、数据分析要临时跑查询——学会用 Shell 脚本调用 MySQL 命令,都能省下大把时间。脚本的门槛不高,你只需要懂一点 bash 语法,加上几个关键参数,就能做出非常顺手的工具。

2. 工具选型与基础姿势:搞懂 mysql 命令行客户端的正确用法

2.1 一键登录:别在密码上浪费时间

很多人在 Shell 脚本里操作 MySQL,第一步就卡在登录上。直接在命令行里用mysql -uroot -p'密码',确实简单,但有两个问题:一是密码明文暴露在进程列表里,安全上过不去;二是在脚本里这样写,密码重复出现,后期改密码要改一堆地方。

我常用的做法是配置文件集中管理。

# config.sh DB_HOST="127.0.0.1" DB_PORT="3306" DB_USER="ops_user" DB_PASSWORD="你的密码" DB_NAME="information_schema"

然后在主脚本里引入这个配置:

source ./config.sh MYSQL_CMD="mysql -h${DB_HOST} -P${DB_PORT} -u${DB_USER} -p${DB_PASSWORD} --skip-column-names --batch"

注意我特意加了--batch和--skip-column-names这两个参数。--batch让输出以制表符分隔且不做边框美化,结果更容易用 awk 分割处理;--skip-column-names去掉表头,避免在循环处理时把字段名当成数据。

提示:生产环境建议用.my.cnf或专门的运维账号 + 权限最小化,但在这篇文章的示例里,用配置文件管理已经比直接硬编码强一个层级。

2.2 执行单条 SQL 的正确姿势

mysql -e是最常用的方式,直接在命令行后面跟 SQL 字符串:

mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} -e "SELECT NOW();"

脚本里如果要执行多条 SQL,可以用分号分隔:

mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} -e "USE test_db; SHOW TABLES; SELECT COUNT(*) FROM users;"

但这里有个容易踩的坑:如果 SQL 里包含特殊字符,比如$、反引号、双引号,在 bash 里就要特别注意转义。我的经验是,能用单引号包 SQL 就用单引号,SQL 内部字符串再用双引号,这样能减少一层转义麻烦。

mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} -e 'SELECT * FROM users WHERE name = "张三";'

2.3 从文件读 SQL:写复杂逻辑的必备技能

当 SQL 逻辑变复杂,比如要写一个存储过程或者多表关联的查询,直接在-e后面拼接字符串就非常痛苦,各种转义、换行问题接踵而至。这时候把 SQL 写到文件里,再用输入重定向喂给 mysql 命令,是更优雅的解法。

mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} < ./cleanup_expired_data.sql

甚至可以结合管道,动态生成 SQL:

echo "SELECT id FROM users WHERE status=0;" | mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD}

这条思路很关键,后面讲批量处理的时候会用到。它的本质是把"生成 SQL"和"执行 SQL"两个阶段解耦,你可以在脚本里用循环一行一行生成 SQL,也可以读文件批量执行,灵活性完全不一样。

3. 核心操作脚本实战:从查询、导出到批量执行

3.1 查询结果如何处理?Shell 脚本的"灵魂三问"

脚本操作 MySQL,最核心的问题只有一个:查询结果怎么拿、怎么拆、怎么用。我总结为"灵魂三问":

  • 结果是一行还是一个集合?
  • 结果里有没有空值和特殊字符?
  • 结果要喂给后续的哪个命令?

对应到 bash 代码,常见的有三种处理姿势。

姿势一:直接赋值给变量

适合"查一个值"的场景,比如取当前时间、取某个表的行数。

DB_ROWS=$(mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} --batch --skip-column-names -e "SELECT COUNT(*) FROM test_db.users;") echo "当前 user 表数据量: ${DB_ROWS}"

注意:输出里如果有多行,变量只会拿到第一行。所以这种姿势只适合单值查询。

姿势二:while 循环逐行处理

查询结果有多行时,这是我最常用的姿势。

mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} --batch --skip-column-names -e "SELECT id, name FROM test_db.users WHERE status=1;" | while IFS=$'\t' read -r user_id user_name; do echo "用户 ${user_id} 的名字是 ${user_name}" done

这里有两个关键点:IFS=$'\t'把字段分隔符临时改为制表符,对应前面--batch的输出格式;-r参数防止 read 把反斜杠当转义符吃掉。实测下来,只要字段值里不包含制表符,这个拆分方式非常稳。

姿势三:先导出成文件,再用 awk/sed 二次处理

当查询结果作为中间产物,后面还要复杂加工时,建议先落盘。

mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} --batch --skip-column-names -e "SELECT id, name FROM test_db.users;" > /tmp/users.tsv awk -F'\t' '{print $2}' /tmp/users.tsv | sort | uniq -c | sort -rn

这种"先导出、再分析"的方式还有一个好处:导出文件和排查步骤拆分,中途断了不用重新查数据库。

3.2 批量执行 SQL:小心你的引号和分隔符

批量操作是最能体现 Shell 脚本价值的一块,但也是最容易翻车的一块。我举个实际场景:要给所有订单表加上一个索引,表名分别是orders_2023_01、orders_2023_02、orders_2023_03……一直到orders_2023_12。

用一条命令拼出全部 SQL 再执行:

TABLES=$(mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} --batch --skip-column-names -e "SHOW TABLES LIKE 'orders_2023_%';") INDEX_SQL="" for tbl in ${TABLES}; do INDEX_SQL="${INDEX_SQL} ALTER TABLE ${tbl} ADD INDEX idx_created_at (created_at);" done mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} -e "${INDEX_SQL}"

这个方案表面上可用,但有一个隐患:如果TABLES为空,INDEX_SQL为空字符串,mysql 会直接报错说没有 SQL 传入。所以在脚本里做一层判空非常有必要。

if [ -z "${INDEX_SQL}" ]; then echo "未匹配到任何表,跳过索引变更" exit 0 fi

另一个常见问题是,ALTER TABLE这种 DDL 语句在 MySQL 5.6 以后的版本会自动提交,不能像 DML 那样包在事务里回滚。所以真要执行批量 DDL,请务必先打印 SQL 做个 dry run。

# dry run 模式 if [ "${DRY_RUN}" = "1" ]; then echo "将要执行的 SQL:" echo "${INDEX_SQL}" exit 0 fi

3.3 导入 SQL 文件:source 命令和重定向的区别

有时候需要导入一个较大的 SQL 备份文件,比如从测试环境导出的带数据的 dump 文件。常见做法有两种:

# 方法一:mysql 客户端直接重定向 mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} --default-character-set=utf8mb4 test_db < /backup/test_db.sql # 方法二:source 命令 mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} -e "source /backup/test_db.sql;"

这两种方式都能用,但有几个细节容易被忽略。第一,务必指定--default-character-set=utf8mb4,否则中文数据很容易乱码。第二,重定向方式在导入过程中如果出错,mysql 默认会继续往下执行,不会自动中断;如果希望遇到错误立即停止,需要在 SQL 文件开头加一句:

SET GLOBAL max_allowed_packet=1073741824; SET FOREIGN_KEY_CHECKS=0; -- 遇到错误立即停止 \p -- 或者用 mysql --force 的相反逻辑:不用 --force 即可

其实 mysql 客户端默认遇到错误不会停止,除非你用--force的反向思路——不传--force,但依然有更稳妥的办法。我实际验证过,最简单可靠的是在执行命令末尾加2>&1然后把输出捕获到日志文件,脚本里再 grep 有没有 "ERROR" 关键字。

mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} --default-character-set=utf8mb4 test_db < /backup/test_db.sql > /tmp/import.log 2>&1 if grep -q "ERROR" /tmp/import.log; then echo "导入过程中出现错误,请检查日志" exit 1 fi

4. 备份、巡检与自动化:运维场景中的三把实战利器

4.1 自动化备份脚本的设计思路

备份是运维的底线,Shell 脚本做备份最合适不过。设计思路一般是四步:导出、压缩、保留轮转、异地同步。

先给一个基础版本:

#!/bin/bash # backup_mysql.sh source ./config.sh BACKUP_DIR="/data/backup/mysql" DATE=$(date +%Y%m%d_%H%M%S) DB_LIST="test_db order_db user_db" RETENTION_DAYS=7 mkdir -p ${BACKUP_DIR} for db in ${DB_LIST}; do echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始备份 ${db}" mysqldump -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} --single-transaction --routines --triggers --events ${db} | gzip > ${BACKUP_DIR}/${db}_${DATE}.sql.gz if [ $? -eq 0 ]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] ${db} 备份成功" else echo "[$(date '+%Y-%m-%d %H:%M:%S')] ${db} 备份失败" | tee -a /var/log/mysql_backup.log fi done # 保留最近 7 天 find ${BACKUP_DIR} -name "*.sql.gz" -mtime +${RETENTION_DAYS} -delete echo "备份完成"

这里面的--single-transaction必须给 InnoDB 表用,它利用事务一致性快照做备份,不锁表,线上低峰期跑没问题。--routines、--triggers、--events三个参数保证存储过程、触发器、事件都被导出,否则恢复的时候会丢东西。这个过程我反复被坑过——有次恢复完数据才发现少了触发器,业务直接报错。

经验:mysqldump 导出的 SQL 文件可以在导入前先做一次语法快速检查,办法是grep -i "CREATE.*TRIGGER" ${BACKUP_DIR}/xxx.sql.gz,压缩文件要先用 zcat 再 grep。这个检查 30 秒内完成,但能救一次事故。

4.2 一台机器上的定时巡检脚本

备份做了之后,巡检也不能落下。我设计了一个轻量巡检脚本,放在 crontab 里每五分钟跑一次,检查三个核心指标:实例是否存活、慢查询数量有没有突增、主从延迟(如果有主从的话)。

#!/bin/bash # health_check.sh source ./config.sh ALERT_EMAIL="ops@example.com" # 1. 实例存活检查 if ! mysqladmin -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} ping > /dev/null 2>&1; then echo "[CRITICAL] MySQL 实例无法 ping 通" | mail -s "MySQL Down Alert" ${ALERT_EMAIL} exit 1 fi # 2. 慢查询数量(基于 information_schema) SLOW_COUNT=$(mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} --batch --skip-column-names -e "SELECT COUNT(*) FROM information_schema.processlist WHERE command != 'Sleep' AND time > 10;" 2>/dev/null) if [ "${SLOW_COUNT}" -gt 20 ]; then echo "[WARN] 慢查询数量异常:${SLOW_COUNT}" | mail -s "MySQL Slow Query Alert" ${ALERT_EMAIL} fi # 3. 连接数使用率 MAX_CONN=$(mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} --batch --skip-column-names -e "SHOW VARIABLES LIKE 'max_connections';" | awk '{print $2}') CUR_CONN=$(mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} --batch --skip-column-names -e "SHOW STATUS LIKE 'Threads_connected';" | awk '{print $2}') if [ "${CUR_CONN}" -gt $((MAX_CONN * 80 / 100)) ]; then echo "[WARN] 连接数达到 max_connections 的 80%" fi

巡检脚本的要点是:宁可漏报,不能误报太频繁。如果每分钟发一封告警邮件,最后运维会直接把它拉黑,真正出问题时反而没人看。所以我通常把慢查询阈值设得比较高,比如超过 20 或 30 才算异常,而不是一有慢查询就报警。

crontab 配置是这样的:

*/5 * * * * /opt/scripts/health_check.sh >> /var/log/mysql_health.log 2>&1

注意脚本内部千万不能写交互式的逻辑,全程非交互输出,否则 crontab 环境下会卡住。

4.3 批量实例的统一运维脚本

如果你的环境里有多台 MySQL 实例,单机脚本就有点浪费人力了。这时候我给每台实例一个独立的配置文件,用一个分发脚本去批量执行。

#!/bin/bash # run_all_instances.sh # 用法:./run_all_instances.sh /opt/scripts/task.sh TASK_SCRIPT=$1 INSTANCE_CONFIG_DIR="/opt/ops/instances" for conf in ${INSTANCE_CONFIG_DIR}/*.conf; do instance_name=$(basename ${conf} .conf) echo "============ 开始处理实例:${instance_name} ============" # 读取该实例的配置并注入环境变量 source ${conf} DB_HOST=${INSTANCE_HOST} DB_PORT=${INSTANCE_PORT} DB_USER=${INSTANCE_USER} DB_PASSWORD=${INSTANCE_PASSWORD} # 在新进程中执行任务脚本 bash ${TASK_SCRIPT} if [ $? -eq 0 ]; then echo "实例 ${instance_name} 执行成功" else echo "实例 ${instance_name} 执行失败,检查日志" fi done

配套的实例配置文件长这样:

# instance_192_168_1_10.conf INSTANCE_HOST="192.168.1.10" INSTANCE_PORT="3306" INSTANCE_USER="ops_user" INSTANCE_PASSWORD="password"

这个方案能扩展到几十台实例,只要新加一台机器就复制一份 conf 文件。我有一次做全量索引检查和 schema 比对,就是用这套框架跑完的,效率提升非常明显。

5. 常见问题与排查技巧:这些坑我替你踩过了

5.1 参数压测表:不同场景的命令模板

先上一张我在项目中整理的速查表,把不同场景下推荐用的命令模板固定下来。这省去了很多临时查参数的麻烦。

场景推荐命令关键参数说明
单条查询mysql -e "SELECT ..."--batch+--skip-column-names
批量变更拼 SQL 字符串 + 判空 + dry run禁止直接用-e传多行未转义 SQL
备份mysqldump --single-transaction加上--routines --triggers --events
导入重定向 + 日志捕获 grep ERROR必须指定--default-character-set=utf8mb4
数据导出mysql --batch -e "..." > file.tsv用 IFS=$'\t'拆分
实例状态巡检mysqladmin ping+SHOW STATUS非交互式

5.2 引号地狱:bash 与 SQL 的转义冲突

Shell 脚本里最容易踩的坑就是引号。特别是 SQL 里有单引号字符串、bash 里有单引号包裹、变量还要嵌入的时候,三层套娃非常折磨人。

我举一个真实例子:要更新一张表,把公司名称里的旧后缀替换掉。

OLD_SUFFIX="(旧)" NEW_SUFFIX="(新)" mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} -e "UPDATE company SET name = REPLACE(name, '${OLD_SUFFIX}', '${NEW_SUFFIX}') WHERE name LIKE '%${OLD_SUFFIX}%';"

看起来没问题,但万一OLD_SUFFIX变量里本身包含单引号,整个 SQL 就废了。更稳的办法是把 SQL 中所有用户可控的变量都经过 sed 转义,或者直接用参数化思路,把值放在拼接前的变量里单独做转义。

但 MySQL 命令行客户端本身不支持绑定参数,所以实在复杂的情况下,我会改用--init-command或者在代码里用 Python 写更复杂的变更。Shell 脚本适合 80% 的简单场景,剩下那 20% 别有执念,换工具不丢人。

5.3 定时任务里的环境变量陷阱

很多人写好的脚本手动执行一切正常,但放进 crontab 就各种 "command not found"。

原因很简单:crontab 的环境变量非常少,PATH通常只有/usr/bin:/bin,而你的 mysql 命令可能装在/usr/local/mysql/bin下面。

解决方案包括:

  1. 脚本开头显式设置 PATH:
#!/bin/bash export PATH=/usr/local/mysql/bin:/usr/local/bin:/usr/bin:/bin:$PATH
  1. 在 crontab 里直接写全路径:
*/5 * * * * /usr/local/mysql/bin/mysqladmin -h127.0.0.1 -uops_user -p'password' ping

我自己的习惯是第一种,统一在脚本开头加一行,省得后期换路径还要全局搜。

还有一个容易被忽略的坑:crontab 默认的 shell 是/bin/sh,不是/bin/bash。如果你的脚本用了 bash 特有的语法(比如[[ ]]、$RANDOM、数组),在 cron 里执行就会报错。稳妥做法是脚本第一行写#!/bin/bash,并且给脚本可执行权限,crontab 里直接写脚本路径而不是bash /path/script.sh的变体。

5.4 排查慢查询的一个完整实战案例

最后分享一个我印象比较深的案例。

某天上午巡检脚本报警,说information_schema.processlist里有超过 20 个执行时间大于 10 秒的会话。手动上去查:

mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} -e "SELECT id, user, host, db, command, time, state, LEFT(info, 80) FROM information_schema.processlist WHERE command != 'Sleep' ORDER BY time DESC LIMIT 20;"

发现大量会话卡在Waiting for table metadata lock状态。这个状态的意思是有会话持有表的元数据锁,其他会话在排队等锁。常见触发原因是某个长事务或者没提交的 DDL。

我用另一条 SQL 查是否存在长时间未结束的事务:

mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} -e "SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started ASC LIMIT 10;"

定位到一个跑了四个小时的事务,对应的线程 id 是12345。再回到 processlist 里看这个线程,发现是一条慢到离谱的 SELECT,是业务侧一个没加索引的全表扫描。

处理方式分两步:先 kill 掉这个阻塞源,再给相关查询补索引。kill 操作用脚本执行非常简单:

# kill_all_blocking_sessions.sh mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} --batch --skip-column-names -e " SELECT CONCAT('KILL ', id, ';') FROM information_schema.processlist WHERE command='Query' AND time > 300 AND info IS NOT NULL;" | while read kill_stmt; do echo "执行: ${kill_stmt}" mysql -h${DB_HOST} -u${DB_USER} -p${DB_PASSWORD} -e "${kill_stmt}" done

这个脚本的逻辑是:先把所有执行超过 300 秒的查询拼成KILL id;语句,然后一行一行执行。注意它生成的KILL语句本身就是 SQL,再用 mysql 去执行,属于"用 SQL 生成 SQL"的经典思路。

这次事故之后,我反思总结出三条经验,写在最后供你参考:

第一,杀会话前务必确认影响。这个脚本一次杀所有超过 300 秒的查询,在生产环境可能过于激进,建议先输出会话列表人工确认,再决定是否执行第二段 kill 逻辑。

第二,timeout变量的单位是秒,阈值设置要看业务特征。数据分析团队跑批任务经常超过 300 秒,如果一刀切杀掉,可能把正常的批任务误杀。我在生产环境的做法是另外维护一个白名单,排除已知的长任务。

第三,信息收集优先于干预。任何自动化脚本在对 MySQL 做变更前,都要先做一遍只读探测并输出日志。这个原则适用于所有运维脚本。

如果你打算在自己的环境里落地这套脚本,我还有个建议:先搭一套最小可用的版本,管理好一台实例的备份、巡检和批量变更,再考虑扩展到多实例。因为脚本这东西,复杂度是随实例数量非线性增长的——多一台实例,就多一类网络超时、认证失败、版本差异的可能,这些只有跑起来才会遇到,静态推演很难穷尽。慢慢来,把第一台跑稳了,后面就是复制粘贴的事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进