一个被 Spring Boot 静默吞掉的 Redis 连接池:主从读写分流的隐形 Bug

一个被 Spring Boot 静默吞掉的 Redis 连接池:主从读写分流的隐形 Bug 项目里本想做 Redis 主从读写分离——主库读写走主、只读场景走从。代码看着没毛病跑起来也不报错但实际上整个应用只剩一个连接池而且所有主库读全跑到了从库上。这篇记录一下这个静默 Bug 的定位过程与修复方案。一、背景项目里有两个 Redis 工具类分工明确RedisUtils主库读写封装各类数据结构的读写、分布式锁、菜单缓存等。ReplicaRedisUtils从读Replica Read只封装可容忍主从复制延迟的读方法对应主库方法的从库版。设计意图很清晰主库工厂(读主写) ──→ RedisUtils 主库读写 副本工厂(读从) ──→ ReplicaRedisUtils 只读从库两个LettuceConnectionFactory各带一个连接池互不干扰。二、代码看起来没问题主库配置RedisConfig.java6 个主库模板都按类型注入RedisConnectionFactory期望用 Spring Boot 自动装配的主库工厂BeanpublicRedisTemplateString,ObjectredisTemplate(RedisConnectionFactoryconnectionFactory){RedisTemplateString,ObjecttemplatenewRedisTemplate();template.setConnectionFactory(connectionFactory);// ... 序列化配置returntemplate;}副本配置ReplicaRedisConfig.java手动建了一个副本工厂ReadFrom SLAVE_PREFERRED4 个副本模板都挂它BeanpublicLettuceConnectionFactoryreplicaConnectionFactory(RedisPropertiesredisProperties){// ... 解析集群节点、密码、连接池参数LettucePoolingClientConfigurationbuilderLettucePoolingClientConfiguration.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上挂着一个条件BeanConditionalOnMissingBean(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-active500主库工厂从未出生。四、影响场景预期实际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{BeanPrimarypublicLettuceConnectionFactoryprimaryLettuceConnectionFactory(RedisPropertiesredisProperties){returncreateLettuceConnectionFactory(redisProperties,null);// 不设 readFrom → 默认拓扑走主}BeanpublicLettuceConnectionFactoryreplicaConnectionFactory(RedisPropertiesredisProperties){returncreateLettuceConnectionFactory(redisProperties,ReadFrom.SLAVE_PREFERRED);}privateLettuceConnectionFactorycreateLettuceConnectionFactory(RedisPropertiesredisProperties,ReadFromreadFrom){RedisClusterConfigurationclusterConfigbuildClusterConfig(redisProperties);GenericObjectPoolConfig?poolConfigbuildPoolConfig(redisProperties);LettucePoolingClientConfiguration.LettucePoolingClientConfigurationBuilderbuilderLettucePoolingClientConfiguration.builder().poolConfig(poolConfig);if(readFrom!null){builder.readFrom(readFrom);}DurationtimeoutredisProperties.getTimeout();if(timeout!null){builder.commandTimeout(timeout);}LettuceConnectionFactoryfactorynewLettuceConnectionFactory(clusterConfig,builder.build());factory.afterPropertiesSet();returnfactory;}// ... buildClusterConfig / buildPoolConfig 省略两个工厂共用集群拓扑、密码、连接池参数BeanpublicRedisTemplateString,ObjectcustomStringRedisTemplateReplica(Qualifier(replicaConnectionFactory)LettuceConnectionFactoryreplicaConnectionFactory){// 注意 Qualifier 不能少否则被 Primary 抢走RedisTemplateString,ObjecttemplatenewRedisTemplate();template.setConnectionFactory(replicaConnectionFactory);// ...returntemplate;}}几个关键点主库工厂标PrimaryRedisConfig里 6 个模板按类型注入RedisConnectionFactory会优先选中主库工厂。副本模板参数必须加Qualifier(replicaConnectionFactory)这是另一个坑——标了Primary后按类型注入会优先选 primary若不显式Qualifier副本模板会被主工厂抢走等于白改。两工厂共用配置构建逻辑集群拓扑、密码、max-redirects、连接池、超时完全一致仅ReadFrom不同抽公共方法避免重复。RedisUtils和ReplicaRedisUtils不用改。修复后拓扑主库工厂(默认拓扑) ──→ RedisUtils 主库读写走主 副本工厂(SLAVE_PREFERRED) ──→ ReplicaRedisUtils 从读优先从库两个独立LettuceConnectionFactory各一个连接池主从分流真正生效。七、验证方法编译mvn compile通过。Bean 检查启动后访问/actuator/beans确认存在两个 beanprimaryLettuceConnectionFactoryprimaryreplicaConnectionFactory且不再有自动装配的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.xspring-boot-maven-plugin 2.7.18Lettuce 6.2.4.RELEASERedis Cluster 模式commons-pool2 2.11.1