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

Redis 缓存穿透、雪崩与 日志采集

上个月值晚班,凌晨两点多被告警叫醒,一个商品详情接口的耗时从20ms飙到了3s,数据库CPU直接拉满,连接池也满了。第一反应是有慢SQL,登上服务器一看,应用日志里全是同一个请求在刷:GET /goods/999999,而这个ID压根就不存在。也就是说请求根本没被缓存挡住,全部打穿到了数据库上。当时日志散在三台机器上,我是挨个 grep 才把现场拼出来的,折腾完就决定这事儿得从两头解决:一头是缓存本身怎么防,另一头是日志得先能集中查,不然下次还是干瞪眼。

缓存穿透:查一条不存在的数据

正常流程是请求先查Redis,命中就直接返回,查不到再去查数据库,顺手把结果写回缓存。问题就出在“查不到”这里:如果来请求的数据在数据库里也不存在,缓存里永远不会有它,每次请求都会一路穿到DB。正常用户撞上一两次无所谓,但如果有人故意拿一堆不存在的ID来扫你的接口(爬虫、恶意脚本,或者前端bug拼错了参数),DB很快就会顶不住。

最省事的做法是缓存空值,查DB发现不存在时,也往Redis里写一个空值,过期时间设短一点:

Object cache = redisTemplate.opsForValue().get("goods:" + id);
if (cache != null) {
    // 空字符串代表"确认不存在",直接拦掉
    return "".equals(cache) ? null : deserialize(cache);
}
Object dbResult = goodsMapper.selectById(id);
// 查不到也写,60秒过期,防止这个key被人一直拿来刷
redisTemplate.opsForValue().set("goods:" + id, dbResult == null ? "" : serialize(dbResult), 60, TimeUnit.SECONDS);
return dbResult;

这种方式简单直接,但有个前提:key的范围不能太散。如果对方每次都换一个不存在的ID,空值缓存会白白占掉不少内存。这个时候就得上布隆过滤器了,启动时把所有合法的ID塞进去,请求先过一遍过滤器,说没有的就直接拦下,连Redis都不用查:

if (!bloomFilter.mightContain("goods:" + id)) {
    return null; // 过滤器说不存在,那就是真不存在
}

布隆过滤器这玩意儿偶尔会误判(说不存在的一定不存在,说存在的可能存在),所以它只能做前置拦截,不能反过来当“存在证明”用,这点写代码的时候要留意。Guava和Redisson都有现成的实现,不用自己造轮子。

缓存雪崩:同一秒集体过期

穿透是查不存在的数据,雪崩则相反,是缓存里的数据在同一时刻集体失效。我们这边踩过一次:大促前批量导入了一批key,图省事统一设了24小时过期,结果第二天那个整点,几万个key同一秒过期,DB瞬间被打出一波高峰,到现在想起来还后怕。

防雪崩的思路有几个,互相不冲突,建议一起上。

最基础的是给过期时间加随机值,把过期时间摊开:

long ttl = 24 * 3600 + ThreadLocalRandom.current().nextLong(0, 600); // <--- 在24小时基础上随机加0~600秒
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);

然后是Redis本身的高可用,主从加哨兵或者直接上集群,别让Redis挂了之后全部请求落到DB上。最后是兜底:给查DB的路径加限流和降级,真被打穿了宁可让一部分请求快速失败,也别把DB拖死,不然恢复起来更麻烦。

日志采集:别等出事了再挨台登服务器

开头说的那个凌晨,最难受的其实不是修问题,是找问题——日志散在几台机器上,得一台台 grep,根本没法快速回答“这一小时里有多少请求在查不存在的ID”这种问题。所以后来把日志采集也捋了一遍,用的是Filebeat + Elasticsearch + Kibana,机器多的话中间可以再加一层Kafka缓一缓。

应用这边先把日志格式固定下来,尤其是缓存未命中这种关键事件,单独打一行,方便后面聚合:

2024-05-20 02:13:45.123 INFO  [http-nio-8080-exec-3] CacheMissEvent key=goods:999999 ip=10.2.3.4

测试环境图省事的话,可以直接用docker把ES拉起来 docker run -d --name es -p 9200:9200 -e "discovery.type=single-node" elasticsearch:8.13.4,正式环境还是老老实实上集群,不然ES自己先被打爆就尴尬了。

然后每台机器上跑一个Filebeat,把日志目录挂进去:

filebeat.inputs:
  - type: filestream
    enabled: true
    paths:
      - /var/log/app/*.log
output.elasticsearch:
  hosts: ["10.0.0.10:9200"]
  index: "app-log-%{+yyyy.MM.dd}"

不出问题的话,日志过个十几秒就能在Kibana里搜到了。在Kibana里对 CacheMissEvent 按key做个聚合,穿透一发生,哪个key被刷、刷了多少次、来自哪些IP,一眼就能看出来。顺手还可以加个告警规则,比如“CacheMissEvent一分钟超过500条”就发通知,比等到DB先躺下才发现强多了。

最后

总结一下:穿透就缓存空值加上布隆过滤器,雪崩就TTL加随机再加高可用和限流兜底。这几招本身都不难,十几行代码的事,难的是日志要能集中查、能聚合、能告警,不然每次都是半夜被叫起来挨台登服务器找现场,这个代价就太大了。

评论

还没有评论。

发表评论

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

未在播放