本文永久链接 – https://tonybai.com/2026/10/26/victorialogs-high-performance-go-logging-engine
大家好,我是Tony Bai。
【导读】
日志系统是基础设施软件中最容易被低估的性能挑战之一。
每秒数十万甚至数百万条日志持续涌入,字段数量不断变化,查询需要跨越海量历史数据,存储成本还必须控制在合理范围内。面对这样的工作负载,仅仅依靠 Go 的 goroutine、channel 和垃圾回收机制,显然无法自动获得理想的性能。
2025 年以来,VictoriaMetrics 团队持续演进开源日志数据库 VictoriaLogs。近期,团队又通过一篇技术博客和一期 Tech Talk,分享了 VictoriaLogs 背后的性能工程实践。
其中最值得 Go 开发者关注的,并不是某个神奇的优化技巧,而是一个更深层的工程原则:真正高性能的系统,不是把每一件事都做得更快,而是从架构上减少必须做的事。
本文将从数据架构、查询路径、存储编码和 Go 运行时四个层面,拆解这些工程实践,并通过可以直接运行的 Go 代码,展示如何把其中的部分思想应用到自己的项目中。
【文章要点】
- 性能首先是架构问题,其次才是代码问题。数据组织方式决定了系统需要读取、解码和计算多少数据。
- 减少不必要的工作,通常比加快必要的工作更有价值。时间分区、流局部性和 Bloom Filter 共同减少无效扫描。
- 列式存储不仅是压缩技巧。它改变了查询的数据访问路径,让系统能够只读取所需字段。
- 编码和压缩需要协同设计。先让数据更容易压缩,再减少磁盘 I/O,并考虑解码所需的 CPU 成本。
- Go 的 GC 不是性能优化的唯一对象。热路径上的分配、数据复制和对象生命周期值得关注,但应该由真实的性能剖析结果决定优化优先级。
- 基准测试必须贴近实际负载。单一的
ns/op无法代表系统的整体性能,还需要关注吞吐量、分配量、CPU、内存、磁盘 I/O 和查询延迟。

一个反直觉的结论:性能优化,首先要学会少干活
在 VictoriaMetrics 团队近期的技术讨论中,VictoriaLogs 的 Principal Engineer Jesús Espino 分享了一个非常重要的观点:系统性能很大程度上取决于架构和数据结构,微优化则是在此基础上的进一步提升。
这个观点听起来很简单,却直接触及了很多 Go 开发者在性能优化中的盲区。
假设我们需要在一亿条日志中搜索包含 timeout 的记录。
一种实现方式是,逐条读取日志,将每条日志解码成 Go 字符串,再执行字符串匹配。如果一亿条日志中只有一千条包含目标词,那么为了找出这一千条记录,系统仍然可能需要处理大量无关数据。
我们可以优化字符串匹配函数,可以减少临时对象,可以使用更快的哈希算法,甚至可以针对 CPU 指令集做优化。
但这些优化都有一个共同的局限:系统依然需要面对那一亿条日志。
如果换一种思路呢?
假设系统在存储时已经知道每个数据块包含哪些关键词。当查询 timeout 时,它可以先判断某个数据块是否可能包含这个词。如果确定不包含,就完全跳过这个数据块。
此时,优化的不再是每条日志的处理速度,而是需要处理的日志数量。
这就是两种完全不同的性能优化思路。
思路 A:让每条数据处理得更快
优化函数、减少分配、加快字符串匹配。
数据量仍然是一亿条。
思路 B:让系统少处理数据
利用索引、过滤器和数据布局跳过无关数据块。
真正需要读取的数据可能只占很小一部分。
当然,实际效果取决于日志分布、查询条件和索引的有效性,不能简单地认为任何查询都能跳过绝大部分数据。
但从工程优先级来看,先考虑思路 B,往往比一开始就钻进字符串匹配函数更有价值。
这也为理解 VictoriaLogs 的整体设计提供了一个切入点。
第一层优化:数据架构决定性能上限
为什么日志数据库需要 LSM-tree?
VictoriaLogs 使用 LSM-tree(Log-Structured Merge-tree)架构组织数据。
LSM-tree 的核心思想是:不要要求每条新数据在写入时就立即进入一个完全整理好的磁盘结构,而是先接收数据,再通过后台合并逐步整理存储布局。
这与日志系统的工作负载高度契合。
日志通常具有持续写入、追加为主、历史数据很少修改的特点。如果每次写入都需要在磁盘上寻找正确的位置、调整复杂的索引结构,那么写入路径就可能承担大量随机 I/O 和维护成本。
LSM-tree 则采用不同的方式。
新日志先进入内存缓冲区,随后被组织成磁盘上的数据分片。新的分片可以独立写入,后台任务再将较小的分片合并为较大的分片。
整个过程可以概括为:

这里有两个关键点。
第一,写入路径不必承担全部的数据整理工作。
系统可以先高效接收日志,再把一部分整理成本交给后台任务。
第二,后台合并不只是清理文件,它还可以改善数据的组织方式。
假设系统刚开始写入时,数据被分散在许多小分片中。随着后台合并进行,系统可以更好地组织时间相近、属于同一日志流的数据,改善后续查询时的数据局部性。
这意味着 LSM-tree 的价值并不仅仅在于提升写入吞吐量。它还为后续查询优化提供了基础。
需要注意的是,采用 LSM-tree 并不意味着合并没有成本。后台合并会消耗 CPU、磁盘带宽和存储空间。如果合并速度长期赶不上数据写入速度,系统仍然可能面临性能压力。
因此,真正重要的不是简单地使用某一种存储结构,而是让写入、合并、查询之间形成合理的资源平衡。
时间分区:不要搜索与时间无关的数据
VictoriaLogs 将日志按照天划分为独立分区。
假设数据库保存了过去一年的日志,而用户只查询最近十分钟的错误记录。
如果系统没有有效的时间分区和索引,就可能需要检查大量与查询时间范围无关的数据。
有了按天划分的分区后,系统可以先排除不属于查询时间范围的分区,再对剩余分区执行更细粒度的筛选。
时间过滤因此不再只是查询执行阶段的一个条件,它同时也是数据定位的手段。
这对日志系统非常重要,因为日志查询通常带有时间范围。排查线上故障时,工程师更常见的问题是“过去五分钟发生了什么”,而不是“数据库中所有历史记录里出现过什么”。
日志流局部性:让相关数据尽可能靠近
除了时间,VictoriaLogs 还重视日志流(log stream)。
可以将日志流理解为来自同一应用实例或具有相同流标识的一组日志。例如:
{"_time":"2026-10-23T10:00:00Z","_msg":"request started","service":"checkout","instance":"node-1","level":"info"}
{"_time":"2026-10-23T10:00:01Z","_msg":"payment failed","service":"checkout","instance":"node-1","level":"error"}
{"_time":"2026-10-23T10:00:02Z","_msg":"request completed","service":"checkout","instance":"node-1","level":"info"}
如果这些日志在物理存储中能够保持较好的局部性,那么查询 service="checkout" 或 instance="node-1" 时,数据库就更容易定位相关数据。
局部性还有另一个好处:同一日志流往往会重复出现相同的字段值。将这些数据组织在一起,通常有利于提高压缩率。
这形成了一条很有价值的因果链:
更合理的数据组织 → 更好的局部性 → 更少的无关读取 → 更低的查询成本。
但这里也有一个工程上的平衡问题:日志字段的组合可能非常多。如果将过多高基数(High Cardinality)字段纳入流标识,可能导致流数量迅速增长。
因此,日志流设计不能只追求过滤能力,还需要考虑基数(cardinality)、存储开销和实际查询模式。
相关设计概念可参阅 VictoriaLogs Key Concepts。
第二层优化:让查询尽可能少读数据
如果说数据布局决定了查询能否快速定位,那么索引和过滤器决定了系统能否进一步跳过无关数据。
VictoriaLogs 的相关优化中,有两个值得重点理解的机制:稀疏时间索引和 Bloom Filter。
稀疏索引:不必为每一条日志建立昂贵的索引
一种直觉上的设计是,为每条日志都建立详细索引,以便快速找到目标记录。
但索引不是免费的。
索引需要占用内存和磁盘空间,需要在写入时维护,也会增加数据结构的复杂度。对持续写入的日志系统来说,过于密集的索引可能会将查询成本转化为写入成本和存储成本。
稀疏索引采用了另一种权衡方式:只为部分位置建立索引,再通过这些索引缩小搜索范围。
VictoriaLogs 使用稀疏时间索引帮助定位符合时间条件的数据。
这意味着系统不必为每一条日志都保存同样细粒度的定位信息,也能利用时间顺序减少不必要的扫描。
这类设计的关键,在于找到索引空间、定位精度和查询成本之间的平衡点。
Bloom Filter:快速证明某些数据根本不值得读取
Bloom Filter(布隆过滤器)是一种概率型数据结构,可以用较小的空间判断一个元素是否可能存在于某个集合中。
它的判断有两种结果:
- 一定不存在。可以安全地跳过对应的数据块。
- 可能存在。需要进一步检查真实数据。
Bloom Filter 允许出现假阳性,即实际不存在的元素可能被判断为“可能存在”。但在正确实现的前提下,它不会因为假阴性而错误地把实际存在的元素判断为不存在。
假设某个日志数据块中包含:
request started
request completed
connection established
如果该数据块关联的 Bloom Filter 能够证明 payment_failed 不在其中,查询引擎就不必读取该块并逐条检查。
需要强调的是,Bloom Filter 并不能直接返回目标日志,也不能保证所有查询都能获得相同的收益。它只是帮助系统排除不可能匹配的数据块。
可以用下面的示意图理解这一过程。

对于海量日志而言,这种机制的价值非常直观:即使读取单个数据块并不昂贵,当查询需要面对成千上万个数据块时,能够跳过其中大量无关数据,仍然可能显著降低总成本。
而且,这种优化通常不需要改变用户的查询语义。它属于数据库内部的执行优化:只要过滤器的判断正确,查询结果就不会因为跳过数据块而丢失。
这也是为什么 Bloom Filter 常被用于需要在较大数据集合中快速排除不匹配项的存储系统。
第三层优化:列式存储、专用编码与压缩
数据布局解决了“读哪些数据”的问题,接下来还要解决另一个问题:
读取的数据究竟需要占用多少空间,又需要多少 CPU 才能解码和处理?
为什么日志不应该永远被当成一整行字符串?
很多日志系统接收的都是 JSON 格式数据。
例如:
{
"_time": "2026-10-23T10:00:00Z",
"_msg": "payment request failed",
"service": "checkout",
"level": "error",
"ip": "192.0.2.10",
"user_id": "user-1001"
}
假设一次查询只需要统计 level 字段中 error 出现的次数。
如果存储系统把每条日志作为一个整体保存,查询就可能需要读取并解析大量与统计无关的字段。
而在列式存储中,同一字段的值可以集中存放在一起。查询只需要 level 时,系统就有机会主要读取这一列,而不必同时读取消息、IP 地址和用户标识。

这张图表达的是概念差异,并不代表 VictoriaLogs 磁盘文件的精确物理布局。
VictoriaLogs 会将字段组织为独立的数据块,并在查询时读取需要的字段。这种设计与列式分析数据库的思路相通:将物理读取范围与查询实际需要的数据尽可能对齐。
当然,列式存储并不意味着每次查询只读取一列。实际执行过程仍然取决于过滤条件、返回字段、数据块布局以及查询操作。
但当查询只需要少数几个字段时,列式布局能够避免许多无效的数据读取。
不同类型的数据,应该使用不同的编码方式
列式存储还带来一个额外优势:系统可以针对不同字段类型选择更合适的编码方式。
例如,时间戳通常存在连续性,很多相邻日志的时间差并不大。与其反复存储完整时间戳,不如考虑保存基准值和差值。
假设一组时间戳是:
1000000
1000001
1000003
1000004
一种差分表示方式是:
1000000, +1, +2, +1
这只是解释差分编码的简化示例,并非 VictoriaLogs 实际采用的具体二进制格式。
类似地,IP 地址也不一定需要一直以文本形式保存。
IPv4 地址 192.0.2.10 可以表示为四个字节,而不是将十进制字符、点号等文本内容反复保存。
对于日志级别(level)这样的低基数字段,字典编码也可能非常有效:
info
info
error
info
warning
error
可以先建立一个字典:
0 = info
1 = error
2 = warning
再将原始值表示为:
0, 0, 1, 0, 2, 1
上面的例子同样也只是概念演示,不是 VictoriaLogs 的实际编码协议。
真正的数据库会根据字段类型、值分布和数据块结构选择合适的编码策略。
编码与压缩不是二选一
专用编码和通用压缩并不是互相替代的关系。
编码可以先消除数据中明显的冗余,或者把数据转化为更适合压缩的形式;压缩算法再进一步减少存储体积。

这一过程涉及的不只是磁盘空间。
更小的数据意味着磁盘 I/O 可能减少,缓存能够容纳更多有效数据,存储带宽受限时的查询也可能更快。
但压缩同样会消耗 CPU。如果数据本身难以压缩,或者查询需要频繁解压大量数据,压缩带来的收益就可能下降。
因此,优秀的存储引擎需要同时考虑压缩率、编码成本、解码成本和查询模式,而不能仅仅追求最小的磁盘文件。
VictoriaLogs 官方文档也指出,它会针对不同类型的字段采用专用编码,并结合压缩和数据块组织提升存储与查询效率。详见 VictoriaLogs FAQ。
第四层优化:Go 的性能边界在哪里?
到这里,我们已经看到,VictoriaLogs 的性能基础首先来自数据架构。
但架构设计再好,最终仍然需要由代码执行。对于用 Go 实现的高吞吐数据库而言,内存分配、垃圾回收、CPU 消耗和数据复制依然是必须认真对待的问题。
GC 不是敌人,但分配行为需要有意识地设计
Go 的垃圾回收降低了手动管理内存的复杂度,却不意味着内存分配没有成本。
假设一个日志处理服务每秒处理十万条日志,每条日志的处理路径都会创建多个临时对象。
如果每次处理产生十个需要由 GC 管理的新对象,那么每秒就可能产生百万级别的对象分配。
这并不意味着所有对象都会立即触发一次垃圾回收,也不能仅凭对象数量判断性能一定很差。但持续、高频的分配会增加内存分配器和 GC 的工作量,并可能带来额外的内存占用和延迟波动。
因此,VictoriaMetrics 团队在技术讨论中强调的不是“消灭所有内存分配”,而是先找到真正的热点。
这一区别非常重要。
一个每秒调用十次的配置解析函数,即使每次分配了几十个对象,也未必值得优先优化。
一个每秒调用数百万次的字段解析函数,即使每次只产生少量分配,也可能成为系统的重要开销来源。
性能优化必须结合调用频率、每次调用的成本和整体资源消耗来判断。
内存复用:减少重复工作,而不是盲目使用对象池
对于高频处理路径,可以考虑复用临时缓冲区,减少不必要的内存分配。
例如,拼接日志字段、构造序列化结果时,如果每次都从头创建新的缓冲区,就可能产生重复的分配和扩容操作。
Go 标准库提供了 bytes.Buffer 等工具,可以在适当场景下复用已有的缓冲区。
但复用也有代价。
如果缓冲区长期保留过大的底层数组,就可能增加常驻内存;如果对象池中的对象包含较大的引用关系,还可能延长相关对象的生命周期。
因此,sync.Pool 不是一个可以无条件添加的性能开关。它只适合在经过测量、对象可安全复用且生命周期设计合理的场景下使用。
同样,减少分配也不应以牺牲代码正确性为代价。例如,用免拷贝方式将字节切片转换为字符串后,如果后续操作会修改原始字节,那么就必须明确数据所有权和生命周期,避免引入隐蔽的数据竞争或错误结果。
高性能代码不是把所有内存都复用起来,而是让内存分配与数据生命周期匹配。
先看数据,再改代码
Go 提供了成熟的性能剖析工具,包括 CPU Profile、内存 Profile、堆分配分析和基准测试。
这些工具的意义,在于将“我认为这里很慢”变成“数据表明这里消耗了多少资源”。
以 CPU Profile 为例,它可以帮助我们发现 CPU 时间主要花在哪里:JSON 解码、字符串匹配、压缩、哈希计算,还是其他操作。
内存 Profile 则可以帮助我们判断内存分配主要来自哪些函数,以及哪些分配值得进一步研究。
在实际工程中,一个合理的优化循环是:
- 用真实或接近真实的工作负载建立基准。
- 收集 CPU 和内存 Profile。
- 确认最重要的成本来源。
- 先考虑是否能从架构或算法上消除这些成本。
- 再优化具体实现。
- 重新运行基准,确认收益并检查是否引入回归。
这与 VictoriaMetrics 团队在近期讨论中反复强调的原则一致:先测量,再优化。
从单机到集群:性能不能只看一个进程
VictoriaLogs 的设计还体现了另一个重要原则:单机性能优化和水平扩展并不是互相替代的关系。
VictoriaLogs 提供单节点和集群部署方式。集群模式中,系统由三个主要组件组成:
vlinsert:接收日志并将数据分发到存储节点。vlstorage:保存日志数据,并执行相关查询工作。vlselect:接收查询,将查询分发到存储节点,汇总结果后返回客户端。
其大致架构如下:

这种架构将数据接入、持久化和查询协调分离开来,为横向扩展提供了基础。
但增加节点并不意味着所有负载都能线性加速。最终效果还取决于数据分布、网络开销、查询并行度、节点间的负载均衡,以及是否存在单节点瓶颈。
同样,即使集群能够扩展,如果每个存储节点都在做大量无用的数据读取,扩展也只是让系统以更高的资源成本完成同样的无效工作。
因此,优秀的基础设施软件需要同时具备两种能力:
一是通过架构、数据布局和算法减少单个任务的成本;二是在工作负载增长时,通过合理的并行化和水平扩展增加系统处理能力。
有关集群组件的职责,可参阅 VictoriaLogs Cluster 官方文档。
给 Go 开发者的性能工程启示
回到最初的问题:VictoriaLogs 为什么能够实现高效的日志处理?
答案不是某一个独立的技巧,而是多个层次的设计共同作用。
第一,数据架构让相关数据更容易被定位,减少无关读取。
第二,索引和 Bloom Filter 帮助查询引擎跳过不可能匹配的数据块。
第三,列式存储让系统有机会只读取查询需要的字段。
第四,专用编码与压缩降低了数据存储和传输的成本。
第五,Go 的内存分配、GC 和执行路径经过测量与针对性优化,进一步减少热点路径上的资源消耗。
这些优化并非相互独立。它们共同决定了一条日志从进入系统、被组织、写入磁盘,到最终被查询读取时,究竟需要消耗多少 CPU、内存和 I/O。
对于 Go 开发者来说,可以将其总结为一套实用的性能优化顺序。
第一步,重新审视架构。
先问系统是否真的需要处理这么多数据,能否通过索引、缓存、批处理或更合理的数据布局减少工作量。
第二步,优化算法与数据结构。
当处理范围已经合理后,再检查算法复杂度、数据局部性、编码方式和内存访问模式。
第三步,分析 Go 运行时成本。
使用基准测试和 Profile 定位分配、GC、CPU 消耗和数据复制问题。
第四步,用真实负载验证。
不仅观察微基准中的执行时间,也要检查端到端吞吐量、P95/P99 延迟、内存峰值、磁盘 I/O 和系统稳定性。
第五步,持续验证而不是凭感觉优化。
每一次改动都应当有明确的假设、可复现的测试和可以解释的结果。
这里还有一个经常被忽略的事实:Go 运行时持续演进,但新版本的 Go 并不会自动让所有程序获得同样幅度的性能提升。运行时改进能否转化为实际收益,取决于应用的工作负载以及原有瓶颈所在。
如果一个系统的主要成本来自不合理的数据读取方式,那么仅仅升级 Go 版本,显然无法解决根本问题。
小结
VictoriaLogs 的工程实践,为我们提供了一个很好的观察窗口。
它提醒我们,高性能数据库不是一组微优化技巧的简单集合,而是一套围绕数据组织、访问路径、存储成本和计算资源建立起来的系统设计。
对 Go 开发者而言,最值得学习的不是盲目复制某个对象池、某种编码方式或者某段底层代码,而是学会从完整的数据处理路径出发,识别真正昂贵的工作,并判断哪些工作可以被彻底消除。
当你下一次面对一个高吞吐 Go 服务,不妨先问自己三个问题:
- 我们处理的数据中,有多少实际上与结果无关?
- 我们能否通过更好的数据结构或访问路径,避免读取和计算这些数据?
- 当架构已经合理之后,Profile 是否证明某个具体的 Go 代码热点值得优化?
很多时候,性能提升最大的那一步,不是把一段代码写得更快。
而是让它根本不必执行。
参考资料
- Go, Logs and the Engineering Behind VictoriaLogs Performance —— 本文的主要参考资料,涉及 LSM-tree、数据局部性、Bloom Filter、列式存储、压缩、Go 内存分配与性能剖析。
- Tech Talk #15:Go, Logs and Performance —— 观看 VictoriaMetrics 团队现场讲解存储布局、数据分片和性能工程实践。
- VictoriaLogs 源码仓库 —— 可进一步研究实际的数据存储、查询和后台合并实现。
- VictoriaLogs Key Concepts —— 了解日志字段、日志流以及数据模型。
- VictoriaLogs Cluster —— 了解集群模式下的组件职责与数据流。
- Go 官方性能剖析文档 —— 学习 Go 程序的性能诊断工具与方法。
- VictoriaLogs FAQ —— 了解数据块、列式布局、编码、压缩和查询机制。
还在为写 Agent 框架频频死循环、上下文爆炸而束手无策?我的新专栏 《从0 开始构建 Agent Harness》 将带你:
- 抛弃臃肿框架,回归“驾驭工程 (Harness Engineering)”的第一性原理
- 用 Go 语言手写 ReAct 循环、并发拦截与上下文压缩引擎等,复刻极简OpenClaw
- 构建坚不可摧的 Safety Middleware 与飞书人工审批防线
- 在底层实现 Token 成本审计、链路追踪与自动化跑分评估
- 从“调包侠”进化为掌控大模型边界的“AI 操作系统架构师”
扫描下方二维码,开启从 0 开始构建Agent Harness 的实战之旅。

你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?
- 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
- 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
- 想打造生产级的Go服务,却在工程化实践中屡屡受挫?
继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!
我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。
目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。
