缓存与检查点
设计原则
数据缓冲区与检查点是相辅相成的,所以放在同一个章节介绍。由于CPU与持久化设备之间存在巨大的速度差距,所以在内存中引入缓冲区缩小这个差距。从读的角度来看,将热点数据或预判用户可能读取的数据提前加载到内存中,从而将持久化设备的读时延和带宽提升至内存的时延和带宽。从写的角度来看,直接修改缓冲区中的数据而不是磁盘中的数据,可以带来两方面的优势。其一,将持久化设备的写时延和带宽提升至内存的时延和带宽。其二,将多次数据写合并为一次对持久化设备的写。
数据缓冲区将写缓存在内存中,并通过顺序写的REDO日志确保事务的数据不丢失,即事务的持久性。然而,随着写事务的不断进行,可能带来多方面问题,如日志文件持续增长,系统重启需要恢复的时间不断增加,脏块或脏页持续增多导致内存空间不足等等。这就需要通过检查点机制定期将脏块或脏页写入到持久化设备中,从而降低REDO日志长度。
在设计数据缓冲区和检查点时,有如下几点需要考虑:
-
缓冲区的效率:在多样性负载(不同数据大小、不同负载类型)、并发和 LRU 算法方面的效率;
-
缓冲区的弹性:缓冲区大小的可调节性,即随着负载的变化的调节能力;
-
检查点的平衡性:体现为脏块或脏页持久化与系统恢复时长之间的平衡能力,写脏块或脏页过于频繁会与正常负载争抢资源,而过于稀少则导致系统恢复时间过长;
Oracle设计原理
缓冲区与granule
表7.2-1 缓冲区类型和控制参数
缓冲区用于在内存中缓存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 工作集头部部分关键信息
表7.2-3 buffer header部分关键信息
每个工作集都有一个工作集头部和若干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模式相容矩阵
实际上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写优先级
实际上,除了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部分关键信息
表7.3-2 buf_block_t部分关键信息
表7.3-3 buf_pool_t部分关键信息
各缓冲区实例是互相独立的,即各自拥有独立的控制结构和互斥控制,从而最大化地提升并发性。对于每一个缓冲区实例,控制结构包括如下关键部分:
-
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等一系列配置项。