从零开始构建分布式日志系统:设计一个新系统

原文:Building a Distributed Log from Scratch, Part 5: Sketching a New System
作者:Tyler Treat
翻译:雁惊寒

摘要:本文简单介绍了如何搭建一个分布式日志系统。以下是译文。

在本系列的第四篇文章中,我们研究了一些比较重要的与分布式日志实现相关的优缺点,并讨论了在构建NATS流的过程中学到的一些经验教训。本文是本系列的第五篇文章,也是最后一篇,我们将基于前面四篇文章讨论的要点设计一个崭新的日志系统。

背景

需要指出的是,NATS和NATS Streaming是两个不同的东西。 NATS Streaming是一个建立在NATS之上的基于日志的流式传输系统,而NATS是一个轻量级的pub/sub消息投递系统。 NATS最初是作为Cloud Foundry的控制层面创建的(随后开源了)。 NATS Streaming则是为了响应社区更高级别的要求(持久化、至少一次的投递)而创造出来了,因此,它超越了NATS所能提供的功能,并作为NATS之上的一个单独的层构建的。本人倾向于将NATS描述为拨号音,无所不在,这对于“在线”通信非常合适。 而NATS Streaming则是语音邮件,“请在哔声之后留言,稍后会有人听到”。当然,它们之间还有更多的细微差别,但这些是最主要的。

这里的关键是:NATS和NATS Streaming是不同的系统,具有不同的协议、不同的API和不同的客户端库。实际上,NATS Streaming被设计为充当NATS的客户端。因此,对端客户端不会直接与NATS Streaming交互,而是所有的通信都必须通过NATS。但是,NATS Streaming二进制文件可以嵌入到NATS中或者独立部署。该架构如下图所示,来自于NATS的官网。



从架构上来说,这非常有意义。它符合端到端的原则,因为我们添加了一个附加层,而不是直接修改底层架构。然而,这个特殊的架构也引入了一些难题(秘闻:虽然我仍然是NATS的粉丝,但我不再参与NATS项目,NATS团队已经意识到这些问题,毫无疑问,他们正在努力解决这些问题)。

首先,NATS和NATS Streaming之间不存在“串扰”,这意味着发布到NATS的消息在NATS Streaming中是不可见的,反之亦然。再次,它们是两个完全独立的系统,只是共享相同的架构。这意味着我们并没有真正地对NATS的消息持久化进行分层,我们只是公开了一个提供这些语义的新系统。

其次,NATS Streaming作为NATS的“边三轮”,其所有的通信都是通过NATS进行的,因此NATS连接存在固有的瓶颈。这可能只是在理论上存在限制,但是它却使得某些优化无法实现,例如使用sendfile对日志进行零拷贝读取。这也意味着即使服务器可以立即发送响应,也可能会发生超时。

第三,NATS Streaming目前在线性扩展方面缺少卖点,除了在应用层面上运行多个集群和分区通道之外。关于单个通道的伸缩,目前唯一的方法是在应用级别将其划分为多个频道。

第四,在不扩展协议的情况下,NATS Streaming的授权在本质上受限于NATS提供的授权,因为所有的通信都是通过NATS进行的。这本身并不是个问题。 NATS支持多用户身份验证和主题级权限,但由于NATS Streaming在NATS上使用了不透明的协议,因此很难在流层面设置合适的访问控制列表。当然,许多分层协议都支持认证,比如基于TCP的HTTP。NATS Streaming协议可以携带认证令牌或会话密钥,但目前它并没有这样做。

第五,NATS Streaming不支持通配符语义,在我看来,这是NATS的一大卖点,这正是一些用户所期望的。具体一点地说,NATS在主题订阅中支持两个通配符:星号(*),匹配主题中的任何标记(例如,foo.*匹配foo.barfoo.baz等等),以及完全通配符(>),匹配主题尾部的一个或多个标记(例如,foo.>匹配foo.barfoo.bar.baz等等)。请注意,NATS Streaming中的这种限制与整个体系结构并不直接相关,而是更多的跟我们打算如何设计日志有关。

更一般地说,集群和数据复制更像是NATS Streaming后来添加的东西。正如我们在第四篇中所讨论的那样,这个很难在事后添加。结合NATS Streaming提供的API(可用于流量控制和跟踪消费者的状态),这会让服务器变得更加复杂。

一个崭新的系统

除了集群之外,我并没有怎么参与到NATS Streaming的研发工作中去。但是,从这个工作、我自己使用NATS的经历,以及社区中的讨论中,我知道该如何开发一个类似的东西。虽然它可能看起来不同于NATS Streaming或Kafka,但总有一些相似的地方。我把这个理论体系称为Jetstream ,尽管我还没有真正创建过除了小型原型以外的其他任何东西。这是项目的副产品,我希望在未来的某个时间能够完成。

跟NATS Streaming一样,Jetstream是一个独立的组件,充当NATS的客户端。但它与NATS Streaming不同,它对NATS进行了增强,而不是实现一个全新的协议。接下来,我们将讨论如何实现这一设计。

串扰

在NATS Streaming中,日志被建模为channel。客户通过发布或订阅主题(在NATS中称为subject)来创建通道。一个通道可能是foo,但是在内部,这个通道可能会被翻译成一个NATS pub/sub主题,比如_STAN.pub.foo。因此,NATS Streaming在技术上是NATS的客户端,仅用于调度客户端和服务器之间的通信。日志是在普通的pub/sub消息上实现的。

Jetstream只是NATS的消费者。其中,日志被建模为stream。客户端会显式地创建流。由于Jetstream消息就是NATS消息,因此,不存在“串扰”或内部主题。客户端就像平常一样将消息发布给NATS,然后Jetstream在后台将消息存储在日志文件中。从某种意义上说,这只是一个流经NATS的消息审计日志。



由于流被绑定到了NATS主题上,因此,我们可以很轻松地获得通配符这个特性。但是,这其中也有一些优缺点,稍后我们会讨论。

性能

Jetstream并不跟踪订阅的位置。消费者需要自己跟踪它们在某个流中的位置,或者将它们的位置存储在流中(稍后会详细介绍)。也就是说,我们可以将一个流看作是一个简单的日志,它能够快速、顺序地执行磁盘I/O,并最大限度地减少复制、协议冗余以及代码的复杂性。

消费者可以使用基于pull的套接字API直接连接到Jetstream上。日志以第一篇中所述的方式进行存储。这使我们能够从流中进行零拷贝读取,以及应用不能在NATS Streaming中使用的其他重要优化。它还简化了流量控制和批处理,正如我们在第三篇中讨论的那样。

可扩展性

Jetstream从设计一开始就支持集群和横向扩展。我们知道,NATS在路由消息方面效率很高,特别是消费者的扇出度很高,并且还提供了兴趣图的聚类。流在Jetstream中提供了存储和可扩展性的基本单位。

流是连接到某个NATS主题的命名日志。类似于Kafka中的分区,每个流都有一个replicationFactor,它控制了参与复制流的Jetstream集群中的节点数量,而且,每个流都有一个leader。leader负责接收来自NATS的消息,对它们进行排序,并执行复制操作。

和Kafka的控制器一样,Jetstream集群也有一个元数据leader,它负责处理创建或删除流的请求。如果请求被发送给follower,它会自动将请求转发给leader。在流被创建的时候,元数据leader选择将replicationFactor节点添加到流中并且将流复制到集群中的所有节点上。一旦复制完成,流就完成了创建,它的leader就开始处理消息。这意味着NATS消息不会被存储,除非存在匹配其主题的流。

多个流可以附加到同一个NATS主题或者具有等同语义的主题上,例如,foo.barfoo.*。在NATS进行处理的时候,每个流都会收到消息的一个副本。然而,流的名称在给定的主题上是唯一的。例如,分别为主题foo.bar创建的名为foobar的两个流将会对NATS主题foo.bar上的所有消息进行独立排序,但试图为同一个主题创建两个名称都为foo的流,那么最终将只会创建一个流。

考虑到这一点,我们可以按照第三篇所讲的那样进行横向扩展,即向Jetstream集群添加更多的节点,并在集群中创建更多的流。这样做的好处是,只要NATS能够承受负载,我们就不必担心分区问题。我们可以从存储和消费中分离出消息路由,以进行独立扩展。

另外,流可以被加入到一个命名的用户组中。实际上,在组中的流​​之间划分NATS主题让我们能够为负载均衡而创建竞争的消费者。这是通过NATS队列订阅来实现的,所以,它的缺点是分区是随机的。但好消息是消费者组不会影响正常的流。



压缩和偏移量跟踪

流支持多种日志压缩规则:基于时间的、基于消息的、基于大小的。和Kafka一样,我们还支持第四种压缩规则:键压缩,我们在第三篇中做了描述。

如上所述,Jetstream中的消息只是NATS消息。 Jetstream不需要特殊的协议来处理消息。然而,发布者可以通过提供额外的元数据并将消息序列化到报文中来“增强”消息。报文包括一个特殊的Cookie,用于检测该消息是报文还是普通的NATS信息。

报文中有一个可选的元数据字段,消息键。一个流可以被配置为按键压缩。在这种情况下,它只保留每个键的最后一个消息(如果键不存在,这消息都会被保留下来)。

消费者可以选择在Jetstream中存储它们的偏移量,并发布它们最新的偏移量。这样,消费者就可以从流中检索到自己的偏移量。为了提高性能,客户端应该定期检查这个偏移量。

授权

由于Jetstream是一个独立的服务器,并且仅仅是NATS的消费者,所以它可以在流上提供ACL或者其他的授权机制。只需一个简单的配置就可以限制NATS访问Jetstream,或者配置成Jetstream只允许访问某些主题。由于存在一个独立的访问控制系统,所以牵涉的东西比较多,但是分离的系统提供了更大的灵活性。

至少一次的投递

为了确保消息的至少一次的投递,Jetstream依赖于复制和发布者应答机制。当一条消息在一个流上被接收时,leader会给它分配一个偏移量,然后消息被复制。在复制成功后,流会向附加到消息回复主题上的NATS发布该对应的应答。

这其中包含了两层含义。首先,如果发布者不关心消息是否被存储,那么就不需要设置回复主题。其次,因为可能有多个(或没有)流连接到主题(流的创建/删除是动态的),所以发布者不可能知道到底有多少个应答。我们认为,如果要确保投递的有效性,那么发布者应负责提前确定目的流。请注意,这可能是未来改进的一个方面,例如将流存储到注册表中。然而,这与其他的系统是类似的,比如Kafka,你必须先创建一个主题,然后再发布消息到该主题。

复制协议

对于元数据的复制和领导关系的选择,我们依靠Raft。然而,对于流的复制,我们并没有使用Raft,而是使用了在第二篇中提到的类似于Kafka的技术。

对于每个流,我们都维护了一个同步副本集(ISR),并且是所有副本中最新的。在复制的过程中,leader将消息写入WAL,并且在提交之前我们只等待ISR中的副本。如果复制落后或失败,则将其从ISR中移除。如果leader失败,则ISR中的任何副本都可以将其取代。流复制的过程一般是这样的:

  1. 客户端创建一个流,replicationFactorn
  2. 元数据leader选择n副本,并随机选择一名leader。
  3. 元数据leader通过Raft将流复制到整个群集上。
  4. 参与到流的节点对其进行初始化,leader订阅NATS主题。
  5. leader将高水位标志(HW)初始化为0。这是流中最后提交的消息的偏移量。
  6. leader开始对来自NATS的消息进行排序,并将它们写入未提交日志。
  7. 副本消费leader的日志,并将消息复制到自己的日志中。
  8. 副本通知leader它们已经复制了这个消息。
  9. 一旦leader从ISR收到消息,那么消息就会被提交,并且HW会被更新。

请注意,客户端只能在日志中看到提交的消息。复制过程中可能发生各种故障。下面将介绍这些内容。

如果一个follower怀疑leader失败了,那么它会通知元数据leader。如果元数据leader在有限的时间内收到来自大多数ISR的通知,那么它将为该数据流选择一个新的leader,将此更新应用到Raft组,并通知副本集。已经提交的消息在leader改变的过程中会保留,但未提交的消息可能会丢失。

如果数据流leader检测到副本已经失败或落后太多,它会通过通知元数据leader来从ISR中删除副本。

在失败的副本重启的时候,它将从稳定的存储中恢复最新的HW,并将其日志截断至HW。这可能会删除日志中的所有未提交的消息。然后,副本从HW开始获取leader的消息。一旦副本赶上更新进度,它就会被添加回ISR中,并且系统恢复到完全复制状态。

如果元数据leader失败,那么Raft将负责选择新的leader。元数据Raft组存储了每个流的leader和ISR,因此,元数据leader的故障转移并不是个问题。

总结

这就是构建一个快速、高可用、可扩展的分布式日志的系列文章。在第一篇中,我们介绍了日志抽象,并讨论了它背后的存储机制。在第二篇中,我们介绍了高可用性和数据复制。在第三篇中,我们讨论了消息投递的扩展。在第四篇中,我们讨论了一些优缺点,并得到了一些经验教训。最后,在这第五篇文章中,我们基于前面文章讨论的要点设计一个新的日志系统。

本系列文章的目标是了解日志抽象的内部原理,学习如何实现之前提到的三个优先级,并学习一些应用分布式系统理论。希望你能觉得它们有用,或者至少有趣。

参考文献

Benchmarking Commit Logs
Building a Distributed Log from Scratch, Part 4: Trade-Offs and Lessons Learned
Building a Distributed Log from Scratch, Part 1: Storage Mechanics

你可能感兴趣的:(技术翻译)