项目里明明写着 spring.data.redis.database=5,缓存却总在 db0。把 Spring Boot 升到 4.2.0-RC2 后,同一份 master/replica 配置会写入 db5;4.2.0-RC1 仍写 db0。下面用 Lettuce 7.8.0.RELEASE 和 Redis 8.2.3 主从实例,把两次运行和 CLI 核验放在一起。

这不是 Redis 的 SELECT 失效,也不是升级 Lettuce 顺带解决了问题。两次对照使用的 Spring Data Redis 和 Lettuce 完全相同,变化在 Spring Boot 组装 master/replica 连接配置的地方。

这次修复改的是什么

Spring Boot 在 2026 年 11 月 5 日发布 4.2.0-RC2。发布说明的 Upgrading 部分专门列出 Redis Database Applied to Master/Replica Connections:以前只要配置了 spring.data.redis.masterreplica.nodes,spring.data.redis.database 就会被静默忽略,连接工厂一直使用 db0。RC2 开始,master/replica 和 standalone、Sentinel 一样使用配置的库号。

对应修复是 spring-boot#52189。改动落在 Boot 的 spring-boot-data-redis 模块:

  1. DataRedisConnectionDetails.MasterReplica 新增 getDatabase(),默认返回 0,已有自定义实现不必跟着改。
  2. 基于属性的连接详情把配置的 database 传给 master/replica。
  3. 创建 RedisStaticMasterReplicaConfiguration 时调用 setDatabase(...),不再丢掉这个值。

这里讨论的是 Lettuce 的静态 master/replica 连接。Spring Boot 文档明确写着 Jedis 不支持 master/replica;standalone 和 Sentinel 原本就会应用 database,Cluster 也不是这次修复的对象。

实验环境和最小项目

组件RC2 运行旧版对照
JDKOpenJDK 21.0.12.1OpenJDK 21.0.12.1
Maven3.9.93.9.9
Spring Boot4.2.0-RC24.2.0-RC1
Spring Data Redis4.2.0-RC14.2.0-RC1
Lettuce7.8.0.RELEASE7.8.0.RELEASE
Jedis未引入,不支持此模式未引入,不支持此模式
Redis server8.2.3,6379 master + 6380 replica同一组实例

RC2 的 starter 名称仍是 spring-boot-starter-data-redis。它内部转到 spring-boot-data-redis,默认客户端仍是 Lettuce,不需要换成名字里带 masterreplica 的新依赖。

pom.xml 的关键部分如下。Java 版本设为 21,对照时只改 parent 的版本号。

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.2.0-RC2</version>
  <relativePath/>
</parent>

<properties>
  <java.version>21</java.version>
</properties>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
  </dependency>
</dependencies>

依赖树能确认两版的底层 Redis 组件没有跟着变化:

\- org.springframework.boot:spring-boot-starter-data-redis:jar:4.2.0-RC2:compile
   \- org.springframework.boot:spring-boot-data-redis:jar:4.2.0-RC2:compile
      +- org.springframework.boot:spring-boot-data-commons:jar:4.2.0-RC2:compile
      |  \- org.springframework.data:spring-data-commons:jar:4.2.0-RC1:compile
      +- io.lettuce:lettuce-core:jar:7.8.0.RELEASE:compile
      \- org.springframework.data:spring-data-redis:jar:4.2.0-RC1:compile

启动一主一从,不用单节点冒充主从

下面两个容器共用宿主机网络。6379 是 master,6380 通过 replicaof 指向它。静态 master/replica 本身不负责故障转移,这个拓扑只用于验证连接和库号。

以下是隔离演示机上的启动命令。--network host 使用宿主网络,端口暴露范围取决于 Redis 的绑定与防火墙配置,不要直接照搬到公网机器。后面的 db0、db5 清理也只针对本次专用实例,不能对生产库执行。

sudo docker run -d --name boot42-redis-master --network host redis:8.2.3-alpine redis-server --port 6379 --save '' --appendonly no
sudo docker run -d --name boot42-redis-replica --network host redis:8.2.3-alpine redis-server --port 6380 --save '' --appendonly no --replicaof 127.0.0.1 6379

启动后先查复制状态。以下保留角色、在线副本和复制链路几项原始输出:

$ sudo docker exec boot42-redis-master redis-cli -p 6379 INFO replication
# Replication
role:master
connected_slaves:1
slave0:ip=127.0.0.1,port=6380,state=online,offset=1186,lag=1

$ sudo docker exec boot42-redis-replica redis-cli -p 6380 INFO replication
# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up

关键配置:nodes 和 database 同时保留

application.properties 使用逗号分隔两个节点,database 固定为 5。EXPECTED_DB 只是演示程序的断言预期,不参与 Redis 连接配置。RC2 默认期望 5,跑 RC1 时把它设为 0,用来验证旧行为。

spring.application.name=boot42-redis-masterreplica-db
spring.main.banner-mode=off
spring.data.redis.masterreplica.nodes=127.0.0.1:6379,127.0.0.1:6380
spring.data.redis.database=5
app.expected-database=${EXPECTED_DB:5}
app.verification-key=boot42:masterreplica:where-am-i
logging.level.root=WARN

验证程序先用自动配置的 StringRedisTemplate 写 key,再通过 RedisConnection 执行 CLIENT INFO。Redis 返回的 db 字段就是当前连接的库号,比只看 Bean 里的配置值更直接。下面是实际运行的完整类。

package com.example.redisdb;

import java.nio.charset.StandardCharsets;
import java.util.Arrays;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringBootVersion;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

@SpringBootApplication
public class RedisDatabaseApplication {
    public static void main(String[] args) {
        try (var context = org.springframework.boot.SpringApplication.run(RedisDatabaseApplication.class, args)) {
            // Verification runs during startup; close the context so Maven exits cleanly.
        }
    }
}

@Component
class DatabaseVerifier implements CommandLineRunner {
    private final StringRedisTemplate template;
    private final int configuredDatabase;
    private final int expectedDatabase;
    private final String key;

    DatabaseVerifier(StringRedisTemplate template,
            @Value("${spring.data.redis.database}") int configuredDatabase,
            @Value("${app.expected-database}") int expectedDatabase,
            @Value("${app.verification-key}") String key) {
        this.template = template;
        this.configuredDatabase = configuredDatabase;
        this.expectedDatabase = expectedDatabase;
        this.key = key;
    }

    @Override
    public void run(String... args) {
        template.delete(key);
        template.opsForValue().set(key, "written-through-masterreplica");
        String clientInfo = template.execute((RedisCallback<String>) connection -> {
            Object result = connection.execute("CLIENT", "INFO".getBytes(StandardCharsets.UTF_8));
            return (result instanceof byte[] bytes) ? new String(bytes, StandardCharsets.UTF_8)
                    : String.valueOf(result);
        });
        int actualDatabase = Arrays.stream(clientInfo.trim().split(" "))
                .filter(part -> part.startsWith("db="))
                .mapToInt(part -> Integer.parseInt(part.substring(3)))
                .findFirst()
                .orElseThrow(() -> new IllegalStateException("CLIENT INFO lacks db: " + clientInfo));
        System.out.printf("VERIFY spring-boot=%s lettuce=%s configured-db=%d actual-db=%d key=%s%n",
                SpringBootVersion.getVersion(),
                io.lettuce.core.RedisURI.class.getPackage().getImplementationVersion(),
                configuredDatabase, actualDatabase, key);
        System.out.printf("VERIFY value=%s client-info=%s%n", template.opsForValue().get(key), clientInfo.trim());
        if (actualDatabase != expectedDatabase) {
            throw new AssertionError("expected db " + expectedDatabase + " but got " + actualDatabase);
        }
        System.out.println("ASSERTION PASSED actual-db=" + actualDatabase);
    }
}

RC2:配置 db5,连接和 key 都在 db5

直接运行:

mvn -B -ntp spring-boot:run

启动日志明确给出配置值和真实连接库号,断言通过:

[INFO] --- spring-boot:4.2.0-RC2:run (default-cli) @ boot42-redis-masterreplica-db ---
VERIFY spring-boot=4.2.0-RC2 lettuce=7.8.0.RELEASE/4d12c0c configured-db=5 actual-db=5 key=boot42:masterreplica:where-am-i
ASSERTION PASSED actual-db=5
[INFO] BUILD SUCCESS

完整的 CLIENT INFO 输出中还能看到 laddr=127.0.0.1:6379、db=5 和 lib-name=Lettuce(spring-data-redis_v4.2.0-RC1)。接着从应用外部查 Redis,避免程序自己证明自己。

$ sudo docker exec boot42-redis-master redis-cli -p 6379 -n 0 EXISTS boot42:masterreplica:where-am-i
0
$ sudo docker exec boot42-redis-master redis-cli -p 6379 -n 5 EXISTS boot42:masterreplica:where-am-i
1
$ sudo docker exec boot42-redis-master redis-cli -p 6379 -n 5 GET boot42:masterreplica:where-am-i
written-through-masterreplica
$ sudo docker exec boot42-redis-master redis-cli -p 6379 -n 0 INFO keyspace
# Keyspace
db5:keys=1,expires=0,avg_ttl=0,subexpiry=0

-n 0 和 -n 5 就是在连接时选择库。用交互模式的 SELECT 再查一遍,结果相同:

SELECT 0
EXISTS boot42:masterreplica:where-am-i
SELECT 5
EXISTS boot42:masterreplica:where-am-i
GET boot42:masterreplica:where-am-i
INFO keyspace

OK
0
OK
1
written-through-masterreplica
# Keyspace
db5:keys=1,expires=0,avg_ttl=0,subexpiry=0

6380 replica 也只在 db5 看到这个 key,说明核验的不只是 master 本地状态:

$ sudo docker exec boot42-redis-replica redis-cli -p 6380 -n 0 EXISTS boot42:masterreplica:where-am-i
0
$ sudo docker exec boot42-redis-replica redis-cli -p 6380 -n 5 EXISTS boot42:masterreplica:where-am-i
1

切回 RC1:同一份配置静默落 db0

先清空 db0 和 db5,再把 parent 的版本从 4.2.0-RC2 改成 4.2.0-RC1。节点、database=5、Java 代码和 Redis 实例都不动。仅通过 EXPECTED_DB=0 告诉演示程序,这次要断言的是旧版的实际行为。

EXPECTED_DB=0 mvn -B -ntp spring-boot:run

输出变成了 actual-db=0,配置值仍然是 5:

[INFO] --- spring-boot:4.2.0-RC1:run (default-cli) @ boot42-redis-masterreplica-db ---
VERIFY spring-boot=4.2.0-RC1 lettuce=7.8.0.RELEASE/4d12c0c configured-db=5 actual-db=0 key=boot42:masterreplica:where-am-i
ASSERTION PASSED actual-db=0
[INFO] BUILD SUCCESS

CLI 的 db0/db5 结果也跟着反转:

$ sudo docker exec boot42-redis-master redis-cli -p 6379 -n 0 EXISTS boot42:masterreplica:where-am-i
1
$ sudo docker exec boot42-redis-master redis-cli -p 6379 -n 5 EXISTS boot42:masterreplica:where-am-i
0
$ sudo docker exec boot42-redis-master redis-cli -p 6379 -n 0 GET boot42:masterreplica:where-am-i
written-through-masterreplica
$ sudo docker exec boot42-redis-master redis-cli -p 6379 -n 0 INFO keyspace
# Keyspace
db0:keys=1,expires=0,avg_ttl=0,subexpiry=0
$ sudo docker exec boot42-redis-replica redis-cli -p 6380 -n 0 EXISTS boot42:masterreplica:where-am-i
1
$ sudo docker exec boot42-redis-replica redis-cli -p 6380 -n 5 EXISTS boot42:masterreplica:where-am-i
0
运行版本配置 databaseCLIENT INFO 实际库master 上 key 所在库replica 上 key 所在库
4.2.0-RC15db0db0,EXISTS=1;db5,EXISTS=0db0,EXISTS=1;db5,EXISTS=0
4.2.0-RC25db5db0,EXISTS=0;db5,EXISTS=1db0,EXISTS=0;db5,EXISTS=1

升级时真正会变的是读写位置

这个修复会带来可观察的行为变化。老应用如果同时设置非零 database 和 master/replica nodes,升级前读写 db0,升级到 RC2 后读写配置的库。Redis 里原有的数据不会被搬过去,所以升级后可能表现为缓存突然查不到,或者同一个 key 同时存在于两个逻辑库。

如果旧系统实际上依赖 db0,按 RC2 发布说明,可以删除 spring.data.redis.database,或者显式设为 0。使用 spring.data.redis.url 的场景则删除 URL 中的 database 段,或把它设为 0。需要按业务设计分库的系统保留非零值即可,RC2 会让它按配置生效。

发布说明还指出 URL 中的 database 过去也会被忽略,现在同样会应用。本文的真实运行覆盖的是 spring.data.redis.database=5,URL 写法没有纳入这组对照,不把两种验证混为一谈。

官方资料

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。