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