上周公司线上服务突然炸了查了半小时才发现是缓存雪崩把数据库打挂了。折腾完我翻了十几篇相关的文章发现很多讲得都太绕新手根本看不懂。今天我就用大白话把这三个问题一次性讲清楚附6种亲测有效的解决方案。先搞懂三个问题到底是什么很多人搞不清这三个问题的区别我给你举个超市的例子一秒钟就懂了。首先说缓存穿透你开了个超市有人天天来问你有没有奥特曼变身器卖你仓库里根本没有这玩意儿每次有人问你都得去仓库翻一遍翻多了仓库管理员就烦了。对应到技术场景就是用户请求的key根本不在缓存里也不在数据库里每次请求都直接打到数据库请求多了数据库直接挂。然后是缓存击穿超市里的可乐搞促销1块钱一瓶几千人同时来买刚好货架上的可乐卖完了所有人都挤到仓库去抢仓库门直接被挤爆。对应技术场景就是某个热点key刚好过期了这时候大量请求过来缓存里没有直接全打到数据库数据库瞬间被打满。最后是缓存雪崩超市货架上的所有商品今天统一到期几千人同时来买东西发现货架全空了所有人都冲到仓库去仓库直接瘫痪。对应技术场景就是大量缓存key同一时间过期大量请求直接打到数据库数据库直接宕机。说白了三个问题本质都是缓存没挡住请求直接打到数据库把库打挂了只是发生的场景不一样而已。6种亲测有效的解决方案我整理了工作这几年实际用过的6种方案覆盖了三个问题的所有场景你可以根据自己的业务情况选。1. 缓存空值/默认值解决穿透这个是最简单粗暴的方案对付穿透贼好用。既然用户请求的key在数据库里不存在那你就把这个key对应的空值或者默认值写到缓存里过期时间设短一点比如5分钟。这样下次再有同样的请求过来直接从缓存里拿空值返回就不用打数据库了。举个代码例子defget_user_info(user_id):# 先查缓存userredis.get(fuser:{user_id})ifuser:returnjson.loads(user)ifuser!nullelseNone# 缓存没有查数据库userdb.query(SELECT * FROM user WHERE id %s,user_id).first()ifnotuser:# 数据库不存在缓存空值过期时间5分钟redis.setex(fuser:{user_id},300,null)returnNone# 数据库存在写缓存过期时间1小时redis.setex(fuser:{user_id},3600,json.dumps(user))returnuser这个方案的优点就是简单几行代码就搞定了。缺点就是如果有人用不同的不存在的key疯狂请求会产生很多垃圾缓存占用Redis内存。适合请求的key规律比较强或者恶意请求不多的场景。2. 布隆过滤器解决穿透如果恶意请求很多用缓存空值就不合适了这时候布隆过滤器就是你的救星。布隆过滤器你可以理解成一个超级高效的存在性检查器它可以告诉你某个key一定不存在或者可能存在。你把数据库里所有的合法key都提前放到布隆过滤器里请求过来先过布隆过滤器如果过滤器说这个key不存在直接返回错误不用查缓存也不用查数据库。用法也很简单Redis 4.0之后自带布隆过滤器插件直接用就行# 加载插件127.0.0.1:6379MODULE LOAD /usr/lib/redis/modules/rebloom.so# 创建布隆过滤器误差率0.01预计存储100万条数据127.0.0.1:6379BF.RESERVE user_filter0.011000000# 添加key127.0.0.1:6379BF.ADD user_filter1001# 查询key是否存在127.0.0.1:6379BF.EXISTS user_filter1001(integer)1血泪教训布隆过滤器有误差率你设置的误差率越低占用的内存就越大一定要根据自己的业务数据量合理设置。还有布隆过滤器不能删除元素如果你的数据是频繁删除的这个方案就不太适合。3. 热点key永不过期解决击穿对于特别热的key比如首页的商品列表秒杀活动的商品信息直接设置永不过期就完事儿了。你可能会问那数据更新怎么办很简单后台有数据更新的时候主动去更新缓存里的内容就行不用等它过期。这个方案的优点就是完全不会出现热点key过期的问题性能最高。缺点就是如果数据更新不及时会出现缓存和数据库不一致的情况适合对数据一致性要求不是特别高或者更新频率很低的热点key。4. 互斥锁解决击穿如果热点key需要经常更新不能永不过期那互斥锁就是最好的选择。当缓存失效的时候不是所有请求都去查数据库而是让第一个请求先拿到锁去查数据库然后更新缓存其他请求等缓存更新完了再去缓存里拿数据这样数据库就只会被打一次。代码示例defget_hot_goods(goods_id):# 先查缓存goodsredis.get(fgoods:{goods_id})ifgoods:returnjson.loads(goods)# 缓存没有尝试加锁lock_keyflock:goods:{goods_id}# 锁过期时间10秒防止死锁ifredis.set(lock_key,1,ex10,nxTrue):try:# 拿到锁查数据库goodsdb.query(SELECT * FROM goods WHERE id %s,goods_id).first()ifgoods:redis.setex(fgoods:{goods_id},3600,json.dumps(goods))returngoodsfinally:# 释放锁redis.delete(lock_key)else:# 没拿到锁等100毫秒再重试time.sleep(0.1)returnget_hot_goods(goods_id)这个方案的优点就是一致性好数据库压力小。缺点就是代码稍微复杂一点还要注意锁的过期时间防止死锁。适合对数据一致性要求比较高的热点key场景。5. 缓存过期时间加随机值解决雪崩这个是防止大量key同时过期最简单的方案亲测有效。你给缓存的过期时间加个随机的偏移量比如本来过期时间是1小时你就设成50分钟到70分钟之间的随机数这样所有key就不会同时过期了自然就不会发生雪崩了。代码示例importrandom# 基础过期时间1小时BASE_EXPIRE3600# 随机偏移量±10分钟OFFSET600# 最终过期时间expire_timeBASE_EXPIRErandom.randint(-OFFSET,OFFSET)redis.setex(key,expire_time,value)这个方案几乎没有额外成本几行代码就能搞定强烈推荐大家都这么设置缓存过期时间从根源上避免雪崩问题。6. 多级缓存架构解决所有问题如果你的业务流量特别大上面的单个方案都不够稳那直接上多级缓存架构就完事儿了。简单来说就是用户请求先到本地缓存比如Caffeine本地缓存没有再查RedisRedis没有再查数据库查到之后依次回写到Redis和本地缓存。而且本地缓存的过期时间设得比Redis短一点即使Redis雪崩了还有本地缓存挡一层数据库不会直接被打挂。架构大概是这样的用户请求 → Nginx → 服务本地缓存 → Redis → 数据库这个方案的优点就是稳定性最高三个问题都能解决性能也最好。缺点就是架构复杂一点需要维护本地缓存和Redis的一致性。适合流量特别大的核心业务场景。踩坑记录这些方案我都在生产环境用过踩了不少坑给你们提个醒。首先是布隆过滤器的误判问题我之前有个业务设置的误差率是0.1%结果线上还是出现了正常请求被拦截的情况后来把误差率调到0.01%就好了不要为了省内存把误差率设太高。然后是互斥锁的死锁问题之前有个同事写锁的时候忘了设置过期时间刚好服务拿到锁之后挂了锁一直没释放导致整个业务瘫痪了半小时加锁的时候一定要设置合理的过期时间。还有就是多级缓存的一致性问题本地缓存和Redis的数据更新一定要同步不然会出现用户拿到的数据不一致的情况最好用消息队列通知各个节点更新本地缓存。写在最后其实这三个问题没有你想的那么复杂也不用所有方案都用上根据自己的业务场景选就行。如果你的业务流量不大用缓存空值过期时间加随机值就足够了不用搞什么布隆过滤器、多级缓存过度设计只会给自己添麻烦。如果流量很大再根据实际情况加对应的方案就行。毕竟技术是为业务服务的合适的才是最好的。如果你们也遇到过相关的坑欢迎评论区交流。