分布式缓存架构——Redis 集群、哨兵、读写分离的选型与实战
分布式缓存架构——Redis 集群、哨兵、读写分离的选型与实战
适读人群:负责缓存架构设计或优化的 Java 后端开发者 | 阅读时长:约17分钟 | 核心价值:彻底理清 Redis 三种高可用模式的差异,结合业务场景做出正确选型
半夜的 Redis 告警
记得一个冬天的夜晚,临近凌晨两点,我的微信被一条告警炸醒——Redis Master 节点宕机。
我们当时的架构是单机 Redis,加了个 AOF 持久化。Master 宕机后,运维重启了,数据从 AOF 恢复,花了将近 15 分钟。这 15 分钟里,依赖 Redis 的接口全部报错,业务方第二天给我们发了一封措辞强硬的邮件。
从那以后,我开始认真研究 Redis 的高可用方案。踩坑三年,今天把三种方案的原理、选型和坑全部梳理出来。
三种模式核心区别
Redis Sentinel(哨兵):主从复制 + 自动故障转移。有 Master + 若干 Slave,哨兵监控 Master 健康,Master 宕机后自动选举 Slave 晋升。
Redis Cluster(集群):分布式数据分片,将数据分散在多个节点,每个节点负责一部分 Slot(0-16383),每个主节点有若干副本。
读写分离(主从):Master 处理写操作,Slave 处理读操作,适合读多写少的场景。
方案一:Redis Sentinel(哨兵模式)
哨兵是 Redis 官方的高可用解决方案,适合数据量适中、需要自动故障转移的场景。
哨兵工作原理
- 多个哨兵节点监控 Master 和所有 Slave
- 当某个哨兵发现 Master 不可达,发起"主观下线"(SDOWN)
- 哨兵之间互相通信,超过半数哨兵认为 Master 不可达,才变成"客观下线"(ODOWN)
- 哨兵集群选举一个 Leader,负责执行故障转移
- Leader 从 Slave 中选出新 Master,更新配置,通知所有客户端
Java 客户端连接哨兵
@Configuration
public class SentinelRedisConfig {
@Bean
public RedisConnectionFactory sentinelConnectionFactory() {
RedisSentinelConfiguration sentinelConfig =
new RedisSentinelConfiguration()
// Master 名称(与 sentinel.conf 中的 monitor 名称一致)
.master("mymaster")
// 哨兵节点地址
.sentinel("sentinel1", 26379)
.sentinel("sentinel2", 26380)
.sentinel("sentinel3", 26381);
sentinelConfig.setPassword(RedisPassword.of("your-redis-password"));
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofSeconds(2))
.build();
return new LettuceConnectionFactory(sentinelConfig, clientConfig);
}
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}
}哨兵故障转移期间的处理
故障转移期间(通常 10-30 秒),写操作会失败。业务代码必须处理这种情况:
@Service
public class CacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 写缓存(带降级逻辑)
* 哨兵切换期间写缓存失败,降级为直接写 DB,缓存等恢复后再回填
*/
public <T> void setWithFallback(String key, T value, Duration ttl) {
try {
redisTemplate.opsForValue().set(key, value, ttl);
} catch (RedisConnectionFailureException | RedisCommandTimeoutException e) {
log.warn("Redis 写入失败,降级处理。key={}, error={}", key, e.getMessage());
// 记录到"待回填列表",Redis 恢复后重新写入
pendingCacheQueue.add(new CacheEntry(key, value, ttl));
}
}
/**
* 读缓存(带降级逻辑)
*/
public <T> T getWithFallback(String key, Class<T> type,
Supplier<T> dbFallback) {
try {
Object cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return (T) cached;
}
} catch (Exception e) {
log.warn("Redis 读取失败,降级查 DB。key={}", key);
}
// 降级:查数据库
return dbFallback.get();
}
}方案二:Redis Cluster(集群模式)
当数据量超过单机内存(一般 > 20GB),或写入量极大时,需要 Redis Cluster 做水平扩展。
Cluster 核心机制
Redis Cluster 使用 CRC16 算法对 key 取哈希,再对 16384 取模,得到 Slot 编号(0-16383)。
每个主节点负责一部分 Slot,比如:
- Node1: Slot 0-5460
- Node2: Slot 5461-10922
- Node3: Slot 10923-16383
部署配置
# redis.conf(每个节点相同)
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes
# 创建集群(3主3从)
redis-cli --cluster create \
192.168.1.1:7000 192.168.1.2:7000 192.168.1.3:7000 \
192.168.1.4:7000 192.168.1.5:7000 192.168.1.6:7000 \
--cluster-replicas 1Java 连接 Cluster
@Configuration
public class ClusterRedisConfig {
@Bean
public RedisConnectionFactory clusterConnectionFactory() {
RedisClusterConfiguration clusterConfig =
new RedisClusterConfiguration(Arrays.asList(
"192.168.1.1:7000",
"192.168.1.2:7000",
"192.168.1.3:7000"
));
clusterConfig.setMaxRedirects(3); // MOVED 重定向最大次数
// Lettuce 客户端配置(推荐,支持集群拓扑刷新)
ClusterTopologyRefreshOptions topologyRefreshOptions =
ClusterTopologyRefreshOptions.builder()
.enableAdaptiveRefreshTrigger(
RefreshTrigger.MOVED_REDIRECT,
RefreshTrigger.PERSISTENT_RECONNECTS)
.adaptiveRefreshTriggersTimeout(Duration.ofSeconds(30))
.build();
ClusterClientOptions clientOptions = ClusterClientOptions.builder()
.topologyRefreshOptions(topologyRefreshOptions)
.build();
LettuceClientConfiguration lettuceConfig =
LettuceClientConfiguration.builder()
.clientOptions(clientOptions)
.build();
return new LettuceConnectionFactory(clusterConfig, lettuceConfig);
}
}Cluster 的 key 散列标签
Redis Cluster 中,不同 key 可能在不同节点,因此不支持涉及多个 key 的操作(如 mget、pipeline、事务)。
解决方案:散列标签(Hash Tag)——用 {} 包裹 key 的一部分,保证相同标签的 key 在同一个 Slot:
// 同一个订单的多个缓存放到同一个节点
String orderKey = "{order:123456}:info"; // 订单信息
String orderItemsKey = "{order:123456}:items"; // 订单明细
String orderStatusKey = "{order:123456}:status"; // 订单状态
// 现在可以对这三个 key 用 pipeline(都在同一节点)
List<Object> results = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
connection.get(orderKey.getBytes());
connection.get(orderItemsKey.getBytes());
connection.get(orderStatusKey.getBytes());
return null;
});方案三:读写分离
读写分离不是独立的部署模式,而是在主从或集群基础上配置路由策略:写操作走 Master,读操作走 Slave。
适合读写比例 > 5:1,读请求量大的场景:
@Configuration
public class ReadWriteSplitConfig {
@Bean
public LettuceClientConfiguration lettuceClientConfiguration() {
return LettuceClientConfiguration.builder()
// 读策略:优先从 Slave 读,Slave 不可用时从 Master 读
.readFrom(ReadFrom.REPLICA_PREFERRED)
.build();
}
}三大踩坑实录
坑一:哨兵切换后客户端连接没有更新
现象: Master 宕机,哨兵完成故障转移,新 Master 正常了,但应用还是一直报连接失败,必须重启应用才能恢复。
原因: 使用了 Jedis 老版本,哨兵切换后客户端连接池里的连接指向了旧 Master,没有自动刷新。
解法: 升级到 Lettuce 客户端(Spring Boot 2.0+ 默认),Lettuce 原生支持哨兵自动重连。如果必须用 Jedis,用 JedisSentinelPool 而不是 JedisPool。
坑二:Cluster 的 CROSSSLOT 错误
现象: 升级到 Redis Cluster 后,某些功能报错 CROSSSLOT Keys in request don't hash to the same slot,影响了 Session 存储、分布式锁等功能。
原因: 这些功能用了多个 key 的批量操作,而这些 key 分布在不同 Slot。
解法:
- 短期:改用单 key 操作替代批量操作
- 中期:用散列标签保证相关 key 在同一 Slot
- 对于分布式锁:改用 Redisson(自动处理 Cluster 分片)
坑三:读写分离读到旧数据
现象: 写入一条数据后立即读取,结果读到的是旧数据(或 nil),用户反馈"刚改的信息没生效"。
原因: 主从复制是异步的,写入 Master 后,Slave 有几毫秒到几十毫秒的复制延迟。配置了 ReadFrom.REPLICA_PREFERRED 后,读请求走 Slave,可能读到还没同步的旧数据。
解法:
- 对一致性要求高的读操作,强制走 Master(
ReadFrom.MASTER) - 使用注解区分读策略:
@Service
public class UserCacheService {
@Autowired
private RedisTemplate<String, User> masterTemplate; // ReadFrom.MASTER
@Autowired
private RedisTemplate<String, User> replicaTemplate; // ReadFrom.REPLICA_PREFERRED
/**
* 写之后立即读的场景用 master 读
*/
public User getUserImmediatelyAfterUpdate(String userId) {
return (User) masterTemplate.opsForValue().get("user:" + userId);
}
/**
* 普通查询用 replica 读(可以接受稍有延迟)
*/
public User getUserProfile(String userId) {
return (User) replicaTemplate.opsForValue().get("user:" + userId);
}
}选型决策
| 场景 | 推荐方案 |
|---|---|
| 单机数据量 < 20GB,可接受 10-30s 故障转移 | 哨兵 |
| 数据量大,需要水平扩展 | Cluster |
| 读写比 > 5:1,主要读压力大 | 读写分离(+哨兵/Cluster) |
| 超高读写(亿级 QPS) | Cluster + 读写分离 |
我自己的实践:绝大多数业务用哨兵模式,三节点够用,简单稳定。只有数据量超过 30GB 或写入压力超过 10 万/s 才考虑上 Cluster。
