基本介绍

简介

Redis: REmote DIctionary Service,远程字典服务

Redis线程:Redis只有处理客户端请求(执行命令)是单线程,其他很多地方是多线程,如:后台删除对象、通过Redis模块实现阻塞命令、网络I/O(6.0)等

使用单线程的原因:

  1. 多线程不一定比单线程快,有创建和销毁的过程
  2. 多线程会涉及数据的安全性问题

Redis快的原因:

  1. Redis是纯内存结构,避免磁盘I/O的操作
  2. Redis命令处理的核心模块是单线程,减少锁竞争,减少线程创建和销毁过程,减少线程上下问的切换
  3. 采用了I/O多路复用机制,提高并发效率

安装

安装:

// 下载
wget https://labfile.oss.aliyuncs.com/courses/3368/redis-5.0.5.tar.gz

// 解压
tar -zxvf redis-5.0.5.tar.gz

// 安装
cd redis-5.0.5/src
make && make install PREFIX=/home/project/redis-5.0.5

// 配置
vim /home/project/redis-5.0.5/redis.conf
// 修改配置,后台运行redis
daemonize no => daemonize yes

// 启动redis服务
/home/project/redis-5.0.5/bin/redis-server /home/project/redis5.0.5/redis.conf
// 蓝桥云课环境启动redis方法
sudo redis-server /etc/redis/redis.conf

// ubuntu仅安装redis-cli
sudo apt-get install redis-tools

速度测试

redis-benchmark - 吞吐量测试命令

  • -h - hostname,主机地址,默认为127.0.0.1
  • -p - port,端口号,默认为6379
  • -s - socket,套接字,会覆盖主机名和端口
  • -a - password,redis登陆认证密码
  • -t - tests,测试以逗号分割的测试列表,名称与输出名称相同
  • -n - requests,请求的数量,默认为10万
  • -q - quiet,仅输出每秒次数

系统命令

  • redis-server --versionredis-server -v - 查看redis-server版本
  • redis-cli --versionredis-cli -v - 查看redis-cli版本
  • info - redis-cli中输入查看相关信息

数据类型

数据类型简介

截止到6.0.6,一共支持9种类型

  1. Binary-safe strings(二进制安全字符串)
  2. Lists(列表)
  3. Sets(集合)
  4. Sorted sets(有序集合)
  5. Hashes(哈希)
  6. Bit arrays(or simply bitmaps)(位图)
  7. HyperLogLogs
  8. Geospatial
  9. Streams

前五种为基础类型,后四种是基于前五种基本类型及特定算法来实现的特殊类型

数据结构

Redis中数据类型的存储,都会进行包装,每创建一个key-value值,都会创建两个对象,一个键对象,一个值对象,值对象会被包装成redisObject对象,然后将键对象和值对象通过dictEntry对象封装

typedef struct dictEntry {
    void *key; //指向key,即sds
    union {
        void *val; //指向value,即redisObject
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next; //指向下一个key-value键值对(哈希值相同的键值对会形成一个链表,从而解决哈希冲突问题)
}

redisObject对象定义为:

typedef struct redisObject{
    unsigned type:4; // 对象类型(4位=0.5字节)
    unsigned encoding:4; // 编码(4位=0.5字节)
    unisgned lru:LRU_BITS; // 记录对象最后一次被应用程序访问的时间(24位=3字节)
    int refcount; // 引用计数。等于0时表示可以被垃圾回收(32位=4字节)
    void *ptr; // 指向底层实际的数据存储结构,如:sds、quicklist等(8字节)
}robj;

类型和编码

type 和 encoding 用来记录当前的value属于什么基本数据类型,以及采用了哪种数据结构进行存储

type 属性表示对象类型

类型属性 描述 type命令返回值
REDIS_STRING 字符串对象 string
REDIS_LIST 列表对象 list
REDIS_HASH 哈希对象 hash
REDIS_SET 集合对象 set
REDIS_ZSET 有序集合对象 zset

encoding 属性表示对象编码方式

编码属性 描述 object encoding 命令返回值
OBJ_ENCODING_INT 使用整数的字符串对象 int
OBJ_ENCODING_EMBSTR 使用embstr编码实现的字符串对象 embstr
OBJ_ENCODING_RAW 使用raw编码实现的字符串对象 raw

说明:

  • int编码:如果字符串对象存储的是整型,且能用8个字节的long类型表示,就会用int编码存储,redisObject中的ptr指针也会替换为long类型
  • embstr编码:当储存的是字符串且长度小于44(redis3.2之前是39),会选择embstr编码来存储
  • raw编码:当存储的是字符串且长度大于44时,会用raw来存储

embstr编码长度的含义:

  • 在embstr编码中,redisObject和sds是连续的一块空间,Redis限制长度为64个字节,其中redisObject固定占用16个字节,剩余48个字节
  • redis3.2版本以前,sds占用8个字节,加上字符串末尾的\0,所以只剩39个字节
  • redis3.2版本以后,embstr采用sdshdr8来存储,sdshdr8只占用3个字节(len、alloc、flag各占用一个字节),加上末尾的\0,所以剩余44个字节

注意:

  • embstr编码字符串是只读的,如果对其修改,会重新分配内存,并且编码转换为raw
  • 编码一旦升级(int->embstr->raw),无法进行回退

命令:

  • type 键名 - 获取键的类型
  • object encoding 键名 - 获取键的存储方式

string 二进制安全字符串

SDS简介

Redis自己编写了一个新的数据结构来表示字符串,称为简单动态字符串(Simple dynamic string),简称sds

优点:不会由于字符串一直占用着空间而导致内存泄漏,也不会由于前面的字符串扩充覆盖到后面的字符串而造成缓存区溢出

SDS结构

老版本sds结构定义:

struct sdshdr{
    int len; // 记录buf数组已使用的长度,即SDS的长度(不包含末尾的`\0`)
    int free; // 记录bug数组中未使用的长度
    char buf[]; // 字节数组,用来保存字符串
}

3.2版本之后进行了优化,分为了sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,分别用来存储32 字节(2^5),256 字节(2^8),64KB(2^16),4GB 大小(2^32)以及 2^64 大小的字符串

因为目前版本 key 和 value 都限制了最大 512MB,所以 sdshdr64 暂时并未使用到

sdshdr5 只被应用在了 Redis 的 key 中,value 中不会被使用到,因为 sdshdr5 和其它类型也不一样,其并没有存储未使用空间,所以比较适用于使用大小固定的场景(比如 key 值)

//定义了一个char 指针
typedef char *sds;

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
//lsb 代表最低有效位的意思,msb代表最高有效位
//__attribute__ ((__packed__)) 代表structure 采用手动对齐的方式。
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* 已经使用的长度 */
    uint8_t alloc; /* 分配的长度,等于buf[]的总长度-1,因为buf有包括一个/0的结束符 */
    unsigned char flags; /* 最低三位表示类型,0-4,前五位未使用 */
    char buf[]; /* 实际的字符串 */
};
// 与上面的变化只有len和alloc, 就是长度不同而已
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len;
    uint32_t alloc;
    unsigned char flags;
    char buf[];
};

struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len;
    uint64_t alloc;
    unsigned char flags;
    char buf[];
};

SDS命令

  • incr 键名 - 将键对应的值加1,只适用于value为整数的情况
  • decr 键名 - 将键对应的值减1,只适用于value为整数的情况
  • incrby 键名 n - 将键对应的值加n,只适用于value为整数的情况
  • decrby 键名 n - 将键对应的值减n,只适用于value为整数的情况

list 列表

列表简介

  • Redis3.2之前,列表有两种存储结构:linkedlist(双端列表) 和 ziplist(压缩列表)
  • Redis3.2之后,列表对象底层只有一种存储结构:quicklist(快速列表)

查看列表底层结构

编码属性 描述 object encoding命令返回值
OBJ_ENCODING_LINKEDLIST 使用linkedlist实现列表对象 linkedlist
OBJ_ENCODING_ZIPLIST 使用ziplist实现列表对象 ziplist
OBJ_ENCODING_QUICKLIST 使用quicklist实现列表对象 quicklist

ziplist 和 linkedlist 的选择:

使用ziplist,需要同时满足以下两个条件:

  • 列表对象保存的所有字符串元素的长度都小于64字节,可以使用list-max-ziplist-value进行修改
  • 列表对象保存的元素数量小于512个,可以使用list-max-ziplist-entries进行修改

压缩列表ziplist

压缩列表简介

  • ziplist 是一种节省内存的数据结构
  • ziplist 是一块连续内存块的顺序型数据结构
  • ziplist 是由多个entry组成的,每个entry可以保存一个字节数组或者整数值
  • ziplist 和双端列表(如likedlist)区别是不存储前后节点的指针(通常占8个字节),而是维护了上一个节点和当前节点的长度
  • ziplist 有连锁更新的问题。当多个节点的长度位接近254(250-253)的时候,下一个节点存储上一个节点只需要一个字节,如果插入一个新节点大于254,则下一个节点存储上一个节点的位置会由一个字节变为5个字节,以此类推。

压缩列表属性说明

属性 类型 长度 说明
zlbytes uint32_t 4字节 记录压缩列表占用的字节数,包括自己的四个字节
zltail uint32_t 4字节 记录压缩列表末节点距离压缩列表起始地址的字节数(计算尾节点的地址,注意是尾节点的开始距离ziplist的开始的字节数)
zllen uint16_t 2字节 记录压缩列表包含的节点数量,当长度超过65535时,存储为65535,此时需要遍历计算真实的节点数量
entry 节点 - 压缩列表中的节点,存储实际数据
zlend uint8_t 1字节 特殊字符0xff,标记压缩列表的末端

压缩列表结构

ziplist的结构:

<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
  • zlbytes、zltail、zllen 为 ziplist 的 head 部分;
  • entry 为 ziplist 的 entries 部分,每一个 entry 代表一个数据;
  • 最后 zlend 表示 ziplist 的 end 部分。

entry的结构:

<prevlen> <encoding> <entry-data>

prevlen

  • 存储了前一个entry的长度,可以计算出前一个entry的地址,进程从后向前遍历,prevlen长度可能是1或5
  • 当链表的前一个entry占用字节小于254,则用1个字节表示
  • 当链表的前一个entry占用字节大于或等于254时,用5个字节表示,第一个字节为254,后四个字节表示前一个entry的真实占用字节数
  • prevlen只取到254是因为255是ziplist的zlend固定为255,防止冲突

encoding

  • 存储了当前entry的数据类型及长度,encoding的长度为1字节、2字节或5字节
  • encoding第一个字节来确定当前entry存储的是整数还是字节数组,当为整数时,第一个字节前两位总是11,其他情况则是字节数组

encoding存储为整型时,第一个字节的含义(前两位总为11):

编码 长度 entry 保存的数据
11000000 1字节 int16_t 类型整数
11010000 1字节 int32_t 类型整数
11100000 1字节 int64_t 类型整数
11110000 1字节 24 位有符号整数
11111110 1字节 8 位有符号整数
1111xxxx 1字节 xxxx 代表区间 0001-1101,存储了一个介于 0-12 之间的整数,此时 entry-data 属性被省略

注意:xxxx只能取值0001-1101之间,因为0000、1110、1111被使用了。而且因为0是一个常用的数,所以将0001-1101都减去1,表示为0-12

encoding存储为字节数组时,用00、01、10来表示字节数组的长度:

编码 长度 entry保存的数据
00pppppp 1字节 长度小于等于 63 字节(6 位)的字节数组
01pppppp qqqqqqqq 2字节 长度小于等于 16383 字节(14 位)的字节数组
10000000 qqqqqqqq rrrrrrrr ssssssss tttttttt 5字节 长度小于等于 2 的 32 次方减 1 (32 位)的字节数组,其中第 1 个字节的后 6 位设置为 0,暂时没有用到,后面的 32 位(4 个字节)存储了数据

entry-data

  • entry-data 存储的是数据的本身
  • 存储0-12时,数据保存在encoding中,此时entry-data会被省略

linkedlist 双端列表

双端列表简介

  • 每个节点都会存储指向上一个节点和下一个节点的指针
  • 每个节点之间的空间不连续,会造成空间碎片
  • linkedlist是一个封装的list对象,list由多个listNode组成

双端列表结构

listNode是一个list对象

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

listNode结构

typedef struct listNode {
    struct listNode *prev; //前一个节点
    struct listNode *next; //后一个节点
    void *value; //值(字符串对象)
} listNode;

当next指向NULL时,表示已经到了列表末尾了

quicklist 快速列表

快速列表简介

  • quicklist存储了一个双向列表,每个节点就是一个ziplist
  • quicklist实际上就是ziplist和linkedlist的结合
  • quicklist的每个节点都是一个quicklistNode对象

快速列表结构

quicklist对象结构:

typedef struct quicklist {
    quicklistNode *head; //列表头节点
    quicklistNode *tail; //列表尾节点
    unsigned long count; //ziplist中一共存储了多少元素,即:每一个quicklistNode内的count相加
    unsigned long len; //双向链表的长度,即quicklistNode的数量
    int fill : 16; //填充因子
    unsigned int compress : 16; //压缩深度 0-不压缩
} quicklist;

quicklistNode对象结构定义:

typedef struct quicklistNode {
    struct quicklistNode *prev; //前一个节点
    struct quicklistNode *next; //后一个节点
    unsigned char *zl; //当前指向的ziplist或者quicklistLZF
    unsigned int sz; //当前ziplist占用字节
    unsigned int count : 16; //ziplist中存储的元素个数,16字节(最大65535个)
    unsigned int encoding : 2; //是否采用了LZF压缩算法压缩节点 1:RAW 2:LZF
    unsigned int container : 2; //存储结构,NONE=1, ZIPLIST=2
    unsigned int recompress : 1; //当前ziplist是否需要再次压缩(如果前面被解压过则为true,表示需要再次被压缩)
    unsigned int attempted_compress : 1; //测试用 
    unsigned int extra : 10; //后期留用
} quicklistNode;
  • compress 压缩深度,可以通过list-compress-depth控制,默认为0不压缩,1表示首尾第一个元素不压缩,以此类推
  • zl 默认指向ziplist,如果节点被压缩了,则指向一个quicklistLZF对象

quicklistLZF对象

typedef struct quicklistLZF {
    unsigned int sz; // LZF大小,占用4字节
    char compressed[]; //被压缩的内容,占用N字节
} quicklistLZF;

命令

  • lpush key value1 value2 - 将一个或多个value插入到key的头部,key不存在则创建,value2在value1之前
  • lpushx key value1 value2 - 将一个或多个value插入到key的头部,key不存在则不做任何处理,value2在value1之前
  • lpop key - 移除并返回key列表的头部值
  • rpush key value1 value2 - 将一个或多个value插入到key的尾部,key不存在则创建,value2在value1之后
  • rpushx key value1 value2 - 将一个或多个value插入到key的尾部,key不存在则不做任何处理,value2在value1之后
  • rpop key - 移除并返回key列表的尾部值
  • llen key - 返回key列表的长度
  • lindex key index - 返回列表key中下标为index的元素,index为正数(从0开始)表示从头算,index为负数(-1开始)表示从尾算
  • lrange key start stop - 返回列表key中下标[start, stop]之间的元素,包括start和stop
  • lset key index value - 将key列表中下标为index的值改为value,key不存在或index越界会报错
  • ltrim key start end - 截取[start, end]的值并将其更新为key的值

hash 哈希对象

哈希简介

  • hash对象本身是一个key-value存储结构
  • hash底层存储结构是ziplist(压缩列表)和hashtable(哈希表)
  • hash对象中的ziplist结构中的entry是key,value紧挨在一起的
编码属性 描述 object encoding命令返回值
OBJ_ENCODING_ZIPLIST 使用压缩列表实现哈希对象 ziplist
OBJ_ENCODING_HT 使用字典实现哈希对象 hashtable

哈希对象满足以下条件会使用ziplist

  • 哈希对象中所有键对总长度小于64(可以使用hash-max-ziplist-value修改)个字节
  • 哈希对象中键值对数量小于512(可以使用hash-max-ziplist-entries修改)

哈希结构

hash结构

注意:hash对象的key-value是从dictEntry对象进行包装的。hash表就是dictEntry对象进行再一次包装得到的。

字典是redis数据结构redisObject在hash类型中value指向的结构,定义如下

typedef struct dict {
    dictType *type; //字典类型的一些特定函数
    void *privdata; //私有数据,type中的特定函数可能需要用到
    dictht ht[2]; //哈希表(注意这里有2个哈希表)
    long rehashidx; //rehash索引,不在rehash时,值为-1
    unsigned long iterators; //正在使用的迭代器数量
} dict;

hash表就是dictEntry的一个封装,成为dictht,dict中会指向dictht。

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

rehash 操作

dict中会定义一个数组ht[2],其元素为dictht对象,默认情况下只会使用一个,不会为另一个分配初始空间。设置一个哈希对象时会计算哈希值确定落在哪个下标上,如果发生hash碰撞,同一个下标就会有多个dictEntry,每次发生冲突时,插入的值总在最前面。

进行rehash条件

  • 负载因子大于等于 1 且 dict_can_resize 为 1 时。
  • 负载因子大于等于安全阈值(dict_force_resize_ratio=5)时。

负载因子 = 哈希表已使用节点数 / 哈希表大小(即:h[0].used/h[0].size)

rehash 步骤

rehash的目的主要是重新分配hash数组的大小,然后将保存的的hash对重新分配到数组的不同下标上去

  1. 给字典dict的ht[1]分配空间
    • 如果是扩展,ht[1]的大小为2的n次方中第一个大于等于 ht[0].used * 2 的值,如ht[0]有三个hash对象,used=3,则 ht[0].used * 2=6 < 2^3,故ht[1]的大小为8
    • 如果是收缩,ht[1]的大小为2的n次方中第一个大于等于ht[0].used的值。如ht[0].used=3,则分配空间为2^2
  2. 将字典中的属性 rehashidx 的值设置为 0,表示正在执行 rehash 操作
  3. 将 ht[0] 中所有的键值对依次重新计算哈希值,并放到 ht[1] 数组对应位置,每完成一个键值对的 rehash 之后 rehashidx 的值需要自增 1。
  4. 当 ht[0] 中所有的键值对都迁移到 ht[1] 之后,释放 ht[0],并将 ht[1] 修改为 ht[0],然后再创建一个新的 ht[1] 数组,为下一次 rehash 做准备。
  5. 将字典中的属性 rehashidx 设置为 -1,表示此次 rehash 操作结束,等待下一次 rehash。

redis的rehash不是一次全部性执行完,而是渐进式执行,如果新的值进来,则统一放到ht[1]中,如果有查询,先查询ht[0],不存在再查询ht[1]

哈希命令

  • hset key field value - 设置单个field(哈希对象的key值)
  • hmset key field1 value1 field2 value2 - 设置多个field(哈希对象的key值)
  • hsetnx key field value - 将key中field对应的值设置为value,如果存在field则不执行任何操作
  • hget key field - 获取哈希表key中filed对应的value
  • hmget key field1 field2 - 获取哈希表key中多个field对应的value
  • hdel key field1 field2 - 删除哈希表key中的多个field
  • hlen key - 获取哈希表key中的数量
  • hincrby key field increment - 给哈希表key中field对应的值加上increment,increment可以为负数,不是数字会报错
  • hincrbyfloat key field increment - 为哈希表key中的field的值加上increment,increment可以为负数,不是float类型则会报错。
  • hkeys key - 获取哈希表key中的所有field
  • hvals key - 获取哈希表key中的所有value

sets 集合

集合简介

  • 集合对象是一个包含字符串类型元素的无序集合
  • 集合中元素唯一不可重复
  • 集合底层的数据类型是intset 和 hashtable
编码属性 描述 object encoding 命令返回值
OBJ_ENCODING_INTSET 使用整数集合实现的集合对象 intset
OBJ_ENCODING_HT 使用字典实现的集合对象 hashtable

当同时满足以下两个条件时,会使用intset

  • 集合对象保存的所有元素都是整数值
  • 集合对象保存的元素数量小于512(可以使用set-max-intset-entries控制)

intset整数集合结构

intset可以保存类型为int16_t、int32_t、int64_t的整数

intset定义:

typedef struct intset {
    uint32_t encoding; //编码方式
    uint32_t length; //当前集合中的元素数量
    int8_t contents[]; //集合中具体的元素
} intset;

encoding:

  • INTSET_ENC_INT16:此时 contents[] 内的每个元素都是一个 int16_t 类型的整数值,范围为-2^15~2^15-1
  • INTSET_ENC_INT32:此时 contents[] 内的每个元素都是一个 int32_t 类型的整数值,范围为-2^31~2^31-1
  • INTSET_ENC_INT64:此时 contents[] 内的每个元素都是一个 int64_t 类型的整数值,范围为-2^63~2^63-1

content[]: 结构上定义为int8_t,实际由encoding决定

类型的升级:

当一个32位的整数插入到int16_t类型中时,会进行类型的升级:

  1. 根据新添加元素的类型来扩展底层数组空间的大小,按照升级后现有元素的位数来分配新的空间。
  2. 将现有的元素进行类型转换,并将转换类型后的元素从后到前逐个重新放回到数组内。
  3. 将新元素放到数组的头部或者尾部(因为触发升级的条件就是当前数组的整数类型无法存储新元素,所以新元素要么比现有元素都大,要么就比现有元素都小)。
  4. 将 encoding 属性修改为最新的编码,并且同步修改 length 属性。

注意:类型升级后无法进行降级

集合命令

  • sadd key member1 member2 - 将一个或多个元素加入到集合key中,返回成功加入的条目,已存在的元素会被忽略
  • sismember key member - 判断member是否在key中
  • srem key member1 member2 - 移除key中指定的元素,不存在会被忽略
  • smove source dest member - 将元素从source移动到dest中,元素不存在会被忽略
  • smembers key - 返回集合中所有的元素

Sorted sets 有序集合

有序集合简介

  • 有序集合中每个元素都会关联一个double类型的分数,然后按照分数从小到大排序
  • 有序集合对象底层数据结构有两种:skiplist和ziplist
编码属性 描述 object encoding命令返回值
OBJ_ENCODING_SKIPLIST 使用跳跃表实现的有序集合对象 skiplist
OBJ_ENCODING_ZIPLIST 使用压缩列表实现的有序集合对象 ziplist

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

  • 有序集合对象中保存的元素个数小于128个(可以使用zset-max-ziplist-entries控制)
  • 有序集合对象中保存的所有元素的总长度小于64字节(可以使用zset-max-ziplist-value控制)

skiplist结构

使用skiplist的有序集合使用了zset结构作为有序集合的值,zset中包含了一个哈希字典和一个skiplist

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

其中,zskiplist是一个由多个zskiplistNode组成的对象

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;//跳跃表的头节点和尾结点指针
    unsigned long length;//跳跃表的节点数
    int level;//所有节点中最大的层数
} zskiplist;

通过zskiplist头尾节点和最大层数,就可以得知zskiplistNode的遍历方法,skiplistNode定义如下:

typedef struct zskiplistNode {
    sds ele;//元素
    double score;//分值
    struct zskiplistNode *backward;//后退指针
    struct zskiplistLevel {//层
        struct zskiplistNode *forward;//前进指针
        unsigned long span;//当前节点到下一个节点的跨度(跨越的节点数)
    } level[];
} zskiplistNode;
  • level 层 表示跳跃表的层,是一个数组,所以一个节点可以有多个层,程序可以通过不同的层来选择最快捷的访问方式,每次创建新的节点时根据幂次定律随机生成一个1~32的数字
  • forward 前进指针 每个层都有一个指向链表尾部方向的指针,遍历时需要使用
  • span 跨度 记录两个节点之间的距离,如果为RULL,表示跨度为零
  • backward 后退指针 后退指针只有一个,且只能后退到链表当前位置的前一个节点
  • ele 跳跃表中的元素,是一个sds对象,必须唯一,不能重复
  • score 分数,表示元素的权重跳跃表会将节点按照分值从小到大排列,不同节点分值可以重复

redis中skiplist的结构

skiplist结构

同时使用skiplist和字典的原因:

  • 查找单个元素时,使用字典更快,但是字典无序
  • 查找排列顺序是,使用skiplist有序

有序集合命令

  • zadd key score1 member1 score2 member2 - 将一个或多个元素(member)及其 score 添加到有序集合key中。
  • zscore key member - 返回有序集合key中member成员的score。
  • zincrby key num member - 将有序集合key中的member加上num,num可以为负数。
  • zcount key min max - 返回有序集合key中score值在 [min,max] 区间的member数量。
  • zrange key start stop - 返回有序集合key中score从小到大排列后在[start,stop] 区间的所有member。
  • zrevrange key start stop - 返回有序集合key中score从大到小排列后在[start,stop]区间的所有 member。
  • zrangebyscore key min max - 返回有序集合中按score从小到大排列后在[min,max]区间的所有元素。注意这里默认是闭区间,但是可以在max和min的数值前面加上(或者[来控制开闭区间。
  • zrevrangebyscore key max min - 返回有序集合中按score从大到小排列后在[min,max]区间的所有元素。注意这里默认是闭区间,但是可以在max和min的数值前面加上(或者[来控制开闭区间。
  • zrank key member - 返回有序集合中member中元素排名(从小到大),返回的结果从0开始计算。
  • zrevrank key member - 返回有序集合中member中元素排名(从大到小),返回的结果从0开始计算。
  • zlexcount key min max - 返回有序集合中min和max之间的member数量。注意这个命令中的min和max前面必须加(或者[来控制开闭区间,特殊值-和+分别表示负无穷和正无穷。

发布和订阅

双端列表实现发布和订阅的问题:

  • 如果生产者生产消息的速度远大于消费者消费消息的速度,那么链表中未消费的消息会大量堆积,导致占用大量的内存。
  • 基于链表实现的消息队列,不支持一对多的消息分发。

redis实现发布和订阅方式:基于频道和基于模式

当同时存在基于频道和基于模式两种订阅,Redis会首先寻找频道字典,再去遍历模式链表

基于频道

基于频道实现原理

客户端与其订阅的频道信息被保存在redisServer对象中的pubsub_channels属性中

struct redisServer {
    dict *pubsub_channels;//保存了客户端及其订阅的频道信息
    //... 省略其他信息
};

pubsub_channels属性是一个字典,key为频道名,value为链表,分别为每个客户端的id

基于频道发布与订阅结构

订阅:订阅的时候首先会检查字典是否存在这个频道,如果不存在,则需要在当前频道创建字典,同时创建一个链表作为value,将当前客户端id放入链表。如果存在,直接将当前客户端id放入链表末尾

取消订阅:取消订阅的时候需要将该客户端id从对应的链表中移除,如果移除之后链表为空,则需要同时将该频道从字典中删除

发送消息:发送消息时首先会去pubsub_channels字典内寻找键,如果发现有可以匹配的键,则找到对应的链表,进行遍历发送消息

基于频道发布订阅命令

  • subscribe channel-1 channel-2 - 订阅一个或者多个频道
  • unsubscribe channel-1 - 取消频道的订阅
  • publish channel-1 message - 向频道channel-1发送消息message
  • pubsub channels [channel_name] - 查看当前服务器被订阅的频道,不带参数则返回所有频道,后面的参数可以使用通配符?或*
  • pubsub numsub channel_name [channel_name] - 查看指定频道的订阅数(可同时查看多个)

基于模式

基于模式实现原理

客户端与其订阅的模式信息被保存在 redisServer 对象中的 pubsub_patterns 属性中

struct redisServer {
    list pubsub_patterns;//保存了客户端及其订阅的模式信息
    //...省略其他信息
};

pubsub_patterns是一个列表,内部为结构为

typedef struct pubsubPattern {
    client *client; //订阅模式的客户端
    robj *pattern; //被订阅的模式
} pubsubPattern;

基于模式发布与订阅结构

订阅:新建一个pubsubPattern数据结构加入到链表pubsub_patterns的结尾。

取消订阅:从链表中将当前取消订阅的客户端pubsubPattern从链表pubsub_patterns中移除。

发送消息:此时需要遍历整个链表来寻找能匹配的模式。之所以基于模式场景使用链表是因为模式支持通配符,所以没有办法直接用字典实现。

基于模式发布订阅命令

  • psubscribe pattern-1 pattern-2 - 订阅一个或者多个模式,模式可以通过通配符?和*来表示。
  • pubsubscribe pattern-1 pattern-1 - 取消模式的订阅(基于命令操作,界面上无法退订)
  • publish channel-1 message - 向频道channel-1发送消息message。这里和上面基于频道命令是一样的。
  • pubsub numpat - 查询当前服务器被订阅模式的数量

Lua脚本

Lua脚本简介

  • Redis从2.6版本开始支持Lua脚本
  • Redis服务器中嵌入了Lua环境

调用lua脚本

eval lua-script numkeys key [key ...] arg [arg ...]

  • eval - 执行lua脚本的命令
  • lua-script - Lua脚本内容
  • numkeys - 表示的是lua脚本中需要用到多少个key,如果没写则是0
  • key [key ...] - 将key按顺序传入到lua脚本中,numkeys为0时可省略
  • arg - lua脚本中用到的参数,没有则可以省略

示例:eval "return 'Hello Redis'" 0

lua脚本执行redis

redis.call(command, key [key ...] argv [argv…])

  • command - redis中的命令,如set,get等
  • key - 操作redis的key值,相当于调用方法的形参
  • param - 代表参数,相当于调用方法的实参

示例:eval "return redis.call('set',KEYS[1],ARGV[1])" 1 name lonely_wolf

等同于:set name lonely_wolf

注意:KEYS和ARGS必须大写,参数下标从1开始,1表示需要一个参数

lua脚本摘要

有时候如果我们执行的一个 Lua 脚本很长,那么直接调用 Lua 脚本会非常不方便,所以 Redis 当中提供了一个命令 script load 来手动给每个 Lua 脚本生成摘要,这里之所以要说手动的原因是即使我们不使用这个命令,每次调用完 Lua 脚本的时候,Redis 也会为每个 Lua 脚本生成一个摘要。

  • script load lua_src - 为lua_src内容生成一个摘要
  • script exists 摘要 - 判断一个摘要是否存在,0表示不存在,1表示存在
  • evalsha "摘要id" key [key ...] value [value ...] - 通过摘要执行lua脚本
  • script flush - 清除所有lua脚本缓存

示例:

script load "return redis.call('set',KEYS[1],ARGV[1])"  //给当前 Lua脚本生成摘要,这时候会返回一个摘要
evalsha "c686f316aaf1eb01d5a4de1b0b63cd233010e63d" 1 address china  //相当于执行命令 set address china
get address //获取 adress,确认上面的脚本是否执行成功
script exists "c686f316aaf1eb01d5a4de1b0b63cd233010e63d"  //判断当前摘要的 Lua脚本是否存在
script flush //清除所有 Lua脚本缓存
script exists "c686f316aaf1eb01d5a4de1b0b63cd233010e63d"  //清除之后这里就不存在了

lua脚本文件

新建一个test.lua脚本

redis.call('set',KEYS[1],ARGV[1])
return redis.call('get',KEYS[1])

bash命令行执行

# 注意 key 和 arg 参数之间要以逗号隔开,且逗号两边的空格不能省略
redis-cli --eval /home/project/test.lua 1 age , 18

lua脚本异常

  • redis默认脚本超时时间lua-time-limit 为5000毫秒
  • script kill - 脚本陷入死循环后,可以中断执行,但是要求当前脚本必须没有成功执行过redis操作的命令
  • shutdown nosave - 强制退出lua脚本

redis持久化

持久化简介

  • redis提供了两种持久化机制,RDB和AOF

RDB持久化

  • RDB全称为 Redis DataBase
  • redis默认的的持久化方案
  • 会生成一个dump.rdb文件,重启后通过解析dump.rdb文件进行数据恢复
  • 支持手动触发和自动触发

优点:

  • RDB 是一个非常紧凑的压缩文件,保存了不同时间点上的文件,非常适合用来灾备和数据恢复。
  • RDB 最大限度地提高了Redis的性能,因为父进程只需要派生一个子进程,父进程永远不会执行磁盘 I/O 或类似的耗时操作。
  • 与AOF持久化机制比较,RDB方式恢复数据的速度更快

缺点:

  • RDB无法做到实时备份,如果Redis因异常停止工作而没有正确的关机,那么从上一次备份到异常宕机的这一段时间的数据将会丢失
  • RDB通常需要父进程来执行fork操作创建子线程,所以如果频繁执行fork操作而CPU性能又不是很高的话可能会造成短时间内父进程不可用

自动触发条件

  • 执行flushall命令(flushdb不会触发)时,此时生成的dump文件内数据是空的
  • 执行shutdown命令时
  • 通过配置文件自动生成,redis默认配置如下,达到任意一个条件就会触发
save 900 1 #900秒内至少有1个key被添加或者更新
save 300 10 #300秒内至少有10个key被添加或者更新
save 60 10000 #60秒内至少有10000个key被添加或者更新

手动触发

  • save - 这个命令会阻塞redis服务器,直到成功创建RDB文件,在此期间不能执行redis命令
  • bgsave - 父进程会fork一个子进程,子进程负责RDB文件生成

注意:

  • 两个命令不能同时执行,一个执行另一个会被拒绝执行
  • lastsave 可以查看最近成功执行save或bgsave的时间,返回的是unix时间戳

RDB机制的相关配置,前面加config get表示从命令行获取该配置

  • config get dir - rdb文件的目录,默认是安装目录
  • config get dbfilename - rdb文件名,默认是dump.rdb
  • config get rdbcompression - rdb文件是否是LZF压缩文件,默认是yes
  • config get rdbchecksum - 是否开启数据校验。默认是yes

AOF持久化

  • AOF全称为Append Only File
  • AOF是采用日志的形式将写操作追加到文件中去到
  • 执行更改数据命令时,命令会被追加到AOF文件中去
  • Redis重启时会根据日志内容依次执行AOF到命令来恢复数据
  • 同时开启AOF和RDB时,redis优先会使用AOF
  • AOF记录的是命令,RDB记录的是数据

开启AOF,需要修改配置文件

appendonly no  #是否开启AOF机制,默认是no表示关闭,修改为yes则表示开启
appendfilename "appendonly.aof"  #AOF文件名

AOF是否实时写入磁盘通过appendfsync配置

appendfsync 描述 备注
always 写入缓存的同时通知操作系统刷新(fsync)到磁盘(但是也可能会有部分操作系统只是尽快刷盘,而不是实时刷盘) Slow, Safest
everysec 先写入缓存,然后每秒中刷一次盘(默认值),这种模式极端情况可能会丢失1s的数据 Compromise
no 只写入缓存,什么时候刷盘由操作系统自己决定 Faster

AOF文件重写

对于一个数据进行了多次重复修改,记录多次数据会没意义,这样就通过AOF文件重写实现

  1. 重写原理:redis会重新去读取现有的键值对,然后用一条命令记录键值对的值,但当元素超过64个,就会用多条命令来记录
  2. 重写操作:引入一个重写缓冲区,解决数据不一致的问题,当开始执行AOF文件重写之后又接收到客户端当请求命令,不但要将命令写入原本的AOF缓冲区,还要同时写入AOF重写缓冲区。

一旦子进程完成来AOF文件的重写,此时会向父进程发出信号,父进程收到信号之后会进行阻塞,并进行

  • 将AOF重写缓冲区的文件刷新到新的AOF文件内
  • 将新AOF文件进行改名并原子的替换掉旧的AOF文件

触发条件:

自动触发:自动触发通过以下参数设置

auto-aof-rewrite-percentag #文件大小超过上次AOF重写之后的文件的百分比。默认100,也就是默认达到上一次AOF重写文件的2倍之后会再次触发AOF重写
auto-aof-rewrite-min-size #设置允许重写的最小AOF文件大小,默认是64M。主要是避免满足了上面的百分比,但是文件还是很小的情况。

手动触发:执行bgrewriteaof命令。

bgrewriteaof 命令也不能和上面 RDB 持久化命令 bgsave 同时执行,这么做是为了避免同时创建两个子进程来同时执行大量写磁盘操作,影响到 Redis 的性能。

AOF机制优点:

  • 使用AOF机制,可以自由选择不同fsync(刷盘)策略,而且在默认策略下最多也仅仅是损失1s的数据。
  • AOF日志是一个仅追加的日志,因此如果出现断电,也不存在查找或损坏问题。即使由于某些原因(磁盘已满或其它原因),日志已经写了一半的命令结束,redis-check-aof工具也能够轻松地修复它。
  • 当AOF文件变得太大时,Redis能够在后台自动重写。
  • 不同于RDB的文件格式,AOF是一种易于理解和解析的格式,依次包含所有操作的日志。

AOF机制缺点:

  • 对于相同的数据集,AOF文件通常比等效的RDB文件大。
  • 根据fsync的具体策略,AOF机制可能比RDB机制慢。但是一般情况下,fsync设置为每秒的性能仍然很高,禁用fsync后,即使在高负载下,它的速度也能和RDB一样快。
  • 因为AOF文件是追加形式,可能会遇到BRPOP、LPUSH等阻塞命令的错误,从而导致生成的AOF 在重新加载时不能复制完全相同的数据集,而RDB文件每次都是重新从头创建快照,这在一定程度上来说RDB文件更加健壮。

内存管理

内存回收

可以设置数据有效时间来使内存释放

  • EXPIRE key seconds - 设置 key 过期时间,单位为秒
  • EXPIREAT key timestamp - 设置以秒为单位的 key 过期时间的时间戳,时间戳长度为10
  • PEXPIRE key milliseconds - 设置 key 过期时间,单位为毫秒
  • PEXPIREAT key milliseconds-timestamp - 设置一个以毫秒为单位的 key 过期时间的时间戳,时间戳长度为13
  • SETEX key seconds value - 设置 key 对应的字符串,并设置该 key 的过期时间,等价于执行一下两条命令
    • SET key value
    • EXPIRE key seconds
  • PSETEX key milliseconds value - 功能同 SETEX ,设置 key 对于的字符串,并设置该 key 的过期时间,时间单位为毫秒

所有设置过期方法底层都是通过pexpireat来实现的

过期策略

redis中的过期策略:

  • 惰性删除
    • 使用 expireIfNeeded() 函数对 key 进行检查,如果过期了就会将 key 删除掉,如果未过期则不做操作
  • 定期删除
    • Redis 启动服务时,读取配置中的 server.hz 的值,默认为10
    • 每秒执行 server.hzserverCron() 函数, serverCron() 会调用 databasesCrondatabasesCron() 会调用 activeExpireCycle()
    • activeExpireCycle() 对每个 expires[*] 逐一进行检测,每次执行 250ms/server.hz 毫秒
    • 对每个 expires[*] 检测时,随机挑选W个key检测
      • 如果key超时,则删除 key
      • 如果一轮中满足 删除的 key 数量 > W * 25% , 则循环该过程
      • 如果一轮中满足 删除的 key 数量 <= W * 25% , 则检查下个 db 的expires
      • W 取值为 ACTIVE_EXPIRE_CYCLE_LOOKUP_PER_LOOP 属性的值
    • 参数 current_db 记录 activeExpireCycle() 执行到哪个 db
    • 如果执行 activeExpireCycle() 时间到期,下次继续从 current_db 进行执行

redis中定期扫描只扫描有过期时间的键,不会扫描所有的键,有过期时间的键是单独存储的。

typedef struct redisDb {
    dict *dict; //所有的键值对
    dict *expires; //设置了过期时间的键值对
    dict *blocking_keys; //被阻塞的key,如客户端执行BLPOP等阻塞指令时
    dict *watched_keys; //WATCHED keys
    int id; //Database ID
    //... 省略了其他属性
} redisDb;

淘汰策略

数据淘汰相关的配置:

  • maxmemory ?mb - 最大可用内存,64位系统默认值为0,表示不限制,32位系统默认隐式限制为3GB
  • maxmemory-samples count - 每次选取删除数据的个数,采用随机获取数据的方式为待检测删除数据
  • maxmemory-policy policy - 删除数据所使用的策略,主要存在以下8种

maxmemory-policy可以配置为以下值

淘汰策略 说明
volatile-lru 根据 LRU 算法删除设置了过期时间的键,直到腾出可用空间。如果没有可删除的键对象,且内存还是不够用时,则报错
allkeys-lru 根据 LRU 算法删除所有的键,直到腾出可用空间。如果没有可删除的键对象,且内存还是不够用时,则报错
volatile-lfu 根据 LFU 算法删除设置了过期时间的键,直到腾出可用空间。如果没有可删除的键对象,且内存还是不够用时,则报错
volatile-ttl 根据键值对象的 ttl 属性, 删除最近将要过期数据。 如果没有,则直接报错
allkeys-lfu 根据 LFU 算法删除所有的键,直到腾出可用空间。如果没有可删除的键对象,且内存还是不够用时,则报错
volatile-random 随机删除设置了过期时间的键,直到腾出可用空间。如果没有可删除的键对象,且内存还是不够用时,则报错
allkeys-random 随机删除所有键,直到腾出可用空间。如果没有可删除的键对象,且内存还是不够用时,则报错
noeviction 默认策略,不作任何处理,直接报错

报错为 (error)OOM command not allowed when used memory>'maxmemory'

LRU 算法

LRU全称为least recently used,最近最长时间未使用

缺点:

  • 需要额外的空间进行存储
  • 可能存在某些key使用很频繁,但最近没使用,被LRU算法删除

redis改进了LRU算法,通过抽样的方式进行删除

配置文件有个maxmemory_samples,默认为5,表示随机抽取5个key,对这5个key值按照LRU算法删除

redisObject对象中有一个lru属性:

typedef struct redisObject {
    unsigned type:4;//对象类型(4位=0.5字节)
    unsigned encoding:4;//编码(4位=0.5字节)
    unsigned lru:LRU_BITS;//记录对象最后一次被应用程序访问的时间(24位=3字节)
    int refcount;//引用计数。等于0时表示可以被垃圾回收(32位=4字节)
    void *ptr;//指向底层实际的数据存储结构,如:SDS等(8字节)
} robj;

lru属性就是记录了这个键最后被访问的时间,然后redis维护了一个全局的lru_clock,这个全局属性通过一个全局函数serverCron每隔100毫秒执行一次来更新,记录当前unix时间戳。使用lru_clock可以避免每次读取系统时间,对性能的极致优化

注意:redisObject的lru为24位,只能存储194天的时间戳,超过194天又会从0开始计数,所以此处有两种情况

  • 当lru_clock > lru时,空闲时间为lruclock - lru
  • 当lru_clock < lru时,空闲时间为lruclock_max(194天) - lru + lruclock

LFU算法

LFU全称为Least Frequently Used,即最近最少频率使用

当采用LFU回收策略时,lru属性高16位用来记录访问时间,低8为记录访问频率(counter)

增加:取0到1的随机数,计算counter-初始值(默认为5)为基础值,计算1/(基础值*lfu_log_factor+1),如果值大于随机数,counter则加1

减小:去除当前的时间戳和对象中的lru属性进行对比,计算出多久没有被访问,再除以配置的lfu_decay_time(默认为10),用counter减去得到的值。

redis事务

事务使用

  • multi - 开启事务
  • exec - 执行事务(提交)
  • discard - 取消事务
  • watch - 监视

事务执行步骤:

  1. multi开启一个事务
  2. 开始事务后执行的命令会放入一个队列,
  3. 执行exec提交事务,redis会依次执行队列中的命令,并返回结果,如果想要放弃,可以执行discard

示例:

multi //开启事务
set name lonely_wolf //设置 name,此时 Redis 会将命令放入队列
set age 18  //设值 age,此时 Redis 会将命令放入队列
get name  //获取 name,此时 Redis 会将命令放入队列
exec //提交事务,此时会依次执行队列里的命令,并依次返回结果

事务实现原理

redis事务的结构简图

redis事务

redis每个客户端都有记录当前事务状态的multState

一个客户端的定义:

typedef struct client {
    uint64_t id;//客户端唯一 id
    multiState mstate; //MULTI 和 EXEC 状态(即事务状态)
    //...省略其他属性
} client;

multiState定义:

typedef struct multiState {
    multiCmd *commands;//存储命令的 FIFO 队列
    int count;//命令总数
    //...省略了其他属性
} multiState;

commands是一个队列,队列元素都是multiCmd对象,

typedef struct multiCmd {
    robj **argv;//用来存储参数的数组
    int argc;//参数的数量
    struct redisCommand *cmd;//命令指针
} multiCmd;

事务的acid特性

原子性:redis中执行事务的时候某一个命令失败了,不会影响其他的命令,即redis事务不会回滚

一致性:一致性指的是事务执行前后数据符合数据库的定义和要求,Redis满足,语法错误和运行错误时,命令均不会被执行

  • 隔离性:redis在执行事务的过程中,另一个客户端的请求无法被执行,满足了隔离性的要求

  • 持久性:当redis开启了持久化后,满足持久性

watch

当一个事务开启但未执行的时候,另一个客户端发出了修改值的命令,会造成数据非预期或覆盖有效值的问题 watch命令可以为Redis事务提供一个乐观锁的行为,在exec命令执行前监视key的变化,当多个线程更新同一个key的时候,会跟原值做比较,一旦发现修改过,则拒绝执行命令,并会返回nil

示例:

开启一个客户端执行

flushall  //清空数据库
watch name //监视 name
multi     //开启事务
set name lonely_wolf //设置 name
set age 18 // 设置 age
get name   //获取 name
get age    //获取 age

开启第二个客户端执行

set name zhangsan

在第一个客户端执行exec,会返回nil,整个事务都不会被执行

原理:redisDB中有一个watched_keys,类型为dict,字典的键为被上锁的key,字典的值为客户端的id,客户端中还有个CLIENT_DIRTY_CAS属性,当键执行了set,sadd等能修改key值对应的value时,遍历watched_keys,将键上锁的客户端中的CLIENT_DIRTY_CAS进行修改,提交事务的时候发现CLIENT_DIRTY_CAS被修改过则会拒绝执行事务。

typedef struct redisDb {
    dict *watched_keys;  //被 watch 命令监视的 key
    int id;           //Database ID
    //...省略了其他属性
} redisDb;

redis集群

集群简介

redis支持的集群方案:

  • 主从复制集群(master-slave)
  • 基于哨兵机制实现高可用集群
  • Redis Cluster分布式集群

主从复制

主库用来读写,从库用来读,主从库之前数据保持同步

配置主从复制

配置一主两从master-slave集群

在主机redis-cli命令行中执行replicaof no one,成为一个主节点

在从机的redis-cli命令行中执行replicaof 主机ip 主机端口,成为一个从节点

info replication - 查看主从机的状态

在启动redis-server的时候也可以指定主从机 redis-server --replicaof ip port

默认从机为只读,修改配置文件中的replica-read-only来决定从机是否只读

注意:replicaof和slaveof为同一含义,slaveof在外国有奴隶的意思,最后进行了改名。

主从复制原理

建立连接:

  1. 执行replicaof命令时,从服务器会将本地主服务器的一些信息(ip和端口等)保持在redisServer中
  2. 创建和主服务器的连接,创建连接后,从服务器相当于主服务器的一个客户端
  3. 从服务器向主服务器发送ping命令,确认连接是否可用,如果需要授权,此处需要授权认证
  4. 从服务器收到pong后,表示连接可用,此时从服务器会将自己的端口号发送给主服务器,主服务器收到后记录在redisClient中
  5. 没有收到pong回复,则尝试重新连接

从服务器与主服务器建立连接后,首次需要全量同步

全量同步:

  1. 从服务器向主服务器发送同步数据的命令
  2. 主服务器收到命令后,执行bgsave命令,生成一个RDB文件。并发送给从服务器。此时有新的命令进来会记录在缓冲区内
  3. 从服务器收文件后,先清除自己的数据,然后载入RDB文件数据
  4. 从服务器执行完RDB文件后,主服务器会将缓冲区的命令发送给从服务器,从服务器再依次执行

首次全量同步完成后,主服务器会将自己收到的修改数据的命令发送给从服务器。这个过程有延迟,配置中 repl-disable-tcp-nodelay 默认为no,表示每执行一条命令都立刻同步给从服务器。当改为yes时,会合并数据包发送,降低发送频率

服务器重启后通常会使用部分重同步:

  • 主从服务器各自会维护一个数据复制的偏移量,这个偏移量表示的发送命令的字节数,
  • 当从服务器恢复和主服务器的连接时,会发送同步命令,并带上偏移量,主服务器根据偏移量进行部分同步
  • 使用部分重同步,主服务器需要保持历史命令。master维护了一个固定长度的FIFO队列,成为复制积压缓冲区。默认大小为1MB
  • 当复制偏移量+1(因为传给主服务器为100,返回数据应该从101开始)不再复制积压缓冲区时,就执行全量同步

主从服务的问题

  • 首次同步如果数据量很大,会非常耗时
  • 如果master宕机了,需要认为切换master服务器

Sentinel 哨兵机制

  • Sentinel服务本身是一个特殊的redis服务,通过sentinel.conf文件配置
  • Sentinel机制可以实现主从服务器的自动切换
  • Sentinel用来监控redis集群的所有节点,当master不可用时,会从所有的slave中选出一个节点升级为master,即自动切换
  • Sentinel本身也是一个集群,但sentinel集群没有主从关系,节点之间互相监控,master宕机后会通过sentinel集群选举产生新的master

运行流程

  • Sentinel每隔1s会向redis服务器节点发送ping命令,如果在指定时间内(down-after-milliseconds,默认30s)没有收到有效回复,则Sentinel会把该服务器标记为下线,称为主观下线。
  • Sentinel把master标记为主观下线后,会询问其他Sentinel,当达到一定数量(sentinel monitor <master-name> <ip> <redis-port> <quorum>, quorum就是判断客观下线的数量)的Sentinel认为该master下线后,首先发现节点下线当Sentinel会把master标记为客观下线,并发起Leader选举,最后由Leader执行故障转移操作
  • 不同的Sentinel配置的下线条件可能不一样。但只要有一个Sentinel认为master客观下线,就会准备执行故障转移,故障转移前一定会进行Leader选举
  • Leader选举算法:基于Raft算法的修改,Raft的原始算法见下文,具体修改项为:
    • 触发选举是由谁先发现master下线来决定的
    • Sentinel节点没有维护election timeout,而是维护来一个配置纪元属性configuration epoch,配置纪元是一个计数器(默认为0),每个节点同一纪元只能投一次票,每次投票前配置纪元会自增1
    • 选举出Leader后,不会通知其他Follow自己成为来Leader;当Leader节点选出新的master,其他Sentinel服务检测到新的master上线之后就会删除自己的主观下线标记。
  • Leader选出新的master
    • 断开连接时长:首先将所有于已下线master节点断开连接时间超过down-after-milliseconds*10的slave节点删除掉,确保slave节点的数据都是比较新的。
    • slave 节点的优先级排序:将所有的 slave 节点按照优先级进行排序,选出优先级最高的 slave 节点作为新的 master 节点(优先级由配置文件参数 replica-priority 决定,默认 100)。
    • 复制偏移量:如果有多个优先级相同的 slave 节点,则选出复制偏移量最大的 slave 节点。
    • 进程 id:如果还是没选出新的 master 节点,那么会再次选择进程 id 最小的 slave 节点作为新的 master节点。
  • Leader执行故障转移
    • 在slave服务器列表中找一个合格的slave服务器,向其发送 replicaof no one 的命令,使其变为master
    • 向其它从服务器发送 replicaof ip port 命令(ip和port为新master地址),使其成为新master服务的slave节点。
    • 将已下线的master服务也设置为新的master服务的slave节点,这样当旧master恢复之后能以slave的角色继续运行。

Raft算法:(少数服从多数,先到先得),每个Sentinel服务都维护了一个随机时间属性election timeout,范围在150~300ms之间,哪个节点先到这个时间就可以发起选举

  • 发起选举的服务给自己投一票
  • 发起选举的节点向其他节点发送投票请求,其他节点收到请求后在同一个election timeout内没有投过票,就会个发起选举的节点投票,然后重置election timeout。(一个election timeout区间只能投一次票)
  • 如果发起选举的节点获得的票数超过一半,那么当前服务就会成为Leader节点,成为Leader节点之后就会维护一个heartbeat timeout时间属性(即:心跳间隔时间),在每一次到达heartbeat timeout时间时,Leader节点就会向其它Follow节点发起一个心跳检测。
  • Follow节点收到Leader节点的心跳包之后就会将election timeout清空,这样可以防止Follow节点因为到达election timeout而发起选举。
  • 假如Leader节点挂了,那么Follow节点的election timeout将不会被清空,谁先到达,谁就会再次发起选举。

配置方法

修改redis目录下的sentinel配置文件

protected-mode no //表示不开启外网访问
daemonize yes //表示后台运行
sentinel monitor mymaster 127.0.0.1 6379 2  //监控的master的服务名称,mymaster 可以任意起名,后面就是主节点的 ip 和端口,最后的 2 表示当前有 2 个 sentinel 认为 master 主观下线,则可以修改为客观下线
sentinel down-after-milliseconds mymaster 30000  //master下线多少时间判定为主观下线
sentinel parallel-syncs mymaster 1  //切换新的 master-slave 时,slave 需要从新的 master 同步数据,这个数字表示允许多少个 slave 同时复制数据
sentinel failover-timeout mymaster 180000  //故障转移超时时间
port 26379  //sentinel运行的端口
pidfile /var/run/redis-sentinel-26379.pid  //pid文件的保存目录
logfile "/home/project/redis-5.0.5/sentinel.log" //logfile日志文件的保存目录
dir "/home/project/redis-5.0.5" //sentinel工作主目录

sentinel failover-timeout超时时间有较多用处

  • 同一个sentinel对同一个master两次failover之间的间隔时间。
  • 从检测到master服务器故障开始,到被强制切换到新的master服务器并开始复制数据为止的时间。
  • 取消已经在进行故障转移(没有产生任何配置更改的故障转移)所需的时间。
  • 将所有slave配置新的master节点所需要的时间。超过这个时间如果仍然没有完成还是会继续进行,但是不一定会按照配置parallel-syncs所指定的并行数来进行。

启动Sentinel服务:

redis_work_dir/sentinel.conf --sentinel

Sentinel哨兵机制问题

  • 主从切换切换过程有等待期,切换过程服务不可用
  • 本质还是master-slave集群,没有实现水平扩展

Redis Cluster分布式集群

分布式集群简介

实现水平扩展,需要使用分片来进行数据共享,通常有三种思路

  • 客户端实现分片逻辑,决定路由到哪台服务器
    • 需要客户端支持分片
    • 不能很好实现服务器动态增减,客户端需要获取所有服务器才能平均分配
  • 添加一个中间服务来处理分片逻辑,客户端连接中间服务,再进行路由分发
    • 又添加来一个中间服务,为保证中间服务的高可用也需要配置集群。
  • 基于服务端实现

Redis Cluster是redis3.0后推出的,实现了高可用的分布式集群部署。通过配置文件中的cluster-enabled来控制是否开启集群模式

原理

水平扩展有个重要的问题就是数据如何分配:主流有哈希后取模和一致性哈希

  • 哈希后取模:将key值求哈希后再除以节点数,根据余数来决定落到哪个节点上,节点数发生变化,所有数据要重新分配
  • 一致性哈希:把所有的哈希值组成一个虚拟的哈希圆环,起点0和终点2^32-1位置重叠,每个节点负责一个区间的哈希值,这样添加节点只会影响相邻的两个节点,但是这个方式会出现数据分布不均匀

redis实现数据分布使用了槽的概念

  • redis将整个数据库分为16384个槽(slot)
  • 根据节点数来划分节点负责的槽
  • 一个数据落在哪个槽是通过CRC16算法得到的,落到哪个槽是固定的
  • 节点负责的槽可能会发生改变
  • 为了让同一个业务的数据落到同一个槽中,可以在key里面带有{},redis只会计算{}里面的字符进行哈希,这样同一个业务的数据都在{}中放入相同的字符就会落到同一个槽
  • 对客户端请求重定向,如果一个数据不再自己的服务器上,就会计算出在哪个服务器上,然后返回一个MOVED指令,带上服务器的ip和端口,客户端收到MOVED指令重定向就能获取到正确的值。
  • 重新分片:可以将已经指派给某节点的任意数量的槽重新指派给另一个节点,且重新指派的槽所属的键值对也会被移动到新的目标节点,这样就可以实现新增或删除节点

ASK指令和MOVED指令:

  • ASK 指令可以认为是一种过渡时期的特殊指令,只有在发生槽迁移的过程中,发现原本属于 node1 管理的槽被指派给了 node2,而数据又还没有迁移完成的情况下。因为这是一种特殊场景,所以客户端收到 ASK 指令之后不能直接连到目标节点执行命令(这时候直接连过去目标节点会返回 MOVED 命令指向 node1),客户端收到 ASK 指令之后需要先向 node2 节点发送一个 ASKING 命令给自己打上标记才能真正发送客户端想要执行的命令。
  • 目标节点在接收其它节点指派过来的槽所对应键值对时,会通过一个临时数组属性 importing_slots_from[16384] 来存储,客户端在接收到返回的 ASK 错误之后,客户端会先向目标节点发送一个 ASKING 命令,之后再发送原本想要执行的命令,这样目标节点就知道当前客户端访问的 key 是正在迁移过来的,知道去哪里取这个数据。
  • MOVED 指令是在正常情况或者说槽已经完成重新分片的情况下返回的错误,这种情况服务器发现当前 key 所在槽不归自己管,那么就会直接返回 MOVED 指令并同时返回负责管理该槽的服务器信息,客户端收到 MOVED 指令及其携带的目标服务器地址之后就会再次连接目标节点执行命令

Redis Group

  • Redis 集群中的节点是一个由 master-slave 组成的集群,称之为 Redis Group
  • Redis Group的主从复制就是master-slave的同步方式
  • 故障检测
    • 集群中的节点定期会向其他节点发送ping命令,规定时间内没返回pong,发送ping的节点就会把该节点标记为疑似下线
    • 节点之间互发消息交换集群中各个节点的状态信息,当一个主节点发现半数的节点都将另一个主节点被标记为疑似下线,就会将该节点标记为下线,并广播给所有节点,所有收到广播的节点都会将该节点标记为已下线
  • 故障转移
    • 被选中的从节点会执行 replicaof no one 命令,使得自己成为新的主节点。
    • 新的主节点会将已下线的主节点负责的槽全部指派给自己。
    • 新的主节点会向集群发一条 PONG 消息的广播,收到这条消息的其它节点就会知道这个节点已经成为了新的 master 节点,并且接管了旧 master 节点的槽。
  • 选举新的master
    • 该从节点会将集群的配置纪元自增 1(和 Sentinel 机制一样,配置纪元默认值也是 0)。
    • 该从节点会向集群发一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 的消息广播。
    • 其它节点收到广播后,master 节点会判断合法性,如果一个主节点具有投票权(正在负责处理槽),那么就会返回一个 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 消息给它投 1 票(一个配置纪元内,一个 master 节点最多只能投票 1 次)。
    • 如果同时有多个从节点发起投票,那么每个从节点都会统计自己所得票数,然后进行统计,只要得到了大于参与投票的主节点数的一半的从节点,就会成为新的 master 节点。
    • 如果一个配置纪元内没有一个从节点达到要求,那么集群会把配置纪元再次自增,并再次进行选举,直到选出新的 master。

槽为什么是16384个:需要频繁发送心跳消息,16384只需要2k的空间,而且集群节点数最好不要超过1000,极端情况下每个节点负责16个slot,官方解释

配置

Redis Cluster 集群通常都至少需要3个master节点,每个master节点又至少需要一个slave节点

修改redis.conf文件

daemonize yes  //开启守护进程后台运行
protected-mode no  //网络是否允许对外访问 no 表示允许
cluster-enabled yes  //是否开启集群模式(默认是注释掉的,打开注释就可以了)
cluster-node-timeout 5000 //集群超时时间
port 6379 //端口号
dir /home/project/redis-6379/  //redis 工作主目录
logfile "/home/project/redis-6379/redis.log" //自定义日志文件目录
cluster-config-file /home/project/redis-6370/nodes-6379.conf //node 文件
pidfile /var/run/redis_6379.pid  //pid 文件

配置好后启动redis-server

此时在redis-cli中查看redis cluster集群的状态是fail cluster info

redis-cli --cluster create ip1:port1 ip2:port2 ip3:port3 [ip4:port4 ...] --cluster-replicas 1 - 创建集群,系统会自动分配槽从而确定主从关系。期间需要输入yes确定配置

此时在redis-cli中查看redis cluster集群的状态是OK了 cluster info

可能遇到的问题:

  1. [ERR] Node 47.107.155.197:6370 is not empty. Either the node already knows other nodes (check with CLUSTER NODES) or contains some key in database 0.

    • 这个错误有 2 个原因:
    • 一个就是 Redis 当中有数据,这个执行 flushall 命令或者把 rdb 和 aof 两个持久化文件删除掉(如果有的话),再重启即可;
    • 另一个原因就是可能初始化集群失败过 1 次,那么这时候需要把当时配置好的 nodes.conf 文件删掉,并重启 Redis 服务就可以解决。
  2. 初始化集群输入 yes 之后提示 Waiting for the cluster to join,但是却迟迟等不到成功。

    • 这个原因可能是防火墙引起的,除了正常的数据连接端口,如 6370-6375,还有另一个端口需要用数据端口固定加上 10000 得到 16370-16375。也就是说需要确保以下 12 个端口都是通的:6370 16370 6371 16371 6372 16372 6373 16373 6374 16374 6375 16375
  3. [ERR] Not all 16384 slots are covered by nodes.

    • 这个原因说明分配槽的时候失败了,所以需要检查下配置是否正确。尤其是 cluster-config-file 这个 node 文件的配置是否正确,确认之后不管有没有配置错误,都把所有服务的 node 文件删除掉,并重启所有 Redis 服务,然后再次进行集群搭建。

集群命令

  • cluster info - 打印当前集群的信息
  • cluster nodes - 打印出集群中node及其信息
  • cluster meet <ip> <port> - 将指定ip和port的node添加到当前执行命令节点所在的集群中
  • cluster forget <node_id> - 从集群中移除某一个节点
  • cluster replicate <node_id> - 将当前节点设置为指定节点的从节点
  • cluster saveconfig - 将当前节点的配置文件保存到硬盘里面
  • cluster keyslot <key> - 计算键key应该被放置在哪个槽上
  • cluster countkeysinslot <slot> - 返回槽目前包含的键值对数量
  • cluster getkeysinslot <slot> <count> - 返回count个slot槽中的键
  • cluster addslots <slot> [slot...] - 将一个或多个槽(slot)指派给当前节点
  • cluster delslots <slot> [slot...] - 移除一个或多个槽对当前节点的指派
  • cluster flushslots - 移除当前节点的所有槽
  • cluster setslot <slot> node <node_id> - 将槽 slot 指派给指定的节点, 如果槽 slot 已经指派给另一个节点,则会先删除再指派
  • cluster setslot <slot> migrating <node_id> - 将本节点的槽 slot 迁移到指定的节点中
  • cluster setslot <slot> importing <node_id> - 从指定的节点中导入槽 slot 到当前节点
  • cluster setslot <slot> stable - 取消对槽 slot 的导入(import)或者迁移(migrate)

缓存穿透和缓存雪崩

缓存雪崩

Redis当中的大量缓存在同一时间全部失效,在此时又有大量请求被发起,那么所有请求都会到数据库,导致数据库压力过大。

解决办法:

  • 加锁,保证单线程访问缓存,这样就不会很多请求同时访问数据库
  • key的失效时间不要设置为一样,典型的就是初始化预热数据的时候,采用随机时间来确保不会在同一时间有大量缓存失效
  • 内存允许的情况下,可以将缓存设置为永不失效

缓存击穿

缓存击穿一般指的是单个缓存失效。同一时间又有很大的并发请求需要访问这个key,造成数据库压力

解决办法:

  • 加锁,保证单线程访问缓存。这样第一个请求到达数据库后就会重新写入缓存,后续的请求就可以直接读取缓存。
  • 内存允许的情况下,可以将缓存设置为永不失效。

缓存穿透

当访问的数据在redis和数据库中都不存在的时候,并发量过大也会造成数据库的很大压力

解决办法:

  • 接口层校验,发现非法的key直接返回
  • 缓存不存在的数据,缓存一个空value,需要设置一个短期过期时间,否则大量不存在的key被存在redis中,也会占用大量内存

布隆过滤器

当有大量key值为空的时候,此时会发送缓存穿透,但是如果把空的key放在redis中,又会占用大量的空间,所以引入布隆过滤器(Bloom Filter)

布隆过滤器是一个二进制向量(位图)和一系列随机随机映射函数(哈希函数)

实现原理:存在一个二进制向量,就是一个位数组,每个元素都只有0或1两个状态,一个位置只占用一个bit位,0表示元素不存在,1表示存在

问题:如果发生了哈希碰撞,就会出现多个数据由一个位置表示,影响判断的准确性。解决哈希碰撞方法:

  • 增大位图数组的大小(会占用过大内存)
  • 增加哈希次数(同一个key多次哈希,用多个位置来表示该数据是否存在,会消耗过多时间)

布隆过滤器特点:

  • 如果布隆过滤器判断一个元素存在,那么这个元素可能存在
  • 如果布隆过滤器判断一个元素不存在,那么这个元素一定不存在
  • 如果一个元素存在,那么布隆过滤器一定判断存在
  • 如果一个元素不存在,那么布隆过滤器可能判断存在

布隆过滤器删除元素需要引入计数器

Copyright © book.stolenzc.com 2021-2024 all right reserved,powered by GitbookFile Modify: 2024-09-24 02:47:04

results matching ""

    No results matching ""