Redis雪崩时,没有一个Key是无辜的:一次“集体阵亡”引发的血案

#【Code实战派】技术分享征文挑战赛#

凌晨三点,手机用一种足以击穿耳膜的尖啸,将我从深度睡眠中强行唤醒。这种为核心报警定制的铃声,是每个SRE(网站可靠性工程师)的梦魇。

报警短信言简意赅,甚至有些“无辜”:“核心业务API响应时间超过2000ms”。

然而,我那在无数次线上事故中锤炼出的第六感却在狂吼:这绝不是一次普通的网络抖动。

当我用微微颤抖的手指接入VPN,点开监控大盘时,后背瞬间被冷汗浸湿。那条代表数据库CPU占用率的曲线,像是在嘲笑我所有过往的经验,放弃了任何挣扎,直接从20%悍然拉升到99%,在屏幕上画出了一座垂直的、令人绝望的墓碑。

我脑子里只剩下两个字:雪崩

整点雪崩——当你的缓存大军约定好同一秒“集体阵亡”

直觉告诉我,问题出在缓存。能让数据库在瞬息之间从“度假模式”切换到“过劳死模式”的,只有一种可能:海量请求绕过缓存,像潮水一样直接拍在了数据库的沙滩上。

果然,日志里充斥着大量的数据库查询,而这些查询本该由Redis挡住。我迅速定位到了负责缓存预热的代码:

// 灾难的源头:所有Key的过期时间完全一致
@PostConstruct
public void wrongInit() {
    // 初始化1000个城市数据到Redis,所有缓存数据有效期30秒
    IntStream.rangeClosed(1, 1000).forEach(i -> 
        stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 30, TimeUnit.SECONDS));
    log.info("Cache init finished");
}

看到30, TimeUnit.SECONDS这个整齐划一的参数,我差点把键盘砸了。

这意味着什么?意味着我们亲手设置了一颗定时炸弹。所有缓存Key在同一时刻被创建,也将在30秒后的同一时刻集体失效。那一瞬间,成千上万的请求,就像约定好了一样,手拉手冲向数据库,场面堪比双十一零点的抢购。

这就是最经典的缓存雪崩。不是缓存服务挂了,而是你的设计,让它在某个瞬间“社会性死亡”。

给每个缓存Key发一张不同时间的“死亡通知书”

修复方案简单到令人发指,甚至有些“反常识”的优雅。我们不需要复杂的锁,也不需要高深的算法,只需要给死亡时间,加上一点小小的“不确定性”。

@PostConstruct
public void rightInit1() {
    // 破局的关键:在固定时间上,增加一个随机的“扰动值”
    IntStream.rangeClosed(1, 1000).forEach(i -> 
        stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 
            30 + ThreadLocalRandom.current().nextInt(10), TimeUnit.SECONDS));
    log.info("Cache init finished");
}

通过增加一个0~10秒的随机延迟,我们将集中的过期时间点,打散成了一条平滑的曲线。原本在1秒内压垮数据库的洪峰,被削平成了未来10秒内可以轻松应对的涓涓细流。

热点狙击——那个被万人追捧的Key,成了压垮DB的最后一根稻草

解决了雪崩问题,警报暂时解除。但我的神经没有放松。在复盘日志时,我发现了一个更诡异的现象:在雪崩的瞬间,某个特定的Key——hotsopt,回源数据库的次数,竟然高达几十次。

这不正常。雪崩是“面”的打击,而这个,是精准的“点”的狙击。

这就是缓存击穿。当一个被高并发访问的“超级热点”Key,在它过期的一瞬间,成百上千的线程会同时发现缓存不存在,然后像一群饿狼扑向同一只羊羔一样,同时去查询数据库、重建缓存。

// “单纯”的代码,在高并发下处处是陷阱
@GetMapping("wrong")
public String wrong() {
    String data = stringRedisTemplate.opsForValue().get("hotsopt");
    if (StringUtils.isEmpty(data)) {
        data = getExpensiveData(); // 极其耗时的数据库操作
        stringRedisTemplate.opsForValue().set("hotsopt", data, 5, TimeUnit.SECONDS);
    }
    return data;
}

在高并发下,if (StringUtils.isEmpty(data))这个判断,就像一个开了闸的大坝。第一个线程冲进去执行getExpensiveData()时,后面99个线程已经通过了if判断,正在门外排队等着冲进去。

破局:双重检查锁——“单线程”过独木桥,请把DB的门焊死

对付这种“热点狙击”,唯一的办法就是“加锁”。只允许一个线程去重建缓存,其他线程要么等待,要么直接返回旧数据(如果业务允许)。在这里,一把基于Redis的分布式锁是最好的“武器”。

@GetMapping("right")
public String right() {
    String data = stringRedisTemplate.opsForValue().get("hotsopt");
    if (StringUtils.isEmpty(data)) {
        RLock locker = redissonClient.getLock("locker");
        // 只允许一个线程获得锁
        if (locker.tryLock()) {
            try {
                // 双重检查:防止A线程重建缓存后,B线程又查了一遍
                data = stringRedisTemplate.opsForValue().get("hotsopt");
                if (StringUtils.isEmpty(data)) {
                    data = getExpensiveData();
                    stringRedisTemplate.opsForValue().set("hotsopt", data, 5, TimeUnit.SECONDS);
                }
            } finally {
                // 必须在finally中释放锁,这是纪律!
                locker.unlock();
            }
        }
    }
    return data;
}

双重检查锁(DCL)在这里是精髓。第一个if是性能考量,避免所有请求都去抢锁。第二个if是逻辑严谨性的保证,防止在等待锁的过程中,缓存已经被其他线程重建。

幽灵攻击——当黑客用“不存在”的数据,发起致命的“穿透”

正当我以为已经堵上了所有漏洞时,DBA又发来了告警:数据库出现了大量针对user表的无效ID查询。ID从-1、-2到-999,全是些压根不可能存在的数据。

我瞬间明白,我们遭遇了最阴险的攻击:缓存穿透

攻击者在短时间内,用大量“数据库里肯定不存在”的Key来请求你的接口。由于这些Key在缓存中永远是miss,请求会次次都“穿透”缓存,直达数据库。如果我们的数据库对id < 0这种查询没有索引优化,每一次查询都是一次慢扫描。这相当于有人在用一把小锤子,不知疲倦地敲打你数据库最薄弱的那块玻璃。

// 无法区分“缓存失效”和“数据本就为空”的致命缺陷
@GetMapping("wrong")
public String wrong(@RequestParam("id") int id) {
    String key = "user" + id;
    String data = stringRedisTemplate.opsForValue().get(key);
    // 致命问题:当data为null时,你不知道是缓存过期了,还是用户压根不存在
    if (StringUtils.isEmpty(data)) {
        data = getCityFromDb(id); // 每次都去查库
        stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
    }
    return data;
}

getCityFromDb对一个不存在的ID返回的是空字符串,而isEmpty无法区分这个空字符串是来自数据库的“查无此人”,还是Redis的“我这里没有”。

破局(一):给“空值”一个名分——承认它的“不存在”,也是一种保护

最直接的办法,就是缓存这个“不存在”的状态。当数据库告诉你“查无此人”时,不要直接返回,而是在缓存里存一个特殊的占位符,比如"NODATA"

// ...
data = getCityFromDb(id);
if (!StringUtils.isEmpty(data)) {
    stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
} else {
    // 核心:为“空结果”建立缓存,并设置一个较短的过期时间
    stringRedisTemplate.opsForValue().set(key, "NODATA", 5, TimeUnit.MINUTES);
}
// ...

这样,下一次同样的非法ID请求过来时,会直接命中"NODATA"这个缓存,根本不会给它骚扰数据库的机会。

破局(二):布隆过滤器——门口的那个保安,他甚至不存你的名字,却认识你

如果非法Key的模式千奇百怪,缓存空值可能会污染缓存空间。这时,就需要祭出大杀器——布隆过滤器

你可以把它想象成一个极其节省内存的“白名单”。在系统启动时,把所有合法的用户ID都放进布隆过滤器里。

它的神奇之处在于:

  • 它说**“不存在”,那就一定不存在**。
  • 它说**“可能存在”**,那才需要去查缓存和数据库(有极低的误判率)。
// 引入Guava的BloomFilter
private BloomFilter<Integer> bloomFilter;

@PostConstruct
public void init() {
    // 初始化一个能容纳1万个元素,期望误判率为1%的布隆过滤器
    bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 10000, 0.01);
    // 把所有合法的ID都“告诉”布隆过滤器
    IntStream.rangeClosed(1, 10000).forEach(bloomFilter::put);
}

@GetMapping("right2")
public String right2(@RequestParam("id") int id) {
    // 第一道防线:问问“保安”见没见过这个人
    if (bloomFilter.mightContain(id)) {
        // “保安”说见过,再走正常流程
        String key = "user" + id;
        // ...后续逻辑同上
    }
    // “保安”说没见过,直接打发走,连Redis的门都不让碰
    return "";
}

布隆过滤器像一个尽职尽责的保安,挡住了99%的恶意流量,让它们连缓存服务器的面都见不到,更别说去骚扰金贵的数据库了。

深渊回响:数据一致性的“妄念”——你先更新了缓存,但数据库背叛了你

风暴平息,但真正的反思才刚刚开始。上述所有问题,都围绕着“缓存查不到怎么办”。但还有一个终极问题:当数据变化时,我该怎么更新缓存?

  • 先更新缓存,再更新数据库? 绝对不行! 你成功更新了缓存,但数据库事务回滚了,数据就彻底不一致了,这是最严重的“缓存投毒”。
  • 先更新数据库,再更新缓存? 听起来不错,但在高并发下,请求A更新了数据库,请求B也更新了数据库。但由于网络延迟,B先更新了缓存,A后更新了缓存。最终,缓存里存的是A的旧数据。脏数据诞生。
  • 先删除缓存,再更新数据库? 同样有并发问题。A删了缓存,还没来得及更新数据库,B的读请求来了,发现缓存没有,就去读了数据库的旧值,然后重建了缓存。A再完成数据库更新。最终,缓存里还是旧数据。
先操作数据库,再“通知”缓存滚蛋

经历无数次血泪教训后,业界沉淀出了最经典、最稳妥的模式:Cache-Aside Pattern(旁路缓存模式)

  1. 读操作:先读缓存,缓存命中则直接返回。缓存未命中,则读数据库,成功后将数据放入缓存,然后返回。
  2. 写操作先更新数据库,再删除缓存

为什么是删除缓存,而不是更新缓存?

  • 懒加载:只有当数据真正被访问时,才去重建缓存。你的写操作可能很频繁,但读操作很少,更新缓存的开销完全是浪费。
  • 简单性与幂等性:删除操作是幂等的,删1次和删10次效果一样。如果因为网络问题需要重试,删除操作的风险远小于更新操作。

当然,“更新数据库成功,但删除缓存失败”的极端情况依然存在。对此,可以通过消息队列+延迟重试等机制来保证最终一致性。但对于绝大多数场景,Cache-Aside模式已经是健壮性、性能和简单性之间最好的平衡。


缓存避坑金句指北

  1. 雪崩之戒:为你的缓存过期时间,加上一个随机的“扰动值”。永远不要让你的缓存大军在同一秒“集体阵亡”。
  2. 击穿之锁:面对热点Key,请毫不犹豫地使用分布式锁或进程内锁,并牢记“双重检查”原则,只让一个请求成为重建缓存的“天选之子”。
  3. 穿透之盾:用“缓存空值”大法承认“不存在”也是一种状态,或用“布隆过滤器”这个门卫,将99%的恶意流量挡在门外。
  4. 一致性之道:抛弃所有“先写缓存”的妄念。牢记**“先更新数据库,再删除缓存”**的黄金法则(Cache-Aside Pattern),让数据流动得简单、可控。

你的每一次缓存写入,都可能是在为未来的事故埋下一颗定时炸弹。 在另一篇文章中,我复盘了那些由Redis引发的真实生产事故:从KEYS锁死服务,到“巨无霸键”撑爆内存,揭示了看似常规的操作背后隐藏的致命陷阱,点击查看

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

CV大魔王

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值