package blockcache import ( "container/heap" "flag" "sync" "sync/atomic" "time" "unsafe" "github.com/VictoriaMetrics/VictoriaMetrics/lib/cgroup" "github.com/VictoriaMetrics/VictoriaMetrics/lib/fasttime" "github.com/VictoriaMetrics/VictoriaMetrics/lib/timeutil" "github.com/cespare/xxhash/v2" ) var missesBeforeCaching = flag.Int("blockcache.missesBeforeCaching", 2, "The number of cache misses before putting the block into cache. "+ "Higher values may reduce indexdb/dataBlocks cache size at the cost of higher CPU and disk read usage") // Cache caches Block entries. // // Call NewCache() for creating new Cache. type Cache struct { shards []*cache cleanerMustStopCh chan struct{} cleanerStoppedCh chan struct{} } // NewCache creates new cache. // // Cache size in bytes is limited by the value returned by getMaxSizeBytes() callback. // Call MustStop() in order to free up resources occupied by Cache. func NewCache(getMaxSizeBytes func() int) *Cache { cpusCount := cgroup.AvailableCPUs() shardsCount := cgroup.AvailableCPUs() // Increase the number of shards with the increased number of available CPU cores. // This should reduce contention on per-shard mutexes. multiplier := cpusCount if multiplier > 16 { multiplier = 16 } shardsCount *= multiplier shards := make([]*cache, shardsCount) getMaxShardBytes := func() int { n := getMaxSizeBytes() return n / shardsCount } for i := range shards { shards[i] = newCache(getMaxShardBytes) } c := &Cache{ shards: shards, cleanerMustStopCh: make(chan struct{}), cleanerStoppedCh: make(chan struct{}), } go c.cleaner() return c } // MustStop frees up resources occupied by c. func (c *Cache) MustStop() { close(c.cleanerMustStopCh) <-c.cleanerStoppedCh } // RemoveBlocksForPart removes all the blocks for the given part from the cache. func (c *Cache) RemoveBlocksForPart(p any) { for _, shard := range c.shards { shard.RemoveBlocksForPart(p) } } // GetBlock returns a block for the given key k from c. func (c *Cache) GetBlock(k Key) Block { idx := uint64(0) if len(c.shards) > 1 { h := k.hashUint64() idx = h % uint64(len(c.shards)) } shard := c.shards[idx] return shard.GetBlock(k) } // PutBlock puts the given block b under the given key k into c. func (c *Cache) PutBlock(k Key, b Block) { idx := uint64(0) if len(c.shards) > 1 { h := k.hashUint64() idx = h % uint64(len(c.shards)) } shard := c.shards[idx] shard.PutBlock(k, b) } // Len returns the number of blocks in the cache c. func (c *Cache) Len() int { n := 0 for _, shard := range c.shards { n += shard.Len() } return n } // SizeBytes returns an approximate size in bytes of all the blocks stored in the cache c. func (c *Cache) SizeBytes() int { n := 0 for _, shard := range c.shards { n += shard.SizeBytes() } return n } // SizeMaxBytes returns the max allowed size in bytes for c. func (c *Cache) SizeMaxBytes() int { n := 0 for _, shard := range c.shards { n += shard.SizeMaxBytes() } return n } // Requests returns the number of requests served by c. func (c *Cache) Requests() uint64 { n := uint64(0) for _, shard := range c.shards { n += shard.Requests() } return n } // Misses returns the number of cache misses for c. func (c *Cache) Misses() uint64 { n := uint64(0) for _, shard := range c.shards { n += shard.Misses() } return n } func (c *Cache) cleaner() { d := timeutil.AddJitterToDuration(time.Minute) ticker := time.NewTicker(d) defer ticker.Stop() d = timeutil.AddJitterToDuration(time.Minute * 3) perKeyMissesTicker := time.NewTicker(d) defer perKeyMissesTicker.Stop() for { select { case <-c.cleanerMustStopCh: close(c.cleanerStoppedCh) return case <-ticker.C: c.cleanByTimeout() case <-perKeyMissesTicker.C: c.cleanPerKeyMisses() } } } func (c *Cache) cleanByTimeout() { for _, shard := range c.shards { shard.cleanByTimeout() } } func (c *Cache) cleanPerKeyMisses() { for _, shard := range c.shards { shard.cleanPerKeyMisses() } } type cache struct { requests atomic.Uint64 misses atomic.Uint64 // sizeBytes contains an approximate size for all the blocks stored in the cache. sizeBytes atomic.Int64 // getMaxSizeBytes() is a callback, which returns the maximum allowed cache size in bytes. getMaxSizeBytes func() int // mu protects all the fields below. mu sync.Mutex // m contains cached blocks keyed by Key.Part and then by Key.Offset m map[any]map[uint64]*cacheEntry // perKeyMisses contains per-block cache misses. // // Blocks with up to *missesBeforeCaching cache misses aren't stored in the cache in order to prevent from eviction for frequently accessed items. perKeyMisses map[Key]int // The heap for removing the least recently used entries from m. lah lastAccessHeap } // Key represents a key, which uniquely identifies the Block. type Key struct { // Part must contain a pointer to part structure where the block belongs to. Part any // Offset is the offset of the block in the part. Offset uint64 } func (k *Key) hashUint64() uint64 { buf := (*[unsafe.Sizeof(*k)]byte)(unsafe.Pointer(k)) return xxhash.Sum64(buf[:]) } // Block is an item, which may be cached in the Cache. type Block interface { // SizeBytes must return the approximate size of the given block in bytes SizeBytes() int } type cacheEntry struct { // The timestamp in seconds for the last access to the given entry. lastAccessTime uint64 // heapIdx is the index for the entry in lastAccessHeap. heapIdx int // k contains the associated key for the given block. k Key // b contains the cached block. b Block } func newCache(getMaxSizeBytes func() int) *cache { var c cache c.getMaxSizeBytes = getMaxSizeBytes c.m = make(map[any]map[uint64]*cacheEntry) c.perKeyMisses = make(map[Key]int) return &c } func (c *cache) RemoveBlocksForPart(p any) { c.mu.Lock() defer c.mu.Unlock() sizeBytes := 0 for _, e := range c.m[p] { sizeBytes += e.b.SizeBytes() heap.Remove(&c.lah, e.heapIdx) // do not delete the entry from c.perKeyMisses, since it is removed by cache.cleaner later. } c.updateSizeBytes(-sizeBytes) delete(c.m, p) } func (c *cache) updateSizeBytes(n int) { c.sizeBytes.Add(int64(n)) } func (c *cache) cleanPerKeyMisses() { c.mu.Lock() c.perKeyMisses = make(map[Key]int, len(c.perKeyMisses)) c.mu.Unlock() } func (c *cache) cleanByTimeout() { // Delete items accessed more than three minutes ago. // This time should be enough for repeated queries. lastAccessTime := fasttime.UnixTimestamp() - 3*60 c.mu.Lock() defer c.mu.Unlock() for len(c.lah) > 0 { if lastAccessTime < c.lah[0].lastAccessTime { break } c.removeLeastRecentlyAccessedItem() } } func (c *cache) GetBlock(k Key) Block { c.requests.Add(1) var e *cacheEntry c.mu.Lock() defer c.mu.Unlock() pes := c.m[k.Part] if pes != nil { e = pes[k.Offset] if e != nil { // Fast path - the block already exists in the cache, so return it to the caller. currentTime := fasttime.UnixTimestamp() if e.lastAccessTime != currentTime { e.lastAccessTime = currentTime heap.Fix(&c.lah, e.heapIdx) } return e.b } } // Slow path - the entry is missing in the cache. c.perKeyMisses[k]++ c.misses.Add(1) return nil } func (c *cache) PutBlock(k Key, b Block) { c.mu.Lock() defer c.mu.Unlock() misses := c.perKeyMisses[k] if misses > 0 && misses <= *missesBeforeCaching { // If the entry wasn't accessed yet (e.g. misses == 0), then cache it, // since it has been just created without consulting the cache and will be accessed soon. // // Do not cache the entry if there were up to *missesBeforeCaching unsuccessful attempts to access it. // This may be one-time-wonders entry, which won't be accessed more, so do not cache it // in order to save memory for frequently accessed items. return } // Store b in the cache. pes := c.m[k.Part] if pes == nil { pes = make(map[uint64]*cacheEntry) c.m[k.Part] = pes } else if pes[k.Offset] != nil { // The block has been already registered by concurrent goroutine. return } e := &cacheEntry{ lastAccessTime: fasttime.UnixTimestamp(), k: k, b: b, } heap.Push(&c.lah, e) pes[k.Offset] = e c.updateSizeBytes(e.b.SizeBytes()) maxSizeBytes := c.getMaxSizeBytes() for c.SizeBytes() > maxSizeBytes && len(c.lah) > 0 { c.removeLeastRecentlyAccessedItem() } } func (c *cache) removeLeastRecentlyAccessedItem() { e := c.lah[0] c.updateSizeBytes(-e.b.SizeBytes()) p := e.k.Part pes := c.m[p] delete(pes, e.k.Offset) if len(pes) == 0 { // Remove reference to p from c.m in order to free up memory occupied by p. delete(c.m, p) } heap.Pop(&c.lah) } func (c *cache) Len() int { c.mu.Lock() defer c.mu.Unlock() n := 0 for _, m := range c.m { n += len(m) } return n } func (c *cache) SizeBytes() int { return int(c.sizeBytes.Load()) } func (c *cache) SizeMaxBytes() int { return c.getMaxSizeBytes() } func (c *cache) Requests() uint64 { return c.requests.Load() } func (c *cache) Misses() uint64 { return c.misses.Load() } // lastAccessHeap implements heap.Interface type lastAccessHeap []*cacheEntry func (lah *lastAccessHeap) Len() int { return len(*lah) } func (lah *lastAccessHeap) Swap(i, j int) { h := *lah a := h[i] b := h[j] a.heapIdx = j b.heapIdx = i h[i] = b h[j] = a } func (lah *lastAccessHeap) Less(i, j int) bool { h := *lah return h[i].lastAccessTime < h[j].lastAccessTime } func (lah *lastAccessHeap) Push(x any) { e := x.(*cacheEntry) h := *lah e.heapIdx = len(h) *lah = append(h, e) } func (lah *lastAccessHeap) Pop() any { h := *lah e := h[len(h)-1] // Remove the reference to deleted entry, so Go GC could free up memory occupied by the deleted entry. h[len(h)-1] = nil *lah = h[:len(h)-1] return e }