Redis缓存穿透击穿雪崩,面试高频
▌ 技术引导 实战中Redis缓存穿透、击穿、雪崩三个问题,光靠“加锁”或“布隆过滤器”是解决不了根本的。我踩过坑发现,穿透需要在请求入口处做校验,但不是简单的校验是否存在,得结合多层校验策略。比如用Redis+本地缓存做二层过滤,请求先打本地缓存,再打Redis,如果本地都找不到才去数据库。击穿问题更棘手,单个key失效导致大量请求直接穿透到数据库,这时候用互斥锁或者队列做限流是硬道理。我见过用Redis的Lua脚本实现互斥锁,能有效避免多次请求同时执行。雪崩则是多个缓存同时失效,这时候得考虑缓存失效时间的随机性,比如在TTL基础上加随机值,避免全量失效。此外,监控和告警也是必须的,我用Prometheus+Grafana实时监控缓存命中率,发现异常就能及时处理。 ▌ 技术参考 一 缓存穿透问题多发于高并发场景,尤其是接口校验不严时,恶意请求会直接攻击数据库。常见解决方案是使用布隆过滤器或者本地缓存做预过滤。布隆过滤器的使用需要提前构建,比如用Redis的set数据结构模拟布隆过滤器,但这样效率不高。我见过用Redis的位图(bit)实现布隆过滤器,效果不错。具体操作是将每个key的hash值计算出对应的bit位,记录是否存在。当请求来时先检查bit位是否为1,不是再查Redis。这种方案适合数据量不大的场景,但容量有限,可根据实际需求调整bit位长度。比如在Spring Boot中可以用RedisTemplate实现简单的位图逻辑,避免穿透。 二 缓存击穿常发生在热点key过期的瞬间,此时大量请求会直接穿透到数据库,造成压力骤增。解决的关键是避免所有请求同时执行,常用手段是加互斥锁。互斥锁可以用Redis的setnx命令实现,但容易出现锁失效或死锁问题。我改用Lua脚本封装setnx和get操作,确保原子性。比如在Lua中先检查key是否存在,存在则直接返回,不存在则设置锁。锁的过期时间要合理,通常在key的TTL基础上加一小段时间,比如30秒。同时,锁的粒度要控制好,不能锁太多key,否则影响性能。比如在Go语言中,可以用redis-go的Pipeline功能优化Lua脚本的执行效率。 三 缓存雪崩是多个缓存key同时失效,导致数据库负载飙升。解决方案是随机化TTL时间,避免所有key在同一时间失效。比如在生成key的时候,加上一个随机数,将TTL设为不同的值。例如,将某个key的TTL从300秒调整为300±50秒,这样在失效时间上会有一定错开。我见过用Redis的EXPIRE命令结合随机函数实现,比如在代码中随机生成一个时间偏移量,再设置TTL。但这种做法需要在初始化缓存时就处理好,否则容易出现批量失效问题。此外,还可以考虑冷热分离,将高频访问的key设置较长的TTL,降低雪崩概率。 四 在实际项目中,我曾用本地缓存做第一层过滤,使用Caffeine作为缓存库,设置最大大小为10000,过期时间为5分钟。当请求到达时,先查询本地缓存,如果存在则直接返回,不存在再查Redis。这种做法可以减少Redis的访问压力,同时避免穿透问题。Caffeine的配置比较灵活,比如可以设置最大条目数、加载策略等。比如在Java中配置Caffeine的方法是: ```java Cache cache = Caffeine.newBuilder() .maximumSize(10000) .expireAfterAccess(5, TimeUnit.MINUTES) .build(); ``` 五 对于互斥锁的实现,我曾用Redis的Lua脚本来避免锁失效问题。例如,定义一个脚本,检查key是否存在,如果不存在则设置锁,并设置过期时间。脚本如下: ```lua local key = KEYS[1] local lock = ARGV[1] local ttl = tonumber(ARGV[2]) if redis.call("EXISTS", key) == 0 then return redis.call("SET", key, lock, "NX", "EX", ttl) else return 0 end ``` 在调用时,使用eval命令执行脚本,并传入key、lock值、ttl时间。例如: ```bash EVAL "local key = KEYS[1]; local lock = ARGV[1]; local ttl = tonumber(ARGV[2]); if redis.call('EXISTS', key) == 0 then return redis.call('SET', key, lock, 'NX', 'EX', ttl) else return 0 end" 1 $key $lock $ttl ``` 六 当多个key同时失效时,可以用随机TTL策略来平滑失效时间。比如在Redis中,使用SETEX命令设置TTL时,可以加入一个随机数。例如: ```bash SETEX key 300 $(($RANDOM % 50 + 250)) ``` 这样每个key的TTL会有小幅随机波动,避免同时失效。但需要注意,随机数的范围要控制在合理范围内,不能让TTL变得太小。在实际部署中,可以借助脚本或中间件自动处理,比如用Python的random模块生成随机值,再拼接到TTL上。不过这种方式在Redis集群环境下要特别注意一致性,避免出现数据不一致的情况。 七 除了互斥锁和布隆过滤器,我同时也使用过队列来解决击穿问题。比如在Spring Cloud中,当缓存key失效后,将请求放入RabbitMQ队列,由后台线程异步处理。这样可以避免大量请求同时到达数据库,也能减少锁的使用频率。队列的处理逻辑是:当key失效后,触发一个线程,从队列中取出请求,重新加载缓存,然后将结果写回。需要注意的是,队列的堆积问题,比如如果后台处理速度跟不上,会导致队列越积越多,反而影响性能。所以队列的消费速率要和数据库的负载能力匹配,可以通过监控队列长度调整线程数。 八 在监控缓存性能时,我使用Prometheus+Grafana来采集Redis的指标,比如缓存命中率、连接数、内存占用等。通过这些指标,可以及时发现穿透、击穿或雪崩的迹象。例如,当缓存命中率突然下降到30%以下,说明可能有穿透问题;当命中率短时间内波动剧烈,可能是击穿或雪崩。监控的配置比较直接,只需要在Redis的配置文件中开启info命令的输出,然后通过exporter拉取数据。例如,修改Redis配置文件中的`config set`参数: ```bash config set info "all" ``` 九 对于布隆过滤器的实现,我曾用Redis的set数据结构来模拟,但在数据量大的情况下,效率很低。后来改用Go的github.com/cesbit/bloom包,效果更好。该包支持自定义hash函数,避免哈希碰撞。在初始化时,先创建布隆过滤器,传入预计的数据量和误判率,比如: ```go bloom := NewBloom(1000000, 0.01) ``` 当请求来时,先检查布隆过滤器是否包含该key,如果包含再查Redis和数据库,否则直接返回错误。这种方式能有效拦截非法请求,但需要权衡误判率和内存占用,不能设置得过于宽松。 十 在高并发场景中,我见过一个项目因为没有处理雪崩,导致数据库瞬间崩溃。当时采用的是Redis单节点,所有key的TTL都是固定的,比如300秒。解决方法是将TTL拆分成多个时间段,并在初始化缓存时加入随机偏移。例如,用Lua脚本在set操作时随机加上一个时间偏移,比如: ```bash SETEX key $(($RANDOM % 100 + 250)) value ``` 这样每个key的TTL在250到350秒之间随机分布,避免同时失效。但需要注意,这种方式可能和业务逻辑冲突,比如用户需要固定的TTL,所以得根据实际业务调整。 十一 缓存穿透的另一种解决方案是记录请求日志,对异常请求进行拦截。例如在Nginx中配置一个简单的日志记录规则,抓取所有非缓存命中请求,然后分析这些请求是否是恶意攻击。如果发现大量无效请求,可以将这些key加到布隆过滤器中,或者直接返回404。这种方法适合有日志系统和分析能力的团队,但对实时性要求高,需要在请求入口处做处理,不能等日志分析完才拦截。比如在Nginx配置中可以这样写: ```nginx location /api { access_log /var/log/nginx/api.log; error_page 404 /404.html; ... } ``` 十二 在性能优化方面,我发现布隆过滤器对穿透问题的处理比简单的本地缓存更有效。布隆过滤器的空间复杂度远低于本地缓存,适合处理大量数据。但布隆过滤器本身会有误判,所以需要配合其他手段一起使用。比如在Spring Boot中,我曾用Redis的位图实现一个简易的布隆过滤器,设置位图长度为500万,每个key的hash值对应一个bit位。当请求来时,先检查这个bit位是否为1,不是再查Redis。这种方法在某些场景下足够用,但不适合作为核心方案,特别是数据量大的时候。 十三 对于缓存击穿,我见过一个项目用Redis锁加队列的方式,效果还不错。具体是,当key失效时,触发一个队列任务,由后台线程异步处理。例如,用Kafka作为消息队列,当key失效后,发送一个消息到Kafka,由消费者处理。处理逻辑是,从数据库获取数据后,重新写入缓存,并设置TTL。这种方式能避免锁的滥用,同时减少对数据库的直接压力。但在分布式环境下,要考虑消费者是否正常运行,以及消息是否会被重复消费。 十四 在高并发下,我曾用Redis的Lua脚本解决击穿问题,脚本逻辑是先检查key是否存在,存在则直接返回;不存在则设置锁,并在锁失效后重新获取数据。这种方案能保证只有一个线程在重新加载数据,避免同时触发多个线程。例如,在Python中使用redis-py的Pipeline功能执行Lua脚本,确保原子性。这种方式需要对Lua脚本进行充分测试,避免出现逻辑错误,比如锁未正确释放。 十五 对于缓存雪崩,我见过一些团队用缓存预热的方式,提前将数据加载到Redis中,避免短时间内大量失效。例如,在系统启动时,用定时任务加载热点数据,或者在应用初始化时异步填充数据。这种方法需要和业务逻辑结合,比如用户登录信息、商品信息等,提前加载到缓存。但要注意,预热的数据可能被频繁修改,所以需要定期刷新。例如,用Spring的@Scheduled注解设置定时任务,每小时执行一次数据加载。 十六 处理穿透和击穿时,我曾用过Redis的watch命令实现乐观锁,但这种方式容易出现死锁问题。比如当多个线程同时读取同一个key时,watch会监控key的变化,如果发生变化则回滚操作,重新尝试。这种方式在某些场景下能有效避免重复加载,但不适合高并发。后来改用Lua脚本实现互斥锁,效果更稳定。 十七 在实际部署中,我用过Redis集群来缓解雪崩问题。集群的分片机制能分散流量,避免单点压力过大。例如,将缓存数据分布在多个节点上,每个节点处理一部分key。但需要注意数据一致性问题,比如使用RedLock算法或Quorum机制确保数据同步。这种方式对网络和配置要求较高,适合大型分布式系统。 十八 除了上述方法,我还用过缓存降级策略,比如当Redis不可用时,临时切换到本地缓存或直接返回默认值。这种策略需要在应用层面进行配置,比如设置一个环境变量: ```env CACHE_FALLBACK=local ``` 当Redis连接失败时,根据这个变量判断是否使用本地缓存。这种方式能保障服务的可用性,但可能影响数据一致性,需要根据业务场景权衡使用。 十九 在监控层面,我曾用过Redis的`INFO memory`、`INFO stats`、`INFO server`等命令来获取缓存状态。例如,通过定期执行这些命令,可以监控Redis的内存占用、连接数、命中率等指标。当发现命中率异常下降时,可以及时排查是否出现了穿透或击穿。例如,执行以下命令获取命中率: ```bash redis-cli info stats | grep 'keyspace' ``` 二十 我见过一些项目在缓存失效时,会触发一个异步补偿任务,由后台线程重新加载数据。例如,使用Spring的@Async注解,当key失效后,调用一个异步方法,从数据库获取数据并写入缓存。这种方式能减少对数据库的直接访问,同时避免阻塞主线程。但要注意任务堆积问题,需要合理设置并发数和超时时间。例如,在Java中配置线程池: ```java @Async("cacheThreadPool") public void asyncLoadData(String key) { // 从数据库获取数据并写入缓存 } ```





