图解 Redis
2022-11-01 15:55:37 60 举报
AI智能生成
小林coding 图解 Redis 脑图总结
作者其他创作
大纲/内容
Redis 是一种基于内存的数据库,对数据的读写操作都是在内存中完成,因此 读写速度非常快,常用于 缓存,消息队列、分布式锁 等场景。
Redis 提供了多种数据类型来支持不同的业务场景,比如 String(字符串)、Hash(哈希)、 List (列表)、Set(集合)、Zset(有序集合)、Bitmaps(位图)、HyperLogLog(基数统计)、GEO(地理信息)、Stream(流),并且 对数据类型的操作都是原子性的,因为 执行命令由单线程负责,不存在并发竞争的问题。
除此之外,Redis 还支持 事务 、持久化、Lua 脚本、多种集群方案(主从复制模式、哨兵模式、切片机群模式)、发布/订阅模式,内存淘汰机制、过期删除机制 等等。
什么是 Redis
很多人都说用 Redis 作为缓存,但是 Memcached 也是基于内存的数据库,为什么不选择它作为缓存呢?要解答这个问题,我们就要弄清楚 Redis 和 Memcached 的区别。
Redis 与 Memcached 共同点:都是基于内存的数据库,一般都用来当做缓存使用。都有过期策略。两者的性能都非常高。
Redis 与 Memcached 区别:Redis 支持的数据类型更丰富(String、Hash、List、Set、ZSet),而 Memcached 只支持最简单的 key-value 数据类型;Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 没有持久化功能,数据全部存在内存之中,Memcached 重启或者挂掉后,数据就没了;Redis 原生支持集群模式,Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持;
Redis 和 Memcached 有何区别?
主要是因为 Redis 具备「高性能」和「高并发」两种特性。
假如用户第一次访问 MySQL 中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。若将该用户访问的数据缓存在 Redis 中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了,操作 Redis 缓存就是直接操作内存,所以速度相当快。
如果 MySQL 中的对应数据改变的之后,同步改变 Redis 缓存中相应的数据即可,不过这里会有 Redis 和 MySQL 双写一致性 的问题,后面我们会提到。
1、Redis 具备高性能
单台设备的 Redis 的 QPS(Query Per Second,每秒钟处理完请求的次数) 是 MySQL 的 10 倍,Redis 单机的 QPS 能轻松破 10w,而 MySQL 单机的 QPS 很难破 1w。
所以,直接访问 Redis 能够承受的请求是远远大于直接访问 MySQL 的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。
2、 Redis 具备高并发
为什么用 Redis 作为 MySQL 的缓存?
认识 Redis
Redis 提供了丰富的数据类型,常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
随着 Redis 版本的更新,后面又支持了四种数据类型: BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)。
Redis 五种常见数据类型的应用场景:String:缓存对象、常规计数、分布式锁、共享 session 信息等。List:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。Hash:缓存对象、购物车等。Set:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。Zset:排序场景,比如排行榜、电话和姓名排序等。
Redis 后续版本又支持四种数据类型,它们的应用场景如下:BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。
Redis 数据类型以及使用场景
我画了一张 Redis 数据类型和底层数据结构的对应关图,左边是 Redis 3.0版本的,也就是《Redis 设计与实现》这本书讲解的版本,现在看还是有点过时了,右边是现在 Redis 7.0 版本的。
String 类型的底层的数据结构实现主要是 SDS(简单动态字符串)。 SDS 和我们认识的 C 字符串不太一样,之所以没有使用 C 语言的字符串表示,因为 SDS 相比于 C 的原生字符串:SDS 不仅可以保存文本数据,还可以保存二进制数据(二进制安全)。因为 SDS 使用 len 属性的值来判断字符串是否结束,而不是空字符,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf[] 数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。SDS 保证数据在写入和读取时都是一样的。SDS 获取字符串长度的时间复杂度是 O(1)。因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);而 SDS 结构里用 len 属性记录了字符串长度,所以复杂度为 O(1)。Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足 要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。减少修改字符串时带来的内存重新分配次数。SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联:在 SDS 中 buff 数组的长度不一定就是字符串数量 +1(结尾的空格),还可以包含未使用的字节,这些字节数量由 free 属性记录。
String 类型内部实现
List 类型的底层数据结构是由 双向链表或压缩列表 实现的:如果列表的 元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用 压缩列表 作为 List 类型的底层数据结构;如果列表的元素 不满足上面的条件,Redis 会使用 双向链表 作为 List 类型的底层数据结构;
注意:在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。
List 类型内部实现
Hash 类型的底层数据结构是由 压缩列表或哈希表 实现的:如果哈希类型 元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有 值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用 压缩列表 作为 Hash 类型的底层数据结构;如果哈希类型元素 不满足上面条件,Redis 会使用 哈希表 作为 Hash 类型的底层数据结构。
注意:在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
Hash 类型内部实现
Set 类型的底层数据结构是由 哈希表或整数集合 实现的:如果集合中的 元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用 整数集合 作为 Set 类型的底层数据结构;如果集合中的元素 不满足上面条件,则 Redis 使用 哈希表 作为 Set 类型的底层数据结构。
Set 类型内部实现
Zset 类型的底层数据结构是由压缩列表或跳表实现的:如果有序集合的 元素个数小于 128 个,并且每个元素的 值小于 64 字节 时,Redis 会使用 压缩列表 作为 Zset 类型的底层数据结构;如果有序集合的元素 不满足上面的条件,Redis 会使用 跳表 作为 Zset 类型的底层数据结构;
ZSet 类型内部实现
五种常见的数据类型是如何实现的?
Redis 数据结构
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 主线程就很容易发生阻塞,这样就无法处理后续的请求了。
后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。
关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:BIO_CLOSE_FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭;BIO_AOF_FSYNC,AOF刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘,BIO_LAZY_FREE,lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象;
Redis 是单线程吗?
Redis 6.0 版本之前的单线模式如下图:
图中的蓝色部分是一个事件循环,是由主线程负责的,可以看到网络 I/O 和命令处理都是单线程。
Redis 初始化的时候,会做下面这几件事情:首先,调用 epoll_create() 创建一个 epoll 对象和调用 socket() 一个服务端 socket然后,调用 bind() 绑定端口和调用 listen() 监听该 socket;然后,将调用 epoll_ctl() 将 listen socket 加入到 epoll,同时注册「连接事件」处理函数。
初始化完后,主线程就进入到一个 事件循环函数,主要会做以下事情:首先,先调用 处理发送队列函数,看是发送队列里是否有任务,如果有发送任务,则通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。接着,调用 epoll_wait 函数等待事件的到来: · 如果是 连接事件 到来,则会调用 连接事件处理函数,该函数会做这些事情: 调用 accpet 获取已连接的 socket -> 调用 epoll_ctl 将已连接的 socket 加入到 epoll -> 注册「读事件」处理函数; · 如果是 读事件 到来,则会调用 读事件处理函数,该函数会做这些事情: 调用 read 获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送; · 如果是 写事件 到来,则会调用 写事件处理函数,该函数会做这些事情: 通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完, 就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。
Redis 单线程模式是怎样的?
官方使用基准测试的结果是,单线程的 Redis 吞吐量可以达到 10W/每秒,如下图所示:
之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:Redis 的大部分操作都 在内存中完成,并且采用了 高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了; Redis 采用单线程模型可以 避免了多线程之间的竞争,省去了 多线程切换 带来的时间和性能上的开销,而且也不会 导致死锁 问题。Redis 采用了 I/O 多路复用机制 处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果
Redis 采用单线程为何还这么快?
CPU 并不是制约 Redis 性能表现的瓶颈所在,更多情况下是受到内存大小和网络I/O的限制,所以 Redis 核心网络模型使用单线程并没有什么问题,如果你想要使用服务的多核CPU,可以在一台服务器上启动多个节点或者采用分片集群的方式。
使用了单线程后,可维护性高,多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了 并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。
Redis 6.0 之前为什么使用单线程?
虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。
所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理,所以大家不要误解 Redis 有多线程同时执行命令。
Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上。
Redis 6.0 版本支持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据(write client socket),并不会以多线程的方式处理读请求(read client socket)。要想开启多线程处理客户端读请求,就需要把 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。
同时, Redis.conf 配置文件中提供了 IO 多线程个数的配置项。
关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。
因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会创建 6 个线程:Redis-server : Redis的主线程,主要负责执行命令;bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务;io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。
Redis 6.0 之后为什么引入了多线程?
Redis 线程模型
Redis 的读写操作都是在内存中,所以 Redis 性能才会高,但是当 Redis 重启后,内存中的数据就会丢失,那为了保证内存中的数据不会丢失,Redis 实现了数据持久化的机制,这个机制会把数据存储到磁盘,这样在 Redis 重启就能够从磁盘中恢复原有的数据。
Redis 共有三种数据持久化的方式:AOF 日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里;RDB 快照:将某一时刻的内存数据,以二进制的方式写入磁盘;混合持久化方式:Redis 4.0 新增的方式,集成了 AOF 和 RBD 的优点;
Redis 如何实现数据不丢失?
Redis 在执行完一条写操作命令后,就会 把该命令以追加的方式 写入到一个文件里,然后 Redis 重启时,会读取该文件记录的命令,然后 逐一执行命令 的方式来进行 数据恢复。
我这里以「set name xiaolin」命令作为例子,Redis 执行了这条命令后,记录在 AOF 日志里的内容如下图:「*3」表示当前命令有三个部分,每部分都是以「$+数字」开头,后面紧跟着具体的命令、键或值。然后,这里的「数字」表示这部分中的命令、键或值一共有多少字节。例如,「$3 set」表示这部分有 3 个字节,也就是「set」命令这个字符串的长度。
简介
Reids 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处。避免额外的检查开销:因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该 错误的命令记录到 AOF 日志 里后,Redis 在使用日志恢复数据时,就可能会出错。不会阻塞当前写操作命令的执行:因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。
当然,这样做也会带来风险:数据可能会丢失: 执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险。可能阻塞其他操作: 由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前命令的执行,但因为 AOF 日志也是在主线程中执行,所以当 Redis 把日志文件写入磁盘的时候,还是会阻塞后续的操作无法执行。
为什么先执行命令,再把数据写入日志呢?
先来看看,Redis 写入 AOF 日志的过程,如下图:
具体说说:Redis 执行完写操作命令后,会 将命令追加到 server.aof_buf 缓冲区;然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是 拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;具体 内核缓冲区的数据什么时候写入到硬盘,由内核决定。
Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。 在 Redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:Always,每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;Everysec,每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后 每隔一秒将缓冲区里的内容写回到硬盘;No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再 由操作系统决定何时将缓冲区内容写回硬盘。
3 个写回策略的优缺点:
AOF 写回策略有几种?
AOF 日志是一个文件,随着执行的写操作命令越来越多,文件的大小会越来越大。 如果当 AOF 日志文件过大就会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果 文件过大,整个 恢复的过程就会很慢。
所以,Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。
AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后将 每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。
举个例子,在没有使用重写机制前,假设前后执行了「set name xiaolin」和「set name xiaolincoding」这两个命令的话,就会将这两个命令记录到 AOF 文件。但是在 使用重写机制后,就会读取 name 最新的 value(键值对) ,然后 用一条 「set name xiaolincoding」命令记录到新的 AOF 文件,之前的第一个命令就没有必要记录了,因为它属于「历史」命令,没有作用了。这样一来,一个键值对在重写日志中只用一条命令就行了。
重写工作完成后,就会将新的 AOF 文件覆盖现有的 AOF 文件,这就相当于压缩了 AOF 文件,使得 AOF 文件体积变小了。
AOF 日志过大,会触发什么机制?
Redis 的 重写 AOF 过程是由 后台子进程 bgrewriteaof 来完成的(注意是子进程),这么做可以达到两个好处:子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而 避免阻塞主进程;子进程带有主进程的数据副本,这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在 修改共享内存数据 的时候,需要通过 加锁 来保证数据的安全,而这样就会 降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程 任意一方修改了该共享内存,就会发生「写时复制」,于是 父子进程就有了独立的数据副本,就不用加锁来保证数据安全。
触发重写机制后,主进程就会创建重写 AOF 的子进程,此时父子进程共享物理内存,重写子进程只会对这个内存进行只读,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。
但是重写过程中,主进程依然可以正常处理命令,那问题来了,重写 AOF 日志过程中,如果 主进程修改了已经存在 key-value,那么会 发生写时复制,此时这个 key-value 数据在子进程的内存数据就跟主进程的内存数据 不一致 了,这时要怎么办呢?
为了解决这种数据不一致问题,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。
在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会 同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」。
也就是说,在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作:执行客户端发来的命令;将执行后的写命令追加到 「AOF 缓冲区」;将执行后的写命令追加到 「AOF 重写缓冲区」;
当子进程完成 AOF 重写工作(扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志)后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。
主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作:将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致;新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。
信号函数执行完后,主进程就可以继续像往常一样处理命令了。
重写 AOF 日志的过程是怎样的?
AOF 日志是如何实现的?
因为 AOF 日志记录的是操作命令,不是实际的数据,所以用 AOF 方法做故障恢复时,需要全量把日志都执行一遍,一旦 AOF 日志非常多,势必会造成 Redis 的恢复操作缓慢。
为了解决这个问题,Redis 增加了 RDB 快照。所谓的快照,就是记录某一个瞬间东西,比如当我们给风景拍照时,那一个瞬间的画面和信息就记录到了一张照片。
所以,RDB 快照 就是记录某一个瞬间的内存数据,记录的是 实际数据,而 AOF 文件 记录的是 命令 操作的日志,而不是实际的数据。
因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。
介绍
Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave,他们的区别就在于是否在「主线程」里执行:执行了 save 命令,就会在 主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程;执行了 bgsave 命令,会创建一个 子进程 来生成 RDB 文件,这样可以 避免主线程的阻塞;
Redis 还可以通过配置文件的选项来实现每隔一段时间自动执行一次 bgsave 命令,默认会提供以下配置:
别看选项名叫 save,实际上执行的是 bgsave 命令,也就是会创建子进程来生成 RDB 快照文件。 只要满足上面条件的任意一个,就会执行 bgsave,它们的意思分别是:900 秒之内,对数据库进行了至少 1 次修改;300 秒之内,对数据库进行了至少 10 次修改;60 秒之内,对数据库进行了至少 10000 次修改。
这里提一点,Redis 的快照是 全量快照,也就是说每次执行快照,都是 把内存中的「所有数据」都记录到磁盘中。所以执行快照是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多。
RDB 做快照时会阻塞线程吗?
RDB 文件的创建:生成 RDB 文件就是 SAVE 和 BGSAVE 命令。
RDB 文件的载入:和使用 SAVE 命令或者 BGSAVE 命令创建 RDB 文件不同,RDB文件的载入工作 是在 服务器启动时 自动执行的;Redis 并没有专门用于载入 RDB 文件的命令,只要 Redis 服务器在启动时检测到 RDB 文件存在,它就会自动载入RDB文件
RDB 文件的创建和载入
可以的,执行 bgsave 过程中,Redis 依然可以 继续处理操作命令 的,也就是数据是能被修改的,关键的技术就在于 font color=\"#0000ff\
执行 bgsave 命令的时候,会通过 fork() 创建 子进程,此时子进程和父进程是 共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个,此时如果主线程执行读操作,则主线程和 bgsave 子进程互相不影响。
如果主线程执行写操作,则 被修改的数据会复制一份副本,然后 bgsave 子进程会把 该副本数据写入 RDB 文件,在这个过程中,主线程仍然可以直接修改原来的数据。
RDB 在执行快照的时候,数据能修改吗?
RDB 快照是如何实现的?
RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。
AOF 优点是丢失数据少,但是数据恢复不快。
为了集成了两者的优点, Redis 4.0 提出了 混合使用 AOF 日志和内存快照,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。
混合持久化工作在 AOF 日志重写过程,当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写 子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后 主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程 将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。
也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。
这样的好处在于,重启 Redis 加载数据的时候,由于 前半部分是 RDB 内容,这样 加载的时候速度会很快。
加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台 子进程重写 AOF 期间,主线程处理的操作命令,可以使得 数据更少的丢失。
混合持久化 优点:混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。
混合持久化 缺点:AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差;兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。
为什么会有混合持久化?
Redis 提供了 3 种 AOF 日志写回硬盘的策略,这三种策略只是在控制 fsync() 函数的调用时机。
当应用程序向文件写入数据时,内核通常先将数据复制到内核缓冲区中,然后排入队列,然后由内核决定何时写入硬盘。
如果想要应用程序向文件写入数据后,能立马将数据同步到硬盘,就可以调用 fsync() 函数,这样内核就会将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。
Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数;Everysec 策略就会创建一个异步任务来执行 fsync() 函数;No 策略就是永不执行 fsync() 函数;
当使用 Always 策略的时候,如果写入是一个 大 Key,主线程在 执行 fsync() 函数 的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的。
当使用 Everysec 策略的时候,由于是 异步执行 fsync() 函数,所以大 Key 持久化的过程(数据同步磁盘)不会影响主线程。
当使用 No 策略的时候,由于 永不执行 fsync() 函数,所以大 Key 持久化的过程 不会影响主线程。
大 key 对 AOF 的影响
当 AOF 日志写入了很多的大 Key,AOF 日志文件的大小会很大,那么 很快就会触发 AOF 重写机制。
AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别 通过 fork() 函数创建一个子进程 来处理任务。
在创建子进程的过程中,操作系统会把父进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而 不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。
这样一来,子进程就共享了父进程的物理内存数据了,这样能够节约物理内存资源,页表对应的页表项的属性会标记 该物理内存的权限为只读。
随着 Redis 存在越来越多的大 Key,那么 Redis 就会占用很多内存,对应的 页表就会越大。
在通过 fork() 函数创建子进程的时候,虽然不会复制父进程的物理内存,但是 内核会把父进程的页表复制一份给子进程,如果 页表很大,那么这个 复制过程是会很耗时 的,那么在执行 fork 函数的时候就会发生阻塞现象。
而且,fork 函数是由 Redis 主线程调用的,如果 fork 函数发生阻塞,那么意味着就 会阻塞 Redis 主线程。由于 Redis 执行命令是在主线程处理的,所以 当 Redis 主线程发生阻塞,就无法处理后续客户端发来的命令。
写时复制发生物理内存复制 的情况:当父进程或者子进程在 向共享内存发起写操作时,CPU 就会触发 写保护中断,这个「写保护中断」是由于违反权限导致的,然后操作系统会在「写保护中断处理函数」里进行 物理内存的复制,并 重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,然后再对内存进行写操作,这个过程被称为「写时复制(Copy On Write)」。
写时复制顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。
如果创建完子进程后,父进程对共享内存中的大 Key 进行了修改,那么内核就会发生写时复制,会把物理内存复制一份,由于大 Key 占用的物理内存是比较大的,那么在复制物理内存这一过程中,也是比较耗时的,于是父进程(主线程)就会发生阻塞。
所以,有两个阶段会导致阻塞父进程:创建子进程的途中,由于要 复制父进程的页表 等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;创建完子进程后,如果子进程或者父进程 修改了共享数据,就会发生 写时复制,这期间 会拷贝物理内存,如果内存越大,自然阻塞的时间也越长;
大 key 对 AOF 重写和 RDB 的影响
大 key 除了会影响持久化之外,还会有以下的影响:客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。
如何避免大 Key 呢?最好在设计阶段,就 把大 key 拆分成一个一个小 key。定时检查 Redis 是否存在大 key ,如果该大 key 是可以删除的,不要使用 DEL 命令删除,因为该命令删除过程会阻塞主线程,而是用 unlink 命令(Redis 4.0+)删除大 key,因为该命令的删除过程是异步的,不会阻塞主线程。
大 key 对持久化的影响
Redis 持久化
要想设计一个高可用的 Redis 服务,一定要从 Redis 的多服务节点来考虑,比如 Redis 的主从复制、哨兵模式、切片集群。
主从复制是 Redis 高可用服务的最基础的保证,实现方案就是将从前的一台 Redis 服务器,同步数据到多台从 Redis 服务器上,即一主多从的模式,且主从服务器之间采用的是「读写分离」的方式。
主服务器可以进行读写操作,当发生 写操作时自动将写操作同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。
也就是说,所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器,这样就使得主从服务器的数据是一致的。
注意,主从服务器之间的 命令复制是异步进行的。
具体来说,在主从服务器命令传播阶段,主服务器收到新的写命令后,会发送给从服务器。但是,主服务器并不会等到从服务器实际执行完命令后,再把结果返回给客户端,而是 主服务器自己在本地执行完命令后,就会向客户端返回结果了。如果 从服务器还没有执行主服务器同步过来的命令,主从服务器间的数据就不一致了。
所以,无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。
主从复制
在使用 Redis 主从服务的时候,会有一个问题,就是当 Redis 的主从服务器出现故障宕机时,需要手动进行恢复。
为了解决这个问题,Redis 增加了 哨兵模式(Redis Sentinel),因为哨兵模式做到了可以 监控主从服务器,并且提供 主从节点故障转移的功能。
哨兵模式
当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 Redis 切片集群(Redis Cluster )方案,它 将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。
Redis Cluster 方案采用哈希槽(Hash Slot),来处理数据和节点之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中,具体执行过程分为两大步:根据键值对的 key,按照 CRC16 算法 (opens new window)计算一个 16 bit 的值。再用 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。
接下来的问题就是,这些哈希槽怎么被映射到具体的 Redis 节点上的呢?有两种方案:平均分配: 在使用 cluster create 命令创建 Redis 集群时,Redis 会自动把所有哈希槽平均分布到集群节点上。比如集群中有 9 个节点,则每个节点上槽的个数为 16384/9 个。手动分配: 可以使用 cluster meet 命令手动建立节点间的连接,组成集群,再使用 cluster addslots 命令,指定每个节点上的哈希槽个数。
通过一张图来解释数据、哈希槽,以及节点三者的映射分布关系。
上图中的切片集群一共有 2 个节点,假设有 4 个哈希槽(Slot 0~Slot 3)时,我们就可以通过命令手动分配哈希槽,比如节点 1 保存哈希槽 0 和 1,节点 2 保存哈希槽 2 和 3。
然后在集群运行的过程中,key1 和 key2 计算完 CRC16 值后,对哈希槽总个数 4 进行取模,再根据各自的模数结果,就可以被映射到对应的节点 1 和节点 2 上了。
需要注意的是,在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。
切片集群模式
Redis 如何实现服务高可用?
先来理解集群的脑裂现象,这就好比一个人有两个大脑,那么到底受谁控制呢?
那么在 Redis 中,集群脑裂产生数据丢失的现象是怎样的呢?
在 Redis 主从架构中,部署方式一般是「一主多从」,主节点提供写操作,从节点提供读操作。
如果主节点的网络突然发生了问题,它与所有的从节点都失联了,但是此时的 主节点和客户端的网络是正常的,这个客户端并不知道 Redis 内部已经出现了问题,还在照样的 向这个失联的主节点写数据(过程A),此时这些数据被主节点缓存到了缓冲区里,因为主从节点之间的网络问题,这些数据都是 无法同步给从节点的。
这时,哨兵也发现主节点失联了,它就认为主节点挂了(但实际上主节点正常运行,只是网络出问题了),于是哨兵就会 在从节点中选举出一个 leeder 作为主节点,这时 集群就有两个主节点了 —— 脑裂出现了。
这时候网络突然好了,哨兵因为之前已经选举出一个新主节点了,它就会 把旧主节点降级为从节点(A),然后从节点(A)会向新主节点请求数据同步,因为 第一次同步是全量同步 的方式,此时的 从节点(A)会清空掉自己本地的数据,然后再做全量同步。所以,之前客户端在过程 A 写入的数据就会丢失了,也就是 集群产生脑裂数据丢失的问题。
总结一句话就是:由于 网络问题,集群节点之间失去联系。主从数据不同步;重新平衡选举,产生两个主服务。等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于 从节点会清空自己的缓冲区,所以 导致之前客户端写入的数据丢失了。
什么是脑裂?
当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么禁止主节点进行写数据,直接把错误返回给客户端。
在 Redis 的配置文件中有两个参数我们可以设置:min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果超过,主节点会禁止写数据。
我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。
这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的写请求了。
即使原主库是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从库进行同步,自然也就无法和从库进行 ACK 确认了。这样一来,min-slaves-to-write 和 min-slaves-max-lag 的组合要求就无法得到满足,原主库就会被限制接收客户端写请求,客户端也就不能在原主库中写入新数据了。
等到新主库上线时,就只有新主库能接收和处理客户端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。
再来举个例子:假设我们将 min-slaves-to-write 设置为 1,把 min-slaves-max-lag 设置为 12s,把哨兵的 down-after-milliseconds 设置为 10s,主库因为某些原因卡住了 15s,导致哨兵判断主库客观下线,开始进行主从切换。
同时,因为原主库卡住了 15s,没有一个从库能和原主库在 12s 内进行数据复制,原主库也无法接收客户端请求了。
这样一来,主从切换完成后,也只有新主库能接收请求,不会发生脑裂,也就不会发生数据丢失的问题了。
解决方案
集群脑裂导致数据丢失怎么办?
Redis 集群
Redis 是可以对 key 设置过期时间的,因此需要有相应的机制将已过期的键值对删除,而做这个工作的就是过期键值删除策略。
每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个 过期字典(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。
当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:如果不在,则正常读取键值;如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。
Redis 使用的过期删除策略是「惰性删除+定期删除」这两种策略配和使用。
惰性删除策略的做法是:不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。
惰性删除的流程图如下:
惰性删除策略的 优点:因为每次访问时,才会检查 key 是否过期,所以此策略只会 使用很少的系统资源,因此,惰性删除策略对 CPU 时间最友好。
惰性删除策略的 缺点:如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的 内存空间浪费。所以,惰性删除策略对内存不友好。
什么是惰性删除策略?
定期删除策略的做法是:每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。
Redis 的定期删除的流程:从过期字典中随机抽取 20 个 key;检查这 20 个 key 是否过期,并删除已过期的 key;如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。
可以看到,定期删除是一个循环的流程。那 Redis 为了保证定期删除 不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。
定期删除的流程如下:
定期删除策略的 优点:通过限制删除操作执行的时长和频率,来 减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据 减少了过期键对空间的无效占用。
定期删除策略的 缺点:难以确定删除操作执行的时长和频率。如果执行的太频繁,就会对 CPU 不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。
可以看到,惰性删除策略和定期删除策略都有各自的优点,所以 Redis 选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。
什么是定期删除策略?
Redis 使用的过期删除策略是什么?
Redis 持久化文件有两种格式:RDB(Redis Database)和 AOF(Append Only File),下面我们分别来看过期键在这两种格式中的呈现状态。
RDB 文件分为两个阶段,RDB 文件生成阶段和加载阶段:RDB 文件生成阶段:从内存状态持久化成 RDB(文件)的时候,会对 key 进行过期检查,过期的键「不会」被保存到新的 RDB 文件中,因此 Redis 中的过期键不会对生成新 RDB 文件产生任何影响。RDB 加载阶段:RDB 加载阶段时,要看服务器是主服务器还是从服务器,分别对应以下两种情况: (1)如果 Redis 是「主服务器」运行模式的话,在载入 RDB 文件时,程序会对文件中保存的键进行检查, 过期键「不会」被载入到数据库中。所以过期键不会对载入 RDB 文件的主服务器造成影响; (2)如果 Redis 是「从服务器」运行模式的话,在载入 RDB 文件时,不论键是否过期都会被载入到数据库中。但由于主从服务器 在进行数据同步时,从服务器的数据会被清空。所以一般来说,过期键对载入 RDB 文件的从服务器也不会造成影响。
AOF 文件分为两个阶段,AOF 文件写入阶段和 AOF 重写阶段。AOF 文件写入阶段:当 Redis 以 AOF 模式持久化时,如果数据库某个过期键还没被删除,那么 AOF 文件会保留此过期键,当此过期键被删除后,Redis 会向 AOF 文件追加一条 DEL 命令来显式地删除该键值。AOF 重写阶段:执行 AOF 重写时,会对 Redis 中的键值对进行检查,已过期的键不会被保存到重写后的 AOF 文件中,因此不会对 AOF 重写造成任何影响。
Redis 持久化时,对过期键如何处理的?
当 Redis 运行在主从模式下时,从库不会进行过期扫描,从库对过期的处理是被动的。如果有客户端 访问从库的过期 key 时,依然可以得到 key 对应的值,像未过期的键值对一样返回。
从库的过期键处理依靠主服务器控制,主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key。
Redis 主从模式中,对过期键如何处理?
在 Redis 的运行内存达到了某个阀值,就会 触发内存淘汰机制,这个阀值就是我们设置的最大运行内存,此值在 Redis 的配置文件中可以找到,配置项为 maxmemory。
Redis 内存满了,会发生什么?
Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。
noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误。
不进行数据淘汰的策略
针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。
在设置了 过期时间的数据中进行淘汰:volatile-random:随机淘汰设置了过期时间的任意键值;volatile-ttl:优先淘汰更早过期的键值。volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;
在 所有数据范围内进行淘汰:allkeys-random:随机淘汰任意键值;allkeys-lru:淘汰整个键值中最久未使用的键值;allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。
进行数据淘汰的策略
Redis 内存淘汰策略有哪些?
LRU 全称是 Least Recently Used 翻译为 最近最少使用,会选择淘汰最近最少使用的数据。
传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。
Redis 并没有使用这样的方式实现 LRU 算法,因为 传统的 LRU 算法存在两个问题:需要用链表管理所有的缓存数据,这会带来额外的空间开销;当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。
什么是 LRU 算法?
Redis 实现的是一种 近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是 在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。
当 Redis 进行内存淘汰时,会使用 随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后 淘汰最久没有使用的那个。
Redis 实现的 LRU 算法的 优点:不用为所有的数据维护一个大链表,节省了空间占用;不用在每次数据访问时都移动链表项,提升了缓存的性能;
但是 LRU 算法有一个问题,无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。
因此,在 Redis 4.0 之后引入了 LFU 算法来解决这个问题。
Redis 是如何实现 LRU 算法的?
LFU 全称是 Least Frequently Used 翻译为 最近最不常用的,LFU 算法是根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。
所以, LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些。
什么是 LFU 算法?
LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。Redis 对象的结构如下:
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),用来记录 key 的访问时间戳;低 8bit 存储 logc(Logistic Counter),用来记录 key 的访问频次。
Redis 是如何实现 LFU 算法的?
LRU 算法和 LFU 算法有什么区别?
Redis 过期删除与内存淘汰
通常我们为了保证缓存中的数据与数据库中的数据一致性,会给 Redis 里的数据设置过期时间,当缓存数据过期后,用户访问的数据如果不在缓存里,业务系统需要重新生成缓存,因此就会访问数据库,并将数据更新到 Redis 里,这样后续请求都可以直接命中缓存。
那么,当 大量缓存数据在同一时间过期(失效)时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是 全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是 缓存雪崩 的问题。
对于缓存雪崩问题,我们可以采用两种方案解决:将缓存失效时间随机打散: 我们可以在原有的失效时间基础上增加一个随机值(比如 1 到 10 分钟)这样每个缓存的过期时间都不重复了,也就降低了缓存集体失效的概率。设置缓存不过期: 我们可以通过后台服务来更新缓存数据,从而避免因为缓存失效造成的缓存雪崩,也可以在一定程度上避免缓存并发问题。
如何避免缓存雪崩?
我们的业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类被频地访问的数据被称为热点数据。
如果缓存中的 某个热点数据过期了,此时 大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是 缓存击穿 的问题。
可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。 应对缓存击穿可以采取前面说到两种方案:互斥锁方案(Redis 中使用 setNX 方法设置一个状态位,表示这是一种锁定状态),保证同一时间只有一个业务线程请求缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存 以及重新设置过期时间;
如何避免缓存击穿?
当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据,一旦缓存恢复相对应的数据,就可以减轻数据库的压力,而缓存穿透就不一样了。
当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是 缓存穿透 的问题。
缓存穿透的发生一般有这两种情况:业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;黑客恶意攻击,故意大量访问某些读取不存在数据的业务;
应对缓存穿透的方案,常见的方案有三种。非法请求的限制:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。设置空值或者默认值:当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。使用 布隆过滤器 快速判断数据是否存在,避免通过查询数据库来判断数据是否存在:我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在,即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。
如何避免缓存穿透?
缓存雪崩、缓存击穿、缓存穿透
由于数据存储受限,系统并不是将所有数据都需要存放到缓存中的,而 只是将其中一部分热点数据缓存起来,所以我们要设计一个热点数据动态缓存的策略。
热点数据动态缓存的策略总体思路:通过数据最新访问时间来做排名,并过滤掉不常访问的数据,只留下经常访问的数据。
以电商平台场景中的例子,现在要求只缓存用户经常访问的 Top 1000 的商品。具体细节如下:先通过缓存系统做一个排序队列(比如存放 1000 个商品),系统会根据商品的访问时间,更新队列信息,越是最近访问的商品排名越靠前;同时系统会定期过滤掉队列中排名最后的 200 个商品,然后再从数据库中随机读取出 200 个商品加入队列中;这样当请求每次到达的时候,会先从队列中获取商品 ID,如果命中,就根据 ID 再从另一个缓存数据结构中读取实际的商品信息,并返回。
在 Redis 中可以用 zadd 方法和 zrange 方法来完成排序队列和获取 200 个商品的操作。
如何设计一个缓存策略,可以动态缓存热点数据呢?
常见的缓存更新策略共有3种:Cache Aside(旁路缓存)策略;Read/Write Through(读穿 / 写穿)策略;Write Back(写回)策略;实际开发中,Redis 和 MySQL 的更新策略用的是 Cache Aside,另外两种策略应用不了。
Cache Aside(旁路缓存)策略是最常用的,应用程序直接与「数据库、缓存」交互,并负责对缓存的维护,该策略又可以细分为「读策略」和「写策略」。
写策略的步骤:先更新数据库中的数据,再删除缓存中的数据。
读策略的步骤:如果读取的数据命中了缓存,则直接返回数据;如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。
注意,写策略的步骤的顺序顺序不能倒过来,即 不能先删除缓存再更新数据库,原因是在「读+写」并发的时候,会出现缓存和数据库的数据不一致性的问题。
举个例子,假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后 请求 A 继续更改数据库,将用户的年龄更新为 21。
最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库的数据不一致。
而且,如果先删除缓存再更新数据库的话,如果缓存的是热点数据,在并发场景下,在删除缓存后,更新数据库之前的这段时间内,很有可能会有大量请求访问该热点数据,就会造成缓存击穿。
为什么「先更新数据库再删除缓存」不会有数据不一致的问题?
继续用「读 + 写」请求的并发的场景来分析。
假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。
最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据不一致。 从上面的理论上分析,先更新数据库,再删除缓存 也是会出现数据不一致性 的问题,但是在实际中,这个问题出现的概率并不高。
因为 缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。
Cache Aside 策略适合读多写少的场景,不适合写多的场景,因为当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:一种做法是 在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于 写入的性能会有一些影响;另一种做法同样也是 在更新数据时更新缓存,只是 给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。
Cache Aside(旁路缓存)策略
Read/Write Through(读穿 / 写穿)策略原则是应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存自己代理了。
先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库查询数据,并将结果写入到缓存组件,最后缓存组件将数据返回给应用。
1、Read Through 策略
当有数据更新的时候,先查询要写入的数据在缓存中是否已经存在:如果缓存中数据已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,然后缓存组件告知应用程序更新完成。如果缓存中数据不存在,直接更新数据库,然后返回;
2、Write Through 策略
下面是 Read Through/Write Through 策略的示意图:
Read Through/Write Through 策略的特点是由缓存节点而非应用程序来和数据库打交道,在我们开发过程中相比 Cache Aside 策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是 Memcached 还是 Redis 都不提供写入数据库和自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略。
Read/Write Through(读穿 / 写穿)策略
Write Back(写回)策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。
实际上,Write Back(写回)策略也不能应用到我们常用的数据库和缓存的场景中,因为 Redis 并没有异步更新数据库的功能。
Write Back 是计算机体系结构中的设计,比如 CPU 的缓存、操作系统中文件系统的缓存都采用了 Write Back(写回)策略。
Write Back 策略特别适合写多的场景,因为发生写操作的时候, 只需要更新缓存,就立马返回了。比如,写文件的时候,实际上是写入到文件系统的缓存就返回了,并不会写磁盘。
但是带来的问题是,数据不是强一致性的,而且会有数据丢失的风险,因为缓存一般使用内存,而内存是非持久化的,所以一旦缓存机器掉电,就会造成原本缓存中的脏数据丢失。所以你会发现系统在掉电之后,之前写入的文件会有部分丢失,就是因为 Page Cache 还没有来得及刷盘造成的。
这里贴一张 CPU 缓存与内存使用 Write Back 策略的流程图:
这在操作系统中使用的较多。
Write Back(写回)策略
说说常见的缓存更新策略?
在下面缓存篇中。
如何保证缓存和数据库数据的一致性?
Redis 缓存设计
MULTI、EXEC、DISCARD 和 WATCH 命令是 Redis 实现事务的的基础。
Redis 事务的执行过程包含三个步骤:开启事务;命令入队;执行事务或丢弃;
显式开启一个事务:客户端通过 MULTI 命令显式地表示开启一个事务,随后的命令将排队缓存,并不会实际执行。
命令入队:客户端把事务中的要执行的一系列指令发送到服务端。虽然指令发送到服务端,但是 Redis 实例 只是把这一系列指令暂存在一个命令队列中,并不会立刻执行。
执行事务或丢弃:客户端向服务端发送提交或者丢弃事务的命令,让 Redis 执行第二步中发送的具体指令或者清空队列命令,放弃执行。Redis 只需在调用 EXEC 时,即可安排队列命令执行。也可通过 DISCARD 丢弃第二步中保存在队列中的命令。
Redis 如何实现事务
Redis 事务可以一次执行多个命令, 并且带有以下三个重要的保证:批量指令在执行 EXEC 命令之前会放入队列暂存;收到 EXEC 命令后进入事务执行,事务中任意命令执行失败,其余的命令依然被执行;事务执行过程中,其他客户端提交的命令不会插入到当前命令执行的序列中。
对于 Redis 事务来说,原子性仅仅是指事务队列中的 命令 要么全部都执行,要么一个都不执行。
要注意这和 MySQL 中的事务的原子性不同,Redis 只保证命令执行,不保证命令是否成功执行。
所以 Redis 是 不支持回滚 的,即使在执行过程中有命令发生了错误,正确的命令也是能执行成功的。
所以从严格意义上来说,Redis 是不保证原子性的。
不保证原子性
在事务期间,可能遇到三种命令错误:入队错误,发送的指令本身就错误。如下: (1)命令格式不正确,例如参数数量错误; (2)命令名称错误,使用了不存在的命令; (3)内存不足(Redis 实例使用 maxmemory指令配置内存限制)。执行错误,在执行 EXEC 后,命令在执行过程中可能会失败(入队时无法检测出来的错误)。例如,命令和操作的数据类型不匹配(对 String 类型 的 value 执行了 List 列表操作);服务器停机,在执行事务过程中,Redis 实例发生了故障导致事务执行失败。
在命令入队时,Redis 就会 报错并且记录下这个错误。不过此时 还能继续提交命令操作。
等到执行了 EXEC 命令之后,Redis 就会 拒绝执行所有提交的命令操作,返回事务失败的结果。
这样一来,事务中的所有命令都不会再被执行了,错误命令自然也不会执行,因此没有影响一致性。
如下是指令入队发生错误,导致事务失败的例子:
入队错误
事务操作入队时,命令和操作的数据类型不匹配,但 Redis 实例没有检查出错误。
但是,在执行完 EXEC 命令以后,Redis 实际执行这些指令,就会报错。
因为 Redis 会对错误指令报错,不会执行错误命令,所以也没有影响一致性。
但是事务依然会把正确的命令执行完,这时候事务的 原子性就无法保证了!
如下就是入队时未检查出错误,执行时才发生错误的事务:
执行错误
在执行过程中发生故障,例如在事务执行过程中,Redis 宕机了。
我们知道,宕机后内存中的数据肯定是全部消失了,所以需要根据持久化机制来判断。
无论是 RDB 还是 AOF 模式,重启后都可以根据 RDB/AOF 文件进行数据恢复,从而将数据库还原到一个一致的状态。因此数据库是一致的。
即使重启后找不到可用的 RDB 或 AOF 文件,那么此时数据库是空白的,而空白数据库总是一致的。
如果 Redis 启动了 AOF ,那么,只会有部分的事务操作被记录到 AOF 日志中。此时是不保证原子性的。
但是,Redis 提供了 redis-check-aof 工具来检测 AOF 文件,可以把 未完成的事务操作从 AOF 中删除。
这样一来,使用 AOF 恢复实例后,保证了事务的原子性。
如果 Redis 使用 RDB,那么 在执行事务的过程中是不会执行 RDB 的,所以使用 RDB 恢复实例后,也能 保证原子性。
不过因为入队操作成功,执行错误,Redis 还是会执行正确的命令,因此总的来说 Redis 是不支持原子性的。
这里提一下对原子性的影响。
服务器停机
保证一致性
因为 Redis 是使用单线程来执行事务(以及事务中的命令),并且服务器保证,在执行事务期间不会对事务进行中断。
因此,Redis 的事务总是以串行的方式运行的,所以事务总是有隔离性的。
保证隔离性
Redis 的事务不过是简单地使用队列包裹起了一组 Redis 命令,Redis 并没有为事务提供任何额外的持久化功能,所以 Redis 的持久化性由所使用的持久化机制决定。
当未启动任何持久化模式时,一旦停机,内存中的数据全部消失,不保证持久性。
当使用 RDB 持久化模式时,我们一般不会使用 save 来同步持久化,因为这样性能太低了。使用 bgsave 进行持久化时,服务器只会在特定的保存条件被满足时,才会执行,而且 bgsave是异步执行的,因此也 不能保证持久性。
当使用 AOF 持久化模式时,如果 appendfsync 选项为 always 时,程序总会在执行命令后调用 fsync(),将数据刷入磁盘,因此 具有持久性。其他两个选项(no,everysec)不能保证持久性。
不过需要注意,在 开启 no-appendfsync-on-rewrite 配置后,会对 AOF 重写操作有影响,进而影响持久性。
bgrewriteaof 机制解决了 AOF 文件过大问题,而且是在一个子进程中进行 AOF 的重写,从而不阻塞主进程对其余命令的处理。
但是如果同时执行 bgrewriteaof 操作和主进程写 AOF 文件的操作,两者都会操作磁盘,而且 bgrewriteaof 往往会涉及大量磁盘操作,这样就会 造成主进程在写 AOF 文件的时候出现阻塞的情形。
所以 no-appendfsync-on-rewrite 参数就是用来解决上面问题的:如果该参数设置为 no,是最安全的方式,不会丢失数据,但是要忍受阻塞的问题。如果设置为 yes,这就相当于将 appendfsync 设置为 no,这说明并没有执行磁盘操作,只是写入了缓冲区,因此这样并不会造成阻塞(因为没有竞争磁盘)。
但是如果这个时候 Redis 挂掉了,就会丢失数据。在 linux 默认设置下,最多会丢失 30s 的数据。
no-appendfsync-on-rewrite 默认为 no,这样是不会影响 appendfsync 为 always 时的持久性的。
但是,如果将 no-appendfsync-on-rewrite 设为 yes,即使 appendfsync 为 always 时,也会影响持久性。
不保证持久性
Redis 事务满足 ACID 吗
WATCH 命令是一个乐观锁,它可以在 EXEC 命令执行前,监视任意数量的数据库键,并在 EXEC 命令执行时,检查被监视的键是否以已经被更改过,如果是的话,则拒绝执行事务,并向客户端返回代表事务执行失败的空回复。
注意:EXEC 命令执行前只是将事务中的命令全部入队,并没有执行,所以此时服务器还可以执行其他客户端的命令,进而出现入队的命令中的 key 被更改。
使用方法很简单直接在 MULTI 命令前执行 WATCH \"key\" ... 即可。
WATCH 命令
Redis 事务
延迟队列是指把当前要做的事情,往后推迟一段时间再做。延迟队列的常见使用场景有以下几种:在淘宝、京东等购物平台上下单,超过一定时间未付款,订单会自动取消;打车的时候,在规定时间没有车主接单,平台会取消你的单并提醒你暂时没有车主接单;点外卖的时候,如果商家在10分钟还没接单,就会自动取消订单;
在 Redis 可以使用 有序集合(ZSet)的方式来实现延迟消息队列,ZSet 有一个 Score 属性可以用来存储延迟执行的时间。
使用 zadd score1 value1 命令就可以一直往内存中生产消息。再利用 zrangebysocre 查询符合条件的所有待处理的任务, 通过循环执行队列任务即可。
Redis 如何实现延迟队列?
大 key 并不是指 key 的值很大,而是 key 对应的 value 很大。
一般而言,下面这两种情况被称为大 key:String 类型的值大于 10 KB;Hash、List、Set、ZSet 类型的元素的个数超过 5000个;
什么是 Redis 大 key?
大 key 会带来以下四种影响:客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。引发网络阻塞。每次 获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。
大 key 会造成什么问题?
可以通过 redis-cli --bigkeys 命令查找大 key:
使用的时候注意事项:最好选择在从节点上执行该命令。因为主节点上执行时,会阻塞主节点;如果没有从节点,那么可以选择在 Redis 实例业务压力的低峰阶段进行扫描查询,以免影响到实例的正常运行;或者可以使用 -i 参数控制扫描间隔,避免长时间扫描降低 Redis 实例的性能。
该方式的不足之处:这个方法只能返回每种类型中最大的那个 bigkey,无法得到大小排在前 N 位的 bigkey;对于集合类型来说,这个方法只统计集合元素个数的多少,而不是实际占用的内存量。但是,一个集合中的元素个数多,并不一定占用的内存就多。因为,有可能每个元素占用的内存很小,这样的话,即使元素个数有很多,总内存开销也不大;
1、redis-cli --bigkeys 查找大 key
使用 SCAN 命令对数据库扫描,然后用 TYPE 命令获取返回的每一个 key 的类型。
对于 String 类型,可以直接使用 STRLEN 命令获取字符串的长度,也就是占用的内存空间字节数。
对于集合类型来说,有两种方法可以获得它占用的内存大小:如果能够预先从业务层知道集合元素的平均大小,那么,可以使用下面的命令获取集合元素的个数,然后乘以集合元素的平均大小,这样就能获得集合占用的内存大小了。List 类型:LLEN 命令;Hash 类型:HLEN 命令;Set 类型:SCARD 命令;Sorted Set 类型:ZCARD 命令;如果不能提前知道写入集合的元素大小,可以使用 MEMORY USAGE 命令(需要 Redis 4.0 及以上版本),查询一个键值对占用的内存空间。
2、使用 SCAN 命令查找大 key
使用 RdbTools 第三方开源工具,可以用来解析 Redis 快照(RDB)文件,找到其中的大 key。
比如,下面这条命令,将大于 10 kb 的 key 输出到一个表格文件。
3、使用 RdbTools 工具查找大 key
如何找到大 key ?
删除操作的本质是要释放键值对占用的内存空间,不要小瞧内存的释放过程。
释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。
所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。
因此,删除大 key 这一个动作,我们要小心。具体要怎么做呢?这里给出两种方法:分批次删除异步删除(Redis 4.0版本以上)
对于删除大 Hash,使用 hscan 命令,每次获取 100 个字段,再用 hdel 命令,每次删除 1 个字段。Python代码:
对于删除大 List,通过 ltrim 命令,每次删除少量元素。Python代码:
对于删除大 Set,使用 sscan 命令,每次扫描集合中 100 个元素,再用 srem 命令每次删除一个键。Python代码:
对于删除大 ZSet,使用 zremrangebyrank 命令,每次删除 top 100个元素。Python代码:
1、分批次删除
从 Redis 4.0 版本开始,可以采用 异步删除法,用 unlink 命令代替 del 来删除。
这样 Redis 会将这个 key 放入到一个异步线程中进行删除,这样不会阻塞主线程。
除了主动调用 unlink 命令实现异步删除之外,我们还可以通过配置参数,达到某些条件的时候自动进行异步删除。
主要有 4 种场景,默认都是关闭的:
它们代表的含义如下:lazyfree-lazy-eviction:表示当 Redis 运行内存超过 maxmeory 时,是否开启 lazy free 机制删除;lazyfree-lazy-expire:表示设置了过期时间的键值,当过期之后是否开启 lazy free 机制删除;lazyfree-lazy-server-del:有些指令在处理已存在的键时,会带有一个隐式的 del 键的操作,比如 rename 命令,当目标键已存在,Redis 会先删除目标键,如果这些目标键是一个 big key,就会造成阻塞删除的问题,此配置表示在这种场景中是否开启 lazy free 机制删除;slave-lazy-flush:针对 slave (从节点) 进行全量数据同步,slave 在加载 master 的 RDB 文件前,会运行 flushall 来清理自己的数据,它表示此时是否开启 lazy free 机制删除。
建议开启其中的 lazyfree-lazy-eviction、lazyfree-lazy-expire、lazyfree-lazy-server-del 等配置,这样就可以有效的提高主线程的执行效率。
2、异步删除
如何删除大 key?
Redis 的大 key 如何处理?
管道技术(Pipeline)是客户端提供的一种批处理技术,用于一次处理多个 Redis 命令,从而提高整个交互的性能。
普通命令模式,如下图所示:
管道模式,如下图所示:
使用管道技术可以 解决多个命令执行时的网络等待,它是把多个命令整合到一起发送给服务器端处理之后统一返回给客户端,这样就免去了每条命令执行后都要等待的情况,从而有效地提高了程序的执行效率。
但使用管道技术也要注意避免发送的命令过大,或管道内的数据太多而导致的网络阻塞。
要注意的是,管道技术本质上是客户端提供的功能,而非 Redis 服务器端的功能。
Redis 管道有什么用?
MySQL 在执行事务时,会提供回滚机制,当事务执行发生错误时,事务中的所有操作都会撤销,已经修改的数据也会被恢复到事务执行前的状态。
Redis 中并没有提供回滚机制,虽然 Redis 提供了 DISCARD 命令,但是这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。
下面是 DISCARD 命令用法:
事务执行过程中,如果命令入队时没报错,而事务提交后,实际执行时报错了,正确的命令依然可以正常执行,所以这可以看出 Redis 并不一定保证原子性(原子性:事务中的命令要不全部成功,要不全部失败)。
比如下面这个例子:
为什么Redis 不支持事务回滚?
Redis 官方文档的解释如下:
大概的意思是,作者不支持事务回滚的原因有以下两个:他认为 Redis 事务的执行时,错误通常都是编程错误造成的,这种错误通常只会出现在开发环境中,而很少会在实际的生产环境中出现,所以他认为没有必要为 Redis 开发事务回滚功能;不支持事务回滚是因为这种复杂的功能和 Redis 追求的简单高效的设计主旨不符合。
这里不支持事务回滚,指的是不支持事务 运行时错误的事务回滚。
Redis 事务支持回滚吗?
分布式锁是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用。如下图所示:
Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且 Redis 的读写性能高,可以应对高并发的锁操作场景。
Redis 的 SET 命令有个 NX 参数可以实现「key不存在才插入」,所以可以用它来实现分布式锁:如果 key 不存在,则显示插入成功,可以用来表示加锁成功;如果 key 存在,则会显示插入失败,可以用来表示加锁失败。
基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件:加锁 包括了读取锁变量、检查锁变量值和设置锁变量值 三个操作,但需要 以原子操作的方式完成,所以,我们 使用 SET 命令带上 NX 选项来实现加锁;锁变量需要 设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端;
满足这三个条件的分布式命令如下:lock_key 就是 key 键;unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作;NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。
而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。
可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。
这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。
基于 Redis 实现分布式锁的 优点:性能高效(这是选择缓存实现分布式锁最核心的出发点)。实现方便。很多研发工程师选择使用 Redis 来实现分布式锁,很大成分上是因为 Redis 提供了 setnx 方法,实现分布式锁很方便。避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)。
基于 Redis 实现分布式锁的 缺点:超时时间不好设置。如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短会保护不到共享资源。比如在有些场景中,一个线程 A 获取到了锁之后,由于业务代码执行时间可能比较长,导致超过了锁的超时时间,自动失效,注意 A 线程没执行完,后续线程 B 又意外的持有了锁,意味着可以操作共享资源,那么两个线程之间的共享资源就没办法进行保护了。那么如何合理设置超时时间呢? 我们可以 基于续约的方式 设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。实现方式就是:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可,不过这种方式实现起来相对复杂。Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。
基于 Redis 实现分布式锁有什么优缺点?
为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个 分布式锁算法 Redlock(红锁)。
它是基于 多个 Redis 节点 的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。
Redlock 算法的基本思路:让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端 能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。
这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。
Redlock 算法加锁三个过程:第一步是,客户端获取当前时间(t1)。第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作: (1)加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。 (2)如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行, 我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」 设置超时时间),加锁操作的超时时间需要远远小于锁的过期时间,一般也就是设置为几十毫秒。第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。
可以看到,加锁成功要同时满足两个条件(简述:如果有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功):条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁;条件二:客户端从大多数节点获取锁的总耗时(t2-t1)小于锁设置的过期时间。
加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。
加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。
Redis 如何解决集群情况下分布式锁的可靠性?
如何用 Redis 实现分布式锁的?
Redis 实战
面试篇
String 是最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value 其实不仅是字符串, 也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是 512M。
String 类型的底层的数据结构实现主要是 int 和 SDS(简单动态字符串)。
SDS 和我们认识的 C 字符串不太一样,之所以没有使用 C 语言的字符串表示,因为 SDS 相比于 C 的原生字符串:SDS 不仅可以保存文本数据,还可以保存二进制数据。因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会 以处理二进制的方式来处理 SDS 存放在 buf[] 数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。SDS 获取字符串长度的时间复杂度是 O(1)。因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);而 SDS 结构里用 len 属性记录了字符串长度,所以复杂度为 O(1)。Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。
字符串对象的内部编码(encoding)有 3 种 :int、raw和 embstr。
如果一个字符串对象保存的是 整数值,并且这个 整数值可以用 long 类型来表示,那么字符串对象会将整数值保存在字符串对象结构的 ptr 属性里面(将 void* 转换成 long),并将字符串对象的编码设置为 int。
如果字符串对象保存的是一个字符串,并且这个 字符申的长度小于等于 32 字节(redis 2.+版本),那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为 embstr, embstr 编码是专门用于保存短字符串的一种优化编码方式:
如果字符串对象保存的是一个字符串,并且这个 字符串的长度大于 32 字节(redis 2.+版本),那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的 编码设置为 raw:
注意,embstr 编码和 raw 编码的边界在 redis 不同版本中是不一样的:redis 2.+ 是 32 字节redis 3.0-4.0 是 39 字节redis 5.0 是 44 字节
可以看到 embstr 和 raw 编码都会使用 SDS 来保存值,但不同之处在于:embstr 会通过一次内存分配函数来分配一块连续的内存空间 来保存 redisObject 和 SDS,因此 redisObject 和 SDS 是连续的;raw 编码会通过调用两次内存分配函数来分别分配两块空间 来保存 redisObject 和 SDS,分别为 redis 和 SDS 分配空间;Redis 这样做会有很多 好处:embstr 编码将创建字符串对象所需的 内存分配次数 从 raw 编码的两次 降低为一次;释放 embstr 编码的字符串对象同样只需要 调用一次内存释放函数;因为 embstr 编码的字符串对象的 所有数据 都保存在一块 连续的内存 里,可以更好的利用 CPU 缓存提升性能。
但是 embstr 也有 缺点 的:如果字符串的 长度增加 需要重新分配内存时,整个 redisObject 和 sds 都需要 重新分配空间,所以 embstr 编码的字符串 对象实际上是只读的,redis 没有为 embstr 编码的字符串对象编写任何相应的修改程序。当我们对 embstr 编码的字符串 对象执行任何修改命令(例如 append)时,程序会 先将对象的编码从 embstr 转换成 raw,然后再执行修改命令。
内部实现
普通字符串的基本操作:
批量设置 :
计数器(字符串的内容为整数的时候可以使用):
过期(默认为永不过期):
不存在才插入:
常用命令
使用 String 来缓存对象有两种方式:直接缓存整个对象的 JSON,命令例子: SET user:1 '{\"name\":\"xiaolin\
缓存对象
因为 Redis 处理命令是单线程,所以执行命令的过程是原子的。因此 String 数据类型适合计数场景,比如计算 访问次数、点赞、转发、库存数量 等等。
比如计算文章的阅读量:
常规计数
SET 命令有个 NX 参数可以实现「key不存在才插入」,可以用它来实现分布式锁:如果 key 不存在,则显示插入成功,可以用来表示加锁成功;如果 key 存在,则会显示插入失败,可以用来表示加锁失败。
满足这三个条件的分布式命令如下:lock_key 就是 key 键;unique_value 是客户端生成的唯一的标识;NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。
而解锁的过程就是将 lock_key 键删除,但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。
分布式锁
通常我们在开发后台管理系统时,会使用 Session 来保存用户的会话(登录)状态,这些 Session 信息会被保存在服务器端,但这只适用于单系统应用,如果是分布式系统此模式将不再适用 (会出现分布式 session 问题)。
例如用户一的 Session 信息被存储在服务器一,但第二次访问时用户一被分配到服务器二,这个时候服务器并没有用户一的 Session 信息,就会出现需要重复登录的问题,问题在于分布式系统每次会把请求随机分配到不同的服务器。
分布式系统单独存储 Session 流程图:
因此,我们需要借助 Redis 对这些 Session 信息进行统一的存储和管理,这样无论请求发送到那台服务器,服务器都会去同一个 Redis 获取相关的 Session 信息,这样就解决了分布式系统下 Session 存储的问题。
分布式系统使用同一个 Redis 存储 Session 流程图:
共享 Session 信息
应用场景
String
List 列表是简单的字符串列表,按照 插入顺序排序,可以从头部或尾部向 List 列表添加元素。
列表的最大长度为 2^32 - 1,也即每个列表支持超过 40 亿个元素。
List 类型的底层数据结构是由 双向链表或压缩列表 实现的:如果列表的 元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的 值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用 压缩列表 作为 List 类型的底层数据结构;如果列表的元素 不满足上面的条件,Redis 会使用 双向链表 作为 List 类型的底层数据结构;
但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。
具体的数据结构在后面有详解。
消息队列在存取消息时,必须要满足三个需求,分别是 消息保序、处理重复的消息和保证消息可靠性。
Redis 的 List 和 Stream 两种数据类型,就可以满足消息队列的这三个需求。我们先来了解下基于 List 的消息队列实现方法,后面在介绍 Stream 数据类型时候,在详细说说 Stream。
List 本身就是按先进先出的顺序对数据进行存取的,所以,如果使用 List 作为消息队列保存消息的话,就已经能满足消息保序的需求了。
List 可以使用 LPUSH + RPOP (或者反过来,RPUSH+LPOP)命令实现消息队列:生产者使用 LPUSH key value[value...] 将消息插入到队列的头部,如果 key 不存在则会创建一个空的队列再插入消息。消费者使用 RPOP key 依次读取队列的消息,先进先出。
不过,在消费者读取数据时,有一个潜在的性能风险点。
在生产者往 List 中写入数据时,List 并不会主动地通知消费者有新消息写入,如果消费者想要及时处理消息,就需要在程序中不停地调用 RPOP 命令(比如使用一个while(1)循环)。如果有新消息写入,RPOP命令就会返回结果,否则,RPOP命令返回空值,再继续循环。
所以,即使没有新消息写入List,消费者也要不停地调用 RPOP 命令,这就会导致消费者程序的 CPU 一直消耗在执行 RPOP 命令上,带来不必要的性能损失。
为了解决这个问题,Redis 提供了 BRPOP 命令。BRPOP 命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。和消费者程序自己不停地调用 RPOP 命令相比,这种方式能节省 CPU 开销。
1、如何满足消息保序需求?
消费者要实现重复消息的判断,需要 2 个方面的要求:每个消息都有一个全局的 ID。消费者要记录已经处理过的消息的 ID。当收到一条消息后,消费者程序就可以对比收到的消息 ID 和记录的已处理过的消息 ID,来判断当前收到的消息有没有经过处理。如果已经处理过,那么,消费者程序就不再进行处理了。
但是 List 并不会为每个消息生成 ID 号,所以我们需要自行为每个消息生成一个全局唯一 ID,生成之后,我们在用 LPUSH 命令把消息插入 List 时,需要在消息中包含这个全局唯一 ID。
例如,我们执行以下命令,就把一条全局 ID 为 111000102、库存量为 99 的消息插入了消息队列:
2、如何处理重复的消息?
当消费者程序从 List 中读取一条消息后,List 就不会再留存这条消息了。所以,如果消费者程序在处理消息的过程出现了故障或宕机,就会导致消息没有处理完成,那么,消费者程序再次启动后,就没法再次从 List 中读取消息了。
为了留存消息,List 类型提供了 BRPOPLPUSH 命令,这个命令的作用是让消费者程序 从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存。
这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份 List 中重新读取消息并进行处理了。
3、如何保证消息可靠性?
好了,到这里可以知道基于 List 类型的消息队列,满足消息队列的三大需求:消息保序:使用 LPUSH + RPOP;阻塞读取:使用 BRPOP;重复消息处理:生产者自行实现全局唯一 ID;消息的可靠性:使用 BRPOPLPUSH;
List 不支持多个消费者消费同一条消息,因为一旦消费者拉取一条消息后,这条消息就从 List 中删除了,无法被其它消费者再次消费。
要实现一条消息可以被多个消费者消费,那么就要将多个消费者组成一个消费组,使得多个消费者可以消费同一条消息,但是 List 类型并不支持消费组的实现。
不过,从 Redis 5.0 版本开始提供的 Stream 数据类型了,Stream 同样能够满足消息队列的三大需求,而且它还支持「消费组」形式的消息读取。
List 作为消息队列有什么 缺陷?
消息队列
List
Hash 是一个 键值对(key - value)集合,其中 value 的形式如: value=[{field1,value1},...{fieldN,valueN}]。特别适合用于存储对象。
Hash 与 String 对象的区别如下所示:
Hash 类型的底层数据结构是由 压缩列表或哈希表 实现的:如果哈希类型 元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有 值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;如果哈希类型元素不满足上面条件,Redis 会使用 哈希表 作为 Hash 类型的 底层数据结构。
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
Hash 类型的 (key,field, value) 的结构与对象的(对象id, 属性, 值)的结构相似,也可以用来存储对象。
我们以用户信息为例,它在关系型数据库中的结构是这样的:
我们可以使用如下命令,将用户对象的信息存储到 Hash 类型:
Redis Hash 存储其结构如下图:
在介绍 String 类型的应用场景时有所介绍,String + Json 也是存储对象的一种方式,那么存储对象时,到底用 String + json 还是用 Hash 呢?
一般对象用 String + Json 存储,对象中 某些频繁变化的属性可以考虑抽出来用 Hash 类型存储。
以用户 id 为 key,商品 id 为 field,商品数量为 value,恰好构成了购物车的3个要素,如下图所示:
涉及的命令如下:添加商品:HSET cart:{用户id} {商品id} 1添加数量:HINCRBY cart:{用户id} {商品id} 1商品总数:HLEN cart:{用户id}删除商品:HDEL cart:{用户id} {商品id}获取购物车所有商品:HGETALL cart:{用户id}
当前仅仅是将商品ID存储到了Redis 中,在回显商品具体信息的时候,还需要拿着商品 id 查询一次数据库,获取完整的商品的信息。
购物车
Hash
Set 类型是一个 无序并唯一 的 键值集合,它的存储顺序不会按照插入的先后顺序进行存储。
一个集合最多可以存储 2^32-1 个元素。概念和数学中个的集合基本类似,可以交集,并集,差集等等,所以 Set 类型除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集。
Set 类型和 List 类型的区别如下:List 可以存储重复元素,Set 只能存储非重复元素;List 是按照元素的先后顺序存储元素的,而 Set 则是无序方式存储元素的。
Set 类型的底层数据结构是由 哈希表或整数集合 实现的:如果集合中的 元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用 整数集合 作为 Set 类型的底层数据结构;如果集合中的元素 不满足上面条件,则 Redis 使用 哈希表 作为 Set 类型的底层数据结构。key 为 set 的值,value 为 null。
Set 常用操作:
Set 运算操作:
集合的主要几个特性:无序、不可重复、支持并交 差等操作。
因此 Set 类型比较适合用来数据去重和保障数据的唯一性,还可以用来统计多个集合的交集、错集和并集等,当我们存储的数据是无序并且需要去重的情况下,比较适合使用集合类型进行存储。
但是要提醒你一下,这里有一个潜在的风险。Set 的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致 Redis 实例阻塞。
在主从集群中,为了避免主库因为 Set 做聚合计算(交集、差集、并集)时导致主库被阻塞,我们可以选择一个从库完成聚合统计,或者把数据返回给客户端,由客户端来完成聚合统计。
Set 类型可以保证一个用户只能点一个赞,这里举例子一个场景,key 是文章id,value 是用户id。
uid:1 、uid:2、uid:3 三个用户分别对 article:1 文章点赞了。
uid:1 取消了对 article:1 文章点赞。
获取 article:1 文章所有点赞用户 :
获取 article:1 文章的点赞用户数量:
判断用户 uid:1 是否对文章 article:1 点赞了:
点赞
Set 类型支持交集运算,所以可以用来计算共同关注的好友、公众号等。
key 可以是用户id,value 则是已关注的公众号的id。
uid:1 用户关注公众号 id 为 5、6、7、8、9,uid:2 用户关注公众号 id 为 7、8、9、10、11。
uid:1 和 uid:2 共同关注的公众号:
给 uid:2 推荐 uid:1 关注的公众号:
验证某个公众号是否同时被 uid:1 或 uid:2 关注:
共同关注
存储某活动中中奖的用户名 ,Set 类型因为有去重功能,可以保证同一个用户不会中奖两次。
key为抽奖活动名,value为员工名称,把所有员工名称放入抽奖箱 :
如果允许重复中奖,可以使用 SRANDMEMBER 命令。
如果不允许重复中奖,可以使用 SPOP 命令。
抽奖活动
Set
Zset 类型(有序集合类型)相比于 Set 类型多了一个 排序属性 score(分值),对于有序集合 ZSet 来说,每个存储元素相当于有两个值组成的,一个是有序结合的元素值,一个是排序值。
有序集合保留了集合不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。
Zset 类型的底层数据结构是由压缩列表或跳表实现的:如果有序集合的元素个数小于 128 个,并且每个元素的 值小于 64 字节 时,Redis 会使用 压缩列表 作为 Zset 类型的底层数据结构;如果有序集合的元素 不满足上面的条件,Redis 会使用 跳表 作为 Zset 类型的底层数据结构;
Zset 常用操作:
Zset 运算操作(相比于 Set 类型,ZSet 类型没有支持差集运算):
Zset 类型(Sorted Set,有序集合) 可以根据元素的权重来排序,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入 Sorted Set 的时间确定权重值,先插入的元素权重小,后插入的元素权重大。
在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,可以优先考虑使用 Sorted Set。
有序集合比较典型的使用场景就是排行榜。例如学生成绩的排名榜、视频播放排名、商品的销量排名等。
我们以博文点赞排名为例,小林发表了五篇博文,分别获得赞为 200、40、100、50、150。
文章 arcticle:4 新增一个赞,可以使用 ZINCRBY 命令(为有序集合key中元素member的分值加上increment):
查看某篇文章的赞数,可以使用 ZSCORE 命令(返回有序集合key中元素个数):
获取小林文章赞数最多的 3 篇文章,可以使用 ZREVRANGE 命令(倒序获取有序集合 key 从start下标到stop下标的元素):
获取小林 100 赞到 200 赞的文章,可以使用 ZRANGEBYSCORE 命令(返回有序集合中指定分数区间内的成员,分数由低到高排序):
排行榜
使用有序集合的 ZRANGEBYLEX 或 ZREVRANGEBYLEX 可以帮助我们实现电话号码或姓名的排序,我们以 ZRANGEBYLEX (返回指定成员区间内的成员,按 key 正序排列,分数必须相同)为例。
注意:不要在分数不一致的 SortSet 集合中去使用 ZRANGEBYLEX和 ZREVRANGEBYLEX 指令,因为获取的结果会不准确。
我们可以将电话号码存储到 SortSet 中,然后根据需要来获取号段:
获取所有号码:
获取132、133号段的号码:
1、电话排序
获取所有人的名字:
获取名字中大写字母A开头的所有人:
获取名字中大写字母 C 到 Z 的所有人:
2、姓名排序
电话、姓名排序
有什么数据结构可以既存储任务描述,又能存储任务执行时间,还能根据任务执行时间进行排序呢?
那肯定是 Zset 了。我们可以把任务的描述存到 Zset 的 value 中,执行时间作为 score。
利用 Zset 天然的排序特性,执行时刻越早的会排在前面。
这样一来,我们只要启动一个或多个定时线程,定期去查一下这个 Zset 中 score 小于等于当前时间的元素(通过 zrangebyscore),然后执行该任务描述对应的任务即可。
当然,执行完任务后,还要将元素从 Zset 中删除,避免任务重复执行。
如果是多个线程去轮询这个 Zset,还有考虑并发问题,假如说一个任务到期了,也被多个线程拿到了,这个时候必须保证只有一个线程能执行这个任务。这可以通过 zrem 命令来实现,只有删除成功了,才能执行任务,这样就能保证任务不被多个任务重复执行了。
如果任务执行失败,还需要将该任务重新放入 Zset 中,避免任务丢失。
延时任务
Zset
Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。
BitMap 通过最小的单位 bit 来进行 0|1 的设置,表示某个元素的值或者状态,时间复杂度为 O(1)。
由于 bit 是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些 数据量大 且使用 二值统计的场景。
Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。
String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态,你可以把 Bitmap 看作是一个 bit 数组。
bitmap 基本操作:
bitmap 运算操作:
Bitmap 类型非常适合二值状态统计的场景,这里的二值状态就是指集合元素的取值就只有 0 和 1 两种,在记录海量数据时,Bitmap 能够有效地节省内存空间。
在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态。
签到统计时,每个用户一天的签到用 1 个 bit 位就能表示,一个月(假设是 31 天)的签到情况用 31 个 bit 位就可以,而一年的签到也只需要用 365 个 bit 位,根本不用太复杂的集合类型。
假设我们要统计 ID 100 的用户在 2022 年 6 月份的签到情况,就可以按照下面的步骤进行操作。
第一步,执行下面的命令,记录该用户 6 月 3 号已签到。
第二步,检查该用户 6 月 3 日是否签到。
第三步,统计该用户在 6 月份的签到次数。
这样,我们就知道该用户在 6 月份的签到情况了。
Redis 提供了 BITPOS key bitValue [start] [end] 指令,返回数据表示 Bitmap 中第一个值为 bitValue 的 offset 位置。
在默认情况下, 命令将检测整个位图, 用户可以通过可选的 start 参数和 end 参数指定要检测的范围。所以我们可以通过执行这条命令来获取 userID = 100 在 2022 年 6 月份首次打卡日期:
需要注意的是,因为 offset 从 0 开始的,所以我们需要将返回的 value + 1 。
如何统计这个月首次打卡时间呢?
签到统计
Bitmap 提供了 GETBIT、SETBIT 操作,通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行读写操作,需要注意的是 offset 从 0 开始。
只需要一个 key = login_status 表示存储用户登陆状态集合数据, 将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 GETBIT判断对应的用户是否在线。 50000 万 用户只需要 6 MB 的空间。
假如我们要判断 ID = 10086 的用户的登陆情况:
第一步,执行以下指令,表示用户已登录。
第二步,检查该用户是否登陆,返回值 1 表示已登录。
第三步,登出,将 offset 对应的 value 设置成 0。
判断用户登陆态
如何统计出这连续 7 天连续打卡用户总数呢?
我们把每天的日期作为 Bitmap 的 key,userId 作为 offset,若是打卡则将 offset 位置的 bit 设置成 1。
key 对应的集合的每个 bit 位的数据则是一个用户在该日期的打卡记录。
一共有 7 个这样的 Bitmap,如果我们能对这 7 个 Bitmap 的对应的 bit 位做 『与』运算。同样的 UserID offset 都是一样的,当一个 userID 在 7 个 Bitmap 对应对应的 offset 位置的 bit = 1 就说明该用户 7 天连续打卡。
结果保存到一个新 Bitmap 中,我们再通过 BITCOUNT 统计 bit = 1 的个数便得到了连续打卡 7 天的用户总数了。
Redis 提供了 BITOP operation destkey key [key ...] 这个指令用于对一个或者多个 key 的 Bitmap 进行位元操作:operation 可以是 and、OR、NOT、XOR。当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0 。空的 key 也被看作是包含 0 的字符串序列。
假设要统计 3 天连续打卡的用户数,则是将三个 bitmap 进行 AND 操作,并将结果保存到 destmap 中,接着对 destmap 执行 BITCOUNT 统计,如下命令:
即使一天产生一个亿的数据,Bitmap 占用的内存也不大,大约占 12 MB 的内存(10^8/8/1024/1024),7 天的 Bitmap 的内存开销约为 84 MB。同时我们最好给 Bitmap 设置过期时间,让 Redis 删除过期的打卡数据,节省内存。
连续签到用户总数
BitMap
Redis HyperLogLog 是 Redis 2.8.9 版本新增的数据类型,是一种用于「统计基数」的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,HyperLogLog 是统计规则是基于概率完成的,不是非常准确,标准误算率是 0.81%。
所以,简单来说 HyperLogLog 提供 不精确的去重计数。
HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的内存空间总是固定的、并且是很小的。
在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。
这什么概念?举个例子给大家对比一下。
用 Java 语言来说,一般 long 类型占用 8 字节,而 1 字节有 8 位,即:1 byte = 8 bit,即 long 数据类型最大可以表示的数是:2^63-1。对应上面的2^64个数,假设此时有2^63-1这么多个数,从 0 ~ 2^63-1,按照long以及1k = 1024 字节的规则来计算内存总数,就是:((2^63-1) * 8/1024)K,这是很庞大的一个数,存储空间远远超过12K,而 HyperLogLog 却可以用 12K 就能统计完。
HyperLogLog 的实现涉及到很多数学问题,太费脑子了。
HyperLogLog 命令很少,就三个。
Redis HyperLogLog 优势在于只需要花费 12 KB 内存,就可以计算接近 2^64 个元素的基数,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。
所以,非常适合统计百万级以上的网页 UV 的场景。
在统计 UV 时,你可以用 PFADD 命令(用于向 HyperLogLog 中添加新元素)把访问页面的每个用户都添加到 HyperLogLog 中。
接下来,就可以用 PFCOUNT 命令直接获得 page1 的 UV 值了,这个命令的作用就是返回 HyperLogLog 的统计结果。
不过,有一点需要你注意一下,HyperLogLog 的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是 0.81%。
这也就意味着,你使用 HyperLogLog 统计的 UV 是 100 万,但实际的 UV 可能是 101 万。虽然误差率不算大,但是,如果你需要精确统计结果的话,最好还是继续用 Set 或 Hash 类型。
百万级网页 UV 计数
HyperLogLog
Redis GEO 是 Redis 3.2 版本新增的数据类型,主要用于存储地理位置信息,并对存储的信息进行操作。
在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based Service,LBS)的应用。LBS 应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO 就非常适合应用在 LBS 服务的场景中。
GEO 本身并没有设计新的底层数据结构,而是直接使用了 Sorted Set 集合类型。
GEO 类型使用 GeoHash 编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。
这样一来,我们就可以把经纬度保存到 Sorted Set 中,利用 Sorted Set 提供的“按权重进行有序范围查找”的特性,实现 LBS 服务中频繁使用的“搜索附近”的需求。
这里以滴滴叫车的场景为例,介绍下具体如何使用 GEO 命令:GEOADD 和 GEORADIUS 这两个命令。
假设车辆 ID 是 33,经纬度位置是(116.034579,39.030452),我们可以用一个 GEO 集合保存所有车辆的经纬度,集合 key 是 cars:locations。
执行下面的这个命令,就可以把 ID 号为 33 的车辆的当前经纬度位置存入 GEO 集合中:
当用户想要寻找自己附近的网约车时,LBS 应用就可以使用 GEORADIUS 命令。
例如,LBS 应用执行下面的命令时,Redis 会根据输入的用户的经纬度信息(116.054579,39.030452 ),查找以这个经纬度为中心的 5 公里内的车辆信息,并返回给 LBS 应用。
滴滴叫车
GEO
Redis Stream 是 Redis 5.0 版本新增加的数据类型,Redis 专门为消息队列设计的数据类型。
在 Redis 5.0 Stream 没出来之前,消息队列的实现方式都有着各自的缺陷,例如:发布订阅模式,不能持久化也就无法可靠的保存消息,并且对于离线重连的客户端不能读取历史消息的缺陷;List 实现消息队列的方式不能重复消费,一个消息消费完就会被删除,而且生产者需要自行实现全局唯一 ID。
基于以上问题,Redis 5.0 便推出了 Stream 类型也是此版本最重要的功能,用于完美地实现消息队列,它支持消息的持久化、支持自动生成全局唯一 ID、支持 ack 确认消息的模式、支持消费组模式等,让消息队列更加的稳定和可靠。
Stream (未完)
Redis 常见的五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)及 Zset (sorted set:有序集合)。
这五种数据类型都由多种数据结构实现的,主要是出于时间和空间的考虑,当数据量小的时候使用更简单的数据结构,有利于节省内存,提高性能。
这五种数据类型与底层数据结构对应关系图如下,左边是 Redis 3.0版本的,也就是《Redis 设计与实现》这本书讲解的版本,现在看还是有点过时了,右边是现在 Github 最新的 Redis 代码的。
Redis 五种数据类型的应用场景:String:缓存对象、常规计数、分布式锁、共享 session 信息等。List:消息队列(有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。Hash:缓存对象、购物车等。Set:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。Zset:排序场景,比如排行榜、电话和姓名排序等。
针对 Redis 是否适合做消息队列,关键看你的业务场景:如果你的业务场景足够简单,对于数据丢失不敏感,而且消息积压概率比较小的情况下,把 Redis 当作队列是完全可以的。如果你的业务有海量消息,消息积压的概率比较大,并且不能接受数据丢失,那么还是用专业的消息队列中间件吧。
总结
Redis 数据类型和应用场景
Redis 为什么那么快?
除了它是内存数据库,使得所有的操作都在内存上进行之外,还有一个重要因素,它 实现的数据结构,使得我们对数据进行增删查改操作时,Redis 能高效的处理。
注意,Redis 数据结构并不是指 String(字符串)对象、List(列表)对象、Hash(哈希)对象、Set(集合)对象和 Zset(有序集合)对象,因为这些是 Redis 键值对中 值的数据类型,也就是 数据的保存形式,这些对象的底层实现的方式就用到了数据结构。
下图是 Redis 数据类型(也叫 Redis 对象)和底层数据结构的对应关图,左边是 Redis 3.0 版本的,也就是《Redis 设计与实现》这本书讲解的版本,现在看还是有点过时了,右边是现在 Github 最新的 Redis 代码的(还未发布正式版本)。
前提概要
在开始讲数据结构之前,先给介绍下 Redis 是怎样实现键值对(key-value)数据库的。
Redis 的键值对中的 key 就是字符串对象,而 value 可以是字符串对象,也可以是集合数据类型的对象,比如 List 对象、Hash 对象、Set 对象和 Zset 对象。
举个例子,我这里列出几种 Redis 新增键值对的命令:
这些命令代表着:第一条命令:name 是一个字符串键,因为键的值是一个字符串对象;第二条命令:person 是一个哈希表键,因为键的值是一个包含两个键值对的哈希表对象;第三条命令:stu 是一个列表键,因为键的值是一个包含两个元素的列表对象;
这些键值对是如何保存在 Redis 中的呢?
Redis 是使用了一个「哈希表」保存所有键值对,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对。哈希表其实就是一个数组,数组中的元素叫做哈希桶。
Redis 的哈希桶是怎么保存键值对数据的呢?
哈希桶存放的是指向键值对数据的指针(dictEntry*),这样通过指针就能找到键值对数据,然后因为键值对的值可以保存字符串对象和集合数据类型的对象,所以键值对的数据结构中并不是直接保存值本身,而是保存了 void * key 和 void * value 指针,分别指向了实际的键对象和值对象,这样一来,即使值是集合数据,也可以通过 void * value 指针找到。
我这里画了一张 Redis 保存键值对所涉及到的数据结构。
这些数据结构的内部细节,我先不展开讲,后面在讲哈希表数据结构的时候,在详细的说说,因为用到的数据结构是一样的。这里先大概说下图中涉及到的数据结构的名字和用途:redisDb 结构,表示 Redis 数据库的结构,结构体里存放了指向了 dict 结构的指针;dict 结构,结构体里存放了 2 个哈希表,正常情况下都是用「哈希表1」,「哈希表2」只有在 rehash 的时候才用,具体什么是 rehash,我在本文的哈希表数据结构会讲;ditctht 结构,表示 哈希表的结构,结构里存放了 哈希表数组,数组中的每个元素都是指向一个哈希表节点结构(dictEntry)的指针;dictEntry 结构,表示 哈希表节点的结构,结构里存放了 void * key 和 void * value 指针, key 指向的是 String 对象,而 value 则可以指向 String 对象,也可以指向集合类型的对象,比如 List 对象、Hash 对象、Set 对象和 Zset 对象。
特别说明下,void * key 和 void * value 指针指向的是 Redis 对象,Redis 中的每个对象都由 redisObject 结构表示,如下图:
对象结构里包含的成员变量:type,标识该对象是什么类型的对象(String 对象、 List 对象、Hash 对象、Set 对象和 Zset 对象);encoding,标识该对象使用了哪种底层的数据结构;ptr,指向底层数据结构的指针。
我画了一张 Redis 键值对数据库的全景图,你就能清晰知道 Redis 对象和数据结构的关系了:
接下里,就好好聊一下底层数据结构!
kv 数据库是怎样实现的?
字符串在 Redis 中是很常用的,键值对中的键是字符串类型,值有时也是字符串类型。
Redis 是用 C 语言实现的,但是它没有直接使用 C 语言的 char* 字符数组来实现字符串,而是自己封装了一个名为简单动态字符串(simple dynamic string,SDS) 的数据结构来表示字符串,也就是 Redis 的 String 数据类型的底层数据结构是 SDS。
既然 Redis 设计了 SDS 结构来表示字符串,肯定是 C 语言的 char* 字符数组存在一些缺陷。
要了解这一点,得先来看看 char* 字符数组的结构。
C 语言的字符串其实就是一个字符数组,即数组中每个元素是字符串中的一个字符。
比如,下图就是字符串“xiaolin”的 char* 字符数组的结构:
在 C 语言里,对字符串操作时,char * 指针只是指向字符数组的起始位置,而 字符数组的结尾位置就用“\\0”表示,意思是指字符串的结束。
因此,C 语言标准库中的字符串操作函数就通过判断字符是不是 “\\0” 来决定要不要停止操作,如果当前字符不是 “\\0” ,说明字符串还没结束,可以继续操作,如果当前字符是 “\\0” 是则说明字符串结束了,就要停止操作。
举个例子,C 语言获取字符串长度的函数 strlen,就是通过字符数组中的每一个字符,并进行计数,等遇到字符为 “\\0” 后,就会停止遍历,然后返回已经统计到的字符个数,即为字符串长度。下图显示了 strlen 函数的执行流程:
很明显,C 语言获取字符串长度的时间复杂度是 O(N)(这是一个可以改进的地方)
C 语言字符串用 “\\0” 字符作为结尾标记有个缺陷。假设 有个字符串中有个 “\\0” 字符,这时在操作这个字符串时就会 提早结束,比如 “xiao\\0lin” 字符串,计算字符串长度的时候则会是 4,如下图:
因此,除了字符串的末尾之外,字符串里面不能含有 “\\0” 字符,否则最先被程序读入的 “\\0” 字符将被误认为是字符串结尾,这个限制使得 C 语言的字符串只能保存文本数据,不能保存像图片、音频、视频文化这样的二进制数据(这也是一个可以改进的地方)
另外, C 语言标准库中字符串的操作函数是很不安全的,对程序员很不友好,稍微一不注意,就会导致缓冲区溢出。
举个例子,strcat 函数是可以将两个字符串拼接在一起。
C 语言的字符串是不会记录自身的缓冲区大小的,所以 strcat 函数假定程序员在执行这个函数时,已经为 dest 分配了足够多的内存,可以容纳 src 字符串中的所有内容,而 一旦这个假定不成立,就会发生缓冲区溢出将可能会造成程序运行终止,(这是一个可以改进的地方)。
而且,strcat 函数和 strlen 函数类似,时间复杂度也很高,也都需要先通过遍历字符串才能得到目标字符串的末尾。然后对于 strcat 函数来说,还要再遍历源字符串才能完成追加,对字符串的操作效率不高。
好了, 通过以上的分析,我们可以得知 C 语言的字符串不足之处以及可以改进的地方:获取字符串长度的时间复杂度为 O(N);字符串的结尾是以 “\\0” 字符标识,字符串里面不能包含有 “\\0” 字符,因此不能保存二进制数据;字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;
Redis 实现的 SDS 的结构就把上面这些问题解决了,接下来我们一起看看 Redis 是如何解决的。
C 语言字符串的缺陷
下图就是 Redis 5.0 的 SDS 的数据结构:
结构中的每个成员变量分别介绍下:len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。flags,用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,后面在说明区别之处。buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。
总的来说,Redis 的 SDS 结构在原本字符数组之上,增加了三个元数据:len、alloc、flags,用来解决 C 语言字符串的缺陷。
SDS 结构设计
C 语言的字符串长度获取 strlen 函数,需要通过遍历的方式来统计字符串长度,时间复杂度是 O(N)。
而 Redis 的 SDS 结构因为加入了 len 成员变量,那么 获取字符串长度的时候,直接返回这个成员变量的值就行,所以复杂度只有 O(1)。
O(1)复杂度获取字符串长度
因为 SDS 不需要用 “\\0” 字符来标识字符串结尾了,而是 有个专门的 len 成员变量来记录长度,所以可存储包含 “\\0” 的数据。但是 SDS 为了兼容部分 C 语言标准库的函数, SDS 字符串结尾还是会加上 “\\0” 字符。
因此, SDS 的 API 都是 以处理二进制的方式来处理 SDS 存放在 buf[] 里的数据,程序不会对其中的数据做任何限制,数据写入的时候时什么样的,它被读取时就是什么样的。
通过使用二进制安全的 SDS,而不是 C 字符串,使得 Redis 不仅可以保存文本数据,也可以保存任意格式的二进制数据。
二进制安全
Redis 的 SDS 结构里引入了 alloc 和 len 成员变量,这样 SDS API 通过 alloc - len 计算,可以算出剩余可用的空间大小,这样在对字符串做修改操作的时候,就可以 由程序内部判断缓冲区大小是否足够用。
而且,当判断出 缓冲区大小不够用时,Redis 会 自动将扩大 SDS 的空间大小,以满足修改所需的大小。
SDS 扩容的规则代码如下:如果所需的 sds 长度 小于 1 MB,那么最后的扩容是按照翻倍扩容来执行的,即 2 倍的 newlen如果所需的 sds 长度 超过 1 MB,那么最后的扩容长度应该是 newlen + 1MB。
在扩容 SDS 空间之前,SDS API 会优先检查未使用空间是否足够,如果不够的话,API 不仅会为 SDS 分配修改所必须要的空间,还会给 SDS 分配额外的「未使用空间」。这样的好处是,下次在操作 SDS 时,如果 SDS 空间够的话,API 就会直接使用「未使用空间」,而无须执行内存分配,有效的减少内存分配次数。
所以,使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现缓冲区溢出的问题。
不会发生缓冲区溢出
SDS 结构中有个 flags 成员变量,表示的是 SDS 类型。
Redis 一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。
这 5 种类型的主要区别就在于,它们数据结构中的 len 和 alloc 成员变量的数据类型不同。
比如 sdshdr16 和 sdshdr32 这两个类型,它们的定义分别如下:sdshdr16 类型的 len 和 alloc 的数据类型都是 uint16_t,表示字符数组长度和分配空间大小不能超过 2 的 16 次方。sdshdr32 则都是 uint32_t,表示表示字符数组长度和分配空间大小不能超过 2 的 32 次方。
之所以 SDS 设计不同类型的结构体,是为了能 灵活保存不同大小的字符串,从而有效节省内存空间。比如,在保存小字符串时,结构头占用空间也比较少。
除了设计不同类型的结构体,Redis 在编程上还 使用了专门的编译优化来节省内存空间,即在 struct 声明了 __attribute__ ((packed)) ,它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐。
比如,sdshdr16 类型的 SDS,默认情况下,编译器 会按照 2 字节对齐的方式给变量分配内存,这意味着,即使一个变量的大小不到 2 个字节,编译器也会给它分配 2 个字节。
举个例子,假设下面这个结构体,它有两个成员变量,类型分别是 char 和 int,如下所示:
大家猜猜这个结构体大小是多少?我先直接说答案,这个结构体大小计算出来是 8。
这是因为默认情况下,编译器是使用「字节对齐」的方式分配内存,虽然 char 类型只占一个字节,但是由于成员变量里有 int 类型,它占用了 4 个字节,所以在成员变量为 char 类型分配内存时,会分配 4 个字节,其中这多余的 3 个字节是为了字节对齐而分配的,相当于有 3 个字节被浪费掉了。
如果不想编译器使用字节对齐的方式进行分配内存,可以采用了 __attribute__ ((packed)) 属性定义结构体,这样一来,结构体实际占用多少内存空间,编译器就分配多少空间。
比如,我用 __attribute__ ((packed)) 属性定义下面的结构体 ,同样包含 char 和 int 两个类型的成员变量,代码如下所示:
这时打印的结果是 5(1 个字节 char + 4 字节 int)。
可以看得出,这是按照实际占用字节数进行分配内存的,这样可以节省内存空间。
节省内存空间
如何优化的?
SDS
大家最熟悉的数据结构除了数组之外,我相信就是链表了。
Redis 的 List 对象的底层实现之一就是链表。C 语言本身没有链表这个数据结构的,所以 Redis 自己设计了一个链表数据结构。
先来看看「链表节点」结构的样子:
有前置节点和后置节点,可以看的出,这个是一个 双向链表。
链表节点结构设计
不过,Redis 在 listNode 结构体基础上又封装了 list 这个数据结构,这样操作起来会更方便,链表结构如下:
list 结构为链表提供了链表头指针 head、链表尾节点 tail、链表节点数量 len、以及可以自定义实现的 dup、free、match 函数。
举个例子,下面是由 list 结构和 3 个 listNode 结构组成的链表。
链表结构设计
Redis 的链表实现 优点 如下:listNode 链表节点的结构里带有 prev 和 next 指针,获取某个节点的前置节点或后置节点的时间复杂度只需O(1),而且这两个指针都可以指向 NULL,所以链表是无环链表;list 结构因为提供了 表头指针 head 和表尾节点 tail,所以 获取链表的表头节点和表尾节点的时间复杂度只需 O(1);list 结构因为提供了链表节点数量 len,所以 获取链表中的节点数量的时间复杂度只需 O(1);listNode 链表节使用 void* 指针保存节点值,并且可以通过 list 结构的 dup、free、match 函数指针为节点设置该节点类型特定的函数,因此 链表节点可以保存各种不同类型的值;
链表的缺陷也是有的:链表每个节点之间的 内存都是不连续的,意味着 无法很好利用 CPU 缓存。能很好利用 CPU 缓存的数据结构就是数组,因为数组的内存是连续的,这样就可以充分利用 CPU 缓存来加速访问。(CPU 读取内存的时候,会把一片连续的内存块读取出来,然后放到缓存中)还有一点,保存一个链表节点的值都需要 一个链表节点结构头的分配,内存开销较大。
因此,Redis 3.0 的 List 对象在数据量比较少的情况下,会采用「压缩列表」作为底层数据结构的实现,它的优势是节省内存空间,并且是内存紧凑型的数据结构。
不过,压缩列表存在性能问题(具体什么问题,下面会说),所以 Redis 在 3.2 版本设计了新的数据结构 quicklist,并将 List 对象的底层数据结构改由 quicklist 实现。
然后在 Redis 5.0 设计了新的数据结构 listpack,沿用了压缩列表紧凑型的内存布局,最终在最新的 Redis 版本,将 Hash 对象和 Zset 对象的底层数据结构实现之一的压缩列表,替换成由 listpack 实现。
链表的优势与缺陷
链表 List
压缩列表的最大特点,就是它被设计成一种 内存紧凑型 的数据结构,占用一块连续的内存空间,不仅可以 利用 CPU 缓存(CPU 读取内存的时候,会把一片连续的内存块读取出来,然后放到缓存中),而且会 针对不同长度的数据,进行相应编码,这种方法可以有效地 节省内存开销。
但是,压缩列表的 缺陷 也是有的:不能保存 过多的元素,否则 查询效率就会降低;新增或修改 某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发 连锁更新的问题。
因此,Redis 对象(List 对象、Hash 对象、Zset 对象)包含的元素数量较少,或者元素值不大的情况才会使用压缩列表作为底层数据结构。
压缩列表是 Redis 为了 节约内存 而开发的,它是 由连续内存块组成的顺序型数据结构,有点类似于数组。
压缩列表在表头有三个字段:zlbytes,记录整个压缩列表占用对内存字节数;zltail,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;zllen,记录压缩列表包含的节点数量;zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)。
在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段(zllen)的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素。
另外,压缩列表节点(entry)的构成如下:
压缩列表节点包含三部分内容:prevlen,记录了「前一个节点」的长度,目的是为了实现从后向前遍历;encoding,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。data,记录了当前节点的实际数据,类型和长度都由 encoding 决定;
当我们往压缩列表中插入数据时,压缩列表就会根据数据类型是字符串还是整数,以及数据的大小,会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的。
分别说下,prevlen 和 encoding 是如何根据数据的大小和类型来进行不同的空间大小分配。
压缩列表里的每个节点中的 prevlen 属性都记录了「前一个节点的长度」,而且 prevlen 属性的空间大小跟前一个节点长度值有关(可能会引发连锁更新),比如:如果 前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间 来保存这个长度值;如果 前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间 来保存这个长度值;
encoding 属性的空间大小跟数据是字符串还是整数,以及字符串的长度有关,如下图(下图中的 content 表示的是实际数据,即本文的 data 字段):如果 当前节点的数据是整数,则 encoding 会使用 1 字节的空间 进行编码,也就是 encoding 长度为 1 字节。通过 encoding 确认了整数类型,就可以确认整数数据的实际大小了,比如如果 encoding 编码确认了数据是 int16 整数,那么 data 的长度就是 int16 的大小。如果 当前节点的数据是字符串,根据字符串的长度大小,encoding 会使用 1 字节/2字节/5字节的空间进行编码,encoding 编码的前两个 bit 表示数据的类型,后续的其他 bit 标识字符串数据的实际长度,即 data 的长度。
压缩列表结构设计
压缩列表除了查找复杂度高的问题,还有一个问题。
压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。
前面提到,压缩列表节点的 prevlen 属性会根据前一个节点的长度进行不同的空间大小分配:如果 前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间 来保存这个长度值;如果 前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间 来保存这个长度值;
现在假设一个压缩列表中有多个连续的、长度在 250~253 之间的节点,如下图:
因为这些节点长度值小于 254 字节,所以 prevlen 属性需要用 1 字节的空间来保存这个长度值。
这时,如果将一个 长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为 e1 的前置节点,如下图:
因为 e1 节点的 prevlen 属性只有 1 个字节大小,无法保存新节点的长度,此时就需要对压缩列表的空间重分配操作,并将 e1 节点的 prevlen 属性从原来的 1 字节大小扩展为 5 字节大小。
多米诺牌的效应就此开始。
正如扩展 e1 引发了对 e2 扩展一样,扩展 e2 也会引发对 e3 的扩展,而扩展 e3 又会引发对 e4 的扩展.... 一直持续到结尾。
这种在特殊情况下产生的 连续多次空间扩展操作就叫做「连锁更新」,就像多米诺牌的效应一样,第一张牌倒下了,推动了第二张牌倒下;第二张牌倒下,又推动了第三张牌倒下....,
连锁更新
空间扩展操作也就是重新分配内存,因此 连锁更新一旦发生,就会导致压缩列表占用的内存空间要多次重新分配,这就会直接影响到压缩列表的访问性能。
所以说,虽然压缩列表紧凑型的内存布局能节省内存开销,但是如果保存的元素数量增加了,或是元素变大了,会导致内存重新分配,最糟糕的是会有「连锁更新」的问题。
因此,压缩列表只会用于保存的节点数量不多的场景,只要节点数量足够小,即使发生连锁更新,也是能接受的。
虽说如此,Redis 针对压缩列表在设计上的不足,在后来的版本中,新增设计了两种数据结构:quicklist(Redis 3.2 引入) 和 listpack(Redis 5.0 引入)。这两种数据结构的设计目标,就是尽可能地保持压缩列表节省内存的优势,同时解决压缩列表的「连锁更新」的问题。
压缩列表的缺陷
压缩列表 ziplist
哈希表是一种保存键值对(key-value)的数据结构,哈希表中的每一个 key 都是独一无二的。
在讲压缩列表的时候,提到过 Redis 的 Hash 对象的底层实现之一是压缩列表(最新 Redis 代码已将压缩列表替换成 listpack)。Hash 对象的另外一个底层实现就是哈希表。
哈希表优点在于,它能以 O(1) 的复杂度快速查询数据。怎么做到的呢?将 key 通过 Hash 函数的计算,就能定位数据在表中的位置,因为哈希表实际上是数组,所以可以通过索引值快速查询到数据。
但是存在的风险也是有,在哈希表大小固定的情况下,随着数据不断增多,那么 哈希冲突 的可能性也会越高。
Redis 采用了「链式哈希」来解决哈希冲突,在不扩容哈希表的前提下,将具有相同哈希值的数据串起来,形成链接起,以便这些数据在表中仍然可以被查询到。
Redis 的哈希表结构如下:
可以看到,哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。
哈希表节点的结构如下:
dictEntry 结构里不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对链接起来,以此来解决哈希冲突的问题,这就是链式哈希。
另外,这里还跟你提一下,dictEntry 结构里键值对中的 值是一个「联合体 v」定义的,因此,键值对中的 值可以是一个指向实际值的指针,或者是一个无符号的 64 位整数或有符号的 64 位整数或double 类的值。这么做的好处是可以节省内存空间,因为当「值」是整数或浮点数时,就可以将值的数据内嵌在 dictEntry 结构里,无需再用一个指针指向实际的值,从而 节省了内存空间。
哈希表结构设计
哈希表实际上是一个数组,数组里多每一个元素就是一个哈希桶。
当一个键值对的键经过 Hash 函数计算后得到哈希值,再将(哈希值 % 哈希表大小)取模计算,得到的结果值就是该 key-value 对应的数组元素位置,也就是第几个哈希桶。
举个例子,有一个可以存放 8 个哈希桶的哈希表。key1 经过哈希函数计算后,再将「哈希值 % 8 」进行取模计算,结果值为 1,那么就对应哈希桶 1,类似的,key9 和 key10 分别对应哈希桶 1 和桶 6。
此时,key1 和 key9 对应到了相同的哈希桶中,这就发生了哈希冲突。
因此,当有两个以上数量的 kay 被分配到了哈希表中同一个哈希桶上时,此时称这些 key 发生了冲突。
哈希冲突
Redis 采用了「链式哈希」的方法来解决哈希冲突。
实现的方式就是每个哈希表节点都有一个 next 指针,用于指向下一个哈希表节点,因此多个哈希表节点可以用 next 指针构成一个单项链表,被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来,这样就解决了哈希冲突。
还是用前面的哈希冲突例子,key1 和 key9 经过哈希计算后,都落在同一个哈希桶,链式哈希的话,key1 就会通过 next 指针指向 key9,形成一个单向链表。
不过,链式哈希局限性也很明显,随着链表长度的增加,在查询这一位置上的数据的耗时就会增加,毕竟链表的查询的时间复杂度是 O(n)。
要想解决这一问题,就需要进行 rehash,也就是对哈希表的大小进行扩展。
接下来,看看 Redis 是如何实现的 rehash 的。
链式哈希
哈希表结构设计的这一小节,我给大家介绍了 Redis 使用 dictht 结构体表示哈希表。不过,在实际使用哈希表时,Redis 定义一个 dict 结构体,这个结构体里定义了 两个哈希表(ht[2])。
之所以定义了 2 个哈希表,是因为进行 rehash 的时候,需要用上 2 个哈希表了。
在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。
随着数据逐步增多,触发了 rehash 操作,这个过程分为三步:给「哈希表 2」 分配空间,一般会 比「哈希表 1」 大 2 倍;将「哈希表 1 」的数据迁移到「哈希表 2」 中;迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。
为了方便你理解,我把 rehash 这三个过程画在了下面这张图:
这个过程看起来简单,但是其实第二步很有问题,如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。
rehash
为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了 渐进式 rehash,也就是将数据的迁移的工作不再是一次性迁移完成,而是 分多次迁移。
渐进式 rehash 步骤如下:给「哈希表 2」 分配空间;在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上;随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。
这样就巧妙地把一次性大量数据迁移工作的开销,分摊到了多次处理请求的过程中,避免了一次性 rehash 的耗时操作。
在进行渐进式 rehash 的过程中,会有两个哈希表,所以在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。
比如,查找一个 key 的值的话,先会在「哈希表 1」 里面进行查找,如果没找到,就会继续到哈希表 2 里面进行找到。
另外,在渐进式 rehash 进行期间,新增一个 key-value 时,会被保存到「哈希表 2 」里面,而「哈希表 1」 则不再进行任何添加操作,这样保证了「哈希表 1 」的 key-value 数量只会减少,随着 rehash 操作的完成,最终「哈希表 1 」就会变成空表。
渐进式 rehash
介绍了 rehash 那么多,还没说什么时情况下会触发 rehash 操作呢?
rehash 的触发条件跟 负载因子(load factor)有关系。
负载因子可以通过下面这个公式计算:
触发 rehash 操作的条件,主要有两个:当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。
rehash 触发条件
哈希表 hash
整数集合是 Set 对象的底层实现之一。当一个 Set 对象只包含整数值元素,并且元素数量不大时,就会使用整数集这个数据结构作为底层实现。
整数集合本质上是一块 连续内存空间,它的结构定义如下:
可以看到,保存元素的容器是一个 contents 数组,虽然 contents 被声明为 int8_t 类型的数组,但是实际上 contents 数组并不保存任何 int8_t 类型的元素,contents 数组的真正类型取决于 intset 结构体里的 encoding 属性的值。比如:如果 encoding 属性值为 INTSET_ENC_INT16,那么 contents 就是一个 int16_t 类型的数组,数组中每一个元素的类型都是 int16_t;如果 encoding 属性值为 INTSET_ENC_INT32,那么 contents 就是一个 int32_t 类型的数组,数组中每一个元素的类型都是 int32_t;如果 encoding 属性值为 INTSET_ENC_INT64,那么 contents 就是一个 int64_t 类型的数组,数组中每一个元素的类型都是 int64_t;
不同类型的 contents 数组,意味着数组的大小也会不同。
整数集合结构设计
整数集合会有一个升级规则,就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进行升级,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性。
整数集合升级的过程 不会重新分配一个新类型的数组,而是 在原本的数组上扩展空间,然后再将每个元素按间隔类型大小分割,如果 encoding 属性值为 INTSET_ENC_INT16,则每个元素的间隔就是 16 位。
举个例子,假设有一个整数集合里有 3 个类型为 int16_t 的元素。
现在,往这个整数集合中加入一个新元素 65535,这个新元素需要用 int32_t 类型来保存,所以整数集合要进行升级操作,首先需要为 contents 数组扩容,在原本空间的大小之上再扩容多 80 位(4x32-3x16=80),这样就能保存下 4 个类型为 int32_t 的元素。
扩容完 contents 数组空间大小后,需要将之前的三个元素转换为 int32_t 类型,并将转换后的元素放置到正确的位上面,并且需要维持底层数组的有序性不变,整个转换过程如下:
如果要让一个数组同时保存 int16_t、int32_t、int64_t 类型的元素,最简单做法就是直接使用 int64_t 类型的数组。不过这样的话,当如果元素都是 int16_t 类型的,就会造成内存浪费的情况。
整数集合升级就能避免这种情况,如果一直向整数集合添加 int16_t 类型的元素,那么整数集合的底层实现就一直是用 int16_t 类型的数组,只有在我们要将 int32_t 类型或 int64_t 类型的元素添加到集合时,才会对数组进行升级操作。
因此,整数集合升级的好处是 节省内存资源。
整数集合升级有什么好处呢?
不支持降级操作,一旦对数组进行了升级,就会一直保持升级后的状态。比如前面的升级操作的例子,如果删除了 65535 元素,整数集合的数组还是 int32_t 类型的,并不会因此降级为 int16_t 类型。
整数集合支持降级操作吗?
整数集合的升级操作
整数集合 intset
Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。
zset 结构体里有两个数据结构:一个是跳表,一个是哈希表。这样的好处是既能进行 高效的范围查询,也能进行 高效单点查询。
Zset 对象在执行数据插入或是数据更新的过程中,会依次在跳表和哈希表中插入或更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。
Zset 对象能支持范围查询(如 ZRANGEBYSCORE 操作),这是因为它的数据结构设计采用了跳表,而又能以常数复杂度获取元素权重(如 ZSCORE 操作),这是因为它同时采用了哈希表进行索引。
可能很多人会奇怪,为什么我开头说 Zset 对象的底层数据结构是「压缩列表」或者「跳表」,而没有说哈希表呢?
Zset 对象在使用跳表作为数据结构的时候,是使用由「哈希表+跳表」组成的 struct zset,但是我们讨论的时候,都会说跳表是 Zset 对象的底层数据结构,而不会提及哈希表,是因为 struct zset 中的哈希表只是用于以常数复杂度获取元素权重,大部分操作都是跳表实现的。
链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,这样的好处是能快读定位数据。
那跳表长什么样呢?我这里举个例子,下图展示了一个层级为 3 的跳表。
图中头节点有 L0~L2 三个头指针,分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来:L0 层级共有 5 个节点,分别是节点1、2、3、4、5;L1 层级共有 3 个节点,分别是节点 2、3、5;L2 层级只有 1 个节点,也就是节点 3 。
如果我们要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次,而使用了跳表后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再往前遍历找到节点 4。
可以看到,这个查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是 O(logN)。
那跳表节点是怎么实现多层级的呢?这就需要看「跳表节点」的数据结构了,如下:
Zset 对象要同时保存「元素」和「元素的权重」,对应到跳表节点结构里就是 sds 类型的 ele 变量和 double 类型的 score 变量。每个跳表节点都有一个 后向指针(struct zskiplistNode *backward),指向前一个节点,目的是为了方便 从跳表的尾节点开始访问节点,这样倒序查找时很方便。
跳表是一个带有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的 zskiplistLevel 结构体类型的 level 数组。
这里要注意:level 数组中的元素只有两个属性,*forward 和 span。所以存储的对象,score,向后指向 *backward 都是每个数组元素共享的。
level 数组中的每一个元素代表跳表的一层,也就是由 zskiplistLevel 结构体表示,比如 leve[0] 就表示第一层,leve[1] 就表示第二层。zskiplistLevel 结构体里定义了「指向下一个跳表节点的指针」和「跨度」,跨度时用来记录两个节点之间的距离。
比如,下面这张图,展示了各个节点的跨度。
第一眼看到跨度的时候,以为是遍历操作有关,实际上并没有任何关系,遍历操作只需要用前向指针(struct zskiplistNode *forward)就可以完成了。
跨度实际上是为了计算这个节点在跳表中的排位。具体怎么做的呢?因为跳表中的节点都是 按序排列 的,那么计算某个节点排位的时候,从头节点到该结点的查询路径上,将沿途访问过的所有层的跨度累加起来,得到的结果就是目标节点在跳表中的排位。
这个节点在跳表中的 排位 就是这个节点的 score 在跳表中的排位,所以跨度越大,score 越大。
举个例子,查找图中节点 3 在跳表中的排位,从头节点开始查找节点 3,查找的过程只经过了一个层(L2),并且层的跨度是 3,所以节点 3 在跳表中的排位是 3。
另外,图中的 头节点其实也是 zskiplistNode 跳表节点,只不过头节点的后向指针、权重、元素值都没有用到,所以图中省略了这部分。
问题来了,由谁定义哪个跳表节点是头节点呢?这就介绍「跳表」结构体了,如下所示:
跳表结构里包含了:跳表的头尾节点,便于在 O(1) 时间复杂度内访问跳表的头节点和尾节点;跳表的长度,便于在 O(1) 时间复杂度获取跳表节点的数量;跳表的 最大层数,便于在 O(1) 时间复杂度获取跳表中 层高最大的那个节点的层数量;
完整的跳表结构如下:
跳表结构设计
以下面这个简约版的跳表结构来演示插入过程:
先遍历跳表,找到待插入节点的前驱节点,同时一边遍历一边记录从头节点到这个前驱节点的跨度。
记录跨度主要是为了在插入新节点后,前驱节点的跨度发生了改变,而且要计算新节点的跨度。
注意:前驱节点是保存在一个数组中的,因为要查找前驱节点是查找每一层的,若新插入节点有 N 层,那么 N 层都需要连接。同理,跨度也是保存在一个数组中,因为每层的跨度不相同。数组下标就对应了层数。
1. 找到待插入节点的前驱节点和记录头节点到前驱节点的跨度
新加入的节点,层数是随机的,首先每个节点肯定都有第 0 层(其实是从第一层开始),因为最底层要存储数据。
有了最底层之后,这个节点有没有第 i 层,是通过一个随机概率 P 来决定的。这个 P = 1/4,也就是说每增加一层的概率为 25%,层数越高,再加一层的概率就越小(加第一层 1/4,加第二层 1/16,...)。
这样设计尽可能的保证了上下层之间的差距不会相差太远,而且也不需要像平衡树那样自平衡。
当然,为了避免每次都在这个概率范围内,规定了最高层数不超过 64。
2. 随机生成层数
假设要在上面那个图上插入一个节点,分值是 9.5,且随机生成的层数是 2,那么插入这个节点之后,跳跃表应该是这样的:
如果要实现这个效果,我们要分两步去做:找到新节点在b style=\
注意,是每一层都需要操作,每层的前驱节点在第一步都存放在一个数组中了。
3. 插入新节点
除此之外,不要忘了每一层还有一个 span 变量,在插入新节点之后,我们需要 计算新节点在每一层的 span。
另外,在插入新节点之后,新节点的上一个节点的 span 也会发生变化,需要我们更新。
首先看最底层,最底层其实是没有这个问题的,因为底层压根就没有跳嘛。所有 节点在最底层都是直接相邻的,所以 span 都是1。
问题在于高层,比如 level1,我们要想办法确定插入节点 9.5 之后:节点 8 到节点 9.5 之间的距离,和节点 9.5 到节点 12 之间的距离
实际上这两个确定一个就可以,因为 这两个距离之和就是原来 level1 层节点 8 的 span+1(+1是因为插入了节点 9.5),既然如此,我们就想办法确定节点 8 到节点 9.5 之间的距离,节点 9.5 到节点 12 之间的距离用总和减就行了。
节点 8 到节点 9.5 之间的距离又该怎么算呢?我们可以将这段距离转化为 level1 的节点 8 到 level0 的节点 9 之间的距离 +1,也就是下面这段:
而这一段又该怎么算呢?可以把它当做下面两段距离的差,这两段已经在第一步求出来了:
注意:每一层的跨度都要更新,每层的原始跨度已经在第一步的时候算出来了,存放在一个数组中的。
4. 计算跨度 span
针对每一层的调整已经全部完成后,也就是 level 数组已经搞定。
接下来,处理一下 *backward 指针,首先新节点的 *backward 要指向前一个节点。
然后,新节点的下一个节点要将 *backward 指向新节点。
最后,更新一下跳跃表的总节点个数,插入操作就完成了。
5. 设置后继指针,更新跳跃表总节点个数
跳表节点插入过程
查找一个跳表节点的过程时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断,共有两个判断条件:如果 当前节点的权重「小于」要查找的权重时,跳表就会 访问该层上的下一个节点。如果 当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会 访问该层上的下一个节点。
如果 上面两个条件都不满足,或者 下一个节点为空时,跳表就会使用 目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于 跳到了下一层接着查找。
举个例子,下图有个 3 层级的跳表。
如果要查找「元素:abcd,权重:4」的节点,查找的过程是这样的:先从头节点的最高层开始,L2 指向了「元素:abc,权重:3」节点,这个节点的权重比要查找节点的小,所以要访问该层上的下一个节点;但是该层的下一个节点是空节点( leve[2]指向的是空节点),于是就会跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[1];「元素:abc,权重:3」节点的 leve[1] 的下一个指针指向了「元素:abcde,权重:4」的节点,然后将其和要查找的节点比较。虽然「元素:abcde,权重:4」的节点的权重和要查找的权重相同,但是当前节点的 SDS 类型数据「大于」要查找的数据,所以会继续跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[0];「元素:abc,权重:3」节点的 leve[0] 的下一个指针指向了「元素:abcd,权重:4」的节点,该节点正是要查找的节点,查询结束。
跳表节点查询过程
跳表的相邻两层的节点数量的比例会影响跳表的查询性能。
举个例子,下图的跳表,第二层的节点数量只有 1 个,而第一层的节点数量有 6 个。
这时,如果想要查询节点 6,那基本就跟链表的查询复杂度一样,就需要在第一层的节点中依次顺序查找,复杂度就是 O(N) 了。所以,为了降低查询复杂度,我们就需要维持相邻层结点数间的关系。
跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)。
下图的跳表就是,相邻两层的节点数量的比例是 2 : 1。
如果采用新增节点或者删除节点时,来调整跳表节点以维持比例的方法的话,会带来额外的开销。
Redis 则采用一种巧妙的方法是,跳表在创建节点的时候,随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况。
具体的做法是,跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。
这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低(第一层 0.25,第二层 0.25×0.25 ......),层高最大限制是 64。
例如下面的插入过程:
那怎样才能维持相邻两层的节点数量的比例为 2 : 1 呢?
跳表节点层数设置
这里插一个常见的面试题:为什么 Zset 的实现用跳表而不用平衡树(如 AVL树、红黑树等)?
对于这个问题,Redis的作者 @antirez 是怎么说的:
简单翻译一下,主要是从内存占用、对范围查找的支持、实现难易程度这三方面总结的原因:它们不是非常内存密集型的。基本上由你决定。改变关于节点具有给定级别数的概率的参数将使其比 btree 占用更少的内存。Zset 经常需要执行 ZRANGE 或 ZREVRANGE 的命令,即作为链表遍历跳表。通过此操作,跳表的缓存局部性至少与其他类型的平衡树一样好。它们更易于实现、调试等。例如,由于跳表的简单性,我收到了一个补丁(已经在Redis master中),其中扩展了跳表,在 O(log(N) 中实现了 ZRANK。它只需要对代码进行少量修改。
我再详细补充点:从内存占用上来比较,跳表比平衡树更灵活一些。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均 为 1/(1-p),具体取决于参数 p 的大小。如果像 Redis里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。在做范围查找的时候,跳表比平衡树操作要简单。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它 不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在跳表上进行范围查找就非常简单,只需要在 找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。通过随机生成结点层数的插入维持相邻两层的结点数为 2 : 1,而平衡树需要自平衡。从算法实现难度上来比较,跳表比平衡树要简单得多。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除 只需要修改相邻节点的指针,操作简单又快速。
一句话总结为什么 Redis 要用跳表来实现 Zset:内存使用更少,简单易维护,性能不比搜索树差
为什么用跳表而不用平衡树?
跳表 zskiplist
在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表。然后在 Redis 3.2 的时候,List 对象的底层改由 quicklist 数据结构实现。
其实 quicklist 就是「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。
在前面讲压缩列表的时候,我也提到了压缩列表的不足,虽然压缩列表是通过紧凑型的内存布局节省了内存开销,但是因为它的结构设计,如果保存的元素数量增加,或者元素变大了,压缩列表会有「连锁更新」的风险,一旦发生,会造成性能下降。
quicklist 解决办法,通过 控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为 压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。
quicklist 的结构体跟链表的结构体类似,都包含了表头和表尾,区别在于 quicklist 的节点是 quicklistNode。
接下来看看,quicklistNode 的结构定义:
可以看到,quicklistNode 结构体里包含了前一个节点和下一个节点指针,这样每个 quicklistNode 形成了一个双向链表。但是 链表节点的元素不再是单纯保存元素值,而是保存了一个压缩列表,所以 quicklistNode 结构体里有个指向压缩列表的指针 *zl。
在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。
quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并 没有完全解决连锁更新的问题。
quicklist 结构设计
quicklist
quicklist 虽然通过 控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并 没有完全解决连锁更新的问题。
因为 quicklistNode 还是用了 压缩列表 来保存元素,压缩列表连锁更新的问题,来源于它的结构设计,所以要想 彻底解决 这个问题,需要 设计一个新的数据结构。
于是,Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中 每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。
我看了 Redis 的 Github,在最新 6.2 发行版本中,Redis Hash 对象、ZSet 对象的底层数据结构的压缩列表还未被替换成 listpack,而 Redis 的最新代码(还未发布版本)已经将所有用到压缩列表底层数据结构的 Redis 对象替换成 listpack 数据结构来实现,估计不久将来,Redis 就会发布一个将压缩列表为 listpack 的发行版本。
listpack 采用了压缩列表的很多优秀的设计,比如还是 用一块连续的内存空间来紧凑地保存数据,并且为了节省内存的开销,listpack 节点会 采用不同的编码方式保存不同大小的数据。
我们先看看 listpack 结构:
listpack 头包含两个属性,分别记录了 listpack 总字节数和元素数量,然后 listpack 末尾也有个结尾标识。图中的 listpack entry 就是 listpack 的节点了。
每个 listpack 节点结构如下:
主要包含三个方面内容:encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;data,实际存放的数据;len,encoding+data 的总长度;
可以看到,listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而 避免了压缩列表的连锁更新问题。
压缩列表的 entry 保存 prevlen 是为了实现节点从后往前遍历,知道前一个节点的长度,就可以计算前一个节点的偏移量。
listpack 一样可以支持从后往前遍历的。详细的算法可以看:https://github.com/antirez/listpack/blob/master/listpack.c 里的 lpDecodeBacklen 函数,lpDecodeBacklen 函数就可以 从当前列表项起始位置的指针开始,向左逐个字节解析,得到前一项的 entry-len 值。
压缩列表的 entry 为什么要保存 prevlen 呢?listpack 改成 len 之后不会影响功能吗?
listpack 结构设计
listpack
数据类型篇
用户的数据一般都是存储于数据库,数据库的数据是落在磁盘上的,磁盘的读写速度可以说是计算机里最慢的硬件了。
当用户的请求,都访问数据库的话,请求数量一上来,数据库很容易就奔溃的了,所以为了避免用户直接访问数据库,会用 Redis 作为缓存层。
因为 Redis 是内存数据库,我们可以将数据库的数据缓存在 Redis 里,相当于数据缓存在内存,内存的读写速度比硬盘快好几个数量级,这样大大提高了系统性能。
引入了缓存层,就会有缓存异常的三个问题,分别是 缓存雪崩、缓存击穿、缓存穿透。
那么,当大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是 缓存雪崩 的问题。
可以看到,发生缓存雪崩有两个原因:大量数据同时过期;Redis 故障宕机;
不同的诱因,应对的策略也会不同。
针对大量数据同时过期而引发的缓存雪崩问题,常见的应对方法有下面这几种:均匀设置过期时间;互斥锁;双 key 策略;后台更新缓存;
如果要给缓存数据设置过期时间,应该避免将大量的数据设置成同一个过期时间。
我们可以在对缓存数据设置过期时间时,给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间过期。
1. 均匀设置过期时间
当业务线程在处理用户请求时,如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
实现互斥锁的时候,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。(详细看 redis 实现分布式锁)
2. 互斥锁
我们对缓存数据可以使用两个 key,一个是主 key,会设置过期时间,一个是备 key,不会设置过期,它们只是 key 不一样,但是 value 值是一样的,相当于给缓存数据做了个副本。
当业务线程访问不到「主 key 」的缓存数据时,就直接返回「备 key 」的缓存数据,然后在更新缓存的时候,同时更新「主 key 」和「备 key 」的数据。
双 key 策略的好处是,当主 key 过期了,有大量请求获取缓存数据的时候,直接返回备 key 的数据,这样可以快速响应请求。而不用因为 key 失效而导致大量请求被锁阻塞住(采用了互斥锁,仅一个请求来构建缓存),后续再通知后台线程,重新构建主 key 的数据。
3. 双 key 策略
业务线程不再负责更新缓存,缓存也不设置有效期,而是 让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新。
事实上,缓存数据不设置有效期,并不是意味着数据一直能在内存里,因为 当系统内存紧张的时候,有些缓存数据会被“淘汰”,而在缓存被“淘汰”到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角就以为是数据丢失了。
解决上面的问题的方式有两种。
第一种方式,后台线程不仅负责定时更新缓存,而且也负责 频繁地检测缓存是否有效,检测到缓存失效了,原因可能是系统紧张而被淘汰的,于是就要马上从数据库读取数据,并更新到缓存。这种方式的检测时间间隔不能太长,太长也导致用户获取的数据是一个空值而不是真正的数据,所以检测的间隔最好是毫秒级的,但是总归是有个间隔时间,用户体验一般。
第二种方式,在业务线程发现缓存数据失效后(缓存数据被淘汰),通过消息队列发送一条消息通知后台线程更新缓存,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据加载到缓存。这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。
在业务刚上线的时候,我们最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的 缓存预热,后台更新缓存的机制刚好也适合干这个事情。
4. 后台更新缓存
大量数据同时过期
针对 Redis 故障宕机而引发的缓存雪崩问题,常见的应对方法有下面这几种:服务熔断或请求限流机制;构建 Redis 缓存高可靠集群;
因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动 服务熔断 机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。
服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作
为了减少对业务的影响,我们可以启用 请求限流 机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。
1. 服务熔断或请求限流机制
服务熔断或请求限流机制是缓存雪崩发生后的应对方案,我们最好通过 主从节点的方式构建 Redis 缓存高可靠集群。
如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。
2. 构建 Redis 缓存高可靠集群
Redis 故障宕机
缓存雪崩
如果缓存中的 某个热点数据过期 了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是 缓存击穿 的问题。
可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。
应对缓存击穿可以采取前面说到两种方案:互斥锁方案,保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;
缓存击穿
当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是 缓存穿透 的问题。
应对缓存穿透的方案,常见的方案有三种。非法请求的限制;缓存空值或者默认值;使用 布隆过滤器 快速判断数据是否存在,避免通过查询数据库来判断数据是否存在;
当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
1. 非法请求的限制
当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。
2. 缓存空值或者默认值
我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认 缓存失效后,可以 通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。
即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。
布隆过滤器使用位图,占用空间极小;并且插入和查询效率都很高,直接通过N个哈希函数定位即可
那问题来了,布隆过滤器是如何工作的呢?接下来,我介绍下。
布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。当我们在 写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。
布隆过滤器会通过 3 个操作 完成标记:第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。第三步,将每个哈希值在位图数组的对应位置的值设置为 1;
举个例子,假设有一个位图数组长度为 8,哈希函数 3 个的布隆过滤器。
在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。
当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中。
布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在 哈希冲突 的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。
所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据。
另外,由于上面的原因,使得布隆过滤器 不能删除元素,因为删除后会影响其他元素(哈希冲突后落到同一个槽中的元素)。
至于 为什么要用 N 个哈希函数,主要是为了 减小哈希碰撞,例如 N = 3,则需要 3 个哈希函数求出的值都相同,才会发生碰撞。
3. 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在
缓存穿透
缓存异常会面临的三个问题:缓存雪崩、击穿和穿透。
其中,缓存雪崩和缓存击穿主要原因是数据不在缓存中,而导致大量请求访问了数据库,数据库压力骤增,容易引发一系列连锁反应,导致系统奔溃。不过,一旦数据被重新加载回缓存,应用又可以从缓存快速读取数据,不再继续访问数据库,数据库的压力也会瞬间降下来。因此,缓存雪崩和缓存击穿应对的方案比较类似。
而缓存穿透主要原因是数据既不在缓存也不在数据库中。因此,缓存穿透与缓存雪崩、击穿应对的方案不太一样。
我这里整理了表格,你可以从下面这张表格很好的知道缓存雪崩、击穿和穿透的区别以及应对方案。
什么是缓存雪崩、击穿、穿透?
阿旺在准备给数据库加缓存时,遇到这样的一个问题:
由于引入了缓存,那么在数据更新时,不仅要更新数据库,而且要更新缓存,这两个更新操作存在前后的问题:先更新数据库,再更新缓存;先更新缓存,再更新数据库;
阿旺没想到太多,他觉得最新的数据肯定要先更新数据库,这样才可以确保数据库里的数据是最新的,于是他就采用了「先更新数据库,再更新缓存」的方案。
过了一段时间,老板突然收到一个客户的投诉,客户说他刚发起了 两次更新年龄的操作,但是显示的年龄确还是第一次更新时的年龄,而第二次更新年龄并没有生效。
老板立马就找了阿旺,训斥着阿旺说:「这么简单的更新操作,都有 bug?我脸往哪儿放?」
阿旺瞬间就慌了,立马登陆服务器排查问题,阿旺查询缓存和数据库的数据后发现了问题。
数据库的数据是客户第二次更新操作的数据,而缓存确还是第一次更新操作的数据,也就是出现了 数据库和缓存的数据不一致的问题。
这个问题可大了,阿旺经过一轮的分析,造成缓存和数据库的数据不一致的现象,是因为 并发问题!
举个例子,比如「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:
A 请求先将数据库的数据更新为 1(这个更新很慢),然后在更新缓存前,请求 B 将数据库的数据更新为 2,紧接着也把缓存更新为 2,然后 A 请求更新缓存为 1。(注意:这里是因为 A 请求更新数据库慢,导致缓存更新为 1 慢了,跟「缓存更新一般快于数据库更新」没关系)
此时,数据库中的数据是 2,而缓存中的数据却是 1,出现了缓存和数据库中的数据不一致的现象。
先更新数据库,再更新缓存
那换成「先更新缓存,再更新数据库」这个方案,还会有问题吗?
依然还是存在并发的问题,分析思路也是一样。
假设「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:
A 请求先将缓存的数据更新为 1,然后在更新数据库前,B 请求来了, 将缓存的数据更新为 2,紧接着把数据库更新为 2,然后 A 请求将数据库的数据更新为 1。
此时,数据库中的数据是 1,而缓存中的数据却是 2,出现了缓存和数据库中的数据不一致的现象。
所以,无论是「先更新数据库,再更新缓存」,还是「先更新缓存,再更新数据库」,这两个方案 都存在并发问题,当两个请求并发更新同一条数据的时候,可能会出现缓存和数据库中的数据不一致的现象。
先更新缓存,再更新数据库
先更新数据库,还是先更新缓存?
现在来讨论缓存更新的策略问题:即更新缓存时,是直接淘汰 cache 中的旧数据,还是将更新操作也放在缓存中进行?
淘汰 cache:优点:操作简单,无论更新操作是否复杂,直接将缓存中的旧值淘汰缺点:淘汰 cache 后,下一次查询无法在 cache 中查到,会有一次 cache miss,这时需要重新读取数据库
更新 cache:更新 cache 的意思就是将更新操作也放到缓冲中执行,并不是数据库中的值更新后再将最新值传到缓存。优点:命中率高,直接更新缓存,不会有 cache miss 的情况缺点:更新 cache 消耗较大
所以当业务对命中率要求没那么高时,选择 直接淘汰缓存更好,如果之后需要再次读取这个数据,最多会有一次缓存失败。
而且若有多个线程并发的进行修改操作,若采用更新缓存也会遇到和数据库相同的并发问题,所以还是推荐直接删除缓存。
问题:是更新缓存还是删除缓存?
阿旺定位出问题后,思考了一番后,决定在更新数据时,不更新缓存,而是删除缓存中的数据。然后,到读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。
阿旺想的这个策略是有名字的,是叫 Cache Aside 策略,中文是叫 旁路缓存策略。
该策略又可以细分为「读策略」和「写策略」。
写策略 的步骤:更新数据库中的数据;删除缓存中的数据。
读策略 的步骤:如果读取的数据命中了缓存,则直接返回数据;如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。
阿旺在想到「写策略」的时候,又陷入更深层次的思考,到底该选择哪种顺序呢?先删除缓存,再更新数据库;先更新数据库,再删除缓存。
于是阿旺用并发的角度来分析,看看这两种方案哪个可以保证数据库与缓存的数据一致性。
阿旺还是以用户表的场景来分析。
假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。
可以看到,先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题。
可以采用「延迟双删」策略:先删除缓存,这是一删。再更新数据库。此时休眠一段时间,此时其他线程可能会将旧数据刷入缓存。再删除缓存,确保旧缓存被删除掉,再从数据库中去读取最新值。
休眠的时间一般是一个线程从缓存未命中 -> 从数据库读取数据 -> 更新缓存所消耗的时间。
由于这个时间是一个不变量,请求时间随网络而波动,所以很难测出来。
如果非要采用先删除缓存,再更新数据库的策略,如何保证一致性?
这种情况下,造成数据不一致的原因如下:请求A 先删除缓存,再去更新主库;请求B 读取不到缓存,去从库读取数据,此时主从还没同步,导致读取到的是旧数据,然后写回缓存了;接着主从数据库才同步完成,从库中的数据是新值,此时造成了数据不一致;
解决办法还是「延迟双删」,只不过需要比上面多睡眠几百 ms,保证主从数据库已同步完成后,再删除一次缓存。
如果是主从复制的数据库呢?如何保证一致性?
先删除缓存,再更新数据库
最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据还是不一致。
从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。
因为 缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。
而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。
所以,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。
而且阿旺为了确保万无一失,还给缓存数据加上了「过期时间」,就算在这期间存在缓存数据不一致,有过期时间来兜底,这样也能达到最终一致。
结果又没过多久,老板又收到客户的投诉了,说自己明明更新了数据,但是数据要过一段时间才生效,客户接受不了。
阿旺得知又有 Bug 就更慌了,立马就登录服务器去排查问题,查看日志后得知了原因。
「先更新数据库, 再删除缓存」其实是两个操作,前面的所有分析都是建立在这两个操作都能同时执行成功,而这次客户投诉的问题就在于,在删除缓存(第二个操作)的时候失败了,导致缓存中的数据是旧值。
好在之前给缓存加上了过期时间,所以才会出现客户说的过一段时间才更新生效的现象,假设如果没有这个过期时间的兜底,那后续的请求读到的就会一直是缓存中的旧数据,这样问题就更大了。
所以新的问题来了,如何保证「先更新数据库 ,再删除缓存」这两个操作能执行成功?
阿旺分析出问题后,阿旺会用什么方式来解决这个问题呢?
先更新数据库,再删除缓存
先更新数据库,还是先删除缓存?
阿旺的事情就聊到这,我们继续说点其他。
「先更新数据库,再删除缓存」的方案虽然保证了数据库与缓存的数据一致性,但是每次更新数据的时候,缓存的数据都会被删除,这样会对缓存的命中率带来影响。
所以,如果我们的业务 对缓存命中率有很高的要求,我们可以采用「更新数据库 + 更新缓存」的方案,因为更新缓存并不会出现缓存未命中的情况。
但是这个方案前面我们也分析过,在两个更新请求并发执行的时候,会出现数据不一致的问题,因为更新数据库和更新缓存这两个操作是独立的,而我们又没有对操作做任何并发控制,那么当两个线程并发更新它们的话,就会因为写入顺序的不同造成数据的不一致。
所以我们得增加一些手段来解决这个问题,这里提供两种做法:在更新缓存前先加个 分布式锁,保证同一时间只运行一个请求更新缓存,就会不会产生并发问题了,当然引入了锁后,对于写入的性能就会带来影响。在更新完缓存时,给缓存加上较短的 过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快过期,对业务还是能接受的。
对了,针对「先删除缓存,再更新数据库」方案在「读 + 写」并发请求而造成缓存不一致的解决办法是「延迟双删」。
延迟双删实现的伪代码如下:
加了个睡眠时间,主要是 为了确保请求 A 在睡眠的时候,请求 B 能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后 请求 A 睡眠完,再删除缓存。
所以,请求 A 的睡眠时间就需要大于请求 B 「从数据库读取数据 + 写入缓存」的时间。
但是具体睡眠多久其实是个 玄学,很难评估出来,所以这个方案也只是 尽可能 保证一致性而已,极端情况下,依然也会出现缓存不一致的现象。
因此,还是 比较建议用「先更新数据库,再删除缓存」的方案。
小插曲
这次用户的投诉是因为在删除缓存(第二个操作)的时候失败了,导致缓存还是旧值,而数据库是最新值,造成数据库和缓存数据不一致的问题,会对敏感业务造成影响。
举个例子,来说明下。
应用要把数据 X 的值从 1 更新为 2,先成功更新了数据库,然后在 Redis 缓存中删除 X 的缓存,但是这个操作却失败了,这个时候数据库中 X 的新值为 2,Redis 中的 X 的缓存值为 1,出现了数据库和缓存数据不一致的问题。
那么,后续有访问数据 X 的请求,会先在 Redis 中查询,因为缓存并没有删除,所以会缓存命中,但是读到的却是旧值 1。
其实 不管是先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据一致的问题。
问题原因知道了,该怎么解决呢?有两种方法:重试机制。订阅 MySQL binlog,再操作缓存。
我们可以引入 消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。如果应用 删除缓存失败,还可以从消息队列中重新读取数据,然后再次删除缓存,这个就是 重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。如果 删除缓存成功,就要 把数据从消息队列中移除,避免重复操作,否则就继续重试。
举个例子,来说明重试机制的过程。
重试机制
「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。
于是我们就可以 通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。
Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。
下图是 Canal 的工作原理:
所以,如果要想保证「先更新数据库,再删缓存」策略第二个操作能执行成功,我们可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存。
订阅 MySQL binlog,再操作缓存
如何保证「先更新数据库 ,再删除缓存」两个操作都能执行成功?
数据库和缓存如何保证一致性?
在 Redis 中,访问频率高的 key 称为热点 key。热点 key 处理不当容易造成 Redis 进程阻塞,影响正常服务。
热点 key 问题产生的原因大致有两种。
用户消费的数据远大于生产的数据(热卖商品、热点新闻、热点评论、明星直播)。在日常工作生活中一些 突发的事件,例如:双十一期间某些热门商品的降价促销,当这其中的某一件商品被数万次点击浏览或者购买时,会形成一个较大的需求量,这种情况下就会造成热点问题。同理,被大量刊发、浏览的热点新闻、热点评论、明星直播等,这些典型的读多写少的场景也会产生热点问题。
请求分片集中,超过单 Server 的性能极限。在服务端读数据进行访问时,往往会对数据进行分片切分,此过程中会在某一主机 Server 上对相应的 Key 进行访问,当访问超过 Server 极限时,就会导致热点 Key 问题的产生。
热点 key 问题的产生
流量集中,达到物理网卡上限。请求过多,缓存分片服务被打垮。DB 击穿,引起业务雪崩。
当某一热点 Key 的请求在某一主机上 超过该主机网卡上限时,由于 流量的过度集中,会导致 服务器中其它服务无法进行。
如果热点过于集中,热点 Key 的缓存过多,超过目前的缓存容量时,就会导致 缓存分片服务被打垮 现象的产生。
当缓存服务崩溃后,此时再有请求产生,会缓存到后台 DB 上,由于 DB 本身性能较弱,在面临 大请求时很容易发生请求穿透现象,会进一步导致 雪崩现象,严重影响设备的性能。
热点 key 的危害
通常的解决方案主要集中在 对客户端和 Server 端进行相应的改造。
首先 Client 会将请求发送至 Server 上,而 Server 又是一个多线程的服务,本地就具有一个基于 Cache LRU 策略的缓存空间。当 Server 本身就拥堵时,Server 不会将请求进一步发送给 DB 而是直接返回,只有当 Server 本身畅通时才会将 Client 请求发送至 DB,并且将该数据重新写入到缓存中。此时就完成了缓存的访问跟重建。
但该方案也存在以下问题:缓存失效,多线程构建缓存问题缓存丢失,缓存构建问题脏读问题
使用 Memcache、Redis 方案
服务端缓存方案
常见解决方案
热点 key 问题的发现与解决
外框
缓存篇
如果 Redis 每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里,然后重启 Redis 的时候,先去读取这个文件里的命令,并且执行它,这不就相当于恢复了缓存数据了吗?
这种保存写操作命令到日志的持久化方式,就是 Redis 里的 AOF(Append Only File) 持久化功能,注意 只会记录写操作命令,读操作命令是不会被记录的,因为没意义。
在 Redis 中 AOF 持久化功能 默认是不开启的,需要我们修改 redis.conf 配置文件中的以下参数:
AOF 日志文件其实就是普通的文本,文本的内容记录的是命令,我们可以通过 cat 命令查看里面的内容,不过里面的内容如果不知道一定的规则的话,可能会看不懂。
我这里以「set name xiaolin」命令作为例子,Redis 执行了这条命令后,记录在 AOF 日志里的内容如下图:「*3」表示当前命令有三个部分,每部分都是以「$+数字」开头,后面紧跟着具体的命令、键或值。然后,这里的「数字」表示这部分中的命令、键或值一共有 多少字节。例如,「$3 set」表示这部分有 3 个字节,也就是「set」命令这个字符串的长度。
不知道大家注意到没有,Redis 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处。
第一个好处,避免额外的检查开销。
因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。
而如果先执行写操作命令再记录日志的话,只有在该命令执行成功后,才将命令记录到 AOF 日志里,这样就不用额外的检查开销,保证记录在 AOF 日志里的命令都是可执行并且正确的。
第二个好处,不会阻塞当前写操作命令的执行,因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。
当然,AOF 持久化功能也有潜在风险。
第一个风险,执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有 丢失的风险。
第二个风险,前面说道,由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前写操作命令的执行,但是可能会给「下一个」命令带来阻塞风险。
因为 将命令写入到日志 的这个操作也是在 主进程完成 的(执行命令也是在主进程),也就是说这两个操作是同步的。
如果在将日志内容写入到硬盘时,服务器的硬盘的 I/O 压力太大,就会导致写硬盘的速度很慢,进而 阻塞住了主线程,也就会导致后续的命令无法执行。
认真分析一下,其实这两个风险都有一个共性,都跟「 AOF 日志写回硬盘的时机」有关。
AOF 日志
Redis 写入 AOF 日志的过程,如下图:
我先来具体说说:Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区;然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是 拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;具体 内核缓冲区的数据什么时候写入到硬盘,由内核决定。
Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。
在 redis.conf 配置文件中的 appendfsync 配置项 可以有以下 3 种参数可填:Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后 每隔一秒将缓冲区里的内容写回到硬盘;No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。
这 3 种写回策略都无法能完美解决「主进程阻塞」和「减少数据丢失」的问题,因为两个问题是对立的,偏向于一边的话,就会要牺牲另外一边,原因如下:Always 策略的话,可以 最大程度保证数据不丢失,但是由于它每执行一条写操作命令就同步将 AOF 内容写回硬盘,所以是不可避免 会影响主进程的性能;No 策略的话,是交由操作系统来决定何时将 AOF 日志内容写回硬盘,相比于 Always 策略 性能较好,但是 操作系统写回硬盘的时机是不可预知的,如果 AOF 日志内容没有写回硬盘,一旦服务器宕机,就会丢失不定数量的数据。Everysec 策略的话,是 折中 的一种方式,避免了 Always 策略的性能开销,也比 No 策略更能避免数据丢失,当然如果上一秒的写操作命令日志没有写回到硬盘,发生了宕机,这一秒内的数据自然也会丢失。
大家根据自己的业务场景进行选择:如果要高性能,就选择 No 策略;如果要高可靠,就选择 Always 策略;如果允许数据丢失一点,但又想性能高,就选择 Everysec 策略。
我也把这 3 个写回策略的优缺点总结成了一张表格:
大家知道这三种策略是怎么实现的吗?
深入到源码后,你就会发现这三种策略只是 在控制 fsync() 函数的调用时机。
如果想要应用程序向文件写入数据后,能立马将数据同步到硬盘,就可以 调用 fsync() 函数,这样内核就会 将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。
Always 策略就是 每次写入 AOF 文件数据后,就执行 fsync() 函数;Everysec 策略就会 每秒创建一个异步任务来执行 fsync() 函数;No 策略就是 永不执行 fsync() 函数,由内核决定何时写入磁盘;
三种写回策略
AOF 日志是一个文件,随着执行的写操作命令越来越多,文件的大小会越来越大。
如果当 AOF 日志文件过大就会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果 文件过大,整个恢复的过程就会很慢。
所以,Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会 启用 AOF 重写机制,来压缩 AOF 文件。
AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后 将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。
举个例子,在没有使用重写机制前,假设前后执行了「set name xiaolin」和「set name xiaolincoding」这两个命令的话,就会将这两个命令记录到 AOF 文件。
但是 在使用重写机制后,就会读取 name 最新的 value(键值对) ,然后用一条 「set name xiaolincoding」命令记录到新的 AOF 文件,之前的第一个命令就没有必要记录了,因为它属于「历史」命令,没有作用了。这样一来,一个键值对在重写日志中只用一条命令就行了。
然后,在通过 AOF 日志恢复数据时,只用执行这条命令,就可以直接完成这个键值对的写入了。
所以,重写机制的妙处在于,尽管某个键值对被多条写命令反复修改,最终也只需要根据这个「键值对」当前的最新状态,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令,这样就减少了 AOF 文件中的命令数量。最后在重写工作完成后,将新的 AOF 文件覆盖现有的 AOF 文件。
为什么重写 AOF 的时候,不直接复用现有的 AOF 文件,而是先写到新的 AOF 文件再覆盖过去。
因为如果 AOF 重写过程中失败了,现有的 AOF 文件就会造成污染,可能无法用于恢复使用。
所以 AOF 重写过程,先重写到新的 AOF 文件,重写失败的话,就直接删除这个文件就好,不会对现有的 AOF 文件造成影响。
AOF 重写机制
写入 AOF 日志 的操作虽然是在主进程完成的,因为它 写入的内容不多,所以一般 不太影响命令的操作。
但是在触发 AOF 重写时,比如当 AOF 文件大于 64M 时,就会对 AOF 文件进行重写,这时是需要读取所有缓存的键值对数据,并为每个键值对生成一条命令,然后将其写入到新的 AOF 文件,重写完后,就把现在的 AOF 文件替换掉。
这个过程其实是很耗时的,所以 重写的操作不能放在主进程里。
所以,Redis 的 重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的,这么做可以达到两个好处:子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而 避免阻塞主进程;子进程带有主进程的数据副本(数据副本怎么产生的后面会说),这里 使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么 在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。
子进程是怎么拥有主进程一样的数据副本的呢?
主进程在通过 fork 系统调用生成 bgrewriteaof 子进程时,操作系统会把主进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。(页表: 即页面映像表,通过它能在内存中找到每个页面对应的物理块。)
这样一来,子进程就共享了父进程的物理内存数据了,这样能够 节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为 只读。
不过,当父进程或者子进程在向这个内存发起写操作时,CPU 就会触发 写保护中断,这个写保护中断是由于违反权限导致的,然后操作系统会 在「写保护中断处理函数」里进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为 可读写,最后才会对内存进行写操作,这个过程被称为「写时复制(Copy On Write)」。
当然,操作系统复制父进程页表的时候,父进程也是阻塞中的,不过 页表的大小相比实际的物理内存小很多,所以通常 复制页表的过程是比较快的。
不过,如果父进程的内存数据非常大,那自然页表也会很大,这时父进程在通过 fork 创建子进程的时候,阻塞的时间也越久。
所以,有两个阶段会导致阻塞父进程:创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,自然阻塞的时间也越长;
但是子进程重写过程中,主进程依然可以正常处理命令。
如果此时 主进程修改了已经存在 key-value,就会发生写时复制,注意这里只会复制主进程修改的物理内存数据,没修改物理内存还是与子进程共享的。
所以如果这个阶段修改的是一个 bigkey,也就是数据量比较大的 key-value 的时候,这时 复制的物理内存数据的过程就会比较耗时,有阻塞主进程的风险。
还有个问题,重写 AOF 日志过程中,如果主进程修改了已经存在 key-value,此时这个 key-value 数据 在子进程的内存数据就跟主进程的内存数据不一致了,这时要怎么办呢?
在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」。
在整个 AOF 后台重写过程中,除了发生写时复制会对主进程造成阻塞,还有信号处理函数执行时也会对主进程造成阻塞,在其他时候,AOF 后台重写都不会阻塞主进程。
AOF 后台重写
这次小林给大家介绍了 Redis 持久化技术中的 AOF 方法,这个方法是每执行一条写操作命令,就将该命令以追加的方式写入到 AOF 文件,然后在恢复时,以逐一执行命令的方式来进行数据恢复。
Redis 提供了三种将 AOF 日志写回硬盘的策略,分别是 Always、Everysec 和 No,这三种策略在可靠性上是从高到低,而在性能上则是从低到高。
随着执行的命令越多,AOF 文件的体积自然也会越来越大,为了避免日志文件过大, Redis 提供了 AOF 重写机制,它会直接扫描数据中所有的键值对数据,然后为每一个键值对生成一条写操作命令,接着将该命令写入到新的 AOF 文件,重写完成后,就替换掉现有的 AOF 日志。重写的过程是由后台子进程完成的,这样可以使得主进程可以继续正常处理命令。
用 AOF 日志的方式来恢复数据其实是很慢的,因为 Redis 执行命令由单线程负责的,而 AOF 日志恢复数据的方式是顺序执行日志里的每一条命令,如果 AOF 日志很大,这个「重放」的过程就会很慢了。
AOF 持久化是怎么实现的?
虽说 Redis 是内存数据库,但是它为数据的持久化提供了两个技术,分别是「 AOF 日志和 RDB 快照」。
这两种技术都会用各用一个日志文件来记录信息,但是记录的内容是不同的。AOF 文件的内容是 操作命令;RDB 文件的内容是 二进制数据。
关于 AOF 持久化的原理我在上一篇已经介绍了,今天主要讲下 RDB 快照。
所谓的快照,就是记录某一个瞬间东西,比如当我们给风景拍照时,那一个瞬间的画面和信息就记录到了一张照片。
所以,RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。
Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave,他们的区别就在于是否在「主线程」里执行:执行了 save 命令,就会 在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程;执行了 bgsave 命令,会创建一个 子进程来生成 RDB 文件,这样可以避免主线程的阻塞;
RDB 文件的加载工作是在服务器启动时自动执行的,Redis 并没有提供专门用于加载 RDB 文件的命令。
Redis 还可以通过配置文件的选项来实现 每隔一段时间自动执行一次 bgsave 命令,默认会提供以下配置:
别看选项名叫 save,实际上执行的是 bgsave 命令,也就是会创建子进程来生成 RDB 快照文件。
只要满足上面条件的任意一个,就会执行 bgsave,它们的意思分别是:900 秒之内,对数据库进行了至少 1 次修改;300 秒之内,对数据库进行了至少 10 次修改;60 秒之内,对数据库进行了至少 10000 次修改。
这里提一点,Redis 的快照是 全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。
所以可以认为,执行快照是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多。
通常可能设置至少 5 分钟才保存一次快照,这时如果 Redis 出现宕机等情况,则意味着最多可能丢失 5 分钟数据。
这就是 RDB 快照的缺点,在服务器发生故障时,丢失的数据会比 AOF 持久化的方式更多,因为 RDB 快照是全量快照的方式,因此执行的频率不能太频繁,否则会影响 Redis 性能,而 AOF 日志可以以秒级的方式记录操作命令,所以丢失的数据就相对更少。
快照怎么用?
那问题来了,执行 bgsave 过程中,由于是交给子进程来构建 RDB 文件,主线程还是可以继续工作的,此时主线程可以修改数据吗?
如果不可以修改数据的话,那这样性能一下就降低了很多。如果可以修改数据,又是如何做到到呢?
直接说结论吧,执行 bgsave 过程中,Redis 依然 可以继续处理操作命令的,也就是数据是能被修改的。
那具体如何做到到呢?关键的技术就在于 font color=\"#0000ff\
执行 bgsave 命令的时候,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个。
只有在发生修改内存数据的情况时,物理内存才会被复制一份。
这样的目的是为了减少创建子进程时的性能损耗,从而加快创建子进程的速度,毕竟创建子进程的过程中,是会阻塞主线程的。
所以,创建 bgsave 子进程后,由于共享父进程的所有内存数据,于是就可以直接读取主线程(父进程)里的内存数据,并将数据写入到 RDB 文件。
当主线程(父进程)对这些共享的内存数据也都是只读操作,那么,主线程(父进程)和 bgsave 子进程相互不影响。
但是,如果主线程(父进程)要 修改共享数据里的某一块数据(比如键值对 A)时,就会发生 写时复制,于是这块数据的 物理内存就会被复制一份(键值对 A),然后 主线程在这个数据副本(键值对 A)进行修改操作。与此同时,bgsave 子进程可以继续把原来的数据(键值对 A)写入到 RDB 文件。
就是这样,Redis 使用 bgsave 对当前内存中的所有数据做快照,这个操作是由 bgsave 子进程在后台完成的,执行时不会阻塞主线程,这就使得主线程同时可以修改数据。
细心的同学,肯定发现了,bgsave 快照过程中,如果主线程修改了共享数据,发生了写时复制后,RDB 快照保存的是原本的内存数据,而 主线程刚修改的数据,是被办法在这一时间写入 RDB 文件的,只能交由下一次的 bgsave 快照。
所以 Redis 在使用 bgsave 快照过程中,如果主线程修改了内存数据,不管是否是共享的内存数据,RDB 快照都无法写入主线程刚修改的数据,因为此时主线程(父进程)的内存数据和子进程的内存数据已经分离了,子进程写入到 RDB 文件的内存数据只能是原本的内存数据。
如果系统恰好在 RDB 快照文件创建完毕后崩溃了,那么 Redis 将会丢失主线程在快照期间修改的数据。
另外,写时复制的时候会出现这么个极端的情况。
在 Redis 执行 RDB 持久化期间,刚 fork 时,主进程和子进程共享同一物理内存,但是途中主进程处理了写操作,修改了共享内存,于是当前被修改的数据的物理内存就会被复制一份。
那么极端情况下,如果所有的共享内存都被修改,则此时的内存占用是原先的 2 倍。
所以,针对写操作多的场景,我们要留意下快照过程中内存的变化,防止内存被占满了。
执行快照时,数据能被修改吗?
RDB 快照是怎么实现的?
先来分析一下 AOF 和 RDB 各自的优点和缺点。
优点:更高的数据安全性,AOF 提供了三种同步策略,其中 everysec 是异步完成,而且最多只会有 1s 的数据丢失。写入采用 append 方式,写入过程中宕机,也不会影响已写入磁盘的内容。如果 AOF 文件过大,可以自启动重写机制,以压缩文件大小。
缺点:AOF 文件保存的是命令,所以重启恢复数据需要执行命令来恢复,恢复时比较耗时。体积相对更大,尽管是将 aof 文件重写了,但是毕竟是操作过程和操作结果仍然有很大的差别。
AOF
优点:相同的数据量 rdb 数据比 aof 的小,因为 rdb 是紧凑型文件。RDB 文件保存的是二进制数据,恢复较快。适合备份,它保存了某个时间点上的数据集。
缺点:数据安全性没那么高,如果在持久化之前出现宕机,没来得及写入磁盘的数据将丢失。而且 rdb 是全量复制,若在复制过程中发生异常,数据也会丢失。
RDB
那有没有什么方法不仅有 RDB 恢复速度快的优点,又有 AOF 丢失数据少的优点呢?
当然有,那就是将 RDB 和 AOF 合体使用,这个方法是在 Redis 4.0 提出的,该方法叫 混合使用 AOF 日志和内存快照,也叫混合持久化。
如果想要开启混合持久化功能,可以在 Redis 配置文件将下面这个配置项设置成 yes:
混合持久化工作在 AOF 日志重写过程。
当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程 共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的 操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程 将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。
也就是说,使用了混合持久化,AOF 文件的 前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。
这样的好处在于,重启 Redis 加载数据的时候,由于 前半部分是 RDB 内容,这样加载的时候速度会很快。
加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得 数据更少的丢失。
混合持久化
Redis 提供了 3 种 AOF 日志写回硬盘的策略,分别是:Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。
这三种策略只是在控制 fsync() 函数的调用时机上有所不同。
Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数;Everysec 策略就会每一秒创建一个异步任务来执行 fsync() 函数;No 策略就是永不执行 fsync() 函数;
先说说 AOF 日志三种写回磁盘的策略
在使用 Always 策略的时候,主线程在执行完命令后,会把数据写入到 AOF 日志文件,然后会调用 fsync() 函数,将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。
当使用 Always 策略的时候,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的。
当使用 Everysec 策略的时候,由于是 异步执行 fsync() 函数,所以大 Key 持久化的过程(数据同步磁盘)不会影响主线程。
当使用 No 策略的时候,由于 永不执行 fsync() 函数,所以大 Key 持久化的过程 不会影响主线程。
这三种策略在持久化大 Key 的时候,会影响什么?
大 key 对 AOF 日志的影响
AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 fork() 函数创建一个子进程来处理任务。
在创建子进程的过程中,操作系统 会把父进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。
这样一来,子进程就共享了父进程的物理内存数据了,这样能够节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为 只读。
随着 Redis 存在越来越多的 大 Key,那么 Redis 就会占用很多内存,对应的页表就会越大。当然,小 key 数量过多也会使页表变大。但这里想表达的是 大 key 会使页表变大得更快。
在通过 fork() 函数创建子进程的时候,虽然不会复制父进程的物理内存,但是内核会把父进程的页表复制一份给子进程,如果页表很大,那么这个复制过程是会很耗时的,那么在 执行 fork 函数的时候就会发生阻塞现象。
而且,fork 函数是由 Redis 主线程调用的,如果 fork 函数发生阻塞,那么意味着就会阻塞 Redis 主线程。由于 Redis 执行命令是在主线程处理的,所以当 Redis 主线程发生阻塞,就无法处理后续客户端发来的命令。
我们可以执行 info 命令获取到 latest_fork_usec 指标,表示 Redis 最近一次 fork 操作耗时。
如果 fork 耗时很大,比如超过1秒,则需要做出优化调整:单个实例的内存占用控制在 10 GB 以下,这样 fork 函数就能很快返回。如果 Redis 只是当作纯缓存使用,不关心 Redis 数据安全性问题,可以考虑关闭 AOF 和 AOF 重写,这样就不会调用 fork 函数了。在主从架构中,要适当调大 repl-backlog-size,避免因为 repl_backlog_buffer 不够大,导致主节点频繁地使用全量同步的方式,全量同步的时候,是会创建 RDB 文件的,也就是会调用 fork 函数。
当父进程或者子进程在向共享内存发起写操作时,CPU 就会触发 写保护中断,这个「写保护中断」是由于违反权限导致的,然后操作系统会 在「写保护中断处理函数」里进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为「写时复制(Copy On Write)」。
如果创建完子进程后,父进程对共享内存中的大 Key 进行了修改,那么内核就会发生 写时复制,会把物理内存复制一份,由于大 Key 占用的物理内存是比较大的,那么 在复制物理内存这一过程中,也是比较耗时的,于是 父进程(主线程)就会发生阻塞。
所以,有两个阶段会导致阻塞父进程:创建子进程的途中,由于 要复制父进程的页表 等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;创建完子进程后,如果子进程或者父进程 修改了共享数据,就会发生写时复制,这期间 会拷贝物理内存,如果内存越大,自然阻塞的时间也越长;
这里额外提一下, 如果 Linux 开启了内存大页,会影响 Redis 的性能的。
Linux 内核从 2.6.38 开始支持内存大页机制,该机制支持 2MB 大小的内存页分配,而常规的内存页分配是按 4KB 的粒度来执行的。
如果 采用了内存大页,那么即使客户端请求只修改 100B 的数据,在发生写时复制后,Redis 也需要拷贝 2MB 的大页。相反,如果是常规内存页机制,只用拷贝 4KB。
两者相比,你可以看到,每次写命令引起的 复制内存页单位放大了 512 倍,会拖慢写操作的执行时间,最终导致 Redis 性能变慢。
那该怎么办呢?很简单,关闭内存大页(默认是关闭的)。
那什么时候会发生物理内存的复制呢?
当 AOF 写回策略配置了 Always 策略,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的。
AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 fork() 函数创建一个子进程来处理任务。会有两个阶段会导致阻塞父进程(主线程):创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;创建完子进程后,如果父进程修改了共享数据中的大 Key,就会发生写时复制,这期间会拷贝物理内存,由于大 Key 占用的物理内存会很大,那么在复制物理内存这一过程,就会比较耗时,所以有可能会阻塞父进程。
大 key 除了会影响持久化之外,还会有以下的影响。客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。
如何避免大 Key 呢?最好在设计阶段,就把大 key 拆分成一个一个小 key。或者,定时检查 Redis 是否存在大 key ,如果该大 key 是可以删除的,不要使用 DEL 命令删除,因为该命令删除过程会阻塞主线程,而是用 unlink 命令(Redis 4.0+)删除大 key,因为该命令的删除过程是异步的,不会阻塞主线程。
Redis 大 key 对持久化有什么影响?
持久化篇
先说一下对 key 设置过期时间的命令。 设置 key 过期时间的命令一共有 4 个:expire <key> <n>:设置 key 在 n 秒后过期;pexpire <key> <n>:设置 key 在 n 毫秒后过期。expireat <key> <n>:设置 key 在某个时间戳(精确到秒)之后过期,比如 expireat key3 1655654400 表示 key3 在时间戳 1655654400 后过期(精确到秒);pexpireat <key> <n>:设置 key 在某个时间戳(精确到毫秒)之后过期,比如 pexpireat key4 1655654400000 表示 key4 在时间戳 1655654400000 后过期(精确到毫秒)
当然,在设置字符串时,也可以同时对 key 设置过期时间,共有 3 种命令:set <key> <value> ex <n> :设置键值对的时候,同时指定过期时间(精确到秒);set <key> <value> px <n> :设置键值对的时候,同时指定过期时间(精确到毫秒);setex <key> <n> <valule> :设置键值对的时候,同时指定过期时间(精确到秒)。
如果你想查看某个 key 剩余的存活时间,可以使用 TTL <key> 命令。
如果突然反悔,取消 key 的过期时间,则可以使用 PERSIST <key> 命令(persist:保持)。
如何设置过期时间?
每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个 过期字典(expires)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。
过期字典存储在 redisDb 结构中,如下:
过期字典数据结构结构如下:过期字典的 key 是一个指针,指向某个键对象;过期字典的 value 是一个 long long 类型的整数,这个整数保存了 key 的过期时间;
过期字典的数据结构如下图所示:
字典实际上是哈希表,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找。当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:如果不在,则正常读取键值;如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。
过期键判断流程如下图所示:
如何判定 key 已过期?
在说 Redis 过期删除策略之前,先跟大家介绍下,常见的三种过期删除策略:定时删除;惰性删除;定期删除;
定时删除策略的做法是,在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。
定时删除策略的 优点:可以保证 过期 key 会被尽快删除,也就是内存可以被尽快地释放。因此,定时删除对内存是最友好的。
定时删除策略的 缺点:在 过期 key 比较多 的情况下,删除过期 key 可能会 占用相当一部分 CPU 时间,在内存不紧张但 CPU 时间紧张的情况下,将 CPU 时间用于删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。所以,定时删除策略对 CPU 不友好。
定时删除策略是怎么样的?
惰性删除策略的做法是,不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。
惰性删除策略的 优点:因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源,因此,惰性删除策略对 CPU 时间最友好。
惰性删除策略的 缺点:如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个 过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。所以,惰性删除策略对内存不友好。
惰性删除策略是怎么样的?
定期删除策略的做法是,每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期 key。
定期删除策略的 优点:通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用,较综合。
定期删除策略的 缺点:内存清理方面没有定时删除效果好,同时没有惰性删除使用的系统资源少。难以确定删除操作执行的时长和频率。如果执行的太频繁,定期删除策略变得和定时删除策略一样,对CPU不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。
定期删除策略是怎么样的?
过期删除策略有哪些?
前面介绍了三种过期删除策略,每一种都有优缺点,仅使用某一个策略都不能满足实际需求。
所以, Redis 选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。
Redis 过期删除策略是什么?
Redis 的惰性删除策略由 db.c 文件中的 expireIfNeeded 函数实现,代码如下:
Redis 在访问或者修改 key 之前,都会调用 expireIfNeeded 函数对其进行检查,检查 key 是否过期:如果过期,则删除该 key,至于选择异步删除,还是选择同步删除,根据 lazyfree_lazy_expire 参数配置决定(Redis 4.0版本开始提供参数),然后返回 null 客户端;如果没有过期,不做任何处理,然后返回正常的键值对给客户端;
Redis 是怎么实现惰性删除的?
再回忆一下,定期删除策略的做法:每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期 key。
在 Redis 中,默认 每秒进行 10 次 过期检查一次数据库,此配置可通过 Redis 的配置文件 redis.conf 进行配置,配置键为 hz 它的默认值是 hz 10。
特别强调下,每次检查数据库并不是遍历过期字典中的所有 key,而是从数据库中 随机抽取一定数量 的 key 进行过期检查。
1、这个间隔检查的时间是多长呢?
我查了下源码,定期删除的实现在 expire.c 文件下的 activeExpireCycle 函数中,其中随机抽查的数量由 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 定义的,它是写死在代码中的,数值是 20。
也就是说,数据库每轮抽查时,会随机选择 20 个 key 判断是否过期。
接下来,详细说说 Redis 的定期删除的流程:从过期字典中随机抽取 20 个 key;检查这 20 个 key 是否过期,并删除已过期的 key;如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%, 则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。
可以看到,定期删除是一个循环的流程。
那 Redis 为了保证定期删除 不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。
针对定期删除的流程,我写了个伪代码:
2、随机抽查的数量是多少呢?
Redis 是怎么实现定期删除的?
如何实现惰性和定期删除?
过期删除策略
前面说的过期删除策略,是删除已过期的 key,而 当 Redis 的运行内存已经超过 Redis 设置的最大内存之后,则会使用 内存淘汰策略删除符合条件的 key,以此来保障 Redis 高效的运行。
在配置文件 redis.conf 中,可以通过参数 maxmemory <bytes> 来设定最大运行内存,只有在 Redis 的运行内存达到了我们设置的最大运行内存,才会触发内存淘汰策略。
不同位数的操作系统,maxmemory 的默认值是不同的:在 64 位 操作系统中,maxmemory 的默认值是 0,表示 没有内存大小限制,那么不管用户存放多少数据到 Redis 中,Redis 也不会对可用内存进行检查,直到 Redis 实例因内存不足而崩溃也无作为。在 32 位 操作系统中,maxmemory 的默认值是 3G,因为 32 位的机器最大只支持 4GB 的内存,而系统本身就需要一定的内存资源来支持运行,所以 32 位操作系统限制最大 3 GB 的可用内存是非常合理的,这样可以避免因为内存不足而导致 Redis 实例崩溃。(“32位操作系统”中的“位”并不是内存中的“bit”的概念,对应到内存中其实是“byte”,2^32 ➗ 1024 ÷ 1024 ÷ 1024 = 4G)
如何设置 Redis 最大运行内存?
noeviction(Redis3.0 之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据 写入,则会 触发 OOM,但是如果没用数据写入的话,只是单纯的查询或者删除操作的话,还是可以正常工作。(no eviction:不驱逐)
1、不进行数据淘汰的策略
在设置了过期时间的数据中进行淘汰:volatile-random:随机淘汰设置了过期时间的任意键值;volatile-ttl:优先淘汰更早过期的键值。volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;
在所有数据范围内进行淘汰:allkeys-random:随机淘汰任意键值;allkeys-lru:淘汰整个键值中最久未使用的键值;allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。
2、进行数据淘汰的策略
可以使用 config get maxmemory-policy 命令,来查看当前 Redis 的内存淘汰策略,命令如下:
可以看出,当前 Redis 使用的是 noeviction 类型的内存淘汰策略,它是 Redis 3.0 之后默认使用的内存淘汰策略,表示当运行内存超过最大设置内存时,不淘汰任何数据,但新增操作会报错。
如何查看当前 Redis 使用的内存淘汰策略?
设置内存淘汰策略有两种方法:方式一:通过“config set maxmemory-policy <策略>”命令设置。它的优点是设置之后立即生效,不需要重启 Redis 服务,缺点是重启 Redis 之后,设置就会失效。方式二:通过修改 Redis 配置文件修改,设置“maxmemory-policy <策略>”,它的优点是重启 Redis 服务后配置不会丢失,缺点是必须重启 Redis 服务,设置才能生效。
如何修改 Redis 内存淘汰策略?
LFU 内存淘汰算法是 Redis 4.0 之后新增内存淘汰策略,那为什么要新增这个算法?那肯定是为了解决 LRU 算法的问题。
接下来,就看看这两个算法有什么区别?Redis 又是如何实现这两个算法的?
Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题:需要 用链表管理 所有的缓存数据,这会带来 额外的空间开销;当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会 带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。
Redis 实现的是一种 近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。
当 Redis 进行内存淘汰时,会使用 随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个。
Redis 实现的 LRU 算法的优点:不用为所有的数据维护一个大链表,节省了空间占用;不用在每次数据访问时都移动链表项,提升了缓存的性能;
但是 近似 LRU 算法有一个问题,无法解决 缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。
因此,在 Redis 4.0 之后 引入了 LFU 算法来解决这个问题。
LFU 全称是 Least Frequently Used 翻译为 最近最不常用的,LFU 算法是 根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。
在 LRU 算法中,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。
在 LFU 算法中,Redis对象头的 24 bits 的 lru 字段 被分成两段来存储:span style=\
注意,logc 并不是单纯的访问次数,而是访问频次(访问频率),因为 logc 会随时间推移而衰减的。
在每次 key 被访问时,会先对 logc 做一个衰减操作,衰减的值跟前后访问时间的差距有关系,如果上一次访问的时间与这一次访问的时间差距很大,那么衰减的值就越大,这样实现的 LFU 算法是根据访问频率来淘汰数据的,而不只是访问次数。访问频率需要考虑 key 的访问是多长时间段内发生的。key 的先前访问距离当前时间越长,那么这个 key 的访问频率相应地也就会降低,这样被淘汰的概率也会更大。
对 logc 做完衰减操作后,就开始对 logc 进行增加操作,增加操作并不是单纯的 + 1,而是根据概率增加,如果 logc 越大的 key,它的 logc 就越难再增加。
所以,Redis 在访问 key 时,对于 logc 是这样变化的:先按照上次访问距离当前的时长,来对 logc 进行衰减;然后,再按照一定概率增加 logc 的值
redis.conf 提供了两个配置项,用于 调整 LFU 算法从而控制 logc 的增长和衰减:lfu-decay-time 用于调整 logc 的衰减速度,它是一个以分钟为单位的数值,默认值为1,lfu-decay-time 值越大,衰减越慢;lfu-log-factor 用于调整 logc 的增长速度,lfu-log-factor 值越大,logc 增长越慢。
LRU 算法和 LFU 算法有何区别?
内存淘汰策略
Redis 使用的过期删除策略是「惰性删除+定期删除」,删除的对象是已过期的 key。
内存淘汰策略是解决内存过大的问题,当 Redis 的运行内存超过最大运行内存时,就会触发内存淘汰策略,Redis 4.0 之后共实现了 8 种内存淘汰策略,我也对这 8 种的策略进行分类,如下:
功能篇
在前两篇已经给大家图解了 AOF 和 RDB,这两个持久化技术保证了即使在服务器重启的情况下也不会丢失数据(或少量损失)。
不过,由于数据都是存储在一台服务器上,如果出事就完犊子了,比如:如果服务器发生了宕机,由于数据恢复是需要点时间,那么这个期间是无法服务新的请求的;如果这台服务器的硬盘出现了故障,可能数据就都丢失了。
要避免这种单点故障,最好的办法是将数据备份到其他服务器上,让这些服务器也可以对外提供服务,这样即使有一台服务器出现了故障,其他服务器依然可以继续提供服务。
多台服务器要保存同一份数据,这里问题就来了。
这些服务器之间的数据如何保持一致性呢?数据的读写操作是否每台服务器都可以处理?
Redis 提供了 主从复制模式,来避免上述的问题。
这个模式可以保证多台服务器的数据一致性,且主从服务器之间采用的是「读写分离」的方式。
主服务器可以进行读写操作,当发生写操作时自动将写操作同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。
同步这两个字说的简单,但是这个同步过程并没有想象中那么简单,要考虑的事情不是一两个。
我们先来看看,主从服务器间的第一次同步是如何工作的?
多台服务器之间要通过什么方式来确定谁是主服务器,或者谁是从服务器呢?
我们可以使用 replicaof(Redis 5.0 之前使用 slaveof)命令形成主服务器和从服务器的关系。
比如,现在有服务器 A 和 服务器 B,我们在服务器 B 上执行下面这条命令:
接着,服务器 B 就会变成服务器 A 的「从服务器」,然后与主服务器进行第一次同步。
主从服务器间的第一次同步的过程可分为三个阶段:第一阶段是建立链接、协商同步;第二阶段是主服务器同步数据给从服务器;第三阶段是主服务器发送新写操作命令给从服务器。
为了让你更清楚了解这三个阶段,我画了一张图。
执行了 replicaof 命令后,从服务器就会给主服务器发送 psync 命令,表示 要进行数据同步。
psync 命令包含两个参数,分别是 主服务器的 runID 和 复制进度 offset。runID,每个 Redis 服务器在启动时都会自动生产一个随机的 ID 来唯一标识自己。当从服务器和主服务器第一次同步时,因为不知道主服务器的 run ID,所以将其设置为 \"?\"。offset,表示复制的进度,第一次同步时,其值为 -1。
主服务器收到 psync 命令后,会用 FULLRESYNC 作为响应命令返回给对方。并且这个响应命令会带上两个参数:主服务器的 runID 和主服务器目前的复制进度 offset。从服务器收到响应后,会记录这两个值。
FULLRESYNC 响应命令的意图是采用 全量复制 的方式,也就是主服务器会 把所有的数据都同步给从服务器。
所以,第一阶段的工作时为了全量复制做准备。
那具体怎么全量同步呀呢?我们可以往下看第二阶段。
第一阶段:建立链接、协商同步
接着,主服务器 会执行 bgsave 命令来生成 RDB 文件,然后把文件 发送给从服务器。
从服务器收到 RDB 文件后,会 先清空当前的数据,然后 载入 RDB 文件。
这里有一点要注意,主服务器生成 RDB 这个过程是不会阻塞主线程的,因为 bgsave 命令是产生了一个子进程来做生成 RDB 文件的工作,是异步工作的,这样 Redis 依然可以正常处理命令。
但是,这期间的写操作命令并没有记录到刚刚生成的 RDB 文件中,这时 主从服务器间的数据就不一致了。
那么为了保证主从服务器的数据一致性,主服务器在下面这三个时间间隙中将收到的写操作命令,写入到 replication buffer 缓冲区 b style=\
第二阶段:主服务器同步数据给从服务器
在主服务器生成的 RDB 文件发送完,从服务器收到 RDB 文件后,丢弃所有旧数据,将 RDB 数据载入到内存。完成 RDB 的载入后,会回复一个确认消息给主服务器。
接着,主服务器将 replication buffer 缓冲区里所记录的写操作命令发送给从服务器,从服务器执行来自主服务器 replication buffer 缓冲区里发来的命令,这时 主从服务器的数据就一致了。
至此,主从服务器的第一次同步的工作就完成了。
第三阶段:主服务器发送新写操作命令给从服务器
第一次同步
主从服务器在 完成第一次同步后,双方之间就会 维护一个 TCP 连接。
后续主服务器可以通过这个连接继续将写操作命令传播给从服务器,然后从服务器执行该命令,使得与主服务器的数据库状态相同。
而且这个连接是 长连接 的,目的是避免频繁的 TCP 连接和断开带来的性能开销。
上面的这个过程被称为 基于长连接的命令传播,通过这种方式来保证第一次同步后的主从服务器的数据一致性。
命令传播
在前面的分析中,我们可以知道主从服务器在 第一次数据同步 的过程中,主服务器会做 两件耗时的操作:生成 RDB 文件和传输 RDB 文件。
主服务器是可以有多个从服务器的,如果 从服务器数量非常多,而且都与主服务器进行全量同步的话,就会带来两个问题:由于是通过 bgsave 命令来生成 RDB 文件的,那么 主服务器就会忙于使用 fork() 创建子进程,如果主服务器的内存数据非大,在执行 fork() 函数时是会阻塞主线程的,从而使得 Redis 无法正常处理请求;传输 RDB 文件会占用主服务器的网络带宽,会对主服务器响应命令请求产生影响。
这种情况就好像,刚创业的公司,由于人不多,所以员工都归老板一个人管,但是随着公司的发展,人员的扩充,老板慢慢就无法承担全部员工的管理工作了。
要解决这个问题,老板就需要设立经理职位,由经理管理多名普通员工,然后老板只需要管理经理就好。
Redis 也是一样的,从服务器可以有自己的从服务器,我们可以把拥有从服务器的从服务器当作经理角色,它不仅可以接收主服务器的同步数据,自己也可以同时作为主服务器的形式将数据同步给从服务器,组织形式如下图:
相当于一个服务器既是主服务器,也是从服务器。
通过这种方式,主服务器生成 RDB 和传输 RDB 的压力可以分摊到充当经理角色的从服务器。
那具体怎么做到的呢?
其实很简单,我们在「从服务器」上执行下面这条命令,使其作为目标服务器的从服务器:
此时如果目标服务器本身也是「从服务器」,那么该目标服务器就会成为「经理」的角色,不仅可以接受主服务器同步的数据,也会把数据同步给自己旗下的从服务器,从而减轻主服务器的负担。
分摊主服务器的压力
主从服务器在完成第一次同步后,就会 基于长连接进行命令传播。
可是,网络总是不按套路出牌的嘛,说延迟就延迟,说断开就断开。
如果主从服务器间的网络连接断开了,那么就无法进行命令传播了,这时从服务器的数据就没办法和主服务器保持一致了,客户端就可能从「从服务器」读到旧的数据。
那么问题来了,如果此时 断开的网络,又恢复正常了,要怎么继续保证主从服务器的数据一致性呢?
在 Redis 2.8 之前,如果主从服务器在命令同步时出现了网络断开又恢复的情况,从服务器就会和主服务器 重新进行一次全量复制,很明显这样的 开销太大了,必须要改进一波。
所以,从 Redis 2.8 开始,网络断开又恢复后,从主从服务器会采用 增量复制 的方式继续同步,也就是 只会把网络断开期间主服务器接收到的写操作命令,同步给从服务器。
网络恢复后的增量复制过程如下图:
主要有三个步骤:从服务器在恢复网络后,会发送 psync 命令给主服务器,此时的 psync 命令里的 offset 参数不是 -1;主服务器收到该命令后,然后用 CONTINUE 响应命令告诉从服务器接下来采用增量复制的方式同步数据;然后主服务将主从服务器断线期间,所执行的 写命令发送给从服务器,然后从服务器执行这些命令。
那么关键的问题来了,主服务器怎么知道要将哪些增量数据发送给从服务器呢?
答案藏在这两个东西里:repl_backlog_buffer,是一个「环形」缓冲区,位于主服务器中,用于主从服务器断连后,从中找到差异的数据;replication_offset,标记上面那个 缓冲区的同步进度,主从服务器都有各自的偏移量。主服务器使用 master_repl_offset 来记录自己「写」到的位置;从服务器使用 slave_repl_offset 来记录自己「读」到的位置。
那 repl_backlog_buffer 缓冲区是什么时候写入的呢?
在主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会 将写命令写入到 repl_backlog_buffer 缓冲区里,因此这个缓冲区里会保存着最近传播的写命令。
网络断开后,当从服务器重新连上主服务器时,从服务器会通过 psync 命令将自己的复制偏移量 slave_repl_offset 发送给主服务器,主服务器 根据自己的 master_repl_offset 和 slave_repl_offset 之间的差距,然后来决定对从服务器执行哪种同步操作:如果判断出 从服务器要读取的数据还在 repl_backlog_buffer 缓冲区里,那么主服务器将采用 增量同步 的方式;相反,如果判断出 从服务器要读取的数据已经不存在 repl_backlog_buffer 缓冲区里,那么主服务器将采用 全量同步 的方式。
当主服务器 在 repl_backlog_buffer 中找到主从服务器差异(增量)的数据后,就会 将增量的数据写入到 replication buffer 缓冲区,这个缓冲区我们前面也提到过,它是缓存将要传播给从服务器的命令。
repl_backlog_buffer 缓行缓冲区的 默认大小是 1M,并且由于它是一个 环形缓冲区,所以当缓冲区写满后,主服务器继续写入的话,就会 覆盖 之前的数据。因此,当主服务器的写入速度远超于从服务器的读取速度,缓冲区的数据很快就会被覆盖。
那么在网络恢复时,如果从服务器想读的数据已经被覆盖了,主服务器就会采用 全量同步,这个方式比增量同步的性能损耗要大很多。
因此,为了避免在网络恢复时,主服务器频繁地使用全量同步的方式,我们应该调整下 repl_backlog_buffer 缓冲区大小,尽可能的大一些,减少出现从服务器要读取的数据被覆盖的概率,从而使得主服务器采用增量同步的方式。
那 repl_backlog_buffer 缓冲区具体要调整到多大呢?
repl_backlog_buffer 最小的大小可以根据这面这个公式估算。
我来解释下这个公式的意思:second 为 从服务器断线后重新连接上主服务器所需的平均时间 (以秒计算)。write_size_per_second 则是 主服务器平均每秒产生的写命令数据量大小。
举个例子,如果主服务器平均每秒产生 1 MB 的写命令,而从服务器断线之后平均要 5 秒才能重新连接主服务器。
那么 repl_backlog_buffer 大小就不能低于 5 MB,否则新写地命令就会覆盖旧数据了。
当然,为了应对一些突发的情况,可以将 repl_backlog_buffer 的大小设置为此基础上的 2 倍,也就是 10 MB。
关于 repl_backlog_buffer 大小修改的方法,只需要修改配置文件里下面这个参数项的值就可以。
增量复制
主从复制共有三种模式:全量复制、基于长连接的命令传播、增量复制。
主从服务器第一次同步的时候,就是采用全量复制,此时主服务器会两个耗时的地方,分别是生成 RDB 文件和传输 RDB 文件。为了避免过多的从服务器和主服务器进行全量复制,可以把一部分从服务器升级为「经理角色」,让它也有自己的从服务器,通过这样可以分摊主服务器的压力。
第一次同步完成后,主从服务器都会维护着一个长连接,主服务器在接收到写操作命令后,就会通过这个连接将写命令传播给从服务器,来保证主从服务器的数据一致性。
如果遇到网络断开,增量复制就可以上场了,不过这个还跟 repl_backlog_size 这个大小有关系。
如果它配置的过小,主从服务器网络恢复时,可能发生「从服务器」想读的数据已经被覆盖了,那么这时就会导致主服务器采用全量复制的方式。所以为了避免这种情况的频繁发生,要调大这个参数的值,以降低主从服务器断开后全量同步的概率。
主从复制怎么实现的?
长连接。
Redis 主从节点是长连接还是短链接
Redis 判断接点是否正常工作,基本都是通过互相的 ping-pong 心态检测机制,如果有一半以上的节点去 ping 一个节点的时候没有 pong 回应,集群就会认为这个节点挂掉了,会断开与这个节点的连接。
Redis 主从节点发送的心跳间隔是不一样的,而且作用也有一点区别:Redis 主节点默认每隔 10 秒对从节点发送 ping 命令,判断从节点的存活性和连接状态,可通过参数 repl-ping-slave-period 控制发送频率。Redis 从节点每隔 1 秒发送 replconf ack{offset} 命令,给主节点上报自身当前的复制偏移量,目的是为了: (1)实时监测主从节点网络状态; (2)上报自身复制偏移量, 检查复制数据是否丢失, 如果从节点数据丢失, 再从主节点的复制缓冲区中拉取丢失数据。
集群中怎么判断 Redis 某个节点是否正常工作
主节点处理了一个 key 或者通过淘汰算法淘汰了一个 key,这个时间 主节点模拟一条 del 命令发送给从节点,从节点收到该命令后,就进行删除key的操作。
主从复制架构中,过期 key 如何处理
Redis 主节点每次收到写命令之后,先写到内部的缓冲区,然后 异步 发送给从节点。
Redis 是同步复制还是异步复制
replication buffer 、repl backlog buffer 区别如下:出现的阶段不一样:repl backlog buffer 是在 增量复制阶段出现,一个主节点只分配一个 repl backlog buffer;replication buffer 是在全量复制阶段和增量复制阶段都会出现,主节点会给每个新连接的从节点,分配一个 replication buffer;这两个 Buffer 都有大小限制的,当缓冲区满了之后,发生的事情不一样:当 repl backlog buffer 满了,因为是 环形 结构,会直接覆盖起始位置数据;当 replication buffer 满了,会导致连接断开,删除缓存,从节点重新连接,重新开始全量复制。
主从复制中两个 Buffer 有什么区别
主从数据不一致,就是指客户端从从节点中读取到的值和主节点中的最新值并不一致。
之所以会出现主从数据不一致的现象,是因为 主从节点间的命令复制是异步进行的,所以 无法实现强一致性保证(主从数据时时刻刻保持一致)。
具体来说,在主从节点命令传播阶段,主节点收到新的写命令后,会发送给从节点。但是,主节点并不会等到从节点实际执行完命令后,再把结果返回给客户端,而是主节点自己在本地执行完命令后,就会向客户端返回结果了。如果从节点还没有执行主节点同步过来的命令,主从节点间的数据就不一致了。
为什么会出现主从数据不一致?
第一种方法,尽量保证 主从节点间的网络连接状况良好,避免主从节点在不同的机房。
第二种方法,可以 开发一个外部程序来监控主从节点间的复制进度。具体做法:Redis 的 INFO replication 命令可以查看主节点接收写命令的进度信息(master_repl_offset)和从节点复制写命令的进度信息(slave_repl_offset),所以,我们就可以开发一个 监控程序,先用 INFO replication 命令查到主、从节点的进度,然后,我们 用 master_repl_offset 减去 slave_repl_offset,这样就能得到从节点和主节点间的复制进度差值了。如果某个 从节点的进度差值大于我们预设的阈值,我们可以让客户端不再和这个从节点连接进行数据读取,这样就可以减少读到不一致数据的情况。不过,为了避免出现客户端和所有从节点都不能连接的情况,我们需要把复制进度差值的阈值设置得大一些。
如何如何应对主从数据不一致?
如何应对主从数据不一致
主从切换过程中,产生数据丢失的情况有两种:异步复制同步丢失集群产生脑裂数据丢失我们不可能保证数据完全不丢失,只能做到使得尽量少的数据丢失。
对于 Redis 主节点与从节点之间的数据复制,是异步复制的,当客户端发送写请求给主节点的时候,客户端会返回 ok,接着主节点将写请求异步同步给各个从节点,但是如果此时 主节点还没来得及同步给从节点时发生了断电,那么 主节点内存中的数据会丢失。
有什么方案能有效减少异步复制的数据丢失呢?
Redis 配置里有一个参数 min-slaves-max-lag,表示一旦 所有的从节点数据复制和同步的延迟都超过了 min-slaves-max-lag 定义的值,那么 主节点就会拒绝接收任何请求。
假设将 min-slaves-max-lag 配置为 10s 后,根据目前 master->slave 的复制速度,如果 数据同步完成所需要时间超过10s,就会认为 master 未来宕机后损失的数据会很多,master 就拒绝写入新请求。这样就能将 master 和 slave 数据差控制在10s内,即使 master 宕机也只是这未复制的 10s 数据。
异步复制同步丢失
主从切换如何减少数据丢失
主节点挂了 ,从节点是无法自动升级为主节点的,这个过程需要人工处理,在此期间 Redis 无法对外提供写操作。
此时,Redis 哨兵机制 就登场了,哨兵在发现主节点出现故障时,由哨兵自动完成故障发现和故障转移,并通知给应用方,从而实现高可用性。
那么对于客户端,当客户端发现 master 不可写后,我们可以采取降级措施,将数据暂时写入本地缓存和磁盘中,在一段时间(等 master 恢复正常)后重新写入 master 来保证数据不丢失,也可以将数据写入 kafka 消息队列,等 master 恢复正常,再隔一段时间去消费 kafka 中的数据,让将数据重新写入 master 。
主从如何做到故障自动切换
集群产生脑裂数据丢失
面试题
在 Redis 的主从架构中,由于主从模式是读写分离的,如果主节点(master)挂了,那么将没有主节点来服务客户端的写操作请求,也没有主节点给从节点(slave)进行数据同步了。
这时如果要恢复服务的话,需要人工介入,选择一个「从节点」切换为「主节点」,然后让其他从节点指向新的主节点,同时还需要通知上游那些连接 Redis 主节点的客户端,将其配置中的主节点 IP 地址更新为「新主节点」的 IP 地址。
这样也不太“智能”了,要是有一个节点能监控「主节点」的状态,当发现主节点挂了 ,它自动将一个「从节点」切换为「主节点」的话,那么可以节省我们很多事情啊!
Redis 在 2.8 版本以后提供的 哨兵(Sentinel)机制,它的作用是 实现主从节点故障转移。它会 监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。
为什么要有哨兵机制?
哨兵其实是一个运行在特殊模式下的 Redis 进程,所以它也是一个节点。从“哨兵”这个名字也可以看得出来,它相当于是“观察者节点”,观察的对象是主从节点。
当然,它不仅仅是观察那么简单,在它观察到有异常的状况下,会做出一些“动作”,来修复异常状态。
哨兵节点主要负责三件事情:监控、选主、通知。
所以,我们重点要学习这三件事情:哨兵节点是如何监控节点的?又是如何判断主节点是否真的故障了?根据什么规则选择一个从节点切换为主节点?怎么把新主节点的相关信息通知给从节点和客户端呢?
哨兵机制是如何工作的?
哨兵会每隔 1 秒给所有主从节点发送 PING 命令,当主从节点收到 PING 命令后,会发送一个响应命令给哨兵,这样就可以判断它们是否在正常运行。
如果主节点或者从节点没有在规定的时间内响应哨兵的 PING 命令,哨兵就会将它们标记为「主观下线」。这个「规定的时间」是配置项 down-after-milliseconds 参数设定的,单位是毫秒。
有的,客观下线只适用于主节点。
之所以针对「主节点」设计「主观下线」和「客观下线」两个状态,是因为有可能「主节点」其实并没有故障,可能只是因为主节点的系统压力比较大或者网络发送了拥塞,导致主节点没有在规定时间内响应哨兵的 PING 命令。
所以,为了减少误判的情况,哨兵在部署的时候不会只部署一个节点,而是用多个节点部署成 哨兵集群(最少需要三台机器来部署哨兵集群),通过多个哨兵节点一起判断,就可以就可以避免单个哨兵因为自身网络状况不好,而误判主节点下线的情况。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。
具体是怎么判定主节点为「客观下线」的呢?
当一个哨兵判断主节点为「主观下线」后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。
当这个哨兵的 赞同票数达到哨兵配置文件中的 quorum(法定人数) 配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」。
例如,现在有 3 个哨兵,quorum 配置的是 2,那么一个哨兵需要 2 张赞成票,就可以标记主节点为“客观下线”了。这 2 张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。
PS:quorum 的值一般设置为哨兵个数的二分之一加1,例如 3 个哨兵就设置 2。
哨兵判断完主节点客观下线后,哨兵就要开始在多个「从节点」中,选出一个从节点来做新主节点。
主观下线是什么?有客观下线吗?
如何判断主节点真的故障了?
前面说过,为了更加“客观”的判断主节点故障了,一般不会只由单个哨兵的检测结果来判断,而是多个哨兵一起判断,这样可以减少误判概率,所以 哨兵是以哨兵集群的方式存在的。
问题来了,由哨兵集群中的哪个节点进行主从故障转移呢?
所以这时候,还需要在哨兵集群中选出一个 leader,让 leader 来执行主从切换。
选举 leader 的过程其实是一个投票的过程,在投票开始前,肯定得有个「候选者」。
哪个哨兵节点判断主节点为「客观下线」,这个哨兵节点就是候选者,所谓的候选者就是想当 Leader 的哨兵。
举个例子,假设有三个哨兵。当哨兵 B 先判断到主节点「主观下线后」,就会给其他实例发送 is-master-down-by-addr 命令。接着,其他哨兵会根据自己和主节点的网络连接情况,做出赞成投票或者拒绝投票的响应。
当哨兵 B 收到赞成票数达到哨兵配置文件中的 quorum 配置项设定的值后,就会将主节点标记为「客观下线」,此时的哨兵 B 就是一个Leader 候选者。
那谁来作为候选者呢?
候选者会向其他哨兵发送命令,表明希望成为 Leader 来执行主从切换,并让所有其他哨兵对它进行投票。
每个哨兵只有一次投票机会,如果用完后就不能参与投票了,可以投给自己或投给别人,但是只有候选者才能把票投给自己。
那么在投票过程中,任何一个「候选者」,要满足两个条件:第一,拿到半数以上的赞成票;第二,拿到的票数同时还需要 大于等于哨兵配置文件中的 quorum 值。
注意:判断一个节点客观下线由 quorum 决定;选举 Leader 需要由赞成票一半以上和 quorum 共同决定。
举个例子,假设哨兵节点有 3 个,quorum 设置为 2,那么任何一个想成为 Leader 的哨兵只要拿到 2 张赞成票,就可以选举成功了。如果没有满足条件,就需要重新进行选举。
这时候有的同学就会问了,如果某个时间点,刚好有两个哨兵节点判断到主节点为客观下线,那这时不就 有两个候选者了?这时该如何决定谁是 Leader 呢?
每位候选者都会先给自己投一票,然后向其他哨兵发起投票请求。如果投票者先收到「候选者 A」的投票请求,就会先投票给它,如果投票者用完投票机会后,收到「候选者 B」的投票请求后,就会拒绝投票。这时,候选者 A 先满足了上面的那两个条件,所以「候选者 A」就会被选举为 Leader。
也就是说,其他哨兵先接收到哪个候选者的投票请求,就会先给它投票。其他候选者再发投票请求也没用,因为每个哨兵只能投一次票。
候选者如何选举成为 Leader?
如果哨兵集群中只有 2 个哨兵节点,此时如果一个哨兵想要成功成为 Leader,必须获得 2 票,而不是 1 票。
所以,如果哨兵集群中 有个哨兵挂掉了,那么就只剩一个哨兵了,如果这个哨兵想要成为 Leader,这时票数就没办法达到 2 票,就无法成功成为 Leader,这时是无法进行主从节点切换的。
因此,通常我们至少会配置 3 个哨兵节点。这时,如果哨兵集群中有个哨兵挂掉了,那么还剩下两个个哨兵,如果这个哨兵想要成为 Leader,这时还是有机会达到 2 票的,所以还是可以选举成功的,不会导致无法进行主从节点切换。
当然,你要问,如果 3 个哨兵节点,挂了 2 个怎么办?这个时候得人为介入了,或者增加多一点哨兵节点。
再说一个问题,Redis 1 主 4 从,5 个哨兵 ,quorum 设置为 3,如果 2 个哨兵故障,当主节点宕机时,哨兵能否判断主节点“客观下线”?主从能否自动切换?哨兵集群 可以 判定主节点“客观下线”。哨兵集群还剩下 3 个哨兵,当一个哨兵判断主节点“主观下线”后,询问另外 2 个哨兵后,有可能能拿到 3 张赞同票,这时就达到了 quorum 的值,因此,哨兵集群可以判定主节点为“客观下线”。哨兵集群 可以 完成主从切换。当有个哨兵标记主节点为「客观下线」后,就会进行选举 Leader 的过程,因为此时哨兵集群还剩下 3 个哨兵,那么还是可以拿到半数以上(5/2+1=3)的票,而且也达到了 quorum 值,满足了选举 Leader 的两个条件, 所以就能选举成功,因此哨兵集群可以完成主从切换。
可以看到,quorum 为 2 的时候,并且如果有 3 个哨兵故障的话,虽然可以判定主节点为“客观下线”,但是不能完成主从切换,这样感觉「判定主节点为客观下线」这件事情白做了一样,既然这样,还不如不要做,quorum 为 3 的时候,就可以避免这种无用功。
所以,quorum 的值建议设置为哨兵个数的二分之一加 1,例如 3 个哨兵就设置 2,5 个哨兵设置为 3,而且 哨兵节点的数量应该是奇数。
为什么哨兵节点至少要有 3 个?
由哪个哨兵进行主从故障转移?
在哨兵集群中通过投票的方式,选举出了哨兵 leader 后,就可以进行主从故障转移的过程了,如下图:
主从故障转移操作包含以下四个步骤:第一步:在已下线主节点(旧主节点)属下的所有「从节点」里面,挑选出一个从节点,并将其转换为主节点。第二步:让已下线主节点属下的所有「从节点」修改复制目标,修改为复制「新主节点」;第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端;第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点;
故障转移操作第一步要做的就是在已下线主节点属下的所有「从节点」中,挑选出一个状态良好、数据完整的从节点,然后向这个「从节点」发送 SLAVEOF no one 命令,将这个「从节点」转换为「主节点」。
那么多「从节点」,到底选择哪个从节点作为新主节点的?
随机的方式好吗?随机的方式,实现起来很简单,但是如果选到一个网络状态不好的从节点作为新主节点,那么可能在将来不久又要做一次主从故障迁移。
所以,我们首先要把网络状态不好的从节点给过滤掉。首先把已经下线的从节点过滤掉,然后把以往网络连接状态不好的从节点也给过滤掉。
怎么判断从节点之前的网络连接状态不好呢?
哨兵 有个叫 down-after-milliseconds 的配置项,表示 当一个节点失去联系超过了这个时间,哨兵就开始认为这个节点挂掉了。我们会 排除所有与已下线主节点连接断开超过 down-after-milliseconds * 10 毫秒的从节点。即表示哨兵累计 10 次认为该从节点挂掉了,那么哨兵会排除它。这样可以 保证剩余的从节点都没有过早地与原来主节点断开连接,因为这些从节点没被哨兵认为挂掉超过 10 次,即剩余的从节点保存的数据都是比较新的。
至此,我们就把网络状态不好的从节点过滤掉了,接下来要对所有从节点进行三轮考察:优先级、复制进度、ID 号。在进行每一轮考察的时候,哪个从节点优先胜出,就选择其作为新主节点。
第一轮考察:哨兵首先会根据从节点的优先级来进行排序,优先级越小排名越靠前,第二轮考察:如果优先级相同,则查看复制的下标,哪个从「主节点」接收的复制数据多,哪个就靠前。第三轮考察:如果优先级和下标都相同,就选择从节点 ID 较小的那个。
Redis 有个叫 slave-priority 配置项,可以给从节点设置优先级。
每一台从节点的服务器配置不一定是相同的,我们可以根据服务器性能配置来设置从节点的优先级。
比如,如果 「 A 从节点」的物理内存是所有从节点中最大的, 那么我们可以把「 A 从节点」的优先级设置成最高。这样当哨兵进行第一轮考虑的时候,优先级最高的 A 从节点就会优先胜出,于是就会成为新主节点。
第一轮考察:优先级最高的从节点胜出
如果在第一轮考察中,发现优先级最高的从节点有两个,那么就会进行第二轮考察,比较两个从节点哪个复制进度。
什么是复制进度?主从架构中,主节点会将写操作同步给从节点,在这个过程中,主节点会用 master_repl_offset 记录当前的最新写操作在 repl_backlog_buffer 中的位置(如下图中的「主服务器已经写入的数据」的位置),而从节点会用 slave_repl_offset 这个值记录当前的复制进度(如下图中的「从服务器要读的位置」的位置)。
如果某个从节点的 slave_repl_offset 最接近 master_repl_offset,说明它的 复制进度是最靠前的,于是就可以将它选为新主节点。
第二轮考察:复制进度最靠前的从节点胜出
如果在第二轮考察中,发现有两个从节点优先级和复制进度都是一样的,那么就会进行第三轮考察,比较两个从节点的 ID 号,ID 号小的从节点胜出。
什么是 ID 号?每个从节点都有一个编号,这个编号就是 ID 号,是用来唯一标识从节点的。
第三轮考察:ID 号小的从节点胜出
到这里,选主的事情终于结束了。简单给大家总结下:
在选举出从节点后,哨兵 leader 向被选中的从节点发送 SLAVEOF no one 命令,让这个从节点解除从节点的身份,将其变为新主节点。
如下图,哨兵 leader 向被选中的从节点 server2 发送 SLAVEOF no one 命令,将该从节点升级为新主节点。
在发送 SLAVEOF no one 命令之后,哨兵 leader 会以每秒一次的频率向被升级的从节点发送 INFO 命令(没进行故障转移之前,INFO 命令的频率是每十秒一次),并观察命令回复中的角色信息,当被升级节点的角色信息从原来的 slave 变为 master 时,哨兵 leader 就知道被选中的从节点已经顺利升级为主节点了。
如下图,选中的从节点 server2 升级成了新主节点:
步骤一:选出新主节点
当新主节点出现之后,哨兵 leader 下一步要做的就是,让已下线主节点属下的所有「从节点」指向「新主节点」,这一动作可以直接向「从节点」发送 SLAVEOF 命令来实现。
如下图,哨兵 leader 向所有从节点(server3和server4)发送 SLAVEOF ,让它们成为新主节点的从节点。
所有从节点指向新主节点后的拓扑图如下:
步骤二:将从节点指向新主节点
经过前面一系列的操作后,哨兵集群终于完成主从切换的工作,那么新主节点的信息要如何通知给客户端呢?
这主要通过 Redis 的发布者/订阅者机制 来实现的。每个哨兵节点提供发布者/订阅者机制,客户端可以从哨兵订阅消息。
哨兵提供的消息订阅频道有很多,不同频道包含了主从节点切换过程中的不同关键事件,几个常见的事件如下:
客户端和哨兵建立连接后,客户端会订阅哨兵提供的频道。主从切换完成后,哨兵就会向 +switch-master 频道发布新主节点的 IP 地址和端口的消息,这个时候客户端就可以收到这条信息,然后用这里面的新主节点的 IP 地址和端口进行通信了。
通过发布者/订阅者机制机制,有了这些事件通知,客户端不仅可以在主从切换后得到新主节点的连接信息,还可以监控到主从节点切换过程中发生的各个重要事件。这样,客户端就可以知道主从切换进行到哪一步了,有助于了解切换进度。
步骤三:通知客户的主节点已更换
故障转移操作最后要做的是,继续监视旧主节点,当旧主节点重新上线时,哨兵集群就会向它发送 SLAVEOF 命令,让它成为新主节点的从节点,如下图:
至此,整个主从节点的故障转移的工作结束。
步骤四:将旧主节点变为从节点
主从故障转移的过程是怎样的?
前面提到了 Redis 的发布者/订阅者机制,那就不得不提一下 哨兵集群的组成方式,因为它也用到了这个技术。
在我第一次搭建哨兵集群的时候,当时觉得很诧异。因为在配置哨兵的信息时,竟然只需要填下面这几个参数,设置主节点名字、主节点的 IP 地址和端口号以及 quorum 值。
不需要填其他哨兵节点的信息,我就好奇它们是如何感知对方的,又是如何组成哨兵集群的?
后面才了解到,哨兵节点之间是通过 Redis 的发布者/订阅者机制来相互发现的。
在主从集群中,主节点上有一个名为__sentinel__:hello 的频道,不同哨兵就是通过它来相互发现,实现互相通信的。
在下图中,哨兵 A 把自己的 IP 地址和端口的信息发布到__sentinel__:hello 频道上,哨兵 B 和 C 订阅了该频道。那么此时,哨兵 B 和 C 就可以从这个频道直接获取哨兵 A 的 IP 地址和端口号。然后,哨兵 B、C 可以和哨兵 A 建立网络连接。
通过这个方式,哨兵 B 和 C 也可以建立网络连接,这样一来,哨兵集群就形成了。
主节点知道所有「从节点」的信息,所以 哨兵会每 10 秒一次的频率向主节点发送 INFO 命令来获取所有「从节点」的信息。
如下图所示,哨兵 B 给主节点发送 INFO 命令,主节点接受到这个命令后,就会把从节点列表返回给哨兵。接着,哨兵就可以根据从节点列表中的连接信息,和每个从节点建立连接,并在这个连接上持续地对从节点进行监控。哨兵 A 和 C 可以通过相同的方法和从节点建立连接。
正是通过 Redis 的 发布者/订阅者机制,哨兵之间可以相互感知,然后组成集群,同时,哨兵又通过 INFO 命令,在主节点里获得了所有从节点连接信息,于是就能和从节点建立连接,并进行监控了。
哨兵集群会对「从节点」的运行状态进行监控,那哨兵集群如何知道「从节点」的信息?
哨兵集群是如何组成的?
Redis 在 2.8 版本以后提供的 哨兵(Sentinel)机制,它的作用是实现 主从节点故障转移。它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。
哨兵一般是以集群的方式部署,至少需要 3 个哨兵节点,哨兵集群主要负责三件事情:监控、选主、通知。
当哨兵集群中的某个哨兵判定主节点下线(主观下线)后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。
当这个哨兵的赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」。
1、判断主节点下线
某个哨兵判定主节点客观下线后,该哨兵就会发起投票,告诉其他哨兵,它想成为 leader,想成为 leader 的哨兵节点,要满足两个条件:第一,拿到半数以上的赞成票;第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。
2、选出哨兵 leader
选举出了哨兵 leader 后,就可以进行主从故障转移的过程了。该操作包含以下四个步骤:第一步:在已下线主节点(旧主节点)属下的所有「从节点」里面,挑选出一个从节点,并将其转换为主节点,选择的规则:过滤掉已经离线的从节点;过滤掉历史网络连接状态不好的从节点;将剩下的从节点,进行三轮考察:优先级、复制进度、ID 号。在每一轮考察过程中,如果找到了一个胜出的从节点,就将其作为新主节点。第二步:让已下线主节点属下的所有「从节点」修改复制目标,修改为复制「新主节点」;第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端;第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点;
3、由哨兵 leader 进行主从故障转移
为什么要有哨兵?
高可用篇
图解 Redis
0 条评论
下一页
为你推荐
查看更多