在查阅lettuce官网文档时(lettuce.io/core/releas…
Lettuce is thread-safe by design which is sufficient for most cases. All Redis user operations are executed single-threaded. Using multiple connections does not impact the performance of an application in a positive way. The use of blocking operations usually goes hand in hand with worker threads that get their dedicated connection. The use of Redis Transactions is the typical use case for dynamic connection pooling as the number of threads requiring a dedicated connection tends to be dynamic. That said, the requirement for dynamic connection pooling is limited. Connection pooling always comes with a cost of complexity and maintenance.
大致意思lettuce是线程安全的,大多数场景下,使用单连接即可满足业务业务需求,多连接并不会给性能带来性能上的提升。但在某些特殊场景,比如事物操作,使用连接池会是一个比较好的方案。
看到这,起初笔者是表示怀疑的,配置连接池能够充分发挥计算机的多核优势,怎么就不能提高应用性能呢?带着这个疑问,笔者随即在本地环境做了一些测试,并跟读了源码。结果发现,官网还是权威的,最终得出的结论是:
在执行一些简单指令的情况下,lettuce不配置连接池,使用单连接是最佳方案。连接池配置太小,性能会急剧下降,连接池配置太大,性能和单连接情况下大致相当,但会造成资源浪费。
4.1 验证流程
gradle 配置:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.12.0'
compileOnly 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.apache.commons:commons-pool2'
}
lettuce连接池配置:
redis:
timeout: 10000 # 连接超时时间(毫秒)
host: srd-redis-cluster-01-qcsh-dev.nioint.com
port: 6379
password: 密码
lettuce:
pool: #连接池配置
maxActive: 16 # 连接池最大连接数(使用负值表示没有限制) 默认 8
maxWait: 100000 # 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1
max-idle: 16 # 连接池中的最大空闲连接 默认 8
min-idle: 16 # 连接池中的最大空闲连接 默认 8
timeBetweenEvictionRuns: 30000 # 每timeBetweenEvictionRunsMillis毫秒秒清理连接池中空闲的连接 (默认-1,不开启)
testOnBorrow: true # 在borrow一个redis实例时,是否提前进行vaidate操作;如果为true,则得到的redis实例均是可用的 (默认false)
testWhileIdle: true # 如果为true,表示有一个idle object evitor线程对idle object进行扫描,如果validate失败,此object会被从pool中drop掉(默认false)
注:另需要配置LettuceConnectionFactory参数“shareNativeConnection”为false,该参数默认为true,默认启用共享连接,此时所有redis操作共用一个redis物理连接,redis连接池配置失效。
核心代码:
package org.example;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.*;
@RestController
@RequestMapping("/lettuce/test")
@RequiredArgsConstructor
public class LettuceTest {
@Autowired
private RedisTemplate redisTemplate;
private static final Executor executor = new ThreadPoolExecutor(3000, 3000, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingDeque<>());
@PostMapping("/workStation/parallelTest")
public void parallelTest(@RequestParam int count, @RequestParam String key) throws InterruptedException {
long startTime = System.currentTimeMillis();
CountDownLatch latch = new CountDownLatch(count);
for (int i = 0; i < count; i++) {
executor.execute(() -> {
try {
redisTemplate.opsForValue().set(key, "test");
Object o = redisTemplate.opsForValue().get(key);
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
});
}
latch.await();
System.out.println("系统耗费时间:" + (System.currentTimeMillis() - startTime));
}
}
4.2 环境配置
4.3 验证结果
| 连接数量 | 最大请求并发 | 请求数 | 耗时No.1/ ms | 耗时No.3/ ms | 耗时No.3/ ms |
|---|---|---|---|---|---|
| 1(不启用连接池) | 3000 | 5000 | 2073 | 781 | 895 |
| 16 | 3000 | 5000 | 19070 | 18843 | 20227 |
| 100 | 3000 | 5000 | 4684 | 3495 | 3608 |
| 500 | 3000 | 5000 | 2285 | 1372 | 1026 |
| 1000 | 3000 | 5000 | 2655 | 1256 | 802 |
| 3000 | 3000 | 5000 | 3467 | 1586 | 870 |
通过以上数据可知,可以验证前文所述结论。
在执行一些简单指令的情况下,lettuce不配置连接池,使用单连接是最佳方案。连接池配置太小,性能会急剧下降,连接池配置太大,性能和单连接情况下大致相当,但会造成资源浪费。
4.4 源码解析
在分析lettuce连接池源码之前,我们先来思考一下这个问题。redis是单进程单线程模型架构,当我们多个连接同时向redis发送请求时,redis只能按请求的先后顺序逐一处理,那么这样和我们直接用一个连接向redis发送请求有什么区别呢,又是否能够提升整体性能?
假设有5条redis执行需要发送到redis服务器执行,每条指令redis处理时长为100ms,同时忽略所有网络耗时和操作系统读写耗时。即当客户端发送redis命令到redis服务器,redis能够立即收到并处理,处理之后的结果能够立即被客户端感知。在以上假设下,我们考虑不采用连接池和采用连接池之下,redis的执行效果:
1、单连接(单线程阻塞执行):此时1、2、3、4、5条redis命令会按照顺序依次被redis服务器执行,则全部处理完需要500ms。
2、采用连接池(每个连接池对应一个线程):此时5条指令同时到达redis服务器,但由于redis的命令处理逻辑是单线程,只能依次处理这5条指令,所以最终处理完也需要500ms。
在上述假设下,配置redis连接池并不会提高性能。但在现实中,数据传输和操作系统调度都是需要时间的。假设每条指令在数据传输需要额外耗费20ms,我们再来看下在不采用连接池和采用连接池之下,redis的执行效果:
1、单连接(单线程阻塞执行):此时1、2、3、4、5条redis命令会按照顺序依次被redis服务器执行,则全部处理完需要(100+20)* 5=600ms。
2、采用连接池(每个连接池对应一个线程):此时5条指令同时到达redis服务器(耗时10ms),redis服务器依次处理这5条指令(耗时500ms),每条指令的处理结果返回客户端(耗时10ms),所以最终处理完也需要520ms。
通过上面分析,在实际中,在并发高的情况下,采用redis连接池的方式是可以有效的提高整体效率的。但是在4.1的验证结果中,却与理论相悖,属实疑惑。
但这这一疑惑,我去扒了下lettuce使用连接池的相关代码,在一番苦苦探索后,终于是发现关键所在。lettuce中,底层redis连接通过netty管理,连接池则是采用org.apache.commons:commons-pool2规范实现,在发送命令请求,获取redis连接时,会调用GenericObjectPool::borrowObject方法从对象池(即redis连接池)中阻塞获取一个对象,等对象使用完毕再归还到对象池中。导致以上理论和实际相悖的原因也就是在这了。
lettuce底层通过netty创建redis连接,在BootStrap初始化的时候会创建一个NioEventLoopGroup,NioEventLoopGroup中NioEventLoop的数量为cpu核心数乘以2,每个NioEventLoop相当于一个线程,通过Selector管理多个redis连接。
此处,我们假设lettuce连接池的最小连接数和最大连接数均为4,系统核心数为2,那么NioEventLoop的数量也为4,每个NioEventLoop中的Selector管理一个redis连接。同时,在GenericObjectPool中ObjectDeque的idleObjects最大为4个。命令请求处理流程如下:
- 客户端接收外部命令请求,从idleObjects中获取一个空闲的Object,如果获取不到,则阻塞等待,直到有可用的空闲Object。
- 第一个步骤中获取到的Object为一个redis connection对象,该对象持有netty创建的连接到redis的channel,当利用该Object发起命令请求的时候,会往channel里write数据,netty在处理时,会判断该线程是否是NioEventLoop线程,由于这是外部线程,netty会将其包装为一个任务,放入NioEventLoop中的队列中,等待NioEventLoop线程执行,最后将数据传输到redis。
- redsi接收到数据后,进行命令解析,执行命令,最终将结果通过channel传输到客户端。
- 客户端收到redis处理结果后,将数据返回,并最终在finally里释放Object对象到idleObjects中,唤醒其它在等待的线程。
在lettuce中,GenericObjectPool这个对象池将外部的并行请求,变成了并行度为4的阻塞请求,如外部有5个并发请求过来,那么其中四个可以正常被包装成任务放入NioEventLoop中的队列中,但是最后一个必须要阻塞等到前面4个请求处理结束。
当不采用lettuce连接池时,外部的5个并发请求可以同时添加到NioEventLoop中的队列中,同时得益于netty内部种采用的异步处理方式,相较于采用连接池的方式,更加高效。
通过上面的分析,可以与最开始的实验相互验证,最终得出的结论就是采用lettuce连接池真没必要。但另外一方面我也在想,是否有其它连接池的方式,可以使lettuce效率更高呢?比如通过下面这种方式,在每个NioEventLoop中开启一个redis连接,这样是否可以发挥出计算机多核心的优势呢?不知道目前lettuce是否支持这样的配置,希望大家一起探讨。
暂时无法在飞书文档外展示此内容