🚀 设计一个分布式缓存系统:闪电侠的秘密!

23 阅读12分钟

📖 开场:图书馆的智慧

想象你在图书馆借书 📚:

没有缓存(每次去仓库)

你:想看《三体》
    ↓
图书管理员:去仓库找
    ↓
走到仓库(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的环上

         03→   |   ←1
         |
    2←   |   →42^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-100100个hash值)
Node2负责:100-200100个hash值)
Node3负责:200-2^32-140亿个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                   │
│    - 自动分片                      │
│    - 自动故障转移                  │
└────────────────────────────────────┘

🎉 恭喜你!

你已经完全掌握了分布式缓存系统的设计!🎊

核心要点

  1. 一致性Hash:虚拟节点,扩容时少量数据迁移
  2. 主从复制:Sentinel自动故障转移
  3. 缓存淘汰:allkeys-lru,LRU算法
  4. 热点数据:多级缓存 + 数据复制
  5. 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⭐!