SpringBootRedis使用踩坑记录一、使用方式

一、使用方式

使用SpringBoot封装的StringRedisTemplete中的API,使用Redis的基本类型String来存储数据。

注意:如果使用RedisTemplate,在自动注入时需要给定泛型,否则执行API不报错,但是库中没有数据,即命令不生效,如下图所示。

此外,使用RedisTemplate时需要注意序列化的问题,RedisTemplate默认使用JDK序列化方式,直接使用会导致存储的数据在第三方软件中查看时显示为二进制格式。

二、问题场景

2.1 所有redis操作都报超时异常

2.1.1 配置与依赖

spring:
  redis:
  host: XXX # Redis 服务器地址
  port: XXX # Redis 服务器连接端口
  password: XXX # Redis 服务器连接密码(默认为空)
  max-active: 1024 # 连接池最大连接数(使用负值表示没有限制)
  max-wait: 10000 # 连接池最大阻塞等待时间(使用负值表示没有限制)
  max-idle: 1000 # 连接池中的最大空闲连接
  min-idle: 200 # 连接池中的最小空闲连接
  timeout: 10000 #请求超时时间
复制代码
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.3.1.RELEASE</version>
</dependency>
复制代码

2.1.2 问题情形

上线后第二天,发现查询Redis时报错,重试多次或重启服务才恢复正常,一段时间后又报错。错误信息为『Caused by: io.lettuce.core.RedisCommandTimeoutException: Command timed out after 10 second(s)』

2.1.3 原因

image (2).png
红框中的@Import注解,按先后顺序注入,Lettuce在Jedis之前,因此默认使用Lettuce

排查发现原因是springboot版本升级到2.X 默认使用Lettuce客户端管理redis,其中自适应拓扑刷新与定时拓扑刷新是默认关闭,导致一段时间未操作redis会自动断开连接,再次操作redis会发生连接池超时的异常。

redis服务器配置中有timeout这一属性,意为当客户端闲置多长秒后关闭连接,如果指定为 0 ,表示关闭该功能。查看沙箱redis库的超时时间设置为3600s=1h,与公司DB同事确认线上超时时间也为3600s。

Lettuce有断线重连的机制,但是断开连接之后并不是立即重连,而是根据一个延时重连的策略来延迟执行重连任务,因此在调用方在重试几次后会出现成功的情况。

测试环节未出现连接超时的问题,因为沙箱环境连接的是测试库,DB同事认为是测试和线上redis的版本不同,驱动不同导致沙箱环境未出现连接超时的情况;上线后测试未出现超时的问题,因为测试时间距离服务启动时间较短,没有超过自动断开连接的时间。

2.1.4 解决方案

1、开启Lettuce客户端的定时刷新配置,保持”心跳”。 缺点:新增代码,不熟悉,容易出现新的问题。

2、redis更换为Jedis客户端,Jedis自动会保持物理连接。 缺点:高并发情况下性能没有Lettuce效果好。

由于是线上问题,方案2操作起来只需要修改pom依赖和配置文件,花费时间最少,因此选择临时方案2。

2.1.5 修改内容

image (1).png

依赖中排除掉lettuce,以及增加jedis依赖。

2.1.6 补充

后续排查问题时发现有些项目配置redis时选择使用Jedis客户端,虽然如上面一样修改了依赖内容,但是配置文件中的格式还是和左边相同(没有jedis.pool字段),由于版本迭代,这种写法配置的连接池并不生效,使用的是Jedis默认的连接池配置。

配置:

spring:
  redis:
  host: XXX # Redis 服务器地址
  port: XXX # Redis 服务器连接端口
  password: XXX # Redis 服务器连接密码(默认为空)
  max-active: 1024 # 连接池最大连接数(使用负值表示没有限制)
  max-wait: 10000 # 连接池最大阻塞等待时间(使用负值表示没有限制)
  max-idle: 1000 # 连接池中的最大空闲连接
  min-idle: 200 # 连接池中的最小空闲连接
  timeout: 10000 #请求超时时间
复制代码

以下是使用该配置调试时的相关截图。

因为使用的是Jedis,因此项目启动初始化ConnectionFactory时直接打到JedisConnectionFactory的构造方法。因为只是在application.yml文件中配置了redis,没有重写Configuration类,因此这里显示clientConfig的类实际为DefaultJedisClientConfigConfiguration。

查看DefaultJedisClientConfigConfiguration的属性,usePooling=false表示没有配置连接池,说明上面的连接池写法是错误的。虽然这里显示没有配置连接池,但是可以看到poolConfig还是有值的,这里实际传入的是GenericObjectPoolConfig,所属包为Jedis中引入的commons.pool2。

因此,如果引入了Jedis,即使没有配置连接池或配置错误,Jedis也会创建1个最大连接数和最大空闲连接数都为8的默认连接池。前提是没有排掉Jedis依赖中的commons.pool2,之所以强调这点,是因为lettuce中默认没有commons.pool2包。(实际上如果真的排除掉的话会构建不成功)

spring:
    redis:
        host: XXX # Redis 服务器地址
        port: XXX # Redis 服务器连接端口
        password: XXX # Redis 服务器连接密码(默认为空)
        jedis:
            pool:
                max-active: 1024 # 连接池最大连接数(使用负值表示没有限制)
                max-wait: 10000 # 连接池最大阻塞等待时间
                max-idle: 200 # 连接池中的最大空闲连接
                min-idle: 0 # 连接池中的最小空闲连接
                timeout: 10000 #请求超时时间
复制代码

如果将配置文件格式修改正确,可以看到连接池的配置被正常传入到DefaultJedisClientConfigConfiguration中。

关于实际连接数,可以使用 lsof -i :XXX(redis端口)命令来查看服务中的redis连接数。

image (2).png

2.2 redis查询耗时有突刺

2.2.1 配置

如2.1.1章节中配置。

2.2.2 问题情形

查看我们的服务监控平台,看到服务耗时参差不齐,TP99到了100ms,表示服务不太稳定质量不高,业务场景对性能要求较高,因此需要优化服务质量。

2.2.3 原因

Jedis底层是同步阻塞IO——BIO,读写操作都会占用一个连接,QPS过高会出现耗时变高的情况,我们的服务QPS在100左右,Jedis性能较差。

2.2.3 解决方案

Lettuce底层使用Netty进行连接,可以做到多路复用异步非阻塞(NIO)执行redis命令,适用于连接数较多且连接较短的场景,物料服务插入操作很少,查询操作很多,因此可以使用Lettuce替换Jedis,防止出现问题一的连接redis超时的报错,需要开启Lettuce的拓扑刷新。

关于同步、异步与阻塞、非阻塞,我之前有做过方便理解的笔记:

    场景为你给书店老板打电话问是否有某本书。

同步:老板说不要挂电话,我看看,找书的过程中同时通话,找完告诉你结果。

异步:老板说我先找找看,待会给你打电话,挂断电话,老板找完书后给你打来了电话。

阻塞:你问完老板之后,就坐在那一直等着老板回复结果,其他的什么也不干。

非阻塞:你问完老板之后,跑去忙别的事,但是会时不时的确认老板有没有找到书。
复制代码

可以组合为三种IO模型BIO--同步阻塞,NIO--同步非阻塞(也有种说法是多通道服用),AIO--异步非阻塞。详细区别与介绍可以参考链接:
常见IO模型

2.2.4 修改内容

恢复spring-redis的依赖,增加连接池commons.pool2依赖。

将Jedis改为Lettuce,并开启拓扑刷新,保持"心跳"。

image (3).png

经过压测之后,效果立竿见影,耗时突刺明显减少,同时间段中的耗时趋于平稳,TP99在10ms以内。

2.2.4 补充

从上面的修改内容可以看到,Lettuce如果想要使用连接池,需要增加commons.pool2依赖,这是因为Lettuce是基于NIO模型来管理redis连接的,默认情况下连接池是没有用的,这点可以从源码中看出。

image (1).png

当没有连接池依赖时,创建LettuceConnectionFactory时,Configuration类中的关于连接池的类都显示没有引入,注解也提示了需要引入commons.pool2包。如果项目中只是普通的redis命令,如set、get等,不使用事务或BLPOP(Redis Blpop 命令移出并获取列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止)操作,项目中没连接多个redis库的话,往往不使用Lettuce连接池也可以支持(实际上,这种场景下,使用连接池反而性能更低),这时需要修改配置文件,去掉其中的lettuce.pool,如:

spring:
    redis:
        host: XXX # Redis 服务器地址
        port: XXX # Redis 服务器连接端口
        password: XXX # Redis 服务器连接密码(默认为空)
        timeout: 10000 #请求超时时间
        lettuce:
            cluster:
                refresh:
                    adaptive: true
                    #官方建议60秒自动刷新一次
                    period: 60s
复制代码

如果配置文件中包含lettuce.pool字段,且依赖中没有commons.pool2包,服务启动时会报错,因为getPoolConfig(Pool properties)方法的调用依赖于配置文件中有没有lettuce.pool字段。

image (2).png

image (1).png

当不使用Lettuce连接池时,使用的是SharedConnection,从名字可以看出它是复用的。在终端中使用top命令查看进程,选出java进程PID如13015,再用jmap -histo:live 13015 |more |grep LettuceConnection 命令查看进程中当前存活的对象数,多QPS调用查询、插入接口时,发现始终只有一个SharedConnection对象,并且也只有一个redis连接。

而如果开启事务的话,再次多QPS执行redis命令时,可以发现出现了多个LettuceConnection对象,实际的redis也有多个。

image (3).png

image (4).png

2.3 只有插入操作报超时异常

2.3.1 配置

如1.2章节『修改内容』中配置与依赖。

由于业务需求变动,导致redis的value由JsonObject改为JsonArray,为了兼容老数据,这里没有修改redis的类型,还是使用String,没有改为hash。物料服务会出现同一个key多个创意插入的情况,因为插入流程为先get--再set,因此会出现线程不安全的情况。为了数据一致性,决定使用watch乐观锁与redis事务来处理并发情况,同一时间只允许一个线程操作同一key的value,保证前面的物料内容不会被后续的操作覆盖。

由于没有重写RedisConfiguration类,所以没有全部开启redis事务,只在插入、更新接口中开启了事务,这一操作让插入、删除接口没有报错,逃过一劫。

try{

//开启事务

stringRedisTemplate.setEnableTransactionSupport(true);

//乐观锁

stringRedisTemplate.watch(keyList);

//查询

stringRedisTemplate.opsForValue().get();

//事务开始标签

stringRedisTemplate.multi();

//插入操作

stringRedisTemplate.opsForValue().set();

//事务提交

resultList = stringRedisTemplate.exec();

}catch(Exception e){

XXX

}finally {

//释放

stringRedisTemplate.unwatch();

}
复制代码

2.3.2 问题情形

出现了问题一中相同的报错,但不是必现,并且只有插入时偶现,同时间段的查询、删除都没有报错。错误日志都指向RedisTemplate.watch() 或 unwatch()方法。

2.3.3 原因

排查发现报错的命令都为watch和unwatch,并且只有插入时偶现报错,因此猜测问题与事务的使用相关。

查看官网的回答,给出的原因分别是1-redis服务或网络分区出错,2-命令超时导致阻塞,3-设置的超时时间太短,4-阻塞Netty的EventLoop。按实际情况来看,原因2的可能性最大,因此我在本地进行调试的过程中,将配置中的timeout改为1000(1秒),断点停顿时间特意延长,果然出现了同样的报错,将事务去掉之后,命令由6个减为2个,并且为普通的get、set命令,不会出现因命令超时导致阻塞连接的问题。
image (5).png

2.3.4 解决方案

去除插入、更新接口中的事务代码,只保留业务代码。物料服务插入操作由incjob控制了并发请求,因此去除物料服务中的事务代码不会导致数据错误。

2.3.5 修改内容

只保留『配置』中带"业务代码"批注的代码。

2.3.6 补充

以下内容前提是还未去掉插入操作中的事务。

本地调试过程中发现,单元测试结束,服务关闭时,打印了连接池中有未释放的连接warn日志。

深入研究发现了,Lettuce开启redis事务后又一坑--执行事务命令的连接并不会主动释放。

这里的releaseConnection方法在每次执行redis命令后都会被调用,其中关于释放连接的逻辑显示,如果该连接执行的是事务命令,且不是只读事务,命令执行完毕后连接不会被释放。

实际日志:

验证过程中,我将max-active(连接池最大连接数)改为10,同时调用20次插入接口。

失败是因为watch监测到key的value发生变化,而null则是因为服务报错,错误信息如下,意为从连接池中获取不到连接资源。

image (6).png

查看redis连接数为10,此时可以正常调用查询接口(没有事务的命令)。

三、总结

对于Redis框架的选择,要根据具体的业务场景来决定使用的对象。

对于流行框架的使用,使用其封装的某部分工具前应该先了解使用版本的相关信息,如果是新版本需要知道做了哪些变动,旧版本的话应该查看是否有还未解决的issue。

出现问题最好从源码上找答案。

目前疑惑点是为什么watch和unwatch命令会超时,有经验的同学欢迎评论区中指教。

四、参考

SpringBoot整合redis使用Lettuce客户端超时问题

jedis和lettuce连接redis方案性能对比

Jedis和Lettuce性能对比