先讲个真实的故事。
那年我负责一个电商系统,商品详情页巨慢,SQL打到了3秒。Leader眉头一皱说上Redis缓存。我花了两个晚上搞定,QPS从200飙升到8000,Leader露出了满意的笑容。
然后商品改价了。
用户看到的还是旧价格。下单的时候发现价格对不上,客诉电话直接把客服打爆。最后怎么解决的?运维手动flush Redis,赔偿用户优惠券,Leader请我吃了一顿散伙饭——不是,是庆功宴。
从那天起我明白了:缓存是这个世界上最昂贵的谎言。它让你以为你在优化性能,其实你只是在给自己埋雷。
缓存失效的四种死法
踩了无数坑之后,我总结出缓存失效的四种典型死法,每一种都血淋淋。
第一种:时间驱动型——你的缓存比你先退休
最常见的写法是这样的:
// 设置缓存,过期时间2小时
redis.set("product:123", data, 7200);
看起来没问题。但想象一下这个场景:
- 商品A的缓存设置了2小时过期
- 1小时59分的时候,运营把价格改了
- 用户1秒后访问,看到的还是旧价格
- 客诉电话响起的时候你正在吃午饭
这就是时间驱动型缓存的致命问题:你根本不知道用户看到的是哪个版本的数据。
更可怕的是,如果你的缓存过期时间设置得很长,比如24小时,那这24小时内所有的数据变更对用户来说都是不可见的。你以为你在做缓存,其实你做的是一个巨大无比的数据黑洞。
第二种:删除驱动型——删了,但没完全删
聪明一点的同学会说:那不用过期时间,用主动删除。
// 更新数据库后删除缓存
db.update("product", {...});
redis.del("product:123");
这看起来更可靠了。但问题来了:
删除缓存的瞬间,另一个请求刚好进来,它发现缓存没了,去数据库读,然后把旧数据写回缓存。
这就是经典的缓存双写问题:你删的是新缓存,写进去的是旧数据。
代码跑起来的时候,你根本不知道哪个请求会赢。这是竞态条件,是多线程世界里最恶心的问题之一。
第三种:多级缓存型——缓存的缓存的缓存
当系统变复杂了,你可能会有这样的架构:
- CDN → Nginx本地缓存 → Redis → 数据库
四级缓存。听起来很厉害,实际上是四级噩梦。
当你更新了一条数据,你要:
- 更新数据库
- 删除Redis缓存
- 让Nginx缓存失效(如果你能控制的话)
- 等待CDN刷新(如果你能触发的话)
现实中第3和第4步基本不可控。你只能祈祷CDN有及时刷新机制,或者祈祷用户不刷到那些"经典缓存"。
我见过最离谱的是一个系统,缓存配置写在XML里,改了配置要重启应用。每次发布完,运维要手动登录三台服务器执行clear cache脚本。这是人干的事吗?
第四种:缓存穿透型——有人专门搞你
你的缓存是空的,有人故意请求一个不存在的数据。
// 正常逻辑
data = redis.get("product:-1"); // null
if (!data) {
data = db.query("SELECT * FROM product WHERE id = -1"); // null
// null 不会写入缓存,所以下次还是查库
}
请求不存在的数据,缓存根本没用,每次都打数据库。如果有人用脚本大量请求不存在的数据,你的数据库直接被打挂。
这就是缓存穿透。攻击成本极低,防御成本极高。
我的实战解法:不优雅,但有用
理论讲完了,说说我在生产环境里用的方案。不完美,但经历过真实流量洗礼。
方案一:Cache Aside + 短过期时间
这是最经典的模式,也是最容易出问题的模式:
// 读
data = redis.get(key);
if (!data) {
data = db.query(sql);
redis.setex(key, 300, data); // 短过期,5分钟
}
return data;
// 写
db.update(sql);
redis.del(key);
关键在于短过期时间。如果过期时间是24小时,那数据不一致最久持续24小时。如果是5分钟,最久持续5分钟。这是一个权衡,不是完美的解决方案。
方案二:延迟双删——给删除加个保险
针对"删了但没完全删"的问题,有一个技巧叫延迟双删:
// 写
db.update(sql);
redis.del(key);
Thread.sleep(100); // 等待100ms
// 再次删除,防止步骤3的回填
redis.del(key);
为什么要sleep?因为步骤3(另一个请求读库写缓存)通常在步骤1(删除缓存)之后很短的时间内发生。sleep 100ms可以大幅降低旧数据被写回缓存的概率。
这个方案不完美,但胜在简单,而且能覆盖90%的场景。
方案三:分布式锁——让写操作串行化
如果你对数据一致性要求极高,那就别让读操作去填充缓存了。用分布式锁保证同一时刻只有一个请求去查库:
// 读
lock = redis.setnx("lock:product:123", "1", 10);
if (lock) {
// 拿到锁,去查库
data = db.query(sql);
redis.set(key, data);
redis.del("lock:product:123");
} else {
// 没拿到锁,等一下再试,或者直接查库
Thread.sleep(50);
return redis.get(key);
}
这个方案代价很大:所有并发读请求都被串行化了。如果你的QPS很高,这个方案会让你的响应时间变长。适合那些数据一致性要求极高、但QPS不高的场景(比如商品详情页)。
方案四:布隆过滤器——专治缓存穿透
针对缓存穿透,用布隆过滤器判断数据是否存在:
// 初始化布隆过滤器,加入所有存在的商品ID
bf.add("1");
bf.add("2");
bf.add("3");
// 读
if (!bf.exists(productId)) {
return null; // 直接返回,不查库
}
data = redis.get(key);
布隆过滤器的特点是:如果它说数据不存在,那就一定不存在;如果它说存在,数据可能不存在(假阳性)。这对防穿透来说刚刚好:它说不存在,直接返回,不查库,完美。
代价是:布隆过滤器需要预热,而且如果数据量巨大,内存占用不小。但比起数据库被打挂,这点代价算什么?
一个反直觉的观点
说了这么多,其实我最想说的是:不要轻易上缓存。
缓存是性能优化的最后一招,不是第一招。在你决定加缓存之前,先问自己几个问题:
- 数据库有没有加索引?
- SQL有没有做 EXPLAIN 分析?
- 有没有不必要的全表扫描?
- 连接池配置合理吗?
我见过太多项目,一遇到性能问题就加缓存,结果缓存层越来越厚,数据越来越乱,bug越来越难找。最后维护成本远超性能收益。
缓存的本质是:用数据不一致的风险,换取性能的提升。这不是免费的午餐。
如果你确定要上缓存,那就在系统设计之初就把缓存失效的策略想清楚,而不是等上线了再临时抱佛脚。到时候你抱的可能不是佛脚,是锅。
总结
缓存失效是后端开发中最容易被低估的问题。它不像代码bug那样有明确的报错信息,它只是静静地让你的数据悄悄变旧,等你发现的时候,用户已经骂街了。
我的经验是:缓存的坑,都是自己挖的。不是因为技术难,而是因为一开始就没想清楚数据一致性意味着什么。
所以下次有人跟你说"上个Redis缓存很简单"的时候,你可以把本文链接甩给他。
让他知道什么叫简单。