adjust content format for hugo hextra

This commit is contained in:
Feng Ruohang 2025-07-31 07:28:15 +08:00
parent 0b9033753d
commit 8f5f50977d
25 changed files with 1669 additions and 1129 deletions

View File

@ -1,77 +0,0 @@
# Summary
* [简介](README.md)
* [序言](preface.md)
* [第一部分:数据系统基础](part-i.md)
* [第一章:可靠性、可伸缩性和可维护性](ch1.md)
* [关于数据系统的思考](ch1.md#关于数据系统的思考)
* [可靠性](ch1.md#可靠性)
* [可伸缩性](ch1.md#可伸缩性)
* [可维护性](ch1.md#可维护性)
* [本章小结](ch1.md#本章小结)
* [第二章:数据模型与查询语言](ch2.md)
* [关系模型与文档模型](ch2.md#关系模型与文档模型)
* [数据查询语言](ch2.md#数据查询语言)
* [图数据模型](ch2.md#图数据模型)
* [本章小结](ch2.md#本章小结)
* [第三章:存储与检索](ch3.md)
* [驱动数据库的数据结构](ch3.md#驱动数据库的数据结构)
* [事务处理还是分析?](ch3.md#事务处理还是分析?)
* [列式存储](ch3.md#列式存储)
* [本章小结](ch3.md#本章小结)
* [第四章:编码与演化](ch4.md)
* [编码数据的格式](ch4.md#编码数据的格式)
* [数据流的类型](ch4.md#数据流的类型)
* [本章小结](ch4.md#本章小结)
* [第二部分:分布式数据](part-ii.md)
* [第五章:复制](ch5.md)
* [领导者与追随者](ch5.md#领导者与追随者)
* [复制延迟问题](ch5.md#复制延迟问题)
* [多主复制](ch5.md#多主复制)
* [无主复制](ch5.md#无主复制)
* [本章小结](ch5.md#本章小结)
* [第六章:分区](ch6.md)
* [分区与复制](ch6.md#分区与复制)
* [键值数据的分区](ch6.md#键值数据的分区)
* [分区与次级索引](ch6.md#分区与次级索引)
* [分区再平衡](ch6.md#分区再平衡)
* [请求路由](ch6.md#请求路由)
* [本章小结](ch6.md#本章小结)
* [第七章:事务](ch7.md)
* [事务的棘手概念](ch7.md#事务的棘手概念)
* [弱隔离级别](ch7.md#弱隔离级别)
* [可串行化](ch7.md#可串行化)
* [本章小结](ch7.md#本章小结)
* [第八章:分布式系统的麻烦](ch8.md)
* [故障与部分失效](ch8.md#故障与部分失效)
* [不可靠的网络](ch8.md#不可靠的网络)
* [不可靠的时钟](ch8.md#不可靠的时钟)
* [知识、真相与谎言](ch8.md#知识、真相与谎言)
* [本章小结](ch8.md#本章小结)
* [第九章:一致性与共识](ch9.md)
* [一致性保证](ch9.md#一致性保证)
* [线性一致性](ch9.md#线性一致性)
* [顺序保证](ch9.md#顺序保证)
* [分布式事务与共识](ch9.md#分布式事务与共识)
* [本章小结](ch9.md#本章小结)
* [第三部分:衍生数据](part-iii.md)
* [第十章:批处理](ch10.md)
* [使用Unix工具的批处理](ch10.md#使用Unix工具的批处理)
* [MapReduce和分布式文件系统](ch10.md#MapReduce和分布式文件系统)
* [MapReduce之后](ch10.md#MapReduce之后)
* [本章小结](ch10.md#本章小结)
* [第十一章:流处理](ch11.md)
* [传递事件流](ch11.md#传递事件流)
* [数据库与流](ch11.md#数据库与流)
* [流处理](ch11.md#流处理)
* [本章小结](ch11.md#本章小结)
* [第十二章:数据系统的未来](ch12.md)
* [数据集成](ch12.md#数据集成)
* [分拆数据库](ch12.md#分拆数据库)
* [将事情做正确](ch12.md#将事情做正确)
* [做正确的事情](ch12.md#做正确的事情)
* [本章小结](ch12.md#本章小结)
* [术语表](glossary.md)
* [后记](colophon.md)

View File

@ -1,5 +0,0 @@
- 语言版本
- [简体中文](/)
- [繁体中文](/zh-tw/)
- [英文原文](/en-us/)

View File

@ -1,18 +0,0 @@
- [序言](preface.md)
- [第一部分:数据系统基础](part-i.md)
- [第一章:可靠性、可伸缩性和可维护性](ch1.md)
- [第二章:数据模型与查询语言](ch2.md)
- [第三章:存储与检索](ch3.md)
- [第四章:编码与演化](ch4.md)
- [第二部分:分布式数据](part-ii.md)
- [第五章:复制](ch5.md)
- [第六章:分区](ch6.md)
- [第七章:事务](ch7.md)
- [第八章:分布式系统的麻烦](ch8.md)
- [第九章:一致性与共识](ch9.md)
- [第三部分:衍生数据](part-iii.md)
- [第十章:批处理](ch10.md)
- [第十一章:流处理](ch11.md)
- [第十二章:数据系统的未来](ch12.md)
- [术语表](glossary.md)
- [后记](colophon.md)

315
content/zh/_index.md Normal file
View File

@ -0,0 +1,315 @@
---
title: 设计数据密集型应用
linkTitle: DDIA
cascade:
type: docs
breadcrumbs: false
---
**作者** [Martin Kleppmann](https://martin.kleppmann.com)[《Designing Data-Intensive Applications 2nd Edition》](https://learning.oreilly.com/library/view/designing-data-intensive-applications/9781098119058/ch01.html) 英国剑桥大学分布式系统研究员,演讲者,博主和开源贡献者,软件工程师和企业家,曾在 LinkedIn 和 Rapportive 负责数据基础架构。
**译者**[**冯若航**](https://vonng.com),网名 [@Vonng](https://github.com/Vonng)。
PostgreSQL 专家,数据库老司机,云计算泥石流。
[**Pigsty**](https://pgsty.com) 作者与创始人。
架构师DBA全栈工程师 @ TanTanAlibabaApple。
独立开源贡献者,[GitStar Ranking 585](https://gitstar-ranking.com/Vonng)[国区活跃 Top20](https://committers.top/china)。
[DDIA](https://ddia.pigsty.io) / [PG Internal](https://pgint.vonng.com) 中文版译者,公众号:《老冯云数》,数据库 KOL。
**校订** [@yingang](https://github.com/yingang) [繁體中文](/tw) **版本维护** by [@afunTW](https://github.com/afunTW) [完整贡献者列表](/contrib)
> [!NOTE]
> DDIA [**第二版**](https://github.com/Vonng/ddia/tree/v2) 正在翻译中 ([`v2`](https://github.com/Vonng/ddia/tree/v2)分支),欢迎加入并提出您的宝贵意见!
## 译序
> 不懂数据库的全栈工程师不是好架构师 —— 冯若航 / Vonng
现今尤其是在互联网领域大多数应用都属于数据密集型应用。本书从底层数据结构到顶层架构设计将数据系统设计中的精髓娓娓道来。其中的宝贵经验无论是对架构师、DBA、还是后端工程师、甚至产品经理都会有帮助。
这是一本理论结合实践的书,书中很多问题,译者在实际场景中都曾遇到过,读来让人击节扼腕。如果能早点读到这本书,该少走多少弯路啊!
这也是一本深入浅出的书,讲述概念的来龙去脉而不是卖弄定义,介绍事物发展演化历程而不是事实堆砌,将复杂的概念讲述的浅显易懂,但又直击本质不失深度。每章最后的引用质量非常好,是深入学习各个主题的绝佳索引。
本书为数据系统的设计、实现、与评价提供了很好的概念框架。读完并理解本书内容后,读者可以轻松看破大多数的技术忽悠,与技术砖家撕起来虎虎生风。
这是 2017 年译者读过最好的一本技术类书籍,这么好的书没有中文翻译,实在是遗憾。某不才,愿为先进技术文化的传播贡献一份力量。既可以深入学习有趣的技术主题,又可以锻炼中英文语言文字功底,何乐而不为?
## 前言
> 在我们的社会中,技术是一种强大的力量。数据、软件、通信可以用于坏的方面:不公平的阶级固化,损害公民权利,保护既得利益集团。但也可以用于好的方面:让底层人民发出自己的声音,让每个人都拥有机会,避免灾难。本书献给所有将技术用于善途的人们。
> 计算是一种流行文化,流行文化鄙视历史。流行文化关乎个体身份和参与感,但与合作无关。流行文化活在当下,也与过去和未来无关。我认为大部分(为了钱)编写代码的人就是这样的,他们不知道自己的文化来自哪里。
>
> —— 阿兰・凯接受 Dobb 博士的杂志采访时2012 年)
## 目录
### [序言](/preface)
### [第一部分:数据系统基础](/part-i)
* [第一章:可靠性、可伸缩性和可维护性](/ch1)
* [第二章:数据模型与查询语言](/ch2)
* [第三章:存储与检索](/ch3)
* [第四章:编码与演化](/ch4)
### [第二部分:分布式数据](/part-ii)
* [第五章:复制](/ch5)
* [第六章:分区](/ch6)
* [第七章:事务](/ch7)
* [第八章:分布式系统的麻烦](/ch8)
* [第九章:一致性与共识](/ch9)
### [第三部分:衍生数据](/part-iii)
* [第十章:批处理](/ch10)
* [第十一章:流处理](/ch11)
* [第十二章:数据系统的未来](/ch12)
### [术语表](/glossary)
### [后记](/colophon)
<br>
---------
## 法律声明
从原作者处得知,已经有简体中文的翻译计划,将于 2018 年末完成。[购买地址](https://search.jd.com/Search?keyword=设计数据密集型应用)
译者纯粹出于 **学习目的****个人兴趣** 翻译本书,不追求任何经济利益。
译者保留对此版本译文的署名权,其他权利以原作者和出版社的主张为准。
本译文只供学习研究参考之用,不得公开传播发行或用于商业用途。有能力阅读英文书籍者请购买正版支持。
---------
## 贡献
0. 全文校订 by [@yingang](https://github.com/Vonng/ddia/commits?author=yingang)
1. [序言初翻修正](https://github.com/Vonng/ddia/commit/afb5edab55c62ed23474149f229677e3b42dfc2c) by [@seagullbird](https://github.com/Vonng/ddia/commits?author=seagullbird)
2. [第一章语法标点校正](https://github.com/Vonng/ddia/commit/973b12cd8f8fcdf4852f1eb1649ddd9d187e3644) by [@nevertiree](https://github.com/Vonng/ddia/commits?author=nevertiree)
3. [第六章部分校正](https://github.com/Vonng/ddia/commit/d4eb0852c0ec1e93c8aacc496c80b915bb1e6d48) 与[第十章的初翻](https://github.com/Vonng/ddia/commit/9de8dbd1bfe6fbb03b3bf6c1a1aa2291aed2490e) by [@MuAlex](https://github.com/Vonng/ddia/commits?author=MuAlex)
4. [第一部分](/part-i)前言,[ch2](/ch2)校正 by [@jiajiadebug](https://github.com/Vonng/ddia/commits?author=jiajiadebug)
5. [词汇表](/glossary)、[后记](/colophon)关于野猪的部分 by [@Chowss](https://github.com/Vonng/ddia/commits?author=Chowss)
6. [繁體中文](https://github.com/Vonng/ddia/pulls)版本与转换脚本 by [@afunTW](https://github.com/afunTW)
7. 多处翻译修正 by [@songzhibin97](https://github.com/Vonng/ddia/commits?author=songzhibin97) [@MamaShip](https://github.com/Vonng/ddia/commits?author=MamaShip) [@FangYuan33](https://github.com/Vonng/ddia/commits?author=FangYuan33)
8. [感谢所有作出贡献,提出意见的朋友们](/contrib)
<details>
<summary><a href="https://github.com/Vonng/ddia/pulls">Pull Requests</a> & <a href="https://github.com/Vonng/ddia/issues">Issues</a></summary>
| ISSUE & Pull Requests | USER | Title |
|-------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------|
| [359](https://github.com/Vonng/ddia/pull/359) | [@c25423](https://github.com/c25423) | ch10: 修正一处拼写错误 |
| [358](https://github.com/Vonng/ddia/pull/358) | [@lewiszlw](https://github.com/lewiszlw) | ch4: 修正一处拼写错误 |
| [356](https://github.com/Vonng/ddia/pull/356) | [@lewiszlw](https://github.com/lewiszlw) | ch2: 修正一处标点错误 |
| [355](https://github.com/Vonng/ddia/pull/355) | [@DuroyGeorge](https://github.com/DuroyGeorge) | ch12: 修正一处格式错误 |
| [354](https://github.com/Vonng/ddia/pull/354) | [@justlorain](https://github.com/justlorain) | ch7: 修正一处参考链接 |
| [353](https://github.com/Vonng/ddia/pull/353) | [@fantasyczl](https://github.com/fantasyczl) | ch3&9: 修正两处引用错误 |
| [352](https://github.com/Vonng/ddia/pull/352) | [@fantasyczl](https://github.com/fantasyczl) | 支持输出为 EPUB 格式 |
| [349](https://github.com/Vonng/ddia/pull/349) | [@xiyihan0](https://github.com/xiyihan0) | ch1: 修正一处格式错误 |
| [348](https://github.com/Vonng/ddia/pull/348) | [@omegaatt36](https://github.com/omegaatt36) | ch3: 修正一处图像链接 |
| [346](https://github.com/Vonng/ddia/issues/346) | [@Vermouth1995](https://github.com/Vermouth1995) | ch1: 优化一处翻译 |
| [343](https://github.com/Vonng/ddia/pull/343) | [@kehao-chen](https://github.com/kehao-chen) | ch10: 优化一处翻译 |
| [341](https://github.com/Vonng/ddia/pull/341) | [@YKIsTheBest](https://github.com/YKIsTheBest) | ch3: 优化两处翻译 |
| [340](https://github.com/Vonng/ddia/pull/340) | [@YKIsTheBest](https://github.com/YKIsTheBest) | ch2: 优化多处翻译 |
| [338](https://github.com/Vonng/ddia/pull/338) | [@YKIsTheBest](https://github.com/YKIsTheBest) | ch1: 优化一处翻译 |
| [335](https://github.com/Vonng/ddia/pull/335) | [@kimi0230](https://github.com/kimi0230) | 修正一处繁体中文错误 |
| [334](https://github.com/Vonng/ddia/pull/334) | [@soulrrrrr](https://github.com/soulrrrrr) | ch2: 修正一处繁体中文错误 |
| [332](https://github.com/Vonng/ddia/pull/332) | [@justlorain](https://github.com/justlorain) | ch5: 修正一处翻译错误 |
| [331](https://github.com/Vonng/ddia/pull/331) | [@Lyianu](https://github.com/Lyianu) | ch9: 更正几处拼写错误 |
| [330](https://github.com/Vonng/ddia/pull/330) | [@Lyianu](https://github.com/Lyianu) | ch7: 优化一处翻译 |
| [329](https://github.com/Vonng/ddia/issues/329) | [@Lyianu](https://github.com/Lyianu) | ch6: 指出一处翻译错误 |
| [328](https://github.com/Vonng/ddia/pull/328) | [@justlorain](https://github.com/justlorain) | ch4: 更正一处翻译遗漏 |
| [326](https://github.com/Vonng/ddia/pull/326) | [@liangGTY](https://github.com/liangGTY) | ch1: 优化一处翻译 |
| [323](https://github.com/Vonng/ddia/pull/323) | [@marvin263](https://github.com/marvin263) | ch5: 优化一处翻译 |
| [322](https://github.com/Vonng/ddia/pull/322) | [@marvin263](https://github.com/marvin263) | ch8: 优化一处翻译 |
| [304](https://github.com/Vonng/ddia/pull/304) | [@spike014](https://github.com/spike014) | ch11: 优化一处翻译 |
| [298](https://github.com/Vonng/ddia/pull/298) | [@Makonike](https://github.com/Makonike) | ch11&12: 修正两处错误 |
| [284](https://github.com/Vonng/ddia/pull/284) | [@WAangzE](https://github.com/WAangzE) | ch4: 更正一处列表错误 |
| [283](https://github.com/Vonng/ddia/pull/283) | [@WAangzE](https://github.com/WAangzE) | ch3: 更正一处错别字 |
| [282](https://github.com/Vonng/ddia/pull/282) | [@WAangzE](https://github.com/WAangzE) | ch2: 更正一处公式问题 |
| [281](https://github.com/Vonng/ddia/pull/281) | [@lyuxi99](https://github.com/lyuxi99) | 更正多处内部链接错误 |
| [280](https://github.com/Vonng/ddia/pull/280) | [@lyuxi99](https://github.com/lyuxi99) | ch9: 更正内部链接错误 |
| [279](https://github.com/Vonng/ddia/issues/279) | [@codexvn](https://github.com/codexvn) | ch9: 指出公式在 GitHub Pages 显示的问题 |
| [278](https://github.com/Vonng/ddia/pull/278) | [@LJlkdskdjflsa](https://github.com/LJlkdskdjflsa) | 发现了繁体中文版本中的错误翻译 |
| [275](https://github.com/Vonng/ddia/pull/275) | [@117503445](https://github.com/117503445) | 更正 LICENSE 链接 |
| [274](https://github.com/Vonng/ddia/pull/274) | [@uncle-lv](https://github.com/uncle-lv) | ch7: 修正错别字 |
| [273](https://github.com/Vonng/ddia/pull/273) | [@Sdot-Python](https://github.com/Sdot-Python) | ch7: 统一了 write skew 的翻译 |
| [271](https://github.com/Vonng/ddia/pull/271) | [@Makonike](https://github.com/Makonike) | ch6: 统一了 rebalancing 的翻译 |
| [270](https://github.com/Vonng/ddia/pull/270) | [@Ynjxsjmh](https://github.com/Ynjxsjmh) | ch7: 修正不一致的翻译 |
| [263](https://github.com/Vonng/ddia/pull/263) | [@zydmayday](https://github.com/zydmayday) | ch5: 修正译文中的重复单词 |
| [260](https://github.com/Vonng/ddia/pull/260) | [@haifeiWu](https://github.com/haifeiWu) | ch4: 修正部分不准确的翻译 |
| [258](https://github.com/Vonng/ddia/pull/258) | [@bestgrc](https://github.com/bestgrc) | ch3: 修正一处翻译错误 |
| [257](https://github.com/Vonng/ddia/pull/257) | [@UnderSam](https://github.com/UnderSam) | ch8: 修正一处拼写错误 |
| [256](https://github.com/Vonng/ddia/pull/256) | [@AlphaWang](https://github.com/AlphaWang) | ch7: 修正“可串行化”相关内容的多处翻译不当 |
| [255](https://github.com/Vonng/ddia/pull/255) | [@AlphaWang](https://github.com/AlphaWang) | ch7: 修正“可重复读”相关内容的多处翻译不当 |
| [253](https://github.com/Vonng/ddia/pull/253) | [@AlphaWang](https://github.com/AlphaWang) | ch7: 修正“读已提交”相关内容的多处翻译不当 |
| [246](https://github.com/Vonng/ddia/pull/246) | [@derekwu0101](https://github.com/derekwu0101) | ch3: 修正繁体中文的转译错误 |
| [245](https://github.com/Vonng/ddia/pull/245) | [@skyran1278](https://github.com/skyran1278) | ch12: 修正繁体中文的转译错误 |
| [244](https://github.com/Vonng/ddia/pull/244) | [@Axlgrep](https://github.com/Axlgrep) | ch9: 修正不通顺的翻译 |
| [242](https://github.com/Vonng/ddia/pull/242) | [@lynkeib](https://github.com/lynkeib) | ch9: 修正不通顺的翻译 |
| [241](https://github.com/Vonng/ddia/pull/241) | [@lynkeib](https://github.com/lynkeib) | ch8: 修正不正确的公式格式 |
| [240](https://github.com/Vonng/ddia/pull/240) | [@8da2k](https://github.com/8da2k) | ch9: 修正不通顺的翻译 |
| [239](https://github.com/Vonng/ddia/pull/239) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch7: 修正不一致的翻译 |
| [237](https://github.com/Vonng/ddia/pull/237) | [@zhangnew](https://github.com/zhangnew) | ch3: 修正错误的图片链接 |
| [229](https://github.com/Vonng/ddia/pull/229) | [@lis186](https://github.com/lis186) | 指出繁体中文的转译错误:复杂 |
| [226](https://github.com/Vonng/ddia/pull/226) | [@chroming](https://github.com/chroming) | ch1: 修正导航栏中的章节名称 |
| [220](https://github.com/Vonng/ddia/pull/220) | [@skyran1278](https://github.com/skyran1278) | ch9: 修正线性一致的繁体中文翻译 |
| [194](https://github.com/Vonng/ddia/pull/194) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch4: 修正错误的翻译 |
| [193](https://github.com/Vonng/ddia/pull/193) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch4: 优化译文 |
| [192](https://github.com/Vonng/ddia/pull/192) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch4: 修正不一致和不通顺的翻译 |
| [190](https://github.com/Vonng/ddia/pull/190) | [@Pcrab](https://github.com/Pcrab) | ch1: 修正不准确的翻译 |
| [187](https://github.com/Vonng/ddia/pull/187) | [@narojay](https://github.com/narojay) | ch9: 修正生硬的翻译 |
| [186](https://github.com/Vonng/ddia/pull/186) | [@narojay](https://github.com/narojay) | ch8: 修正错别字 |
| [185](https://github.com/Vonng/ddia/issues/185) | [@8da2k](https://github.com/8da2k) | 指出小标题跳转的问题 |
| [184](https://github.com/Vonng/ddia/pull/184) | [@DavidZhiXing](https://github.com/DavidZhiXing) | ch10: 修正失效的网址 |
| [183](https://github.com/Vonng/ddia/pull/183) | [@OneSizeFitsQuorum](https://github.com/OneSizeFitsQuorum) | ch8: 修正错别字 |
| [182](https://github.com/Vonng/ddia/issues/182) | [@lroolle](https://github.com/lroolle) | 建议docsify的主题风格 |
| [181](https://github.com/Vonng/ddia/pull/181) | [@YunfengGao](https://github.com/YunfengGao) | ch2: 修正翻译错误 |
| [180](https://github.com/Vonng/ddia/pull/180) | [@skyran1278](https://github.com/skyran1278) | ch3: 指出繁体中文的转译错误 |
| [177](https://github.com/Vonng/ddia/pull/177) | [@exzhawk](https://github.com/exzhawk) | 支持 Github Pages 里的公式显示 |
| [176](https://github.com/Vonng/ddia/pull/176) | [@haifeiWu](https://github.com/haifeiWu) | ch2: 语义网相关翻译更正 |
| [175](https://github.com/Vonng/ddia/pull/175) | [@cwr31](https://github.com/cwr31) | ch7: 不变式相关翻译更正 |
| [174](https://github.com/Vonng/ddia/pull/174) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | README & preface: 更正不正确的中文用词和标点符号 |
| [173](https://github.com/Vonng/ddia/pull/173) | [@ZvanYang](https://github.com/ZvanYang) | ch12: 修正不完整的翻译 |
| [171](https://github.com/Vonng/ddia/pull/171) | [@ZvanYang](https://github.com/ZvanYang) | ch12: 修正重复的译文 |
| [169](https://github.com/Vonng/ddia/pull/169) | [@ZvanYang](https://github.com/ZvanYang) | ch12: 更正不太通顺的翻译 |
| [166](https://github.com/Vonng/ddia/pull/166) | [@bp4m4h94](https://github.com/bp4m4h94) | ch1: 发现错误的文献索引 |
| [164](https://github.com/Vonng/ddia/pull/164) | [@DragonDriver](https://github.com/DragonDriver) | preface: 更正错误的标点符号 |
| [163](https://github.com/Vonng/ddia/pull/163) | [@llmmddCoder](https://github.com/llmmddCoder) | ch1: 更正错误字 |
| [160](https://github.com/Vonng/ddia/pull/160) | [@Zhayhp](https://github.com/Zhayhp) | ch2: 建议将 network model 翻译为网状模型 |
| [159](https://github.com/Vonng/ddia/pull/159) | [@1ess](https://github.com/1ess) | ch4: 更正错误字 |
| [157](https://github.com/Vonng/ddia/pull/157) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通顺的翻译 |
| [155](https://github.com/Vonng/ddia/pull/155) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通顺的翻译 |
| [153](https://github.com/Vonng/ddia/pull/153) | [@DavidZhiXing](https://github.com/DavidZhiXing) | ch9: 修正缩略图的错别字 |
| [152](https://github.com/Vonng/ddia/pull/152) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 除重->去重 |
| [151](https://github.com/Vonng/ddia/pull/151) | [@ZvanYang](https://github.com/ZvanYang) | ch5: 修订sibling相关的翻译 |
| [147](https://github.com/Vonng/ddia/pull/147) | [@ZvanYang](https://github.com/ZvanYang) | ch5: 更正一处不准确的翻译 |
| [145](https://github.com/Vonng/ddia/pull/145) | [@Hookey](https://github.com/Hookey) | 识别了当前简繁转译过程中处理不当的地方,暂通过转换脚本规避 |
| [144](https://github.com/Vonng/ddia/issues/144) | [@secret4233](https://github.com/secret4233) | ch7: 不翻译`next-key locking` |
| [143](https://github.com/Vonng/ddia/issues/143) | [@imcheney](https://github.com/imcheney) | ch3: 更新残留的机翻段落 |
| [142](https://github.com/Vonng/ddia/issues/142) | [@XIJINIAN](https://github.com/XIJINIAN) | 建议去除段首的制表符 |
| [141](https://github.com/Vonng/ddia/issues/141) | [@Flyraty](https://github.com/Flyraty) | ch5: 发现一处错误格式的章节引用 |
| [140](https://github.com/Vonng/ddia/pull/140) | [@Bowser1704](https://github.com/Bowser1704) | ch5: 修正章节Summary中多处不通顺的翻译 |
| [139](https://github.com/Vonng/ddia/pull/139) | [@Bowser1704](https://github.com/Bowser1704) | ch2&ch3: 修正多处不通顺的或错误的翻译 |
| [137](https://github.com/Vonng/ddia/pull/137) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch5&ch6: 优化多处不通顺的或错误的翻译 |
| [134](https://github.com/Vonng/ddia/pull/134) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch4: 优化多处不通顺的或错误的翻译 |
| [133](https://github.com/Vonng/ddia/pull/133) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch3: 优化多处错误的或不通顺的翻译 |
| [132](https://github.com/Vonng/ddia/pull/132) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch3: 优化一处容易产生歧义的翻译 |
| [131](https://github.com/Vonng/ddia/pull/131) | [@rwwg4](https://github.com/rwwg4) | ch6: 修正两处错误的翻译 |
| [129](https://github.com/Vonng/ddia/pull/129) | [@anaer](https://github.com/anaer) | ch4: 修正两处强调文本和四处代码变量名称 |
| [128](https://github.com/Vonng/ddia/pull/128) | [@meilin96](https://github.com/meilin96) | ch5: 修正一处错误的引用 |
| [126](https://github.com/Vonng/ddia/pull/126) | [@cwr31](https://github.com/cwr31) | ch10: 修正一处错误的翻译(功能 -> 函数) |
| [125](https://github.com/Vonng/ddia/pull/125) | [@dch1228](https://github.com/dch1228) | ch2: 优化 how best 的翻译(如何以最佳方式) |
| [123](https://github.com/Vonng/ddia/pull/123) | [@yingang](https://github.com/yingang) | translation updates (chapter 9, TOC in readme, glossary, etc.) |
| [121](https://github.com/Vonng/ddia/pull/121) | [@yingang](https://github.com/yingang) | translation updates (chapter 5 to chapter 8) |
| [120](https://github.com/Vonng/ddia/pull/120) | [@jiong-han](https://github.com/jiong-han) | Typo fix: 呲之以鼻 -> 嗤之以鼻 |
| [119](https://github.com/Vonng/ddia/pull/119) | [@cclauss](https://github.com/cclauss) | Streamline file operations in convert() |
| [118](https://github.com/Vonng/ddia/pull/118) | [@yingang](https://github.com/yingang) | translation updates (chapter 2 to chapter 4) |
| [117](https://github.com/Vonng/ddia/pull/117) | [@feeeei](https://github.com/feeeei) | 统一每章的标题格式 |
| [115](https://github.com/Vonng/ddia/pull/115) | [@NageNalock](https://github.com/NageNalock) | 第七章病句修改: 重复词语 |
| [114](https://github.com/Vonng/ddia/pull/114) | [@Sunt-ing](https://github.com/Sunt-ing) | Update README.md: correct the book name |
| [113](https://github.com/Vonng/ddia/pull/113) | [@lpxxn](https://github.com/lpxxn) | 修改语句 |
| [112](https://github.com/Vonng/ddia/pull/112) | [@ibyte2011](https://github.com/ibyte2011) | Update ch9.md |
| [110](https://github.com/Vonng/ddia/pull/110) | [@lpxxn](https://github.com/lpxxn) | 读已写入数据 |
| [107](https://github.com/Vonng/ddia/pull/107) | [@abbychau](https://github.com/abbychau) | 單調鐘和好死还是赖活着 |
| [106](https://github.com/Vonng/ddia/pull/106) | [@enochii](https://github.com/enochii) | typo in ch2: fix braces typo |
| [105](https://github.com/Vonng/ddia/pull/105) | [@LiminCode](https://github.com/LiminCode) | Chronicle translation error |
| [104](https://github.com/Vonng/ddia/pull/104) | [@Sunt-ing](https://github.com/Sunt-ing) | several advice for better translation |
| [103](https://github.com/Vonng/ddia/pull/103) | [@Sunt-ing](https://github.com/Sunt-ing) | typo in ch4: should be 完成 rather than 完全 |
| [102](https://github.com/Vonng/ddia/pull/102) | [@Sunt-ing](https://github.com/Sunt-ing) | ch4: better-translation: 扼杀 → 破坏 |
| [101](https://github.com/Vonng/ddia/pull/101) | [@Sunt-ing](https://github.com/Sunt-ing) | typo in Ch4: should be "改变" rathr than "盖面" |
| [100](https://github.com/Vonng/ddia/pull/100) | [@LiminCode](https://github.com/LiminCode) | fix missing translation |
| [99 ](https://github.com/Vonng/ddia/pull/99) | [@mrdrivingduck](https://github.com/mrdrivingduck) | ch6: fix the word rebalancing |
| [98 ](https://github.com/Vonng/ddia/pull/98) | [@jacklightChen](https://github.com/jacklightChen) | fix ch7.md: fix wrong references |
| [97 ](https://github.com/Vonng/ddia/pull/97) | [@jenac](https://github.com/jenac) | 96 |
| [96 ](https://github.com/Vonng/ddia/pull/96) | [@PragmaTwice](https://github.com/PragmaTwice) | ch2: fix typo about 'may or may not be' |
| [95 ](https://github.com/Vonng/ddia/pull/95) | [@EvanMu96](https://github.com/EvanMu96) | fix translation of "the battle cry" in ch5 |
| [94 ](https://github.com/Vonng/ddia/pull/94) | [@kemingy](https://github.com/kemingy) | ch6: fix markdown and punctuations |
| [93 ](https://github.com/Vonng/ddia/pull/93) | [@kemingy](https://github.com/kemingy) | ch5: fix markdown and some typos |
| [92 ](https://github.com/Vonng/ddia/pull/92) | [@Gilbert1024](https://github.com/Gilbert1024) | Merge pull request #1 from Vonng/master |
| [88 ](https://github.com/Vonng/ddia/pull/88) | [@kemingy](https://github.com/kemingy) | fix typo for ch1, ch2, ch3, ch4 |
| [87 ](https://github.com/Vonng/ddia/pull/87) | [@wynn5a](https://github.com/wynn5a) | Update ch3.md |
| [86 ](https://github.com/Vonng/ddia/pull/86) | [@northmorn](https://github.com/northmorn) | Update ch1.md |
| [85 ](https://github.com/Vonng/ddia/pull/85) | [@sunbuhui](https://github.com/sunbuhui) | fix ch2.md: fix ch2 ambiguous translation |
| [84 ](https://github.com/Vonng/ddia/pull/84) | [@ganler](https://github.com/ganler) | Fix translation: use up |
| [83 ](https://github.com/Vonng/ddia/pull/83) | [@afunTW](https://github.com/afunTW) | Using OpenCC to convert from zh-cn to zh-tw |
| [82 ](https://github.com/Vonng/ddia/pull/82) | [@kangni](https://github.com/kangni) | fix gitbook url |
| [78 ](https://github.com/Vonng/ddia/pull/78) | [@hanyu2](https://github.com/hanyu2) | Fix unappropriated translation |
| [77 ](https://github.com/Vonng/ddia/pull/77) | [@Ozarklake](https://github.com/Ozarklake) | fix typo |
| [75 ](https://github.com/Vonng/ddia/pull/75) | [@2997ms](https://github.com/2997ms) | Fix typo |
| [74 ](https://github.com/Vonng/ddia/pull/74) | [@2997ms](https://github.com/2997ms) | Update ch9.md |
| [70 ](https://github.com/Vonng/ddia/pull/70) | [@2997ms](https://github.com/2997ms) | Update ch7.md |
| [67 ](https://github.com/Vonng/ddia/pull/67) | [@jiajiadebug](https://github.com/jiajiadebug) | fix issues in ch2 - ch9 and glossary |
| [66 ](https://github.com/Vonng/ddia/pull/66) | [@blindpirate](https://github.com/blindpirate) | Fix typo |
| [63 ](https://github.com/Vonng/ddia/pull/63) | [@haifeiWu](https://github.com/haifeiWu) | Update ch10.md |
| [62 ](https://github.com/Vonng/ddia/pull/62) | [@ych](https://github.com/ych) | fix ch1.md typesetting problem |
| [61 ](https://github.com/Vonng/ddia/pull/61) | [@xianlaioy](https://github.com/xianlaioy) | docs:钟-->种去掉ou |
| [60 ](https://github.com/Vonng/ddia/pull/60) | [@Zombo1296](https://github.com/Zombo1296) | 否则 -> 或者 |
| [59 ](https://github.com/Vonng/ddia/pull/59) | [@AlexanderMisel](https://github.com/AlexanderMisel) | 呼叫->调用,显着->显著 |
| [58 ](https://github.com/Vonng/ddia/pull/58) | [@ibyte2011](https://github.com/ibyte2011) | Update ch8.md |
| [55 ](https://github.com/Vonng/ddia/pull/55) | [@saintube](https://github.com/saintube) | ch8: 修改链接错误 |
| [54 ](https://github.com/Vonng/ddia/pull/54) | [@Panmax](https://github.com/Panmax) | Update ch2.md |
| [53 ](https://github.com/Vonng/ddia/pull/53) | [@ibyte2011](https://github.com/ibyte2011) | Update ch9.md |
| [52 ](https://github.com/Vonng/ddia/pull/52) | [@hecenjie](https://github.com/hecenjie) | Update ch1.md |
| [51 ](https://github.com/Vonng/ddia/pull/51) | [@latavin243](https://github.com/latavin243) | fix 修正ch3 ch4几处翻译 |
| [50 ](https://github.com/Vonng/ddia/pull/50) | [@AlexZFX](https://github.com/AlexZFX) | 几个疏漏和格式错误 |
| [49 ](https://github.com/Vonng/ddia/pull/49) | [@haifeiWu](https://github.com/haifeiWu) | Update ch1.md |
| [48 ](https://github.com/Vonng/ddia/pull/48) | [@scaugrated](https://github.com/scaugrated) | fix typo |
| [47 ](https://github.com/Vonng/ddia/pull/47) | [@lzwill](https://github.com/lzwill) | Fixed typos in ch2 |
| [45 ](https://github.com/Vonng/ddia/pull/45) | [@zenuo](https://github.com/zenuo) | 删除一个多余的右括号 |
| [44 ](https://github.com/Vonng/ddia/pull/44) | [@akxxsb](https://github.com/akxxsb) | 修正第七章底部链接错误 |
| [43 ](https://github.com/Vonng/ddia/pull/43) | [@baijinping](https://github.com/baijinping) | "更假简单"->"更加简单" |
| [42 ](https://github.com/Vonng/ddia/pull/42) | [@tisonkun](https://github.com/tisonkun) | 修复 ch1 中的无序列表格式 |
| [38 ](https://github.com/Vonng/ddia/pull/38) | [@renjie-c](https://github.com/renjie-c) | 纠正多处的翻译小错误 |
| [37 ](https://github.com/Vonng/ddia/pull/37) | [@tankilo](https://github.com/tankilo) | fix translation mistakes in ch4.md |
| [36 ](https://github.com/Vonng/ddia/pull/36) | [@wwek](https://github.com/wwek) | 1.修复多个链接错误 2.名词优化修订 3.错误修订 |
| [35 ](https://github.com/Vonng/ddia/pull/35) | [@wwek](https://github.com/wwek) | fix ch7.md to ch8.md link error |
| [34 ](https://github.com/Vonng/ddia/pull/34) | [@wwek](https://github.com/wwek) | Merge pull request #1 from Vonng/master |
| [33 ](https://github.com/Vonng/ddia/pull/33) | [@wwek](https://github.com/wwek) | fix part-ii.md link error |
| [32 ](https://github.com/Vonng/ddia/pull/32) | [@JCYoky](https://github.com/JCYoky) | Update ch2.md |
| [31 ](https://github.com/Vonng/ddia/pull/31) | [@elsonLee](https://github.com/elsonLee) | Update ch7.md |
| [26 ](https://github.com/Vonng/ddia/pull/26) | [@yjhmelody](https://github.com/yjhmelody) | 修复一些明显错误 |
| [25 ](https://github.com/Vonng/ddia/pull/25) | [@lqbilbo](https://github.com/lqbilbo) | 修复链接错误 |
| [24 ](https://github.com/Vonng/ddia/pull/24) | [@artiship](https://github.com/artiship) | 修改词语顺序 |
| [23 ](https://github.com/Vonng/ddia/pull/23) | [@artiship](https://github.com/artiship) | 修正错别字 |
| [22 ](https://github.com/Vonng/ddia/pull/22) | [@artiship](https://github.com/artiship) | 纠正翻译错误 |
| [21 ](https://github.com/Vonng/ddia/pull/21) | [@zhtisi](https://github.com/zhtisi) | 修正目录和本章标题不符的情况 |
| [20 ](https://github.com/Vonng/ddia/pull/20) | [@rentiansheng](https://github.com/rentiansheng) | Update ch7.md |
| [19 ](https://github.com/Vonng/ddia/pull/19) | [@LHRchina](https://github.com/LHRchina) | 修复语句小bug |
| [16 ](https://github.com/Vonng/ddia/pull/16) | [@MuAlex](https://github.com/MuAlex) | Master |
| [15 ](https://github.com/Vonng/ddia/pull/15) | [@cg-zhou](https://github.com/cg-zhou) | Update translation progress |
| [14 ](https://github.com/Vonng/ddia/pull/14) | [@cg-zhou](https://github.com/cg-zhou) | Translate glossary |
| [13 ](https://github.com/Vonng/ddia/pull/13) | [@cg-zhou](https://github.com/cg-zhou) | 详细修改了后记中和印度野猪相关的描述 |
| [12 ](https://github.com/Vonng/ddia/pull/12) | [@ibyte2011](https://github.com/ibyte2011) | 修改了部分翻译 |
| [11 ](https://github.com/Vonng/ddia/pull/11) | [@jiajiadebug](https://github.com/jiajiadebug) | ch2 100% |
| [10 ](https://github.com/Vonng/ddia/pull/10) | [@jiajiadebug](https://github.com/jiajiadebug) | ch2 20% |
| [9 ](https://github.com/Vonng/ddia/pull/9) | [@jiajiadebug](https://github.com/jiajiadebug) | Preface, ch1, part-i translation minor fixes |
| [7 ](https://github.com/Vonng/ddia/pull/7) | [@MuAlex](https://github.com/MuAlex) | Ch6 translation pull request |
| [6 ](https://github.com/Vonng/ddia/pull/6) | [@MuAlex](https://github.com/MuAlex) | Ch6 change version1 |
| [5 ](https://github.com/Vonng/ddia/pull/5) | [@nevertiree](https://github.com/nevertiree) | Chapter 01语法微调 |
| [2 ](https://github.com/Vonng/ddia/pull/2) | [@seagullbird](https://github.com/seagullbird) | 序言初翻 |
</details><br />
---------
## 许可证
本项目采用 [CC-BY 4.0](https://github.com/Vonng/ddia/blob/master/LICENSE) 许可证,您可以在这里找到完整说明:
- [署名 4.0 协议国际版 CC BY 4.0 Deed](https://creativecommons.org/licenses/by/4.0/deed.zh-hans)
- [Attribution 4.0 International CC BY 4.0](https://creativecommons.org/licenses/by/4.0/deed.en)

View File

@ -1,15 +1,17 @@
# 第一章:可靠性、可伸缩性和可维护性
---
title: "第一章:可靠性、可伸缩性和可维护性"
linkTitle: "1. 可靠性、可伸缩性和可维护性"
weight: 101
breadcrumbs: false
---
![](img/ch1.png)
![](/img/ch1.png)
> 互联网做得太棒了,以至于大多数人将它看作像太平洋这样的自然资源,而不是什么人工产物。上一次出现这种大规模且无差错的技术,你还记得是什么时候吗?
>
> —— [艾伦・凯](http://www.drdobbs.com/architecture-and-design/interview-with-alan-kay/240003442) 在接受 Dobb 博士杂志采访时说2012 年)
-----------------------
[TOC]
现今很多应用程序都是 **数据密集型data-intensive** 的,而非 **计算密集型compute-intensive** 的。因此 CPU 很少成为这类应用的瓶颈,更大的问题通常来自数据量、数据复杂性、以及数据的变更速度。
数据密集型应用通常由标准组件构建而成,标准组件提供了很多通用的功能;例如,许多应用程序都需要:
@ -39,9 +41,9 @@
其次,越来越多的应用程序有着各种严格而广泛的要求,单个工具不足以满足所有的数据处理和存储需求。取而代之的是,总体工作被拆分成一系列能被单个工具高效完成的任务,并通过应用代码将它们缝合起来。
例如如果将缓存应用管理的缓存层Memcached 或同类产品)和全文搜索(全文搜索服务器,例如 Elasticsearch 或 Solr功能从主数据库剥离出来那么使缓存 / 索引与主数据库保持同步通常是应用代码的责任。[图 1-1](img/fig1-1.png) 给出了这种架构可能的样子(细节将在后面的章节中详细介绍)。
例如如果将缓存应用管理的缓存层Memcached 或同类产品)和全文搜索(全文搜索服务器,例如 Elasticsearch 或 Solr功能从主数据库剥离出来那么使缓存 / 索引与主数据库保持同步通常是应用代码的责任。[图 1-1](/img/fig1-1.png) 给出了这种架构可能的样子(细节将在后面的章节中详细介绍)。
![](img/fig1-1.png)
![](/img/fig1-1.png)
**图 1-1 一个可能的组合使用多个组件的数据系统架构**
@ -166,7 +168,7 @@
大体上讲,这一对操作有两种实现方式。
1. 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如 [图 1-2](img/fig1-2.png) 所示的关系型数据库中,可以编写这样的查询:
1. 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如 [图 1-2](/img/fig1-2.png) 所示的关系型数据库中,可以编写这样的查询:
```sql
SELECT tweets.*, users.*
@ -176,13 +178,13 @@
WHERE follows.follower_id = current_user
```
![](img/fig1-2.png)
![](/img/fig1-2.png)
**图 1-2 推特主页时间线的关系型模式简单实现**
2. 为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱([图 1-3](img/fig1-3.png))。当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。
2. 为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱([图 1-3](/img/fig1-3.png))。当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。
![](img/fig1-3.png)
![](/img/fig1-3.png)
**图 1-3 用于分发推特至关注者的数据流水线2012 年 11 月的负载参数【16】**
@ -192,7 +194,7 @@
在推特的例子中,每个用户粉丝数的分布(可能按这些用户的发推频率来加权)是探讨可伸缩性的一个关键负载参数,因为它决定了扇出负载。你的应用程序可能具有非常不同的特征,但可以采用相似的原则来考虑它的负载。
推特轶事的最终转折:现在已经稳健地实现了方法 2推特逐步转向了两种方法的混合。大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户即名流会被排除在外。当用户读取主页时间线时分别地获取出该用户所关注的每位名流的推文再与用户的主页时间线缓存合并如方法 1 所示。这种混合方法能始终如一地提供良好性能。在 [第十二章](ch12.md) 中我们将重新讨论这个例子,这在覆盖更多技术层面之后。
推特轶事的最终转折:现在已经稳健地实现了方法 2推特逐步转向了两种方法的混合。大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户即名流会被排除在外。当用户读取主页时间线时分别地获取出该用户所关注的每位名流的推文再与用户的主页时间线缓存合并如方法 1 所示。这种混合方法能始终如一地提供良好性能。在 [第十二章](/ch12) 中我们将重新讨论这个例子,这在覆盖更多技术层面之后。
### 描述性能
@ -213,9 +215,9 @@
即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值 **分布distribution**,而不是单个数值。
在 [图 1-4](img/fig1-4.png) 中,每个灰条代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如:上下文切换到后台进程,网络数据包丢失与 TCP 重传垃圾收集暂停强制从磁盘读取的页面错误服务器机架中的震动【18】还有很多其他原因。
在 [图 1-4](/img/fig1-4.png) 中,每个灰条代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如:上下文切换到后台进程,网络数据包丢失与 TCP 重传垃圾收集暂停强制从磁盘读取的页面错误服务器机架中的震动【18】还有很多其他原因。
![](img/fig1-4.png)
![](/img/fig1-4.png)
**图 1-4 展示了一个服务 100 次请求响应时间的均值与百分位数**
@ -225,7 +227,7 @@
如果想知道典型场景下用户需要等待多长时间,那么中位数是一个好的度量标准:一半用户请求的响应时间少于响应时间的中位数,另一半服务时间比中位数长。中位数也被称为第 50 百分位点,有时缩写为 p50。注意中位数是关于单个请求的如果用户同时发出几个请求在一个会话过程中或者由于一个页面中包含了多个资源则至少一个请求比中位数慢的概率远大于 50%。
为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第 95、99 和 99.9 百分位点(缩写为 p95p99 和 p999。它们意味着 95%、99% 或 99.9% 的请求响应时间要比该阈值快,例如:如果第 95 百分位点响应时间是 1.5 秒,则意味着 100 个请求中的 95 个响应时间快于 1.5 秒,而 100 个请求中的 5 个响应时间超过 1.5 秒。如 [图 1-4](img/fig1-4.png) 所示。
为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第 95、99 和 99.9 百分位点(缩写为 p95p99 和 p999。它们意味着 95%、99% 或 99.9% 的请求响应时间要比该阈值快,例如:如果第 95 百分位点响应时间是 1.5 秒,则意味着 100 个请求中的 95 个响应时间快于 1.5 秒,而 100 个请求中的 5 个响应时间超过 1.5 秒。如 [图 1-4](/img/fig1-4.png) 所示。
响应时间的高百分位点(也称为 **尾部延迟**,即 **tail latencies**)非常重要,因为它们直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时是以 99.9 百分位点为准,即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户,也可以说是最有价值的客户 —— 因为他们掏钱了【19】。保证网站响应迅速对于保持客户的满意度非常重要亚马逊观察到响应时间增加 100 毫秒,销售量就减少 1%【20】而另一些报告说慢 1 秒钟会让客户满意度指标减少 16%【2122】。
@ -239,13 +241,13 @@
> #### 实践中的百分位点
>
> 在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行调用完成。如 [图 1-5](img/fig1-5.png) 所示,只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(该效果称为尾部延迟放大,即 tail latency amplification【24】
> 在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行调用完成。如 [图 1-5](/img/fig1-5.png) 所示,只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(该效果称为尾部延迟放大,即 tail latency amplification【24】
>
> 如果你想将响应时间百分点添加到你的服务的监视仪表板则需要持续有效地计算它们。例如你可以使用滑动窗口来跟踪连续10分钟内的请求响应时间。每一分钟你都会计算出该窗口中的响应时间中值和各种百分数并将这些度量值绘制在图上。
>
> 简单的实现是在时间窗口内保存所有请求的响应时间列表,并且每分钟对列表进行排序。如果对你来说效率太低,那么有一些算法能够以最小的 CPU 和内存成本如前向衰减【25】、t-digest【26】或 HdrHistogram 【27】来计算百分位数的近似值。请注意平均百分比例如减少时间分辨率或合并来自多台机器的数据在数学上没有意义 - 聚合响应时间数据的正确方法是添加直方图【28】。
![](img/fig1-5.png)
![](/img/fig1-5.png)
**图 1-5 当一个请求需要多个后端请求时,单个后端慢请求就会拖慢整个终端用户的请求**
@ -257,7 +259,7 @@
人们经常讨论 **纵向伸缩**scaling up也称为垂直伸缩即 vertical scaling转向更强大的机器**横向伸缩**scaling out也称为水平伸缩即 horizontal scaling将负载分布到多台小机器上之间的对立。跨多台机器分配负载也称为 “**无共享shared-nothing**” 架构。可以在单台机器上运行的系统通常更简单,但高端机器可能非常贵,所以非常密集的负载通常无法避免地需要横向伸缩。现实世界中的优秀架构需要将这两种方法务实地结合,因为使用几台足够强大的机器可能比使用大量的小型虚拟机更简单也更便宜。
有些系统是 **弹性elastic** 的,这意味着可以在检测到负载增加时自动增加计算资源,而其他系统则是手动伸缩(人工分析容量并决定向系统添加更多的机器)。如果负载 **极难预测highly unpredictable**,则弹性系统可能很有用,但手动伸缩系统更简单,并且意外操作可能会更少(请参阅 “[分区再平衡](ch6.md#分区再平衡)”)。
有些系统是 **弹性elastic** 的,这意味着可以在检测到负载增加时自动增加计算资源,而其他系统则是手动伸缩(人工分析容量并决定向系统添加更多的机器)。如果负载 **极难预测highly unpredictable**,则弹性系统可能很有用,但手动伸缩系统更简单,并且意外操作可能会更少(请参阅 “[分区再平衡](/ch6#分区再平衡)”)。
跨多台机器部署 **无状态服务stateless services** 非常简单,但将带状态的数据系统从单节点变为分布式配置则可能引入许多额外复杂度。出于这个原因,常识告诉我们应该将数据库放在单个节点上(纵向伸缩),直到伸缩成本或可用性需求迫使其改为分布式。
@ -366,7 +368,7 @@
不幸的是,使应用可靠、可伸缩或可维护并不容易。但是某些模式和技术会不断重新出现在不同的应用中。在接下来的几章中,我们将看到一些数据系统的例子,并分析它们如何实现这些目标。
在本书后面的 [第三部分](part-iii.md) 中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(如 [图 1-1](img/fig1-1.png) 中的例子)
在本书后面的 [第三部分](/part-iii) 中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(如 [图 1-1](/img/fig1-1.png) 中的例子)
## 参考文献
@ -404,11 +406,4 @@
1. Frederick P Brooks: “No Silver Bullet Essence and Accident in Software Engineering,” in *The Mythical Man-Month*, Anniversary edition, Addison-Wesley, 1995. ISBN: 978-0-201-83595-3
1. Ben Moseley and Peter Marks: “[Out of the Tar Pit](https://curtclifton.net/papers/MoseleyMarks06a.pdf),” at *BCS Software Practice Advancement* (SPA), 2006.
1. Rich Hickey: “[Simple Made Easy](http://www.infoq.com/presentations/Simple-Made-Easy),” at *Strange Loop*, September 2011.
1. Hongyu Pei Breivold, Ivica Crnkovic, and Peter J. Eriksson: “[Analyzing Software Evolvability](http://www.es.mdh.se/pdf_publications/1251.pdf),” at *32nd Annual IEEE International Computer Software and Applications Conference* (COMPSAC), July 2008. [doi:10.1109/COMPSAC.2008.50](http://dx.doi.org/10.1109/COMPSAC.2008.50)
------
| 上一章 | 目录 | 下一章 |
| ----------------------------------- | ------------------------------- | ------------------------------------ |
| [第一部分:数据系统基础](part-i.md) | [设计数据密集型应用](README.md) | [第二章:数据模型与查询语言](ch2.md) |
1. Hongyu Pei Breivold, Ivica Crnkovic, and Peter J. Eriksson: “[Analyzing Software Evolvability](http://www.es.mdh.se/pdf_publications/1251.pdf),” at *32nd Annual IEEE International Computer Software and Applications Conference* (COMPSAC), July 2008. [doi:10.1109/COMPSAC.2008.50](http://dx.doi.org/10.1109/COMPSAC.2008.50)

View File

@ -1,18 +1,19 @@
# 第十章:批处理
---
title: "第十章:批处理"
linkTitle: "10. 批处理"
weight: 310
breadcrumbs: false
---
![](img/ch10.png)
![](/img/ch10.png)
> 带有太强个人色彩的系统无法成功。当最初的设计完成并且相对稳定时,不同的人们以自己的方式进行测试,真正的考验才开始。
>
> —— 高德纳
---------------
[TOC]
在本书的前两部分中,我们讨论了很多关于 **请求****查询** 以及相应的 **响应****结果**。许多现有数据系统中都采用这种数据处理方式你发送请求指令一段时间后我们期望系统会给出一个结果。数据库、缓存、搜索索引、Web 服务器以及其他一些系统都以这种方式工作。
像这样的 **在线online** 系统,无论是浏览器请求页面还是调用远程 API 的服务,我们通常认为请求是由人类用户触发的,并且正在等待响应。他们不应该等太久,所以我们非常关注系统的响应时间(请参阅 “[描述性能](ch1.md#描述性能)”)。
像这样的 **在线online** 系统,无论是浏览器请求页面还是调用远程 API 的服务,我们通常认为请求是由人类用户触发的,并且正在等待响应。他们不应该等太久,所以我们非常关注系统的响应时间(请参阅 “[描述性能](/ch1#描述性能)”)。
Web 和越来越多的基于 HTTP/REST 的 API 使交互的请求 / 响应风格变得如此普遍,以至于很容易将其视为理所当然。但我们应该记住,这不是构建系统的唯一方式,其他方法也有其优点。我们来看看三种不同类型的系统:
@ -26,7 +27,7 @@ Web 和越来越多的基于 HTTP/REST 的 API 使交互的请求 / 响应风格
* 流处理系统(准实时系统)
流处理介于在线和离线(批处理)之间,所以有时候被称为 **准实时near-real-time****准在线nearline** 处理。像批处理系统一样,流处理消费输入并产生输出(并不需要响应请求)。但是,流式作业在事件发生后不久就会对事件进行操作,而批处理作业则需等待固定的一组输入数据。这种差异使流处理系统比起批处理系统具有更低的延迟。由于流处理基于批处理,我们将在 [第十一章](ch11.md) 讨论它。
流处理介于在线和离线(批处理)之间,所以有时候被称为 **准实时near-real-time****准在线nearline** 处理。像批处理系统一样,流处理消费输入并产生输出(并不需要响应请求)。但是,流式作业在事件发生后不久就会对事件进行操作,而批处理作业则需等待固定的一组输入数据。这种差异使流处理系统比起批处理系统具有更低的延迟。由于流处理基于批处理,我们将在 [第十一章](/ch11) 讨论它。
正如我们将在本章中看到的那样批处理是构建可靠、可伸缩和可维护应用程序的重要组成部分。例如2004 年发布的批处理算法 Map-Reduce可能被过分热情地被称为 “造就 Google 大规模可伸缩性的算法”【2】。随后在各种开源数据系统中得到应用包括 Hadoop、CouchDB 和 MongoDB。
@ -122,7 +123,7 @@ Ruby 脚本在内存中保存了一个 URL 的哈希表,将每个 URL 映射
哪种方法更好?这取决于你有多少个不同的 URL。对于大多数中小型网站你可能可以为所有不同网址提供一个计数器假设我们使用 1GB 内存)。在此例中,作业的 **工作集**working set即作业需要随机访问的内存大小仅取决于不同 URL 的数量:如果日志中只有单个 URL重复出现一百万次则散列表所需的空间表就只有一个 URL 加上一个计数器的大小。当工作集足够小时,内存散列表表现良好,甚至在性能较差的笔记本电脑上也可以正常工作。
另一方面,如果作业的工作集大于可用内存,则排序方法的优点是可以高效地使用磁盘。这与我们在 “[SSTables 和 LSM 树](ch3.md#SSTables和LSM树)” 中讨论过的原理是一样的:数据块可以在内存中排序并作为段文件写入磁盘,然后多个排序好的段可以合并为一个更大的排序文件。归并排序具有在磁盘上运行良好的顺序访问模式。(请记住,针对顺序 I/O 进行优化是 [第三章](ch3.md) 中反复出现的主题,相同的模式在此重现)
另一方面,如果作业的工作集大于可用内存,则排序方法的优点是可以高效地使用磁盘。这与我们在 “[SSTables 和 LSM 树](/ch3#SSTables和LSM树)” 中讨论过的原理是一样的:数据块可以在内存中排序并作为段文件写入磁盘,然后多个排序好的段可以合并为一个更大的排序文件。归并排序具有在磁盘上运行良好的顺序访问模式。(请记住,针对顺序 I/O 进行优化是 [第三章](/ch3) 中反复出现的主题,相同的模式在此重现)
GNU CoreutilsLinux中的 `sort` 程序通过溢出至磁盘的方式来自动应对大于内存的数据集,并能同时使用多个 CPU 核进行并行排序【9】。这意味着我们之前看到的简单的 Unix 命令链很容易伸缩至大数据集,且不会耗尽内存。瓶颈可能是从磁盘读取输入文件的速度。
@ -203,11 +204,11 @@ MapReduce 有点像 Unix 工具,但分布在数千台机器上。像 Unix 工
[^iv]: 一个不同之处在于,对于 HDFS可以将计算任务安排在存储特定文件副本的计算机上运行而对象存储通常将存储和计算分开。如果网络带宽是一个瓶颈从本地磁盘读取有性能优势。但是请注意如果使用纠删码Erasure Coding则会丢失局部性因为来自多台机器的数据必须进行合并以重建原始文件【20】。
与网络连接存储NAS和存储区域网络SAN架构的共享磁盘方法相比HDFS 基于 **无共享** 原则(请参阅 [第二部分](part-ii.md) 的介绍)。共享磁盘存储由集中式存储设备实现,通常使用定制硬件和专用网络基础设施(如光纤通道)。而另一方面,无共享方法不需要特殊的硬件,只需要通过传统数据中心网络连接的计算机。
与网络连接存储NAS和存储区域网络SAN架构的共享磁盘方法相比HDFS 基于 **无共享** 原则(请参阅 [第二部分](/part-ii) 的介绍)。共享磁盘存储由集中式存储设备实现,通常使用定制硬件和专用网络基础设施(如光纤通道)。而另一方面,无共享方法不需要特殊的硬件,只需要通过传统数据中心网络连接的计算机。
HDFS 在每台机器上运行了一个守护进程,它对外暴露网络服务,允许其他节点访问存储在该机器上的文件(假设数据中心中的每台通用计算机都挂载着一些磁盘)。名为 **NameNode** 的中央服务器会跟踪哪个文件块存储在哪台机器上。因此HDFS 在概念上创建了一个大型文件系统,可以使用所有运行有守护进程的机器的磁盘。
为了容忍机器和磁盘故障,文件块被复制到多台机器上。复制可能意味着多个机器上的相同数据的多个副本,如 [第五章](ch5.md) 中所述,或者诸如 Reed-Solomon 码这样的纠删码方案它能以比完全复制更低的存储开销来支持恢复丢失的数据【20,22】。这些技术与 RAID 相似,后者可以在连接到同一台机器的多个磁盘上提供冗余;区别在于在分布式文件系统中,文件访问和复制是在传统的数据中心网络上完成的,没有特殊的硬件。
为了容忍机器和磁盘故障,文件块被复制到多台机器上。复制可能意味着多个机器上的相同数据的多个副本,如 [第五章](/ch5) 中所述,或者诸如 Reed-Solomon 码这样的纠删码方案它能以比完全复制更低的存储开销来支持恢复丢失的数据【20,22】。这些技术与 RAID 相似,后者可以在连接到同一台机器的多个磁盘上提供冗余;区别在于在分布式文件系统中,文件访问和复制是在传统的数据中心网络上完成的,没有特殊的硬件。
HDFS 的可伸缩性已经很不错了:在撰写本书时,最大的 HDFS 部署运行在上万台机器上,总存储容量达数百 PB【23】。如此大的规模已经变得可行因为使用商品硬件和开源软件的 HDFS 上的数据存储和访问成本远低于在专用存储设备上支持同等容量的成本【24】。
@ -222,7 +223,7 @@ MapReduce 是一个编程框架,你可以使用它编写代码来处理 HDFS
这四个步骤可以作为一个 MapReduce 作业执行。步骤 2Map和 4Reduce是你编写自定义数据处理代码的地方。步骤 1将文件分解成记录由输入格式解析器处理。步骤 3 中的排序步骤隐含在 MapReduce 中 —— 你不必编写它,因为 Mapper 的输出始终在送往 Reducer 之前进行排序。
要创建 MapReduce 作业你需要实现两个回调函数Mapper 和 Reducer其行为如下请参阅 “[MapReduce 查询](ch2.md#MapReduce查询)”):
要创建 MapReduce 作业你需要实现两个回调函数Mapper 和 Reducer其行为如下请参阅 “[MapReduce 查询](/ch2#MapReduce查询)”):
* Mapper
@ -238,21 +239,21 @@ MapReduce 是一个编程框架,你可以使用它编写代码来处理 HDFS
MapReduce 与 Unix 命令管道的主要区别在于MapReduce 可以在多台机器上并行执行计算而无需编写代码来显式处理并行问题。Mapper 和 Reducer 一次只能处理一条记录;它们不需要知道它们的输入来自哪里,或者输出去往什么地方,所以框架可以处理在机器之间移动数据的复杂性。
在分布式计算中可以使用标准的 Unix 工具作为 Mapper 和 Reducer【25】但更常见的是它们被实现为传统编程语言的函数。在 Hadoop MapReduce 中Mapper 和 Reducer 都是实现特定接口的 Java 类。在 MongoDB 和 CouchDB 中Mapper 和 Reducer 都是 JavaScript 函数(请参阅 “[MapReduce 查询](ch2.md#MapReduce查询)”)。
在分布式计算中可以使用标准的 Unix 工具作为 Mapper 和 Reducer【25】但更常见的是它们被实现为传统编程语言的函数。在 Hadoop MapReduce 中Mapper 和 Reducer 都是实现特定接口的 Java 类。在 MongoDB 和 CouchDB 中Mapper 和 Reducer 都是 JavaScript 函数(请参阅 “[MapReduce 查询](/ch2#MapReduce查询)”)。
[图 10-1](img/fig10-1.png) 显示了 Hadoop MapReduce 作业中的数据流。其并行化基于分区(请参阅 [第六章](ch6.md)):作业的输入通常是 HDFS 中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理 map 任务([图 10-1](img/fig10-1.png) 中的 m1m2 和 m3 标记)。
[图 10-1](/img/fig10-1.png) 显示了 Hadoop MapReduce 作业中的数据流。其并行化基于分区(请参阅 [第六章](/ch6)):作业的输入通常是 HDFS 中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理 map 任务([图 10-1](/img/fig10-1.png) 中的 m1m2 和 m3 标记)。
每个输入文件的大小通常是数百兆字节。MapReduce 调度器(图中未显示)试图在其中一台存储输入文件副本的机器上运行每个 Mapper只要该机器有足够的备用 RAM 和 CPU 资源来运行 Mapper 任务【26】。这个原则被称为 **将计算放在数据附近**【27】它节省了通过网络复制输入文件的开销减少网络负载并增加局部性。
![](img/fig10-1.png)
![](/img/fig10-1.png)
**图 10-1 具有三个 Mapper 和三个 Reducer 的 MapReduce 任务**
在大多数情况下,应该在 Mapper 任务中运行的应用代码在将要运行它的机器上还不存在,所以 MapReduce 框架首先将代码(例如 Java 程序中的 JAR 文件)复制到适当的机器。然后启动 Map 任务并开始读取输入文件,一次将一条记录传入 Mapper 回调函数。Mapper 的输出由键值对组成。
计算的 Reduce 端也被分区。虽然 Map 任务的数量由输入文件块的数量决定,但 Reducer 的任务的数量是由作业作者配置的(它可以不同于 Map 任务的数量)。为了确保具有相同键的所有键值对最终落在相同的 Reducer 处,框架使用键的散列值来确定哪个 Reduce 任务应该接收到特定的键值对(请参阅 “[根据键的散列分区](ch6.md#根据键的散列分区)”)。
计算的 Reduce 端也被分区。虽然 Map 任务的数量由输入文件块的数量决定,但 Reducer 的任务的数量是由作业作者配置的(它可以不同于 Map 任务的数量)。为了确保具有相同键的所有键值对最终落在相同的 Reducer 处,框架使用键的散列值来确定哪个 Reduce 任务应该接收到特定的键值对(请参阅 “[根据键的散列分区](/ch6#根据键的散列分区)”)。
键值对必须进行排序,但数据集可能太大,无法在单台机器上使用常规排序算法进行排序。相反,分类是分阶段进行的。首先每个 Map 任务都按照 Reducer 对输出进行分区。每个分区都被写入 Mapper 程序的本地磁盘,使用的技术与我们在 “[SSTables 与 LSM 树](ch3.md#SSTables和LSM树)” 中讨论的类似。
键值对必须进行排序,但数据集可能太大,无法在单台机器上使用常规排序算法进行排序。相反,分类是分阶段进行的。首先每个 Map 任务都按照 Reducer 对输出进行分区。每个分区都被写入 Mapper 程序的本地磁盘,使用的技术与我们在 “[SSTables 与 LSM 树](/ch3#SSTables和LSM树)” 中讨论的类似。
只要当 Mapper 读取完输入文件并写完排序后的输出文件MapReduce 调度器就会通知 Reducer 可以从该 Mapper 开始获取输出文件。Reducer 连接到每个 Mapper并下载自己相应分区的有序键值对文件。按 Reducer 分区,排序,从 Mapper 向 Reducer 复制分区数据,这一整个过程被称为 **混洗shuffle**【26】一个容易混淆的术语 —— 不像洗牌,在 MapReduce 中的混洗没有随机性)。
@ -276,23 +277,23 @@ Hadoop 的各种高级工具(如 Pig 【30】、Hive 【31】、Cascading 【3
### Reduce侧连接与分组
我们在 [第二章](ch2.md) 中讨论了数据模型和查询语言的连接,但是我们还没有深入探讨连接是如何实现的。现在是我们再次捡起这条线索的时候了。
我们在 [第二章](/ch2) 中讨论了数据模型和查询语言的连接,但是我们还没有深入探讨连接是如何实现的。现在是我们再次捡起这条线索的时候了。
在许多数据集中,一条记录与另一条记录存在关联是很常见的:关系模型中的 **外键**,文档模型中的 **文档引用** 或图模型中的 **边**。当你需要同时访问这一关联的两侧(持有引用的记录与被引用的记录)时,连接就是必须的。正如 [第二章](ch2.md) 所讨论的,非规范化可以减少对连接的需求,但通常无法将其完全移除 [^v]。
在许多数据集中,一条记录与另一条记录存在关联是很常见的:关系模型中的 **外键**,文档模型中的 **文档引用** 或图模型中的 **边**。当你需要同时访问这一关联的两侧(持有引用的记录与被引用的记录)时,连接就是必须的。正如 [第二章](/ch2) 所讨论的,非规范化可以减少对连接的需求,但通常无法将其完全移除 [^v]。
[^v]: 我们在本书中讨论的连接通常是等值连接,即最常见的连接类型,其中记录通过与其他记录在特定字段(例如 ID中具有 **相同值** 相关联。有些数据库支持更通用的连接类型,例如使用小于运算符而不是等号运算符,但是我们没有地方来讲这些东西。
在数据库中,如果执行只涉及少量记录的查询,数据库通常会使用 **索引** 来快速定位感兴趣的记录(请参阅 [第三章](ch3.md))。如果查询涉及到连接,则可能涉及到查找多个索引。然而 MapReduce 没有索引的概念 —— 至少在通常意义上没有。
在数据库中,如果执行只涉及少量记录的查询,数据库通常会使用 **索引** 来快速定位感兴趣的记录(请参阅 [第三章](/ch3))。如果查询涉及到连接,则可能涉及到查找多个索引。然而 MapReduce 没有索引的概念 —— 至少在通常意义上没有。
当 MapReduce 作业被赋予一组文件作为输入时,它读取所有这些文件的全部内容;数据库会将这种操作称为 **全表扫描**。如果你只想读取少量的记录,则全表扫描与索引查询相比,代价非常高昂。但是在分析查询中(请参阅 “[事务处理还是分析?](ch3.md#事务处理还是分析?)”),通常需要计算大量记录的聚合。在这种情况下,特别是如果能在多台机器上并行处理时,扫描整个输入可能是相当合理的事情。
当 MapReduce 作业被赋予一组文件作为输入时,它读取所有这些文件的全部内容;数据库会将这种操作称为 **全表扫描**。如果你只想读取少量的记录,则全表扫描与索引查询相比,代价非常高昂。但是在分析查询中(请参阅 “[事务处理还是分析?](/ch3#事务处理还是分析?)”),通常需要计算大量记录的聚合。在这种情况下,特别是如果能在多台机器上并行处理时,扫描整个输入可能是相当合理的事情。
当我们在批处理的语境中讨论连接时,我们指的是在数据集中解析某种关联的全量存在。例如我们假设一个作业是同时处理所有用户的数据,而非仅仅是为某个特定用户查找数据(而这能通过索引更高效地完成)。
#### 示例:用户活动事件分析
[图 10-2](img/fig10-2.png) 给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为 **活动事件**,即 activity events**点击流数据**,即 clickstream data右侧是用户数据库。你可以将此示例看作是星型模式的一部分请参阅 “[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。
[图 10-2](/img/fig10-2.png) 给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为 **活动事件**,即 activity events**点击流数据**,即 clickstream data右侧是用户数据库。你可以将此示例看作是星型模式的一部分请参阅 “[星型和雪花型:分析的模式](/ch3#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。
![](img/fig10-2.png)
![](/img/fig10-2.png)
**图 10-2 用户行为日志与用户档案的连接**
@ -302,13 +303,13 @@ Hadoop 的各种高级工具(如 Pig 【30】、Hive 【31】、Cascading 【3
为了在批处理过程中实现良好的吞吐量,计算必须(尽可能)限于单台机器上进行。为待处理的每条记录发起随机访问的网络请求实在是太慢了。而且,查询远程数据库意味着批处理作业变为 **非确定的nondeterministic**,因为远程数据库中的数据可能会改变。
因此,更好的方法是获取用户数据库的副本(例如,使用 ETL 进程从数据库备份中提取数据,请参阅 “[数据仓库](ch3.md#数据仓库)”),并将它和用户行为日志放入同一个分布式文件系统中。然后你可以将用户数据库存储在 HDFS 中的一组文件中,而用户活动记录存储在另一组文件中,并能用 MapReduce 将所有相关记录集中到同一个地方进行高效处理。
因此,更好的方法是获取用户数据库的副本(例如,使用 ETL 进程从数据库备份中提取数据,请参阅 “[数据仓库](/ch3#数据仓库)”),并将它和用户行为日志放入同一个分布式文件系统中。然后你可以将用户数据库存储在 HDFS 中的一组文件中,而用户活动记录存储在另一组文件中,并能用 MapReduce 将所有相关记录集中到同一个地方进行高效处理。
#### 排序合并连接
回想一下Mapper 的目的是从每个输入记录中提取一对键值。在 [图 10-2](img/fig10-2.png) 的情况下,这个键就是用户 ID一组 Mapper 会扫过活动事件(提取用户 ID 作为键,活动事件作为值),而另一组 Mapper 将会扫过用户数据库(提取用户 ID 作为键,用户的出生日期作为值)。这个过程如 [图 10-3](img/fig10-3.png) 所示。
回想一下Mapper 的目的是从每个输入记录中提取一对键值。在 [图 10-2](/img/fig10-2.png) 的情况下,这个键就是用户 ID一组 Mapper 会扫过活动事件(提取用户 ID 作为键,活动事件作为值),而另一组 Mapper 将会扫过用户数据库(提取用户 ID 作为键,用户的出生日期作为值)。这个过程如 [图 10-3](/img/fig10-3.png) 所示。
![](img/fig10-3.png)
![](/img/fig10-3.png)
**图 10-3 在用户 ID 上进行的 Reduce 端连接。如果输入数据集分区为多个文件,则每个分区都会被多个 Mapper 并行处理**
@ -344,11 +345,11 @@ Hadoop 的各种高级工具(如 Pig 【30】、Hive 【31】、Cascading 【3
如果存在与单个键关联的大量数据,则 “将具有相同键的所有记录放到相同的位置” 这种模式就被破坏了。例如在社交网络中,大多数用户可能会与几百人有连接,但少数名人可能有数百万的追随者。这种不成比例的活动数据库记录被称为 **关键对象linchpin object**【38】或 **热键hot key**
在单个 Reducer 中收集与某个名人相关的所有活动(例如他们发布内容的回复)可能导致严重的 **偏斜**(也称为 **热点**,即 hot spot—— 也就是说,一个 Reducer 必须比其他 Reducer 处理更多的记录(请参阅 “[负载偏斜与热点消除](ch6.md#负载偏斜与热点消除)”)。由于 MapReduce 作业只有在所有 Mapper 和 Reducer 都完成时才完成,所有后续作业必须等待最慢的 Reducer 才能启动。
在单个 Reducer 中收集与某个名人相关的所有活动(例如他们发布内容的回复)可能导致严重的 **偏斜**(也称为 **热点**,即 hot spot—— 也就是说,一个 Reducer 必须比其他 Reducer 处理更多的记录(请参阅 “[负载偏斜与热点消除](/ch6#负载偏斜与热点消除)”)。由于 MapReduce 作业只有在所有 Mapper 和 Reducer 都完成时才完成,所有后续作业必须等待最慢的 Reducer 才能启动。
如果连接的输入存在热键可以使用一些算法进行补偿。例如Pig 中的 **偏斜连接skewed join** 方法首先运行一个抽样作业Sampling Job来确定哪些键是热键【39】。连接实际执行时Mapper 会将热键的关联记录 **随机**(相对于传统 MapReduce 基于键散列的确定性方法)发送到几个 Reducer 之一。对于另外一侧的连接输入,与热键相关的记录需要被复制到 **所有** 处理该键的 Reducer 上【40】。
这种技术将处理热键的工作分散到多个 Reducer 上,这样可以使其更好地并行化,代价是需要将连接另一侧的输入记录复制到多个 Reducer 上。Crunch 中的 **分片连接sharded join** 方法与之类似,但需要显式指定热键而不是使用抽样作业。这种技术也非常类似于我们在 “[负载偏斜与热点消除](ch6.md#负载偏斜与热点消除)” 中讨论的技术,使用随机化来缓解分区数据库中的热点。
这种技术将处理热键的工作分散到多个 Reducer 上,这样可以使其更好地并行化,代价是需要将连接另一侧的输入记录复制到多个 Reducer 上。Crunch 中的 **分片连接sharded join** 方法与之类似,但需要显式指定热键而不是使用抽样作业。这种技术也非常类似于我们在 “[负载偏斜与热点消除](/ch6#负载偏斜与热点消除)” 中讨论的技术,使用随机化来缓解分区数据库中的热点。
Hive 的偏斜连接优化采取了另一种方法。它需要在表格元数据中显式指定热键,并将与这些键相关的记录单独存放,与其它文件分开。当在该表上执行连接时,对于热键,它会使用 Map 端连接(请参阅下一节)。
@ -367,11 +368,11 @@ Reduce 侧方法的优点是不需要对输入数据做任何假设:无论其
适用于执行 Map 端连接的最简单场景是大数据集与小数据集连接的情况。要点在于小数据集需要足够小,以便可以将其全部加载到每个 Mapper 的内存中。
例如,假设在 [图 10-2](img/fig10-2.png) 的情况下,用户数据库小到足以放进内存中。在这种情况下,当 Mapper 启动时它可以首先将用户数据库从分布式文件系统读取到内存中的散列表中。完成此操作后Mapper 可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户 ID [^vi]。
例如,假设在 [图 10-2](/img/fig10-2.png) 的情况下,用户数据库小到足以放进内存中。在这种情况下,当 Mapper 启动时它可以首先将用户数据库从分布式文件系统读取到内存中的散列表中。完成此操作后Mapper 可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户 ID [^vi]。
[^vi]: 这个例子假定散列表中的每个键只有一个条目,这对用户数据库(用户 ID 唯一标识一个用户)可能是正确的。通常,哈希表可能需要包含具有相同键的多个条目,而连接运算符将对每个键输出所有的匹配。
参与连接的较大输入的每个文件块各有一个 Mapper在 [图 10-2](img/fig10-2.png) 的例子中活动事件是较大的输入)。每个 Mapper 都会将较小输入整个加载到内存中。
参与连接的较大输入的每个文件块各有一个 Mapper在 [图 10-2](/img/fig10-2.png) 的例子中活动事件是较大的输入)。每个 Mapper 都会将较小输入整个加载到内存中。
这种简单有效的算法被称为 **广播散列连接broadcast hash join****广播** 一词反映了这样一个事实,每个连接较大输入端分区的 Mapper 都会将较小输入端数据集整个读入内存中(所以较小输入实际上 “广播” 到较大数据的所有分区上),**散列** 一词反映了它使用一个散列表。Pig名为 “**复制链接replicated join**”Hive“**MapJoin**”Cascading 和 Crunch 支持这种连接。它也被诸如 Impala 的数据仓库查询引擎使用【41】。
@ -379,7 +380,7 @@ Reduce 侧方法的优点是不需要对输入数据做任何假设:无论其
#### 分区散列连接
如果 Map 侧连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在 [图 10-2](img/fig10-2.png) 的情况中,你可以根据用户 ID 的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有 10 个分区。例如Mapper3 首先将所有具有以 3 结尾的 ID 的用户加载到散列表中,然后扫描 ID 为 3 的每个用户的所有活动事件。
如果 Map 侧连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在 [图 10-2](/img/fig10-2.png) 的情况中,你可以根据用户 ID 的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有 10 个分区。例如Mapper3 首先将所有具有以 3 结尾的 ID 的用户加载到散列表中,然后扫描 ID 为 3 的每个用户的所有活动事件。
如果分区正确无误,可以确定的是,所有你可能需要连接的记录都落在同一个编号的分区中。因此每个 Mapper 只需要从输入两端各读取一个分区就足够了。好处是每个 Mapper 都可以在内存散列表中少放点数据。
@ -406,7 +407,7 @@ Reduce 侧方法的优点是不需要对输入数据做任何假设:无论其
我们已经说了很多用于实现 MapReduce 工作流的算法,但却忽略了一个重要的问题:这些处理完成之后的最终结果是什么?我们最开始为什么要跑这些作业?
在数据库查询的场景中我们将事务处理OLTP与分析两种目的区分开来请参阅 “[事务处理还是分析?](ch3.md#事务处理还是分析?)”。我们看到OLTP 查询通常根据键查找少量记录,使用索引,并将其呈现给用户(比如在网页上)。另一方面,分析查询通常会扫描大量记录,执行分组与聚合,输出通常有着报告的形式:显示某个指标随时间变化的图表,或按照某种排位取前 10 项,或将一些数字细化为子类。这种报告的消费者通常是需要做出商业决策的分析师或经理。
在数据库查询的场景中我们将事务处理OLTP与分析两种目的区分开来请参阅 “[事务处理还是分析?](/ch3#事务处理还是分析?)”。我们看到OLTP 查询通常根据键查找少量记录,使用索引,并将其呈现给用户(比如在网页上)。另一方面,分析查询通常会扫描大量记录,执行分组与聚合,输出通常有着报告的形式:显示某个指标随时间变化的图表,或按照某种排位取前 10 项,或将一些数字细化为子类。这种报告的消费者通常是需要做出商业决策的分析师或经理。
批处理放哪里合适?它不属于事务处理,也不是分析。它和分析比较接近,因为批处理通常会扫过输入数据集的绝大部分。然而 MapReduce 作业工作流与用于分析目的的 SQL 查询是不同的(请参阅 “[Hadoop 与分布式数据库的对比](#Hadoop与分布式数据库的对比)”)。批处理过程的输出通常不是报表,而是一些其他类型的结构。
@ -414,15 +415,15 @@ Reduce 侧方法的优点是不需要对输入数据做任何假设:无论其
Google 最初使用 MapReduce 是为其搜索引擎建立索引,其实现为由 5 到 10 个 MapReduce 作业组成的工作流【1】。虽然 Google 后来也不仅仅是为这个目的而使用 MapReduce 【43】但如果从构建搜索索引的角度来看更能帮助理解 MapReduce。直至今日Hadoop MapReduce 仍然是为 Lucene/Solr 构建索引的好方法【44】
我们在 “[全文搜索和模糊索引](ch3.md#全文搜索和模糊索引)” 中简要地了解了 Lucene 这样的全文搜索索引是如何工作的:它是一个文件(关键词字典),你可以在其中高效地查找特定关键字,并找到包含该关键字的所有文档 ID 列表(文章列表)。这是一种非常简化的看法 —— 实际上,搜索索引需要各种额外数据,以便根据相关性对搜索结果进行排名、纠正拼写错误、解析同义词等等 —— 但这个原则是成立的。
我们在 “[全文搜索和模糊索引](/ch3#全文搜索和模糊索引)” 中简要地了解了 Lucene 这样的全文搜索索引是如何工作的:它是一个文件(关键词字典),你可以在其中高效地查找特定关键字,并找到包含该关键字的所有文档 ID 列表(文章列表)。这是一种非常简化的看法 —— 实际上,搜索索引需要各种额外数据,以便根据相关性对搜索结果进行排名、纠正拼写错误、解析同义词等等 —— 但这个原则是成立的。
如果需要对一组固定文档执行全文搜索则批处理是一种构建索引的高效方法Mapper 根据需要对文档集合进行分区,每个 Reducer 构建该分区的索引,并将索引文件写入分布式文件系统。构建这样的文档分区索引(请参阅 “[分区与次级索引](ch6.md#分区与次级索引)”)并行处理效果拔群。
如果需要对一组固定文档执行全文搜索则批处理是一种构建索引的高效方法Mapper 根据需要对文档集合进行分区,每个 Reducer 构建该分区的索引,并将索引文件写入分布式文件系统。构建这样的文档分区索引(请参阅 “[分区与次级索引](/ch6#分区与次级索引)”)并行处理效果拔群。
由于按关键字查询搜索索引是只读操作,因而这些索引文件一旦创建就是不可变的。
如果索引的文档集合发生更改,一种选择是定期重跑整个索引工作流,并在完成后用新的索引文件批量替换以前的索引文件。如果只有少量的文档发生了变化,这种方法的计算成本可能会很高。但它的优点是索引过程很容易理解:文档进,索引出。
另一个选择是,可以增量建立索引。如 [第三章](ch3.md) 中讨论的如果要在索引中添加删除或更新文档Lucene 会写新的段文件,并在后台异步合并压缩段文件。我们将在 [第十一章](ch11.md) 中看到更多这种增量处理。
另一个选择是,可以增量建立索引。如 [第三章](/ch3) 中讨论的如果要在索引中添加删除或更新文档Lucene 会写新的段文件,并在后台异步合并压缩段文件。我们将在 [第十一章](/ch11) 中看到更多这种增量处理。
#### 键值存储作为批处理输出
@ -440,7 +441,7 @@ Google 最初使用 MapReduce 是为其搜索引擎建立索引,其实现为
更好的解决方案是在批处理作业 **内** 创建一个全新的数据库,并将其作为文件写入分布式文件系统中作业的输出目录,就像上节中的搜索索引一样。这些数据文件一旦写入就是不可变的,可以批量加载到处理只读查询的服务器中。不少键值存储都支持在 MapReduce 作业中构建数据库文件,包括 Voldemort 【46】、Terrapin 【47】、ElephantDB 【48】和 HBase 批量加载【49】。
构建这些数据库文件是 MapReduce 的一种好用法:使用 Mapper 提取出键并按该键排序已经完成了构建索引所必需的大量工作。由于这些键值存储大多都是只读的文件只能由批处理作业一次性写入然后就不可变所以数据结构非常简单。比如它们就不需要预写式日志WAL请参阅 “[让 B 树更可靠](ch3.md#让B树更可靠)”)。
构建这些数据库文件是 MapReduce 的一种好用法:使用 Mapper 提取出键并按该键排序已经完成了构建索引所必需的大量工作。由于这些键值存储大多都是只读的文件只能由批处理作业一次性写入然后就不可变所以数据结构非常简单。比如它们就不需要预写式日志WAL请参阅 “[让 B 树更可靠](/ch3#让B树更可靠)”)。
将数据加载到 Voldemort 时服务器将继续用旧数据文件服务请求同时将新数据文件从分布式文件系统复制到服务器的本地磁盘。一旦复制完成服务器会自动将查询切换到新文件。如果在这个过程中出现任何问题它可以轻易回滚至旧文件因为它们仍然存在而且不可变【46】。
@ -456,7 +457,7 @@ MapReduce 作业的输出处理遵循同样的原理。通过将输入视为不
- 同一组文件可用作各种不同作业的输入,包括计算指标的监控作业并且评估作业的输出是否具有预期的性质(例如,将其与前一次运行的输出进行比较并测量差异) 。
- 与 Unix 工具类似MapReduce 作业将逻辑与布线(配置输入和输出目录)分离,这使得关注点分离,可以重用代码:一个团队可以专注实现一个做好一件事的作业;而其他团队可以决定何时何地运行这项作业。
在这些领域,在 Unix 上表现良好的设计原则似乎也适用于 Hadoop但 Unix 和 Hadoop 在某些方面也有所不同。例如,因为大多数 Unix 工具都假设输入输出是无类型文本文件,所以它们必须做大量的输入解析工作(本章开头的日志分析示例使用 `{print $7}` 来提取 URL。在 Hadoop 上可以通过使用更结构化的文件格式消除一些低价值的语法转换:比如 Avro请参阅 “[Avro](ch4.md#Avro)”)和 Parquet请参阅 “[列式存储](ch3.md#列式存储)”)经常使用,因为它们提供了基于模式的高效编码,并允许模式随时间推移而演进(见 [第四章](ch4.md))。
在这些领域,在 Unix 上表现良好的设计原则似乎也适用于 Hadoop但 Unix 和 Hadoop 在某些方面也有所不同。例如,因为大多数 Unix 工具都假设输入输出是无类型文本文件,所以它们必须做大量的输入解析工作(本章开头的日志分析示例使用 `{print $7}` 来提取 URL。在 Hadoop 上可以通过使用更结构化的文件格式消除一些低价值的语法转换:比如 Avro请参阅 “[Avro](/ch4#Avro)”)和 Parquet请参阅 “[列式存储](/ch3#列式存储)”)经常使用,因为它们提供了基于模式的高效编码,并允许模式随时间推移而演进(见 [第四章](/ch4))。
### Hadoop与分布式数据库的对比
@ -474,11 +475,11 @@ MapReduce 作业的输出处理遵循同样的原理。通过将输入视为不
在纯粹主义者看来,这种仔细的建模和导入似乎是可取的,因为这意味着数据库的用户有更高质量的数据来处理。然而实践经验表明,简单地使数据快速可用 —— 即使它很古怪,难以使用,使用原始格式 —— 也通常要比事先决定理想数据模型要更有价值【54】。
这个想法与数据仓库类似(请参阅 “[数据仓库](ch3.md#数据仓库)”将大型组织的各个部分的数据集中在一起是很有价值的因为它可以跨越以前相互分离的数据集进行连接。MPP 数据库所要求的谨慎模式设计拖慢了集中式数据收集速度;以原始形式收集数据,稍后再操心模式的设计,能使数据收集速度加快(有时被称为 “**数据湖data lake**” 或 “**企业数据中心enterprise data hub**”【55】
这个想法与数据仓库类似(请参阅 “[数据仓库](/ch3#数据仓库)”将大型组织的各个部分的数据集中在一起是很有价值的因为它可以跨越以前相互分离的数据集进行连接。MPP 数据库所要求的谨慎模式设计拖慢了集中式数据收集速度;以原始形式收集数据,稍后再操心模式的设计,能使数据收集速度加快(有时被称为 “**数据湖data lake**” 或 “**企业数据中心enterprise data hub**”【55】
不加区分的数据转储转移了解释数据的负担:数据集的生产者不再需要强制将其转化为标准格式,数据的解释成为消费者的问题(**读时模式** 方法【56】请参阅 “[文档模型中的模式灵活性](ch2.md#文档模型中的模式灵活性)”)。如果生产者和消费者是不同优先级的不同团队,这可能是一种优势。甚至可能不存在一个理想的数据模型,对于不同目的有不同的合适视角。以原始形式简单地转储数据,可以允许多种这样的转换。这种方法被称为 **寿司原则sushi principle**“原始数据更好”【57】。
不加区分的数据转储转移了解释数据的负担:数据集的生产者不再需要强制将其转化为标准格式,数据的解释成为消费者的问题(**读时模式** 方法【56】请参阅 “[文档模型中的模式灵活性](/ch2#文档模型中的模式灵活性)”)。如果生产者和消费者是不同优先级的不同团队,这可能是一种优势。甚至可能不存在一个理想的数据模型,对于不同目的有不同的合适视角。以原始形式简单地转储数据,可以允许多种这样的转换。这种方法被称为 **寿司原则sushi principle**“原始数据更好”【57】。
因此Hadoop 经常被用于实现 ETL 过程(请参阅 “[数据仓库](ch3.md#数据仓库)”):事务处理系统中的数据以某种原始形式转储到分布式文件系统中,然后编写 MapReduce 作业来清理数据,将其转换为关系形式,并将其导入 MPP 数据仓库以进行分析。数据建模仍然在进行,但它在一个单独的步骤中进行,与数据收集相解耦。这种解耦是可行的,因为分布式文件系统支持以任何格式编码的数据。
因此Hadoop 经常被用于实现 ETL 过程(请参阅 “[数据仓库](/ch3#数据仓库)”):事务处理系统中的数据以某种原始形式转储到分布式文件系统中,然后编写 MapReduce 作业来清理数据,将其转换为关系形式,并将其导入 MPP 数据仓库以进行分析。数据建模仍然在进行,但它在一个单独的步骤中进行,与数据收集相解耦。这种解耦是可行的,因为分布式文件系统支持以任何格式编码的数据。
#### 处理模型的多样性
@ -492,7 +493,7 @@ MapReduce 使工程师能够轻松地在大型数据集上运行自己的代码
至关重要的是,这些不同的处理模型都可以在共享的单个机器集群上运行,所有这些机器都可以访问分布式文件系统上的相同文件。在 Hadoop 方式中,不需要将数据导入到几个不同的专用系统中进行不同类型的处理:系统足够灵活,可以支持同一个集群内不同的工作负载。不需要移动数据,使得从数据中挖掘价值变得容易得多,也使采用新的处理模型容易的多。
Hadoop 生态系统包括随机访问的 OLTP 数据库,如 HBase请参阅 “[SSTables 和 LSM 树](ch3.md#SSTables和LSM树)”)和 MPP 风格的分析型数据库,如 Impala 【41】。HBase 与 Impala 都不使用 MapReduce但都使用 HDFS 进行存储。它们是迥异的数据访问与处理方法,但是它们可以共存,并被集成到同一个系统中。
Hadoop 生态系统包括随机访问的 OLTP 数据库,如 HBase请参阅 “[SSTables 和 LSM 树](/ch3#SSTables和LSM树)”)和 MPP 风格的分析型数据库,如 Impala 【41】。HBase 与 Impala 都不使用 MapReduce但都使用 HDFS 进行存储。它们是迥异的数据访问与处理方法,但是它们可以共存,并被集成到同一个系统中。
#### 针对频繁故障设计
@ -528,7 +529,7 @@ MapReduce 方式更适用于较大的作业:要处理如此之多的数据并
但是MapReduce 执行模型本身也存在一些问题这些问题并没有通过增加另一个抽象层次而解决而对于某些类型的处理它表现得非常差劲。一方面MapReduce 非常稳健:你可以使用它在任务会频繁终止的多租户系统上处理几乎任意大量级的数据,并且仍然可以完成工作(虽然速度很慢)。另一方面,对于某些类型的处理而言,其他工具有时会快上几个数量级。
在本章的其余部分中,我们将介绍一些批处理方法。在 [第十一章](ch11.md) 我们将转向流处理,它可以看作是加速批处理的另一种方法。
在本章的其余部分中,我们将介绍一些批处理方法。在 [第十一章](/ch11) 我们将转向流处理,它可以看作是加速批处理的另一种方法。
### 物化中间状态
@ -538,7 +539,7 @@ MapReduce 方式更适用于较大的作业:要处理如此之多的数据并
但在很多情况下,你知道一个作业的输出只能用作另一个作业的输入,这些作业由同一个团队维护。在这种情况下,分布式文件系统上的文件只是简单的 **中间状态intermediate state**:一种将数据从一个作业传递到下一个作业的方式。在一个用于构建推荐系统的,由 50 或 100 个 MapReduce 作业组成的复杂工作流中存在着很多这样的中间状态【29】。
将这个中间状态写入文件的过程称为 **物化materialization**。(在 “[聚合:数据立方体和物化视图](ch3.md#聚合:数据立方体和物化视图)” 中已经在物化视图的背景中遇到过这个术语。它意味着对某个操作的结果立即求值并写出来,而不是在请求时按需计算)
将这个中间状态写入文件的过程称为 **物化materialization**。(在 “[聚合:数据立方体和物化视图](/ch3#聚合:数据立方体和物化视图)” 中已经在物化视图的背景中遇到过这个术语。它意味着对某个操作的结果立即求值并写出来,而不是在请求时按需计算)
作为对照,本章开头的日志分析示例使用 Unix 管道将一个命令的输出与另一个命令的输入连接起来。管道并没有完全物化中间状态,而是只使用一个小的内存缓冲区,将输出增量地 **流stream** 向输入。
@ -597,13 +598,13 @@ Spark、Flink 和 Tez 避免将中间状态写入 HDFS因此它们采取了
### 图与迭代处理
在 “[图数据模型](ch2.md#图数据模型)” 中,我们讨论了使用图来建模数据,并使用图查询语言来遍历图中的边与点。[第二章](ch2.md) 的讨论集中在 OLTP 风格的应用场景:快速执行查询来查找少量符合特定条件的顶点。
在 “[图数据模型](/ch2#图数据模型)” 中,我们讨论了使用图来建模数据,并使用图查询语言来遍历图中的边与点。[第二章](/ch2) 的讨论集中在 OLTP 风格的应用场景:快速执行查询来查找少量符合特定条件的顶点。
批处理上下文中的图也很有趣,其目标是在整个图上执行某种离线处理或分析。这种需求经常出现在机器学习应用(如推荐引擎)或排序系统中。例如,最着名的图形分析算法之一是 PageRank 【69】它试图根据链接到某个网页的其他网页来估计该网页的流行度。它作为配方的一部分用于确定网络搜索引擎呈现结果的顺序。
> 像 Spark、Flink 和 Tez 这样的数据流引擎(请参阅 “[物化中间状态](#物化中间状态)”)通常将算子作为 **有向无环图DAG** 的一部分安排在作业中。这与图处理不一样:在数据流引擎中,**从一个算子到另一个算子的数据流** 被构造成一个图,而数据本身通常由关系型元组构成。在图处理中,数据本身具有图的形式。又一个不幸的命名混乱!
许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在 [图 2-6](img/fig2-6.png) 中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为 **传递闭包**,即 transitive closure
许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在 [图 2-6](/img/fig2-6.png) 中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为 **传递闭包**,即 transitive closure
可以在分布式文件系统中存储图(包含顶点和边的列表的文件),但是这种 “重复至完成” 的想法不能用普通的 MapReduce 来表示,因为它只扫过一趟数据。这种算法因此经常以 **迭代** 的风格实现:
@ -621,13 +622,13 @@ Spark、Flink 和 Tez 避免将中间状态写入 HDFS因此它们采取了
在每次迭代中,为每个顶点调用一个函数,将所有发送给它的消息传递给它 —— 就像调用 Reducer 一样。与 MapReduce 的不同之处在于,在 Pregel 模型中,顶点在一次迭代到下一次迭代的过程中会记住它的状态,所以这个函数只需要处理新的传入消息。如果图的某个部分没有被发送消息,那里就不需要做任何工作。
这与 Actor 模型有些相似(请参阅 “[分布式的 Actor 框架](ch4.md#分布式的Actor框架)”除了顶点状态和顶点之间的消息具有容错性和持久性且通信以固定的回合进行在每次迭代中框架递送上次迭代中发送的所有消息。Actor 通常没有这样的时序保证。
这与 Actor 模型有些相似(请参阅 “[分布式的 Actor 框架](/ch4#分布式的Actor框架)”除了顶点状态和顶点之间的消息具有容错性和持久性且通信以固定的回合进行在每次迭代中框架递送上次迭代中发送的所有消息。Actor 通常没有这样的时序保证。
#### 容错
顶点只能通过消息传递进行通信(而不是直接相互查询)的事实有助于提高 Pregel 作业的性能,因为消息可以成批处理,且等待通信的次数也减少了。唯一的等待是在迭代之间:由于 Pregel 模型保证所有在一轮迭代中发送的消息都在下轮迭代中送达,所以在下一轮迭代开始前,先前的迭代必须完全完成,而所有的消息必须在网络上完成复制。
即使底层网络可能丢失、重复或任意延迟消息(请参阅 “[不可靠的网络](ch8.md#不可靠的网络)”Pregel 的实现能保证在后续迭代中消息在其目标顶点恰好处理一次。像 MapReduce 一样,框架能从故障中透明地恢复,以简化在 Pregel 上实现算法的编程模型。
即使底层网络可能丢失、重复或任意延迟消息(请参阅 “[不可靠的网络](/ch8#不可靠的网络)”Pregel 的实现能保证在后续迭代中消息在其目标顶点恰好处理一次。像 MapReduce 一样,框架能从故障中透明地恢复,以简化在 Pregel 上实现算法的编程模型。
这种容错是通过在迭代结束时定期存档所有顶点的状态来实现的即将其全部状态写入持久化存储。如果某个节点发生故障并且其内存中的状态丢失则最简单的解决方法是将整个图计算回滚到上一个存档点然后重启计算。如果算法是确定性的且消息记录在日志中那么也可以选择性地只恢复丢失的分区就像之前讨论过的数据流引擎【72】。
@ -658,13 +659,13 @@ Spark、Flink 和 Tez 避免将中间状态写入 HDFS因此它们采取了
与硬写执行连接的代码相比指定连接关系算子的优点是框架可以分析连接输入的属性并自动决定哪种上述连接算法最适合当前任务。Hive、Spark 和 Flink 都有基于代价的查询优化器可以做到这一点甚至可以改变连接顺序最小化中间状态的数量【66,77,78,79】。
连接算法的选择可以对批处理作业的性能产生巨大影响,而无需理解和记住本章中讨论的各种连接算法。如果连接是以 **声明式declarative** 的方式指定的,那这就这是可行的:应用只是简单地说明哪些连接是必需的,查询优化器决定如何最好地执行连接。我们以前在 “[数据查询语言](ch2.md#数据查询语言)” 中见过这个想法。
连接算法的选择可以对批处理作业的性能产生巨大影响,而无需理解和记住本章中讨论的各种连接算法。如果连接是以 **声明式declarative** 的方式指定的,那这就这是可行的:应用只是简单地说明哪些连接是必需的,查询优化器决定如何最好地执行连接。我们以前在 “[数据查询语言](/ch2#数据查询语言)” 中见过这个想法。
但 MapReduce 及其数据流后继者在其他方面,与 SQL 的完全声明式查询模型有很大区别。MapReduce 是围绕着回调函数的概念建立的对于每条记录或者一组记录调用一个用户定义的函数Mapper 或 Reducer并且该函数可以自由地调用任意代码来决定输出什么。这种方法的优点是可以基于大量已有库的生态系统创作解析、自然语言分析、图像分析以及运行数值或统计算法等。
自由运行任意代码,长期以来都是传统 MapReduce 批处理系统与 MPP 数据库的区别所在(请参阅 “[Hadoop 与分布式数据库的对比](#Hadoop与分布式数据库的对比)” 一节)。虽然数据库具有编写用户定义函数的功能,但是它们通常使用起来很麻烦,而且与大多数编程语言中广泛使用的程序包管理器和依赖管理系统兼容不佳(例如 Java 的 Maven、Javascript 的 npm 以及 Ruby 的 gems
然而数据流引擎已经发现,支持除连接之外的更多 **声明式特性** 还有其他的优势。例如,如果一个回调函数只包含一个简单的过滤条件,或者只是从一条记录中选择了一些字段,那么在为每条记录调用函数时会有相当大的额外 CPU 开销。如果以声明方式表示这些简单的过滤和映射操作,那么查询优化器可以利用列式存储布局(请参阅 “[列式存储](ch3.md#列式存储)”只从磁盘读取所需的列。Hive、Spark DataFrames 和 Impala 还使用了向量化执行(请参阅 “[内存带宽和矢量化处理](ch3.md#内存带宽和矢量化处理)”):在对 CPU 缓存友好的内部循环中迭代数据避免函数调用。Spark 生成 JVM 字节码【79】Impala 使用 LLVM 为这些内部循环生成本机代码【41】。
然而数据流引擎已经发现,支持除连接之外的更多 **声明式特性** 还有其他的优势。例如,如果一个回调函数只包含一个简单的过滤条件,或者只是从一条记录中选择了一些字段,那么在为每条记录调用函数时会有相当大的额外 CPU 开销。如果以声明方式表示这些简单的过滤和映射操作,那么查询优化器可以利用列式存储布局(请参阅 “[列式存储](/ch3#列式存储)”只从磁盘读取所需的列。Hive、Spark DataFrames 和 Impala 还使用了向量化执行(请参阅 “[内存带宽和矢量化处理](/ch3#内存带宽和矢量化处理)”):在对 CPU 缓存友好的内部循环中迭代数据避免函数调用。Spark 生成 JVM 字节码【79】Impala 使用 LLVM 为这些内部循环生成本机代码【41】。
通过在高级 API 中引入声明式的部分,并使查询优化器可以在执行期间利用这些来做优化,批处理框架看起来越来越像 MPP 数据库了(并且能实现可与之媲美的性能)。同时,通过拥有运行任意代码和以任意格式读取数据的可扩展性,它们保持了灵活性的优势。
@ -804,10 +805,3 @@ Spark、Flink 和 Tez 避免将中间状态写入 HDFS因此它们采取了
1. Michael Armbrust, Reynold S Xin, Cheng Lian, et al.: “[Spark SQL: Relational Data Processing in Spark](http://people.csail.mit.edu/matei/papers/2015/sigmod_spark_sql.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2015. [doi:10.1145/2723372.2742797](http://dx.doi.org/10.1145/2723372.2742797)
1. Daniel Blazevski: “[Planting Quadtrees for Apache Flink](https://blog.insightdatascience.com/planting-quadtrees-for-apache-flink-b396ebc80d35),” *insightdataengineering.com*, March 25, 2016.
1. Tom White: “[Genome Analysis Toolkit: Now Using Apache Spark for Data Processing](https://web.archive.org/web/20190215132904/http://blog.cloudera.com/blog/2016/04/genome-analysis-toolkit-now-using-apache-spark-for-data-processing/),” *blog.cloudera.com*, April 6, 2016.
------
| 上一章 | 目录 | 下一章 |
| --------------------------------- | ------------------------------- | ------------------------ |
| [第三部分:衍生数据](part-iii.md) | [设计数据密集型应用](README.md) | [第十一章:流处理](ch11.md) |

View File

@ -1,18 +1,21 @@
# 第十一章:流处理
---
title: "第十一章:流处理"
linkTitle: "11. 流处理"
weight: 311
breadcrumbs: false
---
![](img/ch11.png)
![](/img/ch11.png)
> 有效的复杂系统总是从简单的系统演化而来。反之亦然:从零设计的复杂系统没一个能有效工作的。
>
> —— 约翰・加尔Systemantics1975
---------------
[TOC]
在 [第十章](/ch10) 中,我们讨论了批处理技术,它读取一组文件作为输入,并生成一组新的文件作为输出。输出是 **衍生数据derived data** 的一种形式;也就是说,如果需要,可以通过再次运行批处理过程来重新创建数据集。我们看到了如何使用这个简单而强大的想法来建立搜索索引、推荐系统、做分析等等。
在 [第十章](ch10.md) 中,我们讨论了批处理技术,它读取一组文件作为输入,并生成一组新的文件作为输出。输出是 **衍生数据derived data** 的一种形式;也就是说,如果需要,可以通过再次运行批处理过程来重新创建数据集。我们看到了如何使用这个简单而强大的想法来建立搜索索引、推荐系统、做分析等等。
然而,在 [第十章](ch10.md) 中仍然有一个很大的假设即输入是有界的即已知和有限的大小所以批处理知道它何时完成输入的读取。例如MapReduce 核心的排序操作必须读取其全部输入,然后才能开始生成输出:可能发生这种情况:最后一条输入记录具有最小的键,因此需要第一个被输出,所以提早开始输出是不可行的。
然而,在 [第十章](/ch10) 中仍然有一个很大的假设即输入是有界的即已知和有限的大小所以批处理知道它何时完成输入的读取。例如MapReduce 核心的排序操作必须读取其全部输入,然后才能开始生成输出:可能发生这种情况:最后一条输入记录具有最小的键,因此需要第一个被输出,所以提早开始输出是不可行的。
实际上,很多数据是 **无界限** 的,因为它随着时间的推移而逐渐到达:你的用户在昨天和今天产生了数据,明天他们将继续产生更多的数据。除非你停业,否则这个过程永远都不会结束,所以数据集从来就不会以任何有意义的方式 “完成”【1】。因此批处理程序必须将数据人为地分成固定时间段的数据块例如在每天结束时处理一天的数据或者在每小时结束时处理一小时的数据。
@ -27,11 +30,11 @@
在批处理领域,作业的输入和输出是文件(也许在分布式文件系统上)。流处理领域中的等价物看上去是什么样子的?
当输入是一个文件(一个字节序列),第一个处理步骤通常是将其解析为一系列记录。在流处理的上下文中,记录通常被叫做 **事件event** ,但它本质上是一样的:一个小的、自包含的、不可变的对象,包含某个时间点发生的某件事情的细节。一个事件通常包含一个来自日历时钟的时间戳,以指明事件发生的时间(请参阅 “[单调钟与日历时钟](ch8.md#单调钟与日历时钟)”)。
当输入是一个文件(一个字节序列),第一个处理步骤通常是将其解析为一系列记录。在流处理的上下文中,记录通常被叫做 **事件event** ,但它本质上是一样的:一个小的、自包含的、不可变的对象,包含某个时间点发生的某件事情的细节。一个事件通常包含一个来自日历时钟的时间戳,以指明事件发生的时间(请参阅 “[单调钟与日历时钟](/ch8#单调钟与日历时钟)”)。
例如,发生的事件可能是用户采取的行动,例如查看页面或进行购买。它也可能来源于机器,例如对温度传感器或 CPU 利用率的周期性测量。在 “[使用 Unix 工具的批处理](ch10.md#使用Unix工具的批处理)” 的示例中Web 服务器日志的每一行都是一个事件。
例如,发生的事件可能是用户采取的行动,例如查看页面或进行购买。它也可能来源于机器,例如对温度传感器或 CPU 利用率的周期性测量。在 “[使用 Unix 工具的批处理](/ch10#使用Unix工具的批处理)” 的示例中Web 服务器日志的每一行都是一个事件。
事件可能被编码为文本字符串或 JSON或者某种二进制编码如 [第四章](ch4.md) 所述。这种编码允许你存储一个事件,例如将其追加到一个文件,将其插入关系表,或将其写入文档数据库。它还允许你通过网络将事件发送到另一个节点以进行处理。
事件可能被编码为文本字符串或 JSON或者某种二进制编码如 [第四章](/ch4) 所述。这种编码允许你存储一个事件,例如将其追加到一个文件,将其插入关系表,或将其写入文档数据库。它还允许你通过网络将事件发送到另一个节点以进行处理。
在批处理中,文件被写入一次,然后可能被多个作业读取。类似地,在流处理术语中,一个事件由 **生产者producer** (也称为 **发布者publisher****发送者sender** )生成一次,然后可能由多个 **消费者consumer** **订阅者subscribers****接收者recipients** 进行处理【3】。在文件系统中文件名标识一组相关记录在流式系统中相关的事件通常被聚合为一个 **主题topic****流stream**
@ -44,21 +47,21 @@
### 消息传递系统
向消费者通知新事件的常用方式是使用 **消息传递系统messaging system**:生产者发送包含事件的消息,然后将消息推送给消费者。我们之前在 “[消息传递中的数据流](ch4.md#消息传递中的数据流)” 中谈到了这些系统,但现在我们将详细介绍这些系统。
向消费者通知新事件的常用方式是使用 **消息传递系统messaging system**:生产者发送包含事件的消息,然后将消息推送给消费者。我们之前在 “[消息传递中的数据流](/ch4#消息传递中的数据流)” 中谈到了这些系统,但现在我们将详细介绍这些系统。
像生产者和消费者之间的 Unix 管道或 TCP 连接这样的直接信道是实现消息传递系统的简单方法。但是大多数消息传递系统都在这一基本模型上进行了扩展。特别的是Unix 管道和 TCP 将恰好一个发送者与恰好一个接收者连接,而一个消息传递系统允许多个生产者节点将消息发送到同一个主题,并允许多个消费者节点接收主题中的消息。
在这个 **发布 / 订阅** 模式中,不同的系统采取各种各样的方法,并没有针对所有目的的通用答案。为了区分这些系统,问一下这两个问题会特别有帮助:
1. **如果生产者发送消息的速度比消费者能够处理的速度快会发生什么?** 一般来说,有三种选择:系统可以丢掉消息,将消息放入缓冲队列,或使用 **背压**backpressure也称为 **流量控制**,即 flow control阻塞生产者以免其发送更多的消息。例如 Unix 管道和 TCP 就使用了背压:它们有一个固定大小的小缓冲区,如果填满,发送者会被阻塞,直到接收者从缓冲区中取出数据(请参阅 “[网络拥塞和排队](ch8.md#网络拥塞和排队)”)。
1. **如果生产者发送消息的速度比消费者能够处理的速度快会发生什么?** 一般来说,有三种选择:系统可以丢掉消息,将消息放入缓冲队列,或使用 **背压**backpressure也称为 **流量控制**,即 flow control阻塞生产者以免其发送更多的消息。例如 Unix 管道和 TCP 就使用了背压:它们有一个固定大小的小缓冲区,如果填满,发送者会被阻塞,直到接收者从缓冲区中取出数据(请参阅 “[网络拥塞和排队](/ch8#网络拥塞和排队)”)。
如果消息被缓存在队列中那么理解队列增长会发生什么是很重要的。当队列装不进内存时系统会崩溃吗还是将消息写入磁盘如果是这样磁盘访问又会如何影响消息传递系统的性能【6】
2. **如果节点崩溃或暂时脱机,会发生什么情况? —— 是否会有消息丢失?** 与数据库一样,持久性可能需要写入磁盘和 / 或复制的某种组合(请参阅 “[复制与持久性](ch7.md#复制与持久性)”),这是有代价的。如果你能接受有时消息会丢失,则可能在同一硬件上获得更高的吞吐量和更低的延迟。
2. **如果节点崩溃或暂时脱机,会发生什么情况? —— 是否会有消息丢失?** 与数据库一样,持久性可能需要写入磁盘和 / 或复制的某种组合(请参阅 “[复制与持久性](/ch7#复制与持久性)”),这是有代价的。如果你能接受有时消息会丢失,则可能在同一硬件上获得更高的吞吐量和更低的延迟。
是否可以接受消息丢失取决于应用。例如对于周期传输的传感器读数和指标偶尔丢失的数据点可能并不重要因为更新的值会在短时间内发出。但要注意如果大量的消息被丢弃可能无法立刻意识到指标已经不正确了【7】。如果你正在对事件计数那么它们能够可靠送达是更重要的因为每个丢失的消息都意味着使计数器的错误扩大。
我们在 [第十章](ch10.md) 中探讨的批处理系统的一个很好的特性是,它们提供了强大的可靠性保证:失败的任务会自动重试,失败任务的部分输出会自动丢弃。这意味着输出与没有发生故障一样,这有助于简化编程模型。在本章的后面,我们将研究如何在流处理的上下文中提供类似的保证。
我们在 [第十章](/ch10) 中探讨的批处理系统的一个很好的特性是,它们提供了强大的可靠性保证:失败的任务会自动重试,失败任务的部分输出会自动丢弃。这意味着输出与没有发生故障一样,这有助于简化编程模型。在本章的后面,我们将研究如何在流处理的上下文中提供类似的保证。
#### 直接从生产者传递给消费者
@ -66,8 +69,8 @@
* UDP 组播广泛应用于金融行业例如股票市场其中低时延非常重要【8】。虽然 UDP 本身是不可靠的,但应用层的协议可以恢复丢失的数据包(生产者必须记住它发送的数据包,以便能按需重新发送数据包)。
* 无代理的消息库,如 ZeroMQ 【9】和 nanomsg 采取类似的方法,通过 TCP 或 IP 多播实现发布 / 订阅消息传递。
* StatsD 【10】和 Brubeck 【7】使用不可靠的 UDP 消息传递来收集网络中所有机器的指标并对其进行监控。(在 StatsD 协议中,只有接收到所有消息,才认为计数器指标是正确的;使用 UDP 将使得指标处在一种最佳近似状态【11】。另请参阅 “[TCP 与 UDP](ch8.md#TCP与UDP)”
* 如果消费者在网络上公开了服务,生产者可以直接发送 HTTP 或 RPC 请求(请参阅 “[服务中的数据流REST 与 RPC](ch4.md#服务中的数据流REST与RPC)”)将消息推送给使用者。这就是 webhooks 背后的想法【12】一种服务的回调 URL 被注册到另一个服务中,并且每当事件发生时都会向该 URL 发出请求。
* StatsD 【10】和 Brubeck 【7】使用不可靠的 UDP 消息传递来收集网络中所有机器的指标并对其进行监控。(在 StatsD 协议中,只有接收到所有消息,才认为计数器指标是正确的;使用 UDP 将使得指标处在一种最佳近似状态【11】。另请参阅 “[TCP 与 UDP](/ch8#TCP与UDP)”
* 如果消费者在网络上公开了服务,生产者可以直接发送 HTTP 或 RPC 请求(请参阅 “[服务中的数据流REST 与 RPC](/ch4#服务中的数据流REST与RPC)”)将消息推送给使用者。这就是 webhooks 背后的想法【12】一种服务的回调 URL 被注册到另一个服务中,并且每当事件发生时都会向该 URL 发出请求。
尽管这些直接消息传递系统在设计它们的环境中运行良好,但是它们通常要求应用代码意识到消息丢失的可能性。它们的容错程度极为有限:即使协议检测到并重传在网络中丢失的数据包,它们通常也只是假设生产者和消费者始终在线。
@ -83,7 +86,7 @@
#### 消息代理与数据库的对比
有些消息代理甚至可以使用 XA 或 JTA 参与两阶段提交协议(请参阅 “[实践中的分布式事务](ch9.md#实践中的分布式事务)”)。这个功能与数据库在本质上非常相似,尽管消息代理和数据库之间仍存在实践上很重要的差异:
有些消息代理甚至可以使用 XA 或 JTA 参与两阶段提交协议(请参阅 “[实践中的分布式事务](/ch9#实践中的分布式事务)”)。这个功能与数据库在本质上非常相似,尽管消息代理和数据库之间仍存在实践上很重要的差异:
* 数据库通常保留数据直至显式删除,而大多数消息代理在消息成功递送给消费者时会自动删除消息。这样的消息代理不适合长期的数据存储。
* 由于它们很快就能删除消息,大多数消息代理都认为它们的工作集相当小 —— 即队列很短。如果代理需要缓冲很多消息比如因为消费者速度较慢如果内存装不下消息可能会溢出到磁盘每个消息需要更长的处理时间整体吞吐量可能会恶化【6】。
@ -94,7 +97,7 @@
#### 多个消费者
当多个消费者从同一主题中读取消息时,有两种主要的消息传递模式,如 [图 11-1](img/fig11-1.png) 所示:
当多个消费者从同一主题中读取消息时,有两种主要的消息传递模式,如 [图 11-1](/img/fig11-1.png) 所示:
* 负载均衡load balancing
@ -104,7 +107,7 @@
每条消息都被传递给 **所有** 消费者。扇出允许几个独立的消费者各自 “收听” 相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 JMS 中的主题订阅与 AMQP 中的交叉绑定提供了这一功能)。
![](img/fig11-1.png)
![](/img/fig11-1.png)
**图 11-1 a负载平衡在消费者间共享消费主题b扇出将每条消息传递给多个消费者。**
@ -114,11 +117,11 @@
消费者随时可能会崩溃,所以有一种可能的情况是:代理向消费者递送消息,但消费者没有处理,或者在消费者崩溃之前只进行了部分处理。为了确保消息不会丢失,消息代理使用 **确认acknowledgments**:客户端必须显式告知代理消息处理完毕的时间,以便代理能将消息从队列中移除。
如果与客户端的连接关闭,或者代理超出一段时间未收到确认,代理则认为消息没有被处理,因此它将消息再递送给另一个消费者。(请注意可能发生这样的情况,消息 **实际上是** 处理完毕的,但 **确认** 在网络中丢失了。需要一种原子提交协议才能处理这种情况,正如在 “[实践中的分布式事务](ch9.md#实践中的分布式事务)” 中所讨论的那样)
如果与客户端的连接关闭,或者代理超出一段时间未收到确认,代理则认为消息没有被处理,因此它将消息再递送给另一个消费者。(请注意可能发生这样的情况,消息 **实际上是** 处理完毕的,但 **确认** 在网络中丢失了。需要一种原子提交协议才能处理这种情况,正如在 “[实践中的分布式事务](/ch9#实践中的分布式事务)” 中所讨论的那样)
当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在 [图 11-2](img/fig11-2.png) 中,消费者通常按照生产者发送的顺序处理消息。然而消费者 2 在处理消息 m3 时崩溃,与此同时消费者 1 正在处理消息 m4。未确认的消息 m3 随后被重新发送给消费者 1结果消费者 1 按照 m4m3m5 的顺序处理消息。因此 m3 和 m4 的交付顺序与生产者 1 的发送顺序不同。
当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在 [图 11-2](/img/fig11-2.png) 中,消费者通常按照生产者发送的顺序处理消息。然而消费者 2 在处理消息 m3 时崩溃,与此同时消费者 1 正在处理消息 m4。未确认的消息 m3 随后被重新发送给消费者 1结果消费者 1 按照 m4m3m5 的顺序处理消息。因此 m3 和 m4 的交付顺序与生产者 1 的发送顺序不同。
![](img/fig11-2.png)
![](/img/fig11-2.png)
**图 11-2 在处理 m3 时消费者 2 崩溃,因此稍后重传至消费者 1**
@ -130,7 +133,7 @@
数据库和文件系统采用截然相反的方法论:至少在某人显式删除前,通常写入数据库或文件的所有内容都要被永久记录下来。
这种思维方式上的差异对创建衍生数据的方式有巨大影响。如 [第十章](ch10.md) 所述,批处理过程的一个关键特性是,你可以反复运行它们,试验处理步骤,不用担心损坏输入(因为输入是只读的)。而 AMQP/JMS 风格的消息传递并非如此:收到消息是具有破坏性的,因为确认可能导致消息从代理中被删除,因此你不能期望再次运行同一个消费者能得到相同的结果。
这种思维方式上的差异对创建衍生数据的方式有巨大影响。如 [第十章](/ch10) 所述,批处理过程的一个关键特性是,你可以反复运行它们,试验处理步骤,不用担心损坏输入(因为输入是只读的)。而 AMQP/JMS 风格的消息传递并非如此:收到消息是具有破坏性的,因为确认可能导致消息从代理中被删除,因此你不能期望再次运行同一个消费者能得到相同的结果。
如果你将新的消费者添加到消息传递系统,通常只能接收到消费者注册之后开始发送的消息。先前的任何消息都随风而逝,一去不复返。作为对比,你可以随时为文件和数据库添加新的客户端,且能读取任意久远的数据(只要应用没有显式覆盖或删除这些数据)。
@ -138,15 +141,15 @@
#### 使用日志进行消息存储
日志只是磁盘上简单的仅追加记录序列。我们先前在 [第三章](ch3.md) 中日志结构存储引擎和预写式日志的上下文中讨论了日志,在 [第五章](ch5.md) 复制的上下文里也讨论了它。
日志只是磁盘上简单的仅追加记录序列。我们先前在 [第三章](/ch3) 中日志结构存储引擎和预写式日志的上下文中讨论了日志,在 [第五章](/ch5) 复制的上下文里也讨论了它。
同样的结构可以用于实现消息代理生产者通过将消息追加到日志末尾来发送消息而消费者通过依次读取日志来接收消息。如果消费者读到日志末尾则会等待新消息追加的通知。Unix 工具 `tail -f` 能监视文件被追加写入的数据,基本上就是这样工作的。
为了伸缩超出单个磁盘所能提供的更高吞吐量,可以对日志进行 **分区**(按 [第六章](ch6.md) 的定义)。不同的分区可以托管在不同的机器上,使得每个分区都有一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如 [图 11-3](img/fig11-3.png) 所示。
为了伸缩超出单个磁盘所能提供的更高吞吐量,可以对日志进行 **分区**(按 [第六章](/ch6) 的定义)。不同的分区可以托管在不同的机器上,使得每个分区都有一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如 [图 11-3](/img/fig11-3.png) 所示。
在每个分区内,代理为每个消息分配一个单调递增的序列号或 **偏移量**offset在 [图 11-3](img/fig11-3.png) 中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。
在每个分区内,代理为每个消息分配一个单调递增的序列号或 **偏移量**offset在 [图 11-3](/img/fig11-3.png) 中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。
![](img/fig11-3.png)
![](/img/fig11-3.png)
**图 11-3 生产者通过将消息追加写入主题分区文件来发送消息,消费者依次读取这些文件**
@ -159,7 +162,7 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
然后每个客户端将消费被指派分区中的 **所有** 消息。通常情况下,当一个用户被指派了一个日志分区时,它会以简单的单线程方式顺序地读取分区中的消息。这种粗粒度的负载均衡方法有一些缺点:
* 共享消费主题工作的节点数,最多为该主题中的日志分区数,因为同一个分区内的所有消息被递送到同一个节点 [^i]。
* 如果某条消息处理缓慢,则它会阻塞该分区中后续消息的处理(一种行首阻塞的形式;请参阅 “[描述性能](ch1.md#描述性能)”)。
* 如果某条消息处理缓慢,则它会阻塞该分区中后续消息的处理(一种行首阻塞的形式;请参阅 “[描述性能](/ch1#描述性能)”)。
因此在消息处理代价高昂希望逐条并行处理以及消息的顺序并没有那么重要的情况下JMS/AMQP 风格的消息代理是可取的。另一方面,在消息吞吐量很高,处理迅速,顺序很重要的情况下,基于日志的方法表现得非常好。
@ -169,7 +172,7 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
顺序消费一个分区使得判断消息是否已经被处理变得相当容易:所有偏移量小于消费者的当前偏移量的消息已经被处理,而具有更大偏移量的消息还没有被看到。因此,代理不需要跟踪确认每条消息,只需要定期记录消费者的偏移即可。这种方法减少了额外簿记开销,而且在批处理和流处理中采用这种方法有助于提高基于日志的系统的吞吐量。
实际上,这种偏移量与单领导者数据库复制中常见的日志序列号非常相似,我们在 “[设置新从库](ch5.md#设置新从库)” 中讨论了这种情况。在数据库复制中,日志序列号允许跟随者断开连接后,重新连接到领导者,并在不跳过任何写入的情况下恢复复制。这里原理完全相同:消息代理表现得像一个主库,而消费者就像一个从库。
实际上,这种偏移量与单领导者数据库复制中常见的日志序列号非常相似,我们在 “[设置新从库](/ch5#设置新从库)” 中讨论了这种情况。在数据库复制中,日志序列号允许跟随者断开连接后,重新连接到领导者,并在不跳过任何写入的情况下恢复复制。这里原理完全相同:消息代理表现得像一个主库,而消费者就像一个从库。
如果消费者节点失效,则失效消费者的分区将指派给其他节点,并从最后记录的偏移量开始消费消息。如果消费者已经处理了后续的消息,但还没有记录它们的偏移量,那么重启后这些消息将被处理两次。我们将在本章后面讨论这个问题的处理方法。
@ -208,9 +211,9 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
我们之前曾经说过,事件是某个时刻发生的事情的记录。发生的事情可能是用户操作(例如键入搜索查询)或读取传感器,但也可能是 **写入数据库**。某些东西被写入数据库的事实是可以被捕获、存储和处理的事件。这一观察结果表明,数据库和数据流之间的联系不仅仅是磁盘日志的物理存储 —— 而是更深层的联系。
事实上,复制日志(请参阅 “[复制日志的实现](ch5.md#复制日志的实现)”)是一个由数据库写入事件组成的流,由主库在处理事务时生成。从库将写入流应用到它们自己的数据库副本,从而最终得到相同数据的精确副本。复制日志中的事件描述发生的数据更改。
事实上,复制日志(请参阅 “[复制日志的实现](/ch5#复制日志的实现)”)是一个由数据库写入事件组成的流,由主库在处理事务时生成。从库将写入流应用到它们自己的数据库副本,从而最终得到相同数据的精确副本。复制日志中的事件描述发生的数据更改。
我们还在 “[全序广播](ch9.md#全序广播)” 中遇到了状态机复制原理,其中指出:如果每个事件代表对数据库的写入,并且每个副本按相同的顺序处理相同的事件,则副本将达到相同的最终状态 (假设事件处理是一个确定性的操作)。这是事件流的又一种场景!
我们还在 “[全序广播](/ch9#全序广播)” 中遇到了状态机复制原理,其中指出:如果每个事件代表对数据库的写入,并且每个副本按相同的顺序处理相同的事件,则副本将达到相同的最终状态 (假设事件处理是一个确定性的操作)。这是事件流的又一种场景!
在本节中,我们将首先看看异构数据系统中出现的一个问题,然后探讨如何通过将事件流的想法带入数据库来解决这个问题。
@ -218,21 +221,21 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
正如我们在本书中所看到的,没有一个系统能够满足所有的数据存储、查询和处理需求。在实践中,大多数重要应用都需要组合使用几种不同的技术来满足所有的需求:例如,使用 OLTP 数据库来为用户请求提供服务,使用缓存来加速常见请求,使用全文索引来处理搜索查询,使用数据仓库用于分析。每一种技术都有自己的数据副本,并根据自己的目的进行存储方式的优化。
由于相同或相关的数据出现在了不同的地方,因此相互间需要保持同步:如果某个项目在数据库中被更新,它也应当在缓存、搜索索引和数据仓库中被更新。对于数据仓库,这种同步通常由 ETL 进程执行(请参阅 “[数据仓库](ch3.md#数据仓库)”),通常是先取得数据库的完整副本,然后执行转换,并批量加载到数据仓库中 —— 换句话说,批处理。我们在 “[批处理工作流的输出](ch10.md#批处理工作流的输出)” 中同样看到了如何使用批处理创建搜索索引、推荐系统和其他衍生数据系统。
由于相同或相关的数据出现在了不同的地方,因此相互间需要保持同步:如果某个项目在数据库中被更新,它也应当在缓存、搜索索引和数据仓库中被更新。对于数据仓库,这种同步通常由 ETL 进程执行(请参阅 “[数据仓库](/ch3#数据仓库)”),通常是先取得数据库的完整副本,然后执行转换,并批量加载到数据仓库中 —— 换句话说,批处理。我们在 “[批处理工作流的输出](/ch10#批处理工作流的输出)” 中同样看到了如何使用批处理创建搜索索引、推荐系统和其他衍生数据系统。
如果周期性的完整数据库转储过于缓慢,有时会使用的替代方法是 **双写dual write**,其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。
但是,双写有一些严重的问题,其中一个是竞争条件,如 [图 11-4](img/fig11-4.png) 所示。在这个例子中,两个客户端同时想要更新一个项目 X客户端 1 想要将值设置为 A客户端 2 想要将其设置为 B。两个客户端首先将新值写入数据库然后将其写入到搜索索引。因为运气不好这些请求的时序是交错的数据库首先看到来自客户端 1 的写入将值设置为 A然后来自客户端 2 的写入将值设置为 B因此数据库中的最终值为 B。搜索索引首先看到来自客户端 2 的写入,然后是客户端 1 的写入,所以搜索索引中的最终值是 A。即使没发生错误这两个系统现在也永久地不一致了。
但是,双写有一些严重的问题,其中一个是竞争条件,如 [图 11-4](/img/fig11-4.png) 所示。在这个例子中,两个客户端同时想要更新一个项目 X客户端 1 想要将值设置为 A客户端 2 想要将其设置为 B。两个客户端首先将新值写入数据库然后将其写入到搜索索引。因为运气不好这些请求的时序是交错的数据库首先看到来自客户端 1 的写入将值设置为 A然后来自客户端 2 的写入将值设置为 B因此数据库中的最终值为 B。搜索索引首先看到来自客户端 2 的写入,然后是客户端 1 的写入,所以搜索索引中的最终值是 A。即使没发生错误这两个系统现在也永久地不一致了。
![](img/fig11-4.png)
![](/img/fig11-4.png)
**图 11-4 在数据库中 X 首先被设置为 A然后被设置为 B而在搜索索引处写入以相反的顺序到达**
除非有一些额外的并发检测机制,例如我们在 “[检测并发写入](ch5.md#检测并发写入)” 中讨论的版本向量,否则你甚至不会意识到发生了并发写入 —— 一个值将简单地以无提示方式覆盖另一个值。
除非有一些额外的并发检测机制,例如我们在 “[检测并发写入](/ch5#检测并发写入)” 中讨论的版本向量,否则你甚至不会意识到发生了并发写入 —— 一个值将简单地以无提示方式覆盖另一个值。
双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(请参阅 “[原子提交与两阶段提交](ch9.md#原子提交与两阶段提交)”)。
双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(请参阅 “[原子提交与两阶段提交](/ch9#原子提交与两阶段提交)”)。
如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在 [图 11-4](img/fig11-4.png) 中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(请参阅 “[多主复制](ch5.md#多主复制)”)。
如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在 [图 11-4](/img/fig11-4.png) 中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(请参阅 “[多主复制](/ch5#多主复制)”)。
如果实际上只有一个领导者 —— 例如,数据库 —— 而且我们能让搜索索引成为数据库的追随者,情况要好得多。但这在实践中可能吗?
@ -244,29 +247,29 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
最近,人们对 **变更数据捕获change data capture, CDC** 越来越感兴趣这是一种观察写入数据库的所有数据变更并将其提取并转换为可以复制到其他系统中的形式的过程。CDC 是非常有意思的,尤其是当变更能在被写入后立刻用于流时。
例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如 [图 11-5](img/fig11-5.png) 所示。
例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如 [图 11-5](/img/fig11-5.png) 所示。
![](img/fig11-5.png)
![](/img/fig11-5.png)
**图 11-5 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统**
#### 变更数据捕获的实现
我们可以将日志消费者叫做 **衍生数据系统**,正如在 [第三部分](part-iii.md) 的介绍中所讨论的:存储在搜索索引和数据仓库中的数据,只是 **记录系统** 数据的额外视图。变更数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在衍生数据系统中,以便衍生系统具有数据的准确副本。
我们可以将日志消费者叫做 **衍生数据系统**,正如在 [第三部分](/part-iii) 的介绍中所讨论的:存储在搜索索引和数据仓库中的数据,只是 **记录系统** 数据的额外视图。变更数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在衍生数据系统中,以便衍生系统具有数据的准确副本。
从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了 [图 11-2](img/fig11-2.png) 的重新排序问题)。
从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了 [图 11-2](/img/fig11-2.png) 的重新排序问题)。
数据库触发器可用来实现变更数据捕获(请参阅 “[基于触发器的复制](ch5.md#基于触发器的复制)”),通过注册观察所有变更的触发器,并将相应的变更项写入变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的方法,但它也很有挑战,例如如何应对模式变更。
数据库触发器可用来实现变更数据捕获(请参阅 “[基于触发器的复制](/ch5#基于触发器的复制)”),通过注册观察所有变更的触发器,并将相应的变更项写入变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的方法,但它也很有挑战,例如如何应对模式变更。
LinkedIn 的 Databus【25】Facebook 的 Wormhole【26】和 Yahoo! 的 Sherpa【27】大规模地应用这个思路。Bottled Water 使用解码 WAL 的 API 实现了 PostgreSQL 的 CDC【28】Maxwell 和 Debezium 通过解析 binlog 对 MySQL 做了类似的事情【29,30,31】Mongoriver 读取 MongoDB oplog【32,33】而 GoldenGate 为 Oracle 提供类似的功能【34,35】。
类似于消息代理,变更数据捕获通常是异步的:记录数据库系统在提交变更之前不会等待消费者应用变更。这种设计具有的运维优势是,添加缓慢的消费者不会过度影响记录系统。不过,所有复制延迟可能有的问题在这里都可能出现(请参阅 “[复制延迟问题](ch5.md#复制延迟问题)”)。
类似于消息代理,变更数据捕获通常是异步的:记录数据库系统在提交变更之前不会等待消费者应用变更。这种设计具有的运维优势是,添加缓慢的消费者不会过度影响记录系统。不过,所有复制延迟可能有的问题在这里都可能出现(请参阅 “[复制延迟问题](/ch5#复制延迟问题)”)。
#### 初始快照
如果你拥有 **所有** 对数据库进行变更的日志,则可以通过重播该日志,来重建数据库的完整状态。但是在许多情况下,永远保留所有更改会耗费太多磁盘空间,且重播过于费时,因此日志需要被截断。
例如,构建新的全文索引需要整个数据库的完整副本 —— 仅仅应用最近变更的日志是不够的,因为这样会丢失最近未曾更新的项目。因此,如果你没有完整的历史日志,则需要从一个一致的快照开始,如先前的 “[设置新从库](ch5.md#设置新从库)” 中所述。
例如,构建新的全文索引需要整个数据库的完整副本 —— 仅仅应用最近变更的日志是不够的,因为这样会丢失最近未曾更新的项目。因此,如果你没有完整的历史日志,则需要从一个一致的快照开始,如先前的 “[设置新从库](/ch5#设置新从库)” 中所述。
数据库的快照必须与变更日志中的已知位置或偏移量相对应,以便在处理完快照后知道从哪里开始应用变更。一些 CDC 工具集成了这种快照功能,而其他工具则把它留给你手动执行。
@ -274,7 +277,7 @@ LinkedIn 的 Databus【25】Facebook 的 Wormhole【26】和 Yahoo! 的 Sherp
如果你只能保留有限的历史日志,则每次要添加新的衍生数据系统时,都需要做一次快照。但 **日志压缩log compaction** 提供了一个很好的备选方案。
我们之前在 “[散列索引](ch3.md#散列索引)” 中关于日志结构存储引擎的上下文中讨论了日志压缩(请参阅 [图 3-2](img/fig3-2.png) 的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。
我们之前在 “[散列索引](/ch3#散列索引)” 中关于日志结构存储引擎的上下文中讨论了日志压缩(请参阅 [图 3-2](/img/fig3-2.png) 的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。
在日志结构存储引擎中,具有特殊值 NULL**墓碑**,即 tombstone的更新表示该键被删除并会在日志压缩过程中被移除。但只要键不被覆盖或删除它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容而不取决于数据库中曾经发生的写入次数。如果相同的键经常被覆盖写入则先前的值将最终将被垃圾回收只有最新的值会保留下来。
@ -298,14 +301,14 @@ Kafka Connect【41】致力于将广泛的数据库系统的变更数据捕获
与变更数据捕获类似,事件溯源涉及到 **将所有对应用状态的变更** 存储为变更事件日志。最大的区别是事件溯源将这一想法应用到了一个不同的抽象层次上:
* 在变更数据捕获中,应用以 **可变方式mutable way** 使用数据库,可以任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免 [图 11-4](img/fig11-4.png) 中的竞态条件。写入数据库的应用不需要知道 CDC 的存在。
* 在变更数据捕获中,应用以 **可变方式mutable way** 使用数据库,可以任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免 [图 11-4](/img/fig11-4.png) 中的竞态条件。写入数据库的应用不需要知道 CDC 的存在。
* 在事件溯源中,应用逻辑显式构建在写入事件日志的不可变事件之上。在这种情况下,事件存储是仅追加写入的,更新与删除是不鼓励的或禁止的。事件被设计为旨在反映应用层面发生的事情,而不是底层的状态变更。
事件溯源是一种强大的数据建模技术:从应用的角度来看,将用户的行为记录为不可变的事件更有意义,而不是在可变数据库中记录这些行为的影响。事件溯源使得应用随时间演化更为容易,通过更容易理解事情发生的原因来帮助调试的进行,并有利于防止应用 Bug请参阅 “[不可变事件的优点](#不可变事件的优点)”)。
例如,存储 “学生取消选课” 事件以中性的方式清楚地表达了单个行为的意图,而其副作用 “从登记表中删除了一个条目,而一条取消原因的记录被添加到学生反馈表” 则嵌入了很多有关稍后对数据的使用方式的假设。如果引入一个新的应用功能,例如 “将位置留给等待列表中的下一个人” —— 事件溯源方法允许将新的副作用轻松地从现有事件中脱开。
事件溯源类似于 **编年史chronicle** 数据模型【45】事件日志与星型模式中的事实表之间也存在相似之处请参阅 “[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”) 。
事件溯源类似于 **编年史chronicle** 数据模型【45】事件日志与星型模式中的事实表之间也存在相似之处请参阅 “[星型和雪花型:分析的模式](/ch3#星型和雪花型:分析的模式)”) 。
诸如 Event Store【46】这样的专业数据库已经被开发出来供使用事件溯源的应用使用但总的来说这种方法独立于任何特定的工具。传统的数据库或基于日志的消息代理也可以用来构建这种风格的应用。
@ -326,17 +329,17 @@ Kafka Connect【41】致力于将广泛的数据库系统的变更数据捕获
事件溯源的哲学是仔细区分 **事件event****命令command**【48】。当来自用户的请求刚到达时它一开始是一个命令在这个时间点上它仍然可能失败比如因为违反了一些完整性条件。应用必须首先验证它是否可以执行该命令。如果验证成功并且命令被接受则它变为一个持久化且不可变的事件。
例如,如果用户试图注册特定用户名,或预定飞机或剧院的座位,则应用需要检查用户名或座位是否已被占用。(先前在 “[容错共识](ch9.md#容错共识)” 中讨论过这个例子)当检查成功时,应用可以生成一个事件,指示特定的用户名是由特定的用户 ID 注册的,或者座位已经预留给特定的顾客。
例如,如果用户试图注册特定用户名,或预定飞机或剧院的座位,则应用需要检查用户名或座位是否已被占用。(先前在 “[容错共识](/ch9#容错共识)” 中讨论过这个例子)当检查成功时,应用可以生成一个事件,指示特定的用户名是由特定的用户 ID 注册的,或者座位已经预留给特定的顾客。
在事件生成的时刻,它就成为了 **事实fact**。即使客户稍后决定更改或取消预订,他们之前曾预定了某个特定座位的事实仍然成立,而更改或取消是之后添加的单独的事件。
事件流的消费者不允许拒绝事件:当消费者看到事件时,它已经成为日志中不可变的一部分,并且可能已经被其他消费者看到了。因此任何对命令的验证,都需要在它成为事件之前同步完成。例如,通过使用一个可以原子性地自动验证命令并发布事件的可串行事务。
或者,预订座位的用户请求可以拆分为两个事件:第一个是暂时预约,第二个是验证预约后的独立的确认事件(如 “[使用全序广播实现线性一致的存储](ch9.md#使用全序广播实现线性一致的存储)” 中所述) 。这种分割方式允许验证发生在一个异步的过程中。
或者,预订座位的用户请求可以拆分为两个事件:第一个是暂时预约,第二个是验证预约后的独立的确认事件(如 “[使用全序广播实现线性一致的存储](/ch9#使用全序广播实现线性一致的存储)” 中所述) 。这种分割方式允许验证发生在一个异步的过程中。
### 状态、流和不变性
我们在 [第十章](ch10.md) 中看到,批处理因其输入文件不变性而受益良多,你可以在现有输入文件上运行实验性处理作业,而不用担心损坏它们。这种不变性原则也是使得事件溯源与变更数据捕获如此强大的原因。
我们在 [第十章](/ch10) 中看到,批处理因其输入文件不变性而受益良多,你可以在现有输入文件上运行实验性处理作业,而不用担心损坏它们。这种不变性原则也是使得事件溯源与变更数据捕获如此强大的原因。
我们通常将数据库视为应用程序当前状态的存储 —— 这种表示针对读取进行了优化,而且通常对于服务查询而言是最为方便的表示。状态的本质是,它会变化,所以数据库才会支持数据的增删改。这又该如何匹配不变性呢?
@ -344,13 +347,13 @@ Kafka Connect【41】致力于将广泛的数据库系统的变更数据捕获
无论状态如何变化,总是有一系列事件导致了这些变化。即使事情已经执行与回滚,这些事件出现是始终成立的。关键的想法是:可变的状态与不可变事件的仅追加日志相互之间并不矛盾:它们是一体两面,互为阴阳的。所有变化的日志 —— **变化日志changelog**,表示了随时间演变的状态。
如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如 [图 11-6](img/fig11-6.png) 所示【49,50,51】。这个比喻有一些局限性例如状态的二阶导似乎没有意义但这是考虑数据的一个实用出发点。
如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如 [图 11-6](/img/fig11-6.png) 所示【49,50,51】。这个比喻有一些局限性例如状态的二阶导似乎没有意义但这是考虑数据的一个实用出发点。
$$
state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\
stream(t) = \frac{d\ state(t)}{dt}
$$
![](img/fig11-6.png)
![](/img/fig11-6.png)
**图 11-6 应用当前状态与事件流之间的关系**
@ -366,35 +369,35 @@ $$
如果发生错误,会计师不会删除或更改分类帐中的错误交易 —— 而是添加另一笔交易以补偿错误例如退还一笔不正确的费用。不正确的交易将永远保留在分类帐中对于审计而言可能非常重要。如果从不正确的分类账衍生出的错误数字已经公布那么下一个会计周期的数字就会包括一个更正。这个过程在会计事务中是很常见的【54】。
尽管这种可审计性只在金融系统中尤其重要,但对于不受这种严格监管的许多其他系统,也是很有帮助的。如 “[批处理输出的哲学](ch10.md#批处理输出的哲学)” 中所讨论的,如果你意外地部署了将错误数据写入数据库的错误代码,当代码会破坏性地覆写数据时,恢复要困难得多。使用不可变事件的仅追加日志,诊断问题与故障恢复就要容易的多。
尽管这种可审计性只在金融系统中尤其重要,但对于不受这种严格监管的许多其他系统,也是很有帮助的。如 “[批处理输出的哲学](/ch10#批处理输出的哲学)” 中所讨论的,如果你意外地部署了将错误数据写入数据库的错误代码,当代码会破坏性地覆写数据时,恢复要困难得多。使用不可变事件的仅追加日志,诊断问题与故障恢复就要容易的多。
不可变的事件也包含了比当前状态更多的信息。例如在购物网站上顾客可以将物品添加到他们的购物车然后再将其移除。虽然从履行订单的角度第二个事件取消了第一个事件但对分析目的而言知道客户考虑过某个特定项而之后又反悔可能是很有用的。也许他们会选择在未来购买或者他们已经找到了替代品。这个信息被记录在事件日志中但对于移出购物车就删除记录的数据库而言这个信息在移出购物车时可能就丢失了【42】。
#### 从同一事件日志中派生多个视图
此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图 11-5](img/fig11-5.png)):例如,分析型数据库 Druid 使用这种方式直接从 Kafka 摄取数据【55】Pistachio 是一个分布式的键值存储,使用 Kafka 作为提交日志【56】Kafka Connect 能将来自 Kafka 的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统如搜索服务器来说是很有意义的当系统要从分布式日志中获取输入时亦然请参阅 “[保持系统同步](#保持系统同步)”)。
此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图 11-5](/img/fig11-5.png)):例如,分析型数据库 Druid 使用这种方式直接从 Kafka 摄取数据【55】Pistachio 是一个分布式的键值存储,使用 Kafka 作为提交日志【56】Kafka Connect 能将来自 Kafka 的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统如搜索服务器来说是很有意义的当系统要从分布式日志中获取输入时亦然请参阅 “[保持系统同步](#保持系统同步)”)。
添加从事件日志到数据库的显式转换能够使应用更容易地随时间演进如果你想要引入一个新功能以新的方式表示现有数据则可以使用事件日志来构建一个单独的、针对新功能的读取优化视图无需修改现有系统而与之共存。并行运行新旧系统通常比在现有系统中执行复杂的模式迁移更容易。一旦不再需要旧的系统你可以简单地关闭它并回收其资源【47,57】。
如果你不需要担心如何查询与访问数据,那么存储数据通常是非常简单的。模式设计、索引和存储引擎的许多复杂性,都是希望支持某些特定查询和访问模式的结果(请参阅 [第三章](ch3.md))。出于这个原因,通过将数据写入的形式与读取形式相分离,并允许几个不同的读取视图,你能获得很大的灵活性。这个想法有时被称为 **命令查询责任分离command query responsibility segregation, CQRS**【42,58,59】。
如果你不需要担心如何查询与访问数据,那么存储数据通常是非常简单的。模式设计、索引和存储引擎的许多复杂性,都是希望支持某些特定查询和访问模式的结果(请参阅 [第三章](/ch3))。出于这个原因,通过将数据写入的形式与读取形式相分离,并允许几个不同的读取视图,你能获得很大的灵活性。这个想法有时被称为 **命令查询责任分离command query responsibility segregation, CQRS**【42,58,59】。
数据库和模式设计的传统方法是基于这样一种谬论,数据必须以与查询相同的形式写入。如果可以将数据从针对写入优化的事件日志转换为针对读取优化的应用状态,那么有关规范化和非规范化的争论就变得无关紧要了(请参阅 “[多对一和多对多的关系](ch2.md#多对一和多对多的关系)”):在针对读取优化的视图中对数据进行非规范化是完全合理的,因为翻译过程提供了使其与事件日志保持一致的机制。
数据库和模式设计的传统方法是基于这样一种谬论,数据必须以与查询相同的形式写入。如果可以将数据从针对写入优化的事件日志转换为针对读取优化的应用状态,那么有关规范化和非规范化的争论就变得无关紧要了(请参阅 “[多对一和多对多的关系](/ch2#多对一和多对多的关系)”):在针对读取优化的视图中对数据进行非规范化是完全合理的,因为翻译过程提供了使其与事件日志保持一致的机制。
在 “[描述负载](ch1.md#描述负载)” 中,我们讨论了推特主页时间线,它是特定用户关注的人群所发推特的缓存(类似邮箱)。这是 **针对读取优化的状态** 的又一个例子:主页时间线是高度非规范化的,因为你的推文与你所有粉丝的时间线都构成了重复。然而,扇出服务保持了这种重复状态与新推特以及新关注关系的同步,从而保证了重复的可管理性。
在 “[描述负载](/ch1#描述负载)” 中,我们讨论了推特主页时间线,它是特定用户关注的人群所发推特的缓存(类似邮箱)。这是 **针对读取优化的状态** 的又一个例子:主页时间线是高度非规范化的,因为你的推文与你所有粉丝的时间线都构成了重复。然而,扇出服务保持了这种重复状态与新推特以及新关注关系的同步,从而保证了重复的可管理性。
#### 并发控制
事件溯源和变更数据捕获的最大缺点是,事件日志的消费者通常是异步的,所以可能会出现这样的情况:用户会写入日志,然后从日志衍生视图中读取,结果发现他的写入还没有反映在读取视图中。我们之前在 “[读己之写](ch5.md#读己之写)” 中讨论了这个问题以及可能的解决方案。
事件溯源和变更数据捕获的最大缺点是,事件日志的消费者通常是异步的,所以可能会出现这样的情况:用户会写入日志,然后从日志衍生视图中读取,结果发现他的写入还没有反映在读取视图中。我们之前在 “[读己之写](/ch5#读己之写)” 中讨论了这个问题以及可能的解决方案。
一种解决方案是将事件追加到日志时同步执行读取视图的更新。而将这些写入操作合并为一个原子单元需要 **事务**,所以要么将事件日志和读取视图保存在同一个存储系统中,要么就需要跨不同系统进行分布式事务。或者,你也可以使用在 “[使用全序广播实现线性一致的存储](ch9.md#使用全序广播实现线性一致的存储)” 中讨论的方法。
一种解决方案是将事件追加到日志时同步执行读取视图的更新。而将这些写入操作合并为一个原子单元需要 **事务**,所以要么将事件日志和读取视图保存在同一个存储系统中,要么就需要跨不同系统进行分布式事务。或者,你也可以使用在 “[使用全序广播实现线性一致的存储](/ch9#使用全序广播实现线性一致的存储)” 中讨论的方法。
另一方面,从事件日志导出当前状态也简化了并发控制的某些部分。许多对于多对象事务的需求(请参阅 “[单对象和多对象操作](ch7.md#单对象和多对象操作)”)源于单个用户操作需要在多个不同的位置更改数据。通过事件溯源,你可以设计一个自包含的事件以表示一个用户操作。然后用户操作就只需要在一个地方进行单次写入操作 —— 即将事件附加到日志中 —— 这个还是很容易使原子化的。
另一方面,从事件日志导出当前状态也简化了并发控制的某些部分。许多对于多对象事务的需求(请参阅 “[单对象和多对象操作](/ch7#单对象和多对象操作)”)源于单个用户操作需要在多个不同的位置更改数据。通过事件溯源,你可以设计一个自包含的事件以表示一个用户操作。然后用户操作就只需要在一个地方进行单次写入操作 —— 即将事件附加到日志中 —— 这个还是很容易使原子化的。
如果事件日志与应用状态以相同的方式分区(例如,处理分区 3 中的客户事件只需要更新分区 3 中的应用状态),那么直接使用单线程日志消费者就不需要写入并发控制了。它从设计上一次只处理一个事件(请参阅 “[真的串行执行](ch7.md#真的串行执行)”。日志通过在分区中定义事件的序列顺序消除了并发性的不确定性【24】。如果一个事件触及多个状态分区那么需要做更多的工作我们将在 [第十二章](ch12.md) 讨论。
如果事件日志与应用状态以相同的方式分区(例如,处理分区 3 中的客户事件只需要更新分区 3 中的应用状态),那么直接使用单线程日志消费者就不需要写入并发控制了。它从设计上一次只处理一个事件(请参阅 “[真的串行执行](/ch7#真的串行执行)”。日志通过在分区中定义事件的序列顺序消除了并发性的不确定性【24】。如果一个事件触及多个状态分区那么需要做更多的工作我们将在 [第十二章](/ch12) 讨论。
#### 不变性的局限性
许多不使用事件溯源模型的系统也还是依赖不可变性:各种数据库在内部使用不可变的数据结构或多版本数据来支持时间点快照(请参阅 “[索引和快照隔离](ch7.md#索引和快照隔离)” 。Git、Mercurial 和 Fossil 等版本控制系统也依靠不可变的数据来保存文件的版本历史记录。
许多不使用事件溯源模型的系统也还是依赖不可变性:各种数据库在内部使用不可变的数据结构或多版本数据来支持时间点快照(请参阅 “[索引和快照隔离](/ch7#索引和快照隔离)” 。Git、Mercurial 和 Fossil 等版本控制系统也依靠不可变的数据来保存文件的版本历史记录。
永远保持所有变更的不变历史,在多大程度上是可行的?答案取决于数据集的流失率。一些工作负载主要是添加数据,很少更新或删除;它们很容易保持不变。其他工作负载在相对较小的数据集上有较高的更新 / 删除率在这些情况下不可变的历史可能增至难以接受的巨大碎片化可能成为一个问题压缩与垃圾收集的表现对于运维的稳健性变得至关重要【60,61】。
@ -402,7 +405,7 @@ $$
在这种情况下,仅仅在日志中添加另一个事件来指明先前的数据应该被视为删除是不够的 —— 你实际上是想改写历史并假装数据从一开始就没有写入。例如Datomic 管这个特性叫 **切除excision** 【62】而 Fossil 版本控制系统有一个类似的概念叫 **避免shunning** 【63】。
真正删除数据是非常非常困难的【64】因为副本可能存在于很多地方例如存储引擎文件系统和 SSD 通常会向一个新位置写入而不是原地覆盖旧数据【52】而备份通常是特意做成不可变的防止意外删除或损坏。删除操作更多的是指 “使取回数据更困难”,而不是指 “使取回数据不可能”。无论如何,有时你必须得尝试,正如我们在 “[立法与自律](ch12.md#立法与自律)” 中所看到的。
真正删除数据是非常非常困难的【64】因为副本可能存在于很多地方例如存储引擎文件系统和 SSD 通常会向一个新位置写入而不是原地覆盖旧数据【52】而备份通常是特意做成不可变的防止意外删除或损坏。删除操作更多的是指 “使取回数据更困难”,而不是指 “使取回数据不可能”。无论如何,有时你必须得尝试,正如我们在 “[立法与自律](/ch12#立法与自律)” 中所看到的。
## 流处理
@ -411,15 +414,15 @@ $$
剩下的就是讨论一下你可以用流做什么 —— 也就是说,你可以处理它。一般来说,有三种选项:
1. 你可以将事件中的数据写入数据库、缓存、搜索索引或类似的存储系统,然后能被其他客户端查询。如 [图 11-5](img/fig11-5.png) 所示,这是数据库与系统其他部分所发生的变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如 “[批处理工作流的输出](ch10.md#批处理工作流的输出)” 中所讨论的,它是写入存储系统的流等价物。
1. 你可以将事件中的数据写入数据库、缓存、搜索索引或类似的存储系统,然后能被其他客户端查询。如 [图 11-5](/img/fig11-5.png) 所示,这是数据库与系统其他部分所发生的变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如 “[批处理工作流的输出](/ch10#批处理工作流的输出)” 中所讨论的,它是写入存储系统的流等价物。
2. 你能以某种方式将事件推送给用户,例如发送报警邮件或推送通知,或将事件流式传输到可实时显示的仪表板上。在这种情况下,人是流的最终消费者。
3. 你可以处理一个或多个输入流,并产生一个或多个输出流。流可能会经过由几个这样的处理阶段组成的流水线,最后再输出(选项 1 或 2
在本章的剩余部分中,我们将讨论选项 3处理流以产生其他衍生流。处理这样的流的代码片段被称为 **算子operator****作业job**。它与我们在 [第十章](ch10.md) 中讨论过的 Unix 进程和 MapReduce 作业密切相关,数据流的模式是相似的:一个流处理器以只读的方式使用输入流,并将其输出以仅追加的方式写入一个不同的位置。
在本章的剩余部分中,我们将讨论选项 3处理流以产生其他衍生流。处理这样的流的代码片段被称为 **算子operator****作业job**。它与我们在 [第十章](/ch10) 中讨论过的 Unix 进程和 MapReduce 作业密切相关,数据流的模式是相似的:一个流处理器以只读的方式使用输入流,并将其输出以仅追加的方式写入一个不同的位置。
流处理中的分区和并行化模式也非常类似于 [第十章](ch10.md) 中介绍的 MapReduce 和数据流引擎,因此我们不再重复这些主题。基本的 Map 操作(如转换和过滤记录)也是一样的。
流处理中的分区和并行化模式也非常类似于 [第十章](/ch10) 中介绍的 MapReduce 和数据流引擎,因此我们不再重复这些主题。基本的 Map 操作(如转换和过滤记录)也是一样的。
与批量作业相比的一个关键区别是,流不会结束。这种差异会带来很多隐含的结果。正如本章开始部分所讨论的,排序对无界数据集没有意义,因此无法使用 **排序合并连接**(请参阅 “[Reduce 侧连接与分组](ch10.md#Reduce侧连接与分组)”)。容错机制也必须改变:对于已经运行了几分钟的批处理作业,可以简单地从头开始重启失败任务,但是对于已经运行数年的流作业,重启后从头开始跑可能并不是一个可行的选项。
与批量作业相比的一个关键区别是,流不会结束。这种差异会带来很多隐含的结果。正如本章开始部分所讨论的,排序对无界数据集没有意义,因此无法使用 **排序合并连接**(请参阅 “[Reduce 侧连接与分组](/ch10#Reduce侧连接与分组)”)。容错机制也必须改变:对于已经运行了几分钟的批处理作业,可以简单地从头开始重启失败任务,但是对于已经运行数年的流作业,重启后从头开始跑可能并不是一个可行的选项。
### 流处理的应用
@ -452,13 +455,13 @@ CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
这些统计值通常是在固定时间区间内进行计算的,例如,你可能想知道在过去 5 分钟内服务每秒查询次数的均值,以及此时间段内响应时间的第 99 百分位点。在几分钟内取平均,能抹平秒和秒之间的无关波动,且仍然能向你展示流量模式的时间图景。聚合的时间间隔称为 **窗口window**,我们将在 “[时间推理](#时间推理)” 中更详细地讨论窗口。
流分析系统有时会使用概率算法,例如 Bloom filter我们在 “[性能优化](ch3.md#性能优化)” 中遇到过来管理成员资格HyperLogLog【72】用于基数估计以及各种百分比估计算法请参阅 “[实践中的百分位点](ch1.md#实践中的百分位点)”。概率算法产出近似的结果但比起精确算法的优点是内存使用要少得多。使用近似算法有时让人们觉得流处理系统总是有损的和不精确的但这是错误看法流处理并没有任何内在的近似性而概率算法只是一种优化【73】。
流分析系统有时会使用概率算法,例如 Bloom filter我们在 “[性能优化](/ch3#性能优化)” 中遇到过来管理成员资格HyperLogLog【72】用于基数估计以及各种百分比估计算法请参阅 “[实践中的百分位点](/ch1#实践中的百分位点)”。概率算法产出近似的结果但比起精确算法的优点是内存使用要少得多。使用近似算法有时让人们觉得流处理系统总是有损的和不精确的但这是错误看法流处理并没有任何内在的近似性而概率算法只是一种优化【73】。
许多开源分布式流处理框架的设计都是针对分析设计的:例如 Apache Storm、Spark Streaming、Flink、Concord、Samza 和 Kafka Streams 【74】。托管服务包括 Google Cloud Dataflow 和 Azure Stream Analytics。
#### 维护物化视图
我们在 “[数据库与流](#数据库与流)” 中看到,数据库的变更流可以用于维护衍生数据系统(如缓存、搜索索引和数据仓库),并使其与源数据库保持最新。我们可以将这些示例视作维护 **物化视图materialized view** 的一种具体场景(请参阅 “[聚合:数据立方体和物化视图](ch3.md#聚合:数据立方体和物化视图)”在某个数据集上衍生出一个替代视图以便高效查询并在底层数据变更时更新视图【50】。
我们在 “[数据库与流](#数据库与流)” 中看到,数据库的变更流可以用于维护衍生数据系统(如缓存、搜索索引和数据仓库),并使其与源数据库保持最新。我们可以将这些示例视作维护 **物化视图materialized view** 的一种具体场景(请参阅 “[聚合:数据立方体和物化视图](/ch3#聚合:数据立方体和物化视图)”在某个数据集上衍生出一个替代视图以便高效查询并在底层数据变更时更新视图【50】。
同样,在事件溯源中,应用程序的状态是通过应用事件日志来维护的;这里的应用程序状态也是一种物化视图。与流分析场景不同的是,仅考虑某个时间窗口内的事件通常是不够的:构建物化视图可能需要任意时间段内的 **所有** 事件,除了那些可能由日志压缩丢弃的过时事件(请参阅 “[日志压缩](#日志压缩)”)。实际上,你需要一个可以一直延伸到时间开端的窗口。
@ -474,13 +477,13 @@ CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
#### 消息传递和RPC
在 “[消息传递中的数据流](ch4.md#消息传递中的数据流)” 中我们讨论过,消息传递系统可以作为 RPC 的替代方案,即作为一种服务间通信的机制,比如在 Actor 模型中所使用的那样。尽管这些系统也是基于消息和事件,但我们通常不会将其视作流处理组件:
在 “[消息传递中的数据流](/ch4#消息传递中的数据流)” 中我们讨论过,消息传递系统可以作为 RPC 的替代方案,即作为一种服务间通信的机制,比如在 Actor 模型中所使用的那样。尽管这些系统也是基于消息和事件,但我们通常不会将其视作流处理组件:
* Actor 框架主要是管理模块通信的并发和分布式执行的一种机制,而流处理主要是一种数据管理技术。
* Actor 之间的交流往往是短暂的、一对一的;而事件日志则是持久的、多订阅者的。
* Actor 可以以任意方式进行通信(包括循环的请求 / 响应模式),但流处理通常配置在无环流水线中,其中每个流都是一个特定作业的输出,由良好定义的输入流中派生而来。
也就是说RPC 类系统与流处理之间有一些交叉领域。例如Apache Storm 有一个称为 **分布式 RPC** 的功能它允许将用户查询分散到一系列也处理事件流的节点上然后这些查询与来自输入流的事件交织而结果可以被汇总并发回给用户【78】另请参阅 “[多分区数据处理](ch12.md#多分区数据处理)”)。
也就是说RPC 类系统与流处理之间有一些交叉领域。例如Apache Storm 有一个称为 **分布式 RPC** 的功能它允许将用户查询分散到一系列也处理事件流的节点上然后这些查询与来自输入流的事件交织而结果可以被汇总并发回给用户【78】另请参阅 “[多分区数据处理](/ch12#多分区数据处理)”)。
也可以使用 Actor 框架来处理流。但是,很多这样的框架在崩溃时不能保证消息的传递,除非你实现了额外的重试逻辑,否则这种处理不是容错的。
@ -490,13 +493,13 @@ CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
在批处理中过程中,大量的历史事件被快速地处理。如果需要按时间来分析,批处理器需要检查每个事件中嵌入的时间戳。读取运行批处理机器的系统时钟没有任何意义,因为处理运行的时间与事件实际发生的时间无关。
批处理可以在几分钟内读取一年的历史事件;在大多数情况下,感兴趣的时间线是历史中的一年,而不是处理中的几分钟。而且使用事件中的时间戳,使得处理是 **确定性** 的:在相同的输入上再次运行相同的处理过程会得到相同的结果(请参阅 “[容错](ch10.md#容错)”)。
批处理可以在几分钟内读取一年的历史事件;在大多数情况下,感兴趣的时间线是历史中的一年,而不是处理中的几分钟。而且使用事件中的时间戳,使得处理是 **确定性** 的:在相同的输入上再次运行相同的处理过程会得到相同的结果(请参阅 “[容错](/ch10#容错)”)。
另一方面,许多流处理框架使用处理机器上的本地系统时钟(**处理时间**,即 processing time来确定 **窗口windowing**【79】。这种方法的优点是简单如果事件创建与事件处理之间的延迟可以忽略不计那也是合理的。然而如果存在任何显著的处理延迟 —— 即,事件处理显著地晚于事件实际发生的时间,这种处理方式就失效了。
#### 事件时间与处理时间
很多原因都可能导致处理延迟:排队,网络故障(请参阅 “[不可靠的网络](ch8.md#不可靠的网络)”),性能问题导致消息代理 / 消息处理器出现争用,流消费者重启,从故障中恢复时重新处理过去的事件(请参阅 “[重播旧消息](#重播旧消息)”),或者在修复代码 BUG 之后。
很多原因都可能导致处理延迟:排队,网络故障(请参阅 “[不可靠的网络](/ch8#不可靠的网络)”),性能问题导致消息代理 / 消息处理器出现争用,流消费者重启,从故障中恢复时重新处理过去的事件(请参阅 “[重播旧消息](#重播旧消息)”),或者在修复代码 BUG 之后。
而且,消息延迟还可能导致无法预测消息顺序。例如,假设用户首先发出一个 Web 请求(由 Web 服务器 A 处理),然后发出第二个请求(由服务器 B 处理。A 和 B 发出描述它们所处理请求的事件,但是 B 的事件在 A 的事件发生之前到达消息代理。现在,流处理器将首先看到 B 事件,然后看到 A 事件,即使它们实际上是以相反的顺序发生的。
@ -504,9 +507,9 @@ CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
[^ii]: 感谢 Flink 社区的 Kostas Kloudas 提出这个比喻。
将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图 11-7](img/fig11-7.png))。
将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图 11-7](/img/fig11-7.png))。
![](img/fig11-7.png)
![](/img/fig11-7.png)
**图 11-7 按处理时间分窗,会因为处理速率的变动引入人为因素**
@ -527,7 +530,7 @@ CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
当事件可能在系统内多个地方进行缓冲时,为事件分配时间戳更加困难了。例如,考虑一个移动应用向服务器上报关于用量的事件。该应用可能会在设备处于脱机状态时被使用,在这种情况下,它将在设备本地缓冲事件,并在下一次互联网连接可用时向服务器上报这些事件(可能是几小时甚至几天)。对于这个流的任意消费者而言,它们就如延迟极大的滞留事件一样。
在这种情况下,事件上的事件戳实际上应当是用户交互发生的时间,取决于移动设备的本地时钟。然而用户控制的设备上的时钟通常是不可信的,因为它可能会被无意或故意设置成错误的时间(请参阅 “[时钟同步与准确性](ch8.md#时钟同步与准确性)”)。服务器收到事件的时间(取决于服务器的时钟)可能是更准确的,因为服务器在你的控制之下,但在描述用户交互方面意义不大。
在这种情况下,事件上的事件戳实际上应当是用户交互发生的时间,取决于移动设备的本地时钟。然而用户控制的设备上的时钟通常是不可信的,因为它可能会被无意或故意设置成错误的时间(请参阅 “[时钟同步与准确性](/ch8#时钟同步与准确性)”)。服务器收到事件的时间(取决于服务器的时钟)可能是更准确的,因为服务器在你的控制之下,但在描述用户交互方面意义不大。
要校正不正确的设备时钟一种方法是记录三个时间戳【82】
@ -557,11 +560,11 @@ CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
* 会话窗口Session window
与其他窗口类型不同,会话窗口没有固定的持续时间,而定义为:将同一用户出现时间相近的所有事件分组在一起,而当用户一段时间没有活动时(例如,如果 30 分钟内没有事件)窗口结束。会话切分是网站分析的常见需求(请参阅 “[分组](ch10.md#分组)”)。
与其他窗口类型不同,会话窗口没有固定的持续时间,而定义为:将同一用户出现时间相近的所有事件分组在一起,而当用户一段时间没有活动时(例如,如果 30 分钟内没有事件)窗口结束。会话切分是网站分析的常见需求(请参阅 “[分组](/ch10#分组)”)。
### 流连接
在 [第十章](ch10.md) 中,我们讨论了批处理作业如何通过键来连接数据集,以及这种连接是如何成为数据管道的重要组成部分的。由于流处理将数据管道泛化为对无限数据集进行增量处理,因此对流进行连接的需求也是完全相同的。
在 [第十章](/ch10) 中,我们讨论了批处理作业如何通过键来连接数据集,以及这种连接是如何成为数据管道的重要组成部分的。由于流处理将数据管道泛化为对无限数据集进行增量处理,因此对流进行连接的需求也是完全相同的。
然而,新事件随时可能出现在一个流中,这使得流连接要比批处理连接更具挑战性。为了更好地理解情况,让我们先来区分三种不同类型的连接:**流 - 流** 连接,**流 - 表** 连接,与 **表 - 表** 连接【84】。我们将在下面的章节中通过例子来说明。
@ -577,11 +580,11 @@ CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
#### 流表连接(流扩充)
在 “[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)”([图 10-2](img/fig10-2.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户 ID 的活动事件流,而输出还是活动事件流,但其中用户 ID 已经被扩展为用户的档案信息。这个过程有时被称为使用数据库的信息来 **扩充enriching** 活动事件。
在 “[示例:用户活动事件分析](/ch10#示例:用户活动事件分析)”([图 10-2](/img/fig10-2.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户 ID 的活动事件流,而输出还是活动事件流,但其中用户 ID 已经被扩展为用户的档案信息。这个过程有时被称为使用数据库的信息来 **扩充enriching** 活动事件。
要执行此连接,流处理器需要一次处理一个活动事件,在数据库中查找事件的用户 ID并将档案信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现。但正如在 “[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)” 一节中讨论的此类远程查询可能会很慢并且有可能导致数据库过载【75】。
要执行此连接,流处理器需要一次处理一个活动事件,在数据库中查找事件的用户 ID并将档案信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现。但正如在 “[示例:用户活动事件分析](/ch10#示例:用户活动事件分析)” 一节中讨论的此类远程查询可能会很慢并且有可能导致数据库过载【75】。
另一种方法是将数据库副本加载到流处理器中,以便在本地进行查询而无需网络往返。这种技术与我们在 “[Map 侧连接](ch10.md#Map侧连接)” 中讨论的散列连接非常相似:如果数据库的本地副本足够小,则可以是内存中的散列表,比较大的话也可以是本地磁盘上的索引。
另一种方法是将数据库副本加载到流处理器中,以便在本地进行查询而无需网络往返。这种技术与我们在 “[Map 侧连接](/ch10#Map侧连接)” 中讨论的散列连接非常相似:如果数据库的本地副本足够小,则可以是内存中的散列表,比较大的话也可以是本地磁盘上的索引。
与批处理作业的区别在于,批处理作业使用数据库的时间点快照作为输入,而流处理器是长时间运行的,且数据库的内容可能随时间而改变,所以流处理器数据库的本地副本需要保持更新。这个问题可以通过变更数据捕获来解决:流处理器可以订阅用户档案数据库的更新日志,如同活动事件流一样。当增添或修改档案时,流处理器会更新其本地副本。因此,我们有了两个流之间的连接:活动事件和档案更新。
@ -589,7 +592,7 @@ CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
#### 表表连接(维护物化视图)
我们在 “[描述负载](ch1.md#描述负载)” 中讨论的推特时间线例子时说过,当用户想要查看他们的主页时间线时,迭代用户所关注人群的推文并合并它们是一个开销巨大的操作。
我们在 “[描述负载](/ch1#描述负载)” 中讨论的推特时间线例子时说过,当用户想要查看他们的主页时间线时,迭代用户所关注人群的推文并合并它们是一个开销巨大的操作。
相反,我们需要一个时间线缓存:一种每个用户的 “收件箱”,在发送推文的时候写入这些信息,因而读取时间线时只需要简单地查询即可。物化与维护这个缓存需要处理以下事件:
@ -612,7 +615,7 @@ GROUP BY follows.follower_id
流连接直接对应于这个查询中的表连接。时间线实际上是这个查询结果的缓存,每当底层的表发生变化时都会更新 [^iii]。
[^iii]: 如果你将流视作表的衍生物,如 [图 11-6](img/fig11-6.png) 所示而把一个连接看作是两个表的乘法u·v那么会发生一些有趣的事情物化连接的变化流遵循乘积法则(u·v)'= u'v + uv'。换句话说任何推文的变化量都与当前的关注联系在一起任何关注的变化量都与当前的推文相连接【49,50】。
[^iii]: 如果你将流视作表的衍生物,如 [图 11-6](/img/fig11-6.png) 所示而把一个连接看作是两个表的乘法u·v那么会发生一些有趣的事情物化连接的变化流遵循乘积法则(u·v)'= u'v + uv'。换句话说任何推文的变化量都与当前的关注联系在一起任何关注的变化量都与当前的推文相连接【49,50】。
#### 连接的时间依赖性
@ -630,7 +633,7 @@ GROUP BY follows.follower_id
### 容错
在本章的最后一节中,让我们看一看流处理是如何容错的。我们在 [第十章](ch10.md) 中看到,批处理框架可以很容易地容错:如果 MapReduce 作业中的任务失败,可以简单地在另一台机器上再次启动,并且丢弃失败任务的输出。这种透明的重试是可能的,因为输入文件是不可变的,每个任务都将其输出写入到 HDFS 上的独立文件中,而输出仅当任务成功完成后可见。
在本章的最后一节中,让我们看一看流处理是如何容错的。我们在 [第十章](/ch10) 中看到,批处理框架可以很容易地容错:如果 MapReduce 作业中的任务失败,可以简单地在另一台机器上再次启动,并且丢弃失败任务的输出。这种透明的重试是可能的,因为输入文件是不可变的,每个任务都将其输出写入到 HDFS 上的独立文件中,而输出仅当任务成功完成后可见。
特别是,批处理容错方法可确保批处理作业的输出与没有出错的情况相同,即使实际上某些任务失败了。看起来好像每条输入记录都被处理了恰好一次 —— 没有记录被跳过,而且没有记录被处理两次。尽管重启任务意味着实际上可能会多次处理记录,但输出中的可见效果看上去就像只处理过一次。这个原则被称为 **恰好一次语义exactly-once semantics**,尽管 **等效一次effectively-once** 可能会是一个更写实的术语【90】。
@ -650,9 +653,9 @@ Apache Flink 则使用不同的方法,它会定期生成状态的滚动存档
为了在出现故障时表现出恰好处理一次的样子,我们需要确保事件处理的所有输出和副作用 **当且仅当** 处理成功时才会生效。这些影响包括发送给下游算子或外部消息传递系统(包括电子邮件或推送通知)的任何消息,任何数据库写入,对算子状态的任何变更,以及对输入消息的任何确认(包括在基于日志的消息代理中将消费者偏移量前移)。
这些事情要么都原子地发生,要么都不发生,但是它们不应当失去同步。如果这种方法听起来很熟悉,那是因为我们在分布式事务和两阶段提交的上下文中讨论过它(请参阅 “[恰好一次的消息处理](ch9.md#恰好一次的消息处理)”)。
这些事情要么都原子地发生,要么都不发生,但是它们不应当失去同步。如果这种方法听起来很熟悉,那是因为我们在分布式事务和两阶段提交的上下文中讨论过它(请参阅 “[恰好一次的消息处理](/ch9#恰好一次的消息处理)”)。
在 [第九章](ch9.md) 中,我们讨论了分布式事务传统实现中的问题(如 XA。然而在限制更为严苛的环境中也是有可能高效实现这种原子提交机制的。Google Cloud Dataflow【81,92】和 VoltDB 【94】中使用了这种方法Apache Kafka 有计划加入类似的功能【95,96】。与 XA 不同,这些实现不会尝试跨异构技术提供事务,而是通过在流处理框架中同时管理状态变更与消息传递来内化事务。事务协议的开销可以通过在单个事务中处理多个输入消息来分摊。
在 [第九章](/ch9) 中,我们讨论了分布式事务传统实现中的问题(如 XA。然而在限制更为严苛的环境中也是有可能高效实现这种原子提交机制的。Google Cloud Dataflow【81,92】和 VoltDB 【94】中使用了这种方法Apache Kafka 有计划加入类似的功能【95,96】。与 XA 不同,这些实现不会尝试跨异构技术提供事务,而是通过在流处理框架中同时管理状态变更与消息传递来内化事务。事务协议的开销可以通过在单个事务中处理多个输入消息来分摊。
#### 幂等性
@ -664,7 +667,7 @@ Apache Flink 则使用不同的方法,它会定期生成状态的滚动存档
Storm 的 Trident 基于类似的想法来处理状态【78】。依赖幂等性意味着隐含了一些假设重启一个失败的任务必须以相同的顺序重播相同的消息基于日志的消息代理能做这些事处理必须是确定性的没有其他节点能同时更新相同的值【98,99】。
当从一个处理节点故障切换到另一个节点时,可能需要进行 **防护**fencing请参阅 “[领导者和锁](ch8.md#领导者和锁)”),以防止被假死节点干扰。尽管有这么多注意事项,幂等操作是一种实现 **恰好一次语义** 的有效方式,仅需很小的额外开销。
当从一个处理节点故障切换到另一个节点时,可能需要进行 **防护**fencing请参阅 “[领导者和锁](/ch8#领导者和锁)”),以防止被假死节点干扰。尽管有这么多注意事项,幂等操作是一种实现 **恰好一次语义** 的有效方式,仅需很小的额外开销。
#### 失败后重建状态
@ -672,7 +675,7 @@ Storm 的 Trident 基于类似的想法来处理状态【78】。依赖幂等性
一种选择是将状态保存在远程数据存储中,并进行复制,然而正如在 “[流表连接(流扩充)](#流表连接(流扩充))” 中所述,每个消息都要查询远程数据库可能会很慢。另一种方法是在流处理器本地保存状态,并定期复制。然后当流处理器从故障中恢复时,新任务可以读取状态副本,恢复处理而不丢失数据。
例如Flink 定期捕获算子状态的快照,并将它们写入 HDFS 等持久存储中【92,93】。Samza 和 Kafka Streams 通过将状态变更发送到具有日志压缩功能的专用 Kafka 主题来复制状态变更这与变更数据捕获类似【84,100】。VoltDB 通过在多个节点上对每个输入消息进行冗余处理来复制状态(请参阅 “[真的串行执行](ch7.md#真的串行执行)”)。
例如Flink 定期捕获算子状态的快照,并将它们写入 HDFS 等持久存储中【92,93】。Samza 和 Kafka Streams 通过将状态变更发送到具有日志压缩功能的专用 Kafka 主题来复制状态变更这与变更数据捕获类似【84,100】。VoltDB 通过在多个节点上对每个输入消息进行冗余处理来复制状态(请参阅 “[真的串行执行](/ch7#真的串行执行)”)。
在某些情况下,甚至可能都不需要复制状态,因为它可以从输入流重建。例如,如果状态是从相当短的窗口中聚合而成,则简单地重播该窗口中的输入事件可能是足够快的。如果状态是通过变更数据捕获来维护的数据库的本地副本,那么也可以从日志压缩的变更流中重建数据库(请参阅 “[日志压缩](#日志压缩)”)。
@ -681,19 +684,19 @@ Storm 的 Trident 基于类似的想法来处理状态【78】。依赖幂等性
## 本章小结
在本章中,我们讨论了事件流,它们所服务的目的,以及如何处理它们。在某些方面,流处理非常类似于在 [第十章](ch10.md) 中讨论的批处理,不过是在无限的(永无止境的)流而不是固定大小的输入上持续进行。从这个角度来看,消息代理和事件日志可以视作文件系统的流式等价物。
在本章中,我们讨论了事件流,它们所服务的目的,以及如何处理它们。在某些方面,流处理非常类似于在 [第十章](/ch10) 中讨论的批处理,不过是在无限的(永无止境的)流而不是固定大小的输入上持续进行。从这个角度来看,消息代理和事件日志可以视作文件系统的流式等价物。
我们花了一些时间比较两种消息代理:
* AMQP/JMS 风格的消息代理
代理将单条消息分配给消费者,消费者在成功处理单条消息后确认消息。消息被确认后从代理中删除。这种方法适合作为一种异步形式的 RPC另请参阅 “[消息传递中的数据流](ch4.md#消息传递中的数据流)”),例如在任务队列中,消息处理的确切顺序并不重要,而且消息在处理完之后,不需要回头重新读取旧消息。
代理将单条消息分配给消费者,消费者在成功处理单条消息后确认消息。消息被确认后从代理中删除。这种方法适合作为一种异步形式的 RPC另请参阅 “[消息传递中的数据流](/ch4#消息传递中的数据流)”),例如在任务队列中,消息处理的确切顺序并不重要,而且消息在处理完之后,不需要回头重新读取旧消息。
* 基于日志的消息代理
代理将一个分区中的所有消息分配给同一个消费者节点,并始终以相同的顺序传递消息。并行是通过分区实现的,消费者通过存档最近处理消息的偏移量来跟踪工作进度。消息代理将消息保留在磁盘上,因此如有必要的话,可以回跳并重新读取旧消息。
基于日志的方法与数据库中的复制日志(请参阅 [第五章](ch5.md))和日志结构存储引擎(请参阅 [第三章](ch3.md))有相似之处。我们看到,这种方法对于消费输入流,并产生衍生状态或衍生输出数据流的系统而言特别适用。
基于日志的方法与数据库中的复制日志(请参阅 [第五章](/ch5))和日志结构存储引擎(请参阅 [第三章](/ch3))有相似之处。我们看到,这种方法对于消费输入流,并产生衍生状态或衍生输出数据流的系统而言特别适用。
就流的来源而言,我们讨论了几种可能性:用户活动事件,定期读数的传感器,和 Feed 数据(例如,金融中的市场数据)能够自然地表示为流。我们发现将数据库写入视作流也是很有用的:我们可以捕获变更日志 —— 即对数据库所做的所有变更的历史记录 —— 隐式地通过变更数据捕获,或显式地通过事件溯源。日志压缩允许流也能保有数据库内容的完整副本。
@ -822,9 +825,3 @@ Storm 的 Trident 基于类似的想法来处理状态【78】。依赖幂等性
1. E. N. (Mootaz) Elnozahy, Lorenzo Alvisi, Yi-Min Wang, and David B. Johnson: “[A Survey of Rollback-Recovery Protocols in Message-Passing Systems](http://www.cs.utexas.edu/~lorenzo/papers/SurveyFinal.pdf),” *ACM Computing Surveys*, volume 34, number 3, pages 375408, September 2002. [doi:10.1145/568522.568525](http://dx.doi.org/10.1145/568522.568525)
1. Adam Warski: “[Kafka Streams How Does It Fit the Stream Processing Landscape?](https://softwaremill.com/kafka-streams-how-does-it-fit-stream-landscape/),” *softwaremill.com*, June 1, 2016.
------
| 上一章 | 目录 | 下一章 |
| ------------------------- | ------------------------------- | ---------------------------------- |
| [第十章:批处理](ch10.md) | [设计数据密集型应用](README.md) | [第十二章:数据系统的未来](ch12.md) |

View File

@ -1,24 +1,26 @@
# 第十二章:数据系统的未来
---
title: "第十二章:数据系统的未来"
linkTitle: "12. 数据系统的未来"
weight: 312
breadcrumbs: false
---
![](img/ch12.png)
![](/img/ch12.png)
> 如果船长的终极目标是保护船只,他应该永远待在港口。
>
> —— 圣托马斯・阿奎那《神学大全》1265-1274
---------------
[TOC]
到目前为止,本书主要描述的是 **现状**。在这最后一章中,我们将放眼 **未来**,讨论应该是怎么样的:我将提出一些想法与方法,我相信它们能从根本上改进我们设计与构建应用的方式。
对未来的看法与推测当然具有很大的主观性。所以在撰写本章时,当提及我个人的观点时会使用第一人称。你完全可以不同意这些观点并提出自己的看法,但我希望本章中的概念,至少能成为富有成效的讨论出发点,并澄清一些经常被混淆的概念。
[第一章](ch1.md) 概述了本书的目标:探索如何创建 **可靠**、**可伸缩** 和 **可维护** 的应用与系统。这一主题贯穿了所有的章节:例如,我们讨论了许多有助于提高可靠性的容错算法,有助于提高可伸缩性的分区,以及有助于提高可维护性的演化与抽象机制。在本章中,我们将把所有这些想法结合在一起,并在它们的基础上展望未来。我们的目标是,发现如何设计出比现有应用更好的应用 —— 健壮、正确、可演化、且最终对人类有益。
[第一章](/ch1) 概述了本书的目标:探索如何创建 **可靠**、**可伸缩** 和 **可维护** 的应用与系统。这一主题贯穿了所有的章节:例如,我们讨论了许多有助于提高可靠性的容错算法,有助于提高可伸缩性的分区,以及有助于提高可维护性的演化与抽象机制。在本章中,我们将把所有这些想法结合在一起,并在它们的基础上展望未来。我们的目标是,发现如何设计出比现有应用更好的应用 —— 健壮、正确、可演化、且最终对人类有益。
## 数据集成
本书中反复出现的主题是,对于任何给定的问题都会有好几种解决方案,所有这些解决方案都有不同的优缺点与利弊权衡。例如在 [第三章](ch3.md) 讨论存储引擎时我们看到了日志结构存储、B 树以及列式存储。在 [第五章](ch5.md) 讨论复制时,我们看到了单领导者、多领导者和无领导者的方法。
本书中反复出现的主题是,对于任何给定的问题都会有好几种解决方案,所有这些解决方案都有不同的优缺点与利弊权衡。例如在 [第三章](/ch3) 讨论存储引擎时我们看到了日志结构存储、B 树以及列式存储。在 [第五章](/ch5) 讨论复制时,我们看到了单领导者、多领导者和无领导者的方法。
如果你有一个类似于 “我想存储一些数据并稍后再查询” 的问题,那么并没有一种正确的解决方案。但对于不同的具体环境,总会有不同的合适方法。软件实现通常必须选择一种特定的方法。使单条代码路径能做到稳定健壮且表现良好已经是一件非常困难的事情了 —— 尝试在单个软件中完成所有事情,几乎可以保证,实现效果会很差。
@ -32,7 +34,7 @@
例如,为了处理任意关键词的搜索查询,将 OLTP 数据库与全文搜索索引集成在一起是很常见的需求。尽管一些数据库(例如 PostgreSQL包含了全文索引功能对于简单的应用完全够了【1】但更复杂的搜索能力就需要专业的信息检索工具了。相反的是搜索索引通常不适合作为持久的记录系统因此许多应用需要组合这两种不同的工具以满足所有需求。
我们在 “[保持系统同步](ch11.md#保持系统同步)” 中接触过集成数据系统的问题。随着数据不同表示形式的增加,集成问题变得越来越困难。除了数据库和搜索索引之外,也许你需要在分析系统(数据仓库,或批处理和流处理系统)中维护数据副本;维护从原始数据中衍生的缓存,或反规范化的数据版本;将数据灌入机器学习、分类、排名或推荐系统中;或者基于数据变更发送通知。
我们在 “[保持系统同步](/ch11#保持系统同步)” 中接触过集成数据系统的问题。随着数据不同表示形式的增加,集成问题变得越来越困难。除了数据库和搜索索引之外,也许你需要在分析系统(数据仓库,或批处理和流处理系统)中维护数据副本;维护从原始数据中衍生的缓存,或反规范化的数据版本;将数据灌入机器学习、分类、排名或推荐系统中;或者基于数据变更发送通知。
令人惊讶的是我经常看到软件工程师做出这样的陈述“根据我的经验99% 的人只需要 X” 或者 “...... 不需要 X”对于各种各样的 X。我认为这种陈述更像是发言人自己的经验而不是技术实际上的实用性。可能对数据执行的操作其范围极其宽广。某人认为鸡肋而毫无意义的功能可能是别人的核心需求。当你拉高视角并考虑跨越整个组织范围的数据流时数据集成的需求往往就会变得明显起来。
@ -40,23 +42,23 @@
当需要在多个存储系统中维护相同数据的副本以满足不同的访问模式时,你要对输入和输出了如指掌:哪些数据先写入,哪些数据表示衍生自哪些来源?如何以正确的格式,将所有数据导入正确的地方?
例如,你可能会首先将数据写入 **记录系统** 数据库,捕获对该数据库所做的变更(请参阅 “[变更数据捕获](ch11.md#变更数据捕获)”然后将变更以相同的顺序应用于搜索索引。如果变更数据捕获CDC是更新索引的唯一方式则可以确定该索引完全派生自记录系统因此与其保持一致除软件错误外。写入数据库是向该系统提供新输入的唯一方式。
例如,你可能会首先将数据写入 **记录系统** 数据库,捕获对该数据库所做的变更(请参阅 “[变更数据捕获](/ch11#变更数据捕获)”然后将变更以相同的顺序应用于搜索索引。如果变更数据捕获CDC是更新索引的唯一方式则可以确定该索引完全派生自记录系统因此与其保持一致除软件错误外。写入数据库是向该系统提供新输入的唯一方式。
允许应用程序直接写入搜索索引和数据库引入了如 [图 11-4](img/fig11-4.png) 所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。
允许应用程序直接写入搜索索引和数据库引入了如 [图 11-4](/img/fig11-4.png) 所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。
如果你可以通过单个系统来提供所有用户输入,从而决定所有写入的排序,则通过按相同顺序处理写入,可以更容易地衍生出其他数据表示。这是状态机复制方法的一个应用,我们在 “[全序广播](ch9.md#全序广播)” 中看到。无论你使用变更数据捕获还是事件溯源日志,都不如简单的基于全序的决策原则更重要。
如果你可以通过单个系统来提供所有用户输入,从而决定所有写入的排序,则通过按相同顺序处理写入,可以更容易地衍生出其他数据表示。这是状态机复制方法的一个应用,我们在 “[全序广播](/ch9#全序广播)” 中看到。无论你使用变更数据捕获还是事件溯源日志,都不如简单的基于全序的决策原则更重要。
基于事件日志来更新衍生数据的系统,通常可以做到 **确定性****幂等性**(请参阅 “[幂等性](ch11.md#幂等性)”),使得从故障中恢复相当容易。
基于事件日志来更新衍生数据的系统,通常可以做到 **确定性****幂等性**(请参阅 “[幂等性](/ch11#幂等性)”),使得从故障中恢复相当容易。
#### 衍生数据与分布式事务
保持不同数据系统彼此一致的经典方法涉及分布式事务,如 “[原子提交与两阶段提交](ch9.md#原子提交与两阶段提交)” 中所述。与分布式事务相比,使用衍生数据系统的方法如何?
保持不同数据系统彼此一致的经典方法涉及分布式事务,如 “[原子提交与两阶段提交](/ch9#原子提交与两阶段提交)” 中所述。与分布式事务相比,使用衍生数据系统的方法如何?
在抽象层面,它们通过不同的方式达到类似的目标。分布式事务通过 **锁** 进行互斥来决定写入的顺序(请参阅 “[两阶段锁定](ch7.md#两阶段锁定)”),而 CDC 和事件溯源使用日志进行排序。分布式事务使用原子提交来确保变更只生效一次,而基于日志的系统通常基于 **确定性重试****幂等性**
在抽象层面,它们通过不同的方式达到类似的目标。分布式事务通过 **锁** 进行互斥来决定写入的顺序(请参阅 “[两阶段锁定](/ch7#两阶段锁定)”),而 CDC 和事件溯源使用日志进行排序。分布式事务使用原子提交来确保变更只生效一次,而基于日志的系统通常基于 **确定性重试****幂等性**
最大的不同之处在于事务系统通常提供 [线性一致性](ch9.md#线性一致性),这包含着有用的保证,例如 [读己之写](ch5.md#读己之写)。另一方面,衍生数据系统通常是异步更新的,因此它们默认不会提供相同的时序保证。
最大的不同之处在于事务系统通常提供 [线性一致性](/ch9#线性一致性),这包含着有用的保证,例如 [读己之写](/ch5#读己之写)。另一方面,衍生数据系统通常是异步更新的,因此它们默认不会提供相同的时序保证。
在愿意为分布式事务付出代价的有限场景中,它们已被成功应用。但是,我认为 XA 的容错能力和性能很差劲(请参阅 “[实践中的分布式事务](ch9.md#实践中的分布式事务)”),这严重限制了它的实用性。我相信为分布式事务设计一种更好的协议是可行的。但使这样一种协议被现有工具广泛接受是很有挑战的,且不是立竿见影的事。
在愿意为分布式事务付出代价的有限场景中,它们已被成功应用。但是,我认为 XA 的容错能力和性能很差劲(请参阅 “[实践中的分布式事务](/ch9#实践中的分布式事务)”),这严重限制了它的实用性。我相信为分布式事务设计一种更好的协议是可行的。但使这样一种协议被现有工具广泛接受是很有挑战的,且不是立竿见影的事。
在没有广泛支持的良好分布式事务协议的情况下,我认为基于日志的衍生数据是集成不同数据系统的最有前途的方法。然而,诸如读己之写的保证是有用的,我认为告诉所有人 “最终一致性是不可避免的 —— 忍一忍并学会和它打交道” 是没有什么建设性的(至少在缺乏 **如何** 应对的良好指导时)。
@ -66,26 +68,26 @@
对于足够小的系统,构建一个完全有序的事件日志是完全可行的(正如单主复制数据库的流行所证明的那样,它正好建立了这样一种日志)。但是,随着系统向更大更复杂的工作负载伸缩,限制开始出现:
* 在大多数情况下,构建完全有序的日志,需要所有事件汇集于决定顺序的 **单个领导者节点**。如果事件吞吐量大于单台计算机的处理能力,则需要将其分区到多台计算机上(请参阅 “[分区日志](ch11.md#分区日志)”)。然后两个不同分区中的事件顺序关系就不明确了。
* 如果服务器分布在多个 **地理位置分散** 的数据中心上,例如为了容忍整个数据中心掉线,你通常在每个数据中心都有单独的主库,因为网络延迟会导致同步的跨数据中心协调效率低下(请参阅 “[多主复制](ch5.md#多主复制)”)。这意味着源自两个不同数据中心的事件顺序未定义。
* 将应用程序部署为微服务时(请参阅 “[服务中的数据流REST 与 RPC](ch4.md#服务中的数据流REST与RPC)”),常见的设计选择是将每个服务及其持久状态作为独立单元进行部署,服务之间不共享持久状态。当两个事件来自不同的服务时,这些事件间的顺序未定义。
* 某些应用程序在客户端保存状态,该状态在用户输入时立即更新(无需等待服务器确认),甚至可以继续脱机工作(请参阅 “[需要离线操作的客户端](ch5.md#需要离线操作的客户端)”)。对于这样的应用程序,客户端和服务器很可能以不同的顺序看到事件。
* 在大多数情况下,构建完全有序的日志,需要所有事件汇集于决定顺序的 **单个领导者节点**。如果事件吞吐量大于单台计算机的处理能力,则需要将其分区到多台计算机上(请参阅 “[分区日志](/ch11#分区日志)”)。然后两个不同分区中的事件顺序关系就不明确了。
* 如果服务器分布在多个 **地理位置分散** 的数据中心上,例如为了容忍整个数据中心掉线,你通常在每个数据中心都有单独的主库,因为网络延迟会导致同步的跨数据中心协调效率低下(请参阅 “[多主复制](/ch5#多主复制)”)。这意味着源自两个不同数据中心的事件顺序未定义。
* 将应用程序部署为微服务时(请参阅 “[服务中的数据流REST 与 RPC](/ch4#服务中的数据流REST与RPC)”),常见的设计选择是将每个服务及其持久状态作为独立单元进行部署,服务之间不共享持久状态。当两个事件来自不同的服务时,这些事件间的顺序未定义。
* 某些应用程序在客户端保存状态,该状态在用户输入时立即更新(无需等待服务器确认),甚至可以继续脱机工作(请参阅 “[需要离线操作的客户端](/ch5#需要离线操作的客户端)”)。对于这样的应用程序,客户端和服务器很可能以不同的顺序看到事件。
在形式上,决定事件的全局顺序称为 **全序广播**,相当于 **共识**(请参阅 “[共识算法和全序广播](ch9.md#共识算法和全序广播)”)。大多数共识算法都是针对单个节点的吞吐量足以处理整个事件流的情况而设计的,并且这些算法不提供多个节点共享事件排序工作的机制。设计可以伸缩至单个节点的吞吐量之上,且在地理位置分散的环境中仍然工作良好的的共识算法仍然是一个开放的研究问题。
在形式上,决定事件的全局顺序称为 **全序广播**,相当于 **共识**(请参阅 “[共识算法和全序广播](/ch9#共识算法和全序广播)”)。大多数共识算法都是针对单个节点的吞吐量足以处理整个事件流的情况而设计的,并且这些算法不提供多个节点共享事件排序工作的机制。设计可以伸缩至单个节点的吞吐量之上,且在地理位置分散的环境中仍然工作良好的的共识算法仍然是一个开放的研究问题。
#### 排序事件以捕获因果关系
在事件之间不存在因果关系的情况下,全序的缺乏并不是一个大问题,因为并发事件可以任意排序。其他一些情况很容易处理:例如,当同一对象有多个更新时,它们可以通过将特定对象 ID 的所有更新路由到相同的日志分区来完全排序。然而,因果关系有时会以更微妙的方式出现(请参阅 “[顺序与因果关系](ch9.md#顺序与因果关系)”)。
在事件之间不存在因果关系的情况下,全序的缺乏并不是一个大问题,因为并发事件可以任意排序。其他一些情况很容易处理:例如,当同一对象有多个更新时,它们可以通过将特定对象 ID 的所有更新路由到相同的日志分区来完全排序。然而,因果关系有时会以更微妙的方式出现(请参阅 “[顺序与因果关系](/ch9#顺序与因果关系)”)。
例如,考虑一个社交网络服务,以及一对曾处于恋爱关系但刚分手的用户。其中一个用户将另一个用户从好友中移除,然后向剩余的好友发送消息,抱怨他们的前任。用户的心思是他们的前任不应该看到这些粗鲁的消息,因为消息是在好友状态解除后发送的。
但是如果好友关系状态与消息存储在不同的地方,在这样一个系统中,可能会出现 **解除好友** 事件与 **发送消息** 事件之间的因果依赖丢失的情况。如果因果依赖关系没有被捕捉到,则发送有关新消息的通知的服务可能会在 **解除好友** 事件之前处理 **发送消息** 事件,从而错误地向前任发送通知。
在本例中,通知实际上是消息和好友列表之间的连接,使得它与我们先前讨论的连接的时序问题有关(请参阅 “[连接的时间依赖性](ch11.md#连接的时间依赖性)”。不幸的是这个问题似乎并没有一个简单的答案【2,3】。起点包括
在本例中,通知实际上是消息和好友列表之间的连接,使得它与我们先前讨论的连接的时序问题有关(请参阅 “[连接的时间依赖性](/ch11#连接的时间依赖性)”。不幸的是这个问题似乎并没有一个简单的答案【2,3】。起点包括
* 逻辑时间戳可以提供无需协调的全局顺序(请参阅 “[序列号顺序](ch9.md#序列号顺序)”),因此它们可能有助于全序广播不可行的情况。但是,他们仍然要求收件人处理不按顺序发送的事件,并且需要传递其他元数据。
* 逻辑时间戳可以提供无需协调的全局顺序(请参阅 “[序列号顺序](/ch9#序列号顺序)”),因此它们可能有助于全序广播不可行的情况。但是,他们仍然要求收件人处理不按顺序发送的事件,并且需要传递其他元数据。
* 如果你可以记录一个事件来记录用户在做出决定之前所看到的系统状态并给该事件一个唯一的标识符那么后面的任何事件都可以引用该事件标识符来记录因果关系【4】。我们将在 “[读也是事件](#读也是事件)” 中回到这个想法。
* 冲突解决算法(请参阅 “[自动冲突解决](ch5.md#自动冲突解决)”)有助于处理以意外顺序传递的事件。它们对于维护状态很有用,但如果行为有外部副作用(例如,给用户发送通知),就没什么帮助了。
* 冲突解决算法(请参阅 “[自动冲突解决](/ch5#自动冲突解决)”)有助于处理以意外顺序传递的事件。它们对于维护状态很有用,但如果行为有外部副作用(例如,给用户发送通知),就没什么帮助了。
也许,随着时间的推移,应用开发模式将出现,使得能够有效地捕获因果依赖关系,并且保持正确的衍生状态,而不会迫使所有事件经历全序广播的瓶颈)。
@ -93,27 +95,27 @@
我会说数据集成的目标是,确保数据最终能在所有正确的地方表现出正确的形式。这样做需要消费输入、转换、连接、过滤、聚合、训练模型、评估、以及最终写出适当的输出。批处理和流处理是实现这一目标的工具。
批处理和流处理的输出是衍生数据集,例如搜索索引、物化视图、向用户显示的建议、聚合指标等(请参阅 “[批处理工作流的输出](ch10.md#批处理工作流的输出)” 和 “[流处理的应用](ch11.md#流处理的应用)”)。
批处理和流处理的输出是衍生数据集,例如搜索索引、物化视图、向用户显示的建议、聚合指标等(请参阅 “[批处理工作流的输出](/ch10#批处理工作流的输出)” 和 “[流处理的应用](/ch11#流处理的应用)”)。
正如我们在 [第十章](ch10.md) 和 [第十一章](ch11.md) 中看到的,批处理和流处理有许多共同的原则,主要的根本区别在于流处理器在无限数据集上运行,而批处理输入是已知的有限大小。处理引擎的实现方式也有很多细节上的差异,但是这些区别已经开始模糊。
正如我们在 [第十章](/ch10) 和 [第十一章](/ch11) 中看到的,批处理和流处理有许多共同的原则,主要的根本区别在于流处理器在无限数据集上运行,而批处理输入是已知的有限大小。处理引擎的实现方式也有很多细节上的差异,但是这些区别已经开始模糊。
Spark 在批处理引擎上执行流处理,将流分解为 **微批次microbatches**,而 Apache Flink 则在流处理引擎上执行批处理【5】。原则上一种类型的处理可以用另一种类型来模拟但是性能特征会有所不同例如在跳跃或滑动窗口上微批次可能表现不佳【6】。
#### 维护衍生状态
批处理有着很强的函数式风格(即使其代码不是用函数式语言编写的):它鼓励确定性的纯函数,其输出仅依赖于输入,除了显式输出外没有副作用,将输入视作不可变的,且输出是仅追加的。流处理与之类似,但它扩展了算子以允许受管理的、容错的状态(请参阅 “[失败后重建状态”](ch11.md#失败后重建状态))。
批处理有着很强的函数式风格(即使其代码不是用函数式语言编写的):它鼓励确定性的纯函数,其输出仅依赖于输入,除了显式输出外没有副作用,将输入视作不可变的,且输出是仅追加的。流处理与之类似,但它扩展了算子以允许受管理的、容错的状态(请参阅 “[失败后重建状态”](/ch11#失败后重建状态))。
具有良好定义的输入和输出的确定性函数的原理不仅有利于容错(请参阅 “[幂等性](ch11.md#幂等性)”也简化了有关组织中数据流的推理【7】。无论衍生数据是搜索索引、统计模型还是缓存采用这种观点思考都是很有帮助的将其视为从一个东西衍生出另一个的数据管道通过函数式应用代码推送一个系统的状态变更并将其效果应用至衍生系统中。
具有良好定义的输入和输出的确定性函数的原理不仅有利于容错(请参阅 “[幂等性](/ch11#幂等性)”也简化了有关组织中数据流的推理【7】。无论衍生数据是搜索索引、统计模型还是缓存采用这种观点思考都是很有帮助的将其视为从一个东西衍生出另一个的数据管道通过函数式应用代码推送一个系统的状态变更并将其效果应用至衍生系统中。
原则上,衍生数据系统可以同步地维护,就像关系数据库在与索引表写入操作相同的事务中同步更新次级索引一样。然而,异步是使基于事件日志的系统稳健的原因:它允许系统的一部分故障被抑制在本地。而如果任何一个参与者失败,分布式事务将中止,因此它们倾向于通过将故障传播到系统的其余部分来放大故障(请参阅 “[分布式事务的限制](ch9.md#分布式事务的限制)”)。
原则上,衍生数据系统可以同步地维护,就像关系数据库在与索引表写入操作相同的事务中同步更新次级索引一样。然而,异步是使基于事件日志的系统稳健的原因:它允许系统的一部分故障被抑制在本地。而如果任何一个参与者失败,分布式事务将中止,因此它们倾向于通过将故障传播到系统的其余部分来放大故障(请参阅 “[分布式事务的限制](/ch9#分布式事务的限制)”)。
我们在 “[分区与次级索引](ch6.md#分区与次级索引)” 中看到次级索引经常跨越分区边界。具有次级索引的分区系统需要将写入发送到多个分区如果索引按关键词分区的话或将读取发送到所有分区如果索引是按文档分区的话。如果索引是异步维护的这种跨分区通信也是最可靠和最可伸缩的【8】另请参阅 “[多分区数据处理](#多分区数据处理)”)。
我们在 “[分区与次级索引](/ch6#分区与次级索引)” 中看到次级索引经常跨越分区边界。具有次级索引的分区系统需要将写入发送到多个分区如果索引按关键词分区的话或将读取发送到所有分区如果索引是按文档分区的话。如果索引是异步维护的这种跨分区通信也是最可靠和最可伸缩的【8】另请参阅 “[多分区数据处理](#多分区数据处理)”)。
#### 应用演化后重新处理数据
在维护衍生数据时,批处理和流处理都是有用的。流处理允许将输入中的变化以低延迟反映在衍生视图中,而批处理允许重新处理大量累积的历史数据以便将新视图导出到现有数据集上。
特别是,重新处理现有数据为维护系统、演化并支持新功能和需求变更提供了一个良好的机制(请参阅 [第四章](ch4.md))。没有重新进行处理,模式演化将仅限于简单的变化,例如向记录中添加新的可选字段或添加新类型的记录。无论是在写时模式还是在读时模式中都是如此(请参阅 “[文档模型中的模式灵活性](ch2.md#文档模型中的模式灵活性)”)。另一方面,通过重新处理,可以将数据集重组为一个完全不同的模型,以便更好地满足新的要求。
特别是,重新处理现有数据为维护系统、演化并支持新功能和需求变更提供了一个良好的机制(请参阅 [第四章](/ch4))。没有重新进行处理,模式演化将仅限于简单的变化,例如向记录中添加新的可选字段或添加新类型的记录。无论是在写时模式还是在读时模式中都是如此(请参阅 “[文档模型中的模式灵活性](/ch2#文档模型中的模式灵活性)”)。另一方面,通过重新处理,可以将数据集重组为一个完全不同的模型,以便更好地满足新的要求。
> ### 铁路上的模式迁移
>
@ -131,15 +133,15 @@ Spark 在批处理引擎上执行流处理,将流分解为 **微批次micro
如果批处理用于重新处理历史数据而流处理用于处理最近的更新那么如何将这两者结合起来Lambda 架构【12】是这方面的一个建议引起了很多关注。
Lambda 架构的核心思想是通过将不可变事件附加到不断增长的数据集来记录传入数据,这类似于事件溯源(请参阅 “[事件溯源](ch11.md#事件溯源)”。为了从这些事件中衍生出读取优化的视图Lambda 架构建议并行运行两个不同的系统:批处理系统(如 Hadoop MapReduce和独立的流处理系统如 Storm
Lambda 架构的核心思想是通过将不可变事件附加到不断增长的数据集来记录传入数据,这类似于事件溯源(请参阅 “[事件溯源](/ch11#事件溯源)”。为了从这些事件中衍生出读取优化的视图Lambda 架构建议并行运行两个不同的系统:批处理系统(如 Hadoop MapReduce和独立的流处理系统如 Storm
在 Lambda 方法中,流处理器消耗事件并快速生成对视图的近似更新;批处理器稍后将使用同一组事件并生成衍生视图的更正版本。这个设计背后的原因是批处理更简单,因此不易出错,而流处理器被认为是不太可靠和难以容错的(请参阅 “[容错](ch11.md#容错)”)。而且,流处理可以使用快速近似算法,而批处理使用较慢的精确算法。
在 Lambda 方法中,流处理器消耗事件并快速生成对视图的近似更新;批处理器稍后将使用同一组事件并生成衍生视图的更正版本。这个设计背后的原因是批处理更简单,因此不易出错,而流处理器被认为是不太可靠和难以容错的(请参阅 “[容错](/ch11#容错)”)。而且,流处理可以使用快速近似算法,而批处理使用较慢的精确算法。
Lambda 架构是一种有影响力的想法,它将数据系统的设计变得更好,尤其是通过推广这样的原则:在不可变事件流上建立衍生视图,并在需要时重新处理事件。但是我也认为它有一些实际问题:
* 在批处理和流处理框架中维护相同的逻辑是很显著的额外工作。虽然像 Summingbird【13】这样的库提供了一种可以在批处理和流处理的上下文中运行的计算抽象。调试、调整和维护两个不同系统的操作复杂性依然存在【14】。
* 由于流管道和批处理管道产生独立的输出,因此需要合并它们以响应用户请求。如果计算是基于滚动窗口的简单聚合,则合并相当容易,但如果视图基于更复杂的操作(例如连接和会话化)而导出,或者输出不是时间序列,则会变得非常困难。
* 尽管有能力重新处理整个历史数据集是很好的,但在大型数据集上这样做经常会开销巨大。因此,批处理流水线通常需要设置为处理增量批处理(例如,在每小时结束时处理一小时的数据),而不是重新处理所有内容。这引发了 “[时间推理](ch11.md#时间推理)” 中讨论的问题,例如处理滞留事件和处理跨批次边界的窗口。增量化批处理计算会增加复杂性,使其更类似于流式传输层,这与保持批处理层尽可能简单的目标背道而驰。
* 尽管有能力重新处理整个历史数据集是很好的,但在大型数据集上这样做经常会开销巨大。因此,批处理流水线通常需要设置为处理增量批处理(例如,在每小时结束时处理一小时的数据),而不是重新处理所有内容。这引发了 “[时间推理](/ch11#时间推理)” 中讨论的问题,例如处理滞留事件和处理跨批次边界的窗口。增量化批处理计算会增加复杂性,使其更类似于流式传输层,这与保持批处理层尽可能简单的目标背道而驰。
#### 统一批处理和流处理
@ -147,14 +149,14 @@ Lambda 架构是一种有影响力的想法,它将数据系统的设计变得
在一个系统中统一批处理和流处理需要以下功能,这些功能也正在越来越广泛地被提供:
* 通过处理最近事件流的相同处理引擎来重播历史事件的能力。例如,基于日志的消息代理可以重播消息(请参阅 “[重播旧消息](ch11.md#重播旧消息)”),某些流处理器可以从 HDFS 等分布式文件系统读取输入。
* 对于流处理器来说,恰好一次语义 —— 即确保输出与未发生故障的输出相同,即使事实上发生故障(请参阅 “[容错](ch11.md#容错)”)。与批处理一样,这需要丢弃任何失败任务的部分输出。
* 按事件时间进行窗口化的工具,而不是按处理时间进行窗口化,因为处理历史事件时,处理时间毫无意义(请参阅 “[时间推理](ch11.md#时间推理)”。例如Apache Beam 提供了用于表达这种计算的 API可以在 Apache Flink 或 Google Cloud Dataflow 使用。
* 通过处理最近事件流的相同处理引擎来重播历史事件的能力。例如,基于日志的消息代理可以重播消息(请参阅 “[重播旧消息](/ch11#重播旧消息)”),某些流处理器可以从 HDFS 等分布式文件系统读取输入。
* 对于流处理器来说,恰好一次语义 —— 即确保输出与未发生故障的输出相同,即使事实上发生故障(请参阅 “[容错](/ch11#容错)”)。与批处理一样,这需要丢弃任何失败任务的部分输出。
* 按事件时间进行窗口化的工具,而不是按处理时间进行窗口化,因为处理历史事件时,处理时间毫无意义(请参阅 “[时间推理](/ch11#时间推理)”。例如Apache Beam 提供了用于表达这种计算的 API可以在 Apache Flink 或 Google Cloud Dataflow 使用。
## 分拆数据库
在最抽象的层面上数据库Hadoop 和操作系统都发挥相同的功能它们存储一些数据并允许你处理和查询这些数据【16】。数据库将数据存储为特定数据模型的记录表中的行、文档、图中的顶点等而操作系统的文件系统则将数据存储在文件中 —— 但其核心都是 “信息管理” 系统【17】。正如我们在 [第十章](ch10.md) 中看到的Hadoop 生态系统有点像 Unix 的分布式版本。
在最抽象的层面上数据库Hadoop 和操作系统都发挥相同的功能它们存储一些数据并允许你处理和查询这些数据【16】。数据库将数据存储为特定数据模型的记录表中的行、文档、图中的顶点等而操作系统的文件系统则将数据存储在文件中 —— 但其核心都是 “信息管理” 系统【17】。正如我们在 [第十章](/ch10) 中看到的Hadoop 生态系统有点像 Unix 的分布式版本。
当然,有很多实际的差异。例如,许多文件系统都不能很好地处理包含 1000 万个小文件的目录,而包含 1000 万个小记录的数据库完全是寻常而不起眼的。无论如何,操作系统和数据库之间的相似之处和差异值得探讨。
@ -170,12 +172,12 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
在本书的过程中,我们讨论了数据库提供的各种功能及其工作原理,其中包括:
* 次级索引,使你可以根据字段的值有效地搜索记录(请参阅 “[其他索引结构](ch3.md#其他索引结构)”)
* 物化视图,这是一种预计算的查询结果缓存(请参阅 “[聚合:数据立方体和物化视图](ch3.md#聚合:数据立方体和物化视图)”)
* 复制日志,保持其他节点上数据的副本最新(请参阅 “[复制日志的实现](ch5.md#复制日志的实现)”)
* 全文搜索索引,允许在文本中进行关键字搜索(请参阅 “[全文搜索和模糊索引](ch3.md#全文搜索和模糊索引)”也内置于某些关系数据库【1】
* 次级索引,使你可以根据字段的值有效地搜索记录(请参阅 “[其他索引结构](/ch3#其他索引结构)”)
* 物化视图,这是一种预计算的查询结果缓存(请参阅 “[聚合:数据立方体和物化视图](/ch3#聚合:数据立方体和物化视图)”)
* 复制日志,保持其他节点上数据的副本最新(请参阅 “[复制日志的实现](/ch5#复制日志的实现)”)
* 全文搜索索引,允许在文本中进行关键字搜索(请参阅 “[全文搜索和模糊索引](/ch3#全文搜索和模糊索引)”也内置于某些关系数据库【1】
在 [第十章](ch10.md) 和 [第十一章](ch11.md) 中,出现了类似的主题。我们讨论了如何构建全文搜索索引(请参阅 “[批处理工作流的输出](ch10.md#批处理工作流的输出)”),了解了如何维护物化视图(请参阅 “[维护物化视图](ch11.md#维护物化视图)”)以及如何将变更从数据库复制到衍生数据系统(请参阅 “[变更数据捕获](ch11.md#变更数据捕获)”)。
在 [第十章](/ch10) 和 [第十一章](/ch11) 中,出现了类似的主题。我们讨论了如何构建全文搜索索引(请参阅 “[批处理工作流的输出](/ch10#批处理工作流的输出)”),了解了如何维护物化视图(请参阅 “[维护物化视图](/ch11#维护物化视图)”)以及如何将变更从数据库复制到衍生数据系统(请参阅 “[变更数据捕获](/ch11#变更数据捕获)”)。
数据库中内置的功能与人们用批处理和流处理器构建的衍生数据系统似乎有相似之处。
@ -183,15 +185,15 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
想想当你运行 `CREATE INDEX` 在关系数据库中创建一个新的索引时会发生什么。数据库必须扫描表的一致性快照,挑选出所有被索引的字段值,对它们进行排序,然后写出索引。然后它必须处理自一致快照以来所做的写入操作(假设表在创建索引时未被锁定,所以写操作可能会继续)。一旦完成,只要事务写入表中,数据库就必须继续保持索引最新。
此过程非常类似于设置新的从库副本(请参阅 “[设置新从库](ch5.md#设置新从库)”),也非常类似于流处理系统中的 **引导bootstrap** 变更数据捕获(请参阅 “[初始快照](ch11.md#初始快照)”)。
此过程非常类似于设置新的从库副本(请参阅 “[设置新从库](/ch5#设置新从库)”),也非常类似于流处理系统中的 **引导bootstrap** 变更数据捕获(请参阅 “[初始快照](/ch11#初始快照)”)。
无论何时运行 `CREATE INDEX`,数据库都会重新处理现有数据集(如 “[应用演化后重新处理数据](#应用演化后重新处理数据)” 中所述),并将该索引作为新视图导出到现有数据上。现有数据可能是状态的快照,而不是所有发生变化的日志,但两者密切相关(请参阅 “[状态、流和不变性](ch11.md#状态、流和不变性)”)。
无论何时运行 `CREATE INDEX`,数据库都会重新处理现有数据集(如 “[应用演化后重新处理数据](#应用演化后重新处理数据)” 中所述),并将该索引作为新视图导出到现有数据上。现有数据可能是状态的快照,而不是所有发生变化的日志,但两者密切相关(请参阅 “[状态、流和不变性](/ch11#状态、流和不变性)”)。
#### 一切的元数据库
有鉴于此我认为整个组织的数据流开始像一个巨大的数据库【7】。每当批处理、流或 ETL 过程将数据从一个地方传输到另一个地方并组装时,它表现地就像数据库子系统一样,使索引或物化视图保持最新。
从这种角度来看,批处理和流处理器就像精心实现的触发器、存储过程和物化视图维护例程。它们维护的衍生数据系统就像不同的索引类型。例如,关系数据库可能支持 B 树索引、散列索引、空间索引(请参阅 “[多列索引](ch3.md#多列索引)”)以及其他类型的索引。在新兴的衍生数据系统架构中,不是将这些设施作为单个集成数据库产品的功能实现,而是由各种不同的软件提供,运行在不同的机器上,由不同的团队管理。
从这种角度来看,批处理和流处理器就像精心实现的触发器、存储过程和物化视图维护例程。它们维护的衍生数据系统就像不同的索引类型。例如,关系数据库可能支持 B 树索引、散列索引、空间索引(请参阅 “[多列索引](/ch3#多列索引)”)以及其他类型的索引。在新兴的衍生数据系统架构中,不是将这些设施作为单个集成数据库产品的功能实现,而是由各种不同的软件提供,运行在不同的机器上,由不同的团队管理。
这些发展在未来将会把我们带到哪里?如果我们从没有适合所有访问模式的单一数据模型或存储格式的前提出发,我推测有两种途径可以将不同的存储和处理工具组合成一个有凝聚力的系统:
@ -213,20 +215,20 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
传统的同步写入方法需要跨异构存储系统的分布式事务【18】我认为这是错误的解决方案请参阅 “[衍生数据与分布式事务](#衍生数据与分布式事务)”)。单个存储或流处理系统内的事务是可行的,但是当数据跨越不同技术之间的边界时,我认为具有幂等写入的异步事件日志是一种更加健壮和实用的方法。
例如,分布式事务在某些流处理组件内部使用,以匹配 **恰好一次exactly-once** 语义(请参阅 “[原子提交再现](ch11.md#原子提交再现)”),这可以很好地工作。然而,当事务需要涉及由不同人群编写的系统时(例如,当数据从流处理组件写入分布式键值存储或搜索索引时),缺乏标准化的事务协议会使集成更难。有幂等消费者的有序事件日志(请参阅 “[幂等性](ch11.md#幂等性)”是一种更简单的抽象因此在异构系统中实现更加可行【7】。
例如,分布式事务在某些流处理组件内部使用,以匹配 **恰好一次exactly-once** 语义(请参阅 “[原子提交再现](/ch11#原子提交再现)”),这可以很好地工作。然而,当事务需要涉及由不同人群编写的系统时(例如,当数据从流处理组件写入分布式键值存储或搜索索引时),缺乏标准化的事务协议会使集成更难。有幂等消费者的有序事件日志(请参阅 “[幂等性](/ch11#幂等性)”是一种更简单的抽象因此在异构系统中实现更加可行【7】。
基于日志的集成的一大优势是各个组件之间的 **松散耦合loose coupling**,这体现在两个方面:
1. 在系统级别,异步事件流使整个系统在个别组件的中断或性能下降时更加稳健。如果消费者运行缓慢或失败,那么事件日志可以缓冲消息(请参阅 “[磁盘空间使用](ch11.md#磁盘空间使用)”),以便生产者和任何其他消费者可以继续不受影响地运行。有问题的消费者可以在问题修复后赶上,因此不会错过任何数据,并且包含故障。相比之下,分布式事务的同步交互往往会将本地故障升级为大规模故障(请参阅 “[分布式事务的限制](ch9.md#分布式事务的限制)”)。
1. 在系统级别,异步事件流使整个系统在个别组件的中断或性能下降时更加稳健。如果消费者运行缓慢或失败,那么事件日志可以缓冲消息(请参阅 “[磁盘空间使用](/ch11#磁盘空间使用)”),以便生产者和任何其他消费者可以继续不受影响地运行。有问题的消费者可以在问题修复后赶上,因此不会错过任何数据,并且包含故障。相比之下,分布式事务的同步交互往往会将本地故障升级为大规模故障(请参阅 “[分布式事务的限制](/ch9#分布式事务的限制)”)。
2. 在人力方面,分拆数据系统允许不同的团队独立开发,改进和维护不同的软件组件和服务。专业化使得每个团队都可以专注于做好一件事,并与其他团队的系统以明确的接口交互。事件日志提供了一个足够强大的接口,以捕获相当强的一致性属性(由于持久性和事件的顺序),但也足够普适于几乎任何类型的数据。
#### 分拆系统vs集成系统
如果分拆确实成为未来的方式,它也不会取代目前形式的数据库 —— 它们仍然会像以往一样被需要。为了维护流处理组件中的状态,数据库仍然是需要的,并且为批处理和流处理器的输出提供查询服务(请参阅 “[批处理工作流的输出](ch10.md#批处理工作流的输出)” 与 “[流处理](ch11.md#流处理)”。专用查询引擎对于特定的工作负载仍然非常重要例如MPP 数据仓库中的查询引擎针对探索性分析查询进行了优化,并且能够很好地处理这种类型的工作负载(请参阅 “[Hadoop 与分布式数据库的对比](ch10.md#Hadoop与分布式数据库的对比)”)。
如果分拆确实成为未来的方式,它也不会取代目前形式的数据库 —— 它们仍然会像以往一样被需要。为了维护流处理组件中的状态,数据库仍然是需要的,并且为批处理和流处理器的输出提供查询服务(请参阅 “[批处理工作流的输出](/ch10#批处理工作流的输出)” 与 “[流处理](/ch11#流处理)”。专用查询引擎对于特定的工作负载仍然非常重要例如MPP 数据仓库中的查询引擎针对探索性分析查询进行了优化,并且能够很好地处理这种类型的工作负载(请参阅 “[Hadoop 与分布式数据库的对比](/ch10#Hadoop与分布式数据库的对比)”)。
运行几种不同基础设施的复杂性可能是一个问题每种软件都有一个学习曲线配置问题和操作怪癖因此部署尽可能少的移动部件是很有必要的。比起使用应用代码拼接多个工具而成的系统单一集成软件产品也可以在其设计应对的工作负载类型上实现更好、更可预测的性能【23】。正如在前言中所说的那样为了不需要的规模而构建系统是白费精力而且可能会将你锁死在一个不灵活的设计中。实际上这是一种过早优化的形式。
分拆的目标不是要针对个别数据库与特定工作负载的性能进行竞争;我们的目标是允许你结合多个不同的数据库,以便在比单个软件可能实现的更广泛的工作负载范围内实现更好的性能。这是关于广度,而不是深度 —— 与我们在 “[Hadoop 与分布式数据库的对比](ch10.md#Hadoop与分布式数据库的对比)” 中讨论的存储和处理模型的多样性一样。
分拆的目标不是要针对个别数据库与特定工作负载的性能进行竞争;我们的目标是允许你结合多个不同的数据库,以便在比单个软件可能实现的更广泛的工作负载范围内实现更好的性能。这是关于广度,而不是深度 —— 与我们在 “[Hadoop 与分布式数据库的对比](/ch10#Hadoop与分布式数据库的对比)” 中讨论的存储和处理模型的多样性一样。
因此,如果有一项技术可以满足你的所有需求,那么最好使用该产品,而不是试图用更低层级的组件重新实现它。只有当没有单一软件满足你的所有需求时,才会出现拆分和联合的优势。
@ -236,7 +238,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
例如,如果我们可以简单地声明 `mysql | elasticsearch`,类似于 Unix 管道【22】成为 `CREATE INDEX` 的分拆等价物:它将读取 MySQL 数据库中的所有文档并将其索引到 Elasticsearch 集群中。然后它会不断捕获对数据库所做的所有变更,并自动将它们应用于搜索索引,而无需编写自定义应用代码。这种集成应当支持几乎任何类型的存储或索引系统。
同样,能够更容易地预先计算和更新缓存将是一件好事。回想一下,物化视图本质上是一个预先计算的缓存,所以你可以通过为复杂查询声明指定物化视图来创建缓存,包括图上的递归查询(请参阅 “[图数据模型](ch2.md#图数据模型)”)和应用逻辑。在这方面有一些有趣的早期研究,如 **差分数据流differential dataflow**【24,25】我希望这些想法能够在生产系统中找到自己的方法。
同样,能够更容易地预先计算和更新缓存将是一件好事。回想一下,物化视图本质上是一个预先计算的缓存,所以你可以通过为复杂查询声明指定物化视图来创建缓存,包括图上的递归查询(请参阅 “[图数据模型](/ch2#图数据模型)”)和应用逻辑。在这方面有一些有趣的早期研究,如 **差分数据流differential dataflow**【24,25】我希望这些想法能够在生产系统中找到自己的方法。
### 围绕数据流设计应用
@ -254,14 +256,14 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
当一个数据集衍生自另一个数据集时,它会经历某种转换函数。例如:
* 次级索引是由一种直白的转换函数生成的衍生数据集:对于基础表中的每行或每个文档,它挑选被索引的列或字段中的值,并按这些值排序(假设使用 B 树或 SSTable 索引,按键排序,如 [第三章](ch3.md) 所述)。
* 次级索引是由一种直白的转换函数生成的衍生数据集:对于基础表中的每行或每个文档,它挑选被索引的列或字段中的值,并按这些值排序(假设使用 B 树或 SSTable 索引,按键排序,如 [第三章](/ch3) 所述)。
* 全文搜索索引是通过应用各种自然语言处理函数而创建的,诸如语言检测、分词、词干或词汇化、拼写纠正和同义词识别,然后构建用于高效查找的数据结构(例如倒排索引)。
* 在机器学习系统中,我们可以将模型视作从训练数据通过应用各种特征提取、统计分析函数衍生的数据,当模型应用于新的输入数据时,模型的输出是从输入和模型(因此间接地从训练数据)中衍生的。
* 缓存通常包含将以用户界面UI显示的形式的数据聚合。因此填充缓存需要知道 UI 中引用的字段UI 中的变更可能需要更新缓存填充方式的定义,并重建缓存。
用于次级索引的衍生函数是如此常用的需求,以致于它作为核心功能被内建至许多数据库中,你可以简单地通过 `CREATE INDEX` 来调用它。对于全文索引常见语言的基本语言特征可能内置到数据库中但更复杂的特征通常需要领域特定的调整。在机器学习中特征工程是众所周知的特定于应用的特征通常需要包含很多关于用户交互与应用部署的详细知识【35】。
当创建衍生数据集的函数不是像创建次级索引那样的标准搬砖函数时,需要自定义代码来处理特定于应用的东西。而这个自定义代码是让许多数据库挣扎的地方,虽然关系数据库通常支持触发器、存储过程和用户定义的函数,可以用它们来在数据库中执行应用代码,但它们有点像数据库设计里的事后反思。(请参阅 “[传递事件流](ch11.md#传递事件流)”)。
当创建衍生数据集的函数不是像创建次级索引那样的标准搬砖函数时,需要自定义代码来处理特定于应用的东西。而这个自定义代码是让许多数据库挣扎的地方,虽然关系数据库通常支持触发器、存储过程和用户定义的函数,可以用它们来在数据库中执行应用代码,但它们有点像数据库设计里的事后反思。(请参阅 “[传递事件流](/ch11#传递事件流)”)。
#### 应用代码和状态的分离
@ -279,19 +281,19 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
但是,在大多数编程语言中,你无法订阅可变变量中的变更 —— 你只能定期读取它。与电子表格不同,如果变量的值发生变化,变量的读者不会收到通知(你可以在自己的代码中实现这样的通知 —— 这被称为 **观察者模式** —— 但大多数语言没有将这种模式作为内置功能)。
数据库继承了这种可变数据的被动方法:如果你想知道数据库的内容是否发生了变化,通常你唯一的选择就是轮询(即定期重复你的查询)。订阅变更只是刚刚开始出现的功能(请参阅 “[变更流的 API 支持](ch11.md#变更流的API支持)”)。
数据库继承了这种可变数据的被动方法:如果你想知道数据库的内容是否发生了变化,通常你唯一的选择就是轮询(即定期重复你的查询)。订阅变更只是刚刚开始出现的功能(请参阅 “[变更流的 API 支持](/ch11#变更流的API支持)”)。
#### 数据流:应用代码与状态变化的交互
从数据流的角度思考应用程序,意味着重新协调应用代码和状态管理之间的关系。我们不再将数据库视作被应用操纵的被动变量,取而代之的是更多地考虑状态,状态变更和处理它们的代码之间的相互作用与协同关系。应用代码通过在另一个地方触发状态变更来响应状态变更。
我们在 “[数据库与流](ch11.md#数据库与流)” 中看到了这一思路,我们讨论了将数据库的变更日志视为一种我们可以订阅的事件流。诸如 Actor 的消息传递系统(请参阅 “[消息传递中的数据流](ch4.md#消息传递中的数据流)”)也具有响应事件的概念。早在 20 世纪 80 年代,**元组空间tuple space** 模型就已经探索了表达分布式计算的方式观察状态变更并作出反应的过程【38,39】。
我们在 “[数据库与流](/ch11#数据库与流)” 中看到了这一思路,我们讨论了将数据库的变更日志视为一种我们可以订阅的事件流。诸如 Actor 的消息传递系统(请参阅 “[消息传递中的数据流](/ch4#消息传递中的数据流)”)也具有响应事件的概念。早在 20 世纪 80 年代,**元组空间tuple space** 模型就已经探索了表达分布式计算的方式观察状态变更并作出反应的过程【38,39】。
如前所述,当触发器由于数据变更而被触发时,或次级索引更新以反映索引表中的变更时,数据库内部也发生着类似的情况。分拆数据库意味着将这个想法应用于在主数据库之外,用于创建衍生数据集:缓存、全文搜索索引、机器学习或分析系统。我们可以为此使用流处理和消息传递系统。
需要记住的重要一点是,维护衍生数据不同于执行异步任务。传统的消息传递系统通常是为执行异步任务设计的(请参阅 “[日志与传统的消息传递相比](ch11.md#日志与传统的消息传递相比)”):
需要记住的重要一点是,维护衍生数据不同于执行异步任务。传统的消息传递系统通常是为执行异步任务设计的(请参阅 “[日志与传统的消息传递相比](/ch11#日志与传统的消息传递相比)”):
* 在维护衍生数据时,状态变更的顺序通常很重要(如果多个视图是从事件日志衍生的,则需要按照相同的顺序处理事件,以便它们之间保持一致)。如 “[确认与重新传递](ch11.md#确认与重新传递)” 中所述,许多消息代理在重传未确认消息时没有此属性,双写也被排除在外(请参阅 “[保持系统同步](ch11.md#保持系统同步)”)。
* 在维护衍生数据时,状态变更的顺序通常很重要(如果多个视图是从事件日志衍生的,则需要按照相同的顺序处理事件,以便它们之间保持一致)。如 “[确认与重新传递](/ch11#确认与重新传递)” 中所述,许多消息代理在重传未确认消息时没有此属性,双写也被排除在外(请参阅 “[保持系统同步](/ch11#保持系统同步)”)。
* 容错是衍生数据的关键:仅仅丢失单个消息就会导致衍生数据集永远与其数据源失去同步。消息传递和衍生状态更新都必须可靠。例如,许多 Actor 系统默认在内存中维护 Actor 的状态和消息,所以如果运行 Actor 的机器崩溃,状态和消息就会丢失。
稳定的消息排序和容错消息处理是相当严格的要求,但与分布式事务相比,它们开销更小,运行更稳定。现代流处理组件可以提供这些排序和可靠性保证,并允许应用代码以流算子的形式运行。
@ -300,28 +302,28 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
#### 流处理器和服务
当今流行的应用开发风格涉及将功能分解为一组通过同步网络请求(如 REST API进行通信的 **服务**service请参阅 “[服务中的数据流REST 与 RPC](ch4.md#服务中的数据流REST与RPC)”)。这种面向服务的架构优于单一庞大应用的优势主要在于:通过松散耦合来提供组织上的可伸缩性:不同的团队可以专职于不同的服务上,从而减少团队之间的协调工作(因为服务可以独立部署和更新)。
当今流行的应用开发风格涉及将功能分解为一组通过同步网络请求(如 REST API进行通信的 **服务**service请参阅 “[服务中的数据流REST 与 RPC](/ch4#服务中的数据流REST与RPC)”)。这种面向服务的架构优于单一庞大应用的优势主要在于:通过松散耦合来提供组织上的可伸缩性:不同的团队可以专职于不同的服务上,从而减少团队之间的协调工作(因为服务可以独立部署和更新)。
在数据流中组装流算子与微服务方法有很多相似之处【40】。但底层通信机制是有很大区别数据流采用单向异步消息流而不是同步的请求 / 响应式交互。
除了在 “[消息传递中的数据流](ch4.md#消息传递中的数据流)” 中列出的优点如更好的容错性数据流系统还能实现更好的性能。例如假设客户正在购买以一种货币定价但以另一种货币支付的商品。为了执行货币换算你需要知道当前的汇率。这个操作可以通过两种方式实现【40,41】
除了在 “[消息传递中的数据流](/ch4#消息传递中的数据流)” 中列出的优点如更好的容错性数据流系统还能实现更好的性能。例如假设客户正在购买以一种货币定价但以另一种货币支付的商品。为了执行货币换算你需要知道当前的汇率。这个操作可以通过两种方式实现【40,41】
1. 在微服务方法中,处理购买的代码可能会查询汇率服务或数据库,以获取特定货币的当前汇率。
2. 在数据流方法中,处理订单的代码会提前订阅汇率变更流,并在汇率发生变动时将当前汇率存储在本地数据库中。处理订单时只需查询本地数据库即可。
第二种方法能将对另一服务的同步网络请求替换为对本地数据库的查询(可能在同一台机器甚至同一个进程中)[^ii]。数据流方法不仅更快,而且当其他服务失效时也更稳健。最快且最可靠的网络请求就是压根没有网络请求!我们现在不再使用 RPC而是在购买事件和汇率更新事件之间建立流联接请参阅 “[流表连接(流扩充)](ch11.md#流表连接(流扩充))”)。
第二种方法能将对另一服务的同步网络请求替换为对本地数据库的查询(可能在同一台机器甚至同一个进程中)[^ii]。数据流方法不仅更快,而且当其他服务失效时也更稳健。最快且最可靠的网络请求就是压根没有网络请求!我们现在不再使用 RPC而是在购买事件和汇率更新事件之间建立流联接请参阅 “[流表连接(流扩充)](/ch11#流表连接(流扩充))”)。
[^ii]: 在微服务方法中,你也可以通过在处理购买的服务中本地缓存汇率来避免同步网络请求。但是为了保证缓存的新鲜度,你需要定期轮询汇率以获取其更新,或订阅变更流 —— 这恰好是数据流方法中发生的事情。
连接是时间相关的:如果购买事件在稍后的时间点被重新处理,汇率可能已经改变。如果要重建原始输出,则需要获取原始购买时的历史汇率。无论是查询服务还是订阅汇率更新流,你都需要处理这种时间相关性(请参阅 “[连接的时间依赖性](ch11.md#连接的时间依赖性)”)。
连接是时间相关的:如果购买事件在稍后的时间点被重新处理,汇率可能已经改变。如果要重建原始输出,则需要获取原始购买时的历史汇率。无论是查询服务还是订阅汇率更新流,你都需要处理这种时间相关性(请参阅 “[连接的时间依赖性](/ch11#连接的时间依赖性)”)。
订阅变更流,而不是在需要时查询当前状态,使我们更接近类似电子表格的计算模型:当某些数据发生变更时,依赖于此的所有衍生数据都可以快速更新。还有很多未解决的问题,例如关于时间相关连接等问题,但我认为围绕数据流构建应用的想法是一个非常有希望的方向。
### 观察衍生数据状态
在抽象层面,上一节讨论的数据流系统提供了创建衍生数据集(例如搜索索引、物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为 **写路径write path**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个衍生数据集都会被更新,以适配写入的数据。[图 12-1](img/fig12-1.png) 显示了一个更新搜索索引的例子。
在抽象层面,上一节讨论的数据流系统提供了创建衍生数据集(例如搜索索引、物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为 **写路径write path**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个衍生数据集都会被更新,以适配写入的数据。[图 12-1](/img/fig12-1.png) 显示了一个更新搜索索引的例子。
![](img/fig12-1.png)
![](/img/fig12-1.png)
**图 12-1 在搜索索引中,写(文档更新)遇上读(查询)**
@ -329,7 +331,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
总而言之,写路径和读路径涵盖了数据的整个旅程,从收集数据开始,到使用数据结束(可能是由另一个人)。写路径是预计算过程的一部分 —— 即,一旦数据进入,即刻完成,无论是否有人需要看它。读路径是这个过程中只有当有人请求时才会发生的部分。如果你熟悉函数式编程语言,则可能会注意到写路径类似于立即求值,读路径类似于惰性求值。
如 [图 12-1](img/fig12-1.png) 所示,衍生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。
如 [图 12-1](/img/fig12-1.png) 所示,衍生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。
#### 物化视图和缓存
@ -345,7 +347,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
从这个例子中我们可以看到,索引不是写路径和读路径之间唯一可能的边界;缓存常见搜索结果也是可行的;而在少量文档上使用没有索引的类 grep 扫描也是可行的。由此来看,缓存,索引和物化视图的作用很简单:它们改变了读路径与写路径之间的边界。通过预先计算结果,从而允许我们在写路径上做更多的工作,以节省读路径上的工作量。
在写路径上完成的工作和读路径之间的界限,实际上是本书开始处在 “[描述负载](ch1.md#描述负载)” 中推特例子里谈到的主题。在该例中,我们还看到了与普通用户相比,名人的写路径和读路径可能有所不同。在 500 页之后,我们已经绕回了起点!
在写路径上完成的工作和读路径之间的界限,实际上是本书开始处在 “[描述负载](/ch1#描述负载)” 中推特例子里谈到的主题。在该例中,我们还看到了与普通用户相比,名人的写路径和读路径可能有所不同。在 500 页之后,我们已经绕回了起点!
#### 有状态、可离线的客户端
@ -355,7 +357,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
传统上,网络浏览器是无状态的客户端,只有当连接到互联网时才能做一些有用的事情(能离线执行的唯一事情基本上就是上下滚动之前在线时加载好的页面)。然而,最近的 “单页面” JavaScript Web 应用已经获得了很多有状态的功能,包括客户端用户界面交互,以及 Web 浏览器中的持久化本地存储。移动应用可以类似地在设备上存储大量状态,而且大多数用户交互都不需要与服务器往返交互。
这些不断变化的功能重新引发了对 **离线优先offline-first** 应用的兴趣这些应用尽可能地在同一设备上使用本地数据库无需连接互联网并在后台网络连接可用时与远程服务器同步【42】。由于移动设备通常具有缓慢且不可靠的蜂窝网络连接因此如果用户的用户界面不必等待同步网络请求且应用主要是离线工作的则这是一个巨大优势请参阅 “[需要离线操作的客户端](ch5.md#需要离线操作的客户端)”)。
这些不断变化的功能重新引发了对 **离线优先offline-first** 应用的兴趣这些应用尽可能地在同一设备上使用本地数据库无需连接互联网并在后台网络连接可用时与远程服务器同步【42】。由于移动设备通常具有缓慢且不可靠的蜂窝网络连接因此如果用户的用户界面不必等待同步网络请求且应用主要是离线工作的则这是一个巨大优势请参阅 “[需要离线操作的客户端](/ch5#需要离线操作的客户端)”)。
当我们摆脱无状态客户端与中央数据库交互的假设,并转向在终端用户设备上维护状态时,这就开启了新世界的大门。特别是,我们可以将设备上的状态视为 **服务器状态的缓存**。屏幕上的像素是客户端应用中模型对象的物化视图模型对象是远程数据中心的本地状态副本【27】。
@ -367,17 +369,17 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
用我们的写路径与读路径模型来讲主动将状态变更推至到客户端设备意味着将写路径一直延伸到终端用户。当客户端首次初始化时它仍然需要使用读路径来获取其初始状态但此后它就能够依赖服务器发送的状态变更流了。我们在流处理和消息传递部分讨论的想法并不局限于数据中心中我们可以进一步采纳这些想法并将它们一直延伸到终端用户设备【43】。
这些设备有时会离线,并在此期间无法收到服务器状态变更的任何通知。但是我们已经解决了这个问题:在 “[消费者偏移量](ch11.md#消费者偏移量)” 中,我们讨论了基于日志的消息代理的消费者能在失败或断开连接后重连,并确保它不会错过掉线期间任何到达的消息。同样的技术适用于单个用户,每个设备都是一个小事件流的小小订阅者。
这些设备有时会离线,并在此期间无法收到服务器状态变更的任何通知。但是我们已经解决了这个问题:在 “[消费者偏移量](/ch11#消费者偏移量)” 中,我们讨论了基于日志的消息代理的消费者能在失败或断开连接后重连,并确保它不会错过掉线期间任何到达的消息。同样的技术适用于单个用户,每个设备都是一个小事件流的小小订阅者。
#### 端到端的事件流
最近用于开发有状态的客户端与用户界面的工具,例如如 Elm 语言【30】和 Facebook 的 React、Flux 和 Redux 工具链,已经通过订阅表示用户输入或服务器响应的事件流来管理客户端的内部状态,其结构与事件溯源相似(请参阅 “[事件溯源](ch11.md#事件溯源)”)。
最近用于开发有状态的客户端与用户界面的工具,例如如 Elm 语言【30】和 Facebook 的 React、Flux 和 Redux 工具链,已经通过订阅表示用户输入或服务器响应的事件流来管理客户端的内部状态,其结构与事件溯源相似(请参阅 “[事件溯源](/ch11#事件溯源)”)。
将这种编程模型扩展为:允许服务器将状态变更事件推送到客户端的事件管道中,是非常自然的。因此,状态变化可以通过 **端到端end-to-end** 的写路径流动:从一个设备上的交互触发状态变更开始,经由事件日志,并穿过几个衍生数据系统与流处理器,一直到另一台设备上的用户界面,而有人正在观察用户界面上的状态变化。这些状态变化能以相当低的延迟传播 —— 比如说,在一秒内从一端到另一端。
一些应用(如即时消息传递与在线游戏)已经具有这种 “实时” 架构(在低延迟交互的意义上,不是在 “[响应时间保证](ch8.md#响应时间保证)” 中的意义上)。但我们为什么不用这种方式构建所有的应用?
一些应用(如即时消息传递与在线游戏)已经具有这种 “实时” 架构(在低延迟交互的意义上,不是在 “[响应时间保证](/ch8#响应时间保证)” 中的意义上)。但我们为什么不用这种方式构建所有的应用?
挑战在于,关于无状态客户端和请求 / 响应交互的假设已经根深蒂固地植入在我们的数据库、库、框架以及协议之中。许多数据存储支持读取与写入操作,为请求返回一个响应,但只有极少数提供订阅变更的能力 —— 请求返回一个随时间推移的响应流(请参阅 “[变更流的 API 支持](ch11.md#变更流的API支持)” )。
挑战在于,关于无状态客户端和请求 / 响应交互的假设已经根深蒂固地植入在我们的数据库、库、框架以及协议之中。许多数据存储支持读取与写入操作,为请求返回一个响应,但只有极少数提供订阅变更的能力 —— 请求返回一个随时间推移的响应流(请参阅 “[变更流的 API 支持](/ch11#变更流的API支持)” )。
为了将写路径延伸至终端用户,我们需要从根本上重新思考我们构建这些系统的方式:从请求 / 响应交互转向发布 / 订阅数据流【27】。更具响应性的用户界面与更好的离线支持我认为这些优势值得我们付出努力。如果你正在设计数据系统我希望你对订阅变更的选项留有印象而不只是查询当前状态。
@ -385,11 +387,11 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
我们讨论过,当流处理器将衍生数据写入存储(数据库,缓存或索引)时,以及当用户请求查询该存储时,存储将充当写路径和读路径之间的边界。该存储应当允许对数据进行随机访问的读取查询,否则这些查询将需要扫描整个事件日志。
在很多情况下,数据存储与流处理系统是分开的。但回想一下,流处理器还是需要维护状态以执行聚合和连接的(请参阅 “[流连接](ch11.md#流连接)”。这种状态通常隐藏在流处理器内部但一些框架也允许这些状态被外部客户端查询【45】将流处理器本身变成一种简单的数据库。
在很多情况下,数据存储与流处理系统是分开的。但回想一下,流处理器还是需要维护状态以执行聚合和连接的(请参阅 “[流连接](/ch11#流连接)”。这种状态通常隐藏在流处理器内部但一些框架也允许这些状态被外部客户端查询【45】将流处理器本身变成一种简单的数据库。
我愿意进一步思考这个想法。正如到目前为止所讨论的那样对存储的写入是通过事件日志进行的而读取是临时的网络请求直接流向存储着待查数据的节点。这是一个合理的设计但不是唯一可行的设计。也可以将读取请求表示为事件流并同时将读事件与写事件送往流处理器流处理器通过将读取结果发送到输出流来响应读取事件【46】。
当写入和读取都被表示为事件,并且被路由到同一个流算子以便处理时,我们实际上是在读取查询流和数据库之间执行流表连接。读取事件需要被送往保存数据的数据库分区(请参阅 “[请求路由](ch6.md#请求路由)”),就像批处理和流处理器在连接时需要在同一个键上对输入分区一样(请参阅 “[Reduce 侧连接与分组](ch10.md#Reduce侧连接与分组)”)。
当写入和读取都被表示为事件,并且被路由到同一个流算子以便处理时,我们实际上是在读取查询流和数据库之间执行流表连接。读取事件需要被送往保存数据的数据库分区(请参阅 “[请求路由](/ch6#请求路由)”),就像批处理和流处理器在连接时需要在同一个键上对输入分区一样(请参阅 “[Reduce 侧连接与分组](/ch10#Reduce侧连接与分组)”)。
服务请求与执行连接之间的这种相似之处是非常关键的【47】。一次性读取请求只是将请求传过连接算子然后请求马上就被忘掉了而一个订阅请求则是与连接另一侧过去与未来事件的持久化连接。
@ -401,20 +403,20 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
对于只涉及单个分区的查询,通过流来发送查询与收集响应可能是杀鸡用牛刀了。然而,这个想法开启了分布式执行复杂查询的可能性,这需要合并来自多个分区的数据,利用了流处理器已经提供的消息路由、分区和连接的基础设施。
Storm 的分布式 RPC 功能支持这种使用模式(请参阅 “[消息传递和 RPC](ch11.md#消息传递和RPC)”)。例如,它已经被用来计算浏览过某个推特 URL 的人数 —— 即,发推包含该 URL 的所有人的粉丝集合的并集【48】。由于推特的用户是分区的因此这种计算需要合并来自多个分区的结果。
Storm 的分布式 RPC 功能支持这种使用模式(请参阅 “[消息传递和 RPC](/ch11#消息传递和RPC)”)。例如,它已经被用来计算浏览过某个推特 URL 的人数 —— 即,发推包含该 URL 的所有人的粉丝集合的并集【48】。由于推特的用户是分区的因此这种计算需要合并来自多个分区的结果。
这种模式的另一个例子是欺诈预防:为了评估特定购买事件是否具有欺诈风险,你可以检查该用户 IP 地址电子邮件地址帐单地址送货地址的信用分。这些信用数据库中的每一个都是有分区的因此为特定购买事件采集分数需要连接一系列不同的分区数据集【49】。
MPP 数据库的内部查询执行图有着类似的特征(请参阅 “[Hadoop 与分布式数据库的对比](ch10.md#Hadoop与分布式数据库的对比)”)。如果需要执行这种多分区连接,则直接使用提供此功能的数据库,可能要比使用流处理器实现它要更简单。然而将查询视为流提供了一种选项,可以用于实现超出传统现成解决方案的大规模应用。
MPP 数据库的内部查询执行图有着类似的特征(请参阅 “[Hadoop 与分布式数据库的对比](/ch10#Hadoop与分布式数据库的对比)”)。如果需要执行这种多分区连接,则直接使用提供此功能的数据库,可能要比使用流处理器实现它要更简单。然而将查询视为流提供了一种选项,可以用于实现超出传统现成解决方案的大规模应用。
## 将事情做正确
对于只读取数据的无状态服务出问题也没什么大不了的你可以修复该错误并重启服务而一切都恢复正常。像数据库这样的有状态系统就没那么简单了它们被设计为永远记住事物或多或少所以如果出现问题这种错误的效果也将潜在地永远持续下去这意味着它们需要更仔细的思考【50】。
我们希望构建可靠且 **正确** 的应用(即使面对各种故障,程序的语义也能被很好地定义与理解)。约四十年来,原子性、隔离性和持久性([第七章](ch7.md))等事务特性一直是构建正确应用的首选工具。然而这些地基没有看上去那么牢固:例如弱隔离级别带来的困惑可以佐证(请参阅 “[弱隔离级别](ch7.md#弱隔离级别)”)。
我们希望构建可靠且 **正确** 的应用(即使面对各种故障,程序的语义也能被很好地定义与理解)。约四十年来,原子性、隔离性和持久性([第七章](/ch7))等事务特性一直是构建正确应用的首选工具。然而这些地基没有看上去那么牢固:例如弱隔离级别带来的困惑可以佐证(请参阅 “[弱隔离级别](/ch7#弱隔离级别)”)。
事务在某些领域被完全抛弃,并被提供更好性能与可伸缩性的模型取代,但后者有更复杂的语义(例如,请参阅 “[无主复制](ch5.md#无主复制)”)。**一致性Consistency** 经常被谈起,但其定义并不明确(请参阅 “[一致性](ch7.md#一致性)” 和 [第九章](ch9.md))。有些人断言我们应当为了高可用而 “拥抱弱一致性”,但却对这些概念实际上意味着什么缺乏清晰的认识。
事务在某些领域被完全抛弃,并被提供更好性能与可伸缩性的模型取代,但后者有更复杂的语义(例如,请参阅 “[无主复制](/ch5#无主复制)”)。**一致性Consistency** 经常被谈起,但其定义并不明确(请参阅 “[一致性](/ch7#一致性)” 和 [第九章](/ch9))。有些人断言我们应当为了高可用而 “拥抱弱一致性”,但却对这些概念实际上意味着什么缺乏清晰的认识。
对于如此重要的话题我们的理解以及我们的工程方法却是惊人地薄弱。例如确定在特定事务隔离等级或复制配置下运行特定应用是否安全是非常困难的【51,52】。通常简单的解决方案似乎在低并发性的情况下工作正常并且没有错误但在要求更高的情况下却会出现许多微妙的错误。
@ -428,23 +430,23 @@ MPP 数据库的内部查询执行图有着类似的特征(请参阅 “[Hadoo
仅仅因为一个应用程序使用了具有相对较强安全属性的数据系统(例如可串行化的事务),并不意味着就可以保证没有数据丢失或损坏。例如,如果某个应用有个 Bug导致它写入不正确的数据或者从数据库中删除数据那么可串行化的事务也救不了你。
这个例子可能看起来很无聊,但值得认真对待:应用会出 Bug而人也会犯错误。我在 “[状态、流和不变性](ch11.md#状态、流和不变性)” 中使用了这个例子来支持不可变和仅追加的数据,阉割掉错误代码摧毁良好数据的能力,能让从错误中恢复更为容易。
这个例子可能看起来很无聊,但值得认真对待:应用会出 Bug而人也会犯错误。我在 “[状态、流和不变性](/ch11#状态、流和不变性)” 中使用了这个例子来支持不可变和仅追加的数据,阉割掉错误代码摧毁良好数据的能力,能让从错误中恢复更为容易。
虽然不变性很有用,但它本身并非万灵药。让我们来看一个可能发生的、非常微妙的数据损坏案例。
#### 正好执行一次操作
在 “[容错](ch11.md#容错)” 中,我们见到了 **恰好一次**(或 **等效一次**)语义的概念。如果在处理消息时出现问题,你可以选择放弃(丢弃消息 —— 导致数据丢失)或重试。如果重试,就会有这种风险:第一次实际上成功了,只不过你没有发现。结果这个消息就被处理了两次。
在 “[容错](/ch11#容错)” 中,我们见到了 **恰好一次**(或 **等效一次**)语义的概念。如果在处理消息时出现问题,你可以选择放弃(丢弃消息 —— 导致数据丢失)或重试。如果重试,就会有这种风险:第一次实际上成功了,只不过你没有发现。结果这个消息就被处理了两次。
处理两次是数据损坏的一种形式:为同样的服务向客户收费两次(收费太多)或增长计数器两次(夸大指标)都不是我们想要的。在这种情况下,恰好一次意味着安排计算,使得最终效果与没有发生错误的情况一样,即使操作实际上因为某种错误而重试。我们先前讨论过实现这一目标的几种方法。
最有效的方法之一是使操作 **幂等**idempotent请参阅 “[幂等性](ch11.md#幂等性)”):即确保它无论是执行一次还是执行多次都具有相同的效果。但是,将不是天生幂等的操作变为幂等的操作需要一些额外的努力与关注:你可能需要维护一些额外的元数据(例如更新了值的操作 ID 集合),并在从一个节点故障切换至另一个节点时做好防护(请参阅 “[领导者和锁](ch8.md#领导者和锁)”)。
最有效的方法之一是使操作 **幂等**idempotent请参阅 “[幂等性](/ch11#幂等性)”):即确保它无论是执行一次还是执行多次都具有相同的效果。但是,将不是天生幂等的操作变为幂等的操作需要一些额外的努力与关注:你可能需要维护一些额外的元数据(例如更新了值的操作 ID 集合),并在从一个节点故障切换至另一个节点时做好防护(请参阅 “[领导者和锁](/ch8#领导者和锁)”)。
#### 抑制重复
除了流处理之外其他许多地方也需要抑制重复的模式。例如TCP 使用了数据包上的序列号以便接收方可以将它们正确排序并确定网络上是否有数据包丢失或重复。在将数据交付应用前TCP 协议栈会重新传输任何丢失的数据包,也会移除任何重复的数据包。
但是,这种重复抑制仅适用于单条 TCP 连接的场景中。假设 TCP 连接是一个客户端与数据库的连接,并且它正在执行 [例 12-1]() 中的事务。在许多数据库中,事务是绑定在客户端连接上的(如果客户端发送了多个查询,数据库就知道它们属于同一个事务,因为它们是在同一个 TCP 连接上发送的)。如果客户端在发送 `COMMIT` 之后并在从数据库服务器收到响应之前遇到网络中断与连接超时,客户端是不知道事务是否已经被提交的([图 8-1](img/fig8-1.png))。
但是,这种重复抑制仅适用于单条 TCP 连接的场景中。假设 TCP 连接是一个客户端与数据库的连接,并且它正在执行 [例 12-1]() 中的事务。在许多数据库中,事务是绑定在客户端连接上的(如果客户端发送了多个查询,数据库就知道它们属于同一个事务,因为它们是在同一个 TCP 连接上发送的)。如果客户端在发送 `COMMIT` 之后并在从数据库服务器收到响应之前遇到网络中断与连接超时,客户端是不知道事务是否已经被提交的([图 8-1](/img/fig8-1.png))。
**例 12-1 资金从一个账户到另一个账户的非幂等转移**
@ -457,7 +459,7 @@ COMMIT;
客户端可以重连到数据库并重试事务,但现在已经处于 TCP 重复抑制的范围之外了。因为 [例 12-1]() 中的事务不是幂等的,可能会发生转了 \$22 而不是期望的 \$11。因此尽管 [例 12-1]() 是一个事务原子性的标准样例但它实际上并不正确而真正的银行并不会这样办事【3】。
两阶段提交(请参阅 “[原子提交与两阶段提交](ch9.md#原子提交与两阶段提交)”)协议会破坏 TCP 连接与事务之间的 1:1 映射,因为它们必须在故障后允许事务协调器重连到数据库,告诉数据库将存疑事务提交还是中止。这足以确保事务只被恰好执行一次吗?不幸的是,并不能。
两阶段提交(请参阅 “[原子提交与两阶段提交](/ch9#原子提交与两阶段提交)”)协议会破坏 TCP 连接与事务之间的 1:1 映射,因为它们必须在故障后允许事务协调器重连到数据库,告诉数据库将存疑事务提交还是中止。这足以确保事务只被恰好执行一次吗?不幸的是,并不能。
即使我们可以抑制数据库客户端与服务器之间的重复事务,我们仍然需要担心终端用户设备与应用服务器之间的网络。例如,如果终端用户的客户端是 Web 浏览器,则它可能会使用 HTTP POST 请求向服务器提交指令。也许用户正处于一个信号微弱的蜂窝数据网络连接中,它们成功地发送了 POST但却在能够从服务器接收响应之前没了信号。
@ -482,9 +484,9 @@ BEGIN TRANSACTION;
COMMIT;
```
[例 12-2]() 依赖于 `request_id` 列上的唯一约束。如果一个事务尝试插入一个已经存在的 ID那么 `INSERT` 失败,事务被中止,使其无法生效两次。即使在较弱的隔离级别下,关系数据库也能正确地维护唯一性约束(而在 “[写入偏差与幻读](ch7.md#写入偏差与幻读)” 中讨论过,应用级别的 **检查 - 然后 - 插入** 可能会在不可串行化的隔离下失败)。
[例 12-2]() 依赖于 `request_id` 列上的唯一约束。如果一个事务尝试插入一个已经存在的 ID那么 `INSERT` 失败,事务被中止,使其无法生效两次。即使在较弱的隔离级别下,关系数据库也能正确地维护唯一性约束(而在 “[写入偏差与幻读](/ch7#写入偏差与幻读)” 中讨论过,应用级别的 **检查 - 然后 - 插入** 可能会在不可串行化的隔离下失败)。
除了抑制重复的请求之外,[例 12-2]() 中的请求表表现得就像一种事件日志,暗示着事件溯源的想法(请参阅 “[事件溯源](ch11.md#事件溯源)”)。更新账户余额事实上不必与插入事件发生在同一个事务中,因为它们是冗余的,而能由下游消费者从请求事件中衍生出来 —— 只要该事件被恰好处理一次,这又一次可以使用请求 ID 来强制执行。
除了抑制重复的请求之外,[例 12-2]() 中的请求表表现得就像一种事件日志,暗示着事件溯源的想法(请参阅 “[事件溯源](/ch11#事件溯源)”)。更新账户余额事实上不必与插入事件发生在同一个事务中,因为它们是冗余的,而能由下游消费者从请求事件中衍生出来 —— 只要该事件被恰好处理一次,这又一次可以使用请求 ID 来强制执行。
#### 端到端原则
@ -507,9 +509,9 @@ COMMIT;
这实在是一个遗憾,因为容错机制很难弄好。低层级的可靠机制(比如 TCP 中的那些)运行的相当好,因而剩下的高层级错误基本很少出现。如果能将这些剩下的高层级容错机制打包成抽象,而应用不需要再去操心,那该多好呀 —— 但恐怕我们还没有找到这一正确的抽象。
长期以来,事务被认为是一个很好的抽象,我相信它们确实是很有用的。正如 [第七章](ch7.md) 导言中所讨论的,它们将各种可能的问题(并发写入、违背约束、崩溃、网络中断、磁盘故障)合并为两种可能结果:提交或中止。这是对编程模型而言是一种巨大的简化,但恐怕这还不够。
长期以来,事务被认为是一个很好的抽象,我相信它们确实是很有用的。正如 [第七章](/ch7) 导言中所讨论的,它们将各种可能的问题(并发写入、违背约束、崩溃、网络中断、磁盘故障)合并为两种可能结果:提交或中止。这是对编程模型而言是一种巨大的简化,但恐怕这还不够。
事务是代价高昂的,当涉及异构存储技术时尤为甚(请参阅 “[实践中的分布式事务](ch9.md#实践中的分布式事务)”)。我们拒绝使用分布式事务是因为它开销太大,结果我们最后不得不在应用代码中重新实现容错机制。正如本书中大量的例子所示,对并发性与部分失败的推理是困难且违反直觉的,所以我怀疑大多数应用级别的机制都不能正确工作,最终结果是数据丢失或损坏。
事务是代价高昂的,当涉及异构存储技术时尤为甚(请参阅 “[实践中的分布式事务](/ch9#实践中的分布式事务)”)。我们拒绝使用分布式事务是因为它开销太大,结果我们最后不得不在应用代码中重新实现容错机制。正如本书中大量的例子所示,对并发性与部分失败的推理是困难且违反直觉的,所以我怀疑大多数应用级别的机制都不能正确工作,最终结果是数据丢失或损坏。
出于这些原因,我认为探索对容错的抽象是很有价值的。它使提供应用特定的端到端的正确性属性变得更简单,而且还能在大规模分布式环境中提供良好的性能与运维特性。
@ -517,33 +519,33 @@ COMMIT;
让我们思考一下在 [分拆数据库](#分拆数据库) 上下文中的 **正确性correctness**。我们看到端到端的除重可以通过从客户端一路透传到数据库的请求 ID 实现。那么其他类型的约束呢?
我们先来特别关注一下 **唯一性约束** —— 例如我们在 [例 12-2]() 中所依赖的约束。在 “[约束和唯一性保证](ch9.md#约束和唯一性保证)” 中,我们看到了几个其他需要强制实施唯一性的应用功能例子:用户名或电子邮件地址必须唯一标识用户,文件存储服务不能包含多个重名文件,两个人不能在航班或剧院预订同一个座位。
我们先来特别关注一下 **唯一性约束** —— 例如我们在 [例 12-2]() 中所依赖的约束。在 “[约束和唯一性保证](/ch9#约束和唯一性保证)” 中,我们看到了几个其他需要强制实施唯一性的应用功能例子:用户名或电子邮件地址必须唯一标识用户,文件存储服务不能包含多个重名文件,两个人不能在航班或剧院预订同一个座位。
其他类型的约束也非常类似:例如,确保帐户余额永远不会变为负数,确保不会超卖库存,或者会议室没有重复的预订。执行唯一性约束的技术通常也可以用于这些约束。
#### 唯一性约束需要达成共识
在 [第九章](ch9.md) 中我们看到,在分布式环境中,强制执行唯一性约束需要共识:如果存在多个具有相同值的并发请求,则系统需要决定冲突操作中的哪一个被接受,并拒绝其他违背约束的操作。
在 [第九章](/ch9) 中我们看到,在分布式环境中,强制执行唯一性约束需要共识:如果存在多个具有相同值的并发请求,则系统需要决定冲突操作中的哪一个被接受,并拒绝其他违背约束的操作。
达成这一共识的最常见方式是使单个节点作为领导,并使其负责所有决策。只要你不介意所有请求都挤过单个节点(即使客户端位于世界的另一端),只要该节点没有失效,系统就能正常工作。如果你需要容忍领导者失效,那么就又回到了共识问题(请参阅 “[单主复制与共识](ch9.md#单主复制与共识)”)。
达成这一共识的最常见方式是使单个节点作为领导,并使其负责所有决策。只要你不介意所有请求都挤过单个节点(即使客户端位于世界的另一端),只要该节点没有失效,系统就能正常工作。如果你需要容忍领导者失效,那么就又回到了共识问题(请参阅 “[单主复制与共识](/ch9#单主复制与共识)”)。
唯一性检查可以通过对唯一性字段分区做横向伸缩。例如,如果需要通过请求 ID 确保唯一性(如 [例 12-2]() 所示),你可以确保所有具有相同请求 ID 的请求都被路由到同一分区(请参阅 [第六章](ch6.md))。如果你需要让用户名是唯一的,则可以按用户名的散列值做分区。
唯一性检查可以通过对唯一性字段分区做横向伸缩。例如,如果需要通过请求 ID 确保唯一性(如 [例 12-2]() 所示),你可以确保所有具有相同请求 ID 的请求都被路由到同一分区(请参阅 [第六章](/ch6))。如果你需要让用户名是唯一的,则可以按用户名的散列值做分区。
但异步多主复制排除在外,因为可能会发生不同主库同时接受冲突写操作的情况,因而这些值不再是唯一的(请参阅 “[实现线性一致的系统](ch9.md#实现线性一致的系统)”。如果你想立刻拒绝任何违背约束的写入同步协调是无法避免的【56】。
但异步多主复制排除在外,因为可能会发生不同主库同时接受冲突写操作的情况,因而这些值不再是唯一的(请参阅 “[实现线性一致的系统](/ch9#实现线性一致的系统)”。如果你想立刻拒绝任何违背约束的写入同步协调是无法避免的【56】。
#### 基于日志消息传递中的唯一性
日志确保所有消费者以相同的顺序看见消息 —— 这种保证在形式上被称为 **全序广播total order boardcast** 并且等价于共识(请参阅 “[全序广播](ch9.md#全序广播)”)。在使用基于日志的消息传递的分拆数据库方法中,我们可以使用非常类似的方法来执行唯一性约束。
日志确保所有消费者以相同的顺序看见消息 —— 这种保证在形式上被称为 **全序广播total order boardcast** 并且等价于共识(请参阅 “[全序广播](/ch9#全序广播)”)。在使用基于日志的消息传递的分拆数据库方法中,我们可以使用非常类似的方法来执行唯一性约束。
流处理器在单个线程上依次消费单个日志分区中的所有消息(请参阅 “[日志与传统的消息传递相比](ch11.md#日志与传统的消息传递相比)”。因此如果日志是按需要确保唯一的值做的分区则流处理器可以无歧义地、确定性地决定几个冲突操作中的哪一个先到达。例如在多个用户尝试宣告相同用户名的情况下【57】
流处理器在单个线程上依次消费单个日志分区中的所有消息(请参阅 “[日志与传统的消息传递相比](/ch11#日志与传统的消息传递相比)”。因此如果日志是按需要确保唯一的值做的分区则流处理器可以无歧义地、确定性地决定几个冲突操作中的哪一个先到达。例如在多个用户尝试宣告相同用户名的情况下【57】
1. 每个对用户名的请求都被编码为一条消息,并追加到按用户名散列值确定的分区。
2. 流处理器依序读取日志中的请求,并使用本地数据库来追踪哪些用户名已经被占用了。对于所有申请可用用户名的请求,它都会记录该用户名,并向输出流发送一条成功消息。对于所有申请已占用用户名的请求,它都会向输出流发送一条拒绝消息。
3. 请求用户名的客户端监视输出流,等待与其请求相对应的成功或拒绝消息。
该算法基本上与 “[使用全序广播实现线性一致的存储](ch9.md#使用全序广播实现线性一致的存储)” 中的算法相同。它可以简单地通过增加分区数伸缩至较大的请求吞吐量,因为每个分区都可以被独立处理。
该算法基本上与 “[使用全序广播实现线性一致的存储](/ch9#使用全序广播实现线性一致的存储)” 中的算法相同。它可以简单地通过增加分区数伸缩至较大的请求吞吐量,因为每个分区都可以被独立处理。
该方法不仅适用于唯一性约束,而且适用于许多其他类型的约束。其基本原理是,任何可能冲突的写入都会路由到相同的分区并按顺序处理。正如 “[什么是冲突?](ch5.md#什么是冲突?)” 与 “[写入偏差与幻读](ch7.md#写入偏差与幻读)” 中所述,冲突的定义可能取决于应用,但流处理器可以使用任意逻辑来验证请求。这个想法与 Bayou 在 90 年代开创的方法类似【58】。
该方法不仅适用于唯一性约束,而且适用于许多其他类型的约束。其基本原理是,任何可能冲突的写入都会路由到相同的分区并按顺序处理。正如 “[什么是冲突?](/ch5#什么是冲突?)” 与 “[写入偏差与幻读](/ch7#写入偏差与幻读)” 中所述,冲突的定义可能取决于应用,但流处理器可以使用任意逻辑来验证请求。这个想法与 Bayou 在 90 年代开创的方法类似【58】。
#### 多分区请求处理
@ -557,17 +559,17 @@ COMMIT;
2. 流处理器读取请求日志。对于每个请求消息,它向输出流发出两条消息:付款人账户 A 的借记指令(按 A 分区),收款人 B 的贷记指令(按 B 分区)。被发出的消息中会带有原始的请求 ID。
3. 后续处理器消费借记 / 贷记指令流,按照请求 ID 除重,并将变更应用至账户余额。
步骤 1 和步骤 2 是必要的,因为如果客户直接发送贷记与借记指令,则需要在这两个分区之间进行原子提交,以确保两者要么都发生或都不发生。为了避免对分布式事务的需要,我们首先将请求持久化记录为单条消息,然后从这第一条消息中衍生出贷记指令与借记指令。几乎在所有数据系统中,单对象写入都是原子性的(请参阅 “[单对象写入](ch7.md#单对象写入)),因此请求要么出现在日志中,要么就不出现,无需多分区原子提交。
步骤 1 和步骤 2 是必要的,因为如果客户直接发送贷记与借记指令,则需要在这两个分区之间进行原子提交,以确保两者要么都发生或都不发生。为了避免对分布式事务的需要,我们首先将请求持久化记录为单条消息,然后从这第一条消息中衍生出贷记指令与借记指令。几乎在所有数据系统中,单对象写入都是原子性的(请参阅 “[单对象写入](/ch7#单对象写入)),因此请求要么出现在日志中,要么就不出现,无需多分区原子提交。
如果流处理器在步骤 2 中崩溃,则它会从上一个存档点恢复处理。这样做时,它不会跳过任何请求消息,但可能会多次处理请求并产生重复的贷记与借记指令。但由于它是确定性的,因此它只是再次生成相同的指令,而步骤 3 中的处理器可以使用端到端请求 ID 轻松地对其除重。
如果你想确保付款人的帐户不会因此次转账而透支,则可以使用一个额外的流处理器来维护账户余额并校验事务(按付款人账户分区),只有有效的事务会被记录在步骤 1 中的请求日志中。
通过将多分区事务分解为两个不同分区方式的阶段,并使用端到端的请求 ID我们实现了同样的正确性属性每个请求对付款人与收款人都恰好生效一次即使在出现故障且没有使用原子提交协议的情况下依然如此。使用多个不同分区阶段的想法与我们在 “[多分区数据处理](#多分区数据处理)” 中讨论的想法类似(也请参阅 “[并发控制](ch11.md#并发控制)”)。
通过将多分区事务分解为两个不同分区方式的阶段,并使用端到端的请求 ID我们实现了同样的正确性属性每个请求对付款人与收款人都恰好生效一次即使在出现故障且没有使用原子提交协议的情况下依然如此。使用多个不同分区阶段的想法与我们在 “[多分区数据处理](#多分区数据处理)” 中讨论的想法类似(也请参阅 “[并发控制](/ch11#并发控制)”)。
### 及时性与完整性
事务的一个便利属性是,它们通常是线性一致的(请参阅 “[线性一致性](ch9.md#线性一致性)”),也就是说,写入者会等到事务提交,而之后其写入立刻对所有读取者可见。
事务的一个便利属性是,它们通常是线性一致的(请参阅 “[线性一致性](/ch9#线性一致性)”),也就是说,写入者会等到事务提交,而之后其写入立刻对所有读取者可见。
当我们把一个操作拆分为跨越多个阶段的流处理器时,却并非如此:日志的消费者在设计上就是异步的,因此发送者不会等其消息被消费者处理完。但是,客户端等待输出流中的特定消息是可能的。这正是我们在 “[基于日志消息传递中的唯一性](#基于日志消息传递中的唯一性)” 一节中检查唯一性约束时所做的事情。
@ -577,15 +579,15 @@ COMMIT;
* 及时性Timeliness
及时性意味着确保用户观察到系统的最新状态。我们之前看到,如果用户从陈旧的数据副本中读取数据,它们可能会观察到系统处于不一致的状态(请参阅 “[复制延迟问题](ch5.md#复制延迟问题)”)。但这种不一致是暂时的,而最终会通过等待与重试简单地得到解决。
及时性意味着确保用户观察到系统的最新状态。我们之前看到,如果用户从陈旧的数据副本中读取数据,它们可能会观察到系统处于不一致的状态(请参阅 “[复制延迟问题](/ch5#复制延迟问题)”)。但这种不一致是暂时的,而最终会通过等待与重试简单地得到解决。
CAP 定理(请参阅 “[线性一致性的代价](ch9.md#线性一致性的代价)”)使用 **线性一致性linearizability** 意义上的一致性,这是实现及时性的强有力方法。像 **写后读** 这样及时性更弱的一致性也很有用(请参阅 “[读己之写](ch5.md#读己之写)”)。
CAP 定理(请参阅 “[线性一致性的代价](/ch9#线性一致性的代价)”)使用 **线性一致性linearizability** 意义上的一致性,这是实现及时性的强有力方法。像 **写后读** 这样及时性更弱的一致性也很有用(请参阅 “[读己之写](/ch5#读己之写)”)。
* 完整性Integrity
完整性意味着没有损坏;即没有数据丢失,并且没有矛盾或错误的数据。尤其是如果某些衍生数据集是作为底层数据之上的视图而维护的(请参阅 “[从事件日志中派生出当前状态](ch11.md#从事件日志中派生出当前状态)”),这种衍生必须是正确的。例如,数据库索引必须正确地反映数据库的内容 —— 缺失某些记录的索引并不是很有用。
完整性意味着没有损坏;即没有数据丢失,并且没有矛盾或错误的数据。尤其是如果某些衍生数据集是作为底层数据之上的视图而维护的(请参阅 “[从事件日志中派生出当前状态](/ch11#从事件日志中派生出当前状态)”),这种衍生必须是正确的。例如,数据库索引必须正确地反映数据库的内容 —— 缺失某些记录的索引并不是很有用。
如果完整性被违背,这种不一致是永久的:在大多数情况下,等待与重试并不能修复数据库损坏。相反的是,需要显式地检查与修复。在 ACID 事务的上下文中(请参阅 “[ACID 的含义](ch7.md#ACID的含义)”),一致性通常被理解为某种特定于应用的完整性概念。原子性和持久性是保持完整性的重要工具。
如果完整性被违背,这种不一致是永久的:在大多数情况下,等待与重试并不能修复数据库损坏。相反的是,需要显式地检查与修复。在 ACID 事务的上下文中(请参阅 “[ACID 的含义](/ch7#ACID的含义)”),一致性通常被理解为某种特定于应用的完整性概念。原子性和持久性是保持完整性的重要工具。
口号形式:违反及时性,“最终一致性”;违反完整性,“永无一致性”。
@ -600,14 +602,14 @@ ACID 事务通常既提供及时性(例如线性一致性)也提供完整性
另一方面,对于在本章中讨论的基于事件的数据流系统而言,它们的一个有趣特性就是将及时性与完整性分开。在异步处理事件流时不能保证及时性,除非你显式构建一个在返回之前明确等待特定消息到达的消费者。但完整性实际上才是流处理系统的核心。
**恰好一次** 或 **等效一次** 语义(请参阅 “[容错](ch11.md#容错)”)是一种保持完整性的机制。如果事件丢失或者生效两次,就有可能违背数据系统的完整性。因此在出现故障时,容错消息传递与重复抑制(例如,幂等操作)对于维护数据系统的完整性是很重要的。
**恰好一次** 或 **等效一次** 语义(请参阅 “[容错](/ch11#容错)”)是一种保持完整性的机制。如果事件丢失或者生效两次,就有可能违背数据系统的完整性。因此在出现故障时,容错消息传递与重复抑制(例如,幂等操作)对于维护数据系统的完整性是很重要的。
正如我们在上一节看到的那样,可靠的流处理系统可以在无需分布式事务与原子提交协议的情况下保持完整性,这意味着它们有潜力达到与后者相当的正确性,同时还具备好得多的性能与运维稳健性。为了达成这种正确性,我们组合使用了多种机制:
* 将写入操作的内容表示为单条消息,从而可以轻松地被原子写入 —— 与事件溯源搭配效果拔群(请参阅 “[事件溯源](ch11.md#事件溯源)”)。
* 使用与存储过程类似的确定性衍生函数,从这一消息中衍生出所有其他的状态变更(请参阅 “[真的串行执行](ch7.md#真的串行执行)” 和 “[应用代码作为衍生函数](ch12.md#应用代码作为衍生函数)”)
* 将写入操作的内容表示为单条消息,从而可以轻松地被原子写入 —— 与事件溯源搭配效果拔群(请参阅 “[事件溯源](/ch11#事件溯源)”)。
* 使用与存储过程类似的确定性衍生函数,从这一消息中衍生出所有其他的状态变更(请参阅 “[真的串行执行](/ch7#真的串行执行)” 和 “[应用代码作为衍生函数](/ch12#应用代码作为衍生函数)”)
* 将客户端生成的请求 ID 传递通过所有的处理层次,从而允许端到端的除重,带来幂等性。
* 使消息不可变,并允许衍生数据能随时被重新处理,这使从错误中恢复更加容易(请参阅 “[不可变事件的优点](ch11.md#不可变事件的优点)”)
* 使消息不可变,并允许衍生数据能随时被重新处理,这使从错误中恢复更加容易(请参阅 “[不可变事件的优点](/ch11#不可变事件的优点)”)
这种机制组合在我看来,是未来构建容错应用的一个非常有前景的方向。
@ -626,7 +628,7 @@ ACID 事务通常既提供及时性(例如线性一致性)也提供完整性
道歉的成本是否能接受是一个商业决策。如果可以接受的话,在写入数据之前检查所有约束的传统模型反而会带来不必要的限制,而线性一致性的约束也不是必须的。乐观写入,事后检查可能是一种合理的选择。你仍然可以在做一些挽回成本高昂的事情前确保有相关的验证,但这并不意味着写入数据之前必须先进行验证。
这些应用 **确实** 需要完整性:你不会希望丢失预订信息,或者由于借方贷方不匹配导致资金消失。但是它们在执行约束时 **并不需要** 及时性:如果你销售的货物多于仓库中的库存,可以在事后道歉后并弥补问题。这种做法与我们在 “[处理写入冲突](ch5.md#处理写入冲突)” 中讨论的冲突解决方法类似。
这些应用 **确实** 需要完整性:你不会希望丢失预订信息,或者由于借方贷方不匹配导致资金消失。但是它们在执行约束时 **并不需要** 及时性:如果你销售的货物多于仓库中的库存,可以在事后道歉后并弥补问题。这种做法与我们在 “[处理写入冲突](/ch5#处理写入冲突)” 中讨论的冲突解决方法类似。
#### 无协调数据系统
@ -639,17 +641,17 @@ ACID 事务通常既提供及时性(例如线性一致性)也提供完整性
例如,这种系统可以使用多领导者配置运维,跨越多个数据中心,在区域间异步复制。任何一个数据中心都可以持续独立运行,因为不需要同步的跨区域协调。这样的系统的及时性保证会很弱 —— 如果不引入协调它是不可能是线性一致的 —— 但它仍然可以提供有力的完整性保证。
在这种情况下可串行化事务作为维护衍生状态的一部分仍然是有用的但它们只能在小范围内运行在那里它们工作得很好【8】。异构分布式事务如 XA 事务,请参阅 “[实践中的分布式事务](ch9.md#实践中的分布式事务)”不是必需的。同步协调仍然可以在需要的地方引入例如在无法恢复的操作之前强制执行严格的约束但是如果只是应用的一小部分地方需要它没必要让所有操作都付出协调的代价。【43】。
在这种情况下可串行化事务作为维护衍生状态的一部分仍然是有用的但它们只能在小范围内运行在那里它们工作得很好【8】。异构分布式事务如 XA 事务,请参阅 “[实践中的分布式事务](/ch9#实践中的分布式事务)”不是必需的。同步协调仍然可以在需要的地方引入例如在无法恢复的操作之前强制执行严格的约束但是如果只是应用的一小部分地方需要它没必要让所有操作都付出协调的代价。【43】。
另一种审视协调与约束的角度是:它们减少了由于不一致而必须做出的道歉数量,但也可能会降低系统的性能和可用性,从而可能增加由于宕机中断而需要做出的道歉数量。你不可能将道歉数量减少到零,但可以根据自己的需求寻找最佳平衡点 —— 既不存在太多不一致性,又不存在太多可用性问题。
### 信任但验证
我们所有关于正确性,完整性和容错的讨论都基于一些假设,假设某些事情可能会出错,但其他事情不会。我们将这些假设称为我们的 **系统模型**system model请参阅 “[将系统模型映射到现实世界](ch8.md#将系统模型映射到现实世界)”):例如,我们应该假设进程可能会崩溃,机器可能突然断电,网络可能会任意延迟或丢弃消息。但是我们也可能假设写入磁盘的数据在执行 `fsync` 后不会丢失,内存中的数据没有损坏,而 CPU 的乘法指令总是能返回正确的结果。
我们所有关于正确性,完整性和容错的讨论都基于一些假设,假设某些事情可能会出错,但其他事情不会。我们将这些假设称为我们的 **系统模型**system model请参阅 “[将系统模型映射到现实世界](/ch8#将系统模型映射到现实世界)”):例如,我们应该假设进程可能会崩溃,机器可能突然断电,网络可能会任意延迟或丢弃消息。但是我们也可能假设写入磁盘的数据在执行 `fsync` 后不会丢失,内存中的数据没有损坏,而 CPU 的乘法指令总是能返回正确的结果。
这些假设是相当合理的,因为大多数时候它们都是成立的,如果我们不得不经常担心计算机出错,那么基本上寸步难行。在传统上,系统模型采用二元方法处理故障:我们假设有些事情可能会发生,而其他事情 **永远** 不会发生。实际上,这更像是一个概率问题:有些事情更有可能,其他事情不太可能。问题在于违反我们假设的情况是否经常发生,以至于我们可能在实践中遇到它们。
我们已经看到,数据可能会在尚未落盘时损坏(请参阅 “[复制与持久性](ch7.md#复制与持久性)”),而网络上的数据损坏有时可能规避了 TCP 校验和(请参阅 “[弱谎言形式](ch8.md#弱谎言形式)” )。也许我们应当更关注这些事情?
我们已经看到,数据可能会在尚未落盘时损坏(请参阅 “[复制与持久性](/ch7#复制与持久性)”),而网络上的数据损坏有时可能规避了 TCP 校验和(请参阅 “[弱谎言形式](/ch8#弱谎言形式)” )。也许我们应当更关注这些事情?
我过去所从事的一个应用收集了来自客户端的崩溃报告我们收到的一些报告只有在这些设备内存中出现了随机位翻转才解释的通。这看起来不太可能但是如果有足够多的设备运行你的软件那么即使再不可能发生的事也确实会发生。除了由于硬件故障或辐射导致的随机存储器损坏之外一些病态的存储器访问模式甚至可以在没有故障的存储器中翻转位【62】 —— 一种可用于破坏操作系统安全机制的效应【63】这种技术被称为 **Rowhammer**)。一旦你仔细观察,硬件并不是看上去那样完美的抽象。
@ -663,13 +665,13 @@ ACID 事务通常既提供及时性(例如线性一致性)也提供完整性
而对于应用代码我们不得不假设会有更多的错误因为绝大多数应用的代码经受的评审与测试远远无法与数据库的代码相比。许多应用甚至没有正确使用数据库提供的用于维持完整性的功能例如外键或唯一性约束【36】。
ACID 意义下的一致性(请参阅 “[一致性](ch7.md#一致性)”)基于这样一种想法:数据库以一致的状态启动,而事务将其从一个一致状态转换至另一个一致的状态。因此,我们期望数据库始终处于一致状态。然而,只有当你假设事务没有 Bug 时,这种想法才有意义。如果应用以某种错误的方式使用数据库,例如,不安全地使用弱隔离等级,数据库的完整性就无法得到保证。
ACID 意义下的一致性(请参阅 “[一致性](/ch7#一致性)”)基于这样一种想法:数据库以一致的状态启动,而事务将其从一个一致状态转换至另一个一致的状态。因此,我们期望数据库始终处于一致状态。然而,只有当你假设事务没有 Bug 时,这种想法才有意义。如果应用以某种错误的方式使用数据库,例如,不安全地使用弱隔离等级,数据库的完整性就无法得到保证。
#### 不要盲目信任承诺
由于硬件和软件并不总是符合我们的理想,所以数据损坏似乎早晚不可避免。因此,我们至少应该有办法查明数据是否已经损坏,以便我们能够修复它,并尝试追查错误的来源。检查数据完整性称为 **审计auditing**
如 “[不可变事件的优点](ch11.md#不可变事件的优点)” 一节中所述,审计不仅仅适用于财务应用程序。不过,可审计性在财务中是非常非常重要的,因为每个人都知道错误总会发生,我们也都认为能够检测和解决问题是合理的需求。
如 “[不可变事件的优点](/ch11#不可变事件的优点)” 一节中所述,审计不仅仅适用于财务应用程序。不过,可审计性在财务中是非常非常重要的,因为每个人都知道错误总会发生,我们也都认为能够检测和解决问题是合理的需求。
成熟的系统同样倾向于考虑不太可能的事情出错的可能性并管理这种风险。例如HDFS 和 Amazon S3 等大规模存储系统并不完全信任磁盘它们运行后台进程持续回读文件并将其与其他副本进行比较并将文件从一个磁盘移动到另一个以便降低静默损坏的风险【67】。
@ -685,11 +687,11 @@ ACID 意义下的一致性(请参阅 “[一致性](ch7.md#一致性)”)基
#### 为可审计性而设计
如果一个事务在一个数据库中改变了多个对象,在这一事实发生后,很难说清这个事务到底意味着什么。即使你捕获了事务日志(请参阅 “[变更数据捕获](ch11.md#变更数据捕获)”),各种表中的插入、更新和删除操作并不一定能清楚地表明 **为什么** 要执行这些变更。决定这些变更的是应用逻辑中的调用,而这一应用逻辑稍纵即逝,无法重现。
如果一个事务在一个数据库中改变了多个对象,在这一事实发生后,很难说清这个事务到底意味着什么。即使你捕获了事务日志(请参阅 “[变更数据捕获](/ch11#变更数据捕获)”),各种表中的插入、更新和删除操作并不一定能清楚地表明 **为什么** 要执行这些变更。决定这些变更的是应用逻辑中的调用,而这一应用逻辑稍纵即逝,无法重现。
相比之下,基于事件的系统可以提供更好的可审计性。在事件溯源方法中,系统的用户输入被表示为一个单一不可变事件,而任何其导致的状态变更都衍生自该事件。衍生可以实现为具有确定性与可重复性,因而相同的事件日志通过相同版本的衍生代码时,会导致相同的状态变更。
显式处理数据流(请参阅 “[批处理输出的哲学](ch10.md#批处理输出的哲学)”)可以使数据的 **来龙去脉provenance** 更加清晰,从而使完整性检查更具可行性。对于事件日志,我们可以使用散列来检查事件存储没有被破坏。对于任何衍生状态,我们可以重新运行从事件日志中衍生它的批处理器与流处理器,以检查是否获得相同的结果,或者,甚至并行运行冗余的衍生流程。
显式处理数据流(请参阅 “[批处理输出的哲学](/ch10#批处理输出的哲学)”)可以使数据的 **来龙去脉provenance** 更加清晰,从而使完整性检查更具可行性。对于事件日志,我们可以使用散列来检查事件存储没有被破坏。对于任何衍生状态,我们可以重新运行从事件日志中衍生它的批处理器与流处理器,以检查是否获得相同的结果,或者,甚至并行运行冗余的衍生流程。
具有确定性且定义良好的数据流,也使调试与跟踪系统的执行变得容易,以便确定它 **为什么** 做了某些事情【4,69】。如果出现意想之外的事情那么重现导致意外事件的确切事故现场的诊断能力 —— 一种时间旅行调试功能是非常有价值的。
@ -709,7 +711,7 @@ ACID 意义下的一致性(请参阅 “[一致性](ch7.md#一致性)”)基
我没有资格评论这些技术用于货币,或者合同商定机制的价值。但从数据系统的角度来看,它们包含了一些有趣的想法。实质上,它们是分布式数据库,具有数据模型与事务机制,而不同副本可以由互不信任的组织托管。副本不断检查其他副本的完整性,并使用共识协议对应当执行的事务达成一致。
我对这些技术的拜占庭容错方面有些怀疑(请参阅 “[拜占庭故障](ch8.md#拜占庭故障)”),而且我发现 **工作证明proof of work** 技术非常浪费(比如,比特币挖矿)。比特币的交易吞吐量相当低,尽管更多是出于政治与经济原因而非技术上的原因。不过,完整性检查的方面是很有趣的。
我对这些技术的拜占庭容错方面有些怀疑(请参阅 “[拜占庭故障](/ch8#拜占庭故障)”),而且我发现 **工作证明proof of work** 技术非常浪费(比如,比特币挖矿)。比特币的交易吞吐量相当低,尽管更多是出于政治与经济原因而非技术上的原因。不过,完整性检查的方面是很有趣的。
密码学审计与完整性检查通常依赖 **默克尔树Merkle tree**【74】这是一颗散列值的树能够用于高效地证明一条记录出现在一个数据集中以及其他一些特性。除了炒作的沸沸扬扬的加密货币之外**证书透明性certificate transparency** 也是一种依赖 Merkle 树的安全技术,用来检查 TLS/SSL 证书的有效性【75,76】。
@ -862,7 +864,7 @@ ACID 意义下的一致性(请参阅 “[一致性](ch7.md#一致性)”)基
我们应该允许每个人保留自己的隐私 —— 即,对自己数据的控制 —— 而不是通过监视来窃取这种控制权。我们控制自己数据的个体权利就像是国家公园的自然环境:如果我们不去明确地保护它、关心它,它就会被破坏。这将是公地的悲剧,我们都会因此而变得更糟。无所不在的监视并非不可避免的 —— 我们现在仍然能阻止它。
我们究竟能做到哪一步是一个开放的问题。首先我们不应该永久保留数据而是一旦不再需要就立即清除数据【111,112】。清除数据与不变性的想法背道而驰请参阅 “[不变性的局限性](ch11.md#不变性的局限性)”但这是可以解决的问题。我所看到的一种很有前景的方法是通过加密协议来实施访问控制而不仅仅是通过策略【113,114】。总的来说文化与态度的改变是必要的。
我们究竟能做到哪一步是一个开放的问题。首先我们不应该永久保留数据而是一旦不再需要就立即清除数据【111,112】。清除数据与不变性的想法背道而驰请参阅 “[不变性的局限性](/ch11#不变性的局限性)”但这是可以解决的问题。我所看到的一种很有前景的方法是通过加密协议来实施访问控制而不仅仅是通过策略【113,114】。总的来说文化与态度的改变是必要的。
## 本章小结
@ -1002,11 +1004,3 @@ ACID 意义下的一致性(请参阅 “[一致性](ch7.md#一致性)”)基
1. Sam Thielman: “[You Are Not What You Read: Librarians Purge User Data to Protect Privacy](https://www.theguardian.com/us-news/2016/jan/13/us-library-records-purged-data-privacy),” *theguardian.com*, January 13, 2016.
1. Conor Friedersdorf: “[Edward Snowdens Other Motive for Leaking](http://www.theatlantic.com/politics/archive/2014/05/edward-snowdens-other-motive-for-leaking/370068/),” *theatlantic.com*, May 13, 2014.
1. Phillip Rogaway: “[The Moral Character of Cryptographic Work](http://web.cs.ucdavis.edu/~rogaway/papers/moral-fn.pdf),” Cryptology ePrint 2015/1162, December 2015.
------
| 上一章 | 目录 | 下一章 |
| --------------------------- | ------------------------------- | ------------------- |
| [第十一章:流处理](ch11.md) | [设计数据密集型应用](README.md) | [后记](colophon.md) |

View File

@ -1,15 +1,17 @@
# 第二章:数据模型与查询语言
---
title: "第二章:数据模型与查询语言"
linkTitle: "2. 数据模型与查询语言"
weight: 102
breadcrumbs: false
---
![](img/ch2.png)
![](/img/ch2.png)
> 语言的边界就是思想的边界。
>
> —— 路德维奇・维特根斯坦《逻辑哲学》1922
>
-------------------
[TOC]
数据模型可能是软件开发中最重要的部分了,因为它们的影响如此深远:不仅仅影响着软件的编写方式,而且影响着我们的 **解题思路**
@ -26,7 +28,7 @@
掌握一个数据模型需要花费很多精力(想想关系数据建模有多少本书)。即便只使用一个数据模型,不用操心其内部工作机制,构建软件也是非常困难的。然而,因为数据模型对上层软件的功能(能做什么,不能做什么)有着至深的影响,所以选择一个适合的数据模型是非常重要的。
在本章中,我们将研究一系列用于数据存储和查询的通用数据模型(前面列表中的第 2 点)。特别地,我们将比较关系模型,文档模型和少量基于图形的数据模型。我们还将查看各种查询语言并比较它们的用例。在 [第三章](ch3.md) 中,我们将讨论存储引擎是如何工作的。也就是说,这些数据模型实际上是如何实现的(列表中的第 3 点)。
在本章中,我们将研究一系列用于数据存储和查询的通用数据模型(前面列表中的第 2 点)。特别地,我们将比较关系模型,文档模型和少量基于图形的数据模型。我们还将查看各种查询语言并比较它们的用例。在 [第三章](/ch3) 中,我们将讨论存储引擎是如何工作的。也就是说,这些数据模型实际上是如何实现的(列表中的第 3 点)。
## 关系模型与文档模型
@ -64,13 +66,13 @@
像 ActiveRecord 和 Hibernate 这样的 **对象关系映射ORM object-relational mapping** 框架可以减少这个转换层所需的样板代码的数量,但是它们不能完全隐藏这两个模型之间的差异。
![](img/fig2-1.png)
![](/img/fig2-1.png)
**图 2-1 使用关系型模式来表示领英简介**
例如,[图 2-1](img/fig2-1.png) 展示了如何在关系模式中表示简历(一个 LinkedIn 简介)。整个简介可以通过一个唯一的标识符 `user_id` 来标识。像 `first_name``last_name` 这样的字段每个用户只出现一次,所以可以在 User 表上将其建模为列。但是,大多数人在职业生涯中拥有多于一份的工作,人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系,可以用多种方式来表示:
例如,[图 2-1](/img/fig2-1.png) 展示了如何在关系模式中表示简历(一个 LinkedIn 简介)。整个简介可以通过一个唯一的标识符 `user_id` 来标识。像 `first_name``last_name` 这样的字段每个用户只出现一次,所以可以在 User 表上将其建模为列。但是,大多数人在职业生涯中拥有多于一份的工作,人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系,可以用多种方式来表示:
* 传统 SQL 模型SQL1999 之前)中,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对 User 表提供外键引用,如 [图 2-1](img/fig2-1.png) 所示。
* 传统 SQL 模型SQL1999 之前)中,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对 User 表提供外键引用,如 [图 2-1](/img/fig2-1.png) 所示。
* 后续的 SQL 标准增加了对结构化数据类型和 XML 数据的支持;这允许将多值数据存储在单行内,并支持在这些文档内查询和索引。这些功能在 OracleIBM DB2MS SQL Server 和 PostgreSQL 中都有不同程度的支持【6,7】。JSON 数据类型也得到多个数据库的支持,包括 IBM DB2MySQL 和 PostgreSQL 【8】。
* 第三种选择是将职业,教育和联系信息编码为 JSON 或 XML 文档,将其存储在数据库的文本列中,并让应用程序解析其结构和内容。这种配置下,通常不能使用数据库来查询该编码列中的值。
@ -116,13 +118,13 @@
}
```
有一些开发人员认为 JSON 模型减少了应用程序代码和存储层之间的阻抗不匹配。不过,正如我们将在 [第四章](ch4.md) 中看到的那样JSON 作为数据编码格式也存在问题。无模式对 JSON 模型来说往往被认为是一个优势;我们将在 “[文档模型中的模式灵活性](#文档模型中的模式灵活性)” 中讨论这个问题。
有一些开发人员认为 JSON 模型减少了应用程序代码和存储层之间的阻抗不匹配。不过,正如我们将在 [第四章](/ch4) 中看到的那样JSON 作为数据编码格式也存在问题。无模式对 JSON 模型来说往往被认为是一个优势;我们将在 “[文档模型中的模式灵活性](#文档模型中的模式灵活性)” 中讨论这个问题。
JSON 表示比 [图 2-1](img/fig2-1.png) 中的多表模式具有更好的 **局部性locality**。如果在前面的关系型示例中获取简介,那需要执行多个查询(通过 `user_id` 查询每个表),或者在 User 表与其下属表之间混乱地执行多路连接。而在 JSON 表示中,所有相关信息都在同一个地方,一个查询就足够了。
JSON 表示比 [图 2-1](/img/fig2-1.png) 中的多表模式具有更好的 **局部性locality**。如果在前面的关系型示例中获取简介,那需要执行多个查询(通过 `user_id` 查询每个表),或者在 User 表与其下属表之间混乱地执行多路连接。而在 JSON 表示中,所有相关信息都在同一个地方,一个查询就足够了。
从用户简介文件到用户职位,教育历史和联系信息,这种一对多关系隐含了数据中的一个树状结构,而 JSON 表示使得这个树状结构变得明确(见 [图 2-2](img/fig2-2.png))。
从用户简介文件到用户职位,教育历史和联系信息,这种一对多关系隐含了数据中的一个树状结构,而 JSON 表示使得这个树状结构变得明确(见 [图 2-2](/img/fig2-2.png))。
![](img/fig2-2.png)
![](/img/fig2-2.png)
**图 2-2 一对多关系构建了一个树结构**
@ -144,7 +146,7 @@ JSON 表示比 [图 2-1](img/fig2-1.png) 中的多表模式具有更好的 **局
[^ii]: 关于关系模型的文献区分了几种不同的规范形式,但这些区别几乎没有实际意义。一个经验法则是,如果重复存储了可以存储在一个地方的值,则模式就不是 **规范化normalized** 的。
> 数据库管理员和开发人员喜欢争论规范化和非规范化,让我们暂时保留判断吧。在本书的 [第三部分](part-iii.md),我们将回到这个话题,探讨系统的方法用以处理缓存,非规范化和衍生数据。
> 数据库管理员和开发人员喜欢争论规范化和非规范化,让我们暂时保留判断吧。在本书的 [第三部分](/part-iii),我们将回到这个话题,探讨系统的方法用以处理缓存,非规范化和衍生数据。
不幸的是,对这些数据进行规范化需要多对一的关系(许多人生活在一个特定的地区,许多人在一个特定的行业工作),这与文档模型不太吻合。在关系数据库中,通过 ID 来引用其他表中的行是正常的,因为连接很容易。在文档数据库中,一对多树结构没有必要用连接,对连接的支持通常很弱 [^iii]。
@ -156,19 +158,19 @@ JSON 表示比 [图 2-1](img/fig2-1.png) 中的多表模式具有更好的 **局
* 组织和学校作为实体
在前面的描述中,`organization`(用户工作的公司)和 `school_name`(他们学习的地方)只是字符串。也许他们应该是对实体的引用呢?然后,每个组织、学校或大学都可以拥有自己的网页(标识、新闻提要等)。每个简历可以链接到它所提到的组织和学校,并且包括他们的图标和其他信息(请参阅 [图 2-3](img/fig2-3.png),来自 LinkedIn 的一个例子)。
在前面的描述中,`organization`(用户工作的公司)和 `school_name`(他们学习的地方)只是字符串。也许他们应该是对实体的引用呢?然后,每个组织、学校或大学都可以拥有自己的网页(标识、新闻提要等)。每个简历可以链接到它所提到的组织和学校,并且包括他们的图标和其他信息(请参阅 [图 2-3](/img/fig2-3.png),来自 LinkedIn 的一个例子)。
* 推荐
假设你想添加一个新的功能:一个用户可以为另一个用户写一个推荐。在用户的简历上显示推荐,并附上推荐用户的姓名和照片。如果推荐人更新他们的照片,那他们写的任何推荐都需要显示新的照片。因此,推荐应该拥有作者个人简介的引用。
![](img/fig2-3.png)
![](/img/fig2-3.png)
**图 2-3 公司名不仅是字符串还是一个指向公司实体的链接LinkedIn 截图)**
[图 2-4](img/fig2-4.png) 阐明了这些新功能需要如何使用多对多关系。每个虚线矩形内的数据可以分组成一个文档,但是对单位,学校和其他用户的引用需要表示成引用,并且在查询时需要连接。
[图 2-4](/img/fig2-4.png) 阐明了这些新功能需要如何使用多对多关系。每个虚线矩形内的数据可以分组成一个文档,但是对单位,学校和其他用户的引用需要表示成引用,并且在查询时需要连接。
![](img/fig2-4.png)
![](/img/fig2-4.png)
**图 2-4 使用多对多关系扩展简历**
@ -178,7 +180,7 @@ JSON 表示比 [图 2-1](img/fig2-1.png) 中的多表模式具有更好的 **局
20 世纪 70 年代最受欢迎的业务数据处理数据库是 IBM 的信息管理系统IMS最初是为了阿波罗太空计划的库存管理而开发的并于 1968 年有了首次商业发布【13】。目前它仍在使用和维护运行在 IBM 大型机的 OS/390 上【14】。
IMS 的设计中使用了一个相当简单的数据模型,称为 **层次模型hierarchical model**,它与文档数据库使用的 JSON 模型有一些惊人的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树这很像 [图 2-2](img/fig2-2.png) 的 JSON 结构。
IMS 的设计中使用了一个相当简单的数据模型,称为 **层次模型hierarchical model**,它与文档数据库使用的 JSON 模型有一些惊人的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树这很像 [图 2-2](/img/fig2-2.png) 的 JSON 结构。
同文档数据库一样IMS 能良好处理一对多的关系但是很难应对多对多的关系并且不支持连接。开发人员必须决定是否复制非规范化数据或手动解决从一个记录到另一个记录的引用。这些二十世纪六七十年代的问题与现在开发人员遇到的文档数据库问题非常相似【15】。
@ -214,19 +216,19 @@ CODASYL 中的查询是通过利用遍历记录列和跟随访问路径表在数
#### 与文档数据库相比
在一个方面,文档数据库还原为层次模型:在其父记录中存储嵌套记录([图 2-1](img/fig2-1.png) 中的一对多关系,如 `positions``education` 和 `contact_info`),而不是在单独的表中。
在一个方面,文档数据库还原为层次模型:在其父记录中存储嵌套记录([图 2-1](/img/fig2-1.png) 中的一对多关系,如 `positions``education` 和 `contact_info`),而不是在单独的表中。
但是,在表示多对一和多对多的关系时,关系数据库和文档数据库并没有根本的不同:在这两种情况下,相关项目都被一个唯一的标识符引用,这个标识符在关系模型中被称为 **外键**,在文档模型中称为 **文档引用**【9】。该标识符在读取时通过连接或后续查询来解析。迄今为止文档数据库没有走 CODASYL 的老路。
### 关系型数据库与文档数据库在今日的对比
将关系数据库与文档数据库进行比较时,可以考虑许多方面的差异,包括它们的容错属性(请参阅 [第五章](ch5.md))和处理并发性(请参阅 [第七章](ch7.md))。本章将只关注数据模型中的差异。
将关系数据库与文档数据库进行比较时,可以考虑许多方面的差异,包括它们的容错属性(请参阅 [第五章](/ch5))和处理并发性(请参阅 [第七章](/ch7))。本章将只关注数据模型中的差异。
支持文档数据模型的主要论据是架构灵活性,因局部性而拥有更好的性能,以及对于某些应用程序而言更接近于应用程序使用的数据结构。关系模型通过为连接提供更好的支持以及支持多对一和多对多的关系来反击。
#### 哪种数据模型更有助于简化应用代码?
如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如 [图 2-1](img/fig2-1.png) 中的 `positions`、`education` 和 `contact_info`)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。
如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如 [图 2-1](/img/fig2-1.png) 中的 `positions`、`education` 和 `contact_info`)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。
文档模型有一定的局限性:例如,不能直接引用文档中的嵌套的项目,而是需要说 “用户 251 的位置列表中的第二项”(很像层次模型中的访问路径)。但是,只要文件嵌套不太深,这通常不是问题。
@ -274,13 +276,13 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
#### 查询的数据局部性
文档通常以单个连续字符串形式进行存储,编码为 JSON、XML 或其二进制变体(如 MongoDB 的 BSON。如果应用程序经常需要访问整个文档例如将其渲染至网页那么存储局部性会带来性能优势。如果将数据分割到多个表中如 [图 2-1](img/fig2-1.png) 所示),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。
文档通常以单个连续字符串形式进行存储,编码为 JSON、XML 或其二进制变体(如 MongoDB 的 BSON。如果应用程序经常需要访问整个文档例如将其渲染至网页那么存储局部性会带来性能优势。如果将数据分割到多个表中如 [图 2-1](/img/fig2-1.png) 所示),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。
局部性仅仅适用于同时需要文档绝大部分内容的情况。即使只访问文档其中的一小部分数据库通常需要加载整个文档对于大型文档来说这种加载行为是很浪费的。更新文档时通常需要整个重写。只有不改变文档大小的修改才可以容易地原地执行。因此通常建议保持相对小的文档并避免增加文档大小的写入【9】。这些性能限制大大减少了文档数据库的实用场景。
值得指出的是为了局部性而分组集合相关数据的想法并不局限于文档模型。例如Google 的 Spanner 数据库在关系数据模型中提供了同样的局部性属性允许模式声明一个表的行应该交错嵌套在父表内【27】。Oracle 类似地允许使用一个称为 **多表索引集群表multi-table index cluster tables** 的类似特性【28】。Bigtable 数据模型(用于 Cassandra 和 HBase中的 **列族column-family** 概念与管理局部性的目的类似【29】。
在 [第三章](ch3.md) 将还会看到更多关于局部性的内容。
在 [第三章](/ch3) 将还会看到更多关于局部性的内容。
#### 文档和关系数据库的融合
@ -420,7 +422,7 @@ for (var i = 0; i < liElements.length; i++) {
MapReduce 是一个由 Google 推广的编程模型用于在多台机器上批量处理大规模的数据【33】。一些 NoSQL 数据存储(包括 MongoDB 和 CouchDB支持有限形式的 MapReduce作为在多个文档中执行只读查询的机制。
关于 MapReduce 更详细的介绍在 [第十章](ch10.md)。现在我们只简要讨论一下 MongoDB 使用的模型。
关于 MapReduce 更详细的介绍在 [第十章](/ch10)。现在我们只简要讨论一下 MongoDB 使用的模型。
MapReduce 既不是一个声明式的查询语言,也不是一个完全命令式的查询 API而是处于两者之间查询的逻辑用代码片段来表示这些代码片段会被处理框架重复性调用。它基于 `map`(也称为 `collect`)和 `reduce`(也称为 `fold``inject`)函数,两个函数存在于许多函数式编程语言中。
@ -488,7 +490,7 @@ db.observations.mapReduce(function map() {
map 和 reduce 函数在功能上有所限制:它们必须是 **纯** 函数这意味着它们只使用传递给它们的数据作为输入它们不能执行额外的数据库查询也不能有任何副作用。这些限制允许数据库以任何顺序运行任何功能并在失败时重新运行它们。然而map 和 reduce 函数仍然是强大的:它们可以解析字符串、调用库函数、执行计算等等。
MapReduce 是一个相当底层的编程模型,用于计算机集群上的分布式执行。像 SQL 这样的更高级的查询语言可以用一系列的 MapReduce 操作来实现(见 [第十章](ch10.md)),但是也有很多不使用 MapReduce 的分布式 SQL 实现。須注意SQL 并没有限制它只能在单一机器上运行,而 MapReduce 也并没有垄断所有的分布式查询执行。
MapReduce 是一个相当底层的编程模型,用于计算机集群上的分布式执行。像 SQL 这样的更高级的查询语言可以用一系列的 MapReduce 操作来实现(见 [第十章](/ch10)),但是也有很多不使用 MapReduce 的分布式 SQL 实现。須注意SQL 并没有限制它只能在单一机器上运行,而 MapReduce 也并没有垄断所有的分布式查询执行。
能够在查询中使用 JavaScript 代码是高级查询的一个重要特性,但这不限于 MapReduce一些 SQL 数据库也可以用 JavaScript 函数进行扩展【34】。
@ -533,13 +535,13 @@ db.observations.aggregate([
在刚刚给出的例子中图中的所有顶点代表了相同类型的事物人、网页或交叉路口。不过图并不局限于这样的同类数据同样强大地是图提供了一种一致的方式用来在单个数据存储中存储完全不同类型的对象。例如Facebook 维护一个包含许多不同类型的顶点和边的单个图顶点表示人、地点、事件、签到和用户的评论边表示哪些人是好友、签到发生在哪里、谁评论了什么帖子、谁参与了什么事件等等【35】。
在本节中,我们将使用 [图 2-5](img/fig2-5.png) 所示的示例。它可以从社交网络或系谱数据库中获得:它显示了两个人,来自爱达荷州的 Lucy 和来自法国 Beaune 的 Alain。他们已婚住在伦敦。
在本节中,我们将使用 [图 2-5](/img/fig2-5.png) 所示的示例。它可以从社交网络或系谱数据库中获得:它显示了两个人,来自爱达荷州的 Lucy 和来自法国 Beaune 的 Alain。他们已婚住在伦敦。
![](img/fig2-5.png)
![](/img/fig2-5.png)
**图 2-5 图数据结构示例(框代表顶点,箭头代表边)**
有几种不同但相关的方法用来构建和查询图表中的数据。在本节中,我们将讨论属性图模型(由 Neo4jTitan 和 InfiniteGraph 实现和三元组存储triple-store模型由 Datomic、AllegroGraph 等实现。我们将查看图的三种声明式查询语言CypherSPARQL 和 Datalog。除此之外还有像 Gremlin 【36】这样的图形查询语言和像 Pregel 这样的图形处理框架(见 [第十章](ch10.md))。
有几种不同但相关的方法用来构建和查询图表中的数据。在本节中,我们将讨论属性图模型(由 Neo4jTitan 和 InfiniteGraph 实现和三元组存储triple-store模型由 Datomic、AllegroGraph 等实现。我们将查看图的三种声明式查询语言CypherSPARQL 和 Datalog。除此之外还有像 Gremlin 【36】这样的图形查询语言和像 Pregel 这样的图形处理框架(见 [第十章](/ch10))。
### 属性图
@ -586,7 +588,7 @@ CREATE INDEX edges_heads ON edges (head_vertex);
2. 给定任何顶点,可以高效地找到它的入边和出边,从而遍历图,即沿着一系列顶点的路径前后移动(这就是为什么 [例 2-2]() 在 `tail_vertex``head_vertex` 列上都有索引的原因)。
3. 通过对不同类型的关系使用不同的标签,可以在一个图中存储几种不同的信息,同时仍然保持一个清晰的数据模型。
这些特性为数据建模提供了很大的灵活性,如 [图 2-5](img/fig2-5.png) 所示。图中显示了一些传统关系模式难以表达的事情例如不同国家的不同地区结构法国有省和大区美国有县和州国中国的怪事先忽略主权国家和民族错综复杂的烂摊子不同的数据粒度Lucy 现在的住所记录具体到城市,而她的出生地点只是在一个州的级别)。
这些特性为数据建模提供了很大的灵活性,如 [图 2-5](/img/fig2-5.png) 所示。图中显示了一些传统关系模式难以表达的事情例如不同国家的不同地区结构法国有省和大区美国有县和州国中国的怪事先忽略主权国家和民族错综复杂的烂摊子不同的数据粒度Lucy 现在的住所记录具体到城市,而她的出生地点只是在一个州的级别)。
你可以想象该图还能延伸出许多关于 Lucy 和 Alain 的事实,或其他人的其他更多的事实。例如,你可以用它来表示食物过敏(为每个过敏源增加一个顶点,并增加人与过敏源之间的一条边来指示一种过敏情况),并链接到过敏源,每个过敏源具有一组顶点用来显示哪些食物含有哪些物质。然后,你可以写一个查询,找出每个人吃什么是安全的。图在可演化性方面是富有优势的:当你向应用程序添加功能时,可以轻松扩展图以适应程序数据结构的变化。
@ -594,7 +596,7 @@ CREATE INDEX edges_heads ON edges (head_vertex);
Cypher 是属性图的声明式查询语言,为 Neo4j 图形数据库而发明【37】它是以电影 “黑客帝国” 中的一个角色来命名的而与密码学中的加密算法无关【38】
[例 2-3]() 显示了将 [图 2-5](img/fig2-5.png) 的左边部分插入图形数据库的 Cypher 查询。你可以以类似的方式把图的剩余部分添加进去,但这里为了文章可閱读性而省略这部分的示例。每个顶点都有一个像 `USA``Idaho` 这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:`Idaho - [WITHIN] ->USA` 创建一条标记为 `WITHIN` 的边,`Idaho` 为尾节点,`USA` 为头节点。
[例 2-3]() 显示了将 [图 2-5](/img/fig2-5.png) 的左边部分插入图形数据库的 Cypher 查询。你可以以类似的方式把图的剩余部分添加进去,但这里为了文章可閱读性而省略这部分的示例。每个顶点都有一个像 `USA``Idaho` 这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:`Idaho - [WITHIN] ->USA` 创建一条标记为 `WITHIN` 的边,`Idaho` 为尾节点,`USA` 为头节点。
**例 2-3 将图 2-5 中的数据子集表示为 Cypher 查询**
@ -608,7 +610,7 @@ CREATE
(Lucy) -[:BORN_IN]-> (Idaho)
```
当 [图 2-5](img/fig2-5.png) 的所有顶点和边被添加到数据库后,让我们提些有趣的问题:例如,找到所有从美国移民到欧洲的人的名字。更确切地说,这里我们想要找到符合下面条件的所有顶点,并且返回这些顶点的 `name` 属性:该顶点拥有一条连到美国任一位置的 `BORN_IN` 边,和一条连到欧洲的任一位置的 `LIVING_IN` 边。
当 [图 2-5](/img/fig2-5.png) 的所有顶点和边被添加到数据库后,让我们提些有趣的问题:例如,找到所有从美国移民到欧洲的人的名字。更确切地说,这里我们想要找到符合下面条件的所有顶点,并且返回这些顶点的 `name` 属性:该顶点拥有一条连到美国任一位置的 `BORN_IN` 边,和一条连到欧洲的任一位置的 `LIVING_IN` 边。
[例 2-4]() 展示了如何在 Cypher 中表达这个查询。在 MATCH 子句中使用相同的箭头符号来查找图中的模式:`(person) -[:BORN_IN]-> ()` 可以匹配 `BORN_IN` 边的任意两个顶点。该边的尾节点被绑定了变量 `person`,头节点则未被绑定。
@ -895,9 +897,9 @@ Cypher 和 SPARQL 使用 SELECT 立即跳转,但是 Datalog 一次只进行一
2. 数据库存在 `within (usa, namerica)`,在上一步骤中生成 `within_recursive (namerica, 'North America')`,故运用规则 2。它会产生 `within_recursive (usa, 'North America')`
3. 数据库存在 `within (idaho, usa)`,在上一步生成 `within_recursive (usa, 'North America')`,故运用规则 2。它产生 `within_recursive (idaho, 'North America')`
通过重复应用规则 1 和 2`within_recursive` 谓语可以告诉我们在数据库中包含北美(或任何其他位置名称)的所有位置。这个过程如 [图 2-6](img/fig2-6.png) 所示。
通过重复应用规则 1 和 2`within_recursive` 谓语可以告诉我们在数据库中包含北美(或任何其他位置名称)的所有位置。这个过程如 [图 2-6](/img/fig2-6.png) 所示。
![](img/fig2-6.png)
![](/img/fig2-6.png)
**图 2-6 使用示例 2-11 中的 Datalog 规则来确定爱达荷州在北美。**
@ -927,7 +929,7 @@ Cypher 和 SPARQL 使用 SELECT 立即跳转,但是 Datalog 一次只进行一
* 粒子物理学家数十年来一直在进行大数据类型的大规模数据分析像大型强子对撞机LHC这样的项目现在会处理数百 PB 的数据在这样的规模下需要定制解决方案来阻止硬件成本的失控【49】。
* **全文搜索** 可以说是一种经常与数据库一起使用的数据模型。信息检索是一个很大的专业课题,我们不会在本书中详细介绍,但是我们将在第三章和第三部分中介绍搜索索引。
让我们暂时将其放在一边。在 [下一章](ch3.md) 中,我们将讨论在 **实现** 本章描述的数据模型时会遇到的一些权衡。
让我们暂时将其放在一边。在 [下一章](/ch3) 中,我们将讨论在 **实现** 本章描述的数据模型时会遇到的一些权衡。
## 参考文献
@ -981,9 +983,3 @@ Cypher 和 SPARQL 使用 SELECT 立即跳转,但是 Datalog 一次只进行一
1. Nathan Marz: “[Cascalog](https://github.com/nathanmarz/cascalog)," *github.com*.
1. Dennis A. Benson, Ilene Karsch-Mizrachi, David J. Lipman, et al.: “[GenBank](https://academic.oup.com/nar/article/36/suppl_1/D25/2507746),” *Nucleic Acids Research*, volume 36, Database issue, pages D25D30, December 2007. [doi:10.1093/nar/gkm929](http://dx.doi.org/10.1093/nar/gkm929)
1. Fons Rademakers: “[ROOT for Big Data Analysis](https://indico.cern.ch/event/246453/contributions/1566610/attachments/423154/587535/ROOT-BigData-Analysis-London-2013.pdf),” at *Workshop on the Future of Big Data Management*, London, UK, June 2013.
------
| 上一章 | 目录 | 下一章 |
| -------------------------------------------- | ------------------------------- | ---------------------------- |
| [第一章:可靠性、可伸缩性和可维护性](ch1.md) | [设计数据密集型应用](README.md) | [第三章:存储与检索](ch3.md) |

View File

@ -1,19 +1,20 @@
# 第三章:存储与检索
---
title: "第三章:存储与检索"
linkTitle: "3. 存储与检索"
weight: 103
breadcrumbs: false
---
![](img/ch3.png)
![](/img/ch3.png)
> 建立秩序,省却搜索
>
> —— 德国谚语
>
-------------------
[TOC]
一个数据库在最基础的层次上需要完成两件事情:当你把数据交给数据库时,它应当把数据存储起来;而后当你向数据库要数据时,它应当把数据返回给你。
在 [第二章](ch2.md) 中,我们讨论了数据模型和查询语言,即程序员将数据录入数据库的格式,以及再次要回数据的机制。在本章中我们会从数据库的视角来讨论同样的问题:数据库如何存储我们提供的数据,以及如何在我们需要时重新找到数据。
在 [第二章](/ch2) 中,我们讨论了数据模型和查询语言,即程序员将数据录入数据库的格式,以及再次要回数据的机制。在本章中我们会从数据库的视角来讨论同样的问题:数据库如何存储我们提供的数据,以及如何在我们需要时重新找到数据。
作为程序员,为什么要关心数据库内部存储与检索的机理?你可能不会去从头开始实现自己的存储引擎,但是你 **确实** 需要从许多可用的存储引擎中选择一个合适的。而且为了让存储引擎能在你的工作负载类型上运行良好,你也需要大致了解存储引擎在底层究竟做了什么。
@ -82,9 +83,9 @@ $ cat database
键值存储与在大多数编程语言中可以找到的 **字典dictionary** 类型非常相似,通常字典都是用 **散列映射hash map****散列表hash table** 实现的。散列映射在许多算法教科书中都有描述【1,2】所以这里我们不会讨论它的工作细节。既然我们已经可以用散列映射来表示 **内存中** 的数据结构,为什么不使用它来索引 **硬盘上** 的数据呢?
假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样,那么最简单的索引策略就是:保留一个内存中的散列映射,其中每个键都映射到数据文件中的一个字节偏移量,指明了可以找到对应值的位置,如 [图 3-1](img/fig3-1.png) 所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用散列映射来查找数据文件中的偏移量,**寻找seek** 该位置并读取该值即可。
假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样,那么最简单的索引策略就是:保留一个内存中的散列映射,其中每个键都映射到数据文件中的一个字节偏移量,指明了可以找到对应值的位置,如 [图 3-1](/img/fig3-1.png) 所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用散列映射来查找数据文件中的偏移量,**寻找seek** 该位置并读取该值即可。
![](img/fig3-1.png)
![](/img/fig3-1.png)
**图 3-1 以类 CSV 格式存储键值对的日志,并使用内存散列映射进行索引。**
@ -92,15 +93,15 @@ $ cat database
像 Bitcask 这样的存储引擎非常适合每个键的值经常更新的情况。例如键可能是某个猫咪视频的网址URL而值可能是该视频被播放的次数每次有人点击播放按钮时递增。在这种类型的工作负载中有很多写操作但是没有太多不同的键 —— 每个键有很多的写操作,但是将所有键保存在内存中是可行的。
到目前为止,我们只是在追加写入一个文件 —— 所以如何避免最终用完硬盘空间?一种好的解决方案是,将日志分为特定大小的 **段segment**,当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行 **压缩compaction**,如 [图 3-2](img/fig3-2.png) 所示。这里的压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。
到目前为止,我们只是在追加写入一个文件 —— 所以如何避免最终用完硬盘空间?一种好的解决方案是,将日志分为特定大小的 **段segment**,当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行 **压缩compaction**,如 [图 3-2](/img/fig3-2.png) 所示。这里的压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。
![](img/fig3-2.png)
![](/img/fig3-2.png)
**图 3-2 键值更新日志(统计猫咪视频的播放次数)的压缩,只保留每个键的最近值**
而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如 [图 3-3](img/fig3-3.png) 所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,这个过程进行的同时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新合并的段而不是旧的段 —— 然后旧的段文件就可以简单地删除掉了。
而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如 [图 3-3](/img/fig3-3.png) 所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,这个过程进行的同时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新合并的段而不是旧的段 —— 然后旧的段文件就可以简单地删除掉了。
![](img/fig3-3.png)
![](/img/fig3-3.png)
**图 3-3 同时执行压缩和分段合并**
@ -130,7 +131,7 @@ $ cat database
乍一看,仅追加日志似乎很浪费:为什么不直接在文件里更新,用新值覆盖旧值?仅追加的设计之所以是个好的设计,有如下几个原因:
* 追加和分段合并都是顺序写入操作,通常比随机写入快得多,尤其是在磁性机械硬盘上。在某种程度上,顺序写入在基于闪存的 **固态硬盘SSD** 上也是好的选择【4】。我们将在“[比较 B 树和 LSM 树](#比较B树和LSM树)”中进一步讨论这个问题。
* 追加和分段合并都是顺序写入操作,通常比随机写入快得多,尤其是在磁性机械硬盘上。在某种程度上,顺序写入在基于闪存的 **固态硬盘SSD** 上也是好的选择【4】。我们将在“[比较 B 树和 LSM 树](#比较b树和lsm树)”中进一步讨论这个问题。
* 如果段文件是仅追加的或不可变的,并发和崩溃恢复就简单多了。例如,当一个数据值被更新的时候发生崩溃,你不用担心文件里将会同时包含旧值和新值各自的一部分。
* 合并旧段的处理也可以避免数据文件随着时间的推移而碎片化的问题。
@ -144,23 +145,23 @@ $ cat database
### SSTables和LSM树
在 [图 3-3](img/fig3-3.png) 中,每个日志结构存储段都是一系列键值对。这些键值对按照它们写入的顺序排列,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。
在 [图 3-3](/img/fig3-3.png) 中,每个日志结构存储段都是一系列键值对。这些键值对按照它们写入的顺序排列,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。
现在我们可以对段文件的格式做一个简单的改变:要求键值对的序列按键排序。乍一看,这个要求似乎打破了我们使用顺序写入的能力,我们将稍后再回到这个问题。
我们把这个格式称为 **排序字符串表Sorted String Table**,简称 SSTable。我们还要求每个键只在每个合并的段文件中出现一次压缩过程已经保证。与使用散列索引的日志段相比SSTable 有几个大的优势:
1. 即使文件大于可用内存,合并段的操作仍然是简单而高效的。这种方法就像归并排序算法中使用的方法一样,如 [图 3-4](img/fig3-4.png) 所示:你开始并排读取多个输入文件,查看每个文件中的第一个键,复制最低的键(根据排序顺序)到输出文件,不断重复此步骤,将产生一个新的合并段文件,而且它也是也按键排序的。
1. 即使文件大于可用内存,合并段的操作仍然是简单而高效的。这种方法就像归并排序算法中使用的方法一样,如 [图 3-4](/img/fig3-4.png) 所示:你开始并排读取多个输入文件,查看每个文件中的第一个键,复制最低的键(根据排序顺序)到输出文件,不断重复此步骤,将产生一个新的合并段文件,而且它也是也按键排序的。
![](img/fig3-4.png)
![](/img/fig3-4.png)
**图 3-4 合并几个 SSTable 段,只保留每个键的最新值**
如果在几个输入段中出现相同的键,该怎么办?请记住,每个段都包含在一段时间内写入数据库的所有值。这意味着一个输入段中的所有值一定比另一个段中的所有值都更近(假设我们总是合并相邻的段)。当多个段包含相同的键时,我们可以保留最近段的值,并丢弃旧段中的值。
2. 为了在文件中找到一个特定的键,你不再需要在内存中保存所有键的索引。以 [图 3-5](img/fig3-5.png) 为例:假设你正在内存中寻找键 `handiwork`,但是你不知道这个键在段文件中的确切偏移量。然而,你知道 `handbag``handsome` 的偏移,而且由于排序特性,你知道 `handiwork` 必须出现在这两者之间。这意味着你可以跳到 `handbag` 的偏移位置并从那里扫描,直到你找到 `handiwork`(或没找到,如果该文件中没有该键)。
2. 为了在文件中找到一个特定的键,你不再需要在内存中保存所有键的索引。以 [图 3-5](/img/fig3-5.png) 为例:假设你正在内存中寻找键 `handiwork`,但是你不知道这个键在段文件中的确切偏移量。然而,你知道 `handbag``handsome` 的偏移,而且由于排序特性,你知道 `handiwork` 必须出现在这两者之间。这意味着你可以跳到 `handbag` 的偏移位置并从那里扫描,直到你找到 `handiwork`(或没找到,如果该文件中没有该键)。
![](img/fig3-5.png)
![](/img/fig3-5.png)
**图 3-5 具有内存索引的 SSTable**
@ -168,7 +169,7 @@ $ cat database
[^i]: 如果所有的键与值都是定长的,你可以使用段文件上的二分查找并完全避免使用内存索引。然而实践中的键和值通常都是变长的,因此如果没有索引,就很难知道记录的分界点(前一条记录结束以及后一条记录开始的地方)。
3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对因此可以将这些记录分组为块block并在将其写入硬盘之前对其进行压缩如 [图 3-5](img/fig3-5.png) 中的阴影区域所示)[^译注i] 。稀疏内存索引中的每个条目都指向压缩块的开始处。除了节省硬盘空间之外,压缩还可以减少对 I/O 带宽的使用。
3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对因此可以将这些记录分组为块block并在将其写入硬盘之前对其进行压缩如 [图 3-5](/img/fig3-5.png) 中的阴影区域所示)[^译注i] 。稀疏内存索引中的每个条目都指向压缩块的开始处。除了节省硬盘空间之外,压缩还可以减少对 I/O 带宽的使用。
[^译注i]: 这里的压缩是 compression不是前文的 compaction请注意区分。
@ -214,23 +215,23 @@ Lucene是一种全文搜索的索引引擎在 Elasticsearch 和 Solr 被
我们前面看到的日志结构索引将数据库分解为可变大小的段通常是几兆字节或更大的大小并且总是按顺序写入段。相比之下B 树将数据库分解成固定大小的 **块block****分页page**,传统上大小为 4KB有时会更大并且一次只能读取或写入一个页面。这种设计更接近于底层硬件因为硬盘空间也是按固定大小的块来组织的。
每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在硬盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如 [图 3-6](img/fig3-6.png) 所示。
每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在硬盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如 [图 3-6](/img/fig3-6.png) 所示。
![](img/fig3-6.png)
![](/img/fig3-6.png)
**图 3-6 使用 B 树索引查找一个键**
一个页面会被指定为 B 树的根;在索引中查找一个键时,就从这里开始。该页面包含几个键和对子页面的引用。每个子页面负责一段连续范围的键,根页面上每两个引用之间的键,表示相邻子页面管理的键的范围(边界)。
在 [图 3-6](img/fig3-6.png) 的例子中,我们正在寻找键 251 ,所以我们知道我们需要跟踪边界 200 和 300 之间的页面引用。这将我们带到一个类似的页面,进一步将 200 到 300 的范围拆分到子范围。
在 [图 3-6](/img/fig3-6.png) 的例子中,我们正在寻找键 251 ,所以我们知道我们需要跟踪边界 200 和 300 之间的页面引用。这将我们带到一个类似的页面,进一步将 200 到 300 的范围拆分到子范围。
最终我们将到达某个包含单个键的页面叶子页面leaf page该页面或者直接包含每个键的值或者包含了对可以找到值的页面的引用。
在 B 树的一个页面中对子页面的引用的数量称为 **分支因子branching factor**。例如,在 [图 3-6](img/fig3-6.png) 中,分支因子是 6。在实践中分支因子的大小取决于存储页面引用和范围边界所需的空间但这个值通常是几百。
在 B 树的一个页面中对子页面的引用的数量称为 **分支因子branching factor**。例如,在 [图 3-6](/img/fig3-6.png) 中,分支因子是 6。在实践中分支因子的大小取决于存储页面引用和范围边界所需的空间但这个值通常是几百。
如果要更新 B 树中现有键的值,需要搜索包含该键的叶子页面,更改该页面中的值,并将该页面写回到硬盘(对该页面的任何引用都将保持有效)。如果你想添加一个新的键,你需要找到其范围能包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以反映新的键范围分区,如 [图 3-7](img/fig3-7.png) 所示 [^ii]。
如果要更新 B 树中现有键的值,需要搜索包含该键的叶子页面,更改该页面中的值,并将该页面写回到硬盘(对该页面的任何引用都将保持有效)。如果你想添加一个新的键,你需要找到其范围能包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以反映新的键范围分区,如 [图 3-7](/img/fig3-7.png) 所示 [^ii]。
![](img/fig3-7.png)
![](/img/fig3-7.png)
**图 3-7 通过分割页面来生长 B 树**
@ -254,7 +255,7 @@ B 树的基本底层写操作是用新数据覆写硬盘上的页面,并假定
由于 B 树已经存在了很久,所以并不奇怪这么多年下来有很多优化的设计被开发出来,仅举几例:
* 不同于覆写页面并维护 WAL 以支持崩溃恢复,一些数据库(如 LMDB使用写时复制方案【21】。经过修改的页面被写入到不同的位置并且还在树中创建了父页面的新版本以指向新的位置。这种方法对于并发控制也很有用我们将在 “[快照隔离和可重复读](ch7.md#快照隔离和可重复读)” 中看到。
* 不同于覆写页面并维护 WAL 以支持崩溃恢复,一些数据库(如 LMDB使用写时复制方案【21】。经过修改的页面被写入到不同的位置并且还在树中创建了父页面的新版本以指向新的位置。这种方法对于并发控制也很有用我们将在 “[快照隔离和可重复读](/ch7#快照隔离和可重复读)” 中看到。
* 我们可以通过不存储整个键,而是缩短其大小,来节省页面空间。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此也就允许更少的层级 [^iii]。
* 通常,页面可以放置在硬盘上的任何位置;没有什么要求相邻键范围的页面也放在硬盘上相邻的区域。如果某个查询需要按照排序顺序扫描大部分的键范围,那么这种按页面存储的布局可能会效率低下,因为每个页面的读取都需要执行一次硬盘查找。因此,许多 B 树的实现在布局树时会尽量使叶子页面按顺序出现在硬盘上。但是,随着树的增长,要维持这个顺序是很困难的。相比之下,由于 LSM 树在合并过程中一次性重写一大段存储,所以它们更容易使顺序键在硬盘上连续存储。
* 额外的指针被添加到树中。例如,每个叶子页面可以引用其左边和右边的兄弟页面,使得不用跳回父页面就能按顺序对键进行扫描。
@ -284,13 +285,13 @@ LSM 树可以被压缩得更好,因此通常能比 B 树在硬盘上产生更
#### LSM树的缺点
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。尽管存储引擎尝试增量地执行压缩以尽量不影响并发访问,但是硬盘资源有限,所以很容易发生某个请求需要等待硬盘先完成昂贵的压缩操作。对吞吐量和平均响应时间的影响通常很小,但是日志结构化存储引擎在更高百分位的响应时间(请参阅 “[描述性能](ch1.md#描述性能)”)有时会相当长,而 B 树的行为则相对更具有可预测性【28】。
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。尽管存储引擎尝试增量地执行压缩以尽量不影响并发访问,但是硬盘资源有限,所以很容易发生某个请求需要等待硬盘先完成昂贵的压缩操作。对吞吐量和平均响应时间的影响通常很小,但是日志结构化存储引擎在更高百分位的响应时间(请参阅 “[描述性能](/ch1#描述性能)”)有时会相当长,而 B 树的行为则相对更具有可预测性【28】。
压缩的另一个问题出现在高写入吞吐量时:硬盘的有限写入带宽需要在初始写入(记录日志和刷新内存表到硬盘)和在后台运行的压缩线程之间共享。写入空数据库时,可以使用全硬盘带宽进行初始写入,但数据库越大,压缩所需的硬盘带宽就越多。
如果写入吞吐量很高,并且压缩没有仔细配置好,有可能导致压缩跟不上写入速率。在这种情况下,硬盘上未合并段的数量不断增加,直到硬盘空间用完,读取速度也会减慢,因为它们需要检查更多的段文件。通常情况下,即使压缩无法跟上,基于 SSTable 的存储引擎也不会限制传入写入的速率所以你需要进行明确的监控来检测这种情况【29,30】。
B 树的一个优点是每个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。这个方面使得 B 树在想要提供强大的事务语义的数据库中很有吸引力:在许多关系数据库中,事务隔离是通过在键范围上使用锁来实现的,在 B 树索引中这些锁可以直接附加到树上【5】。在 [第七章](ch7.md) 中,我们将更详细地讨论这一点。
B 树的一个优点是每个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。这个方面使得 B 树在想要提供强大的事务语义的数据库中很有吸引力:在许多关系数据库中,事务隔离是通过在键范围上使用锁来实现的,在 B 树索引中这些锁可以直接附加到树上【5】。在 [第七章](/ch7) 中,我们将更详细地讨论这一点。
B 树在数据库架构中是非常根深蒂固的,为许多工作负载都提供了始终如一的良好性能,所以它们不可能在短期内消失。在新的数据库中,日志结构化索引变得越来越流行。没有简单易行的办法来判断哪种类型的存储引擎对你的使用场景更好,所以需要通过一些测试来得到相关经验。
@ -298,7 +299,7 @@ B 树在数据库架构中是非常根深蒂固的,为许多工作负载都提
到目前为止,我们只讨论了键值索引,它们就像关系模型中的 **主键primary key** 索引。主键唯一标识关系表中的一行,或文档数据库中的一个文档或图形数据库中的一个顶点。数据库中的其他记录可以通过其主键(或 ID引用该行 / 文档 / 顶点,索引就被用于解析这样的引用。
次级索引secondary indexes也很常见。在关系数据库中你可以使用 `CREATE INDEX` 命令在同一个表上创建多个次级索引而且这些索引通常对于有效地执行联接join而言至关重要。例如在 [第二章](ch2.md) 中的 [图 2-1](img/fig2-1.png) 中,很可能在 `user_id` 列上有一个次级索引,以便你可以在每个表中找到属于同一用户的所有行。
次级索引secondary indexes也很常见。在关系数据库中你可以使用 `CREATE INDEX` 命令在同一个表上创建多个次级索引而且这些索引通常对于有效地执行联接join而言至关重要。例如在 [第二章](/ch2) 中的 [图 2-1](/img/fig2-1.png) 中,很可能在 `user_id` 列上有一个次级索引,以便你可以在每个表中找到属于同一用户的所有行。
次级索引可以很容易地从键值索引构建。次级索引主要的不同是键不是唯一的即可能有许多行文档顶点具有相同的键。这可以通过两种方式来解决将匹配行标识符的列表作为索引里的值就像全文索引中的记录列表或者通过向每个键添加行标识符来使键唯一。无论哪种方式B 树和日志结构索引都可以用作次级索引。
@ -368,7 +369,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
在早期的业务数据处理过程中,一次典型的数据库写入通常与一笔 *商业交易commercial transaction* 相对应:卖个货、向供应商下订单、支付员工工资等等。但随着数据库开始应用到那些不涉及到钱的领域,术语 **交易 / 事务transaction** 仍留了下来,用于指代一组读写操作构成的逻辑单元。
> 事务不一定具有 ACID原子性一致性隔离性和持久性属性。事务处理只是意味着允许客户端进行低延迟的读取和写入 —— 而不是只能定期运行(例如每天一次)的批处理作业。我们在 [第七章](ch7.md) 中讨论 ACID 属性,在 [第十章](ch10.md) 中讨论批处理。
> 事务不一定具有 ACID原子性一致性隔离性和持久性属性。事务处理只是意味着允许客户端进行低延迟的读取和写入 —— 而不是只能定期运行(例如每天一次)的批处理作业。我们在 [第七章](/ch7) 中讨论 ACID 属性,在 [第十章](/ch10) 中讨论批处理。
即使数据库开始被用于许多不同类型的数据,比如博客文章的评论、游戏中的动作、地址簿中的联系人等等,基本的访问模式仍然类似于处理商业交易。应用程序通常使用索引通过某个键找少量记录。根据用户的输入来插入或更新记录。由于这些应用程序是交互式的,这种访问模式被称为 **在线事务处理OLTP, OnLine Transaction Processing**
@ -400,9 +401,9 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
这些 OLTP 系统往往对业务运作至关重要,因而通常会要求 **高可用****低延迟**。所以 DBA 会密切关注他们的 OLTP 数据库,他们通常不愿意让业务分析人员在 OLTP 数据库上运行临时的分析查询,因为这些查询通常开销巨大,会扫描大部分数据集,这会损害同时在执行的事务的性能。
相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们想要的内容而不影响 OLTP 操作【48】。数据仓库包含公司各种 OLTP 系统中所有的只读数据副本。从 OLTP 数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为 “**抽取 - 转换 - 加载ETL**”,如 [图 3-8](img/fig3-8.png) 所示。
相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们想要的内容而不影响 OLTP 操作【48】。数据仓库包含公司各种 OLTP 系统中所有的只读数据副本。从 OLTP 数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为 “**抽取 - 转换 - 加载ETL**”,如 [图 3-8](/img/fig3-8.png) 所示。
![](img/fig3-8.png)
![](/img/fig3-8.png)
**图 3-8 ETL 至数据仓库的简化提纲**
@ -422,11 +423,11 @@ Teradata、Vertica、SAP HANA 和 ParAccel 等数据仓库供应商通常使用
### 星型和雪花型:分析的模式
正如 [第二章](ch2.md) 所探讨的根据应用程序的需要在事务处理领域中使用了大量不同的数据模型。另一方面在分析型业务中数据模型的多样性则少得多。许多数据仓库都以相当公式化的方式使用被称为星型模式也称为维度建模【55】
正如 [第二章](/ch2) 所探讨的根据应用程序的需要在事务处理领域中使用了大量不同的数据模型。另一方面在分析型业务中数据模型的多样性则少得多。许多数据仓库都以相当公式化的方式使用被称为星型模式也称为维度建模【55】
[图 3-9](img/fig3-9.png) 中的示例模式显示了可能在食品零售商处找到的数据仓库。在模式的中心是一个所谓的事实表(在这个例子中,它被称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买的产品)。如果我们分析的是网站流量而不是零售量,则每行可能代表一个用户的页面浏览或点击。
[图 3-9](/img/fig3-9.png) 中的示例模式显示了可能在食品零售商处找到的数据仓库。在模式的中心是一个所谓的事实表(在这个例子中,它被称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买的产品)。如果我们分析的是网站流量而不是零售量,则每行可能代表一个用户的页面浏览或点击。
![](img/fig3-9.png)
![](/img/fig3-9.png)
**图 3-9 用于数据仓库的星型模式的示例**
@ -434,7 +435,7 @@ Teradata、Vertica、SAP HANA 和 ParAccel 等数据仓库供应商通常使用
事实表中的一些列是属性,例如产品销售的价格和从供应商那里购买的成本(可以用来计算利润率)。事实表中的其他列是对其他表(称为维度表)的外键引用。由于事实表中的每一行都表示一个事件,因此这些维度代表事件发生的对象、内容、地点、时间、方式和原因。
例如,在 [图 3-9](img/fig3-9.png) 中,其中一个维度是已售出的产品。`dim_product` 表中的每一行代表一种待售产品包括库存单位SKU、产品描述、品牌名称、类别、脂肪含量、包装尺寸等。`fact_sales` 表中的每一行都使用外键表明在特定交易中销售了什么产品。(简单起见,如果客户一次购买了几种不同的产品,则它们在事实表中被表示为单独的行)。
例如,在 [图 3-9](/img/fig3-9.png) 中,其中一个维度是已售出的产品。`dim_product` 表中的每一行代表一种待售产品包括库存单位SKU、产品描述、品牌名称、类别、脂肪含量、包装尺寸等。`fact_sales` 表中的每一行都使用外键表明在特定交易中销售了什么产品。(简单起见,如果客户一次购买了几种不同的产品,则它们在事实表中被表示为单独的行)。
甚至日期和时间也通常使用维度表来表示,因为这允许对日期的附加信息(诸如公共假期)进行编码,从而允许区分假期和非假期的销售查询。
@ -470,13 +471,13 @@ GROUP BY
我们如何有效地执行这个查询?
在大多数 OLTP 数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库也是相似的:整个文档通常存储为一个连续的字节序列。你可以在 [图 3-1](img/fig3-1.png) 的 CSV 例子中看到这个。
在大多数 OLTP 数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库也是相似的:整个文档通常存储为一个连续的字节序列。你可以在 [图 3-1](/img/fig3-1.png) 的 CSV 例子中看到这个。
为了处理像 [例 3-1]() 这样的查询,你可能在 `fact_sales.date_key`、`fact_sales.product_sk` 上有索引,它们告诉存储引擎在哪里查找特定日期或特定产品的所有销售情况。但是,面向行的存储引擎仍然需要将所有这些行(每个包含超过 100 个属性)从硬盘加载到内存中,解析它们,并过滤掉那些不符合要求的属性。这可能需要很长时间。
列式存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列式存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如 [图 3-10](img/fig3-10.png) 所示。
列式存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列式存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如 [图 3-10](/img/fig3-10.png) 所示。
![](img/fig3-10.png)
![](/img/fig3-10.png)
**图 3-10 按列存储关系型数据,而不是行**
@ -489,9 +490,9 @@ GROUP BY
除了仅从硬盘加载查询所需的列以外,我们还可以通过压缩数据来进一步降低对硬盘吞吐量的需求。幸运的是,列式存储通常很适合压缩。
看看 [图 3-10](img/fig3-10.png) 中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如 [图 3-11](img/fig3-11.png) 所示。
看看 [图 3-10](/img/fig3-10.png) 中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如 [图 3-11](/img/fig3-11.png) 所示。
![](img/fig3-11.png)
![](/img/fig3-11.png)
**图 3-11 压缩的位图索引存储布局**
@ -535,9 +536,9 @@ WHERE product_sk = 31 AND store_sk = 3
相反,数据的排序需要对一整行统一操作,即使它们的存储方式是按列的。数据库管理员可以根据他们对常用查询的了解,来选择表格中用来排序的列。例如,如果查询通常以日期范围为目标,例如“上个月”,则可以将 `date_key` 作为第一个排序键。这样查询优化器就可以只扫描近1个月范围的行了这比扫描所有行要快得多。
对于第一排序列中具有相同值的行,可以用第二排序列来进一步排序。例如,如果 `date_key` 是 [图 3-10](img/fig3-10.png) 中的第一个排序关键字,那么 `product_sk` 可能是第二个排序关键字,以便同一天的同一产品的所有销售数据都被存储在相邻位置。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。
对于第一排序列中具有相同值的行,可以用第二排序列来进一步排序。例如,如果 `date_key` 是 [图 3-10](/img/fig3-10.png) 中的第一个排序关键字,那么 `product_sk` 可能是第二个排序关键字,以便同一天的同一产品的所有销售数据都被存储在相邻位置。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。
按顺序排序的另一个好处是它可以帮助压缩列。如果主要排序列没有太多个不同的值,那么在排序之后,将会得到一个相同的值连续重复多次的序列。一个简单的游程编码(就像我们用于 [图 3-11](img/fig3-11.png) 中的位图一样)可以将该列压缩到几 KB —— 即使表中有数十亿行。
按顺序排序的另一个好处是它可以帮助压缩列。如果主要排序列没有太多个不同的值,那么在排序之后,将会得到一个相同的值连续重复多次的序列。一个简单的游程编码(就像我们用于 [图 3-11](/img/fig3-11.png) 中的位图一样)可以将该列压缩到几 KB —— 即使表中有数十亿行。
第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长的连续的重复值。排序优先级更低的列以几乎随机的顺序出现,所以可能不会被压缩。但对前几列做排序在整体上仍然是有好处的。
@ -567,13 +568,13 @@ WHERE product_sk = 31 AND store_sk = 3
当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。数据库可以自动完成该操作,但是这样的更新使得写入成本更高,这就是在 OLTP 数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中,它们可能更有意义(它们是否实际上改善了读取性能取决于使用场景)。
物化视图的常见特例称为数据立方体或 OLAP 立方【64】。它是按不同维度分组的聚合网格。[图 3-12](img/fig3-12.png) 显示了一个例子。
物化视图的常见特例称为数据立方体或 OLAP 立方【64】。它是按不同维度分组的聚合网格。[图 3-12](/img/fig3-12.png) 显示了一个例子。
![](img/fig3-12.png)
![](/img/fig3-12.png)
**图 3-12 数据立方的两个维度,通过求和聚合**
想象一下,现在每个事实都只有两个维度表的外键 —— 在 [图 3-12](img/fig3-12.png) 中分别是日期和产品。你现在可以绘制一个二维表格,一个轴线上是日期,另一个轴线上是产品。每个单元格包含具有该日期 - 产品组合的所有事实的属性(例如 `net_price`)的聚合(例如 `SUM`)。然后,你可以沿着每行或每列应用相同的汇总,并获得减少了一个维度的汇总(按产品的销售额,无论日期,或者按日期的销售额,无论产品)。
想象一下,现在每个事实都只有两个维度表的外键 —— 在 [图 3-12](/img/fig3-12.png) 中分别是日期和产品。你现在可以绘制一个二维表格,一个轴线上是日期,另一个轴线上是产品。每个单元格包含具有该日期 - 产品组合的所有事实的属性(例如 `net_price`)的聚合(例如 `SUM`)。然后,你可以沿着每行或每列应用相同的汇总,并获得减少了一个维度的汇总(按产品的销售额,无论日期,或者按日期的销售额,无论产品)。
一般来说,事实往往有两个以上的维度。在图 3-9 中有五个维度:日期、产品、商店、促销和客户。要想象一个五维超立方体是什么样子是很困难的,但是原理是一样的:每个单元格都包含特定日期 - 产品 - 商店 - 促销 - 客户组合的销售额。这些值可以在每个维度上求和汇总。
@ -673,10 +674,3 @@ WHERE product_sk = 31 AND store_sk = 3
1. Andrew Lamb, Matt Fuller, Ramakrishna Varadarajan, et al.: “[The Vertica Analytic Database: C-Store 7 Years Later](http://vldb.org/pvldb/vol5/p1790_andrewlamb_vldb2012.pdf),” *Proceedings of the VLDB Endowment*, volume 5, number 12, pages 17901801, August 2012.
1. Julien Le Dem and Nong Li: “[Efficient Data Storage for Analytics with Apache Parquet 2.0](http://www.slideshare.net/julienledem/th-210pledem),” at *Hadoop Summit*, San Jose, June 2014.
1. Jim Gray, Surajit Chaudhuri, Adam Bosworth, et al.: “[Data Cube: A Relational Aggregation Operator Generalizing Group-By, Cross-Tab, and Sub-Totals](http://arxiv.org/pdf/cs/0701155.pdf),” *Data Mining and Knowledge Discovery*, volume 1, number 1, pages 2953, March 2007. [doi:10.1023/A:1009726021843](http://dx.doi.org/10.1023/A:1009726021843)
------
| 上一章 | 目录 | 下一章 |
| ------------------------------------ | ------------------------------- | ---------------------------- |
| [第二章:数据模型与查询语言](ch2.md) | [设计数据密集型应用](README.md) | [第四章:编码与演化](ch4.md) |

View File

@ -1,21 +1,22 @@
# 第四章:编码与演化
---
title: "第四章:编码与演化"
linkTitle: "4. 编码与演化"
weight: 104
breadcrumbs: false
---
![](img/ch4.png)
![](/img/ch4.png)
> 唯变所适
>
> —— 以弗所的赫拉克利特,为柏拉图所引(公元前 360 年)
>
-------------------
[TOC]
应用程序不可避免地随时间而变化。新产品的推出,对需求的深入理解,或者商业环境的变化,总会伴随着 **功能feature** 的增增改改。[第一章](ch1.md) 介绍了 **可演化性evolvability** 的概念:应该尽力构建能灵活适应变化的系统(请参阅 “[可演化性:拥抱变化](ch1.md#可演化性:拥抱变化)”)。
应用程序不可避免地随时间而变化。新产品的推出,对需求的深入理解,或者商业环境的变化,总会伴随着 **功能feature** 的增增改改。[第一章](/ch1) 介绍了 **可演化性evolvability** 的概念:应该尽力构建能灵活适应变化的系统(请参阅 “[可演化性:拥抱变化](/ch1#可演化性:拥抱变化)”)。
在大多数情况下,修改应用程序的功能也意味着需要更改其存储的数据:可能需要使用新的字段或记录类型,或者以新方式展示现有数据。
我们在 [第二章](ch2.md) 讨论的数据模型有不同的方法来应对这种变化。关系数据库通常假定数据库中的所有数据都遵循一个模式:尽管可以更改该模式(通过模式迁移,即 `ALTER` 语句),但是在任何时间点都有且仅有一个正确的模式。相比之下,**读时模式**schema-on-read**无模式**,即 schemaless数据库不会强制一个模式因此数据库可以包含在不同时间写入的新老数据格式的混合请参阅 “[文档模型中的模式灵活性](ch2.md#文档模型中的模式灵活性)” )。
我们在 [第二章](/ch2) 讨论的数据模型有不同的方法来应对这种变化。关系数据库通常假定数据库中的所有数据都遵循一个模式:尽管可以更改该模式(通过模式迁移,即 `ALTER` 语句),但是在任何时间点都有且仅有一个正确的模式。相比之下,**读时模式**schema-on-read**无模式**,即 schemaless数据库不会强制一个模式因此数据库可以包含在不同时间写入的新老数据格式的混合请参阅 “[文档模型中的模式灵活性](/ch2#文档模型中的模式灵活性)” )。
当数据 **格式format****模式schema** 发生变化时,通常需要对应用程序代码进行相应的更改(例如,为记录添加新字段,然后修改程序开始读写该字段)。但在大型应用程序中,代码变更通常不会立即完成:
@ -45,7 +46,7 @@
1. 在内存中,数据保存在对象、结构体、列表、数组、散列表、树等中。这些数据结构针对 CPU 的高效访问和操作进行了优化(通常使用指针)。
2. 如果要将数据写入文件,或通过网络发送,则必须将其 **编码encode** 为某种自包含的字节序列例如JSON 文档)。由于每个进程都有自己独立的地址空间,一个进程中的指针对任何其他进程都没有意义,所以这个字节序列表示会与通常在内存中使用的数据结构完全不同 [^i]。
[^i]: 除一些特殊情况外,例如某些内存映射文件或直接在压缩数据上操作(如 “[列压缩](ch3.md#列压缩)” 中所述)。
[^i]: 除一些特殊情况外,例如某些内存映射文件或直接在压缩数据上操作(如 “[列压缩](/ch3#列压缩)” 中所述)。
所以,需要在两种表示之间进行某种类型的翻译。从内存中表示到字节序列的转换称为 **编码Encoding** (也称为 **序列化serialization****编组marshalling**),反过来称为 **解码Decoding**[^ii]**解析Parsing****反序列化deserialization****反编组unmarshalling**[^译i]。
@ -53,7 +54,7 @@
[^译i]: Marshal 与 Serialization 的区别Marshal 不仅传输对象的状态,而且会一起传输对象的方法(相关代码)。
> #### 术语冲突
> 不幸的是,在 [第七章](ch7.md) **事务Transaction** 的上下文里,**序列化Serialization** 这个术语也出现了,而且具有完全不同的含义。尽管序列化可能是更常见的术语,为了避免术语重载,本书中坚持使用 **编码Encoding** 表达此含义。
> 不幸的是,在 [第七章](/ch7) **事务Transaction** 的上下文里,**序列化Serialization** 这个术语也出现了,而且具有完全不同的含义。尽管序列化可能是更常见的术语,为了避免术语重载,本书中坚持使用 **编码Encoding** 表达此含义。
这是一个常见的问题,因而有许多库和编码格式可供选择。首先让我们概览一下。
@ -113,7 +114,7 @@ JSON 比 XML 简洁,但与二进制格式相比还是太占空间。这一事
在下面的章节中,能达到比这好得多的结果,只用 32 个字节对相同的记录进行编码。
![](img/fig4-1.png)
![](/img/fig4-1.png)
**图 4-1 使用 MessagePack 编码的记录(例 4-1**
@ -141,27 +142,27 @@ message Person {
```
Thrift 和 Protocol Buffers 每一个都带有一个代码生成工具它采用了类似于这里所示的模式定义并且生成了以各种编程语言实现模式的类【18】。你的应用程序代码可以调用此生成的代码来对模式的记录进行编码或解码。
用这个模式编码的数据是什么样的令人困惑的是Thrift 有两种不同的二进制编码格式 [^iii],分别称为 BinaryProtocol 和 CompactProtocol。先来看看 BinaryProtocol。使用这种格式的编码来编码 [例 4-1]() 中的消息只需要 59 个字节,如 [图 4-2](img/fig4-2.png) 所示【19】。
用这个模式编码的数据是什么样的令人困惑的是Thrift 有两种不同的二进制编码格式 [^iii],分别称为 BinaryProtocol 和 CompactProtocol。先来看看 BinaryProtocol。使用这种格式的编码来编码 [例 4-1]() 中的消息只需要 59 个字节,如 [图 4-2](/img/fig4-2.png) 所示【19】。
![](img/fig4-2.png)
![](/img/fig4-2.png)
**图 4-2 使用 Thrift 二进制协议编码的记录**
[^iii]: 实际上Thrift 有三种二进制协议BinaryProtocol、CompactProtocol 和 DenseProtocol尽管 DenseProtocol 只支持 C ++ 实现所以不算作跨语言【18】。除此之外它还有两种不同的基于 JSON 的编码格式【19】。真逗
与 [图 4-1](Img/fig4-1.png) 类似,每个字段都有一个类型注释(用于指示它是一个字符串、整数、列表等),还可以根据需要指定长度(字符串的长度,列表中的项目数) 。出现在数据中的字符串 `(“Martin”, “daydreaming”, “hacking”)` 也被编码为 ASCII或者说UTF-8与之前类似。
与 [图 4-1](/img/fig4-1.png) 类似,每个字段都有一个类型注释(用于指示它是一个字符串、整数、列表等),还可以根据需要指定长度(字符串的长度,列表中的项目数) 。出现在数据中的字符串 `(“Martin”, “daydreaming”, “hacking”)` 也被编码为 ASCII或者说UTF-8与之前类似。
与 [图 4-1](img/fig4-1.png) 相比,最大的区别是没有字段名 `(userName, favoriteNumber, interests)`。相反,编码数据包含字段标签,它们是数字 `(1, 2 和 3)`。这些是模式定义中出现的数字。字段标记就像字段的别名 - 它们是说我们正在谈论的字段的一种紧凑的方式,而不必拼出字段名称。
与 [图 4-1](/img/fig4-1.png) 相比,最大的区别是没有字段名 `(userName, favoriteNumber, interests)`。相反,编码数据包含字段标签,它们是数字 `(1, 2 和 3)`。这些是模式定义中出现的数字。字段标记就像字段的别名 - 它们是说我们正在谈论的字段的一种紧凑的方式,而不必拼出字段名称。
Thrift CompactProtocol 编码在语义上等同于 BinaryProtocol但是如 [图 4-3](img/fig4-3.png) 所示,它只将相同的信息打包成只有 34 个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。数字 1337 不是使用全部八个字节,而是用两个字节编码,每个字节的最高位用来指示是否还有更多的字节。这意味着 - 64 到 63 之间的数字被编码为一个字节,-8192 和 8191 之间的数字以两个字节编码,等等。较大的数字使用更多的字节。
Thrift CompactProtocol 编码在语义上等同于 BinaryProtocol但是如 [图 4-3](/img/fig4-3.png) 所示,它只将相同的信息打包成只有 34 个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。数字 1337 不是使用全部八个字节,而是用两个字节编码,每个字节的最高位用来指示是否还有更多的字节。这意味着 - 64 到 63 之间的数字被编码为一个字节,-8192 和 8191 之间的数字以两个字节编码,等等。较大的数字使用更多的字节。
![](img/fig4-3.png)
![](/img/fig4-3.png)
**图 4-3 使用 Thrift 压缩协议编码的记录**
最后Protocol Buffers只有一种二进制编码格式对相同的数据进行编码如 [图 4-4](img/fig4-4.png) 所示。它的打包方式稍有不同,但与 Thrift 的 CompactProtocol 非常相似。Protobuf 将同样的记录塞进了 33 个字节中。
最后Protocol Buffers只有一种二进制编码格式对相同的数据进行编码如 [图 4-4](/img/fig4-4.png) 所示。它的打包方式稍有不同,但与 Thrift 的 CompactProtocol 非常相似。Protobuf 将同样的记录塞进了 33 个字节中。
![](img/fig4-4.png)
![](/img/fig4-4.png)
**图 4-4 使用 Protobuf 编码的记录**
@ -183,7 +184,7 @@ Thrift CompactProtocol 编码在语义上等同于 BinaryProtocol但是如 [
如何改变字段的数据类型?这也许是可能的 —— 详细信息请查阅相关的文档 —— 但是有一个风险,值将失去精度或被截断。例如,假设你将一个 32 位的整数变成一个 64 位的整数。新代码可以轻松读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取由新代码写入的数据,则旧代码仍使用 32 位变量来保存该值。如果解码的 64 位值不适合 32 位,则它将被截断。
Protobuf 的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(`repeated`,这是除必需和可选之外的第三个选项)。如 [图 4-4](img/fig4-4.png) 所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。
Protobuf 的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(`repeated`,这是除必需和可选之外的第三个选项)。如 [图 4-4](/img/fig4-4.png) 所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。
Thrift 有一个专用的列表数据类型,它使用列表元素的数据类型进行参数化。这不允许 Protocol Buffers 所做的从单值到多值的演变,但是它具有支持嵌套列表的优点。
@ -217,11 +218,11 @@ 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 相同)进行编码。
![](img/fig4-5.png)
![](/img/fig4-5.png)
**图 4-5 使用 Avro 编码的记录**
@ -235,11 +236,11 @@ record Person {
当一个应用程序想要解码一些数据(从一个文件或数据库读取数据、从网络接收数据等)时,它希望数据在某个模式中,这就是 Reader 模式。这是应用程序代码所依赖的模式,在应用程序的构建过程中,代码可能已经从该模式生成。
Avro 的关键思想是 Writer 模式和 Reader 模式不必是相同的 - 他们只需要兼容。当数据解码读取Avro 库通过并排查看 Writer 模式和 Reader 模式并将数据从 Writer 模式转换到 Reader 模式来解决差异。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)
![](/img/fig4-6.png)
**图 4-6 一个 Avro Reader 解决读写模式的差异**
@ -265,7 +266,7 @@ Avro 的关键思想是 Writer 模式和 Reader 模式不必是相同的 - 他
* 有很多记录的大文件
Avro 的一个常见用途 - 尤其是在 Hadoop 环境中 - 用于存储包含数百万条记录的大文件,所有记录都使用相同的模式进行编码(我们将在 [第十章](ch10.md) 讨论这种情况)。在这种情况下,该文件的作者可以在文件的开头只包含一次 Writer 模式。Avro 指定了一个文件格式(对象容器文件)来做到这一点。
Avro 的一个常见用途 - 尤其是在 Hadoop 环境中 - 用于存储包含数百万条记录的大文件,所有记录都使用相同的模式进行编码(我们将在 [第十章](/ch10) 讨论这种情况)。在这种情况下,该文件的作者可以在文件的开头只包含一次 Writer 模式。Avro 指定了一个文件格式(对象容器文件)来做到这一点。
* 支持独立写入的记录的数据库
@ -312,7 +313,7 @@ Avro 为静态类型编程语言提供了可选的代码生成功能,但是它
* 维护一个模式的数据库允许你在部署任何内容之前检查模式更改的向前和向后兼容性。
* 对于静态类型编程语言的用户来说,从模式生成代码的能力是有用的,因为它可以在编译时进行类型检查。
总而言之,模式演化保持了与 JSON 数据库提供的无模式 / 读时模式相同的灵活性(请参阅 “[文档模型中的模式灵活性](ch2.md#文档模型中的模式灵活性)”),同时还可以更好地保证你的数据并提供更好的工具。
总而言之,模式演化保持了与 JSON 数据库提供的无模式 / 读时模式相同的灵活性(请参阅 “[文档模型中的模式灵活性](/ch2#文档模型中的模式灵活性)”),同时还可以更好地保证你的数据并提供更好的工具。
## 数据流的类型
@ -342,7 +343,7 @@ Avro 为静态类型编程语言提供了可选的代码生成功能,但是它
前面讨论的编码格式支持未知字段的保存,但是有时候需要在应用程序层面保持谨慎,如图 4-7 所示。例如,如果将数据库值解码为应用程序中的模型对象,稍后重新编码这些模型对象,那么未知字段可能会在该翻译过程中丢失。解决这个问题不是一个难题,你只需要意识到它。
![](img/fig4-7.png)
![](/img/fig4-7.png)
**图 4-7 当较旧版本的应用程序更新以前由较新版本的应用程序编写的数据时,如果不小心,数据可能会丢失。**
@ -356,16 +357,16 @@ Avro 为静态类型编程语言提供了可选的代码生成功能,但是它
因此,模式演变允许整个数据库看起来好像是用单个模式编码的,即使底层存储可能包含用各种历史版本的模式编码的记录。
[^v]: 除了 MySQL即使并非真的必要它也经常会重写整个表正如 “[文档模型中的模式灵活性](ch2.md#文档模型中的模式灵活性)” 中所提到的。
[^v]: 除了 MySQL即使并非真的必要它也经常会重写整个表正如 “[文档模型中的模式灵活性](/ch2#文档模型中的模式灵活性)” 中所提到的。
#### 归档存储
也许你不时为数据库创建一个快照,例如备份或加载到数据仓库(请参阅 “[数据仓库](ch3.md#数据仓库)”)。在这种情况下,即使源数据库中的原始编码包含来自不同时代的模式版本的混合,数据转储通常也将使用最新模式进行编码。既然你不管怎样都要拷贝数据,那么你可以对这个数据拷贝进行一致的编码。
也许你不时为数据库创建一个快照,例如备份或加载到数据仓库(请参阅 “[数据仓库](/ch3#数据仓库)”)。在这种情况下,即使源数据库中的原始编码包含来自不同时代的模式版本的混合,数据转储通常也将使用最新模式进行编码。既然你不管怎样都要拷贝数据,那么你可以对这个数据拷贝进行一致的编码。
由于数据转储是一次写入的,而且以后是不可变的,所以 Avro 对象容器文件等格式非常适合。这也是一个很好的机会,可以将数据编码为面向分析的列式格式,例如 Parquet请参阅 “[列压缩](ch3.md#列压缩)”)。
由于数据转储是一次写入的,而且以后是不可变的,所以 Avro 对象容器文件等格式非常适合。这也是一个很好的机会,可以将数据编码为面向分析的列式格式,例如 Parquet请参阅 “[列压缩](/ch3#列压缩)”)。
在 [第十章](ch10.md) 中,我们将详细讨论使用档案存储中的数据。
在 [第十章](/ch10) 中,我们将详细讨论使用档案存储中的数据。
### 服务中的数据流REST与RPC
@ -378,7 +379,7 @@ Web 浏览器不是唯一的客户端类型。例如,在移动设备或桌面
此外,服务器本身可以是另一个服务的客户端(例如,典型的 Web 应用服务器充当数据库的客户端)。这种方法通常用于将大型应用程序按照功能区域分解为较小的服务,这样当一个服务需要来自另一个服务的某些功能或数据时,就会向另一个服务发出请求。这种构建应用程序的方式传统上被称为 **面向服务的体系结构service-oriented architectureSOA**,最近被改进和更名为 **微服务架构**【31,32】。
在某些方面,服务类似于数据库:它们通常允许客户端提交和查询数据。但是,虽然数据库允许使用我们在 [第二章](ch2.md) 中讨论的查询语言进行任意查询,但是服务公开了一个特定于应用程序的 API它只允许由服务的业务逻辑应用程序代码预定的输入和输出【33】。这种限制提供了一定程度的封装服务能够对客户可以做什么和不可以做什么施加细粒度的限制。
在某些方面,服务类似于数据库:它们通常允许客户端提交和查询数据。但是,虽然数据库允许使用我们在 [第二章](/ch2) 中讨论的查询语言进行任意查询,但是服务公开了一个特定于应用程序的 API它只允许由服务的业务逻辑应用程序代码预定的输入和输出【33】。这种限制提供了一定程度的封装服务能够对客户可以做什么和不可以做什么施加细粒度的限制。
面向服务 / 微服务架构的一个关键设计目标是通过使服务独立部署和演化来使应用程序更易于更改和维护。例如,每个服务应该由一个团队拥有,并且该团队应该能够经常发布新版本的服务,而不必与其他团队协调。换句话说,我们应该期望服务器和客户端的旧版本和新版本同时运行,因此服务器和客户端使用的数据编码必须在不同版本的服务 API 之间兼容 —— 这正是我们在本章所一直在谈论的。
@ -415,8 +416,8 @@ Web 服务仅仅是通过网络进行 API 请求的一系列技术的最新版
所有这些都是基于 **远程过程调用RPC** 的思想,该过程调用自 20 世纪 70 年代以来一直存在【42】。RPC 模型试图向远程网络服务发出请求,看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明)。尽管 RPC 起初看起来很方便但这种方法根本上是有缺陷的【43,44】。网络请求与本地函数调用非常不同
* 本地函数调用是可预测的,并且成功或失败仅取决于受你控制的参数。网络请求是不可预测的:请求或响应可能由于网络问题会丢失,或者远程计算机可能很慢或不可用,这些问题完全不在你的控制范围之内。网络问题很常见,因此必须有所准备,例如重试失败的请求。
* 本地函数调用要么返回结果,要么抛出异常,或者永远不返回(因为进入无限循环或进程崩溃)。网络请求有另一个可能的结果:由于超时,它返回时可能没有结果。在这种情况下,你根本不知道发生了什么:如果你没有得到来自远程服务的响应,你无法知道请求是否通过(我们将在 [第八章](ch8.md) 更详细地讨论这个问题)。
* 如果你重试失败的网络请求,可能会发生请求实际上已经完成,只是响应丢失的情况。在这种情况下,重试将导致该操作被执行多次,除非你在协议中建立数据去重机制(**幂等性**,即 idempotence。本地函数调用时没有这样的问题。在 [第十一章](ch11.md) 更详细地讨论幂等性)
* 本地函数调用要么返回结果,要么抛出异常,或者永远不返回(因为进入无限循环或进程崩溃)。网络请求有另一个可能的结果:由于超时,它返回时可能没有结果。在这种情况下,你根本不知道发生了什么:如果你没有得到来自远程服务的响应,你无法知道请求是否通过(我们将在 [第八章](/ch8) 更详细地讨论这个问题)。
* 如果你重试失败的网络请求,可能会发生请求实际上已经完成,只是响应丢失的情况。在这种情况下,重试将导致该操作被执行多次,除非你在协议中建立数据去重机制(**幂等性**,即 idempotence。本地函数调用时没有这样的问题。在 [第十一章](/ch11) 更详细地讨论幂等性)
* 每次调用本地函数时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也是非常可变的:好的时候它可能会在不到一毫秒的时间内完成,但是当网络拥塞或者远程服务超载时,可能需要几秒钟的时间才能完成相同的操作。
* 调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。如果参数是像数字或字符串这样的基本类型倒是没关系,但是对于较大的对象很快就会出现问题。
* 客户端和服务可以用不同的编程语言实现,所以 RPC 框架必须将数据类型从一种语言翻译成另一种语言。这可能会变得很丑陋,因为不是所有的语言都具有相同的类型 —— 例如回想一下 JavaScript 的数字大于 $2^{53}$ 的问题(请参阅 “[JSON、XML 和二进制变体](#JSON、XML和二进制变体)”)。用单一语言编写的单个进程中不存在此问题。
@ -429,7 +430,7 @@ Web 服务仅仅是通过网络进行 API 请求的一系列技术的最新版
这种新一代的 RPC 框架更加明确的是远程请求与本地函数调用不同。例如Finagle 和 Rest.li 使用 futurespromises来封装可能失败的异步操作。`Futures` 还可以简化需要并行发出多项服务并将其结果合并的情况【45】。gRPC 支持流其中一个调用不仅包括一个请求和一个响应还可以是随时间的一系列请求和响应【46】。
其中一些框架还提供服务发现,即允许客户端找出在哪个 IP 地址和端口号上可以找到特定的服务。我们将在 “[请求路由](ch6.md#请求路由)” 中回到这个主题。
其中一些框架还提供服务发现,即允许客户端找出在哪个 IP 地址和端口号上可以找到特定的服务。我们将在 “[请求路由](/ch6#请求路由)” 中回到这个主题。
使用二进制编码格式的自定义 RPC 协议可以实现比通用的 JSON over REST 更好的性能。但是RESTful API 还有其他一些显著的优点:方便实验和调试(只需使用 Web 浏览器或命令行工具 curl无需任何代码生成或软件安装即可向其请求能被所有主流的编程语言和平台所支持还有大量可用的工具服务器、缓存、负载平衡器、代理、防火墙、监控、调试工具、测试工具等的生态系统。
@ -467,15 +468,15 @@ RPC 方案的向后和向前兼容性属性是从它使用的编码方式中继
#### 消息代理
过去,**消息代理Message Broker** 主要是 TIBCO、IBM WebSphere 和 webMethods 等公司的商业软件的秀场。最近像 RabbitMQ、ActiveMQ、HornetQ、NATS 和 Apache Kafka 这样的开源实现已经流行起来。我们将在 [第十一章](ch11.md) 中对它们进行更详细的比较。
过去,**消息代理Message Broker** 主要是 TIBCO、IBM WebSphere 和 webMethods 等公司的商业软件的秀场。最近像 RabbitMQ、ActiveMQ、HornetQ、NATS 和 Apache Kafka 这样的开源实现已经流行起来。我们将在 [第十一章](/ch11) 中对它们进行更详细的比较。
详细的交付语义因实现和配置而异,但通常情况下,消息代理的使用方式如下:一个进程将消息发送到指定的队列或主题,代理确保将消息传递给那个队列或主题的一个或多个消费者或订阅者。在同一主题上可以有许多生产者和许多消费者。
一个主题只提供单向数据流。但是,消费者本身可能会将消息发布到另一个主题上(因此,可以将它们链接在一起,就像我们将在 [第十一章](ch11.md) 中看到的那样),或者发送给原始消息的发送者使用的回复队列(允许请求 / 响应数据流,类似于 RPC
一个主题只提供单向数据流。但是,消费者本身可能会将消息发布到另一个主题上(因此,可以将它们链接在一起,就像我们将在 [第十一章](/ch11) 中看到的那样),或者发送给原始消息的发送者使用的回复队列(允许请求 / 响应数据流,类似于 RPC
消息代理通常不会执行任何特定的数据模型 —— 消息只是包含一些元数据的字节序列,因此你可以使用任何编码格式。如果编码是向后和向前兼容的,你可以灵活地对发布者和消费者的编码进行独立的修改,并以任意顺序进行部署。
如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题([图 4-7](img/fig4-7.png))。
如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题([图 4-7](/img/fig4-7.png))。
#### 分布式的Actor框架
@ -573,9 +574,3 @@ Actor 模型是单个进程中并发的编程模型。逻辑被封装在 actor
1. “[Microsoft Project Orleans Documentation](http://dotnet.github.io/orleans/),” Microsoft Research, *dotnet.github.io*, 2015.
1. David Mercer, Sean Hinde, Yinso Chen, and Richard A O'Keefe: “[beginner: Updating Data Structures](http://erlang.org/pipermail/erlang-questions/2007-October/030318.html),” email thread on *erlang-questions* mailing list, *erlang.com*, October 29, 2007.
1. Fred Hebert: “[Postscript: Maps](http://learnyousomeerlang.com/maps),” *learnyousomeerlang.com*, April 9, 2014.
------
| 上一章 | 目录 | 下一章 |
| ---------------------------- | ------------------------------- | --------------------------------- |
| [第三章:存储与检索](ch3.md) | [设计数据密集型应用](README.md) | [第二部分:分布式数据](part-ii.md) |

View File

@ -1,22 +1,24 @@
# 第五章:复制
---
title: "第五章:复制"
linkTitle: "5. 复制"
weight: 205
breadcrumbs: false
---
![](img/ch5.png)
![](/img/ch5.png)
> 与可能出错的东西比,“不可能”出错的东西最显著的特点就是:一旦真的出错,通常就彻底玩完了。
>
> —— 道格拉斯・亚当斯1992
------
[TOC]
复制意味着在通过网络连接的多台机器上保留相同数据的副本。正如在 [第二部分](part-ii.md) 的介绍中所讨论的那样,我们希望能复制数据,可能出于各种各样的原因:
复制意味着在通过网络连接的多台机器上保留相同数据的副本。正如在 [第二部分](/part-ii) 的介绍中所讨论的那样,我们希望能复制数据,可能出于各种各样的原因:
* 使得数据与用户在地理上接近(从而减少延迟)
* 即使系统的一部分出现故障,系统也能继续工作(从而提高可用性)
* 伸缩可以接受读请求的机器数量(从而提高读取吞吐量)
本章将假设你的数据集非常小,每台机器都可以保存整个数据集的副本。在 [第六章](ch6.md) 中将放宽这个假设,讨论对单个机器来说太大的数据集的分割(分片)。在后面的章节中,我们将讨论复制数据系统中可能发生的各种故障,以及如何处理这些故障。
本章将假设你的数据集非常小,每台机器都可以保存整个数据集的副本。在 [第六章](/ch6) 中将放宽这个假设,讨论对单个机器来说太大的数据集的分割(分片)。在后面的章节中,我们将讨论复制数据系统中可能发生的各种故障,以及如何处理这些故障。
如果复制中的数据不会随时间而改变,那复制就很简单:将数据复制到每个节点一次就万事大吉。复制的困难之处在于处理复制数据的 **变更change**,这就是本章所要讲的。我们将讨论三种流行的变更复制算法:**单领导者single leader单主****多领导者multi leader多主** 和 **无领导者leaderless无主**。几乎所有分布式数据库都使用这三种方法之一。
@ -36,7 +38,7 @@
[^i]: 不同的人对 **热hot**、**温warm** 和 **冷cold** 备份服务器有不同的定义。例如在 PostgreSQL 中,**热备hot standby** 指的是能接受客户端读请求的副本。而 **温备warm standby** 只是追随领导者,但不处理客户端的任何查询。就本书而言,这些差异并不重要。
![](img/fig5-1.png)
![](/img/fig5-1.png)
**图 5-1 基于领导者的(主/从)复制**
@ -48,13 +50,13 @@
想象一下 [图 5-1](fig5-1.png) 中发生的场景,即网站的用户更新他们的个人头像。在某个时间点,客户向主库发送更新请求;不久之后主库就收到了请求。在某个时间点,主库又会将数据变更转发给自己的从库。最终,主库通知客户更新成功。
[图 5-2](img/fig5-2.png) 显示了系统各个组件之间的通信:用户客户端、主库和两个从库。时间从左向右流动。请求或响应消息用粗箭头表示。
[图 5-2](/img/fig5-2.png) 显示了系统各个组件之间的通信:用户客户端、主库和两个从库。时间从左向右流动。请求或响应消息用粗箭头表示。
![](img/fig5-2.png)
![](/img/fig5-2.png)
**图 5-2 基于领导者的复制:一个同步从库和一个异步从库**
在 [图 5-2](img/fig5-2.png) 的示例中,从库 1 的复制是同步的:在向用户报告写入成功并使结果对其他用户可见之前,主库需要等待从库 1 的确认,确保从库 1 已经收到写入操作。而从库 2 的复制是异步的:主库发送消息,但不等待该从库的响应。
在 [图 5-2](/img/fig5-2.png) 的示例中,从库 1 的复制是同步的:在向用户报告写入成功并使结果对其他用户可见之前,主库需要等待从库 1 的确认,确保从库 1 已经收到写入操作。而从库 2 的复制是异步的:主库发送消息,但不等待该从库的响应。
在这幅图中,从库 2 处理消息前存在一个显著的延迟。通常情况下,复制的速度相当快:大多数数据库系统能在不到一秒内完成从库的同步,但它们不能提供复制用时的保证。有些情况下,从库可能落后主库几分钟或更久,例如:从库正在从故障中恢复,系统正在最大容量附近运行,或者当节点间存在网络问题时。
@ -70,7 +72,7 @@
>
> 对于异步复制系统而言,主库故障时会丢失数据可能是一个严重的问题,因此研究人员仍在研究不丢数据但仍能提供良好性能和可用性的复制方法。例如,**链式复制chain replication**【8,9】是同步复制的一种变体已经在一些系统如 Microsoft Azure Storage【10,11】中成功实现。
>
> 复制的一致性与 **共识**consensus使几个节点就某个值达成一致之间有着密切的联系[第九章](ch9.md) 将详细地探讨这一领域的理论。本章主要讨论实践中的数据库常用的简单复制形式。
> 复制的一致性与 **共识**consensus使几个节点就某个值达成一致之间有着密切的联系[第九章](/ch9) 将详细地探讨这一领域的理论。本章主要讨论实践中的数据库常用的简单复制形式。
>
### 设置新从库
@ -105,8 +107,8 @@
故障切换可以手动进行(通知管理员主库挂了,并采取必要的步骤来创建新的主库)或自动进行。自动的故障切换过程通常由以下步骤组成:
1. 确认主库失效。有很多事情可能会出错:崩溃、停电、网络问题等等。没有万无一失的方法来检测出现了什么问题,所以大多数系统只是简单使用 **超时Timeout** :节点频繁地相互来回传递消息,如果一个节点在一段时间内(例如 30 秒)没有响应,就认为它挂了(因为计划内维护而故意关闭主库不算)。
2. 选择一个新的主库。这可以通过选举过程(主库由剩余副本以多数选举产生)来完成,或者可以由之前选定的 **控制器节点controller node** 来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库(以最小化数据损失)。让所有的节点同意一个新的领导者,是一个 **共识** 问题,将在 [第九章](ch9.md) 详细讨论。
3. 重新配置系统以启用新的主库。客户端现在需要将它们的写请求发送给新主库(将在 “[请求路由](ch6.md#请求路由)” 中讨论这个问题)。如果旧主库恢复,可能仍然认为自己是主库,而没有意识到其他副本已经让它失去领导权了。系统需要确保旧主库意识到新主库的存在,并成为一个从库。
2. 选择一个新的主库。这可以通过选举过程(主库由剩余副本以多数选举产生)来完成,或者可以由之前选定的 **控制器节点controller node** 来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库(以最小化数据损失)。让所有的节点同意一个新的领导者,是一个 **共识** 问题,将在 [第九章](/ch9) 详细讨论。
3. 重新配置系统以启用新的主库。客户端现在需要将它们的写请求发送给新主库(将在 “[请求路由](/ch6#请求路由)” 中讨论这个问题)。如果旧主库恢复,可能仍然认为自己是主库,而没有意识到其他副本已经让它失去领导权了。系统需要确保旧主库意识到新主库的存在,并成为一个从库。
故障切换的过程中有很多地方可能出错:
@ -114,15 +116,15 @@
* 如果数据库需要和其他外部存储相协调,那么丢弃写入内容是极其危险的操作。例如在 GitHub 【13】的一场事故中一个过时的 MySQL 从库被提升为主库。数据库使用自增 ID 作为主键,因为新主库的计数器落后于老主库的计数器,所以新主库重新分配了一些已经被老主库分配掉的 ID 作为主键。这些主键也在 Redis 中使用,主键重用使得 MySQL 和 Redis 中的数据产生不一致,最后导致一些私有数据泄漏到错误的用户手中。
* 发生某些故障时(见 [第八章](ch8.md))可能会出现两个节点都以为自己是主库的情况。这种情况称为 **脑裂split brain**,非常危险:如果两个主库都可以接受写操作,却没有冲突解决机制(请参阅 “[多主复制](#多主复制)”),那么数据就可能丢失或损坏。一些系统采取了安全防范措施:当检测到两个主库节点同时存在时会关闭其中一个节点 [^ii]但设计粗糙的机制可能最后会导致两个节点都被关闭【14】。
* 发生某些故障时(见 [第八章](/ch8))可能会出现两个节点都以为自己是主库的情况。这种情况称为 **脑裂split brain**,非常危险:如果两个主库都可以接受写操作,却没有冲突解决机制(请参阅 “[多主复制](#多主复制)”),那么数据就可能丢失或损坏。一些系统采取了安全防范措施:当检测到两个主库节点同时存在时会关闭其中一个节点 [^ii]但设计粗糙的机制可能最后会导致两个节点都被关闭【14】。
[^ii]: 这种机制称为 **屏障fencing**,或者更充满感情的术语是:**爆彼之头Shoot The Other Node In The Head, STONITH**。我们将在 “[领导者和锁](ch8.md#领导者和锁)” 中对屏障进行详细讨论。
[^ii]: 这种机制称为 **屏障fencing**,或者更充满感情的术语是:**爆彼之头Shoot The Other Node In The Head, STONITH**。我们将在 “[领导者和锁](/ch8#领导者和锁)” 中对屏障进行详细讨论。
* 主库被宣告死亡之前的正确超时应该怎么配置?在主库失效的情况下,超时时间越长意味着恢复时间也越长。但是如果超时设置太短,又可能会出现不必要的故障切换。例如,临时的负载峰值可能导致节点的响应时间增加到超出超时时间,或者网络故障也可能导致数据包延迟。如果系统已经处于高负载或网络问题的困扰之中,那么不必要的故障切换可能会让情况变得更糟糕。
这些问题没有简单的解决方案。因此,即使软件支持自动故障切换,不少运维团队还是更愿意手动执行故障切换。
节点故障、不可靠的网络、对副本一致性、持久性、可用性和延迟的权衡,这些问题实际上是分布式系统中的基本问题。[第八章](ch8.md) 和 [第九章](ch9.md) 将更深入地讨论它们。
节点故障、不可靠的网络、对副本一致性、持久性、可用性和延迟的权衡,这些问题实际上是分布式系统中的基本问题。[第八章](/ch8) 和 [第九章](/ch9) 将更深入地讨论它们。
### 复制日志的实现
@ -144,10 +146,10 @@
#### 传输预写式日志WAL
在 [第三章](ch3.md) 中,我们讨论了存储引擎如何在磁盘上表示数据,我们也发现了通常会将写操作追加到日志中:
在 [第三章](/ch3) 中,我们讨论了存储引擎如何在磁盘上表示数据,我们也发现了通常会将写操作追加到日志中:
* 对于日志结构存储引擎(请参阅 “[SSTables 和 LSM 树](ch3.md#SSTables和LSM树)”),日志是主要的存储位置。日志段在后台压缩,并进行垃圾回收。
* 对于覆写单个磁盘块的 [B 树](ch3.md#B树),每次修改都会先写入 **预写式日志Write Ahead Log, WAL**,以便崩溃后索引可以恢复到一个一致的状态。
* 对于日志结构存储引擎(请参阅 “[SSTables 和 LSM 树](/ch3#SSTables和LSM树)”),日志是主要的存储位置。日志段在后台压缩,并进行垃圾回收。
* 对于覆写单个磁盘块的 [B 树](/ch3#B树),每次修改都会先写入 **预写式日志Write Ahead Log, WAL**,以便崩溃后索引可以恢复到一个一致的状态。
在任何一种情况下,该日志都是包含了所有数据库写入的仅追加字节序列。可以使用完全相同的日志在另一个节点上构建副本:除了将日志写入磁盘之外,主库还可以通过网络将其发送给从库。
@ -171,7 +173,7 @@
由于逻辑日志与存储引擎的内部实现是解耦的,系统可以更容易地做到向后兼容,从而使主库和从库能够运行不同版本的数据库软件,或者甚至不同的存储引擎。
对于外部应用程序来说逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统例如复制到数据仓库进行离线分析或建立自定义索引和缓存【18】这一点会很有用。这种技术被称为 **数据变更捕获change data capture**[第十一章](ch11.md) 将重新讲到它。
对于外部应用程序来说逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统例如复制到数据仓库进行离线分析或建立自定义索引和缓存【18】这一点会很有用。这种技术被称为 **数据变更捕获change data capture**[第十一章](/ch11) 将重新讲到它。
#### 基于触发器的复制
@ -186,7 +188,7 @@
## 复制延迟问题
容忍节点故障只是需要复制的一个原因。正如在 [第二部分](part-ii.md) 的介绍中提到的,其它原因还包括可伸缩性(处理比单个机器更多的请求)和延迟(让副本在地理位置上更接近用户)。
容忍节点故障只是需要复制的一个原因。正如在 [第二部分](/part-ii) 的介绍中提到的,其它原因还包括可伸缩性(处理比单个机器更多的请求)和延迟(让副本在地理位置上更接近用户)。
基于领导者的复制要求所有写入都由单个节点处理但只读查询可以由任何一个副本来处理。所以对于读多写少的场景Web 上的常见模式),一个有吸引力的选择是创建很多从库,并将读请求分散到所有的从库上去。这样能减小主库的负载,并允许由附近的副本来处理读请求。
@ -206,7 +208,7 @@
但对于异步复制,问题就来了。如 [图 5-3](fig5-3.png) 所示:如果用户在写入后马上就查看数据,则新数据可能尚未到达副本。对用户而言,看起来好像是刚提交的数据丢失了,所以他们不高兴是可以理解的。
![](img/fig5-3.png)
![](/img/fig5-3.png)
**图 5-3 用户写入后从旧副本中读取数据。需要写后读 (read-after-write) 的一致性来防止这种异常**
@ -218,7 +220,7 @@
* 如果应用中的大部分内容都可能被用户编辑,那这种方法就没用了,因为大部分内容都必须从主库读取(读伸缩就没效果了)。在这种情况下可以使用其他标准来决定是否从主库读取。例如可以跟踪上次更新的时间,在上次更新后的一分钟内,从主库读。还可以监控从库的复制延迟,防止向任何滞后主库超过一分钟的从库发出查询。
* 客户端可以记住最近一次写入的时间戳,系统需要确保从库在处理该用户的读取请求时,该时间戳前的变更都已经传播到了本从库中。如果当前从库不够新,则可以从另一个从库读取,或者等待从库追赶上来。这里的时间戳可以是逻辑时间戳(表示写入顺序的东西,例如日志序列号)或实际的系统时钟(在这种情况下,时钟同步变得至关重要,请参阅 “[不可靠的时钟](ch8.md#不可靠的时钟)”)。
* 客户端可以记住最近一次写入的时间戳,系统需要确保从库在处理该用户的读取请求时,该时间戳前的变更都已经传播到了本从库中。如果当前从库不够新,则可以从另一个从库读取,或者等待从库追赶上来。这里的时间戳可以是逻辑时间戳(表示写入顺序的东西,例如日志序列号)或实际的系统时钟(在这种情况下,时钟同步变得至关重要,请参阅 “[不可靠的时钟](/ch8#不可靠的时钟)”)。
* 如果你的副本分布在多个数据中心(为了在地理上接近用户或者出于可用性目的),还会有额外的复杂性。任何需要由主库提供服务的请求都必须路由到包含该主库的数据中心。
@ -234,9 +236,9 @@
在从异步从库读取时可能发生的异常的第二个例子是用户可能会遇到 **时光倒流moving backward in time**
如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图 5-4](img/fig5-4.png) 显示了用户 2345 两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库(如果用户刷新网页时每个请求都被路由到一个随机的服务器,这种情况就很有可能发生)。第一个查询返回了最近由用户 1234 添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取到该写入内容。实际上可以认为第二个查询是在比第一个查询更早的时间点上观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户 2345 可能不知道用户 1234 最近添加了评论。但如果用户 2345 先看见用户 1234 的评论,然后又看到它消失,这就会让人觉得非常困惑了。
如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图 5-4](/img/fig5-4.png) 显示了用户 2345 两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库(如果用户刷新网页时每个请求都被路由到一个随机的服务器,这种情况就很有可能发生)。第一个查询返回了最近由用户 1234 添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取到该写入内容。实际上可以认为第二个查询是在比第一个查询更早的时间点上观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户 2345 可能不知道用户 1234 最近添加了评论。但如果用户 2345 先看见用户 1234 的评论,然后又看到它消失,这就会让人觉得非常困惑了。
![](img/fig5-4.png)
![](/img/fig5-4.png)
**图 5-4 用户首先从新副本读取,然后从旧副本读取。时间看上去回退了。为了防止这种异常,我们需要单调的读取。**
@ -257,7 +259,7 @@
这两句话之间有因果关系Cake 夫人听到了 Poons 先生的问题并回答了这个问题。
现在想象第三个人正在通过从库来听这个对话。Cake 夫人说的内容是从一个延迟很低的从库读取的,但 Poons 先生所说的内容,从库的延迟要大的多(见 [图 5-5](img/fig5-5.png))。于是,这个观察者会听到以下内容:
现在想象第三个人正在通过从库来听这个对话。Cake 夫人说的内容是从一个延迟很低的从库读取的,但 Poons 先生所说的内容,从库的延迟要大的多(见 [图 5-5](/img/fig5-5.png))。于是,这个观察者会听到以下内容:
*Mrs. Cake*
> 通常约十秒钟Mr. Poons.
@ -267,13 +269,13 @@
对于观察者来说,看起来好像 Cake 夫人在 Poons 先生提问前就回答了这个问题。这种超能力让人印象深刻但也会把人搞糊涂。【25】。
![](img/fig5-5.png)
![](/img/fig5-5.png)
**图 5-5 如果某些分区的复制速度慢于其他分区,那么观察者可能会在看到问题之前先看到答案。**
要防止这种异常,需要另一种类型的保证:**一致前缀读consistent prefix reads**【23】。这个保证的意思是说如果一系列写入按某个顺序发生那么任何人读取这些写入时也会看见它们以同样的顺序出现。
这是 **分区partitioned****分片sharded** 数据库中的一个特殊问题,我们将在 [第六章](ch6.md) 中讨论分区数据库。如果数据库总是以相同的顺序应用写入,而读取总是看到一致的前缀,那么这种异常不会发生。但是在许多分布式数据库中,不同的分区独立运行,因此不存在 **全局的写入顺序**:当用户从数据库中读取数据时,可能会看到数据库的某些部分处于较旧的状态,而某些则处于较新的状态。
这是 **分区partitioned****分片sharded** 数据库中的一个特殊问题,我们将在 [第六章](/ch6) 中讨论分区数据库。如果数据库总是以相同的顺序应用写入,而读取总是看到一致的前缀,那么这种异常不会发生。但是在许多分布式数据库中,不同的分区独立运行,因此不存在 **全局的写入顺序**:当用户从数据库中读取数据时,可能会看到数据库的某些部分处于较旧的状态,而某些则处于较新的状态。
一种解决方案是,确保任何因果相关的写入都写入相同的分区,但在一些应用中可能无法高效地完成这种操作。还有一些显式跟踪因果依赖关系的算法,我们将在 “[“此前发生” 的关系和并发](#“此前发生”的关系和并发)” 一节中回到这个话题。
@ -285,7 +287,7 @@
如果应用程序开发人员不必担心微妙的复制问题,并可以信赖他们的数据库 “做了正确的事情”,那该多好呀。这就是 **事务transaction** 存在的原因:**数据库通过事务提供强大的保证**,所以应用程序可以更加简单。
单节点事务已经存在了很长时间。然而在走向分布式(复制和分区)数据库时,许多系统放弃了事务,声称事务在性能和可用性上的代价太高,并断言在可伸缩系统中最终一致性是不可避免的。这个叙述有一些道理,但过于简单了,本书其余部分将提出更为细致的观点。我们将在 [第七章](ch7.md) 和 [第九章](ch9.md) 回到事务的话题,并将在 [第三部分](part-iii.md) 讨论一些替代机制。
单节点事务已经存在了很长时间。然而在走向分布式(复制和分区)数据库时,许多系统放弃了事务,声称事务在性能和可用性上的代价太高,并断言在可伸缩系统中最终一致性是不可避免的。这个叙述有一些道理,但过于简单了,本书其余部分将提出更为细致的观点。我们将在 [第七章](/ch7) 和 [第九章](/ch9) 回到事务的话题,并将在 [第三部分](/part-iii) 讨论一些替代机制。
## 多主复制
@ -294,7 +296,7 @@
基于领导者的复制有一个主要的缺点:只有一个主库,而且所有的写入都必须通过它 [^iv]。如果出于任何原因(例如和主库之间的网络连接中断)无法连接到主库,就无法向数据库写入。
[^iv]: 如果数据库被分区(见 [第六章](ch6.md)),每个分区都有一个主库。不同的分区的主库可能在不同的节点上,但是每个分区都必须有一个主库。
[^iv]: 如果数据库被分区(见 [第六章](/ch6)),每个分区都有一个主库。不同的分区的主库可能在不同的节点上,但是每个分区都必须有一个主库。
基于领导者的复制模型的自然延伸是允许多个节点接受写入。复制仍然以同样的方式发生:处理写入的每个节点都必须将该数据变更转发给所有其他节点。我们将其称之为 **多领导者配置**multi-leader configuration也称多主、多活复制即 master-master replication 或 active/active replication。在这种情况下每个主库同时是其他主库的从库。
@ -306,9 +308,9 @@
假如你有一个数据库,副本分散在好几个不同的数据中心(可能会用来容忍单个数据中心的故障,或者为了在地理上更接近用户)。如果使用常规的基于领导者的复制设置,主库必须位于其中一个数据中心,且所有写入都必须经过该数据中心。
多主配置中可以在每个数据中心都有主库。[图 5-6](img/fig5-6.png) 展示了这个架构。在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。
多主配置中可以在每个数据中心都有主库。[图 5-6](/img/fig5-6.png) 展示了这个架构。在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。
![](img/fig5-6.png)
![](/img/fig5-6.png)
**图 5-6 跨多个数据中心的多主复制**
@ -328,7 +330,7 @@
有些数据库默认情况下支持多主配置,但使用外部工具实现也很常见,例如用于 MySQL 的 Tungsten Replicator 【26】用于 PostgreSQL 的 BDR【27】以及用于 Oracle 的 GoldenGate 【19】。
尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如 [图 5-6](img/fig5-6.png) 中的 “冲突解决conflict resolution。本书将在 “[处理写入冲突](#处理写入冲突)” 中详细讨论这个问题。
尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如 [图 5-6](/img/fig5-6.png) 中的 “冲突解决conflict resolution。本书将在 “[处理写入冲突](#处理写入冲突)” 中详细讨论这个问题。
由于多主复制在许多数据库中都属于改装的功能所以常常存在微妙的配置缺陷且经常与其他数据库功能之间出现意外的反应。比如自增主键、触发器、完整性约束等都可能会有麻烦。因此多主复制往往被认为是危险的领域应尽可能避免【28】。
@ -356,9 +358,9 @@
多主复制的最大问题是可能发生写冲突,这意味着需要解决冲突。
例如,考虑一个由两个用户同时编辑的维基页面,如 [图 5-7](img/fig5-7.png) 所示。用户 1 将页面的标题从 A 更改为 B并且用户 2 同时将标题从 A 更改为 C。每个用户的更改已成功应用到其本地主库。但当异步复制时会发现冲突【33】。单主数据库中不会出现此问题。
例如,考虑一个由两个用户同时编辑的维基页面,如 [图 5-7](/img/fig5-7.png) 所示。用户 1 将页面的标题从 A 更改为 B并且用户 2 同时将标题从 A 更改为 C。每个用户的更改已成功应用到其本地主库。但当异步复制时会发现冲突【33】。单主数据库中不会出现此问题。
![](img/fig5-7.png)
![](/img/fig5-7.png)
**图 5-7 两个主库同时更新同一记录引起的写入冲突**
@ -380,7 +382,7 @@
单主数据库按顺序进行写操作:如果同一个字段有多个更新,则最后一个写操作将决定该字段的最终值。
在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在 [图 5-7](img/fig5-7.png) 中,在主库 1 中标题首先更新为 B 而后更新为 C在主库 2 中,首先更新为 C然后更新为 B。两种顺序都不比另一种“更正确”。
在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在 [图 5-7](/img/fig5-7.png) 中,在主库 1 中标题首先更新为 B 而后更新为 C在主库 2 中,首先更新为 C然后更新为 B。两种顺序都不比另一种“更正确”。
如果每个副本只是按照它看到写入的顺序写入,那么数据库最终将处于不一致的状态:最终值将是在主库 1 的 C 和主库 2 的 B。这是不可接受的每个复制方案都必须确保数据最终在所有副本中都是相同的。因此数据库必须以一种 **收敛convergent** 的方式解决冲突,这意味着所有副本必须在所有变更复制完成时收敛至一个相同的最终值。
@ -388,7 +390,7 @@
* 给每个写入一个唯一的 ID例如时间戳、长随机数、UUID 或者键和值的哈希),挑选最高 ID 的写入作为胜利者,并丢弃其他写入。如果使用时间戳,这种技术被称为 **最后写入胜利LWW, last write wins**。虽然这种方法很流行但是很容易造成数据丢失【35】。我们将在本章末尾的 [检测并发写入](#检测并发写入) 一节更详细地讨论 LWW。
* 为每个副本分配一个唯一的 IDID 编号更高的写入具有更高的优先级。这种方法也意味着数据丢失。
* 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在 [图 5-7](img/fig5-7.png) 中,合并的标题可能类似于 “B/C”
* 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在 [图 5-7](/img/fig5-7.png) 中,合并的标题可能类似于 “B/C”
* 用一种可保留所有信息的显式数据结构来记录冲突,并编写解决冲突的应用程序代码(也许通过提示用户的方式)。
@ -404,7 +406,7 @@
当检测到冲突时,所有冲突写入被存储。下一次读取数据时,会将这些多个版本的数据返回给应用程序。应用程序可以提示用户或自动解决冲突,并将结果写回数据库。例如 CouchDB 就以这种方式工作。
请注意冲突解决通常适用于单行记录或单个文档的层面而不是整个事务【36】。因此如果你有一个事务会原子性地进行几次不同的写入请参阅 [第七章](ch7.md)),对于冲突解决而言,每个写入仍需分开单独考虑。
请注意冲突解决通常适用于单行记录或单个文档的层面而不是整个事务【36】。因此如果你有一个事务会原子性地进行几次不同的写入请参阅 [第七章](/ch7)),对于冲突解决而言,每个写入仍需分开单独考虑。
> #### 自动冲突解决
@ -422,38 +424,38 @@
#### 什么是冲突?
有些冲突是显而易见的。在 [图 5-7](img/fig5-7.png) 的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。
有些冲突是显而易见的。在 [图 5-7](/img/fig5-7.png) 的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。
其他类型的冲突可能更为微妙而难以发现。例如,考虑一个会议室预订系统:它记录谁订了哪个时间段的哪个房间。应用程序需要确保每个房间在任意时刻都只能被一组人进行预定(即不得有相同房间的重叠预订)。在这种情况下,如果为同一个房间同时创建两个不同的预订,则可能会发生冲突。即使应用程序在允许用户进行预订之前先检查会议室的可用性,如果两次预订是由两个不同的主库进行的,则仍然可能会有冲突。
虽然现在还没有一个现成的答案,但在接下来的章节中,我们将更好地了解这个问题。我们将在 [第七章](ch7.md) 中看到更多的冲突示例,在 [第十二章](ch12.md) 中我们将讨论用于检测和解决复制系统中冲突的可伸缩方法。
虽然现在还没有一个现成的答案,但在接下来的章节中,我们将更好地了解这个问题。我们将在 [第七章](/ch7) 中看到更多的冲突示例,在 [第十二章](/ch12) 中我们将讨论用于检测和解决复制系统中冲突的可伸缩方法。
### 多主复制拓扑
**复制拓扑**replication topology用来描述写入操作从一个节点传播到另一个节点的通信路径。如果你有两个主库如 [图 5-7](img/fig5-7.png) 所示,只有一个合理的拓扑结构:主库 1 必须把它所有的写入都发送到主库 2反之亦然。当有两个以上的主库多种不同的拓扑都是可能的。[图 5-8](img/fig5-8.png) 举例说明了一些例子。
**复制拓扑**replication topology用来描述写入操作从一个节点传播到另一个节点的通信路径。如果你有两个主库如 [图 5-7](/img/fig5-7.png) 所示,只有一个合理的拓扑结构:主库 1 必须把它所有的写入都发送到主库 2反之亦然。当有两个以上的主库多种不同的拓扑都是可能的。[图 5-8](/img/fig5-8.png) 举例说明了一些例子。
![](img/fig5-8.png)
![](/img/fig5-8.png)
**图 5-8 三种可以在多主复制中使用的拓扑示例。**
最常见的拓扑是全部到全部all-to-all如 [图 5-8 (c)](img/fig5-8.png)),其中每个主库都将其写入发送给其他所有的主库。然而,一些更受限的拓扑也会被使用到:例如,默认情况下 MySQL 仅支持 **环形拓扑circular topology**【34】其中每个节点都从一个节点接收写入并将这些写入加上自己的写入转发给另一个节点。另一种流行的拓扑结构具有星形的形状 [^v]:一个指定的根节点将写入转发给所有其他节点。星形拓扑可以推广到树。
最常见的拓扑是全部到全部all-to-all如 [图 5-8 (c)](/img/fig5-8.png)),其中每个主库都将其写入发送给其他所有的主库。然而,一些更受限的拓扑也会被使用到:例如,默认情况下 MySQL 仅支持 **环形拓扑circular topology**【34】其中每个节点都从一个节点接收写入并将这些写入加上自己的写入转发给另一个节点。另一种流行的拓扑结构具有星形的形状 [^v]:一个指定的根节点将写入转发给所有其他节点。星形拓扑可以推广到树。
[^v]: 不要与星型模式混淆(请参阅 “[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”),其中描述了数据模型的结构,而不是节点之间的通信拓扑。
[^v]: 不要与星型模式混淆(请参阅 “[星型和雪花型:分析的模式](/ch3#星型和雪花型:分析的模式)”),其中描述了数据模型的结构,而不是节点之间的通信拓扑。
在环形和星形拓扑中写入可能需要在到达所有副本之前通过多个节点。因此节点需要转发从其他节点收到的数据更改。为了防止无限复制循环每个节点被赋予一个唯一的标识符并且在复制日志中每次写入都会使用其经过的所有节点的标识符进行标记【43】。当一个节点收到用自己的标识符标记的数据更改时该数据更改将被忽略因为节点知道它已经被处理过。
环形和星形拓扑的问题是,如果只有一个节点发生故障,则可能会中断其他节点之间的复制消息流,导致它们无法通信,除非节点被修复。拓扑结构可以重新配置为跳过发生故障的节点,但在大多数部署中,这种重新配置必须手动完成。更密集连接的拓扑结构(例如全部到全部)的容错性更好,因为它允许消息沿着不同的路径传播,可以避免单点故障。
另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如由于网络拥塞),结果是一些复制消息可能 “超越” 其他复制消息,如 [图 5-9](img/fig5-9.png) 所示。
另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如由于网络拥塞),结果是一些复制消息可能 “超越” 其他复制消息,如 [图 5-9](/img/fig5-9.png) 所示。
![](img/fig5-9.png)
![](/img/fig5-9.png)
**图 5-9 使用多主复制时,写入可能会以错误的顺序到达某些副本。**
在 [图 5-9](img/fig5-9.png) 中,客户端 A 向主库 1 的表中插入一行,客户端 B 在主库 3 上更新该行。然而,主库 2 可以以不同的顺序接收写入:它可能先接收到更新(从它的角度来看,是对数据库中不存在的行的更新),稍后才接收到相应的插入(其应该在更新之前)。
在 [图 5-9](/img/fig5-9.png) 中,客户端 A 向主库 1 的表中插入一行,客户端 B 在主库 3 上更新该行。然而,主库 2 可以以不同的顺序接收写入:它可能先接收到更新(从它的角度来看,是对数据库中不存在的行的更新),稍后才接收到相应的插入(其应该在更新之前)。
这是一个因果关系的问题,类似于我们在 “[一致前缀读](#一致前缀读)” 中看到的:更新取决于先前的插入,所以我们需要确保所有节点先处理插入,然后再处理更新。仅仅在每一次写入时添加一个时间戳是不够的,因为时钟不可能被充分地同步,所以主库 2 就无法正确地对这些事件进行排序(见 [第八章](ch8.md))。
这是一个因果关系的问题,类似于我们在 “[一致前缀读](#一致前缀读)” 中看到的:更新取决于先前的插入,所以我们需要确保所有节点先处理插入,然后再处理更新。仅仅在每一次写入时添加一个时间戳是不够的,因为时钟不可能被充分地同步,所以主库 2 就无法正确地对这些事件进行排序(见 [第八章](/ch8))。
要正确排序这些事件,可以使用一种称为 **版本向量version vectors** 的技术,本章稍后将讨论这种技术(请参阅 “[检测并发写入](#检测并发写入)”。然而许多多主复制系统中的冲突检测技术实现得并不好。例如在撰写本文时PostgreSQL BDR 不提供写入的因果排序【27】而 Tungsten Replicator for MySQL 甚至都不做检测冲突【34】。
@ -474,9 +476,9 @@
假设你有一个带有三个副本的数据库,而其中一个副本目前不可用,或许正在重新启动以安装系统更新。在基于领导者的配置中,如果要继续处理写入,则可能需要执行故障切换(请参阅「[处理节点宕机](#处理节点宕机)」)。
另一方面,在无主配置中,不存在故障转移。[图 5-10](img/fig5-10.png) 演示了会发生了什么事情:客户端(用户 1234并行发送写入到所有三个副本并且两个可用副本接受写入但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的在用户 1234 已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。
另一方面,在无主配置中,不存在故障转移。[图 5-10](/img/fig5-10.png) 演示了会发生了什么事情:客户端(用户 1234并行发送写入到所有三个副本并且两个可用副本接受写入但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的在用户 1234 已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。
![](img/fig5-10.png)
![](/img/fig5-10.png)
**图 5-10 法定写入,法定读取,并在节点中断后读修复。**
@ -492,7 +494,7 @@
* 读修复Read repair
当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在 [图 5-10](img/fig5-10.png) 中,用户 2345 获得了来自副本 3 的版本 6 值和来自副本 1 和 2 的版本 7 值。客户端发现副本 3 具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。
当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在 [图 5-10](/img/fig5-10.png) 中,用户 2345 获得了来自副本 3 的版本 6 值和来自副本 1 和 2 的版本 7 值。客户端发现副本 3 具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。
* 反熵过程Anti-entropy process
@ -502,7 +504,7 @@
#### 读写的法定人数
在 [图 5-10](img/fig5-10.png) 的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?以此类推,究竟多少个副本完成才可以认为写入成功?
在 [图 5-10](/img/fig5-10.png) 的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?以此类推,究竟多少个副本完成才可以认为写入成功?
如果我们知道,每个成功的写操作意味着在三个副本中至少有两个出现,这意味着至多有一个副本可能是陈旧的。因此,如果我们从至少两个副本读取,我们可以确定至少有一个是最新的。如果第三个副本停机或响应速度缓慢,则读取仍可以继续返回最新值。
@ -512,17 +514,17 @@
在 Dynamo 风格的数据库中,参数 n、w 和 r 通常是可配置的。一个常见的选择是使 n 为奇数(通常为 3 或 5并设置 $w = r = (n + 1) / 2$(向上取整)。但是你可以根据需要更改数字。例如,写入次数较少且读取次数较多的工作负载可以从设置 $w = n$ 和 $r = 1$中受益。这会使得读取速度更快,但缺点是只要有一个不可用的节点就会导致所有的数据库写入都失败。
> 集群中可能有多于 n 个的节点(集群的机器数可能多于副本数目)。但是任何给定的值只能存储在 n 个节点上。这允许对数据集进行分区,从而可以支持比单个节点的存储能力更大的数据集。我们将在 [第六章](ch6.md) 继续讨论分区。
> 集群中可能有多于 n 个的节点(集群的机器数可能多于副本数目)。但是任何给定的值只能存储在 n 个节点上。这允许对数据集进行分区,从而可以支持比单个节点的存储能力更大的数据集。我们将在 [第六章](/ch6) 继续讨论分区。
法定人数条件 $w + r > n$ 允许系统容忍不可用的节点,如下所示:
* 如果 $w < n$当节点不可用时我们仍然可以处理写入
* 如果 $r < n$当节点不可用时我们仍然可以处理读取
* 对于 $n = 3w = 2r = 2$,我们可以容忍一个不可用的节点。
* 对于 $n = 5w = 3r = 3$,我们可以容忍两个不可用的节点。这个案例如 [图 5-11](img/fig5-11.png) 所示。
* 对于 $n = 5w = 3r = 3$,我们可以容忍两个不可用的节点。这个案例如 [图 5-11](/img/fig5-11.png) 所示。
* 通常,读取和写入操作始终并行发送到所有 n 个副本。参数 w 和 r 决定我们等待多少个节点,即在我们认为读或写成功之前,有多少个节点需要报告成功。
![](img/fig5-11.png)
![](/img/fig5-11.png)
**图 5-11 如果 $w + r > n$,读取 r 个副本,至少有一个副本必然包含了最近的成功写入。**
@ -531,7 +533,7 @@
### 法定人数一致性的局限性
如果你有 n 个副本,并且你选择了满足 $w + r > n$ 的 w 和 r你通常可以期望每次读取都能返回最近写入的值。情况就是这样因为你写入的节点集合和你读取的节点集合必然有重叠。也就是说你读取的节点中必然至少有一个节点具有最新值如 [图 5-11](img/fig5-11.png) 所示)。
如果你有 n 个副本,并且你选择了满足 $w + r > n$ 的 w 和 r你通常可以期望每次读取都能返回最近写入的值。情况就是这样因为你写入的节点集合和你读取的节点集合必然有重叠。也就是说你读取的节点中必然至少有一个节点具有最新值如 [图 5-11](/img/fig5-11.png) 所示)。
通常r 和 w 被选为多数(超过 $n/2$ )节点,因为这确保了 $w + r > n$,同时仍然容忍多达 $n/2$ 个节点故障。但是法定人数不一定必须是大多数重要的是读写使用的节点至少有一个节点的交集。其他法定人数的配置是可能的这使得分布式算法的设计有一定的灵活性【45】。
@ -546,11 +548,11 @@
* 如果写操作与读操作同时发生,写操作可能仅反映在某些副本上。在这种情况下,不确定读取返回的是旧值还是新值。
* 如果写操作在某些副本上成功,而在其他节点上失败(例如,因为某些节点上的磁盘已满),在小于 w 个副本上写入成功。所以整体判定写入失败但整体写入失败并没有在写入成功的副本上回滚。这意味着一个写入虽然报告失败后续的读取仍然可能会读取这次失败写入的值【47】。
* 如果携带新值的节点发生故障,需要从其他带有旧值的副本进行恢复,则存储新值的副本数可能会低于 w从而打破法定人数条件。
* 即使一切工作正常,有时也会不幸地出现关于 **时序timing** 的边缘情况,我们将在 “[线性一致性和法定人数](ch9.md#线性一致性和法定人数)” 中看到这点。
* 即使一切工作正常,有时也会不幸地出现关于 **时序timing** 的边缘情况,我们将在 “[线性一致性和法定人数](/ch9#线性一致性和法定人数)” 中看到这点。
因此尽管法定人数似乎保证读取返回最新的写入值但在实践中并不那么简单。Dynamo 风格的数据库通常针对可以忍受最终一致性的用例进行优化。你可以通过参数 w 和 r 来调整读取到陈旧值的概率,但把它们当成绝对的保证是不明智的。
尤其是,因为通常得不到 “[复制延迟问题](#复制延迟问题)” 中讨论的那些保证(读己之写,单调读,一致前缀读),前面提到的异常可能会发生在应用程序中。更强有力的保证通常需要 **事务****共识**。我们将在 [第七章](ch7.md) 和 [第九章](ch9.md) 回到这些话题。
尤其是,因为通常得不到 “[复制延迟问题](#复制延迟问题)” 中讨论的那些保证(读己之写,单调读,一致前缀读),前面提到的异常可能会发生在应用程序中。更强有力的保证通常需要 **事务****共识**。我们将在 [第七章](/ch7) 和 [第九章](/ch9) 回到这些话题。
#### 监控陈旧度
@ -595,17 +597,17 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个本地的
Dynamo 风格的数据库允许多个客户端同时写入相同的键Key这意味着即使使用严格的法定人数也会发生冲突。这种情况与多主复制相似请参阅 “[处理写入冲突](#处理写入冲突)”),但在 Dynamo 风格的数据库中,在 **读修复****提示移交** 期间也可能会产生冲突。
其问题在于,由于可变的网络延迟和部分节点的故障,事件可能以不同的顺序到达不同的节点。例如,[图 5-12](img/fig5-12.png) 显示了两个客户机 A 和 B 同时写入三节点数据存储中的键 X
其问题在于,由于可变的网络延迟和部分节点的故障,事件可能以不同的顺序到达不同的节点。例如,[图 5-12](/img/fig5-12.png) 显示了两个客户机 A 和 B 同时写入三节点数据存储中的键 X
* 节点 1 接收来自 A 的写入,但由于暂时中断,未接收到来自 B 的写入。
* 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。
* 节点 3 首先接收来自 B 的写入,然后从 A 写入。
![](img/fig5-12.png)
![](/img/fig5-12.png)
**图 5-12 并发写入 Dynamo 风格的数据存储:没有明确定义的顺序。**
如果每个节点只要接收到来自客户端的写入请求就简单地覆写某个键值,那么节点就会永久地不一致,如 [图 5-12](img/fig5-12.png) 中的最终获取请求所示:节点 2 认为 X 的最终值是 B而其他节点认为值是 A 。
如果每个节点只要接收到来自客户端的写入请求就简单地覆写某个键值,那么节点就会永久地不一致,如 [图 5-12](/img/fig5-12.png) 中的最终获取请求所示:节点 2 认为 X 的最终值是 B而其他节点认为值是 A 。
为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发人员)需要知道很多有关数据库冲突处理的内部信息。
@ -615,11 +617,11 @@ Dynamo 风格的数据库允许多个客户端同时写入相同的键Key
实现最终收敛的一种方法是声明每个副本只需要存储 **“最近”** 的值,并允许 **“更旧”** 的值被覆盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是 “最近的”,并且每个写入最终都被复制到每个副本,那么复制最终会收敛到相同的值。
正如 **“最近”** 的引号所表明的,这个想法其实颇具误导性。在 [图 5-12](img/fig5-12.png) 的例子中,当客户端向数据库节点发送写入请求时,两个客户端都不知道另一个客户端,因此不清楚哪一个先发送请求。事实上,说这两种情况谁先发送请求是没有意义的:既然我们说写入是 **并发concurrent** 的,那么它们的顺序就是不确定的。
正如 **“最近”** 的引号所表明的,这个想法其实颇具误导性。在 [图 5-12](/img/fig5-12.png) 的例子中,当客户端向数据库节点发送写入请求时,两个客户端都不知道另一个客户端,因此不清楚哪一个先发送请求。事实上,说这两种情况谁先发送请求是没有意义的:既然我们说写入是 **并发concurrent** 的,那么它们的顺序就是不确定的。
即使写入没有自然的排序,我们也可以强制进行排序。例如,可以为每个写入附加一个时间戳,然后挑选最大的时间戳作为 **“最近的”**,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为 **最后写入胜利LWW, last write wins**,是 Cassandra 唯一支持的冲突解决方法【53】也是 Riak 中的一个可选特征【35】。
LWW 实现了最终收敛的目标,但以 **持久性** 为代价:如果同一个键有多个并发写入,即使它们反馈给客户端的结果都是成功的(因为它们被写入 w 个副本也只有一个写入将被保留而其他写入将被默默地丢弃。此外LWW 甚至可能会丢弃不是并发的写入,我们将在 “[有序事件的时间戳](ch8.md#有序事件的时间戳)” 中进行讨论。
LWW 实现了最终收敛的目标,但以 **持久性** 为代价:如果同一个键有多个并发写入,即使它们反馈给客户端的结果都是成功的(因为它们被写入 w 个副本也只有一个写入将被保留而其他写入将被默默地丢弃。此外LWW 甚至可能会丢弃不是并发的写入,我们将在 “[有序事件的时间戳](/ch8#有序事件的时间戳)” 中进行讨论。
在类似缓存的一些情况下写入丢失可能是可以接受的。但如果数据丢失不可接受LWW 是解决冲突的一个很烂的选择。
@ -639,7 +641,7 @@ LWW 实现了最终收敛的目标,但以 **持久性** 为代价:如果同
> #### 并发性、时间和相对性
>
> 如果两个操作 **“同时”** 发生,似乎应该称为并发 —— 但事实上,它们在字面时间上重叠与否并不重要。由于分布式系统中的时钟问题,现实中是很难判断两个事件是否是 **同时** 发生的,这个问题我们将在 [第八章](ch8.md) 中详细讨论。
> 如果两个操作 **“同时”** 发生,似乎应该称为并发 —— 但事实上,它们在字面时间上重叠与否并不重要。由于分布式系统中的时钟问题,现实中是很难判断两个事件是否是 **同时** 发生的,这个问题我们将在 [第八章](/ch8) 中详细讨论。
>
> 为了定义并发性,确切的时间并不重要:如果两个操作都意识不到对方的存在,就称这两个操作 **并发**而不管它们实际发生的物理时间。人们有时把这个原理和物理学中的狭义相对论联系起来【54】该理论引入了信息不能比光速更快的思想。因此如果两个事件发生的时间差小于光通过它们之间的距离所需要的时间那么这两个事件不可能相互影响。
>
@ -650,7 +652,7 @@ LWW 实现了最终收敛的目标,但以 **持久性** 为代价:如果同
我们来看一个算法,它可以确定两个操作是否为并发的,还是一个在另一个之前。简单起见,我们从一个只有一个副本的数据库开始。一旦我们知道了如何在单个副本上完成这项工作,我们可以将该方法推广到具有多个副本的无主数据库。
[图 5-13](img/fig5-13.png) 显示了两个客户端同时向同一购物车添加项目。(如果这样的例子让你觉得无趣,那么可以想象一下两个空中交通管制员同时把飞机添加到他们正在跟踪的区域。)最初,购物车是空的。然后客户端向数据库发出五次写入:
[图 5-13](/img/fig5-13.png) 显示了两个客户端同时向同一购物车添加项目。(如果这样的例子让你觉得无趣,那么可以想象一下两个空中交通管制员同时把飞机添加到他们正在跟踪的区域。)最初,购物车是空的。然后客户端向数据库发出五次写入:
1. 客户端 1 将牛奶加入购物车。这是该键的第一次写入,服务器成功存储了它并为其分配版本号 1最后将值与版本号一起回送给客户端。
2. 客户端 2 将鸡蛋加入购物车,不知道客户端 1 同时添加了牛奶(客户端 2 认为它的鸡蛋是购物车中的唯一物品)。服务器为此写入分配版本号 2并将鸡蛋和牛奶存储为两个单独的值。然后它将这两个值 **都** 返回给客户端 2 ,并附上版本号 2。
@ -658,13 +660,13 @@ LWW 实现了最终收敛的目标,但以 **持久性** 为代价:如果同
4. 同时,客户端 2 想要加入火腿,不知道客户端 1 刚刚加了面粉。客户端 2 在最近一次响应中从服务器收到了两个值 [牛奶] 和 [鸡蛋],所以客户端 2 现在合并这些值,并添加火腿形成一个新的值 [鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号 2 。服务器检测到新值会覆盖版本 2 的值 [鸡蛋],但新值也会与版本 3 的值 [牛奶,面粉] **并发**,所以剩下的两个值是版本 3 的 [牛奶,面粉],和版本 4 的 [鸡蛋,牛奶,火腿]。
5. 最后,客户端 1 想要加培根。它之前从服务器接收到了版本 3 的 [牛奶,面粉] 和 [鸡蛋],所以它合并这些,添加培根,并将最终值 [牛奶,面粉,鸡蛋,培根] 连同版本号 3 发往服务器。这会覆盖版本 3 的值 [牛奶,面粉](请注意 [鸡蛋] 已经在上一步被覆盖),但与版本 4 的值 [鸡蛋,牛奶,火腿] 并发,所以服务器将保留这两个并发值。
![](img/fig5-13.png)
![](/img/fig5-13.png)
**图 5-13 在同时编辑购物车时捕获两个客户端之间的因果关系。**
[图 5-13](img/fig5-13.png) 中的操作之间的数据流如 [图 5-14](img/fig5-14.png) 所示。箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。在这个例子中,客户端永远不会完全拿到服务器上的最新数据,因为总是有另一个操作同时进行。但是旧版本的值最终会被覆盖,并且不会丢失任何写入。
[图 5-13](/img/fig5-13.png) 中的操作之间的数据流如 [图 5-14](/img/fig5-14.png) 所示。箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。在这个例子中,客户端永远不会完全拿到服务器上的最新数据,因为总是有另一个操作同时进行。但是旧版本的值最终会被覆盖,并且不会丢失任何写入。
![](img/fig5-14.png)
![](/img/fig5-14.png)
**图 5-14 图 5-13 中的因果依赖关系图。**
@ -683,21 +685,21 @@ LWW 实现了最终收敛的目标,但以 **持久性** 为代价:如果同
合并并发值,本质上是与多主复制中的冲突解决问题相同,我们先前讨论过(请参阅 “[处理写入冲突](#处理写入冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)来选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中额外做些更聪明的事情。
以购物车为例,一种合理的合并值的方法就是做并集。在 [图 5-14](img/fig5-14.png) 中,最后的两个兄弟是 [牛奶,面粉,鸡蛋,培根] 和 [鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋虽然同时出现在两个并发值里,但他们每个只被写过一次。合并的值可以是 [牛奶,面粉,鸡蛋,培根,火腿],不再有重复了。
以购物车为例,一种合理的合并值的方法就是做并集。在 [图 5-14](/img/fig5-14.png) 中,最后的两个兄弟是 [牛奶,面粉,鸡蛋,培根] 和 [鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋虽然同时出现在两个并发值里,但他们每个只被写过一次。合并的值可以是 [牛奶,面粉,鸡蛋,培根,火腿],不再有重复了。
然而,如果你想让人们也可以从他们的购物车中 **移除** 东西而不是仅仅添加东西那么把并发值做并集可能不会产生正确的结果如果你合并了两个客户端的购物车并且只在其中一个客户端里面移除了一个项目那么被移除的项目将会重新出现在这两个客户端的交集结果中【37】。为了防止这个问题要移除一个项目时不能简单地直接从数据库中删除相反系统必须留下一个具有适当版本号的标记以在兄弟合并时表明该项目已被移除。这种删除标记被称为 **墓碑tombstone**(我们上一次看到墓碑是在 “[散列索引”](ch3.md#散列索引) 章节的日志压缩部分)。
然而,如果你想让人们也可以从他们的购物车中 **移除** 东西而不是仅仅添加东西那么把并发值做并集可能不会产生正确的结果如果你合并了两个客户端的购物车并且只在其中一个客户端里面移除了一个项目那么被移除的项目将会重新出现在这两个客户端的交集结果中【37】。为了防止这个问题要移除一个项目时不能简单地直接从数据库中删除相反系统必须留下一个具有适当版本号的标记以在兄弟合并时表明该项目已被移除。这种删除标记被称为 **墓碑tombstone**(我们上一次看到墓碑是在 “[散列索引”](/ch3#散列索引) 章节的日志压缩部分)。
因为在应用程序代码中做兄弟合并是复杂且容易出错的,所以有一些数据结构被设计出来用于自动执行这种合并,比如在 “[自动冲突解决](#自动冲突解决)” 中讨论过的那些。举例来说Riak 的数据类型就支持使用称为 CRDT 【38,39,55】的能以合理方式自动进行兄弟合并的数据结构家族包括对保留删除的支持。
#### 版本向量
[图 5-13](img/fig5-13.png) 中的示例只使用了一个副本。当有多个副本但又没有主库时,算法该如何修改?
[图 5-13](/img/fig5-13.png) 中的示例只使用了一个副本。当有多个副本但又没有主库时,算法该如何修改?
[图 5-13](img/fig5-13.png) 使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键,我们还需要对 **每个副本** 使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些并发值,以及要保留哪些并发值或兄弟值。
[图 5-13](/img/fig5-13.png) 使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键,我们还需要对 **每个副本** 使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些并发值,以及要保留哪些并发值或兄弟值。
所有副本的版本号集合称为 **版本向量version vector**【56】。这个想法的一些变体正在被使用但最有趣的可能是在 Riak 2.0 【58,59】中使用的 **虚线版本向量dotted version vector**【57】。我们不会深入细节但是它的工作方式与我们在购物车示例中看到的非常相似。
与 [图 5-13](img/fig5-13.png) 中的版本号一样当读取值时版本向量会从数据库副本发送到客户端并且随后写入值时需要将其发送回数据库。Riak 将版本向量编码为一个字符串,并称其为 **因果上下文**,即 causal context。版本向量允许数据库区分覆盖写入和并发写入。
与 [图 5-13](/img/fig5-13.png) 中的版本号一样当读取值时版本向量会从数据库副本发送到客户端并且随后写入值时需要将其发送回数据库。Riak 将版本向量编码为一个字符串,并称其为 **因果上下文**,即 causal context。版本向量允许数据库区分覆盖写入和并发写入。
另外,就像在单个副本中的情况一样,应用程序可能需要合并并发值。版本向量结构能够确保从一个副本读取并随后写回到另一个副本是安全的。这样做虽然可能会在其他副本上面创建数据,但只要能正确合并就不会丢失数据。
@ -830,8 +832,3 @@ LWW 实现了最终收敛的目标,但以 **持久性** 为代价:如果同
1. Carlos Baquero: “[Version Vectors Are Not Vector Clocks](https://haslab.wordpress.com/2011/07/08/version-vectors-are-not-vector-clocks/),” *haslab.wordpress.com*, July 8, 2011.
1. Reinhard Schwarz and Friedemann Mattern: “[Detecting Causal Relationships in Distributed Computations: In Search of the Holy Grail](http://dcg.ethz.ch/lectures/hs08/seminar/papers/mattern4.pdf),” *Distributed Computing*, volume 7, number 3, pages 149174, March 1994. [doi:10.1007/BF02277859](http://dx.doi.org/10.1007/BF02277859)
--------
| 上一章 | 目录 | 下一章 |
| :--------------------------------: | :-----------------------------: | :--------------------: |
| [第二部分:分布式数据](part-ii.md) | [设计数据密集型应用](README.md) | [第六章:分区](ch6.md) |

View File

@ -1,19 +1,21 @@
# 第六章:分区
---
linktitle: "第六章:分区"
linkTitle: "6. 分区"
weight: 206
breadcrumbs: false
---
![](img/ch6.png)
![](/img/ch6.png)
> 我们必须跳出电脑指令序列的窠臼。叙述定义、描述元数据、梳理关系,而不是编写过程。
>
> —— Grace Murray Hopper未来的计算机及其管理1962
>
-------------
[TOC]
在 [第五章](/ch5) 中,我们讨论了复制 —— 即数据在不同节点上的副本,对于非常大的数据集,或非常高的吞吐量,仅仅进行复制是不够的:我们需要将数据进行 **分区partitions**,也称为 **分片sharding**[^i]。
在 [第五章](ch5.md) 中,我们讨论了复制 —— 即数据在不同节点上的副本,对于非常大的数据集,或非常高的吞吐量,仅仅进行复制是不够的:我们需要将数据进行 **分区partitions**,也称为 **分片sharding**[^i]。
[^i]: 正如本章所讨论的,分区是一种有意将大型数据库分解成小型数据库的方式。它与 **网络分区network partitions, netsplits** 无关,这是节点之间网络故障的一种。我们将在 [第八章](ch8.md) 讨论这些错误。
[^i]: 正如本章所讨论的,分区是一种有意将大型数据库分解成小型数据库的方式。它与 **网络分区network partitions, netsplits** 无关,这是节点之间网络故障的一种。我们将在 [第八章](/ch8) 讨论这些错误。
> #### 术语澄清
>
@ -22,11 +24,11 @@
通常情况下,每条数据(每条记录,每行或每个文档)属于且仅属于一个分区。有很多方法可以实现这一点,本章将进行深入讨论。实际上,每个分区都是自己的小型数据库,尽管数据库可能支持同时进行多个分区的操作。
分区主要是为了 **可伸缩性**。不同的分区可以放在不共享集群中的不同节点上(请参阅 [第二部分](part-ii.md) 关于 [无共享架构](part-ii.md#无共享架构) 的定义)。因此,大数据集可以分布在多个磁盘上,并且查询负载可以分布在多个处理器上。
分区主要是为了 **可伸缩性**。不同的分区可以放在不共享集群中的不同节点上(请参阅 [第二部分](/part-ii) 关于 [无共享架构](/part-ii#无共享架构) 的定义)。因此,大数据集可以分布在多个磁盘上,并且查询负载可以分布在多个处理器上。
对于在单个分区上运行的查询,每个节点可以独立执行对自己的查询,因此可以通过添加更多的节点来扩大查询吞吐量。大型,复杂的查询可能会跨越多个节点并行处理,尽管这也带来了新的困难。
分区数据库在 20 世纪 80 年代由 Teradata 和 NonStop SQL【1】等产品率先推出最近因为 NoSQL 数据库和基于 Hadoop 的数据仓库重新被关注。有些系统是为事务性工作设计的,有些系统则用于分析(请参阅 “[事务处理还是分析](ch3.md#事务处理还是分析?)”):这种差异会影响系统的运作方式,但是分区的基本原理均适用于这两种工作方式。
分区数据库在 20 世纪 80 年代由 Teradata 和 NonStop SQL【1】等产品率先推出最近因为 NoSQL 数据库和基于 Hadoop 的数据仓库重新被关注。有些系统是为事务性工作设计的,有些系统则用于分析(请参阅 “[事务处理还是分析](/ch3#事务处理还是分析?)”):这种差异会影响系统的运作方式,但是分区的基本原理均适用于这两种工作方式。
在本章中,我们将首先介绍分割大型数据集的不同方法,并观察索引如何与分区配合。然后我们将讨论 [分区再平衡rebalancing](#分区再平衡),如果想要添加或删除集群中的节点,则必须进行再平衡。最后,我们将概述数据库如何将请求路由到正确的分区并执行查询。
@ -34,11 +36,11 @@
分区通常与复制结合使用,使得每个分区的副本存储在多个节点上。这意味着,即使每条记录属于一个分区,它仍然可以存储在多个不同的节点上以获得容错能力。
一个节点可能存储多个分区。如果使用主从复制模型,则分区和复制的组合如 [图 6-1](img/fig6-1.png) 所示。每个分区领导者(主库)被分配给一个节点,追随者(从库)被分配给其他节点。每个节点可能是某些分区的主库,同时是其他分区的从库。
一个节点可能存储多个分区。如果使用主从复制模型,则分区和复制的组合如 [图 6-1](/img/fig6-1.png) 所示。每个分区领导者(主库)被分配给一个节点,追随者(从库)被分配给其他节点。每个节点可能是某些分区的主库,同时是其他分区的从库。
我们在 [第五章](ch5.md) 讨论的关于数据库复制的所有内容同样适用于分区的复制。大多数情况下,分区方案的选择与复制方案的选择是独立的,为简单起见,本章中将忽略复制。
我们在 [第五章](/ch5) 讨论的关于数据库复制的所有内容同样适用于分区的复制。大多数情况下,分区方案的选择与复制方案的选择是独立的,为简单起见,本章中将忽略复制。
![](img/fig6-1.png)
![](/img/fig6-1.png)
**图 6-1 组合使用复制和分区:每个节点充当某些分区的主库,其他分区充当从库。**
@ -56,17 +58,17 @@
### 根据键的范围分区
一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸质百科全书的卷([图 6-2](img/fig6-2.png))。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果你还知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书架上选取正确的书籍)。
一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸质百科全书的卷([图 6-2](/img/fig6-2.png))。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果你还知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书架上选取正确的书籍)。
![](img/fig6-2.png)
![](/img/fig6-2.png)
**图 6-2 印刷版百科全书按照关键字范围进行分区**
键的范围不一定均匀分布,因为数据也很可能不均匀分布。例如在 [图 6-2](img/fig6-2.png) 中,第 1 卷包含以 A 和 B 开头的单词,但第 12 卷则包含以 T、U、V、X、Y 和 Z 开头的单词。只是简单的规定每个卷包含两个字母会导致一些卷比其他卷大。为了均匀分配数据,分区边界需要依据数据调整。
键的范围不一定均匀分布,因为数据也很可能不均匀分布。例如在 [图 6-2](/img/fig6-2.png) 中,第 1 卷包含以 A 和 B 开头的单词,但第 12 卷则包含以 T、U、V、X、Y 和 Z 开头的单词。只是简单的规定每个卷包含两个字母会导致一些卷比其他卷大。为了均匀分配数据,分区边界需要依据数据调整。
分区边界可以由管理员手动选择,也可以由数据库自动选择(我们会在 “[分区再平衡](#分区再平衡)” 中更详细地讨论分区边界的选择。Bigtable 使用了这种分区策略,以及其开源等价物 HBase 【2, 3】、RethinkDB 和 2.4 版本之前的 MongoDB 【4】。
在每个分区中,我们可以按照一定的顺序保存键(请参阅 “[SSTables 和 LSM 树](ch3.md#SSTables和LSM树)”)。好处是进行范围扫描非常简单,你可以将键作为联合索引来处理,以便在一次查询中获取多个相关记录(请参阅 “[多列索引](ch3.md#多列索引)”)。例如,假设我们有一个程序来存储传感器网络的数据,其中主键是测量的时间戳(年月日时分秒)。范围扫描在这种情况下非常有用,因为我们可以轻松获取某个月份的所有数据。
在每个分区中,我们可以按照一定的顺序保存键(请参阅 “[SSTables 和 LSM 树](/ch3#SSTables和LSM树)”)。好处是进行范围扫描非常简单,你可以将键作为联合索引来处理,以便在一次查询中获取多个相关记录(请参阅 “[多列索引](/ch3#多列索引)”)。例如,假设我们有一个程序来存储传感器网络的数据,其中主键是测量的时间戳(年月日时分秒)。范围扫描在这种情况下非常有用,因为我们可以轻松获取某个月份的所有数据。
然而Key Range 分区的缺点是某些特定的访问模式会导致热点。如果主键是时间戳则分区对应于时间范围例如给每天分配一个分区。不幸的是由于我们在测量发生时将数据从传感器写入数据库因此所有写入操作都会转到同一个分区即今天的分区这样分区可能会因写入而过载而其他分区则处于空闲状态【5】。
@ -80,9 +82,9 @@
出于分区的目的散列函数不需要多么强壮的加密算法例如Cassandra 和 MongoDB 使用 MD5Voldemort 使用 Fowler-Noll-Vo 函数。许多编程语言都有内置的简单哈希函数(它们用于散列表),但是它们可能不适合分区:例如,在 Java 的 `Object.hashCode()` 和 Ruby 的 `Object#hash`同一个键可能在不同的进程中有不同的哈希值【6】。
一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如 [图 6-3](img/fig6-3.png) 所示。
一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如 [图 6-3](/img/fig6-3.png) 所示。
![](img/fig6-3.png)
![](/img/fig6-3.png)
**图 6-3 按哈希键分区**
@ -90,7 +92,7 @@
> #### 一致性哈希
>
> 一致性哈希由 Karger 等人定义。【7】 用于跨互联网级别的缓存系统,例如 CDN 中,是一种能均匀分配负载的方法。它使用随机选择的 **分区边界partition boundaries** 来避免中央控制或分布式共识的需要。请注意,这里的一致性与复制一致性(请参阅 [第五章](ch5.md))或 ACID 一致性(请参阅 [第七章](ch7.md)无关而只是描述了一种再平衡rebalancing的特定方法。
> 一致性哈希由 Karger 等人定义。【7】 用于跨互联网级别的缓存系统,例如 CDN 中,是一种能均匀分配负载的方法。它使用随机选择的 **分区边界partition boundaries** 来避免中央控制或分布式共识的需要。请注意,这里的一致性与复制一致性(请参阅 [第五章](/ch5))或 ACID 一致性(请参阅 [第七章](/ch7)无关而只是描述了一种再平衡rebalancing的特定方法。
>
> 正如我们将在 “[分区再平衡](#分区再平衡)” 中所看到的,这种特殊的方法对于数据库实际上并不是很好,所以在实际中很少使用(某些数据库的文档仍然会使用一致性哈希的说法,但是它往往是不准确的)。因为有可能产生混淆,所以最好避免使用一致性哈希这个术语,而只是把它称为 **散列分区hash partitioning**
@ -118,7 +120,7 @@ Cassandra 采取了折衷的策略【11, 12, 13】。Cassandra 中的表可以
到目前为止,我们讨论的分区方案依赖于键值数据模型。如果只通过主键访问记录,我们可以从该键确定分区,并使用它来将读写请求路由到负责该键的分区。
如果涉及次级索引,情况会变得更加复杂(参考 “[其他索引结构](ch3.md#其他索引结构)”)。次级索引通常并不能唯一地标识记录,而是一种搜索记录中出现特定值的方式:查找用户 123 的所有操作、查找包含词语 `hogwash` 的所有文章、查找所有颜色为红色的车辆等等。
如果涉及次级索引,情况会变得更加复杂(参考 “[其他索引结构](/ch3#其他索引结构)”)。次级索引通常并不能唯一地标识记录,而是一种搜索记录中出现特定值的方式:查找用户 123 的所有操作、查找包含词语 `hogwash` 的所有文章、查找所有颜色为红色的车辆等等。
次级索引是关系型数据库的基础,并且在文档数据库中也很普遍。许多键值存储(如 HBase 和 Volde-mort为了减少实现的复杂度而放弃了次级索引但是一些如 Riak已经开始添加它们因为它们对于数据模型实在是太有用了。并且次级索引也是 Solr 和 Elasticsearch 等搜索服务器的基石。
@ -126,31 +128,31 @@ Cassandra 采取了折衷的策略【11, 12, 13】。Cassandra 中的表可以
### 基于文档的次级索引进行分区
假设你正在经营一个销售二手车的网站(如 [图 6-4](img/fig6-4.png) 所示)。每个列表都有一个唯一的 ID—— 称之为文档 ID—— 并且用文档 ID 对数据库进行分区(例如,分区 0 中的 ID 0 到 499分区 1 中的 ID 500 到 999 等)。
假设你正在经营一个销售二手车的网站(如 [图 6-4](/img/fig6-4.png) 所示)。每个列表都有一个唯一的 ID—— 称之为文档 ID—— 并且用文档 ID 对数据库进行分区(例如,分区 0 中的 ID 0 到 499分区 1 中的 ID 500 到 999 等)。
你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的次级索引(文档数据库中这些是 **字段field**,关系数据库中这些是 **列column** )。如果你声明了索引,则数据库可以自动执行索引 [^ii]。例如,无论何时将红色汽车添加到数据库,数据库分区都会自动将其添加到索引条目 `color:red` 的文档 ID 列表中。
[^ii]: 如果数据库仅支持键值模型,则你可能会尝试在应用程序代码中创建从值到文档 ID 的映射来实现次级索引。如果沿着这条路线走下去,请万分小心,确保你的索引与底层数据保持一致。竞争条件和间歇性写入失败(其中一些更改已保存,但其他更改未保存)很容易导致数据不同步 - 请参阅 “[多对象事务的需求](ch7.md#多对象事务的需求)”。
[^ii]: 如果数据库仅支持键值模型,则你可能会尝试在应用程序代码中创建从值到文档 ID 的映射来实现次级索引。如果沿着这条路线走下去,请万分小心,确保你的索引与底层数据保持一致。竞争条件和间歇性写入失败(其中一些更改已保存,但其他更改未保存)很容易导致数据不同步 - 请参阅 “[多对象事务的需求](/ch7#多对象事务的需求)”。
![](img/fig6-4.png)
![](/img/fig6-4.png)
**图 6-4 基于文档的次级索引进行分区**
在这种索引方法中,每个分区是完全独立的:每个分区维护自己的次级索引,仅覆盖该分区中的文档。它不关心存储在其他分区的数据。无论何时你需要写入数据库(添加,删除或更新文档),只需处理包含你正在编写的文档 ID 的分区即可。出于这个原因,**文档分区索引** 也被称为 **本地索引**(而不是将在下一节中描述的 **全局索引**)。
但是,从文档分区索引中读取需要注意:除非你对文档 ID 做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在 [图 6-4](img/fig6-4.png) 中,红色汽车出现在分区 0 和分区 1 中。因此,如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。
但是,从文档分区索引中读取需要注意:除非你对文档 ID 做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在 [图 6-4](/img/fig6-4.png) 中,红色汽车出现在分区 0 和分区 1 中。因此,如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。
这种查询分区数据库的方法有时被称为 **分散 / 聚集scatter/gather**,并且可能会使次级索引上的读取查询相当昂贵。即使并行查询分区,分散 / 聚集也容易导致尾部延迟放大(请参阅 “[实践中的百分位点](ch1.md#实践中的百分位点)”。然而它被广泛使用MongoDBRiak 【15】Cassandra 【16】Elasticsearch 【17】SolrCloud 【18】和 VoltDB 【19】都使用文档分区次级索引。大多数数据库供应商建议你构建一个能从单个分区提供次级索引查询的分区方案但这并不总是可行尤其是当在单个查询中使用多个次级索引时例如同时需要按颜色和制造商查询
这种查询分区数据库的方法有时被称为 **分散 / 聚集scatter/gather**,并且可能会使次级索引上的读取查询相当昂贵。即使并行查询分区,分散 / 聚集也容易导致尾部延迟放大(请参阅 “[实践中的百分位点](/ch1#实践中的百分位点)”。然而它被广泛使用MongoDBRiak 【15】Cassandra 【16】Elasticsearch 【17】SolrCloud 【18】和 VoltDB 【19】都使用文档分区次级索引。大多数数据库供应商建议你构建一个能从单个分区提供次级索引查询的分区方案但这并不总是可行尤其是当在单个查询中使用多个次级索引时例如同时需要按颜色和制造商查询
### 基于关键词(Term)的次级索引进行分区
我们可以构建一个覆盖所有分区数据的 **全局索引**,而不是给每个分区创建自己的次级索引(本地索引)。但是,我们不能只把这个索引存储在一个节点上,因为它可能会成为瓶颈,违背了分区的目的。全局索引也必须进行分区,但可以采用与主键不同的分区方式。
[图 6-5](img/fig6-5.png) 描述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从 `a``r` 的颜色在分区 0 中,`s` 到 `z` 的在分区 1。汽车制造商的索引也与之类似分区边界在 `f``h` 之间)。
[图 6-5](/img/fig6-5.png) 描述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从 `a``r` 的颜色在分区 0 中,`s` 到 `z` 的在分区 1。汽车制造商的索引也与之类似分区边界在 `f``h` 之间)。
![](img/fig6-5.png)
![](/img/fig6-5.png)
**图 6-5 基于关键词对次级索引进行分区**
@ -160,11 +162,11 @@ Cassandra 采取了折衷的策略【11, 12, 13】。Cassandra 中的表可以
关键词分区的全局索引优于文档分区索引的地方点是它可以使读取更有效率:不需要 **分散 / 收集** 所有分区,客户端只需要向包含关键词的分区发出请求。全局索引的缺点在于写入速度较慢且较为复杂,因为写入单个文档现在可能会影响索引的多个分区(文档中的每个关键词可能位于不同的分区或者不同的节点上) 。
理想情况下,索引总是最新的,写入数据库的每个文档都会立即反映在索引中。但是,在关键词分区索引中,这需要跨分区的分布式事务,并不是所有数据库都支持(请参阅 [第七章](ch7.md) 和 [第九章](ch9.md))。
理想情况下,索引总是最新的,写入数据库的每个文档都会立即反映在索引中。但是,在关键词分区索引中,这需要跨分区的分布式事务,并不是所有数据库都支持(请参阅 [第七章](/ch7) 和 [第九章](/ch9))。
在实践中,对全局次级索引的更新通常是 **异步**也就是说如果在写入之后不久读取索引刚才所做的更改可能尚未反映在索引中。例如Amazon DynamoDB 声称在正常情况下其全局次级索引会在不到一秒的时间内更新但在基础架构出现故障的情况下可能会有延迟【20】。
全局关键词分区索引的其他用途包括 Riak 的搜索功能【21】和 Oracle 数据仓库它允许你在本地和全局索引之间进行选择【22】。我们将在 [第十二章](ch12.md) 中继续关键词分区次级索引实现的话题。
全局关键词分区索引的其他用途包括 Riak 的搜索功能【21】和 Oracle 数据仓库它允许你在本地和全局索引之间进行选择【22】。我们将在 [第十二章](/ch12) 中继续关键词分区次级索引实现的话题。
## 分区再平衡
@ -189,7 +191,7 @@ Cassandra 采取了折衷的策略【11, 12, 13】。Cassandra 中的表可以
#### 反面教材hash mod N
我们在前面说过([图 6-3](img/fig6-3.png)),最好将可能的散列分成不同的范围,并将每个范围分配给一个分区(例如,如果 $0 ≤ hash(key)< b_0$则将键分配给分区 0如果 $b_0 hash(key) < b_1$则分配给分区 1
我们在前面说过([图 6-3](/img/fig6-3.png)),最好将可能的散列分成不同的范围,并将每个范围分配给一个分区(例如,如果 $0 ≤ hash(key)< b_0$则将键分配给分区 0如果 $b_0 hash(key) < b_1$则分配给分区 1
也许你想知道为什么我们不使用 ***取模mod***(许多编程语言中的 % 运算符)。例如,`hash(key) mod 10` 会返回一个介于 0 和 9 之间的数字(如果我们将散列写为十进制数,散列模 10 将是最后一个数字)。如果我们有 10 个节点,编号为 0 到 9这似乎是将每个键分配给一个节点的简单方法。
@ -201,11 +203,11 @@ Cassandra 采取了折衷的策略【11, 12, 13】。Cassandra 中的表可以
幸运的是,有一个相当简单的解决方案:创建比节点更多的分区,并为每个节点分配多个分区。例如,运行在 10 个节点的集群上的数据库可能会从一开始就被拆分为 1,000 个分区,因此大约有 100 个分区被分配给每个节点。
现在,如果一个节点被添加到集群中,新节点可以从当前每个节点中 **窃取** 一些分区,直到分区再次公平分配。这个过程如 [图 6-6](img/fig6-6.png) 所示。如果从集群中删除一个节点,则会发生相反的情况。
现在,如果一个节点被添加到集群中,新节点可以从当前每个节点中 **窃取** 一些分区,直到分区再次公平分配。这个过程如 [图 6-6](/img/fig6-6.png) 所示。如果从集群中删除一个节点,则会发生相反的情况。
只有分区在节点之间的移动。分区的数量不会改变,键所指定的分区也不会改变。唯一改变的是分区所在的节点。这种变更并不是即时的 — 在网络上传输大量的数据需要一些时间 — 所以在传输过程中,原有分区仍然会接受读写操作。
![](img/fig6-6.png)
![](/img/fig6-6.png)
**图 6-6 将新节点添加到每个节点具有多个分区的数据库集群。**
@ -219,7 +221,7 @@ Cassandra 采取了折衷的策略【11, 12, 13】。Cassandra 中的表可以
对于使用键范围分区的数据库(请参阅 “[根据键的范围分区](#根据键的范围分区)”),具有固定边界的固定数量的分区将非常不便:如果边界设置错误,可能会导致所有数据都在一个分区中,而其他分区则为空。手动重新配置分区边界将非常繁琐。
出于这个原因,按键的范围进行分区的数据库(如 HBase 和 RethinkDB会动态创建分区。当分区增长到超过配置的大小时在 HBase 上,默认值是 10GB会被分成两个分区每个分区约占一半的数据【26】。与之相反如果大量数据被删除并且分区缩小到某个阈值以下则可以将其与相邻分区合并。此过程与 B 树顶层发生的过程类似(请参阅 “[B 树](ch3.md#B树)”)。
出于这个原因,按键的范围进行分区的数据库(如 HBase 和 RethinkDB会动态创建分区。当分区增长到超过配置的大小时在 HBase 上,默认值是 10GB会被分成两个分区每个分区约占一半的数据【26】。与之相反如果大量数据被删除并且分区缩小到某个阈值以下则可以将其与相邻分区合并。此过程与 B 树顶层发生的过程类似(请参阅 “[B 树](/ch3#B树)”)。
每个分区分配给一个节点,每个节点可以处理多个分区,就像固定数量的分区一样。大型分区拆分后,可以将其中的一半转移到另一个节点,以平衡负载。在 HBase 中,分区文件的传输通过 HDFS底层使用的分布式文件系统来实现【3】。
@ -265,21 +267,21 @@ Cassandra 和 Ketama 使用的第三种方法是使分区数与节点数成正
以上所有情况中的关键问题是:作出路由决策的组件(可能是节点之一,还是路由层或客户端)如何了解分区 - 节点之间的分配关系变化?
![](img/fig6-7.png)
![](/img/fig6-7.png)
**图 6-7 将请求路由到正确节点的三种不同方式。**
这是一个具有挑战性的问题,因为重要的是所有参与者都达成共识 - 否则请求将被发送到错误的节点,得不到正确的处理。在分布式系统中有达成共识的协议,但很难正确地实现(见 [第九章](ch9.md))。
这是一个具有挑战性的问题,因为重要的是所有参与者都达成共识 - 否则请求将被发送到错误的节点,得不到正确的处理。在分布式系统中有达成共识的协议,但很难正确地实现(见 [第九章](/ch9))。
许多分布式数据系统都依赖于一个独立的协调服务,比如 ZooKeeper 来跟踪集群元数据,如 [图 6-8](img/fig6-8.png) 所示。每个节点在 ZooKeeper 中注册自己ZooKeeper 维护分区到节点的可靠映射。其他参与者(如路由层或分区感知客户端)可以在 ZooKeeper 中订阅此信息。只要分区分配发生了改变或者集群中添加或删除了一个节点ZooKeeper 就会通知路由层使路由信息保持最新状态。
许多分布式数据系统都依赖于一个独立的协调服务,比如 ZooKeeper 来跟踪集群元数据,如 [图 6-8](/img/fig6-8.png) 所示。每个节点在 ZooKeeper 中注册自己ZooKeeper 维护分区到节点的可靠映射。其他参与者(如路由层或分区感知客户端)可以在 ZooKeeper 中订阅此信息。只要分区分配发生了改变或者集群中添加或删除了一个节点ZooKeeper 就会通知路由层使路由信息保持最新状态。
![](img/fig6-8.png)
![](/img/fig6-8.png)
**图 6-8 使用 ZooKeeper 跟踪分区分配给节点。**
例如LinkedIn的Espresso使用Helix 【31】进行集群管理依靠ZooKeeper实现了如[图6-8](img/fig6-8.png)所示的路由层。HBase、SolrCloud和Kafka也使用ZooKeeper来跟踪分区分配。MongoDB具有类似的体系结构但它依赖于自己的**配置服务器config server** 实现和mongos守护进程作为路由层。
例如LinkedIn的Espresso使用Helix 【31】进行集群管理依靠ZooKeeper实现了如[图6-8](/img/fig6-8.png)所示的路由层。HBase、SolrCloud和Kafka也使用ZooKeeper来跟踪分区分配。MongoDB具有类似的体系结构但它依赖于自己的**配置服务器config server** 实现和mongos守护进程作为路由层。
Cassandra 和 Riak 采取不同的方法:他们在节点之间使用 **流言协议gossip protocol** 来传播集群状态的变化。请求可以发送到任意节点,该节点会转发到包含所请求的分区的适当节点([图 6-7](img/fig6-7.png) 中的方法 1。这个模型在数据库节点中增加了更多的复杂性但是避免了对像 ZooKeeper 这样的外部协调服务的依赖。
Cassandra 和 Riak 采取不同的方法:他们在节点之间使用 **流言协议gossip protocol** 来传播集群状态的变化。请求可以发送到任意节点,该节点会转发到包含所请求的分区的适当节点([图 6-7](/img/fig6-7.png) 中的方法 1。这个模型在数据库节点中增加了更多的复杂性但是避免了对像 ZooKeeper 这样的外部协调服务的依赖。
Couchbase 不会自动进行再平衡,这简化了设计。通常情况下,它配置了一个名为 moxi 的路由层它会从集群节点了解路由变化【32】。
@ -291,7 +293,7 @@ Couchbase 不会自动进行再平衡,这简化了设计。通常情况下,
然而,通常用于分析的 **大规模并行处理MPP, Massively parallel processing** 关系型数据库产品在其支持的查询类型方面要复杂得多。一个典型的数据仓库查询包含多个连接过滤分组和聚合操作。MPP 查询优化器将这个复杂的查询分解成许多执行阶段和分区,其中许多可以在数据库集群的不同节点上并行执行。涉及扫描大规模数据集的查询特别受益于这种并行执行。
数据仓库查询的快速并行执行是一个专门的话题,由于分析有很重要的商业意义,可以带来很多利益。我们将在 [第十章](ch10.md) 讨论并行查询执行的一些技巧。有关并行数据库中使用的技术的更详细的概述请参阅参考文献【1,33】。
数据仓库查询的快速并行执行是一个专门的话题,由于分析有很重要的商业意义,可以带来很多利益。我们将在 [第十章](/ch10) 讨论并行查询执行的一些技巧。有关并行数据库中使用的技术的更详细的概述请参阅参考文献【1,33】。
## 本章小结
@ -358,11 +360,3 @@ Couchbase 不会自动进行再平衡,这简化了设计。通常情况下,
1. Kishore Gopalakrishna, Shi Lu, Zhen Zhang, et al.: “[Untangling Cluster Management with Helix](http://www.socc2012.org/helix_onecol.pdf?attredirects=0),” at *ACM Symposium on Cloud Computing* (SoCC), October 2012. [doi:10.1145/2391229.2391248](http://dx.doi.org/10.1145/2391229.2391248)
1. “[Moxi 1.8 Manual](http://docs.couchbase.com/moxi-manual-1.8/),” Couchbase, Inc., 2014.
1. Shivnath Babu and Herodotos Herodotou: “[Massively Parallel Databases and MapReduce Systems](https://www.microsoft.com/en-us/research/wp-content/uploads/2013/11/db-mr-survey-final.pdf),” *Foundations and Trends in Databases*, volume 5, number 1, pages 1104, November 2013. [doi:10.1561/1900000036](http://dx.doi.org/10.1561/1900000036)
------
| 上一章 | 目录 | 下一章 |
| :--------------------: | :-----------------------------: | :--------------------: |
| [第五章:复制](ch5.md) | [设计数据密集型应用](README.md) | [第七章:事务](ch7.md) |

View File

@ -1,15 +1,16 @@
# 第七章:事务
---
title: "第七章:事务"
linkTitle: "7. 事务"
weight: 207
breadcrumbs: false
---
![](img/ch7.png)
![](/img/ch7.png)
> 一些作者声称,支持通用的两阶段提交代价太大,会带来性能与可用性的问题。让程序员来处理过度使用事务导致的性能问题,总比缺少事务编程好得多。
>
> —— James Corbett 等人SpannerGoogle 的全球分布式数据库2012
------
[TOC]
在数据系统的残酷现实中,很多事情都可能出错:
- 数据库软件、硬件可能在任意时刻发生故障(包括写操作进行到一半时)。
@ -31,14 +32,14 @@
本章将研究许多出错案例,并探索数据库用于防范这些问题的算法。尤其会深入 **并发控制** 的领域,讨论各种可能发生的竞争条件,以及数据库如何实现 **读已提交read committed****快照隔离snapshot isolation** 和 **可串行化serializability** 等隔离级别。
本章同时适用于单机数据库与分布式数据库;在 [第八章](ch8.md) 中将重点讨论仅出现在分布式系统中的特殊挑战。
本章同时适用于单机数据库与分布式数据库;在 [第八章](/ch8) 中将重点讨论仅出现在分布式系统中的特殊挑战。
## 事务的棘手概念
现今,几乎所有的关系型数据库和一些非关系数据库都支持 **事务**。其中大多数遵循 IBM System R第一个 SQL 数据库)在 1975 年引入的风格【1,2,3】。40 年里尽管一些实现细节发生了变化但总体思路大同小异MySQL、PostgreSQL、Oracle 和 SQL Server 等数据库中的事务支持与 System R 异乎寻常地相似。
2000 年以后非关系NoSQL数据库开始普及。它们的目标是在关系数据库的现状基础上通过提供新的数据模型选择请参阅 [第二章](ch2.md)并默认包含复制第五章和分区第六章来进一步提升。事务是这次运动的主要牺牲品这些新一代数据库中的许多数据库完全放弃了事务或者重新定义了这个词描述比以前所理解的更弱得多的一套保证【4】。
2000 年以后非关系NoSQL数据库开始普及。它们的目标是在关系数据库的现状基础上通过提供新的数据模型选择请参阅 [第二章](/ch2)并默认包含复制第五章和分区第六章来进一步提升。事务是这次运动的主要牺牲品这些新一代数据库中的许多数据库完全放弃了事务或者重新定义了这个词描述比以前所理解的更弱得多的一套保证【4】。
随着这种新型分布式数据库的炒作人们普遍认为事务是可伸缩性的对立面任何大型系统都必须放弃事务以保持良好的性能和高可用性【5,6】。另一方面数据库厂商有时将事务保证作为 “重要应用” 和 “有价值数据” 的基本要求。这两种观点都是 **纯粹的夸张**
@ -70,9 +71,9 @@ ACID 原子性的定义特征是:**能够在错误时中止事务,丢弃该
一致性这个词被赋予太多含义:
* 在 [第五章](ch5.md) 中,我们讨论了副本一致性,以及异步复制系统中的最终一致性问题(请参阅 “[复制延迟问题](ch5.md#复制延迟问题)”)。
* [一致性哈希](ch6.md#一致性哈希) 是某些系统用于重新分区的一种分区方法。
* 在 [CAP 定理](ch9.md#CAP定理) 中,一致性一词用于表示 [线性一致性](ch9.md#线性一致性)。
* 在 [第五章](/ch5) 中,我们讨论了副本一致性,以及异步复制系统中的最终一致性问题(请参阅 “[复制延迟问题](/ch5#复制延迟问题)”)。
* [一致性哈希](/ch6#一致性哈希) 是某些系统用于重新分区的一种分区方法。
* 在 [CAP 定理](/ch9#CAP定理) 中,一致性一词用于表示 [线性一致性](/ch9#线性一致性)。
* 在 ACID 的上下文中,**一致性** 是指数据库在应用程序的特定概念中处于 “良好状态”。
很不幸,这一个词就至少有四种不同的含义。
@ -89,11 +90,11 @@ ACID 一致性的概念是,**对数据的一组特定约束必须始终成立*
大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到 **并发** 问题(**竞争条件**,即 race conditions
[图 7-1](img/fig7-1.png) 是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库没有内建的自增操作)每个客户端需要读取计数器的当前值,加 1 ,再回写新值。[图 7-1](img/fig7-1.png) 中,因为发生了两次增长,计数器应该从 42 增至 44但由于竞态条件实际上只增至 43 。
[图 7-1](/img/fig7-1.png) 是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库没有内建的自增操作)每个客户端需要读取计数器的当前值,加 1 ,再回写新值。[图 7-1](/img/fig7-1.png) 中,因为发生了两次增长,计数器应该从 42 增至 44但由于竞态条件实际上只增至 43 。
ACID 意义上的隔离性意味着,**同时执行的事务是相互隔离的**:它们不能相互冒犯。传统的数据库教科书将隔离性形式化为 **可串行化Serializability**这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当多个事务被提交时结果与它们串行运行一个接一个是一样的尽管实际上它们可能是并发运行的【10】。
![](img/fig7-1.png)
![](/img/fig7-1.png)
**图 7-1 两个客户之间的竞争状态同时递增计数器**
@ -103,9 +104,9 @@ ACID 意义上的隔离性意味着,**同时执行的事务是相互隔离的*
数据库系统的目的是,提供一个安全的地方存储数据,而不用担心丢失。**持久性** 是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。
在单节点数据库中,持久性通常意味着数据已被写入非易失性存储设备,如硬盘或 SSD。它通常还包括预写日志或类似的文件请参阅 “[让 B 树更可靠](ch3.md#让B树更可靠)”),以便在磁盘上的数据结构损坏时进行恢复。在带复制的数据库中,持久性可能意味着数据已成功复制到一些节点。为了提供持久性保证,数据库必须等到这些写入或复制完成后,才能报告事务成功提交。
在单节点数据库中,持久性通常意味着数据已被写入非易失性存储设备,如硬盘或 SSD。它通常还包括预写日志或类似的文件请参阅 “[让 B 树更可靠](/ch3#让B树更可靠)”),以便在磁盘上的数据结构损坏时进行恢复。在带复制的数据库中,持久性可能意味着数据已成功复制到一些节点。为了提供持久性保证,数据库必须等到这些写入或复制完成后,才能报告事务成功提交。
如 “[可靠性](ch1.md#可靠性)” 一节所述,**完美的持久性是不存在的** :如果所有硬盘和所有备份同时被销毁,那显然没有任何数据库能救得了你。
如 “[可靠性](/ch1#可靠性)” 一节所述,**完美的持久性是不存在的** :如果所有硬盘和所有备份同时被销毁,那显然没有任何数据库能救得了你。
> #### 复制与持久性
>
@ -114,8 +115,8 @@ ACID 意义上的隔离性意味着,**同时执行的事务是相互隔离的*
> 真相是,没有什么是完美的:
>
> * 如果你写入磁盘然后机器宕机,即使数据没有丢失,在修复机器或将磁盘转移到其他机器之前,也是无法访问的。这种情况下,复制系统可以保持可用性。
> * 一个相关性故障(停电,或一个特定输入导致所有节点崩溃的 Bug可能会一次性摧毁所有副本请参阅「[可靠性](ch1.md#可靠性)」),任何仅存储在内存中的数据都会丢失,故内存数据库仍然要和磁盘写入打交道。
> * 在异步复制系统中,当主库不可用时,最近的写入操作可能会丢失(请参阅「[处理节点宕机](ch5.md#处理节点宕机)」)。
> * 一个相关性故障(停电,或一个特定输入导致所有节点崩溃的 Bug可能会一次性摧毁所有副本请参阅「[可靠性](/ch1#可靠性)」),任何仅存储在内存中的数据都会丢失,故内存数据库仍然要和磁盘写入打交道。
> * 在异步复制系统中,当主库不可用时,最近的写入操作可能会丢失(请参阅「[处理节点宕机](/ch5#处理节点宕机)」)。
> * 当电源突然断电时,特别是固态硬盘,有证据显示有时会违反应有的保证:甚至 fsync 也不能保证正常工作【12】。硬盘固件可能有错误就像任何其他类型的软件一样【13,14】。
> * 存储引擎和文件系统之间的微妙交互可能会导致难以追踪的错误并可能导致磁盘上的文件在崩溃后被损坏【15,16】。
> * 磁盘上的数据可能会在没有检测到的情况下逐渐损坏【17】。如果数据已损坏一段时间副本和最近的备份也可能损坏。这种情况下需要尝试从历史备份中恢复数据。
@ -136,7 +137,7 @@ ACID 意义上的隔离性意味着,**同时执行的事务是相互隔离的*
同时运行的事务不应该互相干扰。例如,如果一个事务进行多次写入,则另一个事务要么看到全部写入结果,要么什么都看不到,但不应该是一些子集。
这些定义假设你想同时修改多个对象(行,文档,记录)。通常需要 **多对象事务multi-object transaction** 来保持多块数据同步。[图 7-2](img/fig7-2.png) 展示了一个来自电邮应用的例子。执行以下查询来显示用户未读邮件数量:
这些定义假设你想同时修改多个对象(行,文档,记录)。通常需要 **多对象事务multi-object transaction** 来保持多块数据同步。[图 7-2](/img/fig7-2.png) 展示了一个来自电邮应用的例子。执行以下查询来显示用户未读邮件数量:
```sql
SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
@ -144,23 +145,23 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
但如果邮件太多,你可能会觉得这个查询太慢,并决定用单独的字段存储未读邮件的数量(一种反规范化)。现在每当一个新消息写入时,必须也增长未读计数器,每当一个消息被标记为已读时,也必须减少未读计数器。
在 [图 7-2](img/fig7-2.png) 中,用户 2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生 [^ii]。隔离性可以避免这个问题:通过确保用户 2 要么同时看到新邮件和增长后的计数器,要么都看不到,而不是一个前后矛盾的中间结果。
在 [图 7-2](/img/fig7-2.png) 中,用户 2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生 [^ii]。隔离性可以避免这个问题:通过确保用户 2 要么同时看到新邮件和增长后的计数器,要么都看不到,而不是一个前后矛盾的中间结果。
[^ii]: 可以说邮件应用中的错误计数器并不是什么特别重要的问题。但换种方式来看,你可以把未读计数器换成客户账户余额,把邮件收发看成支付交易。
![](img/fig7-2.png)
![](/img/fig7-2.png)
**图 7-2 违反隔离性:一个事务读取另一个事务的未被执行的写入(“脏读”)。**
[图 7-3](img/fig7-3.png) 说明了对原子性的需求:如果在事务过程中发生错误,邮箱和未读计数器的内容可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,并且插入的电子邮件将被回滚。
[图 7-3](/img/fig7-3.png) 说明了对原子性的需求:如果在事务过程中发生错误,邮箱和未读计数器的内容可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,并且插入的电子邮件将被回滚。
![](img/fig7-3.png)
![](/img/fig7-3.png)
**图 7-3 原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致**
多对象事务需要某种方式来确定哪些读写操作属于同一个事务。在关系型数据库中,通常基于客户端与数据库服务器的 TCP 连接:在任何特定连接上,`BEGIN TRANSACTION` 和 `COMMIT` 语句之间的所有内容,被认为是同一事务的一部分.[^iii]
[^iii]: 这并不完美。如果 TCP 连接中断,则事务必须中止。如果中断发生在客户端请求提交之后,但在服务器确认提交发生之前,客户端并不知道事务是否已提交。为了解决这个问题,事务管理器可以通过一个唯一事务标识符来对操作进行分组,这个标识符并未绑定到特定 TCP 连接。后续再 “[数据库的端到端原则](ch12.md#数据库的端到端原则)” 一节将回到这个主题。
[^iii]: 这并不完美。如果 TCP 连接中断,则事务必须中止。如果中断发生在客户端请求提交之后,但在服务器确认提交发生之前,客户端并不知道事务是否已提交。为了解决这个问题,事务管理器可以通过一个唯一事务标识符来对操作进行分组,这个标识符并未绑定到特定 TCP 连接。后续再 “[数据库的端到端原则](/ch12#数据库的端到端原则)” 一节将回到这个主题。
另一方面,许多非关系数据库并没有将这些操作组合在一起的方法。即使存在多对象 API例如某键值存储可能具有在一个操作中更新几个键的 multi-put 操作),但这并不一定意味着它具有事务语义:该命令可能在一些键上成功,在其他的键上失败,使数据库处于部分更新的状态。
@ -172,9 +173,9 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
- 如果在数据库正在覆盖磁盘上的前一个值的过程中电源发生故障,是否最终将新旧值拼接在一起?
- 如果另一个客户端在写入过程中读取该文档,是否会看到部分更新的值?
这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(请参阅 “[让 B 树更可靠](ch3.md#让B树更可靠)”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) 。
这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(请参阅 “[让 B 树更可靠](/ch3#让B树更可靠)”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) 。
一些数据库也提供更复杂的原子操作 [^iv],例如自增操作,这样就不再需要像 [图 7-1](img/fig7-1.png) 那样的读取 - 修改 - 写入序列了。同样流行的是 **[比较和设置CAS, compare-and-set](#比较并设置CAS)** 操作,仅当值没有被其他并发修改过时,才允许执行写操作。
一些数据库也提供更复杂的原子操作 [^iv],例如自增操作,这样就不再需要像 [图 7-1](/img/fig7-1.png) 那样的读取 - 修改 - 写入序列了。同样流行的是 **[比较和设置CAS, compare-and-set](#比较并设置CAS)** 操作,仅当值没有被其他并发修改过时,才允许执行写操作。
[^iv]: 严格地说,**原子自增atomic increment** 这个术语在多线程编程的意义上使用了原子这个词。在 ACID 的情况下,它实际上应该被称为 **隔离的isolated** 的或 **可串行的serializable** 的增量。但这就太吹毛求疵了。
@ -182,23 +183,23 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
#### 多对象事务的需求
许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现,而且在需要高可用性或高性能的情况下,它们可能会碍事。但说到底,在分布式数据库中实现事务,并没有什么根本性的障碍。[第九章](ch9.md) 将讨论分布式事务的实现。
许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现,而且在需要高可用性或高性能的情况下,它们可能会碍事。但说到底,在分布式数据库中实现事务,并没有什么根本性的障碍。[第九章](/ch9) 将讨论分布式事务的实现。
但是我们是否需要多对象事务?**是否有可能只用键值数据模型和单对象操作来实现任何应用程序?**
有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象:
* 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。(类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确保这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的和最新的,不然数据就没有意义。
* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象 —— 更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(请参阅 “[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图 7-2](img/fig7-2.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。
* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象 —— 更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(请参阅 “[关系型数据库与文档数据库在今日的对比](/ch2#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图 7-2](/img/fig7-2.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。
* 在具有次级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。
这些应用仍然可以在没有事务的情况下实现。然而,**没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题**。我们将在 “[弱隔离级别](#弱隔离级别)” 中讨论这些问题,并在 [第十二章](ch12.md) 中探讨其他方法。
这些应用仍然可以在没有事务的情况下实现。然而,**没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题**。我们将在 “[弱隔离级别](#弱隔离级别)” 中讨论这些问题,并在 [第十二章](/ch12) 中探讨其他方法。
#### 处理错误和中止
事务的一个关键特性是如果发生错误它可以中止并安全地重试。ACID 数据库基于这样的哲学:如果数据库有违反其原子性,隔离性或持久性的危险,则宁愿完全放弃事务,而不是留下半成品。
然而并不是所有的系统都遵循这个哲学。特别是具有 [无主复制](ch5.md#无主复制) 的数据存储,主要是在 “尽力而为” 的基础上进行工作。可以概括为 “数据库将做尽可能多的事,运行遇到错误时,它不会撤消它已经完成的事情” —— 所以,从错误中恢复是应用程序的责任。
然而并不是所有的系统都遵循这个哲学。特别是具有 [无主复制](/ch5#无主复制) 的数据存储,主要是在 “尽力而为” 的基础上进行工作。可以概括为 “数据库将做尽可能多的事,运行遇到错误时,它不会撤消它已经完成的事情” —— 所以,从错误中恢复是应用程序的责任。
错误发生不可避免,但许多软件开发人员倾向于只考虑乐观情况,而不是错误处理的复杂性。例如,像 Rails 的 ActiveRecord 和 Django 这样的 **对象关系映射ORM, object-relation Mapping** 框架不会重试中断的事务 —— 这个错误通常会导致一个从堆栈向上传播的异常,所以任何用户输入都会被丢弃,用户拿到一个错误信息。这实在是太耻辱了,因为中止的重点就是允许安全的重试。
@ -207,7 +208,7 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
- 如果事务实际上成功了,但是在服务器试图向客户端确认提交成功时网络发生故障(所以客户端认为提交失败了),那么重试事务会导致事务被执行两次 —— 除非你有一个额外的应用级去重机制。
- 如果错误是由于负载过大造成的,则重试事务将使问题变得更糟,而不是更好。为了避免这种正反馈循环,可以限制重试次数,使用指数退避算法,并单独处理与过载相关的错误(如果允许)。
- 仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
- 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果你正在发送电子邮件,那你肯定不希望每次重试事务时都重新发送电子邮件。如果你想确保几个不同的系统一起提交或放弃,**两阶段提交2PC, two-phase commit** 可以提供帮助(“[原子提交与两阶段提交](ch9.md#原子提交与两阶段提交)” 中将讨论这个问题)。
- 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果你正在发送电子邮件,那你肯定不希望每次重试事务时都重新发送电子邮件。如果你想确保几个不同的系统一起提交或放弃,**两阶段提交2PC, two-phase commit** 可以提供帮助(“[原子提交与两阶段提交](/ch9#原子提交与两阶段提交)” 中将讨论这个问题)。
- 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。
## 弱隔离级别
@ -243,16 +244,16 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
设想一个事务已经将一些数据写入数据库,但事务还没有提交或中止。另一个事务可以看到未提交的数据吗?如果是的话,那就叫做 **脏读dirty reads**【2】。
**读已提交** 隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即变得可见)。如 [图 7-4](img/fig7-4.png) 所示,用户 1 设置了 `x = 3`,但用户 2 的 `get x` 仍旧返回旧值 2 (当用户 1 尚未提交时)。
**读已提交** 隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即变得可见)。如 [图 7-4](/img/fig7-4.png) 所示,用户 1 设置了 `x = 3`,但用户 2 的 `get x` 仍旧返回旧值 2 (当用户 1 尚未提交时)。
![](img/fig7-4.png)
![](/img/fig7-4.png)
**图 7-4 没有脏读:用户 2 只有在用户 1 的事务已经提交后才能看到 x 的新值。**
为什么要防止脏读,有几个原因:
- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。例如,在 [图 7-2](img/fig7-2.png) 中,用户看到新的未读电子邮件,但看不到更新的计数器。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定。
- 如果事务中止,则所有写入操作都需要回滚(如 [图 7-3](img/fig7-3.png) 所示)。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。想想后果就让人头大。
- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。例如,在 [图 7-2](/img/fig7-2.png) 中,用户看到新的未读电子邮件,但看不到更新的计数器。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定。
- 如果事务中止,则所有写入操作都需要回滚(如 [图 7-3](/img/fig7-3.png) 所示)。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。想想后果就让人头大。
#### 没有脏写
@ -262,10 +263,10 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
通过防止脏写,这个隔离级别避免了一些并发问题:
- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图 7-5](img/fig7-5.png)以一个二手车销售网站为例Alice 和 Bob 两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在 [图 7-5](img/fig7-5.png) 的情况下,销售是属于 Bob 的(因为他成功更新了商品列表),但发票却寄送给了 Alice因为她成功更新了发票表。读已提交会防止这样的事故。
- 但是,读已提交并不能防止 [图 7-1](img/fig7-1.png) 中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在 “[防止丢失更新](#防止丢失更新)” 中将讨论如何使这种计数器增量安全。
- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图 7-5](/img/fig7-5.png)以一个二手车销售网站为例Alice 和 Bob 两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在 [图 7-5](/img/fig7-5.png) 的情况下,销售是属于 Bob 的(因为他成功更新了商品列表),但发票却寄送给了 Alice因为她成功更新了发票表。读已提交会防止这样的事故。
- 但是,读已提交并不能防止 [图 7-1](/img/fig7-1.png) 中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在 “[防止丢失更新](#防止丢失更新)” 中将讨论如何使这种计数器增量安全。
![](img/fig7-5.png)
![](/img/fig7-5.png)
**图 7-5 如果存在脏写,来自不同事务的冲突写入可能会混淆在一起**
@ -279,7 +280,7 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
但是要求读锁的办法在实践中效果并不好。因为一个长时间运行的写入事务会迫使许多只读事务等到这个慢写入事务完成。这会影响只读事务的响应时间,并且不利于可操作性:因为等待锁,应用某个部分的迟缓可能由于连锁效应,导致其他部分出现问题。
出于这个原因,大多数数据库 [^vi] 使用 [图 7-4](img/fig7-4.png) 的方式防止脏读:对于写入的每个对象,数据库都会记住旧的已提交值,和由当前持有写入锁的事务设置的新值。当事务正在进行时,任何其他读取对象的事务都会拿到旧值。只有当新值提交后,事务才会切换到读取新值。
出于这个原因,大多数数据库 [^vi] 使用 [图 7-4](/img/fig7-4.png) 的方式防止脏读:对于写入的每个对象,数据库都会记住旧的已提交值,和由当前持有写入锁的事务设置的新值。当事务正在进行时,任何其他读取对象的事务都会拿到旧值。只有当新值提交后,事务才会切换到读取新值。
[^vi]: 在撰写本文时,唯一在读已提交隔离级别使用读锁的主流数据库是 IBM DB2 和使用 `read_committed_snapshot = off` 配置的 Microsoft SQL Server【23,36】。
@ -287,9 +288,9 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
如果只从表面上看读已提交隔离级别,你可能就认为它完成了事务所需的一切,这是情有可原的。它允许 **中止**(原子性的要求);它防止读取不完整的事务结果,并且防止并发写入造成的混乱。事实上这些功能非常有用,比起没有事务的系统来,可以提供更多的保证。
但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如 [图 7-6](img/fig7-6.png) 说明了读已提交时可能发生的问题。
但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如 [图 7-6](/img/fig7-6.png) 说明了读已提交时可能发生的问题。
![](img/fig7-6.png)
![](/img/fig7-6.png)
**图 7-6 读取偏差Alice 观察数据库处于不一致的状态**
@ -297,7 +298,7 @@ Alice 在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元
这种异常被称为 **不可重复读nonrepeatable read****读取偏差read skew**:如果 Alice 在事务结束时再次读取账户 1 的余额她将看到与她之前的查询中看到的不同的值600 美元)。在读已提交的隔离条件下,**不可重复读** 被认为是可接受的Alice 看到的帐户余额确实在阅读时已经提交了。
> 不幸的是,术语 **偏差skew** 这个词是过载的:以前使用它是因为热点的不平衡工作量(请参阅 “[负载偏斜与热点消除](ch6.md#负载偏斜与热点消除)”),而这里偏差意味着异常的时序。
> 不幸的是,术语 **偏差skew** 这个词是过载的:以前使用它是因为热点的不平衡工作量(请参阅 “[负载偏斜与热点消除](/ch6#负载偏斜与热点消除)”),而这里偏差意味着异常的时序。
对于 Alice 的情况,这不是一个长期持续的问题。因为如果她几秒钟后刷新银行网站的页面,她很可能会看到一致的帐户余额。但是有些情况下,不能容忍这种暂时的不一致:
@ -307,7 +308,7 @@ Alice 在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元
* 分析查询和完整性检查
有时,你可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见(请参阅 “[事务处理还是分析?](ch3.md#事务处理还是分析?)”),也可能是定期完整性检查(即监视数据损坏)的一部分。如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果。
有时,你可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见(请参阅 “[事务处理还是分析?](/ch3#事务处理还是分析?)”),也可能是定期完整性检查(即监视数据损坏)的一部分。如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果。
**快照隔离snapshot isolation**【28】是这个问题最常见的解决方案。想法是每个事务都从数据库的 **一致快照consistent snapshot** 中读取 —— 也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
@ -319,15 +320,15 @@ Alice 在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元
与读取提交的隔离类似,快照隔离的实现通常使用写锁来防止脏写(请参阅 “[读已提交](#读已提交)”),这意味着进行写入的事务会阻止另一个事务修改同一个对象。但是读取则不需要加锁。从性能的角度来看,快照隔离的一个关键原则是:**读不阻塞写,写不阻塞读**。这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作,且两者间没有任何锁争用。
为了实现快照隔离,数据库使用了我们看到的用于防止 [图 7-4](img/fig7-4.png) 中的脏读的机制的一般化。数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它同时维护着单个对象的多个版本,所以这种技术被称为 **多版本并发控制MVCC, multi-version concurrency control**
为了实现快照隔离,数据库使用了我们看到的用于防止 [图 7-4](/img/fig7-4.png) 中的脏读的机制的一般化。数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它同时维护着单个对象的多个版本,所以这种技术被称为 **多版本并发控制MVCC, multi-version concurrency control**
如果一个数据库只需要提供 **读已提交** 的隔离级别,而不提供 **快照隔离**,那么保留一个对象的两个版本就足够了:已提交的版本和被覆盖但尚未提交的版本。不过支持快照隔离的存储引擎通常也使用 MVCC 来实现 **读已提交** 隔离级别。一种典型的方法是 **读已提交** 为每个查询使用单独的快照,而 **快照隔离** 对整个事务使用相同的快照。
[图 7-7](img/fig7-7.png) 说明了 PostgreSQL 如何实现基于 MVCC 的快照隔离【31】其他实现类似。当一个事务开始时它被赋予一个唯一的永远增长 [^vii] 的事务 ID`txid`)。每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写入者的事务 ID。
[图 7-7](/img/fig7-7.png) 说明了 PostgreSQL 如何实现基于 MVCC 的快照隔离【31】其他实现类似。当一个事务开始时它被赋予一个唯一的永远增长 [^vii] 的事务 ID`txid`)。每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写入者的事务 ID。
[^vii]: 事实上,事务 ID 是 32 位整数,所以大约会在 40 亿次事务之后溢出。PostgreSQL 的 Vacuum 过程会清理老旧的事务 ID确保事务 ID 溢出(回卷)不会影响到数据。
![](img/fig7-7.png)
![](/img/fig7-7.png)
**图 7-7 使用多版本对象实现快照隔离**
@ -335,7 +336,7 @@ Alice 在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元
[^译注ii]: 在 PostgreSQL 中,`created_by` 的实际名称为 `xmin``deleted_by` 的实际名称为 `xmax`
`UPDATE` 操作在内部翻译为 `DELETE``INSERT` 。例如,在 [图 7-7](img/fig7-7.png) 中,事务 13 从账户 2 中扣除 100 美元,将余额从 500 美元改为 400 美元。实际上包含两条账户 2 的记录:余额为 \$500 的行被标记为 **被事务 13 删除**,余额为 \$400 的行 **由事务 13 创建**
`UPDATE` 操作在内部翻译为 `DELETE``INSERT` 。例如,在 [图 7-7](/img/fig7-7.png) 中,事务 13 从账户 2 中扣除 100 美元,将余额从 500 美元改为 400 美元。实际上包含两条账户 2 的记录:余额为 \$500 的行被标记为 **被事务 13 删除**,余额为 \$400 的行 **由事务 13 创建**
#### 观察一致性快照的可见性规则
@ -346,7 +347,7 @@ Alice 在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元
3. 由具有较晚事务 ID在当前事务开始之后开始的的事务所做的任何写入都被忽略而不管这些事务是否已经提交。
4. 所有其他写入,对应用都是可见的。
这些规则适用于创建和删除对象。在 [图 7-7](img/fig7-7.png) 中,当事务 12 从账户 2 读取时,它会看到 \$500 的余额,因为 \$500 余额的删除是由事务 13 完成的(根据规则 3事务 12 看不到事务 13 执行的删除),且 400 美元记录的创建也是不可见的(按照相同的规则)。
这些规则适用于创建和删除对象。在 [图 7-7](/img/fig7-7.png) 中,当事务 12 从账户 2 读取时,它会看到 \$500 的余额,因为 \$500 余额的删除是由事务 13 完成的(根据规则 3事务 12 看不到事务 13 执行的删除),且 400 美元记录的创建也是不可见的(按照相同的规则)。
换句话说,如果以下两个条件都成立,则可见一个对象:
@ -361,7 +362,7 @@ Alice 在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元
在实践中许多实现细节决定了多版本并发控制的性能。例如如果同一对象的不同版本可以放入同一个页面中PostgreSQL 的优化可以避免更新索引【31】。
在 CouchDB、Datomic 和 LMDB 中使用另一种方法。虽然它们也使用 [B 树](ch3.md#B树),但它们使用的是一种 **仅追加 / 写时拷贝append-only/copy-on-write** 的变体它们在更新时不覆盖树的页面而为每个修改页面创建一份副本。从父页面直到树根都会级联更新以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制并且保持不变【33,34,35】。
在 CouchDB、Datomic 和 LMDB 中使用另一种方法。虽然它们也使用 [B 树](/ch3#B树),但它们使用的是一种 **仅追加 / 写时拷贝append-only/copy-on-write** 的变体它们在更新时不覆盖树的页面而为每个修改页面创建一份副本。从父页面直到树根都会级联更新以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制并且保持不变【33,34,35】。
使用仅追加的 B 树,每个写入事务(或一批事务)都会创建一棵新的 B 树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务 ID 过滤掉对象,因为后续写入不能修改现有的 B 树;它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。
@ -379,7 +380,7 @@ Alice 在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元
到目前为止已经讨论的 **读已提交****快照隔离** 级别,主要保证了 **只读事务在并发写入时** 可以看到什么。却忽略了两个事务并发写入的问题 —— 我们只讨论了脏写(请参阅 “[没有脏写](#没有脏写)”),一种特定类型的写 - 写冲突是可能出现的。
并发的写入事务之间还有其他几种有趣的冲突。其中最著名的是 **丢失更新lost update** 问题,如 [图 7-1](img/fig7-1.png) 所示,以两个并发计数器增量为例。
并发的写入事务之间还有其他几种有趣的冲突。其中最著名的是 **丢失更新lost update** 问题,如 [图 7-1](/img/fig7-1.png) 所示,以两个并发计数器增量为例。
如果应用从数据库中读取一些值,修改它并写回修改的值(读取 - 修改 - 写入序列),则可能会发生丢失更新的问题。如果两个事务同时执行,则其中一个的修改可能会丢失,因为第二个写入的内容并没有包括第一个事务的修改(有时会说后面写入 **狠揍clobber** 了前面的写入)这种模式发生在各种不同的情况下:
@ -399,7 +400,7 @@ UPDATE counters SET value = value + 1 WHERE key = 'foo';
类似地,像 MongoDB 这样的文档数据库提供了对 JSON 文档的一部分进行本地修改的原子操作Redis 提供了修改数据结构(如优先级队列)的原子操作。并不是所有的写操作都可以用原子操作的方式来表达,例如 wiki 页面的更新涉及到任意文本编辑 [^viii],但是在可以使用原子操作的情况下,它们通常是最好的选择。
[^viii]: 将文本文档的编辑表示为原子的变化流是可能的,尽管相当复杂。请参阅 “[自动冲突解决](ch5.md#自动冲突解决)”。
[^viii]: 将文本文档的编辑表示为原子的变化流是可能的,尽管相当复杂。请参阅 “[自动冲突解决](/ch5#自动冲突解决)”。
原子操作通常通过在读取对象时,获取其上的排它锁来实现。以便更新完成之前没有其他事务可以读取它。这种技术有时被称为 **游标稳定性cursor stability**【36,37】。另一个选择是简单地强制所有的原子操作在单一线程上执行。
@ -452,15 +453,15 @@ UPDATE wiki_pages SET content = '新内容'
#### 冲突解决和复制
在复制数据库中(请参阅 [第五章](ch5.md)),防止丢失的更新需要考虑另一个维度:由于在多个节点上存在数据副本,并且在不同节点上的数据可能被并发地修改,因此需要采取一些额外的步骤来防止丢失更新。
在复制数据库中(请参阅 [第五章](/ch5)),防止丢失的更新需要考虑另一个维度:由于在多个节点上存在数据副本,并且在不同节点上的数据可能被并发地修改,因此需要采取一些额外的步骤来防止丢失更新。
锁和 CAS 操作假定只有一个最新的数据副本。但是多主或无主复制的数据库通常允许多个写入并发执行,并异步复制到副本上,因此无法保证只有一个最新数据的副本。所以基于锁或 CAS 操作的技术不适用于这种情况(我们将在 “[线性一致性](ch9.md#线性一致性)” 中更详细地讨论这个问题)。
锁和 CAS 操作假定只有一个最新的数据副本。但是多主或无主复制的数据库通常允许多个写入并发执行,并异步复制到副本上,因此无法保证只有一个最新数据的副本。所以基于锁或 CAS 操作的技术不适用于这种情况(我们将在 “[线性一致性](/ch9#线性一致性)” 中更详细地讨论这个问题)。
相反,如 “[检测并发写入](ch5.md#检测并发写入)” 一节所述,这种复制数据库中的一种常见方法是允许并发写入创建多个冲突版本的值(也称为兄弟),并使用应用代码或特殊数据结构在事实发生之后解决和合并这些版本。
相反,如 “[检测并发写入](/ch5#检测并发写入)” 一节所述,这种复制数据库中的一种常见方法是允许并发写入创建多个冲突版本的值(也称为兄弟),并使用应用代码或特殊数据结构在事实发生之后解决和合并这些版本。
原子操作可以在复制的上下文中很好地工作,尤其当它们具有可交换性时(即,可以在不同的副本上以不同的顺序应用它们,且仍然可以得到相同的结果)。例如,递增计数器或向集合添加元素是可交换的操作。这是 Riak 2.0 数据类型背后的思想它可以防止复制副本丢失更新。当不同的客户端同时更新一个值时Riak 自动将更新合并在一起以免丢失更新【39】。
另一方面最后写入胜利LWW的冲突解决方法很容易丢失更新如 “[最后写入胜利(丢弃并发写入)](ch5.md#最后写入胜利(丢弃并发写入))” 中所述。不幸的是LWW 是许多复制数据库中的默认方案。
另一方面最后写入胜利LWW的冲突解决方法很容易丢失更新如 “[最后写入胜利(丢弃并发写入)](/ch5#最后写入胜利(丢弃并发写入))” 中所述。不幸的是LWW 是许多复制数据库中的默认方案。
### 写入偏差与幻读
@ -470,9 +471,9 @@ UPDATE wiki_pages SET content = '新内容'
首先想象一下这个例子你正在为医院写一个医生轮班管理程序。医院通常会同时要求几位医生待命但底线是至少有一位医生在待命。医生可以放弃他们的班次例如如果他们自己生病了只要至少有一个同事在这一班中继续工作【40,41】。
现在想象一下Alice 和 Bob 是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。[图 7-8](img/fig7-8.png) 说明了接下来的事情。
现在想象一下Alice 和 Bob 是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。[图 7-8](/img/fig7-8.png) 说明了接下来的事情。
![](img/fig7-8.png)
![](/img/fig7-8.png)
**图 7-8 写入偏差导致应用程序错误的示例**
@ -595,7 +596,7 @@ COMMIT;
- **两阶段锁定2PL, two-phase locking**,几十年来唯一可行的选择(请参阅 “[两阶段锁定](#两阶段锁定)”)
- 乐观并发控制技术,例如 **可串行化快照隔离**serializable snapshot isolation请参阅 “[可串行化快照隔离](#可串行化快照隔离)”)
现在将主要在单节点数据库的背景下讨论这些技术;在 [第九章](ch9.md) 中,我们将研究如何将它们推广到涉及分布式系统中多个节点的事务。
现在将主要在单节点数据库的背景下讨论这些技术;在 [第九章](/ch9) 中,我们将研究如何将它们推广到涉及分布式系统中多个节点的事务。
### 真的串行执行
@ -605,8 +606,8 @@ COMMIT;
两个进展引发了这个反思:
- RAM 足够便宜了,许多场景现在都可以将完整的活跃数据集保存在内存中(请参阅 “[在内存中存储一切](ch3.md#在内存中存储一切)”)。当事务需要访问的所有数据都在内存中时,事务处理的执行速度要比等待数据从磁盘加载时快得多。
- 数据库设计人员意识到 OLTP 事务通常很短,而且只进行少量的读写操作(请参阅 “[事务处理还是分析?](ch3.md#事务处理还是分析?)”)。相比之下,长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。
- RAM 足够便宜了,许多场景现在都可以将完整的活跃数据集保存在内存中(请参阅 “[在内存中存储一切](/ch3#在内存中存储一切)”)。当事务需要访问的所有数据都在内存中时,事务处理的执行速度要比等待数据从磁盘加载时快得多。
- 数据库设计人员意识到 OLTP 事务通常很短,而且只进行少量的读写操作(请参阅 “[事务处理还是分析?](/ch3#事务处理还是分析?)”)。相比之下,长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。
串行执行事务的方法在 VoltDB/H-Store、Redis 和 Datomic 中实现【46,47,48】。设计用于单线程执行的系统有时可以比支持并发的系统性能更好因为它可以避免锁的协调开销。但是其吞吐量仅限于单个 CPU 核的吞吐量。为了充分利用单一线程,需要有与传统形式的事务不同的结构。
@ -620,9 +621,9 @@ COMMIT;
在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。
出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如 [图 7-9](img/fig7-9.png) 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘 I/O。
出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如 [图 7-9](/img/fig7-9.png) 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘 I/O。
![](img/fig7-9.png)
![](/img/fig7-9.png)
**图 7-9 交互式事务和存储过程之间的区别(使用图 7-8 的示例事务)**
@ -644,13 +645,13 @@ VoltDB 还使用存储过程进行复制:但不是将事务的写入结果从
顺序执行所有事务使并发控制简单多了,但数据库的事务吞吐量被限制为单机单核的速度。只读事务可以使用快照隔离在其它地方执行,但对于写入吞吐量较高的应用,单线程事务处理器可能成为一个严重的瓶颈。
为了伸缩至多个 CPU 核心和多个节点,可以对数据进行分区(请参阅 [第六章](ch6.md)),在 VoltDB 中支持这样做。如果你可以找到一种对数据集进行分区的方法,以便每个事务只需要在单个分区中读写数据,那么每个分区就可以拥有自己独立运行的事务处理线程。在这种情况下可以为每个分区指派一个独立的 CPU 核,事务吞吐量就可以与 CPU 核数保持线性伸缩【47】。
为了伸缩至多个 CPU 核心和多个节点,可以对数据进行分区(请参阅 [第六章](/ch6)),在 VoltDB 中支持这样做。如果你可以找到一种对数据集进行分区的方法,以便每个事务只需要在单个分区中读写数据,那么每个分区就可以拥有自己独立运行的事务处理线程。在这种情况下可以为每个分区指派一个独立的 CPU 核,事务吞吐量就可以与 CPU 核数保持线性伸缩【47】。
但是,对于需要访问多个分区的任何事务,数据库必须在触及的所有分区之间协调事务。存储过程需要跨越所有分区锁定执行,以确保整个系统的可串行性。
由于跨分区事务具有额外的协调开销所以它们比单分区事务慢得多。VoltDB 报告的吞吐量大约是每秒 1000 个跨分区写入比单分区吞吐量低几个数量级并且不能通过增加更多的机器来增加吞吐量【49】。
事务是否可以是划分至单个分区很大程度上取决于应用数据的结构。简单的键值数据通常可以非常容易地进行分区,但是具有多个次级索引的数据可能需要大量的跨分区协调(请参阅 “[分区与次级索引](ch6.md#分区与次级索引)”)。
事务是否可以是划分至单个分区很大程度上取决于应用数据的结构。简单的键值数据通常可以非常容易地进行分区,但是具有多个次级索引的数据可能需要大量的跨分区协调(请参阅 “[分区与次级索引](/ch6#分区与次级索引)”)。
#### 串行执行小结
@ -661,7 +662,7 @@ VoltDB 还使用存储过程进行复制:但不是将事务的写入结果从
- 写入吞吐量必须低到能在单个 CPU 核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。
- 跨分区事务是可能的,但是它们能被使用的程度有很大的限制。
[^x]: 如果事务需要访问不在内存中的数据,最好的解决方案可能是中止事务,异步地将数据提取到内存中,同时继续处理其他事务,然后在数据加载完毕时重新启动事务。这种方法被称为 **反缓存anti-caching**,正如前面在 “[在内存中存储一切](ch3.md#在内存中存储一切)” 中所述。
[^x]: 如果事务需要访问不在内存中的数据,最好的解决方案可能是中止事务,异步地将数据提取到内存中,同时继续处理其他事务,然后在数据加载完毕时重新启动事务。这种方法被称为 **反缓存anti-caching**,正如前面在 “[在内存中存储一切](/ch3#在内存中存储一切)” 中所述。
### 两阶段锁定
@ -671,14 +672,14 @@ VoltDB 还使用存储过程进行复制:但不是将事务的写入结果从
> #### 2PL不是2PC
>
> 请注意虽然两阶段锁定2PL听起来非常类似于两阶段提交2PC但它们是完全不同的东西。我们将在 [第九章](ch9.md) 讨论 2PC。
> 请注意虽然两阶段锁定2PL听起来非常类似于两阶段提交2PC但它们是完全不同的东西。我们将在 [第九章](/ch9) 讨论 2PC。
之前我们看到锁通常用于防止脏写(请参阅 “[没有脏写](#没有脏写)” 一节):如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交),然后才能继续。
两阶段锁定类似,但是锁的要求更强得多。只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要 **独占访问exclusive access** 权限:
- 如果事务 A 读取了一个对象,并且事务 B 想要写入该对象,那么 B 必须等到 A 提交或中止才能继续(这确保 B 不能在 A 底下意外地改变对象)。
- 如果事务 A 写入了一个对象,并且事务 B 想要读取该对象,则 B 必须等到 A 提交或中止才能继续(像 [图 7-1](img/fig7-1.png) 那样读取旧版本的对象在 2PL 下是不可接受的)。
- 如果事务 A 写入了一个对象,并且事务 B 想要读取该对象,则 B 必须等到 A 提交或中止才能继续(像 [图 7-1](/img/fig7-1.png) 那样读取旧版本的对象在 2PL 下是不可接受的)。
在 2PL 中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。快照隔离使得 **读不阻塞写,写也不阻塞读**(请参阅 “[实现快照隔离](#实现快照隔离)”),这是 2PL 和快照隔离之间的关键区别。另一方面,因为 2PL 提供了可串行化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写入偏差。
@ -703,7 +704,7 @@ VoltDB 还使用存储过程进行复制:但不是将事务的写入结果从
传统的关系数据库不限制事务的持续时间,因为它们是为等待人类输入的交互式应用而设计的。因此,当一个事务需要等待另一个事务时,等待的时长并没有限制。即使你保证所有的事务都很短,如果有多个事务想要访问同一个对象,那么可能会形成一个队列,所以事务可能需要等待几个其他事务才能完成。
因此,运行 2PL 的数据库可能具有相当不稳定的延迟,如果在工作负载中存在争用,那么可能高百分位点处的响应会非常的慢(请参阅 “[描述性能](ch1.md#描述性能)”)。可能只需要一个缓慢的事务,或者一个访问大量数据并获取许多锁的事务,就能把系统的其他部分拖慢,甚至迫使系统停机。当需要稳健的操作时,这种不稳定性是有问题的。
因此,运行 2PL 的数据库可能具有相当不稳定的延迟,如果在工作负载中存在争用,那么可能高百分位点处的响应会非常的慢(请参阅 “[描述性能](/ch1#描述性能)”)。可能只需要一个缓慢的事务,或者一个访问大量数据并获取许多锁的事务,就能把系统的其他部分拖慢,甚至迫使系统停机。当需要稳健的操作时,这种不稳定性是有问题的。
基于锁实现的读已提交隔离级别可能发生死锁,但在基于 2PL 实现的可串行化隔离级别中,它们会出现的频繁的多(取决于事务的访问模式)。这可能是一个额外的性能问题:当事务由于死锁而被中止并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨大的浪费。
@ -784,9 +785,9 @@ WHERE room_id = 123 AND
#### 检测旧MVCC读取
回想一下快照隔离通常是通过多版本并发控制MVCC见 [图 7-10](img/fig7-10.png))来实现的。当一个事务从 MVCC 数据库中的一致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。在 [图 7-10](img/fig7-10.png) 中,事务 43 认为 Alice 的 `on_call = true` ,因为事务 42修改 Alice 的待命状态)未被提交。然而,在事务 43 想要提交时,事务 42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务 43 的前提不再为真。
回想一下快照隔离通常是通过多版本并发控制MVCC见 [图 7-10](/img/fig7-10.png))来实现的。当一个事务从 MVCC 数据库中的一致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。在 [图 7-10](/img/fig7-10.png) 中,事务 43 认为 Alice 的 `on_call = true` ,因为事务 42修改 Alice 的待命状态)未被提交。然而,在事务 43 想要提交时,事务 42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务 43 的前提不再为真。
![](img/fig7-10.png)
![](/img/fig7-10.png)
**图 7-10 检测事务何时从 MVCC 快照读取过时的值**
@ -796,19 +797,19 @@ WHERE room_id = 123 AND
#### 检测影响之前读取的写入
第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如 [图 7-11](img/fig7-11.png) 所示。
第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如 [图 7-11](/img/fig7-11.png) 所示。
![](img/fig7-11.png)
![](/img/fig7-11.png)
**图 7-11 在可串行化快照隔离中,检测一个事务何时修改另一个事务的读取。**
在两阶段锁定的上下文中,我们讨论了索引范围锁(请参阅 “[索引范围锁](#索引范围锁)”),它允许数据库锁定与某个搜索查询匹配的所有行的访问权,例如 `WHERE shift_id = 1234`。可以在这里使用类似的技术,除了 SSI 锁不会阻塞其他事务。
在 [图 7-11](img/fig7-11.png) 中,事务 42 和 43 都在班次 1234 查找值班医生。如果在 `shift_id` 上有索引,则数据库可以使用索引项 1234 来记录事务 42 和 43 读取这个数据的事实。(如果没有索引,这个信息可以在表级别进行跟踪)。这个信息只需要保留一段时间:在一个事务完成(提交或中止),并且所有的并发事务完成之后,数据库就可以忘记它读取的数据了。
在 [图 7-11](/img/fig7-11.png) 中,事务 42 和 43 都在班次 1234 查找值班医生。如果在 `shift_id` 上有索引,则数据库可以使用索引项 1234 来记录事务 42 和 43 读取这个数据的事实。(如果没有索引,这个信息可以在表级别进行跟踪)。这个信息只需要保留一段时间:在一个事务完成(提交或中止),并且所有的并发事务完成之后,数据库就可以忘记它读取的数据了。
当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务直到其他读事务完成,而是像警戒线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。
在 [图 7-11](img/fig7-11.png) 中,事务 43 通知事务 42 其先前读已过时,反之亦然。事务 42 首先提交并成功,尽管事务 43 的写影响了 42 ,但因为事务 43 尚未提交,所以写入尚未生效。然而当事务 43 想要提交时,来自事务 42 的冲突写入已经被提交,所以事务 43 必须中止。
在 [图 7-11](/img/fig7-11.png) 中,事务 43 通知事务 42 其先前读已过时,反之亦然。事务 42 首先提交并成功,尽管事务 43 的写影响了 42 ,但因为事务 43 尚未提交,所以写入尚未生效。然而当事务 43 想要提交时,来自事务 42 的冲突写入已经被提交,所以事务 43 必须中止。
#### 可串行化快照隔离的性能
@ -932,10 +933,3 @@ WHERE room_id = 123 AND
1. D. Z. Badal: “[Correctness of Concurrency Control and Implications in Distributed Databases](http://ieeexplore.ieee.org/abstract/document/762563/),” at *3rd International IEEE Computer Software and Applications Conference* (COMPSAC), November 1979.
1. Rakesh Agrawal, Michael J. Carey, and Miron Livny: “[Concurrency Control Performance Modeling: Alternatives and Implications](http://www.eecs.berkeley.edu/~brewer/cs262/ConcControl.pdf),” *ACM Transactions on Database Systems* (TODS), volume 12, number 4, pages 609654, December 1987. [doi:10.1145/32204.32220](http://dx.doi.org/10.1145/32204.32220)
1. Dave Rosenthal: “[Databases at 14.4MHz](http://web.archive.org/web/20150427041746/http://blog.foundationdb.com/databases-at-14.4mhz),” *blog.foundationdb.com*, December 10, 2014.
------
| 上一章 | 目录 | 下一章 |
| ---------------------- | ------------------------------- | ---------------------------------- |
| [第六章:分区](ch6.md) | [设计数据密集型应用](README.md) | [第八章:分布式系统的麻烦](ch8.md) |

View File

@ -1,6 +1,12 @@
# 第八章:分布式系统的麻烦
---
title: "第八章:分布式系统的麻烦"
linkTitle: "8. 分布式系统的麻烦"
weight: 208
breadcrumbs: false
---
![](img/ch8.png)
![](/img/ch8.png)
> 邂逅相遇
>
@ -12,11 +18,8 @@
>
> —— Kyle Kingsbury, Carly Rae Jepsen 《网络分区的危害》2013 年)[^译著1]
---------
[TOC]
最近几章中反复出现的主题是,系统如何处理错误的事情。例如,我们讨论了 **副本故障切换**(“[处理节点中断](ch5.md#处理节点宕机)”),**复制延迟**(“[复制延迟问题](ch5.md#复制延迟问题)”)和事务控制(“[弱隔离级别](ch7.md#弱隔离级别)”)。当我们了解可能在实际系统中出现的各种边缘情况时,我们会更好地处理它们。
最近几章中反复出现的主题是,系统如何处理错误的事情。例如,我们讨论了 **副本故障切换**(“[处理节点中断](/ch5#处理节点宕机)”),**复制延迟**(“[复制延迟问题](/ch5#复制延迟问题)”)和事务控制(“[弱隔离级别](/ch7#弱隔离级别)”)。当我们了解可能在实际系统中出现的各种边缘情况时,我们会更好地处理它们。
但是,尽管我们已经谈了很多错误,但之前几章仍然过于乐观。现实更加黑暗。我们现在将悲观主义最大化,假设任何可能出错的东西 **都会** 出错 [^i]。(经验丰富的系统运维会告诉你,这是一个合理的假设。如果你问得好,他们可能会一边治疗心理创伤一边告诉你一些可怕的故事)
@ -24,7 +27,7 @@
使用分布式系统与在一台计算机上编写软件有着根本的区别主要的区别在于有许多新颖和刺激的方法可以使事情出错【1,2】。在这一章中我们将了解实践中出现的问题理解我们能够依赖和不可以依赖的东西。
最后,作为工程师,我们的任务是构建能够完成工作的系统(即满足用户期望的保证),尽管一切都出错了。在 [第九章](ch9.md) 中,我们将看看一些可以在分布式系统中提供这种保证的算法的例子。但首先,在本章中,我们必须了解我们面临的挑战。
最后,作为工程师,我们的任务是构建能够完成工作的系统(即满足用户期望的保证),尽管一切都出错了。在 [第九章](/ch9) 中,我们将看看一些可以在分布式系统中提供这种保证的算法的例子。但首先,在本章中,我们必须了解我们面临的挑战。
本章对分布式系统中可能出现的问题进行彻底的悲观和沮丧的总结。我们将研究网络的问题(“[不可靠的网络](#不可靠的网络)”); 时钟和时序问题(“[不可靠的时钟](#不可靠的时钟)”); 我们将讨论他们可以避免的程度。所有这些问题的后果都是困惑的,所以我们将探索如何思考一个分布式系统的状态,以及如何推理发生的事情(“[知识、真相与谎言](#知识、真相与谎言)”)。
@ -67,11 +70,11 @@
* 系统越大其组件之一就越有可能坏掉。随着时间的推移坏掉的东西得到修复新的东西又坏掉但是在一个有成千上万个节点的系统中有理由认为总是有一些东西是坏掉的【7】。当错误处理的策略只由简单放弃组成时一个大的系统最终会花费大量时间从错误中恢复而不是做有用的工作【8】。
* 如果系统可以容忍发生故障的节点,并继续保持整体工作状态,那么这对于运营和维护非常有用:例如,可以执行滚动升级(请参阅 [第四章](ch4.md)),一次重新启动一个节点,同时继续给用户提供不中断的服务。在云环境中,如果一台虚拟机运行不佳,可以杀死它并请求一台新的虚拟机(希望新的虚拟机速度更快)。
* 如果系统可以容忍发生故障的节点,并继续保持整体工作状态,那么这对于运营和维护非常有用:例如,可以执行滚动升级(请参阅 [第四章](/ch4)),一次重新启动一个节点,同时继续给用户提供不中断的服务。在云环境中,如果一台虚拟机运行不佳,可以杀死它并请求一台新的虚拟机(希望新的虚拟机速度更快)。
* 在地理位置分散的部署中(保持数据在地理位置上接近用户以减少访问延迟),通信很可能通过互联网进行,与本地网络相比,通信速度缓慢且不可靠。超级计算机通常假设它们的所有节点都靠近在一起。
如果要使分布式系统工作,就必须接受部分故障的可能性,并在软件中建立容错机制。换句话说,我们需要从不可靠的组件构建一个可靠的系统(正如 “[可靠性](ch1.md#可靠性)” 中所讨论的那样,没有完美的可靠性,所以我们需要理解我们可以实际承诺的极限)。
如果要使分布式系统工作,就必须接受部分故障的可能性,并在软件中建立容错机制。换句话说,我们需要从不可靠的组件构建一个可靠的系统(正如 “[可靠性](/ch1#可靠性)” 中所讨论的那样,没有完美的可靠性,所以我们需要理解我们可以实际承诺的极限)。
即使在只有少数节点的小型系统中,考虑部分故障也是很重要的。在一个小系统中,很可能大部分组件在大部分时间都正常工作。然而,迟早会有一部分系统出现故障,软件必须以某种方式处理。故障处理必须是软件设计的一部分,并且作为软件的运维,你需要知道在发生故障的情况下,软件可能会表现出怎样的行为。
@ -86,16 +89,16 @@
>
> 虽然这个系统可以比它的底层部分更可靠但它的可靠性总是有限的。例如纠错码可以处理少量的单比特错误但是如果你的信号被干扰所淹没那么通过信道可以得到多少数据是有根本性的限制的【13】。TCP 可以隐藏数据包的丢失,重复和重新排序,但是它不能神奇地消除网络中的延迟。
>
> 虽然更可靠的高级系统并不完美,但它仍然有用,因为它处理了一些棘手的低级错误,所以其余的错误通常更容易推理和处理。我们将在 “[数据库的端到端原则](ch12.md#数据库的端到端原则)” 中进一步探讨这个问题。
> 虽然更可靠的高级系统并不完美,但它仍然有用,因为它处理了一些棘手的低级错误,所以其余的错误通常更容易推理和处理。我们将在 “[数据库的端到端原则](/ch12#数据库的端到端原则)” 中进一步探讨这个问题。
## 不可靠的网络
正如在 [第二部分](part-ii.md) 的介绍中所讨论的那样,我们在本书中关注的分布式系统是无共享的系统,即通过网络连接的一堆机器。网络是这些机器可以通信的唯一途径 —— 我们假设每台机器都有自己的内存和磁盘,一台机器不能访问另一台机器的内存或磁盘(除了通过网络向服务器发出请求)。
正如在 [第二部分](/part-ii) 的介绍中所讨论的那样,我们在本书中关注的分布式系统是无共享的系统,即通过网络连接的一堆机器。网络是这些机器可以通信的唯一途径 —— 我们假设每台机器都有自己的内存和磁盘,一台机器不能访问另一台机器的内存或磁盘(除了通过网络向服务器发出请求)。
**无共享** 并不是构建系统的唯一方式,但它已经成为构建互联网服务的主要方式,其原因如下:相对便宜,因为它不需要特殊的硬件,可以利用商品化的云计算服务,通过跨多个地理分布的数据中心进行冗余可以实现高可靠性。
互联网和数据中心(通常是以太网)中的大多数内部网络都是 **异步分组网络asynchronous packet networks**。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。如果你发送请求并期待响应,则很多事情可能会出错(其中一些如 [图 8-1](img/fig8-1.png) 所示):
互联网和数据中心(通常是以太网)中的大多数内部网络都是 **异步分组网络asynchronous packet networks**。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。如果你发送请求并期待响应,则很多事情可能会出错(其中一些如 [图 8-1](/img/fig8-1.png) 所示):
1. 请求可能已经丢失(可能有人拔掉了网线)。
2. 请求可能正在排队,稍后将交付(也许网络或接收方过载)。
@ -104,7 +107,7 @@
5. 远程节点可能已经处理了请求,但是网络上的响应已经丢失(可能是网络交换机配置错误)。
6. 远程节点可能已经处理了请求,但是响应已经被延迟,并且稍后将被传递(可能是网络或者你自己的机器过载)。
![](img/fig8-1.png)
![](/img/fig8-1.png)
**图 8-1 如果发送请求并没有得到响应则无法区分a请求是否丢失b远程节点是否关闭c响应是否丢失。**
@ -122,20 +125,20 @@
> #### 网络分区
>
> 当网络的一部分由于网络故障而被切断时,有时称为 **网络分区network partition****网络断裂netsplit**。在本书中,我们通常会坚持使用更一般的术语 **网络故障network fault**,以避免与 [第六章](ch6.md) 讨论的存储系统的分区(分片)相混淆。
> 当网络的一部分由于网络故障而被切断时,有时称为 **网络分区network partition****网络断裂netsplit**。在本书中,我们通常会坚持使用更一般的术语 **网络故障network fault**,以避免与 [第六章](/ch6) 讨论的存储系统的分区(分片)相混淆。
即使网络故障在你的环境中非常罕见,故障可能发生的事实,意味着你的软件需要能够处理它们。无论何时通过网络进行通信,都可能会失败,这是无法避免的。
如果网络故障的错误处理没有定义与测试武断地讲各种错误可能都会发生例如即使网络恢复【20】集群可能会发生 **死锁**永久无法为请求提供服务甚至可能会删除所有的数据【21】。如果软件被置于意料之外的情况下它可能会做出出乎意料的事情。
处理网络故障并不意味着容忍它们:如果你的网络通常是相当可靠的,一个有效的方法可能是当你的网络遇到问题时,简单地向用户显示一条错误信息。但是,你确实需要知道你的软件如何应对网络问题,并确保系统能够从中恢复。有意识地触发网络问题并测试系统响应(这是 Chaos Monkey 背后的想法;请参阅 “[可靠性](ch1.md#可靠性)”)。
处理网络故障并不意味着容忍它们:如果你的网络通常是相当可靠的,一个有效的方法可能是当你的网络遇到问题时,简单地向用户显示一条错误信息。但是,你确实需要知道你的软件如何应对网络问题,并确保系统能够从中恢复。有意识地触发网络问题并测试系统响应(这是 Chaos Monkey 背后的想法;请参阅 “[可靠性](/ch1#可靠性)”)。
### 检测故障
许多系统需要自动检测故障节点。例如:
* 负载平衡器需要停止向已死亡的节点转发请求(从轮询列表移出,即 out of rotation
* 在单主复制功能的分布式数据库中,如果主库失效,则需要将从库之一升级为新主库(请参阅 “[处理节点宕机](ch5.md#处理节点宕机)”)。
* 在单主复制功能的分布式数据库中,如果主库失效,则需要将从库之一升级为新主库(请参阅 “[处理节点宕机](/ch5#处理节点宕机)”)。
不幸的是,网络的不确定性使得很难判断一个节点是否工作。在某些特定的情况下,你可能会收到一些反馈信息,明确告诉你某些事情没有成功:
@ -154,7 +157,7 @@
长时间的超时意味着长时间等待,直到一个节点被宣告死亡(在这段时间内,用户可能不得不等待,或者看到错误信息)。短的超时可以更快地检测到故障,但有更高地风险误将一个节点宣布为失效,而该节点实际上只是暂时地变慢了(例如由于节点或网络上的负载峰值)。
过早地声明一个节点已经死了是有问题的:如果这个节点实际上是活着的,并且正在执行一些动作(例如,发送一封电子邮件),而另一个节点接管,那么这个动作可能会最终执行两次。我们将在 “[知识、真相与谎言](#知识、真相与谎言)” 以及 [第九章](ch9.md) 和 [第十一章](ch11.md) 中更详细地讨论这个问题。
过早地声明一个节点已经死了是有问题的:如果这个节点实际上是活着的,并且正在执行一些动作(例如,发送一封电子邮件),而另一个节点接管,那么这个动作可能会最终执行两次。我们将在 “[知识、真相与谎言](#知识、真相与谎言)” 以及 [第九章](/ch9) 和 [第十一章](/ch11) 中更详细地讨论这个问题。
当一个节点被宣告死亡时,它的职责需要转移到其他节点,这会给其他节点和网络带来额外的负担。如果系统已经处于高负荷状态,则过早宣告节点死亡会使问题更严重。特别是如果节点实际上没有死亡,只是由于过载导致其响应缓慢;这时将其负载转移到其他节点可能会导致 **级联失效**(即 cascading failure表示在极端情况下所有节点都宣告对方死亡所有节点都将停止工作
@ -166,12 +169,12 @@
在驾驶汽车时由于交通拥堵道路交通网络的通行时间往往不尽相同。同样计算机网络上数据包延迟的可变性通常是由于排队【25】
* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如 [图 8-2](img/fig8-2.png) 所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络拥塞)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。
* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如 [图 8-2](/img/fig8-2.png) 所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络拥塞)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。
* 当数据包到达目标机器时,如果所有 CPU 内核当前都处于繁忙状态,则来自网络的传入请求将被操作系统排队,直到应用程序准备好处理它为止。根据机器上的负载,这可能需要一段任意的时间。
* 在虚拟化环境中,正在运行的操作系统经常暂停几十毫秒,因为另一个虚拟机正在使用 CPU 内核。在这段时间内,虚拟机不能从网络中消耗任何数据,所以传入的数据被虚拟机监视器 【26】排队缓冲进一步增加了网络延迟的可变性。
* TCP 执行 **流量控制**flow control也称为 **拥塞避免**,即 congestion avoidance**背压**,即 backpressure其中节点会限制自己的发送速率以避免网络链路或接收节点过载【27】。这意味着甚至在数据进入网络之前在发送者处就需要进行额外的排队。
![](img/fig8-2.png)
![](/img/fig8-2.png)
**图 8-2 如果有多台机器将网络流量发送到同一目的地,则其交换机队列可能会被填满。在这里,端口 1,2 和 4 都试图发送数据包到端口 3**
@ -186,7 +189,7 @@
所有这些因素都会造成网络延迟的变化。当系统接近其最大容量时,排队延迟的变化范围特别大:拥有足够备用容量的系统可以轻松排空队列,而在高利用率的系统中,很快就能积累很长的队列。
在公共云和多租户数据中心中,资源被许多客户共享:网络链接和交换机,甚至每个机器的网卡和 CPU在虚拟机上运行时。批处理工作负载如 MapReduce请参阅 [第十章](ch10.md)能够很容易使网络链接饱和。由于无法控制或了解其他客户对共享资源的使用情况如果附近的某个人嘈杂的邻居正在使用大量资源则网络延迟可能会发生剧烈变化【28,29】。
在公共云和多租户数据中心中,资源被许多客户共享:网络链接和交换机,甚至每个机器的网卡和 CPU在虚拟机上运行时。批处理工作负载如 MapReduce请参阅 [第十章](/ch10)能够很容易使网络链接饱和。由于无法控制或了解其他客户对共享资源的使用情况如果附近的某个人嘈杂的邻居正在使用大量资源则网络延迟可能会发生剧烈变化【28,29】。
在这种环境下,你只能通过实验方式选择超时:在一段较长的时期内、在多台机器上测量网络往返时间的分布,以确定延迟的预期变化。然后,考虑到应用程序的特性,可以确定 **故障检测延迟****过早超时风险** 之间的适当折衷。
@ -250,7 +253,7 @@
7. 这个缓存条目何时到期?
8. 日志文件中此错误消息的时间戳是什么?
[例 1-4](ch1.md) 测量了 **持续时间**durations例如请求发送与响应接收之间的时间间隔而 [例 5-8](ch5.md) 描述了 **时间点**point in time在特定日期和和特定时间发生的事件
[例 1-4](/ch1) 测量了 **持续时间**durations例如请求发送与响应接收之间的时间间隔而 [例 5-8](/ch5) 描述了 **时间点**point in time在特定日期和和特定时间发生的事件
在分布式系统中,时间是一件棘手的事情,因为通信不是即时的:消息通过网络从一台机器传送到另一台机器需要时间。收到消息的时间总是晚于发送的时间,但是由于网络中的可变延迟,我们不知道晚了多少时间。这个事实导致有时很难确定在涉及多台机器时发生事情的顺序。
@ -313,27 +316,27 @@
让我们考虑一个特别的情况,一件很有诱惑但也很危险的事情:依赖时钟,在多个节点上对事件进行排序。例如,如果两个客户端写入分布式数据库,谁先到达? 哪一个更近?
[图 8-3](img/fig8-3.png) 显示了在具有多主复制的数据库中对时钟的危险使用(该例子类似于 [图 5-9](img/fig5-9.png))。客户端 A 在节点 1 上写入 `x = 1`;写入被复制到节点 3客户端 B 在节点 3 上增加 x我们现在有 `x = 2`);最后这两个写入都被复制到节点 2。
[图 8-3](/img/fig8-3.png) 显示了在具有多主复制的数据库中对时钟的危险使用(该例子类似于 [图 5-9](/img/fig5-9.png))。客户端 A 在节点 1 上写入 `x = 1`;写入被复制到节点 3客户端 B 在节点 3 上增加 x我们现在有 `x = 2`);最后这两个写入都被复制到节点 2。
![](img/fig8-3.png)
![](/img/fig8-3.png)
**图 8-3 客户端 B 的写入比客户端 A 的写入要晚,但是 B 的写入具有较早的时间戳。**
在 [图 8-3](img/fig8-3.png) 中,当一个写入被复制到其他节点时,它会根据发生写入的节点上的日历时钟标记一个时间戳。在这个例子中,时钟同步是非常好的:节点 1 和节点 3 之间的偏差小于 3ms这可能比你在实践中能预期的更好。
在 [图 8-3](/img/fig8-3.png) 中,当一个写入被复制到其他节点时,它会根据发生写入的节点上的日历时钟标记一个时间戳。在这个例子中,时钟同步是非常好的:节点 1 和节点 3 之间的偏差小于 3ms这可能比你在实践中能预期的更好。
尽管如此,[图 8-3](img/fig8-3.png) 中的时间戳却无法正确排列事件:写入 `x = 1` 的时间戳为 42.004 秒,但写入 `x = 2` 的时间戳为 42.003 秒,即使 `x = 2` 在稍后出现。当节点 2 接收到这两个事件时,会错误地推断出 `x = 1` 是最近的值,而丢弃写入 `x = 2`。效果上表现为,客户端 B 的增量操作会丢失。
尽管如此,[图 8-3](/img/fig8-3.png) 中的时间戳却无法正确排列事件:写入 `x = 1` 的时间戳为 42.004 秒,但写入 `x = 2` 的时间戳为 42.003 秒,即使 `x = 2` 在稍后出现。当节点 2 接收到这两个事件时,会错误地推断出 `x = 1` 是最近的值,而丢弃写入 `x = 2`。效果上表现为,客户端 B 的增量操作会丢失。
这种冲突解决策略被称为 **最后写入胜利LWW**,它在多主复制和无主数据库(如 Cassandra 【53】和 Riak 【54】中被广泛使用请参阅 “[最后写入胜利(丢弃并发写入)](ch5.md#最后写入胜利(丢弃并发写入))” 一节)。有些实现会在客户端而不是服务器上生成时间戳,但这并不能改变 LWW 的基本问题:
这种冲突解决策略被称为 **最后写入胜利LWW**,它在多主复制和无主数据库(如 Cassandra 【53】和 Riak 【54】中被广泛使用请参阅 “[最后写入胜利(丢弃并发写入)](/ch5#最后写入胜利(丢弃并发写入))” 一节)。有些实现会在客户端而不是服务器上生成时间戳,但这并不能改变 LWW 的基本问题:
* 数据库写入可能会神秘地消失具有滞后时钟的节点无法覆盖之前具有快速时钟的节点写入的值直到节点之间的时钟偏差消逝【54,55】。此方案可能导致一定数量的数据被悄悄丢弃而未向应用报告任何错误。
* LWW 无法区分 **高频顺序写入**(在 [图 8-3](img/fig8-3.png) 中,客户端 B 的增量操作 **一定** 发生在客户端 A 的写入之后)和 **真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止违背因果关系(请参阅 “[检测并发写入](ch5.md#检测并发写入)”)。
* LWW 无法区分 **高频顺序写入**(在 [图 8-3](/img/fig8-3.png) 中,客户端 B 的增量操作 **一定** 发生在客户端 A 的写入之后)和 **真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止违背因果关系(请参阅 “[检测并发写入](/ch5#检测并发写入)”)。
* 两个节点很可能独立地生成具有相同时间戳的写入,特别是在时钟仅具有毫秒分辨率的情况下。为了解决这样的冲突,还需要一个额外的 **决胜值**tiebreaker可以简单地是一个大随机数但这种方法也可能会导致违背因果关系【53】。
因此,尽管通过保留 “最近” 的值并放弃其他值来解决冲突是很诱惑人的,但是要注意,“最近” 的定义取决于本地的 **日历时钟**,这很可能是不正确的。即使用严格同步的 NTP 时钟,一个数据包也可能在时间戳 100 毫秒(根据发送者的时钟)时发送,并在时间戳 99 毫秒(根据接收者的时钟)处到达 —— 看起来好像数据包在发送之前已经到达,这是不可能的。
NTP 同步是否能足够准确,以至于这种不正确的排序不会发生?也许不能,因为 NTP 的同步精度本身,除了石英钟漂移这类误差源之外,还受到网络往返时间的限制。为了进行正确的排序,你需要一个比测量对象(即网络延迟)要精确得多的时钟。
所谓的 **逻辑时钟logic clock**【56,57】是基于递增计数器而不是振荡石英晶体对于排序事件来说是更安全的选择请参阅 “[检测并发写入](ch5.md#检测并发写入)”)。逻辑时钟不测量一天中的时间或经过的秒数,而仅测量事件的相对顺序(无论一个事件发生在另一个事件之前还是之后)。相反,用来测量实际经过时间的 **日历时钟****单调钟** 也被称为 **物理时钟physical clock**。我们将在 “[顺序保证](ch9.md#顺序保证)” 中来看顺序问题。
所谓的 **逻辑时钟logic clock**【56,57】是基于递增计数器而不是振荡石英晶体对于排序事件来说是更安全的选择请参阅 “[检测并发写入](/ch5#检测并发写入)”)。逻辑时钟不测量一天中的时间或经过的秒数,而仅测量事件的相对顺序(无论一个事件发生在另一个事件之前还是之后)。相反,用来测量实际经过时间的 **日历时钟****单调钟** 也被称为 **物理时钟physical clock**。我们将在 “[顺序保证](/ch9#顺序保证)” 中来看顺序问题。
#### 时钟读数存在置信区间
@ -349,13 +352,13 @@ NTP 同步是否能足够准确,以至于这种不正确的排序不会发生
#### 全局快照的同步时钟
在 “[快照隔离和可重复读](ch7.md#快照隔离和可重复读)” 中,我们讨论了快照隔离,这是数据库中非常有用的功能,需要支持小型快速读写事务和大型长时间运行的只读事务(用于备份或分析)。它允许只读事务看到特定时间点的处于一致状态的数据库,且不会锁定和干扰读写事务。
在 “[快照隔离和可重复读](/ch7#快照隔离和可重复读)” 中,我们讨论了快照隔离,这是数据库中非常有用的功能,需要支持小型快速读写事务和大型长时间运行的只读事务(用于备份或分析)。它允许只读事务看到特定时间点的处于一致状态的数据库,且不会锁定和干扰读写事务。
快照隔离最常见的实现需要单调递增的事务 ID。如果写入比快照晚写入具有比快照更大的事务 ID则该写入对于快照事务是不可见的。在单节点数据库上一个简单的计数器就足以生成事务 ID。
但是当数据库分布在许多机器上,也许可能在多个数据中心中时,由于需要协调,(跨所有分区)全局单调递增的事务 ID 会很难生成。事务 ID 必须反映因果关系:如果事务 B 读取由事务 A 写入的值,则 B 必须具有比 A 更大的事务 ID否则快照就无法保持一致。在有大量的小规模、高频率的事务情景下在分布式系统中创建事务 ID 成为一个难以处理的瓶颈 [^vi]。
[^vi]: 存在分布式序列号生成器,例如 Twitter 的雪花Snowflake其以可伸缩的方式例如通过将 ID 空间的块分配给不同节点)近似单调地增加唯一 ID。但是它们通常无法保证与因果关系一致的排序因为分配的 ID 块的时间范围比数据库读取和写入的时间范围要长。另请参阅 “[顺序保证](ch9.md#顺序保证)”。
[^vi]: 存在分布式序列号生成器,例如 Twitter 的雪花Snowflake其以可伸缩的方式例如通过将 ID 空间的块分配给不同节点)近似单调地增加唯一 ID。但是它们通常无法保证与因果关系一致的排序因为分配的 ID 块的时间范围比数据库读取和写入的时间范围要长。另请参阅 “[顺序保证](/ch9#顺序保证)”。
我们可以使用同步时钟的时间戳作为事务 ID 吗?如果我们能够获得足够好的同步性,那么这种方法将具有很合适的属性:更晚的事务会有更大的时间戳。当然,问题在于时钟精度的不确定性。
@ -419,7 +422,7 @@ while (true) {
> #### 实时是真的吗?
>
> 在嵌入式系统中,实时是指系统经过精心设计和测试,以满足所有情况下的特定时间保证。这个含义与 Web 上对实时术语的模糊使用相反,后者描述了服务器将数据推送到客户端以及没有严格的响应时间限制的流处理(见 [第十一章](ch11.md))。
> 在嵌入式系统中,实时是指系统经过精心设计和测试,以满足所有情况下的特定时间保证。这个含义与 Web 上对实时术语的模糊使用相反,后者描述了服务器将数据推送到客户端以及没有严格的响应时间限制的流处理(见 [第十一章](/ch11))。
例如,如果车载传感器检测到当前正在经历碰撞,你肯定不希望安全气囊释放系统因为 GC 暂停而延迟弹出。
@ -435,7 +438,7 @@ while (true) {
一个新兴的想法是将 GC 暂停视为一个节点的短暂计划中断,并在这个节点收集其垃圾的同时,让其他节点处理来自客户端的请求。如果运行时可以警告应用程序一个节点很快需要 GC 暂停,那么应用程序可以停止向该节点发送新的请求,等待它完成处理未完成的请求,然后在没有请求正在进行时执行 GC。这个技巧向客户端隐藏了 GC 暂停并降低了响应时间的高百分比【70,71】。一些对延迟敏感的金融交易系统【72】使用这种方法。
这个想法的一个变种是只用垃圾收集器来处理短命对象(这些对象可以快速收集),并定期在积累大量长寿对象(因此需要完整 GC之前重新启动进程【65,73】。一次可以重新启动一个节点在计划重新启动之前流量可以从该节点移开就像 [第四章](ch4.md) 里描述的滚动升级一样。
这个想法的一个变种是只用垃圾收集器来处理短命对象(这些对象可以快速收集),并定期在积累大量长寿对象(因此需要完整 GC之前重新启动进程【65,73】。一次可以重新启动一个节点在计划重新启动之前流量可以从该节点移开就像 [第四章](/ch4) 里描述的滚动升级一样。
这些措施不能完全阻止垃圾回收暂停,但可以有效地减少它们对应用的影响。
@ -450,7 +453,7 @@ while (true) {
幸运的是,我们不需要去搞清楚生命的意义。在分布式系统中,我们可以陈述关于行为(系统模型)的假设,并以满足这些假设的方式设计实际系统。算法可以被证明在某个系统模型中正确运行。这意味着即使底层系统模型提供了很少的保证,也可以实现可靠的行为。
但是,尽管可以使软件在不可靠的系统模型中表现良好,但这并不是可以直截了当实现的。在本章的其余部分中,我们将进一步探讨分布式系统中的知识和真相的概念,这将有助于我们思考我们可以做出的各种假设以及我们可能希望提供的保证。在 [第九章](ch9.md) 中,我们将着眼于分布式系统的一些例子,这些算法在特定的假设条件下提供了特定的保证。
但是,尽管可以使软件在不可靠的系统模型中表现良好,但这并不是可以直截了当实现的。在本章的其余部分中,我们将进一步探讨分布式系统中的知识和真相的概念,这将有助于我们思考我们可以做出的各种假设以及我们可能希望提供的保证。在 [第九章](/ch9) 中,我们将着眼于分布式系统的一些例子,这些算法在特定的假设条件下提供了特定的保证。
### 真相由多数所定义
@ -460,17 +463,17 @@ while (true) {
第三种情况,想象一个正在经历长时间 **垃圾收集暂停stop-the-world GC Pause** 的节点,节点的所有线程被 GC 抢占并暂停一分钟因此没有请求被处理也没有响应被发送。其他节点等待重试不耐烦并最终宣布节点死亡并将其丢到灵车上。最后GC 完成节点的线程继续好像什么也没有发生。其他节点感到惊讶因为所谓的死亡节点突然从棺材中抬起头来身体健康开始和旁观者高兴地聊天。GC 后的节点最初甚至没有意识到已经经过了整整一分钟,而且自己已被宣告死亡。从它自己的角度来看,从最后一次与其他节点交谈以来,几乎没有经过任何时间。
这些故事的寓意是,节点不一定能相信自己对于情况的判断。分布式系统不能完全依赖单个节点,因为节点可能随时失效,可能会使系统卡死,无法恢复。相反,许多分布式算法都依赖于法定人数,即在节点之间进行投票(请参阅 “[读写的法定人数](ch5.md#读写的法定人数)”):决策需要来自多个节点的最小投票数,以减少对于某个特定节点的依赖。
这些故事的寓意是,节点不一定能相信自己对于情况的判断。分布式系统不能完全依赖单个节点,因为节点可能随时失效,可能会使系统卡死,无法恢复。相反,许多分布式算法都依赖于法定人数,即在节点之间进行投票(请参阅 “[读写的法定人数](/ch5#读写的法定人数)”):决策需要来自多个节点的最小投票数,以减少对于某个特定节点的依赖。
这也包括关于宣告节点死亡的决定。如果法定数量的节点宣告另一个节点已经死亡,那么即使该节点仍感觉自己活着,它也必须被认为是死的。个体节点必须遵守法定决定并下台。
最常见的法定人数是超过一半的绝对多数(尽管其他类型的法定人数也是可能的)。多数法定人数允许系统继续工作,如果单个节点发生故障(三个节点可以容忍单节点故障;五个节点可以容忍双节点故障)。系统仍然是安全的,因为在这个制度中只能有一个多数 —— 不能同时存在两个相互冲突的多数决定。当我们在 [第九章](ch9.md) 中讨论 **共识算法consensus algorithms** 时,我们将更详细地讨论法定人数的应用。
最常见的法定人数是超过一半的绝对多数(尽管其他类型的法定人数也是可能的)。多数法定人数允许系统继续工作,如果单个节点发生故障(三个节点可以容忍单节点故障;五个节点可以容忍双节点故障)。系统仍然是安全的,因为在这个制度中只能有一个多数 —— 不能同时存在两个相互冲突的多数决定。当我们在 [第九章](/ch9) 中讨论 **共识算法consensus algorithms** 时,我们将更详细地讨论法定人数的应用。
#### 领导者和锁
通常情况下,一些东西在一个系统中只能有一个。例如:
* 数据库分区的领导者只能有一个节点,以避免 **脑裂**(即 split brain请参阅 “[处理节点宕机](ch5.md#处理节点宕机)”)。
* 数据库分区的领导者只能有一个节点,以避免 **脑裂**(即 split brain请参阅 “[处理节点宕机](/ch5#处理节点宕机)”)。
* 特定资源的锁或对象只允许一个事务 / 客户端持有,以防同时写入和损坏。
* 一个特定的用户名只能被一个用户所注册,因为用户名必须唯一标识一个用户。
@ -478,9 +481,9 @@ while (true) {
如果一个节点继续表现为 **天选者**,即使大多数节点已经声明它已经死了,则在考虑不周的系统中可能会导致问题。这样的节点能以自己赋予的权能向其他节点发送消息,如果其他节点相信,整个系统可能会做一些不正确的事情。
例如,[图 8-4](img/fig8-4.png) 显示了由于不正确的锁实现导致的数据损坏错误。这个错误不仅仅是理论上的HBase 曾经有这个问题【74,75】假设你要确保一个存储服务中的文件一次只能被一个客户访问因为如果多个客户试图对此写入该文件将被损坏。你尝试通过在访问文件之前要求客户端从锁定服务获取租约来实现此目的。
例如,[图 8-4](/img/fig8-4.png) 显示了由于不正确的锁实现导致的数据损坏错误。这个错误不仅仅是理论上的HBase 曾经有这个问题【74,75】假设你要确保一个存储服务中的文件一次只能被一个客户访问因为如果多个客户试图对此写入该文件将被损坏。你尝试通过在访问文件之前要求客户端从锁定服务获取租约来实现此目的。
![](img/fig8-4.png)
![](/img/fig8-4.png)
**图 8-4 分布式锁的实现不正确:客户端 1 认为它仍然具有有效的租约,即使它已经过期,从而破坏了存储中的文件**
@ -488,15 +491,15 @@ while (true) {
#### 防护令牌
当使用锁或租约来保护对某些资源(如 [图 8-4](img/fig8-4.png) 中的文件存储)的访问时,需要确保一个被误认为自己是 “天选者” 的节点不能扰乱系统的其它部分。实现这一目标的一个相当简单的技术就是 **防护fencing**,如 [图 8-5](img/fig8-5.png) 所示
当使用锁或租约来保护对某些资源(如 [图 8-4](/img/fig8-4.png) 中的文件存储)的访问时,需要确保一个被误认为自己是 “天选者” 的节点不能扰乱系统的其它部分。实现这一目标的一个相当简单的技术就是 **防护fencing**,如 [图 8-5](/img/fig8-5.png) 所示
![](img/fig8-5.png)
![](/img/fig8-5.png)
**图 8-5 只允许以增加防护令牌的顺序进行写操作,从而保证存储安全**
我们假设每次锁定服务器授予锁或租约时,它还会返回一个 **防护令牌fencing token**,这个数字在每次授予锁定时都会增加(例如,由锁定服务增加)。然后,我们可以要求客户端每次向存储服务发送写入请求时,都必须包含当前的防护令牌。
在 [图 8-5](img/fig8-5.png) 中,客户端 1 以 33 的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端 2 以 34 的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括 34 的令牌。稍后,客户端 1 恢复生机并将其写入存储服务,包括其令牌值 33。但是存储服务器会记住它已经处理了一个具有更高令牌编号34的写入因此它会拒绝带有令牌 33 的请求。
在 [图 8-5](/img/fig8-5.png) 中,客户端 1 以 33 的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端 2 以 34 的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括 34 的令牌。稍后,客户端 1 恢复生机并将其写入存储服务,包括其令牌值 33。但是存储服务器会记住它已经处理了一个具有更高令牌编号34的写入因此它会拒绝带有令牌 33 的请求。
如果将 ZooKeeper 用作锁定服务,则可将事务标识 `zxid` 或节点版本 `cversion` 用作防护令牌。由于它们保证单调递增因此它们具有所需的属性【74】。
@ -514,7 +517,7 @@ while (true) {
> ### 拜占庭将军问题
>
> 拜占庭将军问题是对所谓 “两将军问题” 的泛化【78】它想象两个将军需要就战斗计划达成一致的情况。由于他们在两个不同的地点建立了营地他们只能通过信使进行沟通信使有时会被延迟或丢失就像网络中的信息包一样。我们将在 [第九章](ch9.md) 讨论这个共识问题。
> 拜占庭将军问题是对所谓 “两将军问题” 的泛化【78】它想象两个将军需要就战斗计划达成一致的情况。由于他们在两个不同的地点建立了营地他们只能通过信使进行沟通信使有时会被延迟或丢失就像网络中的信息包一样。我们将在 [第九章](/ch9) 讨论这个共识问题。
>
> 在这个问题的拜占庭版本里,有 n 位将军需要同意,他们的努力因为有一些叛徒在他们中间而受到阻碍。大多数的将军都是忠诚的,因而发出了真实的信息,但是叛徒可能会试图通过发送虚假或不真实的信息来欺骗和混淆他人(在试图保持未被发现的同时)。事先并不知道叛徒是谁。
>
@ -543,7 +546,7 @@ Web 应用程序确实需要预期受终端用户控制的客户端(如 Web
### 系统模型与现实
已经有很多算法被设计以解决分布式系统问题 —— 例如,我们将在 [第九章](ch9.md) 讨论共识问题的解决方案。为了有用,这些算法需要容忍我们在本章中讨论的分布式系统的各种故障。
已经有很多算法被设计以解决分布式系统问题 —— 例如,我们将在 [第九章](/ch9) 讨论共识问题的解决方案。为了有用,这些算法需要容忍我们在本章中讨论的分布式系统的各种故障。
算法的编写方式不应该过分依赖于运行的硬件和软件配置的细节。这就要求我们以某种方式将我们期望在系统中发生的错误形式化。我们通过定义一个系统模型来做到这一点,这个模型是一个抽象,描述一个算法可以假设的事情。
@ -619,7 +622,7 @@ Web 应用程序确实需要预期受终端用户控制的客户端(如 Web
例如,在崩溃 - 恢复crash-recovery模型中的算法通常假设稳定存储器中的数据在崩溃后可以幸存。但是如果磁盘上的数据被破坏或者由于硬件错误或错误配置导致数据被清除会发生什么情况【91】如果服务器存在固件错误并且在重新启动时无法识别其硬盘驱动器即使驱动器已正确连接到服务器那又会发生什么情况【92】
法定人数算法(请参阅 “[读写的法定人数](ch5.md#读写的法定人数)”)依赖节点来记住它声称存储的数据。如果一个节点可能患有健忘症,忘记了以前存储的数据,这会打破法定条件,从而破坏算法的正确性。也许需要一个新的系统模型,在这个模型中,我们假设稳定的存储大多能在崩溃后幸存,但有时也可能会丢失。但是那个模型就变得更难以推理了。
法定人数算法(请参阅 “[读写的法定人数](/ch5#读写的法定人数)”)依赖节点来记住它声称存储的数据。如果一个节点可能患有健忘症,忘记了以前存储的数据,这会打破法定条件,从而破坏算法的正确性。也许需要一个新的系统模型,在这个模型中,我们假设稳定的存储大多能在崩溃后幸存,但有时也可能会丢失。但是那个模型就变得更难以推理了。
算法的理论描述可以简单宣称一些事是不会发生的 —— 在非拜占庭式系统中,我们确实需要对可能发生和不可能发生的故障做出假设。然而,真实世界的实现,仍然会包括处理 “假设上不可能” 情况的代码,即使代码可能就是 `printf("Sucks to be you")``exit(666)`实际上也就是留给运维来擦屁股【93】。这可以说是计算机科学和软件工程间的一个差异
@ -644,13 +647,13 @@ Web 应用程序确实需要预期受终端用户控制的客户端(如 Web
如果你习惯于在理想化的数学完美的单机环境同一个操作总能确定地返回相同的结果中编写软件那么转向分布式系统的凌乱的物理现实可能会有些令人震惊。相反如果能够在单台计算机上解决一个问题那么分布式系统工程师通常会认为这个问题是平凡的【5】现在单个计算机确实可以做很多事情【95】。如果你可以避免打开潘多拉的盒子把东西放在一台机器上那么通常是值得的。
但是,正如在 [第二部分](part-ii.md) 的介绍中所讨论的那样,可伸缩性并不是使用分布式系统的唯一原因。容错和低延迟(通过将数据放置在距离用户较近的地方)是同等重要的目标,而这些不能用单个节点实现。
但是,正如在 [第二部分](/part-ii) 的介绍中所讨论的那样,可伸缩性并不是使用分布式系统的唯一原因。容错和低延迟(通过将数据放置在距离用户较近的地方)是同等重要的目标,而这些不能用单个节点实现。
在本章中,我们也转换了几次话题,探讨了网络、时钟和进程的不可靠性是否是不可避免的自然规律。我们看到这并不是:有可能给网络提供硬实时的响应保证和有限的延迟,但是这样做非常昂贵,且导致硬件资源的利用率降低。大多数非安全关键系统会选择 **便宜而不可靠**,而不是 **昂贵和可靠**
我们还谈到了超级计算机,它们采用可靠的组件,因此当组件发生故障时必须完全停止并重新启动。相比之下,分布式系统可以永久运行而不会在服务层面中断,因为所有的错误和维护都可以在节点级别进行处理 —— 至少在理论上是如此。(实际上,如果一个错误的配置变更被应用到所有的节点,仍然会使分布式系统瘫痪)。
本章一直在讲存在的问题,给我们展现了一幅黯淡的前景。在 [下一章](ch9.md) 中,我们将继续讨论解决方案,并讨论一些旨在解决分布式系统中所有问题的算法。
本章一直在讲存在的问题,给我们展现了一幅黯淡的前景。在 [下一章](/ch9) 中,我们将继续讨论解决方案,并讨论一些旨在解决分布式系统中所有问题的算法。
## 参考文献
@ -752,11 +755,3 @@ Web 应用程序确实需要预期受终端用户控制的客户端(如 Web
1. Frank McSherry, Michael Isard, and Derek G. Murray: “[Scalability! But at What COST?](http://www.frankmcsherry.org/assets/COST.pdf),” at *15th USENIX Workshop on Hot Topics in Operating Systems* (HotOS), May 2015.
[^译著1]: 原诗为Hey I just met you. The networks laggy. But heres my data. So store it maybe.Hey, 应改编自《Call Me Maybe》歌词I just met you, And this is crazy, But here's my number, So call me, maybe?
------
| 上一章 | 目录 | 下一章 |
| ---------------------- | ------------------------------- | ------------------------------ |
| [第七章:事务](ch7.md) | [设计数据密集型应用](README.md) | [第九章:一致性与共识](ch9.md) |

View File

@ -1,23 +1,26 @@
# 第九章:一致性与共识
---
title: "第九章:一致性与共识"
linkTitle: "9. 一致性与共识"
weight: 209
breadcrumbs: false
---
![](img/ch9.png)
![](/img/ch9.png)
> 好死还是赖活着?
> —— Jay Kreps, 关于 Kafka 与 Jepsen 的若干笔记 (2013)
---------------
[TOC]
正如 [第八章](/ch8) 所讨论的,分布式系统中的许多事情可能会出错。处理这种故障的最简单方法是简单地让整个服务失效,并向用户显示错误消息。如果无法接受这个解决方案,我们就需要找到容错的方法 —— 即使某些内部组件出现故障,服务也能正常运行。
正如 [第八章](ch8.md) 所讨论的,分布式系统中的许多事情可能会出错。处理这种故障的最简单方法是简单地让整个服务失效,并向用户显示错误消息。如果无法接受这个解决方案,我们就需要找到容错的方法 —— 即使某些内部组件出现故障,服务也能正常运行
在本章中,我们将讨论构建容错分布式系统的算法和协议的一些例子。我们将假设 [第八章](/ch8) 的所有问题都可能发生:网络中的数据包可能会丢失、重新排序、重复推送或任意延迟;时钟只是尽其所能地近似;且节点可以暂停(例如,由于垃圾收集)或随时崩溃
在本章中,我们将讨论构建容错分布式系统的算法和协议的一些例子。我们将假设 [第八章](ch8.md) 的所有问题都可能发生:网络中的数据包可能会丢失、重新排序、重复推送或任意延迟;时钟只是尽其所能地近似;且节点可以暂停(例如,由于垃圾收集)或随时崩溃。
构建容错系统的最好方法,是找到一些带有实用保证的通用抽象,实现一次,然后让应用依赖这些保证。这与 [第七章](ch7.md) 中的事务处理方法相同:通过使用事务,应用可以假装没有崩溃(原子性),没有其他人同时访问数据库(隔离),存储设备是完全可靠的(持久性)。即使发生崩溃,竞态条件和磁盘故障,事务抽象隐藏了这些问题,因此应用不必担心它们。
构建容错系统的最好方法,是找到一些带有实用保证的通用抽象,实现一次,然后让应用依赖这些保证。这与 [第七章](/ch7) 中的事务处理方法相同:通过使用事务,应用可以假装没有崩溃(原子性),没有其他人同时访问数据库(隔离),存储设备是完全可靠的(持久性)。即使发生崩溃,竞态条件和磁盘故障,事务抽象隐藏了这些问题,因此应用不必担心它们。
现在我们将继续沿着同样的路线前进,寻求可以让应用忽略分布式系统部分问题的抽象概念。例如,分布式系统最重要的抽象之一就是 **共识consensus****就是让所有的节点对某件事达成一致**。正如我们在本章中将会看到的那样,要可靠地达成共识,且不被网络故障和进程故障所影响,是一个令人惊讶的棘手问题。
一旦达成共识,应用可以将其用于各种目的。例如,假设你有一个单主复制的数据库。如果主库挂掉,并且需要故障切换到另一个节点,剩余的数据库节点可以使用共识来选举新的领导者。正如在 “[处理节点宕机](ch5.md#处理节点宕机)” 中所讨论的那样,重要的是只有一个领导者,且所有的节点都认同其领导。如果两个节点都认为自己是领导者,这种情况被称为 **脑裂split brain**,它经常会导致数据丢失。正确实现共识有助于避免这种问题。
一旦达成共识,应用可以将其用于各种目的。例如,假设你有一个单主复制的数据库。如果主库挂掉,并且需要故障切换到另一个节点,剩余的数据库节点可以使用共识来选举新的领导者。正如在 “[处理节点宕机](/ch5#处理节点宕机)” 中所讨论的那样,重要的是只有一个领导者,且所有的节点都认同其领导。如果两个节点都认为自己是领导者,这种情况被称为 **脑裂split brain**,它经常会导致数据丢失。正确实现共识有助于避免这种问题。
在本章后面的 “[分布式事务与共识](#分布式事务与共识)” 中,我们将研究解决共识和相关问题的算法。但首先,我们首先需要探索可以在分布式系统中提供的保证和抽象的范围。
@ -28,11 +31,11 @@
## 一致性保证
在 “[复制延迟问题](ch5.md#复制延迟问题)” 中,我们看到了数据库复制中发生的一些时序问题。如果你在同一时刻查看两个数据库节点,则可能在两个节点上看到不同的数据,因为写请求在不同的时间到达不同的节点。无论数据库使用何种复制方法(单主复制、多主复制或无主复制),都会出现这些不一致情况。
在 “[复制延迟问题](/ch5#复制延迟问题)” 中,我们看到了数据库复制中发生的一些时序问题。如果你在同一时刻查看两个数据库节点,则可能在两个节点上看到不同的数据,因为写请求在不同的时间到达不同的节点。无论数据库使用何种复制方法(单主复制、多主复制或无主复制),都会出现这些不一致情况。
大多数复制的数据库至少提供了 **最终一致性**这意味着如果你停止向数据库写入数据并等待一段不确定的时间那么最终所有的读取请求都会返回相同的值【1】。换句话说不一致性是暂时的最终会自行解决假设网络中的任何故障最终都会被修复。最终一致性的一个更好的名字可能是 **收敛convergence**因为我们预计所有的副本最终会收敛到相同的值【2】。
然而,这是一个非常弱的保证 —— 它并没有说什么时候副本会收敛。在收敛之前读操作可能会返回任何东西或什么都没有【1】。例如如果你写入了一个值然后立即再次读取这并不能保证你能看到刚才写入的值因为读请求可能会被路由到另外的副本上。请参阅 “[读己之写](ch5.md#读己之写)” )。
然而,这是一个非常弱的保证 —— 它并没有说什么时候副本会收敛。在收敛之前读操作可能会返回任何东西或什么都没有【1】。例如如果你写入了一个值然后立即再次读取这并不能保证你能看到刚才写入的值因为读请求可能会被路由到另外的副本上。请参阅 “[读己之写](/ch5#读己之写)” )。
对于应用开发人员而言最终一致性是很困难的因为它与普通单线程程序中变量的行为有很大区别。对于后者如果将一个值赋给一个变量然后很快地再次读取不可能读到旧的值或者读取失败。数据库表面上看起来像一个你可以读写的变量但实际上它有更复杂的语义【3】。
@ -40,7 +43,7 @@
本章将探索数据系统可能选择提供的更强一致性模型。它不是免费的:具有较强保证的系统可能会比保证较差的系统具有更差的性能或更少的容错性。尽管如此,更强的保证能够吸引人,因为它们更容易用对。只有见过不同的一致性模型后,才能更好地决定哪一个最适合自己的需求。
**分布式一致性模型** 和我们之前讨论的事务隔离级别的层次结构有一些相似之处【4,5】请参阅 “[弱隔离级别](ch7.md#弱隔离级别)”)。尽管两者有一部分内容重叠,但它们大多是无关的问题:事务隔离主要是为了 **避免由于同时执行事务而导致的竞争状态**,而分布式一致性主要关于 **在面对延迟和故障时如何协调副本间的状态**
**分布式一致性模型** 和我们之前讨论的事务隔离级别的层次结构有一些相似之处【4,5】请参阅 “[弱隔离级别](/ch7#弱隔离级别)”)。尽管两者有一部分内容重叠,但它们大多是无关的问题:事务隔离主要是为了 **避免由于同时执行事务而导致的竞争状态**,而分布式一致性主要关于 **在面对延迟和故障时如何协调副本间的状态**
本章涵盖了广泛的话题,但我们将会看到这些领域实际上是紧密联系在一起的:
@ -57,11 +60,11 @@
在一个线性一致的系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。要维护数据的单个副本的假象,系统应保障读到的值是最近的、最新的,而不是来自陈旧的缓存或副本。换句话说,线性一致性是一个 **新鲜度保证recency guarantee**。为了阐明这个想法,我们来看看一个非线性一致系统的例子。
![](img/fig9-1.png)
![](/img/fig9-1.png)
**图 9-1 这个系统是非线性一致的,导致了球迷的困惑**
[图 9-1](img/fig9-1.png) 展示了一个关于体育网站的非线性一致例子【9】。Alice 和 Bob 正坐在同一个房间里,都盯着各自的手机,关注着 2014 年 FIFA 世界杯决赛的结果。在最后得分公布后Alice 刷新页面,看到宣布了获胜者,并兴奋地告诉 Bob。Bob 难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。
[图 9-1](/img/fig9-1.png) 展示了一个关于体育网站的非线性一致例子【9】。Alice 和 Bob 正坐在同一个房间里,都盯着各自的手机,关注着 2014 年 FIFA 世界杯决赛的结果。在最后得分公布后Alice 刷新页面,看到宣布了获胜者,并兴奋地告诉 Bob。Bob 难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。
如果 Alice 和 Bob 在同一时间刷新并获得了两个不同的查询结果,也许就没有那么令人惊讶了。因为他们不知道服务器处理他们请求的精确时刻。然而 Bob 是在听到 Alice 惊呼最后得分 **之后**,点击了刷新按钮(启动了他的查询),因此他希望查询结果至少与爱丽丝一样新鲜。但他的查询返回了陈旧结果,这一事实违背了线性一致性的要求。
@ -69,22 +72,22 @@
线性一致性背后的基本思想很简单:使系统看起来好像只有一个数据副本。然而确切来讲,实际上有更多要操心的地方。为了更好地理解线性一致性,让我们再看几个例子。
[图 9-2](img/fig9-2.png) 显示了三个客户端在线性一致数据库中同时读写相同的键 `x`。在分布式系统文献中,`x` 被称为 **寄存器register**,例如,它可以是键值存储中的一个 **键**,关系数据库中的一 **行**,或文档数据库中的一个 **文档**
[图 9-2](/img/fig9-2.png) 显示了三个客户端在线性一致数据库中同时读写相同的键 `x`。在分布式系统文献中,`x` 被称为 **寄存器register**,例如,它可以是键值存储中的一个 **键**,关系数据库中的一 **行**,或文档数据库中的一个 **文档**
![](img/fig9-2.png)
![](/img/fig9-2.png)
**图 9-2 如果读取请求与写入请求并发,则可能会返回旧值或新值**
为了简单起见,[图 9-2](img/fig9-2.png) 采用了用户请求的视角,而不是数据库内部的视角。每个横柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。因为网络延迟变化无常,客户端不知道数据库处理其请求的精确时间 —— 只知道它发生在发送请求和接收响应之间的某个时刻。[^i]
为了简单起见,[图 9-2](/img/fig9-2.png) 采用了用户请求的视角,而不是数据库内部的视角。每个横柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。因为网络延迟变化无常,客户端不知道数据库处理其请求的精确时间 —— 只知道它发生在发送请求和接收响应之间的某个时刻。[^i]
[^i]: 这个图的一个微妙的细节是它假定存在一个全局时钟,由水平轴表示。虽然真实的系统通常没有准确的时钟(请参阅 “[不可靠的时钟](ch8.md#不可靠的时钟)”但这种假设是允许的为了分析分布式算法我们可以假设存在一个精确的全局时钟不过算法无法访问它【47】。算法只能看到由石英振荡器和 NTP 产生的对真实时间的逼近。
[^i]: 这个图的一个微妙的细节是它假定存在一个全局时钟,由水平轴表示。虽然真实的系统通常没有准确的时钟(请参阅 “[不可靠的时钟](/ch8#不可靠的时钟)”但这种假设是允许的为了分析分布式算法我们可以假设存在一个精确的全局时钟不过算法无法访问它【47】。算法只能看到由石英振荡器和 NTP 产生的对真实时间的逼近。
在这个例子中,寄存器有两种类型的操作:
* $read(x)⇒v$表示客户端请求读取寄存器 `x` 的值,数据库返回值 `v`
* $write(x,v)⇒r$ 表示客户端请求将寄存器 `x` 设置为值 `v` ,数据库返回响应 `r` (可能正确,可能错误)。
在 [图 9-2](img/fig9-2.png) 中,`x` 的值最初为 `0`,客户端 C 执行写请求将其设置为 `1`。发生这种情况时,客户端 A 和 B 反复轮询数据库以读取最新值。A 和 B 的请求可能会收到怎样的响应?
在 [图 9-2](/img/fig9-2.png) 中,`x` 的值最初为 `0`,客户端 C 执行写请求将其设置为 `1`。发生这种情况时,客户端 A 和 B 反复轮询数据库以读取最新值。A 和 B 的请求可能会收到怎样的响应?
* 客户端 A 的第一个读操作,完成于写操作开始之前,因此必须返回旧值 `0`
* 客户端 A 的最后一个读操作,开始于写操作完成之后。如果数据库是线性一致性的,它必然返回新值 `1`:因为读操作和写操作一定是在其各自的起止区间内的某个时刻被处理。如果在写入结束后开始读取,则读取处理一定发生在写入完成之后,因此它必须看到写入的新值。
@ -94,31 +97,31 @@
[^ii]: 如果读取(与写入同时发生时)可能返回旧值或新值,则称该寄存器为 **常规寄存器regular register**【7,25】
为了使系统线性一致,我们需要添加另一个约束,如 [图 9-3](img/fig9-3.png) 所示
为了使系统线性一致,我们需要添加另一个约束,如 [图 9-3](/img/fig9-3.png) 所示
![](img/fig9-3.png)
![](/img/fig9-3.png)
**图 9-3 任何一个读取返回新值后,所有后续读取(在相同或其他客户端上)也必须返回新值。**
在一个线性一致的系统中,我们可以想象,在 `x` 的值从 `0` 自动翻转到 `1` 的时候(在写操作的开始和结束之间)必定有一个时间点。因此,如果一个客户端的读取返回新的值 `1`,即使写操作尚未完成,所有后续读取也必须返回新值。
[图 9-3](img/fig9-3.png) 中的箭头说明了这个时序依赖关系。客户端 A 是第一个读取新的值 `1` 的位置。在 A 的读取返回之后B 开始新的读取。由于 B 的读取严格发生于 A 的读取之后,因此即使 C 的写入仍在进行中,也必须返回 `1`(与 [图 9-1](img/fig9-1.png) 中的 Alice 和 Bob 的情况相同:在 Alice 读取新值之后Bob 也希望读取新的值)。
[图 9-3](/img/fig9-3.png) 中的箭头说明了这个时序依赖关系。客户端 A 是第一个读取新的值 `1` 的位置。在 A 的读取返回之后B 开始新的读取。由于 B 的读取严格发生于 A 的读取之后,因此即使 C 的写入仍在进行中,也必须返回 `1`(与 [图 9-1](/img/fig9-1.png) 中的 Alice 和 Bob 的情况相同:在 Alice 读取新值之后Bob 也希望读取新的值)。
我们可以进一步细化这个时序图,展示每个操作是如何在特定时刻原子性生效的。[图 9-4](img/fig9-4.png) 显示了一个更复杂的例子【10】。
我们可以进一步细化这个时序图,展示每个操作是如何在特定时刻原子性生效的。[图 9-4](/img/fig9-4.png) 显示了一个更复杂的例子【10】。
在 [图 9-4](img/fig9-4.png) 中,除了读写之外,还增加了第三种类型的操作:
在 [图 9-4](/img/fig9-4.png) 中,除了读写之外,还增加了第三种类型的操作:
* $cas(x, v_{old}, v_{new})⇒r$ 表示客户端请求进行原子性的 [**比较与设置**](ch7.md#比较并设置CAS) 操作。如果寄存器 $x$ 的当前值等于 $v_{old}$ ,则应该原子地设置为 $v_{new}$ 。如果 $x$ 不等于 $v_{old}$ ,则操作应该保持寄存器不变并返回一个错误。$r$ 是数据库的响应(正确或错误)。
* $cas(x, v_{old}, v_{new})⇒r$ 表示客户端请求进行原子性的 [**比较与设置**](/ch7#比较并设置CAS) 操作。如果寄存器 $x$ 的当前值等于 $v_{old}$ ,则应该原子地设置为 $v_{new}$ 。如果 $x$ 不等于 $v_{old}$ ,则操作应该保持寄存器不变并返回一个错误。$r$ 是数据库的响应(正确或错误)。
[图 9-4](img/fig9-4.png) 中的每个操作都在我们认为操作被执行的时候用竖线标出(在每个操作的横柱之内)。这些标记按顺序连在一起,其结果必须是一个有效的寄存器读写序列(**每次读取都必须返回最近一次写入设置的值**)。
[图 9-4](/img/fig9-4.png) 中的每个操作都在我们认为操作被执行的时候用竖线标出(在每个操作的横柱之内)。这些标记按顺序连在一起,其结果必须是一个有效的寄存器读写序列(**每次读取都必须返回最近一次写入设置的值**)。
线性一致性的要求是,操作标记的连线总是按时间(从左到右)向前移动,而不是向后移动。这个要求确保了我们之前讨论的新鲜度保证:一旦新的值被写入或读取,所有后续的读都会看到写入的值,直到它被再次覆盖。
![](img/fig9-4.png)
![](/img/fig9-4.png)
**图 9-4 将读取和写入看起来已经生效的时间点进行可视化。客户端 B 的最后一次读取不是线性一致的**
[图 9-4](img/fig9-4.png) 中有一些有趣的细节需要指出:
[图 9-4](/img/fig9-4.png) 中有一些有趣的细节需要指出:
* 第一个客户端 B 发送一个读取 `x` 的请求,然后客户端 D 发送一个请求将 `x` 设置为 `0`,然后客户端 A 发送请求将 `x` 设置为 `1`。然而,返回给 B 的读取值为 `1`(由 A 写入的值)。这是可以的:这意味着数据库首先处理 D 的写入,然后是 A 的写入,最后是 B 的读取。虽然这不是请求发送的顺序,但这是一个可以接受的顺序,因为这三个请求是并发的。也许 B 的读请求在网络上略有延迟,所以它在两次写入之后才到达数据库。
@ -126,26 +129,26 @@
* 此模型不假设有任何事务隔离另一个客户端可能随时更改值。例如C 首先读取到 `1` ,然后读取到 `2` ,因为两次读取之间的值被 B 所更改。可以使用原子 **比较并设置cas** 操作来检查该值是否未被另一客户端同时更改B 和 C 的 **cas** 请求成功,但是 D 的 **cas** 请求失败(在数据库处理它时,`x` 的值不再是 `0` )。
* 客户 B 的最后一次读取(阴影条柱中)不是线性一致的。该操作与 C 的 **cas** 写操作并发(它将 `x``2` 更新为 `4` 。在没有其他请求的情况下B 的读取返回 `2` 是可以的。然而,在 B 的读取开始之前,客户端 A 已经读取了新的值 `4` ,因此不允许 B 读取比 A 更旧的值。再次,与 [图 9-1](img/fig9-1.png) 中的 Alice 和 Bob 的情况相同。
* 客户 B 的最后一次读取(阴影条柱中)不是线性一致的。该操作与 C 的 **cas** 写操作并发(它将 `x``2` 更新为 `4` 。在没有其他请求的情况下B 的读取返回 `2` 是可以的。然而,在 B 的读取开始之前,客户端 A 已经读取了新的值 `4` ,因此不允许 B 读取比 A 更旧的值。再次,与 [图 9-1](/img/fig9-1.png) 中的 Alice 和 Bob 的情况相同。
这就是线性一致性背后的直觉。正式的定义【6】更准确地描述了它。通过记录所有请求和响应的时序并检查它们是否可以排列成有效的顺序以测试一个系统的行为是否线性一致性是可能的尽管在计算上是昂贵的【11】。
> ### 线性一致性与可串行化
>
> **线性一致性** 容易和 [**可串行化**](ch7.md#可串行化) 相混淆,因为两个词似乎都是类似 “可以按顺序排列” 的东西。但它们是两种完全不同的保证,区分两者非常重要:
> **线性一致性** 容易和 [**可串行化**](/ch7#可串行化) 相混淆,因为两个词似乎都是类似 “可以按顺序排列” 的东西。但它们是两种完全不同的保证,区分两者非常重要:
>
> ***可串行化***
>
> **可串行化Serializability** 是事务的隔离属性,每个事务可以读写多个对象(行,文档,记录)—— 请参阅 “[单对象和多对象操作](ch7.md#单对象和多对象操作)”。它确保事务的行为,与它们按照 **某种** 顺序依次执行的结果相同每个事务在下一个事务开始之前运行完成。这种执行顺序可以与事务实际执行的顺序不同。【12】。
> **可串行化Serializability** 是事务的隔离属性,每个事务可以读写多个对象(行,文档,记录)—— 请参阅 “[单对象和多对象操作](/ch7#单对象和多对象操作)”。它确保事务的行为,与它们按照 **某种** 顺序依次执行的结果相同每个事务在下一个事务开始之前运行完成。这种执行顺序可以与事务实际执行的顺序不同。【12】。
>
> ***线性一致性***
>
> **线性一致性Linearizability** 是读取和写入寄存器(单个对象)的 **新鲜度保证**。它不会将操作组合为事务,因此它也不会阻止写入偏差等问题(请参阅 “[写入偏差和幻读](ch7.md#写入偏差与幻读)”),除非采取其他措施(例如 [物化冲突](ch7.md#物化冲突))。
> **线性一致性Linearizability** 是读取和写入寄存器(单个对象)的 **新鲜度保证**。它不会将操作组合为事务,因此它也不会阻止写入偏差等问题(请参阅 “[写入偏差和幻读](/ch7#写入偏差与幻读)”),除非采取其他措施(例如 [物化冲突](/ch7#物化冲突))。
>
> 一个数据库可以提供可串行化和线性一致性,这种组合被称为严格的可串行化或 **强的单副本可串行化strong-1SR**【4,13】。基于两阶段锁定的可串行化实现请参阅 “[两阶段锁定](ch7.md#两阶段锁定)” 一节)或 **真的串行执行**(请参阅 “[真的串行执行](ch7.md#真的串行执行)”一节)通常是线性一致性的。
> 一个数据库可以提供可串行化和线性一致性,这种组合被称为严格的可串行化或 **强的单副本可串行化strong-1SR**【4,13】。基于两阶段锁定的可串行化实现请参阅 “[两阶段锁定](/ch7#两阶段锁定)” 一节)或 **真的串行执行**(请参阅 “[真的串行执行](/ch7#真的串行执行)”一节)通常是线性一致性的。
>
> 但是,可串行化的快照隔离(请参阅 “[可串行化快照隔离](ch7.md#可串行化快照隔离)”)不是线性一致性的:按照设计,它从一致的快照中进行读取,以避免读者和写者之间的锁竞争。一致性快照的要点就在于 **它不会包括该快照之后的写入**,因此从快照读取不是线性一致性的。
> 但是,可串行化的快照隔离(请参阅 “[可串行化快照隔离](/ch7#可串行化快照隔离)”)不是线性一致性的:按照设计,它从一致的快照中进行读取,以避免读者和写者之间的锁竞争。一致性快照的要点就在于 **它不会包括该快照之后的写入**,因此从快照读取不是线性一致性的。
### 依赖线性一致性
@ -156,7 +159,7 @@
一个使用单主复制的系统需要确保领导者真的只有一个而不是几个脑裂。一种选择领导者的方法是使用锁每个节点在启动时尝试获取锁成功者成为领导者【14】。不管这个锁是如何实现的它必须是线性一致的所有节点必须就哪个节点拥有锁达成一致否则就没用了。
诸如 Apache ZooKeeper 【15】和 etcd 【16】之类的协调服务通常用于实现分布式锁和领导者选举。它们使用一致性算法以容错的方式实现线性一致的操作在本章后面的 “[容错共识](#容错共识)” 中讨论此类算法)[^iii]。还有许多微妙的细节来正确地实现锁和领导者选举(例如,请参阅 “[领导者和锁](ch8.md#领导者和锁)” 中的防护问题),而像 Apache Curator 【17】这样的库则通过在 ZooKeeper 之上提供更高级别的配方来提供帮助。但是,线性一致性存储服务是这些协调任务的基础。
诸如 Apache ZooKeeper 【15】和 etcd 【16】之类的协调服务通常用于实现分布式锁和领导者选举。它们使用一致性算法以容错的方式实现线性一致的操作在本章后面的 “[容错共识](#容错共识)” 中讨论此类算法)[^iii]。还有许多微妙的细节来正确地实现锁和领导者选举(例如,请参阅 “[领导者和锁](/ch8#领导者和锁)” 中的防护问题),而像 Apache Curator 【17】这样的库则通过在 ZooKeeper 之上提供更高级别的配方来提供帮助。但是,线性一致性存储服务是这些协调任务的基础。
[^iii]: 严格地说ZooKeeper 和 etcd 提供线性一致性的写操作但读取可能是陈旧的因为默认情况下它们可以由任何一个副本提供服务。你可以选择请求线性一致性读取etcd 称之为 **法定人数读取quorum read**【16】而在 ZooKeeper 中,你需要在读取之前调用 `sync()`【15】。请参阅 “[使用全序广播实现线性一致的存储](#使用全序广播实现线性一致的存储)”。
@ -170,27 +173,27 @@
如果想要确保银行账户余额永远不会为负数,或者不会出售比仓库里的库存更多的物品,或者两个人不会都预定了航班或剧院里同一时间的同一个位置。这些约束条件都要求所有节点都同意一个最新的值(账户余额,库存水平,座位占用率)。
在实际应用中,宽松地处理这些限制有时是可以接受的(例如,如果航班超额预订,你可以将客户转移到不同的航班并为其提供补偿)。在这种情况下,可能不需要线性一致性,我们将在 “[及时性与完整性](ch12.md#及时性与完整性)” 中讨论这种宽松的约束。
在实际应用中,宽松地处理这些限制有时是可以接受的(例如,如果航班超额预订,你可以将客户转移到不同的航班并为其提供补偿)。在这种情况下,可能不需要线性一致性,我们将在 “[及时性与完整性](/ch12#及时性与完整性)” 中讨论这种宽松的约束。
然而一个硬性的唯一性约束关系型数据库中常见的那种需要线性一致性。其他类型的约束如外键或属性约束可以不需要线性一致性【19】。
#### 跨信道的时序依赖
注意 [图 9-1](img/fig9-1.png) 中的一个细节:如果 Alice 没有惊呼得分Bob 就不会知道他的查询结果是陈旧的。他会在几秒钟之后再次刷新页面并最终看到最后的分数。由于系统中存在额外的信道Alice 的声音传到了 Bob 的耳朵中),线性一致性的违背才被注意到。
注意 [图 9-1](/img/fig9-1.png) 中的一个细节:如果 Alice 没有惊呼得分Bob 就不会知道他的查询结果是陈旧的。他会在几秒钟之后再次刷新页面并最终看到最后的分数。由于系统中存在额外的信道Alice 的声音传到了 Bob 的耳朵中),线性一致性的违背才被注意到。
计算机系统也会出现类似的情况。例如,假设有一个网站,用户可以上传照片,一个后台进程会调整照片大小,降低分辨率以加快下载速度(缩略图)。该系统的架构和数据流如 [图 9-5](img/fig9-5.png) 所示。
计算机系统也会出现类似的情况。例如,假设有一个网站,用户可以上传照片,一个后台进程会调整照片大小,降低分辨率以加快下载速度(缩略图)。该系统的架构和数据流如 [图 9-5](/img/fig9-5.png) 所示。
图像缩放器需要明确的指令来执行尺寸缩放作业,指令是 Web 服务器通过消息队列发送的(请参阅 [第十一章](ch11.md)。Web 服务器不会将整个照片放在队列中,因为大多数消息代理都是针对较短的消息而设计的,而一张照片的空间占用可能达到几兆字节。取而代之的是,首先将照片写入文件存储服务,写入完成后再将给缩放器的指令放入消息队列。
图像缩放器需要明确的指令来执行尺寸缩放作业,指令是 Web 服务器通过消息队列发送的(请参阅 [第十一章](/ch11)。Web 服务器不会将整个照片放在队列中,因为大多数消息代理都是针对较短的消息而设计的,而一张照片的空间占用可能达到几兆字节。取而代之的是,首先将照片写入文件存储服务,写入完成后再将给缩放器的指令放入消息队列。
![](img/fig9-5.png)
![](/img/fig9-5.png)
**图 9-5 Web 服务器和图像缩放器通过文件存储和消息队列进行通信,打开竞争条件的可能性。**
如果文件存储服务是线性一致的,那么这个系统应该可以正常工作。如果它不是线性一致的,则存在竞争条件的风险:消息队列([图 9-5](img/fig9-5.png) 中的步骤 3 和 4可能比存储服务内部的复制replication更快。在这种情况下当缩放器读取图像步骤 5可能会看到图像的旧版本或者什么都没有。如果它处理的是旧版本的图像则文件存储中的全尺寸图和缩略图就产生了永久性的不一致。
如果文件存储服务是线性一致的,那么这个系统应该可以正常工作。如果它不是线性一致的,则存在竞争条件的风险:消息队列([图 9-5](/img/fig9-5.png) 中的步骤 3 和 4可能比存储服务内部的复制replication更快。在这种情况下当缩放器读取图像步骤 5可能会看到图像的旧版本或者什么都没有。如果它处理的是旧版本的图像则文件存储中的全尺寸图和缩略图就产生了永久性的不一致。
出现这个问题是因为 Web 服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性一致性的新鲜性保证,这两个信道之间的竞争条件是可能的。这种情况类似于 [图 9-1](img/fig9-1.png),数据库复制与 Alice 的嘴到 Bob 耳朵之间的真人音频信道之间也存在竞争条件。
出现这个问题是因为 Web 服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性一致性的新鲜性保证,这两个信道之间的竞争条件是可能的。这种情况类似于 [图 9-1](/img/fig9-1.png),数据库复制与 Alice 的嘴到 Bob 耳朵之间的真人音频信道之间也存在竞争条件。
线性一致性并不是避免这种竞争条件的唯一方法,但它是最容易理解的。如果你可以控制额外信道(例如消息队列的例子,而不是在 Alice 和 Bob 的例子),则可以使用在 “[读己之写](ch5.md#读己之写)” 讨论过的类似方法,不过会有额外的复杂度代价。
线性一致性并不是避免这种竞争条件的唯一方法,但它是最容易理解的。如果你可以控制额外信道(例如消息队列的例子,而不是在 Alice 和 Bob 的例子),则可以使用在 “[读己之写](/ch5#读己之写)” 讨论过的类似方法,不过会有额外的复杂度代价。
### 实现线性一致的系统
@ -198,15 +201,15 @@
由于线性一致性本质上意味着 “表现得好像只有一个数据副本,而且所有的操作都是原子的”,所以最简单的答案就是,真的只用一个数据副本。但是这种方法无法容错:如果持有该副本的节点失效,数据将会丢失,或者至少无法访问,直到节点重新启动。
使系统容错最常用的方法是使用复制。我们再来回顾 [第五章](ch5.md) 中的复制方法,并比较它们是否可以满足线性一致性:
使系统容错最常用的方法是使用复制。我们再来回顾 [第五章](/ch5) 中的复制方法,并比较它们是否可以满足线性一致性:
* 单主复制(可能线性一致)
在具有单主复制功能的系统中(请参阅 “[领导者与追随者](ch5.md#领导者与追随者)”),主库具有用于写入的数据的主副本,而追随者在其他节点上保留数据的备份副本。如果从主库或同步更新的从库读取数据,它们 **可能potential** 是线性一致性的 [^iv]。然而实际上并不是每个单主数据库都是线性一致性的无论是因为设计的原因例如因为使用了快照隔离还是因为在并发处理上存在错误【10】。
在具有单主复制功能的系统中(请参阅 “[领导者与追随者](/ch5#领导者与追随者)”),主库具有用于写入的数据的主副本,而追随者在其他节点上保留数据的备份副本。如果从主库或同步更新的从库读取数据,它们 **可能potential** 是线性一致性的 [^iv]。然而实际上并不是每个单主数据库都是线性一致性的无论是因为设计的原因例如因为使用了快照隔离还是因为在并发处理上存在错误【10】。
[^iv]: 对单主数据库进行分区(分片),使得每个分区有一个单独的领导者,不会影响线性一致性,因为线性一致性只是对单一对象的保证。交叉分区事务是一个不同的问题(请参阅 “[分布式事务与共识](#分布式事务与共识)”)。
从主库读取依赖一个假设,你确切地知道领导者是谁。正如在 “[真相由多数所定义](ch8.md#真相由多数所定义)” 中所讨论的那样,一个节点很可能会认为它是领导者,而事实上并非如此 —— 如果具有错觉的领导者继续为请求提供服务可能违反线性一致性【20】。使用异步复制故障切换时甚至可能会丢失已提交的写入请参阅 “[处理节点宕机](ch5.md#处理节点宕机)”),这同时违反了持久性和线性一致性。
从主库读取依赖一个假设,你确切地知道领导者是谁。正如在 “[真相由多数所定义](/ch8#真相由多数所定义)” 中所讨论的那样,一个节点很可能会认为它是领导者,而事实上并非如此 —— 如果具有错觉的领导者继续为请求提供服务可能违反线性一致性【20】。使用异步复制故障切换时甚至可能会丢失已提交的写入请参阅 “[处理节点宕机](/ch5#处理节点宕机)”),这同时违反了持久性和线性一致性。
* 共识算法(线性一致)
@ -214,27 +217,27 @@
* 多主复制(非线性一致)
具有多主程序复制的系统通常不是线性一致的,因为它们同时在多个节点上处理写入,并将其异步复制到其他节点。因此,它们可能会产生需要被解决的写入冲突(请参阅 “[处理写入冲突](ch5.md#处理写入冲突)”)。这种冲突是因为缺少单一数据副本所导致的。
具有多主程序复制的系统通常不是线性一致的,因为它们同时在多个节点上处理写入,并将其异步复制到其他节点。因此,它们可能会产生需要被解决的写入冲突(请参阅 “[处理写入冲突](/ch5#处理写入冲突)”)。这种冲突是因为缺少单一数据副本所导致的。
* 无主复制(也许不是线性一致的)
对于无主复制的系统Dynamo 风格;请参阅 “[无主复制](ch5.md#无主复制)”),有时候人们会声称通过要求法定人数读写( $w + r > n$ )可以获得 “强一致性”。这取决于法定人数的具体配置,以及强一致性如何定义(通常不完全正确)。
对于无主复制的系统Dynamo 风格;请参阅 “[无主复制](/ch5#无主复制)”),有时候人们会声称通过要求法定人数读写( $w + r > n$ )可以获得 “强一致性”。这取决于法定人数的具体配置,以及强一致性如何定义(通常不完全正确)。
基于日历时钟(例如,在 Cassandra 中;请参阅 “[依赖同步时钟](ch8.md#依赖同步时钟)”)的 “最后写入胜利” 冲突解决方法几乎可以确定是非线性一致的,由于时钟偏差,不能保证时钟的时间戳与实际事件顺序一致。宽松的法定人数(请参阅 “[宽松的法定人数与提示移交](ch5.md#宽松的法定人数与提示移交)”)也破坏了线性一致的可能性。即使使用严格的法定人数,非线性一致的行为也是可能的,如下节所示。
基于日历时钟(例如,在 Cassandra 中;请参阅 “[依赖同步时钟](/ch8#依赖同步时钟)”)的 “最后写入胜利” 冲突解决方法几乎可以确定是非线性一致的,由于时钟偏差,不能保证时钟的时间戳与实际事件顺序一致。宽松的法定人数(请参阅 “[宽松的法定人数与提示移交](/ch5#宽松的法定人数与提示移交)”)也破坏了线性一致的可能性。即使使用严格的法定人数,非线性一致的行为也是可能的,如下节所示。
#### 线性一致性和法定人数
直觉上在 Dynamo 风格的模型中,严格的法定人数读写应该是线性一致性的。但是当我们有可变的网络延迟时,就可能存在竞争条件,如 [图 9-6](img/fig9-6.png) 所示。
直觉上在 Dynamo 风格的模型中,严格的法定人数读写应该是线性一致性的。但是当我们有可变的网络延迟时,就可能存在竞争条件,如 [图 9-6](/img/fig9-6.png) 所示。
![](img/fig9-6.png)
![](/img/fig9-6.png)
**图 9-6 非线性一致的执行,尽管使用了严格的法定人数**
在 [图 9-6](img/fig9-6.png) 中,$x$ 的初始值为 0写入客户端通过向所有三个副本 $n = 3, w = 3$ )发送写入将 $x$ 更新为 `1`。客户端 A 并发地从两个节点组成的法定人群( $r = 2$ )中读取数据,并在其中一个节点上看到新值 `1` 。客户端 B 也并发地从两个不同的节点组成的法定人数中读取,并从两个节点中取回了旧值 `0`
在 [图 9-6](/img/fig9-6.png) 中,$x$ 的初始值为 0写入客户端通过向所有三个副本 $n = 3, w = 3$ )发送写入将 $x$ 更新为 `1`。客户端 A 并发地从两个节点组成的法定人群( $r = 2$ )中读取数据,并在其中一个节点上看到新值 `1` 。客户端 B 也并发地从两个不同的节点组成的法定人数中读取,并从两个节点中取回了旧值 `0`
法定人数条件满足( $w + r> n$ 但是这个执行是非线性一致的B 的请求在 A 的请求完成后开始,但是 B 返回旧值,而 A 返回新值。(又一次,如同 Alice 和 Bob 的例子 [图 9-1](img/fig9-1.png)
法定人数条件满足( $w + r> n$ 但是这个执行是非线性一致的B 的请求在 A 的请求完成后开始,但是 B 返回旧值,而 A 返回新值。(又一次,如同 Alice 和 Bob 的例子 [图 9-1](/img/fig9-1.png)
有趣的是,通过牺牲性能,可以使 Dynamo 风格的法定人数线性化:读取者必须在将结果返回给应用之前,同步执行读修复(请参阅 “[读修复和反熵](ch5.md#读修复和反熵)”) 并且写入者必须在发送写入之前读取法定数量节点的最新状态【24,25】。然而由于性能损失Riak 不执行同步读修复【26】。Cassandra 在进行法定人数读取时,**确实** 在等待读修复完成【27】但是由于使用了最后写入胜利的冲突解决方案当同一个键有多个并发写入时将不能保证线性一致性。
有趣的是,通过牺牲性能,可以使 Dynamo 风格的法定人数线性化:读取者必须在将结果返回给应用之前,同步执行读修复(请参阅 “[读修复和反熵](/ch5#读修复和反熵)”) 并且写入者必须在发送写入之前读取法定数量节点的最新状态【24,25】。然而由于性能损失Riak 不执行同步读修复【26】。Cassandra 在进行法定人数读取时,**确实** 在等待读修复完成【27】但是由于使用了最后写入胜利的冲突解决方案当同一个键有多个并发写入时将不能保证线性一致性。
而且这种方式只能实现线性一致的读写不能实现线性一致的比较和设置CAS操作因为它需要一个共识算法【28】。
@ -245,9 +248,9 @@
一些复制方法可以提供线性一致性,另一些复制方法则不能,因此深入地探讨线性一致性的优缺点是很有趣的。
我们已经在 [第五章](ch5.md) 中讨论了不同复制方法的一些用例。例如对多数据中心的复制而言,多主复制通常是理想的选择(请参阅 “[运维多个数据中心](ch5.md#运维多个数据中心)”)。[图 9-7](img/fig9-7.png) 说明了这种部署的一个例子。
我们已经在 [第五章](/ch5) 中讨论了不同复制方法的一些用例。例如对多数据中心的复制而言,多主复制通常是理想的选择(请参阅 “[运维多个数据中心](/ch5#运维多个数据中心)”)。[图 9-7](/img/fig9-7.png) 说明了这种部署的一个例子。
![](img/fig9-7.png)
![](/img/fig9-7.png)
**图 9-7 网络中断迫使在线性一致性和可用性之间做出选择。**
@ -286,7 +289,7 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
在分布式系统中有更多有趣的 “不可能” 的结果【41】且 CAP 定理现在已经被更精确的结果取代【2,42】所以它现在基本上成了历史古迹了。
[^vi]: 正如 “[真实世界的网络故障](ch8.md#真实世界的网络故障)” 中所讨论的,本书使用 **分区partition** 指代将大数据集细分为小数据集的操作(分片;请参阅 [第六章](ch6.md))。与之对应的是,**网络分区network partition** 是一种特定类型的网络故障,我们通常不会将其与其他类型的故障分开考虑。但是,由于它是 CAP 的 P所以这种情况下我们无法避免混乱。
[^vi]: 正如 “[真实世界的网络故障](/ch8#真实世界的网络故障)” 中所讨论的,本书使用 **分区partition** 指代将大数据集细分为小数据集的操作(分片;请参阅 [第六章](/ch6))。与之对应的是,**网络分区network partition** 是一种特定类型的网络故障,我们通常不会将其与其他类型的故障分开考虑。但是,由于它是 CAP 的 P所以这种情况下我们无法避免混乱。
#### 线性一致性和网络延迟
@ -298,18 +301,18 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
许多分布式数据库也是如此:它们是 **为了提高性能** 而选择了牺牲线性一致性而不是为了容错【46】。线性一致的速度很慢 —— 这始终是事实,而不仅仅是网络故障期间。
能找到一个更高效的线性一致存储实现吗看起来答案是否定的Attiya 和 Welch 【47】证明如果你想要线性一致性读写请求的响应时间至少与网络延迟的不确定性成正比。在像大多数计算机网络一样具有高度可变延迟的网络中请参阅 “[超时与无穷的延迟](ch8.md#超时与无穷的延迟)”),线性读写的响应时间不可避免地会很高。更快地线性一致算法不存在,但更弱的一致性模型可以快得多,所以对延迟敏感的系统而言,这类权衡非常重要。在 [第十二章](ch12.md) 中将讨论一些在不牺牲正确性的前提下,绕开线性一致性的方法。
能找到一个更高效的线性一致存储实现吗看起来答案是否定的Attiya 和 Welch 【47】证明如果你想要线性一致性读写请求的响应时间至少与网络延迟的不确定性成正比。在像大多数计算机网络一样具有高度可变延迟的网络中请参阅 “[超时与无穷的延迟](/ch8#超时与无穷的延迟)”),线性读写的响应时间不可避免地会很高。更快地线性一致算法不存在,但更弱的一致性模型可以快得多,所以对延迟敏感的系统而言,这类权衡非常重要。在 [第十二章](/ch12) 中将讨论一些在不牺牲正确性的前提下,绕开线性一致性的方法。
## 顺序保证
之前说过,线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的方式生效的。这个定义意味着操作是按照某种良好定义的顺序执行的。我们将操作以看上去被执行的顺序连接起来,以此说明了 [图 9-4](img/fig9-4.png) 中的顺序。
之前说过,线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的方式生效的。这个定义意味着操作是按照某种良好定义的顺序执行的。我们将操作以看上去被执行的顺序连接起来,以此说明了 [图 9-4](/img/fig9-4.png) 中的顺序。
**顺序ordering** 这一主题在本书中反复出现,这表明它可能是一个重要的基础性概念。让我们简要回顾一下其它曾经出现过 **顺序** 的上下文:
* 在 [第五章](ch5.md) 中我们看到,领导者在单主复制中的主要目的就是,在复制日志中确定 **写入顺序order of write**—— 也就是从库应用这些写入的顺序。如果不存在一个领导者,则并发操作可能导致冲突(请参阅 “[处理写入冲突](ch5.md#处理写入冲突)”)。
* 在 [第七章](ch7.md) 中讨论的 **可串行化**,是关于事务表现的像按 **某种先后顺序some sequential order** 执行的保证。它可以字面意义上地以 **串行顺序serial order** 执行事务来实现,或者允许并行执行,但同时防止序列化冲突来实现(通过锁或中止事务)。
* 在 [第八章](ch8.md) 讨论过的在分布式系统中使用时间戳和时钟(请参阅 “[依赖同步时钟](ch8.md#依赖同步时钟)”)是另一种将顺序引入无序世界的尝试,例如,确定两个写入操作哪一个更晚发生。
* 在 [第五章](/ch5) 中我们看到,领导者在单主复制中的主要目的就是,在复制日志中确定 **写入顺序order of write**—— 也就是从库应用这些写入的顺序。如果不存在一个领导者,则并发操作可能导致冲突(请参阅 “[处理写入冲突](/ch5#处理写入冲突)”)。
* 在 [第七章](/ch7) 中讨论的 **可串行化**,是关于事务表现的像按 **某种先后顺序some sequential order** 执行的保证。它可以字面意义上地以 **串行顺序serial order** 执行事务来实现,或者允许并行执行,但同时防止序列化冲突来实现(通过锁或中止事务)。
* 在 [第八章](/ch8) 讨论过的在分布式系统中使用时间戳和时钟(请参阅 “[依赖同步时钟](/ch8#依赖同步时钟)”)是另一种将顺序引入无序世界的尝试,例如,确定两个写入操作哪一个更晚发生。
事实证明,顺序、线性一致性和共识之间有着深刻的联系。尽管这个概念比本书其他部分更加理论化和抽象,但对于明确系统的能力范围(可以做什么和不可以做什么)而言是非常有帮助的。我们将在接下来的几节中探讨这个话题。
@ -317,12 +320,12 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
**顺序** 反复出现有几个原因,其中一个原因是,它有助于保持 **因果关系causality**。在本书中我们已经看到了几个例子,其中因果关系是很重要的:
* 在 “[一致前缀读](ch5.md#一致前缀读)”([图 5-5](img/fig5-5.png))中,我们看到一个例子:一个对话的观察者首先看到问题的答案,然后才看到被回答的问题。这是令人困惑的,因为它违背了我们对 **因cause****果effect** 的直觉:如果一个问题被回答,显然问题本身得先在那里,因为给出答案的人必须先看到这个问题(假如他们并没有预见未来的超能力)。我们认为在问题和答案之间存在 **因果依赖causal dependency**
* [图 5-9](img/fig5-9.png) 中出现了类似的模式,我们看到三位领导者之间的复制,并注意到由于网络延迟,一些写入可能会 “压倒” 其他写入。从其中一个副本的角度来看,好像有一个对尚不存在的记录的更新操作。这里的因果意味着,一条记录必须先被创建,然后才能被更新。
* 在 “[检测并发写入](ch5.md#检测并发写入)” 中我们观察到,如果有两个操作 A 和 B则存在三种可能性A 发生在 B 之前,或 B 发生在 A 之前,或者 A 和 B**并发**。这种 **此前发生happened before** 关系是因果关系的另一种表述:如果 A 在 B 前发生,那么意味着 B 可能已经知道了 A或者建立在 A 的基础上,或者依赖于 A。如果 A 和 B 是 **并发** 的,那么它们之间并没有因果联系;换句话说,我们确信 A 和 B 不知道彼此。
* 在事务快照隔离的上下文中(“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”),我们说事务是从一致性快照中读取的。但此语境中 “一致” 到底又是什么意思?这意味着 **与因果关系保持一致consistent with causality**如果快照包含答案它也必须包含被回答的问题【48】。在某个时间点观察整个数据库与因果关系保持一致意味着因果上在该时间点之前发生的所有操作其影响都是可见的但因果上在该时间点之后发生的操作其影响对观察者不可见。**读偏差read skew** 意味着读取的数据处于违反因果关系的状态(不可重复读,如 [图 7-6](img/fig7-6.png) 所示)。
* 事务之间 **写偏差write skew** 的例子(请参阅 “[写入偏差与幻读](ch7.md#写入偏差与幻读)”)也说明了因果依赖:在 [图 7-8](img/fig7-8.png) 中,爱丽丝被允许离班,因为事务认为鲍勃仍在值班,反之亦然。在这种情况下,离班的动作因果依赖于对当前值班情况的观察。[可串行化快照隔离](ch7.md#可串行化快照隔离) 通过跟踪事务之间的因果依赖来检测写偏差。
* 在爱丽丝和鲍勃看球的例子中([图 9-1](img/fig9-1.png)),在听到爱丽丝惊呼比赛结果后,鲍勃从服务器得到陈旧结果的事实违背了因果关系:爱丽丝的惊呼因果依赖于得分宣告,所以鲍勃应该也能在听到爱丽斯惊呼后查询到比分。相同的模式在 “[跨信道的时序依赖](#跨信道的时序依赖)” 一节中,以 “图像大小调整服务” 的伪装再次出现。
* 在 “[一致前缀读](/ch5#一致前缀读)”([图 5-5](/img/fig5-5.png))中,我们看到一个例子:一个对话的观察者首先看到问题的答案,然后才看到被回答的问题。这是令人困惑的,因为它违背了我们对 **因cause****果effect** 的直觉:如果一个问题被回答,显然问题本身得先在那里,因为给出答案的人必须先看到这个问题(假如他们并没有预见未来的超能力)。我们认为在问题和答案之间存在 **因果依赖causal dependency**
* [图 5-9](/img/fig5-9.png) 中出现了类似的模式,我们看到三位领导者之间的复制,并注意到由于网络延迟,一些写入可能会 “压倒” 其他写入。从其中一个副本的角度来看,好像有一个对尚不存在的记录的更新操作。这里的因果意味着,一条记录必须先被创建,然后才能被更新。
* 在 “[检测并发写入](/ch5#检测并发写入)” 中我们观察到,如果有两个操作 A 和 B则存在三种可能性A 发生在 B 之前,或 B 发生在 A 之前,或者 A 和 B**并发**。这种 **此前发生happened before** 关系是因果关系的另一种表述:如果 A 在 B 前发生,那么意味着 B 可能已经知道了 A或者建立在 A 的基础上,或者依赖于 A。如果 A 和 B 是 **并发** 的,那么它们之间并没有因果联系;换句话说,我们确信 A 和 B 不知道彼此。
* 在事务快照隔离的上下文中(“[快照隔离和可重复读](/ch7#快照隔离和可重复读)”),我们说事务是从一致性快照中读取的。但此语境中 “一致” 到底又是什么意思?这意味着 **与因果关系保持一致consistent with causality**如果快照包含答案它也必须包含被回答的问题【48】。在某个时间点观察整个数据库与因果关系保持一致意味着因果上在该时间点之前发生的所有操作其影响都是可见的但因果上在该时间点之后发生的操作其影响对观察者不可见。**读偏差read skew** 意味着读取的数据处于违反因果关系的状态(不可重复读,如 [图 7-6](/img/fig7-6.png) 所示)。
* 事务之间 **写偏差write skew** 的例子(请参阅 “[写入偏差与幻读](/ch7#写入偏差与幻读)”)也说明了因果依赖:在 [图 7-8](/img/fig7-8.png) 中,爱丽丝被允许离班,因为事务认为鲍勃仍在值班,反之亦然。在这种情况下,离班的动作因果依赖于对当前值班情况的观察。[可串行化快照隔离](/ch7#可串行化快照隔离) 通过跟踪事务之间的因果依赖来检测写偏差。
* 在爱丽丝和鲍勃看球的例子中([图 9-1](/img/fig9-1.png)),在听到爱丽丝惊呼比赛结果后,鲍勃从服务器得到陈旧结果的事实违背了因果关系:爱丽丝的惊呼因果依赖于得分宣告,所以鲍勃应该也能在听到爱丽斯惊呼后查询到比分。相同的模式在 “[跨信道的时序依赖](#跨信道的时序依赖)” 一节中,以 “图像大小调整服务” 的伪装再次出现。
因果关系对事件施加了一种 **顺序**:因在果之前;消息发送在消息收取之前。而且就像现实生活中一样,一件事会导致另一件事:某个节点读取了一些数据然后写入一些结果,另一个节点读取其写入的内容,并依次写入一些其他内容,等等。这些因果依赖的操作链定义了系统中的因果顺序,即,什么在什么之前发生。
@ -341,21 +344,21 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
* 线性一致性
在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。这个全序在 [图 9-4](img/fig9-4.png) 中以时间线表示。
在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。这个全序在 [图 9-4](/img/fig9-4.png) 中以时间线表示。
* 因果性
我们说过,如果两个操作都没有在彼此 **之前发生**,那么这两个操作是并发的(请参阅 [“此前发生” 的关系和并发](ch5.md#“此前发生”的关系和并发))。换句话说,如果两个事件是因果相关的(一个发生在另一个事件之前),则它们之间是有序的,但如果它们是并发的,则它们之间的顺序是无法比较的。这意味着因果关系定义了一个偏序,而不是一个全序:一些操作相互之间是有顺序的,但有些则是无法比较的。
我们说过,如果两个操作都没有在彼此 **之前发生**,那么这两个操作是并发的(请参阅 [“此前发生” 的关系和并发](/ch5#“此前发生”的关系和并发))。换句话说,如果两个事件是因果相关的(一个发生在另一个事件之前),则它们之间是有序的,但如果它们是并发的,则它们之间的顺序是无法比较的。这意味着因果关系定义了一个偏序,而不是一个全序:一些操作相互之间是有顺序的,但有些则是无法比较的。
因此,根据这个定义,在线性一致的数据存储中是不存在并发操作的:必须有且仅有一条时间线,所有的操作都在这条时间线上,构成一个全序关系。可能有几个请求在等待处理,但是数据存储确保了每个请求都是在唯一时间线上的某个时间点自动处理的,不存在任何并发。
并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的(即并发操作)。在 [第五章](ch5.md) 中我们看到了这种现象:例如,[图 5-14](img/fig5-14.png) 并不是一条直线的全序关系,而是一堆不同的操作并发进行。图中的箭头指明了因果依赖 —— 操作的偏序。
并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的(即并发操作)。在 [第五章](/ch5) 中我们看到了这种现象:例如,[图 5-14](/img/fig5-14.png) 并不是一条直线的全序关系,而是一堆不同的操作并发进行。图中的箭头指明了因果依赖 —— 操作的偏序。
如果你熟悉像 Git 这样的分布式版本控制系统,那么其版本历史与因果关系图极其相似。通常,一个 **提交Commit** 发生在另一个提交之后,在一条直线上。但是有时你会遇到分支(当多个人同时在一个项目上工作时),**合并Merge** 会在这些并发创建的提交相融合时创建。
#### 线性一致性强于因果一致性
那么因果顺序和线性一致性之间的关系是什么?答案是线性一致性 **隐含着implies** 因果关系任何线性一致的系统都能正确保持因果性【7】。特别是如果系统中有多个通信通道如 [图 9-5](img/fig9-5.png) 中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。
那么因果顺序和线性一致性之间的关系是什么?答案是线性一致性 **隐含着implies** 因果关系任何线性一致的系统都能正确保持因果性【7】。特别是如果系统中有多个通信通道如 [图 9-5](/img/fig9-5.png) 中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。
线性一致性确保因果性的事实使线性一致系统变得简单易懂,更有吸引力。然而,正如 “[线性一致性的代价](#线性一致性的代价)” 中所讨论的,使系统线性一致可能会损害其性能和可用性,尤其是在系统具有严重的网络延迟的情况下(例如,如果系统在地理上散布)。出于这个原因,一些分布式数据系统已经放弃了线性一致性,从而获得更好的性能,但它们用起来也更为困难。
@ -373,16 +376,16 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
为了确定因果依赖,我们需要一些方法来描述系统中节点的 “知识”。如果节点在发出写入 Y 的请求时已经看到了 X 的值,则 X 和 Y 可能存在因果关系。这个分析使用了那些在欺诈指控刑事调查中常见的问题CEO 在做出决定 Y 时是否 **知道** X
用于确定 *哪些操作发生在其他操作之前* 的技术,与我们在 “[检测并发写入](ch5.md#检测并发写入)” 中所讨论的内容类似。那一节讨论了无领导者数据存储中的因果性为了防止丢失更新我们需要检测到对同一个键的并发写入。因果一致性则更进一步它需要跟踪整个数据库中的因果依赖而不仅仅是一个键。可以推广版本向量以解决此类问题【54】。
用于确定 *哪些操作发生在其他操作之前* 的技术,与我们在 “[检测并发写入](/ch5#检测并发写入)” 中所讨论的内容类似。那一节讨论了无领导者数据存储中的因果性为了防止丢失更新我们需要检测到对同一个键的并发写入。因果一致性则更进一步它需要跟踪整个数据库中的因果依赖而不仅仅是一个键。可以推广版本向量以解决此类问题【54】。
为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。这就是为什么在 [图 5-13](img/fig5-13.png) 中,来自先前操作的版本号在写入时被传回到数据库的原因。在 SSI 的冲突检测中会出现类似的想法,如 “[可串行化快照隔离](ch7.md#可串行化快照隔离)” 中所述:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。
为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。这就是为什么在 [图 5-13](/img/fig5-13.png) 中,来自先前操作的版本号在写入时被传回到数据库的原因。在 SSI 的冲突检测中会出现类似的想法,如 “[可串行化快照隔离](/ch7#可串行化快照隔离)” 中所述:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。
### 序列号顺序
虽然因果是一个重要的理论概念,但实际上跟踪所有的因果关系是不切实际的。在许多应用中,客户端在写入内容之前会先读取大量数据,我们无法弄清写入因果依赖于先前全部的读取内容,还是仅包括其中一部分。显式跟踪所有已读数据意味着巨大的额外开销。
但还有一个更好的方法:我们可以使用 **序列号sequence number****时间戳timestamp** 来排序事件。时间戳不一定来自日历时钟(或物理时钟,它们存在许多问题,如 “[不可靠的时钟](ch8.md#不可靠的时钟)” 中所述)。它可以来自一个 **逻辑时钟logical clock**,这是一个用来生成标识操作的数字序列的算法,典型实现是使用一个每次操作自增的计数器。
但还有一个更好的方法:我们可以使用 **序列号sequence number****时间戳timestamp** 来排序事件。时间戳不一定来自日历时钟(或物理时钟,它们存在许多问题,如 “[不可靠的时钟](/ch8#不可靠的时钟)” 中所述)。它可以来自一个 **逻辑时钟logical clock**,这是一个用来生成标识操作的数字序列的算法,典型实现是使用一个每次操作自增的计数器。
这样的序列号或时间戳是紧凑的(只有几个字节大小),它提供了一个全序关系:也就是说每个操作都有一个唯一的序列号,而且总是可以比较两个序列号,确定哪一个更大(即哪些操作后发生)。
@ -390,14 +393,14 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
[^vii]: 与因果关系不一致的全序很容易创建,但没啥用。例如你可以为每个操作生成随机的 UUID并按照字典序比较 UUID以定义操作的全序。这是一个有效的全序但是随机的 UUID 并不能告诉你哪个操作先发生,或者操作是否为并发的。
在单主复制的数据库中(请参阅 “[领导者与追随者](ch5.md#领导者与追随者)”),复制日志定义了与因果一致的写操作。主库可以简单地为每个操作自增一个计数器,从而为复制日志中的每个操作分配一个单调递增的序列号。如果一个从库按照它们在复制日志中出现的顺序来应用写操作,那么从库的状态始终是因果一致的(即使它落后于领导者)。
在单主复制的数据库中(请参阅 “[领导者与追随者](/ch5#领导者与追随者)”),复制日志定义了与因果一致的写操作。主库可以简单地为每个操作自增一个计数器,从而为复制日志中的每个操作分配一个单调递增的序列号。如果一个从库按照它们在复制日志中出现的顺序来应用写操作,那么从库的状态始终是因果一致的(即使它落后于领导者)。
#### 非因果序列号生成器
如果主库不存在(可能因为使用了多主数据库或无主数据库,或者因为使用了分区的数据库),如何为操作生成序列号就没有那么明显了。在实践中有各种各样的方法:
* 每个节点都可以生成自己独立的一组序列号。例如有两个节点,一个节点只能生成奇数,而另一个节点只能生成偶数。通常,可以在序列号的二进制表示中预留一些位,用于唯一的节点标识符,这样可以确保两个不同的节点永远不会生成相同的序列号。
*可以将日历时钟物理时钟的时间戳附加到每个操作上【55】。这种时间戳并不连续但是如果它具有足够高的分辨率那也许足以提供一个操作的全序关系。这一事实应用于* 最后写入胜利 * 的冲突解决方法中(请参阅 “[有序事件的时间戳](ch8.md#有序事件的时间戳)”)。
*可以将日历时钟物理时钟的时间戳附加到每个操作上【55】。这种时间戳并不连续但是如果它具有足够高的分辨率那也许足以提供一个操作的全序关系。这一事实应用于* 最后写入胜利 * 的冲突解决方法中(请参阅 “[有序事件的时间戳](/ch8#有序事件的时间戳)”)。
* 可以预先分配序列号区块。例如,节点 A 可能要求从序列号 1 到 1,000 区块的所有权,而节点 B 可能要求序列号 1,001 到 2,000 区块的所有权。然后每个节点可以独立分配所属区块中的序列号,并在序列号告急时请求分配一个新的区块。
这三个选项都比单一主库的自增计数器表现要好,并且更具可伸缩性。它们为每个操作生成一个唯一的,近似自增的序列号。然而它们都有同一个问题:生成的序列号与因果不一致。
@ -406,9 +409,9 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
* 每个节点每秒可以处理不同数量的操作。因此,如果一个节点产生偶数序列号而另一个产生奇数序列号,则偶数计数器可能落后于奇数计数器,反之亦然。如果你有一个奇数编号的操作和一个偶数编号的操作,你无法准确地说出哪一个操作在因果上先发生。
* 来自物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果不一致。例如 [图 8-3](img/fig8-3.png) 展示了一个例子,其中因果上晚发生的操作,却被分配了一个更早的时间戳。[^viii]
* 来自物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果不一致。例如 [图 8-3](/img/fig8-3.png) 展示了一个例子,其中因果上晚发生的操作,却被分配了一个更早的时间戳。[^viii]
[^viii]: 可以使物理时钟时间戳与因果关系保持一致:在 “[全局快照的同步时钟](ch8.md#全局快照的同步时钟)” 中,我们讨论了 Google 的 Spanner它可以估计预期的时钟偏差并在提交写入之前等待不确定性间隔。这种方法确保了实际上靠后的事务会有更大的时间戳。但是大多数时钟不能提供这种所需的不确定性度量。
[^viii]: 可以使物理时钟时间戳与因果关系保持一致:在 “[全局快照的同步时钟](/ch8#全局快照的同步时钟)” 中,我们讨论了 Google 的 Spanner它可以估计预期的时钟偏差并在提交写入之前等待不确定性间隔。这种方法确保了实际上靠后的事务会有更大的时间戳。但是大多数时钟不能提供这种所需的不确定性度量。
* 在分配区块的情况下,某个操作可能会被赋予一个范围在 1,001 到 2,000 内的序列号,然而一个因果上更晚的操作可能被赋予一个范围在 1 到 1,000 之间的数字。这里序列号与因果关系也是不一致的。
@ -417,9 +420,9 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
尽管刚才描述的三个序列号生成器与因果不一致但实际上有一个简单的方法来产生与因果关系一致的序列号。它被称为兰伯特时间戳莱斯利・兰伯特Leslie Lamport于 1978 年提出【56】现在是分布式系统领域中被引用最多的论文之一。
[图 9-8](img/fig9-8.png) 说明了兰伯特时间戳的应用。每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。兰伯特时间戳就是两者的简单组合:(计数器,节点 ID$(counter, node ID)$。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点 ID每个时间戳都是唯一的。
[图 9-8](/img/fig9-8.png) 说明了兰伯特时间戳的应用。每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。兰伯特时间戳就是两者的简单组合:(计数器,节点 ID$(counter, node ID)$。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点 ID每个时间戳都是唯一的。
![](img/fig9-8.png)
![](/img/fig9-8.png)
**图 9-8 Lamport 时间戳提供了与因果关系一致的全序。**
@ -428,17 +431,17 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
迄今,这个描述与上节所述的奇偶计数器基本类似。使兰伯特时间戳因果一致的关键思想如下所示:每个节点和每个客户端跟踪迄今为止所见到的最大 **计数器** 值,并在每个请求中包含这个最大计数器值。当一个节点收到最大计数器值大于自身计数器值的请求或响应时,它立即将自己的计数器设置为这个最大值。
这如 [图 9-8](img/fig9-8.png) 所示,其中客户端 A 从节点 2 接收计数器值 `5` ,然后将最大值 `5` 发送到节点 1 。此时,节点 1 的计数器仅为 `1` ,但是它立即前移至 `5` ,所以下一个操作的计数器的值为 `6`
这如 [图 9-8](/img/fig9-8.png) 所示,其中客户端 A 从节点 2 接收计数器值 `5` ,然后将最大值 `5` 发送到节点 1 。此时,节点 1 的计数器仅为 `1` ,但是它立即前移至 `5` ,所以下一个操作的计数器的值为 `6`
只要每一个操作都携带着最大计数器值,这个方案确保兰伯特时间戳的排序与因果一致,因为每个因果依赖都会导致时间戳增长。
兰伯特时间戳有时会与我们在 “[检测并发写入](ch5.md#检测并发写入)” 中看到的版本向量相混淆。虽然两者有一些相似之处,但它们有着不同的目的:版本向量可以区分两个操作是并发的,还是一个因果依赖另一个;而兰伯特时间戳总是施行一个全序。从兰伯特时间戳的全序中,你无法分辨两个操作是并发的还是因果依赖的。兰伯特时间戳优于版本向量的地方是,它更加紧凑。
兰伯特时间戳有时会与我们在 “[检测并发写入](/ch5#检测并发写入)” 中看到的版本向量相混淆。虽然两者有一些相似之处,但它们有着不同的目的:版本向量可以区分两个操作是并发的,还是一个因果依赖另一个;而兰伯特时间戳总是施行一个全序。从兰伯特时间戳的全序中,你无法分辨两个操作是并发的还是因果依赖的。兰伯特时间戳优于版本向量的地方是,它更加紧凑。
#### 光有时间戳排序还不够
虽然兰伯特时间戳定义了一个与因果一致的全序,但它还不足以解决分布式系统中的许多常见问题。
例如,考虑一个需要确保用户名能唯一标识用户帐户的系统。如果两个用户同时尝试使用相同的用户名创建帐户,则其中一个应该成功,另一个应该失败(我们之前在 “[领导者和锁](ch8.md#领导者和锁)” 中提到过这个问题)。
例如,考虑一个需要确保用户名能唯一标识用户帐户的系统。如果两个用户同时尝试使用相同的用户名创建帐户,则其中一个应该成功,另一个应该失败(我们之前在 “[领导者和锁](/ch8#领导者和锁)” 中提到过这个问题)。
乍看之下,似乎操作的全序关系足以解决这一问题(例如使用兰伯特时间戳):如果创建了两个具有相同用户名的帐户,选择时间戳较小的那个作为胜者(第一个抓到用户名的人),并让带有更大时间戳者失败。由于时间戳上有全序关系,所以这个比较总是可行的。
@ -456,7 +459,7 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
如果你的程序只运行在单个 CPU 核上,那么定义一个操作全序是很容易的:可以简单认为就是 CPU 执行这些操作的顺序。但是在分布式系统中,让所有节点对同一个全局操作顺序达成一致可能相当棘手。在上一节中,我们讨论了按时间戳或序列号进行排序,但发现它还不如单主复制给力(如果你使用时间戳排序来实现唯一性约束,就不能容忍任何错误,因为你必须要从每个节点都获取到最新的序列号)。
如前所述,单主复制通过选择一个节点作为主库来确定操作的全序,并在主库的单个 CPU 核上对所有操作进行排序。接下来的挑战是,如果吞吐量超出单个主库的处理能力,这种情况下如何扩展系统;以及,如果主库失效(“[处理节点宕机](ch5.md#处理节点宕机)”),如何处理故障切换。在分布式系统文献中,这个问题被称为 **全序广播total order broadcast****原子广播atomic broadcast**[^ix]【25,57,58】。
如前所述,单主复制通过选择一个节点作为主库来确定操作的全序,并在主库的单个 CPU 核上对所有操作进行排序。接下来的挑战是,如果吞吐量超出单个主库的处理能力,这种情况下如何扩展系统;以及,如果主库失效(“[处理节点宕机](/ch5#处理节点宕机)”),如何处理故障切换。在分布式系统文献中,这个问题被称为 **全序广播total order broadcast****原子广播atomic broadcast**[^ix]【25,57,58】。
[^ix]: “原子广播” 是一个传统的术语,非常混乱,而且与 “原子” 一词的其他用法不一致:它与 ACID 事务中的原子性没有任何关系,只是与原子操作(在多线程编程的意义上 或原子寄存器线性一致存储有间接的联系。全序组播total order multicast是另一个同义词。
@ -480,19 +483,19 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
像 ZooKeeper 和 etcd 这样的共识服务实际上实现了全序广播。这一事实暗示了全序广播与共识之间有着紧密联系,我们将在本章稍后进行探讨。
全序广播正是数据库复制所需的:如果每个消息都代表一次数据库的写入,且每个副本都按相同的顺序处理相同的写入,那么副本间将相互保持一致(除了临时的复制延迟)。这个原理被称为 **状态机复制state machine replication**【60】我们将在 [第十一章](ch11.md) 中重新回到这个概念。
全序广播正是数据库复制所需的:如果每个消息都代表一次数据库的写入,且每个副本都按相同的顺序处理相同的写入,那么副本间将相互保持一致(除了临时的复制延迟)。这个原理被称为 **状态机复制state machine replication**【60】我们将在 [第十一章](/ch11) 中重新回到这个概念。
与之类似,可以使用全序广播来实现可串行化的事务:如 “[真的串行执行](ch7.md#真的串行执行)” 中所述如果每个消息都表示一个确定性事务以存储过程的形式来执行且每个节点都以相同的顺序处理这些消息那么数据库的分区和副本就可以相互保持一致【61】。
与之类似,可以使用全序广播来实现可串行化的事务:如 “[真的串行执行](/ch7#真的串行执行)” 中所述如果每个消息都表示一个确定性事务以存储过程的形式来执行且每个节点都以相同的顺序处理这些消息那么数据库的分区和副本就可以相互保持一致【61】。
全序广播的一个重要表现是,顺序在消息送达时被固化:如果后续的消息已经送达,节点就不允许追溯地将(先前)消息插入顺序中的较早位置。这个事实使得全序广播比时间戳排序更强。
考量全序广播的另一种方式是,这是一种创建日志的方式(如在复制日志、事务日志或预写式日志中):传递消息就像追加写入日志。由于所有节点必须以相同的顺序传递相同的消息,因此所有节点都可以读取日志,并看到相同的消息序列。
全序广播对于实现提供防护令牌的锁服务也很有用(请参阅 “[防护令牌](ch8.md#防护令牌)”)。每个获取锁的请求都作为一条消息追加到日志末尾,并且所有的消息都按它们在日志中出现的顺序依次编号。序列号可以当成防护令牌用,因为它是单调递增的。在 ZooKeeper 中,这个序列号被称为 `zxid` 【15】。
全序广播对于实现提供防护令牌的锁服务也很有用(请参阅 “[防护令牌](/ch8#防护令牌)”)。每个获取锁的请求都作为一条消息追加到日志末尾,并且所有的消息都按它们在日志中出现的顺序依次编号。序列号可以当成防护令牌用,因为它是单调递增的。在 ZooKeeper 中,这个序列号被称为 `zxid` 【15】。
#### 使用全序广播实现线性一致的存储
如 [图 9-4](img/fig9-4.png) 所示,在线性一致的系统中,存在操作的全序。这是否意味着线性一致与全序广播一样?不尽然,但两者之间有着密切的联系 [^x]。
如 [图 9-4](/img/fig9-4.png) 所示,在线性一致的系统中,存在操作的全序。这是否意味着线性一致与全序广播一样?不尽然,但两者之间有着密切的联系 [^x]。
[^x]: 从形式上讲,线性一致读写寄存器是一个 “更容易” 的问题。全序广播等价于共识【67】而共识问题在异步的崩溃 - 停止模型【68】中没有确定性的解决方案而线性一致的读写寄存器 **可以** 在这种模型中实现【23,24,25】。然而支持诸如 **比较并设置CAS, compare-and-set**,或 **自增并返回increment-and-get** 的原子操作使它等价于共识问题【28】。因此共识问题与线性一致寄存器问题密切相关。
@ -516,7 +519,7 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
* 你可以通过在日志中追加一条消息然后读取日志直到该消息被读回才执行实际的读取操作。消息在日志中的位置因此定义了读取发生的时间点etcd 的法定人数读取有些类似这种情况【16】
* 如果日志允许以线性一致的方式获取最新日志消息的位置,则可以查询该位置,等待该位置前的所有消息都传达到你,然后执行读取。(这是 Zookeeper `sync()` 操作背后的思想【15】
* 你可以从同步更新的副本中进行读取因此可以确保结果是最新的这种技术用于链式复制chain replication【63】请参阅 “[关于复制的研究](ch5.md#关于复制的研究)”)。
* 你可以从同步更新的副本中进行读取因此可以确保结果是最新的这种技术用于链式复制chain replication【63】请参阅 “[关于复制的研究](/ch5#关于复制的研究)”)。
#### 使用线性一致性存储实现全序广播
@ -539,17 +542,17 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
**共识** 是分布式计算中最重要也是最基本的问题之一。从表面上看似乎很简单:非正式地讲,目标只是 **让几个节点达成一致get serveral nodes to agree on something**。你也许会认为这不会太难。不幸的是,许多出故障的系统都是因为错误地轻信这个问题很容易解决。
尽管共识非常重要,但关于它的内容出现在本书的后半部分,因为这个主题非常微妙,欣赏细微之处需要一些必要的知识。即使在学术界,对共识的理解也是在几十年的过程中逐渐沉淀而来,一路上也有着许多误解。现在我们已经讨论了复制([第五章](ch5.md)),事务([第七章](ch7.md)),系统模型([第八章](ch8.md)),线性一致以及全序广播(本章),我们终于准备好解决共识问题了。
尽管共识非常重要,但关于它的内容出现在本书的后半部分,因为这个主题非常微妙,欣赏细微之处需要一些必要的知识。即使在学术界,对共识的理解也是在几十年的过程中逐渐沉淀而来,一路上也有着许多误解。现在我们已经讨论了复制([第五章](/ch5)),事务([第七章](/ch7)),系统模型([第八章](/ch8)),线性一致以及全序广播(本章),我们终于准备好解决共识问题了。
节点能达成一致,在很多场景下都非常重要,例如:
* 领导选举
在单主复制的数据库中,所有节点需要就哪个节点是领导者达成一致。如果一些节点由于网络故障而无法与其他节点通信,则可能会对领导权的归属引起争议。在这种情况下,共识对于避免错误的故障切换非常重要。错误的故障切换会导致两个节点都认为自己是领导者(**脑裂**,请参阅 “[处理节点宕机](ch5.md#处理节点宕机)”)。如果有两个领导者,它们都会接受写入,它们的数据会发生分歧,从而导致不一致和数据丢失。
在单主复制的数据库中,所有节点需要就哪个节点是领导者达成一致。如果一些节点由于网络故障而无法与其他节点通信,则可能会对领导权的归属引起争议。在这种情况下,共识对于避免错误的故障切换非常重要。错误的故障切换会导致两个节点都认为自己是领导者(**脑裂**,请参阅 “[处理节点宕机](/ch5#处理节点宕机)”)。如果有两个领导者,它们都会接受写入,它们的数据会发生分歧,从而导致不一致和数据丢失。
* 原子提交
在支持跨多节点或跨多分区事务的数据库中,一个事务可能在某些节点上失败,但在其他节点上成功。如果我们想要维护事务的原子性(就 ACID 而言,请参阅 “[原子性](ch7.md#原子性)”),我们必须让所有节点对事务的结果达成一致:要么全部中止 / 回滚(如果出现任何错误),要么它们全部提交(如果没有出错)。这个共识的例子被称为 **原子提交atomic commit** 问题 [^xii]。
在支持跨多节点或跨多分区事务的数据库中,一个事务可能在某些节点上失败,但在其他节点上成功。如果我们想要维护事务的原子性(就 ACID 而言,请参阅 “[原子性](/ch7#原子性)”),我们必须让所有节点对事务的结果达成一致:要么全部中止 / 回滚(如果出现任何错误),要么它们全部提交(如果没有出错)。这个共识的例子被称为 **原子提交atomic commit** 问题 [^xii]。
[^xii]: 原子提交的形式化与共识稍有不同:原子事务只有在 **所有** 参与者投票提交的情况下才能提交,如果有任何参与者需要中止,则必须中止。共识则允许就 **任意一个** 被参与者提出的候选值达成一致。然而原子提交和共识可以相互简化为对方【70,71】。**非阻塞** 原子提交则要比共识更为困难 —— 请参阅 “[三阶段提交](#三阶段提交)”。
@ -557,7 +560,7 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
>
> 你可能已经听说过以作者 FischerLynch 和 Paterson 命名的 FLP 结果【68】它证明如果存在节点可能崩溃的风险则不存在 **总是** 能够达成共识的算法。在分布式系统中,我们必须假设节点可能会崩溃,所以可靠的共识是不可能的。然而这里我们正在讨论达成共识的算法,到底是怎么回事?
>
> 答案是 FLP 结果是在 **异步系统模型** 中被证明的(请参阅 “[系统模型与现实](ch8.md#系统模型与现实)”),而这是一种限制性很强的模型,它假定确定性算法不能使用任何时钟或超时。如果允许算法使用 **超时** 或其他方法来识别可疑的崩溃节点即使怀疑有时是错误的则共识变为一个可解的问题【67】。即使仅仅允许算法使用随机数也足以绕过这个不可能的结果【69】。
> 答案是 FLP 结果是在 **异步系统模型** 中被证明的(请参阅 “[系统模型与现实](/ch8#系统模型与现实)”),而这是一种限制性很强的模型,它假定确定性算法不能使用任何时钟或超时。如果允许算法使用 **超时** 或其他方法来识别可疑的崩溃节点即使怀疑有时是错误的则共识变为一个可解的问题【67】。即使仅仅允许算法使用随机数也足以绕过这个不可能的结果【69】。
>
> 因此,虽然 FLP 是关于共识不可能性的重要理论结果,但现实中的分布式系统通常是可以达成共识的。
@ -568,17 +571,17 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
### 原子提交与两阶段提交
在 [第七章](ch7.md) 中我们了解到,事务原子性的目的是在多次写操作中途出错的情况下,提供一种简单的语义。事务的结果要么是成功提交,在这种情况下,事务的所有写入都是持久化的;要么是中止,在这种情况下,事务的所有写入都被回滚(即撤消或丢弃)。
在 [第七章](/ch7) 中我们了解到,事务原子性的目的是在多次写操作中途出错的情况下,提供一种简单的语义。事务的结果要么是成功提交,在这种情况下,事务的所有写入都是持久化的;要么是中止,在这种情况下,事务的所有写入都被回滚(即撤消或丢弃)。
原子性可以防止失败的事务搅乱数据库,避免数据库陷入半成品结果和半更新状态。这对于多对象事务(请参阅 “[单对象和多对象操作](ch7.md#单对象和多对象操作)”)和维护次级索引的数据库尤其重要。每个次级索引都是与主数据相分离的数据结构 —— 因此,如果你修改了一些数据,则还需要在次级索引中进行相应的更改。原子性确保次级索引与主数据保持一致(如果索引与主数据不一致,就没什么用了)。
原子性可以防止失败的事务搅乱数据库,避免数据库陷入半成品结果和半更新状态。这对于多对象事务(请参阅 “[单对象和多对象操作](/ch7#单对象和多对象操作)”)和维护次级索引的数据库尤其重要。每个次级索引都是与主数据相分离的数据结构 —— 因此,如果你修改了一些数据,则还需要在次级索引中进行相应的更改。原子性确保次级索引与主数据保持一致(如果索引与主数据不一致,就没什么用了)。
#### 从单节点到分布式原子提交
对于在单个数据库节点执行的事务,原子性通常由存储引擎实现。当客户端请求数据库节点提交事务时,数据库将使事务的写入持久化(通常在预写式日志中,请参阅 “[让 B 树更可靠](ch3.md#让B树更可靠)”),然后将提交记录追加到磁盘中的日志里。如果数据库在这个过程中间崩溃,当节点重启时,事务会从日志中恢复:如果提交记录在崩溃之前成功地写入磁盘,则认为事务被提交;否则来自该事务的任何写入都被回滚。
对于在单个数据库节点执行的事务,原子性通常由存储引擎实现。当客户端请求数据库节点提交事务时,数据库将使事务的写入持久化(通常在预写式日志中,请参阅 “[让 B 树更可靠](/ch3#让B树更可靠)”),然后将提交记录追加到磁盘中的日志里。如果数据库在这个过程中间崩溃,当节点重启时,事务会从日志中恢复:如果提交记录在崩溃之前成功地写入磁盘,则认为事务被提交;否则来自该事务的任何写入都被回滚。
因此,在单个节点上,事务的提交主要取决于数据持久化落盘的 **顺序**首先是数据然后是提交记录【72】。事务提交或终止的关键决定时刻是磁盘完成写入提交记录的时刻在此之前仍有可能中止由于崩溃但在此之后事务已经提交即使数据库崩溃。因此是单一的设备连接到单个磁盘的控制器且挂载在单台机器上使得提交具有原子性。
但是,如果一个事务中涉及多个节点呢?例如,你也许在分区数据库中会有一个多对象事务,或者是一个按关键词分区的次级索引(其中索引条目可能位于与主数据不同的节点上;请参阅 “[分区与次级索引](ch6.md#分区与次级索引)”)。大多数 “NoSQL” 分布式数据存储不支持这种分布式事务,但是很多关系型数据库集群支持(请参阅 “[实践中的分布式事务](#实践中的分布式事务)”)。
但是,如果一个事务中涉及多个节点呢?例如,你也许在分区数据库中会有一个多对象事务,或者是一个按关键词分区的次级索引(其中索引条目可能位于与主数据不同的节点上;请参阅 “[分区与次级索引](/ch6#分区与次级索引)”)。大多数 “NoSQL” 分布式数据存储不支持这种分布式事务,但是很多关系型数据库集群支持(请参阅 “[实践中的分布式事务](#实践中的分布式事务)”)。
在这些情况下,仅向所有节点发送提交请求并独立提交每个节点的事务是不够的。这样很容易发生违反原子性的情况:提交在某些节点上成功,而在其他节点上失败:
@ -586,9 +589,9 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
* 某些提交请求可能在网络中丢失,最终由于超时而中止,而其他提交请求则通过。
* 在提交记录完全写入之前,某些节点可能会崩溃,并在恢复时回滚,而其他节点则成功提交。
如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致(如 [图 7-3](img/fig7-3.png) 所示)。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。
如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致(如 [图 7-3](/img/fig7-3.png) 所示)。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。
事务提交必须是不可撤销的 —— 事务提交之后,你不能改变主意,并追溯性地中止事务。这个规则的原因是,一旦数据被提交,其结果就对其他事务可见,因此其他客户端可能会开始依赖这些数据。这个原则构成了 **读已提交** 隔离等级的基础,在 “[读已提交](ch7.md#读已提交)” 一节中讨论了这个问题。如果一个事务在提交后被允许中止,所有那些读取了 **已提交却又被追溯声明不存在数据** 的事务也必须回滚。
事务提交必须是不可撤销的 —— 事务提交之后,你不能改变主意,并追溯性地中止事务。这个规则的原因是,一旦数据被提交,其结果就对其他事务可见,因此其他客户端可能会开始依赖这些数据。这个原则构成了 **读已提交** 隔离等级的基础,在 “[读已提交](/ch7#读已提交)” 一节中讨论了这个问题。如果一个事务在提交后被允许中止,所有那些读取了 **已提交却又被追溯声明不存在数据** 的事务也必须回滚。
提交事务的结果有可能通过事后执行另一个补偿事务compensating transaction来取消【73,74】但从数据库的角度来看这是一个单独的事务因此任何关于跨事务正确性的保证都是应用自己的问题。
@ -596,15 +599,15 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
**两阶段提交two-phase commit** 是一种用于实现跨多个节点的原子事务提交的算法即确保所有节点提交或所有节点中止。它是分布式数据库中的经典算法【13,35,75】。2PC 在某些数据库内部使用,也以 **XA 事务** 的形式对应用可用【76,77】例如 Java Transaction API 支持)或以 SOAP Web 服务的 `WS-AtomicTransaction` 形式提供给应用【78,79】。
[图 9-9](img/fig9-9.png) 说明了 2PC 的基本流程。2PC 中的提交 / 中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。
[图 9-9](/img/fig9-9.png) 说明了 2PC 的基本流程。2PC 中的提交 / 中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。
![](img/fig9-9.png)
![](/img/fig9-9.png)
**图 9-9 两阶段提交2PC的成功执行**
> #### 不要把2PC和2PL搞混了
>
> 两阶段提交2PC和两阶段锁定请参阅 “[两阶段锁定](ch7.md#两阶段锁定)”是两个完全不同的东西。2PC 在分布式数据库中提供原子提交,而 2PL 提供可串行化的隔离等级。为了避免混淆,最好把它们看作完全独立的概念,并忽略名称中不幸的相似性。
> 两阶段提交2PC和两阶段锁定请参阅 “[两阶段锁定](/ch7#两阶段锁定)”是两个完全不同的东西。2PC 在分布式数据库中提供原子提交,而 2PL 提供可串行化的隔离等级。为了避免混淆,最好把它们看作完全独立的概念,并忽略名称中不幸的相似性。
2PC 使用一个通常不会出现在单节点事务中的新组件:**协调者**coordinator也称为 **事务管理器**,即 transaction manager。协调者通常在请求事务的相同应用进程中以库的形式实现例如嵌入在 Java EE 容器中),但也可以是单独的进程或服务。这种协调者的例子包括 Narayana、JOTM、BTM 或 MSDTC。
@ -638,9 +641,9 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
如果协调者在发送 **准备** 请求之前失败,参与者可以安全地中止事务。但是,一旦参与者收到了准备请求并投了 “是”,就不能再单方面放弃 —— 必须等待协调者回答事务是否已经提交或中止。如果此时协调者崩溃或网络出现故障,参与者什么也做不了只能等待。参与者的这种事务状态称为 **存疑in doubt** 的或 **不确定uncertain** 的。
情况如 [图 9-10](img/fig9-10.png) 所示。在这个特定的例子中,协调者实际上决定提交,数据库 2 收到提交请求。但是,协调者在将提交请求发送到数据库 1 之前发生崩溃,因此数据库 1 不知道是否提交或中止。即使 **超时** 在这里也没有帮助:如果数据库 1 在超时后单方面中止,它将最终与执行提交的数据库 2 不一致。同样,单方面提交也是不安全的,因为另一个参与者可能已经中止了。
情况如 [图 9-10](/img/fig9-10.png) 所示。在这个特定的例子中,协调者实际上决定提交,数据库 2 收到提交请求。但是,协调者在将提交请求发送到数据库 1 之前发生崩溃,因此数据库 1 不知道是否提交或中止。即使 **超时** 在这里也没有帮助:如果数据库 1 在超时后单方面中止,它将最终与执行提交的数据库 2 不一致。同样,单方面提交也是不安全的,因为另一个参与者可能已经中止了。
![](img/fig9-10.png)
![](/img/fig9-10.png)
**图 9-10 参与者投赞成票后,协调者崩溃。数据库 1 不知道是否提交或中止**
@ -652,7 +655,7 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
两阶段提交被称为 **阻塞blocking**- 原子提交协议,因为存在 2PC 可能卡住并等待协调者恢复的情况。理论上,可以使一个原子提交协议变为 **非阻塞nonblocking** 的,以便在节点失败时不会卡住。但是让这个协议能在实践中工作并没有那么简单。
作为 2PC 的替代方案,已经提出了一种称为 **三阶段提交3PC** 的算法【13,80】。然而3PC 假定网络延迟有界,节点响应时间有限;在大多数具有无限网络延迟和进程暂停的实际系统中(见 [第八章](ch8.md)),它并不能保证原子性。
作为 2PC 的替代方案,已经提出了一种称为 **三阶段提交3PC** 的算法【13,80】。然而3PC 假定网络延迟有界,节点响应时间有限;在大多数具有无限网络延迟和进程暂停的实际系统中(见 [第八章](/ch8)),它并不能保证原子性。
通常,非阻塞原子提交需要一个 **完美的故障检测器perfect failure detector**【67,71】—— 即一个可靠的机制来判断一个节点是否已经崩溃。在具有无限延迟的网络中超时并不是一种可靠的故障检测机制因为即使没有节点崩溃请求也可能由于网络问题而超时。出于这个原因2PC 仍然被使用,尽管大家都清楚可能存在协调者故障的问题。
@ -683,7 +686,7 @@ CAP 定理的正式定义仅限于很狭隘的范围【30】它只考虑了
然而,只有当所有受事务影响的系统都使用同样的 **原子提交协议atomic commit protocol** 时,这样的分布式事务才是可能的。例如,假设处理消息的副作用是发送一封邮件,而邮件服务器并不支持两阶段提交:如果消息处理失败并重试,则可能会发送两次或更多次的邮件。但如果处理消息的所有副作用都可以在事务中止时回滚,那么这样的处理流程就可以安全地重试,就好像什么都没有发生过一样。
在 [第十一章](ch11.md) 中将再次回到 “恰好一次” 消息处理的主题。让我们先来看看允许这种异构分布式事务的原子提交协议。
在 [第十一章](/ch11) 中将再次回到 “恰好一次” 消息处理的主题。让我们先来看看允许这种异构分布式事务的原子提交协议。
#### XA事务
@ -701,9 +704,9 @@ XA 假定你的应用使用网络驱动或客户端库来与 **参与者**(数
为什么我们这么关心存疑事务?系统的其他部分就不能继续正常工作,无视那些终将被清理的存疑事务吗?
问题在于 **锁locking**。正如在 “[读已提交](ch7.md#读已提交)” 中所讨论的那样,数据库事务通常获取待修改的行上的 **行级排他锁**,以防止脏写。此外,如果要使用可串行化的隔离等级,则使用两阶段锁定的数据库也必须为事务所读取的行加上共享锁(请参阅 “[两阶段锁定](ch7.md#两阶段锁定)”)。
问题在于 **锁locking**。正如在 “[读已提交](/ch7#读已提交)” 中所讨论的那样,数据库事务通常获取待修改的行上的 **行级排他锁**,以防止脏写。此外,如果要使用可串行化的隔离等级,则使用两阶段锁定的数据库也必须为事务所读取的行加上共享锁(请参阅 “[两阶段锁定](/ch7#两阶段锁定)”)。
在事务提交或中止之前,数据库不能释放这些锁(如 [图 9-9](img/fig9-9.png) 中的阴影区域所示)。因此,在使用两阶段提交时,事务必须在整个存疑期间持有这些锁。如果协调者已经崩溃,需要 20 分钟才能重启,那么这些锁将会被持有 20 分钟。如果协调者的日志由于某种原因彻底丢失,这些锁将被永久持有 —— 或至少在管理员手动解决该情况之前。
在事务提交或中止之前,数据库不能释放这些锁(如 [图 9-9](/img/fig9-9.png) 中的阴影区域所示)。因此,在使用两阶段提交时,事务必须在整个存疑期间持有这些锁。如果协调者已经崩溃,需要 20 分钟才能重启,那么这些锁将会被持有 20 分钟。如果协调者的日志由于某种原因彻底丢失,这些锁将被永久持有 —— 或至少在管理员手动解决该情况之前。
当这些锁被持有时,其他事务不能修改这些行。根据数据库的不同,其他事务甚至可能因为读取这些行而被阻塞。因此,其他事务没法儿简单地继续它们的业务了 —— 如果它们要访问同样的数据,就会被阻塞。这可能会导致应用大面积进入不可用状态,直到存疑事务被解决。
@ -723,10 +726,10 @@ XA 事务解决了保持多个参与者(数据系统)相互一致的现实
* 如果协调者没有复制,而是只在单台机器上运行,那么它是整个系统的失效单点(因为它的失效会导致其他应用服务器阻塞在存疑事务持有的锁上)。令人惊讶的是,许多协调者实现默认情况下并不是高可用的,或者只有基本的复制支持。
* 许多服务器端应用都是使用无状态模式开发的(受 HTTP 的青睐),所有持久状态都存储在数据库中,因此具有应用服务器可随意按需添加删除的优点。但是,当协调者成为应用服务器的一部分时,它会改变部署的性质。突然间,协调者的日志成为持久系统状态的关键部分 —— 与数据库本身一样重要,因为协调者日志是为了在崩溃后恢复存疑事务所必需的。这样的应用服务器不再是无状态的了。
* 由于 XA 需要兼容各种数据系统,因此它必须是所有系统的最小公分母。例如,它不能检测不同系统间的死锁(因为这将需要一个标准协议来让系统交换每个事务正在等待的锁的信息),而且它无法与 SSI请参阅 [可串行化快照隔离](ch7.md#可串行化快照隔离))协同工作,因为这需要一个跨系统定位冲突的协议。
* 由于 XA 需要兼容各种数据系统,因此它必须是所有系统的最小公分母。例如,它不能检测不同系统间的死锁(因为这将需要一个标准协议来让系统交换每个事务正在等待的锁的信息),而且它无法与 SSI请参阅 [可串行化快照隔离](/ch7#可串行化快照隔离))协同工作,因为这需要一个跨系统定位冲突的协议。
* 对于数据库内部的分布式事务(不是 XA限制没有这么大 —— 例如,分布式版本的 SSI 是可能的。然而仍然存在问题2PC 成功提交一个事务需要所有参与者的响应。因此,如果系统的 **任何** 部分损坏,事务也会失败。因此,分布式事务又有 **扩大失效amplifying failures** 的趋势,这又与我们构建容错系统的目标背道而驰。
这些事实是否意味着我们应该放弃保持几个系统相互一致的所有希望?不完全是 —— 还有其他的办法,可以让我们在没有异构分布式事务的痛苦的情况下实现同样的事情。我们将在 [第十一章](ch11.md) 和 [第十二章](ch12.md) 回到这些话题。但首先,我们应该概括一下关于 **共识** 的话题。
这些事实是否意味着我们应该放弃保持几个系统相互一致的所有希望?不完全是 —— 还有其他的办法,可以让我们在没有异构分布式事务的痛苦的情况下实现同样的事情。我们将在 [第十一章](/ch11) 和 [第十二章](/ch12) 回到这些话题。但首先,我们应该概括一下关于 **共识** 的话题。
### 容错共识
@ -759,15 +762,15 @@ XA 事务解决了保持多个参与者(数据系统)相互一致的现实
如果你不关心容错,那么满足前三个属性很容易:你可以将一个节点硬编码为 “独裁者”,并让该节点做出所有的决定。但如果该节点失效,那么系统就无法再做出任何决定。事实上,这就是我们在两阶段提交的情况中所看到的:如果协调者失效,那么存疑的参与者就无法决定提交还是中止。
**终止** 属性形式化了容错的思想。它实质上说的是,一个共识算法不能简单地永远闲坐着等死 —— 换句话说,它必须取得进展。即使部分节点出现故障,其他节点也必须达成一项决定(**终止** 是一种 **活性属性**,而另外三种是 **安全属性** —— 请参阅 “[安全性和活性](ch8.md#安全性和活性)”)。
**终止** 属性形式化了容错的思想。它实质上说的是,一个共识算法不能简单地永远闲坐着等死 —— 换句话说,它必须取得进展。即使部分节点出现故障,其他节点也必须达成一项决定(**终止** 是一种 **活性属性**,而另外三种是 **安全属性** —— 请参阅 “[安全性和活性](/ch8#安全性和活性)”)。
共识的系统模型假设,当一个节点 “崩溃” 时,它会突然消失而且永远不会回来。(不像软件崩溃,想象一下地震,包含你的节点的数据中心被山体滑坡所摧毁,你必须假设节点被埋在 30 英尺以下的泥土中,并且永远不会重新上线)在这个系统模型中,任何需要等待节点恢复的算法都不能满足 **终止** 属性。特别是2PC 不符合终止属性的要求。
当然如果 **所有** 的节点都崩溃了,没有一个在运行,那么所有算法都不可能决定任何事情。算法可以容忍的失效数量是有限的:事实上可以证明,任何共识算法都需要至少占总体 **多数majority** 的节点正确工作以确保终止属性【67】。多数可以安全地组成法定人数请参阅 “[读写的法定人数](ch5.md#读写的法定人数)”)。
当然如果 **所有** 的节点都崩溃了,没有一个在运行,那么所有算法都不可能决定任何事情。算法可以容忍的失效数量是有限的:事实上可以证明,任何共识算法都需要至少占总体 **多数majority** 的节点正确工作以确保终止属性【67】。多数可以安全地组成法定人数请参阅 “[读写的法定人数](/ch5#读写的法定人数)”)。
因此 **终止** 属性取决于一个假设,**不超过一半的节点崩溃或不可达**。然而即使多数节点出现故障或存在严重的网络问题,绝大多数共识的实现都能始终确保安全属性得到满足 —— 一致同意完整性和有效性【92】。因此大规模的中断可能会阻止系统处理请求但是它不能通过使系统做出无效的决定来破坏共识系统。
大多数共识算法假设不存在 **拜占庭式错误**,正如在 “[拜占庭故障](ch8.md#拜占庭故障)” 一节中所讨论的那样。也就是说如果一个节点没有正确地遵循协议例如如果它向不同节点发送矛盾的消息它就可能会破坏协议的安全属性。克服拜占庭故障稳健地达成共识是可能的只要少于三分之一的节点存在拜占庭故障【25,93】。但我们没有地方在本书中详细讨论这些算法了。
大多数共识算法假设不存在 **拜占庭式错误**,正如在 “[拜占庭故障](/ch8#拜占庭故障)” 一节中所讨论的那样。也就是说如果一个节点没有正确地遵循协议例如如果它向不同节点发送矛盾的消息它就可能会破坏协议的安全属性。克服拜占庭故障稳健地达成共识是可能的只要少于三分之一的节点存在拜占庭故障【25,93】。但我们没有地方在本书中详细讨论这些算法了。
#### 共识算法和全序广播
@ -788,11 +791,11 @@ XA 事务解决了保持多个参与者(数据系统)相互一致的现实
#### 单主复制与共识
在 [第五章](ch5.md) 中,我们讨论了单主复制(请参阅 “[领导者与追随者](ch5.md#领导者与追随者)”),它将所有的写入操作都交给主库,并以相同的顺序将它们应用到从库,从而使副本保持在最新状态。这实际上不就是一个全序广播吗?为什么我们在 [第五章](ch5.md) 里一点都没担心过共识问题呢?
在 [第五章](/ch5) 中,我们讨论了单主复制(请参阅 “[领导者与追随者](/ch5#领导者与追随者)”),它将所有的写入操作都交给主库,并以相同的顺序将它们应用到从库,从而使副本保持在最新状态。这实际上不就是一个全序广播吗?为什么我们在 [第五章](/ch5) 里一点都没担心过共识问题呢?
答案取决于如何选择领导者。如果主库是由运维人员手动选择和配置的,那么你实际上拥有一种 **独裁类型** 的 “共识算法”:只有一个节点被允许接受写入(即决定写入复制日志的顺序),如果该节点发生故障,则系统将无法写入,直到运维手动配置其他节点作为主库。这样的系统在实践中可以表现良好,但它无法满足共识的 **终止** 属性,因为它需要人为干预才能取得 **进展**
一些数据库会自动执行领导者选举和故障切换,如果旧主库失效,会提拔一个从库为新主库(请参阅 “[处理节点宕机](ch5.md#处理节点宕机)”)。这使我们向容错的全序广播更进一步,从而达成共识。
一些数据库会自动执行领导者选举和故障切换,如果旧主库失效,会提拔一个从库为新主库(请参阅 “[处理节点宕机](/ch5#处理节点宕机)”)。这使我们向容错的全序广播更进一步,从而达成共识。
但是还有一个问题。我们之前曾经讨论过脑裂的问题,并且说过所有的节点都需要同意是谁领导,否则两个不同的节点都会认为自己是领导者,从而导致数据库进入不一致的状态。因此,选出一位领导者需要共识。但如果这里描述的共识算法实际上是全序广播算法,并且全序广播就像单主复制,而单主复制需要一个领导者,那么...
@ -804,9 +807,9 @@ XA 事务解决了保持多个参与者(数据系统)相互一致的现实
每次当现任领导被认为挂掉的时候,节点间就会开始一场投票,以选出一个新领导。这次选举被赋予一个递增的纪元编号,因此纪元编号是全序且单调递增的。如果两个不同的时代的领导者之间出现冲突(也许是因为前任领导者实际上并未死亡),那么带有更高纪元编号的领导说了算。
在任何领导者被允许决定任何事情之前,必须先检查是否存在其他带有更高纪元编号的领导者,它们可能会做出相互冲突的决定。领导者如何知道自己没有被另一个节点赶下台?回想一下在 “[真相由多数所定义](ch8.md#真相由多数所定义)” 中提到的:一个节点不一定能相信自己的判断 —— 因为只有节点自己认为自己是领导者,并不一定意味着其他节点接受它作为它们的领导者。
在任何领导者被允许决定任何事情之前,必须先检查是否存在其他带有更高纪元编号的领导者,它们可能会做出相互冲突的决定。领导者如何知道自己没有被另一个节点赶下台?回想一下在 “[真相由多数所定义](/ch8#真相由多数所定义)” 中提到的:一个节点不一定能相信自己的判断 —— 因为只有节点自己认为自己是领导者,并不一定意味着其他节点接受它作为它们的领导者。
相反,它必须从 **法定人数quorum** 的节点中获取选票(请参阅 “[读写的法定人数](ch5.md#读写的法定人数)”。对领导者想要做出的每一个决定都必须将提议值发送给其他节点并等待法定人数的节点响应并赞成提案。法定人数通常但不总是由多数节点组成【105】。只有在没有意识到任何带有更高纪元编号的领导者的情况下一个节点才会投票赞成提议。
相反,它必须从 **法定人数quorum** 的节点中获取选票(请参阅 “[读写的法定人数](/ch5#读写的法定人数)”。对领导者想要做出的每一个决定都必须将提议值发送给其他节点并等待法定人数的节点响应并赞成提案。法定人数通常但不总是由多数节点组成【105】。只有在没有意识到任何带有更高纪元编号的领导者的情况下一个节点才会投票赞成提议。
因此,我们有两轮投票:第一次是为了选出一位领导者,第二次是对领导者的提议进行表决。关键的洞察在于,这两次投票的 **法定人群** 必须相互 **重叠overlap**如果一个提案的表决通过则至少得有一个参与投票的节点也必须参加过最近的领导者选举【105】。因此如果在一个提案的表决过程中没有出现更高的纪元编号。那么现任领导者就可以得出这样的结论没有发生过更高时代的领导选举因此可以确定自己仍然在领导。然后它就可以安全地对提议值做出决定。
@ -818,7 +821,7 @@ XA 事务解决了保持多个参与者(数据系统)相互一致的现实
尽管如此,它们并不是在所有地方都用上了,因为好处总是有代价的。
节点在做出决定之前对提议进行投票的过程是一种同步复制。如 “[同步复制与异步复制](ch5.md#同步复制与异步复制)” 中所述,通常数据库会配置为异步复制模式。在这种配置中发生故障切换时,一些已经提交的数据可能会丢失 —— 但是为了获得更好的性能,许多人选择接受这种风险。
节点在做出决定之前对提议进行投票的过程是一种同步复制。如 “[同步复制与异步复制](/ch5#同步复制与异步复制)” 中所述,通常数据库会配置为异步复制模式。在这种配置中发生故障切换时,一些已经提交的数据可能会丢失 —— 但是为了获得更好的性能,许多人选择接受这种风险。
共识系统总是需要严格多数来运转。这意味着你至少需要三个节点才能容忍单节点故障(其余两个构成多数),或者至少有五个节点来容忍两个节点发生故障(其余三个构成多数)。如果网络故障切断了某些节点同其他节点的连接,则只有多数节点所在的网络可以继续工作,其余部分将被阻塞(请参阅 “[线性一致性的代价](#线性一致性的代价)”)。
@ -840,11 +843,11 @@ ZooKeeper 模仿了 Google 的 Chubby 锁服务【14,98】不仅实现了全
* 线性一致性的原子操作
使用原子 CAS 操作可以实现锁:如果多个节点同时尝试执行相同的操作,只有一个节点会成功。共识协议保证了操作的原子性和线性一致性,即使节点发生故障或网络在任意时刻中断。分布式锁通常以 **租约lease** 的形式实现,租约有一个到期时间,以便在客户端失效的情况下最终能被释放(请参阅 “[进程暂停](ch8.md#进程暂停)”)。
使用原子 CAS 操作可以实现锁:如果多个节点同时尝试执行相同的操作,只有一个节点会成功。共识协议保证了操作的原子性和线性一致性,即使节点发生故障或网络在任意时刻中断。分布式锁通常以 **租约lease** 的形式实现,租约有一个到期时间,以便在客户端失效的情况下最终能被释放(请参阅 “[进程暂停](/ch8#进程暂停)”)。
* 操作的全序排序
如 “[领导者和锁](ch8.md#领导者和锁)” 中所述当某个资源受到锁或租约的保护时你需要一个防护令牌来防止客户端在进程暂停的情况下彼此冲突。防护令牌是每次锁被获取时单调增加的数字。ZooKeeper 通过全序化所有操作来提供这个功能,它为每个操作提供一个单调递增的事务 ID`zxid`)和版本号(`cversion`【15】。
如 “[领导者和锁](/ch8#领导者和锁)” 中所述当某个资源受到锁或租约的保护时你需要一个防护令牌来防止客户端在进程暂停的情况下彼此冲突。防护令牌是每次锁被获取时单调增加的数字。ZooKeeper 通过全序化所有操作来提供这个功能,它为每个操作提供一个单调递增的事务 ID`zxid`)和版本号(`cversion`【15】。
* 失效检测
@ -860,7 +863,7 @@ ZooKeeper 模仿了 Google 的 Chubby 锁服务【14,98】不仅实现了全
ZooKeeper/Chubby 模型运行良好的一个例子是,如果你有几个进程实例或服务,需要选择其中一个实例作为主库或首选服务。如果领导者失败,其他节点之一应该接管。这对单主数据库当然非常实用,但对作业调度程序和类似的有状态系统也很好用。
另一个例子是,当你有一些分区资源(数据库、消息流、文件存储、分布式 Actor 系统等),并需要决定将哪个分区分配给哪个节点时。当新节点加入集群时,需要将某些分区从现有节点移动到新节点,以便重新平衡负载(请参阅 “[分区再平衡](ch6.md#分区再平衡)”)。当节点被移除或失效时,其他节点需要接管失效节点的工作。
另一个例子是,当你有一些分区资源(数据库、消息流、文件存储、分布式 Actor 系统等),并需要决定将哪个分区分配给哪个节点时。当新节点加入集群时,需要将某些分区从现有节点移动到新节点,以便重新平衡负载(请参阅 “[分区再平衡](/ch6#分区再平衡)”)。当节点被移除或失效时,其他节点需要接管失效节点的工作。
这类任务可以通过在 ZooKeeper 中明智地使用原子操作,临时节点与通知来实现。如果设计得当,这种方法允许应用自动从故障中恢复而无需人工干预。不过这并不容易,尽管已经有不少在 ZooKeeper 客户端 API 基础之上提供更高层工具的库,例如 Apache Curator 【17】。但它仍然要比尝试从头实现必要的共识算法要好得多这样的尝试鲜有成功记录【107】。
@ -880,7 +883,7 @@ ZooKeeper、etcd 和 Consul 也经常用于服务发现 —— 也就是找出
ZooKeeper 和它的小伙伴们可以看作是成员资格服务membership services研究的悠久历史的一部分这个历史可以追溯到 20 世纪 80 年代并且对建立高度可靠的系统例如空中交通管制非常重要【110】。
成员资格服务确定哪些节点当前处于活动状态并且是集群的活动成员。正如我们在 [第八章](ch8.md) 中看到的那样,由于无限的网络延迟,无法可靠地检测到另一个节点是否发生故障。但是,如果你通过共识来进行故障检测,那么节点可以就哪些节点应该被认为是存在或不存在达成一致。
成员资格服务确定哪些节点当前处于活动状态并且是集群的活动成员。正如我们在 [第八章](/ch8) 中看到的那样,由于无限的网络延迟,无法可靠地检测到另一个节点是否发生故障。但是,如果你通过共识来进行故障检测,那么节点可以就哪些节点应该被认为是存在或不存在达成一致。
即使它确实存在,仍然可能发生一个节点被共识错误地宣告死亡。但是对于一个系统来说,知道哪些节点构成了当前的成员关系是非常有用的。例如,选择领导者可能意味着简单地选择当前成员中编号最小的成员,但如果不同的节点对现有的成员都有谁有不同意见,则这种方法将不起作用。
@ -929,13 +932,13 @@ ZooKeeper 和它的小伙伴们可以看作是成员资格服务membership se
尽管单领导者数据库可以提供线性一致性,且无需对每个写操作都执行共识算法,但共识对于保持及变更领导权仍然是必须的。因此从某种意义上说,使用单个领导者不过是 “缓兵之计”:共识仍然是需要的,只是在另一个地方,而且没那么频繁。好消息是,容错的共识算法与容错的共识系统是存在的,我们在本章中简要地讨论了它们。
像 ZooKeeper 这样的工具为应用提供了 “外包” 的共识、故障检测和成员服务。它们扮演了重要的角色,虽说使用不易,但总比自己去开发一个能经受 [第八章](ch8.md) 中所有问题考验的算法要好得多。如果你发现自己想要解决的问题可以归结为共识,并且希望它能容错,使用一个类似 ZooKeeper 的东西是明智之举。
像 ZooKeeper 这样的工具为应用提供了 “外包” 的共识、故障检测和成员服务。它们扮演了重要的角色,虽说使用不易,但总比自己去开发一个能经受 [第八章](/ch8) 中所有问题考验的算法要好得多。如果你发现自己想要解决的问题可以归结为共识,并且希望它能容错,使用一个类似 ZooKeeper 的东西是明智之举。
尽管如此,并不是所有系统都需要共识:例如,无领导者复制和多领导者复制系统通常不会使用全局的共识。这些系统中出现的冲突(请参阅 “[处理写入冲突](ch5.md#处理写入冲突)”)正是不同领导者之间没有达成共识的结果,但这也许并没有关系:也许我们只是需要接受没有线性一致性的事实,并学会更好地与具有分支与合并版本历史的数据打交道。
尽管如此,并不是所有系统都需要共识:例如,无领导者复制和多领导者复制系统通常不会使用全局的共识。这些系统中出现的冲突(请参阅 “[处理写入冲突](/ch5#处理写入冲突)”)正是不同领导者之间没有达成共识的结果,但这也许并没有关系:也许我们只是需要接受没有线性一致性的事实,并学会更好地与具有分支与合并版本历史的数据打交道。
本章引用了大量关于分布式系统理论的研究。虽然理论论文和证明并不总是容易理解,有时也会做出不切实际的假设,但它们对于指导这一领域的实践有着极其重要的价值:它们帮助我们推理什么可以做,什么不可以做,帮助我们找到反直觉的分布式系统缺陷。如果你有时间,这些参考资料值得探索。
这里已经到了本书 [第二部分](part-ii.md) 的末尾,第二部介绍了复制([第五章](ch5.md))、分区([第六章](ch6.md))、事务([第七章](ch7.md))、分布式系统的故障模型([第八章](ch8.md))以及最后的一致性与共识([第九章](ch9.md))。现在我们已经奠定了扎实的理论基础,我们将在 [第三部分](part-iii.md) 再次转向更实际的系统,并讨论如何使用异构的组件积木块构建强大的应用。
这里已经到了本书 [第二部分](/part-ii) 的末尾,第二部介绍了复制([第五章](/ch5))、分区([第六章](/ch6))、事务([第七章](/ch7))、分布式系统的故障模型([第八章](/ch8))以及最后的一致性与共识([第九章](/ch9))。现在我们已经奠定了扎实的理论基础,我们将在 [第三部分](/part-iii) 再次转向更实际的系统,并讨论如何使用异构的组件积木块构建强大的应用。
## 参考文献
@ -1050,11 +1053,3 @@ ZooKeeper 和它的小伙伴们可以看作是成员资格服务membership se
1. Ivan Kelly: “[BookKeeper Tutorial](https://github.com/ivankelly/bookkeeper-tutorial),” *github.com*, October 2014.
1. Camille Fournier: “[Consensus Systems for the Skeptical Architect](https://vimeo.com/102667163),” at *Philly ETE*, Philadelphia, PA, USA, April 2014.
1. Kenneth P. Birman: “[A History of the Virtual Synchrony Replication Model](https://ptolemy.berkeley.edu/projects/truststc/pubs/713/History%20of%20the%20Virtual%20Synchrony%20Replication%20Model%202010.pdf),” in *Replication: Theory and Practice*, Springer LNCS volume 5959, chapter 6, pages 91120, 2010. ISBN: 978-3-642-11293-5, [doi:10.1007/978-3-642-11294-2_6](http://dx.doi.org/10.1007/978-3-642-11294-2_6)
------
| 上一章 | 目录 | 下一章 |
| ---------------------------------- | ------------------------------- | --------------------------------- |
| [第八章:分布式系统的麻烦](ch8.md) | [设计数据密集型应用](README.md) | [第三部分:衍生数据](part-iii.md) |

View File

@ -1,4 +1,8 @@
# 后记
---
title: 后记
weight: 600
breadcrumbs: false
---
## 关于作者
@ -11,11 +15,13 @@ Martin 是一位常规会议演讲者,博主和开源贡献者。他认为,
## 关于译者
[冯若航](https://vonng.com/about)
[**冯若航**](https://vonng.com),网名 [@Vonng](https://github.com/Vonng)。
PostgreSQL 专家,数据库老司机,云计算泥石流。
PostgreSQL 发行版 [**Pigsty**](https://pgsty.com) 作者与创始人。
架构师DBA全栈工程师 @ TanTanAlibabaApple。
独立开源贡献者,[GitStar Ranking 585](https://gitstar-ranking.com/Vonng)[国区活跃 Top20](https://committers.top/china)。
[DDIA](https://ddia.pigsty.io) / [PG Internal](https://pgint.vonng.com) 中文版译者,数据库/云计算 KOL。
PostgreSQL DBA @ TanTan
Alibaba+-Finplus 架构师/全栈工程师 (2015 ~ 2017)
## 后记

232
content/zh/contrib.md Normal file
View File

@ -0,0 +1,232 @@
---
title: 贡献者
weight: 800
breadcrumbs: false
---
## 译者
[**冯若航**](https://vonng.com),网名 [@Vonng](https://github.com/Vonng)。
PostgreSQL 专家,数据库老司机,云计算泥石流。
[**Pigsty**](https://pgsty.com) 作者与创始人。
架构师DBA全栈工程师 @ TanTanAlibabaApple。
独立开源贡献者,[GitStar Ranking 585](https://gitstar-ranking.com/Vonng)[国区活跃 Top20](https://committers.top/china)。
[DDIA](https://ddia.pigsty.io) / [PG Internal](https://pgint.vonng.com) 中文版译者,公众号:《老冯云数》,数据库 KOL。
## 校订与维护
YinGang [@yingang](https://github.com/yingang) 对本书进行了全文校订,并持续维护。
## 繁体中文版本
[繁體中文](/tw) **版本维护** by [@afunTW](https://github.com/afunTW)
## 贡献列表
[GitHub 贡献者列表](https://github.com/Vonng/ddia/graphs/contributors)
0. 全文校订 by [@yingang](https://github.com/Vonng/ddia/commits?author=yingang)
1. [序言初翻修正](https://github.com/Vonng/ddia/commit/afb5edab55c62ed23474149f229677e3b42dfc2c) by [@seagullbird](https://github.com/Vonng/ddia/commits?author=seagullbird)
2. [第一章语法标点校正](https://github.com/Vonng/ddia/commit/973b12cd8f8fcdf4852f1eb1649ddd9d187e3644) by [@nevertiree](https://github.com/Vonng/ddia/commits?author=nevertiree)
3. [第六章部分校正](https://github.com/Vonng/ddia/commit/d4eb0852c0ec1e93c8aacc496c80b915bb1e6d48) 与[第十章的初翻](https://github.com/Vonng/ddia/commit/9de8dbd1bfe6fbb03b3bf6c1a1aa2291aed2490e) by [@MuAlex](https://github.com/Vonng/ddia/commits?author=MuAlex)
4. [第一部分](/part-i)前言,[ch2](/ch2)校正 by [@jiajiadebug](https://github.com/Vonng/ddia/commits?author=jiajiadebug)
5. [词汇表](/glossary)、[后记](/colophon)关于野猪的部分 by [@Chowss](https://github.com/Vonng/ddia/commits?author=Chowss)
6. [繁體中文](https://github.com/Vonng/ddia/pulls)版本与转换脚本 by [@afunTW](https://github.com/afunTW)
7. 多处翻译修正 by [@songzhibin97](https://github.com/Vonng/ddia/commits?author=songzhibin97) [@MamaShip](https://github.com/Vonng/ddia/commits?author=MamaShip) [@FangYuan33](https://github.com/Vonng/ddia/commits?author=FangYuan33)
感谢所有提出意见,作出贡献的朋友们,您可以在这里找到所有贡献的 [Issue 列表](https://github.com/Vonng/ddia/issues) 与 [PR 列表](https://github.com/Vonng/ddia/pulls)
| ISSUE & Pull Requests | USER | Title |
|-------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------|
| [359](https://github.com/Vonng/ddia/pull/359) | [@c25423](https://github.com/c25423) | ch10: 修正一处拼写错误 |
| [358](https://github.com/Vonng/ddia/pull/358) | [@lewiszlw](https://github.com/lewiszlw) | ch4: 修正一处拼写错误 |
| [356](https://github.com/Vonng/ddia/pull/356) | [@lewiszlw](https://github.com/lewiszlw) | ch2: 修正一处标点错误 |
| [355](https://github.com/Vonng/ddia/pull/355) | [@DuroyGeorge](https://github.com/DuroyGeorge) | ch12: 修正一处格式错误 |
| [354](https://github.com/Vonng/ddia/pull/354) | [@justlorain](https://github.com/justlorain) | ch7: 修正一处参考链接 |
| [353](https://github.com/Vonng/ddia/pull/353) | [@fantasyczl](https://github.com/fantasyczl) | ch3&9: 修正两处引用错误 |
| [352](https://github.com/Vonng/ddia/pull/352) | [@fantasyczl](https://github.com/fantasyczl) | 支持输出为 EPUB 格式 |
| [349](https://github.com/Vonng/ddia/pull/349) | [@xiyihan0](https://github.com/xiyihan0) | ch1: 修正一处格式错误 |
| [348](https://github.com/Vonng/ddia/pull/348) | [@omegaatt36](https://github.com/omegaatt36) | ch3: 修正一处图像链接 |
| [346](https://github.com/Vonng/ddia/issues/346) | [@Vermouth1995](https://github.com/Vermouth1995) | ch1: 优化一处翻译 |
| [343](https://github.com/Vonng/ddia/pull/343) | [@kehao-chen](https://github.com/kehao-chen) | ch10: 优化一处翻译 |
| [341](https://github.com/Vonng/ddia/pull/341) | [@YKIsTheBest](https://github.com/YKIsTheBest) | ch3: 优化两处翻译 |
| [340](https://github.com/Vonng/ddia/pull/340) | [@YKIsTheBest](https://github.com/YKIsTheBest) | ch2: 优化多处翻译 |
| [338](https://github.com/Vonng/ddia/pull/338) | [@YKIsTheBest](https://github.com/YKIsTheBest) | ch1: 优化一处翻译 |
| [335](https://github.com/Vonng/ddia/pull/335) | [@kimi0230](https://github.com/kimi0230) | 修正一处繁体中文错误 |
| [334](https://github.com/Vonng/ddia/pull/334) | [@soulrrrrr](https://github.com/soulrrrrr) | ch2: 修正一处繁体中文错误 |
| [332](https://github.com/Vonng/ddia/pull/332) | [@justlorain](https://github.com/justlorain) | ch5: 修正一处翻译错误 |
| [331](https://github.com/Vonng/ddia/pull/331) | [@Lyianu](https://github.com/Lyianu) | ch9: 更正几处拼写错误 |
| [330](https://github.com/Vonng/ddia/pull/330) | [@Lyianu](https://github.com/Lyianu) | ch7: 优化一处翻译 |
| [329](https://github.com/Vonng/ddia/issues/329) | [@Lyianu](https://github.com/Lyianu) | ch6: 指出一处翻译错误 |
| [328](https://github.com/Vonng/ddia/pull/328) | [@justlorain](https://github.com/justlorain) | ch4: 更正一处翻译遗漏 |
| [326](https://github.com/Vonng/ddia/pull/326) | [@liangGTY](https://github.com/liangGTY) | ch1: 优化一处翻译 |
| [323](https://github.com/Vonng/ddia/pull/323) | [@marvin263](https://github.com/marvin263) | ch5: 优化一处翻译 |
| [322](https://github.com/Vonng/ddia/pull/322) | [@marvin263](https://github.com/marvin263) | ch8: 优化一处翻译 |
| [304](https://github.com/Vonng/ddia/pull/304) | [@spike014](https://github.com/spike014) | ch11: 优化一处翻译 |
| [298](https://github.com/Vonng/ddia/pull/298) | [@Makonike](https://github.com/Makonike) | ch11&12: 修正两处错误 |
| [284](https://github.com/Vonng/ddia/pull/284) | [@WAangzE](https://github.com/WAangzE) | ch4: 更正一处列表错误 |
| [283](https://github.com/Vonng/ddia/pull/283) | [@WAangzE](https://github.com/WAangzE) | ch3: 更正一处错别字 |
| [282](https://github.com/Vonng/ddia/pull/282) | [@WAangzE](https://github.com/WAangzE) | ch2: 更正一处公式问题 |
| [281](https://github.com/Vonng/ddia/pull/281) | [@lyuxi99](https://github.com/lyuxi99) | 更正多处内部链接错误 |
| [280](https://github.com/Vonng/ddia/pull/280) | [@lyuxi99](https://github.com/lyuxi99) | ch9: 更正内部链接错误 |
| [279](https://github.com/Vonng/ddia/issues/279) | [@codexvn](https://github.com/codexvn) | ch9: 指出公式在 GitHub Pages 显示的问题 |
| [278](https://github.com/Vonng/ddia/pull/278) | [@LJlkdskdjflsa](https://github.com/LJlkdskdjflsa) | 发现了繁体中文版本中的错误翻译 |
| [275](https://github.com/Vonng/ddia/pull/275) | [@117503445](https://github.com/117503445) | 更正 LICENSE 链接 |
| [274](https://github.com/Vonng/ddia/pull/274) | [@uncle-lv](https://github.com/uncle-lv) | ch7: 修正错别字 |
| [273](https://github.com/Vonng/ddia/pull/273) | [@Sdot-Python](https://github.com/Sdot-Python) | ch7: 统一了 write skew 的翻译 |
| [271](https://github.com/Vonng/ddia/pull/271) | [@Makonike](https://github.com/Makonike) | ch6: 统一了 rebalancing 的翻译 |
| [270](https://github.com/Vonng/ddia/pull/270) | [@Ynjxsjmh](https://github.com/Ynjxsjmh) | ch7: 修正不一致的翻译 |
| [263](https://github.com/Vonng/ddia/pull/263) | [@zydmayday](https://github.com/zydmayday) | ch5: 修正译文中的重复单词 |
| [260](https://github.com/Vonng/ddia/pull/260) | [@haifeiWu](https://github.com/haifeiWu) | ch4: 修正部分不准确的翻译 |
| [258](https://github.com/Vonng/ddia/pull/258) | [@bestgrc](https://github.com/bestgrc) | ch3: 修正一处翻译错误 |
| [257](https://github.com/Vonng/ddia/pull/257) | [@UnderSam](https://github.com/UnderSam) | ch8: 修正一处拼写错误 |
| [256](https://github.com/Vonng/ddia/pull/256) | [@AlphaWang](https://github.com/AlphaWang) | ch7: 修正“可串行化”相关内容的多处翻译不当 |
| [255](https://github.com/Vonng/ddia/pull/255) | [@AlphaWang](https://github.com/AlphaWang) | ch7: 修正“可重复读”相关内容的多处翻译不当 |
| [253](https://github.com/Vonng/ddia/pull/253) | [@AlphaWang](https://github.com/AlphaWang) | ch7: 修正“读已提交”相关内容的多处翻译不当 |
| [246](https://github.com/Vonng/ddia/pull/246) | [@derekwu0101](https://github.com/derekwu0101) | ch3: 修正繁体中文的转译错误 |
| [245](https://github.com/Vonng/ddia/pull/245) | [@skyran1278](https://github.com/skyran1278) | ch12: 修正繁体中文的转译错误 |
| [244](https://github.com/Vonng/ddia/pull/244) | [@Axlgrep](https://github.com/Axlgrep) | ch9: 修正不通顺的翻译 |
| [242](https://github.com/Vonng/ddia/pull/242) | [@lynkeib](https://github.com/lynkeib) | ch9: 修正不通顺的翻译 |
| [241](https://github.com/Vonng/ddia/pull/241) | [@lynkeib](https://github.com/lynkeib) | ch8: 修正不正确的公式格式 |
| [240](https://github.com/Vonng/ddia/pull/240) | [@8da2k](https://github.com/8da2k) | ch9: 修正不通顺的翻译 |
| [239](https://github.com/Vonng/ddia/pull/239) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch7: 修正不一致的翻译 |
| [237](https://github.com/Vonng/ddia/pull/237) | [@zhangnew](https://github.com/zhangnew) | ch3: 修正错误的图片链接 |
| [229](https://github.com/Vonng/ddia/pull/229) | [@lis186](https://github.com/lis186) | 指出繁体中文的转译错误:复杂 |
| [226](https://github.com/Vonng/ddia/pull/226) | [@chroming](https://github.com/chroming) | ch1: 修正导航栏中的章节名称 |
| [220](https://github.com/Vonng/ddia/pull/220) | [@skyran1278](https://github.com/skyran1278) | ch9: 修正线性一致的繁体中文翻译 |
| [194](https://github.com/Vonng/ddia/pull/194) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch4: 修正错误的翻译 |
| [193](https://github.com/Vonng/ddia/pull/193) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch4: 优化译文 |
| [192](https://github.com/Vonng/ddia/pull/192) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch4: 修正不一致和不通顺的翻译 |
| [190](https://github.com/Vonng/ddia/pull/190) | [@Pcrab](https://github.com/Pcrab) | ch1: 修正不准确的翻译 |
| [187](https://github.com/Vonng/ddia/pull/187) | [@narojay](https://github.com/narojay) | ch9: 修正生硬的翻译 |
| [186](https://github.com/Vonng/ddia/pull/186) | [@narojay](https://github.com/narojay) | ch8: 修正错别字 |
| [185](https://github.com/Vonng/ddia/issues/185) | [@8da2k](https://github.com/8da2k) | 指出小标题跳转的问题 |
| [184](https://github.com/Vonng/ddia/pull/184) | [@DavidZhiXing](https://github.com/DavidZhiXing) | ch10: 修正失效的网址 |
| [183](https://github.com/Vonng/ddia/pull/183) | [@OneSizeFitsQuorum](https://github.com/OneSizeFitsQuorum) | ch8: 修正错别字 |
| [182](https://github.com/Vonng/ddia/issues/182) | [@lroolle](https://github.com/lroolle) | 建议docsify的主题风格 |
| [181](https://github.com/Vonng/ddia/pull/181) | [@YunfengGao](https://github.com/YunfengGao) | ch2: 修正翻译错误 |
| [180](https://github.com/Vonng/ddia/pull/180) | [@skyran1278](https://github.com/skyran1278) | ch3: 指出繁体中文的转译错误 |
| [177](https://github.com/Vonng/ddia/pull/177) | [@exzhawk](https://github.com/exzhawk) | 支持 Github Pages 里的公式显示 |
| [176](https://github.com/Vonng/ddia/pull/176) | [@haifeiWu](https://github.com/haifeiWu) | ch2: 语义网相关翻译更正 |
| [175](https://github.com/Vonng/ddia/pull/175) | [@cwr31](https://github.com/cwr31) | ch7: 不变式相关翻译更正 |
| [174](https://github.com/Vonng/ddia/pull/174) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | README & preface: 更正不正确的中文用词和标点符号 |
| [173](https://github.com/Vonng/ddia/pull/173) | [@ZvanYang](https://github.com/ZvanYang) | ch12: 修正不完整的翻译 |
| [171](https://github.com/Vonng/ddia/pull/171) | [@ZvanYang](https://github.com/ZvanYang) | ch12: 修正重复的译文 |
| [169](https://github.com/Vonng/ddia/pull/169) | [@ZvanYang](https://github.com/ZvanYang) | ch12: 更正不太通顺的翻译 |
| [166](https://github.com/Vonng/ddia/pull/166) | [@bp4m4h94](https://github.com/bp4m4h94) | ch1: 发现错误的文献索引 |
| [164](https://github.com/Vonng/ddia/pull/164) | [@DragonDriver](https://github.com/DragonDriver) | preface: 更正错误的标点符号 |
| [163](https://github.com/Vonng/ddia/pull/163) | [@llmmddCoder](https://github.com/llmmddCoder) | ch1: 更正错误字 |
| [160](https://github.com/Vonng/ddia/pull/160) | [@Zhayhp](https://github.com/Zhayhp) | ch2: 建议将 network model 翻译为网状模型 |
| [159](https://github.com/Vonng/ddia/pull/159) | [@1ess](https://github.com/1ess) | ch4: 更正错误字 |
| [157](https://github.com/Vonng/ddia/pull/157) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通顺的翻译 |
| [155](https://github.com/Vonng/ddia/pull/155) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通顺的翻译 |
| [153](https://github.com/Vonng/ddia/pull/153) | [@DavidZhiXing](https://github.com/DavidZhiXing) | ch9: 修正缩略图的错别字 |
| [152](https://github.com/Vonng/ddia/pull/152) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 除重->去重 |
| [151](https://github.com/Vonng/ddia/pull/151) | [@ZvanYang](https://github.com/ZvanYang) | ch5: 修订sibling相关的翻译 |
| [147](https://github.com/Vonng/ddia/pull/147) | [@ZvanYang](https://github.com/ZvanYang) | ch5: 更正一处不准确的翻译 |
| [145](https://github.com/Vonng/ddia/pull/145) | [@Hookey](https://github.com/Hookey) | 识别了当前简繁转译过程中处理不当的地方,暂通过转换脚本规避 |
| [144](https://github.com/Vonng/ddia/issues/144) | [@secret4233](https://github.com/secret4233) | ch7: 不翻译`next-key locking` |
| [143](https://github.com/Vonng/ddia/issues/143) | [@imcheney](https://github.com/imcheney) | ch3: 更新残留的机翻段落 |
| [142](https://github.com/Vonng/ddia/issues/142) | [@XIJINIAN](https://github.com/XIJINIAN) | 建议去除段首的制表符 |
| [141](https://github.com/Vonng/ddia/issues/141) | [@Flyraty](https://github.com/Flyraty) | ch5: 发现一处错误格式的章节引用 |
| [140](https://github.com/Vonng/ddia/pull/140) | [@Bowser1704](https://github.com/Bowser1704) | ch5: 修正章节Summary中多处不通顺的翻译 |
| [139](https://github.com/Vonng/ddia/pull/139) | [@Bowser1704](https://github.com/Bowser1704) | ch2&ch3: 修正多处不通顺的或错误的翻译 |
| [137](https://github.com/Vonng/ddia/pull/137) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch5&ch6: 优化多处不通顺的或错误的翻译 |
| [134](https://github.com/Vonng/ddia/pull/134) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch4: 优化多处不通顺的或错误的翻译 |
| [133](https://github.com/Vonng/ddia/pull/133) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch3: 优化多处错误的或不通顺的翻译 |
| [132](https://github.com/Vonng/ddia/pull/132) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch3: 优化一处容易产生歧义的翻译 |
| [131](https://github.com/Vonng/ddia/pull/131) | [@rwwg4](https://github.com/rwwg4) | ch6: 修正两处错误的翻译 |
| [129](https://github.com/Vonng/ddia/pull/129) | [@anaer](https://github.com/anaer) | ch4: 修正两处强调文本和四处代码变量名称 |
| [128](https://github.com/Vonng/ddia/pull/128) | [@meilin96](https://github.com/meilin96) | ch5: 修正一处错误的引用 |
| [126](https://github.com/Vonng/ddia/pull/126) | [@cwr31](https://github.com/cwr31) | ch10: 修正一处错误的翻译(功能 -> 函数) |
| [125](https://github.com/Vonng/ddia/pull/125) | [@dch1228](https://github.com/dch1228) | ch2: 优化 how best 的翻译(如何以最佳方式) |
| [123](https://github.com/Vonng/ddia/pull/123) | [@yingang](https://github.com/yingang) | translation updates (chapter 9, TOC in readme, glossary, etc.) |
| [121](https://github.com/Vonng/ddia/pull/121) | [@yingang](https://github.com/yingang) | translation updates (chapter 5 to chapter 8) |
| [120](https://github.com/Vonng/ddia/pull/120) | [@jiong-han](https://github.com/jiong-han) | Typo fix: 呲之以鼻 -> 嗤之以鼻 |
| [119](https://github.com/Vonng/ddia/pull/119) | [@cclauss](https://github.com/cclauss) | Streamline file operations in convert() |
| [118](https://github.com/Vonng/ddia/pull/118) | [@yingang](https://github.com/yingang) | translation updates (chapter 2 to chapter 4) |
| [117](https://github.com/Vonng/ddia/pull/117) | [@feeeei](https://github.com/feeeei) | 统一每章的标题格式 |
| [115](https://github.com/Vonng/ddia/pull/115) | [@NageNalock](https://github.com/NageNalock) | 第七章病句修改: 重复词语 |
| [114](https://github.com/Vonng/ddia/pull/114) | [@Sunt-ing](https://github.com/Sunt-ing) | Update README.md: correct the book name |
| [113](https://github.com/Vonng/ddia/pull/113) | [@lpxxn](https://github.com/lpxxn) | 修改语句 |
| [112](https://github.com/Vonng/ddia/pull/112) | [@ibyte2011](https://github.com/ibyte2011) | Update ch9.md |
| [110](https://github.com/Vonng/ddia/pull/110) | [@lpxxn](https://github.com/lpxxn) | 读已写入数据 |
| [107](https://github.com/Vonng/ddia/pull/107) | [@abbychau](https://github.com/abbychau) | 單調鐘和好死还是赖活着 |
| [106](https://github.com/Vonng/ddia/pull/106) | [@enochii](https://github.com/enochii) | typo in ch2: fix braces typo |
| [105](https://github.com/Vonng/ddia/pull/105) | [@LiminCode](https://github.com/LiminCode) | Chronicle translation error |
| [104](https://github.com/Vonng/ddia/pull/104) | [@Sunt-ing](https://github.com/Sunt-ing) | several advice for better translation |
| [103](https://github.com/Vonng/ddia/pull/103) | [@Sunt-ing](https://github.com/Sunt-ing) | typo in ch4: should be 完成 rather than 完全 |
| [102](https://github.com/Vonng/ddia/pull/102) | [@Sunt-ing](https://github.com/Sunt-ing) | ch4: better-translation: 扼杀 → 破坏 |
| [101](https://github.com/Vonng/ddia/pull/101) | [@Sunt-ing](https://github.com/Sunt-ing) | typo in Ch4: should be "改变" rathr than "盖面" |
| [100](https://github.com/Vonng/ddia/pull/100) | [@LiminCode](https://github.com/LiminCode) | fix missing translation |
| [99 ](https://github.com/Vonng/ddia/pull/99) | [@mrdrivingduck](https://github.com/mrdrivingduck) | ch6: fix the word rebalancing |
| [98 ](https://github.com/Vonng/ddia/pull/98) | [@jacklightChen](https://github.com/jacklightChen) | fix ch7.md: fix wrong references |
| [97 ](https://github.com/Vonng/ddia/pull/97) | [@jenac](https://github.com/jenac) | 96 |
| [96 ](https://github.com/Vonng/ddia/pull/96) | [@PragmaTwice](https://github.com/PragmaTwice) | ch2: fix typo about 'may or may not be' |
| [95 ](https://github.com/Vonng/ddia/pull/95) | [@EvanMu96](https://github.com/EvanMu96) | fix translation of "the battle cry" in ch5 |
| [94 ](https://github.com/Vonng/ddia/pull/94) | [@kemingy](https://github.com/kemingy) | ch6: fix markdown and punctuations |
| [93 ](https://github.com/Vonng/ddia/pull/93) | [@kemingy](https://github.com/kemingy) | ch5: fix markdown and some typos |
| [92 ](https://github.com/Vonng/ddia/pull/92) | [@Gilbert1024](https://github.com/Gilbert1024) | Merge pull request #1 from Vonng/master |
| [88 ](https://github.com/Vonng/ddia/pull/88) | [@kemingy](https://github.com/kemingy) | fix typo for ch1, ch2, ch3, ch4 |
| [87 ](https://github.com/Vonng/ddia/pull/87) | [@wynn5a](https://github.com/wynn5a) | Update ch3.md |
| [86 ](https://github.com/Vonng/ddia/pull/86) | [@northmorn](https://github.com/northmorn) | Update ch1.md |
| [85 ](https://github.com/Vonng/ddia/pull/85) | [@sunbuhui](https://github.com/sunbuhui) | fix ch2.md: fix ch2 ambiguous translation |
| [84 ](https://github.com/Vonng/ddia/pull/84) | [@ganler](https://github.com/ganler) | Fix translation: use up |
| [83 ](https://github.com/Vonng/ddia/pull/83) | [@afunTW](https://github.com/afunTW) | Using OpenCC to convert from zh-cn to zh-tw |
| [82 ](https://github.com/Vonng/ddia/pull/82) | [@kangni](https://github.com/kangni) | fix gitbook url |
| [78 ](https://github.com/Vonng/ddia/pull/78) | [@hanyu2](https://github.com/hanyu2) | Fix unappropriated translation |
| [77 ](https://github.com/Vonng/ddia/pull/77) | [@Ozarklake](https://github.com/Ozarklake) | fix typo |
| [75 ](https://github.com/Vonng/ddia/pull/75) | [@2997ms](https://github.com/2997ms) | Fix typo |
| [74 ](https://github.com/Vonng/ddia/pull/74) | [@2997ms](https://github.com/2997ms) | Update ch9.md |
| [70 ](https://github.com/Vonng/ddia/pull/70) | [@2997ms](https://github.com/2997ms) | Update ch7.md |
| [67 ](https://github.com/Vonng/ddia/pull/67) | [@jiajiadebug](https://github.com/jiajiadebug) | fix issues in ch2 - ch9 and glossary |
| [66 ](https://github.com/Vonng/ddia/pull/66) | [@blindpirate](https://github.com/blindpirate) | Fix typo |
| [63 ](https://github.com/Vonng/ddia/pull/63) | [@haifeiWu](https://github.com/haifeiWu) | Update ch10.md |
| [62 ](https://github.com/Vonng/ddia/pull/62) | [@ych](https://github.com/ych) | fix ch1.md typesetting problem |
| [61 ](https://github.com/Vonng/ddia/pull/61) | [@xianlaioy](https://github.com/xianlaioy) | docs:钟-->种去掉ou |
| [60 ](https://github.com/Vonng/ddia/pull/60) | [@Zombo1296](https://github.com/Zombo1296) | 否则 -> 或者 |
| [59 ](https://github.com/Vonng/ddia/pull/59) | [@AlexanderMisel](https://github.com/AlexanderMisel) | 呼叫->调用,显着->显著 |
| [58 ](https://github.com/Vonng/ddia/pull/58) | [@ibyte2011](https://github.com/ibyte2011) | Update ch8.md |
| [55 ](https://github.com/Vonng/ddia/pull/55) | [@saintube](https://github.com/saintube) | ch8: 修改链接错误 |
| [54 ](https://github.com/Vonng/ddia/pull/54) | [@Panmax](https://github.com/Panmax) | Update ch2.md |
| [53 ](https://github.com/Vonng/ddia/pull/53) | [@ibyte2011](https://github.com/ibyte2011) | Update ch9.md |
| [52 ](https://github.com/Vonng/ddia/pull/52) | [@hecenjie](https://github.com/hecenjie) | Update ch1.md |
| [51 ](https://github.com/Vonng/ddia/pull/51) | [@latavin243](https://github.com/latavin243) | fix 修正ch3 ch4几处翻译 |
| [50 ](https://github.com/Vonng/ddia/pull/50) | [@AlexZFX](https://github.com/AlexZFX) | 几个疏漏和格式错误 |
| [49 ](https://github.com/Vonng/ddia/pull/49) | [@haifeiWu](https://github.com/haifeiWu) | Update ch1.md |
| [48 ](https://github.com/Vonng/ddia/pull/48) | [@scaugrated](https://github.com/scaugrated) | fix typo |
| [47 ](https://github.com/Vonng/ddia/pull/47) | [@lzwill](https://github.com/lzwill) | Fixed typos in ch2 |
| [45 ](https://github.com/Vonng/ddia/pull/45) | [@zenuo](https://github.com/zenuo) | 删除一个多余的右括号 |
| [44 ](https://github.com/Vonng/ddia/pull/44) | [@akxxsb](https://github.com/akxxsb) | 修正第七章底部链接错误 |
| [43 ](https://github.com/Vonng/ddia/pull/43) | [@baijinping](https://github.com/baijinping) | "更假简单"->"更加简单" |
| [42 ](https://github.com/Vonng/ddia/pull/42) | [@tisonkun](https://github.com/tisonkun) | 修复 ch1 中的无序列表格式 |
| [38 ](https://github.com/Vonng/ddia/pull/38) | [@renjie-c](https://github.com/renjie-c) | 纠正多处的翻译小错误 |
| [37 ](https://github.com/Vonng/ddia/pull/37) | [@tankilo](https://github.com/tankilo) | fix translation mistakes in ch4.md |
| [36 ](https://github.com/Vonng/ddia/pull/36) | [@wwek](https://github.com/wwek) | 1.修复多个链接错误 2.名词优化修订 3.错误修订 |
| [35 ](https://github.com/Vonng/ddia/pull/35) | [@wwek](https://github.com/wwek) | fix ch7.md to ch8.md link error |
| [34 ](https://github.com/Vonng/ddia/pull/34) | [@wwek](https://github.com/wwek) | Merge pull request #1 from Vonng/master |
| [33 ](https://github.com/Vonng/ddia/pull/33) | [@wwek](https://github.com/wwek) | fix part-ii.md link error |
| [32 ](https://github.com/Vonng/ddia/pull/32) | [@JCYoky](https://github.com/JCYoky) | Update ch2.md |
| [31 ](https://github.com/Vonng/ddia/pull/31) | [@elsonLee](https://github.com/elsonLee) | Update ch7.md |
| [26 ](https://github.com/Vonng/ddia/pull/26) | [@yjhmelody](https://github.com/yjhmelody) | 修复一些明显错误 |
| [25 ](https://github.com/Vonng/ddia/pull/25) | [@lqbilbo](https://github.com/lqbilbo) | 修复链接错误 |
| [24 ](https://github.com/Vonng/ddia/pull/24) | [@artiship](https://github.com/artiship) | 修改词语顺序 |
| [23 ](https://github.com/Vonng/ddia/pull/23) | [@artiship](https://github.com/artiship) | 修正错别字 |
| [22 ](https://github.com/Vonng/ddia/pull/22) | [@artiship](https://github.com/artiship) | 纠正翻译错误 |
| [21 ](https://github.com/Vonng/ddia/pull/21) | [@zhtisi](https://github.com/zhtisi) | 修正目录和本章标题不符的情况 |
| [20 ](https://github.com/Vonng/ddia/pull/20) | [@rentiansheng](https://github.com/rentiansheng) | Update ch7.md |
| [19 ](https://github.com/Vonng/ddia/pull/19) | [@LHRchina](https://github.com/LHRchina) | 修复语句小bug |
| [16 ](https://github.com/Vonng/ddia/pull/16) | [@MuAlex](https://github.com/MuAlex) | Master |
| [15 ](https://github.com/Vonng/ddia/pull/15) | [@cg-zhou](https://github.com/cg-zhou) | Update translation progress |
| [14 ](https://github.com/Vonng/ddia/pull/14) | [@cg-zhou](https://github.com/cg-zhou) | Translate glossary |
| [13 ](https://github.com/Vonng/ddia/pull/13) | [@cg-zhou](https://github.com/cg-zhou) | 详细修改了后记中和印度野猪相关的描述 |
| [12 ](https://github.com/Vonng/ddia/pull/12) | [@ibyte2011](https://github.com/ibyte2011) | 修改了部分翻译 |
| [11 ](https://github.com/Vonng/ddia/pull/11) | [@jiajiadebug](https://github.com/jiajiadebug) | ch2 100% |
| [10 ](https://github.com/Vonng/ddia/pull/10) | [@jiajiadebug](https://github.com/jiajiadebug) | ch2 20% |
| [9 ](https://github.com/Vonng/ddia/pull/9) | [@jiajiadebug](https://github.com/jiajiadebug) | Preface, ch1, part-i translation minor fixes |
| [7 ](https://github.com/Vonng/ddia/pull/7) | [@MuAlex](https://github.com/MuAlex) | Ch6 translation pull request |
| [6 ](https://github.com/Vonng/ddia/pull/6) | [@MuAlex](https://github.com/MuAlex) | Ch6 change version1 |
| [5 ](https://github.com/Vonng/ddia/pull/5) | [@nevertiree](https://github.com/nevertiree) | Chapter 01语法微调 |
| [2 ](https://github.com/Vonng/ddia/pull/2) | [@seagullbird](https://github.com/seagullbird) | 序言初翻 |

View File

@ -1,252 +1,256 @@
# 术语表
---
title: 术语表
weight: 500
breadcrumbs: false
---
> 请注意,本术语表中的定义简短而简单,旨在传达核心思想,而不是术语的完整细微之处。有关更多详细信息,请参阅正文中的参考资料。
> 请注意,本术语表中的定义简短而简单,旨在传达核心思想,而非死扣完整细节。有关更多详细信息,请参阅正文中的参考资料。
* **异步asynchronous**
## **异步asynchronous**
不等待某些事情完成(例如,将数据发送到网络中的另一个节点),并且不会假设要花多长时间。请参阅“[同步复制与异步复制](ch5.md#同步复制与异步复制)”、“[同步网络与异步网络](ch8.md#同步网络与异步网络)”以及“[系统模型与现实](ch8.md#系统模型与现实)”。
不等待某些事情完成(例如,将数据发送到网络中的另一个节点),并且不会假设要花多长时间。请参阅“[同步复制与异步复制](/ch5#同步复制与异步复制)”、“[同步网络与异步网络](/ch8#同步网络与异步网络)”以及“[系统模型与现实](/ch8#系统模型与现实)”。
* **原子atomic**
## **原子atomic**
在并发操作的上下文中:描述一个在单个时间点看起来生效的操作,所以另一个并发进程永远不会遇到处于“半完成”状态的操作。另见隔离。
在事务的上下文中:将一些写入操作分为一组,这组写入要么全部提交成功,要么遇到错误时全部回滚。请参阅“[原子性](ch7.md#原子性)”和“[原子提交与两阶段提交](ch9.md#原子提交与两阶段提交)”。
在事务的上下文中:将一些写入操作分为一组,这组写入要么全部提交成功,要么遇到错误时全部回滚。请参阅“[原子性](/ch7#原子性)”和“[原子提交与两阶段提交](/ch9#原子提交与两阶段提交)”。
* **背压backpressure**
## **背压backpressure**
接收方接收数据速度较慢时,强制降低发送方的数据发送速度。也称为流量控制。请参阅“[消息传递系统](ch11.md#消息传递系统)”。
接收方接收数据速度较慢时,强制降低发送方的数据发送速度。也称为流量控制。请参阅“[消息传递系统](/ch11#消息传递系统)”。
* **批处理batch process**
## **批处理batch process**
一种计算,它将一些固定的(通常是大的)数据集作为输入,并将其他一些数据作为输出,而不修改输入。见[第十章](ch10.md)。
一种计算,它将一些固定的(通常是大的)数据集作为输入,并将其他一些数据作为输出,而不修改输入。见[第十章](/ch10)。
* **边界bounded**
## **边界bounded**
有一些已知的上限或大小。例如,网络延迟情况(请参阅“[超时与无穷的延迟](ch8.md#超时与无穷的延迟)”)和数据集(请参阅[第十一章](ch11.md)的介绍)。
有一些已知的上限或大小。例如,网络延迟情况(请参阅“[超时与无穷的延迟](/ch8#超时与无穷的延迟)”)和数据集(请参阅[第十一章](/ch11)的介绍)。
* **拜占庭故障Byzantine fault**
## **拜占庭故障Byzantine fault**
表现异常的节点,这种异常可能以任意方式出现,例如向其他节点发送矛盾或恶意消息。请参阅“[拜占庭故障](ch8.md#拜占庭故障)”。
表现异常的节点,这种异常可能以任意方式出现,例如向其他节点发送矛盾或恶意消息。请参阅“[拜占庭故障](/ch8#拜占庭故障)”。
* **缓存cache**
## **缓存cache**
一种组件,通过存储最近使用过的数据,加快未来对相同数据的读取速度。缓存中通常存放部分数据:因此,如果缓存中缺少某些数据,则必须从某些底层较慢的数据存储系统中,获取完整的数据副本。
* **CAP定理CAP theorem**
## **CAP定理CAP theorem**
一个被广泛误解的理论结果,在实践中是没有用的。请参阅“[CAP定理](ch9.md#CAP定理)”。
一个被广泛误解的理论结果,在实践中是没有用的。请参阅“[CAP定理](/ch9#CAP定理)”。
* **因果关系causality**
## **因果关系causality**
事件之间的依赖关系,当一件事发生在另一件事情之前。例如,后面的事件是对早期事件的回应,或者依赖于更早的事件,或者应该根据先前的事件来理解。请参阅“[“此前发生”的关系和并发](ch5.md#“此前发生”的关系和并发)”和“[顺序与因果关系](ch9.md#顺序与因果关系)”。
事件之间的依赖关系,当一件事发生在另一件事情之前。例如,后面的事件是对早期事件的回应,或者依赖于更早的事件,或者应该根据先前的事件来理解。请参阅“[“此前发生”的关系和并发](/ch5#“此前发生”的关系和并发)”和“[顺序与因果关系](/ch9#顺序与因果关系)”。
* **共识consensus**
## **共识consensus**
分布式计算的一个基本问题,就是让几个节点同意某些事情(例如,哪个节点应该是数据库集群的领导者)。问题比乍看起来要困难得多。请参阅“[容错共识](ch9.md#容错共识)”。
分布式计算的一个基本问题,就是让几个节点同意某些事情(例如,哪个节点应该是数据库集群的领导者)。问题比乍看起来要困难得多。请参阅“[容错共识](/ch9#容错共识)”。
* **数据仓库data warehouse**
## **数据仓库data warehouse**
一个数据库其中来自几个不同的OLTP系统的数据已经被合并和准备用于分析目的。请参阅“[数据仓库](ch3.md#数据仓库)”。
一个数据库其中来自几个不同的OLTP系统的数据已经被合并和准备用于分析目的。请参阅“[数据仓库](/ch3#数据仓库)”。
* **声明式declarative**
## **声明式declarative**
描述某些东西应有的属性,但不知道如何实现它的确切步骤。在查询的上下文中,查询优化器采用声明性查询并决定如何最好地执行它。请参阅“[数据查询语言](ch2.md#数据查询语言)”。
描述某些东西应有的属性,但不知道如何实现它的确切步骤。在查询的上下文中,查询优化器采用声明性查询并决定如何最好地执行它。请参阅“[数据查询语言](/ch2#数据查询语言)”。
* **非规范化denormalize**
## **非规范化denormalize**
为了加速读取,在标准数据集中引入一些冗余或重复数据,通常采用缓存或索引的形式。非规范化的值是一种预先计算的查询结果,像物化视图。请参阅“[单对象和多对象操作](ch7.md#单对象和多对象操作)”和“[从同一事件日志中派生多个视图](ch11.md#从同一事件日志中派生多个视图)”。
为了加速读取,在标准数据集中引入一些冗余或重复数据,通常采用缓存或索引的形式。非规范化的值是一种预先计算的查询结果,像物化视图。请参阅“[单对象和多对象操作](/ch7#单对象和多对象操作)”和“[从同一事件日志中派生多个视图](/ch11#从同一事件日志中派生多个视图)”。
* **衍生数据derived data**
## **衍生数据derived data**
一种数据集,根据其他数据通过可重复运行的流程创建。必要时,你可以运行该流程再次创建衍生数据。衍生数据通常用于提高特定数据的读取速度。常见的衍生数据有索引、缓存和物化视图。请参阅[第三部分](part-iii.md)的介绍。
一种数据集,根据其他数据通过可重复运行的流程创建。必要时,你可以运行该流程再次创建衍生数据。衍生数据通常用于提高特定数据的读取速度。常见的衍生数据有索引、缓存和物化视图。请参阅[第三部分](/part-iii)的介绍。
* **确定性deterministic**
## **确定性deterministic**
描述一个函数,如果给它相同的输入,则总是产生相同的输出。这意味着它不能依赖于随机数字、时间、网络通信或其他不可预测的事情。
* **分布式distributed**
## **分布式distributed**
在由网络连接的多个节点上运行。对于部分节点故障,具有容错性:系统的一部分发生故障时,其他部分仍可以正常工作,通常情况下,软件无需了解故障相关的确切情况。请参阅“[故障与部分失效](ch8.md#故障与部分失效)”。
在由网络连接的多个节点上运行。对于部分节点故障,具有容错性:系统的一部分发生故障时,其他部分仍可以正常工作,通常情况下,软件无需了解故障相关的确切情况。请参阅“[故障与部分失效](/ch8#故障与部分失效)”。
* **持久durable**
## **持久durable**
以某种方式存储数据,即使发生各种故障,也不会丢失数据。请参阅“[持久性](ch7.md#持久性)”。
以某种方式存储数据,即使发生各种故障,也不会丢失数据。请参阅“[持久性](/ch7#持久性)”。
* **ETLExtract-Transform-Load**
## **ETLExtract-Transform-Load**
提取-转换-加载Extract-Transform-Load。从源数据库中提取数据将其转换为更适合分析查询的形式并将其加载到数据仓库或批处理系统中的过程。请参阅“[数据仓库](ch3.md#数据仓库)”。
提取-转换-加载Extract-Transform-Load。从源数据库中提取数据将其转换为更适合分析查询的形式并将其加载到数据仓库或批处理系统中的过程。请参阅“[数据仓库](/ch3#数据仓库)”。
* **故障切换failover**
## **故障切换failover**
在具有单一领导者的系统中,故障切换是将领导角色从一个节点转移到另一个节点的过程。请参阅“[处理节点宕机](ch5.md#处理节点宕机)”。
在具有单一领导者的系统中,故障切换是将领导角色从一个节点转移到另一个节点的过程。请参阅“[处理节点宕机](/ch5#处理节点宕机)”。
* **容错fault-tolerant**
## **容错fault-tolerant**
如果出现问题(例如,机器崩溃或网络连接失败),可以自动恢复。请参阅“[可靠性](ch1.md#可靠性)”。
如果出现问题(例如,机器崩溃或网络连接失败),可以自动恢复。请参阅“[可靠性](/ch1#可靠性)”。
* **流量控制flow control**
## **流量控制flow control**
见背压backpressure
* **追随者follower**
## **追随者follower**
一种数据副本,仅处理领导者或主库发出的数据变更,不直接接受来自客户端的任何写入。也称为备库、从库、只读副本或热备份。请参阅“[领导者与追随者](ch5.md#领导者与追随者)”。
一种数据副本,仅处理领导者或主库发出的数据变更,不直接接受来自客户端的任何写入。也称为备库、从库、只读副本或热备份。请参阅“[领导者与追随者](/ch5#领导者与追随者)”。
* **全文检索full-text search**
## **全文检索full-text search**
通过任意关键字来搜索文本,通常具有附加特征,例如匹配类似的拼写词或同义词。全文索引是一种支持这种查询的次级索引。请参阅“[全文搜索和模糊索引](ch3.md#全文搜索和模糊索引)”。
通过任意关键字来搜索文本,通常具有附加特征,例如匹配类似的拼写词或同义词。全文索引是一种支持这种查询的次级索引。请参阅“[全文搜索和模糊索引](/ch3#全文搜索和模糊索引)”。
* **图graph**
## **图graph**
一种数据结构,由顶点(可以指向的东西,也称为节点或实体)和边(从一个顶点到另一个顶点的连接,也称为关系或弧)组成。请参阅“[图数据模型](ch2.md#图数据模型)”。
一种数据结构,由顶点(可以指向的东西,也称为节点或实体)和边(从一个顶点到另一个顶点的连接,也称为关系或弧)组成。请参阅“[图数据模型](/ch2#图数据模型)”。
* **散列hash**
## **散列hash**
将输入转换为看起来像随机数值的函数。相同的输入会转换为相同的数值,不同的输入一般会转换为不同的数值,也可能转换为相同数值(也被称为冲突)。请参阅“[根据键的散列分区](ch6.md#根据键的散列分区)”。
将输入转换为看起来像随机数值的函数。相同的输入会转换为相同的数值,不同的输入一般会转换为不同的数值,也可能转换为相同数值(也被称为冲突)。请参阅“[根据键的散列分区](/ch6#根据键的散列分区)”。
* **幂等idempotent**
## **幂等idempotent**
用于描述一种操作可以安全地重试执行,即执行多次的效果和执行一次的效果相同。请参阅“[幂等性](ch11.md#幂等性)”。
用于描述一种操作可以安全地重试执行,即执行多次的效果和执行一次的效果相同。请参阅“[幂等性](/ch11#幂等性)”。
* **索引index**
## **索引index**
一种数据结构。通过索引,你可以根据特定字段的值,在所有数据记录中进行高效检索。请参阅“[驱动数据库的数据结构](ch3.md#驱动数据库的数据结构)”。
一种数据结构。通过索引,你可以根据特定字段的值,在所有数据记录中进行高效检索。请参阅“[驱动数据库的数据结构](/ch3#驱动数据库的数据结构)”。
* **隔离性isolation**
## **隔离性isolation**
在事务上下文中,用于描述并发执行事务的互相干扰程度。串行运行具有最强的隔离性,不过其它程度的隔离也通常被使用。请参阅“[隔离性](ch7.md#隔离性)”。
在事务上下文中,用于描述并发执行事务的互相干扰程度。串行运行具有最强的隔离性,不过其它程度的隔离也通常被使用。请参阅“[隔离性](/ch7#隔离性)”。
* **连接join**
## **连接join**
汇集有共同点的记录。在一个记录与另一个记录有关(外键,文档参考,图中的边)的情况下最常用,查询需要获取参考所指向的记录。请参阅“[多对一和多对多的关系](ch2.md#多对一和多对多的关系)”和“[Reduce侧连接与分组](ch10.md#Reduce侧连接与分组)”。
汇集有共同点的记录。在一个记录与另一个记录有关(外键,文档参考,图中的边)的情况下最常用,查询需要获取参考所指向的记录。请参阅“[多对一和多对多的关系](/ch2#多对一和多对多的关系)”和“[Reduce侧连接与分组](/ch10#Reduce侧连接与分组)”。
* **领导者leader**
## **领导者leader**
当数据或服务被复制到多个节点时,领导者是被指定为可以接受数据变更的副本。领导者可以通过某些协议选举产生,也可以由管理员手动选择。领导者也被称为主库。请参阅“[领导者与追随者](ch5.md#领导者与追随者)”。
当数据或服务被复制到多个节点时,领导者是被指定为可以接受数据变更的副本。领导者可以通过某些协议选举产生,也可以由管理员手动选择。领导者也被称为主库。请参阅“[领导者与追随者](/ch5#领导者与追随者)”。
* **线性化linearizable**
## **线性化linearizable**
表现为系统中只有一份通过原子操作更新的数据副本。请参阅“[线性一致性](ch9.md#线性一致性)”。
表现为系统中只有一份通过原子操作更新的数据副本。请参阅“[线性一致性](/ch9#线性一致性)”。
* **局部性locality**
## **局部性locality**
一种性能优化方式,如果经常在相同的时间请求一些离散数据,把这些数据放到一个位置。请参阅“[查询的数据局部性](ch2.md#查询的数据局部性)”。
一种性能优化方式,如果经常在相同的时间请求一些离散数据,把这些数据放到一个位置。请参阅“[查询的数据局部性](/ch2#查询的数据局部性)”。
* **锁lock**
## **锁lock**
一种保证只有一个线程、节点或事务可以访问的机制,如果其它线程、节点或事务想访问相同元素,则必须等待锁被释放。请参阅“[两阶段锁定](ch7.md#两阶段锁定)”和“[领导者和锁](ch8.md#领导者和锁)”。
一种保证只有一个线程、节点或事务可以访问的机制,如果其它线程、节点或事务想访问相同元素,则必须等待锁被释放。请参阅“[两阶段锁定](/ch7#两阶段锁定)”和“[领导者和锁](/ch8#领导者和锁)”。
* **日志log**
## **日志log**
日志是一个只能以追加方式写入的文件,用于存放数据。预写式日志用于在存储引擎崩溃时恢复数据(请参阅“[让B树更可靠](ch3.md#让B树更可靠)”);结构化日志存储引擎使用日志作为它的主要存储格式(请参阅“[SSTables和LSM树](ch3.md#SSTables和LSM树)”);复制型日志用于把写入从领导者复制到追随者(请参阅“[领导者与追随者](ch5.md#领导者与追随者)”);事件性日志可以表现为数据流(请参阅“[分区日志](ch11.md#分区日志)”)。
日志是一个只能以追加方式写入的文件,用于存放数据。预写式日志用于在存储引擎崩溃时恢复数据(请参阅“[让B树更可靠](/ch3#让B树更可靠)”);结构化日志存储引擎使用日志作为它的主要存储格式(请参阅“[SSTables和LSM树](/ch3#SSTables和LSM树)”);复制型日志用于把写入从领导者复制到追随者(请参阅“[领导者与追随者](/ch5#领导者与追随者)”);事件性日志可以表现为数据流(请参阅“[分区日志](/ch11#分区日志)”)。
* **物化materialize**
## **物化materialize**
急切地计算并写出结果,而不是在请求时计算。请参阅“[聚合:数据立方体和物化视图](ch3.md#聚合:数据立方体和物化视图)”和“[物化中间状态](ch10.md#物化中间状态)”。
急切地计算并写出结果,而不是在请求时计算。请参阅“[聚合:数据立方体和物化视图](/ch3#聚合:数据立方体和物化视图)”和“[物化中间状态](/ch10#物化中间状态)”。
* **节点node**
## **节点node**
计算机上运行的一些软件的实例,通过网络与其他节点通信以完成某项任务。
* **规范化normalized**
## **规范化normalized**
以没有冗余或重复的方式进行结构化。在规范化数据库中,当某些数据发生变化时,你只需要在一个地方进行更改,而不是在许多不同的地方复制很多次。请参阅“[多对一和多对多的关系](ch2.md#多对一和多对多的关系)”。
以没有冗余或重复的方式进行结构化。在规范化数据库中,当某些数据发生变化时,你只需要在一个地方进行更改,而不是在许多不同的地方复制很多次。请参阅“[多对一和多对多的关系](/ch2#多对一和多对多的关系)”。
* **OLAPOnline Analytic Processing**
## **OLAPOnline Analytic Processing**
在线分析处理。通过对大量记录进行聚合(例如,计数,总和,平均)来表征的访问模式。请参阅“[事务处理还是分析?](ch3.md#事务处理还是分析?)”。
在线分析处理。通过对大量记录进行聚合(例如,计数,总和,平均)来表征的访问模式。请参阅“[事务处理还是分析?](/ch3#事务处理还是分析?)”。
* **OLTPOnline Transaction Processing**
## **OLTPOnline Transaction Processing**
在线事务处理。访问模式的特点是快速查询,读取或写入少量记录,这些记录通常通过键索引。请参阅“[事务处理还是分析?](ch3.md#事务处理还是分析?)”。
在线事务处理。访问模式的特点是快速查询,读取或写入少量记录,这些记录通常通过键索引。请参阅“[事务处理还是分析?](/ch3#事务处理还是分析?)”。
* **分区partitioning**
## **分区partitioning**
将单机上的大型数据集或计算结果拆分为较小部分,并将其分布到多台机器上。也称为分片。见[第六章](ch6.md)。
将单机上的大型数据集或计算结果拆分为较小部分,并将其分布到多台机器上。也称为分片。见[第六章](/ch6)。
* **百分位点percentile**
## **百分位点percentile**
通过计算有多少值高于或低于某个阈值来衡量值分布的方法。例如某个时间段的第95个百分位响应时间是时间t则该时间段中95%的请求完成时间小于t5%的请求完成时间要比t长。请参阅“[描述性能](ch1.md#描述性能)”。
通过计算有多少值高于或低于某个阈值来衡量值分布的方法。例如某个时间段的第95个百分位响应时间是时间t则该时间段中95%的请求完成时间小于t5%的请求完成时间要比t长。请参阅“[描述性能](/ch1#描述性能)”。
* **主键primary key**
## **主键primary key**
唯一标识记录的值(通常是数字或字符串)。在许多应用程序中,主键由系统在创建记录时生成(例如,按顺序或随机); 它们通常不由用户设置。另请参阅次级索引。
* **法定人数quorum**
## **法定人数quorum**
在操作完成之前,需要对操作进行投票的最少节点数量。请参阅“[读写的法定人数](ch5.md#读写的法定人数)”。
在操作完成之前,需要对操作进行投票的最少节点数量。请参阅“[读写的法定人数](/ch5#读写的法定人数)”。
* **再平衡rebalance**
## **再平衡rebalance**
将数据或服务从一个节点移动到另一个节点以实现负载均衡。请参阅“[分区再平衡](ch6.md#分区再平衡)”。
将数据或服务从一个节点移动到另一个节点以实现负载均衡。请参阅“[分区再平衡](/ch6#分区再平衡)”。
* **复制replication**
## **复制replication**
在几个节点(副本)上保留相同数据的副本,以便在某些节点无法访问时,数据仍可访问。请参阅[第五章](ch5.md)。
在几个节点(副本)上保留相同数据的副本,以便在某些节点无法访问时,数据仍可访问。请参阅[第五章](/ch5)。
* **模式schema**
## **模式schema**
一些数据结构的描述,包括其字段和数据类型。可以在数据生命周期的不同点检查某些数据是否符合模式(请参阅“[文档模型中的模式灵活性](ch2.md#文档模型中的模式灵活性)”),模式可以随时间变化(请参阅[第四章](ch4.md))。
一些数据结构的描述,包括其字段和数据类型。可以在数据生命周期的不同点检查某些数据是否符合模式(请参阅“[文档模型中的模式灵活性](/ch2#文档模型中的模式灵活性)”),模式可以随时间变化(请参阅[第四章](/ch4))。
* **次级索引secondary index**
## **次级索引secondary index**
与主要数据存储器一起维护的附加数据结构,使你可以高效地搜索与某种条件相匹配的记录。请参阅“[其他索引结构](ch3.md#其他索引结构)”和“[分区与次级索引](ch6.md#分区与次级索引)”。
与主要数据存储器一起维护的附加数据结构,使你可以高效地搜索与某种条件相匹配的记录。请参阅“[其他索引结构](/ch3#其他索引结构)”和“[分区与次级索引](/ch6#分区与次级索引)”。
* **可串行化serializable**
## **可串行化serializable**
保证多个并发事务同时执行时,它们的行为与按顺序逐个执行事务相同。请参阅第七章的“[可串行化](ch7.md#可串行化)”。
保证多个并发事务同时执行时,它们的行为与按顺序逐个执行事务相同。请参阅第七章的“[可串行化](/ch7#可串行化)”。
* **无共享shared-nothing**
## **无共享shared-nothing**
与共享内存或共享磁盘架构相比独立节点每个节点都有自己的CPU内存和磁盘通过传统网络连接。见[第二部分](part-ii.md)的介绍。
与共享内存或共享磁盘架构相比独立节点每个节点都有自己的CPU内存和磁盘通过传统网络连接。见[第二部分](/part-ii)的介绍。
* **偏斜skew**
## **偏斜skew**
各分区负载不平衡,例如某些分区有大量请求或数据,而其他分区则少得多。也被称为热点。请参阅“[负载偏斜与热点消除](ch6.md#负载偏斜与热点消除)”和“[处理偏斜](ch10.md#处理偏斜)”。
各分区负载不平衡,例如某些分区有大量请求或数据,而其他分区则少得多。也被称为热点。请参阅“[负载偏斜与热点消除](/ch6#负载偏斜与热点消除)”和“[处理偏斜](/ch10#处理偏斜)”。
时间线异常导致事件以不期望的顺序出现。请参阅“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”中的关于读取偏差的讨论,“[写入偏差与幻读](ch7.md#写入偏差与幻读)”中的写入偏差以及“[有序事件的时间戳](ch8.md#有序事件的时间戳)”中的时钟偏斜。
时间线异常导致事件以不期望的顺序出现。请参阅“[快照隔离和可重复读](/ch7#快照隔离和可重复读)”中的关于读取偏差的讨论,“[写入偏差与幻读](/ch7#写入偏差与幻读)”中的写入偏差以及“[有序事件的时间戳](/ch8#有序事件的时间戳)”中的时钟偏斜。
* **脑裂split brain**
## **脑裂split brain**
两个节点同时认为自己是领导者的情况,这种情况可能违反系统担保。请参阅“[处理节点宕机](ch5.md#处理节点宕机)”和“[真相由多数所定义](ch8.md#真相由多数所定义)”。
两个节点同时认为自己是领导者的情况,这种情况可能违反系统担保。请参阅“[处理节点宕机](/ch5#处理节点宕机)”和“[真相由多数所定义](/ch8#真相由多数所定义)”。
* **存储过程stored procedure**
## **存储过程stored procedure**
一种对事务逻辑进行编码的方式,它可以完全在数据库服务器上执行,事务执行期间无需与客户端通信。请参阅“[真的串行执行](ch7.md#真的串行执行)”。
一种对事务逻辑进行编码的方式,它可以完全在数据库服务器上执行,事务执行期间无需与客户端通信。请参阅“[真的串行执行](/ch7#真的串行执行)”。
* **流处理stream process**
## **流处理stream process**
持续运行的计算。可以持续接收事件流作为输入,并得出一些输出。见[第十一章](ch11.md)。
持续运行的计算。可以持续接收事件流作为输入,并得出一些输出。见[第十一章](/ch11)。
* **同步synchronous**
## **同步synchronous**
异步的反义词。
* **记录系统system of record**
## **记录系统system of record**
一个保存主要权威版本数据的系统,也被称为真相的来源。首先在这里写入数据变更,其他数据集可以从记录系统衍生。请参阅[第三部分](part-iii.md)的介绍。
一个保存主要权威版本数据的系统,也被称为真相的来源。首先在这里写入数据变更,其他数据集可以从记录系统衍生。请参阅[第三部分](/part-iii)的介绍。
* **超时timeout**
## **超时timeout**
检测故障的最简单方法之一,即在一段时间内观察是否缺乏响应。但是,不可能知道超时是由于远程节点的问题还是网络中的问题造成的。请参阅“[超时与无穷的延迟](ch8.md#超时与无穷的延迟)”。
检测故障的最简单方法之一,即在一段时间内观察是否缺乏响应。但是,不可能知道超时是由于远程节点的问题还是网络中的问题造成的。请参阅“[超时与无穷的延迟](/ch8#超时与无穷的延迟)”。
* **全序total order**
## **全序total order**
一种比较事物的方法(例如时间戳),可以让你总是说出两件事中哪一件更大,哪件更小。总的来说,有些东西是无法比拟的(不能说哪个更大或更小)的顺序称为偏序。请参阅“[因果顺序不是全序的](ch9.md#因果顺序不是全序的)”。
一种比较事物的方法(例如时间戳),可以让你总是说出两件事中哪一件更大,哪件更小。总的来说,有些东西是无法比拟的(不能说哪个更大或更小)的顺序称为偏序。请参阅“[因果顺序不是全序的](/ch9#因果顺序不是全序的)”。
* **事务transaction**
## **事务transaction**
为了简化错误处理和并发问题,将几个读写操作分组到一个逻辑单元中。见[第七章](ch7.md)。
为了简化错误处理和并发问题,将几个读写操作分组到一个逻辑单元中。见[第七章](/ch7)。
* **两阶段提交2PC, two-phase commit**
## **两阶段提交2PC, two-phase commit**
一种确保多个数据库节点全部提交或全部中止事务的算法。请参阅“[原子提交与两阶段提交](ch9.md#原子提交与两阶段提交)”。
一种确保多个数据库节点全部提交或全部中止事务的算法。请参阅“[原子提交与两阶段提交](/ch9#原子提交与两阶段提交)”。
* **两阶段锁定2PL, two-phase locking**
## **两阶段锁定2PL, two-phase locking**
一种用于实现可串行化隔离的算法,该算法通过事务获取对其读取或写入的所有数据的锁,直到事务结束。请参阅“[两阶段锁定](ch7.md#两阶段锁定)”。
一种用于实现可串行化隔离的算法,该算法通过事务获取对其读取或写入的所有数据的锁,直到事务结束。请参阅“[两阶段锁定](/ch7#两阶段锁定)”。
* **无边界unbounded**
## **无边界unbounded**
没有任何已知的上限或大小。反义词是边界bounded

38
content/zh/part-i.md Normal file
View File

@ -0,0 +1,38 @@
---
title: 第一部分:数据系统基础
weight: 100
breadcrumbs: false
---
本书前四章介绍了数据系统底层的基础概念,无论是在单台机器上运行的单点数据系统,还是分布在多台机器上的分布式数据系统都适用。
1. [第一章](/ch1) 将介绍本书使用的术语和方法。**可靠性,可伸缩性和可维护性** ,这些词汇到底意味着什么?如何实现这些目标?
2. [第二章](/ch2) 将对几种不同的 **数据模型和查询语言** 进行比较。从程序员的角度看,这是数据库之间最明显的区别。不同的数据模型适用于不同的应用场景。
3. [第三章](/ch3) 将深入 **存储引擎** 内部,研究数据库如何在磁盘上摆放数据。不同的存储引擎针对不同的负载进行优化,选择合适的存储引擎对系统性能有巨大影响。
4. [第四章](/ch4) 将对几种不同的 **数据编码** 进行比较。特别研究了这些格式在应用需求经常变化、模式需要随时间演变的环境中表现如何。
第二部分将专门讨论在 **分布式数据系统** 中特有的问题。
## 索引
* [第一章:可靠性、可伸缩性和可维护性](/ch1)
* [关于数据系统的思考](/ch1#关于数据系统的思考)
* [可靠性](/ch1#可靠性)
* [可伸缩性](/ch1#可伸缩性)
* [可维护性](/ch1#可维护性)
* [本章小结](/ch1#本章小结)
* [第二章:数据模型与查询语言](/ch2)
* [关系模型与文档模型](/ch2#关系模型与文档模型)
* [数据查询语言](/ch2#数据查询语言)
* [图数据模型](/ch2#图数据模型)
* [本章小结](/ch2#本章小结)
* [第三章:存储与检索](/ch3)
* [驱动数据库的数据结构](/ch3#驱动数据库的数据结构)
* [事务处理还是分析?](/ch3#事务处理还是分析)
* [列式存储](/ch3#列式存储)
* [本章小结](/ch3#本章小结)
* [第四章:编码与演化](/ch4)
* [编码数据的格式](/ch4#编码数据的格式)
* [数据流的类型](/ch4#数据流的类型)
* [本章小结](/ch4#本章小结)

View File

@ -1,4 +1,8 @@
# 第二部分:分布式数据
---
title: 第二部分:分布式数据
weight: 200
breadcrumbs: false
---
> 一个成功的技术,现实的优先级必须高于公关,你可以糊弄别人,但糊弄不了自然规律。
>
@ -7,7 +11,7 @@
-------
在本书的 [第一部分](part-i.md) 中,我们讨论了数据系统的各个方面,但仅限于数据存储在单台机器上的情况。现在我们到了 [第二部分](part-ii.md),进入更高的层次,并提出一个问题:如果 **多台机器** 参与数据的存储和检索,会发生什么?
在本书的 [第一部分](/part-i) 中,我们讨论了数据系统的各个方面,但仅限于数据存储在单台机器上的情况。现在我们到了 [第二部分](/part-ii),进入更高的层次,并提出一个问题:如果 **多台机器** 参与数据的存储和检索,会发生什么?
你可能会出于各种各样的原因,希望将数据库分布到多台机器上:
@ -53,30 +57,55 @@
* 复制Replication
在几个不同的节点上保存数据的相同副本,可能放在不同的位置。复制提供了冗余:如果一些节点不可用,剩余的节点仍然可以提供数据服务。复制也有助于改善性能。[第五章](ch5.md) 将讨论复制。
在几个不同的节点上保存数据的相同副本,可能放在不同的位置。复制提供了冗余:如果一些节点不可用,剩余的节点仍然可以提供数据服务。复制也有助于改善性能。[第五章](/ch5) 将讨论复制。
* 分区 (Partitioning)
将一个大型数据库拆分成较小的子集(称为 **分区**,即 partitions从而不同的分区可以指派给不同的 **节点**nodes亦称 **分片**,即 sharding。[第六章](ch6.md) 将讨论分区。
将一个大型数据库拆分成较小的子集(称为 **分区**,即 partitions从而不同的分区可以指派给不同的 **节点**nodes亦称 **分片**,即 sharding。[第六章](/ch6) 将讨论分区。
复制和分区是不同的机制,但它们经常同时使用。如 [图 II-1](img/figii-1.png) 所示。
复制和分区是不同的机制,但它们经常同时使用。如 [图 II-1](/img/figii-1.png) 所示。
![](img/figii-1.png)
![](/img/figii-1.png)
**图 II-1 一个数据库切分为两个分区,每个分区都有两个副本**
理解了这些概念,就可以开始讨论在分布式系统中需要做出的困难抉择。[第七章](ch7.md) 将讨论 **事务Transaction**,这对于了解数据系统中可能出现的各种问题,以及我们可以做些什么很有帮助。[第八章](ch8.md) 和 [第九章](ch9.md) 将讨论分布式系统的根本局限性。
理解了这些概念,就可以开始讨论在分布式系统中需要做出的困难抉择。[第七章](/ch7) 将讨论 **事务Transaction**,这对于了解数据系统中可能出现的各种问题,以及我们可以做些什么很有帮助。[第八章](/ch8) 和 [第九章](/ch9) 将讨论分布式系统的根本局限性。
在本书的 [第三部分](part-iii.md) 中,将讨论如何将多个(可能是分布式的)数据存储集成为一个更大的系统,以满足复杂的应用需求。但首先,我们来聊聊分布式的数据。
在本书的 [第三部分](/part-iii) 中,将讨论如何将多个(可能是分布式的)数据存储集成为一个更大的系统,以满足复杂的应用需求。但首先,我们来聊聊分布式的数据。
## 索引
5. [复制](ch5.md)
6. [分区](ch6.md)
7. [事务](ch7.md)
8. [分布式系统的麻烦](ch8.md)
9. [一致性与共识](ch9.md)
* [第五章:复制](/ch5)
* [领导者与追随者](/ch5#领导者与追随者)
* [复制延迟问题](/ch5#复制延迟问题)
* [多主复制](/ch5#多主复制)
* [无主复制](/ch5#无主复制)
* [本章小结](/ch5#本章小结)
* [第六章:分区](/ch6)
* [分区与复制](/ch6#分区与复制)
* [键值数据的分区](/ch6#键值数据的分区)
* [分区与次级索引](/ch6#分区与次级索引)
* [分区再平衡](/ch6#分区再平衡)
* [请求路由](/ch6#请求路由)
* [本章小结](/ch6#本章小结)
* [第七章:事务](/ch7)
* [事务的棘手概念](/ch7#事务的棘手概念)
* [弱隔离级别](/ch7#弱隔离级别)
* [可串行化](/ch7#可串行化)
* [本章小结](/ch7#本章小结)
* [第八章:分布式系统的麻烦](/ch8)
* [故障与部分失效](/ch8#故障与部分失效)
* [不可靠的网络](/ch8#不可靠的网络)
* [不可靠的时钟](/ch8#不可靠的时钟)
* [知识、真相与谎言](/ch8#知识真相与谎言)
* [本章小结](/ch8#本章小结)
* [第九章:一致性与共识](/ch9)
* [一致性保证](/ch9#一致性保证)
* [线性一致性](/ch9#线性一致性)
* [顺序保证](/ch9#顺序保证)
* [分布式事务与共识](/ch9#分布式事务与共识)
* [本章小结](/ch9#本章小结)
## 参考文献
@ -84,10 +113,4 @@
1. Ulrich Drepper: “[What Every Programmer Should Know About Memory](https://people.freebsd.org/~lstewart/articles/cpumemory.pdf),” akkadia.org, November 21, 2007.
1. Ben Stopford: “[Shared Nothing vs. Shared Disk Architectures: An Independent View](http://www.benstopford.com/2009/11/24/understanding-the-shared-nothing-architecture/),” benstopford.com, November 24, 2009.
1. Michael Stonebraker: “[The Case for Shared Nothing](http://db.cs.berkeley.edu/papers/hpts85-nothing.pdf),” IEEE Database EngineeringBulletin, volume 9, number 1, pages 49, March 1986.
1. Frank McSherry, Michael Isard, and Derek G. Murray: “[Scalability! But at What COST?](http://www.frankmcsherry.org/assets/COST.pdf),” at 15th USENIX Workshop on Hot Topics in Operating Systems (HotOS),May 2015.
------
| 上一章 | 目录 | 下一章 |
| ---------------------------- | ------------------------------- | ---------------------- |
| [第四章:编码与演化](ch4.md) | [设计数据密集型应用](README.md) | [第五章:复制](ch5.md) |
1. Frank McSherry, Michael Isard, and Derek G. Murray: “[Scalability! But at What COST?](http://www.frankmcsherry.org/assets/COST.pdf),” at 15th USENIX Workshop on Hot Topics in Operating Systems (HotOS),May 2015.

View File

@ -1,6 +1,11 @@
# 第三部分:衍生数据
---
title: 第三部分:衍生数据
weight: 300
breadcrumbs: false
---
在本书的 [第一部分](part-i.md) 和 [第二部分](part-ii.md) 中,我们自底向上地把所有关于分布式数据库的主要考量都过了一遍。从数据在磁盘上的布局,一直到出现故障时分布式系统一致性的局限。但所有的讨论都假定了应用中只用了一种数据库。
在本书的 [第一部分](/part-i) 和 [第二部分](/part-ii) 中,我们自底向上地把所有关于分布式数据库的主要考量都过了一遍。从数据在磁盘上的布局,一直到出现故障时分布式系统一致性的局限。但所有的讨论都假定了应用中只用了一种数据库。
现实世界中的数据系统往往更为复杂。大型应用程序经常需要以多种方式访问和处理数据,没有一个数据库可以同时满足所有这些不同的需求。因此应用程序通常组合使用多种组件:数据存储、索引、缓存、分析系统等等,并实现在这些组件中移动数据的机制。
@ -28,17 +33,23 @@
## 章节概述
我们将从 [第十章](ch10.md) 开始,研究例如 MapReduce 这样 **面向批处理batch-oriented** 的数据流系统。对于建设大规模数据系统,我们将看到,它们提供了优秀的工具和思想。[第十一章](ch11.md) 将把这些思想应用到 **流式数据data streams** 中,使我们能用更低的延迟完成同样的任务。[第十二章](ch12.md) 将对本书进行总结,探讨如何使用这些工具来构建可靠,可伸缩和可维护的应用。
我们将从 [第十章](/ch10) 开始,研究例如 MapReduce 这样 **面向批处理batch-oriented** 的数据流系统。对于建设大规模数据系统,我们将看到,它们提供了优秀的工具和思想。[第十一章](/ch11) 将把这些思想应用到 **流式数据data streams** 中,使我们能用更低的延迟完成同样的任务。[第十二章](/ch12) 将对本书进行总结,探讨如何使用这些工具来构建可靠,可伸缩和可维护的应用。
## 索引
10. [批处理](ch10.md)
11. [流处理](ch11.md)
12. [数据系统的未来](ch12.md)
------
| 上一章 | 目录 | 下一章 |
| ------------------------------ | ------------------------------- | ------------------------- |
| [第九章:一致性与共识](ch9.md) | [设计数据密集型应用](README.md) | [第十章:批处理](ch10.md) |
* [第十章:批处理](/ch10)
* [使用Unix工具的批处理](/ch10#使用Unix工具的批处理)
* [MapReduce和分布式文件系统](/ch10#MapReduce和分布式文件系统)
* [MapReduce之后](/ch10#MapReduce之后)
* [本章小结](/ch10#本章小结)
* [第十一章:流处理](/ch11)
* [传递事件流](/ch11#传递事件流)
* [数据库与流](/ch11#数据库与流)
* [流处理](/ch11#流处理)
* [本章小结](/ch11#本章小结)
* [第十二章:数据系统的未来](/ch12)
* [数据集成](/ch12#数据集成)
* [分拆数据库](/ch12#分拆数据库)
* [将事情做正确](/ch12#将事情做正确)
* [做正确的事情](/ch12#做正确的事情)
* [本章小结](/ch12#本章小结)

View File

@ -1,4 +1,9 @@
# 序言
---
title: 序言
weight: 50
breadcrumbs: false
---
如果近几年从业于软件工程,特别是服务器端和后端系统开发,那么你很有可能已经被大量关于数据存储和处理的时髦词汇轰炸过了: NoSQL大数据Web-Scale分片最终一致性ACIDCAP 定理云服务MapReduce实时
@ -60,11 +65,11 @@
本书分为三部分:
1. 在 [第一部分](part-i.md) 中,我们会讨论设计数据密集型应用所赖的基本思想。我们从 [第一章](ch1.md) 开始,讨论我们实际要达到的目标:可靠性、可伸缩性和可维护性;我们该如何思考这些概念;以及如何实现它们。在 [第二章](ch2.md) 中,我们比较了几种不同的数据模型和查询语言,看看它们如何适用于不同的场景。在 [第三章](ch3.md) 中将讨论存储引擎:数据库如何在磁盘上摆放数据,以便能高效地再次找到它。[第四章](ch4.md) 转向数据编码(序列化),以及随时间演化的模式。
1. 在 [第一部分](/part-i) 中,我们会讨论设计数据密集型应用所赖的基本思想。我们从 [第一章](/ch1) 开始,讨论我们实际要达到的目标:可靠性、可伸缩性和可维护性;我们该如何思考这些概念;以及如何实现它们。在 [第二章](/ch2) 中,我们比较了几种不同的数据模型和查询语言,看看它们如何适用于不同的场景。在 [第三章](/ch3) 中将讨论存储引擎:数据库如何在磁盘上摆放数据,以便能高效地再次找到它。[第四章](/ch4) 转向数据编码(序列化),以及随时间演化的模式。
2. 在 [第二部分](part-ii.md) 中,我们从讨论存储在一台机器上的数据转向讨论分布在多台机器上的数据。这对于可伸缩性通常是必需的,但带来了各种独特的挑战。我们首先讨论复制([第五章](ch5.md))、分区 / 分片([第六章](ch6.md))和事务([第七章](ch7.md))。然后我们将探索关于分布式系统问题的更多细节([第八章](ch8.md)),以及在分布式系统中实现一致性与共识意味着什么([第九章](ch9.md))。
2. 在 [第二部分](/part-ii) 中,我们从讨论存储在一台机器上的数据转向讨论分布在多台机器上的数据。这对于可伸缩性通常是必需的,但带来了各种独特的挑战。我们首先讨论复制([第五章](/ch5))、分区 / 分片([第六章](/ch6))和事务([第七章](/ch7))。然后我们将探索关于分布式系统问题的更多细节([第八章](/ch8)),以及在分布式系统中实现一致性与共识意味着什么([第九章](/ch9))。
3. 在 [第三部分](part-iii.md) 中,我们讨论那些从其他数据集衍生出一些数据集的系统。衍生数据经常出现在异构系统中:当没有单个数据库可以把所有事情都做的很好时,应用需要集成几种不同的数据库、缓存、索引等。在 [第十章](ch10.md) 中我们将从一种衍生数据的批处理方法开始,然后在此基础上建立在 [第十一章](ch11.md) 中讨论的流处理。最后,在 [第十二章](ch12.md) 中,我们将所有内容汇总,讨论在将来构建可靠、可伸缩和可维护的应用程序的方法。
3. 在 [第三部分](/part-iii) 中,我们讨论那些从其他数据集衍生出一些数据集的系统。衍生数据经常出现在异构系统中:当没有单个数据库可以把所有事情都做的很好时,应用需要集成几种不同的数据库、缓存、索引等。在 [第十章](/ch10) 中我们将从一种衍生数据的批处理方法开始,然后在此基础上建立在 [第十一章](/ch11) 中讨论的流处理。最后,在 [第十二章](/ch12) 中,我们将所有内容汇总,讨论在将来构建可靠、可伸缩和可维护的应用程序的方法。
## 参考文献与延伸阅读

92
content/zh/toc.md Normal file
View File

@ -0,0 +1,92 @@
---
title: "目录"
linkTitle: "目录"
weight: 10
breadcrumbs: false
---
![](/img/title.png)
## [序言](/preface)
## [第一部分:数据系统基础](/part-i)
### [第一章:可靠性、可伸缩性和可维护性](/ch1)
* [关于数据系统的思考](/ch1#关于数据系统的思考)
* [可靠性](/ch1#可靠性)
* [可伸缩性](/ch1#可伸缩性)
* [可维护性](/ch1#可维护性)
* [本章小结](/ch1#本章小结)
### [第二章:数据模型与查询语言](/ch2)
* [关系模型与文档模型](/ch2#关系模型与文档模型)
* [数据查询语言](/ch2#数据查询语言)
* [图数据模型](/ch2#图数据模型)
* [本章小结](/ch2#本章小结)
### [第三章:存储与检索](/ch3)
* [驱动数据库的数据结构](/ch3#驱动数据库的数据结构)
* [事务处理还是分析?](/ch3#事务处理还是分析)
* [列式存储](/ch3#列式存储)
* [本章小结](/ch3#本章小结)
### [第四章:编码与演化](/ch4)
* [编码数据的格式](/ch4#编码数据的格式)
* [数据流的类型](/ch4#数据流的类型)
* [本章小结](/ch4#本章小结)
## [第二部分:分布式数据](/part-ii)
### [第五章:复制](/ch5)
* [领导者与追随者](/ch5#领导者与追随者)
* [复制延迟问题](/ch5#复制延迟问题)
* [多主复制](/ch5#多主复制)
* [无主复制](/ch5#无主复制)
* [本章小结](/ch5#本章小结)
### [第六章:分区](/ch6)
* [分区与复制](/ch6#分区与复制)
* [键值数据的分区](/ch6#键值数据的分区)
* [分区与次级索引](/ch6#分区与次级索引)
* [分区再平衡](/ch6#分区再平衡)
* [请求路由](/ch6#请求路由)
* [本章小结](/ch6#本章小结)
### [第七章:事务](/ch7)
* [事务的棘手概念](/ch7#事务的棘手概念)
* [弱隔离级别](/ch7#弱隔离级别)
* [可串行化](/ch7#可串行化)
* [本章小结](/ch7#本章小结)
### [第八章:分布式系统的麻烦](/ch8)
* [故障与部分失效](/ch8#故障与部分失效)
* [不可靠的网络](/ch8#不可靠的网络)
* [不可靠的时钟](/ch8#不可靠的时钟)
* [知识、真相与谎言](/ch8#知识真相与谎言)
* [本章小结](/ch8#本章小结)
### [第九章:一致性与共识](/ch9)
* [一致性保证](/ch9#一致性保证)
* [线性一致性](/ch9#线性一致性)
* [顺序保证](/ch9#顺序保证)
* [分布式事务与共识](/ch9#分布式事务与共识)
* [本章小结](/ch9#本章小结)
## [第三部分:衍生数据](/part-iii)
### [第十章:批处理](/ch10)
* [使用Unix工具的批处理](/ch10#使用Unix工具的批处理)
* [MapReduce和分布式文件系统](/ch10#MapReduce和分布式文件系统)
* [MapReduce之后](/ch10#MapReduce之后)
* [本章小结](/ch10#本章小结)
### [第十一章:流处理](/ch11)
* [传递事件流](/ch11#传递事件流)
* [数据库与流](/ch11#数据库与流)
* [流处理](/ch11#流处理)
* [本章小结](/ch11#本章小结)
### [第十二章:数据系统的未来](/ch12)
* [数据集成](/ch12#数据集成)
* [分拆数据库](/ch12#分拆数据库)
* [将事情做正确](/ch12#将事情做正确)
* [做正确的事情](/ch12#做正确的事情)
* [本章小结](/ch12#本章小结)
### [术语表](/glossary)
### [后记](/colophon)

View File

@ -1,26 +0,0 @@
# 第一部分:数据系统基础
本书前四章介绍了数据系统底层的基础概念,无论是在单台机器上运行的单点数据系统,还是分布在多台机器上的分布式数据系统都适用。
1. [第一章](ch1.md) 将介绍本书使用的术语和方法。**可靠性,可伸缩性和可维护性** ,这些词汇到底意味着什么?如何实现这些目标?
2. [第二章](ch2.md) 将对几种不同的 **数据模型和查询语言** 进行比较。从程序员的角度看,这是数据库之间最明显的区别。不同的数据模型适用于不同的应用场景。
3. [第三章](ch3.md) 将深入 **存储引擎** 内部,研究数据库如何在磁盘上摆放数据。不同的存储引擎针对不同的负载进行优化,选择合适的存储引擎对系统性能有巨大影响。
4. [第四章](ch4) 将对几种不同的 **数据编码** 进行比较。特别研究了这些格式在应用需求经常变化、模式需要随时间演变的环境中表现如何。
第二部分将专门讨论在 **分布式数据系统** 中特有的问题。
## 目录
1. [可靠性、可伸缩性和可维护性](ch1.md)
2. [数据模型与查询语言](ch2.md)
3. [存储与检索](ch3.md)
4. [编码与演化](ch4.md)
------
| 上一章 | 目录 | 下一章 |
| ------------------ | ------------------------------- | -------------------------------------------- |
| [序言](preface.md) | [设计数据密集型应用](README.md) | [第一章:可靠性、可伸缩性和可维护性](ch1.md) |