本篇是DDIA学习的第二部分,主要简述单机环境下数据库是如何存储与检索数据的。
存储与检索的基本设计理念
本篇主要讲述我对DDIA这本书的个人理解,帮助读者更快的了解DDIA并以一种思维贯穿始终,以便可以更好的应用在实际问题当中。数据密集型系统是指,通过IO将数据进行处理的系统,而用于解决将数据进行计算分析的系统则称之为计算密集型系统,本篇主要讨论数据密集型系统
数据系统的目标
数据密集型系统的主要指标有三个,可扩展性,可靠性,可维护性
可扩展性
可扩展性强调数据系统可以通过增加单台机器的性能或者机器的数量来使系统的负载线性增长的一种表现。通过增加单台机器的内存,磁盘,cpu核数或者处理能力,来垂直的增加系统的负载能力可以称之为垂直扩展,而通过增加同规格机器的数量来使负载增强的方式可以称之为水平扩展。对于计算机硬件来说,单台机器的性能总是昂贵的,所以水平扩展的方式虽然在实现上更加复杂,但因其成本很低,故而被广泛采用,随着数据规模的增加,成本的考量将成为重中之重。
如何描述一个系统的扩展性?
对于一个通常的系统,我们主要考察三点,吞吐率,响应时间以及失败率.吞吐率强调在单位之间内处理请求的数量。他强调了一个数据系统能够处理多大的数据规模,响应时间指的是处理一次请求需要用到的时间。二者是相辅相成的,不能孤立存在。因为对于实际中的系统,所有的指标都应该在相同数据规模的前提下,假设一个系统在一秒中接受到了10万个请求,其表示在一瞬间有10万个请求到达服务器,那么其吞吐率就是10w/s;但是每个请求却用了10秒中来处理,其响应时间就是10s/sec而在这10秒中的时间窗口中又有一半的请求失败,其失败率为50%。所以三个指标必须在一定数据规模的前提下同时限定才能描述一个数据系统的扩展性。对于三个参数指标,其精准的测量一般是没有意义的,而是采用统计的方式进行描述,并且坚决鄙视平均值,而是使用百分位数来描述,即在一定吞吐量下,百分之多少的请求数内,其响应时间及其失败率是有效的。例如,10wQPS(吞吐率)下,百分之九十九的请求其响应时间最慢是二十毫秒,其失败率为千分之一。
可靠性
数据系统需要长时间的运行,理想的期望是永不间断,然而总是会有各种问题导致其失效,某种异常没有处理,导致系统崩溃或者负载占用资源过高(例如OOM)被系统kill掉。一旦系统无法对外工作,我们就说其已经失效。如何描述一个系统的可靠性呢?通常我们在一个数据系统在一年中,累积失效的时间来衡量,例如: 系统可靠性为99.999%,即在系统运行的一年中累积只有0.001%的时间处于不可用状态,也就是大约累积5分钟的失效时间。
可维护性
可维护性的范围有些广泛,数据系统本质上是一种超大型的复杂软件,软件是具有生命周期的,其需求与功能都是在不断的变化的,我们需要在一定程度上面向未来的需求设计软件。以便可以更简单的部署,运维,扩展与修改系统。同时打造人类可理解的系统也尤为重要,说到底,软件是服务于人的,必将由人来负责,因此系统行为的可观测性与可理解性非常重要,对于静态的代码,可读性非常关键,这直接关心到一个软件系统的生命力,程序员对于生涩难懂的代码,需要花费大量的精力来理解,提高了其维护成本,对于一个系统来说,影响很可能被持续放大,例如如果程序员无法准确的监控系统,那么系统很可能产生程序员不可理解的行为,这种行为有可能没有意义,或者在特定场合触发bug,甚至可能是导致系统全部失效的bug,进而影响了可用性,又或者一个不可理解的行为导致系统吞吐量,响应时间等可扩展性指标无法提高或者下降。不能预期的行为在系统中是诱发问题的根本所在,因此从长远的角度来看,这三个关键指标中,可维护性是软件在整个生命周期中最应该重视的指标。并且,我们在此断言,系统不可理解的行为通过系统运行的放大作用,终将造成困难。
模型与语言
任何一个计算机从宏观上来看,其最主要的功能是用于计算与存储数据,对于数据密集型系统,其工作就是存储数据并在多个数据结构之间转换以及传输数据。那么为了支持系统存储数据,就需要特定的数据结构,但是底层的数据结构是面向计算机的,为了给使用者提供统一的心智模型,需要抽象一种人类可以接受的数据结构(尽管底层并非这样存储),这样的面向人类的模型就是数据系统的模型,对于存储系统来说,最基本的操作就是增删改查,以及对模型结构的操作,我们不能提供单一的命令或者API使用户操作特定的功能,为了支持复杂的查询统计功能,我们需要定义一种语言,以便用户更加灵活的操作这样的数据库系统。由此,我们知道,数据库的模型与语言是相互影响的,模型定义了基础的存储结构,语言提供了操作模型的规范,以决定以何种方式转换与传输数据。
表模型与SQL
在所有的数据库系统中,表模型是一种通用且成熟的模型,历史悠久的表模型基于行或列的存储,将每一条记录存储为多个字段所代表的一行记录,与人类组织数据最常用的表格非常相似,因此而得名。数据表的模型可以支持各种各样的需求。但并非每一种都很优雅。为了操作表模型,定义了SQL语言(结构化查询语言),其本质是一种结构化命令的语言,数据库通过解析SQL生成执行计划,并以此来操作表模型的方式实现数据库的各项功能。表模型的优点在于 支持各种各样的数据处理形式,无论是查询单表,还是join多张表,一对多,多对一,其存储引擎非常的成熟,抗住很多应用场景的使用,但其本身也是有缺点的,表模型在逻辑上与应用程序的数据模型天生不一致,应用层主要是面向对象,而数据库是表模型,二者操作之间需要对数据进行转换,这是一种代价,在开发设计阶段,为了遵循表设计的范式反而变得麻烦,表设计也算是表模型的一种代价,在使用之前我们需要根据规范设计合理的表结构,这也是一种开发成本,sql的使用上为了得到更高的性能表现,需要对sql的使用进行优化,这也是一种使用成本,总之mysql,PostgreSQL,Oracle等数据提供全能而成熟的表模型时也同样引入了学习,使用,维护上的复杂度。
文档模型与其查询语言
文档模型就是将一条记录组织成json等文本形式的存储模型,其查询语言也是类似json的形式。其优势在于对操作数据性能的提升,以失去表模型对多对多 join查询的灵活性为代价。将数据组织成json的好处是,充分的利用数据模型中局部性原理,在查询某一范围相关的数据时可以快速的将数据返回,获得低延迟的查询效果。同时不用考虑严格的表规范,不再存在类似表结构的约束,每一json记录都可以拥有任意字段,非常的灵活多变,以此来应对对数据处理的各种场景。但其缺点也很明显,那就是不太适合数据记录之间有太多关系的场景尤其是多对多等需要大量join查询的场景。同时,无写入时模式不代表其没有模式,其约束会在读取时做检查,对支持的索引类型等进行校验。
图数据模型与GraphQL
图模型是另一种极端,图模型也是一种读时模式检查,这意味着写入时不做约束,这一特性让数据模型的建立变得快速而随意,将所有的记录组织成图的表述形式,对于图有三种表述形式,三元组,属性图,超图.目前前两者使用的比较多,将数据组织成,主语,谓词,宾语形式来存储数据,或者组织成节点,关系,属性或节点的方式存储数据。图模型的最大优势就是其对复杂关系的灵活支持,对于关系稠密的数据场景其可以利用图遍历的局部性优势快速的实现各种多对多查询。
数据库的两种类型
数据处理存在两种类型OLTP与OLAP,分别是在线事务处理与在线分析处理,之所以要从用途的角度如此划分,对两种指标的不同需求,OLTP追求低延迟,用于快速的响应用户的请求,所以适合在线业务使用,OLAP追求的是吞吐量,为用户提供大量数据的离线计算与存储,进而得到分析数据后,给用户使用,事实上数据处理从目标上来说,一种是给用户提供一致的操作(增删改)以及局部的查询,这些都应该一致且快速,而剩下的则是大规模的查询需求,通常需要全表扫描与离线并行计算,通常在数据仓库技术中使用,查询结果通常会计算的很慢,所以衡量的指标应该是磁盘IO的吞吐量而非返回数据的延迟。从本质上正是对数据系统所期望的功能不同,进而考察的指标也会不同,从而产生了数据处理的这两种分类,以后的文章我会分别叙述这二者在实现与设计理念的不不同考量。
数据库如何存储数据?
数据库的核心就是持久化的存储数据,持久化的存储依赖于磁盘(依赖内存是否也能持久化呢?),那么将数据写入磁盘,并从磁盘中读取数据就是数据库的核心功能。但,数据库的意义在于高效的存储并支持复杂的查询方式,保证查询关联数据的性能。所以数据库必须做到,支持写入大量数据文件并以多种多样的维度读取文件。那么如何做到呢?以关系型数据库为例,一行记录假如在文件中就是一行字符串,怎么做到快速的插入一行字串呢?磁盘是圆的,通过磁头与磁针的调整进行寻址,寻道。这是磁盘存储数据最耗时的动作,减少耗时且不必要的事情就是性能优化的哲学。因此,我们通过仅追加写文件的方式存储数据就会使写入的性能最高效,因为磁头与磁盘不需要来回的寻道,寻址。但,仅追加的写入文件,那我要是想删除这个文件,或者修改这个文件该怎么办?那就采用版本追加的方式,无论是删除还是修改操作,都仅追加的写入文件一行记录,为数据库创建一个自增的唯一字段,用来表示唯一的数据记录,删除和修改操作都是仅追加到文件但唯一字段相同,然后在读取时从文件尾部开始读取数据,这样一来,匹配唯一标识的第一个记录就是最新的记录值了,但是新的问题是这样的, 存储方式会大量的浪费磁盘空间,文件会越来越大,从而影响查询的性能,因为大部分数据都会是旧版本数据。所以,我们需要定期的对数据文件进行压缩,仅追加文件到一定大小时,就将其复制一份并压缩(去除重复标识的记录),以便节省空间,但是压缩文件的数量又太多怎么办?我们就将其进行合并,定期的按顺序将文件合并成一个大文件,这样一个最简单的原始数据库的存储部分的设计就完成了。 这样一来,我们的写入性能将是最高的,可以说所有主流开源数据库的写入性能都没有这个设计快,但是软件架构的本质就是决策与权衡。这样的设计,读取一行记录需要遍历整个文件,如果读取第一个存储的数据,需要遍历整个文件,查询性能完全不可以接受,因此我们需要继续想一想如何提高读取的性能。
查询是数据库存在的意义,影响我们存储数据的结构有两个因素,一个是占用空间的大小,一个是查询时的响应时间,通常后者更加重要,数据库存储的数据本质上也是文件,如何能快速的找到文件内的某个记录呢?分类是快速找到曾经放置物品的方法,这在计算机中同样适用,为数据记录建立分类,那么分类应该怎么被存储,也就是说每次在添加,删除,更新数据记录的时候都要将分类信息也一同更新吗?这样不会更慢吗?但是,这是值得的,因为在数据库中查询是一等重要的事情。那么这个分类将是什么结构的呢?观察生活中的细节,书中的目录本质上是分级的,也就是树形的结构,那么分类以树作为存储结构应该是最自然的,但树的本质是映射,那么hash映射应该也是可以作为分类的,我们以一个人的身份证id为key,经过散列也可以得到其位置信息,而且查询速度更快。但很显然,hash的形式是没法进行顺序查询的对于要求顺序范围的查询将无能为力。在这里,需要给这个分类数据结构下一个定义,将这种可以帮助我们快速找到数据集的数据结构称之为索引,事实上存储数据库主要工作就是如何维持索引的正确性以及有效性。
使用树作为索引,那使用什么树?从最简单的二叉树说起,我们知道,索引应该被存储在磁盘上,每个节点都应该代表磁盘的一个可寻址的地址信息,索引树的查询效率是与树的高度成正相关的,因为树的高度决定你要在磁盘上进行多少次的寻址操作,而磁盘的寻址是耗时的操作,因此一个优秀的适合磁盘的索引树应该是尽量降低树的高度的树。对于树来说,我们知道删除与添加操作会破坏树的平衡性,使树的查询效率退化,数据库是支持任意删除与添加的,所以索引树应当可以自平衡,AVL还是红黑树?这几种树都是二叉树,树的高度会很高,降低树的高度一定是M叉树,能自平衡的树很显然是B树。B树是一种自平衡的多叉树,将B树充分与磁盘的特性结合,B树的一个节点本质上是一个数组,表示数据记录中的一个索引字段,这个数组的大小恰巧设计成一个磁盘页的大小,因为文件系统读取磁盘时是按页批量读取的,一次IO可以读取一个64KB的磁盘页到内存中。这样64K大小就可以用来存储数据了,然而如果非叶子节点上存在数据记录,那么就会影响这个节点能包含的索引字段的数量,索引字段存储的少了,还是会增高B树的高度,同时会造成数据分布不均匀,使查询的效率变的不稳定。所以,还是需要改进一下B树,进而引入了B+树,我们将数据记录全部存储在B+树的叶子节点上,并且将叶子节点连成一个链表便于进行范围查询,这就实现了sql中的模糊查询,区间查询等功能。这样一来,非叶子节点上全部都是索引字段,那么就可以将树的高度将到最少,同时作为B+树我们还可以,在数据的删除与插入时对其进行自平衡操作,为B+树的每个节点进行分裂与合并操作。
现在我们引入了索引数据,那么在文件结构上如何进行设计呢?假设我们以唯一的标示ID为索引字段,那么如何将索引文件与数据文件相互联系起来呢?这里有两种方式,将索引文件与数据文件合并在一起,这样查询效率是最高的,但缺点是只能有一个索引字段(很显然用主键作为索引,主键就是我们说的数据记录的唯一标识),没办法扩展索引,如果我们要扩展到多个需要索引的字段,那就只能在数据文件之外扩展索引文件,那么B+树的叶子节点存储的是什么呢?存储着主键?这样独立的索引文件会更加节省空间便于扩展,存储着数据记录的磁盘地址吗?那要是地址发生变化更新地址会很麻烦,所以综合来看,存储主键比较合适。上述的两种索引类型即是聚集索引与非聚集索引的区别。
然而,回到最初的设计 如果我们真的使用上述的B+树的索引 那么就意味着我们必须在磁盘上进行随机的读写,幸运的是索引的存在使得随机的写入磁盘不是那么难以接受,上述索引实现方案称之为 原地更新, 与我们上述所说的仅追加写入是有所差别的,这种原地更新文件的实现方案,通常是主流关系型数据库的实现方案,通常适合那些 有大量并发请求,少量写入,多数读取的场合。那么我们最开始设计的数据库,有什么意义呢? 能不能继续利用仅追加的方式实现?如何在保证写入吞吐量的前提下降低读取的延迟?
如果我们可以充分的利用内存的特性就可以做到这一点,所有的写入都可以先写在一个有序的内存结构中,不能是hash表,因为按索引字段维护顺序,内存中维护顺序的适合索引的数据结构有哪些?红黑树?跳表? 这些都是可以的,只要将所有的写入请求全部在内存中存储一份,当内存的数据容量到达一个阈值后,就可以按顺序的 仅追加的写入磁盘文件中,这样的文件称之为SSTable,而这样的内存数据结构称之为LSM树,日志排序归并树,而这种存储引擎的实现方案称之为日志结构更新方案。
但是上述使用内存的LSM+SSTable的方案,如果节点崩溃内存数据没有来得及合并到磁盘文件中,那数据是否就丢失了呢?原地更新的方案也是存在这样的问题,我们还是会在未插入之前就可能崩溃丢失数据,那么有什么办法可以提高数据库的可靠性呢?那就是充分利用可追加文件的特性,我们可以写入记录到来之前立刻,将数据写入一个文件中,这个文件是仅追加的写入,非常的快速(文件系统缓存,磁盘不需要寻址寻道),并且这个文件不需要建立索引,当数据库重新启动的时候我们可以先去读取这个文件内的写入记录,逐条的将记录写入存储引擎中即可,当然如果在写入追加文件中崩溃了那就没办法了,不过经验证明这样的可靠性已经足够,这个仅追加的用于数据恢复的文件称之为VWL,预写日志文件。
综上,我们数据库就设计完了,这样一个简单的描述,就是现代数据库存储引擎的核心理念。
列式存储
列式存储是将一条数据记录的列单独存储在不同的列文件中,所有的列文件以顺序来唯一表示一条完整的记录。列式存储是专门对大量的离线查询进行优化的存储格式,这样做有什么益处?首先,这种存储方式解决了行存储中,列数量过多造成聚合查询时IO操作了太多无用数据。大多数情况下聚合查询仅仅关注记录的一部分,在列存储的数据库中,聚合查询时,只需要将那个单独一列的文件进行读取即可。其次,就是列式存储形式极易适合对某一列单独进行压缩处理,数据一旦被压缩那么就会提高读取一写入的吞吐量
如何压缩?根据记录的特点进行压缩,例如排序的列,并存在重复值,可以使用三元组压缩,(x,y,z), 表示从数值x从第y行到第z行都是x值,如果重复数值多但是没有排序则可以使用位图进行压缩,还可以对位图进一步压缩表示,对于有序且重复不多的列可以将当前数值表示为前一个数值加上一个变化量的形式,然后再对其进行压缩。
列式存储中,文件的形式是每一列单独一个文件,在文件后半部分标示数据字段在文件中的偏移位,这就导致当列式存储的数据库想要写入一条数据记录时需要操作更多的列文件,而且由于严格依赖顺序位置来关联不同的列文件中的列为同一条记录,因此很难建立索引,不过由于列存储数据库通常用于大规模查询的OLAP场景,因此可以将数据的不同备份之间以不同的key进行排序,以此来达到加速查询的目的。
同时,我们还可以使用上文提到的LSM+SSTable的方法实现存储来提高列式存储的写入吞吐量,并利用LSM树的特定对列文件进行排序。