├── .idea ├── .gitignore ├── codeStyles │ └── codeStyleConfig.xml ├── distribute_basic.iml ├── misc.xml ├── modules.xml └── vcs.xml ├── README.md ├── blog ├── base │ └── brief.md ├── cap │ └── brief.md ├── concensus │ └── lab1-线性一致性 └── transaction │ └── type.md ├── doc ├── jraft │ ├── basic │ │ ├── Untitled.md │ │ ├── lab1-定时调度时间轮算法.md │ │ └── lab2-投票的实现 │ ├── lab0-初始化时做了什么.md │ ├── lab1-leader选举.md │ ├── lab2-日志复制.md │ ├── lab3-snashopt机制.md │ └── lab4-存储模块.md └── mit │ ├── lab1-introduction.md │ ├── lab2-gfs.md │ ├── lab3-VMware FT.md │ ├── lab4-raft.md │ ├── lab5-raft2.md │ ├── lab6-zookeeper.md │ ├── lab7-CRAQ.md │ └── lab8-Aurora.md └── paper ├── base └── base-danPritichett.md ├── cap ├── 1.brief └── cap-theorem-revisited.md ├── google ├── gfs.md └── mapreduce.md └── raft └── raft-cn.md /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/distribute_basic.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 1.MIT分布式系统课程 2 | 3 | 网课地址:https://www.bilibili.com/video/BV1qk4y197bB?from=search&seid=7469911290247608861 4 | 5 | 官网资源:http://nil.csail.mit.edu/6.824/2020/schedule.html 6 | 7 | ## 课程内容 8 | 9 | ### 第一课:[lab1-introduction](doc/mit/lab1-introduction.md) 10 | 11 | **前置理论及论文基础** 12 | 13 | - **cap** 14 | 15 | 1.[cap简介](blog/cap/brief.md) 16 | 17 | 2.[cap-theorem-revisited](paper/cap/cap-theorem-revisited.md) 18 | 19 | - **base** 20 | 21 | 1.[base简介](blog/base/brief.md) 22 | 23 | 2.[Base: An Acid Alternative](paper/base/base-danPritichett.md) 24 | 25 | 3.[分布式事务简介](blog/transaction/type.md) 26 | 27 | - **mapReduce** 28 | 29 | [google:mapreduce](paper/google/mapreduce.md) 30 | 31 | ### 第二课:[lab2-gfs文件系统](doc/mit/lab2-gfs.md) 32 | 33 | **前置理论及论文基础** 34 | 35 | - gfs论文 36 | 37 | [google:gfs](paper/google/gfs.md) 38 | 39 | ### 第三课:[lab3-VMware FT](doc/mit/lab3-VMware FT.md) 40 | 41 | **前置理论及论文基础** 42 | 43 | - [VMware FT](https://pdos.csail.mit.edu/6.824/papers/vm-ft.pdf) 44 | 45 | ### 第四课:[lab4-raft(一)](doc/mit/lab4-raft.md) 46 | 47 | **前置理论** 48 | 49 | raft论文前五节内容 50 | 51 | - [raft](paper/raft/raft-cn.md) 52 | 53 | ### 第五课:[lab5-raft(二)](doc/mit/lab5-raft2.md) 54 | 55 | **前置理论** 56 | 57 | raft论文后续内容 58 | 59 | - [raft](paper/raft/raft-cn.md) 60 | 61 | ### 第六课:[lab6-zookeeper](doc/mit/lab6-zookeeper.md) 62 | 63 | **前置理论** 64 | 65 | zookeeper论文: 66 | 67 | - [zookeeper](https://pdos.csail.mit.edu/6.824/papers/zookeeper.pdf) 68 | 69 | ### 第七课:[lab7-CRAQ](doc/mit/lab7-CRAQ.md) 70 | 71 | **前置理论** 72 | 73 | CRAQ论文: 74 | 75 | - [CRAQ](http://nil.csail.mit.edu/6.824/2020/papers/craq.pdf) 76 | 77 | ### 第八课:[lab8-Aurora](doc/mit/lab8-Aurora.md) 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | # 2.论文借鉴 92 | 93 | **概览文章** 94 | 95 | [http://www.rgoarchitects.com/Files/fallacies.pdf](https://link.zhihu.com/?target=http%3A//www.rgoarchitects.com/Files/fallacies.pdf) CS7680著名的9个论述 也是这门课推荐对于分布式系统的一个初步认识 96 | 97 | [http://citeseerx.ist.psu.edu/viewdoc/download;jsessionid=20A79A6520D69264C29248D0387C6703?doi=10.1.1.209.355&rep=rep1&type=pdf](https://link.zhihu.com/?target=http%3A//citeseerx.ist.psu.edu/viewdoc/download%3Bjsessionid%3D20A79A6520D69264C29248D0387C6703%3Fdoi%3D10.1.1.209.355%26rep%3Drep1%26type%3Dpdf) windows live的架构师james总结一系列大型后台服务的设计原则 98 | 99 | ## **CAP** 100 | 101 | [https://robertgreiner.com/cap-theorem-revisited/](https://link.zhihu.com/?target=https%3A//robertgreiner.com/cap-theorem-revisited/) 准确说是一篇blog,很精简,文字也不多,其实文中的图比文字更清晰。cap的理解也经历了一些纠结的过程,这一篇其实是作者多年后的二次理解。所以出错其实没啥问题,这位老板就完全推翻了之前文章里的阐述 102 | 103 | [http://ksat.me/a-plain-english-introduction-to-cap-theorem](https://link.zhihu.com/?target=http%3A//ksat.me/a-plain-english-introduction-to-cap-theorem) 也是通俗易懂的入门介绍cap的blog 104 | 105 | [https://www.infoq.com/articles/cap-twelve-years-later-how-the-rules-have-changed/](https://link.zhihu.com/?target=https%3A//www.infoq.com/articles/cap-twelve-years-later-how-the-rules-have-changed/) brewer多年以后写的关于cap的一些误解,C和A并不是完全对立的状态 106 | 107 | [https://sookocheff.com/post/databases/cap-twelve-years-later/](https://link.zhihu.com/?target=https%3A//sookocheff.com/post/databases/cap-twelve-years-later/) 是对上面这片文章的review心得 108 | 109 | 110 | 111 | [http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.24.3690&rep=rep1&type=pdf](https://link.zhihu.com/?target=http%3A//citeseerx.ist.psu.edu/viewdoc/download%3Fdoi%3D10.1.1.24.3690%26rep%3Drep1%26type%3Dpdf) 开始用了两个新名词来阐述 112 | 113 | A)yield, which is the probability of completing a request .感觉说的就是A 114 | 115 | B)harvest ,measures the fraction of the data reflected in the response.感觉说的就是C 116 | 117 | 这篇论文对于available提出里两个比较好的方案: 118 | 119 | 1)牺牲harvest换来yield 120 | 121 | 2)应用架构拆分 和 正交机制 122 | 123 | **BASE** 124 | 125 | [https://queue.acm.org/detail.cfm?id=1394128](https://link.zhihu.com/?target=https%3A//queue.acm.org/detail.cfm%3Fid%3D1394128) base一致性的开山鼻祖,首次提出了和acid相反的一种理论,论文中给出了一些单机事务到多机事务的演进过程,并没有觉得很理论,工程很值得借鉴 126 | 127 | ## 一致性 128 | 129 | [http://jepsen.io/consistency#fundamental-concepts](https://link.zhihu.com/?target=http%3A//jepsen.io/consistency%23fundamental-concepts) 一致性的模型,高屋建瓴,是一篇blog 130 | 131 | [http://vukolic.com/consistency-survey.pdf](https://link.zhihu.com/?target=http%3A//vukolic.com/consistency-survey.pdf) 概述的文章 先看看 132 | 133 | - **sequential consistency** 134 | 135 | [https://lamport.azurewebsites.net/pubs/lamport-how-to-make.pdf](https://link.zhihu.com/?target=https%3A//lamport.azurewebsites.net/pubs/lamport-how-to-make.pdf) lamport大神不用过多的介绍,读他的论文唯一的感受就是智商的差别吧 136 | 137 | [https://cs.brown.edu/~mph/HerlihyW90/p463-herlihy.pdf](https://link.zhihu.com/?target=https%3A//cs.brown.edu/~mph/HerlihyW90/p463-herlihy.pdf) 也是线性一致性的文章 作者在cmu发表的 138 | 139 | - **eventual consistency** 140 | 141 | 最终一致性的文章首推 aws的cto 142 | 143 | [https://www.allthingsdistributed.com/2008/12/eventually_consistent.html](https://link.zhihu.com/?target=https%3A//www.allthingsdistributed.com/2008/12/eventually_consistent.html) 144 | 145 | [https://www.cs.tau.ac.il/~mad/publications/podc2015-replds.pdf](https://link.zhihu.com/?target=https%3A//www.cs.tau.ac.il/~mad/publications/podc2015-replds.pdf) 讲了一些高可用和一致性之间的trade-off 146 | 147 | [https://pdfs.semanticscholar.org/6877/32ca90ce8ec57c0ec8530863b8a693bf4f51.pdf](https://link.zhihu.com/?target=https%3A//pdfs.semanticscholar.org/6877/32ca90ce8ec57c0ec8530863b8a693bf4f51.pdf) 描述了 最终一致性 和 因果一致性的关系 148 | 149 | [https://haslab.uminho.pt/tome/files/global_logical_clocks.pdf](https://link.zhihu.com/?target=https%3A//haslab.uminho.pt/tome/files/global_logical_clocks.pdf) 150 | 151 | - **causal consistency** 152 | 153 | [http://www.bailis.org/papers/bolton-sigmod2013.pdf](https://link.zhihu.com/?target=http%3A//www.bailis.org/papers/bolton-sigmod2013.pdf) Bolt-on的架构设计 154 | 155 | [https://www.cs.cmu.edu/~dga/papers/cops-sosp2011.pdf](https://link.zhihu.com/?target=https%3A//www.cs.cmu.edu/~dga/papers/cops-sosp2011.pdf) cops的架构设计 156 | 157 | [https://www.cs.princeton.edu/~wlloyd/papers/eiger-nsdi13.pdf](https://link.zhihu.com/?target=https%3A//www.cs.princeton.edu/~wlloyd/papers/eiger-nsdi13.pdf) 158 | 159 | [https://www.ronpub.com/OJDB_2015v2i1n02_Elbushra.pdf](https://link.zhihu.com/?target=https%3A//www.ronpub.com/OJDB_2015v2i1n02_Elbushra.pdf) 一个causal consistency的db设计与实现 160 | 161 | [https://smartech.gatech.edu/bitstream/handle/1853/6781/GIT-CC-93-55.pdf?sequence=1&isAllowed=y](https://link.zhihu.com/?target=https%3A//smartech.gatech.edu/bitstream/handle/1853/6781/GIT-CC-93-55.pdf%3Fsequence%3D1%26isAllowed%3Dy) 162 | 163 | 从前三篇文章的作者来看,ucb & cmu&priceton 还是很值得一读的 164 | 165 | 最后一篇的年代已经久远,其实发现计算机的一些理论基础其实是很经得起时间的考验的,所以码农其实也可以过的没有那么的有危机感^_^ 166 | 167 | [https://pdfs.semanticscholar.org/7725/8064a686dfd61d7232172d6706711606dcfc.pdf](https://link.zhihu.com/?target=https%3A//pdfs.semanticscholar.org/7725/8064a686dfd61d7232172d6706711606dcfc.pdf) 这个是最后一篇论文的ppt版本 168 | 169 | [https://arxiv.org/pdf/1802.00706.pdf](https://link.zhihu.com/?target=https%3A//arxiv.org/pdf/1802.00706.pdf) 170 | 171 | - **weak consistency** 172 | 173 | [http://pmg.csail.mit.edu/papers/adya-phd.pdf](https://link.zhihu.com/?target=http%3A//pmg.csail.mit.edu/papers/adya-phd.pdf) 174 | 175 | ## 分布式锁 176 | 177 | [https://static.googleusercontent.com/media/research.google.com/zh-CN//archive/chubby-osdi06.pdf](https://link.zhihu.com/?target=https%3A//static.googleusercontent.com/media/research.google.com/zh-CN//archive/chubby-osdi06.pdf) Google出品的chubby 必属精品 178 | 179 | [https://www.usenix.org/legacy/event/atc10/tech/full_papers/Hunt.pdf](https://link.zhihu.com/?target=https%3A//www.usenix.org/legacy/event/atc10/tech/full_papers/Hunt.pdf) Yahoo的zookeeper 180 | 181 | ## 分布式kv存储 182 | 183 | [http://static.googleusercontent.com/media/research.google.com/en//archive/bigtable-osdi06.pdf](https://link.zhihu.com/?target=http%3A//static.googleusercontent.com/media/research.google.com/en//archive/bigtable-osdi06.pdf) Google三驾马车之一bigtable,hbase的蓝本 184 | 185 | [http://static.googleusercontent.com/media/research.google.com/en/us/archive/gfs-sosp2003.pdf](https://link.zhihu.com/?target=http%3A//static.googleusercontent.com/media/research.google.com/en/us/archive/gfs-sosp2003.pdf) Google三架马车之二gfs,hdfs的蓝本 186 | 187 | [https://static.googleusercontent.com/media/research.google.com/en//archive/bigtable-osdi06.pdf](https://link.zhihu.com/?target=https%3A//static.googleusercontent.com/media/research.google.com/en//archive/bigtable-osdi06.pdf) Google三架马车之三bigtable,hbase的蓝本 188 | 189 | [https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf](https://link.zhihu.com/?target=https%3A//www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf) 现代很多的kv设计或多或少的都参考了先驱dynamo的设计,值得刷10遍以上。[读后感](https://zhuanlan.zhihu.com/p/140050721) 190 | 191 | [https://www.cs.cornell.edu/Projects/ladis2009/papers/Lakshman-ladis2009.PDF](https://link.zhihu.com/?target=https%3A//www.cs.cornell.edu/Projects/ladis2009/papers/Lakshman-ladis2009.PDF) 2009年Cassandra设计的论文 ,很多思想借鉴了dynamo,对于一致性哈希的吐槽也高度类似。 192 | 193 | 在replication的过程中,也会通过一个coordinator节点(master节点)来对其他节点进行replicate(这一点和dynamo一样),但是Cassandra提供了一系列的replicate policy可以选择,比如 Rack Unaware, Rack Aware (within a datacenter) and Datacenter Aware. Cassandra也沿用了dynamo里面关于preference list的定义 194 | 195 | 196 | 197 | [https://www.ssrc.ucsc.edu/media/pubs/9c7bcd06ff4eeccef2cb4c7813fe33ba7d4805c7.pdf](https://link.zhihu.com/?target=https%3A//www.ssrc.ucsc.edu/media/pubs/9c7bcd06ff4eeccef2cb4c7813fe33ba7d4805c7.pdf) 198 | 199 | [https://dsf.berkeley.edu/jmh/papers/anna_ieee18.pdf](https://link.zhihu.com/?target=https%3A//dsf.berkeley.edu/jmh/papers/anna_ieee18.pdf) ucb出的一篇高性能的kv存储,号称比redis快几十倍,使用coordination-free consistency models。虽然说是特别快,但是其实业界的是用并不广泛 200 | 201 | [https://cs.ulb.ac.be/public/_media/teaching/influxdb_2017.pdf](https://link.zhihu.com/?target=https%3A//cs.ulb.ac.be/public/_media/teaching/influxdb_2017.pdf) 时间序列的数据库的一篇介绍 ,介绍了几个应用场景 iot ebay等 ,influxdb的介绍 202 | 203 | [http://btw2017.informatik.uni-stuttgart.de/slidesandpapers/E4-14-109/paper_web.pdf](https://link.zhihu.com/?target=http%3A//btw2017.informatik.uni-stuttgart.de/slidesandpapers/E4-14-109/paper_web.pdf) 比较了业界的几种TSDB的异同 204 | 205 | 206 | 207 | 208 | 209 | 无论是kv还是传统的关系型数据库,在分布式系统里面无非都会涉及到以下这几方面 210 | 211 | - replication 212 | 213 | [https://dsf.berkeley.edu/cs286/papers/dangers-sigmod1996.pdf](https://link.zhihu.com/?target=https%3A//dsf.berkeley.edu/cs286/papers/dangers-sigmod1996.pdf) 指出了一种在replication中存在的问题,并给出了解决方案 214 | 215 | - partition&shard 216 | 217 | 分区都逃不了一致性哈希, 218 | 219 | [https://www.akamai.com/us/en/multimedia/documents/technical-publication/consistent-hashing-and-random-trees-distributed-caching-protocols-for-relieving-hot-spots-on-the-world-wide-web-technical-publication.pdf](https://link.zhihu.com/?target=https%3A//www.akamai.com/us/en/multimedia/documents/technical-publication/consistent-hashing-and-random-trees-distributed-caching-protocols-for-relieving-hot-spots-on-the-world-wide-web-technical-publication.pdf) 被引用度特别高的一篇文章,但是这个版本也是被吐槽最多的,dynamo吐槽过,Cassandra也吐槽了一把 220 | 221 | 1)First, the random position assignment of each node on the ring leads to non-uniform data and load distribution. 222 | 223 | 2)Second, the basic algorithm is oblivious to the heterogeneity in the performance of nodes. 224 | 225 | 解决方案 226 | 227 | 1)One is for nodes to get assigned to multiple positions in the circle (like in Dynamo) dynamo用的就是这种方法 228 | 229 | 2)the second is to analyze load information on the ring and have lightly loaded nodes move on the ring to alleviate heavily loaded nodes 这种方法被Cassandra采用 230 | 231 | [http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.217.2218&rep=rep1&type=pdf](https://link.zhihu.com/?target=http%3A//citeseerx.ist.psu.edu/viewdoc/download%3Fdoi%3D10.1.1.217.2218%26rep%3Drep1%26type%3Dpdf) 2)用的方法 也就是这片论文提出的方法 232 | 233 | - memship 234 | - failure detect 235 | - updated conflicts 236 | - implement 237 | 238 | 关于实现[http://www.sosp.org/2001/papers/welsh.pdf](https://link.zhihu.com/?target=http%3A//www.sosp.org/2001/papers/welsh.pdf) 这篇论文的出镜率特别高,里面的思想被Cassandra和dynamo都采用了 ,作者也是提出cap的大神Eric Brewer(第三作者),值得反复研读 239 | 240 | 241 | 242 | [https://storage.googleapis.com/pub-tools-public-publication-data/pdf/03de87e2856b06a94ffae7dca218db2d4b9afd39.pdf](https://link.zhihu.com/?target=https%3A//storage.googleapis.com/pub-tools-public-publication-data/pdf/03de87e2856b06a94ffae7dca218db2d4b9afd39.pdf) 这个是2019年Google提出的一种有状态的kv存储的思路。在工业界的下个请求依赖于上一个请求的情况 243 | 244 | **数据库** 245 | 246 | - **查询优化器** 247 | 248 | [https://arxiv.org/pdf/1608.02611.pdf](https://link.zhihu.com/?target=https%3A//arxiv.org/pdf/1608.02611.pdf) 249 | 250 | [https://dl.acm.org/doi/pdf/10.1145/335191.335451](https://link.zhihu.com/?target=https%3A//dl.acm.org/doi/pdf/10.1145/335191.335451) 251 | 252 | [https://dl.acm.org/doi/pdf/10.1145/2304510.2304525](https://link.zhihu.com/?target=https%3A//dl.acm.org/doi/pdf/10.1145/2304510.2304525) 253 | 254 | 255 | 256 | 257 | 258 | **MQ** 259 | 260 | - **kafka** 261 | 262 | [http://notes.stephenholiday.com/Kafka.pdf](https://link.zhihu.com/?target=http%3A//notes.stephenholiday.com/Kafka.pdf) 现在很火的kafa最初设计的论文,细节有些已经被优化,基本的架构还是很值得反复研读。比如 263 | 264 | In general, Kafka only guarantees at-least-once delivery. Exactly once delivery typically requires two-phase commits and is not necessary for our applications 265 | 266 | 最初kafka只是支持at-least的delivery, 但是不支持exactly once的投递,具体哪个版本开始支持有点记不清了 267 | 268 | 269 | 270 | **分布式文件系统** 271 | 272 | 除了大名鼎鼎的gfs 分布式文件系统已经走过了好几十个年头了 273 | 274 | [https://www.cs.cmu.edu/~satya/docdir/satya-ieeetc-coda-1990.pdf](https://link.zhihu.com/?target=https%3A//www.cs.cmu.edu/~satya/docdir/satya-ieeetc-coda-1990.pdf) 1990年的coda,在很多的论文中出镜率非常高,后面的fs也借鉴了coda的一些思想 275 | 276 | 277 | 278 | **分布式事务&事务隔离级别** 279 | 280 | [http://www.vldb.org/pvldb/vol7/p181-bailis.pdf](https://link.zhihu.com/?target=http%3A//www.vldb.org/pvldb/vol7/p181-bailis.pdf) 引用率很高的一篇文章 这里面也引用了下面的这篇文章中关于事务隔离级别P0,P1的引用,看之前可以先看下面这篇文章。比如,脏写,脏读,不可重复读&fuzzy读,幻读等 281 | 282 | 读未提交保证了写的串行化,注意只是写的串行化(并不能保证读写的串行化,依然有可能产生脏读),下面这篇论文里面是避免了脏写的操作。如何处理写的冲突呢? 打时间戳或者last write win的方式都是可行的 283 | 284 | [https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-95-51.pdf](https://link.zhihu.com/?target=https%3A//www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-95-51.pdf) 不管是怎么讲事务隔离级别,最原生的味道是这一篇,其他的文章都是咀嚼过吐出来的 285 | 286 | 其中也参考了 [https://pdfs.semanticscholar.org/b40a/2bed6469ccea11d1c5f884215805ba785019.pdf?_ga=2.256890123.1236479272.1592556133-1143139955.1585301775](https://link.zhihu.com/?target=https%3A//pdfs.semanticscholar.org/b40a/2bed6469ccea11d1c5f884215805ba785019.pdf%3F_ga%3D2.256890123.1236479272.1592556133-1143139955.1585301775) 里面阐述了很多隔离级别的标准 287 | 288 | **共识算法** 289 | 290 | 291 | 292 | [https://lamport.azurewebsites.net/pubs/paxos-simple.pdf](https://link.zhihu.com/?target=https%3A//lamport.azurewebsites.net/pubs/paxos-simple.pdf) paxos的simple版本,原来的版本太晦涩,lamport大神自己可能发现之前写的太高深了,写了一个通俗易懂的版本 293 | 294 | [https://dl.acm.org/doi/pdf/10.1145/3373376.3378496](https://link.zhihu.com/?target=https%3A//dl.acm.org/doi/pdf/10.1145/3373376.3378496) hermes 295 | 296 | [https://raft.github.io/raft.pdf](https://link.zhihu.com/?target=https%3A//raft.github.io/raft.pdf) 这个是精简版的raft 里面有些概念如果理解起来吃力可以看下作者的博士毕业论文[https://github.com/ongardie/dissertation#readme](https://link.zhihu.com/?target=https%3A//github.com/ongardie/dissertation%23readme) 里面有download的连接,以下的几篇文章都是raft的推荐 297 | 298 | [https://www.cl.cam.ac.uk/~ms705/pub/papers/2015-osr-raft.pdf](https://link.zhihu.com/?target=https%3A//www.cl.cam.ac.uk/~ms705/pub/papers/2015-osr-raft.pdf) raft 的分析文章 299 | 300 | [http://verdi.uwplse.org/raft-proof.pdf](https://link.zhihu.com/?target=http%3A//verdi.uwplse.org/raft-proof.pdf) 301 | 302 | [http://verdi.uwplse.org/verdi.pdf](https://link.zhihu.com/?target=http%3A//verdi.uwplse.org/verdi.pdf) verdi的实现 303 | 304 | [https://www.cl.cam.ac.uk/techreports/UCAM-CL-TR-857.pdf](https://link.zhihu.com/?target=https%3A//www.cl.cam.ac.uk/techreports/UCAM-CL-TR-857.pdf) raft一致性的分析 305 | 306 | [https://hal.inria.fr/hal-01086522/document](https://link.zhihu.com/?target=https%3A//hal.inria.fr/hal-01086522/document) 307 | 308 | 309 | 310 | 名字服务 311 | 312 | [http://static.cs.brown.edu/courses/csci2270/archives/2012/papers/replication/hunt.pdf](https://link.zhihu.com/?target=http%3A//static.cs.brown.edu/courses/csci2270/archives/2012/papers/replication/hunt.pdf) zk最初设计的论文,感觉比市面上的一些中文材料好懂,推荐 -------------------------------------------------------------------------------- /blog/base/brief.md: -------------------------------------------------------------------------------- 1 | 分布式系统中除了CAP理论,还有一个不得不说的BASE理论,这不仅是面试中常问的一个知识点,也是在学习分布式系统时候一个绕不过去的基础。 2 | 3 | 1、CAP理论回顾 4 | 5 | 分布式CAP理论告诉我们一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容忍 性(Partition tolerance)这三项中的两项。在这三项当中AP在实际应用中较多,它舍弃了一致性。 6 | 7 | 为什么要舍弃一致性呢?就好比是我们在买火车票的时候,明明看到还有一张票,可是等我选好了座位准备付钱的时候,系统却提示没票了。这就是舍弃了一致性,数据可能是不一致的。但是分区容错性和可用性却得到了满足。 8 | 9 | 但这不是说一致性不重要,相反恰恰它是最重要的。对我们来说,我们舍弃的只是强一致性。但是一定要满足最终一致性。也就是说,但是最终也要将数据同步成功来保证数据一致。而强一致性,要求在任何时间查询每个节点数据都必须一致。 10 | 11 | 2、Base理论介绍 12 | 13 | BASE 是 Basically Available(基本可用)、Soft state(软状态)和 Eventually consistent (最终一致性)三个短语的缩 写。BASE理论是对CAP中AP的一个扩展。下面我们来介绍一下这三个概念。 14 | 15 | (1)基本可用 16 | 17 | 指分布式系统在出现故障的时候,保证核心可用,允许损失部分可用性。例如,电商在做促销时,为了保证购物系统的稳定性,部分消费者可能会被引导到一个降级的页面。 18 | 19 | (2)软状态 20 | 21 | 指允许系统中的数据存在中间状态,并认为该中间状态不会影响系统整体可用性,即允许系统不同节点的数据副本之间进行同步的过程存在时延。就好比是使用支付宝的时候,会出现支付中、数据同步中等状态,这时候就叫做软状态。但是最终会显示支付成功。 22 | 23 | (3)最终一致性 24 | 25 | 最终一致性强调的是系统中的数据副本,在经过一段时间的同步后,最终能达到一致的状态。如订单的"支付中"状态,最终会变 为“支付成功”或者"支付失败",使订单状态与实际交易结果达成一致,但需要一定时间的延迟、等待。 26 | 27 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210219160033.jpeg) -------------------------------------------------------------------------------- /blog/cap/brief.md: -------------------------------------------------------------------------------- 1 | ## 一 理论基础 2 | 3 | CAP理论指的是一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)这三项中的两项。拿一个网上的图来看看。 4 | 5 | ![img](https://pics0.baidu.com/feed/e824b899a9014c087ee0766bd00ccb0d79f4f4a6.jpeg?token=f8a4fd41e8364f5497f7cf5204165fbc&s=91F2ED36414EE74D4E7DBB4D03007065) 6 | 7 | 这张图不知道你之前看到过没,如果你看过书或者是视频,这张图应该被列举了好几遍了。下面我不准备直接上来就对每一个特性进行概述。我们先从案例出发逐步过渡。 8 | 9 | ### 1、一个小例子 10 | 11 | 首先我们看一张图。 12 | 13 | ![img](https://pics6.baidu.com/feed/ac345982b2b7d0a2188a88271198bf0c4a369a42.jpeg?token=c97237876d7c96181429d50215941484&s=8C12EF16C4367F8800D531CB0200E0A3) 14 | 15 | 现在网络中有两个节点N1和N2,他们之间网络可以连通,N1中有一个应用程序A,和一个数据库V,N2也有一个应用程序B2和一个数据库V。现在,A和B是分布式系统的两个部分,V是分布式系统的两个子数据库。 16 | 17 | 现在问题来了。突然有两个用户小明和小华分别同时访问了N1和N2。我们理想中的操作是下面这样的。 18 | 19 | ![img](https://pics7.baidu.com/feed/09fa513d269759eeb31d54fb698c8a136d22df2a.jpeg?token=6de985304bf77c47042d71f9210a4aa7&s=1010CF318E03DA094EF0B9CB0200A0B2) 20 | 21 | (1)小明访问N1节点,小华访问N2节点。同时访问的。 22 | 23 | (2)小明把N1节点的数据V0变成了V1。 24 | 25 | (2)N1节点一看自己的数据有变化,立马执行M操作,告诉了N2节点。 26 | 27 | (4)小华读取到的就是最新的数据。也是正确的数据。 28 | 29 | 上面这是一种最理想的情景。它满足了CAP理论的三个特性。现在我们看看如何来理解满足的这三个特性。 30 | 31 | ### 2、Consistency 一致性 32 | 33 | 一致性指的是所有节点在同一时间的数据完全一致。就好比刚刚举得例子中,小明和小华读取的都是正确的数据,对他们用户来说,就好像是操作了同一个数据库的同一个数据一样。 34 | 35 | 因此对于一致性,也可以分为从客户端和服务端两个不同的视角来理解。 36 | 37 | (1)客户端 38 | 39 | 从客户端来看,一致性主要指的是多并发访问时更新过的数据如何获取的问题。也就是小明和小华同时访问,如何获取更新的最新的数据。 40 | 41 | (2)服务端 42 | 43 | 从服务端来看,则是更新如何分布到整个系统,以保证数据最终一致。也就是N1节点和N2节点如何通信保持数据的一致。 44 | 45 | 对于一致性,一致的程度不同大体可以分为强、弱、最终一致性三类。 46 | 47 | (1)强一致性 48 | 49 | 对于关系型数据库,要求更新过的数据能被后续的访问都能看到,这是强一致性。比如小明更新V0到V1,那么小华读取的时候也应该是V1。 50 | 51 | (2)弱一致性 52 | 53 | 如果能容忍后续的部分或者全部访问不到,则是弱一致性。比如小明更新VO到V1,可以容忍那么小华读取的时候是V0。 54 | 55 | (3)最终一致性 56 | 57 | 如果经过一段时间后要求能访问到更新后的数据,则是最终一致性。比如小明更新VO到V1,可以使得小华在一段时间之后读取的时候是V0。 58 | 59 | ### 3、可用性 60 | 61 | 可用性指服务一直可用,而且是正常响应时间。就好比刚刚的N1和N2节点,不管什么时候访问,都可以正常的获取数据值。而不会出现问题。好的可用性主要是指系统能够很好的为用户服务,不出现用户操作失败或者访问超时等用户体验不好的情况。 62 | 63 | 对于可用性来说就比较好理解了。 64 | 65 | ### 4、分区容错性 66 | 67 | 分区容错性指在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性和可用性的服务。就好比是N1节点和N2节点出现故障,但是依然可以很好地对外提供服务。 68 | 69 | 这个分区容错性也是很好理解。 70 | 71 | 在经过上面的分析中,在理想情况下,没有出现任何错误的时候,这三条应该都是满足的。但是天有不测风云。系统总是会出现各种各样的问题。下面来分析一下为什么说CAP理论只能满足两条。 72 | 73 | ## **二、验证CAP理论** 74 | 75 | 既然系统总是会有错误,那我们就来看看可能会出现什么错误。 76 | 77 | ![img](https://pics6.baidu.com/feed/9e3df8dcd100baa1acabcdab9c677017c9fc2e9c.jpeg?token=6243569d671463675594232a12025783&s=10104B319647DB095CF820DB0200B0B2) 78 | 79 | N1节点更新了V0到V1,想在也想把这个消息通过M操作告诉N1节点,却发生了网络故障。这时候小明和小华都要同时访问这个数据,怎么办呢?现在我们依然想要我们的系统具有CAP三个特性,我们分析一下会发生什么。 80 | 81 | (1)系统网络发生了故障,但是系统依然可以访问,因此具有容错性。 82 | 83 | (2)小明在访问节点N1的时候更改了V0到V1,想要小华访问节点N2的V数据库的时候是V1,因此需要等网络故障恢复,将N2节点的数据库进行更新才可以。 84 | 85 | (3)在网络故障恢复的这段时间内,想要系统满足可用性,是不可能的。因为可用性要求随时随地访问系统都是正确有效的。这就出现了矛盾。 86 | 87 | 正是这个矛盾所以CAP三个特性肯定不能同时满足。既然不能满足,那我们就进行取舍。 88 | 89 | 有两种选择: 90 | 91 | (1)牺牲数据一致性,也就是小明看到的衣服数量是10,买了一件应该是9了。但是小华看到的依然是10。 92 | 93 | (2)牺牲可用性,也就是小明看到的衣服数量是10,买了一件应该是9了。但是小华想要获取的最新的数据的话,那就一直等待阻塞,一直到网络故障恢复。 94 | 95 | 现在你可以看到了CAP三个特性肯定是不能同时满足的,但是可以满足其中两个。 96 | 97 | ## 三、CAP特性的取舍 98 | 99 | 我们分析一下既然可以满足两个,那么舍弃哪一个比较好呢? 100 | 101 | (1)满足CA舍弃P,也就是满足一致性和可用性,舍弃容错性。但是这也就意味着你的系统不是分布式的了,因为涉及分布式的想法就是把功能分开,部署到不同的机器上。 102 | 103 | (2)满足CP舍弃A,也就是满足一致性和容错性,舍弃可用性。如果你的系统允许有段时间的访问失效等问题,这个是可以满足的。就好比多个人并发买票,后台网络出现故障,你买的时候系统就崩溃了。 104 | 105 | (3)满足AP舍弃C,也就是满足可用性和容错性,舍弃一致性。这也就是意味着你的系统在并发访问的时候可能会出现数据不一致的情况。 106 | 107 | 实时证明,大多数都是牺牲了一致性。像12306还有淘宝网,就好比是你买火车票,本来你看到的是还有一张票,其实在这个时刻已经被买走了,你填好了信息准备买的时候发现系统提示你没票了。这就是牺牲了一致性。 108 | 109 | 但是不是说牺牲一致性一定是最好的。就好比mysql中的事务机制,张三给李四转了100块钱,这时候必须保证张三的账户上少了100,李四的账户多了100。因此需要数据的一致性,而且什么时候转钱都可以,也需要可用性。但是可以转钱失败是可以允许的。 -------------------------------------------------------------------------------- /blog/concensus/lab1-线性一致性: -------------------------------------------------------------------------------- 1 | ## **1. 背景** 2 | 3 | 我们经常讨论分布式系统的 CAP 理论,那么一定对 CAP 中的 C 有一定的了解,CAP 中 C 指的就是强一致性(**strong consistency** ),也就是线性一致性(**linearizability** )。接下来我们准备分别撰写以下三部分内容,对线性一致性做一次深入的剖析。 4 | 5 | 1. 什么是线性一致性,线性一致性的实现和应用等 6 | 2. 线性一致性测试的理论:包含线性一致性的精确定义和线性一致性验证的算法介绍 7 | 3. 线性一致性测试的框架:主要介绍使用线性一致性测试框架 jepsen 的基本原理和使用 8 | 9 | 开篇首先先介绍线性一致性的定义、实现和应用等。 10 | 11 | ## **2. 定义** 12 | 13 | ## **2.1 什么是线性一致性?** 14 | 15 | - **Linearizable semantics** (Linearizability)(each operation appears to execute instantaneously, exactly once, at some point between its invocation and its response) 16 | 17 | - - 在一个线性一致性的系统里面,任何操作都可能在调用或者返回之间原子和瞬间执行 18 | - 线性一致性,Linearizability,也称为原子一致性(atomic consistency),强一致性(strong consistency)等 19 | - 也就是通常所说的 CAP 理论中的 C 20 | 21 | 22 | 23 | 比较模糊,下面慢慢解析,首先来看看,为什么需要线性一致性 ?还需要一个这么强的一致性 ?首先看一个简单的例子,如下图 [1]: 24 | 25 | 26 | 27 | ![img](https://pic4.zhimg.com/80/v2-df804a5179524594ba34fb9fb5991c33_720w.jpg) 28 | 29 | 30 | 31 | 1. Referee:更新比赛的最终结果,先 insert 到数据库 leader 副本,然后 Leader 再复制给两个 Follower 副本 32 | 2. Alice:从 Follower 1 中查到了最新的比赛分数 33 | 3. Bob:从 Follower 2 中确没查到最新的比赛分数,确显示比赛正在进行 34 | 35 | 如果 Alice 和 Bob 在同一个房间,各自盯着自己的手机观看比赛,Alice 刷新页面,返现 Germany 赢得比赛,然后告诉 Bob 比赛结果,而 Bob 刷新页面,确显示比赛正在进行,显然这个结果是让人不符合预期的,实际上它也不是符合线性一致性的。对 Bob 来说,他希望看到的结果应该是和 Alice 一样最新的比赛结果,而不是一个旧的结果。 36 | 37 | 上面的例子展示了非线性一致性的系统可能会返回一些不合情理的结果,这其实也反应了分布式系统中一个典型的问题 ? 38 | 39 | > 分布式系统通常会是多个副本,那么多个副本复制会存在延迟 ,上图中每个副本就是一个数据库,但是看到了数据库复制中发生的一些时序问题 ,从而让外界看到多个副本的状态是不一致的,导致了一致性问题, 40 | 41 | 42 | 43 | **一致性其实主要是描述了在故障和延迟的情况下副本间的状态协调的问题** 44 | 45 | 想象如果只有一个副本?或者对应用来说,看起来就像一个副本,那么是不是就很容易。 46 | 47 | > 线性一致性的基本的想法是让一个系统看起来好像只有一个数据副本,而且所有的操作都是原子性的。有了这个保证,即使实际中可能有多个副本,应用也不需要担心它们。 48 | 49 | ## **2.2 什么样的系统是线性一致性的?** 50 | 51 | 系统需要加一些什么限制才能符合线性一致的语义呢 ? 52 | 53 | 54 | 55 | ![img](https://pic1.zhimg.com/80/v2-a8d8a539d4b262be9b962ca285076488_720w.jpg) 56 | 57 | 58 | 59 | (图 9-2,横轴为时间,每个 Op 在时间轴上的起点表示表示 Op invocation 的时间戳,结束点表示 reponse) 60 | 61 | 从 client 端角度来看,线性一致性的系统需要如下约束: 62 | 63 | - (1)单个 client Op 都是顺序的:如上图 9-2 [1] 每个 client 的每个 Op 都是在上一个 Op 返回之后,在执行下一个 Op,也就是同一个 client 的 Op 都是顺序的,没有并发 64 | - (2)不同 client 的 Op 如果并发,则可能会返回旧值或新值:如上图 9-2,client B 的第一个 read 和 client C 的第1 个 write 是并发,那么 client B 有可能读到 read 0 或者 1 都是合法,因为它们是并发的,考虑到网络延迟,可能 read 先被执行,也可能 write 先被执行,或者相反(但是这条约束不足以完全描述线性一致性:如果与写入同时发生的读取可以返回旧值或新值,那么读者可能会在写入期间看到数值在旧值和新值之间来回翻转,如果 client B 的第1个 read 返回 0,第 2 个 read 返回 1,那么就出现新旧值的翻转了) 65 | - (3)**任何一个读取返回新值后,所有后续读取(在相同或其他客户端上)也必须返回新值**:如下图 9-3 [1] 所示,client A 第 2 个 read、 client B 的第 1、2 个 read 和 client C 的第 1 个 write 是并发的,但是一旦 client A 的第 2 个 read 看到了这个最新值,那么 client B 的第 2 个 read 也必须看到这个最新的值 66 | 67 | 68 | 69 | ![img](https://pic2.zhimg.com/80/v2-5a509eb1013846047630a93d2fe22701_720w.jpg) 70 | 71 | 其实约束的第(3)条就是所谓的: 72 | 73 | > **Linearizable semantics** (Linearizability)(each operation appears to execute instantaneously, exactly once, at some point between its invocation and its response). 74 | > 在一个线性一致性的系统里面,任何操作都可能在**调用和返回之间原子和瞬间**执行。 75 | 76 | 77 | 78 | 也就是说,由于网络延迟的原因,所以在 client 端看来,一个 Op 是在 invocation 和 response 之间被执行,具体在这之间的某个时刻被执行,并不知道,但是如果这个 Op 在系统后端是在 invocation 和 response 之间的**特定时刻原子和瞬间**执行或者生效的,那么**任何一个读取返回新值后,所有后续读取(在相同或其他客户端上)也必须返回新值**,如下图 9-4,给出了每个 Op 原子或者瞬间执行的时间点就很清晰了 79 | 80 | 81 | 82 | ![img](https://pic4.zhimg.com/80/v2-179960ae9adf50c618c18f4217f2a40f_720w.jpg) 83 | 84 | (注意:上图中 Client B read(x) => 2 不是线性一致性的) 85 | 86 | 87 | 88 | **小结** 89 | 90 | - 原子和瞬间的被执行,一旦执行成功,对**所有**的 client 可见 91 | - 让多个副本,对应用来说看起来就像一个副本 92 | - 线性一致性保证的是对单个对象的单个操作的保证瞬间或者原子的被执行,注意是事务 ACID 语义的区分,事务 ACID 语义是保证的一组操作(单个对象或者多个对象操作) 93 | - 线性一致性并不局限在分布式系统,例如:在多核 CPU 中,如普通变量的 counter 就不是线性一致的,只有 atomic 变量才是线性一致的;还有很多如 queue、mutex 一般都必须满足线性化语义 94 | 95 | ## **3. 实现** 96 | 97 | **如何实现线性一致性系统 ?** 98 | 99 | 现在最成熟的就是共识算法(Consensus Algorithm),例如 Paxos、Raft 等 100 | 101 | **Raft** 102 | 103 | 这里将讨论 Raft 是否是线性一致的 ?以及其大致是如何实现线性一致的,这里不会过多的讨论 Raft 的细节,详细的细节可以参考 [2]。后续考虑给出单独的文章讨论 Raft 算法 104 | 105 | - 线性一致性写 106 | 107 | 所有的 read/write 都会来到 Leader,write 会有 Op log Leader 被序列化,依次顺序往后 commit,并 apply 然后在返回,那么一旦一个 write 被 committed,那么其前面的 write 的 Op log 一定就被 committed 了。 所有的 write 都是有严格的顺序的,一旦被 committed 就可见了,所以 Raft 是线性一致性写,基本是没有什么问题的 108 | 109 | - 线性一致性读 110 | 111 | Raft 的 read 有多种实现: 112 | 113 | 1. Raft log read:每个 read 都有一个对应的 Op log,和 write 一样,都会走一遍一致性协议的流程,会在此 Read Op log 被 Apply 的时候读,那么这个 read Op log 之前的 write Op log 肯定也被 applied 了,那么一定能够被读取到,读到的也一定是最新的 114 | 2. ReadIndex:我们知道 Raft log read,会有 raft read log 的复制和提交的开销,所以出现了 ReadIndex,read 没有 Op log,但是需要额外的机制保证读到最新的,所以 read 发送给 Leader 的时候,1)它需要确认 read 返回的数据的那个点 ?必须返回最新 committed 的结果,但是一个节点刚当选 Leader 的时候并不知道最新的 committed index,这个时候需要提交一个 Noop log entry 来提交之前的 log entry,然后开始 Read;2)它需要确认当前的 Leader 是不是还是 Leader,因为可能因为网络分区,这个 Leader 已经被孤立了,所以 Leader 在返回 read 之前,先和 Replica-group 的其他成员发送 heartbeat 确定自己 Leader 的身份;通过上述两条保证读到最新被 committed 的数据 115 | 3. Lease read:主要是通过 lease 机制维护 Leader 的状态,来减少了 ReadIndex 每次 read 发送 heartheat 的开销,详细参考 [3] 116 | 4. Follower read:先去 Leader 查询最新的 committed index,然后拿着 committed Index 去 Follower read,从而保证能从 Follower 中读到最新的数据,当前 etcd 就实现了 Follower read 117 | 118 | - 来看一个反例吧: 119 | 120 | 假设,对于实现 2. ReadIndex 方法实现,刚选出的 Leader 不通过提交 noop 来获取最新的 committed index,就返回读的话,会存在读取新旧 value 反转的情况: 121 | 122 | 1. 初始状态,三个副本 A,B,C,A 为 leader,B、C 为 Follower 123 | 2. client 发送`w1` 发送给 A,A committed w1,并且 apply 了,但是 B、C 还没有 committed `w1` 124 | 3. client 发送`r1` ,那么自然能够读到最新的写入 `w1` 125 | 4. Leader A 挂了,注意这个时候 B 和 C 还没有收到 A `w1` 的 committed 消息 126 | 5. B,C超时选举,假定 B 成为 Leader,显然 B 并不知道当前的最新 committed index 是多少 127 | 6. client 发送 `r2`,那么 B 就不一定能返回 `w1` 的结果了 128 | 129 | 上面的过程展示了 client 两次读 `r1` 和 `r2` 新旧反转的过程,是不满足线性一致的。所以对于刚起来的 Leader 来说,必须通过提交 noop 来提交已经被 commit,但是自己可能不知道的 log entry 来保证能读到最新的值 130 | 131 | ## **4. 应用** 132 | 133 | 线性一致性的系统有什么用呢 ? 134 | 135 | - Leader 选举,一般通过分布式锁来实现,那么,必须保证这个分布式锁必须是满足线性一致性语义:也就是一旦某个 节点成为了 Leader,**其它节点的选举就必须返回失败**,这样的结果才是一致的,不然就会出现双主,就不合法了 136 | - 对应用来说更简单:例如,设计和实现了一个块存储,不满足线性一致性,假定不能线性一致性读,那么在此块存储之上做其他的应用将很难,例如在这个块存储上面跑数据库,数据库就几乎不能做正确 137 | 138 | ## **5. 代价** 139 | 140 | 线性一致性,是一种强一致性,线性一致性的系统虽好,但是实现是有代价的 141 | 142 | **原子数** 143 | 144 | 例如:在多核系统中,由于 CPU Cache 的原因,一个普通的变量,例如定义 `int count = 0`,可以对其执行 `get`(read)、`increment`(自增) 等操作,显然这个变量 `count` 不是满足线性一致性,例如 `increment` 不是原子的,`get` 可能返回一个旧的值等等。有两种方式让变量 `count` 满足线性一致性: 145 | 146 | 1. 加锁 `mutex`保护 147 | 2. 使用原子变量 148 | 149 | 那么自然,性能也就下降了。 150 | 151 | **不总是需要线性一致** 152 | 153 | 所以在一些系统中,如果不需要线性一致性,那么可以做一些权衡,以 Figure 9-1 为例,用户可以接受一定的 read 延迟,只要不会出现新旧 value 反转即可,那么一个更弱的一致性 **单调读(Monotonic reads)**来避免时光倒流也是可以的,那么系统实现 read/write 就相对来说就简单些了: 154 | 155 | 以 read 为例,就不要向 Raft 一样总是去 Leader 读,只需要保证每次 read 都在相同的副本,就不会有任何问题,甚至对于新闻这种数据来说,偶尔出现一两次时光倒流也不会有什么不可饶恕的大问题 。 156 | 157 | ## **6. CAP** 158 | 159 | 既然聊到了 CAP 中的 C,那么也来聊聊 CAP 理论,这里以块存储场景为例,以常规的一致性思路来简单谈谈: 160 | 161 | - CAP,P - 网络分区是一种错误,不是一个选项,一定会发生,所以,就是 CA 二选一 162 | - 块存储,需要线性一致性(面向上层所有应用,没有tradeoff,必须线性一致性),所以就是 C 也没得选,就剩下 A 了 163 | - 但是在网络分区的场景下,如果要保证线性一致性C,那么可用性A必然就会成为问题,尽管出现的概率极低(云盘厂商一般提供99.999% 5个9的可用性保障,这个对绝大多书应用场景而言,非常高了) 164 | 165 | 166 | 167 | 在确定CA的基础之上,系统设计应该关注一下2点,优秀的系统设计和实现会在一下2点上下足功夫,提供业界一流的云盘: 168 | 169 | - 提高可用性 170 | 171 | - - 在极端网络分区下,为保证线性一致性,只能牺牲可用性,网络分成三个区,{A},{B},{C},那么就没办法提供服务了 172 | - 在一些非极端的网络分区情况下,例如 Raft 中在非对称和对称网络分区的情况下,通过 pre-vote 等提高可用性 173 | - 在大多数副本下线的情况下,而且场景允许,可以使用重置复制组提高可用性 174 | - 甚至通过一些结合客户端的机制,hinted handoff 重开复制组的机制尽最大努力保证读取和写入的可用性 175 | - 等等 176 | 177 | 178 | 179 | - 提高性能 180 | 181 | - - 线性一致性系统确实比较慢,这是一个事实,所以提高性能是如此的重要,Raft 性能优化,batch,pipeline,async commit & apply 各种提高性能的手段一起上 [4],paxos 性能优化 [5],paxos 变种 multi-paxos [6] 等等都是在为提高性能作出努力 182 | - 等等 183 | 184 | 185 | 186 | **Notes** 187 | 188 | 作者:网易存储团队攻城狮 吴德妙 189 | 190 | 如有理解和描述上有疏漏或者错误的地方,欢迎共同交流;参考已经在参考文献中注明,但仍有可能有疏漏的地方,有任何侵权或者不明确的地方,欢迎指出,必定及时更正或者删除;文章供于学习交流,转载注明出处 -------------------------------------------------------------------------------- /blog/transaction/type.md: -------------------------------------------------------------------------------- 1 | ## 分布式事务 2 | 3 | ## 什么是分布式事务 4 | 5 | 分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。 6 | 7 | ## 分布式事务产生的原因 8 | 9 | 从上面本地事务来看,我们可以看为两块,一个是service产生多个节点,另一个是resource产生多个节点。 10 | 11 | ## service多个节点 12 | 13 | 随着互联网快速发展,微服务,SOA等服务架构模式正在被大规模的使用,举个简单的例子,一个公司之内,用户的资产可能分为好多个部分,比如余额,积分,优惠券等等。在公司内部有可能积分功能由一个微服务团队维护,优惠券又是另外的团队维护 14 | 15 | ![img](https://pic4.zhimg.com/80/v2-2225f188ff48e0ad262fd7fd80c99a67_720w.jpg) 16 | 17 | 这样的话就无法保证积分扣减了之后,优惠券能否扣减成功。 18 | 19 | ## resource多个节点 20 | 21 | 同样的,互联网发展得太快了,我们的Mysql一般来说装千万级的数据就得进行分库分表,对于一个支付宝的转账业务来说,你给的朋友转钱,有可能你的数据库是在北京,而你的朋友的钱是存在上海,所以我们依然无法保证他们能同时成功。 22 | 23 | ![img](https://pic2.zhimg.com/80/v2-ffae01dfc1668fe5e7cee463f26b67b9_720w.jpg) 24 | 25 | 26 | 27 | ## 分布式事务的基础 28 | 29 | 从上面来看分布式事务是随着互联网高速发展应运而生的,这是一个必然的我们之前说过数据库的ACID四大特性,已经无法满足我们分布式事务,这个时候又有一些新的大佬提出一些新的理论: 30 | 31 | ## CAP 32 | 33 | CAP定理,又被叫作布鲁尔定理。对于设计分布式系统来说(不仅仅是分布式事务)的架构师来说,CAP就是你的入门理论。 34 | 35 | - C (一致性):对某个指定的客户端来说,读操作能返回最新的写操作。对于数据分布在不同节点上的数据上来说,如果在某个节点更新了数据,那么在其他节点如果都能读取到这个最新的数据,那么就称为强一致,如果有某个节点没有读取到,那就是分布式不一致。 36 | - A (可用性):非故障的节点在合理的时间内返回合理的响应(不是错误和超时的响应)。可用性的两个关键一个是合理的时间,一个是合理的响应。合理的时间指的是请求不能无限被阻塞,应该在合理的时间给出返回。合理的响应指的是系统应该明确返回结果并且结果是正确的,这里的正确指的是比如应该返回50,而不是返回40。 37 | - P (分区容错性):当出现网络分区后,系统能够继续工作。打个比方,这里个集群有多台机器,有台机器网络出现了问题,但是这个集群仍然可以正常工作。 38 | 39 | 熟悉CAP的人都知道,三者不能共有,如果感兴趣可以搜索CAP的证明,在分布式系统中,网络无法100%可靠,分区其实是一个必然现象,如果我们选择了CA而放弃了P,那么当发生分区现象时,为了保证一致性,这个时候必须拒绝请求,但是A又不允许,所以分布式系统理论上不可能选择CA架构,只能选择CP或者AP架构。 40 | 41 | 对于CP来说,放弃可用性,追求一致性和分区容错性,我们的zookeeper其实就是追求的强一致。 42 | 43 | 对于AP来说,放弃一致性(这里说的一致性是强一致性),追求分区容错性和可用性,这是很多分布式系统设计时的选择,后面的BASE也是根据AP来扩展。 44 | 45 | 顺便一提,CAP理论中是忽略网络延迟,也就是当事务提交时,从节点A复制到节点B,但是在现实中这个是明显不可能的,所以总会有一定的时间是不一致。同时CAP中选择两个,比如你选择了CP,并不是叫你放弃A。因为P出现的概率实在是太小了,大部分的时间你仍然需要保证CA。就算分区出现了你也要为后来的A做准备,比如通过一些日志的手段,是其他机器回复至可用。 46 | 47 | ## BASE 48 | 49 | BASE 是 Basically Available(基本可用)、Soft state(软状态)和 Eventually consistent (最终一致性)三个短语的缩写。是对CAP中AP的一个扩展 50 | 51 | 1. 基本可用:分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。 52 | 2. 软状态:允许系统中存在中间状态,这个状态不影响系统可用性,这里指的是CAP中的不一致。 53 | 3. 最终一致:最终一致是指经过一段时间后,所有节点数据都将会达到一致。 54 | 55 | BASE解决了CAP中理论没有网络延迟,在BASE中用软状态和最终一致,保证了延迟后的一致性。BASE和 ACID 是相反的,它完全不同于ACID的强一致性模型,而是通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态。 56 | 57 | ## 分布式事务解决方案 58 | 59 | 有了上面的理论基础后,这里介绍开始介绍几种常见的分布式事务的解决方案。 60 | 61 | ## 是否真的要分布式事务 62 | 63 | 在说方案之前,首先你一定要明确你是否真的需要分布式事务? 64 | 65 | 上面说过出现分布式事务的两个原因,其中有个原因是因为微服务过多。我见过太多团队一个人维护几个微服务,太多团队过度设计,搞得所有人疲劳不堪,而微服务过多就会引出分布式事务,这个时候我不会建议你去采用下面任何一种方案,而是请把需要事务的微服务聚合成一个单机服务,使用数据库的本地事务。因为不论任何一种方案都会增加你系统的复杂度,这样的成本实在是太高了,千万不要因为追求某些设计,而引入不必要的成本和复杂度。 66 | 67 | 如果你确定需要引入分布式事务可以看看下面几种常见的方案。 68 | 69 | ## 2PC 70 | 71 | 说到2PC就不得不聊数据库分布式事务中的 XA Transactions。 72 | 73 | ![img](https://pic1.zhimg.com/80/v2-f296cc98eb448a9abc0869acb1af2534_720w.jpg) 74 | 75 | 在XA协议中分为两阶段: 76 | 77 | 第一阶段:事务管理器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提交. 78 | 79 | 第二阶段:事务协调器要求每个数据库提交数据,或者回滚数据。 80 | 81 | 优点: 尽量保证了数据的强一致,实现成本较低,在各大主流数据库都有自己实现,对于MySQL是从5.5开始支持。 82 | 83 | 缺点: 84 | 85 | - 单点问题:事务管理器在整个流程中扮演的角色很关键,如果其宕机,比如在第一阶段已经完成,在第二阶段正准备提交的时候事务管理器宕机,资源管理器就会一直阻塞,导致数据库无法使用。 86 | - 同步阻塞:在准备就绪之后,资源管理器中的资源一直处于阻塞,直到提交完成,释放资源。 87 | - 数据不一致:两阶段提交协议虽然为分布式数据强一致性所设计,但仍然存在数据不一致性的可能,比如在第二阶段中,假设协调者发出了事务commit的通知,但是因为网络问题该通知仅被一部分参与者所收到并执行了commit操作,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性。 88 | 89 | 总的来说,XA协议比较简单,成本较低,但是其单点问题,以及不能支持高并发(由于同步阻塞)依然是其最大的弱点。 90 | 91 | ## TCC 92 | 93 | 关于TCC(Try-Confirm-Cancel)的概念,最早是由Pat Helland于2007年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。 TCC事务机制相比于上面介绍的XA,解决了其几个缺点: 1.解决了协调者单点,由主业务方发起并完成这个业务活动。业务活动管理器也变成多点,引入集群。 2.同步阻塞:引入超时,超时后进行补偿,并且不会锁定整个资源,将资源转换为业务逻辑形式,粒度变小。 3.数据一致性,有了补偿机制之后,由业务活动管理器控制一致性 94 | 95 | ![img](https://pic2.zhimg.com/80/v2-68e2e27350962356d2b6659f6b9bd631_720w.jpg) 96 | 97 | 对于TCC的解释: 98 | 99 | - 100 | Try阶段:尝试执行,完成所有业务检查(一致性),预留必须业务资源(准隔离性) 101 | - 102 | Confirm阶段:确认执行真正执行业务,不作任何业务检查,只使用Try阶段预留的业务资源,Confirm操作满足幂等性。要求具备幂等设计,Confirm失败后需要进行重试。 103 | - 104 | Cancel阶段:取消执行,释放Try阶段预留的业务资源 Cancel操作满足幂等性Cancel阶段的异常和Confirm阶段异常处理方案基本上一致。 105 | 106 | 举个简单的例子如果你用100元买了一瓶水, Try阶段:你需要向你的钱包检查是否够100元并锁住这100元,水也是一样的。 107 | 108 | 如果有一个失败,则进行cancel(释放这100元和这一瓶水),如果cancel失败不论什么失败都进行重试cancel,所以需要保持幂等。 109 | 110 | 如果都成功,则进行confirm,确认这100元扣,和这一瓶水被卖,如果confirm失败无论什么失败则重试(会依靠活动日志进行重试) 111 | 112 | 对于TCC来说适合一些: 113 | 114 | - 强隔离性,严格一致性要求的活动业务。 115 | - 执行时间较短的业务 116 | 117 | 实现参考:ByteTCC:[https://github.com/liuyangming/ByteTCC/](https://link.zhihu.com/?target=https%3A//github.com/liuyangming/ByteTCC/) 118 | 119 | ## 本地消息表 120 | 121 | 本地消息表这个方案最初是ebay提出的 ebay的完整方案[https://queue.acm.org/detail.cfm?id=1394128](https://link.zhihu.com/?target=https%3A//queue.acm.org/detail.cfm%3Fid%3D1394128)。 122 | 123 | 此方案的核心是将需要分布式处理的任务通过消息日志的方式来异步执行。消息日志可以存储到本地文本、数据库或消息队列,再通过业务规则自动或人工发起重试。人工重试更多的是应用于支付场景,通过对账系统对事后问题的处理。 124 | 125 | ![img](https://pic1.zhimg.com/80/v2-d811b942853f7021df21049db7478468_720w.jpg) 126 | 127 | 128 | 129 | 对于本地消息队列来说核心是把大事务转变为小事务。还是举上面用100元去买一瓶水的例子。 130 | 131 | 1.当你扣钱的时候,你需要在你扣钱的服务器上新增加一个本地消息表,你需要把你扣钱和写入减去水的库存到本地消息表放入同一个事务(依靠数据库本地事务保证一致性。 132 | 133 | 2.这个时候有个定时任务去轮询这个本地事务表,把没有发送的消息,扔给商品库存服务器,叫他减去水的库存,到达商品服务器之后这个时候得先写入这个服务器的事务表,然后进行扣减,扣减成功后,更新事务表中的状态。 134 | 135 | 3.商品服务器通过定时任务扫描消息表或者直接通知扣钱服务器,扣钱服务器本地消息表进行状态更新。 136 | 137 | 4.针对一些异常情况,定时扫描未成功处理的消息,进行重新发送,在商品服务器接到消息之后,首先判断是否是重复的,如果已经接收,在判断是否执行,如果执行在马上又进行通知事务,如果未执行,需要重新执行需要由业务保证幂等,也就是不会多扣一瓶水。 138 | 139 | 本地消息队列是BASE理论,是最终一致模型,适用于对一致性要求不高的。实现这个模型时需要注意重试的幂等。 140 | 141 | ## MQ事务 142 | 143 | 在RocketMQ中实现了分布式事务,实际上其实是对本地消息表的一个封装,将本地消息表移动到了MQ内部,下面简单介绍一下MQ事务,如果想对其详细了解可以参考: [https://www.jianshu.com/p/453c6e7ff81c](https://link.zhihu.com/?target=https%3A//www.jianshu.com/p/453c6e7ff81c)。 144 | 145 | ![img](https://pic1.zhimg.com/80/v2-a0caca38ef3d173dc3e831274c0f2614_720w.jpg) 146 | 147 | 基本流程如下: 第一阶段Prepared消息,会拿到消息的地址。 148 | 149 | 第二阶段执行本地事务。 150 | 151 | 第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。消息接受者就能使用这个消息。 152 | 153 | 如果确认消息失败,在RocketMq Broker中提供了定时扫描没有更新状态的消息,如果有消息没有得到确认,会向消息发送者发送消息,来判断是否提交,在rocketmq中是以listener的形式给发送者,用来处理。 154 | 155 | ![img](https://pic2.zhimg.com/80/v2-421082c966f10bf51c0748fdae2a06a9_720w.jpg) 156 | 157 | 如果消费超时,则需要一直重试,消息接收端需要保证幂等。如果消息消费失败,这个就需要人工进行处理,因为这个概率较低,如果为了这种小概率时间而设计这个复杂的流程反而得不偿失 158 | 159 | ## Saga事务 160 | 161 | Saga是30年前一篇数据库伦理提到的一个概念。其核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。 Saga的组成: 162 | 163 | 每个Saga由一系列sub-transaction Ti 组成 每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果,这里的每个T,都是一个本地事务。 可以看到,和TCC相比,Saga没有“预留 try”动作,它的Ti就是直接提交到库。 164 | 165 | Saga的执行顺序有两种: 166 | 167 | T1, T2, T3, ..., Tn 168 | 169 | T1, T2, ..., Tj, Cj,..., C2, C1,其中0 < j < n Saga定义了两种恢复策略: 170 | 171 | 向后恢复,即上面提到的第二种执行顺序,其中j是发生错误的sub-transaction,这种做法的效果是撤销掉之前所有成功的sub-transation,使得整个Saga的执行结果撤销。 向前恢复,适用于必须要成功的场景,执行顺序是类似于这样的:T1, T2, ..., Tj(失败), Tj(重试),..., Tn,其中j是发生错误的sub-transaction。该情况下不需要Ci。 172 | 173 | 这里要注意的是,在saga模式中不能保证隔离性,因为没有锁住资源,其他事务依然可以覆盖或者影响当前事务。 174 | 175 | 还是拿100元买一瓶水的例子来说,这里定义 176 | 177 | T1=扣100元 T2=给用户加一瓶水 T3=减库存一瓶水 178 | 179 | C1=加100元 C2=给用户减一瓶水 C3=给库存加一瓶水 180 | 181 | 我们一次进行T1,T2,T3如果发生问题,就执行发生问题的C操作的反向。 上面说到的隔离性的问题会出现在,如果执行到T3这个时候需要执行回滚,但是这个用户已经把水喝了(另外一个事务),回滚的时候就会发现,无法给用户减一瓶水了。这就是事务之间没有隔离性的问题 182 | 183 | 可以看见saga模式没有隔离性的影响还是较大,可以参照华为的解决方案:从业务层面入手加入一 Session 以及锁的机制来保证能够串行化操作资源。也可以在业务层面通过预先冻结资金的方式隔离这部分资源, 最后在业务操作的过程中可以通过及时读取当前状态的方式获取到最新的更新。 184 | 185 | 具体实例:可以参考华为的servicecomb 186 | 187 | ## 最后 188 | 189 | 还是那句话,能不用分布式事务就不用,如果非得使用的话,结合自己的业务分析,看看自己的业务比较适合哪一种,是在乎强一致,还是最终一致即可。最后在总结一些问题,大家可以下来自己从文章找寻答案: 190 | 191 | 1. ACID和CAP的 CA是一样的吗? 192 | 2. 分布式事务常用的解决方案的优缺点是什么?适用于什么场景? 193 | 3. 分布式事务出现的原因?用来解决什么痛点? 194 | 195 | 如果喜欢我们的文章的话,请加我扣扣,来和我一起讨论学习吧。 -------------------------------------------------------------------------------- /doc/jraft/basic/Untitled.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hzh0425/hy_distribute_theory_basic/80ffc1bfa0ca2856ecdafd46489a036994414894/doc/jraft/basic/Untitled.md -------------------------------------------------------------------------------- /doc/jraft/basic/lab1-定时调度时间轮算法.md: -------------------------------------------------------------------------------- 1 | ### 算法简介 2 | 3 | ​ ![img](https://gitee.com/zisuu/picture/raw/master/img/20210223171914.png;charset=UTF-8) 4 | 5 | 网上盗个图,时间轮算法可以通过上图来描述。假设时间轮大小为8,1s转一格,每格指向一个链表,保存着待执行的任务。 6 | 7 | 假设,当前位于2,现在要添加一个3s后指向的任务,则2+3=5,在第5格的链表中添加一个节点指向任务即可,标识round=0。 8 | 9 | 假设,当前位于2,现在要添加一个10s后指向的任务,则(2+10)% 8 = 4,则在第4格添加一个节点指向任务,并标识round=1,则当时间轮第二次经过第4格时,即会执行任务。 10 | 11 | 时间轮只会执行round=0的任务,并会把该格子上的其他任务的round减1。 12 | 13 | 算法的原理非常浅显易懂,但是阅读源码实现还是有益的。 14 | 15 | 对于介绍RepeatedTimer,我拿Node初始化的时候的electionTimer进行讲解 16 | 17 | ```java 18 | Copythis.electionTimer = new RepeatedTimer("JRaft-ElectionTimer", this.options.getElectionTimeoutMs()) { 19 | 20 | @Override 21 | protected void onTrigger() { 22 | handleElectionTimeout(); 23 | } 24 | 25 | @Override 26 | protected int adjustTimeout(final int timeoutMs) { 27 | //在一定范围内返回一个随机的时间戳 28 | //为了避免同时发起选举而导致失败 29 | return randomTimeout(timeoutMs); 30 | } 31 | }; 32 | ``` 33 | 34 | ### 构造器[#](https://www.cnblogs.com/luozhiyun/p/11706171.html#6662384) 35 | 36 | 由electionTimer的构造方法可以看出RepeatedTimer需要传入两个参数,一个是name,另一个是time 37 | 38 | ```java 39 | Copy//timer是HashedWheelTimer 40 | private final Timer timer; 41 | //实例是HashedWheelTimeout 42 | private Timeout timeout; 43 | 44 | public RepeatedTimer(String name, int timeoutMs) { 45 | //name代表RepeatedTimer实例的种类,timeoutMs是超时时间 46 | this(name, timeoutMs, new HashedWheelTimer(new NamedThreadFactory(name, true), 1, TimeUnit.MILLISECONDS, 2048)); 47 | } 48 | 49 | public RepeatedTimer(String name, int timeoutMs, Timer timer) { 50 | super(); 51 | this.name = name; 52 | this.timeoutMs = timeoutMs; 53 | this.stopped = true; 54 | this.timer = Requires.requireNonNull(timer, "timer"); 55 | } 56 | ``` 57 | 58 | 在构造器中会根据传进来的值初始化一个name和一个timeoutMs,然后实例化一个timer,RepeatedTimer的run方法是由timer进行回调。在RepeatedTimer中会持有两个对象,一个是timer,一个是timeout 59 | 60 | ### 启动RepeatedTimer[#](https://www.cnblogs.com/luozhiyun/p/11706171.html#4092675847) 61 | 62 | 对于一个RepeatedTimer实例,我们可以通过start方法来启动它: 63 | 64 | ```java 65 | Copypublic void start() { 66 | //加锁,只能一个线程调用这个方法 67 | this.lock.lock(); 68 | try { 69 | //destroyed默认是false 70 | if (this.destroyed) { 71 | return; 72 | } 73 | //stopped在构造器中初始化为ture 74 | if (!this.stopped) { 75 | return; 76 | } 77 | //启动完一次后下次就无法再次往下继续 78 | this.stopped = false; 79 | //running默认为false 80 | if (this.running) { 81 | return; 82 | } 83 | this.running = true; 84 | schedule(); 85 | } finally { 86 | this.lock.unlock(); 87 | } 88 | } 89 | ``` 90 | 91 | 在调用start方法进行启动后会进行一系列的校验和赋值,从上面的赋值以及加锁的情况来看,这个是只能被调用一次的。然后会调用到schedule方法中 92 | 93 | ```java 94 | Copyprivate void schedule() { 95 | if(this.timeout != null) { 96 | this.timeout.cancel(); 97 | } 98 | final TimerTask timerTask = timeout -> { 99 | try { 100 | RepeatedTimer.this.run(); 101 | } catch (final Throwable t) { 102 | LOG.error("Run timer task failed, taskName={}.", RepeatedTimer.this.name, t); 103 | } 104 | }; 105 | this.timeout = this.timer.newTimeout(timerTask, adjustTimeout(this.timeoutMs), TimeUnit.MILLISECONDS); 106 | } 107 | ``` 108 | 109 | 如果timeout不为空,那么会调用HashedWheelTimeout的cancel方法。然后封装一个TimerTask实例,当执行TimerTask的run方法的时候会调用RepeatedTimer实例的run方法。然后传入到timer中,TimerTask的run方法由timer进行调用,并将返回值赋值给timeout。 110 | 111 | 如果timer调用了TimerTask的run方法,那么便会回调到RepeatedTimer的run方法中: 112 | **RepeatedTimer#run** 113 | 114 | ```java 115 | Copypublic void run() { 116 | //加锁 117 | this.lock.lock(); 118 | try { 119 | //表示RepeatedTimer已经被调用过 120 | this.invoking = true; 121 | } finally { 122 | this.lock.unlock(); 123 | } 124 | try { 125 | //然后会调用RepeatedTimer实例实现的方法 126 | onTrigger(); 127 | } catch (final Throwable t) { 128 | LOG.error("Run timer failed.", t); 129 | } 130 | boolean invokeDestroyed = false; 131 | this.lock.lock(); 132 | try { 133 | this.invoking = false; 134 | //如果调用了stop方法,那么将不会继续调用schedule方法 135 | if (this.stopped) { 136 | this.running = false; 137 | invokeDestroyed = this.destroyed; 138 | } else { 139 | this.timeout = null; 140 | schedule(); 141 | } 142 | } finally { 143 | this.lock.unlock(); 144 | } 145 | if (invokeDestroyed) { 146 | onDestroy(); 147 | } 148 | } 149 | 150 | protected void onDestroy() { 151 | // NO-OP 152 | } 153 | ``` 154 | 155 | 这个run方法会由timer进行回调,如果没有调用stop或destroy方法的话,那么调用完onTrigger方法后会继续调用schedule,然后一次次循环调用RepeatedTimer的run方法。 156 | 157 | 如果调用了destroy方法,在这里会有一个onDestroy的方法,可以由实现类override复写执行一个钩子。 158 | 159 | ### HashedWheelTimer的基本介绍[#](https://www.cnblogs.com/luozhiyun/p/11706171.html#255292319) 160 | 161 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210223171834.png) 162 | 163 | HashedWheelTimer通过一定的hash规则将不同timeout的定时任务划分到HashedWheelBucket进行管理,而HashedWheelBucket利用双向链表结构维护了某一时刻需要执行的定时任务列表 164 | 165 | #### Wheel[#](https://www.cnblogs.com/luozhiyun/p/11706171.html#2680396931) 166 | 167 | 时间轮,是一个HashedWheelBucket数组,数组数量越多,定时任务管理的时间精度越精确。tick每走一格都会将对应的wheel数组里面的bucket拿出来进行调度。 168 | 169 | #### Worker[#](https://www.cnblogs.com/luozhiyun/p/11706171.html#540031737) 170 | 171 | Worker继承自Runnable,HashedWheelTimer必须通过Worker线程操作HashedWheelTimer中的定时任务。Worker是整个HashedWheelTimer的执行流程管理者,控制了定时任务分配、全局deadline时间计算、管理未执行的定时任务、时钟计算、未执行定时任务回收处理。 172 | 173 | #### HashedWheelTimeout[#](https://www.cnblogs.com/luozhiyun/p/11706171.html#3019705536) 174 | 175 | 是HashedWheelTimer的执行单位,维护了其所属的HashedWheelTimer和HashedWheelBucket的引用、需要执行的任务逻辑、当前轮次以及当前任务的超时时间(不变)等,可以认为是自定义任务的一层Wrapper。 176 | 177 | #### HashedWheelBucket[#](https://www.cnblogs.com/luozhiyun/p/11706171.html#3135917057) 178 | 179 | HashedWheelBucket维护了hash到其内的所有HashedWheelTimeout结构,是一个双向队列。 180 | 181 | ### HashedWheelTimer的构造器[#](https://www.cnblogs.com/luozhiyun/p/11706171.html#2193283699) 182 | 183 | 在初始化RepeatedTimer实例的时候会实例化一个HashedWheelTimer: 184 | 185 | ```java 186 | Copynew HashedWheelTimer(new NamedThreadFactory(name, true), 1, TimeUnit.MILLISECONDS, 2048) 187 | ``` 188 | 189 | 然后调用HashedWheelTimer的构造器: 190 | 191 | ```java 192 | Copy 193 | private final HashedWheelBucket[] wheel; 194 | private final int mask; 195 | private final long tickDuration; 196 | private final Worker worker = new Worker(); 197 | private final Thread workerThread; 198 | private final long maxPendingTimeouts; 199 | private static final int INSTANCE_COUNT_LIMIT = 256; 200 | private static final AtomicInteger instanceCounter = new AtomicInteger(); 201 | private static final AtomicBoolean warnedTooManyInstances = new AtomicBoolean(); 202 | 203 | 204 | public HashedWheelTimer(ThreadFactory threadFactory, long tickDuration, TimeUnit unit, int ticksPerWheel) { 205 | tickDuration 206 | this(threadFactory, tickDuration, unit, ticksPerWheel, -1); 207 | } 208 | 209 | public HashedWheelTimer(ThreadFactory threadFactory, long tickDuration, TimeUnit unit, int ticksPerWheel, 210 | long maxPendingTimeouts) { 211 | 212 | if (threadFactory == null) { 213 | throw new NullPointerException("threadFactory"); 214 | } 215 | //unit = MILLISECONDS 216 | if (unit == null) { 217 | throw new NullPointerException("unit"); 218 | } 219 | if (tickDuration <= 0) { 220 | throw new IllegalArgumentException("tickDuration must be greater than 0: " + tickDuration); 221 | } 222 | if (ticksPerWheel <= 0) { 223 | throw new IllegalArgumentException("ticksPerWheel must be greater than 0: " + ticksPerWheel); 224 | } 225 | 226 | // Normalize ticksPerWheel to power of two and initialize the wheel. 227 | // 创建一个HashedWheelBucket数组 228 | // 创建时间轮基本的数据结构,一个数组。长度为不小于ticksPerWheel的最小2的n次方 229 | wheel = createWheel(ticksPerWheel); 230 | // 这是一个标示符,用来快速计算任务应该呆的格子。 231 | // 我们知道,给定一个deadline的定时任务,其应该呆的格子=deadline%wheel.length.但是%操作是个相对耗时的操作,所以使用一种变通的位运算代替: 232 | // 因为一圈的长度为2的n次方,mask = 2^n-1后低位将全部是1,然后deadline&mast == deadline%wheel.length 233 | // java中的HashMap在进行hash之后,进行index的hash寻址寻址的算法也是和这个一样的 234 | mask = wheel.length - 1; 235 | 236 | // Convert tickDuration to nanos. 237 | //tickDuration传入是1的话,这里会转换成1000000 238 | this.tickDuration = unit.toNanos(tickDuration); 239 | 240 | // Prevent overflow. 241 | // 校验是否存在溢出。即指针转动的时间间隔不能太长而导致tickDuration*wheel.length>Long.MAX_VALUE 242 | if (this.tickDuration >= Long.MAX_VALUE / wheel.length) { 243 | throw new IllegalArgumentException(String.format( 244 | "tickDuration: %d (expected: 0 < tickDuration in nanos < %d", tickDuration, Long.MAX_VALUE 245 | / wheel.length)); 246 | } 247 | //将worker包装成thread 248 | workerThread = threadFactory.newThread(worker); 249 | //maxPendingTimeouts = -1 250 | this.maxPendingTimeouts = maxPendingTimeouts; 251 | 252 | //如果HashedWheelTimer实例太多,那么就会打印一个error日志 253 | if (instanceCounter.incrementAndGet() > INSTANCE_COUNT_LIMIT 254 | && warnedTooManyInstances.compareAndSet(false, true)) { 255 | reportTooManyInstances(); 256 | } 257 | } 258 | ``` 259 | 260 | 这个构造器里面主要做一些初始化的工作。 261 | 262 | 1. 初始化一个wheel数据,我们这里初始化的数组长度为2048. 263 | 2. 初始化mask,用来计算槽位的下标,类似于hashmap的槽位的算法,因为wheel的长度已经是一个2的n次方,所以2^n-1后低位将全部是1,用&可以快速的定位槽位,比%耗时更低 264 | 3. 初始化tickDuration,这里会将传入的tickDuration转化成纳秒,那么这里是1000,000 265 | 4. 校验整个时间轮走完的时间不能过长 266 | 5. 包装worker线程 267 | 6. 因为HashedWheelTimer是一个很消耗资源的一个结构,所以校验HashedWheelTimer实例不能太多,如果太多会打印error日志 268 | 269 | ### 启动timer[#](https://www.cnblogs.com/luozhiyun/p/11706171.html#3521625645) 270 | 271 | 时间轮算法中并不需要手动的去调用start方法来启动,而是在添加节点的时候会启动时间轮。 272 | 273 | 我们在RepeatedTimer的schedule方法里会调用newTimeout向时间轮中添加一个任务。 274 | 275 | **HashedWheelTimer#newTimeout** 276 | 277 | ```java 278 | Copypublic Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) { 279 | if (task == null) { 280 | throw new NullPointerException("task"); 281 | } 282 | if (unit == null) { 283 | throw new NullPointerException("unit"); 284 | } 285 | 286 | long pendingTimeoutsCount = pendingTimeouts.incrementAndGet(); 287 | 288 | if (maxPendingTimeouts > 0 && pendingTimeoutsCount > maxPendingTimeouts) { 289 | pendingTimeouts.decrementAndGet(); 290 | throw new RejectedExecutionException("Number of pending timeouts (" + pendingTimeoutsCount 291 | + ") is greater than or equal to maximum allowed pending " 292 | + "timeouts (" + maxPendingTimeouts + ")"); 293 | } 294 | // 如果时间轮没有启动,则启动 295 | start(); 296 | 297 | // Add the timeout to the timeout queue which will be processed on the next tick. 298 | // During processing all the queued HashedWheelTimeouts will be added to the correct HashedWheelBucket. 299 | long deadline = System.nanoTime() + unit.toNanos(delay) - startTime; 300 | 301 | // Guard against overflow. 302 | //在delay为正数的情况下,deadline是不可能为负数 303 | //如果为负数,那么说明超过了long的最大值 304 | if (delay > 0 && deadline < 0) { 305 | deadline = Long.MAX_VALUE; 306 | } 307 | // 这里定时任务不是直接加到对应的格子中,而是先加入到一个队列里,然后等到下一个tick的时候, 308 | // 会从队列里取出最多100000个任务加入到指定的格子中 309 | HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline); 310 | //Worker会去处理timeouts队列里面的数据 311 | timeouts.add(timeout); 312 | return timeout; 313 | } 314 | ``` 315 | 316 | 在这个方法中,在校验之后会调用start方法启动时间轮,然后设置deadline,这个时间等于时间轮启动的时间点+延迟的的时间; 317 | 然后新建一个HashedWheelTimeout实例,会直接加入到timeouts队列中去,timeouts对列会在worker的run方法里面取出来放入到wheel中进行处理。 318 | 319 | 然后我们来看看start方法: 320 | **HashedWheelTimer#start** 321 | 322 | ```java 323 | Copy 324 | private static final AtomicIntegerFieldUpdater workerStateUpdater = AtomicIntegerFieldUpdater.newUpdater(HashedWheelTimer.class,"workerState"); 325 | 326 | private volatile int workerState; 327 | //不需要你主动调用,当有任务添加进来的的时候他就会跑 328 | public void start() { 329 | //workerState一开始的时候是0(WORKER_STATE_INIT),然后才会设置为1(WORKER_STATE_STARTED) 330 | switch (workerStateUpdater.get(this)) { 331 | case WORKER_STATE_INIT: 332 | //使用cas来获取启动调度的权力,只有竞争到的线程允许来进行实例启动 333 | if (workerStateUpdater.compareAndSet(this, WORKER_STATE_INIT, WORKER_STATE_STARTED)) { 334 | //如果成功设置了workerState,那么就调用workerThread线程 335 | workerThread.start(); 336 | } 337 | break; 338 | case WORKER_STATE_STARTED: 339 | break; 340 | case WORKER_STATE_SHUTDOWN: 341 | throw new IllegalStateException("cannot be started once stopped"); 342 | default: 343 | throw new Error("Invalid WorkerState"); 344 | } 345 | 346 | // 等待worker线程初始化时间轮的启动时间 347 | // Wait until the startTime is initialized by the worker. 348 | while (startTime == 0) { 349 | try { 350 | //这里使用countDownLauch来确保调度的线程已经被启动 351 | startTimeInitialized.await(); 352 | } catch (InterruptedException ignore) { 353 | // Ignore - it will be ready very soon. 354 | } 355 | } 356 | } 357 | ``` 358 | 359 | 由这里我们可以看出,启动时间轮是不需要手动去调用的,而是在有任务的时候会自动运行,防止在没有任务的时候空转浪费资源。 360 | 361 | 在start方法里面会使用AtomicIntegerFieldUpdater的方式来更新workerState这个变量,如果没有启动过那么直接在cas成功之后调用start方法启动workerThread线程。 362 | 363 | 如果workerThread还没运行,那么会在while循环中等待,直到workerThread运行为止才会往下运行。 364 | 365 | ### 开始时间轮转[#](https://www.cnblogs.com/luozhiyun/p/11706171.html#2243393872) 366 | 367 | 时间轮的运转是在Worker的run方法中进行的: 368 | **Worker#run** 369 | 370 | ```java 371 | Copyprivate final Set unprocessedTimeouts = new HashSet<>(); 372 | private long tick; 373 | public void run() { 374 | // Initialize the startTime. 375 | startTime = System.nanoTime(); 376 | if (startTime == 0) { 377 | // We use 0 as an indicator for the uninitialized value here, so make sure it's not 0 when initialized. 378 | startTime = 1; 379 | } 380 | 381 | //HashedWheelTimer的start方法会继续往下运行 382 | // Notify the other threads waiting for the initialization at start(). 383 | startTimeInitialized.countDown(); 384 | 385 | do { 386 | //返回的是当前的nanoTime- startTime 387 | //也就是返回的是 每 tick 一次的时间间隔 388 | final long deadline = waitForNextTick(); 389 | if (deadline > 0) { 390 | //算出时间轮的槽位 391 | int idx = (int) (tick & mask); 392 | //移除cancelledTimeouts中的bucket 393 | // 从bucket中移除timeout 394 | processCancelledTasks(); 395 | HashedWheelBucket bucket = wheel[idx]; 396 | // 将newTimeout()方法中加入到待处理定时任务队列中的任务加入到指定的格子中 397 | transferTimeoutsToBuckets(); 398 | bucket.expireTimeouts(deadline); 399 | tick++; 400 | } 401 | // 校验如果workerState是started状态,那么就一直循环 402 | } while (workerStateUpdater.get(HashedWheelTimer.this) == WORKER_STATE_STARTED); 403 | 404 | // Fill the unprocessedTimeouts so we can return them from stop() method. 405 | for (HashedWheelBucket bucket : wheel) { 406 | bucket.clearTimeouts(unprocessedTimeouts); 407 | } 408 | for (;;) { 409 | HashedWheelTimeout timeout = timeouts.poll(); 410 | if (timeout == null) { 411 | break; 412 | } 413 | //如果有没有被处理的timeout,那么加入到unprocessedTimeouts对列中 414 | if (!timeout.isCancelled()) { 415 | unprocessedTimeouts.add(timeout); 416 | } 417 | } 418 | //处理被取消的任务 419 | processCancelledTasks(); 420 | } 421 | ``` 422 | 423 | 1. 这个方法首先会设置一个时间轮的开始时间startTime,然后调用startTimeInitialized的countDown让被阻塞的线程往下运行 424 | 2. 调用waitForNextTick等待到下次tick的到来,并返回当次的tick时间-startTime 425 | 3. 通过&的方式获取时间轮的槽位 426 | 4. 移除掉被取消的task 427 | 5. 将timeouts中的任务转移到对应的wheel槽位中,如果槽位中不止一个bucket,那么串成一个链表 428 | 6. 执行格子中的到期任务 429 | 7. 遍历整个wheel,将过期的bucket放入到unprocessedTimeouts队列中 430 | 8. 将timeouts中过期的bucket放入到unprocessedTimeouts队列中 431 | 432 | 上面所有的过期但未被处理的bucket会在调用stop方法的时候返回unprocessedTimeouts队列中的数据。所以unprocessedTimeouts中的数据只是做一个记录,并不会再次被执行。 433 | 434 | 时间轮的所有处理过程都在do-while循环中被处理,我们下面一个个分析 435 | 436 | #### 处理被取消的任务[#](https://www.cnblogs.com/luozhiyun/p/11706171.html#2230304666) 437 | 438 | **Worker#processCancelledTasks** 439 | 440 | ```java 441 | Copyprivate void processCancelledTasks() { 442 | for (;;) { 443 | HashedWheelTimeout timeout = cancelledTimeouts.poll(); 444 | if (timeout == null) { 445 | // all processed 446 | break; 447 | } 448 | try { 449 | timeout.remove(); 450 | } catch (Throwable t) { 451 | if (LOG.isWarnEnabled()) { 452 | LOG.warn("An exception was thrown while process a cancellation task", t); 453 | } 454 | } 455 | } 456 | } 457 | ``` 458 | 459 | 这个方法相当的简单,因为在调用HashedWheelTimer的stop方法的时候会将要取消的HashedWheelTimeout实例放入到cancelledTimeouts队列中,所以这里只需要循环把队列中的数据取出来,然后调用HashedWheelTimeout的remove方法将自己在bucket移除就好了 460 | 461 | **HashedWheelTimeout#remove** 462 | 463 | ```java 464 | Copyvoid remove() { 465 | HashedWheelBucket bucket = this.bucket; 466 | if (bucket != null) { 467 | //这里面涉及到链表的引用摘除,十分清晰易懂,想了解的可以去看看 468 | bucket.remove(this); 469 | } else { 470 | timer.pendingTimeouts.decrementAndGet(); 471 | } 472 | } 473 | ``` 474 | 475 | #### 转移数据到时间轮中[#](https://www.cnblogs.com/luozhiyun/p/11706171.html#609510907) 476 | 477 | **Worker#transferTimeoutsToBuckets** 478 | 479 | ```java 480 | Copyprivate void transferTimeoutsToBuckets() { 481 | // transfer only max. 100000 timeouts per tick to prevent a thread to stale the workerThread when it just 482 | // adds new timeouts in a loop. 483 | // 每次tick只处理10w个任务,以免阻塞worker线程 484 | for (int i = 0; i < 100000; i++) { 485 | HashedWheelTimeout timeout = timeouts.poll(); 486 | if (timeout == null) { 487 | // all processed 488 | break; 489 | } 490 | //已经被取消了; 491 | if (timeout.state() == HashedWheelTimeout.ST_CANCELLED) { 492 | // Was cancelled in the meantime. 493 | continue; 494 | } 495 | //calculated = tick 次数 496 | long calculated = timeout.deadline / tickDuration; 497 | // 计算剩余的轮数, 只有 timer 走够轮数, 并且到达了 task 所在的 slot, task 才会过期 498 | timeout.remainingRounds = (calculated - tick) / wheel.length; 499 | //如果任务在timeouts队列里面放久了, 以至于已经过了执行时间, 这个时候就使用当前tick, 也就是放到当前bucket, 此方法调用完后就会被执行 500 | final long ticks = Math.max(calculated, tick); // Ensure we don't schedule for past. 501 | //// 算出任务应该插入的 wheel 的 slot, slotIndex = tick 次数 & mask, mask = wheel.length - 1 502 | int stopIndex = (int) (ticks & mask); 503 | 504 | HashedWheelBucket bucket = wheel[stopIndex]; 505 | //将timeout加入到bucket链表中 506 | bucket.addTimeout(timeout); 507 | } 508 | } 509 | ``` 510 | 511 | 1. 每次调用这个方法会处理10w个任务,以免阻塞worker线程 512 | 2. 在校验之后会用timeout的deadline除以每次tick运行的时间tickDuration得出需要tick多少次才会运行这个timeout的任务 513 | 3. 由于timeout的deadline实际上还包含了worker线程启动到timeout加入队列这段时间,所以在算remainingRounds的时候需要减去当前的tick次数 514 | 515 | ``` 516 | Copy |_____________________|____________ 517 | worker启动时间 timeout任务加入时间 518 | ``` 519 | 520 | 1. 最后根据计算出来的ticks来&算出wheel的槽位,加入到bucket链表中 521 | 522 | #### 执行到期任务[#](https://www.cnblogs.com/luozhiyun/p/11706171.html#240609497) 523 | 524 | 在worker的run方法的do-while循环中,在根据当前的tick拿到wheel中的bucket后会调用expireTimeouts方法来处理这个bucket的到期任务 525 | 526 | **HashedWheelBucket#expireTimeouts** 527 | 528 | ```java 529 | Copy// 过期并执行格子中的到期任务,tick到该格子的时候,worker线程会调用这个方法, 530 | //根据deadline和remainingRounds判断任务是否过期 531 | public void expireTimeouts(long deadline) { 532 | HashedWheelTimeout timeout = head; 533 | 534 | // process all timeouts 535 | //遍历格子中的所有定时任务 536 | while (timeout != null) { 537 | // 先保存next,因为移除后next将被设置为null 538 | HashedWheelTimeout next = timeout.next; 539 | if (timeout.remainingRounds <= 0) { 540 | //从bucket链表中移除当前timeout,并返回链表中下一个timeout 541 | next = remove(timeout); 542 | //如果timeout的时间小于当前的时间,那么就调用expire执行task 543 | if (timeout.deadline <= deadline) { 544 | timeout.expire(); 545 | } else { 546 | //不可能发生的情况,就是说round已经为0了,deadline却>当前槽的deadline 547 | // The timeout was placed into a wrong slot. This should never happen. 548 | throw new IllegalStateException(String.format("timeout.deadline (%d) > deadline (%d)", 549 | timeout.deadline, deadline)); 550 | } 551 | } else if (timeout.isCancelled()) { 552 | next = remove(timeout); 553 | } else { 554 | //因为当前的槽位已经过了,说明已经走了一圈了,把轮数减一 555 | timeout.remainingRounds--; 556 | } 557 | //把指针放置到下一个timeout 558 | timeout = next; 559 | } 560 | } 561 | ``` 562 | 563 | expireTimeouts方法会根据当前tick到的槽位,然后获取槽位中的bucket并找到链表中到期的timeout并执行 564 | 565 | 1. 因为每一次的指针都会指向bucket中的下一个timeout,所以timeout为空时说明整个链表已经遍历完毕,所以用while循环做非空校验 566 | 2. 因为没一次循环都会把当前的轮数大于零的做减一处理,所以当轮数小于或等于零的时候就需要把当前的timeout移除bucket链表 567 | 3. 在校验deadline之后执行expire方法,这里会真正进行任务调用 568 | 569 | **HashedWheelTimeout#task** 570 | 571 | ```java 572 | Copypublic void expire() { 573 | if (!compareAndSetState(ST_INIT, ST_EXPIRED)) { 574 | return; 575 | } 576 | 577 | try { 578 | task.run(this); 579 | } catch (Throwable t) { 580 | if (LOG.isWarnEnabled()) { 581 | LOG.warn("An exception was thrown by " + TimerTask.class.getSimpleName() + '.', t); 582 | } 583 | } 584 | } 585 | ``` 586 | 587 | 这里这个task就是在schedule方法中构建的timerTask实例,调用timerTask的run方法会调用到外层的RepeatedTimer的run方法,从而调用到RepeatedTimer子类实现的onTrigger方法。 588 | 589 | 到这里Jraft的定时调度就讲完了,感觉还是很有意思的。 -------------------------------------------------------------------------------- /doc/jraft/basic/lab2-投票的实现: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hzh0425/hy_distribute_theory_basic/80ffc1bfa0ca2856ecdafd46489a036994414894/doc/jraft/basic/lab2-投票的实现 -------------------------------------------------------------------------------- /doc/jraft/lab0-初始化时做了什么.md: -------------------------------------------------------------------------------- 1 | 我们这次依然用上次的例子CounterServer来进行讲解: 2 | 3 | 我这里就不贴整个代码了 4 | 5 | ```java 6 | Copypublic static void main(final String[] args) throws IOException { 7 | if (args.length != 4) { 8 | System.out 9 | .println("Useage : java com.alipay.sofa.jraft.example.counter.CounterServer {dataPath} {groupId} {serverId} {initConf}"); 10 | System.out 11 | .println("Example: java com.alipay.sofa.jraft.example.counter.CounterServer " + 12 | "/tmp/server1 " + 13 | "counter " + 14 | "127.0.0.1:8081 127.0.0.1:8081,127.0.0.1:8082,127.0.0.1:8083"); 15 | System.exit(1); 16 | } 17 | //日志存储的路径 18 | final String dataPath = args[0]; 19 | //SOFAJRaft集群的名字 20 | final String groupId = args[1]; 21 | //当前节点的ip和端口 22 | final String serverIdStr = args[2]; 23 | //集群节点的ip和端口 24 | final String initConfStr = args[3]; 25 | 26 | final NodeOptions nodeOptions = new NodeOptions(); 27 | // 为了测试,调整 snapshot 间隔等参数 28 | // 设置选举超时时间为 1 秒 29 | nodeOptions.setElectionTimeoutMs(1000); 30 | // 关闭 CLI 服务。 31 | nodeOptions.setDisableCli(false); 32 | // 每隔30秒做一次 snapshot 33 | nodeOptions.setSnapshotIntervalSecs(30); 34 | // 解析参数 35 | final PeerId serverId = new PeerId(); 36 | if (!serverId.parse(serverIdStr)) { 37 | throw new IllegalArgumentException("Fail to parse serverId:" + serverIdStr); 38 | } 39 | final Configuration initConf = new Configuration(); 40 | //将raft分组加入到Configuration的peers数组中 41 | if (!initConf.parse(initConfStr)) { 42 | throw new IllegalArgumentException("Fail to parse initConf:" + initConfStr); 43 | } 44 | // 设置初始集群配置 45 | nodeOptions.setInitialConf(initConf); 46 | 47 | // 启动 48 | final CounterServer counterServer = new CounterServer(dataPath, groupId, serverId, nodeOptions); 49 | System.out.println("Started counter server at port:" 50 | + counterServer.getNode().getNodeId().getPeerId().getPort()); 51 | } 52 | ``` 53 | 54 | 我们在启动server的main方法的时候会传入日志存储的路径、SOFAJRaft集群的名字、当前节点的ip和端口、集群节点的ip和端口并设值到NodeOptions中,作为当前节点启动的参数。 55 | 56 | 这里会将当前节点初始化为一个PeerId对象 57 | **PeerId** 58 | 59 | ```java 60 | Copy//存放当前节点的ip和端口号 61 | private Endpoint endpoint = new Endpoint(Utils.IP_ANY, 0); 62 | 63 | //默认是0 64 | private int idx; 65 | //是一个ip:端口的字符串 66 | private String str; 67 | public PeerId() { 68 | super(); 69 | } 70 | 71 | public boolean parse(final String s) { 72 | final String[] tmps = StringUtils.split(s, ':'); 73 | if (tmps.length != 3 && tmps.length != 2) { 74 | return false; 75 | } 76 | try { 77 | final int port = Integer.parseInt(tmps[1]); 78 | this.endpoint = new Endpoint(tmps[0], port); 79 | if (tmps.length == 3) { 80 | this.idx = Integer.parseInt(tmps[2]); 81 | } else { 82 | this.idx = 0; 83 | } 84 | this.str = null; 85 | return true; 86 | } catch (final Exception e) { 87 | LOG.error("Parse peer from string failed: {}", s, e); 88 | return false; 89 | } 90 | } 91 | ``` 92 | 93 | PeerId的parse方法会将传入的ip:端口解析之后对变量进行一些赋值的操作。 94 | 95 | 然后会调用到CounterServer的构造器中: 96 | **CounterServer** 97 | 98 | ```java 99 | Copypublic CounterServer(final String dataPath, final String groupId, final PeerId serverId, 100 | final NodeOptions nodeOptions) throws IOException { 101 | // 初始化路径 102 | FileUtils.forceMkdir(new File(dataPath)); 103 | 104 | // 这里让 raft RPC 和业务 RPC 使用同一个 RPC server, 通常也可以分开 105 | final RpcServer rpcServer = new RpcServer(serverId.getPort()); 106 | RaftRpcServerFactory.addRaftRequestProcessors(rpcServer); 107 | // 注册业务处理器 108 | rpcServer.registerUserProcessor(new GetValueRequestProcessor(this)); 109 | rpcServer.registerUserProcessor(new IncrementAndGetRequestProcessor(this)); 110 | // 初始化状态机 111 | this.fsm = new CounterStateMachine(); 112 | // 设置状态机到启动参数 113 | nodeOptions.setFsm(this.fsm); 114 | // 设置存储路径 115 | // 日志, 必须 116 | nodeOptions.setLogUri(dataPath + File.separator + "log"); 117 | // 元信息, 必须 118 | nodeOptions.setRaftMetaUri(dataPath + File.separator + "raft_meta"); 119 | // snapshot, 可选, 一般都推荐 120 | nodeOptions.setSnapshotUri(dataPath + File.separator + "snapshot"); 121 | // 初始化 raft group 服务框架 122 | this.raftGroupService = new RaftGroupService(groupId, serverId, nodeOptions, rpcServer); 123 | // 启动 124 | this.node = this.raftGroupService.start(); 125 | } 126 | ``` 127 | 128 | 这个方法主要是调用NodeOptions的各种方法进行设置,然后调用raftGroupService的start方法启动raft节点。 129 | 130 | ### RaftGroupService[#](https://www.cnblogs.com/luozhiyun/p/11651414.html#3350796144) 131 | 132 | 我们来到RaftGroupService的start方法: 133 | **RaftGroupService#start** 134 | 135 | ```java 136 | Copypublic synchronized Node start(final boolean startRpcServer) { 137 | //如果已经启动了,那么就返回 138 | if (this.started) { 139 | return this.node; 140 | } 141 | //校验serverId和groupId 142 | if (this.serverId == null || this.serverId.getEndpoint() == null 143 | || this.serverId.getEndpoint().equals(new Endpoint(Utils.IP_ANY, 0))) { 144 | throw new IllegalArgumentException("Blank serverId:" + this.serverId); 145 | } 146 | if (StringUtils.isBlank(this.groupId)) { 147 | throw new IllegalArgumentException("Blank group id" + this.groupId); 148 | } 149 | //Adds RPC server to Server. 150 | //设置当前node的ip和端口 151 | NodeManager.getInstance().addAddress(this.serverId.getEndpoint()); 152 | 153 | //创建node 154 | this.node = RaftServiceFactory.createAndInitRaftNode(this.groupId, this.serverId, this.nodeOptions); 155 | if (startRpcServer) { 156 | //启动远程服务 157 | this.rpcServer.start(); 158 | } else { 159 | LOG.warn("RPC server is not started in RaftGroupService."); 160 | } 161 | this.started = true; 162 | LOG.info("Start the RaftGroupService successfully."); 163 | return this.node; 164 | } 165 | ``` 166 | 167 | 这个方法会在一开始的时候对RaftGroupService在构造器实例化的参数进行校验,然后把当前节点的Endpoint添加到NodeManager的addrSet变量中,接着调用RaftServiceFactory#createAndInitRaftNode实例化Node节点。 168 | 169 | 每个节点都会启动一个rpc的服务,因为每个节点既可以被选举也可以投票给其他节点,节点之间需要互相通信,所以需要启动一个rpc服务。 170 | 171 | **RaftServiceFactory#createAndInitRaftNode** 172 | 173 | ```java 174 | Copypublic static Node createAndInitRaftNode(final String groupId, final PeerId serverId, final NodeOptions opts) { 175 | //实例化一个node节点 176 | final Node ret = createRaftNode(groupId, serverId); 177 | //为node节点初始化 178 | if (!ret.init(opts)) { 179 | throw new IllegalStateException("Fail to init node, please see the logs to find the reason."); 180 | } 181 | return ret; 182 | } 183 | 184 | public static Node createRaftNode(final String groupId, final PeerId serverId) { 185 | return new NodeImpl(groupId, serverId); 186 | } 187 | ``` 188 | 189 | createAndInitRaftNode方法首先调用createRaftNode实例化一个Node的实例NodeImpl,然后调用其init方法进行初始化,主要的配置都是在init方法中完成的。 190 | 191 | **NodeImpl** 192 | 193 | ```java 194 | Copypublic NodeImpl(final String groupId, final PeerId serverId) { 195 | super(); 196 | if (groupId != null) { 197 | //检验groupId是否符合格式规范 198 | Utils.verifyGroupId(groupId); 199 | } 200 | this.groupId = groupId; 201 | this.serverId = serverId != null ? serverId.copy() : null; 202 | //一开始的设置为未初始化 203 | this.state = State.STATE_UNINITIALIZED; 204 | //设置新的任期为0 205 | this.currTerm = 0; 206 | //设置最新的时间戳 207 | updateLastLeaderTimestamp(Utils.monotonicMs()); 208 | this.confCtx = new ConfigurationCtx(this); 209 | this.wakingCandidate = null; 210 | final int num = GLOBAL_NUM_NODES.incrementAndGet(); 211 | LOG.info("The number of active nodes increment to {}.", num); 212 | } 213 | ``` 214 | 215 | NodeImpl会在构造器中初始化一些参数。 216 | 217 | ### Node的初始化[#](https://www.cnblogs.com/luozhiyun/p/11651414.html#1793682028) 218 | 219 | Node节点的所有的重要的配置都是在init方法中完成的,NodeImpl的init方法比较长所以分成代码块来进行讲解。 220 | 221 | **NodeImpl#init** 222 | 223 | ```java 224 | Copy//非空校验 225 | Requires.requireNonNull(opts, "Null node options"); 226 | Requires.requireNonNull(opts.getRaftOptions(), "Null raft options"); 227 | Requires.requireNonNull(opts.getServiceFactory(), "Null jraft service factory"); 228 | //目前就一个实现:DefaultJRaftServiceFactory 229 | this.serviceFactory = opts.getServiceFactory(); 230 | this.options = opts; 231 | this.raftOptions = opts.getRaftOptions(); 232 | //基于 Metrics 类库的性能指标统计,具有丰富的性能统计指标,默认不开启度量工具 233 | this.metrics = new NodeMetrics(opts.isEnableMetrics()); 234 | 235 | if (this.serverId.getIp().equals(Utils.IP_ANY)) { 236 | LOG.error("Node can't started from IP_ANY."); 237 | return false; 238 | } 239 | 240 | if (!NodeManager.getInstance().serverExists(this.serverId.getEndpoint())) { 241 | LOG.error("No RPC server attached to, did you forget to call addService?"); 242 | return false; 243 | } 244 | //定时任务管理器 245 | this.timerManager = new TimerManager(); 246 | //初始化定时任务管理器的内置线程池 247 | if (!this.timerManager.init(this.options.getTimerPoolSize())) { 248 | LOG.error("Fail to init timer manager."); 249 | return false; 250 | } 251 | 252 | //定时任务管理器 253 | this.timerManager = new TimerManager(); 254 | //初始化定时任务管理器的内置线程池 255 | if (!this.timerManager.init(this.options.getTimerPoolSize())) { 256 | LOG.error("Fail to init timer manager."); 257 | return false; 258 | } 259 | ``` 260 | 261 | 这段代码主要是给各个变量赋值,然后进行校验判断一下serverId不能为0.0.0.0,当前的Endpoint必须要在NodeManager里面设置过等等(NodeManager的设置是在RaftGroupService的start方法里)。 262 | 263 | 然后会初始化一个全局的的定时调度管理器TimerManager: 264 | **TimerManager** 265 | 266 | ```java 267 | Copyprivate ScheduledExecutorService executor; 268 | 269 | @Override 270 | public boolean init(Integer coreSize) { 271 | this.executor = Executors.newScheduledThreadPool(coreSize, new NamedThreadFactory( 272 | "JRaft-Node-ScheduleThreadPool-", true)); 273 | return true; 274 | } 275 | ``` 276 | 277 | TimerManager的init方法就是初始化一个线程池,如果当前的服务器的cpu线程数*3 大于20 ,那么这个线程池的coreSize就是20,否则就是cpu线程数*3。 278 | 279 | 往下走是计时器的初始化: 280 | 281 | ```java 282 | Copy// Init timers 283 | //设置投票计时器 284 | this.voteTimer = new RepeatedTimer("JRaft-VoteTimer", this.options.getElectionTimeoutMs()) { 285 | 286 | @Override 287 | protected void onTrigger() { 288 | //处理投票超时 289 | handleVoteTimeout(); 290 | } 291 | 292 | @Override 293 | protected int adjustTimeout(final int timeoutMs) { 294 | //在一定范围内返回一个随机的时间戳 295 | return randomTimeout(timeoutMs); 296 | } 297 | }; 298 | //设置预投票计时器 299 | //当leader在规定的一段时间内没有与 Follower 舰船进行通信时, 300 | // Follower 就可以认为leader已经不能正常担任旗舰的职责,则 Follower 可以去尝试接替leader的角色。 301 | // 这段通信超时被称为 Election Timeout 302 | //候选者在发起投票之前,先发起预投票 303 | this.electionTimer = new RepeatedTimer("JRaft-ElectionTimer", this.options.getElectionTimeoutMs()) { 304 | 305 | @Override 306 | protected void onTrigger() { 307 | handleElectionTimeout(); 308 | } 309 | 310 | @Override 311 | protected int adjustTimeout(final int timeoutMs) { 312 | //在一定范围内返回一个随机的时间戳 313 | //为了避免同时发起选举而导致失败 314 | return randomTimeout(timeoutMs); 315 | } 316 | }; 317 | //leader下台的计时器 318 | //定时检查是否需要重新选举leader 319 | this.stepDownTimer = new RepeatedTimer("JRaft-StepDownTimer", this.options.getElectionTimeoutMs() >> 1) { 320 | 321 | @Override 322 | protected void onTrigger() { 323 | handleStepDownTimeout(); 324 | } 325 | }; 326 | //快照计时器 327 | this.snapshotTimer = new RepeatedTimer("JRaft-SnapshotTimer", this.options.getSnapshotIntervalSecs() * 1000) { 328 | 329 | @Override 330 | protected void onTrigger() { 331 | handleSnapshotTimeout(); 332 | } 333 | }; 334 | ``` 335 | 336 | voteTimer是用来控制选举的,如果选举超时,当前的节点又是候选者角色,那么就会发起选举。 337 | electionTimer是预投票计时器。候选者在发起投票之前,先发起预投票,如果没有得到半数以上节点的反馈,则候选者就会识趣的放弃参选。 338 | stepDownTimer定时检查是否需要重新选举leader。当前的leader可能出现它的Follower可能并没有整个集群的1/2却还没有下台的情况,那么这个时候会定期的检查看leader的Follower是否有那么多,没有那么多的话会强制让leader下台。 339 | snapshotTimer快照计时器。这个计时器会每隔1小时触发一次生成一个快照。 340 | 341 | 这些计时器的具体实现现在暂时不表,等到要讲具体功能的时候再进行梳理。 342 | 343 | 这些计时器有一个共同的特点就是会根据不同的计时器返回一个在一定范围内随机的时间。返回一个随机的时间可以防止多个节点在同一时间内同时发起投票选举从而降低选举失败的概率。 344 | 345 | 继续往下看: 346 | 347 | ```java 348 | Copythis.configManager = new ConfigurationManager(); 349 | //初始化一个disruptor,采用多生产者模式 350 | this.applyDisruptor = DisruptorBuilder.newInstance() // 351 | //设置disruptor大小,默认16384 352 | .setRingBufferSize(this.raftOptions.getDisruptorBufferSize()) // 353 | .setEventFactory(new LogEntryAndClosureFactory()) // 354 | .setThreadFactory(new NamedThreadFactory("JRaft-NodeImpl-Disruptor-", true)) // 355 | .setProducerType(ProducerType.MULTI) // 356 | .setWaitStrategy(new BlockingWaitStrategy()) // 357 | .build(); 358 | //设置事件处理器 359 | this.applyDisruptor.handleEventsWith(new LogEntryAndClosureHandler()); 360 | //设置异常处理器 361 | this.applyDisruptor.setDefaultExceptionHandler(new LogExceptionHandler(getClass().getSimpleName())); 362 | // 启动disruptor的线程 363 | this.applyQueue = this.applyDisruptor.start(); 364 | //如果开启了metrics统计 365 | if (this.metrics.getMetricRegistry() != null) { 366 | this.metrics.getMetricRegistry().register("jraft-node-impl-disruptor", 367 | new DisruptorMetricSet(this.applyQueue)); 368 | } 369 | ``` 370 | 371 | 这里初始化了一个Disruptor作为消费队列,不清楚Disruptor的朋友可以去看我上一篇文章:[Disruptor—核心概念及体验](https://www.cnblogs.com/luozhiyun/p/11631305.html)。然后还校验了metrics是否开启,默认是不开启的。 372 | 373 | 继续往下看: 374 | 375 | ```java 376 | Copy//fsmCaller封装对业务 StateMachine 的状态转换的调用以及日志的写入等 377 | this.fsmCaller = new FSMCallerImpl(); 378 | //初始化日志存储功能 379 | if (!initLogStorage()) { 380 | LOG.error("Node {} initLogStorage failed.", getNodeId()); 381 | return false; 382 | } 383 | //初始化元数据存储功能 384 | if (!initMetaStorage()) { 385 | LOG.error("Node {} initMetaStorage failed.", getNodeId()); 386 | return false; 387 | } 388 | //对FSMCaller初始化 389 | if (!initFSMCaller(new LogId(0, 0))) { 390 | LOG.error("Node {} initFSMCaller failed.", getNodeId()); 391 | return false; 392 | } 393 | //实例化投票箱 394 | this.ballotBox = new BallotBox(); 395 | final BallotBoxOptions ballotBoxOpts = new BallotBoxOptions(); 396 | ballotBoxOpts.setWaiter(this.fsmCaller); 397 | ballotBoxOpts.setClosureQueue(this.closureQueue); 398 | //初始化ballotBox的属性 399 | if (!this.ballotBox.init(ballotBoxOpts)) { 400 | LOG.error("Node {} init ballotBox failed.", getNodeId()); 401 | return false; 402 | } 403 | //初始化快照存储功能 404 | if (!initSnapshotStorage()) { 405 | LOG.error("Node {} initSnapshotStorage failed.", getNodeId()); 406 | return false; 407 | } 408 | //校验日志文件索引的一致性 409 | final Status st = this.logManager.checkConsistency(); 410 | if (!st.isOk()) { 411 | LOG.error("Node {} is initialized with inconsistent log, status={}.", getNodeId(), st); 412 | return false; 413 | } 414 | //配置管理raft group中的信息 415 | this.conf = new ConfigurationEntry(); 416 | this.conf.setId(new LogId()); 417 | // if have log using conf in log, else using conf in options 418 | if (this.logManager.getLastLogIndex() > 0) { 419 | this.conf = this.logManager.checkAndSetConfiguration(this.conf); 420 | } else { 421 | this.conf.setConf(this.options.getInitialConf()); 422 | } 423 | ``` 424 | 425 | 这段代码主要是对快照、日志、元数据等功能初始化。 426 | 427 | ```java 428 | Copythis.replicatorGroup = new ReplicatorGroupImpl(); 429 | //收其他节点或者客户端发过来的请求,转交给对应服务处理 430 | this.rpcService = new BoltRaftClientService(this.replicatorGroup); 431 | final ReplicatorGroupOptions rgOpts = new ReplicatorGroupOptions(); 432 | rgOpts.setHeartbeatTimeoutMs(heartbeatTimeout(this.options.getElectionTimeoutMs())); 433 | rgOpts.setElectionTimeoutMs(this.options.getElectionTimeoutMs()); 434 | rgOpts.setLogManager(this.logManager); 435 | rgOpts.setBallotBox(this.ballotBox); 436 | rgOpts.setNode(this); 437 | rgOpts.setRaftRpcClientService(this.rpcService); 438 | rgOpts.setSnapshotStorage(this.snapshotExecutor != null ? this.snapshotExecutor.getSnapshotStorage() : null); 439 | rgOpts.setRaftOptions(this.raftOptions); 440 | rgOpts.setTimerManager(this.timerManager); 441 | 442 | // Adds metric registry to RPC service. 443 | this.options.setMetricRegistry(this.metrics.getMetricRegistry()); 444 | //初始化rpc服务 445 | if (!this.rpcService.init(this.options)) { 446 | LOG.error("Fail to init rpc service."); 447 | return false; 448 | } 449 | this.replicatorGroup.init(new NodeId(this.groupId, this.serverId), rgOpts); 450 | 451 | this.readOnlyService = new ReadOnlyServiceImpl(); 452 | final ReadOnlyServiceOptions rosOpts = new ReadOnlyServiceOptions(); 453 | rosOpts.setFsmCaller(this.fsmCaller); 454 | rosOpts.setNode(this); 455 | rosOpts.setRaftOptions(this.raftOptions); 456 | //只读服务初始化 457 | if (!this.readOnlyService.init(rosOpts)) { 458 | LOG.error("Fail to init readOnlyService."); 459 | return false; 460 | } 461 | ``` 462 | 463 | 这段代码主要是初始化replicatorGroup、rpcService以及readOnlyService。 464 | 465 | 接下来是最后一段的代码: 466 | 467 | ```java 468 | Copy// set state to follower 469 | this.state = State.STATE_FOLLOWER; 470 | 471 | if (LOG.isInfoEnabled()) { 472 | LOG.info("Node {} init, term={}, lastLogId={}, conf={}, oldConf={}.", getNodeId(), this.currTerm, 473 | this.logManager.getLastLogId(false), this.conf.getConf(), this.conf.getOldConf()); 474 | } 475 | 476 | //如果快照执行器不为空,并且生成快照的时间间隔大于0,那么就定时生成快照 477 | if (this.snapshotExecutor != null && this.options.getSnapshotIntervalSecs() > 0) { 478 | LOG.debug("Node {} start snapshot timer, term={}.", getNodeId(), this.currTerm); 479 | this.snapshotTimer.start(); 480 | } 481 | 482 | if (!this.conf.isEmpty()) { 483 | //新启动的node需要重新选举 484 | stepDown(this.currTerm, false, new Status()); 485 | } 486 | 487 | if (!NodeManager.getInstance().add(this)) { 488 | LOG.error("NodeManager add {} failed.", getNodeId()); 489 | return false; 490 | } 491 | 492 | // Now the raft node is started , have to acquire the writeLock to avoid race 493 | // conditions 494 | this.writeLock.lock(); 495 | //这个分支表示当前的jraft集群里只有一个节点,那么个节点必定是leader直接进行选举就好了 496 | if (this.conf.isStable() && this.conf.getConf().size() == 1 && this.conf.getConf().contains(this.serverId)) { 497 | // The group contains only this server which must be the LEADER, trigger 498 | // the timer immediately. 499 | electSelf(); 500 | } else { 501 | this.writeLock.unlock(); 502 | } 503 | 504 | return true; 505 | ``` 506 | 507 | 这段代码里会将当前的状态设置为Follower,然后启动快照定时器定时生成快照。 508 | 如果当前的集群不是单节点集群需要做一下stepDown,表示新生成的Node节点需要重新进行选举。 509 | 最下面有一个if分支,如果当前的jraft集群里只有一个节点,那么个节点必定是leader直接进行选举就好了,所以会直接调用electSelf进行选举。 510 | 选举的代码我们就暂时略过,要不然后面就没得讲了。 511 | 512 | 到这里整个NodeImpl实例的init方法就分析完了,这个方法很长,但是还是做了很多事情的 -------------------------------------------------------------------------------- /doc/jraft/lab3-snashopt机制.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hzh0425/hy_distribute_theory_basic/80ffc1bfa0ca2856ecdafd46489a036994414894/doc/jraft/lab3-snashopt机制.md -------------------------------------------------------------------------------- /doc/jraft/lab4-存储模块.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hzh0425/hy_distribute_theory_basic/80ffc1bfa0ca2856ecdafd46489a036994414894/doc/jraft/lab4-存储模块.md -------------------------------------------------------------------------------- /doc/mit/lab1-introduction.md: -------------------------------------------------------------------------------- 1 | # 1 分布式系统的驱动力和挑战(Drivens and Challenges) 2 | 3 | 本课程是 6.824 分布式系统。我会先简单的介绍我理解的分布式系统。 4 | 5 | 大家都知道分布式系统的核心是通过网络来协调,共同完成一致任务的一些计算机。我们在本课程中将会重点介绍一些案例,包括:大型网站的储存系统、大数据运算,如 MapReduce、以及一些更为奇妙的技术,比如点对点的文件共享。这是我们学习过程中的一些例子。分布式计算之所以如此重要的原因是,许多重要的基础设施都是在它之上建立的,它们需要多台计算机或者说本质上需要多台物理隔离的计算机。 6 | 7 | 在我先介绍分布式系统之前,也是提醒大家,在你设计一个系统时或者面对一个你需要解决的问题时,如果你可以在一台计算机上解决,而不需要分布式系统,那你就应该用一台计算机解决问题。有很多的工作都可以在一台计算机上完成,并且通常比分布式系统简单很多。所以,在选择使用分布式系统解决问题前,你应该要充分尝试别的思路,因为分布式系统会让问题解决变得复杂。 8 | 9 | 人们使用大量的相互协作的计算机驱动力是: 10 | 11 | - 人们需要获得更高的计算性能。可以这么理解这一点,(大量的计算机意味着)大量的并行运算,大量CPU、大量内存、以及大量磁盘在并行的运行。 12 | - 另一个人们构建分布式系统的原因是,它可以提供容错(tolerate faults)。比如两台计算机运行完全相同的任务,其中一台发生故障,可以切换到另一台。 13 | - 第三个原因是,一些问题天然在空间上是分布的。例如银行转账,我们假设银行A在纽约有一台服务器,银行B在伦敦有一台服务器,这就需要一种两者之间协调的方法。所以,有一些天然的原因导致系统是物理分布的。 14 | - 最后一个原因是,人们构建分布式系统来达成一些安全的目标。比如有一些代码并不被信任,但是你又需要和它进行交互,这些代码不会立即表现的恶意或者出现bug。你不会想要信任这些代码,所以你或许想要将代码分散在多处运行,这样你的代码在另一台计算机运行,我的代码在我的计算机上运行,我们通过一些特定的网络协议通信。所以,我们可能会担心安全问题,我们把系统分成多个的计算机,这样可以限制出错域。 15 | 16 | 17 | 18 | ![img](https://pic3.zhimg.com/v2-3f719f38a7148325b6feeb121c4ea18e_b.jpg) 19 | 20 | 21 | 22 | 这门课程中,我们主要会讨论前两点:性能和容错。剩下两点我们会通过对某些案例的研究来学习。 23 | 24 | 所有的这些分布式系统的问题(挑战)在于: 25 | 26 | - 因为系统中存在很多部分,这些部分又在并发执行,你会遇到并发编程和各种复杂交互所带来的问题,以及时间依赖的问题(比如同步,异步)。这让分布式系统变得很难。 27 | - 另一个导致分布式系统很难的原因是,分布式系统有多个组成部分,再加上计算机网络,你会会遇到一些意想不到的故障。如果你只有一台计算机,那么它通常要么是工作,要么是故障或者没电,总的来说,要么是在工作,要么是没有工作。而由多台计算机组成的分布式系统,可能会有一部分组件在工作,而另一部分组件停止运行,或者这些计算机都在正常运行,但是网络中断了或者不稳定。所以,局部错误也是分布式系统很难的原因。 28 | - 最后一个导致分布式系统很难的原因是,人们设计分布式系统的根本原因通常是为了获得更高的性能,比如说一千台计算机或者一千个磁盘臂达到的性能。但是实际上一千台机器到底有多少性能是一个棘手的问题,这里有很多难点。所以通常需要倍加小心地设计才能让系统实际达到你期望的性能。 29 | 30 | 31 | 32 | ![img](https://pic1.zhimg.com/v2-e9d875ba92db137fa5f9b786f3ed6b10_b.jpg) 33 | 34 | 35 | 36 | 本门课程就是为了解决这些问题。通常来说,问题和解决方案在技术上都很有趣。对于这些问题,有些有很好的解决方案,有些就没有那么好的解决方案。 37 | 38 | 分布式系统应用在很多现实生活中系统,例如大型网站通常是由大量的计算机构成的分布式系统来运行。当我刚开始教这门课的时候,分布式系统还是一种学术上的好奇尝试。人们只是发现有时需要一些小规模的系统,并且预感在未来这(大规模分布式系统)可能很重要。但是现在,随着大型网站的兴起和推动,出现了大量的数据和大型数据中心。在过去的二十年中,分布式系统已经是计算架构中很重要的一部分。这意味着大量的精力投入到解决相关问题的工作中,但是同样有少数问题还没有被解决。如果你是个研究生,并且对这方面研究感兴趣,还有很多关于分布式系统的问题等着你去解决,去进行相关研究。最后 如果你是一位热衷动手的同学,这会是一门不错的课程,因为它有一系列实验,你会编写出贴近现实,并且关注性能和容错的分布式系统。所以你会有很多机会去构建一个分布式系统并且让他们正常工作。 39 | 40 | # 2.课程结构 41 | 42 | 在讨论技术内容之前,我先介绍一下课程结构。你们应该可以通过网络搜索到这门课程的网站(最开始的简介里也有)。网站上有一些实验作业,课程时间表和一个Piazza(论坛)页面链接,你可以在那里发布问题并获得解答。课程主要的教学人员有:我Robert Morris会进行课堂授课,和四个助教。助教会重点解决实验问题,在工作时间,他们也会在办公室解答有关实验的问题。所以如果你有关于实验的问题,你应该在办公时间过去找他们,或者你可以将问题发到Piazza上。这门课有几个重要组成部分:课堂授课几乎每节课都有论文阅读两次考试编程实验可选的项目(与Lab4二选一) 43 | ![img](https://pic2.zhimg.com/v2-21a5cd442dec98293bc4e3fb4c5d968d_b.jpg) 44 | 授课内容会围绕分布式系统的两个方面(性能和容错)。有几节课会介绍一些关于编程实验的内容。许多课程我们将会以案例分析为主要形式。我会在课前提供一些关于分布式系统的论文,这些论文有些是学术研究,也有一些是工业界关于现实问题的解决方案。授课内容会被录像并被上传到网络,这样不在课堂的人也可以在别的地方观看视频,同时你们也可以回顾课程视频。这里的论文每周需要读一篇,论文主要是研究论文,也有一些经典论文,比如今天我希望你们阅读的论文是MapReduce的论文。这篇论文很老,但是这篇论文不论在学术界还是工业界都激发了巨大的关于分布式系统的兴趣。所以,论文有一些是经典论文,也有一些最近发布的论文,用来讨论最近人们关心的最新研究成果。我希望通过这些论文可以让你们弄清楚,什么是基本的问题,研究者们有哪些想法,这些想法可能会,也可能不会对解决分布式系统的问题有用。我们有时会讨论这些论文中的一些实施细节,因为这些细节与实际构建软件系统有很多关联。我们同样会花一些时间去看对人们对系统的评估。人们是如何通过系统容错性和性能来评估一个分布式系统。我希望你们在每次讲课前,都可以完成相关论文的阅读。如果没有提前阅读,光是课程本身的内容或许没有那么有意义,因为我们没有足够的时间来解释论文中的所有内容,同时来反思论文中一些有意思的地方。所以,我真的希望大家来课堂前先阅读论文。我也希望快速高效的读论文会是这堂课的一个收获,比如跳过一些并不太重要的部分,而关注作者重要的想法。我们课程网站上每一个日程的链接都有一些思考问题,你应该在读完每篇论文后回答这个问题。我们也需要你在网站上提出关于论文的一些问题,可以让我思考一下我对课程的准备。如果我有时间我会至少通过电子邮件回答一部分问题。这些问题和回答都需要课程前一天的零点前提交。有两次考试,一次是随堂期中,大概在春假前最后一节课;并且会在学期期末周迎来期末考试。考试内容主要为论文和实验中的内容。我建议最好的准备方式当然参加课堂授课,并且阅读论文。另一个好的准备考试的方式就是查看我们过去20年所有的考试,这在网站上都有链接。这样你就知道,我会在考试中问哪些问题?因为我们(相比往年)会涉及到一些重复的论文,所以不可避免的,我会问一些与历年题目类似的问题。有四次编程实验。第一次实验需要在下周五前完成,这是一个简单的MapReduce实验。你们要根据你们在论文中读到的来实现你们版本的MapReduce。我们过一会就会讨论这个论文。第二个实验实现Raft算法,这是一个理论上通过复制来让系统容错的算法,具体是通过复制和出现故障时自动切换来实现。第三个实验,你需要使用你的Raft算法实现来建立一个可以容错的KV服务。第四个实验,你需要把你写的KV服务器分发到一系列的独立集群中,这样你会切分你的KV服务,并通过运行这些独立的副本集群进行加速。同时,你也要负责将不同的数据块在不同的服务器之间搬迁,并确保数据完整。这里我们通常称之为分片式KV服务。分片是指我们将数据在多个服务器上做了分区,来实现并行的加速。 45 | ![img](https://pic2.zhimg.com/v2-395a07d6aff33f154d62ed05dedbc675_b.jpg) 46 | 如果你不想做实验四,你也可以选择你自己的项目。如果你对分布式系统有一些自己的想法,比如我们课堂上讨论到的某个类型的分布式系统,或者说你有一些自己的追求并且想对这个想法进行评估,看他们能不能正确运行,你可以选择做这个项目。这个项目中你需要联系一些你的同学,因为我们需要以2-3人的小组形式完成。你需要把想法发给我,我来确定下是否合适或者是给你一些建议。如果我觉得合适,你也想做这个项目,你就可以用它在本学期末代替实验四。你需要做一些系统设计,并构建一个真实的系统并在最后一节课前演示。同时需要交一个简短的关于如何构建它的书面报告。我在网站上也提出一些或许对你们构建这个项目有帮助的大胆的想法。当然最好的项目应该是,你自己有一个很好的想法。你需要选择一个和课程讨论内容相关的系统作为你的项目。回到实验部分,实验成绩会由一系列针对你代码的测试构成,所以你的成绩就是我们所有测试的结果。我们会公开全部的测试数据,并没有隐藏的测试,所以如果你完成了实验并且可靠的通过了全部测试,除非出现一些愚蠢的问题,一般来说就会得到满分。希望你们不会有任何关于实验评分的问题。我需要提醒你的是,debug这些代码可能很耗时间,因为它们是分布式系统,它们有很多并发和通信,可能发生一些奇怪且困难的错误。所以,你们应该尽早开始实验 ,不要在提交实验的最后时刻还要处理很多麻烦。如果有对实验有问题,可以在工作时间来到助教办公室,你也可以在Piazza上自由提问。当然我也希望,如果你知道一个问题的答案,你可以在Piazza回答别人的提问。还有什么关于课程的问题吗?学生提问:这些部分在总成绩的占比是多少? 47 | Robert教授:我其实不记得了,不过你在课程网站上应该能找到答案。我想实验应该是占比最大的。 48 | 49 | # 3. 分布式系统的抽象和实现工具 50 | 51 | 这门课程是有关应用的基础架构的。所以,贯穿整个课程,我会以分离的方式介绍:第三方的应用程序,和这些应用程序所基于的,我们课程中主要介绍的一些基础架构。基础架构的类型主要是存储,通信(网络)和计算。 52 | ![img](https://pic2.zhimg.com/v2-49239f04ec825c5203e85f9809745235_b.jpg) 53 | 我们会讨论包含所有这三个部分的基础设施,但实际上我们最关注的是存储,因为这是一个定义明确且有用的抽象概念,并且通常比较直观。人们知道如何构建和使用储存系统,知道如何去构建一种多副本,容错的,高性能分布式存储实现。我们还会讨论一些计算系统,比如今天会介绍的MapReduce。我们也会说一些关于通信的问题,但是主要的出发点是通信是我们建立分布式系统所用的工具。比如计算机可能需要通过网络相互通信,但是可能需要保证一定的可靠性,所以我们会提到一些通信。实际上我们更多是使用已有的通信方式,如果你想了解更多关于通信系统的问题,在6.829这门课程有更多的介绍。对于存储和计算,我们的目标是为了能够设计一些简单接口,让第三方应用能够使用这些分布式的存储和计算,这样才能简单的在这些基础架构之上,构建第三方应用程序。这里的意思是,我们希望通过这种抽象的接口,将分布式特性隐藏在整个系统内。尽管这几乎是无法实现的梦想,但是我们确实希望建立这样的接口,这样从应用程序的角度来看,整个系统是一个非分布式的系统,就像一个文件系统或者一个大家知道如何编程的普通系统,并且有一个非常简单的模型语句。我们希望构建一个接口,它看起来就像一个非分布式存储和计算系统一样,但是实际上又是一个有极高的性能和容错性的分布式系统。 54 | ![img](https://pic2.zhimg.com/v2-6449445844002afb5dbe6726a3fdf569_b.jpg) 55 | 随着课程的进行,我们会知道,很难能找到一个抽象来描述分布式的存储或者计算,使得它们能够像非分布式系统一样有简单易懂的接口。但是,人们在这方面的做的越来越好,我们会尝试学习人们在构建这样的抽象时的一些收获。当我们在考虑这些抽象的时候,第一个出现的话题就是实现。人们在构建分布系统时,使用了很多的工具,例如:RPC(Remote Procedure Call)。RPC的目标就是掩盖我们正在不可靠网络上通信的事实。另一个我们会经常看到的实现相关的内容就是线程。这是一种编程技术,使得我们可以利用多核心计算机。对于本课程而言,更重要的是,线程提供了一种结构化的并发操作方式,这样,从程序员角度来说可以简化并发操作。因为我们会经常用到线程,我们需要在实现的层面上,花费一定的时间来考虑并发控制,比如锁。 56 | ![img](https://pic3.zhimg.com/v2-1b2aebb84fcf3493def9cf9fc1595a8a_b.jpg) 57 | 关于这些实现思想会在课程中出现,我们也会在许多论文中看到。对于你来说,你将会在实验中面对这些问题。你需要编程实现分布式系统,而这些工具不仅是普通的编程工具,同时也是非常重要的用来构建分布式系统的工具 58 | 59 | # 4.可扩展性 60 | 61 | 另一个在很多论文中都出现过重要的话题,就是性能。 62 | 63 | 通常来说,构建分布式系统的目的是为了获取人们常常提到的可扩展的加速。所以,我们这里追求的是可扩展性(Scalability)。而我这里说的可扩展或者可扩展性指的是,如果我用一台计算机解决了一些问题,当我买了第二台计算机,我只需要一半的时间就可以解决这些问题,或者说每分钟可以解决两倍数量的问题。两台计算机构成的系统如果有两倍性能或者吞吐,就是我说的可扩展性。 64 | 65 | 66 | 67 | ![img](https://pic4.zhimg.com/v2-4dd59e7b23e55f0d4081b77f08a66cef_b.jpg) 68 | 69 | 70 | 71 | 这是一个很强大的特性。如果你构建了一个系统,并且只要增加计算机的数量,系统就能相应提高性能或者吞吐量,这将会是一个巨大的成果,因为计算机只需要花钱就可以买到。如果不增加计算机,就需要花钱雇程序员来重构这些系统,进而使这些系统有更高的性能,更高的运行效率,或者应用一个更好的算法之类的。花钱请程序员来修补这些代码,使它们运行的更快,通常会是一个昂贵的方法。我们还是希望能够通过从十台计算机提升到一千台计算机,就能扛住一百倍的流量。 72 | 73 | 所以,当人们使用一整个机房的计算机来构建大型网站的时候,为了获取对应的性能,必须要时刻考虑可扩展性。你需要仔细设计系统,才能获得与计算机数量匹配的性能。 74 | 75 | 我在课程中可能经常会画图来说明,比如我们来看这样一个图。假设我们建立了一个常规网站,一般来说一个网站有一个 HTTP服务器,还有一些用户和浏览器,用户与一个基于Python或者PHP的web服务器通信,web服务器进而跟一些数据库进行交互。 76 | 77 | 78 | 79 | ![img](https://pic1.zhimg.com/v2-58d29ff6a40496d4173d16aed3b9b0b4_b.jpg) 80 | 81 | 82 | 83 | 当你只有1-2个用户时,一台计算机就可以运行web服务器和数据,或者一台计算机运行web服务器,一台计算机运行数据库。但是有可能你的网站一夜之间就火了起来,你发现可能有一亿人要登录你的网站。你该怎么修改你的网站,使它能够在一台计算机上支持一亿个用户?你可以花费大量时间极致优化你的网站,但是很显然你没有那个时间。所以,为了提升性能,你要做的第一件事情就是购买更多的web服务器,然后把不同用户分到不同服务器上。这样,一部分用户可以去访问第一台web服务器,另一部分去访问第二台web服务器。因为你正在构建的是类似于Reddit的网站,所有的用户最终都需要看到相同的数据。所以,所有的web服务器都与后端数据库通信。这样,很长一段时间你都可以通过添加web服务器来并行的提高web服务器的代码效率。 84 | 85 | 86 | 87 | ![img](https://pic2.zhimg.com/v2-429d83aa7c42dc3453e82257ca09e9e1_b.jpg) 88 | 89 | 90 | 91 | 只要单台web服务器没有给数据库带来太多的压力,你可以在出现问题前添加很多web服务器,但是这种可扩展性并不是无限的。很可能在某个时间点你有了10台,20台,甚至100台web服务器,它们都在和同一个数据库通信。现在,数据库突然成为了瓶颈,并且增加更多的web服务器都无济于事了。所以很少有可以通过无限增加计算机来获取完整的可扩展性的场景。因为在某个临界点,你在系统中添加计算机的位置将不再是瓶颈了。在我们的例子中,如果你有了很多的web服务器,那么瓶颈就会转移到了别的地方,这里是从web服务器移到了数据库。 92 | 93 | 这时,你几乎是必然要做一些重构工作。但是只有一个数据库时,很难重构它。而虽然可以将一个数据库拆分成多个数据库(进而提升性能),但是这需要大量的工作。 94 | 95 | 96 | 97 | ![img](https://pic2.zhimg.com/v2-38bd12c17be3ae845744ba742f868889_b.jpg) 98 | 99 | 100 | 101 | 我们在本课程中,会看到很多有关分布式存储系统的例子,因为相关论文或者系统的作者都在运行大型网站,而单个数据库或者存储服务器不能支撑这样规模的网站(所以才需要分布式存储)。 102 | 103 | 所以,有关扩展性是这样:我们希望可以通过增加机器的方式来实现扩展,但是现实中这很难实现,需要一些架构设计来将这个可扩展性无限推进下去。 104 | 105 | # 5.可用性 106 | 107 | 另一个重要的话题是容错。 108 | 109 | 如果你只使用一台计算机构建你的系统,那么你的系统大概率是可靠的。因为一台计算机通常可以很好的运行很多年,比如我办公室的服务器已经运行很多年而没有故障,计算机是可靠的,操作系统是可靠的,明显我办公室的电源也是可靠的。所以,一台计算机正常工作很长时间并不少见。然而如果你通过数千台计算机构建你的系统,那么即使每台计算机可以稳定运行一年,对于1000台计算机也意味着平均每天会有3台计算机故障。 110 | 111 | 所以,大型分布式系统中有一个大问题,那就是一些很罕见的问题会被放大。例如在我们的1000台计算机的集群中,总是有故障,要么是机器故障,要么是运行出错,要么是运行缓慢,要么是执行错误的任务。一个更常见的问题是网络,在一个有1000台计算机的网络中,会有大量的网络电缆和网络交换机,所以总是会有人踩着网线导致网线从接口掉出,或者交换机风扇故障导致交换机过热而不工作。在一个大规模分布式系统中,各个地方总是有一些小问题出现。所以大规模系统会将一些几乎不可能并且你不需要考虑的问题,变成一个持续不断的问题。 112 | 113 | 所以,因为错误总会发生,必须要在设计时就考虑,系统能够屏蔽错误,或者说能够在出错时继续运行。同时,因为我们需要为第三方应用开发人员提供方便的抽象接口,我们的确也需要构建这样一种基础架构,它能够尽可能多的对应用开发人员屏蔽和掩盖错误。这样,应用开发人员就不需要处理各种各样的可能发生的错误。 114 | 115 | 对于容错,有很多不同的概念可以表述。这些表述中,有一个共同的思想就是可用性(Availability)。某些系统经过精心的设计,这样在特定的错误类型下,系统仍然能够正常运行,仍然可以像没有出现错误一样,为你提供完整的服务。 116 | 117 | 118 | 119 | ![img](https://pic4.zhimg.com/v2-727bd5223c664b91928c14d88c741773_b.jpg) 120 | 121 | 122 | 123 | 某些系统通过这种方式提供可用性。比如,你构建了一个有两个拷贝的多副本系统,其中一个故障了,另一个还能运行。当然如果两个副本都故障了,你的系统就不再有可用性。所以,可用系统通常是指,在特定的故障范围内,系统仍然能够提供服务,系统仍然是可用的。如果出现了更多的故障,系统将不再可用。 124 | 125 | 除了可用性之外,另一种容错特性是自我可恢复性(recoverability)。这里的意思是,如果出现了问题,服务会停止工作,不再响应请求,之后有人来修复,并且在修复之后系统仍然可以正常运行,就像没有出现过问题一样。这是一个比可用性更弱的需求,因为在出现故障到故障组件被修复期间,系统将会完全停止工作。但是修复之后,系统又可以完全正确的重新运行,所以可恢复性是一个重要的需求。 126 | 127 | 128 | 129 | ![img](https://pic2.zhimg.com/v2-98ff052b7e102ad0f442111871db7349_b.jpg) 130 | 131 | 132 | 133 | 对于一个可恢复的系统,通常需要做一些操作,例如将最新的数据存放在磁盘中,这样在供电恢复之后(假设故障就是断电),才能将这些数据取回来。甚至说对于一个具备可用性的系统,为了让系统在实际中具备应用意义,也需要具备可恢复性。因为可用的系统仅仅是在一定的故障范围内才可用,如果故障太多,可用系统也会停止工作,停止一切响应。但是当足够的故障被修复之后,系统还是需要能继续工作。所以,一个好的可用的系统,某种程度上应该也是可恢复的。当出现太多故障时,系统会停止响应,但是修复之后依然能正确运行。这是我们期望看到的。 134 | 135 | 为了实现这些特性,有很多工具。其中最重要的有两个: 136 | 137 | - 一个是非易失存储(non-volatile storage,类似于硬盘)。这样当出现类似电源故障,甚至整个机房的电源都故障时,我们可以使用非易失存储,比如硬盘,闪存,SSD之类的。我们可以存放一些checkpoint或者系统状态的log在这些存储中,这样当备用电源恢复或者某人修好了电力供给,我们还是可以从硬盘中读出系统最新的状态,并从那个状态继续运行。所以,这里的一个工具是非易失存储。因为更新非易失存储是代价很高的操作,所以相应的出现了很多非易失存储的管理工具。同时构建一个高性能,容错的系统,聪明的做法是避免频繁的写入非易失存储。在过去,甚至对于今天的一个3GHZ的处理器,写入一个非易失存储意味着移动磁盘臂并等待磁碟旋转,这两个过程都非常缓慢。有了闪存会好很多,但是为了获取好的性能,仍然需要许多思考。 138 | - 对于容错的另一个重要工具是复制(replication),不过,管理复制的多副本系统会有些棘手。任何一个多副本系统中,都会有一个关键的问题,比如说,我们有两台服务器,它们本来应该是有着相同的系统状态,现在的关键问题在于,这两个副本总是会意外的偏离同步的状态,而不再互为副本。对于任何一种使用复制实现容错的系统,我们都面临这个问题。lab2和lab3都是通过管理多副本来实现容错的系统,你将会看到这里究竟有多复杂。 139 | 140 | 141 | 142 | ![img](https://pic1.zhimg.com/v2-b430c0a795ea422cf171c9ce2aa7ac08_b.jpg) 143 | 144 | # 6.一致性 145 | 146 | 147 | 148 | 最后一个很重要的话题是一致性(Consistency)。 149 | 150 | 要理解一致性,这里有个例子,假设我们在构建一个分布式存储系统,并且这是一个KV服务。这个KV服务只支持两种操作,其中一个是put操作会将一个value存入一个key;另一个是get操作会取出key对应的value。整体表现就像是一个大的key-value表单。当我需要对一个分布式系统举例时,我总是会想到KV服务,因为它们也很基础,可以算是某种基础简单版本的存储系统。 151 | 152 | 153 | 154 | ![img](https://pic3.zhimg.com/v2-9c7999612bfd5b217f01c8b4e0a76d3a_b.jpg) 155 | 156 | 157 | 158 | 现在,如果你是程序员,如果这两个操作有特定的意义(或者说操作满足一致性),那么对于你是有帮助的。你可以去查看手册,手册会向你解释,如果你调用get你会获取到什么,如果你调用put会有什么效果。如果有这样的手册,那是极好的。否则,如果你不知道put/get的实际行为,你又该如何写你的应用程序呢? 159 | 160 | 一致性就是用来定义操作行为的概念。之所以一致性是分布式系统中一个有趣的话题,是因为,从性能和容错的角度来说,我们通常会有多个副本。在一个非分布式系统中,你通常只有一个服务器,一个表单。虽然不是绝对,但是通常来说对于put/get的行为不会有歧义。直观上来说,put就是更新这个表单,get就是从表单中获取当前表单中存储的数据。但是在一个分布式系统中,由于复制或者缓存,数据可能存在于多个副本当中,于是就有了多个不同版本的key-value对。假设服务器有两个副本,那么他们都有一个key-value表单,两个表单中key 1对应的值都是20。 161 | 162 | 163 | 164 | ![img](https://pic1.zhimg.com/v2-695e5e1ad912d8d40dabcf2c80a088dc_b.jpg) 165 | 166 | 167 | 168 | 现在某个客户端发送了一个put请求,并希望将key 1改成值21。这里或许是KV服务里面的一个计数器。这个put请求发送给了第一台服务器, 169 | 170 | 171 | 172 | ![img](https://pic4.zhimg.com/v2-51a2054292856bd5493db3c8d01587cf_b.jpg) 173 | 174 | 175 | 176 | 之后会发送给第二台服务器,因为相同的put请求需要发送给两个副本,这样这两个副本才能保持同步。但是就在客户端准备给第二台服务器发送相同请求时,这个客户端故障了,可能是电源故障或者操作系统的bug之类的。所以,现在我们处于一个不好的状态,我们发送了一个put请求,更新了一个副本的值是21,但是另一个副本的值仍然是20。 177 | 178 | 179 | 180 | ![img](https://pic2.zhimg.com/v2-5f51b09091bb4513f07bc70796284399_b.jpg) 181 | 182 | 183 | 184 | 如果现在某人通过get读取key为1的值,那么他可能获得21,也可能获得20,取决于get请求发送到了哪个服务器。即使规定了总是把请求先发送给第一个服务器,那么我们在构建容错系统时,如果第一台服务器故障了,请求也会发给第二台服务器。所以不管怎么样,总有一天你会面临暴露旧数据的风险。很可能是这样,最开始许多get请求都得到了21,之后过了一周突然一些get请求得到了一周之前的旧数据(20)。所以,这里不是很一致。并且,如果我们不小心的话,这个场景是可能发生的。所以,我们需要确定put/get操作的一些规则。 185 | 186 | 实际上,对于一致性有很多不同的定义。有一些非常直观,比如说get请求可以得到最近一次完成的put请求写入的值。这种一般也被称为强一致(Strong Consistency)。但是,事实上,构建一个弱一致的系统也是非常有用的。弱一致是指,不保证get请求可以得到最近一次完成的put请求写入的值。尽管有很多细节的工作要处理,强一致可以保证get得到的是put写入的最新的数据;而很多的弱一致系统不会做出类似的保证。所以在一个弱一致系统中,某人通过put请求写入了一个数据,但是你通过get看到的可能仍然是一个旧数据,而这个旧数据可能是很久之前写入的。 187 | 188 | 人们对于弱一致感兴趣的原因是,虽然强一致可以确保get获取的是最新的数据,但是实现这一点的代价非常高。几乎可以确定的是,分布式系统的各个组件需要做大量的通信,才能实现强一致性。如果你有多个副本,那么不管get还是put都需要询问每一个副本。在之前的例子中,客户端在更新的过程中故障了,导致一个副本更新了,而另一个副本没有更新。如果我们要实现强一致,简单的方法就是同时读两个副本,如果有多个副本就读取所有的副本,并使用最近一次写入的数据。但是这样的代价很高,因为需要大量的通信才能得到一个数据。所以,为了尽可能的避免通信,尤其当副本相隔的很远的时候,人们会构建弱一致系统,并允许读取出旧的数据。当然,为了让弱一致更有实际意义,人们还会定义更多的规则。 189 | 190 | 强一致带来的昂贵的通信问题,会把你带入这样的困境:当我们使用多副本来完成容错时,我们的确需要每个副本都有独立的出错概率,这样故障才不会关联。例如,将两个副本放在一个机房的一个机架上,是一个非常糟糕的主意。如果有谁踢到了机架的电源线,那我们数据的两个副本都没了,因为它们都连在同一个机架的同一根电线上。所以,为了使副本的错误域尽可能独立,为了获得良好的容错特性,人们希望将不同的副本放置在尽可能远的位置,例如在不同的城市或者在大陆的两端。这样,如果地震摧毁了一个数据中心,另一个数据中心中的副本有很大可能还能保留。我们期望这样的效果。但是如果我们这么做了,另一个副本可能在数千英里之外,按照光速来算,也需要花费几毫秒到几十毫秒才能完成横跨洲际的数据通信,而这只是为了更新数据的另一个副本。所以,为了保持强一致的通信,代价可能会非常高。因为每次你执行put或者get请求,你都需要等待几十毫秒来与数据的两个副本通信,以确保它们都被更新了或者都被检查了以获得最新的数据。现在的处理器每秒可以执行数十亿条指令,等待几十毫秒会大大影响系统的处理速度。 191 | 192 | 所以,人们常常会使用弱一致系统,你只需要更新最近的数据副本,并且只需要从最近的副本获取数据。在学术界和现实世界(工业界),有大量关于构建弱一致性保证的研究。所以,弱一致对于应用程序来说很有用,并且它可以用来获取高的性能。 193 | 194 | 以上就是本课程中一些技术思想的快速预览。 195 | 196 | # 7.MapReduce基本工作方式 197 | 198 | 接下来介绍MapReduce。这是一个详细的案例研究,它会展示之前讲过的大部分的思想。 199 | 200 | MapReduce是由Google设计,开发和使用的一个系统,相关的论文在2004年发表。Google当时面临的问题是,他们需要在TB级别的数据上进行大量的计算。比如说,为所有的网页创建索引,分析整个互联网的链接路径并得出最重要或者最权威的网页。如你所知,在当时,整个互联网的数据也有数十TB。构建索引基本上等同于对整个数据做排序,而排序比较费时。如果用一台计算机对整个互联网数据进行排序,要花费多长时间呢?可能要几周,几个月,甚至几年。所以,当时Google非常希望能将对大量数据的大量运算并行跑在几千台计算机上,这样才能快速完成计算。对Google来说,购买大量的计算机是没问题的,这样Google的工程师就不用花大量时间来看报纸来等他们的大型计算任务完成。所以,有段时间,Google买了大量的计算机,并让它的聪明的工程师在这些计算机上编写分布式软件,这样工程师们可以将手头的问题分包到大量计算机上去完成,管理这些运算,并将数据取回。 201 | 202 | 如果你只雇佣熟练的分布式系统专家作为工程师,尽管可能会有些浪费,也是可以的。但是Google想雇用的是各方面有特长的人,不一定是想把所有时间都花在编写分布式软件上的工程师。所以Google需要一种框架,可以让它的工程师能够进行任意的数据分析,例如排序,网络索引器,链接分析器以及任何的运算。工程师只需要实现应用程序的核心,就能将应用程序运行在数千台计算机上,而不用考虑如何将运算工作分发到数千台计算机,如何组织这些计算机,如何移动数据,如何处理故障等等这些细节。所以,当时Google需要一种框架,使得普通工程师也可以很容易的完成并运行大规模的分布式运算。这就是MapReduce出现的背景。 203 | 204 | MapReduce的思想是,应用程序设计人员和分布式运算的使用者,只需要写简单的Map函数和Reduce函数,而不需要知道任何有关分布式的事情,MapReduce框架会处理剩下的事情。 205 | 206 | 抽象来看,MapReduce假设有一些输入,这些输入被分割成大量的不同的文件或者数据块。所以,我们假设现在有输入文件1,输入文件2和输入文件3,这些输入可能是从网上抓取的网页,更可能是包含了大量网页的文件。 207 | 208 | 209 | 210 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210220111656.jpeg) 211 | 212 | 213 | 214 | MapReduce启动时,会查找Map函数。之后,MapReduce框架会为每个输入文件运行Map函数。这里很明显有一些可以并行运算的地方,比如说可以并行运行多个只关注输入和输出的Map函数。 215 | 216 | 217 | 218 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210220111656.jpeg) 219 | 220 | 221 | 222 | Map函数以文件作为输入,文件又是整个输入数据的一部分。Map函数的输出是一个key-value对的列表。假设我们在实现一个最简单的MapReduce Job:单词计数器。它会统计每个单词出现的次数。在这个例子中,Map函数会输出key-value对,其中key是单词,而value是1。Map函数会将输入中的每个单词拆分,并输出一个key-value对,key是该单词,value是1。最后需要对所有的key-value进行计数,以获得最终的输出。所以,假设输入文件1包含了单词a和单词b,Map函数的输出将会是key=a,value=1和key=b,value=1。第二个Map函数只从输入文件2看到了b,那么输出将会是key=b,value=1。第三个输入文件有一个a和一个c。 223 | 224 | 225 | 226 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210220111656.jpeg) 227 | 228 | 229 | 230 | 我们对所有的输入文件都运行了Map函数,并得到了论文中称之为中间输出(intermediate output),也就是每个Map函数输出的key-value对。 231 | 232 | 运算的第二阶段是运行Reduce函数。MapReduce框架会收集所有Map函数输出的每一个单词的统计。比如说,MapReduce框架会先收集每一个Map函数输出的key为a的key-value对。收集了之后,会将它们提交给Reduce函数。 233 | 234 | 235 | 236 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210220111656.jpeg) 237 | 238 | 239 | 240 | 之后会收集所有的b。这里的收集是真正意义上的收集,因为b是由不同计算机上的不同Map函数生成,所以不仅仅是数据从一台计算机移动到另一台(如果Map只在一台计算机的一个实例里,可以直接通过一个RPC将数据从Map移到Reduce)。我们收集所有的b,并将它们提交给另一个Reduce函数。这个Reduce函数的入参是所有的key为b的key-value对。对c也是一样。所以,MapReduce框架会为所有Map函数输出的每一个key,调用一次Reduce函数。 241 | 242 | 243 | 244 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210220111656.jpeg) 245 | 246 | 247 | 248 | 在我们这个简单的单词计数器的例子中,Reduce函数只需要统计传入参数的长度,甚至都不用查看传入参数的具体内容,因为每一个传入参数代表对单词加1,而我们只需要统计个数。最后,每个Reduce都输出与其关联的单词和这个单词的数量。所以第一个Reduce输出a=2,第二个Reduce输出b=2,第三个Reduce输出c=1。 249 | 250 | 251 | 252 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210220111656.jpeg) 253 | 254 | 255 | 256 | 这就是一个典型的MapReduce Job。从整体来看,为了保证完整性,有一些术语要介绍一下: 257 | 258 | - Job。整个MapReduce计算称为Job。 259 | - Task。每一次MapReduce调用称为Task。 260 | 261 | 所以,对于一个完整的MapReduce Job,它由一些Map Task和一些Reduce Task组成。所以这是一个单词计数器的例子,它解释了MapReduce的基本工作方式。 262 | 263 | # 8.Map函数和Reduce函数 264 | 265 | Map函数使用一个key和一个value作为参数。我们这里说的函数是由普通编程语言编写,例如C++,Java等,所以这里的函数任何人都可以写出来。入参中,key是输入文件的名字,通常会被忽略,因为我们不太关心文件名是什么,value是输入文件的内容。所以,对于一个单词计数器来说,value包含了要统计的文本,我们会将这个文本拆分成单词。之后对于每一个单词,我们都会调用emit。emit由MapReduce框架提供,并且这里的emit属于Map函数。emit会接收两个参数,其中一个是key,另一个是value。在单词计数器的例子中,emit入参的key是单词,value是字符串“1”。这就是一个Map函数。在一个单词计数器的MapReduce Job中,Map函数实际就可以这么简单。而这个Map函数不需要知道任何分布式相关的信息,不需要知道有多台计算机,不需要知道实际会通过网络来移动数据。这里非常直观。 266 | 267 | 268 | 269 | ![img](https://pic3.zhimg.com/v2-7e712bca5dcb586be6191ec121237932_b.jpg) 270 | 271 | 272 | 273 | Reduce函数的入参是某个特定key的所有实例(Map输出中的key-value对中,出现了一次特定的key就可以算作一个实例)。所以Reduce函数也是使用一个key和一个value作为参数,其中value是一个数组,里面每一个元素是Map函数输出的key的一个实例的value。对于单词计数器来说,key就是单词,value就是由字符串“1”组成的数组,所以,我们不需要关心value的内容是什么,我们只需要关心value数组的长度。Reduce函数也有一个属于自己的emit函数。这里的emit函数只会接受一个参数value,这个value会作为Reduce函数入参的key的最终输出。所以,对于单词计数器,我们会给emit传入数组的长度。这就是一个最简单的Reduce函数。并且Reduce也不需要知道任何有关容错或者其他有关分布式相关的信息。 274 | 275 | 276 | 277 | ![img](https://pic4.zhimg.com/v2-17cea35d52b9d4405650eb156beeae57_b.jpg) 278 | 279 | 280 | 281 | 对于MapReduce的基本框架有什么问题吗? 282 | 283 | > **学生提问:可以将Reduce函数的输出再传递给Map函数吗?** 284 | > Robert教授:在现实中,这是很常见的。MapReduce用户定义了一个MapReduce Job,接收一些输入,生成一些输出。之后可能会有第二个MapReduce Job来消费前一个Job的输出。对于一些非常复杂的多阶段分析或者迭代算法,比如说Google用来评价网页的重要性和影响力的PageRank算法,这些算法是逐渐向答案收敛的。我认为Google最初就是这么使用MapReduce的,他们运行MapReduce Job多次,每一次的输出都是一个网页的列表,其中包含了网页的价值,权重或者重要性。所以将MapReduce的输出作为另一个MapReduce Job的输入这很正常。 285 | > **学生提问:如果可以将Reduce的输出作为Map的输入,在生成Reduce函数的输出时需要有什么注意吗?** 286 | > Robert教授:是的,你需要设置一些内容。比如你需要这么写Reduce函数,使其在某种程度上知道应该按照下一个MapReduce Job需要的格式生成数据。这里实际上带出了一些MapReduce框架的缺点。如果你的算法可以很简单的由Map函数、Map函数的中间输出以及Reduce函数来表达,那是极好的。MapReduce对于能够套用这种形式的算法是极好的。并且,Map函数必须是完全独立的,它们是一些只关心入参的函数。这里就有一些限制了。事实上,很多人想要的更长的运算流程,这涉及到不同的处理。使用MapReduce的话,你不得不将多个MapReduce Job拼装在一起。而在本课程后面会介绍的一些更高级的系统中,会让你指定完整的计算流程,然后这些系统会做优化。这些系统会发现所有你想完成的工作,然后有效的组织更复杂的计算。 287 | > **学生提问:MapReduce框架更重要还是Map/Reduce函数更重要?** 288 | > Robert教授:从程序员的角度来看,只需要关心Map函数和Reduce函数。从我们的角度来看,我们需要关心的是worker进程和worker服务器。这些是MapReduce框架的一部分,它们与其它很多组件一起调用了Map函数和Reduce函数。所以是的,从我们的角度来看,我们更关心框架是如何组成的。从程序员的角度来看,所有的分布式的内容都被剥离了。 289 | > **学生提问:当你调用emit时,数据会发生什么变化?emit函数在哪运行?** 290 | > Robert教授:首先看,这些函数在哪运行。这里可以看MapReduce论文的图1。现实中,MapReduce运行在大量的服务器之上,我们称之为worker服务器或者worker。同时,也会有一个Master节点来组织整个计算过程。这里实际发生的是,Master服务器知道有多少输入文件,例如5000个输入文件,之后它将Map函数分发到不同的worker。所以,它会向worker服务器发送一条消息说,请对这个输入文件执行Map函数吧。之后,MapReduce框架中的worker进程会读取文件的内容,调用Map函数并将文件名和文件内容作为参数传给Map函数。worker进程还需要实现emit,这样,每次Map函数调用emit,worker进程就会将数据写入到本地磁盘的文件中。所以,Map函数中调用emit的效果是在worker的本地磁盘上创建文件,这些文件包含了当前worker的Map函数生成的所有的key和value。 291 | > 所以,Map阶段结束时,我们看到的就是Map函数在worker上生成的一些文件。之后,MapReduce的worker会将这些数据移动到Reduce所需要的位置。对于一个典型的大型运算,Reduce的入参包含了所有Map函数对于特定key的输出。通常来说,每个Map函数都可能生成大量key。所以通常来说,在运行Reduce函数之前。运行在MapReduce的worker服务器上的进程需要与集群中每一个其他服务器交互来询问说,看,我需要对key=a运行Reduce,请看一下你本地磁盘中存储的Map函数的中间输出,找出所有key=a,并通过网络将它们发给我。所以,Reduce worker需要从每一个worker获取特定key的实例。这是通过由Master通知到Reduce worker的一条指令来触发。一旦worker收集完所有的数据,它会调用Reduce函数,Reduce函数运算完了会调用自己的emit,这个emit与Map函数中的emit不一样,它会将输出写入到一个Google使用的共享文件服务中。 292 | > 有关输入和输出文件的存放位置,这是我之前没有提到的,它们都存放在文件中,但是因为我们想要灵活的在任意的worker上读取任意的数据,这意味着我们需要某种网络文件系统(network file system)来存放输入数据。所以实际上,MapReduce论文谈到了GFS(Google File System)。GFS是一个共享文件服务,并且它也运行在MapReduce的worker集群的物理服务器上。GFS会自动拆分你存储的任何大文件,并且以64MB的块存储在多个服务器之上。所以,如果你有了10TB的网页数据,你只需要将它们写入到GFS,甚至你写入的时候是作为一个大文件写入的,GFS会自动将这个大文件拆分成64MB的块,并将这些块平均的分布在所有的GFS服务器之上,而这是极好的,这正是我们所需要的。如果我们接下来想要对刚刚那10TB的网页数据运行MapReduce Job,数据已经均匀的分割存储在所有的服务器上了。如果我们有1000台服务器,我们会启动1000个Map worker,每个Map worker会读取1/1000输入数据。这些Map worker可以并行的从1000个GFS文件服务器读取数据,并获取巨大的读取吞吐量,也就是1000台服务器能提供的吞吐量。 293 | > **学生提问:这里的箭头代表什么意思?** 294 | 295 | 296 | 297 | ![img](https://pic2.zhimg.com/v2-9e9e6fcb5fc40648907807b65374fafd_b.jpg) 298 | 299 | 300 | 301 | > Robert教授:随着Google这些年对MapReduce系统的改进,答案也略有不同。通常情况下,如果我们在一个例如GFS的文件系统中存储大的文件,你的数据分散在大量服务器之上,你需要通过网络与这些服务器通信以获取你的数据。在这种情况下,这个箭头表示MapReduce的worker需要通过网络与存储了输入文件的GFS服务器通信,并通过网络将数据读取到MapReduce的worker节点,进而将数据传递给Map函数。这是最常见的情况。并且这是MapReduce论文中介绍的工作方式。但是如果你这么做了,这里就有很多网络通信。 如果数据总共是10TB,那么相应的就需要在数据中心网络上移动10TB的数据。而数据中心网络通常是GB级别的带宽,所以移动10TB的数据需要大量的时间。在论文发表的2004年,MapReduce系统最大的限制瓶颈是网络吞吐。如果你读到了论文的评估部分,你会发现,当时运行在一个有数千台机器的网络上,每台计算机都接入到一个机架,机架上有以太网交换机,机架之间通过root交换机连接(最上面那个交换机)。 302 | 303 | 304 | 305 | ![img](https://pic3.zhimg.com/v2-3d2bcbfc17e2419f3b8aac19023f8416_b.jpg) 306 | 307 | 308 | 309 | > 如果随机的选择MapReduce的worker服务器和GFS服务器,那么至少有一半的机会,它们之间的通信需要经过root交换机,而这个root交换机的吞吐量总是固定的。如果做一个除法,root交换机的总吞吐除以2000,那么每台机器只能分到50Mb/S的网络容量。这个网络容量相比磁盘或者CPU的速度来说,要小得多。所以,50Mb/S是一个巨大的限制。 310 | > 在MapReduce论文中,讨论了大量的避免使用网络的技巧。其中一个是将GFS和MapReduce混合运行在一组服务器上。所以如果有1000台服务器,那么GFS和MapReduce都运行在那1000台服务器之上。当MapReduce的Master节点拆分Map任务并分包到不同的worker服务器上时,Master节点会找出输入文件具体存在哪台GFS服务器上,并把对应于那个输入文件的Map Task调度到同一台服务器上。所以,默认情况下,这里的箭头是指读取本地文件,而不会涉及网络。虽然由于故障,负载或者其他原因,不能总是让Map函数都读取本地文件,但是几乎所有的Map函数都会运行在存储了数据的相同机器上,并因此节省了大量的时间,否则通过网络来读取输入数据将会耗费大量的时间。 311 | > 我之前提过,Map函数会将输出存储到机器的本地磁盘,所以存储Map函数的输出不需要网络通信,至少不需要实时的网络通信。但是,我们可以确定的是,为了收集所有特定key的输出,并将它们传递给某个机器的Reduce函数,还是需要网络通信。假设现在我们想要读取所有的相关数据,并通过网络将这些数据传递给单台机器,数据最开始在运行Map Task的机器上按照行存储(例如第一行代表第一个Map函数输出a=1,b=1), 312 | 313 | 314 | 315 | ![img](https://pic3.zhimg.com/v2-90c23329fd521e473ead447159ceffee_b.jpg) 316 | 317 | 318 | 319 | > 而我们最终需要这些数据在运行Reduce函数的机器上按照列存储(例如,Reduce函数需要的是第一个Map函数的a=1和第三个Map函数的a=1)。 320 | 321 | 322 | 323 | ![img](https://pic2.zhimg.com/v2-361dd315e4982f8cfb3d810eeed77729_b.jpg) 324 | 325 | 326 | 327 | > 论文里称这种数据转换之为洗牌(shuffle)。所以,这里确实需要将每一份数据都通过网络从创建它的Map节点传输到需要它的Reduce节点。所以,这也是MapReduce中代价较大的一部分。 328 | > **学生提问:是否可以通过Streaming的方式加速Reduce的读取?** 329 | > Robert教授:你是对的。你可以设想一个不同的定义,其中Reduce通过streaming方式读取数据。我没有仔细想过这个方法,我也不知道这是否可行。作为一个程序接口,MapReduce的第一目标就是让人们能够简单的编程,人们不需要知道MapReduce里面发生了什么。对于一个streaming方式的Reduce函数,或许就没有之前的定义那么简单了。 330 | > 不过或许可以这么做。实际上,很多现代的系统中,会按照streaming的方式处理数据,而不是像MapReduce那样通过批量的方式处理Reduce函数。在MapReduce中,需要一直要等到所有的数据都获取到了才会进行Reduce处理,所以这是一种批量处理。现代系统通常会使用streaming并且效率会高一些。 331 | 332 | 所以这里的shuffle的重点是,这里实际上可能会有大量的网络通信。假设你在进行排序,排序的输入输出会有相同的大小。这样,如果你的输入是10TB,为了能排序,你需要将10TB的数据在网络上移动,并且输出也会是10TB,所以这里有大量的数据。这可能发生在任何MapReduce job中,尽管有一些MapReduce job在不同阶段的数据没有那么大。 333 | 334 | 之前有人提过,想将Reduce的输出传给另一个MapReduce job,而这也是人们常做的事情。在一些场景中,Reduce的输出可能会非常巨大,比如排序,比如网页索引器。10TB的输入对应的是10TB的输出。所以,Reduce的输出也会存储在GFS上。但是Reduce只会生成key-value对,MapReduce框架会收集这些数据,并将它们写入到GFS的大文件中。所以,这里有需要一大轮的网络通信,将每个Reduce的输出传输到相应的GFS服务器上。你或许会认为,这里会使用相同的技巧,就将Reduce的输出存储在运行了Reduce Task的同一个GFS服务器上(因为是混部的)。或许Google这么做了,但是因为GFS会将数据做拆分,并且为了提高性能并保留容错性,数据会有2-3份副本。这意味着,不论你写什么,你总是需要通过网络将一份数据拷贝写到2-3台服务器上。所以,这里会有大量的网络通信。这里的网络通信,是2004年限制MapReduce的瓶颈。在2020年,因为之前的网络架构成为了人们想在数据中心中做的很多事情的限制因素,现代数据中心中,root交换机比过去快了很多。并且,你或许已经见过,一个典型的现代数据中心网络,会有很多的root交换机而不是一个交换机(spine-leaf架构)。每个机架交换机都与每个root交换机相连,网络流量在多个root交换机之间做负载分担。所以,现代数据中心网络的吞吐大多了。 335 | 336 | 337 | 338 | ![img](https://pic1.zhimg.com/v2-0d9c5c2648eb974978b891f7c6d88780_b.jpg) 339 | 340 | 341 | 342 | 我认为Google几年前就不再使用MapReduce了,不过在那之前,现代的MapReduce已经不再尝试在GFS数据存储的服务器上运行Map函数了,它乐意从任何地方加载数据,因为网络已经足够快了。 343 | 344 | 好的,我们没有时间聊MapReduce了,下周有一个lab,你会在lab中实现一个你自己的简单版本的MapReduce。 -------------------------------------------------------------------------------- /doc/mit/lab3-VMware FT.md: -------------------------------------------------------------------------------- 1 | # 1.复制 2 | 3 | 这一节课(Lecture 4),我想更多地讨论一些关于容错(Fault-Tolerance)和复制(Replication)的问题,然后,深入的看一下今天的论文,VMware FT。 4 | 5 | 容错本身是为了提供高可用性。例如,当你想构建一个服务时,尽管计算机硬件总是有可能故障,但是我们还是希望能稳定的提供服务,甚至,即使出现了网络问题我们还是想能够提供服务。我们所使用到的工具就是复制,至少在本课程的这一部分是这样。所以,一个很有意思的问题是:复制能处理什么样的故障呢?因为复制也不可能是万能的工具(可以用来解决所有的问题)。 6 | 7 | 用最简单的方法来描述复制能处理的故障,那就是,单台计算机的fail-stop故障。Fail-stop是一种容错领域的通用术语。它是指,如果某些东西出了故障,比如说计算机,那么它会单纯的停止运行。当任何地方出现故障时,就停止运行,而不是运算出错误结果。例如,某人将你服务器的电源线踢掉了,那就会产生一个fail-stop故障。类似的,如果某人拔了你的服务器的网线,即使你的服务器还在运行,那也算是一个fail-stop故障。服务器彻底从网络上隔离的场景有点有趣,因为从外界来看,服务器和停止运行没有两样。所以,这些是我们可以通过复制处理的一些故障。复制也能处理一些硬件问题,比如,服务器的风扇坏了,进而会使CPU过热,而CPU会自我关闭,并停止运行。 8 | 9 | 10 | 11 | ![img](https://pic1.zhimg.com/v2-5eae9768e4e0308b613aee5d84210c4c_b.jpg) 12 | 13 | 14 | 15 | 但是复制不能处理软件中的bug和硬件设计中的缺陷。以MapReduce的Master节点为例,如果我们复制并将其运行在两台计算机上,但是在Master程序里面有一个bug,那么复制对我们没有任何帮助,因为我们在两台计算机上的MapReduce Master都会计算出相同的错误结果,其他组件都会接受这个错误的结果。所以我们不能通过复制软件(为软件构建多副本)来抵御软件的bug,我们不能通过任何的复制的方案来抵御软件的bug。类似的,如我之前所说的,我们也不能期望复制可以处理硬件的漏洞,当硬件有漏洞的时候会计算出错误的结果,这时我们就无能为力了,至少基于复制这种技术,我们就无能为力了。 16 | 17 | 18 | 19 | ![img](https://pic4.zhimg.com/v2-dfa3b168ea382aff5c18f43832ab4d1b_b.jpg) 20 | 21 | 22 | 23 | 当然,如果你足够幸运的话,肯定也有一些硬件和软件的bug是可以被复制处理掉的。比如说,如果有一些不相关的软件运行在你的服务器上,并且它们导致了服务器崩溃,例如kernel panic或者服务器重启,虽然这些软件与你服务的副本无关,但是这种问题对于你的服务来说,也算是一种fail-stop。kernel panic之后,当前服务器上的服务副本会停止运行,备份副本会取而代之。一些硬件错误也可以转换成fail-stop错误,例如,当你通过网络发送了一个包,但是网络传输过程中,由于网络设备故障,导致数据包中的一个bit被翻转了,这可以通过数据包中的校验和检测出来,这样整个数据包会被丢弃。对于磁盘也可以做类似的事情,如果你往磁盘写了一些数据,过了一个月又读出来,但是磁盘的磁面或许不是很完美,导致最重要的几个数据bit读出来是错误的。通过纠错代码,在一定程度上可以修复磁盘中的错误,如果你足够幸运,随机的硬件错误可以被转换成正确的数据,如果没有那么幸运,那么至少可以检测出这里的错误,并将随机的错误转换成检测到的错误,这样,软件就知道发生了错误,并且会将错误转换成一个fail-stop错误,进而停止软件的运行,或者采取一些补救措施。总的来说,我们还是只能期望复制能够处理fail-stop错误。 24 | 25 | 对于复制,还有一些其他的限制。如果我们有两个副本,一个Primay和一个Backup节点,我们总是假设两个副本中的错误是相互独立的。但是如果它们之间的错误是有关联的,那么复制对我们就没有帮助。例如,我们要构建一个大型的系统,我们从同一个厂商买了数千台完全一样的计算机,我们将我们的副本运行在这些同一时间,同一地点购买的计算机上,这还是有一点风险的。因为如果其中一台计算机有制造缺陷,那么极有可能其他的计算机也有相同的缺陷。例如,由于制造商没有提供足够的散热系统,其中一台计算机总是过热,那么很有可能这一批计算机都有相同的问题。所以,如果其中一台因为过热导致宕机,那么其他计算机也很有可能会有相同的问题。这是一种关联错误。 26 | 27 | 你要小心的是另一种情况。比如,数据中心所在的城市发生了地震,摧毁了整个数据中心,无论我们在那个数据中心里有多少副本,都无济于事。因为这种由地震,停电,建筑失火引起的问题,如果多个副本在同一个建筑中,那么这类问题是副本之间关联的错误。所以,如果我们想处理类似地震引起的问题,我们需要将我们的副本放在不同的城市,或者至少物理上把它们分开,这样它们会有独立的供电,不会被同样的自然灾害影响。 28 | 29 | 以上是有关复制的一些背景知识。 30 | 31 | 另一个有关复制的问题是,你或许也会问自己,这种复制的方案是否值得?因为它使用了我们实际需要的2-3倍的计算机资源。GFS对于每个数据块都有3份拷贝,所以我们需要购买实际容量3倍的磁盘。今天的论文(VMware FT)复制了一份,但这也意味着我们需要两倍的计算机,CPU,内存。这些东西都不便宜,所以自然会有这个问题,这里的额外支出真的值得吗? 32 | 33 | 这不是一个可以从技术上来回答的问题,这是一个经济上的问题,它取决于一个可用服务的价值。如果你在运行一个银行系统,并且计算机宕机的后果是你不能再为你的用户提供服务,你将不能再有任何收入,你的用户也会讨厌你,那么多花1000-2000美金再买一台计算机或许是值得的。这种情况下,你可以有一个额外的副本。但是另一方面,如果是这个课程的网站,我不认为它值得拥有一个热备份,因为这个课程网站宕机的后果非常小。所以,对于系统做复制是否值得,该复制多少份,你愿意为复制花费多少,都取决于失败会给你带来多大的损失和不便。 34 | 35 | # 2.复制状态机 36 | 37 | 在VMware FT论文的开始,介绍了两种复制的方法,一种是状态转移(State Transfer),另一种是复制状态机(Replicated State Machine)。这两种我们都会介绍,但是在这门课程中,我们主要还是介绍后者。 38 | 39 | 40 | 41 | ![img](https://pic4.zhimg.com/v2-20c2615958f55ea31523d2d1e5ca3a8f_b.jpg) 42 | 43 | 44 | 45 | 如果我们有一个服务器的两个副本,我们需要让它们保持同步,在实际上互为副本,这样一旦Primary出现故障,因为Backup有所有的信息,就可以接管服务。状态转移背后的思想是,Primary将自己完整状态,比如说内存中的内容,拷贝并发送给Backup。Backup会保存收到的最近一次状态,所以Backup会有所有的数据。当Primary故障了,Backup就可以从它所保存的最新状态开始运行。所以,状态转移就是发送Primary的状态。虽然VMware FT没有采用这种复制的方法,但是假设采用了的话,那么转移的状态就是Primary内存里面的内容。这种情况下,每过一会,Primary就会对自身的内存做一大份拷贝,并通过网络将其发送到Backup。为了提升效率,你可以想到每次同步只发送上次同步之后变更了的内存。 46 | 47 | 复制状态机基于这个事实:我们想复制的大部分的服务或者计算机软件都有一些确定的内部操作,不确定的部分是外部的输入。通常情况下,如果一台计算机没有外部影响,它只是一个接一个的执行指令,每条指令执行的是计算机中内存和寄存器上确定的函数,只有当外部事件干预时,才会发生一些预期外的事。例如,某个随机时间收到了一个网络数据包,导致服务器做一些不同的事情。所以,复制状态机不会在不同的副本之间发送状态,相应的,它只会从Primary将这些外部事件,例如外部的输入,发送给Backup。通常来说,如果有两台计算机,如果它们从相同的状态开始,并且它们以相同的顺序,在相同的时间,看到了相同的输入,那么它们会一直互为副本,并且一直保持一致。 48 | 49 | 所以,状态转移传输的是可能是内存,而复制状态机会将来自客户端的操作或者其他外部事件,从Primary传输到Backup。 50 | 51 | 52 | 53 | ![img](https://pic2.zhimg.com/v2-7035598c7acfd72c20a3356670b69751_b.jpg) 54 | 55 | 56 | 57 | 人们倾向于使用复制状态机的原因是,通常来说,外部操作或者事件比服务的状态要小。如果是一个数据库的话,它的状态可能是整个数据库,可能到达GB这个级别,而操作只是一些客户端发起的请求,例如读key27的数据。所以操作通常来说比较小,而状态通常比较大。所以复制状态机通常来说更吸引人一些。复制状态机的缺点是,它会更复杂一些,并且对于计算机的运行做了更多的假设。而状态转移就比较简单粗暴,我就是将我整个状态发送给你,你不需要再考虑别的东西。 58 | 59 | 有关这些方法有什么问题吗? 60 | 61 | > 学生提问:如果这里的方法出现了问题,导致Primary和Backup并不完全一样,会有什么问题? 62 | > Robert教授:假设我们对GFS的Master节点做了多副本,其中的Primary对Chunk服务器1分发了一个租约。但是因为我们这里可能会出现多副本不一致,所以Backup并没有向任何人发出租约,它甚至都不知道任何人请求了租约,现在Primary认为Chunk服务器1对于某些Chunk有租约,而Backup不这么认为。当Primary挂了,Backup接手,Chunk服务器1会认为它对某些Chunk有租约,而当前的Primary(也就是之前的Backup)却不这么认为。当前的Primary会将租约分发给其他的Chunk服务器。现在我们就有两个Chunk服务器有着相同的租约。这只是一个非常现实的例子,基于不同的副本不一致,你可以构造出任何坏的场景和任何服务器运算出错误结果的情形。我之后会介绍VMware的方案是如何避免这一点的。 63 | > 学生提问:随机操作在复制状态机会怎么处理? 64 | > Robert教授:我待会会再说这个问题,但是这是个好问题。只有当没有外部的事件时,Primary和Backup都执行相同的指令,得到相同的结果,复制状态机才有意义。对于ADD这样的指令来说,这是正确的。如果寄存器和内存都是相同的,那么两个副本执行一条ADD指令,这条指令有相同的输入,也必然会有相同的输出。但是,如你指出的一样,有一些指令,或许是获取当前的时间,因为执行时间的略微不同,会产生不同的结果。又或者是获取当前CPU的唯一ID和序列号,也会产生不同的结果。对于这一类问题的统一答案是,Primary会执行这些指令,并将结果发送给Backup。Backup不会执行这些指令,而是在应该执行指令的地方,等着Primary告诉它,正确的答案是什么,并将监听到的答案返回给软件。 65 | 66 | 有趣的是,或许你已经注意到了,VMware FT论文讨论的都是复制状态机,并且只涉及了单核CPU,目前还不确定论文中的方案如何扩展到多核处理器的机器中。在多核的机器中,两个核交互处理指令的行为是不确定的,所以就算Primary和Backup执行相同的指令,在多核的机器中,它们也不一定产生相同的结果。VMware在之后推出了一个新的可能完全不同的复制系统,并且可以在多核上工作。这个新系统从我看来使用了状态转移,而不是复制状态机。因为面对多核和并行计算,状态转移更加健壮。如果你使用了一台机器,并且将其内存发送过来了,那么那个内存镜像就是机器的状态,并且不受并行计算的影响,但是复制状态机确实会受并行计算的影响。但是另一方面,我认为这种新的多核方案代价会更高一些。 67 | 68 | 如果我们要构建一个复制状态机的方案,我们有很多问题要回答,我们需要决定要在什么级别上复制状态,我们对状态的定义是什么,我们还需要担心Primary和Backup之间同步的频率。因为很有可能Primary会比Backup的指令执行更超前一些,毕竟是Primary接收了外部的输入,Backup几乎必然是要滞后的。这意味着,有可能Primary出现了故障,而Backup没有完全同步上。但是,让Backup与Primary完全同步执行又是代价很高的操作,因为这需要大量的交互。所以,很多设计中,都关注同步的频率有多高。 69 | 70 | 如果Primary发生了故障,必须要有一些切换的方案,并且客户端必须要知道,现在不能与服务器1上的旧Primary通信,而应该与服务器2上的新Primary通信。所有的客户端都必须以某种方式完成这里的切换。几乎不可能设计一个不出现异常现象的切换系统。在理想的环境中,如果Primary故障了,系统会切换到Backup,同时没有人,没有一个客户端会注意到这里的切换。这在实际上基本不可能实现。所以,在切换过程中,必然会有异常,我们必须找到一种应对它们的方法。 71 | 72 | 如果我们的众多副本中有一个故障了,我们需要重新添加一个新的副本。如果我们只有两个副本,其中一个故障了,那我们的服务就命悬一线了,因为第二个副本随时也可能故障。所以我们绝对需要尽快将一个新的副本上线。但是这可能是一个代价很高的行为,因为副本的状态会非常大。我们喜欢复制状态机的原因是,我们认为状态转移的代价太高了。但是对于复制状态机来说,其中的两个副本仍然需要有完整的状态,我们只是有一种成本更低的方式来保持它们的同步。如果我们要创建一个新的副本,我们别无选择,只能使用状态转移,因为新的副本需要有完整状态的拷贝。所以创建一个新的副本,代价会很高。 73 | 74 | 以上就是人们主要担心的问题。我们在讨论其他复制状态机方案时,会再次看到这些问题。 75 | 76 | 让我们回到什么样的状态需要被复制这个话题。VMware FT论文对这个问题有一个非常有趣的回答。它会复制机器的完整状态,这包括了所有的内存,所有的寄存器。这是一个非常非常详细的复制方案,Primary和Backup,即使在最底层也是完全一样的。对于复制方案来说,这种类型是非常少见的。总的来说,大部分复制方案都跟GFS更像。GFS也有复制,但是它绝对没有在Primary和Backup之间复制内存中的每一个bit,它复制的更多是应用程序级别的Chunk。应用程序将数据抽象成Chunk和Chunk ID,GFS只是复制了这些,而没有复制任何其他的东西,所以也不会有复制其他东西的代价。对于应用程序来说,只要Chunk的副本的数据是一致的就可以了。基本上除了VMware FT和一些屈指可数的类似的系统,其他所有的复制方案都是采用的类似GFS的方案。也就是说基本上所有的方案使用的都是应用程序级别的状态复制,因为这更加高效,并且我们也不必陷入这样的困境,比如说需要确保中断在Primary和Backup的相同位置执行,GFS就完全不需要担心这种情况。但是VMware FT就需要担心这种情况,因为它从最底层就开始复制。所以,大多数人构建了高效的,应用程序级别的复制系统。这样做的后果是,复制这个行为,必须构建在应用程序内部。如果你收到了一系列应用程序级别的操作,你确实需要应用程序参与到复制中来,因为一些通用的复制系统,例如VMware FT,理解不了这些操作,以及需要复制的内容。总的来说,大部分场景都是应用程序级别的复制,就像GFS和其他这门课程中会学习的其他论文一样。 77 | 78 | VMware FT的独特之处在于,它从机器级别实现复制,因此它不关心你在机器上运行什么样的软件,它就是复制底层的寄存器和内存。你可以在VMware FT管理的机器上运行任何软件,只要你的软件可以运行在VMware FT支持的微处理器上。这里说的软件可以是任何软件。所以,它的缺点是,它没有那么的高效,优点是,你可以将任何现有的软件,甚至你不需要有这些软件的源代码,你也不需要理解这些软件是如何运行的,在某些限制条件下,你就可以将这些软件运行在VMware FT的这套复制方案上。VMware FT就是那个可以让任何软件都具备容错性的魔法棒。 79 | 80 | # 3.VMware工作原理 81 | 82 | 让我来介绍一下VMware FT是如何工作的。 83 | 84 | 首先,VMware是一个虚拟机公司,它们的业务主要是售卖虚拟机技术。虚拟机的意思是,你买一台计算机,通常只能在硬件上启动一个操作系统。但是如果在硬件上运行一个虚拟机监控器(VMM,Virtual Machine Monitor)或者Hypervisor,Hypervisor会在同一个硬件上模拟出多个虚拟的计算机。所以通过VMM,可以在一个硬件上启动一到多个Linux虚机,一到多个Windows虚机。 85 | 86 | 87 | 88 | ![img](https://pic1.zhimg.com/v2-dd1392d51493fee3f532bd3c748f2910_b.jpg) 89 | 90 | 91 | 92 | 这台计算机上的VMM可以运行一系列不同的操作系统,其中每一个都有自己的操作系统内核和应用程序。 93 | 94 | 95 | 96 | ![img](https://pic3.zhimg.com/v2-972cd38c346a834fdf21175e05ff0cde_b.jpg) 97 | 98 | 99 | 100 | 这是VMware发家的技术,这里的硬件和操作系统之间的抽象,可以有很多很多的好处。首先是,我们只需要购买一台计算机,就可以在上面运行大量不同的操作系统,我们可以在每个操作系统里面运行一个小的服务,而不是购买大量的物理计算机,每个物理计算机只运行一个服务。所以,这是VMware的发家技术,并且它有大量围绕这个技术构建的复杂系统。 101 | 102 | VMware FT需要两个物理服务器。将Primary和Backup运行在一台服务器的两个虚拟机里面毫无意义,因为容错本来就是为了能够抵御硬件故障。所以,你至少需要两个物理服务器运行VMM,Primary虚机在其中一个物理服务器上,Backup在另一个物理服务器上。在其中一个物理服务器上,我们有一个虚拟机,这个物理服务器或许运行了很多虚拟机,但是我们只关心其中一个。这个虚拟机跑了某个操作系统,和一种服务器应用程序,或许是个数据库,或许是MapReduce master或者其他的,我们将之指定为Primary。在第二个物理服务器上,运行了相同的VMM,和一个相同的虚拟机作为Backup。它与Primary有着一样的操作系统。 103 | 104 | 105 | 106 | ![img](https://pic3.zhimg.com/v2-08ceae4474f2c1182d661ed6ee095b52_b.jpg) 107 | 108 | 109 | 110 | 两个物理服务器上的VMM会为每个虚拟机分配一段内存,这两段内存的镜像需要完全一致,或者说我们的目标就是让Primary和Backup的内存镜像完全一致。所以现在,我们有两个物理服务器,它们每一个都运行了一个虚拟机,每个虚拟机里面都有我们关心的服务的一个拷贝。我们假设有一个网络连接了这两个物理服务器。 111 | 112 | 113 | 114 | ![img](https://pic3.zhimg.com/v2-b22d8e62233936988fd58c21e45fc1fe_b.jpg) 115 | 116 | 117 | 118 | 除此之外,在这个局域网(LAN,Local Area Network),还有一些客户端。实际上,它们不必是客户端,可以只是一些我们的多副本服务需要与之交互的其他计算机。其中一些客户端向我们的服务发送请求。在VMware FT里,多副本服务没有使用本地盘,而是使用了一些Disk Server(远程盘)。尽管从论文里很难发现,这里可以将远程盘服务器也看做是一个外部收发数据包的源,与客户端的区别不大。 119 | 120 | 121 | 122 | ![img](https://pic1.zhimg.com/v2-23ea1d9da355a57ef603cd23dfe1de70_b.jpg) 123 | 124 | 125 | 126 | 所以,基本的工作流程是,我们假设这两个副本,或者说这两个虚拟机:Primary和Backup,互为副本。某些我们服务的客户端,向Primary发送了一个请求,这个请求以网络数据包的形式发出。 127 | 128 | 129 | 130 | ![img](https://pic3.zhimg.com/v2-158f69547228cc32ef5e4220d322711e_b.jpg) 131 | 132 | 133 | 134 | 这个网络数据包产生一个中断,之后这个中断送到了VMM。VMM可以发现这是一个发给我们的多副本服务的一个输入,所以这里VMM会做两件事情: 135 | 136 | - 在虚拟机的guest操作系统中,模拟网络数据包到达的中断,以将相应的数据送给应用程序的Primary副本。 137 | - 除此之外,因为这是一个多副本虚拟机的输入,VMM会将网络数据包拷贝一份,并通过网络送给Backup虚机所在的VMM。 138 | 139 | 140 | 141 | ![img](https://pic4.zhimg.com/v2-c8416a54030a1ca36d96139563848573_b.jpg) 142 | 143 | 144 | 145 | Backup虚机所在的VMM知道这是发送给Backup虚机的网络数据包,它也会在Backup虚机中模拟网络数据包到达的中断,以将数据发送给应用程序的Backup。所以现在,Primary和Backup都有了这个网络数据包,它们有了相同的输入,再加上许多细节,它们将会以相同的方式处理这个输入,并保持同步。 146 | 147 | 当然,虚机内的服务会回复客户端的请求。在Primary虚机里面,服务会生成一个回复报文,并通过VMM在虚机内模拟的虚拟网卡发出。之后VMM可以看到这个报文,它会实际的将这个报文发送给客户端。 148 | 149 | 150 | 151 | ![img](https://pic3.zhimg.com/v2-2ad10414898cc6059e7af5d26aabcf4a_b.jpg) 152 | 153 | 154 | 155 | 另一方面,由于Backup虚机运行了相同顺序的指令,它也会生成一个回复报文给客户端,并将这个报文通过它的VMM模拟出来的虚拟网卡发出。但是它的VMM知道这是Backup虚机,会丢弃这里的回复报文。所以这里,Primary和Backup都看见了相同的输入,但是只有Primary虚机实际生成了回复报文给客户端。 156 | 157 | 158 | 159 | ![img](https://pic2.zhimg.com/v2-2542ea3b2fb42fb76dd303b74c6d15f1_b.jpg) 160 | 161 | 162 | 163 | 这里有一个术语,VMware FT论文中将Primary到Backup之间同步的数据流的通道称之为Log Channel。虽然都运行在一个网络上,但是这些从Primary发往Backup的事件被称为Log Channel上的Log Event/Entry。 164 | 165 | 166 | 167 | ![img](https://pic2.zhimg.com/v2-ec2961a3e888611bdb68047ee7a0cb39_b.jpg) 168 | 169 | 170 | 171 | 当Primary因为故障停止运行时,FT(Fault-Tolerance)就开始工作了。从Backup的角度来说,它将不再收到来自于Log Channel上的Log条目。实际中,Backup每秒可以收到很多条Log,其中一个来源就是来自于Primary的定时器中断。每个Primary的定时器中断都会生成一条Log条目并发送给Backup,这些定时器中断每秒大概会有100次。所以,如果Primary虚机还在运行,Backup必然可以期望从Log Channel收到很多消息。如果Primary虚机停止运行了,那么Backup的VMM就会说:天,我都有1秒没有从Log Channel收到任何消息了,Primary一定是挂了或者出什么问题了。当Backup不再从Primary收到消息,VMware FT论文的描述是,Backup虚机会上线(Go Alive)。这意味着,Backup不会再等待来自于Primary的Log Channel的事件,Backup的VMM会让Backup自由执行,而不是受来自于Primary的事件驱动。Backup的VMM会在网络中做一些处理(猜测是发GARP),让后续的客户端请求发往Backup虚机,而不是Primary虚机。同时,Backup的VMM不再会丢弃Backup虚机的输出。当然,它现在已经不再是Backup,而是Primary。所以现在,左边的虚机直接接收输入,直接产生输出。到此为止,Backup虚机接管了服务。 172 | 173 | 类似的一个场景,虽然没那么有趣,但是也需要能正确工作。如果Backup虚机停止运行,Primary也需要用一个类似的流程来抛弃Backup,停止向它发送事件,并且表现的就像是一个单点的服务,而不是一个多副本服务一样。所以,只要有一个因为故障停止运行,并且不再产生网络流量时,Primary和Backup中的另一个都可以上线继续工作。 174 | 175 | > 学生提问:Backup怎么让其他客户端向自己发送请求? 176 | > Robert教授:魔法。。。取决于是哪种网络技术。从论文中看,一种可能是,所有这些都运行在以太网上。每个以太网的物理计算机,或者说网卡有一个48bit的唯一ID(MAC地址)。下面这些都是我(Robert教授)编的。每个虚拟机也有一个唯一的MAC地址,当Backup虚机接手时,它会宣称它有Primary的MAC地址,并向外通告说,我是那个MAC地址的主人。这样,以太网上的其他人就会向它发送网络数据包。不过这只是我(Robert教授)的解读。 177 | > 学生提问:随机数生成器这种操作怎么在Primary和Backup做同步? 178 | > Robert教授:VMware FT的设计者认为他们找到了所有类似的操作,对于每一个操作,Primary执行随机数生成,或者某个时间点生成的中断(依赖于执行时间点的中断)。而Backup虚机不会执行这些操作,Backup的VMM会探测这些指令,拦截并且不执行它们。VMM会让Backup虚机等待来自Log Channel的有关这些指令的指示,比如随机数生成器这样的指令,之后VMM会将Primary生成的随机数发送给Backup。 179 | > 论文有暗示说他们让Intel向处理器加了一些特性来支持这里的操作,但是论文没有具体说是什么特性。 180 | 181 | # 4.非确定事件 182 | 183 | 好的,目前为止,我们都假设只要Backup虚机也看到了来自客户端的请求,经过同样的执行过程,那么它就会与Primary保持一致,但是这背后其实有很多很重要的细节。就如其他同学之前指出的一样,其中一个问题是存在非确定性(Non-Deterministic)的事件。虽然通常情况下,代码执行都是直接明了的,但并不是说计算机中每一个指令都是由计算机内存的内容而确定的行为。这一节,我们来看一下不由当前内存直接决定的指令。如果我们不够小心,这些指令在Primary和Backup的运行结果可能会不一样。这些指令就是所谓的非确定性事件。所以,设计者们需要弄明白怎么让这一类事件能在Primary和Backup之间同步。 184 | 185 | 186 | 187 | ![img](https://pic3.zhimg.com/v2-2cdf43379ae7aa8e52cbd780a41ea056_b.jpg) 188 | 189 | 190 | 191 | 非确定性事件可以分成几类。 192 | 193 | - 客户端输入。假设有一个来自于客户端的输入,这个输入随时可能会送达,所以它是不可预期的。客户端请求何时送达,会有什么样的内容,并不取决于服务当前的状态。我们讨论的系统专注于通过网络来进行交互,所以这里的系统输入的唯一格式就是网络数据包。所以当我们说输入的时候,我们实际上是指接收到了一个网络数据包。而一个网络数据包对于我们来说有两部分,一个是数据包中的数据,另一个是提示数据包送达了的中断。当网络数据包送达时,通常网卡的DMA(Direct Memory Access)会将网络数据包的内容拷贝到内存,之后触发一个中断。操作系统会在处理指令的过程中消费这个中断。对于Primary和Backup来说,这里的步骤必须看起来是一样的,否则它们在执行指令的时候就会出现不一致。所以,这里的问题是,中断在什么时候,具体在指令流中的哪个位置触发?对于Primary和Backup,最好要在相同的时间,相同的位置触发,否则执行过程就是不一样的,进而会导致它们的状态产生偏差。所以,我们不仅关心网络数据包的内容,还关心中断的时间。 194 | 195 | 196 | 197 | ![img](https://pic4.zhimg.com/v2-bcc9cdf43c4472676f759943d49f30fb_b.jpg) 198 | 199 | 200 | 201 | - 另外,如其他同学指出的,有一些指令在不同的计算机上的行为是不一样的,这一类指令称为怪异指令,比如说: 202 | 203 | - - 随机数生成器 204 | - 获取当前时间的指令,在不同时间调用会得到不同的结果 205 | - 获取计算机的唯一ID 206 | 207 | 208 | 209 | 210 | 211 | ![img](https://pic1.zhimg.com/v2-30b8151638300d6f7e6ed6c81710198c_b.jpg) 212 | 213 | 214 | 215 | - 另外一个常见的非确定事件,在VMware FT论文中没有讨论,就是多CPU的并发。我们现在讨论的都是一个单进程系统,没有多CPU多核这种事情。之所以多核会导致非确定性事件,是因为当服务运行在多CPU上时,指令在不同的CPU上会交织在一起运行,进而产生的指令顺序是不可预期的。所以如果我们在Backup上运行相同的代码,并且代码并行运行在多核CPU上,硬件会使得指令以不同(于Primary)的方式交织在一起,而这会引起不同的运行结果。假设两个核同时向同一份数据请求锁,在Primary上,核1得到了锁;在Backup上,由于细微的时间差别核2得到了锁,那么执行结果极有可能完全不一样,这里其实说的就是(在两个副本上)不同的线程获得了锁。所以,多核是一个巨大的非确定性事件来源,VMware FT论文完全没有讨论它,并且它也不适用与我们这节课的讨论。 216 | 217 | 218 | 219 | ![img](https://pic4.zhimg.com/v2-84cb59db02818bb02b8a75d59d9df64b_b.jpg) 220 | 221 | 222 | 223 | > 学生提问:如何确保VMware FT管理的服务只使用单核? 224 | > Robert教授:服务不能使用多核并行计算。硬件几乎可以肯定是多核并行的,但是这些硬件在VMM之下。在这篇论文中,VMM暴露给运行了Primary和Backup虚机操作系统的硬件是单核的。我猜他们也没有一种简单的方法可以将这里的内容应用到一个多核的虚拟机中。 225 | 226 | 所有的事件都需要通过Log Channel,从Primary同步到Backup。有关日志条目的格式在论文中没有怎么描述,但是我(Robert教授)猜日志条目中有三样东西: 227 | 228 | 1. 事件发生时的指令序号。因为如果要同步中断或者客户端输入数据,最好是Primary和Backup在相同的指令位置看到数据,所以我们需要知道指令序号。这里的指令号是自机器启动以来指令的相对序号,而不是指令在内存中的地址。比如说,我们正在执行第40亿零79条指令。所以日志条目需要有指令序号。对于中断和输入来说,指令序号就是指令或者中断在Primary中执行的位置。对于怪异的指令(Weird instructions),比如说获取当前的时间来说,这个序号就是获取时间这条指令执行的序号。这样,Backup虚机就知道在哪个指令位置让相应的事件发生。 229 | 2. 日志条目的类型,可能是普通的网络数据输入,也可能是怪异指令。 230 | 3. 最后是数据。如果是一个网络数据包,那么数据就是网络数据包的内容。如果是一个怪异指令,数据将会是这些怪异指令在Primary上执行的结果。这样Backup虚机就可以伪造指令,并提供与Primary相同的结果。 231 | 232 | 233 | 234 | ![img](https://pic4.zhimg.com/v2-27c9284c3e07d14c9bccf408490447c3_b.jpg) 235 | 236 | 237 | 238 | 举个例子,Primary和Backup两个虚机内部的guest操作系统需要在模拟的硬件里有一个定时器,能够每秒触发100次中断,这样操作系统才可以通过对这些中断进行计数来跟踪时间。因此,这里的定时器必须在Primary和Backup虚机的完全相同位置产生中断,否则这两个虚机不会以相同的顺序执行指令,进而可能会产生分歧。所以,在运行了Primary虚机的物理服务器上,有一个定时器,这个定时器会计时,生成定时器中断并发送给VMM。在适当的时候,VMM会停止Primary虚机的指令执行,并记下当前的指令序号,然后在指令序号的位置插入伪造的模拟定时器中断,并恢复Primary虚机的运行。之后,VMM将指令序号和定时器中断再发送给Backup虚机。虽然Backup虚机的VMM也可以从自己的物理定时器接收中断,但是它并没有将这些物理定时器中断传递给Backup虚机的guest操作系统,而是直接忽略它们。当来自于Primary虚机的Log条目到达时,Backup虚机的VMM配合特殊的CPU特性支持,会使得物理服务器在相同的指令序号处产生一个定时器中断,之后VMM获取到这个中断,并伪造一个假的定时器中断,并将其送入Backup虚机的guest操作系统,并且这个定时器中断会出现在与Primary相同的指令序号位置。 239 | 240 | > 学生提问:这里的操作依赖硬件的定制吗?(实际上我听不清,猜的) 241 | > Robert教授:是的,这里依赖于CPU的一些特殊的定制,这样VMM就可以告诉CPU,执行1000条指令之后暂停一下,方便VMM将伪造的中断注入,这样Backup虚机就可以与Primary虚机在相同的指令位置触发相同的中断,执行相同的指令。之后,VMM会告诉CPU恢复执行。这里需要一些特殊的硬件,但是现在看起来所有的Intel芯片上都有这个功能,所以也不是那么的特殊。或许15年前,这个功能还是比较新鲜的,但是现在来说就比较正常了。现在这个功能还有很多其他用途,比如说做CPU时间性能分析,可以让处理器每1000条指令中断一次,这里用的是相同的硬件让微处理器每1000条指令产生一个中断。所以现在,这是CPU中非常常见的一个小工具。 242 | > 学生提问:如果Backup领先了Primary会怎么样? 243 | > Robert教授: 场景可能是这样,Primary即将在第100万条指令处中断,但是Backup已经执行了100万零1条指令了。如果我们让这种场景发生,那么Primary的中断传输就太晚了。如果我们允许Backup执行领先Primary,就会使得中断在Backup中执行位置落后于Primary。所以我们不能允许这种情况发生,我们不能允许Backup在执行指令时领先于Primary。 244 | > VMware FT是这么做的。它会维护一个来自于Primary的Log条目的等待缓冲区,如果缓冲区为空,Backup是不允许执行指令的。如果缓冲区不为空,那么它可以根据Log的信息知道Primary对应的指令序号,并且会强制Backup虚机最多执行指令到这个位置。所以,Backup虚机的CPU总是会被通知执行到特定的位置就停止。Backup虚机只有在Log缓冲区中有数据才会执行,并且只会执行到Log条目对应的指令序号。在Primary产生的第一个Log,并且送达Backup之前,Backup甚至都不能执行指令,所以Backup总是落后于Primary至少一个Log。如果物理服务器的资源占用过多,导致Backup执行变慢,那么Backup可能落后于Primary多个Log条目。 245 | 246 | 网络数据包送达时,有一个细节会比较复杂。当网络数据包到达网卡时,如果我们没有运行虚拟机,网卡会将网络数据包通过DMA的方式送到计算机的关联内存中。现在我们有了虚拟机,并且这个网络数据包是发送给虚拟机的,在虚拟机内的操作系统可能会监听DMA并将数据拷贝到虚拟机的内存中。因为VMware的虚拟机设计成可以支持任何操作系统,我们并不知道网络数据包到达时操作系统会执行什么样的操作,有的操作系统或许会真的监听网络数据包拷贝到内存的操作。 247 | 248 | 我们不能允许这种情况发生。如果我们允许网卡直接将网络数据包DMA到Primary虚机中,我们就失去了对于Primary虚机的时序控制,因为我们也不知道什么时候Primary会收到网络数据包。所以,实际中,物理服务器的网卡会将网络数据包拷贝给VMM的内存,之后,网卡中断会送给VMM,并说,一个网络数据包送达了。这时,VMM会暂停Primary虚机,记住当前的指令序号,将整个网络数据包拷贝给Primary虚机的内存,之后模拟一个网卡中断发送给Primary虚机。同时,将网络数据包和指令序号发送给Backup。Backup虚机的VMM也会在对应的指令序号暂停Backup虚机,将网络数据包拷贝给Backup虚机,之后在相同的指令序号位置模拟一个网卡中断发送给Backup虚机。这就是论文中介绍的Bounce Buffer机制。 249 | 250 | > 学生提问:怪异的指令(Weird instructions)会有多少呢? 251 | > Robert教授:怪异指令非常少。只有可能在Primary和Backup中产生不同结果的指令,才会被封装成怪异指令,比如获取当前时间,或者获取当前处理器序号,或者获取已经执行的的指令数,或者向硬件请求一个随机数用来加密,这种指令相对来说都很少见。大部分指令都是类似于ADD这样的指令,它们会在Primary和Backup中得到相同的结果。每个网络数据包未做修改直接被打包转发,然后被两边虚拟机的TCP/IP协议栈解析也会得到相同的结果。所以我预期99.99%的Log Channel中的数据都会是网络数据包,只有一小部分是怪异指令。 252 | > 所以对于一个服务于客户端的服务来说,我们可以通过客户端流量判断Log Channel的流量大概是什么样子,因为它基本上就是客户端发送的网络数据包的拷贝。 253 | 254 | # 5.输出控制 255 | 256 | 对于VMware FT系统的输出,也是值得说一下的。在这个系统中,唯一的输出就是对于客户端请求的响应。客户端通过网络数据包将数据送入,服务器的回复也会以网络数据包的形式送出。我之前说过,Primary和Backup虚机都会生成回复报文,之后通过模拟的网卡送出,但是只有Primary虚机才会真正的将回复送出,而Backup虚机只是将回复简单的丢弃掉。 257 | 258 | 好吧,真实情况会复杂一些。假设我们正在跑一个简单的数据库服务器,这个服务器支持一个计数器自增操作,工作模式是这样,客户端发送了一个自增的请求,服务器端对计数器加1,并返回新的数值。假设最开始一切正常,在Primary和Backup中的计数器都存了10。 259 | 260 | 261 | 262 | ![img](https://pic4.zhimg.com/v2-027195631538364ab80ebe3141242c03_b.jpg) 263 | 264 | 265 | 266 | 现在,局域网的一个客户端发送了一个自增的请求给Primary, 267 | 268 | 269 | 270 | ![img](https://pic1.zhimg.com/v2-cf5680641ee7c700e4e715a74fe76a0c_b.jpg) 271 | 272 | 273 | 274 | 这个请求在Primary虚机的软件中执行,Primary会发现,现在的数据是10,我要将它变成11,并回复客户端说,现在的数值是11。 275 | 276 | 277 | 278 | ![img](https://pic4.zhimg.com/v2-b476e9c5968374e9575243b881d15553_b.jpg) 279 | 280 | 281 | 282 | 这个请求也会发送给Backup虚机,并将它的数值从10改到11。Backup也会产生一个回复,但是这个回复会被丢弃,这是我们期望发生的。 283 | 284 | 285 | 286 | ![img](https://pic4.zhimg.com/v2-caf82686f756e429b43e2661a94826ab_b.jpg) 287 | 288 | 289 | 290 | 但是,你需要考虑,如果在一个不恰当的时间,出现了故障会怎样?在这门课程中,你需要始终考虑,故障的最坏场景是什么,故障会导致什么结果?在这个例子中,假设Primary确实生成了回复给客户端,但是之后立马崩溃了。更糟糕的是,现在网络不可靠,Primary发送给Backup的Log条目在Primary崩溃时也丢包了。那么现在的状态是,客户端收到了回复说现在的数据是11,但是Backup虚机因为没有看到客户端请求,所以它保存的数据还是10。 291 | 292 | 293 | 294 | ![img](https://pic2.zhimg.com/v2-2338bc6f49c6510e92d2db11dc274991_b.jpg) 295 | 296 | 297 | 298 | 现在,因为察觉到Primary崩溃了,Backup接管服务。这时,客户端再次发送一个自增的请求,这个请求发送到了原来的Backup虚机,它会将自身的数值从10增加到11,并产生第二个数据是11的回复给客户端。 299 | 300 | 301 | 302 | ![img](https://pic2.zhimg.com/v2-410a3090fb2b6b696e35a9f6a74adf89_b.jpg) 303 | 304 | 305 | 306 | 如果客户端比较前后两次的回复,会发现一个明显不可能的场景(两次自增的结果都是11)。 307 | 308 | 因为VMware FT的优势就是在不修改软件,甚至软件都不需要知道复制的存在的前提下,就能支持容错,所以我们也不能修改客户端让它知道因为容错导致的副本切换触发了一些奇怪的事情。在VMware FT场景里,我们没有修改客户端这个选项,因为整个系统只有在不修改服务软件的前提下才有意义。所以,前面的例子是个大问题,我们不能让它实际发生。有人还记得论文里面是如何防止它发生的吗? 309 | 310 | 论文里的解决方法就是控制输出(Output Rule)。直到Backup虚机确认收到了相应的Log条目,Primary虚机不允许生成任何输出。让我们回到Primary崩溃前,并且计数器的内容还是10,Primary上的正确的流程是这样的: 311 | 312 | 1. 客户端输入到达Primary。 313 | 2. Primary的VMM将输入的拷贝发送给Backup虚机的VMM。所以有关输入的Log条目在Primary虚机生成输出之前,就发往了Backup。之后,这条Log条目通过网络发往Backup,但是过程中有可能丢失。 314 | 3. Primary的VMM将输入发送给Primary虚机,Primary虚机生成了输出。现在Primary虚机的里的数据已经变成了11,生成的输出也包含了11。但是VMM不会无条件转发这个输出给客户端。 315 | 4. Primary的VMM会等到之前的Log条目都被Backup虚机确认收到了才将输出转发给客户端。所以,包含了客户端输入的Log条目,会从Primary的VMM送到Backup的VMM,Backup的VMM不用等到Backup虚机实际执行这个输入,就会发送一个表明收到了这条Log的ACK报文给Primary的VMM。当Primary的VMM收到了这个ACK,才会将Primary虚机生成的输出转发到网络中。 316 | 317 | 所以,这里的核心思想是,确保在客户端看到对于请求的响应时,Backup虚机一定也看到了对应的请求,或者说至少在Backup的VMM中缓存了这个请求。这样,我们就不会陷入到这个奇怪的场景:客户端已经收到了回复,但是因为有故障发生和副本切换,新接手的副本完全不知道客户端之前收到了对应的回复。 318 | 319 | 如果在上面的步骤2中,Log条目通过网络发送给Backup虚机时丢失了,然后Primary虚机崩溃了。因为Log条目丢失了, 所以Backup节点也不会发送ACK消息。所以,如果Log条目的丢失与Primary的崩溃同一时间发生,那么Primary必然在VMM将回复转发到网络之前就崩溃了,所以客户端也就不会收到任何回复,所以客户端就不会观察到任何异常。这就是输出控制(Output rule)。 320 | 321 | > 学生提问:VMM这里是具体怎么实现的? 322 | > Robert教授:我不太清楚,论文也没有说VMM是如何实现的。我的意思是,这里涉及到非常底层的内容,因为包括了内存分配,页表(page table)分配,设备驱动交互,指令拦截,并理解guest操作系统正在执行的指令。这些都是底层的东西,它们通常用C或者C++实现,但是具体的内容我就不清楚了。 323 | 324 | 所以,Primary会等到Backup已经有了最新的数据,才会将回复返回给客户端。这几乎是所有的复制方案中对于性能产生伤害的地方。这里的同步等待使得Primary不能超前Backup太多,因为如果Primary超前了并且又故障了,对应的就是Backup的状态落后于客户端的状态。 325 | 326 | 327 | 328 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210221140250.jpeg) 329 | 330 | 331 | 332 | 所以,几乎每一个复制系统都有这个问题,在某个时间点,Primary必须要停下来等待Backup,这对于性能是实打实的限制。即使副本机器在相邻的机架上,Primary节点发送消息并收到回复仍然需要0.5毫秒的延时。如果我们想要能承受类似于地震或者城市范围内的断电等问题,Primary和Backup需要在不同的城市,之间可能有5毫秒的差距。如果我们将两个副本放置在不同的城市,每次生成一个输出时,都需要至少等待5毫秒,等Backup确认收到了前一个Log条目,然后VMM才能将输出发送到网络。对于一些低请求量的服务,这不是问题。但是如果我们的服务要能够每秒处理数百万个请求,那就会对我们的性能产生巨大的伤害。 333 | 334 | 所以如果条件允许,人们会更喜欢使用在更高层级做复制的系统(详见4.2 最后两段)。这样的复制系统可以理解操作的含义,这样的话Primary虚机就不必在每个网络数据包暂停同步一下,而是可以在一个更高层级的操作层面暂停来做同步,甚至可以对一些只读操作不做暂停。但是这就需要一些特殊的应用程序层面的复制机制。 335 | 336 | > 学生提问:其实不用暂停Primary虚机的执行,只需要阻止Primary虚机的输出就行吧? 337 | > Robert教授:你是对的。所以,这里的同步等待或许没有那么糟糕。但是不管怎么样,在一个系统中,本来可以几微秒响应一个客户端请求,而现在我们需要先更新另一个城市的副本,这可能会将一个10微秒的操作变成10毫秒。 338 | > 学生提问:这里虽然等待时间比较长,如果提高请求的并发度,是不是还是可以有高性能? 339 | > Robert教授:如果你有大量的客户端并发的发送请求,那么你或许还是可以在高延时的情况下获得高的吞吐量,但是就需要你有足够聪明的设计和足够的幸运。 340 | > 学生提问:可以不可以将Log保留在Primary虚机对应的物理服务器内存中,这样就不用长时间的等待了。 341 | > Robert教授:这是一个很好的想法。但是如果你这么做的话,物理服务器宕机,Log就丢失了。通常,如果服务器故障,就认为服务器中的所有数据都没了,其中包括内存的内容。如果故障是某人不小心将服务器的电源拔了,即使Primary对应的物理服务器有电池供电的RAM,Backup也没办法从其获取Log。实际上,系统会在Backup的内存中记录Log。为了保证系统的可靠性,Primary必须等待Backup的ACK才真正输出。你这里的想法很好,但是我们还是不能使用Primary的内存来存Log。 342 | > 学生提问:能不能输入送到Primary,输出从Backup送出? 343 | > Robert教授:这是个很聪明的想法。我之前完全没有想到过这点。它或许可以工作,我不确定,但是这很有意思。 344 | 345 | # 6.重复输出 346 | 347 | 还有一种可能的情况是,回复报文已经从VMM发往客户端了,所以客户端收到了回复,但是这时Primary虚机崩溃了。而在Backup侧,客户端请求还堆积在Backup对应的VMM的Log等待缓冲区(详见4.4倒数第二个学生提问),也就是说客户端请求还没有真正发送到Backup虚机中。当Primary崩溃之后,Backup接管服务,Backup首先需要消费所有在等待缓冲区中的Log,以保持与Primay在相同的状态,这样Backup才能以与Primary相同的状态接管服务。假设最后一条Log条目对应来自客户端的请求,那么Backup会在处理完客户端请求对应的中断之后,再上线接管服务。这意味着,Backup会将自己的计数器增加到11(原来是10,处理完客户端的自增请求变成11),并生成一个输出报文。因为这时,Backup已经上线接管服务,它生成的输出报文会被它的VMM发往客户端。这样客户端会收到两个内容是11的回复。如果这里的情况真的发生了,那么明显这也是一个异常行为,因为不可能在运行在单个服务器的服务上发生这种行为。 348 | 349 | 好消息是,几乎可以肯定,客户端通过TCP与服务进行交互,也就是说客户端请求和回复都通过TCP Channel收发。当Backup接管服务时,因为它的状态与Primary相同,所以它知道TCP连接的状态和TCP传输的序列号。当Backup生成回复报文时,这个报文的TCP序列号与之前Primary生成报文的TCP序列号是一样的,这样客户端的TCP栈会发现这是一个重复的报文,它会在TCP层面丢弃这个重复的报文,用户层的软件永远也看不到这里的重复。 350 | 351 | 这里可以认为是异常的场景,并且被意外的解决了。但是事实上,对于任何有主从切换的复制系统,基本上不可能将系统设计成不产生重复输出。为了避免重复输出,有一个选项是在两边都不生成输出,但这是一个非常糟糕的做法(因为对于客户端来说就是一次失败的请求)。当出现主从切换时,切换的两边都有可能生成重复的输出,这意味着,某种程度上来说,所有复制系统的客户端需要一种重复检测机制。这里我们使用的是TCP来完成重复检测,如果我们没有TCP,那就需要另一种其他机制,或许是应用程序级别的序列号。 352 | 353 | 在lab2和lab3中,基本上可以看到我们前面介绍的所有内容,例如输出控制,你会设计你的复制状态机。 354 | 355 | > 学生提问:太长了,听不太清,直接看回答吧。 356 | > Robert教授:第一部分是对的。当Backup虚机消费了最后一条Log条目,这条Log包含了客户端的请求,并且Backup上线了。从这个时间点开始,我们不需要复制任何东西,因为Primary已经挂了,现在没有任何其他副本。 357 | > 如果Primary向客户端发送了一个回复报文,之后,Primary或者客户端关闭了TCP连接,所以现在客户端侧是没有TCP连接的。Primary挂了之后,Backup虚机还是有TCP连接的信息。Backup执行最后一条Log,Backup会生成一个回复报文,但是这个报文送到客户端时,客户端并没有相应的TCP连接信息。客户端会直接丢弃报文,就像这个报文不存在一样。哦不!这里客户端实际会发送一个TCP Reset,这是一个类似于TCP error的东西给Backup虚机,Backup会处理这里的TCP Reset,但是没关系,因为现在只有一个副本,Backup可以任意处理,而不用担心与其他副本有差异。实际上,Backup会直接忽略这个报文。现在Backup上线了,在这个复制系统里面,它不受任何人任何事的限制。 358 | > 学生提问:Backup接手服务之后,对于之前的TCP连接,还能用相同的TCP源端口来发送数据吗(因为源端口一般是随机的)? 359 | > Robert教授:你可以这么认为。因为Backup的内存镜像与Primary的完全一致,所以它们会以相同的TCP源端口来发送数据,它们在每一件事情上都是一样的。它们发送的报文每一bit都是一样的。 360 | > 学生提问:甚至对于IP地址都会是一样的吗,毕竟这里涉及两个物理服务器? 361 | > Robert教授:在这个层面,物理服务器并没有IP地址。在我们的例子中,Primary虚机和Backup虚机都有IP地址,但是物理服务器和VMM在网络上基本是透明的。物理服务器上的VMM在网络上并没有自己的唯一标识。虚拟机有自己独立的操作系统和独立的TCP栈,但是对于IP地址和其他的关联数据,Primary和Backup是一样的(类似于HA VIP)。当虚机发送一个网络报文,它会以虚机的IP地址和MAC地址来发送,这些信息是直接透传到局域网的,而这正是我们想要的。所以Backup会生成与Primary完全一样的报文。这里有一些tricky,因为如果物理服务器都接在一个以太网交换机上,那么它们必然在交换机的不同端口上,在发生切换时,我们希望以太网交换机能够知道当前主节点在哪,这样才能正常的转发报文,这会有一些额外的有意思的事情。大部分时候,Primary和Backup都是生成相同的报文,并送出。 362 | > (注:早期的VMware虚机都是直接以VLAN或者Flat形式,通过DVS接入到物理网络,所以虚拟机的报文与物理机无关,可以直接在局域网发送。以太网交换机会维护MAC地址表,表明MAC地址与交换机端口的对应,因为Primary和Backup虚机的MAC地址一样,当主从切换时,这个表需要更新,这样同一个目的MAC地址,切换前是发往了Primary虚机所在的物理服务器对应的交换机端口,切换之后是发往了Backup虚机所在的物理服务器对应的交换机端口。交换机MAC地址表的切换通常通过虚机主动发起GARP来更新。) 363 | 364 | # 7.Test-and-set服务 365 | 366 | 最后还有一个细节。我一直都假设Primary出现的是fail-stop故障(详见4.1最开始),但是这不是所有的情况。一个非常常见的场景就是,Primary和Backup都在运行,但是它们之间的网络出现了问题,同时它们各自又能够与一些客户端通信。这时,它们都会以为对方挂了,自己需要上线并接管服务。所以现在,我们对于同一个服务,有两个机器是在线的。因为现在它们都不向彼此发送Log条目,它们自然就出现了分歧。它们或许会因为接收了不同的客户端请求,而变得不一样。 367 | 368 | 因为涉及到了计算机网络,那就可能出现上面的问题,而不仅仅是机器故障。如果我们同时让Primary和Backup都在线,那么我们现在就有了脑裂(Split Brain)。这篇论文解决这个问题的方法是,向一个外部的第三方权威机构求证,来决定Primary还是Backup允许上线。这里的第三方就是Test-and-Set服务。 369 | 370 | Test-and-Set服务不运行在Primary和Backup的物理服务器上,VMware FT需要通过网络支持Test-and-Set服务。这个服务会在内存中保留一些标志位,当你向它发送一个Test-and-Set请求,它会设置标志位,并且返回旧的值。Primary和Backup都需要获取Test-and-Set标志位,这有点像一个锁。为了能够上线,它们或许会同时发送一个Test-and-Set请求,给Test-and-Set服务。当第一个请求送达时,Test-and-Set服务会说,这个标志位之前是0,现在是1。第二个请求送达时,Test-and-Set服务会说,标志位已经是1了,你不允许成为Primary。对于这个Test-and-Set服务,我们可以认为运行在单台服务器。当网络出现故障,并且两个副本都认为对方已经挂了时,Test-and-Set服务就是一个仲裁官,决定了两个副本中哪一个应该上线。 371 | 372 | 对于这种机制有什么问题吗? 373 | 374 | > 学生提问:只有在网络故障的时候才需要询问Test-and-Set服务吗? 375 | > Robert教授:即使没有网络分区,在所有情况下,两个副本中任意一个觉得对方挂了,哪怕对方真的挂了,想要上线的那个副本仍然需要获得Test-and-Set服务的锁。在6.824这门课程中,有个核心的规则就是,你无法判断另一个计算机是否真的挂了,你所知道的就是,你无法从那台计算机收到网络报文,你无法判断是因为那台计算机挂了,还是因为网络出问题了导致的。所以,Backup看到的是,我收不到来自Primary的网络报文,或许Primary挂了,或许还活着。Primary或许也同时看不到Backup的报文。所以,如果存在网络分区,那么必然要询问Test-and-Set服务。但是实际上没人知道现在是不是网络分区,所以每次涉及到主从切换,都需要向Test-and-Set服务进行查询。所以,当副本想要上线的时候,Test-and-Set服务必须要在线,因为副本需要获取这里的Test-and-Set锁。现在Test-and-Set看起来像是个单点故障(Single-Point-of-Failure)。虽然VMware FT尝试构建一个复制的容错的系统,但是最后,主从切换还是依赖于Test-and-Set服务在线,这有点让人失望。我强烈的认为,Test-and-Set服务本身也是个复制的服务,并且是容错的。几乎可以肯定的是,VMware非常乐意向你售卖价值百万的高可用存储系统,系统内使用大量的复制服务。因为这里用到了Test-and-Set服务,我猜它也是复制的。 376 | 377 | 你们将要在Lab2和Lab3构建的系统,会帮助你们构建容错的Test-and-Set服务,所以这个问题可以轻易被解决。 -------------------------------------------------------------------------------- /doc/mit/lab5-raft2.md: -------------------------------------------------------------------------------- 1 | ## 一 日志恢复 2 | 3 | 我们现在处于这样一个场景 4 | 5 | 6 | 7 | ![img](https://pic3.zhimg.com/v2-3f3b77262ed9704d4315a56862f73e1e_b.jpg) 8 | 9 | 10 | 11 | 我们假设下一个任期是6。尽管你无法从黑板上确认这一点,但是下一个任期号至少是6或者更大。我们同时假设S3在任期6被选为Leader。在某个时刻,新Leader S3会发送任期6的第一个AppendEntries RPC,来传输任期6的第一个Log,这个Log应该在槽位13。 12 | 13 | 这里的AppendEntries消息实际上有两条,因为要发给两个Followers。它们包含了客户端发送给Leader的请求。我们现在想将这个请求复制到所有的Followers上。这里的AppendEntries RPC还包含了prevLogIndex字段和prevLogTerm字段。所以Leader在发送AppendEntries消息时,会附带前一个槽位的信息。在我们的场景中,prevLogIndex是前一个槽位的位置,也就是12;prevLogTerm是S3上前一个槽位的任期号,也就是5。 14 | 15 | 16 | 17 | ![img](https://pic3.zhimg.com/v2-38d7975ce732ae668427d723437c4002_b.jpg) 18 | 19 | 20 | 21 | 这样的AppendEntries消息发送给了Followers。而Followers,它们在收到AppendEntries消息时,可以知道它们收到了一个带有若干Log条目的消息,并且是从槽位13开始。Followers在写入Log之前,会检查本地的前一个Log条目,是否与Leader发来的有关前一条Log的信息匹配。 22 | 23 | 所以对于S2 它显然是不匹配的。S2 在槽位12已经有一个条目,但是它来自任期4,而不是任期5。所以S2将拒绝这个AppendEntries,并返回False给Leader。S1在槽位12还没有任何Log,所以S1也将拒绝Leader的这个AppendEntries。到目前位置,一切都还好。为什么这么说呢?因为我们完全不想看到的是,S2 把这条新的Log添加在槽位13。因为这样会破坏Raft论文中图2所依赖的归纳特性,并且隐藏S2 实际上在槽位12有一条不同的Log的这一事实。 24 | 25 | 26 | 27 | ![img](https://pic4.zhimg.com/v2-608de615b86e992eaa7657d2d284763f_b.jpg) 28 | 29 | 30 | 31 | 所以S1和S2都没有接受这条AppendEntries消息,所以,Leader看到了两个拒绝。 32 | 33 | Leader为每个Follower维护了nextIndex。所以它有一个S2的nextIndex,还有一个S1的nextIndex。之前没有说明的是,如果Leader之前发送的是有关槽位13的Log,这意味着Leader对于其他两个服务器的nextIndex都是13。这种情况发生在Leader刚刚当选,因为Raft论文的图2规定了,nextIndex的初始值是从新任Leader的最后一条日志开始,而在我们的场景中,对应的就是槽位13. 34 | 35 | 为了响应Followers返回的拒绝,Leader会减小对应的nextIndex。所以它现在减小了两个Followers的nextIndex。这一次,Leader发送的AppendEntries消息中,prevLogIndex等于11,prevLogTerm等于3。同时,这次Leader发送的AppendEntries消息包含了prevLogIndex之后的所有条目,也就是S3上槽位12和槽位13的Log。 36 | 37 | 38 | 39 | ![img](https://pic4.zhimg.com/v2-13680908f2234401cc2444539010bad7_b.jpg) 40 | 41 | 42 | 43 | 对于S2来说,这次收到的AppendEntries消息中,prevLogIndex等于11,prevLogTerm等于3,与自己本地的Log匹配,所以,S2会接受这个消息。Raft论文中的图2规定,如果接受一个AppendEntries消息,那么需要首先删除本地相应的Log(如果有的话),再用AppendEntries中的内容替代本地Log。所以,S2会这么做:它会删除本地槽位12的记录,再添加AppendEntries中的Log条目。这个时候,S2的Log与S3保持了一致。 44 | 45 | 46 | 47 | ![img](https://pic3.zhimg.com/v2-5a0d17e256802c59be744a96f27cb422_b.jpg) 48 | 49 | 50 | 51 | 但是,S1仍然有问题,因为它的槽位11是空的,所以它不能匹配这次的AppendEntries。它将再次返回False。而Leader会将S1对应的nextIndex变为11,并在AppendEntries消息中带上从槽位11开始之后的Log(也就是槽位11,12,13对应的Log)。并且带上相应的prevLogIndex(10)和prevLogTerm(3)。 52 | 53 | 54 | 55 | ![img](https://pic2.zhimg.com/v2-6c607d572987dec21e6746f260d7c1ed_b.jpg) 56 | 57 | 58 | 59 | 这次的请求可以被S1接受,并得到肯定的返回。现在它们都有了一致的Log。 60 | 61 | 62 | 63 | ![img](https://pic2.zhimg.com/v2-d1a22fdb5e3fa96d17895e4373192779_b.jpg) 64 | 65 | 66 | 67 | 而Leader在收到了Followers对于AppendEntries的肯定的返回之后,它会增加相应的nextIndex到14。 68 | 69 | 70 | 71 | ![img](https://pic3.zhimg.com/v2-2fc87ec04174c30ece48912022254b42_b.jpg) 72 | 73 | 74 | 75 | 在这里,Leader使用了一种备份机制来探测Followers的Log中,第一个与Leader的Log相同的位置。在获得位置之后,Leader会给Follower发送从这个位置开始的,剩余的全部Log。经过这个过程,所有节点的Log都可以和Leader保持一致。 76 | 77 | 重复一个我们之前讨论过的话题,或许我们还会再讨论。在刚刚的过程中,我们擦除了一些Log条目,比如我们刚刚删除了S2中的槽位12的Log。这个位置是任期4的Log。现在的问题是,为什么Raft系统可以安全的删除这条记录?毕竟我们在删除这条记录时,某个相关的客户端请求也随之被丢弃了。 78 | 79 | 80 | 81 | ![img](https://pic2.zhimg.com/v2-47e3bd1cb2dc6198d0f14f0f8d804e45_b.jpg) 82 | 83 | 84 | 85 | 我在上堂课说过这个问题,这里的原理是什么呢?是的,这条Log条目并没有存在于过半服务器中,因此无论之前的Leader是谁,发送了这条Log,它都没有得到过半服务器的认可。因此旧的Leader不可能commit了这条记录,也就不可能将它应用到应用程序的状态中,进而也就不可能回复给客户端说请求成功了。因为它没有存在于过半服务器中,发送这个请求的客户端没有理由认为这个请求被执行了,也不可能得到一个回复。因为这里有一条规则就是,Leader只会在commit之后回复给客户端。客户端甚至都没有理由相信这个请求被任意服务器收到了。并且,Raft论文中的图2说明,如果客户端发送请求之后一段时间没有收到回复,它应该重新发送请求。所以我们知道,不论这个被丢弃的请求是什么,我们都没有执行它,没有把它包含在任何状态中,并且客户端之后会重新发送这个请求。 86 | 87 | > 学生提问:前面的过程中,为什么总是删除Followers的Log的结尾部分? 88 | > Robert教授:一个备选的答案是,Leader有完整的Log,所以当Leader收到有关AppendEntries的False返回时,它可以发送完整的日志给Follower。如果你刚刚启动系统,甚至在一开始就发生了非常反常的事情,某个Follower可能会从第一条Log 条目开始恢复,然后让Leader发送整个Log记录,因为Leader有这些记录。如果有必要的话,Leader拥有填充每个节点的日志所需的所有信息。 89 | 90 | ## 二 快速恢复 91 | 92 | 在前面(7.1)介绍的日志恢复机制中,如果Log有冲突,Leader每次会回退一条Log条目。 这在许多场景下都没有问题。但是在某些现实的场景中,至少在Lab2的测试用例中,每次回退一条Log条目会花费很长很长的时间。所以,现实的场景中,可能一个Follower关机了很长时间,错过了大量的AppendEntries消息。这时,Leader重启了。按照Raft论文中的图2,如果一个Leader重启了,它会将所有Follower的nextIndex设置为Leader本地Log记录的下一个槽位(7.1有说明)。所以,如果一个Follower关机并错过了1000条Log条目,Leader重启之后,需要每次通过一条RPC来回退一条Log条目来遍历1000条Follower错过的Log记录。这种情况在现实中并非不可能发生。在一些不正常的场景中,假设我们有5个服务器,有1个Leader,这个Leader和另一个Follower困在一个网络分区。但是这个Leader并不知道它已经不再是Leader了。它还是会向它唯一的Follower发送AppendEntries,因为这里没有过半服务器,所以没有一条Log会commit。在另一个有多数服务器的网络分区中,系统选出了新的Leader并继续运行。旧的Leader和它的Follower可能会记录无限多的旧的任期的未commit的Log。当旧的Leader和它的Follower重新加入到集群中时,这些Log需要被删除并覆盖。可能在现实中,这不是那么容易发生,但是你会在Lab2的测试用例中发现这个场景。 93 | 94 | 所以,为了能够更快的恢复日志,Raft论文在论文的5.3结尾处,对一种方法有一些模糊的描述。原文有些晦涩,在这里我会以一种更好的方式尝试解释论文中有关快速恢复的方法。这里的大致思想是,让Follower返回足够的信息给Leader,这样Leader可以以任期(Term)为单位来回退,而不用每次只回退一条Log条目。所以现在,在恢复Follower的Log时,如果Leader和Follower的Log不匹配,Leader只需要对每个不同的任期发送一条AppendEntries,而不用对每个不同的Log条目发送一条AppendEntries。这只是一种加速策略,当然,或许你也可以想出许多其他不同的日志恢复加速策略。 95 | 96 | 我将可能出现的场景分成3类,为了简化,这里只画出一个Leader(S2)和一个Follower(S1),S2将要发送一条任期号为6的AppendEntries消息给Follower。 97 | 98 | - 场景1:S1没有任期6的任何Log,因此我们需要回退一整个任期的Log。 99 | 100 | 101 | 102 | ![img](https://pic3.zhimg.com/v2-808d1e4b13b295f92165060c49519f36_b.jpg) 103 | 104 | 105 | 106 | - 场景2:S1收到了任期4的旧Leader的多条Log,但是作为新Leader,S2只收到了一条任期4的Log。所以这里,我们需要覆盖S1中有关旧Leader的一些Log。 107 | 108 | 109 | 110 | ![img](https://pic4.zhimg.com/v2-1c5e4085f5113ac08848a843a3659283_b.jpg) 111 | 112 | 113 | 114 | - 场景3:S1与S2的Log不冲突,但是S1缺失了部分S2中的Log。 115 | 116 | 117 | 118 | ![img](https://pic4.zhimg.com/v2-728bf535c581d94600412ae29d251853_b.jpg) 119 | 120 | 121 | 122 | 可以让Follower在回复Leader的AppendEntries消息中,携带3个额外的信息,来加速日志的恢复。这里的回复是指,Follower因为Log信息不匹配,拒绝了Leader的AppendEntries之后的回复。这里的三个信息是指: 123 | 124 | - XTerm:这个是Follower中与Leader冲突的Log对应的任期号。在之前(7.1)有介绍Leader会在prevLogTerm中带上本地Log记录中,前一条Log的任期号。如果Follower在对应位置的任期号不匹配,它会拒绝Leader的AppendEntries消息,并将自己的任期号放在XTerm中。如果Follower在对应位置没有Log,那么这里会返回 -1。 125 | - XIndex:这个是Follower中,对应任期号为XTerm的第一条Log条目的槽位号。 126 | - XLen:如果Follower在对应位置没有Log,那么XTerm会返回-1,XLen表示空白的Log槽位数。 127 | 128 | 129 | 130 | ![img](https://pic3.zhimg.com/v2-03e692c03c43ff907591e8780057393a_b.jpg) 131 | 132 | 133 | 134 | 我们再来看这些信息是如何在上面3个场景中,帮助Leader快速回退到适当的Log条目位置。 135 | 136 | - 场景1。Follower(S1)会返回XTerm=5,XIndex=2。Leader(S2)发现自己没有任期5的日志,它会将自己本地记录的,S1的nextIndex设置到XIndex,也就是S1中,任期5的第一条Log对应的槽位号。所以,如果Leader完全没有XTerm的任何Log,那么它应该回退到XIndex对应的位置(这样,Leader发出的下一条AppendEntries就可以一次覆盖S1中所有XTerm对应的Log)。 137 | - 场景2。Follower(S1)会返回XTerm=4,XIndex=1。Leader(S2)发现自己其实有任期4的日志,它会将自己本地记录的S1的nextIndex设置到本地在XTerm位置的Log条目后面,也就是槽位2。下一次Leader发出下一条AppendEntries时,就可以一次覆盖S1中槽位2和槽位3对应的Log。 138 | - 场景3。Follower(S1)会返回XTerm=-1,XLen=2。这表示S1中日志太短了,以至于在冲突的位置没有Log条目,Leader应该回退到Follower最后一条Log条目的下一条,也就是槽位2,并从这开始发送AppendEntries消息。槽位2可以从XLen中的数值计算得到。 139 | 140 | 这些信息在Lab中会有用,如果你错过了我的描述,你可以再看看视频(Robert教授说的)。 141 | 142 | 对于这里的快速回退机制有什么问题吗? 143 | 144 | > 学生提问:这里是线性查找,可以使用类似二分查找的方法进一步加速吗? 145 | > Robert教授:我认为这是对的,或许这里可以用二分查找法。我没有排除其他方法的可能,我的意思是,Raft论文中并没有详细说明是怎么做的,所以我这里加工了一下。或许有更好,更快的方式来完成。如果Follower返回了更多的信息,那是可以用一些更高级的方法,例如二分查找,来完成。 146 | > 为了通过Lab2的测试,你肯定需要做一些优化工作。我们提供的Lab2的测试用例中,有一件不幸但是不可避免的事情是,它们需要一些实时特性。这些测试用例不会永远等待你的代码执行完成并生成结果。所以有可能你的方法技术上是对的,但是花了太多时间导致测试用例退出。这个时候,你是不能通过全部的测试用例的。因此你的确需要关注性能,从而使得你的方案即是正确的,又有足够的性能。不幸的是,性能与Log的复杂度相关,所以很容易就写出一个正确但是不够快的方法出来。 147 | > 学生提问:能在解释一下这里的流程吗? 148 | > Robert教授:这里,Leader发现冲突的方法在于,Follower会返回它从冲突条目中看到的任期号(XTerm)。在场景1中,Follower会设置XTerm=5,因为这是有冲突的Log条目对应的任期号。Leader会发现,哦,我的Log中没有任期5的条目。因此,在场景1中,Leader会一次性回退到Follower在任期5的起始位置。因为Leader并没有任何任期5的Log,所以它要删掉Follower中所有任期5的Log,这通过回退到Follower在任期5的第一条Log条目的位置,也就是XIndex达到的。 149 | 150 | ## 三 持久化 151 | 152 | 下一个我想介绍的是持久化存储(persistence)。你可以从Raft论文的图2的左上角看到,有些数据被标记为持久化的(Persistent),有些信息被标记为非持久化的(Volatile)。持久化和非持久化的区别只在服务器重启时重要。当你更改了被标记为持久化的某个数据,服务器应该将更新写入到磁盘,或者其它的持久化存储中,例如一个电池供电的RAM。持久化的存储可以确保当服务器重启时,服务器可以找到相应的数据,并将其加载到内存中。这样可以使得服务器在故障并重启后,继续重启之前的状态。 153 | 154 | 你或许会认为,如果一个服务器故障了,那简单直接的方法就是将它从集群中摘除。我们需要具备从集群中摘除服务器,替换一个全新的空的服务器,并让该新服务器在集群内工作的能力。实际上,这是至关重要的,因为如果一些服务器遭受了不可恢复的故障,例如磁盘故障,你绝对需要替换这台服务器。同时,如果磁盘故障了,你也不能指望能从该服务器的磁盘中获得任何有用的信息。所以我们的确需要能够用全新的空的服务器替代现有服务器的能力。你或许认为,这就足以应对任何出问题的场景了,但实际上不是的。 155 | 156 | 实际上,一个常见的故障是断电。断电的时候,整个集群都同时停止运行,这种场景下,我们不能通过从Dell买一些新的服务器来替换现有服务器进而解决问题。这种场景下,如果我们希望我们的服务是容错的, 我们需要能够得到之前状态的拷贝,这样我们才能保持程序继续运行。因此,至少为了处理同时断电的场景,我们不得不让服务器能够将它们的状态存储在某处,这样当供电恢复了之后,还能再次获取这个状态。这里的状态是指,为了让服务器在断电或者整个集群断电后,能够继续运行所必不可少的内容。这是理解持久化存储的一种方式。 157 | 158 | 在Raft论文的图2中,有且仅有三个数据是需要持久化存储的。它们分别是Log、currentTerm、votedFor。Log是所有的Log条目。当某个服务器刚刚重启,在它加入到Raft集群之前,它必须要检查并确保这些数据有效的存储在它的磁盘上。服务器必须要有某种方式来发现,自己的确有一些持久化存储的状态,而不是一些无意义的数据。 159 | 160 | 161 | 162 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210221203125.jpeg) 163 | 164 | 165 | 166 | Log需要被持久化存储的原因是,这是唯一记录了应用程序状态的地方。Raft论文图2并没有要求我们持久化存储应用程序状态。假如我们运行了一个数据库或者为VMware FT运行了一个Test-and-Set服务,根据Raft论文图2,实际的数据库或者实际的test-set值,并不会被持久化存储,只有Raft的Log被存储了。所以当服务器重启时,唯一能用来重建应用程序状态的信息就是存储在Log中的一系列操作,所以Log必须要被持久化存储。 167 | 168 | 那currentTerm呢?为什么currentTerm需要被持久化存储?是的,currentTerm和votedFor都是用来确保每个任期只有最多一个Leader。在一个故障的场景中,如果一个服务器收到了一个RequestVote请求,并且为服务器1投票了,之后它故障。如果它没有存储它为哪个服务器投过票,当它故障重启之后,收到了来自服务器2的同一个任期的另一个RequestVote请求,那么它还是会投票给服务器2,因为它发现自己的votedFor是空的,因此它认为自己还没投过票。现在这个服务器,在同一个任期内同时为服务器1和服务器2投了票。因为服务器1和服务器2都会为自己投票,它们都会认为自己有过半选票(3票中的2票),那它们都会成为Leader。现在同一个任期里面有了两个Leader。这就是为什么votedFor必须被持久化存储。 169 | 170 | currentTerm的情况要更微妙一些,但是实际上还是为了实现一个任期内最多只有一个Leader,我们之前实际上介绍过这里的内容。如果(重启之后)我们不知道任期号是什么,很难确保一个任期内只有一个Leader。 171 | 172 | 173 | 174 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210221203125.jpeg) 175 | 176 | 177 | 178 | 在这里例子中,S1关机了,S2和S3会尝试选举一个新的Leader。它们需要证据证明,正确的任期号是8,而不是6。如果仅仅是S2和S3为彼此投票,它们不知道当前的任期号,它们只能查看自己的Log,它们或许会认为下一个任期是6(因为Log里的上一个任期是5)。如果它们这么做了,那么它们会从任期6开始添加Log。但是接下来,就会有问题了,因为我们有了两个不同的任期6(另一个在S1中)。这就是为什么currentTerm需要被持久化存储的原因,因为它需要用来保存已经被使用过的任期号。 179 | 180 | 这些数据需要在每次你修改它们的时候存储起来。所以可以确定的是,安全的做法是每次你添加一个Log条目,更新currentTerm或者更新votedFor,你或许都需要持久化存储这些数据。在一个真实的Raft服务器上,这意味着将数据写入磁盘,所以你需要一些文件来记录这些数据。如果你发现,直到服务器与外界通信时,才有可能持久化存储数据,那么你可以通过一些批量操作来提升性能。例如,只在服务器回复一个RPC或者发送一个RPC时,服务器才进行持久化存储,这样可以节省一些持久化存储的操作。 181 | 182 | 之所以这很重要是因为,向磁盘写数据是一个代价很高的操作。如果是一个机械硬盘,我们通过写文件的方式来持久化存储,向磁盘写入任何数据都需要花费大概10毫秒时间。因为你要么需要等磁盘将你想写入的位置转到磁针下面, 而磁盘大概每10毫秒转一次。要么,就是另一种情况更糟糕,磁盘需要将磁针移到正确的轨道上。所以这里的持久化操作的代价可能会非常非常高。对于一些简单的设计,这些操作可能成为限制性能的因素,因为它们意味着在这些Raft服务器上执行任何操作,都需要10毫秒。而10毫秒相比发送RPC或者其他操作来说都太长了。如果你持久化存储在一个机械硬盘上,那么每个操作至少要10毫秒,这意味着你永远也不可能构建一个每秒能处理超过100个请求的Raft服务。这就是所谓的synchronous disk updates的代价。它存在于很多系统中,例如运行在你的笔记本上的文件系统。 183 | 184 | 185 | 186 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210221203125.jpeg) 187 | 188 | 189 | 190 | 设计人员花费了大量的时间来避开synchronous disk updates带来的性能问题。为了让磁盘的数据保证安全,同时为了能安全更新你的笔记本上的磁盘,文件系统对于写入操作十分小心,有时需要等待磁盘(前一个)写入完成。所以这(优化磁盘写入性能)是一个出现在所有系统中的常见的问题,也必然出现在Raft中。 191 | 192 | 如果你想构建一个能每秒处理超过100个请求的系统,这里有多个选择。其中一个就是,你可以使用SSD硬盘,或者某种闪存。SSD可以在0.1毫秒完成对于闪存的一次写操作,所以这里性能就提高了100倍。更高级一点的方法是,你可以构建一个电池供电的DRAM,然后在这个电池供电的DRAM中做持久化存储。这样,如果Server重启了,并且重启时间短于电池的可供电时间,这样你存储在RAM中的数据还能保存。如果资金充足,且不怕复杂的话,这种方式的优点是,你可以每秒写DRAM数百万次,那么持久化存储就不再会是一个性能瓶颈。所以,synchronous disk updates是为什么数据要区分持久化和非持久化(而非所有的都做持久化)的原因(越少数据持久化,越高的性能)。Raft论文图2考虑了很多性能,故障恢复,正确性的问题。 193 | 194 | 有任何有关持久化存储的问题吗? 195 | 196 | > 学生提问:当你写你的Raft代码时,你实际上需要确认,当你持久化存储一个Log或者currentTerm,这些数据是否实时的存储在磁盘中,你该怎么做来确保它们在那呢? 197 | > Robert教授:在一个UNIX或者一个Linux或者一个Mac上,为了调用系统写磁盘的操作,你只需要调用write函数,在write函数返回时,并不能确保数据存在磁盘上,并且在重启之后还存在。几乎可以确定(write返回之后)数据不会在磁盘上。所以,如果在UNIX上,你调用了write,将一些数据写入之后,你需要调用fsync。在大部分系统上,fsync可以确保在返回时,所有之前写入的数据已经安全的存储在磁盘的介质上了。之后,如果机器重启了,这些信息还能在磁盘上找到。fsync是一个代价很高的调用,这就是为什么它是一个独立的函数,也是为什么write不负责将数据写入磁盘,fsync负责将数据写入磁盘。因为写入磁盘的代价很高,你永远也不会想要执行这个操作,除非你想要持久化存储一些数据。 198 | 199 | 200 | 201 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210221203125.jpeg) 202 | 203 | 204 | 205 | 所以你可以使用一些更贵的磁盘。另一个常见方法是,批量执行操作。如果有大量的客户端请求,或许你应该同时接收它们,但是先不返回。等大量的请求累积之后,一次性持久化存储(比如)100个Log,之后再发送AppendEntries。如果Leader收到了一个客户端请求,在发送AppendEntries RPC给Followers之前,必须要先持久化存储在本地。因为Leader必须要commit那个请求,并且不能忘记这个请求。实际上,在回复AppendEntries 消息之前,Followers也需要持久化存储这些Log条目到本地,因为它们最终也要commit这个请求,它们不能因为重启而忘记这个请求。 206 | 207 | 最后,有关持久化存储,还有一些细节。有些数据在Raft论文的图2中标记为非持久化的。所以,这里值得思考一下,为什么服务器重启时,commitIndex、lastApplied、nextIndex、matchIndex,可以被丢弃?例如,lastApplied表示当前服务器执行到哪一步,如果我们丢弃了它的话,我们需要重复执行Log条目两次(重启前执行过一次,重启后又要再执行一次),这是正确的吗?为什么可以安全的丢弃lastApplied? 208 | 209 | 这里综合考虑了Raft的简单性和安全性。之所以这些数据是非持久化存储的,是因为Leader可以通过检查自己的Log和发送给Followers的AppendEntries的结果,来发现哪些内容已经commit了。如果因为断电,所有节点都重启了。Leader并不知道哪些内容被commit了,哪些内容被执行了。但是当它发出AppendEntries,并从Followers搜集回信息。它会发现,Followers中有哪些Log与Leader的Log匹配,因此也就可以发现,在重启前,有哪些被commit了。 210 | 211 | 另外,Raft论文的图2假设,应用程序状态会随着重启而消失。所以图2认为,既然Log已经持久化存储了,那么应用程序状态就不必再持久化存储。因为在图2中,Log从系统运行的初始就被持久化存储下来。所以,当Leader重启时,Leader会从第一条Log开始,执行每一条Log条目,并提交给应用程序。所以,重启之后,应用程序可以通过重复执行每一条Log来完全从头构建自己的状态。这是一种简单且优雅的方法,但是很明显会很慢。这将会引出我们的下一个话题:Log compaction和Snapshot。 -------------------------------------------------------------------------------- /doc/mit/lab7-CRAQ.md: -------------------------------------------------------------------------------- 1 | ## 一 Chain Replication 2 | 3 | 这一部分,我们来讨论另一个论文CRAQ(Chain Replication with Apportioned Queries)。我们选择CRAQ论文有两个原因:第一个是它通过复制实现了容错;第二是它通过以链复制API请求这种有趣的方式,提供了与Raft相比不一样的属性。 4 | 5 | CRAQ是对于一个叫链式复制(Chain Replication)的旧方案的改进。Chain Replication实际上用的还挺多的,有许多现实世界的系统使用了它,CRAQ是对它的改进。CRAQ采用的方式与Zookeeper非常相似,它通过将读请求分发到任意副本去执行,来提升读请求的吞吐量,所以副本的数量与读请求性能成正比。CRAQ有意思的地方在于,它在任意副本上执行读请求的前提下,还可以保证线性一致性(Linearizability)。这与Zookeeper不太一样,Zookeeper为了能够从任意副本执行读请求,不得不牺牲数据的实时性,因此也就不是线性一致的。CRAQ却可以从任意副本执行读请求,同时也保留线性一致性,这一点非常有趣。 6 | 7 | 8 | 9 | ![img](https://pic3.zhimg.com/v2-f8ad56fc79401d846c46114c254c76da_b.jpg) 10 | 11 | 12 | 13 | 首先,我想讨论旧的Chain Replication系统。Chain Replication是这样一种方案,你有多个副本,你想确保它们都看到相同顺序的写请求(这样副本的状态才能保持一致),这与Raft的思想是一致的,但是它却采用了与Raft不同的拓扑结构。 14 | 15 | 首先,在Chain Replication中,有一些服务器按照链排列。第一个服务器称为HEAD,最后一个被称为TAIL。 16 | 17 | 18 | 19 | ![img](https://pic1.zhimg.com/v2-5968a01c3bc7133c92ac53177d3737a8_b.jpg) 20 | 21 | 22 | 23 | 当客户端想要发送一个写请求,写请求总是发送给HEAD。 24 | 25 | 26 | 27 | ![img](https://pic1.zhimg.com/v2-c5f8a22986f3dcce68ac5841ff22ed84_b.jpg) 28 | 29 | 30 | 31 | HEAD根据写请求更新本地数据,我们假设现在是一个支持PUT/GET的key-value数据库。所有的服务器本地数据都从A开始。 32 | 33 | 34 | 35 | ![img](https://pic4.zhimg.com/v2-a8cfb16c989b6ba1a487d0d1679b2423_b.jpg) 36 | 37 | 38 | 39 | 当HEAD收到了写请求,将本地数据更新成了B,之后会再将写请求通过链向下一个服务器传递。 40 | 41 | 42 | 43 | ![img](https://pic1.zhimg.com/v2-00643f986dc4033d9602ab4dc0183db0_b.jpg) 44 | 45 | 46 | 47 | 下一个服务器执行完写请求之后,再将写请求向下一个服务器传递,以此类推,所有的服务器都可以看到写请求。 48 | 49 | 50 | 51 | ![img](https://pic2.zhimg.com/v2-864ac9e09bdda416ca30a44b8dd8a705_b.jpg) 52 | 53 | 54 | 55 | 当写请求到达TAIL时,TAIL将回复发送给客户端,表明写请求已经完成了。这是处理写请求的过程。 56 | 57 | 58 | 59 | ![img](https://pic1.zhimg.com/v2-6804ad85e9c46289312d5fee2f421024_b.jpg) 60 | 61 | 62 | 63 | 对于读请求,如果一个客户端想要读数据,它将读请求发往TAIL, 64 | 65 | 66 | 67 | ![img](https://pic4.zhimg.com/v2-d6d5ca7cf95739c36eab40291b00328b_b.jpg) 68 | 69 | 70 | 71 | TAIL直接根据自己的当前状态来回复读请求。所以,如果当前状态是B,那么TAIL直接返回B。读请求处理的非常的简单。 72 | 73 | 74 | 75 | ![img](https://pic2.zhimg.com/v2-0586b322c99eb668d227e4ba6abd6fe9_b.jpg) 76 | 77 | 78 | 79 | 这里只是Chain Replication,并不是CRAQ。Chain Replication本身是线性一致的,在没有故障时,从一致性的角度来说,整个系统就像只有TAIL一台服务器一样,TAIL可以看到所有的写请求,也可以看到所有的读请求,它一次只处理一个请求,读请求可以看到最新写入的数据。如果没有出现故障的话,一致性是这么得到保证的,非常的简单。 80 | 81 | 从一个全局角度来看,除非写请求到达了TAIL,否则一个写请求是不会commit,也不会向客户端回复确认,也不能将数据通过读请求暴露出来。而为了让写请求到达TAIL,它需要经过并被链上的每一个服务器处理。所以我们知道,一旦我们commit一个写请求,一旦向客户端回复确认,一旦将写请求的数据通过读请求暴露出来,那意味着链上的每一个服务器都知道了这个写请求。 82 | 83 | ## 二 Fail Recover 84 | 85 | 在Chain Replication中,出现故障后,你可以看到的状态是相对有限的。因为写请求的传播模式非常有规律,我们不会陷入到类似于Raft论文中图7和图8描述的那种令人毛骨悚然的复杂场景中。并且在出现故障之后,也不会出现不同的副本之间各种各样不同步的场景。 86 | 87 | 在Chain Replication中,因为写请求总是依次在链中处理,写请求要么可以达到TAIL并commit,要么只到达了链中的某一个服务器,之后这个服务器出现故障,在链中排在这个服务器后面的所有其他服务器不再能看到写请求。所以,只可能有两种情况:committed的写请求会被所有服务器看到;而如果一个写请求没有commit,那就意味着在导致系统出现故障之前,写请求已经执行到链中的某个服务器,所有在链里面这个服务器之前的服务器都看到了写请求,所有在这个服务器之后的服务器都没看到写请求。 88 | 89 | 总的来看,Chain Replication的故障恢复也相对的更简单。 90 | 91 | 如果HEAD出现故障,作为最接近的服务器,下一个节点可以接手成为新的HEAD,并不需要做任何其他的操作。对于还在处理中的请求,可以分为两种情况: 92 | 93 | - 对于任何已经发送到了第二个节点的写请求,不会因为HEAD故障而停止转发,它会持续转发直到commit。 94 | - 如果写请求发送到HEAD,在HEAD转发这个写请求之前HEAD就故障了,那么这个写请求必然没有commit,也必然没有人知道这个写请求,我们也必然没有向发送这个写请求的客户端确认这个请求,因为写请求必然没能送到TAIL。所以,对于只送到了HEAD,并且在HEAD将其转发前HEAD就故障了的写请求,我们不必做任何事情。或许客户端会重发这个写请求,但是这并不是我们需要担心的问题。 95 | 96 | 如果TAIL出现故障,处理流程也非常相似,TAIL的前一个节点可以接手成为新的TAIL。所有TAIL知道的信息,TAIL的前一个节点必然都知道,因为TAIL的所有信息都是其前一个节点告知的。 97 | 98 | 中间节点出现故障会稍微复杂一点,但是基本上来说,需要做的就是将故障节点从链中移除。或许有一些写请求被故障节点接收了,但是还没有被故障节点之后的节点接收,所以,当我们将其从链中移除时,故障节点的前一个节点或许需要重发最近的一些写请求给它的新后继节点。这是恢复中间节点流程的简单版本。 99 | 100 | Chain Replication与Raft进行对比,有以下差别: 101 | 102 | - 从性能上看,对于Raft,如果我们有一个Leader和一些Follower。Leader需要直接将数据发送给所有的Follower。所以,当客户端发送了一个写请求给Leader,Leader需要自己将这个请求发送给所有的Follower。然而在Chain Replication中,HEAD只需要将写请求发送到一个其他节点。数据在网络中发送的代价较高,所以Raft Leader的负担会比Chain Replication中HEAD的负担更高。当客户端请求变多时,Raft Leader会到达一个瓶颈,而不能在单位时间内处理更多的请求。而同等条件以下,Chain Replication的HEAD可以在单位时间处理更多的请求,瓶颈会来的更晚一些。 103 | - 另一个与Raft相比的有趣的差别是,Raft中读请求同样也需要在Raft Leader中处理,所以Raft Leader可以看到所有的请求。而在Chain Replication中,每一个节点都可以看到写请求,但是只有TAIL可以看到读请求。所以负载在一定程度上,在HEAD和TAIL之间分担了,而不是集中在单个Leader节点。 104 | - 前面分析的故障恢复,Chain Replication也比Raft更加简单。这也是使用Chain Replication的一个主要动力。 105 | 106 | > 学生提问:如果一个写请求还在传递的过程中,还没有到达TAIL,TAIL就故障了,会发生什么? 107 | > Robert教授:如果这个时候TAIL故障了,TAIL的前一个节点最终会看到这个写请求,但是TAIL并没有看到。因为TAIL的故障,TAIL的前一个节点会成为新的TAIL,这个写请求实际上会完成commit,因为写请求到达了新的TAIL。所以新的TAIL可以回复给客户端,但是它极有可能不会回复,因为当它收到写请求时,它可能还不是TAIL。这样的话,客户端或许会重发写请求,但是这就太糟糕了,因为同一个写请求会在系统中处理两遍,所以我们需要能够在HEAD抑制重复请求。不过基本上我们讨论的所有系统都需要能够抑制重复的请求。 108 | > 学生提问:假设第二个节点不能与HEAD进行通信,第二个节点能不能直接接管成为新的HEAD,并通知客户端将请求发给自己,而不是之前的HEAD? 109 | > Robert教授:这是个非常好的问题。你认为呢? 110 | > 你的方案听起来比较可行。假设HEAD和第二个节点之间的网络出问题了, 111 | 112 | 113 | 114 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210225161358.jpeg) 115 | 116 | 117 | 118 | > HEAD还在正常运行,同时HEAD认为第二个节点挂了。然而第二个节点实际上还活着,它认为HEAD挂了。所以现在他们都会认为,另一个服务器挂了,我应该接管服务并处理写请求。因为从HEAD看来,其他服务器都失联了,HEAD会认为自己现在是唯一的副本,那么它接下来既会是HEAD,又会是TAIL。第二个节点会有类似的判断,会认为自己是新的HEAD。所以现在有了脑裂的两组数据,最终,这两组数据会变得完全不一样。 119 | 120 | (下一节继续分析怎么解决这里的问题) 121 | 122 | ## 三 Configuration Manager 123 | 124 | 所以,Chain Replication并不能抵御网络分区,也不能抵御脑裂。在实际场景中,这意味它不能单独使用。Chain Replication是一个有用的方案,但是它不是一个完整的复制方案。它在很多场景都有使用,但是会以一种特殊的方式来使用。总是会有一个外部的权威(External Authority)来决定谁是活的,谁挂了,并确保所有参与者都认可由哪些节点组成一条链,这样在链的组成上就不会有分歧。这个外部的权威通常称为Configuration Manager。 125 | 126 | Configuration Manager的工作就是监测节点存活性,一旦Configuration Manager认为一个节点挂了,它会生成并送出一个新的配置,在这个新的配置中,描述了链的新的定义,包含了链中所有的节点,HEAD和TAIL。Configuration Manager认为挂了的节点,或许真的挂了也或许没有,但是我们并不关心。因为所有节点都会遵从新的配置内容,所以现在不存在分歧了。 127 | 128 | 现在只有一个角色(Configuration Manager)在做决定,它不可能否认自己,所以可以解决脑裂的问题。 129 | 130 | 当然,你是如何使得一个服务是容错的,不否认自己,同时当有网络分区时不会出现脑裂呢?答案是,Configuration Manager通常会基于Raft或者Paxos。在CRAQ的场景下,它会基于Zookeeper。而Zookeeper本身又是基于类似Raft的方案。 131 | 132 | 133 | 134 | ![img](https://pic4.zhimg.com/v2-3f50a2d618ae99b0dd6a9931c1cbee0f_b.jpg) 135 | 136 | 137 | 138 | 所以,你的数据中心内的设置通常是,你有一个基于Raft或者Paxos的Configuration Manager,它是容错的,也不会受脑裂的影响。之后,通过一系列的配置更新通知,Configuration Manager将数据中心内的服务器分成多个链。比如说,Configuration Manager决定链A由服务器S1,S2,S3组成,链B由服务器S4,S5,S6组成。 139 | 140 | 141 | 142 | ![img](https://pic2.zhimg.com/v2-3766566e031ec50b4d2fc5fc936d6861_b.jpg) 143 | 144 | 145 | 146 | Configuration Manager通告给所有参与者整个链的信息,所以所有的客户端都知道HEAD在哪,TAIL在哪,所有的服务器也知道自己在链中的前一个节点和后一个节点是什么。现在,单个服务器对于其他服务器状态的判断,完全不重要。假如第二个节点真的挂了,在收到新的配置之前,HEAD需要不停的尝试重发请求。节点自己不允许决定谁是活着的,谁挂了。 147 | 148 | 这种架构极其常见,这是正确使用Chain Replication和CRAQ的方式。在这种架构下,像Chain Replication一样的系统不用担心网络分区和脑裂,进而可以使用类似于Chain Replication的方案来构建非常高速且有效的复制系统。比如在上图中,我们可以对数据分片(Sharding),每一个分片都是一个链。其中的每一个链都可以构建成极其高效的结构来存储你的数据,进而可以同时处理大量的读写请求。同时,我们也不用太担心网络分区的问题,因为它被一个可靠的,非脑裂的Configuration Manager所管理。 149 | 150 | > 学生提问:为什么存储具体数据的时候用Chain Replication,而不是Raft? 151 | > Robert教授:这是一个非常合理的问题。其实数据用什么存并不重要。因为就算我们这里用了Raft,我们还是需要一个组件在产生冲突的时候来做决策。比如说数据如何在我们数百个复制系统中进行划分。如果我需要一个大的系统,我需要对数据进行分片,需要有个组件来决定数据是如何分配到不同的分区。随着时间推移,这里的划分可能会变化,因为硬件可能会有增减,数据可能会变多等等。Configuration Manager会决定以A或者B开头的key在第一个分区,以C或者D开头的key在第二个分区。至于在每一个分区,我们该使用什么样的复制方法,Chain Replication,Paxos,还是Raft,不同的人有不同的选择,有些人会使用Paxos,比如说Spanner,我们之后也会介绍。在这里,不使用Paxos或者Raft,是因为Chain Replication更加的高效,因为它减轻了Leader的负担,这或许是一个非常关键的问题。 152 | > 某些场合可能更适合用Raft或者Paxos,因为它们不用等待一个慢的副本。而当有一个慢的副本时,Chain Replication会有性能的问题,因为每一个写请求需要经过每一个副本,只要有一个副本变慢了,就会使得所有的写请求处理变慢。这个可能非常严重,比如说你有1000个服务器,因为某人正在安装软件或者其他的原因,任意时间都有几个服务器响应比较慢。每个写请求都受限于当前最慢的服务器,这个影响还是挺大的。然而对于Raft,如果有一个副本响应速度较慢,Leader只需要等待过半服务器,而不用等待所有的副本。最终,所有的副本都能追上Leader的进度。所以,Raft在抵御短暂的慢响应方面表现的更好。一些基于Paxos的系统,也比较擅长处理副本相距较远的情况。对于Raft和Paxos,你只需要过半服务器确认,所以不用等待一个远距离数据中心的副本确认你的操作。这些原因也使得人们倾向于使用类似于Raft和Paxos这样的选举系统,而不是Chain Replication。这里的选择取决于系统的负担和系统要实现的目标。 153 | > 不管怎样,配合一个外部的权威机构这种架构,我不确定是不是万能的,但的确是非常的通用。 154 | > 学生提问:如果Configuration Manger认为两个服务器都活着,但是两个服务器之间的网络实际中断了会怎样? 155 | > Robert教授:对于没有网络故障的环境,总是可以假设计算机可以通过网络互通。对于出现网络故障的环境,可能是某人踢到了网线,一些路由器被错误配置了或者任何疯狂的事情都可能发生。所以,因为错误的配置你可能陷入到这样一个情况中,Chain Replication中的部分节点可以与Configuration Manager通信,并且Configuration Manager认为它们是活着的,但是它们彼此之间不能互相通信。 156 | 157 | 158 | 159 | ![img](https://pic1.zhimg.com/v2-00f7a5196370c37aaac1da2da087fa60_b.jpg) 160 | 161 | 162 | 163 | > 这是这种架构所不能处理的情况。如果你希望你的系统能抵御这样的故障。你的Configuration Manager需要更加小心的设计,它需要选出不仅是它能通信的服务器,同时这些服务器之间也能相互通信。在实际中,任意两个节点都有可能网络不通。 -------------------------------------------------------------------------------- /doc/mit/lab8-Aurora.md: -------------------------------------------------------------------------------- 1 | # 一 背景历史 2 | 3 | 4 | 5 | 今天的论文是Amazon的Aurora。Aurora是一个高性能,高可靠的数据库。Aurora本身作为云基础设施一个组成部分而存在,同时又构建在Amazon自己的基础设施之上。 6 | 7 | 我们之所以要看这篇论文,有以下几个原因: 8 | 9 | - 首先这是最近的来自于Amazon的一种非常成功的云服务,有很多Amazon的用户在使用它。Aurora以自己的方式展示了一个聪明设计所取得的巨大成果。从论文的表1显示的与一些其他数据库的性能比较可以看出,在处理事务的速度上,Aurora宣称比其他数据库快35倍。这个数字非常了不起了。 10 | - 这篇论文同时也探索了在使用容错的,通用(General-Purpose)存储前提下,性能可以提升的极限。Amazon首先使用的是自己的通用存储,但是后来发现性能不好,然后就构建了完全是应用定制(Application-Specific)的存储,并且几乎是抛弃了通用存储。 11 | - 论文中还有很多在云基础设施世界中重要的细节。 12 | 13 | 因为这是Amazon认为它的云产品用户应该在Amazon基础设施之上构建的数据库,所以在讨论Aurora之前,我想花一点时间来回顾一下历史,究竟是什么导致了Aurora的产生? 14 | 15 | 16 | 17 | ![img](https://pic3.zhimg.com/v2-9e658cbf1895711a2d969d8f1edf403e_b.jpg) 18 | 19 | 20 | 21 | 最早的时候,Amazon提供的云产品是EC2,它可以帮助用户在Amazon的机房里和Amazon的硬件上创建类似网站的应用。EC2的全称是Elastic Cloud 2(注,视频中是这么说,但是经评论指出应该是Elastic Cloud Compute)。Amazon有装满了服务器的数据中心,并且会在每一个服务器上都运行VMM(Virtual Machine Monitor)。它会向它的用户出租虚拟机,而它的用户通常会租用多个虚拟机用来运行Web服务、数据库和任何其他需要运行的服务。所以,在一个服务器上,有一个VMM,还有一些EC2实例,其中每一个实例都出租给不同的云客户。每个EC2实例都会运行一个标准的操作系统,比如说Linux,在操作系统之上,运行的是应用程序,例如Web服务、数据库。这种方式相对来说成本较低,也比较容易配置,所以是一个成功的服务模式。 22 | 23 | 24 | 25 | ![img](https://pic1.zhimg.com/v2-1a7015738796a356afb6b3c4dd6fa16c_b.jpg) 26 | 27 | 28 | 29 | 这里有一个对我们来说极其重要的细节。因为每一个服务器都有一块本地的硬盘,在最早的时候,如果你租用一个EC2实例,每一个EC2实例会从服务器的本地硬盘中分到一小片硬盘空间。所以,最早的时候EC2用的都是本地盘,每个EC2实例会分到本地盘的一小部分。但是从EC2实例的操作系统看起来就是一个硬盘,一个模拟的硬盘。 30 | 31 | 32 | 33 | ![img](https://pic3.zhimg.com/v2-aa303fe3258597edceea68fae5f469c2_b.jpg) 34 | 35 | 36 | 37 | EC2对于无状态的Web服务器来说是完美的。客户端通过自己的Web浏览器连接到一些运行了Web服务的EC2实例上。如果突然新增了大量客户,你可以立刻向Amazon租用更多的EC2实例,并在上面启动Web服务。这样你就可以很简单的对你的Web服务进行扩容。 38 | 39 | 另一类人们主要运行在EC2实例的服务是数据库。通常来说一个网站包含了一些无状态的Web服务,任何时候这些Web服务需要一些持久化存储的数据时,它们会与一个后端数据库交互。 40 | 41 | 所以,现在的场景是,在Amazon基础设施之外有一些客户端浏览器(C1,C2,C3)。之后是一些EC2实例,上面运行了Web服务,这里你可以根据网站的规模想起多少实例就起多少。这些EC2实例在Amazon基础设施内。之后,还有一个EC2实例运行了数据库。Web服务所在的EC2实例会与数据库所在的EC2实例交互,完成数据库中记录的读写。 42 | 43 | 44 | 45 | ![img](https://pic3.zhimg.com/v2-e9ff3664ae26628cd495659d02fe1ff2_b.jpg) 46 | 47 | 48 | 49 | 不幸的是,对于数据库来说,EC2就不像对于Web服务那样完美了,最直接的原因就是存储。对于运行了数据库的EC2实例,获取存储的最简单方法就是使用EC2实例所在服务器的本地硬盘。如果服务器宕机了,那么它本地硬盘也会无法访问。当Web服务所在的服务器宕机了,是完全没有问题的,因为Web服务本身没有状态,你只需要在一个新的EC2实例上启动一个新的Web服务就行。但是如果数据库所在的服务器宕机了,并且数据存储在服务器的本地硬盘中,那么就会有大问题,因为数据丢失了。 50 | 51 | Amazon本身提供了存储大块数据的服务,叫做S3。你可以定期的对数据库做快照,并将快照存储在S3上,并基于快照来实现故障恢复,但是这种定期的快照意味着你可能会损失两次快照之间的数据。 52 | 53 | 所以,为了向用户提供EC2实例所需的硬盘,并且硬盘数据不会随着服务器故障而丢失,就出现了一个与Aurora相关的服务,并且同时也是容错的且支持持久化存储的服务,这个服务就是EBS。EBS全称是Elastic Block Store。从EC2实例来看,EBS就是一个硬盘,你可以像一个普通的硬盘一样去格式化它,就像一个类似于ext3格式的文件系统或者任何其他你喜欢的Linux文件系统。但是在实现上,EBS底层是一对互为副本的存储服务器。随着EBS的推出,你可以租用一个EBS volume。一个EBS volume看起来就像是一个普通的硬盘一样,但却是由一对互为副本EBS服务器实现,每个EBS服务器本地有一个硬盘。所以,现在你运行了一个数据库,相应的EC2实例将一个EBS volume挂载成自己的硬盘。当数据库执行写磁盘操作时,数据会通过网络送到EBS服务器。 54 | 55 | 56 | 57 | ![img](https://pic3.zhimg.com/v2-abca5f2d670abf3c0a017d45cd6675f2_b.jpg) 58 | 59 | 60 | 61 | 这两个EBS服务器会使用Chain Replication(9.5)进行复制。所以写请求首先会写到第一个EBS服务器,之后写到第二个EBS服务器,然后从第二个EBS服务器,EC2实例可以得到回复。当读数据的时候,因为这是一个Chain Replication,EC2实例会从第二个EBS服务器读取数据。 62 | 63 | 64 | 65 | ![img](https://pic4.zhimg.com/v2-0ae7de7643f3d951018e36d5f50fa9df_b.jpg) 66 | 67 | 68 | 69 | 所以现在,运行在EC2实例上的数据库有了可用性。因为现在有了一个存储系统可以在服务器宕机之后,仍然能持有数据。如果数据库所在的服务器挂了,你可以启动另一个EC2实例,并为其挂载同一个EBS volume,再启动数据库。新的数据库可以看到所有前一个数据库留下来的数据,就像你把硬盘从一个机器拔下来,再插入到另一个机器一样。所以EBS非常适合需要长期保存数据的场景,比如说数据库。 70 | 71 | 对于我们来说,有关EBS有一件很重要的事情:这不是用来共享的服务。任何时候,只有一个EC2实例,一个虚机可以挂载一个EBS volume。所以,尽管所有人的EBS volume都存储在一个大的服务器池子里,每个EBS volume只能被一个EC2实例所使用。 72 | 73 | 尽管EBS是一次很大的进步,但是它仍然有自己的问题。它有一些细节不是那么的完美。 74 | 75 | - 如果你在EBS上运行一个数据库,那么最终会有大量的数据通过网络来传递。论文的图2中,就有对在一个Network Storage System之上运行数据库所需要的大量写请求的抱怨。所以,如果在EBS上运行了一个数据库,会产生大量的网络流量。在论文中有暗示,除了网络的限制之外,还有CPU和存储空间的限制。在Aurora论文中,花费了大量的精力来降低数据库产生的网络负载,同时看起来相对来说不太关心CPU和存储空间的消耗。所以也可以理解成他们认为网络负载更加重要。 76 | - 另一个问题是,EBS的容错性不是很好。出于性能的考虑,Amazon总是将EBS volume的两个副本存放在同一个数据中心。所以,如果一个副本故障了,那没问题,因为可以切换到另一个副本,但是如果整个数据中心挂了,那就没辙了。很明显,大部分客户还是希望在数据中心故障之后,数据还是能保留的。数据中心故障有很多原因,或许网络连接断了,或许数据中心着火了,或许整个建筑断电了。用户总是希望至少有选择的权利,在一整个数据中心挂了的时候,可以选择花更多的钱,来保留住数据。 但是Amazon描述的却是,EC2实例和两个EBS副本都运行在一个AZ(Availability Zone)。 77 | 78 | 79 | 80 | ![img](https://pic4.zhimg.com/v2-203d37b6b74836e5be764155574decbf_b.jpg) 81 | 82 | 83 | 84 | 在Amazon的术语中,一个AZ就是一个数据中心。Amazon通常这样管理它们的数据中心,在一个城市范围内有多个独立的数据中心。大概2-3个相近的数据中心,通过冗余的高速网络连接在一起,我们之后会看一下为什么这是重要的。但是对于EBS来说,为了降低使用Chain Replication的代价,Amazon 将EBS的两个副本放在一个AZ中。 85 | 86 | # 二 故障可恢复事务 87 | 88 | 为了能更好的理解Aurora的设计,在进一步介绍它是如何工作之前,我们必须要知道典型的数据库是如何设计的。因为Aurora使用的是与MySQL类似的机制实现,但是又以一种有趣的方式实现了加速,所以我们需要知道一个典型的数据库是如何设计实现的,这样我们才能知道Aurora是如何实现加速的。 89 | 90 | 所以这一部分是数据库教程,但是实际上主要关注的是,如何实现一个故障可恢复事务(Crash Recoverable Transaction)。所以这一部分我们主要看的是事务(Transaction)和故障可恢复(Crash Recovery)。数据库还涉及到很多其他的方面,但是对于Aurora来说,这两部分最重要。 91 | 92 | 首先,什么是事务?事务是指将多个操作打包成原子操作,并确保多个操作顺序执行。假设我们运行一个银行系统,我们想在不同的银行账户之间转账。你可以这样看待一个事务,首先需要定义想要原子打包的多个操作的开始;之后是操作的内容,现在我们想要从账户Y转10块钱到账户X,那么账户X需要增加10块,账户Y需要减少10块;最后表明事务结束。 93 | 94 | 95 | 96 | ![img](https://pic2.zhimg.com/v2-46fd1ba1ae601c58ca8ef04d9d5547cd_b.jpg) 97 | 98 | 99 | 100 | 我们希望数据库顺序执行这两个操作,并且不允许其他任何人看到执行的中间状态。同时,考虑到故障,如果在执行的任何时候出现故障,我们需要确保故障恢复之后,要么所有操作都已经执行完成,要么一个操作也没有执行。这是我们想要从事务中获得的效果。除此之外,数据库的用户期望数据库可以通知事务的状态,也就是事务是否真的完成并提交了。如果一个事务提交了,用户期望事务的效果是可以持久保存的,即使数据库故障重启了,数据也还能保存。 101 | 102 | 通常来说,事务是通过对涉及到的每一份数据加锁来实现。所以你可以认为,在整个事务的过程中,都对X,Y加了锁。并且只有当事务结束、提交并且持久化存储之后,锁才会被释放。所以,数据库实际上在事务的过程中,是通过对数据加锁来确保其他人不能访问。这一点很重要,理解了这一点,论文中有一些细节才变得有意义。 103 | 104 | 105 | 106 | ![img](https://pic4.zhimg.com/v2-2ef47484cca2307ee9c9073f56c9abf7_b.jpg) 107 | 108 | 109 | 110 | 所以,这里具体是怎么实现的呢?对于一个简单的数据库模型,数据库运行在单个服务器上,并且使用本地硬盘。 111 | 112 | 113 | 114 | ![img](https://pic2.zhimg.com/v2-16dac9c137e796cbe73cfcf41ca8f0f5_b.jpg) 115 | 116 | 117 | 118 | 在硬盘上存储了数据的记录,或许是以B-Tree方式构建的索引。所以有一些data page用来存放数据库的数据,其中一个存放了X的记录,另一个存放了Y的记录。每一个data page通常会存储大量的记录,而X和Y的记录是page中的一些bit位。 119 | 120 | 121 | 122 | ![img](https://pic4.zhimg.com/v2-6b23d0ceab23998d2d7abfa79c6b70cb_b.jpg) 123 | 124 | 125 | 126 | 在硬盘中,除了有数据之外,还有一个预写式日志(Write-Ahead Log,简称为WAL)。预写式日志对于系统的容错性至关重要。 127 | 128 | 129 | 130 | ![img](https://pic3.zhimg.com/v2-a69de9fb583fb1065dccf69253933886_b.jpg) 131 | 132 | 133 | 134 | 在服务器内部,有数据库软件,通常数据库会对最近从磁盘读取的page有缓存。 135 | 136 | 137 | 138 | ![img](https://pic1.zhimg.com/v2-c2a082e371ea0584fa5367bbf0213944_b.jpg) 139 | 140 | 141 | 142 | 当你在执行一个事务内的各个操作时,例如执行 X=X+10 的操作时,数据库会从硬盘中读取持有X的记录,给数据加10。但是在事务提交之前,数据的修改还只在本地的缓存中,并没有写入到硬盘。我们现在还不想向硬盘写入数据,因为这样可能会暴露一个不完整的事务。 143 | 144 | 为了让数据库在故障恢复之后,还能够提供同样的数据,在允许数据库软件修改硬盘中真实的data page之前,数据库软件需要先在WAL中添加Log条目来描述事务。所以在提交事务之前,数据库需要先在WAL中写入完整的Log条目,来描述所有有关数据库的修改,并且这些Log是写入磁盘的。 145 | 146 | 让我们假设,X的初始值是500,Y的初始值是750。 147 | 148 | 149 | 150 | ![img](https://pic1.zhimg.com/v2-fa004ad86ef9c307d6605999abaaa54c_b.jpg) 151 | 152 | 153 | 154 | 在提交并写入硬盘的data page之前,数据库通常需要写入至少3条Log记录: 155 | 156 | 1. 第一条表明,作为事务的一部分,我要修改X,它的旧数据是500,我要将它改成510。 157 | 2. 第二条表明,我要修改Y,它的旧数据是750,我要将它改成740。 158 | 3. 第三条记录是一个Commit日志,表明事务的结束。 159 | 160 | 通常来说,前两条Log记录会打上事务的ID作为标签,这样在故障恢复的时候,可以根据第三条commit日志找到对应的Log记录,进而知道哪些操作是已提交事务的,哪些是未完成事务的。 161 | 162 | 163 | 164 | ![img](https://pic4.zhimg.com/v2-f4f05b00f1941758f37645c5d45037e7_b.jpg) 165 | 166 | 167 | 168 | > 学生提问:为什么在WAL的log中,需要带上旧的数据值? 169 | > Robert教授:在这个简单的数据库中,在WAL中只记录新的数据就可以了。如果出现故障,只需要重新应用所有新的数据即可。但是大部分真实的数据库同时也会在WAL中存储旧的数值,这样对于一个非常长的事务,只要WAL保持更新,在事务结束之前,数据库可以提前将更新了的page写入硬盘,比如说将Y写入新的数据740。之后如果在事务提交之前故障了,恢复的软件可以发现,事务并没有完成,所以需要撤回之前的操作,这时,这些旧的数据,例如Y的750,需要被用来撤回之前写入到data page中的操作。对于Aurora来说,实际上也使用了undo/redo日志,用来撤回未完成事务的操作。 170 | 171 | 如果数据库成功的将事务对应的操作和commit日志写入到磁盘中,数据库可以回复给客户端说,事务已经提交了。而这时,客户端也可以确认事务是永久可见的。 172 | 173 | 接下来有两种情况。 174 | 175 | 如果数据库没有崩溃,那么在它的cache中,X,Y对应的数值分别是510和740。最终数据库会将cache中的数值写入到磁盘对应的位置。所以数据库写磁盘是一个lazy操作,它会对更新进行累积,每一次写磁盘可能包含了很多个更新操作。这种累积更新可以提升操作的速度。 176 | 177 | 如果数据库在将cache中的数值写入到磁盘之前就崩溃了,这样磁盘中的page仍然是旧的数值。当数据库重启时,恢复软件会扫描WAL日志,发现对应事务的Log,并发现事务的commit记录,那么恢复软件会将新的数值写入到磁盘中。这被称为redo,它会重新执行事务中的写操作。 178 | 179 | 这就是事务型数据库的工作原理的简单描述,同时这也是一个极度精简的MySQL数据库工作方式的介绍,MySQL基本以这种方式实现了故障可恢复事务。而Aurora就是基于这个开源软件MYSQL构建的。 180 | 181 | # 三 关系型数据库 RDS 182 | 183 | 在MySQL基础上,结合Amazon自己的基础设施,Amazon为其云用户开发了改进版的数据库,叫做RDS(Relational Database Service)。尽管论文不怎么讨论RDS,但是论文中的图2基本上是对RDS的描述。RDS是第一次尝试将数据库在多个AZ之间做复制,这样就算整个数据中心挂了,你还是可以从另一个AZ重新获得数据而不丢失任何写操作。 184 | 对于RDS来说,有且仅有一个EC2实例作为数据库。这个数据库将它的data page和WAL Log存储在EBS,而不是对应服务器的本地硬盘。当数据库执行了写Log或者写page操作时,这些写请求实际上通过网络发送到了EBS服务器。所有这些服务器都在一个AZ中。 185 | 186 | ![img](https://pic1.zhimg.com/v2-148fbd1355707094435aa32e9e79b9fc_b.jpg) 187 | 188 | 189 | 每一次数据库软件执行一个写操作,Amazon会自动的,对数据库无感知的,将写操作拷贝发送到另一个数据中心的AZ中。从论文的图2来看,可以发现这是另一个EC2实例,它的工作就是执行与主数据库相同的操作。所以,AZ2的副数据库会将这些写操作拷贝AZ2对应的EBS服务器。 190 | 191 | ![img](https://pic2.zhimg.com/v2-6bb9bb9fe023c5bd20b594d77c183c05_b.jpg) 192 | 193 | 194 | 在RDS的架构中,也就是论文图2中,每一次写操作,例如数据库追加日志或者写磁盘的page,数据除了发送给AZ1的两个EBS副本之外,还需要通过网络发送到位于AZ2的副数据库。副数据库接下来会将数据再发送给AZ2的两个独立的EBS副本。之后,AZ2的副数据库会将写入成功的回复返回给AZ1的主数据库,主数据库看到这个回复之后,才会认为写操作完成了。 195 | RDS这种架构提供了更好的容错性。因为现在在一个其他的AZ中,有了数据库的一份完整的实时的拷贝。这个拷贝可以看到所有最新的写请求。即使AZ1发生火灾都烧掉了,你可以在AZ2的一个新的实例中继续运行数据库,而不丢失任何数据。 196 | 学生提问:为什么EBS的两个副本不放在两个数据中心呢?这样就不用RDS也能保证跨数据中心的高可用了。 197 | Robert教授:我不知道怎么回答这个问题。EBS不是这样工作的,我猜是因为,对于大部分的EBS用户,如果每一个写请求都需要跨数据中心传递,这就太慢了。我不太确定具体的实现,但我认为这是他们不这么做的主要原因。RDS可以看成是EBS工作方式的一种补救,所以使用的还是未经更改的EBS工作方式。 198 | 如论文中表1所示,RDS的写操作代价极高,就如你所预期的一样高,因为需要写大量的数据。即使如之前的例子,执行类似于 x+10,y-10,这样的操作,虽然看起来就是修改两个整数,每个整数或许只有8字节或者16字节,但是对于data page的读写,极有可能会比10多个字节大得多。因为每一个page会有8k字节,或者16k字节,或者是一些由文件系统或者磁盘块决定的相对较大的数字。这意味着,哪怕是只写入这两个数字,当需要更新data page时,需要向磁盘写入多得多的数据。如果使用本地的磁盘,明显会快得多。 199 | 我猜,当他们开始通过网络来传输8k字节的page数据时,他们发现使用了太多的网络容量,所以论文中图2的架构,也就是RDS的架构很明显太慢了。 200 | 学生提问:为什么会慢呢?(教室今天好空) 201 | Robert教授:在这个架构中,对于数据库来说是无感知的,每一次数据库调用写操作,更新自己对应的EBS服务器,每一个写操作的拷贝穿过AZ也会写入到另一个AZ中的2个EBS服务器中,另一个AZ会返回确认说写入成功,只有这时,写操作看起来才是完成的。所以这里必须要等待4个服务器更新完成,并且等待数据在链路上传输。 202 | 如论文中表1描述的性能所担心的一样,这种Mirrored MySQL比Aurora慢得多的原因是,它通过网络传输了大量的数据。这就是性能低的原因,并且Amazon想要修复这里的问题。所以这种架构增强了容错性,因为我们在一个不同的AZ有了第二个副本拷贝,但是对于性能来说又太糟糕了。 203 | 204 | # 四 Aurora初探 205 | 206 | 这一部分开始介绍Aurora。整体上来看,我们还是有一个数据库服务器,但是这里运行的是Amazon提供的定制软件。所以,我可以向Amazon租用一个Aurora服务器,但是我不在上面运行我的软件,我租用了一个服务器运行Amazon的Aurora软件。这里只是一个实例,它运行在某个AZ中。 207 | 在Aurora的架构中,有两件有意思的事情: 208 | 第一个是,在替代EBS的位置,有6个数据的副本,位于3个AZ,每个AZ有2个副本。所以现在有了超级容错性,并且每个写请求都需要以某种方式发送给这6个副本。这有些复杂,我们之后会再介绍。 209 | 210 | ![img](https://pic4.zhimg.com/v2-2e0014af115a900b02eb25d2893986f7_b.jpg) 211 | 212 | 213 | 现在有了更多的副本,我的天,为什么Aurora不是更慢了,之前Mirrored MySQL中才有4个副本。答案是,这里通过网络传递的数据只有Log条目,这才是Aurora成功的关键。从之前的简单数据库模型可以看出,每一条Log条目只有几十个字节那么多,也就是存一下旧的数值,新的数值,所以Log条目非常小。然而,当一个数据库要写本地磁盘时,它更新的是data page,这里的数据是巨大的,虽然在论文里没有说,但是我认为至少是8k字节那么多。所以,对于每一次事务,需要通过网络发送多个8k字节的page数据。而Aurora只是向更多的副本发送了少量的Log条目。因为Log条目的大小比8K字节小得多,所以在网络性能上这里就胜出了。这是Aurora的第一个特点,只发送Log条目。 214 | 当然,这里的后果是,这里的存储系统不再是通用(General-Purpose)存储,这是一个可以理解MySQL Log条目的存储系统。EBS是一个非常通用的存储系统,它模拟了磁盘,只需要支持读写数据块。EBS不理解除了数据块以外的其他任何事物。而这里的存储系统理解使用它的数据库的Log。所以这里,Aurora将通用的存储去掉了,取而代之的是一个应用定制的(Application-Specific)存储系统。 215 | 另一件重要的事情是,Aurora并不需要6个副本都确认了写入才能继续执行操作。相应的,只要Quorum形成了,也就是任意4个副本确认写入了,数据库就可以继续执行操作。所以,当我们想要执行写入操作时,如果有一个AZ下线了,或者AZ的网络连接太慢了,或者只是服务器响应太慢了,Aurora可以忽略最慢的两个服务器,或者已经挂掉的两个服务器,它只需要6个服务器中的任意4个确认写入,就可以继续执行。所以这里的Quorum是Aurora使用的另一个聪明的方法。通过这种方法,Aurora可以有更多的副本,更多的AZ,但是又不用付出大的性能代价,因为它永远也不用等待所有的副本,只需要等待6个服务器中最快的4个服务器即可。 216 | 217 | ![img](https://pic3.zhimg.com/v2-b0cf3268962df6aa8eca76e89fcf5f82_b.jpg) 218 | 219 | 220 | 所以,这节课剩下的时间,我们会用来解释Quorum和Log条目。论文的表1总结了一些结果。Mirrored MySQL将大的page数据发送给4个副本,而Aurora只是将小的Log条目发送给6个副本,Aurora获得了35倍的性能提升。论文并没有介绍性能的提升中,有多少是Quorum的功劳,有多少是只发送Log条目的功劳,但是不管怎么样,35倍的性能提升是令人尊敬的结果,同时也是对用户来说非常有价值的结果。我相信对于许多Amazon的客户来说,这是具有革新意义的。 221 | 222 | # 五Aurora存储服务器的容错目标(Fault-Tolerant Goals) 223 | 224 | 从之前的描述可以看出,Aurora的Quorum系统管理了6个副本的容错系统。所以值得思考的是,Aurora的容错目标是什么? 225 | 226 | - 首先是对于写操作,当只有一个AZ彻底挂了之后,写操作不受影响。 227 | - 其次是对于读操作,当一个AZ和一个其他AZ的服务器挂了之后,读操作不受影响。这里的原因是,AZ的下线时间可能很长,比如说数据中心被水淹了。人们可能需要几天甚至几周的时间来修复洪水造成的故障,在AZ下线的这段时间,我们只能依赖其他AZ的服务器。如果其他AZ中的一个服务器挂了,我们不想让整个系统都瘫痪。所以当一个AZ彻底下线了之后,对于读操作,Aurora还能容忍一个额外服务器的故障,并且仍然可以返回正确的数据。至于为什么会定这样的目标,我们必须理所当然的认为Amazon知道他们自己的业务,并且认为这是实现容错的最佳目标。 228 | - 此外,我之前也提过,Aurora期望能够容忍暂时的慢副本。如果你向EBS读写数据,你并不能得到稳定的性能,有时可能会有一些卡顿,或许网络中一部分已经过载了,或许某些服务器在执行软件升级,任何类似的原因会导致暂时的慢副本。所以Aurora期望能够在出现短暂的慢副本时,仍然能够继续执行操作。 229 | - 最后一个需求是,如果一个副本挂了,在另一个副本挂之前,是争分夺秒的。统计数据或许没有你期望的那么好,因为通常来说服务器故障不是独立的。事实上,一个服务器挂了,通常意味着有很大的可能另一个服务器也会挂,因为它们有相同的硬件,或许从同一个公司购买,来自于同一个生产线。如果其中一个有缺陷,非常有可能会在另一个服务器中也会有相同的缺陷。所以,当出现一个故障时,人们总是非常紧张,因为第二个故障可能很快就会发生。对于Aurora的Quorum系统,有点类似于Raft,你只能从局部故障中恢复。所以这里需要快速生成新的副本(Fast Re-replication)。也就是说如果一个服务器看起来永久故障了,我们期望能够尽可能快的根据剩下的副本,生成一个新的副本。 230 | 231 | 232 | 233 | ![img](https://gitee.com/zisuu/picture/raw/master/img/20210225180653.jpeg) 234 | 235 | 236 | 237 | 所以,以上就是论文列出的Aurora的主要容错目标。顺便说一下,这里的讨论只针对存储服务器,所以这里讨论的是存储服务器的故障特性,以及如何从故障中恢复。如果数据库服务器本身挂了, 该如何恢复是一个完全不同的话题。Aurora有一个完全不同的机制,可以发现数据库服务器挂了之后,创建一个新的实例来运行新的数据库服务器。但是这不是我们现在讨论的话题,我们会在稍后再讨论。现在只是讨论存储服务器,以及存储服务器的容错。 238 | 239 | # 六 复制机制 240 | 241 | Aurora使用了Quorum这种思想。接下来,我将描述一下经典的Quorum思想,它最早可以追溯到1970年代。Aurora使用的是一种经典quorum思想的变种。Quorum系统背后的思想是通过复制构建容错的存储系统,并确保即使有一些副本故障了,读请求还是能看到最近的写请求的数据。通常来说,Quorum系统就是简单的读写系统,支持Put/Get操作。它们通常不直接支持更多更高级的操作。你有一个对象,你可以读这个对象,也可以通过写请求覆盖这个对象的数值。 242 | 243 | 假设有N个副本。为了能够执行写请求,必须要确保写操作被W个副本确认,W小于N。所以你需要将写请求发送到这W个副本。如果要执行读请求,那么至少需要从R个副本得到所读取的信息。这里的W对应的数字称为Write Quorum,R对应的数字称为Read Quorum。这是一个典型的Quorum配置。 244 | 245 | 246 | 247 | ![img](https://pic1.zhimg.com/v2-c91994ec4b98d5944cf93b5ddefdbc8c_b.jpg) 248 | 249 | 250 | 251 | 这里的关键点在于,W、R、N之间的关联。Quorum系统要求,任意你要发送写请求的W个服务器,必须与任意接收读请求的R个服务器有重叠。这意味着,R加上W必须大于N( 至少满足R + W = N + 1 ),这样任意W个服务器至少与任意R个服务器有一个重合。 252 | 253 | 254 | 255 | ![img](https://pic2.zhimg.com/v2-f4dc9058b50fb99ebc604bb2472affc1_b.jpg) 256 | 257 | 258 | 259 | 假设你有3个服务器,并且假设每个服务器只存了一个对象。 260 | 261 | 262 | 263 | ![img](https://pic4.zhimg.com/v2-703bdb0fe11a6269d11183055831be03_b.jpg) 264 | 265 | 266 | 267 | 我们发送了一个写请求,想将我们的对象设置成23。为了能够执行写请求,我们需要至少将写请求发送到W个服务器。我们假设在这个系统中,R和W都是2,N是3。为了执行一个写请求,我们需要将新的数值23发送到至少2个服务器上。所以,或许我们的写请求发送到了S1和S3。所以,它们现在知道了我们对象的数值是23。 268 | 269 | 270 | 271 | ![img](https://pic1.zhimg.com/v2-f45672809654aad78ee4a082b2efcd84_b.jpg) 272 | 273 | 274 | 275 | 如果某人发起读请求,读请求会至少检查R个服务器。在这个配置中,R也是2。这里的R个服务器可能包含了并没有看到之前写请求的服务器(S2),但同时也至少还需要一个其他服务器来凑齐2个服务器。这意味着,任何读请求都至少会包含一个看到了之前写请求的服务器。 276 | 277 | 278 | 279 | ![img](https://pic3.zhimg.com/v2-37c023691031ec180cec680397adcffa_b.jpg) 280 | 281 | 282 | 283 | 这是Quorum系统的要求,Read Quorum必须至少与Write Quorum有一个服务器是重合的。所以任何读请求可以从至少一个看见了之前写请求的服务器得到回复。 284 | 285 | 这里还有一个关键的点,客户端读请求可能会得到R个不同的结果,现在的问题是,客户端如何知道从R个服务器得到的R个结果中,哪一个是正确的呢?通过不同结果出现的次数来投票(Vote)在这是不起作用的,因为我们只能确保Read Quorum必须至少与Write Quorum有一个服务器是重合的,这意味着客户端向R个服务器发送读请求,可能只有一个服务器返回了正确的结果。对于一个有6个副本的系统,可能Read Quorum是4,那么你可能得到了4个回复,但是只有一个与之前写请求重合的服务器能将正确的结果返回,所以这里不能使用投票。在Quorum系统中使用的是版本号(Version)。所以,每一次执行写请求,你需要将新的数值与一个增加的版本号绑定。之后,客户端发送读请求,从Read Quorum得到了一些回复,客户端可以直接使用其中的最高版本号的数值。 286 | 287 | 假设刚刚的例子中,S2有一个旧的数值20。每一个服务器都有一个版本号,S1和S3是版本3,因为它们看到了相同的写请求,所以它们的版本号是相同的。同时我们假设没有看到前一个写请求的S2的版本号是2。 288 | 289 | 290 | 291 | ![img](https://pic3.zhimg.com/v2-580a201d71cb1c3ea03c8db2f5625dae_b.jpg) 292 | 293 | 294 | 295 | 之后客户端从S2和S3读取数据,得到了两个不同结果,它们有着不同的版本号,客户端会挑选版本号最高的结果。 296 | 297 | 如果你不能与Quorum数量的服务器通信,不管是Read Quorum还是Write Quorum,那么你只能不停的重试了。这是Quorum系统的规则,你只能不停的重试,直到服务器重新上线,或者重新联网。 298 | 299 | 相比Chain Replication,这里的优势是可以轻易的剔除暂时故障、失联或者慢的服务器。实际上,这里是这样工作的,当你执行写请求时,你会将新的数值和对应的版本号给所有N个服务器,但是只会等待W个服务器确认。类似的,对于读请求,你可以将读请求发送给所有的服务器,但是只等待R个服务器返回结果。因为你只需要等待R个服务器,这意味着在最快的R个服务器返回了之后,你就可以不用再等待慢服务器或者故障服务器超时。这里忽略慢服务器或者挂了的服务器的机制完全是隐式的。在这里,我们不用决定哪个服务器是在线或者是离线的,只要Quorum能达到,系统就能继续工作,所以我们可以非常平滑的处理慢服务或者挂了的服务。 300 | 301 | 除此之外,Quorum系统可以调整读写的性能。通过调整Read Quorum和Write Quorum,可以使得系统更好的支持读请求或者写请求。对于前面的例子,我们可以假设Write Quorum是3,每一个写请求必须被所有的3个服务器所确认。这样的话,Read Quorum可以只是1。所以,如果你想要提升读请求的性能,在一个3个服务器的Quorum系统中,你可以设置R为1,W为3,这样读请求会快得多,因为它只需要等待一个服务器的结果,但是代价是写请求执行的比较慢。如果你想要提升写请求的性能,可以设置R为3,W为1,这意味着可能只有1个服务器有最新的数值,但是因为客户端会咨询3个服务器,3个服务器其中一个肯定包含了最新的数值。 302 | 303 | 当R为1,W为3时,写请求就不再是容错的了,同样,当R为3,W为1时,读请求不再是容错的,因为对于读请求,所有的服务器都必须在线才能执行成功。所以在实际场景中,你不会想要这么配置,你或许会与Aurora一样,使用更多的服务器,将N变大,然后再权衡Read Quorum和Write Quorum。 304 | 305 | 为了实现上一节描述的Aurora的容错目标,也就是在一个AZ完全下线时仍然能写,在一个AZ加一个其他AZ的服务器下线时仍然能读,Aurora的Quorum系统中,N=6,W=4,R=3。W等于4意味着,当一个AZ彻底下线时,剩下2个AZ中的4个服务器仍然能完成写请求。R等于3意味着,当一个AZ和一个其他AZ的服务器下线时,剩下的3个服务器仍然可以完成读请求。当3个服务器下线了,系统仍然支持读请求,仍然可以返回当前的状态,但是却不能支持写请求。所以,当3个服务器挂了,现在的Quorum系统有足够的服务器支持读请求,并据此重建更多的副本,但是在新的副本创建出来替代旧的副本之前,系统不能支持写请求。同时,如我之前解释的,Quorum系统可以剔除暂时的慢副本。 306 | 307 | # 七读写存储服务 308 | 309 | 我之前也解释过,Aurora中的写请求并不是像一个经典的Quorum系统一样直接更新数据。对于Aurora来说,它的写请求从来不会覆盖任何数据,它的写请求只会在当前Log中追加条目(Append Entries)。所以,Aurora使用Quorum只是在数据库执行事务并发出新的Log记录时,确保Log记录至少出现在4个存储服务器上,之后才能提交事务。所以,Aurora的Write Quorum的实际意义是,每个新的Log记录必须至少追加在4个存储服务器中,之后才可以认为写请求完成了。当Aurora执行到事务的结束,并且在回复给客户端说事务已经提交之前,Aurora必须等待Write Quorum的确认,也就是4个存储服务器的确认,组成事务的每一条Log都成功写入了。 310 | 311 | 实际上,在一个故障恢复过程中,事务只能在之前所有的事务恢复了之后才能被恢复。所以,实际中,在Aurora确认一个事务之前,它必须等待Write Quorum确认之前所有已提交的事务,之后再确认当前的事务,最后才能回复给客户端。 312 | 313 | 这里的存储服务器接收Log条目,这是它们看到的写请求。它们并没有从数据库服务器获得到新的data page,它们得到的只是用来描述data page更新的Log条目。 314 | 315 | 但是存储服务器内存最终存储的还是数据库服务器磁盘中的page。在存储服务器的内存中,会有自身磁盘中page的cache,例如page1(P1),page2(P2),这些page其实就是数据库服务器对应磁盘的page。 316 | 317 | 318 | 319 | ![img](https://pic4.zhimg.com/v2-4900de8af25f7d6d2038b2519b0dfeff_b.jpg) 320 | 321 | 322 | 323 | 当一个新的写请求到达时,这个写请求只是一个Log条目,Log条目中的内容需要应用到相关的page中。但是我们不必立即执行这个更新,可以等到数据库服务器或者恢复软件想要查看那个page时才执行。对于每一个存储服务器存储的page,如果它最近被一个Log条目修改过,那么存储服务器会在内存中缓存一个旧版本的page和一系列来自于数据库服务器有关修改这个page的Log条目。所以,对于一个新的Log条目,它会立即被追加到影响到的page的Log列表中。这里的Log列表从上次page更新过之后开始(相当于page是snapshot,snapshot后面再有一系列记录更新的Log)。如果没有其他事情发生,那么存储服务器会缓存旧的page和对应的一系列Log条目。 324 | 325 | 326 | 327 | ![img](https://pic3.zhimg.com/v2-41ebbb323af6a234c361b9fabc9d862a_b.jpg) 328 | 329 | 330 | 331 | 如果之后数据库服务器将自身缓存的page删除了,过了一会又需要为一个新的事务读取这个page,它会发出一个读请求。请求发送到存储服务器,会要求存储服务器返回当前最新的page数据。在这个时候,存储服务器才会将Log条目中的新数据更新到page,并将page写入到自己的磁盘中,之后再将更新了的page返回给数据库服务器。同时,存储服务器在自身cache中会删除page对应的Log列表,并更新cache中的page,虽然实际上可能会复杂的多。 332 | 333 | 如刚刚提到的,数据库服务器有时需要读取page。所以,可能你已经发现了,数据库服务器写入的是Log条目,但是读取的是page。这也是与Quorum系统不一样的地方。Quorum系统通常读写的数据都是相同的。除此之外,在一个普通的操作中,数据库服务器可以避免触发Quorum Read。数据库服务器会记录每一个存储服务器接收了多少Log。所以,首先,Log条目都有类似12345这样的编号,当数据库服务器发送一条新的Log条目给所有的存储服务器,存储服务器接收到它们会返回说,我收到了第79号和之前所有的Log。数据库服务器会记录这里的数字,或者说记录每个存储服务器收到的最高连续的Log条目号。这样的话,当一个数据库服务器需要执行读操作,它只会挑选拥有最新Log的存储服务器,然后只向那个服务器发送读取page的请求。所以,数据库服务器执行了Quorum Write,但是却没有执行Quorum Read。因为它知道哪些存储服务器有最新的数据,然后可以直接从其中一个读取数据。这样的代价小得多,因为这里只读了一个副本,而不用读取Quorum数量的副本。 334 | 335 | 但是,数据库服务器有时也会使用Quorum Read。假设数据库服务器运行在某个EC2实例,如果相应的硬件故障了,数据库服务器也会随之崩溃。在Amazon的基础设施有一些监控系统可以检测到Aurora数据库服务器崩溃,之后Amazon会自动的启动一个EC2实例,在这个实例上启动数据库软件,并告诉新启动的数据库:你的数据存放在那6个存储服务器中,请清除存储在这些副本中的任何未完成的事务,之后再继续工作。这时,Aurora会使用Quorum的逻辑来执行读请求。因为之前数据库服务器故障的时候,它极有可能处于执行某些事务的中间过程。所以当它故障了,它的状态极有可能是它完成并提交了一些事务,并且相应的Log条目存放于Quorum系统。同时,它还在执行某些其他事务的过程中,这些事务也有一部分Log条目存放在Quorum系统中,但是因为数据库服务器在执行这些事务的过程中崩溃了,这些事务永远也不可能完成。对于这些未完成的事务,我们可能会有这样一种场景,第一个副本有第101个Log条目,第二个副本有第102个Log条目,第三个副本有第104个Log条目,但是没有一个副本持有第103个Log条目。 336 | 337 | 338 | 339 | ![img](https://pic1.zhimg.com/v2-fa972db0bf94ce7732ecfa931acdf01c_b.jpg) 340 | 341 | 342 | 343 | 所以故障之后,新的数据库服务器需要恢复,它会执行Quorum Read,找到第一个缺失的Log序号,在上面的例子中是103,并说,好吧,我们现在缺失了一个Log条目,我们不能执行这条Log之后的所有Log,因为我们缺失了一个Log对应的更新。 344 | 345 | 所以,这种场景下,数据库服务器执行了Quorum Read,从可以连接到的存储服务器中发现103是第一个缺失的Log条目。这时,数据库服务器会给所有的存储服务器发送消息说:请丢弃103及之后的所有Log条目。103及之后的Log条目必然不会包含已提交的事务,因为我们知道只有当一个事务的所有Log条目存在于Write Quorum时,这个事务才会被commit,所以对于已经commit的事务我们肯定可以看到相应的Log。这里我们只会丢弃未commit事务对应的Log条目。 346 | 347 | 所以,某种程度上,我们将Log在102位置做了切割,102及之前的Log会保留。但是这些会保留的Log中,可能也包含了未commit事务的Log,数据库服务器需要识别这些Log。这是可行的,可以通过Log条目中的事务ID和事务的commit Log条目来判断(10.3)哪些Log属于已经commit的事务,哪些属于未commit的事务。数据库服务器可以发现这些未完成的事务对应Log,并发送undo操作来撤回所有未commit事务做出的变更。这就是为什么Aurora在Log中同时也会记录旧的数值的原因。因为只有这样,数据库服务器在故障恢复的过程中,才可以回退之前只提交了一部分,但是没commit的事务。 348 | 349 | # 八数据分片 350 | 351 | 这一部分讨论,Aurora如何处理大型数据库。目前为止,我们已经知道Aurora将自己的数据分布在6个副本上,每一个副本都是一个计算机,上面挂了1-2块磁盘。但是如果只是这样的话,我们不能拥有一个数据大小大于单个机器磁盘空间的数据库。因为虽然我们有6台机器,但是并没有为我们提供6倍的存储空间,每个机器存储的都是相同的数据。如果我使用的是SSD,我可以将数TB的数据存放于单台机器上,但是我不能将数百TB的数据存放于单台机器上。 352 | 353 | 为了能支持超过10TB数据的大型数据库。Amazon的做法是将数据库的数据,分割存储到多组存储服务器上,每一组都是6个副本,分割出来的每一份数据是10GB。所以,如果一个数据库需要20GB的数据,那么这个数据库会使用2个PG(Protection Group),其中一半的10GB数据在一个PG中,包含了6个存储服务器作为副本,另一半的10GB数据存储在另一个PG中,这个PG可能包含了不同的6个存储服务器作为副本。 354 | 355 | 356 | 357 | ![img](https://pic1.zhimg.com/v2-08f73125fa7ce779714cb7af18cad290_b.jpg) 358 | 359 | 360 | 361 | 因为Amazon运行了大量的存储服务器,这些服务器一起被所有的Aurora用户所使用。两组PG可能使用相同的6个存储服务器,但是通常来说是完全不同的两组存储服务器。随着数据库变大,我们可以有更多的Protection Group。 362 | 363 | 这里有一件有意思的事情,你可以将磁盘中的data page分割到多个独立的PG中,比如说奇数号的page存在PG1,偶数号的page存在PG2。如果可以根据data page做sharding,那是极好的。 364 | 365 | Sharding之后,Log该如何处理就不是那么直观了。如果有多个Protection Group,该如何分割Log呢?答案是,当Aurora需要发送一个Log条目时,它会查看Log所修改的数据,并找到存储了这个数据的Protection Group,并把Log条目只发送给这个Protection Group对应的6个存储服务器。这意味着,每个Protection Group只存储了部分data page和所有与这些data page关联的Log条目。所以每个Protection Group存储了所有data page的一个子集,以及这些data page相关的Log条目。 366 | 367 | 如果其中一个存储服务器挂了,我们期望尽可能快的用一个新的副本替代它。因为如果4个副本挂了,我们将不再拥有Read Quorum,我们也因此不能创建一个新的副本。所以我们想要在一个副本挂了以后,尽可能快的生成一个新的副本。表面上看,每个存储服务器存放了某个数据库的某个某个Protection Group对应的10GB数据,但实际上每个存储服务器可能有1-2块几TB的磁盘,上面存储了属于数百个Aurora实例的10GB数据块。所以在存储服务器上,可能总共会有10TB的数据,当它故障时,它带走的不仅是一个数据库的10GB数据,同时也带走了其他数百个数据库的10GB数据。所以生成的新副本,不是仅仅要恢复一个数据库的10GB数据,而是要恢复存储在原来服务器上的整个10TB的数据。我们来做一个算术,如果网卡是10Gb/S,通过网络传输10TB的数据需要8000秒。这个时间太长了,我们不想只是坐在那里等着传输。所以我们不想要有这样一种重建副本的策略:找到另一台存储服务器,通过网络拷贝上面所有的内容到新的副本中。我们需要的是一种快的多的策略。 368 | 369 | Aurora实际使用的策略是,对于一个特定的存储服务器,它存储了许多Protection Group对应的10GB的数据块。对于Protection Group A,它的其他副本是5个服务器。 370 | 371 | 372 | 373 | ![img](https://pic1.zhimg.com/v2-04b580fd60eac7565e52a05a54604cec_b.jpg) 374 | 375 | 376 | 377 | 或许这个存储服务器还为Protection Group B保存了数据,但是B的其他副本存在于与A没有交集的其他5个服务器中(虽然图中只画了4个)。 378 | 379 | 380 | 381 | ![img](https://pic4.zhimg.com/v2-52dea91ca695ddabc2205ab1ee071a63_b.jpg) 382 | 383 | 384 | 385 | 类似的,对于所有的Protection Group对应的数据块,都会有类似的副本。这种模式下,如果一个存储服务器挂了,假设上面有100个数据块,现在的替换策略是:找到100个不同的存储服务器,其中的每一个会被分配一个数据块,也就是说这100个存储服务器,每一个都会加入到一个新的Protection Group中。所以相当于,每一个存储服务器只需要负责恢复10GB的数据。所以在创建新副本的时候,我们有了100个存储服务器(下图中下面那5个空白的)。 386 | 387 | 388 | 389 | ![img](https://pic3.zhimg.com/v2-1655cd69f8bb3d4fa8a448f33181ed8a_b.jpg) 390 | 391 | 392 | 393 | 对于每一个数据块,我们会从Protection Group中挑选一个副本,作为数据拷贝的源。这样,对于100个数据块,相当于有了100个数据拷贝的源。之后,就可以并行的通过网络将100个数据块从100个源拷贝到100个目的。 394 | 395 | 396 | 397 | ![img](https://pic3.zhimg.com/v2-b536fef2e8c2aee0f17e9b316e47eea6_b.jpg) 398 | 399 | 400 | 401 | 假设有足够多的服务器,这里的服务器大概率不会有重合,同时假设我们有足够的带宽,现在我们可以以100的并发,并行的拷贝1TB的数据,这只需要10秒左右。如果只在两个服务器之间拷贝,正常拷贝1TB数据需要1000秒左右。 402 | 403 | 这就是Aurora使用的副本恢复策略,它意味着,如果一个服务器挂了,它可以并行的,快速的在数百台服务器上恢复。如果大量的服务器挂了,可能不能正常工作,但是如果只有一个服务器挂了,Aurora可以非常快的重新生成副本。 404 | 405 | # 九只读数据库 406 | 407 | 如果你查看论文的图3,你可以发现,Aurora不仅有主数据库实例,同时多个数据库的副本。对于Aurora的许多客户来说,相比读写查询,他们会有多得多的只读请求。你可以设想一个Web服务器,如果你只是查看Web页面,那么后台的Web服务器需要读取大量的数据才能生成页面所需的内容,或许需要从数据库读取数百个条目。但是在浏览Web网页时,写请求就要少的多,或许一些统计数据要更新,或许需要更新历史记录,所以读写请求的比例可能是100:1。所以对于Aurora来说,通常会有非常大量的只读数据库查询。 408 | 409 | 对于写请求,可以只发送给一个数据库,因为对于后端的存储服务器来说,只能支持一个写入者。背后的原因是,Log需要按照数字编号,如果只在一个数据库处理写请求,非常容易对Log进行编号,但是如果有多个数据库以非协同的方式处理写请求,那么为Log编号将会非常非常难。 410 | 411 | 但是对于读请求,可以发送给多个数据库。Aurora的确有多个只读数据库,这些数据库可以从后端存储服务器读取数据。所以,图3中描述了,除了主数据库用来处理写请求,同时也有一组只读数据库。论文中宣称可以支持最多15个只读数据库。如果有大量的读请求,读请求可以分担到这些只读数据库上。 412 | 413 | 414 | 415 | ![img](https://pic4.zhimg.com/v2-cf7889b7402dd1c8e12ff2773f69e2db_b.jpg) 416 | 417 | 418 | 419 | 当客户端向只读数据库发送读请求,只读数据库需要弄清楚它需要哪些data page来处理这个读请求,之后直接从存储服务器读取这些data page,并不需要主数据库的介入。所以只读数据库向存储服务器直接发送读取page的请求,之后它会缓存读取到的page,这样对于将来的一些读请求,可以直接根据缓存中的数据返回。 420 | 421 | 422 | 423 | ![img](https://pic2.zhimg.com/v2-c6e75a95cab95e35a2b657cf75644aa1_b.png) 424 | 425 | 426 | 427 | 当然,只读数据库也需要更新自身的缓存,所以,Aurora的主数据库也会将它的Log的拷贝发送给每一个只读数据库。这就是你从论文中图3看到的蓝色矩形中间的那些横线。主数据库会向这些只读数据库发送所有的Log条目,只读数据库用这些Log来更新它们缓存的page数据,进而获得数据库中最新的事务处理结果。 428 | 429 | 430 | 431 | ![img](https://pic3.zhimg.com/v2-b7522d32d4758c6ca20fd2cea97ee89a_b.jpg) 432 | 433 | 434 | 435 | 这的确意味着只读数据库会落后主数据库一点,但是对于大部分的只读请求来说,这没问题。因为如果你查看一个网页,如果数据落后了20毫秒,通常来说不会是一个大问题。 436 | 437 | 这里其实有一些问题,其中一个问题是,我们不想要这个只读数据库看到未commit的事务。所以,在主数据库发给只读数据库的Log流中,主数据库需要指出,哪些事务commit了,而只读数据库需要小心的不要应用未commit的事务到自己的缓存中,它们需要等到事务commit了再应用对应的Log。 438 | 439 | 另一个问题是,数据库背后的B-Tree结构非常复杂,可能会定期触发rebalance。而rebalance是一个非常复杂的操作,对应了大量修改树中的节点的操作,这些操作需要有原子性。因为当B-Tree在rebalance的过程中,中间状态的数据是不正确的,只有在rebalance结束了才可以从B-Tree读取数据。但是只读数据库直接从存储服务器读取数据库的page,它可能会看到在rebalance过程中的B-Tree。这时看到的数据是非法的,会导致只读数据库崩溃或者行为异常。 440 | 441 | 论文中讨论了微事务(Mini-Transaction)和VDL/VCL。这部分实际讨论的就是,数据库服务器可以通知存储服务器说,这部分复杂的Log序列只能以原子性向只读数据库展示,也就是要么全展示,要么不展示。这就是微事务(Mini-Transaction)和VDL。所以当一个只读数据库需要向存储服务器查看一个data page时,存储服务器会小心的,要么展示微事务之前的状态,要么展示微事务之后的状态,但是绝不会展示中间状态。 442 | 443 | 以上就是所有技术相关的内容,我们来总结一下论文中有意思的地方,以及我们可以从论文中学到的一些东西。 444 | 445 | - 一件可以学到的事情其实比较通用,并不局限于这篇论文。大家都应该知道事务型数据库是如何工作的,并且知道事务型数据库与后端存储之间交互带来的影响。这里涉及了性能,故障修复,以及运行一个数据库的复杂度,这些问题在系统设计中会反复出现。 446 | 447 | - 另一个件可以学到的事情是,Quorum思想。通过读写Quorum的重合,可以确保总是能看见最新的数据,但是又具备容错性。这种思想在Raft中也有体现,Raft可以认为是一种强Quorum的实现(读写操作都要过半服务器认可)。 448 | 449 | - 这个论文中另一个有趣的想法是,数据库和存储系统基本是一起开发出来的,数据库和存储系统以一种有趣的方式集成在了一起。通常我们设计系统时,需要有好的隔离解耦来区分上层服务和底层的基础架构。所以通常来说,存储系统是非常通用的,并不会为某个特定的应用程序定制。因为一个通用的设计可以被大量服务使用。但是在Aurora面临的问题中,性能问题是非常严重的,它不得不通过模糊服务和底层基础架构的边界来获得35倍的性能提升,这是个巨大的成功。 450 | 451 | - 最后一件有意思的事情是,论文中的一些有关云基础架构中什么更重要的隐含信息。例如: 452 | 453 | - - 需要担心整个AZ会出现故障; 454 | - 需要担心短暂的慢副本,这是经常会出现的问题; 455 | - 网络是主要的瓶颈,毕竟Aurora通过网络发送的是极短的数据,但是相应的,存储服务器需要做更多的工作(应用Log),因为有6个副本,所以有6个CPU在复制执行这些redo Log条目,明显,从Amazon看来,网络容量相比CPU要重要的多。 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | -------------------------------------------------------------------------------- /paper/base/base-danPritichett.md: -------------------------------------------------------------------------------- 1 | # Base: An Acid Alternative 2 | 3 | ## In partitioned databases, trading some consistency for availability can lead to dramatic improvements in scalability. 4 | 5 | ### Dan Pritchett, Ebay 6 | 7 | Web applications have grown in popularity over the past decade. Whether you are building an application for end users or application developers (i.e., services), your hope is most likely that your application will find broad adoption—and with broad adoption will come transactional growth. If your application relies upon persistence, then data storage will probably become your bottleneck. 8 | 9 | There are two strategies for scaling any application. The first, and by far the easiest, is vertical scaling: moving the application to larger computers. Vertical scaling works reasonably well for data but has several limitations. The most obvious limitation is outgrowing the capacity of the largest system available. Vertical scaling is also expensive, as adding transactional capacity usually requires purchasing the next larger system. Vertical scaling often creates vendor lock, further adding to costs. 10 | 11 | Horizontal scaling offers more flexibility but is also considerably more complex. Horizontal data scaling can be performed along two vectors. Functional scaling involves grouping data by function and spreading functional groups across databases. Splitting data within functional areas across multiple databases, or sharding,1 adds the second dimension to horizontal scaling. The diagram in figure 1 illustrates horizontal data-scaling strategies. 12 | 13 | ![img](https://dl.acm.org/cms/attachment/1f883b61-a80c-427b-af43-519d6d0d7737/fig1.jpg) 14 | 15 | As figure 1 illustrates, both approaches to horizontal scaling can be applied at once. Users, products, and transactions can be in separate databases. Additionally, each functional area can be split across multiple databases for transactional capacity. As shown in the diagram, functional areas can be scaled independently of one another. 16 | 17 | #### Functional Partitioning 18 | 19 | Functional partitioning is important for achieving high degrees of scalability. Any good database architecture will decompose the schema into tables grouped by functionality. Users, products, transactions, and communication are examples of functional areas. Leveraging database concepts such as foreign keys is a common approach for maintaining consistency across these functional areas. 20 | 21 | Relying on database constraints to ensure consistency across functional groups creates a coupling of the schema to a database deployment strategy. For constraints to be applied, the tables must reside on a single database server, precluding horizontal scaling as transaction rates grow. In many cases, the easiest scale-out opportunity is moving functional groups of data onto discrete database servers. 22 | 23 | Schemas that can scale to very high transaction volumes will place functionally distinct data on different database servers. This requires moving data constraints out of the database and into the application. This also introduces several challenges that are addressed later in this article. 24 | 25 | #### CAP Theorem 26 | 27 | Eric Brewer, a professor at the University of California, Berkeley, and cofounder and chief scientist at Inktomi, made the conjecture that Web services cannot ensure all three of the following properties at once (signified by the acronym CAP):2 28 | 29 | **Consistency.** The client perceives that a set of operations has occurred all at once. 30 | 31 | **Availability.** Every operation must terminate in an intended response. 32 | 33 | **Partition tolerance.** Operations will complete, even if individual components are unavailable. 34 | 35 | Specifically, a Web application can support, at most, only two of these properties with any database design. Obviously, any horizontal scaling strategy is based on data partitioning; therefore, designers are forced to decide between consistency and availability. 36 | 37 | #### ACID Solutions 38 | 39 | ACID database transactions greatly simplify the job of the application developer. As signified by the acronym, ACID transactions provide the following guarantees: 40 | 41 | **Atomicity.** All of the operations in the transaction will complete, or none will. 42 | 43 | **Consistency.** The database will be in a consistent state when the transaction begins and ends. 44 | 45 | **Isolation.** The transaction will behave as if it is the only operation being performed upon the database. 46 | 47 | **Durability.** Upon completion of the transaction, the operation will not be reversed. 48 | 49 | Database vendors long ago recognized the need for partitioning databases and introduced a technique known as 2PC (two-phase commit) for providing ACID guarantees across multiple database instances. The protocol is broken into two phases: 50 | 51 | - First, the transaction coordinator asks each database involved to precommit the operation and indicate whether commit is possible. If all databases agree the commit can proceed, then phase 2 begins. 52 | - The transaction coordinator asks each database to commit the data. 53 | 54 | If any database vetoes the commit, then all databases are asked to roll back their portions of the transaction. What is the shortcoming? We are getting consistency across partitions. If Brewer is correct, then we must be impacting availability, but how can that be? 55 | 56 | The availability of any system is the product of the availability of the components required for operation. The last part of that statement is the most important. Components that may be used by the system but are not required do not reduce system availability. A transaction involving two databases in a 2PC commit will have the availability of the product of the availability of each database. For example, if we assume each database has 99.9 percent availability, then the availability of the transaction becomes 99.8 percent, or an additional downtime of 43 minutes per month. 57 | 58 | #### An ACID Alternative 59 | 60 | If ACID provides the consistency choice for partitioned databases, then how do you achieve availability instead? One answer is BASE (basically available, soft state, eventually consistent). 61 | 62 | BASE is diametrically opposed to ACID. Where ACID is pessimistic and forces consistency at the end of every operation, BASE is optimistic and accepts that the database consistency will be in a state of flux. Although this sounds impossible to cope with, in reality it is quite manageable and leads to levels of scalability that cannot be obtained with ACID. 63 | 64 | The availability of BASE is achieved through supporting partial failures without total system failure. Here is a simple example: if users are partitioned across five database servers, BASE design encourages crafting operations in such a way that a user database failure impacts only the 20 percent of the users on that particular host. There is no magic involved, but this does lead to higher perceived availability of the system. 65 | 66 | So, now that you have decomposed your data into functional groups and partitioned the busiest groups across multiple databases, how do you incorporate BASE into your application? BASE requires a more in-depth analysis of the operations within a logical transaction than is typically applied to ACID. What should you be looking for? The following sections provide some direction. 67 | 68 | #### Consistency Patterns 69 | 70 | Following Brewer's conjecture, if BASE allows for availability in a partitioned database, then opportunities to relax consistency have to be identified. This is often difficult because the tendency of both business stakeholders and developers is to assert that consistency is paramount to the success of the application. Temporal inconsistency cannot be hidden from the end user, so both engineering and product owners must be involved in picking the opportunities for relaxing consistency. 71 | 72 | Figure 2 is a simple schema that illustrates consistency considerations for BASE. The user table holds user information including the total amount sold and bought. These are running totals. The transaction table holds each transaction, relating the seller and buyer and the amount of the transaction. These are gross oversimplifications of real tables but contain the necessary elements for illustrating several aspects of consistency. 73 | 74 | ![img](https://dl.acm.org/cms/attachment/22aacd46-a9f1-4b32-b023-88039f4551a2/fig2.jpg) 75 | 76 | In general, consistency across functional groups is easier to relax than within functional groups. The example schema has two functional groups: users and transactions. Each time an item is sold, a row is added to the transaction table and the counters for the buyer and seller are updated. Using an ACID-style transaction, the SQL would be as shown in figure 3. 77 | 78 | ![img](https://dl.acm.org/cms/attachment/2cd24546-35dd-45fe-b386-87cfb4d2eb67/fig3.jpg) 79 | 80 | The total bought and sold columns in the user table can be considered a cache of the transaction table. It is present for efficiency of the system. Given this, the constraint on consistency could be relaxed. The buyer and seller expectations can be set so their running balances do not reflect the result of a transaction immediately. This is not uncommon, and in fact people encounter this delay between a transaction and their running balance regularly (e.g., ATM withdrawals and cellphone calls). 81 | 82 | How the SQL statements are modified to relax consistency depends upon how the running balances are defined. If they are simply estimates, meaning that some transactions can be missed, the changes are quite simple, as shown in figure 4. 83 | 84 | ![img](https://dl.acm.org/cms/attachment/825d5232-1537-415f-ab32-c5e96c1fa55d/fig4.jpg) 85 | 86 | We've now decoupled the updates to the user and transaction tables. Consistency between the tables is not guaranteed. In fact, a failure between the first and second transaction will result in the user table being permanently inconsistent, but if the contract stipulates that the running totals are estimates, this may be adequate. 87 | 88 | What if estimates are not acceptable, though? How can you still decouple the user and transaction updates? Introducing a persistent message queue solves the problem. There are several choices for implementing persistent messages. The most critical factor in implementing the queue, however, is ensuring that the backing persistence is on the same resource as the database. This is necessary to allow the queue to be transactionally committed without involving a 2PC. Now the SQL operations look a bit different, as shown in figure 5. 89 | 90 | ![img](https://dl.acm.org/cms/attachment/2a625ce8-0855-4844-bb01-68ff4d64ce99/fig5.jpg) 91 | 92 | This example takes some liberties with syntax and oversimplifying the logic to illustrate the concept. By queuing a persistent message within the same transaction as the insert, the information needed to update the running balances on the user has been captured. The transaction is contained on a single database instance and therefore will not impact system availability. 93 | 94 | A separate message-processing component will dequeue each message and apply the information to the user table. The example appears to solve all of the issues, but there is a problem. The message persistence is on the transaction host to avoid a 2PC during queuing. If the message is dequeued inside a transaction involving the user host, we still have a 2PC situation. 95 | 96 | One solution to the 2PC in the message-processing component is to do nothing. By decoupling the update into a separate back-end component, you preserve the availability of your customer-facing component. The lower availability of the message processor may be acceptable for business requirements. 97 | 98 | Suppose, however, that 2PC is simply never acceptable in your system. How can this problem be solved? First, you need to understand the concept of idempotence. An operation is considered idempotent if it can be applied one time or multiple times with the same result. Idempotent operations are useful in that they permit partial failures, as applying them repeatedly does not change the final state of the system. 99 | 100 | The selected example is problematic when looking for idempotence. Update operations are rarely idempotent. The example increments balance columns in place. Applying this operation more than once obviously will result in an incorrect balance. Even update operations that simply set a value, however, are not idempotent with regard to order of operations. If the system cannot guarantee that updates will be applied in the order they are received, the final state of the system will be incorrect. More on this later. 101 | 102 | In the case of balance updates, you need a way to track which updates have been applied successfully and which are still outstanding. One technique is to use a table that records the transaction identifiers that have been applied. 103 | 104 | The table shown in figure 6 tracks the transaction ID, which balance has been updated, and the user ID where the balance was applied. Now our sample pseudocode is as shown in figure 7. 105 | 106 | ![img](https://dl.acm.org/cms/attachment/3f32fe36-5807-4046-8f24-e79972a35607/fig6.jpg) 107 | 108 | ![img](https://dl.acm.org/cms/attachment/5e4f1dea-5261-40b9-a0be-ad53546c1974/fig7.jpg) 109 | 110 | This example depends upon being able to peek a message in the queue and remove it once successfully processed. This can be done with two independent transactions if necessary: one on the message queue and one on the user database. Queue operations are not committed unless database operations successfully commit. The algorithm now supports partial failures and still provides transactional guarantees without resorting to 2PC. 111 | 112 | There is a simpler technique for assuring idempotent updates if the only concern is ordering. Let's change our sample schema just a bit to illustrate the challenge and the solution (see figure 8). Suppose you also want to track the last date of sale and purchase for the user. You can rely on a similar scheme of updating the date with a message, but there is one problem. 113 | 114 | ![img](https://dl.acm.org/cms/attachment/15125879-ebc9-46da-8fad-441147287726/fig8.jpg) 115 | 116 | Suppose two purchases occur within a short time window, and our message system doesn't ensure ordered operations. You now have a situation where, depending upon which order the messages are processed in, you will have an incorrect value for last_purchase. Fortunately, this kind of update can be handled with a minor modification to the SQL, as illustrated in figure 9. 117 | 118 | ![img](https://dl.acm.org/cms/attachment/14caca42-f855-4daf-b64c-502ab654912e/fig9.jpg) 119 | 120 | By simply not allowing the last_purchase time to go backward in time, you have made the update operations order independent. You can also use this approach to protect any update from out-of-order updates. As an alternative to using time, you can also try a monotonically increasing transaction ID. 121 | 122 | #### Ordering of Message Queues 123 | 124 | A short side note on ordered message delivery is relevant. Message systems offer the ability to ensure that messages are delivered in the order they are received. This can be expensive to support and is often unnecessary, and, in fact, at times gives a false sense of security. 125 | 126 | The examples provided here illustrate how message ordering can be relaxed and still provide a consistent view of the database, eventually. The overhead required to relax the ordering is nominal and in most cases is significantly less than enforcing ordering in the message system. 127 | 128 | Further, a Web application is semantically an event-driven system regardless of the style of interaction. The client requests arrive to the system in arbitrary order. Processing time required per request varies. Request scheduling throughout the components of the systems is nondeterministic, resulting in nondeterministic queuing of messages. Requiring the order to be preserved gives a false sense of security. The simple reality is that nondeterministic inputs will lead to nondeterministic outputs. 129 | 130 | #### Soft State/Eventually Consistent 131 | 132 | Up to this point, the focus has been on trading consistency for availability. The other side of the coin is understanding the influence that soft state and eventual consistency has on application design. 133 | 134 | As software engineers we tend to look at our systems as closed loops. We think about the predictability of their behavior in terms of predictable inputs producing predictable outputs. This is a necessity for creating correct software systems. The good news in many cases is that using BASE doesn't change the predictability of a system as a closed loop, but it does require looking at the behavior in total. 135 | 136 | A simple example can help illustrate the point. Consider a system where users can transfer assets to other users. The type of asset is irrelevant—it could be money or objects in a game. For this example, we will assume that we have decoupled the two operations of taking the asset from one user and giving it to the other with a message queue used to provide the decoupling. 137 | 138 | Immediately, this system feels nondeterministic and problematic. There is a period of time where the asset has left one user and has not arrived at the other. The size of this time window can be determined by the messaging system design. Regardless, there is a lag between the begin and end states where neither user appears to have the asset. 139 | 140 | If we consider this from the user's perspective, however, this lag may not be relevant or even known. Neither the receiving user nor the sending user may know when the asset arrived. If the lag between sending and receiving is a few seconds, it will be invisible or certainly tolerable to users who are directly communicating about the asset transfer. In this situation the system behavior is considered consistent and acceptable to the users, even though we are relying upon soft state and eventual consistency in the implementation. 141 | 142 | #### Event-Driven Architecture 143 | 144 | What if you do need to know when state has become consistent? You may have algorithms that need to be applied to the state but only when it has reached a consistent state relevant to an incoming request. The simple approach is to rely on events that are generated as state becomes consistent. 145 | 146 | Continuing with the previous example, what if you need to notify the user that the asset has arrived? Creating an event within the transaction that commits the asset to the receiving user provides a mechanism for performing further processing once a known state has been reached. EDA (event-driven architecture) can provide dramatic improvements in scalability and architectural decoupling. Further discussion about the application of EDA is beyond the scope of this article. 147 | 148 | #### Conclusion 149 | 150 | Scaling systems to dramatic transaction rates requires a new way of thinking about managing resources. The traditional transactional models are problematic when loads need to be spread across a large number of components. Decoupling the operations and performing them in turn provides for improved availability and scale at the cost of consistency. BASE provides a model for thinking about this decoupling. 151 | **Q** 152 | 153 | #### References 154 | 155 | 1. http://highscalability.com/unorthodox-approach-database-design-coming-shard. 156 | 2. http://citeseer.ist.psu.edu/544596.html. 157 | 158 | DAN PRITCHETT is a Technical Fellow at eBay where he has been a member of the architecture team for the past four years. In this role, he interfaces with the strategy, business, product, and technology teams across eBay marketplaces, PayPal, and Skype. With more than 20 years of experience at technology companies such as Sun Microsystems, Hewlett-Packard, and Silicon Graphics, Pritchett has a depth of technical experience, ranging from network-level protocols and operating systems to systems design and software patterns. He has a B.S. in computer science from the University of Missouri, Rolla. 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | # paper读后感 179 | 180 | 这篇文章可谓是一片经典巨作 181 | 182 | 通篇以数据库事务为线索,从单机acid到集群分布式事务,最后阐述base实现分布式事务的方法->消息队列 183 | 184 | 文章主要内容如下: 185 | 186 | - 首先介绍,随着业务量增大,数据库面临扩充时的两种方法->垂直和水平扩充 187 | 188 | - 紧接着介绍水平扩充的约束方法->依靠数据库外键,但这种方式使得数据库只能运行在一台服务器上 189 | 190 | - 于是,产生了CAP理论,在P存在的情况下,选择C或者A 191 | 192 | - 对于数据库ACID,出现了2PC的提交方式以实现分布式事务,但是会有延迟,相当于降低了可用性 193 | 194 | - 为了能够放弃强一致性,提高可用性,于是引出了ACID的替代品->BASE理论 195 | 196 | - 接着,通过用户表(包含交易额)和事务表(记录买卖情况),从一个事务->事务分开,降低一致性->利用消息队列解耦 197 | 198 | 通过这个列子,以循序渐进的方式,解释如何通过消息队列实现BASE理论,并说明了幂等性的重要性 199 | 200 | - 最后,强调了软状态和最终一致性的体现->即使存在中间状态,但是用户无法感知中间状态,只会在一段延迟过后,体会到最终的结果 201 | 202 | 203 | 204 | -------------------------------------------------------------------------------- /paper/cap/1.brief: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hzh0425/hy_distribute_theory_basic/80ffc1bfa0ca2856ecdafd46489a036994414894/paper/cap/1.brief -------------------------------------------------------------------------------- /paper/cap/cap-theorem-revisited.md: -------------------------------------------------------------------------------- 1 | **Note:** *Close to two months ago, I wrote a [blog post explaining the CAP Theorem](http://robertgreiner.com/2014/06/cap-theorem-explained/). Since publishing, I've come to realize that my thinking on the subject was quite outdated and is no longer applicable to the real world. I've attempted to make up for that in this post.* 2 | 3 | In today's technical landscape, we are witnessing a strong and increasing desire to scale systems *out* when additional resources (compute, storage, etc.) are needed to successfully complete workloads in a reasonable time frame. This is accomplished through adding additional commodity hardware to a system to handle the increased load. As a result of this scaling strategy, an additional penalty of complexity is incurred in the system. This is where the CAP theorem comes into play. 4 | 5 | The CAP Theorem states that, in a distributed system (a collection of interconnected nodes that share data.), you can only have two out of the following three guarantees across a write/read pair: Consistency, Availability, and Partition Tolerance - one of them must be sacrificed. However, as you will see below, you don't have as many options here as you might think. 6 | 7 | ![img](https://robertgreiner.com/content/images/2019/09/CAP-overview.png) 8 | 9 | - **Consistency** - A read is guaranteed to return the most recent write for a given client. 10 | - **Availability** - A non-failing node will return a reasonable response within a reasonable amount of time (no error or timeout). 11 | - **Partition Tolerance** - The system will continue to function when network partitions occur. 12 | 13 | Before moving further, we need to set one thing straight. Object Oriented Programming != Network Programming! There are assumptions that we take for granted when building applications that share memory, which break down as soon as nodes are split across space and time. 14 | 15 | One such [*fallacy of distributed computing*](http://en.wikipedia.org/wiki/Fallacies_of_Distributed_Computing) is that networks are reliable. They aren't. Networks and parts of networks go down frequently and unexpectedly. Network failures *happen to your system* and you don't get to choose when they occur. 16 | 17 | Given that networks aren't completely reliable, you must tolerate partitions in a distributed system, period. Fortunately, though, you get to choose what to do when a partition does occur. According to the CAP theorem, this means we are left with two options: Consistency and Availability. 18 | 19 | - **CP** - Consistency/Partition Tolerance - Wait for a response from the partitioned node which could result in a timeout error. The system can also choose to return an error, depending on the scenario you desire. Choose Consistency over Availability when your business requirements dictate atomic reads and writes. 20 | 21 | ![img](https://robertgreiner.com/content/images/2019/09/CAP-CP.png) 22 | 23 | - **AP** - Availability/Partition Tolerance - Return the most recent version of the data you have, which could be stale. This system state will also accept writes that can be processed later when the partition is resolved. Choose Availability over Consistency when your business requirements allow for some flexibility around when the data in the system synchronizes. Availability is also a compelling option when the system needs to continue to function in spite of external errors (shopping carts, etc.) 24 | 25 | ![img](https://robertgreiner.com/content/images/2019/09/CAP-AP.png) 26 | 27 | The decision between Consistency and Availability is a *software trade off*. You can choose what to do in the face of a network partition - the control is in your hands. Network outages, both temporary and permanent, are a fact of life and occur whether you want them to or not - this exists outside of your software. 28 | 29 | Building distributed systems provide many advantages, but also adds complexity. Understanding the trade-offs available to you in the face of network errors, and choosing the right path is vital to the success of your application. Failing to get this right from the beginning could doom your application to failure before your first deployment. 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 读后感: 40 | 41 | > - 文章开头讲述cap理论可以解决什么问题:不断空充机器时,造成的复杂度后果 42 | > - 接着讲述CAP理论的具体内容 43 | > - 随后,阐述网络分区一定会存在的原因:网络不可靠,经常崩溃 44 | > - 也即P一定要存在,但幸运的是,可以在网络分区出现时,我们能做什么(也即在A和C中选一个) 45 | > - 最后就讲述了在什么情况下选择A或者C: 46 | > - 当业务要求原子性读写时->C,可以直接返回错误,但会使系统不可用 47 | > - 当业务在数据同步时能允许弹性选择->A,但会造成数据不一致 -------------------------------------------------------------------------------- /paper/google/mapreduce.md: -------------------------------------------------------------------------------- 1 | ## 摘要 2 | 3 | MapReduce是一个编程模型,也是一个处理和生成超大数据集的算法模型的相关实现。用户首先创建一个Map函数处理一个基于 key/value pair的数据集合,输出中间的基于key/value pair的数据集合;然后再创建一个Reduce函数用来合并所有的具有相同中间key值的中间value值。现实世界中有很多满足上述处理模型的例子, 本论文将详细描述这个模型。 4 | 5 | 6 | 7 | MapReduce架构的程序能够在大量的普通配置的计算机上实现并行化处理。这个系统在运行时只关心:如何分割输入数据,在大量计算机组成的 集群上的调度,集群中计算机的错误处理,管理集群中计算机之间必要的通信。采用MapReduce架构可以使那些没有并行计算和分布式处理系统开发经验的 程序员有效利用分布式系统的丰富资源。 8 | 9 | 10 | 11 | 我们的MapReduce实现运行在规模可以灵活调整的由普通机器组成的集群上:一个典型的MapReduce计算往往由几千台机器组成、处理 以TB计算的数据。程序员发现这个系统非常好用:已经实现了数以百计的MapReduce程序,在Google的集群上,每天都有1000多个 MapReduce程序在执行。 12 | 13 | ## 1、介绍 14 | 15 | 在过去的5年里,包括本文作者在内的Google的很多程序员,为了处理海量的原始数据,已经实现了数以百计的、专用的计算方法。这些计算方法 用来处理大量的原始数据,比如,文档抓取(类似网络爬虫的程序)、Web请求日志等等;也为了计算处理各种类型的衍生数据,比如倒排索引、Web文档的图 结构的各种表示形势、每台主机上网络爬虫抓取的页面数量的汇总、每天被请求的最多的查询的集合等等。大多数这样的数据处理运算在概念上很容易理解。然而由 于输入的数据量巨大,因此要想在可接受的时间内完成运算,只有将这些计算分布在成百上千的主机上。如何处理并行计算、如何分发数据、如何处理错误?所有这 些问题综合在一起,需要大量的代码处理,因此也使得原本简单的运算变得难以处理。 16 | 17 | 18 | 19 | 为了解决上述复杂的问题,我们设计一个新的抽象模型,使用这个抽象模型,我们只要表述我们想要执行的简单运算即可,而不必关心并行计算、容错、 数据分布、负载均衡等复杂的细节,这些问题都被封装在了一个库里面。设计这个抽象模型的灵感来自Lisp和许多其他函数式语言的Map和Reduce的原 语。我们意识到我们大多数的运算都包含这样的操作:在输入数据的“逻辑”记录上应用Map操作得出一个中间key/value pair集合,然后在所有具有相同key值的value值上应用Reduce操作,从而达到合并中间的数据,得到一个想要的结果的目的。使用 MapReduce模型,再结合用户实现的Map和Reduce函数,我们就可以非常容易的实现大规模并行化计算;通过MapReduce模型自带的“再 次执行”(re-execution)功能,也提供了初级的容灾实现方案。 20 | 21 | 22 | 23 | 这个工作(实现一个MapReduce框架模型)的主要贡献是通过简单的接口来实现自动的并行化和大规模的分布式计算,通过使用MapReduce模型接口实现在大量普通的PC机上高性能计算。 24 | 25 | 26 | 27 | 第二部分描述基本的编程模型和一些使用案例。第三部分描述了一个经过裁剪的、适合我们的基于集群的计算环境的MapReduce实现。第四部分 描述我们认为在MapReduce编程模型中一些实用的技巧。第五部分对于各种不同的任务,测量我们MapReduce实现的性能。第六部分揭示了在 Google内部如何使用MapReduce作为基础重写我们的索引系统产品,包括其它一些使用MapReduce的经验。第七部分讨论相关的和未来的工 作。 28 | 29 | ## 2、编程模型 30 | 31 | MapReduce编程模型的原理是:利用一个输入key/value pair集合来产生一个输出的key/value pair集合。MapReduce库的用户用两个函数表达这个计算:Map和Reduce。 32 | 33 | 34 | 35 | 用户自定义的Map函数接受一个输入的key/value pair值,然后产生一个中间key/value pair值的集合。MapReduce库把所有具有相同中间key值I的中间value值集合在一起后传递给reduce函数。 36 | 37 | 38 | 39 | 用户自定义的Reduce函数接受一个中间key的值I和相关的一个value值的集合。Reduce函数合并这些value值,形成一个较小 的value值的集合。一般的,每次Reduce函数调用只产生0或1个输出value值。通常我们通过一个迭代器把中间value值提供给Reduce 函数,这样我们就可以处理无法全部放入内存中的大量的value值的集合。 40 | 41 | ### 2.1、例子 42 | 43 | 例如,计算一个大的文档集合中每个单词出现的次数,下面是伪代码段: 44 | map(String key, String value): 45 | // key: document name 46 | // value: document contents 47 | for each word w in value: 48 | EmitIntermediate(w, “1″); 49 | reduce(String key, Iterator values): 50 | // key: a word 51 | // values: a list of counts 52 | int result = 0; 53 | for each v in values: 54 | result += ParseInt(v); 55 | Emit(AsString(result)); 56 | 57 | 58 | 59 | Map函数输出文档中的每个词、以及这个词的出现次数(在这个简单的例子里就是1)。Reduce函数把Map函数产生的每一个特定的词的计数累加起来。 60 | 61 | 62 | 63 | 另外,用户编写代码,使用输入和输出文件的名字、可选的调节参数来完成一个符合MapReduce模型规范的对象,然后调用MapReduce 函数,并把这个规范对象传递给它。用户的代码和MapReduce库链接在一起(用C++实现)。附录A包含了这个实例的全部程序代码。 64 | 65 | ### 2.2、类型 66 | 67 | 尽管在前面例子的伪代码中使用了以字符串表示的输入输出值,但是在概念上,用户定义的Map和Reduce函数都有相关联的类型: 68 | map(k1,v1) ->list(k2,v2) 69 | reduce(k2,list(v2)) ->list(v2) 70 | 比如,输入的key和value值与输出的key和value值在类型上推导的域不同。此外,中间key和value值与输出key和value值在类型上推导的域相同。 71 | 72 | (alex注:原文中这个domain的含义不是很清楚,我参考Hadoop、KFS等实现,map和reduce都使用了泛型,因此,我把domain翻译成类型推导的域)。 73 | 我们的C++中使用字符串类型作为用户自定义函数的输入输出,用户在自己的代码中对字符串进行适当的类型转换。 74 | 75 | ### 2.3、更多的例子 76 | 77 | 这里还有一些有趣的简单例子,可以很容易的使用MapReduce模型来表示: 78 | 79 | - 分布式的Grep:Map函数输出匹配某个模式的一行,Reduce函数是一个恒等函数,即把中间数据复制到输出。 80 | - 计算URL访问频率:Map函数处理日志中web页面请求的记录,然后输出(URL,1)。Reduce函数把相同URL的value值都累加起来,产生(URL,记录总数)结果。 81 | - 倒转网络链接图:Map函数在源页面(source)中搜索所有的链接目标(target)并输出为(target,source)。Reduce函数把给定链接目标(target)的链接组合成一个列表,输出(target,list(source))。 82 | - 每个主机的检索词向量:检索词向量用一个(词,频率)列表来概述出现在文档或文档集中的最重要的一些词。Map函数为每一个输入文档输出(主机 名,检索词向量),其中主机名来自文档的URL。Reduce函数接收给定主机的所有文档的检索词向量,并把这些检索词向量加在一起,丢弃掉低频的检索 词,输出一个最终的(主机名,检索词向量)。 83 | - 倒排索引:Map函数分析每个文档输出一个(词,文档号)的列表,Reduce函数的输入是一个给定词的所有(词,文档号),排序所有的文档号,输出(词,list(文档号))。所有的输出集合形成一个简单的倒排索引,它以一种简单的算法跟踪词在文档中的位置。 84 | - 分布式排序:Map函数从每个记录提取key,输出(key,record)。Reduce函数不改变任何的值。这个运算依赖分区机制(在4.1描述)和排序属性(在4.2描述)。 85 | 86 | ## 3、实现 87 | 88 | MapReduce模型可以有多种不同的实现方式。如何正确选择取决于具体的环境。例如,一种实现方式适用于小型的共享内存方式的机器,另外一种实现方式则适用于大型NUMA架构的多处理器的主机,而有的实现方式更适合大型的网络连接集群。 89 | 90 | 本章节描述一个适用于Google内部广泛使用的运算环境的实现:用以太网交换机连接、由普通PC机组成的大型集群。在我们的环境里包括: 91 | 1.x86架构、运行Linux操作系统、双处理器、2-4GB内存的机器。 92 | 2.普通的网络硬件设备,每个机器的带宽为百兆或者千兆,但是远小于网络的平均带宽的一半。 (alex注:这里需要网络专家解释一下了) 93 | 3.集群中包含成百上千的机器,因此,机器故障是常态。 94 | 4.存储为廉价的内置IDE硬盘。一个内部分布式文件系统用来管理存储在这些磁盘上的数据。文件系统通过数据复制来在不可靠的硬件上保证数据的可靠性和有效性。 95 | 5.用户提交工作(job)给调度系统。每个工作(job)都包含一系列的任务(task),调度系统将这些任务调度到集群中多台可用的机器上。 96 | 97 | ### 3.1、执行概括 98 | 99 | 通过将Map调用的输入数据自动分割为M个数据片段的集合,Map调用被分布到多台机器上执行。输入的数据片段能够在不同的机器上并行处理。使 用分区函数将Map调用产生的中间key值分成R个不同分区(例如,hash(key) mod R),Reduce调用也被分布到多台机器上执行。分区数量(R)和分区函数由用户来指定。 100 | 101 | ![谷歌三大核心技术(一)The Google File System中文版 ](http://static.open-open.com/lib/uploadImg/20120209/20120209125046_676.jpg) 102 | 103 | 图1展示了我们的MapReduce实现中操作的全部流程。当用户调用MapReduce函数时,将发生下面的一系列动作(下面的序号和图1中的序号一一对应): 104 | 1.用户程序首先调用的MapReduce库将输入文件分成M个数据片度,每个数据片段的大小一般从 16MB到64MB(可以通过可选的参数来控制每个数据片段的大小)。然后用户程序在机群中创建大量的程序副本。 (alex:copies of the program还真难翻译) 105 | 2.这些程序副本中的有一个特殊的程序–master。副本中其它的程序都是worker程序,由master分配任务。有M个Map任务和R个Reduce任务将被分配,master将一个Map任务或Reduce任务分配给一个空闲的worker。 106 | 3.被分配了map任务的worker程序读取相关的输入数据片段,从输入的数据片段中解析出key/value pair,然后把key/value pair传递给用户自定义的Map函数,由Map函数生成并输出的中间key/value pair,并缓存在内存中。 107 | 4.缓存中的key/value pair通过分区函数分成R个区域,之后周期性的写入到本地磁盘上。缓存的key/value pair在本地磁盘上的存储位置将被回传给master,由master负责把这些存储位置再传送给Reduce worker。 108 | 5.当Reduce worker程序接收到master程序发来的数据存储位置信息后,使用RPC从Map worker所在主机的磁盘上读取这些缓存数据。当Reduce worker读取了所有的中间数据后,通过对key进行排序后使得具有相同key值的数据聚合在一起。由于许多不同的key值会映射到相同的Reduce 任务上,因此必须进行排序。如果中间数据太大无法在内存中完成排序,那么就要在外部进行排序。 109 | 6.Reduce worker程序遍历排序后的中间数据,对于每一个唯一的中间key值,Reduce worker程序将这个key值和它相关的中间value值的集合传递给用户自定义的Reduce函数。Reduce函数的输出被追加到所属分区的输出文件。 110 | 7.当所有的Map和Reduce任务都完成之后,master唤醒用户程序。在这个时候,在用户程序里的对MapReduce调用才返回。 111 | 112 | 113 | 114 | 在成功完成任务之后,MapReduce的输出存放在R个输出文件中(对应每个Reduce任务产生一个输出文件,文件名由用户指定)。一般情况 下,用户不需要将这R个输出文件合并成一个文件–他们经常把这些文件作为另外一个MapReduce的输入,或者在另外一个可以处理多个分割文件的分布式 应用中使用。 115 | 116 | ### 3.2、Master数据结构 117 | 118 | Master持有一些数据结构,它存储每一个Map和Reduce任务的状态(空闲、工作中或完成),以及Worker机器(非空闲任务的机器)的标识。 119 | 120 | 121 | 122 | Master就像一个数据管道,中间文件存储区域的位置信息通过这个管道从Map传递到Reduce。因此,对于每个已经完成的Map任 务,master存储了Map任务产生的R个中间文件存储区域的大小和位置。当Map任务完成时,Master接收到位置和大小的更新信息,这些信息被逐 步递增的推送给那些正在工作的Reduce任务。 123 | 124 | ### 3.3、容错 125 | 126 | 因为MapReduce库的设计初衷是使用由成百上千的机器组成的集群来处理超大规模的数据,所以,这个库必须要能很好的处理机器故障。 127 | 128 | worker故障 129 | master周期性的ping每个worker。如果在一个约定的时间范围内没有收到worker返回的信息,master将把这个 worker标记为失效。所有由这个失效的worker完成的Map任务被重设为初始的空闲状态,之后这些任务就可以被安排给其他的worker。同样 的,worker失效时正在运行的Map或Reduce任务也将被重新置为空闲状态,等待重新调度。 130 | 131 | 132 | 133 | 当worker故障时,由于已经完成的Map任务的输出存储在这台机器上,Map任务的输出已不可访问了,因此必须重新执行。而已经完成的Reduce任务的输出存储在全局文件系统上,因此不需要再次执行。 134 | 135 | 136 | 137 | 当一个Map任务首先被worker A执行,之后由于worker A失效了又被调度到worker B执行,这个“重新执行”的动作会被通知给所有执行Reduce任务的worker。任何还没有从worker A读取数据的Reduce任务将从worker B读取数据。 138 | 139 | 140 | 141 | MapReduce可以处理大规模worker失效的情况。比如,在一个MapReduce操作执行期间,在正在运行的集群上进行网络维护引起 80台机器在几分钟内不可访问了,MapReduce master只需要简单的再次执行那些不可访问的worker完成的工作,之后继续执行未完成的任务,直到最终完成这个MapReduce操作。 142 | 143 | 144 | 145 | master失败 146 | 一个简单的解决办法是让master周期性的将上面描述的数据结构 (alex注:指3.2节)的 写入磁盘,即检查点(checkpoint)。如果这个master任务失效了,可以从最后一个检查点(checkpoint)开始启动另一个 master进程。然而,由于只有一个master进程,master失效后再恢复是比较麻烦的,因此我们现在的实现是如果master失效,就中止 MapReduce运算。客户可以检查到这个状态,并且可以根据需要重新执行MapReduce操作。 147 | 148 | 149 | 150 | 在失效方面的处理机制 151 | (alex注:原文为”semantics in the presence of failures”) 152 | 当用户提供的Map和Reduce操作是输入确定性函数(即相同的输入产生相同的输出)时,我们的分布式实现在任何情况下的输出都和所有程序没有出现任何错误、顺序的执行产生的输出是一样的。 153 | 154 | 155 | 156 | 我们依赖对Map和Reduce任务的输出是原子提交的来完成这个特性。每个工作中的任务把它的输出写到私有的临时文件中。每个Reduce任 务生成一个这样的文件,而每个Map任务则生成R个这样的文件(一个Reduce任务对应一个文件)。当一个Map任务完成的时,worker发送一个包 含R个临时文件名的完成消息给master。如果master从一个已经完成的Map任务再次接收到到一个完成消息,master将忽略这个消息;否 则,master将这R个文件的名字记录在数据结构里。 157 | 158 | 159 | 160 | 当Reduce任务完成时,Reduce worker进程以原子的方式把临时文件重命名为最终的输出文件。如果同一个Reduce任务在多台机器上执行,针对同一个最终的输出文件将有多个重命名 操作执行。我们依赖底层文件系统提供的重命名操作的原子性来保证最终的文件系统状态仅仅包含一个Reduce任务产生的数据。 161 | 162 | 163 | 164 | 使用MapReduce模型的程序员可以很容易的理解他们程序的行为,因为我们绝大多数的Map和Reduce操作是确定性的,而且存在这样的一个 事实:我们的失效处理机制等价于一个顺序的执行的操作。当Map或/和Reduce操作是不确定性的时候,我们提供虽然较弱但是依然合理的处理机制。当使 用非确定操作的时候,一个Reduce任务R1的输出等价于一个非确定性程序顺序执行产生时的输出。但是,另一个Reduce任务R2的输出也许符合一个 不同的非确定顺序程序执行产生的R2的输出。 165 | 166 | 167 | 168 | 考虑Map任务M和Reduce任务R1、R2的情况。我们设定e(Ri)是Ri已经提交的执行过程(有且仅有一个这样的执行过程)。当e(R1)读取了由M一次执行产生的输出,而e(R2)读取了由M的另一次执行产生的输出,导致了较弱的失效处理。 169 | 170 | ### 3.4、存储位置 171 | 172 | 在我们的计算运行环境中,网络带宽是一个相当匮乏的资源。我们通过尽量把输入数据(由GFS管理)存储在集群中机器的本地磁盘上来节省网络带 宽。GFS把每个文件按64MB一个Block分隔,每个Block保存在多台机器上,环境中就存放了多份拷贝(一般是3个拷贝)。MapReduce的 master在调度Map任务时会考虑输入文件的位置信息,尽量将一个Map任务调度在包含相关输入数据拷贝的机器上执行;如果上述努力失败 了,master将尝试在保存有输入数据拷贝的机器附近的机器上执行Map任务(例如,分配到一个和包含输入数据的机器在一个switch里的 worker机器上执行)。当在一个足够大的cluster集群上运行大型MapReduce操作的时候,大部分的输入数据都能从本地机器读取,因此消耗 非常少的网络带宽。 173 | 174 | ### 3.5、任务粒度 175 | 176 | 如前所述,我们把Map拆分成了M个片段、把Reduce拆分成R个片段执行。理想情况下,M和R应当比集群中worker的机器数量要多得 多。在每台worker机器都执行大量的不同任务能够提高集群的动态的负载均衡能力,并且能够加快故障恢复的速度:失效机器上执行的大量Map任务都可以 分布到所有其他的worker机器上去执行。 177 | 178 | 179 | 180 | 但是实际上,在我们的具体实现中对M和R的取值都有一定的客观限制,因为master必须执行O(M+R)次调度,并且在内存中保存O(M*R)个状态(对影响内存使用的因素还是比较小的:O(M*R)块状态,大概每对Map任务/Reduce任务1个字节就可以了)。 181 | 182 | 183 | 184 | 更进一步,R值通常是由用户指定的,因为每个Reduce任务最终都会生成一个独立的输出文件。实际使用时我们也倾向于选择合适的M值,以使得 每一个独立任务都是处理大约16M到64M的输入数据(这样,上面描写的输入数据本地存储优化策略才最有效),另外,我们把R值设置为我们想使用的 worker机器数量的小的倍数。我们通常会用这样的比例来执行MapReduce:M=200000,R=5000,使用2000台worker机器。 185 | 186 | ### 3.6、备用任务 187 | 188 | 影响一个MapReduce的总执行时间最通常的因素是“落伍者”:在运算过程中,如果有一台机器花了很长的时间才完成最后几个Map或 Reduce任务,导致MapReduce操作总的执行时间超过预期。出现“落伍者”的原因非常多。比如:如果一个机器的硬盘出了问题,在读取的时候要经 常的进行读取纠错操作,导致读取数据的速度从30M/s降低到1M/s。如果cluster的调度系统在这台机器上又调度了其他的任务,由于CPU、内 存、本地硬盘和网络带宽等竞争因素的存在,导致执行MapReduce代码的执行效率更加缓慢。我们最近遇到的一个问题是由于机器的初始化代码有bug, 导致关闭了的处理器的缓存:在这些机器上执行任务的性能和正常情况相差上百倍。 189 | 190 | 191 | 192 | 我们有一个通用的机制来减少“落伍者”出现的情况。当一个MapReduce操作接近完成的时候,master调度备用(backup)任务进 程来执行剩下的、处于处理中状态(in-progress)的任务。无论是最初的执行进程、还是备用(backup)任务进程完成了任务,我们都把这个任 务标记成为已经完成。我们调优了这个机制,通常只会占用比正常操作多几个百分点的计算资源。我们发现采用这样的机制对于减少超大MapReduce操作的 总处理时间效果显著。例如,在5.3节描述的排序任务,在关闭掉备用任务的情况下要多花44%的时间完成排序任务。 193 | 194 | 195 | 196 | ## 4、技巧 197 | 198 | 虽然简单的Map和Reduce函数提供的基本功能已经能够满足大部分的计算需要,我们还是发掘出了一些有价值的扩展功能。本节将描述这些扩展功能。 199 | 200 | ### 4.1、分区函数 201 | 202 | MapReduce的使用者通常会指定Reduce任务和Reduce任务输出文件的数量(R)。我们在中间key上使用分区函数来对数据进行 分区,之后再输入到后续任务执行进程。一个缺省的分区函数是使用hash方法(比如,hash(key) mod R)进行分区。hash方法能产生非常平衡的分区。然而,有的时候,其它的一些分区函数对key值进行的分区将非常有用。比如,输出的key值是 URLs,我们希望每个主机的所有条目保持在同一个输出文件中。为了支持类似的情况,MapReduce库的用户需要提供专门的分区函数。例如,使用 “hash(Hostname(urlkey)) mod R”作为分区函数就可以把所有来自同一个主机的URLs保存在同一个输出文件中。 203 | 204 | ### 4.2、顺序保证 205 | 206 | 我们确保在给定的分区中,中间key/value pair数据的处理顺序是按照key值增量顺序处理的。这样的顺序保证对每个分成生成一个有序的输出文件,这对于需要对输出文件按key值随机存取的应用非常有意义,对在排序输出的数据集也很有帮助。 207 | 208 | ### 4.3、Combiner函数 209 | 210 | 在某些情况下,Map函数产生的中间key值的重复数据会占很大的比重,并且,用户自定义的Reduce函数满足结合律和交换律。在2.1节的 词数统计程序是个很好的例子。由于词频率倾向于一个zipf分布(齐夫分布),每个Map任务将产生成千上万个这样的记录。所 有的这些记录将通过网络被发送到一个单独的Reduce任务,然后由这个Reduce任务把所有这些记录累加起来产生一个数字。我们允许用户指定一个可选 的combiner函数,combiner函数首先在本地将这些记录进行一次合并,然后将合并的结果再通过网络发送出去。 211 | 212 | 213 | 214 | Combiner函数在每台执行Map任务的机器上都会被执行一次。一般情况下,Combiner和Reduce函数是一样的。 Combiner函数和Reduce函数之间唯一的区别是MapReduce库怎样控制函数的输出。Reduce函数的输出被保存在最终的输出文件里,而 Combiner函数的输出被写到中间文件里,然后被发送给Reduce任务。 215 | 216 | 217 | 218 | 部分的合并中间结果可以显著的提高一些MapReduce操作的速度。附录A包含一个使用combiner函数的例子。 219 | 220 | ### 4.4、输入和输出的类型 221 | 222 | MapReduce库支持几种不同的格式的输入数据。比如,文本模式的输入数据的每一行被视为是一个key/value pair。key是文件的偏移量,value是那一行的内容。另外一种常见的格式是以key进行排序来存储的key/value pair的序列。每种输入类型的实现都必须能够把输入数据分割成数据片段,该数据片段能够由单独的Map任务来进行后续处理(例如,文本模式的范围分割必 须确保仅仅在每行的边界进行范围分割)。虽然大多数MapReduce的使用者仅仅使用很少的预定义输入类型就满足要求了,但是使用者依然可以通过提供一 个简单的Reader接口实现就能够支持一个新的输入类型。 223 | 224 | 225 | 226 | Reader并非一定要从文件中读取数据,比如,我们可以很容易的实现一个从数据库里读记录的Reader,或者从内存中的数据结构读取数据的Reader。 227 | 228 | 类似的,我们提供了一些预定义的输出数据的类型,通过这些预定义类型能够产生不同格式的数据。用户采用类似添加新的输入数据类型的方式增加新的输出类型。 229 | 230 | ### 4.5、副作用 231 | 232 | 在某些情况下,MapReduce的使用者发现,如果在Map和/或Reduce操作过程中增加辅助的输出文件会比较省事。我们依靠程序writer把这种“副作用”变成原子的和幂等的 (alex注:幂等的指一个总是产生相同结果的数学运算)。通常应用程序首先把输出结果写到一个临时文件中,在输出全部数据之后,在使用系统级的原子操作rename重新命名这个临时文件。 233 | 234 | 235 | 236 | 如果一个任务产生了多个输出文件,我们没有提供类似两阶段提交的原子操作支持这种情况。因此,对于会产生多个输出文件、并且对于跨文件有一致性要求的任务,都必须是确定性的任务。但是在实际应用过程中,这个限制还没有给我们带来过麻烦。 237 | 238 | ### 4.6、跳过损坏的记录 239 | 240 | 有时候,用户程序中的bug导致Map或者Reduce函数在处理某些记录的时候crash掉,MapReduce操作无法顺利完成。惯常的做 法是修复bug后再次执行MapReduce操作,但是,有时候找出这些bug并修复它们不是一件容易的事情;这些bug也许是在第三方库里边,而我们手 头没有这些库的源代码。而且在很多时候,忽略一些有问题的记录也是可以接受的,比如在一个巨大的数据集上进行统计分析的时候。我们提供了一种执行模式,在 这种模式下,为了保证保证整个处理能继续进行,MapReduce会检测哪些记录导致确定性的crash,并且跳过这些记录不处理。 241 | 242 | 243 | 244 | 每个worker进程都设置了信号处理函数捕获内存段异常(segmentation violation)和总线错误(bus error)。在执行Map或者Reduce操作之前,MapReduce库通过全局变量保存记录序号。如果用户程序触发了一个系统信号,消息处理函数将 用“最后一口气”通过UDP包向master发送处理的最后一条记录的序号。当master看到在处理某条特定记录不止失败一次时,master就标志着 条记录需要被跳过,并且在下次重新执行相关的Map或者Reduce任务的时候跳过这条记录。 245 | 246 | ### 4.7、本地执行 247 | 248 | 调试Map和Reduce函数的bug是非常困难的,因为实际执行操作时不但是分布在系统中执行的,而且通常是在好几千台计算机上执行,具体的 执行位置是由master进行动态调度的,这又大大增加了调试的难度。为了简化调试、profile和小规模测试,我们开发了一套MapReduce库的 本地实现版本,通过使用本地版本的MapReduce库,MapReduce操作在本地计算机上顺序的执行。用户可以控制MapReduce操作的执行, 可以把操作限制到特定的Map任务上。用户通过设定特别的标志来在本地执行他们的程序,之后就可以很容易的使用本地调试和测试工具(比如gdb)。 249 | 250 | ### 4.8、状态信息 251 | 252 | master使用嵌入式的HTTP服务器(如Jetty)显示一组状态信息页面,用户可以监控各种执行状态。状态信息页面显示了包括计算执行的 进度,比如已经完成了多少任务、有多少任务正在处理、输入的字节数、中间数据的字节数、输出的字节数、处理百分比等等。页面还包含了指向每个任务的 stderr和stdout文件的链接。用户根据这些数据预测计算需要执行大约多长时间、是否需要增加额外的计算资源。这些页面也可以用来分析什么时候计 算执行的比预期的要慢。 253 | 254 | 255 | 256 | 另外,处于最顶层的状态页面显示了哪些worker失效了,以及他们失效的时候正在运行的Map和Reduce任务。这些信息对于调试用户代码中的bug很有帮助。 257 | 258 | ### 4.9、计数器 259 | 260 | MapReduce库使用计数器统计不同事件发生次数。比如,用户可能想统计已经处理了多少个单词、已经索引的多少篇German文档等等。 261 | 262 | 263 | 264 | 为了使用这个特性,用户在程序中创建一个命名的计数器对象,在Map和Reduce函数中相应的增加计数器的值。例如: 265 | Counter* uppercase; 266 | uppercase = GetCounter(“uppercase”); 267 | 268 | map(String name, String contents): 269 | for each word w in contents: 270 | if (IsCapitalized(w)): 271 | uppercase->Increment(); 272 | EmitIntermediate(w, “1″); 273 | 274 | 这些计数器的值周期性的从各个单独的worker机器上传递给master(附加在ping的应答包中传递)。master把执行成功的Map和Reduce任务的计数器值进行累计,当MapReduce操作完成之后,返回给用户代码。 275 | 276 | 277 | 278 | 计数器当前的值也会显示在master的状态页面上,这样用户就可以看到当前计算的进度。当累加计数器的值的时候,master要检查重复运行的Map或者Reduce任务,避免重复累加(之前提到的备用任务和失效后重新执行任务这两种情况会导致相同的任务被多次执行)。 279 | 280 | 281 | 282 | 有些计数器的值是由MapReduce库自动维持的,比如已经处理的输入的key/value pair的数量、输出的key/value pair的数量等等。 283 | 284 | 285 | 286 | 计数器机制对于MapReduce操作的完整性检查非常有用。比如,在某些MapReduce操作中,用户需要确保输出的key value pair精确的等于输入的key value pair,或者处理的German文档数量在处理的整个文档数量中属于合理范围。 287 | 288 | ## 5、性能 289 | 290 | 本节我们用在一个大型集群上运行的两个计算来衡量MapReduce的性能。一个计算在大约1TB的数据中进行特定的模式匹配,另一个计算对大约1TB的数据进行排序。 291 | 292 | 293 | 294 | 这两个程序在大量的使用MapReduce的实际应用中是非常典型的 — 一类是对数据格式进行转换,从一种表现形式转换为另外一种表现形式;另一类是从海量数据中抽取少部分的用户感兴趣的数据。 295 | 296 | ### 5.1、集群配置 297 | 298 | 所有这些程序都运行在一个大约由1800台机器构成的集群上。每台机器配置2个2G主频、支持超线程的Intel Xeon处理器,4GB的物理内存,两个160GB的IDE硬盘和一个千兆以太网卡。这些机器部署在一个两层的树形交换网络中,在root节点大概有 100-200GBPS的传输带宽。所有这些机器都采用相同的部署(对等部署),因此任意两点之间的网络来回时间小于1毫秒。 299 | 300 | 301 | 302 | 在4GB内存里,大概有1-1.5G用于运行在集群上的其他任务。测试程序在周末下午开始执行,这时主机的CPU、磁盘和网络基本上处于空闲状态。 303 | 304 | ### 5.2、GREP 305 | 306 | 这个分布式的grep程序需要扫描大概10的10次方个由100个字节组成的记录,查找出现概率较小的3个字符的模式(这个模式在92337个记录中出现)。输入数据被拆分成大约64M的Block(M=15000),整个输出数据存放在一个文件中(R=1)。 [![谷歌三大核心技术(一)The Google File System中文版 ](http://static.open-open.com/lib/uploadImg/20120209/20120209125046_607.jpg)](http://img851.ph.126.net/r85vTdroywFvfQNw0BQ52Q==/2721300074839672192.jpg) 307 | 308 | 图2显示了这个运算随时间的处理过程。其中Y轴表示输入数据的处理速度。处理速度随着参与MapReduce计算的机器数量的增加而增加,当 1764台worker参与计算的时,处理速度达到了30GB/s。当Map任务结束的时候,即在计算开始后80秒,输入的处理速度降到0。整个计算过程 从开始到结束一共花了大概150秒。这包括了大约一分钟的初始启动阶段。初始启动阶段消耗的时间包括了是把这个程序传送到各个worker机器上的时间、 等待GFS文件系统打开1000个输入文件集合的时间、获取相关的文件本地位置优化信息的时间。 309 | 310 | ### 5.3、排序 311 | 312 | 排序程序处理10的10次方个100个字节组成的记录(大概1TB的数据)。这个程序模仿TeraSort benchmark[10]。 313 | 314 | 315 | 316 | 排序程序由不到50行代码组成。只有三行的Map函数从文本行中解析出10个字节的key值作为排序的key,并且把这个key和原始文本行作 为中间的key/value pair值输出。我们使用了一个内置的恒等函数作为Reduce操作函数。这个函数把中间的key/value pair值不作任何改变输出。最终排序结果输出到两路复制的GFS文件系统(也就是说,程序输出2TB的数据)。 317 | 318 | 319 | 320 | 如前所述,输入数据被分成64MB的Block(M=15000)。我们把排序后的输出结果分区后存储到4000个文件(R=4000)。分区函数使用key的原始字节来把数据分区到R个片段中。 321 | 322 | 323 | 324 | 在这个benchmark测试中,我们使用的分区函数知道key的分区情况。通常对于排序程序来说,我们会增加一个预处理的MapReduce操作用于采样key值的分布情况,通过采样的数据来计算对最终排序处理的分区点。 325 | 326 | ![谷歌三大核心技术(一)The Google File System中文版 ](http://static.open-open.com/lib/uploadImg/20120209/20120209125047_134.jpg) 327 | 328 | 图三(a)显示了这个排序程序的正常执行过程。左上的图显示了输入数据读取的速度。数据读取速度峰值会达到13GB/s,并且所有Map任务完 成之后,即大约200秒之后迅速滑落到0。值得注意的是,排序程序输入数据读取速度小于分布式grep程序。这是因为排序程序的Map任务花了大约一半的 处理时间和I/O带宽把中间输出结果写到本地硬盘。相应的分布式grep程序的中间结果输出几乎可以忽略不计。 329 | 330 | 331 | 332 | 左边中间的图显示了中间数据从Map任务发送到Reduce任务的网络速度。这个过程从第一个Map任务完成之后就开始缓慢启动了。图示的第一 个高峰是启动了第一批大概1700个Reduce任务(整个MapReduce分布到大概1700台机器上,每台机器1次最多执行1个Reduce任 务)。排序程序运行大约300秒后,第一批启动的Reduce任务有些完成了,我们开始执行剩下的Reduce任务。所有的处理在大约600秒后结束。 333 | 334 | 335 | 336 | 左下图表示Reduce任务把排序后的数据写到最终的输出文件的速度。在第一个排序阶段结束和数据开始写入磁盘之间有一个小的延时,这是因为 worker机器正在忙于排序中间数据。磁盘写入速度在2-4GB/s持续一段时间。输出数据写入磁盘大约持续850秒。计入初始启动部分的时间,整个运 算消耗了891秒。这个速度和TeraSort benchmark[18]的最高纪录1057秒相差不多。 337 | 338 | 339 | 340 | 还有一些值得注意的现象:输入数据的读取速度比排序速度和输出数据写入磁盘速度要高不少,这是因为我们的输入数据本地化优化策略起了作用 — 绝大部分数据都是从本地硬盘读取的,从而节省了网络带宽。排序速度比输出数据写入到磁盘的速度快,这是因为输出数据写了两份(我们使用了2路的GFS文件 系统,写入复制节点的原因是为了保证数据可靠性和可用性)。我们把输出数据写入到两个复制节点的原因是因为这是底层文件系统的保证数据可靠性和可用性的实 现机制。如果底层文件系统使用类似容错编码[14](erasure coding)的方式而不是复制的方式保证数据的可靠性和可用性,那么在输出数据写入磁盘的时候,就可以降低网络带宽的使用。 341 | 342 | ### 5.4、高效的backup任务 343 | 344 | 图三(b)显示了关闭了备用任务后排序程序执行情况。执行的过程和图3(a)很相似,除了输出数据写磁盘的动作在时间上拖了一个很长的尾巴,而 且在这段时间里,几乎没有什么写入动作。在960秒后,只有5个Reduce任务没有完成。这些拖后腿的任务又执行了300秒才完成。整个计算消耗了 1283秒,多了44%的执行时间。 345 | 346 | ### 5.5、失效的机器 347 | 348 | 在图三(c)中演示的排序程序执行的过程中,我们在程序开始后几分钟有意的kill了1746个worker中的200个。集群底层的调度立刻在这些机器上重新开始新的worker处理进程(因为只是worker机器上的处理进程被kill了,机器本身还在工作)。 349 | 350 | 351 | 352 | 图三(c)显示出了一个“负”的输入数据读取速度,这是因为一些已经完成的Map任务丢失了(由于相应的执行Map任务的worker进程被 kill了),需要重新执行这些任务。相关Map任务很快就被重新执行了。整个运算在933秒内完成,包括了初始启动时间(只比正常执行多消耗了5%的时 间)。 353 | 354 | ### 6、经验 355 | 356 | 我们在2003年1月完成了第一个版本的MapReduce库,在2003年8月的版本有了显著的增强,这包括了输入数据本地优化、 worker机器之间的动态负载均衡等等。从那以后,我们惊喜的发现,MapReduce库能广泛应用于我们日常工作中遇到的各类问题。它现在在 Google内部各个领域得到广泛应用,包括: 357 | 358 | - 大规模机器学习问题 359 | - Google News和Froogle产品的集群问题 360 | - 从公众查询产品(比如Google的Zeitgeist)的报告中抽取数据。 361 | - 从大量的新应用和新产品的网页中提取有用信息(比如,从大量的位置搜索网页中抽取地理位置信息)。 362 | - 大规模的图形计算。 363 | 364 | ![谷歌三大核心技术(一)The Google File System中文版 ](http://static.open-open.com/lib/uploadImg/20120209/20120209125047_547.jpg) 365 | 366 | 图四显示了在我们的源代码管理系统中,随着时间推移,独立的MapReduce程序数量的显著增加。从2003年早些时候的0个增长到2004 年9月份的差不多900个不同的程序。MapReduce的成功取决于采用MapReduce库能够在不到半个小时时间内写出一个简单的程序,这个简单的 程序能够在上千台机器的组成的集群上做大规模并发处理,这极大的加快了开发和原形设计的周期。另外,采用MapReduce库,可以让完全没有分布式和/ 或并行系统开发经验的程序员很容易的利用大量的资源,开发出分布式和/或并行处理的应用。 367 | 368 | ![谷歌三大核心技术(一)The Google File System中文版 ](http://static.open-open.com/lib/uploadImg/20120209/20120209125047_950.jpg) 369 | 370 | 在每个任务结束的时候,MapReduce库统计计算资源的使用状况。在表1,我们列出了2004年8月份MapReduce运行的任务所占用的相关资源。 371 | 372 | ### 6.1、大规模索引 373 | 374 | 到目前为止,MapReduce最成功的应用就是重写了Google网络搜索服务所使用到的index系统。索引系统的输入数据是网络爬虫抓取回来的海量的文档,这些文档数据都保存在GFS文件系统里。这些文档原始内容 (alex注:raw contents,我认为就是网页中的剔除html标记后的内容、pdf和word等有格式文档中提取的文本内容等)的大小超过了20TB。索引程序是通过一系列的MapReduce操作(大约5到10次)来建立索引。使用MapReduce(替换上一个特别设计的、分布式处理的索引程序)带来这些好处: 375 | 376 | - 实现索引部分的代码简单、小巧、容易理解,因为对于容错、分布式以及并行计算的处理都是MapReduce库提供的。比如,使用MapReduce库,计算的代码行数从原来的3800行C++代码减少到大概700行代码。 377 | - MapReduce库的性能已经足够好了,因此我们可以把在概念上不相关的计算步骤分开处理,而不是混在一起以期减少数据传递的额外消耗。概念 上不相关的计算步骤的隔离也使得我们可以很容易改变索引处理方式。比如,对之前的索引系统的一个小更改可能要耗费好几个月的时间,但是在使用 MapReduce的新系统上,这样的更改只需要花几天时间就可以了。 378 | - 索引系统的操作管理更容易了。因为由机器失效、机器处理速度缓慢、以及网络的瞬间阻塞等引起的绝大部分问题都已经由MapReduce库解决了,不再需要操作人员的介入了。另外,我们可以通过在索引系统集群中增加机器的简单方法提高整体处理性能。 379 | 380 | ### 7、相关工作 381 | 382 | 很多系统都提供了严格的编程模式,并且通过对编程的严格限制来实现并行计算。例如,一个结合函数可以通过把N个元素的数组的前缀在N个处理器上使用并行前缀算法,在log N的时间内计算完[6,9,13] (alex注:完全没有明白作者在说啥,具体参考相关6、9、13文档)。MapReduce可以看作是我们结合在真实环境下处理海量数据的经验,对这些经典模型进行简化和萃取的成果。更加值得骄傲的是,我们还实现了基于上千台处理器的集群的容错处理。相比而言,大部分并发处理系统都只在小规模的集群上实现,并且把容错处理交给了程序员。 383 | 384 | 385 | 386 | Bulk Synchronous Programming[17]和一些MPI原语[11]提供了更高级别的并行处理抽象,可以更容易写出并行处理的程序。MapReduce和这些系统的 关键不同之处在于,MapReduce利用限制性编程模式实现了用户程序的自动并发处理,并且提供了透明的容错处理。 387 | 388 | 389 | 390 | 我们数据本地优化策略的灵感来源于active disks[12,15]等技术,在active disks中,计算任务是尽量推送到数据存储的节点处理 (alex注:即靠近数据源处理),这样就减少了网络和IO子系统的吞吐量。我们在挂载几个硬盘的普通机器上执行我们的运算,而不是在磁盘处理器上执行我们的工作,但是达到的目的一样的。 391 | 392 | 393 | 394 | 我们的备用任务机制和Charlotte System[3]提出的eager调度机制比较类似。Eager调度机制的一个缺点是如果一个任务反复失效,那么整个计算就不能完成。我们通过忽略引起故障的记录的方式在某种程度上解决了这个问题。 395 | 396 | 397 | 398 | MapReduce的实现依赖于一个内部的集群管理系统,这个集群管理系统负责在一个超大的、共享机器的集群上分布和运行用户任务。虽然这个不是本论文的重点,但是有必要提一下,这个集群管理系统在理念上和其它系统,如Condor[16]是一样。 399 | 400 | 401 | 402 | MapReduce库的排序机制和NOW-Sort[1]的操作上很类似。读取输入源的机器(map workers)把待排序的数据进行分区后,发送到R个Reduce worker中的一个进行处理。每个Reduce worker在本地对数据进行排序(尽可能在内存中排序)。当然,NOW-Sort没有给用户自定义的Map和Reduce函数的机会,因此不具备 MapReduce库广泛的实用性。 403 | 404 | 405 | 406 | River[2]提供了一个编程模型:处理进程通过分布式队列传送数据的方式进行互相通讯。和MapReduce类似,River系统尝试在不 对等的硬件环境下,或者在系统颠簸的情况下也能提供近似平均的性能。River是通过精心调度硬盘和网络的通讯来平衡任务的完成时间。MapReduce 库采用了其它的方法。通过对编程模型进行限制,MapReduce框架把问题分解成为大量的“小”任务。这些任务在可用的worker集群上动态的调度, 这样快速的worker就可以执行更多的任务。通过对编程模型进行限制,我们可用在工作接近完成的时候调度备用任务,缩短在硬件配置不均衡的情况下缩小整 个操作完成的时间(比如有的机器性能差、或者机器被某些操作阻塞了)。 407 | 408 | 409 | 410 | BAD-FS[5]采用了和MapReduce完全不同的编程模式,它是面向广域网 (alex注:wide-area network)的。不过,这两个系统有两个基础功能很类似。(1)两个系统采用重新执行的方式来防止由于失效导致的数据丢失。(2)两个都使用数据本地化调度策略,减少网络通讯的数据量。 411 | 412 | 413 | 414 | TACC[7]是一个用于简化构造高可用性网络服务的系统。和MapReduce一样,它也依靠重新执行机制来实现的容错处理。 415 | 416 | ## 8、结束语 417 | 418 | MapReduce编程模型在Google内部成功应用于多个领域。我们把这种成功归结为几个方面:首先,由于MapReduce封装了并行处 理、容错处理、数据本地化优化、负载均衡等等技术难点的细节,这使得MapReduce库易于使用。即便对于完全没有并行或者分布式系统开发经验的程序员 而言;其次,大量不同类型的问题都可以通过MapReduce简单的解决。比如,MapReduce用于生成Google的网络搜索服务所需要的数据、用 来排序、用来数据挖掘、用于机器学习,以及很多其它的系统;第三,我们实现了一个在数千台计算机组成的大型集群上灵活部署运行的MapReduce。这个 实现使得有效利用这些丰富的计算资源变得非常简单,因此也适合用来解决Google遇到的其他很多需要大量计算的问题。 419 | 420 | 421 | 422 | 我们也从MapReduce开发过程中学到了不少东西。首先,约束编程模式使得并行和分布式计算非常容易,也易于构造容错的计算环境;其次,网络带 宽是稀有资源。大量的系统优化是针对减少网络传输量为目的的:本地优化策略使大量的数据从本地磁盘读取,中间文件写入本地磁盘、并且只写一份中间文件也节 约了网络带宽;第三,多次执行相同的任务可以减少性能缓慢的机器带来的负面影响(alex注:即硬件配置的不平衡),同时解决了由于机器失效导致的数据丢失问题。 --------------------------------------------------------------------------------