如何计算监控数据密集型系统的百分位数?
监测通常涉及百分位数的使用。与受异常值严重影响的平均值不同,百分位数有助于了解系统大部分时间是如何工作的。如果 10 个请求中有 9 个在 1 秒内执行,最后一个需要 10 秒,则平均值将为 1.9 秒,而第 50 个百分位数将为 1 秒。这只是平均值不适合监控的一个例子。因此,需要计算百分位数,正是出于这个原因,我们在 tarantool/metrics 中添加了一个汇总收集器。摘要收集器计算监控数据的分位数。让我告诉你我们用来计算分位数的算法以及我们如何为 tarantool/metrics 实现它。
摘要收集器
算法
一种 -quantile 是随机变量不超过概率为的值 .例如,在 HTTP 请求监控中,等于 1 秒的 0.5 分位数(基本上是第 50 个百分位数)意味着 50% 的请求在不到一秒的时间内处理完毕。计算一个对于大小为 n 的排序数组,您需要找到索引为 .这种方法需要存储所有被监控的数据,并且metrics中可能有很多数据。如果要处理 10 亿个请求,它们将需要 10 亿个数组元素,这将构成大约 1 GB 的数据。
这个问题可以通过许多计算数据流的近似分位数的算法来解决。我们采用了 Prometheus 中使用的算法。它压缩原始数据,将它们表示为一组段。每个段由三个数字的结构描述: 是从前一段起点到当前段起点的距离; 是当前段的长度; 是段的近似分位数。
上图以绿色显示原始数组元素,以红色显示压缩数组元素。为了找到压缩数据的分位数,我们需要遍历这些段,将它们的距离相加,直到总和足够接近 ,并识别相应的段。例如,0.5-quantile 将位于图上绿色数组的中间,近似值将属于对应的红色段。整个压缩过程在原始文章中进行了广泛描述。
执行
我们遵循了该算法的 Go 实现示例。让我们创建两个数组。一个将用作监视值的缓冲区,另一个将用作观察数组来存储段结构:
Go
typedef struct {int Delta, Width; double Value; } sample;
Go
local ffi = require('ffi')
…
array = ffi.new('double[?]', max_samples)
for i = 0, max_samples - 1 do
array[i] = math.huge
end
Go
ffi.cdef[[
Go
void qsort(void *base, size_t nitems, size_t size, int (*compare)(const void *, const void*));
int cmpfunc (const void * a, const void * b);
Go
int cmpfunc (const void * a, const void * b) {
if (*(double*)a > *(double*)b)
return 1;
else if (*(double*)a < *(double*)b)
return -1;
else
return 0;
}
Go
gcc -c -o metrics/quantile.o metrics/quantile.c
gcc -shared -o metrics/libquantile.so metrics/quantile.o
Go
local dlib_path = package.search('libquantile', package.cpath)
local dlib = ffi.load(dlib_path)
Go
local DOUBLE_SIZE = ffi.sizeof('double')
ffi.C.qsort(array, len, DOUBLE_SIZE, dlib.cmpfunc)
Go
tarantoolctl rocks install metrics 0.10.0
Go
local metrics = require('metrics') -- attaching metrics
-- Creating a summary collector
local http_requests_latency = metrics.summary(
'http_requests_latency', 'HTTP requests latency',
{[0.5]=0.01, [0.9]=0.01, [0.99]=0.01},
{max_age_time = 60, age_buckets_count = 5}
)
-- Monitoring a value
local latency = math.random(1, 10)
http_requests_latency:observe(latency)
该算法仅对排序值起作用。让我们将缓冲区大小限制为 500 个值,并将观察数组的大小定义为2 × 500 + 2 。由于压缩将数组大小减少了大约一半,我们平均需要上一步中未压缩数组的 500 个元素 + 在当前步骤中添加到数组中的 500 个元素 + 像简化在数组中的搜索。
发展
我们迭代地处理我们的实现:创建一个版本,使用分析器检查其性能,将其与 Go 版本进行比较,然后寻找改进它的方法。我们使用一个简单的基准来评估我们的结果:10 8个样本,Go 版本大约需要 8 秒。现在让我们深入了解每个迭代的细节。
1.纯 Lua 版本相当糟糕,因为插入平均需要 100 秒左右。探查器数据如下所示:
该代码在将观察结果插入相应数组(`table.insert` 调用)和缓冲区排序(`table.sort`)方面表现不佳。这就是 ffi(外来函数接口)的用武之地。 Ffi 允许访问 C 标准库中的函数并在 Lua 中使用它们,就好像它们是常规 Lua 对象一样(嗯,几乎;例如,虽然 Lua 中的表索引从 1 开始,但用 C 创建的数组仍然从 0 开始)。
2. Lua + ffi版本涉及构建一个双精度值数组而不是创建缓冲区:
去
local ffi = require('ffi')
…
array = ffi.new('double[?]', max_samples)
for i = 0, max_samples - 1 do
array[i] = math.huge
end
我们将使用 C 标准库对该数组进行排序:
去
ffi.cdef[[
去
void qsort(void *base, size_t nitems, size_t size, int (*compare)(const void *, const void*));
int cmpfunc (const void * a, const void * b);
让我们为 C 中的 `double` 值编写一个比较器函数,并将其作为动态库包含进来。这是比较器函数:
去
int cmpfunc (const void * a, const void * b) {
if (*(double*)a > *(double*)b)
return 1;
else if (*(double*)a < *(double*)b)
return -1;
else
return 0;
}
现在让我们构建它:
去
gcc -c -o metrics/quantile.o metrics/quantile.c
gcc -shared -o metrics/libquantile.so metrics/quantile.o
然后我们将在 Lua 代码中包含该库:
去
local dlib_path = package.search('libquantile', package.cpath)
local dlib = ffi.load(dlib_path)
现在我们可以填充 `double` 数组并调用它的排序:
去
local DOUBLE_SIZE = ffi.sizeof('double')
ffi.C.qsort(array, len, DOUBLE_SIZE, dlib.cmpfunc)
测试显示性能提高了 3 倍,平均插入时间长达 30 秒。这一次,代码表现不佳,因为 Lua 表没有固定大小,并且元素类型也没有预定义。尽管这为表处理提供了更大的灵活性,但它显着降低了性能。使用 ffi,您可以从 Lua 表切换到固定大小的 C 数组,因此插入和计算数组大小的成本为 O(1) 而不是 O(log n)。由于固定的类型和固定的元素大小,排序也快得多。但是这个解决方案引入了 GCC 依赖,使应用程序交付复杂化。所以我们必须摆脱 C 代码。
3. Lua + ffi + homebrew 排序。 Lua 中最简单的快速排序结果只比我们之前涉及 C 库的版本多运行几秒钟。这个结果对我们来说已经足够好了,特别是因为它不依赖于 GCC,所以我们决定到此为止。
最后一步是使用滑动窗口算法添加分位数旋转。我们创建了一个由多个收集器(例如 5 个)组成的环形队列,并让其中一个成为前导(头部)。监控值被写入这些收集器中的每一个。在指定的时间到期后(例如 60 秒),头部收集器被重置,队列中的下一个成为新的头部。分位数值仅从当前头部获取。这种方法可确保数据保持最新,因为在没有滑动窗口的情况下,将在整个期间计算值。
内存使用情况
`metrics.quantile` 使用两个数组:
- `max_samples * sizeof(double)` = 500 × 8字节的缓冲区。
- `(2 * max_samples + 2) * sizeof(struct sample)` = 1002 × 16字节的观察数组。当观察值变化几个数量级时,观察数组的大小会增加。
在 `metrics.summary` 中创建了 `age_buckets_count` 收集器,因此总大小为:
`age_buckets_count * (max_samples * sizeof(double) + (2 * max_samples + 2) * sizeof(struct sample))` = 5 × (500 × 8 + 1002 × 16)字节,或大约 100 KB。
性能影响
我们使用Yandex.Tank进行了负载测试。关闭所有应用程序指标后,结果如下:
使用我们的摘要收集器:
性能下降了约 10%,这是您必须为使用指标支付的成本。如果您想避免大幅回撤,您可能需要谨慎使用收集器,例如,仅测量部分请求。
用法
去
tarantoolctl rocks install metrics 0.10.0
去
local metrics = require('metrics') -- attaching metrics
-- Creating a summary collector
local http_requests_latency = metrics.summary(
'http_requests_latency', 'HTTP requests latency',
{[0.5]=0.01, [0.9]=0.01, [0.99]=0.01},
{max_age_time = 60, age_buckets_count = 5}
)
-- Monitoring a value
local latency = math.random(1, 10)
http_requests_latency:observe(latency)
支持导出到 JSON、Prometheus 和 Graphite。以下是收集的结果在 Grafana 中的样子:
结论
我们为 tarantool/metrics 编写了一个摘要收集器。在开发过程中,我们遇到了性能挑战,我们使用 ffi 解决了这个问题。您可以使用新的收集器来监控可能受益于跟踪分位数的值,例如 HTTP 请求延迟。摘要收集器可以应用于任何基于 Tarantool 的产品,其中服务响应时间至关重要,例如通过 HTTP 请求访问大量数据的数据密集型应用程序。监控此指标将帮助您了解哪些请求对您的系统造成了压力。