☰
Prometheus监控MySQL重启误报排查:exporter OOM与uptime指标的正确选择
2026/10/13 11:05:01 网站建设 项目流程

1. 凌晨那条“数据库重启”告警,和 DBA 的“我什么都没动”

凌晨两点多,手机连着震了三下,Prometheus 的告警路由把一条 Critical 级别的通知送到了值班群:某核心订单库的 MySQL 实例疑似重启。我第一反应是立刻从床上爬起来,登录跳板机,先确认是不是业务已经挂了。结果 MySQL 进程稳稳地跑着,错误日志干干净净,没有任何崩溃、重启、恢复的记录。

这已经不是第一次遇到这种“监控觉得你重启了,数据库却说自己一直活着”的诡异情况了。很多做监控的人都会在某一刻被这类问题折腾到怀疑人生:明明 Prometheus 报警说实例重启,可数据库的 Uptime 明明还在往上走,应用层也没有感知到连接中断,DBA 那边的回答永远是“我什么都没动”。本文我就把这次完整排查链路写出来,包括当时走了哪些弯路、最后是怎么定位到那个“藏在毫厘之间”的原因的,以及告警规则最终改成了什么样。如果你也在用 Prometheus 做 MySQL 或者其他数据库的重启监控,这篇内容应该能帮你少踩同样的坑。

整条链路是这样的:Prometheus 每 15 秒抓取一次 mysqld_exporter 暴露的指标,告警规则里有一条专门检测“实例重启”,一旦触发就会以 Critical 级别通知到人。数据库部署在内部的容器平台上,MySQL 实例本身跑在 Pod 里,而 exporter 是以 sidecar 方式部署在同一个 Pod 中。这套拓扑在生产环境很常见,也正是因为常见,问题才容易被忽视。

2. 先查数据库本身:进程没死,uptime 也没归零

2.1 SHOW STATUS 先说话

收到告警之后我没有立刻去看 Prometheus 的告警规则,而是先直接连库,因为第一要务永远是确认业务数据库的真实状态。用管理账号登进去,先执行了一条最基础的命令:

SHOW GLOBAL STATUS LIKE 'Uptime';

结果返回的 Uptime 是一个好几百万秒的累计值,和监控页面上mysql_global_status_uptime的曲线完全对得上,说明这个值一直在持续递增,并没有归零过。如果 MySQL 进程真的重启过,这个计数器一定会在某个时刻归零,然后重新开始累计。它的起点就是进程启动时刻,这是数据库内核自己记录的,不是什么外部系统估算出来的,可信度极高。

接着又查了SHOW GLOBAL STATUS LIKE 'Threads_connected'和SHOW GLOBAL STATUS LIKE 'Questions',连接数没有发生断崖式下跌,Queries 也在正常累计。这说明在告警前后,整个数据库服务并没有发生过对外不可用、连接全部断开、再重新接受连接的窗口。如果客户端在凌晨两点的那个时间点集体重连过一次,连接数曲线一定会有明显的锯齿状波动。

2.2 日志和系统侧旁证

接下来查 MySQL 错误日志,在告警时间前后完全没有Shutdown、Starting、Recovering之类的关键字。因为 MySQL 的正常启动过程会在错误日志里留下大量痕迹,尤其是 InnoDB 的崩溃恢复阶段,就算恢复只用了不到一秒钟,也会写出至少几行日志。一个干净到没有任何启动记录的日志文件,基本可以排除数据库进程本身发生过重启。

我又顺手看了系统层的事件,包括容器平台上的节点状态和系统日志,也没有 mysqld 进程被杀、PID 变化、内存回收之类的异常记录。到这里基本可以下第一个结论:被监控的 MySQL 数据库确实没重启。但 Prometheus 的告警是真实触发的,告警表达式背后一定有一个指标确实发生了变化。问题不在于“数据库有没有重启”,而在于“监控规则拿什么指标来判断重启”。

3. 顺着告警表达式往上摸,发现变化的其实是 process_start_time_seconds

3.1 把告警背后的 PromQL 翻出来

回到 Prometheus 控制台,先看这条告警当时的表达式。项目里最初那版重启检测规则写得很简单,长这样:

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

它的逻辑很直接:process_start_time_seconds是一个 gauge 指标,记录的是“某个进程的启动时间”,单位是 Unix 时间戳。如果进程重启了,这个值会从旧时间戳跳到一个新时间戳,那么在 5 分钟窗口内用changes()函数去数变化次数,只要大于 0 就说明启动时间发生了位移,也就是“进程重启了”。

这个思路本身没有错,但有一个非常隐蔽的坑:这个指标名里的“process”到底指的是哪个进程?它不是 MySQL 内核进程的启动时间,而是当前这个 exporter 进程自身的启动时间。也就是说,这条规则真正在监控的是 mysqld_exporter 这个“旁路采集进程”有没有重启,而不是 MySQL 实例有没有重启。我当时就是在这里绕了很久,因为监控告警的名字写着“MySQL 实例重启”,潜意识里默认指标反映的就是数据库本体,完全没想过 exporter 也会被这个概念涵盖。

3.2 process_start_time_seconds 到底是谁的启动时间

在 Prometheus 的 Graph 页面执行这条查询,把时间范围拉到告警时间附近,可以看到process_start_time_seconds{job="mysql"}这条线在凌晨两点多发生了一次明显的跳变:旧值是一个较早的时间戳,新值比旧值只晚了大概几秒钟。从人的感知角度来看,这几秒几乎可以忽略,但从changes()函数的角度来看,这已经是实打实的一次数值变化,于是告警顺理成章地触发了。

为了确认这次跳变到底来自哪个进程,我看了一下这个 series 上携带的标签:

process_start_time_seconds{job="mysql"}

返回结果里除了job、instance这些常规标签之外,还能看到pod相关的标识。查一下这些标签对应的 Pod 信息,再对照容器平台上的 Pod 事件,结论就非常清楚了:变化的是 exporter 这个 sidecar 容器,而不是 MySQL 主容器。这一瞬间整个事件的解释就通了——数据库没动,动的是监控组件本身。

mysql_global_status_uptime 和 process_start_time_seconds 这两个指标,前者来自数据库内核,后者来自 exporter 容器自身,它们之间没有直接关系。exporter 重启之后重新连接 MySQL,依然能正常读到 Uptime 的累计值,所以数据库这一侧的指标纹丝不动;而 exporter 的process_start_time_seconds却已经悄悄换了一个新值。这就是“毫厘之间”的第一层含义:两个指标指向同一个告警主题,但背后的实体完全不同,差的就是这层窗户纸。

4. exporter 重启的真相:存活探针、内存上限和毫秒级时间差

4.1 容器平台上找到了 restart 记录

确定变量之后,去容器平台的事件中心查这个 Pod 在告警时间附近发生了什么。结果非常明确:exporter 容器因为触发内存限制被 OOMKilled 了,紧接着被 kubelet 按照重启策略拉起来。Pod 的事件流里清清楚楚记录着一次OOMKilled,随后是Created和Started,整个过程发生在几十秒之内。

为什么会 OOM?当时 exporter 容器设置了 128 MiB 的内存 limit,正常情况下完全够用。但告警时间点前后,数据库侧正好有一批慢查询堆积,导致 exporter 在抓取大量 session 状态、processlist 这些指标时临时多分配了一些内存,恰好就顶破了这个 limit。内存压力一上来,cgroup 直接把整个容器杀掉了。这个原因本身不复杂,真正坑人的是后续监控规则的误判,几乎所有人都不会第一时间想到“数据库重启告警”居然和“exporter 容器内存超限”有关。

4.2 “毫厘之间”到底差在哪里

现在把时间轴拉出来看,能发现一个特别微妙的点:exporter 被 OOMKilled 之后几乎立即被重新拉起,新的process_start_time_seconds和旧的启动时间只相差了几秒钟。Prometheus 的抓取周期是 15 秒,exporter 的一次“死亡-复活”完全发生在一个抓取周期之内,或者刚好跨在两个周期之间。

从人工排查的视角看,这几秒的时间差几乎无法感知:数据库 Uptime 依然是连续曲线,错误日志里没有数据库重启痕迹,exporter 虽然重启了但恢复太快,中间只丢了一两次抓取。你甚至可能会在 Prometheus 的 target 页面上看到它短暂处于 down 状态,但很快又变回 up,如果不刻意去翻历史事件,根本不会注意到。

更麻烦的是,changes()这个函数在遇到抓取点缺失时还会产生另一种误判。假设 5 分钟窗口内一共有大约 20 个抓取点,如果 OOMKilled 导致中间有两个点没有抓到,exporter 恢复后带着一个新的process_start_time_seconds回来,changes()在计算时就会把这个“旧值消失、新值出现”的情况统计为一次变化。也就是说,即使这个变化从业务角度完全可以忽略,告警规则依然会毫不犹豫地认为“重启了”。

这种“毫厘之间”的误差,正是监控系统最容易被误解的地方。人的时间感知单位是分钟和小时,监控系统的时间精度是秒级的抓取间隔,而容器编排系统的重启可能发生在秒级甚至更短时间内。三者之间的尺度不一致,导致监控看起来“说谎”了,实际上它只是在用自己的逻辑如实记录了一次组件级重启,只是这个“重启”不是你以为的那个“重启”。

5. 修复 PromQL 规则,让监控只认数据库自己的“心跳”

5.1 替换成数据库自己的 uptime 指标

定位到根因之后,修复方案就很清晰了:重启类告警不能拿 exporter 的进程启动时间来判断,必须用数据库内核自己暴露的状态指标。对于 MySQL 来说,最合适的就是mysql_global_status_uptime,这个指标记录的是数据库实例从启动到现在的累计运行秒数,它才是数据库自己的“心跳”。

改造后的告警规则思路是:如果一个数据库实例此前已经运行了比较长的时间(比如超过 5 分钟),但当前 uptime 突然变成了一个很小的值(说明它刚刚重新开始计数),那就基本可以判定数据库真的重启了。写成 PromQL 大约是这样:

( mysql_global_status_uptime{job="mysql"} < 120 ) and ( mysql_global_status_uptime{job="mysql"} offset 5m > 300 )

这个表达式里的两个条件缺一不可。第一个条件判断“当前 uptime 很大”,说明实例已经运行了至少十几分钟,不是刚配置好监控的新实例在误报。第二个条件判断“5 分钟之前这个实例的 uptime 已经超过 300 秒”,说明它不是刚刚从零开始的新实例,而是一个曾经运行了相当时间的成熟实例。两个条件同时满足时,唯一的解释就是:这个实例在最近 5 分钟之内重启过,uptime 计数器从大值归零后又重新开始累计。

如果不想用 offset 这种写法,也可以用delta()函数检测 uptime 是否出现过下降趋势:

delta(mysql_global_status_uptime{job="mysql"}[5m]) < 0

但这里有个细节需要注意:当抓取点出现缺失时,delta()有可能会返回 NaN,而 NaN 与 0 比较的结果是 false,所以它不会误报,但也不排除某些版本或场景下的边界行为。我个人更推荐第一种写法,因为它的语义更直观,容错性更好,而且能天然规避 exporter 重启带来的影响。不管 exporter 被 OOM 杀掉多少次、被重新拉起多少次,它重新连接 MySQL 之后读到的mysql_global_status_uptime依然是一个连续的累计值,绝不会因为 exporter 自身重启而归零。

5.2 告警规则的完整配置与验证

最终落在 Prometheus 告警规则文件里的配置是这样的:

groups: - name: mysql-restart-check rules: - alert: MySQLInstanceRestarted expr: | ( mysql_global_status_uptime{job="mysql"} < 120 ) and ( mysql_global_status_uptime{job="mysql"} offset 5m > 300 ) for: 1m labels: severity: critical annotations: summary: "MySQL instance {{ $labels.instance }} has restarted" description: "Detected a fresh uptime value while the instance was running before, indicating a real restart of the MySQL server."

配置里加了for: 1m是为了让告警在连续一分钟内都满足条件才触发,避免因为单次抓取的瞬时抖动造成误报。改完之后先做了一次模拟验证:手动把 exporter 容器 kill 掉,触发一次和线上完全相同的 OOMKilled 恢复流程。结果 MySQL 的 Uptime 指标没有产生下降趋势,告警没有任何变化。随后再通过控制台执行promtool check rules校验规则文件格式,确认没有语法问题后把配置热加载进 Prometheus。

还要强调一点,这个修复并不是把所有“数据库重启”检测都一竿子打死。如果你的监控架构里确实存在“无法通过 exporter 拿到数据库内核指标”的场景,那么用process_start_time_seconds去匹配节点上某些固定进程。比如用 node_exporter 的 systemd 指标来判断一个本机服务有没有重启,这个是没有问题的。问题只出在“拿采集器自身状态去推断被采集对象状态”这一层错位上。

6. 同类“假重启”告警的排查套路与指标选择原则

6.1 三种常见的“假重启”变种

这次踩坑之后,我回头整理了一下生产和测试环境里遇到的类似问题,发现“假重启”监控误报其实有几种高度相似的变种,非常值得举一反三。

第一种是 systemd 服务的时间戳误判。有些人监控某个系统服务是否重启,会去读node_systemd_unit_state或者从 systemd 日志里提ActiveEnterTimestamp。这个值会在服务真正启动时更新,但它也会在管理员执行daemon-reload、跨节点迁移、或者 systemd 内部状态切换时发生变化。服务进程本身没重启,时间戳却变了,和这次 exporter 的情况如出一辙。

第二种是 PID 复用导致的误判。用“进程 PID 是否发生变化”来判断重启的脚本很常见,但这玩意儿在长时间运行的机器上特别容易出问题。一个进程前一天归西了,今天某个新起的工作进程恰好复用了同一个 PID,监控脚本就会把“PID 没变”误判成“进程一直活着”,或者反过来,PID 一变就误报重启。说到底还是指征粒度不够细。

第三种是 VIP 漂移和主从切换的场景。主从架构里,监控 exporter 如果通过 VIP 连接数据库,一旦发生主从切换,VIP 在毫秒级时间内漂移到新主节点,exporter 会重新建立连接。连接对象变了,部分自定义指标或者连接状态可能发生变化,如果告警规则又恰好拿这些指标做“变化检测”,就会出现“数据库没重启,但监控认为实例重建了”的现象。这个变种和本文的案例尤其接近,因为差异就发生在毫厘之间。

6.2 设计重启监控时的指标选择原则

经过这一整轮折腾,我给自己总结了几条关于“实例是否重启”这一类告警的指标选择原则,写在这里供大家参考。

第一,能用内核状态指标就不用旁路指标。数据库的 uptime、进程的/proc/pid/stat启动时间这类由被监控对象自己维护的信息,永远比外部采集器暴露的元信息更可信。监控组件自身挂了、重启了、升级了,都不应该直接影响这个判定。

第二,检测“重启”最好回归计数器的物理含义。数据库这类系统大多有一个“启动后累计运行时间”的概念,拿它的突变来判定重启,比拿任何二次加工的时间戳都更本质。如果目标系统没有这类指标,也要选择一个语义上等价于“从启动开始到现在”的原始状态。

第三,所有重启类告警都必须能区分“组件重启”和“业务重启”。容器平台里 OOMKilled、探针失败导致的 controller 拉起重启,对于业务进程来说有时是无感的,但对于监控来说是完整的一次生命周期。在设计告警时,先问一句:这个指标的变化到底代表哪个实体的状态变化?答案不够明确的话,这个告警迟早会再坑你一次。

这次事件最后没有造成线上业务影响,但排查过程相当曲折。我现在养成了一个习惯:凡是看到带 restart、restarted、uptime 之类字眼的告警,第一件事就是去查指标本身的来源 series,而不是直接看告警文案。很多“谎报”的背后,不是监控系统坏了,而是我们定义“重启”的方式,和真实世界的“重启”差了那么毫厘。

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

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

立即咨询