文章目录
- 1 Redis 基础
- 1.1 说一下 Redis 和 Memcached 的区别和共同点
- 1.2 为什么要用 Redis?
- 1.3 Redis 常用的数据类型有哪些?🔥
- 1.4 String 还是 Hash 存储对象数据更好呢?
- 1.5 Redis 基本数据类型底层实现原理🔥
- 1.6 压缩列表是怎么实现的?
- 1.7 跳表是怎么实现的?🔥
- 1.8 介绍一下 Redis 中的 listpack
- 1.9 哈希表是怎么扩容的?
- 1.10 购物车信息用 String 还是 Hash 存储更好呢?
- 1.11 Redis 的有序集合底层为什么要用跳表?🔥
- 1.12 Redis 如何实现数据不丢失(持久化)🔥
- 1.13 AOF 为什么是在执行完命令之后再记录日志?
- 1.14 AOF 重写
- 2 Redis 线程模型
- 3 Redis 内存管理
- 4 Redis 性能优化
- 5 Redis 缓存问题
- 6 Redis 集群
- 7 Redis 应用
本文作者:夏日。主要参考:JavaGuide、小林coding和二哥的Java进阶之路,同时加上网上搜索整理和个人理解总结。
1 Redis 基础
1.1 说一下 Redis 和 Memcached 的区别和共同点
共同点:
- 都是基于内存的数据库,一般都用来当做缓存使用。
- 都有过期策略。
- 两者的性能都非常高。
区别:
- 数据类型:Redis 支持更丰富的数据类型。Memcached 只支持最简单的 k/v 数据类型。Redis 还提供 list,set,zset,hash 等数据结构。
- 数据持久化:Redis 支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用了;Memcached 数据只存在内存之中,重启后就没了。
- 集群模式支持:Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 自 3.0 版本起是原生支持集群模式的。
- 线程模型:Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。 (Redis 6.0 针对网络数据的读写引入了多线程)
- 特性支持:Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。
- 过期数据删除:Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。
1.2 为什么要用 Redis?
**1、Redis 具备高性能
传统数据库数据保存在磁盘,而 Redis 基于内存,内存的访问速度比磁盘快很多。引入 Redis 之后,可以把一些高频访问的数据放到 Redis 中,这样下次就可以直接从内存中读取,速度可以提升几十倍甚至上百倍。
2、Redis 具备高并发
单台设备的 Redis 的 QPS(Query Per Second,每秒钟处理完请求的次数) 是 MySQL 的 10 倍。所以可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库,提高了系统整体的并发。
3、功能全面
Redis 除了可以用作缓存之外,还可以用于分布式锁、限流、消息队列、延时队列等场景,功能强大!
1.3 Redis 常用的数据类型有哪些?🔥
基本数据类型:
- String:字符串
- 值可以是字符串、数字或者二进制,但值最大不能超过512MB。
- 应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。
- Hash:哈希
- Hash 是一个键值对集合。
- 应用场景:缓存对象、购物车等。
- Set:集和
- 无序去重的集合。
- 应用场景:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
- List:列表
- 有序可重复的集合,底层是依赖双向链表实现的。
- 应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
- SortedSet(Zset):有序集和
- 增加了一个权重参数
score
,使得集合中的元素能够按score
进行有序排列 - 应用场景:适用于排行榜和带权重的消息队列等场景。
- 增加了一个权重参数
特殊的数据类型:
- Bitmap:位图,可以认为是一个以位为单位数组,数组中的每个单元只能存0或者1,数组的下标在 Bitmap 中叫做偏移量。Bitmap 的长度与集合中元素个数无关,而是与基数的上限有关。
- 适用场景:二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等。
- Hyperloglog。HyperLogLog 是用来做海量数据基数统计的算法,其优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。
- 应用场景:比如百万级网页 UV 计数,如统计独立访客。
- Geospatial :主要用于存储地理位置信息,并对存储的信息进行操作
- 应用场景:适用场景如定位、附近的人等。
- Stream :Redis Stream 是 Redis 5.0 版本新增的专门为消息队列设计的数据类型,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。
1.4 String 还是 Hash 存储对象数据更好呢?
- String 存储的是序列化后的对象数据,存放的是整个对象。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省网络流量。如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就非常适合。
- String 存储相对来说更加节省内存,缓存相同数量的对象数据,String 消耗的内存约是 Hash 的一半。并且,存储具有多层嵌套的对象时也方便很多。如果系统对性能和资源消耗非常敏感的话,String 就非常适合。
在绝大部分情况,我们建议使用 String 来存储对象数据即可!
1.5 Redis 基本数据类型底层实现原理🔥
1.5.1 String 的底层实现
Redis 是基于 C 语言编写的,但 Redis 的 String 类型的底层实现并不是 C 语言中的字符串(即以空字符 \0
结尾的字符数组),而是自己编写了 SDS(Simple Dynamic String,简单动态字符串) 来作为底层实现。
SDS 相比于 C 语言中的字符串有如下提升:
- 可以避免缓冲区溢出:C 语言中的字符串被修改(比如拼接)时,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。SDS 被修改时,会先根据 len 属性检查空间大小是否满足要求,如果不满足,则先扩展至所需大小再进行修改操作。
- 获取字符串长度的复杂度较低:C 语言中的字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。SDS 的长度获取直接读取 len 属性即可,时间复杂度为 O(1)。
- 可以保存二进制数据:C 语言中的字符串以空字符
\0
作为字符串结束的标识,这存在一些问题,像一些二进制文件(比如图片、视频、音频)就可能包括空字符,C 字符串无法正确保存。SDS 使用 len 属性判断字符串是否结束,不存在这个问题。 - 减少内存分配次数:为了避免修改(增加/减少)字符串时,每次都需要重新分配内存(C 语言的字符串是这样的),SDS 实现了空间预分配和惰性空间释放两种优化策略。当 SDS 需要增加字符串时,Redis 会为 SDS 分配好内存,并且根据特定的算法分配多余的内存,这样可以减少连续执行字符串增长操作所需的内存重分配次数。当 SDS 需要减少字符串时,这部分内存不会立即被回收,会被记录下来,等待后续使用(支持手动释放,有对应的 API)。
1.5.2 List 的底层实现
List 类型的底层数据结构是由双向链表或压缩列表实现的:
- 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值),Redis 会使用压缩列表作为 List 类型的底层数据结构;
- 如果不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;
在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了。
1.5.3 Hash 的底层实现
Hash 类型的底层数据结构是由压缩列表或哈希表实现的:
- 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;
- 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。
在 Redis 7.0 中,压缩列表数据结构已经废弃,交由 listpack 数据结构来实现。
1.5.4 Set 的底层实现
Set 类型的底层数据结构是由哈希表或整数集合实现的:
- 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
- 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。
1.5.5 Zset 的底层实现
Zset 类型的底层数据结构是由压缩列表或跳表实现的:
- 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
- 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现。
1.6 压缩列表是怎么实现的?
压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组。当我们往压缩列表中插入数据时,会根据数据大小和类型进行不同的空间大小分配,以此来节省内存。如果我们要查找第一个元素和最后一个元素,可以通过表头直接定位,复杂度是 O(1)。而查找其他元素就只能逐个查找,此时的复杂度是 O(N) ,因此压缩列表不适合保存过多的元素。
1.7 跳表是怎么实现的?🔥
跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表。实现这一特性就是靠跳表节点中的zskiplistLevel 结构体类型的 level 数组。level 数组中的每一个元素代表跳表的一层。zskiplistLevel 结构体里定义了「指向下一个跳表节点的指针」和「跨度」,跨度时用来记录两个节点之间的距离。
跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。
1.8 介绍一下 Redis 中的 listpack
listpack 中每个节点不再像压缩列表一样包含前一个节点的长度,但还是用一块连续的内存空间来紧凑地保存数据,并且为了节省内存的开销,listpack 节点会采用不同的编码方式保存不同大小的数据。当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。
1.9 哈希表是怎么扩容的?
为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了渐进式 rehash,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移。
- 给「哈希表 2」 分配空间;
- 在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上;
- 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。
1.10 购物车信息用 String 还是 Hash 存储更好呢?
由于购物车中的商品频繁修改和变动,购物车信息建议使用 Hash 存储:
- 用户 id 为 key
- 商品 id 为 field,商品数量为 value
那用户购物车信息的维护具体应该怎么操作呢?
- 用户添加商品就是往 Hash 里面增加新的 field 与 value;
- 查询购物车信息就是遍历对应的 Hash;
- 更改商品数量直接修改对应的 value 值(直接 set 或者做运算皆可);
- 删除商品就是删除 Hash 中对应的 field;
- 清空购物车直接删除对应的 key 即可。
1.11 Redis 的有序集合底层为什么要用跳表?🔥
-
平衡树 vs 跳表:平衡树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n)。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。跳表诞生的初衷就是为了克服平衡树的一些缺点。跳表使用概率平衡而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的算法简单得多,速度也快得多。
-
红黑树 vs 跳表:相比较于红黑树来说,跳表的实现也更简单一些,不需要通过旋转和染色(红黑变换)来保证黑平衡。并且,按照区间来查找数据这个操作,红黑树的效率没有跳表高。
-
B+树 vs 跳表:B+树更适合作为数据库中的索引结构,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+树这种方式进行维护。而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+树那样插入时发现失衡时还需要对节点分裂与合并。
1.12 Redis 如何实现数据不丢失(持久化)🔥
持久化就是把内存的数据写到磁盘中,防止服务宕机导致内存数据丢失。
Redis支持三种方式的持久化: RDB
快照、 AOF
日志,Redis 4.0 新增了RDB 和 AOF 的混合持久化。
1.RDB 是一个快照文件,它会根据指定的规则定时将 redis 内存中的数据以二进制的方式写入磁盘。
- 优点:
- Redis 加载 RDB 恢复数据远远快于 AOF 的方式。
- 使用单独子进程来进行持久化,主进程不会进行任何 IO 操作,保证了 Redis 的高性能。
- RDB文件存储的是压缩的二进制文件,适用于备份、全量复制,可用于灾难备份
- 缺点:
- RDB是间隔一段时间进行持久化,频率太低会丢失大量数据,频率太高就会很影响性能。
- RDB方式数据无法做到实时持久化。因为
BGSAVE
每次运行都要执行fork
操作创建子进程,属于重量级操作。 - RDB 文件使用特定二进制格式保存,Redis 版本升级过程中有多个格式的 RDB 版本,存在老版本 Redis 无法兼容新版 RDB 格式的问题。
2.AOF 的含义是追加文件,当 redis 执行完命令的时候,都会把命令记录到日志文件中。AOF的主要作用是解决了数据持久化的实时性。
- 优点:
- AOF可以更好的保护数据不丢失,可以配置 AOF 每秒执行一次同步操作,如果Redis进程挂掉,最多丢失1秒的数据。
- AOF以
append-only
的模式写入,所以没有磁盘寻址的开销,写入性能非常高。
- 缺点:对于同一份数据,AOF文件比RDB快照要大,数据恢复比较慢。
3.RDB 和 AOF 的混合持久化:AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头,之后的数据再以AOF的格式追加到文件的末尾。
- 好处是可以结合 RDB 和 AOF 的优点,快速加载同时避免丢失过多的数据。
- 缺点是AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。并且兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。
当RDB与AOF两种方式都开启时,Redis会优先使用AOF恢复数据,因为AOF保存的文件比RDB文件更完整。
1.13 AOF 为什么是在执行完命令之后再记录日志?
- 避免额外的检查开销,AOF 记录日志不会对命令进行语法检查;
- 在命令执行完之后再记录,不会阻塞当前命令的执行。
这样也带来了风险
- 如果刚执行完命令 Redis 就宕机会导致对应的修改丢失;
- 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)。
1.14 AOF 重写
当 AOF 变得太大时,Redis 开启后台子进程 bgrewriteaof
读取所有键值对,记录最新的命令记录到一个新的 AOF 文件,相比之前的文件体积更小。
AOF 文件重写期间,Redis 还会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。
2 Redis 线程模型
对于读写命令来说,Redis 一直是单线程模型。不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作, Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)。
2.1 Redis 单线程模型了解吗?
Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型 。Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。
既然是单线程,那怎么监听大量的客户端连接呢?
Redis 通过 IO 多路复用程序 来监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。
这样的好处非常明显:I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和 NIO 中的 Selector
组件很像)。
文件事件处理器(file event handler)主要是包含 4 个部分:
- 多个 socket(客户端连接)
- IO 多路复用程序(支持多个客户端连接的关键)
- 文件事件分发器(将 socket 关联到相应的事件处理器)
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
2.2 Redis 是单线程为什么还这么快?🔥
- Redis 基于内存,内存的访问速度比磁盘快很多;
- Redis 内置了多种优化后的数据结构,如
String
使用的是SDS
。 - 采用单线程,省去了多线程切换带来的时间和性能上的开销,并且没有死锁问题。
- Redis 采用了 I/O 多路复用模型,使得一个线程可以处理多个客户端的 Socket 请求。
那既然都这么快了,为什么不直接用 Redis 当主数据库呢?
主要是因为内存成本太高且 Redis 提供的数据持久化仍然有数据丢失的风险。
2.3 解释下 I/O 多路复用模型🔥
I/O 多路复用是指利用单个线程来同时监听多个 Socket ,并在某个 Socket 可读、可写时得到通知,从而避免无效的等待,充分利用 CPU 资源。目前的 I/O 多路复用都是采用的 epoll 模式实现,它会在通知用户进程 Socket 就绪的同时,把已就绪的 Socket 写入用户空间,不需要挨个遍历 Socket 来判断是否就绪,提升了性能。
其中 Redis 的网络模型就是使用 I/O 多路复用结合事件的处理器来应对多个 Socket 请求,比如,提供了连接应答处理器、命令回复处理器,命令请求处理器;
2.4 Redis 6.0 之前为什么不使用多线程?
- Redis 是基于内存的,性能瓶颈不在 CPU ,主要是内存大小和网络带宽的限制;
- 单线程编程更容易并且更容易维护;
- 多线程增加了系统的复杂度,存在死锁、线程上下文切换等问题。
2.5 Redis6.0 之后为何引入了多线程?🔥
Redis6.0 引入多线程主要是为了提高网络 I/O 读写性能,因为随着网络硬件(内存)的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上
Redis6.0 引入的多线程只是在网络数据的读写这类耗时操作上使用,执行命令仍然是单线程顺序执行。
Redis6.0 的多线程默认是禁用的,只使用主线程。需要修改 redis 配置文件 redis.conf
。设置 IO 线程数 > 1。开启多线程后,默认只会使用多线程发送数据给客户端,如果需要开启多线程处理客户端读请求,同样需要修改 redis 配置文件 redis.conf
中 io-threads-do-reads yes
。
2.6 Redis 后台线程了解吗?
我们虽然经常说 Redis 是单线程模型,但实际还有一些后台线程用于执行一些比较耗时的操作:
- 关闭文件:通过
bio_close_file
后台线程来释放 AOF / RDB 等过程中产生的临时文件资源。2.6版本。 - AOF 刷盘:通过
bio_aof_fsync
后台线程调用fsync
函数将系统内核缓冲区还未同步到到磁盘的数据强制刷到磁盘。2.6版本。 - 释放内存:通过
bio_lazy_free
后台线程释放大对象(已删除)占用的内存空间。4.0版本。
3 Redis 内存管理
3.1 Redis 给缓存数据设置过期时间有啥用?
一般情况下,我们设置保存的缓存数据的时候都会设置一个过期时间。为什么呢?
因为内存是有限的,如果缓存中的所有数据都是一直保存的话,分分钟直接 Out of memory。
Redis 自带了给缓存数据设置过期时间的功能,比如:
expire key 60 # 数据在 60s 后过期
setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)
ttl key # 查看数据还有多久过期
注意:Redis 中除了字符串类型有自己独有设置过期时间的命令 setex
外,其他方法都需要依靠 expire
命令来设置过期时间 。另外, persist
命令可以移除一个键的过期时间。
过期时间除了有助于缓解内存的消耗,还有什么其他用么?
很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在 1 分钟内有效,用户登录的 Token 可能只在 1 天内有效。
如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多。
3.2 Redis 是如何判断数据是否过期的呢?
Redis 通过过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。
当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:
- 如果不在,则正常读取键值;
- 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。
3.3 过期的数据的删除策略了解么?🔥
常用的过期数据的删除策略就两个:
- 惰性删除:只会在取出 key 的时候才会检查是否过期,若过期则删除。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
- 定期删除:每隔一段时间随机抽取一批 key 判断是否过期,若过期则删除。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 的影响。但是难以确定删除操作执行的频率。如果执行的太频繁(类似立即删除),就会对 CPU 不友好;如果执行的太少,过期 key 占用的内存依然不会及时得到释放。
定期删除对内存更加友好,惰性删除对 CPU 更加友好,所以 Redis 采用的是 定期删除+惰性/懒汉式删除 。
但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就 Out of memory 了。
怎么解决这个问题呢?答案就是:Redis 内存淘汰机制。
3.4 Redis 内存淘汰机制了解么?🔥
相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?
答:使用 allkeys-lru 淘汰策略
LRU 的意思就是最少最近使用,用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。LFU 的意思是最少频率使用。会统计每个 key 的访问频率,值越小淘汰优先级越高
- volatile-lru(least recently used):从已设置过期时间的数据集中挑选最近最少使用的数据淘汰。
- volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰。
- volatile-random:从已设置过期时间的数据集中任意选择数据淘汰。
- allkeys-lru(least recently used):淘汰数据集中最近最少使用的 key(这个是最常用的)。
- allkeys-random:从数据集中任意选择数据淘汰。
- no-eviction:不会淘汰数据数据,也就是说当内存不足以写入新数据时,写入操作会报错(默认淘汰策略)。
4.0 版本后增加以下两种:
- volatile-lfu(least frequently used):从已设置过期时间的数据集中挑选最不经常使用的数据淘汰。
- allkeys-lfu(least frequently used):淘汰数据集中最不经常使用的 key。
3.5 Redis 持久化时,对过期键如何处理的?
- RDB 文件生成阶段:会进行过期检查,过期的键「不会」被保存到新的 RDB 文件中
- RDB 加载阶段:要看是否是
- 主服务器:会对文件中保存的键进行检查,过期键「不会」被载入到数据库中
- 从服务器:不论键是否过期「都会」被载入到数据库中(主从同步时会被删除)
- AOF 文件写入阶段:如果过期还没删除,会先保留,当过期键被删除后,追加 DEL 指令删除此键
- AOF 重写阶段:会进行过期检查,过期的键「不会」被保存到重写后的 AOF 文件中
3.6 Redis 主从模式中,对过期键如何处理?
从库不会进行过期扫描,从库对过期的处理是被动的。也就是即使从库中的 key 过期了,如果有客户端访问从库时,依然可以得到 key 对应的值,像未过期的键值对一样返回。
从库的过期键处理依靠主服务器控制,主库在 key 到期时,会在 AOF 文件里增加一条 DEL 指令,同步到所有的从库,从库通过执行这条 DEL 指令来删除过期的 key。
4 Redis 性能优化
4.1 使用批量操作减少网络传输
4.1.1 原生批量操作命令
Redis 中有一些原生支持批量操作的命令,比如:
MGET
(获取一个或多个指定 key 的值)、MSET
(设置一个或多个指定 key 的值)、HMGET
(获取指定哈希表中一个或者多个指定字段的值)、HMSET
(同时将一个或多个 field-value 对设置到指定哈希表中)、SADD
(向指定集合添加一个或多个元素)- ……
不过,在 Redis 官方提供的分片集群解决方案 Redis Cluster 下,使用这些原生批量操作命令可能会存在一些小问题需要解决。就比如说 MGET
无法保证所有的 key 都在同一个 hash slot(哈希槽)上,MGET
可能还是需要多次网络传输,原子操作也无法保证了。不过,相较于非批量操作,还是可以节省不少网络传输次数。
如果想要解决这个多次网络传输的问题,比较常用的办法是自己维护 key 与 slot 的关系。不过这样不太灵活,虽然带来了性能提升,但同样让系统复杂性提升。
Redis Cluster 并没有使用一致性哈希,采用的是 哈希槽分区 ,每一个键值对都属于一个 hash slot(哈希槽) 。当客户端发送命令请求的时候,需要先根据 key 通过上面的计算公示找到的对应的哈希槽,然后再查询哈希槽和节点的映射关系,即可找到目标 Redis 节点。
4.1.2 pipeline
对于不支持批量操作的命令,我们可以利用 pipeline(流水线) 将一批 Redis 命令封装成一组,这些 Redis 命令会被一次性提交到 Redis 服务器,只需要一次网络传输。不过,需要注意控制一次批量操作的 元素个数(例如 500 以内,实际也和元素字节数有关),避免网络传输的数据量过大。
与 MGET
、MSET
等原生批量操作命令一样,pipeline 同样在 Redis Cluster 上使用会存在一些小问题。原因类似,无法保证所有的 key 都在同一个 hash slot(哈希槽)上。如果想要使用的话,客户端需要自己维护 key 与 slot 的关系。
原生批量操作命令和 pipeline 的是有区别的,使用的时候需要注意:
- 原生批量操作命令是原子操作,pipeline 是非原子操作。
- pipeline 可以打包不同的命令,原生批量操作命令不可以。
- 原生批量操作命令是 Redis 服务端支持实现的,而 pipeline 需要服务端和客户端的共同实现。
另外,pipeline 不适用于执行顺序有依赖关系的一批命令。就比如说,你需要将前一个命令的结果给后续的命令使用,pipeline 就没办法满足你的需求了。对于这种需求,我们可以使用 Lua 脚本 。
4.1.3 Lua 脚本
Lua 脚本同样支持批量操作多条命令。一段 Lua 脚本可以视作一条命令执行,可以看作是 原子操作 。也就是说,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰,这是 pipeline 所不具备的。
并且,Lua 脚本中支持一些简单的逻辑处理比如使用命令读取值并在 Lua 脚本中进行处理,这同样是 pipeline 所不具备的。
不过, Lua 脚本依然存在下面这些缺陷:
- 如果 Lua 脚本运行时出错并中途结束,之后的操作不会进行,但是之前已经发生的写操作不会撤销,所以即使使用了 Lua 脚本,也不能实现类似数据库回滚的原子性。
- Redis Cluster 下 Lua 脚本的原子操作也无法保证了,原因同样是无法保证所有的 key 都在同一个 hash slot(哈希槽)上。
4.2 大量 key 集中过期问题
定期删除执行过程中,如果突然遇到大量过期 key 的话,客户端请求必须等待定期清理过期 key 任务线程执行完成,因为这个这个定期任务线程是在 Redis 主线程中执行的。这就导致客户端请求没办法被及时处理,响应速度会比较慢。
如何解决呢? 下面是两种常见的方法:
- 给 key 设置随机过期时间。
- 开启 lazy-free(惰性删除/延迟释放) 。lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。
4.3 Redis bigkey(大 Key)
4.3.1 什么是 bigkey?
如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:
- String 类型的 value 超过 1MB
- 复合类型(List、Hash、Set、Sorted Set 等)的 value 包含的元素超过 5000 个(不过,对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。
4.3.2 bigkey 是怎么产生的?有什么危害?
- 程序设计不当,比如直接使用 String 类型存储较大的文件对应的二进制数据。
- 对于业务的数据规模考虑不周到,比如使用集合类型的时候没有考虑到数据量的快速增长。
- 未及时清理垃圾数据,比如哈希中冗余了大量的无用键值对。
bigkey 除了会消耗更多的内存空间和带宽,还会对性能造成比较大的影响。
- 客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
- 网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
- 工作线程阻塞:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
- 内存分布不均:集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。
4.3.3 如何发现 bigkey?
1、使用 Redis 自带的 --bigkeys
参数来查找。
- 会扫描Redis 中的所有 key ,会对 Redis 的性能有一点影响
- 只能返回每种类型中最大的那个 bigkey,无法得到大小排在前 N 位的 bigkey
- 对于集合类型来说,这个方法只统计集合元素个数的多少,而不是实际占用的内存量。然而一个 key 的元素多并不代表占用内存也多。
在线上执行该命令时,为了降低对 Redis 的影响,需要指定 -i
参数控制扫描的频率。redis-cli -p 6379 --bigkeys -i 3
表示扫描过程中每次扫描后休息的时间间隔为 3 秒。
2、使用 Redis 自带的 SCAN 命令
使用 SCAN
命令对数据库扫描,然后用 TYPE
命令获取返回的每一个 key 的类型。获取了 key 之后,可以利用 STRLEN
、HLEN
、LLEN
等命令返回其长度或成员数量。
对于集合类型还可以使用 MEMORY USAGE
命令(Redis 4.0+),这个命令会返回键值对占用的内存空间。
3、借助开源工具分析 RDB 文件。
通过分析 RDB 文件来找出 big key。这种方案的前提是你的 Redis 采用的是 RDB 持久化。网上有现成的代码/工具可以直接拿来使用:
- redis-rdb-tools:Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具
- rdb_bigkeys : Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。
4、借助公有云的 Redis 分析服务。
如果你用的是公有云的 Redis 服务的话,可以看看其是否提供了 key 分析功能(一般都提供了)。
这里以阿里云 Redis 为例说明,它支持 bigkey 实时分析、发现。
4.3.4 如何处理 bigkey?🔥
bigkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):
- 分割 bigkey:将一个 bigkey 分割为多个小 key。例如,将一个含有上万字段数量的 Hash 按照一定策略(比如二次哈希)拆分为多个 Hash。
- 手动清理:Redis 4.0+ 可以使用
UNLINK
命令来异步删除一个或多个指定的 key。Redis 4.0 以下可以考虑使用SCAN
命令结合DEL
命令来分批次删除。 - 采用合适的数据结构:例如,文件二进制数据不使用 String 保存、使用 HyperLogLog 统计页面 UV、Bitmap 保存状态信息(0/1)。
- 开启 lazy-free(惰性删除/延迟释放) :lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。
4.4 Redis hotkey(热 Key)
4.4.1 什么是 hotkey?
如果一个 key 的访问次数比较多且明显多于其他 key 的话,那这个 key 就可以看作是 hotkey(热 Key)。例如在 Redis 实例的每秒处理请求达到 5000 次,而其中某个 key 的每秒访问量就高达 2000 次,那这个 key 就可以看作是 hotkey。
hotkey 出现的原因主要是某个热点数据访问量暴增,如重大的热搜事件、参与秒杀的商品。
4.4.2 hotkey 有什么危害?
处理 hotkey 会占用大量的 CPU 和带宽,可能会影响 Redis 实例对其他请求的正常处理。此外,如果突然访问 hotkey 的请求超出了 Redis 的处理能力,Redis 就会直接宕机。这种情况下,大量请求将落到后面的数据库上,可能会导致数据库崩溃。
因此,hotkey 很可能成为系统性能的瓶颈点,需要单独对其进行优化,以确保系统的高可用性和稳定性。
4.4.3 如何发现 hotkey?
1、使用 Redis 自带的 --hotkeys
参数来查找。
Redis 4.0.3 版本中新增了 hotkeys
参数,该参数能够返回所有 key 的被访问次数。
使用该方案的前提条件是 Redis Server 的 maxmemory-policy
参数设置为 LFU 算法,不然就会出现如下所示的错误。
需要注意的是,hotkeys
参数命令也会增加 Redis 实例的 CPU 和内存消耗(全局扫描),因此需要谨慎使用。
2、使用 MONITOR
命令。
MONITOR
命令是 Redis 提供的一种实时查看 Redis 的所有操作的方式,可以用于临时监控 Redis 实例的操作情况,包括读写、删除等操作。
由于该命令对 Redis 性能的影响比较大,因此禁止长时间开启 MONITOR
(生产环境中建议谨慎使用该命令)。
在发生紧急情况时,我们可以选择在合适的时机短暂执行 MONITOR
命令并将输出重定向至文件,在关闭 MONITOR
命令后通过对文件中请求进行归类分析即可找出这段时间中的 hotkey。
3、借助开源项目。
京东零售的 hotkey 这个项目不光支持 hotkey 的发现,还支持 hotkey 的处理。
4、根据业务情况提前预估。
可以根据业务情况来预估一些 hotkey,比如参与秒杀活动的商品数据等。不过,我们无法预估所有 hotkey 的出现,比如突发的热点新闻事件等。
5、业务代码中记录分析。
在业务代码中添加相应的逻辑对 key 的访问情况进行记录分析。不过,这种方式会让业务代码的复杂性增加,一般也不会采用。
6、借助公有云的 Redis 分析服务。
如果你用的是公有云的 Redis 服务的话,可以看看其是否提供了 key 分析功能(一般都提供了)。
这里以阿里云 Redis 为例说明,它支持 hotkey 实时分析、发现。
4.4.4 如何解决 hotkey?🔥
hotkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):
- 读写分离:主节点处理写请求,从节点处理读请求。
- 使用 Redis Cluster:将热点数据分散存储在多个 Redis 节点上。
- 二级缓存:hotkey 采用二级缓存的方式进行处理,将 hotkey 存放一份到 JVM 本地内存中(可以用 Caffeine)。
4.5 慢查询命令
4.5.1 为什么会有慢查询命令?
慢查询命令也就是那些命令执行时间较长的命令。
Redis 中的大部分命令都是 O(1)时间复杂度,但也有少部分 O(n) 时间复杂度的命令,例如:
KEYS *
:会返回所有符合规则的 key。HGETALL
:会返回一个 Hash 中所有的键值对。LRANGE
:会返回 List 中指定范围内的元素。- ……
由于这些命令时间复杂度是 O(n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长。不过,这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 HSCAN
、SSCAN
、ZSCAN
代替。
4.5.2 如何找到慢查询命令?
在 redis.conf
文件中,我们可以使用 slowlog-log-slower-than
参数设置耗时命令的阈值,并使用 slowlog-max-len
参数设置耗时命令的最大记录条数。
当 Redis 服务器检测到执行时间超过 slowlog-log-slower-than
阈值的命令时,就会将该命令记录在慢查询日志(slow log) 中,这点和 MySQL 记录慢查询语句类似。当慢查询日志超过设定的最大记录条数之后,Redis 会把最早的执行命令依次舍弃。
⚠️注意:由于慢查询日志会占用一定内存空间,如果设置最大记录条数过大,可能会导致内存占用过高的问题。
5 Redis 缓存问题
5.1 缓存穿透🔥
缓存穿透是指大量请求的 key 是不合理的,既不存在于缓存中,也不存在于数据库中 。这就导致这些请求直接到了数据库上,根本没有经过缓存这一层,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。可能因为业务误操作或者黑客攻击。
1)限制非法请求:首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的格式不对的时候直接返回错误消息给客户端等等。
2)缓存无效 key:针对查询的数据,在缓存中设置一个空值或者默认值。这种方式可以解决请求的 key 变化不频繁的情况,如果黑客每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。这种方案并不能从根本上解决此问题。
3)布隆过滤器:布隆过滤器主要是用于检索一个元素是否在一个集合中,不存在的话,直接返回请求参数错误信息给客户端。
4)接口限流:根据用户或者 IP 对接口进行限流,对于异常频繁的访问行为,还可以采取黑名单机制,例如将异常 IP 列入黑名单。
后面提到的缓存击穿和雪崩都可以配合接口限流来解决,毕竟这些问题的关键都是有很多请求落到了数据库上造成数据库压力过大。
5.2 缓存击穿🔥
请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但在缓存中过期了 。这会导致瞬间大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。
- 永不过期(不推荐):设置热点数据永不过期或者过期时间比较长。
- 提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
- 加锁(看情况):在缓存失效后,通过设置互斥锁确保只有一个请求去查询数据库并更新缓存。
5.3 缓存雪崩🔥
缓存在同一时间大面积的失效,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力。 与缓存击穿的区别:雪崩是很多 key,击穿是某一个 key。另外,缓存服务宕机也会导致缓存雪崩现象,导致所有的请求都落到了数据库上。
针对 Redis 服务不可用的情况:
- Redis 集群:采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。Redis Cluster 和 Redis Sentinel 是两种最常用的 Redis 集群实现方案。
- 多级缓存:设置多级缓存,例如本地缓存+Redis 缓存的二级缓存组合,当 Redis 缓存出现问题时,还可以从本地缓存中获取到部分数据。
针对大量缓存同时失效的情况:
- 永不过期(看情况):虽然一般不推荐设置缓存永不过期,但对于某些关键性和变化不频繁的数据,可以考虑这种策略。
- 提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
- 设置随机失效时间(可选):为缓存设置随机的失效时间,例如在固定过期时间的基础上加上一个随机值,这样可以避免大量缓存同时到期,从而减少缓存雪崩的风险。
5.4 布隆过滤器的底层实现原理🔥
它的底层主要是先去初始化一个位数组,里面存放的二进制0或1。在一开始都是0,当一个 key 来了之后经过几次 hash 计算,找到数组的下标然后把数组中原来的0改为1,这样的话,多个数组的位置就能标明一个 key 的存在。查找的过程也是一样的。
由于哈希冲突的存在,布隆过滤器有可能会产生一定的误判,我们一般可以设置这个误判率,大概不会超过5%,这个误判是必然存在的,要不就得增加数组的长度。布隆过滤器判断数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据。
5.5 缓存预热
缓存预热,也就是在程序启动后或运行过程中,主动将热点数据加载到缓存中。
常见的缓存预热方式有两种:
- 使用定时任务,比如 xxl-job,来定时触发缓存预热的逻辑,将数据库中的热点数据查询出来并存入缓存中。
- 使用消息队列,比如 Kafka,来异步地进行缓存预热,将数据库中的热点数据的主键或者 ID 发送到消息队列中,然后由缓存服务消费消息队列中的数据,根据主键或者 ID 查询数据库并更新缓存。
5.6 如何保证缓存和数据库数据的一致性?🔥
可以用Cache Aside Pattern(旁路缓存模式) :先更新数据库,然后直接删除缓存。
如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说有两个解决方案:
- 缓存失效时间变短(不推荐,治标不治本):我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。
- 增加缓存更新重试机制(常用):如果缓存服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。不过,这里更适合引入消息队列实现异步重试,将删除缓存重试的消息投递到消息队列,然后由专门的消费者来重试,直到成功。
重试删除缓存会造成很多业务代码入侵,可以使用 Canal 优化,Canal 根据 binlog日志将数据变更发送到消息队列中,由此删除缓存,保证数据缓存一致性。
原因:缓存的写入通常要远远快于数据库的写入,实际很难出现数据不一致
拓展:旁路缓存模式适合读多写少,当写入频繁时,频繁删除缓存会影响缓存命中率。解决方法:更新数据时也更新缓存,不过更缓存前加分布式锁或者更缓存时设置一个较短的过期时间。
5.7 听说过延迟双删吗?为什么不用它呢?🔥
延迟双删,如果是写操作,我们先把缓存中的数据删除,然后更新数据库,最后再延时删除缓存中的数据,其中这个延时多久不太好确定,极端情况下,依然也会出现缓存不一致的现象。
6 Redis 集群
6.1 Redis 集群有哪些方案🔥
- 主从复制:一般都是一主多从,读写分离。主节点负责写数据,从节点负责读数据,主节点写入数据之后,需要把数据同步到从节点中。
- 哨兵模式(Sentinel):主从复制中服务器出现故障宕机时,需要手动进行恢复。哨兵模式可以监控主从服务器,并且提供主从节点故障转移的功能。
- Redis 切片集群(Redis Cluster):将数据分布在不同的服务器。一个集群有 16384 个哈希槽。集群中每个主节点绑定了一定范围的哈希槽(平均分配/手动分配),对
key
按照 CRC16 算法计算出值后,对 16384 取模来决定放到哪个哈希槽,找到对应的节点进行存储。
6.2 Redis 主从同步的流程🔥
主从同步分为了两个阶段,一个是全量同步,一个是增量同步
全量同步是指从节点第一次与主节点建立连接的时候使用全量同步。
第一:从节点请求主节点同步数据,从节点会携带自己的 replication id
和 offset 偏移量。
第二:主节点判断是否是第一次请求,主要判断的依据就是,主节点与从节点是否是同一个 replication id,如果不是,就说明是第一次同步,那主节点就会把自己的 replication id 和 offset 发送给从节点,让从节点与主节点的信息保持一致。
第三:同时主节点会执行 bgsave,生成 rdb 文件后,发送给从节点去执行,从节点先把自己的数据清空,然后执行主节点发送过来的 rdb 文件,这样就保持了一致
当然,如果在 rdb 生成执行期间,依然有请求到了主节点,而主节点会以命令的方式记录到缓冲区,缓冲区是一个日志文件,最后把这个日志文件发送给从节点,这样就能保证主节点与从节点完全一致了,后期再同步数据的时候,都是依赖于这个日志文件,这个就是全量同步
增量同步指的是,当从节点服务重启之后,数据就不一致了。这个时候,从节点会请求主节点同步数据,主节点还是判断是不是第一次请求,不是第一次请求就获取从节点的 offset 值,然后主节点从命令日志中获取 offset 值之后的数据,发送给从节点进行数据同步。
6.3 Redis 主从复制集群可以保证数据一致性吗 ?
Redis 主从复制集群在CAP理论都属于AP模型,即在面临网络分区时选择保证可用性和分区容忍性,而牺牲了强一致性。这意味着在网络分区的情况下,Redis 主从复制集群可以继续提供服务并保持可用,但可能会出现部分节点之间的数据不一致。
6.4 什么是 Sentinel? 有什么用?🔥
哨兵是一个运行在特殊模式下的 Redis 进程,所以它也是一个节点。
- 监控:监控所有 redis 节点(包括 sentinel 节点自身)的状态是否正常。
- 故障转移:当 master 出现故障时,Sentinel 会帮助我们实现故障转移,自动根据一定的规则选出一个 slave 升级为 master,确保整个 Redis 系统的可用性。整个过程完全自动,不需要人工介入。
- 通知 :通知 slave 新的 master 连接信息,让它们成为新的 master 的 slave。
- 配置提供 :如果发生故障转移,sentinel 会将新的 master 信息通知给客户端。
6.5 Sentinel 如何检测节点是否下线?主观下线与客观下线的区别?
- 主观下线(SDOWN) :Sentinel节点会定时对所有节点发心跳包检测节点是否正常。如果一个节点在规定时间内没有回复Sentinel节点的心跳包,则会被认定为主观下线。
- 客观下线(ODOWN) :当节点被一个Sentinel节点记为主观下线时,并不意味着该节点肯定故障了,还需要其他Sentinel节点共同判断为主观下线才行。该Sentinel节点会询问其他Sentinel节点,如果超过指定数量(通常过半)的Sentinel节点认为该redis节点主观下线,则该redis客观下线。
如果客观下线的是从节点或者是Sentinel节点,那么就没有后续的操作了;如果客观下线的redis节点为主节点,则开始故障转移,从从节点中选举一个节点升级为主节点。
6.6 Sentinel 是如何实现故障转移的?
如果需要从redis集群选举一个节点为主节点,首先需要从Sentinel集群中选举一个Sentinel节点作为Leader。如果一个Sentinel节点获得的选举票数达到Leader最低票数(quorum和Sentinel节点数/2+1的最大值),则该Sentinel节点选举为Leader;否则重新进行选举。
当选举出Sentinel Leader后,由Sentinel Leader从redis从节点中选择一个redis节点作为主节点:
- 首先过滤掉故障的节点
- 选择优先级slave 优先级最大的从节点作为主节点,如不存在则继续
- 选择复制偏移量(记录写了多少数据)最大的从节点作为主节点,如不存在则继续
- 若是复制偏移量还一样,就选择runid(redis每次启动的时候生成随机的runid作为redis的标识)最小的从节点作为主节点
6.7 怎么保证 Redis 的高并发高可用
首先可以搭建主从集群,再加上使用 redis 中的哨兵模式,哨兵模式可以实现主从集群的自动故障恢复,里面就包含了对主从服务的监控、自动故障恢复、通知;如果 master 故障,Sentinel 会将一个 slave 提升为 master。当故障实例恢复后也以新的 master 为主;同时 Sentinel 也充当 Redis 客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给 Redis 的客户端,所以一般项目都会采用哨兵的模式来保证 redis 的高并发高可用
6.8 Redis 集群脑裂,该怎么解决呢?🔥
由于主节点网络出现问题,但客户端不知道,仍然往主节点写入数据,但数据无法同步给从节点。哨兵发现主节点失联后,选举一个 salve 为 master,这样就存在了两个 master,就像大脑分裂了一样。
等网络恢复后,旧主节点会降级为从节点,再与新主节点进行同步的时候,由于从节点会清空自己的数据,所以导致之前客户端写入的数据丢失了。
解决方案:redis 的配置文件中有两个参数可以设置。第一可以设置最少的 salve 节点个数,如果小于这个数,主节点会禁止写入数据。第二可以设置主从节点通信的延迟时间,达不到要求主节点会禁止写入数据。
6.9 Redis 切片集群🔥
当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 Redis 切片集群(Redis Cluster )方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。
一个集群有 16384 个哈希槽。集群中每个主节点绑定了一定范围的哈希槽(平均分配/手动分配),对 key
按照 CRC16 算法计算出值后,对 16384 取模来决定放到哪个哈希槽,找到对应的节点进行存储。
哈希槽怎么被映射到具体的 Redis 节点上的呢?有两种方案:
- 平均分配: 在使用
cluster create
命令创建 Redis 集群时,Redis 会自动把所有哈希槽平均分布到集群节点上。比如集群中有 9 个节点,则每个节点上槽的个数为 16384/9 个。 - 手动分配: 可以使用
cluster meet
命令手动建立节点间的连接,组成集群,再使用 cluster addslots 命令,指定每个节点上的哈希槽个数。
6.10 为什么 Redis Cluster 的哈希槽是 16384 个?
CRC16 算法产生的校验码有 16 位,理论上可以产生 65536(2^16,0 ~ 65535)个值。为什么 Redis Cluster 的哈希槽偏偏选择的是 16384(2^14)个呢?
- 哈希槽太大会导致心跳包太大,消耗太多带宽;
- 哈希槽总数越少,对存储哈希槽信息的 bitmap 压缩效果越好;
- Redis Cluster 的主节点通常不会扩展太多,16384 个哈希槽已经足够用了。
7 Redis 应用
7.1 Redis 除了做缓存,还能做什么?🔥
- 分布式锁:通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。
- 限流:一般是通过 Redis + Lua 脚本的方式来实现限流。如果不想自己写 Lua 脚本的话,也可以直接利用 Redisson 中的
RRateLimiter
来实现分布式限流,其底层实现就是基于 Lua 代码+令牌桶算法。 - 消息队列:Redis 自带的 List 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。
- 延时队列:Redisson 内置了延时队列(基于 Sorted Set 实现的)。
- 分布式 Session :利用 String 或者 Hash 数据类型保存 Session 数据,所有的服务器都可以访问。
- 复杂业务场景:通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景比如通过 Bitmap 统计活跃用户、通过 Sorted Set 维护排行榜。
7.2 如何基于 Redis 实现分布式锁?🔥
在 redis 中提供了一个命令 setnx(SET if not exists)。由于 redis 的单线程的,用了命令之后,只能有一个客户端对某一个 key 设置值,在没有过期或删除 key 的时候是其他客户端是不能设置这个 key 的。
- 加锁需要以原子操作的方式完成,所以使用 SET 命令时带上 NX 选项来实现加锁;
- 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以在 SET 命令执行时加上 EX/PX 选项,设置过期时间;
- 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,误删其它锁,所以使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端;
- 解锁包括判断锁是否为当前客户端和删除两个操作,所以用 Lua 脚本保证解锁操作的原子性。
SET lock_key unique_value NX PX 10000
7.3 Redission 分布式锁的原理🔥
RLock lock = redisson.getLock("anyLock");
lock.lock();
// 处理业务逻辑
lock.unlock()
- 加锁机制:加锁其实是通过一段 lua 脚本实现的,判断要加锁的那个key 如果不存在,就进行加锁,加锁其实就是设置一个hash数据结构,默认的过期时间为30s。
- 锁互斥机制:另一个客户端尝试获取锁时,首先判断锁是否存在。若存在,判断锁的 hash 结构中是否包含自己的 id。若其中是别的客户端的 id,会返回锁的剩余时间。然后会通过 Redis 的 channel 订阅锁释放的事件,当超过最大等待时间则获取锁失败。如果等到了锁的释放事件的通知,则进入一个不断重试获取锁的循环。
- 锁的续期机制:在 redisson 中引入了一个看门狗机制,每隔10s就检查当前业务是否还持有锁,如果持有就延长锁的过期时间为30s。
- 可重入锁机制:如果锁的 hash 结构中包含自己的 id,那直接获取锁。hash 的 value 是加锁次数,加锁后 value+1。
- 释放锁机制:对锁的 hash 结构的
value-1
,如果发现加锁次数是0
了,说明客户端已经不再持有锁了,就删除这个key。
7.4 redisson 实现的分布式锁能解决主从一致性的问题吗🔥
类似问题:Redis 如何解决集群情况下分布式锁的可靠性?
这个是不能的,如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。
我们可以利用 redisson 提供的红锁来解决这个问题,红锁是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。加锁成功要同时满足两个条件:有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的过期时间,那么就是加锁成功。
但是,如果使用了红锁,因为需要同时在多个节点上都添加锁,性能就变的很低了,并且运维维护成本也非常高,所以,我们一般在项目中也不会直接使用红锁,并且官方也暂时废弃了这个红锁
7.5 业务非要保证数据的强一致性该怎么解决?🔥
redis 本身就是支持高可用的,做到强一致性,就非常影响性能,所以,如果有强一致性要求高的业务,建议使用 zookeeper 实现的分布式锁,它是可以保证强一致性的。
ZooKeeper 分布式锁是基于 临时顺序节点 和 Watcher(事件监听器) 实现的。创建临时顺序节点后,若当前节点是最小的子节点则加锁成功。若加锁失败会在前一个节点注册一个事件监听器,当前一个节点释放锁之后进行通知避免无效自旋。
- 临时: 客户端会话消失则节点消失,避免锁无法释放(在Redis 是过期时间)
- 顺序:只需监听前一个节点,避免所有尝试获取锁的客户端来争夺锁
7.6 Redis 可以做消息队列么?
实际项目中使用 Redis 来做消息队列的非常少,毕竟有更成熟的消息队列中间件可以用。
可以是可以,但不建议使用 Redis 来做消息队列。和专业的消息队列相比,还是有很多欠缺的地方。
Redis 2.0 之前,如果想要使用 Redis 来做消息队列的话,只能通过 List 来实现。
不过,通过 RPUSH/LPOP
或者 LPUSH/RPOP
这样的方式存在性能问题,我们需要不断轮询去调用 RPOP
或 LPOP
来消费消息。当 List 为空时,大部分的轮询的请求都是无效请求,这种方式大量浪费了系统资源。
因此,Redis 还提供了 BLPOP
、BRPOP
这种阻塞式读取的命令(带 B-Blocking 的都是阻塞式),并且还支持一个超时参数。如果 List 为空,Redis 服务端不会立刻返回结果,它会等待 List 中有新数据后再返回或者是等待最多一个超时时间后返回空。如果将超时时间设置为 0 时,即可无限等待,直到弹出消息
List 实现消息队列功能太简单,像消息确认机制等功能还需要我们自己实现,最要命的是没有广播机制,消息也只能被消费一次。
Redis 2.0 引入了发布订阅 (pub/sub) 功能,解决了 List 实现消息队列没有广播机制的问题。
pub/sub 中引入了一个概念叫 channel(频道),发布订阅机制的实现就是基于这个 channel 来做的。
- 发布者通过
PUBLISH
投递消息给指定 channel。 - 订阅者通过
SUBSCRIBE
订阅它关心的 channel。并且,订阅者可以订阅一个或者多个 channel。
pub/sub 既能单播又能广播,还支持 channel 的简单正则匹配。不过,消息丢失(客户端断开连接或者 Redis 宕机都会导致消息丢失)、消息堆积(发布者发布消息的时候不会管消费者的具体消费能力如何)等问题依然没有一个比较好的解决办法。
为此,Redis 5.0 新增加的一个数据结构 Stream
来做消息队列。Stream
支持:
- 发布 / 订阅模式
- 按照消费者组进行消费(借鉴了 Kafka 消费者组的概念)
- 消息持久化( RDB 和 AOF)
- ACK 机制(通过确认机制来告知已经成功处理了消息)
- 阻塞式获取消息
这是一个有序的消息链表,每个消息都有一个唯一的 ID 和对应的内容。ID 是一个时间戳和序列号的组合,用来保证消息的唯一性和递增性。内容是一个或多个键值对(类似 Hash 基本数据类型),用来存储消息的数据。
这里再对图中涉及到的一些概念,进行简单解释:
Consumer Group
:消费者组用于组织和管理多个消费者。消费者组本身不处理消息,而是再将消息分发给消费者,由消费者进行真正的消费last_delivered_id
:标识消费者组当前消费位置的游标,消费者组中任意一个消费者读取了消息都会使 last_delivered_id 往前移动。pending_ids
:记录已经被客户端消费但没有 ack 的消息的 ID。
总的来说,Stream
已经可以满足一个消息队列的基本要求了。不过,Stream
在实际使用中依然会有一些小问题不太好解决比如在 Redis 发生故障恢复后不能保证消息至少被消费一次。
综上,和专业的消息队列相比,使用 Redis 来实现消息队列还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。因此,我们通常建议不要使用 Redis 来做消息队列,你完全可以选择市面上比较成熟的一些消息队列比如 RocketMQ、Kafka。不过,如果你就是想要用 Redis 来做消息队列的话,那我建议你优先考虑 Stream
,这是目前相对最优的 Redis 消息队列实现。
7.7 Redis 可以做搜索引擎么?
Redis 可以实现全文搜索引擎功能,需要借助 RediSearch ,这是一个基于 Redis 的搜索引擎模块。
相比较于 Elasticsearch 来说,RediSearch 主要在下面两点上表现更优异一些:
- 性能更优秀:依赖 Redis 自身的高性能,基于内存操作(Elasticsearch 基于磁盘)。
- 较低内存占用实现快速索引:RediSearch 内部使用压缩的倒排索引,所以可以用较低的内存占用来实现索引的快速构建。
对于比较复杂或者数据规模较大的搜索场景还是不太建议使用 RediSearch 来作为搜索引擎,主要是因为下面这些限制和问题:
- 数据量限制:Elasticsearch 可以支持 PB 级别的数据量,可以轻松扩展到多个节点,利用分片机制提高可用性和性能。RedisSearch 是基于 Redis 实现的,其能存储的数据量受限于 Redis 的内存容量,不太适合存储大规模的数据(内存昂贵,扩展能力较差)。
- 分布式能力较差:Elasticsearch 是为分布式环境设计的,可以轻松扩展到多个节点。虽然 RedisSearch 支持分布式部署,但在实际应用中可能会面临一些挑战,如数据分片、节点间通信、数据一致性等问题。
- 聚合功能较弱:Elasticsearch 提供了丰富的聚合功能,而 RediSearch 的聚合功能相对较弱,只支持简单的聚合操作。
- 生态较差:Elasticsearch 可以轻松和常见的一些系统/软件集成比如 Hadoop、Spark、Kibana,而 RedisSearch 则不具备该优势。
Elasticsearch 适用于全文搜索、复杂查询、实时数据分析和聚合的场景,而 RediSearch 适用于快速数据存储、缓存和简单查询的场景。
7.8 如何基于 Redis 实现延时任务?🔥
订单在 30 分钟后未支付就失效,如何用 Redis 实现?
- Redis 过期事件监听:当 key 过期之后,会发布一个 key 过期的事件到默认的一个channel,只要监听这个channel,就可以拿到过期的 key
- Zset:使用 ZSet 的Score 存储延迟执行的时间,通过 zrangebysocre 命令获取已到达执行时间的任务,即分数小于或等于当前时间的任务,执行相应任务实现延时任务。
- Redisson 内置的延时队列:基于 Redis 的 Zset 实现,过期元素会加入到就绪消息列表(阻塞队列),避免对整个 Zset 轮询
Redis 过期事件监听存在以下几个问题:
- 时效性较差:删除 key 时才会发布过期事件而不是过期后立马发布过期事件
- 丢消息:不支持持久化,没有订阅者时消息直接丢弃
- 多服务实例下消息重复消费:只有广播模式,订阅频道的所有消费者都能收到消息
Redisson 内置的延时队列具备下面这些优势:
- 减少了丢消息的可能:DelayedQueue 中的消息会被持久化,即使 Redis 宕机了,也只可能丢失一点消息,影响不大。
- 消息不存在重复消费问题:每个客户端都是从同一个目标队列中获取任务的,不存在重复消费的问题。
7.9 Redis 管道有什么用?
管道技术(Pipeline)是客户端提供的一种批处理技术,用于一次处理多个 Redis 命令,从而提高整个交互的性能。使用管道技术可以解决多个命令执行时的网络等待,它是把多个命令整合到一起发送给服务器端处理之后统一返回给客户端,这样就免去了每条命令执行后都要等待的情况,从而有效地提高了程序的执行效率。但使用管道技术也要注意避免发送的命令过大,或管道内的数据太多而导致的网络阻塞。
7.10 Redis 事务
了解即可,不是重点
7.10.1 什么是 Redis 事务?
Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。
Redis 事务实际开发中使用的非常少,除了不满足原子性和持久性之外,事务中的每条命令都会与 Redis 服务器进行网络交互,这是比较浪费资源的行为。
7.10.2 如何使用 Redis 事务?
Redis 可以通过 MULTI
,EXEC
,DISCARD
和 WATCH
等命令来实现事务(Transaction)功能。
MULTI
命令后可以输入多个命令,Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC
命令后,再执行所有的命令。
你也可以通过 DISCARD
命令取消一个事务,它会清空事务队列中保存的所有命令。
你可以通过 WATCH
命令监听指定的 Key,当调用 EXEC
命令执行事务时,如果一个被 WATCH
命令监视的 Key 被 其他客户端/Session 修改的话,整个事务都不会被执行。
不过,如果 WATCH 与 事务 在同一个 Session 里,并且被 WATCH 监视的 Key 被修改的操作发生在事务内部,这个事务是可以被执行成功的。
7.10.3 Redis 事务支持原子性吗?
Redis 事务正常执行,可以保证原子性;但Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 事务是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的。
Redis 官网也解释了自己为啥不支持回滚。简单来说就是 Redis 开发者们觉得没必要支持回滚,这样更简单便捷并且性能更好。Redis 开发者觉得即使命令执行错误也应该在开发过程中就被发现而不是生产过程中。
7.10.4 如何解决 Redis 事务的缺陷?
Redis 从 2.6 版本开始支持执行 Lua 脚本,它的功能和事务非常类似。我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。
一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。
不过,如果 Lua 脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。因此, 严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。
如果想要让 Lua 脚本中的命令全部执行,必须保证语句语法和命令都是对的。
另外,Redis 7.0 新增了 Redis functions 特性,你可以将 Redis functions 看作是比 Lua 更强大的脚本。
7.11 Redis 使用规范
实际使用 Redis 的过程中,我们尽量要准守一些常见的规范,比如:
- 使用连接池:避免频繁创建关闭客户端连接。
- 尽量不使用 O(n)指令,使用 O(n) 命令时要关注 n 的数量:像
KEYS *
、HGETALL
、LRANGE
、SMEMBERS
、SINTER
/SUNION
/SDIFF
等 O(n) 命令并非不能使用,但是需要明确 n 的值。另外,有遍历的需求可以使用HSCAN
、SSCAN
、ZSCAN
代替。 - 使用批量操作减少网络传输:原生批量操作命令(比如
MGET
、MSET
等等)、pipeline、Lua 脚本。 - 尽量不适用 Redis 事务:Redis 事务实现的功能比较鸡肋,可以使用 Lua 脚本代替。
- 禁止长时间开启 monitor:对性能影响比较大。
- 控制 key 的生命周期:避免 Redis 中存放了太多不经常被访问的数据。
- ……