上周刚上线的用户中心接口,第二天产品就举着监控截图冲过来:"这接口咋比我早上的地铁还堵?用户说点个按钮等 3 秒,再慢就要卸载了!"
排查半天发现 ------ 数据库被按在地上反复摩擦:同一个用户信息,每秒被查几十次,数据库 CPU 直接飙到 90%。这时候我才拍大腿:"忘了加缓存!"
今天就带大家把缓存扒得明明白白:从 "啥是缓存" 到 "一致性坑怎么填",全是后端日常能用的干货,看完直接上手不踩雷!
1. 先搞懂:缓存到底是个啥?(不是 "存零食" 那种)
其实缓存的逻辑特简单,就像你工位上的 "零食抽屉":
- 平时想吃零食(查数据),不用每次都跑楼下超市(数据库)------ 抽屉里有(缓存命中),直接拿,1 秒搞定;
- 抽屉里没有(缓存未命中),再去超市买,回来顺便塞抽屉里(写缓存),下次吃就快了。
用技术话讲:缓存是 "暂存高频访问数据的临时存储器" ,速度比数据库快 N 倍(比如内存缓存比硬盘数据库快 100-1000 倍),核心作用就一个 ------ 帮数据库 "减负",让接口跑得飞起。
2. 缓存分两类:本地缓存 vs 分布式缓存(别用混了!)
后端写多了就知道,缓存不是 "一刀切",得看场景选 ------ 主要分 "自己用的" 和 "大家共用的" 两种。
2.1 本地缓存:单机 "小抽屉",快但有局限
本地缓存就是把数据存在当前服务器的内存里,比如 Java 里的Guava Cache、Caffeine,Python 的functools.lru_cache。
优点:快到离谱!
不用走网络,直接读内存,响应时间能压到毫秒甚至微秒级 ------ 比如你写个定时任务,把热门配置加载到本地缓存,接口查配置时根本不用碰数据库,爽歪歪。
缺点:分布式环境直接 "翻车"
就像你工位的零食抽屉,只有你能吃 ------ 要是服务部署了 3 台服务器(分布式),每台都有自己的本地缓存:
- 服务器 A 更新了数据库里的用户余额,同步更了自己的缓存;
- 但服务器 B、C 的缓存还是 "旧余额",用户访问 B 就看到旧数据,访问 A 就看到新数据,这不就 "精神分裂" 了?
所以本地缓存只适合:单机部署、数据不常变、不需要多机共享的场景(比如本地测试、简单工具类)。
2.2 分布式缓存:多机 "共享冰箱",统一又能打
分布式缓存是把数据存在独立的缓存服务器集群里(比如 Redis、Memcached),所有后端服务器都去这一个 "冰箱" 里拿数据 ------ 不管你访问哪台后端服务器,查的都是同一个缓存,数据肯定一致。
优点:分布式场景的 "刚需"
- 多机共享:所有服务器都读同一个缓存,不会出现数据不一致;
- 容量大:缓存服务器可以集群扩容,存个几十 G 数据没问题;
- 高可用:Redis 搞个主从 + 哨兵,就算一台缓存机挂了,另一台马上顶上,不影响业务。
缺点:多走一步网络
毕竟要和缓存服务器通信(走 TCP/IP),比本地缓存慢一点 ------ 但也就多几毫秒,对比数据库的几十上百毫秒,还是快很多,绝大多数场景都能接受。
总结:怎么选?
场景 | 选哪种? | 例子 |
---|---|---|
单机部署、数据不变 | 本地缓存 | Guava Cache 存配置 |
分布式部署、多机共享 | 分布式缓存 | Redis 存用户会话、商品 |
3. 最头疼的坑:缓存一致性咋破?(别让缓存变 "脏")
用缓存爽归爽,但一更新数据就容易出问题 ------ 比如你改了数据库里的商品价格,缓存里还是旧价格,用户看到的就是 "假价格",这就是缓存一致性问题(缓存脏了)。
先看为啥会脏?常见场景:
- 先更数据库,再更缓存 ------ 要是更缓存失败,数据库是新的,缓存是旧的,脏了;
- 先删缓存,再更数据库 ------ 要是更数据库失败,下次查缓存没了,去查数据库(旧的),又把旧数据写回缓存,还是脏。
别慌!下面 3 个方案,从 "强一致" 到 "最终一致",覆盖不同业务需求。
3.1 双写加锁:追求 "绝对一致",牺牲点性能
核心思路:用锁保证 "更新数据库" 和 "更新缓存" 是一个 "原子操作"------ 同一时间只有一个线程能改,别人都得排队,不会乱。
代码逻辑(Java 示例):
php
// 用key做锁,保证同一key的操作排队
synchronized (productId) {
try {
// 1. 先更数据库(成功才算第一步)
productMapper.updatePrice(productId, newPrice);
// 2. 再更缓存(数据库成功了,缓存才更)
redisTemplate.opsForValue().set("product:price:" + productId, newPrice);
} catch (Exception e) {
// 失败了可以回滚缓存(比如删了),避免脏数据
redisTemplate.delete("product:price:" + productId);
throw new RuntimeException("更新失败");
}
}
优点:强一致!
只要锁没毛病,数据库和缓存肯定同步,适合 "钱相关" 的场景(比如用户余额、订单金额)------ 总不能用户付了钱,缓存里还是没付的状态吧?
缺点:并发高了会 "堵"
锁会让并发请求排队,比如每秒 1000 个请求,都得等着一个一个处理,接口响应时间会变长 ------ 鱼和熊掌不可兼得,看业务能不能接受。
3.2 延迟双删:最终一致,性能不翻车
要是业务能接受 "短暂不一致"(比如商品详情页,1 秒内看到旧价格没事),延迟双删是性价比很高的方案。
核心思路:删两次缓存,中间隔一会儿,把 "旧数据写回缓存" 的坑堵上。
步骤拆解:
- 先删缓存:不管后面成不成,先把旧缓存清了,避免新请求读旧数据;
- 更新数据库:正常改数据库;
- 等一会儿(比如 1 秒):关键!等那些 "在更新数据库前就查缓存、没查到去查数据库" 的请求,把旧数据写回缓存之前,再删一次;
- 再删缓存:把可能被写回的旧缓存清掉,下次请求就会查新数据库,写新缓存。
代码逻辑(关键部分):
scss
public void updateProductPrice(String productId, BigDecimal newPrice) {
// 1. 第一次删缓存
redisTemplate.delete("product:price:" + productId);
// 2. 更新数据库
productMapper.updatePrice(productId, newPrice);
// 3. 等1秒(时间根据业务调,比如数据库读耗时+网络延迟)
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 4. 第二次删缓存
redisTemplate.delete("product:price:" + productId);
}
优点:性能好,几乎不阻塞
不用加锁,请求来了直接处理,就等 1 秒(一般业务感知不到),适合大部分非金融场景(商品、文章、用户资料)。
缺点:短暂不一致
中间那 1 秒,可能有用户看到旧数据 ------ 但总比一直脏数据强,而且大部分场景用户根本没感觉。
3.3 异步双写:主流程飞快,缓存慢慢更
要是你想让 "更新接口" 跑得飞快,哪怕缓存慢个几十毫秒也没事,异步双写就很合适。
核心思路:更新数据库后,不用等缓存更新,直接返回;缓存更新扔给 "异步线程" 或者 "MQ" 去做,后台慢慢处理。
步骤拆解:
- 同步更新数据库(这步必须成功);
- 往 MQ(比如 RabbitMQ、Kafka)里扔一个 "更新缓存" 的消息;
- 接口直接返回 "更新成功";
- 后台起个消费者,拿 MQ 消息,异步更新缓存。
优点:主流程不阻塞
用户点 "更新",瞬间看到 "成功",体验好;缓存更新在后台慢慢跑,就算 MQ 排队,也只是晚一点同步,不影响用户。
缺点:一致性延迟可能更长
要是 MQ 堆积严重,缓存可能几分钟都没同步 ------ 适合 "对实时性要求不高" 的场景(比如用户改个昵称,就算缓存晚 1 分钟同步,也没啥大问题)。
4. 最后一招:缓存过期不能忘(兜底神器)
不管你用哪种一致性方案,都得给缓存加个 "过期时间"(比如 Redis 的expire)------ 这是最后一道兜底,防止缓存 "永久脏"。
为啥要加过期时间?
- 就算更新缓存失败,过期后缓存会自动失效,下次请求会查数据库,把新数据写回缓存,相当于 "自动修复";
- 避免缓存越存越多,占满内存 ------ 比如一些冷门数据,存一年都没人查,过期了就自动删掉,省内存。
注意点:
- 过期时间别太短:比如设 10 秒,缓存频繁失效,会频繁查数据库("缓存穿透" 的前兆);
- 过期时间别太长:比如设 24 小时,要是缓存脏了,得等 24 小时才修复,用户体验太差;
- 建议:根据业务调,比如商品价格设 5 分钟,用户会话设 2 小时。
总结:缓存用法一句话搞定
- 单机用本地缓存(Guava),分布式用 Redis;
- 钱相关用双写加锁(强一致),一般业务用延迟双删(性价比高),用户体验优先用异步双写;
- 不管啥方案,都给缓存加个过期时间(兜底!)。
最后想问大家:你们平时用缓存踩过哪些坑?比如遇到过缓存穿透、雪崩吗?评论区聊聊,一起避坑涨经验!