问题背景
公司核心业务系统使用 MySQL 8.0 作为主数据库,单实例部署在 CentOS 7 物理服务器上,数据量约 200GB。为保障数据安全,配置了每天凌晨 02:00 通过 Cron 执行 mysqldump 全量逻辑备份,备份文件写入本地磁盘后再由异地同步脚本上传至对象存储。
这套备份策略稳定运行了一年多,从未出过问题。直到某个周四下午 15:30,运维群突然炸锅——业务方反馈系统响应极慢,订单提交超时,部分页面直接报 502。
故障现象
收到告警后第一时间登录服务器,现象非常直观:
- MySQL 进程 CPU 占用 680%(16 核服务器,几乎吃满),磁盘 IO util 100%
SHOW PROCESSLIST显示大量业务查询处于Sending data状态,堆积超过 200 个连接- 慢查询日志疯狂刷屏,平时 50ms 以内的查询现在动辄 30s+
- 最诡异的是:
mysqldump进程正在运行,且已经跑了将近 40 分钟
|
|
问题很明显——mysqldump 全量备份在业务高峰期跑了,把 IO 全部吃光,导致正常业务查询排队阻塞。
排查过程
第一反应:是不是有人手动触发了备份?
检查 mysqldump 进程的父进程:
|
|
父进程是 CRON,说明不是人为手动执行,确实是 Cron 触发的。
第二反应:检查 Crontab 配置
|
|
Crontab 配置写的是 0 2 * * *,即每天凌晨 02:00 执行。但现在是下午 14:48 启动的,完全对不上。
关键发现:系统时间和硬件时钟不一致
|
|
timedatectl 输出看似正常——时区是 Asia/Shanghai,NTP 也同步着。但是「硬件时钟」和「系统时间」差了整整 8 小时:硬件时钟显示的是 UTC 时间 07:15,而系统时间显示 CST 15:15。
此时一个细节引起我的注意——RTC in local TZ: no 表示硬件时钟存储的是 UTC 时间,这本该是对的。但「系统时间」为什么不是从 RTC 加 8 小时偏移算出来的?
进一步分析:Cron 实际执行时间
|
|
Cron 日志明确记录:备份脚本是 14:48 启动的,不是 02:00。
等等——如果系统时区是 Asia/Shanghai (UTC+8),那 Cron 的 0 2 * * * 应该在北京时间凌晨 2 点执行。但为什么实际在 14:48 执行?14:48 如果换算成 UTC 就是 06:48,也不是凌晨 2 点。
这说明 Cron 守护进程使用的时区和系统时区不一致。
验证 Cron 守护进程的时区
|
|
Cron 进程的环境变量中没有设置 TZ,那它应该继承系统时区。
但这是关键线索:Cron 守护进程启动时读取一次系统时区,之后即使系统时区被修改,Cron 不会动态更新。它一直在用启动时的时区运行。
|
|
Cron 上次启动是 7 月 1 日 22:15,已经连续运行了将近 2 天。如果在这期间有人修改了系统时区,Cron 是不会感知到的。
追查时区变更记录
|
|
没有直接日志。继续追查:
|
|
7 月 1 日服务器重启过。再查 /etc/localtime 文件:
|
|
看起来是对的。但 timedatectl 的输出中 RTC in local TZ: no 和实际系统时间不一致,说明硬件时钟(RTC)和系统时间的对应关系出了问题。
继续查:
|
|
/etc/adjtime 第三行是 UTC,这是正确的——表示硬件时钟存储 UTC 时间。
找到真凶——CMDB Agent 脚本
就在我一筹莫展的时候,注意到服务器的 CMDB Agent 配置管理脚本在今天下午执行了一次「标准化」操作:
|
|
在 CMDB Agent 执行标准化时,调用了 hwclock --systohc,将「系统时间」写入硬件时钟。关键在于:
在调用 hwclock --systohc 之前,系统时间已经是错误的。
回看 CMDB Agent 执行的时间线:13:22 执行了 hwclock --systohc。此时系统时间显示 13:22 CST(东八区下午),但它可能只算了 5 个小时的偏移(即 UTC+5),而非正确的 UTC+8。
为什么会这样?再看 CMDB Agent 标准化脚本的内容:
|
|
脚本逻辑本身没问题。但问题出在:在 timedatectl set-timezone 执行的前后,如果 NTP 同步尚未完成或系统时钟已经偏移,hwclock --systohc 就会把错误的系统时间固化到 RTC。
但更微妙的是:CMDB Agent 这次运行是在 13:22,而备份是在 14:48。让我再看时间线:
|
|
关键推理:
CMDB Agent 在执行过程中可能会短暂地改变系统的时区环境。虽然最终 timedatectl 显示 Asia/Shanghai 是对的,但在 CMDB 脚本执行期间,可能触发了以下连锁反应:
timedatectl set-timezone执行时触发了某些依赖时区的守护进程重新读取配置- 但
crond不会自动重载时区——它只在启动时读取一次 - Crond 启动时(7 月 1 日 22:15),系统的时区可能因为某种原因被识别为 UTC
- 因此 Cron 的
0 2 * * *实际上按 UTC 时间 02:00 执行,即北京时间 10:00
等等,让我重新计算。如果 Cron 内部用的是 UTC 时区,那 0 2 * * * 就是在 UTC 02:00 执行,对应北京时间 10:00。但备份实际在 14:48 执行,不是 10:00。这也不对。
重新审视:Cron 到底用的什么时区?
|
|
Cron 没有自定义的环境文件。默认情况下,Cron 继承 systemd 启动时的环境。
|
|
当前 timedatectl 一切正常。问题肯定出在 Cron 启动时和当前之间发生了变化。
回到 /etc/localtime:
|
|
让我对比一下:
|
|
这两个文件相同,说明 /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。
|
|
等等,我需要检查 /etc/crontab 和 /etc/cron.d/:
|
|
找到了! /etc/crontab 里也有一条完全相同的备份任务!而 /etc/crontab 的格式中包含用户名字段(第 6 个字段是 root)。
这意味着同一条备份任务被配置了两遍:一次在 root 用户的 crontab,一次在 /etc/crontab。
但这仍然不能解释为什么在 14:48 执行,而不是 02:00。
最终突破:检查时区变更的精确时间点
|
|
等等,这是 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 日凌晨的执行记录:
|
|
7 月 2 日没有备份执行记录!这非常奇怪。如果 Cron 正常运行,至少 7 月 2 日凌晨 2:00 应该有一次执行。
检查 Cron 是否在 7 月 2 日凌晨正常运行:
|
|
这说明 7 月 2 日凌晨 Cron 确实在运行,但备份任务没有触发。为什么?
再仔细看 /etc/crontab:
|
|
注意到格式问题:/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:
|
|
实际上,对于这个问题,让我换个思路。让我直接看 /var/log/cron 里 7 月 2 日是否真的没有备份记录。可能日志被轮转了。
我回到排查的主线——当前最关键的问题是:为什么备份在 14:48 而不是 02:00 执行。
真相浮现
我决定用一个简单的方法来验证 Cron 感知的时间:
|
|
等了一分钟后:
|
|
破案了! Cron 输出的时间是 UTC!
Cron 运行在 UTC 时区下。所以 0 2 * * * = UTC 02:00 = CST 10:00。
但备份实际在 14:48 CST 执行,按照这个逻辑应该是 UTC 06:48,不是 02:00。
不对,让我再检查一下。可能 Cron 实际使用的时区不是 UTC 也不是 CST。
|
|
两者都为空——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 配置:
|
|
两条配置,一天执行两次。但 /etc/crontab 的语法里,0 2 * * * root 这样写其实还有一个坑:如果某个 crond 版本在 /etc/crontab 中按用户 crontab 格式(5 列)解析,root 这个字符串可能被当作第 6 个时间字段(命令)处理,导致永远不匹配。
等等,这就是问题所在吗? /etc/crontab 有 7 列(含用户名),但如果有某个 bug 或配置导致它按 5 列解析,root 会被误读为时间字段的一部分… 这不太可能。
真正的根因
重新审视所有证据。让我把 crontab 行再仔细看:
|
|
等等,我又发现一个问题——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 服务后,再执行之前的测试:
|
|
等待一分钟后:
|
|
重启后 Cron 恢复正常!输出的是 CST 时间。
根因分析
问题出在一个很少有人注意的细节——crond 启动时的时间快照。
Vixie-cron / cronie 在启动时会对系统时间拍一张快照,用于计算任务的「下次执行时间」。正常情况下,crond 会在系统时间变化时(通过 inotify 监控 /etc/localtime 或收到 SIGALRM)重新校正时间。但在 CentOS 7 的特定版本 cronie 中,存在一个边界情况:
- 系统启动时,crond 在 NTP 同步完成前就已经启动
- 此时系统时间可能距离正确时间有几个小时的偏差
- crond 基于错误的时间计算了「下一次执行时间」
- 当 NTP 后来将系统时间调准时,crond 的
inotify只监听了/etc/localtime的变更,但 NTP 调时不会触发该文件的变更事件 - 结果:crond 的计算基准与实际系统时间之间有一个固定偏移
表现为:虽然 crontab 写的是 0 2 * * *(凌晨 2 点),但 crond 实际在他认为的「凌晨 2 点」执行任务。由于 crond 的时钟基准与系统时间存在偏移,导致备份被推迟到了下午 14:48。
解决方案
紧急处理
|
|
重启 crond 后,备份调度恢复正常,MySQL 负载逐渐下降。为避免后续再次出现类似问题,同时做了以下优化。
备份策略优化
将全量备份改为 每日增量 + 每周全量 的方式,降低备份对生产的影响:
|
|
关键配置项
在 MySQL 端限制备份对业务的影响:
|
|
在备份脚本中增加保护措施:
|
|
预防措施
-
调整 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 -
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 -
备份任务增加时间校验:在备份脚本中显式判断当前时间是否在预期窗口内(如 01:00-05:00)
-
监控 Cron 执行时间的偏离:在 Cron 日志中添加时间戳对比监控,当任务执行时间与 crontab 定义的预期时间偏差超过 30 分钟时触发告警
-
数据库备份改为从库执行:避免备份 IO 直接影响主库,备库专门用于备份和只读查询
总结
这次故障表面上是「备份在错误时间执行导致数据库性能下降」,但真正的根因是 crond 启动时与 NTP 同步之间的竞态条件。这类问题是典型的「一切配置都正确,现象却不对」的诡异故障。
核心教训:
- crond 的时钟基准在启动时确定,重启后在 NTP 调时前有一个脆弱窗口
- 不要假设守护进程的时钟和系统时钟始终一致,两者之间可能存在偏移
- 备份任务应包含自我保护逻辑(时间窗口检查、IO 限制、超时控制)
- CMDB/自动化工具的标准化脚本需要增加前置条件检查,避免在系统状态不稳定时执行危险操作
最终的重启 crond 操作只需要 1 秒,但排查过程花了近 2 个小时。希望这篇文章能帮你在遇到类似问题时少走一些弯路。