核心不是用什么结构,而是存什么、怎么查、何时释放:跳表等只索引offset/length元信息,真正在磁盘跳转定位的是os.seek+io.readat;value存整行必oom,存偏移长度仅占1/12内存。

处理海量文件索引时,内存结构选错会导致 OOM、GC 频繁、查询变慢——核心不是“用什么结构”,而是“存什么、怎么查、何时释放”。跳表、倒排、suffixarray 这些名字听着高级,但它们只索引元信息;真正决定性能的,是 os.Seek 能不能跳、io.ReadAt 能不能准、map 里有没有塞整行内容。
跳表 value 别存字符串,只存 offset 和 length
手写跳表或改用 github.com/google/btree 模拟跳表语义时,最常踩的坑是把完整日志行当 value 存进去。2GB 文件里每行平均 200 字节,100 万行就占掉 200MB 内存;而如果 value 是 []struct{ offset int64; length int },同样 100 万条索引仅需约 16MB(每个条目 16 字节)。
- 构建前必须调
os.Stat().Size()预估总行数,避免 slice 频繁扩容 - 插入前用
sort.Search定位有序位置,维持 key(如时间戳字符串)单调递增 - 查询后别用
bufio.Scanner读——它内部复用底层数组,可能被后续行污染;应file.Seek(offset, io.SeekStart)+io.ReadFull(file, buf[:length])
倒排索引要配行偏移数组,不能直接映射到文件内偏移
map[string][]int 天然适合“文档 ID 列表”,不是“文件字节偏移”。硬要用它查大文件,必须额外维护一个 lineOffset []int64:第 i 行开头在文件里的字节位置。否则每次查到行号后还得从头扫描,退化成 O(n)。
- 按块切分比按行更稳:固定 4KB/块,
idx[word] = append(idx[word], blockID),查词后Seek到块首再局部扫描 - 中文分词必须用
github.com/go-ego/gse,且显式设seg.WithFrequency(false),否则每个词多带一个int频次字段,内存多占 30%+ - 停用词过滤别在循环里
make([]string, 0),直接预分配容量,比如tokens := make([]string, 0, 16)
suffixarray 只适合静态只读大文本,别在运行时反复 New
index/suffixarray 构建开销远大于查询收益。对一个 50KB 的日志模板文本,suffixarray.New(data) 耗时约 20μs;而 strings.Index 查一次只要 30ns——慢 700 倍。它只在一种场景值得用:文本完全不变、长度 >10KB、后续搜索 ≥200 次。
- 误用典型:用户每输一个新关键词,就
suffixarray.New一次 → CPU 全花在建树上 - 正确做法:服务启动时对只读文件(如协议定义、日志格式说明)构建一次
*suffixarray.Index,之后所有搜索复用它 - 若文件会更新,宁可用
map[string][]struct{ offset int64; length int }+ 定期重建,也别扛着 suffixarray 的构建成本
最易被忽略的一点:所有索引结构都只是“地图”,os.Seek 和 io.ReadAt 才是“走路的人”。哪怕 map 键值再小、跳表层级再少,只要 value 里塞了整行内容,内存就注定失控;只要没预建行偏移或块偏移,倒排就只是纸面加速。结构选择的本质,是把“能扔给磁盘做的事”全扔出去,只在内存留最薄一层指针。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











