Redis集群方案对比:主从、哨兵、Cluster模式的数据一致性分析
Redis集群方案对比:主从、哨兵、Cluster模式的数据一致性分析
适读人群:中高级Java工程师 | 阅读时长:约18分钟 | 技术栈:Spring Boot 3.x、Lettuce、Redis 7.x
开篇故事
2022年某个周二下午,我们的 Redis 哨兵集群发生了一次主从切换。起因是主节点所在的物理机突然发生了磁盘 IO 告警,Redis 响应慢,心跳超时,哨兵集群判定主节点"主观下线",随后触发了"客观下线",开始了 Failover(故障切换)。
Failover 过程持续了约 40 秒。在这 40 秒里:
- 大量写操作失败(主节点不可用,Slave 还没升主)
- Slave 晋升为新主节点后,原主节点上未同步到 Slave 的数据(约 500 条写操作)丢失了
- 客户端的连接还指向旧主节点,重试找到新主节点花了约 20 秒
那次事故直接导致约 500 条用户操作记录丢失,涉及到用户积分更新(非资金类),做了人工补录处理。
那之后我把 Redis 的三种集群方案从原理到生产配置都研究了一遍。
一、三种方案的架构对比
主从复制(Master-Slave)
主从复制是 Redis 高可用的基础。Master 处理所有写操作,将写操作的命令(RDB + AOF)异步复制给 Slave,Slave 只接受读操作。
特点:
- 写操作只能走 Master,读操作可以分担到 Slave(读写分离)
- 主从复制是异步的,Slave 数据落后于 Master(通常在毫秒级)
- 没有自动 Failover,Master 宕机需要人工介入切换
- 适合读多写少、对高可用要求不极致的场景
哨兵模式(Sentinel)
哨兵是在主从复制基础上,加了一层自动故障检测和 Failover 机制。哨兵节点(通常 3 个)监控 Master 和 Slave,当 Master 不可用时,哨兵集群通过投票选出一个 Slave 晋升为新 Master,并通知客户端新的 Master 地址。
特点:
- 自动 Failover,无需人工介入
- Failover 期间有约 30-60 秒的不可用窗口
- 只有一个 Master,写操作性能有上限
- 数据量受单节点内存限制
Cluster 模式
Redis Cluster 将数据分布到多个分片(每个分片是一个主从对),使用一致性哈希的变体(16384 个 slot)来决定每个 key 落在哪个分片。
特点:
- 数据水平扩展,突破单节点内存限制
- 多 Master 并行写入,写性能线性扩展
- 每个分片有独立的主从复制和自动 Failover
- 不支持跨分片的原子操作(MULTI/EXEC 只能在同一个分片内)
- 不支持 select 多数据库(只有 DB0)
二、数据一致性深度分析
主从复制的数据丢失场景
Redis 主从复制是异步的,这意味着 Master 响应客户端 OK 之后,数据不一定已经复制到 Slave。以下场景会导致数据丢失:
场景一:Master 宕机,未同步数据丢失 Master 写入了 100 条数据,只同步了 90 条到 Slave 就宕机了,Slave 晋升后丢失了 10 条数据。开篇故事就是这个场景(丢了约 500 条)。
场景二:脑裂(Split Brain) 网络分区导致原 Master 与哨兵/集群失联,哨兵误判为 Master 宕机并选举了新 Master。此时存在两个 Master,客户端可能继续往原 Master 写数据(如果网络还通的话)。等网络恢复后,原 Master 变成 Slave,所有在"双 Master"期间写入原 Master 的数据都会被丢弃(Slave 要同步新 Master 的数据)。
可以限制的数据丢失量:
# redis.conf 主从复制配置
# Master 要求至少 1 个 Slave 在线才接受写操作
min-replicas-to-write 1
# 要求 Slave 的复制延迟不超过 10 秒
min-replicas-max-lag 10这个配置的含义:如果所有 Slave 的复制延迟都超过 10 秒(说明 Master 可能已经孤立),Master 停止接受写操作,返回错误。这样可以避免脑裂期间数据大量写入即将被丢弃的情况。
代价是:Slave 都挂了或网络全断时,Master 也无法写入,可用性降低。
Cluster 的数据一致性
Cluster 同样是异步复制,每个分片的主从复制延迟问题与单主从模式相同。此外,Cluster 还有一个特殊问题:slot 迁移期间的数据一致性。
当扩容时(新增节点,需要迁移 slot),迁移过程中同一个 key 可能在源节点和目标节点都有数据,客户端的请求可能路由到任一节点,读到不同的值。Cluster 使用 ASKING 命令来解决这个问题,但客户端需要正确处理 MOVED 和 ASK 响应。
三、完整代码实现
哨兵模式配置
spring:
data:
redis:
sentinel:
master: mymaster # 主节点名称(与 sentinel.conf 一致)
nodes:
- 127.0.0.1:26379
- 127.0.0.1:26380
- 127.0.0.1:26381
password: sentinel-password # 哨兵密码
password: redis-password # Redis 密码
lettuce:
pool:
max-active: 64
min-idle: 10
max-wait: 3000ms
# 哨兵模式下,读操作可以走 Slave
sentinel:
read-from: REPLICA_PREFERRED # 优先读 Slave,Slave 不可用则读 MasterCluster 模式配置
spring:
data:
redis:
cluster:
nodes:
- 127.0.0.1:7001
- 127.0.0.1:7002
- 127.0.0.1:7003
- 127.0.0.1:7004
- 127.0.0.1:7005
- 127.0.0.1:7006
max-redirects: 3 # MOVED 重定向最大次数
password: redis-password
lettuce:
pool:
max-active: 64
cluster:
refresh:
adaptive: true # 自适应刷新集群拓扑(推荐)
period: 60s # 定期刷新间隔连接池与故障处理
@Configuration
public class RedisConfig {
@Bean
public LettuceConnectionFactory lettuceConnectionFactory(
RedisProperties redisProperties) {
// Cluster 模式配置
RedisClusterConfiguration clusterConfig =
new RedisClusterConfiguration(redisProperties.getCluster().getNodes());
clusterConfig.setPassword(RedisPassword.of(redisProperties.getPassword()));
clusterConfig.setMaxRedirects(3);
// Lettuce 连接池
GenericObjectPoolConfig<StatefulRedisClusterConnection<String, String>> poolConfig =
new GenericObjectPoolConfig<>();
poolConfig.setMaxTotal(64);
poolConfig.setMinIdle(10);
poolConfig.setMaxWaitMillis(3000);
poolConfig.setTestOnBorrow(true); // 获取连接时校验可用性
LettucePoolingClientConfiguration clientConfig =
LettucePoolingClientConfiguration.builder()
.poolConfig(poolConfig)
.commandTimeout(Duration.ofMillis(3000))
// Cluster 拓扑自动刷新
.clientOptions(ClusterClientOptions.builder()
.topologyRefreshOptions(
ClusterTopologyRefreshOptions.builder()
.enableAdaptiveRefreshTrigger(
ClusterTopologyRefreshOptions.RefreshTrigger.MOVED_REDIRECT,
ClusterTopologyRefreshOptions.RefreshTrigger.PERSISTENT_RECONNECTS)
.adaptiveRefreshTriggersTimeout(Duration.ofSeconds(30))
.enablePeriodicRefresh(Duration.ofMinutes(1))
.build())
.build())
.build();
return new LettuceConnectionFactory(clusterConfig, clientConfig);
}
}Cluster 模式下的多 key 操作
Cluster 模式不支持跨 slot 的多 key 操作(MGET、MSET、MULTI 等),需要特殊处理:
@Service
@Slf4j
public class ClusterSafeRedisService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* Cluster 安全的批量获取(按 slot 分组)
*/
public Map<String, String> mgetSafe(List<String> keys) {
// 按 slot 分组
Map<Integer, List<String>> slotGroups = keys.stream()
.collect(Collectors.groupingBy(key ->
JedisClusterCRC16.getSlot(key) // 计算 slot
));
Map<String, String> result = new HashMap<>();
// 对每个 slot 组单独执行
slotGroups.values().forEach(slotKeys -> {
try {
List<String> values = redisTemplate.opsForValue().multiGet(slotKeys);
if (values != null) {
for (int i = 0; i < slotKeys.size(); i++) {
if (values.get(i) != null) {
result.put(slotKeys.get(i), values.get(i));
}
}
}
} catch (Exception e) {
// 如果还是跨 slot,降级为单个查询
log.warn("批量查询失败,降级为单查,keys={}", slotKeys);
slotKeys.forEach(key -> {
String value = redisTemplate.opsForValue().get(key);
if (value != null) {
result.put(key, value);
}
});
}
});
return result;
}
/**
* 使用 Hash Tag 强制多个 key 落在同一 slot
* Hash Tag:key 中 {} 内的部分决定 slot
* 例如:{user:1}:profile 和 {user:1}:orders 都落在 user:1 对应的 slot
*/
public void setUserData(Long userId, String profile, String orders) {
String profileKey = "{user:" + userId + "}:profile";
String ordersKey = "{user:" + userId + "}:orders";
// 使用 pipeline(同一 slot 可以 pipeline)
redisTemplate.executePipelined((RedisCallback<Void>) connection -> {
connection.set(profileKey.getBytes(), profile.getBytes());
connection.set(ordersKey.getBytes(), orders.getBytes());
return null;
});
}
}主从切换的客户端恢复
@Component
@Slf4j
public class RedisSentinelFailoverHandler {
@Autowired
private LettuceConnectionFactory lettuceConnectionFactory;
/**
* 监听哨兵事件(Lettuce 提供了事件订阅机制)
*/
@PostConstruct
public void registerSentinelListener() {
// Lettuce 的哨兵连接工厂
if (lettuceConnectionFactory.getSentinelConfiguration() != null) {
log.info("注册哨兵事件监听器");
// Lettuce 会自动处理哨兵切换,这里主要做监控上报
}
}
/**
* 带重试的 Redis 操作(应对主从切换期间的短暂不可用)
*/
@Retryable(
retryFor = {RedisConnectionFailureException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 500, multiplier = 2)
)
public String getWithRetry(String key) {
return redisTemplate().opsForValue().get(key);
}
@Recover
public String getRecovery(RedisConnectionFailureException e, String key) {
log.error("Redis 连接失败,已达最大重试次数,key={}", key, e);
return null; // 降级返回 null,业务层处理
}
@Autowired
private StringRedisTemplate redisTemplate;
private StringRedisTemplate redisTemplate() {
return redisTemplate;
}
}四、生产选型建议
三种方案选型矩阵
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 单机开发 | 单节点 | 简单 |
| 读写比 7:3,数据 < 单节点上限 | 哨兵 | 自动 Failover,读写分离 |
| 数据量大(> 单节点上限) | Cluster | 水平扩展 |
| 对数据丢失零容忍 | 不能只依赖 Redis | 配合 DB 持久化 |
| 跨机房 | 哨兵(机房内)+ 跨机房同步 | Cluster 跨机房延迟高 |
数据丢失容忍度
不论哪种方案,Redis 的异步复制本质上无法完全避免数据丢失(除非配置 wait 命令要求同步复制,但会大幅降低性能)。
生产建议:Redis 作为缓存时,可以接受数据丢失(重启后从 DB 重建即可)。Redis 作为持久化存储时(存储 Session、分布式锁等),需要配置 AOF 持久化 + min-replicas-to-write,并接受一定的可用性代价。
五、踩坑实录
坑一:开篇哨兵切换数据丢失的复盘
改进措施:
- 添加
min-replicas-to-write 1+min-replicas-max-lag 10,限制 Master 孤立时继续写入。 - 对积分这类不能丢的业务数据,改用数据库存储,Redis 只做缓存加速。
- 监控 Redis 主从同步延迟(
repl_backlog_active、repl_offset的差值),超过 5 秒报警。
坑二:Cluster 的 MOVED 重定向在压测时引发大量重连
压测时,Cluster 中的一个节点因为 CPU 过高,响应慢,Lettuce 发现 key 路由到这个节点时,命令超时,触发了大量 MOVED 重定向。重定向期间,Lettuce 需要重新获取集群拓扑信息,消耗了大量时间,压测的 P99 从 5ms 飙到了 3 秒。
解决方案:配置自适应刷新(enableAdaptiveRefreshTrigger),让 Lettuce 在发生 MOVED 时立刻刷新拓扑,而不是等周期刷新。
坑三:哨兵集群只有两个节点时的脑裂
有一次网络故障,哨兵集群只有两个节点(原来是三个,一个出故障了),两个哨兵对 Master 是否下线的投票是 1:1,无法达成"客观下线"所需的 majority(2/3),结果 Failover 没有触发,但 Master 的响应也很慢(磁盘 IO 问题),客户端大量超时。
哨兵集群必须是奇数个节点(3、5、7),且节点数不低于 3,这样才能在一个节点故障时还能达成多数投票。
六、总结
Redis 三种集群方案的本质差异:
主从:提供高性能(读写分离),无高可用(Master 宕机需人工切换)。 哨兵:在主从基础上加了自动 Failover,适合绝大多数生产场景(数据量 < 单节点上限)。 Cluster:解决了数据量超过单节点上限的问题,代价是部分操作受限(跨 slot)和运维复杂度增加。
无论哪种方案,都要接受一个事实:Redis 不是强一致的持久化存储,异步复制意味着总有丢数据的可能。重要数据的最终权威来源应该是关系型数据库,Redis 只是加速缓存。
