redis读书笔记

[TOC]

压缩列表

压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的
偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。

image-20210206202418097

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段
的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查
找,此时的复杂度就是 O(N) 了。

压缩列表之所以能节省内存,就在于它是用一系列连续的 entry 保存数据。每个 entry 的
元数据包括下面几部分。

prev_len,表示前一个 entry 的长度。prev_len 有两种取值情况:1 字节或 5 字节。
取值 1 字节时,表示上一个 entry 的长度小于 254 字节。虽然 1 字节的值能表示的数
值范围是 0 到 255,但是压缩列表中 zlend 的取值默认是 255,因此,就默认用 255
表示整个压缩列表的结束,其他表示长度的地方就不能再用 255 这个值了。所以,当上
一个 entry 长度小于 254 字节时,prev_len 取值为 1 字节,否则,就取值为 5 字节。
len:表示自身长度,4 字节;
encoding:表示编码方式,1 字节;
content:保存实际数据。

这些 entry 会挨个儿放置在内存中,不需要再用额外的指针进行连接,这样就可以节省指
针所占用的空间。
我们以保存图片存储对象 ID 为例,来分析一下压缩列表是如何节省内存空间的。
每个 entry 保存一个图片存储对象 ID(8 字节),此时,每个 entry 的 prev_len 只需要
1 个字节就行,因为每个 entry 的前一个 entry 长度都只有 8 字节,小于 254 字节。这样
prev_len,表示前一个 entry 的长度。prev_len 有两种取值情况:1 字节或 5 字节。
取值 1 字节时,表示上一个 entry 的长度小于 254 字节。虽然 1 字节的值能表示的数
值范围是 0 到 255,但是压缩列表中 zlend 的取值默认是 255,因此,就默认用 255
表示整个压缩列表的结束,其他表示长度的地方就不能再用 255 这个值了。所以,当上
一个 entry 长度小于 254 字节时,prev_len 取值为 1 字节,否则,就取值为 5 字节。
len:表示自身长度,4 字节;
encoding:表示编码方式,1 字节;
content:保存实际数据。

相比String

一个 RedisObject 包含了 8 字节的元数据和一个 8 字节指针 image-20210206230520050

Redis 会使用一个全局哈希表保存所有键值对,哈希表的每一项是
一个 dictEntry 的结构体,用来指向一个键值对。dictEntry 结构中有三个 8 字节的指针,
分别指向 key、value 以及下一个 dictEntry,三个指针共 24 字节,如下图所示:

image-20210206225823359

但是,这三个指针只有 24 字节,为什么会占用了 32 字节呢?这就要提到 Redis 使用的内
存分配库 jemalloc 了。
jemalloc 在分配内存时,会根据我们申请的字节数 N,找一个比 N 大,但是最接近 N 的
2 的幂次数作为分配的空间,这样可以减少频繁分配的次数。

多路复用

Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的
select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同
时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据
请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个
IO 流的效果。

Redis 单线程对该事件队列不断进行处理。这样一来,
Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时,
Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件
的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升
Redis 的响应性能。

我再以连接请求和读数据请求为例,具体解释一下。

这两个请求分别对应 Accept 事件和 Read 事件,Redis 分别对这两个事件注册 accept 和
get 回调函数。当 Linux 内核监听到有连接请求或读数据请求时,就会触发 Accept 事件
和 Read 事件,此时,内核就会回调 Redis 相应的 accept 和 get 函数进行处理。

image-20210206210546576

AOF

AOF 日志

image-20210206210735965

但是,为了避免额外的检查开销,Redis 在向 AOF 里面记录日志的时候,并不会先去对这
些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误
的命令,Redis 在使用日志恢复数据时,就可能会出错。
而写后日志这种方式,就是先让系统执行命令,只有命令能执行成功,才会被记录到日志
中,否则,系统就会直接向客户端报错。所以,Redis 使用写后日志这一方式的一大好处
是,可以避免出现记录错误命令的情况。
除此之外,AOF 还有一个好处:它是在命令执行后才记录日志,所以不会阻塞当前的写操
作。

不过,AOF 也有两个潜在的风险。
首先,如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数
据就有丢失的风险。如果此时 Redis 是用作缓存,还可以从后端数据库重新读入数据进行
恢复,但是,如果 Redis 是直接用作数据库的话,此时,因为命令没有记入日志,所以就
无法用日志进行恢复了。
其次,AOF 虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险。这是因
为,AOF 日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就
会导致写盘很慢,进而导致后续的操作也无法执行了。

这两个风险都是和 AOF 写回磁盘的时机相关的。

三种写回策略

其实,对于这个问题,AOF 机制给我们提供了三个选择,也就是 AOF 配置项
appendfsync 的三个可选值。
针对避免主线程阻塞和减少数据丢失问题,这三种写回策略都无法做到两全其美。我们来
分析下其中的原因。
Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
Everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲
区,每隔一秒把缓冲区中的内容写入磁盘;
No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓
冲区,由操作系统决定何时将缓冲区内容写回磁盘。

image-20210206211051372

到这里,我们就可以根据系统对高性能和高可靠性的要求,来选择使用哪种写回策略了。
总结一下就是:想要获得高性能,就选择 No 策略;如果想要得到高可靠性保证,就选择
Always 策略;如果允许数据有一点丢失,又希望性能别受太大影响的话,那么就选择
Everysec 策略。
但是,按照系统的性能需求选定了写回策略,并不是“高枕无忧”了。毕竟,AOF 是以文
件的形式在记录接收到的所有写命令。随着接收的写命令越来越多,AOF 文件会越来越
大。这也就意味着,我们一定要小心 AOF 文件过大带来的性能问题。
这里的“性能问题”,主要在于以下三个方面:一是,文件系统本身对文件大小有限制,
无法保存过大的文件;二是,如果文件太大,之后再往里面追加命令记录的话,效率也会
变低;三是,如果发生宕机,AOF 中记录的命令要一个个被重新执行,用于故障恢复,如
果日志文件太大,整个恢复过程就会非常缓慢,这就会影响到 Redis 的正常使用。
所以,我们就要采取一定的控制手段,这个时候,AOF 重写机制就登场了。

日志文件太大了怎么办?

简单来说,AOF 重写机制就是在重写时,Redis 根据数据库的现状创建一个新的 AOF 文
件,也就是说,读取数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写
入。比如说,当读取了键值对“testkey”: “testvalue”之后,重写机制会记录 set
testkey testvalue 这条命令。这样,当需要恢复时,可以重新执行该命令,实
现“testkey”: “testvalue”的写入。

为什么重写机制可以把日志文件变小呢? 实际上,重写机制具有“多变一”功能。所谓
的“多变一”,也就是说,旧日志文件中的多条命令,在重写后的新日志中变成了一条命
令。
我们知道,AOF 文件是以追加的方式,逐一记录接收到的写命令的。当一个键值对被多条
写命令反复修改时,AOF 文件会记录相应的多条命令。但是,在重写的时候,是根据这个
键值对当前的最新状态,为它生成对应的写入命令。这样一来,一个键值对在重写日志中
只用一条命令就行了,而且,在日志恢复时,只用执行这条命令,就可以直接完成这个键
值对的写入了。
下面这张图就是一个例子:

image-20210206211255512

不过,虽然 AOF 重写后,日志文件会缩小,但是,要把整个数据库的最新数据的操作日志
都写回磁盘,仍然是一个非常耗时的过程。这时,我们就要继续关注另一个问题了:重写
会不会阻塞主线程?

AOF 重写会阻塞吗?

和 AOF 日志由主线程写回不同,重写过程是由后台线程 bgrewriteaof 来完成的,这也是
为了避免阻塞主线程,导致数据库性能下降。
我把重写的过程总结为“一个拷贝,两处日志”。

“一个拷贝”就是指,每次执行重写时,主线程 fork 出后台的 bgrewriteaof 子进程。此
时,fork 会把主线程的内存拷贝一份给 bgrewriteaof 子进程,这里面就包含了数据库的
最新数据。然后,bgrewriteaof 子进程就可以在不影响主线程的情况下,逐一把拷贝的数
据写成操作,记入重写日志。
“两处日志”又是什么呢?
因为主线程未阻塞,仍然可以处理新来的操作。此时,如果有写操作,第一处日志就是指
正在使用的 AOF 日志,Redis 会把这个操作写到它的缓冲区。这样一来,即使宕机了,这
个 AOF 日志的操作仍然是齐全的,可以用于恢复。
而第二处日志,就是指新的 AOF 重写日志。这个操作也会被写到重写日志的缓冲区。这
样,重写日志也不会丢失最新的操作。等到拷贝数据的所有操作记录重写完成后,重写日
志记录的这些最新操作也会写入新的 AOF 文件,以保证数据库最新状态的记录。此时,我
们就可以用新的 AOF 文件替代旧文件了。

image-20210206211447998

总结来说,每次 AOF 重写时,Redis 会先执行一个内存拷贝,用于重写;然后,使用两个
日志保证在重写过程中,新写入的数据不会丢失。而且,因为 Redis 采用额外的线程进行
数据重写,所以,这个过程并不会阻塞主线程。

image-20210206213323733

fork子进程

fork子进程时,子进程是会拷贝父进程的页表,即虚
实映射关系,而不会拷贝物理内存。子进程复制了父进程页表,也能共享访问父进程的内存数据
了,此时,类似于有了父进程的所有内存数据。

RDB

快照时数据能修改吗?
Redis 就会借助操作系统提
供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。
简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。
bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。
此时,如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和
bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),
那么,这块数据就会被复制一份,生成该数据的副本。然后,bgsave 子进程会把这个副本
数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。

image-20210206214738843

可以每秒做一次快照吗?

把内存中的所有数据都记录到磁盘中。最好不要,全量快照的基础上做一次增量快照

Redis 4.0 中提出了一个混合使用 AOF 日志和内存快着。简单来说,内存快照以一
定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。
这样一来,快照不用很频繁地执行,这就避免了频繁 fork 对主线程的影响。而且,AOF
日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出
现文件过大的情况了,也可以避免重写开销。
如下图所示,T1 和 T2 时刻的修改,用 AOF 日志记录,等到第二次做全量快照时,就可
以清空 AOF 日志,因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。

Redis做消息队列

消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息
和保证消息可靠性。

保序:

BRPOP 命令也称为阻塞式读取,客户端
在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。

处理重复:

消息的全局唯一 ID 号就需要生产
者程序在发送消息前自行生成。生成之后,我们在用 LPUSH 命令把消息插入 List 时,需
要在消息中包含这个全局唯一 ID。

LPUSH mq “101030001:stock:5”

保证消息可靠性:

当消费者程序从 List 中读取一条消息后,List 就不会再留存这条消息了。所以,如果消费
者程序在处理消息的过程出现了故障或宕机,就会导致消息没有处理完成,那么,消费者
程序再次启动后,就没法再次从 List 中读取消息了。
为了留存消息,List 类型提供了 BRPOPLPUSH 命令,这个命令的作用是让消费者程序从
一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份
List)留存。这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从
备份 List 中重新读取消息并进行处理了。

image-20210206231532817

消息堆积:

采用streams

Redis 从 5.0 版本开始提供的 Streams 数据类型了。

这个时候,我们希望启动多个消费者程序组成一个消费组,一起分担处理 List 中的消息。
但是,List 类型并不支持消费组的实现。那么,还有没有更合适的解决方案呢

XADD:插入消息,保证有序,可以自动生成全局唯一 ID;
XREAD:用于读取消息,可以按 ID 读取数据;
XREADGROUP:按消费组形式读取消息;
XPENDING 和 XACK:XPENDING 命令可以用来查询每个消费组内所有消费者已读取
但尚未确认的消息,而 XACK 命令用于向消息队列确认消息处理已完成。

GEO