一、认识Redis
1. 什么是 Redis?
Redis 是一种基于内存的数据库,对数据的读写操作都是在内存中完成,因此读写速度非常快,常用于缓存,消息队列、分布式锁等场景。Redis 提供了多种数据类型来支持不同的业务场景,比如 String(字符串)、Hash(哈希)。并且对数据类型的操作都是原子性的,因为执行命令由单线程负责的,不存在并发竞争的问题。
除此之外,Redis 还支持事务 、持久化、多种集群方案(主从复制模式、哨兵模式、切片机群模式)、发布/订阅模式,内存淘汰机制、过期删除机制等等。
2. Redis和Memcached有什么区别?
- Redis 支持的数据类型更丰富(String、Hash、List、Set、ZSet),而 Memcached 只支持最简单的 key-value 数据类型;
- Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 没有持久化功能,数据全部存在内存之中,Memcached 重启或者挂掉后,数据就没了;
- Redis 原生支持集群模式,Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;
- Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持;
3. 为什么用Redis作为MySQL的缓存?
- Redis 具备高性能
操作 Redis 缓存就是直接操作内存,所以速度相当快。 - Redis具备高并发
Redis 的 QPS(Query Per Second,每秒钟处理完请求的次数) 是 MySQL 的 10 倍,直接访问 Redis 能够承受的请求是远远大于直接访问 MySQL 的
二、Redis数据结构
1. Redis 数据类型以及使用场景分别是什么?
- String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。
- List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
- Hash 类型:缓存对象、购物车等。
- Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
- Zset 类型:排序场景,比如排行榜、电话和姓名排序等。
- BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
- HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;
- GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;
- Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。
2. Redis 数据类型是怎么实现?
-
SDS
(1)引出:C语言字符串(字符数组)的不足:
获取字符串长度的时间复杂度为 O(N);
字符串的结尾是以 “\0” 字符标识,字符串里面不能包含有 “\0” 字符,因此不能保存二进制数据;
字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;
(2)SDS
优点如下:
len —— O(1)复杂度获取字符串长度;
不需要用 “\0” 字符来标识字符串结尾 —— 二进制安全;
当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小 —— 不会发生缓冲区溢出;
能灵活保存不同大小的字符串,从而有效节省内存空间 —— 节省内存空间 -
链表
(1)list概念:Redis 在 listNode(双向链表节点) 结构体基础上又封装了 list 这个数据结构。
(2)优点
获取某个节点的前置节点或后置节点的时间复杂度只需O(1),而且这两个指针都可以指向 NULL,所以链表是无环链表;
获取链表的表头节点和表尾节点的时间复杂度只需O(1);
获取链表中的节点数量的时间复杂度只需O(1);
链表节点可以保存各种不同类型的值;
(3)缺点
链表每个节点之间的内存都是不连续的,意味着无法很好利用 CPU 缓存。
保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大 -
压缩列表
(1)结构:
压缩列表就会根据数据类型是字符串还是整数,以及数据的大小,会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的。
这就会导致连锁更新。
(2)连锁更新
压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。 -
哈希表
(1)引出:Hash 对象的底层实现是压缩列表(最新Redis已将压缩列表替换成 listpack)和 哈希表。
(2)结构(redis哈希表 和 哈希表节点):
Redis 的哈希表结构如下:
哈希表节点的结构如下:
综上,哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。
(3)哈希冲突,rehash,渐进式rehash
哈希冲突:当有两个以上数量的 kay 被分配到了哈希表中同一个哈希桶上时,此时称这些 key 发生了冲突。
rehash:
在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。rehash操作如下:
第一步、给「哈希表 2」 分配空间,一般会比「哈希表 1」 大 2 倍;
第二步、将「哈希表 1 」的数据迁移到「哈希表 2」 中;
第三步、迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。
问题:可能会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。
渐进式rehash: 也就是 将数据的迁移的工作不再是一次性迁移完成,而是 分多次迁移。 -
整数集合
(1)结构:
contents 数组的真正类型取决于 intset 结构体里的 encoding 属性的值。所以,不同类型的 contents 数组,意味着数组的大小也会不同。这就会出现 整数集合的升级操作。
(2)整数集合的升级操作
过程如下: -
跳表
(1)举例:
以上图中所有层的各个节点都为跳表的节点。
(2)跳表查询过程简介:是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是 O(logN)。
(3)跳表查询过程:
根据以下判断条件在层中或层间 跳来跳去
判断条件一、如果当前节点的权重「小于」要查找的权重时,跳表就会访问该层上的下一个节点。
判读条件二、如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。
如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找
(4)跳表节点层数设置
具体做法为:跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。
(5)为什么用跳表而不用平衡树
-
quicklist
(1)举例:
向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。
(2)问题:quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题。 -
listpack(Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,避免连锁更新)
(1)结构( listpack 结构 和 其节点结构):
这样的话,listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。
三、Redis线程模型
1. Redis 是单线程吗?
Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的。
但是,Redis 程序并不是单线程的,Redis 在启动的时候,是**会启动后台线程(BIO)**的:
- Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;
- Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大key。
之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。
2. Redis 单线程模式是怎样的?
待更
3. Redis 采用单线程为什么还这么快?
- Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;
- Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
- Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
4. Redis 6.0 之前为什么使用单线程?
- Redis采用单线程避免多线程直接的上下文切换和进程调度等,所以CPU 并不是制约 Redis 性能表现的瓶颈所在,更多情况下是受到内存大小和网络I/O的限制
- 可维护性高,多线程模型虽然在某些方面表现优异,但是需要考虑线程之间的同步和通信等。
- 避免竞争和锁 带来性能的消耗。
5. Redis 6.0 之后为什么引入了多线程?
在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。
所以,为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理,所以大家不要误解 Redis 有多线程同时执行命令。
四、Redis持久化
Redis持久化篇
五、Redis集群
Redis集群
六、Redis过期删除和内存淘汰
过期删除策略
- 过期字典(实际上是哈希表):每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。
当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:
如果不在,则正常读取键值;
如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。 - 过期删除策略有哪些?
(1)定时删除;
在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。
(2)惰性删除;
不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。
(3)定期删除;
每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。
最终,Redis 选择「惰性删除+定期删除」这两种策略配和使用
内存淘汰策略
- 不进行数据淘汰的策略
noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据写入,则会触发 OOM,但是如果没用数据写入的话,只是单纯的查询或者删除操作的话,还是可以正常工作。 - 进行数据淘汰的策略(对设置过期时间的数据淘汰 和 所有数据范围内淘汰)
(1)在设置了过期时间的数据中进行淘汰:
volatile-random:随机淘汰设置了过期时间的任意键值;
volatile-ttl:优先淘汰更早过期的键值。
volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;
(2)在所有数据范围内进行淘汰:
allkeys-random:随机淘汰任意键值;
allkeys-lru:淘汰整个键值中最久未使用的键值;
allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。 - 如何查看和修改Redis 内存淘汰策略 ?
查看:config get maxmemory-policy
修改:(1)config set maxmemory-policy <策略> (2)通过修改 Redis 配置文件修改,设置maxmemory-policy <策略> - LRU算法 和 LFU算法
(1)LRU算法:LRU 全称是 Least Recently Used 翻译为最近最少使用,会选择淘汰最近最少使用的数据。传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可。
缺点如下:
缺点一、需要用链表管理所有的缓存数据,这会带来额外的空间开销;
缺点二、当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。
(2)Redis是如何实现LRU算法的?
Redis 实现的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。
当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个。
(3)LFU算法:LFU 全称是 Least Frequently Used 翻译为最近最不常用,LFU 算法是根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。所以, LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。
(4)Redis 是如何实现 LFU 算法的?
LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。
Redis 对象头中的 lru 字段,在 LRU 算法下和 LFU 算法下使用方式并不相同。
在 LRU 算法中,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。
在 LFU 算法中,Redis对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),低 8bit 存储 logc(Logistic Counter)。
两个变量代表的含义:
ldt 是用来记录 key 的访问时间戳;
logc 是用来记录 key 的访问频次,注意,logc 是访问频次(访问频率)
七、Redis缓存设计
Redis缓存篇
八、Redis实战
1. Redis 如何实现延迟队列?
在 Redis 可以使用有序集合(ZSet)的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间。
在使用 zadd score1 value1 命令就可以一直往内存中生产消息之后。再利用 zrangebyscore 查询就能得到待处理的任务了,通过循环执行队列任务即可。
2. Redis 的大 key 如何处理?
- 如何找到大 key ?
(1)redis-cli --bigkeys 查找大key
(2)使用 SCAN 命令查找大 key
(3)使用 RdbTools 工具查找大 key - 如何删除大 key?
(1)分批次删除
删除大 Hash,使用 hscan 命令;删除大 List,通过 ltrim 命令;删除大 Set,使用 sscan 命令;删除大 ZSet,使用 zremrangebyrank
(2)异步删除(Redis 4.0版本以上)
从 Redis 4.0 版本开始,可以采用异步删除法,用 unlink 命令代替 del 来删除。
除了主动调用 unlink 命令实现异步删除之外,我们还可以通过配置参数,达到某些条件的时候自动进行异步删除。
主要有 4 种场景,默认都是关闭的:
lazyfree-lazy-eviction no —— 内存超过 maxmeory
lazyfree-lazy-expire no —— 设置了过期时间的键值,当过期之后是否开启 lazy free 机制删除
lazyfree-lazy-server-del —— 隐式的 del,是否开启 lazy free 机制删除(例如 rename)
noslave-lazy-flush no —— lave 在加载 master 的 RDB 文件前,会运行 flushall 来清理自己的数据(是否开启 lazy free 机制删除)
3. Redis 管道有什么用?
- 主要作用:使用管道技术可以解决多个命令执行时的网络等待。(所以管道技术本质上是客户端提供的功能,而非 Redis 服务器端的功能。)
4. Redis 事务支持回滚吗?
- 一个事务在开启过程之后,向命令队列中加入命令时候,若此时执行DISCARD则清空命令队列中的任务。并不会执行事务任何命令。
- 若一个事务在开启之后,向命令队列中加入命令,当事务执行之后(EXEC命令),若事务中有一句命令是错误的,则该条命令之前都会执行成功,它之后的不会执行。也就是没有回滚机制。
5. 如何用 Redis 实现分布式锁的?
- 概念:在分布式环境下完成并发控制。用于控制某个资源在同一时刻只能被一个应用使用。
- 实现加锁操作(需要满足三个条件):
(1)使用SET命令时候带上NX选项。加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,需要以原子操作的方式完成。
(2)SET 命令执行时加上 EX/PX 选项,设置其过期时间。锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放。
(3) SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值(用于标识客户端)。锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作。
满足上面三个条件之后,SET命令是这个样子:
- 实现解锁操作(Lua脚本):解锁的时候,先判断锁的 unique_value 是否为加锁的客户端,是的话,将 lock_key 键删除。
因为解锁操作需要两步。Redis 在执行 Lua 脚本时,可以以原子性的方式执行,所以可以通过Lua脚本来执行解锁的两步,来保证锁释放操作的原子性。 - 优点:
(1)性能高效
(2)实现方便(setnx命令)
(3)避免单点故障 - 缺点:
(1)超时时间不好设置。如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短会保护不到共享资源。
怎么改善? 基于续约的方式设置超时时间 —— 写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可
(2)Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。
怎么改善? Redlock(红锁)。核心思想为:是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。