프로 메테우스는 요즘 가장 인기있는 오픈 소스 모니터링 솔루션입니다, 우리는 쉽게 프로 메테우스 신속하게 캡처, 저장, 조회 및 알람을 포함 모니터링 지표의 완전한 세트를 구축 할 수있는 핵심 모니터링 시스템 등. 환경에서 빠른 쿼리 데이터 수집에 대한 지원, 그리고는 Kubernetes 잡아 개체에 대한 빈번한 변경, 프로 메테우스가 최선의 선택을하는 동안 프로 메테우스는 초당 샘플 수백만의 단일 인스턴스를 달성 할 수있을 것입니다. 물론, 이러한 우수한 특성이 좋은 데이터베이스 설계 타이밍의 지원에서 분리 될 수 없다 달성했다. 설계와 프로 메테우스의 데이터베이스 TSDB 타이밍을 구현할이 문서는 분석이 읽기 및 아키텍처 설계, 코드 레벨에서 프로 메테우스 강력한 쓰기 성능을 지원 이유를 이해하기 위해 만들었습니다.
시계열 데이터의 1. 개요
프로 메테우스는 일반 데이터 객체와 비교하여 데이터를 읽고 쓸 수있는 타이밍이다, 데이터 계열은 고유의 특색을 가지고, 많은 대상으로 디자인 및 최적화의 TSDB. 그래서 이해는 프로 메테우스 시리즈 데이터 스토리지 모델을 이해하는 첫 번째 단계입니다. 그것은 일반적으로 두 개의 샘플 데이터와 조성의 식별 구성
标识 -> {(t0, v0), (t1, v1), (t2, v2), (t3, v3)...}
- 식별자는 일반적으로 고유 시계열의 일련의 식별 프로 메테우스 + 라벨 인덱스 이름에 사용되는 다양한 모니터링 인덱스를 구별하는 데 사용됩니다. 프로 메테우스가 시계열로 캡처
http_request_total
인덱스 이름, HTTP 요청의 총 수를 나타내며, 그path
와method
두 개의 레이블 경로와 다양한 요청을 나타 내기위한 방법.
http_request_total{path="/", method="GET"} -> {(t0, v1), (t1, v1)...}
사실 고유 라벨이 저장 될 때 마지막 인덱스 이름, 그것은 중요합니다 __name__
다음과 같이. 데이터베이스에 저장 프로 메테우스 마지막 시간 순서 ID는 라벨의 무리입니다. 우리는 호출이 힙 레이블을 참조 series
.
{__name__="http_request_total", path="/", method="GET"}
- 많은 샘플 샘플링 포인트에 의한 데이터 (메테우스 샘플이라고 함) 구성, T0, T1, T2 ...는 샘플 수집 시간, V0, V1을 나타내고, V2 ... 시간 인덱스 값 수집을 나타낸다. 샘플링 시간은 일반적으로 일정하게 증가되고, 인접한 동일한 샘플 간격 종종 메테우스 기본값은 15 초이다. 그리고 일반적으로 인접한 샘플 인덱스 값 v 너무 많이 다르지 않다. 특성 측정 데이터에 기초하여, 효율적 압축 전적으로 가능 수행 저장한다. 샘플링 데이터 압축 알고리즘 메테우스의 참조 데이터베이스 한국어 타이밍 고릴라 이 방법에 의해 실제로, 저장 공간의 137 바이트의 샘플 평균의 16 바이트.
2. 건축 설계
아주 강한 시간에 민감한 데이터 유형을 데이터를 모니터링하는, 그것은 시간이 지남에 따라 조회 할 수 있습니다 가열 이하로 계속뿐만 아니라 액세스의 모니터링 지표 일반적으로, 예를 들어, 마지막 15 분 기간을 지정하고 최근 시간, 등등 과거의 하루. 전년도와 짝수 월 데이터에 액세스하는 동안 일반적으로, 수집 된 데이터의 마지막 시간은 종종 상황 표시의 전반적인 변동성을 이해하는 데 사용할 액세스되는 지난 날의 데이터를 가장 자주 액세스 할 수있다 의미는 매우 큰되지 않습니다.
모니터링 데이터의 위의 특성을 바탕으로, TSDB 설계는 다음과 같이 전체적인 구조는 이해하기 매우 쉽습니다 :
최신 데이터 수집을 위해, 프로 메테우스는 직접 메모리에 저장하여 읽기 속도를 향상하고 데이터를 기록합니다. 그러나 메모리 공간이 제한되어 있지만, 시간이 지남에 따라, 액세스되는 데이터의 이전 부분에 메모리의 가능성은 점차 감소된다. 따라서, 기본적으로, 매 2 시간, 프로 메테우스가 될 것 "오래된"데이터의 일부가 디스크에 지속되고, 별도의 블록 디스크에 저장된 모든 영구 데이터. BLOCK0 상기 예시도 메테우스 모든 모니터링 데이터 획득주기를 [T0, T1]을 저장하기 위해. 이것의 장점은 수, 분명 우리는 범위 [T0, T2]의 데이터 인덱스에 대한 액세스 권한을 찾으려면 크게 다음은 데이터 블록 0 및 블록 1과에보고를로드 할 필요가 감소되도록 범위는 이에 쿼리의 속도를 증가시킨다.
虽然最近采集的数据存放在内存中能够提高读写效率,但是由于内存的易失性,一旦Prometheus崩溃(如果系统内存不足,Prometheus被OOM的概率并不算低)那么这部分数据就彻底丢失了。因此Prometheus在将采集到的数据真正写入内存之前,会首先存入WAL(Write Ahead Log)
中。因为WAL
是存放在磁盘中的,相当于对内存中的监控数据做了一个完全的备份,即使Prometheus崩溃这部分的数据也不至于丢失。当Prometheus重启之后,它首先会将WAL
的内容加载到内存中,从而完美恢复到崩溃之前的状态,接着再开始新数据的抓取。
3. 内存存储结构
在Prometheus的内存中使用如下所示的memSeries
结构存储时间序列,一条时间序列对应一个memSeries
结构:
可以看到,一个memSeries
主要由三部分组成:
lset
:用以识别这个series
的label集合ref
:每接收到一个新的时间序列(即它的label集合与已有的时间序列都不同)Prometheus就会用一个唯一的整数标识它,如果有ref
,我们就能轻易找到相应的series
memChunks
:每一个memChunk
是一个时间段内该时间序列所有sample
的集合。如果我们想要读取[tx, ty](t1 < tx < t2, t2 < ty < t3 )时间范围内该时间序列的数据,只需要对[t1, t3]范围内的两个memChunk
的sample
数据进行裁剪即可,从而提高了查询的效率。每当采集到新的sample
,Prometheus就会用Gorilla中类似的算法将它压缩至最新的memChunk
中
但是ref
仅仅是供Prometheus内部使用的,如果用户要查询某个具体的时间序列,通常会利用一堆的label用以唯一指定一个时间序列。那么如何通过一堆label最快地找到对应的series
呢?哈希表显然是最佳的方案。基于label计算一个哈希值,维护一张哈希值与memSeries
的映射表,如果产生哈希碰撞的话,则直接用label进行匹配。因此,Prometheus有必要在内存中维护如下所示的两张哈希表,从而无论利用ref
还是label
都能很快找到对应的memSeries
:
{
series map[uint64]*memSeries // ref到memSeries的映射
hashes map[uint64][]*memSeries // labels的哈希值到memSeries的映射 }
然而我们知道Golang中的map并不是并发安全的,而Prometheus中又有大量对于memSeries
的增删操作,如果在读写上述结构时简单地用一把大锁锁住,显然无法满足性能要求。所以Prometheus用了如下数据结构将锁的控制精细化:
const stripSize = 1 << 14
// 为表达直观,已将Prometheus原生数据结构简化
type stripeSeries struct { series [stripeSize]map[uint64]*memSeries hashes [stripeSize]map[uint64][]*memSeries locks [stripeSize]sync.RWMutex }
Prometheus将一整个大的哈希表进行了切片,切割成了16k个小的哈希表。如果想要利用ref
找到对应的series
,首先要将ref对16K取模,假设得到的值为x,找到对应的小哈希表series[x]
。至于对小哈希表的操作,只需要锁住模对应的locks[x]
,从而大大减小了读写memSeries
时对锁的抢占造成的损耗,提高了并发性能。对于基于label
哈希值的读写,操作类似。
然而上述数据结构仅仅只能支持对于时间序列的精确查询,必须严格指定每一个label的值从而能够唯一地确定一条时间序列。但很多时候,模糊查询才是更为常用的。例如,我们想知道访问路径为/
的各类HTTP请求的数目(请求的方法可以为GET
,POST
等等),此时提交给Prometheus的查询条件如下:
http_request_total{path="/"}
如果路径/
曾经接收了GET
,POST
以及DELETE
三种方法的HTTP请求,那么此次查询应该返回如下三条时间序列:
http_request_total{path="/", method="GET"} ....
http_request_total{path="/", method="POST"} ....
http_request_total{path="/", method="DELETE"} ....
Prometheus甚至支持在指定label时使用正则表达式:
http_request_total{method="GET|POST"}
上述查询将返回所有包含label名为method
,且值为GET
或者POST
的指标名为http_request_total
的时间序列。
针对如此复杂的查询需求,暴力地遍历所有series进行匹配是行不通的。一个指标往往会包含诸多的label,每个label又可以有很多的取值。因此Prometheus中会存在大量的series
,为了能快速匹配到符合要求的series
,Prometheus引入了倒排索引,结构如下:
struct MemPostings struct {
mtx sync.Mutex
m map[string]map[string][]uint64 ordered bool }
当Prometheus抓取到一个新的series,假设它的ref
为x,包含如下的label pair:
{__name__="http_request_total", path="/", method="GET"}
在初始化相应的memSeries
并且更新了哈希表之后,还需要对倒排索引进行刷新:
MemPostings.m["__name__"]["http_request_total"]{..., x ,...} MemPostings.m["path"]["/"]{..., x ,...} MemPostings.m["method"]["GET"]{..., x, ...}
可以看到,倒排索引能够将所有包含某个label pair的series
都聚合在一起。如果要得到匹配多个label pair的series
,只要将每个label pair包含的series
做交集即可。对于查询请求
http_request_total{path="/"}
的匹配过程如下:
MemPostings.["__name__"]["http_request_total"]{3, 4, 2, 1}
MemPostings.["path"]["/"]{5, 4, 1, 3}
{3, 4, 2, 1} x {5, 4, 1, 3} -> {1, 3, 4}
但是如果每个label pair包含的series
足够多,那么对多个label pair的series
做交集也将是非常耗时的操作。那么能不能进一步优化呢?事实上,只要保持每个label pair里包含的series有序就可以了,这样就能将复杂度从指数级瞬间下降到线性级。
MemPostings.["__name__"]["http_request_total"]{1, 2, 3, 4}
MemPostings.["path"]["/"]{1, 3, 4, 5}
{1, 2, 3, 4} x {1, 3, 4, 5} -> {1, 3, 4}
Prometheus内存中的存储结构大致如上,Gorilla的压缩算法提高了samples
的存储效率,而哈希表以及倒排索引的使用,则对Prometheus复杂的时序数据查询提供了高效的支持。
WAL
Prometheus启动时,往往需要在参数中指定存储目录,该目录包含WAL
以及用于持久化的Block,结构如下:
# ls
01DJQ428PCD7Z06M6GKHES65P2 01DJQAXZY8MPVWMD2M4YWQFD9T 01DJQAY7F9WT8EHT0M8540F0AJ lock wal
形如01DJQ428PCD7Z06M6GKHES65P2
都是Block目录,用于存放持久化之后的时序数据,这部分内容后文会有详细的叙述,本节重点关注WAL
目录,它的内部结构如下:
[wal]# ls -lht
total 596M
-rw-r--r-- 1 65534 65534 86M Aug 20 19:32 00000012
drwxr-xr-x 2 65534 65534 4.0K Aug 20 19:00 checkpoint.000006
-rw-r--r-- 1 65534 65534 128M Aug 20 19:00 00000011
-rw-r--r-- 1 65534 65534 128M Aug 20 18:37 00000010
-rw-r--r-- 1 65534 65534 128M Aug 20 17:47 00000009
-rw-r--r-- 1 65534 65534 128M Aug 20 17:00 00000008
-rw-r--r-- 1 65534 65534 128M Aug 20 16:38 00000007
WAL
目录中包含了多个连续编号的且大小为128M的文件,Prometheus称这样的文件为Segment
,其中存放的就是对内存中series
以及sample
数据的备份。另外还包含一个以checkpoint
为前缀的子目录,由于内存中的时序数据经常会做持久化处理,WAL
中的数据也将因此出现冗余。所以每次在对内存数据进行持久化之后,Prometheus都会对部分编号靠后的Segment
进行清理。但是我们并没有办法做到恰好将已经持久化的数据从Segment
中剔除,也就是说被删除的Segment
中部分的数据依然可能是有用的。所以在清理Segment
时,我们会将肯定无效的数据删除,剩下的数据就存放在checkpoint
中。而在Prometheus重启时,应该首先加载checkpoint
中的内容,再按序加载各个Segment
的内容。
那么series
和sample
在Segment
中是如何组织的呢?在将时序数据备份到WAL
的过程中,由于涉及到磁盘文件Segment
的写入,批量操作显然是最经济的。对于批量写入,Prometheus提供了一个名为Appender
的接口如下:
type Appender interface {
Add(l labels.Labels, t int64, v float64) (uint64, error) AddFast(ref uint64, t int64, v float64) error Commit() error Rollback() error }
每当需要写入数据时,就要创建一个Appender
,Appender
是一个临时结构,仅供一次批量操作使用。一个Appender
类似于其他数据库中事务的概念,通过Add()
或者AddFast()
添加的时序数据会临时在Appender
中进行缓存,只有在最后调用Commit()
之后,这批数据才正式提交给Prometheus,同时写入WAL
。而如果最后调用的Rollback()
,则这批数据的samples
会被全部丢弃,但是通过Add()
方法新增的series
结构则依然会被保留。
series
和sample
在Appender
中是分开存储的,它们在Appender
中的结构如下:
// headAppender是Appender的一种实现
type headAppender struct {
...
series []RefSeries
samples []RefSample } type RefSeries struct { Ref uint64 Labels labels.Labels } type RefSample struct { Ref uint64 T int64 V float64 series *memSeries }
当调用Appender
的Commit()
方法提交这些时序数据时,series
和samples
这两个切片会分别编码,形成两条Record
,如下所示:
|RecordType|RecordContent|
RecordType可以取“RecordSample”或者“RecordSeries”,表示这条Record的类型
RecordContent则根据RecordType可以series或者samples编码后的内容
最后,series
和samples
以Record
的形式被批量写入Segment
文件中,默认当Segment
超过128M时,会创建新的Segment
文件。若Prometheus因为各种原因崩溃了,WAL
里的各个Segment
以及checkpoint
里的内容就是在崩溃时刻Prometheus内存的映像。Prometheus在重启时只要加载WAL
中的内容就能完全"恢复现场"。
Block
虽然将时序数据存储在内存中能够最大化读写效率,但是时序数据的写入是稳定而持续的,随着时间的流逝,数据量会线性增长,而且相对较老的数据被访问的概率也将逐渐下降。因此,定期将内存中的数据持久化到磁盘是合理的。每一个Block存储了对应时间窗口内的所有数据,包括所有的series
,samples
以及相关的索引结构。Block目录的详细内容如下:
[01DJNTVX7GZ2M1EKB4TM76APV8]# ls
chunks index meta.json tombstones
[01DJNTVX7GZ2M1EKB4TM76APV8]# ls chunks/
000001
meta.json
包含了当前Block的元数据信息,其内容如下:
# cat meta.json
{
"ulid": "01DJNTVX7GZ2M1EKB4TM76APV8",
"minTime": 1566237600000,
"maxTime": 1566244800000,
"stats": {
"numSamples": 30432619,
"numSeries": 65064,
"numChunks": 255203
},
"compaction": {
"level": 1,
"sources": [
"01DJNTVX7GZ2M1EKB4TM76APV8"
]
},
"version": 1
}
各字段的含义如下:
ulid
:用于识别这个Block的编号,它与Block的目录名一致
minTime
和maxTime
:表示这个Block存储的数据的时间窗口
stats
:表示这个Block包含的sample
, series
以及chunks
数目
compaction
:这个Block的压缩信息,因为随着时间的流逝,多个Block也会压缩合并形成更大的Block。level
字段表示了压缩的次数,刚从内存持久化的Block的level
为1,每被联合压缩一次,子Block的level
就会在父Block的基础上加一,而sources
字段则包含了构成当前这个Block的所有祖先Block的ulid
。事实上,对于level >= 2
的Block,还会有一个parent
字段,包含了它的父Block的ulid
以及时间窗口信息。
chunks
是一个子目录,包含了若干个从000001
开始编号的文件,一般每个文件大小的上限为512M。文件中存储的就是在时间窗口[minTime,maxTime]以内的所有samples
,本质上就是对于内存中符合要求的memChunk
的持久化。
tombstones
用于存储对于series
的删除记录。如果删除了某个时间序列,Prometheus并不会立即对它进行清理,而是会在tombstones
做一次记录,等到下一次Block压缩合并的时候统一清理。
index
文件存储了索引相关的内容,虽然持久化后的数据被读取的概率是比较低的,但是依然存在被读取的可能。这样一来,如何尽快地从Block中读取时序数据就显得尤为重要了,而快速读取索引并且基于索引查找时序数据则是加快整体查询效率的关键。为了达到这一目标,存储索引信息的index
文件在设计上就显得比较复杂了。
┌────────────────────────────┬─────────────────────┐
│ magic(0xBAAAD700) <4b> │ version(1) <1 byte> │
├────────────────────────────┴─────────────────────┤
│ ┌──────────────────────────────────────────────┐ │
│ │ Symbol Table │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Series │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Label Index 1 │ │
│ ├──────────────────────────────────────────────┤ │
│ │ ... │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Label Index N │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Postings 1 │ │
│ ├──────────────────────────────────────────────┤ │
│ │ ... │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Postings N │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Label Index Table │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Postings Table │ │
│ ├──────────────────────────────────────────────┤ │
│ │ TOC │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
除了文件开头的Magic Number以及版本信息,index
文件可以分为7个部分,各部分的内容及作用如下:
TOC
:虽然位于文件的末尾,但是TOC
包含了整个index
文件的全局信息,它存储的内容是其余六部分的位置信息,即它们的起始位置在index
文件中的偏移量。
Symbol Table
:一个symbol既可以是一个label的key,也可以是它的value,事实上Symbol Table存储的就是在[minTime, maxTime]范围内的samples
所属的series
的所有label的key和value集合,并且为每个symbol进行了编号。之所以要这样做,是因为后面在存储series
以及Label Index
等信息的时候,就不需要完整存储所有的label了,只需将label的key和value用对应的字符串在Symbol Table
中的编号表示即可,从而大大减小了index
文件的体积。
Series
:存储的自然是series
的相关信息,首先存储series
的各个label,正如上文所述,存储的是对应key和value在Symbol Table
中的编号。紧接着存储series
相关的chunks
信息,包含每个chunk的时间窗口,以及该chunk在chunks
子目录下具体的位置信息。
Label Index
:存储了各个label的key和它所有可能的value的关联关系。例如,对于一个有着四个不同的value的key,它在这部分存储的条目如下所示:
┌────┬───┬───┬──────────────┬──────────────┬──────────────┬──────────────┬───────┐
│ 24 │ 1 │ 4 │ ref(value_0) | ref(value_1) | ref(value_2) | ref(value_3) | CRC32 |
└────┴───┴───┴──────────────┴──────────────┴──────────────┴──────────────┴───────┘
各段含义如下:
24 --> 存储的内容包含24个字节
1 --> 本条目仅仅包含一个key
4 --> 与keys相关的有4个value
ref -> 各个value在Symbol Table中的编号
事实上这样一个条目可以存储多个key和它们的value的映射关系,但一般key的个数都为1。这部分的内容乍一看非常让人疑惑,key的信息呢?为什么只记录key的数目,而没有保存具体的key,哪怕是它在Symbol Table
中的编号?其实,我们应该将这部分的内容和Label Index Table
中的内容联合起来看。
Label Index Table
:存储了所有label的key,以及它们在Label Index
中对应的位置信息。那么为什么要将这两部分的内容分开存储呢?Prometheus在读取Block中的数据时会加载index
文件,但是只会首先加载Label Index Table
获取所有label的key,只有在需要对key相关的value进行匹配时,才会加载Label Index
相应的部分以及对应的Symbol。通过Label Index Table
和Label Index
的分离,使得我们能够只对必要数据进行加载,从而加快了index
文件的加载速度。
Postings
: 这部分存储的显然是倒排索引的信息,每一个条目存储的都是包含某个label pair的所有series
的ID。但是与Label Index
相似,条目中并没有指定具体的key和value。
Postings Offset Table
:这部分直接对每个label的key和value以及相关索引在Postings
中的位置进行存储。同样,它会首先被加载到内存中,如果需要知道包含某个label的所有series
,再通过相关索引的偏移位置从Postings
中依次获取。
可以看到,index
文件结构颇为复杂,但其实设计得相当巧妙,环环相扣。不仅高效地对索引信息进行了存储,而且也最大限度保证了对它进行加载的速度。
总结
프로 메테우스 컴팩트 한 디자인은 가능한 효율적으로 대량 일련의 데이터를 읽고 쓸 수 있습니다. 그러나 아래 분석을 통해, 프로 메테우스와 더 혁신 "검정 기술"유형 소위 없지만, 최적화의 "모든 산 개방, 물 바이 패스"유형이있다. 그리고 자신 있다는 것은 프로 메테우스의 디자인 철학은 "한 가지를 수행하고 수행이다 "글쎄. 사실, 프로 메테우스의 기본은 블록이 해제됩니다이 기간 동안 15 일 동안 로컬 스토리지를 지원합니다. 물론,이 커뮤니티 솔루션을 제공하고, 타 노스 와 코어 텍스 기반으로하는 프로 메테우스는하는 수있는 진정한 '프로 메테우스 서비스로 ", 영구 저장소, 고 가용성 및 다른 특성을 제공하기 위해 확장되었다.