Merge branch 'master' into patch-1

This commit is contained in:
Feng Ruohang 2019-11-26 14:24:43 +08:00 committed by GitHub
commit cac153e8a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 60 additions and 76 deletions

42
ch1.md
View File

@ -10,33 +10,17 @@
[TOC]
现今很多应用程序都是**数据密集型data-intensive**的,而非**计算密集型compute-intensive**的。因此CPU很少成为这类应用的瓶颈更大的问题通常来自数据量、数据复杂性、以及数据的变更速度。
​现今很多应用程序都是 **数据密集型data-intensive** 的,而非 **计算密集型compute-intensive** 的。因此CPU很少成为这类应用的瓶颈更大的问题通常来自数据量、数据复杂性、以及数据的变更速度。
数据密集型应用通常由标准组件构建而成,标准组件提供了很多通用的功能;例如,许多应用程序都需要:
***数据库database***
- 存储数据,以便自己或其他应用程序之后能再次找到 ***数据库database***
- 记住开销昂贵操作的结果,加快读取速度(***缓存cache***
- 允许用户按关键字搜索数据,或以各种方式对数据进行过滤(***搜索索引search indexes***
- 向其他进程发送消息,进行异步处理(***流处理stream processing***
- 定期处理累积的大批量数据(***批处理batch processing***
存储数据,以便自己或其他应用程序之后能再次找到
***缓存cache***
记住开销昂贵操作的结果,加快读取速度
***搜索索引search indexes***
允许用户按关键字搜索数据,或以各种方式对数据进行过滤
***流处理stream processing***
向其他进程发送消息,进行异步处理
***批处理batch processing***
定期处理累积的大批量数据
如果这些功能听上去平淡无奇,那是因为这些**数据系统data system**是非常成功的抽象:我们一直不假思索地使用它们并习以为常。绝大多数工程师不会幻想从零开始编写存储引擎,因为在开发应用时,数据库已经是足够完美的工具了。
​如果这些功能听上去平淡无奇,那是因为这些 **数据系统data system** 是非常成功的抽象:我们一直不假思索地使用它们并习以为常。绝大多数工程师不会幻想从零开始编写存储引擎,因为在开发应用时,数据库已经是足够完美的工具了。
但现实没有这么简单。不同的应用有着不同的需求,因而数据库系统也是百花齐放,有着各式各样的特性。实现缓存有很多种手段,创建搜索索引也有好几种方法,诸如此类。因此在开发应用前,我们依然有必要先弄清楚最适合手头工作的工具和方法。而且当单个工具解决不了你的问题时,组合使用这些工具可能还是有些难度的。
@ -80,7 +64,7 @@
***可维护性Maintainability***
许多不同的人(工程师、运维)在不同的生命周期,都能高效地在系统上工作(使系统保持现有行为,并适应新的应用场景)。(参阅”[可维护性](#可维护性)“)
许多不同的人(工程师、运维)在不同的生命周期,都能高效地在系统上工作(使系统保持现有行为,并适应新的应用场景)。(参阅”[可维护性](#可维护性)“)
@ -193,10 +177,10 @@
1. 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如[图1-2](img/fig1-2.png)所示的关系型数据库中,可以编写这样的查询:
```sql
SELECT tweets.*, users.*
FROM tweets
JOIN users ON tweets.sender_id = users.id
JOIN follows ON follows.followee_id = users.id
SELECT tweets.*, users.*
FROM tweets
JOIN users ON tweets.sender_id = users.id
JOIN follows ON follows.followee_id = users.id
WHERE follows.follower_id = current_user
```
![](img/fig1-2.png)
@ -476,4 +460,4 @@
| 上一章 | 目录 | 下一章 |
| ----------------------------------- | ------------------------------- | ------------------------------------ |
| [第一部分:数据系统基础](part-i.md) | [设计数据密集型应用](README.md) | [第二章:数据模型与查询语言](ch2.md) |
| [第一部分:数据系统基础](part-i.md) | [设计数据密集型应用](README.md) | [第二章:数据模型与查询语言](ch2.md) |

View File

@ -676,7 +676,7 @@ GROUP BY follows.follower_id
Storm的Trident基于类似的想法来处理状态【78】。依赖幂等性意味着隐含了一些假设重启一个失败的任务必须以相同的顺序重放相同的消息基于日志的消息代理能做这些事处理必须是确定性的没有其他节点能同时更新相同的值【98,99】。
当从一个处理节点故障转移到另一个节点时,可能需要进行**防护fencing**(参阅“[领导和锁](ch8.md#领导和锁)”),以防止被假死节点干扰。尽管有这么多注意事项,幂等操作是一种实现**恰好一次语义**的有效方式,仅需很小的额外开销。
当从一个处理节点故障切换到另一个节点时,可能需要进行**防护fencing**(参阅“[领导和锁](ch8.md#领导和锁)”),以防止被假死节点干扰。尽管有这么多注意事项,幂等操作是一种实现**恰好一次语义**的有效方式,仅需很小的额外开销。
#### 失败后重建状态

6
ch2.md
View File

@ -264,12 +264,12 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
大型表上运行`UPDATE`语句在任何数据库上都可能会很慢,因为每一行都需要重写。要是不可接受的话,应用程序可以将`first_name`设置为默认值`NULL`,并在读取时再填充,就像使用文档数据库一样。
读时模式更具优势,当由于某种原因(例如,数据是异构的)集合中的项目并不都具有相同的结构时。例如,因为
当由于某种原因(例如,数据是异构的)集合中的项目并不都具有相同的结构时,读时模式更具优势。例如,如果
* 存在许多不同类型的对象,将每种类型的对象放在自己的表中是不现实的。
* 数据的结构由外部系统决定。你无法控制外部系统且它随时可能变化。
这样的情况下,模式的坏处远大于它的帮助,无模式文档可能是一个更加自然的数据模型。但是,要是所有记录都具有相同的结构,那么模式是记录并强制这种结构的有效机制。第四章将更详细地讨论模式和模式演化。
上述情况下,模式的坏处远大于它的帮助,无模式文档可能是一个更加自然的数据模型。但是,要是所有记录都具有相同的结构,那么模式是记录并强制这种结构的有效机制。第四章将更详细地讨论模式和模式演化。
#### 查询的数据局部性
@ -591,7 +591,7 @@ CREATE INDEX edges_heads ON edges (head_vertex);
### Cypher查询语言
Cypher是属性图的声明式查询语言为Neo4j图形数据库而发明【37】。它是以电影“黑客帝国”中的一个角色命名的而与密码术中的密码无关【38】。
Cypher是属性图的声明式查询语言为Neo4j图形数据库而发明【37】。它是以电影“黑客帝国”中的一个角色命名的而与密码术中的密码无关【38】。
[例2-3]()显示了将[图2-5](img/fig2-5.png)的左边部分插入图形数据库的Cypher查询。可以类似地添加图的其余部分为了便于阅读而省略。每个顶点都有一个像`USA`或`Idaho`这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:`Idaho - [WITHIN] ->USA`创建一条标记为`WITHIN`的边,`Idaho`为尾节点,`USA`为头节点。

4
ch3.md
View File

@ -15,7 +15,7 @@
在[第2章](ch2.md)中,我们讨论了数据模型和查询语言,即程序员将数据录入数据库的格式,以及再次要回数据的机制。在本章中我们会从数据库的视角来讨论同样的问题:数据库如何存储我们提供的数据,以及如何在我们需要时重新找到数据。
作为程序员,为什么要关心数据库内部存储与检索的机理?你可能不会去从头开始实现自己的存储引擎,但是你**确实**需要从许多可用的存储引擎中选择一个合适的。而且为了调存储引擎以适配应用工作负载,你也需要大致了解存储引擎在底层究竟做什么。
作为程序员,为什么要关心数据库内部存储与检索的机理?你可能不会去从头开始实现自己的存储引擎,但是你**确实**需要从许多可用的存储引擎中选择一个合适的。而且为了调存储引擎以适配应用工作负载,你也需要大致了解存储引擎在底层究竟做什么。
特别需要注意,针对**事务**性负载和**分析性**负载优化的存储引擎之间存在巨大差异。稍后我们将在 “[事务处理还是分析?](#事务处理还是分析?)” 一节中探讨这一区别,并在 “[列存储](#列存储)”中讨论一系列针对分析优化存储引擎。
@ -273,7 +273,7 @@ B树的基本底层写操作是用新数据覆盖磁盘上的页面。假定覆
B树索引必须至少两次写入每一段数据一次写入预先写入日志一次写入树页面本身也许再次分页。即使在该页面中只有几个字节发生了变化也需要一次编写整个页面的开销。有些存储引擎甚至会覆盖同一个页面两次以免在电源故障的情况下导致页面部分更新【24,25】。
由于反复压缩和合并SSTables日志结构索引也会重写数据。这种影响 —— 在数据库的生命周期中写入数据库导致对磁盘的多次写入 —— 被称为**写放大write amplification**。需要特别关注的是固态硬盘,固态硬盘在磨损之前只能覆写一段时间
由于反复压缩和合并SSTables日志结构索引也会重写数据。这种影响 —— 在数据库的生命周期中写入数据库导致对磁盘的多次写入 —— 被称为**写放大write amplification**。需要特别注意的是固态硬盘,固态硬盘的闪存寿命在覆写有限次数后就会耗尽
在写入繁重的应用程序中,性能瓶颈可能是数据库可以写入磁盘的速度。在这种情况下,写放大会导致直接的性能代价:存储引擎写入磁盘的次数越多,可用磁盘带宽内的每秒写入次数越少。

6
ch4.md
View File

@ -47,7 +47,7 @@
[^i]: 除一些特殊情况外,例如某些内存映射文件或直接在压缩数据上操作(如“[列压缩](ch4.md#列压缩)”中所述)。
所以,需要在两种表示之间进行某种类型的翻译。 从内存中表示到字节序列的转换称为**编码Encoding**(也称为**序列化serialization**或**编组marshalling**),反过来称为**解码Decoding**[^ii]**解析Parsing****反序列化deserialization****反编组() unmarshalling**[^译i]。
所以,需要在两种表示之间进行某种类型的翻译。 从内存中表示到字节序列的转换称为**编码Encoding**(也称为**序列化serialization**或**编组marshalling**),反过来称为**解码Decoding**[^ii]**解析Parsing****反序列化deserialization****反编组( unmarshalling**[^译i]。
[^ii]: 请注意,**编码encode**与**加密encryption**无关。 本书不讨论加密。
[^译i]: Marshal与Serialization的区别Marshal不仅传输对象的状态而且会一起传输对象的方法相关代码
@ -350,7 +350,7 @@ Avro为静态类型编程语言提供了可选的代码生成功能但是它
#### 在不同的时间写入不同的值
数据库通常允许任何时候更新任何值。这意味着在一个单一的数据库中,可能有一些值是五毫秒前写的,而一些值是五年前写的。
数据库通常允许任何时候更新任何值。这意味着在一个单一的数据库中,可能有一些值是五毫秒前写的,而一些值是五年前写的。
在部署应用程序的新版本(至少是服务器端应用程序)时,您可能会在几分钟内完全用新版本替换旧版本。数据库内容也是如此:五年前的数据仍然存在于原始编码中,除非您已经明确地重写了它。这种观察有时被总结为数据超出代码。
@ -638,4 +638,4 @@ Actor模型是单个进程中并发的编程模型。逻辑被封装在角色中
| 上一章 | 目录 | 下一章 |
| ---------------------------- | ------------------------------- | --------------------------------- |
| [第三章:存储与检索](ch3.md) | [设计数据密集型应用](README.md) | [第二部分:分布式数据](part-ii.md)
| [第三章:存储与检索](ch3.md) | [设计数据密集型应用](README.md) | [第二部分:分布式数据](part-ii.md) |

50
ch5.md
View File

@ -96,17 +96,17 @@
在其本地磁盘上,每个从库记录从主库收到的数据变更。如果从库崩溃并重新启动,或者,如果主库和从库之间的网络暂时中断,则比较容易恢复:从库可以从日志中知道,在发生故障之前处理的最后一个事务。因此,从库可以连接到主库,并请求在从库断开连接时发生的所有数据变更。当应用完所有这些变化后,它就赶上了主库,并可以像以前一样继续接收数据变更流。
#### 主库失效:故障转移
#### 主库失效:故障切换
主库失效处理起来相当棘手:其中一个从库需要被提升为新的主库,需要重新配置客户端,以将它们的写操作发送给新的主库,其他从库需要开始拉取来自新主库的数据变更。这个过程被称为**故障转移failover**。
主库失效处理起来相当棘手:其中一个从库需要被提升为新的主库,需要重新配置客户端,以将它们的写操作发送给新的主库,其他从库需要开始拉取来自新主库的数据变更。这个过程被称为**故障切换failover**。
故障转移可以手动进行(通知管理员主库挂了,并采取必要的步骤来创建新的主库)或自动进行。自动故障转移过程通常由以下步骤组成:
故障切换可以手动进行(通知管理员主库挂了,并采取必要的步骤来创建新的主库)或自动进行。自动故障切换过程通常由以下步骤组成:
1. 确认主库失效。有很多事情可能会出错:崩溃,停电,网络问题等等。没有万无一失的方法来检测出现了什么问题,所以大多数系统只是简单使用**超时Timeout**节点频繁地相互来回传递消息并且如果一个节点在一段时间内例如30秒没有响应就认为它挂了因为计划内维护而故意关闭主库不算
2. 选择一个新的主库。这可以通过选举过程(主库由剩余副本以多数选举产生)来完成,或者可以由之前选定的**控制器节点controller node**来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库(最小化数据损失)。让所有的节点同意一个新的领导者,是一个**共识**问题,将在[第9章](ch9.md)详细讨论。
3. 重新配置系统以启用新的主库。客户端现在需要将它们的写请求发送给新主库(将在“[请求路由](ch6.md#请求路由)”中讨论这个问题)。如果老领导回来,可能仍然认为自己是主库,没有意识到其他副本已经让它下台了。系统需要确保老领导认可新领导,成为一个从库。
故障转移会出现很多大麻烦:
故障切换会出现很多大麻烦:
* 如果使用异步复制,则新主库可能没有收到老主库宕机前最后的写入操作。在选出新主库后,如果老主库重新加入集群,新主库在此期间可能会收到冲突的写入,那这些写入该如何处理?最常见的解决方案是简单丢弃老主库未复制的写入,这很可能打破客户对于数据持久性的期望。
@ -116,9 +116,9 @@
[^ii]: 这种机制称为**屏蔽fencing**,充满感情的术语是:**爆彼之头Shoot The Other Node In The Head, STONITH**。
* 主库被宣告死亡之前的正确超时应该怎么配置?在主库失效的情况下,超时时间越长,意味着恢复时间也越长。但是如果超时设置太短,又可能会出现不必要的故障转移。例如,临时负载峰值可能导致节点的响应时间超时,或网络故障可能导致数据包延迟。如果系统已经处于高负载或网络问题的困扰之中,那么不必要的故障切换可能会让情况变得更糟糕。
* 主库被宣告死亡之前的正确超时应该怎么配置?在主库失效的情况下,超时时间越长,意味着恢复时间也越长。但是如果超时设置太短,又可能会出现不必要的故障切换。例如,临时负载峰值可能导致节点的响应时间超时,或网络故障可能导致数据包延迟。如果系统已经处于高负载或网络问题的困扰之中,那么不必要的故障切换可能会让情况变得更糟糕。
这些问题没有简单的解决方案。因此,即使软件支持自动故障切换,不少运维团队还是更愿意手动执行故障转移
这些问题没有简单的解决方案。因此,即使软件支持自动故障切换,不少运维团队还是更愿意手动执行故障切换
节点故障、不可靠的网络、对副本一致性,持久性,可用性和延迟的权衡 ,这些问题实际上是分布式系统中的基本问题。[第8章](ch8.md)和[第9章](ch9.md)将更深入地讨论它们。
@ -153,7 +153,7 @@
PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志记录的数据非常底层WAL包含哪些磁盘块中的哪些字节发生了更改。这使复制与存储引擎紧密耦合。如果数据库将其存储格式从一个版本更改为另一个版本通常不可能在主库和从库上运行不同版本的数据库软件。
看上去这可能只是一个微小的实现细节,但却可能对运维产生巨大的影响。如果复制协议允许从库使用比主库更新的软件版本,则可以先升级从库,然后执行故障转移使升级后的节点之一成为新的主库从而执行数据库软件的零停机升级。如果复制协议不允许版本不匹配传输WAL经常出现这种情况则此类升级需要停机。
看上去这可能只是一个微小的实现细节,但却可能对运维产生巨大的影响。如果复制协议允许从库使用比主库更新的软件版本,则可以先升级从库,然后执行故障切换使升级后的节点之一成为新的主库从而执行数据库软件的零停机升级。如果复制协议不允许版本不匹配传输WAL经常出现这种情况则此类升级需要停机。
#### 逻辑日志复制(基于行)
@ -325,7 +325,7 @@
***容忍数据中心停机***
在单主配置中,如果主库所在的数据中心发生故障,故障转移可以使另一个数据中心里的追随者成为领导者。在多活配置中,每个数据中心可以独立于其他数据中心继续运行,并且当发生故障的数据中心归队时,复制会自动赶上。
在单主配置中,如果主库所在的数据中心发生故障,故障切换可以使另一个数据中心里的追随者成为领导者。在多活配置中,每个数据中心可以独立于其他数据中心继续运行,并且当发生故障的数据中心归队时,复制会自动赶上。
***容忍网络问题***
@ -433,7 +433,7 @@
有些冲突是显而易见的。在[图5-7](img/fig5-7.png)的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。
其他类型的冲突可能更为微妙,难以发现。例如,考虑一个会议室预订系统:它记录谁了哪个时间段的哪个房间。应用需要确保每个房间只有一组人同时预定(即不得有相同房间的重叠预订)。在这种情况下,如果同时为同一个房间创建两个不同的预订,则可能会发生冲突。即使应用程序在允许用户进行预订之前检查可用性,如果两次预订是由两个不同的领导者进行的,则可能会有冲突。
其他类型的冲突可能更为微妙,难以发现。例如,考虑一个会议室预订系统:它记录谁了哪个时间段的哪个房间。应用需要确保每个房间只有一组人同时预定(即不得有相同房间的重叠预订)。在这种情况下,如果同时为同一个房间创建两个不同的预订,则可能会发生冲突。即使应用程序在允许用户进行预订之前检查可用性,如果两次预订是由两个不同的领导者进行的,则可能会有冲突。
现在还没有一个现成的答案但在接下来的章节中我们将追溯到对这个问题有很好的理解。我们将在第7章中看到更多的冲突示例在[第12章](ch12.md)中我们将讨论用于检测和解决复制系统中冲突的可扩展方法。
@ -475,11 +475,11 @@
我们在本章到目前为止所讨论的复制方法 ——单主复制、多主复制——都是这样的想法:客户端向一个主库发送写请求,而数据库系统负责将写入复制到其他副本。主库决定写入的顺序,而从库按相同顺序应用主库的写入。
一些数据存储系统采用不同的方法,放弃主库的概念,并允许任何副本直接接受来自客户端的写入。最早的一些的复制数据系统是**无领导的leaderless**【1,44】但是在关系数据库主导的时代这个想法几乎已被忘却。在亚马逊将其用于其内部的Dynamo系统[^vi]之后它再一次成为数据库的一种时尚架构【37】。Dynamo不适用于Amazon以外的用户。 令人困惑的是AWS提供了一个名为DynamoDB的托管数据库产品它使用了完全不同的体系结构它基于单主程序复制。 RiakCassandra和Voldemort是由Dynamo启发的无领导复制模型的开源数据存储所以这类数据库也被称为*Dynamo风格*。
一些数据存储系统采用不同的方法,放弃主库的概念,并允许任何副本直接接受来自客户端的写入。最早的一些的复制数据系统是**无领导的leaderless**【1,44】但是在关系数据库主导的时代这个想法几乎已被忘却。在亚马逊将其用于其内部的Dynamo系统[^vi]之后它再一次成为数据库的一种时尚架构【37】。 RiakCassandra和Voldemort是由Dynamo启发的无领导复制模型的开源数据存储所以这类数据库也被称为*Dynamo风格*。
[^vi]: Dynamo不适用于Amazon以外的用户。 令人困惑的是AWS提供了一个名为DynamoDB的托管数据库产品它使用了完全不同的体系结构它基于单引导程序复制。
在一些无领导者的实现中,客户端直接将写入发送到到几个副本中,而另一些情况下,一个**协调者coordinator**节点代表客户端进行写入。但与主库数据库不同,协调不执行特定的写入顺序。我们将会看到,这种设计上的差异对数据库的使用方式有着深远的影响。
在一些无领导者的实现中,客户端直接将写入发送到到几个副本中,而另一些情况下,一个**协调者coordinator**节点代表客户端进行写入。但与主库数据库不同,协调不执行特定的写入顺序。我们将会看到,这种设计上的差异对数据库的使用方式有着深远的影响。
### 当节点故障时写入数据库
@ -489,7 +489,7 @@
![](img/fig5-10.png)
**图5-10 仲裁写入,法定读取,并在节点中断后读修复。**
**图5-10 仲裁写入,法定读取,并在节点中断后读修复。**
现在想象一下,不可用的节点重新联机,客户端开始读取它。节点关闭时发生的任何写入都从该节点丢失。因此,如果您从该节点读取数据,则可能会将陈旧(过时)值视为响应。
@ -509,7 +509,7 @@
此外,一些数据存储具有后台进程,该进程不断查找副本之间的数据差异,并将任何缺少的数据从一个副本复制到另一个副本。与基于领导者的复制中的复制日志不同,此反熵过程不会以任何特定的顺序复制写入,并且在复制数据之前可能会有显着的延迟。
并不是所有的系统都实现了这两个;例如Voldemort目前没有反熵过程。请注意如果没有反熵过程某些副本中很少读取的值可能会丢失从而降低了持久性因为只有在应用程序读取值时才执行读修复。
并不是所有的系统都实现了这两个;例如Voldemort目前没有反熵过程。请注意如果没有反熵过程某些副本中很少读取的值可能会丢失从而降低了持久性因为只有在应用程序读取值时才执行读修复。
#### 读写的法定人数
@ -523,7 +523,7 @@
在Dynamo风格的数据库中参数nw和r通常是可配置的。一个常见的选择是使n为奇数通常为3或5并设置 $w = r =n + 1/ 2$(向上取整)。但是可以根据需要更改数字。例如,设置$w = n$和$r = 1$的写入很少且读取次数较多的工作负载可能会受益。这使得读取速度更快,但具有只有一个失败节点导致所有数据库写入失败的缺点。
> 集群中可能有多于n的节点。集群的机器数可能多于副本但是任何给定的值只能存储在n个节点上。 这允许对数据集进行分区,从而支持可以放在一个节点上的数据集更大的数据集。 将在第6章回到分区。
> 集群中可能有多于n的节点。集群的机器数可能多于副本但是任何给定的值只能存储在n个节点上。 这允许对数据集进行分区,从而支持可以放在一个节点上的数据集更大的数据集。 将在第6章回到分区。
>
仲裁条件$w + r> n$允许系统容忍不可用的节点,如下所示:
@ -555,7 +555,7 @@
但是,即使在$w + r> n$的情况下,也可能存在返回陈旧值的边缘情况。这取决于实现,但可能的情况包括:
* 如果使用松散的法定人数(见“[松散法定人数与带提示的接力](#松散法定人数与带提示的接力)”w个写入和r个读取落在完全不同的节点上因此r节点和w之间不再保证有重叠节点【46】。
* 如果两个写入同时发生不清楚哪一个先发生。在这种情况下唯一安全的解决方案是合并并发写入请参阅第171页的“处理写入冲突”。如果根据时间戳最后写入成功)挑选出胜者,则由于时钟偏差[35],写入可能会丢失。我们将返回“[检测并发写入](#检测并发写入)”中的此主题。
* 如果两个写入同时发生不清楚哪一个先发生。在这种情况下唯一安全的解决方案是合并并发写入请参阅第171页的“处理写入冲突”。如果根据时间戳最后写入胜利)挑选出胜者,则由于时钟偏差[35],写入可能会丢失。我们将返回“[检测并发写入](#检测并发写入)”中的此主题。
* 如果写操作与读操作同时发生,写操作可能仅反映在某些副本上。在这种情况下,不确定读取是返回旧值还是新值。
* 如果写操作在某些副本上成功而在其他节点上失败例如因为某些节点上的磁盘已满在小于w个副本上写入成功。所以整体判定写入失败但整体写入失败并没有在写入成功的副本上回滚。这意味着如果一个写入虽然报告失败后续的读取仍然可能会读取这次失败写入的值【47】。
* 如果携带新值的节点失败需要读取其他带有旧值的副本。并且其数据从带有旧值的副本中恢复则存储新值的副本数可能会低于w从而打破法定人数条件。
@ -571,7 +571,7 @@
对于基于领导者的复制,数据库通常会公开复制滞后的度量标准,您可以将其提供给监视系统。这是可能的,因为写入按照相同的顺序应用于领导者和追随者,并且每个节点在复制日志中具有一个位置(在本地应用的写入次数)。通过从领导者的当前位置中减去随从者的当前位置,您可以测量复制滞后量。
然而,在无领导者复制的系统中,没有固定的写入顺序,这使得监控变得更加困难。而且,如果数据库只使用读修复(没有反熵过程),那么对于一个值可能会有多大的限制是没有限制的 - 如果一个值很少被读取,那么由一个陈旧副本返回的值可能是古老的。
然而,在无领导者复制的系统中,没有固定的写入顺序,这使得监控变得更加困难。而且,如果数据库只使用读修复(没有反熵过程),那么对于一个值可能会有多大的限制是没有限制的 - 如果一个值很少被读取,那么由一个陈旧副本返回的值可能是古老的。
已经有一些关于衡量无主复制数据库中的复制陈旧度的研究并根据参数nw和r来预测陈旧读取的预期百分比【48】。不幸的是这还不是很常见的做法但是将过时测量值包含在数据库的标准度量标准中是一件好事。最终的一致性是故意模糊的保证但是对于可操作性来说能够量化“最终”是很重要的。
@ -590,7 +590,7 @@
一旦网络中断得到解决,代表另一个节点临时接受的一个节点的任何写入都被发送到适当的“本地”节点。这就是所谓的**带提示的接力hinted handoff**。 (一旦你再次找到你的房子的钥匙,你的邻居礼貌地要求你离开沙发回家。)
松散法定人数提高写入可用性特别有用只要有任何w节点可用数据库就可以接受写入。然而这意味着即使当$w + r> n$时也不能确定读取某个键的最新值因为最新的值可能已经临时写入了n之外的某些节点【47】。
松散法定人数对写入可用性的提高特别有用只要有任何w节点可用数据库就可以接受写入。然而这意味着即使当$w + r> n$时也不能确定读取某个键的最新值因为最新的值可能已经临时写入了n之外的某些节点【47】。
因此在传统意义上一个松散的法定人数实际上不是一个法定人数。这只是一个保证即数据存储在w节点的地方。不能保证r节点的读取直到提示已经完成。
@ -624,19 +624,19 @@
在“[处理写冲突](#处理写入冲突)”一节中已经简要介绍了一些解决冲突的技术。在总结本章之前,让我们来更详细地探讨这个问题。
#### 最后写入为准(丢弃并发写入)
#### 最后写入胜利(丢弃并发写入)
实现最终融合的一种方法是声明每个副本只需要存储最**“最近”**的值,并允许**“更旧”**的值被覆盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是“最近的”,并且每个写入最终都被复制到每个副本,那么复制最终会收敛到相同的值。
正如**“最近”**的引号所表明的,这个想法其实颇具误导性。在[图5-12](img/fig5-12.png)的例子中,当客户端向数据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事实上,说“发生”是没有意义的:我们说写入是**并发concurrent**的,所以它们的顺序是不确定的。
即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间戳,挑选最**“最近”**的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为**最后写入为准LWW, last write wins**是Cassandra 【53】唯一支持的冲突解决方法也是Riak 【35】中的一个可选特征。
即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间戳,挑选最**“最近”**的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为**最后写入胜利LWW, last write wins**是Cassandra 【53】唯一支持的冲突解决方法也是Riak 【35】中的一个可选特征。
LWW实现了最终收敛的目标但以**持久性**为代价如果同一个Key有多个并发写入即使它们都被报告为客户端成功因为它们被写入 w 个副本),其中一个写道会生存下来,其他的将被无声丢弃。此外LWW甚至可能会删除不是并发的写入我们将在的“[有序事件的时间戳](ch8.md#有序事件的时间戳)”中讨论。
LWW实现了最终收敛的目标但以**持久性**为代价如果同一个Key有多个并发写入即使它们都被报告为客户端成功因为它们被写入 w 个副本),但只有一个写入将存活,而其他写入将被静默丢弃。此外LWW甚至可能会删除不是并发的写入我们将在的“[有序事件的时间戳](ch8.md#有序事件的时间戳)”中讨论。
有一些情况如缓存其中丢失的写入可能是可以接受的。如果丢失数据不可接受LWW是解决冲突的一个很烂的选择。
与LWW一起使用数据库的唯一安全方法是确保一个键只写入一次然后视为不可变从而避免对同一个密钥进行并发更新。例如推荐使用Cassandra的方法是使用UUID作为键从而为每个写操作提供一个唯一的键【53】。
与LWW一起使用数据库的唯一安全方法是确保一个键只写入一次然后视为不可变从而避免对同一个密钥进行并发更新。例如Cassandra推荐使用的方法是使用UUID作为键从而为每个写操作提供一个唯一的键【53】。
#### “此前发生”的关系和并发
@ -670,7 +670,7 @@
1. 客户端 1 将牛奶加入购物车。这是该键的第一次写入服务器成功存储了它并为其分配版本号1最后将值与版本号一起回送给客户端。
2. 客户端 2 将鸡蛋加入购物车,不知道客户端 1 同时添加了牛奶(客户端 2 认为它的鸡蛋是购物车中的唯一物品)。服务器为此写入分配版本号 2并将鸡蛋和牛奶存储为两个单独的值。然后它将这两个值**都**反回给客户端 2 ,并附上版本号 2 。
3. 客户端 1 不知道客户端 2 的写入,想要将面粉加入购物车,因此认为当前的购物车内容应该是 [牛奶,面粉]。它将此值与服务器先前向客户端 1 提供的版本号 1 一起发送到服务器。服务器可以从版本号中知道[牛奶,面粉]的写入取代了[牛奶]的先前值,但与[鸡蛋]的值是**并发**的。因此,服务器将版本 3 分配给[牛奶,面粉]覆盖版本1值[牛奶],但保留版本 2 的值[蛋],并将所有的值返回给客户端 1 。
4. 同时,客户端 2 想要加入火腿,不知道客端户 1 刚刚加了面粉。客户端 2 在最后一个响应中从服务器收到了两个值[牛奶]和[蛋],所以客户端 2 现在合并这些值,并添加火腿形成一个新的值,[鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号 2 。服务器检测到新值会覆盖版本 2 [eggs],但新值也会与版本 3 [牛奶,面粉]**并发**所以剩下的两个值是v3 [milkflour]和v4[鸡蛋,牛奶,火腿]。
4. 同时,客户端 2 想要加入火腿,不知道客端户 1 刚刚加了面粉。客户端 2 在最后一个响应中从服务器收到了两个值[牛奶]和[蛋],所以客户端 2 现在合并这些值,并添加火腿形成一个新的值,[鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号 2 。服务器检测到新值会覆盖版本 2 [鸡蛋],但新值也会与版本 3 [牛奶,面粉]**并发**所以剩下的两个是v3 [牛奶,面粉]和v4[鸡蛋,牛奶,火腿]
5. 最后,客户端 1 想要加培根。它以前在v3中从服务器接收[牛奶,面粉]和[鸡蛋],所以它合并这些,添加培根,并将最终值[牛奶,面粉,鸡蛋,培根]连同版本号v3发往服务器。这会覆盖v3[牛奶,面粉](请注意[鸡蛋]已经在最后一步被覆盖但与v4[鸡蛋,牛奶,火腿]并发,所以服务器保留这两个并发值。
![](img/fig5-13.png)
@ -706,7 +706,7 @@
#### 版本向量
[图5-13](img/fig5-13.png)中的示例只使用一个副本。如果有没有主库,有多个副本,算法如何改变
[图5-13](img/fig5-13.png)中的示例只使用一个副本。当有多个副本但没有领导者时,算法如何修改
[图5-13](img/fig5-13.png)使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键使用版本号之外,还需要在**每个副本**中版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。
@ -758,7 +758,7 @@
***无主复制***
客户端发送每个写入到几个节点,并从多个节点并行读取,以检测和纠正具有陈旧数据的节点。
每种方法都有优点和缺点。单主复制是非常流行的,因为它很容易理解,不需要担心冲突解决。在出现故障节点,网络中断和延迟峰值的情况下,多领导者和无领导者复制可以更加稳健,但代价很难推理,只能提供非常弱的一致性保证
每种方法都有优点和缺点。单主复制是非常流行的,因为它很容易理解,不需要担心冲突解决。在出现故障节点,网络中断和延迟峰值的情况下,多领导者和无领导者复制可以更加稳健,但以更难以推理并仅提供非常弱的一致性保证为代价
复制可以是同步的,也可以是异步的,在发生故障时对系统行为有深远的影响。尽管在系统运行平稳时异步复制速度很快,但是在复制滞后增加和服务器故障时要弄清楚会发生什么,这一点很重要。如果一个领导者失败了,并且你推动一个异步更新的追随者成为新的领导者,那么最近承诺的数据可能会丢失。
@ -770,7 +770,7 @@
***单调读***
当用户在某个时间点看到数据后,他们不应该在较早的时间点看到数据。
用户在一个时间点看到数据后,他们不应该在某个早期时间点看到数据。
***一致前缀读***

4
ch7.md
View File

@ -207,7 +207,7 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
- 如果事务实际上成功了,但是在服务器试图向客户端确认提交成功时网络发生故障(所以客户端认为提交失败了),那么重试事务会导致事务被执行两次——除非你有一个额外的应用级除重机制。
- 如果错误是由于负载过大造成的,则重试事务将使问题变得更糟,而不是更好。为了避免这种正反馈循环,可以限制重试次数,使用指数退避算法,并单独处理与过载相关的错误(如果允许)。
- 仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障转移)后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
- 仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
- 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果你正在发送电子邮件,那你肯定不希望每次重试事务时都重新发送电子邮件。如果你想确保几个不同的系统一起提交或放弃,**二阶段提交2PC, two-phase commit** 可以提供帮助(“[原子提交和两阶段提交2PC](ch9.md#原子提交与二阶段提交2PC)”中将讨论这个问题)。
- 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。
@ -227,7 +227,7 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
实际上不幸的是:隔离并没有那么简单。**可序列化** 会有性能损失许多数据库不愿意支付这个代价【8】。因此系统通常使用较弱的隔离级别来防止一部分而不是全部的并发问题。这些隔离级别难以理解并且会导致微妙的错误但是它们仍然在实践中被使用【23】。
并发性错误导致的并发性错误不仅仅是一个理论问题。它们造成了很多的资金损失【24,25】耗费了财务审计人员的调查【26】并导致客户数据被破坏【27】。关于这类问题的一个流行的评论是“如果你正在处理财务数据请使用ACID数据库” —— 但是这一点没有提到。即使是很多流行的关系型数据库系统通常被认为是“ACID”也使用弱隔离级别所以它们也不一定能防止这些错误的发生。
弱事务隔离级别导致的并发性错误不仅仅是一个理论问题。它们造成了很多的资金损失【24,25】耗费了财务审计人员的调查【26】并导致客户数据被破坏【27】。关于这类问题的一个流行的评论是“如果你正在处理财务数据请使用ACID数据库” —— 但是这一点没有提到。即使是很多流行的关系型数据库系统通常被认为是“ACID”也使用弱隔离级别所以它们也不一定能防止这些错误的发生。
比起盲目地依赖工具,我们应该对存在的并发问题的种类,以及如何防止这些问题有深入的理解。然后就可以使用我们所掌握的工具来构建可靠和正确的应用程序。

4
ch8.md
View File

@ -16,7 +16,7 @@
[TOC]
最近几章中反复出现的主题是,系统如何处理错误的事情。例如,我们讨论了**副本故障转移**(“[处理节点中断](#ch5.md#处理节点宕机)”),**复制延迟**(“[复制延迟问题](ch6.md#复制延迟问题)”)和事务控制(“[弱隔离级别](ch7.md#弱隔离级别)”)。当我们了解可能在实际系统中出现的各种边缘情况时,我们会更好地处理它们。
最近几章中反复出现的主题是,系统如何处理错误的事情。例如,我们讨论了**副本故障切换**(“[处理节点中断](#ch5.md#处理节点宕机)”),**复制延迟**(“[复制延迟问题](ch6.md#复制延迟问题)”)和事务控制(“[弱隔离级别](ch7.md#弱隔离级别)”)。当我们了解可能在实际系统中出现的各种边缘情况时,我们会更好地处理它们。
但是,尽管我们已经谈了很多错误,但之前几章仍然过于乐观。现实更加黑暗。我们现在将悲观主义最大化,假设任何可能出错的东西**都会**出错[^i]。(经验丰富的系统运维会告诉你,这是一个合理的假设。如果你问得好,他们可能会一边治疗心理创伤一边告诉你一些可怕的故事)
@ -467,7 +467,7 @@ while(true){
第三种情况,想象一个经历了一个长时间**停止世界垃圾收集暂停stop-the-world GC Pause**的节点。节点的所有线程被GC抢占并暂停一分钟因此没有请求被处理也没有响应被发送。其他节点等待重试不耐烦并最终宣布节点死亡并将其丢到灵车上。最后GC完成节点的线程继续好像什么也没有发生。其他节点感到惊讶因为所谓的死亡节点突然从棺材中抬起头来身体健康开始和旁观者高兴地聊天。GC后的节点最初甚至没有意识到已经经过了整整一分钟而且自己已被宣告死亡。从它自己的角度来看从最后一次与其他节点交谈以来几乎没有经过任何时间。
这些故事的寓意是,节点不一定能相信自己对于情况的判断。分布式系统不能完全依赖单个节点,因为节点可能随时失效,可能会使系统卡死,无法恢复。相反,许多分布式算法都依赖于法定人数,即在节点之间进行投票(参阅“[读写法定人数](ch5.md#读写法定人数)”):决策需要来自多个节点的最小投票数,以减少对于某个特定节点的依赖。
这些故事的寓意是,节点不一定能相信自己对于情况的判断。分布式系统不能完全依赖单个节点,因为节点可能随时失效,可能会使系统卡死,无法恢复。相反,许多分布式算法都依赖于法定人数,即在节点之间进行投票(参阅“[读写的法定人数](ch5.md#读写的法定人数)“):决策需要来自多个节点的最小投票数,以减少对于某个特定节点的依赖。
这也包括关于宣告节点死亡的决定。如果法定数量的节点宣告另一个节点已经死亡,那么即使该节点仍感觉自己活着,它也必须被认为是死的。个体节点必须遵守法定决定并下台。

10
ch9.md
View File

@ -17,7 +17,7 @@
现在我们将继续沿着同样的路线前进,寻求可以让应用忽略分布式系统部分问题的抽象概念。例如,分布式系统最重要的抽象之一就是**共识consensus****就是让所有的节点对某件事达成一致**。正如我们在本章中将会看到的那样,尽管存在网络故障和流程故障,可靠地达成共识是一个令人惊讶的棘手问题。
一旦达成共识,应用可以将其用于各种目的。例如,假设你有一个单主复制的数据库。如果主库挂点,并且需要故障转移到另一个节点,剩余的数据库节点可以使用共识来选举新的领导者。正如在“[处理节点宕机](ch5.md#处理节点宕机)”中所讨论的那样,重要的是只有一个领导者,且所有的节点都认同其领导。如果两个节点都认为自己是领导者,这种情况被称为**脑裂split brain**,且经常导致数据丢失。正确实现共识有助于避免这种问题。
一旦达成共识,应用可以将其用于各种目的。例如,假设你有一个单主复制的数据库。如果主库挂点,并且需要故障切换到另一个节点,剩余的数据库节点可以使用共识来选举新的领导者。正如在“[处理节点宕机](ch5.md#处理节点宕机)”中所讨论的那样,重要的是只有一个领导者,且所有的节点都认同其领导。如果两个节点都认为自己是领导者,这种情况被称为**脑裂split brain**,且经常导致数据丢失。正确实现共识有助于避免这种问题。
在本章后面的“[分布式事务和共识](#分布式事务和共识)”中,我们将研究解决共识和相关问题的算法。但首先,我们首先需要探索可以在分布式系统中提供的保证和抽象的范围。
@ -210,7 +210,7 @@
[^iv]: 对单领域数据库进行分区(分片),以便每个分区有一个单独的领导者,不会影响线性一致性,因为线性一致性只是对单一对象的保证。 交叉分区事务是一个不同的问题(参阅“[分布式事务和共识](#分布式事务和共识)”)。
从主库读取依赖一个假设,你确定领导是谁。正如在“[真理在多数人手中](ch8.md#真理被多数人定义)”中所讨论的那样一个节点很可能会认为它是领导者而事实上并非如此——如果具有错觉的领导者继续为请求提供服务可能违反线性一致性【20】。使用异步复制故障转移时甚至可能会丢失已提交的写入(参阅“[处理节点宕机](ch5.md#处理节点宕机)”),这同时违反了持久性和线性一致性。
从主库读取依赖一个假设,你确定领导是谁。正如在“[真理在多数人手中](ch8.md#真理被多数人定义)”中所讨论的那样一个节点很可能会认为它是领导者而事实上并非如此——如果具有错觉的领导者继续为请求提供服务可能违反线性一致性【20】。使用异步复制故障切换时甚至可能会丢失已提交的写入(参阅“[处理节点宕机](ch5.md#处理节点宕机)”),这同时违反了持久性和线性一致性。
***共识算法(线性一致)***
@ -240,7 +240,7 @@
仲裁条件满足( $w + r> n$ 但是这个执行是非线性一致的B的请求在A的请求完成后开始但是B返回旧值而A返回新值。 又一次如同Alice和Bob的例子 [图9-1]()
有趣的是通过牺牲性能可以使Dynamo风格的法定人数线性化读取者必须在将结果返回给应用之前同步执行读修复(参阅“[读时修复与反熵过程](ch5.md#读时修复与反熵过程)”) 并且写入者必须在发送写入之前读取法定数量节点的最新状态【24,25】。然而由于性能损失Riak不执行同步读修复【26】。 Cassandra在进行法定人数读取时**确实**在等待读修复完成【27】但是由于使用了最后写入为准的冲突解决方案当同一个键有多个并发写入时将不能保证线性一致性。
有趣的是通过牺牲性能可以使Dynamo风格的法定人数线性化读取者必须在将结果返回给应用之前同步执行读修复参阅“[读时修复与反熵过程](ch5.md#读时修复与反熵过程)”) 并且写入者必须在发送写入之前读取法定数量节点的最新状态【24,25】。然而由于性能损失Riak不执行同步读修复【26】。 Cassandra在进行法定人数读取时**确实**在等待读修复完成【27】但是由于使用了最后写入为准的冲突解决方案当同一个键有多个并发写入时将不能保证线性一致性。
而且这种方式只能实现线性一致的读写不能实现线性一致的比较和设置操作因为它需要一个共识算法【28】。
@ -468,7 +468,7 @@
如果你的程序只运行在单个CPU核上那么定义一个操作全序是很容易的可以简单地就是CPU执行这些操作的顺序。但是在分布式系统中让所有节点对同一个全局操作顺序达成一致可能相当棘手。在上一节中我们讨论了按时间戳或序列号进行排序但发现它还不如单主复制给力如果你使用时间戳排序来实现唯一性约束而且不能容忍任何错误
如前所述单主复制通过选择一个节点作为主库来确定操作的全序并在主库的单个CPU核上对所有操作进行排序。接下来的挑战是如果吞吐量超出单个主库的处理能力这种情况下如何扩展系统以及如果主库失效“[处理节点宕机](#处理节点宕机)”),如何处理故障转移。在分布式系统文献中,这个问题被称为**全序广播total order broadcast**或**原子广播atomic broadcast**[^ix]【25,57,58】。
如前所述单主复制通过选择一个节点作为主库来确定操作的全序并在主库的单个CPU核上对所有操作进行排序。接下来的挑战是如果吞吐量超出单个主库的处理能力这种情况下如何扩展系统以及如果主库失效“[处理节点宕机](#处理节点宕机)”),如何处理故障切换。在分布式系统文献中,这个问题被称为**全序广播total order broadcast**或**原子广播atomic broadcast**[^ix]【25,57,58】。
[^ix]: “原子广播”是一个传统的术语非常混乱而且与“原子”一词的其他用法不一致它与ACID事务中的原子性没有任何关系只是与原子操作在多线程编程的意义上 )或原子寄存器(线性一致存储)有间接的联系。全序广播是另一个同义词。
@ -809,7 +809,7 @@
答案取决于如何选择领导者。如果主库是由运维人员手动选择和配置的,那么你实际上拥有一种**独裁类型**的“共识算法”:只有一个节点被允许接受写入(即决定写入复制日志的顺序),如果该节点发生故障,则系统将无法写入,直到运维手动配置其他节点作为主库。这样的系统在实践中可以表现良好,但它无法满足共识的**终止**属性,因为它需要人为干预才能取得**进展**。
一些数据库会自动执行领导者选举和故障转移,如果旧主库失效,会提拔一个从库为新主库(参见“[处理节点宕机](ch5.md#处理节点宕机)”)。这使我们向容错的全序广播更进一步,从而达成共识。
一些数据库会自动执行领导者选举和故障切换,如果旧主库失效,会提拔一个从库为新主库(参见“[处理节点宕机](ch5.md#处理节点宕机)”)。这使我们向容错的全序广播更进一步,从而达成共识。
但是还有一个问题。我们之前曾经讨论过脑裂的问题,并且说过所有的节点都需要同意是谁领导,否则两个不同的节点都会认为自己是领导者,从而导致数据库进入不一致的状态。因此,选出一位领导者需要共识。但如果这里描述的共识算法实际上是全序广播算法,并且全序广播就像单主复制,而单主复制需要一个领导者,那么...

View File

@ -118,9 +118,9 @@
### 故障转移failover
### 故障切换failover
在具有单一领导者的系统中,故障转移是将领导角色从一个节点转移到另一个节点的过程。请参阅第156页的“处理节点故障”。
在具有单一领导者的系统中,故障切换是将领导角色从一个节点转移到另一个节点的过程。请参阅第156页的“处理节点故障”。

View File

@ -1,4 +1,4 @@
# 第一部分 数据系统基础
# 第一部分 数据系统的基石
本书前四章介绍了数据系统底层的基础概念,无论是在单台机器上运行的单点数据系统,还是分布在多台机器上的分布式数据系统都适用。
@ -26,4 +26,4 @@
| 上一章 | 目录 | 下一章 |
| ------------------ | ------------------------------- | -------------------------------------------- |
| [序言](preface.md) | [设计数据密集型应用](README.md) | [第一章:可靠性、可扩展性、可维护性](ch1.md) |
| [序言](preface.md) | [设计数据密集型应用](README.md) | [第一章:可靠性、可扩展性、可维护性](ch1.md) |