一个被 Spring Boot 静默吞掉的 Redis 连接池:主从读写分流的隐形 Bug
2026/7/17 21:27:13 网站建设 项目流程

项目里本想做 Redis 主从读写分离——主库读写走主、只读场景走从。代码看着没毛病,跑起来也不报错,但实际上整个应用只剩一个连接池,而且所有"主库读"全跑到了从库上。这篇记录一下这个静默 Bug 的定位过程与修复方案。

一、背景

项目里有两个 Redis 工具类,分工明确:

  • RedisUtils:主库读写,封装各类数据结构的读写、分布式锁、菜单缓存等。
  • ReplicaRedisUtils:从读(Replica Read),只封装可容忍主从复制延迟的读方法,对应主库方法的"从库版"。

设计意图很清晰:

主库工厂(读主+写) ──→ RedisUtils (主库读写) 副本工厂(读从) ──→ ReplicaRedisUtils (只读从库)

两个LettuceConnectionFactory,各带一个连接池,互不干扰。

二、代码"看起来"没问题

主库配置RedisConfig.java

6 个主库模板,都按类型注入RedisConnectionFactory,期望用 Spring Boot 自动装配的主库工厂:

@BeanpublicRedisTemplate<String,Object>redisTemplate(RedisConnectionFactoryconnectionFactory){RedisTemplate<String,Object>template=newRedisTemplate<>();template.setConnectionFactory(connectionFactory);// ... 序列化配置returntemplate;}

副本配置ReplicaRedisConfig.java

手动建了一个副本工厂,ReadFrom = SLAVE_PREFERRED,4 个副本模板都挂它:

@BeanpublicLettuceConnectionFactoryreplicaConnectionFactory(RedisPropertiesredisProperties){// ... 解析集群节点、密码、连接池参数LettucePoolingClientConfigurationbuilder=LettucePoolingClientConfiguration.builder().readFrom(ReadFrom.SLAVE_PREFERRED).poolConfig(poolConfig);// ...returnnewLettuceConnectionFactory(clusterConfig,builder.build());}

配置文件里连接池参数也齐全:

spring:redis:timeout:30000lettuce:pool:max-active:500max-idle:128max-wait:30000min-idle:64cluster:max-redirects:3nodes:xxxx

编译通过、启动正常、读写都能跑——谁能想到这里其实已经坏了。

三、实际发生了什么:只剩一个池,而且读全跑去了从库

Spring Boot 自动装配主库连接工厂(LettuceConnectionConfiguration.redisConnectionFactory)上挂着一个条件:

@Bean@ConditionalOnMissingBean(RedisConnectionFactory.class)LettuceConnectionFactoryredisConnectionFactory(...){...}

注意它是按类型判断@ConditionalOnMissingBean(RedisConnectionFactory.class)

ReplicaRedisConfig里手动@Bean创建的replicaConnectionFactory,返回类型是LettuceConnectionFactory——它本身就是RedisConnectionFactory

用户自定义的@Configuration先于自动配置注册,于是启动时:

  1. Spring 扫到replicaConnectionFactory,注册为RedisConnectionFactory
  2. 轮到自动配置时,@ConditionalOnMissingBean(RedisConnectionFactory.class)判定为"已存在"→主库工厂被回退,根本不创建

容器里最终只有一个RedisConnectionFactory,就是副本工厂。

那为什么不报错?

RedisConfig里每个模板方法签名都是(RedisConnectionFactory connectionFactory),没有@Qualifier。容器里恰好只剩副本工厂这一个候选,Spring 顺利把它注入进去——编译运行都不报错。

所以最终的真实拓扑变成了:

副本工厂(读从, SLAVE_PREFERRED) ──→ RedisUtils 的 6 个模板 ← 意外!本该用主库 ──→ ReplicaRedisUtils 的 4 个模板

只有一个连接池(副本那个,max-active=500),主库工厂从未出生。

四、影响

场景预期实际
RedisUtils写(set/save.../tryLock/unlock走主库✅ 仍走主库(集群模式下写命令始终由 slot owner/主节点处理,不受ReadFrom影响)
RedisUtils读(get/getStr/hgetall/hgetallMenu/loadCompressedMenu...走主库❌ 走从库(SLAVE_PREFERRED),有主从复制延迟读风险
ReplicaRedisUtils走从库✅ 走从库(碰巧正确)
连接池数量主+副 两个池❌ 只有一个池,主副本全部流量共用

最隐蔽的是:写没出问题(集群模式写的语义保护了它),副本读碰巧正确,只有"本该读主库却读了从库"这一类静默错位,而且不报错、不影响启动,纯靠业务层发现延迟问题才能察觉。

五、根因小结

一句话:用一个LettuceConnectionFactory类型的 bean 想做"额外的副本工厂",结果它触发了自动配置的@ConditionalOnMissingBean回退,把主库工厂挤没了。

Spring Boot 的@ConditionalOnMissingBean按类型判断时,子类/实现类的存在都会让条件成立。这是"自定义 bean 接管自动配置"的常见副作用——本意是扩展,结果成了替换。

六、修复方案

核心:显式声明主库工厂并标@Primary,让RedisConfig的模板按类型注入时选中主库;副本模板继续用@Qualifier指定副本工厂。

@ConfigurationpublicclassReplicaRedisConfig{@Bean@PrimarypublicLettuceConnectionFactoryprimaryLettuceConnectionFactory(RedisPropertiesredisProperties){returncreateLettuceConnectionFactory(redisProperties,null);// 不设 readFrom → 默认拓扑走主}@BeanpublicLettuceConnectionFactoryreplicaConnectionFactory(RedisPropertiesredisProperties){returncreateLettuceConnectionFactory(redisProperties,ReadFrom.SLAVE_PREFERRED);}privateLettuceConnectionFactorycreateLettuceConnectionFactory(RedisPropertiesredisProperties,ReadFromreadFrom){RedisClusterConfigurationclusterConfig=buildClusterConfig(redisProperties);GenericObjectPoolConfig<?>poolConfig=buildPoolConfig(redisProperties);LettucePoolingClientConfiguration.LettucePoolingClientConfigurationBuilderbuilder=LettucePoolingClientConfiguration.builder().poolConfig(poolConfig);if(readFrom!=null){builder.readFrom(readFrom);}Durationtimeout=redisProperties.getTimeout();if(timeout!=null){builder.commandTimeout(timeout);}LettuceConnectionFactoryfactory=newLettuceConnectionFactory(clusterConfig,builder.build());factory.afterPropertiesSet();returnfactory;}// ... buildClusterConfig / buildPoolConfig 省略,两个工厂共用集群拓扑、密码、连接池参数@BeanpublicRedisTemplate<String,Object>customStringRedisTemplateReplica(@Qualifier("replicaConnectionFactory")LettuceConnectionFactoryreplicaConnectionFactory){// 注意 @Qualifier 不能少,否则被 @Primary 抢走RedisTemplate<String,Object>template=newRedisTemplate<>();template.setConnectionFactory(replicaConnectionFactory);// ...returntemplate;}}

几个关键点

  1. 主库工厂标@PrimaryRedisConfig里 6 个模板按类型注入RedisConnectionFactory,会优先选中主库工厂。
  2. 副本模板参数必须加@Qualifier("replicaConnectionFactory"):这是另一个坑——标了@Primary后,按类型注入会优先选 primary,若不显式@Qualifier,副本模板会被主工厂抢走,等于白改。
  3. 两工厂共用配置构建逻辑:集群拓扑、密码、max-redirects、连接池、超时完全一致,仅ReadFrom不同,抽公共方法避免重复。
  4. RedisUtilsReplicaRedisUtils不用改

修复后拓扑

主库工厂(默认拓扑) ──→ RedisUtils (主库读写,走主) 副本工厂(SLAVE_PREFERRED) ──→ ReplicaRedisUtils (从读,优先从库)

两个独立LettuceConnectionFactory,各一个连接池,主从分流真正生效。

七、验证方法

  1. 编译mvn compile通过。

  2. Bean 检查:启动后访问/actuator/beans,确认存在两个 bean:

    • primaryLettuceConnectionFactory(primary)
    • replicaConnectionFactory

    不再有自动装配的redisConnectionFactory

  3. 运行时核对:在RedisUtils@PostConstruct打印redisTemplate.getConnectionFactory(),确认是primaryLettuceConnectionFactory,与副本工厂不是同一个实例。

八、经验教训

  1. @ConditionalOnMissingBean按类型判断时,要小心自定义 bean 的类型"误伤"自动配置。想加一个"额外"的同类型 bean,往往会把自动配置的那个挤掉。需要自定义时,最好显式声明并标@Primary,不要寄希望于"自动配置的那个还在"。
  2. “能跑"不等于"对”。这个 Bug 不报错、不影响启动、写正常、副本读碰巧正常,只有"主库读跑到从库"这一条静默错位。连接池/读写路由类的 Bug,最好用 Actuator bean 列表 + 运行时打印 connectionFactory 来核对。
  3. 读写分离一定要验证ReadFrom实际生效在谁身上。写命令在集群模式下有 slot owner 保护,读命令则完全受ReadFrom控制——"读错库"比"写错库"更难发现。
  4. @Primary@Qualifier要配对用。标了@Primary之后,所有不该用 primary 的注入点都必须显式@Qualifier,否则会被静默抢走。

附录:环境

  • Spring Boot 2.7.x(spring-boot-maven-plugin 2.7.18
  • Lettuce 6.2.4.RELEASE
  • Redis Cluster 模式
  • commons-pool2 2.11.1

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询