OK Log设计思路

OK Log 姊妹篇html

设计

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

生产者与消费者

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

+-----------+
P -> |           |
P -> |     ?     | -> C
P -> |           |
     +-----------+

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

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

+-------------------+
P -> | R                 |
P -> | R     ?     R R R | -> C
P -> | R                 |
     +-------------------+

设计细节

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

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

producers --> forwarders --> ingester ---> **storage** <--- querying  <--- consumer

          +---+           +---+
P -> F -> | I |           | Q | --.
P -> F -> |   |           +---+   |
          +---+           +---+   '->
          +---+     ?     | Q | ----> C
P -> 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 R
P -> 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 | ----> C
P -> 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         no
  
A-----B      B >= C?
  C-D            yes
  
A-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:不明白)
相关文章
相关标签/搜索