原文为Ryan Allen在Chronosphere的博客上发表的客座文章
在运行M3DB时,监测集群的健康状况是很重要的,以了解查询的表现,以及集群是否需要额外的资源来处理写入它的数据量。一些关键的M3DB健康指标包括集群中每个节点的内存利用率和查询延迟,即查询需要多长时间。
这些指标揭示了数据库可靠地、周期性地遭受膨胀的内存和恶化的查询延迟的模式。
问题:巨大的、频繁的内存峰值
下面的一个例子显示了一个集群中的节点的内存使用量是如何每2个小时激增20-60%的。这种尖峰行为是有问题的,因为它要求我们为这些节点提供足够的内存来支持这些峰值,尽管大多数时候节点需要的内存要少得多。这意味着我们要为大部分时间都没有使用的内存付费--而我们付费只是为了防止在这些高峰期内存耗尽。
![Memory usage for nodes in a cluster spikes up by 20-60% every 2 hours](https://chronosphere.io/wp-content/uploads/2021/12/memory-utilization-resident-top.png)
此外,这些周期性的内存峰值与查询延迟的峰值完全相关。
![Figure showing the periodic memory spikes that were exactly correlated with spikes in query latencies](https://chronosphere.io/wp-content/uploads/2021/12/latency1.png)
这些性能下降的奇怪的周期性导致我们的团队调查数据库中的哪些操作应负责任,并最终解决这种行为以实现更一致的内存利用率和查询速度。
原因。区块旋转
为什么每隔两小时我们就会看到这些性能下降?考虑到这种节奏,我们推测原因是数据库中的一个被称为块旋转的过程,它每2小时发生一次,并通过堆配置文件进行验证。为了理解什么是块旋转,首先我们必须理解数据库的索引 和索引的块。
什么是数据库索引?
在M3DB中,当一个数据库节点收到一个查询请求时,执行该查询包括两个步骤。
- 找到所有符合查询表达式的系列ID(OSS)
// QueryIDs将给定的查询解析为已知的ID。
QueryIDs(
ctx context.Context,
namespace ident.ID,
query index.Query,
opts index.QueryOptions,
) (index.QueryResult, error)
- 读取每个系列ID的数据点(OSS)
// ReadEncoded检索一个ID的编码段。
ReadEncoded(
ctx context.Context,
命名空间 ident.ID,
id ident.ID。
start, end xtime.UnixNano,
)(系列.BlockReaderIter, error)
M3DB的索引在步骤1中被用来从一个查询(例如特定的标签和值)到一组匹配的系列ID。例如,一个查询http_request{status_code=~"200|500"}可以从索引中返回以下系列结果。
{__name__=http_request,status_code=200,service=a}。
{__name__=http_request,status_code=200,service=b}。
{__name__=http_request,status_code=500,service=a}。
{__name__=http_request,status_code=500,service=b}
为了进行这种匹配,索引在数据被写入的过程中积累了所有不同系列ID的集合。这意味着每一个新系列的写入都涉及到对索引的更新--而对一个现有系列的后续写入则不需要更新索引,因为系列ID已经存在,并且可以在该写入中搜索到。
什么是索引块?
如果在磁盘上有一个单一的数据结构,包含所有时间的所有系列ID和它们的标签,搜索起来就太慢了。因此,系列ID/标签被存储为时间块。这样,一个给定的查询只需要检查查询开始/结束范围内的所有块,限制了搜索空间。
例如,下面显示了2个小时的索引块,以及一个查询如何只必须根据查询范围搜索其中的一个子集。
![Shows 2 hour index blocks and how a query only must search a subset of them based on the query range](https://chronosphere.io/wp-content/uploads/2021/12/latency1230.png)
什么是区块旋转?
将索引分解成时间块的一个后果是,每当时间超过前一个最新块到一个新块时,我们就会产生一个成本。这个打开一个新的最新区块的过程被称为区块旋转。
例如,假设我们有以下系列[A,C,D]被积极地写入最新的区块中。
![[A,C,D] being actively written to in the latest block.](https://chronosphere.io/wp-content/uploads/2021/12/latency359.png)
当时钟指向下午4点时,这些传入的数据现在属于一个新的区块,因此一个新的空区块被创建。
![New empty block is created](https://chronosphere.io/wp-content/uploads/2021/12/latency400.png)
由于新的区块最初是空的,所以它很快就会被主动写入的不同系列的数据种下。
![Empty block gets seeded with the distinct series that are actively being written to](https://chronosphere.io/wp-content/uploads/2021/12/block_fixed.png)
但是,为什么填充新的空块的成本这么高呢?在上面的例子中,只有三个活跃的系列,迅速积累到一个新的块中并不是什么负担。然而,大型M3DB集群在任何时候都可能有数百万个不同的系列被写入--记住,索引必须包含每个新的不同系列的条目。这就导致了在新的区块上出现大量的初始争用,以便同时插入这些不同的系列。
写入不仅导致写路径上的争夺和备份,填充新块也扰乱了读取路径,因为查询必须从这个新块中查询系列ID。更糟糕的是,查询最常见的时间范围是现在(例如,简工程师想知道现在发生的错误率,或者警报XYZ想知道现在的延迟指标是否超过了某个阈值)。由于大多数查询需要从现在开始获取数据,所以大多数查询必须在新的索引块被创建后从它那里读取。
此外,为了改善压缩和加快对符合给定查询的时间序列的评估,索引块内的实际数据结构是一个FST(有限状态转换器)(即一个压缩的 trie 来搜索时间序列的 ID)。为了实现这种压缩和快速搜索,FST要求所有系列按字母数字顺序插入(以建立三角形),并且必须在每次更新时重新构建(以重新压缩)。更新FST的成本使得它不适合一次性进行这种积极的突变。下面我们看到在每次区块旋转时对索引进行排队写入的成本指标。
![spikes at each block rotation](https://chronosphere.io/wp-content/uploads/2021/12/block-rotation.png)
关于FST的更多信息,请看Andrew Gallant的这篇博文。
修复。活动索引块
在理解了有问题的数据库操作是索引的块旋转之后,我们知道我们需要一个修复方法,(A)保持数据按时间块的结构,但也(B)在过渡到一个新的块时支付一个较少的惩罚。
我们的新方法是在周围永久地保留一个 "活动 "块,接受所有的写操作,即使是在新的区块被打开之后。这样一来,我们就不必迅速地将一个新的区块从空的地方播种到所有活动的系列。相反,系列是随着时间的推移慢慢积累起来的,因为它们被创建了。虽然这可能意味着随着新系列的写入,"活动 "块的大小会无限地增长,但我们通过后台垃圾收集不再被写入的旧系列来解决这个问题。
完整的活动索引块代码变化可以在M3的开源repo中看到这个PR。
活动块的写入
为了比较这个变化和之前的区块轮换说明,假设我们又有3个主动写入的系列[A,C,D] - 但这次我们也有主动区块存在。
![Same block rotation as before but with the active block present](https://chronosphere.io/wp-content/uploads/2021/12/block359-1.png)
一旦时间到了下午4点,我们又需要打开一个新的区块--但是,写入的内容会继续进入活动区块,而不是最新的区块。
![writes continue to go into the active block instead of the new latest one](https://chronosphere.io/wp-content/uploads/2021/12/block400.png)
新的最新区块最终会按时间积累相关数据,但它可以在后台发生,而且速度更慢,减少所需的峰值内存。
![The new latest block will eventually accumulate the relevant data by time, but it can happen in the background and more slowly, reducing the peak memory required.](https://chronosphere.io/wp-content/uploads/2021/12/block400ACD.png)
为了防止无限增长,不再被写入的系列将在后台从活动块中删除。在前面的例子中,由于系列[A,B,C,D]在活动块中,但只有系列[A,C,D]被主动写入,系列B最终将从活动块中被删除。
活动区块的读取
现在所有的写都指向活动块,重要的是我们也使用这个块进行读取。这样,我们可以确保ID在活动区块中但尚未在其时间区块中的系列的结果出现。
![active block](https://chronosphere.io/wp-content/uploads/2021/12/blockindex.png)
结果。极大的改进
那么......它成功了吗?令人高兴的是,我们看到内存和查询指标有了极大的改善,揭示了这个性能问题。
在这里,我们看到一个集群在活动块改变前后的内存转换。在改变之前,每隔2个小时就有可靠的内存刺痛感,但之后我们看到稳定的内存。
![memory utilization resident](https://chronosphere.io/wp-content/uploads/2021/12/rearchitecting1.png)
* 蓝色的窗口表明了变化部署的时期。
此外,我们不再看到每2小时一次的周期性退化的查询延迟。
![We no longer see periodic degraded query latencies every 2 hours](https://chronosphere.io/wp-content/uploads/2021/12/latency2.png)
* 黄线表示变化部署的时间点。
高层跟踪内存和延迟的重要性
这一性能改进表明,监测系统的健康指标是多么重要!这个问题影响了用户的查询。这个问题影响了用户的查询体验--但只是偶尔为之。有人可能在下午4点运行一个查询,它的时间可能远远超过平均水平--但一分钟后再运行,一切都很正常。这些类型的问题很难从传闻中的用户报告中被意识到--对内存和延迟的高层次跟踪是保持对这些影响用户的问题的一个更系统的方法。