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节点拒绝链接。也就是说日志系统不能同时有N个节点拒绝forwarders节点日志传输请求。这个能够在系统中进行参数配置。
consumers须要可以在没有任什么时候间分区和副本分配等条件的状况下进行查询。没有这些已知条件,这意味着用户的一个查询老是要分散到每一个query节点上,而后聚合和去重。query节点可能会在任什么时候刻挂掉,启动或者所在磁盘数据空。所以查询操做必须优雅地管理部分结果。
另外一个优化点是,consumers可以执行读修复。一个查询应该返回每个匹配的N个备份数据记录,这个N是复制因子。任何日志记录少于N个备份都是须要读修复的。一个新的日志记录段会被建立而且会复制到集群中。更进一步地优化,独立的进程可以执行时空范围内的顺序查询,若是发现查询结果存在不一致,能够当即进行读修复。
在ingest层和query层之间的数据传输也须要注意。理想状况下,任何ingest节点应该可以把段传送到任何查询节点上。咱们必须优雅地从传输失败中恢复。例如:在事务任何阶段的网络分区。
让咱们如今观察怎么样从ingest层把数据安全地传送到query层。
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的段文件。
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节点是有状态的,所以它们须要一个优雅地关闭进程。有三点:
这个ingesters节点充当一个队列,将记录缓冲到称为段的组中。虽然这些段有缓冲区保护,可是若是发生断电故障,这内存中的段数据没有写入到磁盘文件中。因此咱们须要尽快地将段数据传送到query层,存储到磁盘文件中。在这里,咱们从Prometheus的手册中看到,咱们使用了拉模式。query节点从ingester节点中拉取已经flushed段,而不是ingester节点把flushed段推送到query节点上。这可以使这个设计模型提升其吞吐量。为了接受一个更高的ingest速率,更加更多的ingest节点,用更快的磁盘。若是ingest节点正在备份,增长更多的查询节点一共它们使用。
query节点消费分为三个阶段:
GET /next
, 从每个intest节点获取最老的flushed段。(算法能够是随机选取、轮询或者更复杂的算法,目前方案采用的是随机选取)。query节点接收的段逐条读取,而后再归并到一个新的段文件中。这个过程是重复的,query节点从ingest层消费多个活跃段,而后归并它们到一个新的段中。一旦这个新段达到B个字节或者S秒,这个活跃段将被写入到磁盘文件上而后关闭。POST
方法发送这个段到N个随机存储节点的复制端点。一旦咱们把新段复制到了N个节点后,这个段就被确认复制完成。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-------------|
让咱们如今考虑每个阶段的失败处理
若是一个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 | | +---------+
合并分为三步:
t0 t1 +-------+-------+ | | | | D | E | | | | +-------+-------+
合并减小了查询搜索段的数量。在理想状况下,每次都会且只映射到一个段。这是经过减小读数量来提升查询性能。
观察到合并能改善查询性能,并且也不会影响正确性和空间利用率。在上述合并处理过程当中同时使用压缩算法进行合并后的数据压缩。合适的压缩可使得日志记录段可以在磁盘保留更长的时间(ps: 由于可使用的空间更多了,磁盘也没那么快达到设置的上限),可是会消耗衡更多的CPU。它也可能会使UNIX/LINUX上的grep
服务没法使用,可是这多是不重要的。
因为日志记录是能够单独寻址的,所以查询过程当中的日志记录去重会在每一个记录上进行。映射到段的记录能够在每一个节点彻底独立优化,无需协同。
合并的调度和耦合性也是一个很是重要的性能考虑点。在合并期间,单个合并groutine会按照顺序执行每一个合并任务。它每秒最多进行一次合并。更多的性能分析和实际研究是很是必要的。
每一个查询节点提供一个GET /query
的api服务。用户可使用任意的query节点提供的查询服务。系统受到用户的查询请求后,会在query层的每个节点上进行查询。而后每一个节点返回响应的数据,在query层进行数据归并和去重,并最终返回给用户。
真正的查询工做是由每一个查询节点独立完成的。这里分为三步:
这个pipeline是由不少的io.ReaderClosers
构建的,主要开销在读取操做。这个HTTP响应会返回给查询节点,最后返回给用户。
注意一点,这里的每一个段reader都是一个goroutine,而且reading/filtering
是并发的。当前读取段文件列表还进行goroutine数量的限制。(ps: 有多少个段文件,就会生成相应数量的goroutine)。这个是应该要优化的。
用户查询请求包括四个字段:
grep
来讲,空字符串是匹配全部的记录用户查询结果响应有如下几个字段:
io.Reader
的数据对象 - 归而且排序后的数据流StatsOnly能够用来探索和迭代查询,直到它被缩小到一个可用的结果集
下面是日志管理系统的各个组件设计草案
\n
符号分割