Redis心得

这里总结一下我使用Redis的一些心得,主要是参考了Redis设计与实现Redis开发与运维 这两本书。

一. Redis 对象

1.1 简单动态字符串 SDS

struct sdshdr {
 
    // 记录 buf 数组中已使用字节的数量
    // 等于 SDS 所保存字符串的长度
    int len;
 
    // 记录 buf 数组中未使用字节的数量
    int free;
 
    // 字节数组,用于保存字符串
    char buf[];

};
  • len : 字符串的字符长度。

    利用 len 这个属性,很容易知道字符串的长度;一个中文字符算 3 个长度。

  • free : 剩余空间的大小。
  • buf[] : 保存字符串数据。
    • 为了兼容 c 自带的字符串处理库,SDS 也会再字符串最后添加\0这个字符,但是它不计算在 len 里面。
    • 因此 buf[]数组长度就是 len + free + 1

SDSC 字符串相比较,有以下优点:

  • 更容易获取字符串长度,C 字符串需要遍历才知道字符串长度。
  • 因为不是通过 \0 判断字符串结尾,因此SDS可以储存任意二进制数据,而C 字符串只能储存文本字符,否则可能因为\0 字符导致字符串提前终止。
  • 动态扩展,SDS 可以通过动态扩展防止缓冲区溢出,而C 字符串需要使用者自己计算使用大小。

SDS的动态扩展

SDS 进行字符串追加操作时,会先检查当前 SDS 的剩余空间(即free 的值)能否容纳追加的数据,如果不能,那么就需要进行扩展了,规则如下:

  • 计算追加之后字符串的大小len,如果len小于 1MB,那么 free 的值就等于 len
  • 如果 len 大于等于 1MB,那么free 的值就等于 1MB
  • buf[] 数组大小就是 len + free + 1,注意这里是追加之后字符串大小len
  • SDS 通过空间预分配策略,Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。

当减少 SDS 字符,例如清空 SDS,并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录起来,并等待将来使用。当然 SDS 提供了特定的方法来释放多余占有空间。

1.2 链表 list

typedef struct listNode {
 
    // 前置节点
    struct listNode *prev;
 
    // 后置节点
    struct listNode *next;
 
    // 节点的值
    void *value;
 
} listNode;

一个典型的双向链表的节点,prevnext 分别是指向前一个和后一个节点的指针,value 用来储存节点数据。

typedef struct list {
 
    // 表头节点
    listNode *head;
 
    // 表尾节点
    listNode *tail;
 
    // 链表所包含的节点数量
    unsigned long len;
 
    // 节点值复制函数
    void *(*dup)(void *ptr);
 
    // 节点值释放函数
    void (*free)(void *ptr);
 
    // 节点值对比函数
    int (*match)(void *ptr, void *key);

} list;
  • headtail : 分别表示链表头和尾,用来从头或者从尾遍历链表。
  • len : 记录链表的长度,不然就需要通过遍历链表才知道长度了。
  • dup, freematch : 都是操作链表节点的函数。

特别注意 redis 的链表是一个无环双向链表,即链表头head 节点的prevnull,链表尾 tail 节点的 nextnull

1.3 字典

就是哈希表,关于哈希表相关原理请看数据结构_哈希表。

1.3.1 数据结构

typedef struct dictEntry {
 
    // 键
    void *key;
 
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
 
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
 
} dictEntry;
  • key : 哈希表键值对中的键。
  • v : 哈希表键值对中的值,是一个联合体结构。

    类型可以是有符号整数类型 int64_t, 无符号整数类型 uint64_t 和任一类型指针 void *

  • next : 下一个键值对的指针,形成一个链表,使用链地址法解决哈希冲突。
typedef struct dictht {
 
    // 哈希表数组
    dictEntry **table;
 
    // 哈希表大小
    unsigned long size;
 
    // 哈希表大小掩码,用于计算索引值
    // 总是等于 size - 1
    unsigned long sizemask;
 
    // 该哈希表已有节点的数量
    unsigned long used;
 
} dictht;
  • table : 哈希表数组,相当于 dictEntry[],用来记录哈希表数据。
  • size : 就是哈希表数组的大小

    注意为了加快哈希速度,这个 size 值必须是 2 的幂数,方便使用 & 位运算取余。

  • sizemask : 值就是 size - 1。使用 hash & sizemask 来计算hash 相对于 size 的余数。
  • used : 表示哈希表已有节点的数量。
typedef struct dict {
 
    // 类型特定函数
    dictType *type;
 
    // 私有数据
    void *privdata;
 
    // 哈希表
    dictht ht[2];
 
    // rehash 索引
    // 当 rehash 不在进行时,值为 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */
 
} dict;
  • typeprivdata 是用来针对不同类型的键值对,为创建多态字典而设置的。
    • type 属性是一个指向 dictType 结构的指针,每个 dictType 结构保存了一簇用于操作特定类型键值对的函数,Redis 会为用途不同的字典设置不同的类型特定函数。
    • privdata 属性则保存了需要传给那些类型特定函数的可选参数。
  • ht[2]rehashidx 用来实现带有渐进式 rehash 功能的哈希表。

1.3.2 渐进式 rehash

我们知道链地址的哈希表,当数据越多,那么发生哈希冲突就越多,那么每个节点的链就越长,这个时候哈希表的效率就变低了,因为要遍历链来查找想要的key
这个时候必须进行重新哈希 rehash, 即扩大哈希表数组大小,再将哈希表中数据进行重新分配。

  • 但是这里有个问题,当哈希表在 rehash,是没有办法对外提供功能的,只能等到rehash完成才能使用;
  • 如果哈希表中的数据非常多,那么rehash 时间可能会很长。

那么如何在 rehash 情况下,也能继续使用哈希表的功能呢?

就要用的渐进式 rehash 功能了。

dict 中有两个dictht,分为两种情况:

  • 正常情况下

    哈希表 ht[0] 中包含字典所有的数据,对外提供哈希表的功能;ht[1] 中没有任何数据,是一个空的 dictht

  • 发生 rehash
    • 先创建指定大小的 dictht 赋值给 ht[1]
    • 再通过 rehashidx 逐渐将 ht[0] 中的数据迁移到 ht[1],具体在 rehashidx 中讲解。
    • 在这个时候,字典同时使用 ht[0]ht[1] 两个哈希表,所以对字典的删除(delete),查找(find),更新(update)等,会在两个哈希表上进行;但是对字典添加操作,只会在ht[1]上进行,保证了 ht[0] 中的键值对数量会只减不增,并随着 rehash 操作的执行而最终变成空表。
    • rehash完成之后,再将ht[1]赋值给 ht[0],并将ht[1]再设置成空的 dictht

使用 rehashidx 来进行渐进式 rehash

  • 正常情况下,rehashidx 的值就是 -1
  • 渐进式 rehash时候
    • 先将rehashidx 的值设置为 0
    • rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehashht[1] ,当 rehash 工作完成之后,程序将 rehashidx 属性的值增一。
    • 随着字典操作的不断执行,最终在某个时间点上,ht[0] 的所有键值对都会被 rehashht[1] ,这时程序将 rehashidx 属性的值设为 -1 ,表示 rehash 操作已完成。

触发rehash 的情况:

  • 扩展操作

    当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:

    • 服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 1
    • 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 5
  • 收缩操作

    当哈希表的负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作。

哈希表的负载因子的计算公式:

# 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size

1.4 跳跃表

typedef struct zskiplistNode {
 
    // 后退指针
    struct zskiplistNode *backward;
 
    // 分值
    double score;
 
    // 成员对象
    robj *obj;
 
    // 层
    struct zskiplistLevel {
 
        // 前进指针
        struct zskiplistNode *forward;
 
        // 跨度
        unsigned int span;
 
    } level[];
 
} zskiplistNode;
  • backward : 后退指针

    用于从表尾向表头方向访问节点,跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。

  • score : 节点的分值,一个 double 类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。
  • obj : 节点成员对象的指针,在同一个跳跃表中,各个节点保存的成员对象必须是唯一的。
  • level[] : 节点的层。
    • 跳跃表节点的 level 数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。
    • 每次创建一个新跳跃表节点的时候,程序都根据幂次定律,随机生成一个介于 132 之间的值作为 level 数组的大小,这个大小就是层的“高度”。
    • forward 表示前进指针,span 表示跨度,用于记录两个节点之间的距离。

1.5 整数集合

typedef struct intset {
 
    // 编码方式
    uint32_t encoding;
 
    // 集合包含的元素数量
    uint32_t length;
 
    // 保存元素的数组
    int8_t contents[];
 
} intset;
  • encoding : 编码方式,表示这个整数集合存储元素的格式。

    有三种格式,分别是:

    • INTSET_ENC_INT16 : 表示是 int16_t 类型的整数集合,存储数的范围就是 -32768 ~ 32767
    • INTSET_ENC_INT32 : 表示是 int32_t 类型的整数集合,存储数的范围就是 -2,147,483,648 ~ 2,147,483,647
    • INTSET_ENC_INT64 : 表示是 int64_t 类型的整数集合,存储数的范围就是 --9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807
  • length : 表示整数集合中元素个数。
  • contents[] : 储存整数集合的数据。
    • 因为要储存整数集合中所有元素,所以按照整数集合最大的数范围,来确定整数集合的编码方式。
    • 如果向整数集合添加了一个超过当前整数集合编码方式范围的值,例如向INTSET_ENC_INT16中添加一个 40000,那么整数集合就会升级,将原来的值都变成INTSET_ENC_INT32大小储存。
    • 但是整数集合不会降级,即使将超过INTSET_ENC_INT16范围的值删除了,整数集合也不会降级成INTSET_ENC_INT16

1.6 压缩列表

+---------+--------+-------+--------+-------+
| zlbytes | zltail | zllen | entryX | zlend |
+---------+--------+-------+--------+-------+
属性 类型 长度 用途
zlbytes uint32_t 4 字节 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配,或者计算 zlend 的位置时使用。
zltail uint32_t 4 字节 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节:通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址。
zllen uint16_t 2 字节 记录了压缩列表包含的节点数量:当这个属性的值小于 UINT16_MAX (65535)时,这个属性的值就是压缩列表包含节点的数量;当这个值等于 UINT16_MAX 时,节点的真实数量需要遍历整个压缩列表才能计算得出。
entryX 列表节点 不定 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
zlend uint8_t 1 字节 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。

压缩列表节点的结构如下:

+-----------------------+----------+---------+
| previous_entry_length | encoding | content | 
+-----------------------+----------+---------+
  • previous_entry_length : 记录了压缩列表中前一个节点的长度。

    长度可以是 1 字节或者 5 字节:

    • 如果前一节点的长度小于 254 ,那么previous_entry_length就是一个字节,存储前一节点的长度。
    • 如果前一节点的长度大于等于 254 字节,那么previous_entry_length就是五个字节,第一个字节数固定是0xFE(254),之后的四个字节则用于保存前一节点的长度。
    • 通过previous_entry_length就可以知道前一个节点的位置。
  • encoding : 记录保存数据的类型以及长度。

    • 最高位 11 开头,encoding1个字节,表示整数编码。
    • 最高位 00 开头,encoding1个字节,表示字节数组编码。
    • 最高位 01 开头,encoding2个字节,表示字节数组编码。
    • 最高位 10 开头,encoding5个字节,表示字节数组编码。
  • content : 存储节点数据。

根据 encoding 不同,content储存数据格式不同:

编码 编码长度 content 属性保存的值
00bbbbbb 1 字节 长度小于等于 63 字节的字节数组。
01bbbbbb xxxxxxxx 2 字节 长度小于等于 16383 字节的字节数组。
10__ aaaaaaaa bbbbbbbb cccccccc dddddddd 5 字节 长度小于等于 4294967295 的字节数组。
11000000 1 字节 int16_t 类型的整数。
11010000 1 字节 int32_t 类型的整数。
11100000 1 字节 int64_t 类型的整数。
11110000 1 字节 24 位有符号整数。
11111110 1 字节 8 位有符号整数。
1111xxxx 1 字节 使用这一编码的节点没有相应的 content 属性,因为编码本身的 xxxx 四个位已经保存了一个介于 0 和 12 之间的值,所以它无须 content 属性。

1.7 Redis 对象

typedef struct redisObject {
 
    // 类型
    unsigned type:4;
 
    // 编码
    unsigned encoding:4;
 
    // 指向底层实现数据结构的指针
    void *ptr;
 
    // 引用计数
    int refcount;

    unsigned lru:22;
    // ...
 
} robj;
  • type : 表示对象的类型。
    • 分别有字符串对象(string),列表对象(list),哈希对象(hash),集合对象(set)和有序集合对象(zset)。
    • 可以通过 type 命令获取对象的类型。
  • encoding : 对象底层的编码方式。
    • 为了节约内存,redis 为每种类型提供了两种以上的编码方式。
    • 通过通过 object encoding 命令获取对象的编码方式。
  • ptr : 对象数据的指针。
  • refcount : 对象的引用计数,通过它来进行内存回收。
    • 当对象的引用计数值变为 0 时,对象所占用的内存会被释放。
    • 通过通过 object refcount 命令获取对象当前的引用计数。
  • lru : 记录了对象最后一次被命令程序访问的时间。
    • 用来计算键的空转时长,空转时长就是当前时间减去键的值对象的 lru 时间的差值。
    • 通过通过 object idletime 命令获取对象的空转时长。
类型 编码方式 对象
string REDIS_ENCODING_INT(int) 使用整数值实现的字符串对象。
string REDIS_ENCODING_EMBSTR(embstr) 使用 embstr 编码的简单动态字符串实现的字符串对象。
string REDIS_ENCODING_RAW(raw) 使用简单动态字符串实现的字符串对象。
list REDIS_ENCODING_ZIPLIST(ziplist) 使用压缩列表实现的列表对象。
list REDIS_ENCODING_LINKEDLIST(linkedlist) 使用双端链表实现的列表对象。
hash REDIS_ENCODING_ZIPLIST(ziplist) 使用压缩列表实现的哈希对象。
hash REDIS_ENCODING_HT(hashtable) 使用字典实现的哈希对象。
set REDIS_ENCODING_INTSET(intset) 使用整数集合实现的集合对象。
set REDIS_ENCODING_HT(hashtable) 使用字典实现的集合对象。
zset REDIS_ENCODING_ZIPLIST(ziplist) 使用压缩列表实现的有序集合对象。
zset REDIS_ENCODING_SKIPLIST(skiplist) 使用跳跃表和字典实现的有序集合对象。

1.7.1 字符串对象

字符串对象有三种编码方式:

  • int : 字符串对象保存的是整数值。

    浮点型不能用int 数据结构保存。

  • raw : 字符串对象保存的是长度大于 39 字节的字符串值。
  • embstr : 字符串对象保存的是长度小于等于 39 字节的字符串值。
    • raw 编码一样,都使用 redisObject 结构和 sdshdr 结构来表示字符串对象。
    • 但是raw 编码会调用两次内存分配函数来分别创建 redisObject 结构和 sdshdr 结构。
    • embstr 编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含 redisObjectsdshdr 两个结构。

编码的转换:

  • 对于 int 编码的字符串对象来说,如果我们向对象执行了一些命令,使得这个对象保存的不再是整数值,而是一个字符串值,那么字符串对象的编码将从 int 变为raw
  • 当我们对 embstr 编码的字符串对象执行任何修改命令时,程序会先将对象的编码从 embstr 转换成raw ,然后再执行修改命令;因为这个原因,embstr 编码的字符串对象在执行修改命令之后,总会变成一个 raw 编码的字符串对象。

1.7.2 列表对象

列表对象有ziplistlinkedlist两种编码方式:

  • 当列表对象可以同时满足以下两个条件时,列表对象使用 ziplist 编码:
    • 列表对象保存的所有字符串元素的长度都小于 list-max-ziplist-value(默认是64) 字节。
    • 列表对象保存的元素数量小于 list-max-ziplist-entrie(默认是512) 个。
  • 不能满足这两个条件的列表对象需要使用 linkedlist 编码。

注意在现在版本下,使用 quicklist 代替了 ziplist

1.7.3 哈希对象

哈希对象有ziplisthashtable两种编码方式:

  • 当哈希对象可以同时满足以下两个条件时,哈希对象使用 ziplist 编码:
    • 哈希对象保存的所有键值对的键和值的字符串长度都小于 hash-max-ziplist-value(默认是64) 字节;
    • 哈希对象保存的键值对数量小于 hash-max-ziplist-entries(默认是512) 个;
  • 不能满足这两个条件的哈希对象需要使用 hashtable 编码。

1.7.4 集合对象

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

  • 当集合对象可以同时满足以下两个条件时,对象使用 intset 编码:
    • 集合对象保存的所有元素都是整数值。
    • 集合对象保存的元素数量不超过 set-max-intset-entries(默认值512) 个。
  • 不能满足这两个条件的集合对象需要使用 hashtable 编码。

1.7.5 有序集合对象

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

  • 当有序集合对象可以同时满足以下两个条件时,对象使用 ziplist 编码:
    • 有序集合保存的元素数量小于 zset-max-ziplist-entries(默认值128) 个;
    • 有序集合保存的所有元素成员的长度都小于 zset-max-ziplist-value((默认值64) 字节;
  • 不能满足以上两个条件的有序集合对象将使用 skiplist 编码。

二. 数据库功能

2.1 数据库

typedef struct redisServer {
    // ...
 
    // 一个数组,保存服务器中所有数据库
    redisDb *db;
    // 服务器数据库数量
    int dbnum;
 
    // ...
} redisServer;
  • db : 服务器中所有数据库,相当于 redisDb[]

    虽然 Redis 服务器支持多个数据库,但是使用多个数据库,因为redis 是单线程,多个数据库操作都是在同一个线程执行的。

  • dbnum : 服务器数据库数量。
typedef struct redisDb {
    // ...
 
    // 数据库键空间,保存着数据库中的所有键值对
    dict *dict;
    dict *expires;
 
    // ...
} redisDb;

redisDb 结构的 dict 字典保存了数据库中的所有键值对,我们将这个字典称为键空间(key space),键空间和用户所见的数据库是直接对应的:

  • 键空间的键也就是数据库的键,每个键都是一个字符串对象。
  • 键空间的值也就是数据库的值,每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象在内的任意一种 Redis 对象。

redisDb 结构的 expires 字典保存了数据库中所有过期键的时间:

  • expires 字典中键值对的键是一个指针,指向dict 字典中的一个键。
  • expires 字典中键值对的值是一个 long 类型整数,表示这个键的过期时间,一个精确到毫秒的UNIX时间戳。

过期键的删除策略:

  • 惰性删除 : 所有读写数据库的命令执行之前,都会对输入键进行检查。
    • 如果输入键已经过期,那么就删除。
    • 如果输入键没有过期,或者没有过期时间,那么不做任何操作。
  • 定期删除 : 定期遍历一部分数据库,从数据库的 expires 字典中随机检查一部分键的过期时间,删除其中已过期的键。

    个人觉得这里可以使用过期桶的方式,将过期键按照过期时间放到不同的过期时间桶,这样就避免查找过期键的时间,直接删除过期时间桶中所有过期键。

RDB,AOF 和复制过程中,对过期键的处理:

  • 生成 RDB 时,会检查键的过期时间,已经过期的键不会保存到RDB文件中。
  • 载入 RDB文件时,也会检查键的过期时间,已经过期的键直接被忽略。
  • AOF 写入时,如果某个键被惰性或者定期删除了,会直接在AOF文件中插入一个 DEL 命令。
  • AOF 重写时,会检查键的过期时间,已经过期的键不会保存到重写后的AOF 文件中。
  • 复制时,因为只有主服务器才能使用写命令,从服务器只能使用读命令,因此即使从服务器发现了键过期,它也不会删除这个键;只有当主服务器发现这个键过期,删除它,并会向从服务器发送一条 DEL 命令。

2.2 RDB 持久化

Redis 可以通过 save 或者 bgsave 命令,生成RDB 文件,来实现持久化。

  • save命令

    save命令执行时,Redis服务器会被阻塞,客户端发送的所有命令请求都会被拒绝。

  • bgsave命令
    • Redis进程执行fork操作创建子进程,RDB持久化过程由子进程负责,完成后自动结束;阻塞只发生在fork阶段,一般时间很短。
    • Redis服务器在bgsave期间可以继续处理客户端命令请求,但是save,bgsavebgrewriteaof 这三个命令会有不同:
    • 会拒绝执行savebgsave命令;bgrewriteaof 指令会延迟到 bgsave完成之后执行。

2.2.1 自动保存

用户可以通过配置项 save 设置触发 bgsave 命令。
例如

save 900 1
save 300 100
save 60 1000

900 秒内至少一次修改,300 秒内至少100 次修改,60秒内至少 1000 次修改,只有满足其中一个条件就会触发bgsave 命令。

实现原理就是

typedef struct redisServer {
    // ...
 
    // 记录自动保存的条件
    struct saveparam *saveparams;
     // 上次保存后,服务器修改次数。
    long long dirty;
     // 上次保存时间
     time_t lastsave;
 
    // ...
} redisServer;

typedef struct saveparam {
     // 间隔时间
     time_t seconds;
     // 修改次数
     int changes;
} saveparam;
  • redisServer 中有一个 saveparams 数组记录着所有自动保存的条件。
  • redisServerdirtylastsave 记录上次保存后,修改次数和保存时间,那么就可以判断是否满足自动保存条件。

2.2.2 fork 操作

fork 使用了写时复制机制(copy-on-write):

  • 父子进程会共享相同的物理内存页,当父进程处理写请求时会把要修改的页创建副本,不会改变共享内存快照数据。
  • 子进程在fork操作过程中共享整个父进程内存快照。
  • 这样子进程不会复制整个父进程内存,只需要复制物理内存页就行了。

2.3 AOF 持久化

AOFappend only file)持久化,以独立日志的方式记录每次写命令,重启时再重新执行AOF文件中的命令达到恢复数据的目的。

AOF的主要作用是解决了数据持久化的实时性,目前已经是Redis持久化的主流方式。

AOF 持久化功能的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤。

2.3.1 命令追加

struct redisServer {
 
    // ...
 
    // AOF 缓冲区
    sds aof_buf;
 
    // ...
};

AOF 持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾。

2.3.2 文件写入

Redis 的服务器进程就是一个事件循环(loop),这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复。
因为服务器在处理文件事件时可能会执行写命令,使得一些内容被追加到 aof_buf 缓冲区里面,所以在服务器每次结束一个事件循环之前,都会考虑是否需要将 aof_buf 缓冲区中的内容写入和保存到 AOF 文件里面。

2.3.3 文件同步

Redis提供了多种AOF缓冲区同步文件策略,由参数appendfsync控制,可选择值如下:

可配置值 描述
always 将 aof_buf 缓冲区中的所有内容写入并同步到 AOF 文件。
everysec 将 aof_buf 缓冲区中的所有内容写入到 AOF 文件,如果上次同步 AOF 文件的时间距离现在超过一秒钟,那么再次对 AOF 文件进行同步,并且这个同步操作是由一个线程专门负责执行的。
no 将 aof_buf 缓冲区中的所有内容写入到 AOF 文件,但并不对 AOF 文件进行同步,何时同步由操作系统来决定。

2.3.4 重写机制

随着命令不断写入AOF,文件会越来越大,为了解决这个问题,Redis引入AOF重写机制压缩文件体积。

AOF文件重写就是把Redis进程内的数据转化为写命令同步到新AOF文件的过程。

可以通过 auto-aof-rewrite-min-sizeauto-aof-rewrite-percentage 配置项自动触发bgrewriteaof命令。

  • auto-aof-rewrite-min-size表示运行AOF重写时文件最小体积,默认
    64MB
  • auto-aof-rewrite-percentage代表当前AOF文件空间(aof_current_size)和上一次重写后AOF文件空间(aof_base_size)的比值。
  • 自动触发的条件就是 aof_current_size > auto-aof-rewrite-minsize(aof_current_size - aof_base_size)/aof_base_size>=auto-aof-rewritepercentage
  • 其中aof_current_sizeaof_base_size可以在info Persistence统计信息中查看。

AOF重写流程:

  • 父进程执行fork创建子进程。
  • 父进程在子进程重写时间内,会将处理的写命令不但写入 aof_buf 中,还会写入一个AOF重写缓冲区中。
  • 子进程根据内存快照,按照命令合并规则写入到新的AOF文件。
  • 当子进程完成新AOF文件写入后,会发送信号给父进程。
  • 父进程把AOF重写缓冲区的数据写入到新的AOF文件。
  • 最后原子性用新AOF文件替换老文件,完成AOF重写。

2.3.5 AOF 文件载入

  • AOF持久化开启且AOF文件存在时,优先加载AOF文件。
  • AOF关闭或者AOF文件不存在时,加载RDB文件。
  • 加载AOF/RDB文件成功后,Redis启动成功。
  • AOF/RDB文件存在错误时,Redis启动失败并打印错误信息。

载入AOF文件过程如下:

  • 创建一个不带网络连接的伪客户端。
  • 读取AOF文件中一条命令,在伪客户端中执行。
  • 直到AOF文件中所有命令都在伪客户端中执行完成。

2.4 复制

2.4.1 旧版复制

分为两部分:

  • 同步 sync : 将从服务器数据库状态更新到主服务器数据库状态。
  • 命令传播 : 主服务器数据库状态改变时,会发送命令给从服务器,让主从服务器数据库状态重回一致。

2.4.1.1 同步 sync

客户端向从服务器发送 slaveof {masterHost} {masterPort} 命令,根据masterHostmasterPort 与主服务器建立连接,然后执行同步 sync 操作:

  • 从服务器向主服务器发送 sync 命令。
  • 主服务器收到 sync 命令,就会执行bgsave 命令,生成一个RDB 文件,并使用一个缓存区记录从现在开始执行的所有写命令。
  • 当主服务器的RDB 文件生成后,会将这个文件发送给从服务器,这样从服务器根据RDB 文件,将自己的数据库状态更新到主服务器执行bgsave 命令时的状态。
  • 最后主服务器再将记录这段时间内写命令的缓存区数据发送给从服务器。

2.4.1.2 命令传播

主服务器会将执行的写命令(会导致主从服务器数据库状态不一致),发送给从服务器,让主从服务器数据库状态回归一致。

2.4.1.3 旧版复制的缺陷

  • 当主从服务器连接偶尔断链,再重连后,也会发送 sync 命令,同步主从服务器数据库状态。
    • 即使主从服务器数据库差距很小,但还是必须使用 sync 命令,否则没办法同步数据库状态。
    • bgsave 是非常消耗服务器cpu资源,发送RDB 文件又会消耗服务器网络资源。
  • 命令传播时,发送给从服务器的写命令,可能因为网络原因丢失了。但是主服务器却不知道从服务器是否完成了这个写命令,导致主从服务器数据库状态不一致。

    这个才是比较致命的,旧版的复制其实没办法使用的,因为主从服务器数据可能就是不一样的。

2.4.2 新版复制

新版复制使用 psync 命令代替 sync 命令,来执行同步操作,psync 命令有完整同步和部分同步:

  • 完整同步 : 和 sync 命令一样,需要主服务器生成 RDB 文件然后进行同步。
  • 部分同步 : 主服务器将从服务器状态到当前状态之间的写命令都发送给从服务器,这样从服务器执行这些写命令,就可以将自己的状态同步到主服务器的状态。

2.4.2.1 实现部分同步

主要靠三个属性:复制偏移量,复制积压缓存区和服务器运行ID(即runID)。

  • 复制偏移量

    主从服务器都会维护一个复制偏移量

    • 每次主服务器向从服务器发送 N 个字节的写命令时,就会将自己的复制偏移增加 N
    • 从服务器收到主服务器 N 个字节的写命令,执行完成后,也会将自己的复制偏移增加 N
  • 复制积压缓存区

    复制积压缓存区是由主服务器维护的固定长度的先进先出队列,默认大小就是 1MB

    • 当主服务器进行命令传播时,不仅会将写命令发送给所有从服务器,还会将写命令放入复制积压缓存区中。
  • 服务器运行ID

    • 不管是主服务器还是从服务器,都会有自己的 runID
    • runID在服务器启动时自动生成,由 40 个随机十六进制字符组成,每次服务器重启,runID都会不一样。

当从服务器进行同步时,会发送 psync , runid 是上次复制主服务器运行ID,offset 是当前从服务器的复制偏移量。
主服务器收到psync 请求,会进行如下情况:

  • 如果 runid 不是自己的运行ID,或者复制偏移量 offset 不在复制积压缓存区中,那么返回 +FULLRESYNC , 让从服务器进行完整同步。
  • 如果runid是自己的运行ID且复制偏移量 offset 在复制积压缓存区中,那么回复 +CONTINUE ,让客户端进行部分同步,随后会将从服务器缺失的写命令发送过来。

2.4.2.2 心跳检测

从服务器会以一定频率(默认是1秒),向主服务器发送命令

REPLCONF ACK 

主要是实现以下三个功能:

  • 使用心跳检测主从服务器网络连接状态。
  • 实现 min-slaves 的功能。

    Redismin-slaves-to-writemin-slaves-max-lag 选项,防止主服务器在不安全情况下执行写命令。这些都是需要通过心跳,主服务器才知道有多少可用的从服务器。

  • 检测命令转播写命令的丢失

    因为从服务器会上报自己当前的复制偏移量 offset,主服务器就可以将缺失写命令发送给从服务器。

2.5 发布与订阅

2.5.1 数据格式

struct redisServer {
 
    // ...
 
    // 保存所有频道的订阅关系
    dict *pubsub_channels;

    // 保存所有模式订阅关系
    list *pubsub_patterns;
 
    // ...
 
};

struct pubsubPattern {
       // 订阅的客户端
       redisClient *redisClient;
       // 订阅模式
       robj *pattern;
}
  • pubsub_channels : 使用一个字典记录所有频道的订阅关系
    • 字典键值对的键就是频道名。
      *字典键值对的值是一个链表,链表记录所有订阅这个频道的客户端。
  • pubsub_patterns : 使用一个链表记录所有模式订阅关系。

    链表中的节点类型都是 pubsubPattern,保存模式订阅的模式和对应客户端。

2.5.2 发送消息

当客户端执行 publish 命令,向频道channel 发送 message 消息时,服务器会执行如下操作:

  • 根据频道名channel,从pubsub_channels中获取订阅这个频道所有的客户端,然后向它们发送 message 消息。
  • 遍历 pubsub_patterns 链表,如果发现模式与频道名channel匹配,那么就向它客户端发 message 消息。

2.5.3 查看订阅消息

  • pubsub channels : 用于返回服务器当前被订阅的所有频道名。

    其实就是字典pubsub_channels 的键的集合。

  • pubsub numsub : 返回频道名对应的订阅数。

    其实就是字典pubsub_channels 的键对应值链表类型的长度。

  • pubsub numpat : 返回当前服务器模式订阅的数量。

    其实就是链表pubsub_patterns 的长度。

2.6 事务

Redis 通过 MULTI,EXEC,DISCARDWATCH 命令来实现事务。

注意任何在事务里可以完成的事, 在脚本里面也能完成。 并且一般来说, 使用脚本要来得更简单,并且速度更快。

事务的实现经历以下三个阶段:事务开始,命令入队和事务执行。

2.6.1 事务开始

通过 MULTI 命令就开启事务。

2.6.2 命令入队

当一个客户端处于非事务状态时,这个客户端发送的命令会立即被服务器执行。
与此不同的是,当一个客户端切换到事务状态之后,服务器会根据这个客户端发来的不同命令执行不同的操作:

  • 如果客户端发送的命令为 EXECDISCARDWATCHMULTI 四个命令的其中一个,那么服务器立即执行这个命令。
    与此相反,如果客户端发送的命令是 EXECDISCARDWATCHMULTI 四个命令以外的其他命令,那么服务器并不立即执行这个命令,而是将这个命令放入一个事务队列里面,然后向客户端返回 QUEUED 回复。

2.6.3 事务执行

当一个处于事务状态的客户端向服务器发送 EXEC 命令时,这个 EXEC 命令将立即被服务器执行:服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端。
当然你也可以使用 DISCARD 丢弃这个事务,会清空事务队列。

但是需要注意 EXECDISCARD 命令必须是在事务状态下才能执行,非事务状态下,会直接报错。

2.6.4 WATCH 命令实现

WATCH 命令是一个乐观锁,它可以在 EXEC命令执行之前,监控任意数量的数据库键,并在EXEC命令执行时,判断这些被监控的数据库键值是否被修改过,如果是那么EXEC命令将拒绝执行,直接报错。

WATCH 命令实现原理就是:

typedef struct redisDb {
    // ...
 
    // 正在被 WATCH 监控的键
    dict *watched_keys;
 
    // ...
} redisDb;

使用一个字典watched_keys 记录WATCH 监控的键,字典键值对的键是监控键,键值对的值是一个链表,记录WATCH 监控键所有客户端。

所有对数据库修改命令,都会检查修改的键是否在watched_keys 中,如果在,那么就设置这个键对应的所有监控客户端事务安全性已经被破坏了。

2.6.5 事务中的错误

使用事务时可能会遇上以下两种错误:

  • 事务在执行 EXEC之前,入队的命令可能会出错。

    比如说,命令可能会产生语法错误(参数数量错误,参数名错误,等等),或者其他更严重的错误,比如内存不足(如果服务器使用 maxmemory 设置了最大内存限制的话)。

  • 命令可能在EXEC之后失败。

    举个例子,事务中的命令可能处理了错误类型的键,比如将列表命令用在了字符串键上面等等。

对于第一种错误,事务会拒绝执行;但是对于第二中错误,成功的命令会正常执行,失败的命令就失败,而且 redis 不支持事务的回滚。

你可能感兴趣的:(Redis心得)