第一章
讲了数据库的基本组件.行存与列存区别.数据文件和索引文件(文件分页的页大小 一般是磁盘块大小的整数倍,这样一次io我们就可以拿出正好完整的一些数据).数据文件分为堆文件(顺序插入),哈希文件(先定位桶,再定位桶中的具体数据).索引组织文件(就是常见的根据索引组织数据的方式,叶子节点有完整的数据,而不是数据的引用or指针).这一章还说了一下间接索引的事,说白了就是一,二级索引(二级索引保存主键id,之后再走一边主键索引找到数据,也叫回表).这里也有一个常问的问题,有点憨批,所以直接说结论,主键索引不一定聚簇,聚簇索引一定是主键索引.其实也没有那么绝对,所以问这个问题挺憨憨的,换一种文件组织方式就又不一样了.软件没有绝对的,我们常聊的无非是一类问题最常见的那一个解决方案.最后讲了缓冲(写入不够一个磁盘块,就先在内存攒着,减少io次数)不可变性(修改时是原位置修改还是按照写log在文件尾部插入)和有序性(是否按照键顺序存储).
第二章
讲了b树相关,基本数据结构,查找,分裂,合并.但是分裂合并讲的有点唠叨,应该是翻译的问题,这里推荐看一下极客时间的那个数据结构的专栏讲的比较清晰.
第三章
第三章讲的文件系统设计和文件格式的一些事,如何用最少位表示数据,二进制的序列化和反序列化,设计寻址方式.是否需要将文件差分成页,页是否由多个块组成.页在内存中被写满了,就刷入磁盘里面去.
schema(表结构)的好处是有助于减少磁盘上存储的数据量,,需要使用位置标识符而不用让每条记录都带上字段名. 其实就是把一些元信息的起始偏移量存下来,从而加速某些查找.
原始b树论文描述了一种,简单的用来记录定长数据的格式,由p(子页指针),k(键),v(值)三元组构成页.具体来看就是个数组,所以尾部追加没有问题,其他位置的插入和修改代价就比较大,需要移动元素.
已经删除的空间需要回收,我们可以把新的数据存进去.双方大小相等,刚好不浪费空间,回收小于新数据,在找一块空间用来存储剩余的新数据,回收大于新数据就会造成空间浪费,这时候就应该个找一个新空间存储新数据,而不是复用回收旧空间.
分槽页的组成:head,指针,cell(槽 or 单元格).head记录一些元信息,指针指向cell,cell存储具体数据.
分槽页为什么是一种好的页格式?
1.已最小开销存储变长记录:分槽页唯一的开销就是维护指针数组,用于保存记录实际所在的位置.
2.通过对页进行碎片整理和重写 ,就可以回收空间.
3.动态布局:从页外部,只能通过槽id来引用槽,而确切的位置是由页内部决定的(可以说是做了一层解耦吧)
单元格分为两种其实也就是叶子节点和非叶子节点的区别.单元格类型和键,键长是都有的数据,叶子节点村具体的记录和其长度,非叶子节点存储页id.由于每个页都是固定大小,所以可以通过schema查找真正的文件偏移量.
单元格插入分槽页:首先是头部元信息(老三样了).后门跟着的是偏移量数组(从左往右),中间是剩余空间,最后是实际的单元格数据,单元格的插入是从右往左.这样做保证了插入的逻辑顺序(只需要维护偏移量指针顺序即可,不需要移动单元格的位置).
可用列表用来记录一个页里面被标记的删除的单元格,存储可用段的偏移量和大小.碎片整理就是读出所有的单元格重新写入,这样就将可用的单元格做了聚合.如果碎片整理之后还无法存下,那么就创建一个溢出页.
当存储引擎存在多个二进制文件的格式时,可用版本前缀来区分.
最后提了一下crc.感觉上类似md5,这种吧,但不是防人的,使用验证 软硬件故障 导致的文件变更,一般放在的页的head里
第四章
页头的元信息里会存储本页的记录总数,所以在现在本版的mysql里用count(*)应该也不慢了,获取列信息的时候顺便获取纪录数.
魔数也是元信息用来代表页,附带版本信息,可以用来做完整性校验.
同级指针将b树统一层的字节点连成链表,方便了同层节点的定位,
最右指针一般单独存储,因为它并没有与任何键相匹配.一般存储在头部.
由于b树算法规定,每个节点都持有特定数量的元素,当存储变长值的时候就会出现这种状况,元素没存够但是页满了,这时候就允许创建一个以4k(一般是这个大小)为增量的页,然后将还未存储的元素存在这个页里,并将这个页链接在原始上.这个也就叫做溢出页.
b树页总会保留max_payload_size(节点大小除以扇出)个字节,用于存储载荷数据.当插入的数据大于这个值时,就看看这个原始页是不是已经有溢出页了,有的话看一下空间够不够,够就存在这个溢出页里,不够的话就创建一个新的溢出页.
当有多个溢出页时,可以把溢出页的id存储在主页的头里,并用链接起来(意思应该就是存个链表)
导航信息(访问过的节点和单元格索引)就是进行一次插入或者删除时,将节点遍历(从根到叶的引用),用于在传播分裂或合并时进行反向回溯.最自然的结构是栈
一些b树的实现方案试图推迟分裂和合并操作,以便通过在层内再平衡各元素来摊平代价,或将元素从占用较多的节点转移到占用较少的节点中,通过这一方式尽可能推迟分裂或合并.这样做可能维护成本大一些,但是有助于提高节点利用率和减少树的层数.比如说在插入的时候将一下元素转换到同级的另一个节点中,并为插入腾出空间,删除的过程中,我们可以从相邻的节点中移动一些元素,而不是合并同级节点,以确定节点至少是半满的.在平衡改变了同级节点的最小最大不变量,我们必须相应地更新父节点键和指针.
仅在右边追加,是一种快速插入的优化。在pg中当插入的键严格的大于最右页中的第一个键,并且最右页有足够的空间用来存放新插入的条目时,新条目被插入缓存的最右叶中的适当的位置。并且可以跳过整个读取路径。
我们在叶子层按页写入预排序的数据。这时我们只需要将该页的第一个键传给其父页就好。
压缩需要在压缩率和访问速度之间做权衡。整个文件的压缩虽然有不俗的压缩率,但是简单的查询都需要解压整个文件。所以按页压缩是一个不错的选择。当页小于磁盘块的时候一次读取会读到另一个页的一部分数据,页大于磁盘块亦然。
第五章
这一章主要将事务。开头老三样子,介绍ACID:原子性,一致性,隔离性,持久性
事务管理器,锁管理器(读共享,写排他),页缓存(缓冲池)
双层存储结构磁盘加内存,为了减少io次数。
页从磁盘加载到缓存page in,被更改就成了脏页需要flush落盘,满了需要换出evict
数据库的页缓存可以理解为内核页缓存的应用态等效实现。
当存储引擎访问页时,先检查页是否被缓存,如果该页在缓存中则直接返回,如果该页未被缓存,则页缓存会将其逻辑地址或页号转换为物理地址,并将它的内容加载到内存,然后返回缓存的版本给存储引擎,一旦返回这个存有缓存页的缓冲区就被称为被引用。存储引擎用完之后呢必须要接触引用或者归还,若不想让页缓存不要换出某些页,则可以将他们固定(pin)。如果页被修改了,那么这个页就是一个脏页必须要落盘保证持久性。
缓存的回收:
1.满页必须被换出
2.已经落盘并且没有被引用则可以立即换出
3.脏页必须先落盘才能被换出
4.如果页正在被其他线程使用,则不能将其换出
权衡:
1.推迟刷写以减少磁盘访问次数。
2.提早刷写以让页能被快速地换出
3.选择要换出的页,并以最优的顺序刷写
4.避免因数据没有被持久化到主存储中而丢失他们
5.将缓存大小保持在其内存范围内。
改善:
1.在缓存中锁定页。因为b数离根越近越窄,分裂和合并也会传递到较高的层次,所以我们可以锁定(pin)近期大概了会用到的页,这样页会在内存中保留更长的时间,有助于减少磁盘的访问次数。
2.页置换。页满了就需要置换,但是如果把树高节点的页置换出去就很亏,因为高节点的页经常被用到,如果被换出也有可能会被再次载入。所以我们因该换出的是那些不经常使用的页。所以lru就很理所当然。
页置换算法:
1.fifo最简单的当然是先进先出了。
2.使用双lru队列,来区分最近访问和经常访问。
3.clock(第一次知道这算法,太菜了太菜了,呜呜呜呜~!)(呸,这个我知道,不就是无锁的环形队列嘛,就这?)
4.lfu(三个队列)(这里主要说的就是tinglfu,记一下看看相关论文)。
恢复
预写日志(wal:write-ahead-log)。页缓存中数据是允许被修改的,为了保证持久性语意,必须先写wal保存操作,再去修改页缓存。当发生崩溃的时候,使系统可以从操作日志中重建内存中丢失的更改。wal是一个不可变的,仅追加的数据结构。因为日志可能没有写满一个磁盘块,这里就需要一个日志缓冲区。其次日志需要一个lsn(日志序列号)。这样就可以根据序列号顺序的写在磁盘上了。检查点就类似一个快照的东西,每次强制的刷写脏页是一次同步检查点。在日志头部记录检查的开始和最后一次检查点的信息。
影子页(写时复制)。物理日志记录实际数据,逻辑日志记录操作。
这里的写了一段基于日志的事务恢复的解释,但是翻译的有点抽象,老老实实redo ondo我还能看明白点。心累。
当数据崩溃重启时,恢复分为三个阶段。
1.识别脏页和正在进行的事务。脏页信息用与redo的起点。事务信息用于ondo
2.恢复数据的状态。。提交事务以及已提交但未处持久化的事务。
3.回滚(ondo)所有的事务。为了方式回滚失败ondo日志也会被记录到日志中,一避免重复使用。
并发控制:由事务管理器和锁管理器协同工作控制。
乐观并发控制:
允许多个事务并发的读取和写入,事务不会相互阻塞,而是保留记录,并在提交前检查这些历史记录是否冲突,如果产生冲突则终止某一个冲突事务。
多版本并发控制mvcc:
允许一条数据同时存在多个时间戳的版本,通过这种方式事务读到的是过去的某一时刻的一致的视图。后续的操作都是针对一个版本的数据进行的(这里的翻译非常混乱)。
悲观并发控制:
加锁的版本会要求维护数据库记录上的锁,比如mysql 事务相关的日志里面都会记录相关的锁id。加锁就会有死锁(看看mysql差不多就理解了)。
读异常和写异常:
脏读:读到未提交的数据,比如说前面的事务更新之后回滚,后面的事务就读到了未提交的数据。
不可重读:同一事务两次执行,读取到的数据不一致,t1读取一行,t2修改并提交,t1再次读取数据改变。
幻读:指的是两次范围读取获取的行集合不一样。
丢失更新:两个事务同时更新一个值,且都commit,则后提交的覆盖了前面提交的。
脏写:脏读情况下又修改。
写偏斜:单个事务满足一些约束,组合事务却违反了。
隔离级别:
1.读未提交:也就是说脏读是允许的。
2.读已提交:只能读到已经提交的数据。但不保证两次读取的数据一致。
3.可重复读:一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
4.串行化/序列化:并不是完全的串行,只是将事务重新排序后,事务的结果按照一定的串行顺序展现。
只有当事务中的修改的值没有被其他事务修改时,才可以提交事务,否则它将被中止回滚。 之前说过两事务同时更新一个值会造成更新丢失。事务修改之前回去快照读取数据,快照包含之前所有事务已提交的数据,首先提交的事务会成功,另一个事务则被中止,然后重试,这样完整的修改记录就保存下来而不是直接覆盖。
处理数据库死锁的几种方法:
1.引入超时机制并终止长时间运行的事务。
- 2PL(两阶段锁)。
数据库常使用事务管理器来检测或者避免死锁:
1.死锁检检测通过等待图(waits-for graph实现)
第六章 各种b树变体(看了一边没看出啥精髓,相关知识比较少,之后再写)
第七章 lsm tree
lsm tree 将数据文件写入延迟,并将更改缓冲在一个内存驻留表中。表中的内容随后将被写到不可变的磁盘文件中(不可变让我想到了es)。在持久化之前所有的数据一直存在与内存之中。
文件长度会稳定增长所以需要合并文件,来确保在读取过程中查找较少的文件。
- lsm tree的结构。
根据上面的简单分析,lsm tree是一种内存使用少,磁盘使用多的结构。
内存驻留组件:memtable(记录缓冲记录,并充当读写操作的目标)。和b树的buffer pool差不多当写满时再落盘减少对磁盘的io次数。memtable的更新类似mysql中的redo log,会先写入wal中。memtable通常是内存排序树的形式(理解为内存中的b+树?)
磁盘驻留组件:是由mentable写到磁盘来生成的,专门用于读取的不可变结构。
2.双组件lsm tree
双组件lsm tree只有一个磁盘组件,又不可变段组成(b树,100%节点占用率和只读页)。
合并的过程中我们同时迭代磁盘树和一块的memtable,挨个对比进行更新,并产生有序的结果(因为数据源都是有序的)。这个过程就是一个标准的写时复制。我们需要坐三个保证。
1.刷写过程一旦开始所有的新的写操作必须转到新的memtable.
2.在子树刷写期间,磁盘驻留子树和正在刷写的内存驻留子树都要保持可以被读取的状态。
3.在刷写之后,如下操作必须以原子的方式执行:发布合并的内容,丢弃原始的磁盘驻留和内存驻留内容。
双组件lsm的缺点写放大:合并相对频繁,都由memtable刷写所触发。
3.多组件lsm tree
就是有多个磁盘驻留表。memtable被刷写会生成多个磁盘驻留表,磁盘驻留表的数量随着时间的增长而增多。在搜索的时候由于我们并不知道所需数据在那个磁盘表里,所以我们不得不访问多个文件来定位。为了减缓这个代价,将磁盘表的数量维持在最少。所以需要有周期性的压实(compaction)操作。压实说白了就是树的合并并将合并结果写到新文件里,旧表在新表出现的同时被丢弃。合并的过程就是一个归并排序。由于不同的迭代器或者说磁盘表中有相同的数据,所以需要有版本号来标记其版本以确定数据的最终一致性。