prometheus自身也是时序数据库,他的数据可以说分为两部分,一部分叫block,在本地磁盘,一部分叫head,在内存,其操作记录为wal,为了重启时恢复文件内存的数据。内存的数据会根据设置的时间形成block,最终持久化到磁盘。
如何去缓存
基于上面的设计,很明显的lsm树的玩法,最近的数据在内存,记录wal,然后在一个时间点把数据落地到磁盘里。
由于他本身是一个时序数据库,常见的操作就是查询最近发生的数据,当然也可以查询以前的数据,只不过算是一个低频的操作。所有说在内存中的数据可以满足大部分的查询条件,这也是prometheus能有很大的存储量,还能查询很快的一个条件。如果要查询老的数据,就必须走文件了,这样的情况就带来一个问题,是否缓存从文件里获得的数据,应该以什么样的方式去缓存。
场景的缓存策略
如果我们自己写系统的时候,缓存可以说是一种常见的优化手段。尤其是需要读取数据库或者是其他持久化设施的时候。因为那些操作一般涉及文件io,比较慢(kafka则是利用了pagecache)。缓存起来走内存是个不错的方案。但是内存的容量是有限的,必须给缓存设置淘汰设置,有根据时间的,有根据存储数据量的。淘汰的策略也很多,fifo,lru等等,需要根据自己场景去设置。
prometheus的挑战
基于上面的知识,我们再看prometheus的场景,因为最近的数据(这个有参数可以设置)已经存在了内存,已经占用了很大的内存资源。再把文件的数据加载到内存缓存起来不是非常合适,这样就需要更多的内存资源了,而且查询的不可预知性,都无法确认缓存的内容,例如查询20天实例A的数据,下个查询是20天实力B的数据,马上A的数据就失灵了,而且随着时间刻度变大,数据已经比较庞大了。加上查询的不可预知,缓存的内容也不能确认。prometheus应该如何去应对比较复杂的变化呢。
和kafka类似的优化
很多人认为文件读写慢,主要是相比内存而言,kafka就很强的依赖了文件的性能(读110m/s,写55m/s),顺序读写的话。其实顺序读写很难达到的,但是我们可以利用系统的缓存,pagecache达到读取文件的速度变快的目的,例如文件打开后读取,会先把数据加载到pagecache中去的,而且是按照页加载的,只要没有大量的内容把pagecache淘汰掉,那么速度都是很快的。我们看看prometheus是怎么做的。
func (pb *Block) startRead() error {
pb.mtx.RLock()
defer pb.mtx.RUnlock()
if pb.closing {
return ErrClosing
}
pb.pendingReaders.Add(1)
return nil
}
在读取文件的时候pendingReaders执行加1操作。
func (r blockIndexReader) Close() error {
r.b.pendingReaders.Done()
return nil
}
在执行查询完的操作时会执行close方法,这里的done就是减1操作。
也就是说在查询完的时候会看到底现在有多少查询需要打开这个文件,如果计数器是正数,说明还有查询再读文件内容,如果是为0 的时候,说明没有再查询了,只有这种情况才会触发回收的操作。
优缺点
上面的操作的明显好处是充分利用了页缓存,不会轻易的关闭文件,虽然比缓存到内存要慢一下,但是解决了缓存的不确定性。而且对并发查询是友好的,尤其是同时查询同一个文件的时候。而且还有释放的时候,不会导致文件句柄打开过多,只有真正不用的时候才会进行关闭。
缺点也在判定不用的时候,是查询最糟糕的情况,就是刚查完a,然后查b,此时淘汰a,又发起a的查询。导致文件有规律的被加载和释放。虽然这个出现是小概率事件,但是出现就会导致查询的速度变得特别慢。