OK Log设计思路

来源:互联网 发布:云计算p层 编辑:程序博客网 时间:2024/05/30 05:04

在这个文档中,我们首先在顶层设计上描述这个系统。然后,我们再引入约束和不变量来确定问题域。我们会一步步地提出一个具体的解决方案,描述框架中的关键组件和组件之间的行为。

生产者与消费者

我们有一个大且动态地生产者集,它们会生产大量的日志记录流。这些记录应该可供消费者查找到的。

     +-----------+P -> |           |P -> |     ?     | -> CP -> |           |     +-----------+

生产者主要关心日志被消费的速度尽可能地快。如果这个速度没有控制好,有一些策略可以提供,包括:背压策略(ps: 流速控制), 例如:事件日志、缓冲和数据丢弃(例如:应用程序日志)。在这些情况下,接收日志记录流的组件需要优化顺序写操作。

消费者主要关心尽快地响应用户端的日志查询,保证尽可能快的日志持久化。因为我们定义了查询必须带时间边界条件,我们要确保我们可以通过时间分隔数据文件,来解决grep问题。所以存储在磁盘上的最终数据格式,应该是一个按照时间划分的数据文件格式,且这些文件内的数据是由所有生产者的日志记录流全局归并得到的。如下图所示:

     +-------------------+P -> | R                 |P -> | R     ?     R R R | -> CP -> | R                 |     +-------------------+

设计细节

我们有上千个有序的生产者。(一个生产者是由一个应用进程,和一个forward代理构成)。我们的日志系统有必要比要服务的生产系统小得多。因此我们会有多个ingest节点,每个ingest节点需要处理来自多个生产者的写请求。

我们也想要服务于有大量日志产生的生产系统。因此,我们不会对数据量做还原性假设。我们假设即使是最小工作集的日志数据,对单个节点的存储可能也是太大的。因此,消费者将必须通过查询多个节点获取结果。这意味着最终的时间分区的数据集将是分布式的,并且是复制的。

producers --> forwarders --> ingester ---> **storage** <--- querying  <--- consumer          +---+           +---+P -> F -> | I |           | Q | --.P -> F -> |   |           +---+   |          +---+           +---+   '->          +---+     ?     | Q | ----> CP -> F -> | I |           +---+   .->P -> F -> |   |           +---+   |P -> F -> |   |           | Q | --'          +---+           +---+

现在我们引入分布式,这意味着我们必须解决协同问题。

协同

协同是分布式系统的死亡之吻。(协同主要是解决分布式数据的一致性问题)。我们的日志系统是无协同的。让我们看看每个阶段需要什么。

生产者,更准确地说,forwarders,需要能够连接任何一个ingest节点,并且发送日志记录。这些日志记录直接持久化到ingester所在的磁盘上,并尽可能地减少中间处理过程。如果ingester节点挂掉了,它的forwarders应该非常简单地连接其他ingester节点和恢复日志传输。(根据系统配置,在传输期间,它们可以提供背压,缓冲和丢弃日志记录)言外之意,forwarders节点不需要知道哪个ingest是ok的。任何ingester节点也必须是这样。

有一个优化点是,高负载的ingesters节点可以把负载(连接数)转移到其他的ingesters节点。有三种方式:、

  • ingesters节点通过gossip协议传递负载信息给其他的ingesters节点,这些负载信息包括:连接数、IOps(I/O per second)等。

  • 然后高负载ingesters节点可以拒绝新连接请求,这样forwarders会重定向到其他比较轻量级负载的ingesters节点上。

  • 满负载的ingesters节点,如果需要的话,甚至可以中断已经存在的连接。但是这个要十分注意,避免错误的拒绝合理的服务请求。

例如:在一个特定时间内,不应该有许多ingesters节点拒绝连接。也就是说日志系统不能同时有N个节点拒绝forwarders节点日志传输请求。这个可以在系统中进行参数配置。

consumers需要能够在没有任何时间分区和副本分配等条件的情况下进行查询。没有这些已知条件,这意味着用户的一个查询总是要分散到每个query节点上,然后聚合和去重。query节点可能会在任何时刻挂掉,启动或者所在磁盘数据空。因此查询操作必须优雅地管理部分结果。

另一个优化点是,consumers能够执行读修复。一个查询应该返回每一个匹配的N个备份数据记录,这个N是复制因子。任何日志记录少于N个备份都是需要读修复的。一个新的日志记录段会被创建并且会复制到集群中。更进一步地优化,独立的进程能够执行时空范围内的顺序查询,如果发现查询结果存在不一致,可以立即进行读修复。

在ingest层和query层之间的数据传输也需要注意。理想情况下,任何ingest节点应该能够把段传送到任何查询节点上。我们必须优雅地从传输失败中恢复。例如:在事务任何阶段的网络分区。

让我们现在观察怎么样从ingest层把数据安全地传送到query层。

ingest段

ingesters节点从N个forwarders节点接收了N个独立的日志记录流。每个日志记录以带有ULID的字符串开头。每个日志记录有一个合理精度的时间错是非常重要的,它创建了一个全局有序,且唯一的ID。但是时钟全局同步是不重要的,或者说记录是严格线性增长的。如果在一个很小的时间窗口内日志记录同时到达出现了ID乱序,只要这个顺序是稳定的,也没有什么大问题。

到达的日志记录被写到一个活跃段中,在磁盘上这个活跃段是一个文件。

          +---+P -> F -> | I | -> Active: R R R...P -> F -> |   |P -> F -> |   |          +---+

一旦这个段文件达到了B个字节,或者这个段活跃了S秒,那么这个活跃段就会被flush到磁盘上。(ps: 时间限制或者size大小)

          +---+P -> F -> | I | -> Active:  R R R...P -> F -> |   |    Flushed: R R R R R R R R RP -> F -> |   |    Flushed: R R R R R R R R          +---+

这个ingester从每个forwarder连接中顺序消费日志记录。当当前的日志记录成功写入到活跃的段中后,下一个日志记录将会被消费。并且这个活跃段在flush后立即同步复制备份。这是默认的持久化模式,暂定为fast。

Producers选择性地连接一个独立的端口上,其处理程序将在写入每个记录后同步活跃的段。者提供了更强的持久化,但是以牺牲吞吐量为代价。这是一个独立的耐用模式,暂时定为持久化。(ps: 这段话翻译有点怪怪的,下面是原文)

Producers can optionally connect to a separate port, whose handler will sync the active segment after each record is written. This provides stronger durability, at the expense of throughput. This is a separate durability mode, tentatively called durable.

第三个更高级的持久化模式,暂定为混合模式。forwarders一次写入整个段文件到ingester节点中。每一个段文件只有在存储节点成功复制后才能被确认。然后这个forwarder节点才可以发送下一个完整的段。

ingesters节点提供了一个api,用于服务已flushed的段文件。

  • Get /next ---- 返回最老的flushed段,并将其标记为挂起

  • POST /commit?id=ID ---- 删除一个挂起的段

  • POST /failed?id=ID ---- 返回一个已flushed的挂起段

ps: 上面的ID是指:ingest节点的ID

段状态由文件的扩展名控制,我们利用文件系统进行原子重命名操作。这些状态包括:.active、.flushed或者.pending, 并且每个连接的forwarder节点每次只有一个活跃段。

          +---+                     P -> F -> | I | Active              +---+P -> F -> |   | Active              | Q | --.          |   |  Flushed            +---+   |                  +---+                     +---+   '->          +---+              ?      | Q | ----> CP -> F -> | I | Active              +---+   .->P -> F -> |   | Active              +---+   |P -> F -> |   | Active              | Q | --'          |   |  Flushed            +---+          |   |  Flushed          +---+

观察到,ingester节点是有状态的,因此它们需要一个优雅地关闭进程。有三点:

  • 首先,它们应该中断链接和关闭监听者

  • 然后,它们应该等待所有flushed段被消费

  • 最后,它们才可以完成关闭操作

消费段

这个ingesters节点充当一个队列,将记录缓冲到称为段的组中。虽然这些段有缓冲区保护,但是如果发生断电故障,这内存中的段数据没有写入到磁盘文件中。所以我们需要尽快地将段数据传送到query层,存储到磁盘文件中。在这里,我们从Prometheus的手册中看到,我们使用了拉模式。query节点从ingester节点中拉取已经flushed段,而不是ingester节点把flushed段推送到query节点上。这能够使这个设计模型提高其吞吐量。为了接受一个更高的ingest速率,更加更多的ingest节点,用更快的磁盘。如果ingest节点正在备份,增加更多的查询节点一共它们使用。

query节点消费分为三个阶段:

  • 第一个阶段是读阶段。每一个query节点定期地通过GET /next, 从每一个intest节点获取最老的flushed段。(算法可以是随机选取、轮询或者更复杂的算法,目前方案采用的是随机选取)。query节点接收的段逐条读取,然后再归并到一个新的段文件中。这个过程是重复的,query节点从ingest层消费多个活跃段,然后归并它们到一个新的段中。一旦这个新段达到B个字节或者S秒,这个活跃段将被写入到磁盘文件上然后关闭。

  • 第二个阶段是复制阶段。复制意味着写这个新的段到N个独立的query节点上。(N是复制因子)。这是我们仅仅通过POST方法发送这个段到N个随机存储节点的复制端点。一旦我们把新段复制到了N个节点后,这个段就被确认复制完成。

  • 第三个阶段是提交阶段。这个query节点通过POST /commit方法,提交来自所有ingest节点的原始段。如果这个新的段因为任何原因复制失败,这个query节点通过POST /failed方法,把所有的原始段全部改为失败状态。无论哪种情况,这三个阶段都完成了,这个query节点又可以开始循环随机获取ingest节点的活跃段了。

下面是query节点三个阶段的事务图:

Q1        I1  I2  I3--        --  --  --|-Next--->|   |   ||-Next------->|   ||-Next----------->||<-S1-----|   |   ||<-S2---------|   ||<-S3-------------|||--.|  | S1∪S2∪S3 = S4     Q2  Q3|<-'                   --  --|-S4------------------>|   ||-S4---------------------->||<-OK------------------|   ||<-OK----------------------|||         I1  I2  I3|         --  --  --|-Commit->|   |   ||-Commit----->|   ||-Commit--------->||<-OK-----|   |   ||<-OK---------|   ||<-OK-------------|

让我们现在考虑每一个阶段的失败处理

  • 对于第一个阶段:读阶段失败。挂起的段一直到超时都处于闲置状态。对于另一个query节点,ingest节点的活跃段是可以获取的。如果原来的query节点永远挂掉了,这是没有任何问题的。如果原始的query节点又活过来了,它有可能仍然会消费已经被其他query节点消费和复制的段。在这种情况下,重复的记录将会写入到query层,并且一个或者多个会提交失败。如果这个发生了 ,这也ok:记录超过了复制因子,但是它会在读时刻去重,并且最终会重新合并。因此提交失败应该被注意,但是也能够被安全地忽略。

  • 对于第二个阶段:复制阶段。错误的处理流程也是相似的。假设这个query节点没有活过来,挂起的ingest段将会超时并且被其他query节点重试。如果这个query节点活过来了,复制将会继续进行而不会失败,并且一个或者多个最终提交将将失败

  • 对于第三个阶段:commit阶段。如果ingest节点等待query节点commit发生超时,则处在pending阶段的一个或者多个ingest节点,会再次flushed到段中。和上面一样,记录将会重复,在读取时进行数据去重,然后合并。

节点失败

如果一个ingest节点永久挂掉,在其上的所有段记录都会丢失。为了防止这种事情的发生,客户端应该使用混合模式。在段文件被复制到存储层之前,ingest节点都不会继续写操作。

如果一个存储节点永久挂掉,只要有N-1个其他节点存在都是安全的。但是必须要进行读修复,把该节点丢失的所有段文件全部重新写入到新的存储节点上。一个特别的时空追踪进行会执行这个修复操作。它理论上可以从最开始进行读修复,但是这是不必要的,它只需要修复挂掉的段文件就ok了。

查询索引

所有的查询都是带时间边界的,所有段都是按照时间顺序写入。但是增加一个索引对找个时间范围内的匹配段也是非常必要的。不管查询节点以任何理由写入一个段,它都需要首先读取这个段的第一个ULID和最后一个ULID。然后更新内存索引,使这个段携带时间边界。在这里,一个线段树是一个非常好的数据结构。

另一种方法是,把每一个段文件命名为FROM-TO,FROM表示该段中ULID的最小值,TO表示该段中ULID的最大值。然后给定一个带时间边界的查询,返回所有与时间边界有叠加的段文件列表。给定两个范围(A, B)和(C, D),如果A<=B, C<=D以及A<=C的话。(A, B)是查询的时间边界条件,(C, D)是一个给定的段文件。然后进行范围叠加,如果B>=C的话,结果就是FROM C TO B的段结果

A--B         B >= C?  C--D           yes A--B         B >= C?     C--D         noA-----B      B >= C?  C-D            yesA-B          B >= C?C----D           yes

这就给了我们两种方法带时间边界的查询设计方法

合并

合并有两个目的:

  • 记录去重

  • 段去叠加

在上面三个阶段出现有失败的情况,例如:网络故障(在分布式协同里,叫脑裂),会出现日志记录重复。但是段会定期透明地叠加。

在一个给定的查询节点,考虑到三个段文件的叠加。如下图所示:

t0             t1+-------+       ||   A   |       |+-------+       ||  +---------+  ||  |    B    |  ||  +---------+  ||     +---------+|     |    C    ||     +---------+

合并分为三步:

  • 首先在内存中把这些重叠的段归并成一个新的聚合段

  • 在归并期间,通过ULID来进行日志记录去重和丢弃

  • 最后,合并再把新的聚合段分割成多个size的段,生成新的不重叠的段文件列表

t0             t1+-------+-------+|       |       ||   D   |   E   ||       |       |+-------+-------+

合并减少了查询搜索段的数量。在理想情况下,每次都会且只映射到一个段。这是通过减少读数量来提高查询性能。

观察到合并能改善查询性能,而且也不会影响正确性和空间利用率。在上述合并处理过程中同时使用压缩算法进行合并后的数据压缩。合适的压缩可以使得日志记录段能够在磁盘保留更长的时间(ps: 因为可以使用的空间更多了,磁盘也没那么快达到设置的上限),但是会消耗衡更多的CPU。它也可能会使UNIX/LINUX上的grep服务无法使用,但是这可能是不重要的。

由于日志记录是可以单独寻址的,因此查询过程中的日志记录去重会在每个记录上进行。映射到段的记录可以在每个节点完全独立优化,无需协同。

合并的调度和耦合性也是一个非常重要的性能考虑点。在合并期间,单个合并groutine会按照顺序执行每个合并任务。它每秒最多进行一次合并。更多的性能分析和实际研究是非常必要的。

查询

每个查询节点提供一个GET /query的api服务。用户可以使用任意的query节点提供的查询服务。系统受到用户的查询请求后,会在query层的每一个节点上进行查询。然后每个节点返回响应的数据,在query层进行数据归并和去重,并最终返回给用户。

真正的查询工作是由每个查询节点独立完成的。这里分为三步:

  • 首先匹配查询时间边界条件的段文件被标记。(时间边界条件匹配)

  • 对于第一步获取的所有段,都有一个reader进行段文件查找匹配的日志记录,获取日志记录列表

  • 最后对获取到的日志记录列表通过归并Reader进行归并,排序,并返回给查询节点

这个pipeline是由很多的io.ReaderClosers构建的,主要开销在读取操作。这个HTTP响应会返回给查询节点,最后返回给用户。

注意一点,这里的每个段reader都是一个goroutine,并且reading/filtering是并发的。当前读取段文件列表还进行goroutine数量的限制。(ps: 有多少个段文件,就会生成相应数量的goroutine)。这个是应该要优化的。

用户查询请求包括四个字段:

  • FROM, TO time.Time - 查询的时间边界

  • Q字符串 - 对于grep来说,空字符串是匹配所有的记录

  • Regex布尔值 - 如果是true,则进行正则表达式匹配

  • StatsOnly布尔值 - 如果是true,只返回统计结果

用户查询结果响应有以下几个字段:

  • NodeCount整型 - 查询节点参与的数量

  • SegmentCount整型 - 参与读的段文件数量

  • Size整型 - 响应结果中段文件的size

  • io.Reader的数据对象 - 归并且排序后的数据流

StatsOnly可以用来探索和迭代查询,直到它被缩小到一个可用的结果集

组件模型

下面是日志管理系统的各个组件设计草案

进程

forward
  • ./my_application | forward ingest.mycorp.local:7651

  • 应该接受多个ingest节点host:ports的段拉取 

  • 应该包含DNS解析到单个实例的特性

  • 应该包含在连接断掉后进行容错的特性

  • 能够有选择fast, durable和chunked写的特性

  • Post-MVP: 更复杂的HTTP? forward/ingest协议;

ingest
  • 可以接收来自多个forwarders节点的写请求

  • 每条日志记录以\n符号分割

  • 每条日志记录的前缀必须是ULID开头

  • 把日志记录追加到活跃段中

  • 当活跃段达到时间限制或者size时,需要flush到磁盘上

  • 为存储层的所有节点提供轮询的段api服务

  • ingest节点之间通过Gossip协议共享负载统计数据

  • Post-MVP: 负载扩展/脱落;分段到存储层的流传输

store
  • 轮询ingest层的所有flush段

  • 把ingest段归并到一起

  • 复制归并后的段到其他存储节点上

  • 为客户端提供查询API服务

  • 在某个时刻执行合并操作

  • Post-MVP:来自ingest层的流式段合并;提供更高级的查询条件

Libraries

Ingest日志
  • 在ingest层的段Abstraction

  • 主要操作包括:创建活跃段,flush、pending标记,和提交

  • (I've got a reasonable prototype for this one) (ps: 不明白)

  • 请注意,这实际上是一个磁盘备份队列,有时间期限的持久化存储

Store日志
  • 在storage层的段Abstraction

  • 操作包括段收集、归并、复制和合并

  • 注意这个是长期持久化存储

集群

  • 来之各个节点之间的信息的Abstraction

  • 大量的数据共享通信是不必要的,只需要获取节点身份和健康检查信息就足够了

  • HashiCorp's memberlist fits the bill (ps:不明白)

原创粉丝点击