Golang 如何计算监控数据密集型系统的百分位数
监控通常涉及使用百分位数。与平均值不同,它们受到异常值的影响较小,能够帮助我们了解系统大多数时间的运行状态。例如,如果有10个请求中有9个在1秒内执行完成,而最后一个需要10秒钟,那么平均值将是1.9秒,而第50个百分位数将是1秒。这仅是平均值在监控中不适用的一个例子。因此,需要计算百分位数,正因为如此,我们在tarantool/metrics中添加了一个摘要收集器。汇总收集器会计算被监控数据的分位数。现在让我告诉您我们用于计算分位数的算法以及我们如何为tarantool/metrics实现它。
摘要收集器
算法
一个
分位数是随机变量不超过
概率的值。例如,在HTTP请求监控中,一个0.5分位数(基本上就是第50个百分位数),等于1秒,意味着50%的请求处理时间小于1秒。要计算已排序数组的
需要找到索引为
的元素。这种方法需要存储所有被监控的数据,而在“度量衡”中会产生大量的数据。如果有10亿个请求需要处理,它们需要10亿个数组元素,大约1GB的数据。
这个问题可以通过许多算法来解决,这些算法计算数据流的近似分位数值。我们采用了Prometheus中使用的算法。它将原始数据压缩,表示为一组段。每个段由三个数字的结构描述:
图中显示了原始数组元素为绿色,压缩的数组元素为红色。要查找压缩数据的分位数,需要遍历各个段,将它们的距离相加,直到和接近于
, 并确定相应的段。例如,0.5分位数将位于图中绿色数组的中间,近似值将属于相应的红色段。整个压缩过程在原始文章中有详细描述。
实现
我们遵循了Go实现此算法的示例。我们创建两个数组。一个将作为监控值的缓冲区,另一个将用作观察数组来存储段结构:
typedef struct {int Delta, Width; double Value; } sample;
该算法仅对排序的值起作用。让我们将缓冲区大小限制为500个值,并将观察数组的大小定义为2×500+2。由于压缩使数组大小减少了约一半,因此我们需要平均需要来自上一步未压缩数组的500个元素+当前步骤中添加到数组中的500个元素+像
这样的元素以简化在数组中的搜索。
发展
我们对实现进行了迭代:创建一个版本,使用分析器检查其性能,与 Go 版本进行比较,然后寻找改进的方法。我们使用一个简单的基准测试评估了我们的结果:10亿样本,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 sorting. 简单的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) 字节,约为100KB。
性能影响
我们使用 Yandex.Tank 进行了负载测试。关闭所有应用程序指标后,结果如下:
使用我们的摘要收集器:
性能下降了约10%,这是使用指标时必须支付的代价。如果要避免显着下降,您可能需要谨慎使用收集器,例如只测量部分请求。
用法
tarantoolctl rocks install metrics 0.10.0
local metrics = require('metrics') -- 附加指标
-- 创建摘要收集器
local http_requests_latency = metrics.summary(
'http_requests_latency', 'HTTP请求延迟',
{[0.5]=0.01, [0.9]=0.01, [0.99]=0.01},
{max_age_time = 60, age_buckets_count = 5}
)
-- 监控一个值
local latency = math.random(1, 10)
http_requests_latency:observe(latency)
支持导出为JSON、Prometheus和Graphite。在Grafana中,收集到的结果可能如下所示:
结论
我们为Tarantool/metrics写了一个摘要收集器。在开发过程中,我们遇到了一个性能挑战,我们使用ffi解决了这个问题。您可以使用新的收集器来监视可能从跟踪分位数中获益的值,例如HTTP请求延迟。摘要收集器可应用于任何基于Tarantool的产品中,其中服务响应时间很关键,例如通过HTTP请求访问大量数据的数据密集型应用程序。监视此指标将帮助您了解哪些请求会影响您的系统。