Redis 缓存穿透、雪崩与 限流
前几天线上服务半夜报了一波警,爬起来看监控,发现数据库连接数直接被打满了,慢查询一大堆。奇怪的是 Redis 明明就架在那儿,怎么请求还全跑到数据库去了?翻了半天日志才搞明白,是有个接口被爬虫盯上了,拿一堆不存在的 id 在那儿疯狂请求,这就是典型的缓存穿透。趁着记忆还热乎,把这次踩的坑一起整理一下。
缓存穿透:查的数据根本不存在
正常流程大家都会写,先查缓存,缓存没有就查数据库,查完再塞回缓存。但这个流程有个漏洞:如果这条数据在数据库里压根就不存在呢?那每次请求都会穿透缓存打到数据库,缓存里永远不会有值。平时没事儿,一旦被人恶意拿随机 id 扫你的接口,数据库就顶不住了。
最简单的办法是把空值也缓存起来,给个短一点的过期时间:
public Object get(String key) {
String value = redis.get(key);
// 命中空值标记,说明这数据就是不存在,直接返回
if ("".equals(value)) {
return null;
}
if (value != null) {
return deserialize(value);
}
Object result = queryFromDb(key);
if (result == null) {
// 不存在也缓存住,过期时间别太长,60秒足够了
redis.setex(key, 60, "");
return null;
}
redis.set(key, serialize(result));
return result;
}
这个方案简单粗暴,缺点是如果人家扫的 id 每次都不一样,空值缓存会占不少内存,不过给个短过期时间一般够用了。
再进一步就是布隆过滤器,启动的时候把所有合法的 id 塞进去,请求来了先问过滤器,说没有那就直接返回,连 Redis 都不用查。布隆过滤器的特点是它说“没有”那就一定没有,说“有”可能误判,但误判也就是多走了一次正常流程,不亏。我用的是 Redisson 自带的:
RBloomFilter<String> filter = redissonClient.getBloomFilter("bloom:user");
filter.tryInit(1000000L, 0.01);
filter.add("10001");
if (!filter.contains(key)) {
return null; // 一定不存在,直接拦掉
}
tryInit 两个参数分别是预期数据量和误判率,0.01 就是用多一点内存去换更低的误判。
缓存雪崩:一堆 key 集体过期
雪崩好理解,就是你给一大批 key 设了同一个过期时间,比如凌晨统一刷新的配置数据,到点全没了,那一瞬间所有请求全砸到数据库上。另外 Redis 实例本身挂了也会造成一样的效果。
解法不复杂,过期时间加个随机数,把它打散:
int ttl = 3600 + ThreadLocalRandom.current().nextInt(600);
redis.setex(key, ttl, value);
这样即使同一批写入的 key,过期时间也分散在一个小时到一个小时十分钟之间,不会挤在同一个时间点集体失效。Redis 本身的问题就靠高可用解决了,主从加哨兵或者集群都行,这里就不展开了。
顺便提一嘴缓存击穿,跟雪崩类似,区别是只有一个热点 key 过期,高并发下这一瞬间大量请求同时去查库。常见解法是加互斥锁,缓存没命中的时候先用 setnx 抢锁,抢到的去查库回填,没抢到的等一下再读缓存:
String value = redis.get(key);
if (value == null) {
// 谁抢到锁谁来查数据库
if (redis.setnx("lock:" + key, "1")) {
redis.expire("lock:" + key, 10);
value = queryFromDb(key);
redis.setex(key, ttl, value);
redis.del("lock:" + key);
} else {
Thread.sleep(50);
value = redis.get(key); // 别人已经回填了,直接读
}
}
注意锁一定要设过期时间,不然拿到锁的那个进程挂了,这把锁就永远释放不了了。另外严格来说 setnx 和 expire 不是原子操作,生产上建议直接用 Redisson 的锁,省心。
限流:最后的兜底
前面说的都是尽量别让数据库被打挂,但说实话,真出事儿的时候这些不一定来得及,所以限流这个兜底手段还是得有。思路很简单,单位时间内只放行固定数量的请求,多出来的直接拒绝,宁可让一部分人看到“稍后再试”,也不能让整个库跟着一起躺下。
最朴素的做法是计数器,incr 加个过期时间:
Long count = redis.incr("rate:" + key);
if (count == 1) {
redis.expire("rate:" + key, 1);
}
if (count > 100) {
throw new RuntimeException("请求太快了,稍后再试");
}
每秒最多 100 次。这个写法有个小毛病:第一秒的最后 100ms 放进来 100 个,第二秒的开头 100ms 又放进来 100 个,等于 200ms 内过了 200 个请求。要更精确就得用滑动窗口,用 Lua 脚本在 Redis 端原子执行:
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, ARGV[4])
redis.call('PEXPIRE', key, window)
return 1
else
return 0
end
用 zset 存每次请求的时间戳,先把窗口外的旧数据清掉,再数一下窗口内有多少个,没超就记一笔放行,超了就拒绝。用 RedisTemplate 传个 DefaultRedisScript 执行就行,ARGV[4] 给个唯一值当 member,时间戳拼个随机数都行。
不想自己折腾的话,直接上 Redisson 的 RRateLimiter 也行,或者干脆放到网关层,Nginx 的 limit_req、Sentinel 这些都是现成的,看你们项目架构里哪层好加就放哪层。
最后
整理一下:穿透管的是“查不存在的数据”,空值缓存加布隆过滤器基本能治;雪崩管的是“大面积同时失效”,过期时间打散加上 Redis 高可用;限流是最后的兜底,前面全漏了也能保住数据库不直接躺平。真出问题了别急着改代码,先看监控和慢查询日志,搞清楚到底是哪种情况再对症下药,不然改了半天没准儿连病根都没找对...
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。