Redis 缓存策略与缓存穿透、雪崩、击穿的防护方案
一、缓存引入的复杂性,往往比它解决的性能问题更难处理
Redis 几乎是每个需要性能优化的系统都会引入的组件。把它放在数据库前面,读请求先查缓存,缓存没有再查数据库并回写缓存——这个模式很简单,也能解决大部分读密集场景的性能瓶颈。但缓存引入后,系统里多了一个需要维护、监控和在高可用架构里考虑故障切换的组件。更麻烦的是,缓存和数据库之间的数据一致性问题、缓存失效时的雪崩问题、以及恶意请求导致的缓存穿透问题,都是引入缓存之前不存在的问题。
一个好的缓存策略,必须在「缓存命中率」、「数据一致性」和「系统复杂度」之间做权衡。Cache-Aside(旁路缓存)模式实现简单、适用场景广,但一致性最弱;Read-Through/Write-Through 模式一致性更好,但实现更复杂;Write-Behind 模式性能最好,但一致性最弱,适合对一致性要求不高的场景。没有哪种模式是万能的,选型取决于业务对数据一致性的要求。
但无论选哪种模式,缓存系统都必须处理三个经典问题:缓存穿透(查询一个不存在的数据,缓存和数据库都没有,导致每次请求都打到数据库)、缓存雪崩(大量缓存同时失效,导致数据库压力骤增)、缓存击穿(一个热点 Key 失效的瞬间,大量请求打到数据库)。这三个问题都有成熟的解决方案,但需要在设计缓存层时就考虑进去,而不是等问题出现再补救。
二、缓存策略选型:Cache-Aside、Read-Through 与 Write-Behind 的权衡
flowchart TD A[缓存策略选型] --> B{一致性要求?} B -- "最终一致可接受" --> C[Cache-Aside] B -- "较高一致性" --> D[Read-Through / Write-Through] B -- "性能优先/可丢失" --> E[Write-Behind] C --> F[实现简单/需手动管理] D --> G[一致性好/实现复杂] E --> H[性能最好/可能丢数据] F --> I[适合读多写少] G --> J[适合一致性敏感] H --> K[适合点选/计数]Cache-Aside(旁路缓存)是最常用的模式。读流程:先查缓存,命中则返回;未命中则查数据库,结果写入缓存,返回。写流程:先更新数据库,再删除缓存(注意是删除而不是更新缓存,这是 Cache-Aside 的标准做法)。删除缓存而不是更新缓存的原因是:并发情况下,更新缓存的操作可能覆盖成旧值。
但 Cache-Aside 有一个一致性窗口:在「删除缓存」和「下一个读请求把新数据加载到缓存」之间,如果有读请求,会读到旧数据。这个窗口通常很短(毫秒级),对大多数业务是可接受的。如果对一致性要求更高,可以考虑在写操作完成后,延迟一小段时间再删除一次缓存(延迟双删),或者用消息队列保证缓存删除的可靠性。
Read-Through 和 Write-Through 模式把缓存当成一个「数据访问层」,应用代码只和缓存交互,缓存负责和数据库同步。这种模式的一致性更好,因为缓存层可以保证「读一定走缓存、写一定同时写缓存和数据库」。但它的实现更复杂,通常需要用一个支持这种模式的缓存客户端或者库。
Write-Behind(异步写回)模式是性能最好的:写操作只写缓存,不立即写数据库,而是定期批量写回数据库。这个模式的性能非常好,因为写操作不需要等数据库确认;但一致性最差,如果缓存节点故障,还没写回数据库的数据就丢了。它适合对一致性要求不高、但写吞吐要求很高的场景(如计数器、点赞数)。
三、缓存穿透、雪崩与击穿:问题分析与工程解决方案
缓存穿透是指查询一个缓存和数据库都没有的数据。攻击者可以构造大量不存在的 ID 发送请求,绕过缓存直接打数据库,导致数据库压力骤增。解决方案有两种:缓存空值和布隆过滤器。
缓存空值:如果数据库查不到,也在缓存里写一个空值(如NULL),并设置一个较短的过期时间(如 5 分钟)。这样下次同样的请求就能命中缓存,不会再打到数据库。但这种方案不能防止「每次用不同的不存在 ID 攻击」——因为每次 ID 不同,缓存里也没有。
布隆过滤器:在缓存前面加一个布隆过滤器,它能在常数时间内判断「某个 ID 是否可能存在」。如果布隆过滤器说「不存在」,那一定不存在,可以直接返回;如果布隆过滤器说「可能存在」,才继续走缓存和数据库的查询流程。布隆过滤器的缺点是「有误判率」(可能存在但实际不存在),以及「不支持删除」。Redis 4.0+ 的 RedisBloom 模块提供了布隆过滤器的实现。
缓存雪崩是指大量缓存同时失效,导致所有请求都打到数据库。解决方案是「过期时间加随机值」:每个缓存的过期时间 = 基础过期时间 + 随机偏移量(如 300 秒 + random(0, 60) 秒)。这样缓存的失效时间会分散开,不会同时失效。
另一个雪崩的解决方案是「缓存永不过期 + 后台更新」:缓存不设过期时间,由后台任务定期更新缓存。这种方式的好处是缓存永远不会失效,坏处是数据可能不是最新的,需要实现更新机制。
缓存击穿是指一个热点 Key 在失效的瞬间,大量并发请求同时发现缓存未命中,同时去查数据库,导致数据库压力骤增。解决方案是「互斥锁」:当缓存未命中时,只有一个请求可以去查数据库,其他请求等待这个结果。
在 Redis 里,可以用SET key value NX EX timeout实现简单的分布式锁:第一个请求设置锁成功,去查数据库并回写缓存;后续请求设置锁失败,等待一小段时间后重试读取缓存。
四、生产环境缓存管理:内存淘汰策略、监控与缓存预热
Redis 的内存是有限的,当内存满了之后,需要有策略决定删除哪些缓存。Redis 提供了多种内存淘汰策略(maxmemory-policy),包括:
noeviction:不淘汰,写操作时返回错误(默认)allkeys-lru:所有 Key 里,删除最久未使用的 Keyallkeys-random:所有 Key 里,随机删除volatile-lru:设置了过期时间的 Key 里,删除最久未使用的volatile-random:设置了过期时间的 Key 里,随机删除volatile-ttl:设置了过期时间的 Key 里,删除 TTL 最短的
生产环境通常选择allkeys-lru或者volatile-lru。但 LRU 是近似 LRU,不是精确 LRU——Redis 为了性能,采样少量 Key 然后淘汰其中最久未使用的,不是扫描所有 Key。
缓存监控的关键指标包括:缓存命中率(hit rate)、内存使用量、连接数、命令延迟、以及 evicted_keys(被淘汰的 Key 数量)。命中率低于 80% 通常意味着缓存策略有问题(如缓存过期太快、缓存 Key 设计不合理);evicted_keys 持续大于 0 意味着内存不够,需要考虑扩容或者优化缓存策略。
缓存预热是系统重启或者新上线时的一个重要操作。如果系统重启后缓存是空的,所有请求都会打到数据库,可能把数据库打挂。缓存预热的做法是:在系统启动后、接收流量前,先把热点数据加载到缓存里。可以写一个预热脚本,也可以在应用启动逻辑里触发。
五、总结
Redis 缓存策略的设计,是在缓存命中率、数据一致性和系统复杂度之间做权衡。Cache-Aside 模式实现简单,适合大多数场景;缓存穿透用布隆过滤器或者缓存空值来解决;缓存雪崩用过期时间加随机值来防止;缓存击穿用互斥锁来保护数据库。生产环境中,内存淘汰策略的选择、缓存命中率的监控、以及缓存预热的准备,是把缓存从「能跑」变成「能稳定跑」的三个关键工程细节。缓存不是银弹,但不合理地使用缓存,比不使用缓存更危险。