Redis 缓存完全指南:从一次缓存雪崩看懂穿透、击穿、雪崩怎么防

2021 年我负责商品详情页后端,早早加了 Redis 缓存——先查 Redis 命中直接返回、没命中才查 DB 再写回,跑了大半年稳稳当当。直到某个大促日出事:数据库 CPU 干到 100%、大量请求超时、详情页大面积打不开,可 Redis 内存连接 CPU 全都健康。扒日志才看明白根因:图省事所有商品缓存过期时间统一设成 30 分钟,而这批商品是大促前用脚本集中预热进缓存的、几乎同一时刻写进 Redis,30 分钟后又在几乎同一时刻一起过期,成千上万个缓存集体失效海量请求直冲数据库——这就是缓存雪崩。这件事逼我把穿透、击穿、雪崩彻底理清:三者共同点都是让请求绕过缓存砸向数据库,区别在缺口怎么出现有多大。穿透是查一个根本不存在的数据缓存永远建不起来,治法是缓存空值+布隆过滤器当前置门卫。击穿是一个高并发热点 key 恰好过期,过期瞬间成百上千并发同时查库,治法是互斥锁只放一个请求去重建。雪崩是一大批 key 同时过期或 Redis 整个宕机,治法是过期时间加随机抖动打散集体过期、Redis 高可用+多级缓存防整体宕机、限流降级做最后兜底。比事故更隐蔽的是一致性:标准做法是先更新数据库再删除缓存(删而不是写、删是幂等的没有并发覆盖)、必要时延迟双删,但只能换来最终一致而非强一致。四个工程坑:大 key 卡住单线程、热 key 打爆单分片、命中率必须接监控告警、序列化别用 JDK 默认。缓存不是加上去就一劳永逸的东西,它是一堵会塌的墙,需要持续运营。

2021 年,我负责一个商品详情页的后端。详情页的 QPS 不低,我早早就给它加了 Redis 缓存——商品数据先查 Redis,命中就直接返回;没命中,才去查数据库,再把结果写回 Redis。这套缓存跑了大半年,稳稳当当,我对它很放心。直到某个周六的大促日,出事了。监控告警炸成一片:数据库 CPU 直接干到 100%,大量请求超时,详情页大面积打不开。我的第一反应是去看 Redis——可 Redis 一切正常,内存、连接数、CPU 全都很健康。这就怪了:缓存明明好好的,数据库怎么会被打挂?我扒了一通日志才看明白:为了图省事,我给所有商品缓存设的过期时间,都是统一的「30 分钟」。而这批商品,是大促开始前,我用一个脚本【集中预热】进缓存的——也就是说,它们几乎是在【同一时刻】被写进 Redis 的。30 分钟后,它们又在【几乎同一时刻】,一起过期了。那一瞬间,成千上万个商品的缓存集体失效,海量请求像决堤的潮水,毫无遮拦地、直接冲进了数据库——数据库在一秒钟内,承受了平时几百倍的压力,当场被打垮。这,就是「缓存雪崩」。这件事逼着我把缓存穿透、缓存击穿、缓存雪崩到底有什么区别、各自该怎么防、以及缓存和数据库的一致性该怎么保证,彻底理清了一遍。本文是这份梳理的完整复盘。

问题背景:一次"缓存好好的,数据库却被打挂"的事故

背景:商品详情页,加了 Redis 缓存,跑了大半年稳稳当当
缓存逻辑:先查 Redis,命中直接返回;没命中才查 DB、写回 Redis

某个大促日,出事:
- ★★ 数据库 CPU 干到 100%,大量请求超时,详情页大面积打不开
- ★ 第一反应去看 Redis —— 内存/连接/CPU 全都健康,一切正常

★ 怪事:缓存好好的,数据库怎么会被打挂?

★★ 扒日志才看明白根因:
- 图省事,所有商品缓存过期时间统一设成【30 分钟】
- 这批商品是大促前用脚本【集中预热】的 —— 几乎同一时刻写进 Redis
- 30 分钟后,它们又在【几乎同一时刻】一起过期
- 那一瞬间成千上万个缓存集体失效,海量请求毫无遮拦
  直冲数据库 —— DB 一秒承受平时几百倍压力,当场被打垮

★★ 这就是【缓存雪崩】。

★ 本文要做的:把穿透、击穿、雪崩的区别、各自怎么防、
  缓存与数据库一致性,彻底讲透。

一次雪崩复盘:先分清穿透、击穿、雪崩

# === ★ 这三个词总被搞混,先一刀切清楚 ===

# === ★ 它们的共同点:都让请求"绕过缓存,砸向数据库" ===
# ★ ★ 缓存的全部意义,是当数据库前面的【挡箭墙】。这三种
#   事故,本质都是这堵墙【出现了缺口】,导致请求穿墙而过、
#   直接砸到后面的数据库上。区别只在于:缺口是【怎么】
#   出现的、有【多大】。

# === ★ 缓存穿透:查一个"根本不存在"的数据 ===
# ★ ★ 用户查询一个【数据库里压根没有】的数据(比如一个
#   不存在的商品 ID)。缓存里当然没有 -> 去查数据库 ->
#   数据库也没有 -> 于是【缓存里始终建立不起来】。
# ★ ★★ 要命的是:下次再用同一个不存在的 ID 来查,会
#   【重复】这个过程。如果有人拿海量的、随机的、不存在的
#   ID 来刷,每一个请求都会穿过缓存、砸到数据库 —— 缓存
#   这堵墙,对它们【形同虚设】。

# === ★ 缓存击穿:一个"热点 key"突然失效 ===
# ★ ★ 注意:数据【是存在】的。问题出在【某一个被高并发
#   访问的热点 key】,它的缓存恰好【过期】了。
# ★ ★★ 在它过期的那个瞬间,成百上千个并发请求同时发现
#   "缓存没了",于是【同时】涌去查数据库、又【同时】想
#   把它写回缓存。一个热点 key 的过期,瞬间在数据库上
#   砸出一个【尖锐的小洞】。

# === ★★ 缓存雪崩:大批 key 在同一时间一起失效 ===
# ★ ★ 它是"击穿"的【放大版、群体版】。不是一个 key,而是
#   【大量】key,在【几乎同一时刻】集体过期(就像我那次,
#   集中预热 + 统一过期时间);或者更直接 —— 整个 Redis
#   实例【宕机】了。
# ★ ★★ 后果:海量请求在同一瞬间全部失去缓存遮挡,像潮水
#   般砸向数据库。这不是一个小洞,是【整面墙塌了】。

# === ★ 一句话记住三者的区别 ===
# ★ ★ 穿透:查的数据【不存在】,缓存永远建不起来;
# ★ ★ 击穿:数据【存在】,但【一个】热点 key 过期了;
# ★ ★ 雪崩:【一大批】key 同时过期,或 Redis 整个挂了。

# === 小结 ===
# ★ 穿透、击穿、雪崩的共同点:缓存是数据库前面的挡箭墙,
#   这三种事故本质都是这堵墙出现了缺口、请求穿墙而过直接
#   砸到数据库,区别只在缺口怎么出现、有多大。★ 缓存穿透:
#   查一个数据库里压根没有的数据(如不存在的商品 ID),
#   缓存没有就查 DB、DB 也没有于是缓存始终建立不起来;要命
#   的是下次同一个不存在的 ID 再查会重复这个过程,有人拿
#   海量随机不存在的 ID 来刷每个请求都穿过缓存砸数据库、
#   缓存形同虚设。★ 缓存击穿:数据是存在的,问题出在某一个
#   被高并发访问的热点 key 恰好过期了,过期瞬间成百上千个
#   并发请求同时发现缓存没了、同时涌去查 DB 又同时写回缓存,
#   在数据库上砸出一个尖锐的小洞。★★ 缓存雪崩:是击穿的
#   放大版群体版 —— 大量 key 在几乎同一时刻集体过期(集中
#   预热+统一过期时间),或整个 Redis 实例宕机,海量请求
#   同一瞬间全部失去遮挡砸向数据库,不是小洞是整面墙塌了。
# ★ 一句话:穿透查的数据不存在缓存永远建不起来、击穿数据
#   存在但一个热点 key 过期了、雪崩一大批 key 同时过期或
#   Redis 整个挂了。
// ★ 最基础的缓存读取:Cache-Aside(旁路缓存)模式
// 这套逻辑本身没错,但它对穿透/击穿/雪崩"裸奔",毫无防御
import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.TimeUnit;

public Product getProduct(Long id) {
    String key = "product:" + id;

    // ① 先查缓存
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;                       // ★ 命中,直接返回
    }

    // ② 缓存没有 -> 查数据库
    Product product = productMapper.selectById(id);

    // ③ 查到了 -> 写回缓存,设 30 分钟过期
    if (product != null) {
        redisTemplate.opsForValue()
                .set(key, product, 30, TimeUnit.MINUTES);
    }
    // ★★ 裸奔点 1(穿透):product == null 时,什么都不做 ->
    //    下次同一个不存在的 id 再来,又走一遍 DB
    // ★★ 裸奔点 2(击穿):热点 key 过期瞬间,大量并发同时
    //    走到 ② -> 一起砸数据库
    // ★★ 裸奔点 3(雪崩):所有 key 都是"30 分钟",集中写入
    //    的 key 会集中过期 -> 整批一起砸数据库
    return product;
}
// ★ 下面三节,就是逐个把这三个"裸奔点"补上。

缓存穿透:挡住"查不存在数据"的恶意请求

# === ★ 穿透的本质:缓存对"不存在的数据"无能为力 ===

# === ★ 为什么普通缓存挡不住穿透 ===
# ★ ★ 缓存的工作方式是"查到了才缓存"。可一个不存在的
#   数据,你【永远查不到】,所以它【永远进不了缓存】——
#   每一次对它的查询,都注定要落到数据库上。
# ★ ★ 正常用户极少会查不存在的数据;但【恶意攻击者】会:
#   他用脚本生成海量随机的、不存在的 ID 狂刷,每一发都
#   精准绕过缓存、直击数据库。

# === ★ 解法一:把"空结果"也缓存起来 ===
# ★ ★ 思路很朴素:数据库查出来是【空】的,那就把这个
#   "空"也【写进缓存】—— 缓存一个特殊的空值标记。
# ★ ★ 这样,下次同一个不存在的 ID 再来,会在缓存里命中
#   这个"空值标记",直接返回,【不再打扰数据库】。
# ★ ★★ 关键细节:空值的过期时间要设得【短】(比如几
#   分钟)。因为这个数据【将来可能会被创建】出来,空值
#   留太久,会导致它真的被创建后、用户还在读到"不存在"。

# === ★★ 解法二:布隆过滤器(Bloom Filter)当"前置门卫" ===
# ★ ★ 缓存空值有个弱点:如果攻击者每次用【不同】的随机
#   ID,缓存里就会堆进海量的空值,占内存。
# ★ ★ 更彻底的方案,是在缓存【之前】加一道布隆过滤器。
#   把【所有真实存在】的 ID,预先灌进这个过滤器。请求
#   先问它:"这个 ID 可能存在吗?"
# ★ ★★ 布隆过滤器的特性很特别:它说【不存在,就一定
#   不存在】(可以放心直接拒掉);它说【可能存在,则
#   未必真存在】(有极小的误判率)。但这正好够用 ——
#   攻击者那些瞎编的 ID,会被它在第一道门就【挡掉绝大
#   多数】,根本到不了缓存和数据库。

# === ★ 两个方案怎么选 ===
# ★ ★ 一般业务:用"缓存空值",简单、够用;
# ★ ★ 数据量大、且面临恶意攻击:上"布隆过滤器"做前置
#   拦截,再配合缓存空值兜底。两者不冲突,可以叠加。

# === 小结 ===
# ★ 穿透的本质:缓存"查到了才缓存",而不存在的数据永远
#   查不到、永远进不了缓存,每次查询都注定落到数据库;
#   正常用户极少查不存在的数据,但恶意攻击者会用脚本生成
#   海量随机不存在的 ID 狂刷、每发都精准绕过缓存直击 DB。
# ★ 解法一缓存空值:数据库查出来是空的就把这个"空"也写进
#   缓存(缓存一个特殊空值标记),下次同一个不存在 ID 再来
#   在缓存命中空值标记直接返回不再打扰数据库;关键细节是
#   空值过期时间要设短(几分钟),因为这数据将来可能被创建
#   出来、空值留太久会导致真被创建后用户还读到"不存在"。
# ★★ 解法二布隆过滤器:缓存空值的弱点是攻击者每次用不同
#   随机 ID 会堆进海量空值占内存,更彻底的是在缓存之前加
#   一道布隆过滤器、把所有真实存在的 ID 预先灌进去;它的
#   特性是说"不存在就一定不存在"(可放心拒掉)、说"可能
#   存在则未必真存在"(极小误判率),正好够用 —— 攻击者
#   瞎编的 ID 在第一道门就被挡掉绝大多数。★ 怎么选:一般
#   业务用缓存空值简单够用,数据量大且面临恶意攻击上布隆
#   过滤器做前置拦截再配缓存空值兜底,两者可叠加。
// ★ 防穿透:缓存空值 + 布隆过滤器,双重拦截
import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.TimeUnit;

// ★ 一个表示"查过了,确实不存在"的空值标记
private static final String NULL_MARKER = "__NULL__";

public Product getProductSafe(Long id) {
    String key = "product:" + id;

    // === ★★ 第一道门:布隆过滤器 —— 拦掉绝大多数瞎编的 ID ===
    // 它说"不存在",就一定不存在 -> 直接拒掉,不碰缓存和 DB
    if (!productBloomFilter.mightContain(id)) {
        return null;
    }

    // === ② 查缓存 ===
    Object cached = redisTemplate.opsForValue().get(key);
    if (cached != null) {
        // ★ 命中的是"空值标记" -> 说明这个 id 已确认不存在
        if (NULL_MARKER.equals(cached)) {
            return null;
        }
        return (Product) cached;
    }

    // === ③ 缓存没有 -> 查数据库 ===
    Product product = productMapper.selectById(id);

    if (product != null) {
        redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
    } else {
        // ★★ 关键:查出来是空,也写进缓存 —— 但过期时间设【短】
        //    (5 分钟),因为这个 id 将来可能真的被创建出来
        redisTemplate.opsForValue()
                .set(key, NULL_MARKER, 5, TimeUnit.MINUTES);
    }
    return product;
}

缓存击穿:别让一个热点 key 过期就压垮数据库

# === ★ 击穿:数据是存在的,问题出在"高并发 + 恰好过期" ===

# === ★ 还原一下击穿的瞬间 ===
# ★ ★ 假设一个超级热点商品,缓存 key 每秒被访问 5000 次。
#   某一刻,它的缓存过期了。接下来的那一秒里:
#  - 5000 个请求,几乎【同时】查缓存,【全部】没命中;
#  - 于是这 5000 个请求,【全部】涌去查数据库;
#  - 然后它们【全部】又去把同一个结果写回缓存。
# ★ ★★ 本来,只需要【1 个】请求去查一次数据库、重建缓存
#   就够了。结果因为没有任何控制,变成了 5000 个请求一起
#   去查 —— 数据库被这一下尖锐的并发,瞬间打疼。

# === ★★ 解法一:互斥锁 —— 只放"一个"请求去重建 ===
# ★ ★ 核心思路:缓存失效后,不要让所有请求都冲去查库。
#   而是让它们去【抢一把锁】。
#  - ★ 抢到锁的那【一个】请求:去查数据库、重建缓存;
#  - ★ 没抢到锁的其他请求:不查库,【稍等一下、重试】——
#    等那个请求把缓存重建好,它们重试时就直接命中了。
# ★ 这样,无论多少并发,落到数据库上的,永远只有【一个】
#   重建请求。这是最常用的防击穿手段。

# === ★ 解法二:逻辑过期 —— 干脆不设物理过期 ===
# ★ ★ 另一种思路:热点 key 在 Redis 里【永不物理过期】。
#   而是在 value 里,自己存一个【逻辑过期时间】字段。
# ★ ★ 读取时,发现"逻辑上已过期",就【异步】起一个线程
#   去重建缓存,而当前请求【先返回那个旧值】。
# ★ ★★ 它的取舍很清晰:用"短暂地返回一点旧数据"作为
#   代价,换来"任何请求都【绝不会】因为缓存过期而被阻塞、
#   而去打数据库"。对一致性要求不高、但绝不能卡的场景
#   (如首页热门商品),很合适。

# === ★ 解法三:热点 key 索性"不过期" ===
# ★ ★ 对那种【极少变动】的超级热点(如首页固定的几个
#   推荐位),最简单的办法就是:不设过期时间,数据更新时
#   由代码【主动去更新】这个缓存。从根上杜绝"过期"这件事。

# === 小结 ===
# ★ 击穿:数据是存在的,问题出在高并发+恰好过期。还原
#   击穿瞬间:一个超热点 key 每秒被访问 5000 次,某刻缓存
#   过期,接下来一秒 5000 个请求几乎同时查缓存全部没命中、
#   全部涌去查数据库、又全部去写回缓存 —— 本来只需 1 个
#   请求查一次库重建缓存,因没有控制变成 5000 个一起查,
#   数据库被尖锐并发瞬间打疼。★★ 解法一互斥锁:缓存失效后
#   不让所有请求都冲去查库,而是让它们抢一把锁 —— 抢到锁的
#   那一个去查库重建缓存,没抢到的不查库、稍等重试,等缓存
#   重建好重试时直接命中;无论多少并发落到数据库的永远只有
#   一个重建请求,这是最常用的防击穿手段。★ 解法二逻辑过期:
#   热点 key 在 Redis 里永不物理过期,在 value 里自己存一个
#   逻辑过期时间字段,读取时发现逻辑上已过期就异步起线程
#   重建、当前请求先返回旧值;取舍是用短暂返回旧数据换来
#   任何请求绝不会因缓存过期被阻塞去打数据库,适合一致性
#   要求不高但绝不能卡的场景。★ 解法三热点 key 索性不过期:
#   对极少变动的超级热点不设过期时间、数据更新时由代码主动
#   更新缓存,从根上杜绝"过期"这件事。
// ★ 防击穿:互斥锁 —— 海量并发里,只放【一个】请求去重建缓存
import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.TimeUnit;

public Product getProductWithLock(Long id) {
    String key = "product:" + id;

    // ① 查缓存,命中直接返回
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }

    // === ★★ 没命中 -> 抢一把"重建锁",而不是直接冲去查库 ===
    String lockKey = "lock:product:" + id;
    // ★ setIfAbsent = SETNX:只有第一个请求能设置成功 = 抢到锁
    Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

    if (Boolean.TRUE.equals(locked)) {
        // ★ 抢到锁的【那一个】请求:负责查库 + 重建缓存
        try {
            Product product = productMapper.selectById(id);
            if (product != null) {
                redisTemplate.opsForValue()
                        .set(key, product, 30, TimeUnit.MINUTES);
            }
            return product;
        } finally {
            redisTemplate.delete(lockKey);   // ★ 重建完,务必释放锁
        }
    } else {
        // ★★ 没抢到锁的其他请求:不查库,稍等一下再重试 ——
        //    等抢到锁那位把缓存重建好,这次重试就直接命中了
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getProductWithLock(id);       // ★ 重试
    }
}
// ★ 无论 5000 并发还是 5 万,落到数据库的,永远只有 1 个

缓存雪崩:别让一整面墙在同一秒塌掉

# === ★ 雪崩的两种成因:要分开治 ===

# === ★ 成因一:大批 key 在同一时刻一起过期 ===
# ★ ★ 这就是我开头那次事故的根因。集中预热(脚本一次性
#   灌进去)+ 统一过期时间(全设 30 分钟)= 它们注定会在
#   同一时刻一起死。
# ★ ★★ 解法朴素到出奇:【过期时间加一个随机抖动】。
#   不要再写死 30 分钟,而是写成「30 分钟 + 随机 0~5 分钟」。
#   这样,即使是同一秒被预热进去的成千上万个 key,它们的
#   过期时刻也会被【打散】在一个 5 分钟的区间里 —— 不再
#   是一堵墙同时塌,而是砖头一块一块、错峰脱落。

# === ★ 成因二:Redis 实例整个宕机 ===
# ★ ★ 上面那招只能防"key 集体过期",防不了"Redis 自己
#   挂了"。Redis 一宕,所有 key 瞬间全部"失效",100%
#   的请求直接砸向数据库 —— 这是最猛的一种雪崩。
# ★ ★ 这一层要靠【架构】来扛,不是靠代码技巧:
#  - ★ Redis 高可用:别用单机。上【主从 + 哨兵】或
#    【Cluster 集群】,主节点挂了,从节点能自动顶上。
#  - ★ 多级缓存:在 Redis 之前,再加一层进程内的【本地
#    缓存】(如 Caffeine)。Redis 整个挂了,本地缓存还能
#    挡下一部分热点请求,不至于 100% 全打到 DB。

# === ★★ 最后一道兜底:限流 + 降级 ===
# ★ ★ 不管前面防得多好,总要假设"万一真的扛不住了"。
#   这时要保的不是"所有请求都成功",而是"数据库别死"。
# ★ ★ 限流:给数据库查询加一道闸门,比如令牌桶 ——
#   每秒最多放 N 个请求去查库,多出来的【直接拒绝】或
#   排队。宁可少数请求失败,也不能让 DB 被打垮、导致
#   【全部】请求失败。
# ★ ★ 降级:DB 扛不住时,干脆返回一个【兜底默认值】
#   (如"商品信息加载中"),或一个稍旧的快照数据。
#   保住核心链路活着,比追求每个数据都最新更重要。

# === 小结 ===
# ★ 雪崩有两种成因要分开治。★ 成因一大批 key 同一时刻
#   一起过期:集中预热+统一过期时间注定它们同时死,解法
#   是过期时间加随机抖动 —— 写成「30 分钟+随机 0~5 分钟」,
#   即使同一秒预热进去的成千上万 key 过期时刻也被打散在
#   5 分钟区间、不再一堵墙同时塌而是砖头错峰脱落。★ 成因二
#   Redis 实例整个宕机:随机抖动防不了 Redis 自己挂,一宕
#   所有 key 瞬间全失效 100% 请求砸向 DB,这层靠架构扛 ——
#   Redis 高可用上主从+哨兵或 Cluster 集群、多级缓存在
#   Redis 之前再加一层进程内本地缓存(Caffeine)、Redis
#   挂了本地缓存还能挡一部分热点。★★ 最后兜底限流+降级:
#   假设万一真扛不住,要保的是"数据库别死"不是"所有请求
#   都成功 —— 限流给 DB 查询加闸门(令牌桶每秒最多放 N 个)
#   多出来的直接拒绝,降级 DB 扛不住时返回兜底默认值或稍旧
#   快照,保住核心链路活着比每个数据都最新更重要。
// ★ 防雪崩第一招:过期时间加随机抖动,把"集体过期"打散
import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ThreadLocalRandom;

public class CacheExpireHelper {

    // ★★ 核心:基础过期时间 + 一段随机抖动
    //    base = 30 分钟,jitter 在 0~5 分钟之间随机
    public void setWithJitter(String key, Object value,
                              long baseMinutes, long jitterMinutes) {
        long jitter = ThreadLocalRandom.current()
                .nextLong(0, jitterMinutes + 1);   // ★ 0 ~ jitter 分钟
        long ttl = baseMinutes + jitter;
        redisTemplate.opsForValue()
                .set(key, value, ttl, TimeUnit.MINUTES);
    }

    // ★ 预热场景:哪怕同一秒灌进 1 万个 key,
    //   它们的 ttl 也会被打散成 30~35 分钟里的一万个不同值
    public void warmUp(java.util.List<Product> products) {
        for (Product p : products) {
            // ★★ 关键:每个 key 单独算一次随机 ttl,不要复用
            setWithJitter("product:" + p.getId(), p, 30, 5);
        }
        // ★ 对比:如果这里统一 set(..., 30, MINUTES) ->
        //   30 分钟后这 1 万个 key 在同一秒一起过期 = 雪崩
    }

    private RedisTemplate<String, Object> redisTemplate;
}
// ★ 防雪崩第二招:多级缓存 —— Redis 挂了,本地缓存兜底
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;

public class MultiLevelCache {

    // ★ L1:进程内本地缓存(Caffeine),容量小、速度极快
    private final Cache<String, Product> localCache = Caffeine.newBuilder()
            .maximumSize(10_000)                       // ★ 只存最热的 1 万个
            .expireAfterWrite(2, TimeUnit.MINUTES)     // ★ 本地缓存 ttl 要短
            .build();

    public Product getProduct(Long id) {
        String key = "product:" + id;

        // === ① 先查 L1 本地缓存 ===
        Product local = localCache.getIfPresent(key);
        if (local != null) {
            return local;                              // ★ 最快路径
        }

        // === ② L1 没有 -> 查 L2 Redis ===
        try {
            Product cached = (Product) redisTemplate.opsForValue().get(key);
            if (cached != null) {
                localCache.put(key, cached);           // ★ 回填 L1
                return cached;
            }
        } catch (Exception e) {
            // ★★ 关键:Redis 整个挂了,会走到这里。此时不要
            //    直接抛异常 —— L1 本地缓存里可能还有热点数据,
            //    上面 ① 已经兜过一层;这里继续往 DB 兜底
            log.warn("Redis 不可用,降级查 DB: {}", key);
        }

        // === ③ L1、L2 都没有(或 Redis 挂了)-> 查 DB ===
        Product product = productMapper.selectById(id);
        if (product != null) {
            localCache.put(key, product);              // ★ 至少回填 L1
        }
        return product;
    }

    private RedisTemplate<String, Object> redisTemplate;
    private ProductMapper productMapper;
    private static final org.slf4j.Logger log =
            org.slf4j.LoggerFactory.getLogger(MultiLevelCache.class);
}

缓存与数据库一致性:更新数据时,该先动谁?

# === ★ 防住了三大事故,还有一个绕不开的问题 ===
# ★ ★ 数据要【改】的时候 —— 缓存里有一份旧的,数据库里
#   要写一份新的。这两个动作,顺序怎么排?动哪个?
#   排错了,用户就会读到【过期的脏数据】。

# === ★ 先否决一个直觉做法:更新 DB + 更新缓存 ===
# ★ ★ 很多人第一反应:改完数据库,就把新值【写进缓存】。
#   看着很对,但有两个坑:
#  - ★ 并发写覆盖:A、B 两个请求都在改这条数据。可能
#    出现 A 先写 DB、B 后写 DB,但缓存里却是 B 先写、A
#    后写 —— 缓存最终留下的是 A 的旧值,和 DB 对不上。
#  - ★ 浪费:如果这个数据写得很频繁、读得却很少,你每次
#    写都辛辛苦苦算一遍新值塞进缓存,可能塞进去到过期都
#    没人读 —— 纯属白干。

# === ★★ 标准答案:更新 DB,然后【删除】缓存 ===
# ★ ★ 这就是 Cache-Aside 模式的写法:不更新缓存,而是
#   【直接把它删掉】。让缓存这个 key 暂时"空"在那。
# ★ ★★ 为什么删比更新好:删除是个【幂等】又【便宜】的
#   动作,不存在并发覆盖问题。下一个来读的请求发现缓存
#   没了,自然会去查一次最新的 DB、再把它写回缓存 ——
#   这叫【懒加载】:谁用,谁负责把它重新加载进来。
#   没人读的数据,就让它一直空着,不浪费。

# === ★ 顺序:为什么是"先更 DB,再删缓存" ===
# ★ ★ 反过来"先删缓存,再更 DB"有个明显的坑:删完缓存、
#   DB 还没来得及更新的那个【空窗期】,如果有个读请求
#   进来,它会查到【旧的 DB 值】,又把这个旧值写回缓存
#   —— 缓存里立刻又是脏的了。
# ★ ★ 所以标准顺序是【先更新 DB,再删除缓存】。

# === ★★ 它仍不是 100% 完美:延迟双删 ===
# ★ ★ "先更 DB 再删缓存"还残留一个极小概率的坑:读请求
#   恰好在"更 DB 之前"读到旧值、却在"删缓存之后"才把这个
#   旧值写回 —— 缓存又脏了。概率极低,但存在。
# ★ ★ 补丁叫【延迟双删】:更新完 DB、删一次缓存后,
#   【再等一小会儿(如 500ms)、补删一次】。把那个可能
#   被写回的脏值,再清理掉一遍。
# ★ ★★ 但要清醒:这些手段都只能【尽量减小】不一致窗口,
#   做不到强一致。缓存的世界里,我们接受的是【最终一致】
#   —— 短暂的、秒级的不一致是可容忍的,只要它最终会对齐。
#   真要强一致,那就别用缓存。

# === 小结 ===
# ★ 防住三大事故还有一致性问题:数据要改时缓存有旧的、DB
#   要写新的,这两个动作顺序怎么排、动哪个,排错用户就读到
#   脏数据。★ 先否决"更新 DB+更新缓存":并发写覆盖(A 先写
#   DB、B 后写 DB 但缓存里 A 后写,缓存留旧值和 DB 对不上)、
#   浪费(写频繁读少每次都算新值塞缓存到过期都没人读)。
#   ★★ 标准答案更新 DB 然后删除缓存:删除是幂等又便宜的
#   动作没有并发覆盖问题,下个读请求发现缓存没了自然查最新
#   DB 再写回 = 懒加载谁用谁负责加载,没人读的就空着不浪费。
#   ★ 顺序为什么先更 DB 再删缓存:反过来先删缓存再更 DB,
#   删完缓存 DB 还没更新的空窗期有读请求进来会查到旧 DB 值
#   又写回缓存、缓存立刻又脏。★★ 仍不完美用延迟双删:更完
#   DB 删一次缓存后再等 500ms 补删一次,清掉可能被写回的
#   脏值;但要清醒这些只能尽量减小不一致窗口做不到强一致,
#   缓存世界接受最终一致 —— 秒级不一致可容忍只要最终对齐,
#   真要强一致就别用缓存。
// ★ 一致性:Cache-Aside 写操作 —— 先更 DB,再删缓存(+ 延迟双删)
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class ProductUpdateService {

    // ★ 用一个调度线程池来做"延迟补删",别用 Thread.sleep 阻塞主流程
    private final ScheduledExecutorService delayExecutor;

    @Transactional
    public void updateProduct(Product product) {
        String key = "product:" + product.getId();

        // === ① 先更新数据库(这是"事实"的最终来源)===
        productMapper.updateById(product);

        // === ② 再删除缓存 —— 注意是"删",不是"写新值" ===
        // ★ 删除是幂等的,不怕并发;下次读自然会懒加载最新值
        redisTemplate.delete(key);

        // === ③ 延迟双删:再等一会儿补删一次 ===
        // ★★ 防的是:某个读请求在 ① 之前读到旧值、却在 ②
        //    之后才把旧值写回缓存。500ms 后再删一次清掉它
        delayExecutor.schedule(() -> {
            try {
                redisTemplate.delete(key);
            } catch (Exception e) {
                log.warn("延迟双删失败,key={}", key, e);
            }
        }, 500, TimeUnit.MILLISECONDS);
    }

    // ★ 反例:别这么写 —— 更新 DB 后直接写新值进缓存
    public void badUpdate(Product product) {
        productMapper.updateById(product);
        // ★★ 错:并发更新时,缓存里可能留下"先更DB那个请求"的
        //    旧值,和 DB 对不上。删除才是对的
        redisTemplate.opsForValue().set("product:" + product.getId(), product);
    }

    private ProductMapper productMapper;
    private RedisTemplate<String, Object> redisTemplate;
    private static final org.slf4j.Logger log =
            org.slf4j.LoggerFactory.getLogger(ProductUpdateService.class);
}

工程坑:大 key、热 key、命中率监控、序列化

# === ★ 防住了事故和一致性,还有四个"日常会咬人"的坑 ===

# === ★ 坑一:大 key —— 一个 value 撑得过大 ===
# ★ ★ 往一个 key 里塞了几 MB 的数据(比如把整个分类下
#   所有商品塞进一个 List)。Redis 是【单线程】的,读写
#   一个大 key 会【长时间占住】那唯一的一根线程,期间
#   所有其他请求全部被卡住排队。
# ★ ★ 治:大集合【拆分】成多个小 key;或换用 Hash 结构、
#   只取需要的字段,别一次性 get 整个大 value。

# === ★ 坑二:热 key —— 流量全压在一个 key 上 ===
# ★ ★ 某个爆款商品,它的 key 每秒被打几十万次。Redis
#   是分片的,这个 key 落在哪个分片,哪个分片的那台机器
#   就被单独打爆 —— 其他机器还很闲,它先扛不住了。
# ★ ★ 治:把这个热 key 在多个分片上【复制多份】(key 后
#   缀加随机编号),读的时候随机挑一份;或者直接用上面
#   讲的【本地缓存】把它拦在进 Redis 之前。

# === ★★ 坑三:不监控命中率,等于蒙着眼开车 ===
# ★ ★ 缓存命中率 = 命中次数 /(命中 + 未命中)。这个数字
#   是缓存健康的【体温计】:
#  - 命中率突然【掉下来】 -> 可能正在发生穿透,或缓存被
#    大面积清空、正在重建。
#  - 命中率一直【偏低】 -> 缓存策略本身有问题(ttl 太短?
#    缓存的根本不是热点数据?)
# ★ ★ INFO stats 里的 keyspace_hits / keyspace_misses
#   就是它。一定要接进监控、配告警。

# === ★ 坑四:序列化 —— 别用 JDK 默认序列化 ===
# ★ ★ JDK 原生序列化生成的字节又大、又慢,而且 value
#   在 redis-cli 里看是一堆乱码,完全没法排查。
# ★ ★ 治:用 JSON(可读、跨语言、好排查)或更紧凑的
#   二进制方案。Spring 里把 RedisTemplate 的 ValueSerializer
#   换成 GenericJackson2JsonRedisSerializer 即可。

# === 认知 ===
# ★ 防住事故和一致性还有四个日常咬人的坑。★ 坑一大 key:
#   一个 value 撑过大(把整个分类商品塞进一个 List),Redis
#   单线程读写大 key 会长时间占住唯一线程、其他请求全卡住
#   排队,治法是大集合拆成多个小 key 或换 Hash 只取需要字段。
#   ★ 坑二热 key:爆款 key 每秒打几十万次,Redis 分片这个
#   key 落哪个分片哪台机器就被单独打爆、其他机器还闲,治法
#   是热 key 在多分片复制多份(后缀加随机编号)读时随机挑或
#   用本地缓存拦在进 Redis 前。★★ 坑三不监控命中率等于蒙眼
#   开车:命中率=命中/(命中+未命中)是缓存健康的体温计,
#   突然掉下来可能正穿透或缓存被大面积清空重建、一直偏低是
#   策略本身有问题(ttl 太短或缓存的根本不是热点),INFO
#   stats 的 keyspace_hits/misses 就是它必须接监控配告警。
#   ★ 坑四序列化别用 JDK 默认:原生序列化字节大又慢、value
#   在 redis-cli 看是乱码没法排查,换 JSON 或紧凑二进制,
#   Spring 里把 ValueSerializer 换成 GenericJackson2Json。
// ★ 工程坑:RedisTemplate 正确配置 + 命中率监控
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(cf);

        // ★★ 关键:key 用 String 序列化 —— redis-cli 里能看懂
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());

        // ★★ 关键:value 用 JSON —— 别用 JDK 默认序列化
        //    JSON 可读、跨语言、出问题能直接在 redis-cli 里排查
        GenericJackson2JsonRedisSerializer json =
                new GenericJackson2JsonRedisSerializer();
        template.setValueSerializer(json);
        template.setHashValueSerializer(json);

        template.afterPropertiesSet();
        return template;
    }
}

// ★ 命中率监控:定时读 INFO stats,算命中率,低了就告警
class CacheHitRateMonitor {

    private final RedisTemplate<String, Object> redisTemplate;

    CacheHitRateMonitor(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    // ★ 建议用 @Scheduled 每分钟跑一次
    public void checkHitRate() {
        Properties stats = redisTemplate.execute(
                (org.springframework.data.redis.core.RedisCallback<Properties>)
                conn -> conn.serverCommands().info("stats"));
        if (stats == null) return;

        long hits = Long.parseLong(stats.getProperty("keyspace_hits", "0"));
        long misses = Long.parseLong(stats.getProperty("keyspace_misses", "0"));
        long total = hits + misses;
        if (total == 0) return;

        double hitRate = (double) hits / total;
        // ★★ 命中率是缓存健康的体温计:突然掉下来 = 可能正在
        //    穿透,或缓存被大面积清空重建
        if (hitRate < 0.90) {
            log.warn("⚠ 缓存命中率过低: {}% —— 排查穿透/雪崩",
                    String.format("%.1f", hitRate * 100));
        }
    }

    private static final org.slf4j.Logger log =
            org.slf4j.LoggerFactory.getLogger(CacheHitRateMonitor.class);
}

一张图:三大事故的防线全景

命令速查

┌──────────────────────┬─────────────────────────────────────────┐
│ 场景 / 命令            │ 说明                                     │
├──────────────────────┼─────────────────────────────────────────┤
│ INFO stats            │ 看 keyspace_hits / keyspace_misses 算命中率│
│ INFO memory           │ 看 used_memory,排查内存与大 key           │
│ --bigkeys             │ redis-cli --bigkeys 扫描大 key            │
│ --hotkeys             │ redis-cli --hotkeys 扫描热 key(需 LFU)   │
│ MEMORY USAGE key      │ 查单个 key 占用多少字节                   │
│ TTL key               │ 查 key 剩余过期时间(-1 永不过期 -2 不存在)│
│ SETNX key val         │ 不存在才设置 —— 防击穿的互斥锁基础         │
│ SET key val EX n NX   │ 原子地"设值+过期+不存在才设",锁的推荐写法 │
│ SLOWLOG GET           │ 看慢命令,大 key 操作通常会出现在这里      │
│ DEBUG SLEEP n         │ 模拟 Redis 阻塞 n 秒,演练雪崩降级用       │
│ CONFIG GET maxmemory  │ 看内存上限与淘汰策略 maxmemory-policy     │
└──────────────────────┴─────────────────────────────────────────┘

★ 一致性写操作顺序:先 UPDATE 数据库 -> 再 DEL 缓存 -> 延迟 500ms 再 DEL 一次
★ 过期时间公式:ttl = 基础时间 + random(0, 抖动区间) —— 永远别写死统一值
★ 排查顺序:命中率掉了先看是不是穿透 -> DB 被打看是不是击穿/雪崩 -> 看预热脚本

避坑清单:缓存上线前过一遍这 10 条

  1. 过期时间永远别写死统一值。所有 key 都设成一样的 30 分钟,等于给自己埋了一颗雪崩定时炸弹。统一写成「基础时间 + 随机抖动」,从源头打散。
  2. 预热脚本是雪崩高发区。用脚本集中灌缓存时,如果再配上统一过期时间,这批 key 必然集体同时过期。预热时尤其要给每个 key 单独算随机 ttl。
  3. 不存在的数据也要缓存。查库为空时,缓存一个空值标记(ttl 设短,几分钟),否则同一个不存在的 ID 反复来查,每次都穿到数据库。
  4. 面临恶意刷量,上布隆过滤器。缓存空值挡不住"每次用不同随机 ID"的攻击,前面加一道布隆过滤器,把瞎编的 ID 在第一道门挡掉。
  5. 热点 key 重建必须加互斥锁。不加锁,热点 key 一过期,成百上千并发会同时查库重建。用 SETNX 锁,只放一个请求去重建,其余重试。
  6. 更新数据时是"删缓存"不是"写缓存"。直接写新值进缓存会有并发覆盖问题。标准做法:先更新数据库,再删除缓存,让下次读懒加载。
  7. 顺序是"先更 DB 再删缓存"。反过来"先删缓存再更 DB",空窗期的读请求会把旧值写回缓存。要更严格再加延迟双删。
  8. 别用单机 Redis 扛生产流量。单机一挂就是 100% 雪崩。上主从+哨兵或 Cluster,关键业务再叠一层本地缓存做多级兜底。
  9. 命中率必须接监控告警。命中率是缓存的体温计。突然下跌往往是穿透或雪崩的最早信号,等数据库告警才发现就晚了。
  10. 警惕大 key 和热 key。Redis 单线程,一个大 key 操作会卡住所有请求;一个热 key 会打爆单个分片。定期用 --bigkeys / --hotkeys 扫描。

总结:缓存是一堵墙,但墙也会塌

那次大促事故过后,我盯着监控图复盘了很久。最扎心的不是数据库被打挂,而是我一直以为"加了缓存"就等于"做好了缓存"。我给商品加 Redis、写好 Cache-Aside 读逻辑、跑了大半年没出事,就理所当然地觉得这件事已经"完成"了。可那半年的平稳,只是因为流量一直没大到能照见那些缺口——直到大促那天,潮水退去,所有裸奔点同时暴露。

把这件事彻底理清之后,我对缓存的理解变了。缓存的本质,是数据库前面的一堵挡箭墙。但这堵墙不是铁板一块,它有三种典型的塌法:穿透,是查一个根本不存在的数据,缓存永远建不起来,每次查询都注定穿墙而过——治它要靠缓存空值和布隆过滤器,把"查不到的东西"也变成一种能被缓存的结果。击穿,是一个高并发的热点 key 恰好过期,过期的瞬间成百上千请求一起涌进数据库——治它要靠互斥锁,让无论多少并发,落到数据库的永远只有一个重建请求。雪崩,是一大批 key 在同一时刻集体过期,或者 Redis 整个宕机,海量请求同一秒全部失去遮挡——治它要靠过期时间的随机抖动打散集体过期、靠 Redis 高可用和多级缓存防住整体宕机、靠限流降级做最后兜底。三种事故,三道不同的防线,不能用一招通治。

而比这三种事故更隐蔽的,是一致性。缓存和数据库是两份数据,数据一改,它们之间就会出现一个不一致的窗口。我后来接受了一个事实:在用缓存的系统里,你追求的不是"任何时刻两边都分毫不差"的强一致,而是"先更新数据库、再删除缓存、必要时延迟双删"换来的最终一致——短暂的、秒级的不一致是可以容忍的,只要它最终会对齐。如果你的业务连秒级不一致都不能忍,那答案不是把缓存调得更精巧,而是这个场景根本就不该用缓存。

如果说这次事故只让我记住一件事,那就是:缓存不是加上去就一劳永逸的东西,它是一个需要持续运营的东西。过期时间要不要抖动、热点 key 要不要单独保护、命中率有没有接监控、Redis 是不是单点、更新数据时动的是哪一个——这些都不是"写代码时顺手"就能做好的,而是要在上线前一条条问自己、上线后一直盯着的。那堵墙立起来的那一刻,工作只完成了一半;剩下的一半,是确保当真正的潮水来临时,它不会在同一秒里整个塌掉。监控图上那条一度冲到 100% 的数据库 CPU 曲线,后来成了我每次设计缓存时,都会想起的一根标尺。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

RAG 实战完全指南:为什么你的检索增强问答总是一本正经胡说八道

2026-5-21 13:42:32

技术教程

大模型 Function Calling 完全指南:从一次 AI 客服乱调工具看懂工具编排

2026-5-21 16:25:25

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索