本文目录导读:

多级缓存架构(通常是本地缓存 + 分布式缓存 + 数据库)是应对高并发、低延迟场景的利器,但在项目落地过程中,确实隐藏着不少“坑”,如果没有处理好,轻则数据不一致,重则引发缓存雪崩、穿透,导致系统崩溃。
下面我结合实际项目经验,梳理了最常遇到的几类坑及其根因与解决方案。
数据一致性:最核心的坑
缓存与数据库的双写不一致
- 场景:先更新数据库,再删除缓存,在并发下,线程A更新数据库后,还没来得及删缓存,线程B就读到了旧缓存,更糟糕的是,如果线程A删缓存失败,后续所有请求都会读到脏数据。
- 场景二:先删缓存,再更新数据库,在并发下,线程A删了缓存,线程B发现缓存缺失,从数据库读到旧数据并写回缓存,此时线程A才更新数据库,导致缓存中永久是旧数据。
- 根因:读写操作时序交错,缺乏原子性保证。
- 应对策略:
- 推荐方案:延迟双删(Cache-Aside Pattern + 延迟删除),先删除缓存,再更新数据库,休眠一小段时间(如几百毫秒),再次删除缓存,休眠是为了保证在“读请求”将旧数据写回缓存之前,第二次删除能覆盖掉。
- 更健壮方案:监听Binlog(如Canal),业务只更新数据库,由异步组件监听MySQL的Binlog变化,然后删除或更新缓存,优点是完全解耦业务逻辑,重试机制可靠,能根本解决双写问题。
本地缓存与分布式缓存的不一致
- 场景:应用A更新了Redis中的用户信息,但应用A、B、C各自的本地缓存(如Caffeine、Guava)中,可能还存着旧数据。
- 根因:本地缓存是进程级别的,无法被远程通知。
- 应对策略:
- 消息队列广播:当数据更新时,向一个独立的Topic发送一条消息(包含缓存Key),所有应用实例订阅该Topic,收到消息后删除本地缓存中对应的Key。
- 共享缓存作为权威源:本地缓存只作为“热数据”加速,且必须设置非常短的过期时间(如1-5秒),读取时先读本地,若未命中或过期,则读Redis,再回写本地,这本质上是“以时间换一致性”。
缓存雪崩、穿透、击穿:高并发的“三大杀手”
缓存雪崩
- 场景:大量缓存同时过期,或者Redis直接宕机,导致所有请求瞬间穿透到数据库。
- 根因:缓存集中过期或缓存层不可用。
- 应对策略:
- 避免集中过期:在设置过期时间时增加随机值(如
expire = 基础时间 + random(0, 300秒))。 - 本地缓存兜底:在Redis挂掉时,使用本地缓存(如Caffeine)顶住一部分请求。
- 互斥锁(Mutex):对缓存重建过程加锁,只允许一个线程去查数据库重建缓存,其他线程等待。
- 限流与熔断:对数据库访问层做限流,防止被打垮。
- 避免集中过期:在设置过期时间时增加随机值(如
缓存穿透
- 场景:查询一个数据库中不存在的数据(如不存在的用户ID),缓存中肯定没有,每次请求都穿过缓存直接打向数据库。
- 根因:恶意攻击或系统Bug,大量请求查询不存在的Key。
- 应对策略:
- 缓存空结果:即使数据库查询结果为空,也将其缓存(如
value = NULL),并设置一个较短的过期时间(如5分钟)。注意:要避免缓存大量无效Key。 - 布隆过滤器(Bloom Filter):预热时将所有可能存在的数据ID放入布隆过滤器,请求到来时先过过滤器,如果不在其中,直接返回空,不再查询数据库,这是最彻底的方案。
- 缓存空结果:即使数据库查询结果为空,也将其缓存(如
缓存击穿
- 场景:一个热点Key(如秒杀商品)在缓存过期的一瞬间,恰好有大量并发请求,全部直接打到数据库。
- 根因:单个热点Key失效 + 超高并发。
- 应对策略:
- 互斥锁(Mutex Key):当缓存失效时,获取分布式锁(如Redisson),只允许一个线程去查数据库并重建缓存,其他线程等待后直接从缓存读取。
- 逻辑过期:不设置物理过期时间,而是在缓存值中额外存一个逻辑过期时间,后台异步线程发现逻辑过期后,重新加载数据,读取时即使逻辑过期,也先返回旧数据,这能保证任何时候都不会有请求穿透,但会有一段时间数据不一致。
本地缓存(堆内缓存)的“坑”
内存泄漏与GC压力
- 场景:缓存了太多对象(尤其是大对象),导致频繁Full GC,或者使用了弱引用、软引用不当,导致对象无法回收。
- 根因:不合理的内存使用策略和缺乏淘汰机制。
- 应对策略:
- 限制最大容量:使用
Caffeine.maximumSize(10_000)或Caffeine.maximumWeight(500_1000)。 - 合理设置引用类型:对不太重要、允许丢失的数据,使用软引用(
SoftReference)或弱引用(WeakReference),让GC在内存不足时自动回收。 - 监控:通过JMX或Micrometer监控本地缓存的大小、命中率、内存占用。
- 限制最大容量:使用
内存占用与OOM
- 场景:本地缓存中存储了大量数据,或者某个时间段内数据急剧膨胀,导致堆内存溢出。
- 根因:未设置容量上限或淘汰策略不当。
- 应对策略:
- 淘汰策略:选择合适的淘汰算法,如
Caffeine的W-TinyLFU(频率+最近访问)比LRU更适合缓存场景。 - 禁止缓存大对象:如果单个对象超过几百KB,应优先考虑只缓存其索引或摘要,或直接放到Redis中。
- 淘汰策略:选择合适的淘汰算法,如
分布式缓存的“坑”
数据倾斜
- 场景:Redis集群中,某个分片(Slot)上的数据量或访问量远高于其他分片。
- 根因:热点Key集中或Hash函数设计不合理。
- 应对策略:
- 热点Key打散:在Key后面加上随机后缀(如
hotkey_0、hotkey_1),写入多个分片,读取时先根据后缀规则到对应分片获取。 - 本地缓存(兜底):对于极高频的Key,使用本地缓存减少对Redis的集中访问。
- 热点Key打散:在Key后面加上随机后缀(如
大Key(Big Key)
- 场景:某个Key对应的Value非常大(如几MB的JSON字符串或包含上万个元素的Set)。
- 影响:网络延迟增加、单个分片CPU飙升、Redis阻塞(如
del一个大Key会长时间阻塞)。 - 应对策略:
- 拆分:将一个大Hash拆分为多个小Hash(如按字段拆分)。
- 压缩:使用Gzip、Snappy等算法对Value进行压缩。
- 禁止使用某些命令:
del、hgetall、lrange、smembers等返回大量数据的命令,改用unlink(异步删除)、hscan、sscan。
代码与设计上的“坑”
缓存穿透攻击
- 场景:恶意用户构造大量数据库不存在的ID(如负数、超长字符串),导致布隆过滤器失效或缓存空值膨胀。
- 应对策略:
- 参数校验:在入口处对Key的合法性进行校验(如正则、ID范围、长度限制)。
- 限流:对IP或用户维度进行限流。
缓存预热不当
- 场景:系统重启后,缓存为空,大量请求瞬间穿透到数据库。
- 应对策略:
- 系统启动时异步预热:加载热点数据到缓存(本地+Redis)。
- 懒加载 + 互斥锁:使用上述“互斥锁”方案防止启动瞬间的击穿。
监控与告警缺失
- 场景:缓存命中率从95%掉到50%无人知晓,直到数据库被打爆才发现。
- 应对策略:
- 关键指标监控:缓存命中率、缓存空间使用率、请求延迟(P99)、更新/删除失败次数。
- 告警:设置阈值(如命中率 < 90% 报警),接入Prometheus + Grafana + AlertManager或公司自研监控。
最佳实践避坑指南
| 问题类别 | 核心坑点 | 推荐解决方案 |
|---|---|---|
| 一致性 | 双写不一致 | 延迟双删 + 重试 / Binlog监听(Canal) |
| 高并发 | 穿透/击穿/雪崩 | 空值缓存 + 布隆过滤器 + 互斥锁 + 随机过期时间 + 限流 |
| 本地缓存 | OOM / GC压力 | 限制最大容量 + 软引用 + W-TinyLFU淘汰算法 + 实时监控 |
| 分布式缓存 | 大Key / 数据倾斜 | 拆分/压缩 + 热点Key打散 + 本地缓存兜底 |
| 设计缺陷 | 无监控 / 预热失效 | 全链路监控(命中率、延迟、容量) + 启动预热 + 参数校验 |
最重要的原则:缓存是加速手段,不是数据持久化方案,始终假设缓存可能随时不可用或数据不一致,业务逻辑必须能回退到数据库查询,且要能忍受短暂的不一致,设计时要对“缓存缺失”和“缓存失败”有兜底策略。