连载中 5/16

缓存击穿:热点 key 过期那一秒的互斥锁与逻辑过期

2026-05-17 · 1881 阅读 · 0 评论 · 0 赞

大促开始三秒,数据库先倒下了

先讲个我亲历的事故。去年大促,运营把一款爆款的详情页挂上了首页 C 位,零点刚过,流量像开闸一样涌进来。前两秒风平浪静——缓存热着,接口稳定在 20 毫秒。第三秒,告警群炸了:商品库 CPU 直接干到 100%,连接池耗尽,详情接口全线超时,页面白屏。

翻日志发现一个非常巧合的事实:被首页疯狂访问的热点 key product:detail:1001,过期时间恰好落在了流量洪峰的正中央。缓存失效的那一毫秒,上万个请求同时未命中,又同时冲向数据库——一个 key,干翻了一个库。

这就是缓存击穿。注意它和上一篇讲的穿透不是一回事:数据在数据库里好好躺着,只是缓存恰好在最不该失效的时刻失效了。穿透是「查无此人」,怎么查都查不到;击穿是「检票员恰好在这个节骨眼上离开了检票口」——队伍还在以每秒几千人的速度变长。

先分清:穿透、击穿、雪崩到底差在哪

缓存经典的「三问」经常被混为一谈。排查线上问题最怕对号入座对错了病,先用一张表划清边界:

问题缓存里数据库里典型触发核心解法
缓存穿透没有也没有恶意请求不存在的 id参数校验、空值缓存、布隆过滤器
缓存击穿没有有(单点热点)热点 key 在高峰期恰好过期互斥锁、逻辑过期
缓存雪崩大批没有有(大面积)大批 key 同时到期或 Redis 宕机过期时间加随机、多级缓存、集群高可用

记忆口诀:穿透查无此数,击穿单点失效,雪崩集体失效。上一篇的四道防线管的是穿透,这一篇的两种方案管击穿,至于雪崩——留给下一篇细说。

方案一:互斥锁,只放一个人去查库

思路朴素到近乎笨拙:缓存未命中时先抢一把分布式锁,抢到的才有资格去查库、回填缓存;没抢到的原地稍等,稍后再读一次缓存。数据库面对的并发瞬间从「上万」收敛成「1」,洪水被捏成了一根水管。

public Product getProduct(Long id) {
    String key = "product:detail:" + id;
    String lockKey = "lock:product:" + id;
    for (int i = 0; i < 10; i++) {
        Product p = redisTemplate.opsForValue().get(key);
        if (p != null) {
            return p;
        }
        // 抢锁:SET NX EX,全场只放一个人去查库
        String requestId = UUID.randomUUID().toString();
        Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, requestId, Duration.ofSeconds(10));
        if (Boolean.TRUE.equals(locked)) {
            try {
                // 双重检查:前面排队的人可能刚把缓存回填好
                p = redisTemplate.opsForValue().get(key);
                if (p == null) {
                    p = productMapper.selectById(id);
                    redisTemplate.opsForValue().set(key, p, Duration.ofMinutes(30));
                }
                return p;
            } finally {
                // 生产环境建议用 Lua 脚本校验 requestId 后再删
                redisTemplate.delete(lockKey);
            }
        }
        // 没抢到锁:稍等片刻,下一轮先读缓存
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            break;
        }
    }
    throw new IllegalStateException("查询过于频繁,请稍后重试");
}

这段代码里有四个细节,一个都不能省:

  • 锁必须带过期时间:万一持锁的机器在查库时宕机,这把锁就永远悬在那,后面所有请求集体陪葬。setIfAbsent 的第三个参数是保命符,不是可选项。
  • 双重检查必不可少:拿到锁之后必须再读一次缓存——你前面排队的那位,可能刚把数据回填好。不查这一次,等于大家白排队。
  • 锁要挂在 key 上:lock:product:1001 只锁这一个商品。要是全站共用一把锁,卖包的和卖鞋的互相阻塞,击穿还没发生,内耗先开始了。
  • 删锁前最好验明正身:示例里直接 delete 是为了简洁,严谨做法是用 Lua 脚本「确认 value 是自己的 requestId 再删」。否则自己的锁恰好超时被别人接管,你恢复后手起刀落,删掉的是别人的锁。
顺带一提:锁的过期时间要略大于「查库 + 回填」的最坏耗时。锁给了 10 秒、查库要 15 秒,第 10 秒锁过期,第二个请求大摇大摆进来又查一遍库——互斥锁等于白加。

方案二:逻辑过期,让热点 key 永不失效

互斥锁的代价是「总得有人排队」。如果你运营的是秒杀级的超热 key,哪怕 50 毫秒的等待也会被千万 QPS 放大成一次体验事故。那就换个思路:干脆不让 key 过期

物理 TTL 不设了,把「逻辑过期时间」塞进 value 一起存。读的时候先看一眼逻辑上过期没有:没过期,正常返回;过期了,先把旧数据还回去保命,同时抢锁异步重建缓存。等重建完成,后续请求自然拿到新数据。

类比一下:超市货架上的牛奶到了保质期,没人会把货架清空、让顾客干着等补货——正常操作是牛奶先摆着,店员同时去仓库搬新的。

// value 里包一层:业务数据 + 逻辑过期时间戳
public class CacheWrapper {
    private Product data;
    private long expireAt; // 逻辑过期时间(毫秒时间戳)
    // 省略 getter/setter
}

public Product getProductV2(String key) {
    String json = redisTemplate.opsForValue().get(key);
    if (json == null) {
        return null; // key 压根不存在,交给上一篇的四道防线
    }
    CacheWrapper wrapper = JSON.parseObject(json, CacheWrapper.class);
    if (wrapper.getExpireAt() > System.currentTimeMillis()) {
        return wrapper.getData(); // 还没过期,正常返回
    }
    // 逻辑过期了:先返回旧数据保命,再尝试异步重建
    String lockKey = "lock:rebuild:" + key;
    Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofSeconds(30));
    if (Boolean.TRUE.equals(locked)) {
        rebuildExecutor.execute(() -> {
            try {
                Product fresh = productMapper.selectById(wrapper.getData().getId());
                CacheWrapper next = new CacheWrapper();
                next.setData(fresh);
                next.setExpireAt(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30));
                redisTemplate.opsForValue().set(key, JSON.toJSONString(next));
            } finally {
                redisTemplate.delete(lockKey);
            }
        });
    }
    // 不管有没有触发重建,先把旧值还回去
    return wrapper.getData();
}

优雅,但有两个代价必须想清楚:

  • 短暂脏读:重建完成前,所有人读到的都是旧数据。商品详情、活动配置这类场景通常无所谓;换成库存、价格这种强一致数据,就得掂量掂量。
  • 内存常驻:TTL 永不生效,key 自己删不掉自己。热点下架后要靠主动删除或定期巡检清理,否则 Redis 会被「僵尸热点」慢慢占满。

怎么选?一张表说清楚

维度互斥锁逻辑过期
数据一致性强,读到的一定是新数据弱,重建完成前返回旧值
请求体验未抢到锁的请求需要等待重试零等待,直接返回
实现复杂度低,几十行代码搞定高,要包装 value、维护异步线程池
额外内存热点数据常驻内存,需主动清理
适合场景一般热点、一致性敏感大促级超热 key、体验敏感

我的选型经验是这样的:

  • 一致性优先、热点等级一般 → 互斥锁。几十行代码,绝大多数业务够用。
  • 大促级超热 key、体验优先 → 逻辑过期。用短暂旧读换零等待。
  • 共同前提是你能识别热点 key。拿不准就上热点探测(Redis 自带 hotkey 分析,或者运营后台手动标记),别指望每条数据都享受 VIP 待遇。

还有一个简化玩法:热点 key 永不过期 + 后台定时任务主动刷新。相当于逻辑过期的手动挡,粗,但稳,适合 key 数量少且可枚举的场景。


到这里,「缓存三问」已经解完两问。下一篇讲第三问——缓存雪崩:当大批 key 集体失效、甚至整个 Redis 都宕掉的时候,怎么让数据库活着撑过那一劫?过期时间加随机值只是入门,多级缓存和集群高可用才是重头戏。下篇见。

咖啡凉了,记得趁热喝。

503

10 年全栈工程师 · 503咖啡馆主理人

#Redis#缓存击穿#分布式锁#热点key#高可用

评论 (0)

相关推荐

连载中 12/20

排查四件套:jstack、jmap、jstat、jcmd 的实战分工

jstack 看线程在干什么,jmap 看堆里装了什么,jstat 看运行时在变什么,jcmd 是统一入口。四把刀各管一段,配合着用没有查不动的现场。

#jstack#jmap#jstat#jcmd#排查工具
2026-09-16 · 4 阅读 · 0 评论 · 0 赞
连载中 11/20

GC 日志:把回收过程翻译成人话

一行 GC 日志里塞着七种信息:谁触发的、收了哪、停了多久、活了哪些。加上 -Xlog 配置,再加上日志分析工具,GC 不再是只能盯监控曲线的黑盒。

#GC日志#Xlog#日志分析#GC监控#Full GC排查
2026-09-16 · 13 阅读 · 0 评论 · 0 赞
连载中 10/20

ZGC:亚毫秒停顿是怎么炼成的

百 G 大堆停顿不到一毫秒,靠的是把搬家全部挪到并发阶段——着色指针让引用自带状态,读屏障让搬运中的对象依然可访问。代价是吞吐与内存,收益是停顿与堆大小解耦。

#ZGC#着色指针#读屏障#亚毫秒停顿#分代ZGC
2026-09-15 · 8 阅读 · 0 评论 · 0 赞