文章目录
- 第一部分:内部数据结构
- 简单动态字符串(simple dynamic string)
- 双端链表
- 字典
- 跳跃表
- 第二部分:内存映射数据结构
- 整数集合intset
- 压缩列表
- redis数据类型
- 对象处理机制(redisObject)
- 字符串string
- 哈希表hash
- 列表list
- 集合set
- 有续集zset
- 第四部分:功能的实现
- 事务
- 订阅与发布
- Lua脚本
- 慢查询日志
- 第五部分:内部运作机制
- 数据库
- dict存储结构
- 过期时间
- 删除策略
- 过期键对 AOF 、RDB 和主从复制的影响
- RDB
- AOF
第一部分:内部数据结构
简单动态字符串(simple dynamic string)
在 Redis 中, 一个字符串对象除了可以保存字符串值之外, 还可以保存 long 类型的值
Sds 在 Redis 中的主要作用有以下两个:
- 实现字符串对象(StringObject);
- 在 Redis 程序内部用作 char* 类型的替代品;
在 C 语言中,字符串可以用一个 \0 结尾的 char 数组来表示。它并不能高效地支持长度计算和追加(append)这两种操作:
- 每次计算字符串长度(strlen(s))的复杂度为 θ(N) 。
- 对字符串进行 N 次追加,必定需要对字符串进行 N 次内存重分配(realloc)。
考虑到这两个原因, Redis 使用 sds 类型替换了 C 语言的默认字符串表示
在前面的内容中, 我们一直将 sds 作为一种抽象数据结构来说明, 实际上, 它的实现由以下两部分组成:
其中,类型 sds 是 char * 的别名(alias),而结构 sdshdr 则保存了 len 、 free 和 buf 三个属性。
typedef char *sds;struct sdshdr {// buf 已占用长度int len;// buf 剩余可用长度int free;// 实际保存字符串数据的地方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
伪代码解释:如果剩余空间足够,则不进行内存重新分配。如果不够则会计算当前字符串的长度=原字符串长度+新增的字符串长度。
如果计算的当前字符串的长度没有超过一个固定值SDS_MAX_PREALLOC,就分配给他两倍自身长度的空间,如果超过了就分配一个固定值长度的空间。
这个固定值SDS_MAX_PREALLOC大小为1MB,是能够分配空间的最大值。
应用举例:
redis> SET msg "hello world"
OKredis> APPEND msg " again"
(integer) 17redis> GET msg
"hello world again"
当SET这个msg时,会保存到一个sdshdr中,记录长度、剩余空间、数据,值得注意的是,SET命令不会分配剩余空间,只有对字符串进行追加才时才会分配剩余空间。
struct sdshdr {len = 11;free = 0;buf = "hello world\0";
}
当执行 APPEND 命令时,相应的 sdshdr 被更新,字符串 " again" 会被追加到原来的 “hello world” 之后:它将原本长度为11的字符串加上更新的字符串长度作为新字符串的长度,并且赋给此长度的剩余空间。如果再次执行APPEND,添加的字符串长度小于剩余空间时,不会再进行内存的分配。
struct sdshdr {len = 17;free = 17;buf = "hello world again\0";// 空白的地方为预分配空间,共 17 + 17 + 1 个字节
}
这种分配策略会浪费内存吗?
执行过 APPEND 命令的字符串会带有额外的预分配空间, 这些预分配空间不会被释放, 除非该字符串所对应的键被删除, 或者等到关闭 Redis 之后, 再次启动时重新载入的字符串对象将不会有预分配空间。因为执行 APPEND 命令的字符串键数量通常并不多, 占用内存的体积通常也不大, 所以这一般并不算什么问题。另一方面, 如果执行 APPEND 操作的键很多, 而字符串的体积又很大的话, 那可能就需要修改 Redis 服务器, 让它定时释放一些字符串键的预分配空间, 从而更有效地使用内存。
双端链表
Redis 列表使用两种数据结构作为底层实现:
- 双端链表
- 压缩列表
因为双端链表占用的内存比压缩列表要多, 所以当创建新的列表键时, 列表会优先考虑使用压缩列表作为底层实现, 并且在有需要的时候, 才从压缩列表实现转换到双端链表实现。
双端链表的实现由 listNode 和 list 两个数据结构构成.
typedef struct listNode {// 前驱节点struct listNode *prev;// 后继节点struct listNode *next;// 值void *value;} listNode;
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 带有 prev 和 next 两个指针,因此,遍历可以双向进行:从表头到表尾,表尾到表头。
- list 保存了 head 和 tail 两个指针,因此,对链表的表头和表尾进行插入的复杂度都为 θ(1) —— 这是高效实现 LPUSH 、 RPOP 、 RPOPLPUSH 等命令的关键。
- list 带有保存节点数量的 len 属性,所以计算链表长度的复杂度仅为 θ(1) ,这也保证了 LLEN 命令不会成为性能瓶颈。
字典
Redis 选择了高效、实现简单的哈希表,作为字典的底层实现。
字典的主要用途有以下两个:
- 实现数据库键空间(key space);
- 用作 Hash 类型键的底层实现之一;
Redis 目前使用两种不同的哈希算法:
- MurmurHash2 32 bit 算法:这种算法的分布率和速度都非常好。
- 基于 djb 算法实现的一个大小写无关散列算法。
命令表以及 Lua 脚本缓存都用到了算法 2 。
算法 1 的应用则更加广泛:数据库、集群、哈希键、阻塞操作等功能都用到了这个算法。
字典是由键值对构成的抽象数据结构。
Redis 中的数据库和哈希键都基于字典来实现。
Redis 字典的底层实现为哈希表,每个字典使用两个哈希表,一般情况下只使用 0 号哈希表,只有在 rehash 进行时,才会同时使用 0 号和 1 号哈希表。
哈希表使用链地址法来解决键冲突的问题。
Rehash 可以用于扩展或收缩哈希表。
对哈希表的 rehash 是分多次、渐进式地进行的。
跳跃表
- 跳跃表是一种随机化数据结构,查找、添加、删除操作都可以在对数期望时间下完成。
- 跳跃表目前在 Redis 的唯一作用,就是作为有序集类型的底层数据结构(之一,另一个构成有序集的结构是字典)。
- 为了满足自身的需求,Redis 基于 William Pugh 论文中描述的跳跃表进行了修改,包括:
- score 值可重复。
- 对比一个元素需要同时检查它的 score 和 memeber 。
- 每个节点带有高度为 1 层的后退指针,用于从表尾方向向表头方向迭代。
第二部分:内存映射数据结构
整数集合intset
- Intset 用于有序、无重复地保存多个整数值,会根据元素的值,自动选择该用什么长度的整数类型来保存元素。
- 当一个位长度更长的整数值添加到 intset 时,需要对 intset 进行升级,新 intset 中每个元素的位长度,会等于新添加值的位长度,但原有元素的值不变。
- 升级会引起整个 intset 进行内存重分配,并移动集合中的所有元素,这个操作的复杂度为 O(N) 。
- Intset 只支持升级,不支持降级。
- Intset 是有序的,程序使用二分查找算法来实现查找操作,复杂度为 O(lgN) 。
压缩列表
- 因为 ziplist 节约内存的性质, 哈希键、列表键和有序集合键初始化的底层实现皆采用 ziplist
redis数据类型
对象处理机制(redisObject)
Redis 的每一种数据类型,比如字符串、列表、有序集, 它们都拥有不只一种底层实现(Redis 内部称之为编码,encoding), 这说明, 每当对某种数据类型的键进行操作时, 程序都必须根据键所采取的编码, 进行不同的操作。操作数据类型的命令除了要对键的类型进行检查之外, 还需要根据数据类型的不同编码进行多态处理。
为了解决以上问题, Redis 构建了自己的类型系统, 这个系统的主要功能包括:
- redisObject 对象。
- 基于 redisObject 对象的类型检查。
- 基于 redisObject 对象的显式多态函数。
- 对 redisObject 进行分配、共享和销毁的机制。
/** Redis 对象*/
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是指五种类型(string,list,hash,set,zset)的一种,encoding是对象所保存的值的编码,ptr是一个指针,指向实际保存值的数据结构,这个数据结构由 type 属性和 encoding 属性决定。
//encoding存储的编码
#define REDIS_ENCODING_RAW 0 // 编码为字符串
#define REDIS_ENCODING_INT 1 // 编码为整数
#define REDIS_ENCODING_HT 2 // 编码为哈希表
#define REDIS_ENCODING_LINKEDLIST 4 // 编码为双端链表
#define REDIS_ENCODING_ZIPLIST 5 // 编码为压缩列表
#define REDIS_ENCODING_INTSET 6 // 编码为整数集合
#define REDIS_ENCODING_SKIPLIST 7 // 编码为跳跃表
举个例子,如果一个 redisObject 的 type 属性为 REDIS_LIST , encoding 属性为 REDIS_ENCODING_LINKEDLIST ,那么这个对象就是一个 Redis 列表,它的值保存在一个双端链表内,而 ptr 指针就指向这个双端链表;
有了 redisObject 结构的存在, 在执行处理数据类型的命令时, 进行类型检查和对编码进行多态操作就简单得多了。
当执行一个处理数据类型的命令时, Redis 执行以下步骤:
- 根据给定 key ,在数据库字典中查找和它相对应的 redisObject ,如果没找到,就返回 NULL 。
- 检查 redisObject 的 type 属性和执行命令所需的类型是否相符,如果不相符,返回类型错误。
- 根据 redisObject 的 encoding 属性所指定的编码,选择合适的操作函数来处理底层的数据结构。
- 返回数据结构的操作结果作为命令的返回值。
Redis 在内部使用了一个 Flyweight 模式 : 通过预分配一些常见的值对象, 并在多个数据结构之间
共享这些对象, 程序避免了重复分配的麻烦, 也节约了一些 CPU 时间。
每个 redisObject 结构都带有一个 refcount 属性,指示这个对象被引用了多少次。当对象的
refcount 降至 0 时,这个 redisObject 结构,以及它所引用的数据结构的内存,都会被释放。
字符串string
字符串类型分别使用 REDIS_ENCODING_INT 和 REDIS_ENCODING_RAW 两种编码:
-
REDIS_ENCODING_INT 使用 long 类型来保存 long 类型值。
-
REDIS_ENCODING_RAW 则使用 sdshdr 结构来保存 sds (也即是 char* )、 long long 、 double 和 long double 类型值。
新创建的字符串默认使用 REDIS_ENCODING_RAW 编码, 在将字符串作为键或者值保存进数据库时, 程序会尝试将字符串转为 REDIS_ENCODING_INT 编码。
在 Redis 中, 只有能表示为 long 类型的值, 才会以整数的形式保存, 其他类型的整数、小数和字符串, 都是用 sdshdr 结构来保存。
哈希表hash
REDIS_HASH (哈希表)是 HSET 、 HLEN 等命令的操作对象, 它使用 REDIS_ENCODING_ZIPLIST(压缩列表) 和 REDIS_ENCODING_HT(字典) 两种编码方式。
新添加的 key-value 对会被添加到压缩列表的表尾。
当进行查找/删除或更新操作时,程序先定位到键的位置,然后再通过对键的位置来定位值的位置。
编码的选择
创建空白哈希表时, 程序默认使用 REDIS_ENCODING_ZIPLIST 编码, 当以下任何一个条件被满足时, 程序将编码从 REDIS_ENCODING_ZIPLIST 切换为 REDIS_ENCODING_HT :
- 哈希表中某个键或某个值的长度大于 server.hash_max_ziplist_value (默认值为 64 )。
- 压缩列表中的节点数量大于 server.hash_max_ziplist_entries (默认值为 512 )。
列表list
REDIS_LIST (列表)是 LPUSH 、 LRANGE 等命令的操作对象, 它使用 REDIS_ENCODING_ZIPLIST(压缩列表) 和 REDIS_ENCODING_LINKEDLIST(双端链表) 这两种方式编码。
创建新列表时 Redis 默认使用 REDIS_ENCODING_ZIPLIST 编码, 当以下任意一个条件被满足时, 列表会被转换成 REDIS_ENCODING_LINKEDLIST 编码:
- 试图往列表新添加一个字符串值,且这个字符串的长度超过 server.list_max_ziplist_value (默认值为 64 )。
- ziplist 包含的节点超过 server.list_max_ziplist_entries (默认值为 512 )。
BLPOP 、 BRPOP 和 BRPOPLPUSH 三个命令都可能造成客户端被阻塞, 以下将这些命令统称为列表的阻塞原语。
阻塞原语并不是一定会造成客户端阻塞:
-
只有当这些命令被用于空列表时, 它们才会阻塞客户端。
-
如果被处理的列表不为空的话, 它们就执行无阻塞版本的 LPOP 、 RPOP 或 RPOPLPUSH 命令。
值得一提的是, 当程序添加一个新的被阻塞客户端到 server.blocking_keys 字典的链表中时, 它将该客户端放在链表的最后, 而当 handleClientsBlockedOnLists 取消客户端的阻塞时, 它从链表的最前面开始取消阻塞: 这个链表形成了一个 FIFO 队列, 最先被阻塞的客户端总是最先脱离阻塞状态, Redis 文档称这种模式为先阻塞先服务(FBFS,first-block-first-serve)。
集合set
REDIS_SET (集合)是 SADD 、 SRANDMEMBER 等命令的操作对象, 它使用 REDIS_ENCODING_INTSET(整数集合) 和 REDIS_ENCODING_HT(字典) 两种方式编码。
REDIS_SET (集合)是 SADD 、 SRANDMEMBER 等命令的操作对象, 它使用 REDIS_ENCODING_INTSET 和 REDIS_ENCODING_HT 两种方式编码。
如果一个集合使用 REDIS_ENCODING_INTSET 编码, 那么当以下任何一个条件被满足时, 这个集合会被转换成 REDIS_ENCODING_HT 编码:
-
intset 保存的整数值个数超过 server.set_max_intset_entries (默认值为 512 )。
-
试图往集合里添加一个新元素,并且这个元素不能被表示为 long long 类型(也即是,它不是一个整数)。
求交集和求差集求并集
SINTER 和 SINTERSTORE 两个命令所使用的求并交集算法
SUNION 和 SUNIONSTORE 两个命令所使用的求并集算法
Redis 为 SDIFF 和 SDIFFSTORE 两个命令准备了两种求集合差的算法。
有续集zset
REDIS_ZSET (有序集)是 ZADD 、 ZCOUNT 等命令的操作对象, 它使用 REDIS_ENCODING_ZIPLIST 和 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) 。
当使用 REDIS_ENCODING_SKIPLIST 编码时,zset 同时使用字典和跳跃表两个数据结构来保存有序集元素。
/** 有序集*/
typedef struct zset {// 字典dict *dict;// 跳跃表zskiplist *zsl;} zset;
其中, 元素的成员由一个 redisObject 结构表示, 而元素的 score 则是一个 double 类型的浮点数, 字典和跳跃表两个结构通过将指针共同指向这两个值来节约空间 (不用每个元素都复制两份)。
通过使用字典结构, 并将 member 作为键, score 作为值, 有序集可以在 O(1) 复杂度内:
-
检查给定 member 是否存在于有序集(被很多底层函数使用);
-
取出 member 对应的 score 值(实现 ZSCORE 命令)。
另一方面, 通过使用跳跃表, 可以让有序集支持以下两种操作:
-
在 O(logN) 期望时间、 O(N) 最坏时间内根据 score 对 member 进行定位(被很多底层函数使用);
-
范围性查找和处理操作,这是(高效地)实现 ZRANGE 、 ZRANK 和 ZINTERSTORE 等命令的关键。
第四部分:功能的实现
事务
Redis 通过 MULTI 、 DISCARD 、 EXEC 和 WATCH 四个命令来实现事务功能。
开启事务和执行事务:
redis> MULTI
OKredis> SET book-name "Mastering C++ in 21 days"
QUEUEDredis> GET book-name
QUEUEDredis> SADD tag "C++" "Programming" "Mastering Series"
QUEUEDredis> SMEMBERS tag
QUEUEDredis> EXEC
1) OK
2) "Mastering C++ in 21 days"
3) (integer) 3
4) 1) "Mastering Series"2) "C++"3) "Programming"
监视事务:
redis> WATCH name
OKredis> MULTI
OKredis> SET name peter
QUEUEDredis> EXEC
1) OK
2)(integer) 1
DISCARD 命令用于取消一个事务, 它清空客户端的整个事务队列, 然后将客户端从事务状态调整回非事务状态, 最后返回字符串 OK 给客户端, 说明事务已被取消。
取消事务:
redis> MULTI
OKredis> SET book-name "Mastering C++ in 21 days"
QUEUEDredis> GET book-name
QUEUEDredis> SADD tag "C++" "Programming" "Mastering Series"
QUEUEDredis> DISCARD
OK
事务ACID
在传统的关系式数据库中,常常用 ACID 性质来检验事务功能的安全性。
redis事务不支持原子性和持久性,支持隔离性、一致性。
- redis对单个指令支持原子性,对多个指令不支持。
- 因为redis存储于内存,当数据持久化到磁盘时,必定会有一定几率出现故障(无论使用何种持久化方式),所以并不支持持久性。
- redis是单线程的,所以天生支持事务隔离性。
- 一致性情况比较复杂,可以分为入队错误、执行错误、Redis 进程被终结三个部分。
– 入队错误: 事务中有错误命令,会将事务状态设置为 REDIS_DIRTY_EXEC,表明此事务为脏执行数据,当客户端执行 EXEC 命令时, Redis 会拒绝执行状态为 REDIS_DIRTY_EXEC 的事务, 并返回失败信息。
– 执行错误:命令在事务执行的过程中发生错误,比如对key进行了错误操作,那么此条指令会执行错误,但并不影响之前和之后的指令执行。
– 进程被终结:rdb模式会在事务执行完成后再进行快照存储,所以rdb不会影响一致性。aof模式存储了一半事务,还原数据库时程序会检测到 AOF 文件并不完整,Redis 会退出,并报告错误。需要使用 redis-check-aof 工具将部分成功的事务命令移除之后,才能再次启动服务器。
WATCH 命令的实现
-
在每个代表数据库的 redis.h/redisDb 结构类型中, 都保存了一个 watched_keys 字典, 它的键是redis数据库被监视的键, 值则是一个链表, 链表中保存了所有监视这个键的客户端。
-
通过 watched_keys 字典, 想检查某个键是否被监视, 只要检查是否存在这个键即可; 要获取监视某个键的所有客户端, 只要取出键的值(一个链表), 对它进行遍历。
-
客户端有一个 REDIS_DIRTY_CAS 选项,表示监视key是否被污染。如果某个客户端对监视key进行了修改, 那么所有监视这个key的客户端的REDIS_DIRTY_CAS 选项都会被打开。
-
当客户端执行EXEC时,会检查这个选项,如果打开了,那么说明事务的安全性已经被破坏。服务器会放弃执行这个事务,直接向客户端返回空回复,表示事务执行失败。
-
当一个客户端结束它的事务时,无论事务是成功执行,还是失败, watched_keys 字典中和这个客户端相关的资料都会被清除。
通过WATCH 命令的实现可以知道,WATCH命令是一个时效命令,在启动事务之前使用,事务执行完后被清除,用来保证事务执行的数据安全性。
typedef struct redisDb {// 保存着数据库以整数表示的号码int id;// 保存着数据库中的所有键值对数据// 这个属性也被称为键空间(key space)dict *dict;// 保存着键的过期信息dict *expires;// 实现列表阻塞原语,如 BLPOP// 在列表类型一章有详细的讨论dict *blocking_keys;dict *ready_keys;// 用于实现 WATCH 命令// 在事务章节有详细的讨论dict *watched_keys;} redisDb;
订阅与发布
实现了的频道channel发布publish,client客户端订阅subscribe。
Lua脚本
初始化 Lua 脚本环境需要一系列步骤,其中最重要的包括:
- 创建 Lua 环境。
- 载入 Lua 库,比如字符串库、数学库、表格库,等等。
- 创建 redis 全局表格,包含各种对 Redis 进行操作的函数,比如 redis.call 和 redis.log ,等等。
- 创建一个无网络连接的伪客户端,专门用于执行 Lua 脚本中的 Redis 命令。
Reids 通过一系列措施保证被执行的 Lua 脚本无副作用,也没有有害的写随机性:对于同样的输入参数和数据集,总是产生相同的写入命令。
EVAL 命令为输入脚本定义一个 Lua 函数,然后通过执行这个函数来执行脚本。
EVALSHA 通过构建函数名,直接调用 Lua 中已定义的函数,从而执行相应的脚本。
慢查询日志
- slowlog-log-slower-than 选项指定执行时间超过多少微秒(1 秒等于 1,000,000 微秒)的命令请求会被记录到日志上。
- slowlog-max-len 选项指定服务器最多保存多少条慢查询日志。
设置慢日志查询时间和存储条数
redis> CONFIG SET slowlog-log-slower-than 0
OKredis> CONFIG SET slowlog-max-len 5
OK
查看日志
redis> SLOWLOG GET
1) 1) (integer) 4 # 日志的唯一标识符(uid)2) (integer) 1378781447 # 命令执行时的 UNIX 时间戳3) (integer) 13 # 命令执行的时长,以微秒计算4) 1) "SET" # 命令以及命令参数2) "database"3) "Redis"
第五部分:内部运作机制
数据库
dict存储结构
typedef struct redisDb {// 保存着数据库以整数表示的号码int id;// 保存着数据库中的所有键值对数据// 这个属性也被称为键空间(key space)dict *dict;// 保存着键的过期信息dict *expires;// 实现列表阻塞原语,如 BLPOP// 在列表类型一章有详细的讨论dict *blocking_keys;dict *ready_keys;// 用于实现 WATCH 命令// 在事务章节有详细的讨论dict *watched_keys;} redisDb;
因为 Redis 是一个键值对数据库(key-value pairs database), 所以它的数据库本身也是一个字典(俗称 key space):
- 字典的键是一个字符串对象。
- 字典的值则可以是包括字符串、列表、哈希表、集合或有序集在内的任意一种 Redis 类型对象。
在 redisDb 结构的 dict 属性中,保存着数据库的所有键值对数据。
由图可以知道,键空间中键都是StringObject类型,而值为五种基本类型,并且键和值是分开保存的。
过期时间
- expires 字典的键是一个指向 dict 字典(键空间)里某个键的指针, 而字典的值则是键所指向的数据库键的到期时间, 这个值以 long long 类型表示。
- expires 的键和dict的键是同一个字符串对象,不会浪费任何空间。
- 通过 EXPIRE 、EXPIREAT、PEXPIRE 和 PEXPIREAT 四个命令, 客户端可以给某个存在的键设置过期时间, 当键的过期时间到达时, 键就不再可用。
- 虽然有那么多种不同单位和不同形式的设置方式, 但是 expires 字典的值只保存“以毫秒为单位的过期 UNIX 时间戳”, 这就是说, 通过进行转换, 所有命令的效果最后都和 PEXPIREAT 命令的效果一样。
命令 TTL 和 PTTL 则用于返回给定键距离过期还有多长时间。
redis> SETEX key 10086 value
OKredis> TTL key
(integer) 10082redis> PTTL key
(integer) 10068998
通过 expires 字典, 可以用以下步骤检查某个键是否过期:
- 检查键是否存在于 expires 字典:如果存在,那么取出键的过期时间;
- 检查当前 UNIX 时间戳是否大于键的过期时间:如果是的话,那么键已经过期;否则,键未过期。
删除策略
定时删除、惰性删除、定期删除(这里删除的都是具有expires属性的键)。
- 定时删除是键过期立刻删除。
- 惰性删除是使用到一个键时对其进行判断,如果过期了就删除并返回空。
- 定期删除每隔一段时间执行一次删除操作,并通过限制删除操作执行的时长和频率,籍此来减少删除操作对 CPU 时间的影响。
redis使用的是惰性删除和定期删除共用的策略。
惰性删除
- 在进行数据读写时,惰性删除会执行一个expireIfNeeded()对输入键进行检查, 并将过期键删除。
定期删除(1、执行的哪个方法2、执行时间3、检测数量4、检测的方式)
- redis初始化时会redis.conf中读取一个server.hz值,默认为10,redis提供了一个方法serverCron(),它每秒执行的次数就由server.hz来确定。serverCron()方法会调用一个databaseCron()方法,databaseCron()会接着调用activeExpireCycle()方法,而这个activeExpireCycle()就是定期删除策略的核心。
- activeExpireCycle()会对每一个数据库的expires进行检测,每次检测的时间是(四分之一/server.hz)秒,比如默认为10次,每次执行四十分之一秒,由于每秒serverCron()执行10次,activeExpireCycle()也就一共执行四分之一秒。也就是说,定期删除策略会占用cpu四分之一的时间来进行过期键的删除。
- activeExpireCycle()对某个数据库的expires进行检测时,会随机挑选key检测并删除过期key,挑选的数量是ACTIVE_EXPIRE_CYCLE_LOOKUPS_PRE_LOOP,如果检测的key过期的数量达到了检测的1/4,就会再次检测这个数据库的expires。
- activeExpireCycle()还有一个current_db参数,用于记录检测的哪个数据库,每次检测值加一来切换数据库。
过期键对 AOF 、RDB 和主从复制的影响
- 过期键不会写入RDB文件,没有影响
- 对AOF文件会追加一条del指令
- 对AOF重写机制,过期键也不会保存到AOF文件中,没有影响
- 对于主从复制,主服务器发现过期数据会删除并向从服务器显示发送del指令,从服务器也删除。从服务器发现过期数据会向主服务器返回键已过期的回复,并不主动删除过期键。以此保证主从服务器数据一致。
RDB
在 Redis 运行时, RDB 程序将当前内存中的数据库快照保存到磁盘文件中, 在 Redis 重启动时, RDB 程序可以通过载入 RDB 文件来还原数据库的状态。
RDB 功能最核心的是 rdbSave 和 rdbLoad 两个函数, 前者用于生成 RDB 文件到磁盘, 而后者则用于将 RDB 文件中的数据重新载入到内存中。
SAVE 和 BGSAVE 两个命令都会调用 rdbSave 函数,但它们调用的方式各有不同:
- SAVE 直接调用 rdbSave ,阻塞 Redis 主进程,直到保存完成为止。在主进程阻塞期间,服务器不能处理客户端的任何请求。
- BGSAVE 则 fork 出一个子进程,子进程负责调用 rdbSave ,并在保存完成之后向主进程发送信号,通知保存已完成。因为 rdbSave 在子进程被调用,所以 Redis 服务器在 BGSAVE 执行期间仍然可以继续处理客户端的请求。
BGREWRITEAOF 和 BGSAVE 不能同时执行:
- 如果 BGSAVE 正在执行,那么 BGREWRITEAOF 的重写请求会被延迟到 BGSAVE 执行完毕之后进行,执行 BGREWRITEAOF 命令的客户端会收到请求被延迟的回复。
- 如果 BGREWRITEAOF 正在执行,那么调用 BGSAVE 的客户端将收到出错信息,表示这两个命令不能同时执行。
BGREWRITEAOF 和 BGSAVE 两个命令在操作方面并没有什么冲突的地方, 不能同时执行它们只是一个性能方面的考虑: 并发出两个子进程, 并且两个子进程都同时进行大量的磁盘写入操作, 这怎么想都不会是一个好主意。
也可以使用自动保存。在redis.conf文件中设置save second changes,表名在second秒内有changes条变化指令就进行bgsave操作,一般设置为:
dbfilename “dump-6379.rdb”
save 900 1
save 300 10
save 60 10000
AOF
aof是将每次的写命令记录到单独的日志文件中,恢复时执行日志文件的命令。如果开启了两种持久化,会优先选择aof恢复。
数据更安全,可以通过配置异步追加为always每执行一次写操作就追加一条,也可以配置成每一秒追加一次所有写操作。
在redis.conf文件中设置:
appendonly yes
appendfilename “appendonly-6379.aof”
appendfsync everysec
aof还有rewrite重写模式,当日志文件过大时可以对命令进行重写。并且在重写之前如果有一些误操作,可以删除一些命令来恢复。重写机制是放在子进程中的,这样可以使服务器进程(父进程)可以继续处理其他命令。同时他也产生了一个新问题,数据不一致。
通过append模式写操作,可以用redis-check-aof来解决数据不一致的问题。
子进程进行 AOF 重写期间,主进程可以继续处理命令请求。
子进程带有主进程的数据副本,使用子进程而不是线程,可以在避免锁的情况下,保证数据的安全性。
不过, 使用子进程也有一个问题需要解决: 因为子进程在进行 AOF 重写期间, 主进程还需要继续处理命令, 而新的命令可能对现有的数据进行修改, 这会让当前数据库的数据和重写后的 AOF 文件中的数据不一致。
为了解决这个问题, Redis 增加了一个 AOF 重写缓存, 这个缓存在 fork 出子进程之后开始启用, Redis 主进程在接到新的写命令之后, 除了会将这个写命令的协议内容追加到现有的 AOF 文件之外, 还会追加到这个缓存中。
操作指令:
- 手动重写:bgrewriteaof
- 自动重写:auto-aof-rewrite-min-size size(2MB)
– auto-aof-rewrite-percentage percentage