☰
Prometheus监控MySQL误报“重启”:exporter OOM与告警规则深层解析
2026/10/3 4:14:03 网站建设 项目流程

凌晨两点十七分,手机在床头柜上震得嗡嗡响。我迷迷糊糊摸过来一看,告警群里的消息带着红色的感叹号:MySQL-主库 疑似重启,instance=10.24.32.15:9104 process_start_time_seconds 发生变化。

我当时的反应和大家一样——数据库重启了?这可是主库,出了大事。我拖鞋都没穿好就冲到书房打开电脑,准备连夜拉日志、看监控曲线、给DBA打电话。但当我连上数据库执行SHOW GLOBAL STATUS LIKE 'Uptime'的时候,结果让我愣住了:Uptime显示服务器已经连续运行了整整187天。数据库根本没重启。

几分钟之后,业务侧反馈没有任何连接中断、慢查询或错误日志。数据库连一根头发都没掉,但Prometheus就是言之凿凿地告诉你——它“重启”了。

这个场景,我相信不少运维同行都经历过。监控是我们运维的眼睛,但当监控本身开始“说谎”的时候,那种困惑和焦虑比业务故障还难受。今天不扯别的,就聊聊这个案例的完整排查过程,把Prometheus监控数据库“重启”的来龙去脉和背后的毫厘之差彻底掰扯清楚。这篇内容适合所有正在用Prometheus+Grafana做数据库监控的同学参考,也适合刚入行、想搞懂监控指标底层逻辑的新手朋友。

1. 先把“重启”的定义掰扯清楚:监控里到底存在哪几种“重启”

排查问题之前,先把一个最基本的问题搞清楚:在Prometheus的监控世界里,“数据库重启”这四个字到底是怎么被定义和观测到的?

1.1 三种形态:主机启动、进程启动、内部状态

我们对“数据库重启”的感知,实际上来自于三个完全不同的观测层面:

主机层重启:指物理机或虚拟机重新开机了,对应的是node_boot_time_seconds这个指标。这个指标记录的是操作系统内核的启动时间,如果机器断电、被强制重启、或者云厂商宿主机迁移,这个值就会跳变。但这个层面的“重启”和数据库进程本身没有直接关系——机器重启了数据库必然停止,但机器没重启不代表数据库没重启。

进程层重启:指数据库进程被kill掉了然后重新拉起来,对应的是process_start_time_seconds这类由exporter暴露的指标。拿MySQL的mysqld_exporter举例,它的输出里包含process_start_time_seconds{instance="10.24.32.15:9104"},这个值记录的是exporter进程自己的启动时间。这一点非常关键:如果exporter本身重启了,这个指标也会跳变,哪怕数据库进程一直活得好好的。

数据库内部状态层:指数据库自己记录的启动时间、运行时长等信息。比如MySQL的SHOW GLOBAL STATUS LIKE 'Uptime',或者PostgreSQL的pg_postmaster_start_time()。这个层面的数据最可信,但问题在于它默认没有被Prometheus采集,需要自己写自定义采集器或使用特定的exporter配置。

我们的告警规则通常是这样写的:

changes(process_start_time_seconds{job="mysqld"}[5m]) > 0

这条规则的意思是:在最近5分钟内,process_start_time_seconds这个指标的值发生了变化,就触发告警。changes()函数会统计指定时间窗口内指标值发生变化的次数。这个规则的逻辑本身没有问题,但是——它监控的真的是“数据库重启”吗?不是。它监控的其实是“任何导致这个指标跳变的因素”,包括数据库重启、exporter重启、指标采集异常、标签漂移等等。

1.2 三种“重启”的不同可信度

观测层面指标来源可信度误报可能性
主机层node_exporter 的node_boot_time_seconds较高低,但采集异常时可能取不到值
进程层mysqld_exporter 的process_start_time_seconds中高,exporter重启会导致误判
内部状态层SQL查询得到的Uptime/postmaster_start_time最高极低,最接近数据库真实状态

我们遇到的就是第二种情况的翻版——进程层指标跳变,但数据库本身毫发无损。

2. 排查链路还原:从告警一路摸到毫厘之差的完整过程

确定了告警来源之后,真正的排查才刚开始。这里我不直接说答案,带着大家一起走一遍完整的排查链路。你能看到我怎么一步步把范围缩小、把问题定位到毫厘之间的。

2.1 第一步:排除数据库本身的重启可能

告警触发后,第一件事永远是确认数据库的真实状态,以数据库自身的内部状态为准,而不是以监控系统为准。这一步是优先级最高、也是最不能被跳过的环节。

MySQL主库这边我直接执行了:

SHOW GLOBAL STATUS LIKE 'Uptime'; SHOW GLOBAL STATUS LIKE 'Threads_connected'; SHOW MASTER STATUS;

Uptime显示187天,Threads_connected正常波动,SHOW MASTER STATUS显示的binlog文件名和位置和前一天巡检记录一致。这说明什么?如果数据库真的重启了,Uptime会从很小开始累计,binlog也会生成新的文件。现在这些都正常,数据库层面的“重启”可以直接排除。

我还顺手查了performance_schema里的连接时间记录,确认没有任何中断空档。同时查了MySQL错误日志,也没有发现异常关闭或启动的记录。

故这一步的结论:数据库本身没重启,是监控层面出现了误报。问题出在Prometheus到数据库之间的某个环节。

2.2 第二步:检查Prometheus本身的采集链路

我把目光转向Prometheus服务端。登录Prometheus的Web界面,进入Status → Targets页面,检查10.24.32.15:9104这个target的抓取状态。一切正常,抓取时间是最近一次,UP状态良好,最近几次抓取的duration也很稳定。

继续查这个指标本身的具体值。在Prometheus的Graph页面执行:

process_start_time_seconds{instance="10.24.32.15:9104"}

返回的结果是一个时间戳:1688874521.74。这串数字是Unix时间戳,表示自1970年1月1日以来的秒数,换算成北京时间大概是2023年7月9日。我又执行了一遍:

process_start_time_seconds{instance="10.24.32.15:9104"}[30m]

返回了最近30分钟的时间序列数据。问题来了——在告警触发的时间点附近,这个值确实发生了变化。从1688871521.42变成了1688874521.74,二者之差刚好是3000秒,也就是50分钟。

这个差值很有讲究。如果数据库真的重启了,新进程的启动时间应该是一个“新”的时间点,这个新时间点到当前时刻的间隔应该约等于数据库实际运行时长。但现在记录的新值比旧值晚了50分钟,而且旧值本身也是一个50分钟前的合理时间戳。

这说明什么?不是数据库进程重启了,而像是采集这个指标的进程——也就是mysqld_exporter——在50分钟前重启过。exporter重启后,process_start_time_seconds重新记录了一个新的启动时间戳,而Prometheus的changes()函数发现这个值变了,就触发了告警。

2.3 第三步:确认mysqld_exporter的重启原因

目标从“数据库重启”转移到了“exporter为什么重启”。我登录到数据库主机上,查看mysqld_exporter的服务状态和日志:

systemctl status mysqld_exporter journalctl -u mysqld_exporter --since "2 hours ago"

日志里有一条关键记录:

Jul 9 01:27:03 db-mysql-01 systemd[1]: mysqld_exporter.service: Main process exited, code=killed, status=9/KILL Jul 9 01:27:04 db-mysql-01 systemd[1]: mysqld_exporter.service: Scheduled restart job, restart counter = 1. Jul 9 01:27:15 db-mysql-01 systemd[1]: mysqld_exporter.service: Started.

status=9/KILL,进程被强杀了。但没有任何人为操作过这个进程,系统日志也没有显示OOM。釘到这个信息之后我的第一反应是——要不要看监控?但上一秒我还在用监控查问题,下一秒就发现监控自身的agent被杀了,这有点讽刺。

继续往系统层面挖,dmesg -T里找到了真相:

[1279963.827011] Out of memory: Killed process 27891 (mysqld_exporter) total-vm:1845600kB, anon-rss:1263432kB, vm-rss:1272640kB

OOM Killer把mysqld_exporter杀了。这台机器上跑了一个比较大的Java项目,加上MySQL自身占用,物理内存吃紧。之前一直没触发OOM是因为业务低峰期内存还有余量,但那天凌晨刚好有个定时任务,Java堆内存被顶到高位,把整机内存边缘的最后一根稻草压垮了。mysqld_exporter成了被选中的victim——它内存占用不小,但又是后台服务,优先级低。

到这里,完整链路就通了:内存压力导致OOM Killer杀掉mysqld_exporter → systemd自动重启exporter →process_start_time_seconds跳变 → Prometheuschanges()函数在5分钟窗口内检测到变化 → 告警触发。

2.4 第四步:回到“毫厘之间”的真正深层问题

但如果你以为到这里就结束了,那就太天真了。上面说的是这个具体案例的表面结论,值得再往深挖一层的还有一个“毫厘”级别的细节:为什么告警能如此精准地在指标变化时立即触发?告警阈值是不是设得太灵敏了?就算exporter重启,如果我把窗口和阈值调整一下,是不是不该告警?

这个问题的答案是:告警规则里用了changes()函数,它只关心“变没变”,不关心“变了多少”。哪怕process_start_time_seconds从1688871521.42变成了1688871521.43——只变了0.01秒——changes()也会认为这是变化,从而触发告警。这就是“毫厘”之差的真正体现:监控系统的误报,不需要大动静,零点几秒的偏差就足够了。

换句话说,我们被“监控系统对于变化的极度敏感”和“对‘变化’这个行为本身缺乏上下文判断”这二者结合在一起给骗了。这就是我想通过这个案例真正告诉大家的:绝大多数Prometheus监控“谎报”问题的核心本质,不是指标的数值错了,而是我们解读指标变化的方式太机械了。

3. 顺藤摸瓜:我是怎么修复告警规则并防止这类“谎报”反复出现的

定位到根因之后,修复工作分成两个层面:第一层是治标——解决OOM问题,避免exporter再被杀;第二层是治本——优化告警规则,让监控更聪明,不再因为任何细小的指标波动就误报“重启”。

3.1 针对OOM的应急处理和长期方案

应急操作很简单:调高mysqld_exporter在OOM Killer里的oom_score_adj,降低它被杀的概率。

# 在 systemd service 文件里添加 [Service] OOMScoreAdjust=-800

OOMScoreAdjust=-800的意思是降低mysqld_exporter被OOM Killer选中的概率,数值越低越不容易被杀。但这里有个底线要讲清楚:如果整机内存已经彻底告急,OOM Killer会按照系统策略选一个最“大”的进程来杀,任何进程都逃不掉。调整OOMScoreAdjust只能让它在同等条件下相对安全,不能完全避免被杀。

长期方案是给这台机器扩容内存,同时优化Java服务的堆内存配置,让整机的内存水位降下来。我顺手加了node_memory_MemAvailable_bytes指标的告警,内存剩余低于10%的时候提前告知,而不是等到OOM Killer动手才追悔莫及。

3.2 告警规则的重新设计:从“变没变”到“真正重启没”

这块是整个过程中技术含量最高的部分。原来的规则只判断changes() > 0,太粗糙了。我把规则改成了下面这个样子:

( process_start_time_seconds{job="mysqld"} - (process_start_time_seconds{job="mysqld"} offset 10m) ) > 60

这段PromQL是什么意思呢?offset 10m取的是10分钟前这个指标的值,当前值减去10分钟前的值,如果差值大于60秒,说明指标发生了变化。为什么是60秒?因为如果是exporter自身重启,新记录的启动时间戳和旧记录之间的差值是exporter重启的时间间隔,通常不会恰好是10分钟的整数倍,也不大可能小于60秒。而如果数据库真的重启了,新启动时间戳和旧启动时间戳之间的差值一般会很大——至少是数据库停机到重启完成的整个时间差,往往远大于60秒。

更严谨一点可以用increase()函数加窗口来做:统计10分钟内process_start_time_seconds的增量,如果增量超过60秒才告警。这和上面的写法逻辑等价,但语义上更直白:

increase(process_start_time_seconds{job="mysqld"}[10m]) > 60

我用这个规则跑了三天,观察到的效果是:exporter因为OOM又被kill了一次(虽然我调了oom_score_adj,但并不能完全免疫),这次告警没有误触发。因为exporter的重启发生在10分钟窗口内的某一刻,新值减去旧值的时间差是exporter从重启到当前时间的间隔——这取决于重启发生在窗口内的哪个位置,通常是几分钟到十几分钟,大于60秒,所以照样会告警。这个方案并不能完全区分“exporter重启”和“数据库重启”!

这是一个重要的反思点:任何基于process_start_time_seconds的告警规则,本质上都受制于“exporter和数据库生命周期不一定绑定”的天然缺陷。想彻底区分二者,只能把采集源分开,或者引入更可靠的前置判断。

3.3 更可靠的做法:优先直接监控数据库内部状态

这是我最终采用的方案。MySQL场景下,用mysqld_exporter自带的采集项直接获取数据库启动时间:

mysql_global_status_uptime{job="mysqld"}

这个指标直接来源于SHOW GLOBAL STATUS LIKE 'Uptime'。如果数据库进程重启了,这个值会重置为从0开始累计;如果exporter重启了,这个值完全不受影响,因为它是从MySQL服务端查询来的,跟exporter进程自己的生命周期无关。这个才是监控数据库重启的正确打开方式。

对应的告警规则可以这样写:

mysql_global_status_uptime{job="mysqld"} < 60

意思是数据库运行时长低于60秒就告警。这个规则绝对不会因为exporter重启而误报,只有真的数据库重启才会触发。PostgreSQL场景下也类似,用pg_postmaster_start_time_seconds指标,配合时间比较来做判断。

如果不想额外写规则,也可以做一个组合判断——两个条件同时满足才告警:

( changes(process_start_time_seconds{job="mysqld"}[5m]) > 0 and mysql_global_status_uptime{job="mysqld"} < 300 )

先用process_start_time_seconds的变化做第一层过滤,再用数据库自身Uptime掉到300秒以下做第二层硬性确认。二层都满足才算数。这个方案的好处是兼容老写法,同时也保留了“数据库真实重启”这个事件的判定能力。

我个人现在在生产环境用的是第二层方案:直接以mysql_global_status_uptime < 60作为数据库重启告警,process_start_time_seconds的变化则作为exporter重启的参考指标,单独一条告警链路,提醒我“exporter曾经被重启过”——这两个告警含义不同,不应该混为一谈。

4. 监控“说谎”还有哪些惯用伎俩:盘点Prometheus误报的常见“毫厘”陷阱

这次的案例只暴露了changes()函数的一个“毫厘”陷阱。但我在这些年排障过程中,见过太多类似的“监控说谎”场景了,很多都是被细节误导。这里把最常见的几个一起整理出来,方便大家排查时有个参考。

4.1 时间戳精度丢失:一个被忽略的浮点问题

Prometheus的指标值存储是float64的。对于process_start_time_seconds这种Unix时间戳来说,秒级精度的数据在float64里存储通常没有问题,但如果时间戳带有毫秒或微秒的精度,就可能出现精度丢失。数据被四舍五入成近似的秒值,在计算差值时可能产生0到1秒的误差。告警规则如果阈值设得太死——比如> 0——这种精度误差就足以触发误报。

4.2 标签漂移和聚合维度改变

如果告警规则里写了instance="10.24.32.15:9104",但Prometheus配置文件里targets列表中这个IP被改成了别的(比如IP变化、端口变化),历史时间序列和新时间序列因为标签集合不同会被认为是两条完全不同的序列。老序列不再更新,新序列从当前时间开始积累,changes()函数同样会认为这条序列发生了变化。

这种情况经常出现在云环境里:ECS实例重启后拿到了新的IP,但Prometheus配置还没更新——或者反过来,配置更新了,但历史数据里的旧标签还在。任何标签的变化,本质上都会被Prometheus当成一条新的时间序列来处理,这也是很多“数据库重启”误报的真正来源。

4.3 抓取周期导致的“阶段性跳变”

如果Prometheus设置了scrape_interval: 15s,而exporter的响应时间在峰值时期超过了某个阈值,或者某个抓取点超时失败,会有一小段数据缺失。紧接着下一轮抓取成功时,数据值可能和上一次成功抓取的值有一个较大的时间差——这对changes()来说是变化,对increase()来说也可能被误判为增量。这也是“毫厘”问题的一种:不是事实变了,而是观测频率和观测空档让变化看起来很剧烈。

4.4 时钟偏移:监控服务器本身的时间也会出错

Prometheus所在的服务器如果开启了NTP但同步异常,或者宿主机时间被篡改、漂移,会导致时间戳出现偏差。最典型的是两个节点时间差了三十秒,那么process_start_time_seconds当前值对比10分钟前的offset值,会产生一个恒定偏移。告警阈值设置不当就可能被这个偏移触发。排障时如果发现时间序列里的值整体平移而不是局部跳变,优先检查NTP和宿主机时钟。

4.5 多实例场景下的主从切换与VIP漂移

主从复制架构里,主库挂了触发自动切换,VIP从旧主机漂移到新主机。如果exporter绑定的是VIP而不是IP,那么VIP漂移后抓到的process_start_time_seconds会发生巨大跳变——新主机上的exporter启动时间当然和旧主机完全不同。这种情况下如果只盯着这个指标,得到的结论是“数据库重启”了,但实际上业务影响应该被描述为“主从切换”或“VIP漂移”,这是完全不同的两个事件。

5. 面对这类“监控谎报”问题的排查方法论:把范围一点点收窄

经历这次事件后,我形成了一套自己的排查方法论。以后再遇到类似的Prometheus告警误报,我会严格按照这个思路走,效率高很多,也少走很多弯路。

5.1 排查顺序:从最可信的数据源到最不可信的监控侧

遇到“数据库重启”告警时,我的排查顺序固定是:

  1. 查数据库自身的状态(Uptime、错误日志、性能数据),确定数据库是否真的重启。这一步的结果最可信。
  2. 如果数据库没重启,检查告警用的具体指标是什么、来源于哪一层采集(主机层、进程层还是内部状态层)。
  3. 检查目标机器的exporter状态(systemd服务状态、日志、OOM记录、重启时间),确认exporter本身是否最近重启过。
  4. 检查Prometheus侧的抓取配置(scrape_interval、targets列表是否变动、标签是否变化)。
  5. 检查告警规则逻辑(用了什么函数、窗口多大、阈值多少),用当前值和历史值回放一遍,确认规则是否合理。

这张表是我专门给自己整理的速查表,现在分享出来:

排查层级关键操作对应常见误报原因
数据库层SHOW GLOBAL STATUS LIKE 'Uptime'、错误日志无
指标来源层确认指标是process_start_time_seconds还是mysql_global_status_uptime指标本身不可靠
Exporter层systemctl status、journalctl、dmesg | grep -i oomexporter被OOM或手动重启
Prometheus抓取层检查targets状态、scrape_interval、标签变动标签漂移、抓取周期
规则逻辑层用当前值、offset值回放判断阈值过灵敏、函数使用不当

5.2 用“两问一验”快速定位“毫厘”问题

排查过程中如果碰到那种“看起来是变化、但变化很小”的异常,我总结了一个“两问一验”的技巧:

第一问:这个指标的变化量到底多大?如果变化量非常小(秒级甚至零点几秒),那大概率是采集或计算层面的问题而非真实重启。真实重启的指标变化量通常会很大——至少是分钟级起步。

第二问:这个指标背后的采集器是否和业务主体强绑定?如果采集器是独立进程(像mysqld_exporter),它的生命周期和数据库生命周期是解耦的,指标变化不代表数据库变化。

一验:把告警触发点前后的原始数据全部捞出来,画成曲线看。变化是“脉冲式”的还是“台阶式”的?“脉冲式”通常是采集异常(抓取失败、短暂中断);“台阶式”通常是有进程启动或停止。结合时间点可以快速判断。

5.3 规则设计的三个层次:从初级到专业

我把告警规则的设计分成三个层次,大家可以对号入座,看自己在哪个层次:

初级层次:直接用changes()函数,> 0就告警。优点是简单、能覆盖所有变化;缺点是极度敏感,任何风吹草动都会告警,噪音极大。

中级层次:用increase()函数加窗口,设置一个合理的阈值,比如increase(x[10m]) > 60。优点是过滤了极小的变化;缺点是无法区分是exporter重启还是数据库重启。

高级层次:结合数据库内部状态指标一起判断,或者直接用数据库自身的内部指标作为告警依据。比如mysql_global_status_uptime < 60或者组合规则。优点是真正监控的是数据库本身,不会被exporter干扰;缺点是配置稍微复杂一点,需要对监控体系有更深的理解。

现在我设计告警规则的第一准则是:**找到最能代表业务真实状态的指标,而不是用最方便采集的指标。**这算是这次踩坑送给我最深刻教训。另一个变通做法是对于关键业务数据库,把exporter本身的重启也当成一条独立告警来管理,而不是混淆到数据库重启这个告警里。

6. 从误报到“征信”:后续我还会做哪些加固

这次事件之后,我在运维体系里做了几件长期的加固工作,也算是对“监控谎报”的系统性反思结果。

6.1 监控自身的可观测性

如果你连监控系统自身都被监控了,那么出现异常时就能快速定位问题是否出在监控本身。我给所有的exporter节点都加了一个基础指标的采集:process_start_time_seconds、up、scrape_duration_seconds。这样每次告警事件发生时,我可以快速判断是业务指标异常还是采集链路异常。

同时,我给Prometheus本身也加了告警:如果某个target的up状态变为0,或者抓取延迟突然升高,第一时间告警。这样在业务还没受影响之前,我就知道监控链路已经有了问题。

6.2 告警去重和抑制规则

Prometheus的Alertmanager支持抑制规则(inhibition rules)。比如,如果某台主机已经触发了InstanceDown告警,那么这台主机上所有的数据库重启、进程异常告警都应该被暂时抑制——因为主机都挂了,其他告警已经没有意义了。

我配置了这样的抑制规则:

groups: - name: db_alerts_optimized rules: - alert: MySQLDatabaseRestart expr: mysql_global_status_uptime{job="mysqld"} < 60 for: 1m labels: severity: critical annotations: summary: "数据库实例 {{ $labels.instance }} 疑似重启" description: "数据库运行时长低于60秒,进程可能刚刚启动"

Alertmanager侧的抑制配置:

inhibit_rules: - source_matchers: - severity="critical" - alertname="InstanceDown" target_matchers: - severity="critical" equal: ["instance"]

意思是如果InstanceDown告警已经触发,那么同一实例上的其他严重告警都会被抑制,避免在一个事件中重复告警轰炸。

6.3 OOM和资源水位提前预警

这次的直接原因虽然是OOM,但深层问题是内存水位管理不到位。我新增了一条内存告警:

(node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 < 15

可用内存低于15%就预警,提前扩容或优化服务,而不是等OOM Killer来“帮你”做决定。这条规则配合之前的数据库重启告警,一个负责预防,一个负责兜底。

6.4 定期演练监控有效性

每隔一段时间,我会主动做一次“监控可信度测试”:找一台非关键的测试数据库,手动kill掉exporter进程、手动kill掉数据库进程,看看各自的告警是否正确触发、是否误报、是否漏报。这相当于给监控系统做体检。很多监控问题都是在“真出事”的时候才暴露的,如果平时不做这种演练,等大故障来了才发现告警规则本身就是个摆设,那才是真正的大问题。

我也把这个演练纳入到了季度运维巡检的固定流程中。每个季度抽一个周末的凌晨,在低峰期做一轮完整的监控可信度演练。

7. 写在最后:监控不是越多越好,而是越准越好

这次“数据库没动,监控却谎报重启”的事件,最终以调整OOM参数、优化告警规则、新增内存预警三条措施收场。但对我来说,最大的收获不是这几条措施,而是对“监控可信度”这个概念的重新认识。

监控系统的价值不在于它告警多少,而在于它告警的精度和可信度。一个动不动就“狼来了”的监控系统,带来的不只是告警疲劳,更是对真实告警的信任透支——等真正遇到数据库重启的时候,值班人员可能已经在无数条假告警中麻木了,反而错过了真正的故障。

所以在告警规则的每一个条件、每一个函数、每一个阈值背后,都值得多问一句:我看到的变化,真的是业务变化吗?还是监控链路自身的变化?这“毫厘之间”的思考,往往才是决定一个运维系统是“看上去很完善”还是“真正可靠”的分水岭。毫厘之差,千里的不是别的,是信任。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询