本文由OceanBase高级技术专家陈群在2018年3月首发于OceanBase公众号
Oracle、MySQL、OceanBase三款面向OLTP场景的关系数据库系统,它们的Cache设计有什么异同,本文带你一探究竟。
Oracle的Cache设计
Oracle的内存主要分为SGA(System Global Area)和PGA(Program Global Area)两部分,SGA由所有服务及后台进程共享,PGA由每个服务及后台进程独有。附一张Oracle官方文档中的内存结构图。
上图中我们可以看到,在SGA中包含了很多不同的内存结构,如Buffer Cache、Redo Log Buffer、Shared Pool、Large Pool、Java Pool、Strems Pool等等。在Shared Pool中又包含了Library Cache、Data Dictionary Cache、Result Cache、Reserved Pool等数据结构。
单从Cache来说,在Oracle中有Buffer Cache(缓存数据页)、Library Cache(缓存sql、存储过程、计划等对象)、Result Cache(缓存查询结果)等多种不同类型的Cache,尽管它们在结构上来说都是Cache,但是在实现上却非常不一样。例如Buffer Cache中缓存的数据都是定长的内存页,并且可能会对内存页进行修改,修改后的页就成为脏页,留待后台进程在合适的时候将其刷到磁盘上;而Result Cache中缓存的是变长的查询结果,通常是只读的,当数据发生变更时需要使查询结果失效。因此Oracle将Buffer Cache和其他Cache分在不同的模块中进行管理,在Oracle 10g之前,需要DBA手工设置SGA中各个内存模块如Buffer Cache、Redo Log Buffer、Shared Pool、Large Pool等的大小。在Oracle 10g中引入了Automatic Shared Memory Management(ASMM),DBA只需要设置SGA的总大小,Oracle可以对SGA内部各个模块的内存使用进行自动调整;更进一步地,在Oracle 11g中引入了Automatic Memory Management(AMM),DBA只要设置总内存占用,Oracle可以对SGA和PGA的大小进行自动调整。
通常来说,在Oracle中Buffer Cache会占用大部分的内存,在结构上Buffer Cache是一个由定长内存块(由参数DB_BLOCK_SIZE指定,默认为8KB)组成的链表,通过LRU算法进行淘汰,同时由DBW进程将比较冷的脏页定期刷到磁盘。在经典LRU算法中,每次数据读取都需要把记录移动到链表头,这意味着每次读取都需要对链表加写锁,这对于高并发的数据读取是不友好的,因此Oracle对经典的LRU算法做了改进。在Oracle中,一条LRU链表在逻辑上被分为两半,一半为热链,一半为冷链。同时会在每个数据块上记录Buffer Touch Counts,当一个数据块被Pin住时(通常是读取或者是写入),会判断这个数据块的Touch Counts是否在3s之前增加过,如果没有增加过那么就对Touch Counts加1,注意在这里并没有将数据块移动到链表头,所以也就不需要对LRU链表加锁,提升了高并发下数据读取的性能。当对Buffer Cache有新增写入,但是Buffer Cache中没有free的内存块时,Oracle会先从冷链尾部寻找可以淘汰的内存块,如果对应内存块的Touch Counts较大,那么就将这个内存块移动到热链上,同时Touch Counts归0;如果对应内存块的Touch Counts较小,那么就会被淘汰用于新数据的写入,新写入的数据会被放入冷链头。
MySQL的Cache设计
目前Innodb是MySQL的默认存储引擎,因此下面我们只讨论Innodb的Cache设计。Innodb的存储引擎架构和Oracle是比较类似的,也有Buffer Pool来缓存数据页(对应Oracle的Buffer Cache,默认页大小16KB),以及Query Cache(对于Oracle的Result Cache)来缓存查询结果,但是内存管理没有Oracle做的那么优雅,对于Buffer Pool及Query Cache的大小需要DBA进行手工设置。Buffer Pool中的数据页同时承担读写,当内存页被修改后成为脏页,当脏页数量达到一定比例后会有后台线程负责把脏页刷到磁盘上去。
Innodb的Buffer Pool也是基于LRU的,同样也对经典的LRU算法做了改进。Innodb的Cache淘汰算法和Oracle非常类似,也是将一条LRU链表从逻辑上分为两部分,一部分Innodb称为Young Buffer,一部分称为Old Buffer。Young Buffer存储那些比较热的数据,默认占整个Buffer的5/8;Old Buffer存储那些比较冷的数据,默认占整个Buffer的3/8。当一个新页被从磁盘读取到Buffer Pool中时,会将其放到Old Buffer的头部(也就是整个LRU链表的中间5/8的位置);当在Old Buffer中的page被再次访问,那么就将其提升到Young Buffer的头部。注意到对Young Buffer中元素的频繁访问并不会对链表加锁,也就提升了对热数据高并发访问的性能。
OceanBase的Cache设计
OceanBase的Cache设计与Oracle、MySQL相比有很大的不同。由于OceanBase存储引擎是基于LSM-tree架构的,所有的修改只会写入memtable,而sstable是只读的,这就意味着OceanBase的Cache是一个只读Cache,没有刷脏页的相关逻辑,这相对于传统数据库来说会简单一些;但同时我们对存储在sstable内的数据会进行数据编码和压缩,这意味着我们需要存储的数据是变长而非定长的,和传统数据库相比Cache的内存管理又要复杂很多。除此之外,OceanBase还是一个面向多租户的分布式数据库系统,除了和传统数据库一样要处理Cache的内存淘汰之外,在Cache内部还需要进行多租户的内存隔离。
和Oracle与MySQL类似,在OceanBase内部,也会有很多种不同类型的Cache,除了用于缓存sstable数据的block cache(类似于Oracle和MySQL的buffer cache)之外,还有row cache(用于缓存数据行)、log cache(用于缓存redo log)、location cache(用于缓存数据副本所在的位置)、schema cache(用于缓存表的schema信息)、bloom filter cache(用于缓存静态数据的bloomfilter,快速过滤空查)等等。类似于Oracle的AMM,我们设计了一套统一的Cache框架,所有不同租户的不同类型的Cache都由框架统一管理。对于不同类型的Cache,会配置不同的优先级,不同类型的Cache会根据各自的优先级以及数据访问热度做相互挤占;对于不同租户,会配置对应租户内存使用的上限和下限,不同租户的Cache会根据各自租户的内存上下限以及Server整体的内存上限做相互挤占。
为了处理变长数据的问题,OceanBase的底层Cache框架将内存划分为多个2MB大小的内存块,对内存的申请和释放都以2MB为单位进行。变长数据被简单pack在2MB大小的内存块内。为了支持对数据的快速定位,在Hashmap中存储了指向对应数据的指针,整体结构如下图所示:
Cache内存是以2MB为单位整体淘汰的,我们会根据每个2MB内存块上各个元素的访问热度为其计算一个分值,访问越频繁的内存块的分值越高,同时有一个后台线程来定期对所有2M内存块的分值做排序,淘汰掉分值较低的内存块。对于一个整体分值不高但是内部存在热点数据的2MB内存块,我们会将其热点数据从它所在的“冷块”移动到“热块”中,避免热点数据被淘汰。在数据结构上我们并没有维持类似于Oracle和MySQL的LRU链表,因此对于数据读取,OceanBase的Cache访问几乎是无锁的(除了HashMap上的Bucket锁之外),对于热点数据的高并发访问更加友好。在淘汰时,我们会考虑租户的内存上限及下限,控制各个租户中Cache内存的用量。
如下图所示,在测试的前75s,我们将tenant1的内存上限设置为3G,下限设置为2G,将tenant2的内存上限设置为4G,下限设置为3G;在后75s则将tenant1和tenant2的内存配置互换,可以看到租户间内存使用可以得到比较好的隔离。
更多架构设计相关问题,欢迎在「问答区」一起讨论。