「译文」Loki 简明指南:如何充分利用查询性能

本文最后更新于:2023年12月29日 上午

👉️URL: https://grafana.com/blog/2023/12/28/the-concise-guide-to-loki-how-to-get-the-most-out-of-your-query-performance/

✍️Author: Ed Welch

📝Description:

了解 Grafana Loki 如何执行查询,并阅读可提高查询性能的最佳实践和技术。

感谢您阅读 "Grafana Loki 简明指南 " 系列博文的第三部分,这一部分将详细介绍使用日志聚合系统的 各方面 最佳实践。今天这篇文章是我为所有运行 Loki 并希望从集群中获得最高查询性能的用户准备的节日礼物。

Loki 项目 的工作中,我最喜欢的部分不是构建它,而是运行它。我似乎天生就有发现问题的本领,或许是问题喜欢找上我;无论如何,我都喜欢调查和排除故障的过程。今天的这篇文章是我的一次尝试,我将从运行 Loki 的过程中学到的所有有关查询性能的知识,尽可能地加以提炼。这篇文章的内容会比较密集,但希望无论安装规模大小,这里都能为运行洛基的每个人提供一些帮助。

基准最佳实践

在介绍细节之前,您应该遵循一些能够产生巨大影响的一般最佳做法。由于这是一份 简洁 的 Loki 指南(相对来说比较简洁),我将在这里简要地介绍每一种做法:

  • 您一定要阅读本系列的 第二部分,该部分主要介绍标签。如果您在标签上做了不该做的事情,请现在就改正。
  • 升级!我们正在不断提高 Loki 的速度,因此请确保您使用的是最新版本!升级时请务必查看 升级指南
  • 本系列的第 2 部分还谈到了自动流分片。请确保已启用,因为目前默认情况下并未启用。您可以查看 如何在此启用。Loki 的分片查询是在流级别完成的,这与写操作类似。真正大型的单个数据流无法分割,这就限制了查询性能。
  • 在本博客的开篇,我使用了 " 集群 " 一词。您确实需要在简单可扩展部署模式 (SSD) 微服务模式 下运行 Loki,才能获得更高水平的查询性能。如果您使用的是 SSD 模式,请 转换为三个目标模式,这样添加 " 读取 " 资源就会容易得多,因为它们可以是无状态的。

注意 : 如果在 monolithic mode 中将 Loki 作为单个二进制文件运行,则可以在一定程度上实现并行化。不过,如果您出于性能考虑而添加实例,那么在 SSD 或微服务模式下运行 Loki 会有更好的表现。

  • 你应该确保使用的是 TSDB 索引类型。如果不是,或者不确定,请查看这些关于切换到 TSDB 索引的文档。虽然这里谈到的许多概念和设置都适用于其他索引类型以提高性能,但 TSDB 索引类型具有一些重要的额外分片功能,使其在处理大型和小型查询时都更快。

注意: 使用新条目更改模式时,该条目必须始终位于未来。请注意您当前的时间与 UTC 的比较,因为滚转总是发生在 00:00 UTC。

  • 使用 snappy 作为 chunk_encoding 以获得最佳性能。与 GZIP(默认设置)相比,Snappy 的压缩率较低,但速度要快得多。我们的所有集群都使用 Snappy 进行压缩。Loki 确实支持其他一些压缩算法,如 Lz4 和 Zstd,它们产生的输出文件比 Snappy 小,性能也差不多。不过,我们只在基准测试中对它们进行了真正的比较,而没有在更大的范围内进行比较。

注意: 更改 "chunk_encoding " 可以随时进行,但只能向前移动;之前设置创建的块将保持之前的格式。

  • 缓存,我相信在本系列文章中会有自己的专门文章,但如果你没有耐心,你应该确保你已经为 Loki 配置了结果缓存和块缓存。结果缓存可以帮助 Loki 避免多次执行相同的工作,主要是在度量查询中。块缓存有助于减少存储上的 I/O 操作,并提高性能。
  • 存储 …您真的真的应该使用主要云提供商的对象存储,以便从 Loki 中获得最佳性能。我们一次又一次地看到,人们试图在自我管理的存储系统前使用与亚马逊 S3 兼容的 API,但结果却令人失望和沮丧。Loki 是一种能够在对象存储上完成大量工作的数据库–我们最大的 Loki 集群可以从谷歌云存储和 S3 中获取超过 1 Tbps 的带宽。我们经常发现,无论规模大小,自我管理存储解决方案都无法达到云管理对象存储的性能要求。如果这是唯一的选择,您可能不得不降低对高端查询性能的期望值。

Loki 中的查询工作原理

现在我自封为 “动画专家”(见:第 2 部分),我想一个漂亮的动画会对这里有所帮助。

洛基执行查询的第一步是将其分割成更小的时间段:

然后通过分片过程再次分割每个时间段:

在 Loki 中,分片是一个动态过程,在按时间分割查询之后,每个分割的时间段又会被分成若干分片。分片的数量取决于需要处理的数据量,有些分片可能有很多分片,而有些分片可能只有几个。

一旦查询被分拆成子查询,它们就会被放入一个队列,由 query worker 池处理所有的子查询。

从哪里开始 大小和配置

在开始排除性能问题之前,您需要对基础架构及其对查询的影响有一个粗略的了解。遗憾的是,CPU 和内存需求并不是完全可以预测的–有许多类型的查询对 CPU 和内存的需求截然不同,这取决于处理这些查询的机器。不过,为了给您提供一个参考点,我们使用 2 个 CPU 内核和 3 GB 内存运行我们的查询器。更具体地说,我们在 Kubernetes 中运行,使用 2 个 CPU 和 3 GB RAM 的 requests,以及 5 个内核和 12 GB RAM 的 limits。这对我们来说非常有效,因为在我们的环境中可以非常容易地进行调度。

我们将 max_concurrent 设置为 8,一般来说这样就没问题了。如果对资源使用有更严格的要求,请降低 max_concurrent。如果您看到查询器在内存不足时崩溃,请降低 max_concurrent(一定程度的内存不足崩溃可能是正常现象;Loki 在查询中设有重试机制,重新安排查询后通常都能成功)。

注意:一般来说,我们更倾向于使用 Loki 进行横向扩展,而不是纵向扩展。我们宁愿用较小的 CPU/ 内存运行更多的组件,也不愿用较大的 CPU/ 内存运行较少的组件。您完全可以采取使用更高的 CPU/ 内存和更高的 max_concurent 来运行更少的查询器,但这种方法有几个缺点。最大的影响可能来自在所有查询器中执行的使用大量内存和 CPU 的大型查询。这些查询会对集群或集群的其他用户造成负面影响。另外–诚然,我并不是这方面的专家–我相信 Go 运行时 goroutine 调度器可能会看到一些负面影响,那就是越来越大的单进程和越来越多的运行 goroutines。

对于 tsdb_max_query_parallelism(以前的 max_query_parallelism)和 split_queries_by_interval,我们在运行 Loki 时采用与摄取率挂钩的层级。租户摄取的数据越多,查询的并行性就越高。这实际上对小型和大型租户都更有利,因为过度并行化确实会带来惩罚,如果你对少量数据过度并行化,你会发现 Loki 在并行化和执行查询的机制上花费的时间比实际处理数据的时间更多。

:几年前,我决定将单个二进制文件配置为与 SSD 和微服务完全相同的运行方式,在这种情况下,它的并行化查询与这些大型部署模型完全相同。现在回想起来,我认为这损害了单一二进制文件的性能,特别是因为默认设置过于激进(例如,max_concurrent 默认设置为 10)。如果你运行的是单一二进制文件,你可能真的需要尝试将 max_concurrent 降到更适合你的内核数量(2 倍内核或更少)的值,将 tsdb_max_query_parallelism 降到更小的值,比如 32(默认值是 128),并将 split_queries_by_interval 增加到 6 小时或 12 小时(默认值是 1 小时)。很抱歉我没有太多时间来测试这一点,但我很想知道大家是否发现通过减少并行性,单二进制的体验会更好。

这些值的用户层级从小型租户到大型租户,范围如下:

  • tsdb_max_query_parallelism 128 → 2048
  • split_queries_by_interval 1h → 15m

查询性能预期

不同类型的查询具有不同的性能特征。也许不难理解,查询越复杂,执行速度就越慢。Loki 执行速度最快的查询是 过滤查询,如:

1
{namespace=”loki-ops”,container=”querier”} |= “metrics.go” |= “org_id=233432”

以及简单的度量查询,如

1
2
sum(count_over_time({namespace=”loki-ops”,container=”querier”} |= “metrics.go” |= “org_id=233432”[$__auto])) 
sum(rate({namespace=”loki-ops”,container=”querier”} |= “metrics.go” |= “org_id=233432”[$__auto]))

注意: 正则表达式确实会对查询的 CPU 消耗造成影响。无论是在 regexp 解析器的 |~ regex 过滤匹配器中,还是在解析器后的 =~ 正则表达式匹配器中,几乎没有什么能像复杂的正则表达式那样耗费大量 CPU。在许多情况下,正则表达式是必要的,但如果可以的话,应尽量避免使用正则表达式,并始终使用 pattern 解析器,而不是 regexp 解析器。 *

这些查询的每个子查询的吞吐量应在 100 MB/s - 400 MB/s 之间。我提到在高端我们使用的 tsdb_max_query_parallelism 值为 2,048,因此让我们快速计算一下:

1
2
3
100 MB/s * 2048 parallel queries =~ 200 GB/s

400 MB/s * 2048 parallel queries =~ 800 GB/s

在这个纸上数学练习中还值得注意: 要执行一个查询,我们至少需要 256 个查询器(2048 (tsdb_max_query_parallelism) / 8 (max_concurrent))。而这仅仅是为了完全并行执行一个查询。在现实中,经常会有多个用户同时执行查询。在拥有数百名活跃用户的大型集群中,我们可能会自动扩展到 1000 多个查询器,以确保每个人都有足够的容量。

随着查询复杂度的增加,执行速度也会变慢,但还有其他因素会影响查询性能,例如

  • 查询匹配的数据流数量
  • 查询块的大小
  • 查询是否可分片
  • 对象存储的速度

这些都是潜在的瓶颈,所以接下来我们将探讨如何帮助缩小可能导致速度变慢的原因。

如何排除性能问题

即使采用了我迄今为止讨论过的所有最佳做法,仍然无法获得您想要的性能?我们最常用来排除 Loki 查询性能故障的工具是…另一个 Loki。

在任何合理规模的设置中,您都希望确保在另一个 Loki 实例中捕获您的 Loki 日志(您甚至可以为此使用 免费 Grafana 云帐户)。当然,您也可以让 Loki 采集自己的日志,但除非您使用的是非常小的实例,否则我不建议您这样做,因为您将无法对 Loki 本身的中断进行故障排除。

Loki 执行的每个查询和子查询都会产生一行日志,我们称之为 “metrics.go”。这一行包含了所执行查询的大量统计数据。每个子查询都会在查询器(或在 SSD 模式下使用 component=”querier”的读取 pod)中发出这一行,而每个查询都会在查询前端(或在 SSD 模式下使用 component=”frontend” 的读取 pod)中发出一行摘要 “metrics.go”。

Query-frontend “metrics.go”

1
{container=”query-frontend”} |= “metrics.go” {container=”read”} |= “component=frontend” |= “metrics.go”

您可以使用查询前端 metrics.go 行来了解查询的整体性能。以下是最有用的统计信息:

  • total_bytes(总字节数):查询处理的总字节数
  • duration(持续时间):查询执行了多长时间
  • throughput(吞吐量):总字节数 / 持续时间
  • total_lines(总行数):查询处理的总行数
  • length:查询执行了多长时间
  • post_filter_lines:有多少行符合查询中的筛选条件
  • cache_chunk_req:为查询获取的大块总数(缓存会请求每个大块,因此这相当于请求的大块总数)
  • splits:根据时间和 “split_queries_by_interval”(按时间间隔分割查询),查询被分割成多少块
  • shards(分片):查询被分割成多少个分片

查找耗时较长的查询,查看查询的数据量、请求的块数、查询的并行性(即有多少个分块和分片)。我通常会从这样的查询开始,它会返回所有耗时超过 20 秒、吞吐量低于 50 GB/s 的查询:

1
{container=”query-frontend”} |= “metrics.go” | logfmt | duration > 20s and throughput < 50GB

这样就可以将结果减少到仅处理速度低于 50 GB/s 的 20 秒以上的查询。在这里,我通常会从查询中获取 traceID,然后搜索查询器中的 "metrics.go" 行,查看所有子查询的处理情况(稍后将给出示例查询)。

有一点可以从前端日志中快速获得: 如果分片计数为 0 或 1,这意味着查询没有分片。这是因为 Loki 中并非每种查询类型都可以分片(例如,"quantile_over_time " 就不可以分片)。分片实际上是将查询中的数据流分割开来,并在不同的查询器上并行执行,而有些操作无法通过这种方式生成准确的结果,因为它们需要所有日志同时出现在同一个地方,才能完成计算量化值等工作。

我们正在积极改善这一问题。例如,我们目前正在添加使用概率数据结构的功能,在不需要 100% 的准确性,但需要 接近 快速出结果 的情况下,可以将其作为一种选择。

关于这一行中与查询前端相关的统计信息,还有一些补充说明: 在前端日志中,queue_timechunk_refs_fetch_timestore_chunks_download_time等任何以 _time 结尾的统计信息都会让人感到困惑。这些统计信息将显示所有子查询的累计值,由于 Loki 会并行执行大量此类工作,因此即使总查询持续时间只有几秒钟,这里的值也有可能显示为几分钟或几小时。

我在这里做了一个最恰当 / 最糟糕的比喻: 想象一下,在一场大型音乐会上,1000 个便池并排在一起,每个便池前都有排队等候的人。如果每个人上厕所的时间都是 30 秒,那么 1000 人的一个完整周期所需的总时间就是 30 秒(这就是查询持续时间)。但如果把每个人所经历的时间加起来,总时间就是 1000*30=8.3 小时(这就是查询前端报告的队列时间)。

分块和分片的计算是相互独立的;按时间计算的每个分块都会被加到分块中,每个分片也会被加到分片中。因此,要准确知道一个查询处理了多少个子查询,需要做一点推理,因为分片发生在拆分之后,如果分片计数大于 1,这将是执行的子查询数。如果分片数等于 0 或 1,则表示查询没有分片,子查询数就是 " 拆分 " 的次数。

Querier “metrics.go”

1
{container=”querier”} |= “metrics.go” {container=”read”} |= “component=querier” |= “metrics.go”

查询器输出的 "metrics.go " 行包含与前端相同的信息,但通常更有助于了解查询性能并排除故障。这主要是因为它能告诉你查询器是如何花费时间执行子查询的。

这有助于了解在查询执行过程中,什么占用了所有时间,主要分为以下四类:

  • 在队列中等待处理: 队列时间
  • 从索引中获取块信息: 从索引中获取块信息:`chunk_refs_fetch_time
  • 从缓存 / 存储中获取块信息:store_chunks_download_time(存储块下载时间
  • 执行查询

下面的查询可以对这些统计信息进行细分,以便于查看 Loki 在执行子查询时是如何花费时间的

1
{container=”querier”} |= "metrics.go" # |= "traceID=e3574441507879932799daf0f160102a" # 搜索特定查询的所有子查询 | logfmt | duration > 5s # 您还可以在此处过滤查询 | query_type="metric" or query_type="filter" or query_type="limited" # 注意: 新创建的标签不能使用与创建时相同的标签格式,因此我们使用几个步骤来创建标签 | label_format duration_s=`{{.duration | duration}}`, queue_time_s=`{{.queue_time | duration}}`, chunk_refs_s=`{{.chunk_refs_fetch_time | duration}}`, chunk_total_s=`{{.store_chunks_download_time | duration}}` | label_format total_time_s=`{{addf .queue_time_s .duration_s}}` # 队列时间是查询执行时间之外的时间 | label_format queue_pct=`{{mulf (divf .queue_time_s .total_time_s) 100 }}`, index_pct=`{{mulf (divf (.chunk_refs_fetch_time | duration) .total_time_s) 100 }}`, chunks_pct=`{{mulf (divf .chunk_total_s .total_time_s) 100}}`, execution_pct=`{{mulf (divf (subf .duration_s .chunk_refs_s .chunk_total_s) .total_time_s) 100}}` | line_format `| total_time {{printf "%3.0f" (.total_time_s | float64)}}s | queued {{printf "%3.0f" (.queue_pct | float64)}}% | execution {{printf "%3.0f" (.execution_pct | float64)}}% | index {{printf "%3.0f" (.index_pct | float64)}}% | store {{printf "%3.0f" (.chunks_pct | float64)}}% |`

一般来说,您希望看到尽可能多的时间用于 执行。如果您的集群确实如此,但查询性能却不尽如人意,可能有以下几个原因:

  • 您的查询执行了大量耗费 CPU 资源的操作,如正则表达式处理。看看是否可以修改你的查询,减少或删除正则表达式,或者让你的查询器使用更多的 CPU。
  • 您的查询有大量的小日志行。通常情况下,Loki 只能四处移动数据,而不能迭代日志行,但在执行速度受 Loki 迭代日志行本身的速度限制的情况下,确实会出现大量小日志行。有趣的是,这在某种程度上成为了 CPU 时钟频率的瓶颈。要想让速度更快,就需要更快的 CPU,或者尝试更多的并行性。
  • 如果你的时间都花在了 队列 上,那么你就没有足够的查询器在运行,如果你不能运行更多的查询器,那么你在这里可能也做不了什么,不过你可以减少并行性设置,因为如果你没有硬件来执行并行性,那么并行性再高也无济于事。
  • 如果你的时间都花在 索引 上,你可能需要更多的索引网关(或 SSD 模式下的后端 pod)。或者确保它们有足够的 CPU,因为对于匹配大量数据流的查询,它们可能会占用相当多的 CPU。
  • 如果你的时间都花在了 存储 上,那么即使是云对象存储也可能是查询的瓶颈。您可以用 "total_bytes " 除以 "cache_chunk_req " 来查看平均分块大小。这表示每个分块的平均未压缩字节数。作为一个平均值,它并不是发现分块大小问题的详尽方法,但如果平均值只有几百字节或几千字节,而不是兆字节(最好是兆字节),它就能提示你一个明显的问题。如果你的数据块太小,请重新检查你的标签,看看你是否过度分割了数据,本系列的 第 2 部分 有很多关于标签的信息。

摘要

我认为对于大多数人来说,查询性能不佳通常源于几个常见问题:

  • 不正确的 "max_concurrent " 和并行设置("tsdb_max_query_parallelism " 和 “split_queries_by_interval”)。
  • 没有足够的查询器运行,无法充分利用配置的并行性。
  • 过度热衷于使用标签,导致过多的小块。
  • 对象存储无法以与 Loki 查询速度相同的速度提供数据块。

希望本指南有助于揭开 Loki 如何执行查询的神秘面纱,并帮助大多数人调整更好的查询性能。请在下周继续收看 "Loki 简明指南 " 的另一篇文章!