客户端库是你的 Redis 驱动应用中一个关键但重要的部分。客户端库是你编写的软件与 Redis 之间的“胶水”。它们主要执行以下几项基本任务:
第一点是标准化的——RESP 有规范,所有客户端都必须遵循,否则,什么也做不了。第二点是每个库独有的——这使得 Redis 在你的编程语言中感觉自然;即使是同一语言的客户端库,实现方式也可能不同。这更像是艺术而不是科学。第三点,连接管理,是这样一个技术点却在不同库之间存在异常大的差异的地方。
客户端(你的应用程序)和 Redis 服务器之间的连接是持久的。相比之下,许多其他 API 依赖于一次性连接,即使用一次后就丢弃。如果你使用过 REST 接口,它遵循的就是这种模式。持久连接速度快,因为它无需处理创建和销毁连接的开销。然而,它确实带来了一些挑战:客户端库需要管理连接如何被重用(或不重用)以及共享(或不共享)。关于这种管理的观点以及语言的运行时特性解释了为什么客户端架构在这一点上仍然有相当大的差异。
关于连接管理,有三种基本思路
非托管连接是指将连接的管理推迟到应用程序本身。一个主要例子是 node_redis 库,除了基本的重连逻辑外,它提供的连接管理功能很少。Node.js 世界使用 JavaScript,它本质上是异步且单线程的,因此 Node.js 应用程序的扩展很大程度上是通过运行多个应用程序实例来实现的。
连接池在任何给定时间保持一系列与 Redis 服务器的就绪连接,然后允许应用程序从池中获取一个连接,使用它,并在完成后归还。Java 的 Jedis 使用了这种技术,因为 Java 是多线程的,这使得连接可以在线程之间更合乎逻辑地共享。
最后,我们有多路复用。在多路复用中,你使用多个线程共享一个连接。.NET 生态系统的 StackExchange.Redis 使用了这种模式。这听起来可能适得其反,但让我们更仔细地看看它的工作原理以及它对你的应用程序意味着什么。
从视觉上看,你可以把多路复用想象成编织绳索。许多股线以特定的方式排列,最终形成一根单股线。在多线程运行时环境中,你不是将与 Redis 服务器通信的完全控制权独家交给任何一个线程。相反,你让客户端库接收来自这些线程的通信,并智能地将其合并到一个连接中。然后,当 Redis 服务器返回通信时,你将响应“解缠”回每个单独的线程。
这为客户端带来了一些明显的优势。多路复用可以处理大量独立执行线程的任意创建和销毁,而无需创建和销毁连接(这对你的应用程序和 Redis 来说都很昂贵)。其次,与连接池接口不同,你不必担心从池中获取和归还连接。
此外,还有一些不那么明显的优势。多路复用允许一种隐式的管道化形式。管道化在 Redis 的语境中,意味着向服务器发送命令,而不必等待接收响应。如果你曾去过汽车餐厅的得来速窗口,对着扬声器一口气报完所有点餐,这就是一种管道化。你不需要等待餐厅员工确认每个项目,他们会在最后将整个订单复述一遍。这自然会更快,因为它消除了发送和等待每个项目响应之间的延迟。
管道化 | 非管道化 |
SADD order cheeseburger SADD order milkshake SADD order large-fry SADD order chicken-sandwich SADD order onion-rings SADD order small-sprite 1 1 1 1 1 1 | SADD order cheeseburger (延迟) 1 (延迟) SADD order milkshake (延迟) 1 (延迟) SADD order large-fry (延迟) 1 (延迟) SADD order chicken-sandwich (延迟) 1 (延迟) SADD order onion-rings (延迟) 1 (延迟) SADD order small-sprite (延迟) 1 |
使用多路复用器时,所有命令都会在任何时候都压入同一个连接中,因此无关线程发出的无关 Redis 命令会立即发送到服务器,无需等待连接池可用或等待任何响应返回。而你的应用程序对此一无所知。
就像计算领域的许多事物一样,多路复用并非没有代价。使用单个连接并非总是优势。Redis 中的某些操作故意需要很长时间才能响应:这些统称为客户端阻塞操作。客户端阻塞操作会一直等待响应,直到满足条件,通常是直到向某个结构添加新项或超时发生,以先发生者为准。这些命令包括 BLPOP、BRPOP、BRPOPLPUSH、BZPOPMIN、BZPOPMAX、XREAD…BLOCK 和 XREADGROUP…BLOCK。
如果从多路复用的角度思考,一旦发出其中一个命令,你的应用程序所有线程与 Redis 服务器之间的所有流量都会暂停,直到新数据到达或超时发生。这可不好!因此,StackExchange.Redis 不支持这些命令(并且根据当前文档,永远不会支持它们)。
如果你曾使用过 Redis 的发布/订阅命令,你会注意到 SUBSCRIBE 命令的工作方式有点类似,那么多路复用器是如何管理它的呢?实际上,它会创建一个专门用于订阅的 Redis 连接,然后将收到的所有已发布消息多路复用到相关的线程。
最后,当从 Redis 发送或接收非常大的数据块时,多路复用器的动态与其他客户端不同。想象一下向 Redis 发送一个 500MB 的数据块。Redis 本身是单线程的,将专注于接收这些数据,但你的客户端应用程序在此端完成整个 500MB 之前无法继续向管道添加数据。从 Redis 接收大量数据也是如此。
StackExchange.Redis 是一个不错的客户端,而多路复用对于 Redis 客户端库来说是一种有趣的架构。然而,重要的是要知道你在处理什么:一方面,多路复用解决了一个常见问题(延迟),另一方面,它限制了 Redis 的一些功能。
了解 Redis 生态系统中当前和未来的变体将如何与这种客户端架构交互也很有用。Redis Enterprise 基于零延迟代理进程,该进程在集群端内部进行了一些自动管道化处理,这会削弱多路复用的一些优势。
此外,即将发布的 Redis 6 将对多路复用器模型带来两个新的挑战。在 Redis 6 中,ACL 将控制单个用户可以使用哪些键和命令,因此,如果多路复用连接必须不断切换用户上下文,将适得其反。Redis 6 还引入了多线程 I/O,这意味着客户端单连接与服务器端多线程连接之间的处理差异可能会增大。
另一方面,.NET 生态系统中已有很多写得很好的现有应用程序和库,它们将自动利用 Redis 6 的优化,并且无需修改代码即可继续以极快的速度运行。需要注意的是,StackExchange.Redis 库的作者 Marc Gravell 最近在一条推文中暗示,他正在考虑在新版本中进行一些更改,这可能会使该库的架构不再基于多路复用。