小Cの已经记不起来的博客

Redis 缓存穿透、雪崩与 配置中心

上周三晚上运营搞了个限时秒杀,活动开始不到两分钟,数据库 CPU 直接飙到 100%,接口超时报警响个不停。排查下来发现,凌晨批量刷进 Redis 的一批商品数据用的是同一个过期时间,正好卡在活动开始这个点集体失效,请求一股脑全砸到了数据库上。当时群里有人管这叫击穿,严格来说单个热点 key 失效才叫击穿,大批 key 一起失效才是雪崩,那天算是两个都占了。

比事故本身更气人的是事后复盘:明明早就知道过期时间要加随机值,但这个值写死在代码里,想调一下得改代码、提测、走发布,前后折腾一个多小时。所以这篇就把穿透和雪崩的处理方式整理一下,顺便聊聊为什么我后来把这一堆缓存参数全挪到了配置中心。

先说缓存穿透

穿透就是请求的数据在 Redis 里没有,数据库里也没有,每次都穿过缓存直接打到数据库。正常用户一般触发不了这个,多半是有人拿脚本带着一堆乱七八糟的 id 疯狂请求接口,id=-1、id=999999999 这种。

处理方式常见两种。第一种是把空结果也缓存起来,查不到就往 Redis 写个占位符,过期时间给短一点,同样的垃圾请求第二次就被挡住了。第二种是布隆过滤器,把全量合法 id 放进去,请求先过过滤器,说不存在就直接返回,连 Redis 都不用查。Redis 装了 RedisBloom 模块的话,BF.RESERVE 加 BF.EXISTS 就能搞定,不用自己在代码里维护。

代码大概是这个样子:

public Product getProduct(long id) {
    String key = "product:" + id;
    if (bloomEnabled && !bloomFilter.mightContain(key)) {
        return null;
    }
    String value = redis.get(key);
    if ("NULL_CACHE".equals(value)) {
        return null;
    }
    if (value != null) {
        return JSON.parseObject(value, Product.class);
    }
    Product product = productMapper.selectById(id);
    if (product == null) {
        redis.setex(key, nullTtl, "NULL_CACHE");
        return null;
    }
    redis.setex(key, baseTtl + jitter(), JSON.toJSONString(product));
    return product;
}

布隆过滤器这玩意儿有个前提,启动时得把全量 id 灌进去,后续数据还得维护增量。数据量不大的话其实空值缓存就够了,别过度设计。

再说缓存雪崩

雪崩两种情况:一是大量 key 设置了相同或接近的过期时间,到点一起失效;二是 Redis 本身挂了,缓存这层直接没了。

针对第一种,最简单也最有效的一招就是过期时间加随机值:

int ttl = baseTtl + ThreadLocalRandom.current().nextInt(jitterRange);
redis.setex(key, ttl, JSON.toJSONString(product));

这样 key 的过期时间散落在 1 小时到 1 小时 10 分之间,就不会挤在同一个点了。

针对第二种,Redis 得做高可用,哨兵或者集群挑一个,别裸奔。数据库前面最好有限流,真出事了至少能保住数据库不挂。另外像首页这种热点数据,可以考虑干脆不设过期时间,靠后台任务定时去刷新。

为什么扯到配置中心

那天处理事故时最难受的不是没方案,方案网上到处都是,难受的是所有关键参数全写死在代码里。空值缓存 60 秒、抖动 600 秒、限流阈值 1000 QPS,全是常量。想临时把空值缓存改成 10 秒验证一下?走完发布流程黄花菜都凉了。

后来把项目接入了 Nacos(用 Apollo 道理也一样,公司现有啥就用啥,新项目没必要再折腾一套),把这些参数全部挪了出去:

cache:
  null-ttl: 60            # 空值缓存时间,秒
  base-ttl: 3600          # 正常过期基准,秒
  ttl-jitter: 600         # 过期随机抖动上限,秒
  bloom-filter-enabled: true
  degrade-enabled: false  # 降级开关,打开后直接返回默认数据,不再查库

代码里配合 @RefreshScope,配置改完几秒内就能生效:

@RefreshScope
@Component
public class CacheProps {
    @Value("${cache.null-ttl:60}") private int nullTtl;
    @Value("${cache.base-ttl:3600}") private int baseTtl;
    @Value("${cache.ttl-jitter:600}") private int ttlJitter;
    @Value("${cache.degrade-enabled:false}") private boolean degradeEnabled;
}

这么改完体验确实不一样。上个月又有一次小波动,在配置中心把 degrade-enabled 一拨,数据库压力几秒钟就下来了,全程没碰代码,没发一版。等你半夜被数据库报警叫醒的时候,你会感谢当初把参数挪出去的自己。

最后是几个取值上的经验:ttl-jitter 给到 base-ttl 的 10% 到 20% 基本够用;null-ttl 别设太长,5 分钟以内,不然正常数据更新后要等很久才能被查到;降级开关平时务必保持关闭。别问我怎么知道的。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放