物理存储系统
这一部分重点并不多,主要是各种存储类型上的差异,比较了几种物理存储上的差异,以及SSD,HDD 之间的差异,最后又介绍了RAID的思想以及RAID分层,总结几个问题
说一下常见物理存储介质?
- 高速缓存(cache),通常指CPU的cache,处于速度最高但是最昂贵的级别,在数据库当中,通常不需要我们来进行手动管理
- 主存,即通常所说的内存,在数据库当中通常作为缓存使用,通过构建bufferpool manager来进行管理。至此仍为易失的(volatile)
- 闪存:早些年用于U盘等存储介质当中,目前的SSD也基于闪存,用途与磁盘(HDD)同效,可以保持非易失(non-volatile),但是在访问速度上会领先HDD很多
- 磁盘:最传统的非易失存储介质,通过物理的方式进行存储,因此在速度上受限于物理速度,即disk arm的移动速度。购买磁盘时也会说明disk arm的转速
磁盘的物理特性
磁盘在垂直上由多个扇片组成,延中心,每一圈可称为一个磁道,在一个磁道上,有个氛围多个扇区,扇区即为磁盘写入和读取的最早单位。 读取和写入的过程极为disk arm转动找到对应的位置,再通过磁头进行写入,这个过程中涉及到了物理运动,因此速度较慢。
RAID
RAID(Redundant Array of Independent Disk) 独立磁盘冗余阵列,主要思想为将数据存储在不同的硬盘上,主要可以带来两点好处:
- 通过冗余提高可靠性:即将同一份数据以page或者自定的块大小来存储在不同的磁盘之上,如果其中的一个磁盘损坏,还可以切换到备份磁盘上,从而保证能够提供正常服务,只有在第一份磁盘被修复前,备份全部挂掉时,才会真正的数据丢失。
- 通过并行提高性能:通过磁盘镜像,可以提高读取的速度,由于所有的备份全部为一致的,因此读请求可以发送到任意一个磁盘上,从而形成负载均衡或者减少单一磁盘的压力。此外,还一刻通过跨多张磁盘的方式进行数据拆分,从而提高数据传输率:
- 比特级拆分(bit-level striping) :较为简单或者常见的数据拆分方式为对一个字节进行拆分,将一个字节按照8bit进行拆分,如如果有一个八张磁盘组成的阵列,将每个字节的第i位写到第i张磁盘上。
- 块级拆分(block-level striping) :将磁盘阵列是为一个完整的大磁盘,按照一个完整磁盘来对各个块进行编号,之后根据编号取模于磁盘数得到该块存放的位置
RAID级别
镜像提供了高可靠性,但是很昂贵。拆分提供了高数据传输率,但是并未提高可靠性。我们系统通过较低的一个成本来获取可靠性并提高性能,这之间就涉及到了一些相关的trade off,从而RAID产生了各个级别:
- RAID 0:有块拆分但是没有任何冗余,可以提高数据传输率但是无法提高可靠性
- RAID 1:提供冗余,有块级别拆分的磁盘镜像
- RAID 5:具有交叉分布的奇偶校验,对于N + 1个磁盘,其中一个磁盘存储奇偶校验,另外N张磁盘存储这些块
- RAID 6:P+Q的冗余方案,在RAID5点基础上存储了额外的冗余信息
- RAID 234均不再使用 RAID1提供了最佳的写入性能,通常用于数据库系统的日志文件当中。RAID5具有比RAID1更低的存储开销,但是具有更高的写入时间通常用于写入较少的系统当中
电梯算法
电梯算法通常应用于磁盘臂的调度当中,工作方式与电梯的工作方式相同,假设一开始,磁盘臂从最内侧的磁道向磁盘的外侧移动,在电梯算法的控制下,对每条有访问请求的磁道,磁盘臂都会在那条磁道停下来为请求提供服务,之后继续向外移动,知道没有对更外侧磁道的请求,之后再调转方向向内侧移动,按照同样的思路进行,知道导师没有更靠近中心的有请求的磁道,之后调转方向开始一个新的周期
数据存储结构
上一章所讨论的是用于存储的物理介质,而在此之上,通常有操作系统来进行进一步的抽象,对上层提供一个文件系统,而数据库即是在该文件系统之上来完成数据的存储。这一章所讨论的就是如何将数据组织安放到文件当中。
文件组织
数据库使用操作系统所提供的文件系统进行存储,操作系统的文件在逻辑上进行划分,通常可以划分为4KB-8KB的page为单位,数据库可以默认使用操作系统的page大小,也可以自定义page大小,之后根据page大小进行访问和读取,一次性加载一个page到内存当中进行读写操作。 首先假定没有比块更大的记录,之后一条记录不会跨page
定长记录的存储
对于定长记录,当确定长度之后,每条长度分配合适的空间,之后从前向后排列即可,存储时不需要在意顺序,因此在删除时选择将最后一条数据移动到当前删除的位置,之后拆入再从后插入即可。另外一种解决方案为设置一个header,通过free list的形式管理所有的被删除的数据的空白空间,之后即可插入时先通过free list进行获取
变长记录的存储
思路基本一致,只不过对于一条记录,需要创建一个Entry,Entry的前几个元素用于存储后面每一个属性的长度,之后跟随记录当中定长的属性,最后放置变长属性,在整体存储上采用header来表示具体位置,新的记录从page的后面创建,header与记录之间的区域为自由空间。
文件组织
堆文件组织: 记录可以存储在对应一个关系的文件中的任何位置,通常一旦确定位置,之后就不会再移动。对于堆文件(HeapFile)来说,一个HeapFile通常由多个HeapPage组成,堆文件需要通过一个自由空间图(free space map)来标识各个HeapPage当中的剩余空间,通常是以比例的形式存在,之后需要添加新的记录时,只需要通过遍历free-space map即可找到对应的空白位置。将数据写入到对应的HeapPage当中。而当heapPage过多时,可以考虑建立二级索引来提高效率。 多表聚簇组织 : 对于某些常用的join语句,可以考虑将join的两张表进行联合存储,可以加快特定的join查询速度,但是带来的问题就是对于单表查询需要查询额外的字段 顺序文件组织 : 在文件当中,根据某个属性去维护顺序存储,可以有利于某些依赖于顺序的查询操作,但是插入时的代价较高,需要进行整体移动,通常是一个page当中采用顺序组织,而各个page之间通过B+ Tree的形式进行组织,可以减少开销。
缓冲池替换策略
对于操作系统,操作系统无法预知未来的page的访问情况,因此操作系统根据局部性原理使用LRU进行page的缓冲控制。 但是数据库能够比操作系统更准确的预测未来的访问方式,通过查看执行计划即可得知之后需要用到哪些page,可以针对计划进行设计:
|
|
对于上述的sql,在使用完一条记录之后后面则不会再用到, 因此可以设计最近最常使用(Most Recently Used,MRU),使用完立刻淘汰,与LRU刚好相反。
事务
事务的四大特性
事务的四大特性分别为原子性、一致性、隔离性与持久性 对于原子性,则事务本身作为一个整体,要么全部成功执行,要么全部失败回滚,不存在执行成功一半的中间态。 一致性表明数据库在事务执行前和执行后,数据库的状态必须满足预定的一致性规则,即数据库只能从一种状态转换为另外一种一致性状态,最简单的例子即为银行转账,只有转账前与转账后的两种一致性状态,不存在一人转出而另一人未收到的中间状态。此外一致性分为数据库一致性和事务一致性,数据库只保证数据库一致性,就像上面所说的那样,而事务一致性则更是一种逻辑关系,需要操作人员去手动约束 此外 数据库的一致性应当与分布式系统当中的一致性的概念相区分,分布式当中的一致性指的是在多个节点上数据保持一致的状态,比如其中最严格的线性一致性就要求对外需要表现成单机节点,如果一次写入完成那么后续就一定要能够读取到数据 隔离型:并发执行的多个事务之间应当隔离,使每个事务感觉在独立的操作数据库,防止事务之间的相互干扰与数据污染 持久性:持久性表明一旦事务提交,那么其做出的更改应当为永久性的
不过个人认为这四者不是简单的平级或者独立的关系,其中,一致性需要原子性和隔离型的支持,系统如果无法保证所有命令全部成功或者全部失败的话,自然会出现只执行了一半命令的中间态,而如果无法保证隔离性的话,一个事务还未提交的变更就能被其他的事务读取到,那么同样无法保证一致性,持久性可能相对的独立一点,和其他的两个没什么太大的联系
可串行化
如果能够保证一个调度策略能够在结果上等价于串行化调度(即没有任何并发),那么就称为该调度为可串行化或者冲突可串行化。而冲突指的是读写冲突 判断可串行化主要有两个方案:
- 通过交换指令进行判断,如果能够通过交换不冲突的指令来得到一个串行化调度,那么就称为其为可串行化的。
- 通过构造有向图的方式,即如果T1,T2之间有指令冲突,那么就构建一个从先执行的指令的事务指向后执行的指令的事务,而如果出现了回路,则证明其为不可串行化的。 而冲突的一种解决或者保护方式即为通过加锁来实现,如果加锁采用同样的读写锁模型+两阶段锁协议的话,那么冲突不可串行化就代表着死锁,同样可以通过寻找环路来判断和解决。 此外需要注意的是,存在一个调度与串行调度在结果上等价,但是为不可串行化的。
可恢复调度与无级联调度
可恢复调度指的是可以通过级联中止的方式来恢复脏读对数据库的影响。考虑这样一种情况,T1wtrieA之后T2进行了readA,而如果T1进行了回滚就会导致A称为脏数据,T2进行了脏读,而此时如果T2在T1之后才提交就为可恢复,反之为不可恢复。
而恢复的方式就是通过级联中止的方式进行恢复,即T1回滚会连同T2一同进行回滚。
为保证可恢复调度,可以通过标记依赖的方式,即T2依赖于T1,T2会等到T1commit之后再进行commit,或者通过强两阶段锁的方式(一同解决可恢复性和无级联)
正如上面所说,如果不想产生脏读,那么T1的回滚就会导致T2的回滚,这种情况成为级联回滚,级联回滚在涉及到大量事务时会产生相当大的开销,因此希望避免级联回滚的发生,如果一个调度不会产生级联回滚,那么称为无级联调度,在实现上通常采用两阶段锁的方式进行
事务的隔离级别
四种隔离级别的差别主要聚焦于读操作上,对于写操作,为了保证数据库一致性,只有写入的锁只有数据提交时才能够释放,如果中途释放锁,写入的操作结果可能会被其他的覆盖,从而违反了数据库一致性。单独看写锁的话,都是采用了严格两阶段锁的策略,保证一致性,同时避免级联中止。
READ UNCOMMITTED
最宽松的隔离级别,根据上表可以看到脏读 不可重复读 幻读都会发生
在实现上读操作不需要加锁,写操作和其他的一致,因此所有读相关的锁都不支持,如果想尝试加[S,IS,SIX],都会抛出异常。由于只支持写锁,在shrinking阶段,任何加锁操作都是不允许的,会抛出异常。
因此并不保证读取到的数据一定是提交的数据,由于并没有加读锁和其他的事务进行互斥,其他的事务一旦写入之后就可以被读取,因此当读取到之后,如果写入数据的事务中止回滚,那么读取到的数据就变成了脏数据产生脏读。
READ COMMITTED
相比于读未提交解决了脏读的问题,但是依旧会出现不可重复读和幻读
在实现上读操作需要在Table上加IS,在Row上加S,并且当完成一次读取之后即可将锁释放,无需等待事务的提交。释放S锁并不会导致事务从growing向shrinking转变。
由于通过S锁进行互斥,从而保证那些正在写入的事务在提交前不应被读取到,能够读取到的数据一定是提交之后稳定写入的数据。
REPEATABLE READ
在读提交的基础上又解决了不可重复读的问题,存在幻读。
实现上加锁策略和READ COMMITTED并没有区别,但是在释放锁上读写锁全部采用严格两阶段锁的策略,此次事务会一直持有读锁,从而中途不可能被其他的事务重新写入更新,在一次事务当中读取的数据全部为一致的。
shrinking阶段不允许添加任何的锁,释放S X锁就会向shrinking进行转变。
可串行化
最严格的隔离级别,简单来说就是在该隔离级别下,事务的执行可以等价成一次不存在并发的串行执行。最简单的判断是否为可串行化的方法就是交换一系列不冲突的指令,从而看原本的事务调度是否可以被分为两个在时间上完全错开的串行调度,简单用读写来表示,一次通过交换或重拍转换成串行结果如下:
相比于可重复读,主要需要解决幻读的问题,当前成熟的商业数据库通常采用两阶段锁 + 索引锁(谓词锁)的方式进行解决,而一种比较简单粗暴的解决方式即为直接上表锁,即可避免在间隙中的读写行为,从而保证可串行化。### 说一下事务的隔离级别
四种隔离级别的差别主要聚焦于读操作上,对于写操作,为了保证数据库一致性,只有写入的锁只有数据提交时才能够释放,如果中途释放锁,写入的操作结果可能会被其他的覆盖,从而违反了数据库一致性。单独看写锁的话,都是采用了严格两阶段锁的策略,保证一致性,同时避免级联中止。
READ UNCOMMITTED
最宽松的隔离级别,根据上表可以看到脏读 不可重复读 幻读都会发生
在实现上读操作不需要加锁,写操作和其他的一致,因此所有读相关的锁都不支持,如果想尝试加[S,IS,SIX],都会抛出异常。由于只支持写锁,在shrinking阶段,任何加锁操作都是不允许的,会抛出异常。
因此并不保证读取到的数据一定是提交的数据,由于并没有加读锁和其他的事务进行互斥,其他的事务一旦写入之后就可以被读取,因此当读取到之后,如果写入数据的事务中止回滚,那么读取到的数据就变成了脏数据产生脏读。
READ COMMITTED
相比于读未提交解决了脏读的问题,但是依旧会出现不可重复读和幻读
在实现上读操作需要在Table上加IS,在Row上加S,并且当完成一次读取之后即可将锁释放,无需等待事务的提交。释放S锁并不会导致事务从growing向shrinking转变。
由于通过S锁进行互斥,从而保证那些正在写入的事务在提交前不应被读取到,能够读取到的数据一定是提交之后稳定写入的数据。
REPEATABLE READ
在读提交的基础上又解决了不可重复读的问题,存在幻读。
实现上加锁策略和READ COMMITTED并没有区别,但是在释放锁上读写锁全部采用严格两阶段锁的策略,此次事务会一直持有读锁,从而中途不可能被其他的事务重新写入更新,在一次事务当中读取的数据全部为一致的。
shrinking阶段不允许添加任何的锁,释放S X锁就会向shrinking进行转变。
可串行化
最严格的隔离级别,简单来说就是在该隔离级别下,事务的执行可以等价成一次不存在并发的串行执行。最简单的判断是否为可串行化的方法就是交换一系列不冲突的指令,从而看原本的事务调度是否可以被分为两个在时间上完全错开的串行调度,简单用读写来表示,一次通过交换或重拍转换成串行结果如下:
并发控制
锁的授予方式
首先考虑锁冲突的问题,最简单的为读写冲突,如果产生冲突自然无法进行加锁,之后可以考虑更加细粒度的锁,如对row加锁时需要先获取table上的锁。 不存在冲突时并不意味着可以直接获取锁如果当前T1持有S锁,T2试图获取X锁正在阻塞,此时T3想要获取S锁,则与T1不冲突可以直接获取,但是之后的事务都来获取S锁,那么长久以来就会出现T2事务starved。 因此合理的组织方式就是对于一个需要上锁的资源,通过一个队列进行管理,只有自身与当前持有的锁不冲突,并且在等待队列中排名第一时才能获取锁。保证了互斥与防止starved。此外还有锁升级的相关细节,可以参考bustub当中的实现。
两阶段锁协议
首先明确概念,两阶段锁协议分为两个阶段:
- 增长阶段(growing phase),只能获取锁
- 缩减阶段(shrinking phase)一个事务可以释放锁,但是不能获取任何新锁 事务一旦释放了任何一个锁,就会从growing phase转换到shrinking phase。
- 2PL:读锁写锁都按照growing shrinking的方式来获取释放,最基础的两阶段锁所解决的不可重复读的问题,整个读取过程中持有锁,从而保证了不会有其他的事务来修改数据
- S2PL:写锁只有在事务提交时才能够释放,读锁与原本保持不变,即写锁的shrinking阶段变成了一个点,2PL存在的问题是由于过早的释放锁,会导致出现脏读/脏写的问题,需要通过级联回滚的方法来解决,但是这个方案会产生较大的开销。S2PL只有在commit时才会释放锁,从而其他事务读取到的一定是commit了的数据,从而避免了级联中止的问题
- SS2PL(rigorous two-phase locking protocol):作为S2PL的变体,要求读锁也在commit时进行释放。
基于图的协议
对所有需要加锁的资源来构建一个偏序的关系(有向无环图),之后如果需要进行加锁,则按照该偏序关系进行加锁,并且,只构建带有根节点的形式。 通过该树形式,可以保证冲突可串行化和不会产生死锁,但是并不能够保证可恢复性和无级联性
- 如果想要保证可恢复性,可以引入一个依赖关系,自身依赖的事务在commit前自身不能够进行commit
- 如果想要同时保证可恢复性和无级联性,则需要在事务结束前不释放排他锁。
死锁的预防
死锁的预防主要分为两种:
- 一种是通过排序的方式,即上面的树状形式就是通过一种偏序的方式来进行死锁的预防,或者像之前godis所写的那样,通过对全局的key进行排序以预防死锁。
- 另外一种即为基于时间戳的方式,细分为抢占式的wound-wait和非抢占式的wait-die
- 此外最简单的方式即为通过锁超时的方式进行,超时即放弃事务回滚。
死锁检测与恢复
死锁检测的方法和之前冲突可串行化的判断方式相同,通过构建等待有向图的方式进行判断,如果在图中存在环路,则证明存在死锁/不可串行化。 死锁恢复: Victim:通过环路找到了死锁之后,考虑选择一个牺牲者事务进行回滚,来破除掉环路(死锁),牺牲者的选择准则:
- 事务执行时间,以及之后还要执行多久
- 事务已经使用的数据项,完成事务还需要多少数据项
- 回滚事务牵扯到的事务数量 回滚:
- 全部回滚
- 部分回滚,回滚到刚好可以打破死锁的位置,需要记录额外的信息。
幻读与索引锁
幻读是在事务并发执行时可能出现的一种现象,它指的是在一个事务内部,多次执行相同的查询操作,但在不同的查询中返回的行数却不一致。通常是由于其他事务并发地插入或删除了符合查询条件的数据所导致的。 主要原因为单独的行锁无法对不存在数据进行加锁,因此无法限制新数据的插入,从而产生了两次查询结果不一致的问题,解决方案也即为对“不存在”的数据进行上锁。常见的解决方法为索引锁或者谓词锁。 索引锁 索引锁以B+树为例,对叶子结点进行上锁,此次sql涉及到的所有的叶子结点均进行上锁,如果要在会涉及到的范围内插入新数据,则势必会引起冲突,即便是开区间如
|
|
然后插入一个col1 = 20000的记录,也会落到最后一个叶子结点上,即便最后一个叶子结点已满,也需要先插入到其中再分裂。 谓词锁 谓词锁即为针对where后面的条件进行上锁,从逻辑上进行冲突判断
时间戳排序协议
先介绍一下基本时间戳顺序协议
首先通过时间戳来对事务进行标记,同时对于一个tuple 需要记录其 W-TS和R-TS,即最后一次对其进行读写操作的事务的时间戳,用于在一个事务要对一个tuple进行读写操作时判断是否有其他事务会和它产生冲突
在基本时间戳顺序协议中,时间戳记录的是该事务开始时的时间,即通过BEGIN关键字声明开启事务时旧分配一个时间戳,但是DBMS本身还是并发的,因此可能会出现后续的事务在较早的事务之前执行,此时就可能存在问题,可能要考虑将较早的事务给撤销,并重新分配时间戳重新执行
当一个事务访问到一个tuple时,发现TS < W-TS ,则证明未来的一次写入覆盖了目前正在读的数值,即出现了脏读问题,读取到的是旧值,需要终止
对于读写,有以下的规则:
读:
如果TS($T_i$) < W-TS(X),则违反了时间戳顺序,读到了旧的值,该事务需要重启并分配新的时间戳
否则:
- 允许事务T去读取tuple X
- 更新R-TS(X)为$max(R-TS(X),TS(T_i))$
- 生成一个本地副本用于该事务后续的重复读(如果再去读最新的数据则会产生两次读取不一致的不可重复读的问题)
写:
如果$TS(T_i)<R-TS(X) or TS(T_i) < W-TS(X)$ 放弃$T_i$并重启$T_i$
- $TS(T_i)<R-TS(X)$会导致后续读取操作读取不到此次的写入
- $TS(T_i) < W-TS(X)$会导致此次的写入无意义
否则:
- 允许事务$T_i$去写入,并且更新W-TS(X)
- 生成一个本地副本用于可重复读
对于写操作,还可以通过Thomas Write Rule进行优化:
- $TS(T_i) <R-TS(X)$:放弃并重启$T_i$(与原本一致)
- $TS(T_i) <W-TS(X)$:忽略此次写入,并允许事务继续执行
- 其他情况则未违反协议,允许$T_i$写入,并更新$W-TS(X)$
虽然本质上违反了协议,但是当前此次写后续会被覆盖掉,因此可以直接进行忽略
总的来说,基本时间戳顺序协议的精髓为当一个事务创建时分配一个时间戳,由于事务的原子性,将事务当中所有的指令的发生时间全部集中于begin的这一时间戳上,之后如果有违反时间顺序的,事务则会中止回滚,并尝试重新开启事务,如读取时发现有未来的事务已经对其进行了写入。 可恢复性与无级联性 基础的时间戳排序协议是无法提供可恢复性与无级联性的,可以通过以下的方式来保证:
- 通过在事务末尾一起执行所有的写操作可以保证可恢复性和无级联性
- 可恢复性与无级联性也可以通过将读操作推迟到更新该数据的事务提交之后进行来保证。
- 单独的可恢复性可以通过依赖追踪的方式进行解决,即只有依赖的事务提交了才会提交当前事务
Thomas写规则
Thomas写规则对基本时间戳协议进行了一些优化,经过优化后的写规则如下:
- 如果$TS(T_i)$ < R-timestamp,则证明已经存在了一个更新的读取操作,此次写入是不被需要的,因此直接拒绝并回滚
- 如果$TS(T_i)$ < W-timestamp,则证明已经存在了一个更新的写入操作,当前的写入之后会被覆盖,因此什么也不需要做,忽略掉此次write
- 其他情况则正常写入
MVCC
严格来说,MVCC(Multiversion Concurrency Control)并不是一种并发手段,他并不能够完整的解决并发冲突问题,MVCC能够提供的只有读与写之间不产生冲突,而无法保证写与写之间的冲突问题,因此需要引入额外的并发控制手段,分别有两种,为与时间戳协议进行组合的多版本时间戳排序和与两阶段锁组合的多版本两阶段封锁。 最后用一句话来描述MVCC即为读不阻塞写,写不阻塞读。
多版本时间戳排序
对于每个数据项,存在一个版本序列$<Q_1,Q_2,Q_3,…,Q_k>$ ,并且每个版本$Q_k$包含三个字段:
- Content:当前版本的值
- W-timestamp:创建$Q_k$版本的事务时间戳
- R-timestamp:所有成功读取过的当中的最大的事务时间戳 当事务$T_i$携带时间戳$TS(T_i)$创建该版本时,使用该时间戳来初始化W-timestamp和R-timestamp 基于时间戳,读写有以下的规则:
- 如果事务T发出read(Q),那么它能够读取到的为小于等于T的时间戳的最大的版本的值
- 如果事务发出write(Q):
- 如果时间戳$TS(T_i)$ < R-timestamp,则证明存在更新的读取,回滚事务
- 如果时间戳$TS(T_i)$ < W-timestamp,则忽略掉此次写入
- 如果时间戳$TS(T_i)$ = W-timestamp,则覆盖掉此次版本的内容
- 如果时间戳$TS(T_i)$ > W-timestamp,则创建一个W-timestamp = $TS(T_i)$的新版本 由于应用了时间戳,因此本质上为乐观的并发控制协议,适用于冲突发生比较少的情况。 该方案不保证可恢复性和无级联性。可以按照对基本时间戳排序的方式进行扩展,使其具有可恢复性和无级联性。
多版本两阶段锁协议
将MVCC与两阶段锁协议结合起来,对于事务分为只读事务和更新事务。
- 更新事务在创建时会获得一个逻辑时间戳,创建的新版本的时间戳为正无穷,之后在事务提交时更新为事务的时间戳
- 更新事务想要读取一个数据时需要获取一个S锁,尝试写入时需要获取X锁
- 只读事务无需加锁,只需要更新时间戳去读取对应的版本即可。