在 Spring Boot 项目中接入 Lucene 后,有几个问题很自然地连在一起:索引到底保存在哪里?服务重启后能不能继续用?每次搜索是不是都要读硬盘?如果索引越来越大,操作系统的文件缓存会不会撑满内存?

这些问题涉及不同层次:索引持久化、Lucene 的读取方式、操作系统的内存管理,以及应用的查询负载。本文结合一个包含 10 条 FoodData Central 食品预设数据的项目,把这条链路讲清楚。

项目采用 Java 17、Spring Boot 3.5.16、Lucene 9.12.3。代码片段来自当前项目,涉及实现选择的说明以 Lucene 9.12.3 为准;Linux 监控命令单独标注适用环境。

1. 数据加载到内存,不代表索引保存在内存

项目启动时,初始化器读取本地 JSON:

List<Food> foods;
try (var input = new ClassPathResource("seed/fdc-foods.json").getInputStream()) {
    foods = objectMapper.readValue(input, new TypeReference<List<Food>>() {});
}

这一步确实产生了内存中的 Java 对象。foods 保存 10 条食品记录,供初始化器遍历并转换成搜索文档。

但它只是导入过程中的临时对象集合,不是 Lucene 索引的存储位置。决定索引存在哪里的是下面这段代码:

analyzer = new StandardAnalyzer();
directory = FSDirectory.open(Path.of(indexPath));
writer = new IndexWriter(directory, new IndexWriterConfig(analyzer));

本例使用 FSDirectory,索引文件存放在文件系统中。配置如下:

lucene:
  index-path: ${LUCENE_INDEX_PATH:./data/index}
  seed:
    enabled: ${LUCENE_SEED_ENABLED:true}

默认目录是启动工作目录下的 data/index。写入方法将数据转换成 Lucene 文档:

public synchronized void index(String id, String title, String content)
        throws IOException {
    Document document = new Document();
    document.add(new StringField("id", id, Field.Store.YES));
    document.add(new TextField("title", title, Field.Store.YES));
    document.add(new TextField("content", content, Field.Store.YES));
    writer.updateDocument(new Term("id", id), document);
    writer.commit();
}

id 用于精确匹配,title 和 content 用于全文检索。Store.YES 让命中后可以读取字段原值。commit() 将更改提交为持久化的索引状态。

所以,这个项目同时存在三种不同的数据:

数据所在位置用途
JSON 预设文件应用资源,构建后进入 JAR提供初始化输入
List<Food> 等 Java 对象JVM 堆在导入过程中处理记录
Lucene 索引文件data/index 等磁盘目录持久化保存可搜索的数据

初始化完成后,代码不会把整份 List<Food> 留在字段中。它在不再被引用后可以被垃圾回收,具体回收时间由 JVM 决定。

2. 服务重启后,为什么可以复用原来的索引

IndexWriterConfig 默认采用 CREATE_OR_APPEND 模式:不存在索引时创建,存在时打开并继续写入。这个行为见 Lucene IndexWriterConfig 官方文档。

因此,在相同目录、兼容版本和正常文件访问条件下,重启不需要把所有食品重新分词、重新建立索引。

本项目的预设初始化器还会检查业务 ID。存在 fdc-173944 这样的文档时,直接跳过,避免覆盖用户修改。

需要注意的是,相对路径跟随启动工作目录变化。以下配置在不同目录启动时,可能指向不同位置:

./data/index

部署时可以使用固定路径,例如:

export LUCENE_INDEX_PATH=/srv/lucene-learning/index
./gradlew bootRun

这里的目录需要提前确保具备写入权限。容器环境则应把索引目录映射到持久卷;否则容器重建时,即使应用内执行过 commit,索引也可能随临时文件系统一起消失。

3. 搜索会读磁盘,但不会逐条扫描全部正文

当前项目的搜索核心代码如下,省略了参数校验和查询解析部分:

try (var reader = DirectoryReader.open(writer)) {
    var searcher = new IndexSearcher(reader);
    var hits = searcher.search(query, limit);
    var results = new ArrayList<SearchResult>();

    for (var hit : hits.scoreDocs) {
        var document = searcher.storedFields().document(hit.doc);
        results.add(new SearchResult(
                document.get("id"),
                document.get("title"),
                document.get("content"),
                hit.score
        ));
    }
    return results;
}

这个过程可以分成两个阶段:

  1. 利用倒排索引定位匹配文档,并计算相关度。
  2. 对返回的命中文档读取已保存字段,组装响应。

假设标题和正文中存在如下对应关系:

bananas → 文档 A
broccoli → 文档 B
protein → 文档 A、B、C……

查询 bananas 时,Lucene 利用词项和文档之间的索引关系定位结果,而不是把所有食品正文都读取出来,再逐条判断是否包含这个字符串。

这也说明不同查询的成本可能不同:一个只匹配少数文档的查询,与一个需要处理大量候选文档的查询,工作量并不相同。限制返回 10 条,不能简单推导为只处理了 10 条文档。

索引文件最终在磁盘上,但一次查询需要的内容可能已经驻留在内存中。真正发生多少物理磁盘读取,取决于读取方式、缓存状态和查询访问范围。

4. 内存映射、物理内存与 JVM 堆的区别

FSDirectory.open() 会根据运行环境选择具体实现。Lucene 9.12.3 文档说明,在常见的 64 位 Linux、macOS、Windows 等环境下会返回 MMapDirectory。这个默认选择可能随版本变化,不能把它当作所有版本、所有环境下的固定结论。参见 FSDirectory.open 官方说明。

MMapDirectory 使用内存映射读取文件。可以把它理解为:在进程的虚拟地址空间中建立文件区域的映射,程序访问这些地址时,由操作系统处理相应文件页面。映射本身并不等于把全部文件复制到 Java 堆中。MMapDirectory 官方文档 也区分了映射地址空间和可选的物理内存预加载。

下面这三个指标不能混用:

指标描述
JVM 堆Java 对象使用的内存,受 -Xmx 等参数约束
进程虚拟地址空间包含堆、库、文件映射等地址范围
驻留内存当前实际驻留在物理内存中的页面

假设有 100 GB 索引、16 GB RAM,这是一个用于说明机制的例子,不是本项目的压测结果。

映射一个很大的文件范围,不要求同等大小的 RAM 立即可用。被访问的文件页面可能已在缓存中,也可能需要从存储设备读取。操作系统还可能预读相邻页面,因此实际行为并非严格的“一次访问只读一个页面”。

在内存映射路径中,可以用下面的概念流程理解访问:

查询需要读取某个索引区域
    ↓
访问文件映射地址
    ↓
相关文件页面是否已在内存中?
    ├─ 是:建立或利用映射,读取驻留页面
    └─ 否:触发必要的文件读取,页面载入后继续执行

缺页也不能直接等同于磁盘 I/O:文件页面可能已经在操作系统缓存中,只是尚未建立当前进程的页表映射。

5. 索引太大,操作系统文件缓存会不会撑爆

文件缓存不是一个把整个索引永久保存起来的 Java 集合。以 Linux 为例,内核使用 page cache 管理文件页面,并在内存压力下回收页面;干净且可回收的文件页可以丢弃,需要时再读取,脏页则需要适当写回。参见 Linux 内核内存管理概念文档。

因此,索引超过 RAM 容量,并不自动意味着应用无法工作。不过,页面回收、再次读取和内存竞争都有成本。性能是否满足要求,还取决于查询访问范围、磁盘速度、并发和 CPU 等条件。

最值得关注的是查询的“工作集”:一段时间内,查询经常访问的那部分索引页面。

下面两个情境仍是概念示例:

情境可能出现的行为
大部分请求反复查询少数热门食品常用页面有机会留在缓存中
请求不断访问大量不同食品、不同字段新页面持续进入,旧页面反复被回收

第二种情况下,应用可能频繁等待磁盘读取,响应延迟增加。即使没有抛出内存异常,也可能出现明显的吞吐量下降。

所以,判断容量不能只看“索引有多少 GB”。还要看查询在多长时间内访问了多少不同页面,以及这些页面是否能在有限内存中保持驻留。

6. 缓存能回收,为什么服务仍然可能 OOM

文件缓存可回收,并不表示整个系统的内存压力消失了。

搜索服务还需要 Java 对象、线程栈、JVM 元空间、原生内存,以及其他运行时资源。写入缓冲、大量并发请求、复杂查询和大正文响应,都可能增加消耗。

尤其在容器中,不能只比较 -Xmx 与宿主机内存。Linux cgroup v2 的内存统计包含匿名内存、文件缓存等;触及限制且回收不足时,仍可能发生 OOM。参见 cgroup v2 内存控制器文档。

例如,容器限制为 4 GB 时,把 Java 最大堆直接设置成 4 GB,并没有为堆外和文件页面等使用留出余量。这里并不存在一个适用于所有 Lucene 项目的固定堆内存比例,应结合部署限制和实际负载测量。

还需要避免另一个误判:映射文件页面可能计入进程的驻留内存统计,同时属于文件支持的页面。不能把所有统计项不加区分地相加,否则会重复计算。

7. 当前项目怎样验证这些结论

项目只有 10 条食品数据,适合验证读写和持久化流程,不适合证明大规模搜索性能。

启动应用并查询香蕉:

./gradlew bootRun

另一个终端执行:

curl 'http://localhost:8080/api/documents/search?q=bananas'

返回结果应包含预设文档 fdc-173944。

停止服务后,在同一索引路径关闭预设初始化并重新启动:

LUCENE_SEED_ENABLED=false ./gradlew bootRun

再次执行上述搜索,如果仍返回原文档,说明读取了已有的磁盘索引,而不是靠初始化器重新导入。这项验证需要保留原来的索引目录。

也可以连续请求同一个查询,观察接口总耗时:

for i in 1 2 3 4 5; do
  curl --silent --output /dev/null \
    --write-out 'total=%{time_total}s\n' \
    'http://localhost:8080/api/documents/search?q=bananas'
done

这些数字只是 HTTP 请求耗时,不等于 Lucene 查询耗时,更不能据此计算文件缓存命中率。首次请求还可能涉及 JIT 编译、类加载和 Web 层初始化。重启应用也不保证清空操作系统文件缓存,因此不能直接把“重启后第一次查询”当作冷缓存实验。

在 Linux 部署环境中,可以结合以下工具观察系统,具体可用性取决于发行版及已安装工具:

free -h
vmstat 1
iostat -xz 1

同时观察应用的 P95/P99 延迟、GC、请求并发和 CPU 使用情况。低空闲内存或高 RSS 单独出现,都不足以证明内存泄漏;磁盘读取增加也要与具体查询负载、合并任务等活动关联分析。

上述命令是读者的验证方法。本文没有执行大索引压测,也没有给出虚构的延迟、缓存命中率或容量上限。

8. 数据量增加后,先优化哪一层

当前代码每次搜索都打开 Reader,并且整个搜索方法使用 synchronized。这使流程容易理解,但同时带来重复打开读取视图和请求排队的开销。

一个后续方向是使用 SearcherManager 复用搜索器。下面是调用结构示意,假设已有 IndexWriter 和 Query,不是当前项目已经应用的替换代码:

SearcherManager manager = new SearcherManager(writer, new SearcherFactory());

IndexSearcher searcher = manager.acquire();
try {
    TopDocs hits = searcher.search(query, 10);
    // 在释放前完成原文读取和响应对象构造。
} finally {
    manager.release(searcher);
}

管理器在应用生命周期内复用,写入后还需要设计刷新策略,例如由后台任务定期调用 maybeRefresh(),并在关闭应用时释放管理器。它负责安全共享和引用管理,不会自行把整个磁盘索引搬进堆,也不会自动安排刷新任务。相关职责见 SearcherManager 官方文档。

如果仍把搜索方法整体放在 synchronized 中,即使引入管理器,请求依然串行。因此提高并发还需要同时设计应用关闭、写入和读取之间的协调。

优化应根据瓶颈选择:

观察到的瓶颈优先检查的方向
请求排队,CPU 和磁盘都不忙应用锁、线程池、并发限制
Java 堆或 GC 压力高分配量、正文大小、返回条数、请求并发
文件读取和存储等待明显工作集、缓存余量、SSD 性能
写入影响查询延迟commit 频率、索引刷新、合并活动
单机资源难以满足目标数据拆分、分片及整体部署架构

SearcherManager 可以减少读取视图管理开销,但无法替代磁盘带宽和内存容量。给 JVM 留出合理余量、限制并发、避免每次返回过大的正文,通常也需要一起考虑。

回到开头的问题:本项目的索引保存到磁盘,可以在重启后复用。查询利用索引结构,并结合操作系统管理的文件页面完成读取。索引大于内存时,主要需要关注工作集、页面回收与磁盘读取带来的延迟,同时检查应用和容器的总体内存限制。这几个层次分清后,才能判断该优化 Java 堆、查询并发,还是存储系统。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐