dot 速度的未来正在您的城市举办活动。

加入我们在 Redis 发布活动

永不停歇的服务:让 Redis 模块并发

1. Redis 是单线程的吗?

Redis 从一开始就是单线程的。这是 Redis 应用程序以及最近的 Redis 模块(例如 RediSearch)必须应对的现实。

虽然保持单线程使 Redis 变得简单快捷,但缺点是长时间运行的命令会阻塞整个服务器,直到查询执行完成。大多数 Redis 命令运行速度很快,所以这不是问题,但像 ZUNIONSTORELRANGESINTER 以及臭名昭著的 KEYS 这样的命令可能会阻塞 Redis 几秒甚至几分钟,具体取决于它们处理的数据量。

2. RediSearch 和单线程问题

RediSearch 是一个新的搜索引擎模块,在 Redis 中编写。它利用 Redis 强大的基础架构,通过高效的数据结构,创建了一个快速且 功能丰富、实时搜索引擎

虽然它非常快,并使用高度优化的数据结构和算法,但它面临并发挑战。根据您的数据集大小和搜索查询的基数,它们可能需要从几微秒到数百毫秒,甚至在极端情况下需要几秒钟的时间。发生这种情况时,运行该引擎的整个 Redis 服务器都会被阻塞。

例如,考虑一个全文本查询,它将“hello”和“world”这两个词交叉,每个词都有 100 万个条目,并且 50 万个共同的交集点。要在一毫秒内完成此操作,您必须在一纳秒内扫描、交叉并对每个结果进行排名,这在目前的硬件中是不可能的。同样适用于索引 1000 字的文档。它会完全阻塞 Redis,直到索引完成。

因此,搜索查询可能会与您平均的 Redis O(1) 命令的行为截然不同,因为它们可能会阻塞整个服务器很长时间。当然,您可以并且应该将您的搜索索引拆分成一个集群,并且 RediSearch 的集群版本很快将作为 Redis Enterprise 的一部分提供——但即使我们在集群节点之间分发数据,某些查询也可能会很慢。

3. 进入 Redis GIL

幸运的是,Salvatore Sanfilippo 在 Redis 4.0 的最后阶段和模块 API 发布之前添加了一项革命性的变化:线程安全上下文全局锁

这个想法很简单。虽然 Redis 仍然是单线程的,但模块可以运行多个线程。任何一个线程在需要访问 Redis 数据、对其进行操作并释放时,都可以获取全局锁。

我们仍然无法真正并行查询 Redis。只有一个线程可以获取锁,包括 Redis 主线程,但我们可以确保长时间运行的查询通过不时地释放此锁,从而为其他查询提供适当的运行时间(此限制仅适用于此特定的用例——在其他用例中,例如训练机器学习模型,实际的后台并行处理是可实现且容易的)。

 

4. 使搜索并发

到目前为止,搜索查询的流程很简单:查询到达 Redis 模块中的一个命令处理程序回调,并且它将是 Redis 内部唯一运行的东西。然后它会解析查询,执行查询,花费尽可能长的时间并返回结果。

为了允许并发,我们采用了以下设计

  1. RediSearch 有一个线程池用于运行并发搜索查询。
  2. 当搜索请求到达时,它会到达处理程序,在主线程上被解析,并且请求对象通过队列传递给线程池。
  3. 线程池在其自己的线程中运行查询处理函数。
  4. 该函数锁定 Redis 全局锁,并开始执行查询。
  5. 由于搜索执行基本上是在循环中运行的迭代器,我们只需每隔几次迭代就对经过的时间进行采样。
  6. 如果经过足够的时间,查询处理器会释放全局锁,并立即尝试再次获取它。当锁被释放时,内核会调度另一个线程运行——无论是 Redis 的主线程还是另一个查询线程。
  7. 当锁被再次获取时,我们重新打开我们在释放锁之前持有的所有 Redis 资源,并从上一个状态继续工作。

因此,操作系统的调度程序确保所有查询线程都能获得 CPU 时间来运行。当一个线程运行时,其他线程处于空闲等待状态,但由于每秒会释放大约 5000 次执行,因此它产生了并发的效果。快速查询将在一轮中完成,无需释放执行,而慢速查询将需要多轮才能完成,但将允许其他查询并发运行。

Figure 1: Serial vs. Concurrent Search
在左侧,所有查询都是一个接一个地处理的。在右侧,每个查询都有自己的时间片来运行。请注意,虽然所有查询的总时间保持不变,但查询 3 和 4 完成得快得多。

相同的方法适用于索引。如果一个文档很大,以至于对其进行标记化和索引会阻塞 Redis 很长时间,我们会将其分解成许多更小的迭代,并允许 Redis 做其他事情,而不是阻塞很长时间。事实上,在索引的情况下,有足够的工作可以使用多个核心并行完成,即标记化和规范化文档。对于非常大的文档来说,这尤其有效。

作为旁注,这可以使用一个线程在所有查询执行循环之间切换来实现,但为此所需的代码重构要大得多,并且在合理的负载下,效果将保持相似,因此我们选择将其保留在将来的版本中。

5. 并发的影响

虽然这不是魔术,如果您的所有查询都很慢,它们将仍然很慢,并且这里不会进行真正的并行处理——但在 Redis 的术语中,这是革命性的。考虑一下在繁忙的 Redis 实例中运行KEYS *的旧问题。在单线程操作中,这会导致实例挂起几秒钟,甚至几分钟。现在,可以在模块中实现 KEYS 的并发版本,它几乎不会影响性能。事实上,Salvatore 已经实现了一个!

然而,也存在负面影响:我们在一定程度上牺牲了读写原子性以换取并发性。考虑以下情况:一个线程正在处理一个应该检索文档 A 的查询,然后释放执行上下文;同时,另一个线程删除或更改文档 A。结果——第一个线程运行的查询将无法检索文档,因为它在该线程“休眠”时已经被更改或删除了。

当然,这仅与高更新/删除负载以及相对缓慢且复杂的查询相关。在我们看来,对于大多数用例来说,这是一个值得付出的牺牲,而且通常查询处理速度足够快,以至于发生这种情况的可能性非常低。

但是,如果需要,这很容易克服:如果操作的强原子性很重要,RediSearch 可以以“安全模式”运行,使所有搜索和更新成为原子操作,从而确保每个查询都引用其调用时的索引状态。

要启用安全模式并禁用查询并发,您可以在加载时配置 RediSearch:redis-server --loadmodule redisearch.so SAFEMODE 在命令行中,或者通过在 redis.conf 中添加 loadmodule redisearch.so SAFEMODE,具体取决于您加载模块的方式。

6. 一些数字!

我测试了模块的两个版本——简单的单线程版本和并发多线程版本,在相同的设置下。

基准测试设置

1. 数据集包含大约 1,000,000 条 Reddit 评论。

2. 两个使用 Redis 基准测试的客户端正在运行——首先分别运行,然后并行运行

3. 一个客户端执行一个非常密集的查询——“i”,它有 200,000 个结果,并且有 5 个并发连接。

4. 一个客户端执行一个非常轻量的查询——“Obama”,它大约有 500 个结果——并且有 10 个并发连接(我们假设在正常情况下,轻量级查询将多于重量级查询)。

5. 两个客户端和服务器都运行在我的个人笔记本电脑上——MacBook Pro,配备 Intel 四核 i7 @ 2.2Ghz。

结果

在左侧,当在单线程中运行时,吞吐量受较慢查询的限制。在右侧,在并发模式下,相同的负载使快速查询运行速度提高了 60 倍!
虽然我们可以看到,当在没有争用的并发模式下运行时,轻量级查询明显更慢,但它们仍然非常快。但在争用情况下,我们看到,轻量级查询在并发模式下运行速度快得多,因为它们不像在单线程模式下那样被慢速查询阻塞。在单线程模式下,我们只有与最慢的查询一样快。

7. 结束语

这个小小的全局锁功能和线程安全上下文,可能是模块 API 提供的最强大的功能。我们只触及了并发问题,但它还支持后台任务,对不影响 Redis 键空间的数据进行真正的并行处理,等等。

对于 RediSearch 来说,它让 RediSearch 从一个小巧的引擎,变成了可以处理海量数据和高负载的强大引擎。结合即将到来的 RediSearch 分布式版本(也利用了线程 API,但这将是另一篇文章的内容),它将使 RediSearch 成为一个非常强大的搜索和索引引擎。