Tips
面试指南系列,很多情况下不会去深挖细节,是小六六以被面试者的角色去回顾知识的一种方式,所以我默认大部分的东西,作为面试官的你,肯定是懂的。
上面的是脑图地址
叨絮
这个系列也写了几篇了,今天我们来看看redis,前面的可以去github上看看。 然后下面是前面的文章汇总
- 2021-Java后端工程师面试指南-(引言)
- 2021-Java后端工程师面试指南-(Java基础篇)
- 2021-Java后端工程师面试指南-(并发-多线程)
- 2021-Java后端工程师面试指南-(JVM)
- 2021-Java后端工程师面试指南-(MySQL)
说说什么是redis吧
Redis是一个开放源代码(BSD许可)的内存中数据结构存储,用作数据库,缓存和消息代理。它支持数据结构,例如字符串,哈希,列表,集合,带范围查询的排序集合,位图,超日志,带有半径查询和流的地理空间索引。Redis具有内置的复制,Lua脚本,LRU逐出,事务和不同级别的磁盘持久性,并通过Redis Sentinel和Redis Cluster自动分区提供了高可用性。
说说Redis有哪些优缺点
优点
- 性能优异, Redis能读的速度是110000次/s,写的速度是81000次/s。
- 数据持久化,支持AOF和RDB两种持久化方式。
- 事务,Redis的所有操作都是原子性的,同时Redis还支持对几个操作合并后的原子性执行。
- 结构丰富,除了支持string类型的value外还支持hash、set、zset、list等数据结构。
- 主从复制,主机会自动将数据同步到从机,可以进行读写分离。
缺点
- 库容量受到物理内存的限制,不能用作海量数据的高性能读写,因此Redis适合的场景主要局限在较小数据量的高性能操作和运算上。
- 宕机,宕机前有部分数据未能及时同步到从机,切换IP后还会引入数据不一致的问题,降低了系统的可用性。
- redis 较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂。为避免这一问题,运维人员在系统上线时必须确保有足够的空间,这对资源造成了很大的浪费。
说说为啥要用缓存
主要是为了提高系统的吞吐量,应对高并发,高性能场景
为什么要用 Redis 而不用 map/guava 做缓存?
- Java实现的Map是本地缓存,如果有多台实例(机器)的话,每个实例都需要各自保存一份缓存,缓存不具有一致性
- Redis实现的是分布式缓存,如果有多台实例(机器)的话,每个实例都共享一份缓存,缓存具有一致性。
- Java实现的Map不是专业做缓存的,JVM内存太大容易挂掉的。一般用做于容器来存储临时数据,缓存的数据随着JVM销毁而结束。Map所存储的数据结构,缓存过期机制等等是需要程序员自己手写的。
- Redis是专业做缓存的,可以用几十个G内存来做缓存。Redis一般用作于缓存,可以将缓存数据保存在硬盘中,Redis重启了后可以将其恢复。原生提供丰富的数据结构、缓存过期机制等等简单好用的功能。
Redis为什么这么快
1、完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1);
2、数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的;
3、采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗(绝大多数的瓶颈不在cpu)
4、使用多路I/O复用模型,非阻塞IO;
5、使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis直接自己构建了VM 机制 ,使用了resp协议
聊聊resp协议吧
Redis是Redis序列化协议,Redis客户端RESP协议与Redis服务器通信。Redis协议在以下几点之间做出了折衷:
- 简单的实现
- 快速地被计算机解析
- 简单得可以能被人工解析
其实就是一个二进制的序列化协议,举几个简单的例子哈 在RESP中,某些数据的类型取决于第一个字节:
“+”代表简单字符串Simple Strings
“+”代表错误类型
“:”代表整数
基于这种协议的话,其实我们可以自己去实现一个redis的客户端,以后有机会给大家写写。
如果万一CPU成为你的Redis瓶颈了,或者,你就是不想让服务器其他核闲置,那怎么办?
那也很简单,你多起几个Redis进程就好了。Redis是key-value数据库,又不是关系数据库,数据之间没有约束。只要客户端分清哪些key放在哪个Redis进程上就可以了。redis-cluster可以帮你做的更好。
说说 Redis的基本数据结构
- String 整数,浮点数或者字符串
- Set 集合
- Zset 有序集合
- Hash 散列表
- List 列表
那说说有序集合的实现方式是哪种数据结构?
跳跃表。
- 当数据较少时,sorted set是由一个ziplist来实现的。
- 当数据多的时候,sorted set是由一个dict + 一个skiplist来实现的。简单来讲,dict用来查询数据到分数的对应关系,而skiplist用来根据分数查询数据(可能是范围查找)。
说说redis的底层数据结构
sds
Redis的字符串,不是 C 语言中的字符串(即以空字符’\0’结尾的字符数组),它是自己构建了一种名为 简单动态字符串(simple dynamic string,SDS)的抽象类型,并将 SDS 作为 Redis的默认字符串表示。
1、len 保存了SDS保存字符串的长度
2、buf[] 数组用来保存字符串的每个元素
3、free j记录了 buf 数组中未使用的字节数量
链表
链表是一种常用的数据结构,C 语言内部是没有内置这种数据结构的实现,所以Redis自己构建了链表的实现
字典
字典又称为符号表或者关联数组、或映射(map),是一种用于保存键值对的抽象数据结构。字典中的每一个键 key 都是唯一的,通过 key 可以对值来进行查找或修改。C 语言中没有内置这种数据结构的实现,所以字典依然是 Redis自己构建的。
跳跃表
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其它节点的指针,从而达到快速访问节点的目的。具有如下性质:
1、由很多层结构组成;
2、每一层都是一个有序的链表,排列顺序为由高层到底层,都至少包含两个链表节点,分别是前面的head节点和后面的nil节点;
3、最底层的链表包含了所有的元素;
4、如果一个元素出现在某一层的链表中,那么在该层之下的链表也全都会出现(上一层的元素是当前层的元素的子集);
5、链表中的每个节点都包含两个指针,一个指向同一层的下一个链表节点,另一个指向下一层的同一个链表节点;
压缩列表
压缩列表(ziplist)是Redis为了节省内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
压缩列表的原理:压缩列表并不是对数据利用某种算法进行压缩,而是将数据按照一定规则编码在一块连续的内存区域,目的是节省内存。
说说缓存雪崩
一个缓存雪崩发过程
- redis集群大面积故障
- 缓存失效,但依然大量请求访问缓存服务redis
- redis大量失效后,大量请求转向到mysql数据库
- mysql的调用量暴增,很快就扛不住了,甚至直接宕机
- 由于大量的应用服务依赖mysql和redis的服务,这个时候很快会演变成各服务器集群的雪崩,最后网站彻底崩溃。
如何解决缓存雪崩
第一种方案: 缓存层设计成高可用,防止缓存大面积故障。即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务,例如 Redis Sentinel 和 Redis Cluster 都实现了高可用。
第二种方案:在批量往Redis存数据的时候,把每个Key的失效时间都加个随机值就好了,这样可以保证数据不会在同一时间大面积失效,我相信,Redis这点流量还是顶得住的。
那你聊聊缓存击穿
我个人理解 击穿 就是正面刚 比如我是矛 你是盾 我直接把你的盾击穿, 就是比如 几个热点Key 同时几百万并发直接把redis 干掉了, 然后数据全部打到数据库的情况,或者是redis的这几个热点数据失效的情景下,同时全部的并发查这个热数据,导致最后打到数据库的情况 这个就是缓存击穿。
如何解决缓存击穿
还是分布式锁 哈哈 因为分布式锁能控制到数据库的最后一到防线 redis做集群 哨兵
正常来说一般系统的qps都有一个峰值,一般我们使用能抗住这个峰值的内存去做这个缓存
那你说说缓存穿透
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
如何解决缓存穿透
第一种方案 和上面的双重锁一样 如果是拿到数据库为空 那么就给这个key 设置一个null值 时间设置短一点 30s, 这样下次并发进来就不会说把数据打到我们的数据库上了
还有就是我们写代码的时候 要对一些非法的请求参数校验 我相信大家都是这样做的。
第二种方案 采用我们第一篇中学到的一个高级用法 bitMap,查询的时候先查bitmap确定是否含有这个key
说说你是怎么解决缓存一致性问题的
几种方式缓存不一致的原因和解决方案
方案一 先更新数据库,再删缓存
这个方案的问题是什么呢? 就是假设我们更新数据成功了 然后去删除缓存的时候失败了 这就导致了缓存中是老数据,会造成缓存不一致
那我们就要保证删除一定要成功,我们可以在最后删除的时候 多删除几次,第二个就是用一个中间件canal 去兼听mysql的binlog 然后 从binlong中解析出要删除的字段 然后 继续上面第一个的方式(这个方式的好处 全程也算是异步的跟业务代码是没有关系的)
方案二 先更新数据库,再更新缓存
这个操作 问题更多感觉 首先 更新数据成功 更新缓存失败,或者是开始更新数据库成功 然后更新缓存成功 然后事务回滚,也是缓存不一致。
方案三 删除缓存 再更新数据库
看起来好像最好 我反正是删除缓存了 就算更新失败 下次去读也是最新的数据(一切看起来很美好),其实不然,试想2个并发一个更新 一个查询 你先更新的时候 删除了缓存 但是此时 查询发现没有缓存 然后吧数据缓存到了数据库 就会去查数据库 但是此时更新的又更新成功,最后就会再很长的一个时间内 缓存和数据库是不一致的,所以这种是方案是不可取的
综上所诉,我觉得最好的方式先查再删除 然后再配合订阅binlong 来做多重删除的方式是不错的,可能我接触的不是很多,希望各位大佬有更好的方式提出