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

一、背景

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

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

设计意图很清晰:

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

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

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

主库配置 RedisConfig.java

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

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(connectionFactory);
    // ... 序列化配置
    return template;
}

副本配置 ReplicaRedisConfig.java

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

@Bean
public LettuceConnectionFactory replicaConnectionFactory(RedisProperties redisProperties) {
    // ... 解析集群节点、密码、连接池参数
    LettucePoolingClientConfiguration builder = LettucePoolingClientConfiguration.builder()
            .readFrom(ReadFrom.SLAVE_PREFERRED)
            .poolConfig(poolConfig);
    // ...
    return new LettuceConnectionFactory(clusterConfig, builder.build());
}

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

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

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

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

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

@Bean
@ConditionalOnMissingBean(RedisConnectionFactory.class)
LettuceConnectionFactory redisConnectionFactory(...) { ... }

注意它是按类型判断 @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 指定副本工厂。

@Configuration
public class ReplicaRedisConfig {

    @Bean
    @Primary
    public LettuceConnectionFactory primaryLettuceConnectionFactory(RedisProperties redisProperties) {
        return createLettuceConnectionFactory(redisProperties, null);  // 不设 readFrom → 默认拓扑走主
    }

    @Bean
    public LettuceConnectionFactory replicaConnectionFactory(RedisProperties redisProperties) {
        return createLettuceConnectionFactory(redisProperties, ReadFrom.SLAVE_PREFERRED);
    }

    private LettuceConnectionFactory createLettuceConnectionFactory(
            RedisProperties redisProperties, ReadFrom readFrom) {
        RedisClusterConfiguration clusterConfig = buildClusterConfig(redisProperties);
        GenericObjectPoolConfig<?> poolConfig = buildPoolConfig(redisProperties);

        LettucePoolingClientConfiguration.LettucePoolingClientConfigurationBuilder builder =
                LettucePoolingClientConfiguration.builder().poolConfig(poolConfig);
        if (readFrom != null) {
            builder.readFrom(readFrom);
        }
        Duration timeout = redisProperties.getTimeout();
        if (timeout != null) {
            builder.commandTimeout(timeout);
        }

        LettuceConnectionFactory factory = new LettuceConnectionFactory(clusterConfig, builder.build());
        factory.afterPropertiesSet();
        return factory;
    }

    // ... buildClusterConfig / buildPoolConfig 省略,两个工厂共用集群拓扑、密码、连接池参数

    @Bean
    public RedisTemplate<String, Object> customStringRedisTemplateReplica(
            @Qualifier("replicaConnectionFactory") LettuceConnectionFactory replicaConnectionFactory) {
        // 注意 @Qualifier 不能少,否则被 @Primary 抢走
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(replicaConnectionFactory);
        // ...
        return template;
    }
}

几个关键点

  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
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐