Prometheus监控误报MySQL重启:秒级误差如何骗过changes()告警
深夜两点十七分Grafana的告警横幅突然从屏幕边缘弹出来红色“MySQLRestart”在暗色监控大屏上格外刺眼。告警对象是db-master-01和db-master-02两个MySQL主从节点同时提示“实例疑似重启”。这是最让我紧张的一种告警数据库重启往往意味着连接池被打满、事务回滚、慢查询堆积接下来就是业务方一连串的“数据库是不是挂了”的追问。我立刻打开终端连上数据库准备面对一片狼藉的error log结果SHOW GLOBAL STATUS一敲Uptime显示三十多天进程启动时间纹丝不动。数据库没动那Prometheus到底在报警什么这一夜我花了将近三个小时才找到答案而真相就藏在一秒钟的“毫厘”之间。这个案例很适合写给正在搭建Prometheus监控、特别是用mysqld_exporter监控MySQL实例的运维和开发同学。你可能会遇到类似的怪事监控面板告诉你服务重启了但业务方咬死说没动过双方各执一词。其实很多时候不是数据库真的重启而是监控采集与数值计算链路里埋着一些极其隐蔽的细节。下面我把完整的排查过程、根因分析和修复方案整理出来希望帮你少走这个弯路。1. 告警之夜数据库“重启”的红色警报DBA现场却说一切正常1.1 告警详情与最初反应先说下当时的监控环境。这套体系是半年前从Zabbix迁到Prometheus的选了Prometheus 2.45 mysqld_exporter 0.15 Grafana 10的组合用Consul做服务发现scrape_interval设置的是30秒告警评估周期是1分钟。告警规则由两部分组成先通过mysqld_process_start_time_seconds拿到MySQL进程的启动时间戳然后用changes()函数判断这个值在5分钟内是否发生过变化一旦有变化就判定实例重启。告警规则大致长这样groups: - name: mysql-restart-alert rules: - alert: MySQLRestart expr: changes(mysqld_process_start_time_seconds[5m]) 0 for: 0m labels: severity: critical annotations: summary: MySQL instance {{ $labels.instance }} may have restarted这个规则本身看起来没毛病如果mysqld进程真的重启了它的启动时间戳一定会跳变changes()自然大于0。但问题恰恰出在“看起来没毛病”上面。当时凌晨的告警信息里instance标签指向的是db-master-01:9104和db-master-02:9104两个实例同时触发。我第一反应是主从集群出了问题可能是内存OOM导致mysqld被systemd拉起或者有人误操作执行了systemctl restart mysqld。如果是OOMerror log里一般会有痕迹如果是手动操作DBA那边应该有人知情。1.2 数据库侧验证Uptime与进程信息都正常我先连上db-master-01执行第一组命令SHOW GLOBAL STATUS LIKE Uptime; SHOW GLOBAL STATUS LIKE Uptime_since_flush_status; SHOW GLOBAL VARIABLES LIKE version;结果Uptime是2659201秒约等于30.76天Uptime_since_flush_status也很大说明最近没有人执行过FLUSH STATUS。如果mysqld进程真的在几分钟前重启过Uptime顶多几百秒不可能是一千多万。接着用系统命令确认进程的真实创建时间ps -o pid,lstart,etime,cmd -p $(pgrep -x mysqld)输出的lstart日期和几天前的部署记录完全吻合etime显示的存活时长也是30天左右。我又翻了/var/log/mysql/error.log最后几十行里面没有任何异常关闭、信号终止或InnoDB恢复的记录最后一条日志还是白天正常的慢查询警告。这一套组合拳打下来基本可以排除mysqld进程重启的可能。1.3 系统侧验证服务器没有重启有些时候数据库没重启但操作系统或虚拟机被云厂商热迁移了磁盘、网卡、内存的计数器发生变化也会让监控产生错觉。所以我顺手查了系统的启动时间who -b uptime -s cat /proc/uptimewho -b显示系统启动时间是两个月前/proc/uptime里第一个数字折算下来也超过60天。再看node_exporter暴露的指标curl -s http://127.0.0.1:9100/metrics | grep node_boot_time_secondsnode_boot_time_seconds的值和系统启动时间戳完全一致说明节点没有重启过。到这里数据库侧和操作系统侧全部正常。告警却还是坚挺地挂着问题一定出在Prometheus采集链路内部。2. 从数据库到服务器逐层“体检”一切正常矛盾指向采集链路2.1 顺着采集链路逐层检查我把排查视角从数据库本身移到Prometheus体系上。首先怀疑是不是mysqld_exporter这个进程重启过。在Prometheus自带的指标里exporter自身会暴露一个process_start_time_seconds{jobmysqld-exporter}它记录的是exporter进程自己的启动时间戳。如果这个值也变了说明是exporter容器或systemd单元被重启过导致采集断档、指标时间序列出现断层。查下来的结果反而让事情更有意思Prometheus自带的process_start_time_seconds没有变化说明exporter进程也是长期稳定运行。Prometheus实例本身更不用说了我们是有HA部署的另一套Prometheus的告警记录里也能看到同一个时间点触发了同样的MySQLRestart。接下来我做了几个关键试验。第一个试验直接绕过Prometheus手动抓一次exporter的原始输出看mysqld_process_start_time_seconds到底长什么样。curl -s http://127.0.0.1:9104/metrics | grep mysqld_process_start_time_seconds # HELP mysqld_process_start_time_seconds Unix time when the process was started # TYPE mysqld_process_start_time_seconds gauge mysqld_process_start_time_seconds{instancedb-master-01} 1.7773407994e09这个时间戳显示进程启动于2026年5月某个时刻。我记住这个值等30秒后再抓一次数值变成了1.7773407996e09。又过30秒再抓一次变成了1.7773407992e09。对你没看错它在原地来回跳而且毫无规律。一会儿0.2秒一会儿-0.4秒数值并不是单调递增或递减的而是在真实启动时间附近来回抖动。第二个试验把同一个实例在Prometheus里连续几个小时的历史数据拉出来看。用PromQL查询:mysqld_process_start_time_seconds{instancedb-master-01:9104}在Grafana的Explore页面里把step调成30秒你会发现这根线不是一条水平直线而是一条带毛刺的锯齿线毛刺幅度约1秒。这个视觉特征非常关键——真正的进程启动时间戳应该是恒定的水平线出现锯齿说明采集端在反复计算一个本来就固定的值而且每次计算的精度都不是精确的。2.2 复现“幽灵抖动”问题锁定在exporter指标再进一步我发现db-master-02也有同样的锯齿而且两个实例的抖动幅度完全相同都是±1秒级别。如果只是网络抖动或单机时间同步问题不太可能所有实例表现一致。这让我彻底相信一定有个系统性的计算误差在起作用。为了确认是不是mysqld_exporter版本特有的bug我查了这套exporter的版本和参数mysqld_exporter --version # mysqld_exporter, version 0.15.1另外我注意到当时我用的采集配置里启用了collect.global_status但没开启collect.info_schema.processlist等额外采集项整个exporter是相当干净的默认配置。0.15.1这个版本比较旧了后面文章里我会说升级exporter是否有效但当时的排查重点不在版本而在于它生成这个指标的计算逻辑。我同时也怀疑过Prometheus的staleness机制由于某些抓取失败Prometheus会不会用插值或外推重新计算了样本值但查了up{jobmysqld-exporter}之后发现整个时间窗口内所有抓取都是成功的up值始终为1抓取间隔也稳定在30秒不存在断点或重试。所以Prometheus存储层没有篡改数据是exporter给的原始值本身就是抖动的。到这里排查链路变得很清晰数据库没动、OS没动、exporter没被重启、Prometheus抓取正常唯一异常就集中在mysqld_process_start_time_seconds这个指标的值在不断跳动。接下来就是拆解这个指标为什么会产生抖动。3. 毫厘之间的真相整数秒的Uptime让“启动时间”像幽灵一样跳动3.1 exporter计算启动时间戳的方式mysqld_exporter暴露的mysqld_process_start_time_seconds并不是直接从MySQL内部拿到的“启动时间戳”。MySQL本身并没有这样一个变量直接告诉你“我是哪年哪月哪日启动的”它只维护了一个相对量从mysqld启动到现在已经经过了多少秒也就是SHOW GLOBAL STATUS里的Uptime变量。exporter的源码逻辑大概是这样的var ( uptime status[Uptime] // 字符串比如 2659201 now time.Now().Unix() // exporter所在主机的当前Unix秒 ) processStartTime now - uptime也就是说它拿exporter自己运行环境的当前时间戳减去MySQL的Uptime反推出一个所谓的“进程启动时间戳”。这个公式在理想情况下是正确的如果MySQL的Uptime精确到纳秒且exporter和mysqld共用同一套时钟那每次抓取计算出的start_time都会完全一致等于一个恒定的历史时间。但现实有两个破坏理想条件的因素。第一MySQL的Uptime变量精度只有整数秒。它的实现是每秒更新一次计数器返回的是一个整型秒数。类似于你家里电表只显示整数kWh不显示小数点后几位。Uptime是2659201秒可能实际是2659201.3秒但MySQL直接砍掉了小数部分。第二exporter每次抓取的时刻是不断变化的。30秒抓一次但不可能分毫不差地刚好在整秒边界上。第1次抓取发生在第0.3秒第2次发生在第0.7秒第3次发生在第0.1秒。因此time.Now().Unix()这个值本身也在整数秒边界上跳来跳去。这两个因素叠加起来就产生了下面这个行为模型。3.2 毫秒取整误差如何演变成“重启告警”假设真实启动时间是2026年5月1日 00:00:00.000。第一次抓取发生在当前时刻的0.4秒MySQL Uptime恰好是2659201整那边Uptime内部其实记录的是2659201.4秒但被截断成了2659201。于是第1次抓取 now 1780000000.4 uptime 2659201 start_time 1780000000.4 - 2659201 1777340799.4 误差0.4秒偏早第2次抓取发生在某个0.6秒的边界Uptime推进到2659231第2次抓取 now 1780000030.6 uptime 2659231 start_time 1780000030.6 - 2659231 1777340799.6 误差0.6秒偏早第3次抓取没踩准Uptime还是2659261但now变成了1780000060.2第3次抓取 now 1780000060.2 uptime 2659261 start_time 1780000060.2 - 2659261 1777340799.2 误差0.2秒偏早问题就出在这里。第2次计算出的start_time是1777340799.6第3次变成了1777340799.2两次之间不仅方向反转数值也差了0.4秒。从Prometheus的视角来看一个本应单调不变的“常量指标”在相邻样本之间发生了非零变化。而告警规则用的是changes(mysqld_process_start_time_seconds[5m]) 0changes()函数的语义是“区间内这个指标到底有没有变过”。哪怕它只变了0.000001秒只要浮点数不严格相等就返回1。于是这个0.4秒的幽灵抖动就堂而皇之地变成了一条critical级别告警。3.3 为什么另一个实例也有同样的现象db-master-02之所以同时告警原因完全相同所有采集链路都经过同样的exporter进程、同样的采集逻辑、同样的整数截断误差。主从节点虽然物理机不同但MySQL版本、exporter版本、scrape配置完全一致所以抖动模式高度相似。这不是个例而是一个系统性误差。我还特意验证了另一个冷门但重要的点如果MySQL实例恰好运行在容器里或者exporter与mysqld之间的时钟存在明显的NTP偏差这个误差会被放得更大。比如容器场景下宿主机时间和mysqld容器内时间存在几十毫秒偏差加上time.Now()取值来自exporter容器而非mysqld容器start_time的抖动范围可能从±1秒扩大到±数秒。后面文章里我会提到NTP对这类指标的影响。到这里“毫厘之间”的真相已经水落石出不是数据库重启不是exporter崩溃而是一个涉及整数取整、抓取时刻微小差异导致的秒级精度误差在changes()这个敏感的检测函数作用下被放大成了重启告警。4. 修复与加固让告警只对真实重启敏感4.1 告警规则的错误建模changes()不适合做重启判断问题定位之后修复思路就很清晰了。第一步就是把这条告警规则从“值变化检测”改成“真实重启建模”。用changes()这种对任何微小变化都一触即发的函数去检测一个本身就存在抖动误差的指标本身就是错误建模。真实重启会带来什么是启动时间戳发生几十天、几百天的巨大跳变而不仅仅是0.4秒的抖动。最简单可靠的方式是判断“进程启动时间是否在最近一段时间之内”。如果mysqld是真的刚重启那么time() - mysqld_process_start_time_seconds得到的结果应该很小比如小于300秒如果一切正常这个差值等于Uptime也就是几十万秒、几百万秒的量级。用阈值做判断天然免疫秒级抖动。改进后的告警规则groups: - name: mysql-restart-alert rules: - alert: MySQLRestart expr: time() - mysqld_process_start_time_seconds 300 for: 5m labels: severity: critical annotations: summary: MySQL instance {{ $labels.instance }} restarted recently description: process start time is {{ $value | humanizeDuration }} ago这里我加了for: 5m目的是给告警加一个持续条件防止瞬时抖动导致Pending直接变Firing。真实重启后启动时间戳会稳定停留在新值上5分钟内持续满足条件告警必然会触发而秒级抖动的场合time() - start_time这个值始终在几十万秒左右远达不到300秒的阈值根本不会误报。4.2 替换规则与组合指标验证如果团队里已经习惯了原来的changes()写法不想彻底改掉也可以保留原规则但加上更严格的持续时间。比如for: 10m。因为抖动虽然会不断造成数值变化但它不会让指标值稳定地停留在某个新值上持续10分钟而真实重启后start_time跳变到新值之后changes()在后续的10分钟内会持续判定为“值发生了变化”因为旧值区间的样本结束、新值样本进入区间大概率还是会触发告警。不过说实话我更推荐直接用上面那个阈值方案逻辑更贴近真实语义。同时我顺手加了一个“组合判定”的recording rule把判断可靠性进一步提高groups: - name: mysql-restart-detection rules: - record: instance:mysql_restart_evidence:sum expr: | ( time() - mysqld_process_start_time_seconds 300 ) on(instance) group_left ( node_boot_time_seconds time() - 300 )这里node_boot_time_seconds取自node_exporter用来确认服务器本身有没有重启。MySQL实例在节点上运行时如果节点重启两个条件都会满足如果只是mysqld进程重启只有第一个条件满足如果两个条件都不满足系统就是完全稳定的。有了这个组合证据告警才真正做到了多维度交叉验证。4.3 验证与告警收敛后的观察修改完告警规则之后我先把旧的changes()规则挂起观察了整整一个晚上。结果非常干净告警不再触发Grafana Alert List里MySQLRestart保持Pending状态直到消失。第二天白天业务高峰期又观察了6小时仍然没有误报。不过我没有就此收工。因为我还想看看这个抖动会不会对其它仪表盘造成视觉误导。查了一圈Grafana上所有引用mysqld_process_start_time_seconds的面板发现好几个“数据库运行时长”仪表盘用的表达式是time() - mysqld_process_start_time_seconds它们虽然在展示层没有触发告警但显示出来的运行时长曲线同样带着锯齿看起来很毛糙。我把这些面板的表达式统一改成了直接读取mysql_global_status_uptime这个counter指标它本身就是MySQL的整数秒计数波动特性和展示需求完全匹配。这里补充一个exporter升级的小结论。后来我把mysqld_exporter从0.15.1升级到了0.16.x重新观察采集值mysqld_process_start_time_seconds依然存在±1秒的抖动。原因很简单MySQL的Uptime整数秒精度没变exporter算法没变升级自然解决不了计算误差。所以不要指望通过升级来科学避坑真正有效的手段是改进告警规则和展示方式。5. 监控“说谎”的常见陷阱清单Counter重置、时间同步、浮点与取整这次排查让我对整个Prometheus监控体系的信任度有了新的认识。监控系统看起来客观、精确其实内部处处是近似值和约定。除了“进程启动时间”的整数截断坑还有几类类似的“监控说谎”场景值得记录方便你自己排查时对号入座。5.1 counter重置increase()的算数缺陷很多人喜欢用increase()或者rate()计算计数器增量尤其是在检测“某类事件是否发生”时。比如increase(mysql_global_status_uptime[5m]) 0这类写法的前提是Prometheus能正确识别counter重置。当counter发生重置也就是服务真的重启了Prometheus在计算increase时会做“补偿”——它会把重置后的当前值加上重置前的值来估算总增量。但如果你抓取间隔较大而服务在两次抓取之间重启了又恢复了大量请求补偿算法算出来的增量可能和真实业务行为偏差很大。更糟的是如果有人把gauge类型的数据误当成counter去算increase一旦值发生正常的“变小再变大”Prometheus也会判定为一次重置进而算出错误的增量。这一点和本次changes()误报属于同一类问题函数选型必须匹配指标类型和数据语义。排查建议先用up指标确认抓取是否连续再用process_start_time_seconds确认exporter是否重启过最后再判断增加量异常是业务变化还是重置误判。5.2 时间同步与时间戳基准问题Prometheus的time()函数返回的是Prometheus服务器自身的时间而exporter生成指标时间戳用的是exporter所在节点的时间。如果这两台机器之间存在NTP偏差那么所有涉及“当前时间减去指标时间戳”的计算都会产生系统性误差。尤其在虚拟化环境里宿主机时间漂移、云平台时钟服务中断都可能导致秒级偏差。这次排查虽然最终锁定的是整数截断问题但我建议你在做这类排障时永远把NTP差作为一个检查项。排查建议搭建监控体系时统一用chrony或systemd-timesyncd做时间同步并在Grafana里建一个time() - node_time_seconds的面板盯住偏差偏差超过1秒就告警。5.3 抓取失败、staleness与浮点精度Prometheus对时间序列有一个staleness机制如果一个指标的样本超过5分钟没有更新查询时会返回NaNGrafana表现为断线。但如果只是偶发抓取失败再恢复一些看似连续的查询结果可能会因为样本插值而产生异常跳变。更隐蔽的是浮点精度问题。Prometheus内部用float64存储样本值epoch时间戳通常是1.7e9量级float64在这个量级上的最小精度约为0.0000002秒虽然远小于秒级误差但当多个指标做加减乘除时累计误差可能让阈值恰好卡在边界上。这类问题一般不常见但一旦出现就是“找不出原因”的那种建议排查时把所有先验条件重新审视一遍。最后说一个实操体会经过这一晚我把所有监控告警规则重新过了一遍核心原则就是“告警表达式必须能准确刻画故障语义”。数据计算有误差不可怕可怕的是用放大镜去看误差。把阈值、持续时间、合理容忍度这些参数设计好Prometheus还是我们最可靠的哨兵。下次再看到这种“数据库没动监控却报重启”的诡异告警你可以先稳住数据库侧的Uptime和ps信息永远是第一手铁证然后把目光转向采集器指标的计算细节——答案往往就在那一秒的毫厘之差里。