日志存储架构:从倒排索引到弱索引的范式转移

引言

日志是可观测性三大支柱中数据量最大、存储成本最高的信号。与指标(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]

字段大小说明
MagicNumber4 bytes文件魔数,标识本例为 Loki chunk 文件
version1 byte格式版本号
encoding1 byte压缩编码类型(gzip / snappy / none)
#structuredMetadatauvarint标签名/值数组长度,后跟实际标签字符串(压缩存储)
len(label-n)uvarint第 n 个标签的字节长度
label-nbytes标签名或标签值字符串
...多个标签重复上述结构
checksum4 bytesstructuredMetadata 整段的校验和
block-1bytes压缩后的日志块数据
checksum4 bytesblock-1 的校验和
...多个 block 重复上述结构
#blocksuvarintblock 总数量
#entriesuvarint该 block 的日志条目数
mint, maxts64, s64该 block 的时间范围(纳秒时间戳)
offset, lens64, s64该 block 在 chunk 文件中的偏移量和长度
...每 block 重复上述 4 个元数据字段
checksum4 bytes所有 blocks 元数据的校验和

Block 内部(解压后):每条日志 = ts(纳秒时间戳) | len | log bytes | symbols 引用[L1]。Symbols 机制引用 structuredMetadata 中的字符串,减少重复存储。

写入路径与持久化

Loki 的写入路径采用类 Cassandra 的一致性哈希架构,下图展示了一条日志从客户端到持久化的完整流程:

mermaid
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[INFO] request started | [ERROR] db fail | [WARN] retrying...日志流持续到达,Ingester 在内存中累积接收② Ingester 内存缓冲区累积Ingester 内存缓冲区(约 256KB)256KB③ 压缩为 Chunk(约 10x)~256KB 未压缩gzip/snappy~25KB Chunk~10x④ Flush 到对象存储Chunk对象存储S3 / GCS✓ Loki:仅存压缩 chunk + 标签索引,成本极低✕ ES:每条日志建立倒排索引 → 存储成本 10x+

动画说明:上方动画展示 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]

维度BoltDBTSDB
存储格式B-tree KV倒排索引(Postings + Series)
内存映射mmap 后不可控mmap 零拷贝,内存可回收
索引缓存必须(memcached)不需要
分片策略静态分片动态分片(目标 300-600MB/分片)
高基数表现差(内存膨胀)好(内存可控)
特性演进已冻结新功能仅限 TSDB

TSDB 写路径

下图展示了 Loki 使用 TSDB 时的完整写路径:

mermaid
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 索引查询流程查询: {app="api"} |= "error"Postings Offset TablePostingsSeries + Chunk Refapp=api -> @0x100app=api -> [0, 2]series_0: {app=api,env=prod}-> chunk_[t1, t5]app=web -> @0x200app=web -> [1]series_1: {app=web,env=prod}-> chunk_[t3]env=prod -> @0x300env=prod -> [0,1,2]series_2: {app=api,env=prod}-> chunk_[t6, t8](1) 在 Postings Offset Table 中查找 app=api -> 定位到偏移 0x100(2) Postings 段返回匹配 series ID 列表: [0, 2](3) 解析 series_0 和 series_2 条目 -> 获取完整标签集合以及对应的 chunk 引用(路径 + 时间范围)完整查询路径: {app="api"}-> Offset Table -> Postings [0,2] -> Series Chunks [t1,t5] & [t6,t8]

动画说明:上述动画展示了一条 TSDB 索引查询的完整路径。四个阶段依次展示:(1) 查询条件 {app="api"} 进入,在 Postings Offset Table 中查找匹配标签对;(2) 通过偏移量定位 postings 段,获取匹配的 series ID 列表 [0, 2];(3) 解析这些 series 条目,获取完整标签集合和 chunk 引用;(4) 全景展示完整查询链路。

查询路径与动态分片

TSDB 的查询路径比 BoltDB 更高效[L5][L6]

  1. Index Gateway 缓存:作为 TSDB 索引的前置缓存,Index Gateway 从对象存储下载并缓存近期 TSDB block,querier 通过 gRPC 获取索引,避免每个 querier 都去读取 S3
  2. 动态查询分片:TSDB 在每个 chunk 的索引条目中额外存储了 chunk 的 size(KB) 和行数。Query Frontend 据此估算每个时间分片的数据量,动态决定分片粒度——目标每分片 300-600MB。相比 BoltDB 的静态分片(按固定索引行数),动态分片在数据量波动时更均衡
  3. 无需索引缓存:TSDB 格式紧凑且 mmap 友好,不再需要 memcached/redis 作为索引缓存层

结构化元数据(Loki 3.0)

Loki 3.0 默认启用了 Structured Metadata[L4]:将既不属于标签(太大/太高基数)也不属于日志正文但查询中常用的字段(如 trace_idhttp.statusmethod)存入独立元数据存储。结构化元数据也被索引到 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]。写入链路如下:

  1. per-CPU 分片缓冲区:日志到达后按 CPU 核分散到各自的环形缓冲区,避免锁竞争
  2. 内存 part(~1 秒):缓冲区满或周期超时后刷新为内存 part,立即可查
  3. 小 part:内存 part 后台合并为小 part(写入磁盘,留在 page cache)
  4. 大 part:小 part 进一步合并为大 part(最大 ~1TB,不可变,写入绕过 page cache)

关键特性:part 一旦写入就不可变,合并后全量压缩,无碎空间。快照只需复制目录,无需一致性处理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
victoria-logs-data/
└── partitions/
    ├── 20260109/               # Per-day partition (UTC)
    │   └── datadb/
    │       ├── 1882C35B4CE64498/   # Part (十六进制 ID)
    │       │   ├── metadata.json            # 元数据
    │       │   ├── index.bin + metaindex.bin # block 索引
    │       │   ├── timestamps.bin            # 时间戳列 (delta 编码)
    │       │   ├── values.bin0..127          # 字段值列 (最多 128 shard)
    │       │   ├── bloom.bin0..127           # 每列的 bloom filter
    │       │   ├── message_values.bin        # _msg 字段
    │       │   └── column_names.bin          # 列名索引
    │       └── parts.json                    # 活跃 part 列表
    └── 20260110/

列式存储的精髓:同字段值连续存放。例如所有 status 值在 values.bin 的同一位置——同分布的值经 ZSTD 压缩效率极高。查询 | fields _time, status 只需读这两列,跳过其余全部[VL6]

写入流程

下方 Mermaid 图展示了 VictoriaLogs 从日志源到磁盘的完整写入路径:

mermaid
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,只读可能匹配的数据:

VictoriaLogs Bloom Filter 查询过滤流程查询: status=500Block 列表(按时间 + stream 排序)Block Astream: api | 10:00-10:05Block Bstream: api | 10:05-10:10Block Cstream: api | 10:10-10:15Block Dstream: api | 10:15-10:20Block Estream: web | 10:05-10:10Block Fstream: web | 10:10-10:15(1) 查询进入,时间范围过滤 → 定位到 partition 2026/01/09(2) metaindex 过滤 stream → Block A~D 匹配 api stream(3) Bloom filter 检查 "500" → Block A/C/D pass,Block B fail(4) 仅读取 passing block 的 values → 只要扫描 3/6 block结果: 在 Block A/C/D 中找到 status=500 的日志行

动画说明:四个阶段展示 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 字段(低基数):hostapppodcontainer — 标识日志来源
  • 非 stream 字段(高基数):trace_iduser_idip — 自动索引但不作为 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> | ...

logsql
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 全文搜索(默认搜索 _msg 字段)
error

# 时间范围(性能最佳实践——无时间范围的查询可能较慢)
_time:5m

# 精确字段匹配
status:=500

# 字段关键词搜索
log.level:error

# 短语匹配
"error: connection refused"

# 流过滤(可选——不像 Loki 必须指定)
{app="nginx"}

# 管道变换:排序 → 选字段 → 限制
_time:5m error | sort by (_time) desc | fields _time, host, _msg | limit 20

# 聚合统计:按 host 分组,算错误率,过滤 >10%
_time:5m
  | stats by (host)
      count() logs,
      count() if (error) errors
  | math (errors / logs) as error_rate
  | filter error_rate:>0.1

对比 LogQL(Loki)[VL8]

功能LogQLLogsQL
流选择器必须指定可选
全文搜索|= "error"error
字段过滤需先 | json 解析field:=value 直接过滤
高基数字段做标签则膨胀;塞 JSON 则慢作为普通字段索引,自动高效
聚合count_over_time / ratecount() / rate()
复杂统计需组合多个查询stats by ... 一步完成

性能对比

第三方实测(4 vCPU / 8GiB RAM,~500GB 日志,7 天)[VL11]

指标LokiVictoriaLogs差距
摄入吞吐量20 MB/s66 MB/s3.3x
CPU 使用4 vCPU(满)1.1 vCPU(峰值)73% 更低
内存(稳态)6-7 GiB0.6-2 GiB~5x 更低
磁盘占用501 GiB318 GiB37% 更少
“大海捞针"查询12 s0.9 s13x 更快
否定查询(300GB)2.6 s0.27 s10x 更快

架构组件

单节点模式[VL8]:单一二进制 victoria-logs-prod,端口 9428,零配置,自动适配 CPU/内存。

集群模式(同一二进制,不同启动参数)[VL8]

  • vlinsert:写入网关,按 stream 哈希分发到 vlstorage
  • vlselect:查询网关,扇出查询并合并结果
  • vlstorage:存储节点(每个节点即一个独立实例)

数据持久性:内存数据每 5 秒刷新磁盘(可配置 -inmemoryDataFlushInterval[VL6],写入后约 1 秒内即可查询。

压缩与成本

VictoriaLogs 的极致压缩来自三个层次:

  1. 列式布局:同类型值连续存放,ZSTD 压缩率极高
  2. 类型感知编码:IP → 4 bytes,时间戳 → delta 编码,重复值 → 字典编码
  3. 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 倍。

下方的动画演示了一条日志从原始文本到倒排索引的完整构建过程:

倒排索引构建过程1. 原始日志文本[ERROR] db connection failed at 10:03:452. 分词(Tokenization):将文本拆解为独立 term[ERROR]dbconnectionfailedat10:03:453. 构建倒排索引:每个 term 对应文档 ID 列表Term Dictionary(词典)Posting List(倒排表)[ERROR]Doc [1, 5, 12, ...]dbDoc [1, 3, 7, ...]connectionDoc [1, 8, 9, ...]failedDoc [1, 4, ...]一条日志产生 6 个 term → 百万条日志产生数百万条目 → 索引膨胀 3-5x

动画说明:上方动画分四阶段展示倒排索引构建过程。首先是一条原始日志文本,然后被拆分为独立的 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)可搜索,下图展示了从文档写入到磁盘持久化的完整流程:

mermaid
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 translog
  • async:每 sync_interval(默认 5s)后台 fsync
  • flush_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),包含所有索引词及其在倒排列表中的偏移位置和文档频率。

json
1
2
3
4
5
6
7
8
9
// hotcache 内容结构示意
{
  "terms": [
    {"term": "error", "posting_offset": 0, "doc_freq": 1542},
    {"term": "timeout", "posting_offset": 124, "doc_freq": 233}
  ],
  "field_stats": { "doc_count": 50000, "avg_field_len": 128 },
  "block_index": { "block_size": 128, "blocks": [] }
}

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 驱动的搜索过程:

  1. Split 预筛选:Searcher 从 Metastore 获取候选 split 列表(已通过时间范围裁剪)
  2. 获取 Hotcache:从对象存储下载每个候选 split 的 hotcache(~60ms,微请求)
  3. Term 定位:在 hotcache 中通过二分查找检查查询 term 是否存在
  4. 按需加载:仅对包含匹配 term 的 split,从对象存储拉取完整 inverted index 并解压 posting list
  5. 跳过优化:不包含 term 的 split 无需任何额外 I/O——这是 Quickwit 对比传统方案的核心优势

这种设计使 Searcher 节点可以完全无状态——不维护本地索引缓存,随时弹性伸缩。

索引配置模式

Quickwit 允许为字段选择三种索引模式,以平衡搜索能力与存储成本[QW23][QW25]

模式存储方式适用场景存储成本典型查询
Inverted IndexTantivy 倒排(term → posting list)全文搜索、关键词过滤message:error
Fast Field列式压缩(ZSTD + bit packing)数值过滤、聚合、排序status:>=500
Doc Store行存 JSON(ZSTD 压缩)原始文档检索返回完整日志行

索引配置示例(YAML):

yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
version: 0.7
index_id: "nginx-logs"
doc_mapping:
  field_mappings:
    - name: message
      type: text
      tokenizer: raw
      record: basic
    - name: status
      type: u64
      fast: true
      indexed: false
    - name: latency_ms
      type: u64
      fast: true
    - name: body
      type: json
      stored: true
  timestamp_field: "@timestamp"
indexing_settings:
  commit_timeout_secs: 30
  split_num_docs_target: 1000000

写入流程

Quickwit 的索引写入采用"缓冲 → 构建 → 上传 → 发布"的四阶段模型:

mermaid
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"]

各阶段详解:

  1. 缓冲积累:Indexer 从 Kafka / Kinesis / HTTP API 消费数据流,在内存中积累文档。默认 commit 间隔 30 秒或达到 split 目标大小(约 5GB 未压缩数据)
  2. 构建 Split:触发 Tantivy 进行索引构建——分词、建立 inverted index、编码 fast field、压缩 doc store,同时生成 hotcache。每个 split 以 UUID 命名
  3. 上传对象存储:将 split 文件夹(含所有索引文件 + hotcache)以原子方式上传至 S3/MinIO/GCS。上传成功后通知 Indexer
  4. 发布元数据:Indexer 向 Metastore(PostgreSQL)写入 split 元数据,包括 UUID、状态(published)、时间范围(min/max timestamp)、文件列表和大小
  5. Split 发现:Searcher 通过轮询 Metastore 发现新发布的 split,开始提供搜索服务

Merge 策略:后台 merge 进程定期将小 split 合并为大 split,以减少查询期间需要扫描的 split 数量。合并后更新 Metastore 中的 split 状态(staged → published → merged),并删除已合并的小 split。

查询流程

mermaid
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/>返回结果"]

查询过程详解:

  1. 查询接收:用户通过 REST API(或 gRPC)提交 QQL 查询,例如 message:"connection refused" AND status:>=500
  2. 时间裁剪:Metastore 利用每个 split 的 timestamp min/max 信息,快速过滤出时间范围匹配的 split——第一层关键剪枝
  3. Hotcache 预检:对每个候选 split,从对象存储下载 tiny hotcache,在其中查找查询 term 是否存在——第二层剪枝,核心性能优化
  4. 按需加载:仅对包含 term 的 split,从对象存储拉取完整的 inverted index,解码 posting list
  5. 打分排序:使用 Tantivy 的 BM25 算法对匹配文档打分,跨 split 合并排序
  6. 结果返回:从 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:500AND / OR / NOT
JSON 嵌套body.container:web点号访问嵌套字段

性能与基准测试

根据官方基准和社区测试数据[QW23][QW25]

维度QuickwitElasticsearch备注
索引吞吐(单节点)~200 MB/s~100 MB/s日志型数据
P50 搜索延迟(S3)~500ms~50ms(本地盘)存储后端差异
P50 搜索延迟(本地)~50ms~50ms本地缓存时可比
压缩比~6:1~3:1ZSTD vs LZ4
存储成本ES 的 ~1/101x对象存储 vs 本地盘
部署复杂度无状态 vs 有状态

Kalvad 案例:迁移至 Quickwit 后 TCO 降低 10x,压缩比比 ES 好 2x[QW26]

集群架构一览

mermaid
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 用跳数索引弥补)

下图展示了各系统在索引深度与存储成本之间的定位光谱:

mermaid
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