redis客户端利用pipeline机制来实现全双工通讯 类似多路复用,在存在并发请求时能减少大量通讯延迟,但pipeline不支持blocking相关的操作(如BLPOP)
配置 RedisTemplate
RedisTemplate自动根据操作类型,选择是在单连接上进行多路复用(setex/get),还是申请新的连接/等待空闲连接(blpop)
@Configuration
public class RedisTemplateConfig {
@Bean
public LettuceConnectionFactory lettuceConnectionFactory(@Value("${redis.cluster}") String address,
@Value("${redis.password}") String password) {
// 配置连接池管理
var poolConfig = new GenericObjectPoolConfig<StatefulRedisClusterConnection<String, String>>();
poolConfig.setMaxTotal(20);
// 池内空闲连接总数大于这个设置,会立即关闭归还的连接
poolConfig.setMaxIdle(20);
poolConfig.setMinIdle(2);
poolConfig.setTestWhileIdle(true);
// commons-pool-evictor线程定期执行Eviction任务:
// 1、直接关闭长期空闲连接
// 2、验证短期空闲连接的可用性
// 3、空闲连接总数小于minIdle,会自动新建连接
poolConfig.setTimeBetweenEvictionRuns(Duration.ofMillis(30000));
// 连接空闲时长超过这个值后,会在下次执行Eviction任务时被直接关闭
poolConfig.setMinEvictableIdleDuration(Duration.ofMillis(60000));
poolConfig.setNumTestsPerEvictionRun(-1);
// 配置客户端行为
var clientConfig = LettucePoolingClientConfiguration.builder()
.clientOptions(ClusterClientOptions.builder()
.autoReconnect(true)
.pingBeforeActivateConnection(true)
.socketOptions(SocketOptions.builder()
.connectTimeout(Duration.ofSeconds(3))
// tcp_user_timeout控制多久收不到ack则认为连接已中断
.tcpUserTimeout(TcpUserTimeoutOptions.builder()
.tcpUserTimeout(Duration.ofSeconds(6))
.build())
.build())
.timeoutOptions(TimeoutOptions.builder().fixedTimeout(Duration.ofSeconds(3)).build())
.topologyRefreshOptions(ClusterTopologyRefreshOptions.builder()
.enableAdaptiveRefreshTrigger(RefreshTrigger.MOVED_REDIRECT,
RefreshTrigger.PERSISTENT_RECONNECTS)
.adaptiveRefreshTriggersTimeout(Duration.ofSeconds(10))
.build())
.build())
.poolConfig(poolConfig)
.build();
// 配置集群连接信息
var redisConfig = new RedisClusterConfiguration();
redisConfig.setMaxRedirects(5);
redisConfig.setPassword(password);
String[] serverArray = address.split(",|,|;|;");// 获取服务器数组
Set<RedisNode> nodes = new HashSet<>();
for (String ipPort : serverArray) {
nodes.add(RedisNode.fromString(ipPort));
}
redisConfig.setClusterNodes(nodes);
return new LettuceConnectionFactory(redisConfig, clientConfig);
}
@Bean
public StringRedisTemplate redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
return new StringRedisTemplate(lettuceConnectionFactory);
}
}
验证blocking操作在独立连接上进行
实现blocking LPOP操作的方法在opsForList()里;
并发100压测期间查看客户端本地的tcp连接,可以看到和每个redis节点都建立了大量连接;
证明RedisTemplate没有选择让不同并发线程共用同一个StatefulConnection
@Test
public void blpop() throws Exception {
long start = System.currentTimeMillis();
AtomicLong err = new AtomicLong();
int maxThreads = 100;
Semaphore semaphore = new Semaphore(maxThreads);
for (int i = 0; i < maxThreads; i++) {
final int threadnum = i + 1;
semaphore.acquire(1);
new Thread(new Runnable() {
@Override
public void run() {
try {
// 这里是blocking LPOP
template.opsForList().leftPop("test" + threadnum, 10, TimeUnit.SECONDS);
} catch (Exception ex) {
log.error("leftPop", ex);
err.addAndGet(1L);
} finally {
semaphore.release(1);
}
}
}).start();
}
semaphore.acquire(maxThreads);
long end = System.currentTimeMillis();
log.info("耗时{}ms, 错误{}", end - start, err.get());
}
在这个场景下,redis template为每个阻塞式命令分配专用的tcp连接;
Lettuce的StatefulConnection是线程安全的,如果多线程共用一个StatefulConnection来执行blocking LPOP操作,那么会串行执行,严重影响执行效率(可通过操作Lettuce原生接口进行验证)
验证单tcp连接的多路复用
发起100个线程,每个线程连续进行100w次setex + get操作;
执行期间查看客户端本地的tcp连接,可以看到只建立了一个和redis节点的tcp连接;
Lettuce负责接收来自多个线程的请求,并通过一个tcp连接上的一个session,串行地发送pipeline格式指令给服务端;
服务端收到pipeline格式指令后,会依次地串行地返回执行结果,然后由Lettuce负责分发给各个线程;
利用pipeline机制实现的全双工通讯,效果类似多路复用,但不是真的多路复用,因为是严格按请求顺序来接收每个请求的响应数据包;
如果pipeline上存在很多已发送但未收到结果的指令,则会引发redis服务端指令缓冲区过大,所以需要限制客户端程序的最大并发线程数量;
和使用半双工通讯的Jedis进行对比,需要配置Jedis连接池maxTotal和maxIdle连接数都到100,才能有相同通讯效率
@Slf4j
@EnableAutoConfiguration(exclude = { RedisAutoConfiguration.class })
@SpringBootTest(classes = { RedisTemplateConfig.class })
public class RedisTemplateTest {
@Autowired
StringRedisTemplate template;
@Test
public void getSet() throws Exception {
long start = System.currentTimeMillis();
int maxThreads = 100;
long maxMessagess = 1000000;
AtomicLong err = new AtomicLong();
AtomicLong num = new AtomicLong();
Semaphore semaphore = new Semaphore(maxThreads);
for (int i = 0; i < maxThreads; i++) {
final int threadnum = i + 1;
semaphore.acquire(1);
new Thread(new Runnable() {
@Override
public void run() {
int j = 0;
try {
for (; j < maxMessagess; j++) {
String key = "thread" + threadnum + "test" + j;
String value = "test" + j;
template.opsForValue().set(key, value, 1, TimeUnit.SECONDS);
assertEquals(value, template.opsForValue().get(key));
}
} finally {
num.addAndGet(j);
semaphore.release(1);
}
}
}).start();
}
semaphore.acquire(maxThreads);
long end = System.currentTimeMillis();
double rate = 1000d * num.get() / (end - start);
log.info("每秒发送并读取消息{}; 耗时{}ms, 累计发送{}", rate, end - start, num.get());
}
RedisTemplate屏蔽了哪些并发命令可以共用连接的决策难点,所以不要自行使用Lettuce客户端获取连接或从连接池申请连接。
必须配置tcpUserTimeout,因为单个命令超时,不会导致lettuce舍弃pipeline连接;lettuce也没有在pipeline指令流里插入探活指令;操作系统默认舍弃异常中断的连接(另一端实际不再响应ack),需要等待15分钟