时序数据库中的性能优化技术:字符串驻留(string interning)
本文翻译自victoriametrics的博客,原文链接。
前言
VictoriaMetrics 是一个用 Go 语言编写的开源时间序列数据库(TSDB),在过去几年中,我有幸参与了它的开发工作。TSDB 有着严格的性能要求,构建 VictoriaMetrics 让我学到了很多关于优化的知识。在这篇博客文章中,我将分享一些在 VictoriaMetrics 工作期间学到的性能技巧。如果您对 Go 语言有所了解,您将从这篇文章中获得最多的收益,但您不需要具备 TSDB 的背景!文章中的技巧应该适用于任何对性能敏感的应用程序,而且许多技巧也适用于 Go 语言之外的场景。
时间序列数据库简介
在我们讨论 TSDB 的优化之前,我们需要先了解它们是什么,以及它们试图解决的问题。在本节中,我们将介绍一些关于 TSDB 的背景知识,以便将我们稍后讨论的优化放在正确的视角中。
指标
在 VictoriaMetrics 中,数据模型以指标的概念为中心。一个指标是在某个时间点对某个值的观察。上面的图片显示了一个收集 Go 安装信息的示例指标。指标由以下部分组成:
Name
指标的名称描述了这个指标测量的内容。Metadata
以标签-值(label-value)对形式表示的关于指标的信息。Value
observation 本身。Timestamp
observation 被记录的时间。
指标名称和其元数据的组合定义了一个时间序列。在现实世界中,我们可能期望在中等负载下处理数百万个独特的时间序列并将其存储在 TSDB 中。
VictoriaMetrics 提供了一个名为 vmagent 的组件,它是一个指标收集器。指标收集器会定期从您的服务或其他目标中收集时间序列数据,并将其转发到 TSDB 中。收集器会访问每个目标的/metrics
端点来收集目标公开的指标。例如,访问http://service``:port/metrics
可能会产生以下时间序列数据:
go_gc_duration_seconds{quantile="0"} 7.3744e-05
go_gc_duration_seconds{quantile="0.25"} 0.000110502
go_gc_duration_seconds{quantile="0.5"} 0.000123689
go_gc_duration_seconds{quantile="0.75"} 0.00015938
go_gc_duration_seconds{quantile="1"} 0.004539404
go_goroutines 103
go_info{version="go1.16.4"} 1
go_memstats_alloc_bytes 1.0304216e+08
go_memstats_alloc_bytes_total 1.423416806112e+12
上述指标以“Prometheus exposition format”格式呈现,它是一种由 Prometheus 社区推广的、非常人性化且易于阅读的格式。
工作负载
在优化应用程序之前,了解其工作负载情况至关重要。如果你针对读取操作进行了优化,但你的应用程序主要是写入密集型的,那么你可能无法获得显著的性能提升,甚至可能会导致性能下降。
对于 TSDB 而言,写入的工作量通常非常大。每秒处理数百万次写入的情况并不罕见,而读取次数通常要少几个数量级。请参考下面两张图:
虽然 每秒 1700 万个摄入样本 看起来很多,但这并不是我们见过的最大安装规模。生产应用程序会生成大量数据!因此,TSDB 面临的巨大负载也是我们投入优化工作的重要动力。
上述图表中还需注意的一点是,读取操作具有很强的不可预测性和随机性。写入操作代表了机器生成的一致负载,而读取操作可能是由人类触发的,这给负载图带来了一定的随机性。
我们该如何构建一个能够应对如此庞大工作负载的系统呢?TSDB(时间序列数据库)的性能主要取决于其写入系统的设计。VictoriaMetrics 在设计中遵循了以下几个重要原则:
- Log Structured Merge(LSM):LSM是考虑了其实现所在的存储介质的数据结构。它们可以帮助防止 写入放大,因为写入放大很容易使最快的存储介质饱和。
- 面向列的存储:单独存储数据的每一列允许您单独对数据进行排序和压缩,这两种方法都可以实现面向行的存储无法实现的优化。
- 仅追加写入:通常情况下,TSDB 存储的数据是已经发生的,而且很可能永远不会改变。例如,过去的天气预报不太可能更新。基于这一原则,您可以使用仅追加的数据结构。这种数据结构以牺牲部分灵活性为代价,换取了更高的写入速度。
系统级设计并不适合大多数团队优化应用程序时使用。很有可能您正在处理的现有代码库并不是 TSDB,因此,上述优化并不真正适用于您。
别担心!在本文的其余部分,您将了解非特定于设计的优化。这些优化方法适用于各种代码库,无论它的年龄或领域。
字符串驻留(string interning)
指标的元数据由之前的标签值对组成,在 VictoriaMetrics 中表示为string。这为用户提供了很大的灵活性,因为他们可以在元数据中表示任何内容,并随时引入新的标签和值。但实际上,元数据字符串不会经常更改,因此在收集过程中会产生大量重复。
以go_info
指标为例,它的元数据有一个 Go 版本标签-值(label-value)对。Go 版本的数量有限,而且 Go 版本变化的频率也不太可能很高。然而,每次从应用程序中收集该指标时,都需要解析其元数据并在内存中为其分配空间,直到该空间被GC。该指标可能会被数千个应用程序而不是一个应用程序暴露,指标收集器将不得不在内存中一遍又一遍地解析和分配相同的字符串!
为了避免多次存储相同的字符串,可以采取一种称为字符串驻留的方法,即只存储每个唯一的字符串一次,并在需要时引用它。这样可以节省大量内存:
在上图中,vmagent
在多个抓取中捕获到了相同的元数据字符串。在左图中,内存中存储了同一字符串的三个副本,而在右图中,内存中仅存储了该字符串的一个副本。这可以节省3 倍的内存。
字符串驻留的简单实现使用了map,它可能类似于下面的代码:
var internStringsMap = make(map[string]string)
func intern(s string) string {
m := internStringsMap
if v, ok := m[s]; ok {
return v
}
m[s] = s
return s
}
这个示例在单线程应用程序中表现良好,但vmagent
同时有多个线程在多个目标上工作。您可以为intern
函数添加锁,但在多核系统上这种做法的扩展性不好,因为访问 Map 时可能会有很多竞争。
跨多个线程进行字符串驻留的解决方案是使用sync.Map
,这是 Go 标准库中内置的线程安全实现。
var internStringsMap sync.Map
func intern(s string) string {
m := &internStringsMap
interned, _ := m.LoadOrStore(s, s)
return interned.(string)
}
最妙的是,sync.Map
简化了我们原始的代码!它提供了一个名为 LoadOrStore
的方法,这意味着我们不再需要自己检查字符串是否已经在 Map 中。sync.Map
针对以下两种用例进行了优化:
- 当给定的键只被写入一次,但被多次使用时,也就是说缓存的命中率很高。
- 当多个 goroutine 读取、写入和覆盖不相交的键集的条目时,即每个 goroutine 使用不同的键集。
sync.Map
通过减少锁争用,提高了应用程序的性能。与使用 Map 并添加Mutex
或RWMutex
相比,它更具优势。
但是使用sync.Map
时应该注意几个“陷阱”。
Map 大小会随时间增长
不受限制的内存增长是危险的。为了防止 Map 无限增长,我们可以采取以下两种方法:要么周期性地删除并重新创建 Map
(Map Rotation
),要么为键实现某种形式的生命周期(TTL)逻辑。
对 intern
的参数进行健全性检查
当传递常规字符串时,intern
函数的性能非常好,但除非您锁定接口,否则始终有人会尝试传递可能破坏您函数的东西。
例如,在 Go 语言中,字符串是不可变的,而字节切片(byte slice)是可变的。这意味着如果使用字节切片作为 Map 的键,它可能会在任何时间被改变,从而导致 Map 中键的值不再与实际的字符串值相对应,这会引起错误和不一致性。在 Go 中,将字节切片转换为字符串是一个常见的优化手段,但这种“不安全的转换”(unsafe conversion)如果使用不当,也是产生 bug 的一个常见原因。
解决方案
为了避免这类问题,可以采取以下措施,确保intern
函数的稳定性和安全性:
- 字符串克隆:在
intern
函数中,对传入的字符串进行克隆,以确保无论原始字符串的来源如何,映射中存储的都是一个不可变的副本。
func TestB2T(t *testing.T) {
bs := []byte("hello")
str := ToUnsafeString(bs)
fmt.Println("Original string:", str)
strCopy := strings.Clone(str)
bs[1] = 'a'
fmt.Println("Modified string:", str)
fmt.Println("Clone string:", strCopy)
}
Original string: hello
Modified string: hallo
Clone string: hello
- 错误处理:如果检测到传入的参数类型不正确,
intern
函数应该返回一个错误,而不是尝试进行不安全的转换。 - 单元测试:编写全面的单元测试,确保
intern
函数能够正确处理预期的输入,并且在接收到错误的输入类型时能够给出明确的错误反馈。
转载自:https://juejin.cn/post/7373937820177612838