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