这是系列博客文章的第4部分 最初发表在 Clément Renault 的博客上。从 第1部分、 第2部分和 第3部分开始。这篇博客文章假设您已经阅读了第1部分和第2部分。
在这篇博客文章中,我们将讨论如何在 Arroy 中实现增量索引。增量意味着在树中插入或删除项目时,我们可以只更新所需的部分,而不是重建所有内容。
在 Meilisearch,这至关重要,因为我们的用户通常会定期发送更新,并且他们的内容会不断变化。从头开始重建所有内容成本非常高,并且不会带来良好的体验;这就是为什么我们已经在最新版本中花费了大量时间,使所有 数据结构都支持增量索引,而 arroy 作为其中之一,我们*必须*也实现它!
理论
以下图表展示了将文档添加到现有树中和从现有树中删除文档时应发生的高级视图。
提醒一下,arroy 是一个基于 LMDB 的向量存储,LMDB 是一个嵌入式事务性内存映射键值存储。它将您的向量存储在由三种节点组成的树中
- 分裂节点 - 存储关于项目 ID 如何分布在其左右的信息。
- 后代节点 - 存储多个项目 ID。
- 项目 - 单个向量。
对于以下示例,我们将考虑后代节点不能包含**超过**两个项目,并且 ID 越接近,项目在空间中也越接近。例如,项目 1
接近项目 2
,但远离项目 10
。
在这里,我们尝试插入项目 2
和 7
,并删除项目 3
和 11
。
按照我们在搜索中所做的,我们将要插入的项目在左右分割节点之间进行分割。但是已删除的项目不再存在于数据库中!我们没有它们相关的向量,因此我们需要遍历所有叶子来查找并删除它们。
幸运的是,由于我们使用了咆哮位图,这个列表不会占用太多内存,并且由于我们从不更新它,我们可以与每个线程共享它而无需复制🎉
请注意,要插入的项目是如何在左右分割节点之间平衡的。在下一步中,我们将遵循相同的过程,并将在分割节点上分割这两个要插入的项目列表。
正是在这一步,所有精彩的事情发生了。从左到右
- 第一个后代节点移除了它的项目
3
,并添加了项目2
。 - 第二个后代节点变得过大,必须被新的分裂节点取代。
- 项目
8
成为一个包含项目7
和8
的后代。 - 在最后一个后代节点中删除项目
10
后,我们将其替换为指向项目的直接链接,以减少树中的节点数量(并通过减少查找次数来提高搜索时间)。
现在您已经了解了我们增量索引过程的所有步骤,我们仍然要对发生的事情做一些说明
- 在前三个步骤中,我们必须读取所有树节点,这默认等于数据库中的项目总数。
- 在最后一步中,我们必须写入四个新的树节点。以前我们会重写整个树。写入次数(这是数据库中最慢的操作)被减少到最低限度。
- 该过程在单个树上工作,这意味着我们仍然可以对每棵树的计算进行多线程处理。
- ID不再是顺序的,这与并行写入树中描述的ID生成策略不兼容。
我们是如何修复 ID 生成的?
在索引过程开始时,我们需要收集所有现有的树 ID。
我们需要生成新节点的信息是
- 用于知道何时停止构建树的树节点总数。
- 我们想要重用的已使用 ID 中的“空洞”。这种碎片化是在我们编辑树时产生的。
- 树中存在的最高 ID,用于生成下一个新 ID。
“简单”的想法是为树节点总数设置一个计数器。我们可以递增这个计数器来生成新的 ID。最困难的部分是选择一个在线程之间共享且没有大型互斥锁的可用 ID。
我花了不少时间才找到解决最后一点的办法;我实现了一个非常复杂的解决方案 (150行代码),但最终我花了一个小时从头完全重写,得到了一个更简单、更高效的方案。我们将回顾在找到最终解决方案之前我们经历过的所有想法。
共享可用ID的迭代器
最直接和最简单的想法是创建一个并共享一个遍历所有可用 ID 的迭代器。
let mut available_ids = available_ids.into_iter(); roots.into_par_iter().for_each(|root| { // stuff let next_id = available_ids.next(); // stuff });
如果您熟悉 Rust,您会立即注意到 for_each
无法捕获 available_ids
的可变引用,因为我们使用了 Rayon 的 into_par_iter
方法,该方法以多线程方式执行迭代器上的后续方法。这意味着我们不能调用 .next()
。
通过使用互斥锁(mutual exclusion 的缩写),我们可以使其编译成功
let mut available_ids = Mutex::new(available_ids.into_iter()); roots.into_par_iter().for_each(|root| { // stuff let next_id = available_ids.lock().unwrap().next(); // stuff });
这是安全的,并且会产生正确的结果,但由于所有线程都必须在锁上相互等待,因此扩展性不佳。
让每个线程拥有自己的 ID 列表
当您需要移除锁时,一个常见的解决方案是提前分割工作,以便每个线程都能充分发挥其能力,而无需知道其他线程正在发生什么。这就是我最初实现的解决方案。这个想法是将咆哮位图分解成与需要更新的树数量相同的小咆哮位图。
以下函数可以将一个咆哮位图分割成 n
个大小相等的小位图
pub fn split_ids_in(available: RoaringBitmap, n: usize) -> Vec<RoaringBitmap> { // Define the number of elements per sub-bitmap let chunk_size = available.len() / n as u64; let mut ret = Vec::new(); let mut iter = available.into_iter(); for _ in 0..(n - 1) { // Create a roaring bitmap of `chunk_size` elements from the iterator without consuming it let available: RoaringBitmap = iter.by_ref().take(chunk_size as usize).collect(); ret.push(available); } // the last element is going to contain everything remaining ret.extend(iter); ret }
有了这个函数,使用 Rayon 并行迭代器上的 zip
方法为每个要更新的树使用一个位图就变得微不足道了
let available_ids = split_ids_in(available_ids, roots.len()); roots.into_par_iter().zip(available_ids).for_each(|(root, available_ids)| { let mut available_ids = available_ids.into_iter(); // stuff let next_id = available_ids.next(); // Here, we can use the available_ids without any locks or inter-thread synchronization // stuff });
这在更新现有根树时运行良好。但之后,我们可能需要从头开始创建新树,我们无法提前知道需要创建多少新树。这是一个问题,因为没有这些信息,我们就不知道需要创建多少个子位图。
此时,我无法找到一个简单的修复方法来解决这个问题,但我假设我们很少创建大量新树,并且所有新树都会使用大量 ID。这意味着,将所有 ID 都提供给**第一棵**新树可能**会奏效**(在生产环境中不进行监控很难百分百确定)。
最终解决方案
之前的解决方案既复杂又未能完美运行。当我浏览 Roaring Bitmap 的文档时,我看到了 select
方法,并立即明白了如何使最初的方法奏效。
此方法允许您以高效的方式按索引获取位图中的值。
例如,在位图中具有以下值:13, 14, 15, 98, 99, 100
select(0)
返回13
。select(3)
返回98
。select(5)
返回100
。select(6)
返回None
。
考虑到这一点,如果我们以**只读**方式与所有线程共享位图,并以原子数字共享位图中的索引,那么多个线程可以同时获取可用 ID,而无需锁同步。
一旦 select
方法返回 None
,这意味着我们可以停止从位图中获取 ID,而是简单地使用旧方法从头开始生成新的 ID。
即使它速度超快,调用 select
方法仍然需要一些时间,所以一旦线程从该方法返回 None
,我们将更新一个原子布尔值,告诉我们位图中是否还有值需要查看。如果没有,我们可以跳过调用 select
并直接生成一个新的 ID。
考虑到这一点,我们在第2部分中展示的炫酷的 ConcurrentNodeIds
结构变得稍微复杂了一些,但它仍然让我们可以在没有任何锁的情况下公平地生成 ID!
/// A concurrent ID generator that will never return the same ID twice. pub struct ConcurrentNodeIds { /// The current tree node ID we should use if no other IDs are available. current: AtomicU32, /// The total number of tree node IDs used. used: AtomicU64, /// A list of IDs to exhaust before picking IDs from `current`. available: RoaringBitmap, /// The current Nth ID to select in the bitmap. select_in_bitmap: AtomicU32, /// Tells if you should look in the roaring bitmap or if all the IDs are already exhausted. look_into_bitmap: AtomicBool, } impl ConcurrentNodeIds { /// Creates an ID generator returning unique IDs, avoiding the specified used IDs. pub fn new(used: RoaringBitmap) -> ConcurrentNodeIds { let last_id = used.max().map_or(0, |id| id + 1); let used_ids = used.len(); let available = RoaringBitmap::from_sorted_iter(0..last_id).unwrap() - used; ConcurrentNodeIds { current: AtomicU32::new(last_id), used: AtomicU64::new(used_ids), select_in_bitmap: AtomicU32::new(0), look_into_bitmap: AtomicBool::new(!available.is_empty()), available, } } /// Returns a new unique ID and increases the count of IDs used. pub fn next(&self) -> Result<u32> { if self.look_into_bitmap.load(Ordering::Relaxed) { let current = self.select_in_bitmap.fetch_add(1, Ordering::Relaxed); match self.available.select(current) { Some(id) => Ok(id), None => { self.look_into_bitmap.store(false, Ordering::Relaxed); Ok(self.current.fetch_add(1, Ordering::Relaxed)) } } } else { Ok(self.current.fetch_add(1, Ordering::Relaxed)) } } }
实际表现
我还没有进行大量基准测试(尽管我很想,可能会稍后更新此帖子),但根据我对几十万个项目的基准测试,结果如下
- 平均而言,在三个批次的项目索引后,我们获得了超过10倍的速度提升。
- 之后我们观察到的那些小波动,使我们从约700毫秒变为约3秒,是由于随着项目数量的增加而创建新树造成的。
- 我预计这个基准测试代表了最坏情况
- 我所有的项目 ID 都是随机生成的,这意味着很少有重复/更新,但总是会有新的插入和更多的写入。
- 我的向量也是随机生成的,采用均匀分布,这意味着不应该出现我们在实际数据集观察到的聚类现象。
这项功能目前正在数百万文档的生产数据上使用,我们看到在不到一分钟的时间内,在200万个项目的数据库中添加了约9000个项目。
Meilisearch 是一个开源搜索引擎,使开发人员能够构建最先进的体验,同时享受简单、直观的开发体验。
要了解更多关于 Meilisearch 的信息,您可以加入 Discord 社区,或者订阅 新闻通讯。您可以通过查看 路线图 并参与 产品讨论 来了解更多关于该产品的信息。