3.3 分词与索引:让日志可检索

日志存起来只是第一步,真出事时每个人第一反应都是「grep 一下」。但日志量到千万级以后 grep 就力不从心了:一次跨天搜索扫几十 GB,等结果出来故障都过去了。要可检索,就得像搜索引擎一样建倒排索引:把每条日志切成词,记录每个词出现在哪些段里,搜的时候先查索引再精准定位。

先决定索引什么字段

全文索引又贵又慢,日志检索的实际情况是 80% 的查询集中在少数几个字段:trace ID、用户 ID、接口路径、错误码。所以正确的姿势是区分对待:

字段 索引方式 查询频率
trace ID / 请求 ID 精确 KV 索引 极高
用户 ID、设备 ID 精确 KV 索引 高
级别、错误码 分桶倒排 高
接口路径、模块名 前缀分词倒排 中
正文全文 可选,成本最高 低

精确 KV 索引不需要分词,直接把「trace ID → 段位置」写进 RocksDB 就行;只有需要模糊匹配与全文搜的字段才上倒排。检索能力自己做有限时,也可以把转储后的日志投递到日志服务 CLS,借现成的检索能力,比自己维护倒排省心。字段选择的思路与数据建模相通,可对照 MySQL 分库分表实战 的索引选型。

分词策略:字符级与词典级

日志不像自然语言句子,分词有两条主流路线:

策略 怎么切 优点 缺点
字符 N-gram 按固定长度滑窗切 无需词典、通配符友好 索引膨胀大
词典分词 按预置词表与符号切 索引小、精度高 词典要维护
按空白与标点切 最简单的朴素切分 零成本 分割粒度粗

日志里大量是 key=value、JSON、路径这类结构化文本,按非字母数字字符切分(split on boundaries)往往比中文词典还好用:user_id=42&path=/api/order 自然切成 user_id、42、path、api、order。实战组合拳:结构化字段走精确索引,正文走 2~3 gram 兜底模糊搜。

顺带一句,分词粒度和存储成本直接挂钩:每多一种切分方式,索引就多一倍。上线前把「哪些字段需要模糊查」问清楚,没需求的字段一律不进倒排,省下来的不只是磁盘,还有索引重建的时间。

// 朴素分词:按非字母数字边界切,再压小写
fn tokenize(line: &str) -> Vec<String> {
    line.split(|c: char| !c.is_alphanumeric())
        .filter(|s| !s.is_empty())
        .map(|s| s.to_lowercase())
        .collect()
}

倒排索引怎么落

倒排索引的本质是词到文档列表的映射。日志场景的经典做法是按段落建索引:一个段(比如 15 分钟或 256 MB)建一个小索引,查询时按时间范围挑选相关段、段内各自命中后合并。这样索引天然带时间局部性,老段可以直接整段转冷归档。

// 段级倒排:词 -> 段内行号偏移
use std::collections::HashMap;

let mut inverted: HashMap<String, Vec<u64>> = HashMap::new();
for (lineno, word) in tokens {
    inverted.entry(word).or_default().push(lineno);
}
// 段索引序列化后追加写入段尾
结构 键 值 存哪里
精确索引 trace ID 段号+行号 RocksDB
倒排索引 词 段内行号列表 段尾或独立索引段
元数据 段时间范围 段文件名 RocksDB

查询流程:先缩范围,再精准打

一次查询分三步:按时间范围筛段(元数据索引)→ 段内查倒排拿行号 → 读段解压取原文。前两步都走索引,只有第三步才真正碰数据,扫描量从全量降到一两个段,速度有数量级差异。注意压缩与索引的配合:索引必须建在原文上,压缩段随机读时要能按行定位,所以段内每 N 行最好记一个偏移锚点,避免每次都从头解压。

步骤 操作 开销
筛段 时间范围 + 段元数据 极低
查索引 倒排/精确索引拿行号 低
取原文 段内按锚点解区间 中

三步里最容易掉链子的是第三步的锚点设计。段是压缩存储的,从头解压到目标行显然太亏;每隔 1024 行记一个「字节偏移 → 压缩流位置」的锚点,查到行号后定位最近锚点、只解一小段即可。锚点间隔是典型的以空间换时间:间隔太密索引体积上涨,间隔太疏单次解压成本上升。实测 1024 行的间隔在压缩段上也只占千分之几的体积,几乎免费。

锚点间隔 锚点体积占比 单次查询解压量
256 行 约 0.8% 极小
1024 行 约 0.2% 小
8192 行 约 0.03% 中

合并查询:跨段结果怎么归并

一次查询往往跨多个段:先按时间范围选出 N 个段,每个段各自返回行号命中,再按时间戳归并成一条结果流。这里有三个细节值得注意:其一,段之间的时间可能重叠(切段的时刻与段内日志时间戳不完全对齐),归并必须以日志自身的时间戳为准而不是段元数据;其二,分页要做成「游标式」,把(段号, 行号)当游标,避免深分页时反复重头扫;其三,结果条数要设上限,一次无限制的查询能把管道自己拖垮——日志检索场景里「先给前 100 条」比「给你全部」重要得多。分页与归并的套路可对照 Node.js 看板实战 里的列表分页设计。

索引的维护成本

建索引一时爽,维护火葬场。日志场景的索引有两个先天特点:只追加、按段过期,这使得它比通用搜索引擎好伺候很多——老段整体过期,索引跟着整段删掉,不存在碎片整理的问题。真正费心的是段未冻结时索引的写入:段还在追加,倒排表放内存里攒着,冻结时一次性序列化进段尾;冻结前机器崩了,内存索引丢了也没关系,重放段内数据重建即可。

维护项 频率 说明
段冻结刷索引 每段一次 增量成本
索引重建 仅崩溃后 从段数据重建
过期段删除 按生命周期 整段连带索引一起删

顺带一提,索引体积要有监控:如果它悄悄涨到段的 30% 以上,多半是分词粒度没控住(N-gram 全字段开了、或者词典没维护),这时先缩小索引字段集,比换压缩算法有用得多。

索引重建的存在意味着索引可以被当作「可重建的缓存」,而不是唯一权威——想通这一点,索引的容错设计就简单了:宁可丢索引,不可丢段。冷备与重建的思路可对照 PHP API 实战 的缓存重建设计。

踩坑提示

第一个坑是索引膨胀:N-gram 看着通用,但 2 gram 的索引体积能到原文的两三倍,全字段上就是灾难。一定要区分精确索引与全文索引,全文索引只给正文上。第二个坑是小写化与归一化漏做:ERROR 与 error 搜不出来是查询体验永远的第一名投诉,建索引时统一归一化(小写、去标点、归一化时间戳格式),查询时用同一套逻辑。第三个坑是索引与数据不一致:写段成功但写索引失败,这段数据就「存在但搜不到」。要么索引写入与段写入共享一个事务边界(RocksDB 的 WriteBatch),要么后台任务定期校验段与索引的一致性。数据一致性的通用兜底可对照 MySQL 分库分表实战 的一致性章节。

小结

精确字段上 KV 索引,正文上朴素分词加倒排;索引按段组织,查询先筛段再取原文;归一化放进写入与查询的公共路径。分词与索引让日志从「压在磁盘里的一堆字节」变成「十秒内能问出答案的现场」。一句话总结:字段分层索引、段级小倒排、锚点供随机读、归并按时间戳,索引本身永远可重建。

延伸阅读

  • 索引数据如何存进 RocksDB,见 4.1。
  • 段的时间组织与滚动,见 3.4。
  • 检索词归一化的实践,见 Go 微服务实战。
  • 统一检索能力的投递,见 Go 云盘实战。