diff --git a/ch01.md b/ch01.md index 70d11ff..8f900d8 100644 --- a/ch01.md +++ b/ch01.md @@ -162,7 +162,7 @@ 1. **吞吐量(throughput)**:每秒可以处理的单位数据量,通常记为 QPS。 2. **响应时间(response time)**:从用户侧观察到的发出请求到收到回复的时间。 -3. **延迟(latency)**:日常中,延迟经常和响应时间混用指代响应时间;但严格来说,延迟只是指请求过程中排队等休眠时间,虽然其在响应时间中一般占大头;但只有我们把请求真正处理耗时认为是瞬时,延迟才能等同于响应时间。 +3. **延迟(latency)**:日常中,延迟经常和响应时间混用指代响应时间;但严格来说,延迟只是指请求过程中排队等待时间,虽然其在响应时间中一般占大头;但只有我们把请求真正处理耗时认为是瞬时,延迟才能等同于响应时间。 响应时间通常以百分位点来衡量,比如 p95,p99 和 p999,它们意味着 95%,99%或 99.9% 的请求都能在该阈值内完成。在实际中,通常使用滑动窗口滚动计算最近一段时间的响应时间分布,并通常以折线图或者柱状图进行呈现。 @@ -270,7 +270,7 @@ 否则,需求一定是不断在变,引起变化的原因多种多样: -1. 对问题阈了解更全面 +1. 对问题域了解更全面 2. 出现了之前未考虑到的用例 3. 商业策略的改变 4. 客户爸爸要求新功能 diff --git a/ch02.md b/ch02.md index e8e4610..cb4c316 100644 --- a/ch02.md +++ b/ch02.md @@ -160,7 +160,7 @@ network model 是 hierarchical model 的一种扩展:允许一个节点有多 在关系模型中,数据被组织成**元组(tuples)**,进而集合成**关系(relations)**;在 SQL 中分别对应行(rows)和表(tables)。 -- 不知道大家好奇过没,明明看起来更像表模型,为什叫**关系模型**? +- 不知道大家好奇过没,明明看起来更像表模型,为什么叫**关系模型**? 表只是一种实现。 关系(relation)的说法来自集合论,指的是几个集合的笛卡尔积的子集。 R ⊆ (D1×D2×D3 ··· ×Dn) @@ -419,7 +419,7 @@ db.observations.aggregate([ | 知识图谱 | 概念是点,关联关系是边 | 启发式问答 | > 同构(homogeneous)数据和异构数据 -> 图模型中的点变可以像关系中的表一样都具有相同类型;但是,一张图中的点和变也可以具有不同类型,能够容纳异构数据是图模型善于处理多对多关系的一大原因。 +> 图模型中的点边可以像关系中的表一样都具有相同类型;但是,一张图中的点和边也可以具有不同类型,能够容纳异构数据是图模型善于处理多对多关系的一大原因。 本节都会以下图为例,它表示了一对夫妇,来自美国爱达荷州的 Lucy 和来自法国 的 Alain:他们已婚,住在伦敦。 @@ -663,7 +663,7 @@ SELECT ?personName WHERE { } ``` -他是 Cypher 的前驱,因此结构看起来很像: +它是 Cypher 的前驱,因此结构看起来很像: ``` (person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (location) # Cypher @@ -683,7 +683,7 @@ SELECT ?personName WHERE { 图模型是网络模型旧瓶装新酒吗? -否,他们在很多重要的方面都不一样。 +否,它们在很多重要的方面都不一样。 | 模型 | 图模型(Graph Model) | 网络模型(Network Model) | | -------- | --------------------------------------------------------- | ------------------------------------------------------ | diff --git a/ch03.md b/ch03.md index 9c05420..e2252cd 100644 --- a/ch03.md +++ b/ch03.md @@ -25,7 +25,7 @@ # 驱动数据库的底层数据结构 -本节由一个 shell 脚本出发,到一个相当简单但可用的存储引擎 Bitcask,然后引出 LSM-tree,他们都属于日志流范畴。之后转向存储引擎另一流派——B 族树,之后对其做了简单对比。最后探讨了存储中离不开的结构——索引。 +本节由一个 shell 脚本出发,到一个相当简单但可用的存储引擎 Bitcask,然后引出 LSM-tree,它们都属于日志流范畴。之后转向存储引擎另一流派——B 族树,之后对其做了简单对比。最后探讨了存储中离不开的结构——索引。 首先来看,世界上“最简单”的数据库,由两个 Bash 函数构成: @@ -76,7 +76,7 @@ $ db_get 42 ![ddia-3-1-hash-map-csv.png](img/ch03-fig01.png) -看来很简单,但这正是 [Bitcask](https://docs.riak.com/riak/kv/2.2.3/setup/planning/backend/bitcask/index.html 'Bitcask') 的基本设计,但关键是,他 Work(在小数据量时,即所有 key 都能存到内存中时):能提供很高的读写性能: +看来很简单,但这正是 [Bitcask](https://docs.riak.com/riak/kv/2.2.3/setup/planning/backend/bitcask/index.html 'Bitcask') 的基本设计,但关键是,它 Work(在小数据量时,即所有 key 都能存到内存中时):能提供很高的读写性能: 1. 写:文件追加写。 2. 读:一次内存查询,一次磁盘 seek;如果数据已经被缓存,则 seek 也可以省掉。 @@ -97,7 +97,7 @@ $ db_get 42 4. **记录写坏、少写**。系统任何时候都有可能宕机,由此会造成记录写坏、少写。为了识别错误记录,我们需要增加一些校验字段,以识别并跳过这种数据。为了跳过写了部分的数据,还要用一些特殊字符来标识记录间的边界。 5. **并发控制**。由于只有一个活动(追加)文件,因此写只有一个天然并发度。但其他的文件都是不可变的(compact 时会读取然后生成新的),因此读取和紧缩可以并发执行。 -乍一看,基于日志的存储结构存在折不少浪费:需要以追加进行更新和删除。但日志结构有几个原地更新结构无法做的优点: +乍一看,基于日志的存储结构存在不少浪费:需要以追加进行更新和删除。但日志结构有几个原地更新结构无法做的优点: 1. **以顺序写代替随机写**。对于磁盘和 SSD,顺序写都要比随机写快几个数量级。 2. **简易的并发控制**。由于大部分的文件都是**不可变(immutable)** 的,因此更容易做并发读取和紧缩。也不用担心原地更新会造成新老数据交替。 @@ -278,7 +278,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 前述索引只提供全字段的精确匹配,而不提供类似搜索引擎的功能。比如,按字符串中包含的单词查询,针对笔误的单词查询。 -在工程中常用 [Apace Lucene](https://lucene.apache.org/ 'Apace Lucene') 库,和其包装出来的服务:[Elasticsearch](https://www.elastic.co/cn/ 'Elasticsearch')。他也使用类似 LSM-tree 的日志存储结构,但使用其索引进行模糊匹配的过程,本质上是一个有限状态自动机,在行为上类似 Trie 树。 +在工程中常用 [Apace Lucene](https://lucene.apache.org/ 'Apace Lucene') 库,和其包装出来的服务:[Elasticsearch](https://www.elastic.co/cn/ 'Elasticsearch')。它也使用类似 LSM-tree 的日志存储结构,但使用其索引进行模糊匹配的过程,本质上是一个有限状态自动机,在行为上类似 Trie 树。 ### 全内存数据结构 @@ -291,7 +291,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 > VoltDB, MemSQL, and Oracle TimesTen 是提供关系模型的内存数据库。RAMCloud 是提供持久化保证的 KV 数据库。Redis and Couchbase 仅提供弱持久化保证。 -内存数据库存在优势的原因不仅在于不需要读取磁盘,而在更于不需要对数据结构进行**序列化、编码**后以适应磁盘所带来的**额外开销**。 +内存数据库存在优势的原因不仅在于不需要读取磁盘,而更在于不需要对数据结构进行**序列化、编码**后以适应磁盘所带来的**额外开销**。 当然,内存数据库还有以下优点: @@ -348,7 +348,7 @@ AP 中的处理模型相对较少,比较常用的有**星状模型**,也称 如上图所示,星状模型通常包含一张**事件表(_fact table_)** 和多张**维度表(_dimension tables_)**。事件表以事件流的方式将数据组织起来,然后通过外键指向不同的维度。 -星状模型的一个变种是雪花模型,可以类比雪花(❄️)图案,其特点是在维度表中会进一步进行二次细分,讲一个维度分解为几个子维度。比如品牌和产品类别可能有单独的表格。星状模型更简单,雪花模型更精细,具体应用中会做不同取舍。 +星状模型的一个变种是雪花模型,可以类比雪花(❄️)图案,其特点是在维度表中会进一步进行二次细分,将一个维度分解为几个子维度。比如品牌和产品类别可能有单独的表格。星状模型更简单,雪花模型更精细,具体应用中会做不同取舍。 在典型的数仓中,事件表可能会非常宽,即有很多的列:一百到数百列。 @@ -385,7 +385,7 @@ GROUP BY 将所有数据分列存储在一块,带来了一个意外的好处,由于同一属性的数据相似度高,因此更易压缩。 -如果每一列中值阈相比行数要小的多,可以用**位图编码(_[bitmap encoding](https://en.wikipedia.org/wiki/Bitmap_index 'bitmap encoding')_)**。举个例子,零售商可能有数十亿的销售交易,但只有 100,000 个不同的产品。 +如果每一列中值域相比行数要小的多,可以用**位图编码(_[bitmap encoding](https://en.wikipedia.org/wiki/Bitmap_index 'bitmap encoding')_)**。举个例子,零售商可能有数十亿的销售交易,但只有 100,000 个不同的产品。 ![ddia-3-11-compress.png](img/ch03-fig11.png) @@ -417,7 +417,7 @@ WHERE product_sk = 31 AND store_sk = 3 ### 列族 -书中特别提到**列族(column families)**。它是 Cassandra 和 HBase 中的的概念,他们都起源于自谷歌的 [BigTable](https://en.wikipedia.org/wiki/Bigtable 'BigTable') 。注意到他们和**列式(column-oriented)存储**有相似之处,但绝不完全相同: +书中特别提到**列族(column families)**。它是 Cassandra 和 HBase 中的的概念,它们都起源于谷歌的 [BigTable](https://en.wikipedia.org/wiki/Bigtable 'BigTable') 。注意到他们和**列式(column-oriented)存储**有相似之处,但绝不完全相同: 1. 同一个列族中多个列是一块存储的,并且内嵌行键(row key)。 2. 并且列不压缩(存疑?) @@ -431,7 +431,7 @@ WHERE product_sk = 31 AND store_sk = 3 1. 内存处理带宽 2. CPU 分支预测错误和[流水线停顿](https://zh.wikipedia.org/wiki/%E6%B5%81%E6%B0%B4%E7%BA%BF%E5%81%9C%E9%A1%BF '流水线停顿') -关于内存的瓶颈可已通过前述的数据压缩来缓解。对于 CPU 的瓶颈可以使用: +关于内存的瓶颈可以通过前述的数据压缩来缓解。对于 CPU 的瓶颈可以使用: 1. 列式存储和压缩可以让数据尽可能多地缓存在 L1 中,结合位图存储进行快速处理。 2. 使用 SIMD 用更少的时钟周期处理更多的数据。 diff --git a/ch04.md b/ch04.md index 5dbccea..436cac2 100644 --- a/ch04.md +++ b/ch04.md @@ -95,7 +95,7 @@ CSV(以逗号\TAB、换行符分割)还算紧凑,但是表达能力有限 ## Thrift 和 Protocol Buffers -Thrift 最初由 Facebook,ProtoBuf 由 Google 在 07~08 年左右开源。他们都有对应的 RPC 框架和编解码工具。表达能力类似,语法也类似,在编码前都需要由接口定义语言(IDL)来描述模式: +Thrift 最初由 Facebook,ProtoBuf 由 Google 在 07~08 年左右开源。它们都有对应的 RPC 框架和编解码工具。表达能力类似,语法也类似,在编码前都需要由接口定义语言(IDL)来描述模式: ```protobuf struct Person { @@ -117,7 +117,7 @@ IDL 是编程语言无关的,可以利用相关代码生成工具,可以将 这也是不同 service 可以使用不同编码语言,且能够互相通信的基础。 -此外,Thrift 还支持多种不同的编码格式,常用的有:Binary、Compact、JSON。可以让用户自行在:编码速度、占用空间、可读性方便进行取舍。 +此外,Thrift 还支持多种不同的编码格式,常用的有:Binary、Compact、JSON。可以让用户自行在:编码速度、占用空间、可读性方面进行取舍。 ![ddia4-thrift-binary-enc.png](img/ch04-fig02.png) @@ -191,7 +191,7 @@ record Person { 可以看到 Avro 没有使用字段标号。 - 仍是编码之前例子,Avro 只用了 32 个字节,为什么呢? - 他没有编入类型。 + 它没有编入类型。 ![ddia4-avro-enc.png](img/ch04-fig05.png) @@ -432,4 +432,4 @@ REST 相比 RPC 的好处在于,它不试图隐去网络,更为显式,让 由于 Actor 和外界交互都是通过消息,因此本身可以并行的,且不需要加锁。 -分布式的 Actor 框架,本质上是将消息队列和 actor 编程模型集成到一块。自然,在 Actor 滚动升级是,也需要考虑前后向兼容问题。 +分布式的 Actor 框架,本质上是将消息队列和 actor 编程模型集成到一块。自然,在 Actor 滚动升级时,也需要考虑前后向兼容问题。 diff --git a/ch05.md b/ch05.md index 4fd4fd2..9f0e8be 100644 --- a/ch05.md +++ b/ch05.md @@ -50,7 +50,7 @@ ## 同步复制和异步复制 -**同步(synchronously)复制**和**异步(asynchronously)复制**和关键区别在于:请求何时返回给客户端。 +**同步(synchronously)复制**和**异步(asynchronously)复制**的关键区别在于:请求何时返回给客户端。 1. 如果等待某副本写完成后,则该副本为同步复制。 2. 如果不等待某副本写完成,则该副本为异步复制。 @@ -107,7 +107,7 @@ 1. **新老主副本数据冲突**。新主副本在上位前没有同步完所有日志,旧主副本恢复后,可能会发现和新主副本数据冲突。 2. **相关外部系统冲突**。即新主副本,和使用该副本数据的外部系统冲突。书中举了 github 数据库 MySQL 和缓存系统 redis 冲突的例子。 -3. **新老主副本角色冲突**。即新老主副本都以为自己才是主副本,称为**脑裂(split brain)**。如果他们两个都能接受写入,且没有冲突解决机制,数据会丢失或者损坏。有的系统会在检测到脑裂后,关闭其中一个副本,但设计的不好可能将两个主副本都关闭调。 +3. **新老主副本角色冲突**。即新老主副本都以为自己才是主副本,称为**脑裂(split brain)**。如果他们两个都能接受写入,且没有冲突解决机制,数据会丢失或者损坏。有的系统会在检测到脑裂后,关闭其中一个副本,但设计的不好可能将两个主副本都关闭掉。 4. **超时阈值选取**。如果超时阈值选取的过小,在不稳定的网络环境中(或者主副本负载过高)可能会造成主副本频繁的切换;如果选取过大,则不能及时进行故障切换,且恢复时间也增长,从而造成服务长时间不可用。 所有上述问题,在不同需求、不同环境、不同时间点,都可能会有不同的解决方案。因此在系统上线初期,不少运维团队更愿意手动进行切换;等积累一定经验后,再进行逐步自动化。 @@ -400,7 +400,7 @@ git 也是一个类似的协议。 ### 自定义解决 -由于只有用户知道数据本身的信息,因此较好的方式是,将如何解决冲突交给用户。即,允许用户编写回调代码,提供冲突解决逻。该回调可以在: +由于只有用户知道数据本身的信息,因此较好的方式是,将如何解决冲突交给用户。即,允许用户编写回调代码,提供冲突解决逻辑。该回调可以在: 1. **写时执行**。在写入时发现冲突,调用回调代码,解决冲突后写入。这些代码通常在后台执行,并且不能阻塞,因此不能在调用时同步的通知用户。但打个日志之类的还是可以的。 2. **读时执行**。在写入冲突时,所有冲突都会被保留(如使用多版本)。下次读取时,系统会将所有数据本版本返回给用户,进行交互式的或者自动的解决冲突,并将结果写回系统。 @@ -491,7 +491,7 @@ Dynamo 流派的存储中通常有两种机制: 在 Dynamo 流派的存储中,n、r 和 w 通常是可以配置的: 1. n 越大冗余度就越高,也就越可靠。 -2. r 和 w 都常都选择超过半数,如 `(n+1)/2` +2. r 和 w 通常都选择超过半数,如 `(n+1)/2` 3. w = n 时,可以让 r = 1。此时是牺牲写入性能换来读取性能。 考量满足 w+r > n 系统对节点故障的容忍性: @@ -587,8 +587,8 @@ LWW 有一个问题,就是多个并发写入的客户端,可能都认为自 考虑之前的两个图: -1. 在 5-9 中,由于 client B 的更新依赖于 client A 的插入,因此他们是因果关系。 -2. 在 5-12 中,set X = A 和 set X = B 是并发的,因为他们都互相不知道对方存在,也不存在因果关系。 +1. 在 5-9 中,由于 client B 的更新依赖于 client A 的插入,因此它们是因果关系。 +2. 在 5-12 中,set X = A 和 set X = B 是并发的,因为它们都互相不知道对方存在,也不存在因果关系。 系统中任意的两个写入 A 和 B,只可能存在三种关系: @@ -640,7 +640,7 @@ A 和 B 并发 < === > A 不 happens-before B && B 不 happens-before A 因此需要根据实际情况,选择一些策略来解决冲突,合并数据。 1. 对于上述购物车中只增加物品的例子,可以使用“并集”来合并冲突数据。 -2. 如果购物车汇总还有删除操作,就不能简单并了,但是可以将删除变为增加(写一个 tombstone 标记)。 +2. 如果购物车中还有删除操作,就不能简单并了,但是可以将删除变为增加(写一个 tombstone 标记)。 ### 版本向量 diff --git a/ch06.md b/ch06.md index 29ed4b7..3c083fc 100644 --- a/ch06.md +++ b/ch06.md @@ -78,7 +78,7 @@ 而加密并不在考虑之列,因此并不需要多么复杂的加密算法,如,Cassandra 和 MongoDB 使用 MD5,Voldemort 使用 Fowler-Noll-Vo 函数。 -选定哈希函数后,将原 Key 定义域映射到新的散列值阈,而散列值是均匀的,因此可以对散列值阈按给定分区数进行等分。 +选定哈希函数后,将原 Key 定义域映射到新的散列值域,而散列值是均匀的,因此可以对散列值域按给定分区数进行等分。 ![partition by hash key](img/ch06-fig03.png) @@ -168,7 +168,7 @@ 之前提到过,分区包括**逻辑分区**和**物理调度**两个阶段,此处说的是将两者合二为一:假设集群有 N 个节点,编号 `0 ~ N-1`,一条键为 key 的数据到来后,通过 `hash(key) mod N` 得到一个编号 n,然后将该数据发送到编号为 n 的机器上去。 -为什么说这种策略不好呢?因为他不能应对机器数量的变化,如果要增删节点,就会有大量的数据需要发生迁移,否则,就不能保证数据在 `hash(key) mod N` 标号的机器上。在大规模集群中,机器节点增删比较频繁,这种策略更是不可接受。 +为什么说这种策略不好呢?因为它不能应对机器数量的变化,如果要增删节点,就会有大量的数据需要发生迁移,否则,就不能保证数据在 `hash(key) mod N` 标号的机器上。在大规模集群中,机器节点增删比较频繁,这种策略更是不可接受。 ### 静态分区 @@ -250,7 +250,7 @@ 无论记在何处,都有一个重要问题:如何让相关组件(节点本身、路由层、客户端)及时感知(分区到节点)的映射变化,将请求正确的路由到相关节点?也即,如何让所有节点就路由信息快速达成一致,业界有很多做法。 -**依赖外部协调组件**。如 Zookeeper、Etcd,他们各自使用某种共识协议保持高可用,可以维护轻量的路由表,并提供发布订阅接口,在有路由信息更新时,让外部所有节点快速达成一致。 +**依赖外部协调组件**。如 Zookeeper、Etcd,它们各自使用某种共识协议保持高可用,可以维护轻量的路由表,并提供发布订阅接口,在有路由信息更新时,让外部所有节点快速达成一致。 ![zookeeper partitions](img/ch06-fig08.png) @@ -264,7 +264,7 @@ ## 并行查询执行 -大部分 NoSQL 存储,所支持的查询都不太负载,如基于主键的查询、基于次级索引的 scatter/gather 查询。如前所述,都是针对单个键值非常简单的查询路由。 +大部分 NoSQL 存储,所支持的查询都不太复杂,如基于主键的查询、基于次级索引的 scatter/gather 查询。如前所述,都是针对单个键值非常简单的查询路由。 但对于关系型数据库产品,尤其是支持 **大规模并行处理(MPP, Massively parallel processing)** 数仓,一个查询语句在执行层要复杂的多,可能会: diff --git a/ch07.md b/ch07.md index 2cf5d1a..9f12334 100644 --- a/ch07.md +++ b/ch07.md @@ -107,7 +107,7 @@ ACID 中隔离性是指,每个事务的执行是互相隔离的,每个事务 SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true ``` -如果邮件过多,为了加快查询,可以使用额外字段将未读邮件数存储存储起来(术语:[denormalization](https://en.wikipedia.org/wiki/Denormalization)),但每次新增、读过邮件之后都要更新该计数值。 +如果邮件过多,为了加快查询,可以使用额外字段将未读邮件数存储起来(术语:[denormalization](https://en.wikipedia.org/wiki/Denormalization)),但每次新增、读过邮件之后都要更新该计数值。 如下图,用户 1 插入一封邮件,然后更新未读邮件数;用户 2 先读取读取邮件列表,后读取未读计数。但邮箱列表中显示有新邮件,但未读计数却显示 0。 @@ -184,7 +184,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true ## 读已提交 -性能最好的隔离级别就是不上任何锁,但会存在**脏读**和**脏写**的问题。为了避免脏写,可以给要更改的对象加长时写锁,但读数据时并不加锁,此时的隔离级别称为**读未提交**(RU,Read Uncommitted)。但此时仍然会有脏读,为了避免脏读,可以对要读取的对象加短时读锁,此时的隔离级别是**读已提交**(RC,Read Committed),他提供了两个保证: +性能最好的隔离级别就是不上任何锁,但会存在**脏读**和**脏写**的问题。为了避免脏写,可以给要更改的对象加长时写锁,但读数据时并不加锁,此时的隔离级别称为**读未提交**(RU,Read Uncommitted)。但此时仍然会有脏读,为了避免脏读,可以对要读取的对象加短时读锁,此时的隔离级别是**读已提交**(RC,Read Committed),它提供了两个保证: 1. 从数据库读取时,只能读到已经提交的数据(即没有脏读,no dirty reads) 2. 往数据库写入时,只能覆盖已经提交的数据(即没有脏写,no dirty writes) @@ -294,7 +294,7 @@ MVCC 的基本要点为: 1. 在事务开始时,创建该版本的事务已经提交。 2. 未被标记删除,或被标记删除的事务尚未提交。 -则该对象版本对改事务可见。 +则该对象版本对该事务可见。 长时间运行的事务,可能会导致某些标记删除的对象版本不能够真正的被回收。但如果此类事务不太多,则代价并不大,只是需要维护一些对象的多个版本。 @@ -512,7 +512,7 @@ COMMIT; ### 物化冲突 -幻读在步骤 1 读不到任何对象来进行加锁。那很自然的一个想法就是,能不能手动引入一些对象槽来代表不存在的对象,从而是的加锁成为可能。 +幻读在步骤 1 读不到任何对象来进行加锁。那很自然的一个想法就是,能不能手动引入一些对象槽来代表不存在的对象,从而使得加锁成为可能。 在预定会议室的例子中,可以创建一个会议室号 + 时间段表,比如每 15 分钟一个时间段。可以在该表中插入未来几个月中所有可预订的会议室号 + 时间段。如果现在一个事务想要预定某个会议室的某个时间段,便可在该表中将对应对象都锁住,然后执行预定的操作。 @@ -685,7 +685,7 @@ WHERE room_id = 123 AND 2008 年,Michael Cahill 在其博士论文中提出了一种新型的可串行化实现方案:**可串行的快照隔离**(SSI,serializable snapshot isolation)。今天,无论单机数据库(PostgreSQL 9.1+ 的可串行化隔离级别)和分布式数据库(FoundationDB 使用了类似算法)都有 SSI 的身影。相比其他实现方式,SSI 还相对不太成熟,但其表现出的性能优势,使其隐隐然有成为可串行化默认实现的趋势。 -### 乐悲观并发控制 +### 乐观与悲观并发控制 2PL 是一种**悲观**(*pessimistic*)的并发控制机制,就像多线程编程中的**互斥锁**(mutual exclusion)。其背后哲学是,当可能有不好的事情(如并发)发生时,先悲观的等待到条件好转(其他事务释放锁),再进行执行。而物理上的串行执行,是将这种悲观哲学提升到了极致,等价于每个事务在执行时都持有了整个数据库级别的互斥锁。为了弥补这种悲观带来的性能损失,需要保证每个事务执行足够快。 @@ -754,7 +754,7 @@ SSI,顾名思义,基于快照隔离。即在 SSI 隔离级别中,所有的 在某些情况下,即使一个事务读到的信息被另外一个事务的写入覆盖,仍然能保证可串行化的隔离级别。这取决于事务读到这些信息后,用来做了什么,*PostgreSQL* 便根据这个原则来减少不必要的重试。 -和 2PL 相比,SSI 的最大优点是,不会通过锁来阻塞有依赖关系的事务并发执行。SSI 就想运行在快照隔离级别一样,读不阻塞写,写不阻塞读。只是追踪记录,在提交时决定是否提交或重试。这种设计是的查询延迟更可预测。尤其是,只读事务可以工作在一致性快照上,而不受影响,这对读负载很重的场景很有吸引力。 +和 2PL 相比,SSI 的最大优点是,不会通过锁来阻塞有依赖关系的事务并发执行。SSI 就像运行在快照隔离级别一样,读不阻塞写,写不阻塞读。只是追踪记录,在提交时决定是否提交或重试。这种设计使得查询延迟更可预测。尤其是,只读事务可以工作在一致性快照上,而不受影响,这对读负载很重的场景很有吸引力。 相比物理上的串行化,SSI 能够进行平滑扩展。如 FoundationDB 就可以利用多机并行进行冲突检测,从而通过加机器获取很高的吞吐。 diff --git a/ch08.md b/ch08.md index ed3b432..651cee2 100644 --- a/ch08.md +++ b/ch08.md @@ -1,6 +1,6 @@ # DDIA 逐章精读(八): 分布式系统中的麻烦事(The Trouble with Distributed Systems) -之前几章都在谈系统如何处理出错:副本故障切换、副本数据滞后、事务的并发控制。但前几张考虑到的情况:机器宕机、网络延迟都相对较**理想**。在实际大型分布式系统中,情况会更为悲观,可能会出错组件的一定会出错,而且出错的方式会更为复杂。任何大型系统的运维人员想必对此都有深有体会。 +之前几章都在谈系统如何处理出错:副本故障切换、副本数据滞后、事务的并发控制。但前几章考虑到的情况:机器宕机、网络延迟都相对较**理想**。在实际大型分布式系统中,情况会更为悲观,可能会出错组件的一定会出错,而且出错的方式会更为复杂。任何大型系统的运维人员想必对此都有深有体会。 构建分布式系统和单机软件完全不同。在分布式系统中,系统有一千种奇妙的出错方法,本章将会探讨其中的一部分。我们会发现,在单机中我们以为是无比自然的假设,在分布式系统中,都可能不成立。作为工程师,我们总期望能构建能够处理任何可能故障的系统,但在实践中,一切都是**权衡**。不过,我们首先需要知道,可能会遇到哪些问题,才能进而选择:**是否要在目标场景下解决这些问题、还是为了降低系统复杂度忽略这些问题**。 @@ -8,7 +8,7 @@ # 故障和部分失败 -在单机上编程,其行为通常可预测。如果程序运行有问题,通常不是计算机硬件的问题,一般是代码代码写的不够好。而且: +在单机上编程,其行为通常可预测。如果程序运行有问题,通常不是计算机硬件的问题,一般是代码写的不够好。而且: 1. 当硬件没问题时,确定的行为总会产生确定的结果 2. 一旦硬件有问题,通常会造成整个系统故障 @@ -55,7 +55,7 @@ # 不可靠的网络 -首先需要明确,本书讨论系统范畴是 share-nothing 架构:**所有机器不共享资源(如内存、磁盘),通信的唯一途径就是网络**。share-nothing 不是唯一的系统构建方式,但相比来说,他是最经济的,不需要特殊的硬件,并且可以通过异地冗余做高可用。但同时,构建这种风格的系统复杂度也最高。 +首先需要明确,本书讨论系统范畴是 share-nothing 架构:**所有机器不共享资源(如内存、磁盘),通信的唯一途径就是网络**。share-nothing 不是唯一的系统构建方式,但相比来说,它是最经济的,不需要特殊的硬件,并且可以通过异地冗余做高可用。但同时,构建这种风格的系统复杂度也最高。 互联网和数据中心(多是以太网)的内部网络多是**异步封包网络**(**asynchronous packet networks**)。在这种类型网络中,一个机器向其他机器发送数据包时,不提供任何保证:你不知道数据包什么时候到、甚至不知道它是否能够到。具体来说,当我们的应用发送网络请求后,可能会面临以下诸多情况: @@ -70,7 +70,7 @@ 因此,在异步网络中,当你发送出一个请求,并在一段时间内没有收到应答,任何事情都有可能发生:由于没有收到任何信息,你无从得知具体原因是什么。甚至,你都不知道你的请求是否已被送达处理。 -应对这种情况的惯常做法是——**超时**(timeout)。即,设定一个时限,到点后,我们便认为这个请求废了。但在实际上,该请求可能只是还在排队、可能稍后到到达远端节点、甚至可能最终还会收到应答。 +应对这种情况的惯常做法是——**超时**(timeout)。即,设定一个时限,到点后,我们便认为这个请求废了。但在实际上,该请求可能只是还在排队、可能稍后到达远端节点、甚至可能最终还会收到应答。 ## 实践中的网络故障 @@ -130,8 +130,8 @@ ![Untitled](img/ch08-fig02.png) -1. **去程网络排队**。如果多个节点试图将数据包同时发给一个目的端,则交换机得将他们**排队**以逐个送达目的端(如上图)。如果流量进一步增大,超过交换机的处理能力,则其可能会随机进行丢包。 -2. **目的机器排队**。当数据包到达目的端时,如果目标机器 CPU 负载很高,操作系统会将进来的数据包进行排队,直到有时间片分给他们。目的机器负载的不同决定了对应数据包被处理的延迟。 +1. **去程网络排队**。如果多个节点试图将数据包同时发给一个目的端,则交换机得将它们**排队**以逐个送达目的端(如上图)。如果流量进一步增大,超过交换机的处理能力,则其可能会随机进行丢包。 +2. **目的机器排队**。当数据包到达目的端时,如果目标机器 CPU 负载很高,操作系统会将进来的数据包进行排队,直到有时间片分给它们。目的机器负载的不同决定了对应数据包被处理的延迟。 3. **虚拟机排队**。在虚拟化环境中,由于多个虚拟机共用物理机,因此经常会整体让出 CPU 一段时间的情况。在让出 CPU 等待期间,是不能处理任何外部请求的,又会进一步给网络请求的排队时延增加变数。 4. **TCP 流控**。TCP 流量控制(又称拥塞避免或反压,backpressure,一种负反馈调节)为了避免网络过载或者目的端过载,会限制发送方的发送频率,也即,有些请求可能还没发出去就要在本机排队。 @@ -168,11 +168,11 @@ 1. 电路中的固定带宽一旦被预留,则其他任何电路不能够使用。 2. TCP 连接中的数据包,只要余量允许,都有可能使用到任何网络带宽。 -应用层给到 TCP 的任意大小的数据,都会在尽可能短的时间内被发送给对端。如果一个 TCP 连接暂时空闲,则他不会占用任何网络带宽。相比之下,在打电话时即使不说话,电路所占带宽也得一直被预留。 +应用层给到 TCP 的任意大小的数据,都会在尽可能短的时间内被发送给对端。如果一个 TCP 连接暂时空闲,则它不会占用任何网络带宽。相比之下,在打电话时即使不说话,电路所占带宽也得一直被预留。 -如果数据中心和互联网使用**电路交换**(*circuit-switched*)网络,他们应该能够建立一条保证稳定最大延迟的数据链路。但是事实上,由于以太网和 IP 网采用**封包交换**协议(*packet-switched protocols*,常翻译为**分组交换**,但我老感觉它不太直观),没有电路的概念,只能在数据包传送的时候对其进行排队,也不得不忍受由此带来的无界延迟。 +如果数据中心和互联网使用**电路交换**(*circuit-switched*)网络,它们应该能够建立一条保证稳定最大延迟的数据链路。但是事实上,由于以太网和 IP 网采用**封包交换**协议(*packet-switched protocols*,常翻译为**分组交换**,但我老感觉它不太直观),没有电路的概念,只能在数据包传送的时候对其进行排队,也不得不忍受由此带来的无界延迟。 -那为什么数据中心网络和互联网要使用封包交换协议呢?答曰,为了应对互联网中无处不在的**突发流量**(*bursty traffic*)。在电话电路中,音频传输所需带宽是固定的;但在互联网中,各种多媒体数据(如电子邮件、网页、文件)所需带宽却是差异极大且动态变化的,我们对他们的唯一要求就是传地尽可能快。 +那为什么数据中心网络和互联网要使用封包交换协议呢?答曰,为了应对互联网中无处不在的**突发流量**(*bursty traffic*)。在电话电路中,音频传输所需带宽是固定的;但在互联网中,各种多媒体数据(如电子邮件、网页、文件)所需带宽却是差异极大且动态变化的,我们对它们的唯一要求就是传地尽可能快。 设想你使用电路网络传输一个网页,你需要为它预留带宽,如果你预留过低,则传输速度会很慢;如果你预留过高,则可能电路都没法建立(带宽余量不够,就没法建立连接),如果建立了,也会浪费带宽。互联网数据的**丰富性**和**异构性**,让使用电路网络不太可能。 @@ -211,11 +211,11 @@ ## 单调时钟和日历时钟 -当代的计算机通常支持两类时钟:**日历时钟**(time-of-day clock)和**单调时钟**(monotonic clock),他们之间有些区别,其实分别和该小节最初提出的需求相对应:前者常用于时间点需求,后者常用于计算时间间隔。 +当代的计算机通常支持两类时钟:**日历时钟**(time-of-day clock)和**单调时钟**(monotonic clock),它们之间有些区别,其实分别和该小节最初提出的需求相对应:前者常用于时间点需求,后者常用于计算时间间隔。 ### 日历时钟 -该时钟和我们日常生活中的时钟关联,也称为**挂钟时间**(wall-clock time),通常会返回当前日期和时间。如:Linux 上的 `clock_gettime(CLOCK_REALTIME)` 和 Java 里的 `System.currentTimeMillis()` ,他们都会返回基于**格里历**(Gregorian calendar)1970 年 1 月 1 日 00:00:00 时刻以来的秒数(或者毫秒数),不包括闰秒。当然,一些系统可能会用其他时刻作为计时起点。 +该时钟和我们日常生活中的时钟关联,也称为**挂钟时间**(wall-clock time),通常会返回当前日期和时间。如:Linux 上的 `clock_gettime(CLOCK_REALTIME)` 和 Java 里的 `System.currentTimeMillis()` ,它们都会返回基于**格里历**(Gregorian calendar)1970 年 1 月 1 日 00:00:00 时刻以来的秒数(或者毫秒数),不包括闰秒。当然,一些系统可能会用其他时刻作为计时起点。 日历时钟常常使用 **NTP 进行同步**,以使得不同机器上时间戳能够同步。但之后会提到,日历时钟有诸多不确定性。这里值得一提的是,如果某个机器时间大大领先于 NTP 服务器,则其日历时钟会被重置,从而让该机器上的时间看起来倒流了一样。**时钟回拨、跳过闰秒**等等问题,使得日历时钟不能用于精确计算一个时间间隔。 @@ -245,7 +245,7 @@ - 在虚拟机中,其**物理时钟是虚拟化**出来的,从而给运行其上并依赖精确计时的应用带来额外挑战。由于一个 CPU 内核是被多个 VM 所共享的,当一个 VM 运行时,其他 VM 就得让出内核几十毫秒。在 VM 恢复运行后,从应用代码的视角,其时钟就是毫无征兆的突然往前跳变了一段。 - 如果你的软件将会运行在**不受控的设备**上,如智能手机或者嵌入式设备,则你不能完全相信设备系统时钟。因为用户可能会由于一些原因(比如绕开游戏时间限制),故意将其硬件时钟设置成一个错误的日期和时间,从而引起系统时钟的跳变。 -当然,如果**不计代价**,我们是能够获得足够精确的时钟的。例如针对金融机构的欧洲法案:MIFID II,要求所有高频交易的基金需要和 UTC 的误差不超过 100 微秒,以便调试如“闪崩”之类的市场异常,并帮助检测市场操纵行文。 +当然,如果**不计代价**,我们是能够获得足够精确的时钟的。例如针对金融机构的欧洲法案:MIFID II,要求所有高频交易的基金需要和 UTC 的误差不超过 100 微秒,以便调试如“闪崩”之类的市场异常,并帮助检测市场操纵行为。 可以通过组合使用 **GPS 接收机、PTP 协议**(Precision Time Protocol),并进行小心部署和监控,来获取此类高精度时钟。然而,这需要非常多的专业知识和精力投入,且仍有很多问题会引起时钟不同步:NTP 服务器配置错误、防火墙错误组织了 NTP 流量。 @@ -305,7 +305,7 @@ 但不幸,大多数服务器的时钟系统 API 在给出时间点时,并不会一并给出对应的不确定区间。例如,你使用 `clock_gettime()` 系统调用获取时间戳时,返回值并不包括其置信区间,因此你无法知道这个时间点的误差是 5 毫秒还是 5 年。 -一个有趣的反例是谷歌在 Spanner 系统中使用的 *TrueTime* API,会显式的给出置信区间。当你向 TrueTime 系统询问当前时钟时,会得到两个值,或者说一个区间:`[earliest, latest]`,前者是最早可能的时间戳。后者是最迟可能的时间错。通过该不确定预估,我们可以确定准确时间点就在该时钟范围内。此时,区间的大小取决于,上一次同步过后本地石英钟的漂移多少。 +一个有趣的反例是谷歌在 Spanner 系统中使用的 *TrueTime* API,会显式的给出置信区间。当你向 TrueTime 系统询问当前时钟时,会得到两个值,或者说一个区间:`[earliest, latest]`,前者是最早可能的时间戳。后者是最迟可能的时间戳。通过该不确定预估,我们可以确定准确时间点就在该时钟范围内。此时,区间的大小取决于,上一次同步过后本地石英钟的漂移多少。 ### 用于快照的时钟同步 @@ -315,7 +315,7 @@ 然而,当数据库横跨多个机器,甚至多个数据库中心时,一个可用于事务全局自增 ID 并不容易实现,因为需要进行**多机协作**。事务 ID 必须要反应**因果性**:如当事务 B 读到事务 A 写的内容时,事务 B 的事务 ID 就需要比事务 A 大。非如此,快照不能维持一致。另外,如果系统中存在大量短小事务,分配事务 ID 可能会成为分布式系统中的一个瓶颈。这其实就是分布式事务中常说的 **TSO 方案**(Timestamp Oracle,统一中心授时),这种方案通常会有性能瓶颈;尤其在跨数据中心的数据库里,会延迟很高,实践中也有很多优化方案。 -那么,我们可以用机器的挂历时钟的时间戳作为事物的 ID 吗?如果我们能让系统中的多台机器时钟保持严格同步,则其可以满足要求:**后面的事务会具有较大的时间戳,即较大的事务 ID**。但现实中,由于时钟同步的不确定性,用这种方法产生事务 ID 是不太靠谱的。 +那么,我们可以用机器的挂历时钟的时间戳作为事务的 ID 吗?如果我们能让系统中的多台机器时钟保持严格同步,则其可以满足要求:**后面的事务会具有较大的时间戳,即较大的事务 ID**。但现实中,由于时钟同步的不确定性,用这种方法产生事务 ID 是不太靠谱的。 但 Spanner 就使用了物理时钟实现了快照隔离,它是如何做到可用的呢?Spanner 在设计 TrueTime 的 API 时,让其返回一个**置信区间**,而非一个时间点,来代表一个**时间戳**。假如现在你有两个时间戳 A 和 B(*A* = [*Aearliest*, *Alatest*] and *B* = [*Bearliest*, *Blatest*]),且这两个时间戳对应的区间没有交集(例如,*Aearliest* < *Alatest* < *Bearliest* < *Blatest*),则我们可以确信时间戳 B 发生于 A 之后。但如果两个区间有交集,我们则不能确定 A 和 B 的相对顺序。 @@ -329,7 +329,7 @@ 现在我们来看另外一种分布式系统中使用时钟的危险情况。假设你的数据库有多分片,每个分片多副本单主,只有主副本可以接受写入。那么一个很直接的问题就是:对于每个主副本来说,为了保证安全的接受写入,我们需要确定它仍是**事实上的**主副本。那我们如何确定呢?毕竟有时他会自认为是主副本,但事实上不是(比如其他副本和他产生了网络隔离,已经重新选出了主)? -一种解决方案是使用**租约**(lease)。该机制(注意和 Raft 中 Leader 的租约区分)类似于具有超时的锁:**任意时刻只有一个副本可以持有改租约**。因此,一旦某副本获取到租约,它就获取到了一段时间的领导权,直到租约过期;为了持续掌握领导权,该副本需要定期续租,且续租间隔要小于租期时间。如果该副本宕机,自然就会停止续约,其他副本就可以上位。 +一种解决方案是使用**租约**(lease)。该机制(注意和 Raft 中 Leader 的租约区分)类似于具有超时的锁:**任意时刻只有一个副本可以持有该租约**。因此,一旦某副本获取到租约,它就获取到了一段时间的领导权,直到租约过期;为了持续掌握领导权,该副本需要定期续租,且续租间隔要小于租期时间。如果该副本宕机,自然就会停止续约,其他副本就可以上位。 用代码表示,续租大概长这样: @@ -419,7 +419,7 @@ while (true) { 幸运的是,我们并不需要进一步追问到人生的意义是什么(笑)。在分布式系统中,我们可以做一些**基本假设**,并基于这些假设设计真实系统。基于特定假设,我们能够设计出能够**被证明正确性**的算法。那么,纵然底层系统不怎么可靠,我们仍能通过罩一层协议,使其对上提供相对可靠的保证(比如 TCP)。 -虽然可以在不稳定的系统模型上构建运行良好的系统,但这种**构建过程本**身却并非直观易懂。在本章余下的小节,我们将继续探讨分布式系统中的**知识**和**事实**,来辅助我们思考**对下做什么样的假设、对上提供什么样的保证**。在第九章,我们会进一步考察一些分布式系统中的例子和算法,看看他们是如何来通过特定的假设来提供特定服务的。 +虽然可以在不稳定的系统模型上构建运行良好的系统,但这种**构建过程本**身却并非直观易懂。在本章余下的小节,我们将继续探讨分布式系统中的**知识**和**事实**,来辅助我们思考**对下做什么样的假设、对上提供什么样的保证**。在第九章,我们会进一步考察一些分布式系统中的例子和算法,看看它们是如何来通过特定的假设来提供特定服务的。 ## 真相由多数派定义 @@ -465,7 +465,7 @@ while (true) { 如上图,客户端 1 获得了一个关联了令牌号 33 的租期,但随即经历了长时间的停顿,然后租约过期。客户端 2 获得了一个关联令牌号 34 的租期,并且向存储服务发送了一个附带了该令牌号的写请求。稍后,当客户端 1 结束停顿时,附带令牌号 33,给存储服务发送写请求。然而,由于存储服务记下了它**处理过更高令牌号**(34)的请求,于是它就会拒绝该使用令牌号 33 的请求。 -如果我们使用 ZooKeeper 作为锁服务,那么事务 ID zxid 或者节点版本 cversion 可以用于防护令牌。因为他们单调递增,符合需求。 +如果我们使用 ZooKeeper 作为锁服务,那么事务 ID zxid 或者节点版本 cversion 可以用于防护令牌。因为它们单调递增,符合需求。 注意到,该机制要求资源服务自己可以**主动拒绝**使用过期版本令牌的写请求,也就是说,仅依赖客户端对锁状态进行自检是不够的。对于那些不能**显式支持**防护令牌检查的资源服务来说,我们仍然可以有一些变通手段(work around,如在写入时将令牌号写到文件路径中),总之,引入一些检查手段是必要的,以避免在锁的保护外执行请求。 @@ -479,7 +479,7 @@ while (true) { 在本书中我们假设所有参与系统的节点有可能**不可靠**(unreliable)、但一定是**诚实的**(honest):这些节点有可能反应较慢甚至没有响应(由于故障),他们的状态可能会过期(由于 GC 停顿或者网络延迟),但一旦节点响应,“说的都是真话”:**在其认知范围内,尽可能的遵守协议进行响应**。 -如果系统中的节点有“说谎”(发送任意错误的的或者损坏的信息)的可能性,分布式系统将会变得十分复杂。如,一个节点没有收到某条消息却声称收到了。这种行为称为**拜占庭故障**(Byzantine fault),在具有拜占庭故障的环境中达成共识也被称为**拜占庭将军问题**(Byzatine Generals Problem)。 +如果系统中的节点有“说谎”(发送任意错误的或者损坏的信息)的可能性,分布式系统将会变得十分复杂。如,一个节点没有收到某条消息却声称收到了。这种行为称为**拜占庭故障**(Byzantine fault),在具有拜占庭故障的环境中达成共识也被称为**拜占庭将军问题**(Byzantine Generals Problem)。 > **拜占庭将军问题** > @@ -504,15 +504,15 @@ Web 应用确实可能遇到由任意终端用户控制的客户端(如浏览 ### 弱谎言 -即使我们通常假设节点是诚实的,但为软件加上一些对**弱谎言**(week forms of lying)的简单防护机制仍然很有用,例如由于硬件故障、软件 bug、错误配置等问题,一些节点可能会发送非法消息。由于不能挡住有预谋对手的蓄意攻击,这种防护机制不是完全的的拜占庭容错的,但却是一种简单有效的获取更好可用性的方法。例如: +即使我们通常假设节点是诚实的,但为软件加上一些对**弱谎言**(week forms of lying)的简单防护机制仍然很有用,例如由于硬件故障、软件 bug、错误配置等问题,一些节点可能会发送非法消息。由于不能挡住有预谋对手的蓄意攻击,这种防护机制不是完全的拜占庭容错的,但却是一种简单有效的获取更好可用性的方法。例如: -- 由于操作系统、硬件驱动、路由器中的 bug,网络中的数据包有时会损坏。通常来说,TCP 或者 UDP 协议中内置的校验和机制会检测到这些损坏的数据包,但有时他们也会逃脱检测。使用一些很简单的手段就能挡住这些损坏的数据包,如**应用层的校验字段**。 +- 由于操作系统、硬件驱动、路由器中的 bug,网络中的数据包有时会损坏。通常来说,TCP 或者 UDP 协议中内置的校验和机制会检测到这些损坏的数据包,但有时它们也会逃脱检测。使用一些很简单的手段就能挡住这些损坏的数据包,如**应用层的校验字段**。 - 可公开访问的应用需要**仔细地过滤**任何来自用户的输入,如检查输入值是否在合理的范围内、限制字符串长度,以避免过量内存分配造成的拒绝服务攻击。防火墙内部的服务可以适当放宽检查,但(如在协议解析时)一些基本的合法性检查仍是十分推荐的。 - 可以为 NTP 客户端配置**多个 NTP 服务源**。当进行时钟同步时,客户端会向所有源发送请求,估算误差,以判断是否绝大多数源提供的时间会落在同一个时间窗口内。只要大部分 NTP 服务器正常运行,一两个提供错误时间的 NTP 服务器就会被检测出来,并被排除在外。从而,使用多个服务器让 NTP 同步比使用单个服务器更加鲁棒。 ## 系统模型和现实 -前人已经设计了很多算法以解决分布式系统的的问题,如我们将要在第九章讨论的共识问题的一些解决方案。这些算法需要能够处理本章提到的各种问题,才能够在实际环境用有用。 +前人已经设计了很多算法以解决分布式系统的问题,如我们将要在第九章讨论的共识问题的一些解决方案。这些算法需要能够处理本章提到的各种问题,才能够在实际环境用有用。 在设计算法的时候,不能过重的依赖硬件的细节和软件的配置,这迫使我们对系统中可能遇到的问题进行**抽象化处理**。我们的解决办法是定义一个**系统模型**(system model),以对算法的期望会遇到的问题进行抽象。 diff --git a/ch09.md b/ch09.md index 951937f..0eac089 100644 --- a/ch09.md +++ b/ch09.md @@ -1,6 +1,6 @@ # DDIA 逐章精读(九): 一致性和共识协议(Consistency and Consensus) -> 本章的线性一致性是在铺垫了多副本、网络问题、时钟问题后的一个综合探讨。首先探讨了线性一致的内涵:让系统表现得好像只有一个数据副本。然后讨论如何实现线性一致性,以及背后所做出的的取舍考量。其间花了一些笔墨探讨 CAP,可以看出作者很不喜欢 CAP 的模糊性。 +> 本章的线性一致性是在铺垫了多副本、网络问题、时钟问题后的一个综合探讨。首先探讨了线性一致的内涵:让系统表现得好像只有一个数据副本。然后讨论如何实现线性一致性,以及背后所做出的取舍考量。其间花了一些笔墨探讨 CAP,可以看出作者很不喜欢 CAP 的模糊性。 如前所述,分布式系统中很多事情都有可能出错。解决出错最**简单粗暴**的方法是让整个**系统宕机**,并给出出错原因。但在实际生产中,这种方式多不可接受,此时我们就需要找到**容错**(tolerating faults)的方法。即,即使系统构件出现了一些问题,我们能保证系统仍然正常运行。 @@ -14,7 +14,7 @@ 本章将继续讨论一些可以减轻应用层负担的分布式系统中的**基本抽象**。比如,分布式系统中最重要的一个抽象——**共识**(consensus),即,*让所有节点在**某件事情**上达成一致*。在本章稍后的讨论可以看出,让系统中的所有节点在有网络故障和节点宕机的情况下达成共识,是一件非常棘手的事情。 -> 为什么共识协议如此重要呢?他和真实系统的连接点在于哪里?答曰,**操作日志**。而大部分**数据系统**都可以抽象为一系列**数据操作**的依次施加,即状态机模型。而共识协议可以让多机对某个**确定**的**操作序列**达成共识,进而对系统的任意状态达成共识。 +> 为什么共识协议如此重要呢?它和真实系统的连接点在于哪里?答曰,**操作日志**。而大部分**数据系统**都可以抽象为一系列**数据操作**的依次施加,即状态机模型。而共识协议可以让多机对某个**确定**的**操作序列**达成共识,进而对系统的任意状态达成共识。 一旦我们实现了**共识协议**,应用层可以依赖其做很多事情。例如,你有一个使用单主模型的数据库,如果主副本所在节点宕机,我们便可以使用共识协议选出新的主。在第五章处理节点下线(**Handling Node Outages**)一节中我们提到过,只有唯一的主,并且所有副本都认可该主,是一个需要确保的非常重要的特性。如果有超过一个节点都认为自己是主,我们称之为**脑裂**(split brain)。脑裂很容易导致数据丢失,而正确实现的共识协议能够避免该问题。 @@ -40,7 +40,7 @@ > 在实践中,我们常会使用分层策略,让某些底层解决可用性、性能和容量的问题,让上层解决一致性的问题。比如云上各种基于 aws s3 的关系型数据库。另外,也有些系统会同时提供多种一致性模型供用户选择,在一致性和性能间进行取舍。 -分布式系统中的**一致性模型的强弱**和第七章讲的事物的**隔离级别层次**有一些共通之处,比如在性能和隔离性/一致性间做取舍。但他们是相对独立的抽象: +分布式系统中的**一致性模型的强弱**和第七章讲的事务的**隔离级别层次**有一些共通之处,比如在性能和隔离性/一致性间做取舍。但它们是相对独立的抽象: 1. **事务隔离级别**是为了解决并发所引起的数据竞态条件 2. **分布式一致性**是处理由于多副本间延迟和故障所引入的数据同步问题 @@ -55,7 +55,7 @@ 在提供最终一致性语义的数据库里,如果你问不同副本同一个问题(比如说查询某条数据),则很可能得到不同的回答(响应),这就很让人迷惑了。如果多副本数据库在行为上能够表现的像只有一个副本,应用层编程将会简单很多。这样在任意时刻,每个客户端所看到的数据视图都是一样的,而不用去担心引入多副本带来的**副本滞后**(replication lag)等问题。 -这就是**线性一致性**(linearizability)的基本思想,他还有很多其他称呼:原子一致性(atomic consistency)、强一致性(strong consistency)、即时一致性(immediate consistency),或者外部一致性(external consistency)。线性一致性的精确定义很精妙,本节余下部分会进行详细探讨。但其基本思想是,一个系统对外表现的像所有数据**只有一个副本**,作用于数据上的操作都可以**原子地完成**。有了这个保证,不管系统中实际上有多少副本,应用层都不用关心。这种抽象,或者说保证,类似于编程中的接口。 +这就是**线性一致性**(linearizability)的基本思想,它还有很多其他称呼:原子一致性(atomic consistency)、强一致性(strong consistency)、即时一致性(immediate consistency),或者外部一致性(external consistency)。线性一致性的精确定义很精妙,本节余下部分会进行详细探讨。但其基本思想是,一个系统对外表现的像所有数据**只有一个副本**,作用于数据上的操作都可以**原子地完成**。有了这个保证,不管系统中实际上有多少副本,应用层都不用关心。这种抽象,或者说保证,类似于编程中的接口。 在一个提供线性一致性的系统中,只要某个客户端成功的进行了写入某值,其他所有客户端都可以在数据库中读到该值。提供单副本的抽象,意味着客户端任何时刻读到的都是**最近、最新**(up-to-date)的值,而不会是过期缓存、副本中的旧值。换句话说,线性一致性是一种数据**新鲜度保证**(recency guarantee)。为了理解这个说法,让我们看一个非线性一致性系统的例子: @@ -139,7 +139,7 @@ 在使用单主模型的系统中,需要保证任何时刻只有一个主副本,而非多个(脑裂)。一种进行主选举的方法是使用锁:每个节点在启动时都试图去获取锁,最终只有一个节点会成功并且变为主。不论使用什么方式实现锁,都必须**满足线性一致性**:所有节点必须就某节点拥有锁达成一致,否则这样的锁服务是不能用的。 -像 Apache Zookeeper 和 Etcd 之类的协调服务(Coordination services)通常用来实现分布式锁和主选举。他们通常使用共识算法来实现线性一致性操作,并且能够进行容错。当然,在此之上,为了正确的实现锁服务和主选举,还需要讨论一些非常微妙的细节。像 [Apache Curator](https://github.com/apache/curator) 等库可以基于 Zookeeper 提供高层的协调服务抽象。但是,一个提供线性一致性保证的存储服务是实现这些协调任务的**基础**。 +像 Apache Zookeeper 和 Etcd 之类的协调服务(Coordination services)通常用来实现分布式锁和主选举。它们通常使用共识算法来实现线性一致性操作,并且能够进行容错。当然,在此之上,为了正确的实现锁服务和主选举,还需要讨论一些非常微妙的细节。像 [Apache Curator](https://github.com/apache/curator) 等库可以基于 Zookeeper 提供高层的协调服务抽象。但是,一个提供线性一致性保证的存储服务是实现这些协调任务的**基础**。 一些分布式数据库在**更细粒度上**使用了分布式锁,如 Oracle Real Application Clusters(RAC)。当有多个节点共同访问同一个磁盘存储系统时,RAC 会为每个磁盘页配一把锁。由于这些线性化的锁是事务执行的关键路径,RAC 通常将其部署在和数据节点使用专线连接起来的集群里。 @@ -171,7 +171,7 @@ **图片调整服务**(image resizer)需要显式的指定任务,任务指令是通过消息队列由 web 服务器发给图片调整服务。但由于消息队列是针对短小消息(1kb 以下)而设计的,而图片通常有数 M,因此不能直接将图片发送到消息队列。而是,首先将图片写入**文件存储服务**(File Storage Service),然后将包含**该文件路径**的**调整请求**发送到消息队列中。 -如果文件存储服务是线性一致的,则这个系统能正常运作。但如果他不是,则可能会存在竞态条件:**消息队列可能会比文件存储服务内部多副本同步要快**。在这种情况下,当图片调整服务去文件存储服务中捞照片时,就会发现一个旧照片、或者照片不存在。如果调整服务看到的是旧照片,却以为是新的,然后把它调整了并且存回了存储服务,就会出现永久的不一致。 +如果文件存储服务是线性一致的,则这个系统能正常运作。但如果它不是,则可能会存在竞态条件:**消息队列可能会比文件存储服务内部多副本同步要快**。在这种情况下,当图片调整服务去文件存储服务中捞照片时,就会发现一个旧照片、或者照片不存在。如果调整服务看到的是旧照片,却以为是新的,然后把它调整了并且存回了存储服务,就会出现永久的不一致。 出现这种情况是因为在 web 服务器和图片调整服务中间存在两条**不同的通信渠道**(communication channels):**存储系统和消息队列**。如果没有线性一致性提供的新鲜度保证,两条通信渠道就有可能发生竞态条件(race condition)。这也和图 9-1 的情况类似,在那个场景中,也存在着两条有竞态条件的通信渠道:数据库多副本同步渠道和 Alice 的嘴到 Bob 的耳朵的声音传播。 @@ -246,7 +246,7 @@ Quorum 的配置是严格满足 w+r>n 的,然而这个读写序列却不是线 CAP 最初被提出只是一个为了激发数据库取舍讨论的模糊的取舍参考,而非被精确定义的定理,Martin 还专门写过一篇[文章](https://www.qtmuniao.com/2020/02/16/not-cp-or-ap/)来探讨这件事。在当时,很多分布式数据库还在着眼于基于共享存储的一组机器上提供线性一致性语义。CAP 的提出,鼓励工程师们在 share-nothing 等更广阔的设计领域进行架构探索,以找出更加适合大规模可扩展 web 服务架构。在新世纪的最初十年里,CAP 的提出见证并推动了当时数据库设计思潮从强一致系统转向弱一致系统(也被称为 NoSQL 架构)。 -**CAP 定理**的形式化定义适用范围很窄:仅包含一种一致性模型(即线性一致性)和一种故障类型(网络分区,或者说节点存活,但互不连通)。它没有进一步说明任何关于网络延迟、宕机节点、以及其他的一些取舍考量。因此,尽管 CAP 在历史上很有影响力,但他在设计系统时缺乏实际有效指导力。 +**CAP 定理**的形式化定义适用范围很窄:仅包含一种一致性模型(即线性一致性)和一种故障类型(网络分区,或者说节点存活,但互不连通)。它没有进一步说明任何关于网络延迟、宕机节点、以及其他的一些取舍考量。因此,尽管 CAP 在历史上很有影响力,但它在设计系统时缺乏实际有效指导力。 在分布式系统中有很多其他难以兼顾的有趣结果,CAP 现在已经被很多更为精确的描述所取代,因此 CAP 在今天更多的作为一个历史名词。 @@ -283,7 +283,7 @@ CAP 最初被提出只是一个为了激发数据库取舍讨论的模糊的取 - 在[第七章](https://ddia.qtmuniao.com/#/ch07),我们讨论了**可串行化**(serializability),即保证所有并发的事务像以某种顺序一样串行执行(some sequential order)。可以通过物理上真的串行执行来实现,也可以通过并发执行但解决冲突(加锁互斥或者抛弃执行)来实现。 - 在[第八章](https://ddia.qtmuniao.com/#/ch08),我们讨论了在分布式系统中使用时钟(参见[依赖同步时钟](https://ddia.qtmuniao.com/#/ch08?id=%e4%be%9d%e8%b5%96%e5%90%8c%e6%ad%a5%e6%97%b6%e9%92%9f)),这也是一个试图对无序的真实世界引入某种顺序,以解决诸如哪个写入更靠后之类的问题。 -**顺序性**(ordering)、**线性一致性**(linearizability)和**共识协议**(consensus)三个概念间有很深的联系。相比本书其他部分,尽管这几个概念更偏理论和抽象,但理解他们却有助于来厘清系统的功能边界——哪些可以做,哪些做不了。在接下来的几小节中,我们会对此进行详细探讨。 +**顺序性**(ordering)、**线性一致性**(linearizability)和**共识协议**(consensus)三个概念间有很深的联系。相比本书其他部分,尽管这几个概念更偏理论和抽象,但理解它们却有助于来厘清系统的功能边界——哪些可以做,哪些做不了。在接下来的几小节中,我们会对此进行详细探讨。 ## 顺序和因果(**Ordering and Causality**) @@ -291,7 +291,7 @@ CAP 最初被提出只是一个为了激发数据库取舍讨论的模糊的取 - 在[一致前缀读](https://ddia.qtmuniao.com/#/ch05?id=%e4%b8%80%e8%87%b4%e5%89%8d%e7%bc%80%e8%af%bb)中我们提到一个先看到答案、后看到问题的例子。这种现象看起来很奇怪,是因为它违反了我们关于因果顺序的直觉:问题应该先于答案出现。因为只有看到了问题,才可能针对其给出答案(假设这不是超自然现象,并且不能预言将来)。对于这种情况,我们说在问题和答案之间存在着**因果依赖**(causal dependency)。 - 在第五章[图 5-9](https://ddia.qtmuniao.com/#/ch05?id=%e5%a4%9a%e4%b8%bb%e5%a4%8d%e5%88%b6%e6%8b%93%e6%89%91) 中有类似的情况,在有三个主的情况下,由于网络延迟,一些本应该先到的写入操作却居于后面。从某个副本的角度观察,就感觉像在更新一个不存在的数据。**因果**在此处意味着,某一行数据*只有先被创建才能够被更新*。 -- 在[并发写入检测](https://ddia.qtmuniao.com/#/ch05?id=%e5%b9%b6%e5%8f%91%e5%86%99%e5%85%a5%e6%a3%80%e6%b5%8b)一节我们知到,对于两个操作 A 和 B,有三种可能性:A 发生于 B 之前,B 发生于 A 之前,A 和 B 是并发的。这种**发生于之前**(happened before)是因果性的另一种表现:如果 A 发生在 B 之前,则 B 有可能知道 A,进而基于 A 构建,或者说依赖于 A。如果 A 和 B 是并发的,则他们之间没有因果联系,也即,我们可以断定他们互不知道。 +- 在[并发写入检测](https://ddia.qtmuniao.com/#/ch05?id=%e5%b9%b6%e5%8f%91%e5%86%99%e5%85%a5%e6%a3%80%e6%b5%8b)一节我们知道,对于两个操作 A 和 B,有三种可能性:A 发生于 B 之前,B 发生于 A 之前,A 和 B 是并发的。这种**发生于之前**(happened before)是因果性的另一种表现:如果 A 发生在 B 之前,则 B 有可能知道 A,进而基于 A 构建,或者说依赖于 A。如果 A 和 B 是并发的,则它们之间没有因果联系,也即,我们可以断定它们互不知道。 - 在事务的快照隔离级别下(参见[快照隔离和重复读](https://ddia.qtmuniao.com/#/ch07?id=%e5%bf%ab%e7%85%a7%e9%9a%94%e7%a6%bb%e5%92%8c%e9%87%8d%e5%a4%8d%e8%af%bb)),所有的读取都会发生在某个**一致性**的快照上。这里的一致性是什么意思呢?是**因果一致性**(consistent with causality)。如果一个快照包含某个问题答案,它一定包含该问题本身。假设我们以上帝视角,在某个**时间点**(意味着瞬时观察完)观察整个数据库可以让得到的快照满足因果一致性:所有在该时间点之前操作结果都可见,在该时间点之后的操作结果都不可见。**读偏序**(Read skew,即图 7-6 中提到的不可重复读),即意味读到了违反因果关系的状态。 - 之前提到的事务间的写偏序的例子(参见[写偏序和幻读](https://ddia.qtmuniao.com/#/ch07?id=%e5%86%99%e5%81%8f%e5%ba%8f%e5%92%8c%e5%b9%bb%e8%af%bb))本质上也是因果依赖:在图 7-8 中,系统允许 Alice 请假,是因为事务看到的 Bob 的状态是仍然再岗;当然,对于 Bob 也同样。在这个例子中,一个医生是否允许在值班时请假,依赖于当时是否仍有其他医生值班。在**可串行的快照隔离级别**(SSI,参见[可串行的快照隔离](https://ddia.qtmuniao.com/#/ch07?id=%e5%8f%af%e4%b8%b2%e8%a1%8c%e7%9a%84%e5%bf%ab%e7%85%a7%e9%9a%94%e7%a6%bb)) 下,我们通过追踪事务间的因果依赖(即读写数据集依赖)来检测写偏序。 - 在 Alice 和 Bob 看足球比赛的例子中,Bob 在 Alice 表示结果已经出来之后,仍然没有看到网页结果,便是违反了因果关系:Alice 的说法基于比赛结果已经出来的事实,因此 Bob 在听到 Alice 的陈述之后,应该当能看到比赛结果。图片尺寸调整的例子也是类似。 @@ -308,7 +308,7 @@ CAP 最初被提出只是一个为了激发数据库取舍讨论的模糊的取 ### 因果序非全序 -**全序**(total order)意味着**系统内任意两个元素可比大小**。如,自然数是全序:任举两个自然数,比如 5 和 13,我们可以确定 13 是比 5 大的。但与此相对,数学中的集合就不是全序的,比如我们无从比较 {a, b} 和 {b, c} 的大小关系,因为他们互不为对方子集。对于这种情况,我们称其**不可比**(incomparable)。反之,集合是**偏序**(partially ordered):在某些情况下,我们可以说一个集合比另一个集合大(两个集合间有包含关系);但在另外一些情况下,两个集合间没有可比关系。 +**全序**(total order)意味着**系统内任意两个元素可比大小**。如,自然数是全序:任举两个自然数,比如 5 和 13,我们可以确定 13 是比 5 大的。但与此相对,数学中的集合就不是全序的,比如我们无从比较 {a, b} 和 {b, c} 的大小关系,因为它们互不为对方子集。对于这种情况,我们称其**不可比**(incomparable)。反之,集合是**偏序**(partially ordered):在某些情况下,我们可以说一个集合比另一个集合大(两个集合间有包含关系);但在另外一些情况下,两个集合间没有可比关系。 全序和偏序的区别还反应在不同强度**数据库一致性模型**(database consistency models)上: @@ -331,7 +331,7 @@ CAP 最初被提出只是一个为了激发数据库取舍讨论的模糊的取 > 用我们之前的图模型来说,就是**不存在环**。即,**因果一致性**(有向无环图) ⇒ **线性一致性**(在有向无环图的基础上,**存在一条能串起所有点的单向路径**)。 -线性一致性能能够保证因果关系,该特点让系统易于使用,从而对应用层很有吸引力。但任何事情都是有代价的,如我们在之前线性一致性的代价一节中所讨论的:**提供线性一致性非常伤性能和可用性,在网络有显著延迟时**(如全球部署的系统)**,该副作用尤其明显**。因此,很多系统会舍弃线性一致性以换取更好的性能,但当然,代价是更难用了。 +线性一致性能够保证因果关系,该特点让系统易于使用,从而对应用层很有吸引力。但任何事情都是有代价的,如我们在之前线性一致性的代价一节中所讨论的:**提供线性一致性非常伤性能和可用性,在网络有显著延迟时**(如全球部署的系统)**,该副作用尤其明显**。因此,很多系统会舍弃线性一致性以换取更好的性能,但当然,代价是更难用了。 好消息是存在折中路线。线性一致性并非保持因果关系的唯一途径,还有很多其他办法。也即,一个系统可以不必承担线性一致性所带来的性能损耗,而仍然是**因果一致的**(consistent)。当然,在这种情况下,CAP 定理是不适用的。事实上,**因果一致性**是系统在保证**有网络延迟而不降低性能、在有网络故障而仍然可用**的情况下,能够提供的最强一致性模型。 @@ -349,7 +349,7 @@ CAP 最初被提出只是一个为了激发数据库取舍讨论的模糊的取 为了确定因果依赖,我们需要某种手段来描述系统中节点的“**知识**”(knowledge)。如果某个节点在收到 Y 的写入请求时已经看到了值 X,则 X 和 Y 间可能会存在着因果关系。就如在调查公司的欺诈案时,CEO 常被问到,“你在做出 Y 决定时知道 X 吗”? -确定哪些操作先于哪些些操作发生的方法类似于我们在“[并发写入检测](https://ddia.qtmuniao.com/#/ch05?id=%e5%b9%b6%e5%8f%91%e5%86%99%e5%85%a5%e6%a3%80%e6%b5%8b)”一节讨论的技术。那一节针对无主模型讨论了如何检测针对单个 Key 的并发写入,以防止更新丢失问题。因果一致性所需更多:**需要在整个数据库范围内追踪所有 Key 间操作的因果依赖,而非仅仅单个 Key 上**。**版本向量**(version vectors)常用于此道。 +确定哪些操作先于哪些操作发生的方法类似于我们在“[并发写入检测](https://ddia.qtmuniao.com/#/ch05?id=%e5%b9%b6%e5%8f%91%e5%86%99%e5%85%a5%e6%a3%80%e6%b5%8b)”一节讨论的技术。那一节针对无主模型讨论了如何检测针对单个 Key 的并发写入,以防止更新丢失问题。因果一致性所需更多:**需要在整个数据库范围内追踪所有 Key 间操作的因果依赖,而非仅仅单个 Key 上**。**版本向量**(version vectors)常用于此道。 为了解决确定因果顺序,数据库需要知道应用读取数据的**版本信息**。这也是为什么在图 5-13 中(参见 [确定 Happens-Before 关系](https://ddia.qtmuniao.com/#/ch05?id=%e7%a1%ae%e5%ae%9a-happens-before-%e5%85%b3%e7%b3%bb)),我们在写入数据时需要知道先前读取操作中数据库返回的版本号。在 SSI 的冲突检测(参见[可串行的快照隔离](https://ddia.qtmuniao.com/#/ch07?id=%e5%8f%af%e4%b8%b2%e8%a1%8c%e7%9a%84%e5%bf%ab%e7%85%a7%e9%9a%94%e7%a6%bb))中也有类似的思想:当一个事务提交时,数据库需要检查其读取集合中的数据版本是否仍然是最新的。为此,数据库需要跟踪一个事务读取了哪些数据的哪些版本。 @@ -375,7 +375,7 @@ CAP 最初被提出只是一个为了激发数据库取舍讨论的模糊的取 2. **可以为每个操作关联一个日历时钟**(或者说物理时钟)。这些时间戳不是有序的(因为回拨?),但如果有足够的精读,就可以让任意两个操作关联的时间戳不同,依次也可以达到全序的目的。此种方法有时候会被用在解决冲突使用后者胜的策略(但会有风险)。 3. **每次可以批量产生一组序列号**。比如,在请求序列号时,节点 A 可以一次性声明占用 1 ~ 1000 的序列号,节点 B 会一次占用 1001~2000 的序列号。则本地的操作可以从拿到的这批序列号中直接分配,仅在快耗尽时再去请求一批。这种方法常被用在 TSO(timestamp oracle,单点授时)的优化中。 -这三种方案都要比使用单点计数器生成序列号要性能好、扩展性更强,且能为系统中的每个操作产生全局唯一的、**近似递增**的序列号。但他们都存在着同样的问题:**产生的序列号不是因果一致的**。 +这三种方案都要比使用单点计数器生成序列号要性能好、扩展性更强,且能为系统中的每个操作产生全局唯一的、**近似递增**的序列号。但它们都存在着同样的问题:**产生的序列号不是因果一致的**。 由于这些序列号生成方法都不能够很好地捕捉跨节点的操作因果关系,因此都存在因果问题: @@ -413,15 +413,15 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于 ### 时间戳定序还不够 -尽管 Lamport 时间戳能够给出一种能够追踪因果关系的全序时间戳生成算法,但并不足以解决分布式系统中所面临的的很多基本问题。 +尽管 Lamport 时间戳能够给出一种能够追踪因果关系的全序时间戳生成算法,但并不足以解决分布式系统中所面临的很多基本问题。 举个例子,考虑一个系统,在该系统中,以用户名唯一确定一个账户。如果两个用户并发的用同一个用户名创建账户,则一个成功,另一个失败(参见[领导者和锁](https://ddia.qtmuniao.com/#/ch08?id=%e9%a2%86%e5%af%bc%e8%80%85%e5%92%8c%e9%94%81))。 第一感觉,对所有事件进行全序定序(如使用 Lamport 时间戳)能够解决该问题:如果系统收到两个具有相同用户名的账户创建请求,让具有较小时间戳的那个请求成功,让另一个失败。由于所有时间戳满足全序关系,这两个请求的时间戳总是可以比的。 -该方法能够确定赢家基于一个隐藏假设:**当你拿到系统中所有的账户创建操作后,你才可以比较他们的时间戳**。然而,在收到某个账户创建请求时,系统中单个节点并不能**立即独自的**判断该请求成功还是失败。此时此刻,该节点并不知道其他节点是否收到了具有同样用户名的账户创建请求,以及其请求的时间戳是大还是小。 +该方法能够确定赢家基于一个隐藏假设:**当你拿到系统中所有的账户创建操作后,你才可以比较它们的时间戳**。然而,在收到某个账户创建请求时,系统中单个节点并不能**立即独自的**判断该请求成功还是失败。此时此刻,该节点并不知道其他节点是否收到了具有同样用户名的账户创建请求,以及其请求的时间戳是大还是小。 -如果其他节点收到同名账户的创建请求,并且获得了较小的时间戳,本节点的创建请求就得失败。为了避免这一点,它需要不断和其他节点沟通,以知晓他们在做啥。但如果沟通时,其他节点宕机或者网络出现问题,则可能会导致系统陷入停顿而不能提供服务。这显然不符合我们对一个高可用系统的期望。 +如果其他节点收到同名账户的创建请求,并且获得了较小的时间戳,本节点的创建请求就得失败。为了避免这一点,它需要不断和其他节点沟通,以知晓它们在做啥。但如果沟通时,其他节点宕机或者网络出现问题,则可能会导致系统陷入停顿而不能提供服务。这显然不符合我们对一个高可用系统的期望。 上述问题的核心在于,**只有在收集到系统中所有操作之后,才能真正确定所有操作的全序**。如果其他节点正在进行某些操作,但你并不知晓,也就自然不能确定最终的事件的全序:毕竟这些未知节点的操作可能被插入到不同位置。举个例子,本节点的事件顺序为 n1e1, n1e2, n1e3,另外一个节点有两个事件,顺序为 n2e1, n2e2,将两个序列进行合并时,会有多种可能的结果。这有点类似于多个并发事务中的读写序列定序。 @@ -465,7 +465,7 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于 ### 使用全序广播实现线性一致性存储 -如图 9-4,在线性一致系统中,所有操作存在着一个全局序列。这是否意味着全序广播就是线性一致性?不尽然,但他们间有很深的联系。 +如图 9-4,在线性一致系统中,所有操作存在着一个全局序列。这是否意味着全序广播就是线性一致性?不尽然,但它们间有很深的联系。 全序广播是**异步的**:系统保证以同样的**顺序**交付消息,但并不保证消息的交付**时刻**(即,有的消息接收者间可能存在着滞后)。与之相对,线性一致性是一种**新鲜度保证**:读取一定能看到最新成功的写。 @@ -512,7 +512,7 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于 在分布式计算领域,共识问题是最重要而基础的问题。从表面上看含义很直接:可以粗略的理解为**多个节点就某件事达成共识**。乍看起来,你会觉得,这有什么难的?但不幸的是,很多系统都因为低估了共识算法的实现难度而问题百出。 -尽管共识问题非常之重要,但在本书中直到现在才才被提及,似乎有点晚了。这是因为这个主题实在是太艰深了,而欣赏其精妙需要非常多的前置知识。即使在学术界,对共识问题的研究也是历经数十年坎坷才逐渐有了一些沉淀。在本书里,我们在第五章铺垫了**冗余**(replication),在第七章铺陈了事务,在第八章探讨了分布式系统的**系统模型**,在本章又讨论了**线性一致性和全序广**播,到现在,我们终于做足了准备来好好谈谈共识问题了。 +尽管共识问题非常之重要,但在本书中直到现在才被提及,似乎有点晚了。这是因为这个主题实在是太艰深了,而欣赏其精妙需要非常多的前置知识。即使在学术界,对共识问题的研究也是历经数十年坎坷才逐渐有了一些沉淀。在本书里,我们在第五章铺垫了**冗余**(replication),在第七章铺陈了事务,在第八章探讨了分布式系统的**系统模型**,在本章又讨论了**线性一致性和全序广播**,到现在,我们终于做足了准备来好好谈谈共识问题了。 在很多场景下让多个节点达成共识是非常重要的。比如: @@ -568,7 +568,7 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于 ![Untitled](img/ch09-fig09.png) -> **不要混淆 2PC 和 2PL**。Two-phase commit (2PC) 和 two-phase locking (2PL,参见[两阶段锁](https://ddia.qtmuniao.com/#/ch07?id=%e4%b8%a4%e9%98%b6%e6%ae%b5%e9%94%81)) 是两个完全不同的概念。2PC 是为了在分布式系统中进行原子提交,而 2PL 是为了进行事务并发控制的一种加锁方式。为了避免歧义,可以忽略他们在名字简写上的相似性,而把它们当成完全不同的概念。 +> **不要混淆 2PC 和 2PL**。Two-phase commit (2PC) 和 two-phase locking (2PL,参见[两阶段锁](https://ddia.qtmuniao.com/#/ch07?id=%e4%b8%a4%e9%98%b6%e6%ae%b5%e9%94%81)) 是两个完全不同的概念。2PC 是为了在分布式系统中进行原子提交,而 2PL 是为了进行事务并发控制的一种加锁方式。为了避免歧义,可以忽略它们在名字简写上的相似性,而把它们当成完全不同的概念。 2PC 引入了一个单机事务中没有的角色:**协调者**(coordinator,有时也被称为事务管理器,transaction manager)。协调者通常以库的形式出现,并会嵌入到请求事务的应用进程中,但当然,它也可以以单独进程或者服务的形式出现。比如说,Narayana, JOTM, BTM, or MSDTC. @@ -630,7 +630,7 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于 ## 实践中的分布式事务 -分布式事务,尤其是使用两阶段提交实现的分布式事务,毁誉参半。一方面,他们可以提供其他方式难以实现的**安全保证**;另一方面,由于运维复杂、降低性能、承诺过多,他们广受诟病。为了避免分布式事务带来的运维复杂度,很多云服务选择不支持分布式事务。 +分布式事务,尤其是使用两阶段提交实现的分布式事务,毁誉参半。一方面,它们可以提供其他方式难以实现的**安全保证**;另一方面,由于运维复杂、降低性能、承诺过多,它们广受诟病。为了避免分布式事务带来的运维复杂度,很多云服务选择不支持分布式事务。 很多分布式事务的实现会带来严重的性能下降——如 MySQL 中的分布式事务据说比单机事务慢一个数量级,也无怪乎人们建议不要用。两阶段提交的很多性能损耗是算法内生的: @@ -682,7 +682,7 @@ XA 不是一个网络协议——它定义了一组和事务协调者交互的 C 数据库在提交或者中止事务前**不能够释放获取的这些锁**。因此,在使用两阶段提交时,一个事务必须在其处于未定状态期间一直持有锁。如果协调者在宕机后花了 20 分钟才重新启动起来,则对应参与者的锁就要持有 20 分钟。如果参与者日志由于某种原因丢掉了,这些锁会被永远的持有——除非系统管理员会手动释放它们。 -如果这些锁一直被持有,则其他事务不能够更改这些数据。取决于数据库的实现,有些事务甚至会在读这些行的数据是被阻塞。因此,其他的事务并不能正常的运行——如果他们要访问这些上锁的数据,就会被阻塞。这会造成应用的大部分功能不可用,直到未定事务被解决。 +如果这些锁一直被持有,则其他事务不能够更改这些数据。取决于数据库的实现,有些事务甚至会在读这些行的数据时被阻塞。因此,其他的事务并不能正常的运行——如果它们要访问这些上锁的数据,就会被阻塞。这会造成应用的大部分功能不可用,直到未定事务被解决。 ### 从协调者故障中恢复 @@ -738,11 +738,11 @@ XA 事务解决了一些很现实而重要的难题:让异构的数据系统 ### 全序广播中的共识算法 -最广为人知的容错性的共识算法有——VSR(Viewstamped Replication)、Paxos、Raft 和 Zab。这些共识算法间有非常多的共同点,但他们确实不完全相同(虽然 Lamport 说过类似,世界上只有一种共识算法——Paxos)。在本书中我们不会探究每个共识算法的区别的所有细节:只需知道他们在顶层设计中有很多相似之处即可。除非,你想自己实现一个共识算法。 +最广为人知的容错性的共识算法有——VSR(Viewstamped Replication)、Paxos、Raft 和 Zab。这些共识算法间有非常多的共同点,但它们确实不完全相同(虽然 Lamport 说过类似,世界上只有一种共识算法——Paxos)。在本书中我们不会探究每个共识算法的区别的所有细节:只需知道它们在顶层设计中有很多相似之处即可。除非,你想自己实现一个共识算法。 > 当然,并不推荐这么做,因为实现一个工业级可用的共识算法很难,需要处理特别多的边角情况,而这些情况不经过大量实践是根本不会想到的。虽然 TLA 可以验证你的算法,但并不能验证你的实现。 -这些共识算法通常不会直接按上述形式化的定义(如提议并在**单值上**进行决策,同时满足一致性、正直性,有效性和可终止性)来实现。转而,他们通常会在**一系列值**上做出决策,从而事实上变成一种**全序广播算法**,本章前面小节讨论过这个问题。 +这些共识算法通常不会直接按上述形式化的定义(如提议并在**单值上**进行决策,同时满足一致性、正直性,有效性和可终止性)来实现。转而,它们通常会在**一系列值**上做出决策,从而事实上变成一种**全序广播算法**,本章前面小节讨论过这个问题。 全序广播等价于多轮次的共识协议(每个轮次,会使用共识协议对全序广播中的一条**消息**的全局顺序做出决策): @@ -767,7 +767,7 @@ VSR,Raft 和 Zab 都直接实现了全序广播,相对多次使用共识算 ### 纪元编号和法定人数 -到目前为止所提到的共识算法都在内部需要一个**某种形式上**的主节点,但都不能保证主节点是唯一的。但,他们可以给出一个稍弱的保证:协议会定义一个**纪元编号**(epoch number;在 Paxos 中称为**投票编号**,ballot number;在 Viewstamp Replication 中称为**视图编号**,view number;在 Raft 中称为**任期编号**,term number),并且保证在每一个纪元(epoch)内,主节点是唯一的。 +到目前为止所提到的共识算法都在内部需要一个**某种形式上**的主节点,但都不能保证主节点是唯一的。但,它们可以给出一个稍弱的保证:协议会定义一个**纪元编号**(epoch number;在 Paxos 中称为**投票编号**,ballot number;在 Viewstamp Replication 中称为**视图编号**,view number;在 Raft 中称为**任期编号**,term number),并且保证在每一个纪元(epoch)内,主节点是唯一的。 每次当前的主节点被认为下线时(可能是宕机,也可能只是网络不通),所有认为该主下线的节点就会发起选举,以选出新的主节点。每次选举会使用一个更高的纪元编号,因此所有的纪元编号是全序且单调递增的。如果不同纪元中有两个节点都认为自己是主(比如之前的主节点并没有宕机),则具有较高纪元编号的主节点胜出。 @@ -784,7 +784,7 @@ VSR,Raft 和 Zab 都直接实现了全序广播,相对多次使用共识算 ### 共识算法的局限性 -共识算法对于分布式系统是一个划时代的突破:他们能够在不确定的环境里保证**安全性**(一致性、正直性和有效性),在此基础上还能够进行**容错**(只要大多数节点还活着就能正常运转)。他们还实现了全序广播,因此能够用来实现容错的线性一致的系统。 +共识算法对于分布式系统是一个划时代的突破:它们能够在不确定的环境里保证**安全性**(一致性、正直性和有效性),在此基础上还能够进行**容错**(只要大多数节点还活着就能正常运转)。它们还实现了全序广播,因此能够用来实现容错的线性一致的系统。 然而,共识算法并非银弹,因为这些收益都是有代价的。 @@ -805,7 +805,7 @@ VSR,Raft 和 Zab 都直接实现了全序广播,相对多次使用共识算 1. 你可以读取或者写入给定 key 的 value 2. 你也可以遍历一组 keys -如果这些系统本质上是数据库,为什么它们要费这么大力气实现共识算法呢?到底是什么让他们区别于一般意义上的数据库? +如果这些系统本质上是数据库,为什么它们要费这么大力气实现共识算法呢?到底是什么让它们区别于一般意义上的数据库? 为了弄清该问题的答案,我们需要简单的探讨下**如何使用类似 Zookeeper 这样的服务**。作为一个应用开发者,你很少直接使用 Zookeeper,因为它并不能作为通常意义上的数据库而直接被应用层使用。它更像是一种你在使用其他项目时间接依赖:例如,Hbase,Hadoop YARN,OpenStack Nove 和 Kafka 都在背后依赖了 Zookeeper。这些项目到底依赖 Zookeeper 的什么呢? @@ -850,4 +850,4 @@ ZooKeeper 及类似服务可以视为**成员服务**(membership services) 成员服务可以确定当前**集群中哪些节点当前是存活的**。如第八章中所说,在具有无界延迟的网络中,不可能可靠的检测出一个节点是否故障。然而,如果你综合使用故障检测和共识算法,所有节点能够对哪些节点存活这件事达成共识。 -使用共识协议也有可能错将一个节点认为下线了,尽管它事实上是存活的。但尽管如此,只要系统能够对当前系统包含哪些节点达成共识,就仍然很有用处。例如,选主算法可以是——在系统当前所有节点中选一个具有最小标号的节点。如果所有节点对系统当前包含哪些节点存在分歧,则这种方法就不能正常工作(不同节点眼中的的最小编号节点可能不一致,从而让大家选出的主不一致)。 +使用共识协议也有可能错将一个节点认为下线了,尽管它事实上是存活的。但尽管如此,只要系统能够对当前系统包含哪些节点达成共识,就仍然很有用处。例如,选主算法可以是——在系统当前所有节点中选一个具有最小标号的节点。如果所有节点对系统当前包含哪些节点存在分歧,则这种方法就不能正常工作(不同节点眼中的最小编号节点可能不一致,从而让大家选出的主不一致)。 diff --git a/ch10.md b/ch10.md index dc8ebdc..8acb64e 100644 --- a/ch10.md +++ b/ch10.md @@ -29,9 +29,9 @@ web 服务和日趋增长的基于 HTTP/REST 的 API,让请求/应答风格 实际上,批处理是一种非常古老的计算形式。在可编程的数字计算机发明之前,**打孔卡制表机**——比如用于 1890 年美国人口普查的 Hollerith 制表机(IBM 前身生产的)——实现了一种对大量输入的半机械化批处理。MapReduce 与二十世纪四五十年代 IBM 生产的卡片分类机有着惊人的相似。就像我们常说的,历史总是在自我重复。 -在本章,我们将会介绍 MapReduce 和其他几种批处理算法和框架,并探讨下他们如何用于现代数据系统中。作为引入,我们首先来看下使用标准 **Unix 工具**进行数据处理。尽管你可能对 Unix 工具链非常熟悉,但对 Unix 的哲学做下简单回顾仍然很有必要,因为我们可以将其经验运用到大规模、异构的分布式数据系统中。 +在本章,我们将会介绍 MapReduce 和其他几种批处理算法和框架,并探讨下它们如何用于现代数据系统中。作为引入,我们首先来看下使用标准 **Unix 工具**进行数据处理。尽管你可能对 Unix 工具链非常熟悉,但对 Unix 的哲学做下简单回顾仍然很有必要,因为我们可以将其经验运用到大规模、异构的分布式数据系统中。 -# 使用Unix工具进行批处理 +# 使用 Unix 工具进行批处理 让我们从一个简单的例子开始。设你有一个 web 服务器,并且当有请求进来时,服务器就会向日志文件中追加一行日志: @@ -70,7 +70,7 @@ cat /var/log/nginx/access.log | #(1) 1. 读取给定日志文件 2. 将每一行按空格分成多个字段,然后取出第七个,即我们关心的 URL 字段。在上面的例子中,即:`/css/typography.css` -3. 按字符序对所有 url 进行排序。如果某个 url 出现了 n 次,则排序后他们会连着出现 n 次。 +3. 按字符序对所有 url 进行排序。如果某个 url 出现了 n 次,则排序后它们会连着出现 n 次。 4. `uniq` 命令会将输入中相邻的重复行过滤掉。`-c` 选项告诉命令输出一个计数:对于每个 URL,输出其重复的次数。 5. 第二个 `sort` 命令会按每行起始数字进行排序(`-n`),即按请求次数多少进行排序。`-r` 的意思是按出现次数降序排序,不加该参数默认是升序的。 6. 最后,`head` 命令会只输出前 5 行,丢弃其他多余输入。 @@ -87,7 +87,7 @@ cat /var/log/nginx/access.log | #(1) 如果你对 Unix 工具链不熟悉,读懂上面这一串命令可能会有点吃力,但只要理解之后就会发现它们非常强大。这个组合可以在数秒内处理上 G 的日志文件,并且,如果需求发生变动,也可以很方便的重新组合命令。比如,如果你想**在输出中跳过 CSS 文件**,可以将 awk 的参数改成 `'$7 !~ /\.css$/ {print $7}'` 。如果你想**统计最常访问的 IP 数**而非访问网页,则可以将 awk 的参数变为 `'{print $1}'`。如此种种。 -本书中没有余力去详细讨论所有 Unix 工具使用细节,但他们都很值得一学。你可以在短短几分钟内,通过灵活组合 awk, sed, grep, sort, uniq, 和 xargs 等命令,应对很多数据分析需求,并且性能都相当不错。 +本书中没有余力去详细讨论所有 Unix 工具使用细节,但它们都很值得一学。你可以在短短几分钟内,通过灵活组合 awk, sed, grep, sort, uniq, 和 xargs 等命令,应对很多数据分析需求,并且性能都相当不错。 ### 链式命令 vs 专用程序 @@ -136,7 +136,7 @@ Doug McIlroy,Unix 管道(pipe)的发明人,在 1964 年是这样描述 1. **每一个程序专注干一件小事**。在想做一个新任务时,新造一个轮子,而非向已有的程序中增加新的“功能”。 2. **每个程序的输出成为其他程序(即便下一个程序还没有确定)的输入**。不要在输出中混入无关信息(比如在数据中混入日志信息),避免使用严格的列式数据(数据要面向行,以行为最小粒度?)或者二进制数据格式。不要使用**交互式输入**。 3. **尽快的设计和构建软件**,即便复杂如操作系统,也最好在几周内完成(译注:这里翻译稍微有些歧义,即到底是尽快迭代还是尽早让用户试用,当然他们最终思想差不多,即构造最小可用模型,试用-迭代)。对于丑陋部分,不要犹豫,立即推倒重构。 -4. (Q:unskilled help是指?这一条没太理解)**相比不成熟的帮助,更倾向于使用工具完成编程任务**,即使可能会进行反复构建相似的工具,并且在用完之后大部分工具就再也不会用到。 +4. (Q:unskilled help 是指?这一条没太理解)**相比不成熟的帮助,更倾向于使用工具完成编程任务**,即使可能会进行反复构建相似的工具,并且在用完之后大部分工具就再也不会用到。 这些手段——尽可能自动化、快速原型验证、小步增量迭代、易于实验测试,将大型工程拆解成一组易于管理的模块——听起来非常像今天的**敏捷开发**和 **DevOps 运动**。令人惊讶的是,很多软件工程的核心思想在四十年间并没有太多变化。 @@ -185,7 +185,7 @@ Unix 工具生态如此成功的另外一个原因是,可以很方便让用户 然而,Unix 工具最大的局限在于**只能运行在单机上**——这也是大数据时代人们引入 Hadoop 的进行数据处理的原因——单机尺度已经无法处理如此巨量的数据。 -# MapReduce和分布式文件系统 +# MapReduce 和分布式文件系统 MapReduce 在某种程度上有点像 Unix 工具,但不同之处在于可以分散到上千台机器上并行执行。和 Unix 工具一样,MapReduce 虽然看起来简单粗暴,但组合起来却非常强大。一个 MapReduce 任务就像一个 Unix 进程:**接受一到多个输入,产生一到多个输出**。 @@ -221,7 +221,7 @@ MapReduce 是一个**编程框架**,你可以基于 MapReduce 编写代码以 1. **读取一组输入文件,将其切分为记录(records)**。在网站服务器日志的例子中,每个记录就是日志中的一行(即,使用 \n 作为记录分隔符) 2. **调用 Mapper 函数从每个记录中抽取 key 和 value**。在之前的例子中,mapper 函数是 `awk '{print $7}'` :抽取 URL($7)作为 key,value 留空。 3. **将所有的 key-value 对按 key 进行排序**。在前面例子中,该环节由 sort 承担。 -4. **调用 Reducer 函数对排好序的 kv 列表迭代处理**。如果某个 key 出现了多次,排序环节会让其在在列表中集中到一块,因此可以在不在内存中保存过多状态的的情况下,对具有相同 key 的数据进行汇总处理。在前面例子中,reducer 对应命令 `uniq -c` ,功能是对所有具有相同 key 的记录值进行计数。 +4. **调用 Reducer 函数对排好序的 kv 列表迭代处理**。如果某个 key 出现了多次,排序环节会让其在列表中集中到一块,因此可以在不在内存中保存过多状态的情况下,对具有相同 key 的数据进行汇总处理。在前面例子中,reducer 对应命令 `uniq -c` ,功能是对所有具有相同 key 的记录值进行计数。 这四个步骤(split-map-sort-reduce)可以通过一个 MapReduce 任务来实现。你可以在步骤 2 (map)和步骤 4(reduce)编写代码来自定义数据处理逻辑。步骤 1 (将文件拆分成记录)由**输入格式解析器**(input format parser)来完成。步骤 3,排序阶段,由 MapReduce 框架**隐式完成**,所有 Mapper 的输出在给到 Reducer 前,框架都会对其进行排序。 @@ -240,11 +240,11 @@ MapReduce 是一个**编程框架**,你可以基于 MapReduce 编写代码以 ### MapReduce 的分布式执行 -与 Unix 工具流水线的相比,MapReduce 的最大区别在于可以在**多台机器上**进行分布式的执行,但并不需要用户显式地写处理并行的代码。mapper 和 Reducer 函数每次只处理一个记录;他们不必关心输入从哪里来,输出要到哪里去,框架会处理分布式系统所带来的的复杂度(如在机器间移动数据的)。 +与 Unix 工具流水线的相比,MapReduce 的最大区别在于可以在**多台机器上**进行分布式的执行,但并不需要用户显式地写处理并行的代码。mapper 和 Reducer 函数每次只处理一个记录;它们不必关心输入从哪里来,输出要到哪里去,框架会处理分布式系统所带来的复杂度(如在机器间移动数据的)。 虽然可以使用 Unix 工具作为分布式计算中的 Mapper 和 reducer,但更为常见的是使用**通用编程语言**的函数来实现这两个回调。在 Hadoop MapReduce 中,mapper 和 Reducer 是需要实现特殊接口的类(本质上只需要一个函数,因为不需要保存状态。但在 Java 老版本中,函数不是一等公民,所以需要一个类来包裹);在 MongoDB 和 CouchDB 中,mapper 和 Reducer 是 JavaScript 函数。 -图 10-1 中展示了 Hadoop MapReduce 任务中的数据流。其并行是基于分片的的:任务的输入通常是 HDFS 中的一个文件夹,输入文件夹中的每个文件或者文件块是一个可被 Map 子任务(task)处理的分片。 +图 10-1 中展示了 Hadoop MapReduce 任务中的数据流。其并行是基于分片的:任务的输入通常是 HDFS 中的一个文件夹,输入文件夹中的每个文件或者文件块是一个可被 Map 子任务(task)处理的分片。 ![Untitled](img/ch10-fig01.png) @@ -284,7 +284,7 @@ Reducer 在调用时会传入一个 key 一个 Iterator(迭代器),使用 因此,链式调用的 MapReduce 任务不太像多个 Unix 命令组成的**流水线**(仅使用一小段缓冲区,就可以将数据流从一个命令的输出引向另一个命令的输入),而更像一组以文件为媒介进行链式调用的命令,即前一个命令将输出写入**中间文件**,后一个命令读取该文件作为输入。这种方式有利有弊,我们将会在“**对中间结果进行物化**”一节进行讨论。 -仅当一个任务完全成功的执行后,其输出才被认为是有效的(也即,MapReduce 任务会丢掉失败任务的不完整输出)。因此,工作流中的任务只有在前一个任务成功结束后才能启动——即,前驱任务必须**成功地**将输出写入到对应文件夹中。为了处理多个任务间执行的依赖关系(比如 DAG 依赖),人们开发了很多针对 Hadoop的工作流调度框架,如 Oozie,Azkaban,Luigi,Airflow 和 Pinball。 +仅当一个任务完全成功的执行后,其输出才被认为是有效的(也即,MapReduce 任务会丢掉失败任务的不完整输出)。因此,工作流中的任务只有在前一个任务成功结束后才能启动——即,前驱任务必须**成功地**将输出写入到对应文件夹中。为了处理多个任务间执行的依赖关系(比如 DAG 依赖),人们开发了很多针对 Hadoop 的工作流调度框架,如 Oozie,Azkaban,Luigi,Airflow 和 Pinball。 在需要调度的任务非常多时,这些工作流管理框架非常有用。在构建推荐系统时,一个包含 50 到 100 个 MapReduce 的工作流非常常见。此外,在大型组织中,不同团队的任务相互依赖非常常见。在这些复杂的工作流场景中,借助工具十分必要。 @@ -355,7 +355,7 @@ MapReduce 编程模型,可以将计算的**物理拓扑**(将数据放到合 分组的另外一个使用场景是:收集某个用户会话中的所有用户活动——也称为**会话化**(sessionization)。例如,可以用来对比用户对于新老版本网站的分别购买意愿(A/B 测试)或者统计某些市场推广活动是否起作用。 -假设你的 web 服务架设在多台服务器上,则某个特定用户的活动日志大概率会分散在不同服务器上。这时,你可以实现一个会话化的 MapReduce 程序,使用会话 cookie、用户 ID或者其他类似的 ID 作为分组 key,以将相**同用户**的所有活动记录聚集到一块、并将**不同用户**分散到多个分区进行处理。 +假设你的 web 服务架设在多台服务器上,则某个特定用户的活动日志大概率会分散在不同服务器上。这时,你可以实现一个会话化的 MapReduce 程序,使用会话 cookie、用户 ID 或者其他类似的 ID 作为分组 key,以将相**同用户**的所有活动记录聚集到一块、并将**不同用户**分散到多个分区进行处理。 ### 处理偏斜(**skew**) @@ -482,7 +482,7 @@ MapReduce 任务在处理输出时,遵从同样的哲学。通过**不改变 - **数据复用**。同一个文件集能够作为不同任务的输入,包括用于计算指标的监控任务、评估任务的输出是否满足预期性质(如,和之前一个任务的比较并计算差异)。 - **逻辑布线分离**。和 Unix 工具一样,MapReduce 也将逻辑和接线分离(通过配置输入、输出文件夹),从而分拆复杂度并且提高代码复用度:一些团队可以专注于实现干好单件事的任务开发;另一些团队可以决定在哪里、在何时来组合跑这些代码。 -在上述方面,Unix 中用的很好地一些设计原则也适用 Hadoop——但 Unix 工具和 Hadoop 也有一些不同的地方。比如,大部分 Unix 工具假设输入输出是无类型的文本,因此不得不花一些时间进行输入解析(比如之前的例子中,需要按空格分割,然后取第 7 个字段,以提取 URL)。在 Hadoop 中,通过使用**更结构化的数据格式**,消除了底层的一些低价值的语法解析和转换:Avro (参见[Avro](https://ddia.qtmuniao.com/#/ch04?id=avro))和 Parquet 是较常使用的两种编码方式,他们提供基于模式的**高效编码方式**,并且支持**模式版本的演进**。 +在上述方面,Unix 中用的很好地一些设计原则也适用 Hadoop——但 Unix 工具和 Hadoop 也有一些不同的地方。比如,大部分 Unix 工具假设输入输出是无类型的文本,因此不得不花一些时间进行输入解析(比如之前的例子中,需要按空格分割,然后取第 7 个字段,以提取 URL)。在 Hadoop 中,通过使用**更结构化的数据格式**,消除了底层的一些低价值的语法解析和转换:Avro (参见[Avro](https://ddia.qtmuniao.com/#/ch04?id=avro))和 Parquet 是较常使用的两种编码方式,它们提供基于模式的**高效编码方式**,并且支持**模式版本的演进**。 --- @@ -519,7 +519,7 @@ MPP 数据库是一种将硬盘上的存储布局、查询计划生成、调度 MapReduce 使工程师能够在大型数据集尺度上轻松的运行自己的代码(而不用关心底层分布式的细节)。如果你已经有 HDFS 集群和 MapReduce 计算框架,你可以基于此构建一个 SQL 查询执行引擎, Hive 项目就是这么干的。当然,对于一些不适合表达为 SQL 查询的处理需求,也可以基于 Hadoop 平台来构建一些其他形式的批处理逻辑。 -但后来人们又发现,对于某些类型的数据处理, MapReduce 限制太多、性能不佳,因此基于 Hadoop开发了各种其他的处理模型(在之后 “MapReduce 之外”小节中会提到一些)。仅有 SQL 和 MapReduce 这两种处理模型是不够的,我们需要更多的处理模型!由于Hadoop平台的开放性,我们可以较为容易实现各种处理模型。然而,在 MPP 数据库的限制下,我们想支持更多处理模型基本是不可能的。 +但后来人们又发现,对于某些类型的数据处理, MapReduce 限制太多、性能不佳,因此基于 Hadoop 开发了各种其他的处理模型(在之后 “MapReduce 之外”小节中会提到一些)。仅有 SQL 和 MapReduce 这两种处理模型是不够的,我们需要更多的处理模型!由于 Hadoop 平台的开放性,我们可以较为容易实现各种处理模型。然而,在 MPP 数据库的限制下,我们想支持更多处理模型基本是不可能的。 更为重要的是,基于 Hadoop 实现的各种处理模型可以**共享集群并行运行**,且不同的处理模型都可以访问 HDFS 上的相同文件。在 Hadoop 生态中,无需将数据在不同的特化系统间倒来倒去以进行不同类型的处理:**Hadoop 系统足够开放,能够以单一集群支持多种负载类型**。**无需移动数据**让我们更容易的从数据中挖掘价值,也更容易开发新的处理模型。 @@ -552,9 +552,9 @@ Hadoop 生态系统既包括随机访问型的 OLTP 数据库,如HBase(参 但在开源的集群调度系统中,可抢占调度并不普遍。YARN 的 CapacityScheduler 支持抢占以在不同队列间进行资源的均衡,但到本书写作时,YARN、Mesos、Kubernetes 都不支持更为通用的按优先级抢占调度。在抢占不频繁的系统中,MapReduce 这种设计取舍就不太有价值了。在下一节,我们会考察一些做出不同取舍的 MapReduce 的替代品。 -# MapReduce之外 +# MapReduce 之外 -尽管 MapReduce 在本世纪10年代最后几年中被炒的非常热,但它其实只是众多分布式系统编程模型中的一种。在面对不同的数据量、数据结构和数据处理类型时,很多其他计算模型可能更为合适。 +尽管 MapReduce 在本世纪 10 年代最后几年中被炒的非常热,但它其实只是众多分布式系统编程模型中的一种。在面对不同的数据量、数据结构和数据处理类型时,很多其他计算模型可能更为合适。 但作为分布式系统之上的一种抽象, MapReduce 非常干净、简洁,很适合作为入门的学习对象,因此我们本章花了很多篇幅来讨论它。但需要指出,这里的**简洁**指的是**易于理解,而非易于使用**。恰恰相反,使用裸的 MapReduce 接口来完成复杂的处理任务时,实现会变得非常复杂。比如,你需要从头实现数据处理中常见的各种 join 算法。 @@ -584,7 +584,7 @@ Hadoop 生态系统既包括随机访问型的 OLTP 数据库,如HBase(参 ### 数据流引擎 -为了解决 MapReduce 的这些问题,针对分布式系统中的批处理负载,人们开发了很多新的执行引擎。其中最知名的是 Spark、Tez 和 Flink。这几个处理引擎的设计有诸多不同之处,但有一点是相同的:他们将**整个数据流看做一个任务,而非将其拆分成几个相对独立的子任务**。 +为了解决 MapReduce 的这些问题,针对分布式系统中的批处理负载,人们开发了很多新的执行引擎。其中最知名的是 Spark、Tez 和 Flink。这几个处理引擎的设计有诸多不同之处,但有一点是相同的:它们将**整个数据流看做一个任务,而非将其拆分成几个相对独立的子任务**。 由于这些引擎会显式地考虑跨越多个阶段的全局数据流,因此也常被称为**数据流引擎**(dataflow engines)。和 MapReduce 一样,这些引擎也会对每个数据记录在单个线程中,重复调用用户的定制函数(包裹用户逻辑)。并且会将输入数据集进行**切片**(partition),并行地执行(数据并行),然后将一个函数的输出通过网络传递给下一个函数作为输入。 @@ -606,7 +606,7 @@ Hadoop 生态系统既包括随机访问型的 OLTP 数据库,如HBase(参 你可以使用数据流引擎实现和 MapReduce 数据流一样的计算逻辑,并且由于上面的优化,执行速度通常更快。由于**算子**是 map 和 reduce 的泛化,同样处理逻辑的代码,仅简单调整下配置,便可以无缝的跑在两种数据流引擎上: 1. 基于 MapReduce 的数据流引擎(如 Pig,Hive 或者 Cascading) -2. 新型的的数据流引擎(如 Tez 或者 Spark) +2. 新型的数据流引擎(如 Tez 或者 Spark) Tez 是一个依赖 YARN 的 shuffle 服务在节点间进行数据拷贝的**轻量级库**;而 Spark 和 Flink 是各有其自身一套完整的网络通信层、调度模块和用户 API 的**重量级框架**。我们稍后将会讨论这些高层接口(high-level API)。 @@ -614,7 +614,7 @@ Tez 是一个依赖 YARN 的 shuffle 服务在节点间进行数据拷贝的** 将所有中间状态持久化到分布式文件系统中的一个好处是——**持久性**(durable),这会使得 MapReduce 的容错方式变得非常简单:如果某个任务挂了,仅需要在其他机器上重新启动,并从文件系统中读取相同的输入即可。 -Spark、Flink 和 Tez 都会避免将中间状态写到 HDFS 中,因此他们采用了完全不同的容错方式:**如果某个机器上的中间结果丢了,就回溯工作流的算子依赖(DAG 依赖),找到最近可用的数据按照工作流重新计算**(最差的情况会一直找到输入数据,而输入数据通常存在于 HDFS 上)。 +Spark、Flink 和 Tez 都会避免将中间状态写到 HDFS 中,因此它们采用了完全不同的容错方式:**如果某个机器上的中间结果丢了,就回溯工作流的算子依赖(DAG 依赖),找到最近可用的数据按照工作流重新计算**(最差的情况会一直找到输入数据,而输入数据通常存在于 HDFS 上)。 为了能够通过重新计算来容错,框架必须跟踪每一部分数据的**计算轨迹**(DGA 依赖,或者说数据谱系,data lineage)——涉及哪些输入分片、应用了哪些算子。Spark 使用**弹性分区数据集**(RDD)抽象来追踪数据的祖先;Flink 使用了快照来记录所有算子状态,以从最近的**检查点**(checkpoint)重启运行出错的算子。 @@ -704,7 +704,7 @@ Pregel 中限定只能通过消息传递(而不是通过主动拉取)来进 ## 高层 API 和语言 -在 MapReduce 流行这些年之后,针对大数据集的**分布式批处理执行引擎**已经逐渐成熟。到现在(2017年)已经有比较成熟的基础设施可以在上千台机器上处理 PB 量级的数据。因此,针对这个量级的**基本数据处理问题**可以认为已经被解决,大家的注意力开始转到其他问题上: +在 MapReduce 流行这些年之后,针对大数据集的**分布式批处理执行引擎**已经逐渐成熟。到现在(2017 年)已经有比较成熟的基础设施可以在上千台机器上处理 PB 量级的数据。因此,针对这个量级的**基本数据处理问题**可以认为已经被解决,大家的注意力开始转到其他问题上: 1. 完善编程模型 2. 提升处理性能 @@ -752,7 +752,7 @@ Spark 使用 JVM 字节码、Impala 使用 LLVM 来通过生成代码的方式 其他有用的算法还有—— **k 最近邻算法**(*k-nearest neighbors*)——一种在多维空间中搜索与给定数据条目相似度最高的数据算法,是一种近似性搜索算法。近似搜索对于基因组分析算法也很重要,因为在基因分析中,常需要找不同但类似的基因片段。近年来较火的向量数据库也是主要基于该算法。 -批处理引擎被越来越多的用到不同领域算法的分布式执行上。随着批处理系统越来越多支持**内置函数**和**高层声明式算子、**MPP 数据库变的越来越**可编程**和**灵活度高**,他们开始长的越来越像——说到底,本质上他们都是用于存储和处理数据的系统。 +批处理引擎被越来越多的用到不同领域算法的分布式执行上。随着批处理系统越来越多支持**内置函数**和**高层声明式算子、**MPP 数据库变的越来越**可编程**和**灵活度高**,它们开始长的越来越像——说到底,本质上它们都是用于存储和处理数据的系统。 # 小结 @@ -770,14 +770,14 @@ Spark 使用 JVM 字节码、Impala 使用 LLVM 来通过生成代码的方式 在 MapReduce 中,会根据输入数据的文件块(file chunk)的数量来调度 mappers。mappers 的输出会在**二次分片、排序、合并**(我们通常称之为 shuffle)到用户指定数量的 Reducer 中。该过程是为了将所有相关的数据(如具有相同 key)集结到一块。 - 后 MapReduce 时代的数据流工具会尽量避免不必要的排序(因为代价太高了),但他们仍然使用了和 MapReduce 类似的分区方式。 + 后 MapReduce 时代的数据流工具会尽量避免不必要的排序(因为代价太高了),但它们仍然使用了和 MapReduce 类似的分区方式。 - **容错** MapReduce 通过频繁的(每次 MapReduce 后)**刷盘**,从而可以避免重启整个任务,而只重新运行相关子任务就可以从其故障中快速恢复过来。但在错误频率很低的情况下,这种频繁刷盘做法代价很高。数据流工具通过尽可能的减少中间状态的刷盘(当然,shuffle 之后还是要刷的),并将其尽可能的保存在内存中,但这意味着一旦出现故障就要从头重算。算子的**确定性**可以减少重算的数据范围(确定性能保证只需要算失败分区,并且结果和其他分区仍然一致)。 -接下来我们讨论了几种基于 MapReduce 的 Join 算法,这些算法也常被用在各种数据流工具和 MPP 数据库里。他们很好的说明了基于**数据分区**的算法的工作原理: +接下来我们讨论了几种基于 MapReduce 的 Join 算法,这些算法也常被用在各种数据流工具和 MPP 数据库里。它们很好地说明了基于**数据分区**的算法的工作原理: - **Sort-merge joins** diff --git a/ch11.md b/ch11.md index eace624..16f08dc 100644 --- a/ch11.md +++ b/ch11.md @@ -20,7 +20,7 @@ 在批处理系统中,任务的输入和输出都是文件(可能是单机文件系统中的、也可能是分布式文件系统中的),那么在流式系统中,承载输入和输出的是什么呢? -在批处理系统中,虽然输入是文件,但第一步也通常是解析成一系列的**数据记录**(records)。在流式处理的上下中,对应数据记录的实体通常被称为**事件**(event)。但他们本质上都是一个东西:**一段小的、自包含的(self-contained、不引用其他数据)、不可变的某个时间点发生的信息数据**。流式系统中的一个事件通常会包含一个时间戳,来标志该事件在某个时钟系统(time-of-day clock)中发生的时间点。 +在批处理系统中,虽然输入是文件,但第一步也通常是解析成一系列的**数据记录**(records)。在流式处理的上下中,对应数据记录的实体通常被称为**事件**(event)。但它们本质上都是一个东西:**一段小的、自包含的(self-contained、不引用其他数据)、不可变的某个时间点发生的信息数据**。流式系统中的一个事件通常会包含一个时间戳,来标志该事件在某个时钟系统(time-of-day clock)中发生的时间点。 下面举几个事件的例子。事件可以是由用户活动产生的,如浏览网页、网上购物;也可以由机器产生,如周期性的温度传感器、CPU 利用率指标;在[使用Unix工具进行批处理](https://ddia.qtmuniao.com/#/ch10?id=%e4%bd%bf%e7%94%a8unix%e5%b7%a5%e5%85%b7%e8%bf%9b%e8%a1%8c%e6%89%b9%e5%a4%84%e7%90%86)一节的例子中,我们提到的 web 服务器中的每一行日志,也是一个事件。 @@ -49,11 +49,11 @@ 通知消费者有新事件产生的一个常见方法是**消息系统**(messaging system):生产者将事件以消息的形式发送到消息系统,消息系统将其推送给消费者。我们在[经由消息传递的数据流](https://ddia.qtmuniao.com/#/ch04?id=%e7%bb%8f%e7%94%b1%e6%b6%88%e6%81%af%e4%bc%a0%e9%80%92%e7%9a%84%e6%95%b0%e6%8d%ae%e6%b5%81)一节简单提过消息系统,本节我们将会讨论更多细节。 -实现消息系统最简单的方式,就是使用 Unix 管道或者 TCP连接来沟通生产者和消费者。但大部分消息系统不会如此简单。比如,Unix 管道和 TCP 连接都是一对一的发送者和接受者,但成熟的消息系统通常要支持**多对多**的生产消费——即多个生产者可以将数据发送到一个**主题**( topic )下,多个消费者可以共通消费这个 topic。 +实现消息系统最简单的方式,就是使用 Unix 管道或者 TCP 连接来沟通生产者和消费者。但大部分消息系统不会如此简单。比如,Unix 管道和 TCP 连接都是一对一的发送者和接受者,但成熟的消息系统通常要支持**多对多**的生产消费——即多个生产者可以将数据发送到一个**主题**( topic )下,多个消费者可以共通消费这个 topic。 但在这种**发布/订阅**(publish/subscribe)模式之下,不同具体的系统实现方式千差万别。没有一种方案能满足所有需求。为了理解不同系统的实现,我们可以带着两个问题去考察各个系统: -1. **如果生产者的生产速度快于消费者的消费速度会发生什么**?通常来说,有三种选择:**丢掉部分消息、缓存多余消息、背压阻止新消息**(backpressure,也被称为**流控**,即在消费者处理完之前,阻止生产者产生更多数据)。具体来说,Unix 管道和 TCP 都使用背压的方式:他们都有一个很小的缓冲区(Buffer),如果缓冲区被填满,则发送方阻塞直到接收方消费掉缓冲区中一些消息,以空出新的位置。如果使用队列缓冲消息,则需要了解当数据量增大到一定地步之后该怎么办?当内存装不下数据之后是宕机还是刷到硬盘上?如果刷到硬盘上,硬盘的访问将如何影响消息系统的性能? +1. **如果生产者的生产速度快于消费者的消费速度会发生什么**?通常来说,有三种选择:**丢掉部分消息、缓存多余消息、背压阻止新消息**(backpressure,也被称为**流控**,即在消费者处理完之前,阻止生产者产生更多数据)。具体来说,Unix 管道和 TCP 都使用背压的方式:它们都有一个很小的缓冲区(Buffer),如果缓冲区被填满,则发送方阻塞直到接收方消费掉缓冲区中一些消息,以空出新的位置。如果使用队列缓冲消息,则需要了解当数据量增大到一定地步之后该怎么办?当内存装不下数据之后是宕机还是刷到硬盘上?如果刷到硬盘上,硬盘的访问将如何影响消息系统的性能? 2. **当系统中一些节点短时间下线会发生什么?会有消息因此而丢失吗**?和数据库一样,要想保证持久性,是需要付出一些代价的:如将数据写到硬盘中、将数据冗余到其他节点上等等。如果你能够接受偶尔丢一些数据,那在同样的硬件配置下,你或许能获得更高的吞吐和更低的延迟。 **是否能够接受消息丢失取决于应用层**。例如,对于一些周期性上报的传感器读数来说,偶尔的一两个采点的丢失影响不大, 因为后面的数据会很快的报上来。然而需要注意,如果消息大面积的丢失,可能也很难立即看出来。另外,如果你的目标是对所有到来的事件进行计数,则每条信息都要可靠的传输,因为任何一条信息的丢失都会导致计数错误。 @@ -86,7 +86,7 @@ ### 对比消息代理和数据库 -有一些消息代理甚至能够参与两阶段提交(使用 XA 或者 JTA,参见[实践中的分布式事务](https://ddia.qtmuniao.com/#/ch09?id=%e5%ae%9e%e8%b7%b5%e4%b8%ad%e7%9a%84%e5%88%86%e5%b8%83%e5%bc%8f%e4%ba%8b%e5%8a%a1))。这种功能让消息代理看起来非常像数据库,尽管在实践中他们有一些非常重要的区别: +有一些消息代理甚至能够参与两阶段提交(使用 XA 或者 JTA,参见[实践中的分布式事务](https://ddia.qtmuniao.com/#/ch09?id=%e5%ae%9e%e8%b7%b5%e4%b8%ad%e7%9a%84%e5%88%86%e5%b8%83%e5%bc%8f%e4%ba%8b%e5%8a%a1))。这种功能让消息代理看起来非常像数据库,尽管在实践中它们有一些非常重要的区别: - **删除过程**:数据库会一直保存数据,直到其被**显式地**删除。然而,大部分的消息代理会在消息被消费后,隐式的对其自动删除。这种类型的消息代理并不适合对数据的长时间存储。 - **尺寸假设**:由于消息代理会在消息被消费后将其删除,因此大部分消息代理都会**假设**其所存数据并不是很多——所有队列都很短。在这样的假设下,如果由于消费者过慢而造成消息在消息代理中堆积(当内存中存不下后可能需要放到硬盘中),则可能造成消息代理的性能降级,所有消息都需要更长时间才能被处理。 @@ -191,11 +191,11 @@ Apache Kafka,Amazon Kinesis Streams 和 Twitter 的 DistributedLog 背后都 在消息系统小节一开始,我们讨论过如果消费者不能跟上生产者速率后的几种选择:丢消息、缓存或者使用背压。在基于日志的消息系统中,我们无疑采用了缓存的方式,使用固定但巨大的缓存(受限于磁盘空间)。 -如果某个消费者掉队太远,以至于消费进度调出本机所存日志范围,则该消费者就不能再消费这些已经被丢弃的消息——因为消息代理已经把他们删掉了。一种解决办法是,你可以**监控每个消费者的进度**,如果某个消费者落后太多,就及时进行报警,让运维人员介入来看下为啥这个消费者这么慢,能不能修复或者干脆重新分配。通常来说,硬盘能够容纳足够长时间的消息,因此运维人员通常有充足的时间来介入。 +如果某个消费者掉队太远,以至于消费进度调出本机所存日志范围,则该消费者就不能再消费这些已经被丢弃的消息——因为消息代理已经把它们删掉了。一种解决办法是,你可以**监控每个消费者的进度**,如果某个消费者落后太多,就及时进行报警,让运维人员介入来看下为啥这个消费者这么慢,能不能修复或者干脆重新分配。通常来说,硬盘能够容纳足够长时间的消息,因此运维人员通常有充足的时间来介入。 即使一个消费者掉队太远,也只会影响他自己;而不会干扰其他消费者。这对运维同学来说是重大利好:你可以任意的针对线上日志进行消费、测试、调试,而不用担心影响线上服务。当一个消费者主动下线或者意外宕机时,它不会对线上系统造成任何影响——除了留下一个专属于它的消费者偏移量外。 -这种设定与传统的消息代理形成鲜明对比。在那些不基于日志的消息代理中,你需要小心的回收每个已下线的消费者的相应队列缓存,否则即使他们下线了,他们所占的资源(每个消费者都会维护不少元信息)也会慢慢耗尽消息代理的内存。 +这种设定与传统的消息代理形成鲜明对比。在那些不基于日志的消息代理中,你需要小心的回收每个已下线的消费者的相应队列缓存,否则即使它们下线了,它们所占的资源(每个消费者都会维护不少元信息)也会慢慢耗尽消息代理的内存。 ### 回放旧消息 @@ -207,7 +207,7 @@ Apache Kafka,Amazon Kinesis Streams 和 Twitter 的 DistributedLog 背后都 # 数据库和流 -我们已经对比了消息代理和数据库的诸多方面。在传统上,他们被认为是两个完全不同类别的系统,但在之前小节的分析我们看到,基于日志的消息系统中成功地从数据库中借鉴了许多经验。其实,我们也可以有另外一条路,从消息系统中借鉴一些思想,应用到数据库中。 +我们已经对比了消息代理和数据库的诸多方面。在传统上,它们被认为是两个完全不同类别的系统,但在之前小节的分析我们看到,基于日志的消息系统中成功地从数据库中借鉴了许多经验。其实,我们也可以有另外一条路,从消息系统中借鉴一些思想,应用到数据库中。 我们在之前提到过,事件(event)是对某个时间点发生的事情记录。事件可以是一个用户行为(如,一次搜索),可以是传感器数值,但其实也可以是**写入数据库**(write to a database)。写入数据库这个事情本身也可以被当做一个事件被捕获、存储和处理。我们通过这个连接可以发现,硬盘上的日志只是数据库和流数据之间最基本的牵连,其更深层次的关联远不止于此。 @@ -344,9 +344,9 @@ Kafka Connect 是一个可以将数据库 CDC 导出的流接入 Kafka 的工具 通常我们可以认为,数据库保存了应用的当前状态——这本质上是对读优化的,可以很方便、高效的处理读请求。但**状态的本质在于变化**,因此数据库允许对数据进行插入、更新和删除。那这种情况下,怎么保持不变性呢? -无论状态如何变化,都是**事件序列**按时间顺序依次施加的结果。例如,当前可用的座位列表是所有座位减去所有接收到的预定的结果、当前的账户余额是是该账户所有收支事件累加的结果、web 服务器的响应分布图是所有 web 请求的单个相应事件累加的结果。 +无论状态如何变化,都是**事件序列**按时间顺序依次施加的结果。例如,当前可用的座位列表是所有座位减去所有接收到的预定的结果、当前的账户余额是该账户所有收支事件累加的结果、web 服务器的响应分布图是所有 web 请求的单个相应事件累加的结果。 -不管系统的状态如何变化,总是和一个固定的事件序列对应。无论事件内容是什么,是发生还是取消,但不变的是——他们都作为事件发生了。其背后的关键点在于,**可变化的状态和不可变的事件序列并不冲突:他们是一体两面的**。所有变化的日志,**变更日志**(change log),正是状态随时间的演进过程。 +不管系统的状态如何变化,总是和一个固定的事件序列对应。无论事件内容是什么,是发生还是取消,但不变的是——它们都作为事件发生了。其背后的关键点在于,**可变化的状态和不可变的事件序列并不冲突:它们是一体两面的**。所有变化的日志,**变更日志**(change log),正是状态随时间的演进过程。 如果从数学角度来思考这个问题,可以认为: @@ -461,7 +461,7 @@ CEP 系统常使用偏高层的描述式查询语言,如 SQL 或者图形用 数据流分析中有时会使用一些概率算法,如使用布隆过滤器(我们[性能优化](https://ddia.qtmuniao.com/#/ch03?id=%e6%80%a7%e8%83%bd%e4%bc%98%e5%8c%96)小节中遇到过)来管理成员资格、使用 HyperLogLog 来进行基数估计,还有各种各样的分位数值估计算法。概率算法会产生近似结果,但通常比确定算法需要更少的资源(如 CPU,内存)。使用近似算法容易让人们认为流式处理系统总是损失精度和不精确的,但这种看法并不正确:**流式处理本身并非近似的,概率算法只是一种在分析场景中的处理优化**。 -很多开源的分布式流处理库框架都是针对分析场景设计的:例如,Apache Storm、Spark Streaming、Flink、Concord、Samza 和 Kafka Streams。有一些云上的托管服务也是,如Google Cloud Dataflow 和 Azure Stream Analytics。 +很多开源的分布式流处理库框架都是针对分析场景设计的:例如,Apache Storm、Spark Streaming、Flink、Concord、Samza 和 Kafka Streams。有一些云上的托管服务也是,如 Google Cloud Dataflow 和 Azure Stream Analytics。 ### 管理物化视图 @@ -487,7 +487,7 @@ CEP 系统常使用偏高层的描述式查询语言,如 SQL 或者图形用 - Actor 之间的通信通常是短暂且一对一的,而事件日志则是持久的且通常是多下游的(多个订阅者/消费者)。 - Actor 间可以用任意模式(注意区分模式和方式,Actor 的通信方式肯定是消息传递)进行通信(包括循环往复的请求-应答模式),但流处理通常由有向无环的流水线构成。每个任务通常以多个流作为输入,进行处理后,然后产生一个新的流。 -也就是说,类 RPC 系统和流处理系统间在定位上有一些交叉。举个例子,Apache Storm 有一个叫做分布式 RPC的功能,可以将用户的一个查询分发到所有处理事件流上的节点。在这些节点上,查询请求和事件会被交替的执行,之后所有查询结果会被聚合后返回给用户(参阅多分区的数据处理)。 +也就是说,类 RPC 系统和流处理系统间在定位上有一些交叉。举个例子,Apache Storm 有一个叫做分布式 RPC 的功能,可以将用户的一个查询分发到所有处理事件流上的节点。在这些节点上,查询请求和事件会被交替的执行,之后所有查询结果会被聚合后返回给用户(参阅多分区的数据处理)。 当然,也可以使用 Actor 框架来进行流处理。但在系统节点宕机时,这些框架通常不对消息的交付有任何保证。因此,除非你实现额外的逻辑,否则这种方式通常不能够进行容错。 @@ -505,7 +505,7 @@ CEP 系统常使用偏高层的描述式查询语言,如 SQL 或者图形用 有很多原因会造成**处理延迟**:处理排队、网络故障(参见[不可靠的网络](https://ddia.qtmuniao.com/#/ch08?id=%e4%b8%8d%e5%8f%af%e9%9d%a0%e7%9a%84%e7%bd%91%e7%bb%9c))、机器性能低下造成的消息在消息代理和处理节点的堆积、流消费者的重启或者 bug 修复后对之前事件进行重新处理。 -更有甚者,**消息延迟可能会导致无法预知的消息乱序**。举个例子,一个用户首先发送了一个 web 请求(被 web 服务器 A 处理),然后发送了第二个请求(被服务器 B 处理)。A 和 B 各自将其包装成事件消息进行发送,但是 B 的事件比 A 的事件先到达消息代理。则此时,流处理任务会首先看到 B 事件,然后才看到 A 事件,但他们的实际产生顺序其实是相反的。 +更有甚者,**消息延迟可能会导致无法预知的消息乱序**。举个例子,一个用户首先发送了一个 web 请求(被 web 服务器 A 处理),然后发送了第二个请求(被服务器 B 处理)。A 和 B 各自将其包装成事件消息进行发送,但是 B 的事件比 A 的事件先到达消息代理。则此时,流处理任务会首先看到 B 事件,然后才看到 A 事件,但它们的实际产生顺序其实是相反的。 下面用一个类比或许能帮助理解,对于《星球大战》电影系列:第四部是 1977 年上映,第五部 1980 年上映,第六部 1983 年,然后一,二,三部(作为前传)分别于 1999,2002 和 2005 年上映,最后,第七部于 2015 年上映。如果你按照上映的顺序来看这个系列,就会发现你“处理”电影的顺序和电影叙事顺序是不一致的(在这个例子中,电影内容的编号类似于事件发生的时间戳,你看电影的时间是事件处理的时间)。当然,作为人类,我们可以处理这种不连续型,但流处理算法就得针对性的编写特殊逻辑才能应对这种乱序问题。