📖 开场:图书馆的智慧
想象你在图书馆借书 📚:
没有缓存(每次去仓库):
你:想看《三体》
↓
图书管理员:去仓库找
↓
走到仓库(10分钟)🚶
找到书(5分钟)🔍
走回来(10分钟)🚶
↓
总耗时:25分钟 ❌
第二次借:
又去仓库(25分钟)💀
有缓存(热门书放前台):
你:想看《三体》
↓
图书管理员:前台就有!✅
↓
拿书(10秒)⚡
↓
总耗时:10秒 ✅
速度提升:150倍!💪
这就是缓存:把热门数据放在离你最近的地方!
🤔 为什么需要分布式缓存?
场景1:单机缓存的瓶颈 💀
单机Redis:
- 内存:64GB
- QPS:10万
业务增长:
- 数据:200GB(超过单机内存)💀
- QPS:50万(超过单机能力)💀
问题:
- 单机内存不够 ❌
- 单机性能不够 ❌
- 单点故障 ❌
场景2:分布式缓存 ✅
分布式Redis集群:
- 节点数:10台
- 总内存:640GB ✅
- 总QPS:100万 ✅
优势:
- 横向扩展 ✅
- 高可用 ✅
- 负载均衡 ✅
🎯 核心设计
设计1:一致性Hash ⭐⭐⭐
为什么需要一致性Hash?
普通Hash取模:
key → hash(key) % 3 → 节点
例子(3台服务器):
key1 → hash(key1) % 3 = 0 → 节点0
key2 → hash(key2) % 3 = 1 → 节点1
key3 → hash(key3) % 3 = 2 → 节点2
问题:扩容时数据全部失效!💀
扩容到4台:
key1 → hash(key1) % 4 = ? → 可能不是节点0了 ❌
key2 → hash(key2) % 4 = ? → 可能不是节点1了 ❌
key3 → hash(key3) % 4 = ? → 可能不是节点2了 ❌
大量缓存失效!💀
一致性Hash原理
一致性Hash环:
把hash值映射到0 ~ 2^32-1的环上
0
↑
3→ | ←1
|
2← | →4
↓
2^32-1
节点分布:
Node1: hash(node1) = 100
Node2: hash(node2) = 200
Node3: hash(node3) = 300
数据分布:
key1: hash(key1) = 50 → 顺时针找到Node1
key2: hash(key2) = 150 → 顺时针找到Node2
key3: hash(key3) = 250 → 顺时针找到Node3
扩容时(添加Node4,hash=350):
- key1(50) → 还是Node1 ✅
- key2(150) → 还是Node2 ✅
- key3(250) → 还是Node3 ✅
- 新key4(360) → Node4 ✅
只有少量数据迁移!✅
虚拟节点(解决数据倾斜)
问题:节点少时,数据分布不均匀
3个节点:
Node1(100) Node2(200) Node3(300)
↓
Node1负责:0-100(100个hash值)
Node2负责:100-200(100个hash值)
Node3负责:200-2^32-1(40亿个hash值)💀
Node3数据太多!❌
解决:虚拟节点
每个物理节点对应多个虚拟节点(如150个)
Node1:
- Node1-1(hash=100)
- Node1-2(hash=500)
- Node1-3(hash=900)
- ...
- Node1-150(hash=...)
Node2:
- Node2-1(hash=200)
- Node2-2(hash=600)
- Node2-3(hash=1000)
- ...
这样数据分布更均匀!✅
代码实现
@Component
public class ConsistentHash<T> {
// ⭐ Hash环(虚拟节点 → 真实节点)
private final TreeMap<Long, T> ring = new TreeMap<>();
// 虚拟节点数量
private final int virtualNodeCount;
// Hash函数
private final HashFunction hashFunction;
public ConsistentHash(int virtualNodeCount) {
this.virtualNodeCount = virtualNodeCount;
this.hashFunction = Hashing.murmur3_128();
}
/**
* ⭐ 添加节点
*/
public void addNode(T node) {
// 为每个物理节点创建多个虚拟节点
for (int i = 0; i < virtualNodeCount; i++) {
// 虚拟节点名称:node-0, node-1, node-2, ...
String virtualNodeName = node.toString() + "-" + i;
// 计算虚拟节点的hash值
long hash = hashFunction.hashString(virtualNodeName, StandardCharsets.UTF_8)
.asLong();
// 添加到hash环
ring.put(hash, node);
}
System.out.println("⭐ 添加节点:" + node + ",虚拟节点数:" + virtualNodeCount);
}
/**
* ⭐ 移除节点
*/
public void removeNode(T node) {
for (int i = 0; i < virtualNodeCount; i++) {
String virtualNodeName = node.toString() + "-" + i;
long hash = hashFunction.hashString(virtualNodeName, StandardCharsets.UTF_8)
.asLong();
ring.remove(hash);
}
System.out.println("⭐ 移除节点:" + node);
}
/**
* ⭐ 获取key对应的节点
*/
public T getNode(String key) {
if (ring.isEmpty()) {
return null;
}
// 计算key的hash值
long hash = hashFunction.hashString(key, StandardCharsets.UTF_8)
.asLong();
// ⭐ 在hash环上顺时针查找第一个节点
Map.Entry<Long, T> entry = ring.ceilingEntry(hash);
if (entry == null) {
// 没找到,返回第一个节点(环形)
entry = ring.firstEntry();
}
return entry.getValue();
}
}
使用示例:
// 创建一致性Hash(每个节点150个虚拟节点)
ConsistentHash<String> consistentHash = new ConsistentHash<>(150);
// 添加节点
consistentHash.addNode("192.168.1.1:6379");
consistentHash.addNode("192.168.1.2:6379");
consistentHash.addNode("192.168.1.3:6379");
// 获取key对应的节点
String node = consistentHash.getNode("user:123");
System.out.println("user:123 存储在:" + node);
// 扩容(添加新节点)
consistentHash.addNode("192.168.1.4:6379");
// 扩容后,大部分key的节点不变 ✅
String node2 = consistentHash.getNode("user:123");
System.out.println("扩容后 user:123 存储在:" + node2);
设计2:主从复制 🔄
为什么需要主从复制?
单节点Redis:
↓
节点宕机 💀
↓
数据丢失 ❌
服务不可用 ❌
主从复制:
Master(主节点)
↓ 复制
Slave1(从节点1)
Slave2(从节点2)
↓
Master宕机:
↓
Slave1升级为Master ✅
↓
服务继续可用 ✅
Redis主从复制原理
主从复制流程:
1. Slave连接Master
↓
2. Slave发送:SYNC命令
↓
3. Master执行:BGSAVE(后台保存RDB)
↓
4. Master发送:RDB文件 → Slave
↓
5. Slave加载RDB文件
↓
6. 全量同步完成 ✅
↓
7. 增量同步:
Master每次写操作 → 发送给Slave
Redis Sentinel(哨兵)
Sentinel的作用:
1. 监控:监控Master和Slave是否正常
2. 通知:发现故障后通知管理员
3. 故障转移:Master宕机后,自动选举新Master
架构:
Sentinel1
↓
Master ←→ Sentinel2
↓ ↓ ↓
Slave1 Sentinel3
↓
Slave2
故障转移流程:
1. Sentinel检测到Master宕机
2. 多数Sentinel确认Master宕机(投票)
3. 选举一个Slave作为新Master
4. 其他Slave连接新Master
5. 故障转移完成 ✅
配置Redis主从
Master配置:
# redis.conf
bind 0.0.0.0
port 6379
Slave配置:
# redis.conf
bind 0.0.0.0
port 6380
# ⭐ 设置主节点
replicaof 192.168.1.1 6379
# 主节点密码(如果有)
masterauth yourpassword
Sentinel配置:
# sentinel.conf
# ⭐ 监控Master(2表示至少2个Sentinel确认才算宕机)
sentinel monitor mymaster 192.168.1.1 6379 2
# Master密码
sentinel auth-pass mymaster yourpassword
# 多久没响应算宕机(毫秒)
sentinel down-after-milliseconds mymaster 30000
# 故障转移超时时间
sentinel failover-timeout mymaster 180000
设计3:缓存淘汰策略 🗑️
问题
Redis内存:64GB
数据量:100GB 💀
问题:
内存不够,怎么办?
8种淘汰策略
1. noeviction(默认):
内存满了,拒绝写入 ❌
2. allkeys-lru(推荐)⭐:
淘汰最近最少使用的key
3. allkeys-lfu:
淘汰最不经常使用的key
4. allkeys-random:
随机淘汰key
5. volatile-lru:
在设置了过期时间的key中,淘汰最近最少使用的
6. volatile-lfu:
在设置了过期时间的key中,淘汰最不经常使用的
7. volatile-random:
在设置了过期时间的key中,随机淘汰
8. volatile-ttl:
淘汰即将过期的key
LRU算法原理
LRU(Least Recently Used):最近最少使用
数据结构:
双向链表 + HashMap
HashMap
┌─────┬─────┬─────┬─────┐
│key1 │key2 │key3 │key4 │
└──┬──┴──┬──┴──┬──┴──┬──┘
↓ ↓ ↓ ↓
Node1 Node2 Node3 Node4
↕ ↕ ↕ ↕
双向链表(最近使用的在头部)
访问key1:
1. 从HashMap找到Node1
2. 将Node1移到链表头部
内存满了:
1. 删除链表尾部的Node(最久未使用)
2. 从HashMap删除对应的key
配置Redis淘汰策略:
# redis.conf
maxmemory 64gb
maxmemory-policy allkeys-lru
设计4:热点数据问题 🔥
问题
热点数据:
明星出轨新闻 🔥
↓
100万人同时访问
↓
QPS:100万
↓
单个Redis节点扛不住 💀
解决方案1:多级缓存
三级缓存:
1. 本地缓存(应用内存)⚡
- Caffeine/Guava Cache
- 超快(纳秒级)
- 容量小(MB级)
2. 分布式缓存(Redis)🚀
- 毫秒级
- 容量中(GB级)
3. 数据库(MySQL)🐢
- 秒级
- 容量大(TB级)
查询流程:
1. 查询本地缓存
2. 未命中 → 查询Redis
3. 未命中 → 查询MySQL
4. 写入Redis
5. 写入本地缓存
代码实现:
@Service
public class UserService {
// ⭐ 本地缓存(Caffeine)
private final Cache<Long, User> localCache = Caffeine.newBuilder()
.maximumSize(10000) // 最多1万条
.expireAfterWrite(5, TimeUnit.MINUTES) // 5分钟过期
.build();
@Autowired
private RedisTemplate<String, User> redisTemplate;
@Autowired
private UserMapper userMapper;
/**
* ⭐ 获取用户(三级缓存)
*/
public User getUser(Long userId) {
// 1. 查询本地缓存
User user = localCache.getIfPresent(userId);
if (user != null) {
System.out.println("⭐ 本地缓存命中");
return user;
}
// 2. 查询Redis
String key = "user:" + userId;
user = redisTemplate.opsForValue().get(key);
if (user != null) {
System.out.println("⭐ Redis缓存命中");
// 写入本地缓存
localCache.put(userId, user);
return user;
}
// 3. 查询数据库
user = userMapper.selectById(userId);
if (user != null) {
System.out.println("⭐ 数据库查询");
// 写入Redis
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
// 写入本地缓存
localCache.put(userId, user);
}
return user;
}
}
解决方案2:数据复制
热点数据:user:1(明星)
↓
复制多份:
user:1-1 → Redis节点1
user:1-2 → Redis节点2
user:1-3 → Redis节点3
查询时:
随机访问其中一份
↓
负载分散到3个节点 ✅
代码实现:
@Service
public class HotDataService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final int REPLICA_COUNT = 3; // 复制3份
/**
* ⭐ 设置热点数据(复制多份)
*/
public void setHotData(String key, String value) {
for (int i = 0; i < REPLICA_COUNT; i++) {
String replicaKey = key + "-" + i;
redisTemplate.opsForValue().set(replicaKey, value, 30, TimeUnit.MINUTES);
}
}
/**
* ⭐ 获取热点数据(随机访问一份)
*/
public String getHotData(String key) {
int random = ThreadLocalRandom.current().nextInt(REPLICA_COUNT);
String replicaKey = key + "-" + random;
return redisTemplate.opsForValue().get(replicaKey);
}
}
设计5:Redis Cluster ⭐⭐⭐
Redis Cluster架构
Redis Cluster:
- 自动分片(16384个slot)
- 主从复制
- 故障转移
Master1 Master2 Master3
(slot (slot (slot
0-5460) 5461-10922) 10923-16383)
↓ ↓ ↓
Slave1 Slave2 Slave3
数据分片:
key → CRC16(key) % 16384 → slot → Master
例子:
key="user:123"
↓
CRC16("user:123") = 12345
↓
12345 % 16384 = 12345
↓
slot 12345 属于 Master3
↓
存储到 Master3
Redis Cluster配置
节点配置:
# redis.conf
port 7000
cluster-enabled yes
cluster-config-file nodes-7000.conf
cluster-node-timeout 5000
创建集群:
# 启动6个节点(3主3从)
redis-server redis-7000.conf
redis-server redis-7001.conf
redis-server redis-7002.conf
redis-server redis-7003.conf
redis-server redis-7004.conf
redis-server redis-7005.conf
# ⭐ 创建集群
redis-cli --cluster create \
127.0.0.1:7000 \
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 \
--cluster-replicas 1 # 每个主节点1个从节点
Java连接Redis Cluster
@Configuration
public class RedisClusterConfig {
@Bean
public JedisCluster jedisCluster() {
// ⭐ 集群节点
Set<HostAndPort> nodes = new HashSet<>();
nodes.add(new HostAndPort("192.168.1.1", 7000));
nodes.add(new HostAndPort("192.168.1.2", 7001));
nodes.add(new HostAndPort("192.168.1.3", 7002));
// 连接配置
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(100);
poolConfig.setMaxIdle(10);
return new JedisCluster(nodes, 2000, 2000, 5, poolConfig);
}
}
@Service
public class CacheService {
@Autowired
private JedisCluster jedisCluster;
/**
* ⭐ 使用Redis Cluster
*/
public void set(String key, String value) {
// 自动路由到正确的节点
jedisCluster.set(key, value);
}
public String get(String key) {
return jedisCluster.get(key);
}
}
🎓 面试题速答
Q1: 一致性Hash的原理?
A: Hash环 + 虚拟节点:
1. 将hash值映射到0~2^32-1的环上
2. 节点分布在环上(根据hash值)
3. 数据顺时针找到最近的节点
虚拟节点:
每个物理节点对应150个虚拟节点
↓
数据分布更均匀 ✅
扩容时:
只有少量数据迁移 ✅
Q2: Redis主从复制的流程?
A: 全量同步 + 增量同步:
1. Slave连接Master,发送SYNC
2. Master执行BGSAVE,生成RDB
3. Master发送RDB → Slave
4. Slave加载RDB(全量同步)✅
5. Master每次写操作 → 发送给Slave(增量同步)
Q3: Redis缓存淘汰策略?
A: **allkeys-lru(推荐)**⭐:
LRU(Least Recently Used):
淘汰最近最少使用的key
数据结构:双向链表 + HashMap
访问key:移到链表头部
内存满:删除链表尾部的key
配置:
maxmemory 64gb
maxmemory-policy allkeys-lru
Q4: 热点数据如何处理?
A: 多级缓存 + 数据复制:
方案1:多级缓存
本地缓存(纳秒级)→ Redis(毫秒级)→ MySQL(秒级)
方案2:数据复制
user:1-1 → Redis1
user:1-2 → Redis2
user:1-3 → Redis3
↓
随机访问一份,负载分散 ✅
Q5: Redis Cluster如何分片?
A: 16384个slot:
1. key → CRC16(key) % 16384 → slot
2. slot分配给Master节点
3. Master1: slot 0-5460
Master2: slot 5461-10922
Master3: slot 10923-16383
优点:
- 自动分片 ✅
- 自动故障转移 ✅
Q6: 如何保证缓存高可用?
A: 主从复制 + Sentinel/Cluster:
1. 主从复制:数据备份
2. Sentinel:自动故障转移
3. Cluster:分片 + 故障转移
Master宕机:
↓
Sentinel/Cluster自动选举新Master
↓
服务继续可用 ✅
🎬 总结
分布式缓存系统核心
┌────────────────────────────────────┐
│ 1. 一致性Hash ⭐⭐⭐ │
│ - Hash环 + 虚拟节点 │
│ - 扩容时少量数据迁移 │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 2. 主从复制 │
│ - 全量同步 + 增量同步 │
│ - Sentinel故障转移 │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 3. 缓存淘汰(allkeys-lru) │
│ - LRU算法 │
│ - 双向链表 + HashMap │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 4. 热点数据 │
│ - 多级缓存 │
│ - 数据复制 │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 5. Redis Cluster ⭐⭐⭐ │
│ - 16384个slot │
│ - 自动分片 │
│ - 自动故障转移 │
└────────────────────────────────────┘
🎉 恭喜你!
你已经完全掌握了分布式缓存系统的设计!🎊
核心要点:
- 一致性Hash:虚拟节点,扩容时少量数据迁移
- 主从复制:Sentinel自动故障转移
- 缓存淘汰:allkeys-lru,LRU算法
- 热点数据:多级缓存 + 数据复制
- Redis Cluster:16384个slot,自动分片
下次面试,这样回答:
"分布式缓存系统采用一致性Hash实现数据分片。将hash值映射到0~2^32-1的环上,节点和数据都分布在环上。数据顺时针找到最近的节点存储。为解决数据倾斜问题,每个物理节点对应150个虚拟节点,使数据分布更均匀。扩容时,只有少量数据需要迁移,大部分key的节点不变。
高可用通过主从复制实现。Slave连接Master后,Master执行BGSAVE生成RDB快照发送给Slave,完成全量同步。之后每次写操作,Master都会发送给Slave,完成增量同步。使用Sentinel监控集群,Master宕机时,Sentinel自动选举一个Slave升级为新Master,实现故障转移。
缓存淘汰策略使用allkeys-lru。LRU算法通过双向链表和HashMap实现,访问key时将其移到链表头部,内存满时删除链表尾部的key。这样保证淘汰最近最少使用的数据。
热点数据通过多级缓存和数据复制处理。多级缓存包括本地缓存(Caffeine)、Redis、MySQL,依次查询。对于极热的数据,如明星用户信息,复制3份存储到不同Redis节点,查询时随机访问一份,将负载分散到多个节点。
实际项目中使用Redis Cluster。Cluster将数据分为16384个slot,每个Master节点负责一部分slot。写入数据时,根据CRC16(key) % 16384计算slot,自动路由到对应的Master。每个Master配置Slave节点,Master宕机时自动故障转移。"
面试官:👍 "很好!你对分布式缓存的设计理解很深刻!"
本文完 🎬
上一篇: 211-设计一个日志收集和分析系统.md
下一篇: 213-设计一个支付系统.md
作者注:写完这篇,我都想去优化Redis了!🚀
如果这篇文章对你有帮助,请给我一个Star⭐!