114-分布式协议与算法实战

开篇词 | 想成为分布式高手？那就先把协议和算法烂熟于心吧

你好，我是韩健，你叫我“老韩”就可以了。

在专栏开始之前，我想先和你聊聊自己的经历，加深彼此的了解。在重庆大学的软件工程专业毕业之后，我就开始和分布式系统打交道，至今有十多年了。早期，我接触了电信级分布式系统，比如内核态 HA Cluster，现在是互联网分布式系统，比如名字服务、NoSQL 存储、监控大数平台。

我曾经做过创业公司的 CTO，后来加入腾讯之后，负责过 QQ 后台海量服务分布式中间件，以及时序数据库 InfluxDB 自研集群系统的架构设计和研发工作。

你可能会问我，为什么要单独讲分布式协议和算法呢？（为了不啰嗦，咱们下文都简称分布式算法）在我看来，它其实就是决定分布式系统如何运行的核心规则和关键步骤。 如果一个人想真正搞懂分布式技术，开发出一个分布式系统，最先需要掌握的就是这部分知识。

举个例子，学数学的时候，我们总是会学到很多公式或者定理，我上学的时候，还觉得这些定理枯燥至极。但后来我明白了，这些定理和公式其实就是前人花了很长时间思考、验证、总结出来的规律，如果我们能在这之上做事情，更容易快速地找到正确答案。同样，你学习咱们这个专栏也是这个道理。

分布式算法是分布式技术中的核心

可能有些同学会说：“老韩，你别忽悠我，我可是系统看过分布式领域的经典书的，比如《分布式系统：概念与设计》《分布式系统原理与范型》，这些书里分布式算法的篇幅可不多啊。”

是的，这也是我奇怪的地方。不过，你可以看看网上关于分布式的提问，这里面点击量大的肯定与分布式算法有关，这是不是侧面说明了它的重要性呢？

而且从我多年的经验来看，很多同学读了那几本厚重的经典书之后，在实际工作中还是云里雾里。我想，如果他们来问我，我会建议他们先把各种分布式算法搞清楚。因为分布式系统里，最重要的事情，就是如何选择或设计适合的算法，解决一致性和可用性相关的问题了。

可尽管它是分布式技术中的核心与关键，但实际掌握的人或者公司却很少。我来说个真实的事儿。

我刚刚提到的 InfluxDB 其实是一个开源的时序数据库系统，当然，开源的只是单机版本，如果你要使用集群功能，要么就是基于开源版本自研，要么就是购买人家的企业版本。

而这里面，企业版本一个节点一年 License 授权费就是 1.5 万美刀，是不是很贵？那贵在哪里呢？相比于单机版本，企业版本的技术壁垒又是什么？

在我自己折腾了一番 InfluxDB 系统后，我捂着胸口和你说，它的护城河就是以分布式算法为核心的分布式集群能力。

我知道有很多技术团队曾经试图自己实现 InfluxDB 的企业版本功能，但最后还是放弃了，因为这里面坑太多了。比如，实现集群能力的时候，怎么支持基于时序进行分片？怎么支持水平扩展？甚至还有些人错误地将一致性等同于完整性，该使用反熵（Anti-Entropy）算法的时候，却用了 Raft 算法，让人哭笑不得。聊到这儿，我也想问问你：你是否也曾错误地把一致性理解为完整性了呢？

可以看到，分布式系统的价值和意义的确很大，但如果不能准确理解分布式算法，可能不仅开发实现的分布式系统无法稳定运行，而且你还会因为种种现网故障，逐渐影响到职业发展，丧失职场竞争力。

再说点儿更实际的，现阶段，掌握分布式算法也是你面试架构师、技术专家等高端岗位时的敲门砖。 你可以搜索看看，知名的公司在招聘架构师或者高级工程师时，岗位要求中是不是写着熟悉分布式算法相关理论等内容？不过从我作为面试官的经验来看，懂这部分的候选人实在是少之又少。

别看啰嗦了这么多，我只是想强调，不管你是基于技术追求的考虑，还是基于长期职业发展和提升职场竞争力的考量，“分布式算法”都是你在这个时代应该掌握的基本功。

当然了，我也知道，分布式算法虽然很重要，但是也比较难学，原因有这样几点。

除了算法本身抽象，不容易理解之外，即使是非常经典的论文，也存在在一些关键细节上没有讲清楚的情况。比如，你比较熟悉的拜占庭将军问题，在阅读口信消息型拜占庭问题之解时，你是不是感到很吃力呢？那是因为论文没有说透彻，而我会在01 讲带你了解这些内容。

信息时代资料丰富，但质量参差不齐，甚至有错误。网上信息大多是“复制粘贴”的结果，而且因为分布式领域的研究多以英文论文的形式出现，中文翻译内容的错误非常多，这也给自主学习带来很多不必要的障碍和误导。如果你没有足够的好奇心和探究精神，很难完全吃透关键细节。

很多资料是为了讲解理论而讲解理论，无法站在“用”的角度，将理论和实战结合。最终，你只能在“嘴”上理解，而无法动手。

方法得当，知识并不难学

在我看来，要想掌握这部分内容，不仅要理解常用算法的原理、特点和局限，还要能根据场景特点选择适合的分布式算法。

所以，为了更好地帮你轻松、透彻地搞懂分布式技术，理解其中最核心和最为精妙的内容，我希望将自己支撑海量互联网服务中的分布式算法实战心得分享给你。

我将课程划分了三个模块，分别是理论篇、协议和算法篇以及实战篇。

其中，理论篇，我会带你搞懂分布式架构设计核心且具有“实践指导性”的基础理论，这里面会涉及典型的分布式问题，以及如何认识分布式系统中相互矛盾的特性，帮助你在实战中根据场景特点选择适合的分布式算法。

协议和算法篇，会让你掌握它们的原理、特点、适用场景和常见误区等。比如，你以为开发分布式系统使用 Raft 算法就可以了，其实它比较适合性能要求不高的强一致性场景；又比如在面试时，如果被问到“Paxos 和 Raft 的区别在哪里”，你都会在第二部分中找到答案。

实战篇，教你如何将所学知识落地，我会带你掌握分布式基础理论和分布式算法在工程实践中的应用。比如，剖析 InfluxDB 企业版的 CP 架构和 AP 架构的设计和背后的思考，以及 Raft、Quorum NWR、Anti-Entropy 等分布式算法的具体实现。

从实战篇中，你可以掌握如何根据场景特点选择适合的分布式算法，以及如何使用和实现分布式算法的实战技巧。这样，当你需要据场景特点选择适合的分布式算法时，就能举一反三，独立思考，设计开发了。

除此之外，我还会带你剖析 Hashicorp Raft 的实现，并以一个分布式 KV 系统的开发实战为例，来聊聊如何使用 Raft 算法实际开发一个分布式系统，以此让你全面拥有分布式算法的实战能力。

总体来说，学完这次课程，你会有以下几个收获：

破除你对分布式协议和算法的困惑，帮助你建立信心；

可落地的 4 大分布式基础理论；

8 个最常用的分布式协议和算法；

3 大实战案例手把手教学；

以实战为中心的分布式内容体系。

写在最后

我承诺课程的每一讲都是干货，也会第一时间和你交流答疑，也请你监督。只要你紧跟脚步，不懂就问，课后多加思考和练习，相信你一定会学有所成。

与此同时，我希望所有对技术有追求的工程师，都能在学完课程之后，顺利攻下这一关。再具体一点说，就是能够在工作中根据场景特点，灵活地设计架构和使用分布式算法开发出适合该场景的分布式系统，并且对架构设计的理解更上一层。姑且把这段话当成我们的教学目标吧。

最后，欢迎你在留言区说一说自己在技术上的困惑，或者想通过这个专栏收获些什么，这样可以方便我在后续的备课中，针对性地讲解内容。重要的是，也能帮你在学完之后回顾这些疑难问题，感受到自己切实的进步和能力的提升。

期待与你在这个课程中碰撞出更多的思维火花，未来的两个月里，让我们成为朋友，携手同行，共同进步！

01 | 拜占庭将军问题：有叛徒的情况下，如何才能达成共识？

你好，我是韩健。

在日常工作中，我常听到有人吐槽“没看懂拜占庭将军问题”“中文的文章看不懂，英文论文更看不下去”。想必你也跟他们一样，有类似的感受。

在我看来，拜占庭将军问题（The Byzantine Generals Problem），它其实是借拜占庭将军的故事展现了分布式共识问题，还探讨和论证了解决的办法。而大多数人觉得它难理解，除了因为分布式共识问题比较复杂之外，还与莱斯利·兰伯特（Leslie Lamport）的讲述方式有关，他在一些细节上（比如，口信消息型拜占庭问题之解的算法过程上）没有说清楚。

实际上，它是分布式领域最复杂的一个容错模型，一旦搞懂它，你就能掌握分布式共识问题的解决思路，还能更深刻地理解常用的共识算法，在设计分布式系统的时候，也能根据场景特点选择适合的算法，或者设计适合的算法了。而我把拜占庭将军的问题放到第一讲，主要是因为它很好地抽象了分布式系统面临的共识问题，理解了这个问题，会为你接下来的学习打下基础。

那么接下来，我就以战国时期六国抗秦的故事为主线串联起整篇文章，让你读懂、学透。

苏秦的困境

战国时期，齐、楚、燕、韩、赵、魏、秦七雄并立，后来秦国的势力不断强大起来，成了东方六国的共同威胁。于是，这六个国家决定联合，全力抗秦，免得被秦国各个击破。一天，苏秦作为合纵长，挂六国相印，带着六国的军队叩关函谷，驻军在了秦国边境，为围攻秦国作准备。但是，因为各国军队分别驻扎在秦国边境的不同地方，所以军队之间只能通过信使互相联系，这时，苏秦面临了一个很严峻的问题：如何统一大家的作战计划？

万一一些诸侯国在暗通秦国，发送误导性的作战信息，怎么办？如果信使被敌人截杀，甚至被敌人间谍替换，又该怎么办？这些都会导致自己的作战计划被扰乱，然后出现有的诸侯国在进攻，有的诸侯国在撤退的情况，而这时，秦国一定会趁机出兵，把他们逐一击破的。

所以，如何达成共识，制定统一的作战计划呢？苏秦他很愁。

这个故事，是拜占庭将军问题的一个简化表述，苏秦面临的就是典型的共识难题，也就是如何在可能有误导信息的情况下，采用合适的通讯机制，让多个将军达成共识，制定一致性的作战计划？

你可以先停下来想想，这个问题难在哪儿？我们又是否有办法，帮助诸侯国们达成共识呢？

二忠一叛的难题

为了便于你理解和层层深入，我先假设只有 3 个国家要攻打秦国，这三个国家的三位将军，咱们简单点儿，分别叫齐、楚、燕。同时，又因为秦国很强大，所以只有半数以上的将军参与进攻，才能击败敌人（注意，这里是假设哈，你别较真），在这个期间，将军们彼此之间需要通过信使传递消息，然后协商一致之后，才能在同一时间点发动进攻。

举个例子，有一天，这三位将军各自一脸严肃地讨论明天是进攻还是撤退，并让信使传递信息，按照“少数服从多数”的原则投票表决，两个人意见一致就可以了，比如：

齐根据侦查情况决定撤退；

楚和燕根据侦查信息，决定进攻。

那么按照原则，齐也会进攻。最终，3 支军队同时进攻，大败秦军。

可是，问题来了： 一旦有人在暗通秦国，就会出现作战计划不一致的情况。比如齐向楚、燕分别发送了“撤退”的消息，燕向齐和楚发送了“进攻”的消息。撤退：进攻 =1:1，无论楚投进攻还是撤退，都会成为 2:1，这个时候还是会形成一个一致性的作战方案。

但是，楚这个叛徒在暗中配合秦国，让信使向齐发送了“撤退”，向燕发送了“进攻”，那么：

燕看到的是，撤退：进攻 =1:2；

齐看到的是，撤退：进攻 =2:1。

按照“少数服从多数”的原则，就会出现燕单独进攻秦军，当然，最后肯定是因为寡不敌众，被秦军给灭了。

在这里，你可以看到，叛将楚通过发送误导信息，非常轻松地干扰了齐和燕的作战计划，导致这两位忠诚将军被秦军逐一击败。这就是所说的二忠一叛难题。 那么苏秦应该怎么解决这个问题呢？我们来帮苏秦出出主意。

如果你觉得上面的逻辑有点绕的话，可以找张白纸，自己比划比划。

苏秦该怎么办？

解决办法一：口信消息型拜占庭问题之解

先来说说第一个解决办法。首先，三位将军都分拨一部分军队，由苏秦率领，苏秦参与作战计划讨论并执行作战指令。这样，3 位将军的作战讨论，就变为了 4 位将军的作战讨论，这能够增加讨论中忠诚将军的数量。

然后呢，4 位将军还约定了，如果没有收到命令，就执行预设的默认命令，比如“撤退”。除此之外，还约定一些流程来发送作战信息、执行作战指令，比如，进行两轮作战信息协商。为什么要执行两轮呢？先卖个关子，你一会儿就知道了。

第一轮：

先发送作战信息的将军作为指挥官，其他的将军作为副官；

指挥官将他的作战信息发送给每位副官；

每位副官，将从指挥官处收到的作战信息，作为他的作战指令；如果没有收到作战信息，将把默认的“撤退”作为作战指令。

第二轮：

除了第一轮的指挥官外，剩余的 3 位将军将分别作为指挥官，向另外 2 位将军发送作战信息；

然后，这 3 位将军按照“少数服从多数”，执行收到的作战指令。

为了帮助你直观地理解苏秦的整个解决方案，我来演示一下作战信息协商过程。而且，我会分别以忠诚将军和叛将先发送作战信息为例来演示， 这样可以完整地演示叛将对作战计划干扰破坏的可能性。

首先是 3 位忠诚的将军先发送作战信息的情况。

为了演示方便，假设苏秦先发起作战信息，作战指令是“进攻”。那么在第一轮作战信息协商中，苏秦向齐、楚、燕发送作战指令“进攻”。

在第二轮作战信息协商中，齐、楚、燕分别作为指挥官，向另外 2 位发送作战信息“进攻”，因为楚已经叛变了，所以，为了干扰作战计划，他就对着干，发送“撤退”作战指令。

最终，齐和燕收到的作战信息都是“进攻、进攻、撤退”，按照原则，齐和楚与苏秦一起执行作战指令“进攻”，实现了作战计划的一致性，保证了作战的胜利。

那么，如果是叛徒楚先发送作战信息，干扰作战计划，结果会有所不同么？我们来具体看一看。在第一轮作战信息协商中，楚向苏秦发送作战指令“进攻”，向齐、燕发送作战指令“撤退”。

然后，在第二轮作战信息协商中，苏秦、齐、燕分别作为指挥官，向另外两位发送作战信息。

最终，苏秦、齐和燕收到的作战信息都是“撤退、撤退、进攻”，按照原则，苏秦、齐和楚一起执行作战指令“撤退”，实现了作战计划的一致性。也就是说，无论叛将楚如何捣乱，苏秦、齐和燕，都执行一致的作战计划，保证作战的胜利。

这个解决办法，其实是兰伯特在论文《The Byzantine Generals Problem》中提到的口信消息型拜占庭问题之解：如果叛将人数为 m，将军人数不能少于 3m + 1 ，那么拜占庭将军问题就能解决了。 不过，作者在论文中没有讲清楚一些细节，为了帮助你阅读和理解论文，在这里我补充一点：

这个算法有个前提，也就是叛将人数 m，或者说能容忍的叛将数 m，是已知的。在这个算法中，叛将数 m 决定递归循环的次数（也就是说，叛将数 m 决定将军们要进行多少轮作战信息协商），即 m+1 轮（所以，你看，只有楚是叛变的，那么就进行了两轮）。你也可以从另外一个角度理解：n 位将军，最多能容忍 (n - 1) / 3 位叛将。关于这个公式，你只需要记住就好了，推导过程你可以参考论文。

不过，这个算法虽然能解决拜占庭将军问题，但它有一个限制：如果叛将人数为 m，那么将军总人数必须不小于 3m + 1。

在二忠一叛的问题中，在存在 1 位叛将的情况下，必须增加 1 位将军，将 3 位将军协商共识，转换为 4 位将军协商共识，这样才能实现忠诚将军的一致性作战计划。那么有没有办法，在不增加将军人数的时候，直接解决二忠一叛的难题呢？

解决办法二：签名消息型拜占庭问题之解

其实，苏秦还可以通过签名的方式，在不增加将军人数的情况下，解决二忠一叛的难题。首先，苏秦要通过印章、虎符等信物，实现这样几个特性：

忠诚将军的签名无法伪造，而且对他签名消息的内容进行任何更改都会被发现；

任何人都能验证将军签名的真伪。

这时，如果忠诚的将军，比如齐先发起作战信息协商，一旦叛将小楚修改或伪造收到的作战信息，那么燕在接收到楚的作战信息的时候，会发现齐的作战信息被修改，楚已叛变，这时他执行齐发送的作战信息。

如果叛变将军楚先发送误导的作战信息，那么齐和燕将发现楚发送的作战信息是不一致的，知道楚已经叛变。这个时候，他们可以先处理叛将，然后再重新协商作战计划。

这个解决办法，是兰伯特在论文中提到的签名消息型拜占庭问题之解。而通过签名机制约束叛将的叛变行为，任何叛变行为都会被发现，也就会实现无论有多少忠诚的将军和多少叛将，忠诚的将军们总能达成一致的作战计划。

我想，如果当时苏秦能够具备分布式系统设计的思维，掌握这几种算法，应该就不用担心作战计划被干扰了吧。

内容小结

本节课，为了帮助你理解拜占庭将军问题，我讲了苏秦协商作战的故事，现在让我们跳回现实世界，回到计算机世界的分布式场景中：

故事里的各位将军，你可以理解为计算机节点；

忠诚的将军，你可以理解为正常运行的计算机节点；

叛变的将军，你可以理解为出现故障并会发送误导信息的计算机节点；

信使被杀，可以理解为通讯故障、信息丢失；

信使被间谍替换，可以理解为通讯被中间人攻击，攻击者在恶意伪造信息和劫持通讯。

这样一来，你是不是就理解了计算机分布式场景中面临的问题，并且知道了解决的办法呢？

那么我想强调的是，拜占庭将军问题描述的是最困难的，也是最复杂的一种分布式故障场景，除了存在故障行为，还存在恶意行为的一个场景。你要注意，在存在恶意节点行为的场景中（比如在数字货币的区块链技术中），必须使用拜占庭容错算法（Byzantine Fault Tolerance，BFT）。除了故事中提到两种算法，常用的拜占庭容错算法还有：PBFT 算法，PoW 算法（为了重点突出，这些内容我会在后面讲解）。

而在计算机分布式系统中，最常用的是非拜占庭容错算法，即故障容错算法（Crash Fault Tolerance，CFT）。CFT 解决的是分布式的系统中存在故障，但不存在恶意节点的场景下的共识问题。 也就是说，这个场景可能会丢失消息，或者有消息重复，但不存在错误消息，或者伪造消息的情况。常见的算法有 Paxos 算法、Raft 算法、ZAB 协议（这些内容我同样会在后面讲解）。

那么，如何在实际场景选择合适的算法类型呢？答案是：如果能确定该环境中各节点是可信赖的，不存在篡改消息或者伪造消息等恶意行为（例如 DevOps 环境中的分布式路由寻址系统），推荐使用非拜占庭容错算法；反之，推荐使用拜占庭容错算法，例如在区块链中使用 PoW 算法。

课堂思考

文中我提了两类容错算法，分别是拜占庭容错算法和非拜占庭容错算法，那么在常见的分布式软件系统中，哪些场景必须要使用拜占庭容错算法呢？哪些场景使用非拜占庭容错算法就可以了呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

02 | CAP理论：分布式系统的PH试纸，用它来测酸碱度

你好，我是韩健。

很多同学可能都有这样的感觉，每次要开发分布式系统的时候，就会遇到一个非常棘手的问题，那就是如何根据业务特点，为系统设计合适的分区容错一致性模型，以实现集群能力。这个问题棘手在当发生分区错误时，应该如何保障系统稳定运行，不影响业务。

这和我之前经历的一件事比较像，当时，我负责自研 InfluxDB 系统的项目，接手这个项目后，我遇到的第一个问题就是，如何为单机开源版的 InfluxDB 设计分区容错一致性模型。 因为 InfluxDB 有 META 和 DATA 两个节点，它们的功能和数据特点不同，所以我还需要考虑这两个逻辑单元的特点，然后分别设计分区容错一致性模型。

那个时候，我想到了 CAP 理论，并且在 CAP 理论的帮助下，成功地解决了问题。讲到这儿，你可能会问了：为什么 CAP 理论可以解决这个问题呢？

因为在我看来，CAP 理论是一个很好的思考框架，它对分布式系统的特性做了高度抽象，比如抽象成了一致性、可用性和分区容错性，并对特性间的冲突（也就是 CAP 不可能三角）做了总结。一旦掌握它，你就像拥有了引路人，自然而然就能根据业务场景的特点进行权衡，设计出适合的分区容错一致性模型。

那么问题来了：我说的一致性、可用性和分区容错性是什么呢？它们之间有什么关系？你又该如何使用 CAP 理论来思考和设计分区容错一致性模型呢？这些问题就是我们本节课所要讲的重点了。我建议你集中注意力，认真学习内容，还能学以致用，把 CAP 理论应用到日常工作中。

CAP 三指标

我刚刚提到，CAP 理论对分布式系统的特性做了高度抽象，形成了三个指标：

一致性（Consistency）

可用性（Availability）

分区容错性（Partition Tolerance）

一致性说的是客户端的每次读操作，不管访问哪个节点，要么读到的都是同一份最新的数据，要么读取失败。

你可以把一致性看作是分布式系统对访问本系统的客户端的一种承诺：不管你访问哪个节点，要么我给你返回的都是绝对一致的数据，要么你都读取失败。你可以看到，一致性强调的不是数据完整，而是各节点间的数据一致。

为了帮你理解一致性这个指标，我给你举一个具体的例子。比如，2 个节点的 KV 存储，原始的 KV 记录为“X = 1”。

紧接着，客户端向节点 1 发送写请求“SET X = 2”。

如果节点 1 收到写请求后，只将节点 1 的 X 值更新为 2，然后返回成功给客户端，这个时候节点 2 的 X 值还是 1，那么两个节点是非一致性的。

如果节点 1 收到写请求后，通过节点间的通讯，同时将节点 1 和节点 2 的 X 值都更新为 2，然后返回成功给客户端，那么在完成写请求后，两个节点的数据就是一致的了，之后，不管客户端访问哪个节点，读取到的都是同一份最新数据。

一致性这个指标，描述的是分布式系统非常重要的一个特性，强调的是数据的一致。也就是说，在客户端看来，集群和单机在数据一致性上是一样的。

不过集群毕竟不是单机，当发生分区故障的时候，有时不能仅仅因为节点间出现了通讯问题，节点中的数据会不一致，就拒绝写入新数据，之后在客户端查询数据时，就一直返回给客户端出错信息。这句话怎么理解呢？我来举个例子。

业务集群中的一些关键系统，比如名字路由系统，如果仅仅因为发生了分布故障，节点中的数据会不一致，集群就拒绝写入新的路由信息，之后，当客户端查询相关路由信息时，系统就一直返回给客户端出错信息，那么相关的服务都将因为获取不到指定路由信息而不可用、瘫痪，这可以说是灾难性的故障了。

这个时候，我们就需要牺牲数据的一致性，每个节点使用本地数据来响应客户端请求，来保证服务可用，这就是我要说的另外一个指标，可用性。

可用性说的是任何来自客户端的请求，不管访问哪个节点，都能得到响应数据，但不保证是同一份最新数据。你也可以把可用性看作是分布式系统对访问本系统的客户端的另外一种承诺：我尽力给你返回数据，不会不响应你，但是我不保证每个节点给你的数据都是最新的。这个指标强调的是服务可用，但不保证数据的一致。

我还是用一个例子，帮助你理解一下。比如，用户可以选择向节点 1 或节点 2 发起读操作，如果不管节点间的数据是否一致，只要节点服务器收到请求，就响应 X 的值，那么，2 个节点的服务是满足可用性的。

最后的分区容错性说的是，当节点间出现任意数量的消息丢失或高延迟的时候，系统仍然可以继续提供服务。也就是说，分布式系统在告诉访问本系统的客户端：不管我的内部出现什么样的数据同步问题，我会一直运行，提供服务。这个指标，强调的是集群对分区故障的容错能力。

来看下面的图，当节点 1 和节点 2 通信出问题的时候，如果系统仍能提供服务，那么，2 个节点是满足分区容错性的。

因为分布式系统与单机系统不同，它涉及到多节点间的通讯和交互，节点间的分区故障是必然发生的，所以我要提醒你，在分布式系统中分区容错性是必须要考虑的。

现在你了解了一致性、可用性和分区容错性，那么你在设计分布式系统时，是选择一致性？还是可用性？还是分区容错性？还是都可以选择呢？这三个特性有什么冲突么？这些问题就与我接下来要讲的“CAP 不可能三角”有关了。

CAP 不可能三角

CAP 不可能三角说的是对于一个分布式系统而言，一致性（Consistency）、可用性（Availability）、分区容错性（Partition Tolerance）3 个指标不可兼得，只能在 3 个指标中选择 2 个。

CAP 不能三角最初是埃里克·布鲁尔（Eric Brewer）基于自己的工程实践，提出的一个猜想，后被赛斯·吉尔伯特（Seth Gilbert）和南希·林奇（Nancy Lynch）证明，证明过程可以参考论文《Brewer’s conjecture and the feasibility of consistent, available, partition-tolerant web services》，你记住结论就好了。不过，为了帮你阅读论文，我补充一点：

基于证明严谨性的考虑，赛斯·吉尔伯特（Seth Gilbert）和南希·林奇（Nancy Lynch）对指标的含义做了预设和限制，比如，将一致性限制为原子性。

说了这么多，那么 CAP 理论是怎么解决我在开篇提到的问题呢？或者说，你要如何使用 CAP 理论来思考和设计分区容错一致性模型呢？

如何使用 CAP 理论

我们都知道，只要有网络交互就一定会有延迟和数据丢失，而这种状况我们必须接受，还必须保证系统不能挂掉。所以就像我上面提到的，节点间的分区故障是必然发生的。也就是说，分区容错性（P）是前提，是必须要保证的。

现在就只剩下一致性（C）和可用性（A）可以选择了：要么选择一致性，保证数据绝对一致；要么选择可用性，保证服务可用。那么 CP 和 AP 的含义是什么呢？

当选择了一致性（C）的时候，如果因为消息丢失、延迟过高发生了网络分区，部分节点无法保证特定信息是最新的，那么这个时候，当集群节点接收到来自客户端的写请求时，因为无法保证所有节点都是最新信息，所以系统将返回写失败错误，也就是说集群拒绝新数据写入。

当选择了可用性（A）的时候，系统将始终处理客户端的查询，返回特定信息，如果发生了网络分区，一些节点将无法返回最新的特定信息，它们将返回自己当前的相对新的信息。

这里我想强调一点，大部分人对 CAP 理论有个误解，认为无论在什么情况下，分布式系统都只能在 C 和 A 中选择 1 个。 其实，在不存在网络分区的情况下，也就是分布式系统正常运行时（这也是系统在绝大部分时候所处的状态），就是说在不需要 P 时，C 和 A 能够同时保证。只有当发生分区故障的时候，也就是说需要 P 时，才会在 C 和 A 之间做出选择。而且如果各节点数据不一致，影响到了系统运行或业务运行（也就是说会有负面的影响），推荐选择 C，否则选 A。

那么我当时是怎么根据场景特点，进行 CAP 权衡，设计适合的分布式系统呢？为了便于你理解，我先来说说背景。

开源版的 InfluxDB，缺乏集群能力和可用性，而且，InfluxDB 是由 META 节点和 DATA 节点 2 个逻辑单元组成，这 2 个节点的功能和数据特点不同，需要我们分别为它们设计分区容错一致性模型。

InfluxDB 程序的逻辑架构示意图

我具体是这么设计的：

作为分布式系统，分区容错性是必须要实现的，不能因为节点间出现了分区故障，而出现整个系统不能用的情况。

考虑到 META 节点保存的是系统运行的关键元信息，比如数据库名、表名、保留策略信息等，所以必须保持所有节点的一致性，这样才能避免由于各节点元信息不一致，导致时序数据记录不一致或者影响系统运行。比如，数据库 Telegraf 的信息在一些节点上存在，在另外一些节点上不存在，那么将导致向某些节点写入时序数据记录失败，所以，我选择 CAP 理论中的 C 和 P，采用 CP 架构。

DATA 节点保存的是具体的时序数据记录，比如一条记录 CPU 负载的时序数据，“cpu_usage,host=server01,location=cn-sz user=23.0,system=57.0”。虽然不是系统运行相关的元信息，但服务会被访问频繁，水平扩展、性能、可用性等是关键，所以，我选择了 CAP 理论中的 A 和 P，采用 AP 架构。

你看，我用 CAP 理论进行思考，并分别设计了 InfluxDB 的 META 节点和 DATA 节点的分区容错一致性模型，而你也可以采用类似的思考方法，设计出符合自己业务场景的分区容错一致性模型。

那么假设我当时没有受到 CAP 理论的影响，或者对 CAP 理论理解不深入，DATA 节点不采用 AP 架构，而是直接使用了现在比较流行的分区容错一致性算法，比如使用 Raft 算法，会有什么痛点呢？

受限于 Raft 的强领导者模型。所有请求都在领导者节点上处理，整个集群的性能等于单机性能。这样会造成集群接入性能低下，无法支撑海量或大数据量的时序数据。

受限于强领导者模型，以及 Raft 的节点和副本一一对应的限制，无法实现水平扩展，分布式集群扩展了读性能，但写性能并没有提升。这样会出现写性能低下，和因为架构上的限制，无法提升写性能的问题。

Raft 的“一切以领导者为准”的日志复制特性，会导致 DATA 节点丢数据，出现时序数据记录缺失的问题。

关于 Raft 算法的一些细节（比如强领导模型），我会在 07 讲详细带你了解，这里你知道有这么回事儿就可以了。

最后我想再次强调的是，一致性不等同于完整性， 有些技术团队基于数据完整性的考虑，使用 Raft 算法实现 DATA 节点的数据的分布式一致性容错，恰恰是这个设计，会导致 DATA 节点丢数据。我希望你能注意到这一点。

那么在这里，我也想考考你：如果 META 节点采用 AP 架构，会有什么痛点呢？你可以思考一下。

内容小结

本节课我主要带你了解了 CAP 理论，以及 CAP 理论的应用，我希望你明确的重点如下：

CA 模型，在分布式系统中不存在。因为舍弃 P，意味着舍弃分布式系统，就比如单机版关系型数据库 MySQL，如果 MySQL 要考虑主备或集群部署时，它必须考虑 P。

CP 模型，采用 CP 模型的分布式系统，一旦因为消息丢失、延迟过高发生了网络分区，就影响用户的体验和业务的可用性。因为为了防止数据不一致，集群将拒绝新数据的写入，典型的应用是 ZooKeeper，Etcd 和 HBase。

AP 模型，采用 AP 模型的分布式系统，实现了服务的高可用。用户访问系统的时候，都能得到响应数据，不会出现响应错误，但当出现分区故障时，相同的读操作，访问不同的节点，得到响应数据可能不一样。典型应用就比如 Cassandra 和 DynamoDB。

在我看来，CAP 理论像 PH 试纸一样，可以用来度量分布式系统的酸碱值，帮助我们思考如何设计合适的酸碱度，在一致性和可用性之间进行妥协折中，设计出满足场景特点的分布式系统。关于酸（Acid）和碱（Base），我会在 03 和 04 讲带你了解。

最后我想说的是，在当前分布式系统开发中，延迟是非常重要的一个指标，比如，在 QQ 后台的名字路由系统中，我们通过延迟评估服务可用性，进行负载均衡和容灾；再比如，在 Hashicorp/Raft 实现中，通过延迟评估领导者节点的服务可用性，以及决定是否发起领导者选举。所以，我希望你在分布式系统的开发中，也能意识到延迟的重要性，能通过延迟来衡量服务的可用性。

课堂思考

既然我提了 CAP 理论是一个很好的思考框架，能帮助我们思考，如何进行权衡，设计适合业务场景特性的分布式系统，那么你不妨思考一下，CP 模型的 KV 存储和 AP 模型的 KV 存储，分别适合怎样的业务场景呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

03 | ACID理论：CAP的酸，追求一致性

你好，我是韩健。

提到 ACID，我想你并不陌生，很多同学也会觉得它容易理解，在单机上实现 ACID 也不难，比如可以通过锁、时间序列等机制保障操作的顺序执行，让系统实现 ACID 特性。但是，一说要实现分布式系统的 ACID 特性，很多同学就犯难了。那么问题来了，为什么分布式系统的 ACID 特性在实现上，比较难掌握呢？

在我看来，ACID 理论是对事务特性的抽象和总结，方便我们实现事务。你可以理解成：如果实现了操作的 ACID 特性，那么就实现了事务。而大多数人觉得比较难，是因为分布式系统涉及多个节点间的操作。加锁、时间序列等机制，只能保证单个节点上操作的 ACID 特性，无法保证节点间操作的 ACID 特性。

那么怎么做才会让实现不那么难呢？答案是你要掌握分布式事务协议，比如二阶段提交协议和 TCC（Try-Confirm-Cancel）。这也是我接下来重点和你分享的内容。

不过在带你了解二阶段提交协议和 TCC 之前，咱们先继续看看苏秦的故事，看这回苏秦又遇到了什么事儿。

最近呢，秦国按捺不住自己躁动的心，开始骚扰魏国边境，魏王头疼，向苏秦求助，苏秦认为“三晋一家亲”，建议魏王联合赵、韩一起对抗秦国。但是这三个国家实力都很弱，需要大家都同意联合，一致行动，如果有任何一方不方便行动，就取消整个计划。

根据侦查情况，明天发动反攻胜算比较大。苏秦想协调赵、魏、韩，明天一起行动。那么对苏秦来说，他面临的问题是，如何高效协同赵、魏、韩一起行动，并且保证当有一方不方便行动时，取消整个计划。

苏秦面对的这个新问题，就是典型的如何实现分布式事务的问题，赵、魏、韩明天攻打秦国，这三个操作组成一个分布式事务，要么全部执行，要么全部不执行。

了解了这个问题之后，我们看看如何通过二阶段提交协议和 TCC，来帮助苏秦解决这个难题。

二阶段提交协议

二阶段提交协议，顾名思义，就是通过二阶段的协商来完成一个提交操作，那么具体是怎么操作的呢？

首先，苏秦发消息给赵，赵接收到消息后就扮演协调者（Coordinator）的身份，由赵联系魏和韩，发起二阶段提交：

赵发起二阶段提交后，先进入提交请求阶段（又称投票阶段）。 为了方便演示，我们先假设赵、魏、韩明天都能去攻打秦国：

也就是说，第一步，赵分别向魏、韩发送消息：“明天攻打秦国，方便吗？”

第二步，赵、魏、韩，分别评估明天能否去攻打秦国，如果能，就预留时间并锁定，不再安排其他军事活动。

第三步，赵得到全部的回复结果（包括他自己的评估结果），都是 YES。

赵收到所有回复后，进入提交执行阶段（又称完成阶段）， 也就是具体执行操作了，大致步骤如下：

首先，赵按照“要么全部执行，要么放弃”的原则，统计投票结果，因为所有的回复结果都是 YES，所以赵决定执行分布式事务，明天攻打秦国。

然后，赵通知魏、韩：“明天攻打秦国。”

接到通知之后，魏、韩执行事务，明天攻打秦国。

最后，魏、韩将执行事务的结果返回给赵。

这样一来，赵就将事务执行的结果（也就是赵、魏、韩明天一起攻打秦国），返回给苏秦，那么，这时苏秦就解决了问题，协调好了明天的作战计划。

在这里，赵采用的方法就是二阶段提交协议。在这个协议中：

你可以将“赵明天攻打秦国、魏明天攻打秦国、韩明天攻打秦国”，理解成一个分布式事务操作；

将赵、魏、韩理解为分布式系统的三个节点，其中，赵是协调者（Coordinator），将苏秦理解为业务，也就是客户端；

将消息理解为网络消息；

将“明天能否攻打秦国，预留时间”，理解为评估事务中需要操作的对象和对象状态，是否准备好，能否提交新操作。

需要注意的是，在第一个阶段，每个参与者投票表决事务是放弃还是提交。一旦参与者投票要求提交事务，那么就不允许放弃事务。也就是说，在一个参与者投票要求提交事务之前，它必须保证能够执行提交协议中它自己那一部分，即使参与者出现故障或者中途被替换掉。 这个特性，是我们需要在代码实现时保障的。

还需要你注意的是，在第二个阶段，事务的每个参与者执行最终统一的决定，提交事务或者放弃事务。这个约定，是为了实现 ACID 中的原子性。

二阶段提交协议最早是用来实现数据库的分布式事务的，不过现在最常用的协议是 XA 协议。这个协议是 X/Open 国际联盟基于二阶段提交协议提出的，也叫作 X/Open Distributed Transaction Processing（DTP）模型，比如 MySQL 就是通过 MySQL XA 实现了分布式事务。

但是不管是原始的二阶段提交协议，还是 XA 协议，都存在一些问题：

在提交请求阶段，需要预留资源，在资源预留期间，其他人不能操作（比如，XA 在第一阶段会将相关资源锁定）；

数据库是独立的系统。

因为上面这两点，我们无法根据业务特点弹性地调整锁的粒度，而这些都会影响数据库的并发性能。那用什么办法可以解决这些问题呢？答案就是 TCC。

TCC（Try-Confirm-Cancel）

TCC 是 Try（预留）、Confirm（确认）、Cancel（撤销） 3 个操作的简称，它包含了预留、确认或撤销这 2 个阶段。那么你如何使用 TCC 协议，解决苏秦面临的问题呢？

首先，我们先进入到预留阶段，大致的步骤如下：

第一步，苏秦分别发送消息通知赵、魏、韩，让他们预留明天的时间和相关资源。然后苏秦实现确认操作（明天攻打秦国），和撤销操作（取消明天攻打秦国）。

第二步，苏秦收到赵、魏、韩的预留答复，都是 OK。

如果预留阶段的执行都没有问题，就进入确认阶段，大致步骤如下：

第一步，苏秦执行确认操作，通知赵、魏、韩明天攻打秦国。

第二步，收到确认操作的响应，完成分布式事务。

如果预留阶段执行出错，比如赵的一部分军队还在赶来的路上，无法出兵，那么就进入撤销阶段，大致步骤如下：

第一步，苏秦执行撤销操作，通知赵、魏、韩取消明天攻打秦国的计划。

第二步，收到撤销操作的响应。

你看，在经过了预留和确认（或撤销）2 阶段的协商，苏秦实现这个分布式事务：赵、魏、韩三国，要么明天一起进攻，要么明天都按兵不动。

其实在我看来，TCC 本质上是补偿事务，它的核心思想是针对每个操作都要注册一个与其对应的确认操作和补偿操作（也就是撤销操作）。 它是一个业务层面的协议，你也可以将 TCC 理解为编程模型，TCC 的 3 个操作是需要在业务代码中编码实现的，为了实现一致性，确认操作和补偿操作必须是等幂的，因为这 2 个操作可能会失败重试。

另外，TCC 不依赖于数据库的事务，而是在业务中实现了分布式事务，这样能减轻数据库的压力，但对业务代码的入侵性也更强，实现的复杂度也更高。所以，我推荐在需要分布式事务能力时，优先考虑现成的事务型数据库（比如 MySQL XA），当现有的事务型数据库不能满足业务的需求时，再考虑基于 TCC 实现分布式事务。

内容小结

本节课我主要带你了解了实现分布式系统 ACID 特性的方法，二阶段提交协议和 TCC，我希望你明确这样几个重点。

二阶段提交协议，不仅仅是协议，也是一种非常经典的思想。二阶段提交在达成提交操作共识的算法中应用广泛，比如 XA 协议、TCC、Paxos、Raft 等。我希望你不仅能理解二阶段提交协议，更能理解协议背后的二阶段提交的思想，当后续需要时，能灵活地根据二阶段提交思想，设计新的事务或一致性协议。

幂等性，是指同一操作对同一系统的任意多次执行，所产生的影响均与一次执行的影响相同，不会因为多次执行而产生副作用。常见的实现方法有 Token、索引等。它的本质是通过唯一标识，标记同一操作的方式，来消除多次执行的副作用。

Paxos、Raft 等强一致性算法，也采用了二阶段提交操作，在“提交请求阶段”，只要大多数节点确认就可以，而具有 ACID 特性的事务，则要求全部节点确认可以。所以可以将具有 ACID 特性的操作，理解为最强的一致性。

另外，我想补充一下，三阶段提交协议，虽然针对二阶段提交协议的“协调者故障，参与者长期锁定资源”的痛点，通过引入了询问阶段和超时机制，来减少资源被长时间锁定的情况，不过这会导致集群各节点在正常运行的情况下，使用更多的消息进行协商，增加系统负载和响应延迟。也正是因为这些问题，三阶段提交协议很少被使用，所以，你只要知道有这么个协议就可以了，但如果你想继续研究，可以参考《Concurrency Control and Recovery in Database Systems》来学习。

最后我想强调的是，你可以将 ACID 特性理解为 CAP 中一致性的边界，最强的一致性，也就是 CAP 的酸（Acid）。根据 CAP 理论，如果在分布式系统中实现了一致性，可用性必然受到影响。比如，如果出现一个节点故障，则整个分布式事务的执行都是失败的。实际上，绝大部分场景对一致性要求没那么高，短暂的不一致是能接受的，另外，也基于可用性和并发性能的考虑，建议在开发实现分布式系统，如果不是必须，尽量不要实现事务，可以考虑采用强一致性或最终一致性。

课堂思考

既然我提了一些实现分布式事务的方法，比如二阶段提交协议、TCC 等，那么你不妨思考一下，事务型分布式系统有哪些优点，哪些缺点呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

04 | BASE理论：CAP的碱，追求可用性

你好，我是韩健。

很多同学可能喜欢使用事务型的分布式系统，或者是强一致性的分布式系统，因为使用起来很方便，不需要考虑太多，就像使用单机系统一样。但是学了 CAP 理论后，你肯定知道在分布式系统中要实现强一致性必然会影响可用性。比如，在采用两阶段提交协议的集群系统中，因为执行提交操作，需要所有节点确认和投票。

所以，集群的可用性是每个节点可用性的乘积，比如，假设 3 个节点的集群，每个节点的可用性为 99.9％，那么整个集群的可用性为 99.7％，也就是说，每个月约宕机 129.6 分钟，这是非常严重的问题。 而解决可用性低的关键在于，根据实际场景，尽量采用可用性优先的 AP 模型。

讲到这儿，可能会有一些同学“举手提问”：这也太难了，难道没有现成的库或者方案，来实现合适的 AP 模型？是的，的确没有。因为它是一个动态模型，是基于业务场景特点妥协折中后设计实现的。不过，你可以借助 BASE 理论帮助你达成目的。

在我看来，BASE 理论是 CAP 理论中的 AP 的延伸，是对互联网大规模分布式系统的实践总结，强调可用性。几乎所有的互联网后台分布式系统都有 BASE 的支持，这个理论很重要，地位也很高。一旦掌握它，你就能掌握绝大部分场景的分布式系统的架构技巧，设计出适合业务场景特点的、高可用性的分布式系统。

而它的核心就是基本可用（Basically Available）和最终一致性（Eventually consistent）。也有人会提到软状态（Soft state），在我看来，软状态描述的是实现服务可用性的时候系统数据的一种过渡状态，也就是说不同节点间，数据副本存在短暂的不一致。你只需要知道软状态是一种过渡状态就可以了，我们不多说。

那么基本可用以及最终一致性到底是什么呢？你又如何在实践中使用 BASE 理论提升系统的可用性呢？这些就是本节课的重点了，而我建议你集中注意力，认真学习本节课的内容，学以致用，将 BASE 理论应用到日常工作中。

实现基本可用的 4 板斧

在我看来，基本可用是说，当分布式系统在出现不可预知的故障时，允许损失部分功能的可用性，保障核心功能的可用性。就像弹簧一样，遇到外界的压迫，它不是折断，而是变形伸缩，不断适应外力，实现基本的可用。

具体说的话，你可以把基本可用理解成，当系统节点出现大规模故障的时候，比如专线的光纤被挖断、突发流量导致系统过载（出现了突发事件，服务被大量访问），这个时候可以通过服务降级，牺牲部分功能的可用性，保障系统的核心功能可用。

就拿 12306 订票系统基本可用的设计为例，这个订票系统在春运期间，因为开始售票后先到先得的缘故，会出现极其海量的请求峰值，如何处理这个问题呢？

咱们可以在不同的时间，出售不同区域的票，将访问请求错开，削弱请求峰值。比如，在春运期间，深圳出发的火车票在 8 点开售，北京出发的火车票在 9 点开售。这就是我们常说的流量削峰。

另外，你可能已经发现了，在春运期间，自己提交的购票请求，往往会在队列中排队等待处理，可能几分钟或十几分钟后，系统才开始处理，然后响应处理结果，这就是你熟悉的延迟响应。 你看，12306 订票系统在出现超出系统处理能力的突发流量的情况下，会通过牺牲响应时间的可用性，保障核心功能的运行。

而 12306 通过流量削峰和延迟响应，是不是就实现了基本的可用呢？现在它不会再像最初的时候那样，常常 404 了吧？

再比如，你正负责一个互联网系统，突然出现了网络热点事件，好多用户涌进来，产生了海量的突发流量，系统过载了，大量图片因为网络超时无法显示。那么这个时候你可以通过哪些方法，保障系统的基本可用呢？

相信你马上就能想到体验降级， 比如用小图片来替代原始图片，通过降低图片的清晰度和大小，提升系统的处理能力。

然后你还能想到过载保护， 比如把接收到的请求放在指定的队列中排队处理，如果请求等待时间超时了（假设是 100ms），这个时候直接拒绝超时请求；再比如队列满了之后，就清除队列中一定数量的排队请求，保护系统不过载，实现系统的基本可用。

你看，和 12306 的设计类似，只不过你负责的互联网系统是通过牺牲部分功能的可用性，保障核心功能的运行。

我说了这么多，主要是想强调：基本可用在本质上是一种妥协，也就是在出现节点故障或系统过载的时候，通过牺牲非核心功能的可用性，保障核心功能的稳定运行。

我希望你能在后续的分布式系统的开发中，不仅掌握流量削峰、延迟响应、体验降级、过载保护这 4 板斧，更能理解这 4 板斧背后的妥协折中，从而灵活地处理不可预知的突发问题。

带你了解了基本可用之后，我再来说说 BASE 理论中，另一个非常核心的内容：最终一致性。

最终的一致

在我看来，最终一致性是说，系统中所有的数据副本在经过一段时间的同步后，最终能够达到一个一致的状态。也就是说，在数据一致性上，存在一个短暂的延迟。

几乎所有的互联网系统采用的都是最终一致性，只有在实在无法使用最终一致性，才使用强一致性或事务，比如，对于决定系统运行的敏感元数据，需要考虑采用强一致性，对于与钱有关的支付系统或金融系统的数据，需要考虑采用事务。

你可以将强一致性理解为最终一致性的特例，也就是说，你可以把强一致性看作是不存在延迟的一致性。在实践中，你也可以这样思考： 如果业务的某功能无法容忍一致性的延迟（比如分布式锁对应的数据），需要实现的是强一致性；如果能容忍短暂的一致性的延迟（比如 QQ 状态数据），就可以考虑最终一致性。

那么如何实现最终一致性呢？你首先要知道它以什么为准，因为这是实现最终一致性的关键。一般来说，在实际工程实践中有这样几种方式：

以最新写入的数据为准，比如 AP 模型的 KV 存储采用的就是这种方式；

以第一次写入的数据为准，如果你不希望存储的数据被更改，可以以它为准。

那实现最终一致性的具体方式是什么呢？常用的有这样几种。

读时修复：在读取数据时，检测数据的不一致，进行修复。比如 Cassandra 的 Read Repair 实现，具体来说，在向 Cassandra 系统查询数据的时候，如果检测到不同节点的副本数据不一致，系统就自动修复数据。

写时修复：在写入数据，检测数据的不一致时，进行修复。比如 Cassandra 的 Hinted Handoff 实现。具体来说，Cassandra 集群的节点之间远程写数据的时候，如果写失败就将数据缓存下来，然后定时重传，修复数据的不一致性。

异步修复：这个是最常用的方式，通过定时对账检测副本数据的一致性，并修复。

在这里，我想强调的是因为写时修复不需要做数据一致性对比，性能消耗比较低，对系统运行影响也不大，所以我推荐你在实现最终一致性时优先实现这种方式。而读时修复和异步修复因为需要做数据的一致性对比，性能消耗比较多，在开发实际系统时，你要尽量优化一致性对比的算法，降低性能消耗，避免对系统运行造成影响。

另外，我还想补充一点，在实现最终一致性的时候，我推荐同时实现自定义写一致性级别（All、Quorum、One、Any）， 让用户可以自主选择相应的一致性级别，比如可以通过设置一致性级别为 All，来实现强一致性。

现在，想必你了解了 BASE 理论的核心内容了吧？不过这是理论层面上的，那么在实践中，该如何使用 BASE 理论的呢？

如何使用 BASE 理论

我以自研 InfluxDB 系统中 DATA 节点的集群实现为例，带你来使用 BASE 理论。咱们先来看看如何保障基本可用。

DATA 节点的核心功能是读和写，所以基本可用是指读和写的基本可用。那么我们可以通过分片和多副本，实现读和写的基本可用。也就是说，将同一业务的数据先分片，然后再以多份副本的形式分布在不同的节点上。比如下面这张图，这个 3 节点 2 副本的集群，除非超过一半的节点都故障了，否则是能保障所有数据的读写的。

那么如果实现最终一致性呢？就像我上文提到的样子，我们可以通过写时修复和异步修复实现最终一致性。另外，还实现自定义写一致性级别，支持 All、Quorum、One、Any 4 种写一致性级别，用户在写数据的时候，可以根据业务数据的特点，设置不同的写一致性级别。

内容小结

本节课我主要带你了解了 BASE 理论，以及 BASE 理论的应用，我希望你明确几个重点：

BASE 理论是对 CAP 中一致性和可用性权衡的结果，它来源于对大规模互联网分布式系统实践的总结，是基于 CAP 定理逐步演化而来的。它的核心思想是，如果不是必须的话，不推荐实现事务或强一致性，鼓励可用性和性能优先，根据业务的场景特点，来实现非常弹性的基本可用，以及实现数据的最终一致性。

BASE 理论主张通过牺牲部分功能的可用性，实现整体的基本可用，也就是说，通过服务降级的方式，努力保障极端情况下的系统可用性。

ACID 理论是传统数据库常用的设计理念，追求强一致性模型。BASE 理论支持的是大型分布式系统，通过牺牲强一致性获得高可用性。BASE 理论在很大程度上，解决了事务型系统在性能、容错、可用性等方面痛点。另外我再多说一句，BASE 理论在 NoSQL 中应用广泛，是 NoSQL 系统设计的事实上的理论支撑。

最后我强调一下，对于任何集群而言，不可预知的故障的最终后果，都是系统过载。如何设计过载保护，实现系统在过载时的基本可用，是开发和运营互联网后台的分布式系统的重中之重。那么我建议你，在开发实现分布式系统，要充分考虑如何实现基本可用。

课堂思考

我在文章中提了一些实现基本可用的方法，比如流量削峰、延迟响应、体验降级、过载保护等，那么你不妨思考一下，还有哪些方法可以用来实现基本可用呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

05 | Paxos算法（一）：如何在多个节点间确定某变量的值？

你好，我是韩健。

提到分布式算法，就不得不提 Paxos 算法，在过去几十年里，它基本上是分布式共识的代名词，因为当前最常用的一批共识算法都是基于它改进的。比如，Fast Paxos 算法、Cheap Paxos 算法、Raft 算法、ZAB 协议等等。而很多同学都会在准确和系统理解 Paxos 算法上踩坑，比如，只知道它可以用来达成共识，但不知道它是如何达成共识的。

这其实侧面说明了 Paxos 算法有一定的难度，可分布式算法本身就很复杂，Paxos 算法自然也不会例外，当然了，除了这一点，还跟兰伯特有关。

兰伯特提出的 Paxos 算法包含 2 个部分：

一个是 Basic Paxos 算法，描述的是多节点之间如何就某个值（提案 Value）达成共识；

另一个是 Multi-Paxos 思想，描述的是执行多个 Basic Paxos 实例，就一系列值达成共识。

可因为兰伯特提到的 Multi-Paxos 思想，缺少代码实现的必要细节（比如怎么选举领导者），所以在理解上比较难。

为了让你理解 Paxos 算法，接下来我会用 2 节课的时间，分别以 Basic Paxos 和 Multi-Paxos 为核心，带你了解 Basic Paxos 如何达成共识，以及针对 Basic Paxos 的局限性 Multi-Paxos 又是如何改进的。今天咱们先来聊聊 Basic Paxos。

在我看来，Basic Paxos 是 Multi-Paxos 思想的核心，说白了，Multi-Paxos 就是多执行几次 Basic Paxos。所以掌握它之后，你能更好地理解后几讲基于 Multi-Paxos 思想的共识算法（比如 Raft 算法），还能掌握分布式共识算法的最核心内容，当现在的算法不能满足业务需求，进行权衡折中，设计自己的算法。

来看一道思考题。

假设我们要实现一个分布式集群，这个集群是由节点 A、B、C 组成，提供只读 KV 存储服务。你应该知道，创建只读变量的时候，必须要对它进行赋值，而且这个值后续没办法修改。因此一个节点创建只读变量后就不能再修改它了，所以所有节点必须要先对只读变量的值达成共识，然后所有节点再一起创建这个只读变量。

那么，当有多个客户端（比如客户端 1、2）访问这个系统，试图创建同一个只读变量（比如 X），客户端 1 试图创建值为 3 的 X，客户端 2 试图创建值为 7 的 X，这样要如何达成共识，实现各节点上 X 值的一致呢？带着这个问题，我们进入今天的学习。

图1

在一些经典的算法中，你会看到一些既形象又独有的概念（比如二阶段提交协议中的协调者），Basic Paxos 算法也不例外。为了帮助人们更好地理解 Basic Paxos 算法，兰伯特在讲解时，也使用了一些独有而且比较重要的概念，提案、准备（Prepare）请求、接受（Accept）请求、角色等等，其中最重要的就是“角色”。因为角色是对 Basic Paxos 中最核心的三个功能的抽象，比如，由接受者（Acceptor）对提议的值进行投票，并存储接受的值。

你需要了解的三种角色

在 Basic Paxos 中，有提议者（Proposer）、接受者（Acceptor）、学习者（Learner）三种角色，他们之间的关系如下：

图2

看着是不是有些复杂，其实并不难理解：

提议者（Proposer）：提议一个值，用于投票表决。为了方便演示，你可以把图 1 中的客户端 1 和 2 看作是提议者。但在绝大多数场景中，集群中收到客户端请求的节点，才是提议者（图 1 这个架构，是为了方便演示算法原理）。这样做的好处是，对业务代码没有入侵性，也就是说，我们不需要在业务代码中实现算法逻辑，就可以像使用数据库一样访问后端的数据。

接受者（Acceptor）：对每个提议的值进行投票，并存储接受的值，比如 A、B、C 三个节点。 一般来说，集群中的所有节点都在扮演接受者的角色，参与共识协商，并接受和存储数据。

讲到这儿，你可能会有疑惑：前面不是说接收客户端请求的节点是提议者吗？这里怎么又是接受者呢？这是因为一个节点（或进程）可以身兼多个角色。想象一下，一个 3 节点的集群，1 个节点收到了请求，那么该节点将作为提议者发起二阶段提交，然后这个节点和另外 2 个节点一起作为接受者进行共识协商，就像下图的样子：

图3

学习者（Learner）：被告知投票的结果，接受达成共识的值，存储保存，不参与投票的过程。一般来说，学习者是数据备份节点，比如“Master-Slave”模型中的 Slave，被动地接受数据，容灾备份。

其实，这三种角色，在本质上代表的是三种功能：

提议者代表的是接入和协调功能，收到客户端请求后，发起二阶段提交，进行共识协商；

接受者代表投票协商和存储数据，对提议的值进行投票，并接受达成共识的值，存储保存；

学习者代表存储数据，不参与共识协商，只接受达成共识的值，存储保存。

因为一个完整的算法过程是由这三种角色对应的功能组成的，所以理解这三种角色，是你理解 Basic Paxos 如何就提议的值达成共识的基础。那么接下来，咱们看看如何使用 Basic Paxos 达成共识，解决开篇提到的那道思考题。

如何达成共识？

想象这样一个场景，现在疫情这么严重，每个村的路都封得差不多了，就你的村委会不作为，迟迟没有什么防疫的措施。你决定给村委会提交个提案，提一些防疫的建议，除了建议之外，为了和其他村民的提案做区分，你的提案还得包含一个提案编号，来起到唯一标识的作用。

与你的做法类似，在 Basic Paxos 中，兰伯特也使用提案代表一个提议。不过在提案中，除了提案编号，还包含了提议值。为了方便演示，我使用[n, v]表示一个提案，其中 n 为提案编号，v 为提议值。

我想强调一下，整个共识协商是分 2 个阶段进行的（也就是我在 03 讲提到的二阶段提交）。那么具体要如何协商呢？

我们假设客户端 1 的提案编号为 1，客户端 2 的提案编号为 5，并假设节点 A、B 先收到来自客户端 1 的准备请求，节点 C 先收到来自客户端 2 的准备请求。

准备（Prepare）阶段

先来看第一个阶段，首先客户端 1、2 作为提议者，分别向所有接受者发送包含提案编号的准备请求：

图4

你要注意，在准备请求中是不需要指定提议的值的，只需要携带提案编号就可以了，这是很多同学容易产生误解的地方。

接着，当节点 A、B 收到提案编号为 1 的准备请求，节点 C 收到提案编号为 5 的准备请求后，将进行这样的处理：

图5

由于之前没有通过任何提案，所以节点 A、B 将返回一个 “尚无提案”的响应。也就是说节点 A 和 B 在告诉提议者，我之前没有通过任何提案呢，并承诺以后不再响应提案编号小于等于 1 的准备请求，不会通过编号小于 1 的提案。

节点 C 也是如此，它将返回一个 “尚无提案”的响应，并承诺以后不再响应提案编号小于等于 5 的准备请求，不会通过编号小于 5 的提案。

另外，当节点 A、B 收到提案编号为 5 的准备请求，和节点 C 收到提案编号为 1 的准备请求的时候，将进行这样的处理过程：

图6

当节点 A、B 收到提案编号为 5 的准备请求的时候，因为提案编号 5 大于它们之前响应的准备请求的提案编号 1，而且两个节点都没有通过任何提案，所以它将返回一个 “尚无提案”的响应，并承诺以后不再响应提案编号小于等于 5 的准备请求，不会通过编号小于 5 的提案。

当节点 C 收到提案编号为 1 的准备请求的时候，由于提案编号 1 小于它之前响应的准备请求的提案编号 5，所以丢弃该准备请求，不做响应。

接受（Accept）阶段

第二个阶段也就是接受阶段，首先客户端 1、2 在收到大多数节点的准备响应之后，会分别发送接受请求：

图7

当客户端 1 收到大多数的接受者（节点 A、B）的准备响应后，根据响应中提案编号最大的提案的值，设置接受请求中的值。因为该值在来自节点 A、B 的准备响应中都为空（也就是图 5 中的“尚无提案”），所以就把自己的提议值 3 作为提案的值，发送接受请求[1, 3]。

当客户端 2 收到大多数的接受者的准备响应后（节点 A、B 和节点 C），根据响应中提案编号最大的提案的值，来设置接受请求中的值。因为该值在来自节点 A、B、C 的准备响应中都为空（也就是图 5 和图 6 中的“尚无提案”），所以就把自己的提议值 7 作为提案的值，发送接受请求[5, 7]。

当三个节点收到 2 个客户端的接受请求时，会进行这样的处理：

图8

当节点 A、B、C 收到接受请求[1, 3]的时候，由于提案的提案编号 1 小于三个节点承诺能通过的提案的最小提案编号 5，所以提案[1, 3]将被拒绝。

当节点 A、B、C 收到接受请求[5, 7]的时候，由于提案的提案编号 5 不小于三个节点承诺能通过的提案的最小提案编号 5，所以就通过提案[5, 7]，也就是接受了值 7，三个节点就 X 值为 7 达成了共识。

讲到这儿我想补充一下，如果集群中有学习者，当接受者通过了一个提案时，就通知给所有的学习者。当学习者发现大多数的接受者都通过了某个提案，那么它也通过该提案，接受该提案的值。

通过上面的演示过程，你可以看到，最终各节点就 X 的值达成了共识。那么在这里我还想强调一下，Basic Paxos 的容错能力，源自“大多数”的约定，你可以这么理解：当少于一半的节点出现故障的时候，共识协商仍然在正常工作。

内容小结

本节课我主要带你了解了 Basic Paxos 的原理和一些特点，我希望你明确这样几个重点。

你可以看到，Basic Paxos 是通过二阶段提交的方式来达成共识的。二阶段提交是达成共识的常用方式，如果你需要设计新的共识算法的时候，也可以考虑这个方式。

除了共识，Basic Paxos 还实现了容错，在少于一半的节点出现故障时，集群也能工作。它不像分布式事务算法那样，必须要所有节点都同意后才提交操作，因为“所有节点都同意”这个原则，在出现节点故障的时候会导致整个集群不可用。也就是说，“大多数节点都同意”的原则，赋予了 Basic Paxos 容错的能力，让它能够容忍少于一半的节点的故障。

本质上而言，提案编号的大小代表着优先级，你可以这么理解，根据提案编号的大小，接受者保证三个承诺，具体来说：如果准备请求的提案编号，小于等于接受者已经响应的准备请求的提案编号，那么接受者将承诺不响应这个准备请求；如果接受请求中的提案的提案编号，小于接受者已经响应的准备请求的提案编号，那么接受者将承诺不通过这个提案；如果接受者之前有通过提案，那么接受者将承诺，会在准备请求的响应中，包含已经通过的最大编号的提案信息。

课堂思考

在示例中，如果节点 A、B 已经通过了提案[5, 7]，节点 C 未通过任何提案，那么当客户端 3 提案编号为 9 时，通过 Basic Paxos 执行“SET X = 6”，最终三个节点上 X 值是多少呢？为什么呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

06 | Paxos算法（二）：Multi-Paxos不是一个算法，而是统称

你好，我是韩健。

经过上节课的学习，你应该知道，Basic Paxos 只能就单个值（Value）达成共识，一旦遇到为一系列的值实现共识的时候，它就不管用了。虽然兰伯特提到可以通过多次执行 Basic Paxos 实例（比如每接收到一个值时，就执行一次 Basic Paxos 算法）实现一系列值的共识。但是，很多同学读完论文后，应该还是两眼摸黑，虽然每个英文单词都能读懂，但还是不理解兰伯特提到的 Multi-Paxos，为什么 Multi-Paxos 这么难理解呢？

在我看来，兰伯特并没有把 Multi-Paxos 讲清楚，只是介绍了大概的思想，缺少算法过程的细节和编程所必须的细节（比如缺少选举领导者的细节）。这也就导致每个人实现的 Multi-Paxos 都不一样。不过从本质上看，大家都是在兰伯特提到的 Multi-Paxos 思想上补充细节，设计自己的 Multi-Paxos 算法，然后实现它（比如 Chubby 的 Multi-Paxos 实现、Raft 算法、ZAB 协议等）。

所以在这里，我补充一下：兰伯特提到的 Multi-Paxos 是一种思想，不是算法。而 Multi-Paxos 算法是一个统称，它是指基于 Multi-Paxos 思想，通过多个 Basic Paxos 实例实现一系列值的共识的算法（比如 Chubby 的 Multi-Paxos 实现、Raft 算法等）。 这一点尤其需要你注意。

为了帮你掌握 Multi-Paxos 思想，我会先带你了解，对于 Multi-Paxos 兰伯特是如何思考的，也就是说，如何解决 Basic Paxos 的痛点问题；然后我再以 Chubby 的 Multi-Paxos 实现为例，具体讲解一下。为啥选它呢？因为 Chubby 的 Multi-Paxos 实现，代表了 Multi-Paxos 思想在生产环境中的真正落地，它将一种思想变成了代码实现。

兰伯特关于 Multi-Paxos 的思考

熟悉 Basic Paxos 的同学（可以回顾一下05 讲）可能还记得，Basic Paxos 是通过二阶段提交来达成共识的。在第一阶段，也就是准备阶段，接收到大多数准备响应的提议者，才能发起接受请求进入第二阶段（也就是接受阶段）：

而如果我们直接通过多次执行 Basic Paxos 实例，来实现一系列值的共识，就会存在这样几个问题：

如果多个提议者同时提交提案，可能出现因为提案冲突，在准备阶段没有提议者接收到大多数准备响应，协商失败，需要重新协商。你想象一下，一个 5 节点的集群，如果 3 个节点作为提议者同时提案，就可能发生因为没有提议者接收大多数响应（比如 1 个提议者接收到 1 个准备响应，另外 2 个提议者分别接收到 2 个准备响应）而准备失败，需要重新协商。

2 轮 RPC 通讯（准备阶段和接受阶段）往返消息多、耗性能、延迟大。你要知道，分布式系统的运行是建立在 RPC 通讯的基础之上的，因此，延迟一直是分布式系统的痛点，是需要我们在开发分布式系统时认真考虑和优化的。

那么如何解决上面的 2 个问题呢？可以通过引入领导者和优化 Basic Paxos 执行来解决，咱们首先聊一聊领导者。

领导者（Leader）

我们可以通过引入领导者节点，也就是说，领导者节点作为唯一提议者，这样就不存在多个提议者同时提交提案的情况，也就不存在提案冲突的情况了：

在这里，我补充一点：在论文中，兰伯特没有说如何选举领导者，需要我们在实现 Multi-Paxos 算法的时候自己实现。 比如在 Chubby 中，主节点（也就是领导者节点）是通过执行 Basic Paxos 算法，进行投票选举产生的。

那么，如何解决第二个问题，也就是如何优化 Basic Paxos 执行呢？

优化 Basic Paxos 执行

我们可以采用“当领导者处于稳定状态时，省掉准备阶段，直接进入接受阶段”这个优化机制，优化 Basic Paxos 执行。也就是说，领导者节点上，序列中的命令是最新的，不再需要通过准备请求来发现之前被大多数节点通过的提案，领导者可以独立指定提案中的值。这时，领导者在提交命令时，可以省掉准备阶段，直接进入到接受阶段：

你看，和重复执行 Basic Paxos 相比，Multi-Paxos 引入领导者节点之后，因为只有领导者节点一个提议者，只有它说了算，所以就不存在提案冲突。另外，当主节点处于稳定状态时，就省掉准备阶段，直接进入接受阶段，所以在很大程度上减少了往返的消息数，提升了性能，降低了延迟。

讲到这儿，你可能会问了：在实际系统中，该如何实现 Multi-Paxos 呢？接下来，我以 Chubby 的 Multi-Paxos 实现为例，具体讲解一下。

Chubby 的 Multi-Paxos 实现

既然兰伯特只是大概的介绍了 Multi-Paxos 思想，那么 Chubby 是如何补充细节，实现 Multi-Paxos 算法的呢？

首先，它通过引入主节点，实现了兰伯特提到的领导者（Leader）节点的特性。也就是说，主节点作为唯一提议者，这样就不存在多个提议者同时提交提案的情况，也就不存在提案冲突的情况了。

另外，在 Chubby 中，主节点是通过执行 Basic Paxos 算法，进行投票选举产生的，并且在运行过程中，主节点会通过不断续租的方式来延长租期（Lease）。比如在实际场景中，几天内都是同一个节点作为主节点。如果主节点故障了，那么其他的节点又会投票选举出新的主节点，也就是说主节点是一直存在的，而且是唯一的。

其次，在 Chubby 中实现了兰伯特提到的，“当领导者处于稳定状态时，省掉准备阶段，直接进入接受阶段”这个优化机制。

最后，在 Chubby 中，实现了成员变更（Group membership），以此保证节点变更的时候集群的平稳运行。

最后，我想补充一点：在 Chubby 中，为了实现了强一致性，读操作也只能在主节点上执行。 也就是说，只要数据写入成功，之后所有的客户端读到的数据都是一致的。具体的过程，就是下面的样子。

所有的读请求和写请求都由主节点来处理。当主节点从客户端接收到写请求后，作为提议者，执行 Basic Paxos 实例，将数据发送给所有的节点，并且在大多数的服务器接受了这个写请求之后，再响应给客户端成功：

当主节点接收到读请求后，处理就比较简单了，主节点只需要查询本地数据，然后返回给客户端就可以了：

Chubby 的 Multi-Paxos 实现，尽管是一个闭源的实现，但这是 Multi-Paxos 思想在实际场景中的真正落地，Chubby 团队不仅编程实现了理论，还探索了如何补充细节。其中的思考和设计非常具有参考价值，不仅能帮助我们理解 Multi-Paxos 思想，还能帮助我们理解其他的 Multi-Paxos 算法（比如 Raft 算法）。

内容小结

本节课我主要带你了解了 Basic Paxos 的局限，以及 Chubby 的 Multi-Paxos 实现。我希望你明确的重点如下：

兰伯特提到的 Multi-Paxos 是一种思想，不是算法，而且还缺少算法过程的细节和编程所必须的细节，比如如何选举领导者等，这也就导致了每个人实现的 Multi-Paxos 都不一样。而 Multi-Paxos 算法是一个统称，它是指基于 Multi-Paxos 思想，通过多个 Basic Paxos 实例实现一系列数据的共识的算法（比如 Chubby 的 Multi-Paxos 实现、Raft 算法等）。

Chubby 实现了主节点（也就是兰伯特提到的领导者），也实现了兰伯特提到的 “当领导者处于稳定状态时，省掉准备阶段，直接进入接受阶段” 这个优化机制，省掉 Basic Paxos 的准备阶段，提升了数据的提交效率，但是所有写请求都在主节点处理，限制了集群处理写请求的并发能力，约等于单机。

因为在 Chubby 的 Multi-Paxos 实现中，也约定了“大多数原则”，也就是说，只要大多数节点正常运行时，集群就能正常工作，所以 Chubby 能容错（n - 1）/2 个节点的故障。

本质上而言，“当领导者处于稳定状态时，省掉准备阶段，直接进入接受阶段”这个优化机制，是通过减少非必须的协商步骤来提升性能的。这种方法非常常用，也很有效。比如，Google 设计的 QUIC 协议，是通过减少 TCP、TLS 的协商步骤，优化 HTTPS 性能。我希望你能掌握这种性能优化思路，后续在需要时，可以通过减少非必须的步骤，优化系统性能。

最后，我想说的是，我个人比较喜欢 Paxos 算法（兰伯特的 Basic Paxos 和 Multi-Paxos），虽然 Multi-Paxos 缺失算法细节，但这反而给我们提供了思考空间，让我们可以反复思考和考据缺失的细节，比如在 Multi-Paxos 中到底需不需要选举领导者，再比如如何实现提案编号等等。

但我想强调，Basic Paxos 是经过证明的，而 Multi-Paxos 是一种思想，缺失实现算法的必须编程细节，这就导致，Multi-Paxos 的最终算法实现，是建立在一个未经证明的基础之上的，正确性是个问号。

与此同时，实现 Multi-Paxos 算法，最大的挑战是如何证明它是正确的。 比如 Chubby 的作者做了大量的测试，和运行一致性检测脚本，验证和观察系统的健壮性。在实际使用时，我不推荐你设计和实现新的 Multi-Paxos 算法，而是建议优先考虑 Raft 算法，因为 Raft 的正确性是经过证明的。当 Raft 算法不能满足需求时，你再考虑实现和优化 Multi-Paxos 算法。

课堂思考

既然，我提了 Chubby 只能在主节点上执行读操作，那么在最后，我给你留了一个思考题，这个设计有什么局限呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

07 | Raft算法（一）：如何选举领导者？

你好，我是韩健。

通过前两节课，我带你打卡了 Paxos 算法，今天我想和你聊聊最常用的共识算法，Raft 算法。

Raft 算法属于 Multi-Paxos 算法，它是在兰伯特 Multi-Paxos 思想的基础上，做了一些简化和限制，比如增加了日志必须是连续的，只支持领导者、跟随者和候选人三种状态，在理解和算法实现上都相对容易许多。

除此之外，Raft 算法是现在分布式系统开发首选的共识算法。绝大多数选用 Paxos 算法的系统（比如 Cubby、Spanner）都是在 Raft 算法发布前开发的，当时没得选；而全新的系统大多选择了 Raft 算法（比如 Etcd、Consul、CockroachDB）。

对你来说，掌握这个算法，可以得心应手地处理绝大部分场景的容错和一致性需求，比如分布式配置系统、分布式 NoSQL 存储等等，轻松突破系统的单机限制。

如果要用一句话概括 Raft 算法，我觉得是这样的：从本质上说，Raft 算法是通过一切以领导者为准的方式，实现一系列值的共识和各节点日志的一致。这句话比较抽象，我来做个比喻，领导者就是 Raft 算法中的霸道总裁，通过霸道的“一切以我为准”的方式，决定了日志中命令的值，也实现了各节点日志的一致。

我会用三讲的时间，分别以领导者选举、日志复制、成员变更为核心，讲解 Raft 算法的原理，在实战篇中，会带你进一步剖析 Raft 算法的实现，介绍基于 Raft 算法的分布式系统开发实战。那么我希望从原理到实战，在帮助你掌握分布式系统架构设计技巧和开发实战能力的同时，加深你对 Raft 算法的理解。

在课程开始之前，我们先来看一道思考题。

假设我们有一个由节点 A、B、C 组成的 Raft 集群（如图所示），因为 Raft 算法一切以领导者为准，所以如果集群中出现了多个领导者，就会出现不知道谁来做主的问题。在这样一个有多个节点的集群中，在节点故障、分区错误等异常情况下，Raft 算法如何保证在同一个时间，集群中只有一个领导者呢？带着这个问题，我们正式进入今天的学习。

既然要选举领导者，那要从哪些成员中选举呢？除了领导者，Raft 算法还支持哪些成员身份呢？这部分内容是你需要掌握的，最基础的背景知识。

有哪些成员身份？

成员身份，又叫做服务器节点状态，Raft 算法支持领导者（Leader）、跟随者（Follower）和候选人（Candidate） 3 种状态。为了方便讲解，我们使用不同的图形表示不同的状态。在任何时候，每一个服务器节点都处于这 3 个状态中的 1 个。

跟随者：就相当于普通群众，默默地接收和处理来自领导者的消息，当等待领导者心跳信息超时的时候，就主动站出来，推荐自己当候选人。

候选人：候选人将向其他节点发送请求投票（RequestVote）RPC 消息，通知其他节点来投票，如果赢得了大多数选票，就晋升当领导者。

领导者：蛮不讲理的霸道总裁，一切以我为准，平常的主要工作内容就是 3 部分，处理写请求、管理日志复制和不断地发送心跳信息，通知其他节点“我是领导者，我还活着，你们现在不要发起新的选举，找个新领导者来替代我。”

需要你注意的是，Raft 算法是强领导者模型，集群中只能有一个“霸道总裁”。

选举领导者的过程

那么这三个成员是怎么选出来领导者的呢？为了方便你理解，我以图例的形式演示一个典型的领导者选举过程。

首先，在初始状态下，集群中所有的节点都是跟随者的状态。

Raft 算法实现了随机超时时间的特性。也就是说，每个节点等待领导者节点心跳信息的超时时间间隔是随机的。通过上面的图片你可以看到，集群中没有领导者，而节点 A 的等待超时时间最小（150ms），它会最先因为没有等到领导者的心跳信息，发生超时。

这个时候，节点 A 就增加自己的任期编号，并推举自己为候选人，先给自己投上一张选票，然后向其他节点发送请求投票 RPC 消息，请它们选举自己为领导者。

如果其他节点接收到候选人 A 的请求投票 RPC 消息，在编号为 1 的这届任期内，也还没有进行过投票，那么它将把选票投给节点 A，并增加自己的任期编号。

如果候选人在选举超时时间内赢得了大多数的选票，那么它就会成为本届任期内新的领导者。

节点 A 当选领导者后，他将周期性地发送心跳消息，通知其他服务器我是领导者，阻止跟随者发起新的选举，篡权。

讲到这儿，你是不是发现领导者选举很容易理解？与现实中的议会选举也蛮类似？当然，你可能还是对一些细节产生一些疑问：

节点间是如何通讯的呢？

什么是任期呢？

选举有哪些规则？

随机超时时间又是什么？

选举过程四连问

老话说，细节是魔鬼。这些细节也是很多同学在学习 Raft 算法的时候比较难掌握的，所以我认为有必要具体分析一下。咱们一步步来，先来看第一个问题。

节点间如何通讯？

在 Raft 算法中，服务器节点间的沟通联络采用的是远程过程调用（RPC），在领导者选举中，需要用到这样两类的 RPC：

1. 请求投票（RequestVote）RPC，是由候选人在选举期间发起，通知各节点进行投票；

2. 日志复制（AppendEntries）RPC，是由领导者发起，用来复制日志和提供心跳消息。

我想强调的是，日志复制 RPC 只能由领导者发起，这是实现强领导者模型的关键之一，希望你能注意这一点，后续能更好地理解日志复制，理解日志的一致是怎么实现的。

什么是任期？

我们知道，议会选举中的领导者是有任期的，领导者任命到期后，要重新开会再次选举。Raft 算法中的领导者也是有任期的，每个任期由单调递增的数字（任期编号）标识，比如节点 A 的任期编号是 1。任期编号是随着选举的举行而变化的，这是在说下面几点。

跟随者在等待领导者心跳信息超时后，推举自己为候选人时，会增加自己的任期号，比如节点 A 的当前任期编号为 0，那么在推举自己为候选人时，会将自己的任期编号增加为 1。

如果一个服务器节点，发现自己的任期编号比其他节点小，那么它会更新自己的编号到较大的编号值。比如节点 B 的任期编号是 0，当收到来自节点 A 的请求投票 RPC 消息时，因为消息中包含了节点 A 的任期编号，且编号为 1，那么节点 B 将把自己的任期编号更新为 1。

我想强调的是，与现实议会选举中的领导者的任期不同，Raft 算法中的任期不只是时间段，而且任期编号的大小，会影响领导者选举和请求的处理。

在 Raft 算法中约定，如果一个候选人或者领导者，发现自己的任期编号比其他节点小，那么它会立即恢复成跟随者状态。比如分区错误恢复后，任期编号为 3 的领导者节点 B，收到来自新领导者的，包含任期编号为 4 的心跳消息，那么节点 B 将立即恢复成跟随者状态。

还约定如果一个节点接收到一个包含较小的任期编号值的请求，那么它会直接拒绝这个请求。比如节点 C 的任期编号为 4，收到包含任期编号为 3 的请求投票 RPC 消息，那么它将拒绝这个消息。

在这里，你可以看到，Raft 算法中的任期比议会选举中的任期要复杂。同样，在 Raft 算法中，选举规则的内容也会比较多。

选举有哪些规则

在议会选举中，比成员的身份、领导者的任期还要重要的就是选举的规则，比如一人一票、弹劾制度等。“无规矩不成方圆”，在 Raft 算法中，也约定了选举规则，主要有这样几点。

领导者周期性地向所有跟随者发送心跳消息（即不包含日志项的日志复制 RPC 消息），通知大家我是领导者，阻止跟随者发起新的选举。

如果在指定时间内，跟随者没有接收到来自领导者的消息，那么它就认为当前没有领导者，推举自己为候选人，发起领导者选举。

在一次选举中，赢得大多数选票的候选人，将晋升为领导者。

在一个任期内，领导者一直都会是领导者，直到它自身出现问题（比如宕机），或者因为网络延迟，其他节点发起一轮新的选举。

在一次选举中，每一个服务器节点最多会对一个任期编号投出一张选票，并且按照“先来先服务”的原则进行投票。比如节点 C 的任期编号为 3，先收到了 1 个包含任期编号为 4 的投票请求（来自节点 A），然后又收到了 1 个包含任期编号为 4 的投票请求（来自节点 B）。那么节点 C 将会把唯一一张选票投给节点 A，当再收到节点 B 的投票请求 RPC 消息时，对于编号为 4 的任期，已没有选票可投了。

当任期编号相同时，日志完整性高的跟随者（也就是最后一条日志项对应的任期编号值更大，索引号更大），拒绝投票给日志完整性低的候选人。比如节点 B、C 的任期编号都是 3，节点 B 的最后一条日志项对应的任期编号为 3，而节点 C 为 2，那么当节点 C 请求节点 B 投票给自己时，节点 B 将拒绝投票。

我想强调的是，选举是跟随者发起的，推举自己为候选人；大多数选票是指集群成员半数以上的选票；大多数选票规则的目标，是为了保证在一个给定的任期内最多只有一个领导者。

其实在选举中，除了选举规则外，我们还需要避免一些会导致选举失败的情况，比如同一任期内，多个候选人同时发起选举，导致选票被瓜分，选举失败。那么在 Raft 算法中，如何避免这个问题呢？答案就是随机超时时间。

如何理解随机超时时间

在议会选举中，常出现未达到指定票数，选举无效，需要重新选举的情况。在 Raft 算法的选举中，也存在类似的问题，那它是如何处理选举无效的问题呢？

其实，Raft 算法巧妙地使用随机选举超时时间的方法，把超时时间都分散开来，在大多数情况下只有一个服务器节点先发起选举，而不是同时发起选举，这样就能减少因选票瓜分导致选举失败的情况。

我想强调的是，在 Raft 算法中，随机超时时间是有 2 种含义的，这里是很多同学容易理解出错的地方，需要你注意一下：

1. 跟随者等待领导者心跳信息超时的时间间隔，是随机的；

2. 当没有候选人赢得过半票数，选举无效了，这时需要等待一个随机时间间隔，也就是说，等待选举超时的时间间隔，是随机的。

内容小结

以上就是本节课的全部内容了，本节课我主要带你了解了 Raft 算法的特点、领导者选举等。我希望你明确这样几个重点。

Raft 算法和兰伯特的 Multi-Paxos 不同之处，主要有 2 点。首先，在 Raft 中，不是所有节点都能当选领导者，只有日志最完整的节点，才能当选领导者；其次，在 Raft 中，日志必须是连续的。

Raft 算法通过任期、领导者心跳消息、随机选举超时时间、先来先服务的投票原则、大多数选票原则等，保证了一个任期只有一位领导，也极大地减少了选举失败的情况。

本质上，Raft 算法以领导者为中心，选举出的领导者，以“一切以我为准”的方式，达成值的共识，和实现各节点日志的一致。

在本讲，我们使用 Raft 算法在集群中选出了领导者节点 A，那么选完领导者之后，领导者需要处理来自客户的写请求，并通过日志复制实现各节点日志的一致（下节课我会重点带你了解这一部分内容）。

课堂思考

既然我提到，Raft 算法实现了“一切以我为准”的强领导者模型，那么你不妨思考，这个设计有什么限制和局限呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

08 | Raft算法（二）：如何复制日志？

你好，我是韩健。

通过上一讲的学习，你应该知道 Raft 除了能实现一系列值的共识之外，还能实现各节点日志的一致，不过你也许会有这样的疑惑：“什么是日志呢？它和我的业务数据有什么关系呢？”

想象一下，一个木筏（Raft）是由多根整齐一致的原木（Log）组成的，而原木又是由木质材料组成，所以你可以认为日志是由多条日志项（Log entry）组成的，如果把日志比喻成原木，那么日志项就是木质材料。

在 Raft 算法中，副本数据是以日志的形式存在的，领导者接收到来自客户端写请求后，处理写请求的过程就是一个复制和提交日志项的过程。

那 Raft 是如何复制日志的呢？又如何实现日志的一致的呢？这些内容是 Raft 中非常核心的内容，也是我今天讲解的重点，我希望你不懂就问，多在留言区提出你的想法。首先，咱们先来理解日志，这是你掌握如何复制日志、实现日志一致的基础。

如何理解日志？

刚刚我提到，副本数据是以日志的形式存在的，日志是由日志项组成，日志项究竟是什么样子呢？

其实，日志项是一种数据格式，它主要包含用户指定的数据，也就是指令（Command），还包含一些附加信息，比如索引值（Log index）、任期编号（Term）。那你该怎么理解这些信息呢？

指令：一条由客户端请求指定的、状态机需要执行的指令。你可以将指令理解成客户端指定的数据。

索引值：日志项对应的整数索引值。它其实就是用来标识日志项的，是一个连续的、单调递增的整数号码。

任期编号：创建这条日志项的领导者的任期编号。

从图中你可以看到，一届领导者任期，往往有多条日志项。而且日志项的索引值是连续的，这一点你需要注意。

讲到这儿你可能会问：不是说 Raft 实现了各节点间日志的一致吗？那为什么图中 4 个跟随者的日志都不一样呢？日志是怎么复制的呢？又该如何实现日志的一致呢？别着急，接下来咱们就来解决这几个问题。先来说说如何复制日志。

如何复制日志？

你可以把 Raft 的日志复制理解成一个优化后的二阶段提交（将二阶段优化成了一阶段），减少了一半的往返消息，也就是降低了一半的消息延迟。那日志复制的具体过程是什么呢？

首先，领导者进入第一阶段，通过日志复制（AppendEntries）RPC 消息，将日志项复制到集群其他节点上。

接着，如果领导者接收到大多数的“复制成功”响应后，它将日志项提交到它的状态机，并返回成功给客户端。如果领导者没有接收到大多数的“复制成功”响应，那么就返回错误给客户端。

学到这里，有同学可能有这样的疑问了，领导者将日志项提交到它的状态机，怎么没通知跟随者提交日志项呢？

这是 Raft 中的一个优化，领导者不直接发送消息通知其他节点提交指定日志项。因为领导者的日志复制 RPC 消息或心跳消息，包含了当前最大的，将会被提交的日志项索引值。所以通过日志复制 RPC 消息或心跳消息，跟随者就可以知道领导者的日志提交位置信息。

因此，当其他节点接受领导者的心跳消息，或者新的日志复制 RPC 消息后，就会将这条日志项提交到它的状态机。而这个优化，降低了处理客户端请求的延迟，将二阶段提交优化为了一段提交，降低了一半的消息延迟。

为了帮你理解，我画了一张过程图，然后再带你走一遍这个过程，这样你可以更加全面地掌握日志复制。

接收到客户端请求后，领导者基于客户端请求中的指令，创建一个新日志项，并附加到本地日志中。

领导者通过日志复制 RPC，将新的日志项复制到其他的服务器。

当领导者将日志项，成功复制到大多数的服务器上的时候，领导者会将这条日志项提交到它的状态机中。

领导者将执行的结果返回给客户端。

当跟随者接收到心跳信息，或者新的日志复制 RPC 消息后，如果跟随者发现领导者已经提交了某条日志项，而它还没提交，那么跟随者就将这条日志项提交到本地的状态机中。

不过，这是一个理想状态下的日志复制过程。在实际环境中，复制日志的时候，你可能会遇到进程崩溃、服务器宕机等问题，这些问题会导致日志不一致。那么在这种情况下，Raft 算法是如何处理不一致日志，实现日志的一致的呢？

如何实现日志的一致？

在 Raft 算法中，领导者通过强制跟随者直接复制自己的日志项，处理不一致日志。也就是说，Raft 是通过以领导者的日志为准，来实现各节点日志的一致的。具体有 2 个步骤。

首先，领导者通过日志复制 RPC 的一致性检查，找到跟随者节点上，与自己相同日志项的最大索引值。也就是说，这个索引值之前的日志，领导者和跟随者是一致的，之后的日志是不一致的了。

然后，领导者强制跟随者更新覆盖的不一致日志项，实现日志的一致。

我带你详细地走一遍这个过程（为了方便演示，我们引入 2 个新变量）。

PrevLogEntry：表示当前要复制的日志项，前面一条日志项的索引值。比如在图中，如果领导者将索引值为 8 的日志项发送给跟随者，那么此时 PrevLogEntry 值为 7。

PrevLogTerm：表示当前要复制的日志项，前面一条日志项的任期编号，比如在图中，如果领导者将索引值为 8 的日志项发送给跟随者，那么此时 PrevLogTerm 值为 4。

领导者通过日志复制 RPC 消息，发送当前最新日志项到跟随者（为了演示方便，假设当前需要复制的日志项是最新的），这个消息的 PrevLogEntry 值为 7，PrevLogTerm 值为 4。

如果跟随者在它的日志中，找不到与 PrevLogEntry 值为 7、PrevLogTerm 值为 4 的日志项，也就是说它的日志和领导者的不一致了，那么跟随者就会拒绝接收新的日志项，并返回失败信息给领导者。

这时，领导者会递减要复制的日志项的索引值，并发送新的日志项到跟随者，这个消息的 PrevLogEntry 值为 6，PrevLogTerm 值为 3。

如果跟随者在它的日志中，找到了 PrevLogEntry 值为 6、PrevLogTerm 值为 3 的日志项，那么日志复制 RPC 返回成功，这样一来，领导者就知道在 PrevLogEntry 值为 6、PrevLogTerm 值为 3 的位置，跟随者的日志项与自己相同。

领导者通过日志复制 RPC，复制并更新覆盖该索引值之后的日志项（也就是不一致的日志项），最终实现了集群各节点日志的一致。

从上面步骤中你可以看到，领导者通过日志复制 RPC 一致性检查，找到跟随者节点上与自己相同日志项的最大索引值，然后复制并更新覆盖该索引值之后的日志项，实现了各节点日志的一致。需要你注意的是，跟随者中的不一致日志项会被领导者的日志覆盖，而且领导者从来不会覆盖或者删除自己的日志。

内容小结

本节课我主要带你了解了在 Raft 中什么是日志、如何复制日志、以及如何处理不一致日志等内容。我希望你明确这样几个重点。

在 Raft 中，副本数据是以日志的形式存在的，其中日志项中的指令表示用户指定的数据。

兰伯特的 Multi-Paxos 不要求日志是连续的，但在 Raft 中日志必须是连续的。而且在 Raft 中，日志不仅是数据的载体，日志的完整性还影响领导者选举的结果。也就是说，日志完整性最高的节点才能当选领导者。

Raft 是通过以领导者的日志为准，来实现日志的一致的。

学完本节课你可以看到，值的共识和日志的一致都是由领导者决定的，领导者的唯一性很重要，那么如果我们需要对集群进行扩容或缩容，比如将 3 节点集群扩容为 5 节点集群，这时候是可能同时出现两个领导者的。这是为什么呢？在 Raft 中，又是如何解决这个问题的呢？我会在下一讲带你了解。

课堂思考

我提到，领导者接收到大多数的“复制成功”响应后，就会将日志提交到它自己的状态机，然后返回“成功”响应客户端。如果此时有个节点不在“大多数”中，也就是说它接收日志项失败，那么在这种情况下，Raft 会如何处理实现日志的一致呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

09 | Raft算法（三）：如何解决成员变更的问题？

你好，我是韩健。

在日常工作中，你可能会遇到服务器故障的情况，这时你就需要替换集群中的服务器。如果遇到需要改变数据副本数的情况，则需要增加或移除集群中的服务器。总的来说，在日常工作中，集群中的服务器数量是会发生变化的。

讲到这儿，也许你会问：“老韩，Raft 是共识算法，对集群成员进行变更时（比如增加 2 台服务器），会不会因为集群分裂，出现 2 个领导者呢？”

在我看来，的确会出现这个问题，因为 Raft 的领导者选举，建立在“大多数”的基础之上，那么当成员变更时，集群成员发生了变化，就可能同时存在新旧配置的 2 个“大多数”，出现 2 个领导者，破坏了 Raft 集群的领导者唯一性，影响了集群的运行。

而关于成员变更，不仅是 Raft 算法中比较难理解的一部分，非常重要，也是 Raft 算法中唯一被优化和改进的部分。比如，最初实现成员变更的是联合共识（Joint Consensus），但这个方法实现起来难，后来 Raft 的作者就提出了一种改进后的方法，单节点变更（single-server changes）。

为了帮你掌握这块内容，今天我除了带你了解成员变更问题的本质之外，还会讲一下如何通过单节点变更的方法，解决成员变更的问题。学完本讲内容之后，你不仅能理解成员变更的问题和单节点变更的原理，也能更好地理解 Raft 源码实现，掌握解决成员变更问题的方法。

在开始今天内容之前，我先介绍一下“配置”这个词儿。因为常听到有同学说，自己不理解配置（Configuration）的含义，从而不知道如何理解论文中的成员变更。

的确，配置是成员变更中一个非常重要的概念，我建议你这么理解：它就是在说集群是哪些节点组成的，是集群各节点地址信息的集合。比如节点 A、B、C 组成的集群，那么集群的配置就是[A, B, C]集合。

理解了这一点之后，咱们先来看一道思考题。

假设我们有一个由节点 A、B、C 组成的 Raft 集群，现在我们需要增加数据副本数，增加 2 个副本（也就是增加 2 台服务器），扩展为由节点 A、B、C、D、E， 5 个节点组成的新集群：

那么 Raft 算法是如何保障在集群配置变更时，集群能稳定运行，不出现 2 个领导者呢？带着这个问题，我们正式进入今天的学习。

老话说得好，“认识问题，才能解决问题”。为了帮你更好地理解单节点变更的方法，我们先来看一看，成员变更时，到底会出现什么样的问题？

成员变更的问题

在我看来，在集群中进行成员变更的最大风险是，可能会同时出现 2 个领导者。比如在进行成员变更时，节点 A、B 和 C 之间发生了分区错误，节点 A、B 组成旧配置中的“大多数”，也就是变更前的 3 节点集群中的“大多数”，那么这时的领导者（节点 A）依旧是领导者。

另一方面，节点 C 和新节点 D、E 组成了新配置的“大多数”，也就是变更后的 5 节点集群中的“大多数”，它们可能会选举出新的领导者（比如节点 C）。那么这时，就出现了同时存在 2 个领导者的情况。

如果出现了 2 个领导者，那么就违背了“领导者的唯一性”的原则，进而影响到集群的稳定运行。你要如何解决这个问题呢？也许有的同学想到了一个解决方法。

因为我们在启动集群时，配置是固定的，不存在成员变更，在这种情况下，Raft 的领导者选举能保证只有一个领导者。也就是说，这时不会出现多个领导者的问题，那我可以先将集群关闭再启动新集群啊。也就是先把节点 A、B、C 组成的集群关闭，然后再启动节点 A、B、C、D、E 组成的新集群。

在我看来，这个方法不可行。 为什么呢？因为你每次变更都要重启集群，意味着在集群变更期间服务不可用，肯定不行啊，太影响用户体验了。想象一下，你正在玩王者荣耀，时不时弹出一个对话框通知你：系统升级，游戏暂停 3 分钟。这体验糟糕不糟糕？

既然这种方法影响用户体验，根本行不通，那到底怎样解决成员变更的问题呢？最常用的方法就是单节点变更。

如何通过单节点变更解决成员变更的问题？

单节点变更，就是通过一次变更一个节点实现成员变更。如果需要变更多个节点，那你需要执行多次单节点变更。比如将 3 节点集群扩容为 5 节点集群，这时你需要执行 2 次单节点变更，先将 3 节点集群变更为 4 节点集群，然后再将 4 节点集群变更为 5 节点集群，就像下图的样子。

现在，让我们回到开篇的思考题，看看如何用单节点变更的方法，解决这个问题。为了演示方便，我们假设节点 A 是领导者：

目前的集群配置为[A, B, C]，我们先向集群中加入节点 D，这意味着新配置为[A, B, C, D]。成员变更，是通过这么两步实现的：

第一步，领导者（节点 A）向新节点（节点 D）同步数据；

第二步，领导者（节点 A）将新配置[A, B, C, D]作为一个日志项，复制到新配置中所有节点（节点 A、B、C、D）上，然后将新配置的日志项提交到本地状态机，完成单节点变更。

在变更完成后，现在的集群配置就是[A, B, C, D]，我们再向集群中加入节点 E，也就是说，新配置为[A, B, C, D, E]。成员变更的步骤和上面类似：

第一步，领导者（节点 A）向新节点（节点 E）同步数据；

第二步，领导者（节点 A）将新配置[A, B, C, D, E]作为一个日志项，复制到新配置中的所有节点（A、B、C、D、E）上，然后再将新配置的日志项提交到本地状态机，完成单节点变更。

这样一来，我们就通过一次变更一个节点的方式，完成了成员变更，保证了集群中始终只有一个领导者，而且集群也在稳定运行，持续提供服务。

我想说的是，在正常情况下，不管旧的集群配置是怎么组成的，旧配置的“大多数”和新配置的“大多数”都会有一个节点是重叠的。 也就是说，不会同时存在旧配置和新配置 2 个“大多数”：

从上图中你可以看到，不管集群是偶数节点，还是奇数节点，不管是增加节点，还是移除节点，新旧配置的“大多数”都会存在重叠（图中的橙色节点）。

需要你注意的是，在分区错误、节点故障等情况下，如果我们并发执行单节点变更，那么就可能出现一次单节点变更尚未完成，新的单节点变更又在执行，导致集群出现 2 个领导者的情况。

如果你遇到这种情况，可以在领导者启动时，创建一个 NO_OP 日志项（也就是空日志项），只有当领导者将 NO_OP 日志项提交后，再执行成员变更请求。这个解决办法，你记住就可以了，可以自己在课后试着研究下。具体的实现，可参考 Hashicorp Raft 的源码，也就是 runLeader() 函数中：

noop := &logFuture{
        log: Log{
               Type: LogNoop,
        },
}
r.dispatchLogs([]*logFuture{noop})

当然，有的同学会好奇“联合共识”，在我看来，因为它难以实现，很少被 Raft 实现采用。比如，除了 Logcabin 外，未见到其他常用 Raft 实现采用了它，所以这里我就不多说了。如果你有兴趣，可以自己去阅读论文，加深了解。

内容小结

以上就是本节课的全部内容了，本节课我主要带你了解了成员变更的问题和单节点变更的方法，我希望你明确这样几个重点。

成员变更的问题，主要在于进行成员变更时，可能存在新旧配置的 2 个“大多数”，导致集群中同时出现两个领导者，破坏了 Raft 的领导者的唯一性原则，影响了集群的稳定运行。

单节点变更是利用“一次变更一个节点，不会同时存在旧配置和新配置 2 个‘大多数’”的特性，实现成员变更。

因为联合共识实现起来复杂，不好实现，所以绝大多数 Raft 算法的实现，采用的都是单节点变更的方法（比如 Etcd、Hashicorp Raft）。其中，Hashicorp Raft 单节点变更的实现，是由 Raft 算法的作者迭戈·安加罗（Diego Ongaro）设计的，很有参考价值。

除此之外，考虑到本节课是 Raft 算法的最后一讲，所以在这里，我想多说几句，帮助你更好地理解 Raft 算法。

有很多同学把 Raft 当成一致性算法，其实 Raft 不是一致性算法而是共识算法，是一个 Multi-Paxos 算法，实现的是如何就一系列值达成共识。并且，Raft 能容忍少数节点的故障。虽然 Raft 算法能实现强一致性，也就是线性一致性（Linearizability），但需要客户端协议的配合。在实际场景中，我们一般需要根据场景特点，在一致性强度和实现复杂度之间进行权衡。比如 Consul 实现了三种一致性模型。

default：客户端访问领导者节点执行读操作，领导者确认自己处于稳定状态时（在 leader leasing 时间内），返回本地数据给客户端，否则返回错误给客户端。在这种情况下，客户端是可能读到旧数据的，比如此时发生了网络分区错误，新领导者已经更新过数据，但因为网络故障，旧领导者未更新数据也未退位，仍处于稳定状态。

consistent：客户端访问领导者节点执行读操作，领导者在和大多数节点确认自己仍是领导者之后返回本地数据给客户端，否则返回错误给客户端。在这种情况下，客户端读到的都是最新数据。

stale：从任意节点读数据，不局限于领导者节点，客户端可能会读到旧数据。

一般而言，在实际工程中，Consul 的 consistent 就够用了，可以不用线性一致性，只要能保证写操作完成后，每次读都能读到最新值就可以了。比如为了实现冥等操作，我们使用一个编号 (ID) 来唯一标记一个操作，并使用一个状态字段（nil/done）来标记操作是否已经执行，那么只要我们能保证设置了 ID 对应状态值为 done 后，能立即和一直读到最新状态值就可以了，也就通过防止操作的重复执行，实现了冥等性。

总的来说，Raft 算法能很好地处理绝大部分场景的一致性问题，我推荐你在设计分布式系统时，优先考虑 Raft 算法，当 Raft 算法不能满足现有场景需求时，再去调研其他共识算法。

比如我负责过多个 QQ 后台的海量服务分布式系统，其中配置中心、名字服务以及时序数据库的 META 节点，采用了 Raft 算法。在设计时序数据库的 DATA 节点一致性时，基于水平扩展、性能和数据完整性等考虑，就没采用 Raft 算法，而是采用了 Quorum NWR、失败重传、反熵等机制。这样安排不仅满足了业务的需求，还通过尽可能采用最终一致性方案的方式，实现系统的高性能，降低了成本。

课堂思考

在最后，我给你留了一个思考题，强领导者模型会限制集群的写性能，那你想想看，有什么办法能突破 Raft 集群的写性能瓶颈呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

10 | 一致哈希算法：如何分群，突破集群的“领导者”限制？

你好，我是韩健。

学完前面几讲后，有些同学可能有这样的疑问：如果我们通过 Raft 算法实现了 KV 存储，虽然领导者模型简化了算法实现和共识协商，但写请求只能限制在领导者节点上处理，导致了集群的接入性能约等于单机，那么随着业务发展，集群的性能可能就扛不住了，会造成系统过载和服务不可用，这时该怎么办呢？

其实这是一个非常常见的问题。在我看来，这时我们就要通过分集群，突破单集群的性能限制了。

说到这儿，有同学可能会说了，分集群还不简单吗？加个 Proxy 层，由 Proxy 层处理来自客户端的读写请求，接收到读写请求后，通过对 Key 做哈希找到对应的集群就可以了啊。

是的，哈希算法的确是个办法，但它有个明显的缺点：当需要变更集群数时（比如从 2 个集群扩展为 3 个集群），这时大部分的数据都需要迁移，重新映射，数据的迁移成本是非常高的。那么如何解决哈希算法，数据迁移成本高的痛点呢？答案就是一致哈希（Consistent Hashing）。

为了帮你更好地理解如何通过哈希寻址实现 KV 存储的分集群，我除了会带你了解哈希算法寻址问题的本质之外，还会讲一下一致哈希是如何解决哈希算法数据迁移成本高这个痛点，以及如何实现数据访问的冷热相对均匀。

对你来说，学完本讲内容之后，不仅能理解一致哈希的原理，还能掌握通过一致哈希实现数据访问冷热均匀的实战能力。

老规矩，在正式开始学习之前，我们先看一道思考题。

假设我们有一个由 A、B、C 三个节点组成（为了方便演示，我使用节点来替代集群）的 KV 服务，每个节点存放不同的 KV 数据：

图1

那么，使用哈希算法实现哈希寻址时，到底有哪些问题呢？带着这个问题，让我们开始今天的内容吧。

使用哈希算法有什么问题？

通过哈希算法，每个 key 都可以寻址到对应的服务器，比如，查询 key 是 key-01，计算公式为 hash(key-01) % 3 ，经过计算寻址到了编号为 1 的服务器节点 A（就像图 2 的样子）。

图2

但如果服务器数量发生变化，基于新的服务器数量来执行哈希算法的时候，就会出现路由寻址失败的情况，Proxy 无法找到之前寻址到的那个服务器节点，这是为什么呢？

想象一下，假如 3 个节点不能满足业务需要了，这时我们增加了一个节点，节点的数量从 3 变化为 4，那么之前的 hash(key-01) % 3 = 1，就变成了 hash(key-01) % 4 = X，因为取模运算发生了变化，所以这个 X 大概率不是 1（可能 X 为 2），这时你再查询，就会找不到数据了，因为 key-01 对应的数据，存储在节点 A 上，而不是节点 B：

图3

同样的道理，如果我们需要下线 1 个服务器节点（也就是缩容），也会存在类似的可能查询不到数据的问题。

而解决这个问题的办法，在于我们要迁移数据，基于新的计算公式 hash(key-01) % 4 ，来重新对数据和节点做映射。需要你注意的是，数据的迁移成本是非常高的。

为了便于你理解，我举个例子，对于 1000 万 key 的 3 节点 KV 存储，如果我们增加 1 个节点，变为 4 节点集群，则需要迁移 75% 的数据。

$ go run ./hash.go  -keys 10000000 -nodes 3 -new-nodes 4
74.999980%

从示例代码的输出，你可以看到，迁移成本是非常高昂的，这在实际生产环境中也是无法想象的。

那我们如何通过一致哈希解决这个问题呢？

如何使用一致哈希实现哈希寻址？

一致哈希算法也用了取模运算，但与哈希算法不同的是，哈希算法是对节点的数量进行取模运算，而一致哈希算法是对 2^32 进行取模运算。你可以想象下，一致哈希算法，将整个哈希值空间组织成一个虚拟的圆环，也就是哈希环：

图4

从图 4 中你可以看到，哈希环的空间是按顺时针方向组织的，圆环的正上方的点代表 0，0 点右侧的第一个点代表 1，以此类推，2、3、4、5、6……直到 2^32-1，也就是说 0 点左侧的第一个点代表 2^32-1。

在一致哈希中，你可以通过执行哈希算法（为了演示方便，假设哈希算法函数为“c-hash()”），将节点映射到哈希环上，比如选择节点的主机名作为参数执行 c-hash()，那么每个节点就能确定其在哈希环上的位置了：

图5

当需要对指定 key 的值进行读写的时候，你可以通过下面 2 步进行寻址：

首先，将 key 作为参数执行 c-hash() 计算哈希值，并确定此 key 在环上的位置；

然后，从这个位置沿着哈希环顺时针“行走”，遇到的第一节点就是 key 对应的节点。

为了帮助你更好地理解如何通过一致哈希进行寻址，我举个例子。假设 key-01、key-02、key-03 三个 key，经过哈希算法 c-hash() 计算后，在哈希环上的位置就像图 6 的样子：

图6

那么根据一致哈希算法，key-01 将寻址到节点 A，key-02 将寻址到节点 B，key-03 将寻址到节点 C。讲到这儿，你可能会问：“老韩，那一致哈希是如何避免哈希算法的问题呢？”

别着急，接下来我分别以增加节点和移除节点为例，具体说一说一致哈希是如何避免上面的问题的。假设，现在有一个节点故障了（比如节点 C）：

图7

你可以看到，key-01 和 key-02 不会受到影响，只有 key-03 的寻址被重定位到 A。一般来说，在一致哈希算法中，如果某个节点宕机不可用了，那么受影响的数据仅仅是，会寻址到此节点和前一节点之间的数据。比如当节点 C 宕机了，受影响的数据是会寻址到节点 B 和节点 C 之间的数据（例如 key-03），寻址到其他哈希环空间的数据（例如 key-01），不会受到影响。

那如果此时集群不能满足业务的需求，需要扩容一个节点（也就是增加一个节点，比如 D）：

图8

你可以看到，key-01、key-02 不受影响，只有 key-03 的寻址被重定位到新节点 D。一般而言，在一致哈希算法中，如果增加一个节点，受影响的数据仅仅是，会寻址到新节点和前一节点之间的数据，其它数据也不会受到影响。

让我们一起来看一个例子。使用一致哈希的话，对于 1000 万 key 的 3 节点 KV 存储，如果我们增加 1 个节点，变为 4 节点集群，只需要迁移 24.3% 的数据：

$ go run ./consistent-hash.go  -keys 10000000 -nodes 3 -new-nodes 4
24.301550%

你看，使用了一致哈希后，我们需要迁移的数据量仅为使用哈希算法时的三分之一，是不是大大提升效率了呢？

总的来说，使用了一致哈希算法后，扩容或缩容的时候，都只需要重定位环空间中的一小部分数据。也就是说，一致哈希算法具有较好的容错性和可扩展性。

需要你注意的是，在哈希寻址中常出现这样的问题：客户端访问请求集中在少数的节点上，出现了有些机器高负载，有些机器低负载的情况，那么在一致哈希中，有什么办法能让数据访问分布的比较均匀呢？答案就是虚拟节点。

在一致哈希中，如果节点太少，容易因为节点分布不均匀造成数据访问的冷热不均，也就是说大多数访问请求都会集中少量几个节点上：

图9

你能从图中看到，虽然有 3 个节点，但访问请求主要集中的节点 A 上。那如何通过虚拟节点解决冷热不均的问题呢？

其实，就是对每一个服务器节点计算多个哈希值，在每个计算结果位置上，都放置一个虚拟节点，并将虚拟节点映射到实际节点。比如，可以在主机名的后面增加编号，分别计算 “Node-A-01”“Node-A-02”“Node-B-01”“Node-B-02”“Node-C-01”“Node-C-02”的哈希值，于是形成 6 个虚拟节点：

图10

你可以从图中看到，增加了节点后，节点在哈希环上的分布就相对均匀了。这时，如果有访问请求寻址到“Node-A-01”这个虚拟节点，将被重定位到节点 A。你看，这样我们就解决了冷热不均的问题。

最后我想说的是，可能有同学已经发现了，当节点数越多的时候，使用哈希算法时，需要迁移的数据就越多，使用一致哈希时，需要迁移的数据就越少：

$ go run ./hash.go  -keys 10000000 -nodes 3 -new-nodes 4
74.999980%
$ go run ./hash.go  -keys 10000000 -nodes 10 -new-nodes 11
90.909000%

$ go run ./consistent-hash.go  -keys 10000000 -nodes 3 -new-nodes 4
24.301550%
$ go run ./consistent-hash.go  -keys 10000000 -nodes 10 -new-nodes 11
6.479330%

从示例代码的输出中你可以看到，当我们向 10 个节点集群中增加节点时，如果使用了哈希算法，需要迁移高达 90.91% 的数据，使用一致哈希的话，只需要迁移 6.48% 的数据。

我希望你能注意到这个规律，使用一致哈希实现哈希寻址时，可以通过增加节点数降低节点宕机对整个集群的影响，以及故障恢复时需要迁移的数据量。后续在需要时，你可以通过增加节点数来提升系统的容灾能力和故障恢复效率。

内容小结

以上就是本节课的全部内容了，本节课我主要带你了解了哈希算法的缺点、一致哈希的原理等内容。我希望你明确这样几个重点。

一致哈希是一种特殊的哈希算法，在使用一致哈希算法后，节点增减变化时只影响到部分数据的路由寻址，也就是说我们只要迁移部分数据，就能实现集群的稳定了。

当节点数较少时，可能会出现节点在哈希环上分布不均匀的情况。这样每个节点实际占据环上的区间大小不一，最终导致业务对节点的访问冷热不均。需要你注意的是，这个问题可以通过引入更多的虚拟节点来解决。

最后我想说的是，一致哈希本质上是一种路由寻址算法，适合简单的路由寻址场景。比如在 KV 存储系统内部，它的特点是简单，不需要维护路由信息。

课堂思考

Raft 集群具有容错能力，能容忍少数的节点故障，那么在多个 Raft 集群组成的 KV 系统中，如何设计一致哈希，实现当某个集群的节点出现了故障时，整个系统还能稳定运行呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

11 | Gossip协议：流言蜚语，原来也可以实现一致性

你好，我是韩健。

有一部分同学的业务在可用性上比较敏感，比如监控主机和业务运行的告警系统。这个时候，相信你希望自己的系统能在极端情况下（比如集群中只有一个节点在运行）也能运行。回忆了二阶段提交协议和 Raft 算法之后，你发现它们都需要全部节点或者大多数节点正常运行，才能稳定运行，那么它们就不适合了。而根据 Base 理论，你需要实现最终一致性，怎么样才能实现最终一致性呢？

在我看来，你可以通过 Gossip 协议实现这个目标。

Gossip 协议，顾名思义，就像流言蜚语一样，利用一种随机、带有传染性的方式，将信息传播到整个网络中，并在一定时间内，使得系统内的所有节点数据一致。对你来说，掌握这个协议不仅能很好地理解这种最常用的，实现最终一致性的算法，也能在后续工作中得心应手地实现数据的最终一致性。

为了帮你彻底吃透 Gossip 协议，掌握实现最终一致性的实战能力，我会先带你了解 Gossip 三板斧，因为这是 Gossip 协议的核心内容，也是实现最终一致性的常用三种方法。然后以实际系统为例，带你了解在实际系统中是如何实现反熵的。接下来，就让我们开始今天的内容吧。

Gossip 的三板斧

Gossip 的三板斧分别是：直接邮寄（Direct Mail）、反熵（Anti-entropy）和谣言传播（Rumor mongering）。

直接邮寄：就是直接发送更新数据，当数据发送失败时，将数据缓存下来，然后重传。从图中你可以看到，节点 A 直接将更新数据发送给了节点 B、D。

图1

在这里我想补充一点，直接邮寄虽然实现起来比较容易，数据同步也很及时，但可能会因为缓存队列满了而丢数据。也就是说，只采用直接邮寄是无法实现最终一致性的，这一点我希望你能注意到。

那如何实现最终一致性呢？答案就是反熵。本质上，反熵是一种通过异步修复实现最终一致性的方法（关于异步修复，你可以回顾一下04 讲）。常见的最终一致性系统（比如 Cassandra），都实现了反熵功能。

反熵指的是集群中的节点，每隔段时间就随机选择某个其他节点，然后通过互相交换自己的所有数据来消除两者之间的差异，实现数据的最终一致性：

图2

从图 2 中你可以看到，节点 A 通过反熵的方式，修复了节点 D 中缺失的数据。那具体怎么实现的呢？

其实，在实现反熵的时候，主要有推、拉和推拉三种方式。我将以修复下图中，2 个数据副本的不一致为例，具体带你了解一下。

图3

推方式，就是将自己的所有副本数据，推给对方，修复对方副本中的熵：

图4

拉方式，就是拉取对方的所有副本数据，修复自己副本中的熵：

图5

理解了推和拉之后，推拉这个方式就很好理解了，这个方式就是同时修复自己副本和对方副本中的熵：

图6

也许有很多同学，会觉得反熵是一个很奇怪的名词。其实，你可以这么来理解，反熵中的熵是指混乱程度，反熵就是指消除不同节点中数据的差异，提升节点间数据的相似度，降低熵值。

另外需要你注意的是，因为反熵需要节点两两交换和比对自己所有的数据，执行反熵时通讯成本会很高，所以我不建议你在实际场景中频繁执行反熵，并且可以通过引入校验和（Checksum）等机制，降低需要对比的数据量和通讯消息等。

虽然反熵很实用，但是执行反熵时，相关的节点都是已知的，而且节点数量不能太多，如果是一个动态变化或节点数比较多的分布式环境（比如在 DevOps 环境中检测节点故障，并动态维护集群节点状态），这时反熵就不适用了。那么当你面临这个情况要怎样实现最终一致性呢？答案就是谣言传播。

谣言传播，广泛地散播谣言，它指的是当一个节点有了新数据后，这个节点变成活跃状态，并周期性地联系其他节点向其发送新数据，直到所有的节点都存储了该新数据：

图7

从图中你可以看到，节点 A 向节点 B、D 发送新数据，节点 B 收到新数据后，变成活跃节点，然后节点 B 向节点 C、D 发送新数据。其实，谣言传播非常具有传染性，它适合动态变化的分布式系统。

如何使用 Anti-entropy 实现最终一致

在分布式存储系统中，实现数据副本最终一致性，最常用的方法就是反熵了。为了帮你彻底理解和掌握在实际环境中实现反熵的方法，我想以自研 InfluxDB 的反熵实现为例，具体带你了解一下。

在自研 InfluxDB 中，一份数据副本是由多个分片组成的，也就是实现了数据分片，三节点三副本的集群，就像下图的样子：

图8

反熵的目标是确保每个 DATA 节点拥有元信息指定的分片，而且不同节点上，同一分片组中的分片都没有差异。比如说，节点 A 要拥有分片 Shard1 和 Shard2，而且，节点 A 的 Shard1 和 Shard2，与节点 B、C 中的 Shard1 和 Shard2，是一样的。

那么，在 DATA 节点上，存在哪些数据缺失的情况呢？也就说，我们需要解决哪些问题呢？

我们将数据缺失，分为这样 2 种情况。

缺失分片：也就是说，在某个节点上整个分片都丢失了。

节点之间的分片不一致：也就是说，节点上分片都存在，但里面的数据不一样，有数据丢失的情况发生。

第一种情况修复起来不复杂，我们只需要将分片数据，通过 RPC 通讯，从其他节点上拷贝过来就可以了：

图9

你需要注意的是第二种情况，因为第二种情况修复起来要复杂一些。我们需要设计一个闭环的流程，按照一个顺序修复，执行完流程后，也就是实现了一致性了。具体是怎么设计的呢？

它是按照一定顺序来修复节点的数据差异，先随机选择一个节点，然后循环修复，每个节点生成自己节点有、下一个节点没有的差异数据，发送给下一个节点，进行修复（为了方便演示，假设 Shard1、Shard2 在各节点上是不一致的）：

图10

从图中你可以看到，数据修复的起始节点为节点 A，数据修复是按照顺时针顺序，循环修复的。需要你注意的是，最后节点 A 又对节点 B 的数据执行了一次数据修复操作，因为只有这样，节点 C 有、节点 B 缺失的差异数据，才会同步到节点 B 上。

学到这里你可以看到，在实现反熵时，实现细节和最初算法的约定有些不同。比如，不是一个节点不断随机选择另一个节点，来修复副本上的熵，而是设计了一个闭环的流程，一次修复所有节点的副本数据不一致。

为什么这么设计呢？因为我们希望能在一个确定的时间范围内实现数据副本的最终一致性，而不是基于随机性的概率，在一个不确定的时间范围内实现数据副本的最终一致性。

这样做能减少数据不一致对监控视图影响的时长。而我希望你能注意到，技术是要活学活用的，要能根据场景特点权衡妥协，设计出最适合这个场景的系统功能。最后需要你注意的是，因为反熵需要做一致性对比，很消耗系统性能，所以建议你将是否启用反熵功能、执行一致性检测的时间间隔等，做成可配置的，能在不同场景中按需使用。

内容小结

以上就是本节课的全部内容了，本节课我主要带你了解了 Gossip 协议、如何在实际系统中实现反熵等。我希望你明确这样几个重点：

作为一种异步修复、实现最终一致性的协议，反熵在存储组件中应用广泛，比如 Dynamo、InfluxDB、Cassandra，我希望你能彻底掌握反熵的实现方法，在后续工作中，需要实现最终一致性时，优先考虑反熵。

因为谣言传播具有传染性，一个节点传给了另一个节点，另一个节点又将充当传播者，传染给其他节点，所以非常适合动态变化的分布式系统，比如 Cassandra 采用这种方式动态管理集群节点状态。

在实际场景中，实现数据副本的最终一致性时，一般而言，直接邮寄的方式是一定要实现的，因为不需要做一致性对比，只是通过发送更新数据或缓存重传，来修复数据的不一致，性能损耗低。在存储组件中，节点都是已知的，一般采用反熵修复数据副本的一致性。当集群节点是变化的，或者集群节点数比较多时，这时要采用谣言传播的方式，同步更新数据，实现最终一致。

课堂思考

既然使用反熵实现最终一致性时，需要通过一致性检测发现数据副本的差异，如果每次做一致性检测时都做数据对比的话，肯定是比较消耗性能的，那有什么办法降低一致性检测时的性能消耗呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

12 | Quorum NWR算法：想要灵活地自定义一致性，没问题！

你好，我是韩健。

不知道你在工作中有没有遇到这样的事儿：你开发实现了一套 AP 型的分布式系统（我在04 讲提到了 AP 型系统的特点，你可以回顾一下），实现了最终一致性。业务也接入了，运行正常，一起看起来都那么美好。

可是，突然有同事说，我们要拉这几个业务的数据做实时分析，希望数据写入成功后，就能立即读取到新数据，也就是要实现强一致性（Werner Vogels 提出的客户端侧一致性模型，不是指线性一致性），数据更改后，要保证用户能立即查询到。这时你该怎么办呢？首先你要明确最终一致性和强一致性有什么区别。

强一致性能保证写操作完成后，任何后续访问都能读到更新后的值；

最终一致性只能保证如果对某个对象没有新的写操作了，最终所有后续访问都能读到相同的最近更新的值。也就是说，写操作完成后，后续访问可能会读到旧数据。

其实，在我看来，为了一个临时的需求，我们重新开发一套系统，或者迁移数据到新系统，肯定是不合适的。因为工作量比较大，而且耗时也长，而我建议你通过 Quorum NWR 解决这个问题。

也就是说，在原有系统上开发实现一个新功能，就可以满足业务同学的需求了。因为通过 Quorum NWR，你可以自定义一致性级别，通过临时调整写入或者查询的方式，当 W + R > N 时，就可以实现强一致性了。

其实，在 AP 型分布式系统中（比如 Dynamo、Cassandra、InfluxDB 企业版的 DATA 节点集群），Quorum NWR 是通常都会实现的一个功能，很常用。对你来说，掌握 Quorum NWR，不仅是掌握一种常用的实现一致性的方法，更重要的是，后续用户可以根据业务的特点，灵活地指定一致性级别。

为了帮你掌握 Quorum NWR，除了带你了解它的原理外，我还会以 InfluxDB 企业版的实现为例，带你看一下它在实际场景中的实现，这样你可以在理解原理的基础上，掌握 Quorum NWR 的实战技巧。

首先，你需要了解 Quorum NWR 中的三个要素，N、W、R。因为它们是 Quorum NWR 的核心内容，我们就是通过组合这三个要素，实现自定义一致性级别的。

Quorum NWR 的三要素

N 表示副本数，又叫做复制因子（Replication Factor）。也就是说，N 表示集群中同一份数据有多少个副本，就像下图的样子：

图1

从图中你可以看到，在这个三节点的集群中，DATA-1 有 2 个副本，DATA-2 有 3 个副本，DATA-3 有 1 个副本。也就是说，副本数可以不等于节点数，不同的数据可以有不同的副本数。

需要你注意的是，在实现 Quorum NWR 的时候，你需要实现自定义副本的功能。也就是说，用户可以自定义指定数据的副本数，比如，用户可以指定 DATA-1 具有 2 个副本，DATA-2 具有 3 个副本，就像图中的样子。

当我们指定了副本后，就可以对副本数据进行读写操作了。那么这么多副本，你要如何执行读写操作呢？先来看一看写操作，也就是 W。

W，又称写一致性级别（Write Consistency Level），表示成功完成 W 个副本更新，才完成写操作：

图2

从图中你可以看到，DATA-2 的写副本数为 2，也就说，对 DATA-2 执行写操作时，完成了 2 个副本的更新（比如节点 A、C），才完成写操作。

那么有的同学会问了，DATA-2 有 3 个数据副本，完成了 2 副本的更新，就完成了写操作，那么如何实现强一致性呢？如果读到了第三个数据副本（比如节点 B），不就可能无法读到更新后的值了吗？别急，我讲完如何执行读操作后，你就明白了。

R，又称读一致性级别（Read Consistency Level），表示读取一个数据对象时需要读 R 个副本。你可以这么理解，读取指定数据时，要读 R 副本，然后返回 R 个副本中最新的那份数据：

图3

从图中你可以看到，DATA-2 的读副本数为 2。也就是说，客户端读取 DATA-2 的数据时，需要读取 2 个副本中的数据，然后返回最新的那份数据。

这里需要你注意的是，无论客户端如何执行读操作，哪怕它访问的是写操作未强制更新副本数据的节点（比如节点 B），但因为 W(2) + R(2) > N(3)，也就是说，访问节点 B，执行读操作时，因为要读 2 份数据副本，所以除了节点 B 上的 DATA-2，还会读取节点 A 或节点 C 上的 DATA-2，就像上图的样子（比如节点 C 上的 DATA-2），而节点 A 和节点 C 的 DATA-2 数据副本是强制更新成功的。这个时候，返回给客户端肯定是最新的那份数据。

你看，通过设置 R 为 2，即使读到前面问题中的第三份副本数据（比如节点 B），也能返回更新后的那份数据，实现强一致性了。

除此之外，关于 NWR 需要你注意的是，N、W、R 值的不同组合，会产生不同的一致性效果，具体来说，有这么两种效果：

当 W + R > N 的时候，对于客户端来讲，整个系统能保证强一致性，一定能返回更新后的那份数据。

当 W + R < N 的时候，对于客户端来讲，整个系统只能保证最终一致性，可能会返回旧数据。

你可以看到，Quorum NWR 的原理并不复杂，也相对比较容易理解，但在这里，我想强调一下，掌握它的关键在于如何根据不同的场景特点灵活地实现 Quorum NWR，所以接下来，我带你具体问题具体分析，以 InfluxDB 企业版为例讲解一下。

如何实现 Quorum NWR？

在 InfluxDB 企业版中，可以在创建保留策略时，设置指定数据库（Database）对应的副本数，具体的命令，就像下面的样子：

create retention policy “rp_one_day” on “telegraf” duration 1d replication 3

通过 replication 参数，指定了数据库 telegraf 对应的副本数为 3。

需要你注意的，在 InfluxDB 企业版中，副本数不能超过节点数据。你可以这么理解，多副本的意义在于冗余备份，如果副本数超过节点数，就意味着在一个节点上会存在多个副本，那么这时冗余备份的意义就不大了。比如机器故障时，节点上的多个副本是同时被影响的。

InfluxDB 企业版，支持“any、one、quorum、all”4 种写一致性级别，具体的含义是这样的。

any：任何一个节点写入成功后，或者接收节点已将数据写入 Hinted-handoff 缓存（也就是写其他节点失败后，本地节点上缓存写失败数据的队列）后，就会返回成功给客户端。

one：任何一个节点写入成功后，立即返回成功给客户端，不包括成功写入到 Hinted-handoff 缓存。

quorum：当大多数节点写入成功后，就会返回成功给客户端。此选项仅在副本数大于 2 时才有意义，否则等效于 all。

all：仅在所有节点都写入成功后，返回成功。

我想强调一下，对时序数据库而言，读操作常会拉取大量数据，查询性能是挑战，是必须要考虑优化的，因此，在 InfluxDB 企业版中，不支持读一致性级别，只支持写一致性级别。另外，我们可以通过设置写一致性级别为 all，来实现强一致性。

你看，如果我们像 InfluxDB 企业版这样，实现了 Quorum NWR，那么在业务临时需要实现强一致性时，就可以通过设置写一致性级别为 all，来实现了。

内容小结

以上就是本节课的全部内容了，本节课我主要带你了解了 Quorum NWR 的原理、InfluxDB 企业版的 Quorum NWR 实现。我希望你明确这样几个重点。

一般而言，不推荐副本数超过当前的节点数，因为当副本数据超过节点数时，就会出现同一个节点存在多个副本的情况。当这个节点故障时，上面的多个副本就都受到影响了。

当 W + R > N 时，可以实现强一致性。另外，如何设置 N、W、R 值，取决于我们想优化哪方面的性能。比如，N 决定了副本的冗余备份能力；如果设置 W = N，读性能比较好；如果设置 R = N，写性能比较好；如果设置 W = (N + 1) / 2、R = (N + 1) / 2，容错能力比较好，能容忍少数节点（也就是 (N - 1) / 2）的故障。

最后，我想说的是，Quorum NWR 是非常实用的一个算法，能有效弥补 AP 型系统缺乏强一致性的痛点，给业务提供了按需选择一致性级别的灵活度，建议你的开发实现 AP 型系统时，也实现 Quorum NWR。

课堂思考

我提到实现 Quorum NWR 时，需要实现自定义副本的能力，那么，一般设置几个副本就可以了，为什么呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

13 | PBFT算法：有人作恶，如何达成共识？

你好，我是韩健。

学完了01 讲的拜占庭将军问题之后，有同学在留言中表达了自己的思考和困惑：口信消息型拜占庭问题之解在实际项目中是如何落地的呢？先给这位同学点个赞，很棒！你能在学习的同时思考落地实战。

不过事实上，它很难在实际项目落地，因为口信消息型拜占庭问题之解是一个非常理论化的算法，没有和实际场景结合，也没有考虑如何在实际场景中落地和实现。

比如，它实现的是在拜占庭错误场景下，忠将们如何在叛徒干扰时，就一致行动达成共识。但是它并不关心结果是什么，这会出现一种情况：现在适合进攻，但将军们达成的最终共识却是撤退。

很显然，这不是我们想要的结果。因为在实际场景中，我们需要就提议的一系列值（而不是单值），即使在拜占庭错误发生的时候也能被达成共识。那你要怎么做呢？答案就是掌握 PBFT 算法。

PBFT 算法非常实用，是一种能在实际场景中落地的拜占庭容错算法，它在区块链中应用广泛（比如 Hyperledger Sawtooth、Zilliqa）。为了帮助你更好地理解 PBFT 算法，在今天的内容中，我除了带你了解 PBFT 达成共识的原理之外，还会介绍口信消息型拜占庭问题之解的局限。相信学习完本讲内容后，你不仅能理解 PBFT 达成共识的基本原理，还能理解算法背后的演化和改进。

老规矩，在开始今天的学习之前，咱们先看一道思考题：

假设苏秦再一次带队抗秦，这一天，苏秦和 4 个国家的 4 位将军赵、魏、韩、楚商量军机要事，结果刚商量完没多久苏秦就接到了情报，情报上写道：联军中可能存在一个叛徒。这时，苏秦要如何下发作战指令，保证忠将们正确、一致地执行下发的作战指令，而不是被叛徒干扰呢？

带着这个问题，我们正式进入今天的学习。

首先，咱们先来研究一下，为什么口信消息型拜占庭问题之解很难在实际场景中落地，除了我在开篇提到的非常理论化，没有和实际的需求结合之外，还有其他的原因么？

其实，这些问题是后续众多拜占庭容错算法在努力改进和解决的，理解了这些问题，能帮助你更好地理解后来的拜占庭容错算法（包括 PBFT 算法）。

口信消息型拜占庭问题之解的局限

我想说的是，这个算法有个非常致命的缺陷。如果将军数为 n、叛将数为 f，那么算法需要递归协商 f+1 轮，消息复杂度为 O(n ^ (f + 1))，消息数量指数级暴增。你可以想象一下，如果叛将数为 64，消息数已经远远超过 int64 所能表示的了，这是无法想象的，肯定不行啊。

另外，尽管对于签名消息，不管叛将数（比如 f）是多少，经过 f + 1 轮的协商，忠将们都能达成一致的作战指令，但是这个算法同样存在“理论化”和“消息数指数级暴增”的痛点。

讲到这儿，你肯定明白为什么这个算法很难在实际场景中落地了。可技术是不断发展的，算法也是在解决实际场景问题中不断改进的。那么 PBFT 算法的原理是什么呢？为什么它能在实际场景中落地呢？

PBFT 是如何达成共识的？

我们先来看看如何通过 PBFT 算法，解决苏秦面临的共识问题。先假设苏秦制定的作战指令是进攻，而楚是叛徒（为了演示方便）：

图1

需要你注意的是，所有的消息都是签名消息，也就是说，消息发送者的身份和消息内容都是无法伪造和篡改的（比如，楚无法伪造一个假装来自赵的消息）。

首先，苏秦联系赵，向赵发送包含作战指令“进攻”的请求（就像下图的样子）。

图2

当赵接收到苏秦的请求之后，会执行三阶段协议（Three-phase protocol）。

赵将进入预准备（Pre-prepare）阶段，构造包含作战指令的预准备消息，并广播给其他将军（魏、韩、楚）。

图3

那么在这里，我想问你一个问题：魏、韩、楚，收到消息后，能直接执行指令吗？

答案是不能，因为他们不能确认自己接收到指令和其他人接收到的指令是相同的。比如，赵可能是叛徒，赵收到了 2 个指令，分别是“进攻”和“准备 30 天的粮草”，然后他给魏发送的是“进攻”，给韩、楚发送的是“准备 30 天粮草”，这样就会出现无法一致行动的情况。那么他们具体怎么办呢？我接着说一下。

接收到预准备消息之后，魏、韩、楚将进入准备（Prepare）阶段，并分别广播包含作战指令的准备消息给其他将军。比如，魏广播准备消息给赵、韩、楚（如图所示）。为了方便演示，我们假设叛徒楚想通过不发送消息，来干扰共识协商（你能看到，图中的楚是没有发送消息的）。

图4

然后，当某个将军收到 2f 个一致的包含作战指令的准备消息后，会进入提交（Commit）阶段（这里的 2f 包括自己，其中 f 为叛徒数，在我的演示中是 1）。在这里，我也给你提一个问题：这个时候该将军（比如魏）可以直接执行指令吗？

答案还是不能，因为魏不能确认赵、韩、楚是否收到了 2f 个一致的包含作战指令的准备消息。也就是说，魏这时无法确认赵、韩、楚是否准备好了执行作战指令。那么怎么办呢？别着急，咱们继续往下看。

进入提交阶段后，各将军分别广播提交消息给其他将军，也就是告诉其他将军，我已经准备好了，可以执行指令了。

图5

最后，当某个将军收到 2f + 1 个验证通过的提交消息后（包括自己，其中 f 为叛徒数，在我的演示中为 1），也就是说，大部分的将军们已经达成共识，这时可以执行作战指令了，那么该将军将执行苏秦的作战指令，执行完毕后发送执行成功的消息给苏秦。

图6

最后，当苏秦收到 f+1 个相同的响应（Reply）消息时，说明各位将军们已经就作战指令达成了共识，并执行了作战指令（其中 f 为叛徒数，在我的演示中为 1）。

你看，经过了三轮协商，是不是就指定的作战指令达成了共识，并执行了作战指令了呢？

在这里，苏秦采用的就是简化版的 PBFT 算法。在这个算法中：

你可以将赵、魏、韩、楚理解为分布式系统的四个节点，其中赵是主节点（Primary node），魏、韩、楚是从节点（Secondary node）；

将苏秦理解为业务，也就是客户端；

将消息理解为网络消息；

将作战指令“进攻”，理解成客户端提议的值，也就是希望被各节点达成共识，并提交给状态机的值。

在这里我想说的是， PBFT 算法是通过签名（或消息认证码 MAC）约束恶意节点的行为，也就是说，每个节点都可以通过验证消息签名确认消息的发送来源，一个节点无法伪造另外一个节点的消息。最终，基于大多数原则（2f + 1）实现共识的。

需要你注意的是，最终的共识是否达成，客户端是会做判断的，如果客户端在指定时间内未收到请求对应的 f + 1 相同响应，就认为集群出故障了，共识未达成，客户端会重新发送请求。

另外需要你注意的是，PBFT 算法通过视图变更（View Change）的方式，来处理主节点作恶，当发现主节点在作恶时，会以“轮流上岗”方式，推举新的主节点。

最后我想说的是，尽管 PBFT 算法相比口信消息型拜占庭之解已经有了很大的优化，将消息复杂度从 O(n ^ (f + 1)) 降低为 O(n ^ 2)，能在实际场景中落地，并解决实际的共识问题。但 PBFT 还是需要比较多的消息。比如在 13 节点集群中（f 为 4）。

请求消息：1

预准备消息：3f = 12

准备消息：3f * (3f - f) = 96

提交消息：(3f - f + 1) * (3f + 1)= 117

回复消息：3f - 1 = 11

也就是说，一次共识协商需要 237 个消息，你看，消息数还是蛮多的，所以我推荐你，在中小型分布式系统中使用 PBFT 算法。

内容小结

以上就是本节课的全部内容了，本节课我主要带你了解了口信消息型拜占庭问题之解的局限和 PBFT 的原理，我希望你明确这样几个重点。

不管口信消息型拜占庭问题之解，还是签名消息型拜占庭问题之解，都是非常理论化的，未考虑实际场景的需求，而且协商成本非常高，指数级的消息复杂度是很难在实际场景中落地，和解决实际场景问题的。

PBFT 算法是通过签名（或消息认证码 MAC）约束恶意节点的行为，采用三阶段协议，基于大多数原则达成共识的。另外，与口信消息型拜占庭问题之解（以及签名消息型拜占庭问题之解）不同的是，PBFT 算法实现的是一系列值的共识，而不是单值的共识。

最后，我想说的是，相比 Raft 算法完全不适应有人作恶的场景，PBFT 算法能容忍 (n - 1)/3 个恶意节点 (也可以是故障节点)。另外，相比 PoW 算法，PBFT 的优点是不消耗算力，所以在日常实践中，PBFT 比较适用于相对“可信”的场景中，比如联盟链。

需要你注意的是，PBFT 算法与 Raft 算法类似，也存在一个“领导者”（就是主节点），同样，集群的性能也受限于“领导者”。另外，O(n ^ 2) 的消息复杂度，以及随着消息数的增加，网络时延对系统运行的影响也会越大，这些都限制了运行 PBFT 算法的分布式系统的规模，也决定了 PBFT 算法适用于中小型分布式系统。

课堂思考

当客户端在收到了 f + 1 个结果，就认为共识达成了，那么为什么这个值不能小于 f + 1 呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

14 | PoW算法：有办法黑比特币吗？

你好，我是韩健。

谈起比特币，你应该再熟悉不过了，比特币是基于区块链实现的，而区块链运行在因特网上，这就存在有人试图作恶的情况。学完01 讲和13 讲之后，有些同学可能已经发现了，口信消息型拜占庭问题之解、PBFT 算法虽然能防止坏人作恶，但只能防止少数的坏人作恶，也就是 (n - 1) / 3 个坏人 (其中 n 为节点数)。可如果区块链也只能防止一定比例的坏人作恶，那就麻烦了，因为坏人可以不断增加节点数，轻松突破 (n - 1) / 3 的限制。

那区块链是如何改进这个问题的呢？答案就是 PoW 算法。

在我看来，区块链通过工作量证明（Proof of Work）增加了坏人作恶的成本，以此防止坏人作恶。比如，如果坏人要发起 51% 攻击，需要控制现网 51% 的算力，成本是非常高昂的。为啥呢？因为根据 Cryptoslate 估算，对比特币进行 51% 算力攻击需要上百亿人民币！

那么为了帮你更好地理解和掌握 PoW 算法，我会详细讲解它的原理和 51% 攻击的本质。希望让你在理解 PoW 算法的同时，也了解 PoW 算法的局限。

首先我来说说 PoW 的原理，换句话说，就是 PoW 是如何运行的。

如何理解工作量证明？

什么是工作量证明 (Proof Of Work，简称 PoW) 呢？你可以这么理解：就是一份证明，用来确认你做过一定量的工作。比如，你的大学毕业证书就是一份工作量证明，证明你通过 4 年的努力完成了相关课程的学习。

那么回到计算机世界，具体来说就是，客户端需要做一定难度的工作才能得出一个结果，验证方却很容易通过结果来检查出客户端是不是做了相应的工作。

比如小李来 BAT 面试，说自己的编程能力很强，那么他需要做一定难度的工作（比如做个编程题）。根据做题结果，面试官可以判断他是否适合这个岗位。你看，小李做个编程题，面试官核验做题结果，这就是一个现实版的工作量证明。

具体的工作量证明过程，就像下图中的样子：

请求方做了一些运算，解决了某个问题，然后把运算结果发送给验证方，进行核验，验证方根据运算结果，就能判断请求方是否做了相关的工作。

需要你注意的是，这个算法具有不对称性，也就是说，工作对于请求方是有难度的，对于验证方则是比较简单的，易于验证的。

既然工作量证明是通过指定的结果，来证明自己做过了一定量的工作。那么在区块链的 PoW 算法中需要做哪些工作呢？答案是哈希运算。

区块链是通过执行哈希运算，然后通过运算后的结果值，证明自己做过了相关工作。为了帮你更好地理解哈希运算，在介绍哈希运算之前，咱们先来聊一聊哈希函数。

哈希函数（Hash Function），也叫散列函数。就是说，你输入一个任意长度的字符串，哈希函数会计算出一个长度相同的哈希值。假设我们对任意长度字符串（比如"geektime"）执行 SHA256 哈希运算，就会得到一个 32 字节的哈希值，就像下面的样子：

$ echo -n "geektime" | sha256sum
bb2f0f297fe9d3b8669b6b4cec3bff99b9de596c46af2e4c4a504cfe1372dc52  -

那我们如何通过哈希函数进行哈希运算，从而证明工作量呢？为了帮你理解这部分内容，我举个具体的例子。

我们给出的工作量要求是，基于一个基本的字符串（比如"geektime"），你可以在这个字符串后面添加一个整数值，然后对变更后（添加整数值) 的字符串进行 SHA256 哈希运算，如果运算后得到的哈希值（16 进制形式）是以"0000"开头的，就验证通过。为了达到这个工作量证明的目标，我们需要不停地递增整数值，一个一个试，对得到的新字符串进行 SHA256 哈希运算。

按照这个规则，我们需要经过 35024 次计算，才能找到恰好前 4 位为 0 的哈希值。

"geektime0" => 01f28c5df06ef0a575fd0e529be9a6f73b1290794762de014ec84182081e118e
"geektime1" => a2567c06fdb5775cb1e3ce17b72754cf146fcc6da75c8f1d87d7ab6a1b8c4523
...
"geektime35022" =>
8afc85049a9e92fe0b6c98b02b27c09fb869fbfe273d0ab84ad8c5ac17b8627e
"geektime35023" =>
0000ec5927ba10ea45a6822dcc205050ae74ae1ad2d9d41e978e1ec9762dc404

通过这个示例你可以看到，工作量证明是通过执行哈希运算，经过一段时间的计算后，得到符合条件的哈希值。也就是说，可以通过这个哈希值，来证明我们的工作量。

关于这个规则，我也想多说几句，这个规则不是固定的，在实际场景中，你可以根据场景特点，制定不同的规则，比如，你可以试试分别运行多少次，才能找到恰好前 3 位和前 5 位为 0 的哈希值。

现在，你对工作量证明的原理应该有一定的了解了，那么有同学肯定好奇了，在区块链中是如何实现工作量证明的呢？

区块链如何实现 PoW 算法的？

区块链也是通过 SHA256 来执行哈希运算的，通过计算出符合指定条件的哈希值，来证明工作量的。因为在区块链中，PoW 算法是基于区块链中的区块信息，进行哈希运算的，所以我先带你回顾一下区块链的相关知识。

区块链的区块，是由区块头、区块体 2 部分组成的，就像下图中的样子。

区块头（Block Head）：区块头主要由上一个区块的哈希值、区块体的哈希值、4 字节的随机数（nonce）等组成的。

区块体（Block Body）：区块包含的交易数据，其中的第一笔交易是 Coinbase 交易，这是一笔激励矿工的特殊交易。

我想说的是，拥有 80 字节固定长度的区块头，就是用于区块链工作量证明的哈希运算中输入字符串，而且通过双重 SHA256 哈希运算（也就是对 SHA256 哈希运算的结果，再执行一次哈希运算），计算出的哈希值，只有小于目标值（target），才是有效的，否则哈希值是无效的，必须重算。

学到这儿你可以看到，在区块链中是通过对区块头执行 SHA256 哈希运算，得到小于目标值的哈希值，来证明自己的工作量的。

计算出符合条件的哈希值后，矿工就会把这个信息广播给集群中所有其他节点，其他节点验证通过后，会将这个区块加入到自己的区块链中，最终形成一串区块链，就像下图的样子：

最后，我想说的是，算力越强，系统大概率会越先计算出这个哈希值。这也就意味着，如果坏人们掌握了 51% 的算力，就可以发起 51% 攻击，比如，实现双花（Double Spending），也就是说，同一份钱花 2 次。

具体说的话，就是攻击者掌握了较多的算力，能挖掘一条比原链更长的攻击链，并将攻击链向全网广播，这时呢，按照约定，节点将接受更长的链，也就是攻击链，丢弃原链。就像下图的样子：

需要你注意的是，即使攻击者只有 30% 的算力，他也有可能连续计算出多个区块的哈希值，挖掘出更长的攻击链，发动攻击； 另外，即使攻击者拥有 51% 的算力，他也有可能半天无法计算出一个区块的哈希值，也就是攻击失败。也就是说，能否计算出符合条件的哈希值，有一定的概率性，但长久来看，攻击者攻击成功的概率等同于攻击者算力的权重。

内容小结

以上就是本节课的全部内容了，本节课我主要带你了解了 PoW 算法的原理，和 51% 攻击，我希望你明确这样几个重点。

在比特币的区块链中，PoW 算法，是通过 SHA256 进行哈希运算，计算出符合指定条件的哈希值，来证明工作量的。

51% 攻击，本质是因为比特币的区块链约定了“最长链胜出，其它节点在这条链基础上扩展”，攻击者可以通过优势算力实现对最长链的争夺。

除了通过 PoW 算法，增加坏人作恶的成本，比特币还通过“挖矿得币”奖励好人，最终保持了整个系统的运行稳定。

因为本讲是拜占庭容错算法的最后一讲，我想多说几句：学完了 01 讲的同学，应该还记得，我们提到 Raft 算法是非拜占庭容错算法。那么如果我们把 Raft 算法用于拜占庭场景中，会怎么样呢？

比如，在比特币中，我们采用了 Raft 算法实现共识，而不是基于 PoW 算法的区块链，那么，就会出现这样的情况，当恶意节点当选为领导者后，他可以不断地告诉其他节点，这些比特币都是我的，按照 Raft 的约定，其他节点也就只能接受这种情况，谁让恶意节点是领导者呢？最终就会出现，所有的比特币都被恶意节点盗走的情况，完全乱套了。

另外我想说的是，因为拜占庭容错算法（比如 PoW 算法、PBFT 算法），能容忍一定比例的作恶行为，所以它在相对开放的场景中应用广泛，比如公链、联盟链。非拜占庭容错算法（比如 Raft）无法对作恶行为进行容错，主要用于封闭、绝对可信的场景中，比如私链、公司内网的 DevOps 环境。我希望你能准确理解 2 类算法之间的差异，根据场景特点，选择合适的算法，保障业务高效、稳定的运行。

课堂思考

既然，我提了如何通过计算得到"0000"开头的哈希值，来做实现工作量证明，那么你不妨思考下，如果约定是更多“0”开头的哈希值，比如“00000000”，工作量是增加了还是减少了，为什么呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

15 | ZAB协议：如何实现操作的顺序性？

你好，我是韩健。

很多同学应该使用过 ZooKeeper，它是一个开源的分布式协调服务，比如你可以使用它进行配置管理、名字服务等等。在 ZooKeeper 中，数据是以节点的形式存储的。如果你要用 ZooKeeper 做配置管理，那么就需要在里面创建指定配置，假设创建节点"/geekbang"和"/geekbang/time"，步骤如下：

[zk: localhost:2181(CONNECTED) 7] create /geekbang 123
Created /geekbang
[zk: localhost:2181(CONNECTED) 8] create /geekbang/time 456
Created /geekbang/time

我们分别创建了配置"/geekbang" 和"/geekbang/time"，对应的值分别为 123 和 456。那么在这里我提个问题：你觉得在 ZooKeeper 中，能用兰伯特的 Multi-Paxos 实现各节点数据的共识和一致吗？

当然不行。因为兰伯特的 Multi-Paxos，虽然能保证达成共识后的值不再改变，但它不管关心达成共识的值是什么，也无法保证各值（也就是操作）的顺序性。这是为什么呢？这个问题是 ZAB 协议着力解决的，也是理解 ZAB 协议的关键。

不过，虽然大家都在提 ZAB 协议，但是在我看来，ZAB 协议和 ZooKeeper 代码耦合在一起，也就是说，你是无法单独使用 ZAB 协议的，所以一般而言，只需要理解 ZAB 协议的架构和基础原理就可以了，不需要对代码和细节做太多的深究。所以，我会从 ZAB 协议的最核心设计目标（如何实现操作的顺序性）出发，带你了解它的基础原理。

为什么 Multi-Paxos 无法实现操作顺序性？

兰伯特的 Multi-Paxos 解决的是一系列值如何达成共识的问题，它关心的是，对于指定序号的位置，最多只有一个指令（Command）会被选定，但它不关心选定的是哪个指令，也就是说，它不关心指令的顺序性（也就是操作的顺序性）。

这么说可能比较抽象，为了方便你理解，我举个具体的例子演示一下（一个 3 节点的 Multi-Paxos 集群），为了演示方便，我们假设当前所有节点被选定的指令的最大序号为 100，也就是说，新提议的指令对应的序号将为 101。

图1

首先节点 A 是领导者，提议了指令 X、Y，但是因为网络故障，指令只成功复制到了节点 A。

图2

假设这时节点 A 故障了，新当选的领导者为节点 B。节点 B 当选领导者后，需要先作为学习者了解目前已被选定的指令。节点 B 学习之后，发现当前被选定指令的最大序号为 100（因为节点 A 故障了，它被选定指令的最大序号 102，无法被节点 B 发现），那么它可以从序号 101 开始提议新的指令。这时它接收到客户端请求，并提议了指令 Z，指令 Z 被成功复制到节点 B、C。

图3

这时节点 B 故障了，节点 A 恢复了，选举出领导者 C 后，节点 B 故障也恢复了。节点 C 当选领导者后，需要先作为学习者，了解目前已被选定的指令，这时它执行 Basic Paxos 的准备阶段，就会发现之前选定的值（比如 Z、Y），然后发送接受请求，最终在序号 101、102 处达成共识的指令是 Z、Y。就像下图的样子。

图4

在这里，你可以看到，原本预期的指令是 X、Y，最后变成了 Z、Y，也就是说，虽然 Multi-Paxos 能就一系列值达成共识，但它不关心达成共识后的值是什么，这显然不是我们想要的结果。

比如，假设在 ZooKeeper 中直接使用了兰伯特的 Multi-Paxos，这时咱们创建节点"/geekbang"和"/geekbang/time"，那么就可能出现，系统先创建了节点"/geekbang/time"，这样肯定就出错了：

[zk: localhost:2181(CONNECTED) 6] create /geekbang/time 456
Node does not exist: /geekbang/time

因为创建节点"/geekbang/time"时，找不到节点"/geekbang"，所以就会创建失败。

在这里我多说几句，兰伯特有很多关于分布式的理论，这些理论都很经典（比如拜占庭将军问题、Paxos），但也因为太早了，与实际场景结合的不多，所以后续的众多算法是在这个基础之上做了大量的改进（比如，PBFT、Raft 等）。关于这一点，我在13 讲也强调过，你需要注意。

另外我还想补充一下，在我看来，在ZAB 论文中，关于 Paxos 问题（Figure 1 ​​​​）的分析是有争议的。因为 ZooKeeper 当时应该考虑的是 Multi-Paxos，而不是有多个提议者的 Basic Paxos。对于 Multi-Paxos 而言，领导者作为唯一提议者，不存在同时多个提议者的情况。也就是说，Multi-Paxos 无法保证操作的顺序性的问题是存在的，但原因不是文中演示的原因，本质上是因为 Multi-Paxos 实现的是一系列值的共识，不关心最终达成共识的值是什么，不关心各值的顺序。

既然 Multi-Paxos 不行，ZooKeeper 怎么实现操作的顺序性的呢? 答案是它实现了 ZAB 协议。

你可能会说了：Multi-Paxos 无法实现操作的顺序性，但 Raft 可以啊，为什么 ZooKeeper 不用 Raft 呢？这个问题其实比较简单，因为 Raft 出来的比较晚，直到 2013 年才正式提出，在 2007 年开发 ZooKeeper 的时候，还没有 Raft 呢。

ZAB 是如何保证操作的顺序性的？

与兰伯特的 Multi-Paxos 不同，ZAB 不是共识算法，不基于状态机，而是基于主备模式的原子广播协议，最终实现了操作的顺序性。

这里我说的主备，就是 Master-Slave 模型，一个主节点和多个备份节点，所有副本的数据都以主节点为准，主节点采用二阶段提交，向备份节点同步数据，如果主节点发生故障，数据最完备的节点将当选主节点。而原子广播协议，你可以理解成广播一组消息，消息的顺序是固定的。

需要你注意的是，ZAB 在这里做了个优化，为了实现分区容错能力，将数据复制到大多数节点后（也就是如果大多数节点准备好了），领导者就会进入提交执行阶段，通知备份节点执行提交操作。在这一点上，Raft 和 ZAB 是类似的，我建议你可以对比着 Raft 算法来理解 ZAB。

讲到这儿我再多说一句，前面几讲的留言中有同学问状态机的事情：在 Multi-Paxos、Raft 中为什么需要状态机？这是一个很棒的问题，为你的深入思考点个赞！所以咱们先来看一下这个问题。

什么是状态机？

本质上来说，状态机指的是有限状态机，它是一个数学模型。你可以这么理解：状态机是一个功能模块，用来处理一系列请求，最大的特点就是确定性，也就是说，对于相同的输入，不管重复运行多少次，最终的内部状态和输出都是相同的。

就像你敲击键盘，在 Word 文档上打字一样，你敲击键盘的顺序决定了 Word 文档上的文字，你按照相同的顺序敲击键盘，一定能敲出相同的文字，这就是一个现实版的状态机。

那么为什么在 Multi-Paxos、Raft 中需要状态机呢？

你想一下，Multi-Paxos、Raft 都是共识算法，而共识算法是就一系列值达成共识的，达成共识后，这个值就不能改了。但有时候我们是需要更改数据的值的，比如 KV 存储，我们肯定需要更改指定 key（比如 X）对应的值，这时我们就可以通过状态机来解决这个问题。

比如，如果你想把 X 的值改为 7，那你可以提议一个新的指令“SET X = 7”，当这个指令被达成共识并提交到状态机后，你查询到的值就是 7 了，也就成功修改了 X 的值。

讲到这儿，你应该理解什么是状态机，为什共识算法需要状态机了吧？在解决这个问题之后，咱们说回刚刚的话题：ZAB 协议如何保证操作的顺序性？

如何实现操作的顺序性？

首先，ZAB 实现了主备模式，也就是所有的数据都以主节点为准：

图5

其次，ZAB 实现了 FIFO 队列，保证消息处理的顺序性。

另外，ZAB 还实现了当主节点崩溃后，只有日志最完备的节点才能当选主节点，因为日志最完备的节点包含了所有已经提交的日志，所以这样就能保证提交的日志不会再改变。

你看，ZAB 协议通过这几个特性就能保证后来的操作不会比当前的操作先执行，也就能保证节点"/geekbang"会在节点"/geekbang/time"之前创建。

学到这里，想必你已经发现了，这些特性好像和 Raft 很像。是的，因为在前面几讲，我们已经学习了 Raft 算法，所以你可以类比 Raft 来理解，在 Raft 中：

所有日志以领导者的为准；

领导者接收到客户端请求后，会基于请求中的指令，创建日志项，并将日志项缓存在本地，然后按照顺序，复制到其他节点和提交 ;

在 Raft 中，也是日志最完备的节点才能当选领导者。

内容小结

本节课我主要带你了解了状态机、为什么 Multi-Paxos 无法实现操作的顺序性，以及 ZAB 协议如何保证操作的顺序性。我希望你明确这样几个重点。

状态机最大的特点是确定性，对于相同的输入不管运行多少次，最终的内部状态和输出都是相同的。需要你注意的是，在共识算法中，我们可以通过提议新的指令，达成共识后，提交给状态机执行，来达到修改指定内容的效果，比如修改 KV 存储中指定 key 对应的值。

ZAB 是通过“一切以领导者为准”的强领导者模型和严格按照顺序提交日志，来实现操作的顺序性的，这一点和 Raft 是一样的。

最后我想说的是，兰伯特的 Multi-Paxos 只考虑了如何实现共识，也就是，如何就一系列值达成共识，未考虑如何实现各值（也就是操作）的顺序性。最终 ZooKeeper 实现了基于主备模式的原子广播协议，保证了操作的顺序性，而且，ZAB 协议的实现，影响到了后来的共识算法，也就是 Raft 算法，Raft 除了能就一些值达成共识，还能保证各值的顺序性。

学习完本讲内容后，你可以看到，Raft 算法和 ZAB 协议很类似，比如主备模式（也就是领导者、跟随者模型）、日志必须是连续的、以领导者的日志为准是日志一致等等。你可以想一下，那为什么它们会比较类似呢？

我的看法是，“英雄所见略同”。比如 ZAB 协议要实现操作的顺序性，而 Raft 的设计目标，不仅仅是操作的顺序性，而是线性一致性，这两个目标，都决定了它们不能允许日志不连续，要按照顺序提交日志，那么，它们就要通过上面的方法实现日志的顺序性，并保证达成共识（也就是提交）后的日志不会再改变。

课堂思考

我提到在 ZAB 中，写操作必须在主节点上执行，主节点是通过简化版的二阶段提交向备份节点同步数据。那么如果读操作访问的是备份节点，能保证每次都能读到最新的数据吗？为什么呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

16 | InfluxDB企业版一致性实现剖析：他山之石，可以攻玉

你好，我是韩健。

学习了前面 15 讲的内容后，我们了解了很多常用的理论和算法（比如 CAP 定理、Raft 算法等）。是不是理解了这些内容，就能够游刃有余地处理实际系统的问题了呢？

在我看来，还远远不够，因为理论和实践的中间是存在鸿沟的，比如，你可能有这样的感受，提到编程语言的语法或者分布式算法的论文，你说起来头头是道，但遇到实际系统时，还是无法写程序，开发分布式系统。

而我常说，实战是学习的最终目的。为了帮你更好地掌握前面的理论和算法，接下来，我用 5 讲的时间，分别以 InfluxDB 企业版一致性实现、Hashicorp Raft、KV 系统开发实战为例，带你了解如何在实战中使用技术，掌握分布式的实战能力。

今天这一讲，我就以 InfluxDB 企业版为例，带你看一看系统是如何实现一致性的。有的同学可能会问了：为什么是 InfluxDB 企业版呢？因为它是排名第一的时序数据库，相比其他分布式系统（比如 KV 存储），时序数据库更加复杂，因为我们要分别设计 2 个完全不一样的一致性模型。当你理解了这样一个复杂的系统实现后，就能更加得心应手地处理简单系统的问题了。

那么为了帮你达到这个目的。我会先介绍一下时序数据库的背景知识，因为技术是用来解决实际场景的问题的，正如我之前常说的“要根据场景特点，权衡折中来设计系统”。所以当你了解了这些背景知识后，就能更好的理解为什么要这么设计了。

什么是时序数据库？

你可以这么理解，时序数据库，就是存储时序数据的数据库，就像 MySQL 是存储关系型数据的数据库。而时序数据，就是按照时间顺序记录系统、设备状态变化的数据，比如 CPU 利用率、某一时间的环境温度等，就像下面的样子：

> insert cpu_usage,host=server01,location=cn-sz user=23.0,system=57.0
> select * from cpu_usage
name: cpu_usage
time                host     location system user
----                ----     -------- ------ ----
1557834774258860710 server01 cn-sz    55     25
>

在我看来，时序数据最大的特点是数据量很大，可以不夸张地说是海量。时序数据主要来自监控（监控被称为业务之眼），而且在不影响业务运行的前提下，监控埋点是越多越好，这样才能及时发现问题、复盘故障。

那么作为时序数据库，InfluxDB 企业版的架构是什么样子呢？

你可能已经了解过，它是由 META 节点和 DATA 节点 2 个逻辑单元组成的，而且这两个节点是 2 个单独的程序。那你也许会问了，为什么不能合成到一个程序呢？答案是场景不同。

META 节点存放的是系统运行的关键元信息，比如数据库（Database）、表（Measurement）、保留策略（Retention policy）等。它的特点是一致性敏感，但读写访问量不高，需要一定的容错能力。

DATA 节点存放的是具体的时序数据。它有这样几个特点：最终一致性、面向业务、性能越高越好，除了容错，还需要实现水平扩展，扩展集群的读写性能。

我想说的是，对于 META 节点来说，节点数的多少代表的是容错能力，一般 3 个节点就可以了，因为从实际系统运行观察看，能容忍一个节点故障就可以了。但对 DATA 节点而言，节点数的多少则代表了读写性能，一般而言，在一定数量以内（比如 10 个节点）越多越好，因为节点数越多，读写性能也越高，但节点数量太多也不行，因为查询时就会出现访问节点数过多而延迟大的问题。

所以，基于不同场景特点的考虑，2 个单独程序更合适。如果 META 节点和 DATA 节点合并为一个程序，因读写性能需要，设计了一个 10 节点的 DATA 节点集群，这就意味着 META 节点集群（Raft 集群）也是 10 个节点。在学了 Raft 算法之后，你应该知道，这时就会出现消息数多、日志提交慢的问题，肯定不行了。（对 Raft 日志复制不了解的同学，可以回顾一下08 讲）

现在你了解时序数据库，以及 InfluxDB 企业版的 META 节点和 DATA 节点了吧？那么怎么实现 META 节点和 DATA 节点的一致性呢？

如何实现 META 节点一致性？

你可以这样想象一下，META 节点存放的是系统运行的关键元信息，那么当写操作发生后，就要立即读取到最新的数据。比如，创建了数据库“telegraf”，如果有的 DATA 节点不能读取到这个最新信息，那就会导致相关的时序数据写失败，肯定不行。

所以，META 节点需要强一致性，实现 CAP 中的 CP 模型（对 CAP 理论不熟悉的同学，可以先回顾下02 讲）。

那么，InfluxDB 企业版是如何实现的呢？

因为 InflxuDB 企业版是闭源的商业软件，通过官方文档，我们可以知道它使用 Raft 算法实现 META 节点的一致性（一般推荐 3 节点的集群配置）。那么说完 META 节点的一致性实现之后，我接着说一说 DATA 节点的一致性实现。

如何实现 DATA 节点一致性？

我们刚刚提到，DATA 节点存放的是具体的时序数据，对一致性要求不高，实现最终一致性就可以了。但是，DATA 节点也在同时作为接入层直接面向业务，考虑到时序数据的量很大，要实现水平扩展，所以必须要选用 CAP 中的 AP 模型，因为 AP 模型不像 CP 模型那样采用一个算法（比如 Raft 算法）就可以实现了，也就是说，AP 模型更复杂，具体有这样几个实现步骤。

自定义副本数

首先，你需要考虑冗余备份，也就是同一份数据可能需要设置为多个副本，当部分节点出问题时，系统仍然能读写数据，正常运行。

那么，该如何设置副本呢？答案是实现自定义副本数。

关于自定义副本数的实现，我们在12 讲介绍了，在这里就不啰嗦了。不过，我想补充一点，相比 Raft 算法节点和副本必须一一对应，也就是说，集群中有多少个节点就必须有多少个副本，你看，自定义副本数，是不是更灵活呢？

学到这里，有同学可能已经想到了，当集群支持多副本时，必然会出现一个节点写远程节点时，RPC 通讯失败的情况，那么怎么处理这个问题呢？

Hinted-handoff

我想说的是，一个节点接收到写请求时，需要将写请求中的数据转发一份到其他副本所在的节点，那么在这个过程中，远程 RPC 通讯是可能会失败的，比如网络不通了，目标节点宕机了，等等，就像下图的样子。

图1

那么如何处理这种情况呢？答案是实现 Hinted-handoff。在 InfluxDB 企业版中，Hinted-handoff 是这样实现的:

写失败的请求，会缓存到本地硬盘上 ;

周期性地尝试重传 ;

相关参数信息，比如缓存空间大小 (max-szie)、缓存周期（max-age）、尝试间隔（retry-interval）等，是可配置的。

在这里我想补充一点，除了网络故障、节点故障外，在实际场景中，临时的突发流量也会导致系统过载，出现 RPC 通讯失败的情况，这时也需要 Hinted-handoff 能力。

虽然 Hinted-handoff 可以通过重传的方式来处理数据不一致的问题，但当写失败请求的数据大于本地缓存空间时，比如某个节点长期故障，写请求的数据还是会丢失的，最终的节点的数据还是不一致的，那么怎么实现数据的最终一致性呢？答案是反熵。

反熵

需要你注意的是，时序数据虽然一致性不敏感，能容忍短暂的不一致，但如果查询的数据长期不一致的话，肯定就不行了，因为这样就会出现“Flapping Dashboard”的现象，也就是说向不同节点查询数据，生成的仪表盘视图不一样，就像图 2 和图 3 的样子。

图2

图3

从上面的 2 个监控视图中你可以看到，同一份数据，查询不同的节点，生成的视图是不一样的。那么，如何实现最终一致性呢？

答案就是咱们刚刚说的反熵，而我在11 讲以自研 InfluxDB 系统为例介绍过反熵的实现，InfluxDB 企业版类似，所以在这里就不啰嗦了。

不过有的同学可能会存在这样的疑问，实现反熵是以什么为准来修复数据的不一致呢？我想说的是，时序数据像日志数据一样，创建后就不会再修改了，一直存放在那里，直到被删除。

所以，数据副本之间的数据不一致，是因为数据写失败导致数据丢失了，也就是说，存在的都是合理的，缺失的就是需要修复的。这时我们可以采用两两对比、添加缺失数据的方式，来修复各数据副本的不一致了。

Quorum NWR

最后，有同学可能会说了，我要在公司官网上展示的监控数据的仪表板（Dashboard），是不能容忍视图不一致的情况的，也就是无法容忍任何“Flapping Dashboard”的现象。那么怎么办呢？这时我们就要实现强一致性（Werner Vogels 提到的强一致性），也就是每次读操作都要能读取最新数据，不能读到旧数据。

那么在一个 AP 型的分布式系统中，如何实现强一致性呢？

答案是实现 Quorum NWR。同样，关于 Quorum NWR 的实现，我们在 12 讲已介绍，在这里也就不啰嗦了。

最后我想说的是，你可以看到，实现 AP 型分布式系统，比实现 CP 型分布式要复杂的。另外，通过上面的内容学习，我希望你能注意到，技术是用来解决场景需求的，没有十全十美的技术，在实际工作中，需要我们深入研究场景特点，提炼场景需求，然后根据场景特点权衡折中，设计出适合该场景特点的分布式系统。

内容小结

本节课我主要带你了解时序数据库、META 节点一致性的实现、DATA 节点一致性的实现。以一个复杂的实际系统为例，带你将前面学习到的理论串联起来，让你知道它们如何在实际场景中使用。我希望你明确的重点如下：

CAP 理论是一把尺子，能辅助我们分析问题、总结归纳问题，指导我们如何做妥协折中。所以，我建议你在实践中多研究多思考，一定不能认为某某技术“真香”，十全十美了，要根据场景特点活学活用技术。

通过 Raft 算法，我们能实现强一致性的分布式系统，能保证写操作完成后，后续所有的读操作，都能读取到最新的数据。

通过自定义副本数、Hinted-handoff、反熵、Quorum NWR 等技术，我们能实现 AP 型分布式系统，还能通过水平扩展，高效扩展集群的读写能力。

最后，我想再强调下，技术是用来解决场景的需求的，只有当你吃透技术，深刻理解场景的需求，才能开发出适合这个场景的分布式系统。另外我还想让你知道的是，InfluxDB 企业版一年的 License 费高达 1.5 万美刀，为什么它值这个价钱？就是因为技术带来的高性能和成本优势。比如：

相比 OpenTSDB，InfluxDB 的写性能是它的 9.96 倍，存储效率是它的 8.69 倍，查询效率是它的 7.38 倍。

相比 Graphite，InfluxDB 的写性能是它的 12 倍，存储效率是 6.3 倍，查询效率是 9 倍。

在这里我想说的是，数倍或者数量级的性能优势其实就是钱，而且业务规模越大，省钱效果越突出。

另外我想说的是，尽管 influxdb-comparisons 的测试比较贴近实际场景，比如它的 DevOps 测试模型，与我们观察到常见的实际场景是一致的。但从实际效果看，InfluxDB 的优势更加明显，成本优势更加突出。因为传统的时序数据库不仅仅是性能低，而且在海量数据场景下，接入和查询的痛点突出。为了缓解这些痛点，引入和堆砌了更多的开源软件。比如：

往往需要引入 Kafka 来缓解，因突发接入流量导致的丢数据问题；

需要引入 Storm、Flink 来缓解，时序数据库计算性能差的问题；

需要做热数据的内存缓存，来解决查询超时的问题。

所以在实施中，除了原有的时序数据库会被替换掉，还有大量的开源软件会被省掉，成本优势突出。在这里我想说的是，从实际实施看（自研 InfluxDB 系统），性能优势和成本优势也是符合这个预期的。

最后我想说的是，我反对堆砌开源软件，建议谨慎引入 Kafka 等缓存中间件。老话说，在计算机中，任何问题都可以通过引入一个中间层来解决。这句话是正确的，但背后的成本是不容忽视的，尤其是在海量系统中。我的建议是直面问题，通过技术手段在代码和架构层面解决它，而不是引入和堆砌更多的开源软件。其实，InfluxDB 团队也是这么做，比如他们两次重构存储引擎。

课堂思考

我提到没有十全十美的技术，而是需要根据场景特点，权衡折中，设计出适合场景特点的分布式系统。那么你试着思考一下，假设有这样一个场景，一个存储系统，访问它的写请求不多（比如 1K QPS），但访问它的读请求很多（比如 1M QPS），而且客户端查询时，对数据的一致性敏感，也就是需要实现强一致性，那么我们该如何设计这个系统呢？为什么呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

17 | Hashicorp Raft（一）：如何跨过理论和代码之间的鸿沟？

你好，我是韩健。

很多同学在开发系统的时候，都会有这样的感觉：明明自己看了很多资料，掌握了技术背后的原理，可在开发和调试的时候还是很吃力，这是为什么呢？

答案很简单，因为理论和实践本来就是两回事，实践不仅需要掌握 API 接口的用法，还需要理解 API 背后的代码实现。

所以，如果你在使用 Raft 开发分布式系统的时候，仅仅阅读 Raft 论文或者 Raft 实现的 API 手册，是远远不够的。你还要吃透 API 背后的代码实现，“不仅知其然，也要知其所以然”，这样才能“一切尽在掌握中”，从而开发实现能稳定运行的分布式系统。那么怎么做才能吃透 Raft 的代码实现呢？

要知道，任何 Raft 实现都承载了两个目标：实现 Raft 算法的原理，设计易用的 API 接口。所以，你不仅要从算法原理的角度理解代码实现，而且要从场景使用的角度理解 API 接口的用法。

而我会用两节课的时间，从代码实现和接口使用两个角度，带你循序渐进地掌握当前流行的一个 Raft 实现：Hashicorp Raft（以最新稳定版 v1.1.1 为例）。希望你在这个过程中集中注意力，勾划重点，以便提高学习效率，吃透原理对应的技术实现，彻底掌握 Raft 算法的实战技巧。

本节课，我会从算法原理的角度，聊一聊 Raft 算法的核心功能（领导者选举和日志复制）在 Hashicorp Raft 中是如何实现的。（如果 Raft 算法的原理你已经忘得差不多了，那你可以先回顾下 7～9 讲，加深印象之后，再进入今天的学习。）

Hashicorp Raft 如何实现领导者选举？

在我看来，阅读源码的关键，在于找到代码的入口函数，比如在 Golang 代码中，程序的入口函数一般为 main() 函数，那么领导者选举的入口函数是哪个呢？

我们知道，典型的领导者选举在本质上是节点状态的变更。具体到 Hashicorp Raft 源码中，领导者选举的入口函数 run()，在 raft.go 中以一个单独的协程运行，来实现节点状态变迁，就像下面的样子：

func (r *Raft) run() {
        for {
                select {
                // 关闭节点
                case <-r.shutdownCh:
                        r.setLeader("")
                        return
                default:
                }

                switch r.getState() {
                // 跟随者
                case Follower:
                        r.runFollower()
                // 候选人
                case Candidate:
                        r.runCandidate()
                // 领导者
                case Leader:
                        r.runLeader()
                }
        }
}

从上面这段代码中，你能看到，Follower（跟随者）、Candidate（候选人）、Leader（领导者）三个节点状态对应的功能，都被抽象成一个函数，分别是 runFollower()、runCandidate() 和 runLeader()。

数据结构

在07 讲中，我们先学习了节点状态，不过主要侧重理解节点状态的功能作用（比如说，跟随者相当于普通群众，领导者是霸道总裁），并没有关注它在实际代码中是如何实现的，所以我们先来看看在 Hashicorp Raft 中是如何实现节点状态的。

节点状态相关的数据结构和函数，是在 state.go 中实现的。跟随者、候选人和领导者的 3 个状态，是由 RaftState 定义的，一个无符号 32 位的只读整型数值（uint32）：

type RaftState uint32
const (
        // 跟随者
        Follower RaftState = iota
        // 候选人
        Candidate
        // 领导者
        Leader
        // 关闭状态
        Shutdown
)

需要注意的是，也存在一些需要使用字符串格式的节点状态的场景（比如日志输出），这时你可以使用 RaftState.String() 函数。

你应该还记得，每个节点都有属于本节点的信息（比如任期编号），那么在代码中如何实现这些信息呢？这就要说到 raftState 数据结构了。

raftState 属于结构体类型，是表示节点信息的一个大数据结构，里面包含了只属于本节点的信息，比如节点的当前任期编号、最新提交的日志项的索引值、存储中最新日志项的索引值和任期编号、当前节点的状态等，就像下面的样子：

type raftState struct {
        // 当前任期编号
        currentTerm uint64

        // 最大被提交的日志项的索引值
        commitIndex uint64

        // 最新被应用到状态机的日志项的索引值
        lastApplied uint64

        // 存储中最新的日志项的索引值和任期编号
        lastLogIndex uint64
        lastLogTerm  uint64

        // 当前节点的状态
        state RaftState

        ......
}

节点状态与节点信息的定义就是这么简单，这里我就不多说了。而在分布式系统中要实现领导者选举，更重要的一层内容是实现 RPC 消息，因为领导者选举的过程，就是一个 RPC 通讯的过程。

在理论篇中我说过，Raft 算法中支持多种 RPC 消息（比如请求投票 RPC 消息、日志复制 RPC 消息）。所以接下来我们看一看，在 Hashicorp Raft 中又是怎样实现 RPC 消息的。又因为在一个 RPC 消息中，最重要的部分就是消息的内容，所以我们先来看一看 RPC 消息对应的数据结构。

RPC 消息相关的数据结构是在 commands.go 中定义的，比如，日志复制 RPC 的请求消息，对应的数据结构为 AppendEntriesRequest。而 AppendEntriesRequest 是一个结构体类型，里面包含了 Raft 算法论文中约定的字段，比如以下这些内容。

Term：当前的任期编号。

PrevLogEntry：表示当前要复制的日志项，前面一条日志项的索引值。

PrevLogTerm：表示当前要复制的日志项，前面一条日志项的任期编号。

Entries：新日志项。

具体的结构信息，就像下面的样子：

type AppendEntriesRequest struct {
        // 当前的任期编号，和领导者信息（包括服务器ID和地址信息）
        Term   uint64
        Leader []byte

        // 当前要复制的日志项，前面一条日志项的索引值和任期编号
        PrevLogEntry uint64
        PrevLogTerm  uint64

        // 新日志项
        Entries []*Log

        // 领导者节点上的已提交的日志项的最大索引值
        LeaderCommitIndex uint64
}

我建议你可以采用上面的思路，对照着算法原理去学习其他 RPC 消息的实现，这样一来你就能掌握独立学习的能力了。其他 RPC 消息的数据结构我就不一一描述了（如果你遇到问题，可以在留言区留言）。

现在，你已经了解了节点状态和 RPC 消息的格式，掌握了这些基础知识后，我们继续下一步，看看在 Hashicorp Raft 中是如何进行领导者选举的。

选举领导者

首先，在初始状态下，集群中所有的节点都处于跟随者状态，函数 runFollower() 运行，大致的执行步骤，就像下图的样子：

我带你走一遍这五个步骤，便于你加深印象。

根据配置中的心跳超时时长，调用 randomTimeout() 函数来获取一个随机值，用以设置心跳超时时间间隔。

进入到 for 循环中，通过 select 实现多路 IO 复用，周期性地获取消息和处理。如果步骤 1 中设置的心跳超时时间间隔发生了超时，执行步骤 3。

如果等待心跳信息未超时，执行步骤 4，如果等待心跳信息超时，执行步骤 5。

执行 continue 语句，开始一次新的 for 循环。

设置节点状态为候选人，并退出 runFollower() 函数。

当节点推举自己为候选人之后，函数 runCandidate() 执行，大致的执行步骤，如图所示：

同样的，我们走一遍这个过程，加深一下印象。

首先调用 electSelf() 发起选举，给自己投一张选票，并向其他节点发送请求投票 RPC 消息，请求他们选举自己为领导者。然后调用 randomTimeout() 函数，获取一个随机值，设置选举超时时间。

进入到 for 循环中，通过 select 实现多路 IO 复用，周期性地获取消息和处理。如果发生了选举超时，执行步骤 3，如果得到了投票信息，执行步骤 4。

发现了选举超时，退出 runCandidate() 函数，然后再重新执行 runCandidate() 函数，发起新一轮的选举。

如果候选人在指定时间内赢得了大多数选票，那么候选人将当选为领导者，调用 setState() 函数，将自己的状态变更为领导者，并退出 runCandidate() 函数。

当节点当选为领导者后，函数 runLeader() 就执行了：

整个过程，主要有 4 个步骤。

调用 startStopReplication()，执行日志复制功能。

然后启动新的协程，调用 replicate() 函数，执行日志复制功能。

接着在 replicate() 函数中，启动一个新的协程，调用 heartbeat() 函数，执行心跳功能。

在 heartbeat() 函数中，周期性地发送心跳信息，通知其他节点，我是领导者，我还活着，不需要你们发起新的选举。

其实，在 Hashicorp Raft 中实现领导者选举并不难，你只要充分理解上述步骤，并记住，领导者选举本质上是节点状态变迁，跟随者、候选人、领导者对应的功能函数分别为 runFollower()、runCandidate()、runLeader()，就可以了。

Hashicorp Raft 如何复制日志？

学习08讲之后，你应该知道了日志复制的重要性，因为 Raft 是基于强领导者模型和日志复制，最终实现强一致性的。那么你该如何学习日志复制的代码实现呢？和学习“如何实现领导者选举”一样，你需要先了解了日志相关的数据结构，阅读日志复制相关的代码。

学习了理论篇后，你应该还记得日志复制是由领导者发起的，跟随者来接收的。可能有同学已经想到了，领导者复制日志和跟随者接收日志的入口函数，应该分别在 runLeader() 和 runFollower() 函数中调用的。赞！理解正确！

领导者复制日志的入口函数为 startStopReplication()，在 runLeader() 中，以 r.startStopReplication() 形式被调用，作为一个单独协程运行。

跟随者接收日志的入口函数为 processRPC()，在 runFollower() 中以 r.processRPC(rpc) 形式被调用，来处理日志复制 RPC 消息。

不过，在分析日志复制的代码实现之前，咱们先来聊聊日志相关的数据结构，便于你更好地理解代码实现。

数据结构

08 讲中我提到过，一条日志项主要包含了 3 种信息，分别是指令、索引值、任期编号，而在 Hashicorp Raft 实现中，日志对应的数据结构和函数接口是在 log.go 中实现的，其中，日志项对应的数据结构是结构体类型的，就像下面的样子：

type Log struct {
        // 索引值
        Index uint64

        // 任期编号
        Term uint64

        // 日志项类别
        Type LogType

        // 指令
        Data []byte

        // 扩展信息
        Extensions []byte
}

我强调一下，与协议中的定义不同，日志项对应的数据结构中，包含了 LogType 和 Extensions 两个额外的字段：

LogType 可用于标识不同用途的日志项，比如，使用 LogCommand 标识指令对应的日志项，使用 LogConfiguration 表示成员变更配置对应的日志项。

Extensions 可用于在指定日志项中存储一些额外的信息。这个字段使用的比较少，在调试等场景中可能会用到，你知道有这么个字段就可以了。

说完日志复制对应的数据结构，我们分步骤看一下，在 Hashicorp Raft 中是如何实现日志复制的。

领导者复制日志

日志复制是由领导者发起，在 runLeader() 函数中执行的，主要有这样几个步骤。

在 runLeader() 函数中，调用 startStopReplication() 函数，执行日志复制功能。

启动一个新协程，调用 replicate() 函数，执行日志复制相关的功能。

在 replicate() 函数中，调用 replicateTo() 函数，执行步骤 4，如果开启了流水线复制模式，执行步骤 5。

在 replicateTo() 函数中，进行日志复制和日志一致性检测，如果日志复制成功，则设置 s.allowPipeline = true，开启流水线复制模式。

调用 pipelineReplicate() 函数，采用更高效的流水线方式，进行日志复制。

在这里我强调一下，在什么条件下开启了流水线复制模式，很多同学可能会在这一块儿产生困惑，因为代码逻辑上有点儿绕。你可以这么理解，是在不需要进行日志一致性检测，复制功能已正常运行的时候，开启了流水线复制模式，目标是在环境正常的情况下，提升日志复制性能，如果在日志复制过程中出错了，就进入 RPC 复制模式，继续调用 replicateTo() 函数，进行日志复制。

跟随者接收日志

领导者复制完日志后，跟随者会接收日志并开始处理日志。跟随者接收和处理日志，是在 runFollower() 函数中执行的，主要有这样几个步骤。

在 runFollower() 函数中，调用 processRPC() 函数，处理接收到的 RPC 消息。

在 processRPC() 函数中，调用 appendEntries() 函数，处理接收到的日志复制 RPC 请求。

appendEntries() 函数，是跟随者处理日志的核心函数。在步骤 3.1 中，比较日志一致性；在步骤 3.2 中，将新日志项存放在本地；在步骤 3.3 中，根据领导者最新提交的日志项索引值，来计算当前需要被应用的日志项，并应用到本地状态机。

讲到这儿，你应该可以了解日志复制的代码实现了吧。关于更多的 Raft 原理的代码实现，你可以继续阅读源码来学习，如果在学习过程中有疑问，欢迎给我留言。

内容小结

本节课我主要带你了解了如何从算法原理的角度理解 Hashicorp Raft 实现，有几个重点我想强调一下：

跟随者、候选人、领导者 3 种节点状态都有分别对应的功能函数，当需要查看各节点状态相关的功能实现时（比如，跟随者如何接收和处理日志），都可以将对应的函数作为入口函数，来阅读代码和研究功能实现。

raft.go 是 Hashicorp Raft 的核心代码文件，大部分的核心功能都是在这个文件中实现的，平时可以多研究这个文件中的代码，直到彻底吃透，掌握。

在 Hashicorp Raft 中，支持两种节点间通讯机制，内存型和 TCP 协议型，其中，内存型通讯机制，主要用于测试，2 种通讯机制的代码实现，分别在文件 inmem_transport.go 和 tcp_transport.go 中。

Hashicorp Raft 实现，是常用的 Golang 版 Raft 算法的实现，被众多流行软件使用，如 Consul、InfluxDB、IPFS 等，相信你对它并不陌生。其他的实现还有Go-Raft、LogCabin、Willemt-Raft等，不过我建议你在后续开发分布式系统时，优先考虑 Hashicorp Raft，因为 Hashicorp Raft 实现，功能完善、代码简洁高效、流行度高，可用性和稳定性被充分打磨。

最后，关于如何高效地阅读源码，我还想多说一说。在我看来，高效阅读源码的关键在于抓住重点，要有“底线”，不要芝麻和西瓜一把抓，什么都想要，最终陷入到枝节琐碎的细节中出不来。什么是重点呢？我认为重点是数据结构和关键的代码执行流程，比如在 Hashicorp Raft 源码中，日志项对应的数据结构、RPC 消息对应的数据结构、选举领导者的流程、日志复制的流程等，这些就是重点。

有的同学可能还有疑问：在阅读源码的时候，如果遇到不是很明白的代码，该怎么办呢？我建议你可以通过打印日志或 GDB 单步调试的方式，查看上下文中的变量的内容、代码执行逻辑等，帮助理解。

课堂思考

在 Hashicorp Raft 实现中，我讲了如何实现选举领导者，以及如何复制日志等，那么在 Hashicorp Raft 中，网络通讯是如何实现的呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

18 | Hashicorp Raft（二）：如何以“集群节点”为中心使用API？

你好，我是韩健。

上一讲结束后，相信有的同学已经跃跃欲试，想把 Hashicorp Raft 使用起来了。不过，也有一些同学跟我反馈，说自己看到 Hashicorp Raft 的Godoc，阅读完接口文档后，感觉有些不知所措，无从下手，Hashicorp Raft 支持了那么多的函数，自己却不知道如何将这些函数使用起来。

这似乎是一个共性的问题，在我看来，之所以出现这个问题，是因为文档里虽然提到了 API 的功能，但并没有提如何在实际场景中使用这些 API，每个 API 都是孤立的点，缺乏一些场景化的线将它们串联起来。

所以，为了帮你更好地理解 Hashicorp Raft 的 API 接口，在实践中将它们用起来，我以“集群节点”为核心，通过创建、增加、移除集群节点，查看集群节点状态这 4 个典型的场景，具体聊一聊在 Hashicorp Raft 中，通过哪些 API 接口能创建、增加、移除集群节点，查看集群节点状态。这样一来，我们会一步一步，循序渐进地彻底吃透 Hashicorp Raft 的 API 接口用法。

我们知道，开发实现一个 Raft 集群的时候，首先要做的第一个事情就是创建 Raft 节点，那么在 Hashicorp Raft 中如何创建节点呢？

如何创建 Raft 节点

在 Hashicorp Raft 中，你可以通过 NewRaft() 函数，来创建 Raft 节点。我强调一下，NewRaft() 是非常核心的函数，是 Raft 节点的抽象实现，NewRaft() 函数的原型是这样的：

func NewRaft(
        conf *Config,
        fsm FSM,
        logs LogStore,
        stable StableStore,
        snaps SnapshotStore,
        trans Transport) (*Raft, error)

你可以从这段代码中看到，NewRaft() 函数有这么几种类型的参数，它们分别是：

Config（节点的配置信息）；

FSM（有限状态机）；

LogStore（用来存储 Raft 的日志）；

StableStore（稳定存储，用来存储 Raft 集群的节点信息等）；

SnapshotStore（快照存储，用来存储节点的快照信息）；

Transport（Raft 节点间的通信通道）。

这 6 种类型的参数决定了 Raft 节点的配置、通讯、存储、状态机操作等核心信息，所以我带你详细了解一下，在这个过程中，你要注意是如何创建这些参数信息的。

Config 是节点的配置信息，可通过函数 DefaultConfig() 来创建默认配置信息，然后按需修改对应的配置项。一般情况下，使用默认配置项就可以了。不过，有时你可能还是需要根据实际场景，调整配置项的，比如：

如果在生产环境中部署的时候，你可以将 LogLevel 从 DEBUG 调整为 WARM 或 ERROR；

如果部署环境中网络拥堵，你可以适当地调大 HeartbeatTimeout 的值，比如，从 1s 调整为 1.5s，避免频繁的领导者选举；

那么 FSM 又是什么呢？它是一个 interface 类型的数据结构，借助 Golang Interface 的泛型编程能力，应用程序可以实现自己的 Apply(*Log)、Snapshot()、Restore(io.ReadCloser) 3 个函数，分别实现将日志应用到本地状态机、生成快照和根据快照恢复数据的功能。FSM 是日志处理的核心实现，原理比较复杂，不过不是咱们本节课的重点，现在你只需要知道这 3 个函数就可以了。在 20 讲，我会结合实际代码具体讲解的。

第三个参数 LogStore 存储的是 Raft 日志，你可以用raft-boltdb来实现底层存储，持久化存储数据。在这里我想说的是，raft-boltdb 是 Hashicorp 团队专门为 Hashicorp Raft 持久化存储，而开发设计的，使用广泛，打磨充分。具体用法是这样的：

logStore, err := raftboltdb.NewBoltStore(filepath.Join(raftDir, "raft-log.db"))

NewBoltStore() 函数只支持一个参数，也就是文件路径。

第四个参数 StableStore 存储的是节点的关键状态信息，比如，当前任期编号、最新投票时的任期编号等，同样，你也可以采用 raft-boltdb 来实现底层存储，持久化存储数据。

stableStore, err := raftboltdb.NewBoltStore(filepath.Join(raftDir, "raft-stable.db"))

第五个参数 SnapshotStore 存储的是快照信息，也就是压缩后的日志数据。在 Hashicorp Raft 中提供了 3 种快照存储方式，它们分别是：

DiscardSnapshotStore（不存储，忽略快照，相当于 /dev/null，一般来说用于测试）；

FileSnapshotStore（文件持久化存储）；

InmemSnapshotStore（内存存储，不持久化，重启程序后，数据会丢失）。

这 3 种方式，在生产环境中，建议你采用 FileSnapshotStore 实现快照， 使用文件持久化存储，避免因程序重启，导致快照数据丢失。具体代码实现如下：

snapshots, err := raft.NewFileSnapshotStore(raftDir, retainSnapshotCount, os.Stderr)

NewFileSnapshotStore() 函数支持 3 个参数。也就是说，除了指定存储路径（raftDir），还要指定需要保留的快照副本的数量 (retainSnapshotCount)，以及日志输出的方式。一般而言，将日志输出到标准错误 IO 就可以了。

最后一个 Transport 指的是 Raft 集群内部节点之间的通信机制，节点之间需要通过这个通道来进行日志同步、领导者选举等等。Hashicorp Raft 支持两种方式：

一种是基于 TCP 协议的 TCPTransport，可以跨机器跨网络通信的；

另一种是基于内存的 InmemTransport，不走网络，在内存里面通过 Channel 来通信。

在生产环境中，我建议你使用 TCPTransport，使用 TCP 进行网络通讯，突破单机限制，提升集群的健壮性和容灾能力。具体代码实现如下：

addr, err := net.ResolveTCPAddr("tcp", raftBind)
transport, err := raft.NewTCPTransport(raftBind, addr, maxPool, timeout, os.Stderr)

NewTCPTransport() 函数支持 5 个参数，也就是，指定创建连接需要的信息。比如，要绑定的地址信息（raftBind、addr）、连接池的大小（maxPool）、超时时间（timeout），以及日志输出的方式，一般而言，将日志输出到标准错误 IO 就可以了。

以上就是这 6 个参数的详细内容了，既然我们已经了解了这些基础信息，那么如何使用 NewRaft() 函数呢？其实，你可以在代码中直接调用 NewRaft() 函数，创建 Raft 节点对象，就像下面的样子：

raft, err := raft.NewRaft(config, (*storeFSM)(s), logStore, stableStore, snapshots, transport)

接口清晰，使用方便，你可以亲手试一试。

现在，我们已经创建了 Raft 节点，打好了基础，但是我们要实现的是一个多节点的集群，所以，创建一个节点是不够的，另外，创建了节点后，你还需要让节点启动，当一个节点启动后，你还需要创建新的节点，并将它加入到集群中，那么具体怎么操作呢？

如何增加集群节点

集群最开始的时候，只有一个节点，我们让第一个节点通过 bootstrap 的方式启动，它启动后成为领导者：

raftNode.BootstrapCluster(configuration)

BootstrapCluster() 函数只支持一个参数，也就是 Raft 集群的配置信息，因为此时只有一个节点，所以配置信息为这个节点的地址信息。

后续的节点在启动的时候，可以通过向第一个节点发送加入集群的请求，然后加入到集群中。具体来说，先启动的节点（也就是第一个节点）收到请求后，获取对方的地址（指 Raft 集群内部通信的 TCP 地址），然后调用 AddVoter() 把新节点加入到集群就可以了。具体代码如下：

raftNode.AddVoter(id,
            addr, prevIndex, timeout)

AddVoter() 函数支持 4 个参数，使用时，一般只需要设置服务器 ID 信息和地址信息 ，其他参数使用默认值 0，就可以了：

id（服务器 ID 信息）；

addr（地址信息）；

prevIndex（前一个集群配置的索引值，一般设置为 0，使用默认值）；

timeout（在完成集群配置的日志项添加前，最长等待多久，一般设置为 0，使用默认值）。

当然了，也可以通过 AddNonvoter()，将一个节点加入到集群中，但不赋予它投票权，让它只接受日志记录，这个函数平时用不到，你只需知道有这么函数，就可以了。

在这里，我想补充下，早期版本中的用于增加集群节点的函数，AddPeer() 函数，已废弃，不再推荐使用。

你看，在创建集群或者扩容时，我们尝试着增加了集群节点，但一旦出现不可恢复性的机器故障或机器裁撤时，我们就需要移除节点，进行节点替换，那么具体怎么做呢？

如何移除集群节点

我们可以通过 RemoveServer() 函数来移除节点，具体代码如下：

raftNode.RemoveServer(id, prevIndex, timeout)

RemoveServer() 函数支持 3 个参数，使用时，一般只需要设置服务器 ID 信息 ，其他参数使用默认值 0，就可以了：

id（服务器 ID 信息）；

prevIndex（前一个集群配置的索引值，一般设置为 0，使用默认值）；

timeout（在完成集群配置的日志项添加前，最长等待多久，一般设置为 0，使用默认值）。

我要强调一下，RemoveServer() 函数必须在领导者节点上运行，否则就会报错。这一点，很多同学在实现移除节点功能时会遇到，所以需要注意一下。

最后，我想补充下，早期版本中的用于移除集群节点的函数，RemovePeer() 函数也已经废弃了，不再推荐使用。

关于如何移除集群节点的代码实现，也比较简单易用，通过服务器 ID 信息，就可以将对应的节点移除了。除了增加和移除集群节点，在实际场景中，我们在运营分布式系统时，有时需要查看节点的状态。那么该如何查看节点状态呢？

如何查看集群节点状态

在分布式系统中，日常调试的时候，节点的状态信息是很重要的，比如在 Raft 分布式系统中，如果我们想抓包分析写请求，那么必须知道哪个节点是领导者节点，它的地址信息是多少，因为在 Raft 集群中，只有领导者能处理写请求。

那么在 Hashicorp Raft 中，如何查看节点状态信息呢？

我们可以通过 Raft.Leader() 函数，查看当前领导者的地址信息，也可以通过 Raft.State() 函数，查看当前节点的状态，是跟随者、候选人，还是领导者。不过你要注意，Raft.State() 函数返回的是 RaftState 格式的信息，也就是 32 位无符号整数，适合在代码中使用。如果想在日志或命令行接口中查看节点状态信息，我建议你使用 RaftState.String() 函数，通过它，你可以查看字符串格式的当前节点状态。

为了便于你理解，我举个例子。比如，你可以通过下面的代码，判断当前节点是否是领导者节点：

func isLeader() bool {
       return raft.State() == raft.Leader
}

了解了节点状态，你就知道了当前集群节点之间的关系，以及功能和节点的对应关系，这样一来，你在遇到问题，需要调试跟踪时，就知道登录到哪台机器，去调试分析了。

内容小结

本节课我主要以“集群节点”为核心，带你了解了 Hashicorp Raft 的常用 API 接口，我希望你明确的重点如下：

除了提到的 raft-boltdb 做作为 LogStore 和 StableStore，也可以调用 NewInmemStore() 创建内存型存储，在测试时比较方便，重新执行程序进行测试时，不需要手动清理数据存储。

你还可以通过 NewInmemTransport() 函数，实现内存型通讯接口，在测试时比较方便，将集群通过内存进行通讯，运行在一台机器上。

你可以通过 Raft.Stats() 函数，查看集群的内部统计信息，比如节点状态、任期编号、节点数等，这在调试或确认节点运行状况的时候很有用。

我以集群节点为核心，讲解了 Hashicorp Raft 常用的 API 接口，相信现在你已经掌握这些接口的用法了，对如何开发一个分布式系统，也有了一定的感觉。既然学习是为了使用，那么我们学完这些内容，也应该用起来才是，所以，为了帮你更好地掌握 Raft 分布式系统的开发实战技巧，我会用接下来两节课的时间，以分布式 KV 系统开发实战为例，带你了解 Raft 的开发实战技巧。

课堂思考

我提到了一些常用的 API 接口，比如创建 Raft 节点、增加集群节点、移除集群节点、查看集群节点状态等，你不妨思考一下，如何创建一个支持 InmemTransport 的 Raft 节点呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

19 | 基于Raft的分布式KV系统开发实战（一）：如何设计架构？

你好，我是韩健。

学完前面 2 讲之后，相信你已经大致了解了 Raft 算法的代码实现（Hashcorp Raft），也掌握了常用 API 接口的用法，对 Raft 算法的理解也更深刻了。那么，是不是掌握这些，就能得心应手的处理实际场景的问题了呢？

在我看来，掌握的还不够，因为 Raft 算法的实现只是工具。而掌握了工具的用法，和能使用工具得心应手地处理实际场景的问题，是两回事。也就是说，我们还需要掌握使用 Raft 算法开发分布式系统的实战能力，然后才能游刃有余的处理实际场景的问题。

我从这个角度出发，在接下来的 2 节课中，我会分别从架构和代码实现的角度，以一个基本的分布式 KV 系统为例，具体说一说，如何基于 Raft 算法构建一个分布式 KV 系统。那么我希望你能课下多动手，自己写一遍，不给自己留下盲区。如果条件允许的话，你还可以按需开发实现需要的功能，并将这套系统作为自己的“配置中心”“名字路由”维护下去，不断在实战中加深自己对技术的理解。

可能有同学会问：“老韩，为什么不以 Etcd 为例呢？它不是已经在生产环境中落地了吗？”

我是这么考虑的，这个基本的分布式 KV 系统的代码比较少，相对纯粹聚焦在技术本身，涉及的 KV 业务层面的逻辑少，适合入门学习（比如你可以从零开始，动手编程实现），是一个很好的学习案例。

另外，对一些有经验的开发者来说，这部分知识能够帮助你掌握 Raft 算法中，一些深层次的技术实现，比如如何实现多种读一致性模型，让你更加深刻地理解 Raft 算法。

今天这节课，我会具体说一说如何设计一个基本的分布式 KV 系统，也就是需要实现哪些功能，以及在架构设计的时候，你需要考虑哪些点（比如跟随者是否要转发写请求给领导者？或者如何设计接入访问的 API？）

好了，话不多说，一起进入今天的课程吧！

在我看来，基于技术深度、开发工作量、学习复杂度等综合考虑，一个基本的分布式 KV 系统，至少需要具备这样几块功能，就像下图的样子。

接入协议：供客户端访问系统的接入层 API，以及与客户端交互的通讯协议。

KV 操作：我们需要支持的 KV 操作（比如赋值操作）。

分布式集群：也就是说，我们要基于 Raft 算法实现一个分布式存储集群，用于存放 KV 数据。

需要你注意的是，这 3 点就是分布式 KV 系统的核心功能，也就是我们需要编程实现的需求。

在我看来，要实现一个基本的分布式 KV 系统，首先要做的第一件事，就是实现访问接入的通讯协议。因为如果用户想使用这套系统，对他而言的第一件事，就是如何访问这套系统。那么，如何实现访问接入的通讯协议呢？

如何设计接入协议？

我想说的是，在早些时候，硬件性能低，服务也不是很多，开发系统的时候，主要矛盾是性能瓶颈，所以，更多的是基于性能的考虑，采用 UDP 协议和实现私有的二进制协议，比如，早期的 QQ 后台组件，就是这么做的。

现在呢，硬件性能有了很大幅度的提升，后台服务器的 CPU 核数都近百了，开发系统的时候，主要的矛盾已经不是性能瓶颈了，而是快速增长的海量服务和开发效率，所以这时，基于开发效率和可维护性的考虑，我们就需要优先考虑标准的协议了（比如 HTTP）。

如果使用 HTTP 协议，那么就需要设计 HTTP RESTful API，作为访问接口。具体怎么设计呢？

我想说的是，因为我们设计实现的是 KV 系统，肯定要涉及到 KV 操作，那么我们就一定需要设计个 API（比如"/key"）来支持 KV 操作。也就是说，通过访问这个 API，我们能执行相关的 KV 操作了，就像下面的样子（查询指定 key（就是 foo）对应的值）。

curl -XGET http://raft-cluster-host01:8091/key/foo

另外，需要你注意的是，因为这是一个 Raft 集群系统，除了业务层面（KV 操作），我们还需要实现平台本身的一些操作的 API 接口，比如增加、移除集群节点等。我们现在只考虑增加节点操作的 API（比如"/join"），就像下面的样子。

http://raft-cluster-host01:8091/join

另外，在故障或缩容情况下，如何替换节点、移除节点，我建议你在线下对比着增加节点的操作，自主实现。

除此之外，在我看来，实现 HTTP RESTful API，还有非常重要的一件事情要做，那就是在设计 API 时，考虑如何实现路由，为什么这么说呢？你这么想象一下，如果我们实现了多个 API，比如"/key"和"/join"，那么就需要将 API 对应的请求和它对应的处理函数一一映射起来。

我想说的是，我们可以在 serveHTTP() 函数（Golang）中，通过检测 URL 路径，来设置请求对应处理函数，实现路由。大概的原理，就像下面的样子。

func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {    // 设置HTTP请求对应的路由信息
        if strings.HasPrefix(r.URL.Path, "/key") {
                s.handleKeyRequest(w, r)
        } else if r.URL.Path == "/join" {
                s.handleJoin(w, r)
        } else {
                w.WriteHeader(http.StatusNotFound)
        }
}

从上面代码中，我们可以看到，当检测到 URL 路径为“/key”时，会调用 handleKeyRequest() 函数，来处理 KV 操作请求；当检测到 URL 路径为"/join"时，会调用 handleJoin() 函数，将指定节点加入到集群中。

你看，通过"/key"和"/join"2 个 API，我们就能满足这个基本的分布式 KV 系统的运行要求了，既能支持来自客户端的 KV 操作，也能新增节点并将集群运行起来。

当客户端通过通讯协议访问到系统后，它最终的目标，还是执行 KV 操作。那么，我们该如何设计 KV 操作呢？

如何设计 KV 操作？

我想说的是，常见的 KV 操作是赋值、查询、删除，也就是说，我们实现这三个操作就可以了，其他的操作可以先不考虑。具体可以这么实现。

赋值操作：我们可以通过 HTTP POST 请求，来对指定 key 进行赋值，就像下面的样子。

curl -XPOST http://raft-cluster-host01:8091/key -d '{"foo": "bar"}'

查询操作：我们可以通过 HTTP GET 请求，来查询指定 key 的值，就像下面的样子。

curl -XGET http://raft-cluster-host01:8091/key/foo

删除操作：我们可以通过 HTTP DELETE 请求，来删除指定 key 和 key 对应的值，就像下面的样子。

curl -XDELETE http://raft-cluster-host01:8091/key/foo

在这里，尤其需要你注意的是，操作需要具有幂等性。幂等性这个词儿你估计不会陌生，你可以这样理解它：同一个操作，不管执行多少次，最终的结果都是一样的，也就是，这个操作是可以重复执行的，而是重复执行不会对系统产生预期外的影响。

为什么操作要具有冥等性呢？

因为共识算法能保证达成共识后的值（也就是指令）就不再改变了，但不能保证值只被提交一次，也就是说，共识算法是一个“at least once”的指令执行模型，是可能会出现同一个指令被重复提交的情况，为什么呢？我以 Raft 算法为例，具体说一说。

比如，如果客户端接收到 Raft 的超时响应后，也就是这时日志项还没有提交成功，如果此时它重试，发送一个新的请求，那么这个时候 Raft 会创建一个新的日志项，并最终将新旧 2 个日志项都提交了，出现了指令重复执行的情况。

在这里我想强调的是，你一定要注意到这样的情况，在使用 Raft 等共识算法时，要充分评估操作是否具有幂等性，避免对系统造成预期外的影响，比如，直接使用“Add”操作，就会因重复提交，导致最终的执行结果不准了，影响到业务。这就可能会出现，用户购买了 100Q 币，系统却给他充值了 500Q 币，肯定不行了。

说完如何设计 KV 操作后，因为我们的最终目标是实现分布式 KV 系统，那么，就让我们回到分布式系统最本源的一个问题上，如何实现分布式集群？

如何实现分布式集群？

我想说的是，正如在 09 讲中提到的，我推荐使用 Raft 算法实现分布式集群。而实现一个 Raft 集群，我们首先要考虑的是如何创建集群，为了简单起见，我们暂时不考虑节点的移除和替换等。

创建集群

在 Raft 算法中，我们可以这样创建集群。

先将第一个节点，通过 Bootstrap 的方式启动，并作为领导者节点。

其他节点与领导者节点通讯，将自己的配置信息发送给领导者节点，然后领导者节点调用 AddVoter() 函数，将新节点加入到集群中。

创建了集群后，在集群运行中，因为 Raft 集群的领导者不是固定不变的，而写请求是必须要在领导者节点上处理的，那么如何实现写操作，来保证写请求都会发给领导者呢？

写操作

一般而言，有 2 种方法来实现写操作。我来具体说说。

方法 1：跟随者接收到客户端的写请求后，拒绝处理这个请求，并将领导者的地址信息返回给客户端，然后客户端直接访问领导者节点，直到该领导者退位，就像下图的样子。

方法 2：跟随者接收到客户端的写请求后，将写请求转发给领导者，并将领导者处理后的结果返回给客户端，也就是说，这时跟随者在扮演“代理”的角色，就像下图的样子。

在我看来，虽然第一种方法需要客户端的配合，但实现起来复杂度不高；另外，第二种方法，虽然能降低客户端的复杂度，客户端像访问一个黑盒一样，访问系统，对领导者变更完全无感知。

但是这个方法会引入一个中间节点（跟随者），增加了问题分析排查的复杂度。而且，一般情况下，在绝大部分的时间内（比如 Google Chubby 团队观察到的值是数天），领导者是处于稳定状态的，某个节点一直是领导者，那么引入中间节点，就会增加大量的不必要的消息和性能消耗。所以，综合考虑，我推荐方法 1。

学习了 Raft 算法后，我们知道，相比写操作（只要在领导者节点执行就可以了）而言，读操作要复杂些，因为如何实现读操作，关乎着一致性的实现，也就是说，怎么实现读操作，决定了客户端是否会读取到旧数据。那么如何实现读操作呢？

读操作

其实，在实际系统中，并不是实现了强一致性就是最好的，因为实现了强一致性，必然会限制集群的整体性能。也就是说，我们需要根据实际场景特点进行权衡折中，这样，才能设计出最适合该场景特点的读操作。比如，我们可以实现类似 Consul 的 3 种读一致性模型。

default：偶尔读到旧数据。

consistent：一定不会读到旧数据。

stale：会读到旧数据。

如果你不记得这 3 种模型的含义了，你可以去 09 讲回顾下，在这里，我就不啰嗦了。

也就是说，我们可以实现多种读一致性模型，将最终的一致性选择权交给用户，让用户去选择，就像下面的样子。

curl -XGET http://raft-cluster-host02:8091/key/foo?level=consistent  -L

内容小结

本节课我主要带你了解了一个基本的分布式 KV 系统的架构，和需要权衡折中的技术细节，我希望你明确的重点如下。

1. 在设计 KV 操作时，更确切的说，在实现 Raft 指令时，一定要考虑冥等性，因为 Raf 指令是可能会被重复提交和执行。

2. 推荐你采用这种方式来实现写操作：跟随者接收到客户端的写请求时，拒绝该请求并返回领导者的地址信息给客户端，然后客户端直接访问领导者。

3. 在 Raft 集群中，如何实现读操作，关乎一致性的实现，推荐实现 default、consistent、stale 三种一致性模型，将一致性的选择权交给用户，让用户根据实际业务特点，按需选择，灵活使用。

最后，我想说的是，这个基本的分布式 KV 系统，除了适合入门学习外，也比较适合配置中心、名字服务等小数据量的系统。另外我想补充一下，对于数据层组件，不仅性能重要，成本也很重要，而决定数据层组件的成本的最关键的一个理念是冷热分离，一般而言，可以这么设计三级缓存：

热数据：经常被访问到的数据，我们可以将它们放在内存中，提升访问效率。

冷数据：有时会被访问到的数据，我们可以将它们放在 SSD 硬盘上，访问起来也比较快。

陈旧数据：偶尔会被访问到的数据，我们可以将它们放在普通磁盘上，节省存储成本。

在实际系统中，你可以统计热数据的命中率，并根据命中率来动态调整冷热模型。在这里，我想强调的是，冷热分离理念在设计海量数据存储系统时尤为重要，比如，自研 KV 存储的成本仅为 Redis 数十分之一，其中系统设计时非常重要的一个理念就是冷热分离。希望你能重视这个理念，在实际场景中活学活用。

课堂思考

我提到了其他节点与领导者节点通讯，将自己的配置信息发送给领导者节点，然后领导者节点调用 addVoter() 函数，将新节点加入到集群中，那么，你不妨思考一下，当节点故障时，如何替换一个节点呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

20 | 基于Raft的分布式KV系统开发实战（二）：如何实现代码？

你好，我是韩健。

学完上一讲后，相信你已经了解了分布式 KV 系统的架构设计，同时应该也很好奇，架构背后的细节代码是怎么实现的呢？

别着急，今天这节课，我会带你弄明白这个问题。我会具体讲解分布式 KV 系统核心功能点的实现细节。比如，如何实现读操作对应的 3 种一致性模型。而我希望你能在课下反复运行程序，多阅读源码，掌握所有的细节实现。

话不多说，我们开始今天的学习。

在上一讲中，咱们将系统划分为三大功能块（接入协议、KV 操作、分布式集群），那么今天我会按顺序具体说一说每块功能的实现，帮助你掌握架构背后的细节代码。首先，先来了解一下，如何实现接入协议。

如何实现接入协议？

在 19 讲提到，我们选择了 HTTP 协议作为通讯协议，并设计了"/key"和"/join"2 个 HTTP RESTful API，分别用于支持 KV 操作和增加节点的操作，那么，它们是如何实现的呢？

接入协议的核心实现，就是下面的样子。

图1

我带你走一遍这三个步骤，便于你加深印象。

在 ServeHTTP() 中，会根据 URL 路径设置相关的路由信息。比如，会在 handlerKeyRequest() 中处理 URL 路径前缀为"/key"的请求，会在 handleJoin() 中处理 URL 路径为"/join"的请求。

在 handleKeyRequest() 中，处理来自客户端的 KV 操作请求，也就是基于 HTTP POST 请求的赋值操作、基于 HTTP GET 请求的查询操作、基于 HTTP DELETE 请求的删除操作。

在 handleJoin() 中，处理增加节点的请求，最终调用 raft.AddVoter() 函数，将新节点加入到集群中。

在这里，需要你注意的是，在根据 URL 设置相关路由信息时，你需要考虑是路径前缀匹配（比如 strings.HasPrefix(r.URL.Path, “/key”)），还是完整匹配（比如 r.URL.Path == “/join”），避免在实际运行时，路径匹配出错。比如，如果对"/key"做完整匹配（比如 r.URL.Path == “/key”），那么下面的查询操作会因为路径匹配出错，无法找到路由信息，而执行失败。

curl -XGET raft-cluster-host01:8091/key/foo

另外，还需要你注意的是，只有领导者节点才能执行 raft.AddVoter() 函数，也就是说，handleJoin() 函数，只能在领导者节点上执行。

说完接入协议后，接下来咱们来分析一下第二块功能的实现，也就是，如何实现 KV 操作。

如何实现 KV 操作？

上一节课，我提到这个分布式 KV 系统会实现赋值、查询、删除 3 类操作，那具体怎么实现呢？你应该知道，赋值操作是基于 HTTP POST 请求来实现的，就像下面的样子。

curl -XPOST http://raft-cluster-host01:8091/key -d '{"foo": "bar"}'

也就是说，我们是通过 HTTP POST 请求，实现了赋值操作。

图2

同样的，我们走一遍这个过程，加深一下印象。

当接收到 KV 操作的请求时，系统将调用 handleKeyRequest() 进行处理。

在 handleKeyRequest() 函数中，检测到 HTTP 请求类型为 POST 请求时，确认了这是一个赋值操作，将执行 store.Set() 函数。

在 Set() 函数中，将创建指令，并通过 raft.Apply() 函数将指令提交给 Raft。最终指令将被应用到状态机。

当 Raft 将指令应用到状态机后，最终将执行 applySet() 函数，创建相应的 key 和值到内存中。

在这里，我想补充一下，FSM 结构复用了 Store 结构体，并实现了 fsm.Apply()、fsm.Snapshot()、fsm.Restore()3 个函数。最终应用到状态机的数据，以 map[string]string 的形式，存放在 Store.m 中。

那查询操作是怎么实现的呢？它是基于 HTTP GET 请求来实现的。

curl -XGET http://raft-cluster-host01:8091/key/foo

也就是说，我们是通过 HTTP GET 请求实现了查询操作。在这里我想强调一下，相比需要将指令应用到状态机的赋值操作，查询操作要简单多了，因为系统只需要查询内存中的数据就可以了，不涉及状态机。具体的代码流程如图所示。

图3

我们走一遍这个过程，加深一下印象。

当接收到 KV 操作的请求时，系统将调用 handleKeyRequest() 进行处理。

在 handleKeyRequest() 函数中，检测到 HTTP 请求类型为 GET 请求时，确认了这是一个赋值操作，将执行 store.Get() 函数。

Get() 函数在内存中查询指定 key 对应的值。

而最后一个删除操作，是基于 HTTP DELETE 请求来实现的。

curl -XDELETE http://raft-cluster-host01:8091/key/foo

也就是说，我们是通过 HTTP DELETE 请求，实现了删除操作。

图4

同样的，我们走一遍这个过程。

当接收到 KV 操作的请求时，系统将调用 handleKeyRequest() 进行处理。

在 handleKeyRequest() 函数中，检测到 HTTP 请求类型为 DELETE 请求时，确认了这是一个删除操作，将执行 store.Delete() 函数。

在 Delete() 函数中，将创建指令，并通过 raft.Apply() 函数，将指令提交给 Raft。最终指令将被应用到状态机。

当前 Raft 将指令应用到状态机后，最终执行 applyDelete() 函数，删除 key 和值。

学习这部分内容的时候，有一些同学可能会遇到，不知道如何判断指定的操作是否需要在领导者节点上执行的问题，我给的建议是这样的。

需要向 Raft 状态机中提交指令的操作，是必须要在领导者节点上执行的，也就是所谓的写请求，比如赋值操作和删除操作。

需要读取最新数据的查询操作（比如客户端设置查询操作的读一致性级别为 consistent），是必须在领导者节点上执行的。

说完了如何实现 KV 操作后，来看一下最后一块功能，如何实现分布式集群。

如何实现分布式集群？

创建集群

实现一个 Raft 集群，首先我们要做的就是创建集群，创建 Raft 集群，主要分为两步。首先，第一个节点通过 Bootstrap 的方式启动，并作为领导者节点。启动命令就像下面的样子。

$GOPATH/bin/raftdb -id node01  -haddr raft-cluster-host01:8091 -raddr raft-cluster-host01:8089 ~/.raftdb

这时将在 Store.Open() 函数中，调用 BootstrapCluster() 函数将节点启动起来。

接着，其他节点会通过 -join 参数指定领导者节点的地址信息，并向领导者节点发送，包含当前节点配置信息的增加节点请求。启动命令就像下面的样子。

$GOPATH/bin/raftdb -id node02 -haddr raft-cluster-host02:8091 -raddr raft-cluster-host02:8089 -join raft-cluster-host01:8091 ~/.raftdb

当领导者节点接收到来自其他节点的增加节点请求后，将调用 handleJoin() 函数进行处理，并最终调用 raft.AddVoter() 函数，将新节点加入到集群中。

在这里，需要你注意的是，只有在向集群中添加新节点时，才需要使用 -join 参数。当节点加入集群后，就可以像下面这样，正常启动进程就可以了。

$GOPATH/bin/raftdb -id node02 -haddr raft-cluster-host02:8091 -raddr raft-cluster-host02:8089  ~/.raftdb

集群运行起来后，因为领导者是可能会变的，那么如何实现写操作，来保证写请求都在领导者节点上执行呢？

写操作

在 19 讲中，我们选择了方法 2 来实现写操作。也就是，当跟随者接收到写请求后，将拒绝处理该请求，并将领导者的地址信息转发给客户端。后续客户端就可以直接访问领导者（为了演示方便，我们以赋值操作为例）。

图5

我们来看一下具体的内容。

调用 Set() 函数执行赋值操作。

如果执行 Set() 函数成功，将执行步骤 3；如果执行 Set() 函数出错，且提示出错的原因是当前节点不是领导者，那这就说明了当前节点不是领导者，不能执行写操作，将执行步骤 4；如果执行 Set() 函数出错，且提示出错的原因不是因为当前节点不是领导者，将执行步骤 5。

赋值操作执行成功，正常返回。

节点将构造包含领导者地址信息的重定向响应，并返回给客户端。然后客户端直接访问领导者节点执行赋值操作。

系统运行出错，返回错误信息给客户端。

需要你注意的是，赋值操作和删除操作属于写操作，必须在领导者节点上执行。而查询操作，只是查询内存中的数据，不涉及指令提交，可以在任何节点上执行。

而为了更好的利用 curl 客户端的 HTTP 重定向功能，我实现了 HTTP 307 重定向，这样，你在执行赋值操作时，就不需要关心访问节点是否是领导者节点了。比如，你可以使用下面的命令，访问节点 2（也就是 raft-cluster-host02，192.168.0.20）执行赋值操作。

curl -XPOST raft-cluster-host02:8091/key -d '{"foo": "bar"}' -L

如果当前节点（也就是节点 2）不是领导者，它将返回包含领导者地址信息的 HTTP 307 重定向响应给 curl。这时，curl 根据响应信息，重新发起赋值操作请求，并直接访问领导者节点（也就是节点 1，192.168.0.10）。具体的过程，就像下面的 Wireshark 截图。

图6

相比写请求必须在领导者节点上执行，虽然查询操作属于读操作，可以在任何节点上执行，但是如何实现却更加复杂，因为读操作的实现关乎着一致性的实现。那么，具体怎么实现呢？

读操作

我想说的是，我们可以实现 3 种一致性模型（也就是 stale、default、consistent），这样，用户就可以根据场景特点，按需选择相应的一致性级别，是不是很灵活呢？

具体的读操作的代码实现，就像下面的样子。

图7

我们走一遍这个过程。

当接收到 HTTP GET 的查询请求时，系统会先调用 level() 函数，来获取当前请求的读一致性级别。

调用 Get() 函数，查询指定 key 和读一致性级别对应的数据。

如果执行 Get() 函数成功，将执行步骤 4；如果执行 Get() 函数出错，且提示出错的原因是当前节点不是领导者节点，那么这就说明了，在当前节点上执行查询操作不满足读一致性级别，必须要到领导者节点上执行查询操作，将执行步骤 5；如果执行 Get() 函数出错，且提示出错的原因不是因为当前节点不是领导者，将执行步骤 6。

查询操作执行成功，返回查询到的值给客户端。

节点将构造，包含领导者地址信息的重定向响应，并返回给客户端。然后客户端直接访问领导者节点查询数据。

系统运行出错，返回错误信息给客户端。

在这里，为了更好地利用 curl 客户端的 HTTP 重定向功能，我同样实现了 HTTP 307 重定向（具体原理，前面已经介绍了，这里就不啰嗦了）。比如，你可以使用下面的命令，来实现一致性级别为 consistent 的查询操作，不需要关心访问节点（raft-cluster-host02）是否是领导者节点。

curl -XGET raft-cluster-host02:8091/key/foo?level=consistent  -L

内容小结

本节课我主要带你了解了接入协议、KV 操作、分布式集群的实现，我希望你记住下面三个重点内容：

我们可以借助 HTTP 请求类型，来实现相关的操作，比如，我们可以通过 HTTP GET 请求实现查询操作，通过 HTTP DELETE 请求实现删除操作。

你可以通过 HTTP 307 重定向响应，来返回领导者的地址信息给客户端，需要你注意的是，curl 已支持 HTTP 307 重定向，使用起来很方便，所以推荐你优先考虑 curl，在日常中执行 KV 操作。

在 Raft 中，我们可以通过 raft.VerifyLeader() 来确认当前领导者，是否仍是领导者。

在这里，我还想强调的一点，任何大系统都是由小系统和具体的技术组成的，比如能无限扩展和支撑海量服务的 QQ 后台，是由多个组件（协议接入组件、名字服务、存储组件等）组成的。而做技术最为重要的就是脚踏实地彻底吃透和掌握技术本质，小系统的关键是细节技术，大系统的关键是架构。所以，在课程结束后，我会根据你的反馈意见，再延伸性地讲解大系统（大型互联网后台）的架构设计技巧，和我之前支撑海量服务的经验。

这样一来，我希望能帮你从技术到代码、从代码到架构、从小系统到大系统，彻底掌握实战能力，跨过技术和实战的鸿沟。

虽然这个分布式 KV 系统比较简单，但它相对纯粹聚焦在技术，能帮助你很好的理解 Raft 算法、Hashicorp Raft 实现、分布式系统开发实战等。所以，我希望你不懂就问，有问题多留言，咱们一起讨论解决，不要留下盲区。

另外，我会持续维护和优化这个项目，并会针对大家共性的疑问，开发实现相关代码，从代码和理论 2 个角度，帮助你更透彻的理解技术。我希望你能在课下采用自己熟悉的编程语言，将这个系统重新实现一遍，在实战中，加深自己对技术的理解。如果条件允许，你可以将自己的分布式 KV 系统，以“配置中心”、“名字服务”等形式，在实际场景中落地和维护起来，不断加深自己对技术的理解。

课堂思考

我提到了通过 -join 参数，将新节点加入到集群中，那么，你不妨思考一下，如何实现代码移除一个节点呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

加餐 | 拜占庭将军问题：如何基于签名消息实现作战计划的一致性？

你好，我是韩健。

现在，课程更新了一大半，我也一直关注着留言区的问题，我发现很多同学还是对一些知识有一些误区，再三考虑之后，决定利用今天这节课，先解决留言区提到的一个比较多的问题：如何基于签名消息实现作战计划的一致性？

除此之外，在论文学习中，很多同学遇到的共性问题比较多（比如 ZAB 协议的细节，后面我会补充几讲），在这里，我十分感谢你提出了这样宝贵的意见，不同的声音会帮助我不断优化课程。

所以，在课程结束之后，我会再从头梳理一遍，按照关注点通过更多的加餐不断优化内容，把相关的理论和算法的内容展开，帮你彻底吃透相关的内容。

说回咱们的拜占庭将军问题。在01 讲中，为了不啰嗦，让你举一反三地学习，我对签名消息型拜占庭问题之解，没有详细展开，而是聚焦在最核心的点“签名约束了叛徒的作恶行为”，但从留言来看，很多同学在理解签名和如何实现作战一致性上，还是遇到了问题。比如不理解如何实现作战计划的一致性。

另外，考虑到签名消息是一些常用的拜占庭容错算法（比如 PBFT）的实现基础，很重要，所以这节课我会对签名消息型拜占庭问题之解进行补充。在今天的内容中，除了具体讲解如何基于签名消息实现作战计划的一致性之外，我还会说一说什么是签名消息。希望在帮你掌握签名消息型拜占庭问题之解的同时，还帮你吃透相关的基础知识。

在这里，我想强调一下，为了更好地理解这一讲的内容，我建议你先回顾一下 01 讲，加深印象。当然，在学完 01 讲之后，相信你已经明白了，签名消息拜占庭问题之解，之所以能够容忍任意数量的叛徒，关键就在于通过消息的签名，约束了叛徒的作恶行为，也就是说，任何篡改和伪造忠将的消息的行为，都会被发现。

既然签名消息这么重要，那么什么是签名消息呢？

什么是签名消息？

签名消息指的就是带有数字签名的消息，你可以这么理解“数字签名”：类似在纸质合同上进行签名来确认合同内容和证明身份。

在这里我想说的是，数字签名既可以证实内容的完整性，又可以确认内容的来源，实现不可抵赖性（Non-Repudiation）。既然签名消息优点那么多，那么如何实现签名消息呢？

你应该还记得密码学的学术 CP（Bob 和 Alice）吧（不记得的话也没关系，你把他们当作 2 个人就可以了），今天 Bob 要给 Alice 发送一个消息，告诉她，“我已经到北京了”，但是 Bob 希望这个消息能被 Alice 完整地接收到，内容不能被篡改或者伪造，我们一起帮 Bob 和 Alice 想想办法，看看如何实现这个消息。

首先，为了避免密钥泄露，我们推荐 Bob 和 Alice 使用非对称加密算法（比如 RSA）。也就是说，加密和解密使用不同的秘钥，在这里，Bob 持有需要安全保管的私钥，Alice 持有公开的公钥。

然后，Bob 用哈希算法（比如 MD5）对消息进行摘要，然后用私钥对摘要进行加密，生成数字签名（Signature），就像下图的样子：

图1

接着，Bob 将加密摘要和消息一起发送给 Alice：

图2

接下来，当 Alice 接收到消息和加密摘要（Signature）后，她会用自己的公钥对加密摘要（Signature）进行解密，并对消息内容进行摘要（Degist-2），然后将新获取的摘要（Degist-2）和解密后的摘要（Degist-1）进行对比，如果 2 个摘要（Digest-1 和 Digest-2）一致，就说明消息是来自 Bob 的，并且是完整的，就像下图的样子：

图3

你看，通过这种方法，Bob 的消息就能被 Alice 完整接收到了，任何篡改和伪造 Bob 消息的行为，都会因为摘要不一致，而被发现。而这个消息就是签名消息。

现在，你应该理解了什么是签名消息了吧？另外，关于在留言区提到的“为什么签名消息能约束叛将们的作恶行为？”，在这里，我再补充下，通过上面的 Bob 和 Alice 的故事，我们可以看到，在数字签名的约束下，叛将们是无法篡改和伪造忠将的消息的，因为任何篡改和伪造消息的行为都会被发现，也就是作恶的行为被约束了。也就是说，叛将这时能做“小”恶（比如，不响应消息，或者叛将们相互串通发送指定的消息）但他们无法篡改或伪造忠将的消息了。

既然数字签名约束了叛将们的作恶行为，那么苏秦怎么做才能实现作战的一致性的呢？也就是忠将们执行一致的作战计划。

如何实现作战计划的一致性？

之前我已经提到了，苏秦可以通过签名消息的方式，不仅能在不增加将军人数的情况下，解决二忠一叛的难题，还能实现无论叛将数多少，忠诚的将军们始终能达成一致的作战计划。

为了方便你理解，我以二忠二叛（更复杂的叛徒作恶模型，因为叛徒们可以相互勾结串通）为例具体演示一下，是怎样实现作战计划的一致性的：

图4

需要你注意的是，4 位将军约定了一些流程来发送作战信息、执行作战指令。

第一轮：

先发送作战指令的将军，作为指挥官，其他的将军作为副官。

指挥官将他的签名的作战指令发送给每位副官。

每位副官，将从指挥官处收到的新的作战指令（也就与之前收的作战指令不同），按照顺序（比如按照首字母字典排序）放到一个盒子里。

第二轮：

除了第一轮的指挥官外，剩余的 3 位将军将分别作为指挥官，在上一轮收到的作战指令上，加上自己的签名，并转发给其他将军。

第三轮：

除了第一、二轮的指挥官外，剩余的 2 位将军将分别作为指挥官，在上一轮收到的作战指令上，加上自己的签名，并转发给其他将军。

最后，各位将军按照约定，比如使用盒子里最中间的那个指令来执行作战指令。（假设盒子中的指令为 A、B、C，那中间的指令也就是第 n /2 个命令。其中，n 为盒子里的指令数，指令从 0 开始编号，也就是 B）。

为了帮你直观地理解，如何基于签名消息实现忠将们作战计划的一致性，我来演示一下作战信息协商过程。而且我会分别以忠将和叛将先发送作战信息为例来演示，这样可以完整地演示叛将对作战计划干扰破坏的可能性。

那么忠诚的将军先发送作战信息的情况是什么呢？

为了演示方便，假设苏秦先发起带有签名的作战信息，作战指令是“进攻”。那么在第一轮作战信息协商中，苏秦向齐、楚、燕发送作战指令“进攻”。

图5

在第二轮作战信息协商中，齐、楚、燕分别作为指挥官，向另外 2 位发送作战信息“进攻”。可是楚、燕已经叛变了，但在签名的约束下，他们无法篡改和伪造忠将的消息，为了达到干扰作战计划的目的，他们俩一个选择发送消息，一个默不作声，不配合。

图6

在第三轮作战信息协商中，齐、楚分别作为指挥官，将接收到的作战信息，附加上自己的签名，并转发给另外一位（这时的叛徒燕，还是默不作声，不配合）。

图7

最终，齐收到的作战信息都是“进攻”（它收到了苏秦和楚的），按照“执行盒子最中间的指令”的约定，齐会和苏秦一起执行作战指令“进攻”，实现忠将们作战计划的一致性。

那么如果是叛徒楚先发送作战信息，干扰作战计划，结果会有所不同吗？我们来具体看一看。在第一轮作战信息协商中，楚向苏秦发送作战指令“进攻”，向齐、燕发送作战指令“撤退”。（当然还有其他的情况，这里只是选择了其中一种，其他的情况，你可以都推导着试试，看看结果是不是一样？）

图8

然后，在第二轮作战信息协商中，苏秦、齐、燕分别作为指挥官，将接收到的作战信息，附加上自己的签名，并转发给另外两位。

图9

为了达到干扰作战计划的目的，叛徒楚和燕相互勾结了。比如，燕拿到了楚的私钥，也就是燕可以伪造楚的签名，这个时候，燕为了干扰作战计划，给苏秦发送作战指令“进攻”，给齐发送作战指令却是“撤退”。

接着，在第三轮作战信息协商中，苏秦、齐、燕分别作为指挥官，将接收到的作战信息，附加上自己的签名，并转发给另外一位。

图10

最终，苏秦和齐收到的作战信息都是“撤退、进攻”，按照“执行盒子最中间的指令”的约定，苏秦、齐和燕一起执行作战指令“撤退”，实现了作战计划的一致性。也就是说，无论叛将楚和燕如何捣乱，苏秦和齐都能执行一致的作战计划，保证作战的胜利。

另外在这里，我想补充一点，签名消息的拜占庭问题之解，也是需要进行 m+1 轮（其中 m 为叛将数，所以你看，只有楚、燕是叛变的，那么就进行了三轮协商）。你也可以从另外一个角度理解：n 位将军，能容忍 (n - 2) 位叛将（只有一位忠将没有意义，因为此时不需要达成共识了）。关于这个公式，你只需要记住就好了，推导过程你可以参考论文。

最后，我想说的是，签名消息型拜占庭问题之解，解决的是忠将们如何就作战计划达成共识的问题，也就只要忠将们执行了一致的作战计划就可以了。但它不关心这个共识是什么，比如，在适合进攻的时候，忠将们可能执行的作战计划是撤退。也就是，这个算法比较理论化。

关于理论化这一点，有的同学会想知道它如何去用，在我看来呢，这个算法解决的是共识的问题，没有与实际场景结合，是很难在实际场景中落地的。在实际场景中，你可以考虑后来的改进过后的拜占庭容错算法，比如 PBFT 算法。

内容小结

本节课我主要带你了解了什么签名消息，以及忠将们如何通过签名消息实现作战的一致性，我希望你明确这样几个重点：

1. 数字签名是基于非对称加密算法（比如 RSA、DSA、DH）实现的，它能防止消息的内容被篡改和消息被伪造。

2. 签名消息约束了叛徒的作恶行为，比如，叛徒可以不响应，可以相互勾结串通，但叛徒无法篡改和伪造忠将的消息。

3. 需要你注意的是，签名消息拜占庭问题之解，虽然实现了忠将们作战计划的一致性，但它不关心达成共识的结果是什么。

最后，我想说的是，签名消息、拜占庭将军问题的签名消息之解是非常经典的基础知识，影响和启发了后来的众多拜占庭容错算法（比如 PBFT），理解了本讲的内容后，你能更好地理解其他的拜占庭容错算法，以及它们如何改进的？为什么要这么改进？比如，在 PBFT 中，基于性能的考虑，大部分场景的消息采用消息认证码（MAC），只有在视图变更（View Change）等少数场景中采用了数字签名。

课堂思考

我演示了在“二忠二叛”情况下，忠将们如何实现作战计划的一致性，那么你不妨推演下，在“二忠一叛”情况下，忠将们如何实现作战计划的一致性呢？欢迎在留言区分享你的看法，与我一同讨论。

最后，感谢你的阅读，如果这篇文章让你有所收获，也欢迎你将它分享给更多的朋友。

结束语 | 静下心来，享受技术的乐趣

你好，我是韩健。

一晃几个月的时间就过去了，这段日子里，我们一起在课程里沟通交流，与我而言，这是一段很特别的经历。我看到很多同学凌晨还在学习、留言，留言区里经常会看到熟悉的身影，比如约书亚、唔多志、每天晒白牙、小晏子，很感谢你们一直保持着学习的热情。

就要说再见了，借今天这个机会，我想跟你唠点儿心里话。我问自己，如果只说一句话会是啥？想来想去，我觉得就是它了：静下心来，享受技术的乐趣。其实这与我之前的经历有关，我想你也能从我的经历中，看到你自己的影子。

我们都有这样的感觉，无论任何事情，如果想把它做好，其实都不容易。我记得自己在开发 InfluxDB 系统期间，为了确保进度不失控，常常睡在公司，加班加点；在写稿期间，为了交付更高质量的课程，我总是会有很多想法，偶尔会通宵写稿，核对每句话、每个细节；再比如，为了解答 kernel_distribution 同学的一个关于外部 PPT 的问题，我通过 Google 找到相关代码的出处，然后反复推敲，在凌晨 4 点准备了一个答案。

当然，技术的学习就更加不容易了，不是读几遍材料、调调代码就可以了，而是需要我们设计检测模型，来验证自己是否准确地理解了技术。我曾见过一些团队，做技术决策的依据是不成立的，设计和开发的系统，尽管迭代多版，也始终稳定不下来。在我看来，这些团队最大的问题，就是对技术的理解不准、不够。

在我看来，我们需要调整下心态，也就是静下心来，全身心地投入，去体会技术的乐趣，“Hack it and enjoy it!”。然后学习和工作中的小成就，又会不断地给我们正反馈，激励我们，最终可以行云流水般地把事情越做越好。

具体到我们课程的主题，也就是分布式技术，该怎么继续精进呢？我们都知道，分布式技术属于新技术，仍在快速发展（比如 Raft 在 2013 年才提出），没有体系化的学习材料，而且知识碎片，学习起来尤为不易。今天我想再补充几点个人看法。

首先是“杨不悔”。也就是我们要“衣带渐宽终不悔，为伊消得人憔悴”。想想你在大学的时候，是不是很执着呢？学习分布式技术，也需要这么个劲头儿。

其次是“张无忌”。也就是我们要“不唯书不唯上只唯实”。理论是为了解决问题的，而不是为了“正确”，理论也是在实战中不断发展的，所以在日常学习和使用技术时，我们要注意妥协，没有十全十美的技术，我们需要根据场景特点，权衡折中使用技术，并且实战也会进一步加深我们对技术的理解。

最后是“师夷长技以制夷”。也就是我们要科学上网，多阅读英文资料。

另外，有些同学可能刚刚接触分布式系统和分布式技术，我对你的建议是“单点突破，再全面开花”。比如，你可以反复研究 20 讲的分布式 KV 系统，然后研究 Raft 算法，最后再去研究其他分布式算法，循序渐进地学习。

为了帮助你更好地学习，掌握“渔”的技巧。在这里，我推荐一些适合入门和深究的学习材料（当然材料不能太多，太多了，相当于没推荐）。

迭戈·安加罗（Diego Ongaro）的博士论文：安加罗的博士论文，对 Raft 算法做了很详细的描述，我建议你反复读，结合源码（比如 Hashicorp Raft）读，直到读懂每一句话。

《Paxos Made Live》：这是 Google 团队的 Paxos 实践总结，我建议你从工程实践的角度去阅读，多想想如果是你，你会怎么做。

《Eventually Consistent》：了解下沃纳·威格尔（亚马逊 CTO）对一致性的理解和定义。

说到这里，我还想强调一点，希望能在后续的工作和学习中帮到你。那就是，“技术要具有成本优势”。什么意思呢？

基于开源软件，我们很容易“堆砌”一套业务需要的功能。基于大型互联网后台（比如 QQ）的架构理念，我们能支撑极其海量的服务和流量。也就是说，实现功能或支撑海量流量，相关的软件和理念，都已经很成熟，不是挑战了，但功能背后的成本问题突出。

而成本就是钱，功能背后的成本问题是需要重视和解决的，比如，自研 KV 存储相比 Redis 降低了数量级倍数的成本。另外，分布式技术本身就是适用于规模业务的，而且随着业务规模的增加，成本的痛点会更加突出。我希望你能注意到这点，在根据实际场景设计系统架构时，如果需要的话，也将成本作为一个权衡点考虑进去。

为什么要考虑这些？因为我真心希望你是分布式系统的架构师、开发者，而不仅仅是开源软件的使用者。

好了，专栏到此就告一段落了。但专栏的结束，也是另一种开始。我会花时间处理还没来得及回复的留言，也会针对一些同学的共性问题策划答疑或者加餐（这是一个承诺，也请你监督）。总的来说，我会继续帮你吃透算法原理，让你掌握分布式系统的开发实战能力。当然，你可以随时在遇到问题时，在留言区留言，我们一起交流讨论。

在文章结尾，我为你准备了一份调查问卷，题目不多，希望你能抽出两三分钟填写一下。我非常希望听听你对这个专栏的意见和建议，期待你的反馈！

最后，我想用一段话结束今天的分享，学习技术的路上你可能会遇到对无法准确理解某技术原理的问题，但你不要觉得孤单，因为这是一个正常的情况，大家都会遇到。如果你觉得某技术的原理，理解起来很吃力，你不妨先把这个技术使用起来，然后多想想，如果是你，你会怎么设计，接着你可以带着自己的猜测去研究技术背后的原理。

希望你能在繁忙的工作中，保持一颗极客的初心，享受技术的乐趣！