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_seconds3.1 把告警背后的 PromQL 翻出来回到 Prometheus 控制台先看这条告警当时的表达式。项目里最初那版重启检测规则写得很简单长这样changes(process_start_time_seconds{jobmysql}[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{jobmysql}这条线在凌晨两点多发生了一次明显的跳变旧值是一个较早的时间戳新值比旧值只晚了大概几秒钟。从人的感知角度来看这几秒几乎可以忽略但从changes()函数的角度来看这已经是实打实的一次数值变化于是告警顺理成章地触发了。为了确认这次跳变到底来自哪个进程我看了一下这个 series 上携带的标签process_start_time_seconds{jobmysql}返回结果里除了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{jobmysql} 120 ) and ( mysql_global_status_uptime{jobmysql} offset 5m 300 )这个表达式里的两个条件缺一不可。第一个条件判断“当前 uptime 很大”说明实例已经运行了至少十几分钟不是刚配置好监控的新实例在误报。第二个条件判断“5 分钟之前这个实例的 uptime 已经超过 300 秒”说明它不是刚刚从零开始的新实例而是一个曾经运行了相当时间的成熟实例。两个条件同时满足时唯一的解释就是这个实例在最近 5 分钟之内重启过uptime 计数器从大值归零后又重新开始累计。如果不想用 offset 这种写法也可以用delta()函数检测 uptime 是否出现过下降趋势delta(mysql_global_status_uptime{jobmysql}[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{jobmysql} 120 ) and ( mysql_global_status_uptime{jobmysql} 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而不是直接看告警文案。很多“谎报”的背后不是监控系统坏了而是我们定义“重启”的方式和真实世界的“重启”差了那么毫厘。
阅读完成 · 觉得有帮助?