RSS
菜单
全部文章快讯开发科技深度热点

第 68 章 定时任务与脚本踩坑

内容摘要

定时任务和运维脚本是 DBA 的日常工具,但它们也是事故高发区——多实例并发跑、静默失败、高峰跑大事务、密码明文……每个都能引出线上故障。本篇基于 MySQL 8.0 与 Linux crontab,梳理定时任务并发、幂等、失败告警、分批提交等 11 个高频坑。前置知识是存储过程与事件调度。学完后你应能写出"并发安全、失败可告警、可重复执行"的运维脚本。

📖 本篇简介:定时任务和运维脚本是 DBA 的日常工具,但它们也是事故高发区——多实例并发跑、静默失败、高峰跑大事务、密码明文……每个都能引出线上故障。本篇基于 MySQL 8.0 与 Linux crontab,梳理定时任务并发、幂等、失败告警、分批提交等 11 个高频坑。前置知识是存储过程与事件调度。学完后你应能写出"并发安全、失败可告警、可重复执行"的运维脚本。

目录

  • 坑 1:定时任务多实例并发重复执行
  • 坑 2:脚本失败静默无告警
  • 坑 3:定时大事务把表锁住
  • 坑 4:定时任务时间相互重叠
  • 坑 5:备份脚本在业务高峰跑
  • 坑 6:脚本里数据库密码明文
  • 坑 7:脚本不做幂等重复执行出错
  • 坑 8:crontab 环境变量缺失
  • 坑 9:任务日志无轮转吃满磁盘
  • 坑 10:大批量 update 漏了分批提交
  • 坑 11:定时任务依赖人工确认步骤
  • 运维脚本编写规范
  • 速查表:定时任务检查项
  • 自测练习

坑 1:定时任务多实例并发重复执行

场景 / 错误示范:

应用部署了 3 个实例,每个实例的定时任务都到点触发。同一个"清理过期订单"任务同时跑了 3 遍,重复处理、重复发通知,业务被骚扰。

# 错误:每个实例都跑定时任务,没有分布式锁
# 3 个实例 = 同一任务跑 3 次
@cron("0 2 * * *")
def clean_expired_orders():
    delete_expired_orders()

为什么是坑:多实例部署是常态,定时任务如果不加分布式锁,每个实例都会触发同一任务,造成重复执行。重复清理、重复发消息、重复扣减,轻则数据错乱,重则资损。定时任务的第一原则是"同一时刻全局只有一个实例在跑"。

正确做法:

用分布式锁保证全局单实例执行。

# 正确:用 Redis/数据库分布式锁,抢到锁的实例才执行
@cron("0 2 * * *")
def clean_expired_orders():
    lock = redis.lock("lock:clean_orders", timeout=3600)
    if not lock.acquire(blocking=False):
        return   # 没抢到锁,说明别的实例在跑,直接退出
    try:
        delete_expired_orders()
    finally:
        lock.release()

加分点:数据库侧也可以用 GET_LOCK() 做分布式锁,效果类似,无需额外中间件。


坑 2:脚本失败静默无告警

场景 / 错误示范:

备份脚本某天因为磁盘满失败了,但脚本只是把错误写进日志文件,没人看。一周后要恢复数据才发现备份早就没成功过。

# 错误:脚本失败只写日志,不上报
mysqldump -u root -p$PWD db > /backup/db.sql
# 失败了?错误写进 nohup.out,没人知道

为什么是坑:脚本失败如果不主动告警,就只能靠"下次需要时才发现"。备份、清理这类脚本一旦失败,短期不发作,等真要用的时候(恢复数据、磁盘清理)才发现早就坏了,损失已经造成。脚本的每个失败路径都必须有告警。

正确做法:

脚本退出码 + 失败告警。

# 正确:脚本失败立即告警(本示例为通用脚本)
mysqldump -u root -p"$PWD" db > /backup/db.sql
if [ $? -ne 0 ]; then
    # 失败:立即通知值班
    curl -X POST "$ALERT_WEBHOOK" -d "备份失败: db"
    exit 1
fi
-- 备份后还要校验备份文件是否有效(本示例基于 MySQL 8.0)
-- 空备份/损坏备份比没备份更危险,必须 checksum 校验

加分点:备份脚本除了告警,还要定期做"恢复演练",确认备份真的能用。


坑 3:定时大事务把表锁住

场景 / 错误示范:

凌晨定时任务跑 DELETE FROM log WHERE create_time < '2026-01-01',一次删几千万行。这个大事务持锁时间长,业务高峰前还没跑完,把日志表锁住,正常业务写入超时。

-- 错误:一次性删几千万行,大事务长持锁
-- 本示例基于 MySQL 8.0
DELETE FROM t_log WHERE create_time < '2026-01-01';   -- 几千万行,长事务

为什么是坑:大批量 DELETE/UPDATE 在一个事务里,会长时间持有行锁甚至间隙锁,阻塞业务对这些行的访问。一次性删几千万行,事务巨大、undo 膨胀、锁持有时间长,从"定时清理"变成"业务事故"。大批量写必须分批小事务,每批提交一次。

正确做法:

分批小批量处理,每批提交。

-- 正确:分批删除,每批 1000 行,小事务快速提交
-- 本示例基于 MySQL 8.0
DELETE FROM t_log WHERE create_time < '2026-01-01' LIMIT 1000;
-- 重复执行直到 affected rows = 0,每次小事务持锁时间短
# 脚本循环分批,每批之间 sleep 一下给业务让路
while True:
    cur.execute("DELETE FROM t_log WHERE create_time < %s LIMIT 1000", (cutoff,))
    conn.commit()
    if cur.rowcount == 0:
        break
    time.sleep(0.1)   # 小睡,降低对业务的冲击

加分点:清理历史数据优先用分区 DROP,比分批 delete 快且不持锁。


坑 4:定时任务时间相互重叠

场景 / 错误示范:

清理任务设成每天 2:00,备份任务设成每天 2:30。结果清理任务跑了 2 小时还没完,备份任务 2:30 又启动,两个任务同时压数据库,IO 和 CPU 飙高。

# 错误:多个定时任务时间撞车,相互重叠
0 2 * * *  /opt/clean.sh     # 跑 2 小时
30 2 * * * /opt/backup.sh    # 清理还没完,备份又启动

为什么是坑:定时任务各自独立触发,不感知彼此是否在跑。两个重任务时间重叠,会互相争抢资源,把数据库压垮。排定时任务时要预估每个任务耗时,错峰安排,而不是都堆在凌晨。

正确做法:

错峰安排任务,长任务之间留间隔。

# 正确:错峰,给每个任务留足完成时间
0 1 * * *  /opt/backup.sh     # 备份 1 点跑,约 1 小时
0 4 * * *  /opt/clean.sh      # 清理 4 点跑,避开备份
# 两个重任务之间留足间隔,不重叠

加分点:监控里加一条"当前正在跑的定时任务",重叠时能立刻看到。


坑 5:备份脚本在业务高峰跑

场景 / 错误示范:

mysqldump 备份脚本设在每天上午 10 点(业务高峰)。备份产生一致性读和大量 IO,正好撞上业务高峰,数据库负载翻倍,用户变慢。

# 错误:备份跑在业务高峰
0 10 * * * mysqldump --single-transaction db > /backup.sql

为什么是坑:备份(尤其逻辑备份)要读全库、产生一致性视图和大量 IO,对数据库有明显负载。在业务高峰跑备份,等于在最忙的时候再压一份负载,必然影响用户体验。备份必须放在业务低峰(深夜)。

正确做法:

备份放在业务低峰,并用对库影响小的方式。

# 正确:备份放在凌晨低峰(本示例为 mysqldump)
0 3 * * * mysqldump --single-transaction --quick db > /backup.sql
# --single-transaction 保证 InnoDB 一致性且不锁表

加分点:更大的库用物理备份(xtrabackup)或从库备份,把备份负载从主库卸掉。


坑 6:脚本里数据库密码明文

场景 / 错误示范:

脚本里直接写 mysql -u root -pMyPass123,密码明文躺在脚本文件里。脚本被提交到代码库,谁都能看到生产密码。

# 错误:密码明文写在脚本里
mysql -u root -pMyPass123 -e "SELECT 1"

为什么是坑:密码明文写在脚本里,脚本一旦被提交到代码库、被拷给别人、或日志里打出来,密码就泄露。生产数据库密码是核心机密,不能硬编码在脚本里。

正确做法:

密码放配置文件/环境变量,脚本只引用。

# 正确:密码放在 ~/.my.cnf(权限 600),脚本不写明文
# ~/.my.cnf:
# [client]
# user=root
# password=MyPass123
mysql --defaults-file=~/.my.cnf -e "SELECT 1"
# 或从环境变量读取,不写死
import os
conn = pymysql.connect(host=..., user=os.environ["DB_USER"], password=os.environ["DB_PWD"])

加分点:.my.cnf 权限必须设成 600,只有属主能读。


坑 7:脚本不做幂等重复执行出错

场景 / 错误示范:

补数据脚本跑了一半失败了,运维直接重跑。结果脚本里的插入没有幂等校验,已经处理过的数据又被处理一遍,重复插入。

-- 错误:脚本不幂等,重跑会重复插入
INSERT INTO t_reconcile (order_id, amount) VALUES (1001, 99.0);
-- 重跑一次,又插一条 order_id=1001,重复

为什么是坑:运维脚本经常要"失败重跑"。如果脚本不幂等(重复执行结果一致),重跑就会造成重复插入、重复扣减。补数据、对账、迁移类脚本尤其必须幂等——设计时就要考虑"跑一半失败了,重头跑会不会出问题"。

正确做法:

用唯一键/INSERT ... ON DUPLICATE KEY 保证幂等。

-- 正确:幂等写法,重跑不会重复(本示例基于 MySQL 8.0)
INSERT INTO t_reconcile (order_id, amount) VALUES (1001, 99.0)
AS new
ON DUPLICATE KEY UPDATE amount = new.amount;
-- order_id 有唯一键,重跑变成更新而不是再插一条

加分点:脚本设计时就问自己"这个脚本跑两遍会怎样",答案是"和跑一遍一样"才算合格。


坑 8:crontab 环境变量缺失

场景 / 错误示范:

脚本在命令行手动跑好好的,放进 crontab 就报"command not found"或连不上数据库。因为 crontab 的环境变量(PATH 等)和交互式 shell 不一样。

# 错误:依赖交互 shell 的环境变量,crontab 里没有
# crontab 执行时 PATH 很简洁,找不到 mysql、java 等命令
0 3 * * * /opt/backup.sh

为什么是坑:crontab 执行环境很干净——PATH 短、没有 .bashrc 里的别名和环境变量。脚本如果依赖这些(比如 java 的路径、JDBC 依赖),手动跑正常、定时跑就失败。这是"在我机器上好好的"定时任务版。

正确做法:

脚本里显式设置环境变量、用绝对路径。

# 正确:脚本开头显式 source 环境变量 + 绝对路径
#!/bin/bash
source /etc/profile
export PATH=/usr/local/bin:/usr/bin:/bin
export JAVA_HOME=/usr/local/jdk
# 用绝对路径调用命令
/usr/local/mysql/bin/mysqldump ...

加分点:crontab 里也可以写 PATH=... 声明,但更可靠的是脚本内部自给自足。


坑 9:任务日志无轮转吃满磁盘

场景 / 错误示范:

定时任务每天追加写日志到 task.log,没有轮转。半年后这个日志文件几个 G,加上其他任务日志,把磁盘吃满。

# 错误:日志只追加不轮转
0 3 * * * /opt/backup.sh >> /var/log/task.log 2>&1
# task.log 无限增长,吃磁盘

为什么是坑:定时任务日志默认只追加,没有大小和数量限制。长期累积,日志文件越来越大,和 binlog 一样吃磁盘。日志轮转(logrotate)是基本配置,忘了它,日志迟早变成磁盘杀手。

正确做法:

用 logrotate 轮转日志。

# 正确:配置 logrotate 按天/大小轮转,保留最近 N 份
# /etc/logrotate.d/task:
# /var/log/task.log {
#   daily
#   rotate 14        # 保留 14 天
#   compress         # 旧日志压缩
#   missingok
# }

加分点:日志既要看得到,又不能无限长,轮转策略两者兼顾。


坑 10:大批量 update 漏了分批提交

场景 / 错误示范:

补数据脚本里写了个批量 UPDATE,一次更新 500 万行,没分批没提交。事务巨大,undo 暴涨、锁持几个小时,业务被堵死。

-- 错误:一次 update 500 万行,不分批不提交
UPDATE t_user SET level=1 WHERE register_time < '2025-01-01';
-- 500 万行一个大事务,锁、undo、binlog 全爆

为什么是坑:和坑 3 同理,大批量写必须分批。很多人 delete 记得分批,update 却漏了。大批量 update 在一个事务里,同样会长时间持锁、撑大 undo、产生巨大 binlog。补数据脚本最容易犯这个错——想着"一次跑完省事",结果变成事故。

正确做法:

分批 update,每批提交。

-- 正确:分批更新,每批小事务(本示例基于 MySQL 8.0)
UPDATE t_user SET level=1
WHERE register_time < '2025-01-01'
LIMIT 1000;   -- 每批 1000 行,循环执行直到 affected rows=0
# 脚本循环分批,每批 commit
while True:
    cur.execute("UPDATE t_user SET level=1 WHERE register_time<%s LIMIT 1000", (cutoff,))
    conn.commit()
    if cur.rowcount == 0:
        break

加分点:大批量 DML 一律"分批 + 小事务 + 间隔",是运维脚本的铁律。


坑 11:定时任务依赖人工确认步骤

场景 / 错误示范:

自动化脚本里留了一步"等人工确认后再继续"。凌晨自动跑时没人确认,任务卡住,第二天才发现没执行。

# 错误:自动化脚本里夹了人工 input
do_step1()
input("请确认无误后按回车继续...")   # 自动跑时没人回车,卡住
do_step2()

为什么是坑:自动化定时任务的价值就是"无人值守"。脚本里如果夹了需要人工确认的步骤,自动跑时就卡死。要自动化就全自动,需要人工把关的环节应该放在脚本外的流程里,而不是卡在脚本中间。

正确做法:

全自动执行,人工把关放在事前审批和事后校验。

# 正确:脚本全自动,不夹人工确认
do_step1()
do_step2()
do_step3()
# 需要人工把关的:事前审批脚本内容,事后校验执行结果
# 而不是在脚本中间等人按回车

加分点:高风险脚本可以加"dry-run 模式"先预演,人工看预演结果后再真跑,而不是卡中间。


运维脚本编写规范

运维脚本看似简单,实则最容易出事。以下规范是无数次事故换来的。

| 规范项 | 做法 | 反面教材 |

|---|---|---|

| 并发安全 | 分布式锁保证单实例 | 多实例重复执行 |

| 失败告警 | 非零退出即告警 | 静默写日志没人看 |

| 幂等 | 重跑结果一致 | 重跑重复插入 |

| 大批量写 | 分批小事务 | 一次几百万行大事务 |

| 密码安全 | 配置文件/环境变量 | 明文写脚本 |

| 环境自给 | 脚本内绝对路径+source | 依赖交互 shell 环境 |

| 日志轮转 | logrotate | 日志无限增长吃盘 |

| 无人值守 | 全自动无人工卡点 | 中间等 input 卡死 |

核心认知:运维脚本是"无人值守自动跑"的,要假设它在凌晨没人看着的时候执行——所以失败必须告警、重复必须幂等、大批量必须分批、密码必须隐藏。你不盯着它的时候,它才最容易出事。


速查表:定时任务检查项

| 检查项 | 风险 | 正确做法 |

|---|---|---|

| 多实例并发 | 重复执行 | 分布式锁 |

| 失败处理 | 静默失败 | 退出码+告警 |

| 大批量写 | 锁表/undo 膨胀 | 分批 LIMIT+提交 |

| 执行时间 | 高峰跑/任务重叠 | 低峰错峰 |

| 幂等性 | 重跑出错 | 唯一键/ON DUPLICATE |

| 密码 | 泄露 | .my.cnf/环境变量 |

| 日志 | 吃磁盘 | logrotate |


自测练习

1. 多实例部署下定时任务为什么会重复执行?怎么解决?

<details>

<summary>参考答案</summary>

每个实例都会触发同一 cron,没有全局互斥。应引入分布式锁(Redis 或 GET_LOCK()),抢到锁的实例才执行,保证同一时刻全局只有一个实例在跑。

</details>

2. 大批量 DELETE/UPDATE 为什么要分批?

<details>

<summary>参考答案</summary>

一次性处理几百万行会形成巨大事务,长时间持锁阻塞业务、撑大 undo log。应 LIMIT 分批小事务,每批提交,必要时小睡给业务让路。历史清理优先用分区 DROP。

</details>

3. 脚本为什么要做幂等?

<details>

<summary>参考答案</summary>

运维脚本经常失败重跑,不幂等会导致重复插入/重复扣减。应靠唯一键 + ON DUPLICATE KEY UPDATE 等手段,保证跑两遍和跑一遍结果一致。

</details>

4. 脚本在 crontab 里跑不起来但手动跑正常,常见原因?

<details>

<summary>参考答案</summary>

crontab 环境干净,PATH 短、缺环境变量。应在脚本内 source /etc/profile、显式设 PATH、用命令绝对路径,让脚本自给自足。

</details>

5. 备份脚本最容易被忽视的风险是什么?

<details>

<summary>参考答案</summary>

失败静默——备份没成功但没人知道,等到要恢复才发现备份早就坏了。应失败立即告警,并定期做恢复演练确认备份可用,同时放在低峰执行避免影响业务。

</details>

下一篇预告:第 69 章 升级迁移踩坑

— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 68 章 定时任务与脚本踩坑》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。