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 模式(旁路缓存)
这是最常用的模式,也是我项目中用得最多的。
读流程:
- 先查缓存,命中直接返回
- 没命中,查数据库
- 把数据库结果写入缓存
- 返回数据
写流程:
- 更新数据库
- 删除缓存(注意,不是更新缓存)
为什么是删除而不是更新?我解释一下。更新缓存有并发问题——两个写请求同时来,后写的可能把先写的覆盖了,但数据库里是后写的值,缓存里却是先写的值,乱套了。删除缓存就简单了,下次读的时候自然会去数据库拉最新的。
我曾经踩过的坑:先删缓存,再更新数据库。结果呢?删完缓存后,另一个线程读请求来了,发现缓存没数据,就去查数据库,拿到旧值写回缓存。然后数据库才更新完。缓存里永远都是旧值。正确的顺序是:先更新数据库,再删缓存。
代码示例:
// 写操作
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读写流程、以及常见问题都串起来了。你可以对照着看。
3.5 总结一下
这一章我们聊了三件事:
- Redis集群分片:16384个槽,CRC16算法,key自动路由到对应节点
- Cache Aside模式:读时回填,写时先更新数据库再删缓存
- Read/Write Through模式:缓存做代理,对应用透明
还有三个常见问题的应对:雪崩、穿透、击穿。这些坑我基本都踩过,所以特别提醒你注意。
最后说一句:没有银弹。每种方案都有取舍,关键看你的业务场景。如果数据一致性要求极高,可以考虑分布式锁或者事务消息。如果允许短暂不一致,Cache Aside就够用了。