4、缓存架构设计:Redis集群、缓存穿透/击穿/雪崩、多级缓存(本地+分布式)、缓存一致性
缓存这东西,说白了就是拿空间换时间。但很多团队把缓存当成了万能药,结果一上线就出各种幺蛾子。我见过最夸张的一次,某个活动页面直接把数据库打挂了,原因就是缓存穿透——几百万个请求全怼到了MySQL上。
这一章,咱们就把缓存架构的坑一个个填平。从Redis集群搭建,到三大经典问题(穿透、击穿、雪崩),再到多级缓存和一致性方案。嗯,都是实战里摸爬滚打出来的经验。
核心观点:缓存不是银弹,但用好了能扛住90%的读压力。关键在于——分层、限流、兜底。
4.1 Redis集群:不只是主从复制
很多人觉得Redis集群就是搞个主从,再弄个哨兵就完事了。其实不然。我建议你根据业务场景选方案:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 主从+哨兵 | 读多写少,数据量<10G | 部署简单,自动故障转移 | 写性能瓶颈在主节点 |
| Redis Cluster | 数据量>10G,需要水平扩展 | 自动分片,无中心化 | 跨节点操作受限,运维复杂 |
| Proxy方案(Twemproxy/Codis) | 业务层不想感知集群 | 对客户端透明 | Proxy成为单点,有性能损耗 |
我个人习惯用Redis Cluster。为什么?因为它原生支持分片,扩容时不用停机。我记得有一次双十一前扩容,从6个节点扩到12个,整个过程业务无感知。
部署时要注意几个点:
- 节点数最好是奇数——投票选举时不会平局
- 不要跨机房部署——网络延迟会导致频繁的选举
- 预留20%内存——给RDB持久化和主从同步留空间
小技巧:Redis Cluster的槽位分配是16384个。你可以用redis-cli --cluster rebalance手动调整,避免热点槽集中在某几个节点上。
4.2 缓存穿透:别让请求打到数据库
什么是缓存穿透?就是查询一个根本不存在的数据。缓存里没有,数据库里也没有。每次请求都穿透缓存,直接怼到数据库上。
我在项目中遇到过最典型的场景:用户批量查询不存在的商品ID。攻击者构造几万个不存在的ID,瞬间把数据库打满连接。
解决方案有三个层次:
- 缓存空对象——最简单,但要注意设置较短的过期时间(比如30秒),防止大量空key占内存
- 布隆过滤器——在缓存前面加一层过滤器,判断key是否存在。不存在直接返回,不查数据库
- 参数校验——比如ID必须是正整数,长度不超过10位。非法请求直接拒绝
// 布隆过滤器示例(Java + Guava)
public class BloomFilterDemo {
private static final int EXPECTED_INSERTIONS = 100_0000;
private static final double FPP = 0.01; // 误判率1%
private static BloomFilter<String> bloomFilter =
BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()),
EXPECTED_INSERTIONS, FPP);
// 初始化时加载所有合法ID
public void init(List<String> ids) {
ids.forEach(bloomFilter::put);
}
public boolean mightContain(String id) {
return bloomFilter.mightContain(id);
}
}
注意:布隆过滤器有误判率。它说“不存在”就一定不存在,但说“存在”可能不存在。所以过滤后还是要查缓存和数据库,只是把大部分无效请求挡在外面。
4.3 缓存击穿:热点key的生死时刻
缓存击穿和穿透不一样。击穿是某个热点key过期的瞬间,大量请求同时涌入,全部打到数据库上。
你想想看,一个爆款商品的详情页,平时每秒几万次请求。突然缓存过期了,所有请求都去查数据库——数据库不挂才怪。
我曾经踩过这个坑。那次是某个秒杀活动的商品信息缓存过期,数据库连接数瞬间飙到5000+,直接OOM了。
解决方案:
- 互斥锁(Mutex)——只让一个线程去查数据库重建缓存,其他线程等待。用Redis的SETNX实现
- 逻辑过期——缓存永不过期,但存一个逻辑过期时间。后台异步刷新
- 热点key永不过期——对已知的热点key,不设置过期时间,通过后台任务定期更新
// 互斥锁重建缓存(伪代码)
public String getProduct(String id) {
String cache = redis.get("product:" + id);
if (cache != null) {
return cache;
}
// 尝试获取锁
String lockKey = "lock:product:" + id;
if (redis.setnx(lockKey, "1", 3)) { // 3秒过期
try {
String dbData = db.query(id);
redis.set("product:" + id, dbData, 60);
return dbData;
} finally {
redis.del(lockKey);
}
} else {
// 没拿到锁,等待后重试
Thread.sleep(50);
return getProduct(id); // 递归重试
}
}
经验之谈:互斥锁的过期时间要设置合理。如果数据库查询超过3秒,锁自动释放,其他线程又会去重建。我一般设置为“预估查询时间×2”。
4.4 缓存雪崩:大面积过期引发的灾难
缓存雪崩比击穿更可怕。它不是单个key过期,而是大量key在同一时间过期,或者Redis节点挂了,导致所有请求都打到数据库。
我记得有一次,运营团队在后台批量上架了1000个商品,所有商品的缓存过期时间都设成了凌晨0点。结果0点一到,数据库直接被打爆。
预防措施:
- 过期时间加随机值——比如基础时间60秒,再加0-30秒的随机偏移
- 多级缓存——本地缓存+分布式缓存,本地缓存扛住第一波
- 限流降级——数据库层面做限流,超出部分直接返回默认值或错误
- Redis高可用——主从+哨兵,或者Cluster模式,避免单点故障
// 过期时间加随机值
int baseExpire = 60;
int randomOffset = new Random().nextInt(30);
int expireTime = baseExpire + randomOffset;
redis.set(key, value, expireTime);
4.5 多级缓存:本地+分布式
单靠Redis扛百万并发?有点悬。网络开销和序列化成本摆在那里。我建议用多级缓存:
- L1:本地缓存(Caffeine/Guava Cache)——毫秒级响应,扛住80%的热点请求
- L2:分布式缓存(Redis)——毫秒级响应,扛住剩下的20%
- L3:数据库——兜底,只处理少量请求
为什么本地缓存这么重要?因为Redis再快,也有网络开销。本地缓存直接在JVM内存里,0网络延迟。我做过压测,本地缓存的QPS能到几十万,而Redis单节点也就几万到十几万。
但本地缓存有个问题——数据一致性。每个节点的本地缓存可能不一样。怎么解决?看下一节。
架构建议:本地缓存只放“几乎不变”的数据,比如商品分类、配置信息。对于频繁变化的数据(比如库存),只用Redis。
4.6 缓存一致性:CAP的妥协
缓存一致性是个老生常谈的问题。说白了,就是数据库更新了,缓存怎么同步?
我见过很多团队用“先删缓存,再更新数据库”的方案。但这样会有并发问题:线程A删了缓存,还没更新数据库,线程B读到了旧数据,又把旧数据写回缓存。
业界常用的方案:
| 方案 | 原理 | 一致性 | 复杂度 |
|---|---|---|---|
| Cache Aside | 先更新数据库,再删缓存 | 最终一致 | 低 |
| 读写穿透 | 缓存层封装读写逻辑 | 强一致 | 中 |
| 异步通知(MQ) | 数据库变更后发消息,缓存消费更新 | 最终一致 | 中 |
| Binlog监听(Canal) | 监听MySQL binlog,同步更新缓存 | 最终一致 | 高 |
我个人推荐Cache Aside + 延迟双删:
- 先更新数据库
- 删除缓存
- 延迟几百毫秒,再删一次缓存(解决并发读写的脏数据问题)
// 延迟双删示例
public void updateProduct(Product product) {
// 1. 更新数据库
db.update(product);
// 2. 删除缓存
redis.del("product:" + product.getId());
// 3. 延迟再删(异步)
executor.schedule(() -> {
redis.del("product:" + product.getId());
}, 500, TimeUnit.MILLISECONDS);
}
注意:延迟双删不能保证100%强一致,但能保证99.9%的场景下数据最终一致。如果业务要求强一致,建议用读写穿透方案,或者干脆不用缓存。
4.7 本章知识体系
下面这张图总结了缓存架构的核心逻辑。你可以看到,从客户端请求到数据库,中间经过了多层防护:
从这张图你能看到,一个请求从客户端到数据库,经过了五层防护。每一层都解决了一个特定问题。这就是缓存架构的精髓——分层防御,各司其职。
最后说一句:缓存架构没有银弹。你要根据业务场景选择方案。如果数据一致性要求极高,就别用缓存;如果QPS不高,单机Redis+主从就够了。别为了技术而技术。