浅谈数据库

写在前面

  从去年四月中旬拿到阿里offer之后就再也没写过博客,平时工作的确也比较忙,但是每天晚上回来沉淀一下的机会还是有的。还是希望自己不要工作之后就把很多基础知识都忘了,从这篇文章开始还是继续没事总结一些基础知识。本文简单谈一下数据库,更多的只是一个泛谈,很多东西不会展开,主要从数据库自底向上进行一个概述。

 

 

什么是数据库?

  什么是数据库,听名字能感觉到这是某种存储数据的东西,事实上它的确是用来存储数据的。

  对于一个数据库,数据的存储肯定是基于磁盘的,这个应该很好理解,数据需要持久化,不然一关机数据就全没了。所以刚开始的数据库简单结构是这样:

  简单的说就是一块单独的磁盘通过数据库引擎进行处理。但是当需要处理的数据量上来之后这样的结构显然不可以了,对容量、cp、IO、内存都会提出很大的要求,于是衍生出了两种扩展方式:

  第一种是scale up:

  这种scale up的思想简单来说就是利用网络,可以将远程的许多磁盘视为一大块磁盘,然后多个数据库引擎从这一大块磁盘上读取数据,而不用关心其读取的数据是本地的还是远程的,其实就是share disk。而且多个数据库引擎之间可以通过网络继续共享数据,也就是我们说的share everything。如果不share everything,这样的实现方式比较简单。但是数据库引擎毕竟是通过网络对一个分布式存储磁盘进行读取,网络是这个系统的瓶颈。

  为了处理更多的数据,scale out架构衍生出来了:

  这种scale out的思想简单来说就是将数据进行一个partition,落到不同的磁盘,每个磁盘对应不同数据库引擎,尽量保证每个数据库引擎之间不会产生数据交流,也就是share nothing。(当然有些时候这种交流在所难免,比如range查询,比如聚集函数,比如join操作等等,这种交流也有很多策略来处理,具体可以看一些分库分表中间件的,此处不展开。)这种情况下超大数据量就可以通过堆机器来解决了。

  数据库从最初发展到现在,可以分为两种需求,一种OLAP(On-Line Analytical Processing),一种OLTP(on-line transaction processing)。

  OLTP是传统的关系型数据库的主要应用,主要是进行事务处理,例如银行交易,关于事务处理下面会展开。

  而OLAP是数据仓库系统的主要应用,支持复杂的分析操作,侧重决策支持,并且提供直观易懂的查询结果。 

  本文主要是讲OLTP。

  说了这么多,但是存储数据我们用文件用的好好的,为什么要用数据库呢?这里先给出几条回答,觉得比较模糊也不用担心,之后进行延展:

  1、对磁盘友好的存储方式

  2、能够通过构建“索引”增加查找速度

  3、能够对查询做出合理优化

  4、可以进行事务处理,也就是all or nothing

  5、可以对请求进行并发控制

  6、保证高可用

  7、某些分布式数据库还需要保证一致性问题

一、数据库的存储方式

  前面提到了相比文件数据库的存储方式对磁盘更友好,这是为什么?数据库的进行一次IO的最小单位是page,也就是说一次IO只能数页数页地读取。所有page都是存储在block中,每个page又对应着一个page entry,这个page entry包含这个page的地址以及这个page相关的一些数据。而这个page entry实际上也是存于entry中的,这些page entry又组成一个block。在block中这些page是物理上连续的,画成图也就是这样,其实是一种叫做heap file structure的结构:

  那么,数据在page中到底是怎么存的呢?我们把page中的一条数据称为一个record,简单来说就是数据库里看到的一行数据,record存放的位置我们称为slot,record可以是定长的和不定长的,至于record在page中的存储方式需要分定长record和不定长record来讨论。

定长record VS 不定长record

定长record

  所谓定长record,就是比如对该行数据,需要存储student_id,name,age三个属性,思想就是每个属性的长度都有一个上界。比如student_id长度不会超过a,name长度不会超过b,age长度不会超过c。这样一个record的表现方式如:

 

 

  对于这种定长record,在page中如何存储呢?就拿刚才那个例子来说,这个record每个属性长度都是固定的,也就是这个record总长度总是固定的,长度为a+b+c,我们就定义一个存放这样长度record的位置为一个slot,也就是一个slot能存放一个record,但是这个slot有可能有record,也有可能没有record。

  但是对于这么一片page,我怎么知道哪些slot有record,哪些没有record呢?这个地方又有两种思路:

  第一种思路是将有record的slot放往一侧,没有record的slot放往一侧,然后专门在这个page中专门记录这个page中有几个record,表现方式如:

  意思就是当前page前三个slot有record,其他的为空。这种方式的优点是插入新的record很简单,直接在最后一个record后追加。但是这种方式的缺点是每次删除都需要调整slot,将有record的slot移往一侧,没有record的slot移往一侧。

  第二种思路是每次变动record不调整slot,但是要记录当前slot有没有record。可能讲着不太好理解,其实就是这样:

  最下面的bit位就表示第几个slot有没有record。

不定长record

  不定长record即record内的属性长度是不固定的,可变的。对于区分不定长record的不同属性有两种思路。

  第一种思路是,如果某个字符在我们的系统中从来没有出现,也永远不会出现,我们就那这个字符来作为属性的分隔符,比如:

  

  第二种思路是在该record中记录下每个属性的偏移量,根据这个偏移量就可以找到每个属性,比如:

  那对于这种不定长record,怎么存于page中呢,如图:

  

  每一页维护一个slot目录,每个slot由( record偏移量, record长度) 对组成,第一部分record偏移量是指向记录的“指针“。它表示从页上数据部分的开始处到record的开始处的字节偏移量。删除操作通过设置记录的偏移量为-1很容易完成。record能在page内以移动,因为由page和slot组成的rid(slot即目录中的位置)在记录移动时不会发生改变,只有存储在slot中的record的偏移量改变了。

  为新record分配空间的一种方法是维护一个指向空闲空间开始处的指针(即从数据部分开始处的偏移量)。当一个新record太大而不能放在剩余的空闲空间时,就不得不在page内移动记录以收回先前已被删除记录释放的空间。其思想是确保重新组织后,所有记录是连续的,后面是可分配的空闲空间(类似JAVA的GC算法中的标记-整理算法)。

​ 值得注意的是,被删除record所在的slot不能从slot目录中移出,因为slot号用于标识record,如果删除一个slot,将改变slot目录中后续slot的槽slot,以至于导致后续slot所指向的记录的rid的改变。从slot目录中删除slot的唯一方法是当最后一个slot所指向的record被删除后,可以移出最后一个slot。然而,当插入一个record时,应该首先扫描slot目录已寻找目前未指向任何目录的slot,并把新record存于该slot。只有当所有存在的slot都指向record时,才能向slot目录中增加新的slot。如果插入操作比删除操作更普遍(这是比较典型的情况),slot目录中的record项数将非常接近page上的实际记录数。换句话说,slot目录中的每个slot不一定都指向record,也许这个slot所指向的record刚刚被删除,但是还没有被整理内存。

行存储 VS 列存储

  事实上上面讲的一个record里放多个属性的方式,定长的也好,不定长的也好,其实都是行存储。这个地方放一个别人的图:

  简单的说,行存储按照行的序列来存储表;列存储按照列的序列来存储表。

  对于行存储,我们OLTP见的比较多,因为行存储中一条record相关的属性全在一行了,我们进行插入和更新比较容易,因为我们前面说过数据库中IO的最小单位是page,这种行存储方式可以保证我们需要更新的record只需要一次IO就可以将这个record所有属性读入。插入同理,一条record所有属性写入page只需要一次IO。但是行存储当然也有他的缺点,想象一下现在有一张大宽表,有几十个属性,我们只需要读取一个属性。但是由于数据库IO的最小单位是page,我们会将许多不相关的属性也读入内存,然后只取其中一个属性。这显然很浪费。除此之外,对于行存储的索引需要我们去额外建立,建立索引其实会带来一定的插入开销和磁盘存储上的成本(关于索引后面会延展)。

  对于列存储,OLAP见的比较多,因为OLAP一般数据量特别大,表比较宽,而且我们在利用OLAP分析数据时对OLTP中的事务也不是很关心。另外由于其基于列存储,我们每次读取属性都只需要将相关的属性读入内存,不用读入不相关属性。并且按列存储可以保证这一列数据都是相关的数据,这样可以保证这些相关的数据能带来比行存储更好的压缩比。然而列存储在更新和插入的时候由于可能这些属性不在一个page,就需要进行多次IO。

  看到了行存储和列存储的优缺点,可能有人会问,为什么不结合起来呢?实际上这种结合起来的方式也是有的,并且已经实现了,阿里的ads(也称为garuda)就是这样的存储方式。简单的说,这种方式就是在列存储的时候不是严格按照一列一列的进行存储的,而是将某些相关性比较强的列先列存储之后按照row group的方式按照行存储组合在一起,某些独立性比较强的列就单独列存储。举个例子比如如图的表:

  我们认为id、first name、last name这三个列相关性比较强,age和其他没什么相关性,我们就可以这样存(只是简单举个例子,事实上一般来说每个列会按照数据特性对列进行压缩,在列存储当中数据即索引。常见的,倒排索引可以加快列存储中的filter和join,bitmap索引适合重复率较高的列,区间树索引适合数据类型为数字的列等等等等):

  这种混合方式看起来很简单,但是实际上如何将相关列合并在一起形成row group是需要考量的,这就涉及到统计学和机器学习。

  除了行存储、列存储、混合存储,目前还有基于LSM(log-structure merge)树的多级存储,大致思想就是对于一个表,不是所有数据所有时刻都是随时在插入在更新的,对于比较热的数据,我们先按照行存储存在内存里,等这些数据冷下来,也就是不需要频繁插入更新的时候我们往下一层也就是磁盘推,变成列存。但是这个有一些冷热数据判断的挑战,同样涉及到统计学和机器学习,以及数据在内存中没来得及刷盘就挂掉的挑战,我不是很懂,在此不展开。数据库的底层存储告一段落。

 

二、数据库的索引

  通过上面的第一部分,我们已经了解了数据库的底层存储,那么数据已经全都有了。但是思考一个问题,对于很多数据,我们目前想要查找其中一条怎么查找,全表扫描吗?那样肯定会很慢很慢,显然不可取。解决这个问题的东西就叫做索引(indexing)。简单来说索引就是一种可以有效减小查询开销的存储结构。

  基于树的索引

  基于树的索引可能是数据库索引中最经典的一种索引了。对于n个数据,前者按照链表或者数组的方式排好序,后者按照二叉搜索书的方式排好序,我们找出一个值的时间复杂度各是多少?可能学过数据结构与算法的人都知道,前者线性时间复杂度,后者对数时间复杂度。基于树的索引就是干这么一件事,不过基于树的索引一般这棵树都是B+树,不是普通二叉树,不是红黑树(这些树的区别由于篇幅问题不再赘述)。了解红黑树就知道红黑树的节点虽然逻辑上挨得很近,但是物理上可能挨得很远,由于数据库IO时会有一个预读(为什么会预读下面会讲),也就是会把逻辑相邻的节点也读入内存。另外就是B+树解决了一个遍历问题,B+树的所有数据都存在叶子节点中,这样扫表只用扫一遍叶子结点即可。总的来说对于一棵B+树如下(叶子结点大家就想像成是连在一起的好了,实在是太难画了):

  这就是一棵B+树索引,但是现在有一个问题,如果一个page只放入一个节点,那相当于无端浪费了很多IO资源。比较一个page可能是一个节点大小的好多倍。对于这个问题,我们可以把n个节点放入一个page,也就是这样:

  这样就解决了page容量浪费的问题,当然也会带来上面提到的磁盘预读问题,就是每次读一个节点时,会把其逻辑上比较近的节点也读入内存。

  上面提到的B+树索引只是一维数据的索引,那对于多维数据怎么办呢?对于思想最朴素的quad tree,就是对于多维数据,其维度有a,b,c...n这么多维度,我按照每个维度都将数据分成四个象限。这样在数据分布比较均匀的时候完全可以,但是往往数据分布在各个维度不是很均匀。这种问题可以用KD-tree(k-dimensional tree)来解决,其思想就是先从a维度,按照a维度的中位数将数据分成两部分,然后两部分数据按照b维度中位数进行拆分,得到的四部分数据按照c维度的中位数进行拆分...最后建立得到的树就是一个按照各个维度均匀分布的树。这颗树会遇到我们之前提到的B+树一样的问题,也就是一次只读入一个节点浪费page空间。同样我们将多个节点放入一个page,也就是KDB-tree。

  然而上面提到的树不能覆盖所有的场景,现在想一个case,需要查询20km内的所有餐厅。用上面的思想可以解决,但是会带来很大的开销。这里引入一种叫做R-tree的树,这种树经常作为一些GIS数据库的索引。简单来说就是用一个最小矩形将节点及其子节点cover住,节点高度越高,这个最小矩形就越大。这里涉及到很多相关知识,就不详细展开了,引用一篇文章:https://blog.csdn.net/houzuoxin/article/details/16113895

    基于Hash的索引

  hash算法是计算机科学里面很著名的一种算法了,简单的说就是一种压缩映射,将一个任意长度的输入通过一个hash函数转化为固定长度的数据。典型的比如java中的HashMap中的数据结构简单来说就是一个存放<key,value>这样的二元组的数组,通过计算key的hash值,得到一个hashCode,然后通过hashCode mod length,将key打散在长度为length的数组里(当然jdk里和这个实现不一样,由于length是2的整数次幂,hashCode mod length等价于hashCode & (length - 1)),将任意输入的key散列到数组长度对应的下标:

  数据库里基于hash的索引就是通过索引列将这个列对应的record映射到hash表里的不同位置。因为hash表的查询时间复杂度为O(1),而B+树的查询时间复杂度为O(log2n),等值查询的时候hash索引会比较快,但是很明显hash索引不适合排序,分组,范围查询的场景。

    基于SkipList的索引

  skipList,即跳表。对于有序链表,我们知道,即使对于排过序的链表,我们对于查找还是需要进行通过链表的指针进行遍历的,时间复杂度很高依然是O(n),这个显然是不能接受的。而且由于在内存中的存储的不确定性,不能二分查找。但是我们还是可以结合二分法的思想,跳表实际上是一种可以二分查找的有序链表,特性如下:
1.链表从头节点到尾节点都是有序的 
2.可以进行跳跃查找(形如二分法),降低时间复杂度

  如图:

  跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。首先在最高级索引上查找最后一个小于当前查找元素的位置,然后再跳到次高级索引继续查找,直到跳到最底层为止,这时候以及十分接近要查找的元素的位置了(如果查找元素存在的话)。由于根据索引可以一次跳过多个元素,所以查找速度也就变快了。

  跳表的性能和AVL树,红黑树不相上下。

  跳表在之前的数据库系统中用的很少,现在用的比较多,主要是因为现在出现了一种叫做分级存储技术的存储方式。分级存储是根据数据的重要性、访问频率、保留时间、容量、性能等指标,将数据采取不同的存储方式分别存储在不同性能的存储设备上,通过分级存储管理实现数据客体在存储设备之间的自动迁移。跳表的这种多级索引的存储方式和这种分级存储技术很搭,所以这种数据结构在现代数据库系统中得以出现。另一方面,现代数据库出现了一种内存数据库的形态,简而言之就是将数据放在内存中操作的数据库,而skiplist作为索引对这种数据库十分友好。从其中一个内存数据库memsql官网上看到了几个原因:

  1.充分利用内存:在内存中指针的开销比磁盘中间接去访问数据开销小很多,可以免去buffer pool,而且可以充分利用指针。

  2.简单:memSql官网上说其skiplist实现的代码量仅仅是innodb的b+实现的代码量的1/50。

  3.无锁:skiplist的无锁算法比较简单(skiplist的无锁算法是怎么样的?这里先埋个坑。),而B+树需要复杂的并发控制来保证线程安全,虽然也有对应的无锁算法,但是skiplist的无锁算法更简单。

  4.快:因为其简单的数据结构及其的无锁算法,这个数据结构对应索引的db在插入、删除、查询时都更快。

  5.灵活:skiplist可以更灵活地支持一些操作,比如支持对数时间复杂度地计算一个区间的元素个数(skiplist特性)。

三、数据库的查询优化

  数据库查询语句的执行,主要分为5个阶段,

  1.SQL输入

  2.语法分析:对输入语句进行词法分析、语法分析,得到语法分析树

  3.语意检查:对语法分析树的每个节点进行语意分析,报告语法错误

  4.SQL优化:一是逻辑优化,通过代数关系式的等价变换,把语法分析树优化生成逻辑查询执行计划,二是物理优化,对逻辑查询执行计划进行改造,主要对单表扫描方式、两表连接算法以及多表连接顺序进行优化,最终生成物理查询执行计划。

  5.SQL执行:根据物理查询执行计划执行具体查询。

  换句话说,查询优化器通常包含两部分工作,逻辑查询优化与物理查询优化。

  所谓逻辑查询优化就是根据关系代数的原理,把语法分析树转换成关系代数语法树的形式(终于明白了为什么大学数据库要学根据sql写关系代数,此处不展开)。​所谓物理查询优化主要就是改造单表扫描方式的选择、两表连接算法的选择、以及对多表连接算法的选择。

单表扫描方式的选择

  单表扫描方式的选择就是在可选的单表扫描方式中,挑选什么样的单表扫描方式是最优的。单表扫描算法如下:

1.顺序扫描:从物理存储上按照存储顺序直接读取表的数据。当无索引可用,或访问表中的大部分数据,或表的数据量很小时,使用顺序扫描效果较好。

2.索引扫描:根据索引键读取索引,找出物理元组的位置。一般选择率较低,有索引覆盖时,使用索引扫描效果较好。

3.只读索引扫描:根据索引键读取索引,索引中的数据即能满足条件判断,不需要读取数据页面。

4.行扫描:为元组增加特殊的列,通过该列直接计算出元组的物理位置,然后直接读取元组对应的页面,获取元组。

5.并行表扫描:对同一个表,并行地、通过顺序的方式获取表的数据,结果得到是一个完整的表数据。

6.并行索引扫描:对同一个表,并行地、通过索引的方式获取表的数据,将结果合并在一起。

7.组合多个索引扫描:对同一个元组的组合条件进行多次索引扫描,然后在内存里组织一个位图,用位图描述索引扫描结果中符合索引条件的元组位置。

两表连接算法的选择

  两表连接算法的选择就是在进行两表连接操作时,查询优化器需要选择执行连接操作的算法,常见的两表连接算法如下:

1.嵌套循环连接:将一个表为出发点,将该表全部记录逐条去遍历另外一张表的记录,符合条件的就是写入结果集。嵌套循环连接时比较适合内表关联建有索引的情况,否则每次匹配都需要全表扫描一遍内表,将会大大影响效率。

2.排序归并连接:A、B两表先排序,然后从两边的一端开始匹配,一直到一张表结束。如果两表已经是排好序的,那么使用排序归并连接可以节省两表排序的消耗。

3.hash连接:优化器使用两个表中较小的表(通常是小一点的那个表或数据源)利用连接键在内存中建立散列表,将列数据存储到hash列表中,然后扫描较大的表,同样对连接键进行hash后探测散列表,找出与散列表匹配的行。hash连接效率最高,但hash连接只能用于等值连接,而且如果数据分布不均匀,还会因为数据倾斜问题影响效率。

多表连接算法的选择

  多表连接算法的选择就是找到执行效率最高的多表连接顺序。 因为在进行多表连接操作时,不同的表连接顺序的执行效率往往是差别巨大的。查询优化器在决定多表连接的顺序时,需要使用多表连接算法实现。常见的多表连接算法如下:

1.动态规划:动态规划算法的特点是需要遍历全部的解空间,通过动态规划一定能够得到连接路径的最优解,但是因为需要遍历全部解空间的原因,搜索空间会随着连接表的数量指数增长。其能确保得到最优解,因此是首选的多表连接算法(如果数据库实现了该方法的话),但是因为存在搜索空间膨胀的问题,需要结合其他算法作为补充。

2.遗传算法:遗传算法不一定能获取到全局最优解,只能获取到局部最优解。在配置项开启遗传算法的情况下,表连接数超过配置时,由于动态规划存在搜索空间膨胀的问题,会选择遗传算法实现。

3.贪婪算法:贪婪算法也不能获取到全局最优解,只能获取到局部最优解。贪婪算法不从整体最优加以考虑,省去了为找到最优解要穷尽所有可能而必须耗费的大量时间。

 

  上面提到了各个单表扫描方式、两表连接算法、多表连接算法的优缺点,但是实际上想要量化这些抉择方式是有专门的代价模型公式的:

  查询代价估算基于CPU代价和IO代价,所以代价估算模型用如下:

  COST = P*a_page_cpu_time + W*T

  P:计划运行时访问的页面数。

  a_page_cpu_time:每个页面读取的时间花费。

  W:权重因子,表明IO到CPU的相关性,又称为选择率。即命中的元组数和R的所有元组数N的比值。

  T:访问的元组数。

四、事务处理

  事务是所有操作必须成功完成,否则在每个操作中所作的所有更改都会被撤消。也就是事务具有原子性,是一个基本单元,一个事务中的一系列的操作要么全部成功,要么一个都不做,all or nothing。

  事务特性如下:

  1.原子性(Atomicity):事务包含的所有操作要么全部成功,要么全部失败回滚。

  2.一致性(Consistency):事务必须使数据库从一个一致性状态变换到另一个一致性状态,也就是说一个事务执行之前和执行之后都必须处于一致性状态。举个例子就是对于某个银行系统中,所有用户的总金额是一个值,无论这些用户如何转账,事务结束后这些用户总金额还是这个值。

  3.隔离性(Isolation):隔离性是当多个用户并发访问数据库时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。关于事务的隔离性是有多种隔离级别,后面会延展。

  4.持久性(Durability):持久性是指一个事务一旦被提交了,那么对数据库中的数据的改变就是永久性的,即便是在数据库系统遇到故障的情况下也不会丢失提交事务的操作。

  处理多个并发事务的时候一个很简单的保证正确性的方式就是严格按照事务来的先后顺序执行事务,一个事务执行结束之后再执行下一个任务,但是这样做显然性能很低。所以很多时候我们不会严格要求严格序列化地并发处理多个并发事务。但是这样会带来不同的隔离型异常:

  1.脏读(dirty read):是指在数据库访问中,事务T1将某一值修改,然后事务T2读取该值,此后T1因为某种原因撤销对该值的修改,这就导致了T2所读取到的数据是无效的。

  2.不可重复读(unrepeated read):由于查询时系统中其他事务修改的提交而导致一个事务范围内两个相同的查询却返回了不同数据。比如事务T1读取某一数据,事务T2读取并修改了该数据,T1再次读取该数据,却得到了不同的结果。

  3.幻读(phatom read):由于事务不是独立执行时发生的一种现象。比如第一个事务对一个表中的数据进行了修改,这种修改涉及到表中的全部数据行。同时,第二个事务也修改这个表中的数据,这种修改是向表中插入一行新数据。那么,以后就会发生操作第一个事务的用户发现表中还存在没有修改的数据行,就好象发生了幻觉一样。

  4.丢失更新(lost update):如果多个事务操作,基于同一个查询结构对表中的记录进行修改,那么后修改的记录将会覆盖前面修改的记录,前面的修改就丢失掉了,这就叫做更新丢失。

  5.写偏序(write skew):对一张表的多行数据的并发修改满足隔离性,一旦存在冲突的写入,会触发回滚;但是如果涉及以多张表返回的结果计算出新结果,写入另一张表,则容易出现 Write Skew。

  前四个异常很多地方都已经说的很清楚了,主要说一下第五个,也就是写偏序。写偏序是09年新提出来的一种异常情况:如果两个事务重叠并写相同的数据,那么其中一个将会失败。然而,在两个事务重叠且其中一个读取另一个正在写的数据的情况下,两者都可以成功。这种情况称为写偏序。一个用来描述这种异常的图如下:

  我们开两个事务T1和T2,其中一个事务T1将白球变成黑球,另一个事务T2将黑球变成白球。在严格序列化的情况下,我们希望如图1,先将白球替换成黑球,再将黑球替换成白球。如果数据库处于快照隔离(MVCC并发控制策略下,至于MVCC并发控制策略是啥后面会延展)之下,T1和T2将对数据库的私有快照进行操作:T1锁住想要更新的白球,T2锁住想要更新的黑球。由于两个更新都没有冲突,所以都提交成功,然而提交之后的结果显然不是我们想要的。

  不同的隔离级别会遇到以上不同的异常,隔离级别从低到高有读未提交(read uncommitted)、读已提交(read committed)、可重复读(repeated read)、快照隔离(snapshot isolution)、序列化(serializable)。各个隔离级别及其会遇到的异常表格如下,X表示这个异常情况在当前隔离级别下不会出现,否则就是会出现:

    大部分数据库都只是实现到了snapshot isolution,虽然很多公司标榜自己实现了serializable,但是实际上write skew还是很难抓,这个东西目前还是需要去靠系统业务层去杜绝这个东西。

五、并发控制

  前面提到有可能会出现多个事务同时进行的场景,那如何对这些事务进行并发控制呢,达到以上各个隔离级别呢?主要是三种思想:基于锁的并发控制、多版本并发控制、乐观锁并发控制。

基于锁的并发控制(locking based concurrency control)

  这个地方的基于锁的含义其实是基于悲观锁的并发控制,也就是如果我认为多个事务并发时,我们希望事务在同一时间对同一资源有着独占的能力,那么就可以保证操作同一资源的不同事务不会相互影响。

  最简单的、应用最广的方法就是使用锁来解决,当事务需要对资源进行操作时需要先获得资源对应的锁,保证其他事务不会访问该资源后,在对资源进行各种操作;在悲观并发控制中,数据库程序对于数据被修改持悲观的态度,在数据处理的过程中都会被锁定,以此来解决竞争的问题。

  然而为了最大化数据库事务的并发能力,数据库中的锁被设计为共享锁和互斥锁。当一个事务获得共享锁之后,它只可以进行读操作,所以共享锁也叫读锁;而当一个事务获得一行数据的互斥锁时,就可以对该行数据进行读和写操作,所以互斥锁也叫写锁。

  这种方法当然会带来较大的开销,而且使用锁有可能会带来死锁的风险。现代数据库常用的并发控制策略主要是下面两种:

多版本并发控制(multi-version concurrency control)

  多版本并发控制就是在进行写操作时,将数据copy一份,不会影响原有数据,然后进行修改,修改完成后原子替换掉旧的数据,而读操作只会读取原有数据。通过这种方式实现写操作不会阻塞读操作,从而优化读效率。而写操作之间是要互斥的,并且每次写操作都会有一次copy,所以只适合读大于写的情况。看这个样子有点像copywrite的思想。

  多版本并发控制的具体实现方式有很多,有的是在每一个record后都增加两个隐藏列,记录创建版本号和删除版本号,而每一个事务在启动的时候,都有一个唯一的递增的版本号。有的是记录下每个record的有效时间段。具体细节不太懂,不展开。

  这种方式读操作都是读的老版本的数据,并且读的时候不会阻塞更新操作,这就带来更高的并发度。但是相当于每次更新都需要生成一份新版本的数据,这就使得更新操作很昂贵,而且需要涉及到废弃版本的垃圾回收。这只适合读远大于写的场景。

乐观锁并发控制(optimisitic concurrency control)

  乐观锁并发控制假设多个事务经常可以在不相互干扰的情况下完成。在运行时,事务使用数据资源而不获取这些资源上的锁。在提交之前,每个事务都验证没有其他事务修改它所读取的数据。如果检查显示有冲突的修改,提交的事务将回滚并可以重新启动。

  这种方法通常用于低数据争用的环境。当冲突很少发生时,事务可以在不需要管理锁的情况下完成,并且不需要事务等待其他事务的锁被释放,这将导致比其他并发控制方法更高的吞吐量。然而,如果对数据资源的争用频繁,重复重启事务的成本会严重影响性能。

  这种方式比起传统锁抢占方式实现起来更廉价,开销更小,但是在高并发场景下容易冲突而频发重启事务。

 

  (各个隔离级别的具体实现先埋个坑,我看网上说的都是通过基于锁的并发控制也就是读写锁来实现的,但是他们说的肯定不全。后面会关注下这个问题。)

六、高可用性

  我们的数据库一个很核心很核心的功能就是将数据持久化,也就是将数据写入磁盘。对于这个问题我们可以在事务提交后,将事务修改后的数据刷到磁盘。但是这样做会导致刷盘次数很多,影响效率。如果我们不这样做,而是将修改保存在buffer里,然后多次事务之后统一刷盘,这样如果数据库突然宕了,那内存中的数据也就全没了。我们可以有一种折中的方案,预写日志(write-ahead logging)。这种思路的思想简单来说就是在事务提交然后准备刷盘之前先将修改写到一个WAL文件里,如果事务失败,WAL中的记录会被忽略,撤销修改;如果事务成功,它将在随后的某个时间被写回到数据库文件中,提交修改。

  准确的来说步骤如下:

  1.变更发生后,先将变更写入WAL buffer;

  2.再将变更写入数据库刷盘前的那个buffer;

  3.commit时,将WAL buffer刷到磁盘(文件也是存在磁盘上的);

  4.判断有没有达到需要将数据库buffer刷盘的临界点(checkpoint),如果达到了就将数据库buffer刷盘,否则等之后达到临界点再刷盘。

  用这种方式当数据库宕机时data buffer的数据虽然没了,但是WAL里的内容是在磁盘的,任何没有执行到数据page上的改动都可以根据日志变更做出相应变化。由于WAL是连续写,所以其写效率是比数据库刷盘效率高很多的。这种涉及到大量连续写且读少的写入,我们可以使用LSM(log structure merge)树,简单来说就是将数据增量保存在内存中,达到一定值之后刷盘。但是这样又会导致我们的WAL写不安全了,会一宕机就丢失一部分日志。我们可以把存在内存中那部分数据存在NVM(non-volatile memory)里,保证宕机这部分内存中的数据不会丢失。具体涉及到一些偏硬件知识,我也不懂,不延展。

  前面提到的记录变更的方法有两种。一种是redo日志,一种是undo日志。redo日志就是涉及到任何需要持久化的增删改操作,都用redo日志来保证持久性。这种日志需要在事务提交前落盘,只需要基于硬盘上的数据根据redo日志重新做一遍已提交的事务的修改,已提交但未及时刷盘的修改不会丢失,保证了事务的持久性,其余未提交的事务也会在内存上自动回滚,毕竟一宕机内存里的东西也就丢了。而这个失败的异常可以在上层进行捕获处理。对于undo日志基本和redo日志一样,不过undo日志记录的是变更前的值。有了undo日志,不用关系是否提交事务就可以刷盘,只要日志记录了就行。宕机恢复时,倒序执行undo日志把所有未提交的事务回滚,从而保证了事务的原子性。

 

  到目前为止我们保证了宕机时数据在内存中不会轻易丢失,但是我们再邪恶一点,我们直接把数据库炸了,如何保证这种情况下数据不丢失?很简单,再加一台。其实就是多台机器保证可用性,涉及到的东西太多了,这里就只给个链接吧:https://www.cnblogs.com/devinzhang/p/7001424.html

七、数据一致性

  待完成

  • 5
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值