多级缓存架构(Redis + 本地缓存)场景下,仅依赖 Redis 互斥锁无法有效防御缓存击穿——本地缓存各自独立、无锁机制、过期时间难以统一,极易导致多个服务实例同时回源数据库。必须引入外部协调机制,将“重建缓存”的任务集中管控,消息队列(MQ)是目前较为成熟且可靠的解决方案之一。

为什么本地缓存会让 Redis 互斥锁形同虚设
本地缓存(如 Caffeine、Guava Cache)属于进程内独立缓存,各实例间互不共享。即便在 Redis 层通过 SETNX 实现了互斥锁,当 10 台服务实例各自的本地缓存同时过期,它们会在同一毫秒内发现本地缓存缺失、同时查询 Redis、又同时发现 Redis 数据也已过期,最终全部触发回源数据库的逻辑。核心问题在于:锁的作用范围无法覆盖本地缓存这一层级。
实践中常见的异常现象包括:
- 数据库 QPS 瞬间飙升数倍,远超单台机器的回源承载能力
- 服务日志显示多个实例几乎同时输出“开始加载 user:123”
- 本地缓存设置 5 分钟过期,Redis 设置 10 分钟过期,本地先过期后触发无效回源
根本原因并非缺少锁机制,而是锁的覆盖范围未能延伸到本地缓存层。
基于 MQ 实现“全局唯一回源”的核心设计要点
整体思路清晰明确:所有服务实例在发现两级缓存均未命中时,不直接查询数据库,而是发送一条 CacheRefreshEvent 到消息队列(如 Kafka 或 RocketMQ),由专属消费者负责执行缓存重建,并写入 Redis 同时广播清理本地缓存。
实际操作建议:
- 消息必须携带唯一业务标识(如
"user:123"),消费者按业务 key 进行并发控制(Kafka 单分区内保证顺序,或 RocketMQ 按 key 哈希分发到固定队列) - 生产者发送消息前,先通过 Redis 的
SET key_refresh_lock:user:123 "1" NX PX 30000进行去重——避免网络重试导致重复发送事件 - 消费者成功完成缓存重建后,除了执行
SET写入 Redis,还需发送一条CacheInvalidateEvent到专用 topic(如cache-invalidate),各实例监听该消息并调用localCache.invalidate("user:123") - 本地缓存建议启用
refreshAfterWrite(Caffeine)而非expireAfterWrite,让旧值继续对外服务,后台异步刷新,降低缓存击穿的感知度
MQ 方案中容易被忽视的三个关键陷阱
并非简单搭建 producer/consumer 就能解决问题,任何一个环节处理不当都可能让系统退化为无防护状态:
CacheRefreshEvent消息未实现幂等消费:消费者重启或重平衡后重复处理,导致数据库被反复查询。必须借助 Redis 记录已处理的事件 ID 或业务 key 的最后刷新时间戳,消费前进行校验- 本地缓存失效广播丢失:MQ 消费失败未重试、topic 无备份、消费者 group offset 提交过快。建议采用至少一次语义,同时为本地缓存失效操作增加 fallback 日志告警
- Redis 缓存写入与 MQ 发送未处于同一事务:数据库查询成功但 Redis
SET失败,或 MQ 发送失败但本地缓存已被清空。应将 Redis 写入作为消息生产的前置条件,写入失败则不发送消息
真正的难点不在于发送消息,而在于让“本地缓存失效”这一操作在分布式环境下实现可靠、及时、可追溯。MQ 只是传输通道,关键在于事件 payload 的设计、消费者的幂等性保障,以及本地缓存生命周期与消息生命周期的对齐。否则,缓存击穿问题只是从数据库层面转移到了 MQ 消费端。
