前言
本文对Redis相关知识做了一个相对完整的总结,涉及到Redis数据类型结构、持久化、对象、LRU和LFU、处理命令、主从复制等。特别注意,口语化八股文系列,仅作突击复习核心知识点用,推荐有一定八股基础的人食用,更细的点需要大家自行查询相关详细图文资料
正文
Redis为什么快
- 基于内存操作数据
- 单线程操作数据避免多线程切换和竞争
- 使用IO多路复用模型处理网络请求
- 高效的数据结构比如String,Hash,Set,ZSet,List
- 合理的数据编码比如SDS,Hash表,ZipList,快表
- **虚拟内存机制(冷热数据交换)**会将不经常访问的冷数据暂时从内存交换到磁盘,腾出空间给热数据
Redis有哪些数据类型
String、Hash、List、Set、Zset、Bitmap(位存储)、地理位置(Geospatial)、基数统计(HyperLogLogs)、Stream
- String类型如字面意思,value存储字符串或者整数,通常用来做KV缓存。
- Hash的value是键值对,因此适合做对象的缓存。
- List因为可以左右自由push和pop,所以能当成队列和栈使用。
- Set的value是无序不重复集合,
- Zset则是有序不重复集合。
- 基数统计存储一组不重复的数据,以一定误差为代价,节省大量内存并提供了丰富的统计功能,适合统计每日访问数、共同好友数等不太精确的数据。
- Bitmap操作二进制位记录数据,只有0和1两个状态,适合记录类似打卡、未打卡这样只有两种状态的数据。
- 地理位置主要应用在附近的人这类场景中。
- Stream是官方5.0版本后推出的一个消息队列的完善实现。
Redis数据类型有哪些底层数据结构
基础五种类型的数据结构在数据量不同大小时会使用不同的编码模式。
- String:如果存储数字的话,是用int类型的编码;如果存储非数字,小于等于39字节的字符串,是embstr;大于39个字节,则是raw编码。
- List:如果列表的元素个数小于512个,列表每个元素的值都小于64字节(默认),使用ziplist编码,否则使用linkedlist(quicklist)编码
- Hash:哈希类型元素个数小于512个,所有值小于64字节的话,使用ziplist编码,否则使用hashtable编码。
- Set:如果集合中的元素都是整数且元素个数小于512个,使用intset编码,否则使用hashtable编码。
- Zset:当有序集合的元素个数小于128个,每个元素的值小于64字节时,使用ziplist编码,否则使用skiplist(跳跃表)编码
简单动态字符串(SDS)
简单动态字符串的特点在于动态扩容,其中有两大优势,分别是空间预分配和惰性释放空间,意思就是扩容时分配空间会额外多分配一些,避免后续多次扩容,相应的释放时,不会立刻释放不用的内存空间,而是记录留待后用。
还有一个特点是增加长度标记位len,用来记录字符串实际长度。这是在C语言字符串上做的优化,比如有了长度标记位len,在获取字符串长度时不用遍历直接获取即可,时间复杂度O(1),原版需要遍历,时间复杂度O(n)。原版在做字符串拼接修改的时候会造成缓冲区溢出,增加长度标记位len之后,会判断修改后的长度是否大于len,大于的话会提前扩容,避免了缓冲区溢出。
哈希表(Hash)
Redis用一张全局哈希表存储所有字典数据,解决哈希冲突的方法是链地址法,通过字典对象里的next指针指向下一个拥有相同哈希值的节点。
触发扩容分成两种情况,第一种是Redis没有执行bgsave(rdb)或者bgrewriteaof(aof)命令时,负载因子(总节点数/哈希表长度)大于1。第二种是Redis正在备份,负载因子大于5。
扩容时会触发渐进式rehash,此时会生成原哈希表两倍长度的新哈希表,收缩则生成一半大小。渐进式rehash的意思是,当rehash时存在两个哈希表,查删改操作可能在两张表进行,但是新增一定是在新哈希表执行,并且rehash的速率也跟服务器繁忙程度有关,忙就一次少迁移点,闲就多迁点。
整数集(IntSet)
整数集是Set类型的底层实现之一,当数据量小且全为整数时使用整数集。整数集存储数据有序且不重复,内部根据数据大小分为三种整数编码,分别是int16、int32、int64。
整数集存在升级,比如在int16的整数集中插入一个int32的数据,那么整个整数集就会生成一个新的每个元素都是int32大小的数组,然后有序填充数据到扩容后的整数集。不存在降级,原因是需要权衡减少缩减带来的开销。
压缩列表(ZipList)
压缩列表是List、Hash、Zset在数据小且节点少情况下的首选编码。压缩列表是一个字节数组,它的压缩是相对于正常的集合来讲的,正常集合每个元素的大小取决于集合中最大的元素,而压缩列表则是选择按照元素的编码和实际大小分配内存空间,同时增加了一些和长度有关的字段来保障遍历。
压缩列表也存在缺点,比如因为存储实际长度,所以不论是增删改都会触发内存分配,并且节点扩容时可能产生链式反应,会修改整个压缩列表有关长度字段的数据。
快表(QuickList)
快表是List类型的底层实现之一,是以压缩列表作为节点的双端链表。压缩列表的引入减少了对内存的消耗,同时带来了压缩列表的问题,就是写数据带来的内存重分配,Redis提供给使用者quicklist.fill参数用来做调参优化。
跳表(ZSkipList)
跳表是Zset类型的底层实现之一,是在链表的基础上增加多级索引,通过索引以Ologn的时间复杂度来实现数据的增删改查。因为增加了索引来辅助查询,所以是典型的空间换时间的优化。
谈谈redis的对象机制(redisObject)
redisObject内部维护了多个属性字段,用以描述当前对象的数据类型、数据编码。还有指针指向真实的数据结构。
增加了LRU字段用来实现淘汰策略,当选择LRU时,该字段存储最后一次访问时间,当选择LFU时,该字段前16位存储分钟级的最后一次访问时间,后8位存储最近访问次数。
引用计数字段表示对象被引用的次数,当引用次数>1时说明是共享对象,redis为了节省内存,不会重复建相同的对象,目前共享对象仅支持整数值的字符串对象。之所以只支持整数,原因是整数之间判断是否相等时间复杂度为O1,其他类型的数据会消耗更多的时间。Redis在启动的时候默认会创建0到9999的整数对象用于共享。
LRU和LFU原理及应用
LRU意为最近最少使用,淘汰最后一次访问时间最远的数据。LFU意为最不频繁使用,淘汰最近一段时间访问次数最少的数据。
lru可以通过链表实现,根据数据变动分为两种情况。一是当链表已满,新元素加入进来时,会先将链表尾部节点丢弃,并将新元素放在头部,头部之后的数据顺延一位。二是当链表中元素被访问后,会将该元素提到头部,相关节点后移一位。
Redis在实现lru和lfu时并没有用到链表来维护全部Key,而是选择了固定大小的待淘汰数据集合。每次随机选择一批数据放入集合中,根据设置的淘汰策略比如lru、lfu、ttl、随机来淘汰数据。
lru和lfu的实现离不开redisObject的lru字段,内部存储一个时间戳,每次访问之后,lru会通过获取redis全局时钟来更新这个字段。lru为了节省内存开销复用了lru这个字段,存储了分钟级的时间戳,访问次数会随着时间进行衰减。这里的全局时间值,redis为了减少获取机器时钟的调用次数,通过定时任务维护了一个全局变量。
Redis是如何处理一条命令
- 根据给定的key,在数据库字典中查找和他相对应的redisObject,如果没找到,就返回NULL;
- 检查redisObject的type属性和执行命令所需的类型是否相符,如果不相符,返回类型错误;
- 根据redisObject的encoding属性所指定的编码,选择合适的操作函数来处理底层的数据结构;
- 返回数据结构的操作结果作为命令的返回值。
Redis持久化(触发方式及优缺点)
RDB持久化
RDB是redis data base的简写,意为数据快照。RDB持久化是某一时刻对当前进程数据生成快照并保存在磁盘上的过程。
触发方式分为手动和自动两种。手动触发通过save和bgsave命令实现。save命令使用主进程进行RDB持久化,会阻塞Redis,通常不建议使用。更建议使用bgsave命令,该命令会执行fork操作创建子进程,由子进程执行RDB持久化,阻塞仅在fork操作。自动触发依赖于redis.conf配置文件中的配置项save m n,意思是m秒内由n次操作就调用bgsave命令执行RDB持久化
RDB的优点有二。一是使用LZF压缩算法,生成快照文件远小于内存数据,便于全量复制、备份等场景。二是Redis从RDB快照文件中恢复数据的速度远远快于AOF。
RDB缺点有三。一是fork是重量级操作,会造成短暂阻塞,因此不能频繁执行,导致RDB持久化会有间隔,恰好宕机会丢失数据。二是RDB经过压缩,文件不可读,而AOF是可读的。三是不同版本间的RDB可能不兼容。
使用子进程而不是线程的原因是,避免多线程竞争数据资源导致的并发性能问题。fork使用了写时复制技术,在fork子进程时拷贝的是父进程的内存页表,也就是虚拟内存和物理内存的映射索引表,相比直接拷贝物理内存会快得多。
AOF持久化
AOF采用写后日志即先写内存再写日志。优点是实时性好而且因为命令运行成功,所以不用校验命令语法。缺点是写后日志如果命令执行完写日志之前宕机会丢数据,同时主线程写日志会阻塞后续操作。
AOF记录命令的步骤分为两步,一是追加命令,当执行完命令后,将命令追加到AOF Buffer缓冲区中。第二步是根据配置项决定什么时候同步到磁盘,配置共有三种,always、everysec和no。always会使用主线程将每一条命令都写入磁盘,性能较差。everysec会使用另一个后台IO线程每一秒同步一次磁盘,不阻塞主线程,缺点是会丢失一秒数据。no是将同步时机交给操作系统,性能最好但是宕机会丢失大量数据。
因为AOF文件没有压缩,所以为了避免AOF文件膨胀,增加了AOF重写机制。AOF重写就是去除冗余命令,比如str从1变成2再变3,经过重写后只会留下str=3的命令。基于性能和内存开销的考虑,选择了fork子进程负责重写,重写会生成一个新的AOF文件,然后原子替换原来的AOF文件,避免出现资源竞争或者因为重写失败污染原文件。
混合
Redis在4.0后新增混合模式,可以在RDB两次快照之间使用AOF进行持久化
Redis 过期键的删除策略有哪些
过期键的删除策略有三种,分别是定期删除、惰性删除和定时删除。定期删除每隔一段时间删除当前所有过期的键。惰性删除是每次获取键时,判断该键是否过期,过期就删除,没有就返回。定时删除是设置键过期时间的同时生成一个定时器,让定时器在时间到的时候删除键。Redis基于性能和节省空间的考虑选择使用定期+惰性删除结合的方案。
过期键对RDB不会造成影响,在生成RDB文件时,过期键会被剔除。载入RDB文件恢复数据时,分成主从两种情况,主库会剔除已过期数据,从库则是全包。
Redis 内存淘汰算法有哪些
当Redis内存满了之后,写入数据会报错,因为4.0之后默认内存淘汰策略是不淘汰,也就是不主动淘汰旧数据同时拒绝新数据写入。
内存淘汰策略有八种,分为noeviction(不淘汰)、volatile-random(过期键随机淘汰)、volatile-ttl(淘汰最快过期的数据)、volatile-lru(根据LRU策略淘汰过期键)、volatile-lfu(根据LFU策略淘汰过期键)、allkeys-random(全部键随机淘汰)、allkeys-lru(根据LRU策略淘汰全部键)、allkeys-lfu(根据LFU策略淘汰全部键)。
内存淘汰策略根据实际情况选择即可,一般是全键或者过期键的LRU策略。处理逻辑是每次从库里拿出一批数据,根据LRU删除部分键,然后暂存到一个池子里,再取出一批数据与池子里的对比根据LRU策略删除,直到降到设置的最大内存之下。
主从复制(全量及增量)
主从复制是指将一台Redis服务器的数据,复制到另一台服务器。前者被称为主节点,后者被称为从节点,数据的复制是单向的,不可逆。
主从复制的作用有很多,比如数据冗余、负载均衡、高可用。数据冗余是指从库实际上相当于主库的热备份。负载均衡是指主从配合读写分离使用,能通过让从库分担读工作的方式降低主库服务器负载。高可用是指哨兵或者集群部署时,如果主库宕机能马上切换从库顶上,保证系统的高可用。
全量复制分为三个阶段,第一步是主从库建立连接,从库发送同步请求。第二步是主库通过bgsave命令生成RDB文件发送给从库,从库先清空数据再根据RDB文件恢复数据。第三步是主库将第二步中间产生的记录在replication buffer中的追加写命令发送给从库,从库再执行这些命令,自此全量复制结束。
全量复制是个耗时耗资源的大动作,因此在合适情况下会选择增量复制。Redis主库会有一个repl_backlog主从复制日志环形缓冲区,每一次主从复制的时候,从库会记录一个当前复制位置的slave_repl_offset,下一次主从复制的时候会带上这个标记位在主从复制日志环形缓冲区找有没有该标记位,如果没有说明距离上一次复制过了很久被新的数据覆盖掉了,那么Redis选择进行全量复制。如果存在该标记位,那么就选择增量复制,将该标记位后面的写命令同步给从库执行。
Redis-Cluster(节点对请求的处理过程)
Redis-Cluster引入了哈希槽的概念,一共16384个,也就是2的14次方,由各个主节点分别负责一部分槽位,key通过CRC(循环冗余校验)16对16384取模判断存放在哪个槽位。
CRC16算法产生16位数据,也就是会产生2的16次方数据,那么为什么哈希槽是2的14次方呢?原因是作者基于性能的权衡,在Redis-Cluster内部通信机制采用Gossip协议,也就是每个节点都存储一份元数据,其中包含槽位信息。一旦发生修改,节点间将互相传递元数据信息进行同步更新,因此传输的心跳包大小尤为重要,2的14次方经过bitmap压缩后是2k(2 * 8 (8 bit) * 1024(1k) = 16K)大小,2的16次方则是8k,并且主节点一般不超过1000个,因此16384比较合适。
内部通信机制除了上面的Gossip,常见的还有集中制。集中制的意思是将集群信息,比如节点数据、故障信息放在一个节点上管理,比如zookeeper。
集中制的好处是统一管理信息,一旦某个节点发生变更,就会马上推送到管理节点更新信息,其他节点马上就能读取到更新后的信息,时效性好。坏处是管理节点压力大。Gossip的好处是元数据分散,各个节点更新压力小,不好之处在于时效性不好。
在cluster模式下,节点对请求的处理过程如下:
-
首先通过crc16(key)/16384计算出slot,如果当前节点负责该slot,则执行请求,否则Moved重定向到对应slot,
-
还要考虑该slot是否在迁出(MIGRATING),也就是集群伸缩变动中。如果是的话,返回ASK错误重定向客户端到迁移的目的服务器上
-
MOVED 错误: 表示数据已经迁移到了其他节点,客户端应该根据错误信息更新本地路由表,并重新发送请求到新的节点。
-
ASK 错误: 表示数据仍然在迁移的过程中,客户端应该根据错误信息更新本地路由表,并将请求发送到指定的节点上,该节点会处理请求并将客户端重定向到数据迁移目标节点上。
缓存场景解决方案
缓存穿透
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求
解决方案
- 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击
- 布隆过滤器。bloomfilter就类似于一个hash set,用于快速判某个元素是否存在于集合中,其典型的应用场景就是快速判断一个key是否存在于某容器,不存在就直接返回
缓存击穿
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
解决方案
- 若缓存的数据是基本不会发生更新的,则可尝试将该热点数据设置为永不过期
- 若缓存的数据更新频繁或者在缓存刷新的流程耗时较长的情况下,可以利用定时线程在缓存过期前主动地重新构建缓存或者延后缓存的过期时间,以保证所有的请求能一直访问到对应的缓存。
- 若缓存的数据更新不频繁,且缓存刷新的整个流程耗时较少的情况下,则可以采用基于 Redis、zookeeper 等分布式中间件的分布式互斥锁,或者本地互斥锁以保证仅少量的请求能请求数据库并重新构建缓存,其余线程则在锁释放后能访问到新缓存。
缓存雪崩
缓存雪崩是指缓存中数据大批量到过期时间或者缓存挂掉,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
redis没有挂的情况解决方案
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
- 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中。
- 设置热点数据永远不过期
redis挂了的情况
- 事前:Redis 高可用,主从+哨兵,Redis cluster,避免全盘崩溃。
- 事中:本地缓存 + hystrix 限流&降级,避免 MySQL 被打死。
- 事后:Redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。
如何保证数据库和缓存的数据一致性
如果数据量级很小,可以全量数据装载进缓存,定时刷入数据库,有一个缺陷是如果缓存宕机,数据库可能不是最新的数据。
通常对缓存的操作分为修改和删除两种,修改有两个问题,一是异常,修改数据库和缓存只要有一个出现异常,双方的值都会不一样,二是并发带来的修改丢失问题。删除可以避免修改异常带来的问题,但是也不能解决并发的问题,解决可以通过延时双删。通常会采用旁路缓存模式,也就是先更新数据库,再删除缓存,先删除缓存的话容易在并发时缓存写入旧值。后删除缓存同样会存在问题,在缓存刚好失效的情况下,读请求先于写请求读取数据库,此时写请求更新数据库,然后删除缓存,最后读请求写入旧缓存。但这种极端条件不容易满足,还不如考虑删除缓存失败的情况,删除失败可以考虑重试,重试最好异步可以走消息队列。
如果要保证强一致性,就要考虑分布式事务,比如2PC,3PC之类的一致性协议,但是性能降低了,为了保证性能,还是考虑最终一致性。
Nacos为什么要加一层本地文件缓存,如何解决数据一致性问题
Nacos 之所以在其架构中加入本地文件缓存的层次,主要是为了提高系统的性能和可用性。本地文件缓存允许Nacos在应对高并发和大规模数据时,能够快速访问数据,减轻服务器负载,并且提供更快的数据读取速度。然而,这种本地缓存也引入了数据一致性的问题。因为当数据在Nacos服务器上发生变化时,本地文件缓存可能与实际数据不一致。这可能导致客户端读取的是旧数据,而不是最新的数据。为了解决这个问题,Nacos采取了以下措施:
- 定期刷新或实时刷新: Nacos的本地缓存可以定期或者实时地与服务器同步,以确保缓存数据与服务器上的数据保持一致。这样做可以减少不一致性的风险,使得本地缓存中的数据更接近于实际数据。
- 数据版本控制: Nacos使用版本控制机制来跟踪数据的变化。每次数据变更时,都会更新数据的版本号。客户端在请求数据时可以带上版本号,服务端可以根据版本号判断是否需要更新缓存。
- 强制刷新: 当客户端需要获取最新数据时,可以向Nacos服务器发送强制刷新的请求,以确保从服务器获取最新数据而不使用缓存数据。
- 提供一致性保证的API接口: Nacos提供了一致性保证的API接口,使得开发人员可以选择在需要强一致性的情况下直接访问服务器,而不是通过本地缓存。
总体而言,Nacos在使用本地文件缓存时,采取了诸多策略来保证数据的一致性,并提供了一些机制供开发人员在需要时主动控制数据的刷新和获取,以应对缓存数据与实际数据不一致的问题。
Redisson分布式锁原理
Redisson分布式锁的加锁和解锁通过Lua脚本来确保原子性。加锁的Lua脚本判断锁是否存在,不存在则创建一个Hash结构的数据,存入客户端的固定UUID和线程ID作为唯一标识KEY,值为可重入次数(例如数字1),过期时间为30秒。如果存在则判断是否为同一客户端同一线程,如果是的话,可重入次数加一。
过期时间是为了避免死锁,在客户端宕机后,过期时间到了就会自动释放锁。同时为了避免客户端在过期时间后释放锁,锁失效,Redisson在客户端会开启一个定时任务,默认每隔10s给当前锁续期,这是看门狗机制。