前期因为布隆过滤器的实现需求,导入了 redisson 的依赖,后面项目需求迭代,需要用到 redis 的 bitmap 来做签到信息的存储,并且需要提供读取每月签到记录的功能,这里需要用 bitField 方法将位信息读取成 Long 数值,之后进行移位操作得到当月每天的签到情况,问题代码如下:
对于 bit 的 set 操作是没问题的,但是用到这个 bitField 就出问题了。
项目的依赖版本信息如下:
依赖 | 版本 |
---|---|
springboot | 2.4.12 |
spring-data-redis | 2.4.14 |
lettuce-core | 6.0.8.RELEASE |
redisson-spring-boot-starter | 3.11.5 |
做单元测试的时候发现在 bitField 方法会栈溢出,看堆栈信息是递归调用自己了:
在图 1 的 136 行上打个断点 debug 看下,跟着 stringRedisTemplate.opsForValue()
方法可以来到 DefaultValueOperations 的 bitField 方法,398 行是将 key 转换为字节数组形式可以直接跳过,我们直接在 399 行进去看看。
进到 AbstractOperations 的 execute 方法,接着进到 RedisTemplate 的 execute 方法,然后进入重载 execute 方法,重点看下这个方法。
关键是这里的工厂设置,可以看到这里是 redisson 的工厂。
前面是一些判断以及工厂的设置,209、214 行是获取连接的操作,这里可以看到 conn 获取的是 RedissonConnection 对象,而 connToUse 获取的是 DefaultStringRedisConnection 连接对象。
继续单步下去可以看到这里调用了 doInRedis 方法,并且传进去的是 DefaultStringRedisConnection 。
跟进去可以看到 doInRedis 实际上是个回调方法,回调到一开始的 connection.bitField
方法。
继续跟进去来到了 DefaultStringRedisConnection 的 bitField 方法,这里我们可以看到 delegate 是一个 RedissonConnection 对象,我们再跟进看看。
来到的是接口的默认方法 bitField,再看看这里的 stringCommands 方法返回的是什么对象,
stringCommands 方法实际上返回的还是 RedissonConnection,意味着stringCommands().bitField(xx)
继续执行下去一直是在调用自己,别忘了我们刚刚是怎么进来的,就是在 delegate 为 RedissionConnection 的时候进到了这个接口的默认方法。
那么到这里我们可以发现,实际上是在接口的默认方法这里发生了递归调用,并且没有停止条件,导致最后的栈溢出,这一点也跟我们的堆栈信息是一致的。
那么明明 delegate 是 RedissonConnection 对象,为什么会进到接口的默认方法呢?只有一种可能就是其中没有对接口进行实现。
为了验证我们看看 redissonConnection 中有没有实现这个方法:
搜一下可以发现只有三个 bit 开头的方法,并没有 bitField,这就导致只能循环调用到接口的默认方法。
这里对比之下我们在调用 set 方法时是没问题的,我们同样探究以下它的执行过程。首先是业务代码,就是一行简单的调用:
接着我们跟进去看 set 方法,可以看到跟 bitField 类似,进到 execute 看看。
接着经过一系列的 execute 调用、重载到真正执行的方法 RedisTemplate.execute(xx)。
可以看到这里的 connToExpose 也是 DefaultStringRedisConnection,接着跟进 doInRedis 方法。
依旧是执行回调方法,进去 connection.set 方法看看。
来到了 DefaultStringRedisConnection 的 set 方法,可以看到这里的 delegate 同样是 RedissonConnection 。
跟进去可以看到,这里跟 bitField 不同了,不是进到接口的默认方法实现,而是到了 RedissonConnection 的 set 实现,也就代表着这里可以调用里面的执行流程了,不会无休止地调用接口的默认方法。
其实到这里已经可以结束了,但为了过程的完整我们继续跟下去。跟到 write 方法中可以看到这里是用到了异步的写入,我们跟进这个方法。
可以看到这里只是注册了异步的请求信息,并没有等待获取结果。
回到 write 方法继续往下走,实际上获取结果是在 sync 方法这里,进行同步等待。
跟进可以看到这里返回的是 syncFuture 的结果,看名字可以猜到这个方法是同步的。
进到方法里面可以看到是调用刚刚注册好异步事件的服务的 get 方法进行结果获取。
可以看到这里会 await,直到有结果会被唤醒进入下面的获取操作,getNow 里面就是获取返回值了。
到这里 set 的操作就结束了,对比可以发现,正是因为 redisson 中没有实现 bitField 方法才导致的递归调用默认方法,进而导致栈溢出。
翻看 redisson 的 issue 记录可以发现在 2021 年提出 RedissonConnection 中缺少部分方法这个问题,那么
可以看到在这一次的 commit 中对缺失的方法进行实现,其中就包括 bitField 方法。
对照版本看一下,在 3.15.6 已经应用上了,也就是说如果使用的是 3.15.6 之后的版本就不存在这个问题了。
实际上在未引入 redisson 之前这个方法是可用的,因为在 yml 文件中已经做了 lettuce 连接池的相关配置,那么会自动配置到 lettuce 工厂中。
从 debug 结果也可以看到,获取到的是 lettuceConnectionFactory,那么后续的 bitField 自然就没问题了,问题是引入了 redisson 之后工厂的实现就被替换为 redisson 了。
如果要在当前版本解决这个问题,就需要对那些 issue 中提到的缺失的方法进行引导,让他们找到对应的实现。
简而言之,如果连接工厂是 lettuce 或者 jedis 就可以正常使用,因为其中有对 bitField 的实现,那么我们只要手动配置一个 lettuce 连接工厂即可,并且这不会影响到使用 redisson 的初衷——使用其中的布隆过滤器实现。
package cn.gpnusz.userservice.config;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettucePoolingClientConfiguration;
@Configuration
public class LettuceConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private Integer port;
@Value("${spring.redis.password}")
private String password;
@Value("${spring.redis.database}")
private Integer database;
@Value("${spring.redis.lettuce.pool.max-active}")
private Integer maxActive;
@Value("${spring.redis.lettuce.pool.max-idle}")
private Integer maxIdle;
@Value("${spring.redis.lettuce.pool.max-wait}")
private Long maxWait;
@Value("${spring.redis.lettuce.pool.min-idle}")
private Integer minIdle;
@Bean
public LettuceConnectionFactory lettuceConnectionFactory() {
GenericObjectPoolConfig<Object> genericObjectPoolConfig = new GenericObjectPoolConfig<>();
genericObjectPoolConfig.setMaxIdle(maxIdle);
genericObjectPoolConfig.setMinIdle(minIdle);
genericObjectPoolConfig.setMaxTotal(maxActive);
genericObjectPoolConfig.setMaxWaitMillis(maxWait);
genericObjectPoolConfig.setTimeBetweenEvictionRunsMillis(100);
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setDatabase(database);
redisStandaloneConfiguration.setHostName(host);
redisStandaloneConfiguration.setPort(port);
redisStandaloneConfiguration.setPassword(RedisPassword.of(password));
LettuceClientConfiguration clientConfig = LettucePoolingClientConfiguration.builder()
.poolConfig(genericObjectPoolConfig)
.build();
return new LettuceConnectionFactory(redisStandaloneConfiguration, clientConfig);
}
}
① 全面理解新特性。实际上我们会发现,正是 JDK8 之后接口中多了默认方法的实现,一些老版本的工具包的类才得以兼容,但是今天遇到的这个异常还真就是因为默认方法的实现,导致了找不到实现类一直调接口自己的默认方法,只能说有利有弊吧;
② 尽量选择稳定版本。对于工具/框架的使用尽量使用修复已知 BUG 的稳定版本,否则因为一个小缺陷可能就要花上大半天去解决;
③ 持续锻炼排错能力。对于工具/框架内部某些方法的报错信息,还是要坚持打断点去调试,经过一轮探索之后的收获远强于自己去网上 Copy 相应的解决方案,在这个过程中对排错能力也有较大提升。