本地缓存已经算是数据库的常规设计了,因为数据直接缓存在机器内存中,避免昂贵的IO开销,数据的读取和写入快到不可思议。本地缓存常驻在内存中,就好像业务逻辑中声明的全局变量一样,不会被垃圾回收。但本地内存也会导致单机内存使用率变高,频繁的GC导致系统性能下降。
在说 Go 本地内存之前,我们先看看别的数据库是怎么使用本地内存的。我查了一下,觉得 HBase 本地内存和 Go 的设计特别的接近,我们来简单看看 HBase 的 BlockCache:
BlockCache
HBase 实现的缓存结构称为 BlockCache,用来缓存 Block 对象,它实现了3种 BlockCache 的缓存方案,我们来最早的设计实现 LRUBlockCache,看名字就知道缓存淘汰策略是 LRU,该策略将所有数据都放入到 JVM Heap 中,交给 JVM 进行管理。而 JVM 的垃圾回收机制注定这个策略存在缺陷,FullGC对业务的读写请求会有很大的影响。后来,引入了后续的堆外内存分配。
多级缓存
在缓存设计中,常用的手段就是设计多级缓存,可能大家都参考了CPU读取缓存的设计。既然是多级缓存,每一级缓存之间总得存在些差异,基于缓存大小、数据热度、过期时间这些维度,就可以做到层级缓存之间的区分。
上图简单的示意一个多级缓存,包含了三个层级,每个层级的内存容量都不相同,level3的容量是最小的,相对应它的存储介质可以选择最好的(当然三个层级存储介质也可以相同),有点类似数据先查询本地内存数据,查询不到查询 Redis 数据,再继续查询 MySQL 的意思。
假设这三级缓存的存储介质都是相同的,我们还可以从数据的冷热层度进行区分,最初数据都存储在level1中,如果数据被多次访问,就可以迁移到 level2 中,再超过一定的阈值,就可以迁移到 level3中。但要明确区分这三个级别,还需要引入另一个参考因素,比如缓存时间,更热的数据值得缓存更长的时间。
但这也面临一个问题,在数据不存在的情况下,数据查询也会被增加到3次。究竟是否要使用多级缓存,还是要结合实际业务去看,毕竟,多级缓存带来的也有代码复杂度的提升。
HBase采用了缓存分层设计,它包含三个部分:single-access、multi-access、in-memory,分别占到整个BlockCache大小的25%、50%、25%。在一次随机读中,一个Block从HDFS加载出来之后首先放入 single-access 区,如果后续有多次请求访问这个 Block,就会将这个 Block 移动到 mutlti-access 区。而 in-memory 区表示数据可以常驻内存,一般用来存放访问频繁、量小的数据。
缓存淘汰策略
在选择本地缓存时,缓存淘汰策略是非常重要的考量因素,进程的内存容量是有限的,当缓存容量超过一定的限度时,缓存中的哪些数据应该被淘汰呢?最完美的状态是移除那些不再被使用的数据,这需要我们基于业务形态最决策,选择最适合的淘汰策略。
LRU
常见的 LRU 缓存,基于最近最少使用原则对缓存数据进行淘汰,它基于的假设是:最近被访问的数据大概率后续还会被访问。如果这个数据是热点数据,LRU的性能应该挺好的。
FIFO
我在阅读 bigcache 的时候,作者采用的 FIFO 的缓存策略。 FIFO顾名思义,先进先出,当缓存满了之后,最早缓存的数据会被淘汰,缓存的底层数据结构支持是队列。这种策略实现起来会比较简单,正常来模拟一个循环队列,不断进行数据覆写就可以。这种方式对所有数据一视同仁,不考虑数据本身是否为热点数据。
谈一下我的想法,使用FIFO的话要控制好缓存的容量,淘汰的缓存和新增的缓存能有一个相对稳定的动态平衡。当缓存容量达到上限时,每新加入一个缓存,势必要淘汰一个缓存,如果有一个批量的加缓存操作,缓存就会频繁被淘汰出去,缓存的效果也就大打折扣。
假设缓存最多能容纳 N 个数据,如果不考虑缓存的业务过期时间,缓存的生命周期就是从加入缓存开始,到之后再加入 N 个新的数据为止。如果将FIFO的队列假设为一个环形队列,绿色表示新加入的缓存,如果重新加入新的缓存空,红色的缓存数据就会被覆盖。
缓存的淘汰就由两个因素决定,首先是缓存数据的有效期,假设数据的过期时间是 5m,如果新加入的绿色数据已经持续了 5m,那么绿色数据之前加入的所有数据都应该被清空。另一个因数就是缓存的容量了。
总结来说,要想让 FIFO 发挥大的作用,就是要让缓存队列不满,要么缓存的内存总容量要超过实际的缓存容量,要么限制缓存的对象数量。
图中我使用了循环队列来解释FIFO,主要是因为后续要介绍的 bigcache 使用的也是循环队列, 后面也要介绍实现细节,所以没有采用链表说明。
BigCace
GitHub地址在:github.com/allegro/bigcache。Usage非常简单。
内存容量限制
本地缓存存储数据有两个维度,一个是容量,比如2G的内容容量限制;另一个是数据条数,比如最多缓存20000条数据。bigCache 采用的是容量限制,但提供了两个比较容易混淆的配置项 MaxEntrySize 和 HardMaxCacheSize,其中,HardMaxCacheSize 用来限制最大可使用的物理内存,单位是 MB。当缓存大小超过这个容量限制,需要执行 FIFO 淘汰策略。因为 bigCache 采用 sharding 的分片策略,所以,具体到每个 shard 的分片内存大小,还需要平均折算一下。
MaxEntrySize 用来指定每个缓存对象的最大大小,单位是 byte,这其实是一个预估值,如果实际缓存对象大于 MaxEntrySize,数据仍然可以写入到 bigCache 中。正如注释所言,这个配置只是用来预估初始内存容量大小的。它需要和 MaxEntriesInWindow 配合使用,用来计算初始化 shard 的内存大小。如果这两个值评估的比较合适,可以有效避免底层内存扩容带来的性能开销问题。
HardMaxCacheSize 用来给 shard 设置一个内存边界,MaxEntrySize 和 MaxEntriesInWindow 用来设置初始化 shard 的内存大小。举个例子,我们允许 bigCache 使用的最大内存为 800M,我们预计实际使用的内存只需要 400M,就可以通过这3个属性配合来实现。
我们参考下面的代码来理解,这是初始化 shard 的配置方法。其中,maximumShardSizeInBytes 就是通过 HardMaxCacheSize 计算出来的结果,而 bytesQueueInitialCapacity 是通过 MaxEntrySize 和 HardMaxCacheSize 计算出来的结果。可以看出,最终其内存决定作用的还是
maximumShardSizeInBytes。
func initNewShard(config Config, callback onRemoveCallback, clock clock) *cacheShard {
bytesQueueInitialCapacity := config.initialShardSize() * config.MaxEntrySize
maximumShardSizeInBytes := config.maximumShardSizeInBytes()
if maximumShardSizeInBytes > 0 && bytesQueueInitialCapacity > maximumShardSizeInBytes {
bytesQueueInitialCapacity = maximumShardSizeInBytes
}
return &cacheShard{
hashmap: make(map[uint64]uint32, config.initialShardSize()),
hashmapStats: make(map[uint64]uint32, config.initialShardSize()),
entries: *queue.NewBytesQueue(bytesQueueInitialCapacity, maximumShardSizeInBytes, config.Verbose),
entryBuffer: make([]byte, config.MaxEntrySize+headersSizeInBytes),
onRemove: callback,
isVerbose: config.Verbose,
logger: newLogger(config.Logger),
clock: clock,
lifeWindow: uint64(config.LifeWindow.Seconds()),
statsEnabled: config.StatsEnabled,
cleanEnabled: config.CleanWindow > 0,
}
}
内存间隙
bigCache 存在内存间隙的问题,基于 FIFO 的缓存策略,当我们对缓存中的对象进行更新时,缓存的对象首先需要被删除,然后再重新将新值 push 到队列中。但这个理应被删除的对象,实际并没有从队列中真实删除,只是被标记为了“无效”,内存占用还是存在的,而这些间隙其实浪费了新对象的空间。
参考 bigCache 写缓存的代码实现,这个过程非常模式化。如果在缓存中查找到,就直接删除。然后向缓存中插入新的值,如果此时缓存容量已经到达最大值,写不进去的话,就开始 FIFO 的淘汰策略(如果待设置的缓存和已存在的缓存数据相等,也仍然执行删除的流程)。
func (s *cacheShard) set(key string, hashedKey uint64, entry []byte) error {
currentTimestamp := uint64(s.clock.Epoch())
s.lock.Lock()
if previousIndex := s.hashmap[hashedKey]; previousIndex != 0 {
if previousEntry, err := s.entries.Get(int(previousIndex)); err == nil {
resetKeyFromEntry(previousEntry)
//remove hashkey
delete(s.hashmap, hashedKey)
}
}
if !s.cleanEnabled {
if oldestEntry, err := s.entries.Peek(); err == nil {
s.onEvict(oldestEntry, currentTimestamp, s.removeOldestEntry)
}
}
w := wrapEntry(currentTimestamp, hashedKey, key, entry, &s.entryBuffer)
for {
if index, err := s.entries.Push(w); err == nil {
s.hashmap[hashedKey] = uint32(index)
s.lock.Unlock()
return nil
}
if s.removeOldestEntry(NoSpace) != nil {
s.lock.Unlock()
return fmt.Errorf("entry is bigger than max shard size")
}
}
}
bigCache 是如何标记这个对象已失效呢?这个失效和缓存到达过期时间的失效肯定是不相同的。当这块失效的内存前面的所有缓存数据都过期时,这块内存才可以重新被利用起来。
假设一个场景,一旦缓存对象写入 bigCache 后,就不再对这个对象进行更新,一直等到这个对象过期,然后重新写入 bigCache。这个场景就不会产生内存间隙,是一种比较理想的情况。
Omitting GC
当map中存储过百万的object时,Go语言自身的GC甚至会影响不相关的请求,即使是对一个空对象做Marsh操作,响应时间也可能在1s以上。所以,如何避免Go默认对map做的Garbage Collector至关重要。
GC回收heap中对象,所以我们不把对象创建在heap中就可以避过垃圾回收。查阅offheap。
使用freecache.
在map结构的key和value中不存储pointer,这样便可以将map创建在堆上,同时忽略GC的影响。这来源于Go的优化.
Concurrency
为了避免加锁成为系统的瓶颈,BigTable采用了Shared的方式来解决,确实也有点Redis单线程的感觉。将一块大的数据划分成多块小的数据,为小数据块加锁,确实很好的缓解了加锁的瓶颈。这体现出了拆分的思想,突然想到了曾经被面试的问题:“请将2G的数据进行排序”。
我比较好奇它的Hash方法,客户端的key转换为实际存储的hashedKey的过程。请看通过hashedKey获取shard的部分,作者没有使用%取余来实现,而是使用了&与运算来替代,确实很注重细节啊!
说到与运算:0&0=0; 0&1=0; 1&0=0; 1&1=1;,所以,最终拆分个数完全取决与二进制中1的数量。如果shardMask等于3,那就可以拆分成4份,如果等于4,那结果就是2份,以此类推。
//通过客户端的key获取实际存储的key
// Sum64 gets the string and returns its uint64 hash value.
func (f fnv64a) Sum64(key string) uint64 {
var hash uint64 = offset64
for i := 0; i < len(key); i++ {
hash ^= uint64(key[i])
hash *= prime64
}
return hash
}
//通过实际存储的key获取shard块,使用与运算。
func (c *BigCache) getShard(hashedKey uint64) (shard *cacheShard) {
return c.shards[hashedKey&c.shardMask]
}
Entry中存储的数据
这也是我特别好奇的地方。因为作者只简单介绍了它是模拟queue实现的,而且在map的结构中,它存储的仅仅是offset。那么,它是如何通过一个offset来获取到完整的数据信息的?
如代码所示,每个entry由5部分组成,分别是时间戳(8byte)、key的hash值(8byte)、key的长度(2byte)、key的值本身以及value的值本身。这里通过小端字节序来存储,所以后续的反编译也应该指定这种模式。从PutUint64、PutUint16也可以对应到字节的大小。
func wrapEntry(timestamp uint64, hash uint64, key string, entry []byte, buffer *[]byte) []byte {
keyLength := len(key)
blobLength := len(entry) + headersSizeInBytes + keyLength
if blobLength > len(*buffer) {
*buffer = make([]byte, blobLength)
}
blob := *buffer
binary.LittleEndian.PutUint64(blob, timestamp)
binary.LittleEndian.PutUint64(blob[timestampSizeInBytes:], hash)
binary.LittleEndian.PutUint16(blob[timestampSizeInBytes+hashSizeInBytes:], uint16(keyLength))
copy(blob[headersSizeInBytes:], key)
copy(blob[headersSizeInBytes+keyLength:], entry)
return blob[:blobLength]
}
queue存储实现
我们通过源码来分析一下 bigcache 是如何使用数组来模拟 FIFO 的,queue 操作由结构体 BytesQueue 对象来实现,核心点在于 Push 和 Pop 两个方法,Push负责向队列中写入数据,Pop负责从队列中“淘汰”数据。
Push代码中调用函数的命名做到了“见名知意”,canInsertAfterTail 和 canInsertBeforeHead 分别用来判断新的数据是否可以在 tail 之后插入,如果不可以,是否可以在 head 之前插入。插入到 tail 之后比较符合我们一般的认知,但在 head 之前插入,就有点反认知了。如果可以在 head 之前插入,tail 就会被赋值给一个新的初始值,这个时候,tail < head。所谓的“循环”就开始了。
关于 leftMarginIndex 这个常量, BytesQueue 结构体的 Reset 方法也有使用到,就是 head 和 tail 的初始状态,数值为1。是什么原因让作者不从 0 开始的呢?
// Push copies entry at the end of queue and moves tail pointer. Allocates more space if needed.
// Returns index for pushed data or error if maximum size queue limit is reached.
func (q *BytesQueue) Push(data []byte) (int, error) {
neededSize := getNeededSize(len(data))
if !q.canInsertAfterTail(neededSize) {
if q.canInsertBeforeHead(neededSize) {
q.tail = leftMarginIndex
} else if q.capacity+neededSize >= q.maxCapacity && q.maxCapacity > 0 {
return -1, &queueError{"Full queue. Maximum size limit reached."}
} else {
q.allocateAdditionalMemory(neededSize)
}
}
index := q.tail
q.push(data, neededSize)
return index, nil
}
上面有了 tail 循环,那个 head 什么时候会再次循环到 tail 的前面呢?就只能是 Pop 方法了,当 head 移动到队列的右边界的时候,head就会被重新初始化为 leftMarginIndex。源码中 rightMargin 是一个程序动态计算的结果,而 leftMarginIndex 是一个常量。
// Pop reads the oldest entry from queue and moves head pointer to the next one
func (q *BytesQueue) Pop() ([]byte, error) {
data, blockSize, err := q.peek(q.head)
if err != nil {
return nil, err
}
q.head += blockSize
q.count--
if q.head == q.rightMargin {
q.head = leftMarginIndex
if q.tail == q.rightMargin {
q.tail = leftMarginIndex
}
q.rightMargin = q.tail
}
q.full = false
return data, nil
}
可以看出,bigcache 的循环队列其实是通过4个要素来确定的,head、tail、队列开始边界、队列结束边界来,当head或者tail移动到队列结束边界时,都会被赋值队列开始边界,我们可以看 BytesQueue的结构体声明来验证这一点。因为左边界是常量,并不需要在结构体中进行声明。
type BytesQueue struct {
full bool
array []byte
capacity int
maxCapacity int
head int
tail int
count int
rightMargin int
headerBuffer []byte
verbose bool
}
queue中每个元素都由2部分组成,前4个byte是数据的长度,后面是数据的值本身。其中PutUint32变需要4byte。所以queue中每个元素最下的长度应该是4,而它的值部分只能是0了。
func (q *BytesQueue) push(data []byte, len int) {
binary.LittleEndian.PutUint32(q.headerBuffer, uint32(len))
q.copy(q.headerBuffer, headerEntrySize)
q.copy(data, len)
if q.tail > q.head {
q.rightMargin = q.tail
}
q.count++
}
关于rightMargin,用于标识队列中最后一个元素的位置,是一个绝对位置。所以,当队列需要扩容时,会copy该坐标之前的所有元素,如下面的示例代码。对于最正常的情况,该值跟tail相等。
copy(q.array, oldArray[:q.rightMargin])
关于head和tail是一个相对的坐标,而且跟严格意义上队列的两个属性不一致。在queue中存储的元素有timestamp的部分,而head所指向的元素不一定是最早插入队列的元素,同理,tail指向的元素也不是最晚插入队列的元素。它们会因为循环而相互变动,只要的作用便是:推断是否可以合理的插入新的元素。
if q.tail < q.head {
emptyBlobLen := q.head - q.tail - headerEntrySize
q.push(make([]byte, emptyBlobLen), emptyBlobLen)
q.head = leftMarginIndex
//absoulate position to right margin
q.tail = q.rightMargin
}
关于leftMarginIndex声明成一个常量,而且head默认从1开始。为什么要这样处理,注释给出的解释:
// Bytes before left margin are not used. Zero index means element does not exist in queue,
//useful while reading slice from index
关于申请新的空间,引入了minimumEmptyBlobSize,它占用36个byte。它其实占用了一个比实际需要大的多的空间。
minimumEmptyBlobSize = 32 + headerEntrySize
当tail和head间的空隙,不足以容纳当前要插入的元素的时候,期间需要插入一个空的元素,具体到下面的代码:
emptyBlobLen := q.head - q.tail - headerEntrySize
q.push(make([]byte, emptyBlobLen), emptyBlobLen)
这个赋值的意义在这行代码才体现出来,当申请空间的时候,需要一个默认的值来标识:是否可以申请空间了。那么availableSpaceBeforeHead是可能产生负数的。
func (q *BytesQueue) availableSpaceBeforeHead() int {
if q.tail >= q.head {
//leftMarginIndex mean
return q.head - leftMarginIndex - minimumEmptyBlobSize
}
return q.head - q.tail - minimumEmptyBlobSize
}