设计数据密集型应用
本文最后更新于:2 年前
这里是兔馋了很久的大名鼎鼎的DDIA,设计数据密集型应用。终于来啦!其他的一些经典书籍,后续也会慢慢更新昂!!!
DDIA就是神,尤其是自己虽然做了6824,但是感觉对于MapReduce的了解还是太少了,面试的时候尤其乏力。继续深入,继续努力,继续学习!
Introduction
分类:
- 数据密集型应用:数据是其主要挑战(数据量,数据复杂度或数据 变化速度)
- 计算密集型应用:处理器速度是其瓶颈。
书籍纲要:
- 第一部分:设计密集型引用所赖的基本思想。
- 第二部分:存储在一台机器上的数据 -> 分布在多台机器上的数据。
- 第三部分:从其他数据集衍生出的一些数据集的系统。
第一部分 数据系统的基石
第一章:可靠性,可扩展性,可维护性
数据系统的思考:
许多新的数据存储工具与数据处理工具。它们针对不同应用场景进行优化,类别之间的界限变得越来越模糊。(e.g. Redis可以作为MQ使用)
单个工具不满足,多工具协同使用(封装,提供接口和特性,同时缝合组件也有自己的特性和可能遇到的问题。)
可靠性
可以把可靠性粗略理解为“即使出现问题,也能 继续正确工作”。造成错误的原因叫做故障(fault),能预料并应对故障的系统特性可称为容错(fault-tolerant)或韧性(resilient)。
注意故障(fault)不同于失效(failure)。故障通常定义为系统的一部分状态偏离其标准,而失效则是系统作为一个整体停止向用户提供服务。
硬件故障:
- 硬盘崩溃、 内存出错、机房断电、有人拔错网线…,故障总会发生!!!
- 减少故障率 -> 增加单个硬件的冗余度
- 我们通常认为硬件故障是随机的、相互独立的:一台机器的磁盘失效并不意味着另一台机器 的磁盘也会失效。大量硬件组件不可能同时发生故障,除非它们存在比较弱的相关性(同样 的原因导致关联性错误,例如服务器机架的温度)。
软件错误:
- 软件故障的BUG通常会潜伏很长时间,直到被异常情况触发为止。
- 减少软件错误 -> 仔细考虑系统中的 假设和交互;᧿底的测试;进程隔离;允许进程崩溃并重启;测量、监控并分析生产环境中的系统行为。
- 系统也可以运行时自检,出现问题时报警。
人为错误:
- 总会犯错。
- 系统有很多方法,减少人的介入,故障恢复啊之类的。
可靠性的重要性:不可靠可能造成收入和声誉的巨大损失。
可拓展性
可扩展性(Scalability)是用来描述系统应对负载增长能力的术语。原书中有个Tweet技术选型的例子,可以看看书中,很有意思昂!!!
描述性能:
一旦系统的负载被描述好,就可以研究当负载增加会发生什么。
- 系统资源不变,负载增加,性能怎样。
- 性能不变,负载增加,需要额外多少资源。
所以如何描述呢?
- 对于批处理系统,Throughput(吞吐量)
- 在线系统,Response Time(服务响应时间)
延迟(latency)和响应时间(response time)经常用作同义词,但实际上它们并不一 样。响应时间是客户看到的。延迟是某个请求等待处理的持续时长,在此期间它 处于休眠(latent)状态,并等待服务。
- 平均响应时间,百分位点(百分之多少的用户经历过这个时间),中位数,高位百分点(异常的糟糕情况,p95, p99, p999,p就是percent),尾部延迟。
实践中的百分位点:
在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍 然需要等待最慢的并行呼叫完成。📊直方图很有效
应对负载:负载增加,保持良好性能
- 纵向拓展(scaling up/vertical scaling):转向强大的机器
- 横向拓展(scaling out/horizontal scaling):负载分布到多台小机器。
跨多台机器分配负载也称为“无共享(shared-nothing)”架构。非常密集的负载通常无法避免地需要横向扩展。
优秀架构需要将这两种方法务实地结合,因为使用几台足 够强大的机器可能比使用大量的小型虚拟机更简单也更便宜。
- 有些系统是弹性(elastic)的,这意味着可以在检测到负载增加时自动增加计算资源。如果负载极难预测 (highly unpredictable),则弹性系统可能很有用,但手动扩展系统更简单。
跨多台机器部署无状态服务(stateless services)非常简单,但将带状态的数据系统从单节 点变为分布式配置则可能引入许多额外复杂度。出于这个原因,常识告诉我们应该将数据库放在单个节点上(纵向扩展),直到扩展成本或可用性需求迫使其改为分布式。常识可能会变 -> 可以预见分布式数据系统将成为未来的默认设置,即使对不处理大量数据或流量的场景 也如此。
大规模的系统架构通常是应用特定的—— 没有一招鲜吃遍天的通用可扩展架构。应用的问题将是读取量,写入量等多方面或者所有问题的大杂烩。
一个良好适配应用的可扩展架构,是围绕着假设(assumption)建立的:哪些操作是常见 的?哪些操作是罕见的?这就是所谓负载参数。如果假设最终是错误的,那么为扩展所做的 工程投入就白费了,最糟糕的是适得其反。在早期创业公司或非正式产品中,通常支持产品 快速迭代的能力,要比可扩展至未来的假想负载要重要的多。 -> 尽管这些架构是应用程序特定的,但可扩展的架构通常也是从通用的积木块搭建而成的,并 以常见的模式排列。
可维护性
软件持续运行的维护阶段,包括修复漏洞、保持系统正常运行、调查失效、适配新的平台、为新的场景进行修改、偿还技术债、添加新的功能等等。
- 提前预防,设计就做好,考虑尽可能减少维护期间的痛苦。三个设计原则:
- 可操作性:人生苦短,关爱运维
- 简单性:管理复杂度
- 可演化性(也称为可扩展性extensibility,可修改性modifiability或可塑性plasticity):拥抱变化
第二章:数据模型与查询语言
一个复杂的应用程序可能会有更多的中间层次,比如基于API的API,不过基本思想仍然是一 样的:每个层都通过提供一个明确的数据模型来隐藏更低层次中的复杂性。这些抽象允许不 同的人群有效地协作(例如数据库厂商的工程师和使用数据库的应用程序开发人员)。
- 数据模型繁多,用起来体验不一,掌握很难,构建也很难,介绍简单的昂!
关系模型与文档模型
MySQL -> NoSQL,在可预见的未来,关系数据库似乎可能会继续与各种非关系数据库一起使用 - 这种想法有时也被称为混合持久化(polyglot persistence)
- 对象关系不匹配 -> SQL,应用程序代码中的对象和表,行,列的数据库模型之间,不连贯有时被称为阻抗不匹配。 -> Hibernate和ActiveRecord这种对象关系映射(object-relational mapping, ORM)框架,可以减少转换层所需的样板代码的数量,但是也没办法隐藏着两个模型之间的差异。
- 大多数人在职业生涯中,用于多于一份的工作,可能有不同样的教育阶段和任意数量的联系信息,如何存?
多种模型:
- 传统SQL
- SQL + 对于结构化数据类型和XML的支持,在行内存储对应的数据,并支持文档内的查询和索引。
- 信息编码为JSON或者XML文档。
1 |
|
有一些开发人员认为JSON模型减少了应用程序代码和存储层之间的阻抗不匹配。JSON作为数据编码格式也存在问题。缺乏一个模式往往被认为是一个优势。
JSON比多表模式有更好的局部性(Locality),JSON中相关信息都在一个地方,一个查询足够。
多对一和多对多的关系
- 存储ID还是文本字符串,这是个副本(duplication)问题。当使用ID时,对人类有意义的信息(比如单词:Philanthropy)只存储在一处,所有引用它的地方使用ID(ID只在数据库中有意义)。当直接存储文本时,对人类有意义的信息会复制在每处使用记录中。
使用ID的好处是,ID对人类没有任何意义,因而永远不需要改变:ID可以保持不变,即使它标识的信息发生变化。任何对人类有意义的东西都可能需要在将来某个时候改变——如果这些信息被复制,所有的冗余副本都需要更新。这会导致写入开销,也存在不一致的风险(一些副本被更新了,还有些副本没有被更新)。去除此类重复是数据库规范化 (normalization)的关键思想。
关于关系模型的文献区分了几种不同的规范形式,但这些区别几乎没有实际意义。一个经验法则是,如果重复存储了可以存储在一个地方的值,则模式就不是规范化 (normalized)的。论规范化和非规范化都有道理!
- 数据进行规范化需要多对一的关系,和文档模型不太符合。在关系数据库中,通过ID来引用其他表中的行是正常的,因为连接很容易。在文档数据库中,一对多树结构没有必要用连接,对 连接的支持通常很弱。
- 如果数据库本身不支持连接,则必须在应用程序代码中通过对数据库进行多个查询来模拟连接。在这种情况中,地区和行业的列表可能很小,改动很少,应用程序可以简单地将其保存在内存中。不过,执行连接的工作从数据库被转移到应用程序代码上。
此外,即便应用程序的最初版本适合无连接的文档模型,随着功能添加到应用程序中,数据会变得更加互联。 -> 越来越偏向于关系数据库。
- 多对多的例子:
可能文档就有点力不从心了,在多对多的关系和连接已常规用在关系数据库时,文档数据库和NoSQL重启了辩论:如何最好地在数据库中表示多对多关系。 -> MySQL,文档数据库,NoSQL不断在讨论发展ing,看看哪种情况,哪种技术最合适的捏!
哪个模型方便写代码?
- 如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。 -> 将类似文档的结构分解成多个表的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。
- 文档模型有一定的局限性:
- 例如,不能直接引用文档中的嵌套的项目,而是需要说“用户”的位置列表中的第二项
- 文档数据库对连接的糟糕支持也许或也许不是一个问题,这取决于应用程序。例如,分析应 用程可能永远不需要多对多的关系,如果它使用文档数据库来记录何事发生于何时。
- 但是,如果你的应用程序确实使用多对多关系,那么文档模型就没有那么吸引人了。通过反 规范化可以减少对连接的需求,但是应用程序代码需要做额外的工作来保持数据的一致性。 -> 可以在应用程序代码中模拟连接,但是这也将复杂性转移到应 用程序中,并且通常比由数据库内的专用代码执行的连接慢。在这种情况下,使用文档模型 会导致更复杂的应用程序代码和更差的性能.
summary:
- 很难说在一般情况下哪个数据模型让应用程序代码更简单;它取决于数据项之间存在的关系种类。
- 对于高度相联的数据,选用文档模型是糟糕的,选用关系模型是可接受的,而选用图形模型(参见“图数据模型”)是最自然的。
- 数据模型也不是一成不变的,一对多,随着数据的添加,可能就变成多对多了orz,所以考虑要周全。
文档模型中的架构灵活性
JSON, XML等不一定保证文档中的特定字段
- 文档数据库有时称为无模式(schemaless),读取数据的代码通常假 定某种结构——即存在隐式模式,但不由数据库强制执行。
- 更精确的术语是读时模式(schema-on-read)(数据的结构是隐含的,只有在数据被读取时才被解释),相应的是写时模式(schema-on-write)(传统的关系数据库方法中,模式明确,且数据库确保所有的数据都符合其模式)-> 一个是运行时检查,一个是编译时检查(类似)。
在应用程序想要改变其数据格式的情况下,这些方法之间的区别尤其明显:
- 文档数据库中,只需开始写入具有新字段的新文档,并在应用程序中使用代码来处理读取旧文档的情 况。
- 在“静态类型”数据库模式中,通常会执行以下迁移(migration)操作:
1
2
3ALTER TABLE users ADD COLUMN first_name text;
UPDATE users SET first_name = split_part(name, ' ', 1); -- PostgreSQL
UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQLMySQL执行 ALTER TABLE 时会复制整个表。上运行 UPDATE 语句在任何数据库上都可能会很慢,因为每一行都需要重写。
对象不同,而且可能发生变化 -> 模式的坏处远大于它的帮助,无模式文档可能是一个更加自然的数据模型。 但是,要是所有记录都具有相同的结构,那么模式是记录并强制这种结构的有效机制。(无模式自然,有模式规矩)
查询的数据局部性
如果应用程序经常需要访问整个文档(例如,将其渲染至网页),那么存储局部性会带来性能优势。
数据存在多个表中,则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。
局部性仅仅适用于同时需要文档绝大部分内容的情况。数据库通常需要加载整个文档,即使 只访问其中的一小部分,这对于大型文档来说是很浪费的。
更新文档时,通常需要整个重写。只有不改变文档大小的修改才可以容易地原地执行。因此,通常建议保持相对小的文 档,并避免增加文档大小的写入。这些性能限制大大减少了文档数据库的实用场景。
为了局部性而分组集合相关数据的想法并不局限于文档模型。
- 文档和关系数据库的融合:
- 大多数关系数据库系统(MySQL除外)都已支持XML。这包括对XML 文档进行本地修改的功能,以及在XML文档中进行索引和查询的功能。
- JSON也逐渐在补充,好事儿 -> 相互补充,实现最符合需求的功能组合。
数据查询语言
- 编程语言类型:
- 命令式:命令式语言告诉计算机以特定顺序执行某些操作。可以想象一下,逐行地遍历代码,评估条 件,更新变量,并决定是否再循环一遍。(底层,自己实现)
- 声明式:只需指定所需数据的模式 - 结果必须符合哪些 条件,以及如何将数据转换(例如,排序,分组和集合) - 但不是如何实现这一目标。(高层抽象,底层可以灵活调整,动态性强,也可以优化。 -> 程序员对于底层的掌控减少了!!!)
声明式查询语言是迷人的,因为它通常比命令式API更加简洁和容易。但更重要的是,它还隐 藏了数据库引擎的实现细节,这使得数据库系统可以在无需对查询做任何更改的情况下进行 性能提升。
声明式语言往往适合并行执行。现在,CPU的速度通过内核的增加变得更快,而不是以比以前更高的时钟速度运行。命令代码很难在多个内核和多个机器之间并行化,因 为它指定了指令必须以特定顺序执行。声明式语言更具有并行执行的潜力,因为它们仅指定 结果的模式,而不指定用于确定结果的算法。在适当情况下,数据库可以自由使用查询语言 的并行实现。
- 不同的查询:
- Web上的声明式查询
- MapReduce查询
图数据模型
一个图由两种对象组成:顶点(vertices)(也称为节点(nodes)或实体(entities)),和边(edges)( 也称为关系(relationships)或弧 (arcs) )。多种数据可以被建模为一个图形。 -> 图数据结构,相应的算法(如PageRank)
属性图
- Vertex: Identifer, outgoing edges, ingoing edges, properties(k:v)
- Edge: Identifier, 边的终点/头部顶点(head vertex) 描述两个顶点之间关系类型的标签 一组属性(键值对)
可以将图存储看作由两个关系表组成:一个存储顶点,另一个存储边。头部和尾部顶点用来存储每条边;如果你想要一组顶点的输入或输出边,你可以分别通过 head_vertex 或 tail_vertex 来查询 edges 表。
模型的重要方面:
- 任何顶点之间都可以相连
- 任何顶底那都能高效找到Ingoing&Outgoing edges,从而遍历图。
- 对不同类型的关系使用不同的标签,可以在一个图中存储集中不同的信息,同时仍然保持一个清晰的数据模型。
为数据建模提供了很大的灵活性!
对应还有Cypher查询语言进行查询!
SQL中的图查询:可以把图数据放入SQL,当然也能够使用SQL的方式对他们进行查询!!!
三元组存储和SPARQL
三元组存储模式大体上与属性图模型相同,用不同的词来描述相同的想法。
三元组的主语相当于图中的一个顶点。而宾语:
- 原始数据类型中的值,例如字符串或数字。在这种情况下,三元组的谓语和宾语相当于主语顶点上的属性的键和值。例如, (lucy, age, 33) 就像属性 {“age”:33} 的顶点 lucy。
- 图中的另一个顶点。在这种情况下,谓语是图中的一条边,主语是其尾部顶点,而宾语 是其头部顶点。例如,在 (lucy, marriedTo, alain) 中主语和宾语 lucy 和 alain 都是顶 点,并且谓语 marriedTo 是连接他们的边的标签。
如果你阅读更多关于三元组存储的信息,你可能会被卷入关于语义网络的文章漩涡中。三元组存储数据模型完全独立于语义网络。
语义网络:语义网是一个简单且合理的想法:网站已经将信息发布为文字和图片供人类阅 读,为什么不将信息作为机器可读的数据也发布给计算机呢?
RDF数据模型
SPARQL是一种用于三元组存储的面向RDF数据模型的查询语言。(它是SPARQL 协议和RDF查询语言的缩写,发音为“sparkle”。)
基础:Datalog。Datalog是比SPARQL或Cypher更古老的语言,在20世纪80年代被学者广泛研究。它在软件工程师中不太知名,但是它是重要的,因为它为以后的查询语言提供了基础。
第三章:存储与检索
数据库最基本的,就是存储 & 检索。我们将从您最可能熟悉的两大类数据库:传统关系型数据库与很多所谓的“NoSQL”数 据库开始,通过介绍它们的存储引擎来开始本章的内容。我们会研究两大类存储引擎:日志结构(log-structured)的存储引擎,以及面向页面(page-oriented)的存储引擎(例如B 树)。
驱动数据库的数据结构
1 |
|
这两个函数实现了键值存储的功能。执行 db_set key value ,会将 键(key)和值 (value) 存储在数据库中。键和值(几乎)可以是你喜欢的任何东西,例如,值可以是 JSON文档。然后调用 db_get key ,查找与该键关联的最新值并将其返回。 -> 最简单的数据库。使用:
1 |
|
底层的存储格式非常简单:一个文本文件,每行包含一条逗号分隔的键值对(忽略转义问题 的话,大致与CSV文件类似)。每次对 db_set 的调用都会向文件末尾追加记录,所以更新 键的时候旧版本的值不会被覆盖 —— 因而查找最新值的时候,需要找到文件中键最后一次出 现的位置(因此 db_get 中使用了 tail -n 1 。)
- db_set 函数对于极其简单的场景其实有非常好的性能,因为在文件尾部追加写入通常是非常 高效的。与 db_set 做的事情类似,许多数据库在内部使用了日志(log),也就是一个仅追加(append-only)的数据文件。真正的数据库有更多的问题需要处理(如并发控制,回收 磁盘空间以避免日志无限增长,处理错误与部分写入的记录),但基本原理是一样的。日志 极其有用,我们还将在本书的其它部分重复见到它好几次。
日志(log)这个词通常指应用日志:即应用程序输出的描述发生事情的文本。本书在更 普遍的意义下使用日志这一词:一个仅追加的记录序列。它可能压根就不是给人类看 的,使用二进制格式,并仅能由其他程序读取。
- 另一方面,如果这个数据库中有着大量记录,则这个 db_get 函数的性能会非常糟糕。每次你 想查找一个键时, db_get 必须从头到尾扫描整个数据库文件来查找键的出现。用算法的语言 来说,查找的开销是 O(n) :如果数据库记录数量 n 翻了一倍,查找时间也要翻一倍。这就 不好了。
- 为了高效查找数据库中特定键的值,我们需要一个数据结构:索引(index)。索引背后的大致思想是,保存一些额外的元数据作为路标,帮助你找到想要的数据。如果您想在同一份数据中以几种不同的方式进行搜索,那么你 也许需要不同的索引,建在数据的不同部分上。
索引是从主数据衍生的附加(additional)结构。许多数据库允许添加与删除索引,这不会影 响数据的内容,它只影响查询的性能。维护额外的结构会产生开销,特别是在写入时。写入 性能很难超过简单地追加写入文件,因为追加写入是最简单的写入操作。任何类型的索引通 常都会减慢写入速度,因为每次写入数据时都需要更新索引。
这是存储系统中一个重要的权衡:精心选择的索引加快了读查询的速度,但是每个索引都会拖慢写入速度。因为这个原因,数据库默认并不会索引所有的内容,而需要你(程序员或 DBA)通过对应用查询模式的了解来手动选择索引。你可以选择能为应用带来最大收益,同时又不会引入超出必要开销的索引。
哈希索引
键值存储与在大多数编程语言中可以找到的字典(dictionary)类型非常相似,通常字典都是 用散列映射(hash map)(或哈希表(hash table))实现的。
- 最简单的索引策 略就是:保留一个内存中的哈希映射,其中每个键都映射到一个数据文件中的字节偏移量, 指明了可以找到对应值的位置。
以类CSV格式存储键值对的日志,并使用内存哈希映射进行索引。
- 直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完磁盘空间?一种好的解决 方案是,将日志分为特定大小的段,当日志增长到特定尺寸时关闭当前段文件,并开始写入 一个新的段文件。然后,我们就可以对这些段进行压缩(compaction)
压缩键值更新日志(统计猫视频的播放次数),只保留每个键的最近值
- 由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,在进行时, 我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求 转换为使用新的合并段而不是旧段 —— 然后可以简单地删除旧的段文件。
同时执行压缩和分段合并
每个段现在都有自己的内存散列表,将键映射到文件偏移量。为了找到一个键的值,我们首 先检查最近段的哈希映射;如果键不存在,我们检查第二个最近的段,依此类推。合并过程保 持细分的数量,所以查找不需要检查许多哈希映射。 大量的细节进入实践这个简单的想法工作。
其他问题:
- 文件格式: CSV不是日志的最佳格式。使用二进制格式更快,更简单,首先以字节为单位对字符串的长 度进行编码,然后使用原始字符串(不需要转义)。
- 删除记录: 如果要删除一个键及其关联的值,则必须在数据文件(有时称为逻辑删除)中附加一个特殊的删除记录。当日志段被合并时,逻辑删除告诉合并过程放弃删除键的任何以前的值。
- 崩溃恢复: 如果数据库重新启动,则内存散列映射将丢失。原则上,您可以通过从头到尾读取整个段文件并在每次按键时注意每个键的最近值的偏移量来恢复每个段的哈希映射。但是,如果段文件很大,这可能需要很长时间,这将使服务器重新启动痛苦。 Bitcask通过存储加速恢复磁盘上每个段的哈希映射的快照,可以更快地加载到内存中。