1. 什么是缓存击穿、缓存穿透、缓存雪崩?
1.1 缓存穿透问题
先来看一个常见的缓存使用方式:读请求来了,先查下缓存,缓存有值命中,就直接返回;缓存没命中,就去查数据库,然后把数据库的值更新到缓存,再返回。
缓存穿透:是指查询一个不存在的数据,由于 Redis 中没有该数据,因此会直接查询数据库,导致数据库压力增大,甚至可能引发恶意攻击。
缓存穿透一般都是这几种情况产生的:
- 业务不合理的设计,比如大多数用户都没开守护,但是你的每个请求都去缓存,查询某个userid查询有没有守护。
- 业务/运维/开发失误的操作,比如缓存和数据库的数据都被误删除了。
- 黑客非法请求攻击,比如黑客故意捏造大量非法请求,以读取不存在的业务数据。
如何避免缓存穿透呢? 一般有二种方法。
方法一:缓存空数据
查询返回的数据为空,仍把这个空结果进行缓存;比如一个get请求:gugu/shop/getById/1,可以将{key:1,value:null}存入redis中。
如果查询数据库为空,我们可以给redis缓存设置个空值,或者默认值,当查询一个不存在的数据时,Redis 中有该键,但是该键对应的值是一个空对象,因此不会查询数据库。 注意:有写请求进来的话,需要更新缓存哈,以保证缓存一致性,同时,最后给缓存设置适当的过期时间。(业务上比较常用,简单有效)。
优点:实现简单。
缺点:①如果有大量查询的数据都不存在,则redis中会缓存大量空数据,这会消耗内存(这里可以给缓存添加一个TTL,减少内存消耗);②如果原先查的数据不存在,但是后来数据库中又添加上了,可能存在数据不一致的问题。
方法二:布隆过滤器
使用布隆过滤器快速判断数据是否存在。即一个查询请求过来时,先通过布隆过滤器判断值是否存在,存在才继续往下查。
优点:内存占用较少,没有多余key。
缺点:①实现复杂;②存在误判。
1.3 小结
缓存穿透的解决方案有哪些?
①缓存nul值
②布隆过滤
【额外补充几点】
③增强id的复杂度,避免被猜测id规律
④做好数据的基础格式校验
⑤加强用户权限校验
⑥做好热点参数的限流
1.2 缓存雪奔问题
缓存雪奔: 是指在同一时段大量的缓存key过期或者Redis服务宕机,导致大量请求都直接访问数据库,引起数据库压力过大甚至down机。
方法一:大量的缓存key失效
给不同的Key的TTL添加随机值,比如将缓存失效时间分散开,可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
方法二:Redis服务宕机
①利用Redis集群提高服务的可用性,比如哨兵模式、集群模式;
②给缓存业务添加降级限流策略,比如可以在ngxin或spring cloud gateway中处理;(注:降级可做为系统的保底策略,适用于穿透、击穿、雪崩)
③给业务添加多级缓存,比如使用Guava或Caffeine作为一级缓存,redis作为二级缓存等;
1.3 缓存击穿问题
缓存击穿: 给某一个key设置了过期时间,当key过期的时候,恰好这时间点对这个key有大量的并发请求过来,这些并发的请求可能会瞬间把DB压垮。
缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
方法一: 互斥锁
使用互斥锁方案。缓存失效时,不是立即去加载db数据,而是先使用某些带成功返回的原子操作命令,如(Redis的setnx)去操作,成功的时候,再去加载db数据库数据和设置缓存。否则就去重试获取缓存。
方法二: 逻辑过期
①在设置key的时候,设置一个过期时间字段,一起存入缓存中,注意不给当前key设置TTL过期时间;
②当查询的时候,从redis取出数据后判断时间是否过期;
③如果过期则开通另外一个线程进行数据同步,当前线程正常返回数据,但这个数据不是最新的
“永不过期”,是指没有设置过期时间,但是热点数据快要过期时,异步线程去更新和设置过期时间。
2.Redis缓存之双写一致性
redis做为缓存,mysql的数据如何与redis进行同步呢?(本质上问的就是双写一致性)
2.1 双写一致性定义
双写一致性:当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要保持一致。
在这里插入图片描述
读操作:缓存命中,直接返回;缓存未命中查询数据库,写入缓存,设定超时时间;
写操作:延迟双删
2.2 问题分析
这里就会产生一个问题,上图步骤①②可以调换顺序吗?即先删除缓存,还是先修改数据库?
解答:不管先删除缓存,还是先修改数据库,都会产生脏数据,所以需要删除两次缓存;由于数据库主从同步的问题,还需要延时删除。
但是延迟双删这个延迟时间不好判断;而且在延时的过程中可能会出现脏数据,并不能保证强一致性;
2.3 解决方案
2.3.1 针对一致性要求高
使用分布式锁
- 共享锁:读锁readLock,加锁之后,其他线程可以共享读操作;
- 排他锁:也叫独占锁writeLock,加锁之后,阻塞其他线程读写操作;
读数据的时候,添加共享锁,写数据的时候,添加排他锁
虽然使用读写锁,可以保证强一致性,但性能会降低。
2.3.2 针对允许延迟一致
①基于MQ的异步通知:使用MQ中间件,更新数据之后,通知缓存删除;
②基于Canal的异步通知:利用canal中间件,不需要修改业务代码,伪装为mysql的一个从节点,canal通过读取binlog数据更新缓存;
备注:二进制日志(BINLOG)记录了所有的DDL(数据定义语言)语句和DML(数据操纵语言)语句,但不包括数据查询(SELECT、SHOW)语句;
3.说说Redis的常用应用场景
- 缓存
- 排行榜
- 计数器应用
- 共享Session
- 分布式锁
- 社交网络
- 消息队列
- 位操作
2.1 缓存
我们一提到redis,自然而然就想到缓存,国内外中大型的网站都离不开缓存。合理的利用缓存,比如缓存热点数据,不仅可以提升网站的访问速度,还可以降低数据库DB的压力。并且,Redis相比于memcached,还提供了丰富的数据结构,并且提供RDB和AOF等持久化机制,强的一批。
2.2 排行榜
当今互联网应用,有各种各样的排行榜,如电商网站的月度销量排行榜、社交APP的礼物排行榜、小程序的投票排行榜等等。Redis提供的zset数据类型能够实现这些复杂的排行榜。
比如,用户每天上传视频,获得点赞的排行榜可以这样设计:
1.用户Jay上传一个视频,获得6个赞,可以酱紫:
zadd user:ranking:2021-03-03 Jay 3
2.过了一段时间,再获得一个赞,可以这样:
zincrby user:ranking:2021-03-03 Jay 1
3.如果某个用户John作弊,需要删除该用户:
zrem user:ranking:2021-03-03 John
4.展示获取赞数最多的3个用户
zrevrangebyrank user:ranking:2021-03-03 0 2
2.3 计数器应用
各大网站、APP应用经常需要计数器的功能,如短视频的播放数、电商网站的浏览数。这些播放数、浏览数一般要求实时的,每一次播放和浏览都要做加1的操作,如果并发量很大对于传统关系型数据的性能是一种挑战。Redis天然支持计数功能而且计数的性能也非常好,可以说是计数器系统的重要选择。
2.4 共享Session
如果一个分布式Web服务将用户的Session信息保存在各自服务器,用户刷新一次可能就需要重新登录了,这样显然有问题。实际上,可以使用Redis将用户的Session进行集中管理,每次用户更新或者查询登录信息都直接从Redis中集中获取。
2.5 分布式锁
几乎每个互联网公司中都使用了分布式部署,分布式服务下,就会遇到对同一个资源的并发访问的技术难题,如秒杀、下单减库存等场景。
用synchronize或者reentrantlock本地锁肯定是不行的。
如果是并发量不大话,使用数据库的悲观锁、乐观锁来实现没啥问题。
但是在并发量高的场合中,利用数据库锁来控制资源的并发访问,会影响数据库的性能。
实际上,可以用Redis的setnx来实现分布式的锁。
使用过Redis分布式锁嘛?有哪些注意点呢?
分布式锁,是控制分布式系统不同进程共同访问共享资源的一种锁的实现。秒杀下单、抢红包等等业务场景,都需要用到分布式锁,我们项目中经常使用Redis作为分布式锁。
选了Redis分布式锁的几种实现方法,大家来讨论下,看有没有啥问题哈。
- 命令setnx + expire分开写
- setnx + value值是过期时间
- set的扩展命令(set ex px nx)
- set ex px nx + 校验唯一随机值,再删除
2.5.1 命令setnx + expire分开写
if(jedis.setnx(key,lock_value) == 1){ //加锁 expire(key,100); //设置过期时间 try { do something //业务请求 }catch(){ } finally { jedis.del(key); //释放锁 } }
如果执行完setnx加锁,正要执行expire设置过期时间时,进程crash掉或者要重启维护了,那这个锁就“长生不老”了,别的线程永远获取不到锁啦,所以分布式锁不能这么实现。
2.5.2 setnx + value值是过期时间
long expires = System.currentTimeMillis() + expireTime; //系统时间+设置的过期时间 String expiresStr = String.valueOf(expires); // 如果当前锁不存在,返回加锁成功 if (jedis.setnx(key, expiresStr) == 1) { return true; } // 如果锁已经存在,获取锁的过期时间 String currentValueStr = jedis.get(key); // 如果获取到的过期时间,小于系统当前时间,表示已经过期 if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) { // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间(不了解redis的getSet命令的小伙伴,可以去官网看下哈) String oldValueStr = jedis.getSet(key_resource_id, expiresStr); if (oldValueStr != null && oldValueStr.equals(currentValueStr)) { // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁 return true; } } //其他情况,均返回加锁失败 return false; }
笔者看过有开发小伙伴是这么实现分布式锁的,但是这种方案也有这些缺点:
- 过期时间是客户端自己生成的,分布式环境下,每个客户端的时间必须同步。
- 没有保存持有者的唯一标识,可能被别的客户端释放/解锁。
- 锁过期的时候,并发多个客户端同时请求过来,都执行了jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖。
2.5.3:set的扩展命令(set ex px nx)(注意可能存在的问题)
if(jedis.set(key, lock_value, "NX", "EX", 100s) == 1){ //加锁 try { do something //业务处理 }catch(){ } finally { jedis.del(key); //释放锁 } }
这个方案可能存在这样的问题:
锁过期释放了,业务还没执行完。
锁被别的线程误删。
2.5.4 set ex px nx + 校验唯一随机值,再删除
if(jedis.set(key, uni_request_id, "NX", "EX", 100s) == 1){ //加锁 try { do something //业务处理 }catch(){ } finally { //判断是不是当前线程加的锁,是才释放 if (uni_request_id.equals(jedis.get(key))) { jedis.del(key); //释放锁 } } }
在这里,判断当前线程加的锁和释放锁是不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。
一般也是用lua脚本代替。lua脚本如下:
if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end;
这种方式比较不错了,一般情况下,已经可以使用这种实现方式。但是存在锁过期释放了,业务还没执行完的问题(实际上,估算个业务处理的时间,一般没啥问题了)。
2.6 社交网络
赞/踩、粉丝、共同好友/喜好、推送、下拉刷新等是社交网站的必备功能,由于社交网站访问量通常比较大,而且传统的关系型数据不太适保存 这种类型的数据,Redis提供的数据结构可以相对比较容易地实现这些功能。
2.7 消息队列
消息队列是大型网站必用中间件,如ActiveMQ、RabbitMQ、Kafka等流行的消息队列中间件,主要用于业务解耦、流量削峰及异步处理实时性低的业务。Redis提供了发布/订阅及阻塞队列功能,能实现一个简单的消息队列系统。另外,这个不能和专业的消息中间件相比。
2.8 位操作
用于数据量上亿的场景下,例如几亿用户系统的签到,去重登录次数统计,某用户是否在线状态等等。腾讯10亿用户,要几个毫秒内查询到某个用户是否在线,能怎么做?千万别说给每个用户建立一个key,然后挨个记(你可以算一下需要的内存会很恐怖,而且这种类似的需求很多。这里要用到位操作——使用setbit、getbit、bitcount命令。原理是:redis内构建一个足够长的数组,每个数组元素只能是0和1两个值,然后这个数组的下标index用来表示用户id(必须是数字哈),那么很显然,这个几亿长的大数组就能通过下标和元素值(0和1)来构建一个记忆系统。