java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot Lettuce Redis集群读写分离

基于SpringBoot + Lettuce实现Redis集群读写分离

作者:IT_Octopus

本文手把手教你基于SpringBoot和Lettuce实现读写分离,通过配置SLAVE_PREFERRED让读请求优先走从库,同时保持写操作和强一致读走主库,方案零侵入、可灰度,只改需要分流的调用点,完美平衡性能与一致性,需要的朋友可以参考下

一、背景

项目基于 Spring Boot 2.1.5 + Redis 集群 + Lettuce 客户端,所有读写默认都走主库(ReadFrom = MASTER)。随着读流量增大,主库压力上升,需要把部分可容忍主从延迟的读分流到从节点,同时保证写操作和强一致读仍走主库

目标很明确:

二、方案选型

Lettuce 原生支持 ReadFrom 策略,常见取值:

ReadFrom行为
MASTER只读主库(默认)
MASTER_PREFERRED优先主库,主库不可用读从库
SLAVE_PREFERRED / REPLICA_PREFERRED优先从库,从库不可用回退主库
SLAVE / REPLICA只读从库

版本坑:Spring Boot 2.1.5 自带 Lettuce 5.1.x,枚举名是 SLAVE_PREFERRED;Lettuce 5.2+ 才更名为 REPLICA_PREFERRED,语义完全相同。直接照抄高版本代码会编译报错。

核心思路是:构建一个独立的副本 LettuceConnectionFactory,只把 ReadFrom 设为 SLAVE_PREFERRED,其余配置复用主库,再基于它注册一组"副本 RedisTemplate",在工具类里提供带 FromReplica 后缀的专用读方法,由调用方按需选择。

这种"新增方法、不动现有方法"的做法好处是:零 风险、可灰度——只改需要分流的调用点,其余代码一行不动。

三、实现

3.1 副本连接工厂 + 副本模板

新建 ReplicaRedisConfig,关键点:

import io.lettuce.core.ReadFrom;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettucePoolingClientConfiguration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;

import java.time.Duration;

/**
 * Redis 从读(Replica Read)配置
 *
 * 副本连接工厂 ReadFrom = SLAVE_PREFERRED(优先从库,从库不可用回退主库)。
 * 复用主库的集群拓扑/密码/连接池/超时,仅 ReadFrom 不同。
 * 注意:副本模板仅用于读命令;集群模式下写命令始终由主节点处理。
 */
@Configuration
public class ReplicaRedisConfig {

    @Bean
    public LettuceConnectionFactory replicaConnectionFactory(RedisProperties redisProperties) {
        if (redisProperties.getCluster() == null
                || redisProperties.getCluster().getNodes() == null
                || redisProperties.getCluster().getNodes().isEmpty()) {
            throw new IllegalStateException(
                    "ReplicaRedisConfig 需要集群配置 spring.redis.cluster.nodes,当前未配置");
        }

        RedisClusterConfiguration clusterConfig = new RedisClusterConfiguration();
        for (String node : redisProperties.getCluster().getNodes()) {
            String[] parts = node.split(":");
            clusterConfig.clusterNode(parts[0].trim(), Integer.parseInt(parts[1].trim()));
        }
        clusterConfig.setMaxRedirects(redisProperties.getCluster().getMaxRedirects());
        if (redisProperties.getPassword() != null && !redisProperties.getPassword().isEmpty()) {
            clusterConfig.setPassword(RedisPassword.of(redisProperties.getPassword()));
        }

        GenericObjectPoolConfig<?> poolConfig = new GenericObjectPoolConfig<>();
        RedisProperties.Pool pool = redisProperties.getLettuce().getPool();
        if (pool != null) {
            poolConfig.setMaxTotal(pool.getMaxActive());
            poolConfig.setMaxIdle(pool.getMaxIdle());
            poolConfig.setMinIdle(pool.getMinIdle());
            if (pool.getMaxWait() != null) {
                poolConfig.setMaxWaitMillis(pool.getMaxWait().toMillis());
            }
        }

        LettucePoolingClientConfiguration.LettucePoolingClientConfigurationBuilder builder =
                LettucePoolingClientConfiguration.builder()
                        .readFrom(ReadFrom.SLAVE_PREFERRED)   // 关键:优先从库
                        .poolConfig(poolConfig);

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

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

    /** 副本纯字符串序列化模板 */
    @Bean
    public RedisTemplate<String, Object> customStringRedisTemplateReplica(
            LettuceConnectionFactory replicaConnectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(replicaConnectionFactory);
        template.setKeySerializer(RedisSerializer.string());
        template.setHashKeySerializer(RedisSerializer.string());
        template.setValueSerializer(RedisSerializer.string());
        template.setHashValueSerializer(RedisSerializer.string());
        template.afterPropertiesSet();
        return template;
    }

    // 其他副本模板(gzip / Protobuf+Zstd)序列化器与主库保持一致,略
}

3.2 工具类新增 FromReplica 读方法

RedisUtils 中注入副本模板(用 @Qualifier 按名注入,避免与主库模板冲突),新增一组后缀为 FromReplica 的读方法:

@Autowired
@Qualifier("customStringRedisTemplateReplica")
private RedisTemplate<String, Object> customStringRedisTemplateReplica;

@Autowired
@Qualifier("gzipRedisTemplateReplica")
private RedisTemplate<String, Object> gzipRedisTemplateReplica;

@Autowired
@Qualifier("menuRedisTemplateReplica")
private RedisTemplate<String, Map<String, Menu>> menuRedisTemplateReplica;

// ==================== 从读(Replica Read)方法 ====================
// 走副本连接工厂(ReadFrom = SLAVE_PREFERRED),仅用于可容忍主从复制延迟的读场景。
// 现有同名(不带 FromReplica)方法仍走主库,语义不变。

public Object getStrFromReplica(String key) {
    return key == null ? null : customStringRedisTemplateReplica.opsForValue().get(key);
}

public Object hgetStringFromReplica(String key, String field) throws IOException {
    final Object object = customStringRedisTemplateReplica.opsForHash().get(key, field);
    if (object == null) {
        log.debug("data is null");
        return null;
    }
    return object;
}

public boolean existsFromReplica(String key) {
    return customStringRedisTemplateReplica.hasKey(key);
}

public Map<String, Menu> loadCompressedMenuByProtobufZstdFromReplica(String key) {
    try {
        Map<String, Menu> value = menuRedisTemplateReplica.opsForValue().get(key);
        return value == null ? null : value;
    } catch (Exception e) {
        throw new RuntimeException("Failed to load compressed menu from replica", e);
    }
}

3.3 调用方改造

把原来调主库的地方换成 xxxFromReplica 即可,一行改动:

// 改造前:走主库
Object object = redisUtils.getStr(redisKey);

// 改造后:走从库优先
Object object = redisUtils.getStrFromReplica(redisKey);

四、踩坑记录

  1. Lettuce 版本与枚举名:SB 2.1.x 对应 Lettuce 5.1.x,必须用 ReadFrom.SLAVE_PREFERRED,高版本的 REPLICA_PREFERRED 编译不过。
  2. 副本模板只读不写:集群模式下写命令(SET/HSET 等)始终路由到 slot owner(主节点),用副本模板写没有意义且易混淆,应在规范上禁止。
  3. 连接池配置要同步:副本连接工厂是手动 new 出来的,不会自动继承 spring.redis.lettuce.pool.*,必须显式读取 RedisProperties 里的 pool 配置套用,否则会用默认池参数。
  4. afterPropertiesSet() 别忘:手动创建的 LettuceConnectionFactory 需要调用 afterPropertiesSet() 完成初始化,否则启动时连接不上。
  5. 主从延迟评估SLAVE_PREFERRED 回退主库只在"从节点不可达/报错"时触发,不会因延迟大而回退。对延迟敏感的读(写后立即读、分布式锁判断)必须用主库方法。

五、总结

整套方案的核心是"双连接工厂 + 专用方法名":

优点是渐进式、可控、零回归风险:想分流哪个调用点就改哪个,出问题随时回退到主库方法,不影响其他业务。

以上就是基于SpringBoot + Lettuce实现Redis集群读写分离的详细内容,更多关于SpringBoot Lettuce Redis集群读写分离的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文