4、近实时搜索(NRT)原理
好,咱们来聊聊近实时搜索。说实话,我刚接触搜索引擎那会儿,一直以为数据写进去就能马上搜到。后来被现实狠狠教育了一顿——你想想看,如果每次写入都立刻刷新索引,那磁盘I/O和CPU都得炸。
近实时搜索,英文叫 Near Real Time,简称 NRT。它解决的核心矛盾就是:写入要快,搜索也要快,但两者不能同时做到极致。说白了,就是找个平衡点。
NRT的核心思想
NRT 的核心思想其实很简单:不追求写入后立刻可见,而是允许一个短暂的延迟。这个延迟通常是多少?几秒到几十秒。我见过有些业务场景要求 1 秒内可见,有些能接受 30 秒。具体看需求。
为什么不能做到实时?因为索引构建是个重操作。你写入一条数据,Lucene 要分词、要建倒排表、要写磁盘。如果每条数据都实时刷新,那性能会惨不忍睹。我在项目中遇到过一位产品经理,非要「毫秒级可见」,我给他看了压测数据后,他默默改成了「3 秒内可见」。
NRT 的核心权衡:
- 写入吞吐量 vs 搜索可见性
- 磁盘 I/O 频率 vs 内存使用率
- 索引段数量 vs 查询性能
嗯,这里要注意:NRT 不是「最终一致性」,也不是「强一致性」。它是个中间态——数据写入后,在内存中暂存,定期刷到磁盘并开放搜索。
从写入到可搜索的延迟分析
一条数据从写入到能被搜到,到底经历了什么?我拆开来说。
第一步:写入内存缓冲区
数据先写到内存里的缓冲区。这一步很快,微秒级。但此时数据还不可搜——它只是暂存着。
第二步:生成内存段
缓冲区满了或者定时器到了,Lucene 会把缓冲区数据生成一个内存段。这个段在内存里,但已经可以被搜索了。这一步是关键,延迟主要在这里。
第三步:刷到磁盘
内存段最终会刷到磁盘,变成持久化的段。这一步比较慢,但可以异步做。
我画个时间线你就明白了:
| 阶段 | 耗时 | 数据可搜? |
|---|---|---|
| 写入内存缓冲区 | 微秒级 | 否 |
| 生成内存段 | 毫秒级 | 是 |
| 刷到磁盘 | 秒级 | 是 |
所以,从写入到可搜索的延迟,主要取决于「生成内存段」这一步的触发频率。你设置 1 秒刷新一次,那延迟就是 1 秒左右。你设置 10 秒,那就是 10 秒。
避坑指南:我曾经把刷新间隔设成 100 毫秒,结果写入吞吐量直接掉了 80%。后来改成 2 秒,吞吐量上来了,业务也能接受。别盲目追求低延迟。
Lucene的NRT实现机制
Lucene 的 NRT 实现,核心就两个东西:DirectoryReader 和 IndexWriter。我当年看源码的时候,花了整整两天才理清楚它们的关系。
IndexWriter 的角色
IndexWriter 负责写数据。它维护了一个内存缓冲区,数据先写到缓冲区。当调用 commit() 时,才会刷到磁盘。但 NRT 场景下,我们不调 commit(),而是调 flush() 或者让 Lucene 自动触发。
DirectoryReader 的角色
DirectoryReader 负责读数据。它打开索引的快照。普通的 DirectoryReader.open() 只能看到磁盘上已经提交的数据。但 NRT 场景下,我们用 DirectoryReader.open(IndexWriter)——这个 API 能从 IndexWriter 的内存里拿到还没提交的数据。
代码示例:
// 创建 IndexWriter
IndexWriter writer = new IndexWriter(directory, config);
// 写入文档
writer.addDocument(doc);
// 关键一步:从 writer 打开 NRT reader
// 这个 reader 能看到刚才写入但还没提交的数据
DirectoryReader reader = DirectoryReader.open(writer);
// 用 reader 搜索
IndexSearcher searcher = new IndexSearcher(reader);
TopDocs hits = searcher.search(query, 10);
看到了吗?DirectoryReader.open(writer) 就是 NRT 的核心。它不要求你先 commit,直接从 writer 的内存里拿数据。
背后的机制
Lucene 内部维护了一个「未提交的段列表」。当 writer 把内存缓冲区刷成段时,这个段会加入列表。NRT reader 打开时,会把这个列表里的段也纳入搜索范围。
我举个例子:
- 你写了 100 条数据到 writer
- writer 自动刷了一次,生成了 1 个内存段
- 此时打开 NRT reader,能看到这 100 条数据
- 你又写了 50 条,还没刷
- 此时打开 NRT reader,只能看到之前的 100 条
- 等那 50 条也刷了,再打开 NRT reader,才能看到全部 150 条
所以,NRT 的延迟取决于 writer 的刷新频率。Lucene 默认是 根据写入量和时间综合判断,你也可以手动控制。
注意:频繁打开 NRT reader 是有代价的。每次打开都会重新打开段文件,消耗文件句柄和内存。我建议不要每次查询都打开新 reader,而是复用一段时间。
刷新策略配置
Lucene 提供了 LiveIndexWriterConfig 来配置刷新行为:
// 设置最大内存缓冲区大小
config.setRAMBufferSizeMB(256.0);
// 设置最大文档数触发刷新
config.setMaxBufferedDocs(10000);
// 设置刷新间隔(毫秒)
// 注意:这个不是 Lucene 原生支持的,需要自己实现定时器
// 但很多框架如 Elasticsearch 封装了这个功能
我个人习惯把内存缓冲区设成 256MB,文档数设成 10000。哪个先到就触发刷新。这样既能保证吞吐量,又能控制延迟在 1-2 秒内。
最后说一句:NRT 不是银弹。如果你的业务要求「写入后必须立刻搜到」,那 NRT 不适合你。你得考虑同步写入或者别的方案。但大部分场景下,几秒的延迟完全能接受——用户发个帖子,等几秒才能搜到,没人会觉得有问题。