缓存与检查点
设计原则
数据缓冲区与检查点是相辅相成的,所以放在同一个章节介绍。由于CPU与持久化设备之间存在巨大的速度差距,所以在内存中引入缓冲区缩小这个差距。从读的角度来看,将热点数据或预判用户可能读取的数据提前加载到内存中,从而将持久化设备的读时延和带宽提升至内存的时延和带宽。从写的角度来看,直接修改缓冲区中的数据而不是磁盘中的数据,可以带来两方面的优势。其一,将持久化设备的写时延和带宽提升至内存的时延和带宽。其二,将多次数据写合并为一次对持久化设备的写。
数据缓冲区将写缓存在内存中,并通过顺序写的REDO日志确保事务的数据不丢失,即事务的持久性。然而,随着写事务的不断进行,可能带来多方面问题,如日志文件持续增长,系统重启需要恢复的时间不断增加,脏块或脏页持续增多导致内存空间不足等等。这就需要通过检查点机制定期将脏块或脏页写入到持久化设备中,从而降低REDO日志长度。
在设计数据缓冲区和检查点时,有如下几点需要考虑:
-
缓冲区的效率:在多样性负载(不同数据大小、不同负载类型)、并发和LRU 算法方面的效率;
-
缓冲区的弹性:缓冲区大小的可调节性,即随着负载的变化的调节能力;
-
检查点的平衡性:体现为脏块或脏页持久化与系统恢复时长之间的平衡能力,写脏块或脏页过于频繁会与正常负载争抢资源,而过于稀少则导致系统恢复时间过长;
Oracle设计原理
缓冲区与granule
表7.2-1 缓冲区类型和控制参数
缓冲区类型 |
控制参数 |
说明 |
db_cache |
db_cache_size |
默认缓冲区大小,system、sysaux、temporary表空间都使用默认block size,默认block size表空间对应的缓冲区大小由db_cache_size设置 |
db_2k_cache_size |
除默认的block size外,还可以创建block size为2k的表空间,该类表空间对应的缓冲区大小由db_2k_cache_size设置 |
|
db_4k_cache_size |
除默认的block size外,还可以创建block size为4k的表空间,该类表空间对应的缓冲区大小由db_4k_cache_size设置 |
|
db_8k_cache_size |
除默认的block size外,还可以创建block size为8k的表空间,该类表空间对应的缓冲区大小由db_8k_cache_size设置 |
|
db_16k_cache_size |
除默认的block size外,还可以创建block size为16k的表空间,该类表空间对应的缓冲区大小由db_16k_cache_size设置 |
|
db_32k_cache_size |
除默认的block size外,还可以创建block size为32k的表空间,该类表空间对应的缓冲区大小由db_32k_cache_size设置 |
|
db_keep_cache |
db_keep_cache_size |
keep缓冲区,用于存放热点数据,提高命中率,该缓冲区的block size为默认block size,缓冲区的大小由db_keep_cache_size设置 |
db_recycle_cache |
db_recycle_cache_size |
recycle缓冲区,用于存放一次性数据,降低对热点数据的影响,该缓冲区的block size为默认block size,缓冲区的大小由db_recycle_cache_size设置 |
缓冲区用于在内存中缓存block,从而提高数据库的访问效率。如表7.2-1所示,Oracle设计了三大类缓冲区:db_cache、db_keep_cache、db_recycle_cache。db_cache用于缓存正常的数据,db_keep_cache用于缓存热点数据,而db_recycle_cache用于缓存一次性数据。Oracle设计三类缓冲区的目的是让应用有机会根据业务的特点将不同的数据缓存在不同的缓冲区中(创建表时通过storage子句指定),从而降低不同类型数据之间的相互影响,提高内存命中率。当然这三类缓冲区在各自的内存空间不足时都会采用LRU算法进行block淘汰。
db_cache是最常用的普通缓冲区。由于业务诉求的多样性,block size可以是2k、4k、8k、16k和32k,所以普通缓冲区又可以进一步细分为5种不同block size的缓冲区,大小分别由db_Nk_cache_size设置(N可以是2、4、8、16、32)。当然,这五种缓冲区中有一个是默认缓冲区(即block size为默认block size的那个缓冲区),该缓冲区的大小不是由db_Nk_cache_size设置的,而是由db_cache_size设置的。不过考虑到系统复杂度,db_keep_cache和db_recycle_cache没有设计过多的block size,仅支持默认的block size。
除了手工配置上述缓冲区大小之外,Oracle还支持AAM(Automatic Memory Management)功能,可以根据负载情况自动动态调节各类缓冲区的大小。达成自动调节的基础是granule机制,其原理是将内存按照固定大小划分为一个个granule内存块,自动调节就是以granule为粒度将一个内存块从某个缓冲区调节给另外一个缓冲区(share pool和缓冲区之间的内存也可以自动调节)。
图7.2-1 db cache缓冲区与share pool共享池
如图7.2-1所示,db cache和share pool都是由granule内存块构成的。图中上半部分和下半部分相比,AMM将granule3从db cache缓冲区调节给share pool共享池。可见每个缓冲区或者共享池等内存使用者都是一个由granule组成的链表,调节内存实际上就是将一个或多个granule内存块从一个链表移到另一个链表中。系统启动时会创建并维护一个granule Entry的数组,数组成员中的关键元素如下:
-
Granuum :数组的下标,每个granule 内存块都对应于数组中的一个元素;
-
Baseaddr :指针地址,指向和本数组元素相对应的granule 内存块的地址;
-
Granutype :本granule 内存块归属于哪个内存使用者,0 free ,2 share pool ,3 large pool ,4 java pool ,5 buffer pool ;
-
Granustate :granule 内存块的状态,allocated 表示已经申请,invalid 表示尚未申请;
-
Granuprev :将Granutype 相同的数组元素链接在一起,指向前一个数组元素;
-
Granunext :将Granutype 相同的数组元素链接在一起,指向下一个数组元素;
通过x$ksmge可以查看granule entry的使用情况。Oracle在每个granule内存块中还会维护一个granule header,通过granule header以及granule entry就可以将所有内存管理起来,并根据负载情况,在各内存使用者之间动态调配。
图7.2-2 granule内存结构(db cache)
图7.2-2给出了db cache缓冲区中单个granule内存块中的组成,总体包括granule header、buffer header和buffer block三个部分:
-
granule header :granule 内存块的通用部分,用于granule 内存块自身的管理,与db cache 等内存块使用者无关;
-
buffer block :data block ,用于存放实际数据,buffer block 的大小与具体是哪种db cache 有关,例如对于db_8k_cache 缓冲区,buffer block 的大小即为8K ,对于db_2k_cache 缓冲区,buffer block 的大小即为2K ;
-
buffer header :与buffer block 一一对应,用于对db cache 进行管理,例如LRU 、脏块控制等等;
可见,granule内存块是Oracle管理缓冲区的基石,Oracle是以granule为单位在各个内存使用者之间进行调配的。当SGA的大小小于128M时granule的颗粒度为4M,当SGA的大小大于128M时granule的颗粒度为16M。
工作集
图7.2-3 db cache、granule、DBW、工作集之间的关系
在上节我们知道db cache是由大量的granule内存块组成的。如果db cache只是一条双向链表,高并发下将产生大量的竞争,严重影响性能。为此,Oracle将单个db cache进一步分解成多个工作集,每个工作集进行独立的管理,从而降低冲突概率,提高性能。图7.2-3给出了db cache、granule、DBW、工作集之间的关系。图中假设将db cache划分为4个工作集,每个工作集占用每个granule内存块的1/4空间(buffer block)。一个工作集只会被一个DBW进程处理,但DBW采用异步IO方式,所以一个DBW进程可以处理多个工作集。实际上,工作集的数量一般等于cpu_count,而DBW的数量一般等于ceil(cpu_count/8),且所有类型的db cache的工作集数量是相等的。
表7.2-2 工作集头部部分关键信息
域 |
类型 |
含义 |
INST_ID |
NUMBER |
归属的实例id |
SET_ID |
NUMBER |
本工作集(work set)的id |
DBWR_NUM |
NUMBER |
负责本工作集的DBW |
BLK_SIZE |
NUMBER |
本工作集的block size |
CKPT_LATCH |
RAW(8) |
指向本工作集的check point queue latch |
CKPT_LATCH1 |
RAW(8) |
指向本工作集的check point queue latch |
SET_LATCH |
RAW(8) |
指向本工作集的cache buffer LRU chain latch |
CNUM_SET |
NUMBER |
本工作集中的block数量 |
NXT_REPL |
RAW(8) |
本工作集的replacement list(主链),指向链头 |
PRV_REPL |
RAW(8) |
本工作集的replacement list(主链),指向链尾 |
NXT_REPL_AX |
RAW(8) |
本工作集的replacement list(辅链),指向链头 |
PRV_REPL_AX |
RAW(8) |
本工作集的replacement list(辅链),指向链尾 |
CNUM_REPL |
NUMBER |
Replacement list主链和辅链中的总block数 |
ANUM_REPL |
NUMBER |
Replacement list辅链中的block数 |
COLD_HD |
RAW(8) |
replacement list(主链)分为冷区和热区,本指针指向链中的某个buffer header,在buffer header前面为热区,后面为冷区 |
HBMAX |
NUMBER |
replacement list主链可容纳的最大block数 |
HBUFS |
NUMBER |
replacement list主链中当前的热block数 |
NXT_WRITE |
RAW(8) |
本工作集的write list(主链),指向链头 |
PRV_WRITE |
RAW(8) |
本工作集的write list(主链),指向链尾 |
NXT_WRITE_AX |
RAW(8) |
本工作集的write list(辅链),指向链头 |
PRV_WRITE_AX |
RAW(8) |
本工作集的write list(辅链),指向链尾 |
CNUM_WRITE |
NUMBER |
write list主链和辅链中的总block数 |
ANUM_WRITE |
NUMBER |
write list辅链中的block数 |
NXT_XOBJ |
RAW(8) |
本工作集的XOBJ list(主链),指向链头,用于drop/truncate等场景 |
PRV_XOBJ |
RAW(8) |
本工作集的XOBJ list(主链),指向链尾,用于drop/truncate等场景 |
NXT_XOBJ_AX |
RAW(8) |
本工作集的XOBJ list(辅链),指向链头,用于drop/truncate等场景 |
PRV_XOBJ_AX |
RAW(8) |
本工作集的XOBJ list(辅链),指向链尾,用于drop/truncate等场景 |
NXT_XRNG |
RAW(8) |
本工作集的XRNG list(主链),指向链头,用于tablespace offline等场景 |
PRV_XRNG |
RAW(8) |
本工作集的XRNG list(主链),指向链尾,用于tablespace offline等场景 |
NXT_XRNG_AX |
RAW(8) |
本工作集的XRNG list(辅链),指向链头,用于tablespace offline等场景 |
PRV_XRNG_AX |
RAW(8) |
本工作集的XRNG list(辅链),指向链尾,用于tablespace offline等场景 |
表7.2-3 buffer header部分关键信息
域 |
类型 |
含义 |
INST_ID |
NUMBER |
本buffer header归属的实例id |
HLADDR |
RAW(8) |
管理本buffer header的hash chain latch的地址 |
BLK_SIZE |
NUMBER |
本工作集的block size |
NXT_HASH |
RAW(8) |
hash链,指向双向链表中的下一个buffer header |
PRV_HASH |
RAW(8) |
hash链,指向双向链表中的上一个buffer header |
NXT_REPL |
RAW(8) |
replacement list链、Write链、XOBJ链、XRNG链,指向双向链表中的下一个buffer header |
PRV_REPL |
RAW(8) |
replacement list链、Write链、XOBJ链、XRNG链,指向双向链表中的上一个buffer header |
FLAG |
NUMBER |
BIT标志位: 0x0000001:buffer dirty 0x0000002:notify dbwr after change 0x0000004:modification started, no new writes 0x0000008:block logged 0x0000010:temporary data(no redo for changes) 0x0000020:being written, can’t modify 0x0000040:waiting for write to finish 0x0000080:multiple waiters when gc lock acquired 0x0000100:recovery reading, do not reuse, being read 0x0000200:unlink from lock element(make non-current) 0x0000400:write block & stop using for lock down grade 0x0000800:write block for cross instance call 0x0001000:reading from disk into KCBBHCR buffer 0x0002000:has been gotten in current mode 0x0004000:stale(unused CR buf made from current) 0x0008000:deferred ping 0x0010000:direct access to buffer contents 0x0020000:hash chain dump used in debug print routine 0x0040000:ignore redo for instance recovery 0x0080000:sequential scan only flag 0x0100000:indicates that buffer was prefetched 0x0200000:buffer hash been written once 0x0400000:buffer is logically flushed 0x0800000:resilvered already (do not redirty) 0x1000000:buffer is nocache 0x2000000:redo generated since block read 0x10000000:skipped write for checkpoint 0x20000000:buffer is directly from a foreign DB 0x40000000:flush after writing |
LOBID |
NUMBER |
如果缓冲区属于SecureFiles对象,该字段是SecureFiles对象的唯一标识符 |
LRU_FLAG |
NUMBER |
本block在replacement list中的位置,bit标志位: 1:LRU dump flag used in debug print routine 2:moved to tail of LRU(for extended stats) 4:on auxiliary list 8:hot buffer(not in cold portion of LUR) |
TS |
NUMBER |
本block归属的表空间 |
FILE |
NUMBER |
本block归属的绝对文件号 |
DBARFIL |
NUMBER |
本block归属的相对文件号 |
DBABLK |
NUMBER |
本block对应的块号 |
CLASS |
NUMBER |
本block的类型: 1:data block;2:sort block;3:save undo block;4:segment header;5: save undo header;6:free list;7:extent map;8:1st level bmb;9:2nd level bmb;10:3rd level bmb;11:bitmap block;12:bitmap index block;13:file header block;14:unused;15:system undo header;16:system undo block;17:undo header;18:undo block |
STATE |
NUMBER |
本block的状态: 0:FREE, no valid block image 1:XCUR,a current mode block, exclusive to this instance 2:SCUR, a current mode block, shared with other instances 3:CR, a consistent read(stale) block image 4:READ, buffer is reserved for a block being read from disk 5:MREC, a block in media recovery mode 6:IREC, a block in instance(crash) recovery mode 7:WRITE, writing to disk 8:PL, past image block involved in cache fusion block transfer |
MODE_HELD |
NUMBER |
本block当前被pin的模式: NULL:空 SHR:共享 EXL:排它 |
LE_ADDR |
RAW(8) |
指向本block归属的PCM锁地址 |
OBJ |
NUMBER |
本block归属的对象 |
BA |
RAW(8) |
指向block的内存地址 |
CR_SCN_BAS |
NUMBER |
Consistent Read的SCN |
CR_SCN_WRP |
NUMBER |
|
CR_XID_USN |
NUMBER |
Consistent Read的XID |
CR_XID_SLT |
NUMBER) |
|
CR_XID_SQN |
NUMBER |
|
CR_UBA_FIL |
NUMBER |
Consistent Read的UBA |
CR_UBA_BLK |
NUMBER |
|
CR_UBA_SEQ |
NUMBER |
|
CR_UBA_REC |
NUMBER |
|
LRBA_SEQ |
NUMBER |
本block为脏块,该脏块第一次修改对应的REDO地址 |
LRBA_BNO |
NUMBER |
|
LRBA_BOF |
NUMBER |
|
HRBA_SEQ |
NUMBER |
本block为脏块,该脏块第新一次修改对应的REDO地址 |
HRBA_BNO |
NUMBER |
|
HRBA_BOF |
NUMBER |
|
US_NXT |
RAW(8) |
已经拥有本block的使用者双向链表,即已经pin住本block的使用者,指向链表的下一个pin对象 |
US_PRV |
RAW(8) |
已经拥有本block的使用者双向链表,即已经pin住本block的使用者,指向链表的上一个pin对象 |
WA_NXT |
RAW(8) |
阻塞等待本block的使用者双向链表,因为pin不相容而等待的的使用者,指向链表的下一个pin对象 |
WA_PRV |
RAW(8) |
阻塞等待本block的使用者双向链表,因为pin不相容而等待的的使用者,指向链表的上一个pin对象 |
CKPTQ_NXT |
RAW(8) |
checkpoint queue双向链表,指向链中下一个buffer header |
CKPTQ_PRV |
RAW(8) |
checkpoint queue双向链表,指向链中上一个buffer header |
FILEQ_NXT |
RAW(8) |
file queue双向链表,指向链中下一个buffer header |
FILEQ_PRV |
RAW(8) |
file queue双向链表,指向链中上一个buffer header |
OQ_NXT |
RAW(8) |
对象双向链表,指向链中下一个buffer header |
OQ_PRV |
RAW(8) |
对象双向链表,指向链中上一个buffer header |
AQ_NXT |
RAW(8) |
辅助对象双向链表,指向链中下一个buffer header |
AQ_PRV |
RAW(8) |
辅助对象双向链表,指向链中上一个buffer header |
OBJ_FLAG |
NUMBER |
对象标志: object_ckpt_list:本block同时在object ckpt中 |
TCH |
NUMBER |
touch count |
TIME |
NUMBER |
touch time |
每个工作集都有一个工作集头部和若干buffer header以及与buffer header一一对应的buffer block组成,其中工作集头部和buffer header的详细信息分别如表7.2-2和7.2-3所示。Oracle正是通过工作集头部和buffer header中的相关结构将工作集中的所有buffer block管理起来的,其中关键的链表有:
-
replacement 链表:用于将本工作集中的所有buffer block 按照冷热情况管理起来,当buffer block 不足时可以及时将不常用的block 从缓冲区剔除。replacment 链表由主链和辅链组成,涉及的相关元素有工作集头部中的NXT_REPL 、PRV_REPL 、NXT_REPL_AX 、PRV_REPL_AX 、CNUM_REPL 、ANUM_REPL 、CNUM_REPL 、ANUM_REPL 、COLD_HD 和buffer header 中的NXT_REPL 、PRV_REPL 、LRU_FLAG ;
-
checkpoint queue 链表:用于将本工作集中的所有脏块按照LRBA 顺序组成双向链表,用于checkpoint 决策脏块的写入策略,涉及的相关元素有工作集头部中的CKPT_LATCH 、CKPT_LATCH1 (链表头尾的指针待进一步核实)和buffer header 中的CKPTQ_NXT 、CKPTQ_PRV ;
-
File queue 链表:用于将本工作集中的所有脏块按照归属的数据文件组成双向链表,用于按文件决策脏块的写入策略,涉及的相关元素有buffer header 中的FILEQ_NXT 、FILEQ_PRV (工作集头部中的相关部分待进一步核实);
-
Write 链表:用于将本工作集中的部分冷脏块管理起来,当buffer block 不足时可以及时将这些脏块刷入磁盘。Write 链表由主链和辅链组成,涉及的相关元素有工作集头部中的NXT_WRITE 、PRV_WRITE 、NXT_WRITE_AX 、PRV_WRITE_AX 、CNUM_WRITE 、ANUM_WRITE 和buffer header 中的NXT_REPL 、PRV_REPL ;
-
其它写链表:按照对象的视角将相关的脏块组织起来,尽快将某个对象的脏块写入磁盘,主要有XOBJ 链表和XRNG 链表,涉及工作集头部中的NXT_XOBJ 、PRV_XOBJ 、NXT_XOBJ_AX 、PRV_XOBJ_AX 、NXT_XRNG 、PRV_XRNG 、NXT_XRNG_AX 、PRV_XRNG_AX 和buffer header 中的OQ_NXT 、OQ_PRV 、AQ_NXT 、AQ_PRV 、OBJ_FLAG ;
通过上述链表,可以将所有buffer header管理起来,而buffer header与buffer block是一一对应的,所以本质上就是将所有buffer block管理起来了。
Hash Chain
除了通过工作集管理buffer block,我们还需要查询和定位某个特定的block。查询某个block时首先要确定该block是否已经缓存在缓冲区中,如果已经在缓冲区中,需要快速定位到该block。和library cache类似,Oracle也是通过hash链进行快速定位和查询的。
图7.2-4 hash chain管理示意图
如图7.2-4所示,整个缓冲区的hash管理由三部分组成:
-
buffer header :每个buffer header 中有NXT_HASH 和PRV_HASH 双向指针,将hash 值相同的buffer header 链接在一起,组成双向链表。实际上,hash 值相同的buffer header 有两种情况,分别是hash 冲突和同一个block 的多个CR 版本;
-
bucket :hash 桶,数量由参数_db_block_hash_buckets 配置,默认为接近于db_block_buffers*2 的一个2 的幂数;
-
cache buffer chains latch :保护hash 链表的latch ,一般每个latch 保护32 个bucket ,即32 条hash 链;
可见,hash链是全局的,不受限于某个具体的工作集。通过绝对文件号和块号计算hash值,就可以快速定位某个block是否在缓冲区中。下面详细看一下通过hash chain操作某个block的过程:
-
通过文件号、块号、ClassNumber 计算出hash 值,定位到对应的cache buffer chain latch 和bucket ;
-
申请该cache buffer chain latch ;
-
遍历bucket 对应的cache buffer chain ,找到对应的buffer header ,并pin 住该buffer header ;
-
释放该cache buffer chain latch ;
-
操作找到的buffer block ;
-
申请该cache buffer chain latch ;
-
unpin 住该buffer header ;
-
释放该cache buffer chain latch ;
整个互斥过程分为cache buffer chain latch和pin两个部分。一个cache buffer chain latch需要保护多个cache buffer chain,同时数据库在操作具体buffer header时花费的时间可能会比较长,所以Oracle设计了latch和pin两个阶段,有效地提升并发性。当然,这样也会引入额外的成本,一次buffer涉及两次latch申请(pin和unpin),不过这和长时间占用latch相比仍然是有益的。在某些情况下,数据库如果预测到短期还会访问该block,会延后unpin动作,提升整体效率。
图7.2-5 pin管理示意图
当多个session同时操作某个block时,pin机制就会发挥作用。pin有共享(S)和排它(X)两种模式,图7.2-5分别给出了共享和排它两种示例。每个buffer header有两对链表指针:
-
US_NXT 和US_PRV :User’s List ,用于将当前正在操作本buffer header 的session 链接在一起,即在User’s List 中的session 是已经拥有本buffer header 的session ;
-
WA_NXT 和WA_PRV :Waiter’s List ,用于将等待本buffer header 的session 链接在一起,即在Waiter’s List 中的session 是阻塞等待的session ;
某个session操作某buffer header时,如果该buffer header尚未被其它session pin住,或者pin的模式是相容的,那么将本session加入到该buffer header的User’s List中,本session可以操作该buffer header。如果该buffer header已经被其它session pin住,且pin的模式不相容,那么将本session加入到该buffer header的Waiter’s List中,本session阻塞等待。阻塞等待的事件为“buffer busy wait”(从10g以后,如果是因为等待其它session从磁盘读数据块,等待事件调整为“read by other session”)。
表7.2-4 pin模式相容矩阵
已经持有的模式 |
请求的模式 |
|||||
NEW |
SHR |
EXL |
CR |
CRX |
NULL |
|
NULL |
Y |
Y |
Y |
Y |
Y |
Y |
SHR |
N |
Y |
N |
Y |
Y |
N |
EXL |
N |
N |
N |
N |
N |
N |
实际上Oracle内部是通过调用函数kcbget(descriptor, lock_mode)获得期望的buffer header,其中descriptor描述期望操作的具体块,lock_mode表示期望的加pin模式:
-
NEW :以排它方式访问新块;
-
SHR :以共享方式访问当前块;
-
EXL :以排它方式访问当前块;
-
CR :以共享方式访问CR 块;
-
CRX :CR 模式的变体;
-
NULL :用于保持对块的引用,防止被换出缓存;
每个pin结构都会记录session的地址和期望的pin模式。pin结构又称为buffer handle,通过_db_handles_cached设置每个进程可以缓存的pin对象数(默认为5),_db_handles设置整个系统可以缓存的pin对象数(默认为_db_handles_cached*进程数),_cursor_db_buffers_pinned设置单个游标可以使用的最大pin对象数(默认为db_handles_buffers/进程数-2)。当User List中的session都完成操作后,会唤醒Waiter List中的session。该session被唤醒后会加入到User List中,并开始操作buffer header。出于死锁等健壮性考虑,session阻塞等待的默认时间为1秒(可通过隐藏参数_buffer_busy_wait_timeout调整)。当session超时自我唤醒后认为发生了死锁,报“buffer deadlock”等待时间(实际上并没有等待),然后释放该session已经占有的所有pin,之后再重新申请。
下面再回顾一下Oracle操作普通block的过程:
-
Step1 :计算出bucket 后,以独占方式申请对应的cache buffer chain latch ;
-
Step2 :遍历cache buffer chain ,找到对应的buffer header ,并pin 住该buffer header ;
-
Step3 :释放该cache buffer chain latch ;
-
Step4 :操作对应的buffer block ;
-
Step5 :再次以独占方式申请该cache buffer chain latch ;
-
Step6 :unpin 该buffer header ;
-
Step7 :释放该cache buffer chain latch ;
按照上述步骤操作block的前提是step4消耗的时间比较长,应当仅可能地降低latch的持有时间。但是其代价也是非常明显的,两次latch操作且都是独占方式,同时还需要pin和unpin。而有些场景下,step4是只读操作且非常快速,按照上述正常步骤操作反而影响性能。为此,Oracle设计了Examination方式(consistent get - examination):
-
Step1 :计算出bucket 后,以共享方式申请对应的cache buffer chain latch ;
-
Step2 :遍历cache buffer chain ,找到对应的buffer header ;
-
Step3 :读取对应的buffer block 的相关内容;
-
Step4 :释放该cache buffer chain latch ;
可见,当step3的耗时极短时,examination方式的优势非常明显。一般情况下,对于根索引block、索引非叶子block、唯一索引的叶子block、通过唯一索引访问的表block、undo block的读操作都会采用examination方式。
LRU/TCH
内存资源是有限的,所以缓冲区也是有限的。当缓冲区已经被buffer block占满,就需要一种机制将不常用的buffer block从缓冲区中换出,给其它buffer block腾出空间,这就是Oracle的LRU/TCH机制。和hash chain不同,为了提升性能LRU/TCH不是全局的,而是属于工作集的,即每个工作集都有一个独立的LRU/TCH,该LRU/TCH由相应的cache buffer lru chain latch保护。Oracle选择待置换出去的buffer block时,首先随机选择某个工作集,然后以立刻模式尝试获取该工作集的cache buffer lru chain latch。如果获得latch则在该工作集的LRU/TCH中寻找待置换的buffer block,否则按照某种顺序遍历各工作集直到获得某个工作集上的lru chain latch。
图7.2-6 replacement主链示意图
下面以单个工作集的LRU/TCH为例讲解Oracle的LRU机制,每个LRU/TCH由一个replacement主链和一个replacement辅链组成。replacement主链如图7.2-6所示,通过示例我们发现replacement主链由如下三部分组成:
-
MRU :工作集中的nxt_repl 指向replacement 主链的热端,即处于该端的buffer block 属于最常用的block ;
-
LRU :工作集中的prv_repl 指向replacement 主链的冷端,即处于该端的buffer block 属于最不常用的block ;
-
SCP :工作集中的cold_hd 指向replacment 主链的冷热隔离边界,一般取replacement 主链的中间位置(由隐藏参数_db_percent_hot_default 控制,默认50 );
replacement主链实际上是由buffer header组成的双向链表,buffer header中有表征buffer block使用热度的TCH和TIM。TCH记录该buffer block被使用的次数,即buffer block每被复用一次,该buffer block对应的TCH就加一。然而,如果在某个极短的时间内大量反复使用某个buffer block,之后再也不使用该buffer block,会造成该buffer block假热。为此,Oracle引入了TIM,用于记录该buffer block上次更新TCH的时间。只有本次复用的时间减去上次更新的时间(即TIM)超过某个时间阈值(默认3秒),才会增加TCH,从而有效地避免了buffer block假热。
有了replacement主链的MRU、LRU、SCP以及buffer header中的TCH/TIM这些概念之后,我们来看一下Oracle搜索replacement主链的算法。从LRU冷端开始搜索:
-
如果遇到已经pin 住的buffer header ,直接跳过;
-
如果遇到dirty 状态且TCH 小于_db_aging_hot_criteria (默认为2 )的buffer header ,将其转移到write 主链中,从而加快冷dirty buffer block 的写入速度,降低replacement 主链的搜索长度;
-
如果遇到TCH 小于_db_aging_hot_criteria 的buffer header ,直接复用,复用后将该buffer header 的TCH 置为1 ,并转移到replacement 主链的SCP 位置;
-
如果遇到TCH 大于等于_db_aging_hot_criteria 的buffer header ,说明该块是热块,将其转移至replacement 主链的MRU 端(随着MRU 端不断插入新的buffer header ,就有相应的buffer header 不断跨过SCP ,一旦某buffer header 跨过SCP ,该buffer header 的TCH 降为1 ),并对TCH 做如下调整:
-
如果_db_aging_stay_count 大于等于_db_aging_hot_criteria ,将该块的TCH 除以2 ;
-
如果_db_aging_stay_count 大于等于_db_aging_hot_criteria ,该块的TCH 保持不变,并将该块的TCH 赋给_db_aging_stay_count ;
-
通过上述方法从LRU端向MRU端搜索,只到找到可复用的buffer header。如果搜索了replacement主链的40%(可通过隐藏参数_db_block_max_scan_pct控制,默认40)仍然没有找到可复用的buffer header,说明系统存在太多脏页,Oracle会停止搜索,并向DBWR进程发送消息,让其尽快将脏块写入磁盘。每次都从replacement主链的LRU端开始搜索,有可能需要较长一段时间才能找到合适的buffer header。为了提高搜索效率,Oracle引入了replacement辅链。其本质是SMON进程每3秒醒来一次,会搜索replacement主链,提前将TCH小于_db_aging_hot_criteria且非dirty、非pin的buffer block转移到replacement辅链的MRU端。实际上,系统刚启动时,所有的buffer header都在replacement辅链中,replacement主链为空。随着数据不断被访问,replacement主链会越来越长,replacement辅链会越来越短。SMON进程会在后台维持replacement主链和辅链之间的平衡,一般维持在75:25的平衡比例。至于某个buffer header在replacement主链还是辅链中,在主链的热区还是冷区中,可以通过buffer header的lru_flag查看。
不管是操作replacement主链还是replacement辅链,都需要在cache buffer lru chain latch保护下进行。当我们复用buffer block时,由于涉及实际block内容的更改,还需要更改该buffer header归属的hash chain,还需要申请cache buffers chain latch,这时就需要考虑latch的level,防止死锁。
LRU/TCH通过上述机制将热数据保留在内存中,将冷数据及时从内存中换出。然而,对于全表扫描或者全索引扫描场景,由于涉及大规模数据的读取,如果不对LRU/TCH机制做调整,热数据很可能全部被清出内存。为此,Oracle对全表扫描和全索引扫描场景进行了优化。在扫描读操作实施前,会评估待扫描的总数据量:
-
总数据量小于buffer cache 的2% :采用通用的LRU/TCH 机制,不做特殊处理;
-
总数据量在buffer cache 的2%~10% 之间:TCH 设置为0 ,并直接加入到replacement 主链的LRU 端,从而快速被移到replacement 辅链中。当然,如果此时有其它session 同时访问该buffer block ,该buffer header 有可能被移到replacement 主链的MRU 端;
-
总数据量在buffer cache 的10%~25% 之间:直接在replacement 辅链上批量获取buffer header ,读完后仍然加入到replacement 辅链中,不增加TCH ,不进入replacement 主链;
-
总数据量大于buffer cache 的25% :采用direct path read 模式,不走LRU/TCH 机制,直接在session 的PGA 中进行,不消耗buffer cache 和latch 。当然该模式下,需要确保扫描设计的block 在磁盘上都是最新的,所以在某段数据之前需要对该段数据做一次checkpoint ,确保落盘(direct path read 还有另外一个缺点,可能导致重复的延迟清理操作);
系统启动时或者执行flush cache后,所有的buffer header都挂在replacement辅链上。
CR与扫描
在前面章节我们知道,Oracle是通过latch和pin来协调buffer block的读写的。Pin分为共享和独占两种模式,共享和共享是相容的,但共享和独占、独占和独占是不相容的,需要阻塞等待。不过,Oracle为了进一步提升并发性,将共享和独占也优化为不阻塞。共享和独占主要有两种情况:先读后写同一个block,或者先写后读同一个block。对于先读后写同一个block,Oracle的优化过程如下:
-
Step1 :计算出bucket 后,以独占方式申请对应的cache buffer chain latch ;
-
Step2 :遍历cache buffer chain ,找到对应的buffer header ,尝试以独占的方式pin 住该buffer header ,发现不相容,改为以共享的方式pin 住该buffer header ,并修改状态以表征此block 正在被克隆(防止其它session 同时做克隆);
-
Step3 :释放该cache buffer chain latch ;
-
Step4 :申请一个新的buffer header ,将原block 的内容克隆过来,并完成修改(写操作);
-
Step5 :再次以独占方式申请该cache buffer chain latch ;
-
Step6 :将新的buffer header 加入到hash chain 中,并将新buffer header 的state 置为XCUR ,表示当前版本;
-
Step7 :将原buffer header 的state 状态改为CR ,表示是克隆版本,并将原buffer header 中属于本自己的pin 对象释放,以及其它阻塞等待的pin 迁移到新的buffer header 上;
-
Step8 :释放该cache buffer chain latch ;
通过上述方法,Oracle将先读后写同一个block优化为不阻塞。对于先写后读,Oracle会优先寻找本block的CR:
-
如果存在CR ,且CR 的scn 等于读操作的scn ,直接读取;
-
如果存在CR ,且CR 的scn 大于读操作的scn ,通过undo 构造新的CR ,然后读取;
-
如果不存在CR ,或者CR 的scn 小于读操作的scn ,在当前block 上加共享pin ,阻塞等待;
可见,先写后读的优化存在场景限制,需要等待前序的pin对象释放。不过不管是哪种情况,都是通过构造CR来提高并发性的。这样就会导致同一个block在内存中有多个版本,如果不做控制就会浪费内存。为此,Oracle定义了隐藏_db_block_max_cr_dba(默认为6),即block的CR版本数不允许超过_db_block_max_cr_dba。同时为了提高搜索消息,当前版本的block会放在所有CR版本的前面。
下面看看物理IO的情况。从磁盘读取数据的时间会比较长,为了防止多个session重复读取相同的数据,Oracle设计的物理IO过程如下:
-
Step1 :计算出bucket 后,以独占方式申请对应的cache buffer chain latch ;
-
Step2 :遍历cache buffer chain ,找不到对应的buffer header ,需要从磁盘上读物读取。申请一个新的buffer header ,初始化buffer header 并加pin ,然后加入到hash chain 中;
-
Step3 :释放该cache buffer chain latch ;
-
Step4 :从磁盘读取该block 数据;
-
Step5 :再次以独占方式申请该cache buffer chain latch ;
-
Step6 :unpin ;
-
Step7 :释放该cache buffer chain latch ;
虽然step4的时间会比较长,但对应的buffer header已经在hash chain中,这样其它session读相同的block时,就会发现已经有session在读,只要将这些session pin在该buffer header上即可,从而有效地防止重复读。对于物理IO来说,Oracle支持如下三种IO:
-
db file sequence read :一次只读一个block ,例如点查等场景;
-
db file parallel read :一次从多个位置上读取多个block ,例如根据索引中间block 读取多个叶子block ;
-
db file scattered read :一次从相邻位置读取多个block ,例如顺序扫描;
Checkpoint
Oracle在运行过程中会持续不断地写redo日志,从而保证数据的持久性。不过数据block也需要在适当的实际刷入磁盘,从而保证磁盘中的数据仅可能地接近内存中的数据,这就是检查点机制。Oracle根据检查点目的的不同,设计了如下几种检查点:
-
全量检查点:将数据库中的所有脏块全部刷入磁盘,例如shutdown instance 、手工执行alter database close 、手工执行alter system checkpoint local (单个RAC 实例)、手工执行alter system checkpoint global (所有RAC 实例)等等;
-
文件检查点:将特定数据文件的所有脏块刷入磁盘,例如手工执行alter tablespace begin backup 、手工执行alter tablespace offline 等等;
-
对象检查点:将特定某个对象的所有脏块刷入磁盘,例如手工执行drop table xxx purge 、手工执行drop idnex xxx 、手工执行truncate table xxx 等等;
-
并行查询检查点:并行查询走direct path read ,数据不走buffer cache ,所以需要提前将该对象相关的所有脏块刷入磁盘;
-
增量检查点:就是根据active redo 日志量的情况,持续不断地将脏块刷入磁盘,在写脏块和系统恢复时间之间取得平衡;
-
日志切换检查点:和增量检查点类似,redo 日志文件切换后,为了保证redo 日志文件有足够的余量空间,需要将相关脏块刷入磁盘;
根据上述不同的检查点,Oracle设计了三种刷新队列:
-
checkpoint queue :按照redo 日志的顺序组织dirty buffer block (buffer header ),用于降低实例异常重启的恢复时间;
-
file queue :按照数据文件组织dirty buffer block (buffer header ),用于tablespace backup 、tablespace offline 等操作,可以高效地将属于某个数据文件的dirty block 识别出来,并刷入磁盘;
-
object queue :按照对象组织dirty buffer block (buffer header ),用于drop table 、truncate table 、direct path read 等操作,可以高效地将属于某个对象的dirty block 识别出来,并刷入磁盘;
同一个脏块会同时出现在上述三个队列中,当然一旦完成持久化也会同时从这三个队列中摘除。三个队列的目的是从不同角度组织脏块,从而不同场景下都可以高效地找到对应的脏块,并完成持久化工作,所以三个队列也是由同一个checkpoint queue latch保护。
图7.2-7 checkpoint queue组织结构
首先看Oracle关于checkpoint queue的设计,图7.2-7给出checkpoint queue的组织结构。session进程在修改数据block时,如果是第一个将该block从非脏块变为脏块,需要申请申请checkpoint queue latch,然后将该block从为尾部加入到checkpoint queue中。后继对该block的再次变更,不涉及脏块状态的变化,所以不需要操作checkpoint queue,也就不需要申请checkpoint queue latch。Buffer header中的LRBA记录的是该buffer block的第一个redo日志的日志(相对于磁盘上的对应block),而HRBA记录的是该block的最近一条redo日志的地址。可见,checkpoint queue是按照LRBA的顺序组织起来的,按照checkpoint queue的顺序写脏块就可以基本反映redo日志的顺序,有效地实现了redo日志文件从active状态向inactive状态的转换。然而,同一个block可能被多次修改,导致HRBA大于LRBA。DBWR在持久化该block时,如果HRBA对应的redo日志已经持久化,DBWR正常持久化该block即可。否则由于WAL原则的约束,需要将HRBA的地址发送给LGW,让其尽快完成该地址之前的日志持久化,同时跳过该block,继续持久化队列中的后继block(刷完后继多个block还会回来检查跳过的block对应的redo日志是否已经完成持久化)。
图7.2-7还给出了另外一个重要的信息,checkpoint queue很可能并不是按照工作集为粒度组织的,而是按照DBWR为粒度组织的(尚不清楚checkpoint queue队列首部的存放位置)。这是因为主要是前台session进程和DBWR进程操作checkpoint queue,而前台session进程只有在block第一次从非脏块变更为脏块时才需要操作checkpoint queue,所以checkpoint queue的latch冲突并不高。当然为了尽可能提高并发性,采用了双队列机制。当DBWR访问某个checkpoint queue时,前端session进程可以操作另外一个checkpoint queue,分散访问。正常情况下,前端session进程不会调整已经在checkpoint queue中的buffer header,然而当发生先读后写时,前端session进程会将原来的块变更为CR并创建新的当前块,这时就需要调整checkpoint queue,并申请checkpoint queue latch,即将checkpoint queue中的块置换为新的当前块。
增量checkpoint就是每次将部分脏块持久化。这些脏块每次不会太多,导致IO资源占用过大。也不会太少,导致active状态的redo日志量过大,既影响实例异常重启时恢复时间,也可能导致redo日志空间不足引起短暂性不可访问。DBWR每3秒钟唤醒一次,计算出本次的目标redo日志地址(根据fast_start_mttr_target、fast_start_io_target、log_checkpoint_timeout、log_checkpoint_interval、_target_rba_max_lag_percentage综合计算)。然后遍历归属于本DBWR的所有checkpoint queue。对于某个checkpoint queue而言,从队列头部开始遍历,如果buffer header的LRBA小于等于目标redo日志地址,该block就需要持久化。DBWR在遍历checkpoint queue时,以willing-to-wait方式申请checkpoint queue latch。实际上,DBWR在持久化脏块时会做相邻块合并,批量写入,从而进一步优化IO。
DBWR在按批持久化脏块时,对持久化本身也会记录redo日志。日志内容主要包括每个脏块的地址、scn等。当系统崩溃恢复时,读取该日志就可以这些block是否需要应用redo日志,否则需要读取这些block,并拿这些block中的scn和redo日志中记录的scn进行比较才知道是否需要应用redo日志。DBWR写redo日志的过程和通用过程比较类似,需要申请copy和allocation latch,然后给LGW发送消息,但不需要等待LGW真正完成日志持久化。
最后CKPT进程将checkpoint的完成情况写入到各数据文件以及控制文件中:
-
system checkpoint scn :系统检查点scn ,存在于控制文件中,Oracle 会根据checkpoint 情况持续更新该scn (对于checkpoint 队列第一个buffer header 的LRBA );
-
datafile checkpoint scn :文件检查点scn ,存在于控制文件中,每个数据文件一个,Oracle 会根据checkpoint 情况持续更新该scn (只读tablespace 不更新);
-
datafile header start scn :文件检查点scn ,存在于每个数据文件的文件头中,Oracle 会根据checkpoint 情况持续更新该scn ;
系统异常重启后分为两个阶段应用redo日志。第一阶段,从LRBA处开始扫描redo日志文件,列出所有潜在的block。同时根据批量写脏块日志将磁盘上已经是最新的block剔除,判断原则是批量写时该block的scn大于等于redo日志的scn。第二阶段,对仍然需要应用重做日志的block进行并行回放。在前面章节我们知道,正常运行期间,Oracle也是先创建redo日志,存放在日志缓冲区中,并将日志应用到记录上。可见,恢复期间的应用日志和运行态的应用日志是相同的代码,不同的知识redo日志源不同而已。
Write链
Checkpoint主要按照崩溃恢复的时间决策将哪些脏块刷入磁盘,但有时我们还需要从其它维度来考虑。当replacement主链中有大量的TCH小于_db_aging_hot_criteria且为脏的块时,这些脏块不能移入replacement辅链。既导致replacement主链过长,影响搜索效率,又可能导致前端session进程搜索了_db_block_max_scan_pct的buffer header仍然找不到可用块,使得前端session阻塞等待。为此,Oracle在每个工作集中又设计了write主链和write辅链,工作集中的相关变量有NXT_WRITE、PRV_WRITE、NXT_WRITEAX、PRV_WRITEAX、CNUM_WRITE和ANUM_WRITE。
图7.2-8 Write主链生成示意图
如图7.2-8所示,前端session进程通过扫描replacement辅链获得可用的buffer block。如果replacement辅链为空,则需要进一步扫描replacement主链。为了提高扫描replacement主链的效率,前端session进程对遇到的状态为脏、未被pin住且TCH小于2的块,会将该块从replacement主链上摘除,并加入到write主链上。当前端session进程扫描了_db_block_max_scan_pct仍然找不到可用的buffer block,会给DBWR进程发送消息,并将自己阻塞在free buffer waits事件上,等待DBWR进程尽快将脏数据写盘。当然,当write主链到达一定长度,虽然还未到达_db_block_max_scan_pct,前端session进程也会给DBWR进程发送消息,只不过不会阻塞在free buffer waits事件上。
DBWR进程被唤醒后,执行如下逻辑:
-
step1 :从尾端开始扫描write 主链,如果遇到的block 对应的redo 日志尚未写盘,跳过该block ,并给LGW 发送消息,让其尽快将相应的redo 日志写盘。如果遇到的block 对应的redo 日志已经写盘,对该block 加pin ,然后从write 主链中摘除,并加入到write 辅链中;
-
step2 :重复step1 的动作,直到完成整个write 主链的扫描,或者扫描的block 数达到_db_writer_scan_depth_pct (默认25 );
-
step3 :将write 辅链中的block 添加到批量写结构中(该结构对多个DBWR 进程开放,会按文件相邻性进行重组),然后触发异步写文件,并对本次写盘记redo 日志;
-
step4 :完成写盘后,遍历write 辅链,对每个block ,清理buffer header 的dirty 状态以及LRBA ,并将block 从write 辅链和checkpoint queue 中摘除,并加入到replacement 辅链中;
DBWR不断地重复执行step1到step4,直到归属于本DBWR的所有工作集中的write主链中的block都完成持久化。
表7.2-5 dirty block写优先级
写类型 |
优先级 |
写类型 |
优先级 |
LRU-P(RAC) |
高 |
增量检查点 |
中 |
并行查询检查点 |
高 |
cold dirty buffers(老化) |
中 |
LRU-XO |
高 |
用户触发的检查点 |
低 |
LRU-XR |
高 |
表空间检查点 |
低 |
实际上,除了write主辅链之外,Oracle还设计了LRU-P、LRU-XO、LRU-XR多个写队列,用于区分写优先级,不过这些队列也是由同一个cache buffer lru chain latch保护。这些队列的含义如下:
-
LRU-P :ping-list ,用于RAC ,不过后期Oracle 版本在实例间传递block 时,已经不需要提前将脏块刷入磁盘;
-
LRU-XO :reuse object list ,用于将可复用的某对象相关脏块写入磁盘(drop/truncate );
-
LRU-XR :reuse range list ,用于将可复用的相关脏块写入磁盘(alter tablespace begin backup 、alter tablespace offline 、alter tablespace read only );
同一个脏块不能同时在replacement链和write链上,但同一个脏块可能既会在write链上,同时也会在某个LRU-P、LRU-XO或者LRU-XR上。write链是通用的写队列,LRU-P、LRU-XO、LRU-XR是针对某些特殊场景的写链。这些写链的机制是类似的,都采用主链和辅链机制,DBWR进程操作这些链上脏块的机制也是相同的。不同的是不同的链针对不同的场景,DBWR操作这些链的优先级也不同。具体优先级如表7.2-5所示,write链之外的其它写链优先级都比较高,都需要立刻写出。
最后,我们在总结一下DBWR的触发条件:
-
每3 秒触发一次;
-
checkpoint 触发;
-
前端session 进程扫描replacement 主链找不到可用块触发;
-
write 主链和辅链上的block 达到_db_large_dirty_queue 触发;
-
用户命令触发,例如将表空间设置为离线、只读,对表空间进行备份等;
MySQL设计原理
缓冲区与Chunk
MySQL的缓冲区同样用于在内存中缓冲page,其大小由innodb_buffer_pool_size设置。和Oracle不同的是其只有一种类型缓冲区,且同时仅支持一种大小的page。虽然page大小可以通过innodb_page_size设置(支持4K、8K、16K、32K、64K),但不支持多种大小并存。
图7.3-1 缓冲区结构
为了支持动态调整缓冲区大小,缓冲区是以chunk为单位组织的,每个chunk的大小由innodb_buffer_pool_chunk_size设置,默认128M。如图7.3-1所示,缓冲区对应于buf_pool_t结构,是由buf_chunk_t结构组成的数组,数组中每个buf_chunk_t结构中都有一个指针,指向其对应的chunk size的内存空间,用于缓存实际的page数据。因此,缓冲区的动态增加或减小是以chunk size为单位进行的。
图7.3-2 chunk内存的结构
对于每个chunk而言,分为buf_block_t和page两个部分。如图7.3-2所示,buf_block_t和page一一对应,page用于缓冲数据页在内存中的映射,和innodb_page_size相等,而buf_block_t是page在内存中的控制结构。实际上,buf_block_t结构就是通过其中的frame指针指向page内存的。
缓冲区实例
为了解决高并发下缓冲区本身引起的并发冲突问题,MySQL同样对缓冲区进行了切分。每个子缓冲区就是一个缓冲区实例,对应一个buf_pool_t结构。缓冲区实例的数量可以通过配置项innodb_buffer_pool_instances进行设置,大小为innodb_buffer_pool_size的内存会以chunk为单位均匀地分配到各个缓冲区实例中。
对于任何page的访问,都可以基于表空间号和页号计算出哈希值,从而快速定位到对应的缓冲区实例,即某个特定page归属于的缓冲区实例是确定的。具体算法如下:
(space_id <<20+space_id+page_id>>6)%innodb_buffer_pool_instances
可见,该算法有如下特点:
-
对于同一个表空间的连续page ,会尽可能地放在同一个缓冲区实例中,即一个extent 中的page 缓存在同一个实例中,从而便于预读等优化;
-
对于不同表空间的page ,尽可能放在不同缓冲区实例中,从而便于降低冲突;
表7.3-1 buf_page_t部分关键信息
域 |
类型 |
含义 |
id |
page_id_t |
本page的表空间号和页号 |
size |
page_size_t |
page的大小 |
buf_fix_count |
uint32 |
本page当前正在被引用的次数,正在被引用的page不能被置换出缓冲区 |
io_fix |
buf_io_fix |
正在进行中的IO类型 |
state |
buf_page_state |
本page的当前状态 |
hash |
buf_page_t* |
单向指针,用于构建hash表 |
newest_modification |
lsn_t |
本page最近一次被修改时对应的lsn |
oldest_modification |
lsn_t |
本page第一次被修改时对应的lsn |
list |
UT_LIST_NODE_T |
双向指针,将属于某个缓冲区实例的buf_page_t链接在一起,用于构建Free List或Flush List,具体根据state区分 |
LRU |
UT_LIST_NODE_T |
双向指针,用于构建LRU List |
old |
bool |
本page是否在LRU List的冷端 |
access_time |
UNSIGNED |
本page被访问的时间戳,用于防止伪热page |
表7.3-2 buf_block_t部分关键信息
域 |
类型 |
含义 |
page |
buf_page_t |
对应于buf_page_t结构 |
frame |
byte * |
指向本page对应的内存地址 |
lock |
BPageLock |
保护对应page的并发访问 |
mutex |
BPageMutex |
保护控制结构的并发访问 |
表7.3-3 buf_pool_t部分关键信息
域 |
类型 |
含义 |
mutex |
BufPoolMutex |
保护对应本缓冲区实例的并发访问 |
instance_no |
ulint |
缓冲区实例号 |
curr_pool_size |
ulint |
本缓冲区实例的大小 |
n_chunks |
ulint |
本缓冲区实例的chunk数 |
chunks |
buf_chunk_t* |
构成本缓冲区实例的chunk数组 |
page_hash |
hash_table_t* |
本缓冲区实例的hash表 |
flush_list |
UT_LIST_BASE_NODE_T |
Flush List双向链表,跟踪本缓冲区实例中的脏页,用于checkpoint |
flush_list_mutex |
FlushListMutex |
保护Flush List的并发访问 |
free |
UT_LIST_BASE_NODE_T |
Free List双向链表,跟踪空闲页 |
LRU |
UT_LIST_BASE_NODE_T |
LRU List双向链表,跟踪页的访问情况,用于将不经常访问的页置换出去 |
LRU_old |
buf_page_t* |
指向LRU List的冷热分界点 |
LRU_old_len |
ulint |
LRU冷端的长度 |
各缓冲区实例是互相独立的,即各自拥有独立的控制结构和互斥控制,从而最大化地提升并发性。对于每一个缓冲区实例,控制结构包括如下关键部分:
-
Page Hash :对应于buf_poo_t 中的page_hash ,用于快速定位某个page 是否在本缓冲区实例中;
-
Free List :对应于buf_pool_t 中的free ,用于跟踪缓冲区实例中的空闲page ,方便用户线程快速获得空闲page ,由buf_pool_t.mutex 提供互斥保护;
-
LRU List :对应于buf_pool_t 中的LRU ,用于按照LRU 策略将缓存在本缓冲区实例中的page 组织起来,从而方便淘汰不常用的page ,由buf_pool_t.mutex 提供互斥保护;
-
Flush List :对应于buf_pool_t 中的flush_list ,用于按照脏页产生的顺序将脏页组织起来,从而方便按照特定的策略进行脏页持久化,由buf_pool_t.flush_list_mutex 提供互斥保护;
Page Hash、Free List、LRU List和Flush List的头部都在缓冲区实例的控制结构buf_pool_t中,具体如表7.3-3所示。Page Hash、Free List、LRU List和Flush List需要每个page的参与,因此buf_page_t中的hash、list、LRU正是用于构建对应的单向或双向LIST,具体如表7.3-1所示。出现在Page Hash中的page一定在LRU List中,出现在LRU List中的page也一定在Page Hash中。出现在Flush List中的page一定在LRU List中,但在LRU List中的page不一定在Flush List,即LRU List是Flush List的超集。
Page Hash
当需要在缓冲区中查找某个特定的page时,首先通过表空间号和页号确定确定具体的缓冲区实例的,然后再通过该缓冲区实例的Page Hash定位到对应的page。
图7.3-3 Page Hash结构
如图7.3-3所示,Page Hash对应于hash_table_t结构,由如下三个部分组成:
-
buf_page_t :归属于buf_block_t 结构,为缓冲区实例缓存page 的控制结构,其中的hash 指针用于将hash 值相同的buf_page_t 链接在一起;
-
bucket :hash 桶,桶的数量为缓冲区实例中可容纳page 数的2 倍;
-
RW_LOCK :并发互斥量,用于保护bucket 和hash 链的并发访问,默认每个缓冲区实例16 个互斥量,每个互斥量保护一段范围内的hash 桶及其hash 链;
MySQL和Oracle的不同之处在于Page Hash不是全局的,而是隶属于各个缓冲区实例的。通过空间号和页号可以快速定位某个page是否在缓冲区实例中,具体过程如下:
-
STEP1 :通过空间号和页号确定page 对应的缓冲区实例;
-
STEP2 :通过空间号和页号查找缓冲区实例的Page Hash ,定位到对应的RW_LOCK 和bucket ;
-
STEP3 :对RW_LOCK 加R 型锁;
-
STEP4 :遍历hash 链表,找到对应的buf_block_t 结构;
-
STEP5 :对buf_block_t 中的buf_fix_count 进行原子加1 ;
-
STEP6 :释放RW_LOCK 锁;
-
STEP7 :访问page 中的数据;
-
STEP8 :对buf_block_t 中的buf_fix_count 进行原子减1 ;
上述是查找过程,所以对RW_LOCK加R型锁。如果是修改Page Hash,就需要加W型锁。Page Hash仅保护hash的访问过程及buf_page_t的原子操作,即确保能够获取到对应的page。一旦获取完毕之后,就与Page Hash的RW_LOCK无关。Buf_fix_count确保对应page不会被缓冲区实例置换出去,而buf_block_t中的lock和mutex分别提供page及其控制结构的访问保护。
LRU List
LRU List主要用于跟踪缓冲区实例中各page的访问频率(或冷热),当缓冲区实例中的空间不足时优先将冷数据置换出去。MySQL在申请新的page空间时,优先从Free List中获取。如果Free List已经为空,再从LRU List中获取。
图7.3-4 LRU List结构
如图7.3-4所示,LRU List也是由buf_page_t构成的双向链表(具体参考表7.3-1和表7.3-3),其由如下三个部分组成:
-
Head :在buf_pool_t 结构中( UT_LIST_BASE_NODE_T 结构 ),指向LRU List 的头部,表示最近或最频繁被访问的page ;
-
Tail :在buf_pool_t 结构中( UT_LIST_BASE_NODE_T 结构 ),指向LRU List 的尾部,表示最旧或最不频繁被访问的page ;
-
LRU_old :在buf_pool_t 结构中,指向LRU List 的冷热分界位置。Head 与LRU_old 之前的部分称为Young sublist ,为热数据。LRU_old 与Tail 之间的部分称为Old sublist ,为冷数据。Young sublist 和Old sublist 之间的page 数比例可以通过配置项innodb_old_blocks_pct 配置,默认为63:37 ;
当读取新的page时,将插入到LRU_old位置。如果在innodb_old_blocks_time时间之外再次访问该page,该page将被移动到Head位置。时间的判断正是通过buf_page_t中的access_time完成的,从而防止短时间内的频繁访问造成伪热数据页。早期版本的MySQL没有类似Oracle的TCH机制,page在LRU List中的移动非常频繁,影响性能。因此,MySQL对Young sublist中的page移动算法做了调整,即page只有在Old sublist中或Young sublist的后3/4位置中,再次被访问才会被移动的Head位置。这样对于已经在Young sublist中靠前位置的page,即使再次被访问也不会被移动到Head位置,从而在一定程度上缓解了频繁移动问题。
当Free List中没有空闲页时,用户线程将搜索LRU List以获得待置换的page。具体算法如下:
-
第1 轮遍历:从LRU List 尾部开始遍历,最多遍历100 个page 。一旦遇到可置换的page ,将该page 移入Free List ,并退出遍历。如果没有找到可置换的page 但存在可刷新的脏页,将该脏页持久化后移入Free List ,并退出遍历;
-
第2 轮遍历:如果第1 轮遍历没有获得可置换的page ,立刻进行第2 轮遍历,其动作和第1 轮类似,不同的是没有100 个page 的限制,而是遍历整个LRU List ;
-
第n 轮遍历:如果第2 轮遍历仍然没有获得可置换的page ,则等待10ms 后进行下一轮遍历,遍历过程和第2 轮完全一致,不断重复直至获得可置换的page ;
可见,直接从Free List中获取的空闲页的效率是最高的,其次是在第1轮遍历中活动前可置换的page,而刷新脏页或第n轮遍历的效率都是相对较低的。为了解决这个问题,MySQL在后台引入了Page Cleaner线程,其会周期性地遍历LRU List,并将LRU List尾部的page转移到Free List中,从而提高效率。Page Cleaner线程的实现机制将在Flush List章节进一步讲解。
Flush List
当page第一次从正常page变为脏页时,就会从头部插入到Flush List中。可见,Flush List就是一个由脏页组成的有序双向链表,头部page的oldest_modification大,尾部page的oldest_modification小。实际上,页面修改都被封装为一个mini-transaction,mini-transaction提交时涉及的页面就会加入到Flush List中。Page从LRU List中加入到Flush List中时不会从LRU List中删除,即Flush List中的page一定在LRU List。不过,LRU List中的page不一定在Flush List中。
MySQL引入了Page Cleaner线程,从而将脏页有节奏地刷入持久化设备,线程的数量可以通过配置项innodb_page_cleaners设置。Page Cleaner线程由一个协调线程和若干个执行线程组成(实际上,协调线程除了做协调工作之外本身也是一个执行线程),协调线程每1秒进行一次Flush,每次计算各缓冲区实例需要持久化的page数,然后通知各执行线程对各缓冲区实例中的page进行持久化。Page Cleaner线程和缓冲区实例不是一一对应的,而是轮训各缓冲区实例。如果某缓冲区实例没有被Page Cleaner线程处理过,Cleaner线程就会打上标签并开始处理,直至所有缓冲区实例都打上标签,表示本轮持久化工作执行结束。
下面来看协调线程是如何计算各缓冲区实例应当持久化的脏页数的。MySQL是从历史脏页刷新速度、REDO日志情况、脏页情况和系统IO能力4个维度进行综合评估的。历史刷脏页速度从page和REDO日志两个维度进行跟踪的,分别为avg_page_rate和lsn_avg_rate,前者表示历史每秒刷脏页的数量,后者表示历史每秒REDO日志的产生数量。为了防止avg_page_rate和lsn_avg_rate剧烈波动,每innodb_flushing_avg_loops秒(或次)才会更新一次。Avg_page_rate=(avg_page_rate+上个周期刷新的脏页数/上个周期刷新的耗时)/2,而lsn_avg_rate=(lsn_avg_rate+上个周期产生的日志数/上周周期刷新的耗时)/2,可见avg_page_rate和lsn_avg_rate是根据上个周期的实际速度不断进行迭代更新的。
REDO日志情况是从活跃日志占比的角度进行跟踪的,活跃日志不能占总日志文件太大,否则很容易引起REDO满而等待。计算活跃日志占比pct_for_lsn的算法如下:
-
当前的LSN 减去Flush List 中最旧的oldest_modification ,记为age ;
-
如果age/ 日志文件大小小于innodb_adaptive_flushing_lwm ,pct_for_lsn=0 ,否则进入下一步;
-
如果innodb_adaptive_flushing 为false ,pct_for_lsn=0 ,否则进入下一步;
-
lsn_age_factor=age*100/ 日志文件大小,pct_for_lsn=(innodb_max_io_capacity / innodb_io_capacity)*(lsn_age_factor*sqrt(lsn_age_factor))/7.5 ;
可见,pct_for_lsn已经考虑了系统IO能力。脏页情况是跟踪脏页的占比情况,脏页数不能占总页数太大,否则很容易引起无法获得可置换页而等待。计算脏页占比pct_for_dirty的算法如下:
-
当前的脏页数除以总页数,记为dirty_pct ;
-
innodb_max_dirty_pages_pct_lwm 等于0 ,表示不启动预刷新机制。此时,如果dirty_pct 小于innodb_max_dirty_pages_pct ,那么pct_for_dirty=0 ,否则pct_for_dirty=100 ;
-
innodb_max_dirty_pages_pct_lwm 不等于0 ,表示启动预刷新机制。此时,如果dirty_pct 小于innodb_max_dirty_pages_pct ,那么pct_for_dirty=0 ,否则pct_for_dirty=dirty_pct*100/(innodb_max_dirty_pages_pct+1) ;
有了上述信息之后,就可以计算出本次需要刷入的页数,记为n_pages。N_pages等于(avg_page_rate+pages_for_lsn+max(pct_for_dirty, pct_for_lsn)*innodb_io_capacity/100)/3,其中pages_for_lsn等于Flush List中oldest_modification小于当前已持久化的LSN+lsn_avg_rate的总page数。当然还需要考虑系统的IO能力,因此最终的n_pages=min(n_pages, innodb_max_io_capacity)。得到待刷新的总page数n_pages后,下一步就是将其分解到各个缓冲区实例中。如果pct_for_lsn大于30,说明REDO日志可能是潜在的瓶颈点,需要优先降低活跃日志数量,因此需要根据各缓冲区实例中的活跃日志比例,将n_pages分配给各缓冲区实例。如果pct_for_lsn小于30,比较简单,将n_pages均匀地分配给各缓冲区实例即可。
完成待刷新脏页数的计算后,各Page Cleaner线程开始并行实施持久化工作。对于每个缓冲区实例而言,主要包括刷新LRU List和Flush List两项工作。刷新LRU List从LRU List尾部开始遍历innodb_lru_scan_depth个page。如果page为非脏且没有其它线程在访问,将该page移到Free List中,从而提高用户线程获取空闲page的效率。如果page为脏页且没有其它线程访问或正在被刷新,刷新该page。刷新Flush List从Flush List尾部开始刷新特定数量的脏页。
Checkpoint
MySQL支持Sharp Checkpoint和Fuzzy Checkpoint。当innodb_fast_shutdown配置为0或者1时,数据库关闭时会执行Sharp Checkpoint,即将所有脏页刷入持久化设备中。由于Sharp Checkpoint会刷新所有脏页,很可能影响正常的用户数据访问,所以数据库运行期间执行Fuzzy Checkpoint。Fuzzy Checkpoint会综合考虑历史刷新速度、日志情况、脏页情况和系统IO能力,动态决定每次刷新脏页的数量,具体算法间Flush List章节。
Page Cleaner线程的任务是周期性地将脏页刷入持久化设备,而MASTER线程的任务之一就是周期性地(每1秒)将Flush List中的oldest_modification写入到REDO日志中,记为checkpoint LSN。当数据库重启后,会从REDO日志中获取最近一次的checkpoint lsn,并从该lsn开始应用重做日志。
总结与分析
Oracle与MySQL在缓冲区和检查点设计思想上是非常类似的,只是在实现精细化程度上有所不同。首先来看负载的多样性,在block或page大小方面,Oracle可以同时支持2K、4K、8K、16K和32K的block,而MySQL虽然也可以支持4K、8K、16K、32K和64K的page,但同时支持能支持一种。Oracle在普通缓冲区的基础上,还可以同时支持KEEP和RECYCLE类型的缓冲区,让用户在创建表是可以根据负载特点自行选择,只是MySQL所没有的。
在并发方面,Oracle根据CPU核数自动将缓冲区分割为多个工作集,MySQL需要根据用户配置的缓冲区实例数对缓冲区进行切割。不管是Oracle的工作集还是MySQL的缓冲区实例,都将缓冲区分解为多个子缓冲区,每个都有独立的控制结构和互斥量,从而提高并发性。不同之处是两者在Hash表的设计上,Oracle的Hash表是独立于工作集设计的,是全局的,大约每32个桶有一个独立的互斥量。MySQL的Hash表是依附于缓冲区实例的,即每个缓冲区实例多有一个Hash表,是局部的,每个Hash表大约有16个互斥量。MySQL的局部Hash表设计需要引入两次Hash过程,既要将page均匀地Hash到各个缓冲区实例上,又要为了效率将属于同一个extent上的page放到同一个缓冲区实例中,两者存在一定的冲突。Oracle的Hash表互斥量可以随着缓冲区的大小自动增加,而MySQL的Hash表只能跟踪缓冲区实例数的增加而增加。
在LRU算法方面,Oracle有主链和辅链,MySQL有LRU List和Free List。Oracle在主链上有热区和冷区,MySQL在LRU List也有热区和冷区。Oracle设计了TIM防止伪热块,MySQL也同样设计了access_time。不过在具体算法和设计方面Oracle更加精细化。例如,Oracle通过TCH机制更好地降低块在LRU链表上的移动,而MySQL只是做了适当的缓解。Oracle引入Write链区分LRU脏块和普通块,进一步提高效率,这是MySQL所没有的。Oracle根据负载对缓冲区的冲击情况做了不同的应对,这也是MySQL所不具备的。
在弹性方面,Oracle和MySQL都支持在线调节缓冲区大小的能力。Oracle的颗粒度为Granule,MySQL的颗粒度为Chunk。不同的是Oracle将SGA中大部分缓存都拉通了,且之间可以自动地、动态地调整,对业务基本没有影响。MySQL当前仅支持手动调整缓冲区大小,且对用户线程访问缓冲区有一定影响。
最后看checkpoint的平衡性,Oracle设计了Checkpoint Queue、File Queue、Object Queue多个维度的队列,并引入了LRU-W的主链和辅链,从而根据负载的不同更加高效地识别出对应的脏块,并将脏块写入持久化设备。MySQL设计了Flush List(对应于Oracle的Checkpoint Queue),并结合LRU List进行脏页的持久化。Oracle和MySQL都综合考虑日志、脏页、Io等多个维度来确定脏页的写入速度,但Oracle很好地利用了统计信息,更加自动化地计算每次的脏页写入量。体现为在最简情况下Oracle可以仅设置系统启动时长fast_start_mttr_target,而MySQL需要设置innodb_flushing_avg_loops、innodb_adaptive_flushing_lwm、innodb_adaptive_flushing、innodb_max_dirty_pages_pct_lwm、innodb_max_dirty_pages_pct、innodb_max_io_capacity、innodb_max_io_capacity等一系列配置项。