缓存击穿的核心是热点key过期瞬间并发查库,真正有效的方案是让数据库查询异步排队而非抢锁;用MQ或Redis队列+Lua控制唯一任务提交,配合逻辑过期与显式过期,确保高并发下仅一次查库。

缓存击穿的核心问题不是“查不到”,而是“同一热点 key 过期瞬间,多个并发请求同时涌向数据库”。直接加锁排队能缓解,但真正在生产环境稳住数据库的,是把「数据库查询」本身变成可排队、可限流、可降级的操作——不是靠 Redis 锁抢着查,而是让数据库查询任务进队列,由单个消费者串行执行。
为什么 setnx + sleep 自旋不是真正意义上的“排队”
很多实现用 SETNX 抢锁,失败就 Thread.sleep(100) 再重试。这看起来像排队,实际是线程空转+竞争浪费:
- CPU 在无意义地轮询锁,尤其在重建耗时长(比如多表 JOIN + 计算)时,几百个线程都在自旋,不释放资源
- 锁释放后所有线程又几乎同时去
GET缓存,仍可能触发二次未命中(因写入有延迟或网络抖动) - 没有超时熔断:若数据库卡住或慢 SQL 挂起,所有等待线程会无限阻塞
用消息队列替代“抢锁”,让数据库查询真正排队
关键不是让请求等锁,而是让“重建缓存”这个动作变成异步、可追踪、可重试的任务。典型流程:
- 请求 A 发现缓存未命中 → 触发
publish到 Redis Channel 或投递到 Kafka/RocketMQ 的cache-rebuild-topic - 独立的 consumer 服务监听该 topic,收到后执行
SELECT ... FROM users WHERE id = ?,并写入SET user:123 {...} - 请求 A 及后续同 key 请求,统一走“先查缓存,未命中则返回旧值(逻辑过期)或兜底默认值”,不阻塞等待
这样,无论 100 个还是 1000 个并发请求打进来,最终只产生 1 次数据库查询;其余请求要么读旧缓存,要么走降级逻辑。
Redis 本地队列 + Lua 脚本控制唯一任务提交
如果不想引入外部 MQ,可用 Redis 自身能力模拟轻量级任务队列:
- 用
LPUSH cache_rebuild_queue user:123提交任务,配合SETNX cache_rebuild_lock:user:123 1 EX 30防重复提交 - 用 Lua 脚本原子判断:若
cache_rebuild_lock:user:123不存在,则设置锁 + 入队;否则跳过 - 后台 worker 定期
BRPOP cache_rebuild_queue 0拉取任务,查库、写缓存、删锁
注意:BRPOP 是阻塞式,worker 数量应严格控制为 1(或用分布式锁保证仅一个实例消费),避免多个 worker 同时处理同一 key。
容易被忽略的三个细节
真正落地时,以下三点常被跳过,却直接决定方案是否扛得住压测:
-
cache_rebuild_lock的过期时间必须明显长于数据库查询最大耗时(建议设为 3× P99 查询延迟),否则锁提前释放,可能引发重复重建 - 写入缓存时,不要只写
SET,要用SET user:123 {...} EX 3600显式带过期,避免因 worker 崩溃导致 key 永久残留 - 对“逻辑过期”的 key,需在业务代码里主动检查
expire_at字段,而不是依赖 Redis TTL —— 因为 Redis 本身不支持给 value 里的时间戳自动触发事件


















