本文来源jocko作者的一篇博客,地址:medium.com/the-hoard/b…
构建一个不依赖zookeeper的kafak
“是否可以构建一个不依赖于Zookeeper的kafka?”这个问题引导我开发了一个使用golang实现的Kafka,名为Jocko。
最近,在项目中,我采用Serf和Raft技术整合了服务发现和服务一致性功能,从而摆脱了对ZooKeeper的依赖,减少用户在此方面的运维负担,并确保提供同等水平的服务保障。在这个过程中,我对以下几点有了深刻的认识:
- Kafka为何要依赖ZooKeeper
- 自建服务发现与一致性机制的优点
- Serf和Raft作为构建分布式服务的强大工具价值
- 如何利用Serf和Raft在分布式服务中集成服务发现与一致性解决方案
我已经就这些内容撰写了文章,并希望那些使用Kafka的人、对Jocko感兴趣的人,以及想要构建分布式服务的开发者能从这篇文章中受益。
在这篇文章中,我提到了 “内置”(build in) 这一概念,我的意思是将这一特性直接整合到Jocko中,而非依赖于其他额外的服务来实现。当我在讨论Raft和Serf时,我更多地是指它们的具体实现层面,而非协议本身。
Kafka使用ZooKeeper(ZK)来实现什么功能?Kafka利用ZooKeeper进行服务发现——即获取服务位置的过程,以及达成共识——也就是让多个节点对某件事情达成一致意见的过程。ZooKeeper在Hadoop生态系统中的众多项目中被广泛使用,Kafka就是其中之一。
在Kafka集群中,服务发现有助于各broker节点找到彼此,并了解哪些节点属于集群的一部分;而共识机制则帮助broker节点选举出集群控制器,明确知道存在哪些分区、它们分别位于何处,以及自身是否为某个分区的领导者、还是跟随者并需要进行复制等信息。
ZooKeeper在2007年发布时是协调服务的绝佳选择之一。如今,分布式服务开发者有了更好的选择方案。
首先,Consul、etcd等服务通过以下方式区分自身与ZooKeeper的不同:
- 简化安装和部署。二进制安装,少量配置(可能只需要将参数传递到CLI)
- 提供高效且特定的高级API。在ZooKeeper中,您需要处理目录和文件,并基于其基本的键值存储构建服务发现、集群成员关系和领导者选举等功能。Kafka与ZooKeeper相关的代码超过2700行,另外还有700多行用于文档说明(这个数字是通过在其仓库中搜索(zookeeper|zk)得出的,实际上甚至不包括那些虽与ZK相关但未包含这些字符串的代码行)。而Consul为这些功能提供了高层次、具有明确观点的框架,从而节省了学习和开发自定义系统所需的时间。
- 内部技术的升级。Consul基于Serf构建——这是一个轻量级、高效的去中心化工具,提供服务发现、集群成员关系、故障检测以及用户事件功能。Serf采用gossip协议来分发健康检查和故障检测信息,能够适应任意规模的集群。相比之下,ZooKeeper的工作负载与其节点数量成线性关系,故障检测至少需要等待超时时间,且客户端必须是“重型”的——因为它们需要维持与ZooKeeper的活动连接,编写起来较为困难,而且经常会出现bug。
到目前为止,我已经将ZooKeeper与Consul进行了比较,并讨论了后者的优势。实际上,Consul的核心由两个库——Serf和Raft组成,这两个库可以独立使用来解决相同的问题。作为分布式服务的开发者,如果你利用它们来内置协调机制,就可以减轻用户配置依赖服务的负担,从而引出下一个可行的选择方案。
其次,诸如Serf和Raft这样的库被Nomad、InfluxDB、Consul、etcd、rqlite等众多项目所采用。其中,Serf是一个用于服务发现的工具,而Raft则是一个用于达成共识和领导者选举的工具。鉴于它们构成了Consul优秀特性的重要部分,上述提到的优点,比如坚实的API设计和技术基础等,同样适用于这些库本身。我想要特别关注的是,在您的服务中使用这些库能带来的独特优势。
- 从用户身上移除依赖服务的负担。由于服务直接内嵌了这些库实现协调功能,用户不再需要单独设置ZooKeeper(或类似服务)。
- 利用它们对事件处理的工作成果。Serf和Raft都提供了可以被利用来构建自身特性的事件接口。例如,Serf有一个关于成员变更事件的通道,Jocko通过这个通道得知集群中有哪些broker。而Raft则有一个关于领导权变更事件的通道,Jocko可以借此进行集群控制器的选举。
- 构建集群通信的框架。Raft管理了一个复制日志,并且可以与有限状态机(FSM)结合使用以管理复制状态机。就像Kafka——一个日志服务——可以作为消息系统使用,使得消费者能够在日志上运行任意操作一样,Raft也可以用作一种机制,驱动基于其复制日志的状态机执行各种动作。
我认为这是分布式服务的最佳应用场景,因为它消除了最大的成本:
- 运行ZK所需的时间(和金钱);
- 由于现在有好的工具,因此需要构建协调的努力
(换句话说,通过使用像Serf和Raft这样的库直接构建在服务内部的协调机制,不仅省去了运行如ZooKeeper这类额外服务的成本(包括时间成本和经济成本),还简化了开发过程,因为可以直接利用现成的良好工具来实现所需的协调与共识功能。)
Jocko如何基于Serf和Raft构建集群协调机制。在启动时,每个Jocko Broker实例背后都会自动创建对应的Serf实例(在此处我将其称为“serf”)和Raft实例(称为“raft”)。因此,一个Jocko集群实际上由3个层次的组构成,每个组各司其职,并建立在下一层的基础之上:
- Serf成员:集群中的各个节点首先作为Serf服务发现系统的一部分,负责节点之间的网络通信、成员变更通知以及故障检测。
- Raft对等节点:在Serf层之上,这些节点进一步作为Raft一致性协议的参与方,通过Raft来实现日志复制和领导选举,以达成共识并确保数据的一致性与可靠性。
- Jocko Brokers:最后,在Raft层之上构建了实际的Jocko Kafka代理节点,它们利用Serf的服务发现能力和Raft的共识机制来管理和维护分布式消息队列服务,从而构成了一个具备高可用性和强一致性的Jocko集群。
Jocko如何处理状态变更请求。对于那些会改变集群状态的请求,其处理流程如下——假设你发送了一个创建主题的请求。这个请求将由作为Raft领导者(即集群控制器)的节点来处理。集群控制器会对该请求生成一个日志条目,大致过程如下:
add partition(
id, // this partition's ID
topic, // topic being created,
replicas, // brokers partition's replicas are assigned to,
leader // the broker that the leader replica is assigned to
)
对于主题中的每个分区,Raft会复制并应用该日志至整个集群中。各个代理(broker)会根据它们是否被分配了该分区的副本而对这个日志采取不同的处理方式——如果没有分配副本,则除了更新元数据外不做任何其他操作;如果分配了副本但不是领导者,它们会从领导者那里开始进行复制;如果分配了副本且恰好是领导者,则开始处理来自客户端的拉取(fetch)和生产(produce)请求。
所有需要达成共识的状态变更请求都以类似的方式处理。如果你将一个涉及集群写入状态的请求发送到非控制器的代理上,在Kafka尚未支持这一功能的情况下,该请求会被转发给控制器。
像fetch和produce这类针对特定代理、仅读取集群状态的请求,其工作原理与Kafka相同。首先,客户端使用Metadata API获取它所关注的分区信息,包括分配给它的代理、最新偏移量等。然后,客户端将协议编码后的请求(例如fetch请求)发送到该代理,代理执行相关操作,并返回响应。存储内部机制的工作原理是非常有趣的。
总结来说,内建服务发现和服务一致性功能非常棒,因为它节省了用户的时间和金钱成本。你可能会认为这样做会让服务构建者比使用专门的服务付出更多工作,但实际上,通过Serf和Raft这样的工具可以减少工作量。我还没有充分阐述有限状态机(FSM)在其中的实用性,它有多么有用,我希望在未来的文章中深入探讨这一点。