日志存储架构:从倒排索引到弱索引的范式转移
引言
日志是可观测性三大支柱中数据量最大、存储成本最高的信号。与指标(Metrics)的固定数值结构不同,日志的变长文本和半结构化特性使得存储设计面临独特挑战:既要支持全文检索,又要控制存储成本。
日志存储系统的核心设计维度是索引策略——索引越深检索越快,但存储成本也越高。从 Elasticsearch 的全倒排索引到 Loki 的仅索引标签,各系统在这个光谱上做出了不同的技术选择。
本文深入分析五种主流日志存储系统——Grafana Loki、VictoriaLogs、Elasticsearch/OpenSearch、ClickHouse、Quickwit——的架构设计、索引策略与核心技术权衡。
新手速查 — 日志存储核心概念一句话理解
概念 类比 说明 倒排索引 书后面的索引页 记录某个词出现在哪些文档,搜索快但索引体积是数据的 3-5 倍 弱索引 只记目录不记内容 知道日志的大致位置,但需要扫描才能找到具体内容 Chunk 压缩包 将一批日志打包压缩,减少存储空间 对象存储 云盘(S3/GCS) 便宜但读取较慢的大容量存储,适合冷数据 列式存储 按列整理发票 同一列数据连续存放,压缩率高且可只读需要的列 如果你对倒排索引、对象存储这些术语还比较陌生,不用担心——下方的示意图和动画会用最直观的方式展示它们的工作原理。核心记住一句话:日志存储的设计就是在一张光谱上做选择——索引越深检索越快,但存储成本也越高。
Grafana Loki
设计理念:只索引标签,不索引日志内容
Loki 的核心理念是"像 Prometheus 一样做日志"——只对流(stream,即唯一标签集合)建立索引,日志正文本身仅压缩分块存储,不做倒排索引。这使得索引体积极小,存储成本可降至 Elasticsearch 的约 1/10 以下[L1][L3]。
底层数据结构:index 与 chunks 两类文件
Index(索引):记录某个标签集合的日志在哪里。两种格式——TSDB(推荐,源自 Prometheus TSDB 索引格式,可扩展)和 BoltDB(已废弃)[L1][L3]。
Chunk(数据块) 磁盘格式[L1]:
| 字段 | 大小 | 说明 |
|---|---|---|
MagicNumber | 4 bytes | 文件魔数,标识本例为 Loki chunk 文件 |
version | 1 byte | 格式版本号 |
encoding | 1 byte | 压缩编码类型(gzip / snappy / none) |
#structuredMetadata | uvarint | 标签名/值数组长度,后跟实际标签字符串(压缩存储) |
len(label-n) | uvarint | 第 n 个标签的字节长度 |
label-n | bytes | 标签名或标签值字符串 |
... | 多个标签重复上述结构 | |
checksum | 4 bytes | structuredMetadata 整段的校验和 |
block-1 | bytes | 压缩后的日志块数据 |
checksum | 4 bytes | block-1 的校验和 |
... | 多个 block 重复上述结构 | |
#blocks | uvarint | block 总数量 |
#entries | uvarint | 该 block 的日志条目数 |
mint, maxt | s64, s64 | 该 block 的时间范围(纳秒时间戳) |
offset, len | s64, s64 | 该 block 在 chunk 文件中的偏移量和长度 |
... | 每 block 重复上述 4 个元数据字段 | |
checksum | 4 bytes | 所有 blocks 元数据的校验和 |
Block 内部(解压后):每条日志 = ts(纳秒时间戳) | len | log bytes | symbols 引用[L1]。Symbols 机制引用 structuredMetadata 中的字符串,减少重复存储。
写入路径与持久化
Loki 的写入路径采用类 Cassandra 的一致性哈希架构,下图展示了一条日志从客户端到持久化的完整流程:
sequenceDiagram
participant Client as Client
participant Dist as Distributor
participant Ingest as Ingester
participant Quorum as Quorum
participant OSS as Object Storage
Client->>Dist: HTTP POST (流式日志)
Dist->>Dist: 一致性哈希 → 目标 ingester
Dist->>Ingest: 转发 stream
Ingest->>Ingest: 创建/追加 chunk
Ingest-->>Dist: 确认
Dist->>Quorum: 等待多数派确认
Quorum-->>Dist: 多数派已确认
Dist-->>Client: 200 OK
Note over Ingest,OSS: 定期 flush
Ingest->>OSS: Flush chunk
Ingest->>OSS: 上传索引图 1:Loki 写入路径。Distributor 收到 HTTP POST 流式日志后,对每个 stream 基于一致性哈希环确定目标 ingester。Ingester 接收流,为该(租户,标签集)创建或追加 chunk,先确认写入;distributor 等待多数派(quorum)确认后才返回成功。Ingester 将 chunk 在内存中累积,定期 flush 到对象存储;索引经 index shipper 上传[L1]。
持久化:依赖对象存储(S3/GCS/Azure)作为长期存储,ingester 内存 + WAL 提供近期数据与容灾。
新手类比:Loki 的 chunk 就像把零散的快递包裹打包成一个大箱子。不打包(ES 的方式)意味着每个包裹各自占用空间,可单独拆开查看;打包后虽然节省空间,但要找某个包裹就得先拆箱翻找。Loki 选择「打包存储 + 索引标签」的策略——知道哪个箱子里可能有你需要的东西,但需要打开箱子才知道里面到底有没有。
下方的动画展示了 chunk 从生成到持久化的完整生命周期:
动画说明:上方动画展示 Loki 的 chunk 生命周期——日志流持续到达 Ingester,在内存缓冲区内累积到约 256KB 后,经 gzip/snappy 压缩为约 25KB 的 chunk,随后 flush 到对象存储(S3/GCS)持久化。Loki 的核心优化在于:不索引日志正文内容,仅记录哪个 stream 的日志在哪个 chunk 里,大幅降低了存储成本。与 Elasticsearch 每一条日志都建立倒排索引的方式形成鲜明对比。
Loki 3.0 的 TSDB 存储引擎改进
Loki 2.8 引入了基于 Prometheus TSDB 子项目的 TSDB(Time Series Database)索引格式,作为 schema v13+ 的推荐索引后端[L1][L3]。本节讨论的 TSDB 指 Loki 的索引文件格式(而非时间序列数据库本身),它从根本上替代了早期 Loki 使用的 BoltDB 嵌入式 KV 存储索引,成为所有新存储特性的基础。
从 BoltDB 到 TSDB:为什么换?
Loki 早期使用 BoltDB(嵌入式 B-tree KV 存储)作为索引后端,通过 boltdb-shipper 将索引文件上传到对象存储。BoltDB 有三个固有缺陷:
- 内存不可控:BoltDB 文件 mmap 后无法按需释放内存,高基数场景下 querier 内存占用持续增长
- 查询开销高:每次查询需从 BoltDB bucket 反序列化整个条目列表,I/O 放大严重
- 索引缓存必须:BoltDB 查询依赖外部缓存层(memcached/redis),增加运维复杂度和成本
TSDB 从根本上解决了这些问题:采用倒排索引结构(Postings + Series),文件 mmap 后可零拷贝读取,无需索引缓存,且支持动态查询分片[L5][L6]。
| 维度 | BoltDB | TSDB |
|---|---|---|
| 存储格式 | B-tree KV | 倒排索引(Postings + Series) |
| 内存映射 | mmap 后不可控 | mmap 零拷贝,内存可回收 |
| 索引缓存 | 必须(memcached) | 不需要 |
| 分片策略 | 静态分片 | 动态分片(目标 300-600MB/分片) |
| 高基数表现 | 差(内存膨胀) | 好(内存可控) |
| 特性演进 | 已冻结 | 新功能仅限 TSDB |
TSDB 写路径
下图展示了 Loki 使用 TSDB 时的完整写路径:
flowchart TD
Agent["日志采集器<br/>(Promtail/Alloy)"] -->|push| Distributor
Distributor -->|hash ring| Ingester
Ingester -->|WAL + in-memory chunk| Chunk["Chunk (内存)"]
Chunk -->|flush @ 256KB/30min| ObjStore["对象存储<br/>S3/GCS"]
Ingester -->|build TSDB block| TSDBBlock["TSDB Index Block"]
TSDBBlock -->|shipper upload| ObjStore
Compactor -->|merge & deduplicate| ObjStore
style Agent fill:#FF9800,color:#fff
style Distributor fill:#2196F3,color:#fff
style Ingester fill:#2196F3,color:#fff
style ObjStore fill:#9C27B0,color:#fff
style Compactor fill:#E65100,color:#fff写路径说明:日志经 Distributor 一致性哈希路由到达 Ingester,写入 WAL 的同时在内存中构建 chunk。当 chunk 达到 ~256KB 或 30 分钟上限时,压缩并 flush 到对象存储。与此同时,Ingester 构建 TSDB index block(含该时段所有 stream 的倒排索引),通过 TSDB shipper 异步上传。Compactor 负责合并小的 TSDB block 并清理已删除的 series。
TSDB 索引结构与查询流程
TSDB 索引文件包含以下核心区域:
- Symbol Table:去重字符串字典,series 的标签名/值通过整数符号引用,大幅缩小索引体积
- Series:按标签字典序排列的系列条目,每个条目包含完整标签集合和对应的 chunk 引用(含 min/max 时间范围)
- Postings:标签对到匹配 series ID 列表的映射,构成倒排索引的核心
- Postings Offset Table:标签对到 postings 段的偏移索引,按标签名/值排序,查询时部分加载到内存
- TOC(Table of Contents):文件入口,记录各区域的偏移量
下方动画演示了一个 {app="api"} 查询如何通过 TSDB 索引结构逐步定位到匹配的 chunk:
动画说明:上述动画展示了一条 TSDB 索引查询的完整路径。四个阶段依次展示:(1) 查询条件 {app="api"} 进入,在 Postings Offset Table 中查找匹配标签对;(2) 通过偏移量定位 postings 段,获取匹配的 series ID 列表 [0, 2];(3) 解析这些 series 条目,获取完整标签集合和 chunk 引用;(4) 全景展示完整查询链路。
查询路径与动态分片
TSDB 的查询路径比 BoltDB 更高效[L5][L6]:
- Index Gateway 缓存:作为 TSDB 索引的前置缓存,Index Gateway 从对象存储下载并缓存近期 TSDB block,querier 通过 gRPC 获取索引,避免每个 querier 都去读取 S3
- 动态查询分片:TSDB 在每个 chunk 的索引条目中额外存储了 chunk 的 size(KB) 和行数。Query Frontend 据此估算每个时间分片的数据量,动态决定分片粒度——目标每分片 300-600MB。相比 BoltDB 的静态分片(按固定索引行数),动态分片在数据量波动时更均衡
- 无需索引缓存:TSDB 格式紧凑且 mmap 友好,不再需要 memcached/redis 作为索引缓存层
结构化元数据(Loki 3.0)
Loki 3.0 默认启用了 Structured Metadata[L4]:将既不属于标签(太大/太高基数)也不属于日志正文但查询中常用的字段(如 trace_id、http.status、method)存入独立元数据存储。结构化元数据也被索引到 TSDB 中,支持 {app="api"} | status="500" 这类查询——status 不是标签,但通过结构化元数据索引快速过滤。
Bloom Filters 演进(Loki 3.0 -> 3.3)
Bloom Filters 是 Loki 弥补不索引日志正文的加速手段[L5][L6]:
- Loki 3.0:对日志正文做 n-gram 构建 Bloom,可在查询时快速判断某 chunk 中是否可能包含某关键词,避免全量扫描
- Loki 3.3:改为基于结构化元数据的 key 和 key-value 对构建 Bloom(block schema V3)。由于元数据值空间远小于正文 n-gram,Bloom 体积小了数个数量级
局限性
尽管 TSDB 大幅改进了索引性能,Loki 的架构性局限仍然存在:
- 高基数标签致命:标签基数高会产生海量 stream,索引庞大、ingester 内存压力剧增,需严格控制标签基数
- 全文检索弱:不索引正文,大规模文本搜索靠全量扫描,Bloom 仅加速结构化元数据场景
- 依赖对象存储:历史查询延迟受对象存储 IOPS 影响,Index Gateway 缓存虽缓解但无法完全消除
VictoriaLogs (vmlogs)
与 VictoriaMetrics 的关系
VictoriaLogs 由 VictoriaMetrics 同一团队开发,是独立的日志数据库,但复用了 VM 的存储引擎设计思想(per-day 分区、合并压缩、列式布局),集群角色命名也与 VM 对齐(vlinsert/vlselect/vlstorage)[VL8][VL9]。注意:VictoriaLogs 集群模式不像 VM 那样在存储节点间自动复制,而是通过摄入层(vlagent)复制到多个独立目标实现 HA[VL8]。
存储架构:列式 LSM-Tree
VictoriaLogs 采用列式 LSM-Tree 架构,灵感来自 ClickHouse[VL6][VL9]。写入链路如下:
- per-CPU 分片缓冲区:日志到达后按 CPU 核分散到各自的环形缓冲区,避免锁竞争
- 内存 part(~1 秒):缓冲区满或周期超时后刷新为内存 part,立即可查
- 小 part:内存 part 后台合并为小 part(写入磁盘,留在 page cache)
- 大 part:小 part 进一步合并为大 part(最大 ~1TB,不可变,写入绕过 page cache)
关键特性:part 一旦写入就不可变,合并后全量压缩,无碎空间。快照只需复制目录,无需一致性处理。
| |
列式存储的精髓:同字段值连续存放。例如所有 status 值在 values.bin 的同一位置——同分布的值经 ZSTD 压缩效率极高。查询 | fields _time, status 只需读这两列,跳过其余全部[VL6]。
写入流程
下方 Mermaid 图展示了 VictoriaLogs 从日志源到磁盘的完整写入路径:
flowchart TD
Logs["日志源"] --> Buffer["per-CPU 缓冲区"]
Buffer --> InMem["内存 Part"]
InMem --> Small["小 Part"]
Small --> Big["大 Part"]
Big --> Retention["按天删除"]索引设计:Bloom Filter 而非倒排索引
这是 VictoriaLogs 最核心的设计决策——使用 Bloom Filter 代替倒排索引[VL6][VL8]:
- 每个 column 在每个 block 都有一个 bloom filter
- 摄入时自动对字段值做分词(单词),构建 bloom filter 指纹
- Bloom filter 大小:~2 bytes / 唯一 token(1000 个唯一词 ≈ 2KB bloom)[VL6]
- 查询时先过 bloom:“肯定不在” = 跳过整块 → “可能在” = 读 values
| 对比维度 | 倒排索引(Loki/ES) | Bloom Filter(VictoriaLogs) |
|---|---|---|
| 空间占用 | 3-5x 原始数据 | ~2 bytes / token |
| 冷缓存 | 需 page in 索引,大量 I/O | 极少数据,内存友好 |
| 高基数 | 索引膨胀,内存压力大 | 自动适应,性能稳定 |
| 自动索引 | 需预定义 schema | 全字段自动索引 |
两级索引过滤(index.bin + metaindex.bin):
metaindex.bin常驻内存,记录 stream 范围 + 时间范围到 index block 的映射index.bin存储 block 头部信息(stream、时间范围、行数、字节偏移)- 查询路径:先过滤 metaindex(内存)→ 读匹配的 index block → 检查 bloom filter → 读 values
下方动画演示了 bloom filter 如何逐 block 过滤,跳过不匹配的 block,只读可能匹配的数据:
动画说明:四个阶段展示 bloom filter 查询过滤: (1) 查询进入,时间范围过滤定位到 partition; (2) metaindex 根据 stream 过滤,淘汰不匹配的 block(web stream 被排除); (3) 对剩余 block 逐一检查 bloom filter(绿色 ✓ = 可能包含,红色 ✗ = 肯定不含); (4) 仅 pass 的 block 需要读 values,参与扫描的 block 减少 50%。
日志流(Log Streams)概念
VictoriaLogs 使用与 Loki 类似的 stream 概念[VL8]:
- Stream 字段(低基数):
host、app、pod、container— 标识日志来源 - 非 stream 字段(高基数):
trace_id、user_id、ip— 自动索引但不作为 stream - 自动生成
_stream_id和_stream标签(Prometheus 格式:{app="api",host="host-1"})
对比 Loki:Loki 只索引 stream 标签。高基数字段要么做标签(索引膨胀)要么塞 JSON 正文(查询时解析)。VictoriaLogs 自动索引所有字段,高基数不受 stream 约束,stream 选择器 {...} 是可选的。
LogsQL 查询语言
LogsQL 是 VictoriaLogs 的自定义查询语言,语法简洁,采用 Unix 管道风格[VL8]:
基本语法:<filters> | <pipe1> | <pipe2> | ...
| |
对比 LogQL(Loki)[VL8]:
| 功能 | LogQL | LogsQL |
|---|---|---|
| 流选择器 | 必须指定 | 可选 |
| 全文搜索 | |= "error" | error |
| 字段过滤 | 需先 | json 解析 | field:=value 直接过滤 |
| 高基数字段 | 做标签则膨胀;塞 JSON 则慢 | 作为普通字段索引,自动高效 |
| 聚合 | count_over_time / rate | count() / rate() |
| 复杂统计 | 需组合多个查询 | stats by ... 一步完成 |
性能对比
第三方实测(4 vCPU / 8GiB RAM,~500GB 日志,7 天)[VL11]:
| 指标 | Loki | VictoriaLogs | 差距 |
|---|---|---|---|
| 摄入吞吐量 | 20 MB/s | 66 MB/s | 3.3x |
| CPU 使用 | 4 vCPU(满) | 1.1 vCPU(峰值) | 73% 更低 |
| 内存(稳态) | 6-7 GiB | 0.6-2 GiB | ~5x 更低 |
| 磁盘占用 | 501 GiB | 318 GiB | 37% 更少 |
| “大海捞针"查询 | 12 s | 0.9 s | 13x 更快 |
| 否定查询(300GB) | 2.6 s | 0.27 s | 10x 更快 |
架构组件
单节点模式[VL8]:单一二进制 victoria-logs-prod,端口 9428,零配置,自动适配 CPU/内存。
集群模式(同一二进制,不同启动参数)[VL8]:
vlinsert:写入网关,按 stream 哈希分发到vlstoragevlselect:查询网关,扇出查询并合并结果vlstorage:存储节点(每个节点即一个独立实例)
数据持久性:内存数据每 5 秒刷新磁盘(可配置 -inmemoryDataFlushInterval)[VL6],写入后约 1 秒内即可查询。
压缩与成本
VictoriaLogs 的极致压缩来自三个层次:
- 列式布局:同类型值连续存放,ZSTD 压缩率极高
- 类型感知编码:IP → 4 bytes,时间戳 → delta 编码,重复值 → 字典编码
- Part 不可变:合并后可全量压缩,无碎空间
典型压缩比 10x-100x+[VL9]:nginx access log 等结构化日志可达 100x+;随机文本日志约 10x。100GiB 磁盘可存超 1TB 未压缩日志。比 ES/Loki 少用至 30x RAM、15x 磁盘[VL9]。
Elasticsearch / OpenSearch
倒排索引原理(Lucene 底层)
ES/OpenSearch 每个分片(shard)即一个 Lucene 索引,由多个不可变 segment 组成[ES13][ES17]。倒排索引核心结构:
- Term Dictionary(词典):term → Posting List 的映射
- Posting List(倒排表):记录该 term 出现在哪些文档(doc ID 列表),常采用 FOR/Roaring bitmap 压缩
新手类比:倒排索引就像书最后的索引页。不建索引就像逐页翻书找关键词——准确但极慢;建了索引就像查索引页找到关键词到页码的映射,瞬间定位。代价是索引页本身也需要空间——一本书的索引页可能增加几十页。在日志场景中,这个索引膨胀问题更严重:每条日志有数十个 term,索引体积可达原始数据的 3-5 倍。
下方的动画演示了一条日志从原始文本到倒排索引的完整构建过程:
动画说明:上方动画分四阶段展示倒排索引构建过程。首先是一条原始日志文本,然后被拆分为独立的 term(橙色块),每个 term 生成词典条目,指向包含该 term 的文档 ID 列表(绿色块)。注意一条仅 6 个 term 的日志就产生了 6 条索引记录——生产环境中每秒数万条日志,索引体积迅速膨胀到原始数据的 3-5 倍。这就是 Elasticsearch 搜索快但存储成本高的根本原因。
Segment 结构与文件格式
Lucene segment 是不可变的,包含多种文件类型:.tim/.tip(term dictionary + term index,FST 结构,前缀压缩)、.doc/.pos/.pay(posting list)、.fdt/.fdx/.fdm(stored fields)、.dvd/.dim(doc values,列式)、.cfs/.cfe(复合文件)。
写入路径与 NRT 机制
ES 的写入路径通过 refresh 机制实现了近实时(NRT)可搜索,下图展示了从文档写入到磁盘持久化的完整流程:
sequenceDiagram
participant Client as Client
participant Buf as In-Memory Buffer
participant TL as Translog
participant FSCache as FileSystem Cache
participant Disk as Disk
Client->>Buf: 写入文档
Client->>TL: 写入 translog
Note over Buf,FSCache: refresh(默认 1s)
Buf->>FSCache: 生成 segment(可搜索)
Note over TL,Disk: flush
FSCache->>Disk: fsync segment 到磁盘
TL->>Disk: commit + 清空 translog图 2:Elasticsearch NRT 写入机制。文档写入 in-memory buffer 的同时写入 translog(事务日志)。默认每秒执行一次 refresh,将 buffer 中的文档生成新 segment 并写入文件系统缓存,此时文档即可被搜索——这就是"近实时"的含义。flush 操作执行 Lucene commit(segment 真正 fsync 到磁盘)、清空 translog 并写入 commit point[ES12][ES13]。
Translog 持久化机制
translog 保证 commit 之间已确认但未持久化的操作在崩溃后可恢复[ES11]:
request(默认):每个请求后 fsync+commit translogasync:每sync_interval(默认 5s)后台 fsyncflush_threshold_size:默认 10GB,translog 达此大小触发 flush
日志场景优化:ILM + Data Tiers
Data Tiers(数据分层)[ES14]:Hot(NVMe/SSD,最新数据)→ Warm(便宜 SSD/HDD)→ Cold(很少查询)→ Frozen(基于 searchable snapshots,对象存储)。
ILM 五阶段[ES15]:hot → warm → cold → frozen → delete,每阶段含 actions(rollover/shrink/forcemerge/allocate/searchable_snapshot/delete)。
日志场景优缺点
优点:全文检索能力最强(倒排索引 + 复杂查询 DSL)、生态成熟、聚合分析强大。
缺点:存储成本高(倒排索引 + doc values + stored fields 导致索引膨胀)、JVM 堆内存开销大、高基数字段易导致 shard 过大。
ClickHouse 作为日志存储
列式存储在日志场景的应用
ClickHouse 是面向分析的列式数据库,将同一列连续存储。用于日志时,日志字段映射为列,时间戳/标签等高频过滤列作为 ORDER BY/PARTITION BY 主键,日志正文作为 String 列压缩存储。
MergeTree 引擎
- 数据按 PARTITION BY 分区,part 内按 ORDER BY 键排序,组成 granule(粒度,默认 8192 行) 为最小读取单元
- 稀疏主键索引:每 granule 记录主键值(mark),查询时按主键范围跳过无关 granule
- 列式压缩:每列独立压缩(LZ4 默认,可配 ZSTD)[CH19]
跳数索引(Data Skipping Indexes)
ClickHouse 列存不维护行级二级索引,用跳数索引记录每个索引粒度的统计信息[CH17][CH19][CH22]:
- minmax:记录每索引粒度的 min/max。Altinity 基准:1 亿行查询从 0.167s → 0.017s[CH19]
- bloom_filter:布隆过滤,适合等值/IN 查询。1.1 亿行数组
has(x,42)从 0.505s → 0.063s,约 10x 加速[CH19] - ngrambf_v1 / tokenbf_v1:n-gram/token 布隆过滤,用于 LIKE/短语查询
- ClickHouse 25.9 引入实验性 full-text index(截至本文撰写时仍为实验性,生产使用需评估稳定性)[CH20]
Quickwit
定位:专为日志/追踪的云原生搜索引擎
Quickwit 用 Rust 编写,基于 Tantivy(Rust 全文检索库,对标 Lucene)构建,专为可观测性工作负载设计[QW23][QW25]。核心架构选择:所有索引数据存对象存储,计算与存储分离,搜索成本约为 Elasticsearch 的 1/10[QW25]。
架构全景
Quickwit 集群由四个核心组件组成[QW23]:
- Indexer(索引器):接收数据流(Kafka、Kinesis、HTTP),在内存中缓冲文档,达到阈值后构建 Tantivy split,上传至对象存储并发布元数据
- Searcher(搜索器):无状态搜索节点,通过 hotcache 高效定位匹配文档,从对象存储拉取所需的倒排索引数据
- Metastore(元数据存储):基于 PostgreSQL(集群模式),管理 split 状态、时间范围、文件位置等元信息
- Control Plane(控制平面):协调节点,负责 split 分配、merge 调度和集群拓扑管理
Hotcache 深度解析
Hotcache 是 Quickwit 实现无状态搜索的核心创新[QW23][QW24]。本质上,hotcache 是 split 内倒排索引的词典文件(term dictionary),包含所有索引词及其在倒排列表中的偏移位置和文档频率。
| |
Tantivy 在构建 inverted index 时采用 block-wise 变长压缩编码,将 term 分组为 block(每 block 128 个 term),每个 block 记录第一个 term 的值作为 key,后续 term 仅存储与前一个 term 的差值。配合 skip list(跳表)结构,查找任意 term 只需 O(log N) 次比较和少量随机读——这使得 hotcache 文件通常仅 几 KB 到几十 KB。
Hotcache 驱动的搜索过程:
- Split 预筛选:Searcher 从 Metastore 获取候选 split 列表(已通过时间范围裁剪)
- 获取 Hotcache:从对象存储下载每个候选 split 的 hotcache(~60ms,微请求)
- Term 定位:在 hotcache 中通过二分查找检查查询 term 是否存在
- 按需加载:仅对包含匹配 term 的 split,从对象存储拉取完整 inverted index 并解压 posting list
- 跳过优化:不包含 term 的 split 无需任何额外 I/O——这是 Quickwit 对比传统方案的核心优势
这种设计使 Searcher 节点可以完全无状态——不维护本地索引缓存,随时弹性伸缩。
索引配置模式
Quickwit 允许为字段选择三种索引模式,以平衡搜索能力与存储成本[QW23][QW25]:
| 模式 | 存储方式 | 适用场景 | 存储成本 | 典型查询 |
|---|---|---|---|---|
| Inverted Index | Tantivy 倒排(term → posting list) | 全文搜索、关键词过滤 | 高 | message:error |
| Fast Field | 列式压缩(ZSTD + bit packing) | 数值过滤、聚合、排序 | 低 | status:>=500 |
| Doc Store | 行存 JSON(ZSTD 压缩) | 原始文档检索 | 中 | 返回完整日志行 |
索引配置示例(YAML):
| |
写入流程
Quickwit 的索引写入采用"缓冲 → 构建 → 上传 → 发布"的四阶段模型:
flowchart TD
A["数据源<br/>Kafka / HTTP"] --> B["Indexer<br/>缓冲区积累"]
B --> C["构建 Split<br/>+ Hotcache"]
C --> D["上传到<br/>对象存储"]
C --> E["发布元数据<br/>到 Metastore"]
E --> F["Searcher<br/>发现新 Split"]各阶段详解:
- 缓冲积累:Indexer 从 Kafka / Kinesis / HTTP API 消费数据流,在内存中积累文档。默认 commit 间隔 30 秒或达到 split 目标大小(约 5GB 未压缩数据)
- 构建 Split:触发 Tantivy 进行索引构建——分词、建立 inverted index、编码 fast field、压缩 doc store,同时生成 hotcache。每个 split 以 UUID 命名
- 上传对象存储:将 split 文件夹(含所有索引文件 + hotcache)以原子方式上传至 S3/MinIO/GCS。上传成功后通知 Indexer
- 发布元数据:Indexer 向 Metastore(PostgreSQL)写入 split 元数据,包括 UUID、状态(published)、时间范围(min/max timestamp)、文件列表和大小
- Split 发现:Searcher 通过轮询 Metastore 发现新发布的 split,开始提供搜索服务
Merge 策略:后台 merge 进程定期将小 split 合并为大 split,以减少查询期间需要扫描的 split 数量。合并后更新 Metastore 中的 split 状态(staged → published → merged),并删除已合并的小 split。
查询流程
flowchart TD
A["QQL 查询请求"] --> B["Metastore<br/>时间范围裁剪"]
B --> C["加载 Hotcache<br/>(来自对象存储)"]
C --> D{"Term<br/>是否存在?"}
D -->|"存在"| E["加载完整<br/>Inverted Index"]
D -->|"不存在"| F["跳过 Split<br/>(零 I/O)"]
E --> G["BM25 打分<br/>& 结果合并"]
F --> B
G --> H["读取 Doc Store<br/>返回结果"]查询过程详解:
- 查询接收:用户通过 REST API(或 gRPC)提交 QQL 查询,例如
message:"connection refused" AND status:>=500 - 时间裁剪:Metastore 利用每个 split 的 timestamp min/max 信息,快速过滤出时间范围匹配的 split——第一层关键剪枝
- Hotcache 预检:对每个候选 split,从对象存储下载 tiny hotcache,在其中查找查询 term 是否存在——第二层剪枝,核心性能优化
- 按需加载:仅对包含 term 的 split,从对象存储拉取完整的 inverted index,解码 posting list
- 打分排序:使用 Tantivy 的 BM25 算法对匹配文档打分,跨 split 合并排序
- 结果返回:从 doc store 读取完整文档行,返回最多 top N 条结果
Quickwit 查询语言 (QQL)
Quickwit 提供了类似 Lucene 语法的查询语言[QW25]:
| 查询类型 | 示例 | 说明 |
|---|---|---|
| 全文搜索 | error | 在默认字段上搜索 |
| 字段限定 | message:timeout | 指定字段搜索 |
| 精确短语 | message:"connection refused" | 短语精确匹配 |
| 范围查询 | status:[400 500] | 闭区间范围 |
| 比较查询 | latency:>1000 | 大于/小于/>=/<= |
| 正则查询 | message:/err.*/ | 正则匹配 |
| 布尔组合 | error AND status:500 | AND / OR / NOT |
| JSON 嵌套 | body.container:web | 点号访问嵌套字段 |
性能与基准测试
| 维度 | Quickwit | Elasticsearch | 备注 |
|---|---|---|---|
| 索引吞吐(单节点) | ~200 MB/s | ~100 MB/s | 日志型数据 |
| P50 搜索延迟(S3) | ~500ms | ~50ms(本地盘) | 存储后端差异 |
| P50 搜索延迟(本地) | ~50ms | ~50ms | 本地缓存时可比 |
| 压缩比 | ~6:1 | ~3:1 | ZSTD vs LZ4 |
| 存储成本 | ES 的 ~1/10 | 1x | 对象存储 vs 本地盘 |
| 部署复杂度 | 低 | 中 | 无状态 vs 有状态 |
Kalvad 案例:迁移至 Quickwit 后 TCO 降低 10x,压缩比比 ES 好 2x[QW26]。
集群架构一览
flowchart TD
CP["Control Plane<br/>协调调度"] --> I["Indexer<br/>写入节点"]
CP --> S["Searcher<br/>搜索节点"]
I --> OBJ["对象存储<br/>S3 / MinIO"]
S --> OBJ
I --> META["PostgreSQL<br/>Metastore"]
S --> META关键设计决策:
- 存储与计算完全分离:所有索引数据持久化在对象存储,Indexer 和 Searcher 均无持久化状态
- Metastore 作为事实来源:split 的元数据(存在性、状态、时间范围)全部由 PostgreSQL 管理
- Control Plane 轻量编排:负责 Indexer / Searcher 的注册发现、split 分配策略、merge 任务调度
- 弹性伸缩:Searcher 节点可按查询负载水平扩缩,新节点只需从 Metastore 读取 split 列表即可提供服务
日志系统横向对比
存储成本对比
| 项目 | 索引策略 | 主存储 | 压缩 | 存储成本 |
|---|---|---|---|---|
| Loki | 仅标签 | 对象存储 (S3) | gzip/snappy/zstd | 极低 |
| VictoriaLogs | 自动索引全字段 | 本地磁盘 | ~10x+ | 极低 |
| ES/OpenSearch | 倒排索引全文 | 本地磁盘 (SSD) | LZ4/DEFLATE | 高(索引膨胀) |
| ClickHouse | 跳数索引+列存 | 本地磁盘 | LZ4/ZSTD | 低 |
| Quickwit | 倒排索引+对象存储 | 对象存储 (S3) | ZSTD | 低(约 ES 1/10) |
核心技术权衡
日志存储的本质权衡是 “索引深度 vs 存储成本”:
- ES/Quickwit 索引正文 → 检索快但存储贵(Quickwit 用对象存储 + 压缩缓解)
- Loki/VictoriaLogs/ClickHouse 弱索引正文 → 存储省但全文检索靠扫描/跳数(VictoriaLogs 用自动字段索引、Loki 用 Bloom、ClickHouse 用跳数索引弥补)
下图展示了各系统在索引深度与存储成本之间的定位光谱:
flowchart TD
ES["ES/OpenSearch<br/>全倒排索引"]
VL["VictoriaLogs<br/>自动索引全字段"]
CH["ClickHouse<br/>跳数索引+列存"]
LK["Loki<br/>仅索引标签"]
ES --> VL
VL --> CH
CH --> LK
style ES fill:#f44336,color:#fff
style VL fill:#2196F3,color:#fff
style CH fill:#9C27B0,color:#fff
style LK fill:#4CAF50,color:#fff图 3:日志存储索引策略光谱。从 ES 的全倒排索引(红色,检索最快但成本最高)到 Loki 的仅索引标签(绿色,成本最低但全文检索靠扫描),各系统在索引深度与存储成本之间做出了不同的权衡。VictoriaLogs(蓝色)采用自动索引全字段的折中策略,ClickHouse(紫色)依靠跳数索引和列式压缩在分析场景中取得了独特的平衡。
来源
[L1] [官方] Grafana Loki architecture: https://grafana.com/docs/loki/v3.2.x/get-started/architecture/
[L3] [官方] Loki storage (TSDB 2.8 推荐, 对象存储): https://archive.grafana.com/docs/loki/v3.0.x/configure/storage/
[L4] [官方] Loki 3.0 release notes: https://grafana.com/docs/loki/v3.0.x/release-notes/v3-0/
[L5] [官方] Loki 3.3 release notes: https://grafana.com/docs/loki/latest/release-notes/v3-3/
[L6] [二级资料] Grafana Loki 3.3 Blooms for structured metadata: https://portal.raintank.io/blog/2024/11/21/grafana-loki-3.3-release-faster-query-results-via-blooms-for-structured-metadata/
[VL8] [官方] VictoriaLogs architecture basics: https://victoriametrics.com/blog/victorialogs-architecture-basics/
[VL9] [官方] VictoriaLogs docs: https://docs.victoriametrics.com/victorialogs/
[VL6] [二级资料] VictoriaLogs per-day 分区与保留: https://docs.victoriametrics.com/victorialogs/
[ES11] [官方] Elasticsearch translog: https://www.elastic.co/guide/en/elasticsearch/reference/8.17/index-modules-translog.html
[ES12] [官方] Elasticsearch translog flush: https://www.elastic.co/guide/en/elasticsearch/guide/master/translog.html
[ES13] [官方] OpenSearch force-merge: https://docs.opensearch.org/3.1/api-reference/index-apis/force-merge/
[ES14] [官方] Elasticsearch data tiers: https://www.elastic.co/guide/en/elasticsearch/reference/7.17/data-tiers.html
[ES15] [官方] Elasticsearch ILM phases and actions: https://docs-v3-preview.elastic.dev/elastic/docs-builder/docs/2927/manage-data/lifecycle/index-lifecycle-management/index-lifecycle
[ES17] [官方] Elasticsearch indices/documents/mappings: https://www.elastic.co/guide/en/elasticsearch/reference/current/documents-indices.html
[CH17] [官方] ClickHouse data skipping indices: https://clickhouse.com/docs/best-practices/use-data-skipping-indices-where-appropriate
[CH19] [二级资料] Altinity KB: ClickHouse skip indexes benchmarks: https://kb.altinity.com/altinity-kb-queries-and-syntax/skip-indexes/printview/
[CH20] [官方] ClickHouse 25.9 release(实验性 full-text index): https://clickhouse.com/blog/clickhouse-release-25-09
[CH22] [官方] ClickHouse data skipping indices best practices: https://clickhouse.com/docs/best-practices/use-data-skipping-indices-where-appropriate
[QW23] [官方] Quickwit architecture: https://quickwit.io/docs/main-branch/overview/architecture
[QW24] [官方] Quickwit querying: https://quickwit.io/docs/overview/concepts/querying
[QW25] [二级资料] Quickwit overview: https://rustutils.com/tools/quickwit/
[QW26] [官方] Kalvad migration story: https://quickwit.io/stories/kalvad-migration