Quantcast
Channel: 数据库内核月报
Viewing all 692 articles
Browse latest View live

POLARDB · 性能优化 · 敢问路在何方 — 论B+树索引的演进方向(中)

$
0
0

前言

在前文《POLARDB · 性能优化 · 敢问路在何方 — 论B+树索引的演进方向(上)》一文中,笔者以多核处理器+闪存为背景,详细分析了Bw树的索引设计、存储层设计和在数据库中的实际应用,并分析了它的优势劣势。本文以多核处理器+非易失内存(Persistent Memory,PM)为背景,分析新型非易失内存场景下B+树的设计与优化[1-5]。

如下图[7]所示,在传统的存储体系结构中,存储系统借助易失性内存提供高性能的数据访问,利用性能较差但价格低廉的闪存/硬盘保证数据的持久性。传统内存是基于动态随机访问存储器(Dynamic Random Access Memory,DRAM)构建的。每个DRAM cell中包含一个电容,它限制了DRAM器件的扩展性。当DRAM cell的尺寸低于某个阈值时,电容存在被电荷击穿的可能,因此难以依赖DRAM在内存层次提供TB乃至更大存储容量的能力[8,9,10]。当然,目前也有一些研究尝试基于3D堆叠技术,提高DRAM的存储密度,但这也存在一些工艺等方面的其它问题。

image.png

另外,DRAM中的电荷会随着时间慢慢流失,当电荷流失超过一定程度时,DRAM cell中的数据就会丢失。为了解决这个问题,传统内存依赖周期性的刷新操作避免数据丢失,这也产生了较高的静态能耗,在大的数据中心中这部分能耗也会产生较为昂贵的电力成本。此外,传统内存是易失性的,数据会随着内存断电而丢失。因此,存储系统需要在合适的时机,将内存中的数据持久化到外存中。然而,不管是磁盘(毫秒级的访问延迟)还是闪存(数十微妙级别的访问延迟),持久化延迟都会显著影响请求的响应延迟,降低整个系统的性能。

为了解决这些问题,学术界以及工业界一直在寻找传统内存和外存的替代材料。非易失内存(PM)是一种在内存层次提供数据持久性的新介质,比如相变存储器(Phase Change Memory,PCM),自旋力矩存储器(Spin-Transfer Torque RAM,STT-RAM)和阻变存储器(Resistive RAM,RRAM)。目前,非易失内存离实用场景越来越近,比如Intel公司开发的基于3DxPoint的DIMM接口内存条即将投入使用(内部实现机制和原理尚未公开)。非易失内存不仅拥有传统磁盘/闪存的持久性,并且具有接近内存的高性能(百纳秒级的访问延迟)及字节寻址的特性。

为了榨干非易失内存的性能红利,降低软件栈开销,研究人员往往会把非易失内存作为内存+外存的单层存储,挂载在内存总线上,直接通过内存接口进行访问。如上图所示,非易失内存将易失性-持久性的边界移动到处理器缓存与持久性内存之间,这将打破传统易失性内存与非易失外存的边界,对上层软件系统的设计产生颠覆性的改变。正因如此,相比于多核处理器+闪存场景,多核处理器+非易失内存场景下B+树的演进方向更加复杂。

首先,非易失内存在内存层次保证了数据持久性,这同样要求存储系统在内存层次保证数据的崩溃一致性,能从系统崩溃中将数据恢复到一致性的状态。如下图所示,访问非易失内存中的数据需要经历多个易失性的存储器件,包括片上的store buffer,处理器缓存和内存控制器缓存。只有确保已经将数据从这些易失性存储写回到非易失内存中,才能保证这些数据的持久性。

然而,当PM挂载在内存总线上时,64位计算机只能保证8字节数据(地址对齐)的原子性和持久性,任何大于8字节的写操作都可能因为系统崩溃处于一个不一致的中间状态。传统存储系统往往依赖日志机制来保证一致性,即在修改某个数据时先将新/旧数据写入日志并持久化,然后再写回到原数据区。因此,为了保证一致性,系统需要按照正确的顺序将脏数据从易失性缓存持久化到非易失内存,比如先写日志,再写原数据区。对于传统存储架构,内存中的数据是一个白盒,存储系统知道每个页的持久化状态,它可以借助文件系统/操作系统中类似fsync的操作,保证数据从内存(page cache)持久化到外存中。然而,处理器缓存往往是硬件控制的,对软件系统而言像是一个黑盒,它根据缓存替换策略(例如LRU等)自动将某些缓存行写回到非易失内存中,这会打乱数据持久化到内存中的顺序。当系统突然崩溃时,非易失内存中的持久性数据可能处于一个不一致的状态。软件系统难以追踪每个缓存行的持久化状态,即使可以,也会带来过于昂贵的追踪开销。

为了解决这个问题,硬件厂商提供一些新的指令用于刷新缓存行,保证某个数据已经持久化到内存中,例如clflush指令,它将包含某个数据的缓存行刷回内存。然而,clflush指令之间有顺序的限制,会产生百纳秒级的延迟。对于访问延迟仅为百纳秒级的非易失内存,clflush会产生严重的性能影响[2]。此外,clflush指令会将对应缓存行置为无效,影响后续相同缓存行的访问,频繁的clflush调用会带来频繁的处理器缓存缺失[11]。另外,clflush指令只能保证缓存行数据写到内存控制器的row buffer中,然后row buffer无法保证数据的持久性,所以它还需要调用pcommit指令将row buffer中的所有数据持久化到介质上,这同样产生了比较高的等待延迟。因此,研究者们开始研究扩展CPU硬件,减少clflush和pcommit指令带来的性能影响。例如Intel公司开始提供clflushoptclwb指令,前者消除了clflush指令之间的顺序限制,后者进一步避免了对应缓存行的失效。因为clflushopt/clwb无法保证相互之间的顺序,所以它们依赖mfence指令保证顺序要求。此外,Intel公司还为row buffer加上了小型电容,它能保证系统突然崩溃时row buffer中的数据持久化到介质上,所以就消除了pcmmit的开销。学术界还研究了很多硬件方法去提高性能,例如设计非易失缓存[12],放松事务之间持久性的限制[13]等等。这部分方法往往需要修改硬件,与本文关系不大。以后有机会,笔者会从体系结构角度,详细分析如何减少非易失内存上的一致性开销。

虽然上述指令级别的优化在一定程度上提高了性能,但是基于日志的一致性保障机制对于非易失内存而言过于厚重,重复写操作+多次持久化指令的调用所带来的延迟,在很大程度上掩盖了非易失内存百纳秒级的性能优势。正因如此,学术界开始针对索引结构的特点,设计更加灵活和轻量级的一致性机制。

其次,非易失内存往往存在读写不对称的特性,写操作的延迟显著高于读延迟,并且具有有限的写寿命问题。以较为成熟的PCM为例,它通过加热操作改变器件阻值,通过不同的阻值范围表示0和1这两个值。然而,这种基于加热修改阻值的写操作需要较长的时间/能量,写延迟往往在200纳秒到1微妙的范围内。并且,写操作会产生较高的动态能耗,每个PCM cell在经过一定的写操作后无法继续写入(寿命大约是10^8次),永久性停留在某个阻值上。相比于写操作,它的读操作只需要通过小的电流测算出阻值范围,就可以得出存储的数值,仅需要数十纳秒的延迟。因此非易失内存往往存在读写不对称的问题,应该尽可能减少写操作。

因此,基于非易失内存的索引需要保证索引结构在系统崩溃后可以恢复到一致性的状态,并且需要降低一致性成本和写成本。为此,研究者针对非易失内存提出了很多B+树的优化,包括例如采用多版本机制的CDDS-Tree[8],采用无序树节点的NVTree[9]/wB+Tree[10],采用混合内存架构的NVTree/FPTree[11],控制缓存行刷出顺序的FAST+FAIR[12]等等。

此外,随着非易失内存的出现,持久性数据离处理器更近。因此,对于基于非易失内存的索引结构,它需要更高的多线程扩展性。在多线程场景下,B+树的优化方向主要分为两类:1. 采用硬件事务内存等新的硬件红利,优化B+树的多线程扩展性,例如FPTree;2. 利用内存字节寻址的特性,设计latch-free的B+树,例如BzTree[6]。由于篇幅和时间限制,本文着力于讨论单核处理器+非易失内存场景下B+树的演进方向,而将多核处理器+非易失内存场景下B+树的演进方向放在下一篇文章中分析。本文的部分观点可能尚不完善,读者可根据文章末尾的引用论文,深入阅读相关工作。

基于多版本机制保证崩溃一致性的CDDS B-Tree

在2011年存储领域顶会FAST上,HP实验室的研究人员发表了第一篇在NVM上保证崩溃一致性的B+树:CDDS B-Tree。CDDS B-Tree采用版本号的机制,在不需要日志的情况下从一个一致性状态更新到下一个一致性状态。版本号机制支持原子性的更新,当事务失败支持将索引结构回滚到最新的一致性状态。与基于内存的Berkeley DB B-Tree相比,CDDS B-Tree 将带宽提高了74%和138%。与基于传统内存+闪存架构的Cassandra相比,基于CDDS B-Tree的KV系统将系统带宽提高了250%-286%。下面,笔者分析CDDS B-Tree的实现原理。如果读者对它的具体操作实现感兴趣,可以详细阅读原文的伪代码。

如下图所示,CDDS具有以下的特性:1. 每个记录都有个版本号区间[a,b),a表示这个记录的起始时间戳,b表示这个记录的失效时间戳。每个存活的记录都有一个版本[a, -),这表示这个记录是最新的,还没有失效,可以被其它线程所访问;2. 每个写操作都会创建一个新版本的记录;3. 每个写操作不会修改记录的旧版本,而是将新版本记录通过原子操作或者写时复制的方式加入到CDDS B-Tree中;4. 所有的写操作都会被持久化,然后全局版本号计数器会被原子性更新。通过以上的四点特性,对于每一个写操作,CDDS B-Tree都会产生对应记录的新版本,而不破坏旧版本。当操作执行过程中系统突然崩溃时,这种多版本机制支持根据完整的旧记录回滚到一致性的状态。

看到这里,读者们很容易联系到MySQL中的undo日志:每个更新操作不会修改原记录,而是生成一个新的记录项,形成一个不同版本的记录项链表。以RR隔离级别为例,事务可以根据事务开始时的活跃事务列表,选择可以读取最新版本的记录。MySQL依赖多版本的undo日志提供MVCC能力,很大程度上提高了读操作的并行能力。CDDS B-Tree就是使用类似的原理,在非易失内存上提供了崩溃一致性的保证。

因为CDDS B-Tree有多个版本的旧数据,所以它需要在合适的时机回收那些不再被访问的旧版本数据,减少空间开销。首先,在插入新的数据时,它可以复用那些不再被访问的记录项。其次,它使用后台运行的垃圾回收线程(例如传统的mark-and-sweep垃圾回收器)回收旧数据:根据目前所有线程访问的最旧版本号,确定可以回收的最大版本号,然后从B-Tree的根结点开始遍历删除旧数据。

基于混合内存架构的NV-Tree

笔者曾完整实现过本文提到的所有B-Tree。对于CDDS B-Tree,它的主要问题在于为了维护多版本的存在,引入了大量写操作和持久化操作。尤其是排序操作,它需要移动现有的记录,为新记录提供插入位置,这需要将相关记录置为非法,并创建新版本,这带来了大量持久化开销。同样,在发生树节点分裂等复杂操作的情况下,它也需要将发生分裂/合并的树节点中所有记录置为非法,并创建相关所有记录的新版本。文献[2]中说明,当树节点大小为4KB时,往CDDS B-Tree中插入一百万条记录,它的持久化版本(存在clflush操作)比易失性版本要慢20倍,原因是clflush操作会带来很高的延迟和大量的缓存缺失。此外,CDDS B-Tree的旧版本回收操作,也会带来不可忽视的开销。

为了解决上述问题,南洋理工大学的研究人员在FAST'15上提出了NV-Tree,基于主要三点设计:1. 选择性的数据一致性保障机制。因为B+树将真实数据存储在叶节点,而内部节点作为叶节点的索引,只用于加速真实数据的查找,所以NV-Tree将叶节点视作为关键节点,内部节点视作为可重建数据。CDDS B-Tree需要保证整棵树的一致性,而NV-Tree只保证叶节点的一致性,从而消除了内部节点的一致性开销。当系统崩溃时,只需要叶节点的数据一直处于一致性的状态,内部节点就可以根据叶节点数据进行重建。2. 保持叶节点的记录是无序的,从而消除排序操作的一致性开销。同时,NV-Tree保证内部节点的记录依然是有序的,这可以加速键值对的查找过程。3. 使用一种处理器缓存友好的格式存储内部节点。NV-Tree将所有内部节点存储在一个连续的内存空间,任何一个内部节点都可以直接通过它的父节点计算出它的偏移位置,从而避免了指针开销,提高了处理器缓存的命中率。

如上图所示,所有的叶子节点通过右指针衔接成一个链表,每个叶节点可以通过最后一层内部节点(parent of leaf node,PLN)的指针进行访问。所有的内部节点都存储在预分配的连续内存空间,所有内部节点的偏移位置都是固定可计算的,从而消除了指针的空间开销和可能导致的缓存缺失操作。

1.jpg

因为NV-Tree只将叶节点放在非易失内存上,下面笔者简单介绍NV-Tree叶节点的树操作。如上图所示,叶节点的更新操作不需要日志或者版本号开销,不论是删除/更新/插入操作,所有的新数据都以append-only方式插入到新节点中,这与传统的redo log原理十分相似。以图(a)为例,向一个节点插入7包含两个步骤:1. 将数据写入树节点,+表示这个数据是插入操作,-表示这个数据是删除操作;2. 用8字节原子操作修改树节点的记录项计数器,只有这个计数器修改成功,插入操作才算完成。如果系统在step1和step2之间发生了崩溃,因为计数器没有改变,所以step1不会影响叶节点的一致性。删除操作与插入操作相似,而更新操作可以分解为删除+插入操作,留给读者自己分析。

当NV-Tree发生了叶节点的分裂或者合并操作时,它使用clflush指令控制了持久化操作的顺序,同样确保操作所产生的修改只有在最后一次原子性操作后才可见。如下图所示,以分裂操作为例,它包含三个步骤:1. 拷贝合法记录到新的叶节点;2. 更新PLN节点,插入新的叶节点。这时候没有产生任何修改原叶节点链表的操作,不影响叶节点的一致性;3. 原子性更新分裂叶节点的前节点,指向新的叶节点。

值得注意的是,当PLN节点空间满的时候,NV-Tree会执行一个重建过程,扩大内部节点数组的大小。这个重建过程可能会对系统带来一定的性能抖动。详细的过程,用户可以阅读原论文。

采用间接排序数组的wB+Tree

针对CDDS B-Tree中排序操作带来的持久化开销,中国科学院的研究人员在VLDB'15上也发表了一种新的内存B+Tree - wB+Tree。针对CDDS B-Tree的缺点,wB+Tree提出了两点优化方向:1. 最小化记录的移位操作。在一个有序的树节点中,任何一个记录的插入和删除操作会引起平均一半记录项的移位操作。这么多的移位操作无法通过8字节原子操作保证崩溃一致性,因此需要依赖日志或者版本号机制,引入大量的持久化开销。2. 良好的搜索性能。虽然NV-Tree通过无序叶节点减少移位操作,但会带来一定程度的搜索延迟。

image.png

如上图所示,wB+Tree的树节点采用了一个小型的slot数组和一个小型的bitmap数组。对于基于磁盘的B+树,它的树节点大小往往位于4KB~256KB的范围内(例如MySQL中的16KB),而基于内存的B+树节点往往只有2-8个处理器缓存行大小,每个树节点往往只能存放数个或者数十个记录。以每个树节点存放64个记录为例,slot数组中slot-0记录合法记录的数目,其它项slot-i记录对应顺序的键值对在树节点Index-entries中的位置,从而wB+Tree通过slot数组就记录了所有键值对的顺序,可以完成二分查找过程。因为每个键值对的排序范围只会处于0-63这个区间,所以每个slot项只需要6个比特位(为了性能考虑,往往采用一个字节)。以上图(a)为例,这个slot数组表示有5个记录,后面slot-1到slot-5记录第1-5个记录在树节点中的位置。

上图(b)显示了一个配置bitmap数组的树节点,bitmap数组中每个比特位标bitmap-i记着对应slot-i是否是一个合法的记录。任何一个插入操作将记录写入数组后,只有将bitmap数组中对应的比特位设置为1才算插入成功。任何一个删除操作,只需要将bitmap数组中对应的比特位设置为0就可以结束。以每个树节点存放64个记录为例,一个bitmap数组只需要8个字节,所以可以用8字节的原子操作保证一致性。

上图(e)显示了一个配置slot+bitmap数组的树节点。以插入操作为例,它先将数据写入无序树节点,然后修改slot数组存储它的顺序,最后通过bitmap数组的原子操作完成这次操作。如果在修改bitmap之前发生了系统崩溃,系统可以通过bitmap数组+无序树节点,恢复出slot数组。综上所述,wB+Tree通过slot+bitmap数组,在支持二分查找和崩溃一致性的前提下,避免插入/删除操作带来的排序/移位操作。虽然绝大部分操作都可以通过原子操作来保证一致性,但是wB+Tree依然依赖redo日志保证树节点的分裂/合并操作的一致性。

基于指纹技术和硬件事务内存的FPTree

在SIGMOD’16上发表的FPTree,不仅减少了wB+Tree中slot数组的写开销,而且提高了索引的多线程扩展性,它主要包括三项优化:

  1. 指纹技术。如上图所示,每个叶节点头部存储着一个指纹数组,每个指纹项fingerprint-i记录的是对应记录K-i的哈希值,每个指纹项仅有1个字节。每个查找操作首先将值与指纹数组进行对比,只有哈希值相同才会对比具体的记录信息。因为指纹数组仅为一个缓存行大小,不需要从内存中访问多个缓存行数据,所以比较操作很快就可以完成。
  2. FPTree采用和NV-Tree类似的混合内存技术,只将叶节点存储在非易失内存上,而将内部节点存在传统内存中,从而消除内部节点的一致性开销。
  3. FPTree针对易失性内部节点和非易失性叶节点采用不同的并发机制。对于易失性内部节点,它采用硬件事务内存技术提高并发处理能力。因为硬件事务内存会被clflush操作影响,所以它采用细粒度的锁技术提供叶节点的并发控制。由于硬件事务内存是一项比较新的技术,笔者会在下一篇文章中详细分析。

可容忍临时不一致性的FAST+FAIR算法

虽然NV-Tree/FPTree采用混合内存技术降低了一致性开销,但是当系统崩溃时,它需要比较长的时间恢复内部节点,无法达到瞬间恢复的速度。另外,wB+Tree依赖redo日志保证分裂/合并操作的一致性,这会带来较高的持久化开销。针对这些问题,UNIST的研究人员在FAST18上提出了FAST+FAIR算法。它利用8字节的原子操作,即使在修改过程中B+Tree出现了不一致性,读操作也可以识别出不一致的数据,因此这种临时性的不一致状态是可容忍和可恢复的。

针对排序操作带来过高的持久化开销,研究人员提出了FAST(Failure-Atomic ShifT)算法。排序操作包括一个load/store操作序列,它们相互之间具有依赖性,通过级联的方式触发。如下图所示,以插入25为例。它从右向左找到插入位置(step 1),然后将后面那些记录右移一个位置,从而可以插入25。因为一个树节点可能包含多个缓存行大小的数据,所以它通过clflush指令保证缓存行的持久化顺序。在FAST算法中,每当一个缓存行的数据移动完时,它就会调用clflush,确保这个移位操作的持久性。移位操作无法保证原子性,所以系统崩溃时会导致数节点出现下图中(2) - (9)中的任何一种状态 (移位操作涉及一个缓存行的数据,无法保证原子性,红色的数据可能是未完成的)。FAST巧妙地解决了这个问题:因为B+树不会存在相同的指针,并且8字节的指针不会出现中间状态,所以当读操作找到目标记录时,它会比较左右儿子指针是否是重复值,从而识别出那些临时性的非一致数据,避免读取。此外,对于ARM处理器那种彻底打乱读写顺序的平台,FAST算法也有相应的处理机制,感兴趣的读者可以阅读原文。

针对树平衡操作带来的持久化开销,研究人员提出了FAIR(Failure- Atomic In-place Rebalance)算法,与NV-Tree比较类似。FAIR算法采用了B-link tree[14]的结构,所有层次的树节点都有兄弟指针相连。如下图所示,以树节点分裂为例(将50插入一个满的树节点),它以下步骤:1. 创建一个空节点,这不会破坏B+树的旧数据;2. 拷贝一半的数据到新节点,并通过clflush指令持久化新节点,这也不会破坏旧数据;3. 将节点A指向节点B并持久化,通过8字节原子性操作保证一致性;4. 删除节点A中那些被移动的记录;5. 通过和FAST类似的方式将新节点插入到父节点。

总结

随着硬件技术的快速发展,软件系统的设计往往也需要作出相应的改变,才能充分榨干硬件红利。本文以多核处理器+非易失内存为背景,分析了多种新型B+树的原理/应用/优势劣势,希望大家看完后有一定的收获。目前在学术界,还有针对非易失内存设计的其它数据结构,笔者在后续的文章中也会进行分析。索引结构作为影响数据库系统性能的关键模块,对数据库系统在高并发场景下的性能表现具有重大影响。如何充分发挥出新型硬件的性能,为用户提供爆炸性的性能提升,POLARDB作为新一代云原生数据库,一直在努力!请持续关注POLARDB!

引用

  • [1] Venkataraman S , Tolia N , Ranganathan P , et al. Consistent and Durable Data Structures for Non-Volatile Byte-Addressable Memory[C]// Usenix Conference on File & Stroage Technologies. USENIX Association, 2010.
  • [2] Yang J , Wei Q , Chen C , et al. NV-Tree: reducing consistency cost for NVM-based single level systems[C]// Usenix Conference on File & Storage Technologies. 2015.
  • [3] Chen S , Jin Q . Persistent B + -trees in non-volatile main memory[M]. VLDB Endowment, 2015.
  • [4] Oukid I , Lasperas J , Nica A , et al. FPTree: A Hybrid SCM-DRAM Persistent and Concurrent B-Tree for Storage Class Memory[C]// the 2016 International Conference. ACM, 2016.
  • [5] Hwang D, Kim W H, Won Y, et al. Endurable transient inconsistency in byte-addressable persistent B+-tree[C]//16th USENIX Conference on File and Storage Technologies. 2018: 187.
  • [6] Arulraj J, Levandoski J, Minhas U F, et al. Bztree: A high-performance latch-free range index for non-volatile memory[J]. Proceedings of the VLDB Endowment, 2018, 11(5): 553-565.
  • [7] Lu Y , Shu J , Sun L . Blurred persistence in transactional persistent memory[J]. Industrial Electronics IEEE Transactions on, 2015, 61(1):1-13.
  • [8] Lee B C, Ipek E, Mutlu O, et al. Architecting phase change memory as a scalable dram alternative[J]. Acm Sigarch Computer Architecture News, 2009, 37(3):2-13.
  • [9] Qureshi M K, Srinivasan V, Rivers J A. Scalable high performance main memory system using phase-change memory technology[J]. Acm Sigarch Computer Architecture News, 2009, 37(3):24-33.
  • [10] Ping Z, Bo Z, Yang J, et al. A durable and energy efficient main memory using phase change memory technology[J]. Acm Sigarch Computer Architecture News, 2009, 37(3):14-23.
  • [11] Kolli A , Pelley S , Saidi A , et al. High-Performance Transactions for Persistent Memories[J]. Acm Sigops Operating Systems Review, 2016, 51(4):399-411.
  • [12] Zhao J , Li S , Yoon D H , et al. Kiln: Closing the performance gap between systems with and without persistence support[C]// Proceedings of the 46th Annual IEEE/ACM International Symposium on Microarchitecture. ACM, 2013.
  • [13] Pelley S, Chen P M, Wenisch T F. Memory persistency[C]// Proceeding of the International Symposium on Computer Architecuture. 2014.
  • [14] Lehman P L, Yao S B. Efficient locking for concurrent operations on B-trees[J]. Acm Transactions on Database Systems, 1981, 6(4):650-670.

MySQL · 引擎特性 · Inspecting the Content of a MySQL Histogram

$
0
0

In my FOSDEM 2018 presentation, I showed how you can inspect the content of a histogram using the information schema table column_statistics. For example, the following query will show the content of the histogram for the column l_linenumber in the table lineitem of the dbt3_sf1 database:

SELECT JSON_PRETTY(histogram)
  FROM information_schema.column_statistics
  WHERE schema_name = 'dbt3_sf1'
  AND table_name ='lineitem'
  AND column_name = 'l_linenumber';

The histogram is stored as a JSON document:

{
  "buckets": [[1, 0.24994938524948698], [2, 0.46421066400720523],
  [3, 0.6427401784471978], [4, 0.7855470933802572],
  [5, 0.8927398868395817], [6, 0.96423707532558], [7, 1] ],
  "data-type": "int",
  "null-values": 0.0,
  "collation-id": 8,
  "last-updated": "2018-02-03 21:05:21.690872",
  "sampling-rate": 0.20829115437457252,
  "histogram-type": "singleton",
  "number-of-buckets-specified": 1024
}

The distribution of values can be found in the buckets array of the JSON document. In the above case, the histogram type is singleton. That means that each bucket contains the frequency of a single value. For the other type of histogram, equi-height, each bucket will contain the minimum and maximum value for the range covered by the bucket. The frequency value recorded, is the cumulative frequency. That is, it gives the frequency of values smaller than the maximum value of the bucket. In the example above, 64.27% of the values in the l_linenumber column is less than or equal to 3.

In other words, if you have created a histogram for a column, you can query the information schema table to get estimates on column values. This will normally be much quicker than to get an exact result by querying the actual table.

As discussed in my FOSDEM presentation, string values are base64 encoded in the histogram. At the time of the presentation, using MySQL 8.0.11, it was a bit complicated to decode these string values. However, from MySQl 8.0.12 on, this has become simpler. As stated in the release notes for MySQL 8.0.12:

The JSON_TABLE() function now automatically decodes base-64 values and prints them using the character set given by the column specification.

JSON_TABLE is a table function that will convert a JSON array to a relational table with one row per element of the array. We can use JSON_TABLE to extract the buckets of the histogram into a relational table:

SELECT v value, c cumulfreq
FROM information_schema.column_statistics,
     JSON_TABLE(histogram->'$.buckets', '$[*]'
         COLUMNS(v VARCHAR(60) PATH '$[0]',
           c double PATH '$[1]')) hist
     WHERE schema_name = 'dbt3_sf1'
     AND table_name ='orders'
     AND column_name = 'o_orderstatus';

Running the above query on my DBT3 database, I get the following result:

+-------+---------------------+
| value | cumulfreq           |
+-------+---------------------+
| F     | 0.48544670343055835 |
| O     |  0.9743427900693199 |
| P     |                   1 |
+-------+---------------------+

The above gives the cumulative frequencies. Normally, I would rather want to see the actual frequencies of each value, and to get that I will need to subtract the value of the previous row. We can use a window function to do that:

mysql> SELECT v value, c cumulfreq,  c - LAG(c, 1, 0) OVER () freq
  -> FROM information_schema.column_statistics,
  ->      JSON_TABLE(histogram->'$.buckets', '$[*]'
  ->          COLUMNS(v VARCHAR(60) PATH '$[0]',
  ->                    c double PATH '$[1]')) hist
  -> WHERE schema_name = 'dbt3_sf1'
  ->   AND table_name ='orders'
  ->   AND column_name = 'o_orderstatus';
  +-------+---------------------+----------------------+
  | value | cumulfreq           | freq                 |
  +-------+---------------------+----------------------+
  | F     | 0.48544670343055835 |  0.48544670343055835 |
  | O     |  0.9743427900693199 |  0.48889608663876155 |
  | P     |                   1 | 0.025657209930680103 |
  +-------+---------------------+----------------------+
3 rows in set (0.00 sec)

So by combining three new features in MySQL 8.0, histogram, JSON_TABLE, and window functions, I am able to quickly get an estimate for the frequencies of the possible values for my column.

Database · 原理介绍 · Snapshot Isolation 综述

$
0
0

前言

Snapshot Isolation对于接触过数据库领域的同学来说,几乎是入门级的知识了。原因有几点:一来,谈到事务的隔离级别,必然会有所谓Read Uncommitted、Read Committed、Repeatable Read、Serializable,以及Snapshot Isolation;二来,主流的数据库,单机如MySQL、MongoDB,分布式如TiDB、OceanBase,几乎都实现了Snapshot Isolation这一隔离级别;三来,且在非形式化的定义中,Snapshot Isolation也很易于理解,易于实现。

但通过最近对Snapshot Isolatino的系统性研究,发现事情并不是这么简单,例如这几个问题:

  • Snapshot Isolation中所说的Snapshot指的是什么,需要满足Consistency约束吗?
  • SI对时钟系统的必要约束是什么?必须是一个单调递增的中心化时钟吗?
  • SI定义写写冲突,是为了解决什么问题?它是一个必要的约束吗?
  • 事务隔离和复制一致性是什么关系?能否基于一个非线性一致的复制协议,实现一个SI?

本篇文章将围绕这几个问题,将时间从2019年拉回到1995年那个雷雨交加的夜晚,围观Hal Berenson等人在小木屋里提出的对ANSI SQL isolation level的critique;再跨越历史的长河,纵观诸多学者对Snapshot Isolation的研究,以望寻得对这些问题的解答。

希望本文能够对读者有所启发。受限于本人当前的技术水平,未尽之处还请指教。

Basic SI

1995年Hal Berenson等人在《A critique of ANSI SQL Isolation levels》中提出了Snapshot Isolation的概念,我们先来看下这里是如何定义SI的,为了不失真,这里只做一下翻译不做主观解读:

  • 事务的读操作从Committed快照中读取数据,快照时间可以是事务的第一次读操作之前的任意时间,记为StartTimestamp
  • 事务准备提交时,获取一个CommitTimestamp,它需要比现存的StartTimestampCommitTimestamp都大
  • 事务提交时进行冲突检查,如果没有其他事务在[StartTS, CommitTS]区间内提交了与自己的WriteSet有交集的数据,则本事务可以提交;这里阻止了Lost Update异常
  • SI允许事务用很旧的StartTS来执行,从而不被任何的写操作阻塞,或者读一个历史数据;当然,如果用一个很旧的CommitTS提交,大概率是会Abort的

其正确性相对容易理解,这里不做赘述。简单提一下冲突检查:

  • 这里的时间和空间没有交集的检查,主要是为了阻止LostUpdate的异常
  • 实现的时候通常利用锁和LastCommit Map,提交之前锁住相应的行,然后遍历自己的WriteSet,检查是否存在一行记录的LastCommit落在了自己的[StartTS, CommitTS]
  • 如果不存在冲突,就把自己的CommitTS更新到LastCommit中,并提交事务释放锁

但仔细思考这里提出Snapshot Isolation,我们会发现存在几个疑问:

  • CommitTS的获取,如何得到一个比现有的StartTSCommitTS都大的时间戳;尤其是在分布式系统中,生成一个全局单调递增的时间戳显然会是一个单点
  • StartTS的获取,这里提到的StartTS可以是一个很旧的时间,那么就不需要单调递增了?
  • 提交时进行的冲突检查是为了解决Lost Update异常,那么对于这个异常来说,写写冲突的检查是充分且必要的吗?
  • 如何实现分布式、甚至去中心化的Snapshot Isolation

接下来会围绕这几个方向进行展开。为了讨论方便,我们将这里提到的Snapshot Isolation实现方法记为Basic SI。

Distributed

分布式是一个很重要的方向,在2010年左右的HBaseSI、Percolator、Omid就对Distributed SI在学术和工程方面进行了探索。

HBase-SI

HBaseSI是完全基于HBase实现的分布式SI方案,注意到它是完全基于HBase,甚至没有其他的系统组件。

它使用了多个HBase表来完成SI的需求:

  • Version Table:用作记录一行数据的最后的CommitTS
  • Committed Table:记录CommitLog,事务提交时将commit log写到这张表中可认为Committed
  • PreCommit Table:用作检查并发冲突,可理解为锁表;
  • Write Label Table:生成全局唯一的Write Label
  • Committed Index Table:加速StartTS的生成
  • DS:实际存储数据

image.pngimage.png

协议的细节较多,简单概括一下:

  • StartTS:从Committed Table中遍历找到单调连续递增的最大提交时间戳,即往前不存在空洞;这里的空洞指的是事务拿了CommitTS但不会按照CommitTS顺序提交
  • Committed Index:为了避免获取StartTS过程遍历太多数据,每个事务在获得StartTS之后会写到Committed Index Table中,之后的事务从这个时间戳开始遍历即可,相当于缓存了一下
  • read:需要判断一个事务的数据是否提交了,去VersionTable和Committed Table检查
  • precommit: 先检查Committed Table是否存在冲突事务,然后在PreCommit Table记录一行,再检查PreCommitTable中是否存在冲突的事务
  • commit:拿到一个commitTS,在CommittedTable写一条记录,更新PreCommit Table

虽然方案的性能堪忧,但这种解耦的思路着实令人称奇。

Percolator

HBaseSI在结构上可谓十分解耦,将所有的状态都下沉到了HBase中,每个场景的需求都用不同的表来实现;但这种解耦也带来了性能损失。同在2010年提出的Percolator就做的更加工程化,将以上的诸多Table,合并成了一个。

image.png

在原来的一列数据的基础上,增加了lock、write列:

  • lock:顾名思义是锁,用作WW冲突检查;实际使用时lock会区分Primary Lock和Secondary Lock
  • write:可理解为commit log,事务提交仍然走2PC,Coordinator决定Commit时会在write列写一条commit log,写完之后事务即认为Committed

同时,作为一个分布式的SI方案,仍然需要依赖2PC实现原子性提交;而prewrite和commit过程,则很好地将事务的加锁和2PC的prepare结合到一起,并利用Bigtable的单行事务,来避免了HBaseSI方案中的诸多race处理。

关于Precolator的解读在中文社区已经很多,这里就不再赘述。之前我也写过一篇解读

Omid三部曲

Omid是Yahoo的作品,同样是基于HBase实现分布式SI,但和Percolator的Pessimistic方法相比,Omid是一种Optimistic的方式,正如其名:『Optimistically transaction Management In Datastores』。其架构相对优雅简洁,工程化做得也不错;近几年接连在ICDE、FAST、PVLDB上发了文章,也是学习分布式事务的优秀资料。

文中多次diss了Percolator,认为Percolator的基于Lock的方案虽然简化了事务冲突检查,但是将事务的驱动交给客户端,在客户端故障的情况下,遗留的Lock清理会影响到其他事务的执行,并且维护额外的lock和write列,显然也会增加不小的开销。而Omid这样的Optimistic方案完全由中心节点来决定Commit与否,在事务Recovery方面会更简单;并且,Omid其实更容易适配到不同的分布式存储系统,侵入较小。

令人好奇的是,Omid早期就是做Write-Snapshot Isolation那些人搞的系统,但后来的Omid发展过程中,并么有使用Write-Snapshot Isolation算法了。

这里的三部曲,分别对应了他们发的三篇文章:

  • ICDE 2014: 《Omid: Lock-free transactional support for distributed data stores》
  • FAST 2017:《Omid, reloaded: Scalable and highly-available transaction processing》
  • PVLDB 2018:《Taking omid to the clouds: fast, scalable transactions for real-time cloud analytics》

2014年的文章即奠定了Omid的架构:

  • TSO:负责时间戳分配、事务提交
  • BookKeeper: 分布式日志组件,用来记录事务的Commit Log
  • DataStore:用HBase存储实际数据,也可适配到其他的分布式存储系统

image.png

TSO维护了几个状态:

  • 时间戳:单调递增的时间戳用于SI的StartTSCommitTS
  • lastCommit: 所有数据的提交时间戳,用于WW冲突检测;这里会根据事务的提交时间进行一定裁剪,使得在内存中能够存下
  • committed:一个事务提交与否,事务ID用StartTS标识;这里记录StartTS -> CommitTS的映射即可
  • uncommitted:分配了CommitTS但还未提交的事务
  • T_max: lastCommit所保留的低水位,小于这个时间戳的事务来提交时一律Abort

这里的lastCommit即关键所在,表明了事务提交时不再采用和Percolator一样的先加锁再检测冲突的Pessimistic方式;而是:

  • 将Commit请求发到TSO来进行Optimistic的冲突检测
  • 根据lastCommit信息,检测一个事务的WriteSet是否与lastCommit存在时间和空间的重叠;如果没有冲突,则更新lastCommit,并写commit log到BookKeeper
  • TSO的lastCommit显然会占用很多内存,并且成为性能瓶颈;为此,仅保留最近的一段lastCommit信息,用Tmax维护低水位,小于这个Tmax时一律abort

另外提出了一个客户端缓存Committed的优化方案,减少到TSO的查询;在事务的start请求中,TSO会将截止到start时间点的committed事务返回给客户端,从而客户端能够直接判断一个事务是否已经提交: undefined

在FAST2017中,Omid对之前的架构进行了调整,做了一些工程上的优化:

  • commit log不再存储于BookKeeper,而是用一张额外的HBase表存储
  • 客户端不再缓存committed信息,而是缓存到了数据表上;因此大部分时候,用户读数据时根据commit字段就能够判断这行数据是否已经提交了

undefined

而在PLVDB2018,Omid再次进行了大幅的工程优化,覆盖了更多的场景:

  • Commit Log不再由TSO来写,而是offload到客户端,提高了扩展性,也降低了事务延迟
  • 优化单行读写事务,在数据上增加一个maxVersion的内存记录,实现了单行的读写事务不再需要进行中心节点校验

undefined

可以看到,中心化的分布式SI也可以取得非常优秀的性能。

Decentralized

上面提到了一众分布式SI的实现都有一个特征,他们仍然保留了中心节点,或用于事务协调,或用于时间戳分配;对于大规模或者跨区域的事务系统来说,这仍然是一个心头之痛。针对这个问题,就有了一系列对去中心化SI的探索。

Clock-SI

Clock-SI是2013年EPFL的作品,一作目前在Google工作(据Linkedin信息)。虽在国内没有什么讨论,但据悉,工业界已经有了实践。在PGCon2018的一个talk《Towards ACID scalable PostgreSQL with partitioning and logical replication》就提到,他们已经在应用Clock-SI的算法到PostgreSQL中,实现去中心化的SI;而MongoDB虽然未曾提及他们使用分布式事务算法,但据目前提交的代码来看,使用Clock-SI的可能性也非常大。

Clock-SI首先高屋建瓴地指出,Snapshot Isolation的正确性包含三点:

  • Consistent Snapshot:所谓Consistent,即快照包含且仅包含Commit先于SnapshotTS的所有事务
  • Commit Total Order:所有事务提交构成一个全序关系,每次提交都会生成一个快照,由CommitTS标识
  • Write-Write Conflict: 事务Ti和Tj有冲突,即它们WriteSet有交集,且[SnapshotTS, CommitTS]有交集

undefined

基于这三个要求,Clock-SI提出了如下的算法:

  1. StartTS:直接从本地时钟获取
  2. Read:当目标节点的时钟小于StartTS时,进行等待,即上图中的Read Delay;当事务处于Prepared或者Committing状态时,也进行等待;等待结束之后,即可读小于StartTS的最新数据;这里的Read Delay是为了保证Consistent Snapshot
  3. CommitTS:区分出单Partition事务和分布式事务,单Partition事务可以使用本地时钟作为CommitTS直接提交;而分布式事务则选择max{PrepareTS}作为CommitTS进行2PC提交;为了保证CommitTS的全序,会在时间戳上加上节点的id,和Lamport Clock的方法一致
  4. Commit:不论是单partition还是多partition事务,都由单机引擎进行WW冲突检测

ClockSI有几点创新:

  • 使用普通的物理时钟,不再依赖中心节点分配时间戳
  • 对单机事务引擎的侵入较小,能够基于一个单机的Snapshot Isolation数据库实现分布式的SI
  • 区分单机事务和分布式事务,几乎不会降低单机事务的性能;分布式使用2PC进行原子性提交

在工程实现中,还需考虑这几个问题:

  • StartTS选择:可以使用较旧的快照时间,从而不被并发事务阻塞
  • 时钟漂移:虽然算法的正确性不受时钟漂移的影响,但时钟漂移会增加事务的延迟,增加abort rate
  • Session Consistency:事务提交后将时间戳返回给客户端记为latestTS,客户端下次请求带上这个latestTS,并进行等待

实验结果自然是非常漂亮,不论是LAN还是WAN都有很低的延迟:

undefined

不过较为遗憾的是,此文对正确性的证明较为简略,后续笔者会对此算法进行详细分析。如果正确性得到保证,不出意外的话这几年会涌现出不少基于ClockSI的分布式数据库实现。

ConfluxDB

如果说Clock-SI还有什么不足,那可能就是依赖了物理时钟,在时钟漂移的场景下会对事务的延迟和abort rate造成影响。能否不依赖物理时钟,同时又能够实现去中心化呢?

ConfluxDB提出的方案中,仅仅依赖逻辑时钟来捕获事务的先于关系,基于先于关系来检测冲突:

  • 当事务Ti准备提交时,2PC的Coordinator向所有参与者请求事务的concurrent(Ti)列表;这里的concurrenct(Ti)定义为begin(Tj) < commit(Ti)的事务
  • Coordinator在收到所有参与者的concurrent(Ti)之后,将其合并成一个大的gConcurrent(Ti),并发回给所有参与者
  • 参与者根据gConcurrent(Ti),检查是否存在一个事务Tj,dependents(Ti,Tj) ∧ (Tj ∈ gConcurrent(Ti)) ∧ (Tj ∈ serials(Ti)),即存在一个事务Tj,在不同的partition中有不同的先后关系,违背了Consistent Snapshot的规则
  • 参与者将冲突检测的结果发回给Coordinator,Coordinator据此决定是Commit还是Abort
  • 除此之外Coordinator需要给这个事务生成一个CommitTS,这里选择和ClockSI类似的方式,commitTS=max{prepareTS},这里的prepareTS和commitTS会按照Logical Clock的方式在节点之间传递

ConfluxDB的这种方案不需要依赖物理时钟,不需要任何wait,甚至不需要单机的事务引擎支持读时间点快照的功能;这意味着,是不是可以基于单机的XA来实现全局一致的快照,MySQL再也不用哭了。但是这个方案的阴暗面是,可能Abort rate并不是很好,以及在执行分布式事务时的延迟问题。

Replication

Replication看起来和事务是独立的两个东西,但实际上他们之间存在一些关联。

Generalized SI

Generalized SI将Snapshot Isolation应用到Replicated Database中,使得事务的Snapshot可以从复制组的从节点读取。这带来的意义有两点,使用一个旧的快照,不会被当前正在运行的事务阻塞,从而降低事务延迟;而从Secondary节点读取数据,则可以实现一定程度上的读写分离,扩展读性能。

GSI首先重新定义SI:

  • snapshot(Ti): 事务获取快照的时间
  • start(Ti): 事务的第一个操作
  • commit(Ti): 事务的提交时间
  • abort(Ti): 事务Abort时间
  • end(Ti): 事务结束时间
• D1. (GSI Read Rule) 
∀Ti,Xj such that Ri(Xj) ∈ h : 
1. Wj(Xj) ∈ h and Cj ∈ h; 
2. commit(Tj) < snapshot(Ti); 
3. ∀Tk such that Wk(Xk),Ck ∈ h : 
 	[commit(Tk) < commit(Tj) or snapshot(Ti) < commit(Tk)].
    
• D2. (GSI Commit Rule) 
∀Ti,Tj such that Ci,Cj ∈ h : 
4. ¬(Tj impacts Ti).

这段话翻译一下,就是说:

  • 如果事务Ti读到了Tj,说明不存在一个事务Tk,其commit(Tk)在[commit(Tj), snapshot(Ti)]之间
  • 事务提交时,不存在两个事务的WriteSet有交集且时间有交集

基于这个定义,Generalized SI可以允许读任意的Snapshot;但实际应用中,我们总是对数据的新旧存在一些要求,因此基于GSI的定义,又衍生出Prefix-Consistent SI,即满足Prefix-Consistency的事务:

5. ∀Tk such that Wk(Xk),Ck ∈ h and Ti ∼ Tk: 
	 [commit(Tk) < commit(Tj) or start(Ti) < commit(Tk)].

这里的Ti ~ Tk,意味着Ti和Tk存在先于关系,那么在[commit(Tj), start(Ti)]内就不允许有事务提交,否则就应该被事务Tj读到。换言之,事务的快照需要满足Prefix-Consistency的条件,能读到自己提交过的数据。

文章的算法相对朴素,但至少给我们带来一点启发:事务的读操作可以发到从节点,而写操作buffer在客户端,最后提交时发到主节点。相应的代价是,由于[snapshot, commit]窗口更大可能会增加abort rate。另外有意思的是,Azure CosmosDB也实现了PrefixConsistency:

Parallel SI

上面的方案中,可以将读请求offload到Secondary节点,一定程度上能够扩展读性能。那么继续将这个思路延伸一下,能不能把事务的提交也交给Secondary节点来执行呢?

这就是Parallel Snapshot Isolation的思路,在跨区域复制的场景下,业务通常会有地理位置局部性的要求,在上海的用户就近把请求发到上海的机房,在广州的用户把请求发到广州的机房;并且在实际的业务场景中,往往可以放松对一致性和隔离性的要求。Parallel放弃了Snapshot Isolation中对Commit Total Order的约束,从而实现了多点的事务提交。在通用数据库中可能很难使用这样的方案,但实际的业务场景中会很有价值。

undefined

Serializable

Snapshot Isolation所区别于Serializable的是Write Skew异常,为了解决这个异常,可以基于Snapshot Isolation进行优化,并且尽量保留Snapshot Isolation的优秀性质。

Serializable Isolation for Snapshot Database

本文发于2009年,是较为早期的对Serializable SI的研究,来自Alan D. Fekete和Michael J. Cahill的作品。

image.png

故事从串行化图理论说起,在Multi-Version的串行图中,增加一种称之为RW依赖的边,即事务T1先写了一个版本,事务T2读了这个版本,则产生RW依赖。当这个图产生环时,则违背了Serializable。

Fekete证明,SI产生的环中,两条RW边必然相邻,也就意味着会有一个pivot点,既有出边也有入边。那么只要检测出这个pivot点,选择其中一个事务abort掉,自然就打破了环的结构。算法的核心就在于动态检测出这个结构,因此会在每个事务记录一些状态,为了减少内存使用,使用inConflictoutConflict两个bool值来记录;在事务执行读写操作的过程中,会将与其他事务的读写依赖记录于这两个状态中。

  • 虽然用bool值减少了内存使用,但显然也增加了false positive,会导致一部分没有异常的事务被abort
  • 据文中的实验结果表明,性能好于S2PL,abort较低,给Snapshot Isolation带来的开销也比较小
  • 但据后来的PostgreSQL的SSI实现,为了减少内存占用仍需要不少的工作量,有兴趣可参考《Serializable Snapshot Isolation in PostgreSQL》

Write-SI

Write-Snapshot Isolation来自Yabandeh的《A critique of snapshot isolation》,名字可谓语不惊人死不休。在工业界也造成一定反响:CockroachDB的文章里提到,WSI的思路对他们产生了很大启发;而Badger则是直接使用了这个算法,实现了支持事务的KV引擎。

之所以critique snapshot isolation,因为Basic Snapshot Isolation给人造成了一种误导:『进行写写冲突检测是必须的』。文章开篇即提出,SI中的LostUpdate异常,不一定需要阻止WW冲突;换成RW检测,允许WW冲突,既能够阻止LostUpdate异常,同时能够实现Serializable,岂不美哉?

为何WW检测不是必须的?非形式化地思考一下,在MVCC中,写冲突的事务写的是不同的版本,为何一定会有冲突;实际上只有两个事务都是RW操作时才有异常,如果其中一个事务事务只有W操作,并不会出现Lost Update;换言之,未必要检测WW冲突,RW冲突才是根源所在。

image.png

基于RW冲突检测的思想,作者提出Write Snapshot Isolation,将之前的Snapshot Isolation命名为Read Snapshot Isolation。例如图中:

  • TXNn和TXNc’有冲突,因为TXNc’修改了TXNn的ReadSet
  • TXNn和TXNc没有冲突,虽然他们都修改了r’这条记录,Basic SI会认为有冲突,但WriteSI认为TXNc没有修改TXNn的ReadSet,则没有RW冲突

如何检测RW冲突:事务读写过程中维护ReadSet,提交时检查自己的ReadSet是否被其他事务修改过,over。但实际也不会这么简单,因为通常维护ReadSet的开销比WriteSet要大,且这个冲突检查如何做,难道加读锁?所以在原文中,作者只解释了中心化的WSI如何实现,至于去中心化的实现,可从Cockroach找到一点影子。

不过RW检测会带来很多好处:

  • 只读事务不需要检测冲突,它的StartTS和CommitTS一样
  • 只写事务不需要检测冲突,它的ReadSet为空

更重要的是,这种算法实现的隔离级别是Serializable而不是Snapshot Isolation。

总结

近年来,Snapshot Isolation围绕着Distributed、Decentralized、Replicated、Serializable这几个方向进行了很多探索,并且也围绕着实际的应用场景进行了特定的优化,例如对一致性、隔离性的放宽。较为遗憾的是,还没有看到一个Understandable的定义,更多的文章中仍然是非形式化的定义,众说纷纭。按照历史进步的轨迹,想必已经有同学在In search of understandable snapshot isolation algorithm,来解决这个纷乱的局面。

本文是今年对领域进行系统性学习的第一次文章总结,旨在通过系统视角,克服视野的局限性;后续会按照感兴趣的领域继续展开,希望在有限的职业生涯,对一个或多个领域有更深刻的认识。

参考

  • Berenson H, Bernstein P, Gray J, et al. A critique of ANSI SQL isolation levels[C]//ACM SIGMOD Record. ACM, 1995, 24(2): 1-10.
  • Yabandeh M, Gómez Ferro D. A critique of snapshot isolation[C]//Proceedings of the 7th ACM european conference on Computer Systems. ACM, 2012: 155-168.
  • Zhang C, De Sterck H. Hbasesi: Multi-row distributed transactions with global strong snapshot isolation on clouds[J]. Scalable Computing: Practice and Experience, 2011, 12(2): 209-226.
  • Peng D, Dabek F. Large-scale Incremental Processing Using Distributed Transactions and Notifications[C]//OSDI. 2010, 10: 1-15.
  • Bortnikov E, Hillel E, Keidar I, et al. Omid, reloaded: Scalable and highly-available transaction processing[C]//15th {USENIX} Conference on File and Storage Technologies ({FAST} 17). 2017: 167-180.
  • Ferro D G, Junqueira F, Kelly I, et al. Omid: Lock-free transactional support for distributed data stores[C]//2014 IEEE 30th International Conference on Data Engineering (ICDE). IEEE, 2014: 676-687.
  • Shacham O, Gottesman Y, Bergman A, et al. Taking omid to the clouds: fast, scalable transactions for real-time cloud analytics[J]. Proceedings of the VLDB Endowment, 2018, 11(12): 1795-1808.
  • Du J, Elnikety S, Zwaenepoel W. Clock-SI: Snapshot isolation for partitioned data stores using loosely synchronized clocks[C]//Reliable Distributed Systems (SRDS), 2013 IEEE 32nd International Symposium on. IEEE, 2013: 173-184.
  • Chairunnanda P, Daudjee K, Özsu M T. Confluxdb: multi-master replication for partitioned snapshot isolation databases[J]. Proceedings of the VLDB Endowment, 2014, 7(11): 947-958.
  • Cahill M J, Röhm U, Fekete A D. Serializable isolation for snapshot databases[J]. ACM Transactions on Database Systems (TODS), 2009, 34(4): 20.
  • Sovran Y, Power R, Aguilera M K, et al. Transactional storage for geo-replicated systems[C]//Proceedings of the Twenty-Third ACM Symposium on Operating Systems Principles. ACM, 2011: 385-400.
  • Elnikety S, Pedone F, Zwaenepoel W. Database replication using generalized snapshot isolation[C]//Reliable Distributed Systems, 2005. SRDS 2005. 24th IEEE Symposium on. IEEE, 2005: 73-84.

MSSQL · 最佳实践 · 数据库备份加密

$
0
0

摘要

在SQL Server安全系列专题月报分享中,我们已经分享了:如何使用对称密钥实现SQL Server列加密技术、使用非对称密钥实现SQL Server列加密、使用混合密钥实现SQL Server列加密技术、列加密技术带来的查询性能问题以及相应解决方案、行级别安全解决方案和SQL Server 2016 dynamic data masking实现隐私数据列打码技术这六篇文章,文章详情可以参见往期月报。本期月报我们分享使用证书做数据库备份加密的最佳实践。

问题引入

谈及数据库安全性问题,如何预防数据库备份文件泄漏,如何防止脱库安全风险,是一个非常重要的安全防范课题。这个课题的目的是万一用户数据库备份文件泄漏,也要保证用户数据的安全。在SQL Server中,2014版本之前,业界均采用的TDE技术来实现与防范脱库行为,但是TDE的原理是需要将用户所有的数据进行加密后落盘,读取时解密。这种写入时加密,读取时解密的行为,必然会导致用户查询性能的降低和CPU使用率的上升(具体对性能和CPU影响,可以参见这片测试文章SQL Server Transparent Data Encryption (TDE) Performance Comparison)。那么,我们一个很自然的问题是:有没有一种技术,既可以保证备份文件的安全,又能够兼顾到用户查询性能和CPU资源的消耗呢?这个技术就是我们今天要介绍的数据库备份加密技术,该技术是SQL Server 2014版本首次引入,企业版本和标准版支持备份加密,Web版和Express版支持备份加密文件的还原。

具体实现

创建测试数据库

为了测试方便,我们专门创建了测试数据库BackupEncrypted。

-- create test database
IF DB_ID('BackupEncrypted') IS NOT NULL
	DROP DATABASE BackupEncrypted
GO
CREATE DATABASE BackupEncrypted
ON PRIMARY
(NAME = BackupEncrypted_data,
	FILENAME = N'E:\SQLDATA\DATA\BackupEncrypted_data.mdf',
	SIZE = 100MB, FILEGROWTH = 10MB),
FILEGROUP SampleDB_MemoryOptimized_filegroup CONTAINS MEMORY_OPTIMIZED_DATA
  ( NAME = BackupEncrypted_MemoryOptimized,
    FILENAME = N'E:\SQLDATA\DATA\BackupEncrypted_MemoryOptimized')
LOG ON
  ( NAME = BackupEncrypted_log,
    FILENAME = N'E:\SQLDATA\DATA\BackupEncrypted_log.ldf',
	SIZE = 100MB, FILEGROWTH = 10MB)
GO

创建测试表

在测试数据库下,创建一张用于测试的表testTable,并插入一条随机数据。

USE [BackupEncrypted]
GO
-- create test table and insert one record
IF OBJECT_ID('dbo.testTable', 'U') IS NOT NULL
	DROP TABLE dbo.testTable
GO
CREATE TABLE dbo.testTable
(
 id UNIQUEIDENTIFIER default NEWID(),
 parent_id UNIQUEIDENTIFIER default NEWSEQUENTIALID()
);
GO

SET NOCOUNT ON;
INSERT INTO dbo.testTable DEFAULT VALUES;
GO

SELECT * FROM dbo.testTable ORDER BY id;

该条数据内容如下截图:

01.png

创建Master Key和证书

创建Master Key和证书,用于加密数据库备份文件。

USE master
GO
-- If the master key is not available, create it. 
IF NOT EXISTS (SELECT * 
				FROM sys.symmetric_keys
				WHERE name LIKE '%MS_DatabaseMasterKey%') 
BEGIN
	CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'MasterKey*'; 
END 
GO

USE master
GO
-- create certificate
CREATE CERTIFICATE MasterCert_BackupEncrypted
AUTHORIZATION dbo
WITH SUBJECT = 'Backup encryption master certificate',
START_DATE = '02/10/2017',
EXPIRY_DATE = '12/30/9999'
GO

备份证书

首先,将证书和证书密钥文件备份到本地,最好它们脱机保存到第三方主机,以免主机意外宕机,导致证书文件丢失,从而造成已加密的备份文件无法还原的悲剧。

USE master
GO
EXEC sys.xp_create_subdir 'C:\Tmp'

-- then backup it up to local path
BACKUP CERTIFICATE MasterCert_BackupEncrypted 
TO FILE = 'C:\Tmp\MasterCert_BackupEncrypted.cer'
WITH PRIVATE KEY (
	FILE = 'C:\Tmp\MasterCert_BackupEncrypted.key',
	ENCRYPTION BY PASSWORD = 'aa11@@AA')
;

加密完全备份

创建完Master Key和证书文件后,我们就可以做数据库完全备份加密操作。

USE master;
GO
-- do full backup database with encryption
BACKUP DATABASE [BackupEncrypted]  
TO DISK = N'C:\Tmp\BackupEncrypted_FULL.bak'  
WITH COMPRESSION, ENCRYPTION (
	ALGORITHM = AES_256, 
	SERVER CERTIFICATE = MasterCert_BackupEncrypted),
	STATS = 10;
GO

加密差异备份

数据库差异备份加密,备份操作前,我们插入一条数据,以供后续的测试数据校验。

USE [BackupEncrypted]
GO
-- insert another record
SET NOCOUNT ON;
INSERT INTO dbo.testTable DEFAULT VALUES;
GO

SELECT * FROM dbo.testTable ORDER BY id;

USE master;
GO
--Differential backup with encryption
BACKUP DATABASE [BackupEncrypted]
TO DISK = N'C:\Tmp\BackupEncrypted_DIFF.bak'
WITH CONTINUE_AFTER_ERROR,ENCRYPTION (
	ALGORITHM = AES_256, 
	SERVER CERTIFICATE = MasterCert_BackupEncrypted),
	STATS = 10,
	DIFFERENTIAL;
GO

差异备份操作前,校验表中的两条数据如下图所示:

02.png

加密日志备份

数据库事物日志备份加密,备份前,我们照样插入一条数据,以供后续测试数据校验。

USE BackupEncrypted
GO
-- insert another record
SET NOCOUNT ON;
INSERT INTO dbo.testTable DEFAULT VALUES;
GO

SELECT * FROM dbo.testTable ORDER BY id;

USE master;
GO
-- backup transaction log with encryption
BACKUP LOG [BackupEncrypted]
TO DISK = N'C:\Tmp\BackupEncrypted_log.trn'
WITH CONTINUE_AFTER_ERROR,ENCRYPTION (
	ALGORITHM = AES_256, 
	SERVER CERTIFICATE = MasterCert_BackupEncrypted),
	STATS = 10;
GO

日志备份操作前,校验表中的三条数据如下图所示:

03.png

查看备份历史

数据完全备份、差异备份和日志备份结束后,查看备份历史记录。

use msdb
GO
-- check backups
SELECT 
	b.database_name,
	b.key_algorithm,
	b.encryptor_thumbprint,
	b.encryptor_type,
	b.media_set_id,
	m.is_encrypted, 
	b.type,
	m.is_compressed,
	bf.physical_device_name
FROM dbo.backupset b
INNER JOIN dbo.backupmediaset m 
	ON b.media_set_id = m.media_set_id
INNER JOIN dbo.backupmediafamily bf 
	on bf.media_set_id=b.media_set_id
WHERE database_name = 'BackupEncrypted'
ORDER BY b.backup_start_date  DESC

备份历史信息展示如下:

04.png

从截图中数据我们可以看出,三种备份都采用了证书做备份加密。

查看备份文件信息

备份历史检查完毕后,在清理测试环境之前,检查备份文件元数据信息,可以成功查看,没有任何报错。

USE master
GO
-- before clean environment, try to get backup files meta info, will be success
RESTORE FILELISTONLY FROM DISK='C:\Tmp\BackupEncrypted_FULL.bak'
RESTORE HEADERONLY FROM DISK='C:\Tmp\BackupEncrypted_FULL.bak'

RESTORE FILELISTONLY FROM DISK='C:\Tmp\BackupEncrypted_DIFF.bak'
RESTORE HEADERONLY FROM DISK='C:\Tmp\BackupEncrypted_DIFF.bak'

RESTORE FILELISTONLY FROM DISK='C:\Tmp\BackupEncrypted_log.trn'
RESTORE HEADERONLY FROM DISK='C:\Tmp\BackupEncrypted_log.trn'

展示结果部分截图如下:

05.png

清理环境

清理环境目的是模拟在一台全新实例上还原数据库备份文件。

use master
GO
-- let's try to simulate a database crash, here we just drop this database.
DROP DATABASE [BackupEncrypted];
GO
-- and clean certificate and master key to simulate restore to a new instance.

DROP CERTIFICATE MasterCert_BackupEncrypted;
GO

DROP MASTER KEY;
GO

再次查看备份文件信息

清理掉证书和Master Key后,再次查看备份文件信息,此时会报错。因为数据库备份文件已经加密。这种报错是我们所预期的,即就算我们的数据库备份文件被脱库泄漏,我们的数据也可以保证绝对安全,而不会非预期的还原回来。

USE master
GO
-- try to get backup files meta info again after clean environment, will be not success now.
RESTORE FILELISTONLY FROM DISK='C:\Tmp\BackupEncrypted_FULL.bak'
RESTORE HEADERONLY FROM DISK='C:\Tmp\BackupEncrypted_FULL.bak'

RESTORE FILELISTONLY FROM DISK='C:\Tmp\BackupEncrypted_DIFF.bak'
RESTORE HEADERONLY FROM DISK='C:\Tmp\BackupEncrypted_DIFF.bak'

RESTORE FILELISTONLY FROM DISK='C:\Tmp\BackupEncrypted_log.trn'
RESTORE HEADERONLY FROM DISK='C:\Tmp\BackupEncrypted_log.trn'

报错信息类似如下:

Msg 33111, Level 16, State 3, Line 178
Cannot find server certificate with thumbprint '0xA938CE32CC86DFA6EAD2AED9429814F1A4C683ED'.
Msg 3013, Level 16, State 1, Line 178
RESTORE FILELIST is terminating abnormally.
Msg 33111, Level 16, State 3, Line 179
Cannot find server certificate with thumbprint '0xA938CE32CC86DFA6EAD2AED9429814F1A4C683ED'.
Msg 3013, Level 16, State 1, Line 179
RESTORE HEADERONLY is terminating abnormally.
Msg 33111, Level 16, State 3, Line 181
Cannot find server certificate with thumbprint '0xA938CE32CC86DFA6EAD2AED9429814F1A4C683ED'.
Msg 3013, Level 16, State 1, Line 181
RESTORE FILELIST is terminating abnormally.
Msg 33111, Level 16, State 3, Line 182
Cannot find server certificate with thumbprint '0xA938CE32CC86DFA6EAD2AED9429814F1A4C683ED'.
Msg 3013, Level 16, State 1, Line 182
RESTORE HEADERONLY is terminating abnormally.
Msg 33111, Level 16, State 3, Line 184
Cannot find server certificate with thumbprint '0xA938CE32CC86DFA6EAD2AED9429814F1A4C683ED'.
Msg 3013, Level 16, State 1, Line 184
RESTORE FILELIST is terminating abnormally.
Msg 33111, Level 16, State 3, Line 185
Cannot find server certificate with thumbprint '0xA938CE32CC86DFA6EAD2AED9429814F1A4C683ED'.
Msg 3013, Level 16, State 1, Line 185
RESTORE HEADERONLY is terminating abnormally.

部分错误信息截图如下:

06.png

还原证书文件

数据库备份加密,可以有效防止脱库泄漏的安全风险。当然,合法用户需要在新实例上成功还原加密备份文件。首先,创建Master Key;然后,从证书备份文件中,重新创建证书。

USE master
GO
-- so we have to re-create master key, the certificate and open the 
IF NOT EXISTS (SELECT * 
				FROM sys.symmetric_keys
				WHERE name LIKE '%MS_DatabaseMasterKey%') 
BEGIN
	CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'MasterKey*'; 
END 
GO

use master
GO
-- re-create certificate
CREATE CERTIFICATE MasterCert_BackupEncrypted
FROM FILE = 'C:\Tmp\MasterCert_BackupEncrypted.cer'
WITH PRIVATE KEY (FILE = 'C:\Tmp\MasterCert_BackupEncrypted.key',
DECRYPTION BY PASSWORD = 'aa11@@AA');
GO

检查备份文件信息

校验备份文件信息,已经可以正确读取。

USE master
GO
-- after re-create certificate, try to get backup files meta info again, will be success.
RESTORE FILELISTONLY FROM DISK='C:\Tmp\BackupEncrypted_FULL.bak'
RESTORE HEADERONLY FROM DISK='C:\Tmp\BackupEncrypted_FULL.bak'

RESTORE FILELISTONLY FROM DISK='C:\Tmp\BackupEncrypted_DIFF.bak'
RESTORE HEADERONLY FROM DISK='C:\Tmp\BackupEncrypted_DIFF.bak'

RESTORE FILELISTONLY FROM DISK='C:\Tmp\BackupEncrypted_log.trn'
RESTORE HEADERONLY FROM DISK='C:\Tmp\BackupEncrypted_log.trn'

还原已加密完全备份文件

首先,尝试还原数据库完全备份文件,成功。

USE [master]
-- restore encrypted full backup
RESTORE DATABASE [BackupEncrypted] 
FROM  DISK = N'C:\Tmp\BackupEncrypted_FULL.bak' 
WITH FILE = 1,  
MOVE 'BackupEncrypted_data' TO N'E:\SQLDATA\DATA\BackupEncrypted_data.mdf',
MOVE 'BackupEncrypted_MemoryOptimized' TO N'E:\SQLDATA\DATA\BackupEncrypted_MemoryOptimized',
MOVE 'BackupEncrypted_log' TO N'E:\SQLDATA\DATA\BackupEncrypted_log.ldf',
NOUNLOAD,  STATS = 5, NORECOVERY
GO

还原已加密差异备份文件

其次,尝试还原数据库差异备份文件,成功。

-- Restore encrypted diff backup
RESTORE DATABASE [BackupEncrypted] 
FROM  DISK = N'C:\Tmp\BackupEncrypted_DIFF.bak' WITH  FILE = 1,  
MOVE 'BackupEncrypted_data' TO N'E:\SQLDATA\DATA\BackupEncrypted_data.mdf',
MOVE 'BackupEncrypted_MemoryOptimized' TO N'E:\SQLDATA\DATA\BackupEncrypted_MemoryOptimized',
MOVE 'BackupEncrypted_log' TO N'E:\SQLDATA\DATA\BackupEncrypted_log.ldf',
NOUNLOAD,  STATS = 5, NORECOVERY
GO

还原已加密日志备份文件

再次,尝试还原数据库日志备份文件,成功。

-- restore encrypted transaction log backup
RESTORE LOG [BackupEncrypted] 
FROM  DISK = N'C:\Tmp\BackupEncrypted_log.trn' WITH  FILE = 1,  
MOVE 'BackupEncrypted_data' TO N'E:\SQLDATA\DATA\BackupEncrypted_data.mdf',
MOVE 'BackupEncrypted_MemoryOptimized' TO N'E:\SQLDATA\DATA\BackupEncrypted_MemoryOptimized',
MOVE 'BackupEncrypted_log' TO N'E:\SQLDATA\DATA\BackupEncrypted_log.ldf',
NOUNLOAD,  STATS = 10
GO

检查测试表数据

最后,检查测试表的三条测试数据。

USE [BackupEncrypted]
GO
-- double check the three records
SELECT * FROM dbo.testTable ORDER BY id;

三条校验数据一致。

07.png

清理测试环境

清理掉我们的测试环境。

use master
GO
-- clean up the environment
DROP DATABASE BackupEncrypted;
GO
DROP CERTIFICATE MasterCert_BackupEncrypted;
GO
DROP MASTER KEY;
GO

最后总结

本期月报我们分享了SQL Server 2014及以上版本如何使用证书实现数据库备份加密技术,在防范脱库安全风险的同时,既能够比较好的保证用户查询性能,又不会带来额外CPU资源的消耗。

参考文章

SQL Server Transparent Data Encryption (TDE) Performance Comparison

SQLServer · 最佳实践 · 透明数据加密TDE在SQLServer的应用

开启TDE的RDS SQL Server还原到本地环境

Understanding Database Backup Encryption in SQL Server

MySQL · 引擎特性 · The design of mysql8.0 redolog

$
0
0

InnoDB 和大部分的存储引擎一样, 都是采用WAL 的方式进行写入数据, 所有的数据都先写入到redo log, 然后后续再从buffer pool 刷脏到数据页 又或者是备份恢复的时候从redo log 恢复到buffer poll, 然后在刷脏到数据页, WAL很重要的一点是将随机写转换成了顺序写, 所以在机械磁盘时代, 顺序写的性能远远大于随机写的背景下, 充分利用了磁盘的性能. 但是也带来一个问题, 就是任何的写入操作都必须加锁访问, 保证上一个写入操作完成以后, 才能进行下一个写入操作. 在 InnoDB 早期版本也是这样实现, 但是随着cpu 核数的增长, 这样频繁的加锁就无法发挥多核的性能, 所以在InnoDB 8.0 改成了无锁实现 这个是官方的介绍:

https://mysqlserverteam.com/mysql-8-0-new-lock-free-scalable-wal-design

5.6 版本实现

有两个操作需要获得全局的mutex, log_sys_t::mutex, log_sys_t::flush_order_mutex

  1. 每一个用户连接有一个线程, 要写入数据之前必须先获得log_sys_t::mutex, 用来保证只有一个用户线程在写入log buffer 那么随着连接数的增加, 这个性能必然会受到影响

  2. 同样的在把已经写入完成的redo log 加入到flush list 的时候, 为了保证只有一个用户线程从log buffer 上添加buffer 到flush list, 因此需要去获得log_sys_t::flush_order_mutex 来保证

如图:

Imgur

因此在5.6 版本的实现中, 我们需要先获得log_sys_t::mutex, 然后写入buffer, 然后获得log_sys_t::flush_order_mutex, 释放log_sys_t::mutex, 然后把对应的 page 加入到flush list

所以8.0 无锁实现主要就是要去掉这两个mutex

8.0 无锁实现

log_sys_t::mutex*

在去掉第一个log_sys_t::mutex 的时候, 通过在写入之前先预先分配地址, 然后在写入的时候往指定地址写入, 这样就无需抢mutex. 同样, 问题来了: 所有的线程都去获得lsn 地址的时候, 同样需要有一个mutex 来防止冲突, InnoDB 通过使用atomic 来达到无锁的实现, 即: const sn_t start_sn = log.sn.fetch_add(len);

在每一个线程获得了自己要写入的lsn 的位置以后, 写入自然就可以并发起来了.

那么在写入的时候, 如果位置在前面的线程未写完, 而位置靠后的已经写完了, 这个时候我该如何将Log buffer 中的内容写入到redo log, 肯定不允许写入的数据有空洞.

8.0 里面引入了log_writer 线程, log_writer 线程去检查log buffer 是否有空洞. 具体实现是引入了叫 recent_written 用来记录log buffer 是否连续, 这个recent_written 是一个link_buf 实现, 类型于并查集. 因此最大允许并发写入的大小 就是这个recent_written 的大小

link_buf 实现如图:

Imgur

这个后台线程在用户写入数据到recent_written buffer 的时候, 就被唤醒, 检查这个recent_written 连续的位置是否可以往前推进, 如果可以, 就往前走, 将recent_written buffer 中的内容写入到redo log

log_sys_t::flush_order_mutex

如果不去掉flush_order_mutex, 用户线程依然无法并发起来, 因为用户线程在写完redo log 以后, 需要把对应的page 加入到flush list才可以退出, 而加入到flush list 需要去获得 flush_order_mutex 锁, 才能保证顺序的加入flush list. 因此也必须把flush_order_mutex 去掉.

具体做法允许把log buffer 中的对应的脏页无序的添加到flush list. 用户写完log buffer 以后就可以把对应的 log buffer 对应的脏页添加到flush list. 而无需去抢flush_order_mutex. 这样可能出现加入到flush list 上的page lsn 是无序的, 因此在做checkpoint 的时候, 就无法保证每一个flush list 上面最头的page lsn 是最小的

InnoDB 用一个recent_closed 来记录添加到flush list 的这一段log buffer 是否连续, 那么容易得出, flush list 上page lsn - recent_closed.size() 得到的lsn 用于做checkpoint 肯定的安全的.

同样, InnoDB 后台有Log_closer 线程定期检查recent_closed 是否连续, 如果连续就把 recent_closed buffer 向前推进, 那么checkpoint 的信息也可以往前推进了

所以在8.0 的实现中, 把一个write redo log 的操作分成了几个阶段

  1. 获得写入位置, 实现: 用户线程
  2. 写入数据到log buffer 实现: 用户线程
  3. 将log buffer 中的数据写入到 redo log 文件 实现: log writer
  4. 将redo log 中的page cache flush 到磁盘 实现: log flusher
  5. 将redo log 中的log buffer 对应的page 添加到flush list
  6. 更新可以打checkpoint 位点信息 recent_closed 实现: log closer
  7. 根据recent_closed 打checkpoint 信息 实现: log checkpointer

代码实现

redo log 里面主要的内存结构

  1. log file. 也就是我们常见的ib_logfile 文件

  2. log buffer, 通常的大小是64M. 用户在写入的时候先从mtr 拷贝到redo log buffer, 然后在log buffer 里面会加入相应的header/footer 信息, 然后由log buffer 刷到redo log file.

  3. log recent written buffer 默认大小是4M, 这个是MySQL 8.0 加入的, 为的是提高写入时候的concurrent, 早5.6 版本的时候, 写入Log buffer 的时候是需要获得Lock, 然后顺序的写入到Log Buffer. 在8.0 的时候做了优化, 写入log buffer 的时候先reserve 空间, 然后后续的时候写入就可以并行的写入了, 也就是这一段的内容是允许有空洞的.

    MySQL__Redo_log_buffer

  4. log recent closed buffer 默认大小也是4M, 这个也是MySQL 8.0 加入的, 可以理解为log recent written buffer 在这个log buffer 的最前面, log recent closed buffer 在log buffer 的最后面. 也是为了添加到flush list 的时候提供concurrent. 具体实现方式和log recent written buffer 类似. 5.6 版本的时候, 将page 添加到flush list 的时候, 必须有一个Mutex 加锁, 然后按照顺序的添加到flush list 上. 8.0 的时候运行recent closed buffer 大小的page 是并行的加入到flush list, 也就是这一段的内容是允许有空洞的.

  5. log write ahead buffer 默认大小是 4k, 用于避免写入小于4k 大小数据的时候需要先将磁盘上的读取, 然后修改一部分的内容, 在写入回去.

主要的lsn

log.write_lsn

这个lsn 是到这个lsn 为止, 之前所有的data 已经从log buffer 写到log files了, 但是并没有保证这些log file 已经flush 到磁盘上了, 下面log.fushed_to_disk_lsn 指的才是已经flush 到磁盘的lsn 了.

这个值是由log writer thread 来更新

log.buf_ready_for_write_lsn

这个lsn 主要是由于redo log 引入的concurrent writes 才引进的, 也就是log recent written buffer. 也就是到了这个lsn 为止, 之前的log buffer 里面都不会有空洞,

这个值也是由 log writer thread 来更新

log.flushed_to_disk_lsn

到了这个lsn 为止, 所有的写入到redo log 的数据已经flush 到log files 上了

这个值是由log flusher thread 来更新

所以有 log.flushed_to_disk_lsn <= log.write_lsn <= log.buf_ready_for_write_lsn

log.sn

也就是不算上12字节的header, 4字节的checksum 以后的实际写入的字节数信息. 通常用这个log.sn 去换算获得当前的current_lsn

*current_lsn = log_get_lsn(log);
inline lsn_t log_get_lsn(const log_t &log) {
  return (log_translate_sn_to_lsn(log.sn.load()));
}
constexpr inline lsn_t log_translate_sn_to_lsn(lsn_t sn) {
  return (sn / LOG_BLOCK_DATA_SIZE * OS_FILE_LOG_BLOCK_SIZE +
          sn % LOG_BLOCK_DATA_SIZE + LOG_BLOCK_HDR_SIZE);
}

以下几个lsn 跟checkpoint 相关

log.buffer_dirty_pages_added_up_to_lsn

到这个lsn 为止, 所有的redo log 对应的dirty page 已经添加到buffer pool 的flush list 了.

这个值其实就是recent_closed.tail()

inline lsn_t log_buffer_dirty_pages_added_up_to_lsn(const log_t &log) { return (log.recent_closed.tail()); }

这个值由log closer thread 来更新

log.available_for_checkpoint_lsn

到这个lsn 为止, 所有的redo log 对应的dirty page 已经flush 到btree 上了, 因此这里我们flush 的时候并不是顺序的flush, 所以有可能存在有空洞的情况, 因此这个lsn 的位置并不是最大的redo log 已经被flush 到btree 的位置. 而是可以作为checkpoint 的最大的位置.

这个值是由log checkpointer thread 来更新

log.last_checkpoint_lsn

到这个lsn 为止, 所有的btree dirty page 已经flushed 到disk了, 并且这个lsn 值已经被更新到了ib_logfile0 这个文件去了.

这个lsn 也是下一次recovery 的时候开始的地方, 因为last_checkpoint_lsn 之前的redo log 已经保证都flush 到btree 中去了. 所以比这个lsn 小的redo log 文件已经可以删除了, 因为数据已经都flush 到btree data page 中去了.

这个值是由log checkpointer thread 来更新

所以log.last_checkpoint_lsn <= log.available_for_checkpoint_lsn <= log.buf_dirty_pages_added_up_to_lsn

为什么会有这么多的lsn?

主要还是由于写redo log 这个过程被拆开成了多个异步的流程.

先写入到log buffer, 然后由log writer 异步写入到 redo log, 然后再由log flusher 异步进行刷新.

中间在log writer 写入到 redo log 的时候, 引入了log recent written buffer 来提高concurrent 写入性能.

同时在把这个page 加入到flush list 的时候, 也一样是为了提高并发, 增加了recent_closed buffer.

redo log 模块后台thread

img

img

在启动的函数 Log_start_background_threads 的时候, 会把相应的线程启动

  os_thread_create(log_checkpointer_thread_key, log_checkpointer, &log);

  os_thread_create(log_closer_thread_key, log_closer, &log);

  os_thread_create(log_writer_thread_key, log_writer, &log);

  os_thread_create(log_flusher_thread_key, log_flusher, &log);

  os_thread_create(log_write_notifier_thread_key, log_write_notifier, &log);

  os_thread_create(log_flush_notifier_thread_key, log_flush_notifier, &log);

这里主要有

log_writer:

log_writer 这个线程等在writer_event 这个os_event上, 然后判断的是 log.write_lsn.load() < ready_lsn. 这个ready_lsn 是去扫一下log buffer, 判断是否有新的连续的内存了. 这个线程主要做的事情就是不断去检查 log buffer 里面是否有连续的已经写入数据的内存 buffer, 执行的函数是 log_writer_write_buffer()=>log_files_write_buffer()=>write_blocks()=>fil_redo_io() =>shard->do_redo_io()=>os_file_write() =>…=> pwrite(m_fh, m_buf, m_n, m_offset);

这里这个io 是同步, 非direct IO.

将这部分的数据内容刷到redolog 中去, 但是不执行fsync 命令, 具体执行fsync 命令的是log_flusher.

问题: 谁来唤醒Log_writer 这个线程?

正常情况下. srv_flush_log_at_trx_commit == 1 的时候是没有人去唤醒这个log_writer, 这个os_event_wait_for 是在pthread_cond_timedwait 上的, 这个时间为 srv_log_writer_timeout = 10 微秒.

这个线程被唤醒以后, 执行log_writer_write_buffer() 后, 在执行Log_files_write_buffer() 函数里面 执行 notify_about_advanced_write_lsn() 函数去唤醒write_notifier_event,

同时, 在执行完成 log_writer_write_buffer() 后. 会判断srv_flush_log_at_trx_commit == 1 就去唤醒 log.flusher_event

log_write_notifier:

log_write_notifer 是等待在 write_notifier_event 这个os_event上, 然后判断的是 log.write_lsn.load() >= lsn, lsn 是上一次的log.write_lsn. 也就是判断Log.write_lsn 有没有增加, 如果有增加就唤醒这个log_write_notifier, 然后log_write_notifier 就去唤醒那些等待在 log.write_events[slot] 的用户thread.

从上面可以看到, 由log_writer 执行os_event_set 唤醒

有哪些线程等待在log.write_events上呢?

都是用户的thread 最后会等待在Log.write_events上, 用户的线程调用log_write_up_to, 最后根据

srv_flush_log_at_trx_commit 这个变量来判断是执行

!=1 log_wait_for_write(log, end_lsn); 然后等待在log.write_events[slot] 上.

const auto wait_stats = ​ os_event_wait_for(log.write_events[slot], max_spins, ​ srv_log_wait_for_write_timeout, stop_condition);

=1 log_wait_for_flush(log, end_lsn); 等待在log.flush_events[slot] 上.

const auto wait_stats = ​ os_event_wait_for(log.flush_events[slot], max_spins, ​ srv_log_wait_for_flush_timeout, stop_condition);

log_flusher

log_flusher 是等待在 log.flusher_event 上,

从上面可以看到一般来说, 由log_writer 执行os_event_set 唤醒

如果是 srv_flush_log_at_trx_commit == 1 的场景, 也就是我们最常见的写了事务, 必须flush 到磁盘, 才能返回的场景. 然后判断的是 last_flush_lsn < log.write_lsn.load(), 也就是上一次last_flush_lsn 比当前的write_lsn, 如果比他小, 说明有新数据写入了, 那么就可以执行flush 操作了,

如果是 srv_flush_log_at_trx_commit != 1 的场景, 也就是写了事务不需要保证redolog 刷盘的场景, 那么执行的是

    os_event_wait_time_low(log.flusher_event,
                           flush_every_us - time_elapsed_us, 0);

也就是会定期的根据时间来唤醒, 然后执行 flusher 操作.

最后 执行完成flush 以后唤醒的是log.flush_notifier_event os_event_set(log.flush_notifier_event);

log_flush_notifier

和log_write_notifier 基本一样, 等待在 flush_notifier_event 上, 然后判断的是 log.flushed_to_disk_lsn.load() >= lsn, 这里lsn 是上一次的flushed_to_disk_lsn, 也就是判断flushed_to_disk_lsn 有没有增加, 如果有增加就唤醒等待在 flush_events[slot] 上面的用户线程, 跟上面一样, 也是用户线程最后会等待在flush_events 上

从上面可以看到, 有log_flusher 唤醒它

log_closer

log_closer 这个线程是在后台不断的去清理recent_closed 的线程, 在mtr/mtr0mtr.cc:execute() 也就是mtr commit 的时候, 会把这个mtr 修改的内容对应start_lsn, end_lsn 的内容添加到recent_closed buffer 里面, 并且在添加到recent_closed buffer 之前, 也会把相应的page 都挂到buffer pool 的flush list 里面.

和其他线程不一样的地方在于, Log_closer 并没有wait 在一个条件变量上, 只是每隔1s 的轮询而已.

而在这1s 一次的轮询里面, 一直执行的操作是 log_advance_dirty_pages_added_up_to_lsn() 这个函数类似recent_writtern 里面的 log_advance_ready_for_write_lsn(), 去这个recent_close 里面的Link_buf 里面

  /*
   * 从recent_closed.m_tail 一直往下找, 只要有连续的就串到一起, 直到
   * 找到有空洞的为止
   * 只要找到数据, 就更新m_tail 到最新的位置, 然后返回true
   * 一条数据都没有返回false
   * 注意: 在advance_tail_until 操作里面, 本身同时会进行的操作就是回收之前的空间
   * 所以执行完advance_tail_until 以后, 连续的内存就会被释放出来了
   * 下面还有validate_no_links 函数进行检查是否释放正确
   */

这样一直清理着recent_closed buffer, 就可以保证recent_closed buffer 一直是有空间的

log_closer thread 会一直更新着这个 log_advance_dirty_pages_added_up_to_lsn(), 这个函数里面就是一直去更新recent_close buffer 里面的 log_buffer_dirty_pages_added_up_to_lsn(), 然后在做check pointer 的时候, 会一直去检查这个log_buffer_dirty_pages_added_up_to_lsn(), 可以做check point 的lsn 必须小于这个log_buffer_dirty_pages_added_up_to_lsn(), 因为 log_buffer_dirty_pages_added_up_to_lsn 表示的是 recent close buffer 里面的其实位置, 在这个位置之前的Lsn 都已经被填满, 是连续的了, 在这个位置之后的lsn 没有这个保证.

那么是谁负责更新recent_closed 这个数组呢? log_closed thread

什么时候把dirty page 加入到buffer pool 的 flush list 上?

在mtr->commit() 的时候, 就会把这个mtr 修改过的page 都加到flush list 上, 在添加到flush list 上之前, 我们会保证写入到redo log, 并且这个redo log 已经flush 了.

log_checkpointer

这个线程等待在 log.checkpointer_event 上, 然后判断的是10*1000, 也就是10s 的时间,

os_event_wait_time_low(log.checkpointer_event, 10 * 1000, sig_count);

os_event_wait_time_low 是等待checkpointer_event 被唤醒, 或者超时时间10s 到了, 其实就是pthread_cond_timedwait()

正常情况下都是等10s 然后log_checkpointer 被唤醒, 那么被通知到checkpointer_event 被唤醒的场景在哪里呢?

其实也是在 log_writer_write_buffer() 函数里面, 先判断

while(1) {
	const lsn_t lsn_diff = min_next_lsn - checkpoint_lsn;

	if (lsn_diff <= log.lsn_capacity) {
  	checkpoint_limited_lsn = checkpoint_lsn + log.lsn_capacity;
  	break;
	}
	log_request_checkpoint(log, false);
  ...
}
// 为什么需要在log_writer 的过程加入这个逻辑, 这个逻辑是判断lsn_diff(当前这次要写入的数据的大小) 是否超过了log.lsn_capacity(redolog 的剩余容量大小), 如果比它小, 那么就可以直接进行写入操作, 就break 出去, 如果比它大, 那么说明如果这次写入写下去的话, 因为redolog 是rotate 形式的, 会把当前的redolog 给写坏, 所以必须先进行一次checkpoint, 把一部分的redolog 中的内容flush 到btree data中, 然后把这个checkpoint 点增加, 腾出空间.
// 所以我们看到如果checkpoint 做的不够及时, 会导致redolog 空间不够, 然后直接影响到线上的写入线程.

首先我们必须知道一个问题是, 一次transaction 修改的page 什么时候flush 下去, 我们是不知道的. 因为用户只需要写入到redo log, 并且确认redo log 已经flush 了以后, 就直接返回了. 至于什么时候从Buffer pool flush 到btree data, 这个是后台异步的, 用户也不关注的. 但是我们打checkpoint 以后, 在checkpoint 之前的redo log 应该是都可以删除的, 因此我们必须保证打的checkpoint lsn 的这个点之前的redo log 已经将对应的page flush到磁盘上了,

那么这里的问题就是如何确定这个checkpoint lsn 点?

在函数 log_update_available_for_checkpoint_lsn(log); 里面更新 log.available_for_checkpoint_lsn

具体的更新过程:

然后在log_request_checkpoint里面执行 log_update_available_for_checkpoint_lsn(log) =>

const lsn_t oldest_lsn = log_get_available_for_checkpoint_lsn(log);

然后执行 lsn_t lwn_lsn = buf_pool_get_oldest_modification_lwm() =>

buf_pool_get_oldest_modification_approx()

这里buf_pool_get_oldest_modification_approx() 指的是获得大概的最老的lsn 的位置, 这里是引入了recent_closed buffer 带来的一个问题, 因为引入了 recent_closed buffer 以后, 从redo log 上面的page 添加到buffer pool 的flush list 是不能保证有序的, 有可能一个flush list 上面存在的是 98 => 85 => 110 这样的情况. 因此这个函数只能获得大概的oldest_modification lsn

具体的做法就是遍历所有的buffer pool 的flush list, 然后只需要取出flush list 里面的最后一个元素(虽然因为引入了recent_closed 不能保证是最老的 lsn), 也就是最老的lsn, 然后对比8个flush_list, 最老的lsn 就是目前大概的lsn 了

然后在buf_pool_get_oldest_modification_lwm() 还是里面, 会将buf_pool_get_oldest_modification_approx() 获得的 lsn 减去recent_closed buffer 的大小, 这样得到的lsn 可以确保是可以打checkpoint 的, 但是这个lsn 不能保证是最大的可以打checkpoint 的lsn. 而且这个 lsn 不一定是指向一个记录的开始, 更多的时候是指向一个记录的中间, 因为这里会强行减去一个 recent_closed buffer 的size. 而以前在5.6 版本是能够保证这个lsn 是默认一个redo log 的record 的开始位置

最后通过 log_consider_checkpoint(log); 来确定这次是否要写这个checkpointer 信息

然后在 log_should_checkpoint() 具体的有3个条件来判断是否要做 checkpointer

最后决定要做的时候通过 log_checkpoint(log); 来写入checkpointer 的信息

在log_checkpoint() 函数里面

通过 log_determine_checkpoint_lsn() 来判断这次checkpointer 是要写入dict_lsn, 还是要写入available_for_checkpoint_lsn. 在 dict_lsn 指的是上一次DDL 相关的操作, 到dict_lsn 为止所有的metadata 相关的都已经写入到磁盘了, 这里为什么要把DDL 相关的操作和非 DDL 相关的操作分开呢?

最后通过 log_files_write_checkpoint 把checkpoint 信息写入到ib_logfile0 文件中

MySQL · 源码分析 · 8.0 Functional index的实现过程

$
0
0

MySQL从8.0.13开始支持functional index。Functional index类似于ORACLE的Function-Based Indexes。该索引可以根据将索引定义的表达式的值按照索引顺序存到索引里,进而减少表达式的计算,加速查询。

下面我们看一下如何创建一个functional index:

CREATE TABLE t1 (col1 INT, col2 INT, INDEX func_index ((ABS(col1))));
CREATE INDEX idx1 ON t1 ((col1 + col2));
CREATE INDEX idx2 ON t1 ((col1 + col2), (col1 - col2), col1);
ALTER TABLE t1 ADD INDEX ((col1 * 40) DESC);

接下来我们继续看一下functional index的效果:

mysql> CREATE TABLE t1 (col1 INT, col2 INT);
Query OK, 0 rows affected (0.13 sec)

mysql> SELECT * FROM t1 WHERE col1+col2 > 10;
Empty set (0.01 sec)

mysql> EXPLAIN SELECT * FROM t1 WHERE col1+col2 > 10;
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | t1    | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    1 |   100.00 | Using where |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.01 sec)

mysql> CREATE INDEX idx1 ON t1 ((col1 + col2));
Query OK, 0 rows affected (0.14 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> EXPLAIN SELECT * FROM t1 WHERE col1+col2 > 10;
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type  | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | t1    | NULL       | range | idx1          | idx1 | 9       | NULL |    1 |   100.00 | Using where |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

从上面的例子中我们可以看到查询中使用了functional索引 idx1来加速查询。

MySQL的functinal index是利用generated column来辅助实现的,后面的章节中我们会详细的进行分析。所以对于创建functional index的一些限制可以参考:创建generated column以及增加generated column

下面我们从源码来看一下MySQL functional index的实现过程。

create_index流程

上面的流程图是MySQL创建functional index的一个基本流程。我们重点看一下add_functional_index_to_create_list这个函数的处理过程。

/**
  Prepares a functional index by adding a hidden indexed generated column for the key part.

  A functional index is implemented as a hidden generated column over the
  expression specified in the index, and the hidden generated column is then indexed. This function adds a hidden generated column to the Create_list, and updates the key specification to point to this new column. The generated column is given a name that is a hash of the key name and the key part number.
*/
static bool add_functional_index_to_create_list(THD *thd,
                        Key_spec *key_spec,
                        Alter_info *alter_info,
                        Key_part_spec *kp,
                        uint key_part_number,
                        HA_CREATE_INFO *create_info) {
  // A functional index cannot be a primary key
  /* 这里限制了functional index 不能作为主键,因为它是个generated column */
  if (key_spec->type == KEYTYPE_PRIMARY) {
    my_error(ER_FUNCTIONAL_INDEX_PRIMARY_KEY, MYF(0));
    return true;
  }

  // If the key isn't given a name explicitly by the user, we must auto-generate
  // a name here. "Normal" indexes will be given a name in prepare_key(), but
  // that is too late for functional indexes since we want the hidden generated
  // column name to be based on the index name.
  // 生成一个默认的索引名称
  if (key_spec->name.str == nullptr) {
    std::string key_name;
    int count = 2;
    key_name.assign("functional_index");
    while (key_name_exists(alter_info->key_list, key_name, nullptr)) {
      key_name.assign("functional_index_");
      key_name.append(std::to_string(count++));
    }

    key_spec->name.length = key_name.size();
    key_spec->name.str = strmake_root(thd->stmt_arena->mem_root,
                                      key_name.c_str(), key_name.size());
  } else {    if (key_name_exists(alter_info->key_list,
                        {key_spec->name.str, key_spec->name.length},
                        key_spec)) {
      my_error(ER_DUP_KEYNAME, MYF(0), key_spec->name.str);
      return true;
    }
  }

  // First we need to resolve the expression in the functional index so that we
  // know the correct collation, data type, length etc...
  ulong saved_privilege = thd->want_privilege;
  thd->want_privilege = SELECT_ACL;

  {
    // Create a scope guard so that we are guaranteed that the privileges are
    // set back to the original value.
    auto handler_guard = create_scope_guard(
        [thd, saved_privilege]() { thd->want_privilege = saved_privilege; });

    Functional_index_error_handler error_handler(
        {key_spec->name.str, key_spec->name.length}, thd);

    Item *expr = kp->get_expression();
    if (expr->type() == Item::FIELD_ITEM) {
      my_error(ER_FUNCTIONAL_INDEX_ON_FIELD, MYF(0));
      return true;
    }
    // 这里验证表达式的合法性,是否违反generated column的约束条件
    if (pre_validate_value_generator_expr(kp->get_expression(),
                                          key_spec->name.str, true)) {
      return true;
    }

    Replace_field_processor_arg replace_field_argument(
        thd, &alter_info->create_list, create_info, key_spec->name.str);
    if (expr->walk(&Item::replace_field_processor, Item::WALK_PREFIX,
                   reinterpret_cast<uchar *>(&replace_field_argument))) {
      return true;
    }

    if (kp->resolve_expression(thd)) return true;
  }

  // 默认隐式列生成一个名字
  const char *field_name = make_functional_index_column_name(
      {key_spec->name.str, key_spec->name.length}, key_part_number,
      thd->stmt_arena->mem_root);

  Item *item = kp->get_expression();

  // Ensure that we aren't trying to index a field
  DBUG_ASSERT(item->type() != Item::FIELD_ITEM);  TABLE tmp_table;
  TABLE_SHARE share;
  tmp_table.s = &share;
  init_tmp_table_share(thd, &share, "", 0, "", "", nullptr);

  tmp_table.s->db_create_options = 0;
  tmp_table.s->db_low_byte_first = false;
  tmp_table.set_not_started();
  // 生成generated column的创建信息
  Create_field *cr = generate_create_field(thd, item, &tmp_table);
  if (cr == nullptr) {
    return true; /* purecov: deadcode */
  }

  if (is_blob(cr->sql_type)) {
    my_error(ER_FUNCTIONAL_INDEX_ON_LOB, MYF(0));
    return true;
  }

  cr->field_name = field_name;
  cr->field = nullptr;
  cr->hidden = dd::Column::enum_hidden_type::HT_HIDDEN_SQL;
  cr->stored_in_db = false;

  Value_generator *gcol_info = new (thd->mem_root) Value_generator();
  gcol_info->expr_item = kp->get_expression();
  // 生成一个virtual generated column
  gcol_info->set_field_stored(false);
  gcol_info->set_field_type(cr->sql_type);  cr->gcol_info = gcol_info;
  alter_info->create_list.push_back(cr);
  alter_info->flags |= Alter_info::ALTER_ADD_COLUMN;

  // 这里将KEY的索引列设置为隐式generated column
  kp->set_name_and_prefix_length(field_name, 0);
  return false;
}

函数的注释里面说的非常详细,functional index的创建过程依赖于generated column来做辅助。创建functional index的时候都要隐式的创建一个generated column,然后在该generated column上创建对应的索引。

上面我们看到了源码中是如何创建一个functional index。那么接下来我们继续看一下MySQL是如何为查询寻找合适的functional index的。

就拿上面的例子看一下调用堆栈:

EXPLAIN SELECT * FROM t1 WHERE col1+col2 > 10;

#0  substitute_gc (thd=0x2aab94000be0, select_lex=0x2aab94270298, where_cond=0x2aab94271ec8, group_list=0x0, order=0x0) 
#1  0x0000000003049283 in JOIN::optimize (this=0x2aab94272750) 
#2  0x0000000003165c32 in SELECT_LEX::optimize (this=0x2aab94270298, thd=0x2aab94000be0) 
#3  0x000000000316221c in Sql_cmd_dml::execute_inner (this=0x2aab94272078, thd=0x2aab94000be0) 
#4  0x00000000031614d3 in Sql_cmd_dml::execute (this=0x2aab94272078, thd=0x2aab94000be0) 
#5  0x00000000030a7396 in mysql_execute_command (thd=0x2aab94000be0, first_level=true) 
#6  0x00000000030ac74b in mysql_parse (thd=0x2aab94000be0, parser_state=0x2aab8c2462d0, force_primary_storage_engine=false)
#7  0x0000000003095b0d in dispatch_command (thd=0x2aab94000be0, com_data=0x2aab8c246c40, command=COM_QUERY) 
#8  0x0000000003091d7d in do_command (thd=0x2aab94000be0) 
#9  0x00000000033d145b in handle_connection (arg=0xcb9cee0) 
#10 0x00000000066cd007 in pfs_spawn_thread (arg=0xca3bde0) 
#11 0x00002aaaaacd4aa1 in start_thread () from /lib64/libpthread.so.0
#12 0x00002aaaabfb993d in clone () from /lib64/libc.so.6

上面的堆栈可以看到优化器调用了substitute_gc这个函数,这个函数就可以将WHERE,GROUP_BY 以及ORDER BY中的相关表达式替换为隐式的generated column,进而可以让优化器来选择functional index。我们再来研究一下substitute_gc这个函数的源码。

bool substitute_gc(THD *thd, SELECT_LEX *select_lex, Item *where_cond,
                   ORDER *group_list, ORDER *order) {
  List<Field> indexed_gc;
  Opt_trace_context *const trace = &thd->opt_trace;
  Opt_trace_object trace_wrapper(trace);
  Opt_trace_object subst_gc(trace, "substitute_generated_columns");

  // Collect all GCs that are a part of a key
  // 这里要遍历所有的表来收集所有可以被替换的generated columns。后面的代码中会分析哪些表达式可以被替换
  for (TABLE_LIST *tl = select_lex->leaf_tables; tl; tl = tl->next_leaf) {
    if (tl->table->s->keys == 0) continue;
    for (uint i = 0; i < tl->table->s->fields; i++) {
      Field *fld = tl->table->field[i];
      // 这里判断只有在索引中的列并且generated column可以用来替换表达式才会作为候选的列。
      if (fld->is_gcol() &&
          !(fld->part_of_key.is_clear_all() &&
            fld->part_of_prefixkey.is_clear_all()) &&
          fld->gcol_info->expr_item->can_be_substituted_for_gc()) {
        // Don't check allowed keys here as conditions/group/order use
        // different keymaps for that.
        indexed_gc.push_back(fld);
      }
    }
  }  // No GC in the tables used in the query
  if (indexed_gc.elements == 0) return false;

  if (where_cond) {
    // Item_func::compile will dereference this pointer, provide valid value.
    uchar i, *dummy = &i;
    /**
      这里会利用generated column来替换where_cond里面对应的表达式。
      
      Item::gc_subst_analyzer 该虚函数定义了每一种Item是否需要进行generated column的替换过程
      Item::gc_subst_transformer 该函数定义了每一种可替换的Item如何利用generated column进行替换
    */
    where_cond->compile(&Item::gc_subst_analyzer, &dummy,
                        &Item::gc_subst_transformer, 
                        (uchar *)&indexed_gc);
    subst_gc.add("resulting_condition", where_cond);
  }

  if (!(group_list || order)) return false;
  // Filter out GCs that do not have index usable for GROUP/ORDER
  Field *gc;
  List_iterator<Field> li(indexed_gc);

  while ((gc = li++)) {
    Key_map tkm = gc->part_of_key;
    // 这里判断generated column相关的索引是否与group-by 或者 order-by的列有交集,如果没有相关性,就忽略。
    tkm.intersect(group_list ? gc->table->keys_in_use_for_group_by
                             : gc->table->keys_in_use_for_order_by);
    if (tkm.is_clear_all()) li.remove();
  }
  if (!indexed_gc.elements) return false;

  // Index could be used for ORDER only if there is no GROUP
  ORDER *list = group_list ? group_list : order;
  bool changed = false;
  for (ORDER *ord = list; ord; ord = ord->next) {  
    li.rewind();
    // 这里判断group-by或者order-by的列是否是表达式或者函数来进行generated column替换。
    if (!(*ord->item)->can_be_substituted_for_gc()) continue;
    while ((gc = li++)) {
      Item_func *tmp = pointer_cast<Item_func *>(*ord->item);
      Item_field *field;
      // 这里会根据表达式与generated column->gcol_info->expr_item进行比较来获取匹配的generated column
      if ((field = get_gc_for_expr(&tmp, gc, gc->result_type()))) {
        changed = true;
        /* Add new field to field list. */
        ord->item = select_lex->add_hidden_item(field);
        break;
      }
    }
  }
  if (changed && trace->is_started()) {
    String str;
    SELECT_LEX::print_order(
        &str, list,
        enum_query_type(QT_TO_SYSTEM_CHARSET | QT_SHOW_SELECT_NUMBER |
                        QT_NO_DEFAULT_DB));
    subst_gc.add_utf8(group_list ? "resulting_GROUP_BY" : "resulting_ORDER_BY",
                      str.ptr(), str.length());
  }
  return changed;
}

综上所述,本篇文章主要从源码层面对MySQL 8.0 实现的Functional index进行了一下简要的分析。Functional index主要依赖于generated column,利用内部隐式的创建一个generated column来辅助创建functional index。代码层面也比较容易理解,希望该篇文章能够帮助广大读者了解MySQL functional index的实现原理。

PgSQL · 源码解析 · Json — 从使用到源码

$
0
0

PostgreSQL从9.2开始支持Json类型,把它当成标准类型一种,渐渐地提供了12个SQL函数。这篇文章先简单介绍一下Json,然后对于12个函数每一个给出一个执行的例子,最后根据一条SQL语句,从源码角度分析如何执行的。源码那部分跟着代码看效果可能会好很多。

Json 简介

JSON用于描述资料结构,有以下形式存在。

  • 物件(object):一个物件以「{」开始,并以「}」结束。一个物件包含一系列非排序的名称/值对,每个名称/值对之间使用「,」分割。
  • 名称/值(collection):名称和值之间使用「:」隔开,一般的形式是:{name:value}一个名称是一个字符串; 一个值可以是一个字符串,一个数值,一个物件,一个布尔值,一个有序列表,或者一个null值。
  • 值的有序列表(Array):一个或者多个值用「,」分割后,使用「[」,「]」括起来就形成了这样的列表,形如:[collection, collection]
  • 字符串:以”“括起来的一串字符。
  • 数值:一系列0-9的数字组合,可以为负数或者小数。还可以用「e」或者「E」表示为指数形式。
  • 布尔值:表示为true或者false。在很多语言中它被解释为阵列。

一个Json数据类型的例子:

{
  "jobname":"linux_os_vmstat",
    "schedule":{
      "type":{"interval":
        "5m"
      },
      "start":"now",
      "end":"None"
    },
    "values":{
      "event":["cpu_r","cpu_w"],
      "data":["cpu_r"],
      "threshold":[1,1]
    },
    "objects":{
      "wintest1":"cpu"
    }
}

二  PostgreSQL 中的Json

在PostgreSQL 9.2中,增加了Json数据类型和与Json类型相关的两个函数(row_to_json 和array_to_json)。我们可以在PG中像其它类型一样存取Json类型的数据,也可以在数据库中把数据转化为Json数据格式输出。PG中提供几种操作符操纵Json数据,并且在之后的几个版本中,增加了Json相关的函数。

2.1   操作符

操作符 右操作数类型 描述 例子
-> int 得到Json数组的元素 '[1,2,3]'::json->2
-> text 得到Json对象的域值 '{"a":1,"b":2}'::json->'b'
->> int 得到Json数组的元素(text格式输出) '[1,2,3]'::json->>2
->> text 得到Json对象的域值(text格式输出) '{"a":1,"b":2}'::json->>'b'
#> array of text 得到指定位置的Json对象 '{"a":[1,2,3],"b":[4,5,6]}'::json#>'{a,2}'
#>> array of text 得到指定位置的Json对象(text格式输出) '{"a":[1,2,3],"b":[4,5,6]}'::json#>>'{a,2}'

这是官方文档中的表格,表格中以text格式输出意思是只输出需要的值,而不关心类型。由第一节知道,Json除了Object和Array之外,合法的值有string,number,bool和null。拿string来说,合法的是加双引号,text类型就只有里面的值。在实际使用中输出结果与数据库编码有关,通常使用的是UTF-8类型和ASCII码。混合编码或者其他类型可能导致错误。具体使用在下节例子中会感受的到。

2.2   函数

postgreSQL 9.3.6目前支持12个与Json相关的函数操作,可以将这些函数分为两类,一类不操纵Json类型数据,只是提供一个其他类型数据向Json转化的接口(如row_to_json)。另一类就是对Json操作的函数,快速获得其中某些特性(如 json_object_key)。下面就对每一个函数给出一个使用的例子。所有的操作都是基于两张表。一张表job表头(id : int  jobdesc : json),其中一行数据如Json简介一节中的示例,用到其它行数据会指明。另一张表films和数据如下:

code  |   title   | did | date_prod  |  kind  |   len
-------+-----------+-----+------------+--------+----------
UA502 | Bananas   | 105 | 1971-07-13 | Comedy | 01:22:00
UA123 | Apples    | 110 | 1999-09-09 | Comedy | 01:44:00
CN111 | Onec More | 111 | 1909-08-11 | Active | 01:54:00
  1. array_to_json(anyarray [, pretty_bool]) 把数组转化成Json类型数据。第一个参数是一个数组,第二个bool类型的表示数组中的元素会不会分行显示。

    先用array_agg函数生成数组:

    bank=# select array_to_json(array_agg(t)) from (select code,title from films) t;
    
    {"(UA502,Bananas)","(UA123,Apples)","(CN111,\"Onec More\")"}
    

    然后再将数组转化为Json:

    bank=# select array_to_json(array_agg(t)) from (select code,title from films) t;
      
    [{"code":"UA502","title":"Bananas"},{"code":"UA123","title":"Apples"},{"code":"CN111","title":"Onec More"}]
    

    第二个参数默认为false,如果为true:

    bank=# select array_to_json(array_agg(t),true) from (select code,title from films) t;
    
    [{"code":"UA502","title":"Bananas"},
    {"code":"UA123","title":"Apples"},
    {"code":"CN111","title":"Onec More"}]
    
  2. row_to_json(record [, pretty_bool]) 关于row_to_json的妙用可参看这里

    select row_to_json(t) from (select code,title from films) t;
       
    {"code":"UA502","title":"Bananas"}
    {"code":"UA123","title":"Apples"}
    {"code":"CN111","title":"Onec More"}
    
  3. to_json(anyelement) 其它格式转化为Json

    bank=# select to_json(t) from (select code,title from films) t;
       
    {"code":"UA502","title":"Bananas"}
    {"code":"UA123","title":"Apples"}
    {"code":"CN111","title":"Onec More"}
    
  4. json_array_length(json)

    bank=# select json_array_length(array_to_json(array_agg(t),true)) from (select code,title from films) t;
       
    3
    
  5. json_each(json) 把一个Json 最外层的Object拆成key-value的形式

    bank=# select json_each(to_json(t)) from (select code,title from films where code = 'UA502') t;
       
    (code,"""UA502""")
    (title,"""Bananas""")
    

    以这种方式使用,value会多出两个双引号,但是像下面这种方式使用就不会,原因还不太明白。

    bank=# select * from json_each( (select jobdesc from job where jobdesc->>'jobname' = 'linux_os_vmstat') );
    
      key    |              value
      ----------+----------------------------------
      jobname  | "linux_os_vmstat"
      schedule | {
            |       "type":{"interval":
            |           "5m"
            |       },
            |       "start":"now",
            |       "end":"None"
            |   }
      values   | {
            |       "event":["cpu_r","cpu_w"],
            |       "data":["cpu_r"],
            |       "threshold":[1,1]
            |   }
      objects  | {
            |       "wintest1":"cpu"
            |   }
    
  6. json_each_text(from_json json) 只是输出格式为text

    bank=# select json_each_text(to_json(t)) from (select code,title from films where code = 'UA502') t;
       
      (code,UA502)
    (title,Bananas)
    
  7. json_extract_path(from_json json, VARIADIC path_elems text[]) 根据第二个参数提供的路径确定Json中返回的对象。

    bank=# select json_extract_path(jobdesc,'objects','wintest1') from job where jobdesc->>'jobname' = 'linux_os_vmstat';
    
    "cpu"
  8. json_extract_path_text(from_json json, VARIADIC path_elems text[])

    bank=# select json_extract_path_text(jobdesc,'objects','wintest1') from job where jobdesc->>'jobname' = 'linux_os_vmstat';
    
    cpu
    
  9. json_object_keys(json) 获得最外层 object的key

    bank=# select json_object_keys(jobdesc) from job where jobdesc->>'jobname' = 'linux_os_vmstat';
    
    jobname
    schedule
    values
    objects
    
  10. json_populate_record(base anyelement, from_json json, [, use_json_as_text bool=false] 这个函数较复杂,作用是按照第一个参数定义的数据类型,把第二个参数的Json数据按照这种类型转换输出,第三个参数表示输出为Json类型的话是不是text类型输出。而且这个函数不能处理嵌套的object数据。也就是说key下面value就必须是待转化的值了。一次只能处理一行数据,觉得这个函数在以后版本还有待完善。

首先要定义下类型:

   bank=# create type JJ as (jobname text,school text);

本次操作的数据为 {“jobname”:”cs”,”school”:”csu”}

   bank=# select* from json_populate_record(null::JJ,(select jobdesc from job where jobdesc->>'jobname' = 'cs'));


   jobname | school
   ---------+--------
   cs      | csu

11. json_populate_recordset(base anyelement, from_json json, [, use_json_as_text bool=false] 和上一个函数不同之处就在于一次可以处理多行数据。

要处理的数据为:{“jobname”:”cs”,”school”:”csu”} 和 {“jobname”:100,”school”:”csu”}

   bank=# select* from json_populate_recordset(null::JJ,(select json_agg(jobdesc) from job where jobdesc->>'school' = 'csu'));

   jobname | school
   ---------+--------
   cs      | csu
   100     | csu

12. json_array_elements(json) 把一个Json数组的每一个元素取出来。

   bank=# select * from json_array_elements( (select jobdesc->'values'->'event' from job where jobdesc->>'jobname' = 'linux_os_vmstat') );
   "cpu_r""cpu_w"

三 一条查询语句

1. 先谈插入

当Json作为一种标准的变长数据类型,进入到内存之后实际上是转化为变长的text类型。存到磁盘上和其他变长数据类型一样,当数据大于2k的时候就会出发toast机制,先对数据试着进行压缩,压缩之后还是大于2kb,就线外存储,放到另一张表里去。

struct varlena
{
  char    vl_len_[4];   /* Do not touch this field directly! */
  char    vl_dat[1];
};

这就是变长的存储结构,第一个变量是数据的长度(其实也不是长度,而是长度经过运算处理的结果),第二个变量是数据,只用一个大小为1的char数组表示数据的起始位置。对于变长数据有一系列的宏操作。因此新加入的Json类型操作在内存中都是与text类型进行交互。源码的重点也在于如何解析字符串。

2.执行一次查询

用一个查询语句的执行过程,来分析在源码中是如何处理Json类型数据的。使用job表完成:

select jobdesc->'jobname' from job where jobdesc->>'school' = 'csu';

首先要在缓存中找到job表的元组数据,如果缓存没有就到文件块中取。然后扫描每一个元组数据,得到一个元组数据后就需要调用Json提供的接口对数据进行分析,判断是否有一个名为‘school’的object的域值为‘csu’。如果是则返回名为‘jobname’的object域值给调用端,不是的话就返回空的结果。Json接口只负责返回名为’school ’的object域值,判断域值是否满足条件也由调用端决定。

取出的一个元组数据text类型,需要转化为Json词法分析上下文这种数据结构:

typedef struct JsonLexContext
{
  char  * input;             // 输入的待解析的json字符串,
  int input_length;          //字符串的长度
  char  * token_start;           //每次分析起始位置      (蓝色)
  char  * token_terminator;    //每次分析的结束位置    (蓝色)
  char  * prev_token_terminator; //上次分析的结束为止    (蓝色)
  JsonTokenType token_type;          //分析的字串类型
  int lex_level;     //分析的“深度”        (蓝色)
  int line_number;         //当前分析到的行数      (红色)
  char  * line_start;    //当前行的起始位置      (红色)
  StringInfo  strval;            //分析得到的结果
} JsonLexContext;

这个结构就像游标一样,遍历一个Json类型数据,对其中每一个object进行解析,得到值再与where子句后面的条件比较,确定是否满足条件。具体是通过makeJsonLexContext这个函数完成由text到JsonLexContext的转化,input指向text转化而来数据的起始位置,表中蓝色为控制变量,在分析过程中不断变化,表示分析过程的状态。红色是为了出错时可以定位到具体的位置。strval为每一次分析得到的临时结果。

除了初始化词法分析上下文,还要初始化最终的保存结果的结构,不同的操作可能对应不同的结构结构(如-> 和 其它SQL函数调用),本例中使用GetState:

typedef struct GetState
{
  JsonLexContext *lex;    //词法解析上下文
  JsonSearch  search_type;  //搜索类型:object array path
  int search_index;         //搜索索引
  int array_index;    //数组索引
  char  *search_term;           //由SQL语句传入的搜索条件的值
  char  *result_start;          //结果起始位置指针(和lexcontext的
  //token_terminator一起得到tresult)
  text  *tresult;   //最终结果
  bool  result_is_null;         //结果是否为空
  bool  normalize_results;  //是否是text类型 (由解引操作符得到 如  ->  和 ->>)
  bool  next_scalar; //函数get_scalar是否可以得到tresult结果(字符串 数值 布尔值)
  char  path;           //路径
  int npath;            //路径数量
  char  current_path;         //当前分析到的路径指针
  bool  *pathok;    //用bool类型的数组判断走过的路径是否每一步都正确
  int *array_level_index; //数组分析深度的指针
  int *path_level_index;  //路径分析深度的指针
}GetState;

初始化后search_term的值为‘school’,用来进行最后的判断。需要解释下的就是path,请参考上一节函数示例7和8 。path记录的就是函数中的路径。

解析需要判断是否符合条件,并将符合条件的值存近tresult中,这些工作由JsonSemAction完成:

typedef struct JsonSemAction
{
  void     *semstate;
  json_struct_action object_start;
  json_struct_action object_end;
  json_struct_action array_start;
  json_struct_action array_end;
  json_ofield_action object_field_start;
  json_ofield_action object_field_end;
  json_aelem_action array_element_start;
  json_aelem_action array_element_end;
  json_scalar_action scalar;
} JsonSemAction;

都是一些函数指针,不同类型的函数类型也不一样。semstate是GetState的指针,当然如果是其它类型的state就是其它类型state的指针。

{
  "jobname":"linux_os_vmstat",   …
}

例: 假如拿到的第一行元组数据为简介中所示数据,调用json_lex函数吃掉第一个”{  ”符号,表明这是一个object,token_type初始化为JSON_TOKEN_OBJECT_START , 再调用parse_object函数,继续推进吃掉“ “ ”号,知道接下来是一个字符串,调用json_lex_string,把值jobname读到strval里面,同时token_type变为JSON_TOKEN_STRING。此时该分析一个object的值域,调用函数parse_object_field,在这个函数中,首先把存在strval里面的值拿出来,再吃掉“:”,之后根据JsonSemAction找到对应类型的处理函数,此处对应的是get_object_field_start,这是函数主要是判断本次解析是否符合条件(也就是strval中的值是否是’school ’), 根据下一个符号的类型判断是否需要递归。因为一个object的值域可以是一个object,一个array或者一个简单的值,每一种有对应的函数。本例中下一个字符是““ ”,还是string,调用函数parse_scalar,推进JsonLexContext到字符串末尾。最后调用JsonSemAchtion中的get_object_field_end,在这里判断是否符合条件,如果符合就把值域写到tresult中。这也就是一个符合条件的返回结果。如果不符合就继续解析。

当根据where子句的条件找到一个元组变量的时候,就使用select中的条件得到元组变量中对应的值域,解析方式都是相同的。当然不会找到一个就停止,要返回所有满足条件的值,就需要遍历所有的元组。

3. 其它

总的来讲解析用JsonLexContext当作游标,不同类型的state当作结果集变量,JsonSemAction判断是否正确。但并不是所有的操作都是这样,当调用Json 支持的SQL函数的时候,不同的函数都有不同的处理方式,比如row_to_json,得到一行元组变量和它的类型给数据加上{}或者[]等,变成有效的Json格式数据就ok。

参考:

http://www.ietf.org/rfc/rfc4627.txt

http://www.postgresql.org/docs/9.3/static/functions-json.html#FUNCTIONS-JSON-OP-TABLE

http://hashrocket.com/blog/posts/faster-json-generation-with-postgresql

http://www.linuxidc.com/Linux/2013-12/94354.htm

MySQL · 最佳实践 · 如何使用C++实现 MySQL 用户定义函数

$
0
0

什么是用户定义函数(UDF, User-Defined Functions)

在MySQL中,可以通过UDF扩充MySQL的功能,加入一个新的SQL函数类似于内置的函数(如,ABS() or CONCAT()等。UDF使用C/C++实现, 编译成动态库文件(Linux对应.so文件),可以使用 CREATE FUNCTION动态加载到 mysqld服务进程里,使用DROP FUNCTION从mysqld 服务进程里移除。本文在MySQL 8.0上首先对MySQL UDF的接口进行了介绍,然后给出了一个简单的例子。 通过使用 UDF:

  • 可以返回 string, integer 或 real 类型的值或作为函数参数
  • 能够定义简单函数或聚集函数(aggregate function),本文只讲解简单函数,聚集函数请参考MySQL用户手册

如何实现MySQL UDF

MySQL UDF必须使用C/C++实现,同时要求操作系统必须支持动态装载,如果使用了mysqld中已经存在的符号, 那么链接动态库的时候必须得使用链接选项 -rdynamic。

UDF接口

为了定义UDF,需要为每个UDF生成对应C/C++函数,为了下文描述方便,我们用“xxx”表示函数名,用大写的XXX()表示一个SQL 函数调用,用小写的xxx()表示一个C/C++函数。下面是实现一个 SQL 函数XXX()所需要定义的C/C++函数。

xxx()

主函数,在SQL调用函数XXX()时最终会调用到这里,SQL的数据类型和C/C++的数据类型对应关系如下:

SQL类型C/C++ 类型
STRINGchar *
INTEGERlong long
REALdouble

这些数据类型用于函数的返回值和函数参数。

函数定义如下:

  • 对于SQL函数的返回值是STRING的 (这个函数原型同样适用于SQL函数返回类型是DECIMAL)
char * xxx(UDF_INIT *initid, UDF_ARGS *args,
   char *result, unsigned long *length,
   char *is_null, char *error);
  • 对于返回值是INTEGER的
long long xxx(UDF_INIT *initid, UDF_ARGS *args,
   char *is_null, char *error);
  • 对于返回值是REAL的
double xxx(UDF_INIT *initid, UDF_ARGS *args,
   char *is_null, char *error);

xxx_init()

xxx()函数的初始化函数,这个函数的作用包括:

  • 检查传入XXX()函数的参数个数
  • 检验传入XXX()的参数的数据类型,而且它还可以让MySQL将传入XXX()的参数转成xxx()需要的数据类型
  • 分配xxx()函数需要的内存
  • 指定返回值的最大长度
  • 指定返回值是REAL的函数的返回值的精度
  • 指定返回值是不是NULL

函数原型如下:

bool xxx_init(UDF_INIT *initid, UDF_ARGS *args, char *message);

xxx_deinit()

xxx()的析构函数,用于释放初始化函数分配的内存或做其它清理工作,这个函数是可选的。 函数原型如下:

void xxx_deinit(UDF_INIT *initid);

UDF执行流程

当在一个SQL语句中调用XXX()时,MySQL首先调用xxx_init()函数做必要的初始化工作,比如:参数检查、内存分配等。 如果xxx_init()返回错误,则主函数xxx()和析构函数xxx_deinit()不会被调用,整个语句会报错退出。如果xxx_init() 执行成功MySQL会调用主函数xxx(),通常情况下会每行数据调用一次,依赖于XXX()在SQL语句中的位置。当所有的主函数xxx() 都调用完成后,MySQL会调用对应的析构函数xxx_deinit()做必要的清理工作。

注意,以上所有的函数都要求是线程安全的。同时如果是用C++实现的,那么在定义的函数开头必须要加上 extern “C”,以便 MySQL可以找到相应的符号。

UDF实现相关数据结构说明

  1. UDF_INIT

    是参数initid的类型,该参数是3个函数都需要的,可以在xxx_init函数中初始化。该结构的主要成员如下:

    • bool maybe_null

    如果xxx()可以返回NULL,xxx_init函数需要把它设置成true,如果函数参数有maybe_null是true的,该值的默认值就是true。

    • unsigned int max_length

    返回值的最大长度。对于不同返回类型该值的默认值不同,对于STRING,默认值和和最长的函数参数相等。对于INTEGER, 默认值是21。如果是BLOB类型的,可以将它设置成65KB或16MB。

    • char *ptr

    一个透明的指针,UDF的实现可以自己根据需要使用。该指针一般在xxx_init()里分配内存,在xxx_deinit()里进行释放。

    • bool const_item

    如果xxx()函数总是返回相同的值,xxx_init()中可以把该值设置成true。

  2. UDF_ARGS

    是参数args是数据类型,主要成员如下:

    • unsigned int arg_count

    SQL函数参数的个数,也是下面其他成员的数组长度。可以在xxx_init()函数里检查是否与预期一致,如:

    if (args->arg_count != 2)
    {
      strcpy(message, "XXX() requires two arguments");
      return 1;
    }
    
    • enum Item_result *arg_type

    是一个定义了每个参数类型的数组,每个元素可能的取值:TRING_RESULT, INT_RESULT, REAL_RESULT, 和DECIMAL_RESULT。 也可以通过它在xxx_init()里指定某个参数的数据类型,MySQL会将输入的参数强制转化为该类型。

    • char **args

    对于xxx_init(),当参数是常量时,比如 3、4*7-2或SIN(3.14) args->args[i]指向参数值,当参数是非常量时 args->args[i]为NULL;对于主函数xxx()总是指向参数的值,如果参数i为null,则args->args[i]为NULL。

    • 对于STRING_RESULT类型,args->args[i]指向对应的字符串,args->lengths[i]是字符串长度。
    • 对于INT_RESULT类型,需要强制转化成long long:
    long long int_val = *(long long *) args->args[i];
    
    • 对于REAL_RESULT类型,需要转成double:
    double real_val = *(double *) args->args[i]
    
    • unsigned long *lengths

    对于xxx_init()函数该数组包含每个参数的最大长度,对于xxx()函数为参数的实际长度。

    • char *maybe_null

    对于xxx_init()该成员表示对应的参数是否可以为null。

    • **attributes

    表示传入参数的参数名,参数名的长度在args->attribute_lengths[i]中。

UDF返回值及错误处理

如果有错误发生xxx_init()应该返回true,同时将错误消息保存在message参数中,message参数的buffer长度为 MYSQL_ERRMSG_SIZE(512)。对于long long和double的SQL函数的返回值通过主函数xxx()的返回值返回。字符串类型的SQL函数 如果字符串长度小于255,可以通过参数result参数返回,实际长度存在*length中,xxx()函数要返回result;如果要返回的字 符串长度大于255,需要自己分配内存并通过xxx()返回值返回。分配的内存需要在xxx_deinit里释放。可以通过设置*is_null = 1来表示SQL函数返回值为null。另外如果函数发生错误需要设置 *error = 1。

UDF的编译和安装

这里只讲Linux下编译和安装,编译可以使用如下命令:

c++ -I$(MYSQL_INSTALLDIR)/include  -fPIC -g -shared  \
  -o $(MYSQL_INSTALLDIR)/lib/plugin/libmyudf.so myudf.cc

这里MYSQL_INSTALLDIR指的是MySQL的安装目录。编译完成后生成的目标动态库直接写到了MySQL的安装目录的plugin目录 下,mysqld只在这个目录上寻找UDF实现动态库。

UDF函数的使用

使用mysql命令连接到MySQL server,执行以下查询在数据库中生成SQL函数

CREATE FUNCTION myudf RETURNS INT SONAME 'libmyudf.so';

这里在的libmyudf.so是前面编译生成的动态库。可以通过系统表mysql.func和performance_schema下的user_defined_functions 来跟踪系统中已经安装的UDF。

一个简单的例子

#include "mysql.h"
#include <sys/types.h> /* getpid() */
#include <unistd.h> /* getpid() */

extern "C" bool
mysqld_pid_init(UDF_INIT *initid __attribute__((unused)),
    UDF_ARGS *args __attribute__((unused)),
    char *message __attribute__((unused)))
{
  return false;
}

extern "C" long long
mysqld_pid(UDF_INIT *initid __attribute__((unused)),
    UDF_ARGS *args __attribute__((unused)),
    char *is_null __attribute__((unused)),
    char *error __attribute__((unused)))
{
  return getpid();
}

这个例子实现了一个简单的UDF:mysqld_pid(),该UDF可以拿到mysqld进程的PID,可以通过SQL语句select mysqld_pid()调用。 将以上例子拷贝到一个文件,比如mysqld_pid.cc然后进行编译:

c++ -I$(MYSQL_INSTALLDIR)/include  -fPIC -g -shared  -o \
   $(MYSQL_INSTALLDIR)/lib/plugin/libmysqld_pid.so mysqld_pid.cc

最后通过mysql连接到数据库执行如下SQL语句,将mysqld_pid安装到数据库,用户就可以在SQL语句上使用了。

CREATE FUNCTION mysqld_pid RETURNS INT SONAME 'libmysqld_pid.so';

MySQL · 最佳实践 · MySQL多队列线程池优化

$
0
0

随着信息技术的进步,各行各业对数据价值的重视程度急剧上升,越来越多的数据被分门别类地积聚下来,对数据库的并发要求越来越高,即同一时间点的数据请求越来越多,对实时性的要求也越来越高。实时性其实是不经过批量排队的高并发实时请求的代名词,同一时间的请求量和请求的处理速度直接决定了并发度:

并发度 = 单位时间请求数/单位时间处理能力

以日常生活中的高铁买票为例,假设每秒钟要卖出1000张票,每张表的处理时间为1s(单个窗口每秒处理1张票),则需要1000个售票窗口。因为物理资源的限制,实际上无法建设1000个售票窗口,大家只好在售票窗口前排起队来。MySQL数据库也是如此,CPU的核数可以理解为物理资源的限制(相当于售票员,假设还无法自动),每一个线程可以理解为一个售票窗口,每一个事务或查询可以理解为买票动作,每个购票者可以理解为一个连接,默认的请求处理方式是每个人都有一个专用的售票窗口,需要售票员跑来跑去(CPU上下文切换,售票窗口越多,跑起来越费力)来为你服务,可以看到这是不够合理的,特别是售票员比较少而购票者很多的场景。为了提升MySQL的处理效率,Oracle官方和Percona / MariaDB都实现了线程池机制(Thread Pool),不再是每个人都有一个专用的售票窗口(每个客户端对应一个后端线程),通过限定售标窗口数,让购票者排队,来减少售票员跑来跑去的成本。

优化思路

这个看似合理的线程池机制,在实际的应用场景中使用极少,原因是它的设计不够合理。同样以高铁购票为例,有的购票者是去现场买票(需要临时决策,花费较长时间,类似于数据库中的事务),有的购票者是直接指定车次快速付款或者直接取票(花费较短时间,类似于数库中的查询或简单更新)。MySQL线程池现在的实现机制就是不区分买票和取票,统一排队共享资源池(默认买票优先,取票操作会被延后),完全没有队列的概念,导致高并发的更新或事务操作会阻止短平快的小查询,对于取票者来讲,是极不合理的(假定读者都做过高铁取票者)。

MySQL线程池目前只有一层排队,即从网络接收请求进行排队,实现线程资源共享,即不知道是购票还是取票,大家共排一个队列。改进的方法是引入多层队列,第一层队列接收网络请求,读取网络包,再根据网络包进行操作类型识别,区分是购票还是取票操作,再引导到购票队列和取票队列。再进行合理的购票窗口和取票窗口配比,使得购票(大操作)和取票(小操作)不会有严重的相互阻塞。从网络包中可以分析操作类型,并得到SQL语句,并可以根据SQL语句类型和事务上下文,将操作分为以下四类:

  • 查询操作,会话处于自动提交模式,SQL类型为查询语句。
  • 更新操作,会话处于自动提交模式,SQL类型为DML语句。
  • 事务操作,会话处于事务模式(start transaction或autocommit=0)下的任何语句。
  • 管理操作,以上之外的操作,比如“show”、“set”等操作。

相对应的,可以在线程池中,实现真正的队列机制,进行更加合理和先进的排队机制。如下所示:

  • 第一层队列为网络请求队列,可以区分为请求队列(不在事务状态中的请求)和高优先级队列(已经在事务状态中的请求,收到请求后会马上执行,不进入第二队列)。
  • 第二层队列为工作任务队列,可以区分为查询队列、更新队列和事务队列。第一层请求队列的请求经过快速的处理和分析进入第二层队列。如果是管理操作,则直接执行,假定所有管理操作都是小操作。

对第二层队列,可以分别设置一个允许的并发度(可以接近售票员/CPU的个数),以实现总线程数的控制。只要线程数大于四类操作的设计并发度之和,则不同类型的操作不会互相干涉(在这里是假定同一操作超过各自并发度而进行排队是合理的)。任何一个队列超过一定的时间,如果没有售出任何票,处于阻塞模式,则可以考虑放行,在MySQL线程池中有“thread_pool_stall_limit”变量来控制这个间隔,以防止任何一个队列挂起。

可以从配置参数的变化来了解优化后的线程池工作机制:

  • thread_pool_oversubscribe:每个Thread Group的目标线程数
  • thread_pool_normal_weights:查询、更新操作的目标线程比例(假定这两类操作的比重相同),即并发度= thread_pool_oversubscribe * 目标比例/100。
  • thread_pool_trans_weights:事务操作的目标线程比例,即并发度= thread_pool_oversubscribe * 目标比例/100。
  • thread_pool_stall_limit:阻塞模式检查频率(同时检查5个队列的状态)
  • thread_pool_size:线程组的个数(在优化锁并发后,线程组的个数不是很关键,可以用来根据物理机器的资源配置情况来软性调节处理能力)

线程池优化的思路是将线程池从单一的对操作类无感知的无优先级资源共享队列,变成可感知操作类型的优先级队列,实现相同操作排队,不同操作相互之间无干扰的目标。相较于原始的线程池,优化后的线程池,可以使用一个连接地址来适应不同类型的操作请求,不再需要前端应用仔细设计请求队列,降底应用研发的要求和成本。

效果测试

测试的版本为内部实验室版本,在公共云上并不可见。下面进行TPCC 1000DW的测试,并发数为1000,使用“show status like ‘thread%’”查看线程数的结果为:

mysql> show status like 'thread%';
+-------------------------+-------+
| Variable_name           | Value |
+-------------------------+-------+
| Threadpool_idle_threads | 31    |
| Threadpool_threads      | 179   |
| Threadpool_wait_threads | 23    |
| Threads_cached          | 0     |
| Threads_connected       | 1001  |
| Threads_created         | 179   |
| Threads_running         | 172   |
+-------------------------+-------+
7 rows in set (0.02 sec)

可以看到,总共创建了179个线程,服务了1000个客户端压测连接,每秒的事务数约为5000,TpmC值约为8万。接下来使用3000并发连接进行测试,结果如下所示:

mysql> show status like 'thread%';
+-------------------------+-------+
| Variable_name           | Value |
+-------------------------+-------+
| Threadpool_idle_threads | 32    |
| Threadpool_threads      | 179   |
| Threadpool_wait_threads | 24    |
| Threads_cached          | 0     |
| Threads_connected       | 3001  |
| Threads_created         | 179   |
| Threads_running         | 172   |
+-------------------------+-------+
7 rows in set (0.05 sec)

可以看到线程数并没有上涨,同样是用179个线程来服务了3000个客户端连接,TpmC值约为8万,没有看到总体TPS的损失,并且“show”执行的速度比较快,没有阻塞的感觉。

适用场景

优化后的线程池也并不是万能的,在以下几种场景中表现会不够理想:

  • 有较高的大查询(指一次查询需要一秒钟以上)并发,如果累计的大查询并发度超过总的查询并发度,则大查询会累积起来,阻止小查询。同理的大的更新也会阻止小的更新,当然这样的场景下,不管是否使用线程池,数据库的表现都是不够理想的,需要应用侧控制大查询的并发度。
  • 有较严重的锁冲突,如果处于锁等待的并发度超过总的处理并发度,也会累积起来,阻止无锁待的处理请求。当然这样的场景下,不管是否使用线程池,数据库的表现都是不够理想的,需要应用层进行优化。
  • 极高并发的Prepared Statement请求,使用Prepared Statement(Java应用不算)时,会使用MySQL Binary Protocol,会增加很多的网络来回操作,比如参数的绑定、结果集的返回,在极高请求压力下会给epoll监听进程带来一定的压力,处于事务状态中时,会让第一层队列的普通请求得不到执行机会。

当处于积累以后,每种类型的操作,都会等待一个阻塞时间,由参数“thread_pool_stall_limit”控制。

PgSQL · 应用案例 · PostgreSQL 时间线修复

$
0
0

背景

1、PG物理流复制的从库,当激活后,可以开启读写,使用pg_rewind可以将从库回退为只读从库的角色。而不需要重建整个从库。

2、当异步主从发生角色切换后,主库的wal目录中可能还有没完全同步到从库的内容,因此老的主库无法直接切换为新主库的从库。使用pg_rewind可以修复老的主库,使之成为新主库的只读从库。而不需要重建整个从库。

3、如果没有pg_rewind,遇到以上情况,需要完全重建从库。或者你可以使用存储层快照,回退回脑裂以前的状态。又或者可以使用文件系统快照,回退回脑裂以前的状态。

原理与修复步骤

1、使用pg_rewind功能的前提条件:必须开启full page write,必须开启wal hint或者data block checksum。

2、需要被修复的库:从激活点开始,所有的WAL必须存在pg_wal目录中。如果WAL已经被覆盖,只要有归档,拷贝到pg_wal目录即可。

3、新的主库,从激活点开始,产生的所有WAL必须存在pg_wal目录中,或者已归档,并且被修复的库可以使用restore_command访问到这部分WAL。

4、修改(source db)新主库或老主库配置,允许连接。

5、修复时,连接新主库,得到切换点。或连接老主库,同时比对当前要修复的新主库的TL与老主库进行比对,得到切换点。

6、解析需要被修复的库的从切换点到现在所有的WAL。同时连接source db(新主库(或老主库)),进行回退操作(被修改或删除的BLOCK从source db获取并覆盖,新增的BLOCK,直接抹除。)回退到切换点的状态。

7、修改被修复库(target db)的recovery.conf, postgresql.conf配置。

8、启动target db,连接source db接收WAL,或restore_command配置接收WAL,从切换点开始所有WAL,进行apply。

9、target db现在是source db的从库。

以EDB PG 11为例讲解

环境部署

《MTK使用 - PG,PPAS,oracle,mysql,ms sql,sybase 迁移到 PG, PPAS (支持跨版本升级)》

export PS1="$USER@`/bin/hostname -s`-> "      
export PGPORT=4000  
export PGDATA=/data04/ppas11/pg_root4000  
export LANG=en_US.utf8      
export PGHOME=/usr/edb/as11  
export LD_LIBRARY_PATH=$PGHOME/lib:/lib64:/usr/lib64:/usr/local/lib64:/lib:/usr/lib:/usr/local/lib:$LD_LIBRARY_PATH      
export DATE=`date +"%Y%m%d%H%M"`    
export PATH=$PGHOME/bin:$PATH:.      
export MANPATH=$PGHOME/share/man:$MANPATH      
export PGHOST=127.0.0.1  
export PGUSER=postgres   
export PGDATABASE=postgres      
alias rm='rm -i'      
alias ll='ls -lh'      
unalias vi    

1、初始化数据库集群

initdb -D /data04/ppas11/pg_root4000 -E UTF8 --lc-collate=C --lc-ctype=en_US.UTF8 -U postgres -k --redwood-like   

2、配置recovery.done

cd $PGDATA  
  
cp $PGHOME/share/recovery.conf.sample ./  
  
mv recovery.conf.sample recovery.done  
  
vi recovery.done  
  
restore_command = 'cp /data04/ppas11/wal/%f %p'  
recovery_target_timeline = 'latest'  
standby_mode = on  
primary_conninfo = 'host=localhost port=4000 user=postgres'  

3、配置postgresql.conf

要使用rewind功能:

必须开启full_page_writes

必须开启data_checksums或wal_log_hints

postgresql.conf  
  
listen_addresses = '0.0.0.0'  
port = 4000  
max_connections = 8000  
superuser_reserved_connections = 13  
unix_socket_directories = '.,/tmp'  
unix_socket_permissions = 0700  
tcp_keepalives_idle = 60  
tcp_keepalives_interval = 10  
tcp_keepalives_count = 10  
shared_buffers = 16GB  
max_prepared_transactions = 8000  
maintenance_work_mem = 1GB  
autovacuum_work_mem = 1GB  
dynamic_shared_memory_type = posix  
vacuum_cost_delay = 0  
bgwriter_delay = 10ms  
bgwriter_lru_maxpages = 1000  
bgwriter_lru_multiplier = 10.0  
effective_io_concurrency = 0  
max_worker_processes = 128  
max_parallel_maintenance_workers = 8  
max_parallel_workers_per_gather = 8  
max_parallel_workers = 24  
wal_level = replica  
synchronous_commit = off  
full_page_writes = on  
wal_compression = on  
wal_buffers = 32MB  
wal_writer_delay = 10ms  
checkpoint_timeout = 25min  
max_wal_size = 32GB  
min_wal_size = 8GB  
checkpoint_completion_target = 0.2  
archive_mode = on  
archive_command = 'cp -n %p /data04/ppas11/wal/%f'  
max_wal_senders = 16  
wal_keep_segments = 4096  
max_replication_slots = 16  
hot_standby = on  
max_standby_archive_delay = 300s  
max_standby_streaming_delay = 300s  
wal_receiver_status_interval = 1s  
wal_receiver_timeout = 10s  
random_page_cost = 1.1  
effective_cache_size = 400GB  
log_destination = 'csvlog'  
logging_collector = on  
log_directory = 'log'  
log_filename = 'edb-%a.log'  
log_truncate_on_rotation = on  
log_rotation_age = 1d  
log_rotation_size = 0  
log_min_duration_statement = 1s  
log_checkpoints = on  
log_error_verbosity = verbose  
log_line_prefix = '%t '  
log_lock_waits = on  
log_statement = 'ddl'  
log_timezone = 'PRC'  
autovacuum = on  
log_autovacuum_min_duration = 0  
autovacuum_max_workers = 6  
autovacuum_freeze_max_age = 1200000000  
autovacuum_multixact_freeze_max_age = 1400000000  
autovacuum_vacuum_cost_delay = 0  
statement_timeout = 0  
lock_timeout = 0  
idle_in_transaction_session_timeout = 0  
vacuum_freeze_table_age = 1150000000  
vacuum_multixact_freeze_table_age = 1150000000  
datestyle = 'redwood,show_time'  
timezone = 'PRC'  
lc_messages = 'en_US.utf8'  
lc_monetary = 'en_US.utf8'  
lc_numeric = 'en_US.utf8'  
lc_time = 'en_US.utf8'  
default_text_search_config = 'pg_catalog.english'  
shared_preload_libraries = 'auto_explain,pg_stat_statements,$libdir/dbms_pipe,$libdir/edb_gen,$libdir/dbms_aq'  
edb_redwood_date = on  
edb_redwood_greatest_least = on  
edb_redwood_strings = on  
db_dialect = 'redwood'                
edb_dynatune = 66  
edb_dynatune_profile = oltp  
timed_statistics = off  

4、配置pg_hba.conf,允许流复制

local   all             all                                     trust  
host    all             all             127.0.0.1/32            trust  
host    all             all             ::1/128                 trust  
local   replication     all                                     trust  
host    replication     all             127.0.0.1/32            trust  
host    replication     all             ::1/128                 trust  
host all all 0.0.0.0/0 md5  

5、配置归档目录

mkdir /data04/ppas11/wal    
chown enterprisedb:enterprisedb /data04/ppas11/wal  

6、创建从库

pg_basebackup -h 127.0.0.1 -p 4000 -D /data04/ppas11/pg_root4001 -F p -c fast  

7、配置从库

cd /data04/ppas11/pg_root4001  
  
mv recovery.done recovery.conf  
vi postgresql.conf  
  
port = 4001  

8、启动从库

pg_ctl start -D /data04/ppas11/pg_root4001  

9、压测主库

pgbench -i -s 1000  
  
pgbench -M prepared -v -r -P 1 -c 24 -j 24 -T 300  

10、检查归档

postgres=# select * from pg_stat_archiver ;  
 archived_count |    last_archived_wal     |        last_archived_time        | failed_count | last_failed_wal | last_failed_time |           stats_reset              
----------------+--------------------------+----------------------------------+--------------+-----------------+------------------+----------------------------------  
            240 | 0000000100000000000000F0 | 28-JAN-19 15:08:43.276965 +08:00 |            0 |                 |                  | 28-JAN-19 15:01:17.883338 +08:00  
(1 row)  
  
postgres=# select * from pg_stat_archiver ;  
 archived_count |    last_archived_wal     |        last_archived_time        | failed_count | last_failed_wal | last_failed_time |           stats_reset              
----------------+--------------------------+----------------------------------+--------------+-----------------+------------------+----------------------------------  
            248 | 0000000100000000000000F8 | 28-JAN-19 15:08:45.120134 +08:00 |            0 |                 |                  | 28-JAN-19 15:01:17.883338 +08:00  
(1 row)  

11、检查从库延迟

postgres=# select * from pg_stat_replication ;  
-[ RECORD 1 ]----+---------------------------------  
pid              | 8124  
usesysid         | 10  
usename          | postgres  
application_name | walreceiver  
client_addr      | 127.0.0.1  
client_hostname  |   
client_port      | 62988  
backend_start    | 28-JAN-19 15:07:34.084542 +08:00  
backend_xmin     |   
state            | streaming  
sent_lsn         | 1/88BC2000  
write_lsn        | 1/88BC2000  
flush_lsn        | 1/88BC2000  
replay_lsn       | 1/88077D48  
write_lag        | 00:00:00.001417  
flush_lag        | 00:00:00.002221  
replay_lag       | 00:00:00.097657  
sync_priority    | 0  
sync_state       | async  

例子1,从库激活后产生读写,使用pg_rewind修复从库,回退到只读从库

1、激活从库

pg_ctl promote -D /data04/ppas11/pg_root4001  

2、写从库

pgbench -M prepared -v -r -P 1 -c 4 -j 4 -T 120 -p 4001  

此时从库已经和主库不在一个时间线,无法直接变成当前主库的从库

enterprisedb@pg11-test-> pg_controldata -D /data04/ppas11/pg_root4001|grep -i time  
Latest checkpoint's TimeLineID:       1  
Latest checkpoint's PrevTimeLineID:   1  
Time of latest checkpoint:            Mon 28 Jan 2019 03:56:38 PM CST  
Min recovery ending loc's timeline:   2  
track_commit_timestamp setting:       off  
Date/time type storage:               64-bit integers  
  
enterprisedb@pg11-test-> pg_controldata -D /data04/ppas11/pg_root4000|grep -i time  
Latest checkpoint's TimeLineID:       1  
Latest checkpoint's PrevTimeLineID:   1  
Time of latest checkpoint:            Mon 28 Jan 2019 05:11:38 PM CST  
Min recovery ending loc's timeline:   0  
track_commit_timestamp setting:       off  
Date/time type storage:               64-bit integers  

3、修复从库,使之继续成为当前主库的从库

4、查看切换点

cd /data04/ppas11/pg_root4001  
  
ll pg_wal/*.history  
-rw------- 1 enterprisedb enterprisedb 42 Jan 28 17:15 pg_wal/00000002.history  
  
cat pg_wal/00000002.history  
1       6/48C62000      no recovery target specified  

5、从库激活时间开始产生的WAL必须全部在pg_wal目录中。

-rw------- 1 enterprisedb enterprisedb   42 Jan 28 17:15 00000002.history  
-rw------- 1 enterprisedb enterprisedb  16M Jan 28 17:16 000000020000000600000048  
............  

000000020000000600000048开始,所有的wal必须存在从库pg_wal目录中。如果已经覆盖了,必须从归档目录拷贝到从库pg_wal目录中。

6、从库激活时,主库从这个时间点开始所有的WAL还在pg_wal目录,或者从库可以使用restore_command获得(recovery.conf)。

recovery.conf  
  
restore_command = 'cp /data04/ppas11/wal/%f %p'  

7、pg_rewind命令帮助

https://www.postgresql.org/docs/11/app-pgrewind.html

pg_rewind --help  
pg_rewind resynchronizes a PostgreSQL cluster with another copy of the cluster.  
  
Usage:  
  pg_rewind [OPTION]...  
  
Options:  
  -D, --target-pgdata=DIRECTORY  existing data directory to modify  
      --source-pgdata=DIRECTORY  source data directory to synchronize with  
      --source-server=CONNSTR    source server to synchronize with  
  -n, --dry-run                  stop before modifying anything  
  -P, --progress                 write progress messages  
      --debug                    write a lot of debug messages  
  -V, --version                  output version information, then exit  
  -?, --help                     show this help, then exit  
  
Report bugs to <support@enterprisedb.com>.  

8、停库(被修复的库,停库)

pg_ctl stop -m fast -D /data04/ppas11/pg_root4001  

9、尝试修复

pg_rewind -n -D /data04/ppas11/pg_root4001 --source-server="hostaddr=127.0.0.1 user=postgres port=4000"  
  
servers diverged at WAL location 6/48C62000 on timeline 1  
rewinding from last common checkpoint at 5/5A8CD30 on timeline 1  
Done!  

10、尝试正常,说明可以修复,实施修复

pg_rewind -D /data04/ppas11/pg_root4001 --source-server="hostaddr=127.0.0.1 user=postgres port=4000"  
  
servers diverged at WAL location 6/48C62000 on timeline 1  
rewinding from last common checkpoint at 5/5A8CD30 on timeline 1  
Done!  

11、已修复,改配置

cd /data04/ppas11/pg_root4001  
  
vi postgresql.conf  
port = 4001  
mv recovery.done recovery.conf  
  
vi recovery.conf  
  
restore_command = 'cp /data04/ppas11/wal/%f %p'   
recovery_target_timeline = 'latest'   
standby_mode = on   
primary_conninfo = 'host=localhost port=4000 user=postgres'   

12、删除归档中错误时间线上产生的文件否则会在启动修复后的从库后,走到00000002时间线上,这是不想看到的。

mkdir /data04/ppas11/wal/error_tl_2  
  
mv /data04/ppas11/wal/00000002* /data04/ppas11/wal/error_tl_2  

13、启动从库

pg_ctl start -D /data04/ppas11/pg_root4001  

14、建议对主库做一个检查点,从库收到检查点后,重启后不需要应用太多WAL,而是从新检查点开始恢复

psql  
checkpoint;  

15、压测主库

pgbench -M prepared -v -r -P 1 -c 16 -j 16 -T 200 -p 4000  

16、查看归档状态

postgres=# select * from pg_stat_archiver ;  
 archived_count |    last_archived_wal     |        last_archived_time        | failed_count | last_failed_wal | last_failed_time |           stats_reset              
----------------+--------------------------+----------------------------------+--------------+-----------------+------------------+----------------------------------  
           1756 | 0000000100000006000000DC | 28-JAN-19 17:41:57.562425 +08:00 |            0 |                 |                  | 28-JAN-19 15:01:17.883338 +08:00  
(1 row)  

17、查看从库健康、延迟,观察修复后的情况

postgres=# select * from pg_stat_replication ;  
-[ RECORD 1 ]----+--------------------------------  
pid              | 13179  
usesysid         | 10  
usename          | postgres  
application_name | walreceiver  
client_addr      | 127.0.0.1  
client_hostname  |   
client_port      | 63198  
backend_start    | 28-JAN-19 17:47:29.85308 +08:00  
backend_xmin     |   
state            | catchup  
sent_lsn         | 7/DDE80000  
write_lsn        | 7/DC000000  
flush_lsn        | 7/DC000000  
replay_lsn       | 7/26A8DCB0  
write_lag        | 00:00:18.373263  
flush_lag        | 00:00:18.373263  
replay_lag       | 00:00:18.373263  
sync_priority    | 0  
sync_state       | async  

例子2,从库激活成为新主库后,老主库依旧有读写,使用pg_rewind修复老主库,将老主库降级为新主库的从库

1、激活从库

pg_ctl promote -D /data04/ppas11/pg_root4001  

2、写从库

pgbench -M prepared -v -r -P 1 -c 16 -j 16 -T 200 -p 4001  

3、写主库

pgbench -M prepared -v -r -P 1 -c 16 -j 16 -T 200 -p 4000  

此时老主库已经和新的主库不在一个时间线

enterprisedb@pg11-test-> pg_controldata -D /data04/ppas11/pg_root4000|grep -i timeline  
Latest checkpoint's TimeLineID:       1  
Latest checkpoint's PrevTimeLineID:   1  
Min recovery ending loc's timeline:   0  
enterprisedb@pg11-test-> pg_controldata -D /data04/ppas11/pg_root4001|grep -i timeline  
Latest checkpoint's TimeLineID:       1  
Latest checkpoint's PrevTimeLineID:   1  
Min recovery ending loc's timeline:   2  
  
  
enterprisedb@pg11-test-> cd /data04/ppas11/pg_root4001/pg_wal  
enterprisedb@pg11-test-> cat 00000002.history   
1       8/48DE2318      no recovery target specified  
  
enterprisedb@pg11-test-> ll *.partial  
-rw------- 1 enterprisedb enterprisedb 16M Jan 28 17:48 000000010000000800000048.partial  

4、修复老主库,变成从库

4.1、从库激活时,老主库从这个时间点开始所有的WAL,必须全部在pg_wal目录中。

000000010000000800000048 开始的所有WAL必须存在pg_wal,如果已经覆盖了,必须从WAL归档拷贝到pg_wal目录

4.2、从库激活时间开始产生的所有WAL,老主库必须可以使用restore_command获得(recovery.conf)。

recovery.conf  
  
restore_command = 'cp /data04/ppas11/wal/%f %p'  

5、关闭老主库

pg_ctl stop -m fast -D /data04/ppas11/pg_root4000  

6、尝试修复老主库

pg_rewind -n -D /data04/ppas11/pg_root4000 --source-server="hostaddr=127.0.0.1 user=postgres port=4001"  
  
servers diverged at WAL location 8/48DE2318 on timeline 1  
rewinding from last common checkpoint at 6/CCCEF770 on timeline 1  
Done!  

7、尝试成功,可以修复,实施修复

pg_rewind -D /data04/ppas11/pg_root4000 --source-server="hostaddr=127.0.0.1 user=postgres port=4001"

8、修复完成后,改配置

cd /data04/ppas11/pg_root4000  
  
vi postgresql.conf  
port = 4000  
mv recovery.done recovery.conf  
  
vi recovery.conf  
  
restore_command = 'cp /data04/ppas11/wal/%f %p'   
recovery_target_timeline = 'latest'   
standby_mode = on   
primary_conninfo = 'host=localhost port=4001 user=postgres'    

9、启动老主库

pg_ctl start -D /data04/ppas11/pg_root4000  

10、建议对新主库做一个检查点,从库收到检查点后,重启后不需要应用太多WAL,而是从新检查点开始恢复

checkpoint;  

11、压测新主库

pgbench -M prepared -v -r -P 1 -c 16 -j 16 -T 200 -p 4001  

12、查看归档状态

psql -p 4001  
  
  
postgres=# select * from pg_stat_archiver ;  
 archived_count |    last_archived_wal     |        last_archived_time        | failed_count | last_failed_wal | last_failed_time |           stats_reset              
----------------+--------------------------+----------------------------------+--------------+-----------------+------------------+----------------------------------  
            406 | 0000000200000009000000DB | 28-JAN-19 21:18:22.976118 +08:00 |            0 |                 |                  | 28-JAN-19 17:47:29.847488 +08:00  
(1 row)  

13、查看从库健康、延迟

psql -p 4001  
  
postgres=# select * from pg_stat_replication ;  
-[ RECORD 1 ]----+---------------------------------  
pid              | 17675  
usesysid         | 10  
usename          | postgres  
application_name | walreceiver  
client_addr      | 127.0.0.1  
client_hostname  |   
client_port      | 60530  
backend_start    | 28-JAN-19 21:18:36.472197 +08:00  
backend_xmin     |   
state            | streaming  
sent_lsn         | 9/E8361C18  
write_lsn        | 9/E8361C18  
flush_lsn        | 9/E8361C18  
replay_lsn       | 9/D235B520  
write_lag        | 00:00:00.000101  
flush_lag        | 00:00:00.000184  
replay_lag       | 00:00:03.028098  
sync_priority    | 0  
sync_state       | async  

小结

1 适合场景

1、PG物理流复制的从库,当激活后,可以开启读写,使用pg_rewind可以将从库回退为只读从库的角色。而不需要重建整个从库。

2、当异步主从发生角色切换后,主库的wal目录中可能还有没完全同步到从库的内容,因此老的主库无法直接切换为新主库的从库。使用pg_rewind可以修复老的主库,使之成为新主库的只读从库。而不需要重建整个从库。

如果没有pg_rewind,遇到以上情况,需要完全重建从库,如果库占用空间很大,重建非常耗时,也非常耗费上游数据库的资源(读)。

2 前提

要使用rewind功能:

1、必须开启full_page_writes

2、必须开启data_checksums或wal_log_hints

initdb -k 开启data_checksums  

3 原理与修复流程

1、使用pg_rewind功能的前提条件:必须开启full page write,必须开启wal hint或者data block checksum。

2、需要被修复的库:从激活点开始,所有的WAL必须存在pg_wal目录中。如果WAL已经被覆盖,只要有归档,拷贝到pg_wal目录即可。

3、新的主库,从激活点开始,产生的所有WAL必须存在pg_wal目录中,或者已归档,并且被修复的库可以使用restore_command访问到这部分WAL。

4、修改(source db)新主库或老主库配置,允许连接。

5、修复时,连接新主库,得到切换点。或连接老主库,同时比对当前要修复的新主库的TL与老主库进行比对,得到切换点。

6、解析需要被修复的库的从切换点到现在所有的WAL。同时连接source db(新主库(或老主库)),进行回退操作(被修改或删除的BLOCK从source db获取并覆盖,新增的BLOCK,直接抹除。)回退到切换点的状态。

7、修改被修复库(target db)的recovery.conf, postgresql.conf配置。

8、启动target db,连接source db接收WAL,或restore_command配置接收WAL,从切换点开始所有WAL,进行apply。

9、target db现在是source db的从库。

参考

https://www.postgresql.org/docs/11/app-pgrewind.html

《PostgreSQL primary-standby failback tools : pg_rewind》

《PostgreSQL 9.5 new feature - pg_rewind fast sync Split Brain Primary & Standby》

《PostgreSQL 9.5 add pg_rewind for Fast align for PostgreSQL unaligned primary & standby》

《MTK使用 - PG,PPAS,oracle,mysql,ms sql,sybase 迁移到 PG, PPAS (支持跨版本升级)》

digoal’s 大量PostgreSQL文章入口

PgSQL · 特性分析 · 内存管理机制

$
0
0

背景

为了提高数据访问的速度,一般数据库操作系统都会引入内存作为缓存,而为了方便管理和合并I/O,一般会开辟一个缓存池(buffer pool)。本文主要讲述PostgreSQL 如何进行缓存池管理。

数据库物理结构

在下文讲述缓存池管理之前,我们需要简单介绍下PostgreSQL 的数据库集簇的物理结构。PostgreSQL 的数据文件是按特定的目录和文件名组成:

  • 如果是特定的tablespace 的表/索引数据,则文件名形如
    $PGDATA/pg_tblspc/$tablespace_oid/$database_oid/$relation_oid.no
    
  • 如果不是特定的tablespace 的表/索引数据,则文件名形如
    $PGDATA/base/$database_oid/$relation_oid.num
    

其中PGDATA 是初始化的数据根目录,tablespace_oid 是tablespace 的oid,database_oid 是database 的oid,relation_oid是表/索引的oid。no 是一个数值,当表/索引的大小超过了1G(该值可以在编译PostgreSQL 源码时由configuration的–with-segsize 参数指定大小),该数值就会加1,初始值为0,但是为0时文件的后缀不加.0。

除此之外,表/索引数据文件中还包含以_fsm(与free space map 相关,详见文档) 和_vm (与visibility map 相关,详见文档)为后缀的文件。这两种文件在PostgreSQL 中被认为是表/索引数据文件的另外两种副本,其中_fsm 结尾的文件为该表/索引的数据文件副本1,_vm结尾的文件为该表/索引的数据文件副本2,而不带这两种后缀的文件为该表/索引的数据文件副本0。

无论表/索引的数据文件副本0或者1或者2,都是按照页面(page)为组织单元存储的,具体数据页的内容和结构,我们这里不再详细展开。但是值得一提的是,缓冲池中最终存储的就是一个个的page。而每个page我们可以按照(tablespace_oid, database_oid, relation_oid, fork_no, page_no) 唯一标示,而在PostgreSQL 源码中是使用结构体BufferTag 来表示,其结构如下,下文将会详细分析这个唯一标示在内存管理中起到的作用。

typedef struct buftag
{
	RelFileNode rnode;			/* physical relation identifier */
	ForkNumber	forkNum;
	BlockNumber blockNum;		/* blknum relative to begin of reln */
} BufferTag;
typedef struct RelFileNode
{
    Oid         spcNode;        /* tablespace */
    Oid         dbNode;         /* database */
    Oid         relNode;        /* relation */
} RelFileNode;

缓存管理结构

在PostgreSQL中,缓存池可以简单理解为在共享内存上分配的一个数组,其初始化的过程如下:

BufferBlocks = (char *) ShmemInitStruct("Buffer Blocks", NBuffers * (Size) BLCKSZ, &foundBufs);

其中NBuffers 即缓存池的大小与GUC 参数shared_buffers 相关(详见链接)。数组中每个元素存储一个缓存页,对应的下标buf_id 可以唯一标示一个缓存页。

为了对每个缓存页进行管理,我们需要管理其元数据,在PostgreSQL 中利用BufferDesc 结构体来表示每个缓存页的元数据,下文称其为缓存描述符,其初始化过程如下:

BufferDescriptors = (BufferDescPadded *) 
                                ShmemInitStruct("Buffer Descriptors",
						             NBuffers * sizeof(BufferDescPadded),
						              &foundDescs);

可以发现,缓存描述符是和缓存池的每个页面一一对应的,即如果有16384 个缓存页面,则就有16384 个缓存描述符。而其中的BufferTag 即是上文的PostgreSQL 中数据页面的唯一标示。

直到这里,我们如果要从缓存池中请求某个特定的页面,只需要遍历所有的缓存描述符即可。但是很显然这样的性能会非常的差。为了优化这个过程,PostgreSQL 引入了一个BufferTag 和缓存描述符的hash 映射表。通过它,我们可以快速找到特定的数据页面在缓存池中的位置。

概括起来,PostgreSQL 的缓存管理主要包括三层结构,如下图:

  • 缓存池,是一个数组,每个元素其实就是一个缓存页,下标buf_id 唯一标示一个缓存页。
  • 缓存描述符,也是一个数组,而且和缓存池的缓存一一对应,保存每个缓存页的元数据信息。
  • 缓存hash 表,是存储BufferTag 和缓存描述符之间映射关系的hash 表。 image.png

下文,我们将分析每层结构的具体实现以及涉及到的锁管理和缓冲页淘汰算法,深入浅出,介绍PostgreSQL 缓存池的管理机制。

缓存hash 表

从上文的分析,我们知道缓存hash 表是为了加快BufferTag 和缓存描述符的检索速度构造的数据结构。在PostgreSQL 中,缓存hash 表的数据结构设计比较复杂,我们会在接下来的月报去介绍在PostgreSQL 中缓存hash 表是如何实现的。在本文中,我们把缓存hash 表抽象成一个个的bucket slot。因为哈希函数还是有可能碰撞的,所以bucket slot 内可能有几个data entry 以链表的形式存储,如下图: image.png

而使用BufferTag 查找对应缓存描述符的过程可以简述如下:

  • 获取BufferTag 对应的哈希值hashvalue
  • 通过hashvalue 定位到具体的bucket slot
  • 遍历bucket slot 找到具体的data entry,其数据结构BufferLookupEnt 如下:
/* entry for buffer lookup hashtable */
typedef struct
{
	BufferTag	key;			/* Tag of a disk page */
	int			id;				/* Associated buffer ID */
} BufferLookupEnt;

BufferLookupEnt 的结构包含id 属性,而这个属性可以和唯一的缓存描述符或者唯一的缓存页对应,所以我们就获取了BufferTag 对应的缓存描述符。

缓存hash 表初始化的过程如下:

InitBufTable(NBuffers + NUM_BUFFER_PARTITIONS);

可以看出,缓存hash 表的bucket slot 个数要比缓存池个数NBuffers 要大。除此之外,多个后端进程同时访问相同的bucket slot 需要使用锁来进行保护,后文的锁管理会详细讲述这个过程。

缓存描述符

上文的缓存hash 表可以通过BufferTag 查询到对应的 buffer ID,而PostgreSQL 在初始化 缓存描述符和缓存页面一一对应,存储每个缓存页面的元数据信息,其数据结构BufferDesc 如下:

typedef struct BufferDesc
{
	BufferTag	tag;			/* ID of page contained in buffer */
	int			buf_id;			/* buffer's index number (from 0) */

	/* state of the tag, containing flags, refcount and usagecount */
	pg_atomic_uint32 state;

	int			wait_backend_pid;	/* backend PID of pin-count waiter */
	int			freeNext;		/* link in freelist chain */

	LWLock		content_lock;	/* to lock access to buffer contents */
} BufferDesc;

其中:

  • tag 指的是对应缓存页存储的数据页的唯一标示
  • buffer_id 指的是对应缓存页的下标,我们通过它可以直接访问对应缓存页
  • state 是一个无符号32位的变量,包含:
    • 18 bits refcount,当前一共有多少个后台进程正在访问该缓存页,如果没有进程访问该页面,本文称为该缓存描述符unpinned,否则称为该缓存描述符pinned
    • 4 bits usage count,最近一共有多少个后台进程访问过该缓存页,这个属性用于缓存页淘汰算法,下文将具体讲解。
    • 10 bits of flags,表示一些缓存页的其他状态,如下:
#define BM_LOCKED				(1U << 22)	/* buffer header is locked */
#define BM_DIRTY				(1U << 23)	/* data needs writing */
#define BM_VALID				(1U << 24)	/* data is valid */
#define BM_TAG_VALID			(1U << 25)	/* tag is assigned */
#define BM_IO_IN_PROGRESS		(1U << 26)	/* read or write in progress */
#define BM_IO_ERROR				(1U << 27)	/* previous I/O failed */
#define BM_JUST_DIRTIED			(1U << 28)	/* dirtied since write started */
#define BM_PIN_COUNT_WAITER		(1U << 29)	/* have waiter for sole pin */
#define BM_CHECKPOINT_NEEDED	(1U << 30)	/* must write for checkpoint */
#define BM_PERMANENT			(1U << 31)	/* permanent buffer (not unlogged,
											 * or init fork) */
  • freeNext,指向该缓存之后第一个空闲的缓存描述符
  • content_lock,是控制缓存描述符的一个轻量级锁,我们会在缓存锁管理具体分析其作用

上文讲到,当数据启动时,会初始化与缓存池大小相同的缓存描述符数组,其每个缓存描述符都是空的,这时整个缓存管理的三层结构如下: image.png

第一个数据页面从磁盘加载到缓存池的过程可以简述如下:

  • 从freelist 中找到第一个缓存描述符,并且把该缓存描述符pinned (增加refcount和usage_count)
  • 在缓存hash 表中插入这个数据页面的BufferTag 与buf_id 的对应新的data entry
  • 从磁盘中将数据页面加载到缓存池中对应缓存页面中
  • 在对应缓存描述符中更新该页面的元数据信息

缓存描述符是可以持续更新的,但是如下场景会使得对应的缓存描述符状态置为空并且放在freelist 中:

  • 数据页面对应的表或者索引被删除
  • 数据页面对应的数据库被删除
  • 数据页面被VACUUM FULL 命令清理

缓存池

上文也提到过,缓存池可以简单理解为在共享内存上分配的一个缓存页数组,每个缓存页大小为PostgreSQL 页面的大小,一般为8KB,而下标buf_id 唯一标示一个缓存页。

缓冲池的大小与GUC 参数shared_buffers 相关,例如shared_buffers 设置为128MB,页面大小为8KB,则有128MB/8KB=16384个缓存页。

缓存锁管理

在PostgreSQL 中是支持并发查询和写入的,多个进程对缓存的访问和更新是使用锁的机制来实现的,接下来我们分析下在PostgreSQL 中缓存相关锁的实现。

因为在缓存管理的三层结构中,每层都有并发读写的情况,通过控制缓存描述符的并发访问就能够解决缓存池的并发访问,所以这里的缓存锁实际上就是讲的缓存hash 表和缓存描述符的锁。

BufMappingLock

BufMappingLock 是缓存hash 表的轻量级锁。为了减少BufMappingLock 的锁争抢并且能够兼顾锁空间的开销,PostgreSQL 中把BufMappingLock 锁分为了很多片,默认为128片,每一片对应总数/128 个bucket slot。

当我们检索一个BufferTag 对应的data entry是需要BufMappingLock 对应分区的共享锁,当我们插入或者删除一个data entry 的时候需要BufMappingLock 对应分区的排他锁。

除此之外,缓存hash 表还需要其他的一些原子锁来保证一些属性的一致性,这里不再赘述。

content_lock

content_lock 是缓存描述符的轻量级锁。当需要读一个缓存页的时候,后台进程会去请求该缓存页对应缓存描述符的content_lock 共享锁。而当以下的场景,后台进程会去请求content_lock 排他锁:

  • 插入或者删除/更新该缓存页的元组
  • vacuum 该缓存页
  • freeze 该缓存页

io_in_progress_lock

io_in_progress_lock 是作用于缓存描述符上的I/O锁。当后台进程将对应缓存页加载至缓存或者刷出缓存,都需要这个缓存描述符上的io_in_progress_lock 排它锁。

其他

缓存描述符中的state 属性含有很多需要原子排他性的字段,例如refcount 和 usage count。但是在这里没有使用锁,而是使用pg_atomic_unlocked_write_u32()或者pg_atomic_read_u32() 方法来保证多进程访问相同缓存描述符的state 的原子性。

缓存页淘汰算法

在PostgreSQL 中采用clock-sweep 算法来进行缓存页的淘汰。clock-sweep 是NFU(Not Frequently Used)最近不常使用算法的一个优化算法。其算法在PostgreSQL 中的实现可以简述如下:

  1. 获取第一个候选缓存描述符,存储在freelist 控制信息的数据结构BufferStrategyControl 的nextVictimBuffer 属性中
  2. 如果该缓存描述符unpinned,则跳到步骤3,否则跳到步骤4
  3. 如果该候选缓存描述符的usage_count 属性为0,则选取该缓存描述符为要淘汰的缓存描述符,跳到步骤5,否则,usage_count–,跳到步骤4
  4. nextVictimBuffer 赋值为下一个缓存描述符(当缓存描述符全部遍历完成,则从第0个继续),跳到步骤1继续执行,直到发现一个要淘汰的缓存描述符
  5. 返回要淘汰的缓存描述符的buf_id

clock-sweep 算法一个比较简单的例子如下图: image.png

总结

至此,我们已经对PostgreSQL 的缓存池管理整个构架和一些关键的技术有了了解,下面我们会举例说明整个流程。

为了能够涉及到较多的操作,我们将缓存池满后访问某个不在缓存池的数据页面这种场景作为例子,其整个流程如下:

  1. 根据请求的数据页面形成BufferTag,假设为Tag_M,用Tag_M 去从缓存hash 表中检索data entry,很明显这里没有发现该BufferTag
  2. 使用clock-sweep 算法选择一个要淘汰的缓存页面,例如这里buf_id=5,该缓存页的data entry 为’Tag_F, buf_id=5’
  3. 如果是脏页,将buf_id=5的缓存页刷新到磁盘,否则跳到步骤4。刷新一个脏页的步骤如下:

    a. 获得buffer_id=5的缓存描述符的content_lock 共享锁和io_in_progress 排它锁(步骤f会释放) b. 修改该描述符的state,BM_IO_IN_PROGRESS 和BM_JUST_DIRTIED 字段设为1 c. 根据情况,执行 XLogFlush() 函数,对应的wal 日志刷新到磁盘 d. 将缓存页刷新到磁盘 e. 修改该描述符的state,BM_IO_IN_PROGRESS 字段设为1,BM_VALID 字段设为1 f. 释放该缓存描述符的content_lock 共享锁和io_in_progress 排它锁

  4. 获取buf_id=5的bucket slot 对应的BufMappingLock 分区排他锁,并将该data entry 标记为旧的
  5. 获取新的Tag_M 对应bucket slot 的BufMappingLock 分区排他锁并且插入一条新的data entry
  6. 删除buf_id=5的data entry,并且释放buf_id=5的bucket slot 对应的BufMappingLock 分区锁
  7. 从磁盘上加载数据页面到buf_id=5的缓存页面,并且更新buf_id=5的缓存描述符state 属性的BM_DIRTY字段为0,初始化state的其他字段。
  8. 释放Tag_M 对应bucket slot 的BufMappingLock 分区排他锁
  9. 从缓存中访问该数据页面

其过程的示意图如下所示: image.pngimage.png

MongoDB · 同步工具 · MongoShake原理分析

$
0
0

1.背景

  在当前的数据库系统生态中,大部分系统都支持多个节点实例间的数据同步机制,如Mysql Master/Slave主从同步,Redis AOF主从同步等,MongoDB更是支持3节点及以上的副本集同步,上述机制很好的支撑了一个逻辑单元的数据冗余高可用。
  跨逻辑单元,甚至跨单元、跨数据中心的数据同步,在业务层有时候就显得很重要,它使得同城多机房的负载均衡,多机房的互备,甚至是异地多数据中心容灾和多活成为可能。由于目前MongoDB副本集内置的主从同步对于这种业务场景有较大的局限性,为此,我们开发了MongoShake系统,可以应用在实例间复制,机房间、跨数据中心复制,满足灾备和多活需求。
  另外,数据备份是作为MongoShake核心但不是唯一的功能。MongoShake作为一个平台型服务,用户可以通过对接MongoShake,实现数据的订阅消费来满足不同的业务场景。

2.简介

  MongoShake是一个以golang语言进行编写的通用的平台型服务,通过读取MongoDB集群的Oplog操作日志,对MongoDB的数据进行复制,后续通过操作日志实现特定需求。日志可以提供很多场景化的应用,为此,我们在设计时就考虑了把MongoShake做成通用的平台型服务。通过操作日志,我们提供日志数据订阅消费PUB/SUB功能,可通过SDK、Kafka、MetaQ等方式灵活对接以适应不同场景(如日志订阅、数据中心同步、Cache异步淘汰等)。集群数据同步是其中核心应用场景,通过抓取oplog后进行回放达到同步目的,实现灾备和多活的业务场景。

3.应用场景举例

  1. MongoDB集群间数据的异步复制,免去业务双写开销。
  2. MongoDB集群间数据的镜像备份(当前1.0开源版本支持受限)
  3. 日志离线分析
  4. 日志订阅
  5. 数据路由。根据业务需求,结合日志订阅和过滤机制,可以获取关注的数据,达到数据路由的功能。
  6. Cache同步。日志分析的结果,知道哪些Cache可以被淘汰,哪些Cache可以进行预加载,反向推动Cache的更新。
  7. 基于日志的集群监控

4.功能介绍

  MongoShake从源库抓取oplog数据,然后发送到各个不同的tunnel通道。源库支持:ReplicaSet,Sharding,Mongod,目的库支持:Mongos,Mongod。现有通道类型有:

  1. Direct:直接写入目的MongoDB
  2. RPC:通过net/rpc方式连接
  3. TCP:通过tcp方式连接
  4. File:通过文件方式对接
  5. Kafka:通过Kafka方式对接
  6. Mock:用于测试,不写入tunnel,抛弃所有数据

  消费者可以通过对接tunnel通道获取关注的数据,例如对接Direct通道直接写入目的MongoDB,或者对接RPC进行同步数据传输等。此外,用户还可以自己创建自己的API进行灵活接入。下面2张图给出了基本的架构和数据流。
pic
pic
  MongoShake对接的源数据库支持单个mongod,replica set和sharding三种模式。目的数据库支持mongod和mongos。如果源端数据库为replica set,我们建议对接备库以减少主库的压力;如果为sharding模式,那么每个shard都将对接到MongoShake并进行并行抓取。对于目的库来说,可以对接多个mongos,不同的数据将会哈希后写入不同的mongos。

4.1 并行复制

  MongoShake提供了并行复制的能力,复制的粒度选项(shard_key)可以为:id,collection或者auto,不同的文档或表可能进入不同的哈希队列并发执行。id表示按文档进行哈希;collection表示按表哈希;auto表示自动配置,如果有表存在唯一键,则退化为collection,否则则等价于id。

4.2 HA方案

  MongoShake定期将同步上下文进行存储,存储对象可以为第三方API(注册中心)或者源库。目前的上下文内容为“已经成功同步的oplog时间戳”。在这种情况下,当服务切换或者重启后,通过对接该API或者数据库,新服务能够继续提供服务。
此外,MongoShake还提供了Hypervisor机制用于在服务挂掉的时候,将服务重新拉起。

4.3 过滤功能

  提供黑名单和白名单机制选择性同步db和collection。

4.4 压缩

  支持oplog在发送前进行压缩,目前支持的压缩格式有gzip, zlib, 或deflate。

4.5 Gid

  一个数据库的数据可能会包含不同来源:自己产生的和从别处复制的数据。如果没有相应的措施,可能会导致数据的环形复制,比如A的数据复制到B,又被从B复制到A,导致服务产生风暴被打挂了。或者从B回写入A时因为唯一键约束写入失败。从而导致服务的不稳定。
  在阿里云上的MongoDB版本中,我们提供了防止环形复制的功能。其主要原理是,通过修改MongoDB内核,在oplog中打入gid标识当前数据库信息,并在复制过程中通过op_command命令携带gid信息,那么每条数据都有来源信息。如果只需要当前数据库产生的数据,那么只抓取gid等于该数据库id的oplog即可。所以,在环形复制的场景下,MongoShake从A数据库抓取gid等于id_A(A的gid)的数据,从B数据库抓取gid等于id_B(B的gid)的数据即可解决这个问题。   说明:由于MongoDB内核gid部分的修改尚未开源,所以开源版本下此功能受限,但在阿里云MongoDB版本已支持。这也是为什么我们前面提到的“MongoDB集群间数据的镜像备份”在目前开源版本下功能受限的原因。

4.6 Checkpoint

  MongShake采用了ACK机制确保oplog成功回放,如果失败将会引发重传,传输重传的过程类似于TCP的滑动窗口机制。这主要是为了保证应用层可靠性而设计的,比如解压缩失败等等。为了更好的进行说明,我们先来定义几个名词:

  • LSN(Log Sequence Number),表示已经传输的最新的oplog序号。
  • LSN_ACK(Acked Log Sequence Number),表示已经收到ack确认的最大LSN,即写入tunnel成功的LSN。
  • LSN_CKPT(Checkpoint Log Sequence Number),表示已经做了checkpoint的LSN,即已经持久化的LSN。
  • LSN、LSN_ACK和LSN_CKPT的值均来自于Oplog的时间戳ts字段,其中隐含约束是:LSN_CKPT<=LSN_ACK<=LSN。

pic
  如上图所示,LSN=16表示已经传输了16条oplog,如果没有重传的话,下次将传输LSN=17;LSN_ACK=13表示前13条都已经收到确认,如果需要重传,最早将从LSN=14开始;LSN_CKPT=8表示已经持久化checkpoint=8。持久化的意义在于,如果此时MongoShake挂掉重启后,源数据库的oplog将从LSN_CKPT位置开始读取而不是从头LSN=1开始读。因为oplog DML的幂等性,同一数据多次传输不会产生问题。但对于DDL,重传可能会导致错误。

4.7 排障和限速

  MongoShake对外提供Restful API,提供实时查看进程内部各队列数据的同步情况,便于问题排查。另外,还提供限速功能,方便用户进行实时控制,减轻数据库压力。

4.8 冲突检测

  目前MongoShake支持表级别(collection)和文档级别(id)的并发,id级别的并发需要db没有唯一索引约束,而表级别并发在表数量小或者有些表分布非常不均匀的情况下性能不佳。所以在表级别并发情况下,需要既能均匀分布的并发,又能解决表内唯一键冲突的情况。为此,如果tunnel类型是direct时候,我们提供了写入前的冲突检测功能。
  目前索引类型仅支持唯一索引,不支持前缀索引、稀疏索引、TTL索引等其他索引。
  冲突检测功能的前提需要满足两个前提约束条件:

  1. MongoShake认为同步的MongoDB Schema是一致的,也不会监听Oplog的System.indexes表的改动
  2. 冲突索引以Oplog中记录的为准,不以当前MongoDB中索引作为参考。

  另外,MongoShake在同步过程中对索引的操作可能会引发异常情况:

  1. 正在创建索引。如果是后台建索引,这段时间的写请求是看不到该索引的,但内部对该索引可见,同时可能会导致内存使用率会过高。如果是前台建索引,所有用户请求是阻塞的,如果阻塞时间过久,将会引发重传。
  2. 如果目的库存在的唯一索引在源库没有,造成数据不一致,不进行处理。
  3. oplog产生后,源库才增加或删除了唯一索引,重传可能导致索引的增删存在问题,我们也不进行处理。

  为了支持冲突检测功能,我们修改了MongoDB内核,使得oplog中带入uk字段,标识涉及到的唯一索引信息,如:

{
    "ts" : Timestamp(1484805725, 2),
    "t" : NumberLong(3),
    "h" : NumberLong("-6270930433887838315"),
    "v" : 2,
    "op" : "u",
    "ns" : "benchmark.sbtest10",
    "o" : { "_id" : 1, "uid" : 1111, "other.sid":"22222", "mid":8907298448, "bid":123 }
    "o2" : {"_id" : 1}
    "uk" : {
        	"uid": "1110""mid^bid": [8907298448, 123]
        	"other.sid_1": "22221"
    }
}

  uk下面的key表示唯一键的列名,key用”^”连接的表示联合索引,上面记录中存在3个唯一索引:uid、mid和bid的联合索引、other.sid_1。value在增删改下具有不同意义:如果是增加操作,则value为空;如果是删除或者修改操作,则记录删除或修改前的值。
  具体处理流程如下:将连续的k个oplog打包成一个batch,流水式分析每个batch之内的依赖,划分成段。如果存在冲突,则根据依赖和时序关系,将batch切分成多个段;如果不存在冲突,则划分成一个段。然后对段内进行并发写入,段与段之间顺序写入。段内并发的意思是多个并发线程同时对段内数据执行写操作,但同一个段内的同一个id必须保证有序;段之间保证顺序执行:只有前面一个段全部执行完毕,才会执行后续段的写入。
  如果一个batch中,存在不同的id的oplog同时操作同一个唯一键,则认为这些oplog存在时序关系,也叫依赖关系。我们必须将存在依赖关系的oplog拆分到2个段中。MongoShake中处理存在依赖关系的方式有2种:

4.8.1 插入barrier

通过插入barrier将batch进行拆分,每个段内进行并发。举个例子,如下图所示:
pic
ID表示文档id,op表示操作,i为插入,u为更新,d为删除,uk表示该文档下的所有唯一键, uk={a:3} => uk={a:1}表示将唯一键的值从a=3改为a=1,a为唯一键。
  在开始的时候,batch中有9条oplog,通过分析uk关系对其进行拆分,比如第3条和第4条,在id不一致的情况下操作了同一个uk={a:3},那么第3条和第4条之间需要插入barrier(修改前或者修改后无论哪个相同都算冲突),同理第5条和第6条,第6条和第7条。同一个id操作同一个uk是允许的在一个段内是允许的,所以第2条和第3条可以分到同一个段中。拆分后,段内根据id进行并发,同一个id仍然保持有序:比如第一个段中的第1条和第2,3条可以进行并发,但是第2条和第3条需要顺序执行。

4.8.2 根据关系依赖图进行拆分

  每条oplog对应一个时间序号N,那么每个序号N都可能存在一个M使得:

  • 如果M和N操作了同一个唯一索引的相同值,且M序号小于N,则构建M到N的一条有向边。
  • 如果M和N的文档ID相同且M序号小于N,则同样构建M到N的一条有向边。

  由于依赖按时间有序,所以一定不存在环。
  所以这个图就变成了一个有向无环图,每次根据拓扑排序算法并发写入入度为0(没有入边)的点即可,对于入度非0的点等待入度变为0后再写入,即等待前序结点执行完毕后再执行写入。
  下图给出了一个例子:一共有10个oplog结点,一个横线表示文档ID相同,右图箭头方向表示存在唯一键冲突的依赖关系。那么,该图一共分为4次执行:并发处理写入1,2,4,5,然后是3,6,8,其次是7,10,最后是9。
pic

5. 架构和数据流

pic
  上图展示了MongoShake内部架构和数据流细节。总体来说,整个MongoShake可以大体分为3大部分:Syncer、Worker和Replayer,其中Replayer只用于tunnel类型为direct的情况。
  Syncer负责从源数据库拉取数据,如果源是Mongod或者ReplicaSet,那么Syncer只有1个,如果是Sharding模式,那么需要有多个Syncer与Shard一一对应。在Syncer内部,首先fetcher用mgo.v2库从源库中抓取数据然后batch打包后放入PendingQueue队列,deserializer线程从PendingQueue中抓取数据进行解序列化处理。Batcher将从LogsQueue中抓取的数据进行重新组织,将前往同一个Worker的数据聚集在一起,然后hash发送到对应Worker队列。
  Worker主要功能就是从WorkerQueue中抓取数据,然后进行发送,由于采用ack机制,所以会内部维持几个队列,分别为未发送队列和已发送队列,前者存储未发送的数据,后者存储发送但是没有收到ack确认的数据。发送后,未发送队列的数据会转移到已发送队列;收到了对端的ack回复,已发送队列中seq小于ack的数据将会被删除,从而保证了可靠性。
  Worker可以对接不同的Tunnel通道,满足用户不同的需求。如果通道类型是direct,那么将会对接Replayer进行直接写入目的MongoDB操作,Worker与Replayer一一对应。首先,Replayer将收到的数据根据冲突检测规则分发到不同的ExecutorQueue,然后executor从队列中抓取进行并发写入。为了保证写入的高效性,MongoShake在写入前还会对相邻的相同Operation和相同Namespace的Oplog进行合并。

6. 用户使用案例

###6.1 高德地图   高德地图 App是国内首屈一指的地图及导航应用,阿里云MongoDB数据库服务为该应用提供了部分功能的存储支撑,存储亿级别数据。现在高德地图使用国内双中心的策略,通过地理位置等信息路由最近中心提升服务质量,业务方(高德地图)通过用户路由到三个城市数据中心,如下图所示,机房数据之间无依赖计算。
pic
  这三个城市地理上从北到南横跨了整个中国 ,这对多数据中心如何做好复制、容灾提出了挑战,如果某个地域的机房、网络出现问题,可以平滑的将流量切换到另一个地方,做到用户几乎无感知?
  目前我们的策略是,拓扑采用机房两两互联方式,每个机房的数据都将同步到另外两个机房。然后通过高德的路由层,将用户请求路由到不同的数据中心,读写均发送在同一个数据中心,保证一定的事务性。然后再通过MongoShake,双向异步复制两个数据中心的数据,这样保证每个数据中心都有全量的数据(保证最终一致性) 。如下图所示:
pic
任意机房出现问题,另两个机房中的一个可以通过切换后提供读写服务。下图展示了城市1和城市2机房的同步情况。
pic
遇到某个单元不能访问的问题,通过MongoShake对外开放的Restful管理接口,可以获得各个机房的同步偏移量和时间戳,通过判断采集和写入值即可判断异步复制是否在某个时间点已经完成。再配合业务方的DNS切流,切走单元流量并保证原有单元的请求在新单元是可以读写的,如下图所示。
pic

6.2 某跨境电商

  某跨境电商在中国和海外分别部署了2套MongoDB,其中海外主库上提供读写服务,同时用户希望把海外的数据拉到国内进行离线计算,以及承担一部分读流量,以下是该用户采用MongoShake搭建的链路方案:
pic

6.3 某著名游戏厂商

  某著名游戏厂商采用了MongoShake搭建了异地容灾链路。用户在2个机房分别部署了2套应用,正常情况下,用户流量通过北向的DNS/SLB只访问主应用,然后再访问到主MongoDB,数据通过MongoShake在2个机房的数据库之间进行同步,一旦机房1不可用,DNS/SLB将用户流量切换到备上,然后继续对外提供读写服务。
pic

6.4 采用MongoShake的开源多活方案

  这里是我们给出的根据MongoShake创建多活的方案,上文我们介绍过2个MongoDB通过MongoShake互相同步将造成回环复制,而gid部分在开源版本中未提供,所以在开源MongoDB下,可以根据控制流量分发来达到多活的需求。比如下面这个图,用户需要编写一个proxy进行流量分发(红色框),部分流量,比如对a, b库的写操作分发到左边的MongoDB,对c库的写操作分发到右边的MongoDB,源库到目的库的MongoShake链路只同步a, b库(MongoShake提供按库过滤功能),目的库到源库的MongoShake链路只同步c库。这样就解决了环形复制的问题。
  总结来说,也就是写流量通过proxy进行固定策略的分发,而读流量可以随意分发到任意MongoDB。
pic

6.5 采用MongoShake的级联同步方案

  这个是一个全球的部署的用户采用MongoShake搭建的全球混合云级联方案的示例图,有些数据库位于云上,有些位于运行,MongoShake提供了混合云不同云环境的同步,还可以直接级联方式的集群同步。
pic

7. 性能测试数据

20W QPS。具体可以参考:具体性能测试数据

8. 开源地址

https://github.com/alibaba/MongoShake

MySQL · InnoDB · Redo log

$
0
0

1. Mini Transaction(mtr)

InnoDB会将事务执行过程拆分为若干个Mini Transaction(mtr),每个mtr包含一系列如加锁,写数据,写redo,放锁等操作。举个例子:

void btr_truncate(const dict_index_t *index) {
  
  ... ...
      
  page_size_t page_size(space->flags);
  const page_id_t page_id(space_id, root_page_no);
  mtr_t mtr;
  buf_block_t *block;

  mtr.start();

  mtr_x_lock(&space->latch, &mtr);

  block = buf_page_get(page_id, page_size, RW_X_LATCH, &mtr);

  page_t *page = buf_block_get_frame(block);
  ut_ad(page_is_root(page));

  /* Mark that we are going to truncate the index tree
  We use PAGE_MAX_TRX_ID as it should be always 0 for clustered
  index. */
  mlog_write_ull(page + (PAGE_HEADER + PAGE_MAX_TRX_ID), IB_ID_MAX, &mtr);

  mtr.commit();
    
  ... ...
      
}

btr_truncate函数将一个BTree truncate到只剩root节点,具体实现这里暂不用关心,不过上面有一步是需要将root page header的PAGE_MAX_TRX_ID修改为IB_ID_MAX,这个操作算是修改page上的数据,这里就需要通过mtr来做,具体:

  • mtr.start() 开启一个mini transaction
  • mtr_x_lock() 加锁,这个操作分成两步,1. 对space->latch加X锁;2. 将space->latch放入mtr_t::m_impl::memo中(这样在mtr.commit()后就可以将mtr之前加过的锁放掉)
  • mlog_write_ull 写数据,这个操作也分成两步,1. 直接修改page上的数据;2. 将该操作的redo log写入mtr::m_impl::m_log中
  • mtr.commit() 写redo log + 放锁,这个操作会将上一步m_log中的内容写入redo log file,并且在最后放锁

以上就是一个mtr大致的执行过程,这里仅需要知道mtr.commit()是开始写redo log的地方就可以了。

2. redo log 概述

在具体看代码之前,先说下InnoDB redo log的一些特点:

  1. 顺序写,每个mtr的redo log追加到文件末尾
  2. 单个文件大小固定,写满之后会切换到下一个文件
  3. 文件个数固定,写完最后一个之后会回绕到第一个文件开始继续写。
  4. 每个文件有2K的FileHeader
  5. FileHeader之后是一个个512B的Block,每个Block包含12字节BlockHeader,4字节BlockTrailer,中间则是实际redo log的内容
  6. 概念上有一个全局递增的SN和LSN,SN对应所有写入的redo log原始内容的序列号,LSN则是原始内容包含BlockHeader和BlockTrailer之后的序列号,二者可以互相转换,LSN主要有2个用处:
    • 每个mtr的redo log都有对应的[start_lsn, end_lsn],通过换算,便可以拿到实际文件对应的写入位置
    • 每个mtr的redo log都有对应修改数据文件的脏页,拿redo log的LSN作为脏页的LSN,因为LSN有序递增,所以后台刷脏页的线程便可以根据每个脏页的LSN知道它们产生的先后顺序,根据此顺序控制落盘

3. 偏移量计算

先上图:

1

图中有两个redo log文件,ib_logfile0和ib_logfile1,每个文件4K(包含2K的FileHeader和4个512B的Block),下面就来看下InnoDB是如何使用这2个文件来管理redo log的:

第一层连续蓝色块是要写的redo log原始内容,sn作为原始内容的序列号,初始值是7936,随着写入增加,每次增加一个mtr中原始log的长度。不过实际写入文件中的并不只是原始内容,InnoDB会将原始内容按496B为单位切分,加上16B的BlockHeader和BlockTrailer,凑成一个512B的Block,这样实际写入log文件的内容是类似图中第二层的格式,一个Block接一个Block。由于sn只表示原始内容的序列号,现在加入了Header和Trailer,那么对应的变化之后的序列号就由current_file_lsn来表示,初始值为8192,sn和current_file_lsn相互转换方式如下:

// sn 转换到 lsn
// 给sn每496B都增加16B的头尾,结果即为对应的lsn
constexpr inline lsn_t log_translate_sn_to_lsn(lsn_t sn) {
  return (sn / LOG_BLOCK_DATA_SIZE * OS_FILE_LOG_BLOCK_SIZE +
          sn % LOG_BLOCK_DATA_SIZE + LOG_BLOCK_HDR_SIZE);
}

// lsn 转换到 sn
// 给lsn每512B都减去16B的头尾,再加上最后一个不完整的Block内偏移,减去
// 该Block的Header 12B,结果即为对应的sn
inline lsn_t log_translate_lsn_to_sn(lsn_t lsn) {
  /* Calculate sn of the beginning of log block, which contains
  the provided lsn value. */
  const sn_t sn = lsn / OS_FILE_LOG_BLOCK_SIZE * LOG_BLOCK_DATA_SIZE;

  /* Calculate offset for the provided lsn within the log block.
  The offset includes LOG_BLOCK_HDR_SIZE bytes of block's header. */
  const uint32_t diff = lsn % OS_FILE_LOG_BLOCK_SIZE;

  if (diff < LOG_BLOCK_HDR_SIZE) {
    /* The lsn points to some bytes inside the block's header.
    Return sn for the beginning of the block. Note, that sn
    values don't enumerate bytes of blocks' headers, so the
    value of diff does not matter at all. */
    return (sn);
  }

  if (diff > OS_FILE_LOG_BLOCK_SIZE - LOG_BLOCK_TRL_SIZE) {
    /* The lsn points to some bytes inside the block's footer.
    Return sn for the beginning of the next block. Note, that
    sn values don't enumerate bytes of blocks' footer, so the
    value of diff does not matter at all. */
    return (sn + LOG_BLOCK_DATA_SIZE);
  }

  /* Add the offset but skip bytes of block's header. */
  return (sn + diff - LOG_BLOCK_HDR_SIZE);
}

current_file_lsn同样是不断增加并且不回环的,它是redo log实际内容在逻辑上的增长,所以理论上是支持到无限大(UINT64_MAX),代表可以写入这么多redo log,但实际上的情况并非这样,redo log文件个数存在多个(图中有2个),这样无限增长的current_file_lsn需要知道什么时候换需要换下一个文件开始写,什么时候所有文件都已经写完需要回绕到第一个文件开始写。这里就需要图中第三层的转换,由于图中只有2个文件,每个文件4K,那么实际能写入的内容上限即是8K,刨除两个文件的FileHeader 4K,实际有效内容只剩4K,也就是说current_file_lsn每增长2K就要切换到下一个文件,每增长4K就要回绕到第一个文件。这里有一点需要注意,redo log这里其实对单个文件的感知并不强,在切换文件时它并不会显式的切换fd,具体切换是在fil_system里根据page_id做的,redo log模块这里认为下面的两个文件组成的实际上是一个逻辑上连续的8K空间,current_file_real_offset代表这8K内的偏移,初始值为2048(前面是第一个文件的FileHeader),初始时current_file_real_offset和current_file_lsn对应起来,即(2048 <—> 8192),之后的每次写入都同步更新这两个值,就可以完成逻辑上无限的current_file_lsn到实际有限的current_file_real_offset的映射转换。另外,current_file_end_offset代表当前在写的这个文件对应的结尾位置,如果current_file_real_offset超过这个位置就需要将其加上2K Header表示切换到下一个文件,files_real_capacity表示2个文件实际大小总和,这里即8K,如果current_file_real_offset超过这个值,代表当前2个文件都已经写完了,需要回绕到第一个文件重新写,这里就会将current_file_real_offset重新置为2048,完成回绕。

【注】:上面为了表述简单,实际current_file_lsn和current_file_real_offset以及current_file_end_offset并不是每次写入都更新,而是只在每次切换文件的时候更新,当文件未发生切换时,current_file_lsn和current_file_real_offset一一对应,代表该文件的起始lsn和real_offset,然后每次写入是通过将要写入的lsn与二者进行比较计算便可以算出要写入的real_offset完成写入,当要写入的offset大于current_file_end_offset则进行文件切换。

具体代码就不展开了,只要知道图中5个变量的含义,redo log文件组织,偏移量转换的代码就很容易看了

4. 并发写入

redo log是追加写,理论上当多个mtr同时追加redo log时,则需要加锁来确保写入顺序。8.0对这里做了无锁优化,来具体看一下。同样先上图大体感受下:

1

试想,如果每个mtr在写log之前可以拿到自己最终会写入的实际文件位置,预留好空间,那么就不需要一把大锁来严格保证日志的顺序写入了。InnoDB就是这么做的,首先在内存中有一块固定大小的log buffer,每个mtr上来先更新log_t::sn拿到自己log对应的sn区间,然后通过sn->lsn->log buffer position的转换,将自己的log内容拷贝到log buffer,由于多个mtr并发写入log buffer的不同位置,所以可能存在大lsn的mtr已经写完但小lsn的mtr还未写,即log buffer的空洞,所以就需要一个单独的线程log_writer,它负责不断扫描log buffer,发现从上次写入位置之后又有新的连续内容写入,就会将这段连续内容写入page cache,可以看到log_writer就是不断检测并刷log buffer到page cache,它是真正往文件里写入的线程。

用户线程并发写log buffer,log_writer线程扫描新的连续内容后写入page cache,二者配合是通过Link_buf来实现的,Link_buf也不难,它是一个大小固定、元素类型为std::atomic的数组,使用时通过将lsn和数组大小取模便可将lsn映射到数组

下面举个例子,假设数组大小为10,初始化为:

0 0 0 0 0 0 0 0 0 0

现在有三个用户线程并发写log,通过上面说的,每个线程fetch_add log_t::sn(加上自身长度),拿到自己对应的lsn区间,假设

mtr 1: [0 , 2]

mtr 2: [3 , 7]

mtr 3: [8, 9]

此时,mtr 2先写完log buffer,通过计算它更新了Link_buf:

0 0 0 5 0 0 0 0 0 0

在位置3写入对应log长度(这里是5),此时log_writer从起始位置扫描,发现位置0的值为0,代表还没有写入(即使位置3已经有了内容,但由于不连续,所以无法继续),之后mtr 3也写完了 log buffer,同样更新Link_buf:

0 0 0 5 0 0 0 0 2 0

在位置8写入对应log长度(这里是2),同样log_writer检测到空洞,不做任何操作,直到mtr 1也写完log buffer并更新Link_buf:

3 0 0 5 0 0 0 0 2 0

在位置0写入对应log长度(这里是3),然后log_writer从起始位置扫描到非0,代表有数据写入,然后根据该位置的长度继续往后跳,调到位置3,还是非0,继续跳到8,非0,继续跳到9,为0,停止,代表从log buffer中上次写入位置之后扫描到3个新的连续log,没有空洞,所以log_writer线程就将这连续log写入到page cache中,并更新log_t::write_lsn(代表之前的lsn都已经写入到page cache)及Link_buf的下一次扫描位置m_tail。

这样就完成了redo log的无锁写入,从上图可以看到用户线程执行

mtr_t::commit() -> mtr_t::Command::execute()->
log_buffer_writer_completed()->log_t::recent_written::add_link()

就是在对应的Link_buf recent_written中标记对应lsn已经写入log buffer,然后log_writer线程通过timeout或者其他用户线程唤醒后,执行

log_advance_ready_for_write_lsn()-> log_t::recent_written::advance_tail_until()

来扫描下一段新的连续内容,如果发现有,就就调用

log_writer_write_buffer()

写入到page cache。

上面用户线程标记完recent_written之后,会将mtr中的脏页用该mtr之前从log_t::sn拿到的序列转换为lsn对应的[start_lsn, end_lsn]作为参数加入到flush_list里,lsn对应脏页的产生顺序。这里具体以后专门总结buffer pool的时候再说。然后调用

log_buffer_close()->log_t::recent_closed::add_link()

在另一个Link_buf recent_closed中标记对应lsn,代表这个区间的脏页已经挂到flush_list上了,这里为什么还要使用Link_buf呢,因为这里挂flush_list的顺序也不是按照lsn严格有序了,它允许在recent_closed大小的范围内存在lsn空洞(即大lsn的脏页已经挂入flush_list而小lsn还未挂入),这里也有允许小范围的并发挂flush_list了,log_closer会不断检查,当发现连续lsn被标记后,先前滚动,更新recent_closed.m_tail,代表此之前的lsn脏页都已经挂入flush_list,这个值在取checkpoint的时候会用到,后面会具体说。

总之,两个Link_buf, recent_written和recent_closed的存在,使得用户线程在写log buffer和挂flush list的时候可以并发起来。

redo log的刷盘也是异步来搞的,log_flusher线程检测到log_t::flushed_to_disk_lsn < log_t::write_lsn,代表有新的数据写入到page cache但还没有刷盘,会调用log_flush_low来完成(write_lsn - flushed_to_disk_lsn)这部分日志的刷盘。

另外,从上面的图和描述中可以看到参与redo log这块的线程真的不少:

N个用户线程
1个log_writer
1个log_closer
1个log_flusher
1个log_write_notifier
1个log_flush_notifier

涉及这些线程间同步的条件变量也很多:

writer_event
write_events[]
write_notifier_event
flusher_event
flush_events[]
flush_notifier_event

下面结合上图说下他们之间的关系

  1. 用户线程,并发写入log buffer,如果写之前发现log buffer剩余空间不足,则唤醒等在writer_event上的log_writer线程来将log buffer数据写入page cache释放log buffer空间,在此期间,用户线程会等待在write_events[]上,等待log_writer线程写完page cache后唤醒,用户线程被唤醒后,代表当前log buffer有空间写入mtr对应的redo log,将其拷贝到log buffer对应位置,然后在recent_written上更新对应区间标记,接着将对应脏页挂到flush list上,并且在recent_closed上更新对应区间标记
  2. log_writer,在writer_event上等待用户线程唤醒或者timeout,唤醒后扫描recent_written,检测从write_lsn后,log buffer中是否有新的连续log,有的话就将他们一并写入page cache,然后唤醒此时可能等待在write_events[]上的用户线程或者等待在write_notifier_event上的log_write_notifier线程,接着唤醒等待在flusher_event上的log_flusher线程
  3. log_flusher,在flusher_event上等待log_writer线程或者其他用户线程(调用log_write_up_to true)唤醒,比较上次刷盘的flushed_to_disk_lsn和当前写入page cache的write_lsn,如果小于后者,就将增量刷盘,然后唤醒可能等待在flush_events[]上的用户线程(调用log_write_up_to true)或者等待在flush_notifier_event上的log_flush_notifier

  4. log_closer,不等待任何条件变量,每隔一段时间,会扫描recent_closed,先前推进,recent_closed.m_tail代表之前的lsn已经都挂在flush_list上了,用来取checkpoint时用。

还有log_write_notifier和log_flush_notifier线程,注意到上面有两个条件变量是数组:write_events[],flush_events[],他们默认有2048个slot,这么做是为什么呢?

根据上面描述可以看到,等待在这两组条件变量上的线程只有用户线程(调用log_write_up_to),每个用户线程其实只关心自己需要的lsn之前的log是否被写入,是否被刷盘,如果这里用的是一个全局条件变量,很多时候不相关lsn的用户线程会被无效唤醒,为了降低无效唤醒,InnoDB这里做了细分,lsn会被映射到对应的slot上,那么就只需要wait在该slot就可以了。这样log_writer和log_flusher在完成写page cache或者刷盘后会判断:如果本次写入的lsn区间落在同一个slot上,那么就唤醒该slot上等待的用户线程,如果跨越多个slot,则唤醒对应的log_write_notifier和log_flush_notifier线程,让他们去扫描并唤醒lsn区间覆盖的所有slot上的用户线程,这里之所以将多slot的唤醒交给专门的notifier来异步做,应该是想减小当lsn跨度过大时,log_writer和log_flusher在此处的耗时

5. Checkpoint

InnoDB在确定checkpoint时,用到将以下几个lsn:

  1. recent_closed.m_tail,它代表在此之前的lsn对应的脏页都已经挂在了flush_list上
  2. flush_list上取oldest_modification最小的lsn,它代表之前的lsn对应的脏页都已经刷到盘上
  3. flushed_to_disk_lsn,它代表此之前lsn对应的redo log都已经刷到盘上

先比较1,2,首先如果当前flush_list比较长,大于recent_closed的capacity(2M),那么肯定2<1,如果当前flush_list刷盘比较快,在recent_closed.m_tail对应的lsn之前可能都已经刷到盘上了,那么之前上面说的,由于flush_list允许有2M的空洞,此时从flush_list上取的oldest_modification的lsn需要减去recent_closed的capacity(2M),减完之后在比较1和2,取二者最小值

然后将其和flushed_to_disk_lsn比较去最小值,这个值如果大于当前的checkpoint_lsn,则该值可以作为新的checkpoint_lsn。总之checkpoint就是取数据和对应redo log都已经落盘的最小lsn。

这里有个疑问,由于recent_closed最多只能有2M空洞,也就是说flush_list的空洞始终增量限制在2M之内,任何时候从flush_list上取得oldest_modification减去2M后,这个值已经可以确保:

  1. 在此之前的lsn对应的脏页都已经挂在flush_list上了
  2. 该值已经是当前还没有落盘的数据页最小oldest_modification的lsn了

直接用它感觉就可以了,没必要和recent_closed.m_tail作比较了??

6. 总结

InnoDB redo log这里整体上分为两大块:

  1. log文件管理,lsn对应文件位置的转换;

  2. 并发写入控制,这里涉及到多个线程的同步。

整体来看这两块设计的都很巧妙:

  1. lsn管理和文件管理分层,在redo log模块来看只有逻辑无限的current_file_lsn和逻辑上连续但空间有限的current_file_real_offset,定位时只需要根据二者计算便可算出对应的逻辑offset,然后交给文件管理模块去定位到具体文件具体偏移读取,这样简化了redo log模块维护这些文件偏移的代价

  2. 使用Link_buf来进行并发写入,相比5.6一把大锁的实现美不少

MSSQL · 最佳实践 · Always Encrypted

$
0
0

摘要

在SQL Server安全系列专题月报分享中,往期我们已经陆续分享了:如何使用对称密钥实现SQL Server列加密技术使用非对称密钥实现SQL Server列加密使用混合密钥实现SQL Server列加密技术列加密技术带来的查询性能问题以及相应解决方案行级别安全解决方案SQL Server 2016 dynamic data masking实现隐私数据列打码技术使用证书做数据库备份加密这七篇文章,直接点击以上文章前往查看详情。本期月报我们分享SQL Server 2016新特性Always Encrypted技术。

问题引入

在云计算大行其道的如今,有没有一种方法保证存储在云端的数据库中数据永远保持加密状态,即便是云服务提供商也看不到数据库中的明文数据,以此来保证客户云数据库中数据的绝对安全呢?答案是肯定的,就是我们今天将要谈到的SQL Server 2016引入的始终加密技术(Always Encrypted)。

使用SQL Server Always Encrypted,始终保持数据处于加密状态,只有调用SQL Server的应用才能读写和操作加密数据,如此您可以避免数据库或者操作系统管理员接触到客户应用程序敏感数据。SQL Server 2016 Always Encrypted通过验证加密密钥来实现了对客户端应用的控制,该加密密钥永远不会通过网络传递给远程的SQL Server服务端。因此,最大限度保证了云数据库客户数据安全,即使是云服务提供商也无法准确获知用户数据明文。

具体实现

SQL Server 2016引入的新特性Always Encrypted让用户数据在应用端加密、解密,因此在云端始终处于加密状态存储和读写,最大限制保证用户数据安全,彻底解决客户对云服务提供商的信任问题。以下是SQL Server 2016 Always Encrypted技术的详细实现步骤。

创建测试数据库

为了测试方便,我们首先创建了测试数据库AlwaysEncrypted。

--Step 1 - Create MSSQL sample database
USE master
GO
IF DB_ID('AlwaysEncrypted') IS NULL
	CREATE DATABASE [AlwaysEncrypted];
GO

-- Not 100% require, but option adviced.
ALTER DATABASE [AlwaysEncrypted] COLLATE Latin1_General_BIN2;

创建列主密钥

其次,在AlwaysEncrypted数据库中,我们创建列主密钥(Column Master Key,简写为CMK)。

-- Step 2 - Create a column master key
USE [AlwaysEncrypted]
GO
CREATE COLUMN MASTER KEY [AE_ColumnMasterKey]
WITH
(
	KEY_STORE_PROVIDER_NAME = N'MSSQL_CERTIFICATE_STORE',
	KEY_PATH = N'CurrentUser/My/C3C1AFCDA7F2486A9BBB16232A052A6A1431ACB0'
)

GO

创建列加密密钥

然后,我们创建列加密密钥(Column Encryption Key,简写为CEK)。

-- Step 3 - Create a column encryption key
USE [AlwaysEncrypted]
GO

CREATE COLUMN ENCRYPTION KEY [AE_ColumnEncryptionKey]
WITH VALUES
(
	COLUMN_MASTER_KEY = [AE_ColumnMasterKey],
	ALGORITHM = 'RSA_OAEP',
	ENCRYPTED_VALUE = 0x016E000001630075007200720065006E00740075007300650072002F006D0079002F006300330063003100610066006300640061003700660032003400380036006100390062006200620031003600320033003200610030003500320061003600610031003400330031006100630062003000956D4610BE7DAEFC2E1B08D557BFF9E33FF23896BD76BB33A84560F5E4BE174D8798D86CC963BA57867404945B166D756CE87AFC9EB29EEB9E26B08115724C1724DCD449D0D14D4D5C4601A631899C733C7646EB845A816A17DB1D400B7C341C2EF5838731583B1C51A457E14692532FD7059B7F0AFF3D89BDF86FB3BB18880F6B49CD2EA6F346BA5EE130FCFCA69A71523722F824CD14B3CE2C29C9E46074F2FE36265450A0424F390C2BC32B724FAB674E2B58DB16347B842597AFEBE983C7F4F51BCC088292219BD6F6E1F092BD77C5AD80331770E0B0B8BF6428D2719560AF56780ECE8805F7B425818F31CF54C84FF11114DB693B6CB7D499B1490B8E155749329C9A7AF4417E2A17D0EACA92CBB59A4EE314C54BCD83F80E8D6363F9CF66D8608772DCEB5D3FF4C8A131E21984C2370AB0788E38CB330C1D6190A7513BE1179432705C0C38B9430FC7A8D10BBDBDBA4AC7A7E24D2E257A0B8B79AC2B6D7E0C2F2056F58579E96009C488F2C1C691B3DC9E2F5D538D2E96BB4E8DB280F3C0461B18ADE30A3A5C5279C6861E3109C8EEFE4BC8192338137BBF7D5BFD64A689689B40B5E1FB7A157D06F6674C807515255C0F124ED866D9C0E5294759FECFF37AEEA672EF5C3A7649CAA8B55288526DF6EF8EB2D7485601E9A72CFA53D046E200320BAAD32AD559C644018964058BBE9BE5A2BAFB28E2FF7B37C85B49680F
)

GO

检查CMK和CEK

接下来,我们检查下刚才创建的列主密钥和列加密密钥,方法如下:

-- Step 4 - CMK & CEK Checking
select * from sys.column_master_keys
select * from sys.column_encryption_keys
select * from sys.column_encryption_key_values

一切正常,如下截图所示:

01.png

当然,您也可以使用SSMS的IDE来查看Column Master Key和Column Encryption Key,方法是: 展开需要检查的数据库 -> Security -> Always Encrypted Keys -> 展开Column Master Keys和 Column Encryption Keys。如下图所示:

02.png

创建Always Encryped测试表

下一步,我们创建Always Encrypted测试表,代码如下:

-- Step 5 -  Create a table with an encrypted column

USE [AlwaysEncrypted]
GO
IF OBJECT_ID('dbo.CustomerInfo', 'U') IS NOT NULL
	DROP TABLE dbo.CustomerInfo
GO
CREATE TABLE dbo.CustomerInfo
(
CustomerId		INT IDENTITY(10000,1)	NOT NULL PRIMARY KEY,
CustomerName	NVARCHAR(100) COLLATE Latin1_General_BIN2 
	ENCRYPTED WITH (
		ENCRYPTION_TYPE = DETERMINISTIC, 
		ALGORITHM = 'AEAD_AES_256_CBC_HMAC_SHA_256', 
		COLUMN_ENCRYPTION_KEY = AE_ColumnEncryptionKey
	) NOT NULL,
CustomerPhone	NVARCHAR(11)  COLLATE Latin1_General_BIN2
	ENCRYPTED WITH (
	ENCRYPTION_TYPE = RANDOMIZED, 
	ALGORITHM = 'AEAD_AES_256_CBC_HMAC_SHA_256', 
	COLUMN_ENCRYPTION_KEY = AE_ColumnEncryptionKey
	) NOT NULL
 )
;
GO

在创建Always Encrypted测试表过程中,对于加密字段,我们指定了:

 加密类型:DETERMINISTIC和RANDOMIZED。

 算法:AEAD_AES_256_CBC_HMAC_SHA_256是Always Encrypted专有算法。

 加密密钥:创建的加密密钥名字。

导出服务器端证书

最后,我们将服务端的证书导出成文件,方法如下: Control Panel –> Internet Options -> Content -> Certificates -> Export。如下图所示:

03.png

导出向导中输入私钥保护密码。

04.png

选择存放路径。

05.png

最后导出成功。

应用程序端测试

SQL Server服务端配置完毕后,我们需要在测试应用程序端导入证书,然后测试应用程序。

客户端导入证书

客户端导入证书方法与服务端证书导出方法入口是一致的,方法是:Control Panel –> Internet Options -> Content -> Certificates -> Import。如下截图所示:

06.png

然后输入私钥文件加密密码,导入成功。

测试应用程序

我们使用VS创建一个C#的Console Application做为测试应用程序,使用NuGet Package功能安装Dapper,做为我们SQL Server数据库操作的工具。 注意:仅.NET 4.6及以上版本支持Always Encrypted特性的SQL Server driver,因此,请确保您的项目Target framework至少是.NET 4.6版本,方法如下:右键点击您的项目 -> Properties -> 在Application中,切换你的Target framework为.NET Framework 4.6。

07.png

为了简单方便,我们直接在SQL Server服务端测试应用程序,因此您看到的连接字符串是连接本地SQL Server服务。如果您需要测试远程SQL Server,修改连接字符串即可。整个测试应用程序代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Dapper;
using System.Data;
using System.Data.SqlClient;

namespace AlwaysEncryptedExample
{
    public class AlwaysEncrypted
    {
        public static readonly string CONN_STRING = "Column Encryption Setting = Enabled;Server=.,1433;Initial Catalog=AlwaysEncrypted;Trusted_Connection=Yes;MultipleActiveResultSets=True;";
        public static void Main(string[] args)
        {
            List<Customer> Customers = QueryCustomerList<Customer>(@"SELECT TOP 3 * FROM dbo.CustomerInfo WITH(NOLOCK)");

            // there is no record
            if(Customers.Count == 0)
            {
                Console.WriteLine("************There is no record.************");
                string execSql = @"INSERT INTO dbo.CustomerInfo VALUES (@customerName, @cellPhone);";

                Console.WriteLine("************Insert some records.************");

                DynamicParameters dp = new DynamicParameters();
                dp.Add("@customerName", "CustomerA", dbType: DbType.String, direction: ParameterDirection.Input, size: 100);
                dp.Add("@cellPhone", "13402871524", dbType: DbType.String, direction: ParameterDirection.Input, size: 11);

                DoExecuteSql(execSql, dp);

                Console.WriteLine("************re-generate records.************");
                Customers = QueryCustomerList<Customer>(@"SELECT TOP 3 * FROM dbo.CustomerInfo WITH(NOLOCK)");
            }
            else
            {
                Console.WriteLine("************There are a couple of records.************");
            }

            foreach(Customer cus in Customers)
            {
                Console.WriteLine(string.Format("Customer name is {0} and cell phone is {1}.", cus.CustomerName, cus.CustomerPhone));
            }

            Console.ReadKey();
        }

        public static List<T> QueryCustomerList<T>(string queryText)
        {
            // input variable checking
            if (queryText == null || queryText == "")
            {
                return new List<T>();
            }
            try
            {
                using (IDbConnection dbConn = new SqlConnection(CONN_STRING))
                {
                    // if connection is closed, open it
                    if (dbConn.State == ConnectionState.Closed)
                    {
                        dbConn.Open();
                    }

                    // return the query result data set to list.
                    return dbConn.Query<T>(queryText, commandTimeout: 120).ToList();
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine("Failed to execute {0} with error message : {1}, StackTrace: {2}.", queryText, ex.Message, ex.StackTrace);
                // return empty list
                return new List<T>();
            }
        }

        public static bool DoExecuteSql(String execSql, object parms)
        {
            bool rt = false;

            // input parameters checking
            if (string.IsNullOrEmpty(execSql))
            {
                return rt;
            }

            if (!string.IsNullOrEmpty(CONN_STRING))
            {
                // try to add event file target
                try
                {
                    using (IDbConnection dbConn = new SqlConnection(CONN_STRING))
                    {
                        // if connection is closed, open it
                        if (dbConn.State == ConnectionState.Closed)
                        {
                            dbConn.Open();
                        }

                        var affectedRows = dbConn.Execute(execSql, parms);

                        rt = (affectedRows > 0);
                    }
                }
                catch (Exception ex)
                {
                    Console.WriteLine("Failed to execute {0} with error message : {1}, StackTrace: {2}.", execSql, ex.Message, ex.StackTrace);
                }
            }

            return rt;
        }

        public class Customer
        {
            private int customerId;
            private string customerName;
            private string customerPhone;

            public Customer(int customerId, string customerName, string customerPhone)
            {
                this.customerId = customerId;
                this.customerName = customerName;
                this.customerPhone = customerPhone;
            }

            public int CustomerId
            {
                get
                {
                    return customerId;
                }

                set
                {
                    customerId = value;
                }
            }

            public string CustomerName
            {
                get
                {
                    return customerName;
                }

                set
                {
                    customerName = value;
                }
            }

            public string CustomerPhone
            {
                get
                {
                    return customerPhone;
                }

                set
                {
                    customerPhone = value;
                }
            }
        }
    }
}

我们在应用程序代码中,仅需要在连接字符串中添加Column Encryption Setting = Enabled;属性配置,即可支持SQL Server 2016新特性Always Encrypted,非常简单。为了方便大家观察,我把这个属性配置放到了连接字符串的第一个位置,如下图所示:

08.png

运行我们的测试应用程序,展示结果如下图所示:

09.png

从应用程序的测试结果来看,我们可以正常读、写Always Encrypted测试表,应用程序工作良好。那么,假如我们抛开应用程序使用其它方式能否读写该测试表,看到又是什么样的数据结果呢?

测试SSMS

假设,我们使用SSMS做为测试工具。首先读取Always Encrypted测试表中的数据:

-- try to read Always Encrypted table and it'll show us encrypted data instead of the plaintext.
USE [AlwaysEncrypted]
GO
SELECT * FROM dbo.CustomerInfo WITH(NOLOCK)

展示结果如下截图:

10.png

然后,使用SSMS直接往测试表中插入数据:

-- try to insert records to encrypted table, will be fail.
USE [AlwaysEncrypted]
GO 
INSERT INTO dbo.CustomerInfo 
VALUES ('CustomerA','13402872514'),('CustomerB','13880674722')
GO

会报告如下错误:

Msg 206, Level 16, State 2, Line 74
Operand type clash: varchar is incompatible with varchar(8000) encrypted with (encryption_type = 'DETERMINISTIC', encryption_algorithm_name = 'AEAD_AES_256_CBC_HMAC_SHA_256', column_encryption_key_name = 'AE_ColumnEncryptionKey', column_encryption_key_database_name = 'AlwaysEncrypted') collation_name = 'Chinese_PRC_CI_AS'

如下截图:

11.png

由此可见,我们无法使用测试应用程序以外的方法读取和操作Always Encrypted表的明文数据。

测试结果分析

从应用程序读写测试和使用SSMS直接读写Always Encrypted表的测试结果来看,用户可以使用前者正常读写测试表,工作良好;而后者无法读取测试表明文,仅可查看测试表的加密后的密文数据,加之写入操作直接报错。

测试应用源代码

如果您需要本文的测试应用程序源代码,请点击下载

最后总结

本期月报,我们分享了SQL Server 2016新特性Always Encrypted的原理及实现方法,以此来保证存储在云端的数据库中数据永远保持加密状态,即便是云服务提供商也看不到数据库中的明文数据,以此来保证客户云数据库的数据绝对安全,解决了云数据库场景中最重要的用户对云服务提供商信任问题。

MySQL · 源码分析 · CHECK TABLE实现

$
0
0

前言

MySQL利用CHECK TABLE检查一张表或数张表的正确性,也可以用于检查视图的正确性,例如视图定义中引用的表是否存在。CHECK TABLE同时支持InnoDB,MyISAM,ARCHIVE和CSV表。

/* CHECK TABLE语法 */
CHECK TABLE tbl_name [, tbl_name] ... [option] ...
option: {FOR UPGRADE | QUICK | FAST | MEDIUM | EXTENDED  | CHANGED}
/* CHECK TABLE同样支持分区表 */
ALTER TABLE ... CHECK PARTITION

检测版本兼容性

FOR UPGRADE选项用于检测表与当前版本MySQL的兼容性。它用于检测在创建表之后,是否在数据类型或者索引上发生了一些不兼容的修改操作。如果检测到一些不兼容的操作,它会在表上执行完整的检测过程,这需要较长的检测时间。不兼容性可能在数据类型的存储格式发生变化或者它的排序顺序发生变化时发生,比如在MySQL 5.0.3和5.0.5两个版本间DECIMAL类型存储结构的变化,在MySQL 4.1和5.0两个版本间TEXT列索引顺序的变化。

检测数据一致性

CHECK TABLE还提供了一些其它检查选项,这些选项信息被传递到存储引擎层,用于检测数据的一致性:

/* 类型: 含义 */
QUICK: 不扫描记录去检查索引结构的正确性,适用于InnoDB/MyISAM
FAST:  只检查哪些没有被正常关闭的表,仅适用于MyISAM
CHANGED: 检查那些在没有被正常关闭或上一次检查后被修改的表,仅适用于MyISAM
MEDIUM: 扫描记录验证那些删除链接的正确性,同时验证checksum的正确性,仅适用于MyISAM
EXTENDED: 扫描所有的记录,确保整张表数据100%的正确性,需要较长的执行时间。仅适用于MyISAM

如果没有指定QUICK,MEDIUM或者EXTENED,在MyISAM中默认的检查类型是MEDIUM。这些检测选项也可以组合使用,例如CHECK TABLE test_table FAST QUICK,在表上执行一个快速的检查去检测它是否被正常关闭。但在InnoDB中,它只有QUICK和非QUICK两种类型。本文以InnoDB为代表,下面分析CHECK TABLE在InnoDB中的注意事项。

CHECK TABLE在InnoDB中的注意事项

如果CHECK TABLE遇到损坏的页面,MySQL实例将退出以防止错误的传播(Bug #10132)。如果数据损坏发生在二级索引中,但表数据依然是可读的,运行CHECK TABLE仍将导致MySQL实例停止。

如果CHECK TABLE在主键索引中遇到错误的DB_TRX_ID或DB_ROLL_PTR项,CHECK TABLE将导致InnoDB访问到一个错误的undo log日志记录,导致MVCC相关服务崩溃。

如果CHECK TABLE遇到innoDB表或索引中的错误(错误包括二级索引中不正确的条目数或者错误的链接),它将报告这个错误,并标记索引/表的状态,避免使用这个索引或者表。

CHECK TABLE检查索引页结构,然后检查每个条目,但它不检查指向主键记录的键指针或遵循BLOB指针的指针。

当一个InnoDB表存储在自己的.ibd文件中时,.ibd文件的前3页包含的是头部元数据,而不是表或索引数据。CHECK TABLE语句不检测这部分数据的不一致性。要验证innodb.ibd文件的全部内容,请使用innochecksum命令。

在大型表上运行CHECK TABLE时,可能会在执行CHECK TABLE期间阻塞其他线程。为了避免超时,CHECK TABLE操作的信号量等待阈值(600秒)将延长2小时(7200秒)。如果InnoDB检测到信号量等待240秒或更长时间,它将开始向错误日志打印监控信息。如果锁请求超出信号量等待阈值,InnoDB将中止进程。

从MySQL 8.0.14开始,InnoDB支持并行访问主键索引,这有效提高了CHECK TABLE操作的性能。InnoDB在CHECK TABLE期间读取主键索引两次,第二次读取可以并行执行。要并行访问主键索引,必须将innodb_parallel_read_threads变量设置为大于1的值(默认值为4)。并行访问主键索引的线程数由innodb_parallel_read_threads设置或要扫描的索引子树数确定,以较小的值为准。

本文以MySQL 8.0.14代码为例,分析CHECK TABLE的实现。

CHECK TABLE的代码实现

int ha_innobase::check(THD *thd, HA_CHECK_OPT *check_opt) 
{
  ...
  /* 如果表已经被标记为corrupted状态,就不需要再检查任何一个索引 */
  if (m_prebuilt->table->is_corrupted()) {
    if (thd_killed(m_user_thd)) {
      thd_set_kill_status(m_user_thd);
    }
    DBUG_RETURN(HA_ADMIN_CORRUPT);
  }
  /* 设置事务的隔离级别 */
  m_prebuilt->trx->isolation_level = TRX_ISO_REPEATABLE_READ;
  /* 遍历所有索引 */
  for (index = m_prebuilt->table->first_index(); index != NULL;
    index = index->next()) {
    /* 如果索引没有标记为corrupted并且check table的选项不是QUICK */
    if (!(check_opt->flags & T_QUICK) && !index->is_corrupted()) {
      /* 增大CHECK TABLE期间锁等待的时间 */
      os_atomic_increment_ulint(&srv_fatal_semaphore_wait_threshold,
        SRV_SEMAPHORE_WAIT_EXTENSION);
      /* 检查索引的一致性,这是非QUICK与QUICK的主要区别 */
      btr_validate_index(index, m_prebuilt->trx, false);
      /* 恢复锁等待的时间 */
      os_atomic_decrement_ulint(&srv_fatal_semaphore_wait_threshold,
        SRV_SEMAPHORE_WAIT_EXTENSION);
    }
    m_prebuilt->index/index_usable/sql_stat_start/template_type/n_template/.. = ..
    /* 设置并行线程数 */
    size_t n_threads = thd_parallel_read_threads(m_prebuilt->trx->mysql_thd);
    /* 并行扫描索引 */
    row_scan_index_for_mysql(m_prebuilt, index, n_threads, true, &n_rows);
    ... 
  }
  /* 恢复事务的隔离级别 */
  m_prebuilt->trx->isolation_level = old_isolation_level;
  ...
}
/* 检查索引结构的一致性 */
bool btr_validate_index(dict_index_t *index, const trx_t *trx, bool lockout)
{
  ...
  bool ok = true;
  mtr_t mtr;
  mtr_start(&mtr);
  /* 持有index的sx或者x锁 */
  if (lockout) mtr_x_lock(dict_index_get_lock(index), &mtr);
  else mtr_x_lock(dict_index_get_lock(index), &mtr) 
  /* 获取索引的根节点 */
  page_t *root = btr_root_get(index, &mtr);
  /* 获得树高 */
  ulint n = btr_page_get_level(root, &mtr);
  /* 验证每一层树结构的正确性 */
  for (ulint i = 0; i <= n; ++i) {
    if (!btr_validate_level(index, trx, n - i, lockout)) {
      ok = false;
      break;
    }
  }
  mtr_commit(&mtr);
  return ok;
}
/* 验证每一层树结构的正确性 */
static bool btr_validate_level(dict_index_t *index, const trx_t *trx, ulint level, bool lockout) 
{
  ...
  mtr_start(&mtr);
  mtr_sx_lock or mtr_x_lock(dict_index_get_lock(index), &mtr);
  /* 获得索引根节点的block/page/seg */
  block = btr_root_block_get(index, RW_SX_LATCH, &mtr);
  ...
  /* 遍历B-Tree,直到访问到指定的层次 */
  while (level != btr_page_get_level(page, &mtr))
    ...
  }
loop:
  ...
  /* 获取左右页号 */
  right_page_no = btr_page_get_next(page, &mtr);
  left_page_no = btr_page_get_prev(page, &mtr);
  /* 如果没有访问到这一层最后一个节点 */
  if (right_page_no != FIL_NULL) {
    /* 1. 根据right_page_no获取right_block / right_page, 检查链表指针的正确性 */
    /* 2. 检查page存储格式的正确性 */
    /* 3. 检查记录的有序性 */
    ...
  }
  /* 检查记录与父节点指针的正确性,并移动到下一个记录 */
  ...
node_ptr_fails:
  mtr_commit(&mtr);
  ...
  /* 如果没有到达这一层最后一个树节点,就goto loop */
  goto loop;
}

除了上述非QUICK选项需要执行的索引结构检查,CHECK TABLE还需要执行row_scan_index_for_mysql函数,确保所有记录的顺序是正确的。这部分代码在MySQL 8.0中有较大的变化,当扫描操作是非堵塞的并且–innodb-parallel-read-threads大于1时,它将索引划分成多个子树,支持多线程扫描。

/* 针对COUNT(*)或者CHECK TABLE扫描索引。如果是CHECK TABLE,检查所有记录的顺序 */
dberr_t row_scan_index_for_mysql(row_prebuilt_t *prebuilt, const dict_index_t *index, size_t n_threads, bool check_keys, ulint *n_rows)
{
  ...
  /* 进行一系列检查,满足条件后执行多线程CHECK TABLE */
  if (prebuilt->select_lock_type == LOCK_NONE && index->is_clustered() &&
     (check_keys || prebuilt->trx->mysql_n_tables_locked == 0) &&
     !prebuilt->ins_sel_stmt && n_threads > 1) {
       /* 开启事务,设置视图 */
       trx_start_if_not_started_xa(prebuilt->trx, false);
       trx_assign_read_view(prebuilt->trx);
       /* 注册按照key值分区的reader对象 */
       Key_reader reader(prebuilt->table, trx, index, prebuilt, n_threads);
       /* 进入多线程检查函数 */
       if (!check_keys) {
         return (parallel_select_count_star(reader, n_rows));
       }
       return (parallel_check_table(reader, n_rows));
  }
  /* 以下单线程处理部分和5.6源码类似 */
  ...
  /* 定位到index的起始cursor */
  row_search_for_mysql(buf, PAGE_CUR_G, prebuilt, 0, 0);
loop:  
  /* 比较rec的大小,确保有序的状态是正确的 */
  ...
next_rec:
  /* 获取下一个rec */
  ret = row_search_for_mysql(buf, PAGE_CUR_G, prebuilt, 0, ROW_SEL_NEXT);
  /* 循环执行 */
  goto loop;
}

Key_reader在持有index的SX锁情况下,针对所有子树创建cursor,然后释放index的SX锁。子树的扫描过程为:

  1. 从根结点开始读取每一层最左边的树节点;

  2. 如果这一层能划分的子树数量少于指定线程数,就继续往下搜索。划分的方法包括按照page或者key值划分,分别在Phy_reader/Key_reader中实现;

  3. 否则,使用该层,根据该层的最左记录向下查找直到叶子节点,然后开始扫描叶节点。

我们以parallel_check_table函数为例,分析多线程代码实现,具体原理读者可以查询 WL#11720: InnoDB: Parallel read of index

static dberr_t parallel_check_table(Key_reader &reader, ulint *n_rows) 
{
  /* 初始化一系列的容器,例如Counter::Shards n_recs/n_dups/n_corrupt, std::vector类型的Tuples/Heaps/Blocks等等 */
  ... 
  /* 注册reader对象的回调函数,从而线程知道如何处理得到的每一行 */
  err = reader.read([&](size_t id, const buf_block_t *block, const rec_t *rec,
    dict_index_t *index, row_prebuilt_t *prebuilt) {
    ...
    auto heap = heaps[id];
    auto prev_tuple = prev_tuples[id];
    auto offsets = rec_get_offsets(rec, index, nullptr, ULINT_UNDEFINED, &heap);
    /* 比较rec和prev_tuple */
    auto cmp = cmp_dtuple_rec_with_match(prev_tuple, rec, index, offsets, &matched_fields);
    /* 根据cmp结果,判断是否出现顺序出错或者重复key的问题 */
    ... 
    /* 将这个rec和block记录到prev_blocks/prev_tuples中后返回 */
    ...
    return (DB_SUCCESS);
  }
  /* 收尾的一些工作 */
  ...
}

本文初步分析了CHECK TABLE的功能与实现,后续笔者会详细分析并行查询的代码实现与优化空间。欢迎大家持续关注内核月报。


PgSQL · 原理介绍 · PostgreSQL中的空闲空间管理

$
0
0

背景

PostgreSQL的MVCC机制中,更新和删除操作并不是对原有的数据空间进行操作,而是通过对元组(tuple)的多版本形式来实现的。而由此引发了过期数据的问题,即当一个版本的元组对所有事物都不可见时,那么它就是过期的,此时它占用的空间是可以被释放的。

上述过期空间的释放工作是交给VACCUM来进行的。在这个过程中,VACCUM会将数据页上的过期元组的空间标记为可用,而当有新的数据插入时,也会优先使用这些可用空间。因此如何将这些可用空间管理起来,并在需要的时候能够高效地分配出去是一个需要解决的问题。

数据结构

PostgreSQL 8.4 引入了FSM(Free Space Map)结构来管理数据页中的空闲空间。FSM是存在以_fsm为后缀的文件中的,每个表都有一个对应的fsm文件。fsm文件的初始大小为24KB,在表创建以后的第一次VACCUM操作中被创建,而且在接下来的每次VACCUM操作中被更新。

$ll $PG_DATA/base/13878/
total 7824
-rw------- 1 postgres postgres  73728 Mar 19 19:26 1247
-rw------- 1 postgres postgres  24576 Mar 19 18:12 1247_fsm

FSM的存在的意义就是为了管理空闲资源,并且让它们可以快速地被再次使用,所以结构的设计要以小而快的目标。FSM的空间管理中,没有细粒度到数据页的每个比特,而是将最小单元定义为页大小(BLCKSZ)的256分之一,也就是说,在默认8KB数据页的大小下,从FSM的角度观察,它有256个单元。所以,为了表述这个256个单元的状态,FSM为每个数据页分配了一个字节的空间。这也是FSM在设计时,一个空间和时间的折中选择。

FSM页结构

为了可以快速去查找的需要的空间,FSM在对数据的组织上没有采用类似数组的线性数据结构,而是选择了树形结构来组织。在一般的空闲查询操作中,调用者想知道的就是当前能不能满足我的空闲需求,FSM中是将每个页的空余空间信息通过一个大根堆的形式组织的。在堆的结构下,调用者想要知道是否有满足需求的空间,只需要从堆的根获取到当前最大的空余空间就可以快速的判断,减少了整体的判断次数,提高效率。FSM页中堆的结构如下图所示:

pic

堆中的每个叶子节点都对应一个数据页,叶子节点上记录的是数据页的可用单元的个数,例如,上图中P1中当前包含了6个空闲单元。每个非叶子节点上的记录的则是它的子节点中较大的可用数目。实例的FSM页中,不一定是一个满二叉树的形式,在叶子节点的最右侧是可能存在空缺的,但是可以保证的是堆所需要的完全二叉树的组织方式,只要是叶子节点,都有相对应的数据页。

这样的结构提供了两个基本操作:更新和查找。我们以上面的图示为里介绍一下这两种操作:

  • 当数据页P3的可用单元数量发生变化为5时(执行完VACCUM操作),先将它对应的叶子节点的记录由0更新为5,然后向上寻找父节点,父节点根据当前子节点的记录(5和3)选择较大的5更新为自己的记录,继续类似的操作递归更新直至根节点,这样的操作也是堆结构中一个典型的调整操作;

pic

  • 当调用方想要找到可以满足自己4个单元需求的数据页时,会先从FSM页的根开始进行比较,发现6大于自己的需求(如果不满足需求这时就可以返回了),则从子节点(6和3)中选择满足需求的左子节点,类似的比较递归向下,当出现两个子节点都可以满足需求的情况时,可以根据自身的策略来选择,以更接近需求的策略来选择的话,整个查找的过程如下图。

pic

这样一个大根堆的结构,在实际存储的时候是以以为数组的形式保存的,利用完全二叉树中父子节点的关系来进行堆节点的访问。在如下图所示的数组中,每个元素对应堆中的一个节点。以某个非叶子节点为例,假设这个节点在数组中的序号为n,那么它的左子节点的序号则为n * 2,右子节点的序号则为n * 2 + 1;相反的,如果某个节点的序号为n,那么它的父节点的序号则为n / 2

pic

Higer-Level

为了把FSM页管理起来,FSM在不同的FSM页间页维护了一个类似的树形结构,PostgreSQL中称这种组织结构相较于FSM页来说是一种“Higher-level structure”。

pic

如上图所示,在Higher-Level的结构中,每个FSM页中的叶子节点对应的不仅是数据页,也可能是另外一个FSM页。当叶子节点对应的是FSM页时,逻辑是类似的,节点保存的是整个子FSM页中根节点的记录数(也就是该FSM中最大的可用单元数)。按照这样的关系,FSM页间组织不再是类似FSM页内的二叉树形式,而是多叉树。

一个FSM页大概可以存下(BLCKSZ - HeaderSize) / 2个数据页的可用空间信息,在默认8KB的页大小下,每个页大约可保存4000个数据页的信息。FSM页作为树形结构的节点,那么这个节点可以关联4000个子节点,以这样的规模扩展,只需要3层就可以管理其一个表的全部数据页。因为三层的FSM页可以管理的数据页数量约为4000^3,而PostgreSQL中每个表的数据页上限为2^32 - 14000^3 > 2^32

在Higher-Level结构中定位一个数据页时需要用到三个概念:

  • 层(level)
  • 页序号(page number)
  • 页槽(slot)

全部的叶子FSM页都在0层,它们的父FSM页在1层,根FSM页在2层。每层中FSM页的序号就是这个页在这一层的顺序位置。

实现分析

接下来,就从代码的角度来分析下FSM的定义和操作。

结构体

首先,先看一下FSM页的定义:

typedef struct
{
	int			fp_next_slot;
	uint8		fp_nodes[FLEXIBLE_ARRAY_MEMBER];
} FSMPageData;

其中的fp_next_slot是指向了上次搜索到的slot的位置,接下来这个page的每次搜索都会从fp_next_slot标识的位置开始。这样的设定是为了:

  1. 可以使得不同的backend不至于同时在一个页中搜索导致争抢;
  2. 在多个backend的访问时可以给与多个请求一个尽可能连续的内存空间,这样也有利于操作系统去进行预取(prefetch)和批量写。

fp_node则是存储当前FSM也中的堆结构,因为是完全二叉树的形式,所以是可以按层遍历依次放入到一维数组中的,这样也可以通过父子节点的下表关系方便的在堆中进行移动。

可用页查找操作

接下来就是常用的两个操作:查找和更新。FSM页的查找操作对应的是fsm_search_avail函数,它的逻辑如下:

int
fsm_search_avail(Buffer buf, uint8 minvalue, bool advancenext,
				 bool exclusive_lock_held)
	...
	
	if (fsmpage->fp_nodes[0] < minvalue)   // 如果堆根不满足要求,那么不用继续查找了
		return -1;

	target = fsmpage->fp_next_slot;   // 从上次查找到的slot开始查找
	if (target < 0 || target >= LeafNodesPerPage)
		target = 0;
	target += NonLeafNodesPerPage;

	nodeno = target;
	while (nodeno > 0)
	{
		if (fsmpage->fp_nodes[nodeno] >= minvalue)   //如果找到满足要求的节点,则跳出
			break;
		nodeno = parentof(rightneighbor(nodeno));   // 否则尝试去它的父节点寻找
	}
	
	// 从找到的非叶子节点开始向下去找满足空间的叶子节点
	while (nodeno < NonLeafNodesPerPage)
	{
		int			childnodeno = leftchild(nodeno);

		if (childnodeno < NodesPerPage &&
			fsmpage->fp_nodes[childnodeno] >= minvalue)   // 如果左子节点满足,则从左子节点继续向下找
		{
			nodeno = childnodeno;
			continue;
		}
		childnodeno++;			/* point to right child */
		if (childnodeno < NodesPerPage &&
			fsmpage->fp_nodes[childnodeno] >= minvalue)   // 否则,如果右子节点满足,则从右子节点向下
		{
			nodeno = childnodeno;
		}
		else   // 如果父节点满足,但孩子节点都不满足,则需要更新将FSM锁定,然后重新从叶子节点开始更新整个堆
		{
			RelFileNode rnode;
			ForkNumber	forknum;
			BlockNumber blknum;

			BufferGetTag(buf, &rnode, &forknum, &blknum);

			if (!exclusive_lock_held)   // 尝试锁定当前FSM页
			{
				LockBuffer(buf, BUFFER_LOCK_UNLOCK);
				LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE);
				exclusive_lock_held = true;
			}
			fsm_rebuild_page(page);   // 重建页结构
			MarkBufferDirtyHint(buf, false);
			goto restart;
		}
	}
	
	fsmpage->fp_next_slot = slot + (advancenext ? 1 : 0);

	return slot;
}

上面就是在一个FSM页中的查找过程:

  • 如果根节点的记录不满足要求,那么不用继续查找了,当前页上没有满足要求的slot;
  • 查找的起点是上次找到满足要求的page对应的slot;
  • 然后从当前slot开始向上寻找,直到有节点的空闲资源满足要求,如果没有满足要求的节点,最终会找到根节点;
  • 从当前的非叶子节点开始向下找:
    • 如果左子节点满足则从左侧向下寻找;
    • 如果右子节点满足,则从右侧向下寻找;
    • 否则,说明父节点的记录和孩子节点的记录不匹配,需要锁定当前FSM页,重新更新堆结构,然后从第一步开始重试;
  • 找到之后,将当前FSM的fp_next_slot更新。

上面介绍的是,在FSM页内的查找过程,Higher Level的超找逻辑,在fsm_search()中,如下:

static BlockNumber
fsm_search(Relation rel, uint8 min_cat)
{
	int			restarts = 0;
	FSMAddress	addr = FSM_ROOT_ADDRESS;   // 从根页开始查找

	for (;;)
	{
		...

		if (BufferIsValid(buf))
		{
			LockBuffer(buf, BUFFER_LOCK_SHARE);
			slot = fsm_search_avail(buf, min_cat,
									(addr.level == FSM_BOTTOM_LEVEL),
									false);   // 在当前FSM页中查找可用空间
			if (slot == -1)
				max_avail = fsm_get_max_avail(BufferGetPage(buf));
			UnlockReleaseBuffer(buf);
		}
		else
			slot = -1;

		if (slot != -1)
		{
			if (addr.level == FSM_BOTTOM_LEVEL)   // 如果已经查找到第0层,则返回找到的数据页信息
				return fsm_get_heap_blk(addr, slot);

			addr = fsm_get_child(addr, slot);  // 否则,继续向下寻找
		}
		else if (addr.level == FSM_ROOT_LEVEL)   // 如果在第二层页没有满足要求,则找不到满足要求的数据页
		{
			return InvalidBlockNumber;
		}
		...
	}
}

在FSM页间的查找和页内的查找逻辑是类似的,只不过将其放大到了页间的逻辑中,步骤如下:

  • 从第2层的根页开始查找;
  • 若当前页找到满足要求的slot:
    • 如果当前是最底层,第0层,那么找到的slot可以转换为数据页的信息输出,查找结束;
    • 否则,继续向下寻找;
  • 若当前页没有找到满足要求的slot:
    • 如果当前页是最顶层,则说明现在没有满足需求的数据页,返回;
    • 如果不是,则说明父页的记录和子页不一致,尝试修复,然后重试查找;

结构恢复

从上面的查找逻辑中可以看到,不论是页内还是页间都可能出现父子节点(或页)的记录不一致的情况:

  • 在FSM页内,可能由于系统Crash,导致FSM页在只有部分数据被更新到磁盘的情况下,会出现不一致;
  • 在FSM页间,可能由于子页出现的更新还反馈更新到父页导致。

不论是哪种情况,都可以通过从底层数据重新向上更新的办法来修复。另外,定期的VACCUM操作也会更新最低层的记录,同时触发向上的更新,也是一种定期修复FSM的方式。FSM对准确度的要求并不高,它可以尽量尝试维护一个最新的可用空间的记录,但不保证它当前的记录一定是完全准确的,但是在运行中会有多种方式来不断的修复结构本身。

参考文献

MySQL · 引擎特性 · 8.0 Descending Index

$
0
0

前言

在MySQL8.0之前的版本中,innodb btree索引中的记录都是严格按照的key的顺序来存储的,但有些时候当我们需要倒序扫描时,效率就会很低。为了解决这个问题,从MySQL8.0版本开始支持在索引Key中倒序存储。你可以按照实际的sql负载来决定如何创建索引,例如你的查询中有Order by a desc, b asc,就可以创建索引key(a desc, b asc),而在8.0之前的版本中则可能需要代价比较大的filesort来进行, 此外逆序扫描Btree也有额外的开销,例如扫描时的page切换,page内扫描,都比正序扫描的开销要大。

本文简单介绍下用法,并分析下对应的代码实现

以下基于当前最新MySQL8.0.13版本

使用

其实对应的语法一直是存在的,只是没有做具体的实现,直到8.0版本才真正实现,使用也很简单,在创建索引时,对索引列加asc/desc关键字,举个简单的例子:

mysql> CREATE TABLE t1 (a INT PRIMARY KEY, b INT, KEY a_idx(a DESC, b ASC));
Query OK, 0 rows affected (0.05 sec)

  mysql> INSERT INTO t1 VALUES(1,1),(2,2),(3,3);
  Query OK, 3 rows affected (0.02 sec)
  Records: 3  Duplicates: 0  Warnings: 0

  mysql> SELECT b FROM t1 FORCE INDEX(a_idx);
  +------+
  | b    |
  +------+
  |    3 |
  |    2 |
  |    1 |
  +------+
  3 rows in set (0.00 sec)

  mysql> SELECT b FROM t1 FORCE INDEX(PRIMARY);
  +------+
  | b    |
  +------+
  |    1 |
  |    2 |
  |    3 |
  +------+
  3 rows in set (0.00 sec)

如上例,可以看到指定不同的索引给出的结果顺序也是不一样的。

mysql> EXPLAIN SELECT * FROM t1 ORDER BY a DESC, b;
+----+-------------+-------+------------+-------+---------------+-------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type  | possible_keys | key   | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-------+------------+-------+---------------+-------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | t1    | NULL       | index | NULL          | a_idx | 9       | NULL |    3 |   100.00 | Using index |
+----+-------------+-------+------------+-------+---------------+-------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

上例中可以看到explain的结果中没有filesort, 而在之前的版本中对于这样的sql是需要进行排序的。

优化器在选择索引时也会考虑到索引列的顺序,目前还有些条件限制:

  • 由于涉及到数据的存储,目前只支持InnoDB
  • Descending index 无法使用change buffer
  • Descneding index不支持fulltext或spatial index, 选择desc关键字会报错
  • GROUP BY不在隐式的保证顺序性,只有明确的指定asc/desc,才去确保顺序

实现

笔者主要工作是在innodb引擎,对server层不甚了解,本文也主要关注innodb的改动。实际上这个特性的改动主要在server层的优化器和执行器,对于innodb来说,尽管数据存储发生了变化,但改动反而很少。

数据词典: 索引上的列属性被持久化到数据词典表(dd::Index)

dd::fill_dd_indexes_from_keyinfo
    dd::fill_dd_index_elements_from_key_parts

key_rec_cmp: 比较的两个key不是大小关系,而是在索引上的前后关系,因此需要考虑键值列上是asc还是desc的 对于range查询,在之前的版本中总是min_Key被传到innodb作为search_tuple来定位btree,但如果是descending index,则需要选择max_key来作为search tuple (ref: SEL_ARG::get_min_flag(), SEL_ARG::get_max_flag(), SEL_ROOT::store_min_key)

InnoDB record compare: 为了支持这个特性,innodb的改动实际上并不大,大部分代码都是没有变化的,这主要是因为InnoDB使用了统一的比较函数来决定key值位置,索引对象传递到底层的比较函数中,以获取是否存在descending column.

相关函数:

cmp_dtuple_rec_with_match_low
cmp_whole_field
cmp_data

判断是否是descending index: dict_index_has_desc(): 这个函数会扫描索引上所有的列,确保没有desc column, 这个函数看起来有点效率问题,我们可以给dict_index_t加个flag来判断,无需每次遍历

参考文档

1.官方文档

2.wl#1074: Add Descending indexes support

3.MySQL 8.0 Labs – Descending Indexes in MySQL

4.MySQL 8.0: Descending Indexes Can Speed Up Your Queries

5.相关代码

理论基础 · Raft phd 论文中的pipeline 优化

$
0
0

raft phd 论文里面是如何做 Pipeline 优化的?

貌似这里Pipeline 的做法也是不会让日志产生洞, 日志仍然是有序的

leader 和follower 在AppendEntry 的时候, 不需要等待follower 的ack 以后, 立刻发送下一个log entry 的内容. 但是在follower 收到这个AppendEntries 的内容以后, 因为AppendEntries 会默认进行consistency check(这里AppendEntries consistency check 指的是在执行AppendEntries 的时候, 会把之前的一个log 的index, term 也都带上, follower 在收到这条消息以后, 会检查这里的index, term 信息是否与自己本地的最后一个log entry的index, term 一致, 不一致的话就返回错误) 那么即使是pipeline 执行AppendEntries, 仍然会保证如果这个follower 接受后面一个entry 的时候, 必定把之前pipeline 的entry 接受了才行, 不然是不会满足这个 AppendEntries 的约束的, 也就是说即使使用pipeline 依然可以保证Log 是不需要带洞的. 当然raft 作者这里的做法依然是保证简单, 所以让没有通过AppendEntries concsistency check 之后, 默认就让这个AppendEntries 错误, 然后让他重试. 当然也可以有其他的处理方法

同时这里也强调, 使用 pipeline 的话, 必须至少一个leader 与一个follower 建立多个连接?

如果一个leader 与一个follower 共用一个连接使用pipeline 的话, 那么效果会是怎样的呢?

其实这样的pipeline 适合batch 是没有多大区别的, pipeline 最大的目的应该是在latency 比较高的情况下, 也可以充分的利用带宽, 但是如果共用一个连接的话, 在tcp 层面其实就已经是串行的, 因为tcp 同样需要对端的ack, 才会发送下一段的报文, 虽然tcp 有滑动窗口来运行批量发送, 然后在对端重组保证有序, 其实这个滑动窗口就和batch 的作用类似. 因此如果使用单挑连接, 其实是和batch 的效果是差不多的, 使用单条连接的pipeline 其实也不会出现包乱序, 因为tcp 层面就保证了先发送的包一定是在前面的.

说道这里其实raft 同步log和tcp 做的事情类似, 也是希望可靠有序的进行数据同步, 又希望尽可能的利用带宽.

那么使用多条连接的话可能存在什么问题?

如果是一个leader 和 follower 建立多个连接的话, 即使因为在多个tcp 连接中不能保证有序, 但是大部分情况还是先发送的先到达, 即使后发送的先到达了, 由于有AppendEntries consistency check 的存在, 后发送的自然会失败, 失败后重试即可. 其实这里完全也可以像tcp 那样, 有类似滑动窗口的概念, 也就是说AppendEntries 的时候, 如果发现之前的内容还没到达, 那么完全可以在本地的内存中保留一份buffer, 那么可以利用这个buffer 就不需要进行重传了, 当然简单的办法仍然是重传.

当然这里如果引入了类似滑动窗口的概念, 在follower 端保留一份数据的话, 那么自然也就需要拥塞阻塞算法的存在了, 也就是说如果一个follower 节点在前面某一个连接缺少了某一个log 以后, 其他的连接一直发送数据, 这个时候该如何处理, 也就是follower 需要告知leader, 让这个leader 不要再发送内容了, 那么其实就和tcp 里面的拥塞阻塞是一样了.

MySQL · 引擎特性 · MySQL 状态信息Status实现

$
0
0

什么是MySQL状态信息

通过Show Status命令查看MySQL server的状态信息是MySQL日常运维中常见的诊断手段。这个命令可以返回在实例运行期间,或者是当前会话范围内的指定的状态信息。具体语法见https://dev.mysql.com/doc/refman/8.0/en/show-status.html, MySQL8.0可以支持的Status列表见 https://dev.mysql.com/doc/refman/8.0/en/server-status-variables.html

MySQL Status代码实现

本文将和大家一起看看Status的内部实现机制,相关的代码是基于MySQL 8.0。

MySQL使用了两张Performance_Schema数据库的表,session_statusglobal_status分别对应Session级别以及Global级别的状态信息访问。 两张表的定义如下,包含的字段是一样的。

CREATE TABLE `session_status` (
  `VARIABLE_NAME` varchar(64) NOT NULL DEFAULT '',
  `VARIABLE_VALUE` varchar(1024) DEFAULT NULL
) ENGINE=PERFORMANCE_SCHEMA DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

CREATE TABLE `global_status` (
  `VARIABLE_NAME` varchar(64) NOT NULL DEFAULT '',
  `VARIABLE_VALUE` varchar(1024) DEFAULT NULL
) ENGINE=PERFORMANCE_SCHEMA DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

Show session|global Status命令会在MySQL server层解析成对performance_schema.session_status或者performance_schema.global_status的Select语句。具体参见 build_show_session_statusbuild_show_global_status方法,这两个方法都会调用build_query方法来真正生成对应的SELECT_LEX,之后执行。

主要的数据结构

Status变量定义见SHOW_VAR结构体

struct SHOW_VAR {
    const char *name;   //Status名字
    char *value;        //Status的值
    enum enum_mysql_show_type type;   //Status类型,比如SHOW_ARRAY, SHOW_LONGLONG,不同的类型Status处理的方法不同
    enum enum_mysql_show_scope scope; //Status作用范围,包括 SHOW_SCOPE_UNDEF,SHOW_SCOPE_GLOBAL, SHOW_SCOPE_SESSION和SHOW_SCOPE_ALL
};

SHOW_SCOPE_UNDEF

  • 未定义作用域,。当成SHOW_SCOPE_ALL对待

SHOW_SCOPE_GLOBAL

  • 全局作用域。这种Status在整个MySQL实例生命周期里面只有一个,且全局的值。只可以通过SHOW GLOBAL STATUS,或者Select Performance_Schema.global_status表查看。 例子: Aborted_connects, Binlog_cache_use

SHOW_SCOPE_SESSION

  • Session级别作用域。这种Status只作用于各自的连接,不会在全局范围做聚集。只可以通过SHOW SESSION STATUS,或者Select Performance_Schema.session_status表查看。 例子: Compression, Last_query_cost

SHOW_SCOPE_ALL

  • 这种Status变量可以作用于各自的连接,也会在全局范围做聚集。既可以通过SHOW SESSION STATUS,或者Select Performance_Schema.session_status表查看,也可以通过SHOW GLOBAL STATUS 或者 Select Performance_Schema.global_status查看。 例子: Bytes_sent, Open_tables, Com variables

all_status_vars

在mysqld.cc文件中定义的Status变量的全局动态SHOW_VAR数组,所有的server以及plugin的Status变量都会在里面定义。在MySQL实例初始化阶段,Status变量都会被组装成这个all_status_vars的全局动态数组,plugin在load的时候,带入的新的status变量会被加到这个数组里面。all_status_vars里面包含的Status的定义都是有序的存放的,以方便Show Status命令或者是对相关系统表session_status或者是global_status的访问。

System_status_var

thread级别的status变量,变量类型必须是long/ulong。

Global locks

LOCK_status 是一个全局mutex,它被用来在初始化阶段以及SHOW STATUS执行阶段保护all_status_vars。

Thread locks

在做多个thread Status聚合的时候后,global thread manager会持有两把锁来防止thread被移除。Global_THD_manager::LOCK_thd_removeGlobal_THD_manager::LOCK_thd_count。 但,两种锁的持有时间不同,LOCK_thd_remove会在整个聚合执行期间一直持有, LOCK_thd_count 锁 会在当前thread list的快照做了copy之后就会释放。

Show Status实现

对Performance_schema.session_status或者是Performance_schema.global_status的访问,会需要调用join_materialize_scan方法,并进一步调用ha_perfschema::rnd_init/rnd_next, PFS_variable_cache::materialize_all等。

取session域的Status的调用栈如下

join_materialize_derived-> TABLE_LIST::materialize_derived->…
                                        ->ha_perfschema::rnd_init->table_session_status::rnd_init
                                        -> PFS_variable_cache<Status_variable>::materialize_all   //Materialize output status
                                        -> PFS_status_variable_cache::do_materialize_all->PFS_status_variable_cache::manifest

join_materialize_derived-> TABLE_LIST::materialize_derived…->ha_perfschema::rnd_next->table_session_status::rnd_next //访问所有的status,做过滤

取global域的Status的调用栈如下,和session域的调用栈很类似

join_materialize_derived-> TABLE_LIST::materialize_derived->…
                                        ->ha_perfschema::rnd_init->table_session_status::rnd_init
                                        -> PFS_variable_cache<Status_variable>::materialize_all   //Materialize output status
                                        ->PFS_status_variable_cache::do_materialize_all->PFS_status_variable_cache::manifest

join_materialize_derived-> TABLE_LIST::materialize_derived->…
                        ->ha_perfschema::rnd_next->table_session_status::rnd_next

type为SHOW_FUNC的Status,例如Aborted_connects,访问方法比较特别,会调用SHOW_VAR里面定义的函数来处理,

PFS_status_variable_cache::manifest
…
    /*   
      If the value is a function reference, then execute the function and
      reevaluate the new SHOW_TYPE and value. Handle nested case where
      SHOW_FUNC resolves to another SHOW_FUNC.
    */
    if (show_var_ptr->type == SHOW_FUNC) {
      show_var_tmp = *show_var_ptr;
      /*   
        Execute the function reference in show_var_tmp->value, which returns
        show_var_tmp with a new type and new value.
      */
      for (const SHOW_VAR *var = show_var_ptr; var->type == SHOW_FUNC;
           var = &show_var_tmp) {
        ((mysql_show_var_func)(var->value))(thd, &show_var_tmp, value_buf.data);  //调用指定的函数,函数名在status_vars数组里定义
      }    
      show_var_ptr = &show_var_tmp;
    }    

例如取Aborted_connects,就会调用show_aborted_connects这个函数来获取真正的值。

{"Aborted_connects", (char *)&show_aborted_connects, SHOW_FUNC, SHOW_SCOPE_GLOBAL},

如何添加一个新的Status

MySQL当前的架构已经对Status扩展做了很好的支持。我们如果想添加一个新的Status,一般的步骤是在System_status_var里面添加一个相应的变量,并在status_vars数组里面 也对应添加一个对应的SHOW_VAR条目。需要处理的是对Status 变量做累计,这个就要根据具体的逻辑了。

PgSQL · 应用案例 · 使用PostgreSQL生成数独方法1

$
0
0

背景

不知道什么时候开始数独游戏风靡起来了,数独游戏由一个N*N的矩阵组成,N必须是一个可以被开根的数值,例如4,9,16,25等。

任意一个像素,必须在三个方向上保证值唯一。这三个方向分别是X,Y,BOX。XY很好理解就是纵横的一条线(X,Y的像素个数就是N)。BOX指这个像素所在的BOX(BOX是由 (N的平方根)*(N的平方根) 个像素组成的矩阵)。

如图,一个9*9个像素的数独。(我把基数称为3)

pic

1616的数独,16行,16列。同时分成44个BOX。(我把基数称为4)

那么如何生成一个有解的数独呢?

这个方法可行吗?

以下方法是按从左到右,从上到下的顺序来生成随机数的,看起来可行,实际上大多数情况下都无法生成有解数独,因为前面还比较容易满足条件,后面基本上就无法满足条件了。

create or replace function gen_sudoku(  
  dim int  -- 基数  
) returns int[] as $$  
declare  
  res int[];   
  vloops int := 2 * (dim^5);  
  vloop int :=0;  
  ovloops int := 2 * (dim^5);  
  ovloop int :=0;  
  rand int;  
begin  
  -- 初始化矩阵  
  select array( select (select array_agg(0) from generate_series(1,(dim^2)::int)) from generate_series(1,(dim^2)::int)) into res;  
    
  loop  
        -- 无法生成并返回  
        if ovloop >= ovloops then  
          raise notice '已循环%次,可能无法生成数独。', ovloop;  
          return res;  
        end if;  
        ovloop := ovloop+1;  
  
  <<outer>>  
  for x in 1..dim^2 loop  
    raise notice 'start again %', ovloop;  
    for y in 1..dim^2 loop  
      vloop := 0;  
      loop  
        -- 生成随机值  
        rand := 1+(random()*((dim^2)-1))::int;  
  
        -- 这轮循环无法生成并返回  
        if vloop >= vloops then  
          -- raise notice '1  %此数已循环%次,可能无法生成数独。', rand, vloop;  
          -- return res;  
          exit outer;  
        end if;  
        vloop := vloop+1;  
  
        -- 横向验证  
        perform 1 where array(select res[x][generate_series(1,(dim^2)::int)]) && array[rand];  
        if found then  
          --raise notice '2  %此数已循环%次,可能无法生成数独。%', rand, vloop, array(select res[x][generate_series(1,(dim^2)::int)]) ;  
          continue;  
        end if;  
          
        -- 纵向验证  
        perform 1 where array(select res[generate_series(1,(dim^2)::int)][y]) && array[rand];  
        if found then  
          --raise notice '3  %此数已循环%次,可能无法生成数独。%', rand, vloop, array(select res[generate_series(1,(dim^2)::int)][y]);  
          continue;  
        end if;  
          
        -- BOX验证  
        perform 1 where array(select res[xx][yy] from (select generate_series(((((x-1)/dim)::int)*dim)+1, ((((x-1)/dim)::int)*dim)+dim) xx) t1, (select generate_series(((((y-1)/dim)::int)*dim)+1, ((((y-1)/dim)::int)*dim)+dim) yy) t2) && array[rand];  
        if found then  
          --raise notice '4  %此数已循环%次,可能无法生成数独。%', rand, vloop, array(select res[xx][yy] from (select generate_series(((((x-1)/dim)::int)*dim)+1, ((((x-1)/dim)::int)*dim)+dim) xx) t1, (select generate_series(((((y-1)/dim)::int)*dim)+1, ((((y-1)/dim)::int)*dim)+dim) yy) t2);  
          continue;  
        end if;  
          
        -- 通过验证  
        res[x][y] := rand;  
        raise notice 'res[%][%] %', x, y, rand;  
        -- 跳出循环  
        exit;  
      end loop;  
    end loop;  
  end loop;  
  end loop;  
  return res;  
end;  
$$ language plpgsql strict;  

以上方法最大的问题是,因为是左右,前后顺序在生成数独,实际上越到后面,会导致可以填充的满足XYB约束值越少,甚至没有。

为了尽可能的每次填充的值都有较大概率,可以在生成顺序上进行调整,不使用从左到右,从上到下的方法。

而是每一步都选择在XYB方向上还有最大概率(即最多没有填充的值)的像素。(我不清楚下围棋先占4个角,是不是也是同样的道理?)

如何找到每个像素在XYB维度上还有多少个未填充的值?

输入一个矩阵,得到另一个矩阵,表示当前位置在XYB轴的未填充值的个数。(非空值的xyb返回x,y,0,0,0)因为非空值不需要再填充它,所以无所谓。

1、首先要创建一个类型,包括数独矩阵的 X,Y坐标。以及这个坐标的横、竖、BOX三个方向上的剩余未填充值的个数。

create type xyb as (  
 ax int, -- 横坐标  
 ay int, -- 纵坐标  
 x int,  -- 横向还有多少未填充像素  
 y int,  -- 竖向还有多少未填充像素  
 b int   -- BOX内还有多少未填充像素  
);  

2、编写一个函数,用来计算一个为完成数独矩阵,其每一个像素的XYB值。

create or replace function comp_xyb(  
  int[],   -- 包含一些值的数独二维矩阵,当像素值为0时,表示这个值没有填充  
  int      -- 数独的基数(比如2,3,。。。),3就是常见的9*9数独,4就是16*16数独。   
)   
returns xyb[]   -- 返回一个复合类型的数组矩阵,矩阵像素和输入矩阵一样,每个像素表示这个像素在XYB轴上还有多少个没有填充的值(没有填充的值用0表示)  
as $$   
declare  
  dims int := ($2)^2;   -- 基数的平方,表示行、列、BOX的像素个数。也是每个方向上的矩阵标记上限  
  res xyb[];            -- 结果  
  
  vx int;  -- 横向还有多少未填充像素  
  vy int;  -- 竖向还有多少未填充像素  
  vb int;  -- BOX内还有多少未填充像素  
  
  lx int;  -- box的X方向矩阵下标  
  ux int;  -- box的X方向矩阵上标  
  ly int;  -- box的Y方向矩阵下标  
  uy int;  -- box的Y方向矩阵上标  
begin  
  -- 初始化矩阵  
  select array (  
    select array( select format('(%s,%s,0,0,0)', x, y) from generate_series(1,dims) t(y))   
      from (select generate_series(1, dims) x) t   
    )  
  into res;   
  
  -- X坐标  
  for x in 1..dims loop  
    -- Y坐标  
    for y in 1..dims loop  
        
      -- 如果这个像素的值不等于0,说明已经是一个已经填充过的像素,返回0,0,0  
      if ($1)[x][y] <> 0 then  
        -- 不计算已填充了非0值的像素  
        continue;  
      else  
        -- x,计算X方向有多少个未填充的像素  
        select sum(case arr when 0 then 1 else 0 end) from   
          (select ($1)[x][generate_series(1, dims)] as arr) t   
        into vx;  
          
        -- y,计算Y方向有多少个未填充的像素  
        select sum(case arr when 0 then 1 else 0 end) from   
          (select ($1)[generate_series(1, dims)][y] as arr) t   
        into vy;  
          
        -- b,计算BOX内有多少个未填充的像素  
        -- x下限  
          lx := ((x-1)/$2)::int * $2 + 1;  
        -- x上限  
          ux := ((x-1)/$2)::int * $2 + $2;  
        -- y下限  
          ly := ((y-1)/$2)::int * $2 + 1;  
        -- y上限  
          uy := ((y-1)/$2)::int * $2 + $2;  
        -- 计算BOX内有多少个未填充的像素  
        select sum(case arr when 0 then 1 else 0 end) from   
          (select ($1)[xx][yy] as arr from   
            (select generate_series(lx,ux) xx) t1, (select generate_series(ly,uy) yy) t2  
          ) t into vb;  
          
        -- 将XYB的值,写入结果变量的对应像素中  
        res[x][y] := format('(%s,%s,%s,%s,%s)',x,y,vx,vy,vb)::xyb;  
      end if;  
    end loop;  
  end loop;  
  return res;  
end;  
$$ language plpgsql strict immutable;  

3、用法举例

计算以下2为基数,4*4的矩阵的xyb值

{1,2,3,4}
{0,1,1,0}
{0,1,1,0}
{0,1,1,0}
postgres=# select array(select (comp_xyb('{ {1,2,3,4},{0,1,1,0},{0,1,1,0},{0,1,1,0} }', 2))[x][generate_series(1,4)]) from generate_series(1,4) t(x);
                           array                             
-----------------------------------------------------------  
 {"(1,1,0,0,0)","(1,2,0,0,0)","(1,3,0,0,0)","(1,4,0,0,0)"}  
 {"(2,1,2,3,1)","(2,2,0,0,0)","(2,3,0,0,0)","(2,4,2,3,1)"}  
 {"(3,1,2,3,2)","(3,2,0,0,0)","(3,3,0,0,0)","(3,4,2,3,2)"}  
 {"(4,1,2,3,2)","(4,2,0,0,0)","(4,3,0,0,0)","(4,4,2,3,2)"}  
(4 rows)

使用unnest可以解开,按XYB三个方向总大小排序,再按某个方向最大排序,从而做到逐级收敛,真正每一次填充的像素,都是具备最大概率的像素。

postgres=# select * from 
unnest(
  comp_xyb('{ {1,2,3,4},{0,1,1,0},{0,1,1,0},{0,1,1,0} }', 2)
) t 
where 
  t.x+t.y+t.b <> 0
order by 
  (t.x+t.y+t.b) desc, 
  greatest(t.x,t.y,t.b) desc;  

 ax | ay | x | y | b 
----+----+---+---+---
  3 |  1 | 2 | 3 | 2
  3 |  4 | 2 | 3 | 2
  4 |  1 | 2 | 3 | 2
  4 |  4 | 2 | 3 | 2
  2 |  1 | 2 | 3 | 1
  2 |  4 | 2 | 3 | 1
(6 rows) 

通过这个SQL得到了某个像素,这个像素的XYB方向上,还有最多的像素没有被填充。

因此这个像素如果生成一个随机值的话,违反数独的约束(或者叫冲突)的概率是最小的。

postgres=# select * from 
unnest(
  comp_xyb('{ {1,2,3,4},{0,1,1,0},{0,1,1,0},{0,1,1,0} }', 2)
) t 
where 
  t.x+t.y+t.b <> 0
order by 
  (t.x+t.y+t.b) desc, 
  greatest(t.x,t.y,t.b) desc 
limit 1;  

 ax | ay | x | y | b 
----+----+---+---+---
  3 |  1 | 2 | 3 | 2
(1 row)

用AX,ZY坐标值,往矩阵的这个像素填充符合数独条件的随机值,可以大幅提高构造可解数独的概率。

小结

本文先介绍如何得到这样的一个像素,填充一个值进行,这个值的取值区间应该是最大的(最不会与数独的游戏规则违背),从而更大可能的生成一个完整可解的数独。

下面一篇文章再介绍如何生成一个N*N的数独。

参考

http://poj.org/problem?id=3074

NP完全问题近似求解。

《PostgreSQL 生成任意基数数独 - 2》

《PostgreSQL 生成任意基数数独 - 3》

《PostgreSQL 生成任意基数数独 - 4》

Viewing all 692 articles
Browse latest View live