Spring Boot + Redis Sentinel (一主两从)测试案例

🚀 Spring Boot + Redis Sentinel 完整测试案例

🏷️ 标签:Redis 、Redis Sentinel、Spring Boot 实战


📚 目录导航

  1. 📝 前言
  2. 🏗️ Redis Sentinel 架构说明
  3. 📦 Docker Compose 搭建 Redis 哨兵环境
  4. ⚙️ Spring Boot 配置
    • 📌 Maven 依赖
    • 📝 application.yml 配置
    • 🔧 Redis 配置类
  5. 🧪 测试 Controller
  6. 🚀 运行测试
  7. 为什么这样配置
  8. 🏁 总结

📝 一、前言

在生产环境中,Redis 通常部署为 一主多从 + 哨兵(Sentinel) 架构,以保证高可用性和数据安全性。

使用 Spring Boot 连接 Redis 哨兵时,开发者可能会遇到以下问题:

  • 哨兵返回主节点名称(如 redis-master)无法被客户端解析
  • 数据序列化和反序列化不一致导致 StreamCorruptedException

本文演示如何通过 Docker Compose 搭建 Redis 哨兵环境,并使用 Spring Boot 完成数据写入和读取操作。


🏗️ 二、Redis Sentinel 架构说明

1. ASCII 拓扑示意

text 复制代码
          ┌─────────────┐
          │ redis-master│
          │    6379     │
          └─────┬───────┘
                │
      ┌─────────┴─────────┐
      │                   │
┌─────────────┐     ┌─────────────┐
│ redis-slave1│     │ redis-slave2│
│    6380     │     │    6381     │
└─────────────┘     └─────────────┘
      ▲                   ▲
      │                   │
┌─────┴─────┐       ┌─────┴─────┐
│ sentinel1 │       │ sentinel2 │
│  26379    │       │  26380    │
└───────────┘       └───────────┘
       ▲
       │
   ┌───────────┐
   │ sentinel3 │
   │  26381    │
   └───────────┘

2. Mermaid 彩色架构图

redis-master:6379 redis-slave1:6380 redis-slave2:6381 sentinel1:26379 sentinel2:26380 sentinel3:26381

🔹 主节点(红色)、从节点(绿色)、哨兵(蓝色),箭头表示数据同步和监控方向。


📦 三、Docker Compose 搭建 Redis 哨兵环境

Docker Compose 搭建Redis哨兵


⚙️ 四、Spring Boot 配置

📌 1. Maven 依赖

xml 复制代码
    <dependencies>
        <!-- Web 模块 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Spring Data Redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- Jackson -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

📝 2. application.yml 配置

yaml 复制代码
spring:
  data:
    redis:
      sentinel:
        nodes:
          - 192.168.3.150:26379
          - 192.168.3.150:26380
          - 192.168.3.150:26381
        master: mymaster
      timeout: 3000ms
      lettuce:
        shutdown-timeout: 100ms
        pool:
          max-active: 8
          max-idle: 8
          min-idle: 0
          max-wait: -1
logging:
  level:
    io.lettuce.core: DEBUG
    org.springframework.data.redis: DEBUG

🔧 3. Redis 配置类

java 复制代码
package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.*;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(template.getStringSerializer());
        template.setHashKeySerializer(template.getStringSerializer());
        template.afterPropertiesSet();
        return template;
    }

    @Bean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
        return new StringRedisTemplate(factory);
    }
}

使用 StringRedisTemplate 避免 Java 默认序列化问题。


🧪 五、测试 Controller

java 复制代码
package com.example.demo.controller;

import com.example.demo.service.RedisService;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("/redis")
public class RedisController {

    private final RedisService redisService;

    // 构造函数注入,Spring 会自动注入 RedisService Bean
    public RedisController(RedisService redisService) {
        this.redisService = redisService;
    }

    // String 操作示例
    @PostMapping("/string/set")
    public String setString(@RequestParam String key, @RequestParam String value) {
        redisService.set(key, value, 60L, TimeUnit.SECONDS);
        return "String set successfully";
    }

     // String获取Key
    @GetMapping("/string/get")
    public Object getString(@RequestParam String key) {
        return redisService.get(key);
    }
}

🚀 六、运行测试

1. 启动 Docker Compose:

bash 复制代码
docker compose up -d

2. 启动 Spring Boot 应用

3. 测试写入:

bash 复制代码
curl "http://localhost:9090/redis/set?key=test&value=HelloRedis"

4. 测试读取:

bash 复制代码
curl "http://localhost:9090/redis/get?key=test"

5. 验证主从同步:

bash 复制代码
docker exec -it redis-slave1 redis-cli GET test
docker exec -it redis-slave2 redis-cli GET test

数据应在主从节点一致。

6.可能出现的问题:

bash 复制代码
2025-08-14T15:51:45.540+08:00 DEBUG 9436 --- [Spring-Redis-Sentinel] [ioEventLoop-4-1] i.lettuce.core.protocol.DefaultEndpoint  : [channel=0xec254466, /192.168.3.36:49466 -> /192.168.3.150:26379, epid=0x2] closeAsync()
2025-08-14T15:51:45.543+08:00 DEBUG 9436 --- [Spring-Redis-Sentinel] [ioEventLoop-4-1] io.lettuce.core.RedisClient              : Resolved SocketAddress redis-master/<unresolved>:6379 using redis-sentinel://192.168.3.150,192.168.3.150:26380,192.168.3.150:26381?sentinelMasterId=mymaster&timeout=3s
2025-08-14T15:51:45.543+08:00 DEBUG 9436 --- [Spring-Redis-Sentinel] [ioEventLoop-4-1] io.lettuce.core.AbstractRedisClient      : Connecting to Redis at redis-master/<unresolved>:6379
2025-08-14T15:51:45.545+08:00 DEBUG 9436 --- [Spring-Redis-Sentinel] [ioEventLoop-4-1] i.lettuce.core.protocol.CommandHandler   : [channel=0xec254466, /192.168.3.36:49466 -> /192.168.3.150:26379, epid=0x2, chid=0x1] channelInactive()
2025-08-14T15:51:45.546+08:00 DEBUG 9436 --- [Spring-Redis-Sentinel] [ioEventLoop-4-1] i.lettuce.core.protocol.DefaultEndpoint  : [channel=0xec254466, /192.168.3.36:49466 -> /192.168.3.150:26379, epid=0x2] deactivating endpoint handler
2025-08-14T15:51:45.546+08:00 DEBUG 9436 --- [Spring-Redis-Sentinel] [ioEventLoop-4-1] i.lettuce.core.protocol.CommandHandler   : [channel=0xec254466, /192.168.3.36:49466 -> /192.168.3.150:26379, epid=0x2, chid=0x1] channelInactive() done
2025-08-14T15:51:45.547+08:00 DEBUG 9436 --- [Spring-Redis-Sentinel] [ioEventLoop-4-1] i.l.core.protocol.ConnectionWatchdog     : [channel=0xec254466, /192.168.3.36:49466 -> /192.168.3.150:26379, last known addr=/192.168.3.150:26379] channelInactive()
2025-08-14T15:51:45.547+08:00 DEBUG 9436 --- [Spring-Redis-Sentinel] [ioEventLoop-4-1] i.l.core.protocol.ConnectionWatchdog     : [channel=0xec254466, /192.168.3.36:49466 -> /192.168.3.150:26379, last known addr=/192.168.3.150:26379] Reconnect scheduling disabled
2025-08-14T15:51:45.547+08:00 DEBUG 9436 --- [Spring-Redis-Sentinel] [ioEventLoop-4-1] i.lettuce.core.protocol.CommandHandler   : [channel=0xec254466, /192.168.3.36:49466 -> /192.168.3.150:26379, epid=0x2, chid=0x1] channelUnregistered()
2025-08-14T15:51:47.799+08:00 DEBUG 9436 --- [Spring-Redis-Sentinel] [ioEventLoop-4-2] io.lettuce.core.AbstractRedisClient      : Connecting to Redis at redis-master/<unresolved>:6379: {}

java.net.UnknownHostException: 不知道这样的主机。 (redis-master)
	at java.base/java.net.Inet6AddressImpl.lookupAllHostAddr(Native Method) ~[na:na]
	at java.base/java.net.InetAddress$PlatformNameService.lookupAllHostAddr(InetAddress.java:933) ~[na:na]
	at java.base/java.net.InetAddress.getAddressesFromNameService(InetAddress.java:1543) ~[na:na]
	at java.base/java.net.InetAddress$NameServiceAddresses.get(InetAddress.java:852) ~[na:na]
	at java.base/java.net.InetAddress.getAllByName0(InetAddress.java:1532) ~[na:na]
	at java.base/java.net.InetAddress.getAllByName(InetAddress.java:1384) ~[na:na]
	at java.base/java.net.InetAddress.getAllByName(InetAddress.java:1305) ~[na:na]
	at java.base/java.net.InetAddress.getByName(InetAddress.java:1255) ~[na:na]
	at io.netty.util.internal.SocketUtils$8.run(SocketUtils.java:156) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.util.internal.SocketUtils$8.run(SocketUtils.java:153) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at java.base/java.security.AccessController.doPrivileged(AccessController.java:569) ~[na:na]
	at io.netty.util.internal.SocketUtils.addressByName(SocketUtils.java:153) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.resolver.DefaultNameResolver.doResolve(DefaultNameResolver.java:41) ~[netty-resolver-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.resolver.SimpleNameResolver.resolve(SimpleNameResolver.java:61) ~[netty-resolver-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.resolver.SimpleNameResolver.resolve(SimpleNameResolver.java:53) ~[netty-resolver-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.resolver.InetSocketAddressResolver.doResolve(InetSocketAddressResolver.java:55) ~[netty-resolver-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.resolver.InetSocketAddressResolver.doResolve(InetSocketAddressResolver.java:31) ~[netty-resolver-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.resolver.AbstractAddressResolver.resolve(AbstractAddressResolver.java:106) ~[netty-resolver-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.bootstrap.Bootstrap.doResolveAndConnect0(Bootstrap.java:220) ~[netty-transport-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.bootstrap.Bootstrap.access$000(Bootstrap.java:47) ~[netty-transport-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.bootstrap.Bootstrap$1.operationComplete(Bootstrap.java:189) ~[netty-transport-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.bootstrap.Bootstrap$1.operationComplete(Bootstrap.java:175) ~[netty-transport-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.util.concurrent.DefaultPromise.notifyListener0(DefaultPromise.java:603) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.util.concurrent.DefaultPromise.notifyListenersNow(DefaultPromise.java:570) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.util.concurrent.DefaultPromise.notifyListeners(DefaultPromise.java:505) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.util.concurrent.DefaultPromise.setValue0(DefaultPromise.java:649) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.util.concurrent.DefaultPromise.setSuccess0(DefaultPromise.java:638) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.util.concurrent.DefaultPromise.trySuccess(DefaultPromise.java:118) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.channel.DefaultChannelPromise.trySuccess(DefaultChannelPromise.java:84) ~[netty-transport-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.channel.AbstractChannel$AbstractUnsafe.safeSetSuccess(AbstractChannel.java:988) ~[netty-transport-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.channel.AbstractChannel$AbstractUnsafe.register0(AbstractChannel.java:515) ~[netty-transport-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.channel.AbstractChannel$AbstractUnsafe.access$200(AbstractChannel.java:428) ~[netty-transport-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.channel.AbstractChannel$AbstractUnsafe$1.run(AbstractChannel.java:485) ~[netty-transport-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.util.concurrent.AbstractEventExecutor.runTask(AbstractEventExecutor.java:173) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.util.concurrent.AbstractEventExecutor.safeExecute(AbstractEventExecutor.java:166) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.util.concurrent.SingleThreadEventExecutor.runAllTasks(SingleThreadEventExecutor.java:472) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:569) ~[netty-transport-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:998) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) ~[netty-common-4.1.123.Final.jar:4.1.123.Final]
	at java.base/java.lang.Thread.run(Thread.java:842) ~[na:na]
1. 为什么会这样

Docker 内部可以通过容器名 redis-master 互相访问(因为有自定义网络和 DNS)。

但是你的 Spring Boot 是在宿主机运行(不是在 Docker 内部),宿主机默认并不认识 redis-master 这个名字。

哨兵返回的主节点地址是它内部配置的 redis-master(来自 sentinel.conf 或 docker-compose 服务名),但宿主机解析不了。

2. 解决方案

在SpringBoot 主机 C:\Windows\System32\drivers\etc 加映射

如果 redis-master 容器的 IP 是 192.168.3.150(或者你用的是桥接 IP):

这样宿主机就能解析 redis-master 了。


❓ 七、为什么这样配置

  1. 哨兵模式:自动故障转移,保证高可用
  2. announce-ip 配置 IP:避免容器名解析问题,防止 UnknownHostException
  3. StringRedisTemplate:避免序列化异常,方便开发调试
  4. Docker Compose:快速搭建一主两从 + 三哨兵环境,便于测试

🏁 八、总结

  • Redis Sentinel + Spring Boot 可以轻松实现高可用读写

  • 注意:

    • 哨兵返回 IP 避免主机名解析问题
    • 数据序列化需与存储类型匹配
  • 本方案适合开发、测试和小型生产环境