Redis 笔记

NoSQL 学习

Posted by John Mactavish on March 24, 2021

Redis 是一个开源的内存型键值数据库,提供 5 种常用的数据结构,支持事务、持久化、Lua 脚本和多种集群方案等, 可应用作计数器、数据库缓存、热点数据缓存等。

底层数据结构与对象

Redis 数据库里面的每个键值对(key-value pair)的键与值都是对象(object),具体来说,键(key)总是一个字符串对象(string object); 而值(value)则可以是字符串对象列表对象(list object)哈希对象(hash object)集合对象(set object)有序集合对象(sorted set object)这五种对象中的其中一种。

比如说,执行以下命令将在数据库中创建一个键为字符串对象,值也为字符串对象的键值对:

redis> SET msg "he11o world"
OK

而执行以下命令将在数据库中创建一个键为字符串对象,值为列表对象的键值对:

redis> RPUSH numbers 13579
(integer) 5

接下来我们将自底向上地(bottom-up)先介绍五种不同类型的对象所使用的底层数据结构,再剖析这些对象。 值得注意的是,这些底层数据结构不仅用在这些对象上,还存在于 Redis 的其他模块中,比如链表除了被用于链表键之外, 还用于发布与订阅、慢查询、监视器等功能,Redis 服务器本身还使用链表来保存多个客户端的状态信息, 以及使用链表来构建客户端输出缓冲区(output buffer)。同时,一种对象的底层也可能使用多种不同的数据结构实现。

因为 Redis 使用 C 实现,而 C 语言很缺少必要的数据结构,所以这些底层结构基本上都是 Redis 自己实现的。

Simple Dynamic String

Redis 的默认字符串表示用的是 SDS(Simple Dynamic String),它的实现类似于 Java 中的 ArrayList,所以具体实现不多赘述。 SDS 通过成员变量 len 保存字符串实际长度,但是在 len+1 位置仍然保存一个 "\0",这是为了利用 Cstring 函数以方便开发。 但是相比与原生的 string 函数,SDSAPI 保证安全性,例如字符串拼接函数(把 src 拼接到 dest 后面)

char *strcat(char *dest, const char *src);

dest 不拥有足够空间容纳 src 时会发生缓冲区溢出;但是 SDS 的类似 API sdscat 却会通过严格的检查避免这种错误。

同时,SDS 实际存储的是二进制数组,而并不对其作过多假设,如果用户需要利用 SDS 存特定编码格式的字符串,他需要自己处理。

LinkedList

Redis 自己实现了一个双链表。

字典

Redis字典(dict)底层使用哈希表(dictht)实现,一个哈希表内有一个数组(table), 其中存放多个哈希表节点(dictEntry)链表的头节点,每个哈希表节点保存字典的一个键值对; 这意味着哈希表采用拉链法解决哈希碰撞问题。

dict-list

随着字典操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负 载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太 少时,程序需要对哈希表的大小进行相应的扩展或者收缩。一个直觉的操作是设计一个调整大小的函数,重新分配一块大小不同的空间, 然后让键值对针对新的大小进行 rehash(重新散列)。这就类似于许多语言中的字典的实现(比如 JavaHashMap)。 但是 Redis 的字典常用于高性能缓存情景,要求均匀平稳的增、删、查、改性能,但是上述的这个调整大小的方案实施期间字典无法提供服务, 如果字典中的键值对数量过多(事实上常常如此),那么函数调用的时间会非常之长。因而 Redis 采用渐进式 rehash 以均摊 rehash 的开销。

以下是哈希表渐进式 rehash 的详细步骤:

  1. 让字典同时持有 ht[0]ht[1] 两个哈希表
  2. 在字典中维持一个索引计数器变量 rehashidx,并将它的值设置为 0,表示 rehash 工作正式开始
  3. rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehashht[1],当本次 rehash 完成后,程序将 rehashidx 的值增一
  4. 随着字典操作的不断执行,最终在某个时间点上,ht[0] 的所有键值对都会被 rehashht[1],这时程序交换 ht[0]ht[1] 的引用, 并将 rehashidx 的值设为 -1,表示 rehash 操作已完成

渐进式 rehash 通过分而治之的方式,将 rehash 键值对所需的工作均摊到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大开销。相对的,渐进式 rehash 要求在相当的一段时间内一直占用两倍左右空间,这是必要的代价(trade-off)。

dict-rehash

渐进式 rehash 进行期间,字典的删除、查找、更新等操作会在两个哈希表上进行。 例如,要在字典里面查找一个键的话,程序会先在 ht[0] 里面进行查找,如果没找到的话,就会继续到 ht[1] 里面进行查找。 另外,在渐进式 rehash 执行期间,新添加到字典的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操作,这一措施保证了 ht[0] 包含的键值对数量会只减不增,并随着 rehash 操作的执行而最终变成空表。

跳表(Skiplist)

跳表又称跳跃表,其插入、删除、查找元素的时间复杂度都是跟红黑树一样量级的,为 O(logN),但是实现更简单。 简单来说,它首先基于链表,但是为了提高链表中间元素的查找效率,每隔几个元素建立一个索引,依此类推建立了多级索引。 不难想到,这样在查找有序链表中元素时可以进行二分查找,但是注意在添加或删除元素时需要调整好索引结构以防失衡退化。 网上很容易找到简易教程,比如 fanrui 的这篇(下图就来自他的文章), 更详细的内容参考原论文 Skip Lists: A Probabilistic Alternative to Balanced Trees

skiplist

Redis 选择使用跳表而不是红黑树来实现有序集合,主要是因为跳表额外支持在 O(logN) 时间内按照范围区间查找元素 (比如查找值在 [100, 356] 之间的数据)。

整数集合(Intset)

整数集合(Intset)用于有序、无重复地保存少量整数值,它使用数组实现,支持二分查找;最重要的是可以根据元素的值,自动选择该用什么长度的整数类型来保存元素。 比如,如果在一个 intset 里面,最长的元素可以用 int16_t 类型来保存,那么这个 intset 的所有元素都以 int16_t 类型来保存; 而如果有一个新元素要加入到这个 intset,并且这个新元素的长度为 int32_t,那么这个 intset 就会自动进行“升级”: 先将集合中现有的所有元素从 int16_t 类型转换为 int32_t 类型,接着再将新元素加入到集合中。 通过仅在需要的时候升级,整数集合可以很好地节约内存。整数集合不支持“降级”。

压缩链表

压缩链表是一种专门为了提升内存使用效率而设计的,经过特殊编码的双端链表数据结构。既可以用来保存整形数值, 也可以用来保存字符串数值,为了节约内存,当保存一个整形数值时,压缩链表会使用一个真正的整型数来保存。 理想状态下,压缩链表允许在链表两端以 O(1) 的时间复杂度执行 Pop 或者 Push

压缩链表与经典双端链表最大的区别在于,压缩链表中所有的数据都是存储在一段连续的内存之中的; 这样可以减少内存申请与释放次数,减少内存碎片。

list:
| zlbytes | ztail | zllen | entry | entry | ... | entry | zlend |

entry:
| prevlen | encoding | content |
  • zlbytes 表示整个压缩链表所占用内存的大小(以字节为单位,包括本身的大小)
  • ztail 表示链表中最后一个节点的偏移字节量,以便我们可以反向遍历链表
  • zllen 表示链表中节点的个数
  • entry 表示链表中的一个节点,它们的长度大概率是不同的
  • zlend 是压缩链表的结束标记,存储着 0xFF

  • prevlen 标记了该节点的前序节点的长度,以便我们可以向链表的头部反向遍历链表
  • encoding 表示该节点使用的编码方式,具体是按照整型数进行编码还是按照字符串进行编码,当使用字符串编码时,该字段还会指明数据的字节长度
  • content 是节点保存的数据

list-iterate

对象(Object)

我们前面介绍了 Redis 用到的所有主要底层数据结构,Redis 并没有直接使用这些数据结构来实现键值对数据库, 而是基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象, 每种对象都用到了至少一种我们前面所介绍的数据结构。 通过这五种不同类型的对象,Redis 可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令。 使用对象的第二个好处是,我们可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。 除此之外,Redis 的对象系统还实现了基于引用计数技术的内存回收机制,当程序不再使用某个对象的时候,这个对象所占用的内存就会被自动释放; 另外,还通过引用计数技术实现了对象共享机制,这一机制可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存。 最后,Redis 的对象带有访问时间记录信息,该信息可以用于计算数据库键的空转时长,在服务器启用了 maxmemory 功能的情况下, 空转时长较大的那些键可能会优先被服务器删除。

Redis 中的每个对象都由一个 redisObject 结构表示,该结构中和保存数据有关的三个属性分别是 typeencodingptr

typedef struct redisObject{
     // 类型
     unsigned type4;
     // 编码
     unsigned encoding4;
     // 指向底层实现數据结构的指针
     void *ptr;

     // ...
} robj;

对象的 type 属性记录了对象的类型,ptr 指针指向对象的底层数据结构,而究竟使用何种数据结构由对象的 encoding 属性决定, 每种类型的对象都至少使用了两种不同的编码,下图列出了每种类型的对象可以使用的编码。

object

字符串对象

字符串对象的编码包括 intrawembstrint 编码用 Clong 类型表示,用于保存整数值,可以高效地实现计数器等功能; 如果字符串对象保存的是长度小于等于 32 字节的字符串,那么将会使用 embstr 编码,否则使用 raw 编码。 两者区别在于用 raw 编码的字符串对象会调用两次内存分配函数来分别创建 redisObject 结构和 sdshdr 结构, 而 embstr 编码则通过调用一次内存分配函数来分配一块连续的空间,空间中一次包含 redisObjectsdshr 两个结构

raw-embstr

显然,embstr 减少了内存分配与释放次数,使用连续内存也提高了缓存效率,是针对短字符串的优化编码。

上述的编码也不是一成不变的,比如,一个字符串对象表示整数,本来用 int 编码,向其使用 APPEND 命令追加字符会导致其转化为另外两种编码。

列表对象

列表对象的编码可以是 ziplist 或者 linkedlist。注意,linkedlist 编码的列表对象在底层的双端链表结构中包含了多个字符串对象, 这种嵌套字符串对象的行为在稍后介绍的哈希对象、集合对象和有序集合对象中都会出现,字符串对象是 Redis 五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象。

当且仅当列表对象可以同时满足以下两个条件时,列表对象使用 ziplist 编码:

  • 列表对象保存的所有字符串元素的长度都小于 64 字节
  • 列表对象保存的元素数量小于 512 个

哈希对象

哈希对象的编码可以是 ziplist 或者 hashtable

ziplist 编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时, 程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的节点推入到表尾。

hashtable 编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:字典的每个键与值都是一个字符串对象。

当且仅当哈希对象可以同时满足以下两个条件时,哈希对象使用 ziplist 编码:

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节
  • 哈希对象保存的键值对数量小于 512 个

集合对象

集合对象的编码可以是 intset 或者 hashtable

hashtable 编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,而字典的值则全部被设置为 NULL

当且仅当集合对象可以同时满足以下两个条件时,对象使用 intset 编码:

  • 集合对象保存的所有元素都是整数值
  • 集合对象保存的元素数量不超过 512 个。

有序集合对象

有序集合的编码可以是 ziplist 或者 skiplist

ziplist 编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存, 第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score),即集合排序的依据。 压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的地方,而分值较大的元素则被放置在靠近表尾的地方。

skiplist 编码的对象使用 zset 结构作为底层实现,一个 zset 同时包含一个字典和一个跳跃表。 跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素。通过这个跳跃表,程序可以对有序集合进行范围型操作, 比如 ZRANKZRANGE 等命令就是基于跳跃表 API 来实现的。而字典创建了一个从成员到分值的映射,通过它, 可以用 0(1) 复杂度查找给定成员的分值(即 ZSCORE 命令)。值得一提的是,虽然 zset 结构同时使用跳跃表和字典来保存有序集合元素, 但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值, 也不会因此而浪费额外的内存。

当且仅当有序集合对象可以同时满足以下两个条件时,对象使用 ziplist 编码:

  • 有序集合保存的元素数量小于 128 个
  • 有序集合保存的所有元素成员的长度都小于 64 字节

引用计数

Redis 在自己的对象系统中使用了引用计数(reference counting)技术, 每个对象的引用计数信息由 redisObject 结构的 refcount 属性记录:

typedef struct redisObject {
     // ...
     //引用计数
     int refcount;
     // ...
} robj;

通过引用计数机制,Redis 可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收,同时可以支持对包含整数值(int 编码)的字符串对象的共享。注意,共享前需要先检查给定的共享对象和想创建的目标对象 是否内容完全相同,而非整数值的字符串对象与列表、哈希等其他对象的检查开销很大,故不进行共享。

空转时长

redisObject 结构还包含一个 lru 属性,该属性记录了对象最后一次被命令访问的时间。 我们把当前时间减去键的值对象的 lru 时间计算得出的值称作空转时长。 如果服务器打开了 maxmemory 选项,并且服务器用于回收内存的算法为 volatile-lru 或者 allkeys-lru, 那么当服务器占用的内存数超过了 maxmemory 选项所设置的上限值时,空转时长较髙的那部分会优先被服务器释放内存。

持久化

Redis 是内存型数据库,如果需要保证数据在断电后不会丢失,可以将内存中的数据持久化到硬盘上。 持久化有两种方法:RDB(Redis Database)AOF(Append Only File)

Redis Database

RDB 通过创建快照来保存内存数据在某个时间点的副本,快照以二进制写入文件, 之后可以将快照复制到其它服务器从而创建具有相同数据的服务器副本。 注意快照按时间点保存,这意味如果系统发生故障,将会丢失最近一次创建快照之后的数据。

创建快照的方式有:

  • 客户端向 Redis 服务器发送 BGSAVE 命令,Redis 服务器调用 fork 创建子进程来将快照写入硬盘, 同时,父进程可以继续处理命令请求
  • 客户端向 Redis 服务器发送 SAVE 命令,Redis 服务器单进程阻塞地创建快照,期间不理会其他命令请求,这种方式不常用
  • 用户设置的 save 配置选项的条件满足时,Redis 自动触发 BGSAVE 命令
  • Redis 接收 SHUTDOWN 命令或标准 TERM 信号后,在关闭服务器前会执行 SAVE 命令
  • 一个 Redis 服务器连接另一个 Redis 服务器,并向对方发送 SYNC 命令准备复制时,如果主服务器最近未执行 BGSAVE 命令,则执行 BGSAVE 命令

Linux 上,fork 创建的子进程惰性地创建自己的内存空间。这意味着,父子进程一开始共享底层的数据段、栈与堆, 内核把这些区域的访问权限设为只读,父子中任一进程试图修改时触发 Copy on Write 机制,为修改区域按页创建副本, 然后修改自己的副本。相比于在 fork 系统调用时立刻创建自己的内存空间,Copy on Write 同样保证进程地址空间隔离, 但是减少了无效操作(毕竟子进程很少需要修改整个内存空间)。BGSAVE 命令利用此特性既避免了并发问题又 在一定程度上保证了备份期间处理其他命令的能力。

fork 机制的细节可参考《Unix 环境高级编程(第三版)》第八章————进程控制。

但是注意,创建进程与进程间资源争抢同样是不小的开销,尤其在数据量比较大时(内存占用几十个 GB), BGSAVE 创建快照的速度往往比 SAVE 慢。按照《Redis 实战》书作者的经验之谈,在 68GBXen 虚拟机上, 占用 50GBRedis 服务器执行 BGSAVE 命令时,创建子进程需要花费 15 秒以上,创建快照需要 15~20 分钟, 相比之下,SAVE 只需要 3~5 分钟。因此,可以在 Redis 服务器能够接受完全中止服务时(比如深夜使用频率低时) 使用 SAVE 命令备份。

鉴于创建快照对性能不小的影响,RDB 持久化方式最多只能每隔一段时间进行一次。 这在持久化需求不敏感时还可接受,但如果想要实时持久化,需要看看下面的方法。

Append Only File

我们可以仿照传统数据库的日志系统,将对数据库的每一条写命令同步写入磁盘,系统崩溃后只需顺序执行所有的写命令即可完成恢复。 磁盘写操作被平摊到每一条写命令后对性能的影响微乎其微,而且文件的只追加(Append Only)写入本身也很快。

鉴于 AOF 的写入的“少量多次”的特性,这里需要讨论操作系统的文件同步机制。为了减少开销较大的 I/O 操作的次数, 操作系统可能并不会在文件写入的系统调用中将数据真的写入物理磁盘,而是放进内核的缓冲区中,待缓冲区中数据量较可观时再一次性 冲刷(flush)进物理磁盘。这提高了 I/O 效率,但在数据库这种特殊应用中却可能导致系统崩溃后一定量的写命令的丢失。 但是逐个命令冲刷缓冲区,甚至跳过缓冲区直写磁盘(Write Through)也确实很影响性能。 我们只好在性能与健壮性之间取个平衡,通常设置 Redis 命令 appendfsynceverysec 以要求每秒钟把缓冲区同步到磁盘一次。 这样在一定程度上保证了 I/O 效率,同时确保用户最多丢失系统崩溃前一秒钟内的写命令。

既然 AOF 在性能(持久化开销被均摊)与健壮性(丢失数据的时间窗口小)两方面都优于 RDB,那后者还有什么存在的必要吗? 那是因为 AOF 的工作机制额外带来了缺陷。不难想到,RDB 中保存的每项数据都至少对应 AOF 中的一条写命令,而且一般都不止一条。 比如说,一项缓存可能会多次失效、重新设置、再失效……留下多个写命令;又或者它会被多次赋值。这导致 AOF 日志文件大小一般远超 RDB 的快照。 从另一个方向考虑,RDB 的大小受限于机器内存大小(最多将占满内存的 Redis 数据备份),而 AOF 文件的大小完全可以无限扩展直至耗尽硬盘空间。 过大的日志文件不仅会增加硬盘负担,还会增加读取文件还原数据的时间。

AOF 文件过大核心的原因在于冗余的命令。例如经过:

RPUSH list "A" "B"   // ["A", "B"]
RPUSH list "C"       // ["A", "B", "C"]
RPUSH list "D" "E"   // ["A", "B", "C", "D", "E"]
LPOP list            // ["B", "C", "D", "E"]
LPOP list            // ["C", "D", "E"]
RPUSH list "F" "G"   // ["C", "D", "E", "F", "G"]

Redis 在内存中实际存储的 list["C", "D", "E", "F", "G"],而 AOF 文件却记录下了 6 条命令, 它们完全可以用一条命令 RPUSH list "C" "D" "E" "F" "G" 代替。事实上 Redis 真的可以这样做,它可以用内存中的实际数据重写日志文件。 重写后可以保证内存中每项数据都只对应文件中的一条写命令。重写文件感觉很类似 RDB 操作,但区别在于重写后的文件依然是 AOF 文件, 这意味着它可以继续接受追加命令。

通过向 Redis 发送 BGREWRITEAOF 命令,可以要求 Redis 服务器创建子进程来重写 AOF 文件。这里有两点值得注意。第一, 不能直接修改原文件,否则如果重写过程中系统崩溃,仅有的原文件也会被损坏;我们应当新建一份 AOF 文件, 重写完成后进行重命名,让其原子地覆盖原有的文件。第二,重写过程中可能会有新的写命令到达, 但是子进程只会记录 fork 调用时的内存数据,导致重写过程中写命令丢失;其实同样的问题在 RDB 中也会出现, 但是 RDB 本来就允许较长时间内的数据丢失,所以并不关心。

aof-1

如图所示,fork 调用时父子进程共享的内存数据仅包括 k1,后续的 k2,k3,k4 触发 Copy on Write 机制, 仅写入了父进程的地址空间,而对子进程不可见。解决方法也简单,让父进程记录这些增量的写命令,在子进程完成自己的重写任务后再通知它不就好了。 为此,Redis 在创建子进程后会把增量的写命令同时写入一个额外的 AOF 重写缓冲区,子进程完成重写后会向父进程发送信号, 父进程然后调用信号处理函数,将 AOF 重写缓冲区的内容刷入新的 AOF 文件,随后完成新旧文件的替换,结束整个 AOF 流程。

aof-2

就像 RDB 可以通过 save 选项配置自动 BGSAVEAOF 也支持 auto-aof-rewrite-percentageauto-aof-rewrite-min-size 选项。 具体用法参考文档。

多机数据库

前面介绍的都是单机数据库,然而在工程实践中,为了突破单个服务器性能的瓶颈,增加系统可用性(high availability),用的都是多机数据库。

复制(Replication)

多机数据库的一个简单实现是主从模型,让一个服务器占据主导地位以方便管理。特别的,可以让多个服务器保存同样的数据, 均摊客户端的请求压力,同时,单个服务器的意外宕机不会导致服务的终止。为了方便保持一致性,可以只允许主服务器处理写命令, 而从服务器只能处理读命令。 为了实现多个服务器保存同样的数据,Redis 支持简单且易用的主从复制(master-slave replication)功能, 该功能可以让从服务器(slave server)成为主服务器(master server)的精确复制品。

Redis 的复制功能分为同步(sync)命令传播(command propagate)两个操作:

  • 同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态
  • 命令传播操作则用于让从服务器在相同状态基础上与主服务器保持状态同步

我们前面介绍的 RDB 就是记录数据库状态的工具,不难想到可以通过传送 RDB 文件进行初始的同步。 因为要保证完全同步,还要再参考 AOF 中“重写缓冲区”的思路,记录创建快照期间的写命令一起同步过去。

replication-1

而当主服务器每次接收写命令时,它还需要把这写命令同步给所有的从服务器以保证它们实时与自己同步。

但是注意一个问题,实际使用当中从服务器可能由于某些原因(网络拥塞等)与主服务器断开链接。 这样重新连接时,主从服务器间还需要重新传送整个 RDB 文件(开销不小),即使两者之间的数据可能只相差一点。 为了解决这个问题,Redis 引入了部分重同步(PSYNC 命令)。

  • 为了确定主从服务器间相差的数据有哪些,引入复制偏移量(replication offset):主从服务器分别维护一个复制偏移量, 记录各自的数据更新到哪
  • 为了保证主服务器可以部分重传,让主服务器维护一个复制积压缓冲区(replication backlog): 用一个固定长度(fixed-size)的先进先出(FIFO)队列记录写命令,记录满后丢弃最先入队的元素,只要复制偏移量标记的写命令仍然存在 于复制积压缓冲区里面,那么主服务器将对从服务器执行部分重同步操作,否则执行完整重同步
  • 为了标识主服务器,我们还需要引入服务器运行 ID(run ID):每个 Redis 服务器,不论主从,都会在启动时随机生成自己的运行 ID, 主从服务器间初次复制时,从服务器记录主服务器的运行 ID,此后若要重同步,向主服务器发送 PSYNC 命令时附带上这个 ID, 当且仅当 ID 匹配现在的主服务器时进行部分重同步,否则说明现在的主服务器是不同的实例(即使 IP 相同, Redis 主服务器也可能是重启的进程,依然是不同实例),需要完全同步。

哨兵(Sentinel)

主从服务器模型高可用性的一个薄弱环节就是主服务器,一旦主服务器宕机或者进行定期维护,系统就会终止支持写命令。但是因为主从服务器高度一致, 可以在存活的从服务器中选择一个充当新的主服务器。Redis 中对应的解决方案叫做哨兵(Sentinel)Sentinel 实例(instance)组成的 Sentinel 系统(system)可以监视任意多个主服务器,以及它们各自属下的所有从服务器, 并在被监视的某个主服务器进人下线状态时,自动将其属下的某个从服务器升级为新的主服务器。

哨兵的工作流程如下:

  1. 每个 Sentinel 进程以每秒钟一次的频率向整个集群中的主服务器,从服务器以及其他 Sentinel 进程发送一个 PING 命令
  2. 如果一个实例距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds 选项所指定的值, 则这个实例会被 Sentinel 进程标记为主观下线(Subjectively Down,简称 SDOWN)
  3. 如果一个主服务器被标记为主观下线,则正在监视这个主服务器的所有 Sentinel 进程要以 每秒一次的频率确认主服务器的确进入了主观下线状态
  4. 当有足够数量的 Sentinel 进程(大于等于配置文件指定的值)在指定的时间范围内确认主服务器进入了主观下线状态, 则主服务器会被标记为客观下线(Objectively Down,简称 ODOWN)
  5. 若没有足够数量的 Sentinel 进程同意主服务器下线,主服务器的客观下线状态就会被移除。若主服务器重新向 Sentinel 进程发送 PING 命令返回有效回复,主服务器的主观下线状态就会被移除
  6. 当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个 Sentinel 会进行协商,选举出一个领头 Sentinel,并由其对下线主服务器执行故障转移操作

故障转移操作包含以下三个步骤:

  1. 在已下线主服务器属下的所有从服务器里面,挑选出一个,将其转换为主服务器
  2. 让已下线主服务器属下的所有从服务器改为复制新的主服务器。
  3. 将已下线主服务器设置为新的主服务器的从服务器,当这个旧的主服务器重新上线 时,它就会成为新的主服务器的从服务器

Redis集群

Redis 集群(Cluster)Redis 提供的分布式数据库方案,集群通过分片(sharding)来进行数据共享,并提供复制和故障转移功能。

这部分内容涉及到的分布式知识有点多,现在啃不下来,以后再说吧。

杂项

事务

Redis 通过 MULTI、EXEC、WATCH 等命令来实现事务(transaction)功能。事务提供了一种将多个命令请求打包, 然后一次性、按顺序地执行多个命令的机制,并且在事务执行期间,服务器不会中断事务而改去执行其他客户端的命令请求, 它会将事务中的所有命令都执行完毕,然后才去处理其他客户端的命令请求。

一般的,一个事务以一个 MULTI 命令为开始,接着将多个命令放人事务当中,最后由 EXEC 命令将这个事务提交(commit)给服务器执行。 处于事务状态时,服务器不立即执行除 EXEC、DISCARD、WATCH、MULTI 外的命令,而是将它们放人一个 FIFO 的事务队列里面; 直到收到 EXEC 命令,再遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端。

在传统的关系式数据库中,常常用 ACID 性质来检验事务功能的可靠性和安全性。 在 Redis 中,事务总是具有原子性(Atomicity)一致性(Consistency)隔离性(Isolation), 并且当 Redis 运行在某种特定的持久化模式下时,事务也具有持久性(Durability)

事务具有原子性指的是,数据库将事务中的多个操作当作一个整体来执行,服务器要么 就执行事务中的所有操作,要么就一个操作也不执行。 对于 Redis 的事务功能来说,事务队列中的命令要么就全部都执行,要么就一个都不执 行,因此可以认为,Redis 的事务是具有原子性的。但是注意,保证的是全部都执行,而不是全部都执行成功。 Redis 的事务和传统的关系型数据库事务的最大区别在于,Redis 不支持事务回滚机制(rollback), 即使事务队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下去,直到将事务队列中的所有命令都执行完毕为止。

尝试对 "msg" 键使用只有列表键才支持的 RPUSH 命令,这种错误只能在命令实际执行时才能发现

redis> SET msg "hello"
OK
redis> MULTI
OK
redis> SADD fruit "apple" "banana" '*cherry"
QUEUED
redis> RPUSH msg "good bye" "bye bye"
QUEUED
redis> SADD alphabet "a" "b" "c"
QUEUED
redis> EXEC
1) (integer) 3
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) (integer) 3

如果一个事务在命令入队的过程中,出现了命令不存在,或者命令的格式不正确等可以在执行前检查出的错误,那么 `Redis` 将完全拒绝执行这个事务

redis> MULTI
OK
redis> SET msg "hello"
QUEUED
redis> YAHOOOO
(error) ERR unknown command * YAHOOOO *
redis> GET msg
QUEUED
redis> EXEC
(error) EXECABORT Transaction discarded because of previous errors.

事务的隔离性指的是,即使数据库中有多个事务并发地执行,各个事务之间也不会互相影响, 并且在并发状态下执行的事务和串行执行的事务产生的结果完全相同。 因为 Redis 使用单线程的方式来执行事务(以及事务队列中的命令),并且服务器保证,在执行事务期间不会对事务进行中断, 因此,Redis 的事务总是以串行的方式运行的,并且事务也总是具有隔离性的。

事务具有一致性指的是,如果数据库在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据库也应该仍然是一致的。 “一致”指的是数据符合数据库本身的定义和要求,没有包含非法或者无效的错误数据。 通过检查入队错误与侦测执行错误,可以避免错误命令对数据的损坏。如果 Redis 服务器在执行事务的过程中宕机, 那么通过持久化机制可以恢复到较早前的一致状态。

事务的持久性指的是,当一个事务执行完毕时,执行这个事务所得的结果已经被保存到 永久性存储介质(比如硬盘)里面了,即使服务器在事务执行完毕之后停机,执行事务所得 的结果也不会丢失。这就比较依赖于前面的持久化机制了,一般只有当服务器运行在 AOF 持久化模式下, 并且 appendfsync 选项的值为 always 时,事务才是具有耐久性的;因为这样,程序才总会在执行命令之后调用同步(sync)函数, 将命令数据真正地保存到硬盘里面。

键淘汰机制

Redis 有四个不同的命令可以用于设置键的生存时间(键可以存在多久)或过期时间 (键什么时候会被删除):

  • EXPIRE key ttl 命令用于将键 key 的生存时间设置为 ttl 秒
  • PEXPIRE key ttl 命令用于将键 key 的生存时间设置为 ttl 毫秒
  • EXPIREAT key timestamp 命令用于将键 key 的过期时间设置为 timestamp 所指定的秒数时间戳
  • PEXPIREAT key timestamp 命令用于将键 key 的过期时间设置为 timestamp 所指定的毫秒数时间戳

那么如果一个键过期了,它什么时候会被删除呢? 有三种不同的删除策略:

  • 定时删除:在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作。它可以保证过期键会尽 可能快地被删除,并释放过期键所占用的内存,但是会占用相当一部分 CPU 时间,不现实
  • 惰性删除:放任键过期不管,但是每次获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。这种方法则是对内存最不友好的,甚至会导致大量无用数据的积压
  • 定期删除:每隔一段时间,程序就对数据库进行一次检査,删除里面的过期键。它是对上面两者的折中,通过恰当选择删除操作执行的时长和频率,可以在 CPU 压力与内存压力之间取得平衡

Redis 服务器实际上同时使用惰性删除和定期删除两种策略。


参考资料:

Redis 实战(Redis in Action)

Redis 设计与实现(The Design and Implementation of Redis)

“散尽浮华”的博客园文章

如果你喜欢我的文章,请我吃根冰棒吧 (o゜▽゜)o ☆

contribution

最后附上 GitHub:https://github.com/gonearewe