mirror of
https://github.com/DistSysCorp/ddia.git
synced 2025-01-30 14:20:21 +08:00
fix: refactor text format
This commit is contained in:
parent
25480f9a9f
commit
33a7a994c7
93
README.md
93
README.md
@ -1,68 +1,69 @@
|
||||
# 《DDIA 逐章精读》小册
|
||||
|
||||
在理解英文原文的基础上,结合我的一些工作经验,进行一些相应扩展,并参考 [github 上 Vonng 的中文翻译版](https://github.com/Vonng/ddia),对每一章用中文重新组织,作为每次分享的文字稿,在此集结为一本开源小册,并附上每章分享的**录屏视频**([b站](https://space.bilibili.com/30933812/channel/collectiondetail?sid=240551), [Youtube](https://www.youtube.com/playlist?list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g)),希望可以对有需要的同学有所帮助,水平所限,难免疏漏,如发现有任何有误之处,欢迎[提 issue](https://github.com/DistSysCorp/ddia/issues/new) 和 [PR](https://github.com/DistSysCorp/ddia/compare)。
|
||||
在理解英文原文的基础上,结合我的一些工作经验,进行一些相应扩展,并参考 [github 上 Vonng 的中文翻译版](https://github.com/Vonng/ddia),对每一章用中文重新组织,作为每次分享的文字稿,在此集结为一本开源小册,并附上每章分享的**录屏视频**([b 站](https://space.bilibili.com/30933812/channel/collectiondetail?sid=240551), [Youtube](https://www.youtube.com/playlist?list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g)),希望可以对有需要的同学有所帮助,水平所限,难免疏漏,如发现有任何有误之处,欢迎[提 issue](https://github.com/DistSysCorp/ddia/issues/new) 和 [PR](https://github.com/DistSysCorp/ddia/compare)。
|
||||
|
||||
## 目录
|
||||
|
||||
### [序](preface.md)
|
||||
|
||||
### 第一部分:数据系统基础
|
||||
|
||||
* [第一章:可靠、可扩展、可维护](ch01.md) [[b站](https://www.bilibili.com/video/BV1bY411L7HA) [Youtube](https://www.youtube.com/watch?v=geVsm3YGF4A&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=1)]
|
||||
* [本书为什么以数据系统为主题](ch01.md#本书为什么以数据系统为主题)
|
||||
* [可靠性](ch01.md#可靠性)
|
||||
* [可伸缩性](ch01.md#可伸缩性)
|
||||
* [可维护性](ch01.md#可维护性)
|
||||
* [第二章:数据模型和查询语言](ch02.md) [b站([上](https://www.bilibili.com/video/BV19a411C7UN) [下](https://www.bilibili.com/video/BV1BZ4y1r79M)) Youtube ([上](https://www.youtube.com/watch?v=1TVdmZ_sJCM&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=2) [下](https://www.youtube.com/watch?v=GdihZOSMuuI&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=3))]
|
||||
* [概要](ch02.md#概要)
|
||||
* [关系模型 vs 文档模型](ch02.md#关系模型与文档模型)
|
||||
* [数据查询语言](ch02.md#数据查询语言)
|
||||
* [图模型](ch02.md#图模型)
|
||||
* [第三章:存储与查询](ch03.md) [b站([上](https://www.bilibili.com/video/BV1mL411P72H/) [下](https://www.bilibili.com/video/BV1bL411A7ga)) Youtube([上](https://www.youtube.com/watch?v=tI1BmIZpK-c&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=4) [下](https://www.youtube.com/watch?v=8wiJSECzADc&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=5))]
|
||||
* [底层数据结构](ch03.md#驱动数据库的底层数据结构)
|
||||
* [事务型还是分析型](ch03.md#事务型还是分析型)
|
||||
* [列式存储](ch03.md#列存)
|
||||
* [第四章:编码和演进](ch04.md) [[b站](https://www.bilibili.com/video/BV1Aa411q7u9) [Youtube](https://www.youtube.com/watch?v=DqddHDVTkps&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=6)]
|
||||
* [数据编码的格式](ch04.md#数据编码的格式)
|
||||
* [几种数据流模型](ch04.md#几种数据流模型)
|
||||
- [第一章:可靠、可扩展、可维护](ch01.md) [[b 站](https://www.bilibili.com/video/BV1bY411L7HA) [Youtube](https://www.youtube.com/watch?v=geVsm3YGF4A&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=1)]
|
||||
- [本书为什么以数据系统为主题](ch01.md#本书为什么以数据系统为主题)
|
||||
- [可靠性](ch01.md#可靠性)
|
||||
- [可伸缩性](ch01.md#可伸缩性)
|
||||
- [可维护性](ch01.md#可维护性)
|
||||
- [第二章:数据模型和查询语言](ch02.md) [b 站 ([上](https://www.bilibili.com/video/BV19a411C7UN) [下](https://www.bilibili.com/video/BV1BZ4y1r79M)) Youtube ([上](https://www.youtube.com/watch?v=1TVdmZ_sJCM&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=2) [下](https://www.youtube.com/watch?v=GdihZOSMuuI&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=3))]
|
||||
- [概要](ch02.md#概要)
|
||||
- [关系模型 vs 文档模型](ch02.md#关系模型与文档模型)
|
||||
- [数据查询语言](ch02.md#数据查询语言)
|
||||
- [图模型](ch02.md#图模型)
|
||||
- [第三章:存储与查询](ch03.md) [b 站 ([上](https://www.bilibili.com/video/BV1mL411P72H/) [下](https://www.bilibili.com/video/BV1bL411A7ga)) Youtube([上](https://www.youtube.com/watch?v=tI1BmIZpK-c&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=4) [下](https://www.youtube.com/watch?v=8wiJSECzADc&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=5))]
|
||||
- [底层数据结构](ch03.md#驱动数据库的底层数据结构)
|
||||
- [事务型还是分析型](ch03.md#事务型还是分析型)
|
||||
- [列式存储](ch03.md#列存)
|
||||
- [第四章:编码和演进](ch04.md) [[b 站](https://www.bilibili.com/video/BV1Aa411q7u9) [Youtube](https://www.youtube.com/watch?v=DqddHDVTkps&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=6)]
|
||||
- [数据编码的格式](ch04.md#数据编码的格式)
|
||||
- [几种数据流模型](ch04.md#几种数据流模型)
|
||||
|
||||
### 第二部分:分布式数据
|
||||
|
||||
* [第五章:冗余](ch05.md) [ b站 ([上](https://www.bilibili.com/video/BV1VR4y1K7eK) [下](https://www.bilibili.com/video/BV1ou4116779)) Youtube([上](https://www.youtube.com/watch?v=pbURsaoKiYc&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=7) [下](https://www.youtube.com/watch?v=y23oqgIG7Vw&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=8))]
|
||||
* [领导者与跟随者](ch05.md#领导者与跟随者)
|
||||
* [复制滞后问题](ch05.md#复制滞后问题)
|
||||
* [多主模型](ch05.md#多主模型)
|
||||
* [无主模型](ch05.md#无主模型)
|
||||
* [第六章:分区](ch06.md) [b站 ([上](https://www.bilibili.com/video/BV1tY4y157Np) [下](https://www.bilibili.com/video/BV1AA4y1f7Hi)) Youtue ([上](https://www.youtube.com/watch?v=7vvycyhJn1s&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=9) [下](https://www.youtube.com/watch?v=aPeHdQgBmi4&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=10))]
|
||||
* [分片与复制](ch06.md#分片与复制)
|
||||
* [键值对集的分片](ch06.md#键值对集的分片)
|
||||
* [分片和次级索引](ch06.md#分片和次级索引)
|
||||
* [分片均衡](ch06.md#分片均衡)
|
||||
* [请求路由](ch06.md#请求路由)
|
||||
* [第七章:事务](ch07.md) [ b站 ([上](https://www.bilibili.com/video/BV1d94y117pW) [中](https://www.bilibili.com/video/BV1u3411w765) [下](https://www.bilibili.com/video/BV1Qr4y1M7Zm)) Youtube ([上](https://www.youtube.com/watch?v=gbExnxslpCs&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=11) [中](https://www.youtube.com/watch?v=sDKKhGFyUmk&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=12) [下](https://www.youtube.com/watch?v=Lhs6H6IgFvY&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=13))]
|
||||
* [棘手的概念](ch07.md#棘手的概念)
|
||||
* [几种弱隔离级别](ch07.md#几种弱隔离级别)
|
||||
* [可串行化](ch07.md#可串行化)
|
||||
* [第八章:分布式系统中的麻烦事](ch08.md) [b站([上](https://www.bilibili.com/video/BV1Ad4y1D7Yy) [中](https://www.bilibili.com/video/BV1114y1L7wU) [下](https://www.bilibili.com/video/BV1uG411A7GK)) Youtube ([上](https://www.youtube.com/watch?v=-q-yY_0aCsg&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=13) [中](https://www.youtube.com/watch?v=mk-QfuBV_NQ&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=14) [下](https://www.youtube.com/watch?v=xhk-X-rkLU4&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=15))]
|
||||
* [故障和部分失败](ch08.md#故障和部分失败)
|
||||
* [不可靠的网络](ch08.md#不可靠的网络)
|
||||
* [不可靠的时钟](ch08.md#不可靠的时钟)
|
||||
* [知识、真相和谎言](ch08.md#知识、真相和谎言)
|
||||
* [第九章:一致性和共识协议](ch09.md) [b站([上](https://www.bilibili.com/video/BV1eK411o73Q) [中](https://www.bilibili.com/video/BV1Y14y1P7xG) [下](https://www.bilibili.com/video/BV1r3411S7J2)) Youtube ([上](https://www.youtube.com/watch?v=Hq2gWib5n_I&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=17) [中](https://www.youtube.com/watch?v=Nds2xpnyS8A&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=18) [下](https://youtu.be/5ZCXSDMcerg))]
|
||||
* [一致性保证](ch09.md#一致性保证)
|
||||
* [线性一致性](ch09.md#线性一致性)
|
||||
* [顺序保证](ch09.md#顺序保证)
|
||||
* [分布式事务和共识协议](ch09.md#分布式事务和共识协议)
|
||||
- [第五章:冗余](ch05.md) [ b 站 ([上](https://www.bilibili.com/video/BV1VR4y1K7eK) [下](https://www.bilibili.com/video/BV1ou4116779)) Youtube([上](https://www.youtube.com/watch?v=pbURsaoKiYc&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=7) [下](https://www.youtube.com/watch?v=y23oqgIG7Vw&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=8))]
|
||||
- [领导者与跟随者](ch05.md#领导者与跟随者)
|
||||
- [复制滞后问题](ch05.md#复制滞后问题)
|
||||
- [多主模型](ch05.md#多主模型)
|
||||
- [无主模型](ch05.md#无主模型)
|
||||
- [第六章:分区](ch06.md) [b 站 ([上](https://www.bilibili.com/video/BV1tY4y157Np) [下](https://www.bilibili.com/video/BV1AA4y1f7Hi)) Youtue ([上](https://www.youtube.com/watch?v=7vvycyhJn1s&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=9) [下](https://www.youtube.com/watch?v=aPeHdQgBmi4&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=10))]
|
||||
- [分片与复制](ch06.md#分片与复制)
|
||||
- [键值对集的分片](ch06.md#键值对集的分片)
|
||||
- [分片和次级索引](ch06.md#分片和次级索引)
|
||||
- [分片均衡](ch06.md#分片均衡)
|
||||
- [请求路由](ch06.md#请求路由)
|
||||
- [第七章:事务](ch07.md) [ b 站 ([上](https://www.bilibili.com/video/BV1d94y117pW) [中](https://www.bilibili.com/video/BV1u3411w765) [下](https://www.bilibili.com/video/BV1Qr4y1M7Zm)) Youtube ([上](https://www.youtube.com/watch?v=gbExnxslpCs&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=11) [中](https://www.youtube.com/watch?v=sDKKhGFyUmk&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=12) [下](https://www.youtube.com/watch?v=Lhs6H6IgFvY&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=13))]
|
||||
- [棘手的概念](ch07.md#棘手的概念)
|
||||
- [几种弱隔离级别](ch07.md#几种弱隔离级别)
|
||||
- [可串行化](ch07.md#可串行化)
|
||||
- [第八章:分布式系统中的麻烦事](ch08.md) [b 站 ([上](https://www.bilibili.com/video/BV1Ad4y1D7Yy) [中](https://www.bilibili.com/video/BV1114y1L7wU) [下](https://www.bilibili.com/video/BV1uG411A7GK)) Youtube ([上](https://www.youtube.com/watch?v=-q-yY_0aCsg&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=13) [中](https://www.youtube.com/watch?v=mk-QfuBV_NQ&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=14) [下](https://www.youtube.com/watch?v=xhk-X-rkLU4&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=15))]
|
||||
- [故障和部分失败](ch08.md#故障和部分失败)
|
||||
- [不可靠的网络](ch08.md#不可靠的网络)
|
||||
- [不可靠的时钟](ch08.md#不可靠的时钟)
|
||||
- [知识、真相和谎言](ch08.md#知识、真相和谎言)
|
||||
- [第九章:一致性和共识协议](ch09.md) [b 站 ([上](https://www.bilibili.com/video/BV1eK411o73Q) [中](https://www.bilibili.com/video/BV1Y14y1P7xG) [下](https://www.bilibili.com/video/BV1r3411S7J2)) Youtube ([上](https://www.youtube.com/watch?v=Hq2gWib5n_I&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=17) [中](https://www.youtube.com/watch?v=Nds2xpnyS8A&list=PLSISRu2b2N54LGT3Pyef70ae8m0tAAo6g&index=18) [下](https://youtu.be/5ZCXSDMcerg))]
|
||||
- [一致性保证](ch09.md#一致性保证)
|
||||
- [线性一致性](ch09.md#线性一致性)
|
||||
- [顺序保证](ch09.md#顺序保证)
|
||||
- [分布式事务和共识协议](ch09.md#分布式事务和共识协议)
|
||||
|
||||
# DDIA 读书会
|
||||
|
||||
DDIA 读书分享会,会逐章进行分享,结合我在工业界分布式存储和数据库的一些经验,补充一些细节。每两周左右分享一次,欢迎加入,Schedule 在[这里](https://docs.qq.com/sheet/DWHFzdk5lUWx4UWJq)。我们有个对应的分布式&数据库讨论群,每次分享前会在群里通知。如想加入,可以加我的微信号:qtmuniao,简单自我介绍下,并注明:分布式系统群。另外,我的公众号:“木鸟杂记”,有更多的分布式系统、存储和数据库相关的文章,欢迎关注。
|
||||
|
||||
读书会安排和往期录屏: [https://docs.qq.com/sheet/DWHFzdk5lUWx4UWJq](https://docs.qq.com/sheet/DWHFzdk5lUWx4UWJq)
|
||||
读书会安排和往期录屏:[https://docs.qq.com/sheet/DWHFzdk5lUWx4UWJq](https://docs.qq.com/sheet/DWHFzdk5lUWx4UWJq)
|
||||
|
||||
# 关于我
|
||||
|
||||
我是青藤木鸟,一个喜欢摄影的分布系统程序员,现主要做数据库方向。可以通过[领英](https://www.linkedin.com/in/qtmuniao/),[b站](https://space.bilibili.com/30933812),[知乎](https://www.zhihu.com/people/qtmuniao) , 微信号(qtmuniao) 找到我。更多分布式系统文章欢迎关注我的公众号——[木鸟杂记](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=Mzg5NTcxNzY2OQ==&action=getalbum&album_id=2164896217070206977&scene=126&devicetype=iOS15.4&version=18001d33&lang=zh_CN&nettype=WIFI&ascene=59&session_us=gh_80636260f9f9&fontScale=106&wx_header=3)。同名博客:木鸟杂记 [https://www.qtmuniao.com](https://www.qtmuniao.com)
|
||||
我是青藤木鸟,一个喜欢摄影的分布系统程序员,现主要做数据库方向。可以通过[领英](https://www.linkedin.com/in/qtmuniao/),[b 站](https://space.bilibili.com/30933812),[知乎](https://www.zhihu.com/people/qtmuniao) , 微信号 (qtmuniao) 找到我。更多分布式系统文章欢迎关注我的公众号——[木鸟杂记](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=Mzg5NTcxNzY2OQ==&action=getalbum&album_id=2164896217070206977&scene=126&devicetype=iOS15.4&version=18001d33&lang=zh_CN&nettype=WIFI&ascene=59&session_us=gh_80636260f9f9&fontScale=106&wx_header=3)。同名博客:木鸟杂记 [https://www.qtmuniao.com](https://www.qtmuniao.com)
|
||||
|
||||
对于 Martin Kleppmann 博士的[DDIA](https://dataintensive.net/),每章大概我自己要读两遍英文版,然后自己翻译一遍,再理顺一遍词句,再找相关资料补充一些背景,最后再讲一遍,因此每章最少都得花十几个小时甚至几十个小时(比如事务那一章)。创作不易,如果感觉感觉对你有帮助的话,不妨分享给更多的人~ 这将对我是莫大的鼓励和支持。当然,也可以通过这个页面请我[吃顿饭](https://afdian.net/a/qtmuniao) ,顺便留个言。
|
||||
|
||||
最后,如果你想对某些章节讨论的话,欢迎来我搭的一个专门的分布式系统论坛:https://distsys.cn/ 。 感谢阅读,感谢关注。
|
||||
最后,如果你想对某些章节讨论的话,欢迎来我搭的一个专门的分布式系统论坛:https://distsys.cn/ 。感谢阅读,感谢关注。
|
||||
|
106
ch01.md
106
ch01.md
@ -8,7 +8,6 @@
|
||||
|
||||
因此,作为 IT 从业人员,有必要系统性的了解一下现代的、分布式的数据系统。学习本书,能够学习到数据系统的背后的原理、了解其常见的实践、进而将其应用到我们工作的系统设计中。
|
||||
|
||||
|
||||
## 常见的数据系统有哪些
|
||||
|
||||
- 存储数据,以便之后再次使用——**数据库**
|
||||
@ -49,16 +48,12 @@
|
||||
如何衡量可靠性?
|
||||
|
||||
- **功能上**
|
||||
1. 正常情况下,应用行为满足 API 给出的行为
|
||||
2. 在用户误输入/误操作时,能够正常处理
|
||||
1. 正常情况下,应用行为满足 API 给出的行为
|
||||
2. 在用户误输入/误操作时,能够正常处理
|
||||
- **性能上**
|
||||
|
||||
在给定硬件和数据量下,能够满足承诺的性能指标。
|
||||
|
||||
在给定硬件和数据量下,能够满足承诺的性能指标。
|
||||
- **安全上**
|
||||
|
||||
能够阻止未授权、恶意破坏。
|
||||
|
||||
能够阻止未授权、恶意破坏。
|
||||
|
||||
可用性也是可靠性的一个侧面,云服务通常以多少个 9 来衡量可用性。
|
||||
|
||||
@ -83,9 +78,7 @@
|
||||
数据系统中常见的需要考虑的硬件指标:
|
||||
|
||||
- **MTTF mean time to failure**
|
||||
|
||||
单块盘 平均故障时间 5 ~10 年,如果你有 1w+ 硬盘,则均匀期望下,每天都有坏盘出现。当然事实是硬盘会一波一波坏。
|
||||
|
||||
单块盘 平均故障时间 5 ~10 年,如果你有 1w+ 硬盘,则均匀期望下,每天都有坏盘出现。当然事实是硬盘会一波一波坏。
|
||||
|
||||
解决办法,增加冗余度:
|
||||
|
||||
@ -93,9 +86,9 @@
|
||||
|
||||
对于数据:
|
||||
|
||||
**单机**:可以做RAID 冗余。如:EC 编码。
|
||||
**单机**:可以做 RAID 冗余。如:EC 编码。
|
||||
|
||||
**多机**:多副本 or EC 编码。
|
||||
**多机**:多副本 or EC 编码。
|
||||
|
||||
## 软件错误
|
||||
|
||||
@ -113,21 +106,19 @@
|
||||
系统中最不稳定的是人,因此要在设计层面尽可能消除人对系统影响。依据软件的生命周期,分几个阶段来考虑:
|
||||
|
||||
- **设计编码**
|
||||
1. 尽可能消除所有不必要的假设,提供合理的抽象,仔细设计 API
|
||||
2. 进程间进行隔离,对尤其容易出错的模块使用沙箱机制
|
||||
3. 对服务依赖进行熔断设计
|
||||
1. 尽可能消除所有不必要的假设,提供合理的抽象,仔细设计 API
|
||||
2. 进程间进行隔离,对尤其容易出错的模块使用沙箱机制
|
||||
3. 对服务依赖进行熔断设计
|
||||
- **测试阶段**
|
||||
1. 尽可能引入第三方成员测试,尽量将测试平台自动化
|
||||
2. 单元测试、集成测试、e2e 测试、混沌测试
|
||||
1. 尽可能引入第三方成员测试,尽量将测试平台自动化
|
||||
2. 单元测试、集成测试、e2e 测试、混沌测试
|
||||
- **运行阶段**
|
||||
1. 详细的仪表盘
|
||||
2. 持续自检
|
||||
3. 报警机制
|
||||
4. 问题预案
|
||||
1. 详细的仪表盘
|
||||
2. 持续自检
|
||||
3. 报警机制
|
||||
4. 问题预案
|
||||
- **针对组织**
|
||||
|
||||
科学的培训和管理
|
||||
|
||||
科学的培训和管理
|
||||
|
||||
## 可靠性有多重要?
|
||||
|
||||
@ -144,14 +135,14 @@
|
||||
应对负载之前,要先找到合适的方法来衡量负载,如**负载参数(load parameters)**:
|
||||
|
||||
- 应用日活月活
|
||||
- 每秒向Web服务器发出的请求
|
||||
- 每秒向 Web 服务器发出的请求
|
||||
- 数据库中的读写比率
|
||||
- 聊天室中同时活跃的用户数量
|
||||
|
||||
书中以 Twitter 2012年11 披露的信息为例进行了说明:
|
||||
书中以 Twitter 2012 年 11 月披露的信息为例进行了说明:
|
||||
|
||||
1. 识别主营业务:发布推文、首页 Feed 流。
|
||||
2. 确定其请求量级:发布推文(平均 4.6k请求/秒,峰值超过 12k请求/秒),查看其他人推文(300k请求/秒)
|
||||
2. 确定其请求量级:发布推文(平均 4.6k 请求/秒,峰值超过 12k 请求/秒),查看其他人推文(300k 请求/秒)
|
||||
|
||||
![twitter-table.png](img/ch01-fig02.png)
|
||||
|
||||
@ -172,29 +163,25 @@
|
||||
|
||||
注意和系统负载区分,系统负载是从用户视角来审视系统,是一种**客观指标**。而系统性能则是描述的系统的一种**实际能力**。比如:
|
||||
|
||||
1. **吞吐量(throughput)**: 每秒可以处理的单位数据量,通常记为 QPS。
|
||||
2. **响应时间(response time)**: 从用户侧观察到的发出请求到收到回复的时间。
|
||||
1. **吞吐量(throughput)**:每秒可以处理的单位数据量,通常记为 QPS。
|
||||
2. **响应时间(response time)**:从用户侧观察到的发出请求到收到回复的时间。
|
||||
3. **延迟(latency)**:日常中,延迟经常和响应时间混用指代响应时间;但严格来说,延迟只是只请求过程中排队等休眠时间,虽然其在响应时间中一般占大头;但只有我们把请求真正处理耗时认为是瞬时,延迟才能等同于响应时间。
|
||||
|
||||
响应时间通常以百分位点来衡量,比如 p95,p99和 p999,它们意味着95%,99%或 99.9% 的请求都能在该阈值内完成。在实际中,通常使用滑动窗口滚动计算最近一段时间的响应时间分布,并通常以折线图或者柱状图进行呈现。
|
||||
响应时间通常以百分位点来衡量,比如 p95,p99 和 p999,它们意味着 95%,99%或 99.9% 的请求都能在该阈值内完成。在实际中,通常使用滑动窗口滚动计算最近一段时间的响应时间分布,并通常以折线图或者柱状图进行呈现。
|
||||
|
||||
## 应对负载
|
||||
|
||||
在有了描述和定义负载、性能的手段之后,终于来到正题,如何应对负载的不断增长,即使系统具有可伸缩性。
|
||||
|
||||
1. **纵向伸缩(scaling up)or 垂直伸缩(vertical scaling)**:换具有更强大性能的机器。e.g. 大型机机器学习训练。
|
||||
2. **横向伸缩(scaling out)or 水平伸缩(horizontal scaling)**:“并联”很多廉价机,分摊负载。 e.g. 马斯克造火箭。
|
||||
1. **纵向伸缩(scaling up)or 垂直伸缩(vertical scaling)**:换具有更强大性能的机器。e.g. 大型机机器学习训练。
|
||||
2. **横向伸缩(scaling out)or 水平伸缩(horizontal scaling)**:“并联”很多廉价机,分摊负载。e.g. 马斯克造火箭。
|
||||
|
||||
负载伸缩的两种方式:
|
||||
|
||||
- **自动**
|
||||
|
||||
如果负载不好预测且多变,则自动较好。坏处在于不易跟踪负载,容易抖动,造成资源浪费。
|
||||
|
||||
如果负载不好预测且多变,则自动较好。坏处在于不易跟踪负载,容易抖动,造成资源浪费。
|
||||
- **手动**
|
||||
|
||||
如果负载容易预测且不长变化,最好手动。设计简单,且不容易出错。
|
||||
|
||||
如果负载容易预测且不长变化,最好手动。设计简单,且不容易出错。
|
||||
|
||||
针对不同应用场景:
|
||||
|
||||
@ -207,15 +194,11 @@
|
||||
两种服务类型:
|
||||
|
||||
- **无状态服务**
|
||||
|
||||
比较简单,多台机器,外层罩一个 gateway 就行。
|
||||
|
||||
比较简单,多台机器,外层罩一个 gateway 就行。
|
||||
- **有状态服务**
|
||||
|
||||
根据需求场景,如读写负载、存储量级、数据复杂度、响应时间、访问模式,来进行取舍,设计合乎需求的架构。
|
||||
|
||||
根据需求场景,如读写负载、存储量级、数据复杂度、响应时间、访问模式,来进行取舍,设计合乎需求的架构。
|
||||
|
||||
**不可能啥都要,没有万金油架构**! 但同时:万变不离其宗,组成不同架构的原子设计模式是有限的,这也是本书稍后要论述的重点。
|
||||
**不可能啥都要,没有万金油架构**!但同时:万变不离其宗,组成不同架构的原子设计模式是有限的,这也是本书稍后要论述的重点。
|
||||
|
||||
# 可维护性
|
||||
|
||||
@ -223,18 +206,12 @@
|
||||
|
||||
但大部分人都喜欢挖坑,不喜欢填坑。因此有必要,在刚开就把坑开的足够好。有三个原则:
|
||||
|
||||
- ***可维护性(Operability)***
|
||||
|
||||
便于运维团队无痛接手。
|
||||
|
||||
- ***简洁性(Simplicity)***
|
||||
|
||||
便于新手开发平滑上手:这需要一个合理的抽象,并尽量消除各种复杂度。如,层次化抽象。
|
||||
|
||||
- ***可演化性(Evolvability)***
|
||||
|
||||
便于后面需求快速适配:避免耦合过紧,将代码绑定到某种实现上。也称为**可扩展性(extensibility)**,**可修改性(modifiability)** 或**可塑性(plasticity)**。
|
||||
|
||||
- **_可维护性(Operability)_**
|
||||
便于运维团队无痛接手。
|
||||
- **_简洁性(Simplicity)_**
|
||||
便于新手开发平滑上手:这需要一个合理的抽象,并尽量消除各种复杂度。如,层次化抽象。
|
||||
- **_可演化性(Evolvability)_**
|
||||
便于后面需求快速适配:避免耦合过紧,将代码绑定到某种实现上。也称为**可扩展性(extensibility)**,**可修改性(modifiability)** 或**可塑性(plasticity)**。
|
||||
|
||||
## **可运维性(Operability):人生苦短,关爱运维**
|
||||
|
||||
@ -269,9 +246,9 @@
|
||||
2. 组件间的强耦合。
|
||||
3. 不一致的术语和[命名](https://www.qtmuniao.com/2021/12/12/how-to-write-code-scrutinize-names/)。
|
||||
4. 为了提升性能的 hack。
|
||||
5. 随处可见的补丁( workaround)。
|
||||
5. 随处可见的补丁(workaround)。
|
||||
|
||||
需求很简单,但不妨碍你实现的很复杂 😉:过多的引入了**额外复杂度**(*accidental* complexity
|
||||
需求很简单,但不妨碍你实现的很复杂 😉:过多的引入了**额外复杂度**(_accidental_ complexity
|
||||
)——非问题本身决定的,而由实现所引入的复杂度。
|
||||
|
||||
通常是问题理解的不够本质,写出了“**流水账**”(没有任何**抽象,abstraction**)式的代码。
|
||||
@ -307,9 +284,6 @@
|
||||
应对之道:
|
||||
|
||||
- 项目管理上
|
||||
|
||||
敏捷开发
|
||||
|
||||
敏捷开发
|
||||
- 系统设计上
|
||||
|
||||
依赖前两点。合理抽象,合理封装,对修改关闭,对扩展开放。
|
||||
依赖前两点。合理抽象,合理封装,对修改关闭,对扩展开放。
|
||||
|
305
ch02.md
305
ch02.md
@ -17,9 +17,8 @@
|
||||
|
||||
## 数据模型
|
||||
|
||||
> A **data model** is an [abstract model](https://en.wikipedia.org/wiki/Abstract_model) that organizes elements of [data](https://en.wikipedia.org/wiki/Data) and standardizes how they relate to one another and to the properties of real-world entities.。
|
||||
—[https://en.wikipedia.org/wiki/Data_model](https://en.wikipedia.org/wiki/Data_model)
|
||||
>
|
||||
> A **data model** is an [abstract model](https://en.wikipedia.org/wiki/Abstract_model) that organizes elements of [data](https://en.wikipedia.org/wiki/Data) and standardizes how they relate to one another and to the properties of real-world entities.
|
||||
> —[https://en.wikipedia.org/wiki/Data_model](https://en.wikipedia.org/wiki/Data_model)
|
||||
|
||||
**数据模型**:如何组织数据,如何标准化关系,如何关联现实。
|
||||
|
||||
@ -34,12 +33,11 @@
|
||||
每层模型核心问题:如何用下一层的接口来对本层进行建模?
|
||||
|
||||
1. 作为**应用开发者,** 你将现实中的具体问题抽象为一组对象、**数据结构(data structure)** 以及作用于其上的 API。
|
||||
2. 作为**数据库管理员(DBA)**,为了持久化上述数据结构,你需要将他们表达为通用的**数据模型(data model)**,如文档数据库中的XML/JSON、关系数据库中的表、图数据库中的图。
|
||||
2. 作为**数据库管理员(DBA)**,为了持久化上述数据结构,你需要将他们表达为通用的**数据模型(data model)**,如文档数据库中的 XML/JSON、关系数据库中的表、图数据库中的图。
|
||||
3. 作为**数据库系统开发者**,你需要将上述数据模型组织为内存中、硬盘中或者网络中的**字节(Bytes)** 流,并提供多种操作数据集合的方法。
|
||||
4. 作为**硬件工程师**,你需要将字节流表示为二极管的电位(内存)、磁场中的磁极(磁盘)、光纤中的光信号(网络)。
|
||||
|
||||
> 在每一层,通过对外暴露简洁的**数据模型**,我们**隔离**和**分解**了现实世界的**复杂度**。
|
||||
>
|
||||
|
||||
这也反过来说明了,好的数据模型需有两个特点:
|
||||
|
||||
@ -48,7 +46,6 @@
|
||||
|
||||
第二章首先探讨了关系模型、文档模型及其对比,其次是相关查询语言,最后探讨了图模型。
|
||||
|
||||
|
||||
# 关系模型与文档模型
|
||||
|
||||
## 关系模型
|
||||
@ -66,13 +63,13 @@
|
||||
2. 分析型(AP):数据报表、监控表盘
|
||||
3. 混合型(HTAP):
|
||||
|
||||
关系模型诞生很多年后,虽有不时有各种挑战者(比如上世纪七八十年代的**网状模型** network model 和**层次模型** hierarchical model ),但始终仍未有根本的能撼动其地位的新模型。
|
||||
关系模型诞生很多年后,虽有不时有各种挑战者(比如上世纪七八十年代的**网状模型** network model 和**层次模型** hierarchical model),但始终仍未有根本的能撼动其地位的新模型。
|
||||
|
||||
直到近十年来,随着移动互联网的普及,数据爆炸性增长,各种处理需求越来越精细化,催生了数据模型的百花齐放。
|
||||
|
||||
## NoSQL 的诞生
|
||||
|
||||
NoSQL(最初表示Non-SQL,后来有人转解为Not only SQL),是对不同于传统的关系数据库的数据库管理系统的统称。根据 [DB-Engines 排名](https://db-engines.com/en/ranking),现在最受欢迎的 NoSQL 前几名为:MongoDB,Redis,ElasticSearch,Cassandra。
|
||||
NoSQL(最初表示 Non-SQL,后来有人转解为 Not only SQL),是对不同于传统的关系数据库的数据库管理系统的统称。根据 [DB-Engines 排名](https://db-engines.com/en/ranking),现在最受欢迎的 NoSQL 前几名为:MongoDB,Redis,ElasticSearch,Cassandra。
|
||||
|
||||
其催动因素有:
|
||||
|
||||
@ -101,8 +98,6 @@ NoSQL(最初表示Non-SQL,后来有人转解为Not only SQL),是对不
|
||||
2. **更好的局部性**:一个人的所有属性被集中访问的同时,也被集中存储。
|
||||
3. **结构表达语义**:简历与联系信息、教育经历、职业信息等隐含一对多的树状关系可以被 JSON 的树状结构明确表达出来。
|
||||
|
||||
|
||||
|
||||
## 多对一和多对多
|
||||
|
||||
是一个对比各种数据模型的切入角度。
|
||||
@ -138,8 +133,7 @@ region 在存储时,为什么不直接存储纯字符串:“Greater Seattle
|
||||
|
||||
20 世纪 70 年代,IBM 的信息管理系统 IMS。
|
||||
|
||||
> A **hierarchical database model** is a [data model](https://en.wikipedia.org/wiki/Data_model) in which the data are organized into a [tree](https://en.wikipedia.org/wiki/Tree_data_structure)-like structure. The data are stored as **records** which are connected to one another through **links.** A record is a collection of fields, with each field containing only one value. The **type** of a record defines which fields the record contains. — wikipedia
|
||||
>
|
||||
> A **hierarchical database model** is a [data model](https://en.wikipedia.org/wiki/Data_model) in which the data are organized into a [tree](https://en.wikipedia.org/wiki/Tree_data_structure)-like structure. The data are stored as **records** which are connected to one another through **links.** A record is a collection of fields, with each field containing only one value. The **type** of a record defines which fields the record contains. — wikipedia
|
||||
|
||||
几个要点:
|
||||
|
||||
@ -167,15 +161,10 @@ network model 是 hierarchical model 的一种扩展:允许一个节点有多
|
||||
在关系模型中,数据被组织成**元组(tuples)**,进而集合成**关系(relations)**;在 SQL 中分别对应行(rows)和表(tables)。
|
||||
|
||||
- 不知道大家好奇过没,明明看起来更像表模型,为什叫**关系模型**?
|
||||
|
||||
表只是一种实现。
|
||||
|
||||
关系(relation)的说法来自集合论,指的是几个集合的笛卡尔积的子集。
|
||||
|
||||
R ⊆ (D1×D2×D3 ··· ×Dn)
|
||||
|
||||
(关系用符号 R 表示,属性用符号 Ai 表示,属性的定义域用符号 Di 表示)
|
||||
|
||||
表只是一种实现。
|
||||
关系(relation)的说法来自集合论,指的是几个集合的笛卡尔积的子集。
|
||||
R ⊆ (D1×D2×D3 ··· ×Dn)
|
||||
(关系用符号 R 表示,属性用符号 Ai 表示,属性的定义域用符号 Di 表示)
|
||||
|
||||
其主要目的和贡献在于提供了一种**声明式**的描述数据和构建查询的方法。
|
||||
|
||||
@ -187,14 +176,13 @@ network model 是 hierarchical model 的一种扩展:允许一个节点有多
|
||||
|
||||
根据数据类型来选择数据模型
|
||||
|
||||
| | 文档型 | 关系型 |
|
||||
| --- | --- | --- |
|
||||
| 对应关系 | 数据有天然的一对多、树形嵌套关系,如简历。 | 通过外键+ Join 可以处理 多对一,多对多关系 |
|
||||
| 代码简化 | 数据具有文档结构,则文档模型天然合适,用关系模型会使得建模繁琐、访问复杂。但不宜嵌套太深,因为只能手动指定访问路径,或者范围遍历 | 主键,索引,条件过滤 |
|
||||
| Join 支持 | 对 Join 支持的不太好 | 支持的还可以,但 Join 的实现会有很多难点 |
|
||||
| 模式灵活性 | 弱 schema,支持动态增加字段 | 强 schema,修改 schema 代价很大 |
|
||||
| 访问局部性 | 1. 一次性访问整个文档,较优 <br/>2. 只访问文档一部分,较差 | 分散在多个表中 |
|
||||
|
||||
| | 文档型 | 关系型 |
|
||||
| ---------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| 对应关系 | 数据有天然的一对多、树形嵌套关系,如简历。 | 通过外键 + Join 可以处理 多对一,多对多关系 |
|
||||
| 代码简化 | 数据具有文档结构,则文档模型天然合适,用关系模型会使得建模繁琐、访问复杂。但不宜嵌套太深,因为只能手动指定访问路径,或者范围遍历 | 主键,索引,条件过滤 |
|
||||
| Join 支持 | 对 Join 支持的不太好 | 支持的还可以,但 Join 的实现会有很多难点 |
|
||||
| 模式灵活性 | 弱 schema,支持动态增加字段 | 强 schema,修改 schema 代价很大 |
|
||||
| 访问局部性 | 1. 一次性访问整个文档,较优 <br/>2. 只访问文档一部分,较差 | 分散在多个表中 |
|
||||
|
||||
对于高度关联的数据集,使用文档型表达比较奇怪,使用关系型可以接受,使用图模型最自然。
|
||||
|
||||
@ -202,10 +190,10 @@ network model 是 hierarchical model 的一种扩展:允许一个节点有多
|
||||
|
||||
说文档型数据库是 schemaless 不太准确,更贴切的应该是 **schema-on-read。**
|
||||
|
||||
| 数据模型 | | 编程语言 | | 性能 & 空间 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| schema-on-read | 写入时不校验,而在读取时进行动态解析。 | 弱类型 | 动态,在运行时解析 | 读取时动态解析,性能较差。写入时无法确定类型,无法对齐空间利用率较差。 |
|
||||
| schema-on-write | 写入时校验,数据对齐到 schema | 强类型 | 静态,编译时确定 | 性能和空间使用都较优。 |
|
||||
| 数据模型 | | 编程语言 | | 性能 & 空间 |
|
||||
| --------------- | -------------------------------------- | -------- | ------------------ | ---------------------------------------------------------------------- |
|
||||
| schema-on-read | 写入时不校验,而在读取时进行动态解析。 | 弱类型 | 动态,在运行时解析 | 读取时动态解析,性能较差。写入时无法确定类型,无法对齐空间利用率较差。 |
|
||||
| schema-on-write | 写入时校验,数据对齐到 schema | 强类型 | 静态,编译时确定 | 性能和空间使用都较优。 |
|
||||
|
||||
文档型数据库使用场景特点:
|
||||
|
||||
@ -227,12 +215,8 @@ network model 是 hierarchical model 的一种扩展:允许一个节点有多
|
||||
### 关系型和文档型的融合
|
||||
|
||||
- MySQL 和 PostgreSQL 开始支持 JSON
|
||||
|
||||
原生支持 JSON 可以理解为,MySQL 可以理解 JSON 格式。如 Date 格式一样,可以把某个字段作为 JSON 格式,可以修改其中的某个字段,可以在其中某个字段建立索引。
|
||||
|
||||
原生支持 JSON 可以理解为,MySQL 可以理解 JSON 格式。如 Date 格式一样,可以把某个字段作为 JSON 格式,可以修改其中的某个字段,可以在其中某个字段建立索引。
|
||||
- RethinkDB 在查询中支持 relational-link Joins
|
||||
|
||||
|
||||
|
||||
科德(Codd):**nonsimple domains**,记录中的值除了简单类型(数字、字符串),还可以一个嵌套关系(表)。这很像 SQL 对 XML、JSON 的支持。
|
||||
|
||||
@ -241,13 +225,14 @@ network model 是 hierarchical model 的一种扩展:允许一个节点有多
|
||||
获取动物表中所有鲨鱼类动物。
|
||||
|
||||
```jsx
|
||||
function getSharks() { var sharks = [];
|
||||
for (var i = 0; i < animals.length; i++) {
|
||||
if (animals[i].family === "Sharks") {
|
||||
sharks.push(animals[i]);
|
||||
}
|
||||
}
|
||||
return sharks;
|
||||
function getSharks() {
|
||||
var sharks = [];
|
||||
for (var i = 0; i < animals.length; i++) {
|
||||
if (animals[i].family === 'Sharks') {
|
||||
sharks.push(animals[i]);
|
||||
}
|
||||
}
|
||||
return sharks;
|
||||
}
|
||||
```
|
||||
|
||||
@ -255,18 +240,19 @@ function getSharks() { var sharks = [];
|
||||
SELECT * FROM animals WHERE family = 'Sharks';
|
||||
```
|
||||
|
||||
| | 声明式(declarative)语言 | 命令式(imperative)语言 |
|
||||
| --- | --- | --- |
|
||||
| 概念 | 描述控制逻辑而非执行流程 | 描述命令的执行过程,用一系列语句来不断改变状态 |
|
||||
| 举例 | SQL,CSS,XSL | IMS,CODASYL,通用语言如 C,C++,JS |
|
||||
| 抽象程度 | 高 | 低 |
|
||||
| 解耦程度 | 与实现解耦。 <br/>可以持续优化查询引擎性能; | 与实现耦合较深。
|
||||
| 解析执行 | 词法分析→ 语法分析 → 语义分析 <br/>生成执行计划→ 执行计划优化 | 词法分析→ 语法分析 → 语义分析 <br/>中间代码生成→ 代码优化 → 目标代码生成 |
|
||||
| 多核并行 | 声明式更具多核潜力,给了更多运行时优化空间 | 命令式由于指定了代码执行顺序,编译时优化空间较小。 |
|
||||
| | 声明式(declarative)语言 | 命令式(imperative)语言 |
|
||||
| -------- | --------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| 概念 | 描述控制逻辑而非执行流程 | 描述命令的执行过程,用一系列语句来不断改变状态 |
|
||||
| 举例 | SQL,CSS,XSL | IMS,CODASYL,通用语言如 C,C++,JS |
|
||||
| 抽象程度 | 高 | 低 |
|
||||
| 解耦程度 | 与实现解耦。 <br/>可以持续优化查询引擎性能; | 与实现耦合较深。 |
|
||||
| 解析执行 | 词法分析 → 语法分析 → 语义分析 <br/>生成执行计划 → 执行计划优化 | 词法分析 → 语法分析 → 语义分析 <br/>中间代码生成 → 代码优化 → 目标代码生成 |
|
||||
| 多核并行 | 声明式更具多核潜力,给了更多运行时优化空间 | 命令式由于指定了代码执行顺序,编译时优化空间较小。 |
|
||||
|
||||
> Q:相对声明式语言,命令式语言有什么优点?
|
||||
> 1. 当描述的目标变得复杂时,声明式表达能力不够。
|
||||
> 2. 实现命令式的语言往往不会和声明式那么泾渭分明,通过合理抽象,通过一些编程范式(函数式),可以让代码兼顾表达力和清晰性。
|
||||
>
|
||||
> 1. 当描述的目标变得复杂时,声明式表达能力不够。
|
||||
> 2. 实现命令式的语言往往不会和声明式那么泾渭分明,通过合理抽象,通过一些编程范式(函数式),可以让代码兼顾表达力和清晰性。
|
||||
|
||||
## 数据库以外:Web 中的声明式
|
||||
|
||||
@ -274,37 +260,37 @@ SELECT * FROM animals WHERE family = 'Sharks';
|
||||
|
||||
```html
|
||||
<ul>
|
||||
<li class="selected">
|
||||
<p>Sharks</p>
|
||||
<ul>
|
||||
<li>Great White Shark</li>
|
||||
<li>Tiger Shark</li>
|
||||
<li>Hammerhead Shark</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<p>Whales</p>
|
||||
<ul>
|
||||
<li>Blue Whale</li>
|
||||
<li>Humpback Whale</li>
|
||||
<li>Fin Whale</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="selected">
|
||||
<p>Sharks</p>
|
||||
<ul>
|
||||
<li>Great White Shark</li>
|
||||
<li>Tiger Shark</li>
|
||||
<li>Hammerhead Shark</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<p>Whales</p>
|
||||
<ul>
|
||||
<li>Blue Whale</li>
|
||||
<li>Humpback Whale</li>
|
||||
<li>Fin Whale</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
如果使用 CSS,则只需(CSS selector):
|
||||
|
||||
```css
|
||||
li.selected > p {
|
||||
background-color: blue;
|
||||
li.selected > p {
|
||||
background-color: blue;
|
||||
}
|
||||
```
|
||||
|
||||
如果使用 XSL,则只需(XPath selector):
|
||||
|
||||
```css
|
||||
<xsl:template match="li[@class='selected']/p">
|
||||
<xsl:template match="li[@class='selected']/p">
|
||||
<fo:block background-color="blue">
|
||||
<xsl:apply-templates/>
|
||||
</fo:block>
|
||||
@ -314,17 +300,17 @@ li.selected > p {
|
||||
但如果使用 JavaScript(而不借助上述 selector 库):
|
||||
|
||||
```jsx
|
||||
var liElements = document.getElementsByTagName("li");
|
||||
var liElements = document.getElementsByTagName('li');
|
||||
for (var i = 0; i < liElements.length; i++) {
|
||||
if (liElements[i].className === "selected") {
|
||||
var children = liElements[i].childNodes;
|
||||
for (var j = 0; j < children.length; j++) {
|
||||
var child = children[j];
|
||||
if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "P") {
|
||||
child.setAttribute("style", "background-color: blue");
|
||||
if (liElements[i].className === 'selected') {
|
||||
var children = liElements[i].childNodes;
|
||||
for (var j = 0; j < children.length; j++) {
|
||||
var child = children[j];
|
||||
if (child.nodeType === Node.ELEMENT_NODE && child.tagName === 'P') {
|
||||
child.setAttribute('style', 'background-color: blue');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@ -334,11 +320,11 @@ for (var i = 0; i < liElements.length; i++) {
|
||||
|
||||
1. 借鉴自函数式编程。
|
||||
2. 一种相当简单的编程模型,或者说原子的抽象,现在不太够用。
|
||||
3. 但在大数据处理工具匮乏的蛮荒时代(03年以前),谷歌提出的这套框架相当有开创性。
|
||||
3. 但在大数据处理工具匮乏的蛮荒时代(03 年以前),谷歌提出的这套框架相当有开创性。
|
||||
|
||||
![how maprduce works](img/ch02-how-mr-works.png)
|
||||
|
||||
**MongoDB 的 MapReduce 模型**
|
||||
**MongoDB 的 MapReduce 模型**
|
||||
|
||||
MongoDB 使用的 MapReduce 是一种介于
|
||||
|
||||
@ -354,7 +340,7 @@ MongoDB 使用的 MapReduce 是一种介于
|
||||
**PostgresSQL**
|
||||
|
||||
```sql
|
||||
SELECT date_trunc('month', observation_timestamp) AS observation_month,
|
||||
SELECT date_trunc('month', observation_timestamp) AS observation_month,
|
||||
sum(num_animals) AS total_animals
|
||||
FROM observations
|
||||
WHERE family = 'Sharks' GROUP BY observation_month;
|
||||
@ -364,22 +350,24 @@ WHERE family = 'Sharks' GROUP BY observation_month;
|
||||
|
||||
```jsx
|
||||
db.observations.mapReduce(
|
||||
function map() { // 2. 对所有符合条件 doc 执行 map
|
||||
var year = this.observationTimestamp.getFullYear();
|
||||
var month = this.observationTimestamp.getMonth() + 1;
|
||||
emit(year + "-" + month, this.numAnimals); // 3. 输出一个 kv pair
|
||||
},
|
||||
function reduce(key, values) { // 4. 按 key 聚集
|
||||
return Array.sum(values); // 5. 相同 key 加和
|
||||
},
|
||||
function map() {
|
||||
// 2. 对所有符合条件 doc 执行 map
|
||||
var year = this.observationTimestamp.getFullYear();
|
||||
var month = this.observationTimestamp.getMonth() + 1;
|
||||
emit(year + '-' + month, this.numAnimals); // 3. 输出一个 kv pair
|
||||
},
|
||||
function reduce(key, values) {
|
||||
// 4. 按 key 聚集
|
||||
return Array.sum(values); // 5. 相同 key 加和
|
||||
},
|
||||
{
|
||||
query: { family: "Sharks" }, // 1. 筛选
|
||||
out: "monthlySharkReport" // 6. reduce 结果集
|
||||
}
|
||||
query: { family: 'Sharks' }, // 1. 筛选
|
||||
out: 'monthlySharkReport', // 6. reduce 结果集
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
上述语句在执行时,经历了:筛选→ 遍历并执行 map → 对输出按 key 聚集(shuffle)→ 对聚集的数据注意 reduce → 输出结果集。
|
||||
上述语句在执行时,经历了:筛选 → 遍历并执行 map → 对输出按 key 聚集(shuffle)→ 对聚集的数据注意 reduce → 输出结果集。
|
||||
|
||||
MapReduce 一些特点:
|
||||
|
||||
@ -392,30 +380,29 @@ MapReduce 一些特点:
|
||||
2. 不是只有 MapReduce 才允许嵌入通用语言(如 js)模块。
|
||||
3. MapReduce 是有一定**理解成本**的,需要熟悉其执行逻辑才能让两个函数紧密配合。
|
||||
|
||||
MongoDB 2.2+ 进化版,*aggregation pipeline:*
|
||||
MongoDB 2.2+ 进化版,_aggregation pipeline:_
|
||||
|
||||
```jsx
|
||||
db.observations.aggregate([
|
||||
{ $match: { family: "Sharks" } },
|
||||
{ $group: {
|
||||
_id: {
|
||||
year: { $year: "$observationTimestamp" },
|
||||
month: { $month: "$observationTimestamp" }
|
||||
},
|
||||
totalAnimals: { $sum: "$numAnimals" } }}
|
||||
{ $match: { family: 'Sharks' } },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
year: { $year: '$observationTimestamp' },
|
||||
month: { $month: '$observationTimestamp' },
|
||||
},
|
||||
totalAnimals: { $sum: '$numAnimals' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
# 图模型
|
||||
|
||||
- 文档模型的适用场景?
|
||||
|
||||
你的数据集中存在着大量**一对多**(one-to-many)的关系。
|
||||
|
||||
你的数据集中存在着大量**一对多**(one-to-many)的关系。
|
||||
- 图模型的适用场景?
|
||||
|
||||
你的数据集中存在大量的**多对多**(many-to-many)的关系。
|
||||
|
||||
你的数据集中存在大量的**多对多**(many-to-many)的关系。
|
||||
|
||||
## 基本概念
|
||||
|
||||
@ -423,18 +410,16 @@ db.observations.aggregate([
|
||||
|
||||
常见的可以用图建模的场景:
|
||||
|
||||
| 例子 | 建模 | 应用 |
|
||||
| --- | --- | --- |
|
||||
| 社交图谱 | 人是点, follow 关系是边 | 六度分隔,信息流推荐 |
|
||||
| 互联网 | 网页是点,链接关系是边 | PageRank |
|
||||
| 路网 | 交通枢纽是点,铁路/公路是边 | 路径规划,导航最短路径 |
|
||||
| 洗钱 | 账户是点,转账关系是边 | 判断是否有环 |
|
||||
| 知识图谱 | 概念时点,关联关系是边 | 启发式问答 |
|
||||
| 例子 | 建模 | 应用 |
|
||||
| -------- | --------------------------- | ---------------------- |
|
||||
| 社交图谱 | 人是点,follow 关系是边 | 六度分隔,信息流推荐 |
|
||||
| 互联网 | 网页是点,链接关系是边 | PageRank |
|
||||
| 路网 | 交通枢纽是点,铁路/公路是边 | 路径规划,导航最短路径 |
|
||||
| 洗钱 | 账户是点,转账关系是边 | 判断是否有环 |
|
||||
| 知识图谱 | 概念时点,关联关系是边 | 启发式问答 |
|
||||
|
||||
- 同构(*homogeneous*)数据和异构数据
|
||||
|
||||
图中的点可以都具有相同类型,但是,也可以具有不同类型,并且更为强大。
|
||||
|
||||
- 同构(_homogeneous_)数据和异构数据
|
||||
图中的点可以都具有相同类型,但是,也可以具有不同类型,并且更为强大。
|
||||
|
||||
本节都会以下图为例,它表示了一对夫妇,来自美国爱达荷州的 Lucy 和来自法国 的 Alain。他们已婚,住在伦敦。
|
||||
|
||||
@ -447,18 +432,16 @@ db.observations.aggregate([
|
||||
|
||||
## 属性图(PG,Property Graphs)
|
||||
|
||||
| 点(vertices, nodes, entities) | 边(edges, relations, arcs) |
|
||||
| --- | --- |
|
||||
| 全局唯一 ID | 全局唯一 ID |
|
||||
| 出边集合 | 起始点 |
|
||||
| 入边集合 | 终止点 |
|
||||
| 属性集(kv 对表示) | 属性集(kv 对表示) |
|
||||
| 表示点类型的 type? | 表示边类型的 label |
|
||||
| 点 (vertices, nodes, entities) | 边 (edges, relations, arcs) |
|
||||
| ------------------------------ | --------------------------- |
|
||||
| 全局唯一 ID | 全局唯一 ID |
|
||||
| 出边集合 | 起始点 |
|
||||
| 入边集合 | 终止点 |
|
||||
| 属性集(kv 对表示) | 属性集(kv 对表示) |
|
||||
| 表示点类型的 type? | 表示边类型的 label |
|
||||
|
||||
- Q:有一个疑惑点,为什么书中对于 PG 点的定义中没有 Type ?
|
||||
|
||||
如果数据是异构的,应该有才对;莫非是通过不同的属性来标记不同的类型?
|
||||
|
||||
- Q:有一个疑惑点,为什么书中对于 PG 点的定义中没有 Type?
|
||||
如果数据是异构的,应该有才对;莫非是通过不同的属性来标记不同的类型?
|
||||
|
||||
如果感觉不直观,可以使用我们熟悉的 SQL 语义来构建一个图模型,如下图。(Facebook TAO 论文中的单机存储引擎便是 MySQL)
|
||||
|
||||
@ -471,7 +454,7 @@ CREATE TABLE vertices (
|
||||
// 边表
|
||||
CREATE TABLE edges (
|
||||
edge_id integer PRIMARY KEY,
|
||||
tail_vertex integer REFERENCES vertices (vertex_id),
|
||||
tail_vertex integer REFERENCES vertices (vertex_id),
|
||||
head_vertex integer REFERENCES vertices (vertex_id),
|
||||
label text,
|
||||
properties json
|
||||
@ -502,7 +485,7 @@ CREATE INDEX edges_heads ON edges (head_vertex);
|
||||
|
||||
Cypher 是 Neo4j 创造的一种查询语言。
|
||||
|
||||
Cypher 和 Neo 名字应该都是来自 《黑客帝国》(The Matrix)。想想 Oracle。
|
||||
Cypher 和 Neo 名字应该都是来自《黑客帝国》(The Matrix)。想想 Oracle。
|
||||
|
||||
Cypher 的一大特点是可读性强,尤其在表达路径模式(Path Pattern)时。
|
||||
|
||||
@ -520,7 +503,7 @@ CREATE
|
||||
|
||||
如果我们要进行一个这样的查询:找出所有从美国移居到欧洲的人名。
|
||||
|
||||
转化为图语言,即为:给定条件, BORN_IN 指向美国的地点,并且 LIVING_IN 指向欧洲的地点,找到所有符合上述条件的点,并且返回其名字属性。
|
||||
转化为图语言,即为:给定条件,BORN_IN 指向美国的地点,并且 LIVING_IN 指向欧洲的地点,找到所有符合上述条件的点,并且返回其名字属性。
|
||||
|
||||
用 Cypher 语句可表示为:
|
||||
|
||||
@ -566,7 +549,7 @@ CREATE PROPERTY GRAPH bank_transfers
|
||||
() -[:WITHIN*0..]-> ()
|
||||
```
|
||||
|
||||
使用 SQL:1999 中 recursive common table expressions (PostgreSQL, IBM DB2, Oracle, and SQL Server 支持)的可以满足。但是,相当冗长和笨拙。
|
||||
使用 SQL:1999 中 recursive common table expressions(PostgreSQL, IBM DB2, Oracle, and SQL Server 支持)的可以满足。但是,相当冗长和笨拙。
|
||||
|
||||
## **Triple-Stores and SPARQL**
|
||||
|
||||
@ -576,10 +559,10 @@ CREATE PROPERTY GRAPH bank_transfers
|
||||
|
||||
其含义如下:
|
||||
|
||||
| Subject | 对应图中的一个点 |
|
||||
| --- | --- |
|
||||
| Object | 1. 一个原子数据,如 string 或者 number。<br/>2. 另一个 Subject。 |
|
||||
| Predicate | 1. 如果 Object 是原子数据,则 <Predicate, Object> 对应点附带的 KV 对。<br/>2. 如果 Object 是另一个 Object,则 Predicate 对应图中的边。 |
|
||||
| Subject | 对应图中的一个点 |
|
||||
| --------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Object | 1. 一个原子数据,如 string 或者 number。<br/>2. 另一个 Subject。 |
|
||||
| Predicate | 1. 如果 Object 是原子数据,则 <Predicate, Object> 对应点附带的 KV 对。<br/>2. 如果 Object 是另一个 Object,则 Predicate 对应图中的边。 |
|
||||
|
||||
仍是上边例子,用 Turtle triples (一种 **Triple-Stores** 语法)**表达为**:
|
||||
|
||||
@ -613,7 +596,7 @@ _:namerica a :Location; :name "North America"; :type "continent".
|
||||
|
||||
### 语义网(The **Semantic Web**)
|
||||
|
||||
万维网之父Tim Berners Lee于1998年提出,知识图谱前身。其目的在于对网络中的资源进行结构化,从而让计算机能够**理解**网络中的数据。即不是以文本、二进制流等等,而是通过某种标准结构化互相关联的数据。
|
||||
万维网之父 Tim Berners Lee 于 1998 年提出,知识图谱前身。其目的在于对网络中的资源进行结构化,从而让计算机能够**理解**网络中的数据。即不是以文本、二进制流等等,而是通过某种标准结构化互相关联的数据。
|
||||
|
||||
**语义**:提供一种统一的方式对所有资源进行描述和**结构化**(机器可读)。
|
||||
|
||||
@ -623,20 +606,20 @@ _:namerica a :Location; :name "North America"; :type "continent".
|
||||
|
||||
![ddia2-rdf.png](img/ch02-semantic-web-stack.png)
|
||||
|
||||
其中 **RDF** (*ResourceDescription Framework,资源描述框架*)提供了一种结构化网络中数据的标准。使发布到网络中的任何资源(文字、图片、视频、网页),都能以统一的形式被计算机理解。即,不需要让资源使用方深度学习抽取资源的语义,而是靠资源提供方通过 RDF 主动提供其资源语义。
|
||||
其中 **RDF** (_ResourceDescription Framework,资源描述框架_)提供了一种结构化网络中数据的标准。使发布到网络中的任何资源(文字、图片、视频、网页),都能以统一的形式被计算机理解。即,不需要让资源使用方深度学习抽取资源的语义,而是靠资源提供方通过 RDF 主动提供其资源语义。
|
||||
|
||||
感觉有点理想主义,但互联网、开源社区都是靠这种理想主义、分享精神发展起来的!
|
||||
|
||||
虽然语义网没有发展起来,但是其**中间数据交换**格式 RDF 所定义的 SPO三元组(Subject-Predicate-Object) 却是一种很好用的数据模型,也就是上面提到的 **Triple-Stores。**
|
||||
虽然语义网没有发展起来,但是其**中间数据交换**格式 RDF 所定义的 SPO 三元组 (Subject-Predicate-Object) 却是一种很好用的数据模型,也就是上面提到的 **Triple-Stores。**
|
||||
|
||||
### RDF 数据模型
|
||||
|
||||
上面提到的 Turtle 语言(SPO三元组)是一种简单易读的描述 RDF 数据的方式, RDF 也可以基于 XML 表示,但是要冗余难读的多(嵌套太深):
|
||||
上面提到的 Turtle 语言(SPO 三元组)是一种简单易读的描述 RDF 数据的方式,RDF 也可以基于 XML 表示,但是要冗余难读的多(嵌套太深):
|
||||
|
||||
```xml
|
||||
<rdf:RDF xmlns="urn:example:"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<Location rdf:nodeID="idaho">
|
||||
<Location rdf:nodeID="idaho">
|
||||
<name>Idaho</name>
|
||||
<type>state</type>
|
||||
<within>
|
||||
@ -644,7 +627,7 @@ _:namerica a :Location; :name "North America"; :type "continent".
|
||||
<name>United States</name>
|
||||
<type>country</type>
|
||||
<within>
|
||||
<Location rdf:nodeID="namerica">
|
||||
<Location rdf:nodeID="namerica">
|
||||
<name>North America</name>
|
||||
<type>continent</type>
|
||||
</Location>
|
||||
@ -669,7 +652,7 @@ lives_in 会表示为 <http://my-company.com/namespace#lives_in>
|
||||
|
||||
### **SPARQL 查询语言**
|
||||
|
||||
有了语义网,自然需要在语义网中进行遍历查询,于是有了 RDF 的查询语言:SPARQL Protocol and RDF Query Language, pronounced “sparkle.”
|
||||
有了语义网,自然需要在语义网中进行遍历查询,于是有了 RDF 的查询语言:SPARQL Protocol and RDF Query Language, pronounced“sparkle.”
|
||||
|
||||
```
|
||||
PREFIX : <urn:example:>
|
||||
@ -702,16 +685,16 @@ SELECT ?personName WHERE {
|
||||
|
||||
否,他们在很多重要的方面都不一样。
|
||||
|
||||
| 模型 | 图模型(Graph Model) | 网络模型(Network Model) |
|
||||
| --- | --- | --- |
|
||||
| 连接方式 | 任意两个点之间都有可以有边 | 指定了嵌套约束 |
|
||||
| 记录查找 | 1. 使用全局 ID <br/>2. 使用属性索引。<br/>3. 使用图遍历。 | 只能使用路径查询 |
|
||||
| 有序性 | 点和边都是无序的 | 记录的孩子们是有序集合,在插入时需要考虑维持有序的开销 |
|
||||
| 查询语言 | 即可命令式,也可以声明式 | 命令式的 |
|
||||
| 模型 | 图模型(Graph Model) | 网络模型(Network Model) |
|
||||
| -------- | --------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| 连接方式 | 任意两个点之间都有可以有边 | 指定了嵌套约束 |
|
||||
| 记录查找 | 1. 使用全局 ID <br/>2. 使用属性索引。<br/>3. 使用图遍历。 | 只能使用路径查询 |
|
||||
| 有序性 | 点和边都是无序的 | 记录的孩子们是有序集合,在插入时需要考虑维持有序的开销 |
|
||||
| 查询语言 | 即可命令式,也可以声明式 | 命令式的 |
|
||||
|
||||
## 查询语言前驱:Datalog
|
||||
|
||||
有点像 triple-store,但是变了下次序:(*subject*, *predicate*, *object*) → *predicate*(*subject*, *object*).
|
||||
有点像 triple-store,但是变了下次序:(_subject_, _predicate_, _object_) → _predicate_(_subject_, _object_).
|
||||
之前数据用 Datalog 表示为:
|
||||
|
||||
```
|
||||
@ -734,10 +717,10 @@ born_in(lucy, idaho).
|
||||
|
||||
```
|
||||
within_recursive(Location, Name) :- name(Location, Name). /* Rule 1 */
|
||||
within_recursive(Location, Name) :- within(Location, Via), /* Rule 2 */
|
||||
within_recursive(Location, Name) :- within(Location, Via), /* Rule 2 */
|
||||
within_recursive(Via, Name).
|
||||
|
||||
migrated(Name, BornIn, LivingIn) :- name(Person, Name), /* Rule 3 */
|
||||
migrated(Name, BornIn, LivingIn) :- name(Person, Name), /* Rule 3 */
|
||||
born_in(Person, BornLoc),
|
||||
within_recursive(BornLoc, BornIn),
|
||||
lives_in(Person, LivingLoc),
|
||||
@ -745,7 +728,7 @@ migrated(Name, BornIn, LivingIn) :- name(Person, Name), /* Rule 3 */
|
||||
?- migrated(Who, 'United States', 'Europe'). /* Who = 'Lucy'. */
|
||||
```
|
||||
|
||||
1. 代码中以大写字母开头的元素是**变量**,字符串、数字或以小写字母开头的元素是**常量**。下划线(_)被称为匿名变量
|
||||
1. 代码中以大写字母开头的元素是**变量**,字符串、数字或以小写字母开头的元素是**常量**。下划线(\_)被称为匿名变量
|
||||
2. 可以使用基本 Predicate 自定义 Predicate,类似于使用基本函数自定义函数。
|
||||
3. 逗号连接的多个谓词表达式为且的关系。
|
||||
|
||||
@ -757,10 +740,10 @@ migrated(Name, BornIn, LivingIn) :- name(Person, Name), /* Rule 3 */
|
||||
2. 应用规则,扩充原集合。
|
||||
3. 如果可以递归,则递归穷尽所有可能性。
|
||||
|
||||
Prolog(Programming in Logic的缩写)是一种逻辑编程语言。它创建在逻辑学的理论基础之上。
|
||||
Prolog(Programming in Logic 的缩写)是一种逻辑编程语言。它创建在逻辑学的理论基础之上。
|
||||
|
||||
## 参考
|
||||
|
||||
1. 声明式(declarative) vs 命令式(imperative)**:**[https://lotabout.me/2020/Declarative-vs-Imperative-language/](https://lotabout.me/2020/Declarative-vs-Imperative-language/)
|
||||
1. 声明式 (declarative) vs 命令式 (imperative)**:**[https://lotabout.me/2020/Declarative-vs-Imperative-language/](https://lotabout.me/2020/Declarative-vs-Imperative-language/)
|
||||
2. **[SimmerChan](https://www.zhihu.com/people/simmerchan)** 知乎专栏,知识图谱,语义网,RDF:[https://www.zhihu.com/column/knowledgegraph](https://www.zhihu.com/column/knowledgegraph)
|
||||
3. MySQL 为什么叫“关系”模型:[https://zhuanlan.zhihu.com/p/64731206](https://zhuanlan.zhihu.com/p/64731206)
|
||||
|
116
ch03.md
116
ch03.md
@ -2,23 +2,24 @@
|
||||
|
||||
第二章讲了上层抽象:数据模型和查询语言。
|
||||
本章下沉一些,聚焦数据库底层如何处理查询和存储。这其中,有个**逻辑链条**:
|
||||
> 使用场景→ 查询类型 → 存储格式。
|
||||
|
||||
> 使用场景 → 查询类型 → 存储格式。
|
||||
|
||||
查询类型主要分为两大类:
|
||||
|
||||
| 引擎类型 | 请求数量 | 数据量 | 瓶颈 | 存储格式 | 用户 | 场景举例 | 产品举例 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| OLTP | 相对频繁,侧重在线交易 | 总体和单次查询都相对较小 | Disk Seek | 多用行存 | 比较普遍,一般应用用的比较多 | 银行交易 | MySQL |
|
||||
| OLAP | 相对较少,侧重离线分析 | 总体和单次查询都相对巨大 | Disk Bandwidth | 列存逐渐流行 | 多为商业用户 | 商业分析 | ClickHouse |
|
||||
| 引擎类型 | 请求数量 | 数据量 | 瓶颈 | 存储格式 | 用户 | 场景举例 | 产品举例 |
|
||||
| -------- | ---------------------- | ------------------------ | -------------- | ------------ | ---------------------------- | -------- | ---------- |
|
||||
| OLTP | 相对频繁,侧重在线交易 | 总体和单次查询都相对较小 | Disk Seek | 多用行存 | 比较普遍,一般应用用的比较多 | 银行交易 | MySQL |
|
||||
| OLAP | 相对较少,侧重离线分析 | 总体和单次查询都相对巨大 | Disk Bandwidth | 列存逐渐流行 | 多为商业用户 | 商业分析 | ClickHouse |
|
||||
|
||||
其中,OLTP 侧,常用的存储引擎又有两种流派:
|
||||
|
||||
| 流派 | 主要特点 | 基本思想 | 代表 |
|
||||
| --- | --- | --- | --- |
|
||||
| log-structured 流 | 只允许追加,所有修改都表现为文件的追加和文件整体增删 | 变随机写为顺序写 | Bitcask、LevelDB、RocksDB、Cassandra、Lucene |
|
||||
| update-in-place 流 | 以页(page)为粒度对磁盘数据进行修改 | 面向页、查找树 | B族树,所有主流关系型数据库和一些非关系型数据库 |
|
||||
| 流派 | 主要特点 | 基本思想 | 代表 |
|
||||
| ------------------ | ---------------------------------------------------- | ---------------- | ------------------------------------------------ |
|
||||
| log-structured 流 | 只允许追加,所有修改都表现为文件的追加和文件整体增删 | 变随机写为顺序写 | Bitcask、LevelDB、RocksDB、Cassandra、Lucene |
|
||||
| update-in-place 流 | 以页(page)为粒度对磁盘数据进行修改 | 面向页、查找树 | B 族树,所有主流关系型数据库和一些非关系型数据库 |
|
||||
|
||||
此外,针对 OLTP, 还探索了常见的建索引的方法,以及一种特殊的数据库——全内存数据库。
|
||||
此外,针对 OLTP,还探索了常见的建索引的方法,以及一种特殊的数据库——全内存数据库。
|
||||
|
||||
对于数据仓库,本章分析了它与 OLTP 的主要不同之处。数据仓库主要侧重于聚合查询,需要扫描很大量的数据,此时,索引就相对不太有用。需要考虑的是存储成本、带宽优化等,由此引出列式存储。
|
||||
|
||||
@ -42,7 +43,7 @@ db_get () {
|
||||
这两个函数实现了一个基于字符串的 KV 存储(只支持 get/set,不支持 delete):
|
||||
|
||||
```bash
|
||||
$ db_set 123456 '{"name":"London","attractions":["Big Ben","London Eye"]}'
|
||||
$ db_set 123456 '{"name":"London","attractions":["Big Ben","London Eye"]}'
|
||||
$ db_set 42 '{"name":"San Francisco","attractions":["Golden Gate Bridge"]}'
|
||||
$ db_get 42
|
||||
{"name":"San Francisco","attractions":["Golden Gate Bridge"]}
|
||||
@ -59,7 +60,7 @@ $ db_get 42
|
||||
|
||||
这便是数据库存储引擎设计和选择时最常见的**权衡(trade off)**:
|
||||
|
||||
1. 恰当的**存储格式**能加快写(日志结构),但是会让读取很慢;也可以加快读(查找树、B族树),但会让写入较慢。
|
||||
1. 恰当的**存储格式**能加快写(日志结构),但是会让读取很慢;也可以加快读(查找树、B 族树),但会让写入较慢。
|
||||
2. 为了弥补读性能,可以构建索引。但是会牺牲写入性能和耗费额外空间。
|
||||
|
||||
存储格式一般不好动,但是索引构建与否,一般交予用户选择。
|
||||
@ -75,7 +76,7 @@ $ db_get 42
|
||||
|
||||
![ddia-3-1-hash-map-csv.png](img/ch03-fig01.png)
|
||||
|
||||
看来很简单,但这正是 [Bitcask](https://docs.riak.com/riak/kv/2.2.3/setup/planning/backend/bitcask/index.html "Bitcask") 的基本设计,但关键是,他 Work(在小数据量时,即所有 key 都能存到内存中时):能提供很高的读写性能:
|
||||
看来很简单,但这正是 [Bitcask](https://docs.riak.com/riak/kv/2.2.3/setup/planning/backend/bitcask/index.html 'Bitcask') 的基本设计,但关键是,他 Work(在小数据量时,即所有 key 都能存到内存中时):能提供很高的读写性能:
|
||||
|
||||
1. 写:文件追加写。
|
||||
2. 读:一次内存查询,一次磁盘 seek;如果数据已经被缓存,则 seek 也可以省掉。
|
||||
@ -84,15 +85,15 @@ $ db_get 42
|
||||
|
||||
> 但有个很重要问题,单个文件越来越大,磁盘空间不够怎么办?
|
||||
>
|
||||
> 在文件到达一定尺寸后,就新建一个文件,将原文件变为只读。同时为了回收多个 key 多次写入的造成的空间浪费,可以将只读文件进行紧缩( compact ),将旧文件进行重写,挤出“水分”(被覆写的数据)以进行垃圾回收。
|
||||
|
||||
> 在文件到达一定尺寸后,就新建一个文件,将原文件变为只读。同时为了回收多个 key 多次写入的造成的空间浪费,可以将只读文件进行紧缩(compact),将旧文件进行重写,挤出“水分”(被覆写的数据)以进行垃圾回收。
|
||||
|
||||
![ddia-3-3-compaction-sim.png](img/ch03-fig03.png)
|
||||
|
||||
当然,如果我们想让其**工业可用**,还有很多问题需要解决:
|
||||
|
||||
1. **文件格式**。对于**日志**来说,CSV 不是一种紧凑的数据格式,有很多空间浪费。比如,可以用 length + record bytes 。
|
||||
1. **文件格式**。对于**日志**来说,CSV 不是一种紧凑的数据格式,有很多空间浪费。比如,可以用 length + record bytes。
|
||||
2. **记录删除**。之前只支持 put\get,但实际还需要支持 delete。但日志结构又不支持更新,怎么办呢?一般是写一个特殊标记(比如墓碑记录,tombstone)以表示该记录已删除。之后 compact 时真正删除即可。
|
||||
3. **宕机恢复**。在机器重启时,内存中的哈希索引将会丢失。当然,可以全盘扫描以重建,但通常一个小优化是,对于每个 segment file, 将其索引条目和数据文件一块持久化,重启时只需加载索引条目即可。
|
||||
3. **宕机恢复**。在机器重启时,内存中的哈希索引将会丢失。当然,可以全盘扫描以重建,但通常一个小优化是,对于每个 segment file,将其索引条目和数据文件一块持久化,重启时只需加载索引条目即可。
|
||||
4. **记录写坏、少写**。系统任何时候都有可能宕机,由此会造成记录写坏、少写。为了识别错误记录,我们需要增加一些校验字段,以识别并跳过这种数据。为了跳过写了部分的数据,还要用一些特殊字符来标识记录间的边界。
|
||||
5. **并发控制**。由于只有一个活动(追加)文件,因此写只有一个天然并发度。但其他的文件都是不可变的(compact 时会读取然后生成新的),因此读取和紧缩可以并发执行。
|
||||
|
||||
@ -110,8 +111,6 @@ $ db_get 42
|
||||
后面讲的 LSM-Tree 和 B+ 树,都能部分规避上述问题。
|
||||
|
||||
- 想想,会如何进行规避?
|
||||
|
||||
|
||||
|
||||
## SSTables 和 LSM-Trees
|
||||
|
||||
@ -132,7 +131,6 @@ $ db_get 42
|
||||
|
||||
![ddia-3-4-merge-sst.png](img/ch03-fig04.png)
|
||||
|
||||
|
||||
**不需要在内存中保存所有数据的索引**。仅需要记录下每个文件界限(以区间表示:[startKey, endKey],当然实际会记录的更细)即可。查找某个 Key 时,去所有包含该 Key 的区间对应的文件二分查找即可。
|
||||
|
||||
![ddia-3-5-sst-index.png](https://s2.loli.net/2022/04/16/j8tM6IUk1QrJXuw.png)
|
||||
@ -160,7 +158,7 @@ SSTables 格式听起来很美好,但须知数据是乱序的来的,我们
|
||||
|
||||
如果 SSTable 文件越来越多,则查找代价会越来越大。因此需要将多个 SSTable 文件合并,以减少文件数量,同时进行 GC,我们称之为**紧缩**( Compaction)。
|
||||
|
||||
**该方案的问题**:如果出现宕机,内存中的数据结构将会消失。 解决方法也很经典:WAL。
|
||||
**该方案的问题**:如果出现宕机,内存中的数据结构将会消失。解决方法也很经典:WAL。
|
||||
|
||||
### 从 SSTables 到 LSM-Tree
|
||||
|
||||
@ -168,7 +166,7 @@ SSTables 格式听起来很美好,但须知数据是乱序的来的,我们
|
||||
|
||||
![ddia-3-leveldb-architecture.png](img/ch03-fig05.png)
|
||||
|
||||
这种数据结构是 Patrick O’Neil 等人,在 1996 年提出的:[The Log-Structured Merge-Tree](https://www.cs.umb.edu/~poneil/lsmtree.pdf "The Log-Structured Merge-Tree")。
|
||||
这种数据结构是 Patrick O’Neil 等人,在 1996 年提出的:[The Log-Structured Merge-Tree](https://www.cs.umb.edu/~poneil/lsmtree.pdf 'The Log-Structured Merge-Tree')。
|
||||
|
||||
Elasticsearch 和 Solr 的索引引擎 Lucene,也使用类似 LSM-Tree 存储结构。但其数据模型不是 KV,但类似:word → document list。
|
||||
|
||||
@ -182,7 +180,7 @@ Elasticsearch 和 Solr 的索引引擎 Lucene,也使用类似 LSM-Tree 存储
|
||||
|
||||
![ddia-sized-tierd-compact.png](img/ch03-sized-tiered.png)
|
||||
|
||||
对于 RocksDB 来说,工程上的优化和使用上的优化就更多了。在其 [Wiki](https://github.com/facebook/rocksdb/wiki "rocksdb wiki") 上随便摘录几点:
|
||||
对于 RocksDB 来说,工程上的优化和使用上的优化就更多了。在其 [Wiki](https://github.com/facebook/rocksdb/wiki 'rocksdb wiki') 上随便摘录几点:
|
||||
|
||||
1. Column Family
|
||||
2. 前缀压缩和过滤
|
||||
@ -194,7 +192,7 @@ Elasticsearch 和 Solr 的索引引擎 Lucene,也使用类似 LSM-Tree 存储
|
||||
|
||||
虽然先讲的 LSM-Tree,但是它要比 B+ 树新的多。
|
||||
|
||||
B 树于 1970 年被 R. Bayer and E. McCreight [提出](https://dl.acm.org/doi/10.1145/1734663.1734671 "b tree paper")后,便迅速流行了起来。现在几乎所有的关系型数据中,它都是数据索引标准一般的实现。
|
||||
B 树于 1970 年被 R. Bayer and E. McCreight [提出](https://dl.acm.org/doi/10.1145/1734663.1734671 'b tree paper')后,便迅速流行了起来。现在几乎所有的关系型数据中,它都是数据索引标准一般的实现。
|
||||
|
||||
与 LSM-Tree 一样,它也支持高效的**点查**和**范围查**。但却使用了完全不同的组织方式。
|
||||
|
||||
@ -205,7 +203,7 @@ B 树于 1970 年被 R. Bayer and E. McCreight [提出](https://dl.acm.org/doi/1
|
||||
|
||||
![ddia-3-6-b-tree-lookup.png](img/ch03-fig06.png)
|
||||
|
||||
**查找**。从根节点出发,进行二分查找,然后加载新的页到内存中,继续二分,直到命中或者到叶子节点。 查找复杂度,树的高度—— O(lgn),影响树高度的因素:分支因子(分叉数,通常是几百个)。
|
||||
**查找**。从根节点出发,进行二分查找,然后加载新的页到内存中,继续二分,直到命中或者到叶子节点。查找复杂度,树的高度—— O(lgn),影响树高度的因素:分支因子(分叉数,通常是几百个)。
|
||||
|
||||
![ddia-3-7-b-tree-grow-by-split.png](img/ch03-fig07.png)
|
||||
|
||||
@ -214,13 +212,11 @@ B 树于 1970 年被 R. Bayer and E. McCreight [提出](https://dl.acm.org/doi/1
|
||||
**分裂 or 合并**。级联分裂和合并。
|
||||
|
||||
- 一个记录大于一个 page 怎么办?
|
||||
|
||||
树的节点是逻辑概念,page or block 是物理概念。一个逻辑节点可以对应多个物理 page。
|
||||
|
||||
树的节点是逻辑概念,page or block 是物理概念。一个逻辑节点可以对应多个物理 page。
|
||||
|
||||
### 让 B 树更可靠
|
||||
|
||||
B 树不像 LSM-Tree ,会在原地修改数据文件。
|
||||
B 树不像 LSM-Tree,会在原地修改数据文件。
|
||||
|
||||
在树结构调整时,可能会级联修改很多 Page。比如叶子节点分裂后,就需要写入两个新的叶子节点,和一个父节点(更新叶子指针)。
|
||||
|
||||
@ -239,15 +235,15 @@ B 树出来了这么久,因此有很多优化:
|
||||
|
||||
## B-Trees 和 LSM-Trees 对比
|
||||
|
||||
| 存储引擎 | B-Tree | LSM-Tree | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| 优势 | 读取更快 | 写入更快 | |
|
||||
| 写放大 | 1. 数据和 WAL<br/>2. 更改数据时多次覆盖整个 Page | 1. 数据和 WAL<br/>2. Compaction | SSD 不能过多擦除。因此 SSD 内部的固件中也多用日志结构来减少随机小写。 |
|
||||
| 写吞吐 | 相对较低:<br/>1. 大量随机写。 | 相对较高:<br/>1. 较低的写放大(取决于数据和配置)<br/>2. 顺序写入。<br/>3. 更为紧凑。 | |
|
||||
| 压缩率 | 1. 存在较多内部碎片。 | 1. 更加紧凑,没有内部碎片。<br/>2. 压缩潜力更大(共享前缀)。 | 但紧缩不及时会造成 LSM-Tree 存在很多垃圾 |
|
||||
| 后台流量 | 1. 更稳定可预测,不会受后台 compaction 突发流量影响。 | 1. 写吞吐过高,compaction 跟不上,会进一步加重读放大。<br/>2. 由于外存总带宽有限,compaction 会影响读写吞吐。<br/>3. 随着数据越来越多,compaction 对正常写影响越来越大。 | RocksDB 写入太过快会引起 write stall,即限制写入,以期尽快 compaction 将数据下沉。 |
|
||||
| 存储放大 | 1. 有些 Page 没有用满 | 1. 同一个 Key 存多遍 | |
|
||||
| 并发控制 | 1. 同一个 Key 只存在一个地方<br/>2. 树结构容易加范围锁。 | 同一个 Key 会存多遍,一般使用 MVCC 进行控制。 | |
|
||||
| 存储引擎 | B-Tree | LSM-Tree | 备注 |
|
||||
| -------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| 优势 | 读取更快 | 写入更快 | |
|
||||
| 写放大 | 1. 数据和 WAL<br/>2. 更改数据时多次覆盖整个 Page | 1. 数据和 WAL<br/>2. Compaction | SSD 不能过多擦除。因此 SSD 内部的固件中也多用日志结构来减少随机小写。 |
|
||||
| 写吞吐 | 相对较低:<br/>1. 大量随机写。 | 相对较高:<br/>1. 较低的写放大(取决于数据和配置)<br/>2. 顺序写入。<br/>3. 更为紧凑。 | |
|
||||
| 压缩率 | 1. 存在较多内部碎片。 | 1. 更加紧凑,没有内部碎片。<br/>2. 压缩潜力更大(共享前缀)。 | 但紧缩不及时会造成 LSM-Tree 存在很多垃圾 |
|
||||
| 后台流量 | 1. 更稳定可预测,不会受后台 compaction 突发流量影响。 | 1. 写吞吐过高,compaction 跟不上,会进一步加重读放大。<br/>2. 由于外存总带宽有限,compaction 会影响读写吞吐。<br/>3. 随着数据越来越多,compaction 对正常写影响越来越大。 | RocksDB 写入太过快会引起 write stall,即限制写入,以期尽快 compaction 将数据下沉。 |
|
||||
| 存储放大 | 1. 有些 Page 没有用满 | 1. 同一个 Key 存多遍 | |
|
||||
| 并发控制 | 1. 同一个 Key 只存在一个地方<br/>2. 树结构容易加范围锁。 | 同一个 Key 会存多遍,一般使用 MVCC 进行控制。 | |
|
||||
|
||||
## 其他索引结构
|
||||
|
||||
@ -260,7 +256,7 @@ B 树出来了这么久,因此有很多优化:
|
||||
1. 数据本身**无序**的存在文件中,称为 **堆文件(heap file)**,索引的值指向对应数据在 heap file 中的位置。这样可以避免多个索引时的数据拷贝。
|
||||
2. 数据本身按某个字段有序存储,该字段通常是主键。则称基于此字段的索引为**聚集索引**(clustered index),从另外一个角度理解,即将索引和数据存在一块。则基于其他字段的索引为**非聚集索引**,在索引中仅存数据的引用。
|
||||
3. 一部分列内嵌到索引中存储,一部分列数据额外存储。称为**覆盖索引(covering index)**
|
||||
或 **包含列的索引(index with included columns)**。
|
||||
或 **包含列的索引(index with included columns)**。
|
||||
|
||||
索引可以加快查询速度,但需要占用额外空间,并且牺牲了部分更新开销,且需要维持某种一致性。
|
||||
|
||||
@ -282,11 +278,11 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
||||
|
||||
前述索引只提供全字段的精确匹配,而不提供类似搜索引擎的功能。比如,按字符串中包含的单词查询,针对笔误的单词查询。
|
||||
|
||||
在工程中常用 [Apace Lucene](https://lucene.apache.org/ "Apace Lucene") 库,和其包装出来的服务:[Elasticsearch](https://www.elastic.co/cn/ "Elasticsearch")。他也使用类似 LSM-tree 的日志存储结构,但其索引是一个有限状态自动机,在行为上类似 Trie 树。
|
||||
在工程中常用 [Apace Lucene](https://lucene.apache.org/ 'Apace Lucene') 库,和其包装出来的服务:[Elasticsearch](https://www.elastic.co/cn/ 'Elasticsearch')。他也使用类似 LSM-tree 的日志存储结构,但其索引是一个有限状态自动机,在行为上类似 Trie 树。
|
||||
|
||||
### 全内存数据结构
|
||||
|
||||
随着单位内存成本下降,甚至支持持久化(*non-volatile memory*,NVM,如 Intel 的 [傲腾](https://www.intel.cn/content/www/cn/zh/products/details/memory-storage/optane-dc-persistent-memory.html "傲腾")),全内存数据库也逐渐开始流行。
|
||||
随着单位内存成本下降,甚至支持持久化(_non-volatile memory_,NVM,如 Intel 的 [傲腾](https://www.intel.cn/content/www/cn/zh/products/details/memory-storage/optane-dc-persistent-memory.html '傲腾')),全内存数据库也逐渐开始流行。
|
||||
|
||||
根据是否需要持久化,内存数据大概可以分为两类:
|
||||
|
||||
@ -294,7 +290,6 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
||||
2. **需要持久化**。通过 WAL、定期 snapshot、远程备份等等来对数据进行持久化。但使用内存处理全部读写,因此仍是内存数据库。
|
||||
|
||||
> VoltDB, MemSQL, and Oracle TimesTen 是提供关系模型的内存数据库。RAMCloud 是提供持久化保证的 KV 数据库。Redis and Couchbase 仅提供弱持久化保证。
|
||||
>
|
||||
|
||||
内存数据库存在优势的原因不仅在于不需要读取磁盘,而在更于不需要对数据结构进行**序列化、编码**后以适应磁盘所带来的**额外开销**。
|
||||
|
||||
@ -305,25 +300,25 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
|
||||
|
||||
此外,内存数据库还可以通过类似操作系统 swap 的方式,提供比物理机内存更大的存储空间,但由于其有更多数据库相关信息,可以将换入换出的粒度做的更细、性能做的更好。
|
||||
|
||||
基于**非易失性存储器**(non-volatile memory,NVM) 的存储引擎也是这些年研究的一个热点。
|
||||
基于**非易失性存储器**(non-volatile memory,NVM)的存储引擎也是这些年研究的一个热点。
|
||||
|
||||
# 事务型还是分析型
|
||||
|
||||
术语 **OL**(Online)主要是指交互式的查询。
|
||||
|
||||
术语**事务**( transaction )由来有一些历史原因。早期的数据库使用方多为商业交易(commercial ),比如买卖、发工资等等。但是随着数据库应用不断扩大,交易\事务作为名词保留了下来。
|
||||
术语**事务**(transaction)由来有一些历史原因。早期的数据库使用方多为商业交易(commercial),比如买卖、发工资等等。但是随着数据库应用不断扩大,交易\事务作为名词保留了下来。
|
||||
|
||||
> 事务不一定具有 ACID 特性,事务型处理多是随机的以较低的延迟进行读写,与之相反,分析型处理多为定期的批处理,延迟较高。
|
||||
|
||||
下表是一个对比:
|
||||
|
||||
| 属性 | OLTP | OLAP |
|
||||
| --- | --- | --- |
|
||||
| 属性 | OLTP | OLAP |
|
||||
| ------------ | ------------------------------- | -------------------------------------- |
|
||||
| 主要读取模式 | 小数据量的随机读,通过 key 查询 | 大数据量的聚合(max,min,sum, avg)查询 |
|
||||
| 主要写入模式 | 随机访问,低延迟写入 | 批量导入(ETL)或者流式写入 |
|
||||
| 主要应用场景 | 通过 web 方式使用的最终用户 | 互联网分析,为了辅助决策 |
|
||||
| 如何看待数据 | 当前时间点的最新状态 | 随着时间推移的 |
|
||||
| 数据尺寸 | 通常 GB 到 TB | 通常 TB 到 PB |
|
||||
| 主要写入模式 | 随机访问,低延迟写入 | 批量导入(ETL)或者流式写入 |
|
||||
| 主要应用场景 | 通过 web 方式使用的最终用户 | 互联网分析,为了辅助决策 |
|
||||
| 如何看待数据 | 当前时间点的最新状态 | 随着时间推移的 |
|
||||
| 数据尺寸 | 通常 GB 到 TB | 通常 TB 到 PB |
|
||||
|
||||
一开始对于 AP 场景,仍然使用的传统数据库。在模型层面来说,SQL 足够灵活,能够基本满足 AP 查询需求。但在实现层面,传统数据库在 AP 负载中的表现(大数据量吞吐较低)不尽如人意,因此大家开始转向在专门设计的数据库中进行 AP 查询,我们称之为**数据仓库**(Data Warehouse)。
|
||||
|
||||
@ -351,7 +346,7 @@ AP 中的处理模型相对较少,比较常用的有**星状模型**,也称
|
||||
|
||||
![ddia3-9-star-schema.png](img/ch03-fig08.png)
|
||||
|
||||
如上图所示,星状模型通常包含一张**事件表(*fact table*)** 和多张**维度表(*dimension tables*****)**。事件表以事件流的方式将数据组织起来,然后通过外键指向不同的维度。
|
||||
如上图所示,星状模型通常包含一张**事件表(_fact table_)** 和多张**维度表(\*dimension tables\*\*\***)\*\*。事件表以事件流的方式将数据组织起来,然后通过外键指向不同的维度。
|
||||
|
||||
星状模型的一个变种是雪花模型,可以类比雪花(❄️)图案,其特点是在维度表中会进一步进行二次细分,讲一个维度分解为几个子维度。比如品牌和产品类别可能有单独的表格。星状模型更简单,雪花模型更精细,具体应用中会做不同取舍。
|
||||
|
||||
@ -390,7 +385,7 @@ GROUP BY
|
||||
|
||||
将所有数据分列存储在一块,带来了一个意外的好处,由于同一属性的数据相似度高,因此更易压缩。
|
||||
|
||||
如果每一列中值阈相比行数要小的多,可以用**位图编码( *[bitmap encoding](https://en.wikipedia.org/wiki/Bitmap_index "bitmap encoding")* )**。举个例子,零售商可能有数十亿的销售交易,但只有 100,000 个不同的产品。
|
||||
如果每一列中值阈相比行数要小的多,可以用**位图编码(_[bitmap encoding](https://en.wikipedia.org/wiki/Bitmap_index 'bitmap encoding')_)**。举个例子,零售商可能有数十亿的销售交易,但只有 100,000 个不同的产品。
|
||||
|
||||
![ddia-3-11-compress.png](img/ch03-fig11.png)
|
||||
|
||||
@ -400,11 +395,11 @@ GROUP BY
|
||||
2. 值为 0 则表示该下标没有出现该值
|
||||
3. 值为 1 则表示该下标出现了该值
|
||||
|
||||
如果 bit array 是稀疏的,即大量的都是 0,只要少量的 1。其实还可以使用 **[游程编码](https://zh.wikipedia.org/zh/%E6%B8%B8%E7%A8%8B%E7%BC%96%E7%A0%81 "游程编码")(RLE, Run-length encoding)** 进一步压缩:
|
||||
如果 bit array 是稀疏的,即大量的都是 0,只要少量的 1。其实还可以使用 **[游程编码](https://zh.wikipedia.org/zh/%E6%B8%B8%E7%A8%8B%E7%BC%96%E7%A0%81 '游程编码')(RLE,Run-length encoding)** 进一步压缩:
|
||||
|
||||
1. 将连续的 0 和 1,改写成 `数量+值`,比如 `product_sk = 29` 是 `9 个 0,1 个 1,8 个 0`。
|
||||
2. 使用一个小技巧,将信息进一步压缩。比如将同值项合并后,肯定是 0 1 交错出现,固定第一个值为 0,则交错出现的 0 和 1 的值也不用写了。则 `product_sk = 29` 编码变成 `9,1,8`
|
||||
3. 由于我们知道 bit array 长度,则最后一个数字也可以省掉,因为它可以通过 `array len - sum(other lens)` 得到,则 `product_sk = 29` 的编码最后变成:`9,1`
|
||||
2. 使用一个小技巧,将信息进一步压缩。比如将同值项合并后,肯定是 0 1 交错出现,固定第一个值为 0,则交错出现的 0 和 1 的值也不用写了。则 `product_sk = 29` 编码变成 `9,1,8`
|
||||
3. 由于我们知道 bit array 长度,则最后一个数字也可以省掉,因为它可以通过 `array len - sum(other lens)` 得到,则 `product_sk = 29` 的编码最后变成:`9,1`
|
||||
|
||||
位图索引很适合应对查询中的逻辑运算条件,比如:
|
||||
|
||||
@ -412,17 +407,17 @@ GROUP BY
|
||||
WHERE product_sk IN(30,68,69)
|
||||
```
|
||||
|
||||
可以转换为 `product_sk = 30`、`product_sk = 68`和 `product_sk = 69`这三个 bit array 按位或(OR)。
|
||||
可以转换为 `product_sk = 30`、`product_sk = 68`和 `product_sk = 69`这三个 bit array 按位或(OR)。
|
||||
|
||||
```sql
|
||||
WHERE product_sk = 31 AND store_sk = 3
|
||||
```
|
||||
|
||||
可以转换为 `product_sk = 31`和 `store_sk = 3`的 bit array 的按位与,就可以得到所有需要的位置。
|
||||
可以转换为 `product_sk = 31`和 `store_sk = 3`的 bit array 的按位与,就可以得到所有需要的位置。
|
||||
|
||||
### 列族
|
||||
|
||||
书中特别提到**列族(column families)**。它是 Cassandra 和 HBase 中的的概念,他们都起源于自谷歌的 [BigTable](https://en.wikipedia.org/wiki/Bigtable "BigTable") 。注意到他们和**列式(column-oriented)存储**有相似之处,但绝不完全相同:
|
||||
书中特别提到**列族(column families)**。它是 Cassandra 和 HBase 中的的概念,他们都起源于自谷歌的 [BigTable](https://en.wikipedia.org/wiki/Bigtable 'BigTable') 。注意到他们和**列式(column-oriented)存储**有相似之处,但绝不完全相同:
|
||||
|
||||
1. 同一个列族中多个列是一块存储的,并且内嵌行键(row key)。
|
||||
2. 并且列不压缩(存疑?)
|
||||
@ -434,7 +429,7 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
数仓的超大规模数据量带来了以下瓶颈:
|
||||
|
||||
1. 内存处理带宽
|
||||
2. CPU 分支预测错误和[流水线停顿](https://zh.wikipedia.org/wiki/%E6%B5%81%E6%B0%B4%E7%BA%BF%E5%81%9C%E9%A1%BF "流水线停顿")
|
||||
2. CPU 分支预测错误和[流水线停顿](https://zh.wikipedia.org/wiki/%E6%B5%81%E6%B0%B4%E7%BA%BF%E5%81%9C%E9%A1%BF '流水线停顿')
|
||||
|
||||
关于内存的瓶颈可已通过前述的数据压缩来缓解。对于 CPU 的瓶颈可以使用:
|
||||
|
||||
@ -446,7 +441,6 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
由于数仓查询多集中于聚合算子(比如 sum,avg,min,max),列式存储中的存储顺序相对不重要。但也免不了需要对某些列利用条件进行筛选,为此我们可以如 LSM-Tree 一样,对所有行按某一列进行排序后存储。
|
||||
|
||||
> 注意,不可能同时对多列进行排序。因为我们需要维护多列间的下标间的对应关系,才可能按行取数据。
|
||||
>
|
||||
|
||||
同时,排序后的那一列,压缩效果会更好。
|
||||
|
||||
@ -461,7 +455,6 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
上述针对数仓的优化(列式存储、数据压缩和按列排序)都是为了解决数仓中常见的读写负载,读多写少,且读取都是超大规模的数据。
|
||||
|
||||
> 我们针对读做了优化,就让写入变得相对困难。
|
||||
>
|
||||
|
||||
比如 B 树的**原地更新流**是不太行的。举个例子,要在中间某行插入一个数据,**纵向**来说,会影响所有的列文件(如果不做 segment 的话);为了保证多列间按下标对应,**横向**来说,又得更新该行不同列的所有列文件。
|
||||
|
||||
@ -479,7 +472,6 @@ WHERE product_sk = 31 AND store_sk = 3
|
||||
其中一个值得一提的是**物化聚合(materialized aggregates,或者物化汇总)**。
|
||||
|
||||
> 物化,可以简单理解为持久化。本质上是一种空间换时间的 tradeoff。
|
||||
>
|
||||
|
||||
数据仓库查询通常涉及聚合函数,如 SQL 中的 COUNT、SUM、AVG、MIN 或 MAX。如果这些函数被多次用到,每次都即时计算显然存在巨大浪费。因此一个想法就是,能不能将其缓存起来。
|
||||
|
||||
|
104
ch04.md
104
ch04.md
@ -4,7 +4,6 @@
|
||||
|
||||
所有涉及跨进程通信的地方,都需要对数据进行**编码**(**Encoding**),或者说**序列化**(**Serialization**)。因为持久化存储和网络传输都是面向字节流的。序列化本质上是一种“**降维**”操作,将内存中高维的数据结构降维成单维的字节流,于是底层硬件和相关协议,只需要处理一维信息即可。
|
||||
|
||||
|
||||
编码主要涉及两方面问题:
|
||||
|
||||
1. 如何编码能够节省空间、提高性能。
|
||||
@ -13,10 +12,9 @@
|
||||
第一小节,以几种常见的编码工具(JSON,XML,Protocol Buffers 和 Avro)为例,逐一探讨了其如何进行编码、如何进行多版本兼容。这里引出了两个非常重要的概念:
|
||||
|
||||
1. 向后兼容 (backward compatibility):当前代码可以读取旧版本代码写入的数据。
|
||||
2. 向前兼容(forward compatibility):当前代码可以读取新版本代码写入的数据。
|
||||
2. 向前兼容 (forward compatibility):当前代码可以读取新版本代码写入的数据。
|
||||
|
||||
> 翻译成中文后,很容易混淆,主要原因在于“后”的歧义性,到底指**身后**(过去),还是指**之后**(将来),私以为还不如翻译为,*兼容过去*和*兼容将来*。但为了习惯,后面行文仍然用向后/前兼容。
|
||||
>
|
||||
|
||||
其中,向后兼容比较常见,因为时间总是向前流逝,版本总是升级,那么升级之后的代码总要处理历史积压的数据,自然会产生向后兼容的问题。向前兼容比较少见,书中给出的例子是多实例滚动升级,但其持续时间也很短。
|
||||
|
||||
@ -26,30 +24,24 @@
|
||||
|
||||
![ddia4-encoding-decoding.png](img/ch04-encodec.png)
|
||||
|
||||
编码(Encoding)有多种称谓,如**序列化(serialization)** 或 **编组(marshalling)**。对应的,解码(Decoding)也有多种别称,**解析(Parsing)**,**反序列化(deserialization)**,**反编组 (unmarshalling)。**
|
||||
编码(Encoding)有多种称谓,如**序列化(serialization)** 或 **编组(marshalling)**。对应的,解码(Decoding)也有多种别称,**解析(Parsing)**,**反序列化(deserialization)**,**反编组 (unmarshalling)。**
|
||||
|
||||
- 为什么内存中数据和外存、网络中的会有如此不同呢?
|
||||
|
||||
在内存中,借助编译器,我们可以将内存解释为各种数据结构;但在文件系统和网络中,我们只能通过 seek\read 等几个有限的操作来流式的读取字节流。那 mmap 呢?
|
||||
|
||||
在内存中,借助编译器,我们可以将内存解释为各种数据结构;但在文件系统和网络中,我们只能通过 seek\read 等几个有限的操作来流式的读取字节流。那 mmap 呢?
|
||||
- 编码和序列化撞车了?
|
||||
|
||||
在事务中,也有序列化相关的术语,所以这里专用编码,以避免歧义。
|
||||
|
||||
在事务中,也有序列化相关的术语,所以这里专用编码,以避免歧义。
|
||||
- 编码(encoding)和加密(**encryption**)?
|
||||
|
||||
研究的范畴不太一样,编码是为了持久化或者传输,着重点在格式和演化;而加密是为了安全,着重点在于安全、防破解。
|
||||
|
||||
研究的范畴不太一样,编码是为了持久化或者传输,着重点在格式和演化;而加密是为了安全,着重点在于安全、防破解。
|
||||
|
||||
## 编程语言内置
|
||||
|
||||
很多编程语言内置了一些缺省的编码方法:
|
||||
|
||||
1. Java 有 `java.io.Serializable`
|
||||
2. Ruby 有 `Marshal`
|
||||
3. Python 有 `pickle`
|
||||
1. Java 有 `java.io.Serializable`
|
||||
2. Ruby 有 `Marshal`
|
||||
3. Python 有 `pickle`
|
||||
|
||||
如果你确定你的数据只会被某种特定的语言所读取,那么这种内置的编码方法很好用。比如深度学习研究员因为基本都用 Python,所以常会把数据以 [pickle](https://docs.python.org/zh-cn/3/library/pickle.html "pickle 官方文档") 的格式传来传去。
|
||||
如果你确定你的数据只会被某种特定的语言所读取,那么这种内置的编码方法很好用。比如深度学习研究员因为基本都用 Python,所以常会把数据以 [pickle](https://docs.python.org/zh-cn/3/library/pickle.html 'pickle 官方文档') 的格式传来传去。
|
||||
|
||||
但这些编程语言内置的编码格式有以下缺点:
|
||||
|
||||
@ -66,13 +58,13 @@ JSON 最初由 JavaScript 引入,因此在 Web Service 中用的较多,当
|
||||
|
||||
XML 比较古老了,比 JSON 冗余度还高,有时候配置文件中会用,但总体而言用的越来越少了。
|
||||
|
||||
CSV (以逗号\TAB、换行符分割)还算紧凑,但是表达能力有限。数据库表导出有时会用。
|
||||
CSV(以逗号\TAB、换行符分割)还算紧凑,但是表达能力有限。数据库表导出有时会用。
|
||||
|
||||
除了不够紧凑外,**文本编码(text encoding)** 还有以下缺点:
|
||||
|
||||
1. 对**数值类型支持不够**。CSV 和 XML 直接不支持,万物皆字符串。JSON 虽区分字符串和数值,但是不进一步区分细分数值类型。可以理解,毕竟文本编码嘛,主要还是面向字符串。
|
||||
2. **对二进制数据支持不够**。支持 Unicode,但是对二进制串支持不够,可能会显示为乱码。虽然可以通过 Base64 编码来绕过,但有点做无用功的感觉。
|
||||
3. **XML和 JSON 支持额外的模式**。模式会描述数据的类型,告诉你如何理解数据。配合这些模式语言,虽然可以让 XML 和 JSON 变得强大,但是大大增加了复杂度。
|
||||
3. **XML 和 JSON 支持额外的模式**。模式会描述数据的类型,告诉你如何理解数据。配合这些模式语言,虽然可以让 XML 和 JSON 变得强大,但是大大增加了复杂度。
|
||||
4. **CSV 没有任何模式**。
|
||||
|
||||
凡事讲究够用,很多场景下需要数据可读,并且不关心编码效率,那么这几种编码格式就够用了。
|
||||
@ -87,9 +79,9 @@ CSV (以逗号\TAB、换行符分割)还算紧凑,但是表达能力有限
|
||||
|
||||
```json
|
||||
{
|
||||
"userName": "Martin",
|
||||
"favoriteNumber": 1337,
|
||||
"interests": ["daydreaming", "hacking"]
|
||||
"userName": "Martin",
|
||||
"favoriteNumber": 1337,
|
||||
"interests": ["daydreaming", "hacking"]
|
||||
}
|
||||
```
|
||||
|
||||
@ -140,7 +132,7 @@ IDL 是编程语言无关的,可以利用相关代码生成工具,可以将
|
||||
|
||||
1. filed tag 只记录 delta。
|
||||
2. 从而将 field tag 和 type 压缩到一个字节中。
|
||||
3. 对数字使用变长编码和[Zigzag编码](img/ch04-fig04.png)。
|
||||
3. 对数字使用变长编码和[Zigzag 编码](img/ch04-fig04.png)。
|
||||
|
||||
ProtoBuf 与 Thrift Compact Protocol 编码方式很类似,也用了变长编码和 Zigzag 编码。但 ProtoBuf 对于数组的处理与 Thrift 显著不同,使用了 repeated 前缀而非真数组,好处后面说。
|
||||
|
||||
@ -156,20 +148,17 @@ ProtoBuf 与 Thrift Compact Protocol 编码方式很类似,也用了变长编
|
||||
|
||||
1. **向后兼容**:新的代码,在处理新的增量数据格式的同时,也得处理旧的存量数据。
|
||||
2. **向前兼容**:旧的代码,如果遇到新的数据格式,不能 crash。
|
||||
|
||||
- ProtoBuf 和 Thrift 是怎么解决这两个问题的呢?
|
||||
|
||||
**字段标号** + **限定符**(optional、required)
|
||||
|
||||
向后兼容:新加的字段需为 optional。这样在解析旧数据时,才不会出现字段缺失的情况。
|
||||
|
||||
向前兼容:字段标号不能修改,只能追加。这样旧代码在看到不认识的标号时,省略即可。
|
||||
|
||||
**字段标号** + **限定符**(optional、required)
|
||||
向后兼容:新加的字段需为 optional。这样在解析旧数据时,才不会出现字段缺失的情况。
|
||||
向前兼容:字段标号不能修改,只能追加。这样旧代码在看到不认识的标号时,省略即可。
|
||||
|
||||
### 数据类型和模式演变
|
||||
|
||||
修改数据类型比较麻烦:只能够在相容类型中进行修改。
|
||||
|
||||
如不能将字符串修改为整形,但是可以在整形内修改: 32 bit 到 64 bit 整形。
|
||||
如不能将字符串修改为整形,但是可以在整形内修改:32 bit 到 64 bit 整形。
|
||||
|
||||
ProtoBuf 没有列表类型,而有一个 repeated 类型。其好处在于**兼容数组类型**的同时,支持将可选(optional)**单值字段**,修改为**多值字段**。修改后,旧代码在看到新的多值字段时,只会使用最后一个元素。
|
||||
|
||||
@ -189,22 +178,20 @@ record Person {
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "record",
|
||||
"name": "Person",
|
||||
"fields": [
|
||||
{"name": "userName", "type": "string"},
|
||||
{"name": "favoriteNumber", "type": ["null", "long"], "default": null},
|
||||
{"name": "interests", "type": {"type": "array", "items": "string"}}
|
||||
]
|
||||
"type": "record",
|
||||
"name": "Person",
|
||||
"fields": [
|
||||
{ "name": "userName", "type": "string" },
|
||||
{ "name": "favoriteNumber", "type": ["null", "long"], "default": null },
|
||||
{ "name": "interests", "type": { "type": "array", "items": "string" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
可以看到 Avro 没有使用字段标号。
|
||||
|
||||
- 仍是编码之前例子,Avro 只用了 32 个字节,为什么呢?
|
||||
|
||||
他没有编入类型。
|
||||
|
||||
他没有编入类型。
|
||||
|
||||
![ddia4-avro-enc.png](img/ch04-fig05.png)
|
||||
|
||||
@ -213,9 +200,7 @@ record Person {
|
||||
### 写入模式和读取模式
|
||||
|
||||
- 没有字段标号,Avro 如何支持模式演进呢?
|
||||
|
||||
答案是**显式的**使用两种模式。
|
||||
|
||||
答案是**显式的**使用两种模式。
|
||||
|
||||
即,在对数据进行编码(写入文件或者进行传输)时,使用模式 A,称为**写入模式**(writer schema);在对数据进行解码(从文件或者网络读取)时,使用模式 B,称为**读取模式**(reader schema),而两者不必相同,只需兼容。
|
||||
|
||||
@ -236,8 +221,8 @@ record Person {
|
||||
### 模式演化规则
|
||||
|
||||
- 那么如何保证写入模式的兼容呢?
|
||||
1. 在增删字段时,只能添加或删除具有默认值的字段。
|
||||
2. 在更改字段类型时,需要 Avro 支持相应的类型转换。
|
||||
1. 在增删字段时,只能添加或删除具有默认值的字段。
|
||||
2. 在更改字段类型时,需要 Avro 支持相应的类型转换。
|
||||
|
||||
Avro 没有像 ProtoBuf、Thrift 那样的 optional 和 required 限定符,是通过 union 的方式,里指定默认值,甚至多种类型:
|
||||
|
||||
@ -256,19 +241,12 @@ union {null, long, string} field;
|
||||
这取决于不同的应用场景。
|
||||
|
||||
- **所有数据条目同构的大文件**
|
||||
|
||||
典型的就是 Hadoop 生态中。如果一个大文件所有记录都使用相同模式编码,则在文件头包含一次写入模式即可。
|
||||
|
||||
典型的就是 Hadoop 生态中。如果一个大文件所有记录都使用相同模式编码,则在文件头包含一次写入模式即可。
|
||||
- **支持模式变更的数据库表**
|
||||
|
||||
由于数据库表允许模式修改,其中的行可能写入于不同模式阶段。对于这种情况,可以在编码时额外记录一个模式版本号(比如自增),然后在某个地方存储所有的模式版本。
|
||||
|
||||
解码时,通过版本去查询对应的写入模式即可。
|
||||
|
||||
由于数据库表允许模式修改,其中的行可能写入于不同模式阶段。对于这种情况,可以在编码时额外记录一个模式版本号(比如自增),然后在某个地方存储所有的模式版本。
|
||||
解码时,通过版本去查询对应的写入模式即可。
|
||||
- **网络中发送数据**
|
||||
|
||||
在两个进程通信的握手阶段,交换写入模式。比如在一个 session 开始时交换模式,然后在整个 session 生命周期内都用此模式。
|
||||
|
||||
在两个进程通信的握手阶段,交换写入模式。比如在一个 session 开始时交换模式,然后在整个 session 生命周期内都用此模式。
|
||||
|
||||
### 动态生成数据中的模式
|
||||
|
||||
@ -276,7 +254,7 @@ Avro 没有使用字段标号的一个好处是,不需要手动维护字段标
|
||||
|
||||
书中给的例子是对数据库做导出备份,注意和数据库本身使用 Avro 编码不是一个范畴,此处是指导出的数据使用 Avro 编码。
|
||||
|
||||
在数据库表模式发生改变前后,Avro 只需要在导出时依据当时的模式,做相应的转换,生成相应的模式数据即可。但如果使用 PB,则需要自己处理多个备份文件中,字段标号到字段名称的映射关系。其本质在于,Avro 的数据模式可以和数据存在一块,但是 ProtoBuf 的数据模式只能体现在生成的代码中,需要手动维护新旧版本备份数据与PB 生成的代码间的映射。
|
||||
在数据库表模式发生改变前后,Avro 只需要在导出时依据当时的模式,做相应的转换,生成相应的模式数据即可。但如果使用 PB,则需要自己处理多个备份文件中,字段标号到字段名称的映射关系。其本质在于,Avro 的数据模式可以和数据存在一块,但是 ProtoBuf 的数据模式只能体现在生成的代码中,需要手动维护新旧版本备份数据与 PB 生成的代码间的映射。
|
||||
|
||||
### 代码生成和动态语言
|
||||
|
||||
@ -337,7 +315,7 @@ Thrift 和 Protobuf 会依据语言无关的 IDL 定义的模式,生成给定
|
||||
|
||||
有时候需要对数据库做备份到外存。在做备份(或者说快照)时,虽然会有不同时间点生成的数据,但通常会将各种版本数据转化、对齐到最新版本。毕竟,总是要全盘拷贝数据,那就顺便做下转换好了。
|
||||
|
||||
之前也提到了,对于这种场景,生成的是一次性的不可变的备份或者快照数据,使用 Avro 比较合适。此时也是一个很好地契机,可以将数据按需要的格式输出,比如面向分析的按列存储格式:[Parquet](https://parquet.apache.org/docs/file-format/ "Parquet")。
|
||||
之前也提到了,对于这种场景,生成的是一次性的不可变的备份或者快照数据,使用 Avro 比较合适。此时也是一个很好地契机,可以将数据按需要的格式输出,比如面向分析的按列存储格式:[Parquet](https://parquet.apache.org/docs/file-format/ 'Parquet')。
|
||||
|
||||
## 经由服务的数据流:REST 和 RPC
|
||||
|
||||
@ -364,7 +342,7 @@ Thrift 和 Protobuf 会依据语言无关的 IDL 定义的模式,生成给定
|
||||
|
||||
有两种设计 HTTP API 的方法:REST 和 SOAP。
|
||||
|
||||
1. **REST 并不是一种协议,而是一种设计哲学**。它强调简单的 API 格式,使用 URL 来标识资源,使用 HTTP 的动作(GET、POST、PUT、DELETE )来对资源进行增删改查。由于其简洁风格,越来越受欢迎。
|
||||
1. **REST 并不是一种协议,而是一种设计哲学**。它强调简单的 API 格式,使用 URL 来标识资源,使用 HTTP 的动作(GET、POST、PUT、DELETE)来对资源进行增删改查。由于其简洁风格,越来越受欢迎。
|
||||
2. SOAP 是基于 XML 的协议。虽然使用 HTTP,但目的在于独立于 HTTP。现在提的比较少了。
|
||||
|
||||
### RPC 面临的问题
|
||||
@ -429,7 +407,7 @@ REST 相比 RPC 的好处在于,它不试图隐去网络,更为显式,让
|
||||
|
||||
消息队列的**送达保证**因实现和配置而异,包括:
|
||||
|
||||
1. **最少一次 (at-least-once)**:同一条数据可能会送达多次给消费者。
|
||||
1. **最少一次(at-least-once)**:同一条数据可能会送达多次给消费者。
|
||||
2. **最多一次(at-most-once)**:同一条数据最多会送达一次给消费者,有可能丢失。
|
||||
3. **严格一次(exactly-once)**:同一条数据保证会送达一次,且最多一次给消费者。
|
||||
|
||||
@ -446,12 +424,12 @@ REST 相比 RPC 的好处在于,它不试图隐去网络,更为显式,让
|
||||
|
||||
### 分布式的 Actor 框架
|
||||
|
||||
**Actor 模型**是一种基于消息传递的并发编程模型。 Actor 通常是由状态(State)、行为(Behavior)和信箱(MailBox,可以认为是一个消息队列)三部分组成:
|
||||
**Actor 模型**是一种基于消息传递的并发编程模型。Actor 通常是由状态(State)、行为(Behavior)和信箱(MailBox,可以认为是一个消息队列)三部分组成:
|
||||
|
||||
1. 状态:Actor 中包含的状态信息。
|
||||
2. 行为:Actor 中对状态的计算逻辑。
|
||||
3. 信箱:Actor 接受到的消息缓存地。
|
||||
|
||||
由于 Actor 和外界交互都是通过消息,因此本身可以并行的,且不需要加锁。
|
||||
由于 Actor 和外界交互都是通过消息,因此本身可以并行的,且不需要加锁。
|
||||
|
||||
分布式的 Actor 框架,本质上是将消息队列和 actor 编程模型集成到一块。自然,在 Actor 滚动升级是,也需要考虑前后向兼容问题。
|
||||
|
63
ch05.md
63
ch05.md
@ -9,14 +9,11 @@
|
||||
3. **提高读吞吐**:平滑扩展可用于查询的机器。
|
||||
|
||||
> 本章假设我们的数据系统中所有数据能够存放到一台机器中,则本章只需考虑多机冗余的问题。如果数据超过单机尺度该怎么办?那是下一章要解决的事情。
|
||||
>
|
||||
|
||||
如果数据是**只读**的,则冗余很好做,直接复制到多机即可。我们有时可以利用这个特性,使用分治策略,将数据分为只读部分和读写部分,则只读部分的冗余就会容易处理的多,甚至可以用 EC 方式做冗余,减小存储放大的同时,还提高了可用性。
|
||||
|
||||
- 想想 EC 牺牲了什么?
|
||||
|
||||
以计算换存储。
|
||||
|
||||
以计算换存储。
|
||||
|
||||
但难点就在于,数据允许数据变更时,如何维护多机冗余且一致。常用的冗余控制算法有:
|
||||
|
||||
@ -72,10 +69,8 @@
|
||||
3. **全异步**:所有的从副本都异步写入。网络环境比较好的话,可以这么配置。
|
||||
|
||||
> 异步复制可能会造成副本丢失等严重问题,为了能兼顾一致性和性能,学术界也在不断研究新的复制方法。如,**链式复制(chain-replication)**。
|
||||
>
|
||||
|
||||
> 多副本的一致性和共识性有诸多联系,本书后面章节会讨论。
|
||||
>
|
||||
|
||||
## 新增副本
|
||||
|
||||
@ -98,7 +93,7 @@
|
||||
|
||||
### **从副本宕机:追赶恢复**。
|
||||
|
||||
类似于新增从副本。如果落后的多,可以直接向主副本拉取快照+日志;如果落后的少,可以仅拉取缺失日志。
|
||||
类似于新增从副本。如果落后的多,可以直接向主副本拉取快照 + 日志;如果落后的少,可以仅拉取缺失日志。
|
||||
|
||||
### **主副本宕机:故障转移。**
|
||||
|
||||
@ -134,7 +129,7 @@
|
||||
|
||||
### 基于语句的复制
|
||||
|
||||
主副本记录下所有更新语句:`INSERT`、`UPDATE` 或 `DELETE` 然后发给从库。主副本在这里类似于充当其他从副本的**伪客户端**。
|
||||
主副本记录下所有更新语句:`INSERT`、`UPDATE` 或 `DELETE` 然后发给从库。主副本在这里类似于充当其他从副本的**伪客户端**。
|
||||
|
||||
但这种方法有一些问题:
|
||||
|
||||
@ -149,7 +144,7 @@
|
||||
|
||||
但是 Corner Case 实在太多,步骤 1 需要考虑的情况太多。
|
||||
|
||||
### 传输预写日志( WAL)
|
||||
### 传输预写日志(WAL)
|
||||
|
||||
我们发现主流的存储引擎都有**预写日志**(WAL,为了宕机恢复):
|
||||
|
||||
@ -178,7 +173,7 @@
|
||||
2. **对于删除行**:日志需要包含待删除行标识,可以是主键,也可以是其他任何可以唯一标识行的信息。
|
||||
3. **对于更新行**:日志需要包含待更新行的标志,以及所有列值(至少是要更新的列值)
|
||||
|
||||
对于多行修改来说,比如事务,可以在修改之后增加一条事务提交的记录。 MySQL 的 binlog 就是这么干的。
|
||||
对于多行修改来说,比如事务,可以在修改之后增加一条事务提交的记录。MySQL 的 binlog 就是这么干的。
|
||||
|
||||
使用逻辑日志的**好处**有:
|
||||
|
||||
@ -203,7 +198,7 @@
|
||||
|
||||
# 复制滞后问题
|
||||
|
||||
如前所述,使用多副本的好处有:
|
||||
如前所述,使用多副本的好处有:
|
||||
|
||||
1. **可用性**:容忍部分节点故障
|
||||
2. **可伸缩性**:增加读副本处理更多读请求
|
||||
@ -217,10 +212,9 @@
|
||||
|
||||
如果此时不再有写入,经过一段时间后,多副本最终会达到一致:**最终一致性**。
|
||||
|
||||
在实际中,网络通常比较快,**副本滞后(replication lag)**不太久,也即这个*最终***通常**不会太久,比如 ms 级别,最多 s 级别。但是,对于分布式系统,谁都不敢打包票,由于网络分区、机器高负载等等软硬件问题,在极端情况下,这个*最终*可能会非常久。
|
||||
在实际中,网络通常比较快,**副本滞后(replication lag)**不太久,也即这个\*最终**\*通常**不会太久,比如 ms 级别,最多 s 级别。但是,对于分布式系统,谁都不敢打包票,由于网络分区、机器高负载等等软硬件问题,在极端情况下,这个*最终*可能会非常久。
|
||||
|
||||
> 总之,**最终**是一个非常不精确的限定词。
|
||||
>
|
||||
|
||||
对于这种最终一致的系统,在工程中,要考虑到由于副本滞后所带来的一致性问题。
|
||||
|
||||
@ -230,14 +224,14 @@
|
||||
|
||||
上图问题在于,在一个**异步复制**的分布式数据库里,同一个客户端,写入**主副本**后返回;稍后再去读一个落后的**从副本**,就会发现:读不到自己刚写的内容!
|
||||
|
||||
为了避免这种反直觉的事情发生,我们引入一种新的一致性:**读写一致性(read-after-write consistency)**,或者 **读你所写一致性(read-your-writes consistency)**。
|
||||
为了避免这种反直觉的事情发生,我们引入一种新的一致性:**读写一致性(read-after-write consistency)**,或者 **读你所写一致性(read-your-writes consistency)**。
|
||||
|
||||
若数据库提供这种一致性保证,对于**单个客户端**来说,就一定能够读到其所写变动。也即,这种一致性是从**单个客户端**角度来看的一种因果一致性。
|
||||
|
||||
那么如何提供这种保证,或者说,实现这种一致性呢?列举几种方案:
|
||||
|
||||
1. **按内容分类**。对于客户端可能修改的内容集,**只从主副本读取**。如社交网络上的个人资料,读自己的资料时,从主副本读取;但读其他人资料时,可以向从副本读。
|
||||
2. **按时间分类**。 如果每个客户端都能访问基本所有数据,则方案一就会退化成所有数据都要从主副本读取,这显然不可接受。此时,可以按时间分情况讨论,近期内有过改动的数据,从主副本读,其他的,向从副本读。那这个区分是否最近的**时间阈值**(比如一分钟)如何选取呢?可以监控从副本一段时间内的最大延迟这个经验值,来设置。
|
||||
2. **按时间分类**。如果每个客户端都能访问基本所有数据,则方案一就会退化成所有数据都要从主副本读取,这显然不可接受。此时,可以按时间分情况讨论,近期内有过改动的数据,从主副本读,其他的,向从副本读。那这个区分是否最近的**时间阈值**(比如一分钟)如何选取呢?可以监控从副本一段时间内的最大延迟这个经验值,来设置。
|
||||
3. **利用时间戳**。客户端记下本客户端上次改动时的时间戳,在读从副本时,利用此时间戳来看某个从副本是否已经同步了改时间戳之前内容。可以在所有副本中找到一个已同步了的;或者阻塞等待某个副本同步到改时间戳后再读取。时间戳可以是逻辑时间戳,也可以是物理时间戳(此时多机时钟同步非常重要)。
|
||||
|
||||
会有一些实际的复杂 case:
|
||||
@ -254,9 +248,7 @@
|
||||
于是,我们再引入一种一致性保证:**单调读(Monotonic reads)**。
|
||||
|
||||
- 读写一致性和单调读有什么区别?
|
||||
|
||||
写后读保证的是写后读顺序,单调读保证的是**多次读**之间的顺序。
|
||||
|
||||
写后读保证的是写后读顺序,单调读保证的是**多次读**之间的顺序。
|
||||
|
||||
如何实现单调读?
|
||||
|
||||
@ -287,7 +279,7 @@
|
||||
多副本异步复制所带来的一致性问题,都可以通过**事务(transaction)** 来解决。单机事务已经存在了很长时间,但在数据库走向分布式时代,一开始很多 NoSQL 系统抛弃了事务。
|
||||
|
||||
- 这是为什么?
|
||||
1. 更容易的实现。2. 更好的性能。3. 更好的可用性。
|
||||
1. 更容易的实现。2. 更好的性能。3. 更好的可用性。
|
||||
|
||||
于是复杂度被转移到了应用层。
|
||||
|
||||
@ -319,11 +311,11 @@
|
||||
|
||||
单主和多主,在多数据中心场景下的对比:
|
||||
|
||||
| 对比项 | 单主模型 | 多主模型 |
|
||||
| --- | --- | --- |
|
||||
| 性能 | 所有写入都要路由到一个数据中心 | 写入可以就近 |
|
||||
| 可用性 | 主副本所在数据中心故障,需要有个切主的过程 | 每个数据中心可以独立工作 |
|
||||
| 网络 | 跨数据中心,写入对网络抖动更敏感 | 数据中心间异步复制,对公网容错性更高 |
|
||||
| 对比项 | 单主模型 | 多主模型 |
|
||||
| ------ | ------------------------------------------ | ------------------------------------ |
|
||||
| 性能 | 所有写入都要路由到一个数据中心 | 写入可以就近 |
|
||||
| 可用性 | 主副本所在数据中心故障,需要有个切主的过程 | 每个数据中心可以独立工作 |
|
||||
| 网络 | 跨数据中心,写入对网络抖动更敏感 | 数据中心间异步复制,对公网容错性更高 |
|
||||
|
||||
但是多主模型在一致性方面有很大缺陷:如果两个数据中心同时修改同样的数据,必须合理解决写冲突。另外,对于数据库来说,多主很难保证一些自增主键、触发器和完整性约束的一致性。因此在工程实践中,多主用的相对较少。
|
||||
|
||||
@ -403,7 +395,7 @@ git 也是一个类似的协议。
|
||||
|
||||
1. **给每个写入一个序号,并且后者胜**。本质上是使用外部系统对所有事件进行定序。但可能会产生数据丢失。举个例子,对于一个账户,原有 10 元,客户端 A - 8,客户端 B - 3,任何一个单独成功都有问题。
|
||||
2. **给每个副本一个序号,序号更高的副本有更高的优先级**。这也会造成低序号副本的数据丢失。
|
||||
3. **提供一种自动的合并冲突的方式**。如,假设结果是字符串,则可以将其排序后,使用连接符进行链接,如在之前 Wiki 的冲突中,合并后的标题为 “B/C”
|
||||
3. **提供一种自动的合并冲突的方式**。如,假设结果是字符串,则可以将其排序后,使用连接符进行链接,如在之前 Wiki 的冲突中,合并后的标题为“B/C”
|
||||
4. **使用程序定制一种保留所有冲突值信息的冲突解决策略**。也可以将这个定制权,交给用户。
|
||||
|
||||
### 自定义解决
|
||||
@ -463,7 +455,6 @@ TODO(自动冲突解决)
|
||||
在关系数据库时代,无主模型已经快被忘却。从 Amazon 的 Dynamo 论文开始,无主模型又重新大放异彩,Riak,Cassandra 和 Voldemort 都受其启发,可以统称为 **Dynamo 流(Dynamo-style)**。
|
||||
|
||||
> 奇特的是,Amazon 的一款数据库产品 DynamoDB,和 Dynamo 并不是一个东西。
|
||||
>
|
||||
|
||||
通常来说,在无主模型中,写入时可以:
|
||||
|
||||
@ -529,7 +520,7 @@ Dynamo 流派的存储中通常有两种机制:
|
||||
2. 对于写入并发,如果处理冲突不当时。比如使用 last-win 策略,根据本地时间戳挑选时,可能由于时钟偏差造成数据丢失。
|
||||
3. 对于读写并发,写操作仅在部分节点成功就被读取,此时不能确定应当返回新值还是旧值。
|
||||
4. 如果写入节点数 < w 导致写入失败,但并没有对数据进行回滚时,客户端读取时,仍然会读到旧的数据。
|
||||
5. 虽然写入时,成功节点数 > w,但中间有故障造成了一些副本宕机,导致成功副本数 < w,则在读取时可能会出现问题。
|
||||
5. 虽然写入时,成功节点数 > w,但中间有故障造成了一些副本宕机,导致成功副本数 < w,则在读取时可能会出现问题。
|
||||
6. 即使都正常工作,也有可能出现一些关于时序(timing)的边角情况。
|
||||
|
||||
因此,虽然 Quorum 读写看起来能够保证返回最新值,但在工程实践中,有很多细节需要处理。
|
||||
@ -538,7 +529,7 @@ Dynamo 流派的存储中通常有两种机制:
|
||||
|
||||
### 一致性监控
|
||||
|
||||
对副本数据陈旧性监控,能够让你了解副本的健康情况,当其落后太多时,可以及时调查原因。
|
||||
对副本数据陈旧性监控,能够让你了解副本的健康情况,当其落后太多时,可以及时调查原因。
|
||||
|
||||
基于领导者的多副本模型,由于每个副本复制顺序一致,则可以方便的给出每个副本的落后(lag)进度。
|
||||
|
||||
@ -578,7 +569,7 @@ Dynamo 流派的存储中通常有两种机制:
|
||||
|
||||
![dynamo style datastore](img/ch05-fig12.png)
|
||||
|
||||
如上图,如果每个节点不去检查顺序,而是简单的接受写入请求,就落到本地,不同副本间可能就会出现永久不一致:上图 Node1 和 Node3 上副本X 的值是 A,Node2 上副本 X 的值是 B。
|
||||
如上图,如果每个节点不去检查顺序,而是简单的接受写入请求,就落到本地,不同副本间可能就会出现永久不一致:上图 Node1 和 Node3 上副本 X 的值是 A,Node2 上副本 X 的值是 B。
|
||||
|
||||
为了使所有副本最终一致,需要有一种手段来解决并发冲突。
|
||||
|
||||
@ -603,7 +594,7 @@ LWW 有一个问题,就是多个并发写入的客户端,可能都认为自
|
||||
|
||||
1. A happens before B
|
||||
2. B happens before A
|
||||
3. A B 并发
|
||||
3. A B 并发
|
||||
|
||||
从另外一个角度来说(集合运算),
|
||||
|
||||
@ -614,10 +605,8 @@ A 和 B 并发 < === > A 不 happens-before B && B 不 happens-before A
|
||||
如果两个操作可以定序,则 last write win;如果两个操作并发,则需要进行冲突解决。
|
||||
|
||||
> 并发、时间和相对性
|
||||
>
|
||||
|
||||
> Lamport 时钟相关论文中有详细推导相关概念关系。为了定义并发,事件发生的绝对时间先后并不重要,只要两个事件都意识不到对方的存在,则称两个操作 “并发”。 从狭义相对论上来说,只要两个事件发生的时间差,小于光在两者距离传播所用时间,则两个事件不可能互相影响。推广到计算机网络中,只要由于网络问题导致,在事件发生时间差内,两者不能互相意识到,则称其是并发的。
|
||||
>
|
||||
> Lamport 时钟相关论文中有详细推导相关概念关系。为了定义并发,事件发生的绝对时间先后并不重要,只要两个事件都意识不到对方的存在,则称两个操作“并发”。从狭义相对论上来说,只要两个事件发生的时间差,小于光在两者距离传播所用时间,则两个事件不可能互相影响。推广到计算机网络中,只要由于网络问题导致,在事件发生时间差内,两者不能互相意识到,则称其是并发的。
|
||||
|
||||
### 确定 Happens-Before 关系
|
||||
|
||||
@ -637,10 +626,10 @@ A 和 B 并发 < === > A 不 happens-before B && B 不 happens-before A
|
||||
|
||||
总结下,该算法如下:
|
||||
|
||||
1. 服务器为每个键分配一个版本号 V ,每次该键有写入时,将 V + 1,并将版本号与写入的值一块保存。
|
||||
1. 服务器为每个键分配一个版本号 V,每次该键有写入时,将 V + 1,并将版本号与写入的值一块保存。
|
||||
2. 当客户端读取该键时,服务器将返回所有未被覆盖的值以及最新的版本号。
|
||||
3. 客户端在进行下次写入时,必须**包含**之前读到的版本号 Vx(说明基于哪个版本进行新的写入),并将读取的值合并到一块。
|
||||
4. 当服务器收到特定版本号 Vx 的写入时,可以用其值覆盖所有 V ≤ Vx 的值。、
|
||||
4. 当服务器收到特定版本号 Vx 的写入时,可以用其值覆盖所有 V ≤ Vx 的值。、
|
||||
|
||||
如果又来一个新的写入,不基于任何版本号,则该写入不会覆盖任何内容。
|
||||
|
||||
@ -658,14 +647,14 @@ A 和 B 并发 < === > A 不 happens-before B && B 不 happens-before A
|
||||
上面例子只有单个副本。将该算法扩展到无主多副本模型时,只使用一个版本值显然不够,这时需要给每个副本的键都引入版本号,对于同一个键来说,不同副本的版本会构成**版本向量(version vector)**。
|
||||
|
||||
```c
|
||||
key1
|
||||
key1
|
||||
A Va
|
||||
B Vb
|
||||
C Vc
|
||||
|
||||
key1: [Va, Vb, Vc]
|
||||
|
||||
[Va-x, Vb-y, Vc-z] <= [Va-x1, Vb-y1, Vc-z1] <==>
|
||||
[Va-x, Vb-y, Vc-z] <= [Va-x1, Vb-y1, Vc-z1] <==>
|
||||
x <= x1 && y <= y1 && z <= z1
|
||||
```
|
||||
|
||||
|
24
ch06.md
24
ch06.md
@ -6,7 +6,6 @@
|
||||
2. **复制(Replication**):系统机器一多,单机故障概率便增大,为了防止数据丢失以及服务高可用,需要做多副本。
|
||||
|
||||
> 分片,Partition,有很多别称。通用的有 Shard;具体到实际系统,HBase 中叫 Region,Bigtable 中叫 tablet,等等。本质上是对数据集的一种逻辑划分,后面行文,分片和分区可能混用,且有时为名词,有时为动词。
|
||||
>
|
||||
|
||||
通常来说,数据系统在分布式系统中会有三级划分:数据集(如 Database、Bucket)——分片(Partition)——数据条目(Row、KV)。通常,每个分片只属于一个数据集,每个数据条目只属于一个分片。单个分片,就像一个小点的数据库。但是,跨分区的操作的,就要复杂的多。
|
||||
|
||||
@ -63,13 +62,13 @@
|
||||
|
||||
![encyclopedia example](img/ch06-fig02.png)
|
||||
|
||||
由于键并不一定在定义域内均匀分布,因此简单按照定义域等分,并不能将数据等分。因此,需要按照数据的分布,动态调整分区的界限,保证分区间数据大致均匀。这个调整的过程,既可以手动完成 ,也可以自动进行。
|
||||
由于键并不一定在定义域内均匀分布,因此简单按照定义域等分,并不能将数据等分。因此,需要按照数据的分布,动态调整分区的界限,保证分区间数据大致均匀。这个调整的过程,既可以手动完成,也可以自动进行。
|
||||
|
||||
按键范围分区好处在于可以进行**快速的范围查询(Rang Query)**。如,某个应用是保存传感器数据,并将时间戳作为键进行分区,则可轻松获取一段时间内(如某年,某月)的数据。
|
||||
|
||||
但坏处在于,数据分散不均匀,且容易造成热点。可能需要动态的调整的分区边界,以维护分片的相对均匀。
|
||||
|
||||
仍以传感器数据存储为例,以时间戳为 Key,按天的粒度进行分区,所有最新写入都被路由到最后一个分区节点,造成严重的写入倾斜,不能充分利用所有机器的写入带宽。一个解决办法是**分级**或者**混合**,使用拼接主键,如使用传感器名称+时间戳作为主键,则可以将同时写入的多个传感器的数据分散到多机上去。
|
||||
仍以传感器数据存储为例,以时间戳为 Key,按天的粒度进行分区,所有最新写入都被路由到最后一个分区节点,造成严重的写入倾斜,不能充分利用所有机器的写入带宽。一个解决办法是**分级**或者**混合**,使用拼接主键,如使用传感器名称 + 时间戳作为主键,则可以将同时写入的多个传感器的数据分散到多机上去。
|
||||
|
||||
## 按键散列(Hash)分区
|
||||
|
||||
@ -83,13 +82,13 @@
|
||||
|
||||
![partition by hash key](img/ch06-fig03.png)
|
||||
|
||||
还有一种常提的哈希方法叫做[一致性哈希](https://zh.m.wikipedia.org/zh-hans/%E4%B8%80%E8%87%B4%E5%93%88%E5%B8%8C) 。其特点是,会考虑逻辑分片和物理拓扑,将数据和物理节点按同样的哈希函数进行哈希,来决定如何将哈希分片路由到不同机器上。它可以避免在内存中维护**逻辑分片到物理节点的映射**,而是每次计算出来。即用一套算法同时解决了我们在最初提出的逻辑分片和物理路由的两个问题。 比较经典的数据系统,[Amazon Dynamo](https://www.qtmuniao.com/2020/06/13/dynamo/) 就用了这种方式。
|
||||
还有一种常提的哈希方法叫做[一致性哈希](https://zh.m.wikipedia.org/zh-hans/%E4%B8%80%E8%87%B4%E5%93%88%E5%B8%8C) 。其特点是,会考虑逻辑分片和物理拓扑,将数据和物理节点按同样的哈希函数进行哈希,来决定如何将哈希分片路由到不同机器上。它可以避免在内存中维护**逻辑分片到物理节点的映射**,而是每次计算出来。即用一套算法同时解决了我们在最初提出的逻辑分片和物理路由的两个问题。比较经典的数据系统,[Amazon Dynamo](https://www.qtmuniao.com/2020/06/13/dynamo/) 就用了这种方式。
|
||||
|
||||
![dynamo partitioning and replication](img/ch06-dynamo.png)
|
||||
|
||||
如果不使用一致性哈希,我们需要在元数据节点中,维护逻辑分片到物理节点的映射。则在某些物理节点宕机后,需要调整该映射并手动进行数据迁移,而不能像一致性哈希一样,半自动的增量式迁移。
|
||||
|
||||
哈希分片在获取均匀散列能力的同时,也丧失了基于键高效的范围查询能力。如书中说,MongoDB 中选择基于哈希的分区方式,范围查询就要发送到所有分区节点;Riak 、Couchbase 或 Voldmort 干脆不支持主键的上的范围查询。
|
||||
哈希分片在获取均匀散列能力的同时,也丧失了基于键高效的范围查询能力。如书中说,MongoDB 中选择基于哈希的分区方式,范围查询就要发送到所有分区节点;Riak、Couchbase 或 Voldmort 干脆不支持主键的上的范围查询。
|
||||
|
||||
一种折中方式,和上小节一样,使用组合的方式,先散列,再顺序。如使用主键进行散列得到分区,在每个分区内使用其他列顺序存储。如在社交网络上,首先按 user_id 进行散列分区,再使用 update_time 对用户事件进行顺序排序,则可以通过 (user_id, update_timestamp) 高效查询某个用户一段事件的事件。
|
||||
|
||||
@ -118,12 +117,11 @@
|
||||
1. 本地索引(local index),书中又称 document-based index
|
||||
2. 全局索引(global index),书中又称 term-based index
|
||||
|
||||
> 注:书中给的 document-based、term-based 两个名词(包括 document 和 term)是从搜索中来的。由于搜索中都是 term→ document id list 的映射,document-based 是指按 document id 进行分区,每个分区存的索引都是本地的 document ids,而不管其他分区,因此是本地索引,查询时需要发到所有分区逐个查询。term-based 是指按 term 进行分区,则每个倒排索引都是存的全局的 document id list,因此查询的时候只需要去 term 所在分区查询即可。
|
||||
>
|
||||
> 注:书中给的 document-based、term-based 两个名词(包括 document 和 term)是从搜索中来的。由于搜索中都是 term→ document id list 的映射,document-based 是指按 document id 进行分区,每个分区存的索引都是本地的 document ids,而不管其他分区,因此是本地索引,查询时需要发到所有分区逐个查询。term-based 是指按 term 进行分区,则每个倒排索引都是存的全局的 document id list,因此查询的时候只需要去 term 所在分区查询即可。
|
||||
|
||||
## 本地索引
|
||||
|
||||
书中举了一个维护汽车信息数据例子:每种汽车信息由 (id, color, make, location) 四元组构成。首先会根据其主键 id 进行分区,其次为了方便查询,需要对汽车颜色( color )和制造商(make)字段(文档数据库中称为**field,字段**;关系型数据库中称为 **column,列**,图数据库中称为 **property,属性**)建立次级索引。
|
||||
书中举了一个维护汽车信息数据例子:每种汽车信息由 (id, color, make, location) 四元组构成。首先会根据其主键 id 进行分区,其次为了方便查询,需要对汽车颜色(color)和制造商(make)字段(文档数据库中称为**field,字段**;关系型数据库中称为 **column,列**,图数据库中称为 **property,属性**)建立次级索引。
|
||||
|
||||
次级索引会对每个数据条目建立一个索引条目,这给数据库的实现带来了一些问题:
|
||||
|
||||
@ -134,7 +132,7 @@
|
||||
|
||||
本地**索引(local index)**,就是对每个数据分区独立地建立次级索引,即,次级索引只针对本分区数据,而不关心其他分区数据。本地索引的**优点**是维护方便,在更新数据时,只需要在该分区所在机器同时更新索引即可。但**缺点**是,查询效率相对较低,所有基于索引的查询请求,都要发送到所有分区,并将结果合并,该过程也称为 **scatter/gather** 。但即使用多分区并发(而非顺序)进行索引查询优化,也仍然容易在某些机器上发生**长尾请求**(由于机器负载过高或者网络问题,导致改请求返回特别慢,称为长尾请求),导致整个请求过程变慢。
|
||||
|
||||
但由于实现简单,本地索引被广泛使用,如 MongoDB,Riak ,Cassandra,Elasticsearch ,SolrCloud 和 VoltDB 都使用本地索引。
|
||||
但由于实现简单,本地索引被广泛使用,如 MongoDB,Riak,Cassandra,Elasticsearch,SolrCloud 和 VoltDB 都使用本地索引。
|
||||
|
||||
## 全局索引
|
||||
|
||||
@ -213,14 +211,12 @@
|
||||
|
||||
但他们的分区数量都和集群节点数量没有直接关系。而另一种均衡策略,则是保持**总分区数量**和节点数量成正比,也即,保持每个节点分区数量不变。
|
||||
|
||||
假设集群有 m 个节点,每个节点有 n 个分区,在此种均衡策略下,当有新节点加入时,会从 m*n 个分区中随机选择 n 个分区,将其一分为二,一半由新节点分走,另一半留在原机器上。
|
||||
假设集群有 m 个节点,每个节点有 n 个分区,在此种均衡策略下,当有新节点加入时,会从 m\*n 个分区中随机选择 n 个分区,将其一分为二,一半由新节点分走,另一半留在原机器上。
|
||||
|
||||
随机选择,很容易产生有倾斜的分割。但如果 n 比较大,如 Cassandra 默认是 256,则新节点会比较容易均摊负载。
|
||||
|
||||
- 为什么?
|
||||
|
||||
是因为可以从每个节点选同样数量的分区吗?比如说 n = 256, m = 16,则可以从每个节点选 16 分区吗?
|
||||
|
||||
是因为可以从每个节点选同样数量的分区吗?比如说 n = 256,m = 16,则可以从每个节点选 16 分区吗?
|
||||
|
||||
随机选择分区,要求使用基于哈希的分区策略,这也是最接近原始一致性哈希的定义的方法。(同样存疑。
|
||||
|
||||
@ -260,7 +256,7 @@
|
||||
|
||||
**使用内部元数据服务器**。如三节点的 Meta 服务器,每个节点都存储一份路由数据,使用某种共识协议达成一致。如 TiDB 的 Placement Driver。
|
||||
|
||||
**使用某种协议点对点同步**。如 Dynamo 、Cassandra 和 Riak 使用流言协议(Gossip Protocol),在集群内所有机器节点间就路由信息进行传播,并最终达成一致。
|
||||
**使用某种协议点对点同步**。如 Dynamo、Cassandra 和 Riak 使用流言协议(Gossip Protocol),在集群内所有机器节点间就路由信息进行传播,并最终达成一致。
|
||||
|
||||
更简单一些,如 Couchbase 不支持自动的负载均衡,因此只需要使用一个路由层通过心跳从集群节点收集到所有路由信息即可。
|
||||
|
||||
|
73
ch07.md
73
ch07.md
@ -44,7 +44,7 @@
|
||||
|
||||
这个保证很重要,否则用户在执行到一半出错时,很难知道哪些操作已经生效、哪些操作尚未生效。有了此保证,用户如果发现出错,可以安全的进行重试。
|
||||
|
||||
### 一致性( Consistency)
|
||||
### 一致性(Consistency)
|
||||
|
||||
**一致性**,是一个被广泛使用的词,在不同上下文中,有不同含义:
|
||||
|
||||
@ -60,7 +60,7 @@
|
||||
1. 应用侧需要写入满足应用侧视角约束要求的数据。
|
||||
2. 数据库侧需要保证多次写入前后,尤其是遇到问题时,维持该约束。
|
||||
|
||||
因此,一致性可以表述为,应用侧依赖数据库提供的原子性、隔离性来实现一致性。可见,一致性并非数据库事务本身的一种特性,更多的是应用侧的一种属性。据此,乔・海勒斯坦(Joe Hellerstein)认为,在 Härder 与 Reuter 的论文中,“ACID 中的 C” 是 “用来凑数的”。
|
||||
因此,一致性可以表述为,应用侧依赖数据库提供的原子性、隔离性来实现一致性。可见,一致性并非数据库事务本身的一种特性,更多的是应用侧的一种属性。据此,乔・海勒斯坦(Joe Hellerstein)认为,在 Härder 与 Reuter 的论文中,“ACID 中的 C”是“用来凑数的”。
|
||||
|
||||
### 隔离性(Isolation)
|
||||
|
||||
@ -68,7 +68,7 @@
|
||||
|
||||
![race condition](img/ch07-fig01.png)
|
||||
|
||||
设有一个计数器,且数据库没有内置原子的自增操作,有两个用户,各自读取当前值,加 1 后写回。如图,期望计数器由 42 变为 44,但由于并发问题,最终变成了 43 。
|
||||
设有一个计数器,且数据库没有内置原子的自增操作,有两个用户,各自读取当前值,加 1 后写回。如图,期望计数器由 42 变为 44,但由于并发问题,最终变成了 43。
|
||||
|
||||
ACID 中隔离性是指,每个事务的执行是互相隔离的,每个事务都可以认为自己是系统中唯一正在运行的事务,因此传统上,教科书将事务隔离形式称为:**可串行化(Serializability)**。即,如果所有事务都串行执行,则任意时刻必然只有一个事务在执行,从而在根本上消除任何并发问题。
|
||||
|
||||
@ -99,7 +99,7 @@ ACID 中隔离性是指,每个事务的执行是互相隔离的,每个事务
|
||||
|
||||
## 单对象和多对象操作
|
||||
|
||||
总结来说,在 ACID 中,原子性和隔离性是数据库对用户进行多个写入时需要提供的保证,并且它们通常假设一个事务中会同时修改多个对象(rows、documents 和 records )。相比**单对象事务**(single-object transaction),这种多**对象事务**(multi-objects transaction)是一种更强的保证,且更常用,因为通常多个写入不会只针对单个对象。
|
||||
总结来说,在 ACID 中,原子性和隔离性是数据库对用户进行多个写入时需要提供的保证,并且它们通常假设一个事务中会同时修改多个对象(rows、documents 和 records)。相比**单对象事务**(single-object transaction),这种多**对象事务**(multi-objects transaction)是一种更强的保证,且更常用,因为通常多个写入不会只针对单个对象。
|
||||
|
||||
设有电子邮件情景,邮箱首页需要如下语句来展示未读邮件数:
|
||||
|
||||
@ -109,7 +109,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
如果邮件过多,为了加快查询,可以使用额外字段将未读邮件数存储存储起来(术语:[denormalization](https://en.wikipedia.org/wiki/Denormalization)),但每次新增、读过邮件之后都要更新该计数值。
|
||||
|
||||
如下图,用户 1 插入一封邮件,然后更新未读邮件数;用户 2 先读取读取邮件列表,后读取未读计数。但邮箱列表中显示有新邮件,但未读计数却显示 0 。
|
||||
如下图,用户 1 插入一封邮件,然后更新未读邮件数;用户 2 先读取读取邮件列表,后读取未读计数。但邮箱列表中显示有新邮件,但未读计数却显示 0。
|
||||
|
||||
![Untitled](img/ch07-fig02.png)
|
||||
|
||||
@ -121,7 +121,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
在多对象事务中,一个关键点是如何确定多个操作是否属于同一事务:
|
||||
|
||||
1. 从**物理上来考虑**。可以通过 TCP 连接来确定,在同一个连接中,`BEGIN TRANSACTION` 和 `COMMIT`语句之间的所有内容,可以认为属于同一个事务。但会有一些 corner case,如在客户端提交请求后,服务器确认提交之前,网络中断,连接断开,此时客户端则无从得知事务是否被成功提交。
|
||||
1. 从**物理上来考虑**。可以通过 TCP 连接来确定,在同一个连接中,`BEGIN TRANSACTION` 和 `COMMIT`语句之间的所有内容,可以认为属于同一个事务。但会有一些 corner case,如在客户端提交请求后,服务器确认提交之前,网络中断,连接断开,此时客户端则无从得知事务是否被成功提交。
|
||||
2. **从逻辑上来考虑**。使用事务管理器,为每个事务分配一个唯一标识符,从而对操作进行分组。
|
||||
|
||||
实际中基本上使用第二种方法。
|
||||
@ -138,15 +138,15 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
这些问题都非常棘手,如果数据库不提供任何保证,用户侧得写很多的错误处理逻辑。因此,一般的数据库哪怕不支持完整的事务,也会提供针对单个对象的原子性和隔离性。比如,可以使用写前日志来保证原子性,使用锁来保证隔离性。
|
||||
|
||||
其他一些数据库,也会提供更复杂的原子支持,如原子的自增操作,从而避免图 7-1 中的交错更新。另一种更泛化的原子性保证是提供单个对象上的 CAS 操作,允许用户原子的执行针对单个对象的 read-modify-write 操作。当然,如果咬文嚼字一下,**原子自增**(atomic increment),在 ACID 中应该是属于隔离性( Isolation )的范畴,此处的原子自增其实是多线程的概念。
|
||||
其他一些数据库,也会提供更复杂的原子支持,如原子的自增操作,从而避免图 7-1 中的交错更新。另一种更泛化的原子性保证是提供单个对象上的 CAS 操作,允许用户原子的执行针对单个对象的 read-modify-write 操作。当然,如果咬文嚼字一下,**原子自增**(atomic increment),在 ACID 中应该是属于隔离性(Isolation)的范畴,此处的原子自增其实是多线程的概念。
|
||||
|
||||
有的 NoSQL 数据库将上述支持宣传为”轻量级事务“,甚而 PR 成 ”ACID”。但这是极其不负责任的,通常来说,事务是一种将针对**多个对象**的**多个操作**封装为一个执行单元的机制。
|
||||
有的 NoSQL 数据库将上述支持宣传为”轻量级事务“,甚而 PR 成”ACID”。但这是极其不负责任的,通常来说,事务是一种将针对**多个对象**的**多个操作**封装为一个执行单元的机制。
|
||||
|
||||
### 界定对多对象事务的需求
|
||||
|
||||
由于跨机器的分布式事务很难实现、且非常损失性能(可能在一个数量级),很多分布式数据库选择不支持多对象事务。但有些场景确实需要多对象事务,因此一些数据库多将是否打开事务设为一个开关。
|
||||
|
||||
因此,在用户侧,在数据库选型时,有必要审视一下是否真的需要多对象事务,键值对模型和单对象事务是否能够满足需求。一些情况下, 是足够的,但更多的情景,需要协同更新多个对象:
|
||||
因此,在用户侧,在数据库选型时,有必要审视一下是否真的需要多对象事务,键值对模型和单对象事务是否能够满足需求。一些情况下,是足够的,但更多的情景,需要协同更新多个对象:
|
||||
|
||||
1. 在关系型数据库中,一些表通常会有一些外键。在更新时,需要进行同步更新。
|
||||
2. 在文档型数据库中,相关的数据通常会放到一个文档中,但由于大部分文档数据库不支持 Join,因此不得不使用前文提到的 denormalization 对信息进行冗余存储,便产生了同步更新需求。
|
||||
@ -195,7 +195,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
![Untitled](img/ch07-fig04.png)
|
||||
|
||||
在用户 1 的事务提交前,用户 2 看到的 x 值一直是 2 。
|
||||
在用户 1 的事务提交前,用户 2 看到的 x 值一直是 2。
|
||||
|
||||
如果允许脏读会有什么问题?举两个例子:
|
||||
|
||||
@ -229,7 +229,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
粗看**读已提交**已经能够满足事务的定义,比如能够终止事务、能够实现某种程度上的隔离,但仍然会产生一些并发问题。
|
||||
|
||||
如图,考察这样一种场景,Alice 分两个账户,各存了 500 块钱,但如果其两次分别查看两个账户期间,发生了一笔转账交易,则两次查看的余额加起来并不等于 1000 。
|
||||
如图,考察这样一种场景,Alice 分两个账户,各存了 500 块钱,但如果其两次分别查看两个账户期间,发生了一笔转账交易,则两次查看的余额加起来并不等于 1000。
|
||||
|
||||
![Untitled](img/ch07-fig06.png)
|
||||
|
||||
@ -259,7 +259,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
MVCC 的基本要点为:
|
||||
|
||||
1. 每个事务开始时会获取一个自增的、唯一的事务 ID(txid),该 txid = max(existing tx id) + 1。
|
||||
1. 每个事务开始时会获取一个自增的、唯一的事务 ID(txid),该 txid = max(existing tx id) + 1。
|
||||
2. 该事务在修改数据时,不会修改以前版本,而会新增一个具有 txid 版本的数据。
|
||||
3. 该事务只能访问到所有版本 ≤ txid 的数据。
|
||||
4. 在写入时,如果发现某个数据存在 > txid 的版本,则存在写写冲突。
|
||||
@ -268,12 +268,10 @@ MVCC 的基本要点为:
|
||||
|
||||
![Untitled](img/ch07-fig07.png)
|
||||
|
||||
使用 delete by 进行标记删除的原因在于,可能还有正在进行的事务( txid < deleted by )可能会访问该对象。之后,会有专门进行 GC 进程对这些数据进行真正的回收,当然删除时需要确认所有正在进行的事务 txid > deleted by 。
|
||||
使用 delete by 进行标记删除的原因在于,可能还有正在进行的事务(txid < deleted by)可能会访问该对象。之后,会有专门进行 GC 进程对这些数据进行真正的回收,当然删除时需要确认所有正在进行的事务 txid > deleted by。
|
||||
|
||||
- 个人认为不使用 delete by 也能达到标记删除的效果?
|
||||
|
||||
新的版本数据存在后,自动就使得老版本不可见。之后,只要确定没有事务正在访问老版本数据,即可进行 gc。通过min(current tx) > latest version 即可判定没有事务访问了。
|
||||
|
||||
新的版本数据存在后,自动就使得老版本不可见。之后,只要确定没有事务正在访问老版本数据,即可进行 gc。通过 min(current tx) > latest version 即可判定没有事务访问了。
|
||||
|
||||
### 可见性规则
|
||||
|
||||
@ -308,15 +306,15 @@ MVCC 的基本要点为:
|
||||
|
||||
CouchDB、Datomic 和 LMDB 中使用一种 **仅追加 / 写时拷贝(append-only/copy-on-write)**的 B 树变体,是一种**多版本技术**的变体。[boltdb](https://www.qtmuniao.com/2020/11/29/bolt-data-organised/) 参考了 LMDB,也可以归为此类,此类 B 族树每次修改,都会引起叶子节点(所有数据都会落到叶子节点)到根节点的一条路径的全部修改(叶子节点变了,其父节点内容——指针也要修改,从而引起级联修改),如果发生节点的分裂或合并,会引起更大范围的更新。
|
||||
|
||||
这种方式在更新时不会覆盖老的页,每个数据修改都会新生成一个树根,每个树根所代表的树可以视作一个版本的快照。使用某个树根就相当于使用某个版本快照,其所能访问到的数据都属于同一个版本,而无须再进行版本过滤。当然, 这类系统也需要后台常驻的 compaction 和 GC。
|
||||
这种方式在更新时不会覆盖老的页,每个数据修改都会新生成一个树根,每个树根所代表的树可以视作一个版本的快照。使用某个树根就相当于使用某个版本快照,其所能访问到的数据都属于同一个版本,而无须再进行版本过滤。当然,这类系统也需要后台常驻的 compaction 和 GC。
|
||||
|
||||
### 可重复读和命名困惑
|
||||
|
||||
在 1975 年 System R 定义 ANSI SQL 标准的隔离级别时,只定义了 RU、RC、RR 和 Serializability。当时,快照隔离还没有被发明,但是上述四种级别汇总有一个和快照隔离类似的级别:RR、Repeatable Read、可重复读。
|
||||
|
||||
因此,虽然快照隔离级别很有用,尤其是只读事务,但很多数据库虽然实现了快照隔离,但却另有称谓。比如 Oracle 将 SI 称为 **可串行化(Serializable)**, PostgreSQL 和 MySQL 将 SI 称为 **可重复读(repeatable read)**。因为这样可以符合 SQL 标准要求,以号称兼容 SQL 标准。
|
||||
因此,虽然快照隔离级别很有用,尤其是只读事务,但很多数据库虽然实现了快照隔离,但却另有称谓。比如 Oracle 将 SI 称为 **可串行化(Serializable)**,PostgreSQL 和 MySQL 将 SI 称为 **可重复读(repeatable read)**。因为这样可以符合 SQL 标准要求,以号称兼容 SQL 标准。
|
||||
|
||||
但严格来说, SQL 对隔离级别的定义是有问题的,比如标准依赖于实现、几个隔离级别不连续、模糊不精确。很多数据库都号称实现了**可重复读**级别,但它们提供的保证却存在着很大差异。虽然一些文献中有对可重复读进行了精确定义,但大部分实现并不严格满足此定义。到最后,没有人知道可重复读的真正含义。
|
||||
但严格来说,SQL 对隔离级别的定义是有问题的,比如标准依赖于实现、几个隔离级别不连续、模糊不精确。很多数据库都号称实现了**可重复读**级别,但它们提供的保证却存在着很大差异。虽然一些文献中有对可重复读进行了精确定义,但大部分实现并不严格满足此定义。到最后,没有人知道可重复读的真正含义。
|
||||
|
||||
## 防止更新丢失
|
||||
|
||||
@ -332,7 +330,7 @@ CouchDB、Datomic 和 LMDB 中使用一种 **仅追加 / 写时拷贝(append-o
|
||||
|
||||
### 原子写
|
||||
|
||||
有些数据库提供原子的(**针对单个对象的**) read-modify-write 操作,因此,如果应用层逻辑能用这个原子操作表达,就可以避免更新丢失。如大多数关系型数据库都可以使用此种原子操作对计数器进行安全并发更新:
|
||||
有些数据库提供原子的(**针对单个对象的**)read-modify-write 操作,因此,如果应用层逻辑能用这个原子操作表达,就可以避免更新丢失。如大多数关系型数据库都可以使用此种原子操作对计数器进行安全并发更新:
|
||||
|
||||
```sql
|
||||
UPDATE counters SET value = value + 1 WHERE key = 'foo';
|
||||
@ -343,7 +341,7 @@ UPDATE counters SET value = value + 1 WHERE key = 'foo';
|
||||
1. 文档数据库如 MongoDB,提供对文档局部的原子更新操作。
|
||||
2. KV 存储如 Redis,支持对复合数据结构优先队列的原子更新。
|
||||
|
||||
原子操作的通常实现方式为,在读取某对象时,获取其互斥锁,从而阻止其他事务读取该对象。这种实现有时也被称为**游标稳定性**(cursor stability)。如[下图](https://www.ibm.com/docs/zh/informix-servers/12.10?topic=level-cursor-stability-isolation),在 `fetch a row` 处,数据库会释放上一行的互斥锁,同时获取该行的互斥锁,以阻止其他事务对改行进行读取或者修改。如果此处只获取短时读锁,则会退化成读已提交级别。
|
||||
原子操作的通常实现方式为,在读取某对象时,获取其互斥锁,从而阻止其他事务读取该对象。这种实现有时也被称为**游标稳定性**(cursor stability)。如[下图](https://www.ibm.com/docs/zh/informix-servers/12.10?topic=level-cursor-stability-isolation),在 `fetch a row` 处,数据库会释放上一行的互斥锁,同时获取该行的互斥锁,以阻止其他事务对改行进行读取或者修改。如果此处只获取短时读锁,则会退化成读已提交级别。
|
||||
|
||||
```cpp
|
||||
set isolation to cursor stability
|
||||
@ -437,7 +435,7 @@ UPDATE wiki_pages SET content = 'new content' WHERE id = 1234 AND content = 'old
|
||||
1. 由于涉及多个对象,针对单对象的原子操作不能使用。
|
||||
2. 在快照隔离中,想要真正避免写偏序需要真正的可串行化。
|
||||
3. 虽然有些数据库允许指定约束(constraints),但往往是单对象的简单约束,如唯一性、外键约束。当然,可以使用触发器来在应用层维护多对象约束,以解决上述问题。
|
||||
4. 如果没有办法使用可串行的化的隔离级别,还可以利用数据库提供的(for update)机制进行显式的加锁。
|
||||
4. 如果没有办法使用可串行的化的隔离级别,还可以利用数据库提供的(for update)机制进行显式的加锁。
|
||||
|
||||
```sql
|
||||
BEGIN TRANSACTION;
|
||||
@ -447,8 +445,8 @@ SELECT * FROM doctors
|
||||
AND shift_id = 1234 FOR UPDATE;
|
||||
|
||||
UPDATE doctors
|
||||
SET on_call = false
|
||||
WHERE name = 'Alice'
|
||||
SET on_call = false
|
||||
WHERE name = 'Alice'
|
||||
AND shift_id = 1234;
|
||||
|
||||
COMMIT;
|
||||
@ -470,7 +468,7 @@ COMMIT;
|
||||
```sql
|
||||
BEGIN TRANSACTION;
|
||||
-- Check for any existing bookings that overlap with the period of noon-1pm
|
||||
SELECT COUNT(*) FROM bookings
|
||||
SELECT COUNT(*) FROM bookings
|
||||
WHERE room_id = 123 AND
|
||||
end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00';
|
||||
|
||||
@ -490,7 +488,7 @@ COMMIT;
|
||||
|
||||
**抢注用户名**
|
||||
|
||||
在每个用户具有唯一用户名的网站上,两个用户可能会并发的尝试创建具有相同名字的账户。如果使用检查是否存在该名字→没有则注册该名字流程,在快照隔离级别下,是没法避免两个用户注册到相同用户名的。当然,可以通过对用户名列加唯一性约束来保证该特性,这样,第二个事务在提交时会因为违反唯一性约束而终止。
|
||||
在每个用户具有唯一用户名的网站上,两个用户可能会并发的尝试创建具有相同名字的账户。如果使用检查是否存在该名字 → 没有则注册该名字流程,在快照隔离级别下,是没法避免两个用户注册到相同用户名的。当然,可以通过对用户名列加唯一性约束来保证该特性,这样,第二个事务在提交时会因为违反唯一性约束而终止。
|
||||
|
||||
**防止一钱多花**
|
||||
|
||||
@ -500,7 +498,7 @@ COMMIT;
|
||||
|
||||
上述例子都可以归纳为以下模式:
|
||||
|
||||
1. 通过 select 语句+条件过滤出符合条件的所有行。
|
||||
1. 通过 select 语句 + 条件过滤出符合条件的所有行。
|
||||
2. 依赖上述结果,应用侧代码决定是否继续。
|
||||
3. 如果应用侧决定继续,就执行更改(插入、更新或者删除),并提交事务。
|
||||
|
||||
@ -516,7 +514,7 @@ COMMIT;
|
||||
|
||||
幻读在步骤 1 读不到任何对象来进行加锁。那很自然的一个想法就是,能不能手动引入一些对象槽来代表不存在的对象,从而是的加锁成为可能。
|
||||
|
||||
在预定会议室的例子中,可以创建一个会议室号+时间段表,比如每 15 分钟一个时间段。可以在该表中插入未来几个月中所有可预订的会议室号+时间段。如果现在一个事务想要预定某个会议室的某个时间段,便可在该表中将对应对象都锁住,然后执行预定的操作。
|
||||
在预定会议室的例子中,可以创建一个会议室号 + 时间段表,比如每 15 分钟一个时间段。可以在该表中插入未来几个月中所有可预订的会议室号 + 时间段。如果现在一个事务想要预定某个会议室的某个时间段,便可在该表中将对应对象都锁住,然后执行预定的操作。
|
||||
|
||||
需要强调的是,该表只用于防止同时预定同一个会议室的同一个时间段,并不用来存储预定相关信息,可以理解为是个**锁表**,每一行都是一把锁。
|
||||
|
||||
@ -547,7 +545,7 @@ COMMIT;
|
||||
虽然实现可串行化最直观的做法就是将所有事务串行的执行。但在过去几十年,单线程事务的性能基本是不可用的。直到 2007 年左右,一些软硬件的的发展,才促成了单线程事务的真正落地:
|
||||
|
||||
1. **RAM 足够大且便宜**。从而促使某些场景的数据可以都放内存中,即,使用内存数据库。由于不需要每次事务都执行 IO(定期 backup 可能还是需要),单线程事务只需访问内存,因此性能还可以接受。
|
||||
2. **AP、TP 场景的界定和区分**。数据库设计人员发现,在 TP 场景下,读写事务通常持续时间较短、用到的数据规模较小;对比来说, AP 场景通常只包含读取操作。因此可以让长时间、大范围的 AP 场景运行在独立于主事务循环外的**只读事务**上,然后只读事务使用一个一致性的快照即可不影响主循环。
|
||||
2. **AP、TP 场景的界定和区分**。数据库设计人员发现,在 TP 场景下,读写事务通常持续时间较短、用到的数据规模较小;对比来说,AP 场景通常只包含读取操作。因此可以让长时间、大范围的 AP 场景运行在独立于主事务循环外的**只读事务**上,然后只读事务使用一个一致性的快照即可不影响主循环。
|
||||
|
||||
VoltDB/H-Store, Redis, and Datomic 实现了物理上的串行执行事务。由于避免了多线程间用锁同步的开销,单线程的事务某些场景下可能性能更好,但在吞吐上可能受制于单核 CPU 的上限。此外,为了充分利用单核,相比传统形式,会对事务结构重新组织(如存储过程)。
|
||||
|
||||
@ -555,7 +553,7 @@ VoltDB/H-Store, Redis, and Datomic 实现了物理上的串行执行事务。由
|
||||
|
||||
在数据库发展早期阶段,人们试图将数据库事务设计成为包含整个用户交互流程。如果整个交互流程都从属于一个事务,那么它们就可以被原子的提交,这么抽象看起来很干净。
|
||||
|
||||
但人的交互所引入延迟远大于计算机 CPU 时钟周期甚至 IO 延迟,因此 OLTP 型数据库多会避免在单个事务中包含人的交互,以求单个事务能够较快的执行结束。在 Web 上,这意味着,不能让单个事务跨多个请求。但如果只允许单次请求执行一个语句,一个完整流程通常会包含多个语句,从而包含多次 RPC\HTTP 请求,会在通信上耗费太多时间。
|
||||
但人的交互所引入延迟远大于计算机 CPU 时钟周期甚至 IO 延迟,因此 OLTP 型数据库多会避免在单个事务中包含人的交互,以求单个事务能够较快的执行结束。在 Web 上,这意味着,不能让单个事务跨多个请求。但如果只允许单次请求执行一个语句,一个完整流程通常会包含多个语句,从而包含多次 RPC\HTTP 请求,会在通信上耗费太多时间。
|
||||
|
||||
因此,单线程串行事务系统不允许交互式的多语句事务。用户需要将多语句封装为存储过程一次性提交给数据库。如果数据都在内存中,则存储过程可以被快速的执行。
|
||||
|
||||
@ -582,7 +580,7 @@ TODO:存储过程需要 if、while 都判断分支,来依赖之前结果进
|
||||
|
||||
### 对数据进行分区
|
||||
|
||||
单线程事务受限于单个 CPU 吞吐,为了提高写入吞吐,处理较大数据量,可以将数据进行分区。 VoltDB 支持对数据以某种方式(猜测是用户指定一个分区函数)对数据进行分区。
|
||||
单线程事务受限于单个 CPU 吞吐,为了提高写入吞吐,处理较大数据量,可以将数据进行分区。VoltDB 支持对数据以某种方式(猜测是用户指定一个分区函数)对数据进行分区。
|
||||
|
||||
需要注意的是,分区方式要谨慎选择,以使绝大部分事务都局限于单个分区上。对于跨分区事务,由于需要进行额外协调(如上分布式锁),以串行执行。这会带来严重的性能损失,要尽量避免。
|
||||
|
||||
@ -610,7 +608,6 @@ TDOO:2PL 并非拿到所有的锁,才开始进行读写操作?而是按需
|
||||
为了和书中保持一致,下面仍然称 2PL。
|
||||
|
||||
> 2PL 和 2PC 听起来很像,但它们不是一个东西,只是恰好都有两个阶段而已。
|
||||
>
|
||||
|
||||
在防止脏写一节,提到了锁。但 2PL 中的锁会严格一些:
|
||||
|
||||
@ -690,9 +687,9 @@ WHERE room_id = 123 AND
|
||||
|
||||
### 乐悲观并发控制
|
||||
|
||||
2PL 是一种**悲观**(*pessimistic*)的并发控制机制,就像多线程编程中的**互斥锁**(mutual exclusion)。其背后哲学是,当可能有不好的事情(如并发)发生时,先悲观的等待到条件好转(其他事务释放锁),再进行执行。而物理上的串行执行,是将这种悲观哲学提升到了极致,等价于每个事务在执行时都持有了整个数据库级别的互斥锁。为了弥补这种悲观带来的性能损失,需要保证每个事务执行足够快。
|
||||
2PL 是一种**悲观**(_pessimistic_)的并发控制机制,就像多线程编程中的**互斥锁**(mutual exclusion)。其背后哲学是,当可能有不好的事情(如并发)发生时,先悲观的等待到条件好转(其他事务释放锁),再进行执行。而物理上的串行执行,是将这种悲观哲学提升到了极致,等价于每个事务在执行时都持有了整个数据库级别的互斥锁。为了弥补这种悲观带来的性能损失,需要保证每个事务执行足够快。
|
||||
|
||||
SSI 是一种**乐观**(*optimistic*)的并发控制机制,类比多线程编程中的乐观锁。其相应哲学是,当存在潜在危险时,仍然不做任何检查去大胆的执行。当事务提交时,再进行冲突检测,如果存在冲突,则回退重试。将乐观发展到极致,则是不上任何锁,但为了给这种乐观进行兜底,需要在执行完后进行检查。
|
||||
SSI 是一种**乐观**(_optimistic_)的并发控制机制,类比多线程编程中的乐观锁。其相应哲学是,当存在潜在危险时,仍然不做任何检查去大胆的执行。当事务提交时,再进行冲突检测,如果存在冲突,则回退重试。将乐观发展到极致,则是不上任何锁,但为了给这种乐观进行兜底,需要在执行完后进行检查。
|
||||
|
||||
乐观并发控制并不是一种新思想,其优缺点被充分的讨论过:
|
||||
|
||||
@ -703,13 +700,13 @@ SSI,顾名思义,基于快照隔离。即在 SSI 隔离级别中,所有的
|
||||
|
||||
### 基于失效前提的决策
|
||||
|
||||
在之前讨论写偏差时,我们观察到一种一再发生的模式:**读取-决策-写入**。
|
||||
在之前讨论写偏差时,我们观察到一种一再发生的模式:**读取 - 决策 - 写入**。
|
||||
|
||||
1. 读取:事务首先从数据库中读取到一些数据。
|
||||
2. 决策:考察读到的数据,做出某种决策。
|
||||
3. 写入:将对应决策造成结果写回数据库。
|
||||
|
||||
即,这里面存在一个因果关系,读为因,写为果。如果在提交时,发现决策的**前提**(*premise*,如:“今天有两名医生排到了值班”)不再满足,则后面写入失去意义。因此为了提供可串行化的隔离级别,需要识别这种因果关系,并且能够在提交时检测前提是否失效,以决定是否中止事务。
|
||||
即,这里面存在一个因果关系,读为因,写为果。如果在提交时,发现决策的**前提**(_premise_,如:“今天有两名医生排到了值班”)不再满足,则后面写入失去意义。因此为了提供可串行化的隔离级别,需要识别这种因果关系,并且能够在提交时检测前提是否失效,以决定是否中止事务。
|
||||
|
||||
那如何检测前提是否失效呢?
|
||||
|
||||
@ -744,7 +741,7 @@ SSI,顾名思义,基于快照隔离。即在 SSI 隔离级别中,所有的
|
||||
|
||||
如上图,假设在班次编号 `shift_id` 上存在索引,事务 42、43 在读取了对应数据后,会在 `shift_id = 1234` 的索引条目上记下事务编号,并在事务和所有并发事务完成时,删除标记。当事务发生写入时,需要通知读过该索引的所有事务(通过标记可以知道):你读到的数据过期了。该过程类似于上锁,但并不真正的等待,而是简单通知。
|
||||
|
||||
如上图,事务 43 会在写入数据时,会通知事务 42 其所读取的数据过期;事务 42 在写入时,也会通知事务 43 。但事务 42 首先发起提交,尽管事务 43 的写入影响了 42, 但 43 未提交,此时 42 会提交成功。但 43 在提交时,发现收到通知的事务已经提交,则 43 只能中止,然后重试。
|
||||
如上图,事务 43 会在写入数据时,会通知事务 42 其所读取的数据过期;事务 42 在写入时,也会通知事务 43。但事务 42 首先发起提交,尽管事务 43 的写入影响了 42,但 43 未提交,此时 42 会提交成功。但 43 在提交时,发现收到通知的事务已经提交,则 43 只能中止,然后重试。
|
||||
|
||||
总结:**在写入时,利用之前在对应索引范围记下的读取事务编号记录冲突,在提交时,看有冲突的读取是否已经提交**。
|
||||
|
||||
@ -755,7 +752,7 @@ SSI,顾名思义,基于快照隔离。即在 SSI 隔离级别中,所有的
|
||||
1. 如果细粒度跟踪,虽然能精确的检测到真正的冲突,减少重试,但会有显著的记录开销。
|
||||
2. 如果粗粒度的跟踪,虽然性能会好,但会导致更多的冲突和重试。
|
||||
|
||||
在某些情况下,即使一个事务读到的信息被另外一个事务的写入覆盖,仍然能保证可串行化的隔离级别。这取决于事务读到这些信息后,用来做了什么,*PostgreSQL* 便根据这个原则来减少不必要的重试。
|
||||
在某些情况下,即使一个事务读到的信息被另外一个事务的写入覆盖,仍然能保证可串行化的隔离级别。这取决于事务读到这些信息后,用来做了什么,_PostgreSQL_ 便根据这个原则来减少不必要的重试。
|
||||
|
||||
和 2PL 相比,SSI 的最大优点是,不会通过锁来阻塞有依赖关系的事务并发执行。SSI 就想运行在快照隔离级别一样,读不阻塞写,写不阻塞读。只是追踪记录,在提交时决定是否提交或重试。这种设计是的查询延迟更可预测。尤其是,只读事务可以工作在一致性快照上,而不受影响,这对读负载很重的场景很有吸引力。
|
||||
|
||||
|
123
ch08.md
123
ch08.md
@ -1,6 +1,5 @@
|
||||
# DDIA 逐章精读(八): 分布式系统中的麻烦事(The Trouble with Distributed Systems)
|
||||
|
||||
|
||||
之前几章都在谈系统如何处理出错:副本故障切换、副本数据滞后、事务的并发控制。但前几张考虑到的情况:机器宕机、网络延迟都相对较**理想**。在实际大型分布式系统中,情况会更为悲观,可能会出错组件的一定会出错,而且出错的方式会更为复杂。任何大型系统的运维人员想必对此都有深有体会。
|
||||
|
||||
构建分布式系统和单机软件完全不同。在分布式系统中,系统有一千种奇妙的出错方法,本章将会探讨其中的一部分。我们会发现,在单机中我们以为是无比自然的假设,在分布式系统中,都可能不成立。作为工程师,我们总期望能构建能够处理任何可能故障的系统,但在实践中,一切都是**权衡**。不过,我们首先需要知道,可能会遇到哪些问题,才能进而选择:**是否要在目标场景下解决这些问题、还是为了降低系统复杂度忽略这些问题**。
|
||||
@ -22,7 +21,7 @@
|
||||
|
||||
在构建大型计算系统的选择上一个光谱:
|
||||
|
||||
1. 在光谱一侧,是**高性能计算**(HPC,*high-performance computing*)。使用上千个 CPU 构建的超级计算机,用于计算密集型工作,如天气预报、分子动力学模拟。
|
||||
1. 在光谱一侧,是**高性能计算**(HPC,_high-performance computing_)。使用上千个 CPU 构建的超级计算机,用于计算密集型工作,如天气预报、分子动力学模拟。
|
||||
2. 在光谱另一侧,是**云计算**(cloud computing)。云计算不是一个严谨的术语,而是一个偏口语化的形象指代。通常指将通用的廉价的计算资源,通过计算机网络收集起来进行池化,然后按需分配给多租户,并按实际用量进行计费。
|
||||
3. 传统的企业**自建的数据中心**位于光谱中间。
|
||||
|
||||
@ -31,8 +30,8 @@
|
||||
但在本章,我们将重点放到以网络连接的多机系统中,这样的系统与单机应用与诸多不同之处:
|
||||
|
||||
1. **在线离线**。互联网应用多为**在线**(online)服务,需要给用户提供随时可用、低延迟服务。在这种场景下,重启以恢复任务或者服务是不可接受的。但在离线任务中,如天气状况模拟。
|
||||
2. **专用通用**。超算多用**专用硬件**(*specialized hardware*)构建而成。组件本身很可靠,组件间通信也很稳定——多通过共享内存或 RDMA 的方式。与之相反,云服务多由通用机器组网而成,通过堆数量达到与超算相当的性能,经济但故障率高。
|
||||
3. **组网方式**。大型数据中心的网络通常基于 IP 和以太网,通常按 Clos 拓扑组网,以提供比较高的**对分带宽**(bisection bandwidth)。超算常用专用的网络拓扑,如多维网格、环面拓扑(*toruses*),能够为已知的 HPC 负载提供更好的性能。
|
||||
2. **专用通用**。超算多用**专用硬件**(_specialized hardware_)构建而成。组件本身很可靠,组件间通信也很稳定——多通过共享内存或 RDMA 的方式。与之相反,云服务多由通用机器组网而成,通过堆数量达到与超算相当的性能,经济但故障率高。
|
||||
3. **组网方式**。大型数据中心的网络通常基于 IP 和以太网,通常按 Clos 拓扑组网,以提供比较高的**对分带宽**(bisection bandwidth)。超算常用专用的网络拓扑,如多维网格、环面拓扑(_toruses_),能够为已知的 HPC 负载提供更好的性能。
|
||||
4. **故障常态化**。系统越是庞大,系统中有组件出错的概率便越高。在上千个节点组成的系统中,可以认为任何时刻,总有组件存在故障。在遇到故障时,如果在整个系统层面,仅简单选择放弃重试的策略,则系统可能不是在重试,就是在重试的路上,花在有效的工作时间少之又少。
|
||||
5. **容错**。当部分节点故障时,如果系统仍能作为一个整体而正常工作,将会对运维十分友好。如,对于滚动升级,虽然单个经历了重启,但是多个节点组成的系统渐次重启时,整体仍然能对外正常工作。在云上,如果某个虚拟机有点慢,我们可以销毁它,再拉起一台(如果故障节点是少数,期望会更快)。
|
||||
6. **本地异地**。多地部署的大型系统,多通过互联网通信(虽然也有专用网络),但总体来说,相对局域网更慢且易出错。相对的,我们对于超算有个基本预期——其多个节点都靠的很近。
|
||||
@ -42,7 +41,6 @@
|
||||
因此,**面向容错进行设计**是对分布式系统软件的基本要求,为此,我们首先要了解**分布式系统中的常见问题**,并依此设计、编写、**测试**你的系统。
|
||||
|
||||
> 但在实践中,任何设计都是取舍(tradeoff),容错是有**代价**(昂贵、损失性能、系统复杂度提升等等)的。因此,充分了解你的系统应用场景,才能做出合理的容错实现,过犹不及。
|
||||
>
|
||||
|
||||
## 基于不可靠组件构建可靠系统
|
||||
|
||||
@ -140,13 +138,10 @@
|
||||
此外,TCP 中存在**超时重传**机制,虽然重传本身对应用层不可见,但是超时重传带来的延迟却是无法掩盖的。
|
||||
|
||||
> **TCP 和 UDP**
|
||||
>
|
||||
|
||||
> 一些对延迟敏感的场景,如视频会议和 IP 语音,常使用 UDP。由于 UDP 不需要提供额外保证,因此不需要做超时重传和流量控制,因此可以避免 TCP 很多排队造成的延迟。
|
||||
>
|
||||
|
||||
> 当然,如果用户仍然需要某种程度的可靠性,可以基于 UDP 在应用层有针对性的做一些优化,比如在视频会议中,如果网络不好,可以主动问下:能再说一遍嘛?这是一种常用的思想,通用场景,可以使用屏蔽底层复杂度的协议;特化场景,可以使用相对底层、粗糙的协议,自己在应用层做有针对性的封装。是一个实现复杂度和效率的 tradeoff。
|
||||
>
|
||||
|
||||
所有上述因素,都能造成网络延迟变化,且一个基本现象是:**网络流量越满,单个请求延迟抖动越大**。
|
||||
|
||||
@ -154,17 +149,17 @@
|
||||
|
||||
**静态设置**。在这种环境中,如果你要为远端故障检测设置超时时间,就只能使用做实验的方式,经过足够长的时间,统计请求延迟分布。进而结合应用需求,在**检测过久**(设置长超时间隔)和**故障误报**(设置过短超时间隔)做一个权衡。
|
||||
|
||||
**动态调整**。当然, 相比预先配置固定死超时间隔,更好的方式是,通过类似时间窗口的方式,不断监测过去一段时间内的**请求时延**和**抖动情况**,来获取请求时延的分布情况,进而动态调整超时间隔。 **Phi 累积故障检测算法**( The Φ Accrual Failure Detector)便是这样一种算法,Akka and Cassandra 中都用到了此种算法,它的工作原理和 TCP 重传间隔的动态调整类似。
|
||||
**动态调整**。当然,相比预先配置固定死超时间隔,更好的方式是,通过类似时间窗口的方式,不断监测过去一段时间内的**请求时延**和**抖动情况**,来获取请求时延的分布情况,进而动态调整超时间隔。 **Phi 累积故障检测算法**(The Φ Accrual Failure Detector)便是这样一种算法,Akka and Cassandra 中都用到了此种算法,它的工作原理和 TCP 重传间隔的动态调整类似。
|
||||
|
||||
## 同步网络和异步网络
|
||||
|
||||
如果我们的底层网络传输数据包时能够保证延迟上界、且不会丢包,那么基于此构建分布式系统将会容易的多。那为什么不在硬件层面解决相关问题让网络更可靠,从而让分布式软件免于关心这些复杂的细节呢?
|
||||
如果我们的底层网络传输数据包时能够保证延迟上界、且不会丢包,那么基于此构建分布式系统将会容易的多。那为什么不在硬件层面解决相关问题让网络更可靠,从而让分布式软件免于关心这些复杂的细节呢?
|
||||
|
||||
为了回答这个问题,我们先来看一种历史产物——**固定电话网**(*fixed-line telephone network* ,非 VOIP、非蜂窝网络)。在固话线路中,高延迟音频帧和意外断线都是非常罕见的。固话网会为每一次通话预留**稳定低延迟**和**充足的带宽**链路以传输语音。如果计算机网络中也采用类似的技术,生活不会很美好吗?
|
||||
为了回答这个问题,我们先来看一种历史产物——**固定电话网**(_fixed-line telephone network_,非 VOIP、非蜂窝网络)。在固话线路中,高延迟音频帧和意外断线都是非常罕见的。固话网会为每一次通话预留**稳定低延迟**和**充足的带宽**链路以传输语音。如果计算机网络中也采用类似的技术,生活不会很美好吗?
|
||||
|
||||
当你在固网内拨打电话时,会建立一条贯穿贯穿全链路的保证足量带宽的固定链路,我们称之为**电路**(circuit),该电路会保持到通话结束才释放。以 ISDN 网络为例,其每秒能容纳 4000 帧语音信号,当发起通话时,它会在每个方向为每帧数据分配 16 比特空间。因此,在整个通话期间,两端各自允许每 250 微秒(250us * 4000 = 1s)发送 16 比特语音数据。
|
||||
当你在固网内拨打电话时,会建立一条贯穿贯穿全链路的保证足量带宽的固定链路,我们称之为**电路**(circuit),该电路会保持到通话结束才释放。以 ISDN 网络为例,其每秒能容纳 4000 帧语音信号,当发起通话时,它会在每个方向为每帧数据分配 16 比特空间。因此,在整个通话期间,两端各自允许每 250 微秒(250us \* 4000 = 1s)发送 16 比特语音数据。
|
||||
|
||||
这种网络是**同步**(*synchronous*)的:尽管数据也会通过多个路由节点,但由于通信所需的资源(如上述 16 bit 空间)已经在下一跳中被提前预留出来了,因此这些数据帧不会面临排队问题。由于不存在排队,则端到端的最大延迟是固定的。我们也称此种网络为**有界网络**(bounded network)。
|
||||
这种网络是**同步**(_synchronous_)的:尽管数据也会通过多个路由节点,但由于通信所需的资源(如上述 16 bit 空间)已经在下一跳中被提前预留出来了,因此这些数据帧不会面临排队问题。由于不存在排队,则端到端的最大延迟是固定的。我们也称此种网络为**有界网络**(bounded network)。
|
||||
|
||||
### 计算机网络为什么不能同样稳定?
|
||||
|
||||
@ -175,9 +170,9 @@
|
||||
|
||||
应用层给到 TCP 的任意大小的数据,都会在尽可能短的时间内被发送给对端。如果一个 TCP 连接暂时空闲,则他不会占用任何网络带宽。相比之下,在打电话时即使不说话,电路所占带宽也得一直被预留。
|
||||
|
||||
如果数据中心和互联网使用**电路交换**(*circuit-switched*)网络,他们应该能够建立一条保证稳定最大延迟的数据链路。但是事实上,由于以太网和 IP 网采用**封包交换**协议(*packet-switched protocols* ,常翻译为**分组交换**,但我老感觉它不太直观),没有电路的概念,只能在数据包传送的时候对其进行排队,也不得不忍受由此带来的无界延迟。
|
||||
如果数据中心和互联网使用**电路交换**(_circuit-switched_)网络,他们应该能够建立一条保证稳定最大延迟的数据链路。但是事实上,由于以太网和 IP 网采用**封包交换**协议(_packet-switched protocols_,常翻译为**分组交换**,但我老感觉它不太直观),没有电路的概念,只能在数据包传送的时候对其进行排队,也不得不忍受由此带来的无界延迟。
|
||||
|
||||
那为什么数据中心网络和互联网要使用封包交换协议呢?答曰,为了应对互联网中无处不在的**突发流量**(*bursty traffic*)。在电话电路中,音频传输所需带宽是固定的;但在互联网中,各种多媒体数据(如电子邮件、网页、文件)所需带宽却是差异极大且动态变化的,我们对他们的唯一要求就是传地尽可能快。
|
||||
那为什么数据中心网络和互联网要使用封包交换协议呢?答曰,为了应对互联网中无处不在的**突发流量**(_bursty traffic_)。在电话电路中,音频传输所需带宽是固定的;但在互联网中,各种多媒体数据(如电子邮件、网页、文件)所需带宽却是差异极大且动态变化的,我们对他们的唯一要求就是传地尽可能快。
|
||||
|
||||
设想你使用电路网络传输一个网页,你需要为它预留带宽,如果你预留过低,则传输速度会很慢;如果你预留过高,则可能电路都没法建立(带宽余量不够,就没法建立连接),如果建立了,也会浪费带宽。互联网数据的**丰富性**和**异构性**,让使用电路网络不太可能。
|
||||
|
||||
@ -186,17 +181,16 @@
|
||||
但,现有的数据中心网络和互联网都不支持 QoS。因此,我们在设计分布式系统时,不能对网络传输的时延和稳定性有任何假设。我们必须要假定我们面对的网络会发生网络拥塞、会产生排队、会有无界延迟,在这种情况下,没有放之四海而皆准的**超时间隔** 。针对不同的具体情况,需要通过经验或者实验来确定它。
|
||||
|
||||
> **通信时延和资源利用**
|
||||
>
|
||||
>
|
||||
> 泛化一下,可以认为是**资源的动态分配(**dynamic resource partitioning**)**导致了**时延的不稳定**。
|
||||
>
|
||||
>
|
||||
> 设你有一条能够承载 10000 路通话的线路,其上的每个电路都要占其中一路。基于此,可以认为该线路是一种能够被至多 10000 个用户共享的资源,并且该资源以一种**静态**(static)的方式被分配:无论该线路中现在有包含你在内的 10000 个人在通话、还是只有你一个人在通话,被分配给你的资源都是固定的:1/10000。
|
||||
>
|
||||
>
|
||||
> 与之对应,互联网中的通信会**动态的**(dynamically)共享网络资源。每个发送者都会将数据包尽可能快的推送到数据线路上,但在任意时刻,哪个数据包被真正发送(即资源分配给谁),则由交换机来动态决定。这种做法的劣势在于排队,但优势在于能够最大化线路资源利用。一条线路的造价是固定的,如果对其利用率越高,则单位数据发送成本越低。
|
||||
>
|
||||
>
|
||||
> 类似的情形还发生在 CPU 的分时复用里。如果再多个线程间动态的共享每个 CPU,则一个线程使用 CPU 时,其他线程必须排队等待,且排队时间不确定。这种使用 CPU 的方式,比分配给每个线程固定的时间片要高效。类似的,使用虚拟化的方式共享同一台物理机,也会有更好的硬件利用率。
|
||||
>
|
||||
> 在资源静态分配的环境中,如专用的硬件、互斥的带宽分配,有界延迟能够被保证。但是,这种方式是以降低资源利用率为代价的,换句话说,更贵。反之,通过多租户方式动态的共享资源,更便宜,但代价是**不稳定的延迟**(*variable delays*)。**不稳定的延迟并非什么不可变的自然法则,而仅是一种代价和收益权衡的结果罢了**。
|
||||
>
|
||||
>
|
||||
> 在资源静态分配的环境中,如专用的硬件、互斥的带宽分配,有界延迟能够被保证。但是,这种方式是以降低资源利用率为代价的,换句话说,更贵。反之,通过多租户方式动态的共享资源,更便宜,但代价是**不稳定的延迟**(_variable delays_)。**不稳定的延迟并非什么不可变的自然法则,而仅是一种代价和收益权衡的结果罢了**。
|
||||
|
||||
# 不可靠的时钟
|
||||
|
||||
@ -221,7 +215,7 @@
|
||||
|
||||
### 日历时钟
|
||||
|
||||
该时钟和我们日常生活中的时钟关联,也称为**挂钟时间**(wall-clock time),通常会返回当前日期和时间。如:Linux 上的 `clock_gettime(CLOCK_REALTIME)` 和 Java 里的 `System.currentTimeMillis()` ,他们都会返回基于**格里历**(Gregorian calendar)1970 年 1 月 1 日 00:00:00 时刻以来的秒数(或者毫秒数),不包括闰秒。当然,一些系统可能会用其他时刻作为计时起点。
|
||||
该时钟和我们日常生活中的时钟关联,也称为**挂钟时间**(wall-clock time),通常会返回当前日期和时间。如:Linux 上的 `clock_gettime(CLOCK_REALTIME)` 和 Java 里的 `System.currentTimeMillis()` ,他们都会返回基于**格里历**(Gregorian calendar)1970 年 1 月 1 日 00:00:00 时刻以来的秒数(或者毫秒数),不包括闰秒。当然,一些系统可能会用其他时刻作为计时起点。
|
||||
|
||||
日历时钟常常使用 **NTP 进行同步**,以使得不同机器上时间戳能够同步。但之后会提到,日历时钟有诸多不确定性。这里值得一提的是,如果某个机器时间大大领先于 NTP 服务器,则其日历时钟会被重置,从而让该机器上的时间看起来倒流了一样。**时钟回拨、跳过闰秒**等等问题,使得日历时钟不能用于精确计算一个时间间隔。
|
||||
|
||||
@ -229,11 +223,11 @@
|
||||
|
||||
### 单调时钟
|
||||
|
||||
单调时钟主用于取两个时间点的**差值**来测量时间间隔,如服务器的超时间隔和响应时间。Linux 上的 `clock_gettime(CLOCK_MONOTONIC)`和 Java 中的 `System.nanoTime()`都是单调时钟。顾名思义,单调时钟不会像日历时钟一样由于同步而进行回拨,可以保证一直单调向前。也正因为如此,用其计算时间间隔才更加准确。
|
||||
单调时钟主用于取两个时间点的**差值**来测量时间间隔,如服务器的超时间隔和响应时间。Linux 上的 `clock_gettime(CLOCK_MONOTONIC)`和 Java 中的 `System.nanoTime()`都是单调时钟。顾名思义,单调时钟不会像日历时钟一样由于同步而进行回拨,可以保证一直单调向前。也正因为如此,用其计算时间间隔才更加准确。
|
||||
|
||||
在具有多个 CPU 的服务中,每个 CPU 可能会有一个单独的计时器,且不同 CPU 之间不一定同步。但操作系统会试图屏蔽其间差异,对应用层保证单调递增。这样,即使一个线程被调度到不同 CPU 上去,也仍能保证单调。当然,最保险的办法是不严格依赖此单调性。
|
||||
|
||||
如果检测到本地石英钟和 NTP 服务器不一致,NTP 会相应调整单调时钟的频率,但是幅度不能超过 0.05%。换句话说,**NTP 可以调整单调时钟频率**,但不能直接往前或者往后跳拨单调时钟。因此, 多数机器上单调时钟的置信度通常都很好,能达到毫秒级甚至更细粒度。即,单调时钟会通过调整频率来和 NTP 时间对齐,而非像挂历时钟那样直接跳拨。
|
||||
如果检测到本地石英钟和 NTP 服务器不一致,NTP 会相应调整单调时钟的频率,但是幅度不能超过 0.05%。换句话说,**NTP 可以调整单调时钟频率**,但不能直接往前或者往后跳拨单调时钟。因此,多数机器上单调时钟的置信度通常都很好,能达到毫秒级甚至更细粒度。即,单调时钟会通过调整频率来和 NTP 时间对齐,而非像挂历时钟那样直接跳拨。
|
||||
|
||||
在分布式系统中,使用单调时钟计算时间间隔很合适,因为时间间隔既不关心多机间进行时钟同步,也对时间精度不是很敏感。
|
||||
|
||||
@ -247,13 +241,13 @@
|
||||
- 如果计算机时钟和 NTP 服务器相差太多,该计算机会拒绝同步或强制同步。如果强制同步,应用层在会在同步前后看到一个时钟的**跳变**。
|
||||
- 如果一个节点通过偶然的设置把 **NTP 服务器给墙**了,并且没有留意到这个问题,则可能会造成这段时间内时钟不同步,实践中确实发生过类似问题。
|
||||
- NTP 同步受限于网络的延迟,因此在延迟不稳定的**拥塞网络**中,其精度会受到影响。有实验表明通过互联网进行时钟同步,可以实现最小 35ms 的时钟误差。尽管网络中偶有将近 1s 延迟尖刺,但可以通过合理配置来忽略尖刺对应的同步。
|
||||
- 润秒的存在会导致一分钟可能有 59s 或者 61s,如果系统在做设计时没有考虑这种特殊情况,就有可能在运行时遇到问题,很多大型系统都因此而宕机。处理闰秒最好的办法是,让 NTP 服务器在一天中逐渐调整,**摊平闰秒**(也称为:**拖尾**,*smearing*),不过在实际中, NTP 服务器处理闰秒的行为不尽相同。
|
||||
- 润秒的存在会导致一分钟可能有 59s 或者 61s,如果系统在做设计时没有考虑这种特殊情况,就有可能在运行时遇到问题,很多大型系统都因此而宕机。处理闰秒最好的办法是,让 NTP 服务器在一天中逐渐调整,**摊平闰秒**(也称为:**拖尾**,_smearing_),不过在实际中,NTP 服务器处理闰秒的行为不尽相同。
|
||||
- 在虚拟机中,其**物理时钟是虚拟化**出来的,从而给运行其上并依赖精确计时的应用带来额外挑战。由于一个 CPU 内核是被多个 VM 所共享的,当一个 VM 运行时,其他 VM 就得让出内核几十毫秒。在 VM 恢复运行后,从应用代码的视角,其时钟就是毫无征兆的突然往前跳变了一段。
|
||||
- 如果你的软件将会运行在**不受控的设备**上,如智能手机或者嵌入式设备,则你不能完全相信设备系统时钟。因为用户可能会由于一些原因(比如绕开游戏时间限制),故意将其硬件时钟设置成一个错误的日期和时间,从而引起系统时钟的跳变。
|
||||
|
||||
当然,如果**不计代价**,我们是能够获得足够精确的时钟的。例如针对金融机构的欧洲法案:MIFID II ,要求所有高频交易的基金需要和 UTC 的误差不超过 100 微秒,以便调试如“闪崩”之类的市场异常,并帮助检测市场操纵行文。
|
||||
当然,如果**不计代价**,我们是能够获得足够精确的时钟的。例如针对金融机构的欧洲法案:MIFID II,要求所有高频交易的基金需要和 UTC 的误差不超过 100 微秒,以便调试如“闪崩”之类的市场异常,并帮助检测市场操纵行文。
|
||||
|
||||
可以通过组合使用 **GPS 接收机、PTP 协议**(Precision Time Protocol),并进行小心部署和监控,来获取此类高精度时钟。然而,这需要非常多的专业知识和精力投入,且仍有很多问题会引起时钟不同步: NTP 服务器配置错误、防火墙错误组织了 NTP 流量。
|
||||
可以通过组合使用 **GPS 接收机、PTP 协议**(Precision Time Protocol),并进行小心部署和监控,来获取此类高精度时钟。然而,这需要非常多的专业知识和精力投入,且仍有很多问题会引起时钟不同步:NTP 服务器配置错误、防火墙错误组织了 NTP 流量。
|
||||
|
||||
## 依赖同步时钟
|
||||
|
||||
@ -265,7 +259,7 @@
|
||||
|
||||
前面讨论过,虽然网络丢包和不固定延迟不常发生,但在做设计时,仍要考虑这些极端情况。对于时钟也是如此:虽然大部分时间,时钟都能如我们预期一样工作,但在设计系统时,仍要**考虑最坏可能**,否则一旦出现故障时,通常难以定位。
|
||||
|
||||
**时钟问题造成的影响往往不容易被发现**。如果 CPU 、内存或者网络故障,可能系统会立即出现很严重的问题;但如果不正确的依赖了时钟,可能系统仍然能在表面上看起来正常运转,比如时钟漂移是慢慢累加的,可能到第一程度才会出现问题。
|
||||
**时钟问题造成的影响往往不容易被发现**。如果 CPU、内存或者网络故障,可能系统会立即出现很严重的问题;但如果不正确的依赖了时钟,可能系统仍然能在表面上看起来正常运转,比如时钟漂移是慢慢累加的,可能到第一程度才会出现问题。
|
||||
|
||||
因此,如果你的系统依赖(或者假设)所有参与的机器**时钟同步**(synchronized clocks),就必须通过一定的机制来检测系统内节点间的时钟偏移,如果某个节点系统时钟与其他相差过大,就及时将其从系统内移除,以此来规避**时钟相差太大**所造成的不可挽回的问题。
|
||||
|
||||
@ -275,11 +269,11 @@
|
||||
|
||||
![Untitled](img/ch08-fig03.png)
|
||||
|
||||
如上图,Client A 向节点 1 写入 `x = 1`,然后该写入被复制到节点 3 上;Client B 在节点上将 x 增加 1,得到 `x = 2`;最终上述两个写入都被复制到节点 2 。
|
||||
如上图,Client A 向节点 1 写入 `x = 1`,然后该写入被复制到节点 3 上;Client B 在节点上将 x 增加 1,得到 `x = 2`;最终上述两个写入都被复制到节点 2。
|
||||
|
||||
在图中所有**待同步的数据都会被打上一个时间戳**,接收到同步来数据的节点会根据时间戳对所有写入应用到本地。那么如何使用时间戳呢?所有节点只会通过时间戳递增的顺序接受同步过来的写入请求,如果某个写请求时间戳小于上一个写请求的,则丢弃。
|
||||
|
||||
另外,节点 1 和节点 3 的时钟相差小于 3 ms,这在日常中是一个很小的偏差,算是**时钟同步**的很好了。但在这个例子中,却仍然出现了顺序问题,写请求 x = 1 的时间戳是 42.004,写请求 x = 2 的时间戳是 42.001 ,小于 x = 1 的写请求,但我们知道,写请求 x = 2 应该发生在后面。于是当节点 2 收到第二个写请求 x = 2 时,发现其时间戳小于上一个写时间戳,于是将其丢掉。于是,客户端 B 的自增操作在**节点 2** 上被错误的丢弃了。
|
||||
另外,节点 1 和节点 3 的时钟相差小于 3 ms,这在日常中是一个很小的偏差,算是**时钟同步**的很好了。但在这个例子中,却仍然出现了顺序问题,写请求 x = 1 的时间戳是 42.004,写请求 x = 2 的时间戳是 42.001,小于 x = 1 的写请求,但我们知道,写请求 x = 2 应该发生在后面。于是当节点 2 收到第二个写请求 x = 2 时,发现其时间戳小于上一个写时间戳,于是将其丢掉。于是,客户端 B 的自增操作在**节点 2** 上被错误的丢弃了。
|
||||
|
||||
**后者胜**(Last write win,LWW)作为一种冲突解决策略,在多副本(无论是**多主**还是**无主**)架构中被广泛使用,比如 Cassandra 和 Riak。有一些实现是在客户端侧而非服务侧产生时间戳,但这仍不能解决 LWW 固有问题:
|
||||
|
||||
@ -295,10 +289,10 @@
|
||||
|
||||
### 时钟读数的置信区间
|
||||
|
||||
尽管你可以从机器上读取以微秒(microsecond )甚至纳秒(nanosecond)为单位的日历时间戳(time-of-day),但这并不意味你可以得到具有这样精度的绝对时间。如前所述,误差来自于几方面:
|
||||
尽管你可以从机器上读取以微秒(microsecond)甚至纳秒(nanosecond)为单位的日历时间戳(time-of-day),但这并不意味你可以得到具有这样精度的绝对时间。如前所述,误差来自于几方面:
|
||||
|
||||
1. **石英晶振漂移**。如果你直接从本地石英钟读取时间戳,其偏移很容易就可以累积数毫秒。
|
||||
2. **NTP 同步误差**。如果你想定时通过 NTP 来同步,比如每分钟同步一次。但NTP 协议是走网络的,而网络通信最快做到毫秒级延迟,偶有拥塞,延迟便能冲到数百毫秒。
|
||||
2. **NTP 同步误差**。如果你想定时通过 NTP 来同步,比如每分钟同步一次。但 NTP 协议是走网络的,而网络通信最快做到毫秒级延迟,偶有拥塞,延迟便能冲到数百毫秒。
|
||||
|
||||
总结来说,使用普通硬件,你无论如何都难以得到真正“准确”的时间戳。
|
||||
|
||||
@ -311,11 +305,11 @@
|
||||
|
||||
但不幸,大多数服务器的时钟系统 API 在给出时间点时,并不会一并给出对应的不确定区间。例如,你使用 `clock_gettime()` 系统调用获取时间戳时,返回值并不包括其置信区间,因此你无法知道这个时间点的误差是 5 毫秒还是 5 年。
|
||||
|
||||
一个有趣的反例是谷歌在 Spanner 系统中使用的 *TrueTime* API,会显式的给出置信区间。当你向 TrueTime 系统询问当前时钟时,会得到两个值,或者说一个区间:`[earliest, latest]`,前者是最早可能的时间戳。后者是最迟可能的时间错。通过该不确定预估,我们可以确定准确时间点就在该时钟范围内。此时,区间的大小取决于,上一次同步过后本地石英钟的漂移多少。
|
||||
一个有趣的反例是谷歌在 Spanner 系统中使用的 _TrueTime_ API,会显式的给出置信区间。当你向 TrueTime 系统询问当前时钟时,会得到两个值,或者说一个区间:`[earliest, latest]`,前者是最早可能的时间戳。后者是最迟可能的时间错。通过该不确定预估,我们可以确定准确时间点就在该时钟范围内。此时,区间的大小取决于,上一次同步过后本地石英钟的漂移多少。
|
||||
|
||||
### 用于快照的时钟同步
|
||||
|
||||
在 “快照隔离和可重复读” 一小节,本书讨论过**快照隔离**。当数据库想同时支持短时、较小的读写负载以及长时、很大的只读负载(如数据分析、备份)时,快照隔离很有用。快照隔离能让只读事务在不阻塞正常读写事务的情况下,看到数据库某个时间点之前的一致性视图。
|
||||
在“快照隔离和可重复读”一小节,本书讨论过**快照隔离**。当数据库想同时支持短时、较小的读写负载以及长时、很大的只读负载(如数据分析、备份)时,快照隔离很有用。快照隔离能让只读事务在不阻塞正常读写事务的情况下,看到数据库某个时间点之前的一致性视图。
|
||||
|
||||
快照隔离的实现通常需要一个**全局自增的事务 ID**。如果一个写入发生在快照 S 之后,则基于快照 S 的事务看不到该写入的内容。对于单机数据库,简单的使用一个**全局自增计数器**,就能够充当事务 ID 的来源。
|
||||
|
||||
@ -323,13 +317,13 @@
|
||||
|
||||
那么,我们可以用机器的挂历时钟的时间戳作为事物的 ID 吗?如果我们能让系统中的多台机器时钟保持严格同步,则其可以满足要求:**后面的事务会具有较大的时间戳,即较大的事务 ID**。但现实中,由于时钟同步的不确定性,用这种方法产生事务 ID 是不太靠谱的。
|
||||
|
||||
但 Spanner 就使用了物理时钟实现了快照隔离,它是如何做到可用的呢?Spanner 在设计 TrueTime 的 API 时,让其返回一个**置信区间**,而非一个时间点,来代表一个**时间戳**。假如现在你有两个时间戳 A 和 B(*A* = [*Aearliest*, *Alatest*] and *B* = [*Bearliest*, *Blatest*]),且这两个时间戳对应的区间没有交集(例如,*Aearliest* < *Alatest* < *Bearliest* < *Blatest*),则我们可以确信时间戳 B 发生于 A 之后。但如果两个区间有交集,我们则不能确定 A 和 B 的相对顺序。
|
||||
但 Spanner 就使用了物理时钟实现了快照隔离,它是如何做到可用的呢?Spanner 在设计 TrueTime 的 API 时,让其返回一个**置信区间**,而非一个时间点,来代表一个**时间戳**。假如现在你有两个时间戳 A 和 B(_A_ = [*Aearliest*, *Alatest*] and _B_ = [*Bearliest*, *Blatest*]),且这两个时间戳对应的区间没有交集(例如,_Aearliest_ < _Alatest_ < _Bearliest_ < _Blatest_),则我们可以确信时间戳 B 发生于 A 之后。但如果两个区间有交集,我们则不能确定 A 和 B 的相对顺序。
|
||||
|
||||
为了保证这种时间戳能够用作事务 ID,相邻生成的两个时间戳最好要间隔一个置信区间,以保证其没有交集。为此, Spanner 在索要时间戳时(比如提交事务),会等待一个置信区间。因此置信区间越小,这种方案的性能也就越好。为此,谷歌在每个数据中心使用了专门的硬件做为时钟源,比如原子钟和 GPS 接收器,以保证时钟的置信区间不超过 7 ms。
|
||||
为了保证这种时间戳能够用作事务 ID,相邻生成的两个时间戳最好要间隔一个置信区间,以保证其没有交集。为此,Spanner 在索要时间戳时(比如提交事务),会等待一个置信区间。因此置信区间越小,这种方案的性能也就越好。为此,谷歌在每个数据中心使用了专门的硬件做为时钟源,比如原子钟和 GPS 接收器,以保证时钟的置信区间不超过 7 ms。
|
||||
|
||||
在分布式事务中使用时钟同步,是一个比较活跃的研究领域(在书出版时,大概 2017),很多观点都很有趣,但在谷歌之外,还没有人再实现过。
|
||||
|
||||
- 但 HLC 感觉也算类似的实现,我去翻了下 ,HLC 论文是 2014 年,不知道作者为什么不算?
|
||||
- 但 HLC 感觉也算类似的实现,我去翻了下,HLC 论文是 2014 年,不知道作者为什么不算?
|
||||
|
||||
## 进程停顿
|
||||
|
||||
@ -342,34 +336,34 @@
|
||||
```cpp
|
||||
while (true) {
|
||||
request = getIncomingRequest();
|
||||
|
||||
|
||||
// Ensure that the lease always has at least 10 seconds remaining
|
||||
if (lease.expiryTimeMillis - System.currentTimeMillis() < 10000) {
|
||||
lease = lease.renew();
|
||||
}
|
||||
if (lease.isValid()) {
|
||||
process(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这段代码有什么问题呢?
|
||||
|
||||
1. **依赖时钟同步**。租期的过期时间点是在另外的机器 M 上被设置的,比如说设置为 M 接到续租请求时的当前时间加上 30 秒。然后该时间戳会和副本本地时间戳比较,如果两个机器时钟偏差过大,就会出现一些奇怪的事情(比如租约过晚过期,其他人续租成功,导致同一时刻有两个副本持有租约)。也就是说,比较不同机器上的时间戳是不靠谱的。
|
||||
2. **语句执行间隔的假设**。即使我们都用同一机器上的单调时钟,上述代码仍然可能有问题。我们很自然的以为在测试完租约是否仍然宽裕(`lease.expiryTimeMillis - System.currentTimeMillis() < 10000`)后,会立即进行请求处理:`process(request);` 。一般来说的确是这样。
|
||||
2. **语句执行间隔的假设**。即使我们都用同一机器上的单调时钟,上述代码仍然可能有问题。我们很自然的以为在测试完租约是否仍然宽裕(`lease.expiryTimeMillis - System.currentTimeMillis() < 10000`)后,会立即进行请求处理:`process(request);` 。一般来说的确是这样。
|
||||
|
||||
但如果在程序执行期间,发生了意外的停顿会怎么样?比如说,在 `lease.isValid()` 语句前线程停顿了 15 秒,这种情况下,在执行到真正处理请求的语句时,租约就可能会过期,此时,其他副本就有可能接下领导权限。但对于这个线程来说,并没有其他手段可以限制它继续执行,于是,就有可能发生一些不安全的处理事件。
|
||||
|
||||
程序在执行的时候真可能停顿这么长时间吗?还真有可能,原因有很多:
|
||||
|
||||
- 一些编程语言的运行时(如 JVM),都会有垃圾回收器(GC)。垃圾回收器有时候会暂停所有运行中的线程(以进行垃圾回收),这个**全暂停**(stop-the-world)时间有时甚至能到达数分钟!即便号称可以并发 GC 的最新 GC 算法(如 Hotpot JVM 的 CMS 算法 ),也不能真正的和用户线程并行,仍会时不时的暂停,只不过这个时间缩短了很多。另外,我们也可通过修改内存分配模式和进行 GC 参数调优来进一步降低 GC 影响。但即便如此,如果我们想真正的提供足够鲁棒的程序,就不能对 GC 所造成的停顿时间做最坏假设。
|
||||
- 一些编程语言的运行时(如 JVM),都会有垃圾回收器(GC)。垃圾回收器有时候会暂停所有运行中的线程(以进行垃圾回收),这个**全暂停**(stop-the-world)时间有时甚至能到达数分钟!即便号称可以并发 GC 的最新 GC 算法(如 Hotpot JVM 的 CMS 算法),也不能真正的和用户线程并行,仍会时不时的暂停,只不过这个时间缩短了很多。另外,我们也可通过修改内存分配模式和进行 GC 参数调优来进一步降低 GC 影响。但即便如此,如果我们想真正的提供足够鲁棒的程序,就不能对 GC 所造成的停顿时间做最坏假设。
|
||||
- 在**虚拟化环境**中,一个虚拟机可能会在任意时间点被**挂起**(suspended,暂停所有正在运行的进程,并将其上下文从内存中保存到磁盘)和**恢复**(resumed,将上下文恢复到内存中并且继续执行暂停的进程),挂起到恢复的间隔可能持续任意长时间。有时该功能也被称为虚机的**在线迁移**(live migration),此时虚机暂停的时间取决于上下文的迁移速率。
|
||||
- 哪怕像在笔记本这样的用户终端上,程序的运行也有可能被随时挂起和恢复。如,用户合上笔记本。
|
||||
- 当操作系统做上下文切换,将线程切走时;当**管控程序**(hypervisor)切到一个新的虚拟机时,当前正在执行的线程可能会停在代码中的任意位置。在虚拟机环境中,其他虚拟机占用 CPU 的时间也被称为**被窃时间**(*steal time*)。在物理机负载很重时,比如调度队列中有大量线程在等待时间片,某个被暂停的线程可能要好久才能重新执行。
|
||||
- 当操作系统做上下文切换,将线程切走时;当**管控程序**(hypervisor)切到一个新的虚拟机时,当前正在执行的线程可能会停在代码中的任意位置。在虚拟机环境中,其他虚拟机占用 CPU 的时间也被称为**被窃时间**(_steal time_)。在物理机负载很重时,比如调度队列中有大量线程在等待时间片,某个被暂停的线程可能要好久才能重新执行。
|
||||
- 如果操作系统配置了允许**换页**(swapping to disk, paging),则有时候一个简单的内存访问也可能引起缺页错误,这时我们就需要从磁盘中加载一个页到内存。在进行此 IO 时,线程多会挂起,让出 CPU。如果内存吃紧,缺页换页可能会非常频繁。在极端情况下,操作系统可能会将大部分时间都浪费在换页上,而非正经工作上(也被称为**颠簸**,thrashing)。为了避免此问题,服务器上的允许换页的配置项一般不打开。当然,你也可以点杀一部分进程来释放内存,避免换页,这就是 trade off 了。
|
||||
- 在 Unix 操作系统中,可以通过向进程发送 **SIGSTOP** 信号来让其暂停。如,用户对执行的进程在 Shell 中按下 Ctrl-Z 。该信号会阻止进程再获取 CPU 的时间片,直到我们使用 SIGCONT 将其再度唤起。你自己的环境中可能不怎么使用 SIGSTOP,但是运维人员偶尔还是会用的。
|
||||
- 在 Unix 操作系统中,可以通过向进程发送 **SIGSTOP** 信号来让其暂停。如,用户对执行的进程在 Shell 中按下 Ctrl-Z。该信号会阻止进程再获取 CPU 的时间片,直到我们使用 SIGCONT 将其再度唤起。你自己的环境中可能不怎么使用 SIGSTOP,但是运维人员偶尔还是会用的。
|
||||
|
||||
所有上述情景都会在任意时刻**中断**(*preempt*)正在运行的线程,并在之后某个时刻将其重新唤醒,而线程本身对这个过程是不感知的。类似的情形还有单机多线程编程:你不能对多个线程代码的**相对执行顺序**有任何假设,因为上下文切换和并发执行可能会在任何时间以任何形式发生。
|
||||
所有上述情景都会在任意时刻**中断**(_preempt_)正在运行的线程,并在之后某个时刻将其重新唤醒,而线程本身对这个过程是不感知的。类似的情形还有单机多线程编程:你不能对多个线程代码的**相对执行顺序**有任何假设,因为上下文切换和并发执行可能会在任何时间以任何形式发生。
|
||||
|
||||
在单机我们有很多手段可以对多线程的执行进行协调,使之线程安全。如锁、信号量、原子计数器、无锁数据结构、阻塞队列等等。但不幸的是,分布式系统中我们没有对应的手段。因为在多机间不能**共享内存**,只能依靠**消息同步**,而且是要经过不可靠网络的消息!
|
||||
|
||||
@ -384,14 +378,12 @@ while (true) {
|
||||
举个例子,如果你正在驾车,传感器检测到车祸,你肯定不希望你的车载系统此时正在进行 GC 而不能及时处理该信号吧。
|
||||
|
||||
> 实时系统真的实时吗?
|
||||
>
|
||||
|
||||
> 在**嵌入式**系统里,实时意味着需要通过设计、测试等多层面来让系统在延迟上提供某种保证。其在 **Web** 中也有实时系统(real-time)的叫法,但更多的侧重于服务器会流式的处理客户端请求,并将数据发回客户端,但对响应时间并没有严苛的要求。
|
||||
>
|
||||
|
||||
我们需要在全软件栈进行优化才能提供实时保证:
|
||||
|
||||
1. **在操作系统上**,需要能提供指定所需 CPU 时间片的实时操作系统(*real-time operating system* ,RTOS)。
|
||||
1. **在操作系统上**,需要能提供指定所需 CPU 时间片的实时操作系统(_real-time operating system_,RTOS)。
|
||||
2. **在依赖库中**,所有的函数都需要注释其运行时间的上界。
|
||||
3. **在内存分配上**,要限制甚至禁止动态内存分配(会有实时 GC 器,但不会占用太多时间)。
|
||||
4. **在观测和测试上**,需要进行详尽的衡量和测试,以保证满足实时要求。
|
||||
@ -404,7 +396,7 @@ while (true) {
|
||||
|
||||
我们有一些手段可以用来减轻进程停顿现象,且不必借助代价高昂的强实时系统。比如**垃圾回收器**(GC 进程)可以实时追踪**对象分配速率**和剩余**可利用内存**,利用这些信息,GC 进程可以给应用程序提供一些信号。然后我们在构造系统时捕获这些信号,然后拒绝服务一段时间,等待 GC 结束。就跟临时故障或者下线的节点一样,别的节点会来接管请求。一些对延迟比较敏感的系统,如交易系统,就是用了类似的方法。
|
||||
|
||||
另一个相似的想法是,阉割一下 GC ,只用其对短时对象进行快速回收。对于生命周期较长的对象,通过通过**定期重启**来回收。在重启期间,该节点上的流量可以暂时切走,就像滚动升级一样。
|
||||
另一个相似的想法是,阉割一下 GC,只用其对短时对象进行快速回收。对于生命周期较长的对象,通过通过**定期重启**来回收。在重启期间,该节点上的流量可以暂时切走,就像滚动升级一样。
|
||||
|
||||
这些手段虽然不能治本,但能一定程度上缓解 GC 对应用进程造成的影响。
|
||||
|
||||
@ -435,11 +427,11 @@ while (true) {
|
||||
|
||||
打个比方,这种情况就像一个噩梦:处于半连接的节点就像躺在棺材里被运向墓地,尽管他持续大喊:“我没有死”,但没有人能听到他的喊声,葬礼继续。
|
||||
|
||||
第二个场景,稍微不那么噩梦一些,这个处于半连接的节点意识到了他发出去的消息别人收不到,进而推测出应该是网络出了问题。但纵然如此,该节点仍然被标记为死亡,而它也没有办法做任何事情来改变,*但起码他自己能意识到这一点*。
|
||||
第二个场景,稍微不那么噩梦一些,这个处于半连接的节点意识到了他发出去的消息别人收不到,进而推测出应该是网络出了问题。但纵然如此,该节点仍然被标记为死亡,而它也没有办法做任何事情来改变,_但起码他自己能意识到这一点_。
|
||||
|
||||
第三个场景, 假设一个节点经历了长时间的 GC ,该节点上的所有线程都被中断长达一分钟,此时任何发到该节点的请求都无法被处理,自然也就无法收到答复。其他节点经过等待、重试、失掉耐心进而最终标记该节点死亡,然后将其送进棺材板。经过漫长的一分钟后,终于,GC 完成,所有线程被唤醒从中断处继续执行。**从该线程的角度来看,好像没有发生过任何事情**。但是其他节点惊讶地发现棺材板压不住了,该节点做起来了,恢复了健康,并且又开始跟旁边的人很开心的聊天了。
|
||||
第三个场景,假设一个节点经历了长时间的 GC,该节点上的所有线程都被中断长达一分钟,此时任何发到该节点的请求都无法被处理,自然也就无法收到答复。其他节点经过等待、重试、失掉耐心进而最终标记该节点死亡,然后将其送进棺材板。经过漫长的一分钟后,终于,GC 完成,所有线程被唤醒从中断处继续执行。**从该线程的角度来看,好像没有发生过任何事情**。但是其他节点惊讶地发现棺材板压不住了,该节点做起来了,恢复了健康,并且又开始跟旁边的人很开心的聊天了。
|
||||
|
||||
上述几个故事都表明,任何节点都没法**独自断言**其自身当前状态。一个分布式系统不能有单点依赖,因为单个节点可能在任意时刻故障,进而导致整个系统卡住,甚而不能恢复。因此,大部分分布式算法会基于一个**法定人数**(*quorum*),即让所有节点进行投票:**任何决策都需要达到法定人数才能生效,以避免对单节点的依赖**。
|
||||
上述几个故事都表明,任何节点都没法**独自断言**其自身当前状态。一个分布式系统不能有单点依赖,因为单个节点可能在任意时刻故障,进而导致整个系统卡住,甚而不能恢复。因此,大部分分布式算法会基于一个**法定人数**(_quorum_),即让所有节点进行投票:**任何决策都需要达到法定人数才能生效,以避免对单节点的依赖**。
|
||||
|
||||
其中,前面故事中的宣布某个节点死亡就是这样一种决策。如果有达到法定个数的节点宣布某节点死亡,那他就会被标记为死亡。即使他还活着,也不得不服从系统决策而出局。
|
||||
|
||||
@ -471,7 +463,7 @@ while (true) {
|
||||
|
||||
即,在锁服务每次授予锁或者租约时,会附带给一个**防护令牌**(fencing token)。该防护令牌其实就是一个单调递增数字,锁服务在每次锁被授予时,对其进行加一。当存储服务每次收到客户端的请求时,都会要求出示该令牌。
|
||||
|
||||
如上图,客户端 1 获得了一个关联了令牌号 33 的租期,但随即经历了长时间的停顿,然后租约过期。客户端 2 获得了一个关联令牌号 34 的租期,并且向存储服务发送了一个附带了该令牌号的写请求。稍后,当客户端 1 结束停顿时,附带令牌号 33 ,给存储服务发送写请求。然而,由于存储服务记下了它**处理过更高令牌号**(34)的请求,于是它就会拒绝该使用令牌号 33 的请求。
|
||||
如上图,客户端 1 获得了一个关联了令牌号 33 的租期,但随即经历了长时间的停顿,然后租约过期。客户端 2 获得了一个关联令牌号 34 的租期,并且向存储服务发送了一个附带了该令牌号的写请求。稍后,当客户端 1 结束停顿时,附带令牌号 33,给存储服务发送写请求。然而,由于存储服务记下了它**处理过更高令牌号**(34)的请求,于是它就会拒绝该使用令牌号 33 的请求。
|
||||
|
||||
如果我们使用 ZooKeeper 作为锁服务,那么事务 ID zxid 或者节点版本 cversion 可以用于防护令牌。因为他们单调递增,符合需求。
|
||||
|
||||
@ -483,22 +475,21 @@ while (true) {
|
||||
|
||||
## 拜占庭错误
|
||||
|
||||
防护令牌只能检测并阻止**无意**(*inadvertently,如不知道自己租约过期了*)中犯错的客户端。但如果某个客户端节点存心想打破系统约定,可以通过**伪造**防护令牌来轻易做到。
|
||||
防护令牌只能检测并阻止**无意**(_inadvertently,如不知道自己租约过期了_)中犯错的客户端。但如果某个客户端节点存心想打破系统约定,可以通过**伪造**防护令牌来轻易做到。
|
||||
|
||||
在本书中我们假设所有参与系统的节点有可能**不可靠**(unreliable)、但一定是**诚实的**(honest):这些节点有可能反应较慢甚至没有响应(由于故障),他们的状态可能会过期(由于 GC 停顿或者网络延迟),但一旦节点响应,“说的都是真话”:**在其认知范围内,尽可能的遵守协议进行响应**。
|
||||
|
||||
如果系统中的节点有“说谎”(发送任意错误的的或者损坏的信息)的可能性,分布式系统将会变得十分复杂。如,一个节点没有收到某条消息却声称收到了。这种行为称为**拜占庭故障**(Byzantine fault),在具有拜占庭故障的环境中达成共识也被称为**拜占庭将军问题**(Byzatine Generals Problem)。
|
||||
|
||||
> **拜占庭将军问题**
|
||||
>
|
||||
>
|
||||
> 拜占庭将军问题是**两将军问题**(Two Generals Problem)的**泛化**。两将军问题设想了一个需要达成作战计划的战争场景。有两只军队,驻扎在两个不同的地方,只能通过信使来交换信息,但信使有时候会迟到甚至迷路(如网络中的数据包)。第九章会详细讨论这个问题。
|
||||
>
|
||||
>
|
||||
> 在该问题的拜占庭版本,有 n 个将军,但由于中间出了一些叛徒,他们想达成共识更具难度。但大部分将军仍然是忠诚的,并且会送出真实的消息;与此同时,叛徒会试图通过送出假的或者失实的消息来欺骗和混淆其他人(同时保持隐蔽)。大家事先都不知道谁是叛徒。
|
||||
>
|
||||
>
|
||||
> 拜占庭是一个古希腊城市,后来罗马皇帝君士坦丁在此建立新都,称为“新罗马”,但后人普遍被以建立者之名称作君士坦丁堡,现在是土耳其的伊斯坦布尔。当然,没有任何历史证据表明拜占庭的将军比其他地方更多地使用阴谋诡计。相反,这个名字是来自于拜占庭本身,在计算机出现很久之前,拜占庭就有极度复杂、官僚主义、狡猾多变等含义。Lamport 想选一个不会冒犯任何读者的城市,比如,有人提醒他*阿尔巴尼亚将军问题*就不是一个好名字。
|
||||
>
|
||||
|
||||
如果有一些节点发生故障且不遵守协议,或者恶意攻击者正在扰乱网络,一个系统仍能正确运行,则该系统是**拜占庭容错的**(*Byzantine fault-tolerant*)。举几个相关的场景例子:
|
||||
如果有一些节点发生故障且不遵守协议,或者恶意攻击者正在扰乱网络,一个系统仍能正确运行,则该系统是**拜占庭容错的**(_Byzantine fault-tolerant_)。举几个相关的场景例子:
|
||||
|
||||
- 在航天环境中,由于**高辐射环境**的存在,计算机内存或者寄存器中的数据可能会损坏,进而以任意不可预料的方式响应其他节点的请求。在这种场景下,系统故障代价会非常高昂(如:太空飞船坠毁并致使所有承载人员死亡,或者火箭装上国际空间站),因此**飞控系统**必须容忍拜占庭故障。
|
||||
- 在一个有**多方组织**参与的系统中,有些参与方可能会尝试作弊或者欺骗别人。在这种环境中,由于恶意消息发送方的存在,无脑的相信其他节点的消息是不安全的。如,类似比特币或者其他区块链的 p2p 网络,就是一种让没有互信基础的多方,在不依赖**中央权威**的情况下,就某个交易达成共识的一种方法。
|
||||
@ -527,21 +518,21 @@ Web 应用确实可能遇到由任意终端用户控制的客户端(如浏览
|
||||
|
||||
对于时间的假设,有三种系统模型很常用:
|
||||
|
||||
1. **同步模型(*synchronous model*)**。这种模型假设**网络延迟**、**进程停顿**和**时钟错误**都是**有界**的。但这不是说,时钟时完全同步的、网络完全没有延迟,只是说我们知道上述问题永远不会超过一个上界。但当然,这不是一个现实中的模型,因为在实践中,无界延迟和停顿都会实实在在的发生。
|
||||
1. **同步模型(_synchronous model_)**。这种模型假设**网络延迟**、**进程停顿**和**时钟错误**都是**有界**的。但这不是说,时钟时完全同步的、网络完全没有延迟,只是说我们知道上述问题永远不会超过一个上界。但当然,这不是一个现实中的模型,因为在实践中,无界延迟和停顿都会实实在在的发生。
|
||||
2. **半同步模型(partial synchronous)**。意思是在大多数情况下,网络延迟、进程停顿和时钟漂移都是有界的,只有偶尔,他们会超过界限。这是一种比较真实的模型,即在**大部分时间里**,系统中的网络和进程都表现良好,否则我们不可能完成任何事情。但与此同时,我们必须要记着,任何关于时限的假设都有可能被打破。且一旦出现出现异常现象,我们需要做好最坏的打算:网络延迟、进程停顿和时钟错误都有可能错的非常离谱。
|
||||
3. **异步模型(*Asynchronous model*)**。在这种模型里,算法不能对时间有任何假设,甚至时钟本身都有可能不存在(在这种情况下,超时间隔根本没有意义)。有些算法可能会针对这种场景进行设计,但很少很少。
|
||||
3. **异步模型(_Asynchronous model_)**。在这种模型里,算法不能对时间有任何假设,甚至时钟本身都有可能不存在(在这种情况下,超时间隔根本没有意义)。有些算法可能会针对这种场景进行设计,但很少很少。
|
||||
|
||||
除时间问题,我们还需要对节点故障进行抽象。针对节点,有三种最常用的系统模型:
|
||||
|
||||
1. **宕机停止故障(*Crash-stop faults*)**。节点只会通过崩溃的方式宕机,即某个时刻可能会突然宕机无响应,并且之后永远不会再上线。
|
||||
2. **宕机恢复故障(*Crash-recovery faults*)**。节点可能会在任意时刻宕机,但在宕机之后某个时刻会重新上线,但恢复所需时间我们是不知道的。在此模型中,我们假设节点的稳定存储中的数据在宕机前后不会丢失,但内存中的数据会丢失。
|
||||
3. **拜占庭(任意)故障(*Byzantine (arbitrary) faults*)**。我们不能对节点有任何假设,包括宕机和恢复时间,包括善意和恶意,前面小节已经详细讨论过了这种情形。
|
||||
1. **宕机停止故障(_Crash-stop faults_)**。节点只会通过崩溃的方式宕机,即某个时刻可能会突然宕机无响应,并且之后永远不会再上线。
|
||||
2. **宕机恢复故障(_Crash-recovery faults_)**。节点可能会在任意时刻宕机,但在宕机之后某个时刻会重新上线,但恢复所需时间我们是不知道的。在此模型中,我们假设节点的稳定存储中的数据在宕机前后不会丢失,但内存中的数据会丢失。
|
||||
3. **拜占庭(任意)故障(_Byzantine (arbitrary) faults_)**。我们不能对节点有任何假设,包括宕机和恢复时间,包括善意和恶意,前面小节已经详细讨论过了这种情形。
|
||||
|
||||
对于真实世界,**半同步模型**和宕机恢复故障是较为普遍的建模,那我们又要如何设计算法来应对这两种模型呢?
|
||||
|
||||
### 算法的正确性
|
||||
|
||||
我们可以通过描述算法**需要满足的性质**,来定义其正确性。举个例子,排序算法的输出满足特性:*任取结果列表中的两个元素,左边的都比右边的小*。这是一种简单的对列表有序的形式化定义。
|
||||
我们可以通过描述算法**需要满足的性质**,来定义其正确性。举个例子,排序算法的输出满足特性:_任取结果列表中的两个元素,左边的都比右边的小_。这是一种简单的对列表有序的形式化定义。
|
||||
|
||||
类似的,我们可以给出描述分布式算法的正确性的一些性质。如,我们想通过产生防护令牌的方式来上锁,则我们期望该算法具有以下性质:
|
||||
|
||||
@ -555,7 +546,7 @@ Web 应用确实可能遇到由任意终端用户控制的客户端(如浏览
|
||||
|
||||
为了进一步弄清状况,我们需要进一步区分**两类**不同的属性:**安全性**(safety)和**存活性**(liveness)。在上面的例子中,唯一性和单调有序性属于安全性,但可用性属于存活性。
|
||||
|
||||
那如何区分这两者呢?一个简单的方法是,在描述存活性的属性的定义里总会包含单词:“**最终**(*eventually*)”,对,我知道你想说什么,**最终一致性**(eventually)也是一个存活性属性。
|
||||
那如何区分这两者呢?一个简单的方法是,在描述存活性的属性的定义里总会包含单词:“**最终**(_eventually_)”,对,我知道你想说什么,**最终一致性**(eventually)也是一个存活性属性。
|
||||
|
||||
安全性,通俗的可以理解为**没有坏事发生**(nothing bad happens);而存活性可以理解为**好的事情最终发生了**(something good eventually happens),但也不要对这些非正式定义太过咬文嚼字,因为所谓的“好”和“坏”都是相对的。安全性和存活性的严格定义都是精确且数学化的:
|
||||
|
||||
|
184
ch09.md
184
ch09.md
@ -1,7 +1,6 @@
|
||||
# DDIA 逐章精读(九): 一致性和共识协议(Consistency and Consensus)
|
||||
|
||||
> 本章的线性一致性是在铺垫了多副本、网络问题、时钟问题后的一个综合探讨。首先探讨了线性一致的内涵:让系统表现得好像只有一个数据副本。然后讨论如何实现线性一致性,以及背后所做出的的取舍考量。其间花了一些笔墨探讨 CAP,可以看出作者很不喜欢 CAP 的模糊性。
|
||||
>
|
||||
|
||||
如前所述,分布式系统中很多事情都有可能出错。解决出错最**简单粗暴**的方法是让整个**系统宕机**,并给出出错原因。但在实际生产中,这种方式多不可接受,此时我们就需要找到**容错**(tolerating faults)的方法。即,即使系统构件出现了一些问题,我们能保证系统仍然正常运行。
|
||||
|
||||
@ -13,10 +12,9 @@
|
||||
|
||||
构建一个容错系统最好的方法是:**找到一些基本抽象,可以对上提供某些承诺,应用层可以依赖这些承诺来构建系统,而不必关心底层细节**。在第七章中,通过使用事务,应用层可以假设不会发生宕机(**原子性**,意思是不会因为宕机出现让事务停留在半成功的状态),没有其他应用并发访问数据库数据(**隔离性**),且存储系统非常可靠(**持久性**)。事务模型会隐藏节点宕机、**竞态条件**(race conditions)、硬盘故障等底层细节,即使这些问题出现了,应用层也不必关心。
|
||||
|
||||
本章将继续讨论一些可以减轻应用层负担的分布式系统中的**基本抽象**。比如,分布式系统中最重要的一个抽象——**共识**(consensus),即,*让所有节点在**某件事情**上达成一致*。在本章稍后的讨论可以看出,让系统中的所有节点在有网络故障和节点宕机的情况下达成共识,是一件非常棘手的事情。
|
||||
本章将继续讨论一些可以减轻应用层负担的分布式系统中的**基本抽象**。比如,分布式系统中最重要的一个抽象——**共识**(consensus),即,_让所有节点在**某件事情**上达成一致_。在本章稍后的讨论可以看出,让系统中的所有节点在有网络故障和节点宕机的情况下达成共识,是一件非常棘手的事情。
|
||||
|
||||
> 为什么共识协议如此重要呢?他和真实系统的连接点在于哪里?答曰,**操作日志**。而大部分**数据系统**都可以抽象为一系列**数据操作**的依次施加,即状态机模型。而共识协议可以让多机对某个**确定**的**操作序列**达成共识,进而对系统的任意状态达成共识。
|
||||
>
|
||||
|
||||
一旦我们实现了**共识协议**,应用层可以依赖其做很多事情。例如,你有一个使用单主模型的数据库,如果主副本所在节点宕机,我们便可以使用共识协议选出新的主。在第五章处理节点下线(**Handling Node Outages**)一节中我们提到过,只有唯一的主,并且所有副本都认可该主,是一个需要确保的非常重要的特性。如果有超过一个节点都认为自己是主,我们称之为**脑裂**(split brain)。脑裂很容易导致数据丢失,而正确实现是的共识协议能够避免该问题。
|
||||
|
||||
@ -30,7 +28,7 @@
|
||||
|
||||
在第五章中日志滞后问题(Problems with Replication Lag)小节,我们分析了一些多副本数据所遇到的时序问题。在相同时刻,如果对比多副本数据库中一份数据的两个副本,我们可能会看到不一致的数据。这是因为,写请求到达不同的数据节点,总会存在一个**时间差**。无论我们使用什么数据**副本模型**(单主、多主和无主),这种数据的不一致性都有可能会发生。
|
||||
|
||||
大部分**多副本数据库**(replicated databases)提供**最终一致性**(eventually consistency)的保证,这意味着,只要你对数据库停写,并等待足够长的时间,则所有对相同数据的读取请求最终会返回相同的结果。从另一个角度来说,所有的不一致都是暂时的,最终都会被解决(当然,这得是在网络故障能最终修复的假设之下)。描述相同意思的一个更好的名字可能:**收敛性**(*convergence*),即最终,所有副本都会收敛到相同的值。
|
||||
大部分**多副本数据库**(replicated databases)提供**最终一致性**(eventually consistency)的保证,这意味着,只要你对数据库停写,并等待足够长的时间,则所有对相同数据的读取请求最终会返回相同的结果。从另一个角度来说,所有的不一致都是暂时的,最终都会被解决(当然,这得是在网络故障能最终修复的假设之下)。描述相同意思的一个更好的名字可能:**收敛性**(_convergence_),即最终,所有副本都会收敛到相同的值。
|
||||
|
||||
但这是一个相当不靠谱的保证——没有提供任何关于**何时**收敛的信息。而在收敛之前,对于相同数据的读取,可能会返回任意值甚至不返回。举个例子,你向多副本数据库中写入了一条数据,并立即读取他。你能读到什么,最终一致性对此不会提供任何保证,因为读取请求可能会被路由到任何其他副本。
|
||||
|
||||
@ -41,7 +39,6 @@
|
||||
本章我们会一起探究一些更强的一致性模型,但选择这些模型是有代价的。相对弱一致性模型系统来说,这么做要么会牺牲性能,要么会牺牲可用性。但提供强保证会让应用层能更加容易、正确的使用。但当然,我们最终还是得根据具体场景,来选择使用何等强度一致性模型。
|
||||
|
||||
> 在实践中,我们常会使用分层策略,让某些底层解决可用性、性能和容量的问题,让上层解决一致性的问题。比如云上各种基于 aws s3 的关系型数据库。另外,也有些系统会同时提供多种一致性模型供用户选择,在一致性和性能间进行取舍。
|
||||
>
|
||||
|
||||
分布式系统中的**一致性模型的强弱**和第七章讲的事物的**隔离级别层次**有一些共通之处,比如在性能和隔离性/一致性间做取舍。但他们是相对独立的抽象:
|
||||
|
||||
@ -80,19 +77,18 @@
|
||||
|
||||
在本例中,寄存器支持两种类型的操作:
|
||||
|
||||
1. *read*(*x*) ⇒ *v:* 客户端请求读寄存器 x 的值,数据库会返回值 v
|
||||
2. *write*(*x*, *v*) ⇒ *r:* 客户端请求将寄存器 x 的值设置为 v,数据返回结果 r(可能是成功或者失败)
|
||||
1. _read_(_x_) ⇒ _v:_ 客户端请求读寄存器 x 的值,数据库会返回值 v
|
||||
2. _write_(_x_, _v_) ⇒ _r:_ 客户端请求将寄存器 x 的值设置为 v,数据返回结果 r(可能是成功或者失败)
|
||||
|
||||
在上图中,x 初始值为 0,客户端 C 发出一个写请求将其置为 1。在此期间,客户端 A 和 B 不断地向数据库请求 x 的最新值。试问 A 和 B 的每个读请求都会读到什么值?
|
||||
|
||||
- 客户端 A 的第一个读请求**结束在** C 的写请求**发出之前**,所以该请求一定会返回 0。
|
||||
- 客户端 A 的最后一个读请求**开始于** C 的写请求**完成之后**,因此该请求一定会返回 1,当然,是在该数据库提供线性一致性保证的前提下:我们知道数据库的写操作一定发生在写请求期间的某个时间点、读操作一定也发生在读请求的某个时间点,如果读请求开始于写请求结束之后,则在数据库端,读操作一定是在写操作之后被处理的,因此该读操作一定能看到写操作所写。
|
||||
- 其他的读请求时间条与该写请求都有**交集**,因此可能会返回 1 ,也有可能会返回 0。因为我们无从判断在数据库端,读操作和写操作的具体先后关系。从这个角度来说,这些读请求和写请求都是**并发的**(concurrent)。
|
||||
- 其他的读请求时间条与该写请求都有**交集**,因此可能会返回 1,也有可能会返回 0。因为我们无从判断在数据库端,读操作和写操作的具体先后关系。从这个角度来说,这些读请求和写请求都是**并发的**(concurrent)。
|
||||
|
||||
不过,这也不足以刻画线性一致性:**如果和写请求并发的多个读请求既可能会返回新值,也可能会返回旧值,则在写请求持续期间,读客户端可能会看到不断交替的新旧值**。这明显不符合我们所期待的,能够提供“单数据副本”抽象的系统。
|
||||
|
||||
> 在多副本数据库中,如果要解决线性一致性,就要满足一旦某个客户端读取到新值,则其之后的读请求一定能读到该新值,而不是还可能看到旧值。这很难,由于上一章讲的时钟问题,我们甚至很难对多个客户端定义“**先后**”。此外,这种线性一致性的特性类似于薛定谔的猫,本来可能有多个状态,但一旦有个一个客户端进行了一次观察,就迅速的坍缩到了一个状态,其他后来者,也只能看到这一个状态。从另外一个角度理解,是读取请求**塑造**(seal)了并发请求的多状态边界。
|
||||
>
|
||||
|
||||
为了让该系统满足线性一致性,我们需要增加一些额外限制,如下图:
|
||||
|
||||
@ -108,7 +104,7 @@
|
||||
|
||||
在上图中,我们在读和写之外,增加了第三种操作:
|
||||
|
||||
- $cas(x, v_{old}, v_{new}) ⇒ r$ 表示客户端发出了一个原子的 CAS( compare-and-set) 请求。只有当寄存器 x 的值为 $v_{old}$ 时,才会将 x 的值替换为 $v_{new}$。如果 $x≠v_{old}$ 则 x 的值保持不变,并且返回错误。r 是数据的返回值(成功或者错误)。
|
||||
- $cas(x, v_{old}, v_{new}) ⇒ r$ 表示客户端发出了一个原子的 CAS(compare-and-set)请求。只有当寄存器 x 的值为 $v_{old}$ 时,才会将 x 的值替换为 $v_{new}$。如果 $x≠v_{old}$ 则 x 的值保持不变,并且返回错误。r 是数据的返回值(成功或者错误)。
|
||||
|
||||
上图中的所有请求都被关联上了一条竖线(在每个操作的时间条中),我们认为对应的操作在此时刻真正发生。所有的标记组成一种执行顺序,该顺序必须满足寄存器的**读写特性**(所有的读必须能返回最近的写)。
|
||||
|
||||
@ -118,28 +114,22 @@
|
||||
|
||||
1. **B 读到“稍后”的值**。开始时,客户端 B 首先发出一个针对 x 的读请求,然后客户端 D 发出一个设置 x = 0 的写请求,紧接着,客户端 A 发出了另一个设置 x = 1 的写请求。然后 B 读到的值却是 1。这样是合法的,并且说明数据库先处理了 D(设置 x = 0)的写请求、接着处理了 A 的写请求,最后是 B 的读请求。尽管这个序列不符合请求发出的时间点先后,但这是一个可以接受的顺序,**因为这三个请求本质上并发的,因此事实上谁先谁后被处理都有可能**。比如,有可能是客户端 B 的请求在网络中延迟了一些,以至于在两个写之后才被处理。
|
||||
2. **客户端 A 写请求还没结束客户端 B 就读到了其写的值 1**。这也是合法的:这并不是说我们在 1 写成功之前读到了,而只是说明 A 的写操作的 ok 回应回来的**有一些延迟**。
|
||||
3. **这个模型对隔离性没有任何假设**:客户端可能在任何时刻更改值,并且能被其他客户端看到。例如,C 两次读取,第一次读到 1 第二次读到 2, 这是因为两次读取间 B 修改了 x。使用原子的 CAS 操作可以在修改 x 的值时,避免被其他客户端并发的修改。B 和 C 的 CAS 请求成功了,但是 D 的 CAS 请求失败了(因为数据库处理其 CAS 时,x 的值已经不是 0 了)。
|
||||
3. **这个模型对隔离性没有任何假设**:客户端可能在任何时刻更改值,并且能被其他客户端看到。例如,C 两次读取,第一次读到 1 第二次读到 2,这是因为两次读取间 B 修改了 x。使用原子的 CAS 操作可以在修改 x 的值时,避免被其他客户端并发的修改。B 和 C 的 CAS 请求成功了,但是 D 的 CAS 请求失败了(因为数据库处理其 CAS 时,x 的值已经不是 0 了)。
|
||||
4. **B 的最后一个读请求不满足线性一致**。该请求和 C 的 CAS 写是并发的,C 的 CAS 将 x 从 2 更新到了 4。如果没有其他操作,B 读到 2 是合法的。但是客户端 A 读到了 4,并且在 B 请求开始前就返回了,因此 B 不允许读到比 A 更老的值。这个也和之前 Alice 和 Bob 的例子类似。
|
||||
|
||||
这就是线性一致性背后的一些直觉,[参考文献](https://cs.brown.edu/~mph/HerlihyW90/p463-herlihy.pdf)中有更形式化的定义。可以(但是代价很高)通过记录系统中所有请求的时间线,并检查其能否组成合法的顺序,来测试该系统是否满足线性一致性。
|
||||
|
||||
> **线性一致性和可串行化**
|
||||
>
|
||||
|
||||
> **线性一致性**(Linearizability)很容易和**可串行化**(serializability)相混淆,因为他们看起来都意味着:可以进行顺序化组织。但他们是不同维度的约束,我们很有必要对其进行区分:
|
||||
>
|
||||
|
||||
> **可串行化**(Serializability)。可串行化是事务的一种隔离级别。每个事务可能会涉及多个数据对象(行、文档、记录)的读写,[之前](https://ddia.qtmuniao.com/#/ch07?id=%e5%8d%95%e5%af%b9%e8%b1%a1%e5%92%8c%e5%a4%9a%e5%af%b9%e8%b1%a1%e6%93%8d%e4%bd%9c)有讨论过单对象和多对象。可串行化可以保证所有事务好像按**某种顺序依次执行**(后一个事务在前一个事务结束后才开始)。需要注意的是,如果某种串行顺序和实际执行顺序不一致也没事,只要是串行执行就行。举个例子,如果 A、B、C 三个事务并发执行,真实顺序是 A、B、C,但如果对应用层表现为 CAB 的执行顺序(可能由于多机时间戳不同步),也可以叫可串行化,但CAB 的执行顺序在某个对象上可能不满足线性一致性。
|
||||
>
|
||||
> **可串行化**(Serializability)。可串行化是事务的一种隔离级别。每个事务可能会涉及多个数据对象(行、文档、记录)的读写,[之前](https://ddia.qtmuniao.com/#/ch07?id=%e5%8d%95%e5%af%b9%e8%b1%a1%e5%92%8c%e5%a4%9a%e5%af%b9%e8%b1%a1%e6%93%8d%e4%bd%9c)有讨论过单对象和多对象。可串行化可以保证所有事务好像按**某种顺序依次执行**(后一个事务在前一个事务结束后才开始)。需要注意的是,如果某种串行顺序和实际执行顺序不一致也没事,只要是串行执行就行。举个例子,如果 A、B、C 三个事务并发执行,真实顺序是 A、B、C,但如果对应用层表现为 CAB 的执行顺序(可能由于多机时间戳不同步),也可以叫可串行化,但 CAB 的执行顺序在某个对象上可能不满足线性一致性。
|
||||
|
||||
> **线性一致性**(Linearizability)。线性一致性是一种针对寄存器(register,单个数据对象)的读写新鲜度保证。它不会将多个操作打包成事务,因此不能避免像之前提到的[写偏序](https://ddia.qtmuniao.com/#/ch07?id=%e5%86%99%e5%81%8f%e5%ba%8f%e5%92%8c%e5%b9%bb%e8%af%bb)等问题,除非使用某些辅助手段,如[物化冲突](https://ddia.qtmuniao.com/#/ch07?id=%e7%89%a9%e5%8c%96%e5%86%b2%e7%aa%81)。
|
||||
>
|
||||
|
||||
> 一个数据库可以同时提供可串行化和线性一致性保证,我们称之为**严格可串行化**(*strict serializability*)或者**单副本可串行化**(strong one-copy serializability)。使用两阶段锁或者真正串行化执行实现的可串行化,通常都是线性一致的。
|
||||
>
|
||||
> 一个数据库可以同时提供可串行化和线性一致性保证,我们称之为**严格可串行化**(_strict serializability_)或者**单副本可串行化**(strong one-copy serializability)。使用两阶段锁或者真正串行化执行实现的可串行化,通常都是线性一致的。
|
||||
|
||||
> 然而,基于快照隔离的串行化**通常**不是线性一致的。为了避免读写互相阻塞,所有的读取都会基于某个一致性的快照,则该快照之后的写入不会反映到读请求上,因此,快照读不满足线性一致性。
|
||||
>
|
||||
|
||||
## 依赖线性一致性
|
||||
|
||||
@ -196,25 +186,15 @@
|
||||
最常用的让系统进行容错的方式就是多副本。让我们回顾下第五章的几种多副本模型,然后逐一考察下其是否能够做成可线性化的:
|
||||
|
||||
- **单主模型**(Single-leader replication,potentially linearizable)
|
||||
|
||||
在一个单主模型的系统中,主副本服务于写请求,其他副本负责备份。如果我们让读取也走主副本,或者使用同步更新从副本的策略,则该系统**有可能**满足线性一致性。但是,并不是所有单主模型的数据都提供线性一致性,有时候是故意的(比如提供快照隔离),有时候是由于并发 bug。
|
||||
|
||||
想让主副本也负责读请求,首先我们得确切知道哪一个是主副本。就像我们在“[真相由多数节点定义](https://www.notion.so/Chapter-9-Consistency-and-Consensus-f80d66bdfb7b4d1281722914239a563a)”一节中提到的,很有可能某个节点认为他是主节点,但其事实上不是。如果这个**自以为是的主节点**(delusional leader)继续提供服务,则系统很可能会违反线性一致性。如果使用异步同步策略,节点宕机可能甚至会丢数据,从而不仅违反线性一致性,也违反了可持久性。
|
||||
|
||||
在一个单主模型的系统中,主副本服务于写请求,其他副本负责备份。如果我们让读取也走主副本,或者使用同步更新从副本的策略,则该系统**有可能**满足线性一致性。但是,并不是所有单主模型的数据都提供线性一致性,有时候是故意的(比如提供快照隔离),有时候是由于并发 bug。
|
||||
想让主副本也负责读请求,首先我们得确切知道哪一个是主副本。就像我们在“[真相由多数节点定义](https://www.notion.so/Chapter-9-Consistency-and-Consensus-f80d66bdfb7b4d1281722914239a563a)”一节中提到的,很有可能某个节点认为他是主节点,但其事实上不是。如果这个**自以为是的主节点**(delusional leader)继续提供服务,则系统很可能会违反线性一致性。如果使用异步同步策略,节点宕机可能甚至会丢数据,从而不仅违反线性一致性,也违反了可持久性。
|
||||
- **共识算法**(Consensus algorithms,linearizable)
|
||||
|
||||
我们本章稍后会讨论到,有一些共识算法,看起来与单主模型类似。但这些共识协议有一些阻止脑裂和过期副本的手段。由于这些额外细节,共识算法可以实现安全的线性一致性存储。Zookeeper 和 etcd 就是用的这种手段。
|
||||
|
||||
我们本章稍后会讨论到,有一些共识算法,看起来与单主模型类似。但这些共识协议有一些阻止脑裂和过期副本的手段。由于这些额外细节,共识算法可以实现安全的线性一致性存储。Zookeeper 和 etcd 就是用的这种手段。
|
||||
- **多主模型**(Multi-leader replication,not linearizable)
|
||||
|
||||
由于可以同时在多个节点上处理写入,并且异步同步写入数据,使用多主模型的系统通常不是线性一致的。由于上述原因,这种系统可能会产生需要手动解决的写入冲突。这种冲突便是违反线性一致性要求的一个点:表现的像一个副本。
|
||||
|
||||
由于可以同时在多个节点上处理写入,并且异步同步写入数据,使用多主模型的系统通常不是线性一致的。由于上述原因,这种系统可能会产生需要手动解决的写入冲突。这种冲突便是违反线性一致性要求的一个点:表现的像一个副本。
|
||||
- **无主模型**(Leader replication, probably not linearizable)
|
||||
|
||||
对于使用无主模型的系统(Dynamo-style,参见前面[无主模型](https://ddia.qtmuniao.com/#/ch05?id=%e6%97%a0%e4%b8%bb%e6%a8%a1%e5%9e%8b)一节)来说,厂商有时候会声称你可以通过使用法定数目读写(Quorum reads and write,w+r > n)来获得强一致性。这个说法有点模糊,并不总是正确,这取决于你对法定节点的配置,也取决于你如何定义强一致性。
|
||||
|
||||
“后者胜”(Last write win)的冲突解决方法会依赖于多个机器的挂历时钟(time-of-day,参见[依赖时钟同步](https://ddia.qtmuniao.com/#/ch08?id=%e4%be%9d%e8%b5%96%e5%90%8c%e6%ad%a5%e6%97%b6%e9%92%9f)),由于多机时钟存在偏差,其物理时间戳不能保证和系统事件顺序一致,因此基本上不可能做到线性一致。放松的法定策略(Quorum,见[放松的 Quorum 和提示转交](https://ddia.qtmuniao.com/#/ch05?id=%e6%94%be%e6%9d%be%e7%9a%84-quorum-%e5%92%8c%e6%8f%90%e7%a4%ba%e8%bd%ac%e4%ba%a4))也是破坏了线性一致性。即使对于严格的法定策略,非线性一致的现象也可能出现,下一节将会详细探讨。
|
||||
|
||||
对于使用无主模型的系统(Dynamo-style,参见前面[无主模型](https://ddia.qtmuniao.com/#/ch05?id=%e6%97%a0%e4%b8%bb%e6%a8%a1%e5%9e%8b)一节)来说,厂商有时候会声称你可以通过使用法定数目读写(Quorum reads and write,w+r > n)来获得强一致性。这个说法有点模糊,并不总是正确,这取决于你对法定节点的配置,也取决于你如何定义强一致性。
|
||||
“后者胜”(Last write win)的冲突解决方法会依赖于多个机器的挂历时钟(time-of-day,参见[依赖时钟同步](https://ddia.qtmuniao.com/#/ch08?id=%e4%be%9d%e8%b5%96%e5%90%8c%e6%ad%a5%e6%97%b6%e9%92%9f)),由于多机时钟存在偏差,其物理时间戳不能保证和系统事件顺序一致,因此基本上不可能做到线性一致。放松的法定策略(Quorum,见[放松的 Quorum 和提示转交](https://ddia.qtmuniao.com/#/ch05?id=%e6%94%be%e6%9d%be%e7%9a%84-quorum-%e5%92%8c%e6%8f%90%e7%a4%ba%e8%bd%ac%e4%ba%a4))也是破坏了线性一致性。即使对于严格的法定策略,非线性一致的现象也可能出现,下一节将会详细探讨。
|
||||
|
||||
### 线性一致和法定策略(Quorum)
|
||||
|
||||
@ -264,26 +244,23 @@ Quorum 的配置是严格满足 w+r>n 的,然而这个读写序列却不是线
|
||||
|
||||
总而言之,**如果系统不提供线性一致性,就可以对网络故障更加鲁棒**。该洞见常被称为 **CAP 定理**,于 2000 年被 Eric Brewer 提出。不过,类似的取舍考量从 1970 年代就为分布式数据的设计人员所熟知了。
|
||||
|
||||
CAP 最初被提出只是一个为了激发数据库取舍讨论的模糊的取舍参考,而非被精确定义的定理,Martin 还专门写过一篇[文章](https://www.notion.so/Chapter-9-Consistency-and-Consensus-f80d66bdfb7b4d1281722914239a563a)来探讨这件事。在当时,很多分布式数据库还在着眼于基于共享存储的一组机器上提供线性一致性语义。CAP 的提出,鼓励工程师们在 share-nothing 等更广阔的设计领域进行架构探索,以找出更加适合大规模可扩展 web 服务架构。 在新世纪的最初十年里,CAP 的提出见证并推动了当时数据库设计思潮从强一致系统转向弱一致系统(也被称为 NoSQL 架构)。
|
||||
CAP 最初被提出只是一个为了激发数据库取舍讨论的模糊的取舍参考,而非被精确定义的定理,Martin 还专门写过一篇[文章](https://www.notion.so/Chapter-9-Consistency-and-Consensus-f80d66bdfb7b4d1281722914239a563a)来探讨这件事。在当时,很多分布式数据库还在着眼于基于共享存储的一组机器上提供线性一致性语义。CAP 的提出,鼓励工程师们在 share-nothing 等更广阔的设计领域进行架构探索,以找出更加适合大规模可扩展 web 服务架构。在新世纪的最初十年里,CAP 的提出见证并推动了当时数据库设计思潮从强一致系统转向弱一致系统(也被称为 NoSQL 架构)。
|
||||
|
||||
**CAP 定理**的形式化定义适用范围很窄:仅包含一种一致性模型(即线性一致性)和一种故障类型(网络分区,或者说节点存活,但互不连通)。它没有进一步说明任何关于网络延迟、宕机节点、以及其他的一些取舍考量。因此,尽管 CAP 在历史上很有影响力,但他在设计系统时缺乏实际有效指导力。
|
||||
|
||||
在分布式系统中有很多其他难以兼顾的有趣结果,CAP 现在已经被很多更为精确的描述所取代,因此 CAP 在今天更多的作为一个历史名词。
|
||||
|
||||
> CAP 有时候被表述为,在做系统设计时,一致性(consistency)、可用性(Availability)、分区容错性(Partition tolerance),只能三取其二。然而,这种说法极具误导性,因为网络分区是一种故障类型,而不是一种可以取舍的选项:不管你喜欢还是不喜欢,它都在那。当然,也有人理解为用单机系统可以规避,但我们当下讨论的前提是分布式系统。
|
||||
>
|
||||
|
||||
> 在网络正常连通时,系统可以同时提供一致性(线性一致性)和完全的可用性。当网络故障发生时,你必须在线性一致性和完全可用性之间二选一。因此,对于 CAP 更好的一个表述可能是:**当网络出现分区时,一致性和可用性只能二选其一**(*either Consistent or Available when Partitioned* )。一个可靠的网络,可以减少其上的系统该选择的次数,但无论如何,分布式系统中,该选择是无法避免的。
|
||||
>
|
||||
> 在网络正常连通时,系统可以同时提供一致性(线性一致性)和完全的可用性。当网络故障发生时,你必须在线性一致性和完全可用性之间二选一。因此,对于 CAP 更好的一个表述可能是:**当网络出现分区时,一致性和可用性只能二选其一**(_either Consistent or Available when Partitioned_)。一个可靠的网络,可以减少其上的系统该选择的次数,但无论如何,分布式系统中,该选择是无法避免的。
|
||||
|
||||
> 在有关 CAP 的讨论,有几种关于可用性的大相径庭的定义,且将 CAP 升格为定理并给出证明中的提到的**形式化的可用性**并非通常意义中所说的可用性。很多所谓“高可用”的系统通常并不符合 CAP 定理中关于可用性的独特(idiosyncratic)定义。总而言之,CAP 有很多容易误解和模糊不清的概念,并不能帮助我们更好的理解系统,因此最好不用 CAP 来描述一个系统。
|
||||
>
|
||||
|
||||
### 线性一致性和网络延迟
|
||||
|
||||
尽管线性一致性是一个非常有用的保证,但令人惊讶的是在工程实践中,很少有系统支持真正的线性一致。甚而,即使在现代**多核 CPU 体系下的 RAM 也不是线性一致的**:如果一个核上的某个线程往某个内存地址中写了一个值,稍后另外核的一个线程读取该地址,并不一定能读到刚才的值。
|
||||
|
||||
这是因为每个 CPU 都有自己的缓存(memory cache)和缓冲区(store buffer)。一般缓存通常说的是读取,而缓冲区通常针对写入。线程的内存访问会首先落到缓存里,所有对于缓存的更新会**异步同步**到主存中。缓存访问的速度要(ns 级别)比内存访问(百ns 级别)快几个数量级,由于可以用来弥合寄存器和主存的访问鸿沟,因此是现代 CPU 架构高性能的基石。但一份数据存了多个副本(比如主存中一个,一些 CPU 缓存中各有一个),且是异步更新的,导致线性一致性被破坏。
|
||||
这是因为每个 CPU 都有自己的缓存(memory cache)和缓冲区(store buffer)。一般缓存通常说的是读取,而缓冲区通常针对写入。线程的内存访问会首先落到缓存里,所有对于缓存的更新会**异步同步**到主存中。缓存访问的速度要(ns 级别)比内存访问(百 ns 级别)快几个数量级,由于可以用来弥合寄存器和主存的访问鸿沟,因此是现代 CPU 架构高性能的基石。但一份数据存了多个副本(比如主存中一个,一些 CPU 缓存中各有一个),且是异步更新的,导致线性一致性被破坏。
|
||||
|
||||
为什么会做此种取舍?当然,我们不能用 CAP 来进行考察,毕竟我们通常认为**单机系统内的通信**是稳定可靠的,并且某个 CPU 如果和系统其他部分断开连接也**不可能独自工作**。此处牺牲线性一致性的真正原因在于——**性能**,而不是容错。当然,单机系统都会提供一些同步手段(比如锁),来强制同步相应变量到主存。从而允许用户在关心一致性超过性能的地方,自行进行取舍。
|
||||
|
||||
@ -314,9 +291,9 @@ CAP 最初被提出只是一个为了激发数据库取舍讨论的模糊的取
|
||||
|
||||
- 在[一致前缀读](https://ddia.qtmuniao.com/#/ch05?id=%e4%b8%80%e8%87%b4%e5%89%8d%e7%bc%80%e8%af%bb)中我们提到一个先看到答案、后看到问题的例子。这种现象看起来很奇怪,是因为它违反了我们关于因果顺序的直觉:问题应该先于答案出现。因为只有看到了问题,才可能针对其给出答案(假设这不是超自然现象,并且不能预言将来)。对于这种情况,我们说在问题和答案之间存在着**因果依赖**(causal dependency)。
|
||||
- 在第五章[图 5-9](https://ddia.qtmuniao.com/#/ch05?id=%e5%a4%9a%e4%b8%bb%e5%a4%8d%e5%88%b6%e6%8b%93%e6%89%91) 中有类似的情况,在有三个主的情况下,由于网络延迟,一些本应该先到的写入操作却居于后面。从某个副本的角度观察,就感觉像在更新一个不存在的数据。**因果**在此处意味着,某一行数据*只有先被创建才能够被更新*。
|
||||
- 在[并发写入检测](https://ddia.qtmuniao.com/#/ch05?id=%e5%b9%b6%e5%8f%91%e5%86%99%e5%85%a5%e6%a3%80%e6%b5%8b)一节我们知到,对于两个操作 A 和 B,有三种可能性:A 发生于 B 之前,B 发生于 A 之前,A 和 B 是并发的。这种**发生于之前**(happened before)是因果性的另一种表现:如果 A 发生在 B 之前,则 B 有可能知道 A ,进而基于 A 构建,或者说依赖于 A。如果 A 和 B 是并发的,则他们之间没有因果联系,也即,我们可以断定他们互不知道。
|
||||
- 在[并发写入检测](https://ddia.qtmuniao.com/#/ch05?id=%e5%b9%b6%e5%8f%91%e5%86%99%e5%85%a5%e6%a3%80%e6%b5%8b)一节我们知到,对于两个操作 A 和 B,有三种可能性:A 发生于 B 之前,B 发生于 A 之前,A 和 B 是并发的。这种**发生于之前**(happened before)是因果性的另一种表现:如果 A 发生在 B 之前,则 B 有可能知道 A,进而基于 A 构建,或者说依赖于 A。如果 A 和 B 是并发的,则他们之间没有因果联系,也即,我们可以断定他们互不知道。
|
||||
- 在事务的快照隔离级别下(参见[快照隔离和重复读](https://ddia.qtmuniao.com/#/ch07?id=%e5%bf%ab%e7%85%a7%e9%9a%94%e7%a6%bb%e5%92%8c%e9%87%8d%e5%a4%8d%e8%af%bb)),所有的读取都会发生在某个**一致性**的快照上。这里的一致性是什么意思呢?是**因果一致性**(consistent with causality)。如果一个快照包含某个问题答案,它一定包含该问题本身。假设我们以上帝视角,在某个**时间点**(意味着瞬时观察完)观察整个数据库可以让得到的快照满足因果一致性:所有在该时间点之前操作结果都可见,在该时间点之后的操作结果都不可见。**读偏序**(Read skew,即图 7-6 中提到的不可重复读),即意味读到了违反因果关系的状态。
|
||||
- 之前提到的事务间的写偏序的例子(参见[写偏序和幻读](https://ddia.qtmuniao.com/#/ch07?id=%e5%86%99%e5%81%8f%e5%ba%8f%e5%92%8c%e5%b9%bb%e8%af%bb))本质上也是因果依赖:在图 7-8 中,系统允许 Alice 请假,是因为事务看到的 Bob 的状态是仍然再岗;当然,对于 Bob 也同样。在这个例子中,一个医生是否允许在值班时请假,依赖于当时是否仍有其他医生值班。在**可串行的快照隔离级别**(SSI,参见[可串行的快照隔离](https://ddia.qtmuniao.com/#/ch07?id=%e5%8f%af%e4%b8%b2%e8%a1%8c%e7%9a%84%e5%bf%ab%e7%85%a7%e9%9a%94%e7%a6%bb))下,我们通过追踪事务间的因果依赖(即读写数据集依赖)来检测写偏序。
|
||||
- 之前提到的事务间的写偏序的例子(参见[写偏序和幻读](https://ddia.qtmuniao.com/#/ch07?id=%e5%86%99%e5%81%8f%e5%ba%8f%e5%92%8c%e5%b9%bb%e8%af%bb))本质上也是因果依赖:在图 7-8 中,系统允许 Alice 请假,是因为事务看到的 Bob 的状态是仍然再岗;当然,对于 Bob 也同样。在这个例子中,一个医生是否允许在值班时请假,依赖于当时是否仍有其他医生值班。在**可串行的快照隔离级别**(SSI,参见[可串行的快照隔离](https://ddia.qtmuniao.com/#/ch07?id=%e5%8f%af%e4%b8%b2%e8%a1%8c%e7%9a%84%e5%bf%ab%e7%85%a7%e9%9a%94%e7%a6%bb)) 下,我们通过追踪事务间的因果依赖(即读写数据集依赖)来检测写偏序。
|
||||
- 在 Alice 和 Bob 看足球比赛的例子中,Bob 在 Alice 表示结果已经出来之后,仍然没有看到网页结果,便是违反了因果关系:Alice 的说法基于比赛结果已经出来的事实,因此 Bob 在听到 Alice 的陈述之后,应该当能看到比赛结果。图片尺寸调整的例子也是类似。
|
||||
|
||||
因果将顺序施加于**事件**(event):
|
||||
@ -327,7 +304,7 @@ CAP 最初被提出只是一个为了激发数据库取舍讨论的模糊的取
|
||||
|
||||
这和现实生活一样,一件事的发生会引起另一件的事的出现:一个节点读取数据之后,**依据**读取内容,(依赖于读取)写入了一些数据;另一个节点读取这些写入,进而写入了另外一些数据,循环往复。操作(operation)发生的**依赖链条**定义了系统中事件的因果——也即,**谁居先,谁处后**。
|
||||
|
||||
如果一个系统遵循因果约束,则我们称其为**因果一致的**(*causally consistent*)。比如,快照隔离就可以提供因果一致性:当从数据库读取数据的时候,如果你能读到某个时间点的数据,就一定能读到其之前的数据(当然,要在该数据还没有被删除的情况下)。
|
||||
如果一个系统遵循因果约束,则我们称其为**因果一致的**(_causally consistent_)。比如,快照隔离就可以提供因果一致性:当从数据库读取数据的时候,如果你能读到某个时间点的数据,就一定能读到其之前的数据(当然,要在该数据还没有被删除的情况下)。
|
||||
|
||||
### 因果序非全序
|
||||
|
||||
@ -340,33 +317,29 @@ CAP 最初被提出只是一个为了激发数据库取舍讨论的模糊的取
|
||||
|
||||
根据上述解释,在线性一致性的数据存储服务中,是不存在并发操作的:**因为必然存在一个时间线能将所有操作进行排序**。同一时刻可能会有多个请求到来,但是线性化的存储服务可以保证:**所有请求都会在单个副本上、一个单向向前的时间线上的某个时间点被原子的处理,而没有任何并发**。
|
||||
|
||||
并发(concurrency)意味着时间线的分叉与合并。但在重新合并之时,来自两个时间分支的操作就有可能出现不可比的情况。在第五章的图 5-14 (参见[确定 Happens-Before 关系](https://ddia.qtmuniao.com/#/ch05?id=%e7%a1%ae%e5%ae%9a-happens-before-%e5%85%b3%e7%b3%bb))中我们见过类似的现象,所有的事件不在一条时间线上,而是有相当复杂的图形依赖。图中的每个箭头,本质上定义了一种因果依赖,也即偏序关系。
|
||||
并发(concurrency)意味着时间线的分叉与合并。但在重新合并之时,来自两个时间分支的操作就有可能出现不可比的情况。在第五章的图 5-14(参见[确定 Happens-Before 关系](https://ddia.qtmuniao.com/#/ch05?id=%e7%a1%ae%e5%ae%9a-happens-before-%e5%85%b3%e7%b3%bb))中我们见过类似的现象,所有的事件不在一条时间线上,而是有相当复杂的图形依赖。图中的每个箭头,本质上定义了一种因果依赖,也即偏序关系。
|
||||
|
||||
> 理解全序和偏序、线性一致性和因果一致性的一个关键模型是**有向图**。在该图中,点代表事件,有向边代表因果关系,并且从因事件指向果事件,很自然的,因果性满足**传递性**。**如果该图中有一条单一的路径能串起所有点,且不存在环**,则该系统是线性一致的。可以看出,因果关系是一种局部特性(也即偏序关系),定义在两个点之间(如果两个点之间存在着一条单向途径,则这两点有因果关系);而线性关系是一种全局特性(也即全序关系),定义在整个图上。
|
||||
>
|
||||
|
||||
如果你熟悉一些分布式的版本管理工具,如 Git,你就会发现相似的特性。在 Git 中,版本间的依赖就类似于一个因果依赖图。大部分时候,所有人的提交(commit)是前后相继的,组成一个直线;但有时,如果团队中的几个人同时(并发的)为一个项目工作,版本历史就会产生分叉,并且在提交的时候重又合并。
|
||||
|
||||
> 在 Spark 的多个 RDD 之间,也有类似的感觉。如果所有算子都是单输入算子,则执行图会是一条线,即全序没有并发;如果有的算子有多个输入,则不同输入之间可以并发,此时为偏序关系。
|
||||
>
|
||||
|
||||
### 线性一致性强于因果一致性
|
||||
|
||||
那么因果顺序和线性一致性的关系是什么?答曰:线性一致性是因果一致性的**充分(implies)条件**。所有提供线性一致性的系统都能够能够保证因果性。尤其对于有多个通信通道的系统来说,我们不需要做任何额外努力(比如在多个组件间传递时间戳,以建立因果关系),线性一致性就能够保证系统中发生的所有事件满足因果性。
|
||||
|
||||
> 用我们之前的图模型来说,就是**不存在环**。即,**因果一致性**(有向无环图) ⇒ **线性一致性**(在有向无环图的基础上,**存在一条能串起所有点的单向路径**)。
|
||||
>
|
||||
|
||||
线性一致性能能够保证因果关系,该特点让系统易于使用,从而对应用层很有吸引力。但任何事情都是有代价的,如我们在之前线性一致性的代价一节中所讨论的:**提供线性一致性非常伤性能和可用性,在网络有显著延迟时**(如全球部署的系统)**,该副作用尤其明显**。因此,很多系统会舍弃线性一致性以换取更好的性能,但当然,代价是更难用了。
|
||||
|
||||
好消息是存在折中路线。线性一致性并非保持因果关系的唯一途径,还有很多其他办法。也即,一个系统可以不必承担线性一致性所带来的性能损耗,而仍然是**因果一致的**( consistent)。当然,在这种情况下,CAP 定理是不适用的。事实上,**因果一致性**是系统在保证**有网络延迟而不降低性能、在有网络故障而仍然可用**的情况下,能够提供的最强一致性模型。
|
||||
好消息是存在折中路线。线性一致性并非保持因果关系的唯一途径,还有很多其他办法。也即,一个系统可以不必承担线性一致性所带来的性能损耗,而仍然是**因果一致的**(consistent)。当然,在这种情况下,CAP 定理是不适用的。事实上,**因果一致性**是系统在保证**有网络延迟而不降低性能、在有网络故障而仍然可用**的情况下,能够提供的最强一致性模型。
|
||||
|
||||
在大多数情况下,我们以为我们需要线性一致模型,其实我们真实需要因果一致模型,而后者允许我们实现性能更好的系统。基于这个观察,研究人员在探寻新型数据库的设计,让系统既可以提供因果关系保证,也可以提供(堪比最终一致性系统的)高性能和高可用性。
|
||||
|
||||
这些研究都比较新,还存在很多挑战,也没有进行落地。但无疑,是分布式系统在将来一个很有前景发展方向。
|
||||
|
||||
> 真实系统中,在所有的事件集中,只有部分事件是有因果依赖的,这些事件需要在执行时保证因果顺序执行;而其他的大部分事件是没有因果依赖的,因此可以乱序、按需执行以保证性能。但这件事情的难点在于,因果关系是**应用层定义的**。而我们在系统层,就很难识别。可能需要提供某种接口,可以让应用层显示指定因果,但一来不确定这种接口是否能做的足够宽泛;二来,这种因果追踪的额外代价是非常大的。
|
||||
>
|
||||
|
||||
### 捕获因果依赖
|
||||
|
||||
@ -376,7 +349,7 @@ CAP 最初被提出只是一个为了激发数据库取舍讨论的模糊的取
|
||||
|
||||
为了确定因果依赖,我们需要某种手段来描述系统中节点的“**知识**”(knowledge)。如果某个节点在收到 Y 的写入请求时已经看到了值 X,则 X 和 Y 间可能会存在着因果关系。就如在调查公司的欺诈案时,CEO 常被问到,“你在做出 Y 决定时知道 X 吗”?
|
||||
|
||||
确定哪些操作先于哪些些操作发生的方法类似于我们在 “[并发写入检测](https://ddia.qtmuniao.com/#/ch05?id=%e5%b9%b6%e5%8f%91%e5%86%99%e5%85%a5%e6%a3%80%e6%b5%8b)”一节讨论的技术。那一节针对无主模型讨论了如何检测针对单个 Key 的并发写入,以防止更新丢失问题。因果一致性所需更多:**需要在整个数据库范围内追踪所有 Key 间操作的因果依赖,而非仅仅单个 Key 上**。**版本向量**(version vectors)常用于此道。
|
||||
确定哪些操作先于哪些些操作发生的方法类似于我们在“[并发写入检测](https://ddia.qtmuniao.com/#/ch05?id=%e5%b9%b6%e5%8f%91%e5%86%99%e5%85%a5%e6%a3%80%e6%b5%8b)”一节讨论的技术。那一节针对无主模型讨论了如何检测针对单个 Key 的并发写入,以防止更新丢失问题。因果一致性所需更多:**需要在整个数据库范围内追踪所有 Key 间操作的因果依赖,而非仅仅单个 Key 上**。**版本向量**(version vectors)常用于此道。
|
||||
|
||||
为了解决确定因果顺序,数据库需要知道应用读取数据的**版本信息**。这也是为什么在图 5-13 中(参见 [确定 Happens-Before 关系](https://ddia.qtmuniao.com/#/ch05?id=%e7%a1%ae%e5%ae%9a-happens-before-%e5%85%b3%e7%b3%bb)),我们在写入数据时需要知道先前读取操作中数据库返回的版本号。在 SSI 的冲突检测(参见[可串行的快照隔离](https://ddia.qtmuniao.com/#/ch07?id=%e5%8f%af%e4%b8%b2%e8%a1%8c%e7%9a%84%e5%bf%ab%e7%85%a7%e9%9a%94%e7%a6%bb))中也有类似的思想:当一个事务提交时,数据库需要检查其读取集合中的数据版本是否仍然是最新的。为此,数据库需要跟踪一个事务读取了哪些数据的哪些版本。
|
||||
|
||||
@ -391,7 +364,6 @@ CAP 最初被提出只是一个为了激发数据库取舍讨论的模糊的取
|
||||
进一步,我们可以保证我们产生序列号的方式满足因果关系:**如果操作 A 发生在 B 之前,则 A 获取到的序列号比 B 小**。并发的(无法比较谁先谁后)操作获取到的序列号顺序不确定。序列号本质上是一种全序,通过这种方式可以追踪因果关系,但也施加了一个比因果关系更为严格的全序。
|
||||
|
||||
> 联想我们之前用以理解的有向图,相当于在满足原来有向边(因果关系)的基础上,增加了一些有向边,串出了**一条**能串起所有点(操作)的路径。
|
||||
>
|
||||
|
||||
在使用单主模型的多副本系统中,主节点上**操作日志的追加顺序**确定了一个对所有操作的全序,且满足操作发生的因果关系。主节点可以为每条日志按顺序关联一个全局递增的序列号,如果从节点上也按都按此序列号顺序应用操作日志到状态机,则每个副本总能保持一致的状态(但有可能稍落后于主节点)。
|
||||
|
||||
@ -429,7 +401,6 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于
|
||||
如上图,客户端 A 在收到节点 2 的 counter = 5 的回复后,会使用该值向节点 1 发送请求。节点 1 本来的 counter 值是 1,在收到该请求后,会立即前移为 5。尔后,下一个请求操作到来会将其增加为 6。
|
||||
|
||||
> 系统中所有的事件(event),和交互方(client,server)都要被纳入 Lamport Clock 体系内,才能追踪系统内的所有因果关系。
|
||||
>
|
||||
|
||||
只要最大的 counter 值通过每个操作被传播,就能保证 Lamport 时间戳满足因果一致。因为每次因果依赖的交互都会推高时间戳。
|
||||
|
||||
@ -456,7 +427,7 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于
|
||||
|
||||
小结一下,在分布式系统中,为了实现类似于针对用户名的**唯一性约束**,仅为所有时间进行全局定序是不够的,你还需要知道该定序何时完成。对于某个创建账户的操作,如果我们能够确定在最终的全序里,不会有其他操作插到该操作之前,我们便可以安全的让该操作成功。
|
||||
|
||||
确定全局定序何时收敛,将会在接下来的小节 —— **全序广播**(*total order broadcast*)中讨论。
|
||||
确定全局定序何时收敛,将会在接下来的小节 —— **全序广播**(_total order broadcast_)中讨论。
|
||||
|
||||
## 全序广播
|
||||
|
||||
@ -470,7 +441,6 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于
|
||||
在分布式系统的语境下,该问题也被称为**全序广播**(total order broadcast)或者**原子广播**(atomic broadcast)。
|
||||
|
||||
> **顺序保证的范围。** 多分区的数据库,对于每个分区使用单主模型,能够维持每个分区的操作全局有序,但并不能提供跨分区的一致性保证(比如一致性快照,外键约束)。当然,跨分区的全序保证也是可以提供的,只不过需要进行额外的协调。
|
||||
>
|
||||
|
||||
全序广播是一种多个节点间交换消息的协议。它要求系统满足两个安全性质:
|
||||
|
||||
@ -495,7 +465,7 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于
|
||||
|
||||
### 使用全序广播实现线性一致性存储
|
||||
|
||||
如图 9-4 ,在线性一致系统中,所有操作存在着一个全局序列。这是否意味着全序广播就是线性一致性?不尽然,但他们间有很深的联系。
|
||||
如图 9-4,在线性一致系统中,所有操作存在着一个全局序列。这是否意味着全序广播就是线性一致性?不尽然,但他们间有很深的联系。
|
||||
|
||||
全序广播是**异步的**:系统保证以同样的**顺序**交付消息,但并不保证消息的交付**时刻**(即,有的消息接收者间可能存在着滞后)。与之相对,线性一致性是一种**新鲜度保证**:读取一定能看到最新成功的写。
|
||||
|
||||
@ -510,15 +480,14 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于
|
||||
3. 检查所有想要使用该用户名的消息,这时你可能会得到多条消息,如果你当初写下的消息在第一条,则你是成功的。此时,你可以“确认”(持久化,比如追加日志,比如写入数据库)占有该用户名的信息,然后给客户端返回成功。如果第一条消息不是你的,则终止请求。
|
||||
|
||||
> 这里其实隐藏了一些细节,即我们会将追加消息请求发送给全序广播系统,全序广播系统会真正将消息按之前提到的两条保证的方式(可靠送达和全序送达)同步到每个节点。因此,对于每个节点来说,会首先发起消息追加请求,然后之后某个时刻,可以等到真正同步回来的消息。如果觉得绕,可以带入 Raft 的付之日志来类比。
|
||||
>
|
||||
|
||||
由于所有日志条目都会以同样的顺序送达每个节点,若有并发写入,则所有节点都能依靠日志顺序就谁“先来后到”达成一致。当有同名冲突时,可以选择第一条作为赢家,并舍弃其后的冲突请求。可以使用类似的方式,基于日志来实现涉及到多对象的事务的可串行化。
|
||||
|
||||
尽管该方式能够提供线性化的写入,却不能保证线性化的读取。如果你从一个异步同步日志的节点读取日志,就有可能读到陈旧的数据(更精确一点说,上述过程能够提供**顺序一致性**,s*equential consistency*,有时也被称为**时间线一致性**,*timeline consistency*,比线性一致性稍弱 )。在此基础上,如果想让读取也变得可线性化,有几种做法:
|
||||
尽管该方式能够提供线性化的写入,却不能保证线性化的读取。如果你从一个异步同步日志的节点读取日志,就有可能读到陈旧的数据(更精确一点说,上述过程能够提供**顺序一致性**,s*equential consistency*,有时也被称为**时间线一致性**,_timeline consistency_,比线性一致性稍弱)。在此基础上,如果想让读取也变得可线性化,有几种做法:
|
||||
|
||||
- 让读取也走日志,即通过追加消息的方式将读取顺序化,然后当读取请求所在节点**收到**这条读取日志时才去真正的去读。则消息在日志中的位置定义了整个时间序列中读取真正发生的时间点。(etcd 中的法定读取就是用的类似的做法)
|
||||
- 如果日志服务允许查询最新日志的位置,则可以在请求到来时,获取当时最新位置,然后不断查询日志看是否已经跟到最新位置。如果跟到了,就进行读取。(这是 Zookeeper 中 sync() 操作的背后的原理)
|
||||
- 可以将读取路由到写入发生的那个节点,或者与写入严格同步的节点,以保证能够读到最新的内容。(这种技术用于**链式复制**,*chain replication* 中)
|
||||
- 可以将读取路由到写入发生的那个节点,或者与写入严格同步的节点,以保证能够读到最新的内容。(这种技术用于**链式复制**,_chain replication_ 中)
|
||||
|
||||
### 使用线性一致存储实现全序广播
|
||||
|
||||
@ -529,9 +498,7 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于
|
||||
算法相当简单:对于每一个发给全序广播系统的消息,使用整数寄存器 increment-and-get 操作关联一个序列号;然后将消息发送给所有节点(重试任何丢失的消息)。每个节点接收到消息后利用序列号顺序对外交付消息。这种机制很像 TCP,但并不是描述通信双方,而是一个分布式系统。
|
||||
|
||||
- 如何判断消息是否丢失?
|
||||
|
||||
ACK或者是否遇到异常。
|
||||
|
||||
ACK 或者是否遇到异常。
|
||||
|
||||
注意到,和 Lamport 时间戳不同,从线性化的寄存器中获取的数字是**连续的,非跳跃的**。如此一来,当某节点交付了消息 4 后,收到了消息 6,但不能立即交付,而需要等待消息 5 的到来。但在 Lamport 时间戳系统中则非如此——这(是否连续)**也是全序广播和时间戳顺序的核心不同**。
|
||||
|
||||
@ -550,22 +517,15 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于
|
||||
在很多场景下让多个节点达成共识是非常重要的。比如:
|
||||
|
||||
- **Leader 选举**
|
||||
|
||||
在使用单主模型的数据库中,所有节点需要对谁是主节点达成一致。当网络问题导致有些节点不能正常通信时,领导权就会出现争议。在这种情形下,共识对于避免错误的故障转移非常重要。引入如果出现两个领导者可以同时接受写入(**脑裂**),所有副本上的数据就会产生分叉,从而变得不一致甚而数据丢失。
|
||||
|
||||
在使用单主模型的数据库中,所有节点需要对谁是主节点达成一致。当网络问题导致有些节点不能正常通信时,领导权就会出现争议。在这种情形下,共识对于避免错误的故障转移非常重要。引入如果出现两个领导者可以同时接受写入(**脑裂**),所有副本上的数据就会产生分叉,从而变得不一致甚而数据丢失。
|
||||
- **原子提交**
|
||||
|
||||
在一个横跨多节点或具有多分区的数据库中,可能会出现某个事务在一些节点执行成功,但在另外一些节点却运行失败。如果我们想保持事务的原子性(ACID 中的 A,参见[原子性](https://ddia.qtmuniao.com/#/ch07?id=%e5%8e%9f%e5%ad%90%e6%80%a7%ef%bc%88atomicity%ef%bc%89)),我们就必须让所有节点就事务的结果达成一致:要么全部回滚(只要有故障),要么提交(没有任何故障)。这个共识的特例也被称为**原子提交**(atomic commit)。
|
||||
|
||||
在一个横跨多节点或具有多分区的数据库中,可能会出现某个事务在一些节点执行成功,但在另外一些节点却运行失败。如果我们想保持事务的原子性(ACID 中的 A,参见[原子性](https://ddia.qtmuniao.com/#/ch07?id=%e5%8e%9f%e5%ad%90%e6%80%a7%ef%bc%88atomicity%ef%bc%89)),我们就必须让所有节点就事务的结果达成一致:要么全部回滚(只要有故障),要么提交(没有任何故障)。这个共识的特例也被称为**原子提交**(atomic commit)。
|
||||
|
||||
> **共识的不可能性**。你也许听过 FLP —— 以 Fischer,Lynch 和 Paterson 三位作者姓名首字母命名的一种不可能原理——在网络可靠,但允许节点宕机(即便只有一个)的**异步模型**系统中,不存在总是能够达成共识的算法。在分布式系统中,我们又必须得假设节点可能会宕机,因此稳定可靠的共识算法是不存在的。但是,我们现在却在探讨可以达成共识的算法。这又是为啥?这可能吗?
|
||||
>
|
||||
|
||||
> 答案是,FLP 不可能是基于异步系统模型(参见[系统模型和现实](https://ddia.qtmuniao.com/#/ch08?id=%e7%b3%bb%e7%bb%9f%e6%a8%a1%e5%9e%8b%e5%92%8c%e7%8e%b0%e5%ae%9e))证明的,这是一种非常苛刻的模型,不能够使用任何时钟系统和超时检测。如果允许使用**超时宕机检测**、或者任何可以**识别节点宕机**的方法,就能够实现可靠的共识算法。甚而,只让算法用随机数来进行故障检测,也能够绕过这个不可能定理。
|
||||
>
|
||||
|
||||
> 因此,尽管在理论上,FLP 定理非常重要,断言异步网络中共识不可能达到;但在实践中,分布式系统达成共识是可行的。
|
||||
>
|
||||
|
||||
在本小节,我们首先会详细探讨**原子提交**。特别的,我们将会讨论**两阶段提交**(2PC,two-phase commit)算法,这是一种解决原子提交的最为常见的算法,很多数据库和服务端应用都实现了该算法。可以看出,2PC 在某种程度上是一种共识协议——虽然不是很完美。
|
||||
|
||||
@ -586,7 +546,7 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于
|
||||
|
||||
因此,在单机数据库里,事务是否提交主要取决于数据持久化到磁盘的顺序:**首先是数据,接着是提交记录**。提交事务还是中止事务,决定性时刻在于**提交记录成功刷盘**的那一瞬间:在此之前,事务可能会被中止(由于宕机);在此之后,该事务一定会被提交(即使宕机)。也即,是唯一的硬件设备(某个特定节点上的某个具体的磁盘驱动)保证了提交的原子性。
|
||||
|
||||
然而,当事务涉及到多个节点时又当如何?例如,一个跨分区的多对象事务,或者基于关键词分区的二级索引(在该情况下,索引数据和基础数据可能不在一个分区里,参见[分片和次级索引](https://ddia.qtmuniao.com/#/ch06?id=%e5%88%86%e7%89%87%e5%92%8c%e6%ac%a1%e7%ba%a7%e7%b4%a2%e5%bc%95))。大多数 “NoSQL” 分布式存储不支持这种跨节点的分布式事务,但很多分布式关系型数据库则支持。
|
||||
然而,当事务涉及到多个节点时又当如何?例如,一个跨分区的多对象事务,或者基于关键词分区的二级索引(在该情况下,索引数据和基础数据可能不在一个分区里,参见[分片和次级索引](https://ddia.qtmuniao.com/#/ch06?id=%e5%88%86%e7%89%87%e5%92%8c%e6%ac%a1%e7%ba%a7%e7%b4%a2%e5%bc%95))。大多数“NoSQL”分布式存储不支持这种跨节点的分布式事务,但很多分布式关系型数据库则支持。
|
||||
|
||||
在上述场景中,只是简单地在提交事务时给每个节点发送提交请求让其提交事务,是不能够满足事务基本要求的。这是因为,可能有的节点成功提交了,有的节点却提交失败了,从而违反了原子性保证:
|
||||
|
||||
@ -598,7 +558,7 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于
|
||||
|
||||
**事务提交后是不可撤销的**——在事务提交后,你不能再改变主意说,我要重新中止这个事务。这是因为,一旦事务提交了,就会对其他事务可见,从而可能让其他事务依赖于该事务的结果做出一些新的决策;这个原则构成了**读已提交**(read commited)隔离级别的基础(参见[读已提交](https://ddia.qtmuniao.com/#/ch07?id=%e8%af%bb%e5%b7%b2%e6%8f%90%e4%ba%a4))。如果事务允许在提交后中止,其他已经读取了该事务结果的事务也会失效,从而引起事务的级联中止。
|
||||
|
||||
当然,事务所造成的**结果**在事实上是可以被撤销的,比如,通过**补偿事务**(*compensating transaction*)。但,从数据库的视角来看,这就是另外一个事务了;而跨事务的正确性,需要应用层自己来保证。
|
||||
当然,事务所造成的**结果**在事实上是可以被撤销的,比如,通过**补偿事务**(_compensating transaction_)。但,从数据库的视角来看,这就是另外一个事务了;而跨事务的正确性,需要应用层自己来保证。
|
||||
|
||||
### 两阶段提交简介
|
||||
|
||||
@ -608,15 +568,14 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于
|
||||
|
||||
![Untitled](img/ch09-fig09.png)
|
||||
|
||||
> **不要混淆 2PC 和 2PL**。Two-phase *commit* (2PC) 和 two-phase locking (2PL,参见[两阶段锁](https://ddia.qtmuniao.com/#/ch07?id=%e4%b8%a4%e9%98%b6%e6%ae%b5%e9%94%81)) 是两个完全不同的概念。2PC 是为了在分布式系统中进行原子提交,而 2PL 是为了进行事务并发控制的一种加锁方式。为了避免歧义,可以忽略他们在名字简写上的相似性,而把它们当成完全不同的概念。
|
||||
>
|
||||
> **不要混淆 2PC 和 2PL**。Two-phase _commit_ (2PC) 和 two-phase locking (2PL,参见[两阶段锁](https://ddia.qtmuniao.com/#/ch07?id=%e4%b8%a4%e9%98%b6%e6%ae%b5%e9%94%81)) 是两个完全不同的概念。2PC 是为了在分布式系统中进行原子提交,而 2PL 是为了进行事务并发控制的一种加锁方式。为了避免歧义,可以忽略他们在名字简写上的相似性,而把它们当成完全不同的概念。
|
||||
|
||||
2PC 引入了一个单机事务中没有的角色:**协调者**(coordinator,有时也被称为事务管理器,transaction manager)。协调者通常以库的形式出现,并会嵌入到请求事务的应用进程中,但当然,它也可以以单独进程或者服务的形式出现。比如说,Narayana, JOTM, BTM, or MSDTC.
|
||||
|
||||
和单机事务一样,2PC 事务通常也由应用层对多个节点上的数据读写开始。和协调者相对,我们将这些数据节点称为事务的**参与者**(participants)。当应用层准备好提交后,协调者开始阶段一:向每个参与者发送 **prepare** 请求,询问他们是否能够提交。然后,协调者会根据参与者的返回而进行下一步动作:
|
||||
|
||||
1. 如果**所有参与者**都回复“可以”(yes),表示能够提交,则协调者就会进入第二阶段发出**提交**( **commit** )请求,此时,提交事实上才开始执行。
|
||||
2. 如果有任何参与者回复“不行”(no),或者请求超时了,协调者就会进入第二阶段并发送一个 **中止**(abort) 请求,中止事务。
|
||||
2. 如果有任何参与者回复“不行”(no),或者请求超时了,协调者就会进入第二阶段并发送一个 **中止**(abort)请求,中止事务。
|
||||
|
||||
这个过程在某种程度上很像西方文化中的**结婚仪式**:牧师会分别问新娘、新郎是否愿意与对方结婚,通常,双方都会回答“我愿意”(I do)。当牧师收到双方肯定的回答后,就会宣布他们结为夫妇:即事务提交,并将这个令人高兴的事实传达给所有宾客。如果新娘、新郎有任何一方回答否,则仪式中止。
|
||||
|
||||
@ -628,7 +587,7 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于
|
||||
|
||||
1. 当应用想开启一个分布式事务时,它会首先向协调者要一个**事务 ID**。该事务 ID 是全局唯一的。
|
||||
2. 应用会使用前述事务 ID 向所有的参与者发起一个单机事务,所有节点会各自完成读写请求,在此过程中,如果有任何出错(比如节点宕机或者请求超时),协调者或者任意参与者都可以中止事务。
|
||||
3. 当应用层准备好提交事务时,协调者会向所有参与者发送**准备提交**( prepare) 请求,并在请求中打上事务 ID 标记。如果有请求失败或者超时,则协调者会对所有参与者发送带有该事务 ID 的中止请求。
|
||||
3. 当应用层准备好提交事务时,协调者会向所有参与者发送**准备提交**(prepare)请求,并在请求中打上事务 ID 标记。如果有请求失败或者超时,则协调者会对所有参与者发送带有该事务 ID 的中止请求。
|
||||
4. 当参与者收到**准备提交**请求时,它必须确认该事务能够在任何情况下都能被提交,才能回复“**可以**”。这包括,将所有写入刷到磁盘(一旦承诺了,就不能反悔,即使之后遇到宕机、断电或者磁盘空间不足)、检查是否有冲突或者违反约束的情况。换句话说,如果回复“可以”,意味着参与者**让渡了中止事务的权利(给协调者)**,但此时并没有真正地提交。
|
||||
5. 当协调者收到所有参与者准备提交的回复后,会决定提交还是中止该事务(只有在所有参与者都回复“可以”时,才会提交)。协调者需要将该决策写入事务日志,并下刷到磁盘,以保证即使宕机重启,该决策也不会丢失。这被称为**提交点**(commit point)。
|
||||
6. 协调者将决策刷入了磁盘后,就会将决策(提交或者中止)请求发给所有参与方。如果某个请求失败或者超时,则协调者会对其进行无限重试,直到成功。不允许走回头路:如果协调者决定了提交,则不管要进行多少次的重试,也必须要保证该决策的执行。如果参与者在此时宕机了,则当重启时也必须进行提交——因为它**承诺过要提交**,因此在重启后不能拒绝提交。
|
||||
@ -651,7 +610,7 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于
|
||||
|
||||
然而,我们还没有讨论,当**协调者故障**(coordinator failure)时,系统应当如何应对。
|
||||
|
||||
如果协调者在准备提交请求发送前故障,则参与者可以放心的中止事务。然而,一旦参与者收到准备提交请求,并且回复 “可以”,则根据 2PC 设定,它**不能单方面的中止事务**——而必须等待协调者的提交或者中止请求。如果此时协调者宕机或者网络故障,则参与者只能**死等**。参与者事务的这种状态称为**存疑**(in doubt)或者**未定**(uncertain)。
|
||||
如果协调者在准备提交请求发送前故障,则参与者可以放心的中止事务。然而,一旦参与者收到准备提交请求,并且回复“可以”,则根据 2PC 设定,它**不能单方面的中止事务**——而必须等待协调者的提交或者中止请求。如果此时协调者宕机或者网络故障,则参与者只能**死等**。参与者事务的这种状态称为**存疑**(in doubt)或者**未定**(uncertain)。
|
||||
|
||||
图 9-10 就是一个这样的例子。在该例子中,系统处于第二阶段,协调者准备提交,并且数据库实例 2 收到了提交请求。此时,协调者宕机,还没来得及给数据库实例 1 发送提交请求,因此该实例不知道是要提交还是中止事务。超时机制在这里并不能解决问题:超时后,如果数据库实例 1 单方面决定中止事务,则会和数据库实例 2 处于不一致的状态。类似的,单方面提交事务也不靠谱,毕竟另外的参与者也可能收到请求并中止了事务。
|
||||
|
||||
@ -681,13 +640,9 @@ Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于
|
||||
相较于完全弃之不用,我们应当更加细致的考量分布式事务,因为可以从其中学到相当多的经验教训。首先,我们需要精确地定义什么是“分布式事务”。有两种完全不同的分布式事务经常被混淆:
|
||||
|
||||
- **数据库内部分布式事务**
|
||||
|
||||
在一些分布式数据中(标配支持多分区和多副本的数据库),支持跨节点的**内部分布式事务**。如,VoltDB 和 MySQL 集群的 NDB 存储引擎就有这样的内部事务支持。在这种情况下,所有事务参与节点都运行着同样的二进制代码。
|
||||
|
||||
在一些分布式数据中(标配支持多分区和多副本的数据库),支持跨节点的**内部分布式事务**。如,VoltDB 和 MySQL 集群的 NDB 存储引擎就有这样的内部事务支持。在这种情况下,所有事务参与节点都运行着同样的二进制代码。
|
||||
- **异构的分布式事务**
|
||||
|
||||
在异构的分布式事务中,所有参与者使用了两种以上的技术栈:如,来自不同厂家的两种数据库实例,甚至可能包含非数据库系统,如消息队列。即使每个子系统内部实现完全不同,构建于其上的分布式事务也能够保证原子提交。
|
||||
|
||||
在异构的分布式事务中,所有参与者使用了两种以上的技术栈:如,来自不同厂家的两种数据库实例,甚至可能包含非数据库系统,如消息队列。即使每个子系统内部实现完全不同,构建于其上的分布式事务也能够保证原子提交。
|
||||
|
||||
数据库内部的事务不需要考虑和其他系统的相容性,因此在实现时可以使用任何协议、可以针对特定技术栈进行任何优化。因此,数据库内部的分布式事务通常能够很好地工作。相反,横跨多个异构系统的事务实现则充满了挑战。
|
||||
|
||||
@ -707,8 +662,8 @@ X/Open XA (e**X**tended **A**rchitecture 的简写)是在异构系统间实
|
||||
|
||||
XA 不是一个网络协议——它定义了一组和事务协调者交互的 C 语言 API 接口。当然,该 API 也有其他语言实现。比如,在 Java EE 应用,XA 事务使用 Java 事务 API(JTA)实现,进而被很多支持 JDBC 的数据库使用,也被 Java Message Service(JMS)的消息队列所支持。
|
||||
|
||||
> Open Group 组织针对 XA 定义了分布式事务处理模型,也被称为DTP模型。包括三个组件,
|
||||
>
|
||||
> Open Group 组织针对 XA 定义了分布式事务处理模型,也被称为 DTP 模型。包括三个组件,
|
||||
>
|
||||
> - AP(Application Program):应用程序,通过定义组成事务的特定操作来定义事务边界。
|
||||
> - RM(Resouces Manager):资源管理器,管理共享资源的服务,对应两阶段提交协议中的参与者,如数据库或消息队列服务。
|
||||
> - TM(Transaction Manager):事务管理器,管理全局事务,协调事务的提交或者回滚,并协调故障恢复。
|
||||
@ -737,7 +692,7 @@ XA 不是一个网络协议——它定义了一组和事务协调者交互的 C
|
||||
|
||||
唯一的出路是让管理员手动的来提交或者中止事务。管理员首先需要检查所有包含未定事务的参与者,看是否有任何参与者提交或者中止了,从而对其他卡主的参与者手动执行相同操作(**通过外力来让所有参与者达成一致**)。解决该问题需要大量手工操作,并且在线上环境中断服务的巨大压力和时间限制下(不然,为什么协调者会处在此种错误状态下?)。
|
||||
|
||||
很多 XA 事务的实现会留有紧急后门,称为**启发式决策**(*heuristic decisions*):允许一个参与者不用等待协调者的决策,而**单方面**决定中止还是提交一个未定事务。需要说明的是,这里的启发式仅仅是**可能打破原子性**(probably breaking atomicity)的一种委婉说法。因为这么做可能会违反两阶段提交所提供的保证。因此这种启发式决策仅是为了救急,而不能进行日常使用。
|
||||
很多 XA 事务的实现会留有紧急后门,称为**启发式决策**(_heuristic decisions_):允许一个参与者不用等待协调者的决策,而**单方面**决定中止还是提交一个未定事务。需要说明的是,这里的启发式仅仅是**可能打破原子性**(probably breaking atomicity)的一种委婉说法。因为这么做可能会违反两阶段提交所提供的保证。因此这种启发式决策仅是为了救急,而不能进行日常使用。
|
||||
|
||||
### 分布式事务的限制
|
||||
|
||||
@ -746,7 +701,7 @@ XA 事务解决了一些很现实而重要的难题:让异构的数据系统
|
||||
- 如果协调者没有使用多副本机制,仅运行在一台机器上,则它会成为系统的一个**单点**(因为它的宕机会造成存疑的参与者,进而阻塞其他应用服务的继续运行)。然而,令人惊讶的是,很多协调者的实现要么默认不是高可用的,要么只提供了很粗糙的冗余支持。
|
||||
- 很多服务端应用本身被设计为**无状态的**(stateless,HTTP 比较偏好无状态,如 Restful 设计风格)的,然后将状态都外存到数据库中。这样做的好处是,应用侧进程可以随意增删,按需扩展和收缩。但如果事务的协调者成为了应用层的一部分,就改变了这个本质设定。协调者的日志变成了应用层一个至关重要的、需要持久化的状态——需要像数据库一样按同等重要性对待。因为在宕机重启后,参与者会利用这些日志来推进卡住的参与者。由是,应用层不再是无状态的。
|
||||
- 由于 XA 需要和足够广泛的数据系统进行适配,因此其 API 只能维持一个**最小公共接口集**,由此带来了 XA 在能力上的**诸多限制**。如,XA 不能在跨系统检测死锁,因为这要求增加一种可以获取所有正在等待的锁信息接口(需要使用 Wait-For-Graph 死锁检测);XA 也不能提供跨系统的 SSI 隔离级别(参见[可串行的快照隔离](https://ddia.qtmuniao.com/#/ch07?id=%e5%8f%af%e4%b8%b2%e8%a1%8c%e7%9a%84%e5%bf%ab%e7%85%a7%e9%9a%94%e7%a6%bb)),因为这要求支持一种可以跨系统监测冲突的协议(SSI 要在 SI 的基础上进行读写冲突检测)。
|
||||
- 对于数据库的**内部分布式事务**(非 XA),就没有这些限制——例如,可以提供分布式版本的 SSI 。然而,要成功地提交一个 2PC 事务仍有诸多问题:所有的参与者必须要回复(但可以异步回应)。因此,一旦系统内任何子模块损坏了,则事务也随之失败。从这个角度来说,分布式事务有**放大故障**的嫌疑,这与我们构建容错系统的目标背道而驰(这就是 tradeoff,为上层提供的更多的一致性保证,就会牺牲性能,降低可用性)。
|
||||
- 对于数据库的**内部分布式事务**(非 XA),就没有这些限制——例如,可以提供分布式版本的 SSI。然而,要成功地提交一个 2PC 事务仍有诸多问题:所有的参与者必须要回复(但可以异步回应)。因此,一旦系统内任何子模块损坏了,则事务也随之失败。从这个角度来说,分布式事务有**放大故障**的嫌疑,这与我们构建容错系统的目标背道而驰(这就是 tradeoff,为上层提供的更多的一致性保证,就会牺牲性能,降低可用性)。
|
||||
|
||||
上述事实是否意味着我们应该放弃让不同系统保持一致的努力?不尽然,有很多其他方法,既可以让我们达到同样的目标,而又不必引入异构分布式事务的痛点。我们在第 11 章和 12 章会回到对这个问题的讨论。现在让我们先把共识问题这个主题讲完。
|
||||
|
||||
@ -758,22 +713,14 @@ XA 事务解决了一些很现实而重要的难题:让异构的数据系统
|
||||
|
||||
在这种形式化表述中,一个共识协议必须满足以下条件:
|
||||
|
||||
- **全局一致性**(*Uniform agreement*)
|
||||
|
||||
没有任何两个节点最终做出不同决策。
|
||||
|
||||
- **正直性**(*Integrity*)
|
||||
|
||||
没有任何节点会做出两次决策(不会反复横跳)
|
||||
|
||||
- **有效性**(*Validity*)
|
||||
|
||||
如果一个节点做出了决策,该决策所对应的值一定来自系统内某个节点的提议
|
||||
|
||||
- **可终止性**(*Termination*)
|
||||
|
||||
任何没有宕机的节点,最终都会给出对某个值的决策
|
||||
|
||||
- **全局一致性**(_Uniform agreement_)
|
||||
没有任何两个节点最终做出不同决策。
|
||||
- **正直性**(_Integrity_)
|
||||
没有任何节点会做出两次决策(不会反复横跳)
|
||||
- **有效性**(_Validity_)
|
||||
如果一个节点做出了决策,该决策所对应的值一定来自系统内某个节点的提议
|
||||
- **可终止性**(_Termination_)
|
||||
任何没有宕机的节点,最终都会给出对某个值的决策
|
||||
|
||||
全局一致和正直性定义了共识协议的核心概念:**所有节点都要决策出同样的结果,并且一旦做出决策,就不能反悔**。加入有效性更多的是为了排除一些无效(trivial)结果:如果无论其他节点提议什么,一个算法都会选择 null 作为决策值;该算法虽然满足一致性和正直性约束,但却不满足有效性。
|
||||
|
||||
@ -787,14 +734,13 @@ XA 事务解决了一些很现实而重要的难题:让异构的数据系统
|
||||
|
||||
因此,可终止性受限于少于半数节点宕机或不可达的假设。然而,大多数共识算法的实现在大多数节点都宕机或者网络出现大范围故障时仍然能保持安全性——一致性,正直性和有效性。也即,大范围的节点下线可能会让系统**不能继续处理请求**,但**不会因此破坏共识协议**,让其做出不合法决策。
|
||||
|
||||
大多数共识算法会假设系统中**不存在拜占庭故障**(参见[拜占庭错误](https://ddia.qtmuniao.com/#/ch08?id=%e6%8b%9c%e5%8d%a0%e5%ba%ad%e9%94%99%e8%af%af))。即如果某些节点故意不遵守协议(例如,对不同节点返回完全不同的信息),就有可能破坏协议的安全性。当然, 我们也有办法让系统足够鲁棒以容忍拜占庭错误,但就得要求集群中不能有超过三分之一的恶意节点(具有拜占庭错误的节点),但本书中没有足够精力来详细讨论这种算法的细节了。
|
||||
大多数共识算法会假设系统中**不存在拜占庭故障**(参见[拜占庭错误](https://ddia.qtmuniao.com/#/ch08?id=%e6%8b%9c%e5%8d%a0%e5%ba%ad%e9%94%99%e8%af%af))。即如果某些节点故意不遵守协议(例如,对不同节点返回完全不同的信息),就有可能破坏协议的安全性。当然,我们也有办法让系统足够鲁棒以容忍拜占庭错误,但就得要求集群中不能有超过三分之一的恶意节点(具有拜占庭错误的节点),但本书中没有足够精力来详细讨论这种算法的细节了。
|
||||
|
||||
### 全序广播中的共识算法
|
||||
|
||||
最广为人知的容错性的共识算法有——VSR(Viewstamped Replication)、Paxos、Raft 和 Zab。这些共识算法间有非常多的共同点,但他们确实不完全相同(虽然 Lamport 说过类似,世界上只有一种共识算法——Paxos )。在本书中我们不会探究每个共识算法的区别的所有细节:只需知道他们在顶层设计中有很多相似之处即可。除非,你想自己实现一个共识算法。
|
||||
最广为人知的容错性的共识算法有——VSR(Viewstamped Replication)、Paxos、Raft 和 Zab。这些共识算法间有非常多的共同点,但他们确实不完全相同(虽然 Lamport 说过类似,世界上只有一种共识算法——Paxos)。在本书中我们不会探究每个共识算法的区别的所有细节:只需知道他们在顶层设计中有很多相似之处即可。除非,你想自己实现一个共识算法。
|
||||
|
||||
> 当然,并不推荐这么做,因为实现一个工业级可用的共识算法很难,需要处理特别多的边角情况,而这些情况不经过大量实践是根本不会想到的。虽然 TLA 可以验证你的算法,但并不能验证你的实现。
|
||||
>
|
||||
|
||||
这些共识算法通常不会直接按上述形式化的定义(如提议并在**单值上**进行决策,同时满足一致性、正直性,有效性和可终止性)来实现。转而,他们通常会在**一系列值**上做出决策,从而事实上变成一种**全序广播算法**,本章前面小节讨论过这个问题。
|
||||
|
||||
@ -805,7 +751,7 @@ XA 事务解决了一些很现实而重要的难题:让异构的数据系统
|
||||
- 由于有效性,消息不会是损坏的,也不会是凭空捏造的。
|
||||
- 由于可终止性,消息不会丢失。
|
||||
|
||||
VSR, Raft 和 Zab 都直接实现了全序广播,相对多次使用共识算法,每次就单个只达成一致,这种方法要更高效。对于 Paxos,其全序广播版本是 Multi-Paxos。
|
||||
VSR,Raft 和 Zab 都直接实现了全序广播,相对多次使用共识算法,每次就单个只达成一致,这种方法要更高效。对于 Paxos,其全序广播版本是 Multi-Paxos。
|
||||
|
||||
### 单主复制和共识协议
|
||||
|
||||
@ -821,7 +767,7 @@ XA 事务解决了一些很现实而重要的难题:让异构的数据系统
|
||||
|
||||
### 纪元编号和法定人数
|
||||
|
||||
到目前为止所提到的共识算法都在内部需要一个**某种形式上**的主节点,但都不能保证主节点是唯一的。但,他们可以给出一个稍弱的保证:协议会定义一个**纪元编号**(epoch number;在 Paxos 中称为**投票编号**,ballot number; 在 Viewstamp Replication 中称为**视图编号**,view number;在 Raft 中称为**任期编号**,term number),并且保证在每一个纪元(epoch)内,主节点是唯一的。
|
||||
到目前为止所提到的共识算法都在内部需要一个**某种形式上**的主节点,但都不能保证主节点是唯一的。但,他们可以给出一个稍弱的保证:协议会定义一个**纪元编号**(epoch number;在 Paxos 中称为**投票编号**,ballot number;在 Viewstamp Replication 中称为**视图编号**,view number;在 Raft 中称为**任期编号**,term number),并且保证在每一个纪元(epoch)内,主节点是唯一的。
|
||||
|
||||
每次当前的主节点被认为下线时(可能是宕机,也可能只是网络不通),所有认为该主下线的节点就会发起选举,以选出新的主节点。每次选举会使用一个更高的纪元编号,因此所有的纪元编号是全序且单调递增的。如果不同纪元中有两个节点都认为自己是主(比如之前的主节点并没有宕机),则具有较高纪元编号的主节点胜出。
|
||||
|
||||
@ -868,21 +814,13 @@ Zookeeper 和 etcd 设计目标为**存储小尺度的数据**,比如能装进
|
||||
Zookeeper 是模仿 Google 的 Chunk 锁服务实现的,不仅实现了全序广播算法(进而实现了共识),也实现了其他一些对分布式系统非常有用的功能集:
|
||||
|
||||
- **线性化的原子操作(lock)**
|
||||
|
||||
使用原子的 CAS 操作,可以实现锁:如果多个节点并发执行同一个操作,只有一个会成功。共识协议能够保证,即使随时可能出现节点宕机或者网络故障,操作仍然是原子和线性化的。一个分布式锁通常实现为具有**过期时间的“租约”**(lease),这样即使客户端宕机,锁也能够被最终释放。
|
||||
|
||||
使用原子的 CAS 操作,可以实现锁:如果多个节点并发执行同一个操作,只有一个会成功。共识协议能够保证,即使随时可能出现节点宕机或者网络故障,操作仍然是原子和线性化的。一个分布式锁通常实现为具有**过期时间的“租约”**(lease),这样即使客户端宕机,锁也能够被最终释放。
|
||||
- **操作的全序保证(zxid)**
|
||||
|
||||
在[领导者和锁](https://ddia.qtmuniao.com/#/ch08?id=%e9%a2%86%e5%af%bc%e8%80%85%e5%92%8c%e9%94%81)一节中我们讨论过,当某个资源被锁或者租约保护时,你需要**防护令牌机制**来防止由于进程停顿而造成的加锁冲突。防护令牌一个在每次获取锁都会单调自增的数值。Zookeeper 通过给每个操作赋予一个全局自增的事务 id(zxid)和一个版本号(cversion)来提供该功能。
|
||||
|
||||
在[领导者和锁](https://ddia.qtmuniao.com/#/ch08?id=%e9%a2%86%e5%af%bc%e8%80%85%e5%92%8c%e9%94%81)一节中我们讨论过,当某个资源被锁或者租约保护时,你需要**防护令牌机制**来防止由于进程停顿而造成的加锁冲突。防护令牌一个在每次获取锁都会单调自增的数值。Zookeeper 通过给每个操作赋予一个全局自增的事务 id(zxid)和一个版本号(cversion)来提供该功能。
|
||||
- **故障检测(ephemeral node)**
|
||||
|
||||
客户端和 ZooKeeper 的服务器间维持着一个长会话,客户端和服务端通过周期性的心跳来检测对端是否仍然存活。即使该连接短暂断掉,或者 ZooKeeper 节点故障,该会话仍然能够存活。但如果,心跳停顿间隔过长,超过了会话的超时阈值,ZooKeeper 会标记该会话死亡。所有该会话关联的锁在超时都将会被释放(ZooKeeper 将其称为**暂态节点**,ephemeral nodes,这类节点可以将生命周期与会话进行绑定)。
|
||||
|
||||
客户端和 ZooKeeper 的服务器间维持着一个长会话,客户端和服务端通过周期性的心跳来检测对端是否仍然存活。即使该连接短暂断掉,或者 ZooKeeper 节点故障,该会话仍然能够存活。但如果,心跳停顿间隔过长,超过了会话的超时阈值,ZooKeeper 会标记该会话死亡。所有该会话关联的锁在超时都将会被释放(ZooKeeper 将其称为**暂态节点**,ephemeral nodes,这类节点可以将生命周期与会话进行绑定)。
|
||||
- **变动通知(watch)**
|
||||
|
||||
客户端不仅可以读取其他节点创建的锁或者值,也可以直接对这些对象的变化进行**守望**(watch)。通过守望机制,客户端可以立即发现是否有其他客户端加入集群(通过这些客户端写入 ZooKeeper 的值)、其他客户端是否故障(通过这些客户端注册到 ZooKeeper 中的暂态节点的消失)。通过订阅这些通知,客户端可以避免频繁地去 ZooKeeper 拉取信息,比对以确定是否发生了某些变化。
|
||||
|
||||
客户端不仅可以读取其他节点创建的锁或者值,也可以直接对这些对象的变化进行**守望**(watch)。通过守望机制,客户端可以立即发现是否有其他客户端加入集群(通过这些客户端写入 ZooKeeper 的值)、其他客户端是否故障(通过这些客户端注册到 ZooKeeper 中的暂态节点的消失)。通过订阅这些通知,客户端可以避免频繁地去 ZooKeeper 拉取信息,比对以确定是否发生了某些变化。
|
||||
|
||||
对于这些功能,只有线性化的原子操作真正需要共识算法。但这些操作的组合,使得类似 ZooKeeper 的系统对分布式系统非常有用。
|
||||
|
||||
@ -892,11 +830,11 @@ Zookeeper 是模仿 Google 的 Chunk 锁服务实现的,不仅实现了全序
|
||||
|
||||
另一个例子是,你有一些分了片的资源(数据库、消息流、文件存储、分布式的 actor 等等),并且需要决策哪些分片要放到哪些节点上去。当新节点加入集群后,一些分片需要从现有节点挪动到这些新节点上去,以进行**负载均衡**。当有节点故障或者被移除时,其他的节点需要接管故障节点的负载。
|
||||
|
||||
可以通过谨慎的组合使用 ZooKeeper 中的原子操作、暂态节点和通知机制来实现这类任务。如果实现正确,则可以让应用在遇到故障时,无人工干预的情况下自动恢复。即使有很多基于 ZooKeeper 的二次封装库(如 Apache Curator )可以借助,实现正确仍然不容易。但总好过从头实现所需的共识算法,很少有人能够成功的从头实现一个工业可用的共识系统。
|
||||
可以通过谨慎的组合使用 ZooKeeper 中的原子操作、暂态节点和通知机制来实现这类任务。如果实现正确,则可以让应用在遇到故障时,无人工干预的情况下自动恢复。即使有很多基于 ZooKeeper 的二次封装库(如 Apache Curator)可以借助,实现正确仍然不容易。但总好过从头实现所需的共识算法,很少有人能够成功的从头实现一个工业可用的共识系统。
|
||||
|
||||
刚开始时,一个应用可能会运行在单机上,但最终可能会扩展到上千节点的集群上。在如此多的节点上进行多数票选举会非常低效。相反,ZooKeeper 通常运行在固定节点的集群上(通常是三个或者五个),并且只须在这几个节点间达成共识,然后就可以支持非常多的客户端访问。这样,ZooKeeper 提供了一种可以将**部分功能**(共识算法、外包定序、故障检测)“**外包**”(outsouring)给外部服务的方法。
|
||||
|
||||
通常来说,ZooKeeper 所管理的数据只会**很低频的改变**:比如它会维护类似 “运行在 10.1.1.23 节点上的服务是分片 7 的领导者”的元信息,这种信息只会在**分钟或者小时级**的时间尺度上进行改变。这些系统不是为了存储应用运行时的数据,毕竟这些数据可能会以上千甚至上百万 QPS 的速率被修改。如果应用需要将数据从一个节点同步到另外一个节点,则需要使用其他工具(如 Apache 的 BookKeeper,一个类似于日志的存储服务,会将 log 切分并做冗余,Pulsar 的存储层在用)。
|
||||
通常来说,ZooKeeper 所管理的数据只会**很低频的改变**:比如它会维护类似“运行在 10.1.1.23 节点上的服务是分片 7 的领导者”的元信息,这种信息只会在**分钟或者小时级**的时间尺度上进行改变。这些系统不是为了存储应用运行时的数据,毕竟这些数据可能会以上千甚至上百万 QPS 的速率被修改。如果应用需要将数据从一个节点同步到另外一个节点,则需要使用其他工具(如 Apache 的 BookKeeper,一个类似于日志的存储服务,会将 log 切分并做冗余,Pulsar 的存储层在用)。
|
||||
|
||||
### 服务发现
|
||||
|
||||
|
@ -14,8 +14,8 @@
|
||||
|
||||
第二,具体到第一部分,开篇就给了三个总纲式的“心法”:可靠性、可伸缩性、可维护性。然后,从上到下,由离用户最近的数据模型(比如关系模型)和查询语言(比如 SQL),到稍微底层一点的存储引擎(比如 B+ tree 和 lsm tree)和查询引擎,再到最底层的编码(数据结构的降维)和演化,层层下探,令人拍案叫绝。我之前工作和兴趣之余所接触到的零碎知识,至此百川入海,万法归一。
|
||||
|
||||
第三,具体到每一章,也是节节递进,读起来无比丝滑。比如第三章,在讲存储引擎时,从一个仅由两个 shell 函数组成的“kv 引擎” 起,到一个简单的日志结构的存储(Bitcask),再到经典的 LSM-Tree。这又是工程中惯用思路:从一个最小可用原型开始,不断增加需求、解决瓶颈,最终得到一个工业可用的存储引擎。
|
||||
第三,具体到每一章,也是节节递进,读起来无比丝滑。比如第三章,在讲存储引擎时,从一个仅由两个 shell 函数组成的“kv 引擎”起,到一个简单的日志结构的存储(Bitcask),再到经典的 LSM-Tree。这又是工程中惯用思路:从一个最小可用原型开始,不断增加需求、解决瓶颈,最终得到一个工业可用的存储引擎。
|
||||
|
||||
我们如何认识世界?不断归纳然后演绎。我们如何处理复杂度?不断拆解然后勾连。将汪洋恣肆的复杂度合理疏导,渐次递进,本书无愧神书!
|
||||
|
||||
> 如果你也对此书感兴趣,但苦于无人交流,欢迎参加我们的读书会:https://docs.qq.com/sheet/DWHFzdk5lUWx4UWJq?tab=BB08J2 。里面有往期分享录屏、进群方式和之后安排,共勉~
|
||||
> 如果你也对此书感兴趣,但苦于无人交流,欢迎参加我们的读书会:https://docs.qq.com/sheet/DWHFzdk5lUWx4UWJq?tab=BB08J2。里面有往期分享录屏、进群方式和之后安排,共勉~
|
||||
|
Loading…
Reference in New Issue
Block a user