mirror of
https://github.com/Vonng/ddia.git
synced 2024-12-06 15:20:12 +08:00
Merge pull request #118 from yingang/master
translation updates (chapter 2 and 3)
This commit is contained in:
commit
2dfb6717f2
2
ch1.md
2
ch1.md
@ -220,7 +220,7 @@
|
||||
|
||||
即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值**分布(distribution)**,而不是单个数值。
|
||||
|
||||
在[图1-4](img/fig1-4.png)中,每个灰条表代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如:上下文切换到后台进程,网络数据包丢失与TCP重传,垃圾收集暂停,强制从磁盘读取的页面错误,服务器机架中的震动【18】,还有很多其他原因。
|
||||
在[图1-4](img/fig1-4.png)中,每个灰条代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如:上下文切换到后台进程,网络数据包丢失与TCP重传,垃圾收集暂停,强制从磁盘读取的页面错误,服务器机架中的震动【18】,还有很多其他原因。
|
||||
|
||||
![](img/fig1-4.png)
|
||||
|
||||
|
8
ch2.md
8
ch2.md
@ -161,7 +161,7 @@ JSON表示比[图2-1](img/fig2-1.png)中的多表模式具有更好的**局部
|
||||
|
||||
***推荐***
|
||||
|
||||
假设你想添加一个新的功能:一个用户可以为另一个用户写一个推荐。在用户的简历上显示推荐,并附上推荐用户的姓名和照片。如果推荐人更新他们的照片,那他们写的任何建议都需要显示新的照片。因此,推荐应该拥有作者个人简介的引用。
|
||||
假设你想添加一个新的功能:一个用户可以为另一个用户写一个推荐。在用户的简历上显示推荐,并附上推荐用户的姓名和照片。如果推荐人更新他们的照片,那他们写的任何推荐都需要显示新的照片。因此,推荐应该拥有作者个人简介的引用。
|
||||
![](img/fig2-3.png)
|
||||
|
||||
**图2-3 公司名不仅是字符串,还是一个指向公司实体的链接(LinkedIn截图)**
|
||||
@ -188,7 +188,7 @@ IMS的设计中使用了一个相当简单的数据模型,称为**层次模型
|
||||
|
||||
#### 网络模型
|
||||
|
||||
网络模型由一个称为数据系统语言会议(CODASYL)的委员会进行了标准化,并被数个不同的数据库商实现;它也被称为CODASYL模型【16】。
|
||||
网络模型由一个称为数据系统语言会议(CODASYL)的委员会进行了标准化,并被数个不同的数据库厂商实现;它也被称为CODASYL模型【16】。
|
||||
|
||||
CODASYL模型是层次模型的推广。在层次模型的树结构中,每条记录只有一个父节点;在网络模式中,每条记录可能有多个父节点。例如,“Greater Seattle Area”地区可能是一条记录,每个居住在该地区的用户都可以与之相关联。这允许对多对一和多对多的关系进行建模。
|
||||
|
||||
@ -204,7 +204,7 @@ CODASYL中的查询是通过利用遍历记录列和跟随访问路径表在数
|
||||
|
||||
相比之下,关系模型做的就是将所有的数据放在光天化日之下:一个 **关系(表)** 只是一个 **元组(行)** 的集合,仅此而已。如果你想读取数据,它没有迷宫似的嵌套结构,也没有复杂的访问路径。你可以选中符合任意条件的行,读取表中的任何或所有行。你可以通过指定某些列作为匹配关键字来读取特定行。你可以在任何表中插入一个新的行,而不必担心与其他表的外键关系[^iv]。
|
||||
|
||||
[^iv]: 外键约束允许对修改约束,但对于关系模型这并不是必选项。即使有约束,外键连接在查询时执行,而在CODASYL中,连接在插入时高效完成。
|
||||
[^iv]: 外键约束允许对修改进行限制,但对于关系模型这并不是必选项。即使有约束,外键连接在查询时执行,而在CODASYL中,连接在插入时高效完成。
|
||||
|
||||
在关系数据库中,查询优化器自动决定查询的哪些部分以哪个顺序执行,以及使用哪些索引。这些选择实际上是“访问路径”,但最大的区别在于它们是由查询优化器自动生成的,而不是由程序员生成,所以我们很少需要考虑它们。
|
||||
|
||||
@ -236,7 +236,7 @@ CODASYL中的查询是通过利用遍历记录列和跟随访问路径表在数
|
||||
|
||||
我们没有办法说哪种数据模型更有助于简化应用代码,因为它取决于数据项之间的关系种类。对高度关联的数据而言,文档模型是极其糟糕的,关系模型是可以接受的,而选用图形模型(参见“[图数据模型](#图数据模型)”)是最自然的。
|
||||
|
||||
#### 文档模型中的架构灵活性
|
||||
#### 文档模型中的模式灵活性
|
||||
|
||||
大多数文档数据库以及关系数据库中的JSON支持都不会强制文档中的数据采用何种模式。关系数据库的XML支持通常带有可选的模式验证。没有模式意味着可以将任意的键和值添加到文档中,并且当读取时,客户端对无法保证文档可能包含的字段。
|
||||
|
||||
|
90
ch3.md
90
ch3.md
@ -195,7 +195,7 @@ $ cat database
|
||||
|
||||
这里描述的算法本质上是LevelDB 【6】和RocksDB 【7】中使用的键值存储引擎库,被设计嵌入到其他应用程序中。除此之外,LevelDB可以在Riak中用作Bitcask的替代品。在Cassandra和HBase中使用了类似的存储引擎【8】,这两种引擎都受到了Google的Bigtable文档【9】(引入了SSTable和memtable)的启发。
|
||||
|
||||
最初这种索引结构是由Patrick O'Neil等人描述的。在日志结构合并树(或LSM树)【10】的基础上,建立在以前的工作上日志结构的文件系统【11】。基于这种合并和压缩排序文件原理的存储引擎通常被称为LSM存储引擎。
|
||||
最初这种索引结构是由Patrick O'Neil等人描述的,且被命名为日志结构合并树(或LSM树)【10】,它是基于更早之前的日志结构文件系统【11】来构建的。基于这种合并和压缩排序文件原理的存储引擎通常被称为LSM存储引擎。
|
||||
|
||||
Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使用类似的方法来存储它的词典【12,13】。全文索引比键值索引复杂得多,但是基于类似的想法:在搜索查询中给出一个单词,找到提及单词的所有文档(网页,产品描述等)。这是通过键值结构实现的,其中键是单词(**关键词(term)**),值是包含单词(文章列表)的所有文档的ID的列表。在Lucene中,从术语到发布列表的这种映射保存在SSTable类的有序文件中,根据需要在后台合并【14】。
|
||||
|
||||
@ -203,7 +203,7 @@ Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使
|
||||
|
||||
与往常一样,大量的细节使得存储引擎在实践中表现良好。例如,当查找数据库中不存在的键时,LSM树算法可能会很慢:您必须检查内存表,然后将这些段一直回到最老的(可能必须从磁盘读取每一个),然后才能确定键不存在。为了优化这种访问,存储引擎通常使用额外的Bloom过滤器【15】。 (布隆过滤器是用于近似集合内容的内存高效数据结构,它可以告诉您数据库中是否出现键,从而为不存在的键节省许多不必要的磁盘读取操作。)
|
||||
|
||||
还有不同的策略来确定SSTables如何被压缩和合并的顺序和时间。最常见的选择是大小分层压实。 LevelDB和RocksDB使用平坦压缩(LevelDB因此得名),HBase使用大小分层,Cassandra同时支持【16】。在规模级别的调整中,更新和更小的SSTables先后被合并到更老的和更大的SSTable中。在水平压实中,关键范围被拆分成更小的SSTables,而较旧的数据被移动到单独的“水平”,这使得压缩能够更加递增地进行,并且使用更少的磁盘空间。
|
||||
还有不同的策略来确定SSTables如何被压缩和合并的顺序和时间。最常见的选择是size-tiered和leveled compaction。LevelDB和RocksDB使用leveled compaction(LevelDB因此得名),HBase使用size-tiered,Cassandra同时支持【16】。对于sized-tiered,较新和较小的SSTables相继被合并到较旧的和较大的SSTable中。对于leveled compaction,key范围被拆分到较小的SSTables,而较旧的数据被移动到单独的层级(level),这使得压缩(compaction)能够更加增量地进行,并且使用较少的磁盘空间。
|
||||
|
||||
即使有许多微妙的东西,LSM树的基本思想 —— 保存一系列在后台合并的SSTables —— 简单而有效。即使数据集比可用内存大得多,它仍能继续正常工作。由于数据按排序顺序存储,因此可以高效地执行范围查询(扫描所有高于某些最小值和最高值的所有键),并且因为磁盘写入是连续的,所以LSM树可以支持非常高的写入吞吐量。
|
||||
|
||||
@ -239,7 +239,7 @@ Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使
|
||||
|
||||
**图3-7 通过分割页面来生长B树**
|
||||
|
||||
该算法确保树保持平衡:具有 n 个键的B树总是具有 $O(log n)$ 的深度。大多数数据库可以放入一个三到四层的B树,所以你不需要遵追踪多页面引用来找到你正在查找的页面。 (分支因子为 500 的 4KB 页面的四级树可以存储多达 256TB 。)
|
||||
该算法确保树保持平衡:具有 n 个键的B树总是具有 $O(log n)$ 的深度。大多数数据库可以放入一个三到四层的B树,所以你不需要追踪多个页面引用来找到你正在查找的页面。 (分支因子为 500 的 4KB 页面的四级树可以存储多达 256TB 。)
|
||||
|
||||
#### 让B树更可靠
|
||||
|
||||
@ -247,7 +247,7 @@ B树的基本底层写操作是用新数据覆盖磁盘上的页面。假定覆
|
||||
|
||||
您可以考虑将硬盘上的页面覆盖为实际的硬件操作。在磁性硬盘驱动器上,这意味着将磁头移动到正确的位置,等待旋转盘上的正确位置出现,然后用新的数据覆盖适当的扇区。在固态硬盘上,由于SSD必须一次擦除和重写相当大的存储芯片块,所以会发生更复杂的事情【19】。
|
||||
|
||||
而且,一些操作需要覆盖几个不同的页面。例如,如果因为插入导致页面过度而拆分页面,则需要编写已拆分的两个页面,并覆盖其父页面以更新对两个子页面的引用。这是一个危险的操作,因为如果数据库在仅有一些页面被写入后崩溃,那么最终将导致一个损坏的索引(例如,可能有一个孤儿页面不是任何父项的子项) 。
|
||||
而且,一些操作需要覆盖几个不同的页面。例如,如果因为插入导致页面过满而拆分页面,则需要编写已拆分的两个页面,并覆盖其父页面以更新对两个子页面的引用。这是一个危险的操作,因为如果数据库在仅有一些页面被写入后崩溃,那么最终将导致一个损坏的索引(例如,可能有一个孤儿页面不是任何父项的子项) 。
|
||||
|
||||
为了使数据库对崩溃具有韧性,B树实现通常会带有一个额外的磁盘数据结构:**预写式日志(WAL, write-ahead-log)**(也称为**重做日志(redo log)**)。这是一个仅追加的文件,每个B树修改都可以应用到树本身的页面上。当数据库在崩溃后恢复时,这个日志被用来使B树恢复到一致的状态【5,20】。
|
||||
|
||||
@ -258,34 +258,34 @@ B树的基本底层写操作是用新数据覆盖磁盘上的页面。假定覆
|
||||
由于B树已经存在了这么久,许多优化已经发展了多年,这并不奇怪。仅举几例:
|
||||
|
||||
* 一些数据库(如LMDB)使用写时复制方案【21】,而不是覆盖页面并维护WAL进行崩溃恢复。修改的页面被写入到不同的位置,并且树中的父页面的新版本被创建,指向新的位置。这种方法对于并发控制也很有用,我们将在“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”中看到。
|
||||
* 我们可以通过不存储整个键来节省页面空间,但可以缩小它的大小。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此更少的层次
|
||||
* 通常,页面可以放置在磁盘上的任何位置;没有什么要求附近的键放在页面附近的磁盘上。如果查询需要按照排序顺序扫描大部分关键字范围,那么每个页面的布局可能会非常不方便,因为每个读取的页面都可能需要磁盘查找。因此,许多B树实现尝试布局树,使得叶子页面按顺序出现在磁盘上。但是,随着树的增长,维持这个顺序是很困难的。相比之下,由于LSM树在合并过程中一次又一次地重写存储的大部分,所以它们更容易使顺序键在磁盘上彼此靠近。
|
||||
* 我们可以通过不存储整个键,而是缩短其大小,来节省页面空间。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此更少的层次。
|
||||
* 通常,页面可以放置在磁盘上的任何位置;没有什么要求附近的键放在页面附近的磁盘上。如果查询需要按照排序顺序扫描大部分关键字范围,那么这种按页面存储的布局可能会效率低下,因为每个读取的页面都可能需要磁盘寻道。因此,许多B树实现尝试布局树,使得叶子页面按顺序出现在磁盘上。但是,随着树的增长,维持这个顺序是很困难的。相比之下,由于LSM树在合并过程中一次又一次地重写存储的大部分,所以它们更容易使顺序键在磁盘上彼此靠近。
|
||||
* 额外的指针已添加到树中。例如,每个叶子页面可以在左边和右边具有对其兄弟页面的引用,这允许不跳回父页面就能顺序扫描。
|
||||
* B树的变体如分形树【22】借用一些日志结构的思想来减少磁盘寻道(而且它们与分形无关)。
|
||||
|
||||
### 比较B树和LSM树
|
||||
|
||||
尽管B树实现通常比LSM树实现更成熟,但LSM树由于其性能特点也非常有趣。根据经验,通常LSM树的写入速度更快,而B树的读取速度更快【23】。 LSM树上的读取通常比较慢,因为它们必须在压缩的不同阶段检查几个不同的数据结构和SSTables。
|
||||
尽管B树实现通常比LSM树实现更成熟,但LSM树由于其性能特点也非常有趣。根据经验,通常LSM树的写入速度更快,而B树的读取速度更快【23】。 LSM树上的读取通常比较慢,因为它们必须检查几种不同的数据结构和不同压缩(Compaction)层级的SSTables。
|
||||
|
||||
然而,基准通常对工作量的细节不确定且敏感。 您需要测试具有特定工作负载的系统,以便进行有效的比较。 在本节中,我们将简要讨论一些在衡量存储引擎性能时值得考虑的事情。
|
||||
然而,基准测试通常对工作负载的细节不确定且敏感。 您需要测试具有特定工作负载的系统,以便进行有效的比较。在本节中,我们将简要讨论一些在衡量存储引擎性能时值得考虑的事情。
|
||||
|
||||
#### LSM树的优点
|
||||
|
||||
B树索引必须至少两次写入每一段数据:一次写入预先写入日志,一次写入树页面本身(也许再次分页)。即使在该页面中只有几个字节发生了变化,也需要一次编写整个页面的开销。有些存储引擎甚至会覆盖同一个页面两次,以免在电源故障的情况下导致页面部分更新【24,25】。
|
||||
|
||||
由于反复压缩和合并SSTables,日志结构索引也会重写数据。这种影响 —— 在数据库的生命周期中写入数据库导致对磁盘的多次写入 —— 被称为**写放大(write amplification)**。需要特别注意的是固态硬盘,固态硬盘的闪存寿命在覆写有限次数后就会耗尽。
|
||||
由于反复压缩和合并SSTables,日志结构索引也会重写数据。这种影响 —— 在数据库的生命周期中每次写入数据库导致对磁盘的多次写入 —— 被称为**写放大(write amplification)**。需要特别注意的是固态硬盘,固态硬盘的闪存寿命在覆写有限次数后就会耗尽。
|
||||
|
||||
在写入繁重的应用程序中,性能瓶颈可能是数据库可以写入磁盘的速度。在这种情况下,写放大会导致直接的性能代价:存储引擎写入磁盘的次数越多,可用磁盘带宽内的每秒写入次数越少。
|
||||
|
||||
而且,LSM树通常能够比B树支持更高的写入吞吐量,部分原因是它们有时具有较低的写放大(尽管这取决于存储引擎配置和工作负载),部分是因为它们顺序地写入紧凑的SSTable文件而不是必须覆盖树中的几个页面【26】。这种差异在磁性硬盘驱动器上尤其重要,顺序写入比随机写入快得多。
|
||||
|
||||
LSM树可以被压缩得更好,因此经常比B树在磁盘上产生更小的文件。 B树存储引擎会由于分割而留下一些未使用的磁盘空间:当页面被拆分或某行不能放入现有页面时,页面中的某些空间仍未被使用。由于LSM树不是面向页面的,并且定期重写SSTables以去除碎片,所以它们具有较低的存储开销,特别是当使用平坦压缩时【27】。
|
||||
LSM树可以被压缩得更好,因此经常比B树在磁盘上产生更小的文件。 B树存储引擎会由于分割而留下一些未使用的磁盘空间:当页面被拆分或某行不能放入现有页面时,页面中的某些空间仍未被使用。由于LSM树不是面向页面的,并且定期重写SSTables以去除碎片,所以它们具有较低的存储开销,特别是当使用分层压缩(leveled compaction)时【27】。
|
||||
|
||||
在许多固态硬盘上,固件内部使用日志结构化算法,将随机写入转变为顺序写入底层存储芯片,因此存储引擎写入模式的影响不太明显【19】。但是,较低的写入放大率和减少的碎片对SSD仍然有利:更紧凑地表示数据可在可用的I/O带宽内提供更多的读取和写入请求。
|
||||
|
||||
#### LSM树的缺点
|
||||
|
||||
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。尽管存储引擎尝试逐步执行压缩而不影响并发访问,但是磁盘资源有限,所以很容易发生请求需要等待而磁盘完成昂贵的压缩操作。对吞吐量和平均响应时间的影响通常很小,但是在更高百分比的情况下(参阅“[描述性能](ch1.md#描述性能)”),对日志结构化存储引擎的查询响应时间有时会相当长,而B树的行为则相对更具可预测性【28】。
|
||||
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。尽管存储引擎尝试逐步执行压缩而不影响并发访问,但是磁盘资源有限,所以很容易发生请求需要等待磁盘完成昂贵的压缩操作。对吞吐量和平均响应时间的影响通常很小,但是在更高百分比的情况下(参阅“[描述性能](ch1.md#描述性能)”),对日志结构化存储引擎的查询响应时间有时会相当长,而B树的行为则相对更具可预测性【28】。
|
||||
|
||||
压缩的另一个问题出现在高写入吞吐量:磁盘的有限写入带宽需要在初始写入(记录和刷新内存表到磁盘)和在后台运行的压缩线程之间共享。写入空数据库时,可以使用全磁盘带宽进行初始写入,但数据库越大,压缩所需的磁盘带宽就越多。
|
||||
|
||||
@ -305,14 +305,14 @@ B树在数据库体系结构中是非常根深蒂固的,为许多工作负载
|
||||
|
||||
#### 将值存储在索引中
|
||||
|
||||
索引中的关键字是查询搜索的内容,但是该值可以是以下两种情况之一:它可以是所讨论的实际行(文档,顶点),也可以是对存储在别处的行的引用。在后一种情况下,行被存储的地方被称为**堆文件(heap file)**,并且存储的数据没有特定的顺序(它可以是仅附加的,或者可以跟踪被删除的行以便用新数据覆盖它们后来)。堆文件方法很常见,因为它避免了在存在多个二级索引时复制数据:每个索引只引用堆文件中的一个位置,实际的数据保存在一个地方。
|
||||
索引中的键是查询搜索的内容,而其值可以是以下两种情况之一:它可以是所讨论的实际行(文档,顶点),也可以是对存储在别处的行的引用。在后一种情况下,行被存储的地方被称为**堆文件(heap file)**,并且存储的数据没有特定的顺序(它可以是仅追加的,或者可以跟踪被删除的行以便用新数据覆盖它们后来)。堆文件方法很常见,因为它避免了在存在多个二级索引时复制数据:每个索引只引用堆文件中的一个位置,实际的数据保存在一个地方。
|
||||
在不更改键的情况下更新值时,堆文件方法可以非常高效:只要新值不大于旧值,就可以覆盖该记录。如果新值更大,情况会更复杂,因为它可能需要移到堆中有足够空间的新位置。在这种情况下,要么所有的索引都需要更新,以指向记录的新堆位置,或者在旧堆位置留下一个转发指针【5】。
|
||||
|
||||
在某些情况下,从索引到堆文件的额外跳跃对读取来说性能损失太大,因此可能希望将索引行直接存储在索引中。这被称为聚集索引。例如,在MySQL的InnoDB存储引擎中,表的主键总是一个聚簇索引,二级索引用主键(而不是堆文件中的位置)【31】。在SQL Server中,可以为每个表指定一个聚簇索引【32】。
|
||||
在某些情况下,从索引到堆文件的额外跳跃对读取来说性能损失太大,因此可能希望将索引行直接存储在索引中。这被称为聚集索引。例如,在MySQL的InnoDB存储引擎中,表的主键总是一个聚集索引,二级索引则引用主键(而不是堆文件中的位置)【31】。在SQL Server中,可以为每个表指定一个聚集索引【32】。
|
||||
|
||||
在 **聚集索引(clustered index)** (在索引中存储所有行数据)和 **非聚集索引(nonclustered index)** (仅在索引中存储对数据的引用)之间的折衷被称为 **包含列的索引(index with included columns)**或**覆盖索引(covering index)**,其存储表的一部分在索引内【33】。这允许通过单独使用索引来回答一些查询(这种情况叫做:索引 **覆盖(cover)** 了查询)【32】。
|
||||
|
||||
与任何类型的数据重复一样,聚簇和覆盖索引可以加快读取速度,但是它们需要额外的存储空间,并且会增加写入开销。数据库还需要额外的努力来执行事务保证,因为应用程序不应该因为重复而导致不一致。
|
||||
与任何类型的数据重复一样,聚集和覆盖索引可以加快读取速度,但是它们需要额外的存储空间,并且会增加写入开销。数据库还需要额外的努力来执行事务保证,因为应用程序不应看到任何因为重复而导致的不一致。
|
||||
|
||||
#### 多列索引
|
||||
|
||||
@ -339,23 +339,23 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
||||
|
||||
例如,全文搜索引擎通常允许搜索一个单词以扩展为包括该单词的同义词,忽略单词的语法变体,并且搜索在相同文档中彼此靠近的单词的出现,并且支持各种其他功能取决于文本的语言分析。为了处理文档或查询中的拼写错误,Lucene能够在一定的编辑距离内搜索文本(编辑距离1意味着添加,删除或替换了一个字母)【37】。
|
||||
|
||||
正如“[在SSTables中创建LSM树](#在SSTables中创建LSM树)”中所提到的,Lucene为其词典使用了一个类似于SSTable的结构。这个结构需要一个小的内存索引,告诉查询在排序文件中哪个偏移量需要查找关键字。在LevelDB中,这个内存中的索引是一些键的稀疏集合,但在Lucene中,内存中的索引是键中字符的有限状态自动机,类似于trie 【38】。这个自动机可以转换成Levenshtein自动机,它支持在给定的编辑距离内有效地搜索单词【39】。
|
||||
正如“[在SSTables中创建LSM树](#在SSTables中创建LSM树)”中所提到的,Lucene为其词典使用了一个类似于SSTable的结构。这个结构需要一个小的内存索引,告诉查询需要在排序文件中哪个偏移量查找键。在LevelDB中,这个内存中的索引是一些键的稀疏集合,但在Lucene中,内存中的索引是键中字符的有限状态自动机,类似于trie 【38】。这个自动机可以转换成Levenshtein自动机,它支持在给定的编辑距离内有效地搜索单词【39】。
|
||||
|
||||
其他的模糊搜索技术正朝着文档分类和机器学习的方向发展。有关更多详细信息,请参阅信息检索教科书,例如【40】。
|
||||
|
||||
#### 在内存中存储一切
|
||||
|
||||
本章到目前为止讨论的数据结构都是对磁盘限制的回答。与主内存相比,磁盘处理起来很尴尬。对于磁盘和SSD,如果要在读取和写入时获得良好性能,则需要仔细地布置磁盘上的数据。但是,我们容忍这种尴尬,因为磁盘有两个显著的优点:它们是耐用的(它们的内容在电源关闭时不会丢失),并且每GB的成本比RAM低。
|
||||
本章到目前为止讨论的数据结构都是对磁盘限制的回答。与主内存相比,磁盘处理起来很尴尬。对于磁盘和SSD,如果要在读取和写入时获得良好性能,则需要仔细地布置磁盘上的数据。但是,我们容忍这种尴尬,因为磁盘有两个显著的优点:它们是持久的(它们的内容在电源关闭时不会丢失),并且每GB的成本比RAM低。
|
||||
|
||||
随着RAM变得更便宜,每GB的成本价格被侵蚀了。许多数据集不是那么大,所以将它们全部保存在内存中是非常可行的,可能分布在多个机器上。这导致了内存数据库的发展。
|
||||
随着RAM变得更便宜,每GB成本的论据被侵蚀了。许多数据集不是那么大,所以将它们全部保存在内存中是非常可行的,可能分布在多个机器上。这导致了内存数据库的发展。
|
||||
|
||||
某些内存中的键值存储(如Memcached)仅用于缓存,在重新启动计算机时丢失的数据是可以接受的。但其他内存数据库的目标是持久性,可以通过特殊的硬件(例如电池供电的RAM),将更改日志写入磁盘,将定时快照写入磁盘或通过复制内存来实现,记忆状态到其他机器。
|
||||
某些内存中的键值存储(如Memcached)仅用于缓存,在重新启动计算机时丢失的数据是可以接受的。但其他内存数据库的目标是持久性,可以通过特殊的硬件(例如电池供电的RAM),将更改日志写入磁盘,将定时快照写入磁盘或者将内存中的状态复制到其他机器上。
|
||||
|
||||
内存数据库重新启动时,需要从磁盘或通过网络从副本重新加载其状态(除非使用特殊的硬件)。尽管写入磁盘,它仍然是一个内存数据库,因为磁盘仅用作耐久性附加日志,读取完全由内存提供。写入磁盘也具有操作优势:磁盘上的文件可以很容易地由外部实用程序进行备份,检查和分析。
|
||||
内存数据库重新启动时,需要从磁盘或通过网络从副本重新加载其状态(除非使用特殊的硬件)。尽管写入磁盘,它仍然是一个内存数据库,因为磁盘仅出于持久性目的进行日志追加,读取完全由内存提供。写入磁盘也具有运维优势:磁盘上的文件可以很容易地由外部实用程序进行备份,检查和分析。
|
||||
|
||||
诸如VoltDB,MemSQL和Oracle TimesTen等产品是具有关系模型的内存数据库,供应商声称,通过消除与管理磁盘上的数据结构相关的所有开销,他们可以提供巨大的性能改进【41,42】。 RAM Cloud是一个开源的内存键值存储器,具有持久性(对存储器中的数据以及磁盘上的数据使用日志结构化方法)【43】。 Redis和Couchbase通过异步写入磁盘提供了较弱的持久性。
|
||||
|
||||
反直觉的是,内存数据库的性能优势并不是因为它们不需要从磁盘读取的事实。即使是基于磁盘的存储引擎也可能永远不需要从磁盘读取,因为操作系统缓存最近在内存中使用了磁盘块。相反,它们更快的原因在于省去了将内存数据结构编码为磁盘数据结构的开销。【44】。
|
||||
反直觉的是,内存数据库的性能优势并不是因为它们不需要从磁盘读取的事实。即使是基于磁盘的存储引擎也可能永远不需要从磁盘读取,因为操作系统在内存中缓存了最近使用的磁盘块。相反,它们更快的原因在于省去了将内存数据结构编码为磁盘数据结构的开销。【44】。
|
||||
|
||||
除了性能,内存数据库的另一个有趣的领域是提供难以用基于磁盘的索引实现的数据模型。例如,Redis为各种数据结构(如优先级队列和集合)提供了类似数据库的接口。因为它将所有数据保存在内存中,所以它的实现相对简单。
|
||||
|
||||
@ -367,19 +367,19 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
||||
|
||||
## 事务处理还是分析?
|
||||
|
||||
在早期业务数据对处理过程中,一次典型的数据库写入通常与一笔 *商业交易(commercial transaction)* 相对应:卖个货,向供应商下订单,支付员工工资等等。但随着数据库应用至那些不涉及到钱的领域,术语 **交易/事务(transaction)** 仍留了下来,用于指代一组读写操作构成的逻辑单元。
|
||||
在早期业务数据处理过程中,一次典型的数据库写入通常与一笔 *商业交易(commercial transaction)* 相对应:卖个货,向供应商下订单,支付员工工资等等。但随着数据库应用至那些不涉及到钱的领域,术语 **交易/事务(transaction)** 仍留了下来,用于指代一组读写操作构成的逻辑单元。
|
||||
|
||||
事务不一定具有ACID(原子性,一致性,隔离性和持久性)属性。事务处理只是意味着允许客户端进行低延迟读取和写入 —— 而不是批量处理作业,而这些作业只能定期运行(例如每天一次)。我们在[第7章](ch7.md)中讨论ACID属性,在[第10章](ch10.md)中讨论批处理。
|
||||
|
||||
即使数据库开始被用于许多不同类型的博客文章,游戏中的动作,地址簿中的联系人等等,基本访问模式仍然类似于处理业务事务。应用程序通常使用索引通过某个键查找少量记录。根据用户的输入插入或更新记录。由于这些应用程序是交互式的,因此访问模式被称为 **在线事务处理(OLTP, OnLine Transaction Processing)** 。
|
||||
即使数据库开始被用于许多不同类型的数据,比如博客文章的评论,游戏中的动作,地址簿中的联系人等等,基本访问模式仍然类似于处理商业交易。应用程序通常使用索引通过某个键查找少量记录。根据用户的输入插入或更新记录。由于这些应用程序是交互式的,这种访问模式被称为 **在线事务处理(OLTP, OnLine Transaction Processing)** 。
|
||||
|
||||
但是,数据库也开始越来越多地用于数据分析,这些数据分析具有非常不同的访问模式。通常,分析查询需要扫描大量记录,每个记录只读取几列,并计算汇总统计信息(如计数,总和或平均值),而不是将原始数据返回给用户。例如,如果您的数据是一个销售交易表,那么分析查询可能是:
|
||||
|
||||
* 一月份每个商店的总收入是多少?
|
||||
* 在最近的推广活动中卖了多少香蕉?
|
||||
* 在最近的推广活动中多卖了多少香蕉?
|
||||
* 哪个牌子的婴儿食品最常与X品牌的尿布同时购买?
|
||||
|
||||
这些查询通常由业务分析师编写,并提供给帮助公司管理层做出更好决策(商业智能)的报告。为了区分这种使用数据库的事务处理模式,它被称为**在线分析处理(OLAP, OnLine Analytice Processing)**。【47】。OLTP和OLAP之间的区别并不总是清晰的,但是一些典型的特征在[表3-1]()中列出。
|
||||
这些查询通常由业务分析师编写,并提供给帮助公司管理层做出更好决策(商业智能)的报告。为了将这种使用数据库的模式和事务处理区分开,它被称为**在线分析处理(OLAP, OnLine Analytice Processing)**。【47】。OLTP和OLAP之间的区别并不总是清晰的,但是一些典型的特征在[表3-1]()中列出。
|
||||
|
||||
**表3-1 比较交易处理和分析系统的特点**
|
||||
|
||||
@ -431,17 +431,17 @@ Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂
|
||||
|
||||
通常情况下,事实被视为单独的事件,因为这样可以在以后分析中获得最大的灵活性。但是,这意味着事实表可以变得非常大。像苹果,沃尔玛或eBay这样的大企业在其数据仓库中可能有几十PB的交易历史,其中大部分实际上是表【56】。
|
||||
|
||||
事实表中的一些列是属性,例如产品销售的价格和从供应商那里购买的成本(允许计算利润余额)。事实表中的其他列是对其他表(称为维表)的外键引用。由于事实表中的每一行都表示一个事件,因此这些维度代表事件的发生地点,时间,方式和原因。
|
||||
事实表中的一些列是属性,例如产品销售的价格和从供应商那里购买的成本(允许计算利润余额)。事实表中的其他列是对其他表(称为维度表)的外键引用。由于事实表中的每一行都表示一个事件,因此这些维度代表事件的发生地点,时间,方式和原因。
|
||||
|
||||
例如,在[图3-9](img/fig3-9.md)中,其中一个维度是已售出的产品。 `dim_product` 表中的每一行代表一种待售产品,包括**库存单位(SKU)**,说明,品牌名称,类别,脂肪含量,包装尺寸等。`fact_sales` 表中的每一行都使用外部表明在特定交易中销售了哪些产品。 (为了简单起见,如果客户一次购买几种不同的产品,则它们在事实表中被表示为单独的行)。
|
||||
|
||||
即使日期和时间通常使用维度表来表示,因为这允许对日期(诸如公共假期)的附加信息进行编码,从而允许查询区分假期和非假期的销售。
|
||||
甚至日期和时间也通常使用维度表来表示,因为这允许对日期的附加信息(诸如公共假期)进行编码,从而允许查询区分假期和非假期的销售。
|
||||
|
||||
“星型模式”这个名字来源于这样一个事实,即当表关系可视化时,事实表在中间,由维表包围;与这些表的连接就像星星的光芒。
|
||||
“星型模式”这个名字来源于这样一个事实,即当表关系可视化时,事实表在中间,由维度表包围;与这些表的连接就像星星的光芒。
|
||||
|
||||
这个模板的变体被称为雪花模式,其中尺寸被进一步分解为子尺寸。例如,品牌和产品类别可能有单独的表格,并且 `dim_product` 表格中的每一行都可以将品牌和类别作为外键引用,而不是将它们作为字符串存储在 `dim_product` 表格中。雪花模式比星形模式更规范化,但是星形模式通常是首选,因为分析师使用它更简单【55】。
|
||||
这个模板的变体被称为雪花模式,其中维度被进一步分解为子维度。例如,品牌和产品类别可能有单独的表格,并且 `dim_product` 表格中的每一行都可以将品牌和类别作为外键引用,而不是将它们作为字符串存储在 `dim_product` 表格中。雪花模式比星形模式更规范化,但是星形模式通常是首选,因为分析师使用它更简单【55】。
|
||||
|
||||
在典型的数据仓库中,表格通常非常宽泛:事实表格通常有100列以上,有时甚至有数百列【51】。维度表也可以是非常宽的,因为它们包括可能与分析相关的所有元数据——例如,`dim_store` 表可以包括在每个商店提供哪些服务的细节,它是否具有店内面包房,店面面积,商店第一次开幕的日期,最后一次改造的时间,离最近的高速公路的距离等等。
|
||||
在典型的数据仓库中,表格通常非常宽:事实表格通常有100列以上,有时甚至有数百列【51】。维度表也可以是非常宽的,因为它们包括可能与分析相关的所有元数据——例如,`dim_store` 表可以包括在每个商店提供哪些服务的细节,它是否具有店内面包房,店面面积,商店第一次开张的日期,最后一次改造的时间,离最近的高速公路的距离等等。
|
||||
|
||||
|
||||
|
||||
@ -472,7 +472,7 @@ GROUP BY
|
||||
|
||||
在大多数OLTP数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库是相似的:整个文档通常存储为一个连续的字节序列。你可以在[图3-1](img/fig3-1.png)的CSV例子中看到这个。
|
||||
|
||||
为了处理像[例3-1]()这样的查询,您可能在 `fact_sales.date_key`, `fact_sales.product_sk`上有索引,它们告诉存储引擎在哪里查找特定日期或特定产品的所有销售情况。但是,面向行的存储引擎仍然需要将所有这些行(每个包含超过100个属性)从磁盘加载到内存中,解析它们,并过滤掉那些不符合要求的条件。这可能需要很长时间。
|
||||
为了处理像[例3-1]()这样的查询,您可能在 `fact_sales.date_key`, `fact_sales.product_sk`上有索引,它们告诉存储引擎在哪里查找特定日期或特定产品的所有销售情况。但是,面向行的存储引擎仍然需要将所有这些行(每个包含超过100个属性)从磁盘加载到内存中,解析它们,并过滤掉那些不符合要求的属性。这可能需要很长时间。
|
||||
|
||||
面向列的存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如[图3-10](img/fig3-10.png)所示。
|
||||
|
||||
@ -482,7 +482,7 @@ GROUP BY
|
||||
|
||||
列存储在关系数据模型中是最容易理解的,但它同样适用于非关系数据。例如,Parquet 【57】是一种列式存储格式,支持基于Google的Dremel 【54】的文档数据模型。
|
||||
|
||||
面向列的存储布局依赖于包含相同顺序行的每个列文件。 因此,如果您需要重新组装整行,您可以从每个单独的列文件中获取第23项,并将它们放在一起形成表的第23行。
|
||||
面向列的存储布局依赖于每个列文件包含相同顺序的行。 因此,如果您需要重新组装整行,您可以从每个单独的列文件中获取第23项,并将它们放在一起形成表的第23行。
|
||||
|
||||
|
||||
|
||||
@ -496,7 +496,7 @@ GROUP BY
|
||||
|
||||
**图3-11 压缩位图索引存储布局**
|
||||
|
||||
通常情况下,一列中不同值的数量与行数相比较小(例如,零售商可能有数十亿的销售交易,但只有100,000个不同的产品)。现在我们可以得到一个有 n 个不同值的列,并把它转换成 n 个独立的位图:每个不同值的一个位图,每行一位。如果该行具有该值,则该位为 1 ,否则为 0 。
|
||||
通常情况下,一列中不同值的数量与行数相比较小(例如,零售商可能有数十亿的销售交易,但只有100,000个不同的产品)。现在我们可以拿一个有 n 个不同值的列,并把它转换成 n 个独立的位图:每个不同值的一个位图,每行一位。如果该行具有该值,则该位为 1 ,否则为 0 。
|
||||
|
||||
如果 n 非常小(例如,国家/地区列可能有大约200个不同的值),则这些位图可以每行存储一位。但是,如果n更大,大部分位图中将会有很多的零(我们说它们是稀疏的)。在这种情况下,位图可以另外进行游程编码,如[图3-11](fig3-11.png)底部所示。这可以使列的编码非常紧凑。
|
||||
|
||||
@ -525,35 +525,35 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
|
||||
对于需要扫描数百万行的数据仓库查询来说,一个巨大的瓶颈是从磁盘获取数据到内存的带宽。但是,这不是唯一的瓶颈。分析数据库的开发人员也担心有效利用主存储器带宽到CPU缓存中的带宽,避免CPU指令处理流水线中的分支错误预测和泡沫,以及在现代中使用单指令多数据(SIMD)指令CPU 【59,60】。
|
||||
|
||||
除了减少需要从磁盘加载的数据量以外,面向列的存储布局也可以有效利用CPU周期。例如,查询引擎可以将大量压缩的列数据放在CPU的L1缓存中,然后在紧密的循环中循环(即没有函数调用)。一个CPU可以执行这样一个循环比代码要快得多,这个代码需要处理每个记录的大量函数调用和条件。列压缩允许列中的更多行适合相同数量的L1缓存。前面描述的按位“与”和“或”运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量化处理【58,49】。
|
||||
除了减少需要从磁盘加载的数据量以外,面向列的存储布局也可以有效利用CPU周期。例如,查询引擎可以将大量压缩的列数据放在CPU的L1缓存中,然后在紧密的循环中循环(即没有函数调用)。相比较每个记录的处理都需要大量函数调用和条件判断的代码,CPU执行这样一个循环要快得多。列压缩允许列中的更多行适合相同数量的L1缓存。前面描述的按位“与”和“或”运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量化处理【58,49】。
|
||||
|
||||
|
||||
|
||||
### 列存储中的排序顺序
|
||||
|
||||
在列存储中,存储行的顺序并不一定很重要。按插入顺序存储它们是最简单的,因为插入一个新行就意味着附加到每个列文件。但是,我们可以选择强制执行一个命令,就像我们之前对SSTables所做的那样,并将其用作索引机制。
|
||||
在列存储中,存储行的顺序并不一定很重要。按插入顺序存储它们是最简单的,因为插入一个新行只需要追加到每个列文件。但是,我们可以选择增加一个特定的顺序,就像我们之前对SSTables所做的那样,并将其用作索引机制。
|
||||
|
||||
注意,每列独自排序是没有意义的,因为那样我们就不会知道列中的哪些项属于同一行。我们只能重建一行,因为我们知道一列中的第k项与另一列中的第k项属于同一行。
|
||||
注意,每列独自排序是没有意义的,因为那样我们就不会知道列中的哪些项属于同一行。我们只能在知道一列中的第k项与另一列中的第k项属于同一行的情况才能重建出完整的行。
|
||||
|
||||
相反,即使按列存储数据,也需要一次对整行进行排序。数据库的管理员可以使用他们对常见查询的知识来选择表格应该被排序的列。例如,如果查询通常以日期范围为目标,例如上个月,则可以将 `date_key` 作为第一个排序键。然后,查询优化器只能扫描上个月的行,这比扫描所有行要快得多。
|
||||
相反,即使按列存储数据,也需要一次对整行进行排序。数据库的管理员可以根据他们对常用查询的了解来选择表格应该被排序的列。例如,如果查询通常以日期范围为目标,例如上个月,则可以将 `date_key` 作为第一个排序键。这样查询优化器就可以只扫描上个月的行了,这比扫描所有行要快得多。
|
||||
|
||||
第二列可以确定第一列中具有相同值的任何行的排序顺序。例如,如果 `date_key` 是[图3-10](img/fig3-10.png)中的第一个排序关键字,那么 `product_sk` 可能是第二个排序关键字,因此同一天的同一产品的所有销售都将在存储中组合在一起。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。
|
||||
|
||||
排序顺序的另一个好处是它可以帮助压缩列。如果主要排序列没有多个不同的值,那么在排序之后,它将具有很长的序列,其中相同的值连续重复多次。一个简单的运行长度编码(就像我们用于[图3-11](img/fig3-11.png)中的位图一样)可以将该列压缩到几千字节 —— 即使表中有数十亿行。
|
||||
排序顺序的另一个好处是它可以帮助压缩列。如果主要排序列没有多个不同的值,那么在排序之后,它将具有很长的序列,其中相同的值连续重复多次。一个简单的游程编码(就像我们用于[图3-11](img/fig3-11.png)中的位图一样)可以将该列压缩到几千字节 —— 即使表中有数十亿行。
|
||||
|
||||
第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长时间的重复值。排序优先级下面的列以基本上随机的顺序出现,所以它们可能不会被压缩。但前几列排序仍然是一个整体。
|
||||
第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长时间的重复值。排序优先级更低的列以基本上随机的顺序出现,所以它们可能不会被压缩。但前几列排序在整体上仍然是有好外的。
|
||||
|
||||
#### 几个不同的排序顺序
|
||||
|
||||
这个想法的巧妙扩展在C-Store中引入,并在商业数据仓库Vertica【61,62】中被采用。不同的查询受益于不同的排序顺序,为什么不以相同的方式存储相同的数据呢?无论如何,数据需要复制到多台机器,这样,如果一台机器发生故障,您不会丢失数据。您可能还需要存储以不同方式排序的冗余数据,以便在处理查询时,可以使用最适合查询模式的版本。
|
||||
这个想法的巧妙扩展在C-Store中引入,并在商业数据仓库Vertica【61,62】中被采用。不同的查询受益于不同的排序顺序,为什么不以几种不同的方式来存储相同的数据呢?无论如何,数据需要复制到多台机器,这样,如果一台机器发生故障,您不会丢失数据。您可能还需要存储以不同方式排序的冗余数据,以便在处理查询时,可以使用最适合查询模式的版本。
|
||||
|
||||
在一个面向列的存储中有多个排序顺序有点类似于在一个面向行的存储中有多个二级索引。但最大的区别在于面向行的存储将每一行保存在一个地方(在堆文件或聚簇索引中),二级索引只包含指向匹配行的指针。在列存储中,通常在其他地方没有任何指向数据的指针,只有包含值的列。
|
||||
|
||||
### 写入列存储
|
||||
|
||||
这些优化在数据仓库中是有意义的,因为大多数负载由分析人员运行的大型只读查询组成。面向列的存储,压缩和排序都有助于更快地读取这些查询。然而,他们有写更加困难的缺点。
|
||||
这些优化在数据仓库中是有意义的,因为大多数负载由分析人员运行的大型只读查询组成。面向列的存储,压缩和排序都有助于更快地读取这些查询。然而,他们的缺点是写入更加困难。
|
||||
|
||||
使用B树的更新就地方法对于压缩的列是不可能的。如果你想在排序表的中间插入一行,你很可能不得不重写所有的列文件。由于行由列中的位置标识,因此插入必须始终更新所有列。
|
||||
使用B树的就地更新方法对于压缩的列是不可能的。如果你想在排序表的中间插入一行,你很可能不得不重写所有的列文件。由于行由列中的位置标识,因此插入必须始终更新所有列。
|
||||
|
||||
幸运的是,本章前面已经看到了一个很好的解决方案:LSM树。所有的写操作首先进入一个内存中的存储,在这里它们被添加到一个已排序的结构中,并准备写入磁盘。内存中的存储是面向行还是列的,这并不重要。当已经积累了足够的写入数据时,它们将与磁盘上的列文件合并,并批量写入新文件。这基本上是Vertica所做的【62】。
|
||||
|
||||
@ -565,9 +565,9 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
|
||||
数据仓库的另一个值得一提的是物化汇总。如前所述,数据仓库查询通常涉及一个聚合函数,如SQL中的COUNT,SUM,AVG,MIN或MAX。如果相同的聚合被许多不同的查询使用,那么每次都可以通过原始数据来处理。为什么不缓存一些查询使用最频繁的计数或总和?
|
||||
|
||||
创建这种缓存的一种方式是物化视图。在关系数据模型中,它通常被定义为一个标准(虚拟)视图:一个类似于表的对象,其内容是一些查询的结果。不同的是,物化视图是查询结果的实际副本,写入磁盘,而虚拟视图只是写入查询的捷径。从虚拟视图读取时,SQL引擎会将其展开到视图的底层查询中,然后处理展开的查询。
|
||||
创建这种缓存的一种方式是物化视图(Materialized View)。在关系数据模型中,它通常被定义为一个标准(虚拟)视图:一个类似于表的对象,其内容是一些查询的结果。不同的是,物化视图是查询结果的实际副本,写入磁盘,而虚拟视图只是写入查询的捷径。从虚拟视图读取时,SQL引擎会将其展开到视图的底层查询中,然后处理展开的查询。
|
||||
|
||||
当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。数据库可以自动完成,但是这样的更新使得写入成本更高,这就是在OLTP数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中,它们可能更有意义(不管它们是否实际上改善了读取性能取决于个别情况)。
|
||||
当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。数据库可以自动完成,但是这样的更新使得写入成本更高,这就是在OLTP数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中,它们可能更有意义(它们是否实际上改善了读取性能取决于个别情况)。
|
||||
|
||||
物化视图的常见特例称为数据立方体或OLAP立方【64】。它是按不同维度分组的聚合网格。[图3-12](img/fig3-12.png)显示了一个例子。
|
||||
|
||||
@ -575,9 +575,9 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
|
||||
**图3-12 数据立方的两个维度,通过求和聚合**
|
||||
|
||||
想象一下,现在每个事实都只有两个维度表的外键——在[图3-12](img/fig-3-12.png)中,这些是日期和产品。您现在可以绘制一个二维表格,一个轴线上的日期和另一个轴上的产品。每个单元包含具有该日期 - 产品组合的所有事实的属性(例如,`net_price`)的聚集(例如,`SUM`)。然后,您可以沿着每行或每列应用相同的汇总,并获得一个维度减少的汇总(按产品的销售额,无论日期,还是按日期销售,无论产品如何)。
|
||||
想象一下,现在每个事实都只有两个维度表的外键——在[图3-12](img/fig-3-12.png)中,这些是日期和产品。您现在可以绘制一个二维表格,一个轴线上是日期,另一个轴线上是产品。每个单元包含具有该日期 - 产品组合的所有事实的属性(例如,`net_price`)的聚集(例如,`SUM`)。然后,您可以沿着每行或每列应用相同的汇总,并获得一个维度减少的汇总(按产品的销售额,无论日期,还是按日期销售,无论产品如何)。
|
||||
|
||||
一般来说,事实往往有两个以上的维度。在图3-9中有五个维度:日期,产品,商店,促销和客户。要想象一个五维超立方体是什么样子是很困难的,但是原理是一样的:每个单元格都包含特定日期(产品-商店-促销-客户)组合的销售。这些值可以在每个维度上重复概括。
|
||||
一般来说,事实往往有两个以上的维度。在图3-9中有五个维度:日期,产品,商店,促销和客户。要想象一个五维超立方体是什么样子是很困难的,但是原理是一样的:每个单元格都包含特定日期-产品-商店-促销-客户组合的销售。这些值可以在每个维度上重复概括。
|
||||
|
||||
物化数据立方体的优点是某些查询变得非常快,因为它们已经被有效地预先计算了。例如,如果您想知道每个商店的总销售额,则只需查看合适维度的总计,无需扫描数百万行。
|
||||
|
||||
|
156
ch4.md
156
ch4.md
@ -11,11 +11,11 @@
|
||||
|
||||
[TOC]
|
||||
|
||||
应用程序不可避免地随时间而变化。新产品的推出,对需求的深入理解,或者商业环境的变化,总会伴随着**功能(feature)** 的增增改改。[第一章](ch1.md)介绍了[**可演化性(evolvability)**](ch1.md#可演化性:拥抱变化)的概念:应该尽力构建能灵活适应变化的系统(参阅“[可演化性:拥抱变化]()”)。
|
||||
应用程序不可避免地随时间而变化。新产品的推出,对需求的深入理解,或者商业环境的变化,总会伴随着**功能(feature)** 的增增改改。[第一章](ch1.md)介绍了**可演化性(evolvability)**的概念:应该尽力构建能灵活适应变化的系统(参阅“[可演化性:拥抱变化](ch1.md#可演化性:拥抱变化)”)。
|
||||
|
||||
在大多数情况下,修改应用程序的功能也意味着需要更改其存储的数据:可能需要使用新的字段或记录类型,或者以新方式展示现有数据。
|
||||
|
||||
我们在[第二章](ch2.md)讨论的数据模型有不同的方法来应对这种变化。关系数据库通常假定数据库中的所有数据都遵循一个模式:尽管可以更改该模式(通过模式迁移,即`ALTER`语句),但是在任何时间点都有且仅有一个正确的模式。相比之下,**读时模式(schema-on-read)**(或 **无模式(schemaless)**)数据库不会强制一个模式,因此数据库可以包含在不同时间写入的新老数据格式的混合(参阅 “文档模型中的模式灵活性” )。
|
||||
我们在[第二章](ch2.md)讨论的数据模型有不同的方法来应对这种变化。关系数据库通常假定数据库中的所有数据都遵循一个模式:尽管可以更改该模式(通过模式迁移,即`ALTER`语句),但是在任何时间点都有且仅有一个正确的模式。相比之下,**读时模式(schema-on-read)**(或 **无模式(schemaless)**)数据库不会强制一个模式,因此数据库可以包含在不同时间写入的新老数据格式的混合(参阅 “[文档模型中的模式灵活性](ch2.md#文档模型中的模式灵活性)” )。
|
||||
|
||||
当数据**格式(format)** 或**模式(schema)** 发生变化时,通常需要对应用程序代码进行相应的更改(例如,为记录添加新字段,然后修改程序开始读写该字段)。但在大型应用程序中,代码变更通常不会立即完成:
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
|
||||
向前兼容性可能会更棘手,因为旧版的程序需要忽略新版数据格式中新增的部分。
|
||||
|
||||
本章中将介绍几种编码数据的格式,包括 JSON,XML,Protocol Buffers,Thrift和Avro。尤其将关注这些格式如何应对模式变化,以及它们如何对新旧代码数据需要共存的系统提供支持。然后将讨论如何使用这些格式进行数据存储和通信:在Web服务中,**具象状态传输(REST)**和**远程过程调用(RPC)**,以及**消息传递系统**(如Actor和消息队列)。
|
||||
本章中将介绍几种编码数据的格式,包括 JSON,XML,Protocol Buffers,Thrift和Avro。尤其将关注这些格式如何应对模式变化,以及它们如何对新旧代码数据需要共存的系统提供支持。然后将讨论如何使用这些格式进行数据存储和通信:在Web服务中,**表述性状态传递(REST)**和**远程过程调用(RPC)**,以及**消息传递系统**(如Actor和消息队列)。
|
||||
|
||||
## 编码数据的格式
|
||||
|
||||
@ -78,7 +78,7 @@ JSON,XML和CSV属于文本格式,因此具有人类可读性(尽管它们
|
||||
|
||||
* **数值(numbers)** 的编码多有歧义之处。XML和CSV不能区分数字和字符串(除非引用一个外部模式)。 JSON虽然区分字符串与数值,但不区分整数和浮点数,而且不能指定精度。
|
||||
* 当处理更大的数值时,这个问题显得尤为严重。例如大于$2^{53}$的整数无法使用IEEE 754双精度浮点数精确表示,因此在使用浮点数(例如JavaScript)的语言进行分析时,这些数字会变得不准确。 Twitter有一个关于大于$2^{53}$的数字的例子,它使用64位整数来标识每条推文。 Twitter API返回的JSON包含了两种推特ID,一种是JSON数值,另一种是十进制字符串,以避免JavaScript程序无法正确解析数字的问题【10】。
|
||||
* JSON和XML对Unicode字符串(即人类可读的文本)有很好的支持,但是它们不支持二进制数据(即不带 **字符编码(character encoding)** 的字节序列)。二进制串是很有用的功能,人们通过使用Base64将二进制数据编码为文本来绕过此限制。其特有的模式通常标识着这个值应当被解释为Base64编码的二进制数据。这种方案虽然管用,但比较Hacky,并且会增加三分之一的数据大小。
|
||||
* JSON和XML对Unicode字符串(即人类可读的文本)有很好的支持,但是它们不支持二进制数据(即不带 **字符编码(character encoding)** 的字节序列)。二进制串是很有用的功能,人们通过使用Base64将二进制数据编码为文本来绕过此限制。其特有的模式标识着这个值应当被解释为Base64编码的二进制数据。这种方案虽然管用,但比较Hacky,并且会增加三分之一的数据大小。
|
||||
* XML 【11】和 JSON 【12】都有可选的模式支持。这些模式语言相当强大,所以学习和实现起来都相当复杂。 XML模式的使用相当普遍,但许多基于JSON的工具才不会去折腾模式。对数据的正确解读(例如区分数值与二进制串)取决于模式中的信息,因此不使用XML/JSON模式的应用程序可能需要对相应的编码/解码逻辑进行硬编码。
|
||||
* CSV没有任何模式,因此每行和每列的含义完全由应用程序自行定义。如果应用程序变更添加了新的行或列,那么这种变更必须通过手工处理。 CSV也是一个相当模糊的格式(如果一个值包含逗号或换行符,会发生什么?)。尽管其转义规则已经被正式指定【13】,但并不是所有的解析器都正确的实现了标准。
|
||||
|
||||
@ -88,7 +88,7 @@ JSON,XML和CSV属于文本格式,因此具有人类可读性(尽管它们
|
||||
|
||||
对于仅在组织内部使用的数据,使用最小公约数式的编码格式压力较小。例如,可以选择更紧凑或更快的解析格式。虽然对小数据集来说,收益可以忽略不计;但一旦达到TB级别,数据格式的选型就会产生巨大的影响。
|
||||
|
||||
JSON比XML简洁,但与二进制格式相比还是太占空间。这一事实导致大量二进制编码版本JSON & XML的出现,JSON(MessagePack,BSON,BJSON,UBJSON,BISON和Smile等)(例如WBXML和Fast Infoset)。这些格式已经在各种各样的领域中采用,但是没有一个能像文本版JSON和XML那样被广泛采用。
|
||||
JSON比XML简洁,但与二进制格式相比还是太占空间。这一事实导致大量二进制编码版本JSON(MessagePack,BSON,BJSON,UBJSON,BISON和Smile等) 和 XML(例如WBXML和Fast Infoset)的出现。这些格式已经在各种各样的领域中采用,但是没有一个能像文本版JSON和XML那样被广泛采用。
|
||||
|
||||
这些格式中的一些扩展了一组数据类型(例如,区分整数和浮点数,或者增加对二进制字符串的支持),另一方面,它们没有改变JSON / XML的数据模型。特别是由于它们没有规定模式,所以它们需要在编码数据中包含所有的对象字段名称。也就是说,在[例4-1]()中的JSON文档的二进制编码中,需要在某处包含字符串`userName`,`favoriteNumber`和`interest`。
|
||||
|
||||
@ -105,7 +105,7 @@ JSON比XML简洁,但与二进制格式相比还是太占空间。这一事实
|
||||
我们来看一个MessagePack的例子,它是一个JSON的二进制编码。图4-1显示了如果使用MessagePack 【14】对[例4-1]()中的JSON文档进行编码,则得到的字节序列。前几个字节如下:
|
||||
|
||||
1. 第一个字节`0x83`表示接下来是**3**个字段(低四位= `0x03`)的**对象 object**(高四位= `0x80`)。 (如果想知道如果一个对象有15个以上的字段会发生什么情况,字段的数量塞不进4个bit里,那么它会用另一个不同的类型标识符,字段的数量被编码两个或四个字节)。
|
||||
2. 第二个字节`0xa8`表示接下来是**8**字节长的字符串(最高四位= 0x08)。
|
||||
2. 第二个字节`0xa8`表示接下来是**8**字节长(低四位=0x08)的字符串(高四位= 0x0a)。
|
||||
3. 接下来八个字节是ASCII字符串形式的字段名称`userName`。由于之前已经指明长度,不需要任何标记来标识字符串的结束位置(或者任何转义)。
|
||||
4. 接下来的七个字节对前缀为`0xa6`的六个字母的字符串值`Martin`进行编码,依此类推。
|
||||
|
||||
@ -148,7 +148,7 @@ Thrift和Protocol Buffers每一个都带有一个代码生成工具,它采用
|
||||
|
||||
**图4-2 使用Thrift二进制协议编码的记录**
|
||||
|
||||
[^iii]: 实际上,Thrift有三种二进制协议:CompactProtocol和DenseProtocol,尽管DenseProtocol只支持C ++实现,所以不算作跨语言[18]。 除此之外,它还有两种不同的基于JSON的编码格式【19】。 真逗!
|
||||
[^iii]: 实际上,Thrift有三种二进制协议:BinaryProtocol、CompactProtocol和DenseProtocol,尽管DenseProtocol只支持C ++实现,所以不算作跨语言[18]。 除此之外,它还有两种不同的基于JSON的编码格式【19】。 真逗!
|
||||
|
||||
与[图4-1](Img/fig4-1.png)类似,每个字段都有一个类型注释(用于指示它是一个字符串,整数,列表等),还可以根据需要指定长度(字符串的长度,列表中的项目数) 。出现在数据中的字符串`(“Martin”, “daydreaming”, “hacking”)`也被编码为ASCII(或者说,UTF-8),与之前类似。
|
||||
|
||||
@ -176,17 +176,17 @@ Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4
|
||||
|
||||
您可以添加新的字段到架构,只要您给每个字段一个新的标签号码。如果旧的代码(不知道你添加的新的标签号码)试图读取新代码写入的数据,包括一个新的字段,其标签号码不能识别,它可以简单地忽略该字段。数据类型注释允许解析器确定需要跳过的字节数。这保持了前向兼容性:旧代码可以读取由新代码编写的记录。
|
||||
|
||||
向后兼容性呢?只要每个字段都有一个唯一的标签号码,新的代码总是可以读取旧的数据,因为标签号码仍然具有相同的含义。唯一的细节是,如果你添加一个新的领域,你不能要求。如果您要添加一个字段并将其设置为必需,那么如果新代码读取旧代码写入的数据,则该检查将失败,因为旧代码不会写入您添加的新字段。因此,为了保持向后兼容性,在模式的初始部署之后 **添加的每个字段必须是可选的或具有默认值**。
|
||||
向后兼容性呢?只要每个字段都有一个唯一的标签号码,新的代码总是可以读取旧的数据,因为标签号码仍然具有相同的含义。唯一的细节是,如果你添加一个新的字段,你不能设置为必需。如果您要添加一个字段并将其设置为必需,那么如果新代码读取旧代码写入的数据,则该检查将失败,因为旧代码不会写入您添加的新字段。因此,为了保持向后兼容性,在模式的初始部署之后 **添加的每个字段必须是可选的或具有默认值**。
|
||||
|
||||
删除一个字段就像添加一个字段,倒退和向前兼容性问题相反。这意味着您只能删除一个可选的字段(必填字段永远不能删除),而且您不能再次使用相同的标签号码(因为您可能仍然有数据写在包含旧标签号码的地方,而该字段必须被新代码忽略)。
|
||||
删除一个字段就像添加一个字段,只是这回要考虑的是向前兼容性。这意味着您只能删除一个可选的字段(必需字段永远不能删除),而且您不能再次使用相同的标签号码(因为您可能仍然有数据写在包含旧标签号码的地方,而该字段必须被新代码忽略)。
|
||||
|
||||
#### 数据类型和模式演变
|
||||
|
||||
如何改变字段的数据类型?这可能是可能的——检查文件的细节——但是有一个风险,值将失去精度或被破坏。例如,假设你将一个32位的整数变成一个64位的整数。新代码可以轻松读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取由新代码写入的数据,则旧代码仍使用32位变量来保存该值。如果解码的64位值不适合32位,则它将被截断。
|
||||
如何改变字段的数据类型?这也许是可能的——详细信息请查阅相关的文档——但是有一个风险,值将失去精度或被截断。例如,假设你将一个32位的整数变成一个64位的整数。新代码可以轻松读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取由新代码写入的数据,则旧代码仍使用32位变量来保存该值。如果解码的64位值不适合32位,则它将被截断。
|
||||
|
||||
Protobuf的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(这是第三个选项旁边必要和可选)。如[图4-4](img/fig4-4.png)所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。
|
||||
Protobuf的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(这是除必需和可选之外的第三个选项)。如[图4-4](img/fig4-4.png)所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。
|
||||
|
||||
Thrift有一个专用的列表数据类型,它使用列表元素的数据类型进行参数化。这不允许Protocol Buffers所做的从单值到多值的相同演变,但是它具有支持嵌套列表的优点。
|
||||
Thrift有一个专用的列表数据类型,它使用列表元素的数据类型进行参数化。这不允许Protocol Buffers所做的从单值到多值的演变,但是它具有支持嵌套列表的优点。
|
||||
|
||||
### Avro
|
||||
|
||||
@ -218,7 +218,7 @@ record Person {
|
||||
}
|
||||
```
|
||||
|
||||
首先,请注意架构中没有标签号码。 如果我们使用这个模式编码我们的例子记录([例4-1]()),Avro二进制编码只有32个字节长,这是我们所见过的所有编码中最紧凑的。 编码字节序列的分解如[图4-5](img/fig4-5.png)所示。
|
||||
首先,请注意模式中没有标签号码。 如果我们使用这个模式编码我们的例子记录([例4-1]()),Avro二进制编码只有32个字节长,这是我们所见过的所有编码中最紧凑的。 编码字节序列的分解如[图4-5](img/fig4-5.png)所示。
|
||||
|
||||
如果您检查字节序列,您可以看到没有什么可以识别字段或其数据类型。 编码只是由连在一起的值组成。 一个字符串只是一个长度前缀,后跟UTF-8字节,但是在被包含的数据中没有任何内容告诉你它是一个字符串。 它可以是一个整数,也可以是其他的整数。 整数使用可变长度编码(与Thrift的CompactProtocol相同)进行编码。
|
||||
|
||||
@ -226,19 +226,19 @@ record Person {
|
||||
|
||||
**图4-5 使用Avro编码的记录**
|
||||
|
||||
为了解析二进制数据,您按照它们出现在架构中的顺序遍历这些字段,并使用架构来告诉您每个字段的数据类型。这意味着如果读取数据的代码使用与写入数据的代码完全相同的模式,则只能正确解码二进制数据。读者和作者之间的模式不匹配意味着错误地解码数据。
|
||||
为了解析二进制数据,您按照它们出现在模式中的顺序遍历这些字段,并使用模式来告诉您每个字段的数据类型。这意味着如果读取数据的代码使用与写入数据的代码完全相同的模式,才能正确解码二进制数据。Reader和Writer之间的模式不匹配意味着错误地解码数据。
|
||||
|
||||
那么,Avro如何支持模式演变呢?
|
||||
|
||||
#### 作者模式与读者模式
|
||||
#### Writer模式与Reader模式
|
||||
|
||||
有了Avro,当应用程序想要编码一些数据(将其写入文件或数据库,通过网络发送等)时,它使用它知道的任何版本的模式编码数据,例如,架构可能被编译到应用程序中。这被称为作者的模式。
|
||||
有了Avro,当应用程序想要编码一些数据(将其写入文件或数据库,通过网络发送等)时,它使用它知道的任何版本的模式编码数据,例如,模式可能被编译到应用程序中。这被称为Writer模式。
|
||||
|
||||
当一个应用程序想要解码一些数据(从一个文件或数据库读取数据,从网络接收数据等)时,它希望数据在某个模式中,这就是读者的模式。这是应用程序代码所依赖的模式,在应用程序的构建过程中,代码可能是从该模式生成的。
|
||||
当一个应用程序想要解码一些数据(从一个文件或数据库读取数据,从网络接收数据等)时,它希望数据在某个模式中,这就是Reader模式。这是应用程序代码所依赖的模式,在应用程序的构建过程中,代码可能已经从该模式生成。
|
||||
|
||||
Avro的关键思想是作者的模式和读者的模式不必是相同的 - 他们只需要兼容。当数据解码(读取)时,Avro库通过并排查看作者的模式和读者的模式并将数据从作者的模式转换到读者的模式来解决差异。 Avro规范【20】确切地定义了这种解析的工作原理,如[图4-6](img/fig4-6.png)所示。
|
||||
Avro的关键思想是Writer模式和Reader模式不必是相同的 - 他们只需要兼容。当数据解码(读取)时,Avro库通过并排查看Writer模式和Reader模式并将数据从Writer模式转换到Reader模式来解决差异。 Avro规范【20】确切地定义了这种解析的工作原理,如[图4-6](img/fig4-6.png)所示。
|
||||
|
||||
例如,如果作者的模式和读者的模式的字段顺序不同,这是没有问题的,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到出现在作者模式中但不在读者模式中的字段,则忽略它。如果读取数据的代码需要某个字段,但是作者的模式不包含该名称的字段,则使用在读者模式中声明的默认值填充。
|
||||
例如,如果Writer模式和Reader模式的字段顺序不同,这是没有问题的,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到出现在Writer模式中但不在Reader模式中的字段,则忽略它。如果读取数据的代码需要某个字段,但是Writer模式不包含该名称的字段,则使用在Reader模式中声明的默认值填充。
|
||||
|
||||
![](img/fig4-6.png)
|
||||
|
||||
@ -246,34 +246,35 @@ Avro的关键思想是作者的模式和读者的模式不必是相同的 - 他
|
||||
|
||||
#### 模式演变规则
|
||||
|
||||
使用Avro,向前兼容性意味着您可以将新版本的架构作为编写器,并将旧版本的架构作为读者。相反,向后兼容意味着你可以有一个作为读者的新版本的模式和作为作者的旧版本。
|
||||
使用Avro,向前兼容性意味着您可以将新版本的模式作为Writer,并将旧版本的模式作为Reader。相反,向后兼容意味着你可以有一个作为Reader的新版本模式和作为Writer的旧版本模式。
|
||||
|
||||
为了保持兼容性,您只能添加或删除具有默认值的字段。 (我们的Avro模式中的字段`favourNumber`的默认值为`null`)。例如,假设您添加一个默认值的字段,所以这个新的字段存在于新的模式中,而不是旧的。当使用新模式的阅读器读取使用旧模式写入的记录时,将为缺少的字段填充默认值。
|
||||
为了保持兼容性,您只能添加或删除具有默认值的字段。 (我们的Avro模式中的字段`favourNumber`的默认值为`null`)。例如,假设您添加了一个有默认值的字段,这个新的字段将存在于新模式而不是旧模式中。当使用新模式的Reader读取使用旧模式写入的记录时,将为缺少的字段填充默认值。
|
||||
|
||||
如果你要添加一个没有默认值的字段,新的阅读器将无法读取旧作者写的数据,所以你会破坏向后兼容性。如果您要删除没有默认值的字段,旧的阅读器将无法读取新作者写入的数据,因此您会打破兼容性。在一些编程语言中,null是任何变量可以接受的默认值,但在Avro中并不是这样:如果要允许一个字段为`null`,则必须使用联合类型。例如,`union {null,long,string}`字段;表示该字段可以是数字或字符串,也可以是`null`。如果它是union的分支之一,那么只能使用null作为默认值[^iv]。这比默认情况下可以为`null`是更加冗长的,但是通过明确什么可以和不可以是什么,有助于防止错误的`null` 【22】。
|
||||
如果你要添加一个没有默认值的字段,新的Reader将无法读取旧Writer写的数据,所以你会破坏向后兼容性。如果您要删除没有默认值的字段,旧的Reader将无法读取新Writer写入的数据,因此您会打破向前兼容性。在一些编程语言中,null是任何变量可以接受的默认值,但在Avro中并不是这样:如果要允许一个字段为`null`,则必须使用联合类型。例如,`union {null,long,string} field;`表示field可以是数字或字符串,也可以是`null`。如果要将null作为默认值,则它必须是union的分支之一[^iv]。这样的写法比默认情况下就允许任何变量是`null`显得更加冗长,但是通过明确什么可以和什么不可以是`null`,有助于防止出错【22】。
|
||||
|
||||
[^iv]: 确切地说,默认值必须是联合的第一个分支的类型,尽管这是Avro的特定限制,而不是联合类型的一般特征。
|
||||
|
||||
因此,Avro没有像Protocol Buffers和Thrift那样的`optional`和`required`标记(它有联合类型和默认值)。
|
||||
因此,Avro没有像Protocol Buffers和Thrift那样的`optional`和`required`标记(但它有联合类型和默认值)。
|
||||
|
||||
只要Avro可以转换类型,就可以改变字段的数据类型。更改字段的名称是可能的,但有点棘手:读者的模式可以包含字段名称的别名,所以它可以匹配旧作家的模式字段名称与别名。这意味着更改字段名称是向后兼容的,但不能向前兼容。同样,向联合类型添加分支也是向后兼容的,但不能向前兼容。
|
||||
只要Avro可以支持相应的类型转换,就可以改变字段的数据类型。更改字段的名称也是可能的,但有点棘手:Reader模式可以包含字段名称的别名,所以它可以匹配旧Writer的模式字段名称与别名。这意味着更改字段名称是向后兼容的,但不能向前兼容。同样,向联合类型添加分支也是向后兼容的,但不能向前兼容。
|
||||
|
||||
##### 但作者模式到底是什么?
|
||||
##### 但Writer模式到底是什么?
|
||||
|
||||
到目前为止,我们一直跳过了一个重要的问题:对于一段特定的编码数据,Reader如何知道其Writer模式?我们不能只将整个模式包括在每个记录中,因为模式可能比编码的数据大得多,从而使二进制编码节省的所有空间都是徒劳的。
|
||||
|
||||
到目前为止,我们已经讨论了一个重要的问题:读者如何知道作者的模式是哪一部分数据被编码的?我们不能只将整个模式包括在每个记录中,因为模式可能比编码的数据大得多,从而使二进制编码节省的所有空间都是徒劳的。
|
||||
答案取决于Avro使用的上下文。举几个例子:
|
||||
|
||||
* 有很多记录的大文件
|
||||
|
||||
Avro的一个常见用途 - 尤其是在Hadoop环境中 - 用于存储包含数百万条记录的大文件,所有记录都使用相同的模式进行编码。 (我们将在[第10章](ch10.md)讨论这种情况。)在这种情况下,该文件的作者可以在文件的开头只包含一次作者的模式。 Avro指定一个文件格式(对象容器文件)来做到这一点。
|
||||
Avro的一个常见用途 - 尤其是在Hadoop环境中 - 用于存储包含数百万条记录的大文件,所有记录都使用相同的模式进行编码。 (我们将在[第10章](ch10.md)讨论这种情况。)在这种情况下,该文件的作者可以在文件的开头只包含一次Writer模式。 Avro指定了一个文件格式(对象容器文件)来做到这一点。
|
||||
|
||||
* 支持独立写入的记录的数据库
|
||||
|
||||
在一个数据库中,不同的记录可能会在不同的时间点使用不同的作者的模式编写 - 你不能假定所有的记录都有相同的模式。最简单的解决方案是在每个编码记录的开始处包含一个版本号,并在数据库中保留一个模式版本列表。读者可以获取记录,提取版本号,然后从数据库中获取该版本号的作者模式。使用该作者的模式,它可以解码记录的其余部分。 (例如Espresso 【23】就是这样工作的。)
|
||||
在一个数据库中,不同的记录可能会在不同的时间点使用不同的Writer模式来写入 - 你不能假定所有的记录都有相同的模式。最简单的解决方案是在每个编码记录的开始处包含一个版本号,并在数据库中保留一个模式版本列表。Reader可以获取记录,提取版本号,然后从数据库中获取该版本号的Writer模式。使用该Writer模式,它可以解码记录的其余部分。(例如Espresso 【23】就是这样工作的。)
|
||||
|
||||
* 通过网络连接发送记录
|
||||
|
||||
当两个进程通过双向网络连接进行通信时,他们可以在连接设置上协商模式版本,然后在连接的生命周期中使用该模式。 Avro RPC协议(参阅“[通过服务的数据流:REST和RPC](#通过服务的数据流:REST和RPC)”)如此工作。
|
||||
当两个进程通过双向网络连接进行通信时,他们可以在连接设置上协商模式版本,然后在连接的生命周期中使用该模式。 Avro RPC协议(参阅“[通过服务的数据流:REST和RPC](#通过服务的数据流:REST和RPC)”)就是这样工作的。
|
||||
|
||||
具有模式版本的数据库在任何情况下都是非常有用的,因为它充当文档并为您提供了检查模式兼容性的机会【24】。作为版本号,你可以使用一个简单的递增整数,或者你可以使用模式的散列。
|
||||
|
||||
@ -283,49 +284,50 @@ Avro的关键思想是作者的模式和读者的模式不必是相同的 - 他
|
||||
|
||||
不同之处在于Avro对动态生成的模式更友善。例如,假如你有一个关系数据库,你想要把它的内容转储到一个文件中,并且你想使用二进制格式来避免前面提到的文本格式(JSON,CSV,SQL)的问题。如果你使用Avro,你可以很容易地从关系模式生成一个Avro模式(在我们之前看到的JSON表示中),并使用该模式对数据库内容进行编码,并将其全部转储到Avro对象容器文件【25】中。您为每个数据库表生成一个记录模式,每个列成为该记录中的一个字段。数据库中的列名称映射到Avro中的字段名称。
|
||||
|
||||
现在,如果数据库模式发生变化(例如,一个表中添加了一列,删除了一列),则可以从更新的数据库模式生成新的Avro模式,并在新的Avro模式中导出数据。数据导出过程不需要注意模式的改变 - 每次运行时都可以简单地进行模式转换。任何读取新数据文件的人都会看到记录的字段已经改变,但是由于字段是通过名字来标识的,所以更新的作者的模式仍然可以与旧的读者模式匹配。
|
||||
现在,如果数据库模式发生变化(例如,一个表中添加了一列,删除了一列),则可以从更新的数据库模式生成新的Avro模式,并在新的Avro模式中导出数据。数据导出过程不需要注意模式的改变 - 每次运行时都可以简单地进行模式转换。任何读取新数据文件的人都会看到记录的字段已经改变,但是由于字段是通过名字来标识的,所以更新的Writer模式仍然可以与旧的Reader模式匹配。
|
||||
|
||||
相比之下,如果您为此使用Thrift或Protocol Buffers,则字段标记可能必须手动分配:每次数据库模式更改时,管理员都必须手动更新从数据库列名到字段标签。 (这可能会自动化,但模式生成器必须非常小心,不要分配以前使用的字段标记。)这种动态生成的模式根本不是Thrift或Protocol Buffers的设计目标,而是为Avro。
|
||||
相比之下,如果您为此使用Thrift或Protocol Buffers,则字段标签可能必须手动分配:每次数据库模式更改时,管理员都必须手动更新从数据库列名到字段标签的映射。(这可能会自动化,但模式生成器必须非常小心,不要分配以前使用的字段标签。)这种动态生成的模式根本不是Thrift或Protocol Buffers的设计目标,而是Avro的。
|
||||
|
||||
#### 代码生成和动态类型的语言
|
||||
|
||||
Thrift和Protobuf依赖于代码生成:在定义了模式之后,可以使用您选择的编程语言生成实现此模式的代码。这在Java,C ++或C#等静态类型语言中很有用,因为它允许将高效的内存中结构用于解码的数据,并且在编写访问数据结构的程序时允许在IDE中进行类型检查和自动完成。
|
||||
|
||||
在动态类型编程语言(如JavaScript,Ruby或Python)中,生成代码没有太多意义,因为没有编译时类型检查器来满足。代码生成在这些语言中经常被忽视,因为它们避免了明确的编译步骤。而且,对于动态生成的模式(例如从数据库表生成的Avro模式),代码生成对获取数据是一个不必要的障碍。
|
||||
在动态类型编程语言(如JavaScript,Ruby或Python)中,生成代码没有太多意义,因为没有编译时类型检查器来满足。代码生成在这些语言中经常被忽视,因为它们避免了显示的编译步骤。而且,对于动态生成的模式(例如从数据库表生成的Avro模式),代码生成对获取数据是一个不必要的障碍。
|
||||
|
||||
Avro为静态类型编程语言提供了可选的代码生成功能,但是它也可以在不生成任何代码的情况下使用。如果你有一个对象容器文件(它嵌入了作者的模式),你可以简单地使用Avro库打开它,并以与查看JSON文件相同的方式查看数据。该文件是自描述的,因为它包含所有必要的元数据。
|
||||
Avro为静态类型编程语言提供了可选的代码生成功能,但是它也可以在不生成任何代码的情况下使用。如果你有一个对象容器文件(它嵌入了Writer模式),你可以简单地使用Avro库打开它,并以与查看JSON文件相同的方式查看数据。该文件是自描述的,因为它包含所有必要的元数据。
|
||||
|
||||
这个属性特别适用于动态类型的数据处理语言如Apache Pig 【26】。在Pig中,您可以打开一些Avro文件,开始分析它们,并编写派生数据集以Avro格式输出文件,而无需考虑模式。
|
||||
|
||||
### 模式的优点
|
||||
|
||||
正如我们所看到的,Protocol Buffers,Thrift和Avro都使用模式来描述二进制编码格式。他们的模式语言比XML模式或者JSON模式简单得多,它支持更详细的验证规则(例如,“该字段的字符串值必须与该正则表达式匹配”或“该字段的整数值必须在0和100之间“)。由于Protocol Buffers,Thrift和Avro实现起来更简单,使用起来也更简单,所以它们已经发展到支持相当广泛的编程语言。
|
||||
正如我们所看到的,Protocol Buffers,Thrift和Avro都使用模式来描述二进制编码格式。他们的模式语言比XML模式或者JSON模式简单得多,而后者支持更详细的验证规则(例如,“该字段的字符串值必须与该正则表达式匹配”或“该字段的整数值必须在0和100之间“)。由于Protocol Buffers,Thrift和Avro实现起来更简单,使用起来也更简单,所以它们已经发展到支持相当广泛的编程语言。
|
||||
|
||||
这些编码所基于的想法绝不是新的。例如,它们与ASN.1有很多相似之处,它是1984年首次被标准化的模式定义语言【27】。它被用来定义各种网络协议,其二进制编码(DER)仍然被用于编码SSL证书(X.509),例如【28】。 ASN.1支持使用标签号码的模式演进,类似于Protocol Buffers和Thrift 【29】。然而,这也是非常复杂和严重的文件记录,所以ASN.1可能不是新应用程序的好选择。
|
||||
这些编码所基于的想法绝不是新的。例如,它们与ASN.1有很多相似之处,它是1984年首次被标准化的模式定义语言【27】。它被用来定义各种网络协议,例如其二进制编码(DER)仍然被用于编码SSL证书(X.509)【28】。 ASN.1支持使用标签号码的模式演进,类似于Protocol Buffers和Thrift 【29】。然而,它也非常复杂,而且没有好的配套文档,所以ASN.1可能不是新应用程序的好选择。
|
||||
|
||||
许多数据系统也为其数据实现某种专有的二进制编码。例如,大多数关系数据库都有一个网络协议,您可以通过该协议向数据库发送查询并获取响应。这些协议通常特定于特定的数据库,并且数据库供应商提供将来自数据库的网络协议的响应解码为内存数据结构的驱动程序(例如使用ODBC或JDBC API)。
|
||||
许多数据系统也为其数据实现了某种专有的二进制编码。例如,大多数关系数据库都有一个网络协议,您可以通过该协议向数据库发送查询并获取响应。这些协议通常特定于特定的数据库,并且数据库供应商提供将来自数据库的网络协议的响应解码为内存数据结构的驱动程序(例如使用ODBC或JDBC API)。
|
||||
|
||||
所以,我们可以看到,尽管JSON,XML和CSV等文本数据格式非常普遍,但基于模式的二进制编码也是一个可行的选择。他们有一些很好的属性:
|
||||
|
||||
* 它们可以比各种“二进制JSON”变体更紧凑,因为它们可以省略编码数据中的字段名称。
|
||||
* 模式是一种有价值的文档形式,因为模式是解码所必需的,所以可以确定它是最新的(而手动维护的文档可能很容易偏离现实)。
|
||||
* 保留模式数据库允许您在部署任何内容之前检查模式更改的向前和向后兼容性。
|
||||
* 维护一个模式的数据库允许您在部署任何内容之前检查模式更改的向前和向后兼容性。
|
||||
* 对于静态类型编程语言的用户来说,从模式生成代码的能力是有用的,因为它可以在编译时进行类型检查。
|
||||
|
||||
总而言之,模式进化允许与JSON数据库提供的无模式/模式读取相同的灵活性(请参阅第39页的“文档模型中的模式灵活性”),同时还可以更好地保证数据和更好的工具。
|
||||
总而言之,模式进化允许与JSON数据库提供的无模式/读时模式相同的灵活性(请参阅“[文档模型中的模式灵活性](ch2.md#文档模型中的模式灵活性)”),同时还可以更好地保证数据和更好的工具。
|
||||
|
||||
|
||||
|
||||
## 数据流的类型
|
||||
|
||||
在本章的开始部分,我们曾经说过,无论何时您想要将某些数据发送到不共享内存的另一个进程,例如,只要您想通过网络发送数据或将其写入文件,就需要将它编码为一个字节序列。然后我们讨论了做这个的各种不同的编码。
|
||||
|
||||
我们讨论了向前和向后的兼容性,这对于可演化性来说非常重要(通过允许您独立升级系统的不同部分,而不必一次改变所有内容,可以轻松地进行更改)。兼容性是编码数据的一个进程和解码它的另一个进程之间的一种关系。
|
||||
|
||||
这是一个相当抽象的概念 - 数据可以通过多种方式从一个流程流向另一个流程。谁编码数据,谁解码?在本章的其余部分中,我们将探讨数据如何在流程之间流动的一些最常见的方式:
|
||||
|
||||
* 通过数据库(参阅“[通过数据库的数据流](#通过数据库的数据流)”)
|
||||
* 通过服务调用(参阅“[通过服务传输数据流:REST和RPC](#通过服务传输数据流:REST和RPC)”)
|
||||
* 通过异步消息传递(参阅“[消息传递数据流](#消息传递数据流)”)
|
||||
* 通过数据库(参阅“[数据库中的数据流](#数据库中的数据流)”)
|
||||
* 通过服务调用(参阅“[服务中的数据流:REST和RPC](#服务中的数据流:REST和RPC)”)
|
||||
* 通过异步消息传递(参阅“[消息传递中的数据流](#消息传递中的数据流)”)
|
||||
|
||||
|
||||
|
||||
@ -335,15 +337,13 @@ Avro为静态类型编程语言提供了可选的代码生成功能,但是它
|
||||
|
||||
向后兼容性显然是必要的。否则你未来的自己将无法解码你以前写的东西。
|
||||
|
||||
一般来说,几个不同的进程同时访问数据库是很常见的。这些进程可能是几个不同的应用程序或服务,或者它们可能只是几个相同服务的实例(为了可伸缩性或容错性而并行运行)。无论哪种方式,在应用程序发生变化的环境中,访问数据库的某些进程可能会运行较新的代码,有些进程可能会运行较旧的代码,例如,因为新版本当前正在部署在滚动升级,所以有些实例已经更新,而其他实例尚未更新。
|
||||
一般来说,几个不同的进程同时访问数据库是很常见的。这些进程可能是几个不同的应用程序或服务,或者它们可能只是几个相同服务的实例(为了可伸缩性或容错性而并行运行)。无论哪种方式,在应用程序发生变化的环境中,访问数据库的某些进程可能会运行较新的代码,有些进程可能会运行较旧的代码,例如,因为新版本当前正在部署滚动升级,所以有些实例已经更新,而其他实例尚未更新。
|
||||
|
||||
这意味着数据库中的一个值可能会被更新版本的代码写入,然后被仍旧运行的旧版本的代码读取。因此,数据库也经常需要向前兼容。
|
||||
|
||||
但是,还有一个额外的障碍。假设您将一个字段添加到记录模式,并且较新的代码将该新字段的值写入数据库。随后,旧版本的代码(尚不知道新字段)将读取记录,更新记录并将其写回。在这种情况下,理想的行为通常是旧代码保持新的领域完整,即使它不能被解释。
|
||||
但是,还有一个额外的障碍。假设您将一个字段添加到记录模式,并且较新的代码将该新字段的值写入数据库。随后,旧版本的代码(尚不知道新字段)将读取记录,更新记录并将其写回。在这种情况下,理想的行为通常是旧代码保持新的字段不变,即使它不能被解释。
|
||||
|
||||
前面讨论的编码格式支持未知域的保存,但是有时候需要在应用程序层面保持谨慎,如图4-7所示。例如,如果将数据库值解码为应用程序中的模型对象,稍后重新编码这些模型对象,那么未知字段可能会在该翻译过程中丢失。
|
||||
|
||||
解决这个问题不是一个难题,你只需要意识到它。
|
||||
前面讨论的编码格式支持未知字段的保存,但是有时候需要在应用程序层面保持谨慎,如图4-7所示。例如,如果将数据库值解码为应用程序中的模型对象,稍后重新编码这些模型对象,那么未知字段可能会在该翻译过程中丢失。解决这个问题不是一个难题,你只需要意识到它。
|
||||
|
||||
![](img/fig4-7.png)
|
||||
|
||||
@ -355,21 +355,21 @@ Avro为静态类型编程语言提供了可选的代码生成功能,但是它
|
||||
|
||||
在部署应用程序的新版本时,也许用不了几分钟就可以将所有的旧版本替换为新版本(至少服务器端应用程序是这样的)。但数据库内容并非如此:对于五年前的数据来说,除非对其进行显式重写,否则它仍然会以原始编码形式存在。这种现象有时被概括为:数据的生命周期超出代码的生命周期。
|
||||
|
||||
将数据重写(迁移)到一个新的模式当然是可能的,但是在一个大数据集上执行是一个昂贵的事情,所以大多数数据库如果可能的话就避免它。大多数关系数据库都允许简单的模式更改,例如添加一个默认值为空的新列,而不重写现有数据[^v]读取旧行时,数据库将填充编码数据中缺少的任何列的空值在磁盘上。 LinkedIn的文档数据库Espresso使用Avro存储,允许它使用Avro的模式演变规则【23】。
|
||||
将数据重写(迁移)到一个新的模式当然是可能的,但是在一个大数据集上执行是一个昂贵的事情,所以大多数数据库如果可能的话就避免它。大多数关系数据库都允许简单的模式更改,例如添加一个默认值为空的新列,而不重写现有数据[^v]。读取旧行时,对于磁盘上的编码数据缺少的任何列,数据库将填充空值。 LinkedIn的文档数据库Espresso使用Avro存储,允许它使用Avro的模式演变规则【23】。
|
||||
|
||||
因此,架构演变允许整个数据库看起来好像是用单个模式编码的,即使底层存储可能包含用模式的各种历史版本编码的记录。
|
||||
因此,模式演变允许整个数据库看起来好像是用单个模式编码的,即使底层存储可能包含用各种历史版本的模式编码的记录。
|
||||
|
||||
[^v]: 除了MySQL,即使并非真的必要,它也经常会重写整个表,正如“[文档模型中的架构灵活性](ch3.md#文档模型中的灵活性)”中所提到的。
|
||||
[^v]: 除了MySQL,即使并非真的必要,它也经常会重写整个表,正如“[文档模型中的模式灵活性](ch3.md#文档模型中的模式灵活性)”中所提到的。
|
||||
|
||||
|
||||
|
||||
#### 归档存储
|
||||
|
||||
也许您不时为数据库创建一个快照,例如备份或加载到数据仓库(参阅“[数据仓库](ch3.md#数据仓库)”)。在这种情况下,即使源数据库中的原始编码包含来自不同时代的模式版本的混合,数据转储通常也将使用最新模式进行编码。既然你正在复制数据,那么你可能会一直对数据的副本进行编码。
|
||||
也许您不时为数据库创建一个快照,例如备份或加载到数据仓库(参阅“[数据仓库](ch3.md#数据仓库)”)。在这种情况下,即使源数据库中的原始编码包含来自不同时代的模式版本的混合,数据转储通常也将使用最新模式进行编码。既然你不管怎样都要拷贝数据,那么你可以对这个数据拷贝进行一致的编码。
|
||||
|
||||
由于数据转储是一次写入的,而且以后是不可变的,所以Avro对象容器文件等格式非常适合。这也是一个很好的机会,可以将数据编码为面向分析的列式格式,例如Parquet(请参阅第97页的“[列压缩](ch3.md#列压缩)”)。
|
||||
由于数据转储是一次写入的,而且以后是不可变的,所以Avro对象容器文件等格式非常适合。这也是一个很好的机会,可以将数据编码为面向分析的列式格式,例如Parquet(请参阅“[列压缩](ch3.md#列压缩)”)。
|
||||
|
||||
在[第10章](ch10.md)中,我们将详细讨论在档案存储中使用数据。
|
||||
在[第10章](ch10.md)中,我们将详细讨论使用档案存储中的数据。
|
||||
|
||||
|
||||
|
||||
@ -377,15 +377,15 @@ Avro为静态类型编程语言提供了可选的代码生成功能,但是它
|
||||
|
||||
当您需要通过网络进行通信的进程时,安排该通信的方式有几种。最常见的安排是有两个角色:客户端和服务器。服务器通过网络公开API,并且客户端可以连接到服务器以向该API发出请求。服务器公开的API被称为服务。
|
||||
|
||||
Web以这种方式工作:客户(Web浏览器)向Web服务器发出请求,使GET请求下载HTML,CSS,JavaScript,图像等,并向POST请求提交数据到服务器。 API包含一组标准的协议和数据格式(HTTP,URL,SSL/TLS,HTML等)。由于网络浏览器,网络服务器和网站作者大多同意这些标准,您可以使用任何网络浏览器访问任何网站(至少在理论上!)。
|
||||
Web以这种方式工作:客户(Web浏览器)向Web服务器发出请求,通过GET请求下载HTML,CSS,JavaScript,图像等,并通过POST请求提交数据到服务器。 API包含一组标准的协议和数据格式(HTTP,URL,SSL/TLS,HTML等)。由于网络浏览器,网络服务器和网站作者大多同意这些标准,您可以使用任何网络浏览器访问任何网站(至少在理论上!)。
|
||||
|
||||
Web浏览器不是唯一的客户端类型。例如,在移动设备或桌面计算机上运行的本地应用程序也可以向服务器发出网络请求,并且在Web浏览器内运行的客户端JavaScript应用程序可以使用XMLHttpRequest成为HTTP客户端(该技术被称为Ajax 【30】)。在这种情况下,服务器的响应通常不是用于显示给人的HTML,而是用于便于客户端应用程序代码(如JSON)进一步处理的编码数据。尽管HTTP可能被用作传输协议,但顶层实现的API是特定于应用程序的,客户端和服务器需要就该API的细节达成一致。
|
||||
Web浏览器不是唯一的客户端类型。例如,在移动设备或桌面计算机上运行的本地应用程序也可以向服务器发出网络请求,并且在Web浏览器内运行的客户端JavaScript应用程序可以使用XMLHttpRequest成为HTTP客户端(该技术被称为Ajax 【30】)。在这种情况下,服务器的响应通常不是用于显示给人的HTML,而是便于客户端应用程序代码进一步处理的编码数据(如JSON)。尽管HTTP可能被用作传输协议,但顶层实现的API是特定于应用程序的,客户端和服务器需要就该API的细节达成一致。
|
||||
|
||||
此外,服务器本身可以是另一个服务的客户端(例如,典型的Web应用服务器充当数据库的客户端)。这种方法通常用于将大型应用程序按照功能区域分解为较小的服务,这样当一个服务需要来自另一个服务的某些功能或数据时,就会向另一个服务发出请求。这种构建应用程序的方式传统上被称为**面向服务的体系结构(service-oriented architecture,SOA)**,最近被改进和更名为**微服务架构**【31,32】。
|
||||
|
||||
在某些方面,服务类似于数据库:它们通常允许客户端提交和查询数据。但是,虽然数据库允许使用我们在[第2章](./ch2.md)中讨论的查询语言进行任意查询,但是服务公开了一个特定于应用程序的API,它只允许由服务的业务逻辑(应用程序代码)预定的输入和输出【33】。这种限制提供了一定程度的封装:服务可以对客户可以做什么和不可以做什么施加细粒度的限制。
|
||||
在某些方面,服务类似于数据库:它们通常允许客户端提交和查询数据。但是,虽然数据库允许使用我们在[第2章](./ch2.md)中讨论的查询语言进行任意查询,但是服务公开了一个特定于应用程序的API,它只允许由服务的业务逻辑(应用程序代码)预定的输入和输出【33】。这种限制提供了一定程度的封装:服务能够对客户可以做什么和不可以做什么施加细粒度的限制。
|
||||
|
||||
面向服务/微服务架构的一个关键设计目标是通过使服务独立部署和演化来使应用程序更易于更改和维护。例如,每个服务应该由一个团队拥有,并且该团队应该能够经常发布新版本的服务,而不必与其他团队协调。换句话说,我们应该期望服务器和客户端的旧版本和新版本同时运行,因此服务器和客户端使用的数据编码必须在不同版本的服务API之间兼容——正是我们所做的本章一直在谈论。
|
||||
面向服务/微服务架构的一个关键设计目标是通过使服务独立部署和演化来使应用程序更易于更改和维护。例如,每个服务应该由一个团队拥有,并且该团队应该能够经常发布新版本的服务,而不必与其他团队协调。换句话说,我们应该期望服务器和客户端的旧版本和新版本同时运行,因此服务器和客户端使用的数据编码必须在不同版本的服务API之间兼容——这正是我们在本章所一直在谈论的。
|
||||
|
||||
#### Web服务
|
||||
|
||||
@ -395,15 +395,19 @@ Web浏览器不是唯一的客户端类型。例如,在移动设备或桌面
|
||||
2. 一种服务向同一组织拥有的另一项服务提出请求,这些服务通常位于同一数据中心内,作为面向服务/微型架构的一部分。 (支持这种用例的软件有时被称为 **中间件(middleware)** )
|
||||
3. 一种服务通过互联网向不同组织所拥有的服务提出请求。这用于不同组织后端系统之间的数据交换。此类别包括由在线服务(如信用卡处理系统)提供的公共API,或用于共享访问用户数据的OAuth。
|
||||
|
||||
有两种流行的Web服务方法:REST和SOAP。他们在哲学方面几乎是截然相反的,往往是各自支持者之间的激烈辩论(即使在每个阵营内也有很多争论。 例如,**HATEOAS(超媒体作为应用程序状态的引擎)** 经常引发讨论【35】。)
|
||||
有两种流行的Web服务方法:REST和SOAP。他们在哲学方面几乎是截然相反的,往往也是各自支持者之间的激烈辩论的主题[^vi]。
|
||||
|
||||
[^vi]: 即使在每个阵营内也有很多争论。 例如,**HATEOAS(超媒体作为应用程序状态的引擎)** 就经常引发讨论【35】。
|
||||
|
||||
REST不是一个协议,而是一个基于HTTP原则的设计哲学【34,35】。它强调简单的数据格式,使用URL来标识资源,并使用HTTP功能进行缓存控制,身份验证和内容类型协商。与SOAP相比,REST已经越来越受欢迎,至少在跨组织服务集成的背景下【36】,并经常与微服务相关[31]。根据REST原则设计的API称为RESTful。
|
||||
|
||||
相比之下,SOAP是用于制作网络API请求的基于XML的协议( 尽管首字母缩写词相似,SOAP并不是SOA的要求。 SOAP是一种特殊的技术,而SOA是构建系统的一般方法。)。虽然它最常用于HTTP,但其目的是独立于HTTP,并避免使用大多数HTTP功能。相反,它带有庞大而复杂的多种相关标准(Web服务框架,称为`WS-*`),它们增加了各种功能【37】。
|
||||
相比之下,SOAP是用于制作网络API请求的基于XML的协议[^vii]。虽然它最常用于HTTP,但其目的是独立于HTTP,并避免使用大多数HTTP功能。相反,它带有庞大而复杂的多种相关标准(Web服务框架,称为`WS-*`),它们增加了各种功能【37】。
|
||||
|
||||
SOAP Web服务的API使用称为Web服务描述语言(WSDL)的基于XML的语言来描述。 WSDL支持代码生成,客户端可以使用本地类和方法调用(编码为XML消息并由框架再次解码)访问远程服务。这在静态类型编程语言中非常有用,但在动态类型编程语言中很少(参阅“[代码生成和动态类型化语言](#代码生成和动态类型化语言)”)。
|
||||
[^vii]: 尽管首字母缩写词相似,SOAP并不是SOA的要求。 SOAP是一种特殊的技术,而SOA是构建系统的一般方法。
|
||||
|
||||
由于WSDL的设计不是人类可读的,而且由于SOAP消息通常是手动构建的过于复杂,所以SOAP的用户在很大程度上依赖于工具支持,代码生成和IDE【38】。对于SOAP供应商不支持的编程语言的用户来说,与SOAP服务的集成是困难的。
|
||||
SOAP Web服务的API使用称为Web服务描述语言(WSDL)的基于XML的语言来描述。 WSDL支持代码生成,客户端可以使用本地类和方法调用(编码为XML消息并由框架再次解码)访问远程服务。这在静态类型编程语言中非常有用,但在动态类型编程语言中很少(参阅“[代码生成和动态类型的语言](#代码生成和动态类型的语言)”)。
|
||||
|
||||
由于WSDL的设计不是人类可读的,而且由于SOAP消息通常因为过于复杂而无法手动构建,所以SOAP的用户在很大程度上依赖于工具支持,代码生成和IDE【38】。对于SOAP供应商不支持的编程语言的用户来说,与SOAP服务的集成是困难的。
|
||||
|
||||
尽管SOAP及其各种扩展表面上是标准化的,但是不同厂商的实现之间的互操作性往往会造成问题【39】。由于所有这些原因,尽管许多大型企业仍然使用SOAP,但在大多数小公司中已经不再受到青睐。
|
||||
|
||||
@ -415,11 +419,11 @@ Web服务仅仅是通过网络进行API请求的一系列技术的最新版本
|
||||
|
||||
所有这些都是基于 **远程过程调用(RPC)** 的思想,该过程调用自20世纪70年代以来一直存在【42】。 RPC模型试图向远程网络服务发出请求,看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明)。尽管RPC起初看起来很方便,但这种方法根本上是有缺陷的【43,44】。网络请求与本地函数调用非常不同:
|
||||
|
||||
* 本地函数调用是可预测的,并且成功或失败,这仅取决于受您控制的参数。网络请求是不可预知的:由于网络问题,请求或响应可能会丢失,或者远程计算机可能很慢或不可用,这些问题完全不在您的控制范围之内。网络问题是常见的,所以你必须预测他们,例如通过重试失败的请求。
|
||||
* 本地函数调用是可预测的,并且成功或失败仅取决于受您控制的参数。网络请求是不可预知的:由于网络问题,请求或响应可能会丢失,或者远程计算机可能很慢或不可用,这些问题完全不在您的控制范围之内。网络问题是常见的,所以你必须预测他们,例如通过重试失败的请求。
|
||||
* 本地函数调用要么返回结果,要么抛出异常,或者永远不返回(因为进入无限循环或进程崩溃)。网络请求有另一个可能的结果:由于超时,它可能会返回没有结果。在这种情况下,你根本不知道发生了什么:如果你没有得到来自远程服务的响应,你无法知道请求是否通过。 (我们将在[第8章](ch8.md)更详细地讨论这个问题。)
|
||||
* 如果您重试失败的网络请求,可能会发生请求实际上正在通过,只有响应丢失。在这种情况下,重试将导致该操作被执行多次,除非您在协议中引入除重( **幂等(idempotence)**)机制。本地函数调用没有这个问题。 (在[第十一章](ch11.md)更详细地讨论幂等性)
|
||||
* 每次调用本地功能时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也是非常可变的:在不到一毫秒的时间内它可能会完成,但是当网络拥塞或者远程服务超载时,可能需要几秒钟的时间完成一样的东西。
|
||||
* 调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。没关系,如果参数是像数字或字符串这样的基本类型,但是对于较大的对象很快就会变成问题。
|
||||
* 如果您重试失败的网络请求,可能会发生请求实际上正在通过,只有响应丢失。在这种情况下,重试将导致该操作被执行多次,除非您在协议中引入除重( **幂等(idempotence)**)机制。本地函数调用没有这个问题。 (在[第11章](ch11.md)更详细地讨论幂等性)
|
||||
* 每次调用本地功能时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也是非常可变的:好的时候它可能会在不到一毫秒的时间内完成,但是当网络拥塞或者远程服务超载时,可能需要几秒钟的时间完成一样的东西。
|
||||
* 调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。如果参数是像数字或字符串这样的基本类型倒是没关系,但是对于较大的对象很快就会变成问题。
|
||||
|
||||
客户端和服务可以用不同的编程语言实现,所以RPC框架必须将数据类型从一种语言翻译成另一种语言。这可能会捅出大篓子,因为不是所有的语言都具有相同的类型 —— 例如回想一下JavaScript的数字大于$2^{53}$的问题(参阅“[JSON,XML和二进制变体](#JSON,XML和二进制变体)”)。用单一语言编写的单个进程中不存在此问题。
|
||||
|
||||
@ -429,17 +433,19 @@ Web服务仅仅是通过网络进行API请求的一系列技术的最新版本
|
||||
|
||||
尽管有这样那样的问题,RPC不会消失。在本章提到的所有编码的基础上构建了各种RPC框架:例如,Thrift和Avro带有RPC支持,gRPC是使用Protocol Buffers的RPC实现,Finagle也使用Thrift,Rest.li使用JSON over HTTP。
|
||||
|
||||
这种新一代的RPC框架更加明确的是,远程请求与本地函数调用不同。例如,Finagle和Rest.li 使用futures(promises)来封装可能失败的异步操作。`Futures`还可以简化需要并行发出多项服务的情况,并将其结果合并【45】。 gRPC支持流,其中一个调用不仅包括一个请求和一个响应,还包括一系列的请求和响应【46】。
|
||||
这种新一代的RPC框架更加明确的是,远程请求与本地函数调用不同。例如,Finagle和Rest.li 使用futures(promises)来封装可能失败的异步操作。`Futures`还可以简化需要并行发出多项服务并将其结果合并的情况【45】。 gRPC支持流,其中一个调用不仅包括一个请求和一个响应,还可以是随时间的一系列请求和响应【46】。
|
||||
|
||||
其中一些框架还提供服务发现,即允许客户端找出在哪个IP地址和端口号上可以找到特定的服务。我们将在“[请求路由](ch6.md#请求路由)”中回到这个主题。
|
||||
|
||||
使用二进制编码格式的自定义RPC协议可以实现比通用的JSON over REST更好的性能。但是,RESTful API还有其他一些显著的优点:对于实验和调试(只需使用Web浏览器或命令行工具curl,无需任何代码生成或软件安装即可向其请求),它是受支持的所有的主流编程语言和平台,还有大量可用的工具(服务器,缓存,负载平衡器,代理,防火墙,监控,调试工具,测试工具等)的生态系统。由于这些原因,REST似乎是公共API的主要风格。 RPC框架的主要重点在于同一组织拥有的服务之间的请求,通常在同一数据中心内。
|
||||
使用二进制编码格式的自定义RPC协议可以实现比通用的JSON over REST更好的性能。但是,RESTful API还有其他一些显著的优点:方便实验和调试(只需使用Web浏览器或命令行工具curl,无需任何代码生成或软件安装即可向其请求),能被所有主流的编程语言和平台所支持,还有大量可用的工具(服务器,缓存,负载平衡器,代理,防火墙,监控,调试工具,测试工具等)的生态系统。
|
||||
|
||||
由于这些原因,REST似乎是公共API的主要风格。 RPC框架的主要重点在于同一组织拥有的服务之间的请求,通常在同一数据中心内。
|
||||
|
||||
#### 数据编码与RPC的演化
|
||||
|
||||
对于可演化性,重要的是可以独立更改和部署RPC客户端和服务器。与通过数据库流动的数据相比(如上一节所述),我们可以在通过服务进行数据流的情况下做一个简化的假设:假定所有的服务器都会先更新,其次是所有的客户端。因此,您只需要在请求上具有向后兼容性,并且对响应具有前向兼容性。
|
||||
|
||||
RPC方案的前后向兼容性属性从它使用的编码方式中继承
|
||||
RPC方案的前后向兼容性属性从它使用的编码方式中继承:
|
||||
|
||||
* Thrift,gRPC(Protobuf)和Avro RPC可以根据相应编码格式的兼容性规则进行演变。
|
||||
* 在SOAP中,请求和响应是使用XML模式指定的。这些可以演变,但有一些微妙的陷阱【47】。
|
||||
@ -469,27 +475,27 @@ RPC方案的前后向兼容性属性从它使用的编码方式中继承
|
||||
|
||||
过去,**消息代理(Message Broker)**主要是TIBCO,IBM WebSphere和webMethods等公司的商业软件的秀场。最近像RabbitMQ,ActiveMQ,HornetQ,NATS和Apache Kafka这样的开源实现已经流行起来。我们将在[第11章](ch11.md)中对它们进行更详细的比较。
|
||||
|
||||
详细的交付语义因实现和配置而异,但通常情况下,消息代理的使用方式如下:一个进程将消息发送到指定的队列或主题,代理确保将消息传递给一个或多个消费者或订阅者到那个队列或主题。在同一主题上可以有许多生产者和许多消费者。
|
||||
详细的交付语义因实现和配置而异,但通常情况下,消息代理的使用方式如下:一个进程将消息发送到指定的队列或主题,代理确保将消息传递给那个队列或主题的一个或多个消费者或订阅者。在同一主题上可以有许多生产者和许多消费者。
|
||||
|
||||
一个主题只提供单向数据流。但是,消费者本身可能会将消息发布到另一个主题上(因此,可以将它们链接在一起,就像我们将在[第11章](ch11.md)中看到的那样),或者发送给原始消息的发送者使用的回复队列(允许请求/响应数据流,类似于RPC)。
|
||||
|
||||
消息代理通常不会执行任何特定的数据模型 - 消息只是包含一些元数据的字节序列,因此您可以使用任何编码格式。如果编码是向后兼容的,则您可以灵活地更改发行商和消费者的独立编码,并以任意顺序进行部署。
|
||||
消息代理通常不会执行任何特定的数据模型 - 消息只是包含一些元数据的字节序列,因此您可以使用任何编码格式。如果编码是向后和向前兼容的,您可以灵活地对发布者和消费者的编码进行独立的修改,并以任意顺序进行部署。
|
||||
|
||||
如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题([图4-7](img/fig4-7.png))。
|
||||
|
||||
#### 分布式的Actor框架
|
||||
|
||||
Actor模型是单个进程中并发的编程模型。逻辑被封装在角色中,而不是直接处理线程(以及竞争条件,锁定和死锁的相关问题)。每个角色通常代表一个客户或实体,它可能有一些本地状态(不与其他任何角色共享),它通过发送和接收异步消息与其他角色通信。消息传送不保证:在某些错误情况下,消息将丢失。由于每个角色一次只能处理一条消息,因此不需要担心线程,每个角色可以由框架独立调度。
|
||||
Actor模型是单个进程中并发的编程模型。逻辑被封装在actor中,而不是直接处理线程(以及竞争条件,锁定和死锁的相关问题)。每个actor通常代表一个客户或实体,它可能有一些本地状态(不与其他任何角色共享),它通过发送和接收异步消息与其他角色通信。消息传送不保证:在某些错误情况下,消息将丢失。由于每个角色一次只能处理一条消息,因此不需要担心线程,每个角色可以由框架独立调度。
|
||||
|
||||
在分布式Actor框架中,此编程模型用于跨多个节点伸缩应用程序。不管发送方和接收方是在同一个节点上还是在不同的节点上,都使用相同的消息传递机制。如果它们在不同的节点上,则该消息被透明地编码成字节序列,通过网络发送,并在另一侧解码。
|
||||
|
||||
位置透明在actor模型中比在RPC中效果更好,因为actor模型已经假定消息可能会丢失,即使在单个进程中也是如此。尽管网络上的延迟可能比同一个进程中的延迟更高,但是在使用参与者模型时,本地和远程通信之间的基本不匹配是较少的。
|
||||
位置透明在actor模型中比在RPC中效果更好,因为actor模型已经假定消息可能会丢失,即使在单个进程中也是如此。尽管网络上的延迟可能比同一个进程中的延迟更高,但是在使用actor模型时,本地和远程通信之间的基本不匹配是较少的。
|
||||
|
||||
分布式的Actor框架实质上是将消息代理和角色编程模型集成到一个框架中。但是,如果要执行基于角色的应用程序的滚动升级,则仍然需要担心向前和向后兼容性问题,因为消息可能会从运行新版本的节点发送到运行旧版本的节点,反之亦然。
|
||||
分布式的Actor框架实质上是将消息代理和actor编程模型集成到一个框架中。但是,如果要执行基于actor的应用程序的滚动升级,则仍然需要担心向前和向后兼容性问题,因为消息可能会从运行新版本的节点发送到运行旧版本的节点,反之亦然。
|
||||
|
||||
三个流行的分布式actor框架处理消息编码如下:
|
||||
|
||||
* 默认情况下,Akka使用Java的内置序列化,不提供前向或后向兼容性。 但是,你可以用类似缓冲区的东西替代它,从而获得滚动升级的能力【50】。
|
||||
* 默认情况下,Akka使用Java的内置序列化,不提供前向或后向兼容性。 但是,你可以用类似Prototol Buffers的东西替代它,从而获得滚动升级的能力【50】。
|
||||
* Orleans 默认使用不支持滚动升级部署的自定义数据编码格式; 要部署新版本的应用程序,您需要设置一个新的群集,将流量从旧群集迁移到新群集,然后关闭旧群集【51,52】。 像Akka一样,可以使用自定义序列化插件。
|
||||
* 在Erlang OTP中,对记录模式进行更改是非常困难的(尽管系统具有许多为高可用性设计的功能)。 滚动升级是可能的,但需要仔细计划【53】。 一个新的实验性的`maps`数据类型(2014年在Erlang R17中引入的类似于JSON的结构)可能使得这个数据类型在未来更容易【54】。
|
||||
|
||||
@ -498,7 +504,7 @@ Actor模型是单个进程中并发的编程模型。逻辑被封装在角色中
|
||||
|
||||
## 本章小结
|
||||
|
||||
在本章中,我们研究了将数据结构转换为网络中的字节或磁盘上的字节的几种方法。我们看到了这些编码的细节不仅影响其效率,更重要的是应用程序的体系结构和部署它们的选项。
|
||||
在本章中,我们研究了将数据结构转换为网络中的字节或磁盘上的字节的几种方法。我们看到了这些编码的细节不仅影响其效率,更重要的是也影响了应用程序的体系结构和部署它们的选项。
|
||||
|
||||
特别是,许多服务需要支持滚动升级,其中新版本的服务逐步部署到少数节点,而不是同时部署到所有节点。滚动升级允许在不停机的情况下发布新版本的服务(从而鼓励在罕见的大型版本上频繁发布小型版本),并使部署风险降低(允许在影响大量用户之前检测并回滚有故障的版本)。这些属性对于可演化性,以及对应用程序进行更改的容易性都是非常有利的。
|
||||
|
||||
@ -510,7 +516,7 @@ Actor模型是单个进程中并发的编程模型。逻辑被封装在角色中
|
||||
* JSON,XML和CSV等文本格式非常普遍,其兼容性取决于您如何使用它们。他们有可选的模式语言,这有时是有用的,有时是一个障碍。这些格式对于数据类型有些模糊,所以你必须小心数字和二进制字符串。
|
||||
* 像Thrift,Protocol Buffers和Avro这样的二进制模式驱动格式允许使用清晰定义的前向和后向兼容性语义进行紧凑,高效的编码。这些模式可以用于静态类型语言的文档和代码生成。但是,他们有一个缺点,就是在数据可读之前需要对数据进行解码。
|
||||
|
||||
我们还讨论了数据流的几种模式,说明了数据编码是重要的不同场景:
|
||||
我们还讨论了数据流的几种模式,说明了数据编码重要性的不同场景:
|
||||
|
||||
* 数据库,写入数据库的进程对数据进行编码,并从数据库读取进程对其进行解码
|
||||
* RPC和REST API,客户端对请求进行编码,服务器对请求进行解码并对响应进行编码,客户端最终对响应进行解码
|
||||
|
2
ch5.md
2
ch5.md
@ -31,7 +31,7 @@
|
||||
每一次向数据库的写入操作都需要传播到所有副本上,否则副本就会包含不一样的数据。最常见的解决方案被称为 **基于领导者的复制(leader-based replication)** (也称 **主动/被动(active/passive)** 或 **主/从(master/slave)** 复制),如[图5-1](#fig5-1.png)所示。它的工作原理如下:
|
||||
|
||||
1. 副本之一被指定为 **领导者(leader)**,也称为 **主库(master|primary)** 。当客户端要向数据库写入时,它必须将请求发送给**领导者**,领导者会将新数据写入其本地存储。
|
||||
2. 其他副本被称为**追随者(followers)**,亦称为**只读副本(read replicas)**,**从库(slaves)**,**备库( sencondaries)**,**热备(hot-standby)**[^i]。每当领导者将新数据写入本地存储时,它也会将数据变更发送给所有的追随者,称之为**复制日志(replication log)**记录或**变更流(change stream)**。每个跟随者从领导者拉取日志,并相应更新其本地数据库副本,方法是按照领导者处理的相同顺序应用所有写入。
|
||||
2. 其他副本被称为**追随者(followers)**,亦称为**只读副本(read replicas)**,**从库(slaves)**,**备库( secondaries)**,**热备(hot-standby)**[^i]。每当领导者将新数据写入本地存储时,它也会将数据变更发送给所有的追随者,称之为**复制日志(replication log)**记录或**变更流(change stream)**。每个跟随者从领导者拉取日志,并相应更新其本地数据库副本,方法是按照领导者处理的相同顺序应用所有写入。
|
||||
3. 当客户想要从数据库中读取数据时,它可以向领导者或追随者查询。 但只有领导者才能接受写操作(从客户端的角度来看从库都是只读的)。
|
||||
|
||||
[^i]: 不同的人对 **热(hot)**,**温(warm)**,**冷(cold)** 备份服务器有不同的定义。 例如在PostgreSQL中,**热备(hot standby)** 指的是能接受客户端读请求的副本。而 **温备(warm standby)** 只是追随领导者,但不处理客户端的任何查询。 就本书而言,这些差异并不重要。
|
||||
|
@ -25,7 +25,7 @@
|
||||
|
||||
### 伸缩至更高的载荷
|
||||
|
||||
如果你需要的只是伸缩至更高的**载荷(load)**,最简单的方法就是购买更强大的机器(有时称为**垂直伸缩(vertical scaling)**或**向上伸缩(scale up)**)。许多处理器,内存和磁盘可以在同一个操作系统下相互连接,快速的相互连接允许任意处理器访问内存或磁盘的任意部分。在这种 **共享内存架构(shared-memory architecture)** 中,所有的组件都可以看作一台单独的机器。
|
||||
如果你需要的只是伸缩至更高的**载荷(load)**,最简单的方法就是购买更强大的机器(有时称为**垂直伸缩(vertical scaling)**或**向上伸缩(scale up)**)。许多处理器,内存和磁盘可以在同一个操作系统下相互连接,快速的相互连接允许任意处理器访问内存或磁盘的任意部分。在这种 **共享内存架构(shared-memory architecture)** 中,所有的组件都可以看作一台单独的机器[^i]。
|
||||
|
||||
[^i]: 在大型机中,尽管任意处理器都可以访问内存的任意部分,但总有一些内存区域与一些处理器更接近(称为**非均匀内存访问(nonuniform memory access, NUMA)**【1】)。 为了有效利用这种架构特性,需要对处理进行细分,以便每个处理器主要访问临近的内存,这意味着即使表面上看起来只有一台机器在运行,**分区(partitioning)**仍然是必要的。
|
||||
|
||||
|
@ -19,7 +19,7 @@
|
||||
|
||||
幸运的是,在技术迅速变化的背后总是存在一些持续成立的原则,无论您使用了特定工具的哪个版本。如果您理解了这些原则,就可以领会这些工具的适用场景,如何充分利用它们,以及如何避免其中的陷阱。这正是本书的初衷。
|
||||
|
||||
本书的目标是帮助您在飞速变化的数据处理和数据存储技术大观园中找到方向。本书并不是某个特定工具的教程,也不是一本充满枯燥理论的教科书。相反,我们将看到一些成功数据系统的样例:许多流行应用每天都要在生产中会满足可伸缩性、性能、以及可靠性的要求,而这些技术构成了这些应用的基础。
|
||||
本书的目标是帮助您在飞速变化的数据处理和数据存储技术大观园中找到方向。本书并不是某个特定工具的教程,也不是一本充满枯燥理论的教科书。相反,我们将看到一些成功数据系统的样例:许多流行应用每天都要在生产中满足可伸缩性、性能、以及可靠性的要求,而这些技术构成了这些应用的基础。
|
||||
|
||||
我们将深入这些系统的内部,理清它们的关键算法,讨论背后的原则和它们必须做出的权衡。在这个过程中,我们将尝试寻找**思考**数据系统的有效方式 —— 不仅关于它们**如何**工作,还包括它们**为什么**以这种方式工作,以及哪些问题是我们需要问的。
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user