all is redis

280 阅读1小时+

1.你应该知道的redis几个特性

(1).速度快

  • 数据存储在内存中
  • 底层C语言实现
  • 单线程框架
  • 源代码集性能与优雅于一身

(2).基于键值对的NoSQL数据库

  • 键只有字符串类型,值有string(字符串) hash(哈希) list(列表) set(集合) zset(有序集合) Bitmaps(位图)

(3).持久化
(4).主从复制
(5).高可用与分布式

2.Redis全称REmote DIctionary Server

远程字典服务器

主流来说,Redis是单线程模型,但并不是完全只使用单线程

3.Redis 本质

开源
高性能
键值对数据库
数据结构服务器
数据结构数据库

4.redis特点

.存储结构上特点

键是字符串类型,值可以是字符串,链表,哈希,集合,有序集合

.内存存储,持久化

内存存储速度快,可以一秒内读写超过十万个键值

持久化可以将内存中数据存储到硬盘,方便下次构建数据库

.功能丰富

作为数据库

作为缓存

作为队列

.简单稳定

语法简单,命令少(100+)

稳定,源码公开

5.Redis可执行文件说明

redis-server          Redis服务器

redis-cli          Redis命令行客户端

redis-benchmark   Redis性能测试工具

redis-check-aof     AOF文件修复工具

redis-check-dump   RDB文件检查工具

6.系统级命令

1.keys pattern 支持正则表达式 O(n)

*  0到无穷个字符
? 一个字符
[] 括号内的任一个字符
\x  用于转义字符

例子:
redis>set name Jack

redis>keys *

“name”

用处:可以通过关键字通过模式匹配找到想要的信息

2.exists key O(1)

判断key是否存在,存在返回1,否则返回0

3.del key 。。。 O(n)

删除一个多个键,返回被删除键的个数

4.type key O(1)

判断键的类型

返回值:

none (key不存在)

string (字符串)

list (列表)

set (集合)

zset (有序集)

hash (哈希表)

5.randomkey O(1)

随机返回一个键

返回值:

当数据库不为空时,返回一个 key 。

当数据库为空时,返回 nil 。

6.expire key seconds   O(1)

以秒为单位

超过时间,自动删除

返回成功为1,否则为0

7. pexpire key milliseconds O(1)

力度更小的时间控制,以毫秒为单位

8.expire key timestamp O(1)

给键设置Unix时间戳

成功返回1,否则为0

例子:

redis> SET cache www.google.com

OK

redis> EXPIREAT cache 1355292000

# 这个 key 将在 2012.12.12 过期

(integer) 1

类似的还有 pexpireat key

milliseconds-timestamp

9.ttl key O(1)

以秒为单位,返回剩余时间

key不存在时,返回-2

key没有设置剩余时间,返回-1

否则返回key的剩余时间

类似pttl,以毫秒为单位

10.SORT key [BY pattern] [LIMIT offset count] [GET pattern [GET pattern ...]] [ASC | DESC] [ALPHA] [STORE destination]

sort key alpha 以字母方式排序,默认以数字排序

系统命令的源码全都在db.c里

字符串类型,一个字符串类型键允许存储的数据的最大容量是512MB

static int checkStringLength(redisClient *c, long long size) {

if (size > 512*1024*1024) {

addReplyError(c,"string exceeds maximum allowed size (512MB)");

return REDIS_ERR;

}

return REDIS_OK;

}

incr key 【递增】 ++i/i++

有些公司会用incr来生成订单号。【原子性递增】

字符串的一些二进制运算

一:二进制运算

1. GetBit key offset

获取二进制中offset这个位置的“0,1”状态。

2. SetBit key offset value

设置二进制中offset这个位置的“0,1”状态。

3. Bitcount key

获取二进制中1的个数。

4. BitOp operation

二进制位的操作(^ & |)

二:Redis中二进制有两个坑。

1. 你所输入的数字全部用char类型表示, 2 => ascII形式展现【50】,

所以2是字符的“2”。

sample:

2

0 0 1 0

bitcount= 1

错。

bitcount=3。

2. 一般来说2这个字符的二进制位,采用的顺序是(从右到左。)

但是在redis是(从左到右)计数,0位是最左边

2=> '2' => 50

=>

00110010 32+16+2

3. setbit num1 7 1 =>

00110 011  32+16+2+1 => 51 ('3') //redis中从右到左计数

一:深入剖析String类型

1.Redis采用C语言编写。

2.C语言是怎么标识string的呢??? char[]

3. 如果说redis采用char[] 会有什么样的弊端

StrLen: 是不是要遍历 char[] 数组 O(N)

Append:

是不是也需要遍历char[],然后在char[]中追加。 O(N)

Redis的目标就是高性能,高并发。

4. Redis的解决方案呢??? 自己封装了一下char[],封装类型SDS (简单对象类型)

struct sdshdr {

unsigned int len; //记录char[]实际使用的长度<=buf[].length

unsigned int free; //free记录着buf[]中未使用的坑位。

char buf[];

};

5. StrLen =>

return sh->len;  O(1)

6. 看看string类型如何包装sds。

typedef struct redisObject {

unsigned type:4;

unsigned encoding:4;

unsigned lru:REDIS_LRU_BITS;

int refcount;

//垃圾回收(GC)

void *ptr;

} robj;

7.redis内部数据结构

redisObject 是Redis 类型系统的核心,数据库中的每个键、值,以及Redis 本身处理的参数,

都表示为这种数据类型。redisObject 的定义位于redis.h :

typedef struct redisObject {// 类型unsigned type:4;// 对齐位unsigned notused:2;// 编码方式unsigned encoding:4;// LRU 时间(相对于server.lruclock)unsigned lru:22;// 引用计数int refcount;// 指向对象的值void *ptr;} robj;

type 、encoding 和ptr 是最重要的三个属性。

type 记录了对象所保存的值的类型,它的值可能是以下常量的其中一个(定义位于redis.h):

#define REDIS_STRING 0 // 字符串
#define REDIS_LIST 1 // 列表
#define REDIS_SET 2 // 集合
#define REDIS_ZSET 3 // 有序集
#define REDIS_HASH 4 // 哈希表

encoding 记录了对象所保存的值的编码,它的值可能是以下常量的其中一个(定义位于

redis.h):

#define REDIS_ENCODING_RAW 0 // 编码为字符串#define REDIS_ENCODING_INT 1 // 编码为整数#define REDIS_ENCODING_HT 2 // 编码为哈希表#define REDIS_ENCODING_ZIPMAP 3 // 编码为zipmap#define REDIS_ENCODING_LINKEDLIST 4 // 编码为双端链表#define REDIS_ENCODING_ZIPLIST 5 // 编码为压缩列表#define REDIS_ENCODING_INTSET 6 // 编码为整数集合#define REDIS_ENCODING_SKIPLIST 7 // 编码为跳跃表

ptr 是一个指针,指向实际保存值的数据结构,这个数据结构由type 属性和encoding 属性决

定。

举个例子, 如果一个redisObject 的type 属性为REDIS_LIST , encoding 属性为

REDIS_ENCODING_LINKEDLIST ,那么这个对象就是一个Redis 列表,它的值保存在一个双

端链表内,而ptr 指针就指向这个双端链表;

不同键值类型底层数据结构如下

[string类型]

底层有两种不同数据结构

  • long类型整数
  • 简单动态字符串(sds)

字符串类型分别使用REDIS_ENCODING_INT 和REDIS_ENCODING_RAW 两种编码:

• REDIS_ENCODING_INT 使用long 类型来保存long 类型值。

• REDIS_ENCODING_RAW 则使用sdshdr 结构来保存sds (也即是char* )、long long 、

double 和long double 类型值。

换句话来说,在Redis 中,只有能表示为long 类型的值,才会以整数的形式保存,其他类型

的整数、小数和字符串,都是用sdshdr 结构来保存。

新创建的字符串默认使用REDIS_ENCODING_RAW 编码,在将字符串作为键或者值保存进数据库时,程序会尝试将字符串转为REDIS_ENCODING_INT 编码。

sds的实现

typedef char *sds;
struct sdshdr {
    int len;// buf 已占用长度
    int free;// buf 剩余可用长度
    char buf[];// 实际保存字符串数据的地方
};

sds.c/sdsMakeRoomFor 函数描述了sdshdr 的这种内存预分配优化策略,以下是这个函数的

伪代码版本:

def sdsMakeRoomFor(sdshdr, required_len):# 预分配空间足够,无须再进行空间分配if (sdshdr.free >= required_len):return sdshdr# 计算新字符串的总长度newlen = sdshdr.len + required_len# 如果新字符串的总长度小于SDS_MAX_PREALLOC# 那么为字符串分配2 倍于所需长度的空间# 否则就分配所需长度加上SDS_MAX_PREALLOC 数量的空间if newlen < SDS_MAX_PREALLOC:newlen *= 2else:newlen += SDS_MAX_PREALLOC# 分配内存newsh = zrelloc(sdshdr, sizeof(struct sdshdr)+newlen+1)# 更新free 属性newsh.free = newlen - sdshdr.len# 返回return newsh

在目前版本的Redis 中,SDS_MAX_PREALLOC 的值为1024 * 1024 ,也就是说,当大小小于

1MB 的字符串执行追加操作时,sdsMakeRoomFor 就为它们分配多于所需大小一倍的空间;当

字符串的大小大于1MB ,那么sdsMakeRoomFor 就为它们额外多分配1MB 的空间。

Note: 这种分配策略会浪费内存吗?

执行过APPEND 命令的字符串会带有额外的预分配空间,这些预分配空间不会被释放,除非

该字符串所对应的键被删除,或者等到关闭Redis 之后,再次启动时重新载入的字符串对象将

不会有预分配空间。

因为执行APPEND 命令的字符串键数量通常并不多,占用内存的体积通常也不大,所以这一

般并不算什么问题。

另一方面,如果执行APPEND 操作的键很多,而字符串的体积又很大的话,那可能就需要修

改Redis 服务器,让它定时释放一些字符串键的预分配空间,从而更有效地使用内存。

Redis获取字符串长度的复杂度是O(1)

对比C 字符串,sds 有以下特性:

– 可以高效地执行长度计算(strlen);

– 可以高效地执行追加操作(append);

– 二进制安全;

**

sds 会为追加操作进行优化:加快追加操作的速度,并降低内存分配的次数,代价是多占**

用了一些内存,而且这些内存不会被主动释放。

list列表结构

底层采用两种数据结构:

  • 双端链表(linkedlist)
  • 压缩列表(ziplist)

因为双端链表占用的内存比压缩列表要多,所以当创建新的列表键时,列表会优先考虑使用压

缩列表作为底层实现,并且在有需要的时候,才从压缩列表实现转换到双端链表实现。

除了实现list类型以外,双端链表还被很多Redis 内部模块所应用:

• 事务模块使用双端链表来按顺序保存输入的命令

• 服务器模块使用双端链表来保存多个客户端

• 订阅/发送模块使用双端链表来保存订阅模式的多个客户端

• 事件模块使用双端链表来保存时间事件(time event)

双端链表的实现由listNode 和list 两个数据结构构成,下图展示了由这两个结构组成的一

个双端链表实例:

其中,listNode 是双端链表的节点:

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

而list 则是双端链表本身:

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;

注意,listNode 的value 属性的类型是void * ,说明这个双端链表对节点所保存的值的类型

不做限制。

对于不同类型的值,有时候需要不同的函数来处理这些值,因此,list 类型保留了三个函数指

针——dup 、free 和match ,分别用于处理值的复制、释放和对比匹配。在对节点的值进行处理时,如果有给定这些函数,那么它们就会被调用。举个例子:当删除一个listNode 时,如果包含这个节点的list 的list->free 函数不为空,那么删除函数就会先调用list->free(listNode->value)清空节点的值,再执行余下的删除操作(比如说,释放节点)。

从这两个数据结构的定义上,也可以它们的一些行为和性能特征:

• listNode 带有prev 和next 两个指针,因此,对链表的遍历可以在两个方向上进行:从

表头到表尾,或者从表尾到表头。

• list 保存了head 和tail 两个指针,因此,对链表的表头和表尾进行插入的复杂度都为O(1) ——这是高效实现LPUSH 、RPOP 、RPOPLPUSH 等命令的关键

• list 带有保存节点数量的len 属性,所以计算链表长度的复杂度仅为O(1) ,这也保证了LEN 命令不会成为性能瓶颈。

Redis 为双端链表实现了一个迭代器,这个迭代器可以从两个方向对双端链表进行迭代:

• 沿着节点的next 指针前进,从表头向表尾迭代;

• 沿着节点的prev 指针前进,从表尾向表头迭代;

以下是迭代器的数据结构定义:

typedef struct listIter {// 下一节点listNode *next;// 迭代方向int direction;} listIter;

direction 记录迭代应该从那里开始:

• 如果值为adlist.h/AL_START_HEAD ,那么迭代器执行从表头到表尾的迭代;

• 如果值为adlist.h/AL_START_TAIL ,那么迭代器执行从表尾到表头的迭代;

双端链表及其节点的性能特性如下

– 节点带有前驱和后继指针,访问前驱节点和后继节点的复杂度为O(1) ,并且对链表

的迭代可以在从表头到表尾和从表尾到表头两个方向进行;

– 链表带有指向表头和表尾的指针,因此对表头和表尾进行处理的复杂度为O(1)

– 链表带有记录节点数量的属性,所以可以在O(1) 复杂度内返回链表的节点数量(长

度);

压缩列表

ziplist不是结构体组成,创建逻辑很简单,就是申请固定的包含头尾节点的空间,然后初始化链表上下文

//定义由zlbytes,zltail跟zllen组成的压缩链表的头大小
#define ZIPLIST_HEADER_SIZE     (sizeof(uint32_t)*2+sizeof(uint16_t))

//创建一个压缩链表,并且返回指向该链表的指针
unsigned char *ziplistNew(void) {
    //这里之所以+1是因为尾元素占用一个字节,这也是一个压缩链表最小尺寸
    unsigned int bytes = ZIPLIST_HEADER_SIZE+1;
    //分配内存
    unsigned char *zl = zmalloc(bytes);
    //设置链表大小
    ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
    //设置最后一个元素的偏移量
    ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
    //设置元素个数
    ZIPLIST_LENGTH(zl) = 0;
    //设置尾元素(上面只是申请空间)
    zl[bytes-1] = ZIP_END;
    return zl;
}

因为ziplist header 部分的长度总是固定的(4 字节+ 4 字节+ 2 字节),因此将指针移动到表

头节点的复杂度为常数时间;除此之外,因为表尾节点的地址可以通过zltail 计算得出,因

此将指针移动到表尾节点的复杂度也为常数时间

因为ziplist 由连续的内存块构成,在最坏情况下,当ziplistPush 、ziplistDelete 这类对

节点进行增加或删除的函数之后,程序需要执行一种称为连锁更新的动作来维持ziplist 结构本

身的性质,所以这些函数的最坏复杂度都为O(N2) 。不过,因为这种最坏情况出现的概率并不

高,所以大可以放心使用ziplist ,而不必太担心出现最坏情况

encoding 和length 两部分一起决定了content 部分所保存的数据的类型(以及长度)。

其中,encoding 域的长度为两个bit ,它的值可以是00 、01 、10 和11 :

• 00 、01 和10 表示content 部分保存着字符数组。

• 11 表示content 部分保存着整数

压缩列表每个元素的previous_entry_length字段存储的是前一个元素的长度,因此压缩列表的前向遍历相对简单,表达式(p-previous_entry_length)即可获取前一个元素的首地址,这里不做详述。后向遍历时,需要解码当前元素,计算当前元素长度,才能获取后一个元素首地址;ziplistNext函数实现如下

unsigned char *ziplistNext(unsigned char *zl, unsigned char *p) {
    //zl参数无用;这里只是为了避免警告
    ((void) zl);
 
    if (p[0] == ZIP_END) {
        return NULL;
    }
 
    p += zipRawEntryLength(p);
    if (p[0] == ZIP_END) {
        return NULL;
    }
 
    return p;
}

压缩列表内部节点长度是可变(按需所取)的,所以才能最大化的利用空间节省内存,压缩列表是申请一大块连续内存存储所有节点,双端列表内存是不连续的,所以容易造成内存碎片

哈希类型hash

底层有两种类型

  • 压缩列表(ziplist)
  • 字典(dict)

因为压缩列表比字典更节省内存,所以程序在创建新Hash 值时,默认使用压缩列表作为底层

实现,当有需要时,程序才会将底层实现从压缩列表转换到字典

上面的key value应该是field value

字典的主要用途有以下两个:

1. 实现数据库键空间(key space)

2. 用作Hash 类型值的其中一种底层实现

数据库中的键值对就由字典保存:每个数据库都有一个与之相对应的字典,这个字典被称之为键空间(key space),所以使用命令exist key 的复杂度是O(1)

Redis 选择了高效且实现简单的哈希表作为字典的底层实现,dict.h/dict 给出了字典的定义:

typedef struct dict {
// 特定于类型的处理函数
dictType *type;
// 类型处理函数的私有数据
void *privdata;
// 哈希表(2 个)
dictht ht[2];
// 记录rehash 进度的标志,值为-1 表示rehash 未进行
int rehashidx;
// 当前正在运作的安全迭代器数量
int iterators;
} dict;

注意dict 类型使用了两个指针分别指向两个哈希表。其中,0 号哈希表(ht[0])是字典主要使用的哈希表,而1 号哈希表(ht[1])则只有在程序对0 号哈希表进行rehash 时才使用

字典所使用的哈希表实现由dict.h/dictht 类型定义:

typedef struct dictht {
// 哈希表节点指针数组(俗称桶,bucket)
dictEntry **table;
// 指针数组的大小
unsigned long size;
// 指针数组的长度掩码,用于计算索引值
unsigned long sizemask;
// 哈希表现有的节点数量
unsigned long used;
} dictht;

table 属性是一个数组,数组的每个元素都是一个指向dictEntry 结构的指针。每个dictEntry 都保存着一个键值对,以及一个指向另一个dictEntry 结构的指针:

typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 链往后继节点
struct dictEntry *next;
} dictEntry;

next 属性指向另一个dictEntry 结构,多个dictEntry 可以通过next 指针串连成链表,从

这里可以看出,dictht 使用链地址法来处理键碰撞:当多个不同的键拥有相同的哈希值时,哈

希表用一个链表将这些键连接起来。

整个字典结构可以表示如下:

Redis 目前使用两种不同的哈希算法:

1. MurmurHash2 32 bit 算法:这种算法的分布率和速度都非常好,具体信息请参考MurmurHash

的主页:code.google.com/p/smhasher/

2. 基于djb 算法实现的一个大小写无关散列算法: 具体信息请参考

www.cse.yorku.ca/~oz/hash.ht…

使用哪种算法取决于具体应用所处理的数据:

• 命令表以及Lua 脚本缓存都用到了算法2 。

• 算法1 的应用则更加广泛:数据库、集群、哈希键、阻塞操作等功能都用到了这个算法

• 字典由键值对构成的抽象数据结构。

• Redis 中的数据库和哈希键都基于字典来实现。

• Redis 字典的底层实现为哈希表,每个字典使用两个哈希表,一般情况下只使用0 号哈希

表,只有在rehash 进行时,才会同时使用0 号和1 号哈希表。

• 哈希表使用链地址法来解决键冲突的问题。

• Rehash 可以用于扩展或收缩哈希表。

• 对哈希表的rehash 是分多次、渐进式地进行的。

集合set

底层两种数据结构

  • 整数集合(intset)
  • 字典(dict)

使用字典存储集合元素时,将元素值设为字典键字典值为空

**整数集合(intset)**是集合键的底层实现之一,如果一个集合:

1. 只保存着整数元素;

2. 元素的数量不多;

那么Redis 就会使用intset 来保存集合元素

以下是intset.h/intset 类型的定义:

typedef struct intset {
// 保存元素所使用的类型的长度
uint32_t encoding;
// 元素个数
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;

encoding 的值可以是以下三个常量的其中一个(定义位于intset.c ):

#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

contents 数组是实际保存元素的地方,数组中的元素有以下两个特性:

• 没有重复元素;

• 元素在数组中从小到大排列;

contents 数组的int8_t 类型声明比较容易让人误解,实际上,intset 并不使用int8_t 类型

来保存任何元素,结构中的这个类型声明只是作为一个占位符使用:在对contents 中的元素

进行读取或者写入时,程序并不是直接使用contents 来对元素进行索引,而是根据encoding

的值,对contents 进行类型转换和指针运算,计算出元素在内存中的正确位置。在添加新元

素,进行内存分配时,分配的容量也是由encoding 的值决定

移动不仅出现在升级(intsetUpgradeAndAdd)操作中,还出现其他对contents 数组内

容进行增删的操作上,比如intsetAdd 和intsetRemove ,因为这种移动操作需要处理intset

中的所有元素,所以这些函数的复杂度都不低于O(N) 。

• Intset 用于有序无重复地保存多个整数值,它会根据元素的值,自动选择该用什么长度

的整数类型来保存元素。

• 当一个位长度更长的整数值添加到intset 时,需要对intset 进行升级,新intset 中每个

元素的位长度都等于新添加值的位长度,但原有元素的值不变。

• 升级会引起整个intset 进行内存重分配,并移动集合中的所有元素,这个操作的复杂度

为O(N) 。

• Intset 只支持升级,不支持降级。

• Intset 是有序的,程序使用二分查找算法来实现查找操作,复杂度为O(lgN)

有序集合(zset)

有两种底层数据结构

  • 压缩列表(ziplist)
  • 跳跃表和字典结合(skiplist+dict)

在通过ZADD 命令添加第一个元素到空key 时,程序通过检查输入的第一个元素来决定该创

建什么编码的有序集。

如果第一个元素符合以下条件的话,就创建一个REDIS_ENCODING_ZIPLIST 编码的有序集:

• 服务器属性server.zset_max_ziplist_entries 的值大于0 (默认为128 )。

• 元素的member 长度小于服务器属性server.zset_max_ziplist_value 的值(默认为64

)。

否则,程序就创建一个REDIS_ENCODING_SKIPLIST 编码的有序集

对于一个REDIS_ENCODING_ZIPLIST 编码的有序集,只要满足以下任一条件,就将它转换为

REDIS_ENCODING_SKIPLIST 编码:

• ziplist 所保存的元素数量超过服务器属性server.zset_max_ziplist_entries 的值

(默认值为128 )

• 新添加元素的member 的长度大于服务器属性server.zset_max_ziplist_value 的值

(默认值为64 )

当使用REDIS_ENCODING_ZIPLIST 编码时,有序集将元素保存到ziplist 数据结构里面

其中,每个有序集元素以两个相邻的ziplist 节点表示第一个节点保存元素的member 域,

第二个元素保存元素的score 域

多个元素之间按score 值从小到大排序,如果两个元素的score 相同,那么按字典序对member进行对比,决定那个元素排在前面,那个元素排在后面

虽然元素是按score 域有序排序的,但对ziplist 的节点指针只能线性地移动,所以在

REDIS_ENCODING_ZIPLIST 编码的有序集中,查找某个给定元素的复杂度为O(N)

每次执行添加/删除/更新操作都需要执行一次查找元素的操作,因此这些函数的复杂度都不低

于O(N) ,至于这些操作的实际复杂度,取决于它们底层所执行的ziplist 操作。

当使用REDIS_ENCODING_SKIPLIST 编码时,有序集元素由redis.h/zset 结构来保存:

typedef struct zset {// 字典dict *dict;// 跳跃表zskiplist *zsl;} zset;

zset 同时使用字典和跳跃表两个数据结构来保存有序集元素。

其中,元素的成员由一个redisObject 结构表示,而元素的score 则是一个double 类型的浮

点数,字典和跳跃表两个结构通过将指针共同指向这两个值来节约空间(不用每个元素都复制

两份)。

下图展示了一个REDIS_ENCODING_SKIPLIST 编码的有序集

通过使用字典结构,并将member 作为键,score 作为值,有序集可以在O(1) 复杂度内:

检查给定member 是否存在于有序集(被很多底层函数使用);

取出member 对应的score 值(实现ZSCORE 命令)。

另一方面,通过使用跳跃表,可以让有序集支持以下两种操作:

• 在O(logN) 期望时间、O(N) 最坏时间内根据score 对member 进行定位(被很多底层

函数使用);

• 范围性查找和处理操作,这是(高效地)实现ZRANGE 、ZRANK 和ZINTERSTORE

等命令的关键。

通过同时使用字典和跳跃表,有序集可以高效地实现按成员查找和按顺序查找两种操作。

修改版的跳跃表由redis.h/zskiplist 结构定义:

typedef struct zskiplist {// 头节点,尾节点struct zskiplistNode *header, *tail;// 节点数量unsigned long length;// 目前表内节点的最大层数int level;} zskiplist;

跳跃表的节点由redis.h/zskiplistNode 定义:

typedef struct zskiplistNode {// member 对象robj *obj;// 分值double score;// 后退指针struct zskiplistNode *backward;// 层struct zskiplistLevel {// 前进指针struct zskiplistNode *forward;// 这个层跨越的节点数量unsigned int span;} level[];} zskiplistNode;

• 跳跃表是一种随机化数据结构,它的查找、添加、删除操作都可以在对数期望时间下完

成。

• 跳跃表目前在Redis 的唯一作用就是作为有序集类型的底层数据结构(之一,另一个构

成有序集的结构是字典)。

• 为了适应自身的需求,Redis 基于William Pugh 论文中描述的跳跃表进行了修改,包括:

1. score 值可重复。

2. 对比一个元素需要同时检查它的score 和memeber 。

3. 每个节点带有高度为1 层的后退指针,用于从表尾方向向表头方向迭代

高层的指针越过的元素数量大于等于低层的指针,为了 提高查找的效率,程序总是从高层先开始访问,然后随着元素值范围的缩小,慢慢降低层 次。

数据淘汰策略

redis 内存数据集大小上升到一定大小的时候,就会施行数据淘汰策略(回收策略)。redis 提供 6种数据淘汰策略:

  1. volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
  2. volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
  3. volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
  4. allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
  5. allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
  6. no-enviction(驱逐):禁止驱逐数据

内存回收

Redis中采用两种算法进行内存回收,引用计数算法以及LRU算法

**引用计数算法
**

Redis 的对象系统使用了引用计数技术来负责维持和销毁对象,它的

运作机制如下:

• 每个redisObject 结构都带有一个refcount 属性,指示这个对象被引用了多少次。

• 当新创建一个对象时,它的refcount 属性被设置为1

• 当对一个对象进行共享时,Redis 将这个对象的refcount 增一。

• 当使用完一个对象之后,或者取消对共享对象的引用之后,程序将对象的refcount 减一。

• 当对象的refcount 降至0 时,这个redisObject 结构,以及它所引用的数据结构的内

存,都会被释放。

LRU算法

在内存有限的情况下,当内存容量不足时,为了保证程序的运行,选择最近一段时间内,最久未使用的对象将其淘汰

【第二章 内存映射数据结构】

两者比较:

内存映射数据结构是一系列经过特殊编码的字节序列,创建它们所消耗的内存通常比作用类似

的内部数据结构要少得多,如果使用得当,内存映射数据结构可以为用户节省大量的内存。

不过,因为内存映射数据结构的编码和操作方式要比内部数据结构要复杂得多,所以内存映射

数据结构所占用的CPU 时间会比作用类似的内部数据结构要多

【第三章 Redis数据类型】

Redis

有一些对象在Redis 中非常常见,比如命令的返回值OK 、ERROR 、WRONGTYPE 等字符,另外,

一些小范围的整数,比如个位、十位、百位的整数都非常常见

为了利用这种常见情况,Redis 在内部使用了一个Flyweight 模式:通过预分配一些常见的值

对象,并在多个数据结构之间共享这些对象,程序避免了重复分配的麻烦,也节约了一些CPU

时间。

Redis 预分配的值对象有如下这些:

• 各种命令的返回值,比如执行成功时返回的OK ,执行错误时返回的ERROR ,类型错误时

返回的WRONGTYPE ,命令入队事务时返回的QUEUED ,等等。

• 包括0 在内, 小于redis.h/REDIS_SHARED_INTEGERS 的所有整数

(REDIS_SHARED_INTEGERS 的默认值为10000)

如果某个命令的输入值是一个小于REDIS_SHARED_INTEGERS 的整数对象,那么当这个对象要被保存进数据

库时,Redis 就会释放原来的值,并将值的指针指向共享对象

Note: 共享对象只能被带指针的数据结构使用。

需要提醒的一点是,共享对象只能被字典和双端链表这类能带有指针的数据结构使用。

像整数集合和压缩列表这些只能保存字符串、整数等字面值的内存数据结构,就不能使用共享

对象。

• Redis 使用自己实现的对象机制来实现类型判断、命令多态和基于引用计数的垃圾回收。

• 一种Redis 类型的键可以有多种底层实现。

• Redis 会预分配一些常用的数据对象,并通过共享这些对象来减少内存占用,和避免频繁

地为小对象分配内存

redis事务

事务提供了一种“将多个命令打包,然后一次性、按顺序地执行”的机制,并且事务在执行的期

间不会主动中断——服务器在执行完事务中的所有命令之后,才会继续处理其他客户端的其他

命令

MULTI 命令的执行标记着事务的开始:

redis> MULTI

OK

这个命令唯一做的就是,将客户端的REDIS_MULTI 选项打开,让客户端从非事务状态切换到事

务状态

当客户端进入事务状态之后,服务器在收到来自客户端的命令时,不会立即执行命令,

而是将这些命令全部放进一个事务队列里,事务队列是一个数组,每个数组项是都包含三个属性:

1. 要执行的命令(cmd)。

2. 命令的参数(argv)。

3. 参数的个数(argc)

举个例子,如果客户端执行以下命令:

redis> MULTI

OK

redis> SET book-name "Mastering C++ in 21 days"

QUEUED

redis> GET book-name

QUEUED

redis> SADD tag "C++" "Programming" "Mastering Series"

QUEUED

redis> SMEMBERS tag

QUEUED

那么程序将为客户端创建以下事务队列:

其实并不是所有的命令都会被放进事务队列,其中的例外就是EXEC 、DISCARD 、MULTI

和WATCH 这四个命令——当这四个命令从客户端发送到服务器时,它们会像客户端处于非

事务状态一样,直接被服务器执行

DISCARD 命令用于取消一个事务,它清空客户端的整个事务队列,然后将客户端从事务状态

调整回非事务状态,最后返回字符串OK 给客户端,说明事务已被取消。

Redis 的事务是不可嵌套的,当客户端已经处于事务状态,而客户端又再向服务器发送MULTI

时,服务器只是简单地向客户端发送一个错误,然后继续等待其他命令的入队。MULTI 命令

的发送不会造成整个事务失败,也不会修改事务队列中已有的数据。

WATCH 只能在客户端进入事务状态之前执行,在事务状态下发送WATCH 命令会引发一个

错误,但它不会造成整个事务失败,也不会修改事务队列中已有的数据(和前面处理MULTI

的情况一样)。

WATCH 命令的实现

在每个代表数据库的redis.h/redisDb 结构类型中,都保存了一个watched_keys 字典,字典

的键是这个数据库被监视的键,而字典的值则是一个链表,链表中保存了所有监视这个键的客

户端。

在任何对数据库键空间(key space)进行修改的命令成功执行之后(比如FLUSHDB 、SET

、DEL 、LPUSH 、SADD 、ZREM ,诸如此类),multi.c/touchWatchKey 函数都会被调用

——它检查数据库的watched_keys 字典,看是否有客户端在监视已经被命令修改的键,如果

有的话,程序将所有监视这个/这些被修改键的客户端的REDIS_DIRTY_CAS 选项打开

当客户端发送EXEC 命令、触发事务执行时,服务器会对客户端的状态进行检查:

• 如果客户端的REDIS_DIRTY_CAS 选项已经被打开,那么说明被客户端监视的键至少有一

个已经被修改了,事务的安全性已经被破坏。服务器会放弃执行这个事务,直接向客户端

返回空回复,表示事务执行失败。

• 如果REDIS_DIRTY_CAS 选项没有被打开,那么说明所有监视键都安全,服务器正式执行

事务。

最后,当一个客户端结束它的事务时,无论事务是成功执行,还是失败,watched_keys 字典

中和这个客户端相关的资料都会被清除

在传统的关系式数据库中,常常用ACID 性质来检验事务功能的安全性。

Redis 事务保证了其中的一致性(C)和隔离性(I),但并不保证原子性(A)和持久性(D)

单个Redis 命令的执行是原子性的,但Redis 没有在事务上增加任何维持原子性的机制,所以

Redis 事务的执行并不是原子性的

如果一个事务队列中的所有命令都被成功地执行,那么称这个事务执行成功。

另一方面,如果Redis 服务器进程在执行事务的过程中被停止——比如接到KILL 信号、宿主

机器停机,等等,那么事务执行失败。

当事务失败时,Redis 也不会进行任何的重试或者回滚动作

Redis 的一致性问题可以分为三部分来讨论:入队错误、执行错误、Redis 进程被终结。

入队错误

在命令入队的过程中, 如果客户端向服务器发送了错误的命令, 比如命令的参数数量

不对,等等,那么服务器将向客户端返回一个出错信息,并且将客户端的事务状态设为

REDIS_DIRTY_EXEC 。

当客户端执行EXEC 命令时,Redis 会拒绝执行状态为REDIS_DIRTY_EXEC 的事务,并返回失

败信息。

因此,带有不正确入队命令的事务不会被执行,也不会影响数据库的一致性。

执行错误

如果命令在事务执行的过程中发生错误,比如说,对一个不同类型的key 执行了错误的操作,

那么Redis 只会将错误包含在事务的结果中,这不会引起事务中断或整个失败,不会影响已执

行事务命令的结果,也不会影响后面要执行的事务命令,所以它对事务的一致性也没有影响。

Redis 进程被终结

如果Redis 服务器进程在执行事务的过程中被其他进程终结,或者被管理员强制杀死,那么根

据Redis 所使用的持久化模式,可能有以下情况出现:

• 内存模式:如果Redis 没有采取任何持久化机制,那么重启之后的数据库总是空白的,所

以数据总是一致的。

• RDB 模式:在执行事务时,Redis 不会中断事务去执行保存RDB 的工作,只有在事务执

行之后,保存RDB 的工作才有可能开始。所以当RDB 模式下的Redis 服务器进程在事

务中途被杀死时,事务内执行的命令,不管成功了多少,都不会被保存到RDB 文件里。

恢复数据库需要使用现有的RDB 文件,而这个RDB 文件的数据保存的是最近一次的数

据库快照(snapshot),所以它的数据可能不是最新的,但只要RDB 文件本身没有因为

其他问题而出错,那么还原后的数据库就是一致的。

• AOF 模式:因为保存AOF 文件的工作在后台线程进行,所以即使是在事务执行的中途,

保存AOF 文件的工作也可以继续进行,因此,根据事务语句是否被写入并保存到AOF

文件,有以下两种情况发生:

1)如果事务语句未写入到AOF 文件,或AOF 未被SYNC 调用保存到磁盘,那么当进

程被杀死之后,Redis 可以根据最近一次成功保存到磁盘的AOF 文件来还原数据库,只

要AOF 文件本身没有因为其他问题而出错,那么还原后的数据库总是一致的,但其中的

数据不一定是最新的。

2)如果事务的部分语句被写入到AOF 文件,并且AOF 文件被成功保存,那么不完整的

事务执行信息就会遗留在AOF 文件里,当重启Redis 时,程序会检测到AOF 文件并不

完整,Redis 会退出,并报告错误。需要使用redis-check-aof 工具将部分成功的事务命令

移除之后,才能再次启动服务器。还原之后的数据总是一致的,而且数据也是最新的(直

到事务执行之前为止)。

隔离性(Isolation)

Redis 是单进程程序,并且它保证在执行事务时,不会对事务进行中断,事务可以运行直到执

行完所有事务队列中的命令为止。因此,Redis 的事务是总是带有隔离性的。

持久性(Durability)

因为事务不过是用队列包裹起了一组Redis 命令,并没有提供任何额外的持久性功能,所以事

务的持久性由Redis 所使用的持久化模式决定:

• 在单纯的内存模式下,事务肯定是不持久的。

• 在RDB 模式下,服务器可能在事务执行之后、RDB 文件更新之前的这段时间失败,所

以RDB 模式下的Redis 事务也是不持久的。

• 在AOF 的“总是SYNC ”模式下,事务的每条命令在执行成功之后,都会立即调用fsync

或fdatasync 将事务数据写入到AOF 文件。但是,这种保存是由后台线程进行的,主

线程不会阻塞直到保存成功,所以从命令执行成功到数据保存到硬盘之间,还是有一段

非常小的间隔,所以这种模式下的事务也是不持久的。

其他AOF 模式也和“总是SYNC ”模式类似,所以它们都是不持久的

• 事务提供了一种将多个命令打包,然后一次性、有序地执行的机制。

• 事务在执行过程中不会被中断,所有事务命令执行完之后,事务才能结束。

• 多个命令会被入队到事务队列中,然后按先进先出(FIFO)的顺序执行。

• 带WATCH 命令的事务会将客户端和被监视的键在数据库的watched_keys 字典中进行关

联,当键被修改时,程序会将所有监视被修改键的客户端的REDIS_DIRTY_CAS 选项打开。

• 只有在客户端的REDIS_DIRTY_CAS 选项未被打开时,才能执行事务,否则事务直接返回

失败。

• Redis 的事务保证了ACID 中的一致性(C)和隔离性(I),但并不保证原子性(A)和

持久性(D)

redis频道与订阅

每个Redis 服务器进程都维持着一个表示服务器状态的redis.h/redisServer 结构,结构的

pubsub_channels 属性是一个字典,这个字典就用于保存订阅频道的信息:

struct redisServer {
// ...
dict *pubsub_channels;
// ...
};

其中,字典的键为正在被订阅的频道,而字典的值则是一个链表,链表中保存了所有订阅这个

频道的客户端。

当客户端调用SUBSCRIBE 命令时,程序就将客户端和要订阅的频道在pubsub_channels 字

典中关联起来。

了解了pubsub_channels 字典的结构之后,解释PUBLISH 命令的实现就非常简单了:当调

用PUBLISH channel message 命令,程序首先根据channel 定位到字典的键,然后将信息发

送给字典值链表中的所有客户端。

使用UNSUBSCRIBE 命令可以退订指定的频道,这个命令执行的是订阅的反操作:它从

pubsub_channels 字典的给定频道(键)中,删除关于当前客户端的信息,这样被退订频道的

信息就不会再发送给这个客户端。

struct redisServer {
// ...
list *pubsub_patterns;
// ...
};

订阅模式

PSUBSCRIBE

redisServer.pubsub_patterns 属性是一个链表,链表中保存着所有和模式相关的信息:

链表中的每个节点都包含一个redis.h/pubsubPattern 结构

typedef struct pubsubPattern {
redisClient *client;
robj *pattern;
} pubsubPattern;

client 属性保存着订阅模式的客户端,而pattern 属性则保存着被订阅的模式。

每当调用PSUBSCRIBE 命令订阅一个模式时,程序就创建一个包含客户端信息和被订阅模式的

pubsubPattern 结构,并将该结构添加到redisServer.pubsub_patterns 链表中。

作为例子,下图展示了一个包含两个模式的pubsub_patterns 链表,其中client123 和

client256 都正在订阅tweet.shop.* 模式

通过遍历整个pubsub_patterns 链表,程序可以检查所有正在被订阅的模式,以及订阅这些模

式的客户端。

退订模式

使用PUNSUBSCRIBE 命令可以退订指定的模式,这个命令执行的是订阅模式的反操作:程序

会删除redisServer.pubsub_patterns 链表中,所有和被退订模式相关联的pubsubPattern

结构,这样客户端就不会再收到和模式相匹配的频道发来的信息。

• 订阅信息由服务器进程维持的redisServer.pubsub_channels 字典保存,字典的键为被

订阅的频道,字典的值为订阅频道的所有客户端。

• 当有新消息发送到频道时,程序遍历频道(键)所对应的(值)所有客户端,然后将消息

发送到所有订阅频道的客户端上。

• 订阅模式的信息由服务器进程维持的redisServer.pubsub_patterns 链表保存,链表的

每个节点都保存着一个pubsubPattern 结构,结构中保存着被订阅的模式,以及订阅该

模式的客户端。程序通过遍历链表来查找某个频道是否和某个模式匹配。

• 当有新消息发送到频道时,除了订阅频道的客户端会收到消息之外,所有订阅了匹配频

道的模式的客户端,也同样会收到消息。

• 退订频道和退订模式分别是订阅频道和订阅模式的反操作。

慢日志

每条慢查询日志都以一个slowlog.h/slowlogEntry 结构定义:

typedef struct slowlogEntry {

// 命令参数

robj **argv;

// 命令参数数量

int argc;

// 唯一标识符

long long id;

// 执行命令消耗的时间,以纳秒(1 / 1,000,000,000 秒)为单位

long long duration;

// 命令执行时的时间

time_t time;

} slowlogEntry;

记录服务器状态的redis.h/redisServer 结构里保存了几个和慢查询有关的属性:

struct redisServer {

// ... other fields

// 保存慢查询日志的链表

list *slowlog;

// 慢查询日志的当前id 值

long long slowlog_entry_id;

// 慢查询时间限制

long long slowlog_log_slower_than;

// 慢查询日志的最大条目数量

unsigned long slowlog_max_len;

// ... other fields

};

slowlog 属性是一个链表,链表里的每个节点保存了一个慢查询日志结构,所有日志按添加时

间从新到旧排序,新的日志在链表的左端,旧的日志在链表的右端。

slowlog_entry_id 在创建每条新的慢查询日志时增一,用于产生慢查询日志的ID (这个ID

在执行SLOWLOG RESET 之后会被重置)。

slowlog_log_slower_than 是用户指定的命令执行时间上限,执行时间大于等于这个值的命令

会被慢查询日志记录。

slowlog_max_len 慢查询日志的最大数量,当日志数量等于这个值时,添加一条新日志会造成

最旧的一条日志被删除。

在每次执行命令之前,Redis 都会用一个参数记录命令执行前的时间,在命令执行完之后,再

计算一次当前时间,然后将两个时间值相减,得出执行命令所耗费的时间值duration ,并将

duration 传给slowlogPushEntryIfNeed 函数。

针对慢查询日志有三种操作,分别是查看、清空和获取日志数量:

• 查看日志:在日志链表中遍历指定数量的日志节点,复杂度为O(N) 。

• 清空日志:释放日志链表中的所有日志节点,复杂度为O(N) 。

• 获取日志数量:获取日志的数量等同于获取server.slowlog 链表的数量,复杂度为

O(1) 。

• Redis 用一个链表以FIFO 的顺序保存着所有慢查询日志。

• 每条慢查询日志以一个慢查询节点表示,节点中记录着执行超时的命令、命令的参数、命

令执行时的时间,以及执行命令所消耗的时间等信息。

【第五章 内部运作机制】

Redis 中的每个数据库,都由一个redis.h/redisDb 结构表示:

typedef struct redisDb {

// 保存着数据库以整数表示的号码

int id;

// 保存着数据库中的所有键值对数据

// 这个属性也被称为键空间(key space)

dict *dict;

// 保存着键的过期信息

dict *expires;

// 实现列表阻塞原语,如BLPOP

// 在列表类型一章有详细的讨论

dict *blocking_keys;

dict *ready_keys;

// 用于实现WATCH 命令

// 在事务章节有详细的讨论

dict *watched_keys;

} redisDb;

redisDb 结构的id 域保存着数据库的号码。

这个号码很容易让人将它和切换数据库的SELECT 命令联系在一起,但是,实际上,id 属性

并不是用来实现SELECT 命令,而是给Redis 内部程序使用的。

当Redis 服务器初始化时, 它会创建出redis.h/REDIS_DEFAULT_DBNUM 个数据库, 并

将所有数据库保存到redis.h/redisServer.db 数组中, 每个数据库的id 为从0 到

REDIS_DEFAULT_DBNUM - 1 的值。

当执行SELECT number 命令时,程序直接使用redisServer.db[number] 来切换数据库。

但是,一些内部程序,比如AOF 程序、复制程序和RDB 程序,需要知道当前数据库的号码,

如果没有id 域的话,程序就只能在当前使用的数据库的指针,和redisServer.db 数组中所

有数据库的指针进行对比,以此来弄清楚自己正在使用的是那个数据库。

在数据库中,所有键的过期时间都被保存在redisDb 结构的expires 字典里:

typedef struct redisDb {
// ...
dict *expires;
// ...
} redisDb;

expires 字典的键是一个指向dict 字典(键空间)里某个键的指针,而字典的值则是键所指

向的数据库键的到期时间,这个值以long long 类型表示。

下图展示了一个含有三个键的数据库,其中number 和book 两个键带有过期时间:

Redis 有四个命令可以设置键的生存时间(可以存活多久)和过期时间(什么时候到期):

• EXPIRE 以秒为单位设置键的生存时间;

• PEXPIRE 以毫秒为单位设置键的生存时间;

• EXPIREAT 以秒为单位,设置键的过期UNIX 时间戳;

• PEXPIREAT 以毫秒为单位,设置键的过期UNIX 时间戳。

虽然有那么多种不同单位和不同形式的设置方式,但是expires 字典的值只保存“以毫秒为单

位的过期UNIX 时间戳” ,这就是说,通过进行转换,所有命令的效果最后都和PEXPIREAT

命令的效果一样。

举个例子,从EXPIRE 命令到PEXPIREAT 命令的转换可以用伪代码表示如下:

def EXPIRE(key, sec):

# 将TTL 从秒转换为毫秒

ms = sec_to_ms(sec)

# 获取以毫秒计算的当前UNIX 时间戳

ts_in_ms = get_current_unix_timestamp_in_ms()

# 毫秒TTL 加上毫秒时间戳,就是key 到期的时间戳

PEXPIREAT(ms + ts_in_ms, key)

其他函数的转换方式也是类似的。

作为例子,下图展示了一个expires 字典示例,字典中number 键的过期时间是2013 年2 月

10 日(农历新年),而book 键的过期时间则是2013 年2 月14 日(情人节):

通过expires 字典,可以用以下步骤检查某个键是否过期:

1. 检查键是否存在于expires 字典:如果存在,那么取出键的过期时间;

2. 检查当前UNIX 时间戳是否大于键的过期时间:如果是的话,那么键已经过期;否则,

键未过期。

可以用伪代码来描述这一过程:

def is_expired(key):
# 取出键的过期时间
key_expire_time = expires.get(key)
# 如果过期时间不为空,并且当前时间戳大于过期时间,那么键已经过期
if expire_time is not None and current_timestamp() > key_expire_time:
return True
# 否则,键未过期或没有设置过期时间
return False

我们知道了过期时间保存在expires 字典里,又知道了该如何判定一个键是否过期,现在剩下

的问题是,

过期键删除

如果一个键是过期的,那它什么时候会被删除

这个问题有三种可能的答案:

1. 定时删除:在设置键的过期时间时,创建一个定时事件,当过期时间到达时,由事件处理

器自动执行键的删除操作。

2. 惰性删除:放任键过期不管,但是在每次从dict 字典中取出键值时,要检查键是否过

期,如果过期的话,就删除它,并返回空;如果没过期,就返回键值。

3. 定期删除:每隔一段时间,对expires 字典进行检查,删除里面的过期键。

定时删除

定时删除策略对内存是最友好的:因为它保证过期键会在第一时间被删除,过期键所消耗的内

存会立即被释放。

这种策略的缺点是,它对CPU 时间是最不友好的:因为删除操作可能会占用大量的CPU 时间

——在内存不紧张、但是CPU 时间非常紧张的时候(比如说,进行交集计算或排序的时候),

将CPU 时间花在删除那些和当前任务无关的过期键上,这种做法毫无疑问会是低效的。

除此之外,目前Redis 事件处理器对时间事件的实现方式——无序链表,查找一个时间复杂度

为O(N) ——并不适合用来处理大量时间事件。

惰性删除

惰性删除对CPU 时间来说是最友好的:它只会在取出键时进行检查,这可以保证删除操作只

会在非做不可的情况下进行——并且删除的目标仅限于当前处理的键,这个策略不会在删除其

他无关的过期键上花费任何CPU 时间。

惰性删除的缺点是,它对内存是最不友好的:如果一个键已经过期,而这个键又仍然保留在数

据库中,那么dict 字典和expires 字典都需要继续保存这个键的信息,只要这个过期键不被

删除,它占用的内存就不会被释放。

在使用惰性删除策略时,如果数据库中有非常多的过期键,但这些过期键又正好没有被访问的

话,那么它们就永远也不会被删除(除非用户手动执行),这对于性能非常依赖于内存大小的

Redis 来说,肯定不是一个好消息。

举个例子,对于一些按时间点来更新的数据,比如日志(log),在某个时间点之后,对它们的访

问就会大大减少,如果大量的这些过期数据积压在数据库里面,用户以为它们已经过期了(已

经被删除了),但实际上这些键却没有真正的被删除(内存也没有被释放),那结果肯定是非常

糟糕。

定期删除

从上面对定时删除和惰性删除的讨论来看,这两种删除方式在单一使用时都有明显的缺陷:定

时删除占用太多CPU 时间,惰性删除浪费太多内存。

定期删除是这两种策略的一种折中:

• 它每隔一段时间执行一次删除操作,并通过限制删除操作执行的时长和频率,籍此来减

少删除操作对CPU 时间的影响。

• 另一方面,通过定期删除过期键,它有效地减少了因惰性删除而带来的内存浪费。

Redis 使用的过期键删除策略是惰性删除加上定期删除,这两个策略相互配合,可以很好地在

合理利用CPU 时间和节约内存空间之间取得平衡。

实现过期键惰性删除策略的核心是db.c/expireIfNeeded 函数——所有命令在读取或写入数

据库之前,程序都会调用expireIfNeeded 对输入键进行检查,并将过期键删除

对过期键的定期删除由redis.c/activeExpireCycle 函执行:每当Redis 的例行处理程序

serverCron 执行时,activeExpireCycle 都会被调用——这个函数在规定的时间限制内,尽

可能地遍历各个数据库的expires 字典,随机地检查一部分键的过期时间,并删除其中的过期

键。

过期键对AOF 、RDB 和复制的影响

更新后的RDB 文件

在创建新的RDB 文件时,程序会对键进行检查,过期的键不会被写入到更新后的RDB 文件

中。

因此,过期键对更新后的RDB 文件没有影响

AOF 文件

在键已经过期,但是还没有被惰性删除或者定期删除之前,这个键不会产生任何影响,AOF 文

件也不会因为这个键而被修改。

当过期键被惰性删除、或者定期删除之后,程序会向AOF 文件追加一条DEL 命令,来显式地

记录该键已被删除。

举个例子,如果客户端使用GET message 试图访问message 键的值,但message 已经过期了,

那么服务器执行以下三个动作:

1. 从数据库中删除message ;

2. 追加一条DEL message 命令到AOF 文件;

3. 向客户端返回NIL

AOF 重写

和RDB 文件类似,当进行AOF 重写时,程序会对键进行检查,过期的键不会被保存到重写

后的AOF 文件。

因此,过期键对重写后的AOF 文件没有影响。

复制

当服务器带有附属节点时,过期键的删除由主节点统一控制:

• 如果服务器是主节点,那么它在删除一个过期键之后,会显式地向所有附属节点发送一

个DEL 命令。

• 如果服务器是附属节点,那么当它碰到一个过期键的时候,它会向程序返回键已过期的

回复,但并不真正的删除过期键。因为程序只根据键是否已经过期、而不是键是否已经被

删除来决定执行流程,所以这种处理并不影响命令的正确执行结果。当接到从主节点发来

的DEL 命令之后,附属节点才会真正的将过期键删除掉。

附属节点不自主对键进行删除是为了和主节点的数据保持绝对一致,因为这个原因,当一个过

期键还存在于主节点时,这个键在所有附属节点的副本也不会被删除。

这种处理机制对那些使用大量附属节点,并且带有大量过期键的应用来说,可能会造成一部分

内存不能立即被释放,但是,因为过期键通常很快会被主节点发现并删除,所以这实际上也算

不上什么大问题。

数据库空间的收缩和扩展

因为数据库空间是由字典来实现的,所以数据库空间的扩展/收缩规则和字典的扩展/收缩规则

完全一样,具体的信息可以参考《字典》章节。

因为对字典进行收缩的时机是由使用字典的程序决定的, 所以Redis 使用

redis.c/tryResizeHashTables 函数来检查数据库所使用的字典是否需要进行收缩:每次

redis.c/serverCron 函数运行的时候,这个函数都会被调用。

tryResizeHashTables 函数的完整定义如下

void tryResizeHashTables(void) {

int j;

for (j = 0; j < server.dbnum; j++) {

// 缩小键空间字典

if (htNeedsResize(server.db[j].dict))

dictResize(server.db[j].dict);

// 缩小过期时间字典

if (htNeedsResize(server.db[j].expires))

dictResize(server.db[j].expires);

}

}

• 数据库主要由dict 和expires 两个字典构成,其中dict 保存键值对,而expires 则

保存键的过期时间。

• 数据库的键总是一个字符串对象,而值可以是任意一种Redis 数据类型,包括字符串、哈

希、集合、列表和有序集。

• expires 的某个键和dict 的某个键共同指向同一个字符串对象,而expires 键的值则

是该键以毫秒计算的UNIX 过期时间戳。

• Redis 使用惰性删除和定期删除两种策略来删除过期的键。

• 更新后的RDB 文件和重写后的AOF 文件都不会保留已经过期的键。

• 当一个过期键被删除之后,程序会追加一条新的DEL 命令到现有AOF 文件末尾。

• 当主节点删除一个过期键之后,它会显式地发送一条DEL 命令到所有附属节点。

• 附属节点即使发现过期键,也不会自作主张地删除它,而是等待主节点发来DEL 命令,

这样可以保证主节点和附属节点的数据总是一致的。

• 数据库的dict 字典和expires 字典的扩展策略和普通字典一样。它们的收缩策略是:当

节点的填充百分比不足10% 时,将可用节点数量减少至大于等于当前已用节点数量。

持久化

在Redis 运行时,RDB 程序将当前内存中的数据库快照保存到磁盘文件中,在Redis 重启动

时,RDB 程序可以通过载入RDB 文件来还原数据库的状态

RDB 功能最核心的是rdbSave 和rdbLoad 两个函数,前者用于生成RDB 文件到磁盘,而后

者则用于将RDB 文件中的数据重新载入到内存中:

rdbSave 函数负责将内存中的数据库数据以RDB 格式保存到磁盘中,如果RDB 文件已存在,

那么新的RDB 文件将替换已有的RDB 文件。

在保存RDB 文件期间,主进程会被阻塞,直到保存完成为止。

SAVE 和BGSAVE 两个命令都会调用rdbSave 函数,但它们调用的方式各有不同:

• SAVE 直接调用rdbSave ,阻塞Redis 主进程,直到保存完成为止。在主进程阻塞期间,

服务器不能处理客户端的任何请求。

• BGSAVE 则fork 出一个子进程,子进程负责调用rdbSave ,并在保存完成之后向主

进程发送信号,通知保存已完成。因为rdbSave 在子进程被调用,所以Redis 服务器在

BGSAVE 执行期间仍然可以继续处理客户端的请求

SAVE 、BGSAVE 、AOF 写入和BGREWRITEAOF

除了了解RDB 文件的保存方式之外,我们可能还想知道,两个RDB 保存命令能否同时使用?

它们和AOF 保存工作是否冲突?

SAVE

前面提到过,当SAVE 执行时,Redis 服务器是阻塞的,所以当SAVE 正在执行时,新的

SAVE 、BGSAVE 或BGREWRITEAOF 调用都不会产生任何作用。

只有在上一个SAVE 执行完毕、Redis 重新开始接受请求之后,新的SAVE 、BGSAVE 或

BGREWRITEAOF 命令才会被处理。

另外,因为AOF 写入由后台线程完成,而BGREWRITEAOF 则由子进程完成,所以在SAVE

执行的过程中,AOF 写入和BGREWRITEAOF 可以同时进行。

BGSAVE

在执行SAVE 命令之前,服务器会检查BGSAVE 是否正在执行当中,如果是的话,服务器就

不调用rdbSave ,而是向客户端返回一个出错信息,告知在BGSAVE 执行期间,不能执行

SAVE 。

这样做可以避免SAVE 和BGSAVE 调用的两个rdbSave 交叉执行,造成竞争条件。

另一方面,当BGSAVE 正在执行时,调用新BGSAVE 命令的客户端会收到一个出错信息,告

知BGSAVE 已经在执行当中。

BGREWRITEAOF 和BGSAVE 不能同时执行:

• 如果BGSAVE 正在执行,那么BGREWRITEAOF 的重写请求会被延迟到BGSAVE 执

行完毕之后进行,执行BGREWRITEAOF 命令的客户端会收到请求被延迟的回复。

• 如果BGREWRITEAOF 正在执行,那么调用BGSAVE 的客户端将收到出错信息,表示

这两个命令不能同时执行。

BGREWRITEAOF 和BGSAVE 两个命令在操作方面并没有什么冲突的地方,不能同时执行

它们只是一个性能方面的考虑:并发出两个子进程,并且两个子进程都同时进行大量的磁盘写

入操作,这怎么想都不会是一个好主意。

载入

当Redis 服务器启动时,rdbLoad 函数就会被执行,它读取RDB 文件,并将文件中的数据库

数据载入到内存中。

在载入期间,服务器每载入1000 个键就处理一次所有已到达的请求,不过只有PUBLISH 、

SUBSCRIBE 、PSUBSCRIBE 、UNSUBSCRIBE 、PUNSUBSCRIBE 五个命令的请求会被正确地处理,

其他命令一律返回错误。等到载入完成之后,服务器才会开始正常处理所有命令。

Note: 发布与订阅功能和其他数据库功能是完全隔离的,前者不写入也不读取数据库,所以

在服务器载入期间,订阅与发布功能仍然可以正常使用,而不必担心对载入数据的完整性产生

影响。

另外,因为AOF 文件的保存频率通常要高于RDB 文件保存的频率,所以一般来说,AOF 文

件中的数据会比RDB 文件中的数据要新。

因此,如果服务器在启动时,打开了AOF 功能,那么程序优先使用AOF 文件来还原数据。只

有在AOF 功能未打开的情况下,Redis 才会使用RDB 文件来还原数据

前面介绍了保存和读取RDB 文件的两个函数,现在,是时候介绍RDB 文件本身了。

一个RDB 文件可以分为以下几个部分:

REDIS

文件的最开头保存着REDIS 五个字符,标识着一个RDB 文件的开始。

在读入文件的时候,程序可以通过检查一个文件的前五个字节,来快速地判断该文件是否有可

能是RDB 文件。

RDB-VERSION

一个四字节长的以字符表示的整数,记录了该文件所使用的RDB 版本号。

目前的RDB 文件版本为0006 。

因为不同版本的RDB 文件互不兼容,所以在读入程序时,需要根据版本来选择不同的读入方

式。

DB-DATA

这个部分在一个RDB 文件中会出现任意多次,每个DB-DATA 部分保存着服务器上一个非空数

据库的所有数据。

• rdbSave 会将数据库数据保存到RDB 文件,并在保存完成之前阻塞调用者。

• SAVE 命令直接调用rdbSave ,阻塞Redis 主进程;BGSAVE 用子进程调用rdbSave ,

主进程仍可继续处理命令请求。

• SAVE 执行期间,AOF 写入可以在后台线程进行,BGREWRITEAOF 可以在子进程进

行,所以这三种操作可以同时进行。

• 为了避免产生竞争条件,BGSAVE 执行时,SAVE 命令不能执行。

• 为了避免性能问题,BGSAVE 和BGREWRITEAOF 不能同时执行。

• 调用rdbLoad 函数载入RDB 文件时,不能进行任何和数据库相关的操作,不过订阅与

发布方面的命令可以正常执行,因为它们和数据库不相关联。

• RDB 文件的组织方式如下:

• 键值对在RDB 文件中的组织方式如下:

RDB 文件使用不同的格式来保存不同类型的值。

Redis 分别提供了RDB 和AOF 两种持久化机制:

• RDB 将数据库的快照(snapshot)以二进制的方式保存到磁盘中。

• AOF 则以协议文本的方式,将所有对数据库进行过写入的命令(及其参数)记录到AOF

文件,以此达到记录数据库状态的目的。

Redis 将所有对数据库进行过写入的命令(及其参数)记录到AOF 文件,以此达到记录数据库

状态的目的,为了方便起见,我们称呼这种记录过程为同步

为了处理的方便,AOF 文件使用网络通讯协议的格式来保存这些命令

缓存追加

当命令被传播到AOF 程序之后,程序会根据命令以及命令的参数,将命令从字符串对象转换

回原来的协议文本。

比如说,如果AOF 程序接受到的三个参数分别保存着SET 、KEY 和VALUE 三个字符串,那么

它将生成协议文本"*3\r\n3rnSETrn3\\r\\nSET\\r\\n3\r\nKEY\r\n$5\r\nVALUE\r\n" 。

协议文本生成之后,它会被追加到redis.h/redisServer 结构的aof_buf 末尾。

redisServer 结构维持着Redis 服务器的状态,aof_buf 域则保存着所有等待写入到AOF 文

件的协议文本:

struct redisServer {

// 其他域...

sds aof_buf;

// 其他域...

};

至此,追加命令到缓存的步骤执行完毕

文件写入和保存

每当服务器常规任务函数被执行、或者事件处理器被执行时,aof.c/flushAppendOnlyFile 函

数都会被调用,这个函数执行以下两个工作:

WRITE:根据条件,将aof_buf 中的缓存写入到AOF 文件。

SAVE:根据条件,调用fsync 或fdatasync 函数,将AOF 文件保存到磁盘中。

两个步骤都需要根据一定的条件来执行,而这些条件由AOF 所使用的保存模式来决定,以下

小节就来介绍AOF 所使用的三种保存模式,以及在这些模式下,步骤WRITE 和SAVE 的调

用条件。

AOF 保存模式

Redis 目前支持三种AOF 保存模式,它们分别是:

1. AOF_FSYNC_NO :不保存。

2. AOF_FSYNC_EVERYSEC :每一秒钟保存一次。

3. AOF_FSYNC_ALWAYS :每执行一个命令保存一次。

以下三个小节将分别讨论这三种保存模式。

不保存

在这种模式下,每次调用flushAppendOnlyFile 函数,WRITE 都会被执行,但SAVE 会被

略过。

在这种模式下,SAVE 只会在以下任意一种情况中被执行:

• Redis 被关闭

• AOF 功能被关闭

• 系统的写缓存被刷新(可能是缓存已经被写满,或者定期保存操作被执行)

这三种情况下的SAVE 操作都会引起Redis 主进程阻塞。

每一秒钟保存一次

在这种模式中,SAVE 原则上每隔一秒钟就会执行一次,因为SAVE 操作是由后台子线程调用

的,所以它不会引起服务器主进程阻塞。

注意,在上一句的说明里面使用了词语“原则上” ,在实际运行中,程序在这种模式下对fsync

或fdatasync 的调用并不是每秒一次,它和调用flushAppendOnlyFile 函数时Redis 所处的

状态有关。

每当flushAppendOnlyFile 函数被调用时,可能会出现以下四种情况:

• 子线程正在执行SAVE ,并且:

1. 这个SAVE 的执行时间未超过2 秒,那么程序直接返回,并不执行WRITE 或新的

SAVE 。

2. 这个SAVE 已经执行超过2 秒,那么程序执行WRITE ,但不执行新的SAVE 。

注意,因为这时WRITE 的写入必须等待子线程先完成(旧的)SAVE ,因此这里

WRITE 会比平时阻塞更长时间。

• 子线程没有在执行SAVE ,并且:

3. 上次成功执行SAVE 距今不超过1 秒,那么程序执行WRITE ,但不执行SAVE 。

4. 上次成功执行SAVE 距今已经超过1 秒,那么程序执行WRITE 和SAVE

根据以上说明可以知道,在“每一秒钟保存一次”模式下,如果在情况1 中发生故障停机,那么

用户最多损失小于2 秒内所产生的所有数据。

如果在情况2 中发生故障停机,那么用户损失的数据是可以超过2 秒的。

Redis 官网上所说的,AOF 在“每一秒钟保存一次”时发生故障,只丢失1 秒钟数据的说法,实

际上并不准确。

每执行一个命令保存一次

在这种模式下,每次执行完一个命令之后,WRITE 和SAVE 都会被执行。

另外,因为SAVE 是由Redis 主进程执行的,所以在SAVE 执行期间,主进程会被阻塞,不能

接受命令请求。

AOF 保存模式对性能和安全性的影响

在上一个小节,我们简短地描述了三种AOF 保存模式的工作方式,现在,是时候研究一下这

三个模式在安全性和性能方面的区别了。

对于三种AOF 保存模式,它们对服务器主进程的阻塞情况如下:

1. 不保存(AOF_FSYNC_NO):写入和保存都由主进程执行,两个操作都会阻塞主进程。

2. 每一秒钟保存一次(AOF_FSYNC_EVERYSEC):写入操作由主进程执行,阻塞主进程。保存

操作由子线程执行,不直接阻塞主进程,但保存操作完成的快慢会影响写入操作的阻塞

时长。

3. 每执行一个命令保存一次(AOF_FSYNC_ALWAYS):和模式1 一样。

因为阻塞操作会让Redis 主进程无法持续处理请求,所以一般说来,阻塞操作执行得越少、完

成得越快,Redis 的性能就越好。

模式1 的保存操作只会在AOF 关闭或Redis 关闭时执行,或者由操作系统触发,在一般情况

下,这种模式只需要为写入阻塞,因此它的写入性能要比后面两种模式要高,当然,这种性能

的提高是以降低安全性为代价的:在这种模式下,如果运行的中途发生停机,那么丢失数据的

数量由操作系统的缓存冲洗策略决定。

模式2 在性能方面要优于模式3 ,并且在通常情况下,这种模式最多丢失不多于2 秒的数据,

所以它的安全性要高于模式1 ,这是一种兼顾性能和安全性的保存方案。

模式3 的安全性是最高的,但性能也是最差的,因为服务器必须阻塞直到命令信息被写入并保

存到磁盘之后,才能继续处理请求。

综合起来,三种AOF 模式的操作特性可以总结如下:

AOF 文件保存了Redis 的数据库状态,而文件里面包含的都是符合Redis 通讯协议格式的命

令文本。

这也就是说,只要根据AOF 文件里的协议,重新执行一遍里面指示的所有命令,就可以还原

Redis 的数据库状态了。

Redis 需要对AOF 文件进行重写(rewrite):创建一个新的AOF 文件

来代替原有的AOF 文件,新AOF 文件和原有AOF 文件保存的数据库状态完全一样,但新

AOF 文件的体积小于等于原有AOF 文件的体积

所谓的“重写”其实是一个有歧义的词语,实际上,AOF 重写并不需要对原有的AOF 文件进行

任何写入和读取,它针对的是数据库中键的当前值。

根据键的类型,使用适当的写入命令来重现键的当前值,这就是AOF 重写的实现原理。

AOF 后台重写

上一节展示的AOF 重写程序可以很好地完成创建一个新AOF 文件的任务,但是,在执行这

个程序的时候,调用者线程会被阻塞。

很明显,作为一种辅佐性的维护手段,Redis 不希望AOF 重写造成服务器无法处理请求,所以

Redis 决定将AOF 重写程序放到(后台)子进程里执行,这样处理的最大好处是:

1. 子进程进行AOF 重写期间,主进程可以继续处理命令请求。

2. 子进程带有主进程的数据副本,使用子进程而不是线程,可以在避免锁的情况下,保证数

据的安全性。

不过,使用子进程也有一个问题需要解决:因为子进程在进行AOF 重写期间,主进程还需要

继续处理命令,而新的命令可能对现有的数据进行修改,这会让当前数据库的数据和重写后的

AOF 文件中的数据不一致。

为了解决这个问题,Redis 增加了一个AOF 重写缓存,这个缓存在fork 出子进程之后开始启

用,Redis 主进程在接到新的写命令之后,除了会将这个写命令的协议内容追加到现有的AOF

文件之外,还会追加到这个缓存中

AOF 后台重写的触发条件

AOF 重写可以由用户通过调用BGREWRITEAOF 手动触发。

另外,服务器在AOF 功能开启的情况下,会维持以下三个变量:

• 记录当前AOF 文件大小的变量aof_current_size 。

• 记录最后一次AOF 重写之后,AOF 文件大小的变量aof_rewirte_base_size 。

• 增长百分比变量aof_rewirte_perc 。

每次当serverCron 函数执行时,它都会检查以下条件是否全部满足,如果是的话,就会触发

自动的AOF 重写:

1. 没有BGSAVE 命令在进行。

2. 没有BGREWRITEAOF 在进行。

3. 当前AOF 文件大小大于server.aof_rewrite_min_size (默认值为1 MB)。

4. 当前AOF 文件大小和最后一次AOF 重写后的大小之间的比率大于等于指定的增长百分

比。

默认情况下,增长百分比为100% ,也即是说,如果前面三个条件都已经满足,并且当前AOF

文件大小比最后一次AOF 重写时的大小要大一倍的话,那么触发自动AOF 重写。

• AOF 文件通过保存所有修改数据库的命令来记录数据库的状态。

• AOF 文件中的所有命令都以Redis 通讯协议的格式保存。

• 不同的AOF 保存模式对数据的安全性、以及Redis 的性能有很大的影响。

• AOF 重写的目的是用更小的体积来保存数据库状态,整个重写过程基本上不影响Redis

主进程处理命令请求。

• AOF 重写是一个有歧义的名字,实际的重写工作是针对数据库的当前值来进行的,程序

既不读写、也不使用原有的AOF 文件。

• AOF 可以由用户手动触发,也可以由服务器自动触发。

事件是Redis 服务器的核心,它处理两项重要的任务:

1. 处理文件事件:在多个客户端中实现多路复用,接受它们发来的命令请求,并将命令的执

行结果返回给客户端。

2. 时间事件:实现服务器常规操作(server cron job)。

Redis 将这类因为对套接字进行多路复用而产生的事件称为文件事件(file event),文件事件可

以分为读事件和写事件两类

读事件

读事件标志着客户端命令请求的发送状态。

当一个新的客户端连接到服务器时,服务器会给为该客户端绑定读事件,直到客户端断开连接

之后,这个读事件才会被移除。

写事件

写事件标志着客户端对命令结果的接收状态。

和客户端自始至终都关联着读事件不同,服务器只会在有命令结果要传回给客户端时,才会为

客户端关联写事件,并且在命令结果传送完毕之后,客户端和写事件的关联就会被移除

因为在同一次文件事件处理器的调用中,单个客户端只能执行其中一种事件(要么读,要么写,

但不能又读又写),当出现读事件和写事件同时就绪的情况时,事件处理器优先处理读事件。

这也就是说,当服务器有命令结果要返回客户端,而客户端又有新命令请求进入时,服务器先

处理新命令请求。

时间事件记录着那些要在指定时间点运行的事件,多个时间事件以无序链表的形式保存在服务

器状态中。

每个时间事件主要由三个属性组成:

• when :以毫秒格式的UNIX 时间戳为单位,记录了应该在什么时间点执行事件处理函数。

• timeProc :事件处理函数。

• next 指向下一个时间事件,形成链表。

根据timeProc 函数的返回值,可以将时间事件划分为两类:

• 如果事件处理函数返回ae.h/AE_NOMORE ,那么这个事件为单次执行事件:该事件会在指

定的时间被处理一次,之后该事件就会被删除,不再执行。

• 如果事件处理函数返回一个非AE_NOMORE 的整数值,那么这个事件为循环执行事件:该

事件会在指定的时间被处理,之后它会按照事件处理函数的返回值,更新事件的when 属

性,让这个事件在之后的某个时间点再次运行,并以这种方式一直更新并运行下去。

可以用伪代码来表示这两种事件的处理方式:

def handle_time_event(server, time_event):

# 执行事件处理器,并获取返回值

# 返回值可以是AE_NOMORE ,或者一个表示毫秒数的非符整数值

retval = time_event.timeProc()

if retval == AE_NOMORE:

# 如果返回AE_NOMORE ,那么将事件从链表中删除,不再执行

server.time_event_linked_list.delete(time_event)

else:

# 否则,更新事件的when 属性

# 让它在当前时间之后的retval 毫秒之后再次运行

time_event.when = unix_ts_in_ms() + retval

当时间事件处理器被执行时,它遍历所有链表中的时间事件,检查它们的到达事件(when 属

性),并执行其中的已到达事件:

def process_time_event(server):

# 遍历时间事件链表

for time_event in server.time_event_linked_list:

# 检查事件是否已经到达

if time_event.when >= unix_ts_in_ms():

# 处理已到达事件

handle_time_event(server, time_event)

Note: 无序链表并不影响时间事件处理器的性能

在目前的版本中,正常模式下的Redis 只带有serverCron 一个时间事件,而在benchmark 模

式下,Redis 也只使用两个时间事件。

在这种情况下,程序几乎是将无序链表退化成一个指针来使用,所以使用无序链表来保存时间

事件,并不影响事件处理器的性能。

时间事件应用实例:服务器常规操作

对于持续运行的服务器来说,服务器需要定期对自身的资源和状态进行必要的检查和整理,从

而让服务器维持在一个健康稳定的状态,这类操作被统称为常规操作(cron job)。

在Redis 中,常规操作由redis.c/serverCron 实现,它主要执行以下操作:

• 更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等。

• 清理数据库中的过期键值对。

• 对不合理的数据库进行大小调整。

• 关闭和清理连接失效的客户端。

• 尝试进行AOF 或RDB 持久化操作。

• 如果服务器是主节点的话,对附属节点进行定期同步。

• 如果处于集群模式的话,对集群进行定期同步和连接测试。

Redis 将serverCron 作为时间事件来运行,从而确保它每隔一段时间就会自动运行一次,又

因为serverCron 需要在Redis 服务器运行期间一直定期运行,所以它是一个循环时间事件:

serverCron 会一直定期执行,直到服务器关闭为止。

在Redis 2.6 版本中,程序规定serverCron 每隔10 毫秒就会被运行一次。从Redis 2.8 开始,

10 毫秒是serverCron 运行的默认间隔,而具体的间隔可以由用户自己调整

实际处理时间事件的时间,通常会比时间事件所预定的时间要晚,至于延迟的

时间有多长,取决于时间事件执行之前,执行文件事件所消耗的时间

文件事件先于时间事件处理,根据情况,如果处理文件事件耗费了非常多的时间,serverCron 被推迟到一两秒之后才能执行,也是有可能的。

• Redis 的事件分为时间事件和文件事件两类。

• 文件事件分为读事件和写事件两类:读事件实现了命令请求的接收,写事件实现了命令

结果的返回。

• 时间事件分为单次执行事件和循环执行事件,服务器常规操作serverCron 就是循环事

件。

• 文件事件和时间事件之间是合作关系:一种事件会等待另一种事件完成之后再执行,不

会出现抢占情况。

• 时间事件的实际执行时间通常会比预定时间晚一些。

从启动Redis 服务器,到服务器可以接受外来客户端的网络连接这段时间,Redis 需要执行一

系列初始化操作。

整个初始化过程可以分为以下六个步骤:

1. 初始化服务器全局状态。

2. 载入配置文件。

3. 创建daemon 进程。

4. 初始化服务器功能模块。

5. 载入数据。

6. 开始事件循环。

1. 初始化服务器全局状态

redis.h/redisServer 结构记录了和服务器相关的所有数据,这个结构主要包含以下信息:

• 服务器中的所有数据库。

• 命令表:在执行命令时,根据字符来查找相应命令的实现函数。

• 事件状态。

• 服务器的网络连接信息:套接字地址、端口,以及套接字描述符。

• 所有已连接客户端的信息。

• Lua 脚本的运行环境及相关选项。

• 实现订阅与发布(pub/sub)功能所需的数据结构。

• 日志(log)和慢查询日志(slowlog)的选项和相关信息。

• 数据持久化(AOF 和RDB)的配置和状态。

• 服务器配置选项:比如要创建多少个数据库,是否将服务器进程作为daemon 进程来运

行,最大连接多少个客户端,压缩结构(zip structure)的实体数量,等等。

• 统计信息:比如键有多少次命令、不命中,服务器的运行时间,内存占用,等等。

在这一步,程序创建一个redisServer 结构的实例变量server 用作服务器的全局状态,并将

server 的各个属性初始化为默认值。

2. 载入配置文件

在初始化服务器的上一步中,程序为server 变量(也即是服务器状态)的各个属性设置了默

认值,但这些默认值有时候并不是最合适的:

• 用户可能想使用AOF 持久化,而不是默认的RDB 持久化。

• 用户可能想用其他端口来运行Redis ,以避免端口冲突。

• 用户可能不想使用默认的16 个数据库,而是分配更多或更少数量的数据库。

• 用户可能想对默认的内存限制措施和回收策略做调整。

等等。

为了让使用者按自己的要求配置服务器,Redis 允许用户在运行服务器时,提供相应的配置文

件(config file)或者显式的选项(option),Redis 在初始化完server 变量之后,会读入配置

文件和选项,然后根据这些配置来对server 变量的属性值做相应的修改:

1. 如果单纯执行redis-server 命令,那么服务器以默认的配置来运行Redis 。

2. 另一方面,如果给Redis 服务器送入一个配置文件,那么Redis 将按配置文件的设置来

更新服务器的状态。

比如说,通过命令redis-server /etc/my-redis.conf ,Redis 会根据my-redis.conf

文件的内容来对服务器状态做相应的修改。

3. 除此之外,还可以显式地给服务器传入选项,直接修改服务器配置。

举个例子,通过命令redis-server --port 10086 ,可以让Redis 服务器端口变更为

10086 。

4. 当然,同时使用配置文件和显式选项也是可以的,如果文件和选项有冲突的地方,那么优

先使用选项所指定的配置值。

举个例子,如果运行命令redis-server /etc/my-redis.conf --port 10086 ,并且

my-redis.conf 也指定了port 选项,那么服务器将优先使用--port 10086 (实际上是

选项指定的值覆盖了配置文件中的值)

3. 创建daemon 进程

Redis 默认以daemon 进程的方式运行。

当服务器初始化进行到这一步时,程序将创建daemon 进程来运行Redis ,并创建相应的pid

文件。

4. 初始化服务器功能模块

在这一步,初始化程序完成两件事:

• 为server 变量的数据结构子属性分配内存。

• 初始化这些数据结构。

为数据结构分配内存,并初始化这些数据结构,等同于对相应的功能进行初始化。

比如说,当为订阅与发布所需的链表分配内存之后,订阅与发布功能就处于就绪状态,随时可

以为Redis 所用了。

在这一步,程序完成的主要动作如下:

• 初始化Redis 进程的信号功能。

• 初始化日志功能。

• 初始化客户端功能。

• 初始化共享对象。

• 初始化事件功能。

• 初始化数据库。

• 初始化网络连接。

• 初始化订阅与发布功能。

• 初始化各个统计变量。

• 关联服务器常规操作(cron job)到时间事件,关联客户端应答处理器到文件事件。

• 如果AOF 功能已打开,那么打开或创建AOF 文件。

• 设置内存限制。

• 初始化Lua 脚本环境。

• 初始化慢查询功能。

• 初始化后台操作线程

虽然所有功能已经就绪,但这时服务器的数据库还是一片空白,程序还需要将服务器上一次执

行时记录的数据载入到当前服务器中,服务器的初始化才算真正完成。

5.载入数据

在这一步,程序需要将持久化在RDB 或者AOF 文件里的数据,载入到服务器进程里面。

如果服务器有启用AOF 功能的话,那么使用AOF 文件来还原数据;否则,程序使用RDB 文

件来还原数据。

当执行完这一步时,服务器打印出一段载入完成信息:

[6717] 22 Feb 11:59:14.830 * DB loaded from disk: 0.068 seconds

6. 开始事件循环

到了这一步,服务器的初始化已经完成,程序打开事件循环,开始接受客户端连接。

以下是服务器在这一步打印的信息:

[6717] 22 Feb 11:59:14.830 * The server is now ready to accept connections on port 6379

以下是初始化完成之后,服务器状态和各个模块之间的关系图:

Redis 以多路复用的方式来处理多个客户端,为了让多个客户端之间独立分开、不互相干扰,

服务器为每个已连接客户端维持一个redisClient 结构,从而单独保存该客户端的状态信息。

redisClient 结构主要包含以下信息:

• 套接字描述符。

• 客户端正在使用的数据库指针和数据库号码。

• 客户端的查询缓存(query buffer)和回复缓存(reply buffer)。

• 一个指向命令函数的指针,以及字符串形式的命令、命令参数和命令个数,这些属性会在

命令执行时使用。

• 客户端状态:记录了客户端是否处于SLAVE 、MONITOR 或者事务状态。

• 实现事务功能(比如MULTI 和WATCH)所需的数据结构。

• 实现阻塞功能(比如BLPOP 和BRPOPLPUSH)所需的数据结构。

• 实现订阅与发布功能(比如PUBLISH 和SUBSCRIBE)所需的数据结构。

• 统计数据和选项:客户端创建的时间,客户端和服务器最后交互的时间,缓存的大小,等

等。

命令的请求、处理和结果返回

当客户端连上服务器之后,客户端就可以向服务器发送命令请求了。

从客户端发送命令请求,到命令被服务器处理、并将结果返回客户端,整个过程有以下步骤:

1. 客户端通过套接字向服务器传送命令协议数据。

2. 服务器通过读事件来处理传入数据,并将数据保存在客户端对应redisClient 结构的查

询缓存中。

3. 根据客户端查询缓存中的内容,程序从命令表中查找相应命令的实现函数。

4. 程序执行命令的实现函数,修改服务器的全局状态server 变量,并将命令的执行结果保

存到客户端redisClient 结构的回复缓存中,然后为该客户端的fd 关联写事件。

5. 当客户端fd 的写事件就绪时,将回复缓存中的命令结果传回给客户端。至此,命令执行

完毕。

命令请求实例:SET 的执行过程

为了更直观地理解命令执行的整个过程,我们用一个实际执行SET 命令的例子来讲解命令执

行的过程。

假设现在客户端C1 是连接到服务器S 的一个客户端,当用户执行命令SET YEAR 2013 时,客

户端调用写入函数,将协议内容*3\r\n3rnSETrn3\\r\\nSET\\r\\n4\r\nYEAR\r\n$4\r\n2013\r\n" 写

入连接到服务器的套接字中。

当S 的文件事件处理器执行时,它会察觉到C1 所对应的读事件已经就绪,于是它将协议文本

读入,并保存在查询缓存。

通过对查询缓存进行分析(parse),服务器在命令表中查找SET 字符串所对应的命令实现函数,

最终定位到t_string.c/setCommand 函数,另外,两个命令参数YEAR 和2013 也会以字符串

的形式保存在客户端结构中。

接着,程序将客户端、要执行的命令、命令参数等送入命令执行器:执行器调用setCommand

函数,将数据库中YEAR 键的值修改为2013 ,然后将命令的执行结果保存在客户端的回复缓存

中,并为客户端fd 关联写事件,用于将结果回写给客户端。

因为YEAR 键的修改,其他和数据库命名空间相关程序,比如AOF 、REPLICATION 还有事

务安全性检查(是否修改了被WATCH 监视的键?)也会被触发,当这些后续程序也执行完毕之

后,命令执行器退出,服务器其他程序(比如时间事件处理器)继续运行。

当C1 对应的写事件就绪时,程序就会将保存在客户端结构回复缓存中的数据回写给客户端,

当客户端接收到数据之后,它就将结果打印出来,显示给用户看。

以上就是SET YEAR 2013 命令执行的整个过程

• 服务器经过初始化之后,才能开始接受命令。

• 服务器初始化可以分为六个步骤:

1. 初始化服务器全局状态。

2. 载入配置文件。

3. 创建daemon 进程。

4. 初始化服务器功能模块。

5. 载入数据。

6. 开始事件循环。

• 服务器为每个已连接的客户端维持一个客户端结构,这个结构保存了这个客户端的所有

状态信息。

• 客户端向服务器发送命令,服务器接受命令然后将命令传给命令执行器,执行器执行给

定命令的实现函数,执行完成之后,将结果保存在缓存,最后回传给客户端。

redis分布式锁

set key value [EX seconds] [PX milliseconds] [NX|XX]

1.设置nx防止死锁

2.为了防止A意外释放B的锁,val的值可以设置成该机器的唯一标识,例如时间+请求号。在解锁时需要校验是不是解锁的请求来自于同一个服务器,如果不是说明这是别人的锁不能解

面试题:

zhuanlan.zhihu.com/p/32540678

1.哨兵 集群含义

哨兵的作用就是监控Redis系统的运行状况。它的功能包括以下两个。

(1)监控主数据库和从数据库是否正常运行。
(2)主数据库出现故障时自动将从数据库转换为主数据库

主从模式指的是使用一个redis实例作为主机,其余的实例作为备份机。主机和从机的数据完全一致,主机支持数据的写入和读取等各项操作,而从机则只支持与主机数据的同步和读取,也就是说,客户端可以将数据写入到主机,由主机自动将数据的写入操作同步到从机。主从模式很好的解决了数据备份问题,并且由于主从服务数据几乎是一致的,因而可以将写入数据的命令发送给主机执行,而读取数据的命令发送给不同的从机执行,从而达到读写分离的目的

 redis主从模式解决了数据备份和单例可能存在的性能问题,但是其也引入了新的问题。由于主从模式配置了三个redis实例,并且每个实例都使用不同的ip(如果在不同的机器上)和端口号,根据前面所述,主从模式下可以将读写操作分配给不同的实例进行从而达到提高系统吞吐量的目的,但也正是因为这种方式造成了使用上的不便,因为每个客户端连接redis实例的时候都是指定了ip和端口号的,如果所连接的redis实例因为故障下线了,而主从模式也没有提供一定的手段通知客户端另外可连接的客户端地址,因而需要手动更改客户端配置重新连接。另外,主从模式下,如果主节点由于故障下线了,那么从节点因为没有主节点而同步中断,因而需要人工进行故障转移工作。

为了解决这两个问题,在2.8版本之后redis正式提供了sentinel(哨兵)架构

对于一组主从节点,sentinel只是在其外部额外添加的一组用于监控作用的redis实例。在主从节点和sentinel节点集合配置好之后,sentinel节点之间会相互发送消息,以检测其余sentinel节点是否正常工作,并且sentinel节点也会向主从节点发送消息,以检测监控的主从节点是否正常工作。sentinel架构的主要作用是解决主从模式下主节点的故障转移工作的,选取新的主节点

redis集群是在redis 3.0版本推出的一个功能,其有效的解决了redis在分布式方面的需求。当遇到单机内存,并发和流量瓶颈等问题时,可采用Cluster方案达到负载均衡的目的。并且从另一方面讲,redis中sentinel有效的解决了故障转移的问题,也解决了主节点下线客户端无法识别新的可用节点的问题,但是如果是从节点下线了,sentinel是不会对其进行故障转移的,并且连接从节点的客户端也无法获取到新的可用从节点,而这些问题在Cluster中都得到了有效的解决。

集群就是将数据均匀分配给不同redis实例,防止实例掉线,在对这些实例设置从节点,从而达到高可用高并发

my.oschina.net/zhangxufeng…

-----

Redis的热key问题

dongzl.github.io/2021/01/14/…

dongzl.github.io/2023/03/24/…  (大key问题)