优化 Lucene 搜索速度的一点建议

Lucene 是一个全文检索的框架,具体干嘛的自己 Google,在公司项目中使用到了 Lucene 记录用户的操作日志来做用户的行为分析(好像 Lucene 本来不是干这个用的。。),开始的时候索引的数量少,搜索速度很快,随着日志的数量级增大(目前已到千万级),索引文件的大小已攀升到5个多G,搜索的速度已经明显变慢,后来在 apache 官网找到了相关文章介绍优化 Lecene 搜索速度的文章,记录一下此次优化的过程

没有代码谈什么优化,先上一段示例代码,传入一个完整的用户列表,根据用户在时间段内是否登录过 APP 来切分列表

public Map<String, List> cutListByLoginApp(List<Map> userList, String startDate, String endDate) {
    Map<String, List> tMap = new HashMap<>();
    try {
        // 登陆过APP
        List loginAppUserList = new ArrayList<>();
        // 未登录过APP
        List unLoginAppuserList = new ArrayList<>();

        IndexReader reader = DirectoryReader.open(FSDirectory.open(Paths.get(App.getIndexPath())));
        IndexSearcher searcher = new IndexSearcher(reader);

        // 限制时间范围
        Term startTime = new Term("dt", DataAnalyzerUtil.dateToTime(startDate));
        Term endTime = new Term("dt", DataAnalyzerUtil.dateToTime(endDate));
        TermRangeQuery timeQuery = new TermRangeQuery("dt", startTime.bytes(), endTime.bytes(), true, false);

        QueryParser userIdQueryParser = new QueryParser("userid", new StandardAnalyzer());

        for (Map userInfo : userList) {
            BooleanQuery.Builder builder = new BooleanQuery.Builder();
            builder.add(timeQuery, Occur.MUST);
            Query userIdQuery = userIdQueryParser.parse(userInfo.get("userId").toString());
            builder.add(userIdQuery, Occur.MUST);
            // 搜索
            TopDocs results = searcher.search(builder.build(), searcher.getIndexReader().maxDoc());
            if (results.totalHits != 0) {
                // 有访问记录
                loginAppUserList.add(userInfo);
            } else {
                // 没有访问记录
                unLoginAppuserList.add(userInfo);
            }
        }
        tMap.put("loginAppList", loginAppUserList);
        tMap.put("unLoginAppList", unLoginAppuserList);
    catch (Exception e) {
        e.printStackTrace();
    }
    return tMap;
}

官方文章中一些建议

文章链接: https://wiki.apache.org/lucene-java/ImproveSearchingSpeed

  • 确定你真的需要去优化 Lucene 搜索的速度

大意就是你的应用可能比较复杂,请确定程序慢是 Lucene 慢导致的,此文就是为了优化 Lucene 的搜索速度,这一点略过

  • 确定你使用的是最新版本的 Lucene

我用的版本是 5.5.3,截止至此文发布的最新版本是 6.3.0,为了项目稳定,能不升级就不升级,这一点我没有去实践,所以不发表意见

  • 使用本地的文件系统

意思是将索引文件放在本地,不要放在远程服务器上,如果一定要放在远程服务器,那就将远程的文件系统挂载设为只读模式,可能会提升一点搜索速度,我的索引文件就在本地,还没有做分布式,这一步也没有进行尝试,欢迎尝试过的朋友和大家分享一下心得

  • 用更好的硬件,更快的系统

硬件不是我想换,想换就能换。。。系统不是我想装,想装就能装。。。

  • 优化你的操作系统

。。。。。。以后再说吧

  • 把 IndexReader 设为只读模式

据官方文档说这在多线程共享同一个 IndexReader 的应用中效果很明显,因为这样会消除线程之间相互争抢某些资源,但是我还没有找到在哪里可以设置,以后找到了再来更新文章。。。

  • 在非 Windows 平台,使用 NIOFSDirectory 代替 FSDirectory

终于有一点可以进行优化了,公司项目部署在 Linux 环境下,果断将代码 IndexReader reader = DirectoryReader.open(FSDirectory.open(Paths.get(App.getIndexPath()))); 改为 IndexReader reader = DirectoryReader.open(NIOFSDirectory.open(Paths.get(App.getIndexPath()))); ,实际测试效果不是很明显,但是效果都是累积出来的,按照文档改。。

  • 增加的你的内存,提高 JVM 的堆大小

索引文件越大,搜索时会使用更多的内存,如果你没有足够的内存或者 JVM 的堆大小不够,那么搜索的速度就会很慢,这一点同样目前无法实践

  • 使用单例的 IndexSearcher

在你的程序中使用一个单例的 IndexSearcher 对象在线程之间共享,这一点没有去做,原因是我的索引是实时更新的,如果只使用一个单例对象的话,无法实时的获取最新的索引

  • 测试性能的时候,忽略第一个 Query

第一个 Query 在搜索的时候要初始化缓存,由尤其是要以一个字段进行排序的时候

  • 只有在必要的时候才重新打开 IndexSearcher

只有当要搜索最新的索引的时候再重新打开 IndexSearcher

  • 减少合并因子(mergeFactor)

更小的 mergeFactor 意味着更少的 segments,并且会使搜索速度更快,但是它会降低建立索引的速度,所以你应该尝试不同的值,在索引和搜索之间找到一个平衡点,现在重建索引基本不可能,所以没有并没有实践

  • 限制存储字段和 term vectors 的使用

Lucene 在建立索引的时候,每个字段都有两个属性,一个是 index(索引),另一个是 stored(存储),将不需要存储的字段的 stored属性设为 flase,也是大大提高搜索的速度,同样也是无法重建索引,没有实践

  • 使用 FieldSelector

在检索的时候,FieldSelector 会去选择加载哪些字段以及如何去加载他们

  • 如果非必要,不要去迭代结果集

迭代所有的结果集会很慢,有两个原因:一是当你需要100个以上的结果的时候,search() 方法会在返回的 Hits 对象中再次执行搜索,解决方法是使用 HitController 去替代;二是搜索的结果集会分布在上,这会增加 IO 开销,除非它很小,可以被加载到内存中,如果你不需要一个完整的 document,你可以从 FieldCache 中快速的访问一个字段

  • 使用模糊查询的时候使用长度最小的前缀

模糊查询会让 CPU 执行很密集的字符串比较方法,更短的前缀会提高模糊搜索的速度

  • 考虑使用过滤器

使用过滤器比用一条查询语句可以更高效的限制检索的结果,尤其是在索引数量很大的时候,查询和过滤器的区别是,查询对分数有影响,但是过滤器不会

  • 找到程序的瓶颈在哪里

复杂的查询分析和结果集的再处理是 Lucene 搜索的隐藏瓶颈所在,可以使用 VisualVM 等工具来找到问题的所在

开始优化

看完了官方的建议之后,发现有用的建议其实并不太多(我水平太低,能用上的不多。。),但是有一点可以着重去做一下优化,就是在非必要的时候,不要去迭代结果集,在示例代码中可以看到这样一句

TopDocs results = searcher.search(builder.build(), searcher.getIndexReader().maxDoc());

这句开始执行搜索操作,返回一个结果集,通过 results.totalHits 的值可以判断用户是否有访问记录,但是可以看到 searcher.getIndexReader().maxDoc() 这个值是随着索引文件的增大而不断变大的,之前取这个值是因为我要遍历所有的结果集,但是现在的需求是我只要知道用户有过访问记录就可以,而不需要知道他具体访问了哪些页面,按照文档的建议,使用 HitController 替代,于是改为以下代码

TotalHitCountCollector totalHitCountCollector = new TotalHitCountCollector();
searcher.search(builder.build(), totalHitCountCollector);
if (totalHitCountCollector.getTotalHits() != 0) {
    // 有访问记录
    loginAppUserList.add(object);
} else {
    // 没有访问记录
    unLoginAppuserList.add(object);
}

经过测试,速度有了很大幅度的提升,基本不超过 1ms,而之前没优化之前查询一次要两秒左右,对两万多个用户进行遍历的话,时间简直不敢想象。。。这次优化先到这里结束,之后如果再次优化会更新此文章,由于笔者菜鸟一枚,文中难免会有错误之处,欢迎大家读阅斧正。

Last updated