프로 메테우스 스토리지 모델 분석

프로 메테우스는 요즘 가장 인기있는 오픈 소스 모니터링 솔루션입니다, 우리는 쉽게 프로 메테우스 신속하게 캡처, 저장, 조회 및 알람을 포함 모니터링 지표의 완전한 세트를 구축 할 수있는 핵심 모니터링 시스템 등. 환경에서 빠른 쿼리 데이터 수집에 대한 지원, 그리고는 Kubernetes 잡아 개체에 대한 빈번한 변경, 프로 메테우스가 최선의 선택을하는 동안 프로 메테우스는 초당 샘플 수백만의 단일 인스턴스를 달성 할 수있을 것입니다. 물론, 이러한 우수한 특성이 좋은 데이터베이스 설계 타이밍의 지원에서 분리 될 수 없다 달성했다. 설계와 프로 메테우스의 데이터베이스 TSDB 타이밍을 구현할이 문서는 분석이 읽기 및 아키텍처 설계, 코드 레벨에서 프로 메테우스 강력한 쓰기 성능을 지원 이유를 이해하기 위해 만들었습니다.

시계열 데이터의 1. 개요

프로 메테우스는 일반 데이터 객체와 비교하여 데이터를 읽고 쓸 수있는 타이밍이다, 데이터 계열은 고유의 특색을 가지고, 많은 대상으로 디자인 및 최적화의 TSDB. 그래서 이해는 프로 메테우스 시리즈 데이터 스토리지 모델을 이해하는 첫 번째 단계입니다. 그것은 일반적으로 두 개의 샘플 데이터와 조성의 식별 구성

标识 -> {(t0, v0), (t1, v1), (t2, v2), (t3, v3)...}
  1. 식별자는 일반적으로 고유 시계열의 일련의 식별 프로 메테우스 + 라벨 인덱스 이름에 사용되는 다양한 모니터링 인덱스를 구별하는 데 사용됩니다. 프로 메테우스가 시계열로 캡처 http_request_total인덱스 이름, HTTP 요청의 총 수를 나타내며, 그 pathmethod두 개의 레이블 경로와 다양한 요청을 나타 내기위한 방법.
http_request_total{path="/", method="GET"} -> {(t0, v1), (t1, v1)...}

사실 고유 라벨이 저장 될 때 마지막 인덱스 이름, 그것은 중요합니다 __name__다음과 같이. 데이터베이스에 저장 프로 메테우스 마지막 시간 순서 ID는 라벨의 무리입니다. 우리는 호출이 힙 레이블을 참조 series.

{__name__="http_request_total", path="/", method="GET"}
  1. 많은 샘플 샘플링 포인트에 의한 데이터 (메테우스 샘플이라고 함) 구성, 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

可以看到,一个memSeries主要由三部分组成:

  1. lset:用以识别这个series的label集合
  2. ref:每接收到一个新的时间序列(即它的label集合与已有的时间序列都不同)Prometheus就会用一个唯一的整数标识它,如果有ref,我们就能轻易找到相应的series
  3. memChunks:每一个memChunk是一个时间段内该时间序列所有sample的集合。如果我们想要读取[tx, ty](t1 < tx < t2, t2 < ty < t3 )时间范围内该时间序列的数据,只需要对[t1, t3]范围内的两个memChunksample数据进行裁剪即可,从而提高了查询的效率。每当采集到新的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请求的数目(请求的方法可以为GETPOST等等),此时提交给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的内容。

那么seriessampleSegment中是如何组织的呢?在将时序数据备份到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 }

每当需要写入数据时,就要创建一个AppenderAppender是一个临时结构,仅供一次批量操作使用。一个Appender类似于其他数据库中事务的概念,通过Add()或者AddFast()添加的时序数据会临时在Appender中进行缓存,只有在最后调用Commit()之后,这批数据才正式提交给Prometheus,同时写入WAL。而如果最后调用的Rollback(),则这批数据的samples会被全部丢弃,但是通过Add()方法新增的series结构则依然会被保留。

seriessampleAppender中是分开存储的,它们在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 }

当调用AppenderCommit()方法提交这些时序数据时,seriessamples这两个切片会分别编码,形成两条Record,如下所示:

|RecordType|RecordContent|

RecordType可以取“RecordSample”或者“RecordSeries”,表示这条Record的类型

RecordContent则根据RecordType可以series或者samples编码后的内容

最后,seriessamplesRecord的形式被批量写入Segment文件中,默认当Segment超过128M时,会创建新的Segment文件。若Prometheus因为各种原因崩溃了,WAL里的各个Segment以及checkpoint里的内容就是在崩溃时刻Prometheus内存的映像。Prometheus在重启时只要加载WAL中的内容就能完全"恢复现场"。

Block

虽然将时序数据存储在内存中能够最大化读写效率,但是时序数据的写入是稳定而持续的,随着时间的流逝,数据量会线性增长,而且相对较老的数据被访问的概率也将逐渐下降。因此,定期将内存中的数据持久化到磁盘是合理的。每一个Block存储了对应时间窗口内的所有数据,包括所有的seriessamples以及相关的索引结构。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的目录名一致

minTimemaxTime:表示这个Block存储的数据的时间窗口

stats:表示这个Block包含的sampleseries以及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 TableLabel Index的分离,使得我们能够只对必要数据进行加载,从而加快了index文件的加载速度。

Postings: 这部分存储的显然是倒排索引的信息,每一个条目存储的都是包含某个label pair的所有series的ID。但是与Label Index相似,条目中并没有指定具体的key和value。

Postings Offset Table:这部分直接对每个label的key和value以及相关索引在Postings中的位置进行存储。同样,它会首先被加载到内存中,如果需要知道包含某个label的所有series,再通过相关索引的偏移位置从Postings中依次获取。

可以看到,index文件结构颇为复杂,但其实设计得相当巧妙,环环相扣。不仅高效地对索引信息进行了存储,而且也最大限度保证了对它进行加载的速度。

总结

프로 메테우스 컴팩트 한 디자인은 가능한 효율적으로 대량 일련의 데이터를 읽고 쓸 수 있습니다. 그러나 아래 분석을 통해, 프로 메테우스와 더 혁신 "검정 기술"유형 소위 없지만, 최적화의 "모든 산 개방, 물 바이 패스"유형이있다. 그리고 자신 있다는 것은 프로 메테우스의 디자인 철학은 "한 가지를 수행하고 수행이다 "글쎄. 사실, 프로 메테우스의 기본은 블록이 해제됩니다이 기간 동안 15 일 동안 로컬 스토리지를 지원합니다. 물론,이 커뮤니티 솔루션을 제공하고, 타 노스코어 텍스 기반으로하는 프로 메테우스는하는 수있는 진정한 '프로 메테우스 서비스로 ", 영구 저장소, 고 가용성 및 다른 특성을 제공하기 위해 확장되었다.

참조

추천

출처www.cnblogs.com/YaoDD/p/11391335.html