项目里本想做 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先于自动配置注册,于是启动时:
- Spring 扫到
replicaConnectionFactory,注册为RedisConnectionFactory。 - 轮到自动配置时,
@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;}}几个关键点
- 主库工厂标
@Primary:RedisConfig里 6 个模板按类型注入RedisConnectionFactory,会优先选中主库工厂。 - 副本模板参数必须加
@Qualifier("replicaConnectionFactory"):这是另一个坑——标了@Primary后,按类型注入会优先选 primary,若不显式@Qualifier,副本模板会被主工厂抢走,等于白改。 - 两工厂共用配置构建逻辑:集群拓扑、密码、max-redirects、连接池、超时完全一致,仅
ReadFrom不同,抽公共方法避免重复。 RedisUtils和ReplicaRedisUtils不用改。
修复后拓扑
主库工厂(默认拓扑) ──→ RedisUtils (主库读写,走主) 副本工厂(SLAVE_PREFERRED) ──→ ReplicaRedisUtils (从读,优先从库)两个独立LettuceConnectionFactory,各一个连接池,主从分流真正生效。
七、验证方法
编译:
mvn compile通过。Bean 检查:启动后访问
/actuator/beans,确认存在两个 bean:primaryLettuceConnectionFactory(primary)replicaConnectionFactory
且不再有自动装配的
redisConnectionFactory。运行时核对:在
RedisUtils的@PostConstruct打印redisTemplate.getConnectionFactory(),确认是primaryLettuceConnectionFactory,与副本工厂不是同一个实例。
八、经验教训
@ConditionalOnMissingBean按类型判断时,要小心自定义 bean 的类型"误伤"自动配置。想加一个"额外"的同类型 bean,往往会把自动配置的那个挤掉。需要自定义时,最好显式声明并标@Primary,不要寄希望于"自动配置的那个还在"。- “能跑"不等于"对”。这个 Bug 不报错、不影响启动、写正常、副本读碰巧正常,只有"主库读跑到从库"这一条静默错位。连接池/读写路由类的 Bug,最好用 Actuator bean 列表 + 运行时打印 connectionFactory 来核对。
- 读写分离一定要验证
ReadFrom实际生效在谁身上。写命令在集群模式下有 slot owner 保护,读命令则完全受ReadFrom控制——"读错库"比"写错库"更难发现。 @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