在 4.0 版本中,Redis 公开了一个新的模块 API,允许程序员通过新的命令和数据类型扩展数据库的功能。RediSearch 使用它来允许对存储在 Redis 中的数据进行全文搜索。数据作为哈希(一种核心 Redis 数据类型)保存在 Redis 中,每个数据记录称为文档。文档的数据也索引在 RediSearch 的内部数据结构中。可以告诉 RediSearch 不要将数据存储在哈希中,在这种情况下,文档将仅索引在 RediSearch 的内部数据结构中(此模式称为 NOSAVE)。
搜索引擎必须处理的一个主要挑战是删除和更新文档。为了理解这个问题,我们首先需要了解 RediSearch 如何索引数据。例如,我们采用以下文档
Doc1
名称:“test”
正文:“this is an example”
RediSearch 将创建以下 倒排索引
术语 | DocId |
Test | Doc1 |
This | Doc1 |
is | Doc1 |
an | Doc1 |
example | Doc1 |
当用户搜索术语“example”时,RediSearch 会立即在倒排索引中找到该术语,并返回 Doc1 作为查询结果。
现在假设我们需要通过扫描 Doc1 中的所有术语并从每个术语中删除其记录,从而从索引中删除 Doc1。问题是,当在 NOSAVE 模式下运行时,我们没有 Doc1 中的数据,也不知道需要清理哪些术语。可以跟踪文档中的每个术语,但这会显着增加整体内存占用。
唯一的另一个解决方案是创建一个“垃圾回收”(GC)机制,该机制在后台扫描整个索引并从倒排索引中删除已删除的文档。 RediSearch 之前使用过这种方法。但是,随着产品的不断发展,我们发现它可能非常慢,因为它需要扫描整个索引。此外,由于 Redis 是单线程的,因此在扫描期间,我们必须获取全局(单个)锁,这使得无法对整个数据集执行查询或更新。
为了解决这些问题,我们提出了一种新的 GC 方法,该方法利用了 Linux fork 进程的优势。我们的目标是在尽可能短的时间内获取全局锁,即,不在扫描期间获取它,而仅在实际释放内存期间获取它。每次 GC 启动时,它都会创建一个 fork 进程,该进程在后台扫描索引,而主进程继续执行查询和更新。 fork 进程通知主进程需要删除的内容,然后主进程在很短的时间内获取锁(这足以从倒排索引中删除相关文档)。
我们将这种新的 GC 实现与我们之前的方法进行了比较,方法是将 500 万条记录插入数据库,然后连续更新所有记录。
注意:此比较是在使用以下规格的简单笔记本电脑配置上完成的:MacBook Pro(Retina,15 英寸,2015 年中),OS 10.11.6,CPU 2.2 GHz Intel Core i7,16 GB 内存,频率为 1600 MHz DDR3。我们可以在更强大的服务器配置上运行它,并且可能会获得更令人印象深刻的结果,但我们认为这些规格足以证明我们新方法的优势。
旧 GC | 新 GC | |
GC 收集的总字节数 | 908 KB | 80,727 KB |
插入阶段时间(秒) | 484.657 | 447.099 |
更新/删除阶段时间(秒) | 627.632 | 615.326 |
插入阶段后的内存使用量 | 1.73 Gb | 1.73 Gb |
更新/删除阶段后的内存使用量 | 1.84 Gb | 1.74 Gb |
如您所见,我们新的 GC 方法的主要优点是,RediSearch 现在可以收集 80,727 KB,而使用旧的 GC 机制则只能收集 908 KB,这接近 100 倍的改进。旧的 GC 随机选择倒排索引并进行清理的概率方法花费了更多的时间。我们的新 GC 方法在每次迭代中都在整个索引上运行,因此它可以在我们完成更新时清理所有垃圾。此外,新方法在扫描索引时不需要获取全局锁。
新的 GC 机制现在在我们最新的 RediSearch 版本 1.4.1 中可用。默认情况下它是关闭的,但可以通过在加载模块时在命令行参数中发送“GC_POLICY FORK”来激活它。请记住,这是一个实验性的 GC 版本,因此我们不建议立即在生产中使用它。但是,如果您的用例需要大量更新,欢迎您尝试这种新的 GC 并向我们提供反馈。我们打算在将来的版本中将其设置为 RediSearch 的默认配置。
我们计划随着时间的推移扩展我们的 GC 机制,以进一步提高 GC 性能。我们正在考虑的一种方法是使用启发式机制来指示哪些术语更可能包含垃圾,并首先清理这些术语。