一、引子:一个用户会话缓存的演进之路

假设你正在开发一个电商网站的用户会话管理功能。每次用户请求都需要验证身份,最初的实现是每次都查询数据库,但随着用户量增长,数据库压力越来越大。让我们看看这个功能如何一步步演进,从最简单的HashMap到最终的Redis分布式缓存。

1.1 场景0:无缓存(每次查数据库)

最直接的实现:每次请求都查询数据库验证用户身份。

@RestController
public class UserController {

    @Autowired
    private UserRepository userRepository;

    /**
     * 获取用户信息(每次查数据库)
     * 问题:数据库压力大,响应慢
     */
    @GetMapping("/api/user/info")
    public UserVO getUserInfo(@RequestHeader("token") String token) {
        // 1. 根据token查询用户ID(查数据库)
        Long userId = tokenRepository.findUserIdByToken(token);
        if (userId == null) {
            throw new UnauthorizedException("未登录");
        }

        // 2. 查询用户详细信息(查数据库)
        User user = userRepository.findById(userId);
        if (user == null) {
            throw new UserNotFoundException("用户不存在");
        }

        return convertToVO(user);
    }
}

性能数据

指标数值说明
平均响应时间50ms2次SQL查询
QPS上限1000数据库连接池限制
数据库压力100%每次请求都查库

问题

  1. 每次请求都查数据库,数据库压力大
  2. 响应慢(50ms),用户体验差
  3. QPS受限于数据库性能

1.2 场景1:HashMap本地缓存

为了减轻数据库压力,我们引入最简单的缓存:HashMap。

@RestController
public class UserController {

    @Autowired
    private UserRepository userRepository;

    // HashMap本地缓存
    private Map<String, UserVO> cache = new HashMap<>();

    /**
     * 获取用户信息(HashMap缓存)
     * 问题:无过期、无淘汰、线程不安全
     */
    @GetMapping("/api/user/info")
    public UserVO getUserInfo(@RequestHeader("token") String token) {
        // 1. 先查缓存
        UserVO cached = cache.get(token);
        if (cached != null) {
            return cached;
        }

        // 2. 缓存未命中,查数据库
        Long userId = tokenRepository.findUserIdByToken(token);
        if (userId == null) {
            throw new UnauthorizedException("未登录");
        }

        User user = userRepository.findById(userId);
        if (user == null) {
            throw new UserNotFoundException("用户不存在");
        }

        UserVO vo = convertToVO(user);

        // 3. 写入缓存
        cache.put(token, vo);

        return vo;
    }

    /**
     * 用户登出时删除缓存
     */
    @PostMapping("/api/user/logout")
    public void logout(@RequestHeader("token") String token) {
        cache.remove(token);
        tokenRepository.deleteByToken(token);
    }
}

性能提升

指标无缓存HashMap缓存提升
平均响应时间50ms1ms(命中)50倍
QPS上限10005000050倍
数据库压力100%5%(命中率95%)20倍降低

致命问题

// 问题1:无过期机制(内存泄漏)
// 用户登出后,如果忘记删除缓存,token会永久存在
cache.put(token, user);  // 永不过期,内存泄漏

// 问题2:无淘汰策略(内存溢出)
// 缓存无限增长,最终OOM
for (int i = 0; i < 1000000; i++) {
    cache.put("token" + i, user);  // 内存溢出
}

// 问题3:线程不安全(数据错乱)
// 多线程并发写入,可能导致数据不一致
Thread 1: cache.put("token1", user1);
Thread 2: cache.put("token1", user2);  // 覆盖了user1

// 问题4:无统计信息(无法监控)
// 无法知道命中率、缓存大小等信息

1.3 场景2:ConcurrentHashMap(线程安全)

为了解决线程安全问题,我们使用ConcurrentHashMap。

@RestController
public class UserController {

    @Autowired
    private UserRepository userRepository;

    // ConcurrentHashMap线程安全
    private Map<String, UserVO> cache = new ConcurrentHashMap<>();

    @GetMapping("/api/user/info")
    public UserVO getUserInfo(@RequestHeader("token") String token) {
        // 1. 先查缓存(线程安全)
        UserVO cached = cache.get(token);
        if (cached != null) {
            return cached;
        }

        // 2. 缓存未命中,查数据库
        Long userId = tokenRepository.findUserIdByToken(token);
        if (userId == null) {
            throw new UnauthorizedException("未登录");
        }

        User user = userRepository.findById(userId);
        if (user == null) {
            throw new UserNotFoundException("用户不存在");
        }

        UserVO vo = convertToVO(user);

        // 3. 写入缓存(线程安全)
        cache.put(token, vo);

        return vo;
    }
}

解决的问题

  • ✅ 线程安全(使用分段锁,并发性能好)

仍然存在的问题

  • ❌ 无过期机制
  • ❌ 无淘汰策略
  • ❌ 无统计信息

1.4 场景3:Guava Cache(自动过期+淘汰)

为了解决过期和淘汰问题,我们引入Guava Cache。

@Service
public class UserCacheService {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private TokenRepository tokenRepository;

    /**
     * Guava Cache配置
     * - 最大容量:100万用户
     * - 过期时间:30分钟
     * - 淘汰策略:LRU
     * - 统计信息:命中率
     */
    private LoadingCache<String, UserVO> cache = CacheBuilder.newBuilder()
        .maximumSize(1000000)  // 最大容量100万
        .expireAfterWrite(30, TimeUnit.MINUTES)  // 写入30分钟后过期
        .recordStats()  // 记录统计信息(命中率)
        .removalListener(notification -> {
            // 缓存被删除时的回调
            log.info("缓存被删除:key={}, cause={}",
                notification.getKey(), notification.getCause());
        })
        .build(new CacheLoader<String, UserVO>() {
            @Override
            public UserVO load(String token) throws Exception {
                return loadUserFromDB(token);
            }
        });

    /**
     * 获取用户信息(Guava Cache)
     */
    public UserVO getUserInfo(String token) {
        try {
            return cache.get(token);
        } catch (ExecutionException e) {
            throw new RuntimeException("查询用户失败", e);
        }
    }

    /**
     * 从数据库加载用户
     */
    private UserVO loadUserFromDB(String token) {
        Long userId = tokenRepository.findUserIdByToken(token);
        if (userId == null) {
            throw new UnauthorizedException("未登录");
        }

        User user = userRepository.findById(userId);
        if (user == null) {
            throw new UserNotFoundException("用户不存在");
        }

        return convertToVO(user);
    }

    /**
     * 用户登出时手动删除缓存
     */
    public void logout(String token) {
        cache.invalidate(token);
        tokenRepository.deleteByToken(token);
    }

    /**
     * 获取缓存统计信息
     */
    public CacheStats getStats() {
        return cache.stats();
    }
}

性能数据

// 缓存统计信息
CacheStats stats = userCacheService.getStats();
System.out.println("命中次数:" + stats.hitCount());        // 950000
System.out.println("未命中次数:" + stats.missCount());      // 50000
System.out.println("命中率:" + stats.hitRate());           // 0.95(95%)
System.out.println("加载次数:" + stats.loadCount());       // 50000
System.out.println("平均加载时间:" + stats.averageLoadPenalty() / 1000000 + "ms");  // 50ms

解决的问题

  • ✅ 自动过期(expireAfterWrite、expireAfterAccess)
  • ✅ 自动淘汰(LRU,基于最大容量)
  • ✅ 线程安全(内部使用ConcurrentHashMap)
  • ✅ 统计信息(命中率、加载时间)
  • ✅ 自动加载(CacheLoader)

仍然存在的问题

  • ❌ 单机缓存,多台服务器无法共享
  • ❌ 数据不一致(服务器A更新了,服务器B不知道)
  • ❌ 内存浪费(每台服务器都缓存相同数据)

1.5 场景4:Redis(分布式缓存)

为了解决多台服务器之间的数据共享问题,我们引入Redis。

@Service
public class UserCacheService {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private TokenRepository tokenRepository;

    @Autowired
    private RedisTemplate<String, UserVO> redisTemplate;

    /**
     * 获取用户信息(Redis缓存)
     */
    public UserVO getUserInfo(String token) {
        String cacheKey = "user:token:" + token;

        // 1. 先查Redis
        UserVO cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;
        }

        // 2. Redis未命中,查数据库(加分布式锁防止缓存击穿)
        String lockKey = "lock:user:" + token;
        try {
            Boolean locked = redisTemplate.opsForValue().setIfAbsent(
                lockKey, "1", 10, TimeUnit.SECONDS
            );

            if (Boolean.TRUE.equals(locked)) {
                // 双重检查
                cached = redisTemplate.opsForValue().get(cacheKey);
                if (cached != null) {
                    return cached;
                }

                // 查数据库
                UserVO vo = loadUserFromDB(token);

                // 写入Redis(30分钟过期)
                redisTemplate.opsForValue().set(cacheKey, vo, 30, TimeUnit.MINUTES);

                return vo;
            } else {
                // 获取锁失败,等待后重试
                Thread.sleep(100);
                return getUserInfo(token);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("获取用户信息失败", e);
        } finally {
            redisTemplate.delete(lockKey);
        }
    }

    /**
     * 用户登出时删除Redis缓存(所有服务器都会感知到)
     */
    public void logout(String token) {
        String cacheKey = "user:token:" + token;
        redisTemplate.delete(cacheKey);
        tokenRepository.deleteByToken(token);
    }

    /**
     * 从数据库加载用户
     */
    private UserVO loadUserFromDB(String token) {
        Long userId = tokenRepository.findUserIdByToken(token);
        if (userId == null) {
            throw new UnauthorizedException("未登录");
        }

        User user = userRepository.findById(userId);
        if (user == null) {
            throw new UserNotFoundException("用户不存在");
        }

        return convertToVO(user);
    }
}

性能对比总结

方案响应时间QPS数据一致性过期机制淘汰策略统计信息分布式
无缓存50ms1000✅ 强一致N/AN/AN/A
HashMap1ms50000✅ 强一致
ConcurrentHashMap1ms50000✅ 强一致
Guava Cache1ms50000❌ 不一致
Redis2ms100000✅ 强一致

核心结论

  1. HashMap → ConcurrentHashMap:解决线程安全问题
  2. ConcurrentHashMap → Guava Cache:增加过期、淘汰、统计功能
  3. Guava Cache → Redis:解决分布式一致性问题

二、手写LRU缓存:理解缓存淘汰的本质

在深入理解Redis之前,让我们先手写一个LRU(Least Recently Used,最近最少使用)缓存,理解缓存淘汰的本质。

2.1 LRU算法原理

LRU算法:当缓存满了,淘汰最近最少使用的数据。

核心思想

  • 如果数据最近被访问过,那么将来被访问的概率也高
  • 如果数据很久没被访问,那么将来被访问的概率也低

数据结构:双向链表 + 哈希表

哈希表:O(1)查找
双向链表:O(1)移动节点到头部、O(1)删除尾部节点

缓存结构:
  head → [key1, value1] ⇄ [key2, value2] ⇄ [key3, value3] ← tail
         (最近使用)                              (最久未使用)

操作:
1. get(key):
   - 如果key存在,将节点移到head(标记为最近使用)
   - 如果key不存在,返回null

2. put(key, value):
   - 如果key存在,更新value,将节点移到head
   - 如果key不存在:
     - 如果缓存未满,插入新节点到head
     - 如果缓存已满,删除tail节点,插入新节点到head

2.2 手写LRU缓存(200行代码)

/**
 * 手写LRU缓存
 * @param <K> 键类型
 * @param <V> 值类型
 */
public class LRUCache<K, V> {

    /**
     * 双向链表节点
     */
    private static class Node<K, V> {
        K key;
        V value;
        Node<K, V> prev;
        Node<K, V> next;

        Node(K key, V value) {
            this.key = key;
            this.value = value;
        }
    }

    private final int capacity;  // 最大容量
    private final Map<K, Node<K, V>> map;  // 哈希表
    private final Node<K, V> head;  // 虚拟头节点
    private final Node<K, V> tail;  // 虚拟尾节点

    public LRUCache(int capacity) {
        if (capacity <= 0) {
            throw new IllegalArgumentException("容量必须大于0");
        }
        this.capacity = capacity;
        this.map = new HashMap<>(capacity);

        // 初始化虚拟头尾节点
        this.head = new Node<>(null, null);
        this.tail = new Node<>(null, null);
        head.next = tail;
        tail.prev = head;
    }

    /**
     * 获取缓存值
     * @param key 键
     * @return 值,不存在返回null
     */
    public V get(K key) {
        Node<K, V> node = map.get(key);
        if (node == null) {
            return null;
        }

        // 将节点移到头部(标记为最近使用)
        moveToHead(node);

        return node.value;
    }

    /**
     * 放入缓存
     * @param key 键
     * @param value 值
     */
    public void put(K key, V value) {
        Node<K, V> node = map.get(key);

        if (node != null) {
            // key已存在,更新value
            node.value = value;
            moveToHead(node);
        } else {
            // key不存在,插入新节点
            Node<K, V> newNode = new Node<>(key, value);

            if (map.size() >= capacity) {
                // 缓存已满,删除尾部节点(最久未使用)
                Node<K, V> tailNode = removeTail();
                map.remove(tailNode.key);
            }

            // 插入新节点到头部
            addToHead(newNode);
            map.put(key, newNode);
        }
    }

    /**
     * 删除缓存
     * @param key 键
     */
    public void remove(K key) {
        Node<K, V> node = map.get(key);
        if (node != null) {
            removeNode(node);
            map.remove(key);
        }
    }

    /**
     * 获取缓存大小
     */
    public int size() {
        return map.size();
    }

    /**
     * 清空缓存
     */
    public void clear() {
        map.clear();
        head.next = tail;
        tail.prev = head;
    }

    /**
     * 将节点移到头部
     */
    private void moveToHead(Node<K, V> node) {
        removeNode(node);
        addToHead(node);
    }

    /**
     * 将节点添加到头部
     */
    private void addToHead(Node<K, V> node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }

    /**
     * 删除节点
     */
    private void removeNode(Node<K, V> node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    /**
     * 删除尾部节点
     */
    private Node<K, V> removeTail() {
        Node<K, V> node = tail.prev;
        removeNode(node);
        return node;
    }

    /**
     * 打印缓存(从头到尾)
     */
    public void print() {
        StringBuilder sb = new StringBuilder("LRU Cache: [");
        Node<K, V> current = head.next;
        while (current != tail) {
            sb.append(current.key).append("=").append(current.value);
            if (current.next != tail) {
                sb.append(", ");
            }
            current = current.next;
        }
        sb.append("]");
        System.out.println(sb);
    }
}

2.3 测试LRU缓存

public class LRUCacheTest {

    public static void main(String[] args) {
        // 创建容量为3的LRU缓存
        LRUCache<Integer, String> cache = new LRUCache<>(3);

        System.out.println("=== 测试1:插入数据 ===");
        cache.put(1, "A");
        cache.print();  // [1=A]

        cache.put(2, "B");
        cache.print();  // [2=B, 1=A]

        cache.put(3, "C");
        cache.print();  // [3=C, 2=B, 1=A]

        System.out.println("\n=== 测试2:缓存已满,插入新数据,淘汰最久未使用的 ===");
        cache.put(4, "D");  // 淘汰key=1
        cache.print();  // [4=D, 3=C, 2=B]

        System.out.println("\n=== 测试3:访问数据,将其移到头部 ===");
        String value = cache.get(2);  // 访问key=2
        System.out.println("get(2) = " + value);
        cache.print();  // [2=B, 4=D, 3=C]

        System.out.println("\n=== 测试4:插入新数据,淘汰最久未使用的 ===");
        cache.put(5, "E");  // 淘汰key=3(最久未使用)
        cache.print();  // [5=E, 2=B, 4=D]

        System.out.println("\n=== 测试5:更新已存在的key ===");
        cache.put(2, "B_new");  // 更新key=2
        cache.print();  // [2=B_new, 5=E, 4=D]

        System.out.println("\n=== 测试6:删除缓存 ===");
        cache.remove(5);
        cache.print();  // [2=B_new, 4=D]
    }
}

输出结果

=== 测试1:插入数据 ===
LRU Cache: [1=A]
LRU Cache: [2=B, 1=A]
LRU Cache: [3=C, 2=B, 1=A]

=== 测试2:缓存已满,插入新数据,淘汰最久未使用的 ===
LRU Cache: [4=D, 3=C, 2=B]

=== 测试3:访问数据,将其移到头部 ===
get(2) = B
LRU Cache: [2=B, 4=D, 3=C]

=== 测试4:插入新数据,淘汰最久未使用的 ===
LRU Cache: [5=E, 2=B, 4=D]

=== 测试5:更新已存在的key ===
LRU Cache: [2=B_new, 5=E, 4=D]

=== 测试6:删除缓存 ===
LRU Cache: [2=B_new, 4=D]

时间复杂度分析

操作时间复杂度说明
get(key)O(1)哈希表查找 + 双向链表移动节点
put(key, value)O(1)哈希表插入 + 双向链表插入/删除
remove(key)O(1)哈希表删除 + 双向链表删除

空间复杂度:O(capacity)


三、Redis深度剖析:为什么这么快?

3.1 Redis单线程模型

很多人疑惑:Redis是单线程的,为什么还这么快?

核心原因

1. 纯内存操作(最核心)
   - 数据存储在内存,访问速度100ns
   - 避免磁盘IO(数据库需要10ms+)

2. 单线程避免了多线程竞争
   - 无需加锁,无上下文切换开销
   - 简单高效的事件驱动模型

3. IO多路复用(epoll)
   - 单线程监听多个客户端连接
   - 避免了为每个连接创建线程的开销

4. 高效的数据结构
   - SDS(Simple Dynamic String):比C字符串更高效
   - ziplist:压缩列表,节省内存
   - skiplist:跳表,O(logN)查找

5. 优化的底层实现
   - 渐进式rehash:避免阻塞
   - 惰性删除:避免阻塞
   - 内存分配器优化(jemalloc)

3.2 Redis单线程模型详解

Redis单线程模型:

  ┌─────────────────────────────────────────┐
  │         Redis Server(单线程)           │
  │                                         │
  │  ┌────────────────────────────────┐   │
  │  │  Event Loop(事件循环)         │   │
  │  │                                 │   │
  │  │  1. epoll_wait()                │   │
  │  │     等待客户端请求(阻塞)       │   │
  │  │                                 │   │
  │  │  2. 处理文件事件(读取命令)     │   │
  │  │     read() → 读取客户端请求     │   │
  │  │                                 │   │
  │  │  3. 执行命令(内存操作)         │   │
  │  │     GET/SET/INCR...            │   │
  │  │                                 │   │
  │  │  4. 返回结果(写入响应)         │   │
  │  │     write() → 写入响应到客户端  │   │
  │  │                                 │   │
  │  │  5. 处理时间事件(后台任务)     │   │
  │  │     过期key删除、AOF刷盘...     │   │
  │  │                                 │   │
  │  │  6. 回到步骤1                   │   │
  │  └────────────────────────────────┘   │
  │                                         │
  └─────────────────────────────────────────┘

关键点:
1. 单线程串行处理所有命令(Redis 6.0之前)
2. IO多路复用监听多个客户端连接
3. 内存操作极快,单线程足够

3.3 IO多路复用(epoll)

传统多线程模型

// 为每个客户端创建一个线程(开销大)
while (true) {
    Socket client = serverSocket.accept();  // 阻塞等待连接
    new Thread(() -> {
        // 处理客户端请求
        while (true) {
            String request = client.read();  // 阻塞读取
            String response = process(request);
            client.write(response);
        }
    }).start();
}

问题
├─ 线程创建开销大1MB栈空间
├─ 线程切换开销大上下文切换
├─ 线程数量有限最多几千个
└─ 大量线程阻塞在read()上浪费

Redis的IO多路复用模型

// 伪代码:Redis的事件循环
while (true) {
    // 1. epoll_wait():监听所有客户端连接
    //    返回有数据可读的连接列表
    List<Socket> readySockets = epoll_wait(sockets);

    // 2. 遍历所有有数据可读的连接
    for (Socket socket : readySockets) {
        // 3. 读取命令(非阻塞)
        String command = socket.read();

        // 4. 执行命令(内存操作)
        String result = execute(command);

        // 5. 写入响应(非阻塞)
        socket.write(result);
    }

    // 6. 处理时间事件(后台任务)
    processTimeEvents();
}

IO多路复用的优势

维度传统多线程模型IO多路复用差异
线程数量N个客户端 = N个线程1个线程监听N个客户端N倍节省
内存占用N × 1MB = N MB几MB数百倍节省
上下文切换频繁切换无切换性能提升
连接数上限几千个几万个10倍提升

3.4 Redis 6.0多线程IO

Redis 6.0引入了多线程IO,但命令执行仍然是单线程

Redis 6.0多线程模型:

  ┌─────────────────────────────────────────┐
  │         Redis Server(多线程)           │
  │                                         │
  │  ┌─────────────┐                       │
  │  │  IO Thread 1 │ ← 读取客户端请求       │
  │  │  IO Thread 2 │ ← 读取客户端请求       │
  │  │  IO Thread 3 │ ← 读取客户端请求       │
  │  └─────────────┘                       │
  │         ↓                               │
  │  ┌─────────────┐                       │
  │  │  Main Thread │ ← 执行命令(单线程)   │
  │  └─────────────┘                       │
  │         ↓                               │
  │  ┌─────────────┐                       │
  │  │  IO Thread 1 │ ← 写入响应到客户端     │
  │  │  IO Thread 2 │ ← 写入响应到客户端     │
  │  │  IO Thread 3 │ ← 写入响应到客户端     │
  │  └─────────────┘                       │
  │                                         │
  └─────────────────────────────────────────┘

关键点:
1. 多线程只用于网络IO(读取请求、写入响应)
2. 命令执行仍然是单线程(保证原子性)
3. 提升网络IO性能(从5万QPS提升到10万QPS)

为什么命令执行仍然是单线程?

原因1:保证原子性
  INCR key  # 原子操作
  如果多线程,需要加锁,反而更慢

原因2:避免竞争
  多线程需要处理锁竞争、死锁等问题
  单线程简单高效

原因3:内存操作足够快
  内存访问100ns,单线程每秒可以执行1000万次操作
  瓶颈不在CPU,而在网络IO

结论:
  Redis 6.0多线程IO解决了网络IO瓶颈
  但命令执行仍然单线程,保证简单高效

四、Redis客户端协议(RESP)

4.1 RESP协议详解

RESP(REdis Serialization Protocol)是Redis客户端和服务器之间的通信协议,设计简单高效。

RESP数据类型

类型前缀示例说明
简单字符串++OK\r\n成功响应
错误--ERR unknown command\r\n错误响应
整数::123\r\n整数值
批量字符串$$5\r\nhello\r\n字符串(长度前缀)
数组**2\r\n$3\r\nGET\r\n$3\r\nkey\r\n命令数组

示例

# 客户端发送:GET mykey
*2\r\n          # 数组,2个元素
$3\r\n          # 批量字符串,长度3
GET\r\n         # "GET"
$5\r\n          # 批量字符串,长度5
mykey\r\n       # "mykey"

# 服务器响应:myvalue
$7\r\n          # 批量字符串,长度7
myvalue\r\n     # "myvalue"

# 服务器响应:key不存在
$-1\r\n         # NULL(长度-1)

# 服务器响应:错误
-ERR unknown command 'GETT'\r\n

4.2 手写RESP协议解析器

/**
 * RESP协议解析器
 */
public class RESPParser {

    /**
     * 编码命令
     * @param args 命令参数
     * @return RESP格式字符串
     */
    public static String encodeCommand(String... args) {
        StringBuilder sb = new StringBuilder();

        // 数组标记 + 元素数量
        sb.append("*").append(args.length).append("\r\n");

        // 每个参数
        for (String arg : args) {
            // 批量字符串标记 + 长度
            sb.append("$").append(arg.length()).append("\r\n");
            // 字符串内容
            sb.append(arg).append("\r\n");
        }

        return sb.toString();
    }

    /**
     * 解析响应
     * @param response RESP格式字符串
     * @return 解析结果
     */
    public static Object parseResponse(String response) {
        if (response == null || response.isEmpty()) {
            return null;
        }

        char type = response.charAt(0);

        switch (type) {
            case '+':  // 简单字符串
                return response.substring(1, response.length() - 2);

            case '-':  // 错误
                throw new RuntimeException(response.substring(1, response.length() - 2));

            case ':':  // 整数
                return Long.parseLong(response.substring(1, response.length() - 2));

            case '$':  // 批量字符串
                int len = Integer.parseInt(
                    response.substring(1, response.indexOf("\r\n"))
                );
                if (len == -1) {
                    return null;  // NULL
                }
                int start = response.indexOf("\r\n") + 2;
                return response.substring(start, start + len);

            case '*':  // 数组
                int count = Integer.parseInt(
                    response.substring(1, response.indexOf("\r\n"))
                );
                List<Object> list = new ArrayList<>(count);
                // 递归解析每个元素
                String remaining = response.substring(response.indexOf("\r\n") + 2);
                for (int i = 0; i < count; i++) {
                    Object element = parseResponse(remaining);
                    list.add(element);
                    // 移动到下一个元素
                    remaining = remaining.substring(remaining.indexOf("\r\n") + 2);
                }
                return list;

            default:
                throw new RuntimeException("未知的RESP类型:" + type);
        }
    }

    /**
     * 测试
     */
    public static void main(String[] args) {
        // 测试编码
        System.out.println("=== 测试编码 ===");
        String command = encodeCommand("GET", "mykey");
        System.out.println(command);
        // 输出:
        // *2
        // $3
        // GET
        // $5
        // mykey

        // 测试解析
        System.out.println("\n=== 测试解析 ===");

        // 简单字符串
        String response1 = "+OK\r\n";
        System.out.println("简单字符串:" + parseResponse(response1));  // OK

        // 整数
        String response2 = ":123\r\n";
        System.out.println("整数:" + parseResponse(response2));  // 123

        // 批量字符串
        String response3 = "$5\r\nhello\r\n";
        System.out.println("批量字符串:" + parseResponse(response3));  // hello

        // NULL
        String response4 = "$-1\r\n";
        System.out.println("NULL:" + parseResponse(response4));  // null

        // 错误
        try {
            String response5 = "-ERR unknown command\r\n";
            parseResponse(response5);
        } catch (RuntimeException e) {
            System.out.println("错误:" + e.getMessage());  // ERR unknown command
        }
    }
}

4.3 简易Redis客户端实现

/**
 * 简易Redis客户端
 */
public class SimpleRedisClient {

    private Socket socket;
    private OutputStream out;
    private InputStream in;

    /**
     * 连接Redis服务器
     */
    public void connect(String host, int port) throws IOException {
        socket = new Socket(host, port);
        out = socket.getOutputStream();
        in = socket.getInputStream();
    }

    /**
     * 执行命令
     * @param args 命令参数
     * @return 响应结果
     */
    public Object execute(String... args) throws IOException {
        // 1. 编码命令
        String command = RESPParser.encodeCommand(args);

        // 2. 发送命令
        out.write(command.getBytes());
        out.flush();

        // 3. 读取响应
        byte[] buffer = new byte[1024];
        int len = in.read(buffer);
        String response = new String(buffer, 0, len);

        // 4. 解析响应
        return RESPParser.parseResponse(response);
    }

    /**
     * 关闭连接
     */
    public void close() throws IOException {
        if (socket != null) {
            socket.close();
        }
    }

    /**
     * 测试
     */
    public static void main(String[] args) throws IOException {
        SimpleRedisClient client = new SimpleRedisClient();

        try {
            // 连接Redis
            client.connect("localhost", 6379);

            // SET命令
            Object result1 = client.execute("SET", "mykey", "myvalue");
            System.out.println("SET: " + result1);  // OK

            // GET命令
            Object result2 = client.execute("GET", "mykey");
            System.out.println("GET: " + result2);  // myvalue

            // INCR命令
            Object result3 = client.execute("INCR", "counter");
            System.out.println("INCR: " + result3);  // 1

            // DEL命令
            Object result4 = client.execute("DEL", "mykey");
            System.out.println("DEL: " + result4);  // 1

        } finally {
            client.close();
        }
    }
}

五、缓存更新策略:Cache Aside vs Read/Write Through vs Write Behind

5.1 Cache Aside(旁路缓存)

最常用的模式(90%场景)

读流程

public UserVO getUserInfo(Long userId) {
    String cacheKey = "user:info:" + userId;

    // 1. 先查缓存
    UserVO cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        return cached;  // 缓存命中
    }

    // 2. 缓存未命中,查数据库
    User user = userRepository.findById(userId);
    if (user == null) {
        // 缓存空值(防止缓存穿透)
        redisTemplate.opsForValue().set(cacheKey, new UserVO(), 1, TimeUnit.MINUTES);
        throw new UserNotFoundException();
    }

    UserVO vo = convertToVO(user);

    // 3. 写入缓存
    redisTemplate.opsForValue().set(cacheKey, vo, 30, TimeUnit.MINUTES);

    return vo;
}

写流程

@Transactional
public void updateUser(User user) {
    // 1. 先更新数据库
    userRepository.update(user);

    // 2. 再删除缓存(而不是更新缓存)
    String cacheKey = "user:info:" + user.getId();
    redisTemplate.delete(cacheKey);
}

为什么删除而不是更新缓存?

场景:并发更新

方案1:更新缓存
  时间线:
  T1: 线程A更新数据库(name=Alice)
  T2: 线程B更新数据库(name=Bob)
  T3: 线程B更新缓存(name=Bob)
  T4: 线程A更新缓存(name=Alice)  ← 后执行,覆盖了Bob

  结果:
    数据库:name=Bob(正确)
    缓存:  name=Alice(错误!)

方案2:删除缓存
  时间线:
  T1: 线程A更新数据库(name=Alice)
  T2: 线程B更新数据库(name=Bob)
  T3: 线程B删除缓存
  T4: 线程A删除缓存
  T5: 线程C查询,缓存未命中,查数据库,得到name=Bob(正确)

  结果:
    数据库:name=Bob(正确)
    缓存:  无(下次查询会重新加载)

结论:删除缓存更安全

优劣分析

维度优势劣势
一致性最终一致(通常1秒内)有短暂不一致窗口
性能读性能好写性能一般(需删除缓存)
复杂度简单需要业务代码管理缓存
适用场景读多写少不适合写多读少

5.2 Read/Write Through(读写穿透)

缓存层负责同步数据库,业务代码只操作缓存

架构

业务代码 → 缓存层 → 数据库
           ↑
       负责同步

读流程

// 业务代码:只操作缓存
public UserVO getUserInfo(Long userId) {
    String cacheKey = "user:info:" + userId;
    return cacheService.get(cacheKey);  // 缓存层负责查数据库
}

// 缓存层:自动从数据库加载
@Service
public class CacheService {

    @Autowired
    private RedisTemplate<String, UserVO> redisTemplate;

    @Autowired
    private UserRepository userRepository;

    public UserVO get(String cacheKey) {
        // 1. 先查缓存
        UserVO cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;
        }

        // 2. 缓存未命中,查数据库
        Long userId = extractUserIdFromKey(cacheKey);
        User user = userRepository.findById(userId);
        if (user == null) {
            throw new UserNotFoundException();
        }

        UserVO vo = convertToVO(user);

        // 3. 写入缓存
        redisTemplate.opsForValue().set(cacheKey, vo, 30, TimeUnit.MINUTES);

        return vo;
    }
}

写流程

// 业务代码:只操作缓存
@Transactional
public void updateUser(User user) {
    String cacheKey = "user:info:" + user.getId();
    UserVO vo = convertToVO(user);
    cacheService.set(cacheKey, vo);  // 缓存层负责同步数据库
}

// 缓存层:自动同步到数据库
@Service
public class CacheService {

    @Autowired
    private RedisTemplate<String, UserVO> redisTemplate;

    @Autowired
    private UserRepository userRepository;

    public void set(String cacheKey, UserVO vo) {
        // 1. 先更新数据库
        User user = convertToEntity(vo);
        userRepository.update(user);

        // 2. 再更新缓存
        redisTemplate.opsForValue().set(cacheKey, vo, 30, TimeUnit.MINUTES);
    }
}

优劣分析

维度优势劣势
一致性强一致(同步写数据库)性能较低
性能一般写操作需同步
复杂度业务代码简单缓存层复杂
适用场景需要强一致性不适合高并发写

5.3 Write Behind(异步写回)

写操作只写缓存,异步批量写数据库

架构

业务代码 → 缓存 → 异步队列 → 批量写数据库

写流程

// 业务代码:只写缓存
public void updateUser(User user) {
    String cacheKey = "user:info:" + user.getId();
    UserVO vo = convertToVO(user);

    // 1. 写入缓存(立即返回)
    redisTemplate.opsForValue().set(cacheKey, vo, 30, TimeUnit.MINUTES);

    // 2. 发送到异步队列
    asyncQueue.offer(new UpdateEvent(user.getId(), vo));
}

// 异步任务:批量写数据库
@Scheduled(fixedDelay = 1000)  // 每秒执行一次
public void flushToDatabase() {
    List<UpdateEvent> events = asyncQueue.poll(100);  // 批量获取
    if (events.isEmpty()) {
        return;
    }

    // 批量更新数据库
    List<User> users = events.stream()
        .map(e -> convertToEntity(e.vo))
        .collect(Collectors.toList());

    userRepository.batchUpdate(users);
}

优劣分析

维度优势劣势
一致性最终一致(延迟1秒+)可能丢失数据
性能写性能极高(只写缓存)读可能读到旧数据
复杂度复杂(需异步任务)实现复杂
适用场景日志、统计数据不适合关键数据

5.4 三种策略对比

策略一致性读性能写性能复杂度适用场景
Cache Aside最终一致读多写少(90%场景)
Read/Write Through强一致强一致性要求
Write Behind最终一致极高日志、统计数据

推荐

  • 首选Cache Aside:90%场景适用,简单高效
  • 强一致性用Read/Write Through:金融、支付场景
  • 高性能写用Write Behind:日志、监控数据

六、Redis淘汰策略详解

6.1 Redis内存淘汰策略

当Redis内存达到maxmemory时,会触发淘汰策略。

8种淘汰策略

策略说明适用场景
noeviction不淘汰,内存满时返回错误不推荐
allkeys-lru对所有key使用LRU淘汰推荐(通用场景)
allkeys-lfu对所有key使用LFU淘汰推荐(热点数据)
allkeys-random随机淘汰所有key不推荐
volatile-lru对设置了过期时间的key使用LRU部分key需永久保留
volatile-lfu对设置了过期时间的key使用LFU部分key需永久保留
volatile-random随机淘汰设置了过期时间的key不推荐
volatile-ttl淘汰TTL最小的key按过期时间优先淘汰

配置

# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru

6.2 LRU vs LFU

LRU(Least Recently Used):最近最少使用

原理:淘汰最久未访问的key
适合:时间局部性(最近访问的数据将来也会访问)

示例:
  访问序列:A B C D E A B C D E
  缓存容量:3
  淘汰顺序:A B C(最久未使用)

LFU(Least Frequently Used):最不经常使用

原理:淘汰访问频率最低的key
适合:频率局部性(访问频率高的数据将来也会访问)

示例:
  访问序列:A A A B B C D D D D
  访问频率:A=3, B=2, C=1, D=4
  缓存容量:3
  淘汰顺序:C(访问频率最低)

对比

维度LRULFU
淘汰依据最后访问时间访问频率
实现复杂度简单复杂
内存开销高(需记录频率)
适用场景通用热点数据集中
缺点可能淘汰高频但最近未访问的key新key难以进入缓存

推荐

  • 通用场景用LRU:简单高效,适合大部分场景
  • 热点数据用LFU:访问频率差异大的场景(如热门商品)

七、总结与最佳实践

7.1 缓存演进路径总结

HashMap
  ↓ 问题:无过期、无淘汰、线程不安全
ConcurrentHashMap
  ↓ 问题:无过期、无淘汰
Guava Cache / Caffeine
  ↓ 问题:单机、无分布式
Redis
  ✅ 解决所有问题:分布式、过期、淘汰、高可用

7.2 缓存选型决策树

是否需要分布式?
├─ 否 → 是否需要过期/淘汰?
│       ├─ 否 → ConcurrentHashMap
│       └─ 是 → Caffeine(性能最优)
└─ 是 → 是否需要丰富数据结构?
        ├─ 否 → Memcached(极简KV)
        └─ 是 → Redis(推荐)

7.3 最佳实践

1. 缓存Key设计

// ❌ 错误:key太短,容易冲突
redisTemplate.opsForValue().set("123", user);

// ✅ 正确:带业务前缀,含义清晰
redisTemplate.opsForValue().set("user:info:123", user);

// ✅ 正确:分层命名,便于管理
redisTemplate.opsForValue().set("ecommerce:user:info:123", user);
redisTemplate.opsForValue().set("ecommerce:product:detail:456", product);

2. 缓存过期时间设置

// ❌ 错误:所有key相同过期时间(缓存雪崩)
redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);

// ✅ 正确:过期时间随机化(防止缓存雪崩)
int baseExpire = 30 * 60;  // 30分钟
int randomExpire = new Random().nextInt(60);  // 0-60秒
int expireTime = baseExpire + randomExpire;
redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.SECONDS);

3. 缓存空值(防止缓存穿透)

public UserVO getUserInfo(Long userId) {
    String cacheKey = "user:info:" + userId;

    UserVO cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        // 判断是否是空值
        if (cached.getUserId() == null) {
            throw new UserNotFoundException();
        }
        return cached;
    }

    User user = userRepository.findById(userId);
    if (user == null) {
        // ✅ 缓存空值(TTL设短一点)
        redisTemplate.opsForValue().set(cacheKey, new UserVO(), 1, TimeUnit.MINUTES);
        throw new UserNotFoundException();
    }

    UserVO vo = convertToVO(user);
    redisTemplate.opsForValue().set(cacheKey, vo, 30, TimeUnit.MINUTES);
    return vo;
}

4. 分布式锁(防止缓存击穿)

public UserVO getUserInfo(Long userId) {
    String cacheKey = "user:info:" + userId;
    String lockKey = "lock:user:" + userId;

    UserVO cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        return cached;
    }

    // ✅ 获取分布式锁(防止缓存击穿)
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(
        lockKey, "1", 10, TimeUnit.SECONDS
    );

    if (Boolean.TRUE.equals(locked)) {
        try {
            // 双重检查
            cached = redisTemplate.opsForValue().get(cacheKey);
            if (cached != null) {
                return cached;
            }

            // 查数据库
            UserVO vo = loadFromDB(userId);

            // 写入缓存
            redisTemplate.opsForValue().set(cacheKey, vo, 30, TimeUnit.MINUTES);

            return vo;
        } finally {
            // ✅ 释放锁
            redisTemplate.delete(lockKey);
        }
    } else {
        // 获取锁失败,等待后重试
        Thread.sleep(100);
        return getUserInfo(userId);
    }
}

5. 监控缓存命中率

@Component
public class CacheMonitor {

    private AtomicLong hitCount = new AtomicLong(0);
    private AtomicLong missCount = new AtomicLong(0);

    public void recordHit() {
        hitCount.incrementAndGet();
    }

    public void recordMiss() {
        missCount.incrementAndGet();
    }

    @Scheduled(fixedDelay = 60000)  // 每分钟输出一次
    public void printStats() {
        long hit = hitCount.get();
        long miss = missCount.get();
        double hitRate = (double) hit / (hit + miss);

        log.info("缓存统计:命中={}, 未命中={}, 命中率={:.2f}%",
            hit, miss, hitRate * 100);

        // 重置计数器
        hitCount.set(0);
        missCount.set(0);
    }
}

7.4 性能优化建议

1. 使用Pipeline批量操作

// ❌ 错误:循环执行1000次SET(1000次网络往返)
for (int i = 0; i < 1000; i++) {
    redisTemplate.opsForValue().set("key" + i, "value" + i);
}
// 耗时:1000ms(假设每次1ms)

// ✅ 正确:使用Pipeline批量执行(1次网络往返)
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
    for (int i = 0; i < 1000; i++) {
        connection.set(("key" + i).getBytes(), ("value" + i).getBytes());
    }
    return null;
});
// 耗时:10ms(性能提升100倍)

2. 避免大Key

// ❌ 错误:单个key存储100MB数据
List<String> bigList = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
    bigList.add("item" + i);
}
redisTemplate.opsForValue().set("big:list", bigList);  // 100MB

// ✅ 正确:拆分成多个小key
for (int i = 0; i < 1000; i++) {
    List<String> subList = bigList.subList(i * 1000, (i + 1) * 1000);
    redisTemplate.opsForValue().set("list:part:" + i, subList);
}

3. 合理使用数据结构

// ❌ 错误:用String存储对象(序列化开销大)
redisTemplate.opsForValue().set("user:123", user);  // 序列化整个对象

// ✅ 正确:用Hash存储对象(只序列化变化的字段)
redisTemplate.opsForHash().put("user:123", "name", user.getName());
redisTemplate.opsForHash().put("user:123", "age", user.getAge());

// 更新时只更新变化的字段
redisTemplate.opsForHash().put("user:123", "age", 25);

八、核心要点回顾

8.1 缓存演进总结

1. HashMap → ConcurrentHashMap:解决线程安全
2. ConcurrentHashMap → Guava Cache:增加过期、淘汰
3. Guava Cache → Redis:解决分布式一致性

8.2 Redis核心优势

1. 单线程模型:无锁竞争,简单高效
2. IO多路复用:单线程监听多个客户端
3. 纯内存操作:访问速度100ns,比SSD快1500倍
4. 丰富数据结构:String、List、Hash、Set、ZSet等
5. 持久化能力:RDB+AOF,重启不丢数据
6. 高可用架构:主从+哨兵+集群

8.3 缓存更新策略

1. Cache Aside:最常用(90%场景)
2. Read/Write Through:强一致性场景
3. Write Behind:高性能写场景

8.4 下一步学习

本文是Redis系列的第2篇,后续文章将深入讲解:

  1. ✅ Redis第一性原理:为什么我们需要缓存?
  2. 从HashMap到Redis:分布式缓存的演进
  3. ⏳ Redis五大数据结构:从场景到实现
  4. ⏳ Redis高可用架构:主从复制、哨兵、集群
  5. ⏳ Redis持久化:RDB与AOF的权衡
  6. ⏳ Redis实战:分布式锁、消息队列、缓存设计

参考资料

  • 《Redis设计与实现》- 黄健宏
  • 《Redis实战》- Josiah L. Carlson
  • Redis官方文档:https://redis.io/documentation
  • Guava Cache文档:https://github.com/google/guava/wiki/CachesExplained
  • Caffeine文档:https://github.com/ben-manes/caffeine

本文是"Redis第一性原理"系列的第2篇,共6篇。下一篇将深入讲解《Redis五大数据结构:从场景到实现》,敬请期待。