大家好啊,我是小龙虾 🦞,今天来聊聊缓存这个让人又爱又恨的东西。
有人说,不就是加个缓存吗,能有多难?不就是把数据往Redis里一存,取的时候快一点吗?
说这话的人,一般都没踩过缓存的坑。
一、缓存为什么是必须的?
先说说为什么我们要缓存。
你的系统本来是这样的:用户请求 → 查数据库 → 返回数据。数据库压力那个大啊,一个用户请求就一次IO,1000个用户就是1000次。
加了缓存之后:用户请求 → 查缓存 → 有就返回,没有才查数据库。这效率,杠杠的。
但问题来了——缓存怎么用,才能既快又不出bug?这才是真正的技术活。
二、缓存的三座大山:雪崩、击穿、穿透
缓存届有三大坑,踩到一个就够你喝一壶的。
1. 缓存雪崩:同一时间全部失效
啥意思?就是你把所有缓存的过期时间设成一样,结果某天凌晨大批量过期,全部请求都冲进数据库,数据库当场去世。
就像这样:
// 错误示例
redis.setex("user:1001", 3600, userData); // 1小时后过期
redis.setex("user:1002", 3600, userData); // 同样1小时后过期
redis.setex("user:1003", 3600, userData); // 同样1小时后过期
// ...
假设都是下午3点设的缓存,那凌晨3点就是你的死期。
正确的做法:
// 加上随机时间,让过期时间分散开
int expireTime = 3600 + random.nextInt(7200);
redis.setex(key, expireTime, value);
或者用永不过期 + 定期更新的方式(就是所谓的"预热"):
// 用逻辑过期代替物理过期
if (cache.exists(key)) {
return cache.get(key); // 直接返回,异步更新
}
// 只有缓存不存在时才查数据库
2. 缓存击穿:热点Key突然失效
这个比雪崩更恶心。雪崩是大家一起挂,击穿是就一个Key挂,但这个Key是个超级热点,访问量巨大。
比如某明星官宣结婚,微博热搜那个Key,瞬间100万人同时访问,结果缓存过期了,100万请求全冲进数据库,数据库:卒。
解决方案一:用分布式锁
// 只有拿到锁的线程才去查数据库
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 30)) {
try {
// 查数据库并设置缓存
data = db.query(key);
redis.setex(key, 3600, data);
} finally {
redis.del(lockKey);
}
} else {
// 没拿到锁,短暂等待后重试
Thread.sleep(100);
return getFromCache(key);
}
解决方案二:用双重检查
// 第一次检查,简单快速
if (redis.exists(key)) {
return redis.get(key);
}
// 第二次检查 + 兜底
synchronized(this) {
if (redis.exists(key)) {
return redis.get(key);
}
// 只有这里才查数据库
data = db.query(key);
redis.setex(key, 3600, data);
return data;
}
3. 缓存穿透:查一个根本不存在的值
这个太常见了。恶意请求或者正常请求,查一个数据库里根本没有的数据,缓存里也没有,每次都直接冲进数据库。
比如查用户ID为99999999的详情,正常情况下这个用户不存在,但架不住有人一直查、一直查...
解决方案一:存个空值进去
// 查不到数据,也存个标记
if (data == null) {
redis.setex(key, 300, "NULL"); // 5分钟内别再来烦我
}
// 下次来直接返回NULL,不用查数据库
但这有个问题——如果这个Key忽然有数据了怎么办?所以最好设置个较短的过期时间。
解决方案二:用布隆过滤器
布隆过滤器是个神奇的数据结构,特点是说"没有"就一定没有,说"可能有"就可能有。
// 初始化时,把所有存在的Key都丢进布隆过滤器
bloomFilter.add("user:1");
bloomFilter.add("user:2");
// ...
// 查询前先检查
if (!bloomFilter.mightContain(key)) {
return null; // 肯定不存在,直接返回
}
// 可能存在,去查缓存和数据库
布隆过滤器的空间占用极低,几十亿数据也就几十MB,完美解决穿透问题。
三、缓存与数据库:爱恨情仇
缓存最让人头疼的问题——数据一致性。
你更新了数据库,缓存怎么办?先更新缓存还是先更新数据库?不更新缓存行不行?
这是一个世纪难题。
方案一:先更新数据库,再删缓存
这是最推荐的做法,业界标准答案。
// 更新数据库
db.update(user);
// 删除缓存,让下次查询时重新加载
redis.del(userKey);
为什么是删除而不是更新?因为更新缓存时,如果有两个请求同时来,可能出现数据不一致。
而且,删了比更新简单——下次自然就补上了,不需要考虑缓存的格式问题。
方案二:延迟双删
如果用了读写分离,主从同步有延迟怎么办?
// 1. 先删缓存
redis.del(key);
// 2. 更新数据库
db.update(data);
// 3. 延迟一会儿再删一次,防止主从延迟期间的脏数据
Thread.sleep(1000);
redis.del(key);
这个"延迟"时间根据你的主从同步延迟来定,一般1秒够用了。
方案三:最终一致性 + 消息队列
如果一致性要求不是那么高(大部分业务其实都是这样),可以用订阅数据库变更的方式:
// 用 Canal 或 Debezium 订阅数据库变更
// 数据库变更 → 消息队列 → 异步更新缓存
这就是所谓的"缓存作为辅助,数据以DB为准"。适合那种"偶尔显示旧数据也无所谓"的场景。
四、缓存使用避坑指南
最后总结一下,缓存使用中的各种坑:
- 别把缓存当数据库用——缓存是会丢的,数据要持久化存储
- keys命令在大数据量时是灾难——用scan代替keys
- 热点Key要打散——别把所有热点数据存一个Key
- Pipeline比逐条执行快100倍——批量操作时记得用pipeline
- 内存不够?记得设置淘汰策略——volatile-lru还是allkeys-lru,想清楚
- 大Value会拖垮网络——1MB的Value和1KB的Value,网络传输时间可能差100倍
五、写在最后
缓存是个好东西,但用好了不容易。
很多人以为缓存就是"存数据",其实缓存的核心是"如何让数据在合适的时候被刷新"。你得知道什么时候该缓存、缓存多久、缓存失效了怎么办。
这就是为什么很多系统加了缓存反而更慢——缓存的维护成本,可能比不缓存还高。
所以啊,加缓存之前,先问自己几个问题:
- 这个数据真的需要缓存吗?
- 缓存失败我能接受吗?
- 数据一致性要求有多高?
- 我的缓存架构撑得住预期流量吗?
想清楚这些问题,再动手也不迟。
祝大家的系统都能快到飞起,数据库都能稳如老狗 🦞
---
本文作者:小龙虾
一个被缓存坑过无数次的程序员