Redis 从一开始就是单线程的。这是 Redis 应用程序以及最近的 Redis 模块(例如 RediSearch)必须应对的现实。
虽然保持单线程使 Redis 变得简单快捷,但缺点是长时间运行的命令会阻塞整个服务器,直到查询执行完成。大多数 Redis 命令运行速度很快,所以这不是问题,但像 ZUNIONSTORE、LRANGE、SINTER 以及臭名昭著的 KEYS 这样的命令可能会阻塞 Redis 几秒甚至几分钟,具体取决于它们处理的数据量。
RediSearch 是一个新的搜索引擎模块,在 Redis 中编写。它利用 Redis 强大的基础架构,通过高效的数据结构,创建了一个快速且 功能丰富、实时搜索引擎。
虽然它非常快,并使用高度优化的数据结构和算法,但它面临并发挑战。根据您的数据集大小和搜索查询的基数,它们可能需要从几微秒到数百毫秒,甚至在极端情况下需要几秒钟的时间。发生这种情况时,运行该引擎的整个 Redis 服务器都会被阻塞。
例如,考虑一个全文本查询,它将“hello”和“world”这两个词交叉,每个词都有 100 万个条目,并且 50 万个共同的交集点。要在一毫秒内完成此操作,您必须在一纳秒内扫描、交叉并对每个结果进行排名,这在目前的硬件中是不可能的。同样适用于索引 1000 字的文档。它会完全阻塞 Redis,直到索引完成。
因此,搜索查询可能会与您平均的 Redis O(1) 命令的行为截然不同,因为它们可能会阻塞整个服务器很长时间。当然,您可以并且应该将您的搜索索引拆分成一个集群,并且 RediSearch 的集群版本很快将作为 Redis Enterprise 的一部分提供——但即使我们在集群节点之间分发数据,某些查询也可能会很慢。
幸运的是,Salvatore Sanfilippo 在 Redis 4.0 的最后阶段和模块 API 发布之前添加了一项革命性的变化:线程安全上下文和全局锁。
这个想法很简单。虽然 Redis 仍然是单线程的,但模块可以运行多个线程。任何一个线程在需要访问 Redis 数据、对其进行操作并释放时,都可以获取全局锁。
我们仍然无法真正并行查询 Redis。只有一个线程可以获取锁,包括 Redis 主线程,但我们可以确保长时间运行的查询通过不时地释放此锁,从而为其他查询提供适当的运行时间(此限制仅适用于此特定的用例——在其他用例中,例如训练机器学习模型,实际的后台并行处理是可实现且容易的)。
到目前为止,搜索查询的流程很简单:查询到达 Redis 模块中的一个命令处理程序回调,并且它将是 Redis 内部唯一运行的东西。然后它会解析查询,执行查询,花费尽可能长的时间并返回结果。
为了允许并发,我们采用了以下设计
因此,操作系统的调度程序确保所有查询线程都能获得 CPU 时间来运行。当一个线程运行时,其他线程处于空闲等待状态,但由于每秒会释放大约 5000 次执行,因此它产生了并发的效果。快速查询将在一轮中完成,无需释放执行,而慢速查询将需要多轮才能完成,但将允许其他查询并发运行。
相同的方法适用于索引。如果一个文档很大,以至于对其进行标记化和索引会阻塞 Redis 很长时间,我们会将其分解成许多更小的迭代,并允许 Redis 做其他事情,而不是阻塞很长时间。事实上,在索引的情况下,有足够的工作可以使用多个核心并行完成,即标记化和规范化文档。对于非常大的文档来说,这尤其有效。
作为旁注,这可以使用一个线程在所有查询执行循环之间切换来实现,但为此所需的代码重构要大得多,并且在合理的负载下,效果将保持相似,因此我们选择将其保留在将来的版本中。
虽然这不是魔术,如果您的所有查询都很慢,它们将仍然很慢,并且这里不会进行真正的并行处理——但在 Redis 的术语中,这是革命性的。考虑一下在繁忙的 Redis 实例中运行KEYS *的旧问题。在单线程操作中,这会导致实例挂起几秒钟,甚至几分钟。现在,可以在模块中实现 KEYS 的并发版本,它几乎不会影响性能。事实上,Salvatore 已经实现了一个!
然而,也存在负面影响:我们在一定程度上牺牲了读写原子性以换取并发性。考虑以下情况:一个线程正在处理一个应该检索文档 A 的查询,然后释放执行上下文;同时,另一个线程删除或更改文档 A。结果——第一个线程运行的查询将无法检索文档,因为它在该线程“休眠”时已经被更改或删除了。
当然,这仅与高更新/删除负载以及相对缓慢且复杂的查询相关。在我们看来,对于大多数用例来说,这是一个值得付出的牺牲,而且通常查询处理速度足够快,以至于发生这种情况的可能性非常低。
但是,如果需要,这很容易克服:如果操作的强原子性很重要,RediSearch 可以以“安全模式”运行,使所有搜索和更新成为原子操作,从而确保每个查询都引用其调用时的索引状态。
要启用安全模式并禁用查询并发,您可以在加载时配置 RediSearch:redis-server --loadmodule redisearch.so SAFEMODE
在命令行中,或者通过在 redis.conf 中添加 loadmodule redisearch.so SAFEMODE
,具体取决于您加载模块的方式。
我测试了模块的两个版本——简单的单线程版本和并发多线程版本,在相同的设置下。
1. 数据集包含大约 1,000,000 条 Reddit 评论。
2. 两个使用 Redis 基准测试的客户端正在运行——首先分别运行,然后并行运行
3. 一个客户端执行一个非常密集的查询——“i”,它有 200,000 个结果,并且有 5 个并发连接。
4. 一个客户端执行一个非常轻量的查询——“Obama”,它大约有 500 个结果——并且有 10 个并发连接(我们假设在正常情况下,轻量级查询将多于重量级查询)。
5. 两个客户端和服务器都运行在我的个人笔记本电脑上——MacBook Pro,配备 Intel 四核 i7 @ 2.2Ghz。
这个小小的全局锁功能和线程安全上下文,可能是模块 API 提供的最强大的功能。我们只触及了并发问题,但它还支持后台任务,对不影响 Redis 键空间的数据进行真正的并行处理,等等。
对于 RediSearch 来说,它让 RediSearch 从一个小巧的引擎,变成了可以处理海量数据和高负载的强大引擎。结合即将到来的 RediSearch 分布式版本(也利用了线程 API,但这将是另一篇文章的内容),它将使 RediSearch 成为一个非常强大的搜索和索引引擎。