作为程序员------高阶牛马:你眼里的缓存不应该只是一个存 Key-Value 的字典,它应该是你系统里的**"特种部队"**------反应极快,但脾气暴躁,稍不留神还会炸伤自己人(数据库)。
咱们把 Redis 当成一个只会听指令的"笨蛋天才",来看看怎么通过架构设计把它玩出花来。
三大"死法":穿透、击穿、雪崩
这三个词听起来像灾难片,实际上就是数据库在向你求救:"大哥,别让我干了,我想死!"这个词是不是很熟悉,*天默念*遍,看完咱博客、你会发现这个要学的东西太多了,扶我起来、还能肝!
1. 缓存穿透 ------ "恶意找茬"
场景 :黑客或者爬虫,专门查一些根本不存在 的 ID(比如 user_id = -1 或 95270000)。缓存里没有,数据库里也没有。结果就是:所有请求都像穿针引线一样,直接扎到了数据库身上。
- 后果:数据库 CPU 飙到 100%,当场去世。
药方:
-
方案 A(空值缓存) :虽然数据不存在,我也给你记一笔!Key 是
user:-1,Value 是NULL,过期时间设短点(比如 5 分钟)。下次再来,看到NULL就直接滚蛋,不用查库了。 -
方案 B(布隆过滤器 Bloom Filter):这是数学家的魔法。在请求到达缓存之前,先过一个位图算法。如果布隆过滤器说"这玩意儿不存在",那它就绝对不存在,直接拦截。如果说"存在",那可能是真的,也可能是误判,放行去查。
// 伪代码:布隆过滤器逻辑
if (!bloomFilter.mightContain(userId)) {
return null; // 肯定没有,直接拦截,保护数据库
}
// 可能存在,继续查缓存...
2. 缓存击穿 ------ "热点引爆"
场景 :某个超级热点数据(比如微博热搜第一),在某一瞬间过期了。就在这毫秒之间,几万个并发请求发现缓存没了,于是它们商量好了一样,同时转身冲向数据库。
- 后果:数据库瞬间被几万根"针"扎穿。
药方:
-
互斥锁(Mutex Lock):也就是"单机飞行"。发现缓存没了?别急,大家抢一把分布式锁(Redis SETNX)。抢到锁的那个倒霉蛋去查库,其他人等着。等它查完回填缓存,释放锁,其他人再读缓存就命中了。
public User getUser(String id) {
User user = redis.get(id);
if (user == null) {
// 尝试获取分布式锁
if (redis.setNx("lock:" + id, "1")) {
try {
// 双重检查!万一别人已经查完了呢
user = redis.get(id);
if (user == null) {
user = db.query(id); // 只有一个人能进这里
redis.set(id, user);
}
} finally {
redis.del("lock:" + id); // 必须释放锁
}
} else {
// 没抢到锁,睡一觉重试
Thread.sleep(50);
return getUser(id);
}
}
return user;
}
3. 缓存雪崩 ------ "集体自杀"
场景 :你偷懒,把所有缓存的过期时间都设成了同一个值(比如 1 小时)。好巧不巧,1 小时后,几百万个 Key 同时过期。那一刻,流量如海啸般拍向数据库。
- 后果:数据库直接宕机,甚至导致整个机房瘫痪。
药方:
- 随机扰动(Jitter) :别让大家的死期一样。在原定 TTL 基础上,加个随机数。比如
TTL = 60分钟 + random(1~10分钟)。这样它们就会陆陆续续过期,数据库就能喘口气。 - 多级缓存:本地缓存(Caffeine)+ 分布式缓存(Redis)。Redis 挂了,还有本地内存顶着。
双写一致性:先删还是先更?
这是面试必问,也是线上最容易翻车的地方。
核心矛盾:数据库(DB)和缓存(Cache)就像两个异地恋的情侣,你怎么保证他们手里的账本永远一样?
❌ 错误示范:先更新缓存,再更新数据库
结局:并发情况下,A 线程改了缓存,B 线程也改了缓存。最后数据库是新的,缓存却是旧的(因为 B 改得慢)。千万别这么干。
❌ 也不太行:先删缓存,再更新数据库
结局:
- 线程 A 删了缓存。
- 线程 B 来读数据,发现缓存没了,去 DB 读了旧值,并填回缓存。
- 线程 A 才慢悠悠地把新值写入 DB。
- 结果:缓存里永远是脏数据,直到下一次过期。
业界标准:Cache Aside Pattern(旁路缓存)
口诀 :读的时候先读缓存,没有就读库然后回填;写的时候先更库,成功后再删缓存。
为什么是删缓存 而不是更缓存?
- 省事:如果你只改了用户名字,但缓存里存的是个巨大的对象(包含头像、简介等),你还要重新把整个对象组装一遍去更新缓存吗?直接删了让读请求去重建多香。
- 并发安全:两个线程同时改缓存,谁先谁后很难控制。
但是! Cache Aside 也有极端情况下的 Bug(并发读写竞态)。所以有了进阶版 👇
终极方案:延迟双删(Delayed Double Delete)
为了防止上面提到的"读旧值回填"问题,我们搞个"回马枪"。
-
先删缓存(防止有人读到旧缓存)。
-
更新数据库。
-
休眠一小会儿(比如 500ms,确保所有正在进行的读操作都结束了)。
-
再次删除缓存(把那个可能产生的脏数据清理掉)。
public void updateUser(User user) {
// 1. 先删缓存
redis.delete("user:" + user.getId());// 2. 更新数据库 userMapper.update(user); // 3. 延迟双删 (实际生产建议用 MQ 延时队列,别在这里 sleep 阻塞线程!) // delayQueue.send(new DeleteTask(user.getId()), 500, TimeUnit.MILLISECONDS); try { Thread.sleep(500); } catch (Exception e) {} // 4. 再次删除 redis.delete("user:" + user.getId());}
骨灰级方案:Canal 订阅 Binlog
如果你觉得"延迟双删"太丑,或者怕删失败了怎么办?那就引入阿里开源的 Canal。
原理 :Canal 伪装成 MySQL 的从库,监听 Binlog(数据库的流水账)。
流程:
- 业务代码只管更新数据库(DB 是唯一真理)。
- Canal 监听到变动,把数据扔到 MQ(消息队列)。
- 消费者收到消息,去删除/更新 Redis。
优点:代码解耦,最终一致性极高。哪怕 Redis 挂了,消息还在 MQ 里,重启还能消费
本地缓存Caffeine怎么和Redis结合用
- Redis 是中央大冷库:东西全,所有分店(服务实例)都能去拿,但得开车过去(网络IO),还得排队(网络延迟)。
- Caffeine 是灶台上的调料盒:就在手边,伸手就能拿到(内存读取,纳秒级),但容量小,而且只有这一个灶台能用(进程内共享)。
把 Caffeine 和 Redis 结合起来,就是构建多级缓存(Multi-Level Cache) 。核心思想就一句话:"能不动脚就不动脚,实在没辙了再去跑断腿。"
架构原理:流量是怎么被拦截的?
在一个标准的"Caffeine + Redis"二级缓存架构中,读数据的流程是这样的:
- 第一道防线 (L1 - Caffeine) :请求来了,先看灶台(JVM 堆内存)。如果有,直接返回。(耗时:纳秒级)
- 第二道防线 (L2 - Redis) :如果灶台没有,再去中央冷库(Redis)。如果有,拿回来,顺手放在灶台上 (回填 L1),然后返回。(耗时:毫秒级)
- 最后的手段 (DB) :如果冷库也没有,那就只能去原产地采购(查数据库),查回来后,先放冷库,再放灶台。
为什么要这么麻烦?
因为 Redis 虽然快,但它毕竟是远程调用(RPC/Network)。在高并发下,网卡带宽和网络延迟是巨大的瓶颈。Caffeine 能帮你挡掉 90% 以上的热点流量,让 Redis 甚至感觉不到压力。
实战代码:手写一个"多级缓存管理器"
别光看理论,咱们直接上代码。这里我不使用复杂的 Spring Cache 注解,而是手写一个通用的 TwoLevelCache 工具类,让你看清底层的逻辑流转。
我们需要两个依赖:
-
com.github.ben-manes.caffeine:caffeine -
org.springframework.boot:spring-boot-starter-data-redisimport com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;@Component
public class TwoLevelCache<K, V> {// 1. 本地缓存 (L1):Caffeine // 最大存 1000 个对象,写入后 10 分钟过期 private final Cache<K, V> localCache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES) .recordStats() // 开启监控统计 .build(); @Resource private RedisTemplate<K, V> redisTemplate; /** * 核心读取逻辑 * @param key 键 * @param dbLoader 数据库加载器 (Lambda表达式) */ public V get(K key, Supplier<V> dbLoader) { // === 第一步:查本地缓存 (L1) === V value = localCache.getIfPresent(key); if (value != null) { System.out.println(" [L1 Hit] 命中本地缓存: " + key); return value; } // === 第二步:查分布式缓存 (L2) === // 注意:这里需要加分布式锁防止"缓存击穿",为了代码简洁此处省略锁逻辑 value = redisTemplate.opsForValue().get(key); if (value != null) { System.out.println("[L2 Hit] 命中Redis缓存: " + key); // 【关键动作】:回填到本地缓存!不然下次还得查Redis localCache.put(key, value); return value; } // === 第三步:查数据库 === System.out.println("[Cache Miss] 缓存未命中,查询数据库..."); value = dbLoader.get(); if (value != null) { // 【回填策略】:双写回环 // 1. 写入 Redis (假设过期时间 1 小时) redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS); // 2. 写入 Caffeine localCache.put(key, value); } return value; } /** * 更新/删除时的清理逻辑 */ public void evict(K key) { // 删库之后,必须把两级缓存都清空! localCache.invalidate(key); redisTemplate.delete(key); System.out.println("🧹 已清理两级缓存: " + key); }}
// 在你的 Service 层调用
User user = twoLevelCache.get("user:1001", () -> {
// 这个 Lambda 里的代码只有在缓存都没命中的时候才会执行
return userRepository.findById(1001L);
});
最大的坑:数据一致性(如何保证大家看到的是同一份数据?)
这是架构师必须面对的灵魂拷问。
场景:你有 10 台服务器(10 个 JVM),每台都有自己的 Caffeine。
- 管理员在后台修改了用户信息(更新了 DB 和 Redis)。
- 问题来了:那 10 台服务器里的 Caffeine 还在傻乎乎地拿着旧数据,因为它们根本不知道 DB 变了!
这就导致了:你刚改完名字,刷新页面发现还是旧的,或者有的服务器是新的,有的是旧的。
解决方案:Redis Pub/Sub 广播机制
既然 Caffeine 是"井底之蛙",看不见外面的世界,那我们就通过 Redis 给它传话。
架构设计:
-
当数据发生变更时(比如更新 DB 后),除了删除 Redis 缓存,还要向 Redis 发送一条广播消息(Pub/Sub)。
-
所有服务器的 Caffeine 监听这个频道。
-
一旦收到消息:"嘿,
user:1001变了!",所有服务器立刻把自己本地的 Caffeine 里的user:1001删掉。 -
下次请求进来,发现 L1 没了,自然会去查 L2(Redis),从而拿到最新数据。
// 1. 订阅者:每个服务启动时都要订阅
@EventListener
public void onMessage(RedisMessageEvent event) {
if ("CACHE_CLEAR".equals(event.getType())) {
// 收到清理指令,干掉本地缓存
localCache.invalidate(event.getKey());
log.info("收到广播,清理本地缓存: {}", event.getKey());
}
}// 2. 发布者:在更新数据的服务里调用
public void updateUserData(User user) {
// 1. 更新数据库
userRepo.save(user);// 2. 删除 Redis 集中式缓存 redisTemplate.delete("user:" + user.getId()); // 3. 【关键一步】发布广播,通知所有兄弟节点清理本地缓存 redisTemplate.convertAndSend("CACHE_TOPIC", new CacheClearEvent("user:" + user.getId()));}
决策清单
-
什么时候用 Caffeine + Redis?
- 高频读、低频写的数据。比如:商品详情、配置信息、字典表。
- 如果是频繁变动的数据(如库存、秒杀状态),千万别用本地缓存,否则你会超卖到哭。
-
Caffeine 的配置要点
- 一定要设上限 (
maximumSize):别让本地缓存把 JVM 堆内存撑爆了(OOM)。 - 一定要设过期时间 (
expireAfterWrite):作为兜底策略,万一广播丢了,数据最终也会过期自动修复。
- 一定要设上限 (
-
核心口诀
- 读:先 L1,再 L2,最后 DB。L2 命中要回填 L1。
- 写:先 DB,删 L2,发广播删 L1。
这就是 Caffeine 和 Redis 结合的终极形态:用 Redis 做全量兜底和数据同步通道,用 Caffeine 做极致的性能加速。
总结:"防弹指南"
| 问题 | 解决方案 | 核心逻辑 |
|---|---|---|
| 穿透 | 布隆过滤器 / 空值缓存 | 别让垃圾请求摸到数据库 |
| 击穿 | 互斥锁 (Redisson) | 只有一个勇士能打怪,其他人围观 |
| 雪崩 | 随机 TTL / 多级缓存 | 别让大家一起死 |
| 一致性 | 延迟双删 / Canal | 删比更要紧,异步兜底最靠谱 |