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 实现,核心就两个东西:DirectoryReaderIndexWriter。我当年看源码的时候,花了整整两天才理清楚它们的关系。

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 不适合你。你得考虑同步写入或者别的方案。但大部分场景下,几秒的延迟完全能接受——用户发个帖子,等几秒才能搜到,没人会觉得有问题。