《设计数据密集应用》分为三个部分,数据系统基础;分布式数据;衍生数据;每部分包含若干章节,本文为DDIA读书笔记,包含作者阅读记录,仅用于作者本人学习记录,如有理解歧义/误区等,以原书为准。

数据系统基础

可靠性,可伸缩性和可维护性

本书开篇讲述了作为一个计算密集形的应用需要什么?

一个数据密集的应用程序,通常会包含若干组件,有经验的工程师通常会优先设计数据存储方式用于存储数据,这代表持久化。但随着技术的不断发展,某些应用也可以不使用传统的数据库实现持久化。(比如笔者见过某些应用在云原生中使用ConfigMap进行数据存储)

考虑持久化后不可避免地涉及到数据库的选型(是选择关系性还是非关系型数据库),或者是复杂查询场景下昂贵开销使得应用响应变慢,引入缓存机制。亦或者是为了优化用户体验,允许用户通过关键字搜索数据,过滤等,接入搜索索引。为了避免页面响应过长采用异步处理,还是为了定期处理,清洗大批量数据引入的批处理

这些机制已经被数据系统很好抽象成为某些数据库某个特点了,但是现实中的场景往往比上述要复杂百倍,不同的数据库系统也有不同的特性。当为了实现某个复杂应用时,不得不组合使用这些工具,这还是有些难度的。

所以第一部分我们需要了解数据系统的共性与特性,及各自实现的原理。

第一章的目标就是为了弄懂:可靠,可伸缩,可维护的数据系统是什么样的。

可靠性

人们总是希望自己使用的工具是可靠的,对于软件也有同样直观的想法。比如在使用一个搜索引擎,当你输入关键词后,理所应当地会对查询结果有一个心理预期,并期望它能返回符合预期的答案或结果。

同时人们总是对软件有更高的期许:希望即使不按照正常流程操作,它也不会崩溃;希望它的性能足够好,允许在一定负载下也能良好运行。

工程师们都在追求应用程序的容错性,但是绝对不能容忍因为故障而导致的失效。而故障又分为几种情况:

1.硬件故障:硬盘崩溃,内存出错,机房断电,网线磨损…这些基础的,物理的设备出现故障时给应用程序的打击几乎是毁灭的。当设备足够多时,这些情况总有一天会出现的。为了减少系统的故障率,第一反应通常都是增加单个硬件的冗余度,因为硬件的故障有可能是不可逆的,只要你能快速地把备份恢复到新机器上,故障停机时间对大多数应用而言都算不上灾难性的。只有少量高可用性至关重要的应用才会要求有多套硬件冗余。

对于云平台来说,灵活性和弹性是最优先的,单机可靠显得并不是那么重要。如果出现硬件故障,云平台可以快速创建出新的实例并将备份恢复到机器上,从而增强应用的可靠性。

2.软件故障:通常情况下我们认为,系统性错误是难以意料的,比如Linux 内核错误,比如开源组件的Bug(或彩蛋)。这种系统故障只有在异常情况下触发时才会发现,但发现时可能应用程序已经失效了。对于软件故障只能通过:测试,进程隔离,允许进程崩溃并重启(容器化),测压等方式降低它出现的几率。

3.人为错误:人为因素导致的应用程序可靠性太常见了,运维配置错误是导致服务中断的首要原因,而硬件故障(服务器或网络)仅导致了 10-25% 的服务中断。作者本人就曾在运维时误删生产环境两千条数据,人类也是不可靠的。在DDIA中总结出以下几种方法来降低人为错误的出现几率:

  • 以最小化犯错机会的方式设计系统。例如,精心设计的抽象、API 和管理后台使做对事情更容易,搞砸事情更困难。但如果接口限制太多,人们就会忽略它们的好处而想办法绕开。很难正确把握这种微妙的平衡。
  • 将人们最容易犯错的地方与可能导致失效的地方 解耦(decouple)。特别是提供一个功能齐全的非生产环境 沙箱(sandbox),使人们可以在不影响真实用户的情况下,使用真实数据安全地探索和实验。
  • 在各个层次进行彻底的测试,从单元测试、全系统集成测试到手动测试。自动化测试易于理解,已经被广泛使用,特别适合用来覆盖正常情况中少见的 边缘场景(corner case)
  • 允许从人为错误中简单快速地恢复,以最大限度地减少失效情况带来的影响。 例如,快速回滚配置变更,分批发布新代码(以便任何意外错误只影响一小部分用户),并提供数据重算工具(以备旧的计算出错)。
  • 配置详细和明确的监控,比如性能指标和错误率。 在其他工程学科中这指的是 遥测(telemetry)(一旦火箭离开了地面,遥测技术对于跟踪发生的事情和理解失败是至关重要的)。监控可以向我们发出预警信号,并允许我们检查是否有任何地方违反了假设和约束。当出现问题时,指标数据对于问题诊断是非常宝贵的。
  • 良好的管理实践与充分的培训 —— 一个复杂而重要的方面,但超出了本书的范围。

可伸缩性

一个系统今天能可靠运行并不代表未来一直能可靠运行。对于商业软件来说,随着用户人数的不断增多面临的挑战也就越多,并发量的快速增长,处理数据的量级变大。

可伸缩性用来描述系统应对负载增长能力的术语。但是在谈论可伸缩性之前,我们要明白(或至少正确了解)负载和性能到底是什么。

描述负载

负载可以使用负载参数数字来描述,每秒向Web服务器发出的请求、数据库中的读写比率、同时活跃的用户数量、缓存命中率或其他东西。在某些情况下,高流量网站的请求数可能超过每秒数万次甚至更多。对于普通的中小型网站,每秒数百到数千次的请求数是比较典型的范围。

描述性能

1.增加负载参数并保持系统资源(CPU、内存、网络带宽等)不变时,系统性能将受到什么影响?

2.增加负载参数并希望保持性能不变时,需要增加多少系统资源?

吞吐量(throughput)即每秒可以处理的记录数量,或者在特定规模数据集上运行作业的总时间。对于在线系统,通常更重要的是服务的 响应时间(response time),即客户端发送请求到接收响应之间的时间。

现在我们来通俗翻译刚才的两个问题:

1.当增加负载时系统资源不变,那么响应时间会变长吗?

答案是:会

2.当增加负载的同时希望响应时间不变长,需要增加多少系统资源(内存等物理资源)?

带着这两个问题我们继续讨论响应时间。

即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值 分布(distribution),而不是单个数值。

我们直接忽略平均响应时间,它并不是一个非常好的指标,因为它不能告诉你有多少用户实际上经历了这个延迟。通常使用 百分位点(percentiles) 会更好。如果将响应时间列表按最快到最慢排序,那么 中位数(median) 就在正中间:举个例子,如果你的响应时间中位数是 200 毫秒,这意味着一半请求的返回时间少于 200 毫秒,另一半比这个要长。

为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第 95、99 和 99.9 百分位点(缩写为 p95,p99 和 p999)。它们意味着 95%、99% 或 99.9% 的请求响应时间要比该阈值快,例如:如果第 95 百分位点响应时间是 1.5 秒,则意味着 100 个请求中的 95 个响应时间快于 1.5 秒,而 100 个请求中的 5 个响应时间超过 1.5 秒。

响应时间的高百分位点(也称为 尾部延迟,即 tail latencies)非常重要,因为它们直接影响用户的服务体验。请求响应最慢的客户往往也是数据最多的用户,这代表他们在系统中投入更多的成本。

百分位点通常用于 服务级别目标(SLO, service level objectives)服务级别协议(SLA, service level agreements),即定义服务预期性能和可用性的合同。 SLA 可能会声明,如果服务响应时间的中位数小于 200 毫秒,且 99.9 百分位点低于 1 秒,则认为服务工作正常(如果响应时间更长,就认为服务不达标)。这些指标为客户设定了期望值,并允许客户在 SLA 未达标的情况下要求退款。

排队延迟(queueing delay) 通常占了高百分位点处响应时间的很大一部分。由于服务器只能并行处理少量的事务(如受其 CPU 核数的限制),所以只要有少量缓慢的请求就能阻碍后续请求的处理,这种效应有时被称为 头部阻塞(head-of-line blocking) 。即使后续请求在服务器上处理的非常迅速,由于需要等待先前请求完成,客户端最终看到的是缓慢的总体响应时间。因为存在这种效应,测量客户端的响应时间非常重要。

实践中的百分位点

在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行调用完成。如 图 1-5 所示,只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(效果称为尾部延迟放大)

应对负载的方法

通过上述一系列性能指标和负载参数,现在我们可以讨论:当负载参数增加时,如何保持良好的性能?

改架构可能是其中一个方案,显而易见低负载的架构没办法应付高负载。人们常常讨论纵向伸缩(垂直伸缩,使用更大的内存,更强大的物理服务器来应对高负载)和 横向伸缩(使用更多的小型物理机,将负载均匀分布在上面)。

还有一种方法是弹性伸缩,检测到负载增加时自动增加计算资源。当然也可以人为去进行手动伸缩,这会让意外操作更少一点。

比如博客园就曾经利用弹性伸缩抢注云实例,但也可能因为云实例被消耗殆尽而抢注失败,当负载增加时导致部分用户访问失败。

跨多台机器部署 无状态服务(stateless services) 非常简单,但将带状态的数据系统从单节点变为分布式配置则可能引入许多额外复杂度。出于这个原因,常识告诉我们应该将数据库放在单个节点上(纵向伸缩),直到伸缩成本或可用性需求迫使其改为分布式。

随着分布式系统的工具和抽象越来越好,至少对于某些类型的应用而言,这种常识可能会改变。可以预见分布式数据系统将成为未来的默认设置,即使对不处理大量数据或流量的场景也如此。

可维护性

大部分的软件不是随着开发周期结束而结束,而是在不断修复漏洞,版本更替,新功能新特性的开发中周而复始。有过开发经验的工程师通常不喜欢维护前人遗留下来的系统,因为这代表着可能有你来偿还技术债务,修复bug,调整架构……

更有经验的工程师会在系统设计之初就特别关注设计的三个原则:

1.可操作性:便于运维团队保持系统平稳运行。

为了便于运维团队操作,往往需要引入更多的工具,比如监控系统的运行状况,跟踪问题,建立部署、配置、管理方面的良好实践,编写相应工具。考虑平台迁移,平台的扩展等。

2.简单性:从系统中消除尽可能多的 复杂度(complexity),使新工程师也能轻松理解系统。

用于消除 额外复杂度 的最好工具之一是 抽象(abstraction)。一个好的抽象可以将大量实现细节隐藏在一个干净,简单易懂的外观下面。一个好的抽象也可以广泛用于各类不同应用。比起重复造很多轮子,重用抽象不仅更有效率,而且有助于开发高质量的软件。抽象组件的质量改进将使所有使用它的应用受益。

3.可演化性:使工程师在未来能轻松地对系统进行更改,当需求变化时为新应用场景做适配。也称为 可扩展性(extensibility)可修改性(modifiability)可塑性(plasticity)

可以参考Kubernetes 的设计方式,在最初就考虑到了插件的集成和扩展性。

本章小结

可靠性(Reliability) 意味着即使发生故障,系统也能正常工作。故障可能发生在硬件(通常是随机的和不相关的)、软件(通常是系统性的 Bug,很难处理)和人类(不可避免地时不时出错)。 容错技术 可以对终端用户隐藏某些类型的故障。

可伸缩性(Scalability) 意味着即使在负载增加的情况下也有保持性能的策略。为了讨论可伸缩性,我们首先需要定量描述负载和性能的方法。我们简要了解了推特主页时间线的例子,介绍描述负载的方法,并将响应时间百分位点作为衡量性能的一种方式。在可伸缩的系统中可以添加 处理容量(processing capacity) 以在高负载下保持可靠。

可维护性(Maintainability) 有许多方面,但实质上是关于工程师和运维团队的生活质量的。良好的抽象可以帮助降低复杂度,并使系统易于修改和适应新的应用场景。良好的可操作性意味着对系统的健康状态具有良好的可见性,并拥有有效的管理手段。

数据模型与查询语言

数据模型可能是开发中最重要的部分,它们不仅影响软件的编写方式,而且影响我们的解题思路。

大部分应用程序都使用层层叠加的数据模型构建。数据结构和通用数据模型来表示现实世界中的各个对象,比如金钱,货物,组织,人员等信息。

数据模型种类繁多,每个数据模型特性都不尽相同,有些用法很容易,有些则不支持如此;有些操作运行很快,有些则表现很差;有些数据转换非常自然,有些则很麻烦。

第二章中我们探讨主流的数据模型,对关系模型,文档模型和少量基于图形的数据模型进行分析,同时还将各种查询语言并比较它们的用例。

关系性模型与文档模型

最著名的数据模型可能是SQL。它基于关系模型:数据被组织成关系(SQL中称为表),其中每个关系的是元祖(SQL中称行)的无序集合。

如今网上的大部分内容依旧是由关系性数据库来提供支持,它在互联网的各个领域中都占据主导地位。

NoSQL试图推翻关系模型的统治地位,如今它被重新解释为:不仅是SQL(Not Only SQL)。使用NoSQL数据库通常会有几个原因:

  • 需要比关系数据库更好的可伸缩性,包括非常大的数据集或非常高的写入吞吐量
  • 免费开源
  • 关系模型中不能很好支持一些特殊的查询操作,期望在NoSQL中更容易的实现它
  • 期望一些具有多动态性与表现力的数据模型

当然不同的应用程序存在不同的需求,数据模型也没有万金油,作者本人也赞成书中对于未来数据模型的判断:在可预见的未来,关系数据库似乎可能会继续与各种非关系数据库一起使用 - 这种想法有时也被称为 混合持久化(polyglot persistence)。

对象关系不匹配

目前大多数应用程序开发都使用面向对象的编程语言来开发,这导致了对 SQL 数据模型的普遍批评:如果数据存储在关系表中,那么需要一个笨拙的转换层,处于应用程序代码中的对象和表,行,列的数据库模型之间。模型之间的不连贯有时被称为 阻抗不匹配(impedance mismatch)

有经验的工程师会使用对象关系映射(ORM object-relational mapping)框架,虽然能减少转换层所需的样板代码数量,但是不能完全隐藏两个模型之间的差异。

在《设计数据密集型应用》一书中使用关系性模型做了一个例子来表现简历:

Users table:

user_id first_name last_name summary
255 Bill Gates Co-chair of … blogger.

Regions table

region_id region_name
255 Greater Boston Area
1
2
3
4
5
6
7
8
9
10
11
type Users struct {
UserId int `json:"user_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Summary string `json:"summary"`
}

type Regions struct {
RegionName int `json:"region_name"`
UserId int `json:"user_id"`
}

一般SQL关系性模型通过一个唯一的标识符user_id来标识,对User表提供外键的引用,当然后续的SQL标准中增加对结构化数据类型和XML数据的支持;允许将多值存储在单行内,并支持文档内查询和索引。JSON 数据模型也能得到多个数据库的支持。

Json模型减少了应用程序和存储层之间的阻抗不匹配,当然它也有自己问题。比如灵活性不足。Json的表现更像是一个结构清晰的树状结构。

image-20230809160638692

多对一和多对多的关系

在现实生活中,多对一关系实例可以继续用上述例子,一个部门可以拥有多名所属员工,通常情况下,一名员工只能够属于某个部门。

而在多对多的关系中:一名员工可以参与多个项目,一个项目也可以有多名员工参与。

我们现在略微懂了不同情况的关系映射,现在回到原书中的讨论。

对于计算机来说,工程师在构建关系性数据结构(建数据库表)时,会按照一定的数据库规范化来执行。当然也有一定的技巧,比如关系性模型使用外键来解决关系映射。

在关系数据库中,通过 ID 来引用其他表中的行是正常的,因为连接很容易。在文档数据库中,一对多树结构没有必要用连接,对连接的支持通常很弱。

如果数据库本身不支持连接,则必须在应用程序代码中通过对数据库进行多个查询来模拟连接。(在这种情况中,地区和行业的列表可能很小,改动很少,应用程序可以简单地将其保存在内存中。不过,执行连接的工作从数据库被转移到应用程序代码上。)

image-20230809162338882

文档数据库

在多对多的关系和连接已常规用在关系数据库时,文档数据库和 NoSQL 重启了辩论:如何以最佳方式在数据库中表示多对多关系。那场辩论可比 NoSQL 古老得多,事实上,最早可以追溯到计算机化数据库系统。

IMS 的设计中使用了一个相当简单的数据模型,称为 层次模型(hierarchical model),它与文档数据库使用的 JSON 模型有一些惊人的相似之处,它将所有数据表示为嵌套在记录中的记录树,这很像JSON结构。

同文档数据库一样,IMS 能良好处理一对多的关系,但是很难应对多对多的关系,并且不支持连接。开发人员必须决定是否复制(非规范化)数据或手动解决从一个记录到另一个记录的引用。这些二十世纪六七十年代的问题与现在开发人员遇到的文档数据库问题非常相似。

那时人们提出了各种不同的解决方案来解决层次模型的局限性。其中最突出的两个是 关系模型(relational model,它变成了 SQL,并统治了世界)和 网状模型(network model,最初很受关注,但最终变得冷门)。这两个阵营之间的 “大辩论” 在 70 年代持续了很久时间。

网状模型也被称为CODASYL 模型。在层次模型的树结构中,每条记录只有一个父节点;在网络模式中,每条记录可能有多个父节点。网状模型中记录之间的链接不是外键,而更像编程语言中的指针(同时仍然存储在磁盘上)。访问记录的唯一方法是跟随从根记录起沿这些链路所形成的路径。这被称为 访问路径(access path)

最简单的情况下,访问路径类似遍历链表:从列表头开始,每次查看一条记录,直到找到所需的记录。但在多对多关系的情况中,数条不同的路径可以到达相同的记录,网状模型的程序员必须跟踪这些不同的访问路径。

CODASYL 中的查询是通过利用遍历记录列和跟随访问路径表在数据库中移动游标来执行的。如果记录有多个父结点(即多个来自其他记录的传入指针),则应用程序代码必须跟踪所有的各种关系。甚至 CODASYL 委员会成员也承认,这就像在 n 维数据空间中进行导航。

网状模型让查询和更新数据库的代码变得更加复杂,无论是分层还是网状模型,如果没有所需数据的路径就会陷入困境。你可以改变访问路径,但是必须浏览大量手写数据库查询代码,并重写来处理新的访问路径。更改应用程序的数据模型是很难的。

这里是直接截取原书的一段,稍微了解一下就可以了,现实中这种模型几乎不会遇到。

关系模型只有一个关系(表)和一个元组(行)的集合。可以选择符合任意条件的行,读取表中的任何或所有行。也可以通过指定某些列作为匹配条件来读取特定的行。可以在任何表中插入一个新的行,而不必担心与其他表的外键关系。

在关系数据库中,查询优化器自动决定查询的哪些部分以哪个顺序执行,以及使用哪些索引。这些选择实际上是 “访问路径”,但最大的区别在于它们是由查询优化器自动生成的,而不是由程序员生成,所以我们很少需要考虑它们。

在关系模型中,创建一个新的索引,查询会自动使用最合适的索引,无需程序员进行手动配置。所以关系性模型让应用添加新功能更加方便。

关系模型的一个关键洞察是:只需构建一次查询优化器,随后使用该数据库的所有应用程序都可以从中受益。如果你没有查询优化器的话,那么为特定查询手动编写访问路径比编写通用优化器更容易 —— 不过从长期看通用解决方案更好。

这点可以读一下Mysql 中的InnoDB的设计

文档数据库在其父记录中存储嵌套记录,而不是在单独的表中。在表示多对一和多对多的关系时,关系数据库和文档数据库并没有根本的不同:在这两种情况下,相关项目都被一个唯一的标识符引用,这个标识符在关系模型中被称为 外键,在文档模型中称为 文档引用。该标识符在读取时通过连接或后续查询来解析。迄今为止,文档数据库没有走 CODASYL 的老路。

关系性数据库与文档数据库在今日的对比

关系数据库与文档数据库存在多方面的差异,比如容错性和处理并发性,但是本小结只讨论数据模型中的差异。

支持文档数据模型的主要论据是架构灵活性,因局部性而拥有更好的性能,以及对于某些应用程序而言更接近于应用程序使用的数据结构。关系模型通过为连接提供更好的支持以及支持多对一和多对多的关系来反击。

哪种数据模型更有助于简化应用代码?

如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。

文档模型有一定的局限性:例如,不能直接引用文档中的嵌套的项目,虽然只要嵌套的不太深,这也不算是个问题。

但是如果项目要用到多对多关系,文档模型就会让原本繁琐的连接查询变得更麻烦。这回导致复查功能下的代码质量变得堪忧,以及更难以忍受的性能。

对高度关联的数据而言,文档模型是极其糟糕的,关系模型是可以接受的,而选用图形模型(请参阅 “图数据模型”)是最自然的。

文档模型中的模式灵活性

以为MongoDB中的BSON作例子,如果程序经常需要访问整个文档,那么存储局部性会带来性能优势。如果数据分割到多个表中,则需要进行多次索引查找才能全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。

局部性仅仅适用于同时需要文档绝大部分内容的情况。数据库通常需要加载整个文档,即使只访问其中的一小部分,这对于大型文档来说是很浪费的。更新文档时,通常需要整个重写。只有不改变文档大小的修改才可以容易地原地执行。因此,通常建议保持相对小的文档,并避免增加文档大小的写入。这些性能限制大大减少了文档数据库的实用场景。

如果使用文档数据库,比如MongoDB,在设计文档数据模型时候要保持相对较小的文档

文档和关系数据库的融合

随着时间的推移,关系数据库和文档数据库似乎变得越来越相似,这是一件好事:数据模型相互补充,如果一个数据库能够处理类似文档的数据,并能够对其执行关系查询,那么应用程序就可以使用最符合其需求的功能组合。

关系模型和文档模型的混合是未来数据库一条很好的路线。

数据查询语言

SQL是一种声明式查询语言,而IMS和CODASYL使用命令式代码来查询数据库。

命令式语言告诉计算机以特定顺序执行某些操作。可以想象一下,逐行地遍历代码,评估条件,更新变量,并决定是否再循环一遍。在声明式查询语言(如 SQL 或关系代数)中,你只需指定所需数据的模式 - 结果必须符合哪些条件,以及如何将数据转换(例如,排序,分组和集合) - 但不是如何实现这一目标。数据库系统的查询优化器决定使用哪些索引和哪些连接方法,以及以何种顺序执行查询的各个部分。

SQL 示例不确保任何特定的顺序,因此不在意顺序是否改变。但是如果查询用命令式的代码来写的话,那么数据库就永远不可能确定代码是否依赖于排序。SQL 相当有限的功能性为数据库提供了更多自动优化的空间。

最后,声明式语言往往适合并行执行。现在,CPU 的速度通过核心(core)的增加变得更快,而不是以比以前更高的时钟速度运行。命令代码很难在多个核心和多个机器之间并行化,因为它指定了指令必须以特定顺序执行。声明式语言更具有并行执行的潜力,因为它们仅指定结果的模式,而不指定用于确定结果的算法。在适当情况下,数据库可以自由使用查询语言的并行实现。

MapReduce 既不是一个声明式的查询语言,也不是一个完全命令式的查询 API,而是处于两者之间:查询的逻辑用代码片段来表示,这些代码片段会被处理框架重复性调用。它基于 map(也称为 collect)和 reduce(也称为 foldinject)函数,两个函数存在于许多函数式编程语言中。

本章小结

数据模型与查询语言这章内容繁多且杂,数据模型本身是一个巨大课题,本笔记只是简略记述了文档模型和关系模型。

1.文档数据库只要关注自我包含的数据文档,文档之间的关系连接非常浅薄,它在单个文档中保证了自己的灵活性,但是在多对多关系的查询中变得更加复杂。

2.图形数据库用于相反的场景:任何事物之间都可能存在潜在关联,

关系模型,文档和图形模型在今天都在被广泛使用,在各自的领域中都发挥得很好,随着技术不断演变,它们中的某个部分也趋于相似。

文档数据库和图数据库有一个共同点,那就是它们通常不会将存储的数据强制约束为特定模式,这可以使应用程序更容易适应不断变化的需求。但是应用程序很可能仍会假定数据具有一定的结构;区别仅在于模式是明确的(写入时强制)还是隐含的(读取时处理)。

每个数据模型都具有各自的查询语言或框架,比如关系模型使用SQL,MongoDB的聚合管道等,上述只是简单举了几个例子,覆盖了只是一部分的层面。

存储与检索

本章会讨论:数据库如何存储数据,以及如何寻找需要的数据。

一般情况下程序员都不会从头实现自己的存储引擎,大部分程序员都不会去特意切换数据库的存储引擎(比如使用Mysql默认使用InnoDB),但是在本书中将要注意:

针对事务性负载优化的和针对 分析性 负载优化的存储引擎之间存在巨大差异。

索引

索引是从主数据衍生的 额外的(additional) 结构。许多数据库允许添加与删除索引,这不会影响数据的内容,而只会影响查询的性能。维护额外的结构会产生开销,特别是在写入时。写入性能很难超过简单地追加写入文件,因为追加写入是最简单的写入操作。任何类型的索引通常都会减慢写入速度,因为每次写入数据时都需要更新索引。

这是存储系统中一个重要的权衡:精心选择的索引加快了读查询的速度,但是每个索引都会拖慢写入速度。因为这个原因,数据库默认并不会索引所有的内容,而需要你程序员或数据库管理员(DBA),基于对应用的典型查询模式的了解来手动选择索引。你可以选择那些能为应用带来最大收益而且又不会引入超出必要开销的索引。

散列索引

SSTables 排序字符串表(Sorted String Table)

优点:

1.即使文件大于可用内存,合并段段操作仍然是简单而高效的。这种方法就像归并排序算法中使用的方法一样。

2.为了在文件中找到一个特定的键,不需要在内存中保存所有键的索引。

3.如果需要扫描多个键值对,可以将这些记录分组为块(Block)。稀疏内存索引中的每个条目都指向压缩块的开始处。除了节省硬盘空间之外,压缩还可以减少对 I/O 带宽的使用。

SSTables 在写入新数据时,会将其添加到内存中的平衡树数据局结构(例如:红黑树)。这个内存树也被称为**内存表(memtable)**。当内存表大于某个阈值时,将其作为SSTable 文件写入硬盘。然后新的写入可以在新的内存表实例继续进行。

读取时,首先尝试在内存表中找到对应的键,如果没有就在最近的硬盘段中寻找,如果还没有就再较旧的段中寻找,依此类推。

用SSTables制作LSM树(日志结构合并树)

它是基于更早之前的日志结构文件系统来构建的。基于这种合并和压缩排序文件原理的存储引擎通常被称为 LSM 存储引擎。

Lucene,是一种全文搜索的索引引擎,在 Elasticsearch 和 Solr 被使用,它使用类似的方法来存储它的关键词词典。全文索引比键值索引复杂得多,但是基于类似的想法:在搜索查询中,由一个给定的单词,找到提及单词的所有文档(网页、产品描述等)。这也是通过键值结构实现的:其中键是 单词(term),值是所有包含该单词的文档的 ID 列表(postings list)。在 Lucene 中,从词语到记录列表的这种映射保存在类似于 SSTable 的有序文件中,并根据需要在后台执行合并。

B树

像SSTables 一样,B树保持按键排序的键值对,这允许高效的键值查找和范围查询。但这也就是仅有的相似之处了:B树有着非常不同的设计理念。

B树将数据库分解成固定大小的块(block)或分页(page),默认大小为4kb但有时会更大,并且一次只能读取或写入一个页面。这种设计更接近于底层硬件,因为硬盘空间也是按固定大小的块来组织的。

每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在硬盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树。

B树和LSM树的比较

通常 LSM 树的写入速度更快,而 B 树的读取速度更快。LSM树的读取通常比较慢,因为它们必须检查几种不同的数据结构和不同压缩(Compaction)层级的 SSTables。

LSM树的优点:

1.B树索引中每块数据都必须至少写两次:一次写入预先写入日志(WAL),一次写入树页面本身(如果有分页还需要再写入一次)。即使在该页面中只有几个字节发生了变化,也需要接受写入整个页面的开销。有些存储引擎甚至会覆写同一个页面两次,以免在电源故障的情况下页面未完整更新。

2.反复压缩和合并SSTables,日志结构索引也会重写数据。数据库的生命周期中每笔数据导致对硬盘的多次写入被称为 写入放大

3.LSM通常比B树支持更高的写入吞吐量,部分原因是它们有时具有较低的写入方法,另一部分是因为它们顺序写入紧凑的SSTable文件而不是必须覆写树中的几个页面。

4.LSM具有较低的开销。较低的写入放大率和减少的碎片仍然对固态硬盘更有利:更紧凑地表示数据允许在可用的 I/O 带宽内处理更多的读取和写入请求。

LSM树的缺点:

1.日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。

2.如果写入吞吐量很高,并且压缩没有配置好,可能导致压缩跟不上写入速率。

3.日志结构化的存储引擎可能在不同的段中有相同键的多个副本。

事务处理还是分析?

什么是在线事物处理(OLTP,OnLine Transaction Processing)

应用程序通常使用索引通过某个键来查找少量记录,根据用户的输入来插入或更新记录。由于这些应用程序是交互式的,所以这种访问模式被称为在线事务处理

什么是在线分析处理(OLAP,OnLine Analytic Processing)

分析查询需要扫描大量记录,每个记录只读取几列,并计算汇总统计信息(如计数,总和或平均值),而不是直接将原数据返回给用户。为了将这种使用数据库的模式和事物处理区分开,所以被称为在线分析处理

image-20230814141730053

停止使用OLTP系统进行分析,转而在单独的数据库上运行分析,这个单独的数据库被称为数据仓库(data warehouse)

数据仓库

数据仓库是一个独立的数据库,分析人员可以查询内存而不影响OLTP操作。数据仓库包含了各种OLTP系统中所有的只读数据副本。

数据仓库的数据模型通常是关系型的,因为SQL通常很适合分析查询,有许多图形数据分析工具可以生成 SQL 查询,可视化结果,并允许分析人员探索数据(通过下钻、切片和切块等操作)。

但是数据仓库的内部和OLTP数据库可能完全不同,因为它们针对非常不同的查询模式进行了优化。现在许多数据库供应商都只是重点支持事务处理负载和分析工作负载这两者中的一个,而不是都支持。

星型和雪花型:分析的模式

事务处理领域使用大量不同的数据模型。在分析型业务中,数据模型的多样性则少得多。许多数据仓库都以相当公式化的方式使用,被称为星型模式(也称为维度建模)。

“星型模式” 这个名字来源于这样一个事实,即当我们对表之间的关系进行可视化时,事实表在中间,被维度表包围;与这些表的连接就像星星的光芒。

雪花模式比星形模式更规范化,但是星形模式通常是首选,因为分析师使用它更简单。

列式存储

如果表中有万亿行和数PB的数据,如何高效存储和查询就变成了一个问题。维度表通常要小得多(数百万行)。

列式存储在关系数据模型中是最容易理解的,但它同样适用于非关系数据。

列式存储布局依赖于每个列文件包含相同顺序的行。 因此,如果你需要重新组装完整的行,你可以从每个单独的列文件中获取第 23 项,并将它们放在一起形成表的第 23 行。

除了减少需要从硬盘加载的数据量以外,列式存储布局也可以有效利用 CPU 周期。

列压缩

通过压缩数据可以进一步降低对硬盘吞吐量多需求。列式存储通常很适合压缩。数据仓库中特别有效的一种技术是位图编码。

位图可以另外进行游程编码(run-length encoding,一种无损数据压缩技术),这可以使列的编码非常紧凑。这种位图索引非常适合数据仓库的各种查询。

按顺序排序的另一个好处是它可以帮助压缩列。这让数据仓库在添加数据时就应该排好顺序。

一个简单的游程编码(可以将该列压缩到几 KB —— 即使表中有数十亿行。

第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长的连续的重复值。排序优先级更低的列以几乎随机的顺序出现,所以可能不会被压缩。但对前几列做排序在整体上仍然是有好处的。

写入列式存储

列式存储、压缩和排序都有助于更快地读取这些查询。然而,他们的缺点是写入更加困难。

使用 B 树的就地更新方法对于压缩的列是不可能的。如果你想在排序表的中间插入一行,你很可能不得不重写所有的列文件。由于行由列中的位置标识,因此插入必须对所有列进行一致地更新。

LSM 树。所有的写操作首先进入一个内存中的存储,在这里它们被添加到一个已排序的结构中,并准备写入硬盘。内存中的存储是面向行还是列的并不重要。当已经积累了足够的写入数据时,它们将与硬盘上的列文件合并,并批量写入新文件。

聚合:数据立方体和物化视图

数据仓库的另一个值得一提的方面是物化聚合(materialized aggregates)。

数据仓库查询通常涉及一个聚合函数,创建这种缓存的方式是**物化视图(Materialized View)**。在关系数据模型中,它通常被定义为一个标准(虚拟)视图:一个类似于表的对象,其内容是一些查询的结果。不同的是,物化视图是查询结果的实际副本,会被写入硬盘,而虚拟视图只是编写查询的一个捷径。从虚拟视图读取时,SQL 引擎会将其展开到视图的底层查询中,然后再处理展开的查询。

当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。数据库可以自动完成该操作,但是这样的更新使得写入成本更高,这就是在 OLTP 数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中,它们可能更有意义(它们是否实际上改善了读取性能取决于使用场景)。

本章小节

存储引擎分为两大类:针对 事务处理(OLTP) 优化的存储引擎和针对 在线分析(OLAP) 优化的存储引擎。

  • OLTP 系统通常面向最终用户,这意味着系统可能会收到大量的请求。为了处理负载,应用程序在每个查询中通常只访问少量的记录。应用程序使用某种键来请求记录,存储引擎使用索引来查找所请求的键的数据。硬盘查找时间往往是这里的瓶颈。
  • 数据仓库和类似的分析系统会少见一些,因为它们主要由业务分析人员使用,而不是最终用户。它们的查询量要比 OLTP 系统少得多,但通常每个查询开销高昂,需要在短时间内扫描数百万条记录。硬盘带宽(而不是查找时间)往往是瓶颈,列式存储是针对这种工作负载的日益流行的解决方案。

OLTP能看到两种主流的存储引擎:

  • 日志结构学派:只允许追加到文件和删除过时的文件,但不会更新已经写入的文件。Bitcask、SSTables、LSM 树、LevelDB、Cassandra、HBase、Lucene 等都属于这个类别。
  • 就地更新学派:将硬盘视为一组可以覆写的固定大小的页面。 B 树是这种理念的典范,用在所有主要的关系数据库和许多非关系型数据库中。

为什么分析工作负载与 OLTP 差别很大:当你的查询需要在大量行中顺序扫描时,索引的重要性就会降低很多。相反,非常紧凑地编码数据变得非常重要,以最大限度地减少查询需要从硬盘读取的数据量。我们讨论了列式存储如何帮助实现这一目标。

本章对于存储引擎内部知识进行了讲解,但消化这些知识还需要一定积累,至少它有了足够概念与词汇储备读懂你所选择的数据库的文档。