Redis设计与实现

2016/02/15 缓存

关系型数据库和非关系型数据库

优缺点

  • 关系型数据库:数据和数据之间,表和字段之间、表和表之间是存在关系的
    • 优点
      • 进行增删改查是非常方便的
      • 有事务操作,保证数据完整性
    • 缺点
      • 关系由大量算法保证,拉低运行速度,消耗系统资源
      • 海量数据增删改查会显得无能为力
      • 海量数据对数据表维护和扩展也变得无能为力
  • 非关系型数据库(NoSQL):
    • 优点
      • 海量数据增删改查轻松应对
      • 海量数据维护非常轻松
    • 缺点
      • 数据不能一目了然(没有表和字段关系)
      • 没有强大的事务保证数据的完整和安全

NoSQl分类

  • 键值对存储数据库:
    • 相关产品:Tokyo Cabinet/Tyrant、Redis、Voldemort、Berkeley DB
    • 典型应用:内容缓存,主要用于处理大量数据的高访问负载
    • 数据模型:一系列键值对
    • 优势:优秀的快速查询,稳定性强
    • 劣势:存储的数据缺少结构化
  • 列存储数据库:
    • 相关产品:Apache Cassandra、Apache HBase、Riak
    • 典型应用:分布式的文件系统
    • 数据模型:以列簇式村塾,将同一列数据存在一起
    • 优势:查找速度快,可扩展性强,更容易进行分布式扩展
    • 劣势:功能相对局限,使用极大的内存才可调配,且系统处理算法时将有数秒甚至更长的时间不可以哦那个,导致大量处理超时
  • 文档型数据库(淘汰)
    • 相关产品:CouchDB、MongoDB
    • 典型应用:Web应用(与key-value类似),value是结构化的
    • 数据模型:一系列键值对
    • 优势:数据结构要求不严格
    • 劣势:查询性能不高,切缺乏统一的查询语法
  • 图形数据库
    • 相关产品:Neo4J、InfoGrid、Infinite Graph
    • 典型应用:社交网络
    • 数据模型:图结构
    • 优势:利用图结构相关算法
    • 劣势:需要对整个图结算才能得出结果,不容易做分布式集群方案,局限性强

Redis

Redis是一个开源,先进的key-value存储,并用于构建高性能,可扩展的Web应用程序的完美解决方案。

Redis从它的许多竞争继承来的三个主要特点:

  • Redis数据库完全在内存中,使用磁盘仅用于持久性。
  • 相比许多键值数据存储,Redis拥有一套较为丰富的数据类型。
  • Redis可以将数据复制到任意数量的从服务器。

Redis 优势

  • 异常快速:Redis的速度非常快,每秒能执行约11万集合,每秒约81000+条记录。
    • 所有数据放在内存中
    • 单线程架构,预防了多线程可能产生的竞争问题
  • 支持丰富的数据类型:Redis支持最大多数开发人员已经知道像列表,集合,有序集合,散列数据类型。这使得它非常容易解决各种各样的问题,因为知道哪些问题是可以处理通过它的数据类型更好。
  • 操作都是原子性:所有Redis操作是原子的,这保证了如果两个客户端同时访问的Redis服务器将获得更新后的值。
  • 多功能实用工具:Redis是一个多实用的工具,可以在多个用例如缓存,消息,队列使用(Redis原生支持发布/订阅),任何短暂的数据,应用程序,如Web应用程序会话,网页命中计数等。

常用命令

启动

$redis-server

自启动

存为redis,放到/etc/init.d/下面
上面的注释的意思是,redis服务必须在运行级2,3,4,5下被启动或关闭,启动的优先级是90,关闭的优先级是10。

# chkconfig:   2345 90 10
# description:  Redis is a persistent key-value database

PATH=/usr/local/bin:/sbin:/usr/bin:/bin  

REDISPORT=6379  
EXEC=/usr/local/bin/redis-server  
REDIS_CLI=/usr/local/bin/redis-cli  

PIDFILE=/var/run/redis_6379.pid  
CONF="/etc/redis/redis.conf"  

case "$1" in  
    start)  
        if [ -f $PIDFILE ]  
        then  
                echo "$PIDFILE exists, process is already running or crashed"  
        else  
                echo "Starting Redis server..."  
                $EXEC $CONF  
        fi  
        if [ "$?"="0" ]   
        then  
              echo "Redis is running..."  
        fi  
        ;;  
    stop)  
        if [ ! -f $PIDFILE ]  
        then  
                echo "$PIDFILE does not exist, process is not running"  
        else  
                PID=$(cat $PIDFILE)  
                echo "Stopping ..."  
                $REDIS_CLI -p $REDISPORT SHUTDOWN  
                while [ -x ${PIDFILE} ]  
               do  
                    echo "Waiting for Redis to shutdown ..."  
                    sleep 1  
                done  
                echo "Redis stopped"  
        fi  
        ;;  
   restart|force-reload)  
        ${0} stop  
        ${0} start  
        ;;  
  *)  
    echo "Usage: /etc/init.d/redis {start|stop|restart|force-reload}" >&2  
        exit 1  
esac
chmod +x /etc/init.d/redis

service redis start   #或者 /etc/init.d/redis start  
service redis stop   #或者 /etc/init.d/redis stop  
# 自动启动
chkconfig --add redis
chkconfig redis on

检查Redis是否在工作

$redis-cli
redis 127.0.0.1:6379>

现在输入PING命令检查连接

redis 127.0.0.1:6379> ping
PONG
# 停止
shutdown
shutdown save
shutdown nosave

在远程服务器上执行命令

$redis-cli -h 127.0.0.1 -p 6379 -a "mypass"
redis 127.0.0.1:6379>
redis 127.0.0.1:6379> PING

PONG

直接获取结果

redis-cli -h {host} -p {port} {command}
redis-cli -h 127.0.0.1 -p 6379 get key
"value"

键操作

命令 作用 示例
DEL key 此命令删除键,如果存在。返回受影响行数  
DUMP key 该命令返回存储在指定键的值的序列化版本。  
EXISTS key 此命令检查该键是否存在。  
EXPIRE key seconds 指定键的过期时间 键不存在返回0,设置负数值则立即删除,执行SET命令会重置过期时间为未设置
EXPIREAT key timestamp 指定的键过期时间。在这里,时间是在Unix时间戳格式  
PEXPIRE key milliseconds 设置键以毫秒为单位到期  
PEXPIREAT key milliseconds-timestamp 设置键在Unix时间戳指定为毫秒到期  
KEYS pattern 查找与指定模式匹配的所有键 redis 127.0.0.1:6379> KEYS tutorial*
1) “tutorial3”
2) “tutorial1”
3) “tutorial2”
MOVE key db 移动键到另一个数据库  
PERSIST key 移除过期的键  
PTTL key 以毫秒为单位获取剩余时间的到期键。  
TTL key 获取键到期的剩余时间。 大于0显示剩余过期时间,-1则是没有设置过期时间,-2则是键不存在
RANDOMKEY 从Redis返回随机键 redis 127.0.0.1:6379> SET tutorial1 redis
OK
redis 127.0.0.1:6379> SET tutorial2 mysql
OK
redis 127.0.0.1:6379> SET tutorial3 mongodb
OK
redis 127.0.0.1:6379> RANDOMKEY
1) tutorial3
RENAME key newkey 更改键的名称 如果过存在会覆盖,执行期间会del旧键,可能产生阻塞。
RENAMENX key newkey 重命名键,如果新的键不存在  
TYPE key 返回存储在键的数据类型的值。  
## 查询key总数
eval "return #redis.call('keys', 'prefix-*')" 0
## 批量删除key
eval "redis.call('del', unpack(redis.call('keys','null*')))" 0
SCAN 0 match "*1*" count 10000000

数据类型

Redis支持5种类型的数据类型,对应多种不同的内部编码

所有的key都是字符串,key不要太长,影响使用效率

简单动态字符串

  • 常数复杂度获取字符串长度。
  • 杜绝缓冲区溢出。
  • 减少修改字符串长度时所需的内存重分配次数。
  • 二进制安全。
  • 兼容部分C字符串函数。

Redis字符串是字节序列。Redis字符串是二进制安全的,这意味着他们有一个已知的长度没有任何特殊字符终止,所以你可以存储任何东西,512兆为上限。

Redis使用自己构建的一种名为简单动态字符串的抽象类型(simple dynamic string,SDS),作为字符串的默认类型。

redis> SET msg "hello world"
OK
  • 键值对的键是一个字符串对象,对象的底层实现是一个保存着字符串“msg”的SDS。
  • 键值对的值也是一个字符串对象,对象的底层实现是一个保存着字符串“hello world”的SDS。
redis> RPUSH fruits "apple" "banana" "cherry"
(integer) 3
  • 键值对的键是一个字符串对象,对象的底层实现是一个保存了字符串“fruits”的SDS。
  • 键值对的值是一个列表对象,列表对象包含了三个字符串对象,这三个字符串对象分别由三个SDS实现:第一个SDS保存着字符串“apple”,第二个SDS保存着字符串“banana”,第三个SDS保存着字符串“cherry”。

SDS除了用来保存数据库中的字符串之外,SDS还被用作缓冲区:AOF模块中的AOF缓冲区以及客户端状态中的输入缓冲区。

SDS的定义

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

SDS示例:

SDS示例

带有未使用空间的SDS:

带有未使用空间的SDS

SDS与C字符串的区别

  • 常数复杂度获取字符串长度
    • C字符串不记录自身长度,通过复杂度为O(N)的遍历来获取长度,而SDS只需要访问len属性,将复杂度降低到O(1),保证了获取字符串长度不会成为Redis的性能瓶颈
  • 杜绝缓冲区溢出
    • SDS的api在拼接字符串时会首先检查空间是否满足需求,如果不满足会先分配空间,杜绝了如strcat在空间分配不足时产生缓冲区溢出。
  • 减少修改字符串时带来的内存重分配次数
    • C字符串底层是一个N+1字符长度的数组,额外一个字符空间用于存储空字符,所以每次增长或缩短一个C字符串程序总是要对数据进行重新分配。
      • 如果程序执行的是增长字符串的操作,比如拼接操作(append),那么在执行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小——如果忘了这一步就会产生缓冲区溢出。
      • 如果程序执行的是缩短字符串的操作,比如截断操作(trim),那么在执行这个操作之后,程序需要通过内存重分配来释放字符串不再使用的那部分空间——如果忘了这一步就会产生内存泄漏。
    • SDS通过未使用空间(free),实现了空间预分配和惰性空间释放两种优化策略
      • 空间预分配,通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数。
        • 空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。
        • 额外分配的未使用空间数量由以下公式决定:
          • 如果对SDS进行修改之后,SDS的长度(也即是len属性的值)将小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS len属性的值将和free属性的值相同。举个例子,如果进行修改之后,SDS的len将变成13字节,那么程序也会分配13字节的未使用空间,SDS的buf数组的实际长度将变成13+13+1=27字节(额外的一字节用于保存空字符)。
          • 如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。举个例子,如果进行修改之后,SDS的len将变成30MB,那么程序会分配1MB的未使用空间,SDS的buf数组的实际长度将为30MB+1MB+1byte。
        • 在扩展SDS空间之前,SDS API会先检查未使用空间是否足够,如果足够的话,API就会直接使用未使用空间,而无须执行内存重分配。
        • 通过这种预分配策略,SDS将连续增长N次字符串所需的内存重分配次数从必定N次降低为最多N次。
      • 惰性空间释放,通过惰性空间释放策略,SDS避免了缩短字符串时所需的内存重分配操作,并为将来可能有的增长操作提供了优化。
        • 惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。
        • 与此同时,SDS也提供了相应的API,让可以在有需要时,真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。
  • 二进制安全
    • C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
      • 在关系型数据库中,频繁的编码解码浪费大量是系统性能,同时可能出现乱码
      • redis的编码和解码只会出现在客户端,执行效率很高,并不会出现乱码
        • key中字符串使用unicode编码:你–“\xe4\xbd\xa0”
    • SDS的API都是二进制安全的(binary-safe),所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样。
      • 这也是将SDS的buf属性称为字节数组的原因——Redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据。
    • 通过使用二进制安全的SDS,而不是C字符串,使得Redis不仅可以保存文本数据,还可以保存任意格式的二进制数据。
  • 兼容部分C字符串函数
    • SDS API总会将SDS保存的数据的末尾设置为空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据的SDS可以重用一部分<string.h>库定义的函数。
C字符串 SDS
获取字符串长度的复杂度为 O(N) 。 获取字符串长度的复杂度为 O(1) 。
API 是不安全的,可能会造成缓冲区溢出。 API 是安全的,不会造成缓冲区溢出。
修改字符串长度 N 次必然需要执行 N 次内存重分配。 修改字符串长度 N 次最多需要执行 N 次内存重分配。
只能保存文本数据。 可以保存文本或者二进制数据。
可以使用所有 <string.h> 库中的函数。 可以使用一部分 <string.h> 库中的函数。

字典

  • 字典被广泛用于实现Redis的各种功能,其中包括数据库和哈希键。
  • Redis中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,一个平时使用,另一个仅在进行rehash时使用。
  • 当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。
  • 哈希表使用链地址法来解决键冲突,被分配到同一个索引上的多个键值对会连接成一个单向链表。
  • 在对哈希表进行扩展或者收缩操作时,程序需要将现有哈希表包含的所有键值对rehash到新哈希表里面,并且这个rehash过程并不是一次性地完成的,而是渐进式地完成的。

字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。

除了用来表示数据库之外,字典还是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。

Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。

哈希表

Redis字典所使用的哈希表由dict.h/dictht结构定义:

typedef struct dictht {
    // 哈希表数组
    dictEntry **table;
    // 哈希表大小
    unsigned long size;
    // 哈希表大小掩码,用于计算索引值
    // 总是等于size-1
    unsigned long sizemask;
    // 该哈希表已有节点的数量
    unsigned long used;
} dictht;

table属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对。

大小为4的空哈希表(没有包含任何键值对):

An-empty-hash-table

哈希表节点

哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对:

typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union{
        void *val;
        uint64_tu64;
        int64_ts64;
    } v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

key属性保存着键值对中的键,而v属性则保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个uint64_t整数,又或者是一个int64_t整数。
next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一次,以此来解决键冲突(collision)的问题。

通过next指针,将两个索引值相同的键k1和k0连接在一起:

连接在一起的键K1和键K0

字典

Redis中的字典由dict.h/dict结构表示:

typedef struct dict {
    // 类型特定函数
    dictType *type;
    // 私有数据
    void *privdata;
    // 哈希表
    dictht ht[2];
    // rehash索引
    // 当rehash不在进行时,值为-1
    in trehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;

type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的:

  • type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
  • 而privdata属性则保存了需要传给那些类型特定函数的可选参数。
typedef struct dictType {
    // 计算哈希值的函数
    unsigned int (*hashFunction)(const void *key);
    // 复制键的函数
    void *(*keyDup)(void *privdata, const void *key);
    // 复制值的函数
    void *(*valDup)(void *privdata, const void *obj);
    // 对比键的函数
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    // 销毁键的函数
    void (*keyDestructor)(void *privdata, void *key);
    // 销毁值的函数
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。
除了ht[1]之外,另一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1。

普通状态下(没有进行rehash)的字典:

Dictionary-in-normal-state

哈希算法

当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。

Redis计算哈希值和索引值的方法如下:

// 使用字典设置的哈希函数,计算键key的哈希值
hash = dict->type->hashFunction(key);
// 使用哈希表的sizemask属性和哈希值,计算出索引值
// 根据情况不同,ht[x]可以是ht[0]或者ht[1]
index = hash & dict->ht[x].sizemask;

假设计算得出的哈希值为8,那么程序会继续使用语句:

index = hash&dict->ht[0].sizemask = 8 & 3 = 0;

空字典:

空字典

添加键值对K0和v0之后的字典:

添加键值对K0和v0之后的字典

当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。
MurmurHash算法最初由Austin Appleby于2008年发明,这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。

解决键冲突

当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,称这些键发生了冲突(collision)。

Redis的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。

因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有节点的前面。

一个包含两个键值对的哈希表:

A hash table that contains two key-value pairs

使用链表解决k2和k1的冲突:

Using linked lists to resolve conflicts between K2 and K1

rehash

随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。

扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:

  1. 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值)
    • 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2^n(2的n次方幂);
    • 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2^n。
  2. 将保存在ht[0]中的所有键值对rehash到ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。
  3. 当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。

对图字典的ht[0]进行扩展操作,那么程序将执行以下步骤:

执行rehash之前的字典

1)ht[0].used当前的值为4,4*2=8,而8(2^3)恰好是第一个大于等于4的2的n次方,所以程序会将ht[1]哈希表的大小设置为8。ht[1]在分配空间之后,字典的样子:

为字典的ht[1]哈希表分配空间

2)将ht[0]包含的四个键值对都rehash到ht[1]

ht[0]的所有键值对都已经被迁移到ht[1]

3)释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]分配一个空白哈希表,至此,对哈希表的扩展操作执行完毕,程序成功将哈希表的大小从原来的4改为了现在的8。

完成rehash之后的字典

哈希表的扩展与收缩

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

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

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

其中哈希表的负载因子可以通过公式:

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

因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率, 所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。

渐进式rehash

扩展或收缩哈希表需要将ht[0]里面的所有键值对rehash到ht[1]里面,但是,这个rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。

要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务。 因此,为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部rehash到ht[1],而是分多次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]。

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

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

渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量。

准备开始rehash:

准备开始rehash

rehash索引0上的键值对:

rehash索引0上的键值对

rehash索引1上的键值对:

rehash索引1上的键值对

rehash索引2上的键值对:

rehash索引2上的键值对

rehash索引3上的键值对:

rehash索引3上的键值对

rehash执行完毕:

rehash执行完毕

渐进式reahsh执行期间的哈希表操作

因为在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行。

例如,要在字典里面查找一个键的话,程序会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找,诸如此类。

另外,在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。

链表

链表和链表节点的实现

  • 链表被广泛用于实现Redis的各种功能,比如列表键、发布与订阅、慢查询、监视器等。
  • 每个链表节点由一个listNode结构来表示,每个节点都有一个指向前置节点和后置节点的指针,所以Redis的链表实现是双端链表。
  • 每个链表使用一个list结构来表示,这个结构带有表头节点指针、表尾节点指针,以及链表长度等信息。
  • 因为链表表头节点的前置节点和表尾节点的后置节点都指向NULL,所以Redis的链表实现是无环链表。
  • 通过为链表设置不同的类型特定函数,Redis的链表可以用于保存各种不同类型的值。

链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。

除了链表键之外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还使用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区(output buffer)

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

多个listNode可以通过prev和next指针组成双端链表:

由多个listNode组成的双端链表

使用adlist.h/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;

由list结构和listNode结构组成的链表:

由list结构和listNode结构组成的链表

Redis的链表实现的特性可以总结如下:

  • 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。
  • 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
  • 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
  • 带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
  • 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。

Redis的列表是简单的字符串列表,排序插入顺序。可以添加元素到Redis的列表的头部或尾部。

Redis列表是双向链表,适合做大数据集合的增删和任务队列

压缩列表(ziplist)

  • 压缩列表是一种为节约内存而开发的顺序型数据结构。
  • 压缩列表被用作列表键和哈希键的底层实现之一。
  • 压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值。
  • 添加新节点到压缩列表,或者从压缩列表中删除节点,可能会引发连锁更新操作,但这种操作出现的几率并不高。

压缩列表(ziplist)是列表键和哈希键的底层实现之一。

当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。

redis> RPUSH lst 1 3 5 10086 "hello" "world"
(integer)6
redis> OBJECT ENCODING lst
"ziplist"

当一个哈希键只包含少量键值对,比且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现。

redis> HMSET profile "name" "Jack" "age" 28 "job" "Programmer"
OK
redis> OBJECT ENCODING profile
"ziplist"

压缩列表的构成

压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。

压缩列表的各个组成部分:

压缩列表的各个组成部分

压缩列表各个组成部分的详细说明:

属性 类型 长度 用途
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 ),用于标记压缩列表的末端。

包含三个节点的压缩列表:

包含三个节点的压缩列表

  • 列表zlbytes属性的值为0x50(十进制80),表示压缩列表的总长为80字节。
  • 列表zltail属性的值为0x3c(十进制60),这表示如果有一个指向压缩列表起始地址的指针p,那么只要用指针p加上偏移量60,就可以计算出表尾节点entry3的地址。
  • 列表zllen属性的值为0x3(十进制3),表示压缩列表包含三个节点。

压缩列表节点的构成

每个压缩列表节点可以保存一个字节数组或者一个整数值,其中,字节数组可以是以下三种长度的其中一种:

  • 长度小于等于63(2 6–1)字节的字节数组;
  • 长度小于等于16383(2 14–1)字节的字节数组;
  • 长度小于等于4294967295(2 32–1)字节的字节数组;

而整数值则可以是以下六种长度的其中一种:

  • 4位长,介于0至12之间的无符号整数;
  • 1字节长的有符号整数;
  • 3字节长的有符号整数;
  • int16_t类型整数;
  • int32_t类型整数;
  • int64_t类型整数。

每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成:

压缩列表的各个组成部分

previous_entry_length

节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节:

  • 如果前一节点的长度小于254字节,那么previous_entry_length属性的长度为1字节:前一节点的长度就保存在这一个字节里面。
  • 如果前一节点的长度大于等于254字节,那么previous_entry_length属性的长度为5字节:其中属性的第一字节会被设置为0xFE(十进制值254),而之后的四个字节则用于保存前一节点的长度。

当前节点的前一节点的长度为5字节:

压缩列表的各个组成部分

包含一字节长previous_entry_length属性的压缩列表节点,属性的值为0x05,表示前一节点的长度为5字节。

当前节点的前一节点的长度为10086字节:

当前节点的前一节点的长度为10086字节

包含五字节长previous_entry_length属性的压缩节点,属性的值为0xFE00002766,其中值的最高位字节0xFE表示这是一个五字节长的previous_entry_length属性,而之后的四字节0x00002766(十进制值10086)才是前一节点的实际长度。

因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。
压缩列表的从表尾向表头遍历操作就是使用这一原理实现的,只要拥有了一个指向某个节点起始地址的指针,那么通过这个指针以及这个节点的previous_entry_length属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点。

从表尾节点向表头节点进行遍历的完整过程:

  • 首先,拥有指向压缩列表表尾节点entry4起始地址的指针p1(指向表尾节点的指针可以通过指向压缩列表起始地址的指针加上zltail属性的值得出);
  • 通过用p1减去entry4节点previous_entry_length属性的值,得到一个指向entry4前一节点entry3起始地址的指针p2;
  • 通过用p2减去entry3节点previous_entry_length属性的值,得到一个指向entry3前一节点entry2起始地址的指针p3;
  • 通过用p3减去entry2节点previous_entry_length属性的值,得到一个指向entry2前一节点entry1起始地址的指针p4,entry1为压缩列表的表头节点;
  • 最终,从表尾节点向表头节点遍历了整个列表。

从表尾向表头遍历的例子

encoding

节点的encoding属性记录了节点的content属性所保存数据的类型以及长度:

  • 一字节、两字节或者五字节长,值的最高位为00、01或者10的是字节数组编码:这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录;
  • 一字节长,值的最高位以11开头的是整数编码:这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录;

字节数组编码:

编码 编码长度 content 属性保存的值
00bbbbbb 1 字节 长度小于等于 63 字节的字节数组。
01bbbbbb xxxxxxxx 2 字节 长度小于等于 16383 字节的字节数组。
10__ aaaaaaaa bbbbbbbb cccccccc dddddddd 5 字节 长度小于等于 4294967295 的字节数组。

整数编码:

编码 编码长度 content 属性保存的值
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 属性。
content

节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。

一个保存字节数组的节点示例:

一个保存字节数组的节点示例

  • 编码的最高两位00表示节点保存的是一个字节数组;
  • 编码的后六位001011记录了字节数组的长度11;
  • content属性保存着节点的值”hello world”。

连锁更新

考虑这样一种情况:在一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的节点e1至eN, 因为e1至eN的所有节点的长度都小于254字节,所以记录这些节点的长度只需要1字节长的previous_entry_length属性

包含节点el至eN的压缩列表

这时,如果将一个长度大于等于254字节的新节点new设置为压缩列表的表头节点,那么new将成为e1的前置节点

添加新节点到压缩列表

因为e1的previous_entry_length属性仅长1字节,它没办法保存新节点new的长度,所以程序将对压缩列表执行空间重分配操作,并将e1节点的previous_entry_length属性从原来的1字节长扩展为5字节长。
现在,麻烦的事情来了,e1原本的长度介于250字节至253字节之间,在为previous_entry_length属性新增四个字节的空间之后,e1的长度就变成了介于254字节至257字节之间,而这种长度使用1字节长的previous_entry_length属性是没办法保存的。
因此,为了让e2的previous_entry_length属性可以记录下e1的长度,程序需要再次对压缩列表执行空间重分配操作,并将e2节点的previous_entry_length属性从原来的1字节长扩展为5字节长。
正如扩展e1引发了对e2的扩展一样,扩展e2也会引发对e3的扩展,而扩展e3又会引发对e4的扩展……为了让每个节点的previous_entry_length属性都符合压缩列表对节点的要求,程序需要不断地对压缩列表执行空间重分配操作,直到eN为止。
Redis将这种在特殊情况下产生的连续多次空间扩展操作称之为“连锁更新”(cascade update)

除了添加新节点可能会引发连锁更新之外,删除节点也可能会引发连锁更新。

如果e1至eN都是大小介于250字节至253字节的节点,big节点的长度大于等于254字节(需要5字节的previous_entry_length来保存), 而small节点的长度小于254字节(只需要1字节的previous_entry_length来保存),那么当将small节点从压缩列表中删除之后, 为了让e1的previous_entry_length属性可以记录big节点的长度,程序将扩展e1的空间,并由此引发之后的连锁更新。

连锁更新过程

因为连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N 2)。

要注意的是,尽管连锁更新的复杂度较高,但它真正造成性能问题的几率是很低的:

  • 首先,压缩列表里要恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见;
  • 其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响:比如说,对三五个节点进行连锁更新是绝对不会影响性能的;

因为以上原因,ziplistPush等命令的平均复杂度仅为O(N),在实际中,可以放心地使用这些函数,而不必担心连锁更新会影响压缩列表的性能。

quicklist

Redis 3.2提供了一个quicklist,简单的说是一个ziplist为节点的linkedlist,结合了ziplist和linkedlist两者的优势。

整数集合(intset)

  • 整数集合是集合键的底层实现之一。
  • 整数集合的底层实现为数组,这个数组以有序、无重复的方式保存集合元素,在有需要时,程序会根据新添加元素的类型,改变这个数组的类型。
  • 升级操作为整数集合带来了操作上的灵活性,并且尽可能地节约了内存。
  • 整数集合只支持升级操作,不支持降级操作。

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。

redis> SADD numbers 1 3 5 7 9
(integer) 5
redis> OBJECT ENCODING numbers
"intset"

整数集合的实现

整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素。

每个intset.h/intset结构表示一个整数集合:

typedef struct intset {
    // 编码方式
    uint32_t encoding;
    // 集合包含的元素数量
    uint32_t length;
    // 保存元素的数组
    int8_t contents[];
} intset;

contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。
length属性记录了整数集合包含的元素数量,也即是contents数组的长度。

虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值:

  • 如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组,数组里的每个项都是一个int16_t类型的整数值(最小值为-32768,最大值为32767)。
  • 如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组,数组里的每个项都是一个int32_t类型的整数值(最小值为-2147483648,最大值为2147483647)。
  • 如果encoding属性的值为INTSET_ENC_INT64,那么contents就是一个int64_t类型的数组,数组里的每个项都是一个int64_t类型的整数值(最小值为-9223372036854775808,最大值为9223372036854775807)。

一个包含五个int16_t类型整数值的整数集合:

一个包含五个int16_t类型整数值的整数集合

  • encoding属性的值为INTSET_ENC_INT16,表示整数集合的底层实现为int16_t类型的数组,而集合保存的都是int16_t类型的整数值。
  • length属性的值为5,表示整数集合包含五个元素。
  • contents数组按从小到大的顺序保存着集合中的五个元素。
  • 因为每个集合元素都是int16_t类型的整数值,所以contents数组的大小等于sizeof(int16_t)5=165=80位。

一个包含四个int16_t类型整数值的整数集合:

一个包含四个int16_t类型整数值的整数集合

  • encoding属性的值为INTSET_ENC_INT64,表示整数集合的底层实现为int64_t类型的数组,而数组中保存的都是int64_t类型的整数值。
  • length属性的值为4,表示整数集合包含四个元素。
  • contents数组按从小到大的顺序保存着集合中的四个元素。
  • 因为每个集合元素都是int64_t类型的整数值,所以contents数组的大小为sizeof(int64_t)4=644=256位。

虽然contents数组保存的四个整数值中,只有-2675256175807981027是真正需要用int64_t类型来保存的,而其他的1、3、5三个值都可以用int16_t类型来保存, 不过根据整数集合的升级规则,当向一个底层为int16_t数组的整数集合添加一个int64_t类型的整数值时,整数集合已有的所有元素都会被转换成int64_t类型, 所以contents数组保存的四个整数值都是int64_t类型的,不仅仅是-2675256175807981027。

升级

每当要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面。

升级整数集合并添加新元素共分为三步进行:

  1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  2. 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
  3. 将新元素添加到底层数组里面。

一个包含三个int16_t类型的元素的整数集合,要将类型为int32_t的整数值65535添加到整数集合里面:

一个包含三个int16_t类型的元素的整数集合

contents数组的各个元素,以及它们所在的位:

contents数组的各个元素,以及它们所在的位

进行空间重分配之后的数组:

进行空间重分配之后的数组

将这三个元素转换成int32_t类型,并将转换后的元素放置到正确的位上面,而且在放置元素的过程中,需要维持底层数组的有序性质不变。

对元素3进行类型转换,并保存在适当的位上:

对元素3进行类型转换,并保存在适当的位上

对元素2进行类型转换,并保存在适当的位上:

对元素2进行类型转换,并保存在适当的位上

对元素1进行类型转换,并保存在适当的位上:

对元素1进行类型转换,并保存在适当的位上

添加65535到数组:

添加65535到数组

程序将整数集合encoding属性的值从INTSET_ENC_INT16改为INTSET_ENC_INT32,并将length属性的值从3改为4,完成添加操作之后的整数集合:

添加65535到数组

因为每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组中已有的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为O(N)。

升级之后新元素的摆放位置

因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大,所以这个新元素的值要么就大于所有现有元素,要么就小于所有现有元素:

  • 在新元素小于所有现有元素的情况下,新元素会被放置在底层数组的最开头(索引0);
  • 在新元素大于所有现有元素的情况下,新元素会被放置在底层数组的最末尾(索引length-1)。

升级的好处

整数集合的升级策略有两个好处,一个是提升整数集合的灵活性,另一个是尽可能地节约内存。

提升灵活性

因为C语言是静态类型语言,为了避免类型错误,通常不会将两种不同类型的值放在同一个数据结构里面。
例如,一般只使用int16_t类型的数组来保存int16_t类型的值,只使用int32_t类型的数组来保存int32_t类型的值,诸如此类。

但是,因为整数集合可以通过自动升级底层数组来适应新元素,所以可以随意地将int16_t、int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误,这种做法非常灵活。

节约内存

整数集合现在的做法既可以让集合能同时保存三种不同类型的值,又可以确保升级操作只会在有需要的时候进行,这可以尽量节省内存。

降级

整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。

跳跃表的实现

  • 跳跃表是有序集合的底层实现之一。
  • Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成,其中zskiplist用于保存跳跃表信息(比如表头节点、表尾节点、长度),而zskiplistNode则用于表示跳跃表节点。
  • 每个跳跃表节点的层高都是1至32之间的随机数。
  • 在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的。
  • 跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小进行排序。

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

跳跃表支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。

Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员(member)是比较长的字符串时,Redis就会使用跳跃表来作为有序集合键的底层实现。

和链表、字典等数据结构被广泛地应用在Redis内部不同,Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构,除此之外,跳跃表在Redis里面没有其他用途。

Redis的跳跃表由redis.h/zskiplistNode和redis.h/zskiplist两个结构定义,其中zskiplistNode结构用于表示跳跃表节点,而zskiplist结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。

一个跳跃表:

A-jump-Table

位于图片最左边的是zskiplist结构,该结构包含以下属性:

  • header:指向跳跃表的表头节点。
  • tail:指向跳跃表的表尾节点。
  • level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。
  • length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。

位于zskiplist结构右方的是四个zskiplistNode结构,该结构包含以下属性:

  • 层(level):节点中用L1、L2、L3等字样标记节点的各个层,L1代表第一层,L2代表第二层,以此类推。
    • 每个层都带有两个属性:前进指针和跨度。
      • 前进指针用于访问位于表尾方向的其他节点
      • 而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。
  • 后退(backward)指针:节点中用BW字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。
  • 分值(score):各个节点中的1.0、2.0和3.0是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。
  • 成员对象(obj):各个节点中的o1、o2和o3是节点所保存的成员对象。

注意表头节点和其他节点的构造是一样的:表头节点也有后退指针、分值和成员对象,不过表头节点的这些属性都不会被用到,所以图中省略了这些部分,只显示了表头节点的各个层。

跳跃表节点

跳跃表节点的实现由redis.h/zskiplistNode结构定义:

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

跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。

每次创建一个新跳跃表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”。

高度为1层、3层和5层的节点:

高度为1层、3层和5层的节点

前进指针

每个层都有一个指向表尾方向的前进指针(level[i].forward属性),用于从表头向表尾方向访问节点。

遍历整个跳跃表,用虚线表示出了程序从表头向表尾方向,遍历跳跃表中所有节点的路径:

遍历整个跳跃表

  1. 迭代程序首先访问跳跃表的第一个节点(表头),然后从第四层的前进指针移动到表中的第二个节点。
  2. 在第二个节点时,程序沿着第二层的前进指针移动到表中的第三个节点。
  3. 在第三个节点时,程序同样沿着第二层的前进指针移动到表中的第四个节点。
  4. 当程序再次沿着第四个节点的前进指针移动时,它碰到一个NULL,程序知道这时已经到达了跳跃表的表尾,于是结束这次遍历。
跨度

层的跨度(level[i].span属性)用于记录两个节点之间的距离:

  • 两个节点之间的跨度越大,它们相距得就越远。
  • 指向NULL的所有前进指针的跨度都为0,因为它们没有连向任何节点。

看上去,很容易以为跨度和遍历操作有关,但实际上并不是这样,遍历操作只使用前进指针就可以完成了,跨度实际上是用来计算排位(rank)的:
在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。

虚线标记了在跳跃表中查找分值为3.0、成员对象为o3的节点时,沿途经历的层:查找的过程只经过了一个层,并且层的跨度为3,所以目标节点在跳跃表中的排位为3。

计算节点的排位

虚线标记了在跳跃表中查找分值为2.0、成员对象为o2的节点时,沿途经历的层:在查找节点的过程中,程序经过了两个跨度为1的节点,因此可以计算出,目标节点在跳跃表中的排位为2。

计算节点的排位

后退指针

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

如果从表尾向表头遍历跳跃表中的所有节点:程序首先通过跳跃表的tail指针访问表尾节点,然后通过后退指针访问倒数第二个节点,之后再沿着后退指针访问倒数第三个节点,再之后遇到指向NULL的后退指针,于是访问结束。

从表尾向表头方向遍历跳跃表:

从表尾向表头方向遍历跳跃表

分值和成员

节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。
节点的成员对象(obj属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。

在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:
分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。

三个带有相同分值的跳跃表节点:

三个带有相同分值的跳跃表节点

跳跃表

typedef struct zskiplist {
    // 表头节点和表尾节点
    structz skiplistNode *header, *tail;
    // 表中节点的数量
    unsigned long length;
    // 表中层数最大的节点的层数
    int level;
} zskiplist;

仅靠多个跳跃表节点就可以组成一个跳跃表

由多个跳跃节点组成的跳跃表

但通过使用一个zskiplist结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点,或者快速地获取跳跃表节点的数量(也即是跳跃表的长度)等信息

带有zskiplist结构的跳跃表

header和tail指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为O(1)。 通过使用length属性来记录节点的数量,程序可以在O(1)复杂度内返回跳跃表的长度。 level属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量,注意表头节点的层高并不计算在内。

对象

  • Redis数据库中的每个键值对的键和值都是一个对象。
  • Redis共有字符串、列表、哈希、集合、有序集合五种类型的对象,每种类型的对象至少都有两种或以上的编码方式,不同的编码可以在不同的使用场景上优化对象的使用效率。
  • 服务器在执行某些命令之前,会先检查给定键的类型能否执行指定的命令,而检查一个键的类型就是检查键的值对象的类型。
  • Redis的对象系统带有引用计数实现的内存回收机制,当一个对象不再被使用时,该对象所占用的内存就会被自动释放。
  • Redis会共享值为0到9999的字符串对象。
  • 对象会记录自己的最后一次被访问的时间,这个时间可以用于计算对象的空转时间。

Redis并没有直接使用这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统, 这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,每种对象都用到了至少一种前面所介绍的数据结构。

通过这五种不同类型的对象,Redis可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令。
使用对象的另一个好处是,可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。
除此之外,Redis的对象系统还实现了基于引用计数技术的内存回收机制,当程序不再使用某个对象的时候,这个对象所占用的内存就会被自动释放;
另外,Redis还通过引用计数技术实现了对象共享机制,这一机制可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存。
最后,Redis的对象带有访问时间记录信息,该信息可以用于计算数据库键的空转时长,在服务器启用了maxmemory功能的情况下,空转时长较大的那些键可能会优先被服务器删除。

对象的类型与编码

Redis使用对象来表示数据库中的键和值,每次当在Redis的数据库中新创建一个键值对时,至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。

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

typedef struct redisObject {
    // 类型
    unsigned type:4;
    // 编码
    unsigned encoding:4;
    // 指向底层实现数据结构的指针
    void *ptr;
    // ...
} robj;

类型

对象的type属性记录了对象的类型,这个属性的值可以是表中列出的常量的其中一个。

对象的类型:

类型常量 对象的名称
REDIS_STRING 字符串对象
REDIS_LIST 列表对象
REDIS_HASH 哈希对象
REDIS_SET 集合对象
REDIS_ZSET 有序集合对象

对于 Redis 数据库保存的键值对来说, 键总是一个字符串对象, 而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种。

TYPE命令的实现方式也与此类似,当对一个数据库键执行TYPE命令时,命令返回的结果为数据库键对应的值对象的类型,而不是键对象的类型:

# 键为字符串对象,值为字符串对象
redis> SET msg "hello world"
OK
redis> TYPE msg
string
# 键为字符串对象,值为列表对象
redis> RPUSH numbers 1 3 5
(integer) 6
redis> TYPE numbers
list
# 键为字符串对象,值为哈希对象
redis> HMSET profile name Tome age 25 career Programmer
OK
redis> TYPE profile
hash
# 键为字符串对象,值为集合对象
redis> SADD fruits apple banana cherry
(integer) 3
redis> TYPE fruits
set
# 键为字符串对象,值为有序集合对象
redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3
redis> TYPE price
zset

不同类型值对象的TYPE命令输出:

对象 对象 type 属性的值 TYPE 命令的输出
字符串对象 REDIS_STRING “string”
列表对象 REDIS_LIST “list”
哈希对象 REDIS_HASH “hash”
集合对象 REDIS_SET “set”
有序集合对象 REDIS_ZSET “zset”

编码和底层实现

内部编码的好处:

  • 可以改进内部编码,而对外的数据结构和命令没有影响
    • 如Redis 3.2提供了quicklist,结合了ziplist和linkedlist两者的有事,而外部用户感受不到
  • 多重内部编码实现可以在不同场景下发挥各自的优势

对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定。

encoding属性记录了对象所使用的编码,也即是说这个对象使用了什么数据结构作为对象的底层实现,这个属性的值可以是表中列出的常量的其中一个。

对象的编码:

编码常量 编码所对应的底层数据结构
REDIS_ENCODING_INT long 类型的整数
REDIS_ENCODING_EMBSTR embstr 编码的简单动态字符串
REDIS_ENCODING_RAW 简单动态字符串
REDIS_ENCODING_HT 字典
REDIS_ENCODING_LINKEDLIST 双端链表
REDIS_ENCODING_ZIPLIST 压缩列表
REDIS_ENCODING_INTSET 整数集合
REDIS_ENCODING_SKIPLIST 跳跃表和字典

每种类型的对象都至少使用了两种不同的编码,表中列出了每种类型的对象可以使用的编码。

不同类型和编码的对象:

类型 编码 对象
REDIS_STRING REDIS_ENCODING_INT 使用整数值实现的字符串对象。
REDIS_STRING REDIS_ENCODING_EMBSTR 使用 embstr 编码的简单动态字符串实现的字符串对象。
REDIS_STRING REDIS_ENCODING_RAW 使用简单动态字符串实现的字符串对象。
REDIS_LIST REDIS_ENCODING_ZIPLIST 使用压缩列表实现的列表对象。
REDIS_LIST REDIS_ENCODING_LINKEDLIST 使用双端链表实现的列表对象。
REDIS_HASH REDIS_ENCODING_ZIPLIST 使用压缩列表实现的哈希对象。
REDIS_HASH REDIS_ENCODING_HT 使用字典实现的哈希对象。
REDIS_SET REDIS_ENCODING_INTSET 使用整数集合实现的集合对象。
REDIS_SET REDIS_ENCODING_HT 使用字典实现的集合对象。
REDIS_ZSET REDIS_ENCODING_ZIPLIST 使用压缩列表实现的有序集合对象。
REDIS_ZSET REDIS_ENCODING_SKIPLIST 使用跳跃表和字典实现的有序集合对象。

使用OBJECT ENCODING命令可以查看一个数据库键的值对象的编码:

redis> SET msg "hello wrold"
OK
redis> OBJECT ENCODING msg
"embstr"
redis> SET story "long long long long long long ago ..."
OK
redis> OBJECT ENCODING story
"raw"
redis> SADD numbers 1 3 5
(integer) 3
redis> OBJECT ENCODING numbers
"intset"
redis> SADD numbers "seven"
(integer) 1
redis> OBJECT ENCODING numbers
"hashtable"

不同编码的对象所对应的OBJECT ENCODING命令输出:

对象所使用的底层数据结构 编码常量 OBJECT ENCODING 命令输出
整数 REDIS_ENCODING_INT “int”
embstr 编码的简单动态字符串(SDS) REDIS_ENCODING_EMBSTR “embstr”
简单动态字符串 REDIS_ENCODING_RAW “raw”
字典 REDIS_ENCODING_HT “hashtable”
双端链表 REDIS_ENCODING_LINKEDLIST “linkedlist”
压缩列表 REDIS_ENCODING_ZIPLIST “ziplist”
整数集合 REDIS_ENCODING_INTSET “intset”
跳跃表和字典 REDIS_ENCODING_SKIPLIST “skiplist”

通过encoding属性来设定对象所使用的编码,而不是为特定类型的对象关联一种固定的编码,极大地提升了Redis的灵活性和效率, 因为Redis可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。

字符串对象

编码

int

字符串对象的编码可以是int、raw或者embstr。

如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面(将void*转换成long),并将字符串对象的编码设置为int。

redis> SET number 10086
OK
redis> OBJECT ENCODING number
"int"

INT-encoded-String-object

raw

如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw。

redis> SET story "Long, long ago there lived a king ..."
OK
redis> STRLEN story
(integer) 37
redis> OBJECT ENCODING story
"raw"

Raw-encoded-String-Object

embstr

如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值。

embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象, 但raw编码会调用两次内存分配函数来分别创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间, 空间中依次包含redisObject和sdshdr两个结构

Memory-block-structure-created-by-EMBSTR-encoding

embstr编码的字符串对象在执行命令时,产生的效果和raw编码的字符串对象执行命令时产生的效果是相同的,但使用embstr编码的字符串对象来保存短字符串值有以下好处:

  • embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次。
  • 释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数。
  • 因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起raw编码的字符串对象能够更好地利用缓存带来的优势。

EMBSTR-encoded-String-Object

long和double的编码

最后要说的是,可以用long double类型表示的浮点数在Redis中也是作为字符串值来保存的。如果要保存一个浮点数到字符串对象里面,那么程序会先将这个浮点数转换成字符串值,然后再保存转换所得的字符串值。

在有需要的时候,程序会将保存在字符串对象里面的字符串值转换回浮点数值,执行某些操作,然后再将执行操作所得的浮点数值转换回字符串值,并继续保存在字符串对象里面。

redis> INCRBYFLOAT pi 2.0
"5.14"
redis> OBJECT ENCODING pi
"embstr"

程序首先会取出字符串对象里面保存的字符串值”3.14”,将它转换回浮点数值3.14,然后把3.14和2.0相加得出的值5.14转换成字符串”5.14”,并将这个”5.14”保存到字符串对象里面。

编码
可以用 long 类型保存的整数。 int
可以用 long double 类型保存的浮点数。 embstr 或者 raw
字符串值, 或者因为长度太大而没办法用 long 类型表示的整数, 又或者因为长度太大而没办法用 long double 类型表示的浮点数。 embstr 或者 raw

编码的转换

int编码的字符串对象和embstr编码的字符串对象在条件满足的情况下,会被转换为raw编码的字符串对象。

  • 对于int编码的字符串对象来说,如果向对象执行了一些命令,使得这个对象保存的不再是整数值,而是一个字符串值,那么字符串对象的编码将从int变为raw。
  • embstr编码的字符串对象在执行修改命令之后,总会变成一个raw编码的字符串对象。
    • embstr编码的字符串对象实际上是只读的。因为Redis没有为embstr编码的字符串对象编写任何相应的修改程序(只有int编码的字符串对象和raw编码的字符串对象有这些程序),
    • 程序会先将对象的编码从embstr转换成raw,然后再执行修改命令。
redis> SET number 10086
OK
redis> OBJECT ENCODING number
"int"
redis> APPEND number " is a good number!"
(integer) 23
redis> GET number
"10086 is a good number!"
redis> OBJECT ENCODING number
"raw"
redis> SET msg "hello world"
OK
redis> OBJECT ENCODING msg
"embstr"
redis> APPEND msg " again!"
(integer) 18
redis> OBJECT ENCODING msg
"raw"

字符串操作

redis 127.0.0.1:6379> SET name "yiibai"
OK
redis 127.0.0.1:6379> GET name
"yiibai"
命令 作用 示例
SET key value 此命令用于在指定键设置值  
GET key 键对应的值。  
GETRANGE key start end 得到字符串的子字符串存放在一个键 redis 127.0.0.1:6379> SET mykey “This is my test key”
OK
redis 127.0.0.1:6379> GETRANGE mykey 0 3
“This”
redis 127.0.0.1:6379> GETRANGE mykey 0 -1
“This is my test key”
GETSET key value 设置键的字符串值,并返回旧值  
GETBIT key offset 返回存储在键位值的字符串值的偏移  
MGET key1 [key2..] 得到所有的给定键的值 redis 127.0.0.1:6379> SET key1 “hello”
OK
redis 127.0.0.1:6379> SET key2 “world”
OK
redis 127.0.0.1:6379> MGET key1 key2 someOtherKey
1) “Hello”
2) “World”
3) (nil)
SETBIT key offset value 设置或清除该位在存储在键的字符串值偏移  
SETEX key seconds value 键到期时设置值 原子操作,减少了一次IO
SETNX key value 设置键的值,只有当该键不存在  
SETRANGE key offset value 覆盖字符串的一部分从指定键的偏移  
STRLEN key 得到存储在键的值的长度  
MSET key value [key value …] 设置多个键和多个值  
MSETNX key value [key value …] 设置多个键多个值,只有在当没有按键的存在时  
PSETEX key milliseconds value 设置键的毫秒值和到期时间  
INCR key 增加键的整数值一次 不存在的值incr后为1,非数值型报错
INCRBY key increment 由给定的数量递增键的整数值  
INCRBYFLOAT key increment 由给定的数量递增键的浮点值  
DECR key 递减键一次的整数值 不存在的值incr后为-1
DECRBY key decrement 由给定数目递减键的整数值  
APPEND key value 追加值到一个键 拼接字符串,返回拼接后长度

字符串命令的实现

命令 int 编码的实现方法 embstr 编码的实现方法 raw 编码的实现方法
SET 使用 int 编码保存值。 使用 embstr 编码保存值。 使用 raw 编码保存值。
GET 拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 然后向客户端返回这个字符串值。 直接向客户端返回字符串值。 直接向客户端返回字符串值。
APPEND 将对象转换成 raw 编码, 然后按 raw 编码的方式执行此操作。 将对象转换成 raw 编码, 然后按 raw 编码的方式执行此操作。 调用 sdscatlen 函数, 将给定字符串追加到现有字符串的末尾。
INCRBYFLOAT 取出整数值并将其转换成 long double 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。 取出字符串值并尝试将其转换成 long double 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。 如果字符串值不能被转换成浮点数, 那么向客户端返回一个错误。 取出字符串值并尝试将其转换成 long double 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。 如果字符串值不能被转换成浮点数, 那么向客户端返回一个错误。
INCRBY 对整数值进行加法计算, 得出的计算结果会作为整数被保存起来。 embstr 编码不能执行此命令, 向客户端返回一个错误。 raw 编码不能执行此命令, 向客户端返回一个错误。
DECRBY 对整数值进行减法计算, 得出的计算结果会作为整数被保存起来。 embstr 编码不能执行此命令, 向客户端返回一个错误。 raw 编码不能执行此命令, 向客户端返回一个错误。
STRLEN 拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 计算并返回这个字符串值的长度。 调用 sdslen 函数, 返回字符串的长度。 调用 sdslen 函数, 返回字符串的长度。
SETRANGE 将对象转换成 raw 编码, 然后按 raw 编码的方式执行此命令。 将对象转换成 raw 编码, 然后按 raw 编码的方式执行此命令。 将字符串特定索引上的值设置为给定的字符。
GETRANGE 拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 然后取出并返回字符串指定索引上的字符。 直接取出并返回字符串指定索引上的字符。 直接取出并返回字符串指定索引上的字符。

列表对象

编码

列表对象的编码可以是ziplist或者linkedlist。

ziplist
redis> RPUSH numbers 1 "three" 5
(integer) 3

如果numbers键的值对象使用的是ziplist编码:

ziplist编码的numbers列表对象

linkedlist

如果numbers键的值对象使用的是linkedlist编码:

linkedlist编码的numbers列表对象

注意:为了简化字符串对象的表示,使用了一个带有StringObject字样的格子来表示一个字符串对象
注意:linkedlist编码的列表对象在底层的双端链表结构中包含了多个字符串对象,这种嵌套字符串对象的行为在稍后介绍的哈希对象、集合对象和有序集合对象中都会出现。
Tips:字符串对象是Redis五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象。

编码转换

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

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

不能满足这两个条件的列表对象需要使用linkedlist编码。

Tips:以上两个条件的上限值是可以修改的,具体请看配置文件中关于list-max-ziplist-value选项和list-max-ziplist-entries选项的说明。

对于使用ziplist编码的列表对象来说,当使用ziplist编码所需的两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行, 原本保存在压缩列表里的所有列表元素都会被转移并保存到双端链表里面,对象的编码也会从ziplist变为linkedlist。

# 因为保存了长度太大的元素而进行编码转换的情况:
# 所有元素的长度都小于64字节
redis> RPUSH blah "hello" "world" "again"
(integer)3
redis> OBJECT ENCODING blah
"ziplist"
# 将一个65字节长的元素推入列表对象中
redis> RPUSH blah "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww"
(integer) 4
# 编码已改变
redis> OBJECT ENCODING blah
"linkedlist"
# 因为保存的元素数量过多而进行编码转换的情况:
# 列表对象包含512个元素
redis> EVAL "for i=1, 512 do redis.call('RPUSH', KEYS[1],i)end" 1 "integers"
(nil)
redis> LLEN integers
(integer) 512
redis> OBJECT ENCODING integers
"ziplist"
# 再向列表对象推入一个新元素,使得对象保存的元素数量达到513个
redis> RPUSH integers 513
(integer) 513
# 编码已改变
redis> OBJECT ENCODING integers
"linkedlist"

列表操作

redis 127.0.0.1:6379> lpush tutoriallist redis
(integer) 1
redis 127.0.0.1:6379> lpush tutoriallist mongodb
(integer) 2
redis 127.0.0.1:6379> lpush tutoriallist rabitmq
(integer) 3
redis 127.0.0.1:6379> lrange tutoriallist 0 10

1) "rabitmq"
2) "mongodb"
3) "redis"

列表的最大长度为 2^32 - 1 元素(4294967295,每个列表中可容纳超过4十亿的元素)。

命令 作用 备注
BLPOP key1 [key2 ] timeout 取出并获取列表中的第一个元素,或阻塞,直到有可用  
BRPOP key1 [key2 ] timeout 取出并获取列表中的最后一个元素,或阻塞,直到有可用  
BRPOPLPUSH source destination timeout 从列表中弹出一个值,它推到另一个列表并返回它;或阻塞,直到有可用  
LINDEX key index 从一个列表其索引获取对应的元素  
LINSERT key BEFORE(AFTER) pivot value 在列表中的其他元素之后或之前插入一个元素 要建立索引效率不高
LLEN key 获取列表的长度  
LPOP key 获取并取出列表中的第一个元素 取出为空后列表也被删除
LPUSH key value1 [value2] 在前面加上一个或多个值的列表  
LPUSHX key value 在前面加上一个值列表,仅当列表中存在  
LRANGE key start stop 从一个列表获取各种元素 可以为负数,从后面开始数,range为0 -1则是整个列表
LREM key count value 从列表中删除元素 要建立索引效率不高,count为0删除所有,为负数从尾部开始向前删除
LSET key index value 在列表中的索引设置一个元素的值 要建立索引效率不高
LTRIM key start stop 修剪列表到指定的范围内  
RPOP key 取出并获取列表中的最后一个元素  
RPOPLPUSH source destination 删除最后一个元素的列表,将其附加到另一个列表并返回它 两个参数都是自身实现列表循环
RPUSH key value1 [value2] 添加一个或多个值到列表 lpush list1 a b c d [d,c,b,a]
rpush list2 a b c d [a,b,c,d]
RPUSHX key value 添加一个值列表,仅当列表中存在  

列表命令的实现

命令 ziplist 编码的实现方法 linkedlist 编码的实现方法
LPUSH 调用 ziplistPush 函数, 将新元素推入到压缩列表的表头。 调用 listAddNodeHead 函数, 将新元素推入到双端链表的表头。
RPUSH 调用 ziplistPush 函数, 将新元素推入到压缩列表的表尾。 调用 listAddNodeTail 函数, 将新元素推入到双端链表的表尾。
LPOP 调用 ziplistIndex 函数定位压缩列表的表头节点, 在向用户返回节点所保存的元素之后, 调用 ziplistDelete 函数删除表头节点。 调用 listFirst 函数定位双端链表的表头节点, 在向用户返回节点所保存的元素之后, 调用 listDelNode 函数删除表头节点。
RPOP 调用 ziplistIndex 函数定位压缩列表的表尾节点, 在向用户返回节点所保存的元素之后, 调用 ziplistDelete 函数删除表尾节点。 调用 listLast 函数定位双端链表的表尾节点, 在向用户返回节点所保存的元素之后, 调用 listDelNode 函数删除表尾节点。
LINDEX 调用 ziplistIndex 函数定位压缩列表中的指定节点, 然后返回节点所保存的元素。 调用 listIndex 函数定位双端链表中的指定节点, 然后返回节点所保存的元素。
LLEN 调用 ziplistLen 函数返回压缩列表的长度。 调用 listLength 函数返回双端链表的长度。
LINSERT 插入新节点到压缩列表的表头或者表尾时, 使用 ziplistPush 函数; 插入新节点到压缩列表的其他位置时, 使用 ziplistInsert 函数。 调用 listInsertNode 函数, 将新节点插入到双端链表的指定位置。
LREM 遍历压缩列表节点, 并调用 ziplistDelete 函数删除包含了给定元素的节点。 遍历双端链表节点, 并调用 listDelNode 函数删除包含了给定元素的节点。
LTRIM 调用 ziplistDeleteRange 函数, 删除压缩列表中所有不在指定索引范围内的节点。 遍历双端链表节点, 并调用 listDelNode 函数删除链表中所有不在指定索引范围内的节点。
LSET 调用 ziplistDelete 函数, 先删除压缩列表指定索引上的现有节点, 然后调用 ziplistInsert 函数, 将一个包含给定元素的新节点插入到相同索引上面。 调用 listIndex 函数, 定位到双端链表指定索引上的节点, 然后通过赋值操作更新节点的值。

哈希对象

编码

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

ziplist

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

  • 保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;
  • 先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
redis> HSET profile name "Tom"
(integer) 1
redis> HSET profile age 25
(integer) 1
redis> HSET profile career "Programmer"
(integer) 1

如果profile键的值对象使用的是ziplist编码,ziplist编码的profile哈希对象:

ziplist编码的profile哈希对象

profile哈希对象的压缩列表底层实现:

profile哈希对象的压缩列表底层实现

hashtable

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

  • 字典的每个键都是一个字符串对象,对象中保存了键值对的键;
  • 字典的每个值都是一个字符串对象,对象中保存了键值对的值。

hashtable编码的profile哈希对象:

hashtable编码的profile哈希对象

编码的转换

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

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

不能满足这两个条件的哈希对象需要使用hashtable编码。

Tips:这两个条件的上限值是可以修改的,具体请看配置文件中关于hash-max-ziplist-value选项和hash-max-ziplist-entries选项的说明。

# 因为键值对的键长度太大而引起编码转换的情况:
# 哈希对象只包含一个键和值都不超过64个字节的键值对
redis> HSET book name "Mastering C++ in 21 days"
(integer) 1
redis> OBJECT ENCODING book
"ziplist"
# 向哈希对象添加一个新的键值对,键的长度为66字节
redis> HSET book long_long_long_long_long_long_long_long_long_long_long_description "content"
(integer) 1
# 编码已改变
redis> OBJECT ENCODING book
"hashtable"
# 值的长度太大也会引起编码转换:
# 哈希对象只包含一个键和值都不超过64个字节的键值对
redis> HSET blah greeting "hello world"
(integer) 1
redis> OBJECT ENCODING blah
"ziplist"
# 向哈希对象添加一个新的键值对,值的长度为68字节
redis> HSET blah story "many string ... many string ... many string ... many string ... many"
(integer) 1
# 编码已改变
redis> OBJECT ENCODING blah
"hashtable"
# 因为包含的键值对数量过多而引起编码转换的情况:
# 创建一个包含512个键值对的哈希对象
redis> EVAL "for i=1, 512 do redis.call('HSET', KEYS[1], i, i)end" 1 "numbers"
(nil)
redis> HLEN numbers
(integer) 512
redis> OBJECT ENCODING numbers
"ziplist"
# 再向哈希对象添加一个新的键值对,使得键值对的数量变成513个
redis> HMSET numbers "key" "value"
OK
redis> HLEN numbers
(integer) 513
# 编码改变
redis> OBJECT ENCODING numbers
"hashtable"

哈希操作

Redis的哈希是键值对的集合。Redis的哈希值是字符串字段和字符串值之间的映射,因此它们被用来表示对象。

Hash占用的空间很小

redis 127.0.0.1:6379> HMSET user:1 username yiibai password yiibai points 200
OK
redis 127.0.0.1:6379> HGETALL user:1

1) "username"
2) "yiibai"
3) "password"
4) "yiibai"
5) "points"
6) "200"

# 返回结果为key value对应的列表 count大小决定查询总数 
# key后面的0表示cursor位置,如果未查询到需要在下次查询中携带上次位置
# match及参数为模糊匹配
redis 127.0.0.1:6379> HSCAN key 0 match "*1*" count 10000000
1) "0"
2) 1) "1232"
   2) "value1"
   3) "1862324"
   4) "value2"

redis-cli -h localhost -p 6379 -a password hvals key| grep "valuegrep" |wc -l
命令 作用 备注
HDEL key field2 [field2] 删除一个或多个哈希字段 如果filed为空,hash也被删除
HEXISTS key field 判断一个哈希字段存在与否  
HGET key field 获取存储在指定的键散列字段的值 返回新增的map键的个数
HGETALL key 返回哈希中所有的键和值  
HINCRBY key field increment 由给定数量增加的哈希字段的整数值  
HINCRBYFLOAT key field increment 由给定的递增量哈希字段的浮点值  
HKEYS key 获取所有在哈希字段  
HLEN key 获取哈希字段数  
HMGET key field1 [field2] 获得所有给定的哈希字段的值  
HMSET key field1 value1 [field2 value2 ] 设置多个哈希字段的多个值  
HSET key field value 设置哈希字段的字符串值  
HSETNX key field value 设置哈希字段的值,仅当该字段不存在  
HVALS key 获取在哈希中的所有值  
HSCAN key cursor [MATCH pattern] [COUNT count] 增量迭代哈希字段及相关值  

哈希命令的实现

命令 ziplist 编码实现方法 hashtable 编码的实现方法
HSET 首先调用 ziplistPush 函数, 将键推入到压缩列表的表尾, 然后再次调用 ziplistPush 函数, 将值推入到压缩列表的表尾。 调用 dictAdd 函数, 将新节点添加到字典里面。
HGET 首先调用 ziplistFind 函数, 在压缩列表中查找指定键所对应的节点, 然后调用 ziplistNext 函数, 将指针移动到键节点旁边的值节点, 最后返回值节点。 调用 dictFind 函数, 在字典中查找给定键, 然后调用 dictGetVal 函数, 返回该键所对应的值。
HEXISTS 调用 ziplistFind 函数, 在压缩列表中查找指定键所对应的节点, 如果找到的话说明键值对存在, 没找到的话就说明键值对不存在。 调用 dictFind 函数, 在字典中查找给定键, 如果找到的话说明键值对存在, 没找到的话就说明键值对不存在。
HDEL 调用 ziplistFind 函数, 在压缩列表中查找指定键所对应的节点, 然后将相应的键节点、 以及键节点旁边的值节点都删除掉。 调用 dictDelete 函数, 将指定键所对应的键值对从字典中删除掉。
HLEN 调用 ziplistLen 函数, 取得压缩列表包含节点的总数量, 将这个数量除以 2 , 得出的结果就是压缩列表保存的键值对的数量。 调用 dictSize 函数, 返回字典包含的键值对数量, 这个数量就是哈希对象包含的键值对数量。
HGETALL 遍历整个压缩列表, 用 ziplistGet 函数返回所有键和值(都是节点)。 遍历整个字典, 用 dictGetKey 函数返回字典的键, 用 dictGetVal 函数返回字典的值。

集合对象

编码

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

intset

intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。

redis> SADD numbers 1 3 5
(integer) 3

intset编码的numbers集合对象:

intset编码的numbers集合对象

hashtable

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

redis> SADD Dfruits "apple" "banana" "cherry"
(integer)3

hashtable编码的fruits集合对象:

hashtable编码的fruits集合对象

编码的转换

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

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

不能满足这两个条件的集合对象需要使用hashtable编码。

Tips:第二个条件的上限值是可以修改的,具体请看配置文件中关于set-max-intset-entries选项的说明。

# 向只包含整数元素的集合对象添加一个字符串元素,编码转移操作就会被执行:
redis> SADD numbers 1 3 5
(integer) 3
redis> OBJECT ENCODING numbers
"intset"
redis> SADD numbers "seven"
(integer) 1
redis> OBJECT ENCODING numbers
"hashtable"
# 向集合添加一个新的整数元素,使得这个集合的元素数量超过512,的编码转换操作就会被执行:
redis> EVAL "for i=1, 512 do redis.call('SADD', KEYS[1], i) end" 1 integers
(nil)
redis> SCARD integers
(integer) 512
redis> OBJECT ENCODING integers
"intset"
redis> SADD integers 10086
(integer) 1
redis> SCARD integers
(integer) 513
redis> OBJECT ENCODING integers
"hashtable"

集合操作

Redis的集合是字符串的无序集合,不允许重复。在Redis您可以添加,删除和测试文件是否存在,在成员O(1)的时间复杂度。

大数据集合的交集、并集、差集运算都依赖set

redis 127.0.0.1:6379> sadd tutoriallist redis
(integer) 1
redis 127.0.0.1:6379> sadd tutoriallist mongodb
(integer) 1
redis 127.0.0.1:6379> sadd tutoriallist rabitmq
(integer) 1
redis 127.0.0.1:6379> sadd tutoriallist rabitmq
(integer) 0
redis 127.0.0.1:6379> smembers tutoriallist

1) "rabitmq"
2) "mongodb"
3) "redis"
命令 说明
SADD key member1 [member2] 将一个或多个成员添加到集合
SCARD key 获取集合中的成员数
SDIFF key1 [key2] 减去多个集合
SDIFFSTORE destination key1 [key2] 减去多个集并将结果集存储在键中
SINTER key1 [key2] 相交多个集合
SINTERSTORE destination key1 [key2] 交叉多个集合并将结果集存储在键中
SISMEMBER key member 判断确定给定值是否是集合的成员
SMOVE source destination member 将成员从一个集合移动到另一个集合
SPOP key 从集合中删除并返回随机成员
SRANDMEMBER key [count] 从集合中获取一个或多个随机成员
SREM key member1 [member2] 从集合中删除一个或多个成员
SUNION key1 [key2] 添加多个集合
SUNIONSTORE destination key1 [key2] 添加多个集并将结果集存储在键中
SSCAN key cursor [MATCH pattern] [COUNT count] 递增地迭代集合中的元素
SMEMBERS key 查看set中所有元素

在上面的例子中rabitmq集合添加加两次,但由于集合元素具有唯一属性。

集合命令的实现

命令 intset 编码的实现方法 hashtable 编码的实现方法
SADD 调用 intsetAdd 函数, 将所有新元素添加到整数集合里面。 调用 dictAdd , 以新元素为键, NULL 为值, 将键值对添加到字典里面。
SCARD 调用 intsetLen 函数, 返回整数集合所包含的元素数量, 这个数量就是集合对象所包含的元素数量。 调用 dictSize 函数, 返回字典所包含的键值对数量, 这个数量就是集合对象所包含的元素数量。
SISMEMBER 调用 intsetFind 函数, 在整数集合中查找给定的元素, 如果找到了说明元素存在于集合, 没找到则说明元素不存在于集合。 调用 dictFind 函数, 在字典的键中查找给定的元素, 如果找到了说明元素存在于集合, 没找到则说明元素不存在于集合。
SMEMBERS 遍历整个整数集合, 使用 intsetGet 函数返回集合元素。 遍历整个字典, 使用 dictGetKey 函数返回字典的键作为集合元素。
SRANDMEMBER 调用 intsetRandom 函数, 从整数集合中随机返回一个元素。 调用 dictGetRandomKey 函数, 从字典中随机返回一个字典键。
SPOP 调用 intsetRandom 函数, 从整数集合中随机取出一个元素, 在将这个随机元素返回给客户端之后, 调用 intsetRemove 函数, 将随机元素从整数集合中删除掉。 调用 dictGetRandomKey 函数, 从字典中随机取出一个字典键, 在将这个随机字典键的值返回给客户端之后, 调用 dictDelete 函数, 从字典中删除随机字典键所对应的键值对。
SREM 调用 intsetRemove 函数, 从整数集合中删除所有给定的元素。 调用 dictDelete 函数, 从字典中删除所有键为给定元素的键值对。

有序集合对象

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

编码

ziplist

ziplist编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score)。

压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向。

redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3

ziplist编码的有序集合对象:

ziplist编码的有序集合对象

有序集合元素在压缩列表中按分值从小到大排列:

有序集合元素在压缩列表中按分值从小到大排列

skiplist

skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表:

typedef struct zset {
    zskiplist *zsl;
    dict *dict;
} zset;
  • zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素
    • 跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值。
    • 通过这个跳跃表,程序可以对有序集合进行范围型操作,比如ZRANK、ZRANGE等命令就是基于跳跃表API来实现的。
  • 除此之外,zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素
    • 字典的键保存了元素的成员,而字典的值则保存了元素的分值。
    • 通过这个字典,程序可以用O(1)复杂度查找给定成员的分值,ZSCORE命令就是根据这一特性实现的,而很多其他有序集合命令都在实现的内部用到了这一特性。

有序集合每个元素的成员都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数。

虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费额外的内存。

为什么有序集合需要同时使用跳跃表和字典来实现?

无论单独使用字典还是跳跃表,在性能上对比起同时使用字典和跳跃表都会有所降低。

skiplist编码的有序集合对象:

skiplist编码的有序集合对象

有序集合元素同时被保存在字典和跳跃表中:

有序集合元素同时被保存在字典和跳跃表中

注意:在字典和跳跃表中重复展示了各个元素的成员和分值,实际中字典和跳跃表会共享元素的成员和分值

编码的转换

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

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

不能满足以上两个条件的有序集合对象将使用skiplist编码。

Tips:以上两个条件的上限值是可以修改的,具体请看配置文件中关于zset-max-ziplist-entries选项和zset-max-ziplist-value选项的说明。

# 因为包含了过多元素而引发编码转换的情况:
# 对象包含了128个元素
redis> EVAL "for i=1, 128 do redis.call('ZADD', KEYS[1], i, i) end" 1 numbers
(nil)
redis> ZCARD numbers
(integer) 128
redis> OBJECT ENCODING numbers
"ziplist"
# 再添加一个新元素
redis> ZADD numbers 3.14 pi
(integer) 1
# 对象包含的元素数量变为129个
redis> ZCARD numbers
(integer) 129
# 编码已改变
redis> OBJECT ENCODING numbers
"skiplist"
# 元素的成员过长而引发编码转换的情况:
# 向有序集合添加一个成员只有三字节长的元素
redis> ZADD blah 1.0 www
(integer) 1
redis> OBJECT ENCODING blah
"ziplist"
# 向有序集合添加一个成员为66字节长的元素
redis> ZADD blah 2.0 ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo
(integer) 1
# 编码已改变
redis> OBJECT ENCODING blah
"skiplist"

有序集合操作

Redis的有序集合类似于Redis的集合,字符串不重复的集合。不同的是,一个有序集合的每个成员用分数,以便采取有序set命令,从最小的到最大的成员分数有关。虽然成员具有唯一性,但分数可能会重复。

使用分数保证元素有序,每个元素都要手动赋分,适合做排行榜

redis 127.0.0.1:6379> zadd tutoriallist 0 redis
(integer) 1
redis 127.0.0.1:6379> zadd tutoriallist 0 mongodb
(integer) 1
redis 127.0.0.1:6379> zadd tutoriallist 0 rabitmq
(integer) 1
redis 127.0.0.1:6379> zadd tutoriallist 0 rabitmq
(integer) 0
redis 127.0.0.1:6379> ZRANGEBYSCORE tutoriallist 0 1000

1) "redis"
2) "mongodb"
3) "rabitmq"
命令 作用 示例
ZADD key score1 member1 [score2 member2] 添加一个或多个成员到有序集合,或者如果它已经存在更新其分数  
ZCARD key 得到的有序集合成员的数量  
ZCOUNT key min max 计算一个有序集合成员与给定值范围内的分数  
ZINCRBY key increment member 在有序集合增加成员的分数  
ZINTERSTORE destination numkeys key [key …] 多重交叉排序集合,并存储生成一个新的键有序集合。 redis 127.0.0.1:6379> ZADD myset 1 “hello”
(integer) 1
redis 127.0.0.1:6379> ZADD myset 2 “world”
(integer) 1
redis 127.0.0.1:6379> ZADD myset2 1 “hello”
(integer) 1
redis 127.0.0.1:6379> ZADD myset2 2 “world”
(integer) 1
redis 127.0.0.1:6379> ZADD myset2 3 “foo”
(integer) 1
redis 127.0.0.1:6379> ZINTERSTORE out 2 myset1 myset2 WEIGHTS 2 3”
(integer) 3
redis 127.0.0.1:6379> ZRANGE out 0 -1 WITHSCORES
1) “hello”
2) “5”
3) “world”
4) “10”
ZLEXCOUNT key min max 计算一个给定的字典范围之间的有序集合成员的数量  
ZRANGE key start stop [WITHSCORES] 由索引返回一个成员范围的有序集合。  
ZRANGEBYLEX key min max [LIMIT offset count] 返回一个成员范围的有序集合(由字典范围) redis 127.0.0.1:6379> ZADD myzset 0 a 0 b 0 c 0 d 0 e
(integer) 5
redis 127.0.0.1:6379> ZADD myzset 0 f 0 g
(integer) 2
redis 127.0.0.1:6379> ZRANGEBYLEX myzset - [c
1) “a”
2) “b”
3) “c”
redis 127.0.0.1:6379> ZRANGEBYLEX myzset - (c
1) “a”
2) “b”
两个开始和结束是从零开始的索引,其中0是第一个元素,1是下一个元素等等。它们也可以是表示偏移量从有序集的结尾,以-1作为排序的集合的最后一个元素,-2倒数第二元素等负数。
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT] 按分数返回一个成员范围的有序集合。  
ZRANK key member 确定成员的索引中有序集合  
ZREM key member [member …] 从有序集合中删除一个或多个成员  
ZREMRANGEBYLEX key min max 删除所有成员在给定的字典范围之间的有序集合  
ZREMRANGEBYRANK key start stop 在给定的索引之内删除所有成员的有序集合  
ZREMRANGEBYSCORE key min max 在给定的分数之内删除所有成员的有序集合  
ZREVRANGE key start stop [WITHSCORES] 返回一个成员范围的有序集合,通过索引,以分数排序,从高分到低分  
ZREVRANGEBYSCORE key max min [WITHSCORES] 返回一个成员范围的有序集合,按分数,以分数排序从高分到低分  
ZREVRANK key member 确定一个有序集合成员的索引,以分数排序,从高分到低分  
ZSCORE key member 获取给定成员相关联的分数在一个有序集合  
ZUNIONSTORE destination numkeys key [key …] 添加多个集排序,所得排序集合存储在一个新的键  
ZSCAN key cursor [MATCH pattern] [COUNT count] 增量迭代排序元素集和相关的分数  

有序集合命令的实现

命令 ziplist 编码的实现方法 zset 编码的实现方法
ZADD 调用 ziplistInsert 函数, 将成员和分值作为两个节点分别插入到压缩列表。 先调用 zslInsert 函数, 将新元素添加到跳跃表, 然后调用 dictAdd 函数, 将新元素关联到字典。
ZCARD 调用 ziplistLen 函数, 获得压缩列表包含节点的数量, 将这个数量除以 2 得出集合元素的数量。 访问跳跃表数据结构的 length 属性, 直接返回集合元素的数量。
ZCOUNT 遍历压缩列表, 统计分值在给定范围内的节点的数量。 遍历跳跃表, 统计分值在给定范围内的节点的数量。
ZRANGE 从表头向表尾遍历压缩列表, 返回给定索引范围内的所有元素。 从表头向表尾遍历跳跃表, 返回给定索引范围内的所有元素。
ZREVRANGE 从表尾向表头遍历压缩列表, 返回给定索引范围内的所有元素。 从表尾向表头遍历跳跃表, 返回给定索引范围内的所有元素。
ZRANK 从表头向表尾遍历压缩列表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。 从表头向表尾遍历跳跃表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。
ZREVRANK 从表尾向表头遍历压缩列表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。 从表尾向表头遍历跳跃表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。
ZREM 遍历压缩列表, 删除所有包含给定成员的节点, 以及被删除成员节点旁边的分值节点。 遍历跳跃表, 删除所有包含了给定成员的跳跃表节点。 并在字典中解除被删除元素的成员和分值的关联。
ZSCORE 遍历压缩列表, 查找包含了给定成员的节点, 然后取出成员节点旁边的分值节点保存的元素分值。 直接从字典中取出给定成员的分值。

类型检查与命令多态

Redis中用于操作键的命令基本上可以分为两种类型。

  • 其中一种命令可以对任何类型的键执行
    • 比如DEL命令、EXPIRE命令、RENAME命令、TYPE命令、OBJECT命令等。
  • 而另一种命令只能对特定类型的键执行
    • SET、GET、APPEND、STRLEN等命令只能对字符串键执行;
    • HDEL、HSET、HGET、HLEN等命令只能对哈希键执行;
    • RPUSH、LPOP、LINSERT、LLEN等命令只能对列表键执行;
    • SADD、SPOP、SINTER、SCARD等命令只能对集合键执行;
    • ZADD、ZCARD、ZRANK、ZSCORE等命令只能对有序集合键执行;

类型检查的实现

类型特定命令所进行的类型检查是通过redisObject结构的type属性来实现的:

  • 在执行一个类型特定命令之前,服务器会先检查输入数据库键的值对象是否为执行命令所需的类型,如果是的话,服务器就对键执行指定的命令;
  • 否则,服务器将拒绝执行命令,并向客户端返回一个类型错误。

多态命令的实现

Redis除了会根据值对象的类型来判断键是否能够执行指定命令之外,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令。

DEL、EXPIRE等命令和LLEN等命令的区别在于,前者是基于类型的多态——一个命令可以同时用于处理多种不同类型的键,而后者是基于编码的多态——一个命令可以同时用于处理多种不同编码。

LLEN命令的执行过程:

LLEN命令的执行过程

内存回收

因为C语言并不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个引用计数(reference counting)技术实现的内存回收机制,通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。

每个对象的引用计数信息由redisObject结构的refcount属性记录:

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

对象的引用计数信息会随着对象的使用状态而不断变化:

  • 在创建一个新对象时,引用计数的值会被初始化为1;
  • 当对象被一个新程序使用时,它的引用计数值会被增一;
  • 当对象不再被一个程序使用时,它的引用计数值会被减一;
  • 当对象的引用计数值变为0时,对象所占用的内存会被释放。

修改对象引用计数的API:

函数 作用
incrRefCount 将对象的引用计数值增一。
decrRefCount 将对象的引用计数值减一, 当对象的引用计数值等于 0 时, 释放对象。
resetRefCount 将对象的引用计数值设置为 0 , 但并不释放对象, 这个函数通常在需要重新设置对象的引用计数值时使用。

对象的整个生命周期可以划分为创建对象、操作对象、释放对象三个阶段。

作为例子, 以下代码展示了一个字符串对象从创建到释放的整个过程:

// 创建一个字符串对象 s ,对象的引用计数为 1
robj *s = createStringObject(...)

// 对象 s 执行各种操作 ...

// 将对象 s 的引用计数减一,使得对象的引用计数变为 0
// 导致对象 s 被释放
decrRefCount(s)

对象共享

除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。

在Redis中,让多个键共享同一个值对象需要执行以下两个步骤:

  1. 将数据库键的值指针指向一个现有的值对象;
  2. 将被共享的值对象的引用计数增一。

目前来说,Redis会在初始化服务器时,创建一万个字符串对象,这些对象包含了从0到9999的所有整数值,当服务器需要用到值为0到9999的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象。

创建共享字符串对象的数量可以通过修改redis.h/REDIS_SHARED_INTEGERS常量来修改。

使用OBJECT REFCOUNT命令查看键A的值对象的引用计数:

# 引用这个值对象的两个程序分别是持有这个值对象的服务器程序,以及共享这个值对象的键A
redis> SET A 100
OK
redis> OBJECT REFCOUNT A
(integer) 2

引用数为3的共享对象

这些共享对象不单单只有字符串键可以使用,那些在数据结构中嵌套了字符串对象的对象(linkedlist编码的列表对象、hashtable编码的哈希对象、hashtable编码的集合对象,以及zset编码的有序集合对象)都可以使用这些共享对象。

为什么Redis不共享包含字符串的对象?

当服务器考虑将一个共享对象设置为键的值对象时,程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同, 只有在共享对象和目标对象完全相同的情况下,程序才会将共享对象用作键的值对象, 而一个共享对象保存的值越复杂,验证共享对象和目标对象是否相同所需的复杂度就会越高,消耗的CPU时间也会越多:

  • 如果共享对象是保存整数值的字符串对象,那么验证操作的复杂度为O(1);
  • 如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为O(N);
  • 如果共享对象是包含了多个值(或者对象的)对象,比如列表对象或者哈希对象,那么验证操作的复杂度将会是O(N2)。

因此,尽管共享更复杂的对象可以节约更多的内存,但受到CPU时间的限制,Redis只对包含整数值的字符串对象进行共享。

对象的空转时长

除了前面介绍过的type、encoding、ptr和refcount四个属性之外,redisObject结构包含的最后一个属性为lru属性,该属性记录了对象最后一次被命令程序访问的时间:

typedef struct redisObject {
    // ...
    unsigned lru:22;
    // ...
} robj;

OBJECT IDLETIME命令可以打印出给定键的空转时长,这一空转时长就是通过将当前时间减去键的值对象的lru时间计算得出的:

redis> SET msg "hello world"
OK
# 等待一小段时间
redis> OBJECT IDLETIME msg
(integer) 20
# 等待一阵子
redis> OBJECT IDLETIME msg
(integer) 180
# 访问msg键的值
redis> GET msg
"hello world"
# 键处于活跃状态,空转时长为0 
redis> OBJECT IDLETIME msg
(integer) 0

注意:OBJECT IDLETIME命令的实现是特殊的,这个命令在访问键的值对象时,不会修改值对象的lru属性。

键的空转时长还有另外一项作用:如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru, 那么当服务器占用的内存数超过了maxmemory选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。

配置文件的maxmemory选项和maxmemory-policy选项的说明介绍了关于这方面的更多信息。

内存

内存消耗

内存使用统计

首先需要了解Redis自身使用内存的统计数据,可通过执行info memory命令获取内存相关指标。

info memory详细解释:

info memory详细解释

需要重点关注的指标有:used_memory_rss和used_memory以及它们的比值mem_fragmentation_ratio。

  • 当mem_fragmentation_ratio>1时,说明used_memory_rss-used_memory多出的部分内存并没有用于数据存储,而是被内存碎片所消耗,如果两者相差很大,说明碎片率严重。
  • 当mem_fragmentation_ratio<1时,这种情况一般出现在操作系统把Redis内存交换(Swap)到硬盘导致,出现这种情况时要格外关注,由于硬盘速度远远慢于内存,Redis性能会变得很差,甚至僵死。

内存消耗划分

Redis进程内消耗主要包括:自身内存+对象内存+缓冲内存+内存碎片,其中Redis空进程自身内存消耗非常少,通常used_memory_rss在3MB左右,used_memory在800KB左右,一个空的Redis进程消耗内存可以忽略不计。

Redis主要内存消耗:

Redis主要内存消耗

对象内存

对象内存是Redis内存占用最大的一块,存储着用户所有的数据。Redis所有的数据都采用key-value数据类型,每次创建键值对时,至少创建两个类型对象:

key对象和value对象。对象内存消耗可以简单理解为sizeof(keys)+sizeof(values)。 键对象都是字符串,在使用Redis时很容易忽略键对内存消耗的影响,应当避免使用过长的键。

value对象更复杂些,主要包含5种基本数据类型:字符串、列表、哈希、集合、有序集合。 其他数据类型都是建立在这5种数据结构之上实现的,如:Bitmaps和HyperLogLog使用字符串实现,GEO使用有序集合实现等。

每种value对象类型根据使用规模不同,占用内存不同。在使用时一定要合理预估并监控value对象占用情况,避免内存溢出。

缓冲内存

缓冲内存主要包括:客户端缓冲、复制积压缓冲区、AOF缓冲区。

  • 客户端缓冲指的是所有接入到Redis服务器TCP连接的输入输出缓冲。输入缓冲无法控制,最大空间为1G,如果超过将断开连接。输出缓冲通过参数client-output-buffer-limit控制。
    • 普通客户端:除了复制和订阅的客户端之外的所有连接,Redis的默认配置是:client-output-buffer-limit norma l 0 0 0。
      • Redis并没有对普通客户端的输出缓冲区做限制,一般普通客户端的内存消耗可以忽略不计。
      • 但是当有大量慢连接客户端接入时这部分内存消耗就不能忽略了,可以设置maxclients做限制。特别是当使用大量数据输出的命令且数据无法及时推送给客户端时,如monitor命令,容易造成Redis服务器内存突然飙升。
    • 从客户端:主节点会为每个从节点单独建立一条连接用于命令复制,默认配置是:client-output-buffer-limit slave 256mb 64mb 60。
      • 当主从节点之间网络延迟较高或主节点挂载大量从节点时这部分内存消耗将占用很大一部分,建议主节点挂载的从节点不要多于2个,主从节点不要部署在较差的网络环境下,如异地跨机房环境,防止复制客户端连接缓慢造成溢出。
    • 订阅客户端:当使用发布订阅功能时,连接客户端使用单独的输出缓冲区,默认配置为:client-output-buffer-limit pubsub 32mb 8mb 60。
      • 当订阅服务的消息生产快于消费速度时,输出缓冲区会产生积压造成输出缓冲区空间溢出。
    • 输入输出缓冲区在大流量的场景中容易失控,造成Redis内存的不稳定,需要重点监控。
  • 复制积压缓冲区:Redis在2.8版本之后提供了一个可重用的固定大小缓冲区用于实现部分复制功能,根据repl-backlog-size参数控制,默认1MB。
    • 对于复制积压缓冲区整个主节点只有一个,所有的从节点共享此缓冲区,因此可以设置较大的缓冲区空间,如100MB,这部分内存投入是有价值的,可以有效避免全量复制。
  • AOF缓冲区:这部分空间用于在Redis重写期间保存最近的写入命令。AOF缓冲区空间消耗用户无法控制,消耗的内存取决于AOF重写时间和写入命令量,这部分空间占用通常很小。
内存碎片

Redis默认的内存分配器采用jemalloc,可选的分配器还有:glibc、tcmalloc。

内存分配器为了更好地管理和重复利用内存,分配内存策略一般采用固定范围的内存块进行分配。例如jemalloc在64位系统中将内存空间划分为:小、大、巨大三个范围。

每个范围内又划分为多个小的内存块单位:

  • 小:[8byte],[16byte,32byte,48byte,…,128byte],[192byte,256byte,…,512byte],[768byte,1024byte,…,3840byte]
  • 大:[4KB,8KB,12KB,…,4072KB]
  • 巨大:[4MB,8MB,12MB,…]

比如当保存5KB对象时jemalloc可能会采用8KB的块存储,而剩下的3KB空间变为了内存碎片不能再分配给其他对象存储。内存碎片问题虽然是所有内存服务的通病, 但是jemalloc针对碎片化问题专门做了优化,一般不会存在过度碎片化的问题,正常的碎片率(mem_fragmentation_ratio)在1.03左右。

但是当存储的数据长短差异较大时,以下场景容易出现高内存碎片问题:

  • 频繁做更新操作,例如频繁对已存在的键执行append、setrange等更新操作。
  • 大量过期键删除,键对象过期删除后,释放的空间无法得到充分利用,导致碎片率上升。

出现高内存碎片问题时常见的解决方式如下:

  • 数据对齐:在条件允许的情况下尽量做数据对齐,比如数据尽量采用数字类型或者固定长度字符串等,但是这要视具体的业务而定,有些场景无法做到。
  • 安全重启:重启节点可以做到内存碎片重新整理,因此可以利用高可用架构,如Sentinel或Cluster,将碎片率过高的主节点转换为从节点,进行安全重启。

子进程内存消耗

子进程内存消耗主要指执行AOF/RDB重写时Redis创建的子进程内存消耗。Redis执行fork操作产生的子进程内存占用量对外表现为与父进程相同,理论上需要一倍的物理内存来完成重写操作。

子进程内存消耗总结如下:

  • Redis产生的子进程并不需要消耗1倍的父进程内存,实际消耗根据期间写入命令量决定,但是依然要预留出一些内存防止溢出。
  • 需要设置sysctl vm.overcommit_memory=1允许内核可以分配所有的物理内存,防止Redis进程执行fork时因系统剩余内存不足而失败。
  • 排查当前系统是否支持并开启THP,如果开启建议关闭,防止copy-on-write期间内存过度消耗。

内存管理

设置内存上限

Redis使用maxmemory参数限制最大可用内存。限制内存的目的主要有:

  • 用于缓存场景,当超出内存上限maxmemory时使用LRU等删除策略释放空间。
  • 防止所用内存超过服务器物理内存。

需要注意,maxmemory限制的是Redis实际使用的内存量,也就是used_memory统计项对应的内存。由于内存碎片率的存在,实际消耗的内存可能会比maxmemory设置的更大,实际使用时要小心这部分内存溢出。

服务器分配4个4GB的Redis进程:

服务器分配4个4GB的Redis进程

动态调整内存上限

Redis的内存上限可以通过config set maxmemory进行动态修改,即修改最大可用内存。

通过动态修改maxmemory,可以实现在当前服务器下动态伸缩Redis内存的目的。

Tips:Redis默认无限使用服务器内存,为防止极端情况下导致系统内存耗尽,建议所有的Redis进程都要配置maxmemory。

Tips:在保证物理内存可用的情况下,系统中所有Redis实例可以调整maxmemory参数来达到自由伸缩内存的目的。

内存回收策略

Redis的内存回收机制主要体现在以下两个方面:

  • 删除到达过期时间的键对象。
  • 内存使用达到maxmemory上限时触发内存溢出控制策略。
删除过期键对象

参考《过期键删除策略部分》

内存溢出控制策略

当Redis所用内存达到maxmemory上限时会触发相应的溢出控制策略。

具体策略受maxmemory-policy参数控制,Redis支持6种策略:

  1. noeviction:默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息(error)OOM command not allowed when used memory,此时Redis只响应读操作。
  2. volatile-lru:根据LRU算法删除设置了超时属性(expire)的键,直到腾出足够空间为止。如果没有可删除的键对象,回退到noeviction策略。
  3. allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止。
  4. allkeys-random:随机删除所有键,直到腾出足够空间为止。
  5. volatile-random:随机删除过期键,直到腾出足够空间为止。
  6. volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略。

内存溢出控制策略可以采用config set maxmemory-policy{policy}动态配置。 可以通过执行info stats命令查看evicted_keys指标找出当前Redis服务器已剔除的键数量。

每次Redis执行命令时如果设置了maxmemory参数,都会尝试执行回收内存操作。 当Redis一直工作在内存溢出(used_memory>maxmemory)的状态下且设置非noeviction策略时,会频繁地触发回收内存的操作,影响Redis服务器的性能。

def freeMemoryIfNeeded() :
    int mem_used, mem_tofree, mem_freed;
    //  计算当前内存总量,排除从节点输出缓冲区和 AOF 缓冲区的内存占用
    int slaves = server.slaves;
    mem_used = used_memory()-slave_output_buffer_size(slaves)-aof_rewrite_buffer_size();
    //  如果当前使用小于等于 maxmemory 退出
    if (mem_used <= server.maxmemory) :
        return REDIS_OK;
    //  如果设置内存溢出策略为 noeviction (不淘汰),返回错误。
    if (server.maxmemory_policy == 'noeviction') :
        return REDIS_ERR;
    //  计算需要释放多少内存
    mem_tofree = mem_used - server.maxmemory;
    //  初始化已释放内存量
    mem_freed = 0;
    //  根据 maxmemory-policy 策略循环删除键释放内存
    while (mem_freed < mem_tofree) :
        //  迭代 Redis 所有数据库空间
        for (int j = 0; j < server.dbnum; j++) :
            String bestkey = null;
            dict dict;
            if (server.maxmemory_policy == 'allkeys-lru' ||
                server.maxmemory_policy == 'allkeys-random'):
                // 如果策略是  allkeys-lru/allkeys-random 
                // 回收内存目标为所有的数据库键
                dict = server.db[j].dict;
            else :
                // 如果策略是 volatile-lru/volatile-random/volatile-ttl
                // 回收内存目标为带过期时间的数据库键
                dict = server.db[j].expires;
            //  如果使用的是随机策略,那么从目标字典中随机选出键
            if (server.maxmemory_policy == 'allkeys-random' ||
                server.maxmemory_policy == 'volatile-random') :
                // 随机返回被删除键
                bestkey = get_random_key(dict);
            else if (server.maxmemory_policy == 'allkeys-lru' ||
                server.maxmemory_policy == 'volatile-lru') :
                // 循环随机采样 maxmemory_samples  ( 默认 5  ) ,返回相对空闲时间最长的键
                bestkey = get_lru_key(dict);
            else if (server.maxmemory_policy == 'volatile-ttl') :
                // 循环随机采样 maxmemory_samples 次,返回最近将要过期的键
                bestkey = get_ttl_key(dict);
            //  删除被选中的键
            if (bestkey != null) :
                long delta = used_memory();
                deleteKey(bestkey);
                // 计算删除键所释放的内存量
                delta -= used_memory();
                mem_freed += delta;
                // 删除操作同步给从节点                 
                if (slaves):
                    flushSlavesOutputBuffers();
    return REDIS_OK;

频繁执行回收内存成本很高,主要包括查找可回收键和删除键的开销,如果当前Redis有从节点,回收内存操作对应的删除命令会同步到从节点,导致写放大的问题。

写入数据触发内存回收操作

建议线上Redis内存工作在maxmemory>used_memory状态下,避免频繁内存回收开销。

对于需要收缩Redis内存的场景,可以通过调小maxmemory来实现快速回收。比如对一个实际占用6GB内存的进程设置maxmemory=4GB,之后第一次执行命令时,如果使用非noeviction策略,它会一次性回收到maxmemory指定的内存量,从而达到快速回收内存的目的。

注意事项:此操作会导致数据丢失和短暂的阻塞问题,一般在缓存场景下使用。

内存优化

redisObject对象

可以使用scan和object idletime命令批量查询哪些键长时间未被访问,找出长时间不访问的键进行清理,可降低内存占用。

Redis在3.0之后对值对象是字符串且长度<=39字节的数据,内部编码为embstr类型,字符串sds和redisObject一起分配,从而只要一次内存操作即可。 高并发写入场景中,在条件允许的情况下,建议字符串长度控制在39字节以内,减少创建redisObject内存分配次数,从而提高性能。

缩减键值对象

降低Redis内存使用最直接的方式就是缩减键(key)和值(value)的长度。

  • key长度:如在设计键时,在完整描述业务情况下,键值越短越好。
    • 如user:{uid}:friends:notify:{fid}可以简化为u:{uid}:fs:nt:{fid}。
  • value长度:值对象缩减比较复杂,常见需求是把业务对象序列化成二进制数组放入Redis。
    • 首先应该在业务上精简业务对象,去掉不必要的属性避免存储无效数据。
    • 其次在序列化工具选择上,应该选择更高效的序列化工具来降低字节数组大小。
      • 以Java为例,内置的序列化方式无论从速度还是压缩比都不尽如人意,这时可以选择更高效的序列化工具,如:protostuff、kryo等。

Java常见序列化组件占用内存空间对比(单位字节):

Java常见序列化组件占用内存空间对比(单位字节)

值对象除了存储二进制数据之外,通常还会使用通用格式存储数据比如:json、xml等作为字符串存储在Redis中。 这种方式优点是方便调试和跨语言,但是同样的数据相比字节数组所需的空间更大, 在内存紧张的情况下,可以使用通用压缩算法压缩json、xml后再存入Redis,从而降低内存占用,例如使用GZIP压缩后的json可降低约60%的空间。

共享对象池

参考《对象共享部分》

是否使用整数对象池内存对比:

是否使用整数对象池内存对比

使用共享对象池后,相同的数据内存使用降低30%以上。

当设置maxmemory并启用LRU相关淘汰策略如:volatile-lru,allkeys-lru时,Redis禁止使用共享对象池。

LRU算法需要获取对象最后被访问时间,以便淘汰最长未访问数据,每个对象最后访问时间存储在redisObject对象的lru字段。 对象共享意味着多个引用共享同一个redisObject,这时lru字段也会被共享,导致无法获取每个对象的最后访问时间。 如果没有设置maxmemory,直到内存被用尽Redis也不会触发内存回收,所以共享对象池可以正常工作。

综上所述,共享对象池与maxmemory+LRU策略冲突,使用时需要注意。

对于ziplist编码的值对象,即使内部数据为整数也无法使用共享对象池,因为ziplist使用压缩且内存连续的结构,对象共享判断成本过高,

字符串优化

预分配

SDS字符串内存预分配测试:

字符串内存预分配测试

阶段二由于预分配多占用了内存。

尽量减少字符串频繁修改操作如append、setrange,改为直接使用set修改字符串,降低预分配带来的内存浪费和内存碎片化。

字符串重构

分别使用字符串和hash结构存储json数据测试内存表现:

测试内存表现

调整配置后hash类型内部编码方式变为ziplist,相比字符串更省内存且支持属性的部分操作。

编码优化

ziplist

hash、list、set、zset内部编码配置:

hash、list、set、zset内部编码配置

ziplist在hash,list,zset内存和速度测试:

ziplist在hash,list,zset内存和速度测试

针对性能要求较高的场景使用ziplist,建议长度不要超过1000,每个元素大小控制在512字节以内。

命令平均耗时使用info Commandstats命令获取,包含每个命令调用次数、总耗时、平均耗时,单位为微秒。

intset

使用intset编码的集合时,尽量保持整数范围一致,如都在int-16范围内。防止个别大整数触发集合升级操作,产生内存浪费。

intset编码的集合内存和速度表现:

ziplist编码的集合内存和速度表现

根据以上测试结果发现intset表现非常好,同样的数据内存占用只有不到hashtable编码的十分之一。

当集合内保存非整数数据时,无法使用intset实现内存优化。这时可以使用ziplist-hash类型对象模拟集合类型,hash的field当作集合中的元素,value设置为1字节占位符即可。 使用ziplist编码的hash类型依然比使用hashtable编码的集合节省大量内存。

控制键的数量

对于存储相同的数据内容利用Redis的数据结构降低外层键的数量,也可以节省大量内存。

hash分组控制键规模测试:

hash分组控制键规模测试

  • 同样的数据使用ziplist编码的hash类型存储比string类型节约内存。
  • 节省内存量随着value空间的减少越来越明显。
  • hash-ziplist类型比string类型写入耗时,但随着value空间的减少,耗时逐渐降低。

使用hash重构后节省内存量效果非常明显,特别对于存储小对象的场景,内存只有不到原来的1/5。这种内存优化技巧的关键点:

  • hash类型节省内存的原理是使用ziplist编码,如果使用hashtable编码方式反而会增加内存消耗。
  • ziplist长度需要控制在1000以内,否则由于存取操作时间复杂度在O(n)到O(n^2)之间,长列表会导致CPU消耗严重,得不偿失。
  • ziplist适合存储小对象,对于大对象不但内存优化效果不明显还会增加命令操作耗时。
  • 需要预估键的规模,从而确定每个hash结构需要存储的元素数量。
  • 根据hash长度和元素大小,调整hash-max-ziplist-entries和hash-max-ziplist-value参数,确保hash类型使用ziplist编码。

关于hash键和field键的设计:

  • 当键离散度较高时,可以按字符串位截取,把后三位作为哈希的field,之前部分作为哈希的键。
    • 如:key=1948480哈希key=group:hash:1948,哈希field=480。
  • 当键离散度较低时,可以使用哈希算法打散键
    • 如:使用crc32(key)&10000函数把所有的键映射到“0-9999”整数范围内,哈希field存储键的原始值。
  • 尽量减少hash键和field的长度,如使用部分键内容。

使用hash结构控制键的规模虽然可以大幅降低内存,但同样会带来问题,需要提前做好规避处理。

  • 客户端需要预估键的规模并设计hash分组规则,加重客户端开发成本。
  • hash重构后所有的键无法再使用超时(expire)和LRU淘汰机制自动删除,需要手动维护删除。
    • 使用ziplist+hash优化keys后,如果想使用超时删除功能,开发人员可以存储每个对象写入的时间,再通过定时任务使用hscan命令扫描数据,找出hash内超时的数据项删除即可。
  • 对于大对象,如1KB以上的对象,使用hash-ziplist结构控制键数量反而得不偿失。

数据库

  • Redis服务器的所有数据库都保存在redisServer.db数组中,而数据库的数量则由redisServer.dbnum属性保存。
  • 客户端通过修改目标数据库指针,让它指向redisServer.db数组中的不同元素来切换不同的数据库。
  • 数据库主要由dict和expires两个字典构成,其中dict字典负责保存键值对,而expires字典则负责保存键的过期时间。
  • 因为数据库由字典构成,所以对数据库的操作都是建立在字典操作之上的。
  • 数据库的键总是一个字符串对象,而值则可以是任意一种Redis对象类型,包括字符串对象、哈希表对象、集合对象、列表对象和有序集合对象,分别对应字符串键、哈希表键、集合键、列表键和有序集合键。
  • expires字典的键指向数据库中的某个键,而值则记录了数据库键的过期时间,过期时间是一个以毫秒为单位的UNIX时间戳。
  • Redis使用惰性删除和定期删除两种策略来删除过期的键:惰性删除策略只在碰到过期键时才进行删除操作,定期删除策略则每隔一段时间主动查找并删除过期键。
  • 执行SAVE命令或者BGSAVE命令所产生的新RDB文件不会包含已经过期的键。
  • 执行BGREWRITEAOF命令所产生的重写AOF文件不会包含已经过期的键。
  • 当一个过期键被删除之后,服务器会追加一条DEL命令到现有AOF文件的末尾,显式地删除过期键。
  • 当主服务器删除一个过期键之后,它会向所有从服务器发送一条DEL命令,显式地删除过期键。
  • 从服务器即使发现过期键也不会自作主张地删除它,而是等待主节点发来DEL命令,这种统一、中心化的过期键删除策略可以保证主从服务器数据的一致性。
  • 当Redis命令对数据库进行修改之后,服务器会根据配置向客户端发送数据库通知。

服务器中的数据

Redis服务器将所有数据库都保存在服务器状态redis.h/redisServer结构的db数组中,db数组的每个项都是一个redis.h/redisDb结构,每个redisDb结构代表一个数据库:

struct redisServer {
    // ...
    // 一个数组,保存着服务器中的所有数据库
    redisDb *db;
    // ...
	// 服务器的数据库数量
    int dbnum;
    // ...
};

在初始化服务器时,程序会根据服务器状态的dbnum属性来决定应该创建多少个数据库, dbnum属性的值由服务器配置的database选项决定,默认情况下,该选项的值为16,所以Redis服务器默认会创建16个数据库。

服务器数据库示例:

服务器数据库示例

切换数据库

每个Redis客户端都有自己的目标数据库,每当客户端执行数据库写命令或者数据库读命令的时候,目标数据库就会成为这些命令的操作对象。

默认情况下,Redis客户端的目标数据库为0号数据库,但客户端可以通过执行SELECT命令来切换目标数据库。

redis> SET msg "hello world"
OK
redis> GET msg
"hello world"
redis> SELECT 2
OK
redis[2]> GET msg
(nil)
redis[2]> SET msg"another world"
OK
redis[2]> GET msg
"another world"

在服务器内部,客户端状态redisClient结构的db属性记录了客户端当前的目标数据库,这个属性是一个指向redisDb结构的指针:

typedef struct redisClient {
// ...
// 
记录客户端当前正在使用的数据库
redisDb *db;
// ...
} redisClient;

客户端的目标数据库为2号数据库:

客户端的目标数据库为2号数据库

谨慎处理多数据库程序

到目前为止,Redis仍然没有可以返回客户端目标数据库的命令。虽然redis-cli客户端会在输入符旁边提示当前所使用的目标数据库。

数据库键空间

Redis是一个键值对(key-value pair)数据库服务器,服务器中的每个数据库都由一个redis.h/redisDb结构表示, 其中,redisDb结构的dict字典保存了数据库中的所有键值对,将这个字典称为键空间(key space):

typedef struct redisDb {
    // ...
    // 数据库键空间,保存着数据库中的所有键值对
    dict *dict;
    // ...
} redisDb;

键空间和用户所见的数据库是直接对应的:

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

数据库键空间例子:

redis> SET message "hello world"
OK
redis> RPUSH alphabet "a" "b" "c"
(integer)3
redis> HSET book name "Redis in Action"
(integer) 1
redis> HSET book author "Josiah L. Carlson"
(integer) 1
redis> HSET book publisher "Manning"
(integer) 1

数据库键空间例子

因为数据库的键空间是一个字典,所以所有针对数据库的操作,比如添加一个键值对到数据库,或者从数据库中删除一个键值对,又或者在数据库中获取某个键值对等,实际上都是通过对键空间字典进行操作来实现的。

添加新键、删除键、更新键、对键取值

  • 添加一个新键值对到数据库,实际上就是将一个新键值对添加到键空间字典里面,其中键为字符串对象,而值则为任意一种类型的Redis对象。
  • 删除数据库中的一个键,实际上就是在键空间里面删除键所对应的键值对对象。
  • 对一个数据库键进行更新,实际上就是对键空间里面键所对应的值对象进行更新,根据值对象的类型不同,更新的具体方法也会有所不同。
  • 对一个数据库键进行取值,实际上就是在键空间中取出键所对应的值对象,根据值对象的类型不同,具体的取值方法也会有所不同。

其它键空间操作

还有很多针对数据库本身的Redis命令,也是通过对键空间进行处理来完成的。

比如说,用于清空整个数据库的FLUSHDB命令,就是通过删除键空间中的所有键值对来实现的。又比如说,用于随机返回数据库中某个键的RANDOMKEY命令,就是通过在键空间中随机返回一个键来实现的。
另外,用于返回数据库键数量的DBSIZE命令,就是通过返回键空间中包含的键值对的数量来实现的。类似的命令还有EXISTS、RENAME、KEYS等,这些命令都是通过对键空间进行操作来实现的。

读写键空间时的维护操作

当使用Redis命令对数据库进行读写时,服务器不仅会对键空间执行指定的读写操作,还会执行一些额外的维护操作,其中包括:

  • 在读取一个键之后(读操作和写操作都要对键进行读取),服务器会根据键是否存在来更新服务器的键空间命中(hit)次数或键空间不命中(miss)次数,这两个值可以在INFO stats命令的keyspace_hits属性和keyspace_misses属性中查看。
  • 在读取一个键之后,服务器会更新键的LRU(最后一次使用)时间,这个值可以用于计算键的闲置时间,使用OBJECT idletime命令可以查看键key的闲置时间。
  • 如果服务器在读取一个键时发现该键已经过期,那么服务器会先删除这个过期键,然后才执行余下的其他操作,本章稍后对过期键的讨论会详细说明这一点。
  • 如果有客户端使用WATCH命令监视了某个键,那么服务器在对被监视的键进行修改之后,会将这个键标记为脏(dirty),从而让事务程序注意到这个键已经被修改过,第19章会详细说明这一点。
  • 服务器每次修改一个键之后,都会对脏(dirty)键计数器的值增1,这个计数器会触发服务器的持久化以及复制操作,第10章、第11章和第15章都会说到这一点。
  • 如果服务器开启了数据库通知功能,那么在对键进行修改之后,服务器将按配置发送相应的数据库通知,本章稍后讨论数据库通知功能的实现时会详细说明这一点。

设置键的生存时间或过期时间

通过EXPIRE命令或者PEXPIRE命令,客户端可以以秒或者毫秒精度为数据库中的某个键设置生存时间(Time To Live,TTL),在经过指定的秒数或者毫秒数之后,服务器就会自动删除生存时间为0的键:

redis> SET key value
OK
redis> EXPIRE key 5
(integer) 1
redis> GET key  // 5秒之内
"value"
redis> GET key  // 5秒之后
(nil)

Tips:SETEX命令可以在设置一个字符串键的同时为键设置过期时间,因为这个命令是一个类型限定的命令(只能用于字符串键)。但SETEX命令设置过期时间的原理和本章介绍的EXPIRE命令设置过期时间的原理是完全一样的。

客户端可以通过EXPIREAT命令或PEXPIREAT命令,以秒或者毫秒精度给数据库中的某个键设置过期时间(expire time)。 过期时间是一个UNIX时间戳,当键的过期时间来临时,服务器就会自动从数据库中删除这个键:

redis> SET key value
OK
redis> EXPIREAT key 1377257300
(integer) 1
redis> TIME
1)"1377257296"
2)"296543"
redis> GET key    // 1377257300之前
"value"
redis> TIME
1)"1377257303"
2)"230656"
redis> GET key    // 1377257300之后
(nil)

TTL命令和PTTL命令接受一个带有生存时间或者过期时间的键,返回这个键的剩余生存时间,也就是,返回距离这个键被服务器自动删除还有多长时间:

redis> SET key value
OK
redis> EXPIRE key 1000
(integer) 1
redis> TTL key
(integer) 997
redis> SET another_key another_value
OK
redis> TIME
1)"1377333070"
2)"761687"
redis> EXPIREAT another_key 1377333100
(integer) 1
redis> TTL another_key
(integer) 10

设置过期时间

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

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

虽然有多种不同单位和不同形式的设置命令,但实际上EXPIRE、PEXPIRE、EXPIREAT三个命令都是使用PEXPIREAT命令来实现的:

无论客户端执行的是以上四个命令中的哪一个,经过转换之后,最终的执行效果都和执行PEXPIREAT命令一样。

设置生存时间和设置过期时间的命令之间的转换:

设置生存时间和设置过期时间的命令之间的转换

保存过期时间

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

  • 过期字典的键是一个指针,这个指针指向键空间中的某个键对象(也即是某个数据库键)。
  • 过期字典的值是一个long long类型的整数,这个整数保存了键所指向的数据库键的过期时间——一个毫秒精度的UNIX时间戳。

当客户端执行PEXPIREAT命令(或者其他三个会转换成PEXPIREAT命令的命令)为一个数据库键设置过期时间时,服务器会在数据库的过期字典中关联给定的数据库键和过期时间。

typedef struct redisDb {
    // ...
    // 过期字典,保存着键的过期时间
    dict *expires;
    // ...
} redisDb;

带有过期字典的数据库例子

注意:为了展示方便,图中的键空间和过期字典中重复出现了两次alphabet键对象和book键对象。在实际中,键空间的键和过期字典的键都指向同一个键对象,所以不会出现任何重复对象,也不会浪费任何空间。

移除过期时间

PERSIST命令就是PEXPIREAT命令的反操作:PERSIST命令在过期字典中查找给定的键,并解除键和值(过期时间)在过期字典中的关联。

PERSIST命令可以移除一个键的过期时间:

redis> PEXPIREAT message 1391234400000
(integer) 1
redis> TTL message
(integer) 13893281
redis> PERSIST message
(integer) 1
redis> TTL message
(integer) -1

计算返回剩余生存时间

TTL命令以秒为单位返回键的剩余生存时间,而PTTL命令则以毫秒为单位返回键的剩余生存时间:

redis> PEXPIREAT alphabet 1385877600000
(integer) 1
redis> TTL alphabet
(integer) 8549007
redis> PTTL alphabet
(integer) 8549001011

TTL和PTTL两个命令都是通过计算键的过期时间和当前时间之间的差来实现的。

过期键的判定

通过过期字典,程序可以用以下步骤检查一个给定键是否过期:

  1. 检查给定键是否存在于过期字典:如果存在,那么取得键的过期时间。
  2. 检查当前UNIX时间戳是否大于键的过期时间:如果是的话,那么键已经过期;否则的话,键未过期。

Tips:实现过期键判定的另一种方法是使用TTL命令或者PTTL命令,如果对某个键执行TTL命令,并且命令返回的值大于等于0,那么说明该键未过期。在实际中,Redis检查键是否过期的方法和通过expires获取值判断所描述的方法一致,因为直接访问字典比执行一个命令稍微快一些。

过期键删除策略

  • 定时删除
    • 在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作。
  • 惰性删除
    • 放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。
  • 定期删除
    • 每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。

在这三种策略中,第一种和第三种为主动删除策略,而第二种则为被动删除策略。

定时删除

  • 定时删除策略对内存是最友好的:
    • 通过使用定时器,定时删除策略可以保证过期键会尽可能快地被删除,并释放过期键所占用的内存。
  • 另一方面,定时删除策略的缺点是,它对CPU时间是最不友好的:
    • 在过期键比较多的情况下,删除过期键这一行为可能会占用相当一部分CPU时间,在内存不紧张但是CPU时间非常紧张的情况下,将CPU时间用在删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。
      • 例如,如果正有大量的命令请求在等待服务器处理,并且服务器当前不缺少内存,那么服务器应该优先将CPU时间用在处理客户端的命令请求上面,而不是用在删除过期键上面。
    • 除此之外,创建一个定时器需要用到Redis服务器中的时间事件,而当前时间事件的实现方式——无序链表,查找一个事件的时间复杂度为O(N)——并不能高效地处理大量时间事件。
      • 因此,要让服务器创建大量的定时器,从而实现定时删除策略,在现阶段来说并不现实。

惰性删除

  • 惰性删除策略对CPU时间来说是最友好的:
    • 程序只会在取出键时才对键进行过期检查,这可以保证删除过期键的操作只会在非做不可的情况下进行,并且删除的目标仅限于当前处理的键,这个策略不会在删除其他无关的过期键上花费任何CPU时间。
  • 惰性删除策略的缺点是,它对内存是最不友好的:
    • 如果一个键已经过期,而这个键又仍然保留在数据库中,那么只要这个过期键不被删除,它所占用的内存就不会释放。

在使用惰性删除策略时,如果数据库中有非常多的过期键,而这些过期键又恰好没有被访问到的话,那么它们也许永远也不会被删除(除非用户手动执行FLUSHDB), 甚至可以将这种情况看作是一种内存泄漏——无用的垃圾数据占用了大量的内存,而服务器却不会自己去释放它们,这对于运行状态非常依赖于内存的Redis服务器来说,肯定不是一个好消息。

定期删除

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

  • 定时删除占用太多CPU时间,影响服务器的响应时间和吞吐量。
  • 惰性删除浪费太多内存,有内存泄漏的危险。

定期删除策略是前两种策略的一种整合和折中:

  • 定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响。
  • 除此之外,通过定期删除过期键,定期删除策略有效地减少了因为过期键而带来的内存浪费。

定期删除策略的难点是确定删除操作执行的时长和频率:

  • 如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成定时删除策略,以至于将CPU时间过多地消耗在删除过期键上面。
  • 如果删除操作执行得太少,或者执行的时间太短,定期删除策略又会和惰性删除策略一样,出现浪费内存的情况。

因此,如果采用定期删除策略的话,服务器必须根据情况,合理地设置删除操作的执行时长和执行频率。

Redis的过期键删除策略

Redis服务器实际使用的是惰性删除和定期删除两种策略:通过配合使用这两种删除策略,服务器可以很好地在合理使用CPU时间和避免浪费内存空间之间取得平衡。

惰性删除错略的实现

过期键的惰性删除策略由db.c/expireIfNeeded函数实现,所有读写数据库的Redis命令在执行之前都会调用expireIfNeeded函数对输入键进行检查:

  • 如果输入键已经过期,那么expireIfNeeded函数将输入键从数据库中删除。
  • 如果输入键未过期,那么expireIfNeeded函数不做动作。

expireIfNeeded函数就像一个过滤器,它可以在命令真正执行之前,过滤掉过期的输入键,从而避免命令接触到过期键。

另外,因为每个被访问的键都可能因为过期而被expireIfNeeded函数删除,所以每个命令的实现函数都必须能同时处理键存在以及键不存在这两种情况:

  • 当键存在时,命令按照键存在的情况执行。
  • 当键不存在或者键因为过期而被expireIfNeeded函数删除时,命令按照键不存在的情况执行。

命令调用expireIfNeeded来删除过期键:

命令调用expireIfNeeded来删除过期键

GET命令的执行过程:

GET命令的执行过程

定期删除策略的实现

过期键的定期删除策略由redis.c/activeExpireCycle函数实现,每当Redis的服务器周期性操作redis.c/serverCron函数执行时,activeExpireCycle函数就会被调用, 它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键。

activeExpireCycle函数的工作模式可以总结如下:

  • 函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键。
  • 全局变量current_db会记录当前activeExpireCycle函数检查的进度,并在下一次activeExpireCycle函数调用时,接着上一次的进度进行处理。比如说,如果当前activeExpireCycle函数在遍历10号数据库时返回了,那么下次activeExpireCycle函数执行时,将从11号数据库开始查找并删除过期键。
  • 随着activeExpireCycle函数的不断执行,服务器中的所有数据库都会被检查一遍,这时函数将current_db变量重置为0,然后再次开始新一轮的检查工作。

Redis内部维护一个定时任务,默认每秒运行10次(通过配置hz控制)。定时任务中删除过期键逻辑采用了自适应算法,根据键的过期比例、使用快慢两种速率模式回收键。

定时任务删除过期键逻辑:

定时任务删除过期键逻辑

  1. 定时任务在每个数据库空间随机检查20个键,当发现过期时删除对应的键。
  2. 如果超过检查数25%的键过期,循环执行回收逻辑直到不足25%或运行超时为止,慢模式下超时时间为25毫秒。
  3. 如果之前回收键逻辑超时,则在Redis触发内部事件之前再次以快模式运行回收过期键任务,快模式下超时时间为1毫秒且2秒内只能运行1次。
  4. 快慢两种模式内部删除逻辑相同,只是执行的超时时间不同。

AOF、RDB和复制功能对过期键的处理

生成RDB文件

在执行SAVE命令或者BGSAVE命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件中。

因此,数据库中包含过期键不会对生成新的RDB文件造成影响。

载入RDB文件

在启动Redis服务器时,如果服务器开启了RDB功能,那么服务器将对RDB文件进行载入:

  • 如果服务器以主服务器模式运行,那么在载入RDB文件时,程序会对文件中保存的键进行检查,未过期的键会被载入到数据库中,而过期键则会被忽略,所以过期键对载入RDB文件的主服务器不会造成影响。
  • 如果服务器以从服务器模式运行,那么在载入RDB文件时,文件中保存的所有键,不论是否过期,都会被载入到数据库中。不过,因为主从服务器在进行数据同步的时候,从服务器的数据库就会被清空,所以一般来讲,过期键对载入RDB文件的从服务器也不会造成影响。

AOF文件写入

当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期键而产生任何影响。
当过期键被惰性删除或者定期删除之后,程序会向AOF文件追加(append)一条DEL命令,来显式地记录该键已被删除。

如果客户端使用GET message命令,试图访问过期的message键,那么服务器将执行以下三个动作:

  1. 从数据库中删除message键。
  2. 追加一条DEL message命令到AOF文件。
  3. 向执行GET命令的客户端返回空回复。

AOF重写

和生成RDB文件时类似,在执行AOF重写的过程中,程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF文件中。

因此,数据库中包含过期键不会对AOF重写造成影响。

复制

当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制:

  • 主服务器在删除一个过期键之后,会显式地向所有从服务器发送一个DEL命令,告知从服务器删除这个过期键。
  • 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样来处理过期键。
  • 从服务器只有在接到主服务器发来的DEL命令之后,才会删除过期键。

通过由主服务器来控制从服务器统一地删除过期键,可以保证主从服务器数据的一致性,也正是因为这个原因,当一个过期键仍然存在于主服务器的数据库时,这个过期键在从服务器里的复制品也会继续存在。

数据库中都保存着同样的三个键message、xxx和yyy,其中message为过期键:

主从服务器删除过期键

如果这时有客户端向从服务器发送命令GET message,那么从服务器将发现message键已经过期,但从服务器并不会删除message键,而是继续将message键的值返回给客户端,就好像message键并没有过期一样:

主从服务器删除过期键

假设在此之后,有客户端向主服务器发送命令GET message,那么主服务器将发现键message已经过期:主服务器会删除message键,向客户端返回空回复,并向从服务器发送DEL message命令:

主从服务器删除过期键

从服务器在接收到主服务器发来的DEL message命令之后,也会从数据库中删除message键,在这之后,主从服务器都不再保存过期键message了:

主从服务器删除过期键

数据库通知

这个功能可以让客户端通过订阅给定的频道或者模式,来获知数据库中键的变化,以及数据库中命令的执行情况。

以下代码展示了客户端如何获取0号数据库中针对message键执行的所有命令:

127.0.0.1:6379> SUBSCRIBE _ _keyspace@0_ _:message
Reading messages... (press Ctrl-C to quit)

1) "subscribe"  // 订阅信息
2) "__keyspace@0__:message"     
3) (integer) 1  

1) "message"    // 执行SET命令
2) "_ _keyspace@0_ _:message"   
3) "set"        

1) "message"    // 执行EXPIRE命令
2) "_ _keyspace@0_ _:message"   
3) "expire"     

1) "message"    // 执行DEL命令
2) "_ _keyspace@0_ _:message"   
3) "del"

根据发回的通知显示,先后共有SET、EXPIRE、DEL三个命令对键message进行了操作。

  • 这一类关注“某个键执行了什么命令”的通知称为键空间通知(key-space notification),
  • 除此之外,还有另一类称为键事件通知(key-event notification)的通知,它们关注的是“某个命令被什么键执行了”。

以下是一个键事件通知的例子,代码展示了客户端如何获取0号数据库中所有执行DEL命令的键:

127.0.0.1:6379> SUBSCRIBE _ _keyevent@0_ _:del
Reading messages... (press Ctrl-C to quit)

1) "subscribe"  // 订阅信息
2) "_ _keyevent@0_ _:del"
3) (integer) 1

1) "message"    // 键key执行了DEL命令
2) "_ _keyevent@0_ _:del"
3) "key"

1) "message"    // 键number执行了DEL命令
2) "_ _keyevent@0_ _:del"
3) "number"

1) "message"    // 键message执行了DEL命令
2) "_ _keyevent@0_ _:del"
3) "message"

根据发回的通知显示,key、number、message三个键先后执行了DEL命令。

服务器配置的notify-keyspace-events选项决定了服务器所发送通知的类型:

  • 想让服务器发送所有类型的键空间通知和键事件通知,可以将选项的值设置为AKE。
  • 想让服务器发送所有类型的键空间通知,可以将选项的值设置为AK。
  • 想让服务器发送所有类型的键事件通知,可以将选项的值设置为AE。
  • 想让服务器只发送和字符串键有关的键空间通知,可以将选项的值设置为K$。
  • 想让服务器只发送和列表键有关的键事件通知,可以将选项的值设置为El。

Tips:关于数据库通知功能的详细用法,以及notify-keyspace-events选项的更多设置

发送通知

发送数据库通知的功能是由notify.c/notifyKeyspaceEvent函数实现的:

void notifyKeyspaceEvent(int type,char *event,robj *key,int dbid);
  • 函数的type参数是当前想要发送的通知的类型,程序会根据这个值来判断通知是否就是服务器配置notify-keyspace-events选项所选定的通知类型,从而决定是否发送通知。
  • event、keys和dbid分别是事件的名称、产生事件的键,以及产生事件的数据库号码,函数会根据type参数以及这三个参数来构建事件通知的内容,以及接收通知的频道名。

每当一个Redis命令需要发送数据库通知的时候,该命令的实现函数就会调用notify-KeyspaceEvent函数,并向函数传递传递该命令所引发的事件的相关信息。

void saddCommand(redisClient *c){
    // ...
    // 如果至少有一个元素被成功添加,那么执行以下程序
    if (added) {
        // ...
        // 发送事件通知
		notifyKeyspaceEvent(REDIS_NOTIFY_SET,"sadd",c->argv[1],c->db->id);
    }
    // ...
}

发送通知的实现

notifyKeyspaceEvent函数执行以下操作:

  1. server.notify_keyspace_events属性就是服务器配置notify-keyspace-events选项所设置的值,如果给定的通知类型type不是服务器允许发送的通知类型,那么函数会直接返回,不做任何动作。
  2. 如果给定的通知是服务器允许发送的通知,那么下一步函数会检测服务器是否允许发送键空间通知,如果允许的话,程序就会构建并发送事件通知。
  3. 最后,函数检测服务器是否允许发送键事件通知,如果允许的话,程序就会构建并发送事件通知。

另外,pubsubPublishMessage(发送事件通知)函数是PUBLISH命令的实现函数,执行这个函数等同于执行PUBLISH命令,订阅数据库通知的客户端收到的信息就是由这个函数发出的

数据库命令

命令 作用 示例
SELECT index 更改当前连接所选数据库 默认在0号数据库,总共16个数据库
redis 127.0.0.1:6379> SELECT 1
OK
redis 127.0.0.1:6379[1]>
MOVE key index 将key移动到指定的数据库  
FLUSHDB 清空数据库  
FLUSHALL 清空整个redis  

持久化

两种持久化策略

  • RDB:相当于照快照,默认开启
    • 优点
      • 速度快,占用空间小
      • 适用于灾难备份
    • 缺点
      • 符合要求就会照快照,占用系统资源(突然的),小内存机器不适合使用
        • 服务器正常关闭时
          • redis-cli shutdown
        • key满足条件时
          • save 900 1 每900秒,有一个key发生变化
          • save 300 10 每5分钟,至少有10个key发生变化
          • save 60 10000 每60秒,至少有10000个key发生变化
  • AOF:使用日志功能保存数据,默认关闭
    • 机制:只保存导致key发生变化的语句
      • everysec 每秒同步,默认机制(安全性低,节省资源)
      • always 每修改同步(比较安全,浪费资源)
      • no 完全交给操作系统,操作系统不繁忙时同步(不安全)
    • 优点
      • 持续性占用少量资源
    • 缺点
      • 日志文件会很大,不适用于灾难恢复
appendonly yes
# appendfsync always
appendfsync everysec
# appendfsync no

RDB持久化

  • RDB文件用于保存和还原Redis服务器所有数据库中的所有键值对数据。
  • SAVE命令由服务器进程直接执行保存操作,所以该命令会阻塞服务器。
  • BGSAVE令由子进程执行保存操作,所以该命令不会阻塞服务器。
  • 服务器状态中会保存所有用save选项设置的保存条件,当任意一个保存条件被满足时,服务器会自动执行BGSAVE命令。
  • RDB文件是一个经过压缩的二进制文件,由多个部分组成。
  • 对于不同类型的键值对,RDB文件会使用不同的方式来保存它们。

将服务器中的非空数据库以及它们的键值对统称为数据库状态。

一个包含三个非空数据库的Redis服务器,这三个数据库以及数据库中的键值对就是该服务器的数据库状态。

数据库状态示例

Redis提供了RDB持久化功能,这个功能可以将Redis在内存中的数据库状态保存到磁盘里面,避免数据意外丢失。

RDB持久化既可以手动执行,也可以根据服务器配置选项定期执行,该功能可以将某个时间点上的数据库状态保存到一个RDB文件中。

将数据库状态保存为RDB文件

RDB持久化功能所生成的RDB文件是一个经过压缩的二进制文件,通过该文件可以还原生成RDB文件时的数据库状态。

用RDB文件来还原数据库状态

RDB文件的创建与载入

有两个Redis命令可以用于生成RDB文件,一个是SAVE,另一个是BGSAVE。

  • SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求
  • BGSAVE命令会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求
redis> SAVE       # 等待直到RDB文件创建完毕
OK
redis> BGSAVE     # 派生子进程,并由子进程创建RDB文件
Background saving started

创建RDB文件的实际工作由rdb.c/rdbSave函数完成,SAVE命令和BGSAVE命令会以不同的方式调用这个函数.

和使用SAVE命令或者BGSAVE命令创建RDB文件不同,RDB文件的载入工作是在服务器启动时自动执行的,所以Redis并没有专门用于载入RDB文件的命令, 只要Redis服务器在启动时检测到RDB文件存在,它就会自动载入RDB文件。

因为AOF文件的更新频率通常比RDB文件的更新频率高,所以:

  • 如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态。
  • 只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态。

服务器判断该用哪个文件来还原数据库状态的流程:

服务器判断该用哪个文件来还原数据库状态的流程

载入RDB文件的实际工作由rdb.c/rdbLoad函数完成,这个函数和rdbSave函数之间的关系:

函数和rdbSave函数之间的关系

SAVE命令执行时的服务器状态

当SAVE命令执行时,Redis服务器会被阻塞,所以当SAVE命令正在执行时,客户端发送的所有命令请求都会被拒绝。 只有在服务器执行完SAVE命令、重新开始接受命令请求之后,客户端发送的命令才会被处理。

BGSAVE命令执行时的服务器状态

因为BGSAVE命令的保存工作是由fork的子进程执行的,所以在子进程创建RDB文件的过程中,Redis服务器仍然可以继续处理客户端的命令请求,阻塞只发生在fork阶段 但是,在BGSAVE命令执行期间,服务器处理SAVE、BGSAVE、BGREWRITEAOF三个命令的方式会和平时有所不同。

  • 首先,在BGSAVE命令执行期间,客户端发送的SAVE命令会被服务器拒绝,服务器禁止SAVE命令和BGSAVE命令同时执行是为了避免父进程(服务器进程)和子进程同时执行两个rdbSave调用,防止产生竞争条件。
  • 其次,在BGSAVE命令执行期间,客户端发送的BGSAVE命令会被服务器拒绝,因为同时执行两个BGSAVE命令也会产生竞争条件。
  • 最后,BGREWRITEAOF和BGSAVE两个命令不能同时执行
    • 如果BGSAVE命令正在执行,那么客户端发送的BGREWRITEAOF命令会被延迟到BGSAVE命令执行完毕之后执行。
    • 如果BGREWRITEAOF命令正在执行,那么客户端发送的BGSAVE命令会被服务器拒绝。

bgsave命令的运作流程:

bgsave命令的运作流程

  1. 执行bgsave命令,Redis父进程判断当前是否存在正在执行的子进程,如RDB/AOF子进程,如果存在bgsave命令直接返回。
  2. 父进程执行fork操作创建子进程,fork操作过程中父进程会阻塞,通过info stats命令查看latest_fork_usec选项,可以获取最近一个fork操作的耗时,单位为微秒。
  3. 父进程fork完成后,bgsave命令返回“Background saving started”信息并不再阻塞父进程,可以继续响应其他命令。
  4. 子进程创建RDB文件,根据父进程内存生成临时快照文件,完成后对原有文件进行原子替换。执行lastsave命令可以获取最后一次生成RDB的时间,对应info统计的rdb_last_save_time选项。
  5. 进程发送信号给父进程表示完成,父进程更新统计信息,具体见info Persistence下的rdb_*相关选项。

在执行fork的时候操作系统(类Unix操作系统)会使用写时复制(copy-on-write)策略,即fork函数发生的一刻父子进程共享同一内存数据, 当父进程要更改其中某片数据时(如执行一个写命令 ),操作系统会将该片数据复制一份以保证子进程的数据不受影响,所以新的RDB文件存储的是执行fork那一刻的内存数据。

RDB文件载入时的服务器状态

服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入工作完成为止。

自动间隔性保存

用户可以通过save选项设置多个保存条件,但只要其中任意一个条件被满足,服务器就会执行BGSAVE命令。

save 900 1
save 300 10
save 60 10000

那么只要满足以下三个条件中的任意一个,BGSAVE命令就会被执行:

  • 服务器在900秒之内,对数据库进行了至少1次修改。
  • 服务器在300秒之内,对数据库进行了至少10次修改。
  • 服务器在60秒之内,对数据库进行了至少10000次修改。
设置保存条件

如果用户没有设置,服务器使用默认条件:

save 900 1
save 300 10
save 60 10000

服务器程序会根据save选项所设置的保存条件,设置服务器状态redisServer结构的saveparams属性:

struct redisServer {
    // ...
    // 记录了保存条件的数组
    struct saveparam *saveparams;
    // ...
};

saveparams属性是一个数组,数组中的每个元素都是一个saveparam结构,每个saveparam结构都保存了一个save选项设置的保存条件:

struct saveparam {
    // 秒数
    time_t seconds;
    // 修改数
    int changes;
};

服务器状态中的保存条件

dirty计数器和lastsave属性

除了saveparams数组之外,服务器状态还维持着一个dirty计数器,以及一个lastsave属性:

  • dirty计数器记录距离上一次成功执行SAVE命令或者BGSAVE命令之后,服务器对数据库状态(服务器中的所有数据库)进行了多少次修改(包括写入、删除、更新等操作)。
  • lastsave属性是一个UNIX时间戳,记录了服务器上一次成功执行SAVE命令或者BGSAVE命令的时间。
struct redisServer {
    // ...
    // 修改计数器
    long long dirty;
    // 上一次执行保存的时间
    time_t lastsave;
    // ...
};
# 程序会将dirty计数器的值增加3。
redis> SADD database Redis MongoDB MariaDB
(integer) 3

服务器状态示例

检查保存条件是否满足

Redis的服务器周期性操作函数serverCron默认每隔100毫秒就会执行一次,该函数用于对正在运行的服务器进行维护, 它的其中一项工作就是检查save选项所设置的保存条件是否已经满足,如果满足的话,就执行BGSAVE命令。

程序会遍历并检查saveparams数组中的所有保存条件,只要有任意一个条件被满足,那么服务器就会执行BGSAVE命令。

RDB文件结构

一个完整RDB文件所包含的各个部分:

RDB文件结构

Notice:图中用全大写单词标示常量,用全小写单词标示变量和数据。

  • RDB文件的最开头是REDIS部分,这个部分的长度为5字节,保存着“REDIS”五个字符。
    • 通过这五个字符,程序可以在载入文件时,快速检查所载入的文件是否RDB文件。
    • RDB文件保存的是二进制数据,图中用”REDIS”符号代表’R’、’E’、’D’、’I’、’S’五个字符
  • db_version长度为4字节,它的值是一个字符串表示的整数,这个整数记录了RDB文件的版本号
    • 比如”0006”就代表RDB文件的版本为第六版。
  • databases部分包含着零个或任意多个数据库,以及各个数据库中的键值对数据
    • 如果服务器的数据库状态为空(所有数据库都是空的),那么这个部分也为空,长度为0字节。
    • 如果服务器的数据库状态为非空(有至少一个数据库非空),那么这个部分也为非空,根据数据库所保存键值对的数量、类型和内容不同,这个部分的长度也会有所不同。
  • EOF常量的长度为1字节
    • 这个常量标志着RDB文件正文内容的结束,当读入程序遇到这个值的时候,它知道所有数据库的所有键值对都已经载入完毕了。
  • check_sum是一个8字节长的无符号整数,保存着一个校验和,这个校验和是程序通过对REDIS、db_version、databases、EOF四个部分的内容进行计算得出的。
    • 服务器在载入RDB文件时,会将载入数据所计算出的校验和与check_sum所记录的校验和进行对比,以此来检查RDB文件是否有出错或者损坏的情况出现。
databases部分

一个RDB文件的databases部分可以保存任意多个非空数据库。

带有两个非空数据库的RDB文件示例

每个非空数据库在RDB文件中都可以保存为SELECTDB、db_number、key_value_pairs三个部分:

RDB文件中的数据库结构

  • SELECTDB常量的长度为1字节
    • 当读入程序遇到这个值的时候,它知道接下来要读入的将是一个数据库号码。
  • db_number保存着一个数据库号码,根据号码的大小不同,这个部分的长度可以是1字节、2字节或者5字节。
    • 当程序读入db_number部分之后,服务器会调用SELECT命令,根据读入的数据库号码进行数据库切换,使得之后读入的键值对可以载入到正确的数据库中。
  • key_value_pairs部分保存了数据库中的所有键值对数据,如果键值对带有过期时间,那么过期时间也会和键值对保存在一起。
    • 根据键值对的数量、类型、内容以及是否有过期时间等条件的不同,key_value_pairs部分的长度也会有所不同。

RDB文件中的数据库结构示例

databases的key_values_pairs部分

RDB文件中的每个key_value_pairs部分都保存了一个或以上数量的键值对,如果键值对带有过期时间的话,那么键值对的过期时间也会被保存在内。

不带过期时间的键值对在RDB文件中由TYPE、key、value三部分组成:

不带过期时间的键值对

TYPE记录了value的类型,长度为1字节,值可以是以下常量的其中一个:

  • REDIS_RDB_TYPE_STRING
  • REDIS_RDB_TYPE_LIST
  • REDIS_RDB_TYPE_SET
  • REDIS_RDB_TYPE_ZSET
  • REDIS_RDB_TYPE_HASH
  • REDIS_RDB_TYPE_LIST_ZIPLIST
  • REDIS_RDB_TYPE_SET_INTSET
  • REDIS_RDB_TYPE_ZSET_ZIPLIST
  • REDIS_RDB_TYPE_HASH_ZIPLIST

以上列出的每个TYPE常量都代表了一种对象类型或者底层编码,当服务器读入RDB文件中的键值对数据时,程序会根据TYPE的值来决定如何读入和解释value的数据。

key和value分别保存了键值对的键对象和值对象:

  • 其中key总是一个字符串对象,它的编码方式和REDIS_RDB_TYPE_STRING类型的value一样。根据内容长度的不同,key的长度也会有所不同。
  • 根据TYPE类型的不同,以及保存内容长度的不同,保存value的结构和长度也会有所不同,本节稍后会详细说明每种TYPE类型的value结构保存方式。

带有过期时间的键值对:

带有过期时间的键值对

带有过期时间的键值对中新增的EXPIRETIME_MS和ms,它们的意义如下:

  • EXPIRETIME_MS常量的长度为1字节,它告知读入程序,接下来要读入的将是一个以毫秒为单位的过期时间。
  • ms是一个8字节长的带符号整数,记录着一个以毫秒为单位的UNIX时间戳,这个时间戳就是键值对的过期时间。

带有过期时间的集合键值对示例:

带有过期时间的集合键值对示例

key_values_pairs的value编码

RDB文件中的每个value部分都保存了一个值对象,每个值对象的类型都由与之对应的TYPE记录,根据类型的不同,value部分的结构、长度也会有所不同。

字符串对象

如果TYPE的值为REDIS_RDB_TYPE_STRING,那么value保存的就是一个字符串对象,字符串对象的编码可以是REDIS_ENCODING_INT或者REDIS_ENCODING_RAW。

  • 如果字符串对象的编码为REDIS_ENCODING_INT,那么说明对象中保存的是长度不超过32位的整数。
    • 其中,ENCODING的值可以是REDIS_RDB_ENC_INT8、REDIS_RDB_ENC_INT16或者REDIS_RDB_ENC_INT32三个常量的其中一个,它们分别代表RDB文件使用8位(bit)、16位或者32位来保存整数值integer。
  • 如果字符串对象的编码为REDIS_ENCODING_RAW,那么说明对象所保存的是一个字符串值,根据字符串长度的不同,有压缩和不压缩两种方法来保存这个字符串:
    • 如果字符串的长度小于等于20字节,那么这个字符串会直接被原样保存。
    • 如果字符串的长度大于20字节,那么这个字符串会被压缩之后再保存。

注意事项:以上两个条件是在假设服务器打开了RDB文件压缩功能的情况下进行的,如果服务器关闭了RDB文件压缩功能,那么RDB程序总以无压缩的方式保存字符串值。具体信息可以参考redis.conf文件中关于rdbcompression选项的说明。

INT编码字符串对象的保存结构:

INT编码字符串对象的保存结构

用8位来保存整数的例子:

字符串对象中保存的是可以用8位来保存的整数123。

用8位来保存整数的例子

无压缩字符串的保存结构:

无压缩字符串的保存结构

其中,string部分保存了字符串值本身,而len保存了字符串值的长度。

无压缩的字符串:

无压缩的字符串

压缩后字符串的保存结构:

压缩后字符串的保存结构

其中,REDIS_RDB_ENC_LZF常量标志着字符串已经被LZF算法压缩过了, 读入程序在碰到这个常量时,会根据之后的compressed_len、origin_len和compressed_string三部分,对字符串进行解压缩: 其中compressed_len记录的是字符串被压缩之后的长度,而origin_len记录的是字符串原来的长度,compressed_string记录的则是被压缩之后的字符串。

压缩后的字符串:

压缩后的字符串

列表对象

如果TYPE的值为REDIS_RDB_TYPE_LIST,那么value保存的就是一个REDIS_ENCODING_LINKEDLIST编码的列表对象。

LINKEDLIST编码列表对象的保存结构:

LINKEDLIST编码列表对象的保存结构

  • list_length记录了列表的长度,它记录列表保存了多少个项(item),读入程序可以通过这个长度知道自己应该读入多少个列表项。
  • 以item开头的部分代表列表的项,因为每个列表项都是一个字符串对象,所以程序会以处理字符串对象的方式来保存和读入列表项。

保存LINKEDLIST编码列表的例子:

保存LINKEDLIST编码列表的例子

结构中的第一个数字3是列表的长度,之后跟着的分别是第一个列表项、第二个列表项和第三个列表项,其中:

  • 第一个列表项的长度为5,内容为字符串”hello”。
  • 第二个列表项的长度也为5,内容为字符串”world”。
  • 第三个列表项的长度为1,内容为字符串”!”。
集合对象

如果TYPE的值为REDIS_RDB_TYPE_SET,那么value保存的就是一个REDIS_ENCODING_HT编码的集合对象。

HT编码集合对象的保存结构:

HT编码集合对象的保存结构

  • set_size是集合的大小,它记录集合保存了多少个元素,读入程序可以通过这个大小知道自己应该读入多少个集合元素。
  • elem开头的部分代表集合的元素,因为每个集合元素都是一个字符串对象,所以程序会以处理字符串对象的方式来保存和读入集合元素。

保存HT编码集合的例子:

保存HT编码集合的例子

结构中的第一个数字4记录了集合的大小,之后跟着的是集合的四个元素:

  • 第一个元素的长度为5,值为”apple”。
  • 第二个元素的长度为6,值为”banana”。
  • 第三个元素的长度为3,值为”cat”。
  • 第四个元素的长度为3,值为”dog”。
哈希表对象

如果TYPE的值为REDIS_RDB_TYPE_HASH,那么value保存的就是一个REDIS_ENCODING_HT编码的集合对象。

HT编码哈希表对象的保存结构:

HT编码哈希表对象的保存结构

  • hash_size记录了哈希表的大小,也即是这个哈希表保存了多少键值对,读入程序可以通过这个大小知道自己应该读入多少个键值对。
  • key_value_pair开头的部分代表哈希表中的键值对,键值对的键和值都是字符串对象,所以程序会以处理字符串对象的方式来保存和读入键值对。

结构中的每个键值对都以键紧挨着值的方式排列在一起:

更详细的HT编码哈希表对象的保存结构

保存HT编码哈希表的例子:

保存HT编码哈希表的例子

在这个示例结构中,第一个数字2记录了哈希表的键值对数量,之后跟着的是两个键值对:

  • 第一个键值对的键是长度为1的字符串”a”,值是长度为5的字符串”apple”。
  • 第二个键值对的键是长度为1的字符串”b”,值是长度为6的字符串”banana”。
有序集合对象

如果TYPE的值为REDIS_RDB_TYPE_ZSET,那么value保存的就是一个REDIS_ENCODING_SKIPLIST编码的有序集合对象。

SKIPLIST编码有序集合对象的保存结构:

SKIPLIST编码有序集合对象的保存结构

  • sorted_set_size记录了有序集合的大小,也即是这个有序集合保存了多少元素,读入程序需要根据这个值来决定应该读入多少有序集合元素。
  • element开头的部分代表有序集合中的元素,每个元素又分为成员(member)和分值(score)两部分,成员是一个字符串对象,分值则是一个double类型的浮点数,程序在保存RDB文件时会先将分值转换成字符串对象,然后再用保存字符串对象的方法将分值保存起来。

成员和分值的保存结构:

更详细的SKIPLIST编码有序集合对象的保存结构

保存SKIPLIST编码有序集合的例子:

保存SKIPLIST编码有序集合的例子

在这个示例结构中,第一个数字2记录了有序集合的元素数量,之后跟着的是两个有序集合元素:

  • 第一个元素的成员是长度为2的字符串”pi”,分值被转换成字符串之后变成了长度为4的字符串”3.14”。
  • 第二个元素的成员是长度为1的字符串”e”,分值被转换成字符串之后变成了长度为3的字符串”2.7”。
INTSET编码的对象

如果TYPE的值为REDIS_RDB_TYPE_SET_INTSET,那么value保存的就是一个整数集合对象,RDB文件保存这种对象的方法是,先将整数集合转换为字符串对象,然后将这个字符串对象保存到RDB文件里面。

如果程序在读入RDB文件的过程中,碰到由整数集合对象转换成的字符串对象,那么程序会根据TYPE值的指示,先读入字符串对象,再将这个字符串对象转换成原来的整数集合对象。

ZIPLIST编码的列表、哈希表或有序集合

如果TYPE的值为REDIS_RDB_TYPE_LIST_ZIPLIST、REDIS_RDB_TYPE_HASH_ZIPLIST或者REDIS_RDB_TYPE_ZSET_ZIPLIST,那么value保存的就是一个压缩列表对象。

RDB文件保存这种对象的方法是:

  1. 将压缩列表转换成一个字符串对象。
  2. 将转换所得的字符串对象保存到RDB文件。

如果程序在读入RDB文件的过程中,碰到由压缩列表对象转换成的字符串对象,那么程序会根据TYPE值的指示,执行以下操作:

  1. 读入字符串对象,并将它转换成原来的压缩列表对象。
  2. 根据TYPE的值,设置压缩列表对象的类型.

由于TYPE的存在,RDB读入程序总可以将读入并转换之后得出的压缩列表设置成原来的类型。

分析RDB文件

使用od命令来分析Redis服务器产生的RDB文件,该命令可以用给定的格式转存(dump)并打印输入文件。

比如说,给定-c参数可以以ASCII编码的方式打印输入文件,给定-x参数可以以十六进制的方式打印输入文件,具体的信息可以参考od命令的文档。

因为Redis本身带有RDB文件检查工具redis-check-dump,网上也能找到很多处理RDB文件的工具,所以人工分析RDB文件的内容并不是学习Redis所必须掌握的技能。

不包含任何键值对的RDB文件
redis> FLUSHALL
OK
redis> SAVE
OK
$ od -c dump.rdb
0000000   R E D I S 0 0 0 6 377 334 263 C 360 Z 334
0000020 362 V
0000022
  • 五个字节的”REDIS”字符串。
  • 四个字节的版本号(db_version)。
  • 一个字节的EOF常量。
  • 八个字节的校验和(check_sum)。

最开头的是“REDIS”字符串,之后的0006是版本号,再之后的一个字节377代表EOF常量,最后的334 263 C 360 Z 334 362 V八个字节则代表RDB文件的校验和。

包含过期时间的字符串键的RDB文件
$ od -c dump.rdb
0000000   R   E  D   I   S   0   0   0  6 376 \0 374  \  2 365 336
0000020   @ 001 \0  \0  \0 003   M   S  G 005  H   E  L  L   O 377
0000040 212 231  x 247 252   } 021 306
0000050
  • REDIS 0006
    • RDB文件标志和版本号。
  • 376 \0
    • 切换到0号数据库。
  • 374
    • 代表特殊值EXPIRETIME_MS。
  • \2 365 336 @ 001 \0 \0
    • 代表八字节长的过期时间。
  • \0
    • \0表示这是一个字符串键,
  • 003 M S G
    • 003是键的长度,MSG是键。
  • 005 H E L L O
    • 005是值的长度,HELLO是值。
  • 377
    • 代表EOF常量。
  • 212 231 x 247 252 } 021 306
    • 代表八字节长的校验和。
包含一个集合的RDB文件
$ od -c dump.rdb
0000000   R   E   D   I   S  0   0   0 6 376 \0 002 004 L   A   N
0000020   G 003 004   R   U  B   Y 004 J   A  V   A 001 C 377 202
0000040 312   r 352 346 305  * 023
0000047
  • REDIS 0006
    • RDB文件标志和版本号。
  • 376 \0
    • 切换到0号数据库。
  • 002 004 L A N G
    • 002是常量REDIS_RDB_TYPE_SET(这个常量的实际值为整数2),表示这是一个哈希表编码的集合键,004表示键的长度,LANG是键的名字。
  • 003
    • 集合的大小,说明这个集合包含三个元素。
  • 004 R U B Y
    • 集合的第一个元素。
  • 004 J A V A
    • 集合的第二个元素。
  • 001 C
    • 集合的第三个元素。
  • 377
    • 代表常量EOF。
  • 202 312 r 352 346 305 * 023
    • 代表校验和。

RDB的优缺点

RDB的优点:

  • RDB是一个紧凑压缩的二进制文件,代表Redis在某个时间点上的数据快照。非常适用于备份,全量复制等场景。比如每6小时执行bgsave备份,并把RDB文件拷贝到远程机器或者文件系统中(如hdfs),用于灾难恢复。
  • Redis加载RDB恢复数据远远快于AOF的方式。

RDB的缺点:

  • RDB方式数据没办法做到实时持久化/秒级持久化。因为bgsave每次运行都要执行fork操作创建子进程,属于重量级操作,频繁执行成本过高。
  • RDB文件使用特定二进制格式保存,Redis版本演进过程中有多个格式的RDB版本,存在老版本Redis服务无法兼容新版RDB格式的问题。

针对RDB不适合实时持久化的问题,Redis提供了AOF持久化方式来解决。

AOF持久化

  • AOF文件通过保存所有修改数据库的写命令请求来记录服务器的数据库状态。
  • AOF文件中的所有命令都以Redis命令请求协议的格式保存。
  • 命令请求会先保存到AOF缓冲区里面,之后再定期写入并同步到AOF文件。
  • appendfsync选项的不同值对AOF持久化功能的安全性以及Redis服务器的性能有很大的影响。
  • 服务器只要载入并重新执行保存在AOF文件中的命令,就可以还原数据库本来的状态。
  • AOF重写可以产生一个新的AOF文件,这个新的AOF文件和原有的AOF文件所保存的数据库状态一样,但体积更小。
  • AOF重写是一个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序无须对现有AOF文件进行任何读入、分析或者写入操作。
  • 在执行BGREWRITEAOF命令时,Redis服务器会维护一个AOF重写缓冲区,该缓冲区会在子进程创建新AOF文件期间,记录服务器执行的所有写命令。当子进程完成创建新AOF文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新AOF文件的末尾,使得新旧两个AOF文件所保存的数据库状态一致。最后,服务器用新的AOF文件替换旧的AOF文件,以此来完成AOF文件重写操作。

Redis还提供了AOF(Append Only File)持久化功能。与RDB持久化通过保存数据库中的键值对来记录数据库状态不同,AOF持久化是通过保存Redis服务器所执行的写命令来记录数据库状态的.

AOF持久化

服务器在启动时,可以通过载入和执行AOF文件中保存的命令来还原服务器关闭之前的数据库状态。

被写入AOF文件的所有命令都是以Redis的命令请求协议格式保存的,因为Redis的命令请求协议是纯文本格式,所以可以直接打开一个AOF文件,观察里面的内容。

redis> SET msg "hello"
OK
redis> SADD fruits "apple" "banana" "cherry"
(integer) 3
redis> RPUSH numbers 128 256 512
(integer) 3

对于之前执行的三个写命令来说,服务器将产生包含以下内容的AOF文件:

*2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n
*3\r\n$3\r\nSET\r\n$3\r\nmsg\r\n$5\r\nhello\r\n
*5\r\n$4\r\nSADD\r\n$6\r\nfruits\r\n$5\r\napple\r\n$6\r\nbanana\r\n$6\r\ncherry\r\n
*5\r\n$5\r\nRPUSH\r\n$7\r\nnumbers\r\n$3\r\n128\r\n$3\r\n256\r\n$3\r\n512\r\n

在这个AOF文件里面,除了用于指定数据库的SELECT命令是服务器自动添加的之外,其他都是之前通过客户端发送的命令。

AOF持久化的实现

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

AOF工作流程

  1. 所有的写入命令会追加到aof_buf(缓冲区)中。
  2. AOF缓冲区根据对应的策略向硬盘做同步操作。
  3. 随着AOF文件越来越大,需要定期对AOF文件进行重写,达到压缩的目的。
  4. 当Redis服务器重启时,可以加载AOF文件进行数据恢复。
命令追加

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

struct redisServer {
    // ...
    // AOF缓冲区
    sds aof_buf;
    // ...
};
redis> RPUSH NUMBERS ONE TWO THREE
(integer) 3

那么服务器在执行这个SET命令之后,会将以下协议内容追加到aof_buf缓冲区的末尾:

*5\r\n$5\r\nRPUSH\r\n$7\r\nNUMBERS\r\n$3\r\nONE\r\n$3\r\nTWO\r\n$5\r\nTHREE\r\n

AOF为什么直接采用文本协议格式?

  • 文本协议具有很好的兼容性。
  • 开启AOF后,所有写入命令都包含追加操作,直接采用协议格式,避免了二次处理开销。
  • 文本协议具有可读性,方便直接修改和处理。

AOF为什么把命令追加到aof_buf中?

  • Redis使用单线程响应命令,如果每次写AOF文件命令都直接追加到硬盘,那么性能完全取决于当前硬盘负载。
  • 先写入缓冲区aof_buf中,还有另一个好处,Redis可以提供多种缓冲区同步硬盘的策略,在性能和安全性方面做出平衡。
AOF文件的写入与同步

Redis的服务器进程就是一个事件循环(loop),这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复, 而时间事件则负责执行像serverCron函数这样需要定时运行的函数。

因为服务器在处理文件事件时可能会执行写命令,使得一些内容被追加到aof_buf缓冲区里面, 所以在服务器每次结束一个事件循环之前,它都会调用flushAppendOnlyFile函数,考虑是否需要将aof_buf缓冲区中的内容写入和保存到AOF文件里面

这个过程可以用以下伪代码表示:

def eventLoop():
    while True:
        # 处理文件事件,接收命令请求以及发送命令回复
        # 处理命令请求时可能会有新内容被追加到 aof_buf 缓冲区中
        processFileEvents()
        # 处理时间事件
        processTimeEvents()
        # 考虑是否要将 aof_buf 中的内容写入和保存到 AOF 文件里面
        flushAppendOnlyFile()

flushAppendOnlyFile函数的行为由服务器配置的appendfsync选项的值来决定:

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

如果用户没有主动为appendfsync选项设置值,那么appendfsync选项的默认值为everysec,更多信息参考Redis项目附带的示例配置文件redis.conf。

如果这时flushAppendOnlyFile函数被调用,假设服务器当前appendfsync选项的值为everysec,并且距离上次同步AOF文件已经超过一秒钟,那么服务器会先将aof_buf中的内容写入到AOF文件中,然后再对AOF文件进行同步。

AOF持久化的效率和安全性

  • 当appendfsync的值为always时,服务器在每个事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件,并且同步AOF文件,所以always的效率是appendfsync选项三个值当中最慢的一个,但从安全性来说,always也是最安全的,因为即使出现故障停机,AOF持久化也只会丢失一个事件循环中所产生的命令数据。
  • 当appendfsync的值为everysec时,服务器在每个事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件,并且每隔一秒就要在子线程中对AOF文件进行一次同步。从效率上来讲,everysec模式足够快,并且就算出现故障停机,数据库也只丢失一秒钟的命令数据。
  • 当appendfsync的值为no时,服务器在每个事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件,至于何时对AOF文件进行同步,则由操作系统控制。因为处于no模式下的flushAppendOnlyFile调用无须执行同步操作,所以该模式下的AOF文件写入速度总是最快的,不过因为这种模式会在系统缓存中积累一段时间的写入数据,所以该模式的单次同步时长通常是三种模式中时间最长的。

从平摊操作的角度来看,no模式和everysec模式的效率类似,当出现故障停机时,使用no模式的服务器将丢失上次同步AOF文件之后的所有写命令数据。

AOF文件的载入与数据还原

因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。

Redis读取AOF文件并还原数据库状态的详细步骤如下:

  1. 创建一个不带网络连接的伪客户端(fake client):因为Redis的命令只能在客户端上下文中执行,而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端执行命令的效果完全一样。
  2. 从AOF文件中分析并读取出一条写命令。
  3. 使用伪客户端执行被读出的写命令。
  4. 一直执行步骤2和步骤3,直到AOF文件中的所有写命令都被处理完毕为止。

AOF文件载入过程:

AOF文件载入过程

AOF重写

因为AOF持久化是通过保存被执行的写命令来记录数据库状态的,所以随着服务器运行时间的流逝,AOF文件中的内容会越来越多,文件的体积也会越来越大, 如果不加以控制的话,体积过大的AOF文件很可能对Redis服务器、甚至整个宿主计算机造成影响,并且AOF文件的体积越大,使用AOF文件来进行数据还原所需的时间就越多。

为了解决AOF文件体积膨胀的问题,Redis提供了AOF文件重写(rewrite)功能。通过该功能,Redis服务器可以创建一个新的AOF文件来替代现有的AOF文件, 新旧两个AOF文件所保存的数据库状态相同,但新AOF文件不会包含任何浪费空间的冗余命令,所以新AOF文件的体积通常会比旧AOF文件的体积要小得多。

AOF文件重写的实现

虽然Redis将生成新AOF文件替换旧AOF文件的功能命名为“AOF文件重写”,但实际上,AOF文件重写并不需要对现有的AOF文件进行任何读取、分析或者写入操作,这个功能是通过读取服务器当前的数据库状态来实现的。

除了列表键和集合键之外,其他所有类型的键都可以用同样的方法去减少AOF文件中的命令数量:

首先从数据库中读取键现在的值,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令,这就是AOF重写功能的实现原理。

生成的新AOF文件只包含还原当前数据库状态所必须的命令,所以新AOF文件不会浪费任何硬盘空间。

在实际中,为了避免在执行命令时造成客户端输入缓冲区溢出,重写程序在处理列表、哈希表、集合、有序集合这四种可能会带有多个元素的键时,会先检查键所包含的元素数量, 如果元素的数量超过了redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD常量的值,那么重写程序将使用多条命令来记录键的值,而不单单使用一条命令。

AOF文件后台重写

上面介绍的AOF重写程序aof_rewrite函数可以很好地完成创建一个新AOF文件的任务, 但是,因为这个函数会进行大量的写入操作,所以调用这个函数的线程将被长时间阻塞,因为Redis服务器使用单个线程来处理命令请求, 所以如果由服务器直接调用aof_rewrite函数的话,那么在重写AOF文件期间,服务期将无法处理客户端发来的命令请求。

Redis不希望AOF重写造成服务器无法处理请求,所以Redis决定将AOF重写程序放到子进程里执行,这样做可以同时达到两个目的:

  • 子进程进行AOF重写期间,服务器进程(父进程)可以继续处理命令请求。
  • 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性。

不过,使用子进程也有一个问题需要解决,因为子进程在进行AOF重写期间,服务器进程还需要继续处理命令请求, 而新的命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的AOF文件所保存的数据库状态不一致。

为了解决这种数据不一致问题,Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令之后,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区,。

服务器同时将命令发送给AOF文件和AOF重写缓冲区

在子进程执行AOF重写期间,服务器进程需要执行以下三个工作:

  1. 执行客户端发来的命令。
  2. 将执行后的写命令追加到AOF缓冲区。
  3. 将执行后的写命令追加到AOF重写缓冲区。

这样一来可以保证:

  • AOF缓冲区的内容会定期被写入和同步到AOF文件,对现有AOF文件的处理工作会如常进行。
  • 从创建子进程开始,服务器执行的所有写命令都会被记录到AOF重写缓冲区里面。

当子进程完成AOF重写工作之后,它会向父进程发送一个信号,父进程在接到该信号之后,会调用一个信号处理函数,并执行以下工作:

  1. 将AOF重写缓冲区中的所有内容写入到新AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前的数据库状态一致。
  2. 对新的AOF文件进行改名,原子地(atomic)覆盖现有的AOF文件,完成新旧两个AOF文件的替换。

这个信号处理函数执行完毕之后,父进程就可以继续像往常一样接受命令请求了。

在整个AOF后台重写过程中,只有信号处理函数执行时会对服务器进程(父进程)造成阻塞,在其他时候,AOF后台重写都不会阻塞父进程,这将AOF重写对服务器性能造成的影响降到了最低。

AOF重写运作流程

  • 执行AOF重写请求。
    • 如果当前进程正在执行AOF重写,请求不执行并返回错误响应。
    • 如果当前进程正在执行bgsave操作,重写命令延迟到bgsave完成之后再执行
  • 父进程执行fork创建子进程,开销等同于bgsave过程。
    • 主进程fork操作完成后,继续响应其他命令。所有修改命令依然写入AOF缓冲区并根据appendfsync策略同步到硬盘,保证原有AOF机制正确性。
    • 由于fork操作运用写时复制技术,子进程只能共享fork操作时的内存数据。由于父进程依然响应命令,Redis使用“AOF重写缓冲区”保存这部分新数据,防止新AOF文件生成期间丢失这部分数据。
  • 子进程根据内存快照,按照命令合并规则写入到新的AOF文件。每次批量写入硬盘数据量由配置aof-rewrite-incremental-fsync控制,默认为32MB,防止单次刷盘数据过多造成硬盘阻塞。
  • 新AOF文件写入完成后,子进程发送信号给父进程,父进程更新统计信息,具体见info persistence下的aof_*相关统计。
    • 父进程把AOF重写缓冲区的数据写入到新的AOF文件。
    • 使用新AOF文件替换老文件,完成AOF重写。

以上就是AOF后台重写,也即是BGREWRITEAOF命令的实现原理。

重写触发
  • 手动:调用bgwriteaof命令
  • 自动:根据auto-aof-rewrite-min-size和auto-aof-rewrite-percentage参数确定自动触发时机。
    • 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-min-size && (aof_current_size-aof_base_size) / aof_base_size >= auto-aof-rewrite-percentage

其中aof_current_size和aof_base_size可以在info Persistence统计信息中查看。

重新加载

AOF和RDB文件都可以用于服务器重启时的数据恢复。

Redis持久化文件加载流程

文件校验

加载损坏的AOF文件时会拒绝启动。

对于错误格式的AOF文件,先进行备份,然后采用redis-check-aof –fix命令进行修复,修复后使用diff -u对比数据的差异,找出丢失的数据,有些可以人工修改补全。

AOF文件可能存在结尾不完整的情况,比如机器突然掉电导致AOF尾部文件命令写入不全。Redis为我们提供了aof-load-truncated配置来兼容这种情况,默认开启。 加载AOF时,当遇到此问题时会忽略并继续启动,同时打印如下警告日志。

问题定位与优化

fork操作

当Redis做RDB或AOF重写时,一个必不可少的操作就是执行fork操作创建子进程,对于大多数操作系统来说fork是个重量级操作。
虽然fork创建的子进程不需要拷贝父进程的物理内存空间,但是会复制父进程的空间内存页表。

例如对于10GB的Redis进程,需要复制大约20MB的内存页表,因此fork操作耗时跟进程总内存量息息相关,如果使用虚拟化技术,特别是Xen虚拟机,fork操作会更耗时。

fork耗时问题定位:

对于高流量的Redis实例OPS可达5万以上,如果fork操作耗时在秒级别将拖慢Redis几万条命令执行,对线上应用延迟影响非常明显。 正常情况下fork耗时应该是每GB消耗20毫秒左右。可以在info stats统计中查latest_fork_usec指标获取最近一次fork操作耗时,单位微秒。

如何改善fork操作的耗时:

  1. 优先使用物理机或者高效支持fork操作的虚拟化技术,避免使用Xen。
  2. 控制Redis实例最大可用内存,fork耗时跟内存量成正比,线上建议每个Redis实例内存控制在10GB以内。
  3. 合理配置Linux内存分配策略,避免物理内存不足导致fork失败。
  4. 降低fork操作的频率,如适度放宽AOF自动触发时机,避免不必要的全量复制等。

子进程开销监控和优化

CPU
  • CPU开消分析
    • 子进程负责把进程内的数据分批写入文件,这个过程会给你输入CPU密集操作,通常子进程对单核CPU的利用率接近90%
  • CPU消耗优化
    • Redis是CPU密集型服务,不要做绑定单核CPU操作,由于子进程非常消耗CPU,会和父进程产生单核资源竞争。
    • 不要和其他CPU密集型服务部署在一起。
    • 如果部署多个实例,尽量保证同一时刻只有一个子进程执行重写工作。
内存
  • 内存消耗分析
    • 子进程通过fork操作产生,占用内存大小等同于父进程,理论上需要两倍内存来完成持久化操作。
      • Linux有写时复制机制。子进程会共享共同的物理页内存,当父进程处理写请求时会把要修改的页创建副本,而子进程在fork操作过程中共享整个父进程的内存快照。
  • 内存消耗监控
    • 日志中的cpoy-on-wirte的大小,加上aof重写缓冲区的大小
  • 内存消耗优化
    • 如果部署多个实例,尽量保证同一时刻只有一个子进程执行重写工作。
    • 避免大量写入时做子进程重写操作,这样导致父进程维护大量页副本,造成内存消耗。
    • 关闭Linux的Transparent HUge Pages(THP)。
硬盘
  • 硬盘消耗分析
    • 把AOF或者RDB文件写入硬盘持久化,定会造成硬盘压力。
      • 使用sar、iostat、iotop等命令监控
  • 硬盘开销优化
    • 不要和其他高硬盘负载的应用部署在一起,如存储服务和消息队列等
    • AOF重写消耗大量的I/O,开启配置no-appendfsync-on-rewrite,默认关闭。表示AOF重写期间不做fsync操作。
      • 有丢失重写期间数据的风险
    • 当开启AOF功能的Redis用于高流量写入场景时,如果使用普通机械硬盘,写入吞吐一般在100MB/s左右,这是Redis的瓶颈主要在AOF同步磁盘上
    • 对于单机配置多个实例的情况,可以配置不同实例分盘存储AOF文件,分摊硬盘写入压力。

AOF追加阻塞

开启AOF时,常用的策略是everysec,用于平衡性能和数据安全性。这是Redis使用另一条线程每秒执行fsync同步硬盘。

当系统硬盘资源繁忙时,会造成Redis主线程阻塞。

阻塞流程分析:

  • 主线程负责写入AOF缓冲区
  • AOF线程负责每秒执行一次同步磁盘操作,并记录一次最近同步时间。
  • 主线程负责对比上次AOF同步时间
    • 如果距上次同步成功时间在2s内,主线程直接返回,否则主线程阻塞直至操作完成

发现两个问题:

  • everysec配置最多可能丢失2s数据,不是1s。
  • 如果系统fsync缓慢,将会导致主线程阻塞响应效率。

阻塞问题定位:

  • 发生阻塞时,会产生AOF fsync相关的日志。
  • info Persistence中aof_delayed_fsync指标会累加。
  • 延迟发生说明硬盘高负载问题,使用iostat命令监控。

优化:

参考上节磁盘优化。

事件

  • Redis服务器是一个事件驱动程序,服务器处理的事件分为时间事件和文件事件两类。
  • 文件事件处理器是基于Reactor模式实现的网络通信程序。
  • 文件事件是对套接字操作的抽象:每次套接字变为可应答(acceptable)、可写(writable)或者可读(readable)时,相应的文件事件就会产生。
  • 文件事件分为AE_READABLE事件(读事件)和AE_WRITABLE事件(写事件)两类。
  • 时间事件分为定时事件和周期性事件:定时事件只在指定的时间到达一次,而周期性事件则每隔一段时间到达一次。
  • 服务器在一般情况下只执行serverCron函数一个时间事件,并且这个事件是周期性事件。
  • 文件事件和时间事件之间是合作关系,服务器会轮流处理这两种事件,并且处理事件的过程中也不会进行抢占。
  • 时间事件的实际处理时间通常会比设定的到达时间晚一些。

Redis服务器是一个事件驱动程序,服务器需要处理以下两类事件:

  • 文件事件(file event):Redis服务器通过套接字与客户端(或者其他Redis服务器)进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端(或者其他服务器)的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作。
  • 时间事件(time event):Redis服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象。

文件事件

Redis基于Reactor模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。

  • 文件事件处理器使用I/O多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
  • 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过使用I/O多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与Redis服务器中其他同样以单线程方式运行的模块进行对接,这保持了Redis内部单线程设计的简单性。

文件事件处理器的构成

文件事件处理器的四个组成部分,它们分别是套接字、I/O多路复用程序、文件事件分派器(dispatcher),以及事件处理器。

文件事件处理器的四个组成部分

文件事件是对套接字操作的抽象,每当一个套接字准备好执行连接应答(accept)、写入、读取、关闭等操作时,就会产生一个文件事件。 因为一个服务器通常会连接多个套接字,所以多个文件事件有可能会并发地出现。

I/O多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字。

尽管多个文件事件可能会并发地出现,但I/O多路复用程序总是会将所有产生事件的套接字都放到一个队列里面,然后通过这个队列,以有序(sequentially)、同步(synchronously)、每次一个套接字的方式向文件事件分派器传送套接字。 当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕),I/O多路复用程序才会继续向文件事件分派器传送下一个套接字。

I/O多路复用程序通过队列向文件事件分派器传送套接字

文件事件分派器接收I/O多路复用程序传来的套接字,并根据套接字产生的事件的类型,调用相应的事件处理器。

服务器会为执行不同任务的套接字关联不同的事件处理器,这些处理器是一个个函数,它们定义了某个事件发生时,服务器应该执行的动作。

I/O多路复用程序的实现

Redis的I/O多路复用程序的所有功能都是通过包装常见的select、epoll、evport和kqueue这些I/O多路复用函数库来实现的,每个I/O多路复用函数库在Redis源码中都对应一个单独的文件,比如ae_select.c、ae_epoll.c、ae_kqueue.c,诸如此类。

因为Redis为每个I/O多路复用函数库都实现了相同的API,所以I/O多路复用程序的底层实现是可以互换的。

Redis的I/O多路复用程序有多个I/O多路复用库实现可选

Redis在I/O多路复用程序的实现源码中用#include宏定义了相应的规则,程序会在编译时自动选择系统中性能最高的I/O多路复用函数库来作为Redis的I/O多路复用程序的底层实现:

/* Include the best multiplexing layer supported by this system.
  * The following should be ordered by performances, descending. */
# ifdef HAVE_EVPORT
# include "ae_evport.c"
# else
    # ifdef HAVE_EPOLL
    # include "ae_epoll.c"
    # else
        # ifdef HAVE_KQUEUE
        # include "ae_kqueue.c"
        # else
        # include "ae_select.c"
        # endif
    # endif
# endif

事件的类型

I/O多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE事件,这两类事件和套接字操作之间的对应关系如下:

  • 当套接字变得可读时(客户端对套接字执行write操作,或者执行close操作),或者有新的可应答(acceptable)套接字出现时(客户端对服务器的监听套接字执行connect操作),套接字产生AE_READABLE事件。
  • 当套接字变得可写时(客户端对套接字执行read操作),套接字产生AE_WRITABLE事件。

I/O多路复用程序允许服务器同时监听套接字的AE_READABLE事件和AE_WRITABLE事件,如果一个套接字同时产生了这两种事件,那么文件事件分派器会优先处理AE_READABLE事件,等到AE_READABLE事件处理完之后,才处理AE_WRITABLE事件。 这也就是说,如果一个套接字又可读又可写的话,那么服务器将先读套接字,后写套接字。

API

  • ae.c/aeCreateFileEvent函数接受一个套接字描述符、一个事件类型,以及一个事件处理器作为参数,将给定套接字的给定事件加入到I/O多路复用程序的监听范围之内,并对事件和事件处理器进行关联。
  • ae.c/aeDeleteFileEvent函数接受一个套接字描述符和一个监听事件类型作为参数,让I/O多路复用程序取消对给定套接字的给定事件的监听,并取消事件和事件处理器之间的关联。
  • ae.c/aeGetFileEvents函数接受一个套接字描述符,返回该套接字正在被监听的事件类型:
    • 如果套接字没有任何事件被监听,那么函数返回AE_NONE。
    • 如果套接字的读事件正在被监听,那么函数返回AE_READABLE。
    • 如果套接字的写事件正在被监听,那么函数返回AE_WRITABLE。
    • 如果套接字的读事件和写事件正在被监听,那么函数返回AE_READABLE|AE_WRITABLE
  • ae.c/aeWait函数接受一个套接字描述符、一个事件类型和一个毫秒数为参数,在给定的时间内阻塞并等待套接字的给定类型事件产生,当事件成功产生,或者等待超时之后,函数返回。
  • ae.c/aeApiPoll函数接受一个sys/time.h/struct timeval结构为参数,并在指定的时间內,阻塞并等待所有被aeCreateFileEvent函数设置为监听状态的套接字产生文件事件,当有至少一个事件产生,或者等待超时后,函数返回。
  • ae.c/aeProcessEvents函数是文件事件分派器,它先调用aeApiPoll函数来等待事件产生,然后遍历所有已产生的事件,并调用相应的事件处理器来处理这些事件。
  • ae.c/aeGetApiName函数返回I/O多路复用程序底层所使用的I/O多路复用函数库的名称:返回”epoll”表示底层为epoll函数库,返回”select”表示底层为select函数库,诸如此类。

文件事件处理器

Redis为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通信需求,比如说:

  • 为了对连接服务器的各个客户端进行应答,服务器要为监听套接字关联连接应答处理器。
  • 为了接收客户端传来的命令请求,服务器要为客户端套接字关联命令请求处理器。
  • 为了向客户端返回命令的执行结果,服务器要为客户端套接字关联命令回复处理器。
  • 当主服务器和从服务器进行复制操作时,主从服务器都需要关联特别为复制功能编写的复制处理器。

在这些事件处理器里面,服务器最常用的要数与客户端进行通信的连接应答处理器、命令请求处理器和命令回复处理器。

连接应答处理器

networking.c/acceptTcpHandler函数是Redis的连接应答处理器,这个处理器用于对连接服务器监听套接字的客户端进行应答,具体实现为sys/socket.h/accept函数的包装。

当Redis服务器进行初始化的时候,程序会将这个连接应答处理器和服务器监听套接字的AE_READABLE事件关联起来, 当有客户端用sys/socket.h/connect函数连接服务器监听套接字的时候,套接字就会产生AE_READABLE事件,引发连接应答处理器执行,并执行相应的套接字应答操作。

服务器对客户端的连接请求进行应答

命令请求处理器

networking.c/readQueryFromClient函数是Redis的命令请求处理器,这个处理器负责从套接字中读入客户端发送的命令请求内容,具体实现为unistd.h/read函数的包装。

当一个客户端通过连接应答处理器成功连接到服务器之后,服务器会将客户端套接字的AE_READABLE事件和命令请求处理器关联起来, 当客户端向服务器发送命令请求的时候,套接字就会产生AE_READABLE事件,引发命令请求处理器执行,并执行相应的套接字读入操作。

服务器接收客户端发来的命令请求

在客户端连接服务器的整个过程中,服务器都会一直为客户端套接字的AE_READABLE事件关联命令请求处理器。

命令回复处理器

networking.c/sendReplyToClient函数是Redis的命令回复处理器,这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端,具体实现为unistd.h/write函数的包装。

当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的AE_WRITABLE事件和命令回复处理器关联起来, 当客户端准备好接收服务器传回的命令回复时,就会产生AE_WRITABLE事件,引发命令回复处理器执行,并执行相应的套接字写入操作。

服务器向客户端发送命令回复

当命令回复发送完毕之后,服务器就会解除命令回复处理器与客户端套接字的AE_WRITABLE事件之间的关联。

完整的事件案例

假设一个Redis服务器正在运作,那么这个服务器的监听套接字的AE_READABLE事件应该正处于监听状态之下,而该事件所对应的处理器为连接应答处理器。

如果这时有一个Redis客户端向服务器发起连接,那么监听套接字将产生AE_READABLE事件,触发连接应答处理器执行。 处理器会对客户端的连接请求进行应答,然后创建客户端套接字,以及客户端状态,并将客户端套接字的AE_READABLE事件与命令请求处理器进行关联,使得客户端可以向主服务器发送命令请求。

之后,假设客户端向主服务器发送一个命令请求,那么客户端套接字将产生AE_READABLE事件,引发命令请求处理器执行,处理器读取客户端的命令内容,然后传给相关程序去执行。

执行命令将产生相应的命令回复,为了将这些命令回复传送回客户端,服务器会将客户端套接字的AE_WRITABLE事件与命令回复处理器进行关联。

当客户端尝试读取命令回复的时候,客户端套接字将产生AE_WRITABLE事件,触发命令回复处理器执行, 当命令回复处理器将命令回复全部写入到套接字之后,服务器就会解除客户端套接字的AE_WRITABLE事件与命令回复处理器之间的关联。

客户端和服务器的通信过程

时间事件

Redis的时间事件分为以下两类:

  • 定时事件:让一段程序在指定的时间之后执行一次。比如说,让程序X在当前时间的30毫秒之后执行一次。
  • 周期性事件:让一段程序每隔指定时间就执行一次。比如说,让程序Y每隔30毫秒就执行一次。

一个时间事件主要由以下三个属性组成:

  • id:服务器为时间事件创建的全局唯一ID(标识号)。ID号按从小到大的顺序递增,新事件的ID号比旧事件的ID号要大。
  • when:毫秒精度的UNIX时间戳,记录了时间事件的到达(arrive)时间。
  • timeProc:时间事件处理器,一个函数。当时间事件到达时,服务器就会调用相应的处理器来处理事件。

一个时间事件是定时事件还是周期性事件取决于时间事件处理器的返回值:

  • 如果事件处理器返回ae.h/AE_NOMORE,那么这个事件为定时事件:该事件在达到一次之后就会被删除,之后不再到达。
  • 如果事件处理器返回一个非AE_NOMORE的整数值,那么这个事件为周期性时间:当一个时间事件到达之后,服务器会根据事件处理器返回的值,对时间事件的when属性进行更新,让这个事件在一段时间之后再次到达,并以这种方式一直更新并运行下去。比如说,如果一个时间事件的处理器返回整数值30,那么服务器应该对这个时间事件进行更新,让这个事件在30毫秒之后再次到达。

目前版本的Redis只使用周期性事件,而没有使用定时事件。

实现

服务器将所有时间事件都放在一个无序链表中,每当时间事件执行器运行时,它就遍历整个链表,查找所有已到达的时间事件,并调用相应的事件处理器。

一个保存时间事件的链表的例子,链表中包含了三个不同的时间事件:因为新的时间事件总是插入到链表的表头,所以三个时间事件分别按ID逆序排序,表头事件的ID为3,中间事件的ID为2,表尾事件的ID为1。

用链表连接起来的三个时间事件

注意事项:说保存时间事件的链表为无序链表,指的不是链表不按ID排序,而是说,该链表不按when属性的大小排序。正因为链表没有按when属性进行排序,所以当时间事件执行器运行的时候,它必须遍历链表中的所有时间事件,这样才能确保服务器中所有已到达的时间事件都会被处理。

API

  • ae.c/aeCreateTimeEvent函数接受一个毫秒数milliseconds和一个时间事件处理器proc作为参数,将一个新的时间事件添加到服务器,这个新的时间事件将在当前时间的milliseconds毫秒之后到达,而事件的处理器为proc。
  • ae.c/aeDeleteFileEvent函数接受一个时间事件ID作为参数,然后从服务器中删除该ID所对应的时间事件。
  • ae.c/aeSearchNearestTimer函数返回到达时间距离当前时间最接近的那个时间事件。
  • ae.c/processTimeEvents函数是时间事件的执行器,这个函数会遍历所有已到达的时间事件,并调用这些事件的处理器。已到达指的是,时间事件的when属性记录的UNIX时间戳等于或小于当前时间的UNIX时间戳。

时间事件应用实例:serverCron函数

持续运行的Redis服务器需要定期对自身的资源和状态进行检查和调整,从而确保服务器可以长期、稳定地运行,这些定期操作由redis.c/serverCron函数负责执行,它的主要工作包括:

  • 更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等。
  • 清理数据库中的过期键值对。
  • 关闭和清理连接失效的客户端。
  • 尝试进行AOF或RDB持久化操作。
  • 如果服务器是主服务器,那么对从服务器进行定期同步。
  • 如果处于集群模式,对集群进行定期同步和连接测试。

Redis服务器以周期性事件的方式来运行serverCron函数,在服务器运行期间,每隔一段时间,serverCron就会执行一次,直到服务器关闭为止。

在Redis2.6版本,服务器默认规定serverCron每秒运行10次,平均每间隔100毫秒运行一次。
从Redis2.8开始,用户可以通过修改hz选项来调整serverCron的每秒执行次数,具体信息请参考示例配置文件redis.conf关于hz选项的说明。

事件的调度与执行

因为服务器中同时存在文件事件和时间事件两种事件类型,所以服务器必须对这两种事件进行调度,决定何时应该处理文件事件,何时又应该处理时间事件,以及花多少时间来处理它们等等。

事件的调度和执行由ae.c/aeProcessEvents函数负责:

def aeProcessEvents():
    # 获取到达时间离当前时间最接近的时间事件
    time_event = aeSearchNearestTimer()
    # 计算最接近的时间事件距离到达还有多少毫秒
    remaind_ms = time_event.when - unix_ts_now()
    # 如果事件已到达,那么remaind_ms的值可能为负数,将它设定为0
    if remaind_ms < 0:
        remaind_ms = 0
    # 根据remaind_ms的值,创建timeval结构
    timeval = create_timeval_with_ms(remaind_ms)
    # 阻塞并等待文件事件产生,最大阻塞时间由传入的timeval结构决定
    # 如果remaind_ms的值为0,那么aeApiPoll调用之后马上返回,不阻塞
    aeApiPoll(timeval)
    # 处理所有已产生的文件事件
    processFileEvents()
    # 处理所有已到达的时间事件

注意事项:processFileEvents这个函数,它并不存在,处理已产生文件事件的代码是直接写在aeProcessEvents函数里面的,这里为了方便讲述。

将aeProcessEvents函数置于一个循环里面,加上初始化和清理函数,这就构成了Redis服务器的主函数,以下是该函数的伪代码表示:

def main():
    # 初始化服务器
    init_server()
    # 一直处理事件,直到服务器关闭为止
    while server_is_not_shutdown():
        aeProcessEvents()
    # 服务器关闭,执行清理操作
    clean_server()

事件处理角度下的服务器运行流程:

事件处理角度下的服务器运行流程

以下是事件的调度和执行规则:

  1. aeApiPoll函数的最大阻塞时间由到达时间最接近当前时间的时间事件决定,这个方法既可以避免服务器对时间事件进行频繁的轮询(忙等待),也可以确保aeApiPoll函数不会阻塞过长时间。
  2. 因为文件事件是随机出现的,如果等待并处理完一次文件事件之后,仍未有任何时间事件到达,那么服务器将再次等待并处理文件事件。随着文件事件的不断执行,时间会逐渐向时间事件所设置的到达时间逼近,并最终来到到达时间,这时服务器就可以开始处理到达的时间事件了。
  3. 对文件事件和时间事件的处理都是同步、有序、原子地执行的,服务器不会中途中断事件处理,也不会对事件进行抢占,因此,不管是文件事件的处理器,还是时间事件的处理器,它们都会尽可地减少程序的阻塞时间,并在有需要时主动让出执行权,从而降低造成事件饥饿的可能性。比如说,在命令回复处理器将一个命令回复写入到客户端套接字时,如果写入字节数超过了一个预设常量的话,命令回复处理器就会主动用break跳出写入循环,将余下的数据留到下次再写;另外,时间事件也会将非常耗时的持久化操作放到子线程或者子进程执行。
  4. 因为时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理时间,通常会比时间事件设定的到达时间稍晚一些。

一次完整的事件调度和执行过程:

开始时间 结束时间 动作
0 10 创建在一个100毫秒到达的时间事件
11 39 等待文件事件
31 50 处理文件事件
51 85 等待文件事件
85 130 处理文件事件
131 150 执行时间事件

记录的事件执行过程凸显了上面列举的事件调度规则中的规则2、3、4:

  • 因为时间事件尚未到达,所以在处理时间事件之前,服务器已经等待并处理了两次文件事件。
  • 因为处理事件的过程中不会出现抢占,所以实际处理时间事件的时间比预定的100毫秒慢了30毫秒。

客户端

  • 服务器状态结构使用clients链表连接起多个客户端状态,新添加的客户端状态会被放到链表的末尾。
  • 客户端状态的flags属性使用不同标志来表示客户端的角色,以及客户端当前所处的状态。
  • 输入缓冲区记录了客户端发送的命令请求,这个缓冲区的大小不能超过1GB。
  • 命令的参数和参数个数会被记录在客户端状态的argv和argc属性里面,而cmd属性则记录了客户端要执行命令的实现函数。
  • 客户端有固定大小缓冲区和可变大小缓冲区两种缓冲区可用,其中固定大小缓冲区的最大大小为16KB,而可变大小缓冲区的最大大小不能超过服务器设置的硬性限制值。
  • 输出缓冲区限制值有两种,如果输出缓冲区的大小超过了服务器设置的硬性限制,那么客户端会被立即关闭;除此之外,如果客户端在一定时间内,一直超过服务器设置的软性限制,那么客户端也会被关闭。
  • 当一个客户端通过网络连接连上服务器时,服务器会为这个客户端创建相应的客户端状态。网络连接关闭、发送了不合协议格式的命令请求、成为CLIENT KILL命令的目标、空转时间超时、输出缓冲区的大小超出限制,以上这些原因都会造成客户端被关闭。
  • 处理Lua脚本的伪客户端在服务器初始化时创建,这个客户端会一直存在,直到服务器关闭。
  • 载入AOF文件时使用的伪客户端在载入工作开始时动态创建,载入工作完毕之后关闭。

Redis服务器是典型的一对多服务器程序:一个服务器可以与多个客户端建立网络连接,每个客户端可以向服务器发送命令请求,而服务器则接收并处理客户端发送的命令请求,并向客户端返回命令回复。

通过使用由I/O多路复用技术实现的文件事件处理器,Redis服务器使用单线程单进程的方式来处理命令请求,并与多个客户端进行网络通信。

对于每个与服务器进行连接的客户端,服务器都为这些客户端建立了相应的redis.h/redisClient结构(客户端状态),这个结构保存了客户端当前的状态信息,以及执行相关功能时需要用到的数据结构,其中包括:

  • 客户端的套接字描述符。
  • 客户端的名字。
  • 客户端的标志值(flag)。
  • 指向客户端正在使用的数据库的指针,以及该数据库的号码。
  • 客户端当前要执行的命令、命令的参数、命令参数的个数,以及指向命令实现函数的指针。
  • 客户端的输入缓冲区和输出缓冲区。
  • 客户端的复制状态信息,以及进行复制所需的数据结构。
  • 客户端执行BRPOP、BLPOP等列表阻塞命令时使用的数据结构。
  • 客户端的事务状态,以及执行WATCH命令时用到的数据结构。
  • 客户端执行发布与订阅功能时用到的数据结构。
  • 客户端的身份验证标志。
  • 客户端的创建时间,客户端和服务器最后一次通信的时间,以及客户端的输出缓冲区大小超出软性限制(soft limit)的时间。

Redis服务器状态结构的clients属性是一个链表,这个链表保存了所有与服务器连接的客户端的状态结构,对客户端执行批量操作,或者查找某个指定的客户端,都可以通过遍历clients链表来完成:

struct redisServer {
    // ...
	// 一个链表,保存了所有客户端状态
    list *clients;
    // ...
};

客户端与服务器

clients链表

客户端通信协议

几乎所有的主流编程语言都有Redis的客户端,不考虑Redis非常流行的原因,如果站在技术的角度看原因还有两个:

  • 第一,客户端与服务端之间的通信协议是在TCP协议之上构建的。
  • 第二,Redis制定了RESP(Redis Serialization Protocol,Redis序列化协议)实现客户端与服务端的正常交互,这种协议简单高效,既能够被机器解析,又容易被人类识别。

发送命令格式

RESP的规定一条命令的格式如下,CRLF代表”\r\n”。

*<参数数量> CRLF
$<参数1的字节数量> CRLF
<参数1> CRLF
...
$<参数N的字节数量> CRLF
<参数N> CRLF
*3\r\n$3\r\nSET\r\n$5\r\nhello\r\n$5\r\nworld\r\n

返回结果格式

Redis的返回结果类型分为以下五种:

  • 状态回复:在RESP中第一个字节为”+”。
  • 错误回复:在RESP中第一个字节为”-“。
  • 整数回复:在RESP中第一个字节为”:”。
  • 字符串回复:在RESP中第一个字节为”$”。
  • 多条字符串回复:在RESP中第一个字节为”*“。

redis-cli只能看到最终的执行结果,那是因为redis-cli本身就是按照RESP进行结果解析的,所以看不到中间结果。

可以使用nc命令、telnet命令、甚至写一个socket程序进行模拟。

nc 127.0.0.1 6379
set hello world
+OK

无论是字符串回复还是多条字符串回复,如果有nil值,那么会返回$-1。

客户端属性

客户端状态包含的属性可以分为两类:

  • 一类是比较通用的属性,这些属性很少与特定功能相关,无论客户端执行的是什么工作,它们都要用到这些属性。
  • 另外一类是和特定功能相关的属性
    • 比如操作数据库时需要用到的db属性和dictid属性,执行事务时需要用到的mstate属性,以及执行WATCH命令时需要用到的watched_keys属性等等。

套接字描述符

客户端状态的fd属性记录了客户端正在使用的套接字描述符:

typedef struct redisClient {
    // ...
    int fd;
    // ...
} redisClient;

根据客户端类型的不同,fd属性的值可以是-1或者是大于-1的整数:

  • 伪客户端(fake client)的fd属性的值为-1:伪客户端处理的命令请求来源于AOF文件或者Lua脚本,而不是网络,所以这种客户端不需要套接字连接,自然也不需要记录套接字描述符。目前Redis服务器会在两个地方用到伪客户端,一个用于载入AOF文件并还原数据库状态,而另一个则用于执行Lua脚本中包含的Redis命令。
  • 普通客户端的fd属性的值为大于-1的整数:普通客户端使用套接字来与服务器进行通信,所以服务器会用fd属性来记录客户端套接字的描述符。因为合法的套接字描述符不能是-1,所以普通客户端的套接字描述符的值必然是大于-1的整数。

执行CLIENT list命令可以列出目前所有连接到服务器的普通客户端,命令输出中的fd域显示了服务器连接客户端所使用的套接字描述符:

redis> CLIENT list
addr=127.0.0.1:53428 fd=6 name= age=1242 idle=0 ...
addr=127.0.0.1:53469 fd=7 name= age=4 idle=4 ...

名字

在默认情况下,一个连接到服务器的客户端是没有名字的。

使用CLIENT setname命令可以为客户端设置一个名字,让客户端的身份变得更清晰。

redis> CLIENT list
addr=127.0.0.1:53428 fd=6 name=message_queue age=2093 idle=0 ...
addr=127.0.0.1:53469 fd=7 name=user_relationship age=855 idle=2 ...

客户端的名字记录在客户端状态的name属性里面:

typedef struct redisClient {
    // ...
    robj *name;
    // ...
} redisClient;

标志

客户端的标志属性flags记录了客户端的角色(role),以及客户端目前所处的状态:

typedef struct redisClient {
    // ...
    int flags;
    // ...
} redisClient;

flags属性的值可以是单个标志,也可以是多个标志的二进制或:

flags = <flag>
flags = <flag1> | <flag2> | ...

每个标志使用一个常量表示,一部分标志记录了客户端的角色:

  • 在主从服务器进行复制操作时,主服务器会成为从服务器的客户端,而从服务器也会成为主服务器的客户端。REDIS_MASTER标志表示客户端代表的是一个主服务器,REDIS_SLAVE标志表示客户端代表的是一个从服务器。
  • REDIS_PRE_PSYNC标志表示客户端代表的是一个版本低于Redis2.8的从服务器,主服务器不能使用PSYNC命令与这个从服务器进行同步。这个标志只能在REDIS_SLAVE标志处于打开状态时使用。
  • REDIS_LUA_CLIENT标识表示客户端是专门用于处理Lua脚本里面包含的Redis命令的伪客户端。

而另外一部分标志则记录了客户端目前所处的状态:

  • REDIS_MONITOR标志表示客户端正在执行MONITOR命令。
  • REDIS_UNIX_SOCKET标志表示服务器使用UNIX套接字来连接客户端。
  • REDIS_BLOCKED标志表示客户端正在被BRPOP、BLPOP等命令阻塞。
  • REDIS_UNBLOCKED标志表示客户端已经从REDIS_BLOCKED标志所表示的阻塞状态中脱离出来,不再阻塞。REDIS_UNBLOCKED标志只能在REDIS_BLOCKED标志已经打开的情况下使用。
  • REDIS_MULTI标志表示客户端正在执行事务。
  • REDIS_DIRTY_CAS标志表示事务使用WATCH命命令监视的数据库键已经被修改,REDIS_DIRTY_EXEC标志表示事务在命令入队时出现了错误,以上两个标志都表示事务的安全性已经被破坏,只要这两个标记中的任意一个被打开,EXEC命令必然会执行失败。这两个标志只能在客户端打开了REDIS_MULTI标志的情况下使用。
  • REDIS_CLOSE_ASAP标志表示客户端的输出缓冲区大小超出了服务器允许的范围,服务器会在下一次执行serverCron函数时关闭这个客户端,以免服务器的稳定性受到这个客户端影响。积存在输出缓冲区中的所有内容会直接被释放,不会返回给客户端。
  • REDIS_CLOSE_AFTER_REPLY标志表示有用户对这个客户端执行了CLIENT KILL命令,或者客户端发送给服务器的命令请求中包含了错误的协议内容。服务器会将客户端积存在输出缓冲区中的所有内容发送给客户端,然后关闭客户端。
  • REDIS_ASKING标志表示客户端向集群节点(运行在集群模式下的服务器)发送了ASKING命令。
  • REDIS_FORCE_AOF标志强制服务器将当前执行的命令写入到AOF文件里面,REDIS_FORCE_REPL标志强制主服务器将当前执行的命令复制给所有从服务器。执行PUBSUB命令会使客户端打开REDIS_FORCE_AOF标志,执行SCRIPT LOAD命令会使客户端打开REDIS_FORCE_AOF标志和REDIS_FORCE_REPL标志。
  • 在主从服务器进行命令传播期间,从服务器需要向主服务器发送REPLICATION ACK命令,在发送这个命令之前,从服务器必须打开主服务器对应的客户端的REDIS_MASTER_FORCE_REPLY标志,否则发送操作会被拒绝执行。

以上提到的所有标志都定义在redis.h文件里面。

PUBSUB命令和SCRIPT LOAD命令的特殊性

通常情况下,Redis只会将那些对数据库进行了修改的命令写入到AOF文件,并复制到各个从服务器。 如果一个命令没有对数据库进行任何修改,那么它就会被认为是只读命令,这个命令不会被写入到AOF文件,也不会被复制到从服务器。
以上规则适用于绝大部分Redis命令,但PUBSUB命令和SCRIPT LOAD命令是其中的例外。 PUBSUB命令虽然没有修改数据库,但PUBSUB命令向频道的所有订阅者发送消息这一行为带有副作用,接收到消息的所有客户端的状态都会因为这个命令而改变。 因此,服务器需要使用REDIS_FORCE_AOF标志,强制将这个命令写入AOF文件,这样在将来载入AOF文件时,服务器就可以再次执行相同的PUBSUB命令,并产生相同的副作用。 SCRIPT LOAD命令的情况与PUBSUB命令类似:虽然SCRIPT LOAD命令没有修改数据库,但它修改了服务器状态,所以它是一个带有副作用的命令,服务器需要使用REDIS_FORCE_AOF标志,强制将这个命令写入AOF文件,使得将来在载入AOF文件时,服务器可以产生相同的副作用。
另外,为了让主服务器和从服务器都可以正确地载入SCRIPT LOAD命令指定的脚本,服务器需要使用REDIS_FORCE_REPL标志,强制将SCRIPT LOAD命令复制给所有从服务器。

# 客户端是一个主服务器
REDIS_MASTER
# 客户端正在被列表命令阻塞
REDIS_BLOCKED
# 客户端正在执行事务,但事务的安全性已被破坏
REDIS_MULTI | REDIS_DIRTY_CAS
# 客户端是一个从服务器,并且版本低于Redis 2.8 
REDIS_SLAVE | REDIS_PRE_PSYNC
# 这是专门用于执行Lua脚本包含的Redis
命令的伪客户端
# 它强制服务器将当前执行的命令写入AOF文件,并复制给从服务器
REDIS_LUA_CLIENT | REDIS_FORCE_AOF| REDIS_FORCE_REPL

输入缓冲区

客户端状态的输入缓冲区用于保存客户端发送的命令请求:

typedef struct redisClient {
    // ...
    sds querybuf;
    // ...
} redisClient;
SET key value

客户端状态的querybuf属性将是包含以下内容的SDS值:

*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n

querybuf属性示例

输入缓冲使用不当会产生两个问题:

  • 输入缓冲区的大小会根据输入内容动态地缩小或者扩大,但它的最大大小不能超过1GB,否则服务器将关闭这个客户端。
  • 输入缓冲区不受maxmemory控制,假设一个Redis实例设置了maxmemory为4G,已经存储了2G数据,但是如果此时输入缓冲区使用了3G,已经超过maxmemory限制,可能会产生数据丢失、键值淘汰、OOM等情况

输入缓冲区超过了maxmemory

成输入缓冲区过大的原因:

  • 输入缓冲区过大主要是因为Redis的处理速度跟不上输入缓冲区的输入速度,并且每次进入输入缓冲区的命令包含了大量bigkey,从而造成了输入缓冲区过大的情况。
  • 还有一种情况就是Redis发生了阻塞,短期内不能处理命令,造成客户端输入的命令积压在了输入缓冲区,造成了输入缓冲区过大。

监控输入缓冲区异常的方法有两种:

  • 通过定期执行client list命令,收集qbuf和qbuf-free找到异常的连接记录并分析,最终找到可能出问题的客户端
  • 通过info命令的info clients模块,找到最大的输入缓冲区,例如可以设置超过10M就进行报警

对比client list和info clients监控输入缓冲区的优劣势

命令与命令参数

在服务器将客户端发送的命令请求保存到客户端状态的querybuf属性之后,服务器将对命令请求的内容进行分析,并将得出的命令参数以及命令参数的个数分别保存到客户端状态的argv属性和argc属性:

typedef struct redisClient {
    // ...
    robj **argv;
    int argc;
    // ...
} redisClient;
  • argv属性是一个数组,数组中的每个项都是一个字符串对象,其中argv[0]是要执行的命令,而之后的其他项则是传给命令的参数。
  • argc属性则负责记录argv数组的长度。

argv属性和argc属性示例:

argv属性和argc属性示例

注意事项:argc属性的值为3,而不是2,因为命令的名字”SET”本身也是一个参数。

命令的实现函数

当服务器从协议内容中分析并得出argv属性和argc属性的值之后,服务器将根据项argv[0]的值,在命令表中查找命令所对应的命令实现函数。

当程序在命令表中成功找到argv[0]所对应的redisCommand结构时,它会将客户端状态的cmd指针指向这个结构:

typedef struct redisClient {
    // ...
    struct redisCommand *cmd;
    // ...
} redisClient;

之后,服务器就可以使用cmd属性所指向的redisCommand结构,以及argv、argc属性中保存的命令参数信息,调用命令实现函数,执行客户端指定的命令。

查找命令并设置cmd属性

针对命令表的查找操作不区分输入字母的大小写。

输出缓冲区

与输入缓冲区不同的是,输出缓冲区的容量可以通过参数client-output-buffer-limit来进行设置,并且输出缓冲区做得更加细致, 按照客户端的不同分为三种:普通客户端、发布订阅客户端、slave客户端。

配置规则:

client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>
  • <class>:客户端类型,分为三种。
    • normal:普通客户端;
    • slave:slave客户端,用于复制;
    • pubsub:发布订阅客户端。
  • <hard limit>:如果客户端使用的输出缓冲区大于<hard limit>,客户端会被立即关闭。
  • <soft limit><soft seconds>:如果客户端使用的输出缓冲区超过了<soft limit>并且持续了<soft limit>秒,客户端会被立即关闭。

和输入缓冲区相同的是,输出缓冲区也不会受到maxmemory的限制,如果使用不当同样会造成maxmemory用满产生的数据丢失、键值淘汰、OOM等情况。

执行命令所得的命令回复会被保存在客户端状态的输出缓冲区里面,每个客户端都有两个输出缓冲区可用,一个缓冲区的大小是固定的,另一个缓冲区的大小是可变的:

  • 固定大小的缓冲区用于保存那些长度比较小的回复,比如OK、简短的字符串值、整数值、错误回复等等。
  • 可变大小的缓冲区用于保存那些长度比较大的回复,比如一个非常长的字符串值,一个由很多项组成的列表,一个包含了很多元素的集合等等。

客户端的固定大小缓冲区由buf和bufpos两个属性组成:

typedef struct redisClient {
    // ...
    char buf[REDIS_REPLY_CHUNK_BYTES];
    int bufpos;
    // ...
} redisClient;
  • buf是一个大小为REDIS_REPLY_CHUNK_BYTES字节的字节数组,
    • REDIS_REPLY_CHUNK_BYTES常量目前的默认值为16*1024,也即是说,buf数组的默认大小为16KB。
  • bufpos属性则记录了buf数组目前已使用的字节数量。

使用固定大小缓冲区来保存返回值+OK\r\n的例子:

固定大小缓冲区示例

当buf数组的空间已经用完,或者回复因为太大而没办法放进buf数组里面时,服务器就会开始使用可变大小缓冲区。

可变大小缓冲区由reply链表和一个或多个字符串对象组成:

typedef struct redisClient {
    // ...
    list *reply;
    // ...
} redisClient;

通过使用链表来连接多个字符串对象,服务器可以为客户端保存一个非常长的命令回复,而不必受到固定大小缓冲区16KB大小的限制。

可变大小缓冲区示例

查看缓冲区大小:

id=7 addr=127.0.0.1:56358 fd=6 name= age=91 idle=0 flags=O db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=4869 omem=133081288 events=rw cmd=monitor

obl(代表固定缓冲区的长度)、oll(动态缓冲区列表的长度)、omem(代表使用的字节数)。

监控输出缓冲区:

  • 通过定期执行client list命令,收集obl、oll、omem找到异常的连接记录并分析,最终找到可能出问题的客户端。
  • 通过info命令的info clients模块,找到输出缓冲区列表最大对象数。
127.0.0.1:6379> info clients
# Clients
connected_clients:502
client_longest_output_list:4869 # 输出缓冲区列表最大对象数 
client_biggest_input_buf:0
blocked_clients:0

输出缓冲区出现问题的概率比较大,预防措施:

  • 进行上述监控,设置阀值,超过阀值及时处理。
  • 限制普通客户端输出缓冲区的<hard limit><soft limit><soft seconds>,把错误扼杀在摇篮中。
    • client-output-buffer-limit normal 20mb 10mb 120
  • 适当增大slave的输出缓冲区的<hard limit><soft limit><soft seconds>,如果master节点写入较大,slave客户端的输出缓冲区可能会比较大,一旦slave客户端连接因为输出缓冲区溢出被kill,会造成复制重连。
  • 限制容易让输出缓冲区增大的命令。
    • 例如,高并发下的monitor命令就是一个危险的命令。
  • 及时监控内存,一旦发现内存抖动频繁,可能就是输出缓冲区过大。

身份验证

客户端状态的authenticated属性用于记录客户端是否通过了身份验证:

typedef struct redisClient {
    // ...
    int authenticated;
    // ...
} redisClient;

如果authenticated的值为0,那么表示客户端未通过身份验证;如果authenticated的值为1,那么表示客户端已经通过了身份验证。

# authenticated
属性的值从0
变为1
redis> AUTH 123321
OK
redis> PING
PONG
redis> SET msg "hello world"
OK

authenticated属性仅在服务器启用了身份验证功能时使用。

时间

typedef struct redisClient {
    // ...
    time_t ctime;
    time_t lastinteraction;
    time_t obuf_soft_limit_reached_time;
    // ...
} redisClient;
  • ctime属性记录了创建客户端的时间,这个时间可以用来计算客户端与服务器已经连接了多少秒
    • CLIENT list命令的age域记录了这个秒数。
  • lastinteraction属性记录了客户端与服务器最后一次进行互动(interaction)的时间,这里的互动可以是客户端向服务器发送命令请求,也可以是服务器向客户端发送命令回复。
    • lastinteraction属性可以用来计算客户端的空转(idle)时间,也即是,距离客户端与服务器最后一次进行互动以来,已经过去了多少秒,CLIENT list命令的idle域记录了这个秒数
  • obuf_soft_limit_reached_time属性记录了输出缓冲区第一次到达软性限制(soft limit)的时间,
    • 输出缓冲区大小限制详细说明这个属性的作用。

客户端的创建与关闭

创建普通客户端

如果客户端是通过网络连接与服务器进行连接的普通客户端,那么在客户端使用connect函数连接到服务器时,服务器就会调用连接事件处理器,为客户端创建相应的客户端状态,并将这个新的客户端状态添加到服务器状态结构clients链表的末尾。

关闭普通客户端

一个普通客户端可以因为多种原因而被关闭:

  • 如果客户端进程退出或者被杀死,那么客户端与服务器之间的网络连接将被关闭,从而造成客户端被关闭。
  • 如果客户端向服务器发送了带有不符合协议格式的命令请求,那么这个客户端也会被服务器关闭。
  • 如果客户端成为了CLIENT KILL命令的目标,那么它也会被关闭。
  • 如果用户为服务器设置了timeout配置选项,那么当客户端的空转时间超过timeout选项设置的值时,客户端将被关闭。
    • 不过timeout选项有一些例外情况: 如果客户端是主服务器(打开了REDIS_MASTER标志),从服务器(打开了REDIS_SLAVE标志),正在被BLPOP等命令阻塞(打开了REDIS_BLOCKED标志),或者正在执行SUBSCRIBE、PSUBSCRIBE等订阅命令,那么即使客户端的空转时间超过了timeout选项的值,客户端也不会被服务器关闭。
  • 如果客户端发送的命令请求的大小超过了输入缓冲区的限制大小(默认为1 GB),那么这个客户端会被服务器关闭。
  • 如果要发送给客户端的命令回复的大小超过了输出缓冲区的限制大小,那么这个客户端会被服务器关闭。

可变大小缓冲区由一个链表和任意多个字符串对象组成,理论上来说,这个缓冲区可以保存任意长的命令回复。 但是,为了避免客户端的回复过大,占用过多的服务器资源,服务器会时刻检查客户端的输出缓冲区的大小,并在缓冲区的大小超出范围时,执行相应的限制操作。

服务器使用两种模式来限制客户端输出缓冲区的大小:

  • 硬性限制(hard limit)
    • 如果输出缓冲区的大小超过了硬性限制所设置的大小,那么服务器立即关闭客户端。
  • 软性限制(soft limit)
    • 如果输出缓冲区的大小超过了软性限制所设置的大小,但还没超过硬性限制,那么服务器将使用客户端状态结构的obuf_soft_limit_reached_time属性记录下客户端到达软性限制的起始时间;
    • 之后服务器会继续监视客户端,如果输出缓冲区的大小一直超出软性限制,并且持续时间超过服务器设定的时长,那么服务器将关闭客户端;
    • 相反地,如果输出缓冲区的大小在指定时间之内,不再超出软性限制,那么客户端就不会被关闭,并且obuf_soft_limit_reached_time属性的值也会被清零。

使用client-output-buffer-limit选项可以为普通客户端、从服务器客户端、执行发布与订阅功能的客户端分别设置不同的软性限制和硬性限制,该选项的格式为:

client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>
# 将普通客户端的硬性限制和软性限制都设置为0,表示不限制客户端的输出缓冲区大小。
client-output-buffer-limit normal 0 0 0
# 将从服务器客户端的硬性限制设置为256MB,而软性限制设置为64MB,软性限制的时长为60秒。
client-output-buffer-limit slave 256mb 64mb 60
# 将执行发布与订阅功能的客户端的硬性限制设置为32MB,软性限制设置为8MB,软性限制的时长为60秒。
client-output-buffer-limit pubsub 32mb 8mb 60

Lua脚本的伪客户端

服务器会在初始化时创建负责执行Lua脚本中包含的Redis命令的伪客户端,并将这个伪客户端关联在服务器状态结构的lua_client属性中:

struct redisServer {
    // ...
    redisClient *lua_client;
    // ...
};

lua_client伪客户端在服务器运行的整个生命期中会一直存在,只有服务器被关闭时,这个客户端才会被关闭。

AOF文件的伪客户端

服务器在载入AOF文件时,会创建用于执行AOF文件包含的Redis命令的伪客户端,并在载入完成之后,关闭这个伪客户端。

Java客户端Jedis

对于第三方开发包,版本的选择也是至关重要的,因为Redis更新速度比较快,如果客户端跟不上服务端的速度,有些特性和bug不能及时更新,不利于日常开发。通常来讲选取第三方开发包有如下两个策略:

  • 选择比较稳定的版本,也就是尽可能选择稳定的里程碑版本,这些版本已经经过多次alpha,beta的修复,基本算是稳定了。
  • 选择更新活跃的第三方开发包,例如Redis3.0有了Redis Cluster新特性,但是如果使用的客户端一直不支持,并且维护的人也比较少,这种就谨慎选择。

Jedis的基本使用

// 1. 生成一个Jedis对象,这个对象负责和指定Redis实例进行通信
Jedis jedis = new Jedis("127.0.0.1", 6379);
// 2. jedis执行set操作
jedis.set("hello", "world");
// 3. jedis执行get操作, value="world"
String value = jedis.get("hello");

Jedis jedis = null;
try {
	jedis = new Jedis("127.0.0.1", 6379);
	jedis.get("hello");
} catch (Exception e) {
	logger.error(e.getMessage(),e);
} finally {
	if (jedis != null) {
		jedis.close();
	}
}

// 1.string
// 输出结果:OK
jedis.set("hello", "world");
// 输出结果:world
jedis.get("hello");
// 输出结果:1
jedis.incr("counter");
// 2.hash
jedis.hset("myhash", "f1", "v1");
jedis.hset("myhash", "f2", "v2");
// 输出结果:{f1=v1, f2=v2}
jedis.hgetAll("myhash");
// 3.list
jedis.rpush("mylist", "1");
jedis.rpush("mylist", "2");
jedis.rpush("mylist", "3");
// 输出结果:[1, 2, 3]
jedis.lrange("mylist", 0, -1);
// 4.set
jedis.sadd("myset", "a");
jedis.sadd("myset", "b");
jedis.sadd("myset", "a");
// 输出结果:[b, a]
jedis.smembers("myset");
// 5.zset
jedis.zadd("myzset", 99, "tom");
jedis.zadd("myzset", 66, "peter");
jedis.zadd("myzset", 33, "james");
// 输出结果:[[["james"],33.0], [["peter"],66.0], [["tom"],99.0]]
jedis.zrangeWithScores("myzset", 0, -1);

参数除了可以是字符串,Jedis还提供了字节数组的参数,例如:

public String set(final String key, String value)
public String set(final byte[] key, final byte[] value)
public byte[] get(final byte[] key)
public String get(final String key)

有了这些API的支持,就可以将Java对象序列化为二进制,当应用需要获取Java对象时,使用get(final byte[]key)函数将字节数组取出,然后反序列化为Java对象即可。

序列化的工具有很多,例如XML、Json、谷歌的Protobuf、Facebook的Thrift等等。

Jedis连接池使用

客户端连接Redis使用的是TCP协议,直连的方式每次需要建立TCP连接,而连接池的方式是可以预先初始化好Jedis连接,所以每次只需要从Jedis连接池借用即可, 而借用和归还操作是在本地进行的,只有少量的并发同步开销,远远小于新建TCP连接的开销。
另外直连的方式无法限制Jedis对象的个数,在极端情况下可能会造成连接泄露,而连接池的形式可以有效的保护和控制资源的使用。但是直连的方式也并不是一无是处。

Jedis直连方式和连接池方式对比

Jedis提供了JedisPool这个类作为对Jedis的连接池,同时使用了Apache的通用对象池工具common-pool作为资源的管理工具。

GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
// 设置最大连接数为默认值的5倍
poolConfig.setMaxTotal(GenericObjectPoolConfig.DEFAULT_MAX_TOTAL * 5);
// 设置最大空闲连接数为默认值的3倍
poolConfig.setMaxIdle(GenericObjectPoolConfig.DEFAULT_MAX_IDLE * 3);
// 设置最小空闲连接数为默认值的2倍
poolConfig.setMinIdle(GenericObjectPoolConfig.DEFAULT_MIN_IDLE * 2);
// 设置开启jmx功能
poolConfig.setJmxEnabled(true);
// 设置连接池没有连接后客户端的最大等待时间(单位为毫秒)
poolConfig.setMaxWaitMillis(3000);

Jedis jedis = null;
try {
    // 1. 从连接池获取jedis对象
    jedis = jedisPool.getResource();
    // 2. 执行操作
    jedis.get("hello");
} catch (Exception e) {
    logger.error(e.getMessage(),e);
} finally {
    if (jedis != null) {
        // 如果使用JedisPool,close操作不是关闭连接,代表归还连接池
        jedis.close();
    }
}

GenericObjectPoolConfig的重要属性

Jedis中Pipeline的使用

// 注意没有资源管理的代码
// 1)生成pipeline对象
Pipeline pipeline = jedis.pipelined();
// 2)pipeline执行命令,注意此时命令并未真正执行
for (String key : keys) {
    pipeline.del(key);
}
// 3)执行命令
pipeline.sync();

Pipeline pipeline = jedis.pipelined();
pipeline.set("hello", "world");
pipeline.incr("counter");
ListresultList = pipeline.syncAndReturnAll();
for (Object object : resultList) {
    System.out.println(object);
}
// OK
// 1

Jedis的Lua脚本使用

String key = "hello";
String script = "return redis.call('get',KEYS[1])";
Object result = jedis.eval(script, 1, key);
// 打印结果为world
System.out.println(result)

String scriptSha = jedis.scriptLoad(script);
Stirng key = "hello";
Object result = jedis.evalsha(scriptSha, 1, key);
// 打印结果为world
System.out.println(result);

总体来说,Jedis的使用还是比较简单的,重点注意以下几点即可:

  1. Jedis操作放在try catch finally里更加合理。
  2. 区分直连和连接池两种实现方式优缺点。
  3. jedis.close()方法的两种实现方式。
  4. Jedis依赖了common-pool,有关common-pool的参数需要根据不同的使用场景,各不相同,需要具体问题具体分析。
  5. 如果key和value涉及了字节数组,需要自己选择适合的序列化方法。

客户端管理

Redis接受上配置监听TCP端口和Unix套接字客户端的连接,如果启用。当一个新的客户端连接被接受,如有以下操作进行:

客户端套接字置于非阻塞状态,因为Redis的使用复用和非阻塞I/O操作。

TCP_NODELAY选项设定是为了以确保没有连接延迟。

创建一个可读的文件时,这样Redis能够尽快收集客户端的查询作为新的数据可供读取的Socket中。

client list

  • 标识:id、addr、fd、name
    • id:客户端连接的唯一标识,这个id是随着Redis的连接自增的,重启Redis后会重置为0。
    • addr:客户端连接的ip和端口。
    • fd:socket的文件描述符,与lsof命令结果中的fd是同一个,如果fd=-1代表当前客户端不是外部客户端,而是Redis内部的伪装客户端。
    • name:客户端的名字,client setName和client getName两个命令会对其进行设置。
  • 输入缓冲区:qbuf、qbuf-free
    • 分别代表总容量和剩余容量
  • 输出缓冲区:obl、oll、omem
    • 固定缓冲区长度、动态缓冲区列表的长度、使用的字节
  • 客户端的存活状态
    • age和idle分别代表当前客户端已经连接的时间和最近一次的空闲时间
  • 客户端的限制maxclients和timeout
    • 最大连接数和最大空闲时间
  • 客户端类型
    • flag代表客户端类型
  • 其它属性

客户端类型:

客户端类型

其它属性:

client list命令结果的全部属性 client list命令结果的全部属性

客户端的最大数量
config get maxclients

1) "maxclients"
2) "10000"

默认情况下,此属性设置为10000(这取决于操作系统的文件描述符限制最大数量),但你可以改变这个属性。.

redis-server --maxclients 100000
命令 描述
CLIENT LIST 返回客户端的列表连接到Redis服务器
CLIENT SETNAME 指定名称的当前连接
CLIENT GETNAME 返回由CLIENT SETNAME设置当前连接的名称。
CLIENT PAUSE 这是一个连接控制命令可以暂停所有Redis客户指定的时间量(以毫秒为单位)。
CLIENT KILL 该命令关闭特定的客户端连接。

client pause

client pause timeout(毫秒)

client pause命令用于阻塞客户端timeout毫秒数,在此期间客户端连接将被阻塞。

该命令可以在如下场景起到作用:

  • client pause只对普通和发布订阅客户端有效,对于主从复制(从节点内部伪装了一个客户端)是无效的,也就是此期间主从复制是正常进行的,所以此命令可以用来让主从复制保持一致。
  • client pause可以用一种可控的方式将客户端连接从一个Redis节点切换到另一个Redis节点。

需要注意的是在生产环境中,暂停客户端成本非常高。

客户端统计片段

127.0.0.1:6379> info clients
# Clients
connected_clients:1414
client_longest_output_list:0
client_biggest_input_buf:2097152
blocked_clients:0
  1. connected_clients:代表当前Redis节点的客户端连接数,需要重点监控,一旦超过maxclients,新的客户端连接将被拒绝。
  2. client_longest_output_list:当前所有输出缓冲区中队列对象个数的最大值。
  3. client_biggest_input_buf:当前所有输入缓冲区中占用的最大容量。
  4. blocked_clients:正在执行阻塞命令(例如blpop、brpop、brpoplpush)的客户端个数。

除此之外info stats中还包含了两个客户端相关的统计指标:

127.0.0.1:6379> info stats
# Stats
total_connections_received:80
...
rejected_connections:0
  • total_connections_received:Redis自启动以来处理的客户端连接数总数。
  • rejected_connections:Redis自启动以来拒绝的客户端连接数,需要重点监控。

客户端常见异常

无法从连接池获取到连接

  • JedisPool中的Jedis对象个数是有限的,默认是8个。
  • 设置了blockWhenExhausted=false,那么调用者发现池子中没有资源时,会立即抛出异常不进行等待。

为什么连接池没有资源了:

  • 客户端:高并发下连接池设置过小,出现供不应求,所以会出现上面的错误,但是正常情况下只要比默认的最大连接数(8个)多一些即可,因为正常情况下JedisPool以及Jedis的处理效率足够高。
  • 客户端:没有正确使用连接池,比如没有进行释放,例如下面代码所示。
  • 客户端:存在慢查询操作,这些慢查询持有的Jedis对象归还速度会比较慢,造成池子满了。
  • 服务端:客户端是正常的,但是Redis服务端由于一些原因造成了客户端命令执行过程的阻塞,也会使得客户端抛出这种异常。

客户端读写超时

  • 读写超时间设置得过短。
  • 命令本身就比较慢。
  • 客户端与服务端网络不正常。
  • Redis自身发生阻塞。

客户端连接超时

  • 连接超时设置得过短。
  • Redis发生阻塞,造成tcp-backlog已满,造成新的连接失败。
    • TCP三次握手后,会将接受的连接放入队列中,tcp-backlog就是队列的大小
  • 客户端与服务端网络不正常。

客户端缓冲区异常

redis.clients.jedis.exceptions.JedisConnectionException: Unexpected end of stream.
  • 输出缓冲区满。例如将普通客户端的输出缓冲区设置为1M1M60,然后get一个bigkey。
  • 长时间闲置连接被服务端主动断开。
  • 不正常并发读写:Jedis对象同时被多个线程并发操作,可能会出现上述异常。

Lua脚本正在执行

如果Redis当前正在执行Lua脚本,并且超过了lua-time-limit,此时Jedis调用Redis时,会收到下面的异常。可以kill掉进Lua。

Redis正在持久化文件

Jedis调用Redis时,Redis正在加载持久化文件。

客户端连接数过大

这个问题可能会比较棘手,因为此时无法执行Redis命令进行问题修复,一般来说可以从两个方面进行着手解决:

  • 客户端:如果maxclients参数不是很小的话,应用方的客户端连接数基本不会超过maxclients,通常来看是由于应用方对于Redis客户端使用不当造成的。此时如果应用方是分布式结构的话,可以通过下线部分应用节点(例如占用连接较多的节点),使得Redis的连接数先降下来。从而让绝大部分节点可以正常运行,此时再通过查找程序bug或者调整maxclients进行问题的修复。
  • 服务端:如果此时客户端无法处理,而当前Redis为高可用模式(例如Redis Sentinel和Redis Cluster),可以考虑将当前Redis做故障转移。

此问题不存在确定的解决方式,但是无论从哪个方面进行处理,故障的快速恢复极为重要,当然更为重要的是找到问题的所在,否则一段时间后客户端连接数依然会超过maxclients。

案例

内存陡增

服务端现象:

Redis主节点内存陡增,几乎用满maxmemory,而从节点内存并没有变化。

客户端现象:

客户端产生了OOM异常,也就是Redis主节点使用的内存已经超过了maxmemory的设置,无法写入新的数据。

分析:

  • 确实有大量写入,但是主从复制出现问题。
    • 排查复制的相关信息
  • 排查是否由客户端缓冲区造成主节点内存陡增。
    • 使用info命令查看缓冲区

处理及后期处理:

  • 限制缓冲区大小
  • 杜绝或者监控monitor命令
客户端周期性超时

客户端现象:

客户端出现大量超时,经过分析发现超时是周期性出现的,这为问题的查找提供了重要依据。

服务端现象:

服务端并没有明显的异常,只是有一些慢查询操作。

分析:

  • 网络原因:服务端和客户端之间的网络出现周期性问题,经过观察网络是正常的。
  • Redis本身:经过观察Redis日志统计,并没有发现异常。
  • 客户端:由于是周期性出现问题,就和慢查询日志的历史记录对应了一下时间,发现只要慢查询出现,客户端就会产生大量连接超时,两个时间点基本一致。

处理及后期处理:

  • 从运维层面,监控慢查询,一旦超过阀值,就发出报警。
  • 从开发层面,加强对于Redis的理解,避免不正确的使用方式。

服务器

  • 一个命令请求从发送到完成主要包括以下步骤:
    1. 客户端将命令请求发送给服务器;
    2. 服务器读取命令请求,并分析出命令参数;
    3. 命令执行器根据参数查找命令的实现函数,然后执行实现函数并得出命令回复;
    4. 服务器将命令回复返回给客户端。
  • serverCron函数默认每隔100毫秒执行一次,它的工作主要包括更新服务器状态信息,处理服务器接收的SIGTERM信号,管理客户端资源和数据库状态,检查并执行持久化操作等等。
  • 服务器从启动到能够处理客户端的命令请求需要执行以下步骤:
    1. 初始化服务器状态;
    2. 载入服务器配置;
    3. 初始化服务器数据结构;
    4. 还原数据库状态;
    5. 执行事件循环。

Redis服务器负责与多个客户端建立网络连接,处理客户端发送的命令请求,在数据库中保存客户端执行命令所产生的数据,并通过资源管理来维持服务器自身的运转。

命令请求的执行过程

一个命令请求从发送到获得回复的过程中,客户端和服务器需要完成一系列操作。举个例子,如果使用客户端执行以下命令:

redis> SET KEY VALUE
OK

那么从客户端发送SET KEY VALUE命令到获得回复OK期间,客户端和服务器共需要执行以下操作:

  1. 客户端向服务器发送命令请求SET KEY VALUE。
  2. 服务器接收并处理客户端发来的命令请求SET KEY VALUE,在数据库中进行设置操作,并产生命令回复OK。
  3. 服务器将命令回复OK发送给客户端。
  4. 客户端接收服务器返回的命令回复OK,并将这个回复打印给用户观看。

发送命令请求

Redis服务器的命令请求来自Redis客户端,当用户在客户端中键入一个命令请求时,客户端会将这个命令请求转换成协议格式,然后通过连接到服务器的套接字,将协议格式的命令请求发送给服务器.

客户端接收并发送命令请求的过程

读取命令请求

当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器来执行以下操作:

  1. 读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里面。
  2. 对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的argv属性和argc属性里面。
  3. 调用命令执行器,执行客户端指定的命令。

querybuf属性示例

argv属性和argc属性示例

命令执行器

查找命令实现

命令执行器要做的第一件事就是根据客户端状态的argv[0]参数,在命令表(command table)中查找参数所指定的命令,并将找到的命令保存到客户端状态的cmd属性里面。

命令表是一个字典,字典的键是一个个命令名字;而字典的值则是一个个redisCommand结构,每个redisCommand结构记录了一个Redis命令的实现信息,

redisCommand结构的主要属性:

属性名 类型 作用
name char * 命令的名字,比如 “set” 。
proc redisCommandProc * 函数指针,指向命令的实现函数,比如 setCommand 。 redisCommandProc 类型的定义为 typedef void redisCommandProc(redisClient *c); 。
arity int 命令参数的个数,用于检查命令请求的格式是否正确。 如果这个值为负数 -N ,那么表示参数的数量大于等于 N 。 注意命令的名字本身也是一个参数, 比如说 SET msg “hello world” 命令的参数是 “SET” 、 “msg” 、 “hello world” , 而不仅仅是 “msg” 和 “hello world” 。
sflags char * 字符串形式的标识值, 这个值记录了命令的属性, 比如这个命令是写命令还是读命令, 这个命令是否允许在载入数据时使用, 这个命令是否允许在 Lua 脚本中使用, 等等。
flags int 对 sflags 标识进行分析得出的二进制标识, 由程序自动生成。 服务器对命令标识进行检查时使用的都是 flags 属性而不是 sflags 属性, 因为对二进制标识的检查可以方便地通过 & 、 ^ 、 ~ 等操作来完成。
calls long long 服务器总共执行了多少次这个命令。
milliseconds long long 服务器执行这个命令所耗费的总时长。

sflags属性的标识:

标识 意义 带有这个标识的命令
w 这是一个写入命令,可能会修改数据库。 SET 、 RPUSH 、 DEL ,等等。
r 这是一个只读命令,不会修改数据库。 GET 、 STRLEN 、 EXISTS ,等等。
m 这个命令可能会占用大量内存, 执行之前需要先检查服务器的内存使用情况, 如果内存紧缺的话就禁止执行这个命令。 SET 、 APPEND 、 RPUSH 、 LPUSH 、 SADD 、 SINTERSTORE ,等等。
a 这是一个管理命令。 SAVE 、 BGSAVE 、 SHUTDOWN ,等等。
p 这是一个发布与订阅功能方面的命令。 PUBLISH 、 SUBSCRIBE 、 PUBSUB ,等等。
s 这个命令不可以在 Lua 脚本中使用。 BRPOP 、 BLPOP 、 BRPOPLPUSH 、 SPOP ,等等。
R 这是一个随机命令, 对于相同的数据集和相同的参数, 命令返回的结果可能不同。 SPOP 、 SRANDMEMBER 、 SSCAN 、 RANDOMKEY ,等等。
S 当在 Lua 脚本中使用这个命令时, 对这个命令的输出结果进行一次排序, 使得命令的结果有序。 SINTER 、 SUNION 、 SDIFF 、 SMEMBERS 、 KEYS ,等等。
l 这个命令可以在服务器载入数据的过程中使用。 INFO 、 SHUTDOWN 、 PUBLISH ,等等。
t 这是一个允许从服务器在带有过期数据时使用的命令。 SLAVEOF 、 PING 、 INFO ,等等。
M 这个命令在监视器(monitor)模式下不会自动被传播(propagate)。 EXEC

命令表:

命令表

设置客户端状态的cmd指针

设置客户端状态的cmd指针

执行预备操作

到目前为止,服务器已经将执行命令所需的命令实现函数(保存在客户端状态的cmd属性)、参数(保存在客户端状态的argv属性)、参数个数(保存在客户端状态的argc属性)都收集齐了, 但是在真正执行命令之前,程序还需要进行一些预备操作,从而确保命令可以正确、顺利地被执行,这些操作包括:

  • 检查客户端状态的cmd指针是否指向NULL,如果是的话,那么说明用户输入的命令名字找不到相应的命令实现,服务器不再执行后续步骤,并向客户端返回一个错误。
  • 根据客户端cmd属性指向的redisCommand结构的arity属性,检查命令请求所给定的参数个数是否正确,当参数个数不正确时,不再执行后续步骤,直接向客户端返回一个错误。比如说,如果redisCommand结构的arity属性的值为-3,那么用户输入的命令参数个数必须大于等于3个才行。
  • 检查客户端是否已经通过了身份验证,未通过身份验证的客户端只能执行AUTH命令,如果未通过身份验证的客户端试图执行除AUTH命令之外的其他命令,那么服务器将向客户端返回一个错误。
  • 如果服务器打开了maxmemory功能,那么在执行命令之前,先检查服务器的内存占用情况,并在有需要时进行内存回收,从而使得接下来的命令可以顺利执行。如果内存回收失败,那么不再执行后续步骤,向客户端返回一个错误。
  • 如果服务器上一次执行BGSAVE命令时出错,并且服务器打开了stop-writes-on-bgsave-error功能,而且服务器即将要执行的命令是一个写命令,那么服务器将拒绝执行这个命令,并向客户端返回一个错误。
  • 如果客户端当前正在用SUBSCRIBE命令订阅频道,或者正在用PSUBSCRIBE命令订阅模式,那么服务器只会执行客户端发来的SUBSCRIBE、PSUBSCRIBE、UNSUBSCRIBE、PUNSUBSCRIBE四个命令,其他命令都会被服务器拒绝。
  • 如果服务器正在进行数据载入,那么客户端发送的命令必须带有l标识(比如INFO、SHUTDOWN、PUBLISH等等)才会被服务器执行,其他命令都会被服务器拒绝。
  • 如果服务器因为执行Lua脚本而超时并进入阻塞状态,那么服务器只会执行客户端发来的SHUTDOWN nosave命令和SCRIPT KILL命令,其他命令都会被服务器拒绝。
  • 如果客户端正在执行事务,那么服务器只会执行客户端发来的EXEC、DISCARD、MULTI、WATCH四个命令,其他命令都会被放进事务队列中。
  • 如果服务器打开了监视器功能,那么服务器会将要执行的命令和参数等信息发送给监视器。当完成了以上预备操作之后,服务器就可以开始真正执行命令了。

注意事项:以上只列出了服务器在单机模式下执行命令时的检查操作,当服务器在复制或者集群模式下执行命令时,预备操作还会更多一些。

调用命令的实现函数

在前面的操作中,服务器已经将要执行命令的实现保存到了客户端状态的cmd属性里面,并将命令的参数和参数个数分别保存到了客户端状态的argv属性和argv属性里面,当服务器决定要执行命令时,它只要执行以下语句就可以了:

// client是指向客户端状态的指针
client->cmd->proc(client);

客户端状态:

客户端状态

被调用的命令实现函数会执行指定的操作,并产生相应的命令回复,这些回复会被保存在客户端状态的输出缓冲区里面(buf属性和reply属性),之后实现函数还会为客户端的套接字关联命令回复处理器,这个处理器负责将命令回复返回给客户端。

保存了命令回复的客户端状态:

保存了命令回复的客户端状态

执行后续工作

在执行完实现函数之后,服务器还需要执行一些后续工作:

  • 如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志。
  • 根据刚刚执行命令所耗费的时长,更新被执行命令的redisCommand结构的milliseconds属性,并将命令的redisCommand结构的calls计数器的值增一。
  • 如果服务器开启了AOF持久化功能,那么AOF持久化模块会将刚刚执行的命令请求写入到AOF缓冲区里面。
  • 如果有其他从服务器正在复制当前这个服务器,那么服务器会将刚刚执行的命令传播给所有从服务器。

当以上操作都执行完了之后,服务器对于当前命令的执行到此就告一段落了,之后服务器就可以继续从文件事件处理器中取出并处理下一个命令请求了。

将命令回复给客户端

命令实现函数会将命令回复保存到客户端的输出缓冲区里面,并为客户端的套接字关联命令回复处理器,当客户端套接字变为可写状态时,服务器就会执行命令回复处理器,将保存在客户端输出缓冲区中的命令回复发送给客户端。

当命令回复发送完毕之后,回复处理器会清空客户端状态的输出缓冲区,为处理下一个命令请求做好准备。

客户端接收并打印命令回复

当客户端接收到协议格式的命令回复之后,它会将这些回复转换成人类可读的格式,并打印给用户观看(假设使用的是Redis自带的redis-cli客户端)。

户端接收并打印命令回复的过程

serverCron函数

Redis服务器中的serverCron函数默认每隔100毫秒执行一次,这个函数负责管理服务器的资源,并保持服务器自身的良好运转。

更新服务器的时间缓存

Redis服务器中有不少功能需要获取系统的当前时间,而每次获取系统的当前时间都需要执行一次系统调用,为了减少系统调用的执行次数,服务器状态中的unixtime属性和mstime属性被用作当前时间的缓存:

struct redisServer {
    // ...
    // 保存了秒级精度的系统当前UNIX时间戳
    time_t unixtime;
    // 保存了毫秒级精度的系统当前UNIX时间戳
    long long mstime;
    // ...
};

因为serverCron函数默认会以每100毫秒一次的频率更新unixtime属性和mstime属性,所以这两个属性记录的时间的精确度并不高:

  • 服务器只会在打印日志、更新服务器的LRU时钟、决定是否执行持久化任务、计算服务器上线时间(uptime)这类对时间精确度要求不高的功能上。
  • 对于为键设置过期时间、添加慢查询日志这种需要高精确度时间的功能来说,服务器还是会再次执行系统调用,从而获得最准确的系统当前时间。

更新LRU时钟

服务器状态中的lruclock属性保存了服务器的LRU时钟,这个属性和上面介绍的unixtime属性、mstime属性一样,都是服务器时间缓存的一种:

struct redisServer {
    // ...
    // 默认每10秒更新一次的时钟缓存,
    // 用于计算键的空转(idle)时长。
    unsigned lruclock:22;
    // ...
};

每个Redis对象都会有一个lru属性,这个lru属性保存了对象最后一次被命令访问的时间:

typedef struct redisObject {
    // ...
    unsigned lru:22;
    // ...
} robj;

当服务器要计算一个数据库键的空转时间(也即是数据库键对应的值对象的空转时间),程序会用服务器的lruclock属性记录的时间减去对象的lru属性记录的时间,得出的计算结果就是这个对象的空转时间:

redis> SET msg "hello world"
OK
# 等待一小段时间
redis> OBJECT IDLETIME msg
(integer)20
# 等待一阵子
redis> OBJECT IDLETIME msg
(integer)180
# 访问msg键的值
redis> GET msg
"hello world"
# 键处于活跃状态,空转时长为0
redis> OBJECT IDLETIME msg
(integer)0

serverCron函数默认会以每10秒一次的频率更新lruclock属性的值,因为这个时钟不是实时的,所以根据这个属性计算出来的LRU时间实际上只是一个模糊的估算值。

lruclock时钟的当前值可以通过INFO server命令的lru_clock域查看:

redis> INFO server
# Server
...
lru_clock:55923
...

更新服务器的每秒执行命令次数

serverCron函数中的trackOperationsPerSecond函数会以每100毫秒一次的频率执行,这个函数的功能是以抽样计算的方式,估算并记录服务器在最近一秒钟处理的命令请求数量,这个值可以通过INFO status命令的instantaneous_ops_per_sec域查看:

redis> INFO stats
# Stats
...
instantaneous_ops_per_sec:6
...

trackOperationsPerSecond函数和服务器状态中四个ops_sec_开头的属性有关:

struct redisServer {
    // ...
    // 上一次进行抽样的时间
    long long ops_sec_last_sample_time;
    // 上一次抽样时,服务器已执行命令的数量
    long long ops_sec_last_sample_ops;
    // REDIS_OPS_SEC_SAMPLES大小(默认值为16)的环形数组,
    // 数组中的每个项都记录了一次抽样结果。
    long long ops_sec_samples[REDIS_OPS_SEC_SAMPLES];
    // ops_sec_samples数组的索引值,
    // 每次抽样后将值自增一,
    // 在值等于16时重置为0,
    // 让ops_sec_samples数组构成一个环形数组。
    int ops_sec_idx;
    // ...
};

trackOperationsPerSecond函数每次运行,都会根据ops_sec_last_sample_time记录的上一次抽样时间和服务器的当前时间, 以及ops_sec_last_sample_ops记录的上一次抽样的已执行命令数量和服务器当前的已执行命令数量,计算出两次trackOperationsPerSecond调用之间, 服务器平均每一毫秒处理了多少个命令请求,然后将这个平均值乘以1000,这就得到了服务器在一秒钟内能处理多少个命令请求的估计值, 这个估计值会被作为一个新的数组项被放进ops_sec_samples环形数组里面。

当客户端执行INFO命令时,服务器就会调用getOperationsPerSecond函数,根据ops_sec_samples环形数组中的抽样结果,计算出instantaneous_ops_per_sec属性的值, instantaneous_ops_per_sec属性的值是通过计算最近REDIS_OPS_SEC_SAMPLES次取样的平均值来计算得出的,它只是一个估算值。

更新服务器的内存峰值记录

服务器状态中的stat_peak_memory属性记录了服务器的内存峰值大小:

struct redisServer {
    // ...
    // 已使用内存峰值
    size_t stat_peak_memory;
    // ...
};

每次serverCron函数执行时,程序都会查看服务器当前使用的内存数量,并与stat_peak_memory保存的数值进行比较, 如果当前使用的内存数量比stat_peak_memory属性记录的值要大,那么程序就将当前使用的内存数量记录到stat_peak_memory属性里面。

INFO memory命令的used_memory_peak和used_memory_peak_human两个域分别以两种格式记录了服务器的内存峰值:

redis> INFO memory
# Memory
...
used_memory_peak:501824
used_memory_peak_human:490.06K
...

处理SIGTERM信号

在启动服务器时,Redis会为服务器进程的SIGTERM信号关联处理器sigtermHandler函数,这个信号处理器负责在服务器接到SIGTERM信号时,打开服务器状态的shutdown_asap标识:

// SIGTERM信号的处理器
static void sigtermHandler(int sig) {
    // 打印日志
    redisLogFromHandler(REDIS_WARNING,"Received SIGTERM, scheduling shutdown...");
    // 打开关闭标识
    server.shutdown_asap = 1;
}

每次serverCron函数运行时,程序都会对服务器状态的shutdown_asap属性进行检查,并根据属性的值决定是否关闭服务器:

struct redisServer {
    // ...
    // 关闭服务器的标识:
    // 值为1时,关闭服务器,
    // 值为0时,不做动作。
    int shutdown_asap;
    // ...
};

服务器在关闭自身之前会进行RDB持久化操作,这也是服务器拦截SIGTERM信号的原因,如果服务器一接到SIGTERM信号就立即关闭,那么它就没办法执行持久化操作了。

管理客户端资源

serverCron函数每次执行都会调用clientsCron函数,clientsCron函数会对一定数量的客户端进行以下两个检查:

  • 如果客户端与服务器之间的连接已经超时(很长一段时间里客户端和服务器都没有互动),那么程序释放这个客户端。
  • 如果客户端在上一次执行命令请求之后,输入缓冲区的大小超过了一定的长度,那么程序会释放客户端当前的输入缓冲区,并重新创建一个默认大小的输入缓冲区,从而防止客户端的输入缓冲区耗费了过多的内存。

管理数据库资源

serverCron函数每次执行都会调用databasesCron函数,这个函数会对服务器中的一部分数据库进行检查,删除其中的过期键,并在有需要时,对字典进行收缩操作。

被延迟的BGWRITEAOF

在服务器执行BGSAVE命令的期间,如果客户端向服务器发来BGREWRITEAOF命令,那么服务器会将BGREWRITEAOF命令的执行时间延迟到BGSAVE命令执行完毕之后。

服务器的aof_rewrite_scheduled标识记录了服务器是否延迟了BGREWRITEAOF命令:

struct redisServer {
    // ...
    // 如果值为1,那么表示有BGREWRITEAOF命令被延迟了。
    int aof_rewrite_scheduled;
    // ...
};

每次serverCron函数执行时,函数都会检查BGSAVE命令或者BGREWRITEAOF命令是否正在执行,如果这两个命令都没在执行,并且aof_rewrite_scheduled属性的值为1,那么服务器就会执行之前被推延的BGREWRITEAOF命令。

持久化操作的运行状态

服务器状态使用rdb_child_pid属性和aof_child_pid属性记录执行BGSAVE命令和BGREWRITEAOF命令的子进程的ID,这两个属性也可以用于检查BGSAVE命令或者BGREWRITEAOF命令是否正在执行:

struct redisServer {
    // ...
    // 记录执行BGSAVE命令的子进程的ID:
    // 如果服务器没有在执行BGSAVE,
    // 那么这个属性的值为-1。
    pid_t rdb_child_pid;                /* PID of RDB saving child */
    // 记录执行BGREWRITEAOF命令的子进程的ID:
    // 如果服务器没有在执行BGREWRITEAOF,
    // 那么这个属性的值为-1。
    pid_t aof_child_pid;                /* PID if rewriting process */
    // ...
};

每次serverCron函数执行时,程序都会检查rdb_child_pid和aof_child_pid两个属性的值,只要其中一个属性的值不为-1,程序就会执行一次wait3函数,检查子进程是否有信号发来服务器进程:

  • 如果有信号到达,那么表示新的RDB文件已经生成完毕(对于BGSAVE命令来说),或者AOF文件已经重写完毕(对于BGREWRITEAOF命令来说),服务器需要进行相应命令的后续操作,比如用新的RDB文件替换现有的RDB文件,或者用重写后的AOF文件替换现有的AOF文件。
  • 如果没有信号到达,那么表示持久化操作未完成,程序不做动作。

另一方面,如果rdb_child_pid和aof_child_pid两个属性的值都为-1,那么表示服务器没有在进行持久化操作,在这种情况下,程序执行以下三个检查:

  1. 查看是否有BGREWRITEAOF被延迟了,如果有的话,那么开始一次新的BGREWRITEAOF操作(这就是上一个小节说到的检查)。
  2. 检查服务器的自动保存条件是否已经被满足,如果条件满足,并且服务器没有在执行其他持久化操作,那么服务器开始一次新的BGSAVE操作(因为条件1可能会引发一次BGREWRITEAOF,所以在这个检查中,程序会再次确认服务器是否已经在执行持久化操作了)。
  3. 检查服务器设置的AOF重写条件是否满足,如果条件满足,并且服务器没有在执行其他持久化操作,那么服务器将开始一次新的BGREWRITEAOF操作(因为条件1和条件2都可能会引起新的持久化操作,所以在这个检查中,要再次确认服务器是否已经在执行持久化操作了)。

判断是否需要执行持久化操作:

判断是否需要执行持久化操作

AOF缓冲区的内容写入AOF文件

如果服务器开启了AOF持久化功能,并且AOF缓冲区里面还有待写入的数据,那么serverCron函数会调用相应的程序,将AOF缓冲区中的内容写入到AOF文件里面。

关闭异步客户端

在这一步,服务器会关闭那些输出缓冲区大小超出限制的客户端。

增加cronloops计数器的值

服务器状态的cronloops属性记录了serverCron函数执行的次数:

struct redisServer {
    // ...
    // serverCron函数的运行次数计数器
    // serverCron函数每执行一次,这个属性的值就增一。
    int cronloops;
    // ...
};

cronloops属性目前在服务器中的唯一作用,就是在复制模块中实现“每执行serverCron函数N次就执行一次指定代码”的功能。

初始化服务器

一个Redis服务器从启动到能够接受客户端的命令请求,需要经过一系列的初始化和设置过程,比如初始化服务器状态,接受用户指定的服务器配置,创建相应的数据结构和网络连接等等。

初始化服务器状态结构

初始化服务器的第一步就是创建一个struct redisServer类型的实例变量server作为服务器的状态,并为结构中的各个属性设置默认值。

初始化server变量的工作由redis.c/initServerConfig函数完成。

void initServerConfig(void) {
    // 设置服务器的运行id
    getRandomHexChars(server.runid,REDIS_RUN_ID_SIZE);
    // 为运行id加上结尾字符
    server.runid[REDIS_RUN_ID_SIZE] = '\0';
    // 设置默认配置文件路径
    server.configfile = NULL;
    // 设置默认服务器频率
    server.hz = REDIS_DEFAULT_HZ;
    // 设置服务器的运行架构
    server.arch_bits = (sizeof(long) == 8) ? 64 : 32;
    // 设置默认服务器端口号
    server.port = REDIS_SERVERPORT;
    // ...
}

以下是initServerConfig函数完成的主要工作:

  • 设置服务器的运行ID。
  • 设置服务器的默认运行频率。
  • 设置服务器的默认配置文件路径。
  • 设置服务器的运行架构。
  • 设置服务器的默认端口号。
  • 设置服务器的默认RDB持久化条件和AOF持久化条件。
  • 初始化服务器的LRU时钟。
  • 创建命令表。

initServerConfig函数设置的服务器状态属性基本都是一些整数、浮点数、或者字符串属性,除了命令表之外,initServerConfig函数没有创建服务器状态的其他数据结构,数据库、慢查询日志、Lua环境、共享对象这些数据结构在之后的步骤才会被创建出来。

当initServerConfig函数执行完毕之后,服务器就可以进入初始化的第二个阶段——载入配置选项。

载入配置选项

服务器在用initServerConfig函数初始化完server变量之后,就会开始载入用户给定的配置参数和配置文件,并根据用户设定的配置,对server变量相关属性的值进行修改。

  • 如果用户为这些属性的相应选项指定了新的值,那么服务器就使用用户指定的值来更新相应的属性。
  • 如果用户没有为属性的相应选项设置新的值,那么服务器就沿用之前initServerConfig函数为属性设置的默认值。

服务器在载入用户指定的配置选项,并对server状态进行更新之后,服务器就可以进入初始化的第三个阶段——初始化服务器数据结构。

初始化服务器数据结构

在之前执行initServerConfig函数初始化server状态时,程序只创建了命令表一个数据结构,服务器状态还包含其他数据结构,比如:

  • server.clients链表,这个链表记录了所有与服务器相连的客户端的状态结构,链表的每个节点都包含了一个redisClient结构实例。
  • server.db数组,数组中包含了服务器的所有数据库。
  • 用于保存频道订阅信息的server.pubsub_channels字典,以及用于保存模式订阅信息的server.pubsub_patterns链表。
  • 用于执行Lua脚本的Lua环境server.lua。
  • 用于保存慢查询日志的server.slowlog属性。

当初始化服务器进行到这一步,服务器将调用initServer函数,为以上提到的数据结构分配内存,并在有需要时,为这些数据结构设置或者关联初始化值。

除了初始化数据结构之外,initServer还进行了一些非常重要的设置操作,其中包括:

  • 为服务器设置进程信号处理器。
  • 创建共享对象:这些对象包含Redis服务器经常用到的一些值,比如包含”OK”回复的字符串对象,包含”ERR”回复的字符串对象,包含整数1到10000的字符串对象等等,服务器通过重用这些共享对象来避免反复创建相同的对象。
  • 打开服务器的监听端口,并为监听套接字关联连接应答事件处理器,等待服务器正式运行时接受客户端的连接。
  • 为serverCron函数创建时间事件,等待服务器正式运行时执行serverCron函数。
  • 如果AOF持久化功能已经打开,那么打开现有的AOF文件,如果AOF文件不存在,那么创建并打开一个新的AOF文件,为AOF写入做好准备。
  • 初始化服务器的后台I/O模块(bio),为将来的I/O操作做好准备。

当initServer函数执行完毕之后,服务器将用ASCII字符在日志中打印出Redis的图标,以及Redis的版本号信息。

还原数据库状态

在完成了对服务器状态server变量的初始化之后,服务器需要载入RDB文件或者AOF文件,并根据文件记录的内容来还原服务器的数据库状态。

根据服务器是否启用了AOF持久化功能,服务器载入数据时所使用的目标文件会有所不同:

  • 如果服务器启用了AOF持久化功能,那么服务器使用AOF文件来还原数据库状态。
  • 如果服务器没有启用AOF持久化功能,那么服务器使用RDB文件来还原数据库状态。

当服务器完成数据库状态还原工作之后,服务器将在日志中打印出载入文件并还原数据库状态所耗费的时长。

执行事件循环

在初始化的最后一步,服务器将打印出以下日志:

[5244] 21 Nov 22:43:49.084 * The server is now ready to accept connections on port 6379

并开始执行服务器的事件循环(loop)。

至此,服务器的初始化工作圆满完成,服务器现在开始可以接受客户端的连接请求,并处理客户端发来的命令请求了。

服务器命令(客户端命令)

Redis服务器命令基本上都用于管理Redis服务器。

命令 作用
BGREWRITEAOF 异步改写仅追加文件
BGSAVE 异步保存数据集到磁盘(快照)
CLIENT KILL [ip:port] [ID client-id] 杀死一个客户端的连接
CLIENT LIST 获取客户端连接到服务器的连接列表
CLIENT GETNAME 获取当前连接的名称
CLIENT PAUSE timeout 停止指定的时间处理来自客户端的命令,此期间客户端连接将被阻塞
CLIENT SETNAME connection-name 设置当前连接名称
CLUSTER SLOTS 获取集群插槽数组节点的映射
COMMAND 获取Redis的命令的详细信息数组
COMMAND COUNT 得到的Redis命令的总数
COMMAND GETKEYS 给予充分的Redis命令提取键
COMMAND INFO command-name [command-name …] 获取具体的Redis命令的详细信息数组
CONFIG GET parameter 获取配置参数的值
CONFIG REWRITE 重写的存储器配置的配置文件
CONFIG SET parameter value 配置参数设置为给定值
CONFIG RESETSTAT 复位信息返回的统计
DBSIZE 返回所选数据库中的键的数目
DEBUG OBJECT key 获取有关的一个关键的调试信息
DEBUG SEGFAULT 使服务器崩溃
FLUSHALL 从所有数据库中删除所有项
FLUSHDB 从当前数据库中删除所有项
INFO [section] 获取有关服务器的信息和统计数据
LASTSAVE 获得最后成功的UNIX时间时间戳保存到磁盘
MONITOR 监听由实时服务器接收到的所有请求
ROLE 返回在复制的情况下实例的角色
SAVE 同步保存数据集到磁盘(快照)
SHUTDOWN [NOSAVE] [SAVE] 同步的数据集保存到磁盘,然后关闭服务器
SLAVEOF host port 使服务器为另一个实例的从站或者促进其作为主
SLOWLOG subcommand [argument] 管理Redis的慢查询日志
SYNC 命令用于复制
TIME 返回当前服务器时间

复制

  • Redis 2.8以前的复制功能不能高效地处理断线后重复制情况,但Redis 2.8新添加的部分重同步功能可以解决这个问题。
  • 部分重同步通过复制偏移量、复制积压缓冲区、服务器运行ID三个部分来实现。
  • 在复制操作刚开始的时候,从服务器会成为主服务器的客户端,并通过向主服务器发送命令请求来执行复制步骤,而在复制操作的后期,主从服务器会互相成为对方的客户端。
  • 主服务器通过向从服务器传播命令来更新从服务器的状态,保持主从服务器一致,而从服务器则通过向主服务器发送命令来进行心跳检测,以及命令丢失检测。

在Redis中,用户可以通过执行SLAVEOF命令或者设置slaveof选项,让一个服务器去复制(replicate)另一个服务器。

127.0.0.1:12345> SLAVEOF 127.0.0.1 6379
OK
  1. 在配置文件中加入slaveof {masterHost} {masterPort}随Redis启动生效。
  2. 在redis-server启动命令后加入–slaveof {masterHost} {masterPort}生效。
  3. 直接使用命令:slaveof {masterHost} {masterPort}生效。
  4. 使用slaveof no one断开复制。
    • 断开与主节点复制关系
    • 从节点晋升为主节点

进行复制中的主从服务器双方的数据库将保存相同的数据,概念上将这种现象称作“数据库状态一致”,或者简称“一致”。

Redis在2.8版本以前使用的旧版复制功能的实现原理,并说明旧版复制功能在处理断线后重新连接的从服务器时,会遇上怎样的低效情况。
Redis从2.8版本开始使用的新版复制功能是如何通过部分重同步来解决旧版复制功能的低效问题的,并说明部分重同步的实现原理。

旧版复制功能的实现

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

  • 同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态。
  • 命令传播操作则用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重新回到一致状态。

同步

当客户端向从服务器发送SLAVEOF命令,要求从服务器复制主服务器时,从服务器首先需要执行同步操作,也即是,将从服务器的数据库状态更新至主服务器当前所处的数据库状态。

从服务器对主服务器的同步操作需要通过向主服务器发送SYNC命令来完成,以下是SYNC命令的执行步骤:

  1. 从服务器向主服务器发送SYNC命令。
  2. 收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令。
  3. 当主服务器的BGSAVE命令执行完毕时,主服务器会将BGSAVE命令生成的RDB文件发送给从服务器,从服务器接收并载入这个RDB文件,将自己的数据库状态更新至主服务器执行BGSAVE命令时的数据库状态。
  4. 主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态。

主从服务器在执行SYNC命令期间的通信过程:

主从服务器在执行SYNC命令期间的通信过程

一个主从服务器进行同步的例子:

时间 主服务器 从服务器
T0 服务器启动。 服务器启动。
T1 执行 SET k1 v1 。  
T2 执行 SET k2 v2 。  
T3 执行 SET k3 v3 。  
T4   向主服务器发送 SYNC 命令。
T5 接收到从服务器发来的 SYNC 命令, 执行 BGSAVE 命令, 创建包含键 k1 、 k2 、 k3 的 RDB 文件, 并使用缓冲区记录接下来执行的所有写命令。  
T6 执行 SET k4 v4 , 并将这个命令记录到缓冲区里面。  
T7 执行 SET k5 v5 , 并将这个命令记录到缓冲区里面。  
T8 BGSAVE 命令执行完毕, 向从服务器发送 RDB 文件。  
T9   接收并载入主服务器发来的 RDB 文件 , 获得 k1 、 k2 、 k3 三个键。
T10 向从服务器发送缓冲区中保存的写命令 SET k4 v4 和 SET k5 v5 。  
T11   接收并执行主服务器发来的两个 SET 命令, 得到 k4 和 k5 两个键。
T12 同步完成, 现在主从服务器两者的数据库都包含了键 k1 、 k2 、 k3 、 k4 和 k5 。 同步完成, 现在主从服务器两者的数据库都包含了键 k1 、 k2 、 k3 、 k4 和 k5 。

命令传播

主服务器会将自己执行的写命令,也即是造成主从服务器不一致的那条写命令,发送给从服务器执行,当从服务器执行了相同的写命令之后,主从服务器将再次回到一致状态。

旧版复制功能的缺陷

在Redis中,从服务器对主服务器的复制可以分为以下两种情况:

  • 初次复制:从服务器以前没有复制过任何主服务器,或者从服务器当前要复制的主服务器和上一次复制的主服务器不同。
  • 断线后重复制:处于命令传播阶段的主从服务器因为网络原因而中断了复制,但从服务器通过自动重连接重新连上了主服务器,并继续复制主服务器。

对于初次复制来说,旧版复制功能能够很好地完成任务,但对于断线后重复制来说,旧版复制功能虽然也能让主从服务器重新回到一致状态,但效率却非常低。

从服务器在断线之后重新复制主服务器的例子 从服务器在断线之后重新复制主服务器的例子

在时间T10091,从服务器终于重新连接上主服务器,因为这时主从服务器的状态已经不再一致,所以从服务器将向主服务器发送SYNC命令, 而主服务器会将包含键k1至键k10089的RDB文件发送给从服务器,从服务器通过接收和载入这个RDB文件来将自己的数据库更新至主服务器数据库当前所处的状态。

虽然再次发送SYNC命令可以让主从服务器重新回到一致状态,但如果仔细研究这个断线重复制过程,就会发现传送RDB文件这一步实际上并不是非做不可的:

  • 主从服务器在时间T0至时间T10086中一直处于一致状态,这两个服务器保存的数据大部分都是相同的。
  • 从服务器想要将自己更新至主服务器当前所处的状态,真正需要的是主从服务器连接中断期间,主服务器新添加的k10087、k10088、k10089三个键的数据。
  • 可惜的是,旧版复制功能并没有利用以上列举的两点条件,而是继续让主服务器生成并向从服务器发送包含键k1至键k10089的RDB文件,但实际上RDB文件包含的键k1至键k10086的数据对于从服务器来说都是不必要的。

总的来说,主从服务器断开的时间越短,主服务器在断线期间执行的写命令就越少,而执行少量写命令所产生的数据量通常比整个数据库的数据量要少得多, 在这种情况下,为了让从服务器补足一小部分缺失的数据,却要让主从服务器重新执行一次SYNC命令,这种做法无疑是非常低效的。

SYNC命令是一个非常耗费资源的操作

每次执行SYNC命令,主从服务器需要执行以下动作:

  1. 主服务器需要执行BGSAVE命令来生成RDB文件,这个生成操作会耗费主服务器大量的CPU、内存和磁盘I/O资源。
  2. 主服务器需要将自己生成的RDB文件发送给从服务器,这个发送操作会耗费主从服务器大量的网络资源(带宽和流量),并对主服务器响应命令请求的时间产生影响。
  3. 接收到RDB文件的从服务器需要载入主服务器发来的RDB文件,并且在载入期间,从服务器会因为阻塞而没办法处理命令请求。

因为SYNC命令是一个如此耗费资源的操作,所以Redis有必要保证在真正有需要时才执行SYNC命令。

新版复制功能的实现

为了解决旧版复制功能在处理断线重复制情况时的低效问题,Redis从2.8版本开始,使用PSYNC命令代替SYNC命令来执行复制时的同步操作。

PSYNC命令具有完整重同步(full resynchronization)和部分重同步(partial resynchronization)两种模式:

  • 其中完整重同步用于处理初次复制情况:完整重同步的执行步骤和SYNC命令的执行步骤基本一样,它们都是通过让主服务器创建并发送RDB文件,以及向从服务器发送保存在缓冲区里面的写命令来进行同步。
  • 而部分重同步则用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态。

PSYNC命令的部分重同步模式解决了旧版复制功能在处理断线后重复制时出现的低效情况:

使用PSYNC命令来进行断线后重复制 使用PSYNC命令来进行断线后重复制

执行SYNC命令需要生成、传送和载入整个RDB文件,而部分重同步只需要将从服务器缺少的写命令发送给从服务器执行就可以了。

主从服务器执行部分重同步的过程:

主从服务器执行部分重同步的过程

部分重同步的实现

部分重同步功能由以下三个部分构成:

  • 主服务器的复制偏移量(replication offset)和从服务器的复制偏移量。
  • 主服务器的复制积压缓冲区(replication backlog)。
  • 服务器的运行ID(run ID)。

复制偏移量

执行复制的双方——主服务器和从服务器会分别维护一个复制偏移量:

  • 主服务器每次向从服务器传播N个字节的数据时,就将自己的复制偏移量的值加上N。
  • 从服务器每次收到主服务器传播来的N个字节的数据时,就将自己的复制偏移量的值加上N。

拥有相同偏移量的主服务器和它的三个从服务器:

拥有相同偏移量的主服务器和它的三个从服务器

更新偏移量之后的主从服务器:

更新偏移量之后的主从服务器

通过对比主从服务器的复制偏移量,程序可以很容易地知道主从服务器是否处于一致状态:

  • 如果主从服务器处于一致状态,那么主从服务器两者的偏移量总是相同的。
  • 如果主从服务器两者的偏移量并不相同,那么说明主从服务器并未处于一致状态。

复制积压缓冲区

复制积压缓冲区是由主服务器维护的一个固定长度(fixed-size)先进先出(FIFO)队列,默认大小为1MB。

固定长度先进先出队列的入队和出队规则跟普通的先进先出队列一样:新元素从一边进入队列,而旧元素从另一边弹出队列。 和普通先进先出队列随着元素的增加和减少而动态调整长度不同,固定长度先进先出队列的长度是固定的,当入队元素的数量大于队列长度时,最先入队的元素会被弹出,而新元素会被放入队列。

当主服务器进行命令传播时,它不仅会将写命令发送给所有从服务器,还会将写命令入队到复制积压缓冲区里面:

主服务器向复制积压缓冲区和所有从服务器传播写命令数据

因此,主服务器的复制积压缓冲区里面会保存着一部分最近传播的写命令,并且复制积压缓冲区会为队列中的每个字节记录相应的复制偏移量:

复制积压缓冲区的构造

当从服务器重新连上主服务器时,从服务器会通过PSYNC命令将自己的复制偏移量offset发送给主服务器,主服务器会根据这个复制偏移量来决定对从服务器执行何种同步操作:

  • 如果offset偏移量之后的数据(也即是偏移量offset+1开始的数据)仍然存在于复制积压缓冲区里面,那么主服务器将对从服务器执行部分重同步操作。
  • 如果offset偏移量之后的数据已经不存在于复制积压缓冲区,那么主服务器将对从服务器执行完整重同步操作。

正确估算和设置复制积压缓冲区的大小非常重要。

复制积压缓冲区的最小大小可以根据公式second * write_size_per_second来估算:

  • 其中second为从服务器断线后重新连接上主服务器所需的平均时间(以秒计算)。
  • 而write_size_per_second则是主服务器平均每秒产生的写命令数据量(协议格式的写命令的长度总和)。

为了安全起见,可以将复制积压缓冲区的大小设为2 * second * write_size_per_second,这样可以保证绝大部分断线情况都能用部分重同步来处理。

至于复制积压缓冲区大小的修改方法,可以参考配置文件中关于repl-backlog-size选项的说明。

127.0.0.1:6379> info replication
# Replication
role:master
...
repl_backlog_active:1                   # 开启复制缓冲区
repl_backlog_size:1048576               # 缓冲区最大长度
repl_backlog_first_byte_offset:7479     # 起始偏移量,计算当前缓冲区可用范围
repl_backlog_histlen:1048576            # 已保存数据的有效长度。

根据统计指标,可算出复制积压缓冲区内的可用偏移量范围:

[repl_backlog_first_byte_offset,repl_backlog_first_byte_offset+repl_backlog_histlen]。

服务器运行ID

除了复制偏移量和复制积压缓冲区之外,实现部分重同步还需要用到服务器运行ID(run ID):

  • 每个Redis服务器,不论主服务器还是从服务,都会有自己的运行ID。
  • 运行ID在服务器启动时自动生成,由40个随机的十六进制字符组成,例如53b9b28df8042fdc9ab5e3fcbbbabff1d5dce2b3。

当从服务器对主服务器进行初次复制时,主服务器会将自己的运行ID传送给从服务器,而从服务器则会将这个运行ID保存起来。

当从服务器断线并重新连上一个主服务器时,从服务器将向当前连接的主服务器发送之前保存的运行ID:

  • 如果从服务器保存的运行ID和当前连接的主服务器的运行ID相同,那么说明从服务器断线之前复制的就是当前连接的这个主服务器,主服务器可以继续尝试执行部分重同步操作。
  • 如果从服务器保存的运行ID和当前连接的主服务器的运行ID并不相同,那么说明从服务器断线之前复制的主服务器并不是当前连接的这个主服务器,主服务器将对从服务器执行完整重同步操作。

PSYNC命令的实现

PSYNC命令的调用方法有两种:

  • 如果从服务器以前没有复制过任何主服务器,或者之前执行过SLAVEOF no one命令,那么从服务器在开始一次新的复制时将向主服务器发送PSYNC ? -1命令,主动请求主服务器进行完整重同步(因为这时不可能执行部分重同步)。
  • 相反地,如果从服务器已经复制过某个主服务器,那么从服务器在开始一次新的复制时将向主服务器发送PSYNC <runid> <offset>命令:其中runid是上一次复制的主服务器的运行ID,而offset则是从服务器当前的复制偏移量,接收到这个命令的主服务器会通过这两个参数来判断应该对从服务器执行哪种同步操作。

根据情况,接收到PSYNC命令的主服务器会向从服务器返回以下三种回复的其中一种:

  • 如果主服务器返回+FULLRESYNC <runid> <offset>回复,那么表示主服务器将与从服务器执行完整重同步操作:其中runid是这个主服务器的运行ID,从服务器会将这个ID保存起来,在下一次发送PSYNC命令时使用;而offset则是主服务器当前的复制偏移量,从服务器会将这个值作为自己的初始化偏移量。
  • 如果主服务器返回+CONTINUE回复,那么表示主服务器将与从服务器执行部分重同步操作,从服务器只要等着主服务器将自己缺少的那部分数据发送过来就可以了。
  • 如果主服务器返回-ERR回复,那么表示主服务器的版本低于Redis 2.8,它识别不了PSYNC命令,从服务器将向主服务器发送SYNC命令,并与主服务器执行完整同步操作。

PSYNC执行完整重同步和部分重同步时可能遇上的情况:

PSYNC执行完整重同步和部分重同步时可能遇上的情况

全量复制流程:

全量复制流程

部分复制过程:

部分复制过程

复制的实现

通过向从服务器发送SLAVEOF命令,可以让一个从服务器去复制一个主服务器:

SLAVEOF <master_ip> <master_port>

主从节点建立复制流程图

设置主服务器的地址和端口

从服务器首先要做的就是将客户端给定的主服务器IP地址127.0.0.1以及端口6379保存到服务器状态的masterhost属性和masterport属性里面:

struct redisServer {
    // ...
    // 主服务器的地址
    char *masterhost;
    // 主服务器的端口
    int masterport;
    // ...
};

SLAVEOF命令是一个异步命令,在完成masterhost属性和masterport属性的设置工作之后,从服务器将向发送SLAVEOF命令的客户端返回OK,表示复制指令已经被接收,而实际的复制工作将在OK返回之后才真正开始执行。

建立套接字连接

在SLAVEOF命令执行之后,从服务器将根据命令所设置的IP地址和端口,创建连向主服务器的套接字连接。

如果从服务器创建的套接字能成功连接(connect)到主服务器,那么从服务器将为这个套接字关联一个专门用于处理复制工作的文件事件处理器,这个处理器将负责执行后续的复制工作。

而主服务器在接受(accept)从服务器的套接字连接之后,将为该套接字创建相应的客户端状态,并将从服务器看作是一个连接到主服务器的客户端来对待, 这时从服务器将同时具有服务器(server)和客户端(client)两个身份:从服务器可以向主服务器发送命令请求,而主服务器则会向从服务器返回命令回复。

主从服务器之间的关系

因为复制工作接下来的几个步骤都会以从服务器向主服务器发送命令请求的形式来进行,所以理解“从服务器是主服务器的客户端”这一点非常重要。

发送PING命令

从服务器成为主服务器的客户端之后,做的第一件事就是向主服务器发送一个PING命令。

这个PING命令有两个作用:

  • 虽然主从服务器成功建立起了套接字连接,但双方并未使用该套接字进行过任何通信,通过发送PING命令可以检查套接字的读写状态是否正常。
  • 因为复制工作接下来的几个步骤都必须在主服务器可以正常处理命令请求的状态下才能进行,通过发送PING命令可以检查主服务器能否正常处理命令请求。

从服务器在发送PING命令之后将遇到以下三种情况的其中一种:

  • 如果主服务器向从服务器返回了一个命令回复,但从服务器却不能在规定的时限(timeout)内读取出命令回复的内容,那么表示主从服务器之间的网络连接状态不佳,不能继续执行复制工作的后续步骤。当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。
  • 如果主服务器向从服务器返回一个错误,那么表示主服务器暂时没办法处理从服务器的命令请求,不能继续执行复制工作的后续步骤。当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。
    • 比如说,如果主服务器正在处理一个超时运行的脚本,那么当从服务器向主服务器发送PING命令时,从服务器将收到主服务器返回的BUSY Redisis busy running a script.You can only call SCRIPT KILL or SHUTDOWN NOSAVE.错误。
  • 如果从服务器读取到”PONG”回复,那么表示主从服务器之间的网络连接状态正常,并且主服务器可以正常处理从服务器(客户端)发送的命令请求,在这种情况下,从服务器可以继续执行复制工作的下个步骤。

从服务器在发送PING命令时可能遇上的情况:

从服务器在发送PING命令时可能遇上的情况

身份验证

从服务器在收到主服务器返回的”PONG”回复之后,下一步要做的就是决定是否进行身份验证:

  • 如果从服务器设置了masterauth选项,那么进行身份验证。
  • 如果从服务器没有设置masterauth选项,那么不进行身份验证。

在需要进行身份验证的情况下,从服务器将向主服务器发送一条AUTH命令,命令的参数为从服务器masterauth选项的值。

从服务器在身份验证阶段可能遇到的情况有以下几种:

  • 如果主服务器没有设置requirepass选项,并且从服务器也没有设置masterauth选项,那么主服务器将继续执行从服务器发送的命令,复制工作可以继续进行。
  • 如果从服务器通过AUTH命令发送的密码和主服务器requirepass选项所设置的密码相同,那么主服务器将继续执行从服务器发送的命令,复制工作可以继续进行。与此相反,如果主从服务器设置的密码不相同,那么主服务器将返回一个invalid password错误。
  • 如果主服务器设置了requirepass选项,但从服务器却没有设置masterauth选项,那么主服务器将返回一个NOAUTH错误。另一方面,如果主服务器没有设置requirepass选项,但从服务器却设置了masterauth选项,那么主服务器将返回一个no password is set错误。

所有错误情况都会令从服务器中止目前的复制工作,并从创建套接字开始重新执行复制,直到身份验证通过,或者从服务器放弃执行复制为止。

从服务器在身份验证阶段可能遇上的情况:

从服务器在身份验证阶段可能遇上的情况

发送端口信息

在身份验证步骤之后,从服务器将执行命令REPLCONF listening-port <port-number>,向主服务器发送从服务器的监听端口号。

主服务器在接收到这个命令之后,会将端口号记录在从服务器所对应的客户端状态的slave_listening_port属性中:

typedef struct redisClient {
    // ...
    // 从服务器的监听端口号
    int slave_listening_port;
    // ...
} redisClient;

slave_listening_port属性目前唯一的作用就是在主服务器执行INFO replication命令时打印出从服务器的端口号。

同步

从服务器将向主服务器发送PSYNC命令,执行同步操作,并将自己的数据库更新至主服务器数据库当前所处的状态。

在同步操作执行之前,只有从服务器是主服务器的客户端,但是在执行同步操作之后,主服务器也会成为从服务器的客户端:

  • 如果PSYNC命令执行的是完整重同步操作,那么主服务器需要成为从服务器的客户端,才能将保存在缓冲区里面的写命令发送给从服务器执行。
  • 如果PSYNC命令执行的是部分重同步操作,那么主服务器需要成为从服务器的客户端,才能向从服务器发送保存在复制积压缓冲区里面的写命令。

因此,在同步操作执行之后,主从服务器双方都是对方的客户端,它们可以互相向对方发送命令请求,或者互相向对方返回命令回复。

正因为主服务器成为了从服务器的客户端,所以主服务器才可以通过发送写命令来改变从服务器的数据库状态,不仅同步操作需要用到这一点,这也是主服务器对从服务器执行命令传播操作的基础。

主从服务器之间互为客户端:

主从服务器之间互为客户端

从节点成功加载完RDB后,如果当前节点开启了AOF持久化功能,它会立刻做bgrewriteaof操作,为了保证全量复制后AOF持久化文件立刻可用。

命令传播

当完成了同步之后,主从服务器就会进入命令传播阶段,这时主服务器只要一直将自己执行的写命令发送给从服务器,而从服务器只要一直接收并执行主服务器发来的写命令,就可以保证主从服务器一直保持一致了。

心跳检测

主节点默认每隔10s对从服务器发送PING命令,判断从节点的存活性和连接状态,通过repl-ping-slave-period控制发送频率。

在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:

REPLCONF ACK <replication_offset>

其中replication_offset是从服务器当前的复制偏移量。

发送REPLCONF ACK命令对于主从服务器有三个作用:

  • 检测主从服务器的网络连接状态。
  • 辅助实现min-slaves选项。
  • 检测命令丢失。

检测主从服务器的网络连接状态

如果主服务器超过一秒钟没有收到从服务器发来的REPLCONF ACK命令,那么主服务器就知道主从服务器之间的连接出现问题了。

通过向主服务器发送INFO replication命令,在列出的从服务器列表的lag一栏中,可以看到相应从服务器最后一次向主服务器发送REPLCONF ACK命令距离现在过了多少秒:

127.0.0.1:6379> INFO replication
# Replication
role:master
connected_slaves:2
slave0:ip=127.0.0.1,port=12345,state=online,offset=211,lag=0  # 刚刚发送过 REPLCONF ACK命令
slave1:ip=127.0.0.1,port=56789,state=online,offset=197,lag=15   # 15秒之前发送过REPLCONF ACK命令
master_repl_offset:211
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:2
repl_backlog_histlen:210

在一般情况下,lag的值应该在0秒或者1秒之间跳动,如果超过1秒的话,那么说明主从服务器之间的连接出现了故障。

辅助实现min-slaves配置选项

Redis的min-slaves-to-write和min-slaves-max-lag两个选项可以防止主服务器在不安全的情况下执行写命令。

举个例子,如果向主服务器提供以下设置:

min-slaves-to-write 3
min-slaves-max-lag 10

那么在从服务器的数量少于3个,或者三个从服务器的延迟(lag)值都大于或等于10秒时,主服务器将拒绝执行写命令,这里的延迟值就是上面提到的INFO replication命令的lag值。

检测命令丢失

如果因为网络故障,主服务器传播给从服务器的写命令在半路丢失,那么当从服务器向主服务器发送REPLCONF ACK命令时,主服务 器将发觉从服务器当前的复制偏移量少于自己的复制偏移量, 然后主服务器就会根据从服务器提交的复制偏移量,在复制积压缓冲区里面找到从服务器缺少的数据,并将这些数据重新发送给从服务器。

主服务器向从服务器补发缺失数据这一操作的原理和部分重同步操作的原理非常相似。
这两个操作的区别在于,补发缺失数据操作在主从服务器没有断线的情况下执行,而部分重同步操作则在主从服务器断线并重连之后执行。

注意事项:REPLCONF ACK命令和复制积压缓冲区都是Redis 2.8版本新增的,在Redis 2.8版本以前,即使命令在传播过程中丢失,主服务器和从服务器都不会注意到,主服务器更不会向从服务器补发丢失的数据。

拓扑

一主一从结构

一主一从结构是最简单的复制拓扑结构,用于主节点出现宕机时从节点提供故障转移支持。

当应用写命令并发量较高且需要持久化时,可以只在从节点上开启AOF,这样既保证数据安全性同时也避免了持久化对主节点的性能干扰。 但需要注意的是,当主节点关闭持久化功能时,如果主节点脱机要避免自动重启操作。因为主节点之前没有开启持久化功能自动重启后数据集为空, 这时从节点如果继续复制主节点会导致从节点数据也被清空的情况,丧失了持久化的意义。

安全的做法是在从节点上执行slaveof no one断开与主节点的复制关系,再重启主节点从而避免这一问题。

一主多从结构

一主多从结构(又称为星形拓扑结构)使得应用端可以利用多个从节点实现读写分离。对于读占比较大的场景,可以把读命令发送到从节点来分担主节点压力。 同时在日常开发中如果需要执行一些比较耗时的读命令,如:keys、sort等,可以在其中一台从节点上执行,防止慢查询对主节点造成阻塞从而影响线上服务的稳定性。

对于写并发量较高的场景,多个从节点会导致主节点写命令的多次发送从而过度消耗网络带宽,同时也加重了主节点的负载影响服务稳定性。

树状主从结构

树状主从结构(又称为树状拓扑结构)使得从节点不但可以复制主节点数据,同时可以作为其他从节点的主节点继续向下层复制。

通过引入复制中间层,可以有效降低主节点负载和需要传送给从节点的数据量。 数据写入节点A后会同步到B和C节点,B节点再把数据同步到D和E节点,数据实现了一层一层的向下复制。 当主节点需要挂载多个从节点时为了避免对主节点的性能干扰,可以采用树状主从结构降低主节点压力。

树状主从结构

开发运维中的问题

读写分离

对于读占比较高的场景,可以通过把一部分读流量分摊到从节点(slave)来减轻主节点(master)压力,同时需要注意永远只对主节点执行写操作。

当使用从节点响应读请求时,业务端可能会遇到如下问题:

  • 复制数据延迟。
  • 读到过期数据。
  • 从节点故障。
数据延迟

Redis复制数据的延迟由于异步复制特性是无法避免的,延迟取决于网络带宽和命令阻塞情况,比如刚在主节点写入数据后立刻在从节点上读取可能获取不到。 需要业务场景允许短时间内的数据延迟。对于无法容忍大量延迟场景,可以编写外部监控程序监听主从节点的复制偏移量,当延迟较大时触发报警或者通知客户端避免读取延迟过高的从节点。

监控程序监控主从节点偏移量

  1. 监控程序(monitor)定期检查主从节点的偏移量,主节点偏移量在info replication的master_repl_offset指标记录,从节点偏移量可以查询主节点的slave0字段的offset指标,它们的差值就是主从节点延迟的字节量。
  2. 当延迟字节量过高时,比如超过10MB。监控程序触发报警并通知客户端从节点延迟过高。可以采用Zookeeper的监听回调机制实现客户端通知。
  3. 客户端接到具体的从节点高延迟通知后,修改读命令路由到其他从节点或主节点上。当延迟恢复后,再次通知客户端,恢复从节点的读命令请求。

这种方案的成本比较高,需要单独修改适配Redis的客户端类库。如果涉及多种语言成本将会扩大。 客户端逻辑需要识别出读写请求并自动路由,还需要维护故障和恢复的通知。

采用此方案视具体的业务而定,如果允许不一致性或对延迟不敏感的业务可以忽略,也可以采用Redis集群方案做水平扩展。

读到过期数据

当主节点存储大量设置超时的数据时,如缓存数据,Redis内部需要维护过期数据删除策略,删除策略主要有两种:惰性删除和定时删除。

如果此时数据大量超时,主节点采样速度跟不上过期速度且主节点没有读取过期键的操作,那么从节点将无法收到del命令。这时在从节点上可以393读取到已经超时的数据。

Redis在3.2版本解决了这个问题,从节点读取数据之前会检查键的过期时间来决定是否返回数据,可以升级到3.2版本来规避这个问题。

从节点的故障问题

对于从节点的故障问题,需要在客户端维护可用从节点列表,当从节点故障时立刻切换到其他从节点或主节点上。

这个过程类似上文提到的针对延迟过高的监控处理,需要开发人员改造客户端类库。

总结

使用Redis做读写分离存在一定的成本。

Redis本身的性能非常高,开发人员在使用额外的从节点提升读性能之前,尽量在主节点上做充分优化,比如解决慢查询,持久化阻塞,合理应用数据结构等,当主节点优化空间不大时再考虑扩展。

建议在做读写分离之前,可以考虑使用Redis Cluster等分布式解决方案,这样不止扩展了读性能还可以扩展写性能和可支撑数据规模,并且一致性和故障转移也可以得到保证,对于客户端的维护逻辑也相对容易。

主从不一致

主从配置不一致是一个容易忽视的问题。

对于有些配置主从之间是可以不一致,比如:

主节点关闭AOF在从节点开启。但对于内存相关的配置必须要一致,比如maxmemory,hash-max-ziplist-entries等参数。 当配置的maxmemory从节点小于主节点,如果复制的数据量超过从节点maxmemory时,它会根据maxmemory-policy策略进行内存溢出控制,此时从节点数据已经丢失,但主从复制流程依然正常进行,复制偏移量也正常。

当压缩列表相关参数不一致时,虽然主从节点存储的数据一致但实际内存占用情况差异会比较大。

修复这类问题也只能手动进行全量复制。

规避全量复制

第一次建立复制

由于是第一次建立复制,从节点不包含任何主节点数据,因此必须进行全量复制才能完成数据同步。

对于这种情况全量复制无法避免。当对数据量较大且流量较高的主节点添加从节点时,建议在低峰时进行操作,或者尽量规避使用大数据量的Redis节点。

节点运行ID不匹配

当主从复制关系建立后,从节点会保存主节点的运行ID,如果此时主节点因故障重启,那么它的运行ID会改变,从节点发现主节点运行ID不匹配时,会认为自己复制的是一个新的主节点从而进行全量复制。

对于这种情况应该从架构上规避,比如提供故障转移功能。当主节点发生故障后,手动提升从节点为主节点或者采用支持自动故障转移的哨兵或集群方案。

复制积压缓冲区不足

当主从节点网络中断后,从节点再次连上主节点时会发送psync{offset}{runId}命令请求部分复制,如果请求的偏移量不在主节点的积压缓冲区内,则无法提供给从节点数据,因此部分复制会退化为全量复制。

针对这种情况需要根据网络中断时长,写命令数据量分析出合理的积压缓冲区大小。网络中断一般有闪断、机房割接、网络分区等情况。

这时网络中断的时长一般在分钟级(net_break_time)。写命令数据量可以统计高峰期主节点每秒info replication的master_repl_offset差值获取(write_size_per_minute)。 积压缓冲区默认为1MB,对于大流量场景显然不够,这时需要增大积压缓冲区,保证repl_backlog_size > net_break_time * write_size_per_minute,从而避免因复制积压缓冲区不足造成的全量复制。

避免复制风暴

复制风暴是指大量从节点对同一主节点或者对同一台机器的多个主节点短时间内发起全量复制的过程。 复制风暴对发起复制的主节点或者机器造成大量开销,导致CPU、内存、带宽消耗。

单主节点复制风暴

单主节点复制风暴一般发生在主节点挂载多个从节点的场景。

当主节点重启恢复后,从节点会发起全量复制流程,这时主节点就会为从节点创建RDB快照, 如果在快照创建完毕之前,有多个从节点都尝试与主节点进行全量同步,那么其他从节点将共享这份RDB快照。这点Redis做了优化,有效避免了创建多个快照。

但是,同时向多个从节点发送RDB快照,可能使主节点的网络带宽消耗严重,造成主节点的延迟变大,极端情况会发生主从节点连接断开,导致复制失败。

解决方案首先可以减少主节点(master)挂载从节点(slave)的数量,或者采用树状复制结构,加入中间层从节点用来保护主节点,

采用树状结构降低多个从节点对主节点的消耗:

采用树状结构降低多个从节点对主节点的消耗

从节点采用树状树非常有用,网络开销交给位于中间层的从节点,而不必消耗顶层的主节点。但是这种树状结构也带来了运维的复杂性,增加了手动和自动处理故障转移的难度。

单机器复制风暴

由于Redis的单线程架构,通常单台机器会部署多个Redis实例。当一台机器(machine)上同时部署多个主节点(master)时,如果这台机器出现故障或网络长时间中断, 当它重启恢复后,会有大量从节点(slave)针对这台机器的主节点进行全量复制,会造成当前机器网络带宽耗尽。

单机多实例部署:

单机多实例部署

解决方案:

  • 应该把主节点尽量分散在多台机器上,避免在单台机器上部署过多的主节点。
  • 当主节点所在机器故障后提供故障转移机制,避免机器恢复后进行密集的全量复制。

Sentinel

  • Sentinel只是一个运行在特殊模式下的Redis服务器,它使用了和普通模式不同的命令表,所以Sentinel模式能够使用的命令和普通Redis服务器能够使用的命令不同。
  • Sentinel会读入用户指定的配置文件,为每个要被监视的主服务器创建相应的实例结构,并创建连向主服务器的命令连接和订阅连接,其中命令连接用于向主服务器发送命令请求,而订阅连接则用于接收指定频道的消息。
  • Sentinel通过向主服务器发送INFO命令来获得主服务器属下所有从服务器的地址信息,并为这些从服务器创建相应的实例结构,以及连向这些从服务器的命令连接和订阅连接。
  • 在一般情况下,Sentinel以每十秒一次的频率向被监视的主服务器和从服务器发送INFO命令,当主服务器处于下线状态,或者Sentinel正在对主服务器进行故障转移操作时,Sentinel向从服务器发送INFO命令的频率会改为每秒一次。
  • 对于监视同一个主服务器和从服务器的多个Sentinel来说,它们会以每两秒一次的频率,通过向被监视服务器的__sentinel__:hello频道发送消息来向其他Sentinel宣告自己的存在。
  • 每个Sentinel也会从__sentinel__:hello频道中接收其他Sentinel发来的信息,并根据这些信息为其他Sentinel创建相应的实例结构,以及命令连接。
  • Sentinel只会与主服务器和从服务器创建命令连接和订阅连接,Sentinel与Sentinel之间则只创建命令连接。
  • Sentinel以每秒一次的频率向实例(包括主服务器、从服务器、其他Sentinel)发送PING命令,并根据实例对PING命令的回复来判断实例是否在线,当一个实例在指定的时长中连续向Sentinel发送无效回复时,Sentinel会将这个实例判断为主观下线。
  • 当Sentinel将一个主服务器判断为主观下线时,它会向同样监视这个主服务器的其他Sentinel进行询问,看它们是否同意这个主服务器已经进入主观下线状态。
  • 当Sentinel收集到足够多的主观下线投票之后,它会将主服务器判断为客观下线,并发起一次针对主服务器的故障转移操作。

主从复制作用和问题

从节点可以起到两个作用:

  • 作为主节点的一个备份,一旦主节点出了故障不可达的情况,从节点可以作为后备“顶”上来,并且保证数据尽量不丢失(主从复制是最终一致性)。
  • 从节点可以扩展主节点的读能力,一旦主节点不能支撑住大并发量的读操作,从节点可以在一定程度上帮助主节点分担读压力。

主从复制也带来了以下问题:

  • 一旦主节点出现故障,需要手动将一个从节点晋升为主节点,同时需要修改应用方的主节点地址,还需要命令其他从节点去复制新的主节点,整个过程都需要人工干预。
  • 主节点的写能力受到单机的限制。
  • 主节点的存储能力受到单机的限制。

概述

Sentinel(哨岗、哨兵)是Redis的高可用性(high availability)解决方案:由一个或多个Sentinel实例(instance)组成的Sentinel系统(system)可以监视任意多个主服务器, 以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。

服务器与Sentinel系统:

服务器与Sentinel系统

主服务器下线:

主服务器下线

当server1的下线时长超过用户设定的下线时长上限时,Sentinel系统就会对server1执行故障转移操作:

  • 首先,Sentinel系统会挑选server1属下的其中一个从服务器,并将这个被选中的从服务器升级为新的主服务器。
  • 之后,Sentinel系统会向server1属下的所有从服务器发送新的复制指令,让它们成为新的主服务器的从服务器,当所有从服务器都开始复制新的主服务器时,故障转移操作执行完毕。
  • 另外,Sentinel还会继续监视已下线的server1,并在它重新上线时,将它设置为新的主服务器的从服务器。

故障转移:

故障转移

Sentinel领导者节点执行故障转移的四个步骤

原来的主服务器被降级为从服务器:

原来的主服务器被降级为从服务器

故障转移后的拓扑结构

Redis Sentinel具有以下几个功能:

  • 监控:Sentinel节点会定期检测Redis数据节点、其余Sentinel节点是否可达。
  • 通知:Sentinel节点会将故障转移的结果通知给应用方。
  • 主节点故障转移:实现从节点晋升为主节点并维护后续正确的主从关系。
  • 配置提供者:在Redis Sentinel结构中,客户端在初始化的时候连接的是Sentinel节点集合,从中获取主节点信息。

同时看到,Redis Sentinel包含了若个Sentinel节点,这样做也带来了两个好处:

  • 对于节点的故障判断是由多个Sentinel节点共同完成,这样可以有效地防止误判。
  • Sentinel节点集合是由若干个Sentinel节点组成的,这样即使个别Sentinel节点不可用,整个Sentinel节点集合依然是健壮的。

启动并初始化Sentinel

启动一个Sentinel可以使用命令:

# 这两个命令的效果完全相同。
redis-sentinel /path/to/your/sentinel.conf
redis-server /path/to/your/sentinel.conf --sentinel
redis-sentinel-26379.conf
port 26379
daemonize yes
logfile "26379.log"
dir /opt/soft/redis/data
# quorum建议设置为Sentinel节点一半+1,影响主节点故障转移和Sentinel故障转移
sentinel monitor mymaster 127.0.0.1 6379 2
# 宕机判定时间
sentinel down-after-milliseconds mymaster 30000
# 发起复制的并发数
sentinel parallel-syncs mymaster 1
# 故障转移超时时间,被用于故障转移的各个阶段的超时设置
sentinel failover-timeout mymaster 180000
# 认证
#sentinel auth-pass <master-name> <password>
# 故障转移期间,当一些警告级别的Sentinel事件发生(指重要事件,例如-sdown:客观下线、-odown:主观下线)时,
# 会触发对应路径的脚本,并向脚本发送相应的事件参数。
#sentinel notification-script <master-name> <script-path>
# 重新连入节点重置脚本故障转移结束后,会触发对应路径的脚本,并向脚本发送故障转移结果的相关参数。
#sentinel client-reconfig-script <master-name> <script-path>

当一个Sentinel启动时,它需要执行以下步骤:

  1. 初始化服务器。
  2. 将普通Redis服务器使用的代码替换成Sentinel专用代码。
  3. 初始化Sentinel状态。
  4. 根据给定的配置文件,初始化Sentinel的监视主服务器列表。
  5. 创建连向主服务器的网络连接。

初始化服务器

首先,因为Sentinel本质上只是一个运行在特殊模式下的Redis服务器,所以启动Sentinel的第一步,就是初始化一个普通的Redis服务器。 不过,因为Sentinel执行的工作和普通Redis服务器执行的工作不同,所以Sentinel的初始化过程和普通Redis服务器的初始化过程并不完全相同。

Sentinel模式下Redis服务器主要功能的使用情况:

功能 使用情况
数据库和键值对方面的命令, 比如 SET 、 DEL 、 FLUSHDB 。 不使用。
事务命令, 比如 MULTI 和 WATCH 。 不使用。
脚本命令,比如 EVAL 。 不使用。
RDB 持久化命令, 比如 SAVE 和 BGSAVE 。 不使用。
AOF 持久化命令, 比如 BGREWRITEAOF 。 不使用。
复制命令,比如 SLAVEOF 。 Sentinel 内部可以使用,但客户端不可以使用。
发布与订阅命令, 比如 PUBLISH 和 SUBSCRIBE 。 SUBSCRIBE 、 PSUBSCRIBE 、 UNSUBSCRIBE PUNSUBSCRIBE 四个命令在 Sentinel 内部和客户端都可以使用, 但 PUBLISH 命令只能在 Sentinel 内部使用。
文件事件处理器(负责发送命令请求、处理命令回复)。 Sentinel 内部使用, 但关联的文件事件处理器和普通 Redis 服务器不同。
时间事件处理器(负责执行 serverCron 函数)。 Sentinel 内部使用, 时间事件的处理器仍然是 serverCron 函数, serverCron 函数会调用 sentinel.c/sentinelTimer 函数, 后者包含了 Sentinel 要执行的所有操作。

使用Sentinel专用代码

启动Sentinel的第二个步骤就是将一部分普通Redis服务器使用的代码替换成Sentinel专用代码。

普通Redis服务器使用redis.h/REDIS_SERVERPORT常量的值作为服务器端口:

define REDIS_SERVERPORT 6379

Sentinel使用sentinel.c/REDIS_SENTINEL_PORT常量的值作为服务器端口:

define REDIS_SENTINEL_PORT 26379

普通Redis服务器使用redis.c/redisCommandTable作为服务器的命令表:

struct redisCommand redisCommandTable[] = {
    {"get",getCommand,2,"r",0,NULL,1,1,1,0,0},
    {"set",setCommand,-3,"wm",0,noPreloadGetKeys,1,1,1,0,0},
    {"setnx",setnxCommand,3,"wm",0,noPreloadGetKeys,1,1,1,0,0},
    // ...
    {"script",scriptCommand,-2,"ras",0,NULL,0,0,0,0,0},
    {"time",timeCommand,1,"rR",0,NULL,0,0,0,0,0},
    {"bitop",bitopCommand,-4,"wm",0,NULL,2,-1,1,0,0},
    {"bitcount",bitcountCommand,-2,"r",0,NULL,1,1,1,0,0}
}

Sentinel使用sentinel.c/sentinelcmds作为服务器的命令表,并且其中的INFO命令会使用Sentinel模式下的专用实现sentinel.c/sentinelInfoCommand函数,而不是普通Redis服务器使用的实现redis.c/infoCommand函数:

struct redisCommand sentinelcmds[] = {
    {"ping",pingCommand,1,"",0,NULL,0,0,0,0,0},
    {"sentinel",sentinelCommand,-2,"",0,NULL,0,0,0,0,0},
    {"subscribe",subscribeCommand,-2,"",0,NULL,0,0,0,0,0},
    {"unsubscribe",unsubscribeCommand,-1,"",0,NULL,0,0,0,0,0},
    {"psubscribe",psubscribeCommand,-2,"",0,NULL,0,0,0,0,0},
    {"punsubscribe",punsubscribeCommand,-1,"",0,NULL,0,0,0,0,0},
    {"info",sentinelInfoCommand,-1,"",0,NULL,0,0,0,0,0}
};

sentinelcmds命令表也解释了为什么在Sentinel模式下,Redis服务器不能执行诸如SET、DBSIZE、EVAL等等这些命令, 因为服务器根本没有在命令表中载入这些命令。PING、SENTINEL、INFO、SUBSCRIBE、UNSUBSCRIBE、PSUBSCRIBE和PUNSUBSCRIBE这七个命令就是客户端可以对Sentinel执行的全部命令了。

初始化Sentinel状态

在应用了Sentinel的专用代码之后,服务器会初始化一个sentinel.c/sentinelState结构,这个结构保存了服务器中所有和Sentinel功能有关的状态(服务器的一般状态仍然由redis.h/redisServer结构保存):

struct sentinelState {
    // 当前纪元,用于实现故障转移
    uint64_t current_epoch;
    // 保存了所有被这个sentinel监视的主服务器
    // 字典的键是主服务器的名字
    // 字典的值则是一个指向sentinelRedisInstance结构的指针
    dict *masters;
    // 是否进入了TILT模式?
    int tilt;
    // 目前正在执行的脚本的数量
    int running_scripts;
    // 进入TILT模式的时间
    mstime_t tilt_start_time;
    // 最后一次执行时间处理器的时间
    mstime_t previous_time;
    // 一个FIFO队列,包含了所有需要执行的用户脚本
    list *scripts_queue;
} sentinel;

初始化Sentinel状态的masters属性

Sentinel状态中的masters字典记录了所有被Sentinel监视的主服务器的相关信息,其中:

  • 字典的键是被监视主服务器的名字。
  • 而字典的值则是被监视主服务器对应的sentinel.c/sentinelRedisInstance结构。

每个sentinelRedisInstance结构代表一个被Sentinel监视的Redis服务器实例(instance),这个实例可以是主服务器、从服务器,或者另外一个Sentinel。

以下代码展示了实例结构在表示主服务器时使用的其中一部分属性:

typedef struct sentinelRedisInstance {

    // 标识值,记录了实例的类型,以及该实例的当前状态
    int flags;

    // 实例的名字
    // 主服务器的名字由用户在配置文件中设置
    // 从服务器以及 Sentinel 的名字由 Sentinel 自动设置
    // 格式为 ip:port ,例如 "127.0.0.1:26379"
    char *name;

    // 实例的运行 ID
    char *runid;

    // 配置纪元,用于实现故障转移
    uint64_t config_epoch;

    // 实例的地址
    sentinelAddr *addr;

    // SENTINEL down-after-milliseconds 选项设定的值
    // 实例无响应多少毫秒之后才会被判断为主观下线(subjectively down)
    mstime_t down_after_period;

    // SENTINEL monitor <master-name> <IP> <port> <quorum> 选项中的 quorum 参数
    // 判断这个实例为客观下线(objectively down)所需的支持投票数量
    int quorum;

    // SENTINEL parallel-syncs <master-name> <number> 选项的值
    // 在执行故障转移操作时,可以同时对新的主服务器进行同步的从服务器数量
    int parallel_syncs;

    // SENTINEL failover-timeout <master-name> <ms> 选项的值
    // 刷新故障迁移状态的最大时限
    mstime_t failover_timeout;

    // ...
} sentinelRedisInstance;

sentinelRedisInstance.addr属性是一个指向sentinel.c/sentinelAddr结构的指针,这个结构保存着实例的IP地址和端口号:

typedef struct sentinelAddr {
    char *ip;
    int port;
} sentinelAddr;

对Sentinel状态的初始化将引发对masters字典的初始化,而masters字典的初始化是根据被载入的Sentinel配置文件来进行的。

#####################
# master1 configure #
#####################
sentinel monitor master1 127.0.0.1 6379 2
sentinel down-after-milliseconds master1 30000
sentinel parallel-syncs master1 1
sentinel failover-timeout master1 900000
#####################
# master2 configure #
#####################
sentinel monitor master2 127.0.0.1 12345 5
sentinel down-after-milliseconds master2 50000
sentinel parallel-syncs master2 5
sentinel failover-timeout master2 450000

Sentinel状态以及masters字典:

Sentinel状态以及masters字典

创建向主服务器的网络连接

初始化Sentinel的最后一步是创建连向被监视主服务器的网络连接,Sentinel将成为主服务器的客户端,它可以向主服务器发送命令,并从命令回复中获取相关的信息。

对于每个被Sentinel监视的主服务器来说,Sentinel会创建两个连向主服务器的异步网络连接:

  • 一个是命令连接,这个连接专门用于向主服务器发送命令,并接收命令回复。
  • 另一个是订阅连接,这个连接专门用于订阅主服务器的__sentinel__:hello频道。

为什么有两个连接?

在Redis目前的发布与订阅功能中,被发送的信息都不会保存在Redis服务器里面,如果在信息发送时,想要接收信息的客户端不在线或者断线,那么这个客户端就会丢失这条信息。 因此,为了不丢失__sentinel__:hello频道的任何信息,Sentinel必须专门用一个订阅连接来接收该频道的信息。
另一方面,除了订阅频道之外,Sentinel还必须向主服务器发送命令,以此来与主服务器进行通信,所以Sentinel还必须向主服务器创建命令连接。

因为Sentinel需要与多个实例创建多个网络连接,所以Sentinel使用的是异步连接。

Sentinel向主服务器创建网络连接:

Sentinel向主服务器创建网络连接

获取主服务器信息

Sentinel默认会以每十秒一次的频率,通过命令连接向被监视的主服务器发送INFO命令,并通过分析INFO命令的回复来获取主服务器的当前信息。

# Server
...
run_id:7611c59dc3a29aa6fa0609f841bb6a1019008a9c
...
# Replication
role:master
...
slave0:ip=127.0.0.1,port=11111,state=online,offset=43,lag=0
slave1:ip=127.0.0.1,port=22222,state=online,offset=43,lag=0
slave2:ip=127.0.0.1,port=33333,state=online,offset=43,lag=0
...
# Other sections
...

通过分析主服务器返回的INFO命令回复,Sentinel可以获取以下两方面的信息:

  • 一方面是关于主服务器本身的信息,包括run_id域记录的服务器运行ID,以及role域记录的服务器角色;
  • 另一方面是关于主服务器属下所有从服务器的信息,每个从服务器都由一个”slave”字符串开头的行记录,每行的ip=域记录了从服务器的IP地址,而port=域则记录了从服务器的端口号。根据这些IP地址和端口号,Sentinel无须用户提供从服务器的地址信息,就可以自动发现从服务器。

从服务器信息,会被用于更新主服务器实例结构的slaves字典。

主服务器和它的三个从服务器

图中主服务器实例结构和从服务器实例结构之间的区别:

  • 主服务器实例结构的flags属性的值为SRI_MASTER,而从服务器实例结构的flags属性的值为SRI_SLAVE。
  • 主服务器实例结构的name属性的值是用户使用Sentinel配置文件设置的,而从服务器实例结构的name属性的值则是Sentinel根据从服务器的IP地址和端口号自动设置的。

获取从服务器信息

当Sentinel发现主服务器有新的从服务器出现时,Sentinel除了会为这个新的从服务器创建相应的实例结构之外,Sentinel还会创建连接到从服务器的命令连接和订阅连接。

Sentinel与各个从服务器建立命令连接和订阅连接:

Sentinel与各个从服务器建立命令连接和订阅连接

在创建命令连接之后,Sentinel在默认情况下,会以每十秒一次的频率通过命令连接向从服务器发送INFO命令。

# Server
...
run_id:32be0699dd27b410f7c90dada3a6fab17f97899f
...
# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up
slave_repl_offset:11887
slave_priority:100
# Other sections
...

根据INFO命令的回复,Sentinel会提取出以下信息:

  • 从服务器的运行ID run_id。
  • 从服务器的角色role。
  • 主服务器的IP地址master_host,以及主服务器的端口号master_port。
  • 主从服务器的连接状态master_link_status。
  • 从服务器的优先级slave_priority。
  • 从服务器的复制偏移量slave_repl_offset。

根据这些信息,Sentinel会对从服务器的实例结构进行更新。

向主服务器和从服务器发送信息

在默认情况下,Sentinel会以每两秒一次的频率,通过命令连接向所有被监视的主服务器和从服务器发送以下格式的命令:

PUBLISH __sentinel__:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"

这条命令向服务器的__sentinel__:hello频道发送了一条信息,信息的内容由多个参数组成:

  • 其中以s_开头的参数记录的是Sentinel本身的信息。
  • 而m_开头的参数记录的则是主服务器的信息。
    • 如果Sentinel正在监视的是主服务器,那么这些参数记录的就是主服务器的信息;
    • 如果Sentinel正在监视的是从服务器,那么这些参数记录的就是从服务器正在复制的主服务器的信息。

信息中和Sentinel有关的参数:

信息中和Sentinel有关的参数

信息中和主服务器有关的参数:

信息中和主服务器有关的参数

接收来自主服务器和从服务器的频道信息

当Sentinel与一个主服务器或者从服务器建立起订阅连接之后,Sentinel就会通过订阅连接,向服务器发送以下命令:

SUBSCRIBE __sentinel__:hello

Sentinel对__sentinel__:hello频道的订阅会一直持续到Sentinel与服务器的连接断开为止。

对于每个与Sentinel连接的服务器,Sentinel既通过命令连接向服务器的__sentinel__:hello频道发送信息,又通过订阅连接从服务器的__sentinel__:hello频道接收信息。

Sentinel同时向服务器发送和接收信息

对于监视同一个服务器的多个Sentinel来说,一个Sentinel发送的信息会被其他Sentinel接收到,这些信息会被用于更新其他Sentinel对发送信息Sentinel的认知,也会被用于更新其他Sentinel对被监视服务器的认知。

向服务器发送信息:

向服务器发送信息

当一个Sentinel从__sentinel__:hello频道收到一条信息时,Sentinel会对这条信息进行分析,提取出信息中的Sentinel IP地址、Sentinel端口号、Sentinel运行ID等八个参数,并进行以下检查:

  • 如果信息中记录的Sentinel运行ID和接收信息的Sentinel的运行ID相同,那么说明这条信息是Sentinel自己发送的,Sentinel将丢弃这条信息,不做进一步处理。
  • 如果信息中记录的Sentinel运行ID和接收信息的Sentinel的运行ID不相同,那么说明这条信息是监视同一个服务器的其他Sentinel发来的,接收信息的Sentinel将根据信息中的各个参数,对相应主服务器的实例结构进行更新。

更新sentinels字典

Sentinel为主服务器创建的实例结构中的sentinels字典保存了除Sentinel本身之外,所有同样监视这个主服务器的其他Sentinel的资料:

  • sentinels字典的键是其中一个Sentinel的名字,格式为ip:port。
  • sentinels字典的值则是键所对应Sentinel的实例结构。

当一个Sentinel接收到其他Sentinel发来的信息时(称呼发送信息的Sentinel为源Sentinel,接收信息的Sentinel为目标Sentinel),目标Sentinel会从信息中分析并提取出以下两方面参数:

  • 与Sentinel有关的参数:源Sentinel的IP地址、端口号、运行ID和配置纪元。
  • 与主服务器有关的参数:源Sentinel正在监视的主服务器的名字、IP地址、端口号和配置纪元。

根据信息中提取出的主服务器参数,目标Sentinel会在自己的Sentinel状态的masters字典中查找相应的主服务器实例结构,然后根据提取出的Sentinel参数,检查主服务器实例结构的sentinels字典中,源Sentinel的实例结构是否存在。

因为一个Sentinel可以通过分析接收到的频道信息来获知其他Sentinel的存在,并通过发送频道信息来让其他Sentinel知道自己的存在,所以用户在使用Sentinel的时候并不需要提供各个Sentinel的地址信息,监视同一个主服务器的多个Sentinel可以自动发现对方。

创建向其他Sentinel的命令连接

当Sentinel通过频道信息发现一个新的Sentinel时,它不仅会为新Sentinel在sentinels字典中创建相应的实例结构, 还会创建一个连向新Sentinel的命令连接,而新Sentinel也同样会创建连向这个Sentinel的命令连接,最终监视同一主服务器的多个Sentinel将形成相互连接的网络。

各个Sentinel之间的网络连接:

各个Sentinel之间的网络连接

使用命令连接相连的各个Sentinel可以通过向其他Sentinel发送命令请求来进行信息交换,对Sentinel实现主观下线检测和客观下线检测都会使用Sentinel之间的命令连接来进行通信。

检测主观下线状态

在默认情况下,Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他Sentinel在内)发送PING命令,并通过实例返回的PING命令回复来判断实例是否在线。

Sentinel向实例发送PING命令

实例对PING命令的回复可以分为以下两种情况:

  • 有效回复:实例返回+PONG、-LOADING、-MASTERDOWN三种回复的其中一种。
  • 无效回复:实例返回除+PONG、-LOADING、-MASTERDOWN三种回复之外的其他回复,或者在指定时限内没有返回任何回复。

Sentinel配置文件中的down-after-milliseconds选项指定了Sentinel判断实例进入主观下线所需的时间长度:如果一个实例在down-after-milliseconds毫秒内, 连续向Sentinel返回无效回复,那么Sentinel会修改这个实例所对应的实例结构,在结构的flags属性中打开SRI_S_DOWN标识,以此来表示这个实例已经进入主观下线状态。

主观下线时长选项的作用范围

用户设置的down-after-milliseconds选项的值,不仅会被Sentinel用来判断主服务器的主观下线状态,还会被用于判断主服务器属下的所有从服务器,以及所有同样监视这个主服务器的其他Sentinel的主观下线状态。

sentinel monitor master 127.0.0.1 6379 2
sentinel down-after-milliseconds master 50000

多个Sentinel设置的主观下线时长可能不同。这样回造成多个Sentinel有不同的状态判定。

检测客观下线状态

当Sentinel将一个主服务器判断为主观下线之后,为了确认这个主服务器是否真的下线了,它会向同样监视这一主服务器的其他Sentinel进行询问,看它们是否也认为主服务器已经进入了下线状态(可以是主观下线或者客观下线)。 当Sentinel从其他Sentinel那里接收到足够数量的已下线判断之后,Sentinel就会将从服务器判定为客观下线,并对主服务器执行故障转移操作。

Sentinel节点对主节点做客观下线

发送SENTINEL is-master-down-by-addr命令

Sentinel使用命令询问其他Sentinel是否同意主服务器已下线:

SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>

各个参数的意义:

各个参数的意义

接收SENTINEL is-master-down-by-addr命令

目标Sentinel会分析并取出命令请求中包含的各个参数,并根据其中的主服务器IP和端口号,检查主服务器是否已下线,然后向源Sentinel返回一条包含三个参数的Multi Bulk回复作为SENTINEL is-master-down-by命令的回复:

1) <down_state>
2) <leader_runid>
3) <leader_epoch>

回复的意义:

回复的意义

# 说明Sentinel也同意主服务器已下线。
1) 1
2) *
3) 0

接收SENTINEL is-master-down-by-addr命令的回复

根据其他Sentinel发回的SENTINEL is-master-down-by-addr命令回复,Sentinel将统计其他Sentinel同意主服务器已下线的数量,当这一数量达到配置指定的判断客观下线所需的数量时,Sentinel会将主服务器实例结构flags属性的SRI_O_DOWN标识打开,表示主服务器已经进入客观下线状态。

主服务器被标记为客观下线:

主服务器被标记为客观下线

客观下线状态的判断条件

当认为主服务器已经进入下线状态的Sentinel的数量,超过Sentinel配置中设置的quorum参数的值,那么该Sentinel就会认为主服务器已经进入客观下线状态。

# 那么包括当前Sentinel在内,只要总共有两个Sentinel认为主服务器已经进入下线状态,那么当前Sentinel就将主服务器判断为客观下线。
sentinel monitor master 127.0.0.1 6379 2

不同Sentinel判断客观下线的条件可能不同,会导致判断客观下线的结果不同。

选举领头Sentinel

当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个Sentinel会进行协商,选举出一个领头Sentinel,并由领头Sentinel对下线主服务器执行故障转移操作。

以下是Redis选举领头Sentinel的规则和方法:

  • 所有在线的Sentinel都有被选为领头Sentinel的资格,换句话说,监视同一个主服务器的多个在线Sentinel中的任意一个都有可能成为领头Sentinel。
  • 每次进行领头Sentinel选举之后,不论选举是否成功,所有Sentinel的配置纪元(configuration epoch)的值都会自增一次。配置纪元实际上就是一个计数器,并没有什么特别的。
  • 在一个配置纪元里面,所有Sentinel都有一次将某个Sentinel设置为局部领头Sentinel的机会,并且局部领头一旦设置,在这个配置纪元里面就不能再更改。
  • 每个发现主服务器进入客观下线的Sentinel都会要求其他Sentinel将自己设置为局部领头Sentinel。
  • 当一个Sentinel(源Sentinel)向另一个Sentinel(目标Sentinel)发送SENTINEL is-master-down-by-addr命令,并且命令中的runid参数不是*符号而是源Sentinel的运行ID时,这表示源Sentinel要求目标Sentinel将前者设置为后者的局部领头Sentinel。
  • Sentinel设置局部领头Sentinel的规则是先到先得:最先向目标Sentinel发送设置要求的源Sentinel将成为目标Sentinel的局部领头Sentinel,而之后接收到的所有设置要求都会被目标Sentinel拒绝。
  • 目标Sentinel在接收到SENTINEL is-master-down-by-addr命令之后,将向源Sentinel返回一条命令回复,回复中的leader_runid参数和leader_epoch参数分别记录了目标Sentinel的局部领头Sentinel的运行ID和配置纪元。
  • 源Sentinel在接收到目标Sentinel返回的命令回复之后,会检查回复中leader_epoch参数的值和自己的配置纪元是否相同,如果相同的话,那么源Sentinel继续取出回复中的leader_runid参数,如果leader_runid参数的值和源Sentinel的运行ID一致,那么表示目标Sentinel将源Sentinel设置成了局部领头Sentinel。
  • 如果有某个Sentinel被半数以上的Sentinel设置成了局部领头Sentinel,那么这个Sentinel成为领头Sentinel。举个例子,在一个由10个Sentinel组成的Sentinel系统里面,只要有大于等于10/2+1=6个Sentinel将某个Sentinel设置为局部领头Sentinel,那么被设置的那个Sentinel就会成为领头Sentinel。
  • 因为领头Sentinel的产生需要半数以上Sentinel的支持,并且每个Sentinel在每个配置纪元里面只能设置一次局部领头Sentinel,所以在一个配置纪元里面,只会出现一个领头Sentinel。
  • 如果在给定时限内,没有一个Sentinel被选举为领头Sentinel,那么各个Sentinel将在一段时间之后再次进行选举,直到选出领头Sentinel为止。

接收到命令回复的Sentinel就可以根据回复,统计出有多少个Sentinel将自己设置成了局部领头Sentinel。

根据命令请求发送的先后顺序不同,可能会有某个Sentinel的SENTINEL is-master-down-by-addr命令比起其他Sentinel发送的相同命令都更快到达,并最终胜出领头Sentinel的选举,然后这个领头Sentinel就可以开始对主服务器执行故障转移操作了。

选举的过程非常快,基本上谁先完成客观下线,谁就是领导者。

Raft算法GitHub主页

故障转移

在选举产生出领头Sentinel之后,领头Sentinel将对已下线的主服务器执行故障转移操作,该操作包含以下三个步骤:

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

选出新的主服务器

故障转移操作第一步要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个状态良好、数据完整的从服务器,然后向这个从服务器发送SLAVEOF no one命令,将这个从服务器转换为主服务器。

在发送SLAVEOF no one命令之后,领头Sentinel会以每秒一次的频率(平时是每十秒一次),向被升级的从服务器发送INFO命令,并观察命令回复中的角色(role)信息, 当被升级服务器的role从原来的slave变为master时,领头Sentinel就知道被选中的从服务器已经顺利升级为主服务器了。

新的主服务器是怎样挑选出来的

领头Sentinel会将已下线主服务器的所有从服务器保存到一个列表里面,然后按照以下规则,一项一项地对列表进行过滤:

  1. 删除列表中所有处于下线或者断线状态的从服务器,这可以保证列表中剩余的从服务器都是正常在线的。
  2. 删除列表中所有最近五秒内没有回复过领头Sentinel的INFO命令的从服务器,这可以保证列表中剩余的从服务器都是最近成功进行过通信的。
  3. 删除所有与已下线主服务器连接断开超过down-after-milliseconds*10毫秒的从服务器
    • down-after-milliseconds选项指定了判断主服务器下线所需的时间,而删除断开时长超过down-after-milliseconds*10毫秒的从服务器,则可以保证列表中剩余的从服务器都没有过早地与主服务器断开连接,换句话说,列表中剩余的从服务器保存的数据都是比较新的。

之后,领头Sentinel将根据从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级slave-priority最高的从服务器。

如果有多个具有相同最高优先级的从服务器,那么领头Sentinel将按照从服务器的复制偏移量,对具有相同最高优先级的所有从服务器进行排序,并选出其中偏移量最大的从服务器(复制偏移量最大的从服务器就是保存着最新数据的从服务器)。

最后,如果有多个优先级最高、复制偏移量最大的从服务器,那么领头Sentinel将按照运行ID对这些从服务器进行排序,并选出其中runid最小的从服务器。

选出最好的从节点

将server2升级为主服务器:

将server2升级为主服务器

server2成功升级为主服务器:

server2成功升级为主服务器

修改从服务器的复制目标

当新的主服务器出现之后,领头Sentinel下一步要做的就是,让已下线主服务器属下的所有从服务器去复制新的主服务器,这一动作可以通过向从服务器发送SLAVEOF命令来实现。

让从服务器复制新的主服务器:

让从服务器复制新的主服务器

server3和server4成为server2的从服务器:

server3和server4成为server2的从服务器

复制规则和parallel-syncs参数有关。

将旧的主服务器变为从服务器

故障转移操作最后要做的是,将已下线的主服务器设置为新的主服务器的从服务器。

因为旧的主服务器已经下线,所以这种设置是保存在server1对应的实例结构里面的,当server1重新上线时,Sentinel就会向它发送SLAVEOF命令,让它成为server2的从服务器。

server1被设置为新主服务器的从服务器:

server1被设置为新主服务器的从服务器

server1重新上线并成为server2的从服务器:

server1重新上线并成为server2的从服务器

客户端

Sentinel节点集合具备了监控、通知、自动故障转移、配置提供者若干功能,也就是说实际上最了解主节点信息的就是Sentinel节点集合,而各个主节点可以通过<master-name>进行标识的, 所以,无论是哪种编程语言的客户端,如果需要正确地连接Redis Sentinel,必须有Sentinel节点集合和masterName两个参数。

实现一个Redis Sentinel客户端的基本步骤如下:

  1. 遍历Sentinel节点集合获取一个可用的Sentinel节点,后面会介绍Sentinel节点之间可以共享数据,所以从任意一个Sentinel节点获取主节点信息都是可以的。
  2. 通过sentinel get-master-addr-by-name master-name这个API来获取对应主节点的相关信息。
  3. 验证当前获取的“主节点”是真正的主节点,这样做的目的是为了防止故障转移期间主节点的变化。
  4. 保持和Sentinel节点集合的“联系”,时刻获取关于主节点的相关“信息”。

获取一个可用的Sentinel节点:

获取一个可用的Sentinel节点

利用sentinel get-master-addr-by-name返回主节点信息:

利用sentinel get-master-addr-by-name返回主节点信息

验证主节点:

验证主节点

客户端订阅Sentinel节点相关频道:

客户端订阅Sentinel节点相关频道

// masterName——主节点名。
// sentinels——Sentinel节点集合。
// poolConfig——common-pool连接池配置。
// connectTimeout——连接超时。
// soTimeout——读写超时。
// password——主节点密码。
// database——当前数据库索引。
// clientName——客户端名。
public JedisSentinelPool(String masterName, Set<String> sentinels,
    final GenericObjectPoolConfig poolConfig, final int connectionTimeout, 
    final int soTimeout,
    final String password, final int database, 
    final String clientName)

JedisSentinelPool jedisSentinelPool = new JedisSentinelPool(masterName, sentinelSet, poolConfig, timeout);

Jedis jedis = null;
try {
    jedis = jedisSentinelPool.getResource();
    // jedis command
} catch (Exception e) {
    logger.error(e.getMessage(), e);
} finally {
    if (jedis != null)
        jedis.close();
}
public JedisSentinelPool(String masterName, Set<String> sentinels,
    final GenericObjectPoolConfig poolConfig, final int connectionTimeout, 
    final int soTimeout, final String password, final int database, 
    final String clientName) {
    // …
    HostAndPort master = initSentinels(sentinels, masterName);
    initPool(master);
    // …
}

private HostAndPort initSentinels(Set<String> sentinels, final String masterName
    //  主节点
    HostAndPort master = null;
    //  遍历所有 sentinel 节点
    for (String sentinel : sentinels) {
        //  连接 sentinel 节点
        HostAndPort hap = toHostAndPort(Arrays.asList(sentinel.split(":")));
        Jedis jedis = new Jedis(hap.getHost(), hap.getPort());
        //  使用 sentinel get-master-addr-by-name masterName 获取主节点信息
        List<String> masterAddr = jedis.sentinelGetMasterAddrByName(masterName);
        //  命令返回列表为空或者长度不为 2 ,继续从下一个 sentinel 节点查询
        if (masterAddr == null || masterAddr.size() != 2) {
            continue;
        }
        //  解析 masterAddr 获取主节点信息
        master = toHostAndPort(masterAddr);
        //  找到后直接跳出 for 循环
        break;
    }
    if (master == null) {
        //  直接抛出异常,
        throw new Exception();
    }
    //  为每个 sentinel 节点开启主节点 switch 的监控线程
    for (String sentinel : sentinels) {
        final HostAndPort hap = toHostAndPort(Arrays.asList(sentinel.split(":")));
        MasterListener masterListener = new MasterListener(masterName, hap.getHost(), hap.getPort());
            hap.getPort());
        masterListener.start();
    }
    //  返回结果
    return master;
}
// 通过消息订阅监听主节点变化
Jedis sentinelJedis = new Jedis(sentinelHost, sentinelPort);
//  客户端订阅 Sentinel 节点上 "+switch-master"( 切换主节点 ) 频道
sentinelJedis.subscribe(new JedisPubSub() {
    @Override
    public void onMessage(String channel, String message) {
        String[] switchMasterMsg = message.split(" ");
        if (switchMasterMsg.length > 3) {
            //  判断是否为当前 masterName
            if (masterName.equals(switchMasterMsg[0])) {
                //  发现当前 masterName 发生 switch ,使用 initPool 重新初始化连接池
                initPool(toHostAndPort(Arrays.asList(switchMasterMsg[3], switchMasterMsg[4])));
            }
        }
    }
}, "+switch-master");

开发运维中的问题

节点运维

节点下线
  • 临时下线:暂时将节点关掉,之后还会重新启动,继续提供服务。
  • 永久下线:将节点关掉后不再使用,需要做一些清理工作,如删除配置文件、持久化文件、日志文件。

通常来看,无论是主节点、从节点还是Sentinel节点,下线原因无外乎以下几种:

  • 节点所在的机器出现了不稳定或者即将过保被回收。
  • 节点所在的机器性能比较差或者内存比较小,无法支撑应用方的需求。
  • 节点自身出现服务不正常情况,需要快速处理。

主节点

如果需要对主节点进行下线,比较合理的做法是选出一个“合适”(例如性能更高的机器)的从节点,使用sentinel failover功能将从节点晋升主节点。

sentinel failover <master name>

Redis Sentinel存在多个从节点时,如果想将指定从节点晋升为主节点,可以将其他从节点的slavepriority配置为0,但是需要注意failover后,将slave-priority调回原值。

从节点和Sentinel节点

如果需要对从节点或者Sentinel节点进行下线,只需要确定好是临时还是永久下线后执行相应操作即可。 如果使用了读写分离,下线从节点需要保证应用方可以感知从节点的下线变化,从而把读取请求路由到其他节点。

需要注意的是,Sentinel节点依然会对这些下线节点进行定期监控,这是由Redis Sentinel的设计思路所决定的。

节点上线

添加从节点

添加从节点的场景大致有如下几种:

  • 使用了读写分离,但现有的从节点无法支撑应用方的流量。
  • 主节点没有可用的从节点,无法支持故障转移。
  • 添加一个更强悍的从节点利用手动failover替换主节点。

添加方法:添加slaveof {masterIp} {masterPort}的配置,使用redis-server启动即可,它将被Sentinel节点自动发现。

添加Sentinel节点

添加Sentinel节点的场景可以分为以下几种:

  • 当前Sentinel节点数量不够,无法达到Redis Sentinel健壮性要求或者无法达到票数。
  • 原Sentinel节点所在机器需要下线。

添加方法:添加sentinel monitor主节点的配置,使用redis-sentinel启动即可,它将被其余Sentinel节点自动发现。

高可用读写分离

设计Redis Sentinel的从节点高可用时,只要能够实时掌握所有从节点的状态,把所有从节点看做一个资源池, 无论是上线还是下线从节点,客户端都能及时感知到(将其从资源池中添加或者删除),这样从节点的高可用目标就达到了。

Redis Sentinel下的读写分离架构图

最佳实践

  1. 生产环境中建议Redis Sentinel的所有节点应该分布在不同的物理机上。
  2. Redis Sentinel中的数据节点和普通的Redis数据节点在配置上没有任何区别,只不过是添加了一些Sentinel节点对它们进行监控。
  3. 部署至少三个且奇数个的Sentinel节点。
    • 3个以上是通过增加Sentinel节点的个数提高对于故障判定的准确性,因为领导者选举需要至少一半加1个节点,奇数个节点可以在满足该条件的基础上节省一个节点。

只有一套Sentinel,还是每个主节点配置一套Sentinel?

方案一:

一套Sentinel,很明显这种方案在一定程度上降低了维护成本,因为只需要维护固定个数的Sentinel节点,集中对多个Redis数据节点进行管理就可以了。 但是这同时也是它的缺点,如果这套Sentinel节点集合出现异常,可能会对多个Redis数据节点造成影响。还有如果监控的Redis数据节点较多,会造成Sentinel节点产生过多的网络连接,也会有一定的影响。

一套Sentinel节点集合

方案二:

多套Sentinel,显然这种方案的优点和缺点和上面是相反的,每个Redis主节点都有自己的Sentinel节点集合,会造成资源浪费。但是优点也很明显,每套Redis Sentinel都是彼此隔离的。

多套Sentine节点集合

如果Sentinel节点集合监控的是同一个业务的多个主节点集合,那么使用方案一、否则一般建议采用方案二。

Tips:建议所有Sentinel配置一致,以便故障发现和转移时比较容易达成一致

集群

  • 节点通过握手来将其他节点添加到自己所处的集群当中。
  • 集群中的16384个槽可以分别指派给集群中的各个节点,每个节点都会记录哪些槽指派给了自己,而哪些槽又被指派给了其他节点。
  • 节点在接到一个命令请求时,会先检查这个命令请求要处理的键所在的槽是否由自己负责,如果不是的话,节点将向客户端返回一个MOVED错误,MOVED错误携带的信息可以指引客户端转向至正在负责相关槽的节点。
  • 对Redis集群的重新分片工作是由redis-trib负责执行的,重新分片的关键是将属于某个槽的所有键值对从一个节点转移至另一个节点。
  • 如果节点A正在迁移槽i至节点B,那么当节点A没能在自己的数据库中找到命令指定的数据库键时,节点A会向客户端返回一个ASK错误,指引客户端到节点B继续查找指定的数据库键。
  • MOVED错误表示槽的负责权已经从一个节点转移到了另一个节点,而ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施。
  • 集群里的从节点用于复制主节点,并在主节点下线时,代替主节点继续处理命令请求。
  • 集群中的节点通过发送和接收消息来进行通信,常见的消息包括MEET、PING、PONG、PUBLISH、FAIL五种。

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

数据分布

数据分布理论

分布式数据库首先要解决把整个数据集按照分区规则映射到多个节点的问题,即把数据集划分到多个节点上,每个节点负责整体数据的一个子集。

需要重点关注的是数据分区规则。常见的分区规则有哈希分区和顺序分区两种。

哈希分区和顺序分区对比

Redis Cluster采用哈希分区规则。

节点取余分区

使用特定的数据,如Redis的键或用户ID,再根据节点数量N使用公式:hash(key) % N计算出哈希值,用来决定数据映射到哪一个节点上。

  • 问题:当节点数量变化时,如扩容或收缩节点,数据节点映射关系需要重新计算,会导致数据的重新迁移。
  • 优点:简单性,常用于数据库的分库分表规则,一般采用预分区的方式,提前根据数据量规划好分区数
    • 比如划分为512或1024张表,保证可支撑未来一段时间的数据量,再根据负载情况将表迁移到其他数据库中。
    • 扩容时通常采用翻倍扩容,避免数据映射全部被打乱导致全量迁移的情况,

翻倍扩容迁移约50%数据

一致性哈希分区

一致性哈希分区(Distributed Hash Table)实现思路是为系统中每个节点分配一个token,范围一般在0~232,这些token构成一个哈希环。 数据读写执行节点查找操作时,先根据key计算hash值,然后顺时针找到第一个大于等于该哈希值的token节点,

一致性哈希数据分布

这种方式相比节点取余最大的好处在于加入和删除节点只影响哈希环中相邻的节点,对其他节点无影响。但一致性哈希分区存在几个问题:

  • 加减节点会造成哈希环中部分数据无法命中,需要手动处理或者忽略这部分数据,因此一致性哈希常用于缓存场景。
  • 当使用少量节点时,节点变化将大范围影响哈希环中数据映射,因此这种方式不适合少量数据节点的分布式方案。
  • 普通的一致性哈希分区在增减节点时需要增加一倍或减去一半节点才能保证数据和负载的均衡。

正因为一致性哈希分区的这些缺点,一些分布式系统采用虚拟槽对一致性哈希进行改进,比如Dynamo系统。

虚拟槽分区

虚拟槽分区巧妙地使用了哈希空间,使用分散度良好的哈希函数把所有数据映射到一个固定范围的整数集合中,整数定义为槽(slot)。 这个范围一般远远大于节点数,比如Redis Cluster槽范围是0~16383。槽是集群内数据管理和迁移的基本单位。采用大范围槽的主要目的是为了方便数据拆分和集群扩展。

每个节点会负责一定数量的槽由于采用高质量的哈希算法,每个槽所映射的数据通常比较均匀。Redis Cluster就是采用虚拟槽分区。

Redis数据分区

参考《计算键属于哪个槽》

Redis Cluser采用虚拟槽分区,所有的键根据哈希函数映射到0~16383整数槽内,计算公式:slot=CRC16(key)&16383。每一个节点负责维护一部分槽以及槽所映射的键值数据。

Redis虚拟槽分区的特点:

  • 解耦数据和节点之间的关系,简化了节点扩容和收缩难度。
  • 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据。
  • 支持节点、槽、键之间的映射查询,用于数据路由、在线伸缩等场景。

集群功能限制

Redis集群相对单机在功能上存在一些限制,在使用时做好规避。限制如下:

  1. key批量操作支持有限。
    • 如mset、mget,目前只支持具有相同slot值的key执行批量操作。对于映射为不同slot值的key由于执行mget、mget等操作可能存在于多个节点上因此不被支持。
  2. key事务操作支持有限。同理只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能。
  3. key作为数据分区的最小粒度,因此不能将一个大的键值对象如hash、list等映射到不同的节点。
  4. 不支持多数据库空间。单机下的Redis可以支持16个数据库,集群模式下只能使用一个数据库空间,即db 0。
  5. 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。

节点

一个Redis集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独立的,它们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,必须将各个独立的节点连接起来,构成一个包含多个节点的集群。

连接各个节点的工作可以使用CLUSTER MEET命令来完成:

CLUSTER MEET <ip> <port>

向一个节点node发送CLUSTER MEET命令,可以让node节点与ip和port所指定的节点进行握手(handshake),当握手成功时,node节点就会将ip和port所指定的节点添加到node节点当前所在的集群中。

假设现在有三个独立的节点127.0.0.1:7000、127.0.0.1:7001、127.0.0.1:7002(下文省略IP地址,直接使用端口号来区分各个节点),首先使用客户端连上节点7000,通过发送CLUSTER NODE命令可以看到,集群目前只包含7000自己一个节点:

$ redis-cli -c -p 7000
127.0.0.1:7000> CLUSTER NODES
51549e625cfda318ad27423a31e7476fe3cd2939 :0 myself,master - 0 0 0 connected

通过向节点7000发送以下命令,将节点7001添加到节点7000所在的集群里面:

127.0.0.1:7000> CLUSTER MEET 127.0.0.1 7001
OK
127.0.0.1:7000> CLUSTER NODES
68eef66df23420a5862208ef5b1a7005b806f2ff 127.0.0.1:7001 master - 0 1388204746210 0 connected
51549e625cfda318ad27423a31e7476fe3cd2939 :0 myself,master - 0 0 0 connected

继续向节点7000发送以下命令,将节点7002也添加到节点7000和节点7001所在的集群里面:

127.0.0.1:7000> CLUSTER MEET 127.0.0.1 7002
OK
127.0.0.1:7000> CLUSTER NODES
68eef66df23420a5862208ef5b1a7005b806f2ff 127.0.0.1:7001 master - 0 1388204848376 0 connected
9dfb4c4e016e627d9769e4c9bb0d4fa208e65c26 127.0.0.1:7002 master - 0 1388204847977 0 connected
51549e625cfda318ad27423a31e7476fe3cd2939 :0 myself,master - 0 0 0 connected

三个独立的节点:

三个独立的节点

节点7000和7001进行握手:

节点7000和7001进行握手

握手成功的7000与7001处于同一个集群:

握手成功的7000与7001处于同一个集群

节点7000与节点7002进行握手:

节点7000与节点7002进行握手

握手成功的三个节点处于同一个集群:

握手成功的三个节点处于同一个集群

启动节点

一个节点就是一个运行在集群模式下的Redis服务器,Redis服务器在启动时会根据cluster-enabled配置选项是否为yes来决定是否开启服务器的集群模式。

服务器判断是否开启集群模式的过程

节点(运行在集群模式下的Redis服务器)会继续使用所有在单机模式中使用的服务器组件,比如说:

  • 节点会继续使用文件事件处理器来处理命令请求和返回命令回复。
  • 节点会继续使用时间事件处理器来执行serverCron函数,而serverCron函数又会调用集群模式特有的clusterCron函数。
    • clusterCron函数负责执行在集群模式下需要执行的常规操作,例如向集群中的其他节点发送Gossip消息,检查节点是否断线,或者检查是否需要对下线节点进行自动故障转移等。
  • 节点会继续使用数据库来保存键值对数据,键值对依然会是各种不同类型的对象。
  • 节点会继续使用RDB持久化模块和AOF持久化模块来执行持久化工作。
  • 节点会继续使用发布与订阅模块来执行PUBLISH、SUBSCRIBE等命令。
  • 节点会继续使用复制模块来进行节点的复制工作。
  • 节点会继续使用Lua脚本环境来执行客户端输入的Lua脚本。

除此之外,节点会继续使用redisServer结构来保存服务器的状态,使用redisClient结构来保存客户端的状态,至于那些只有在集群模式下才会用到的数据, 节点将它们保存到了cluster.h/clusterNode结构、cluster.h/clusterLink结构,以及cluster.h/clusterState结构里面。

集群数据结构

clusterNode结构保存了一个节点的当前状态,比如节点的创建时间、节点的名字、节点当前的配置纪元、节点的IP地址和端口号等等。

每个节点都会使用一个clusterNode结构来记录自己的状态,并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的clusterNode结构,以此来记录其他节点的状态:

struct clusterNode {
    // 创建节点的时间
    mstime_t ctime;
    // 节点的名字,由40个十六进制字符组成
    // 例如68eef66df23420a5862208ef5b1a7005b806f2ff
    char name[REDIS_CLUSTER_NAMELEN];
    // 节点标识
    // 使用各种不同的标识值记录节点的角色(比如主节点或者从节点),
    // 以及节点目前所处的状态(比如在线或者下线)。
    int flags;
    // 节点当前的配置纪元,用于实现故障转移
    uint64_t configEpoch;
    // 节点的IP地址
    char ip[REDIS_IP_STR_LEN];
    // 节点的端口号
    int port;
    // 保存连接节点所需的有关信息
    clusterLink *link;
    // ...
};

clusterNode结构的link属性是一个clusterLink结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输入缓冲区和输出缓冲区:

typedef struct clusterLink {
  // 连接的创建时间
  mstime_t ctime;
  // TCP 套接字描述符
  int fd;
  // 输出缓冲区,保存着等待发送给其他节点的消息(message)。
  sds sndbuf;
  // 输入缓冲区,保存着从其他节点接收到的消息。
  sds rcvbuf;
  // 与这个连接相关联的节点,如果没有的话就为NULL 
  struct clusterNode *node;
} clusterLink;

每个节点都保存着一个clusterState结构,这个结构记录了在当前节点的视角下,集群目前所处的状态,例如集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元,诸如此类:

typedef struct clusterState {
    // 指向当前节点的指针
    clusterNode *myself;
    // 集群当前的配置纪元,用于实现故障转移
    uint64_t currentEpoch;
    // 集群当前的状态:是在线还是下线
    int state;
    // 集群中至少处理着一个槽的节点的数量
    int size;
    // 集群节点名单(包括myself节点)
    // 字典的键为节点的名字,字典的值为节点对应的clusterNode结构
    dict *nodes;
    // ...
} clusterState;

节点7000创建的clusterState结构:

节点7000创建的clusterState结构

CLUSTER MEET命令的实现

通过向节点A发送CLUSTER MEET命令,客户端可以让接收命令的节点A将另一个节点B添加到节点A当前所在的集群里面:

CLUSTER MEET <ip> <port>

收到命令的节点A将与节点B进行握手(handshake),以此来确认彼此的存在,并为将来的进一步通信打好基础:

  1. 节点A会为节点B创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面。
  2. 之后,节点A将根据CLUSTER MEET命令给定的IP地址和端口号,向节点B发送一条MEET消息(message)。
  3. 如果一切顺利,节点B将接收到节点A发送的MEET消息,节点B会为节点A创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面。
  4. 之后,节点B将向节点A返回一条PONG消息。
  5. 如果一切顺利,节点A将接收到节点B返回的PONG消息,通过这条PONG消息节点A可以知道节点B已经成功地接收到了自己发送的MEET消息。
  6. 之后,节点A将向节点B返回一条PING消息。
  7. 如果一切顺利,节点B将接收到节点A返回的PING消息,通过这条PING消息节点B可以知道节点A已经成功地接收到了自己返回的PONG消息,握手完成。

节点的握手过程:

节点的握手过程

之后,节点A会将节点B的信息通过Gossip协议传播给集群中的其他节点,让其他节点也与节点B进行握手,最终,经过一段时间之后,节点B会被集群中的所有节点认识。

槽指派

Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384个槽(slot),数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。

当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok);相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)。

# 集群目前仍然处于下线状态,因为集群中的三个节点都没有在处理任何槽
127.0.0.1:7000> CLUSTER INFO
cluster_state:fail
cluster_slots_assigned:0
cluster_slots_ok:0
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:3
cluster_size:0
cluster_current_epoch:0
cluster_stats_messages_sent:110
cluster_stats_messages_received:28

通过向节点发送CLUSTER ADDSLOTS命令,可以将一个或多个槽指派(assign)给节点负责:

CLUSTER ADDSLOTS <slot> [slot ...]
# 将槽0至槽5000指派给节点7000负责
127.0.0.1:7000> CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000
OK
127.0.0.1:7000> CLUSTER NODES
9dfb4c4e016e627d9769e4c9bb0d4fa208e65c26 127.0.0.1:7002 master - 0 1388316664849 0 connected
68eef66df23420a5862208ef5b1a7005b806f2ff 127.0.0.1:7001 master - 0 1388316665850 0 connected
51549e625cfda318ad27423a31e7476fe3cd2939 :0 myself,master - 0 0 0 connected 0-5000

127.0.0.1:7001> CLUSTER ADDSLOTS 5001 5002 5003 5004 ... 10000
OK

127.0.0.1:7002> CLUSTER ADDSLOTS 10001 10002 10003 10004 ... 16383
OK

127.0.0.1:7000> CLUSTER INFO
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:3
cluster_size:3
cluster_current_epoch:0
cluster_stats_messages_sent:2699
cluster_stats_messages_received:2617
127.0.0.1:7000> CLUSTER NODES
9dfb4c4e016e627d9769e4c9bb0d4fa208e65c26 127.0.0.1:7002 master - 0 1388317426165 0 connected 10001-16383
68eef66df23420a5862208ef5b1a7005b806f2ff 127.0.0.1:7001 master - 0 1388317427167 0 connected 5001-10000
51549e625cfda318ad27423a31e7476fe3cd2939 :0 myself,master - 0 0 0 connected 0-5000

# 或者使用bash特性批量分配槽
redis-cli -h 127.0.0.1 -p 6379 cluster addslots {0...5461}
redis-cli -h 127.0.0.1 -p 6380 cluster addslots {5462...10922}
redis-cli -h 127.0.0.1 -p 6381 cluster addslots {10923...16383}
# 集群内加入从节点
127.0.0.1:6382>cluster replicate cfb28ef1deee4e0fa78da86abe5d24566744411e
OK
127.0.0.1:6383>cluster replicate 8e41673d59c9568aa9d29fb174ce733345b3e8f1
OK
127.0.0.1:6384>cluster replicate 40b8d09d44294d2e23c7c768efc8fcd153446746
OK

Tips:使用redis-trib.rb搭建集群,简化这个流程

记录节点的槽指派信息

clusterNode结构的slots属性和numslot属性记录了节点负责处理哪些槽:

struct clusterNode {
  // ...
  unsigned char slots[16384/8];
  int numslots;
  // ...
};

slots属性是一个二进制位数组(bit array),这个数组的长度为16384/8=2048个字节,共包含16384个二进制位。
Redis以0为起始索引,16383为终止索引,对slots数组中的16384个二进制位进行编号,并根据索引i上的二进制位的值来判断节点是否负责处理槽i:

  • 如果slots数组在索引i上的二进制位的值为1,那么表示节点负责处理槽i。
  • 如果slots数组在索引i上的二进制位的值为0,那么表示节点不负责处理槽i。

因为取出和设置slots数组中的任意一个二进制位的值的复杂度仅为O(1),所以对于一个给定节点的slots数组来说,程序检查节点是否负责处理某个槽,又或者将某个槽指派给节点负责,这两个动作的复杂度都是O(1)。

至于numslots属性则记录节点负责处理的槽的数量,也即是slots数组中值为1的二进制位的数量。

传播节点的槽指派信息

一个节点除了会将自己负责处理的槽记录在clusterNode结构的slots属性和numslots属性之外,它还会将自己的slots数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽。

当节点A通过消息从节点B那里接收到节点B的slots数组时,节点A会在自己的clusterState.nodes字典中查找节点B对应的clusterNode结构,并对结构中的slots数组进行保存或者更新。

因为集群中的每个节点都会将自己的slots数组通过消息发送给集群中的其他节点,并且每个接收到slots数组的节点都会将数组保存到相应节点的clusterNode结构里面,因此,集群中的每个节点都会知道数据库中的16384个槽分别被指派给了集群中的哪些节点。

记录集群所有槽的指派信息

clusterState结构中的slots数组记录了集群中所有16384个槽的指派信息:

typedef struct clusterState {
  // ...
  clusterNode *slots[16384];
  // ...
} clusterState;

slots数组包含16384个项,每个数组项都是一个指向clusterNode结构的指针:

  • 如果slots[i]指针指向NULL,那么表示槽i尚未指派给任何节点。
  • 如果slots[i]指针指向一个clusterNode结构,那么表示槽i已经指派给了clusterNode结构所代表的节点。

如果只将槽指派信息保存在各个节点的clusterNode.slots数组里,会出现一些无法高效地解决的问题,而clusterState.slots数组的存在解决了这些问题:

  • 如果节点只使用clusterNode.slots数组来记录槽的指派信息,那么为了知道槽i是否已经被指派,或者槽i被指派给了哪个节点,程序需要遍历clusterState.nodes字典中的所有clusterNode结构,检查这些结构的slots数组,直到找到负责处理槽i的节点为止,这个过程的复杂度为O(N),其中N为clusterState.nodes字典保存的clusterNode结构的数量。
  • 而通过将所有槽的指派信息保存在clusterState.slots数组里面,程序要检查槽i是否已经被指派,又或者取得负责处理槽i的节点,只需要访问clusterState.slots[i]的值即可,这个操作的复杂度仅为O(1)。

虽然clusterState.slots数组记录了集群中所有槽的指派信息,但使用clusterNode结构的slots数组来记录单个节点的槽指派信息仍然是有必要的:

  • 因为当程序需要将某个节点的槽指派信息通过消息发送给其他节点时,程序只需要将相应节点的clusterNode.slots数组整个发送出去就可以了。
  • 如果Redis不使用clusterNode.slots数组,而单独使用clusterState.slots数组的话,那么每次要将节点A的槽指派信息传播给其他节点时,程序必须先遍历整个clusterState.slots数组,记录节点A负责处理哪些槽,然后才能发送节点A的槽指派信息,这比直接发送clusterNode.slots数组要麻烦和低效得多。

clusterState.slots数组记录了集群中所有槽的指派信息,而clusterNode.slots数组只记录了clusterNode结构所代表的节点的槽指派信息,这是两个slots数组的关键区别所在。

CLUSTER ADDSLOTS命令的实现

CLUSTER ADDSLOTS命令接受一个或多个槽作为参数,并将所有输入的槽指派给接收该命令的节点负责:

CLUSTER ADDSLOTS <slot> [slot ...]
def CLUSTER_ADDSLOTS(*all_input_slots):
    # 遍历所有输入槽,检查它们是否都是未指派槽
    for i in all_input_slots:
        # 如果有哪怕一个槽已经被指派给了某个节点
        # 那么向客户端返回错误,并终止命令执行
        if clusterState.slots[i] != NULL:
            reply_error()
            return
    # 如果所有输入槽都是未指派槽
    # 那么再次遍历所有输入槽,将这些槽指派给当前节点
    for i in all_input_slots:
        # 设置clusterState结构的slots数组
        # 将slots[i]的指针指向代表当前节点的clusterNode结构
        clusterState.slots[i] = clusterState.myself
        # 访问代表当前节点的clusterNode结构的slots数组
        # 将数组在索引i上的二进制位设置为1
        setSlotBit(clusterState.myself.slots, i)

最后,在CLUSTER ADDSLOTS命令执行完毕之后,节点会通过发送消息告知集群中的其他节点,自己目前正在负责处理哪些槽。

节点的clusterState结构:

节点的clusterState结构

执行CLUSTER ADDSLOTS命令之后的clusterState结构:

执行CLUSTER ADDSLOTS命令之后的clusterState结构

在集群中执行命令

在对数据库中的16384个槽都进行了指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令了。

当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己:

  • 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令。
  • 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误,指引客户端转向(redirect)至正确的节点,并再次发送之前想要执行的命令。

判断客户端是否需要转向的流程

计算键属于哪个槽

节点使用以下算法来计算给定键key属于哪个槽:

def slot_number(key):
    return CRC16(key) & 16383

其中CRC16(key)语句用于计算键key的CRC-16校验和,而&16383语句则用于计算出一个介于0至16383之间的整数作为键key的槽号。

使用CLUSTER KEYSLOT<key>命令可以查看一个给定键属于哪个槽:

127.0.0.1:7000> CLUSTER KEYSLOT "date"
(integer) 2022
127.0.0.1:7000> CLUSTER KEYSLOT "msg"
(integer) 6257
127.0.0.1:7000> CLUSTER KEYSLOT "name"
(integer) 5798
127.0.0.1:7000> CLUSTER KEYSLOT "fruits"
(integer) 14943

CLUSTER KEYSLOT命令就是通过调用上面给出的槽分配算法来实现的。

如果键内容包含{和}大括号字符,则计算槽的有效部分是括号内的内容;否则采用键的全内容计算槽。

127.0.0.1:6379> cluster keyslot key:test:111
(integer) 10050
127.0.0.1:6379> cluster keyslot key:{hash_tag}:111
(integer) 2515
127.0.0.1:6379> cluster keyslot key:{hash_tag}:222
(integer) 2515

其中键内部使用大括号包含的内容又叫做hash_tag,它提供不同的键可以具备相同slot的功能,常用于Redis IO优化。

例如在集群模式下使用mget等命令优化批量调用时,键列表必须具有相同的slot,否则会报错。这时可以利用hash_tag让不同的键具有相同的slot达到优化的目的。

127.0.0.1:6385> mget user:10086:frends user:10086:videos
(error) CROSSSLOT Keys in request don't hash to the same slot
127.0.0.1:6385> mget user:{10086}:friends user:{10086}:videos
1) "friends"
2) "videos"

Pipeline同样可以受益于hash_tag,由于Pipeline只能向一个节点批量发送执行命令,而相同slot必然会对应到唯一的节点,降低了集群使用Pipeline的门槛。

判断是否由当前节点负责处理

当节点计算出键所属的槽i之后,节点就会检查自己在clusterState.slots数组中的项i,判断键所在的槽是否由自己负责:

  1. 如果clusterState.slots[i]等于clusterState.myself,那么说明槽i由当前节点负责,节点可以执行客户端发送的命令。
  2. 如果clusterState.slots[i]不等于clusterState.myself,那么说明槽i并非由当前节点负责,节点会根据clusterState.slots[i]指向的clusterNode结构所记录的节点IP和端口号,向客户端返回MOVED错误,指引客户端转向至正在处理槽i的节点。

MOVED错误

当节点发现键所在的槽并非由自己负责处理的时候,节点就会向客户端返回一个MOVED错误,指引客户端转向至正在负责槽的节点。

MOVED错误的格式为:

MOVED <slot> <ip>:<port>
MOVED 10086 127.0.0.1:7002

其中slot为键所在的槽,而ip和port则是负责处理槽slot的节点的IP地址和端口号。

当客户端接收到节点返回的MOVED错误时,客户端会根据MOVED错误中提供的IP地址和端口号,转向至负责处理槽slot的节点,并向该节点重新发送之前想要执行的命令。

MOVED重定向执行流程

节点7000向客户端返回MOVED错误

客户端根据MOVED错误的指示转向至节点7001

一个集群客户端通常会与集群中的多个节点创建套接字连接,而所谓的节点转向实际上就是换一个套接字来发送命令。
如果客户端尚未与想要转向的节点创建套接字连接,那么客户端会先根据MOVED错误提供的IP地址和端口号来连接节点,然后再进行转向。

注意事项:集群模式的redis-cli客户端在接收到MOVED错误时,并不会打印出MOVED错误,而是根据MOVED错误自动进行节点转向,并打印出转向信息,所以我们是看不见节点返回的MOVED错误的。如果我们使用单机(stand alone)模式的redis-cli客户端,再次向节点7000发送相同的命令,那么MOVED错误就会被客户端打印出来。

cluster slots风暴

Smart客户端通过在内部维护slot→node的映射关系,本地就可实现键到节点的查找,从而保证IO效率的最大化,而MOVED重定向负责协助Smart客户端更新slot→node映射。

  1. 计算slot并根据slots缓存获取目标节点连接,发送命令。
  2. 如果出现连接错误,使用随机连接重新执行键命令,每次命令重试对redi-rections参数减1。
  3. 捕获到MOVED重定向错误,使用cluster slots命令更新slots缓存(renewSlotCache方法)。
  4. 重复执行1~3步,直到命令执行成功,或者当redirections<=0时抛出Jedis ClusterMaxRedirectionsException异常。

Jedis客户端命令执行流程

获得写锁后再执行cluster slots命令初始化缓存,由于集群所有的键命令都会执行getSlotPool方法计算槽对应节点,它内部要求读锁。 Reentrant ReadWriteLock是读锁共享且读写锁互斥,从而导致所有的请求都会造成阻塞。对于并发量高的场景将极大地影响集群吞吐。这个现象称为cluster slots风暴,

  • 重试机制导致IO通信放大问题。比如默认重试5次的情况,当抛出JedisClusterMaxRedirectionsException异常时,内部最少需要9次IO通信
    • 5次发送命令+2次ping命令保证随机节点正常+2次cluster slots命令初始化slots缓存。导致异常判定时间变长。
  • 个别节点操作异常导致频繁的更新slots缓存,多次调用cluster slots命令,高并发时将过度消耗Redis节点资源,如果集群slot<->node映射庞大则cluster slots返回信息越多,问题越严重。

针对以上问题在Jedis2.8.2版本做了改进:

  • 当接收到JedisConnectionException时不再轻易初始化slots缓存,大幅降低内部IO次数
    • 只有当重试次数到最后1次或者出现MovedDataException时才更新slots操作,降低了cluster slots命令调用次数。
  • 当更新slots缓存时,不再使用ping命令检测节点活跃度,并且使用rediscovering变量保证同一时刻只有一个线程更新slots缓存,其他线程忽略,优化了写锁阻塞和cluster slots调用次数。

Jedis2.8.2之后的版本,当出现JedisConnectionException时,命令发送次数变为5次:4次重试命令+1次cluster slots命令,同时避免了cluster slots不必要的并发调用。

可以利用JedisCluster的scan api完成keys、flushall操作。 利用CRC16算法计算出slot,然后对key进行归档后获取对应slot的JedisPool进行mget或者Pipeline等操作。

节点数据库的实现

集群节点保存键值对以及键值对过期时间的方式,与单机Redis服务器保存键值对以及键值对过期时间的方式完全相同。
节点和单机服务器在数据库方面的一个区别是,节点只能使用0号数据库,而单机Redis服务器则没有这一限制。

另外,除了将键值对保存在数据库里面之外,节点还会用clusterState结构中的slots_to_keys跳跃表来保存槽和键之间的关系:

typedef struct clusterState {
    // ...
    zskiplist *slots_to_keys;
    // ...
} clusterState;

slots_to_keys跳跃表每个节点的分值(score)都是一个槽号,而每个节点的成员(member)都是一个数据库键:

  • 每当节点往数据库中添加一个新的键值对时,节点就会将这个键以及键的槽号关联到slots_to_keys跳跃表。
  • 当节点删除数据库中的某个键值对时,节点就会在slots_to_keys跳跃表解除被删除键与槽号的关联。

通过在slots_to_keys跳跃表中记录各个数据库键所属的槽,节点可以很方便地对属于某个或某些槽的所有数据库键进行批量操作。 例如命令CLUSTER GETKEYSINSLOT<slot><count>命令可以返回最多count个属于槽slot的数据库键,而这个命令就是通过遍历slots_to_keys跳跃表来实现的。

节点7000的数据库:

节点7000的数据库

节点7000的slots_to_keys跳跃表:

节点7000的slots_to_keys跳跃表

重新分片

Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。

重新分片操作可以在线(online)进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。

迁移

命令 作用域 原子性 支持多个键
move Redis实例内部
dump+restore Redis实例之间
migrate(就是dump+restore+del的组合) Redis实例之间

重新分片的实现原理

Redis集群的重新分片操作是由Redis的集群管理软件redis-trib负责执行的,Redis提供了进行重新分片所需的所有命令,而redis-trib则通过向源节点和目标节点发送命令来进行重新分片操作。

redis-trib对集群的单个槽slot进行重新分片的步骤如下:

  1. redis-trib对目标节点发送CLUSTER SETSLOT<slot>IMPORTING<source_id>命令,让目标节点准备好从源节点导入(import)属于槽slot的键值对。
  2. redis-trib对源节点发送CLUSTER SETSLOT<slot>MIGRATING<target_id>命令,让源节点准备好将属于槽slot的键值对迁移(migrate)至目标节点。
  3. redis-trib向源节点发送CLUSTER GETKEYSINSLOT<slot><count>命令,获得最多count个属于槽slot的键值对的键名(key name)。
  4. 对于步骤3获得的每个键名,redis-trib都向源节点发送一个MIGRATE<target_ip><target_port><key_name><timeout>命令,将被选中的键原子地从源节点迁移至目标节点。
  5. 重复执行步骤3和步骤4,直到源节点保存的所有属于槽slot的键值对都被迁移至目标节点为止。每次迁移键的过程如图17-24所示。
  6. redis-trib向集群中的任意一个节点发送CLUSTER SETSLOT<slot>NODE<target_id>命令,将槽slot指派给目标节点,这一指派信息会通过消息发送至整个集群,最终集群中的所有节点都会知道槽slot已经指派给了目标节点。

迁移键的过程:

迁移键的过程

对槽slot进行重新分片的过程:

对槽slot进行重新分片的过程

ASK错误

在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。

当客户端向源节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时:

  • 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令。
  • 如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个ASK错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令。

判断是否发送ASK错误的过程:

判断是否发送ASK错误的过程

  • 如果键所在的槽由当前节点负责,但键不存在则查找migrating_slots_to数组查看槽是否正在迁出,如果是返回ASK重定向。
  • 如果客户端发送asking命令打开了CLIENT_ASKING标识,则该客户端下次发送键命令时查找importing_slots_from数组获取clusterNode,如果指向自身则执行命令。
    • asking命令是一次性命令,每次执行完后客户端标识都会修改回原状态,因此每次客户端接收到ASK重定向后都需要发送asking命令。
  • 批量操作。ASK重定向对单键命令支持得很完善,但是,在开发中我们经常使用批量操作,如mget或pipeline。当槽处于迁移状态时,批量操作会受到影响。

集群环境下使用mget、mset操作时,slot迁移数据期间由于键列表无法保证在同一节点,会导致大量错误。 Pipeline也无法批量操作,但是可以根据返回结果的ask错误做容错处理。

集群环境下对于使用批量操作的场景,建议优先使用Pipeline方式,在客户端实现对ASK重定向的正确处理,这样既可以受益于批量操作的IO优化,又可以兼容slot迁移场景。

被隐藏的ASK错误

和接到MOVED错误时的情况类似,集群模式的redis-cli在接到ASK错误时也不会打印错误,而是自动根据错误提供的IP地址和端口进行转向动作。如果想看到节点发送的ASK错误的话,可以使用单机模式的redis-cli客户端:

$ redis-cli -p 7002
127.0.0.1:7002> GET "love"
(error) ASK 16198 127.0.0.1:7003

CLUSTER SETSLOT IMPORTING命令的实现

clusterState结构的importing_slots_from数组记录了当前节点正在从其他节点导入的槽:

typedef struct clusterState {
  // ...
  clusterNode *importing_slots_from[16384];
  // ...
} clusterState;

如果importing_slots_from[i]的值不为NULL,而是指向一个clusterNode结构,那么表示当前节点正在从clusterNode所代表的节点导入槽i。

在对集群进行重新分片的时候,向目标节点发送命令:

CLUSTER SETSLOT <i> IMPORTING <source_id>

可以将目标节点clusterState.importing_slots_from[i]的值设置为source_id所代表节点的clusterNode结构。

# 9dfb... 是节点7002 的ID 
127.0.0.1:7003> CLUSTER SETSLOT 16198 IMPORTING 9dfb4c4e016e627d9769e4c9bb0d4fa208e65c26
OK

节点7003的importing_slots_from数组

CLUSTER SETSLOT MIGRATING命令的实现

clusterState结构的migrating_slots_to数组记录了当前节点正在迁移至其他节点的槽:

typedef struct clusterState {
   // ...
   clusterNode *migrating_slots_to[16384];
   // ...
} clusterState;

如果migrating_slots_to[i]的值不为NULL,而是指向一个clusterNode结构,那么表示当前节点正在将槽i迁移至clusterNode所代表的节点。

在对集群进行重新分片的时候,向源节点发送命令:

CLUSTER SETSLOT <i> MIGRATING <target_id>

可以将源节点clusterState.migrating_slots_to[i]的值设置为target_id所代表节点的clusterNode结构。

# 0457... 是节点7003 的ID 
127.0.0.1:7002> CLUSTER SETSLOT 16198 MIGRATING 04579925484ce537d3410d7ce97bd2e260c459a2
OK

节点7002的migrating_slots_to数组

ASK错误

  • 如果节点收到一个关于键key的命令请求,并且键key所属的槽i正好就指派给了这个节点,那么节点会尝试在自己的数据库里查找键key,如果找到了的话,节点就直接执行客户端发送的命令。
  • 如果节点没有在自己的数据库里找到键key,那么节点会检查自己的clusterState.migrating_slots_to[i],看键key所属的槽i是否正在进行迁移,如果槽i的确在进行迁移的话,那么节点会向客户端发送一个ASK错误,引导客户端到正在导入槽i的节点去查找键key。

客户端接收到节点7002返回的ASK错误:

客户端接收到节点7002返回的ASK错误

接到ASK错误的客户端会根据错误提供的IP地址和端口号,转向至正在导入槽的目标节点,然后首先向目标节点发送一个ASKING命令,之后再重新发送原本想要执行的命令。

ASKING
GET "love"
"you get the key 'love'"

客户端转向至节点7003

ASKING命令

ASKING命令唯一要做的就是打开发送该命令的客户端的REDIS_ASKING标识,以下是该命令的伪代码实现:

def ASKING():
    # 打开标识
    client.flags |= REDIS_ASKING
    # 向客户端返回OK回复
    reply("OK")

在一般情况下,如果客户端向节点发送一个关于槽i的命令,而槽i又没有指派给这个节点的话,那么节点将向客户端返回一个MOVED错误; 但是,如果节点的clusterState.importing_slots_from[i]显示节点正在导入槽i,并且发送命令的客户端带有REDIS_ASKING标识,那么节点将破例执行这个关于槽i的命令一次。

节点判断是否执行客户端命令的过程

客户端的REDIS_ASKING标识是一个一次性标识,当节点执行了一个带有REDIS_ASKING标识的客户端发送的命令之后,客户端的REDIS_ASKING标识就会被移除。

127.0.0.1:7003> ASKING       # 打开REDIS_ASKING标识OK
127.0.0.1:7003> GET "love"   # 移除REDIS_ASKING标识
"you get the key 'love'"
127.0.0.1:7003> GET "love"   # REDIS_ASKING标识未打开,执行失败
(error) MOVED 16198 127.0.0.1:7002

ASK错误和MOVED错误的区别

  • MOVED错误代表槽的负责权已经从一个节点转移到了另一个节点
    • 在客户端收到关于槽i的MOVED错误之后,客户端每次遇到关于槽i的命令请求时,都可以直接将命令请求发送至MOVED错误所指向的节点,因为该节点就是目前负责槽i的节点。
  • ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施
    • 在客户端收到关于槽i的ASK错误之后,客户端只会在接下来的一次命令请求中将关于槽i的命令请求发送至ASK错误所指示的节点
    • 但这种转向不会对客户端今后发送关于槽i的命令请求产生任何影响,客户端仍然会将关于槽i的命令请求发送至目前负责处理槽i的节点,除非ASK错误再次出现。

复制与故障转移

Redis集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。

设置从节点

CLUSTER REPLICATE <node_id>

可以让接收命令的节点成为node_id所指定节点的从节点,并开始对主节点进行复制:

  • 接收到该命令的节点首先会在自己的clusterState.nodes字典中找到node_id所对应节点的clusterNode结构,并将自己的clusterState.myself.slaveof指针指向这个结构,以此来记录这个节点正在复制的主节点:
  • 然后节点会修改自己在clusterState.myself.flags中的属性,关闭原本的REDIS_NODE_MASTER标识,打开REDIS_NODE_SLAVE标识,表示这个节点已经由原来的主节点变成了从节点。
  • 最后,节点会调用复制代码,并根据clusterState.myself.slaveof指向的clusterNode结构所保存的IP地址和端口号,对主节点进行复制。因为节点的复制功能和单机Redis服务器的复制功能使用了相同的代码,所以让从节点复制主节点相当于向从节点发送命令SLAVEOF。
struct clusterNode {
    // ...
    // 如果这是一个从节点,那么指向主节点
    struct clusterNode *slaveof;
    // ...
};

一个节点成为从节点,并开始复制某个主节点这一信息会通过消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个从节点正在复制某个主节点。

集群中的所有节点都会在代表主节点的clusterNode结构的slaves属性和numslaves属性中记录正在复制这个主节点的从节点名单:

struct clusterNode {
    // ...
    // 正在复制这个主节点的从节点数量
    int numslaves;
    // 一个数组
    // 每个数组项指向一个正在复制这个主节点的从节点的clusterNode结构
    struct clusterNode **slaves;
    // ...
};

故障检测

集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息, 那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线(probable fail,PFAIL)(结构的flags属性中打开REDIS_NODE_PFAIL标识)。

集群中的各个节点会通过互相发送消息的方式来交换集群中各个节点的状态信息。

当一个主节点A通过消息得知主节点B认为主节点C进入了疑似下线状态时,主节点A会在自己的clusterState.nodes字典中找到主节点C所对应的clusterNode结构,并将主节点B的下线报告(failure report)添加到clusterNode结构的fail_reports链表里面:

struct clusterNode {
    // ...
    // 一个链表,记录了所有其他节点对该节点的下线报告
    list *fail_reports;
    // ...
};

每个下线报告由一个clusterNodeFailReport结构表示:

struct clusterNodeFailReport {
    // 报告目标节点已经下线的节点
    struct clusterNode *node;
    // 最后一次从node节点收到下线报告的时间
    // 程序使用这个时间戳来检查下线报告是否过期
    // (与当前时间相差太久的下线报告会被删除)
    mstime_t time;
} typedef clusterNodeFailReport;

每个下线报告都存在有效期,每次在尝试触发客观下线时,都会检测下线报告是否过期,对于过期的下线报告将被删除。如果在cluster-node-time*2的时间内该下线报告没有得到更新则过期并删除。

下线报告的有效期限是server.cluster_node_timeout*2,主要是针对故障误报的情况。 例如节点A在上一小时报告节点B主观下线,但是之后又恢复正常。现在又有其他节点上报节点B主观下线,根据实际情况之前的属于误报不能被使用。

主观下线识别流程:

主观下线识别流程

  1. 节点a发送ping消息给节点b,如果通信正常将接收到pong消息,节点a更新最近一次与节点b的通信时间。
  2. 如果节点a与节点b通信出现问题则断开连接,下次会进行重连。如果一直通信失败,则节点a记录的与节点b最后通信时间将无法更新。
  3. 节点a内的定时任务检测到与节点b最后通信时间超高cluster-node-timeout时,更新本地对节点b的状态为主观下线(pfail)。

当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群内传播。通过Gossip消息传播,集群内节点不断收集到故障节点的下线报告。

如果在一个集群里面,半数以上负责处理槽的主节点都将某个主节点x报告为疑似下线,那么这个主节点x将被标记为已下线(FAIL), 将主节点x标记为已下线的节点会向集群广播一条关于主节点x的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点x标记为已下线。

客观下线逻辑流程

尝试客观下线流程

节点7001向集群广播FAIL消息

  1. 当消息体内含有其他节点的pfail状态会判断发送节点的状态,如果发送节点是主节点则对报告的pfail状态处理,从节点则忽略。
  2. 找到pfail对应的节点结构,更新clusterNode内部下线报告链表。
  3. 根据更新后的下线报告链表告尝试进行客观下线。

接收节点收到ping/meet消息时,执行解析消息头和消息体流程:

  • 解析消息头过程:
    • 消息头包含了发送节点的信息,如果发送节点是新节点且消息是meet类型,则加入到本地节点列表;
    • 如果是已知节点,则尝试更新发送节点的状态,如槽映射关系、主从角色等状态。
  • 解析消息体过程:
    • 如果消息体的clusterMsgDataGossip数组包含的节点是新节点,则尝试发起与新节点的meet握手流程;
    • 如果是已知节点,则根据cluster MsgDataGossip中的flags字段判断该节点是否下线,用于故障转移。

消息处理完后回复pong消息,内容同样包含消息头和消息体,发送节点接收到回复的pong消息后,采用类似的流程解析处理消息并更新与接收节点最后通信时间,完成一次消息通信。

为什么必须是负责槽的主节点参与故障发现决策?

因为集群模式下只有处理槽的主节点才负责读写请求和集群槽等关键信息维护,而从节点只进行主节点数据和状态信息的复制。

为什么半数以上处理槽的主节点?

必须半数以上是为了应对网络分区等原因造成的集群分割情况,被分割的小集群因为无法完成从主观下线到客观下线这一关键过程,从而防止小集群完成故障转移之后继续对外提供服务。

故障转移

当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤:

  1. 复制下线主节点的所有从节点里面,会有一个从节点被选中。
  2. 被选中的从节点会执行SLAVEOF no one命令,成为新的主节点。
  3. 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己。
  4. 新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
  5. 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。

故障恢复流程:

故障恢复流程

选择新的主节点

新的主节点是通过选举产生的。

集群选举新的主节点的方法:

  1. 集群的配置纪元是一个自增计数器,它的初始值为0。
  2. 当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被增一。
  3. 对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票的机会,而第一个向主节点要求投票的从节点将获得主节点的投票。
  4. 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,要求所有收到这条消息、并且具有投票权的主节点向这个从节点投票。
  5. 如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示这个主节点支持从节点成为新的主节点。
  6. 每个参与选举的从节点都会接收CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持。
  7. 如果集群里有N个具有投票权的主节点,那么当一个从节点收集到大于等于N/2+1张支持票时,这个从节点就会当选为新的主节点。
    • 与Raft协议不同,Leader并不会把自己成为Leader的消息发给其他Sentinel。其他Sentinel等待Leader从slave选出master后,检测到新的master正常工作后,就会去掉客观下线的标识,从而不需要进入故障转移流程。
  8. 因为在每一个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有N个主节点进行投票,那么具有大于等于N/2+1张支持票的从节点只会有一个,这确保了新的主节点只会有一个。
  9. 如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个新的配置纪元,并再次进行选举,直到选出新的主节点为止。
    • 某个Sentinel认定master客观下线的节点后,该Sentinel会先看看自己有没有投过票,如果自己已经投过票给其他Sentinel了,在2倍故障转移的超时时间自己就不会成为Leader。相当于它是一个Follower。

这个选举新主节点的方法和选举领头Sentinel的方法非常相似,因为两者都是基于Raft算法的领头选举(leader election)方法来实现的。

资格检查

每个从节点都要检查最后与主节点断线时间,判断是否有资格替换故障的主节点。 如果从节点与主节点断线时间超过cluster-node-time*cluster-slave-validity-factor,则当前从节点不具备故障转移资格。 参数cluster-slave-validity-factor用于从节点的有效因子,默认为10。

准备选举时间

当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。

struct clusterState {
    // 记录之前或者下次将要执行故障选举时间
    mstime_t failover_auth_time; 
    // 记录当前从节点排名
    int failover_auth_rank; 
}

这里之所以采用延迟触发机制(使用优先级排名,更新选举触发时间,所有的从节点中复制偏移量最大的将提前触发故障选举流程),主要是通过对多个从节点使用不同的延迟选举时间来支持优先级问题。 复制偏移量越大说明从节点延迟越低,那么它应该具有更高的优先级来替换故障主节点。

的三个从节点根据自身复制偏移量设置延迟选举时间:

从节点延迟触发选举时间

发起选举

当从节点定时任务检测到达故障选举时间(failover_auth_time)到达后,发起选举流程如下:

  1. 更新配置纪元
    • 配置纪元是一个只增不减的整数,每个主节点自身维护一个配置纪元(clusterNode.configEpoch)标示当前主节点的版本,所有主节点的配置纪元都不相等,从节点会复制主节点的配置纪元。
    • 整个集群又维护一个全局的配置纪元(clusterState.current Epoch),用于记录集群内所有主节点配置纪元的最大版本。
    • 配置纪元的主要作用:
      • 标示集群内每个主节点的不同版本和当前集群最大的版本。
      • 每次集群发生重要事件时,这里的重要事件指出现新的主节点(新加入的或者由从节点转换而来),从节点竞争选举。都会递增集群全局的配置纪元并赋值给相关主节点,用于记录这一关键事件。
      • 主节点具有更大的配置纪元代表了更新的集群状态,因此当节点间进行ping/pong消息交换时,如出现slots等关键信息不一致时,以配置纪元更大的一方为准,防止过时的消息状态污染集群。
  2. 广播选举消息
    • 在集群内广播选举消息(FAILOVER_AUTH_REQUEST),并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次选举。

注意事项:之前在通过cluster setslot命令修改槽节点映射时,需要确保执行请求的主节点本地配置纪元(configEpoch)是最大值,否则修改后的槽信息在消息传播中不会被拥有更高的配置纪元的节点采纳。由于Gossip通信机制无法准确知道当前最大的配置纪元在哪个节点,因此在槽迁移任务最后的clustersetslot{slot}node{nodeId}命令需要在全部主节点中执行一遍。

选举投票

只有持有槽的主节点才会处理故障选举消息(FAILOVER_AUTH_REQUEST),因为每个持有槽的节点在一个配置纪元内都有唯一的一张选票, 当接到第一个请求投票的从节点消息时回复FAILOVER_AUTH_ACK消息作为投票,之后相同配置纪元内其他从节点的选举消息将忽略。

投票过程其实是一个领导者选举的过程,如集群内有N个持有槽的主节点代表有N张选票。 由于在每个配置纪元内持有槽的主节点只能投票给一个从节点,因此只能有一个从节点获得N/2+1的选票,保证能够找出唯一的从节点。

Redis集群没有直接使用从节点进行领导者选举,主要因为从节点数必须大于等于3个才能保证凑够N/2+1个节点,将导致从节点资源浪费。 使用集群内所有持有槽的主节点进行领导者选举,即使只有一个从节点也可以完成选举过程。

当从节点收集到N/2+1个持有槽的主节点投票时,从节点可以执行替换主节点操作。

从节点slave b-1成功获得3张选票

故障主节点也算在投票数内,假设集群内节点规模是3主3从,其中有2个主节点部署在一台机器上,当这台机器宕机时, 由于从节点无法收集到3/2+1个主节点选票将导致故障转移失败。这个问题也适用于故障发现环节。 因此部署集群时所有主节点最少需要部署在3台物理机上才能避免单点问题。

投票作废:

每个配置纪元代表了一次选举周期,如果在开始投票之后的cluster-node-timeout*2时间内从节点没有获取足够数量的投票,则本次选举作废。 从节点对配置纪元自增并发起下一轮投票,直到选举成功为止。

替换主节点

当从节点收集到足够的选票之后,触发替换主节点操作:

  1. 当前从节点取消复制变为主节点。
  2. 执行clusterDelSlot操作撤销故障主节点负责的槽,并执行clusterAddSlot把这些槽委派给自己。
  3. 向集群广播自己的pong消息,通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息。
故障转移时间
  1. 主观下线(pfail)识别时间=cluster-node-timeout。
  2. 主观下线状态消息传播时间<=cluster-node-timeout/2。消息通信机制对超过cluster-node-timeout/2未通信节点会发起ping消息,消息体在选择包含哪些节点时会优先选取下线状态节点,所以通常这段时间内能够收集到半数以上主节点的pfail报告从而完成故障发现。
  3. 从节点转移时间<=1000毫秒。由于存在延迟发起选举机制,偏移量最大的从节点会最多延迟1秒发起选举。通常第一次选举就会成功,所以从节点执行转移时间在1秒以内。

根据以上分析可以预估出故障转移时间,如下:

failover-time( 毫秒 ) ≤ cluster-node-timeout + cluster-node-timeout/2 + 1000

因此,故障转移时间跟cluster-node-timeout参数息息相关,默认15秒。

配置时可以根据业务容忍度做出适当调整,但不是越小越好,会增加带宽消耗。

伸缩

迁移槽和数据

在源节点上执行migrate {targetIp} {targetPort} “” 0 {timeout} keys {keys…}命令,把获取的键通过流水线(pipeline)机制批量迁移到目标节点,批量迁移版本的migrate命令在Redis3.0.6以上版本提供,之前的migrate命令只能单个键迁移。 对于大量key的场景,批量键迁移将极大降低节点之间网络IO次数。

# 重新分片
redis-trib.rb reshard host:port --from <arg> --to <arg> --slots <arg> --yes --t <arg> --pipeline <arg>

消息

Gossip消息:

Gossip协议的主要职责就是信息交换。信息交换的载体就是节点彼此发送的Gossip消息。

常用的Gossip消息可分为:ping消息、pong消息、meet消息、fail消息等。

不同消息通信模式

集群中的各个节点通过发送和接收消息(message)来进行通信,我们称发送消息的节点为发送者(sender),接收消息的节点为接收者(receiver)。

节点发送的消息主要有以下五种:

  • MEET消息:当发送者接到客户端发送的CLUSTER MEET命令时,发送者会向接收者发送MEET消息,请求接收者加入到发送者当前所处的集群里面。
  • PING消息:集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送过PING消息的节点发送PING消息,以此来检测被选中的节点是否在线。除此之外,如果节点A最后一次收到节点B发送的PONG消息的时间,距离当前时间已经超过了节点A的cluster-node-timeout选项设置时长的一半,那么节点A也会向节点B发送PING消息,这可以防止节点A因为长时间没有随机选中节点B作为PING消息的发送对象而导致对节点B的信息更新滞后。
  • PONG消息:当接收者收到发送者发来的MEET消息或者PING消息时,为了向发送者确认这条MEET消息或者PING消息已到达,接收者会向发送者返回一条PONG消息。另外,一个节点也可以通过向集群广播自己的PONG消息来让集群中的其他节点立即刷新关于这个节点的认识,例如当一次故障转移操作成功执行之后,新的主节点会向集群广播一条PONG消息,以此来让集群中的其他节点立即知道这个节点已经变成了主节点,并且接管了已下线节点负责的槽。
  • FAIL消息:当一个主节点A判断另一个主节点B已经进入FAIL状态时,节点A会向集群广播一条关于节点B的FAIL消息,所有收到这条消息的节点都会立即将节点B标记为已下线。
  • PUBLISH消息:当节点接收到一个PUBLISH命令时,节点会执行这个命令,并向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会执行相同的PUBLISH命令。

一条消息由消息头(header)和消息正文(data)组成。

消息头

节点发送的所有消息都由一个消息头包裹,消息头除了包含消息正文之外,还记录了消息发送者自身的一些信息,因为这些信息也会被消息接收者用到,所以严格来讲,我们可以认为消息头本身也是消息的一部分。

每个消息头都由一个cluster.h/clusterMsg结构表示:

typedef struct {
	// 消息的长度(包括这个消息头的长度和消息正文的长度)
	uint32_t totlen;
	// 消息的类型
	uint16_t type;
	// 消息正文包含的节点信息数量
	// 只在发送MEET、PING、PONG这三种Gossip协议消息时使用
	uint16_t count;
	// 发送者所处的配置纪元
	uint64_t currentEpoch;
	// 如果发送者是一个主节点,那么这里记录的是发送者的配置纪元
	// 如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的配置纪元
	uint64_t configEpoch;
	// 发送者的名字(ID) 
	char sender[REDIS_CLUSTER_NAMELEN];
	// 发送者目前的槽指派信息
	unsigned char myslots[REDIS_CLUSTER_SLOTS/8];
	// 如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的名字
	// 如果发送者是一个主节点,那么这里记录的是REDIS_NODE_NULL_NAME
	// (一个40字节长,值全为0的字节数组)
	char slaveof[REDIS_CLUSTER_NAMELEN];
	// 发送者的端口号
	uint16_t port;
	// 发送者的标识值
	uint16_t flags;
	// 发送者所处集群的状态
	unsigned char state;
	// 消息的正文(或者说,内容)
	union clusterMsgData data;
} clusterMsg;

clusterMsg.data属性指向联合cluster.h/clusterMsgData,这个联合就是消息的正文:

union clusterMsgData {
    // MEET、PING、PONG消息的正文
    struct {
      // 每条MEET、PING、PONG消息都包含两个
      // clusterMsgDataGossip结构
      clusterMsgDataGossip gossip[1];
    } ping;
    // FAIL消息的正文
    struct {
      clusterMsgDataFail about;
    } fail;
    // PUBLISH消息的正文
    struct {
      clusterMsgDataPublish msg;
    } publish;
    // 其他消息的正文...
};

clusterMsg结构的currentEpoch、sender、myslots等属性记录了发送者自身的节点信息,接收者会根据这些信息,在自己的clusterState.nodes字典里找到发送者对应的clusterNode结构,并对结构进行更新。

MEET、PING、PONG消息的实现

Redis集群中的各个节点通过Gossip协议来交换各自关于不同节点的状态信息,其中Gossip协议由MEET、PING、PONG三种消息实现,这三种消息的正文都由两个cluster.h/clusterMsgDataGossip结构组成:

// MEET、PING、PONG消息的正文
struct {
    // 每条MEET、PING、PONG消息都包含两个
    // clusterMsgDataGossip结构
    clusterMsgDataGossip gossip[1];
} ping;

因为MEET、PING、PONG三种消息都使用相同的消息正文,所以节点通过消息头的type属性来判断一条消息是MEET消息、PING消息还是PONG消息。

每次发送MEET、PING、PONG消息时,发送者都从自己的已知节点列表中随机选出两个节点(可以是主节点或者从节点),并将这两个被选中节点的信息分别保存到两个clusterMsgDataGossip结构里。

clusterMsgDataGossip结构记录了被选中节点的名字,发送者与被选中节点最后一次发送和接收PING消息和PONG消息的时间戳,被选中节点的IP地址和端口号,以及被选中节点的标识值:

typedef struct {
    // 节点的名字
    char nodename[REDIS_CLUSTER_NAMELEN];
    // 最后一次向该节点发送PING消息的时间戳
    uint32_t ping_sent;
    // 最后一次从该节点接收到PONG消息的时间戳
    uint32_t pong_received;
    // 节点的IP地址
    char ip[16];
    // 节点的端口号
    uint16_t port;
    // 节点的标识值
    uint16_t flags;
} clusterMsgDataGossip;

当接收者收到MEET、PING、PONG消息时,接收者会访问消息正文中的两个clusterMsgDataGossip结构,并根据自己是否认识clusterMsgDataGossip结构中记录的被选中节点来选择进行哪种操作:

  • 如果被选中节点不存在于接收者的已知节点列表,那么说明接收者是第一次接触到被选中节点,接收者将根据结构中记录的IP地址和端口号等信息,与被选中节点进行握手。
  • 如果被选中节点已经存在于接收者的已知节点列表,那么说明接收者之前已经与被选中节点进行过接触,接收者将根据clusterMsgDataGossip结构记录的信息,对被选中节点所对应的clusterNode结构进行更新。

假设在一个包含A、B、C、D、E、F六个节点的集群里:

  • 节点A向节点D发送PING消息,并且消息里面包含了节点B和节点C的信息,当节点D收到这条PING消息时,它将更新自己对节点B和节点C的认识。
  • 之后,节点D将向节点A返回一条PONG消息,并且消息里面包含了节点E和节点F的消息,当节点A收到这条PONG消息时,它将更新自己对节点E和节点F的认识。

一个PING-PONG消息通信示例

节点选择

虽然Gossip协议的信息交换机制具有天然的分布式特性,但它是有成本的。 由于内部需要频繁地进行节点信息交换,而ping/pong消息会携带当前节点和部分其他节点的状态数据,势必会加重带宽和计算的负担。 Redis集群内节点通信采用固定频率(定时任务每秒执行10次)。因此节点每次选择需要通信的节点列表变得非常重要。通信节点选择过多虽然可以做到信息及时交换但成本过高。 节点选择过少会降低集群内所有节点彼此信息交换频率,从而影响故障判定、新节点发现等需求的速度。因此Redis集群的Gossip协议需要兼顾信息交换实时性和成本开销

选择通信节点的规则和消息携带的数据量

选择发送消息的节点数量

集群内每个节点维护定时任务默认每秒执行10次,每秒会随机选取5个节点找出最久没有通信的节点发送ping消息,用于保证Gossip信息交换的随机性。 每100毫秒都会扫描本地节点列表,如果发现节点最近一次接受pong消息的时间大于cluster_node_timeout/2,则立刻发送ping消息,防止该节点信息太长时间未更新。

根据以上规则得出每个节点每秒需要发送ping消息的数量=1+10*num(node.pong_received>cluster_node_timeout/2),因此cluster_node_timeout参数对消息发送的节点数量影响非常大。

  • 当我们的带宽资源紧张时,可以适当调大这个参数,如从默认15秒改为30秒来降低带宽占用率。
  • 过度调大cluster_node_timeout会影响消息交换的频率从而影响故障转移、槽信息更新、新节点发现的速度。

因此需要根据业务容忍度和资源消耗进行平衡。同时整个集群消息总交换量也跟节点数成正比。

消息数据量

每个ping消息的数据量体现在消息头和消息体中,其中消息头主要占用空间的字段是myslots[CLUSTER_SLOTS/8],占用2KB,这块空间占用相对固定。

消息体会携带一定数量的其他节点信息用于信息交换。

def get_wanted():
    int total_size = size(cluster.nodes)
    #  默认包含节点总量的 1/10
    int wanted = floor(total_size/10);
    if wanted < 3:
        #  至少携带 3 个其他节点信息
        wanted = 3;
    if wanted > total_size -2 :
        #  最多包含 total_size - 2 个
        wanted = total_size - 2;
  return wanted;

消息体携带数据量跟集群的节点数息息相关,更大的集群每次消息通信的成本也就更高,因此对于Redis集群来说并不是大而全的集群更好。

FAIL消息的实现

当集群里的主节点A将主节点B标记为已下线(FAIL)时,主节点A将向集群广播一条关于主节点B的FAIL消息,所有接收到这条FAIL消息的节点都会将主节点B标记为已下线。

在集群的节点数量比较大的情况下,单纯使用Gossip协议来传播节点的已下线信息会给节点的信息更新带来一定延迟,因为Gossip协议消息通常需要一段时间才能传播至整个集群, 而发送FAIL消息可以让集群里的所有节点立即知道某个主节点已下线,从而尽快判断是否需要将集群标记为下线,又或者对下线主节点进行故障转移。

FAIL消息的正文由cluster.h/clusterMsgDataFail结构表示,这个结构只包含一个nodename属性,该属性记录了已下线节点的名字:

typedef struct {
    char nodename[REDIS_CLUSTER_NAMELEN];
} clusterMsgDataFail;

因为集群里的所有节点都有一个独一无二的名字,所以FAIL消息里面只需要保存下线节点的名字,接收到消息的节点就可以根据这个名字来判断是哪个节点下线了。

节点7001将节点7000标记为已下线:

节点7001将节点7000标记为已下线

节点7001向集群广播FAIL消息:

节点7001向集群广播FAIL消息

节点7002和节点7003也将节点7000标记为已下线:

节点7002和节点7003也将节点7000标记为已下线

PUBLISH消息的实现

当客户端向集群中的某个节点发送命令:

PUBLISH <channel> <message>

接收到PUBLISH命令的节点不仅会向channel频道发送消息message,它还会向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会向channel频道发送message消息。

接收到PUBLISH命令的节点7000向集群广播PUBLISH消息:

接收到PUBLISH命令的节点7000向集群广播PUBLISH消息

PUBLISH消息的正文由cluster.h/clusterMsgDataPublish结构表示:

typedef struct {
    uint32_t channel_len;
    uint32_t message_len;
    // 定义为8 字节只是为了对齐其他消息结构
    // 实际的长度由保存的内容决定
    unsigned char bulk_data[8];
} clusterMsgDataPublish;

clusterMsgDataPublish结构的bulk_data属性是一个字节数组,这个字节数组保存了客户端通过PUBLISH命令发送给节点的channel参数和message参数,而结构的channel_len和message_len则分别保存了channel参数的长度和message参数的长度:

  • 其中bulk_data的0字节至channel_len-1字节保存的是channel参数。
  • 而bulk_data的channel_len字节至channel_len+message_len-1字节保存的则是message参数。
PUBLISH "news.it" "hello"

clusterMsgDataPublish结构示例

为什么不直接向节点广播PUBLISH命令

实际上,要让集群的所有节点都执行相同的PUBLISH命令,最简单的方法就是向所有节点广播相同的PUBLISH命令,这也是Redis在复制PUBLISH命令时所使用的方法, 不过因为这种做法并不符合Redis集群的“各个节点通过发送和接收消息来进行通信”这一规则,所以节点没有采取广播PUBLISH命令的做法。

集群运维

集群完整性

为了保证集群完整性,默认情况下当集群16384个槽任何一个没有指派到节点时整个集群不可用。执行任何键命令返回(error)CLUSTERDOWNHash slot not served错误。

但是当持有槽的主节点下线时,从故障发现到自动完成转移期间整个集群是不可用状态,对于大多数业务无法容忍这种情况, 因此建议将参数cluster-require-full-coverage配置为no,当主节点故障时只影响它负责槽的相关命令执行,不会影响其他主节点的可用性。

带宽消耗

集群内Gossip消息通信本身会消耗带宽,官方建议集群最大规模在1000以内,也是出于对消息通信成本的考虑,因此单集群不适合部署超大规模的节点。

  • 消息发送频率:跟cluster-node-timeout密切相关,当节点发现与其他节点最后通信时间超过cluster-node-timeout/2时会直接发送ping消息。
  • 消息数据量:每个消息主要的数据占用包含:slots槽数组(2KB空间)和整个集群1/10的状态数据(10个节点状态数据约1KB)。
  • 节点部署的机器规模:机器带宽的上线是固定的,因此相同规模的集群分布的机器越多每台机器划分的节点越均匀,则集群内整体的可用带宽越高。

集群带宽消耗主要分为:读写命令消耗+Gossip消息消耗。因此搭建Redis集群时需要根据业务数据规模和消息通信成本做出合理规划:

  1. 在满足业务需要的情况下尽量避免大集群。同一个系统可以针对不同业务场景拆分使用多套集群。这样每个集群既满足伸缩性和故障转移要求,还可以规避大规模集群的弊端。如笔者维护的一个推荐系统,根据数据特征使用了5个Redis集群,每个集群节点规模控制在100以内。
  2. 适度提高cluster-node-timeout降低消息发送频率,同时cluster-node-timeout还影响故障转移的速度,因此需要根据自身业务场景兼顾二者的平衡。
  3. 如果条件允许集群尽量均匀部署在更多机器上。避免集中部署,如集群有60个节点,集中部署在3台机器上每台部署20个节点,这时机器带宽消耗将非常严重。

Pub/Sub问题

当频繁应用Pub/Sub功能时应该避免在大量节点的集群内使用,否则会严重消耗集群内网络带宽。针对这种情况建议使用sentinel结构专门用于Pub/Sub功能,从而规避这一问题。

集群倾斜

数据倾斜

数据倾斜主要分为以下几种:

  • 节点和槽分配严重不均。
    • redis-trib.rb info {host:ip}查看槽分配
  • 不同槽对应键数量差异过大。
    • cluster countkeysinslot {slot}查看槽内键分布
    • cluster getkeysinslot {slot} {count}循环迭代槽内键
  • 集合对象包含大量元素。
    • redis-cli –bigkeys
  • 内存相关配置不一致。
    • hash-max-ziplist-value、set-max-intset-entries等压缩数据结构配置。当集群大量使用hash、set等数据结构时,如果内存压缩数据结构配置不一致,极端情况下会相差数倍的内存
请求倾斜

集群内特定节点请求量/流量过大将导致节点之间负载不均,影响集群均衡和运维成本。

常出现在热点键场景,当键命令消耗较低时如小对象的get、set、incr等,即使请求量差异较大一般也不会产生负载严重不均。 但是当热点键对应高算法复杂度的命令或者是大对象操作如hgetall、smembers等,会导致对应节点负载过高的情况。避免方式如下:

  1. 合理设计键,热点大集合对象做拆分或使用hmget替代hgetall避免整体读取。
  2. 不要使用热键作为hash_tag,避免映射到同一槽。
  3. 对于一致性要求不高的场景,客户端可使用本地缓存减少热键调用。
集群读写分离

只读连接

集群模式下从节点不接受任何读写请求,发送过来的键命令会重定向到负责槽的主节点上(其中包括它的主节点)。 当需要使用从节点分担主节点读压力时,可以使用readonly命令打开客户端连接只读状态。 之前的复制配置slave-read-only在集群模式下无效。当开启只读状态时,从节点接收读命令处理流程变为:
如果对应的槽属于自己正在复制的主节点则直接执行读命令,否则返回重定向信息。

readonly命令是连接级别生效,因此每次新建连接时都需要执行readonly开启只读状态。执行readwrite命令可以关闭连接只读状态。

读写分离

集群模式下读写分离成本比较高,可以直接扩展主节点数量提高集群性能,一般不建议集群模式下做读写分离。

手动故障转移

在从节点上执行cluster failover命令发起转移流程,默认情况下转移期间客户端请求会有短暂的阻塞,但不会丢失数据,流程如下:

  1. 从节点通知主节点停止处理所有客户端请求。
  2. 主节点发送对应从节点延迟复制的数据。
  3. 从节点接收处理复制延迟的数据,直到主从复制偏移量一致为止,保证复制数据不丢失。
  4. 从节点立刻发起投票选举(这里不需要延迟触发选举)。选举成功后断开复制变为新的主节点,之后向集群广播主节点pong消息。
  5. 旧主节点接受到消息后更新自身配置变为从节点,解除所有客户端请求阻塞,这些请求会被重定向到新主节点上执行。
  6. 旧主节点变为从节点后,向新的主节点发起全量复制流程。

主从节点转移后,新的从节点由于之前没有缓存主节点信息无法使用部分复制功能,所以会发起全量复制,当节点包含大量数据时会严重消耗CPU和网络资源,线上不要频繁操作。Redis4.0的Psync2将有效改善这一问题。

  • cluster failover force——用于当主节点宕机且无法自动完成故障转移情况。
    • 从节点接到cluster failover force请求时,从节点直接发起选举,不再跟主节点确认复制偏移量(从节点复制延迟的数据会丢失),当从节点选举成功后替换为新的主节点并广播集群配置。
  • cluster failover takeover——用于集群内超过一半以上主节点故障的场景,因为从节点无法收到半数以上主节点投票,所以无法完成选举过程。
    • 可以执行cluster failover takeover强制转移,接到命令的从节点不再进行选举流程而是直接更新本地配置纪元并替换主节点。
    • takeover故障转移由于没有通过领导者选举发起故障转移,会导致配置纪元存在冲突的可能。当冲突发生时,集群会以nodeId字典序更大的一方配置为准。因此要小心集群分区后,手动执行takeover导致的集群冲突问题。
    • 在集群可以自动完成故障转移的情况下,不要使用clusterfailover takeover强制干扰集群选举机制,该操作主要用于半数以上主节点故障时采取的强制措施,请慎用。

手动故障转移时,在满足当前需求的情况下建议优先级:

cluster failver>cluster failover force>cluster failover takeover

数据迁移

把单机Redis数据迁移到集群环境。redis-trib.rb工具提供了导入功能:

redis-trib.rb import host:port --from <arg> --copy --replace

唯品会开发的redis-migrate-tool,该工具可满足大多数Redis迁移需求:

  • 支持单机、Twemproxy、Redis Cluster、RDB/AOF等多种类型的数据迁移。
  • 工具模拟成从节点基于复制流迁移数据,从而支持在线迁移数据,业务方不需要停写。
  • 采用多线程加速数据迁移过程且提供数据校验和查看迁移状态等功能。

事务

  • 事务提供了一种将多个命令打包,然后一次性、有序地执行的机制。
  • 多个命令会被入队到事务队列中,然后按先进先出(FIFO)的顺序执行。
  • 事务在执行过程中不会被中断,当事务队列中的所有命令都被执行完毕之后,事务才会结束。
  • 带有WATCH命令的事务会将客户端和被监视的键在数据库的watched_keys字典中进行关联,当键被修改时,程序会将所有监视被修改键的客户端的REDIS_DIRTY_CAS标志打开。
  • 只有在客户端的REDIS_DIRTY_CAS标志未被打开时,服务器才会执行客户端提交的事务,否则的话,服务器将拒绝执行客户端提交的事务。
  • Redis的事务总是具有ACID中的原子性、一致性和隔离性,当服务器运行在AOF持久化模式下,并且appendfsync选项的值为always时,事务也具有耐久性。

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

事务的实现

一个事务从开始到结束通常会经历以下三个阶段:

1.事务开始。 2.命令入队。 3.事务执行。

事务开始

MULTI命令可以将执行该命令的客户端从非事务状态切换至事务状态,这一切换是通过在客户端状态的flags属性中打开REDIS_MULTI标识来完成的。

命令入队

当一个客户端切换到事务状态之后,服务器会根据这个客户端发来的不同命令执行不同的操作:

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

服务器判断命令是该入队还是该执行的过程

事务队列

每个Redis客户端都有自己的事务状态,这个事务状态保存在客户端状态的mstate属性里面:

typedef struct redisClient {
    // ...
    // 事务状态
    multiState mstate;    /* MULTI/EXEC state */
    // ...
} redisClient;

事务状态包含一个事务队列,以及一个已入队命令的计数器(也可以说是事务队列的长度):

typedef struct multiState {
    // 事务队列,FIFO顺序
    multiCmd *commands;
    // 已入队命令计数
    int count; 
} multiState;

事务队列是一个multiCmd类型的数组,数组中的每个multiCmd结构都保存了一个已入队命令的相关信息,包括指向命令实现函数的指针、命令的参数,以及参数的数量:

typedef struct multiCmd {
    // 参数
    robj **argv;
    // 参数数量
    int argc;
    // 命令指针
    struct redisCommand *cmd;
} multiCmd;

事务队列以先进先出(FIFO)的方式保存入队的命令,较先入队的命令会被放到数组的前面,而较后入队的命令则会被放到数组的后面。

执行事务

当一个处于事务状态的客户端向服务器发送EXEC命令时,这个EXEC命令将立即被服务器执行。服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端。

WATCH命令的实现

WATCH命令是一个乐观锁(optimistic locking),它可以在EXEC命令执行之前,监视任意数量的数据库键,并在EXEC命令执行时,检查被监视的键是否至少有一个已经被修改过了,如果是的话,服务器将拒绝执行事务,并向客户端返回代表事务执行失败的空回复。

redis> WATCH "name"
OK
redis> MULTI
OK
redis> SET "name" "peter"
QUEUED
redis> EXEC
(nil)

使用WATCH见识数据库键

每个Redis数据库都保存着一个watched_keys字典,这个字典的键是某个被WATCH命令监视的数据库键,而字典的值则是一个链表,链表中记录了所有监视相应数据库键的客户端:

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

通过执行WATCH命令,客户端可以在watched_keys字典中与被监视的键进行关联。

一个watched_keys字典

监控机制的触发

所有对数据库进行修改的命令,比如SET、LPUSH、SADD、ZREM、DEL、FLUSHDB等等,在执行之后都会调用multi.c/touchWatchKey函数对watched_keys字典进行检查, 查看是否有客户端正在监视刚刚被命令修改过的数据库键,如果有的话,那么touchWatchKey函数会将监视被修改键的客户端的REDIS_DIRTY_CAS标识打开,表示该客户端的事务安全性已经被破坏。

判断是否安全

当服务器接收到一个客户端发来的EXEC命令时,服务器会根据这个客户端是否打开了REDIS_DIRTY_CAS标识来决定是否执行事务:

  • 如果客户端的REDIS_DIRTY_CAS标识已经被打开,那么说明客户端所监视的键当中,至少有一个键已经被修改过了,在这种情况下,客户端提交的事务已经不再安全,所以服务器会拒绝执行客户端提交的事务。
  • 如果客户端的REDIS_DIRTY_CAS标识没有被打开,那么说明客户端监视的所有键都没有被修改过(或者客户端没有监视任何键),事务仍然是安全的,服务器将执行客户端提交的这个事务。

事务的ACID性质

原子性

事务具有原子性指的是,数据库将事务中的多个操作当作一个整体来执行,服务器要么就执行事务中的所有操作,要么就一个操作也不执行。

对于Redis的事务功能来说,事务队列中的命令要么就全部都执行,要么就一个都不执行,因此,Redis的事务是具有原子性的。

一个成功执行的事务,事务中的所有命令都会被执行。
一个执行失败的事务,这个事务因为命令入队出错而被服务器拒绝执行,事务中的所有命令都不会被执行。
Redis的事务和传统的关系型数据库事务的最大区别在于,Redis不支持事务回滚机制(rollback),即使事务队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下去,直到将事务队列中的所有命令都执行完毕为止。

不支持事务回滚是因为这种复杂的功能和Redis追求简单高效的设计主旨不相符,并且他认为,Redis事务的执行时错误通常都是编程错误产生的,这种错误通常只会出现在开发环境中。

一致性

事务具有一致性指的是,如果数据库在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据库也应该仍然是一致的。

“一致”指的是数据符合数据库本身的定义和要求,没有包含非法或者无效的错误数据。

Redis通过谨慎的错误检测和简单的设计来保证事务的一致性:

入队错误

如果一个事务在入队命令的过程中,出现了命令不存在,或者命令的格式不正确等情况,那么Redis将拒绝执行这个事务。

因为服务器会拒绝执行入队过程中出现错误的事务,所以Redis事务的一致性不会被带有入队错误的事务影响。

执行错误
  • 执行过程中发生的错误都是一些不能在入队时被服务器发现的错误,这些错误只会在命令实际执行时被触发。
  • 即使在事务的执行过程中发生了错误,服务器也不会中断事务的执行,它会继续执行事务中余下的其他命令,并且已执行的命令(包括执行命令所产生的结果)不会被出错的命令影响。

因为在事务执行的过程中,出错的命令会被服务器识别出来,并进行相应的错误处理,所以这些出错命令不会对数据库做任何修改,也不会对事务的一致性产生任何影响。

服务器停机

如果Redis服务器在执行事务的过程中停机,那么根据服务器所使用的持久化模式,可能有以下情况出现:

  • 如果服务器运行在无持久化的内存模式下,那么重启之后的数据库将是空白的,因此数据总是一致的。
  • 如果服务器运行在RDB模式下,那么在事务中途停机不会导致不一致性,因为服务器可以根据现有的RDB文件来恢复数据,从而将数据库还原到一个一致的状态。如果找不到可供使用的RDB文件,那么重启之后的数据库将是空白的,而空白数据库总是一致的。
  • 如果服务器运行在AOF模式下,那么在事务中途停机不会导致不一致性,因为服务器可以根据现有的AOF文件来恢复数据,从而将数据库还原到一个一致的状态。如果找不到可供使用的AOF文件,那么重启之后的数据库将是空白的,而空白数据库总是一致的。

无论Redis服务器运行在哪种持久化模式下,事务执行中途发生的停机都不会影响数据库的一致性。

隔离性

事务的隔离性指的是,即使数据库中有多个事务并发地执行,各个事务之间也不会互相影响,并且在并发状态下执行的事务和串行执行的事务产生的结果完全相同。

因为Redis使用单线程的方式来执行事务(以及事务队列中的命令),并且服务器保证,在执行事务期间不会对事务进行中断,因此,Redis的事务总是以串行的方式运行的,并且事务也总是具有隔离性的。

持久性

事务的耐久性指的是,当一个事务执行完毕时,执行这个事务所得的结果已经被保存到永久性存储介质(比如硬盘)里面了,即使服务器在事务执行完毕之后停机,执行事务所得的结果也不会丢失。

因为Redis的事务不过是简单地用队列包裹起了一组Redis命令,Redis并没有为事务提供任何额外的持久化功能,所以Redis事务的耐久性由Redis所使用的持久化模式决定:

  • 当服务器在无持久化的内存模式下运作时,事务不具有耐久性:一旦服务器停机,包括事务数据在内的所有服务器数据都将丢失。
  • 当服务器在RDB持久化模式下运作时,服务器只会在特定的保存条件被满足时,才会执行BGSAVE命令,对数据库进行保存操作,并且异步执行的BGSAVE不能保证事务数据被第一时间保存到硬盘里面,因此RDB持久化模式下的事务也不具有耐久性。
  • 当服务器运行在AOF持久化模式下,并且appendfsync选项的值为always时,程序总会在执行命令之后调用同步(sync)函数,将命令数据真正地保存到硬盘里面,因此这种配置下的事务是具有耐久性的。
  • 当服务器运行在AOF持久化模式下,并且appendfsync选项的值为everysec时,程序会每秒同步一次命令数据到硬盘。因为停机可能会恰好发生在等待同步的那一秒钟之内,这可能会造成事务数据丢失,所以这种配置下的事务不具有耐久性。
  • 当服务器运行在AOF持久化模式下,并且appendfsync选项的值为no时,程序会交由操作系统来决定何时将命令数据同步到硬盘。因为事务数据可能在等待同步的过程中丢失,所以这种配置下的事务不具有耐久性。

no-appendfsync-on-rewrite配置选项对耐久性的影响

配置选项no-appendfsync-on-rewrite可以配合appendfsync选项为always或者everysec的AOF持久化模式使用。当no-appendfsync-on-rewrite选项处于打开状态时, 在执行BGSAVE命令或者BGREWRITEAOF命令期间,服务器会暂时停止对AOF文件进行同步,从而尽可能地减少I/O阻塞。 但是这样一来,关于“always模式的AOF持久化可以保证事务的耐久性”这一结论将不再成立,因为在服务器停止对AOF文件进行同步期间,事务结果可能会因为停机而丢失。 因此,如果服务器打开了no-appendfsync-on-rewrite选项,那么即使服务器运行在always模式的AOF持久化之下,事务也不具有耐久性。 在默认配置下,no-appendfsync-on-rewrite处于关闭状态。

事务命令

Redis事务让一组命令在单个步骤执行。事务中有两个属性,说明如下:

  • 在一个事务中的所有命令按顺序执行作为单个隔离操作。通过另一个客户端发出的请求在Redis的事务的过程中执行,这是不可能的。
  • Redis的事务具有原子性。原子意味着要么所有的命令都执行或都不执行。
  • 有一条命令执行失败,剩余的命令还是会执行
redis 127.0.0.1:6379> MULTI
OK
redis 127.0.0.1:6379> SET tutorial redis
QUEUED
redis 127.0.0.1:6379> GET tutorial
QUEUED
redis 127.0.0.1:6379> INCR visitors
QUEUED
redis 127.0.0.1:6379> EXEC

1) OK
2) "redis"
3) (integer) 1
命令 作用
DISCARD 发出命令MULTI后丢弃所有
EXEC MULTI后执行发出所有命令
MULTI 标记事务块的开始
UNWATCH 取消所有的对应关注键
WATCH key [key …] 关注给定项,以确定执行MULTI/EXEC块

Lua脚本

  • Redis服务器在启动时,会对内嵌的Lua环境执行一系列修改操作,从而确保内嵌的Lua环境可以满足Redis在功能性、安全性等方面的需要。
  • Redis服务器专门使用一个伪客户端来执行Lua脚本中包含的Redis命令。
  • Redis使用脚本字典来保存所有被EVAL命令执行过,或者被SCRIPT LOAD命令载入过的Lua脚本,这些脚本可以用于实现SCRIPT EXISTS命令,以及实现脚本复制功能。
  • EVAL命令为客户端输入的脚本在Lua环境中定义一个函数,并通过调用这个函数来执行脚本。
  • EVALSHA命令通过直接调用Lua环境中已定义的函数来执行脚本。
  • SCRIPT FLUSH命令会清空服务器lua_scripts字典中保存的脚本,并重置Lua环境。
  • SCRIPT EXISTS命令接受一个或多个SHA1校验和为参数,并通过检查lua_scripts字典来确认校验和对应的脚本是否存在。
  • SCRIPT LOAD命令接受一个Lua脚本为参数,为该脚本在Lua环境中创建函数,并将脚本保存到lua_scripts字典中。
  • 服务器在执行脚本之前,会为Lua环境设置一个超时处理钩子,当脚本出现超时运行情况时,客户端可以通过向服务器发送SCRIPT KILL命令来让钩子停止正在执行的脚本,或者发送SHUTDOWN nosave命令来让钩子关闭整个服务器。
  • 主服务器复制EVAL、SCRIPT FLUSH、SCRIPT LOAD三个命令的方法和复制普通Redis命令一样,只要将相同的命令传播给从服务器就可以了。
  • 主服务器在复制EVALSHA命令时,必须确保所有从服务器都已经载入了EVALSHA命令指定的SHA1校验和所对应的Lua脚本,如果不能确保这一点的话,主服务器会将EVALSHA命令转换成等效的EVAL命令,并通过传播EVAL命令来获得相同的脚本执行效果。

Redis从2.6版本开始引入对Lua脚本的支持,通过在服务器中嵌入Lua环境,Redis客户端可以使用Lua脚本,直接在服务器端原子地执行多个Redis命令。

使用EVAL命令可以直接对输入的脚本进行求值:

redis> EVAL "return 'hello world'" 0
"hello world"

使用EVALSHA命令则可以根据脚本的SHA1校验和来对脚本进行求值,但这个命令要求校验和对应的脚本必须至少被EVAL命令执行过一次:

redis> EVAL "return 1+1" 0
(integer) 2
redis> EVALSHA "a27e7e8a43702b7046d4f6a7ccf5b60cef6b9bd9" 0 // 上一个脚本的校验和
integer) 2 

或者这个校验和对应的脚本曾经被SCRIPT LOAD命令载入过:

redis> SCRIPT LOAD "return 2*2"
"4475bfb5919b5ad16424cb50f74d4724ae833e72"
redis> EVALSHA "4475bfb5919b5ad16424cb50f74d4724ae833e72" 0
(integer) 4

如果脚本较长,使用reids-cli –eval参数直接执行文件。

eval命令执行Lua脚本过程

创建并修改Lua环境

为了在Redis服务器中执行Lua脚本,Redis在服务器内嵌了一个Lua环境(environ-ment),并对这个Lua环境进行了一系列修改,从而确保这个Lua环境可以满足Redis服务器的需要。

Redis服务器创建并修改Lua环境的整个过程由以下步骤组成:

  1. 创建一个基础的Lua环境,之后的所有修改都是针对这个环境进行的。
  2. 载入多个函数库到Lua环境里面,让Lua脚本可以使用这些函数库来进行数据操作。
  3. 创建全局表格redis,这个表格包含了对Redis进行操作的函数,比如用于在Lua脚本中执行Redis命令的redis.call函数。
  4. 使用Redis自制的随机函数来替换Lua原有的带有副作用的随机函数,从而避免在脚本中引入副作用。
  5. 创建排序辅助函数,Lua环境使用这个辅佐函数来对一部分Redis命令的结果进行排序,从而消除这些命令的不确定性。
  6. 创建redis.pcall函数的错误报告辅助函数,这个函数可以提供更详细的出错信息。
  7. 对Lua环境中的全局环境进行保护,防止用户在执行Lua脚本的过程中,将额外的全局变量添加到Lua环境中。
  8. 将完成修改的Lua环境保存到服务器状态的lua属性中,等待执行服务器传来的Lua脚本。

创建Lua环境

在最开始的这一步,服务器首先调用Lua的C API函数lua_open,创建一个新的Lua环境。

因为lua_open函数创建的只是一个基本的Lua环境,为了让这个Lua环境可以满足Redis的操作要求,接下来服务器将对这个Lua环境进行一系列修改。

载入数据库

Redis修改Lua环境的第一步,就是将以下函数库载入到Lua环境里面:

  • 基础库(base library)
    • 这个库包含Lua的核心(core)函数,比如assert、error、pairs、tostring、pcall等。另外,为了防止用户从外部文件中引入不安全的代码,库中的loadfile函数会被删除。
  • 表格库(table library)
    • 这个库包含用于处理表格的通用函数,比如table.concat、table.insert、table.remove、table.sort等。
  • 字符串库(string library)
    • 这个库包含用于处理字符串的通用函数,比如用于对字符串进行查找的string.find函数,对字符串进行格式化的string.format函数,查看字符串长度的string.len函数,对字符串进行翻转的string.reverse函数等。
  • 数学库(math library)
    • 这个库是标准C语言数学库的接口,它包括计算绝对值的math.abs函数,返回多个数中的最大值和最小值的math.max函数和math.min函数,计算二次方根的math.sqrt函数,计算对数的math.log函数等。
  • 调试库(debug library)
    • 这个库提供了对程序进行调试所需的函数,比如对程序设置钩子和取得钩子的debug.sethook函数和debug.gethook函数,返回给定函数相关信息的debug.getinfo函数,为对象设置元数据的debug.setmetatable函数,获取对象元数据的debug.getmetatable函数等。
  • Lua CJSON库
    • 这个库用于处理UTF-8编码的JSON格式,其中cjson.decode函数将一个JSON格式的字符串转换为一个Lua值,而cjson.encode函数将一个Lua值序列化为JSON格式的字符串。
  • Struct库
    • 这个库用于在Lua值和C结构(struct)之间进行转换,函数struct.pack将多个Lua值打包成一个类结构(struct-like)字符串,而函数struct.unpack则从一个类结构字符串中解包出多个Lua值。
  • Lua cmsgpack库
    • 这个库用于处理MessagePack格式的数据,其中cmsgpack.pack函数将Lua值转换为MessagePack数据,而cmsgpack.unpack函数则将MessagePack数据转换为Lua值。

通过使用这些功能强大的函数库,Lua脚本可以直接对执行Redis命令获得的数据进行复杂的操作。

创建redis全局表格

在这一步,服务器将在Lua环境中创建一个redis表格(table),并将它设为全局变量。这个redis表格包含以下函数:

  • 用于执行Redis命令的redis.call和redis.pcall函数。
    • redis.call和redis.pcall的不同在于,如果redis.call执行失败,那么脚本执行结束会直接返回错误,而redis.pcall会忽略错误继续执行脚本,所以在实际开发中要根据具体的应用场景进行函数的选择。
  • 用于记录Redis日志(log)的redis.log函数,以及相应的日志级别(level)常量:redis.LOG_DEBUG,redis.LOG_VERBOSE,redis.LOG_NOTICE,以及redis.LOG_WARNING。
    • 可以使用redis.log函数将Lua脚本的日志输出到Redis的日志文件中,但是一定要控制日志级别。
  • 用于计算SHA1校验和的redis.sha1hex函数。
  • 用于返回错误信息的redis.error_reply函数和redis.status_reply函数。

在这些函数里面,最常用也最重要的要数redis.call函数和redis.pcall函数,通过这两个函数,用户可以直接在Lua脚本中执行Redis命令:

redis> EVAL "return redis.call('PING')" 0
PONG

使用Redis自制的随机函数来替换Lua原有的

为了保证相同的脚本可以在不同的机器上产生相同的结果,Redis要求所有传入服务器的Lua脚本,以及Lua环境中的所有函数,都必须是无副作用(side effect)的纯函数(pure function)。 但是,在之前载入Lua环境的math函数库中,用于生成随机数的math.random函数和math.randomseed函数都是带有副作用的,它们不符合Redis对Lua环境的无副作用要求。

因为这个原因,Redis使用自制的函数替换了math库中原有的math.random函数和math.randomseed函数,替换之后的两个函数有以下特征:

  • 对于相同的seed来说,math.random总产生相同的随机数序列,这个函数是一个纯函数。
  • 除非在脚本中使用math.randomseed显式地修改seed,否则每次运行脚本时,Lua环境都使用固定的math.randomseed(0)语句来初始化seed。
-- 无论执行这个脚本多少次,产生的值都是相同的
-- 除非调用math.randomseed函数设置不同的seed
-- random-with-default-seed.lua
local i = 10
local seq = {}
while (i > 0) do
    seq[i] = math.random(i)
    i = i-1
end
return seq

创建排序辅助函数

对于Lua脚本来说,另一个可能产生不一致数据的地方是那些带有不确定性质的命令。比如对于一个集合键来说,因为集合元素的排列是无序的,所以即使两个集合的元素完全相同,它们的输出结果也可能并不相同。

Redis将SMEMBERS这种在相同数据集上可能会产生不同输出的命令称为“带有不确定性的命令”,这些命令包括:

  • SINTER
  • SUNION
  • SDIFF
  • SMEMBERS
  • HKEYS
  • HVALS
  • KEYS

为了消除这些命令带来的不确定性,服务器会为Lua环境创建一个排序辅助函数__redis__compare_helper,当Lua脚本执行完一个带有不确定性的命令之后, 程序会使用__redis__compare_helper作为对比函数,自动调用table.sort函数对命令的返回值做一次排序,以此来保证相同的数据集总是产生相同的输出。

如果我们在Lua脚本中对fruit集合和another-fruit集合执行SMEMBERS命令,那么两个脚本将得出相同的结果,因为脚本已经对SMEMBERS命令的输出进行过排序了:

redis> EVAL "return redis.call('SMEMBERS', KEYS[1])" 1 fruit
1) "apple"
2) "banana"
3) "cherry"
redis> EVAL "return redis.call('SMEMBERS', KEYS[1])" 1 another-fruit
1) "apple"
2) "banana"
3) "cherry"

创建redis.pcall函数的错误报告辅助函数

在这一步,服务器将为Lua环境创建一个名为__redis__err__handler的错误处理函数,当脚本调用redis.pcall函数执行Redis命令, 并且被执行的命令出现错误时,__redis__err__handler就会打印出错代码的来源和发生错误的行数,为程序的调试提供方便。

保护Lua的全局环境

在这一步,服务器将对Lua环境中的全局环境进行保护,确保传入服务器的脚本不会因为忘记使用local关键字而将额外的全局变量添加到Lua环境里面。

  • 因为全局变量保护的原因,当一个脚本试图创建一个全局变量时,服务器将报告一个错误
  • 试图获取一个不存在的全局变量也会引发一个错误
  • 不过Redis并未禁止用户修改已存在的全局变量,所以在执行Lua脚本的时候,必须非常小心,以免错误地修改了已存在的全局变量
# 创建
(error) ERR Error running script
(call to f_df1ad3745c2d2f078f0f41377a92bb6f8ac79af0):
@enable_strict_lua:7: user_script:1:
Script attempted to create global variable 'x'
# 读取不存在的变量
redis> EVAL "return x" 0
(error) ERR Error running script
(call to f_03c387736bb5cc009ff35151572cee04677aa374):
@enable_strict_lua:14: user_script:1:
Script attempted to access unexisting global variable 'x'
# 修改
redis> EVAL "redis = 10086; return redis" 0
(integer) 10086

将Lua环境保存到服务器状态的lua属性里

经过以上的一系列修改,Redis服务器对Lua环境的修改工作到此就结束了,在最后的这一步,服务器会将Lua环境和服务器状态的lua属性关联起来。

服务器状态中的Lua环境

因为Redis使用串行化的方式来执行Redis命令,所以在任何特定时间里,最多都只会有一个脚本能够被放进Lua环境里面运行,因此,整个Redis服务器只需要创建一个Lua环境即可。

Lua环境协作组件

除了创建并修改Lua环境之外,Redis服务器还创建了两个用于与Lua环境进行协作的组件,它们分别是负责执行Lua脚本中的Redis命令的伪客户端,以及用于保存Lua脚本的lua_scripts字典。

伪客户端

因为执行Redis命令必须有相应的客户端状态,所以为了执行Lua脚本中包含的Redis命令,Redis服务器专门为Lua环境创建了一个伪客户端,并由这个伪客户端负责处理Lua脚本中包含的所有Redis命令。

Lua脚本使用redis.call函数或者redis.pcall函数执行一个Redis命令,需要完成以下步骤:

  1. Lua环境将redis.call函数或者redis.pcall函数想要执行的命令传给伪客户端。
  2. 伪客户端将脚本想要执行的命令传给命令执行器。
  3. 命令执行器执行伪客户端传给它的命令,并将命令的执行结果返回给伪客户端。
  4. 伪客户端接收命令执行器返回的命令结果,并将这个命令结果返回给Lua环境。
  5. Lua环境在接收到命令结果之后,将该结果返回给redis.call函数或者redis.pcall函数。
  6. 接收到结果的redis.call函数或者redis.pcall函数会将命令结果作为函数返回值返回给脚本中的调用者。

Lua脚本执行Redis命令时的通信步骤

lua_script字典

除了伪客户端之外,Redis服务器为Lua环境创建的另一个协作组件是lua_scripts字典,这个字典的键为某个Lua脚本的SHA1校验和(checksum),而字典的值则是SHA1校验和对应的Lua脚本:

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

Redis服务器会将所有被EVAL命令执行过的Lua脚本,以及所有被SCRIPT LOAD命令载入过的Lua脚本都保存到lua_scripts字典里面。

如果客户端向服务器发送以下命令:

redis> SCRIPT LOAD "return 'hi'"
"2f31ba2bb6d6a0f42cc159d2e2dad55440778de3"
redis> SCRIPT LOAD "return 1+1"
"a27e7e8a43702b7046d4f6a7ccf5b60cef6b9bd9"
redis> SCRIPT LOAD "return 2*2"
"4475bfb5919b5ad16424cb50f74d4724ae833e72"

那么服务器的lua_scripts字典将包含被SCRIPT LOAD命令载入的三个Lua脚本。

lua_scripts字典示例

lua_scripts字典有两个作用,一个是实现SCRIPT EXISTS命令,另一个是实现脚本复制功能。

EVAL命令的实现

EVAL命令的执行过程可以分为以下三个步骤:

  1. 根据客户端给定的Lua脚本,在Lua环境中定义一个Lua函数。
  2. 将客户端给定的脚本保存到lua_scripts字典,等待将来进一步使用。
  3. 执行刚刚在Lua环境中定义的函数,以此来执行客户端给定的Lua脚本。
redis> EVAL "return 'hello world'" 0
"hello world"

定义脚本函数

当客户端向服务器发送EVAL命令,要求执行某个Lua脚本的时候,服务器首先要做的就是在Lua环境中,为传入的脚本定义一个与这个脚本相对应的Lua函数, 其中,Lua函数的名字由f_前缀加上脚本的SHA1校验和(四十个字符长)组成,而函数的体(body)则是脚本本身。

EVAL "return 'hello world'" 0
function f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91()
    return 'hello world'
end

使用函数来保存客户端传入的脚本有以下好处:

  • 执行脚本的步骤非常简单,只要调用与脚本相对应的函数即可。
  • 通过函数的局部性来让Lua环境保持清洁,减少了垃圾回收的工作量,并且避免了使用全局变量。
  • 如果某个脚本所对应的函数在Lua环境中被定义过至少一次,那么只要记得这个脚本的SHA1校验和,服务器就可以在不知道脚本本身的情况下,直接通过调用Lua函数来执行脚本,这是EVALSHA命令的实现原理。

将脚本保存到lua_scripts字典

EVAL命令要做的第二件事是将客户端传入的脚本保存到服务器的lua_scripts字典里面。

执行脚本函数

在为脚本定义函数,并且将脚本保存到lua_scripts字典之后,服务器还需要进行一些设置钩子、传入参数之类的准备动作,才能正式开始执行脚本。

整个准备和执行脚本的过程:

  1. 将EVAL命令中传入的键名(key name)参数和脚本参数分别保存到KEYS数组和ARGV数组,然后将这两个数组作为全局变量传入到Lua环境里面。
  2. 为Lua环境装载超时处理钩子(hook),这个钩子可以在脚本出现超时运行情况时,让客户端通过SCRIPT KILL命令停止脚本,或者通过SHUTDOWN命令直接关闭服务器。
  3. 执行脚本函数。
  4. 移除之前装载的超时钩子。
  5. 将执行脚本函数所得的结果保存到客户端状态的输出缓冲区里面,等待服务器将结果返回给客户端。
  6. 对Lua环境执行垃圾回收操作。

EVALSHA命令的实现

只要脚本对应的函数曾经在Lua环境里面定义过,那么即使不知道脚本的内容本身,客户端也可以根据脚本的SHA1校验和来调用脚本对应的函数,从而达到执行脚本的目的,这就是EVALSHA命令的实现原理。

redis> EVALSHA "5332031c6b470dc5a0dd9b4bf2030dea6d65de91" 0
"hello world"

脚本管理命令的实现

除了EVAL命令和EVALSHA命令之外,Redis中与Lua脚本有关的命令还有四个,它们分别是SCRIPT FLUSH命令、SCRIPT EXISTS命令、SCRIPT LOAD命令、以及SCRIPT KILL命令。

SCRIPT FLUSH

SCRIPT FLUSH命令用于清除服务器中所有和Lua脚本有关的信息,这个命令会释放并重建lua_scripts字典,关闭现有的Lua环境并重新创建一个新的Lua环境。

SCRIPT EXISTS

SCRIPT EXISTS命令根据输入的SHA1校验和,检查校验和对应的脚本是否存在于服务器中。

SCRIPT EXISTS命令是通过检查给定的校验和是否存在于lua_scripts字典来实现的。

SCRIPT EXISTS命令允许一次传入多个SHA1校验和。

SCRIPT LOAD

SCRIPT LOAD命令所做的事情和EVAL命令执行脚本时所做的前两步完全一样:命令首先在Lua环境中为脚本创建相对应的函数,然后再将脚本保存到lua_scripts字典里面。

SCRIPT KILL

如果服务器设置了lua-time-limit配置选项,那么在每次执行Lua脚本之前,服务器都会在Lua环境里面设置一个超时处理钩子(hook)。

超时处理钩子在脚本运行期间,会定期检查脚本已经运行了多长时间,一旦钩子发现脚本的运行时间已经超过了lua-time-limit选项设置的时长, 钩子将定期在脚本运行的间隙中,查看是否有SCRIPT KILL命令或者SHUTDOWN命令到达服务器。

带有超时处理钩子的脚本的执行过程

如果超时运行的脚本未执行过任何写入操作,那么客户端可以通过SCRIPT KILL命令来指示服务器停止执行这个脚本,并向执行该脚本的客户端发送一个错误回复。处理完SCRIPT KILL命令之后,服务器可以继续运行。
另一方面,如果脚本已经执行过写入操作,那么客户端只能用SHUTDOWN nosave命令来停止服务器,从而防止不合法的数据被写入数据库中。

脚本复制

与其他普通Redis命令一样,当服务器运行在复制模式之下时,具有写性质的脚本命令也会被复制到从服务器,这些命令包括EVAL命令、EVALSHA命令、SCRIPT FLUSH命令,以及SCRIPT LOAD命令。

复制EVAL命令、SCRIPT FLUSH命令和SCRIPT LOAD命令

Redis复制EVAL、SCRIPT FLUSH、SCRIPT LOAD三个命令的方法和复制其他普通Redis命令的方法一样,当主服务器执行完以上三个命令的其中一个时,主服务器会直接将被执行的命令传播(propagate)给所有从服务器

将脚本命令传播给从服务器

复制EVALSHA命令

EVALSHA命令是所有与Lua脚本有关的命令中,复制操作最复杂的一个,因为主服务器与从服务器载入Lua脚本的情况可能有所不同, 所以主服务器不能像复制EVAL命令、SCRIPT LOAD命令或者SCRIPT FLUSH命令那样,直接将EVALSHA命令传播给从服务器。

对于一个在主服务器被成功执行的EVALSHA命令来说,相同的EVALSHA命令在从服务器执行时却可能会出现脚本未找到(not found)错误。

更为复杂的是,因为多个从服务器之间载入Lua脚本的情况也可能各有不同,所以即使一个EVALSHA命令可以在某个从服务器成功执行,也不代表这个EVALSHA命令就一定可以在另一个从服务器成功执行。

因为不确定从服务器从何时开始复制了主服务器,不能确定有执行过脚本。

为了防止以上假设的情况出现,Redis要求主服务器在传播EVALSHA命令的时候,必须确保EVALSHA命令要执行的脚本已经被所有从服务器载入过, 如果不能确保这一点的话,主服务器会将EVALSHA命令转换成一个等价的EVAL命令,然后通过传播EVAL命令来代替EVALSHA命令。

传播EVALSHA命令,或者将EVALSHA命令转换成EVAL命令,都需要用到服务器状态的lua_scripts字典和repl_scriptcache_dict字典。

判断传播EVALSHA命令是否安全的方法

主服务器使用服务器状态的repl_scriptcache_dict字典记录自己已经将哪些脚本传播给了所有从服务器:

struct redisServer {
    // ...
    dict *repl_scriptcache_dict;
    // ...
};
  • repl_scriptcache_dict字典的键是一个个Lua脚本的SHA1校验和,而字典的值则全部都是NULL,当一个校验和出现在repl_scriptcache_dict字典时,说明这个校验和对应的Lua脚本已经传播给了所有从服务器,主服务器可以直接向从服务器传播包含这个SHA1校验和的EVALSHA命令,而不必担心从服务器会出现脚本未找到错误。
  • 如果一个脚本的SHA1校验和存在于lua_scripts字典,但是却不存在于repl_scriptcache_dict字典,那么说明校验和对应的Lua脚本已经被主服务器载入,但是并没有传播给所有从服务器,如果我们尝试向从服务器传播包含这个SHA1校验和的EVALSHA命令,那么至少有一个从服务器会出现脚本未找到错误。
清空repl_scriptcache_dict字典

每当主服务器添加一个新的从服务器时,主服务器都会清空自己的repl_scriptcache_dict字典,这是因为随着新从服务器的出现,repl_scriptcache_dict字典里面记录的脚本已经不再被所有从服务器载入过,所以主服务器会清空repl_scriptcache_dict字典,强制自己重新向所有从服务器传播脚本,从而确保新的从服务器不会出现脚本未找到错误。

EVALSHA命令转换成EVAL命令的方法

通过使用EVALSHA命令指定的SHA1校验和,以及lua_scripts字典保存的Lua脚本,服务器总可以将一个EVALSHA命令:

EVALSHA <sha1> <numkeys> [key ...] [arg ...]

转换成一个等价的EVAL命令:

EVAL <script> <numkeys> [key ...] [arg ...]

具体的转换方法如下:

  1. 根据SHA1校验和sha1,在lua_scripts字典中查找sha1对应的Lua脚本script。
  2. 将原来的EVALSHA命令请求改写成EVAL命令请求,并且将校验和sha1改成脚本script,至于numkeys、key、arg等参数则保持不变。

如果一个SHA1值所对应的Lua脚本没有被所有从服务器载入过,那么主服务器可以将EVALSHA命令转换成等价的EVAL命令,然后通过传播等价的EVAL命令来代替原本想要传播的EVALSHA命令,以此来产生相同的脚本执行效果,并确保所有从服务器都不会出现脚本未找到错误。

另外,因为主服务器在传播完EVAL命令之后,会将被传播脚本的SHA1校验和(也即是原本EVALSHA命令指定的那个校验和)添加到repl_scriptcache_dict字典里面,如果之后EVALSHA命令再次指定这个SHA1校验和,主服务器就可以直接传播EVALSHA命令,而不必再次对EVALSHA命令进行转换。

传播EVALSHA命令的方法

当主服务器成功在本机执行完一个EVALSHA命令之后,它将根据EVALSHA命令指定的SHA1校验和是否存在于repl_scriptcache_dict字典来决定是向从服务器传播EVALSHA命令还是EVAL命令:

  • 如果EVALSHA命令指定的SHA1校验和存在于repl_scriptcache_dict字典,那么主服务器直接向从服务器传播EVALSHA命令。
  • 如果EVALSHA命令指定的SHA1校验和不存在于repl_scriptcache_dict字典,那么主服务器会将EVALSHA命令转换成等价的EVAL命令,然后传播这个等价的EVAL命令,并将EVALSHA命令指定的SHA1校验和添加到repl_scriptcache_dict字典里面。

主服务器判断传播EVAL还是EVALSHA的过程

优势与案例

Lua脚本功能为Redis开发和运维人员带来如下三个好处:

  • Lua脚本在Redis中是原子执行的,执行过程中间不会插入其他命令。
  • Lua脚本可以帮助开发和运维人员创造出自己定制的命令,并可以将这些命令常驻在Redis内存中,实现复用的效果。
  • Lua脚本可以将多条命令一次性打包,有效地减少网络开销。
-- lrange_and_mincr.lua
for index,key in ipairs(mylist)
do
    redis.call("incr",key)
    count = count + 1
end
return count
-- 为列表用户所有键+1
redis-cli --eval lrange_and_mincr.lua hot:user:list

脚本命令

Redis脚本使用Lua解释器用于计算脚本。它Redis从2.6.0版本开始内置。使用脚本eval命令。

redis 127.0.0.1:6379> EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second

1) "key1"
2) "key2"
3) "first"
4) "second"
命令 作用
EVAL script numkeys key [key …] arg [arg …] 执行一个Lua脚本。
EVALSHA sha1 numkeys key [key …] arg [arg …] 执行一个Lua脚本。
SCRIPT EXISTS script [script …] 检查脚本是否存在于缓存中。
SCRIPT FLUSH 删除脚本缓存中的所有脚本。
SCRIPT KILL 终止目前在执行的脚本。
SCRIPT LOAD script 加载指定的Lua脚本到脚本缓存。

排序

  • SORT命令通过将被排序键包含的元素载入到数组里面,然后对数组进行排序来完成对键进行排序的工作。
  • 在默认情况下,SORT命令假设被排序键包含的都是数字值,并且以数字值的方式来进行排序。
  • 如果SORT命令使用了ALPHA选项,那么SORT命令假设被排序键包含的都是字符串值,并且以字符串的方式来进行排序。
  • SORT命令的排序操作由快速排序算法实现。
  • SORT命令会根据用户是否使用了DESC选项来决定是使用升序对比还是降序对比来比较被排序的元素,升序对比会产生升序排序结果,被排序的元素按值的大小从小到大排列,降序对比会产生降序排序结果,被排序的元素按值的大小从大到小排列。
  • 当SORT命令使用了BY选项时,命令使用其他键的值作为权重来进行排序操作。
  • 当SORT命令使用了LIMIT选项时,命令只保留排序结果集中LIMIT选项指定的元素。
  • 当SORT命令使用了GET选项时,命令会根据排序结果集中的元素,以及GET选项给定的模式,查找并返回其他键的值,而不是返回被排序的元素。
  • 当SORT命令使用了STORE选项时,命令会将排序结果集保存在指定的键里面。
  • 当SORT命令同时使用多个选项时,命令先执行排序操作(可用的选项为ALPHA、ASC或DESC、BY),然后执行LIMIT选项,之后执行GET选项,再之后执行STORE选项,最后才将排序结果集返回给客户端。
  • 除了GET选项之外,调整选项的摆放位置不会影响SORT命令的排序结果。

Redis的SORT命令可以对列表键、集合键或者有序集合键的值进行排序。

SORT <key>命令的实现

SORT命令的最简单执行形式为:

SORT <key>

这个命令可以对一个包含数字值的键key进行排序。

redis> RPUSH numbers 3 1 2
(integer) 3
redis> SORT numbers
1) "1"
2) "2"
3) "3"

服务器执行SORT numbers命令的详细步骤如下:

  1. 创建一个和numbers列表长度相同的数组,该数组的每个项都是一个redis.h/redisSortObject结构,如图21-1所示。
  2. 遍历数组,将各个数组项的obj指针分别指向numbers列表的各个项,构成obj指针和列表项之间的一对一关系,如图21-2所示。
  3. 遍历数组,将各个obj指针所指向的列表项转换成一个double类型的浮点数,并将这个浮点数保存在相应数组项的u.score属性里面,如图21-3所示。
  4. 根据数组项u.score属性的值,对数组进行数字值排序,排序后的数组项按u.score属性的值从小到大排列,如图21-4所示。
  5. 遍历数组,将各个数组项的obj指针所指向的列表项作为排序结果返回给客户端,程序首先访问数组的索引0,返回u.score值为1.0的列表项”1”;然后访问数组的索引1,返回u.score值为2.0的列表项”2”;最后访问数组的索引2,返回u.score值为3.0的列表项”3”。

命令为排序numbers列表而创建的数组:

命令为排序numbers列表而创建的数组

redisSortObject结构的完整定义:

typedef struct _redisSortObject {
    // 被排序键的值
    robj *obj;
    // 权重
    union {
        // 排序数字值时使用
        double score;
        // 排序带有BY选项的字符串值时使用
        robj *cmpobj;
    } u;
} redisSortObject;

将obj指针指向列表的各个项:

将obj指针指向列表的各个项

设置数组项的u.score属性:

设置数组项的u.score属性

排序后的数组:

排序后的数组

SORT命令为每个被排序的键都创建一个与键长度相同的数组,数组的每个项都是一个redisSortObject结构,根据SORT命令使用的选项不同,程序使用redisSortObject结构的方式也不同。

ALPHA选项的实现

通过使用ALPHA选项,SORT命令可以对包含字符串值的键进行排序:

SORT <key> ALPHA
redis> SADD fruits apple banana cherry
(integer) 3
# 元素在集合中是乱序存放的
redis> SMEMBERS fruits
1) "apple"
2) "cherry"
3) "banana"
# 对fruits键进行字符串排序
redis> SORT fruits ALPHA
1) "apple"
2) "banana"
3) "cherry"

ASC选项和DESC选项的实现

在默认情况下,SORT命令执行升序排序,排序后的结果按值的大小从小到大排列,以下两个命令是完全等价的:

SORT <key>
SORT <key> ASC

从大到小:

SORT <key> DESC

升序排序和降序排序都由相同的快速排序算法执行,它们之间的不同之处在于:

  • 在执行升序排序时,排序算法使用的对比函数产生升序对比结果。
  • 而在执行降序排序时,排序算法所使用的对比函数产生降序对比结果。

BY选项的实现

在默认情况下,SORT命令使用被排序键包含的元素作为排序的权重,元素本身决定了元素在排序之后所处的位置。

通过使用BY选项,SORT命令可以指定某些字符串键,或者某个哈希键所包含的某些域(field)来作为元素的权重,对一个键进行排序。

redis> SADD fruits "apple" "banana" "cherry"
(integer) 3
redis> SORT fruits ALPHA
1) "apple"
2) "banana"
3) "cherry"

redis> MSET apple-price 8 banana-price 5.5 cherry-price 7
OK
redis> SORT fruits BY *-price
1) "banana"
2) "cherry"
3) "apple"
  1. 创建一个redisSortObject结构数组,数组的长度等于fruits集合的大小。
  2. 遍历数组,将各个数组项的obj指针分别指向fruits集合的各个元素.
  3. 遍历数组,根据各个数组项的obj指针所指向的集合元素,以及BY选项所给定的模式*-price,查找相应的权重键.
  4. 将各个权重键的值转换成一个double类型的浮点数,然后保存在相应数组项的u.score属性里面.
  5. 以数组项u.score属性的值为权重,对数组进行排序,得到一个按u.score属性的值从小到大排序的数组.
  6. 遍历数组,依次将数组项的obj指针所指向的集合元素返回给客户端。

带有ALPHA选项的BY选项的实现

BY选项默认假设权重键保存的值为数字值,如果权重键保存的是字符串值的话,那么就需要在使用BY选项的同时,配合使用ALPHA选项。

  1. 创建一个redisSortObject结构数组,数组的长度等于fruits集合的大小。
  2. 遍历数组,将各个数组项的obj指针分别指向fruits集合的各个元素。
  3. 遍历数组,根据各个数组项的obj指针所指向的集合元素,以及BY选项所给定的模式*-id,查找相应的权重键。
  4. 将各个数组项的u.cmpobj指针分别指向相应的权重键(一个字符串对象)。
  5. 以各个数组项的权重键的值为权重,对数组执行字符串排序。
  6. 遍历数组,依次将数组项的obj指针所指向的集合元素返回给客户端。

LIMIT选项的实现

在默认情况下,SORT命令总会将排序后的所有元素都返回给客户端。通过LIMIT选项,我们可以让SORT命令只返回其中一部分已排序的元素。

LIMIT选项的格式为LIMIT <offset> <count>

  • offset参数表示要跳过的已排序元素数量。
  • count参数表示跳过给定数量的已排序元素之后,要返回的已排序元素数量。
  1. 创建一个redisSortObject结构数组,数组的长度等于alphabet集合的大小。
  2. 遍历数组,将各个数组项的obj指针分别指向alphabet集合的各个元素。
  3. 根据obj指针所指向的集合元素,对数组进行字符串排序。
  4. 根据选项LIMIT 2 3,将指针移动到数组的索引2上面,然后依次访问array[2]、array[3]、array[4]这3个数组项,并将数组项的obj指针所指向的元素”c”、”d”、”e”返回给客户端。

GET选项的实现

在默认情况下,SORT命令在对键进行排序之后,总是返回被排序键本身所包含的元素。 通过使用GET选项,我们可以让SORT命令在对键进行排序之后,根据被排序的元素,以及GET选项所指定的模式,查找并返回某些键的值。

# 设置peter、jack、tom的全名
redis> SET peter-name "Peter White"
OK
redis> SET jack-name "Jack Snow"
OK
redis> SET tom-name "Tom Smith"
OK
# SORT命令首先对students集合进行排序,得到排序结果
# 1) "jack"
# 2) "peter"
# 3) "tom"
# 然后根据这些结果,获取并返回键jack-name、peter-name和tom-name的值
redis> SORT students ALPHA GET *-name
1) "Jack Snow"
2) "Peter White"
3) "Tom Smith"

因为一个SORT命令可以带有多个GET选项,所以随着GET选项的增多,命令要执行的查找操作也会增多。

redis> SORT students ALPHA GET *-name GET *-birth

STORE选项的实现

在默认情况下,SORT命令只向客户端返回排序结果,而不保存排序结果。通过使用STORE选项,我们可以将排序结果保存在指定的键里面,并在有需要时重用这个排序结果。

redis> SADD students "peter" "jack" "tom"
(integer) 3
redis> SORT students ALPHA
1) "jack"
2) "peter"
3) "tom"

redis> SORT students ALPHA STORE sorted_students
(integer) 3
redis> LRANGE sorted_students 0-1
1) "jack"
2) "peter"
3) "tom"
  1. 创建一个redisSortObject结构数组,数组的长度等于students集合的大小。
  2. 遍历数组,将各个数组项的obj指针分别指向students集合的各个元素。
  3. 根据obj指针所指向的集合元素,对数组进行字符串排序。
  4. 检查sorted_students键是否存在,如果存在的话,那么删除该键。
  5. 设置sorted_students为空白的列表键。
  6. 遍历数组,将排序后的三个元素”jack”、”peter”和”tom”依次推入sorted_students列表的末尾,相当于执行命令RPUSH sorted_students”jack”、”peter”、”tom”。
  7. 遍历数组,向客户端返回”jack”、”peter”、”tom”三个元素。

多个选项的执行顺序

一个SORT命令请求通常会用到多个选项,而这些选项的执行顺序是有先后之分的。

选项的执行顺序

如果按照选项来划分的话,一个SORT命令的执行过程可以分为以下四步:

  1. 排序:在这一步,命令会使用ALPHA、ASC或DESC、BY这几个选项,对输入键进行排序,并得到一个排序结果集。
  2. 限制排序结果集的长度:在这一步,命令会使用LIMIT选项,对排序结果集的长度进行限制,只有LIMIT选项指定的那部分元素会被保留在排序结果集中。
  3. 获取外部键:在这一步,命令会使用GET选项,根据排序结果集中的元素,以及GET选项指定的模式,查找并获取指定键的值,并用这些值来作为新的排序结果集。
  4. 保存排序结果集:在这一步,命令会使用STORE选项,将排序结果集保存到指定的键上面去。
  5. 向客户端返回排序结果集:在最后这一步,命令遍历排序结果集,并依次向客户端返回排序结果集中的元素。

在以上这些步骤中,后一个步骤必须在前一个步骤完成之后进行。

选项的摆放顺序

调用SORT命令时,除了GET选项之外,改变选项的摆放顺序并不会影响SORT命令执行这些选项的顺序。

因此在调整SORT命令各个选项的摆放顺序时,必须小心处理GET选项。

消息发布与订阅

  • 服务器状态在pubsub_channels字典保存了所有频道的订阅关系:SUBSCRIBE命令负责将客户端和被订阅的频道关联到这个字典里面,而UNSUBSCRIBE命令则负责解除客户端和被退订频道之间的关联。
  • 服务器状态在pubsub_patterns链表保存了所有模式的订阅关系:PSUBSCRIBE命令负责将客户端和被订阅的模式记录到这个链表中,而PUNSUBSCRIBE命令则负责移除客户端和被退订模式在链表中的记录。
  • PUBLISH命令通过访问pubsub_channels字典来向频道的所有订阅者发送消息,通过访问pubsub_patterns链表来向所有匹配频道的模式的订阅者发送消息。
  • PUBSUB命令的三个子命令都是通过读取pubsub_channels字典和pubsub_patterns链表中的信息来实现的。

通过执行SUBSCRIBE命令,客户端可以订阅一个或多个频道,从而成为这些频道的订阅者(subscriber): 每当有其他客户端向被订阅的频道发送消息(message)时,频道的所有订阅者都会收到这条消息。

除了订阅频道之外,客户端还可以通过执行PSUBSCRIBE命令订阅一个或多个模式,从而成为这些模式的订阅者: 每当有其他客户端向某个频道发送消息时,消息不仅会被发送给这个频道的所有订阅者,它还会被发送给所有与这个频道相匹配的模式的订阅者。

将消息发送给频道的订阅者和匹配模式的订阅者

频道的订阅与退订

当一个客户端执行SUBSCRIBE命令订阅某个或某些频道的时候,这个客户端与被订阅频道之间就建立起了一种订阅关系。

Redis将所有频道的订阅关系都保存在服务器状态的pubsub_channels字典里面,这个字典的键是某个被订阅的频道,而键的值则是一个链表,链表里面记录了所有订阅这个频道的客户端:

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

一个pubsub_channels字典示例:

一个pubsub_channels字典示例

SUBSCRIBE "news.sport" "news.movie"
UNSUBSCRIBE "news.sport" "news.movie"

执行SUBSCRIBE之后的pubsub_channels字典

有关订阅命令有两点需要注意:

  • 客户端在执行订阅命令之后进入了订阅状态,只能接收subscribe、psubscribe、unsubscribe、punsubscribe的四个命令。
  • 新开启的订阅客户端,无法收到该频道之前的消息,因为Redis不会对发布的消息进行持久化。

模式的订阅与退订

服务器将所有模式的订阅关系都保存在服务器状态的pubsub_patterns属性里面:

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

pubsub_patterns属性是一个链表,链表中的每个节点都包含着一个pubsub Pattern结构,这个结构的pattern属性记录了被订阅的模式,而client属性则记录了订阅模式的客户端:

typedef struct pubsubPattern {
    // 订阅模式的客户端
    redisClient *client;
    // 被订阅的模式
    robj *pattern;
} pubsubPattern;

pubsub_patterns链表示例:

!pubsub_patterns链表示例

每当客户端执行PSUBSCRIBE命令订阅某个或某些模式的时候,服务器会对每个被订阅的模式执行以下两个操作:

  1. 新建一个pubsubPattern结构,将结构的pattern属性设置为被订阅的模式,client属性设置为订阅模式的客户端。
  2. 将pubsubPattern结构添加到pubsub_patterns链表的表尾。
PSUBSCRIBE "news.*"
PUNSUBSCRIBE "news.*"

发送消息

当一个Redis客户端执行PUBLISH<channel><message>命令将消息message发送给频道channel的时候,服务器需要执行以下两个动作:

  1. 将消息message发送给channel频道的所有订阅者。
  2. 如果有一个或多个模式pattern与频道channel相匹配,那么将消息message发送给pattern模式的订阅者。
  • 将消息发送给频道订阅者
    • 因为服务器状态中的pubsub_channels字典记录了所有频道的订阅关系,所以为了将消息发送给channel频道的所有订阅者,PUBLISH命令要做的就是在pubsub_channels字典里找到频道channel的订阅者名单(一个链表),然后将消息发送给名单上的所有客户端。
  • 将消息发送给模式订阅者
    • 因为服务器状态中的pubsub_patterns链表记录了所有模式的订阅关系,所以为了将消息发送给所有与channel频道相匹配的模式的订阅者,PUBLISH命令要做的就是遍历整个pubsub_patterns链表,查找那些与channel频道相匹配的模式,并将消息发送给订阅了这些模式的客户端。

查看订阅信息

PUBSUB命令是Redis 2.8新增加的命令之一,客户端可以通过这个命令来查看频道或者模式的相关信息。

PUBSUB CHANNELS

PUBSUB CHANNELS[pattern]子命令用于返回服务器当前被订阅的频道,其中pattern参数是可选的:

  • 如果不给定pattern参数,那么命令返回服务器当前被订阅的所有频道。
  • 如果给定pattern参数,那么命令返回服务器当前被订阅的频道中那些与pattern模式相匹配的频道。

这个子命令是通过遍历服务器pubsub_channels字典的所有键(每个键都是一个被订阅的频道),然后记录并返回所有符合条件的频道来实现的。

PUBSUB NUMSUB

PUBSUB NUMSUB[channel-1 channel-2…channel-n]子命令接受任意多个频道作为输入参数,并返回这些频道的订阅者数量。

这个子命令是通过在pubsub_channels字典中找到频道对应的订阅者链表,然后返回订阅者链表的长度来实现的(订阅者链表的长度就是频道订阅者的数量)。

PUBSUB NUMPAT

PUBSUB NUMPAT子命令用于返回服务器当前被订阅模式的数量。

这个子命令是通过返回pubsub_patterns链表的长度来实现的,因为这个链表的长度就是服务器被订阅模式的数量。

使用场景

和很多专业的消息队列系统(例如Kafka、RocketMQ)相比,Redis的发布订阅略显粗糙,例如无法实现消息堆积和回溯。但胜在足够简单,如果当前场景可以容忍的这些缺点,也不失为一个不错的选择。

发布与订阅命令

命令 说明
PSUBSCRIBE pattern [pattern …] 订阅一个或多个符合给定模式的频道。
PUBSUB subcommand [argument [argument …]] 查看订阅与发布系统状态。
PUBLISH channel message 将信息发送到指定的频道。
PUNSUBSCRIBE [pattern [pattern …]] 退订所有给定模式的频道。
SUBSCRIBE channel [channel …] 订阅给定的一个或多个频道的信息。
UNSUBSCRIBE [channel [channel …]] 退订给定的频道。

二进制位数组Bitmaps

  • Redis使用SDS来保存位数组。
  • SDS使用逆序来保存位数组,这种保存顺序简化了SETBIT命令的实现,使得SETBIT命令可以在不移动现有二进制位的情况下,对位数组进行空间扩展。
  • BITCOUNT命令使用了查表算法和variable-precision SWAR算法来优化命令的执行效率。
  • BITOP命令的所有操作都使用C语言内置的位操作来实现。

Redis提供了SETBIT、GETBIT、BITCOUNT、BITOP四个命令用于处理二进制位数组(bit array,又称“位数组”)。

其中,SETBIT命令用于为位数组指定偏移量上的二进制位设置值,位数组的偏移量从0开始计数,而二进制位的值则可以是0或者1。
而GETBIT命令则用于获取位数组指定偏移量上的二进制位的值。
BITCOUNT命令用于统计位数组里面,值为1的二进制位的数量。
最后,BITOP命令既可以对多个位数组进行按位与(and)、按位或(or)、按位异或(xor)运算,也可以对给定的位数组进行取反(not)运算。

redis> SETBIT bit 0 1       # 0000 0001 
(integer) 0     
redis> SETBIT bit 3 1       # 0000 1001 
(integer) 0     
redis> SETBIT bit 0 0       # 0000 1000 
(integer) 1

redis> GETBIT bit 0         # 0000 1000
(integer) 0
redis> GETBIT bit 3         # 0000 1000
(integer) 1

redis> BITCOUNT bit         # 0000 1000
(integer) 1
redis> SETBIT bit 0 1       # 0000 1001
(integer) 0
redis> BITCOUNT bit
(integer) 2
redis> SETBIT bit 1 1       # 0000 1011
(integer) 0
redis> BITCOUNT bit
(integer) 3

redis> SETBIT x 3 1         # x = 0000 1011
(integer) 0
redis> SETBIT x 1 1
(integer) 0
redis> SETBIT x 0 1
(integer) 0
redis> SETBIT y 2 1         # y = 0000 0110
(integer) 0
redis> SETBIT y 1 1
(integer) 0
redis> SETBIT z 2 1         # z = 0000 0101
(integer) 0
redis> SETBIT z 0 1
(integer) 0
redis> BITOP AND and-result x y z   # 0000 0000
(integer) 1
redis> BITOP OR or-result x y z     # 0000 1111
(integer) 1
redis> BITOP XOR xor-result x y z   # 0000 1000
(integer) 1

redis> SETBIT value 0               # 0000 1001
(integer) 0
redis> SETBIT value 3 1
(integer) 0
redis> BITOP NOT not-value value    # 1111 0110
(integer) 1

位数组的表示

Redis使用字符串对象来表示位数组,因为字符串对象使用的SDS数据结构是二进制安全的,所以程序可以直接使用SDS结构来保存位数组,并使用SDS结构的操作函数来处理位数组。

用SDS表示的,一字节长的位数组:

  • redisObject.type的值为REDIS_STRING,表示这是一个字符串对象。
  • sdshdr.len的值为1,表示这个SDS保存了一个一字节长的位数组。
  • buf数组中的buf[0]字节保存了一字节长的位数组。
  • buf数组中的buf[1]字节保存了SDS程序自动追加到值的末尾的空字符’\0’。

SDS表示的位数组:

SDS表示的位数组

为了清晰地展示各个位的值,让各个字节的各个位都可以清楚地展现出来:

一字节长的位数组的SDS表示

buf数组的每个字节都用一行来表示,每行的第一个格子buf[i]表示这是buf数组的哪个字节,而buf[i]之后的八个格子则分别代表这一字节中的八个位。
buf数组保存位数组的顺序和我们平时书写位数组的顺序是完全相反的,图中各个位的值分别是1、0、1、1、0、0、1、0,这表示buf[0]字节保存的位数组为0100 1101。使用逆序来保存位数组可以简化SETBIT命令的实现

三字节长的位数组的SDS表示:

三字节长的位数组的SDS表示

GETBIT命令的实现

GETBIT命令用于返回位数组bitarray在offset偏移量上的二进制位的值:

GETBIT <bitarray> <offset>

GETBIT命令的执行过程如下:

  1. 计算byte = offset ÷ 8,byte值记录了offset偏移量指定的二进制位保存在位数组的哪个字节。
  2. 计算bit = (offset mod 8) + 1,bit值记录了offset偏移量指定的二进制位是byte字节的第几个二进制位。
  3. 根据byte值和bit值,在位数组bitarray中定位offset偏移量指定的二进制位,并返回这个位的值。

因为GETBIT命令执行的所有操作都可以在常数时间内完成,所以该命令的算法复杂度为O(1)。

GETBIT <bitarray> 10
  1. 3 ÷ 8 的值为0。
  2. (3 mod 8) + 1的值为4。
  3. 定位到buf[0]字节上面,然后取出该字节上的第4个二进制位(从左向右数)的值。
  4. 向客户端返回二进制位的值1。

查找并返回offset为3的二进制位的过程

SETBIT命令的实现

SETBIT用于将位数组bitarray在offset偏移量上的二进制位的值设置为value,并向客户端返回二进制位被设置之前的旧值:

SETBIT <bitarray> <offset> <value>

服务器将执行以下操作:

  1. 计算len= offset ÷ 8+ 1,len值记录了保存offset偏移量指定的二进制位至少需要多少字节。
  2. 检查bitarray键保存的位数组(也即是SDS)的长度是否小于len,如果是的话,将SDS的长度扩展为len字节,并将所有新扩展空间的二进制位的值设置为0。
  3. 计算byte = offset÷8」,byte值记录了offset偏移量指定的二进制位保存在位数组的哪个字节。
  4. 计算bit =(offset mod 8) + 1,bit值记录了offset偏移量指定的二进制位是byte字节的第几个二进制位。
  5. 根据byte值和bit值,在bitarray键保存的位数组中定位offset偏移量指定的二进制位,首先将指定二进制位现在值保存在oldvalue变量,然后将新值value设置为这个二进制位的值。
  6. 向客户端返回oldvalue变量的值。

因为SETBIT命令执行的所有操作都可以在常数时间内完成,所以该命令的时间复杂度为O(1)。

注意事项:扩展会由于SDS的空间分配策略多分配空间。

注意事项:因为buf数组使用逆序来保存位数组,所以当程序对buf数组进行扩展之后,写入操作可以直接在新扩展的二进制位中完成,而不必改动位数组原来已有的二进制位。如果buf数组使用和书写位数组时一样的顺序来保存位数组,那么在每次扩展buf数组之后,程序都需要将位数组已有的位进行移动,然后才能执行写入操作,这比SETBIT命令目前的实现方式要复杂,并且移位带来的CPU时间消耗也会影响命令的执行速度。

注意事项:在第一次初始化Bitmap时,如果偏移量非常大,那么整个初始化过程执行会比较慢,可能会造成Redis阻塞。

Tips:有些数据如用户ID有时以一个固定数字开头,可以将这个ID减去这个数字后与Bitmaps的偏移量对应,避免造成浪费。

BITCOUNT命令的实现

BITCOUNT命令用于统计给定位数组中,值为1的二进制位的数量。

二进制位统计算法

遍历算法

实现BITCOUNT命令最简单直接的方法,就是遍历位数组中的每个二进制位,并在遇到值为1的二进制位时,将计数器的值增一。

遍历算法虽然实现起来简单,但效率非常低,因为这个算法在每次循环中只能检查一个二进制位的值是否为1,所以检查操作执行的次数将与位数组包含的二进制位的数量成正比。

尽管遍历算法对单个二进制位的检查可以在很短的时间内完成,但重复执行上亿次这种检查肯定不是一个高效程序应有的表现,为了让BITCOUNT命令的实现尽可能地高效,程序必须尽可能地增加每次检查所能处理的二进制位的数量,从而减少检查操作执行的次数。

查表算法

优化检查操作的一个办法是使用查表法:

  • 对于一个有限集合来说,集合元素的排列方式是有限的。
  • 而对于一个有限长度的位数组来说,它能表示的二进制位排列也是有限的。

根据这个原理,我们可以创建一个表,表的键为某种排列的位数组,而表的值则是相应位数组中,值为1的二进制位的数量。

创建了这种表后,就可以根据输入的位数组进行查表,在无须对位数组的每个位进行检查的情况下,直接知道这个位数组包含了多少个值为1的二进制位。

表越大,检查的位数就越多,检查整个数组所需的查表次数就越少。

初看起来,只要我们创建一个足够大的表,那么统计工作就可以轻易地完成,但这个问题实际上并没有那么简单,因为查表法的实际效果会受到内存和缓存两方面因素的限制:

  • 因为查表法是典型的空间换时间策略,算法在计算方面节约的时间是通过花费额外的内存换取而来的,节约的时间越多,花费的内存就越大。
    • 对于统计二进制位的问题来说,创建键长为8位的表仅需数百个字节,创建键长为16位的表也仅需数百个KB,但创建键长为32位的表却需要十多个GB。
    • 在实际中,服务器只可能接受数百个字节或者数百KB的内存消耗。
  • 除了内存大小的问题之外,查表法的效果还会受到CPU缓存的限制
    • 对于固定大小的CPU缓存来说,创建的表格越大,CPU缓存所能保存的内容相比整个表格的比例就越少,查表时出现缓存不命中(cache miss)的情况就会越高,缓存的换入和换出操作就会越频繁,最终影响查表法的实际效率。

由于以上列举的两个原因,我们可以得出结论,查表法是一种比遍历算法更好的统计办法,但受限于查表法带来的内存压力,以及缓存不命中可能带来的影响, 我们只能考虑创建键长为8位或者键长为16位的表,而这两种表带来的效率提升,对于处理非常长的位数组来说仍然远远不够。

variable-precision SWAR算法

BITCOUNT命令要解决的问题——统计一个位数组中非0二进制位的数量,在数学上被称为“计算汉明重量(Hamming Weight)”。

因为汉明重量经常被用于信息论、编码理论和密码学,所以研究人员针对计算汉明重量开发了多种不同的算法,一些处理器甚至直接带有计算汉明重量的指令, 而对于不具备这种特殊指令的普通处理器来说,目前已知效率最好的通用算法为variable-precision SWAR算法,该算法通过一系列位移和位运算操作, 可以在常数时间内计算多个字节的汉明重量,并且不需要使用任何额外的内存。

处理32位长度位数组的variable-precision SWAR算法的实现:

uint32_t swar(uint32_t i) {
    // 步骤1 
    i = (i & 0x55555555) + ((i >> 1) & 0x55555555);
    // 步骤2 
    i = (i & 0x33333333) + ((i >> 2) & 0x33333333);
    // 步骤3 
    i = (i & 0x0F0F0F0F) + ((i >> 4) & 0x0F0F0F0F);
    // 步骤4 
    i = (i*(0x01010101) >> 24);
    return i;
}

调用swar(bitarray)的执行步骤:

  • 步骤1计算出的值i的二进制表示可以按每两个二进制位为一组进行分组,各组的十进制表示就是该组的汉明重量。
  • 步骤2计算出的值i的二进制表示可以按每四个二进制位为一组进行分组,各组的十进制表示就是该组的汉明重量。
  • 步骤3计算出的值i的二进制表示可以按每八个二进制位为一组进行分组,各组的十进制表示就是该组的汉明重量。
  • 步骤4的i*0x01010101语句计算出bitarray的汉明重量并记录在二进制位的最高八位,而»24语句则通过右移运算,将bitarray的汉明重量移动到最低八位,得出的结果就是bitarray的汉明重量。

swar函数每次执行可以计算32个二进制位的汉明重量,它比之前介绍的遍历算法要快32倍,比键长为8位的查表法快4倍,比键长为16位的查表法快2倍,并且因为swar函数是单纯的计算操作,所以它无须像查表法那样,使用额外的内存。
另外,因为swar函数是一个常数复杂度的操作,所以我们可以按照自己的需要,在一次循环中多次执行swar,从而按倍数提升计算汉明重量的效率。

Redis的实现

BITCOUNT命令的实现用到了查表和variable-precisionSWAR两种算法:

  • 查表算法使用键长为8位的表,表中记录了从0000 0000到1111 1111在内的所有二进制位的汉明重量。
  • 至于variable-precision SWAR算法方面,BITCOUNT命令在每次循环中载入128个二进制位,然后调用四次32位variable-precision SWAR算法来计算这128个二进制位的汉明重量。

在执行BITCOUNT命令时,程序会根据未处理的二进制位的数量来决定使用那种算法:

  • 如果未处理的二进制位的数量大于等于128位,那么程序使用variable-precision SWAR算法来计算二进制位的汉明重量。
  • 如果未处理的二进制位的数量小于128位,那么程序使用查表算法来计算二进制位的汉明重量。

这个BITCOUNT实现的算法复杂度为O(n),其中n为输入二进制位的数量。

BITOP命令的实现

因为C语言直接支持对字节执行逻辑与(&)、逻辑或(|)、逻辑异或(^)和逻辑非(~)操作,所以BITOP命令的AND、OR、XOR和NOT四个操作都是直接基于这些逻辑操作实现的:

  • 在执行BITOP AND命令时,程序用&操作计算出所有输入二进制位的逻辑与结果,然后保存在指定的键上面。
  • 在执行BITOP OR命令时,程序用|操作计算出所有输入二进制位的逻辑或结果,然后保存在指定的键上面。
  • 在执行BITOP XOR命令时,程序用^操作计算出所有输入二进制位的逻辑异或结果,然后保存在指定的键上面。
  • 在执行BITOP NOT命令时,程序用~操作计算出输入二进制位的逻辑非结果,然后保存在指定的键上面。

因为BITOP AND、BITOP OR、BITOP XOR三个命令可以接受多个位数组作为输入,程序需要遍历输入的每个位数组的每个字节来进行计算,所以这些命令的复杂度为O(n^2);因为BITOP NOT命令只接受一个位数组输入,所以它的复杂度为O(n)。

HyperLogLog

HyperLogLog并不是一种新的数据结构(实际类型为字符串类型),而是一种基数算法,通过HyperLogLog可以利用极小的内存空间完成独立总数的统计,数据集可以是IP、Email、ID等。

HyperLogLog的算法是由Philippe Flajolet在The analysis of a near-optimal cardinality estimation algorithm这篇论文中提出。

HyperLogLog提供了3个命令:pfadd、pfcount、pfmerge。

# 添加
pfadd key element [element …]
# 计算独立用户数
pfcount key [key …]
127.0.0.1:6379> pfadd 2016_03_06:unique:ids "uuid-1" "uuid-2" "uuid-3" "uuid-4"
(integer) 1
127.0.0.1:6379> pfcount 2016_03_06:unique:ids
(integer) 4
127.0.0.1:6379> pfadd 2016_03_06:unique:ids "uuid-1" "uuid-2" "uuid-3" "uuid-90"
(integer) 1
127.0.0.1:6379> pfcount 2016_03_06:unique:ids
(integer) 5

HyperLogLog内存占用量小得惊人,但是用如此小空间来估算如此巨大的数据,必然不是100%的正确,其中一定存在误差率。Redis官方给出的数字是0.81%的失误率。

pfmerge可以求出多个HyperLogLog的并集并赋值给destkey:

127.0.0.1:6379> pfadd 2016_03_06:unique:ids "uuid-1" "uuid-2" "uuid-3" "uuid-4"
(integer) 1
127.0.0.1:6379> pfadd 2016_03_05:unique:ids "uuid-4" "uuid-5" "uuid-6" "uuid-7"
(integer) 1
127.0.0.1:6379> pfmerge 2016_03_05_06:unique:ids 2016_03_05:unique:ids
2016_03_06:unique:ids
OK
127.0.0.1:6379> pfcount 2016_03_05_06:unique:ids
(integer) 7

HyperLogLog内存占用量非常小,但是存在错误率,开发者在进行数据结构选型时只需要确认如下两条即可:

  • 只为了计算独立总数,不需要获取单条数据。
  • 可以容忍一定误差率,毕竟HyperLogLog在内存的占用量上有很大的优势。

GEO

Redis3.2版本提供了GEO(地理信息定位)功能,支持存储地理位置信息用来实现诸如附近位置、摇一摇这类依赖于地理位置信息的功能。

GEO功能是Redis的另一位作者Matt Stancliff借鉴NoSQL数据库Ardb实现的,Ardb的作者来自中国,它提供了优秀的GEO功能。

增加地理位置信息

geoadd key longitude latitude member [longitude latitude member ...]
geoadd cities:locations 117.12 39.08 tianjin 114.29 38.02 shijiazhuang 118.01 39.38 tangshan 115.29 38.51 baoding
(integer) 4

获取地理位置信息

geopos key member [member ...]
geopos cities:locations tianjin
1) "117.12000042200088501"
2) "39.0800000535766543"

计算最接近的时间事件距离到达还有多少毫秒

geodist key member1 member2 [unit]
geodist cities:locations tianjin beijing km
"89.2061"

其中unit代表返回结果的单位,包含以下四种:

  • m(meters)代表米。
  • km(kilometers)代表公里。
  • mi(miles)代表英里。
  • ft(feet)代表尺。

获取指定位置范围内的地理信息位置集合

georadius key longitude latitude radiusm|km|ft|mi [withcoord] [withdist] [withhash] [COUNT count] [asc|desc] [store key] [storedist key]
georadiusbymember key member radiusm|km|ft|mi [withcoord] [withdist] [withhash] [COUNT count] [asc|desc] [store key] [storedist key]

georadius和georadiusbymember两个命令的作用是一样的,都是以一个地理位置为中心算出指定半径内的其他地理信息位置,不同的是georadius命令的中心位置给出了具体的经纬度,georadiusbymember只需给出成员即可。

其中radiusm|km|ft|mi是必需参数,指定了半径(带单位),这两个命令有很多可选参数:

  • withcoord:返回结果中包含经纬度。
  • withdist:返回结果中包含离中心节点位置的距离。
  • withhash:返回结果中包含geohash。
  • COUNT count:指定返回结果的数量。
  • asc|desc:返回结果按照离中心节点的距离做升序或者降序。
  • store key:将返回结果的地理位置信息保存到指定键。
  • storedist key:将返回结果离中心节点的距离保存到指定键。
georadiusbymember cities:locations beijing 150 km
1) "beijing"
2) "tianjin"
3) "tangshan"
4) "baoding"

获取geohash

Redis使用geohash将二维经纬度转换为一维字符串,下面操作会返回beijing的geohash值。

geohash key member [member ...]
geohash cities:locations beijing
1) "wx4ww02w070"

geohash有如下特点:

  • GEO的数据类型为zset,Redis将所有地理位置信息的geohash存放在zset中。
  • 字符串越长,表示的位置更精确,表3-8给出了字符串长度对应的精度,例如geohash长度为9时,精度在2米左右。
  • 两个字符串越相似,它们之间的距离越近,Redis利用字符串前缀匹配算法实现相关的命令。
  • geohash编码和经纬度是可以相互转换的。

geohash长度与精度对应关系

Redis正是使用有序集合并结合geohash的特性实现了GEO的若干命令。

删除地理位置信息

GEO没有提供删除成员的命令,但是因为GEO的底层实现是zset,所以可以借用zrem命令实现对地理位置信息的删除。

zrem key member

慢查询日志

  • Redis的慢查询日志功能用于记录执行时间超过指定时长的命令。
  • Redis服务器将所有的慢查询日志保存在服务器状态的slowlog链表中,每个链表节点都包含一个slowlogEntry结构,每个slowlogEntry结构代表一条慢查询日志。
  • 打印和删除慢查询日志可以通过遍历slowlog链表来完成。
  • slowlog链表的长度就是服务器所保存慢查询日志的数量。
  • 新的慢查询日志会被添加到slowlog链表的表头,如果日志的数量超过slowlog-max-len选项的值,那么多出来的日志会被删除。

Redis的慢查询日志功能用于记录执行时间超过给定时长的命令请求,用户可以通过这个功能产生的日志来监视和优化查询速度。

服务器配置有两个和慢查询日志相关的选项:

  • slowlog-log-slower-than选项指定执行时间超过多少微秒(1秒等于1 000 000微秒)的命令请求会被记录到日志上。
  • slowlog-max-len选项指定服务器最多保存多少条慢查询日志。
    • 服务器使用先进先出的方式保存多条慢查询日志,当服务器存储的慢查询日志数量等于slowlog-max-len选项的值时,服务器在添加一条新的慢查询日志之前,会先将最旧的一条慢查询日志删除。

使用SLOWLOG GET命令查看服务器所保存的慢查询日志。
使用SLOWLOG LEN命令查看服务器所保存的慢查询日志数量。
使用SLOWLOG RESET命令清楚所有服务器保存的慢查询日志。

慢查询记录的保存

服务器状态中包含了几个和慢查询日志功能有关的属性:

struct redisServer {
    // ...
    // 下一条慢查询日志的ID
    long long slowlog_entry_id;
    // 保存了所有慢查询日志的链表
    list *slowlog;
    // 服务器配置slowlog-log-slower-than选项的值
    long long slowlog_log_slower_than;
    // 服务器配置slowlog-max-len选项的值
    unsigned long slowlog_max_len;
    // ...
};
  • slowlog_entry_id属性的初始值为0,每当创建一条新的慢查询日志时,这个属性的值就会用作新日志的id值,之后程序会对这个属性的值增一。
  • slowlog链表保存了服务器中的所有慢查询日志,链表中的每个节点都保存了一个slowlogEntry结构,每个slowlogEntry结构代表一条慢查询日志。
typedef struct slowlogEntry {
    // 唯一标识符
    long long id;
    // 命令执行时的时间,格式为UNIX时间戳
    time_t time;
    // 执行命令消耗的时间,以微秒为单位
    long long duration;
    // 命令与命令参数
    robj **argv;
    // 命令与命令参数的数量
    int argc;
} slowlogEntry;

redisServer结构示例

添加新日志

在每次执行命令的之前和之后,程序都会记录微秒格式的当前UNIX时间戳,这两个时间戳之间的差就是服务器执行命令所耗费的时长, 服务器会将这个时长作为参数之一传给slowlogPushEntryIfNeeded函数,而slowlogPushEntryIfNeeded函数则负责检查是否需要为这次执行的命令创建慢查询日志。

slowlogPushEntryIfNeeded函数的作用有两个:

  1. 检查命令的执行时长是否超过slowlog-log-slower-than选项所设置的时间,如果是的话,就为命令创建一个新的日志,并将新日志添加到slowlog链表的表头。
  2. 检查慢查询日志的长度是否超过slowlog-max-len选项所设置的长度,如果是的话,那么将多出来的日志从slowlog链表中删除掉。

最佳实践

  • slowlog-max-len:调大慢查询列表,记录时会截断长命令,不会占用大量内存,增大慢查询列表可以减缓查询被剔除的可能,例如线上可以设置为1000以上。
  • slow-log-slower-than:默认10毫秒,需要根据并发量设置
    • 由于单线程响应命令,对于高流量的场景,如果命令响应时间在1毫秒以上,那么最多可以支撑的OPS不到1000.因此高OPS建议设置为1ms。
  • 慢查询只记录执行时间,网络、排队的时间不包括。
    • 因为命令是排队机制,慢查询会导致其他的命令级联阻塞,因此客户端出现超时是,要检查该时间点是否有对应的慢查询。
  • 定期执行slowlog get命令持久化列表到存储,防止丢失。

监视器monitor

  • 客户端可以通过执行MONITOR命令,将客户端转换成监视器,接收并打印服务器处理的每个命令请求的相关信息。
  • 当一个客户端从普通客户端变为监视器时,该客户端的REDIS_MONITOR标识会被打开。
  • 服务器将所有监视器都记录在monitors链表中。
  • 每次处理命令请求时,服务器都会遍历monitors链表,将相关信息发送给监视器。

通过执行MONITOR命令,客户端可以将自己变为一个监视器,实时地接收并打印出服务器当前处理的命令请求的相关信息:

redis> MONITOR
OK
1378822099.421623 [0 127.0.0.1:56604] "PING"
1378822105.089572 [0 127.0.0.1:56604] "SET" "msg" "hello world"
1378822109.036925 [0 127.0.0.1:56604] "SET" "number" "123"
1378822140.649496 [0 127.0.0.1:56604] "SADD" "fruits" "Apple" "Banana" "Cherry"
1378822154.117160 [0 127.0.0.1:56604] "EXPIRE" "msg" "10086"
1378822257.329412 [0 127.0.0.1:56604] "KEYS" "*"
1378822258.690131 [0 127.0.0.1:56604] "DBSIZE"

每当一个客户端向服务器发送一条命令请求时,服务器除了会处理这条命令请求之外,还会将关于这条命令请求的信息发送给所有监视器。

命令的接收和信息的发送

成为监视器

发送MONITOR命令可以让一个普通客户端变为一个监视器。

def MONITOR():
    # 打开客户端的监视器标志
    client.flags |= REDIS_MONITOR
    # 将客户端添加到服务器状态的monitors链表的末尾
    server.monitors.append(client)
    # 向客户端返回OK
    send_reply("OK")

客户端c10086执行MONITOR命令之后的monitors链表

monitor能监听到所有的命令,一旦Redis的并发量过大,monitor客户端的输出缓冲会暴涨,可能瞬间会占用大量内存。

高并发下monitor命令使用大量输出缓冲区:

高并发下monitor命令使用大量输出缓冲区

向监视器发送命令信息

服务器在每次处理命令请求之前,都会调用replicationFeedMonitors函数,由这个函数将被处理的命令请求的相关信息发送给各个监视器。

连接

Redis的连接命令基本上都是用于管理Redis的服务器与客户端连接。

redis 127.0.0.1:6379> AUTH "password"
OK
redis 127.0.0.1:6379> PING
PONG
命令 作用 示例
AUTH password 服务器验证给定的密码  
ECHO message 打印给定的字符串  
PING 检查服务器是否正在运行  
QUIT 关闭当前连接  
SELECT index 更改当前连接所选数据库 默认在0号数据库,总共16个数据库
redis 127.0.0.1:6379> SELECT 1
OK
redis 127.0.0.1:6379[1]>

备份和还原

备份

Redis SAVE命令用来创建备份当前Redis数据库。

127.0.0.1:6379> SAVE

OK

这个命令将创建dump.rdb文件在Redis目录。

要创建Redis备份备用命令BGSAVE也可以的。这个命令将开始备份过程,并在后台运行。

127.0.0.1:6379> BGSAVE

Background saving started

还原

要恢复Redis数据只是移动Redis备份文件(dump.rdb)到Redis目录,然后启动服务器。为了让Redis读取到Redis目录,使用CONFIG命令如下所示:

redis 127.0.0.1:6379> config get dir
1) "dir"
2) "/var/lib/redis"

在上述的输出命令/var/lib/redis是目录,在安装redis服务器。

安全

Redis数据库可以设置安全,所以做出相关的任何客户端都需要在执行命令之前进行身份验证。为了确保Redis需要设置在配置文件中的密码验证一致。

127.0.0.1:6379> CONFIG get requirepass
1) "requirepass"
2) ""
127.0.0.1:6379> CONFIG set requirepass "yiibai"
OK
127.0.0.1:6379> CONFIG get requirepass
1) "requirepass"
2) "yiibai"

在配置文件中修改:

requirepass password

认证

127.0.0.1:6379> AUTH "yiibai"
OK
127.0.0.1:6379> SET mykey "Test value"
OK
127.0.0.1:6379> GET mykey
"Test value"
redis-cli -h 127.0.0.1 -p 6379 -a password

阻塞

Redis是典型的单线程架构,所有的读写操作都是在一条主线程中完成的。当Redis用于高并发场景时,这条线程就变成了它的生命线。如果出现阻塞,哪怕是很短时间,对于我们的应用来说都是噩梦。

导致阻塞问题的场景大致分为内在原因和外在原因:

  • 内在原因包括:不合理地使用API或数据结构、CPU饱和、持久化阻塞等。
  • 外在原因包括:CPU竞争、内存交换、网络问题等。

发现阻塞

当Redis阻塞时,线上应用服务应该最先感知到,这时应用方会收到大量Redis超时异常。

比如Jedis客户端会抛出JedisConnectionException异常,可以统一输出到一个Appender然后统计报警。


import java.util.Date;

import com.google.common.util.concurrent.AtomicLongMap;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.LoggerFactory;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.spi.IThrowableProxy;
import ch.qos.logback.core.AppenderBase;

public class RedisAppender extends AppenderBase<ILoggingEvent> {
	// 使用 guava 的 AtomicLongMap, 用于并发计数
	public static final AtomicLongMap<String> ATOMIC_LONG_MAP = AtomicLongMap.create();
	static {
		// 自定义 Appender 加入到 logback 的 rootLogger 中
		LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
		Logger rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME);
		ErrorStatisticsAppender errorStatisticsAppender = new ErrorStatisticsAppender();
		errorStatisticsAppender.setContext(loggerContext);
		errorStatisticsAppender.start();
		rootLogger.addAppender(errorStatisticsAppender);
	}

	// 重写接收日志事件方法
	@Override
	protected void append(ILoggingEvent event) {
		// 只监控 error 级别日志
		if (event.getLevel() == Level.ERROR) {
			IThrowableProxy throwableProxy = event.getThrowableProxy();
			// 确认抛出异常
			if (throwableProxy != null) {
				// 以每分钟为 key ,记录每分钟异常数量
				String key = DateUtil.formatDate(new Date(), "yyyyMMddHHmm");
				long errorCount = ATOMIC_LONG_MAP.incrementAndGet(key);
				if (errorCount > 10) {
					// 超过 10 次触发报警代码
				}
				// 清理历史计数统计,防止极端情况下内存泄露
				for (String oldKey : ATOMIC_LONG_MAP.asMap().keySet()) {
					if (!StringUtils.equals(key, oldKey)) {
						ATOMIC_LONG_MAP.remove(oldKey);
					}
				}
			}
		}
	}
}

统一输出,比如所有的异常都通过logger.error打印,这应该作为开发规范推广。

修改底层代码实现出问题时打印出ip和端口。充分利用监控系统。

内在原因

定位到具体的Redis节点异常后,首先应该排查是否是Redis自身原因导致,围绕以下几个方面排查:

  • API或数据结构使用不合理。
  • CPU饱和的问题。
  • 持久化相关的阻塞。

API或数据结构使用不合理

对于高并发的场景我们应该尽量避免在大对象上执行算法复杂度超过O(n)的命令。

如何发现慢查询

Redis原生提供慢查询统计功能,执行slowlog get{n}命令可以获取最近的n条慢查询命令,默认对于执行超过10毫秒的命令都会记录到一个定长队列中,线上实例建议设置为1毫秒便于及时发现毫秒级以上的命令。

如果命令执行时间在毫秒级,则实例实际OPS只有1000左右。慢查询队列长度默认128,可适当调大。

发现慢查询后,开发人员需要作出及时调整。可以按照以下两个方向去调整:

  1. 修改为低算法度的命令,如hgetall改为hmget等,禁用keys、sort等命令。
  2. 调整大对象:缩减大对象数据或把大对象拆分为多个小对象,防止一次命令操作过多的数据。

大对象拆分过程需要视具体的业务决定,如用户好友集合存储在Redis中,有些热点用户会关注大量好友,这时可以按时间或其他维度拆分到多个集合中。

如何发现大对象

Redis本身提供发现大对象的工具,对应命令:redis-cli -h {ip} -p {port} bigkeys。内部原理采用分段进行scan操作,把历史扫描过的最大对象统计出来便于分析优化,

根据结果汇总信息能非常方便地获取到大对象的键,以及不同类型数据结构的使用情况。

CPU饱和

单线程的Redis处理命令时只能使用一个CPU。而CPU饱和是指Redis把单核CPU使用率跑到接近100%。 使用top命令很容易识别出对应Redis进程的CPU使用率。CPU饱和是非常危险的,将导致Redis无法处理更多的命令,严重影响吞吐量和应用方的稳定性。

对于这种情况,首先判断当前Redis的并发量是否达到极限,建议使用统计命令redis-cli -h {ip} -p {port} –stat获取当前Redis使用情况,该命令每秒输出一行统计信息。

# redis-cli --stat
------- data ------ --------------------- load -------------------- - child -
keys       mem      clients blocked requests               connections
3789785    3.20G    507     0       8867955607 (+0)        555894
3789813    3.20G    507     0       8867959511 (+63904)    555894
3789822    3.20G    507     0       8867961602 (+62091)    555894
3789831    3.20G    507     0       8867965049 (+63447)    555894
3789842    3.20G    507     0       8867969520 (+62675)    555894
3789845    3.20G    507     0       8867971943 (+62423)    555894

以上输出是一个接近饱和的Redis实例的统计信息,它每秒平均处理6万+的请求。对于这种情况,垂直层面的命令优化很难达到效果,这时就需要做集群化水平扩展来分摊OPS压力。

如果只有几百或几千OPS的Redis实例就接近CPU饱和是很不正常的,有可能使用了高算法复杂度的命令。

还有一种情况是过度的内存优化,这种情况有些隐蔽,需要我们根据infocommandstats统计信息分析出命令不合理开销时间。

例如下面的耗时统计:

cmdstat_hset:calls=198757512,usec=27021957243,usec_per_call=135.95

查看这个统计可以发现一个问题,hset命令算法复杂度只有O(1)但平均耗时却达到135微秒,显然不合理,正常情况耗时应该在10微秒以下。 这是因为上面的Redis实例为了追求低内存使用量,过度放宽ziplist使用条件(修改了hash-max-ziplist-entries和hash-max-ziplist-value配置)。

进程内的hash对象平均存储着上万个元素,而针对ziplist的操作算法复杂度在O(n)到O(n^2)之间。 虽然采用ziplist编码后hash结构内存占用会变小,但是操作变得更慢且更消耗CPU。ziplist压缩编码是Redis用来平衡空间和效率的优化手段,不可过度使用。

持久化阻塞

对于开启了持久化功能的Redis节点,需要排查是否是持久化导致的阻塞。

持久化引起主线程阻塞的操作主要有:fork阻塞、AOF刷盘阻塞、HugePage写操作阻塞。

fork阻塞

fork操作发生在RDB和AOF重写时,Redis主线程调用fork操作产生共享内存的子进程,由子进程完成持久化文件重写工作。如果fork操作本身耗时过长,必然会导致主线程的阻塞。

可以执行info stats命令获取到latest_fork_usec指标,表示Redis最近一次fork操作耗时, 如果耗时很大,比如超过1秒,则需要做出优化调整,如避免使用过大的内存实例和规避fork缓慢的操作系统等.

AOF刷盘阻塞

当我们开启AOF持久化功能时,文件刷盘的方式一般采用每秒一次,后台线程每秒对AOF文件做fsync操作。当硬盘压力过大时,fsync操作需要等待,直到写入完成。 如果主线程发现距离上一次的fsync成功超过2秒,为了数据安全性它会阻塞直到后台线程执行fsync操作完成。 这种阻塞行为主要是硬盘压力引起,可以查看Redis日志识别出这种情况,当发生这种阻塞行为时,会打印日志。

也可以查看info persistence统计中的aof_delayed_fsync指标,每次发生fdatasync阻塞主线程时会累加。

硬盘压力可能是Redis进程引起的,也可能是其他进程引起的,可以使用iotop查看具体是哪个进程消耗过多的硬盘资源。

HugePage写操作阻塞

子进程在执行重写期间利用Linux写时复制技术降低内存开销,因此只有写操作时Redis才复制要修改的内存页。 对于开启Transparent HugePages的操作系统,每次写命令引起的复制内存页单位由4K变为2MB,放大了512倍,会拖慢写操作的执行时间,导致大量写操作慢查询。

外在原因

CPU竞争

CPU竞争问题如下:

  • 进程竞争:Redis是典型的CPU密集型应用,不建议和其他多核CPU密集型服务部署在一起。当其他进程过度消耗CPU时,将严重影响Redis吞吐量。可以通过top、sar等命令定位到CPU消耗的时间点和具体进程,这个问题比较容易发现,需要调整服务之间部署结构。
  • 绑定CPU:部署Redis时为了充分利用多核CPU,通常一台机器部署多个实例。常见的一种优化是把Redis进程绑定到CPU上,用于降低CPU频繁上下文切换的开销。这个优化技巧正常情况下没有问题,但是存在例外情况。

Redis绑定CPU后父子进程使用一个CPU:

Redis绑定CPU后父子进程使用一个CPU

当Redis父进程创建子进程进行RDB/AOF重写时,如果做了CPU绑定,会与父进程共享使用一个CPU。 子进程重写时对单核CPU使用率通常在90%以上,父进程与子进程将产生激烈CPU竞争,极大影响Redis稳定性。 因此对于开启了持久化或参与复制的主节点不建议绑定CPU。

内存交换

内存交换(swap)对于Redis来说是非常致命的,Redis保证高性能的一个重要前提是所有的数据在内存中。 如果操作系统把Redis使用的部分内存换出到硬盘,由于内存与硬盘读写速度差几个数量级,会导致发生交换后的Redis性能急剧下降。

查询Redis进程号:

redis-cli -p 6383 info server | grep process_id
process_id:4476

根据进程号查询内存交换信息:

cat /proc/4476/smaps | grep Swap
Swap: 0 kB
Swap: 0 kB
Swap: 4 kB
Swap: 0 kB
Swap: 0 kB
.....

如果交换量都是0KB或者个别的是4KB,则是正常现象,说明Redis进程内存没有被交换。

预防内存交换的方法有:

  • 保证机器充足的可用内存。
  • 确保所有Redis实例设置最大可用内存(maxmemory),防止极端情况下Redis内存不可控的增长。
  • 降低系统使用swap优先级,如echo 10>/proc/sys/vm/swappiness。

网络问题

网络问题经常是引起Redis阻塞的问题点。常见的网络问题主要有:连接拒绝、网络延迟、网卡软中断等。

连接拒绝

当出现网络闪断或者连接数溢出时,客户端会出现无法连接Redis的情况。我们需要区分这三种情况:网络闪断、Redis连接拒绝、连接溢出。

网络闪断

一般发生在网络割接或者带宽耗尽的情况,对于网络闪断的识别比较困难,常见的做法可以通过sar-n DEV查看本机历史流量是否正常,或者借助外部系统监控工具(如Ganglia)进行识别。 具体问题定位需要更上层的运维支持,对于重要的Redis服务需要充分考虑部署架构的优化,尽量避免客户端与Redis之间异地跨机房调用。

Redis连接拒绝

Redis通过maxclients参数控制客户端最大连接数,默认10000。当Redis连接数大于maxclients时会拒绝新的连接进入, info stats的rejected_connections统计指标记录所有被拒绝连接的数量.

redis-cli -p 6384 info Stats | grep rejected_connections
rejected_connections:0

Redis使用多路复用IO模型可支撑大量连接,但是不代表可以无限连接。客户端访问Redis时尽量采用NIO长连接或者连接池的方式。

当Redis用于大量分布式节点访问且生命周期比较短的场景时,如比较典型的在Map/Reduce中使用Redis。 因为客户端服务存在频繁启动和销毁的情况且默认Redis不会主动关闭长时间闲置连接或检查关闭无效的TCP连接,因此会导致Redis连接数快速消耗且无法释放的问题。 这种场景下建议设置tcp-keepalive和timeout参数让Redis主动检查和关闭无效连接。

连接溢出

这是指操作系统或者Redis客户端在连接时的问题。这个问题的原因比较多,下面就分别介绍两种原因:进程限制、backlog队列溢出。

进程限制

客户端想成功连接上Redis服务需要操作系统和Redis的限制都通过才可以。

操作系统和Redis对客户端连接的双重限制:

操作系统和Redis对客户端连接的双重限制

操作系统一般会对进程使用的资源做限制,其中一项是对进程可打开最大文件数控制,通过ulimit -n 查看,通常默认1024。 由于Linux系统对TCP连接也定义为一个文件句柄,因此对于支撑大量连接的Redis来说需要增大这个值,如设置ulimit -n 65535,防止Too many open files错误。

backlog队列溢出

系统对于特定端口的TCP连接使用backlog队列保存。Redis默认的长度为511,通过tcp-backlog参数设置。 如果Redis用于高并发场景为了防止缓慢连接占用,可适当增大这个设置,但必须大于操作系统允许值才能生效。 当Redis启动时如果tcp-backlog设置大于系统允许值将以系统值为准。

系统的backlog默认值为128,使用echo 511 > /proc/sys/net/core/somaxconn命令进行修改。 可以通过netstat -s命令获取因backlog队列溢出造成的连接拒绝统计。

netstat -s | grep overflowed
663 times the listen queue of a socket overflowed

如果怀疑是backlog队列溢出,线上可以使用cron定时执行netstat -s |grep overflowed统计,查看是否有持续增长的连接拒绝情况。

网络延迟

网络延迟取决于客户端到Redis服务器之间的网络环境。主要包括它们之间的物理拓扑和带宽占用情况。常见的物理拓扑按网络延迟由快到慢可分为:

同物理机>同机架>跨机架>同机房>同城机房>异地机房。

但它们容灾性正好相反,同物理机容灾性最低而异地机房容灾性最高。

Redis提供了测量机器之间网络延迟的工具,在redis-cli -h {host} -p {port}命令后面加入如下参419数进行延迟测试:

  • –latency:持续进行延迟测试,分别统计:最小值、最大值、平均值、采样次数。
  • –latency-history:统计结果同–latency,但默认每15秒完成一行统计,可通过-i参数控制采样时间。
  • –latency-dist:使用统计图的形式展示延迟统计,每1秒采样一次。

网络延迟问题经常出现在跨机房的部署结构上,对于机房之间延迟比较严重的场景需要调整拓扑结构,如把客户端和Redis部署在同机房或同城机房等。

带宽瓶颈通常出现在以下几个方面:

  • 机器网卡带宽。
  • 机架交换机带宽。
  • 机房之间专线带宽。

带宽占用主要根据当时使用率是否达到瓶颈有关,如频繁操作Redis的大对象对于千兆网卡的机器很容易达到网卡瓶颈,因此需要重点监控机器流量, 及时发现网卡打满产生的网络延迟或通信中断等情况,而机房专线和交换机带宽一般由上层运维监控支持,通常出现瓶颈的概率较小。

网卡软中断

网卡软中断是指由于单个网卡队列只能使用一个CPU,高并发下网卡数420据交互都集中在同一个CPU,导致无法充分利用多核CPU的情况。

网卡软中断瓶颈一般出现在网络高流量吞吐的场景,如下使用“top+数字1”命令可以很明显看到CPU1的软中断指标(si)过高:

top

Cpu0 : 15.3%us, 0.3%sy, 0.0%ni, 84.4%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu1 : 16.6%us, 2.0%sy, 0.0%ni, 47.1%id, 3.3%wa, 0.0%hi, 31.0%si, 0.0%st
Cpu2 : 13.3%us, 0.7%sy, 0.0%ni, 86.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu3 : 14.3%us, 1.7%sy, 0.0%ni, 82.4%id, 1.0%wa, 0.0%hi, 0.7%si, 0.0%st
.....
Cpu15 : 10.3%us, 8.0%sy, 0.0%ni, 78.7%id, 1.7%wa, 0.3%hi, 1.0%si, 0.0%st

Linux在内核2.6.35以后支持Receive Packet Steering(RPS),实现了在软件层面模拟硬件的多队列网卡功能。

配置多CPU分摊软中断

性能

参考《内存优化》部分

单线程架构

Redis使用了单线程架构和I/O多路复用模型来实现高性能的内存数据库服务。

为什么单线程还能这么快(每秒万级别的处理能力)?

  • 纯内存访问,Redis将所有数据放在内存中国,内存的响应市场大约为100纳秒,这是重要基础。
  • 非阻塞I/O,Redis使用epoll作为I/O多路复用技术的实现,再加上Redis自身的事件处理模型将epoll中的连接、读写、关闭都转换为事件,不在网络I/O上浪费过多的时间。
  • 单线程避免了线程切换和竟态产生的消耗,也简化了数据结构和算法的实现。

单线程带来的问题:

对于每个命令的执行时间是有要求的。如果某个命令执行过长,会造成其他命令的阻塞,这是致命的。

数据库

多数据库

多数据库的缺点:

  • Redis是单线程的,多个数据库仍然使用一个CPU,彼此间还是会受到影响
  • 多数据库的使用方式,会让调试和运维不同业务的数据库变的困难,互相影响使得排查问题困难
  • 部分客户端根本不支持这个功能,切换也很容易在开发时造成混乱
  • 集群不支持非0号数据库

多数据库替代:

部署多个Redis实例使用端口区分,充分利用CPU资源又不相互影响。

flush

  • flushdb/flushall命令会将所有数据清除,可以使用rename-command配置规避这个问题
  • 大量数据会造成Redis阻塞

批量操作

使用批量操作有助于提高业务处理效率,因为批量处理降低了多次发送命令的网络开销。

但是要注意命令的数量太多会导致Redis阻塞或网络阻塞。

计数

很多存储系统使用CAS机制实现计数,会有一定的CPU开销,但是Redis中不存在这个问题,因为单线程架构的顺序执行。

keys、scan

redis-cli keys video* | xargs redis-cli del

如果Redis包含了大量的键,执行keys命令很可能会造成Redis阻塞,所以不建议在生产情况使用。

特殊情况:

  • 在一个不对外提供服务的节点上执行,这样不会阻塞到客户端的请求,但是会影响复制
  • 如果确认键数比较少可以执行
  • 使用scan命令渐进式遍历所有键,可以有效防止阻塞。

smembers、lrange、hgetall都属于比较重的命令,如果元素过多存在阻塞redis的可能性,这时候使用hscan、sscan来完成(游标命令替代)。

如果在scan过程中如果有键的变化(增加、删除、修改),那么遍历效果可能会碰到如下问题:

  • 新增的键可能没有遍历到
  • 遍历出了重复的键等情况

benchmark

Redis的基准是实用程序运行n个命令检查Redis的性能。

redis-benchmark -n 100000

PING_INLINE: 141043.72 requests per second
PING_BULK: 142857.14 requests per second
SET: 141442.72 requests per second
GET: 145348.83 requests per second
INCR: 137362.64 requests per second
LPUSH: 145348.83 requests per second
LPOP: 146198.83 requests per second
SADD: 146198.83 requests per second
SPOP: 149253.73 requests per second
LPUSH (needed to benchmark LRANGE): 148588.42 requests per second
LRANGE_100 (first 100 elements): 58411.21 requests per second
LRANGE_300 (first 300 elements): 21195.42 requests per second
LRANGE_500 (first 450 elements): 14539.11 requests per second
LRANGE_600 (first 600 elements): 10504.20 requests per second
MSET (10 keys): 93283.58 requests per second

Redis的基准有许多可供选择

选项 描述 默认值
-h 指定服务器的主机名 127.0.0.1
-p 指定服务器端口 6379
-s 指定服务器套接字  
-c 指定并行连接数 50
-n 指定请求总数 10000
-d 指定以字节为单位设置/获取值的数据大小 2
-k 1=保持活动0=重新连接 1
-r 使用随机键对SET/GET/INCR,随机SADD值  
-p 管道<numreq>请求 1
-h 指定服务器的主机名  
-q Redis强制安静操作。只显示查询/秒值  
–csv 输出为CSV格式  
-l 产生循环,永远运行测试  
-t 只有运行的逗号分隔的测试列表。  
-I 空闲模式。刚刚开N个空闲连接和等待。  
redis-benchmark -h 127.0.0.1 -p 6379 -t set,lpush -n 100000 -q

SET: 146198.83 requests per second
LPUSH: 145560.41 requests per second

多实例部署

Redis单线程架构无法充分利用CPU多核特性,通常的做法是在一台机器上部署多个Redis实例。

多个实例开启AOF重写后,彼此之间会产生多CPU和IO的竞争。

info Persistence片段度量指标:

属性名 属性值
rdb_bgsave_in_progress:0 bgsave子进程是否正在运行
rdb_current_bgsave_time_sec:-1 当前运行bgsave的时间,-1表示未运行
aof_enabled:0 是否开启AOF
aof_rewrite_in_progress:0 AOF重写子进程是否正在运行
aof_rewrite_scheduled:0 bgsave后是否进行AOF重写
aof_current_rewrite_time_sec:-1 当前运行AOF重写的时间,-1表示未运行
aof_current_size:1112 AOF文件当前的字节数
aof_base_size:1112 AOF上次重写rewrite的字节数

可以通过外部程序轮询来控制AOF重写操作的执行。

轮询来控制AOF重写

流程说明:

  1. 外部程序定时轮询监控机器(machine)上所有实例。
  2. 对于开启AOF的实例,查看(aof_current_size - aof_base_size)/ aof_base_size确认增长率。
  3. 当增长了超过特定阀值,执行bgrewriteaof命令手动触发当前实例的AOF重写。
  4. 运行期间循环检查aof_rewrite_in_progress和aof_current_rewrite_time_sec指标,直到AOF重写结束。
  5. 确认实例AOF重写完成后,在检查2-4步操作,从而保证机器内每个Redis实例AOF重写串行化执行。

危险命令

Redis中包含了很多“危险”的命令,一旦错误使用或者误操作,后果不堪设想,例如如下命令:

  • keys:如果键值较多,存在阻塞Redis的可能性。
  • flushall/flushdb:数据全部被清除。
  • save:如果键值较多,存在阻塞Redis的可能性。
  • debug:例如debug reload会重启Redis。
  • config:config应该交给管理员使用。
  • shutdown:停止Redis。

通过rename-command来重命名命令防止开发使用。

缺陷:

  • 管理员要对自己的客户端进行修改,例如jedis.flushall()操作内部使用的是flushall命令,如果用rename-command后需要修改为新的命令,有一定的开发和维护成本。
  • rename-command配置不支持config set,所以在启动前一定要确定哪些命令需要使用rename-command。
  • 如果AOF和RDB文件包含了rename-command之前的命令,Redis将无法启动,因为此时它识别不了rename-command之前的命令。
  • Redis源码中有一些命令是写死的,rename-command可能造成Redis无法正常工作。例如Sentinel节点在修改配置时直接使用了config命令,如果对config使用rename-command,会造成Redis Sentinel无法正常工作。

最佳实践:

  • 对于一些危险的命令(例如flushall),不管是内网还是外网,一律使用rename-command配置
  • 建议第一次配置Redis时,就应该配置rename-command,因为rename-command不支持config set。
  • 如果涉及主从关系,一定要保持主从节点配置的一致性,否则存在主从数据不一致的可能性。

处理bigkey

  • 字符串类型:体现在单个value值很大,一般认为超过10KB就是bigkey,但这个值和具体的OPS相关。
  • 非字符串类型:哈希、列表、集合、有序集合,体现在元素个数过多。

bigkey无论是空间复杂度和时间复杂度都不太友好。

bigkey的危害

  • 内存空间不均匀(平衡):例如在Redis Cluster中,bigkey会造成节点的内存空间使用不均匀。
  • 超时阻塞:由于Redis单线程的特性,操作bigkey比较耗时,也就意味着阻塞Redis可能性增大。
  • 网络拥塞:每次获取bigkey产生的网络流量较大
    • 假设一个bigkey为1MB,每秒访问量为1000,那么每秒产生1000MB的流量,对于普通的千兆网卡(按照字节算是128MB/s)的服务器来说简直是灭顶之灾。
    • 而且一般服务器会采用单机多实例的方式来部署,也就是说一个bigkey可能会对其他实例造成影响。

发现bigkey

redis-cli --bigkey
# 查看serializedlength大小,是用RDB编码序列化后的长度
debug obejct key

被动收集:

许多开发人员确实可能对bigkey不了解或重视程度不够,但是这种bigkey一旦大量访问,很可能就会带来命令慢查询和网卡跑满问题, 开发人员通过对异常的分析通常能找到异常原因可能是bigkey,这种方式虽然不是被笔者推荐的,但是在实际生产环境中却大量存在, 建议修改Redis客户端,当抛出异常时打印出所操作的key,方便排查bigkey问题。

主动检测:

scan+debug object:如果怀疑存在bigkey,可以使用scan命令渐进的扫描出所有的key,分别计算每个key的serializedlength, 找到对应bigkey进行相应的处理和报警,这种方式是比较推荐的方式。

  • 如果键值个数比较多,scan+debug object会比较慢,可以利用Pipeline机制完成。
  • 对于元素个数较多的数据结构,debug object执行速度比较慢,存在阻塞Redis的可能。
  • 如果有从节点,可以考虑在从节点上执行。

删除bigkey

删除字符串类型耗时:

删除字符串类型耗时

String的删除一般不会造成阻塞。

删除hash、list、set、sorted set四种数据结构不同数量不同元素大小:

删除hash、list、set、sorted set四种数据结构不同数量不同元素大小

删除hash、list、set、sorted set四种数据结构不同数量不同元素大小的耗时:

删除hash、list、set、sorted set四种数据结构不同数量不同元素大小的耗时

sscan、hscan、zscan命令来删除其它的数据结构。

import java.util.*;
import java.util.Map.Entry;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.ScanParams;
import redis.clients.jedis.ScanResult;
public class DeleteBigkey {
	public void delBigHash(String bigKey) {
		Jedis jedis = new Jedis("127.0.0.1", 6379);
		// 游标
		String cursor = "0";
		while (true) {
			ScanResult<Map.Entry<String, String>> scanResult = jedis.hscan(bigKey, cursor, new ScanParams().count(100));
			// 每次扫描后获取新的游标
			cursor = scanResult.getStringCursor();
			// 获取扫描结果
			List<Entry<String, String>> list = scanResult.getResult();
			if (list == null || list.size() == 0) {
				continue;
			}
			String[] fields = getFieldsFrom(list);
			// 删除多个 field
			jedis.hdel(bigKey, fields);
			// 游标为 0 时停止
			if (cursor.equals("0")) {
				break;
			}
		}
		// 最终删除 key
		jedis.del(bigKey);
	}
	/**
	 * 获取 field 数组
	 * 
	 * @param list
	 * @return
	 */
	private String[] getFieldsFrom(List<Entry<String, String>> list) {
		List<String> fields = new ArrayList<String>();
		for (Entry<String, String> entry : list) {
			fields.add(entry.getKey());
		}
		return fields.toArray(new String[fields.size()]);
	}
}

Redis将在4.0版本支持lazy delete free的模式,那时删除bigkey不会阻塞Redis。

寻找热点key

  • 客户端修改代码对key调用进行统计。
  • 代理端如Twemproxy、Codis这些基于代理的Redis分布式架构来做热点统计。
  • 服务器端通过monitor命令统计热点key。
    • Facebook开源的redis-faina正是利用上述原理使用Python语言实现的。
      • redis-cli -p 6380 monitor | head -n 100000 | ./redis-faina.py
    • 为了减少网络开销,以及加快输出缓冲区的消费速度,monitor尽可能在本机执行。
    • monitor命令在高并发条件下,会存在内存暴增和影响Redis性能的隐患,所以此种方法适合在短时间内使用。
    • 只能统计一个Redis节点的热点key,对于Redis集群需要进行汇总统计。
  • 过对机器上所有Redis端口的TCP数据包进行抓取完成热点key的统计
    • 需要一定的开发成本。
      • 但是一些开源方案实现了该功能,例如ELK(ElasticSearch Logstash Kibana)体系下的packetbeat插件,可以实现对Redis、MySQL等众多主流服务的数据包抓取、分析、报表展示。
    • 由于是以机器为单位进行统计,要想了解一个集群的热点key,需要进行后期汇总。

寻找热点key的四种方案

解决热点key问题的三种方案:

  • 拆分复杂数据结构:如果当前key的类型是一个二级数据结构,如哈希类型。如果该哈希元素个数较多,可以考虑将当前hash进行拆分,这样该热点key可以拆分为若干个新的key分布到不同Redis节点上,从而减轻压力。
  • 迁移热点key:以Redis Cluster为例,可以将热点key所在的slot单独迁移到一个新的Redis节点上,但此操作会增加运维成本。
  • 本地缓存加通知机制:可以将热点key放在业务端的本地缓存中,因为是在业务端的本地内存中,处理能力要高出Redis数十倍,但当数据更新时,此种模式会造成各个业务端和Redis数据不一致,通常会使用发布订阅机制来解决类似问题。

分布式锁

单机实现分布式锁:

# To acquire the lock
SET resource_name my_random_value NX PX 30000
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

详细介绍

Redis shell

redis-cli

  • -r
    • 命令重复次数
  • -i
    • 每隔几秒执行一次
# 每一秒输出内存是用量
redis-cli -r 100 -i 1 info | grep used_memory_human
  • -x
    • 从标准输入读取数据作为redis-cli的最后一个参数
echo "world" | redis-cli -x set hello
  • -c
    • 连接Cluster节点时需要使用,方式moved和ask异常
  • -a
    • auth密码
  • –scan和–pattern
    • 扫描指定模式的键,相当于使用scan命令
  • –slave
    • 把客户端模拟成当前的从节点,可以用来获取当前Redis节点的更新操作。
  • –rdb
    • 请求Redis实例生成并发送RDB持久化文件,保存到本地,可以使用它做持久化文件的定期备份。
  • –pipe
    • 将命令封装成Redis协议定义的数据格式,批量发送给Redis执行。
    • 参考《管道》部分
  • –bigkeys
    • 使用scan命令对键进行采样,找到内存占用比较大的key
  • –eval
    • 执行指定lua脚本
  • –latency
    • 检测网络延迟
    • –latency-history分段式显示-i控制间隔
    • –latency-dist统计图表输出
  • –stat
    • 实时获取重要统计信息,显示增量数据。
  • –raw和–no-raw
    • 返回原始格式还是格式化后的结果(中文还是二进制格式)

redis-server

redis-server --test-memory可以用来检测当前操作系统能否稳定地分配指定容量的内存给Redis,通过这种检测可以有效避免因为内存问题造成Redis崩溃

redis-server --test-memory 1024

整个内存检测的时间比较长。

redis-benchmark

参考《benchmark》部分

Pipeline管道

Redis客户端执行一条命令分为如下四个过程:

  1. 发送命令
  2. 命令排队
  3. 命令执行
  4. 返回结果

其中1+4称为Round Trip Time(RTT,往返时间)。

Redis提供了批量操作命令(例如mget、mset等),有效地节约RTT。但大部分命令是不支持批量操作的。

Pipeline(流水线)机制能改善上面这类问题,它能将一组Redis命令进行组装,通过一次RTT传输给Redis,再将这组Redis命令的执行结果按顺序返回给客户端。

Redis命令真正执行的时间通常在微秒级别,所以才会有Redis性能瓶颈是网络这样的说法。

管道传输的含义

管道的基本含义是,客户端可以发送多个请求给服务器,而无需等待答复所有,并最后读取在单个步骤中的答应。

要检查redis的管道,只要开始Redis的实例,然后在终端键入以下命令。

$(echo -en "PING\r\n SET tutorial redis\r\nGET tutorial\r\nINCR visitor\r\nINCR visitor\r\nINCR visitor\r\n"; sleep 10) | nc localhost 6379

+PONG
+OK
redis
:1
:2
:3

在上述例子中,必须使用PING命令检查Redis的连接,之后,已经设定Redis字符串的值命名为tutorial,之后取到key值和增量参访问数的三倍。在结果中,可以检查所有的命令都一次提交给Redis,Redis在单一步骤中给定所有命令的输出。

redis-cli的–pipe选项实际上就是使用Pipeline机制。

管道的好处

这种技术的好处是显着提高协议的性能。获得通过管道范围从5个之中的一个因素的连接提高,localhost至少达到过百倍的网络连接速度。

  • Pipeline执行速度一般比逐条执行要快。
  • 客户端和服务端的网络延时越大,Pipeline的效果越明显。

在不同网络下,10000条set非Pipeline和Pipeline的执行时间对比:

对比

原生批处理与管道对比

  • 原生批量命令是原子的,Pipeline是非原子的。
  • 原生批量命令是一个命令对应多个key,Pipeline支持多个命令。
  • 原生批量命令是Redis服务端支持实现的,而Pipeline需要服务端和客户端的共同实现。

最佳实践

Pipeline虽然好用,但是每次Pipeline组装的命令个数不能没有节制,否则一次组装Pipeline数据量过大, 一方面会增加客户端的等待时间,另一方面会造成一定的网络阻塞,可以将一次包含大量命令的Pipeline拆分成多次较小的Pipeline来完成。

Pipeline只能操作一个Redis实例,但是即使在分布式Redis场景中,也可以作为批量操作的重要优化手段。

分区

分区是一种将数据分成多个Redis的情况下,让每一个实例将只包含关键字的子集的过程。

分区的好处

  • 它允许更大的数据库,使用的多台计算机的内存的总和。如果不分区,一台计算机有限的内存可以支持有限的数量。
  • 它允许以大规模的计算能力,以多个内核和多个计算机,以及网络带宽向多台计算机和网络适配器在一起使用。

分区的缺点

  • 通常不支持涉及多个按键的操作。例如,不能两个集合之间执行交叉点,如果它们被存储在被映射到不同的Redis实例中的键。
  • 涉及多个键的Redis事务不能被使用。
  • 分区粒度是键,所以它不可能将分片数据集用一个硕大的键在一个非常大的有序集合。
  • 当分区时,数据处理比较复杂,比如要处理多个RDB/AOF文件,使数据备份,需要从多个实例和主机聚集持久性文件。
  • 添加和删除的能力可能很复杂。比如Redis集群支持有加,并在运行时删除节点不支持此功能的能力,但其他系统,如客户端的分区和代理的数据大多是透明平衡。有一个叫Presharding技术有助于解决这方面的问题。

分区的类型

redis提供两种类型的分区。假设有四个的Redis实例R0,R1,R2,R3和代表用户喜欢的用户很多键: user:1, user:2, … 等等

范围分区

范围分区被映射对象转化为具体的Redis实例的范围内实现。假定在本例中用户ID0〜ID10000将进入实例R0,而用户形成ID10001至20000号将进入实例R1等等。

散列分区

在这种类型的分区,一个散列函数(例如,模数函数)被用于转换键成数字,然后数据被存储在不同地方 - 它们是不同redis的实例。

使用场景

  • 关系型数据库的缓存存在
  • 做任务队列
  • 大量数据集合运算
  • 大量数据排序
    • 排行榜系统
  • 小型消息队列

字符串

  • 缓存
  • 计数器应用
  • Session共享
  • 计时限制访问频率

哈希

  • 关系数据库的缓存

列表

  • 消息队列
    • lpush+brpop实现阻塞队列。
  • 文章列表
    • 哈希保存文章体
      • 获取的文章较多会使用多次hgetall操作,建议使用Pipeline
      • 或者转换为字符串使用mget批量获取
    • 列表保存用户文章,可以进行分页查询
      • lrange分页在两端性能很好,如果列表很大中间的性能不好,可以对列表进行二级拆分
      • 使用quicklist内部编码可以使获取中间范围数据也很高效

lpush + lpop = Stack(栈)
lpush + rpop = Queue(队列)
lpush + ltrim = Capped Collection(有限集合)
lpush + brpop = Message Queue(消息队列)

# 文章列表
# 哈希存储文章
hmset article:1 title xx timestamp 1476536196 content xxx
...
hmset article:k title yy timestamp 1476546196 content yyy
# 用户文章列表
lpush user:1:articles article:1 article:3
...
lpush user:k:articles article:5
# 分页获取文章列表
articles = lrange user:1:articles 0 9
for article in {aritcles}
    hgetall {article}

集合

  • 标签(人群标签等)
    • 给人添加标签同时给标签添加人类似的维护,应该在一个事务中执行。

sadd = Tagging(标签)
spop / srandmember = Random item(生成随机数,如抽奖)
sadd + sinner = Social Graph(社交需求)

# 给用户加标签
sadd user:1:tags tag1 tag2 tag5
sadd user:2:tags tag2 tag3 tag5
# 给标签加用户
sadd tag:1:users user:1 user:3
sadd tag:2:users user:1 user:2 user:3
# 删除用户下的标签
srem user:1:tags tag1 tag5
# 删除标签下的用户
srem tag1:users user:1
srem tag5:users user:1
# 计算共同兴趣
sinter user:1:tags user:2:tags

有序集合

  • 排行榜系统
# 排行榜
# 初始用户
zadd user:ranking:20160316 3 mike
# 增加赞
zincrby user:ranking:20160316 1 mike
# 删除违法用户
zrem user:ranking:20160316 mike
# 获取排行
zrevrangebyrank user:ranking:20160316 0 9
# 获取用户信息、分数和排名
hgetall user:info:tom
zscore user:ranking:2016:03:16 mike
zrank user:ranking:2016:03:16 mike

列表、集合、有序集合的比较

数据结构 是否允许重复元素 是否有序 有序实现方式 应用场景
列表 索引下标 时间轴、消息队列等
集合 标签、社交等
有序集合 分值 排行榜系统、社交等

启动和连接

# 后端启动需要配置daemonize yes
redis-server ./redis.confg
service redis-server start
# --raw命令获取中文不会返回unicode编码
redis-cli -h 127.0.0.1 -p 6379 --raw
# 正常停止redis
redis-cli -h 127.0.0.1 -p 6379 shutdown

Redis缓存设计

缓存的收益和成本

收益如下:

  • 加速读写:因为缓存通常都是全内存的(例如Redis、Memcache),而存储层通常读写性能不够强悍(例如MySQL),通过缓存的使用可以有效地加速读写,优化用户体验。
  • 降低后端负载:帮助后端减少访问量和复杂计算(例如很复杂的SQL语句),在很大程度降低了后端的负载。

成本如下:

  • 数据不一致性:缓存层和存储层的数据存在着一定时间窗口的不一致性,时间窗口跟更新策略有关。
  • 代码维护成本:加入缓存后,需要同时处理缓存层和存储层的逻辑,增大了开发者维护代码的成本。
  • 运维成本:以Redis Cluster为例,加入后无形中增加了运维成本。

缓存的使用场景基本包含如下两种:

  • 开销大的复杂计算:以MySQL为例子,一些复杂的操作或者计算(例如大量联表操作、一些分组计算),如果不加缓存,不但无法满足高并发量,同时也会给MySQL带来巨大的负担。
  • 加速请求响应:即使查询单条后端数据足够快(例如select*from tablewhere id=),那么依然可以使用缓存,以Redis为例子,每秒可以完成数万次读写,并且提供的批量操作可以优化整个IO链的响应时间。

缓存更新策略

三种常见更新策略的对比

LRU/LFU/FIFO算法剔除

使用场景:

剔除算法通常用于缓存使用量超过了预设的最大值时候,如何对现有的数据进行剔除。

例如Redis使用maxmemory-policy这个配置作为内存最大值后对于数据的剔除策略。

一致性:

要清理哪些数据是由具体算法决定,开发人员只能决定使用哪种算法,所以数据的一致性是最差的。

维护成本:

算法不需要开发人员自己来实现,通常只需要配置最大maxmemory和对应的策略即可。开发人员只需要知道每种算法的含义,选择适合自己的算法即可。

超时剔除

使用场景:

超时剔除通过给缓存数据设置过期时间,让其在过期时间后自动删除,例如Redis提供的expire命令。

如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期后,再从真实数据源获取数据,重新放到缓存并设置过期时间。 例如一个视698频的描述信息,可以容忍几分钟内数据不一致,但是涉及交易方面的业务,后果可想而知。

一致性:

一段时间窗口内(取决于过期时间长短)存在一致性问题,即缓存数据和真实数据源的数据不一致。

维护成本:

维护成本不是很高,只需设置expire过期时间即可,当然前提是应用方允许这段时间可能发生的数据不一致。

主动更新

使用场景:

应用方对于数据的一致性要求高,需要在真实数据更新后,立即更新缓存数据。例如可以利用消息系统或者其他方式通知缓存更新。 一致性:

一致性最高,但如果主动更新发生了问题,那么这条数据很可能很长时间不会更新,所以建议结合超时剔除一起使用效果会更好。

维护成本:

维护成本会比较高,开发者需要自己来完成更新,并保证更新操作的正确性。

最佳实践

  • 低一致性业务建议配置最大内存和淘汰策略的方式使用。
  • 高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新出了问题,也能保证数据过期时间后删除脏数据。

缓存粒度控制

是缓存粒度问题,究竟是缓存全部属性还是只缓存部分重要属性呢?

缓存全部数据和部分数据对比

通用性:

缓存全部数据比部分数据更加通用,但从实际经验看,很长时间内应用只需要几个重要的属性。

空间占用:

  • 全部数据会造成内存的浪费。
  • 全部数据可能每次传输产生的网络流量会比较大,耗时相对较大,在极端情况下会阻塞网络。
  • 全部数据的序列化和反序列化的CPU开销更大。

代码维护:

全部数据的优势更加明显,而部分数据一旦要加新字段需要 修改业务代码,而且修改后通常还需要刷新缓存数据。

穿透优化

缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,通常出于容错的考虑,如果从存储层查不到数据则不写入缓存层。

  1. 缓存层不命中。
  2. 存储层不命中,不将空结果写回缓存。
  3. 返回空结果。

缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。

缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕掉。

通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。

造成缓存穿透的基本原因有两个:

  • 自身业务代码或者数据出现问题
  • 一些恶意攻击、爬虫等造成大量空命中。

解决:

缓存空对象和布隆过滤器方案对比

缓存空对象

当第2步存储层不命中后,仍然将空对象保留到缓存层中,之后再访问这个数据将会从缓存中获取,这样就保护了后端数据源。

缓存空值应对穿透问题

缓存空对象会有两个问题:

  • 空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
  • 缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。
    • 存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。

布隆过滤器拦截

在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截。

使用布隆过滤器应对穿透问题

例如:一个推荐系统有4亿个用户id,每个小时算法工程师会根据每个用户之前历史行为计算出推荐数据放到存储层中,但是最新的用户由于没有历史行为, 就会发生缓存穿透的行为,为此可以将所有推荐数据的用户做成布隆过滤器。如果布隆过滤器认为该用户id不存在,那么就不会访问存储层,在一定程度保护了存储层。

可以利用Redis的Bitmaps实现布隆过滤器,redis-lua-scaling-bloom-filter

这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。

无底洞优化

为了满足业务要求添加了大量新节点,但是发现性能不但没有好转反而下降了,当时将这种现象称为缓存的“无底洞”现象。

无底洞问题分析:

  • 客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随着节点的增多,耗时会不断增大。
  • 网络连接数变多,对节点的性能也有一定影响。

分布式存储批量操作多次网络时间:

分布式存储批量操作多次网络时间

当一个节点存储批量操作只需一次网络时间:

当一个节点存储批量操作只需一次网络时间

常见的IO优化思路:

  • 命令本身的优化,例如优化SQL语句等。
  • 减少网络通信次数。
  • 降低接入成本,例如客户端使用长连/连接池、NIO等。

四种批量操作解决方案对比

  • 串行命令
    • 客户端n次get:n次网络+n次get命令本身。
  • 串行IO
    • 客户端k(根据slot分组)次pipeline get:k次网络+k次mget命令本身。
  • 并行IO
    • 客户端k次pipeline get:k次网络+k次mget命令本身(多线程执行)。
  • hash_tag实现
    • 客户端1次mget:1次网络+1次mget命令本身。

串行IO代码示例:

import redis.clients.jedis.JedisCluster;
import redis.clients.jedis.JedisClusterConnectionHandler;
import redis.clients.jedis.JedisClusterInfoCache;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisSlotBasedConnectionHandler;
import redis.clients.util.JedisClusterCRC16;

public class PipelineGet {
	Map<String, String> serialIOMget(List<String> keys) {
		// 结果集
		Map<String, String> keyValueMap = new HashMap<String, String>();
		// 属于各个节点的 key 列表 ,JedisPool 要提供基于 ip 和 port 的 hashcode 方法
		Map<JedisPool, List<String>> nodeKeyListMap = new HashMap<JedisPool, List<String>>();
		// 遍历所有的 key
		for (String key : keys) {
			// 使用 CRC16 本地计算每个 key 的 slot
			int slot = JedisClusterCRC16.getSlot(key);
			// 通过 jedisCluster 本地 slot->node 映射获取 slot 对应的 node
            // 新版本API有变化
			JedisPool jedisPool = jedisCluster.getConnectionHandler().getJedisPoolFromSlot(slot);
			// 归档
			if (nodeKeyListMap.containsKey(jedisPool)) {
				nodeKeyListMap.get(jedisPool).add(key);
			} else {
				List<String> list = new ArrayList<String>();
				list.add(key);
				nodeKeyListMap.put(jedisPool, list);
			}
		}
        // 这里改成多线程就是并行I/O,时间复杂度是最慢的I/O耗时
		// 从每个节点上批量获取,这里使用 mget 也可以使用 pipeline
		for (Entry<JedisPool, List<String>> entry : nodeKeyListMap.entrySet()) {
			JedisPool jedisPool = entry.getKey();
			List<String> nodeKeyList = entry.getValue();
			// 列表变为数组
			String[] nodeKeyArray = nodeKeyList.toArray(new String[nodeKeyList.size()]);
			// 批量获取,可以使用 mget 或者 Pipeline
			List<String> nodeValueList = jedisPool.getResource().mget(nodeKeyArray);
			// 归档
			for (int i = 0; i < nodeKeyList.size(); i++) {
				keyValueMap.put(nodeKeyList.get(i), nodeValueList.get(i));
			}
		}
		return keyValueMap;
	}
}

雪崩优化

缓存雪崩:

由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况。

预防和解决缓存雪崩问题,可以从三个方面进行着手:

  • 保证缓存层服务高可用性。
  • 依赖隔离组件为后端限流并降级
  • 提前演练
    • 在项目上线前,演练缓存层宕掉后,应用以及后端的负载情况以及可能出现的问题,在此基础上做一些预案设定。

降级:

无论是缓存层还是存储层都会有出错的概率,可以将它们视同为资源。 作为并发量较大的系统,假如有一个资源不可用,可能会造成线程全部阻塞(hang)在这个资源上,造成整个系统不可用。

降级机制在高并发系统中是非常普遍的:比如推荐服务中,如果个性化推荐服务不可用,可以降级补充热点数据,不至于造成前端页面是开天窗。 在实际项目中,我们需要对重要的资源(例如Redis、MySQL、HBase、外部接口)都进行隔离,让每种资源都单独运行在自己的线程池中, 即使个别资源出现了问题,对其他服务没有影响。但是线程池如何管理,比如如何关闭资源池、开启资源池、资源池阀值管理,这些做起来还是相当复杂的。

这里推荐一个Java依赖隔离工具Hystrix

热点key重建优化

有两个问题如果同时出现,可能就会对应用造成致命的危害:

  • 当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。
  • 重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的SQL、多次IO、多个依赖等。

在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。

  • 互斥锁(mutex key):这种方案思路比较简单,但是存在一定的隐患,如果构建缓存过程出现问题或者时间较长,可能会存在死锁和线程池阻塞的风险,但是这种方法能够较好地降低后端存储负载,并在一致性上做得比较好。
  • “永远不过期”:这种方案由于没有设置真正的过期时间,实际上已经不存在热点key产生的一系列危害,但是会存在数据不一致的情况,同时代码复杂度会增大。

两种热点key的解决方法

互斥锁

此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可

String get(String key) {
    //  从 Redis 中获取数据
    String value = redis.get(key);
    //  如果 value 为空,则开始重构缓存
    if (value  == null) {
        //  只允许一个线程重构缓存,使用 nx ,并设置过期时间 ex
        String mutexKey = "mutext:key:" + key;
        if (redis.set(mutexKey, "1", "ex 180", "nx")) {
            //  从数据源获取数据
            value = db.get(key);
            //  回写 Redis ,并设置过期时间
            redis.setex(key, timeout, value);
            //  删除 key_mutex
            redis.delete(mutexKey);
        } 
        //  其他线程休息 50 毫秒后重试
        else {
            Thread.sleep(50);
            get(key);
        }
    }
    return value;
}
  1. 从Redis获取数据,如果值不为空,则直接返回值;否则执行下面的2和3步骤。
  2. 如果set(nx和ex)结果为true,说明此时没有其他线程重建缓存,那么当前线程执行缓存构建逻辑。
  3. 如果set(nx和ex)结果为false,说明此时已经有其他线程正在执行构建缓存的工作,那么当前线程将休息指定时间(例如这里是50毫秒,取决于构建缓存的速度)后,重新执行函数,直到获取到数据。

永不过期

“永远不过期”包含两层意思:

  • 从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期。
  • 从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。

从实战看,此方法有效杜绝了热点key产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况,这取决于应用方是否容忍这种不一致。

key名设计

与MySQL等关系型数据库不同的是,Redis没有命令空间,而且也没有对键名有强制要求,但设计合理的键名,有利于防止键冲突和项目的可维护性,

比较推荐的方式是使用业务名:对象名:id:[属性]作为键名。

例如MySQL的数据库名为vs,用户表名为user,那么对应的键可以用”vs:user:1”,”vs:user:1:name”来表示,如果当前Redis只被一个业务使用,甚至可以去掉vs。
如果键名比较长,例如”user:{uid}:friends:message:{mid}”,可以在能描述含义的前提下适当减少键的长度,例如采用缩写形式,从而减少由于键过长的内存浪费。

set b2c_user:id:1:name zhangsan
keys b2c_user:id:1*
get b2c_user:id:1:name

存储方式

  • 原生字符串类型:每个属性一个键
    • 优点:简单直观,每个属性都支持更新操作
    • 缺点:占用过多的键,内存占用量较大,同时用户信息内聚性较差,所以一般不会在生产环境中使用
  • 序列化字符串类型:将数据序列化后存储
    • 优点:简化编程,如果合理使用序列化可以提高内存使用率
    • 缺点:序列化和反序列化有一定开销,同时每次更新属性都需要把全部数据都取出更新后再存进Redis
  • 哈希类型:每个数据属性使用一个field-value,但是只拥有1个键保存
    • 优点:简单直观,使用合理可以减少内存空间的使用
    • 缺点: 要控制哈希在ziplist和hashtable两种内部编码的转换,hashtable会消耗更多的内存

Linux配置优化

内存分配配置

Redis建议把/etc/sysctl的vm.overcommit_memory设置为1,是为了让fork操作能够在低内存下也执行成功。

cat /proc/sys/vm/overcommit_memory
0
echo "vm.overcommit_memory=1" >> /etc/sysctl.conf
sysctl vm.overcommit_memory=1
  • Redis设置合理的maxmemory,保证机器有20%~30%的闲置内存。
  • 集中化管理AOF重写和RDB的bgsave。
  • 设置vm.overcommit_memory=1,防止极端情况下会造成fork失败。

内存交换

如果Linux>3.5,vm.swapniess=1,否则vm.swapniess=0,从而实现如下两个目标:

  • 物理内存充足时候,使Redis足够快。
  • 物理内存不足时候,避免Redis死掉(如果当前Redis为高可用,死掉比阻塞更好)。
echo vm.swappiness=1 >> /etc/sysctl.conf

THP

echo never >  /sys/kernel/mm/transparent_hugepage/enabled

在/etc/rc.local中追加echo never>/sys/kernel/mm/transparent_hugepage/enabled,重启后依然生效。

OOM killer

# 降低参数保证Redis进程权重低,降低被杀掉几率。
for redis_pid in $(pgrep -f "redis-server")
do
    echo -17 > /proc/${redis_pid}/oom_adj
done

高可用集群下,被杀死比僵死更好。

NTP

使用NPT服务器同步时钟。

# 每小时同步
0 * * * * /usr/sbin/ntpdate ntp.xx.com > /dev/null 2>&1

ulimit

调大文件句柄数。

ulimit –a
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                       (-n) 1024
pipe size            (512 bytes, -p) 8…

ulimit –Sn {max-open-files}

TCP backlog

Redis默认的tcp-backlog(TCP连接队列)值为511,可以通过修改配置tcp-backlog进行调整

echo 511 > /proc/sys/net/core/somaxconn

密码

这种密码机制能在一定程度上保护Redis的安全,但是在使用requirepass时候要注意一下几点:

  • 密码要足够复杂(64个字节以上),因为Redis的性能很高,如果密码比较简单,完全是可以在一段时间内通过暴力破解来破译密码。
  • 如果是主从结构的Redis,不要忘记在从节点的配置中加入masterauth(master的密码)配置,否则会造成主从节点同步失效。
  • auth是通过明文进行传输的,所以也不是100%可靠,如果被攻击者劫持也相当危险。

bind绑定多个IP

bind指定的是Redis和哪个网卡进行绑定,和客户端是什么网段没有关系。

# 2.8 以后可以绑定到多个特定的地址上
bind 127.0.0.1 192.168.3.3

配置统计字典

info系统状态说明

  • info:部分Redis系统状态统计信息。
  • info all:全部Redis系统状态统计信息。
  • info section:某一块的系统状态统计信息,其中section可以忽略大小写。

info命令所有的section:

info命令所有的section

Server

info Server模块统计信息

Clients

info Clients模块统计信息

Memory

info Memory模块统计信息

Persistence

info Persistence模块统计信息

Stats

info Stats模块统计信息

Replication

info Replication模块统计信息

CPU

info CPU模块统计信息

Commandstats

info Commandstats模块统计信息

Cluster

info Cluster模块统计信息

Keyspace

info Keyspace模块统计信息

standalone配置说明和分析

总体配置

总体配置

最大内存及策略

内存相关配置

AOF相关配置

AOF相关配置

RDB相关配置

RDB相关配置

慢查询配置

慢查询相关配置

数据结构优化配置

数据结构优化相关配置

复制相关配置

复制相关配置

客户端相关配置

客户端相关配置

安全相关配置

安全相关配置

Sentinel配置说明和分析

Redis Sentinel节点配置说明

Cluster配置说明和分析

Redis Cluster配置说明


以上资源来源于传智播客Redis视频教程、Redis快速入门、《Redis设计与实现》、《Redis开发与运维》

Search

    Post Directory