简介
什么是lettuce
Spring Boot自2.0版本开始默认使用Lettuce作为Redis的客户端(注1)。Lettuce客户端基于Netty的NIO框架实现,对于大多数的Redis操作,只需要维持单一的连接即可高效支持业务端的并发请求 —— 这点与Jedis的连接池模式有很大不同。同时,Lettuce支持的特性更加全面,且其性能表现并不逊于,甚至优于Jedis
官方是这样介绍的:Lettuce 是一个可扩展的线程安全 Redis 客户端,提供同步、 异步和反应式API。如果多个线程避免阻塞和事务性操作(例如BLPOP和 MULTI/ ) ,则它们可以共享一个连接EXEC。优秀的netty NIO框架可以有效地管理多个连接。其中包括对高级 Redis 功能(例如 Sentinel、Cluster 和 Redis 数据模型)的支持。
netty概述
作为Lettuce的底层框架,本节我们首先对Netty NIO进行简单介绍。《Netty In Action》一书中提到:「从高层次的角度看,Netty致力于解决(网络编程领域)技术和体系结构两大我们关心的问题。首先,其构建于Java NIO之上的异步及事件驱动的实现,保证了应用程序在高负载下的性能最大化和可伸缩性;其次,Netty运用一系列设计模式,将程序逻辑与网络层进行解耦,从而简化了用户的开发过程,并在最大程度上保证代码的可测性、模块化水平及可重用性。」
上图展示了Netty NIO的核心逻辑。NIO通常被理解为non-blocking I/O的缩写,表示非阻塞I/O操作。图中Channel表示一个连接通道,用于承载连接管理及读写操作;EventLoop则是事件处理的核心抽象。一个EventLoop可以服务于多个Channel,但它只会与单一线程绑定。EventLoop中所有I/O事件和用户任务的处理都在该线程上进行;其中除了选择器Selector的事件监听动作外,对连接通道的读写操作均以非阻塞的方式进行 —— 这是NIO与BIO(blocking I/O,即阻塞式I/O)的重要区别,也是NIO模式性能优异的原因。
原理
Lettuce实现原理与Redis管道模式
虽然一个Netty的EventLoop可以服务于多个套接字连接,但是Lettuce仅凭单一的Redis连接即可支持业务端的大部分并发请求 —— 即Lettuce是线程安全的。这有赖于以下几个因素的共同作用:
- Netty的单个EventLoop仅与单一线程绑定,业务端的并发请求均会被放入EventLoop的任务队列中,最终被该线程顺序处理。同时,Lettuce自身也会维护一个队列,当其通过EventLoop向Redis发送指令时,成功发送的指令会被放入该队列;当收到服务端的响应时,Lettuce又会以FIFO的方式从队列的头部取出对应的指令,进行后续处理。
- Redis服务端本身也是基于NIO模型,使用单一线程处理客户端请求。虽然Redis能同时维持成百上千个客户端连接,但是在某一时刻,某个客户端连接的请求均是被顺序处理及响应的。
- Redis客户端与服务端通过TCP协议连接,而TCP协议本身会保证数据传输的顺序性。
如此,Lettuce在保证请求处理顺序的基础上,天然地使用了管道模式(pipelining)与Redis交互 —— 在多个业务线程并发请求的情况下,客户端不必等待服务端对当前请求的响应,即可在同一个连接上发出下一个请求。这在加速了Redis请求处理的同时,也高效地利用了TCP连接的全双工特性(full-duplex)。而与之相对的,在没有显式指定使用管道模式的情况下,Jedis只能在处理完某个Redis连接上当前请求的响应后,才能继续使用该连接发起下一个请求 —— Lettuce和Jedis之间的这种差异,在某种程度上与HTTP/2和HTTP/1之间的差异类似。HTTP/2的实现原理读者可参阅《Introduction to HTTP/2》,本文不作赘述
什么是管道模式
Redis官网文档中对管道模式作了详细的论述,大意是客户端与服务端通过网络连接,无论两者间的网络延迟是高还是低,数据包从客户端到服务端(请求),再从服务端返回客户端(响应)的过程总是会消耗一定的时间。我们将这段时间称为RTT(Round Trip Time)。假设在延迟非常高的网络条件下,RTT达到250ms,此时就算服务端拥有每秒处理100k请求的能力,(基于单一连接)整体的QPS也仅仅只有4。而如果借助管道模式,客户端则可以一次性发出大量(如1k)请求,并随后一次性接收大量服务端的响应,从而显著提高请求处理速度。如下图所示:
lettuce的管道模式
Redis 是一个使用客户端-服务器模型和所谓的请求/响应协议的 TCP 服务器。这意味着通常请求是通过以下步骤完成的:
- 客户端向服务器发送查询并从套接字读取(通常以阻塞方式)以获取服务器响应。
- 服务器处理命令并将响应发送回客户端。
可以实现请求/响应服务器,以便即使客户端尚未读取旧响应,它也能够处理新请求。这样就可以向服务器发送多个命令,而无需等待回复,并最终一步读取回复。
使用同步 API,一般来说,程序流程会被阻塞,直到响应完成。底层连接正忙于发送请求和接收其响应。在这种情况下,阻塞仅适用于当前线程的角度,而不适用于全局的角度
。
要理解为什么使用同步 API 不会在全局级别上阻塞,我们需要理解这意味着什么。Lettuce 是一个非阻塞异步客户端。它提供了一个同步 API 来实现基于每个线程的阻塞行为,以创建等待(同步)命令响应。阻塞本身不会影响其他线程
。Lettuce 被设计为以管道方式运行。多个线程可以共享一个连接
。虽然一个线程可以处理一个命令,但另一个线程可以发送新命令。第一个请求一返回,第一个Thread的程序流程就会继续,而第二个请求则由Redis处理并在某个时间点返回。
Lettuce 构建在 Netty 之上,将读取与写入分离并提供线程安全连接。结果是,读取和写入可以由不同的线程处理,并且命令的写入和读取彼此独立但按顺序进行。您可以在Wiki中找到有关消息排序的更多详细信息,以了解单线程和多线程安排中的命令排序规则。在写入、处理命令以及读取其响应之前,传输和命令执行层不会阻塞处理。Lettuce 在调用命令时发送命令。
异步 API就是一个很好的例子。在命令写入 netty 管道后,异步 API上的每次调用都会返回一个(响应句柄)。Future写入管道并不意味着命令被写入底层传输。可以写入多个命令而无需等待响应。对 API 的调用(同步、异步以及从4.0反应式 API 开始)可以由多个线程执行。
Lettuce与Jedis的性能比较
上图展示了Jedis与Redis的交互方式。乍看上去,我们似乎不容易区分在业务线程高并发请求的场景下,Lettuce与Jedis的运作模式在性能表现上孰优孰劣 —— 前者通过在单一共享连接上,以管道模式的方式与Redis交互;后者则通过其维护的连接池,对Redis进行并发操作
。我们首先从Redis服务端的角度进行分析。从Redis服务端的角度看,在客户端请求发送速率相同的情况下,管道的交互方式是具备一定优势的。这里引用一段Redis官网文档《Using pipelining to speedup Redis queries》中的论述:
Pipelining is not just a way to reduce the latency cost associated with the round trip time, it actually greatly improves the number of operations you can perform per second in a given Redis server. This is the result of the fact that, without using pipelining, serving each command is very cheap from the point of view of accessing the data structures and producing the reply, but it is very costly from the point of view of doing the socket I/O. This involves calling the read() and write() syscall, that means going from user land to kernel land. The context switch is a huge speed penalty.
上文大意是:管道模式的作用不仅仅在于其减少了网络RTT带来的延迟影响
,同时,它也显著提升了Redis服务器每秒可执行的指令操作量。这是因为,虽然从访问内存数据并生成响应的角度看,Redis处理某条指令操作的成本是很低的,但是从执行套接字I/O操作的角度看,如果我们不使用管道模式,(当需要逐个处理大量客户端请求时)对Redis来说(相对于内存操作)成本是很高的。套接字I/O操作涉及read和write这两个系统调用,这意味着Redis需要(频繁地)从用户态切换到内核态,而由此导致的上下文切换会非常耗时。
上下文切换
根据《深入理解计算机系统》中的介绍,上下文切换(context switch)发生在内核对系统中不同进程或线程的调度(scheduling)过程中。就进程而言,内核会为每个进程维护一个上下文(context),用于在需要的时候将被中断的进程恢复执行。进程上下文包括多种不同的对象,如各类寄存器、程序计数器、用户栈、内核栈,以及各类内核数据结构(如地址空间页表、文件表)等。当程序在用户态执行系统调用(如前面提到的套接字I/O操作)时,为了避免阻塞,内核会通过上下文切换机制,中断当前进程,并调度执行一个其他的(先前被中断的)进程。这个过程包括:1、保存当前进程的上下文,2、恢复那个先前被中断的进程的被保存的上下文,3、执行这个被恢复的进程。此外,即使系统调用没有阻塞,内核也可以选择执行上下文切换,而不是(在系统调用完成后)将控制返回给调用进程。
可以看到,进程的上下文切换操作是较为复杂的。而对于运行在同一个进程中的线程来说,由于它们共享该进程的上下文,且线程自身的上下文比进程的上下文小不少,因此(同一个进程中的)线程的上下文切换相比进程的上下文切换要快。然而即便如此,由于同样涉及到程序在用户态和内核态之间的来回转换,以及CPU数据的刷写,高强度的线程上下文切换带来的性能损耗也是不可忽视的。这也是为什么很多框架和编程语言会尽可能避免这一点。比如Netty的EventLoop遵循Java NIO的模式,仅与单个线程绑定;JDK 6中默认开启自旋锁,以尽可能减少线程切换的开销;Go语言更是使用goroutine替代线程,以提高程序并发性能。
言归正传,虽然Redis服务端本身使用单线程、NIO模式处理客户端请求,相比传统的一个线程服务一个客户端连接的BIO方式,已经在系统上下文切换和内存管理上达成了不小的优化,但是在高并发请求场景下,服务端的性能仍存在提升空间。根据官网文档中的论述,在Redis的管道模式下,单次read系统调用便可读取到许多指令,且单次write系统调用也能回写许多响应
—— 相比一次read或write系统调用仅处理一个客户端请求而言,这进一步降低了服务端处理请求时的上下文切换开销。Redis的每秒请求处理数,随着管道的加长(即管道中指令数量的增加)会有接近线性的提升,并最终可达到非管道模式下处理性能的约10倍水平。
测试
使用JMH(Java Microbenchmark Harness)框架,模拟业务高并发请求场景,基于localhost本地Redis服务,在多核处理器上对Jedis和Lettuce进行性能测试(由于条件所限,客户端与服务端在同一台机器上运行,不过这对测试数据的参考价值影响较小)。我们使用200个并发线程,分别对Jedis连接池模式、Lettuce单连接模式、Lettuce连接池模式,以及Lettuce多连接模式(后文会对该模式作进一步阐释)进行测试。相关benchmarking代码详见附录,测试结果取多次均值,如下图所示:
上图纵轴表示我们所测试的各类客户端使用模式,横轴表示其对应的QPS性能数据
首先我们可以看到,上文详细讨论的Lettuce单连接模式,虽然在与Redis交互时没有使用多核处理器的并行能力,但是借助其管道特性,仅凭单一共享连接,也展现出了不错的性能水平。Jedis在连接池连接数为50时,表现出了其最优性能水平,QPS达到约90k,超越Lettuce单连接模式;而当其连接数增至200,数量等同于测试所用并发业务线程数时,性能出现急剧下降,跌至排行末尾
我们在命令行通过top和ps命令观察Jedis在不同连接数下测试时的CPU使用情况,可以发现,当连接数为50时,CPU各项指标处于一个比较均衡的水平;而当连接数为200时,Jedis对CPU的使用急剧上升,同时,其中约90%以上的CPU时间消耗在了内核态。仔细分析可以发现,由于我们测试所用的并发线程数是200,当Jedis连接池连接数也为200时,相当于在同一时刻,每个线程都可以持有一个连接,与Redis进行交互。这在某种程度上类似于服务端为每个客户端请求分配一个单独的线程进行处理的BIO模式。在这种模式下,随着并发线程量上升到一定程度,应用的性能便会因为需要频繁地转入内核态进行线程上下文切换而大幅下降
lettuce是否需要开启连接池模式?
从测试数据来看,Lettuce在连接池模式下的整体性能表现处于偏低的水平。究其原因,首先我们可以看到的是,在连接池模式下,Lettuce连接是线程封闭(thread confinement)的 —— 即业务线程从连接池中获得Lettuce连接后,通过该连接进行对Redis的读写操作,并在操作完成后再将连接返回给连接池;而在此期间,其他线程是无法获取到该连接的。这一点其实与Jedis连接池的原理相同。但两者不同之处在于,Jedis连接不是线程安全的,而Lettuce连接本身就是线程安全的(对此我们在上文中已经做了详细的分析)。因此,对Lettuce来说,在大多数情况下,连接池的线程封闭机制是不必要的。连接池的使用,反而会导致连接无法被多个线程共享,使其无法以更高效的管道模式与Redis交互
最后,让我们看看位居benchmarking性能榜首的Lettuce多连接模式。多连接模式与连接池模式虽然都使用了多个连接,但两者的区别在于,在多连接模式下,Lettuce连接不是线程封闭的,而是可以同时被多个业务线程使用(Lettuce连接是线程安全的)
。如下图所示,与Jedis或Lettuce的连接池模式相比,多连接模式发挥了能够以管道模式与Redis交互的优势;与Lettuce单连接模式相比,多连接模式又充分利用了多核处理器的并行操作能力。在我们的测试中,当将连接数设置为处理器核数(8个)时,Lettuce多连接模式可以相对均衡地同时发挥管道模式与并行操作这两个特性,因此展现出了最佳的性能水平。不过,该模式尚未被Lettuce集成,希望其后续版本能进行支持。
调优
1.去掉连接池配置:Lettuce 在设计上是线程安全的,这对于大多数情况来说已经足够了。所有Redis用户操作都是单线程执行的。使用多个连接不会对应用程序的性能产生积极的影响。阻塞操作的使用通常与获取其专用连接的工作线程齐头并进。Redis 事务的使用是动态连接池的典型用例,因为需要专用连接的线程数量往往是动态的。也就是说,动态连接池的需求是有限的。连接池总是伴随着复杂性和维护成本。
2.集群模式下开启定期刷新集群拓扑:如自适应刷新不开启,Redis集群变更时将会导致连接异常
// 集群拓扑刷新
ClusterTopologyRefreshOptions topologyRefreshOptions = ClusterTopologyRefreshOptions.builder()
.enablePeriodicRefresh(Duration.ofSeconds(30))
.enableAllAdaptiveRefreshTriggers()
.build();
ClusterClientOptions clusterClientOptions = ClusterClientOptions.builder()
//redis命令超时时间,超时后才会使用新的拓扑信息重新建立连接
.timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(10)))
.topologyRefreshOptions(topologyRefreshOptions)
.build();
LettuceClientConfiguration clientConfiguration = LettuceClientConfiguration.builder()
.clientResources(clientResources)
.clientOptions(clusterClientOptions)
.build();
3.关闭验证集群节点成员资格:
- Lettuce 会在本地维护一份cluster nodes返回的信息作为路由表。
validateClusterNodeMembership 是 Lettuce 一个客户端 option,会检查某命令访问的地址,是否在第维护的路由表中,此参数默认值为true,即开启检查 - 当 Lettuce 连接 Redis 集群且没有配置 topologyRefreshOptions 时,意味着路由表变化之后,第1步中的路由表不会更新。
- 但是路由变化时返回的MOVED xxx xxx告诉客户端去访问 xxx 新地址,但是因为此地址不在路由表中,第2步的检测就会报错。
参考
https://github.com/lettuce-io/lettuce-core/wiki/Pipelining-and-command-flushing
https://baijiahao.baidu.com/s?id=1748466935749639220&wfr=spider&for=pc