引子
因为一直在跟 Raft 打交道,虽然对 Raft 很熟悉了,但如果你要我去给一个完全不知道什么是 Raft 的人讲 Raft,我觉得难度还是非常大的。所以我决定使用我一贯罗里吧嗦,用比喻和讲故事的方式,来尝试说说 Raft。
如果你跟你孩子一起看过小猪佩奇,你大概就能知道我为啥用了这么怪的取名。如果没看过的,强烈推荐你去看看,这真的是一部很不错的儿童动画。
日志和状态机
兔小姐准备在泥坑小镇成立一家银行(就叫泥坑银行吧)。对于银行储蓄系统的设计,兔小姐找来了猪爸爸。
兔小姐:『猪爸爸,我们要保证,无论怎样用户的金钱不能有错误。假如客户存了 100 块钱,那么他的账户就会多出来 100 块钱,不会是 101,也不会是 99。』
猪爸爸:『好的,兔小姐,我觉得我们可以这样。如果一个客户来存钱,那么,首先我们可以将交易依次顺序记录下来,然后兔先生再把客户实际的钱放到金库对应的保险柜里面。当兔先生把钱放好之后,我们就可以告诉客户这笔交易完成了』
兔小姐:『好主意,猪爸爸,不过为什么要先将交易记录下来呢?直接放到金库不就可以了吗?』
猪爸爸:『首先,如果交易记录都没成功,那么我们就不用再麻烦将钱放到金库了。其次,假设同时很多人来存钱,兔先生有点处理不过来,没准就会弄错。我们有记录的话,兔先生就可以按照记录一个一个处理,虽然这样慢一点,但不会出错。另外,交易记录永远不可能被篡改或者被覆盖,譬如如果我们在记录 N 这个位置记录下客户 A 存了 100 块钱,那么这条记录 N 后面一定是这笔交易,而不可能在变成客户 B 取了 100 块钱。』
兔小姐:『嗯,听起来很有道理,那么我们就这么做吧。』
好了,虽然上面的例子有点不切合实际,毕竟如果银行真这么玩,离倒闭也就不远了,但各位还是先认为这套机制能很好的工作吧。在 Raft 里面,交易记录,我们可以叫做日志,而金库,则是状态机。对于任何的操作,Raft 会首先将其记录到日志,然后等这个日志提交之后,我们再将其对应的数据写入到状态机里面。每一条日志都有一个唯一的编号,这个编号是严格按照加一单调递增的,也就是说,leader 只会追加日志,而不会覆盖日志。假设现在的日志编号是 10,那么下一条日志的编号就一定是 11。如果这条日志被应用到了状态机,那么我们就可以认为这条日志已经是被 applied 了。
Quorum
不久之后,泥坑银行储蓄系统第一个版本就上线了。一切工作的良好,直到有一天,电闪雷鸣,泥坑银行停电了。用户发现根本没办法进行交易了,虽然狗爷爷尽了最大的努力,但让整个银行正常工作也花了不少时间。银行正常营业之后,兔小姐找来了猪爸爸。
兔小姐:『猪爸爸,现在看起来我们必须要保证,即使在泥坑小镇的银行出现了问题,譬如停电这种的,用户仍然能够正常的进行交易。』
猪爸爸:『是的,兔小姐。那要做到这样,我们必须在其他地方建立另一个银行网点,这样,即使在泥坑小镇银行不能对外提供服务了,但客户还是能在其他银行网点进行交易。』
兔小姐:『好主意,猪爸爸,那么我们 就在回音山谷建立一个分部,当泥坑小镇的银行出现了故障,用户仍然能够在回音山谷进行交易。』
猪爸爸:『兔小姐,恐怕只是在回音山谷建立一个分部,是不行的。』
兔小姐:『为什么呢?猪爸爸,我有点不明白了。』
猪爸爸:『现在假设我们在泥坑小镇和回音山谷都部署好系统,如果一个客户到泥坑小镇存钱,我们首先要在泥坑小镇这边记录这笔交易,然后在通知回音山谷那边也记录这笔交易,只有我们知道回音山谷记录交易成功了,我们才可以进行下一步,也就是将用户的钱放到金库里面,同时告诉回音山谷那边,也需要将对应钱放到那边的金库,这样如果泥坑小镇出现了问题,客户仍然能到回音山谷那边取钱。』
兔小姐:『这听起来很复杂,但这样看起来没问题,所以将系统放到两个地方,没问题呀!』
猪爸爸:『不不,兔小姐。上面我说的是都两边都能正常工作的情况,但实际会有很多异常情况。譬如,假设泥坑小镇这边的系统能正常工作,但回音山谷的出现了问题,那么客户来泥坑小镇存钱,因为我们没办法在回音山谷记录这笔交易,所以用户仍然不能存钱。』
兔小姐:『出现这种情况,我们还是先让客户能存钱吧,等回音山谷系统好了再把相关的交易记录放到那边去,这样不行吗?』
猪爸爸:『当然不可以,兔小姐。因为我们要保证客户金钱的绝对安全。假设客户先在泥坑小镇这边存了钱,回音山谷那边可能因为出现了问题并不知道这笔交易,如果泥坑小镇这边的系统出现了问题,那么用户去回音山谷取钱,就会发现,他在回音山谷的钱还是之前的总额,这样问题就大了。所以,如果只有两个地方有系统,我们必须要保证这两个地方的系统都完全能正常工作,任何一方出现了问题,整个系统就是不可用的。』
兔小姐:『哦,我大概明白了,那我们怎么办?』
猪爸爸:『如果我们要容忍一个地方的银行不能对外提供服务,但客户还是能正常的进行交易,我们至少需要在三个地方部署系统。』
兔小姐:『哦,我有点糊涂了,你能仔细解释下吗,为什么三个就可以呢?』
猪爸爸:『好的,兔小姐。假设现在我们在三个地方有部署好了系统,譬如这三个地方就是泥坑小镇,回音山谷和海盗岛吧。假设一个客户来泥坑小镇存钱,首先,我们会在泥坑小镇记录下这笔交易,然后告诉回音山谷和海盗岛也记录下这次交易,如果回音山谷或者海盗岛有一个回复泥坑小镇这边交易已经被成功记录,我们就可以允许客户在泥坑小镇将钱存到金库了,然后在告诉回音山谷和海盗岛那边可以存入金库了。』
猪爸爸停顿了一下,喝了一口水,接着道:『上面我们说到,只要我们知道有两个地方成功记录下这次交易,我们就可以继续存钱了,即使一个地方出现了问题也不会有问题。譬如,我们知道泥坑小镇和回音山谷成功记录了交易,但海盗岛因为一些问题导致了反馈延迟,但还能正常工作。然后泥坑小镇这边突然出现了问题,不能对外提供服务了,但我们还是能正常对外提供服务,因为我们知道最新的交易信息已经被记录到了回音山谷,我们从回音山谷这边就一定能得到正确的金钱总数。但是,这时候我们仍然只有两个地方能正常工作了,所以如果第二个地方出现了问题,我们仍然不能对外提供服务了。所以,如果我们要容忍两个地方出现问题,但系统仍然能够对外提供服务,我们就需要——』
『我们就需要在五个地方部署服务了,是吧,猪爸爸。』兔小姐直接插话道。
『是的,非常正确,兔小姐。』猪爸爸由衷的赞叹道。
『那么我觉得我们先考虑三个地方吧,容忍一个地方不能工作就可以了,那就在回音山谷和海盗岛那边也建立分部吧。』
『好的,兔小姐,不过其实我有点担心海盗岛那边。。。。。。』
『就这么决定了,猪爸爸』,兔小姐没等猪爸爸说完,就直接做了决定。
好了,说了这么多,还是回到现实中来吧。上面的例子,我们可是假设了金钱能被复制成多份放到不同的金库里面的,但现实银行可不会这么干。为了要设计一个高可用的系统,单点问题是必须要解决的,毕竟如果这个点出现了问题,整个系统就没法服务了。为了解决这个问题,我们需要在多个地方部署系统,但这样就会引入另一个问题,也就是数据一致性的问题。
CAP
这里我们来简单说说 CAP,也就是一致性,可用性和分区容忍性。因为在分布式系统里面,P 一定是避免不了的,所以我们无非就是选择 C 或者选择 A 的问题。通常 A 都是能做到 HA,也就是高可用的,所以对于需要完全保证数据安全的系统,我们一定会选择 C。为了保证 C,我们在写入数据的时候,一定会保证至少 quorum 的节点都成功被写入了数据,才会认为这次写入是成功的。 在 Raft 里面,如果一条日志被 quorum 的节点成功接收,那么我们就可以认为这条日志已经被 committed 了。
通常,我们说的 C,其实就是线性一致性,也就是我在某个时间写入了一个值,那么这个时间点之后的任何时间,我们读到的就是这个最新的值,而不可能是老的。在数据写入 quorum 节点之后,我们的读取如果也能够保证在 quorum 节点读取,那么就一定能读到最新的值。这个就是 Amazon 的 Dynamo 做法,但这样就把线性一致性保证的负担落到了读取数据的客户端上面。Raft 采用了另一种简单的做法,我们后续继续说明。
小结
好吧,说了这么多,说了这么多,其实也就提了几个 Raft 的概念。这里稍微总结一下,Raft 使用的是 Log Replication + State Machine 的方式来处理分布式数据的一致性问题,这也是现在的通用做法。对于 Raft 来说,Log 的 ID 一定是加一单调递增的,如果一个 Log 被至少 quorum 个节点接受,我们就可以认为这条日志被 committed 了,然后就可以应用这条 Log,当 Log 被应用之后,改 Log 就是 applied 了。后面,我们将开始讨论 Raft 的 Leader 了。