一、问题背景:高并发查询时Prometheus突然卡成“蜗牛”

做过监控系统的开发者,大概率都踩过Prometheus的坑:平时查指标快得很,一到业务高峰期(比如大促、凌晨定时任务批量触发),明明服务器CPU、内存都没满,查询却慢得离谱,甚至直接超时。很多人第一反应是加内存、扩CPU,结果钱花了问题没解决——其实大部分时候,罪魁祸首是Prometheus底层TSDB(时间序列数据库)里,label索引和series查找时的锁竞争。 要讲明白这个问题,得先搞懂Prometheus最基础的两个概念:label和series。label就是给指标贴的“标签”,比如监控接口响应时间的指标,会有service(服务名)、endpoint(接口路径)、env(环境)这些标签;series则是由所有标签组成的唯一“时间序列”,比如http_request_duration_seconds{service="user-center",endpoint="/login",env="prod"}就是一条series。 Prometheus要实现“按标签查指标”的核心功能,就得有个索引:把标签和对应的series关联起来,就像图书馆的索引卡,把“书名”和“书架位置”对应起来。但这个索引的读写、查找过程,一旦碰到高并发,就容易出现“大家都抢一把锁”的情况,导致查询卡顿。

二、核心原理:label索引与series查找的锁逻辑

2.1 label索引的底层结构

Prometheus的label索引本质是一个“倒排索引”:把每个标签值作为键,对应所有包含这个标签值的series ID(series ID是每个series的唯一标识,类似身份证号)。举个例子,标签service="user-center"对应的倒排索引项,会存所有服务为用户中心的series的ID。 为了保证这个索引的一致性,Prometheus在做两种操作时会加锁:一是写操作(比如新增series、更新标签),二是读操作(比如按标签查series)。早期的Prometheus版本用的是一把全局的读写锁:写锁是排他的(同一时间只能有一个写操作),读锁是共享的(同一时间可以有多个读操作)。 但这个设计在高并发下的问题很明显:如果有大量写操作(比如业务高峰时新增了很多series),写锁会一直占着,所有读操作(查询)都得等;就算是读操作,当并发量足够大时,读锁的争抢也会让每个查询的等待时间变长。

2.2 series查找的完整流程

一个典型的带标签的PromQL查询(比如sum(http_request_duration_seconds{service="user-center"})),底层要经历这几步:

  1. 解析查询里的标签条件,比如service="user-center"
  2. 拿着标签条件去label索引里查,拿到所有符合条件的series ID列表;
  3. 拿着series ID列表去TSDB的原始数据里查对应的时间序列数据;
  4. 计算并返回结果。 锁竞争主要出现在第二步:查label索引的时候。如果此时正好有大量写操作在更新索引,或者大量读操作在同时查索引,锁的争抢就会导致这一步的耗时从几毫秒变成几百毫秒甚至几秒,整个查询自然就卡了。

三、问题复现:用示例直观感受锁竞争

为了让大家直观看到锁竞争的影响,我们用Go语言(Prometheus本身就是用Go写的,示例用Go能最大程度还原场景)写一个简化版的label索引和series查找逻辑,模拟高并发下的锁竞争。 技术栈:Go 1.20+

package main

import (
	"fmt"
	"sync"
	"time"
)

// LabelIndex 简化版的label索引结构
type LabelIndex struct {
	// 标签值到series ID列表的映射
	index map[string][]uint64
	// 读写锁:早期Prometheus的全局锁
	rwMu sync.RWMutex
}

// NewLabelIndex 初始化索引
func NewLabelIndex() *LabelIndex {
	return &LabelIndex{
		index: make(map[string][]uint64),
	}
}

// AddSeries 新增series:写操作,需要加写锁
func (li *LabelIndex) AddSeries(labelValue string, seriesID uint64) {
	// 加写锁,排他锁
	li.rwMu.Lock()
	defer li.rwMu.Unlock()

	// 新增series ID到索引
	li.index[labelValue] = append(li.index[labelValue], seriesID)
	fmt.Printf("新增series:标签值=%s,seriesID=%d\n", labelValue, seriesID)
}

// FindSeries 按标签查series:读操作,需要加读锁
func (li *LabelIndex) FindSeries(labelValue string) []uint64 {
	// 加读锁,共享锁
	li.rwMu.RLock()
	defer li.rwMu.RUnlock()

	// 模拟查询耗时:实际Prometheus会做索引匹配
	time.Sleep(1 * time.Millisecond)
	return li.index[labelValue]
}

func main() {
	li := NewLabelIndex()
	// 先初始化一些数据:100个标签值,每个标签值对应1000个series ID
	for i := 0; i < 100; i++ {
		labelValue := fmt.Sprintf("service-%d", i)
		for j := 0; j < 1000; j++ {
			seriesID := uint64(i*1000 + j)
			li.AddSeries(labelValue, seriesID)
		}
	}

	// 模拟高并发场景:1000个查询同时触发
	var wg sync.WaitGroup
	startTime := time.Now()

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			// 随机选一个标签值查询
			labelValue := fmt.Sprintf("service-%d", i%100)
			seriesList := li.FindSeries(labelValue)
			// 打印查询结果数量,确认正常
			fmt.Printf("查询标签值=%s,返回series数量=%d\n", labelValue, len(seriesList))
		}(i)
	}

	wg.Wait()
	// 打印总耗时
	fmt.Printf("1000个查询总耗时:%s\n", time.Since(startTime))
}

运行这个示例,你会发现1000个查询的总耗时远超过1秒(理想情况下,每个查询1毫秒,1000个并行查询总耗时应该接近1毫秒)。这就是锁竞争导致的:每个查询要抢读锁,并发量一大,抢锁的等待时间就会叠加。 我们可以做个对比实验:把FindSeries里的读锁去掉(不考虑一致性的情况),总耗时会降到10毫秒以内,差距非常明显。

四、问题分析:锁竞争的具体场景与影响

4.1 锁竞争的典型场景

  1. 高并发写+高并发读:业务高峰时,监控的指标采集频率变高,大量新的series被写入,同时又有大量查询触发,写锁和读锁互相争抢,导致双方都变慢。
  2. 大标签值的查询:如果一个标签值对应的series ID特别多(比如env="prod"对应所有生产环境的series),查询时加读锁的时间会变长,其他查询抢锁的等待时间也会变长。
  3. 历史数据的查询:Prometheus的TSDB会按时间分片存储数据,每个分片都有自己的label索引,查历史数据时要遍历多个分片的索引,加锁的次数变多,锁竞争的概率也会变大。

4.2 锁竞争的优缺点

早期Prometheus用全局读写锁的设计,优点是实现简单,能保证索引的一致性,不会出现数据错乱的情况;但缺点也非常明显:锁的粒度太粗,高并发下的性能瓶颈非常突出,一旦锁竞争加剧,整个系统的查询性能会急剧下降。

4.3 注意事项

  1. 不要随意调整锁的配置:Prometheus的锁逻辑是和TSDB的存储结构绑定的,随意调整锁的粒度或类型,可能会导致索引不一致,出现查询结果错误甚至数据丢失的情况。
  2. 避免过度拆分标签:很多开发者为了更细粒度的监控,会加很多标签,导致series的数量爆炸式增长,不仅会增加存储的压力,还会让label索引的体积变大,锁竞争的概率也会变高。
  3. 监控锁竞争的指标:Prometheus本身提供了prometheus_tsdb_lock_wait_seconds这个指标,可以用来监控锁等待的时间,如果这个指标的数值持续升高,说明系统已经出现了锁竞争的问题。

五、优化方案:缓解锁竞争的几种常用方法

5.1 优化锁的粒度

Prometheus从2.0版本开始,就逐步优化了锁的粒度:把原来的全局读写锁,改成了按标签值分片的锁,每个标签值对应一把锁,这样不同标签值的查询和写入就不会互相争抢锁了。 比如,查询service="user-center"service="order-center"的操作,会分别加两把不同的锁,互不影响,大大降低了锁竞争的概率。

5.2 增加索引的副本

对于一些经常被查询的标签值(比如env="prod"),可以增加索引的副本,每个副本对应一把锁,这样多个查询可以同时加不同副本的锁,提高并发度。

5.3 调整查询的并发数

很多时候,查询卡顿不是因为Prometheus本身的性能不够,而是因为查询的并发数太高,超过了系统的承受能力。可以通过调整查询的并发数(比如在Prometheus的配置里设置query.max-concurrency参数),来减少锁竞争的概率。

5.4 定期优化索引

Prometheus的TSDB会定期做压缩操作,把多个小的时间分片合并成一个大的分片,同时也会优化label索引的结构,减少锁的争抢。可以通过调整压缩的频率(比如设置storage.tsdb.compression参数),来平衡性能和存储的压力。

六、应用场景与总结

6.1 应用场景

锁竞争导致的查询卡顿,主要出现在以下场景:

  1. 高并发的监控系统:比如大型互联网公司的监控系统,每天要处理上亿条指标数据,同时要支持大量的查询请求。
  2. 大促或业务高峰:比如电商的双11、618,业务量暴增,监控的指标采集和查询请求也会暴增。
  3. 历史数据的查询:比如查询一个月甚至更长时间的历史数据,需要遍历多个时间分片的索引,锁竞争的概率会更高。

6.2 总结

Prometheus TSDB的label索引与series查找的锁竞争,是高并发场景下的典型性能瓶颈,核心原因是早期的锁设计粒度太粗,高并发下的锁争抢导致查询变慢。通过优化锁的粒度、增加索引副本、调整查询并发数、定期优化索引等方法,可以有效缓解锁竞争的问题。 对于开发者来说,要想避免这个问题,首先要理解Prometheus的底层原理,其次要根据自己的业务场景,合理配置Prometheus的参数,优化标签的设计,同时要监控系统的锁竞争指标,及时发现和解决问题。