一、问题背景:高并发查询时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"})),底层要经历这几步:
- 解析查询里的标签条件,比如
service="user-center"; - 拿着标签条件去label索引里查,拿到所有符合条件的series ID列表;
- 拿着series ID列表去TSDB的原始数据里查对应的时间序列数据;
- 计算并返回结果。 锁竞争主要出现在第二步:查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 锁竞争的典型场景
- 高并发写+高并发读:业务高峰时,监控的指标采集频率变高,大量新的series被写入,同时又有大量查询触发,写锁和读锁互相争抢,导致双方都变慢。
- 大标签值的查询:如果一个标签值对应的series ID特别多(比如
env="prod"对应所有生产环境的series),查询时加读锁的时间会变长,其他查询抢锁的等待时间也会变长。 - 历史数据的查询:Prometheus的TSDB会按时间分片存储数据,每个分片都有自己的label索引,查历史数据时要遍历多个分片的索引,加锁的次数变多,锁竞争的概率也会变大。
4.2 锁竞争的优缺点
早期Prometheus用全局读写锁的设计,优点是实现简单,能保证索引的一致性,不会出现数据错乱的情况;但缺点也非常明显:锁的粒度太粗,高并发下的性能瓶颈非常突出,一旦锁竞争加剧,整个系统的查询性能会急剧下降。
4.3 注意事项
- 不要随意调整锁的配置:Prometheus的锁逻辑是和TSDB的存储结构绑定的,随意调整锁的粒度或类型,可能会导致索引不一致,出现查询结果错误甚至数据丢失的情况。
- 避免过度拆分标签:很多开发者为了更细粒度的监控,会加很多标签,导致series的数量爆炸式增长,不仅会增加存储的压力,还会让label索引的体积变大,锁竞争的概率也会变高。
- 监控锁竞争的指标: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 应用场景
锁竞争导致的查询卡顿,主要出现在以下场景:
- 高并发的监控系统:比如大型互联网公司的监控系统,每天要处理上亿条指标数据,同时要支持大量的查询请求。
- 大促或业务高峰:比如电商的双11、618,业务量暴增,监控的指标采集和查询请求也会暴增。
- 历史数据的查询:比如查询一个月甚至更长时间的历史数据,需要遍历多个时间分片的索引,锁竞争的概率会更高。
6.2 总结
Prometheus TSDB的label索引与series查找的锁竞争,是高并发场景下的典型性能瓶颈,核心原因是早期的锁设计粒度太粗,高并发下的锁争抢导致查询变慢。通过优化锁的粒度、增加索引副本、调整查询并发数、定期优化索引等方法,可以有效缓解锁竞争的问题。 对于开发者来说,要想避免这个问题,首先要理解Prometheus的底层原理,其次要根据自己的业务场景,合理配置Prometheus的参数,优化标签的设计,同时要监控系统的锁竞争指标,及时发现和解决问题。
Comments