Prometheus 2.0 数据陈旧性与隔离机制:监控曲线消失和查询卡顿的底层真相

Prometheus 2.0 数据陈旧性与隔离机制:监控曲线消失和查询卡顿的底层真相 先说一个我遇到的监控现场早上十点零五分一台线上节点被运维误操作关机Grafana 面板上 CPU 曲线没有立刻变成 0而是先保留着历史值过了几分钟整条曲线直接消失。这不是数据丢了而是 Prometheus 2.0 里的数据陈旧性staleness机制在起作用。同一时刻如果有人正对着这台节点跑大范围历史聚合查询查询结果也不会被正在进行的写入和磁盘压缩干扰这靠的是 TSDB 的隔离机制。这两件事看起来是存储层的底层细节但它们直接决定了你看到的监控曲线、告警恢复行为、以及大查询时的体感。不把它们讲透很多人会在生产环境里被“曲线为什么没了”“告警为什么不恢复”“凌晨大查询为什么卡”这类问题反复折腾。这篇就用我实际排查和改造存储配置的经验把 Prometheus 2.0 的数据陈旧性判定逻辑、隔离机制的实现路径以及它们怎么影响日常查询和告警完整拆开说一遍。1. 先搞清楚背景2.0 为什么要重写存储层1.1 1.x 时代最让人头疼的问题Prometheus 1.x 的存储并不是现在这套 TSDB。它用的是一种以内存 chunk 为主体、LevelDB 做索引的混合结构写路径和读路径耦合得很紧。我当时在测试环境压过一旦采集目标数量超过几千内存占用就蹭蹭往上涨如果中间发生一次查询内存里的数据块和 LevelDB 索引会同时被锁住写盘和压缩互相抢资源最直观的表现就是Prometheus 进程响应变慢scrape 循环开始丢点。更难受的是崩溃恢复。1.x 版本启动时要回放很长一段 WAL预写日志把内存里的 chunk 重建出来。数据量大一点启动时间能到十几分钟甚至更久。有一次我升级配置的时候忘了备份数据目录进程起不来最后只能放弃历史数据。当时社区里有不少人都经历过“Prometheus 一崩就掉一大段历史时序”的阴影。1.2 2.0 的架构目标把存储层做成一个正经的时序数据库2.0 的做法是彻底重写存储把 TSDB 独立成一个专门的包github.com/prometheus/prometheus/tsdb。它的设计目标和 1.x 完全不是一个思路数据按块Block组织每 2 小时的数据会从内存 head chunks 刷成一个持久化的 blockblock 内部自包含数据、索引和元数据整体不可变。WAL 只保护 head 数据启动时只需要重放最近几小时的 WAL恢复速度快得多。查询并发能力大幅提升每个 block 可以独立被读取查询引擎可以并行扫描多个 block。块结构是这一切的基础。但块结构也引出了两个必须回答的问题数据块不可变那么“序列停止更新后算什么状态”查询的时候 head 还在变、block 又在被压缩怎么保证查询结果一致这两个问题正是标题里“数据陈旧性”和“隔离机制”要解决的事情。1.3 陈旧性和隔离机制为什么成了“绕不开的设计决策”我举个例子你就明白了。假设我现在往一个 block 里写数据写完之后 block 就再也不能改了。那么一个 target 挂了以后那些已经写进 block 的旧样本是没有问题的但 head 区域里的序列呢如果不对它做任何标记查询引擎就只能靠“这个序列最近不更新了”来猜测它死了猜测的误差最大能到 5 到 10 分钟。这就是陈旧性要处理的问题。隔离机制更直接。2.0 允许查询和写入同时发生而 head 区域是动态追加的压缩机还在后台把旧 block 合并成更大的 block。如果查询读到一半看到的是“旧 block 已经被删、新 block 还没生效”的状态那查出来的结果就会出现空洞。所以必须有一套机制保证一个查询开始时它能看到的数据版本就被固定下来后续写入和压缩都不会破坏这个视图。这两个机制一个管“数据怎么算没”一个管“查询怎么算稳”是 2.0 存储层和上层语义之间的两道关键闸门。2. 数据陈旧性Prometheus 2.0 如何判定一条序列“死了”2.1 陈旧标记的引入从“靠猜”到“主动标记”在 1.x 时代序列停止更新后查询端只能靠时间窗口去判断它是否还有效。比如一个 sequence 最后更新在 10:00你在 10:06 查它发现距离现在超过 5 分钟了于是返回空。这种“靠猜”的问题在于如果 target 只是短暂抖动了一下10:07 又恢复了那 10:02 到 10:06 之间的查询都会误判这条序列“死亡”造成监控面板的短暂空洞和告警误报。Prometheus 2.0 引入了陈旧标记Stale Marker。它不是靠查询端去猜而是由服务端在确认一个序列“不再被更新”时主动写下一个标记声明这条序列从某个时间点开始就是陈旧的了。这里有个特别巧妙的设计陈旧标记在存储层面并不是真正删除数据而是写成一条带有特殊 NaN 位模式的样本。这种位模式的 NaN 和普通 NaN 不同TSDB 内部能识别出来查询引擎看到它就知道“这条序列已经死了”但历史数据仍然完好地保留着随时可以查。2.2 陈旧标记是什么时候被写进去的从我的实战经验看触发陈旧标记写入的典型场景有这么几类抓取失败且重试后仍失败scrape loop 连续几次拿不到样本就会判定 target 失联给该 target 当前暴露的所有序列写陈旧标记。target 从配置中移除比如你删掉了一个 scrape job或者 consul 服务发现里某台节点被摘除Prometheus 会主动清理并标记。序列不再出现在抓取结果里注意HTTP 200 不代表没有问题。我在线上遇到过 exporter 返回 200 但指标集为空的情况Prometheus 一样会把之前有样本、现在不返回的序列标记为 stale。这一点下面专门讲。写入的时机很有讲究。标记不会覆盖最后一条真实样本而是在失败抓取对应的时间戳上写入。也就是说如果最后一次成功抓取是在 10:00 生成了一条样本10:05 的抓取失败了陈旧标记的时间戳就在 10:05 左右这样不会影响 10:00 那一条样本的“有效性”。2.3 lookback delta兜底的陈旧判断机制光靠陈旧标记还不够。为什么因为标记只会在“服务端明确知道”的情况下写入但总有一些场景是服务端不知道的。比如序列确实还在写但写入频率低于查询频率或者 exporter 主动停止推送某些指标却仍然返回 200。为了兜底PromQL 查询引擎引入了lookback delta的概念。这个参数在启动项里对应--query.lookback-delta默认是5 分钟。它的语义是查询时刻t要选择一个序列的最新样本时只会在区间[t-5m, t]里找。如果序列的最后一个样本落在这个区间外查询引擎就认为该序列没有“最新值”。我画条时间线你就懂了。假设某序列样本情况如下10:00 最后一次真实样本写入 10:05 抓取失败Prometheus 写入陈旧标记 10:06 发起即时查询10:03 发起查询查询窗口是[09:58, 10:03]能选到 10:00 的样本返回正常值。10:06 发起查询陈旧标记生效即使 10:00 的样本距离查询点只有 6 分钟查询结果依然是空因为标记已经把序列“关闭”了。如果没有陈旧标记10:06 的查询会因为 10:00 落在 5 分钟窗口外而得到空结果但如果查询发生在 10:04窗口[09:59, 10:04]能覆盖到 10:00 的样本反而会错误地返回“看起来还活着”的数据。陈旧标记的价值就是把这种“窗口边缘的幻觉”提前掐断。2.4 陈旧标记在存储里的生命周期讲一个很多人忽略的细节陈旧标记不写入落盘的 block。block 是所有数据以不可变形式沉淀下来的目录结构它只保存真实样本。陈旧标记存在于 head 内存区域和 WAL 里当 head chunk 被刷成 block 的时候标记就被丢掉了。这意味着如果你用promtool tsdb dump block去看历史 block你是看不到陈旧标记的。查询历史数据比如昨天的曲线时每个时间点的数据都是最终版本不需要关心 stale 历史。但如果你用promtool tsdb dump wal看 WAL能看到大量带特殊 NaN 值的记录那些就是陈旧标记。设计成这样是有道理的陈旧标记描述的是序列在“当时”的活性对历史数据没有意义保留在 head/WAL 里能让新查询立即感知活性变化而 block 不可变又正好符合历史数据“永远不改”的定位。2.5 哪些场景最容易踩到“曲线消失”从我排障的经验来看最常见的有四个target 掉线进程被杀、机器宕机、网络隔离Prometheus 抓取失败重试后打标。job 或 target 被动态摘除consul/kubernetes 服务发现把实例摘掉Prometheus 对残留序列打标。序列的 label 集合发生变化比如 Kubernetes Pod 重建后 IP 变了或者 exporter 暴露的 label 有动态部分如pod、instance旧 label 组合的序列无人继续写就会被标记 stale。exporter 返回 200 但指标集为空很多人只盯状态码不看样本数变化。样本数从有到无Prometheus 会认为序列“没有消失但也不再更新”在这种情况下如果 scrape 解析后样本数为 0TSDB 会像抓取失败一样对这些序列进行 stale 处理。我在 4.1 节有个完整例子。3. 隔离机制块存储与并发查询的底层博弈3.1 2.0 的存储底座head 块 不可变 block隔离机制的实现跟存储结构绑定得很紧。Prometheus 2.0 的 TSDB 大致分两层Head唯一可写区域数据先写到内存里的 chunk也就是 head chunks。这里的数据还没落盘或者只通过 WAL 落了一部分。Blockhead chunks 里的数据到达一定时间跨度默认 2 小时后会被刷成一个不可变的 block 目录。block 里的索引、数据、元数据都是只读的。block 不可变是个很关键的属性所有针对 block 的修改都是“创建新版本”而不是“就地改旧文件”。这就给隔离机制打下了基础。查询一个旧 block 时没有被任何写入操作打断的风险因为根本没人会改它。3.2 查询侧的 MVCC 快照读到“启动查询那一刻”的视图Head 区域是动态的每秒都有新样本追加上来。如果一个长时间运行的 range query 在扫描 Head 期间head 的 chunk 不断追加数据查询结果会变成什么不同时间点看到不同状态数据自然不一致。Prometheus 2.0 用自己的方式实现了类似 MVCC多版本并发控制的效果。查询开始时TSDB 会为 Head 打一个快照不是把所有样本复制一份而是把当前 head 里各 chunk 的可读状态固定下来。后续新追加的样本会写到同一个 chunk 的未读部分也可能触发 chunk 切换但已经开始的查询只认查询启动时记录的 chunk 集合和偏移量。你可以这么理解Head 像一个活页文件夹查询开始时我拿手机拍了一张当前目录照片之后往文件夹里塞再多新页我读的时候还是按照片上的目录去读。写入方不需要等查询读完才能继续写查询也不需要等写入暂停。二者各干各的不互相阻塞。3.3 压缩过程如何做到不打断查询Head 数据刷成 block 后后台还有一个角色叫压缩机Compactor。它会把多个小 block 合并成一个大 block目的是减少查询时需要打开的文件数。比如几十个 2 小时 block 合并成 12 小时甚至 2 天的 block。压缩过程最怕什么怕查询正在读旧 block结果旧 block 被压缩删掉了。Prometheus 的做法是压缩器在临时目录里写好新的 block。校验新 block 的索引和 CRC。原子性地把新 block 移动到数据目录。旧 block 的删除不是立即执行而是通过块引用计数refcount控制。查询端在打开一个 block 时会对它增加引用计数。只要还有查询在引用旧 block压缩器就不会真正释放底层文件。因此查询看到的 block 版本在查询生命周期内是稳定的。这也是为什么你在压缩期间跑一个大范围聚合查询有时能感觉到延迟升高——不是查询被阻塞了而是同一批旧文件还被查询引用着压缩器得等引用归零后才能真正清理IO 压力叠加导致的体感延迟。3.4 WAL 重放与 head 切换中的隔离Prometheus 进程启动时会从 WAL 把最近几个小时的数据重放进 head。这个过程中如果查询已经开始会不会读到“重放一半”的状态不会。因为在 WAL 重放完成后Head 的读取接口才会被标记为可用在那之前TSDB 对外返回的 Querier 会阻塞或返回空。重放期间对 Head 的写入也处于一个受控状态。实际操作中Prometheus 启动后前几秒查询确实可能返回空或部分结果这个“冷启动窗口”就是 WAL 重放和 head 初始化的阶段这是正常的。另外head 内部还会周期性地把内存 chunk 切换为只读 chunk称为cut同时新开一个可写 chunk。切换的瞬间查询端拿到的 chunk 快照只包含已写入部分。Appender 端的写入也通过 chunk 粒度的锁来保护保证同一 chunk 的读写不会交叉进入半提交状态。3.5 隔离机制的边界不是数据库事务要特别强调一点Prometheus 的隔离是存储层查询隔离不是跨查询的数据库事务隔离。它保证的是一个查询在运行期间所有 block 和 head 的数据视图一致。查询不会看到“半压缩”或“半写入”的中间状态。它不保证的是两个先后发起的查询一定看到完全相同的结果。因为第二次查询开始时数据已经更新了这是“不同时间点的正确快照”不是一致性问题。跨多个 API 调用的报表查询之间的一致性。比如你先查了up再查node_load1中间有数据写入两次结果对应的时间点虽然相同但数据版本可能不同。这在监控场景里完全可接受因为监控查的是趋势而不是账本。4. 陈旧性与隔离机制联动后的真实影响4.1 告警规则的“消失即恢复”语义数据陈旧性对告警的影响可能比你想的更大。拿节点存活告警举例- alert: NodeDown expr: up{jobnode-exporter} 0 for: 2m如果 target 直接掉线Prometheus 抓取失败后会给up序列打 stale 标记。此时up{jobnode-exporter}这个序列的结果不再存在表达式up{jobnode-exporter} 0的结果也随之消失。规则引擎在下一轮评估时会因为这个序列“无数据”而走恢复逻辑发出Resolved通知。听到这里你可能会问“不对吧target 挂了up不应该变成 0 吗怎么反而判定为恢复”这是新手最容易踩的坑。up指标是 Prometheus 在抓取成功时才写入的1抓取失败时它不会写0而是直接不写样本。所以 target 挂了以后up序列是消失而不是变成 0。这个消失动作由陈旧标记推动速度比我预想的快得多。我在线上还观察到一个细节如果告警规则的查询里有or on() vector(0)之类的兜底把“无数据”映射成 0那么 target 掉线后告警反倒不会恢复因为表达式一直能算出 0。这是很多人“配置了告警不恢复”的另一个常见原因。4.2 短生命周期任务的曲线为什么“戛然而止”Kubernetes 里的短 Pod、Spark 批处理任务、AWS Lambda 上报的指标这些对象生命周期很短。它们上报完最后一批指标进程就退出了。如果没有陈旧标记查询引擎只能等 lookback delta 窗口滑过也就是说曲线会在最后数据点之后继续残留 5 分钟给看板的人造成“任务可能还活着”的错误暗示。有了陈旧标记Prometheus 会在抓取失败或 target 消失时快速关闭这些序列。你看到的曲线通常是正常波动然后突然断掉没有拖尾。这不是数据丢失而是序列生命周期被正确表达了。但要注意陈旧标记是服务端行为exporter 或者 pushgateway 是否配合“优雅退出”也很重要。有些 exporter 在进程退出前会主动请求 Prometheus 不要打标实际是通过 HTTP 响应末尾的X-Prometheus-...之类不过多数情况下 Prometheus 自己就能感知 target 失联。4.3 查询窗口与 step 边界出现的空洞隔离机制保证了查询视图一致但不会帮你填数据。陈旧标记加上 range query 的 step 语义会造成一种看起来像“数据坑”的现象。比如你用 Grafana 拉一个 1 小时间范围的数据step 默认按像素计算可能是 60 秒。查询引擎在每一个 step 上都会尝试选择该时刻往前 5 分钟内的最新样本。如果某个 step 落在一个陈旧标记之后、下一条样本出现之前这个 step 就取不到数据返回空。我遇到过的最典型场景节点每隔 5 分钟执行一次短任务上报指标短任务结束后很快消失下一次任务可能 label 相同但间隔不固定。在 Grafana 上看到的就是一条每隔几个点就缺一块的虚线。后来我用max_over_time(metric[10m])做平滑才把空点补齐。如果你也碰到类似问题可以先确认是不是 step 踩到了陈旧窗口。4.4 Recording Rule 产物不受 stale 直接影响有一个隐藏较深的行为Recording Rule 产生的合成序列不会被陈旧标记直接标记。因为合成序列不是 scrape 循环写的TSDB 没有它的“抓取目标”也就不会主动给它打标。它只能靠 lookback delta 自然过期。举个例子你给业务指标建了一条 recording rule每 30 秒计算一次sum(rate(http_requests_total[5m]))生成job:http_requests:rate5m。如果底层某个序列被打标消失合成序列并不会立刻消失而是会继续存在一段时间直到它的最后生成时间超过 lookback delta 才会查不到。这在报警场景下会造成一点点别扭源指标没了报警规则命中的却是 recording rule 的合成序列报警会比预期晚几分钟才进入恢复逻辑。所以在设计报警链路时我倾向于对可以直接查询源指标的报警不要过度依赖 recording rule尤其是那些需要“快速感知数据消失”的告警。5. 我踩过的几个坑及排查链路记录5.1 坑一target 都还活着曲线却断成虚线某次线上事故业务反馈监控面板上几个核心节点的node_cpu_seconds_total曲线出现大量断点但 Prometheus 的 Target 页面全部显示 UP抓取状态也是绿色的。这让我一度怀疑是网络或者采集配置问题。排查链路是查 Prometheus 日志没有 scrape 错误。用 PromQL 直接取最近 10 分钟的样本发现确实有缺口。查看scrape_samples_scraped发现缺口时间段内这个指标变成了 0。最后定位到 exporter 版本升级后在特定条件下会返回Content-Type: application/openmetrics-text但 body 里的样本集合不完整Prometheus 成功解析了 HTTP 200 响应但样本数为 0随即对原有序列打了 stale 标记。这说明一个特别容易忽略的事实HTTP 200 不意味着“序列还在”。只要一次 scrape 的样本集合为空Prometheus 就会对之前存在的序列做 stale 处理。后来我们在 exporter 侧检查了版本变更回滚才解决。5.2 坑二range query 最后一个点经常没数据Grafana 看板默认查询区间是[now - 6h, now]。如果查询的 step 落在当前这一分钟内而该 step 对应的“最新样本”还没有被 Prometheus 写进去或者距离查询时刻超过 lookback delta就会出现最后一个点缺失。我在 20 多台节点的集群上专门观察过Grafana 面板最后一个点掉线的概率跟 scrape interval 成正比。scrape interval 越小最后一个点有数据的概率越高但无论如何都有一定的随机性。缓解办法很简单在 Grafana 查询里把时间范围改成now - 1m给写入留出余量。调整--query.lookback-delta到更大值。比如从 5m 改成 10m。但要注意改大这个参数会让“序列是否活着”的判断变迟钝报警和面板都会晚 5 分钟发现数据消失需要权衡。如果你既想解决最后一个点缺失又不想影响 stale 判定个人建议优先用方法一不要把 lookback delta 盲目调大。5.3 坑三压缩期间的查询延迟突增凌晨 2 点左右PromQL 查询延迟从几十毫秒飙到十几秒。通过/debug/tsdb页面看到blocks列表里同时有好几个压缩任务在执行磁盘 IO 接近打满。压缩本身是后台任务但它会对查询产生两类压力压缩需要读取大量旧 block 文件会把系统 page cache 打得比较乱查询需要读的文件块可能被挤出缓存。压缩前后会创建和删除很多临时文件block 列表频繁变化查询端的 block 快照需要去获取和释放引用竞争也会变多。我当时改成把压缩时间窗口限制在凌晨 4 点到 6 点并且把--storage.tsdb.max-block-duration调大让 block 数量保持在一个更小集合查询需要扫描的文件数变少延迟明显回落。5.4 调试与验证的通用工具箱最后分享几个我常用的小工具和命令遇到陈旧性、隔离相关的问题时可以快速确认状态promtool tsdb dump wal查看 WAL 里的样本记录能看到带特殊 NaN 值模式的陈旧标记。promtool tsdb dump block dir查看历史 block 里的数据确认陈旧标记不会出现在真实数据文件里。/debug/tsdb查看 head 状态、chunk 数量、block 列表、压缩进展。GET /api/v1/query?queryup{jobxxx}timets精确指定某个时间点查数据验证陈旧标记生效边界。我这里还想多说一句市面上很多针对 Prometheus 的“性能优化”方案动不动就让你调大lookback delta或者关掉压缩但实际上大部分问题来自对陈旧性和隔离机制的理解偏差。把这两个机制摸透很多监控疑难杂症根本不需要调参就能定位。个人建议所有告警规则都加上一段“至少连续两轮无数据才恢复”的防抖逻辑否则陈旧标记会让恢复通知变得异常灵敏半夜误报比不报更折磨人。