3. 分布式缓存一致性:Redis集群模式下的数据分片,缓存与数据库的双写一致性

这一章,我们来啃一块硬骨头——分布式缓存一致性。

说实话,我在刚做架构那几年,被缓存一致性问题坑过不止一次。有一次线上出了个数据错乱,查了半天才发现是缓存和数据库写岔了。从那以后,我对这块就特别较真。

咱们先理清一个概念:Redis集群解决的是容量和吞吐的问题,但它不解决一致性问题。一致性,得靠我们自己在代码层面去把控。

3.1 Redis集群的数据分片机制

Redis集群怎么存数据的?说白了就是分片。

它把整个键空间分成16384个哈希槽(hash slot)。每个key通过CRC16算法算出一个值,然后对16384取模,落到对应的槽上。集群里的每个节点负责一部分槽。

核心公式:HASH_SLOT = CRC16(key) mod 16384

举个例子,假设你有3个节点:

  • 节点A:负责 0-5460 号槽
  • 节点B:负责 5461-10922 号槽
  • 节点C:负责 10923-16383 号槽

当你执行 SET user:1001 "data",Redis先算这个key的槽位,然后直接转发到对应的节点。客户端不需要知道数据在哪,Redis自己搞定。

嗯,这里要注意:Redis集群不支持多key操作,除非这些key在同一个槽里。我见过有人踩这个坑——批量操作时发现报错,一脸懵。

我的建议:如果业务上需要批量操作,可以用 hash tags 把相关key强制放到同一个槽。比如 user:{1001}:profile 和 user:{1001}:orders,花括号里的内容决定了槽位。

3.2 缓存与数据库的双写一致性

这才是重头戏。缓存和数据库之间怎么保持一致?

你想想看,数据写进数据库了,缓存里还是旧值。下次读请求过来,拿到的就是脏数据。怎么办?

业界有几种主流方案,我一个个说。

3.2.1 Cache Aside 模式(旁路缓存)

这是最常用的模式,也是我项目中用得最多的。

读流程:

  1. 先查缓存,命中直接返回
  2. 没命中,查数据库
  3. 把数据库结果写入缓存
  4. 返回数据

写流程:

  1. 更新数据库
  2. 删除缓存(注意,不是更新缓存)

为什么是删除而不是更新?我解释一下。更新缓存有并发问题——两个写请求同时来,后写的可能把先写的覆盖了,但数据库里是后写的值,缓存里却是先写的值,乱套了。删除缓存就简单了,下次读的时候自然会去数据库拉最新的。

我曾经踩过的坑:先删缓存,再更新数据库。结果呢?删完缓存后,另一个线程读请求来了,发现缓存没数据,就去查数据库,拿到旧值写回缓存。然后数据库才更新完。缓存里永远都是旧值。正确的顺序是:先更新数据库,再删缓存。

代码示例:

// 写操作
public void updateUser(User user) {
    // 1. 更新数据库
    userDao.update(user);
    // 2. 删除缓存
    redisTemplate.delete("user:" + user.getId());
}

// 读操作
public User getUser(Long id) {
    // 1. 查缓存
    User user = redisTemplate.get("user:" + id);
    if (user != null) {
        return user;
    }
    // 2. 缓存未命中,查数据库
    user = userDao.findById(id);
    if (user != null) {
        // 3. 写入缓存
        redisTemplate.set("user:" + id, user);
    }
    return user;
}

3.2.2 Read/Write Through 模式

这个模式把缓存当成一个代理层。应用不直接操作数据库,而是通过缓存来读写。

Read Through:

  • 读请求打到缓存,缓存没有,由缓存组件自己去数据库拉,然后返回
  • 应用层不用关心缓存是否命中

Write Through:

  • 写请求先写缓存,缓存同步写数据库
  • 要么都成功,要么都失败

这个模式的好处是对应用层透明。但实现起来复杂,需要缓存中间件支持。Redis本身不提供这个能力,得自己封装。

对比维度 Cache Aside Read/Write Through
应用复杂度 低,代码里显式控制 高,需要封装代理层
一致性保障 最终一致,有短暂不一致窗口 强一致(取决于实现)
适用场景 大部分业务系统 对一致性要求极高的场景
缓存失效处理 主动删除,下次回填 由代理层自动处理

3.3 一致性问题与解决方案

不管用哪种模式,都会遇到一些棘手的问题。我挑几个典型的说说。

3.3.1 缓存雪崩

大量缓存同时过期,请求全部打到数据库。数据库扛不住就挂了。

解决方案:

  • 设置不同的过期时间,加一个随机偏移量
  • 用互斥锁,只让一个线程去查数据库回填缓存
  • 缓存永不过期,后台异步更新

3.3.2 缓存穿透

查询一个不存在的key。缓存里没有,数据库里也没有。每次请求都穿透到数据库。

解决方案:

  • 缓存空值,设置短过期时间
  • 布隆过滤器,快速判断key是否存在

我个人的习惯:布隆过滤器适合数据量大的场景。如果数据量不大,直接缓存空值更简单。但要注意,空值的过期时间要短,比如30秒,不然数据真的来了,缓存还是空。

3.3.3 缓存击穿

一个热点key,在过期瞬间,大量并发请求同时来。缓存没数据,全部打到数据库。

解决方案:

  • 互斥锁(Mutex Key):只让一个线程去查数据库,其他线程等待
  • 逻辑过期:缓存里存一个逻辑过期时间,后台异步刷新

代码示例(互斥锁方案):

public User getHotUser(Long id) {
    User user = redisTemplate.get("hot:user:" + id);
    if (user != null) {
        return user;
    }
    
    // 尝试获取锁
    String lockKey = "lock:hot:user:" + id;
    if (redisTemplate.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS)) {
        try {
            // 双重检查,防止其他线程已经回填
            user = redisTemplate.get("hot:user:" + id);
            if (user != null) {
                return user;
            }
            // 查数据库
            user = userDao.findById(id);
            if (user != null) {
                redisTemplate.set("hot:user:" + id, user);
            }
            return user;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // 没拿到锁,等待后重试
        Thread.sleep(100);
        return getHotUser(id);
    }
}

3.4 整体架构图

下面这张图,把Redis集群分片、Cache Aside读写流程、以及常见问题都串起来了。你可以对照着看。

Redis集群 + Cache Aside 架构图 应用服务 读请求 Redis 缓存 未命中 数据库 回填缓存 写请求 先写数据库 再删缓存 Redis 集群分片 节点A 0-5460 节点B 5461-10922 节点C 10923-16383 key → CRC16 → 槽位 → 节点 常见问题:雪崩 / 穿透 / 击穿 解决方案:随机过期 / 布隆 / 互斥锁

3.5 总结一下

这一章我们聊了三件事:

  • Redis集群分片:16384个槽,CRC16算法,key自动路由到对应节点
  • Cache Aside模式:读时回填,写时先更新数据库再删缓存
  • Read/Write Through模式:缓存做代理,对应用透明

还有三个常见问题的应对:雪崩、穿透、击穿。这些坑我基本都踩过,所以特别提醒你注意。

最后说一句:没有银弹。每种方案都有取舍,关键看你的业务场景。如果数据一致性要求极高,可以考虑分布式锁或者事务消息。如果允许短暂不一致,Cache Aside就够用了。