datadog 存储日志和 metric 的系统。
datadog logs 的初始版本
一开始它运行得很好,但没过多久就出现了问题。主要问题是,在多租户集群中,一个行为不当的节点可能会破坏所有租户的体验,在最坏的情况下,会导致整个集群不可用。
每当发生这种情况时,缓解问题都很困难。扩展或扩展过载的集群通常会使情况变得更糟,而不是更好。已经被写入或读取压垮的节点除了正在尝试执行的所有工作之外,还会突然开始将数据传输到彼此。
这个比较接近于一个传统的存储,添加或者减少节点需要同步数据。
因此如果已经被写入压垮的话,新增节点不是一个好选择。

如图是第二代存储系统
重点是独立且隔离的单节点容器取代大型集群式搜索系统。将存储与集群分离。

- shdard router 服务会从 kafka 中读取数据,然后将它写入新的 kafka cluster, 数据将会写入 shard (a group of partition)。tenant 过去五分钟的写入数据会决定分配到合适数量的 shard。(以前的 tanant 数据可能会均匀的分散在所有的节点之上,这里 kafka 的 key 为 tenant)
- 每个 shard 有两个存储节点存储数据
- 一个查询引擎,知道 tenant 的数据分散在哪些 shard,然后进行过滤和聚合。
- 各个节点彼此之间都互不了解。每个节点的行为都像是一个“集群”。这意味着单个行为不当或不健康的节点只能破坏我们分配给它所属分片的租户数量,并且它无法导致“集群”其余部分发生连锁故障。
平台高速增长
当 datadog 内部的 continous profiling 上线,产生了很多十几 kb 需要存储的数据的时候,出现了一些问题:
- 单个 shard 中,如果有一个 tenant 的写入数据量增大,会导致 shard 的其他 tenant 的查询受到影响。 (存储和查询没有分开,shard cluster 还没有来得及发挥作用?或者反过来也是一样的)
除了这些可靠性问题之外,产品团队还开始要求我们提供一些我们难以使用该架构支持的新功能:
- 一些日志记录客户拥有仅偶尔需要查询的关键数据,但希望保留(并立即查询)更长时间,这超出了我们现有架构的成本效益。(如果放在 s3 的话,可以按数据的保留时间以及查询的数据量收费;但是放在磁盘里头的话,成本就是不变的,跟 ssd 成正比)
- 客户和产品团队要求能够查询和聚合事件中的任何字段,而无需提前指定索引哪些字段。(可能需要扫描特别多的数据,对磁盘 io 消耗很高)
- 产品团队希望我们支持数组函数、窗口函数,并将DDSketches直接存储在存储引擎中,这样他们就可以向我们发送预先聚合的草图,然后在查询时重新聚合它们。
而这还只是冰山一角!我们了解到了两件事:
- 我们需要一个全新的架构,将计算与存储分开,以独立扩展提取、存储和查询路径,并在高度多租户环境中提供更大的灵活性来控制隔离、性能和服务质量。
- 我们需要从上到下拥有存储引擎来控制我们的命运并提供我们的产品团队所要求的功能。
第三代存储系统:

角色
存储系统现在分为了 writer, reader 和 compactor。
writer 从 kafka 中读取数据,在内存中缓冲,将文件上传到 blob 存储,并更新元数据。
这些节点完全无状态并且可以自动扩展。
最重要的是,query 节点不会跟 writer 节点进行交互,降低了查询损害摄取的能力。 (作为对比,像 tempo 这种系统它的 querier 会直接的去访问 ingester,即使 ingester 最终将数据上传到 s3 中)
压缩器扫描元数据存储,查找较小的文件,然后重新生成更大的文件,类似 lsm 的架构。以原子事务更新元数据存储,因此查询不会获取不一致的视图。
Reader(叶)节点对 blob 存储中的各个文件运行查询并返回部分聚合,这些聚合由分布式查询引擎重新聚合。这些节点(几乎2 个)是无状态的,可以毫无问题地扩大或缩小规模。
存储
唯一有状态的系统是元数据存储和 blob 存储。
将“存储并且不丢失这些字节”的棘手可扩展性、复制和持久性问题推向了经过实战考验的系统,例如FoundationDB 和 S3
将所有的精力花在高度多组户的环境下查询和摄取大量的数据。
隔离
客户的写入数据和查询的硬件能力没有关系。
利用查询节点的规模来消除不同租户的体验差距。
同样,客户可以向我们发送大量数据,但如果他们不需要低延迟查询,那么我们可以限制他们的查询可以使用的计算量。这是我们在线档案日志层的技术基础。
查询现在是可以隔离的,这种方法(以前是不可行的)带来的最直接明显的可靠性好处是,我们可以将自动监控生成的查询与人类生成的查询隔离开来。我们对系统的可靠性感觉好多了,因为我们知道人工生成的查询永远不会削弱我们评估客户自动监控器的能力,反之亦然。
(想像一个通过数据库比如 clickhouse 而不是 s3 设计的查询系统。 由于它的每一个查询都会扩散到所有的 clickhouse 节点,所以我们没法在查询层做这种级别的隔离。 并且由于计算和存储不是分离的,当查询很多的时候,写入可能会被阻塞,没法去进行独立的扩容。 )
效果
Husky 的中值延迟略高于以前的系统,主要是由于远程存储与本地 SSD 相比“延迟下限”要高得多。
我们最终认为,将中值延迟增加几百毫秒对于我们的客户来说是正确的决定,可以分别节省数秒、数十秒和数百秒的 p95/p99/max 延迟。
logging without limit
datadog 的新功能。
www.datadoghq.com/blog/loggin…
传统的日志解决方案要求团队提供并支付每日日志量的费用,如果没有某种形式的服务器端或代理级过滤,这很快就会变得成本高昂。但在发送日志之前过滤日志不可避免地会导致覆盖范围的缺口,并且通常会过滤掉有价值的数据。毕竟,日志的价值会根据无法提前预测的因素不断变化,例如日志是在正常运行期间生成的,还是在停机或部署期间生成的。
- [动态选择要索引和保留的日志]以进行故障排除和分析(并根据需要覆盖这些过滤器)
- [将丰富的日志存档]在您的长期云存储解决方案中,无需额外付费
- [观察并查询整个基础设施中所有已处理日志的实时跟踪](即使您选择不索引日志)
从这个功能可以看出来,首先写日志一定要抗的住特别高的写入量,并且成本非常低,这个和上面的架构设计的调整是一样的。
首先只有索引的日志才会长期保存,datadog 的索引会包括日志的保留时间(不同保留时间收费不同,越长性价比越高);同时也包括每日限额以控制一天的最多存储量。.
而 15 minutes 是 live search 中会保留的日志数量。如果用磁盘的话,这种超大的删除,会带来很高的负载。
tempo 的具体实现
介绍一个 tempo ,架构设计跟以上差不多,实现可以参考一下。
writer
tempo 将 trace 数据通过 block 的格式上传到 s3,一个 block 由三个文件组成。
- meta,包含 tenant, start end time,data 文件格式这些元信息。
- data,parquet 文件格式。由许多个 rowgroup 组成。每 100M 的 trace 数据就会生成一个 rowgroup。(由于 parquet 会压缩,最终生成的数据估计 20 MB 左右)
- bloom, bloom 过滤器。
- index,记录每个 rowgroup 的最大 traceid。
由于一个 block 通常 500MB 左右,太大了,因此通过 s3 的multipart 进行流式上传以节省上传所需的时间。
生成 block 的时候,每个 traceid 都会在内存中等待一段时间以生成对应的 trace 数据,并且最终的 block 是按照 traceid 进行顺序写入的。
type streamingBlock struct {
ctx context.Context
bloom *common.ShardedBloomFilter
meta *backend.BlockMeta
bw tempo_io.BufferedWriteFlusher
pw *parquet.GenericWriter[*Trace]
w *backendWriter
r backend.Reader
to backend.Writer // 写入 s3 的链接,通过 multipart 去上传数据。
index *index
currentBufferedTraces int
currentBufferedBytes int
}
func (b *streamingBlock) Add(tr *Trace, start, end uint32) error {
_, err := b.pw.Write([]*Trace{tr})
if err != nil {
return err
}
id := tr.TraceID
b.index.Add(id)
b.bloom.Add(id)
b.meta.ObjectAdded(start, end)
b.currentBufferedTraces++
b.currentBufferedBytes += estimateMarshalledSizeFromTrace(tr)
return nil
}
// flush 生成 rowgroup
func (b *streamingBlock) Flush() (int, error) {
// Flush row group
b.index.Flush()
err := b.pw.Flush()
if err != nil {
return 0, err
}
n := b.bw.Len()
b.meta.Size_ += uint64(n)
b.meta.TotalRecords++
b.currentBufferedTraces = 0
b.currentBufferedBytes = 0
// Flush to underlying writer
return n, b.bw.Flush()
}
//每当写入了一个 rowgroup,将数据上传到 s3 并 gc 一次。
if s.EstimatedBufferedBytes() > cfg.RowGroupSizeBytes {
_, err = s.Flush()
if err != nil {
return nil, err
}
runtime.GC()
}
query
query在读取的时候:
- 根据 tenant id 过滤 block。
- 根据查询时间戳过滤对应的 block。
- 根据 trace id 去过滤 block。
以上数据存放在 blockmeta 中。
- 通过 bloom fileter 过滤 block。
这些数据很常用,其实都可以做一个缓存。
- 通过一个 []rowGroup, 通过 trace id 去进行一个二分搜索,找到对应的 rowgroup.
然后通过 s3 read range api 去读取对应的 row group.
distributor
tempo 还有一个 distributor 用于接受数据。
每个数据会按照 tenant 被划分到一个子集,在这个子集中,按照 trace id 去进行 hash。
这样相同 tenant 的相同 trace id 的数据总是写入相同的 write 集合。
理论上,一个疯狂写入的 tenant 就不会去影响全局。
而在 datadog 中,这一层是通过 kafka shard router 去实现的。
compactor
compactor 会将一个时间窗口中的 block 给聚合。
合并 block 有助于减少内存中需要缓存的 block meta。
不过这里的实现感觉也有问题,因为 traceid 是一个 uuid,不带 timestamp, 因此一个搜索可能要找所有的 block。
实际上,这里如果生成端生成 trace 的时候,以时间戳去生成的话,查询的时候可以大大减少要查询的 block 数量。
datadog 的 husky 还做了一个类似 lsm 的局部压缩策略:
www.datadoghq.com/blog/engine…
# Optional. Blocks in this time window will be compacted together. Default is 1h.
[compaction_window: <duration>]
# Optional. Maximum number of traces in a compacted block. Default is 6 million.
# WARNING: Deprecated. Use max_block_bytes instead.
[max_compaction_objects: <int>]
# Optional. Maximum size of a compacted block in bytes. Default is 100 GB.
[max_block_bytes: <int>]
不使用 s3 的情况下做写入和存储的分离
s3 的性能肯定不如 clickhouse;并且复杂度大大提升了。
在原始的 jaeger 中引入分离?
原始的 jaeger 中, kafka 写入没有 key 的。
特殊的应用或者环境的高写入会对全局后端系统产生影响。(比如查询到最新数据的整体延迟增加)。
如果根据写入量来分配分区的话,那么确实只会有指定的业务受到影响。
另外,查询需要查找所有机器,如果能够将数据集中到指定的机器上的话,查询会更快。