数据密集型应用系统设计

前言

  • 硬件方面, CPU主频增长日趋缓慢, 而多核系统成为新常态, 网络速度则依旧保持快速发展,这就意味着并行分布式系统将会成为业界主流。
    • 如今一个不起眼的小公司,也完全有能力构建起大型分布式系统 : 跨机器甚至跨地域的数据中心 ,因为现在有了逐渐普及的IaaS云服务(例如 AWS)
  • “数据密集型应用” (Data-Intensive Applications)
    • 对于一个应用系 统,如果“数据”是其成败决定性因素,包括数据的规模数据的复杂度或者数据产生与变化的速率等,我们就可以称为“数据密集型应用系统”(现如今大部分系统都是
    • 与之对应的是计算密集型( Compute-Intensiv) , CPU主频往往是后者最大的制约瓶颈

  • 目前已经有足够丰富的技术或者工具来辅助、帮助我们开发自己的数据密集型应用, 包括存储处理等方面,而这些技术本身也在快速演进之中。
    • 例如很多人在关注新的 NoSQL系统,但实际上消息队列、缓存、搜索引擎、批处理与流处理框架等相关技术 也非常重要, 事实上,很多应用系统总是会集成组合上述多种技术。发
  • 大数据这个商业词汇有些过于滥用或者太模糊,不太适合在严谨的工程领域展开讨论 。 这本书使用歧义更小的术语,如 “单节点” 之于 “分布式系统”,或 “在线 / 交互式系统” 之于 “离线 / 批处理系统”。( This book uses less ambiguous terms, such as single-node versus distributed systems, or online/interactive versus offline/ batch processing systems.)
    • 联机事务处理OLTP(On-line transaction processing):OLTP是传统的关系型数据库的主要应用,主要是基本的、日常的事务处理,例如银行交易。 --偏interactive
    • 联机分析处理OLAP(On-Line Analytical Processing):OLAP是数据仓库系统的主要应用,支持复杂的分析操作,侧重决策支持,并且提供直观易懂的查询结果。 --偏online
  • 本书分为三部分:本书关注的是数据系统的架构(architecture),以及它们被集成到数据密集型应用中的方式

    1. 在 第一部分 中,我们会讨论设计数据密集型应用所赖的基本思想。我们从 第一章 开始,讨论我们实际要达到的目标:可靠性(reliability)可伸缩性\可扩展性(scalability)可维护性(maintainability);我们该如何思考这些概念;以及如何实现它们。在 第二章 中,我们比较了几种不同的数据模型和查询语言,看看它们如何适用于不同的场景。在 第三章 中将讨论存储引擎:数据库如何在磁盘上摆放数据,以便能高效地再次找到它。第四章 转向数据编码(序列化),以及随时间演化的模式。

    2. 在 第二部分 中,我们从讨论存储在一台机器上的数据转向讨论分布在多台机器上的数据。这对于可伸缩性通常是必需的,但带来了各种独特的挑战。我们首先讨论复制(第五章)、分区 / 分片(第六章)和事务(第七章)。然后我们将探索关于分布式系统问题的更多细节(第八章),以及在分布式系统中实现一致性与共识意味着什么(第九章)。

    3. 在 第三部分 中,我们讨论那些从其他数据集衍生出一些数据集的系统。衍生数据经常出现在异构系统中:当没有单个数据库可以把所有事情都做的很好时,应用需要集成几种不同的数据库、缓存、索引等。在 第十章 中我们将从一种衍生数据的批处理方法开始,然后在此基础上建立在 第十一章 中讨论的流处理。最后,在 第十二章 中,我们将所有内容汇总,讨论在将来构建可靠、可伸缩和可维护的应用程序的方法。

第一部分:数据系统基础

本书前四章介绍了数据系统底层的基础概念,无论是在单台机器上运行的单点数据系统,还是分布在多台机器上的分布式数据系统都适用。

  1. 第一章 将介绍本书使用的术语和方法。可靠性,可伸缩性和可维护性 ,这些词汇到底意味着什么?如何实现这些目标?
  2. 第二章 将对几种不同的 数据模型和查询语言 进行比较。从程序员的角度看,这是数据库之间最明显的区别不同的数据模型适用于不同的应用场景
  3. 第三章 将深入 存储引擎 内部,研究数据库如何在磁盘上摆放数据。不同的存储引擎针对不同的负载进行优化,选择合适的存储引擎对系统性能有巨大影响。
  4. 第四章 将对几种不同的 数据编码(序列化) formats for data encoding (serialization)  进行比较。特别研究了这些格式在应用需求经常变化、模式需要随时间演变的环境中表现如何。

第二部分将专门讨论在 分布式数据系统 中特有的问题。

第1章 可靠性、可扩展性与可维护性

数据密集型应用通常由标准组件构建而成,标准组件提供了很多通用的功能;例如,许多应用程序都需要:

  • 存储数据,以便自己或其他应用程序之后能再次找到 (数据库,即 databases
  • 记住开销昂贵操作的结果,加快读取速度(缓存,即 caches
  • 允许用户按关键字搜索数据,或以各种方式对数据进行过滤(搜索索引,即 search indexes
  • 向其他进程发送消息,进行异步处理流处理,即 stream processing
  • 定期处理累积的大批量数据(批处理,即 batch processing

如果这些功能听上去平淡无奇,那是因为这些 数据系统(data system) 是非常成功的抽象:我们一直不假思索地使用它们并习以为常。但现实没有这么简单。不同的应用有着不同的需求,因而数据库系统也是百花齐放,有着各式各样的特性。实现缓存有很多种手段,创建搜索索引也有好几种方法,诸如此类。因此在开发应用前,我们依然有必要先弄清楚最适合手头工作的工具和方法。而且当单个工具解决不了你的问题时,组合使用这些工具可能还是有些难度的。

认识数据系统

我们通常认为,数据库、消息队列、缓存等工具分属于几个差异显著的类别。虽然数据库和消息队列表面上有一些相似性 —— 它们都会存储一段时间的数据 —— 但它们有迥然不同的访问模式,这意味着迥异的性能特征和实现手段。

那我们为什么要把这些东西放在 数据系统(data system) 的总称之下混为一谈呢?

近些年来,出现了许多新的数据存储工具与数据处理工具。它们针对不同应用场景进行优化,因此不再适合生硬地归入传统类别【1】。类别之间的界限变得越来越模糊,例如:数据存储可以被当成消息队列用(Redis),消息队列则带有类似数据库的持久保证(Apache Kafka)。

其次,越来越多的应用程序有着各种严格而广泛的要求,单个工具不足以满足所有的数据处理和存储需求。取而代之的是,总体工作被拆分成一系列能被单个工具高效完成的任务,并通过应用代码将它们缝合起来。(当你将多个工具组合在一起提供服务时,服务的接口或 应用程序编程接口(API, Application Programming Interface) 通常向客户端隐藏这些实现细节。现在,你基本上已经使用较小的通用组件创建了一个全新的、专用的数据系统。这个新的复合数据系统可能会提供特定的保证,例如:缓存在写入时会作废或更新,以便外部客户端获取一致的结果。现在你不仅是应用程序开发人员,还是数据系统设计人员了。)

本书着重讨论三个在大多数软件系统中都很重要的问题:

  • 可靠性(Reliability)

    系统在 困境(adversity,比如硬件故障、软件故障、人为错误)中仍可正常工作(正确完成功能,并能达到期望的性能水准)。请参阅 “可靠性”。

  • 可伸缩性(Scalability)

    有合理的办法应对系统的增长(数据量、流量、复杂性)。请参阅 “可伸缩性”。

  • 可维护性(Maintainability)

    许多不同的人(工程师、运维)在不同的生命周期,都能高效地在系统上工作(使系统保持现有行为,并适应新的应用场景)。请参阅 “可维护性”。

人们经常追求这些词汇,却没有清楚理解它们到底意味着什么。为了工程的严谨性,本章的剩余部分将探讨可靠性、可伸缩性和可维护性的含义。为实现这些目标而使用的各种技术,架构和算法将在后续的章节中研究。

可靠性

人们对于一个东西是否可靠,都有一个直观的想法。人们对可靠软件的典型期望包括:

  • 应用程序表现出用户所期望的功能。
  • 允许用户犯错,允许用户以出乎意料的方式使用软件。
  • 在预期的负载和数据量下,性能满足要求。
  • 系统能防止未经授权的访问和滥用。

如果所有这些在一起意味着 “正确工作”,那么可以把可靠性粗略理解为 “即使出现问题,也能继续正确工作”。

造成错误的原因叫做 故障(fault),能预料并应对故障的系统特性可称为 容错(fault-tolerant) 或 韧性(resilient)。“容错” 一词可能会产生误导,因为它暗示着系统可以容忍所有可能的错误,但在实际中这是不可能的。比方说,如果整个地球(及其上的所有服务器)都被黑洞吞噬了,想要容忍这种错误,需要把网络托管到太空中 —— 这种预算能不能批准就祝你好运了。所以在讨论容错时,只有谈论特定类型的错误才有意义。

注意 故障(fault) 不同于 失效(failure)【2】。故障 通常定义为系统的一部分状态偏离其标准,而 失效 则是系统作为一个整体停止向用户提供服务。

可扩展性

系统今天能可靠运行,并不意味未来也能可靠运行。服务 降级(degradation) 的一个常见原因是负载增加,例如:系统负载已经从一万个并发用户增长到十万个并发用户,或者从一百万增长到一千万。也许现在处理的数据量级要比过去大得多。

可伸缩性(Scalability) 是用来描述系统应对负载增长能力的术语。但是请注意,这不是贴在系统上的一维标签:说 “X 可伸缩” 或 “Y 不可伸缩” 是没有任何意义的。相反,讨论可伸缩性意味着考虑诸如 “如果系统以特定方式增长,有什么选项可以应对增长?” 和 “如何增加计算资源来处理额外的负载?” 等问题。

描述负载

在讨论增长问题(如果负载加倍会发生什么?)前,首先要能简要描述系统的当前负载。负载可以用一些称为 负载参数(load parameters) 的数字来描述。参数的最佳选择取决于系统架构,它可能是每秒向 Web 服务器发出的请求数据库中的读写比率聊天室中同时活跃的用户数量缓存命中率或其他东西、微博或推特 每个用户粉丝数量。除此之外,也许平均情况对你很重要,也许你的瓶颈是少数极端场景。

为了使这个概念更加具体,我们以推特在 2012 年 11 月发布的数据【16】为例。推特的两个主要业务是:

  • 发布推文

    用户可以向其粉丝发布新消息(平均 4.6k 请求 / 秒,峰值超过 12k 请求 / 秒)。

  • 主页时间线

    用户可以查阅他们关注的人发布的推文(300k 请求 / 秒)。  (用户登录主页时,时间线会显示他们关注的人发表的推文,按照时间倒序(或者其他权重)

处理每秒 12,000 次写入(发推文的速率峰值)还是很简单的。然而推特的伸缩性挑战并不是主要来自推特量,而是来自 扇出(fan-out)2—— 每个用户关注了很多人,也被很多人关注。

大体上讲,这一对操作有两种实现方式。

  1. 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如 图 1-2 所示的关系型数据库中,可以编写这样的查询:

    SELECT tweets.*, users.*
      FROM tweets
      JOIN users   ON tweets.sender_id = users.id
      JOIN follows ON follows.followee_id = users.id
      WHERE follows.follower_id = current_user

    图 1-2 推特主页时间线的关系型模式简单实现

  2. 为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱(图 1-3)。 当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。 因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。

    图 1-3 用于分发推特至关注者的数据流水线,2012 年 11 月的负载参数【16】

推特的第一个版本使用了方法 1,但系统很难跟上主页时间线查询的负载。所以公司转向了方法 2,方法 2 的效果更好,因为发推频率比查询主页时间线的频率几乎低了两个数量级,所以在这种情况下,最好在写入时做更多的工作,而在读取时做更少的工作。

然而方法 2 的缺点是,发推现在需要大量的额外工作。平均来说,一条推文会发往约 75 个关注者,所以每秒 4.6k 的发推写入,变成了对主页时间线缓存每秒 345k 的写入。但这个平均值隐藏了用户粉丝数差异巨大这一现实,一些用户有超过 3000 万的粉丝,这意味着一条推文就可能会导致主页时间线缓存的 3000 万次写入!及时完成这种操作是一个巨大的挑战 —— 推特尝试在 5 秒内向粉丝发送推文。

在推特的例子中,每个用户粉丝数的分布(可能按这些用户的发推频率来加权)是探讨可伸缩性的一个关键负载参数,因为它决定了扇出负载。你的应用程序可能具有非常不同的特征,但可以采用相似的原则来考虑它的负载。

推特轶事的最终转折:现在已经稳健地实现了方法 2,推特逐步转向了两种方法的混合大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户(即名流)会被排除在外。当用户读取主页时间线时,分别地获取出该用户所关注的每位名流的推文,再与用户的主页时间线缓存合并,如方法 1 所示。这种混合方法能始终如一地提供良好性能。在 第十二章 中我们将重新讨论这个例子,这在覆盖更多技术层面之后。

描述性能

一旦系统的负载被描述好,就可以研究当负载增加会发生什么。我们可以从两种角度来看:

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

这两个问题都需要性能数据,所以让我们简单地看一下如何描述系统性能。

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

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

图 1-4 展示了一个服务 100 次请求响应时间的均值与百分位数

应对负载的方法

现在我们已经讨论了用于描述负载的参数和用于衡量性能的指标。可以开始认真讨论可伸缩性了:当负载参数增加时,如何保持良好的性能?

适应某个级别负载的架构不太可能应付 10 倍于此的负载。如果你正在开发一个快速增长的服务,那么每次负载发生数量级的增长时,你可能都需要重新考虑架构 —— 或者更频繁。

人们经常讨论 纵向伸缩(scaling up,也称为垂直伸缩,即 vertical scaling,转向更强大的机器)和 横向伸缩(scaling out,也称为水平伸缩,即 horizontal scaling,将负载分布到多台小机器上)之间的对立。跨多台机器分配负载也称为 “无共享(shared-nothing)” 架构。

可维护性

众所周知,软件的大部分开销并不在最初的开发阶段,而是在持续的维护阶段,包括修复漏洞、保持系统正常运行、调查失效、适配新的平台、为新的场景进行修改、偿还技术债、添加新的功能等等。

第2章 数据模型与查询语言

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

多数应用使用层层叠加的数据模型构建。对于每层数据模型的关键问题是:如何将其用下一层来表示?例如:

  1. 作为一名应用开发人员,你观察现实世界(里面有人员、组织、货物、行为、资金流向、传感器等),并采用对象或数据结构,以及操控那些数据结构的 API 来进行建模。那些结构通常是特定于应用程序的。
  2. 当要存储那些数据结构时,你可以利用通用数据模型来表示它们,如 JSON 或 XML 文档关系数据库中的表图模型
  3. 数据库软件的工程师选定如何以内存、磁盘或网络上的字节来表示 JSON / XML/ 关系 / 图数据。这类表示形式使数据有可能以各种方式来查询,搜索,操纵和处理。
  4. 在更低的层次上,硬件工程师已经想出了使用电流、光脉冲、磁场或者其他东西来表示字节的方法。

一个复杂的应用程序可能会有更多的中间层次,比如基于 API 的 API,不过基本思想仍然是一样的:每个层都通过提供一个明确的数据模型来隐藏更低层次中的复杂性。这些抽象允许不同的人群有效地协作(例如数据库厂商的工程师和使用数据库的应用程序开发人员)。

数据模型种类繁多,每个数据模型都带有如何使用的设想。有些用法很容易,有些则不支持如此;有些操作运行很快,有些则表现很差;有些数据转换非常自然,有些则很麻烦。

掌握一个数据模型需要花费很多精力(想想关系数据建模有多少本书)。即便只使用一个数据模型,不用操心其内部工作机制,构建软件也是非常困难的。然而,因为数据模型对上层软件的功能(能做什么,不能做什么)有着至深的影响,所以选择一个适合的数据模型是非常重要的。

在本章中,我们将研究一系列用于数据存储和查询的通用数据模型(前面列表中的第 2 点)。特别地,我们将比较关系模型文档模型一些基于图的数据模型。我们还将讨论多种查询语言即如何使用这些模型)并比较它们的使用场景。第3章将讨论存储引擎, 即这些数据模型是如何实现的(列表的第3点)。

 

关系模型与文档模型

  • 现在最著名的数据模型可能是 SQL:数据被组织成 关系(SQL 中称作 表),其中每个关系是 元组(SQL 中称作 行) 的无序集合
    • NoSQL 被追溯性地重新解释为 不仅是 SQL(Not Only SQL) 
    • 采用 NoSQL 数据库的背后有几个驱动因素,其中包括:

      • 需要比关系数据库更好的可伸缩性,包括非常大的数据集或非常高的写入吞吐量
      • 相比商业数据库产品,免费和开源软件更受偏爱
      • 关系模型不能很好地支持一些特殊的查询操作
      • 受挫于关系模型的限制性,渴望一种更具多动态性与表现力的数据模型【5】
  • 在上一节的 例 2-1 中,region_id 和 industry_id 是以 ID,而不是纯字符串 “Greater Seattle Area” 和 “Philanthropy” 的形式给出的。为什么?
    • 存储 ID 还是文本字符串,这是个 副本(duplication) 问题。当使用 ID 时,对人类有意义的信息(比如单词:Philanthropy)只存储在一处,所有引用它的地方使用 ID(ID 只在数据库中有意义)。当直接存储文本时,对人类有意义的信息会复制在每处使用记录中。

    • 使用 ID 的好处是,ID 对人类没有任何意义,因而永远不需要改变:ID 可以保持不变,即使它标识的信息发生变化。任何对人类有意义的东西都可能需要在将来某个时候改变 —— 如果这些信息被复制,所有的冗余副本都需要更新。这会导致写入开销,也存在不一致的风险(一些副本被更新了,还有些副本没有被更新)。去除此类重复是数据库 规范化(normalization) 的关键思想。2

  • 面向文档数据库的中心概念是“文档”这个概念。尽管每个面向文档数据库实现在这个定义的细节上都有所不同,一般而言,它们都假定文档以某种标准格式编码来封装和编码数据(或信息)。面向文档数据库使用的编码包括XMLYAMLJSONBSON,有的实现还可以存储二进制文档格式如PDF和Microsoft Office文档(MS WordExcel之类)。
    • 在文档存储中的文档粗略的等价于 对象这个编程概念。不要求它们遵守标准模式(schema),也不要求它们都有同样的章节、插槽(slot)、部分(part)或键。一般地说,使用对象的程序有很多不同的对象类型,而这些对象经常有很多可选的字段(field)。每个对象,即使是同类的,也可以看起来非常不同。文档存储类似于此,它们在一个单一存储中允许不同类型的文档,运行在文档中的字段是可选的,并且经常允许它们使用不同的编码系统来编码。
    • 文档数据库有时称为 无模式(schemaless),但这具有误导性,因为读取数据的代码通常假定某种结构 —— 即存在隐式模式,但不由数据库强制执行【20】。一个更精确的术语是 读时模式(即 schema-on-read,数据的结构是隐含的,只有在数据被读取时才被解释),相应的是 写时模式(即 schema-on-write,传统的关系数据库方法中,模式明确,且数据库确保所有的数据都符合其模式)【21】。

      读时模式类似于编程语言中的动态(运行时)类型检查,而写时模式类似于静态(编译时)类型检查

  • 关系模型:关系模型做的就是将所有的数据放在光天化日之下:一个 关系(表) 只是一个 元组(行) 的集合,仅此而已。如果你想读取数据,它没有迷宫似的嵌套结构,也没有复杂的访问路径。你可以选中符合任意条件的行,读取表中的任何或所有行。你可以通过指定某些列作为匹配关键字来读取特定行。你可以在任何表中插入一个新的行,而不必担心与其他表的外键关系 4

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

  • 文档模型:文档数据库还原为层次模型:在其父记录中存储嵌套记录(图 2-1 中的一对多关系,如 positionseducation 和 contact_info),而不是在单独的表中。
  • 关系数据库与文档数据库进行比较时,可以考虑许多方面的差异,包括它们的容错属性(请参阅 第五章)和处理并发性(请参阅 第七章)。本章将只关注数据模型中的差异。

    • 支持文档数据模型的主要论据是架构灵活性,因局部性而拥有更好的性能,以及对于某些应用程序而言更接近于应用程序使用的数据结构

      • 局部性:文档通常以单个连续字符串形式进行存储,编码为 JSON、XML 或其二进制变体(如 MongoDB 的 BSON)。如果应用程序经常需要访问整个文档(例如,将其渲染至网页),那么存储局部性会带来性能优势。如果将数据分割到多个表中(如 图 2-1 所示),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。
    • 关系模型通过为连接提供更好的支持以及支持多对一和多对多的关系来反击。

    • 文档和关系数据库的融合
      • 好多关系型数据库都支持XML、JSON类型
      • 在文档数据库中,RethinkDB 在其查询语言中支持类似关系的连接,一些 MongoDB 驱动程序可以自动解析数据库引用

 

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

 

  • 如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如 图 2-1 中的 positions、education 和 contact_info)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。
  • 文档模型有一定的局限性:例如,不能直接引用文档中的嵌套的项目,而是需要说 “用户 251 的位置列表中的第二项”(很像层次模型中的访问路径)。但是,只要文件嵌套不太深,这通常不是问题。
  • 文档数据库对连接的糟糕支持可能是个问题,也可能不是问题,这取决于应用程序。例如,如果某分析型应用程序使用一个文档数据库来记录何时何地发生了何事,那么多对多关系可能永远也用不上。【19】。

数据库查询语言

    • 两种类型:
      • 声明式语言:在声明式查询语言(如 SQL 或关系代数)中,你只需指定所需数据的模式 - 结果必须符合哪些条件,以及如何将数据转换(例如,排序,分组和集合) - 但不是如何实现这一目标。数据库系统的查询优化器决定使用哪些索引和哪些连接方法,以及以何种顺序执行查询的各个部分。
      • 命令式语言:大部分编程语言都是命令式的,命令式语言告诉计算机以特定顺序执行某些操作。可以想象一下,逐行地遍历代码,评估条件,更新变量,并决定是否再循环一遍。
      • 其它区别
        • 声明式查询语言是迷人的,因为它通常比命令式 API 更加简洁和容易。但更重要的是,它还隐藏了数据库引擎的实现细节,这使得数据库系统可以在无需对查询做任何更改的情况下进行性能提升。
        • 最后,声明式语言往往适合并行执行(多核)
          • 命令式代码很难在多个核心和多个机器之间并行化,因为它指定了指令必须以特定顺序执行
          • 声明式语言更具有并行执行的潜力,因为它们仅指定结果的模式,而不指定用于确定结果的算法。在适当情况下,数据库可以自由使用查询语言的并行实现
    • Web 上的声明式查询:现在想让当前所选页面的标题具有一个蓝色的背景,以便在视觉上突出显示。
      <ul>
          <li class="selected">
              <p>Sharks</p>
              <ul>
                  <li>Great White Shark</li>
                  <li>Tiger Shark</li>
                  <li>Hammerhead Shark</li>
              </ul>
          </li>
          <li><p>Whales</p>
              <ul>
                  <li>Blue Whale</li>
                  <li>Humpback Whale</li>
                  <li>Fin Whale</li>
              </ul>
          </li>
      </ul>
      • CSS:声明式
        li.selected > p {
          background-color: blue;
        }
      • JavaScript:命令式
        var liElements = document.getElementsByTagName("li");
        for (var i = 0; i < liElements.length; i++) {
            if (liElements[i].className === "selected") {
                var children = liElements[i].childNodes;
                for (var j = 0; j < children.length; j++) {
                    var child = children[j];
                    if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "P") {
                        child.setAttribute("style", "background-color: blue");
                    }
                }
            }
        }
    • MapReduce查询:既不是一个声明式的查询语言,也不是一个完全命令式的查询 API,而是处于两者之间查询的逻辑用代码片段来表示,这些代码片段会被处理框架重复性调用。它基于 map(也称为 collect)和 reduce(也称为 fold 或 inject)函数,两个函数存在于许多函数式编程语言中。MapReduce 的一个可用性问题是,必须编写两个密切合作的 JavaScript 函数
      • 假设你是一名海洋生物学家,每当你看到海洋中的动物时,你都会在数据库中添加一条观察记录。现在你想生成一个报告,说明你每月看到多少鲨鱼。
      • PostgreSQL
        SELECT
          date_trunc('month', observation_timestamp) AS observation_month,
          sum(num_animals)                           AS total_animals
        FROM observations
        WHERE family = 'Sharks'
        GROUP BY observation_month;
      •  MongoDB 的 MapReduce 
        db.observations.mapReduce(function map() {   // 每个匹配查询的文档都会调用一次 JavaScript 函数 map,将 this 设置为文档对象
                var year = this.observationTimestamp.getFullYear();
                var month = this.observationTimestamp.getMonth() + 1;
                emit(year + "-" + month, this.numAnimals);   // map 函数发出一个键(包括年份和月份的字符串,如 "2013-12" 或 "2014-1")和一个值(该观察记录中的动物数量)。
        }, function reduce(key, values) {   // map 发出的键值对按键来分组。对于具有相同键(即,相同的月份和年份)的所有键值对,调用一次 reduce 函数。
          return Array.sum(values);
        },
        {
        query: {
        family: "Sharks"
        },
        out: "monthlySharkReport" // reduce 函数将特定月份内所有观测记录中的动物数量相加。将最终的输出写入到 monthlySharkReport 集合中。
        });

 

图 2-1 使用关系型模式来表示领英简介

 

例 2-1. 用 JSON 文档表示一个 LinkedIn 简介

{
  "user_id": 251,
  "first_name": "Bill",
  "last_name": "Gates",
  "summary": "Co-chair of the Bill & Melinda Gates... Active blogger.",
  "region_id": "us:91",
  "industry_id": 131,
  "photo_url": "/p/7/000/253/05b/308dd6e.jpg",
  "positions": [
    {
      "job_title": "Co-chair",
      "organization": "Bill & Melinda Gates Foundation"
    },
    {
      "job_title": "Co-founder, Chairman",
      "organization": "Microsoft"
    }
  ],
  "education": [
    {
      "school_name": "Harvard University",
      "start": 1973,
      "end": 1975
    },
    {
      "school_name": "Lakeside School, Seattle",
      "start": null,
      "end": null
    }
  ],
  "contact_info": {
    "blog": "http://thegatesnotes.com",
    "twitter": "http://twitter.com/BillGates"
  }
}

图数据模型

如我们之前所见,多对多关系是不同数据模型之间具有区别性的重要特征。如果你的应用程序大多数的关系是一对多关系(树状结构化数据),或者大多数记录之间不存在关系,那么使用文档模型是合适的。

但是,要是多对多关系在你的数据中很常见呢?关系模型可以处理多对多关系的简单情况,但是随着数据之间的连接变得更加复杂,将数据建模为图形显得更加自然。

一个图由两种对象组成:顶点(vertices,也称为 节点,即 nodes,或 实体,即 entities),和 边(edges,也称为 关系,即 relationships,或 弧,即 arcs)。多种数据可以被建模为一个图形。典型的例子包括:

  • 社交图谱

    顶点是人,边指示哪些人彼此认识。

  • 网络图谱

    顶点是网页,边缘表示指向其他页面的 HTML 链接。

  • 公路或铁路网络

    顶点是交叉路口,边线代表它们之间的道路或铁路线。

可以将那些众所周知的算法运用到这些图上:例如,汽车导航系统搜索道路网络中两点之间的最短路径,PageRank 可以用在网络图上来确定网页的流行程度,从而确定该网页在搜索结果中的排名。

刚刚给出的例子中,图中的所有顶点代表了相同类型的事物(人、网页或交叉路口)。不过,图并不局限于这样的同类数据:同样强大地是,图提供了一种一致的方式,用来在单个数据存储中存储完全不同类型的对象。例如,Facebook 维护一个包含许多不同类型的顶点和边的单个图:顶点表示人,地点,事件,签到和用户的评论边缘表示哪些人是彼此的朋友,哪个签到发生在何处,谁评论了哪条消息,谁参与了哪个事件,等等【35】。

 

 

 

在本节中,我们将使用 图 2-5 所示的示例。它可以从社交网络或系谱数据库中获得:它显示了两个人,来自爱达荷州的 Lucy 和来自法国 Beaune 的 Alain。他们已婚,住在伦敦。

图 2-5 图数据结构示例(框代表顶点,箭头代表边)

图模型表示-属性图\查询-Cypher

在属性图模型中,每个顶点(vertex)包括:

  • 唯一的标识符
  • 一组出边(outgoing edges)
  • 一组入边(ingoing edges)
  • 一组属性(键值对)

每条边(edge)包括:

  • 唯一标识符
  • 边的起点(尾部顶点,即 tail vertex)
  • 边的终点(头部顶点,即 head vertex)
  • 描述两个顶点之间关系类型的标签
  • 一组属性(键值对)

可以将图存储看作由两个关系表组成:一个存储顶点,另一个存储边,如 例 2-2 所示(该模式使用 PostgreSQL JSON 数据类型来存储每个顶点或每条边的属性)。头部和尾部顶点用来存储每条边;如果你想要一组顶点的输入或输出边,你可以分别通过 head_vertex 或 tail_vertex 来查询 edges 表。

例 2-2 使用关系模式来表示属性图

 1 CREATE TABLE vertices (
 2   vertex_id  INTEGER PRIMARY KEY,
 3   properties JSON
 4 );
 5 
 6 CREATE TABLE edges (
 7   edge_id     INTEGER PRIMARY KEY,
 8   tail_vertex INTEGER REFERENCES vertices (vertex_id),
 9   head_vertex INTEGER REFERENCES vertices (vertex_id),
10   label       TEXT,
11   properties  JSON
12 );
13 
14 CREATE INDEX edges_tails ON edges (tail_vertex);
15 CREATE INDEX edges_heads ON edges (head_vertex);

 

关于这个模型的一些重要方面是:

  1. 任何顶点都可以有一条边连接到任何其他顶点。没有模式限制哪种事物可不可以关联。
  2. 给定任何顶点,可以高效地找到它的入边和出边,从而遍历图,即沿着一系列顶点的路径前后移动(这就是为什么 例 2-2 在 tail_vertex 和 head_vertex 列上都有索引的原因——根据顶点找尾部,再把尾部当顶点,索引没有用上啊???)。
  3. 通过对不同类型的关系使用不同的标签,可以在一个图中存储几种不同的信息,同时仍然保持一个清晰的数据模型。

这些特性为数据建模提供了很大的灵活性,如 图 2-5 所示。图中显示了一些传统关系模式难以表达的事情,例如不同国家的不同地区结构(法国有省和大区,美国有县和州),国中国的怪事(先忽略主权国家和民族错综复杂的烂摊子),不同的数据粒度(Lucy 现在的住所记录具体到城市,而她的出生地点只是在一个州的级别)。

你可以想象该图还能延伸出许多关于 Lucy 和 Alain 的事实,或其他人的其他更多的事实。例如,你可以用它来表示食物过敏(为每个过敏源增加一个顶点,并增加人与过敏源之间的一条边来指示一种过敏情况),并链接到过敏源,每个过敏源具有一组顶点用来显示哪些食物含有哪些物质。然后,你可以写一个查询,找出每个人吃什么是安全的。图在可演化性方面是富有优势的:当你向应用程序添加功能时,可以轻松扩展图以适应程序数据结构的变化。

Cypher 查询语言

Cypher 是属性图的声明式查询语言,为 Neo4j 图形数据库而发明【37】(它是以电影 “黑客帝国” 中的一个角色来命名的,而与密码学中的加密算法无关【38】)。

例 2-3 显示了将 图 2-5 的左边部分插入图形数据库的 Cypher 查询。可以类似地添加图的其余部分,为了便于阅读而省略。每个顶点都有一个像 USA 或 Idaho 这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:(Idaho) - [:WITHIN] ->(USA) 创建一条标记为 WITHIN 的边,Idaho 为尾节点,USA 为头节点。

例 2-3 将图 2-5 中的数据子集表示为 Cypher 查询

CREATE
  (NAmerica:Location {name:'North America', type:'continent'}),
  (USA:Location      {name:'United States', type:'country'  }),
  (Idaho:Location    {name:'Idaho',         type:'state'    }),
  (Lucy:Person       {name:'Lucy' }),
  (Idaho) -[:WITHIN]->  (USA)  -[:WITHIN]-> (NAmerica),
  (Lucy)  -[:BORN_IN]-> (Idaho)

 

当 图 2-5 的所有顶点和边被添加到数据库后,让我们提些有趣的问题:例如,找到所有从美国移民到欧洲的人的名字。更确切地说,这里我们想要找到符合下面条件的所有顶点,并且返回这些顶点的 name 属性:该顶点拥有一条连到美国任一位置的 BORN_IN 边,和一条连到欧洲的任一位置的 LIVING_IN 边。

例 2-4 展示了如何在 Cypher 中表达这个查询。在 MATCH 子句中使用相同的箭头符号来查找图中的模式:(person) -[:BORN_IN]-> () 可以匹配 BORN_IN 边的任意两个顶点。该边的尾节点被绑定了变量 person,头节点则未被绑定。

例 2-4 查找所有从美国移民到欧洲的人的 Cypher 查询:

MATCH
  (person) -[:BORN_IN]->  () -[:WITHIN*0..]-> (us:Location {name:'United States'}),
  (person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (eu:Location {name:'Europe'})
RETURN person.name

 

查询按如下来解读:

找到满足以下两个条件的所有顶点(称之为 person 顶点):

  1. person 顶点拥有一条到某个顶点的 BORN_IN 出边。从那个顶点开始,沿着一系列 WITHIN 出边最终到达一个类型为 Locationname 属性为 United States 的顶点。

  2. person 顶点还拥有一条 LIVES_IN 出边。沿着这条边,可以通过一系列 WITHIN 出边最终到达一个类型为 Locationname 属性为 Europe 的顶点。

对于这样的 Person 顶点,返回其 name 属性。

执行这条查询可能会有几种可行的查询路径。这里给出的描述建议首先扫描数据库中的所有人,检查每个人的出生地和居住地,然后只返回符合条件的那些人。

等价地,也可以从两个 Location 顶点开始反向地查找。假如 name 属性上有索引,则可以高效地找到代表美国和欧洲的两个顶点。然后,沿着所有 WITHIN 入边,可以继续查找出所有在美国和欧洲的位置(州,地区,城市等)。最后,查找出那些可以由 BORN_IN 或 LIVES_IN 入边到那些位置顶点的人。

通常对于声明式查询语言来说,在编写查询语句时,不需要指定执行细节:查询优化程序会自动选择预测效率最高的策略,因此你可以专注于编写应用程序的其他部分。

 

图模型表示-三元组\查询-SPARQL 

元组存储模式大体上与属性图模型相同,用不同的词来描述相同的想法。不过仍然值得讨论,因为三元组存储有很多现成的工具和语言,这些工具和语言对于构建应用程序的工具箱可能是宝贵的补充。

在三元组存储中,所有信息都以非常简单的三部分表示形式存储(主语,谓语,宾语)。例如,三元组 (吉姆, 喜欢, 香蕉) 中,吉姆 是主语,喜欢 是谓语(动词),香蕉 是对象。

三元组的主语相当于图中的一个顶点。而宾语是下面两者之一

  1. 原始数据类型中的值,例如字符串或数字。在这种情况下,三元组的谓语和宾语相当于主语顶点上的属性的键和值。例如(lucy, age, 33) 就像属性 {“age”:33} 的顶点 lucy
  2. 图中的另一个顶点。在这种情况下,谓语是图中的一条边,主语是其尾部顶点,而宾语是其头部顶点。例如,在 (lucy, marriedTo, alain) 中主语和宾语 lucy 和 alain 都是顶点,并且谓语 marriedTo 是连接他们的边的标签。

例 2-6 展示了与 例 2-3 相同的数据,以称为 Turtle 的格式(Notation3(N3)【39】的一个子集)写成三元组。

例 2-6 图 2-5 中的数据子集,表示为 Turtle 三元组

@prefix : <urn:example:>.
_:lucy     a       :Person.
_:lucy     :name   "Lucy".
_:lucy     :bornIn _:idaho.
_:idaho    a       :Location.
_:idaho    :name   "Idaho".
_:idaho    :type   "state".
_:idaho    :within _:usa.
_:usa      a       :Location
_:usa      :name   "United States"
_:usa      :type   "country".
_:usa      :within _:namerica.
_:namerica a       :Location
_:namerica :name   "North America"
_:namerica :type   :"continent"

 

在这个例子中,图的顶点被写为:_:someName。这个名字并不意味着这个文件以外的任何东西。它的存在只是帮助我们明确哪些三元组引用了同一顶点。当谓语表示边时,该宾语是一个顶点,如 _:idaho :within _:usa.。当谓语是一个属性时,该宾语是一个字符串,如 _:usa :name"United States"

一遍又一遍地重复相同的主语看起来相当重复,但幸运的是,可以使用分号来说明关于同一主语的多个事情。这使得 Turtle 格式相当不错,可读性强:请参阅 例 2-7

例 2-7 一种相对例 2-6 写入数据的更为简洁的方法。

@prefix : <urn:example:>.
_:lucy      a :Person;   :name "Lucy";          :bornIn _:idaho.
_:idaho     a :Location; :name "Idaho";         :type "state";   :within _:usa
_:usa       a :Loaction; :name "United States"; :type "country"; :within _:namerica.
_:namerica  a :Location; :name "North America"; :type "continent".

 

RDF 数据模型

从本质上讲,语义网是一个简单且合理的想法:网站已经将信息发布为文字和图片供人类阅读,为什么不将信息作为机器可读的数据也发布给计算机呢?(基于三元组模型的)资源描述框架(RDF)【41】,被用作不同网站以统一的格式发布数据的一种机制,允许来自不同网站的数据自动合并成 一个数据网络 —— 成为一种互联网范围内的 “通用语义网数据库”。

 

例 2-7 中使用的 Turtle 语言是一种用于 RDF 数据的人类可读格式。有时候,RDF 也可以以 XML 格式编写,不过完成同样的事情会相对啰嗦,请参阅 例 2-8。Turtle/N3 是更可取的,因为它更容易阅读,像 Apache Jena 【42】这样的工具可以根据需要在不同的 RDF 格式之间进行自动转换。

例 2-8 用 RDF/XML 语法表示例 2-7 的数据

<rdf:RDF xmlns="urn:example:"
         xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
    <Location rdf:nodeID="idaho">
        <name>Idaho</name>
        <type>state</type>
        <within>
            <Location rdf:nodeID="usa">
                <name>United States</name>
                <type>country</type>
                <within>
                    <Location rdf:nodeID="namerica">
                        <name>North America</name>
                        <type>continent</type>
                    </Location>
                </within>
            </Location>
        </within>
    </Location>
    <Person rdf:nodeID="lucy">
        <name>Lucy</name>
        <bornIn rdf:nodeID="idaho"/>
    </Person>
</rdf:RDF>

 

RDF 有一些奇怪之处,因为它是为了在互联网上交换数据而设计的。三元组的主语,谓语和宾语通常是 URI。例如,谓语可能是一个 URI,如 <http://my-company.com/namespace#within> 或 <http://my-company.com/namespace#lives_in>,而不仅仅是 WITHIN 或 LIVES_IN。这个设计背后的原因为了让你能够把你的数据和其他人的数据结合起来,如果他们赋予单词 within 或者 lives_in 不同的含义,两者也不会冲突,因为它们的谓语实际上是 <http://other.org/foo#within> 和 <http://other.org/foo#lives_in>

从 RDF 的角度来看,URL <http://my-company.com/namespace> 不一定需要能解析成什么东西,它只是一个命名空间。为避免与 http://URL 混淆,本节中的示例使用不可解析的 URI,如 urn:example:within。幸运的是,你只需在文件顶部对这个前缀做一次声明,后续就不用再管了。

SPARQL 查询语言

SPARQL 是一种用于三元组存储的面向 RDF 数据模型的查询语言【43】(它是 SPARQL 协议和 RDF 查询语言的缩写,发音为 “sparkle”)。SPARQL 早于 Cypher,并且由于 Cypher 的模式匹配借鉴于 SPARQL,这使得它们看起来非常相似【37】。

与之前相同的查询 —— 查找从美国移民到欧洲的人 —— 使用 SPARQL 比使用 Cypher 甚至更为简洁(请参阅 例 2-9)。

例 2-9 与示例 2-4 相同的查询,用 SPARQL 表示

PREFIX : <urn:example:>
SELECT ?personName WHERE {
  ?person :name ?personName.
  ?person :bornIn  / :within* / :name "United States".
  ?person :livesIn / :within* / :name "Europe".
}

结构非常相似。以下两个表达式是等价的(SPARQL 中的变量以问号开头):

(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (location)   # Cypher
?person :bornIn / :within* ?location.                   # SPARQL

因为 RDF 不区分属性和边,而只是将它们作为谓语,所以可以使用相同的语法来匹配属性。在下面的表达式中,变量 usa 被绑定到任意 name 属性为字符串值 "United States" 的顶点:

(usa {name:'United States'})   # Cypher
?usa :name "United States".    # SPARQL

本章小结

数据模型是一个巨大的课题,在本章中,我们快速浏览了各种不同的模型。我们没有足够的篇幅来详述每个模型的细节,但是希望这个概述足以激起你的兴趣,以更多地了解最适合你的应用需求的模型。

在历史上,数据最开始被表示为一棵大树(层次数据模型),但是这不利于表示多对多的关系,所以发明了关系模型来解决这个问题。最近,开发人员发现一些应用程序也不适合采用关系模型。新的非关系型 “NoSQL” 数据存储分化为两个主要方向

  1. 文档数据库 主要关注自我包含的数据文档,而且文档之间的关系非常稀少。
  2. 图形数据库 用于相反的场景:任意事物之间都可能存在潜在的关联。

这三种模型(文档,关系和图形)在今天都被广泛使用,并且在各自的领域都发挥很好。一个模型可以用另一个模型来模拟 —— 例如,图数据可以在关系数据库中表示 —— 但结果往往是糟糕的。这就是为什么我们有着针对不同目的的不同系统,而不是一个单一的万能解决方案。

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

每个数据模型都具有各自的查询语言或框架,我们讨论了几个例子:SQL,MapReduce,MongoDB 的聚合管道,Cypher,SPARQL 和 Datalog。我们也谈到了 CSS 和 XSL/XPath,它们不是数据库查询语言,而包含有趣的相似之处。

虽然我们已经覆盖了很多层面,但仍然有许多数据模型没有提到。举几个简单的例子:

  • 使用基因组数据的研究人员通常需要执行 序列相似性搜索,这意味着需要一个很长的字符串(代表一个 DNA 序列),并在一个拥有类似但不完全相同的字符串的大型数据库中寻找匹配。这里所描述的数据库都不能处理这种用法,这就是为什么研究人员编写了像 GenBank 这样的专门的基因组数据库软件的原因【48】。
  • 粒子物理学家数十年来一直在进行大数据类型的大规模数据分析,像大型强子对撞机(LHC)这样的项目现在会处理数百 PB 的数据!在这样的规模下,需要定制解决方案来阻止硬件成本的失控【49】。
  • 全文搜索 可以说是一种经常与数据库一起使用的数据模型。信息检索是一个很大的专业课题,我们不会在本书中详细介绍,但是我们将在第三章和第三部分中介绍搜索索引。

第3章 存储与检索

一个数据库在最基础的层次上需要完成两件事情:当你把数据交给数据库时,它应当把数据存储起来;而后当你向数据库要数据时,它应当把数据返回给你

在 第二章 中,我们讨论了数据模型和查询语言,即程序员将数据录入数据库的格式,以及再次要回数据的机制。在本章中我们会从数据库的视角来讨论同样的问题:数据库如何存储我们提供的数据,以及如何在我们需要时重新找到数据。

作为程序员,为什么要关心(第三章数据库内部存储与检索的机理?你可能不会去从头开始实现自己的存储引擎,但是你 确实 需要从许多可用的存储引擎中选择一个合适的。而且为了让存储引擎能在你的工作负载类型上运行良好,你也需要大致了解存储引擎在底层究竟做了什么。

特别需要注意,针对 事务型 负载优化的和针对 分析型 负载优化的存储引擎之间存在巨大差异。稍后我们将在 “事务处理还是分析?” 一节中探讨这一区别,并在 “列式存储” 中讨论一系列针对分析性负载而优化的存储引擎。

但首先,我们将从你可能已经很熟悉的两大类数据库(传统的关系型数据库和很多所谓的 “NoSQL” 数据库)中使用的 存储引擎 来开始本章的内容。我们将研究两大类存储引擎:日志结构(log-structured) 的存储引擎,以及 面向页面(page-oriented) 的存储引擎(例如 B 树)。

  • 存储引擎
    • 事务型
      • 日志结构的存储引擎
      • 面向页面的存储引擎(如B树)
    • 分析型(列式存储) 

3.1 数据库核心:数据结构

 3.1.1 最简单的数据库

这两个函数实现了键值存储的功能。执行 db_set key value 会将 键(key) 和 值(value) 存储在数据库中。键和值(几乎)可以是你喜欢的任何东西,例如,值可以是 JSON 文档。然后调用 db_get key 会查找与该键关联的最新值并将其返回。

底层的存储格式非常简单:一个文本文件,每行包含一条逗号分隔的键值对(忽略转义问题的话,大致与 CSV 文件类似)。每次对 db_set 的调用都会向文件末尾追加记录,所以更新键的时候旧版本的值不会被覆盖 —— 因而查找最新值的时候,需要找到文件中键最后一次出现的位置(因此 db_get 中使用了 tail -n 1 )。

% cat litedb.sh

 1 #!/bin/bash
 2 
 3 
 4 # 迷你版数据库对外提供的函数
 5 method=$1
 6 # 向迷你版数据库中存放的key
 7 key=$2
 8 # 向迷你版数据库中存储的value
 9 value=$3
10 
11 
12 # key-value 数据存储
13 function db_set() {
14     echo "$key,$value" >> database
15 }
16 
17 
18 # 根据 key 查询数据库存储的 value
19 function db_get() {
20     grep "^$key," database | sed -e "s/^$key,//" | tail -n 1
21 }
22 
23 
24 case $method in
25   (db_set)
26      db_set
27      ;;
28   (db_get)
29      db_get
30      ;;
31   (*)
32      echo "Error method"
33      ;;
34 esac

% cat database

123456,{"name":"London","attractions":["Big Ben","London Eye"]}
42,{"name":"San Francisco","attractions":["Godlen Gate Bridge"]}
42,{"name":"San Francisco","attractions":["Godlen Gate Bridge_new"]}
% ./litedb.sh db_get 42
{"name":"San Francisco","attractions":["Godlen Gate Bridge_new"]}

db_set 函数对于极其简单的场景其实有非常好的性能,因为在文件尾部追加写入通常是非常高效的。与 db_set 做的事情类似,许多数据库在内部使用了 日志(log),也就是一个 仅追加(append-only) 的数据文件 —— 如binlog。真正的数据库有更多的问题需要处理(如并发控制,回收硬盘空间以避免日志无限增长,处理错误与部分写入的记录),但基本原理是一样的。日志极其有用,我们还将在本书的其它部分重复见到它好几次。

日志(log) 这个词通常指应用日志:即应用程序输出的描述正在发生的事情的文本。本书在更普遍的意义下使用 日志 这一词:一个仅追加的记录序列。它可能压根就不是给人类看的,它可以使用二进制格式,并仅能由其他程序读取。

另一方面,如果这个数据库中有着大量记录,则这个 db_get 函数的性能会非常糟糕。每次你想查找一个键时,db_get 必须从头到尾扫描整个数据库文件来查找键的出现。用算法的语言来说,查找的开销是 O(n) :如果数据库记录数量 n 翻了一倍,查找时间也要翻一倍。这就不好了。

为了高效查找数据库中特定键的值,我们需要一个数据结构:索引(index)。本章将介绍一系列的索引结构,并在它们之间进行比较。索引背后的大致思想是通过保存一些额外的元数据作为路标来帮助你找到想要的数据。如果你想以几种不同的方式搜索同一份数据,那么你也许需要在数据的不同部分上建立多个索引。

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

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

 3.1.2 索引结构

 3.1.2.1 散列索引

让我们从 键值数据(key-value Data) 的索引开始。这不是你可以索引的唯一数据类型,但键值数据是很常见的。在引入更复杂的索引之前,它是重要的第一步。

键值存储与在大多数编程语言中可以找到的 字典(dictionary)(kv对) 类型非常相似,通常字典都是用 散列映射(hash map) 或 散列表(hash table) 实现的。既然我们已经可以用散列映射来表示 内存中 的数据结构,为什么不使用它(散列--内存里)来索引 硬盘上 的数据呢?

假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样,那么最简单的索引策略就是:保留一个内存中的散列映射,其中每个键都映射到数据文件中的一个字节偏移量,指明了可以找到对应值的位置,如 图 3-1 所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用散列映射来查找数据文件中的偏移量,寻找(seek) 该位置并读取该值即可。

次。

Bitcask 就是这么设计的(Riak分布式数据库 中默认的存储引擎)

图 3-1 以类 CSV 格式存储键值对的日志,并使用内存散列映射进行索引。

所以如何避免最终用完硬盘空间?

到目前为止,我们只是在追加写入一个文件 —— 所以如何避免最终用完硬盘空间?一种好的解决方案是,将日志分为特定大小的 段(segment),当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行 压缩(compaction),如 图 3-2 所示。这里的压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。

图 3-2 键值更新日志(统计猫咪视频的播放次数)的压缩,只保留每个键的最近值

而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如 图 3-3 所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,这个过程进行的同时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新合并的段而不是旧的段 —— 然后旧的段文件就可以简单地删除掉了

图 3-3 同时执行压缩和分段合并

每个段现在都有自己的内存散列表,将键映射到文件偏移量。为了找到一个键的值,我们首先检查最近的段的散列映射;如果键不存在,我们就检查第二个最近的段,依此类推。合并过程将保持段的数量足够小,所以查找过程不需要检查太多的散列映射。

 散列索引优缺点

乍一看,仅追加日志似乎很浪费:为什么不直接在文件里更新,用新值覆盖旧值?仅追加的设计之所以是个好的设计,有如下几个原因:

  • 追加和分段合并都是顺序写入操作,通常比随机写入快得多,尤其是在磁性机械硬盘上。在某种程度上,顺序写入在基于闪存的 固态硬盘(SSD) 上也是好的选择【4】。我们将在“比较 B 树和 LSM 树”中进一步讨论这个问题。
  • 如果段文件是仅追加的或不可变的,并发和崩溃恢复就简单多了。例如,当一个数据值被更新的时候发生崩溃,你不用担心文件里将会同时包含旧值和新值各自的一部分。
  • 合并旧段的处理也可以避免数据文件随着时间的推移而碎片化的问题。

但是,散列表索引也有其局限性

  • 散列表必须放进内存。如果你有非常多的键,那真是倒霉。原则上可以在硬盘上维护一个散列映射,不幸的是硬盘散列映射很难表现优秀。它需要大量的随机访问 I/O,而后者耗尽时想要再扩充是很昂贵的,并且需要很烦琐的逻辑去解决散列冲突【5】。
  • 范围查询效率不高。例如,你无法轻松扫描 kitty00000 和 kitty99999 之间的所有键 —— 你必须在散列映射中单独查找每个键。
 3.1.2.2 日志结构索引-LSM树

日志结构合并树(The Log-Structured Merge Tree)

  • LSM == 具有内存索引的 SSTable

    • 现在我们可以对段文件的格式做一个简单的改变:要求键值对的序列按键排序。乍一看,这个要求似乎打破了我们使用顺序写入的能力,我们将稍后再回到这个问题。

      我们把这个格式称为 排序字符串表(Sorted String Table),简称 SSTable

    • 你仍然需要一个内存中的索引来告诉你一些键的偏移量,但它可以是稀疏的:每几千字节的段文件有一个键就足够了,因为几千字节可以很快地被扫描完 1
  • Lucene,是一种全文搜索的索引引擎,在 Elasticsearch 和 Solr 被使用,它使用类似的方法来存储它的关键词词典【12,13】。全文索引比键值索引复杂得多,但是基于类似的想法:在搜索查询中,由一个给定的单词,找到提及单词的所有文档(网页,产品描述等)。这也是通过键值结构实现的:其中键是 单词(term),值是所有包含该单词的文档的 ID 列表(postings list)。在 Lucene 中,从词语到记录列表的这种映射保存在类似于 SSTable 的有序文件中,并根据需要在后台执行合并【14】。

  • HBase、Cassandra、RocksDB和LevelDB这样的Nosql数据库,以及Prometheus,其底层的存储引擎都是基于LSM树

图 3-5 具有内存索引的 SSTable

与使用散列索引的日志段相比,SSTable 有几个大的优势

SSTable 有几个大的优势:

  1. 即使文件大于可用内存合并段的操作仍然是简单而高效的。这种方法就像归并排序算法中使用的方法一样,如 图 3-4 所示:你开始并排读取多个输入文件,查看每个文件中的第一个键,复制最低的键(根据排序顺序)到输出文件,不断重复此步骤,将产生一个新的合并段文件,而且它也是也按键排序的。

    图 3-4 合并几个 SSTable 段,只保留每个键的最新值

    如果在几个输入段中出现相同的键,该怎么办?请记住,每个段都包含在一段时间内写入数据库的所有值。这意味着一个输入段中的所有值一定比另一个段中的所有值都更近(假设我们总是合并相邻的段)。当多个段包含相同的键时,我们可以保留最近段的值,并丢弃旧段中的值。

  2. 为了在文件中找到一个特定的键,你不再需要在内存中保存所有键的索引。以 图 3-5 为例:假设你正在内存中寻找键 handiwork,但是你不知道这个键在段文件中的确切偏移量。然而,你知道 handbag 和 handsome 的偏移,而且由于排序特性,你知道 handiwork 必须出现在这两者之间。这意味着你可以跳到 handbag 的偏移位置并从那里扫描,直到你找到 handiwork(或没找到,如果该文件中没有该键)。

  3. 你仍然需要一个内存中的索引来告诉你一些键的偏移量,但它可以是稀疏的:每几千字节的段文件有一个键就足够了,因为几千字节可以很快地被扫描完 1

       4. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组为块(block),并在将其写入硬盘之前对其进行压缩(如 图 3-5 中的阴影区域所示)[^ 译注 i] 。稀疏内存索引中的每个条目都指向压缩块的开始处。除了节省硬盘空间之外,压缩还可以减少对 I/O 带宽的使用。

构建和维护SSTables

到目前为止还不错,但是如何让你的数据能够预先排好序呢?毕竟我们接收到的写入请求可能以任何顺序发生。

虽然在硬盘上维护有序结构也是可能的(请参阅 “B 树”),但在内存保存则要容易得多。有许多可以使用的众所周知的树形数据结构,例如红黑树或 AVL 树【2】。使用这些数据结构,你可以按任何顺序插入键,并按排序顺序读取它们。

现在我们可以让我们的存储引擎以如下方式工作:

  • 有新写入时,将其添加到内存中的平衡树数据结构(例如红黑树)。这个内存树有时被称为 内存表(memtable)。
  • 当 内存表 大于某个阈值(通常为几兆字节)时,将其作为 SSTable 文件写入硬盘。这可以高效地完成,因为树已经维护了按键排序的键值对。新的 SSTable 文件将成为数据库中最新的段。当该 SSTable 被写入硬盘时,新的写入可以在一个新的内存表实例上继续进行。
  • 收到读取请求时,首先尝试在内存表中找到对应的键,如果没有就在最近的硬盘段中寻找,如果还没有就在下一个较旧的段中继续寻找,以此类推。
  • 时不时地,在后台运行一个合并和压缩过程,以合并段文件并将已覆盖或已删除的值丢弃掉。

这个方案效果很好。它只会遇到一个问题:如果数据库崩溃,则最近的写入(在内存表中,但尚未写入硬盘)将丢失。为了避免这个问题,我们可以在硬盘上保存一个单独的日志,每个写入都会立即被追加到这个日志上,就像在前面的章节中所描述的那样。这个日志没有按排序顺序,但这并不重要,因为它的唯一目的是在崩溃后恢复内存表。每当内存表写出到 SSTable 时,相应的日志都可以被丢弃。

 

其它记录https://cloud.tencent.com/developer/article/1441835

在LSM-Tree里,SSTable有一份在内存里面,其他的多级在磁盘上,如下图是一份完整的LSM-Tree图示:

我们总结下在在LSM-Tree里面如何写数据的?

1,当收到一个写请求时,会先把该条数据记录在WAL Log里面,用作故障恢复。

2,当写完WAL Log后,会把该条数据写入内存的SSTable里面(删除是墓碑标记,更新是新记录一条的数据),也称Memtable。注意为了维持有序性在内存里面可以采用红黑树或者跳跃表相关的数据结构。

3,当Memtable超过一定的大小后,会在内存里面冻结,变成不可变的Memtable,同时为了不阻塞写操作需要新生成一个Memtable继续提供服务。

4,把内存里面不可变的Memtable给dump到到硬盘上的SSTable层中,此步骤也称为Minor Compaction,这里需要注意在L0层的SSTable是没有进行合并的,所以这里的key range在多个SSTable中可能会出现重叠,在层数大于0层之后的SSTable,不存在重叠key。

5,当每层的磁盘上的SSTable的体积超过一定的大小或者个数,也会周期的进行合并。此步骤也称为Major Compaction,这个阶段会真正 的清除掉被标记删除掉的数据以及多版本数据的合并,避免浪费空间,注意由于SSTable都是有序的,我们可以直接采用merge sort进行高效合并。

接着我们总结下在LSM-Tree里面如何读数据的?

1,当收到一个读请求的时候,会直接先在内存里面查询,如果查询到就返回。

2,如果没有查询到就会依次下沉,知道把所有的Level层查询一遍得到最终结果。

思考查询步骤,我们会发现如果SSTable的分层越多,那么最坏的情况下要把所有的分层扫描一遍,对于这种情况肯定是需要优化的,如何优化?在 Bigtable 论文中提出了几种方式:

 

性能优化

与往常一样,要让存储引擎在实践中表现良好涉及到大量设计细节。例如,当查找数据库中不存在的键时,LSM 树算法可能会很慢:你必须先检查内存表,然后查看从最近的到最旧的所有的段(可能还必须从硬盘读取每一个段文件),然后才能确定这个键不存在。为了优化这种访问,存储引擎通常使用额外的布隆过滤器(Bloom filters)【15】。 (布隆过滤器是一种节省内存的数据结构,用于近似表达集合的内容,它可以告诉你数据库中是否存在某个键,从而为不存在的键省掉许多不必要的硬盘读取操作。) 

 3.1.2.3 B树

 

 
 

3.2 事务处理与分析处理

3.3 列式存储

如果事实表中有万亿行和数 PB 的数据,那么高效地存储和查询它们就成为一个具有挑战性的问题。维度表通常要小得多(数百万行),所以在本节中我们将主要关注事实表的存储。

尽管事实表通常超过 100 列,但典型的数据仓库查询一次只会访问其中 4 个或 5 个列( “SELECT *” 查询很少用于分析)【51】。以 例 3-1 中的查询为例:它访问了大量的行(在 2013 年中所有购买了水果或糖果的记录),但只需访问 fact_sales 表的三列:date_key, product_sk, quantity。该查询忽略了所有其他的列。

例 3-1 分析人们是否更倾向于在一周的某一天购买新鲜水果或糖果

SELECT
  dim_date.weekday,
  dim_product.category,
  SUM(fact_sales.quantity) AS quantity_sold
FROM fact_sales
  JOIN dim_date ON fact_sales.date_key = dim_date.date_key
  JOIN dim_product ON fact_sales.product_sk = dim_product.product_sk
WHERE
  dim_date.year = 2013 AND
  dim_product.category IN ('Fresh fruit', 'Candy')
GROUP BY
  dim_date.weekday, dim_product.category;
 

我们如何有效地执行这个查询?

在大多数 OLTP 数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库也是相似的:整个文档通常存储为一个连续的字节序列。你可以在 图 3-1 的 CSV 例子中看到这个。

为了处理像 例 3-1 这样的查询,你可能在 fact_sales.date_keyfact_sales.product_sk 上有索引,它们告诉存储引擎在哪里查找特定日期或特定产品的所有销售情况。但是,面向行的存储引擎仍然需要将所有这些行(每个包含超过 100 个属性)从硬盘加载到内存中,解析它们,并过滤掉那些不符合要求的属性。这可能需要很长时间。

列式存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起(一列中的所有值都相邻存储。如果每个列式存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如 图 3-10 所示。

图 3-10 按列存储关系型数据,而不是行

列式存储在关系数据模型中是最容易理解的,但它同样适用于非关系数据。例如,Parquet【57】是一种列式存储格式,支持基于 Google 的 Dremel 的文档数据模型【54】。

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

列压缩

除了仅从硬盘加载查询所需的列以外,我们还可以通过压缩数据来进一步降低对硬盘吞吐量的需求。幸运的是,列式存储通常很适合压缩

列式存储中的排序顺序

在列式存储中,存储行的顺序并不关键。按插入顺序存储它们是最简单的,因为插入一个新行只需要追加到每个列文件。但是,我们也可以选择按某种顺序来排列数据,就像我们之前对 SSTables 所做的那样,并将其用作索引机制。

注意,对每列分别执行排序是没有意义的,因为那样就没法知道不同列中的哪些项属于同一行。我们只能在明确一列中的第 k 项与另一列中的第 k 项属于同一行的情况下,才能重建出完整的行。

相反,数据的排序需要对一整行统一操作,即使它们的存储方式是按列的。数据库管理员可以根据他们对常用查询的了解,来选择表格中用来排序的列。例如,如果查询通常以日期范围为目标,例如“上个月”,则可以将 date_key 作为第一个排序键。这样查询优化器就可以只扫描近1个月范围的行了,这比扫描所有行要快得多。

对于第一排序列中具有相同值的行,可以用第二排序列来进一步排序。

第4章 编码与演化

关系数据库通常假定数据库中的所有数据都遵循一个模式:尽管可以更改该模式(通过模式迁移,即 ALTER 语句),但是在任何时间点都有且仅有一个正确的模式。相比之下,读时模式(schema-on-read,或 无模式,即 schemaless)数据库不会强制一个模式,因此数据库可以包含在不同时间写入的新老数据格式的混合(请参阅 “文档模型中的模式灵活性” )。

当数据 格式(format) 或 模式(schema) 发生变化时,通常需要对应用程序代码进行相应的更改(例如,为记录添加新字段,然后修改程序开始读写该字段)。但在大型应用程序中,代码变更通常不会立即完成:

  • 对于 服务端(server-side) 应用程序,可能需要执行 滚动升级 (rolling upgrade) (也称为 阶段发布(staged rollout) ),一次将新版本部署到少数几个节点,检查新版本是否运行正常,然后逐渐部完所有的节点。这样无需中断服务即可部署新版本,为频繁发布提供了可行性,从而带来更好的可演化性。
  • 对于 客户端(client-side) 应用程序,升不升级就要看用户的心情了。用户可能相当长一段时间里都不会去升级软件。

这意味着,新旧版本的代码,以及新旧数据格式可能会在系统中同时共处。系统想要继续顺利运行,就需要保持 双向兼容性

  • 向后兼容 (backward compatibility)

    新的代码可以读取由旧的代码写入的数据。

  • 向前兼容 (forward compatibility)

    旧的代码可以读取由新的代码写入的数据。

记忆 :老数据排在左侧、新数据排在右侧

老数据 ——新代码----新数据   :  向后兼容

老数据 ----老代码——新数据   :  向前兼容

 

向后兼容性通常并不难实现:新代码的作者当然知道由旧代码使用的数据格式,因此可以显示地处理它(最简单的办法是,保留旧代码即可读取旧数据)。

向前兼容性可能会更棘手,因为旧版的程序需要忽略新版数据格式中新增的部分。

本章中将介绍几种编码数据的格式,包括 JSON、XML、Protocol Buffers、Thrift 和 Avro。尤其将关注这些格式如何应对模式变化,以及它们如何对新旧代码数据需要共存的系统提供支持。然后将讨论如何使用这些格式进行数据存储和通信:在 Web 服务中,表述性状态传递(REST) 和 远程过程调用(RPC),以及 消息传递系统(如 Actor 和消息队列)。

编码数据的格式

程序通常(至少)使用两种形式的数据:

  1. 内存中,数据保存在对象、结构体、列表、数组、散列表、树等中。这些数据结构针对 CPU 的高效访问和操作进行了优化(通常使用指针)。
  2. 如果要将数据写入文件,或通过网络发送,则必须将其 编码(encode) 为某种自包含的字节序列(例如,JSON 文档)。由于每个进程都有自己独立的地址空间,一个进程中的指针对任何其他进程都没有意义,所以这个字节序列表示会与通常在内存中使用的数据结构完全不同 1

所以,需要在两种表示之间进行某种类型的翻译。从内存中表示到字节序列的转换称为 编码(Encoding) (也称为 序列化(serialization) 或 编组(marshalling)),反过来称为 解码(Decoding)2(解析(Parsing),反序列化(deserialization),反编组(unmarshalling))3

 

JSON、XML和二进制变体

当我们谈到可以被多种编程语言读写标准编码时,JSON 和 XML 是最显眼的角逐者。

二进制编码

对于仅在组织内部使用的数据,使用最小公约数式的编码格式压力较小。例如,可以选择更紧凑或更快的解析格式。虽然对小数据集来说,收益可以忽略不计;但一旦达到 TB 级别,数据格式的选型就会产生巨大的影响。

JSON 比 XML 简洁,但与二进制格式相比还是太占空间

Thrift与Protocol Buffers

Apache Thrift 【15】和 Protocol Buffers(protobuf)【16】是基于相同原理的二进制编码库。Protocol Buffers 最初是在 Google 开发的,Thrift 最初是在 Facebook 开发的,并且都是在 2007~2008 开源的【17】。 Thrift 和 Protocol Buffers 都需要一个模式编码任何数据。要在 Thrift 的 例 4-1 中对数据进行编码,可以使用 Thrift 接口定义语言(IDL) 来描述模式,如下所示:

struct Person {
    1: required string       userName,
    2: optional i64          favoriteNumber,
    3: optional list<string> interests
}
 

Protocol Buffers 的等效模式定义看起来非常相似:

message Person {
    required string user_name       = 1;
    optional int64  favorite_number = 2;
    repeated string interests       = 3;
}
 

Thrift 和 Protocol Buffers 每一个都带有一个代码生成工具,它采用了类似于这里所示的模式定义,并且生成了以各种编程语言实现模式的类【18】。

模式的优点

正如我们所看到的,Protocol Buffers、Thrift 和 Avro 都使用模式来描述二进制编码格式。他们的模式语言比 XML 模式或者 JSON 模式简单得多,而后者支持更详细的验证规则(例如,“该字段的字符串值必须与该正则表达式匹配” 或 “该字段的整数值必须在 0 和 100 之间” )。由于 Protocol Buffers,Thrift 和 Avro 实现起来更简单,使用起来也更简单,所以它们已经发展到支持相当广泛的编程语言。

所以,我们可以看到,尽管 JSON、XML 和 CSV 等文本数据格式非常普遍,但基于模式二进制编码也是一个可行的选择。他们有一些很好的属性:

  • 它们可以比各种 “二进制 JSON” 变体更紧凑,因为它们可以省略编码数据中的字段名称。
  • 模式是一种有价值的文档形式,因为模式是解码所必需的,所以可以确定它是最新的(而手动维护的文档可能很容易偏离现实)。
  • 维护一个模式的数据库允许你在部署任何内容之前检查模式更改的向前和向后兼容性。
  • 对于静态类型编程语言的用户来说,从模式生成代码的能力是有用的,因为它可以在编译时进行类型检查。

总而言之,模式演化保持了与 JSON 数据库提供的无模式 / 读时模式相同的灵活性(请参阅 “文档模型中的模式灵活性”),同时还可以更好地保证你的数据并提供更好的工具。

第二部分:分布式数据系统

在本书的 第一部分 中,我们讨论了数据系统的各个方面,但仅限于数据存储在单台机器上的情况。你可能会出于各种各样的原因,希望将数据库分布到多台机器上

  • 可伸缩性

    如果你的数据量、读取负载、写入负载超出单台机器的处理能力,可以将负载分散到多台计算机上。

  • 容错 / 高可用性

    如果你的应用需要在单台机器(或多台机器,网络或整个数据中心)出现故障的情况下仍然能继续工作,则可使用多台机器,以提供冗余。一台故障时,另一台可以接管。

  • 延迟

    如果在世界各地都有用户,你也许会考虑在全球范围部署多个服务器,从而每个用户可以从地理上最近的数据中心获取服务,避免了等待网络数据包穿越半个世界。

 

方法1:伸缩至更高的 载荷(load)

如果你需要的只是伸缩至更高的 载荷(load),最简单的方法就是购买更强大的机器(有时称为 垂直伸缩,即 vertical scaling,或 向上伸缩,即 scale up)。许多处理器,内存和磁盘可以在同一个操作系统下相互连接,快速的相互连接允许任意处理器访问内存或磁盘的任意部分。在这种 共享内存架构(shared-memory architecture) 中,所有的组件都可以看作一台单独的机器 1

共享内存方法的问题在于,成本增长速度快于线性增长:一台有着双倍处理器数量,双倍内存大小,双倍磁盘容量的机器,通常成本会远远超过原来的两倍。而且可能因为存在瓶颈,并不足以处理双倍的载荷。

另一种方法是 共享磁盘架构(shared-disk architecture),它使用多台具有独立处理器和内存的机器,但将数据存储在机器之间共享的磁盘阵列上,这些磁盘通过快速网络连接 2。这种架构用于某些数据仓库,但竞争和锁定的开销限制了共享磁盘方法的可伸缩性【2】。

 

方法2:伸缩至更高的 载荷(load)

  相比之下,无共享架构【3】(shared-nothing architecture,有时被称为 水平伸缩,即 horizontal scaling,或 向外伸缩,即 scaling out)已经相当普及。在这种架构中,运行数据库软件的每台机器 / 虚拟机都称为 节点(node)。每个节点只使用各自的处理器,内存和磁盘。节点之间的任何协调,都是在软件层面使用传统网络实现的。

  无共享系统不需要使用特殊的硬件,所以你可以用任意机器 —— 比如性价比最好的机器。你也许可以跨多个地理区域分布数据从而减少用户延迟,或者在损失一整个数据中心的情况下幸免于难。随着云端虚拟机部署的出现,即使是小公司,现在无需 Google 级别的运维,也可以实现异地分布式架构。

复制 vs 分区

数据分布在多个节点上有两种常见的方式:

  • 复制(Replication)

    在几个不同的节点上保存数据的相同副本,可能放在不同的位置。复制提供了冗余:如果一些节点不可用,剩余的节点仍然可以提供数据服务。复制也有助于改善性能。第五章 将讨论复制。

  • 分区 (Partitioning)

    将一个大型数据库拆分成较小的子集(称为 分区,即 partitions),从而不同的分区可以指派给不同的 节点(nodes,亦称 分片,即 sharding)。第六章 将讨论分区。

复制和分区是不同的机制,但它们经常同时使用。如 图 II-1 所示。

图 II-1 一个数据库切分为两个分区,每个分区都有两个副本

第三部分:派生数据

第10章 批处理

在本书的前两部分中,我们讨论了很多关于 请求 和 查询 以及相应的 响应 或 结果。许多现有数据系统中都采用这种数据处理方式:你发送请求指令,一段时间后(我们期望)系统会给出一个结果。数据库、缓存、搜索索引、Web 服务器以及其他一些系统都以这种方式工作。

像这样的 在线(online) 系统,无论是浏览器请求页面还是调用远程 API 的服务,我们通常认为请求是由人类用户触发的,并且正在等待响应。他们不应该等太久,所以我们非常关注系统的响应时间(请参阅 “描述性能”)。

我们来看看三种不同类型的系统:

  • 服务(在线系统

    服务等待客户的请求或指令到达。每收到一个,服务会试图尽快处理它,并发回一个响应。响应时间通常是服务性能的主要衡量指标,可用性通常非常重要(如果客户端无法访问服务,用户可能会收到错误消息)。

  • 批处理系统离线系统

    一个批处理系统有大量的输入数据,跑一个 作业(job) 来处理它,并生成一些输出数据,这往往需要一段时间(从几分钟到几天),所以通常不会有用户等待作业完成。相反,批量作业通常会定期运行(例如,每天一次)。批处理作业的主要性能衡量标准通常是吞吐量(处理特定大小的输入所需的时间)。本章中讨论的就是批处理。

  • 流处理系统准实时系统

    流处理介于在线和离线(批处理)之间,所以有时候被称为 准实时(near-real-time) 或 准在线(nearline) 处理。像批处理系统一样,流处理消费输入并产生输出(并不需要响应请求)。但是,流式作业在事件发生后不久就会对事件进行操作,而批处理作业则需等待固定的一组输入数据。这种差异使流处理系统比起批处理系统具有更低的延迟。由于流处理基于批处理,我们将在 第十一章 讨论它。

cat /var/log/nginx/access.log | #1
  awk '{print $7}' | #2
  sort             | #3
  uniq -c          | #4
  sort -r -n       | #5
  head -n 5          #6
 
  1. 读取日志文件
  2. 将每一行按空格分割成不同的字段,每行只输出第七个字段,恰好是请求的 URL。在我们的例子中是 /css/typography.css
  3. 按字母顺序排列请求的 URL 列表。如果某个 URL 被请求过 n 次,那么排序后,文件将包含连续重复出现 n 次的该 URL。
  4. uniq 命令通过检查两个相邻的行是否相同来过滤掉输入中的重复行。-c 则表示还要输出一个计数器:对于每个不同的 URL,它会报告输入中出现该 URL 的次数。
  5. 第二种排序按每行起始处的数字(-n)排序,这是 URL 的请求次数。然后逆序(-r)返回结果,大的数字在前。
  6. 最后,只输出前五行(-n 5),并丢弃其余的。该系列命令的输出如下所示:
    4189 /favicon.ico
    3631 /2013/05/24/improving-security-of-ssh-private-keys.html
    2124 /2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html
    1369 /
     915 /css/typography.css
 

MapReduce 有点像 Unix 工具,但分布在数千台机器上。像 Unix 工具一样,它相当简单粗暴,但令人惊异地管用。一个 MapReduce 作业可以和一个 Unix 进程相类比:它接受一个或多个输入,并产生一个或多个输出。(‘grep old ./ -rl’是为了查找当前目录下带有值old的所有文件

和大多数 Unix 工具一样,运行 MapReduce 作业通常不会修改输入,除了生成输出外没有任何副作用。输出文件以连续的方式一次性写入(一旦写入文件,不会修改任何现有的文件部分)。

虽然 Unix 工具使用 stdin 和 stdout(标准输入输出 作为输入和输出,但 MapReduce 作业在分布式文件系统(HDFS)上读写文件。在 Hadoop 的 MapReduce 实现中,该文件系统被称为 HDFS(Hadoop 分布式文件系统),一个 Google 文件系统(GFS)的开源实现【19】。

与网络连接存储(NAS)和存储区域网络(SAN)架构的共享磁盘方法相比,HDFS 基于 无共享 原则(请参阅 第二部分 的介绍)。共享磁盘存储由集中式存储设备实现,通常使用定制硬件和专用网络基础设施(如光纤通道)。而另一方面,无共享方法不需要特殊的硬件,只需要通过传统数据中心网络连接的计算机。

HDFS 在每台机器上运行了一个守护进程,它对外暴露网络服务允许其他节点访问存储在该机器上的文件(假设数据中心中的每台通用计算机都挂载着一些磁盘)。名为 NameNode 的中央服务器会跟踪哪个文件块存储在哪台机器上。因此,HDFS 在概念上创建了一个大型文件系统,可以使用所有运行有守护进程的机器的磁盘。

为了容忍机器和磁盘故障,文件块被复制到多台机器上。复制可能意味着多个机器上的相同数据的多个副本,如 第五章 中所述,或者诸如 Reed-Solomon 码这样的纠删码方案,它能以比完全复制更低的存储开销来支持恢复丢失的数据【20,22】。这些技术与 RAID 相似,后者可以在连接到同一台机器的多个磁盘上提供冗余;区别在于在分布式文件系统中,文件访问和复制是在传统的数据中心网络上完成的,没有特殊的硬件。

 

MapReduce作业执行

MapReduce 是一个编程框架,你可以使用它编写代码来处理 HDFS 等分布式文件系统中的大型数据集。理解它的最简单方法是参考 “简单日志分析” 中的 Web 服务器日志分析示例。MapReduce 中的数据处理模式与此示例非常相似:

  1. 读取一组输入文件,并将其分解成 记录(records)。在 Web 服务器日志示例中,每条记录都是日志中的一行(即 \n 是记录分隔符)。
  2. 调用 Mapper 函数,从每条输入记录中提取一对键值。在前面的例子中,Mapper 函数是 awk '{print $7}':它提取 URL($7)作为键,并将值留空。
  3. 按键排序所有的键值对。在日志的例子中,这由第一个 sort 命令完成。
  4. 调用 Reducer 函数遍历排序后的键值对。如果同一个键出现多次,排序使它们在列表中相邻,所以很容易组合这些值而不必在内存中保留很多状态。在前面的例子中,Reducer 是由 uniq -c 命令实现的,该命令使用相同的键来统计相邻记录的数量。

这四个步骤可以作为一个 MapReduce 作业执行。步骤 2(Map)和 4(Reduce)是你编写自定义数据处理代码的地方。步骤 1(将文件分解成记录)由输入格式解析器处理。步骤 3 中的排序步骤隐含在 MapReduce 中 —— 你不必编写它,因为 Mapper 的输出始终在送往 Reducer 之前进行排序。

要创建 MapReduce 作业,你需要实现两个回调函数,Mapper 和 Reducer,其行为如下(请参阅 “MapReduce 查询”):

  • Mapper

    Mapper 会在每条输入记录上调用一次,其工作是从输入记录中提取键值。对于每个输入,它可以生成任意数量的键值对(包括 None)。它不会保留从一个输入记录到下一个记录的任何状态,因此每个记录都是独立处理的。

  • Reducer

    MapReduce 框架拉取由 Mapper 生成的键值对,收集属于同一个键的所有值,并在这组值上迭代调用 Reducer。Reducer 可以产生输出记录(例如相同 URL 的出现次数)。

在 Web 服务器日志的例子中,我们在第 5 步中有第二个 sort 命令,它按请求数对 URL 进行排序。在 MapReduce 中,如果你需要第二个排序阶段,则可以通过编写第二个 MapReduce 作业并将第一个作业的输出用作第二个作业的输入来实现它。这样看来,Mapper 的作用是将数据放入一个适合排序的表单中,并且 Reducer 的作用是处理已排序的数据。

分布式执行MapReduce

MapReduce 与 Unix 命令管道的主要区别在于,MapReduce 可以在多台机器上并行执行计算,而无需编写代码来显式处理并行问题。Mapper 和 Reducer 一次只能处理一条记录;它们不需要知道它们的输入来自哪里,或者输出去往什么地方,所以框架可以处理在机器之间移动数据的复杂性。

在分布式计算中可以使用标准的 Unix 工具作为 Mapper 和 Reducer【25】,但更常见的是,它们被实现为传统编程语言的函数。在 Hadoop MapReduce 中,Mapper 和 Reducer 都是实现特定接口的 Java 类。

第11章 流处理

  在 第十章 中仍然有一个很大的假设(批处理):即输入是有界的,即已知和有限的大小,所以批处理知道它何时完成输入的读取。例如,MapReduce 核心的排序操作必须读取其全部输入,然后才能开始生成输出:可能发生这种情况:最后一条输入记录具有最小的键,因此需要第一个被输出,所以提早开始输出是不可行的。

  实际上,很多数据是 无界限 的,因为它随着时间的推移而逐渐到达:你的用户在昨天和今天产生了数据,明天他们将继续产生更多的数据。除非你停业,否则这个过程永远都不会结束,所以数据集从来就不会以任何有意义的方式 “完成”【1】。因此,批处理程序必须将数据人为地分成固定时间段的数据块,例如,在每天结束时处理一天的数据,或者在每小时结束时处理一小时的数据。

  日常批处理中的问题是,输入的变更只会在一天之后的输出中反映出来,这对于许多急躁的用户来说太慢了。为了减少延迟,我们可以更频繁地运行处理 —— 比如说,在每秒钟的末尾 —— 或者甚至更连续一些,完全抛开固定的时间切片,当事件发生时就立即进行处理,这就是 流处理(stream processing) 背后的想法。

  一般来说,“流” 是指随着时间的推移逐渐可用的数据。这个概念出现在很多地方:Unix 的 stdin 和 stdout、编程语言(惰性列表)【2】、文件系统 API(如 Java 的 FileInputStream)、TCP 连接、通过互联网传送音频和视频等等。

  在本章中,我们将把 事件流(event stream) 视为一种数据管理机制:无界限增量处理,与上一章中的批量数据相对应。我们将首先讨论怎样表示、存储、通过网络传输流。在 “数据库与流” 中,我们将研究流和数据库之间的关系。最后在 “流处理” 中,我们将研究连续处理这些流的方法和工具,以及它们用于应用构建的方式。

 

传递事件流

在批处理领域,作业的输入和输出是文件(也许在分布式文件系统上)。流处理领域中的等价物看上去是什么样子的?

当输入是一个文件(一个字节序列),第一个处理步骤通常是将其解析为一系列记录。在流处理的上下文中,记录通常被叫做 事件(event) ,但它本质上是一样的:一个小的、自包含的、不可变的对象,包含某个时间点发生某件事情的细节。一个事件通常包含一个来自日历时钟的时间戳,以指明事件发生的时间(请参阅 “单调钟与日历时钟”)。

例如,发生的事件可能是用户采取的行动,例如查看页面或进行购买。它也可能来源于机器,例如对温度传感器或 CPU 利用率的周期性测量。在 “使用 Unix 工具的批处理” 的示例中,Web 服务器日志的每一行都是一个事件。

事件可能被编码为文本字符串或 JSON,或者某种二进制编码,如 第四章 所述。这种编码允许你存储一个事件,例如将其追加到一个文件,将其插入关系表,或将其写入文档数据库。它还允许你通过网络将事件发送到另一个节点以进行处理。

在批处理中,文件被写入一次,然后可能被多个作业读取。类似地,在流处理术语中,一个事件由 生产者(producer) (也称为 发布者(publisher) 或 发送者(sender) )生成一次,然后可能由多个 消费者(consumer) ( 订阅者(subscribers) 或 接收者(recipients) )进行处理【3】。在文件系统中,文件名标识一组相关记录;在流式系统中,相关的事件通常被聚合为一个 主题(topic) 或 流(stream)

原则上讲,文件或数据库就足以连接生产者和消费者:生产者将其生成的每个事件写入数据存储,且每个消费者定期轮询数据存储检查自上次运行以来新出现的事件。这实际上正是批处理在每天结束时处理当天数据时所做的事情。

但当我们想要进行低延迟的连续处理时,如果数据存储不是为这种用途专门设计的,那么轮询开销就会很大。轮询的越频繁,能返回新事件的请求比例就越低,而额外开销也就越高。相比之下,最好能在新事件出现时直接通知消费者。

数据库在传统上对这种通知机制支持的并不好,关系型数据库通常有 触发器(trigger) ,它们可以对变化(如,插入表中的一行)作出反应,但是它们的功能非常有限,并且在数据库设计中有些后顾之忧【4,5】。相应的是,已经开发了专门的工具来提供事件通知。

消息传递系统

向消费者通知新事件的常用方式是使用 消息传递系统(messaging system  或 发布/订阅 模式):生产者发送包含事件的消息,然后将消息推送给消费者。我们之前在 “消息传递中的数据流” 中谈到了这些系统,但现在我们将详细介绍这些系统。

生产者和消费者之间的 Unix 管道TCP 连接这样的直接信道,是实现消息传递系统的简单方法。但是,大多数消息传递系统都在这一基本模型上进行了扩展。特别的是,Unix 管道和 TCP 将恰好一个发送者与恰好一个接收者连接,而一个消息传递系统允许多个生产者节点将消息发送到同一个主题,并允许多个消费者节点接收主题中的消息。

1.直接从生产者传递给消费者

许多消息传递系统使用生产者和消费者之间的直接网络通信,而不通过中间节点:

  • UDP 组播广泛应用于金融行业,例如股票市场,其中低时延非常重要【8】。虽然 UDP 本身是不可靠的,但应用层的协议可以恢复丢失的数据包(生产者必须记住它发送的数据包,以便能按需重新发送数据包)。
  • 无代理的消息库,如 ZeroMQ 【9】和 nanomsg 采取类似的方法,通过 TCP 或 IP 多播实现发布 / 订阅消息传递。
  • StatsD 【10】和 Brubeck 【7】使用不可靠的 UDP 消息传递来收集网络中所有机器的指标并对其进行监控。(在 StatsD 协议中,只有接收到所有消息,才认为计数器指标是正确的;使用 UDP 将使得指标处在一种最佳近似状态【11】。另请参阅 “TCP 与 UDP
  • 如果消费者在网络上公开了服务,生产者可以直接发送 HTTP 或 RPC 请求(请参阅 “服务中的数据流:REST 与 RPC”)将消息推送给使用者。这就是 webhooks 背后的想法【12】,一种服务的回调 URL 被注册到另一个服务中,并且每当事件发生时都会向该 URL 发出请求。

尽管这些直接消息传递系统在设计它们的环境中运行良好,但是它们通常要求应用代码意识到消息丢失的可能性。它们的容错程度极为有限:即使协议检测到并重传在网络中丢失的数据包,它们通常也只是假设生产者和消费者始终在线。

如果消费者处于脱机状态,则可能会丢失其不可达时发送的消息。一些协议允许生产者重试失败的消息传递,但当生产者崩溃时,它可能会丢失消息缓冲区及其本应发送的消息,这种方法可能就没用了。

2.消息代理

一种广泛使用的替代方法是通过 消息代理(message broker,也称为 消息队列,即 message queue)发送消息,消息代理实质上是一种针对处理消息流而优化的数据库。它作为服务器运行,生产者和消费者作为客户端连接到服务器。生产者将消息写入代理,消费者通过从代理那里读取来接收消息。

消息代理与数据库的对比

有些消息代理甚至可以使用 XA 或 JTA 参与两阶段提交协议(请参阅 “实践中的分布式事务”)。这个功能与数据库在本质上非常相似,尽管消息代理和数据库之间仍存在实践上很重要的差异:

  • 数据库通常保留数据直至显式删除,而大多数消息代理在消息成功递送给消费者时自动删除消息。这样的消息代理不适合长期的数据存储。
  • 由于它们很快就能删除消息,大多数消息代理都认为它们的工作集相当小 —— 即队列很短。如果代理需要缓冲很多消息,比如因为消费者速度较慢(如果内存装不下消息,可能会溢出到磁盘),每个消息需要更长的处理时间,整体吞吐量可能会恶化【6】。
  • 数据库通常支持次级索引和各种搜索数据的方式,而消息代理通常支持按照某种模式匹配主题,订阅其子集。虽然机制并不一样,但对于客户端选择想要了解的数据的一部分,都是基本的方式。
  • 查询数据库时,结果通常基于某个时间点的数据快照;如果另一个客户端随后向数据库写入一些改变了查询结果的内容,则第一个客户端不会发现其先前结果现已过期(除非它重复查询或轮询变更)。相比之下,消息代理不支持任意查询,但是当数据发生变化时(即新消息可用时),它们会通知客户端。

这是关于消息代理的传统观点,它被封装在诸如 JMS 【14】和 AMQP 【15】的标准中,并且被诸如 RabbitMQ、ActiveMQ、HornetQ、Qpid、TIBCO 企业消息服务、IBM MQ、Azure Service Bus 和 Google Cloud Pub/Sub 所实现 【16】。

分区日志

通过网络发送数据包或向网络服务发送请求通常是短暂的操作,不会留下永久的痕迹。尽管可以永久记录(通过抓包与日志),但我们通常不这么做。即使是将消息持久地写入磁盘的消息代理,在送达给消费者之后也会很快删除消息,因为它们建立在短暂消息传递的思维方式上。

数据库和文件系统采用截然相反的方法论:至少在某人显式删除前,通常写入数据库或文件的所有内容都要被永久记录下来。

为什么我们不能把它俩杂交一下,既有数据库的持久存储方式,又有消息传递的低延迟通知?这就是 基于日志的消息代理(log-based message brokers) 背后的想法。

使用日志进行消息存储

日志只是磁盘上简单的仅追加记录序列。我们先前在 第三章 中日志结构存储引擎和预写式日志的上下文中讨论了日志,在 第五章 复制的上下文里也讨论了它。

同样的结构可以用于实现消息代理:生产者通过将消息追加到日志末尾来发送消息,而消费者通过依次读取日志来接收消息。如果消费者读到日志末尾,则会等待新消息追加的通知。Unix 工具 tail -f 能监视文件被追加写入的数据,基本上就是这样工作的。

Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 DistributedLog 【20,21】都是基于日志的消息代理 

事件溯源

与变更数据捕获类似,事件溯源涉及到 将所有对应用状态的变更 存储为变更事件日志。最大的区别是事件溯源将这一想法应用到了一个不同的抽象层次上:

  • 在变更数据捕获中,应用以 可变方式(mutable way) 使用数据库,可以任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免 图 11-4 中的竞态条件。写入数据库的应用不需要知道 CDC 的存在。
  • 在事件溯源中,应用逻辑显式构建在写入事件日志的不可变事件之上。在这种情况下,事件存储是仅追加写入的,更新与删除是不鼓励的或禁止的。事件被设计为旨在反映应用层面发生的事情,而不是底层的状态变更。

不可变事件的优点

数据库中的不变性是一个古老的概念。例如,会计在几个世纪以来一直在财务记账中应用不变性。一笔交易发生时,它被记录在一个仅追加写入的分类帐中,实质上是描述货币、商品或服务转手的事件日志。账目,比如利润、亏损、资产负债表,是从分类账中的交易求和衍生而来【53】。

如果发生错误,会计师不会删除或更改分类帐中的错误交易 —— 而是添加另一笔交易以补偿错误,例如退还一笔不正确的费用。不正确的交易将永远保留在分类帐中,对于审计而言可能非常重要。如果从不正确的分类账衍生出的错误数字已经公布,那么下一个会计周期的数字就会包括一个更正。这个过程在会计事务中是很常见的【54】。

尽管这种可审计性只在金融系统中尤其重要,但对于不受这种严格监管的许多其他系统,也是很有帮助的。如 “批处理输出的哲学” 中所讨论的,如果你意外地部署了将错误数据写入数据库的错误代码,当代码会破坏性地覆写数据时,恢复要困难得多。使用不可变事件的仅追加日志,诊断问题与故障恢复就要容易的多。

数据库与流

我们已经在消息代理和数据库之间进行了一些比较。尽管传统上它们被视为单独的工具类别,但是我们看到基于日志的消息代理已经成功地从数据库中获取灵感并将其应用于消息传递。我们也可以反过来:从消息传递和流中获取灵感,并将它们应用于数据库。

我们之前曾经说过,事件是某个时刻发生的事情的记录。发生的事情可能是用户操作(例如键入搜索查询)或读取传感器,但也可能是 写入数据库。某些东西被写入数据库的事实是可以被捕获、存储和处理的事件。这一观察结果表明,数据库和数据流之间的联系不仅仅是磁盘日志的物理存储 —— 而是更深层的联系。

事实上,复制日志(请参阅 “复制日志的实现”)是一个由数据库写入事件组成的,由主库在处理事务时生成。从库将写入流应用到它们自己的数据库副本,从而最终得到相同数据的精确副本。复制日志中的事件描述发生的数据更改。

保持系统同步

正如我们在本书中所看到的,没有一个系统能够满足所有的数据存储、查询和处理需求。在实践中,大多数重要应用都需要组合使用几种不同的技术来满足所有的需求:例如,使用 OLTP 数据库来为用户请求提供服务,使用缓存来加速常见请求,使用全文索引来处理搜索查询,使用数据仓库用于分析。每一种技术都有自己的数据副本,并根据自己的目的进行存储方式的优化。

由于相同或相关的数据出现在了不同的地方,因此相互间需要保持同步:如果某个项目在数据库中被更新,它也应当在缓存、搜索索引和数据仓库中被更新。对于数据仓库,这种同步通常由 ETL 进程执行(请参阅 “数据仓库”),通常是先取得数据库的完整副本,然后执行转换,并批量加载到数据仓库中 —— 换句话说,批处理。我们在 “批处理工作流的输出” 中同样看到了如何使用批处理创建搜索索引、推荐系统和其他衍生数据系统。

如果周期性的完整数据库转储过于缓慢,有时会使用的替代方法是 双写(dual write),其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。

但是,双写有一些严重的问题,其中一个是竞争条件,如 图 11-4 所示。在这个例子中,两个客户端同时想要更新一个项目 X:客户端 1 想要将值设置为 A,客户端 2 想要将其设置为 B。两个客户端首先将新值写入数据库,然后将其写入到搜索索引。因为运气不好,这些请求的时序是交错的:数据库首先看到来自客户端 1 的写入将值设置为 A,然后来自客户端 2 的写入将值设置为 B,因此数据库中的最终值为 B。搜索索引首先看到来自客户端 2 的写入,然后是客户端 1 的写入,所以搜索索引中的最终值是 A。即使没发生错误,这两个系统现在也永久地不一致了。

图 11-4 在数据库中 X 首先被设置为 A,然后被设置为 B,而在搜索索引处,写入以相反的顺序到达

除非有一些额外的并发检测机制,例如我们在 “检测并发写入” 中讨论的版本向量,否则你甚至不会意识到发生了并发写入 —— 一个值将简单地以无提示方式覆盖另一个值。

双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(请参阅 “原子提交与两阶段提交”)。

变更数据捕获

变更数据捕获的实现

我们可以将日志消费者叫做 衍生数据系统,正如在 第三部分 的介绍中所讨论的:存储在搜索索引数据仓库中的数据,只是 记录系统 数据的额外视图。变更数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在衍生数据系统中,以便衍生系统具有数据的准确副本。

从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了 图 11-2 的重新排序问题)。

数据库触发器可用来实现变更数据捕获(请参阅 “基于触发器的复制”),通过注册观察所有变更的触发器,并将相应的变更项写入变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的方法,但它也很有挑战,例如如何应对模式变更。

流处理

到目前为止,本章中我们已经讨论了流的来源(用户活动事件,传感器和写入数据库),我们讨论了流如何传输直接通过消息传送通过消息代理通过事件日志)。

剩下的就是讨论一下你可以用流做什么 —— 也就是说,你可以处理它。一般来说,有三种选项:

  1. 你可以将事件中的数据写入数据库、缓存、搜索索引或类似的存储系统,然后能被其他客户端查询。如 图 11-5 所示,这是数据库与系统其他部分所发生的变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如 “批处理工作流的输出” 中所讨论的,它是写入存储系统的流等价物。
  2. 你能以某种方式将事件推送给用户,例如发送报警邮件或推送通知,或将事件流式传输到可实时显示的仪表板上。在这种情况下,人是流的最终消费者。
  3. 你可以处理一个或多个输入流,并产生一个或多个输出流。流可能会经过由几个这样的处理阶段组成的流水线,最后再输出(选项 1 或 2)。

在本章的剩余部分中,我们将讨论选项 3:处理流以产生其他衍生流。处理这样的流的代码片段,被称为 算子(operator) 或 作业(job)。它与我们在 第十章 中讨论过的 Unix 进程和 MapReduce 作业密切相关,数据流的模式是相似的:一个流处理器以只读的方式使用输入流,并将其输出以仅追加的方式写入一个不同的位置。

流处理中的分区和并行化模式也非常类似于 第十章 中介绍的 MapReduce 和数据流引擎,因此我们不再重复这些主题。基本的 Map 操作(如转换和过滤记录)也是一样的。

流与批量作业相比的一个关键区别是,流不会结束。这种差异会带来很多隐含的结果。正如本章开始部分所讨论的,

(1)排序对无界数据集没有意义,因此无法使用 排序合并连接(请参阅 “Reduce 侧连接与分组”)。

(2)容错机制也必须改变:对于已经运行了几分钟的批处理作业,可以简单地从头开始重启失败任务但是对于已经运行数年的流作业重启后从头开始跑可能并不是一个可行的选项

如何确定事件的时间戳

在流计算(Stream Processing)中,确定事件的时间戳是一个关键的操作,它允许系统对数据进行排序、窗口化处理、时间相关的分析和其他时间敏感的操作。一个事件的时间戳通常代表事件发生的时间。确定事件时间戳的方法依赖于数据源和具体的应用场景,以下是一些确定事件时间戳的常见方法:

  1. 事件产生时的系统时间:这是最直接的方法。当事件发生时(例如,用户点击、传感器读数),可以立即记录下系统的当前时间作为事件时间戳。

  2. 数据源内嵌的时间戳:许多数据源会在事件数据中内嵌时间戳,这些时间戳通常反映了事件在源系统中产生的时间。例如,日志记录通常包含时间戳,传感器数据可能有一个产生读数的时间标记。

  3. 事件日志或消息队列的时间戳:使用消息队列(如Kafka、RabbitMQ等)时,事件通常在进入队列时被赋予时间戳。这个时间戳也可以在流计算中作为事件的时间标记。

  4. 事件头信息或元数据:在网络协议或文件格式中,事件数据可能包含头信息或元数据部分,其中可以包括时间戳信息。例如,HTTP请求头中的Date字段,或者数据库事务日志中的时间记录。

  5. 自定义时间提取逻辑:在有些情况下,事件时间戳可能需要通过自定义的逻辑从事件内容中提取。例如,如果事件是一个JSON对象,时间戳可能是一个字段的值,需要用特定的解析逻辑来提取。

  6. 水印(Watermarks):在某些流计算框架(如Apache Flink、Google Cloud Dataflow)中,为了处理乱序事件或迟到数据,引入了“水印”概念。水印是一个特殊的时间戳,表示在这个时间点之前的数据都已经到达,用于触发时间窗口操作。

在实践中,为流中的事件确定时间戳常常涉及到对数据的预处理和解析,以确保时间戳的准确性和一致性。当选择时间戳的方法时,需要考虑数据的时效性、事件的顺序、系统的时钟同步问题以及可能的延迟和乱序问题。在某些流计算框架中,如Apache Flink和Apache Beam,开发者需要实现一个时间戳提取器(Timestamp Assigner)来为每个事件指定时间戳。

窗口的类型

当你知道如何确定一个事件的时间戳后,下一步就是如何定义时间段的窗口。然后窗口就可以用于聚合,例如事件计数,或计算窗口内值的平均值。

    • 流式计算
      • 滚动窗口
        • 16:00——(1000)—17:00——(清零,前面的1000重新算)—18:00  
        • 实现方式:生命周期和时间窗口选择一致,比如都是1D
        • 如下图:近5min,比如现在是17:04(那就是取 [17:00, 17:05)之间的),下一刻17:09(那就是取 [17:05, 17:09)之间的)
      • 滑动窗口(目前都是这么用的,近1D、近1H
        • 实现方式:1D生命周期、1h时间窗口
          • 存储:24个H的数据,用户取近3个小时,底层SUM(近3个小时)
        • 16:00 ——17:00————————————16:00—17:00
        • 用户:可以近24个小时,任何多少小时的数据
        • 如下图:近5min,比如现在是17:04(那就是取 [17:00, 17:05)之间的),下一刻17:05(那就是取 [17:01, 17:06)之间的)

本章小结

在本章中,我们讨论了事件流,它们所服务的目的,以及如何处理它们。在某些方面,流处理非常类似于在 第十章 中讨论的批处理,不过是在无限的(永无止境的)流而不是固定大小的输入上持续进行。从这个角度来看,消息代理和事件日志可以视作文件系统的流式等价物。

我们花了一些时间比较两种消息代理:

  • AMQP/JMS 风格的消息代理

    代理将单条消息分配给消费者,消费者在成功处理单条消息后确认消息。消息被确认后从代理中删除。这种方法适合作为一种异步形式的 RPC(另请参阅 “消息传递中的数据流”),例如在任务队列中,消息处理的确切顺序并不重要,而且消息在处理完之后,不需要回头重新读取旧消息。

  • 基于日志的消息代理

    代理将一个分区中的所有消息分配给同一个消费者节点,并始终以相同的顺序传递消息。并行是通过分区实现的,消费者通过存档最近处理消息的偏移量来跟踪工作进度。消息代理将消息保留在磁盘上,因此如有必要的话,可以回跳并重新读取旧消息。

基于日志的方法与数据库中的复制日志(请参阅 第五章)和日志结构存储引擎(请参阅 第三章)有相似之处。我们看到,这种方法对于消费输入流,并产生衍生状态或衍生输出数据流的系统而言特别适用。

  

 

posted on 2022-12-27 15:51  gogoy  阅读(487)  评论(0编辑  收藏  举报

导航