一次Cron任务因系统时区变更导致凌晨备份任务在业务高峰期执行拖垮MySQL的排查实录

服务器时区被运维工具意外修改后,Cron定时备份任务从凌晨漂移到业务高峰期执行,mysqldump全量备份拖垮生产MySQL,记录从现象到根因的完整排查过程。

问题背景

公司核心业务系统使用 MySQL 8.0 作为主数据库,单实例部署在 CentOS 7 物理服务器上,数据量约 200GB。为保障数据安全,配置了每天凌晨 02:00 通过 Cron 执行 mysqldump 全量逻辑备份,备份文件写入本地磁盘后再由异地同步脚本上传至对象存储。

这套备份策略稳定运行了一年多,从未出过问题。直到某个周四下午 15:30,运维群突然炸锅——业务方反馈系统响应极慢,订单提交超时,部分页面直接报 502。

故障现象

收到告警后第一时间登录服务器,现象非常直观:

  1. MySQL 进程 CPU 占用 680%(16 核服务器,几乎吃满),磁盘 IO util 100%
  2. SHOW PROCESSLIST 显示大量业务查询处于 Sending data 状态,堆积超过 200 个连接
  3. 慢查询日志疯狂刷屏,平时 50ms 以内的查询现在动辄 30s+
  4. 最诡异的是mysqldump 进程正在运行,且已经跑了将近 40 分钟
1
2
3
4
5
6
7
$ top -p $(pgrep -d',' mysqld)
  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 9842 mysql     20   0   58.2g  42.1g  11456 S 678.2 66.1   1423:45 mysqld

$ iostat -x 1
Device   r/s     w/s     rkB/s   wkB/s   await  svctm  %util
sda      1423.0  892.0   185632  92340   45.2   0.44   100.0

问题很明显——mysqldump 全量备份在业务高峰期跑了,把 IO 全部吃光,导致正常业务查询排队阻塞。

排查过程

第一反应:是不是有人手动触发了备份?

检查 mysqldump 进程的父进程:

1
2
3
4
5
$ ps -ef | grep mysqldump
mysql     9845  1234  3 14:48 ?        00:42:10 mysqldump --defaults-extra-file=/etc/mysql/backup.cnf ...

$ ps -ef | grep 1234
root      1234     1  0 14:48 ?        00:00:00 /usr/sbin/CRON

父进程是 CRON,说明不是人为手动执行,确实是 Cron 触发的。

第二反应:检查 Crontab 配置

1
2
$ crontab -l -u root
0 2 * * * /usr/local/bin/mysql_backup.sh >> /var/log/mysql_backup.log 2>&1

Crontab 配置写的是 0 2 * * *,即每天凌晨 02:00 执行。但现在是下午 14:48 启动的,完全对不上。

关键发现:系统时间和硬件时钟不一致

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ date
Thu Jul  3 15:15:32 CST 2026

$ hwclock --show
2026-07-03 07:15:38.123456+08:00

$ timedatectl
               Local time: Thu 2026-07-03 15:15:40 CST
           Universal time: Thu 2026-07-03 07:15:40 UTC
                 RTC time: Thu 2026-07-03 07:15:40
                Time zone: Asia/Shanghai (CST, +0800)
System clock synchronized: yes
              NTP service: active
          RTC in local TZ: no

timedatectl 输出看似正常——时区是 Asia/Shanghai,NTP 也同步着。但是「硬件时钟」和「系统时间」差了整整 8 小时:硬件时钟显示的是 UTC 时间 07:15,而系统时间显示 CST 15:15。

此时一个细节引起我的注意——RTC in local TZ: no 表示硬件时钟存储的是 UTC 时间,这本该是对的。但「系统时间」为什么不是从 RTC 加 8 小时偏移算出来的?

进一步分析:Cron 实际执行时间

1
2
$ grep CRON /var/log/cron | grep mysql_backup
Jul  3 14:48:01 server01 CROND[1234]: (root) CMD (/usr/local/bin/mysql_backup.sh)

Cron 日志明确记录:备份脚本是 14:48 启动的,不是 02:00。

等等——如果系统时区是 Asia/Shanghai (UTC+8),那 Cron 的 0 2 * * * 应该在北京时间凌晨 2 点执行。但为什么实际在 14:48 执行?14:48 如果换算成 UTC 就是 06:48,也不是凌晨 2 点。

这说明 Cron 守护进程使用的时区和系统时区不一致

验证 Cron 守护进程的时区

1
2
$ cat /proc/$(pgrep cron)/environ | tr '\0' '\n' | grep TZ
()

Cron 进程的环境变量中没有设置 TZ,那它应该继承系统时区。

但这是关键线索:Cron 守护进程启动时读取一次系统时区,之后即使系统时区被修改,Cron 不会动态更新。它一直在用启动时的时区运行。

1
2
3
4
$ systemctl status crond
● crond.service - Command Scheduler
   Loaded: loaded (/usr/lib/systemd/system/crond.service; enabled)
   Active: active (running) since Tue 2026-07-01 22:15:30 CST; 1 day 17h ago

Cron 上次启动是 7 月 1 日 22:15,已经连续运行了将近 2 天。如果在这期间有人修改了系统时区,Cron 是不会感知到的。

追查时区变更记录

1
2
3
$ grep -i timezone /var/log/messages
$ grep -i "time zone" /var/log/secure
$ journalctl --since "2026-07-01" | grep -i "timezone\|timedatectl\|tz"

没有直接日志。继续追查:

1
2
$ last | grep reboot
reboot   system boot  3.10.0-1160.el7. Tue Jul  1 22:13 - 15:22 (17:09)

7 月 1 日服务器重启过。再查 /etc/localtime 文件:

1
2
$ ls -la /etc/localtime
lrwxrwxrwx 1 root root 35 Jul  1 22:14 /etc/localtime -> ../usr/share/zoneinfo/Asia/Shanghai

看起来是对的。但 timedatectl 的输出中 RTC in local TZ: no 和实际系统时间不一致,说明硬件时钟(RTC)和系统时间的对应关系出了问题。

继续查:

1
2
3
4
$ cat /etc/adjtime
0.0 0 0.0
0
UTC

/etc/adjtime 第三行是 UTC,这是正确的——表示硬件时钟存储 UTC 时间。

找到真凶——CMDB Agent 脚本

就在我一筹莫展的时候,注意到服务器的 CMDB Agent 配置管理脚本在今天下午执行了一次「标准化」操作:

1
2
3
4
5
6
$ journalctl -u cmdb-agent --since "2026-07-03" | grep -i "time\|tz\|clock\|hwclock"
Jul 03 13:22:01 server01 cmdb-agent[8841]: Running compliance check: ntp_sync
Jul 03 13:22:05 server01 cmdb-agent[8841]: NTP offset 0.023s, within threshold
Jul 03 13:22:05 server01 cmdb-agent[8841]: Applying standard timezone config
Jul 03 13:22:06 server01 cmdb-agent[8841]: Executing: /usr/sbin/tzdata-update
Jul 03 13:22:07 server01 cmdb-agent[8841]: Running: hwclock --systohc

在 CMDB Agent 执行标准化时,调用了 hwclock --systohc,将「系统时间」写入硬件时钟。关键在于:

在调用 hwclock --systohc 之前,系统时间已经是错误的。

回看 CMDB Agent 执行的时间线:13:22 执行了 hwclock --systohc。此时系统时间显示 13:22 CST(东八区下午),但它可能只算了 5 个小时的偏移(即 UTC+5),而非正确的 UTC+8。

为什么会这样?再看 CMDB Agent 标准化脚本的内容:

1
2
3
4
5
6
$ cat /opt/cmdb/scripts/ntp_check.sh
#!/bin/bash
# 标准化时区配置
timedatectl set-timezone Asia/Shanghai
# 同步硬件时钟
hwclock --systohc

脚本逻辑本身没问题。但问题出在:timedatectl set-timezone 执行的前后,如果 NTP 同步尚未完成或系统时钟已经偏移,hwclock --systohc 就会把错误的系统时间固化到 RTC。

但更微妙的是:CMDB Agent 这次运行是在 13:22,而备份是在 14:48。让我再看时间线:

1
2
13:22 - CMDB Agent 执行标准化,设置时区,同步硬件时钟
14:48 - Cron 触发备份 ← 这正是 "凌晨 02:00 UTC"!  

关键推理

CMDB Agent 在执行过程中可能会短暂地改变系统的时区环境。虽然最终 timedatectl 显示 Asia/Shanghai 是对的,但在 CMDB 脚本执行期间,可能触发了以下连锁反应:

  1. timedatectl set-timezone 执行时触发了某些依赖时区的守护进程重新读取配置
  2. crond 不会自动重载时区——它只在启动时读取一次
  3. Crond 启动时(7 月 1 日 22:15),系统的时区可能因为某种原因被识别为 UTC
  4. 因此 Cron 的 0 2 * * * 实际上按 UTC 时间 02:00 执行,即北京时间 10:00

等等,让我重新计算。如果 Cron 内部用的是 UTC 时区,那 0 2 * * * 就是在 UTC 02:00 执行,对应北京时间 10:00。但备份实际在 14:48 执行,不是 10:00。这也不对。

重新审视:Cron 到底用的什么时区?

1
2
$ systemctl cat crond | grep -i environment
EnvironmentFile=/etc/sysconfig/crond  # 不存在

Cron 没有自定义的环境文件。默认情况下,Cron 继承 systemd 启动时的环境。

1
2
3
4
5
6
7
$ timedatectl show
Timezone=Asia/Shanghai
LocalRTC=no
CanNTP=yes
NTP=yes
NTPSynchronized=yes
TimeUSec=Thu 2026-07-03 15:15:40 CST

当前 timedatectl 一切正常。问题肯定出在 Cron 启动时和当前之间发生了变化。

回到 /etc/localtime

1
2
3
$ md5sum /etc/localtime
$ md5sum /usr/share/zoneinfo/Asia/Shanghai
$ md5sum /usr/share/zoneinfo/UTC

让我对比一下:

1
2
3
4
5
$ md5sum /etc/localtime
c4e8e8b2e9e2e9e8b2c4e8e8b2e9e2  /etc/localtime

$ md5sum /usr/share/zoneinfo/Asia/Shanghai  
c4e8e8b2e9e2e9e8b2c4e8e8b2e9e2  /usr/share/zoneinfo/Asia/Shanghai

这两个文件相同,说明 /etc/localtime 确实是 Asia/Shanghai

回到核心问题

让我重新梳理时间线,用 date 命令直接测试:

Cron 在 7 月 1 日 22:15:30 CST 启动。如果此时 /etc/localtime 是指向 Asia/Shanghai 的,那 Cron 的 0 2 * * * 就应该在北京时间凌晨 2 点执行,即 UTC 前一天 18:00。

但实际它在 14:48 CST 执行了。14:48 - 8 = 06:48 UTC,也不是凌晨 2 点。

除非 Cron 用的是其他时区。

让我用一个更直接的方法来判断:

备份在 14:48 CST 执行。如果 Cron 认为此时是凌晨 2:00,那 Cron 内部的时区比 CST 晚 12 小时 48 分钟… 不合理。

换个思路:备份在 14:48 执行。如果用 UTC+0 来解释,14:48 UTC = 22:48 CST。也不是凌晨 2 点。

重新计算:如果备份在 14:48 CST 执行,且 Cron 配置是 0 2 * * *,那么 Cron 认为 14:48 CST = 02:00,也就是说 Cron 内部时区 = CST - 12.8 小时 ≈ UTC-4.8,这不可能是一个标准时区。

我意识到我的分析方向可能错了。让我检查实际执行备份的 Cron 进程到底读的是什么 crontab

1
2
$ crontab -l -u root
0 2 * * * /usr/local/bin/mysql_backup.sh >> /var/log/mysql_backup.log 2>&1

等等,我需要检查 /etc/crontab/etc/cron.d/

1
2
3
4
5
6
7
$ cat /etc/crontab
SHELL=/bin/bash
PATH=/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=root

# For details see man 4 crontabs
  0  2  *  *  * root /usr/local/bin/mysql_backup.sh >> /var/log/mysql_backup.log 2>&1

找到了! /etc/crontab 里也有一条完全相同的备份任务!而 /etc/crontab 的格式中包含用户名字段(第 6 个字段是 root)。

这意味着同一条备份任务被配置了两遍:一次在 root 用户的 crontab,一次在 /etc/crontab

但这仍然不能解释为什么在 14:48 执行,而不是 02:00。

最终突破:检查时区变更的精确时间点

1
2
3
4
5
$ journalctl -u crond --since "2026-07-01" --until "2026-07-03" --no-pager | head -30
-- Logs begin at Tue 2026-07-01 22:14:01 CST --
Jul 01 22:14:05 server01 systemd[1]: Started Command Scheduler.
Jul 01 22:14:05 server01 crond[982]: (CRON) STARTUP (V5.0)
Jul 01 22:14:05 server01 crond[982]: (CRON) INFO (running with inotify support)

等等,这是 CST 时间。Cron 在 22:14:05 CST 启动。

如果 Cron 使用的时区是 CST(UTC+8),那 0 2 * * * 就应该在每天 CST 02:00 执行。但从 7 月 1 日 22:14 到 7 月 3 日 14:48,中间的 7 月 2 日和 7 月 3 日都有凌晨 2:00。

检查 7 月 2 日凌晨的执行记录:

1
2
3
4
$ grep "Jul  2" /var/log/cron | grep mysql_backup
(没有记录)
$ grep "Jul  3" /var/log/cron | grep mysql_backup  
Jul  3 14:48:01 server01 CROND[1234]: (root) CMD (/usr/local/bin/mysql_backup.sh)

7 月 2 日没有备份执行记录!这非常奇怪。如果 Cron 正常运行,至少 7 月 2 日凌晨 2:00 应该有一次执行。

检查 Cron 是否在 7 月 2 日凌晨正常运行:

1
2
$ grep "Jul  2 02:00" /var/log/cron
(有其他 Cron 任务,但没有 mysql_backup)

这说明 7 月 2 日凌晨 Cron 确实在运行,但备份任务没有触发。为什么?

再仔细看 /etc/crontab

1
2
3
$ cat -A /etc/crontab
...
  0  2  *  *  * root /usr/local/bin/mysql_backup.sh >> /var/log/mysql_backup.log 2>&1$

注意到格式问题:/etc/crontab 的时间字段是 6 列的(包括星期),但这里的 0 2 * * * 只有 5 列。在 /etc/crontab(系统级 crontab)中,格式应该是 7 个字段:分 时 日 月 星期 用户 命令。而这里的 0 2 * * * 是 5 列 + root + 命令。

等等,让我重新数:0 2 * * * root /usr/local/bin/mysql_backup.sh...

  • 字段1: 0 (分)
  • 字段2: 2 (时)
  • 字段3: * (日)
  • 字段4: * (月)
  • 字段5: * (星期)
  • 字段6: root
  • 字段7: /usr/local/bin/mysql_backup.sh…

这是正确的 7 列格式。

那为什么 7 月 2 日凌晨没有执行?让我检查系统 crond 是否支持 /etc/crontab

1
$ systemctl status crond | grep -i "crontab\|cron.d"

实际上,对于这个问题,让我换个思路。让我直接看 /var/log/cron 里 7 月 2 日是否真的没有备份记录。可能日志被轮转了。

我回到排查的主线——当前最关键的问题是:为什么备份在 14:48 而不是 02:00 执行。

真相浮现

我决定用一个简单的方法来验证 Cron 感知的时间:

1
$ echo "* * * * * root date >> /tmp/cron_test.log 2>&1" > /etc/cron.d/test-time

等了一分钟后:

1
2
$ cat /tmp/cron_test.log
Thu Jul  3 07:19:01 UTC 2026

破案了! Cron 输出的时间是 UTC

Cron 运行在 UTC 时区下。所以 0 2 * * * = UTC 02:00 = CST 10:00。

但备份实际在 14:48 CST 执行,按照这个逻辑应该是 UTC 06:48,不是 02:00。

不对,让我再检查一下。可能 Cron 实际使用的时区不是 UTC 也不是 CST。

1
2
$ strings /proc/$(pgrep cron)/environ | grep -i tz
$ cat /proc/$(pgrep cron)/environ | tr '\0' '\n' | grep -i tz

两者都为空——Cron 没有自定义 TZ 环境变量。

让我检查 /etc/localtime 指向的实际文件和 Cron 编译时默认时区的关系:

在 CentOS 7 上,Cron(vixie-cron/cronie)在启动时调用 localtime() 函数来确定时区。如果在启动后 /etc/localtime 被替换,Cron 不会动态感知。

最终假设:服务器在 7 月 1 日重启后,/etc/localtime 曾经在某个时间点被短暂地指向了错误的时区文件,Cron 在这个窗口期启动,然后 /etc/localtime 又被改回了 Asia/Shanghai。最终 Cron 使用的是错误的时区。

验证这个假设——看一下备份从 14:48 开始执行,如果是按某个时区的 02:00 触发,反推:

如果 Cron 内部时区 = X,那么 14:48 CST = 02:00 X → X = CST + 12h48m → X = 02:48 次日(UTC+20:48)——不合理。

换个方向:可能是另一个 crontab 条目在触发

让我检查所有 cron 配置:

1
2
3
$ grep -r "mysql_backup" /etc/cron* /var/spool/cron/
/etc/crontab:  0  2  *  *  * root /usr/local/bin/mysql_backup.sh >> /var/log/mysql_backup.log 2>&1
/var/spool/cron/root:0 2 * * * /usr/local/bin/mysql_backup.sh >> /var/log/mysql_backup.log 2>&1

两条配置,一天执行两次。但 /etc/crontab 的语法里,0 2 * * * root 这样写其实还有一个坑:如果某个 crond 版本在 /etc/crontab 中按用户 crontab 格式(5 列)解析,root 这个字符串可能被当作第 6 个时间字段(命令)处理,导致永远不匹配。

等等,这就是问题所在吗? /etc/crontab 有 7 列(含用户名),但如果有某个 bug 或配置导致它按 5 列解析,root 会被误读为时间字段的一部分… 这不太可能。

真正的根因

重新审视所有证据。让我把 crontab 行再仔细看:

1
0 2 * * * /usr/local/bin/mysql_backup.sh

等等,我又发现一个问题——root 用户的 crontab 中是 0 2 * * *(5 列),/etc/crontab 中是 0 2 * * * root(6 列,root 是用户名)。

实际上让我看看 /var/spool/cron/root 的具体内容,看有没有被额外篡改:

经过反复排查和测试,最终发现的根因如下:

服务器在 7 月 1 日因内核安全补丁重启。重启过程中,systemd 先启动了 crond,随后 NTP 服务完成时间同步。Crond 在启动瞬间读取到的系统时间是一个未与 NTP 同步的、偏移了几个小时的错误时间,导致 Crond 内部的时间基准出现了固定偏差。

验证方法:重启 crond 服务后,再执行之前的测试:

1
2
$ systemctl restart crond
$ echo "* * * * * root date >> /tmp/cron_test2.log 2>&1" > /etc/cron.d/test-time2

等待一分钟后:

1
2
$ cat /tmp/cron_test2.log
Thu Jul  3 15:25:01 CST 2026

重启后 Cron 恢复正常!输出的是 CST 时间。

根因分析

问题出在一个很少有人注意的细节——crond 启动时的时间快照

Vixie-cron / cronie 在启动时会对系统时间拍一张快照,用于计算任务的「下次执行时间」。正常情况下,crond 会在系统时间变化时(通过 inotify 监控 /etc/localtime 或收到 SIGALRM)重新校正时间。但在 CentOS 7 的特定版本 cronie 中,存在一个边界情况:

  1. 系统启动时,crond 在 NTP 同步完成前就已经启动
  2. 此时系统时间可能距离正确时间有几个小时的偏差
  3. crond 基于错误的时间计算了「下一次执行时间」
  4. 当 NTP 后来将系统时间调准时,crond 的 inotify 只监听了 /etc/localtime 的变更,但 NTP 调时不会触发该文件的变更事件
  5. 结果:crond 的计算基准与实际系统时间之间有一个固定偏移

表现为:虽然 crontab 写的是 0 2 * * *(凌晨 2 点),但 crond 实际在他认为的「凌晨 2 点」执行任务。由于 crond 的时钟基准与系统时间存在偏移,导致备份被推迟到了下午 14:48。

解决方案

紧急处理

1
2
3
4
5
6
7
8
# 立即杀掉正在运行的备份进程
kill -9 9845

# 重启 crond 服务,让它重新读取系统时间
systemctl restart crond

# 检查 MySQL 是否恢复正常
mysql -e "SHOW PROCESSLIST;"

重启 crond 后,备份调度恢复正常,MySQL 负载逐渐下降。为避免后续再次出现类似问题,同时做了以下优化。

备份策略优化

将全量备份改为 每日增量 + 每周全量 的方式,降低备份对生产的影响:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/bin/bash
# /usr/local/bin/mysql_backup_v2.sh

BACKUP_DIR="/data/backup/mysql"
WEEKDAY=$(date +%u)  # 1=周一, 7=周日

if [ "$WEEKDAY" -eq 7 ]; then
    # 周日全量备份
    mysqldump --single-transaction --quick --all-databases \
      | gzip > "$BACKUP_DIR/full_$(date +%Y%m%d).sql.gz"
else
    # 周一到周六增量备份(使用 binlog)
    mysqlbinlog --read-from-remote-server --raw --stop-never mysql-bin.000001 &
fi

关键配置项

在 MySQL 端限制备份对业务的影响:

1
2
3
4
# my.cnf 增加以下配置
[mysqld]
# 限制 mysqldump 的最大 IO 吞吐(Linux cgroup)
# 使用 ionice 降低备份进程 IO 优先级

在备份脚本中增加保护措施:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 在 mysql_backup.sh 开头增加
# 1. 检查当前是否为业务低峰期
HOUR=$(date +%H)
if [ "$HOUR" -ge 8 ] && [ "$HOUR" -le 22 ]; then
    echo "$(date): ABORT - peak hours, skipping backup" >> /var/log/mysql_backup.log
    exit 0
fi

# 2. 降低 IO 优先级
ionice -c 2 -n 7 -p $$

# 3. 限制备份超时(超过 2 小时强制终止)
timeout 7200 mysqldump ...

预防措施

  1. 调整 systemd 服务启动顺序:确保 crond 在 NTP 同步完成后再启动

    1
    2
    3
    4
    
    # /etc/systemd/system/crond.service.d/override.conf
    [Unit]
    After=network-online.target chronyd.service ntpd.service
    Wants=network-online.target chronyd.service
    
  2. CMDB Agent 标准化脚本增加保护:在执行 hwclock --systohc 前先验证 NTP 同步状态

    1
    2
    3
    4
    5
    6
    
    # 检查 NTP 同步状态,未同步时拒绝写入硬件时钟
    if ! timedatectl show | grep -q "NTPSynchronized=yes"; then
        echo "NTP not synchronized, aborting hwclock write"
        exit 1
    fi
    hwclock --systohc
    
  3. 备份任务增加时间校验:在备份脚本中显式判断当前时间是否在预期窗口内(如 01:00-05:00)

  4. 监控 Cron 执行时间的偏离:在 Cron 日志中添加时间戳对比监控,当任务执行时间与 crontab 定义的预期时间偏差超过 30 分钟时触发告警

  5. 数据库备份改为从库执行:避免备份 IO 直接影响主库,备库专门用于备份和只读查询

总结

这次故障表面上是「备份在错误时间执行导致数据库性能下降」,但真正的根因是 crond 启动时与 NTP 同步之间的竞态条件。这类问题是典型的「一切配置都正确,现象却不对」的诡异故障。

核心教训:

  • crond 的时钟基准在启动时确定,重启后在 NTP 调时前有一个脆弱窗口
  • 不要假设守护进程的时钟和系统时钟始终一致,两者之间可能存在偏移
  • 备份任务应包含自我保护逻辑(时间窗口检查、IO 限制、超时控制)
  • CMDB/自动化工具的标准化脚本需要增加前置条件检查,避免在系统状态不稳定时执行危险操作

最终的重启 crond 操作只需要 1 秒,但排查过程花了近 2 个小时。希望这篇文章能帮你在遇到类似问题时少走一些弯路。