redis技术内幕

redis基本数据结构及实现

redis基本数据结构

  • string
  • list
  • set
  • zset
  • hash

redis数据结构的实现

SDS(simple dynamic string,SDS简单动态字符串)

SDS是redis基于C字符串自己构建的一种数据结构,在redis中有着广泛的应用,下面揭开SDS的神秘面纱

  • SDS结构定义
// sds.h/sdshdr
struct sdshdr {
    int len;     // SDS保存字符串的长度
    int free;    // buf数组中未使用的字节的数量
    char buf[];  // 字节数组,用于保存字符串
}
  • SDS逻辑图,如图1
    • free(buf数组中未使用的字节的数量,图1中free=5,表示buf空出了5个字节)
    • len(buf数组中未使用的字节的数量,图1中len=5)
    • buf(字节数组,用于保存字符串,length(buf)=len+free+1(多出来的1字节保存’\0’))

redis技术内幕_第1张图片

图1 SDS逻辑图
  • SDS的特性

    • 空间预分配
      空间预分配是指redis的SDS是可修改的,并且在对字符串操作之前进行长度校验,如果长度不足,则会申请一定的空间进行存储。如果source=sdshdr("Redis"), sdscat(source, "spencer")执行sdscat之前会检查append的字符串长度大于free,那么buf需扩容

    • 扩容规则

    1. 修改之后len属性小于1MB,那么free=len(修改后的)。
    2. 修改后的len属性大于1MB,那么free=1MB。
    • 空间释放
      空间释放是说对redis字符串进行截断操作,则SDS的len属性将减少,即使空出一部分,free属性将增加,但redis不回收这部分空间。

    • 二进制安全
      二进制安全是说redis字符串可以保存图片、音频等二进制数据,C字符串不能保存二进制数据是因为C字符串以’\0’结尾,但是这些二进制数据本身可能包含’\0’,而redis的SDS具有len属性,知道buf何处结束,因此能够保存。

    • 兼容C的API
      虽然redis的SDS保存有len属性,但是为了兼容C字符串的API,还是保存了字符串末尾’\0’的规范,这也就是为什么length(buf)=len+free+1

SDS问题

redis是二进制安全的,也就是说它能够保存二进制数据,这些二进制数据文本中可能带有’\0’,而redis的SDS又兼容C字符串的API,那么在对二进制数据执行C字符串的API时难道没有问题吗?二进制数据不会被截断吗?

链表

redis中的链表为带有头尾节点的双向链表,结构比较简单,这里只给出链表的结构定义及逻辑图

  • 链表的结构定义
// adlist.h/listNode
typedef struct listNode {
    struct listNode *prev; // 前置节点
    struct listNode *next; // 后置节点
    void *value;           // 节点的值 
}

// 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);  // 节点值比较函数
}
  • 链表的逻辑图
    redis技术内幕_第2张图片
图2 redis使用的链表逻辑图

字典

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

// dict.h/dictht 
typedef struct dictht {
    dictEntry **table; // 哈希表节点指针数组(俗称桶,bucket)
    unsigned long size; // 指针数组的大小
    unsigned long sizemask; // 指针数组的长度掩码,用于计算索引值
    unsigned long used; // 哈希表现有的节点数量
} dictht;
  • 字典的逻辑图
    redis技术内幕_第3张图片
图3 redis字典的逻辑图
  • hash算法及拉链法

    • redis计算哈希值和索引值方法
    hash = dict->type->hashFunction(key);
    index = hash & dict->ht[x].sizemask; // x根据不同情况可取0,1
    
    • 拉链法解决冲突
      当出现key冲突时,即两个不同的key使用redis计算出来的索引值相同,redis会采用拉链法解决冲突,即每一个dictEntry都有一个next指针指向下一个dictEntry

    • 扩容和缩容(表格中n为需要变化的最小值,取整;bgsave和bgrewriteaof分别为持久化的命令)

      load_factor = ht[0].used / ht[0].size
      
    名称 时机 机制
    扩容 (load_factor >=1 && (!bgsave && !bgrewriteaof)) ||
    ( load_factor >= 5 && ( bgsave || bgrewriteaof ) )
    2^n >= ht[0].used*2
    缩容 load_factor < 0.1 2^n>=ht[0].used
    • 渐进式rehash
      • 步骤
    1. 给ht[1]分配适当的空间;
    2. rehashidx=0,表示rehash工作开始;
    3. 在rehash期间,每次对字典进行操作时,除了必要的操作另外,还会将ht[0]中rehashidx位置上所有键值对rehash到ht[1]上,该rehashidx所有键值对rehash完毕,rehashidx++;
    4. 随着时间进行,ht[0]上所有的键值对都rehash到ht[1]上,此时rehashidx=-1表示当前没有在进行rehash
    • 好处
      渐进式rehash将重新hash的操作分摊到每个key上,避免了集中式rehash带来的庞大计算量

    • 查询键及更新键
      执行delete、find、update等操作时可能服务器正在进行rehash操作,因此需要计算该key在ht[0]和ht[1]两个index然后查找,如果都找不到,则返回不存在。
      新增键一律添加到ht[1]上,这样就保证随着时间的推进ht[0]上所有的键都被rehash到ht[1]上

跳跃表

  • 跳跃表的结构定义
// redis.h/zskiplist
typedef struct zskiplist {
    struct zskiplistNode *header, *tail; // 头节点,尾节点
    unsigned long length;                // 节点数量
    int level;                           // 目前表内节点的最大层数
} zskiplist;

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

  • 跳跃表的逻辑图
    redis技术内幕_第4张图片
图4 跳跃表的结构
  • 结构详解
    将跳跃表的结构和跳跃表的逻辑图结合起来看更方便理解。zskiplist中有指向跳跃表节点zskiplistNode的头尾指针,每个zskiplistNode都有指向下一个节点的指针(forward)和前一个节点的指针(backward),这样设计不管从前往后或者从后往前遍历都很方便。除此之外,每一个zskiplistNode还有“层”的概念,越往上一层,数据越少。redis使用一种算法,当插入某一个节点时(首先插入到level[0]中),会有1/2概率插入到level[1],会有1/4的概率插入到level[2]…

  • 性能表现
    跳跃表是redis中zset的底层实现之一,支持平均O(logN)、最坏O(N)复杂度的节点查找.正是因为跳跃表中含有backward和forward指针,因此可以在O(N)时间内通过score正序/倒序排列出zset所有元素

整数集合

  • 整数集合的结构定义
// intset.h/intset
typedef struct intset {
    uint32_t encoding; // 保存元素所使用的类型的长度
    uint32_t length;   // 元素个数
    int8_t contents[]; // 保存元素的数组
} intset;
  • 整数集合的结构图

    • encoding(取值有int16,int32,int64)
    • length(长度)
    • contents(整数集合中的元素)
      redis技术内幕_第5张图片
    图5 整数集合的结构
  • 升级
    整数集合中如果遇到操作数比当前元素的范围大,则会先进行类型升级(int16–>int32–>int64),但不会降级

压缩列表

  • 压缩列表的结构定义
    redis技术内幕_第6张图片
图6 压缩列表结构定义
  • 压缩列表字段说明
属性 类型 长度 用途
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 ),用于标记压缩列表的末端
  • 压缩列表的结构图
    redis技术内幕_第7张图片
图7 压缩列表结构图
  • 压缩列表节点构成
    • 字段说明

      属性名 长度 详细说明
      previous_entry_length 1/4字节 如果前一节点的长度小于254字节,那么长度为1字节,前一节点的长度就保存在这一个字节里面;如果前一节点的长度大于等于254字节,那么长度为5字节;其中属性的第一字节会被设置为0xFE(十进制值 254)而之后的四个字节则用于保存前一节点的长度
      encoding 1字节 标识content保存的值
      content 变长 保存数据
    • 示例
      Alt

图8 压缩列表节点实例
  • 连锁更新
    在一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的节点e1至eN,这时, 如果我们将一个长度大于等于254字节的新节点new设置为压缩列表的表头节点, 那么 new将成为e1的前置节点,e1的previous_entry_length将扩展为5字节,引起e2更新…从而导致连锁更新.删除一个节点时也会导致连锁更新
  • 遍历与更新
    • 遍历
      在从后往前遍历时,首先拿到起始指针p,根据zltail偏移量计算跳到最后一个entry上,然后在最后一个节点根据previous_entry_length跳到倒数第二个节点…以此类推完成从后往前遍历;在从前往后遍历到某个节点X时,已经知道该节点的起始指针p,再根据encoding中本节点的长度知道偏移多少字节可以到达下一个节点起始地址p+length完成遍历。
    • 更新
      在尾结点后插入元素时,先生成相应的节点,然后更新zltail。在头结点前插入元素时,需要先将所有元素后移,再插入到头结点。插入删除节点都可能导致压缩列表连锁更新。

redis数据结构总结

redis技术内幕_第8张图片

图9 redis基本数据结构与其底层结构实现

redis过期策略与持久化

redis数据库

  • 客户端服务器结构定义
// 服务端 redis.h/redisDb 
struct redisServer {
    // ...
    int dbnum;   // 服务器中数据库数量
    redisDb *db; // 一个数组,保存着redis中所有的数据库
    // ...
}

// 客户端 
struct redisClient {
    // ...
    redisDb *db; // 一个指针,指向当前连接的服务端的数据库
    // ...
}

typedef struct redisDb {
    // ...
    dict *dict; // 数据库键空间,保存着数据库中所有的键值对
    // ...
} redisDb;
  • 客户端服务器逻辑结构图
    redis技术内幕_第9张图片
图10 redis客户端服务端与数据库连接

从图10中可以看到,RedisServer中有一个变量dbnum保存当前redis服务器中数据库的数量,默认是16,配置文件中可以修改。db则是一个大小为dbnum的redisDb的数组。redisClient中有一个指向redisDb数组中某一个元素的指针,使用select可以切换客户端连接的数据库,也就是修改db指针的指向,但是到目前为止,redis没有一个命令返回当前所处的数据库编号,在使用时要格外小心。
redis技术内幕_第10张图片

图11 redisDb数据结构示意

redisDb是一个结构体,其中dict存储了所有的键值对,expires保存了所有带有过期键的信息,键的过期时间保存在longlong类型的变量expireTimestamp中,该变量是一个时间戳,在计算某一个键的过期时间时使用当前时间戳减去expireTimestamp即可。

  • 更新及查询操作
    所有的操作均作用在redisDb.dict上,有了图11的逻辑架构,对于redis的更新及查询操作就很容易理解了

  • 过期键删除设置

    • 方法
      redis通过expire key 5设置过期时间,通过ttl key获取过期时间
    • 删除策略
    策略名称 详细描述 优点 缺点
    定时删除 每当有一个key设置了过期时间则开启一个定时器,定时器到期则删除key 对内存友好,所有的key在过期的第一时间会被清除内存 对CPU不友好,若有大量的key在同一时间过期,redis服务器会在该时间附近处理大量删除key操作,可能造成服务器短暂不可用
    惰性删除 每当有key设置了过期时间则记录该时间,下次访问时,先检查该key是否过期,如果过期,则删除 对CPU友好,删除key的操作只发生在访问该key时,服务器响应快 对内存不友好,如果设置了过期时间但是却不再次访问它,那么该key一直存在于内存中不被清理,某种情况下可以认为是内存泄漏
    定期删除 服务器每隔一定的时间(默认100ms)扫描一遍,查看数据库中的key是否过期,如果过期则删除,限制删除操作的时长和频率 是上述两种方法的折中 难以确定删除执行的时长和频率,如果执行时间太长,或者太频繁,则退化为定时删除;如果执行时间太短,或者执行太少,则退化为惰性删除
  • redis过期键删除策略
    redis采用惰性删除+定期删除的策略.一方面当对某个key进行操作前走惰性删除;另一方面,redis在redis.c/serverCron函数执行时,采取定期删除.

  • redis持久化对过期键的策略

  1. RDB持久化

    • 生成RDB文件
      执行save或bgsave时将未过期的键保存到RDB文件中

    • 载入RDB文件

      1. 服务器以主服务器模式运行
        过期的键被忽略
      2. 服务器以从服务器模式运行
        不管键是否过期,都载入,主从服务器同步时,从服务器的数据库被清空
  2. AOF持久化
    AOF文件写入时,对于已过期的键,会单独生成一条del key的命令;AOF重写时,程序会对数据库中的键进行检查,已过期的键不会保存到重写后的AOF文件中。

  3. 复制
    主服务器删除一个过期键后,会显示发送一条del命令给从服务器;从服务器为保证跟主服务器数据一致性,不主动删除,只有收到主服务器的del命令才删除key

redis持久化

RDB持久化

  • 创建
创建方法 服务器情况
save 阻塞,无法处理请求
bgsave 非阻塞,派生出子进程创建,父进程继续处理请求
  • 自动间隔保存
    redis技术内幕_第11张图片
图12 RDB持久化示意图

图12展示了RDB文件持久化的逻辑,当客户端发送save、bgsave命令时,redis服务器会生成一个RDB文件;当redis服务器因某种原因宕机后重启时,redis会加载本地的dump.rdb文件还原。save和bgsave的区别是save会阻塞redis服务器,直到RDB文件生成完毕,而bgsave会fock出一个子进程进行操作,父进程继续处理客户端请求。由于生成RDB文件是一个比较耗时的操作,因此一般不建议直接执行save命令。
redis支持通过配置的方式以一定的策略进行bgsave的操作,如图12所示,在redis.conf配置文件中写入

save 900 1
save 300 10
save 60 10000

表明上一次生成RDB文件后,900s中有1次更新或者300s中有10次更新或者60s中有10000次更新时需要执行bgsave操作。那么对于RedisServer是怎么做到这一点的呢,首先redis服务器加载redis.conf将其解析成saveparams数组,数组中的每一个元素包含seconds和changes两个参数,在redisServer结构中保存了lastsave和dirty两个参数,它们的含义分别是上一次生成RDB文件的时间戳以及距离上一次生成RDB文件后执行了多少次更新键的操作,伪代码如下:

def serverCron():
  for param in saveparams:
    if (now - lastsave >= seconds) && dirty >= changes:
      bgsave
      lastsave = time_of_now
      dirty = 0
      break
  time.sleep(100ms)
  • RDB文件结构

redis技术内幕_第12张图片

图13 RDB文件结构

图13展示了RDB文件的结构,结构中最重要的就是databases数组,数组中的每一个元素都是database结构体

typedef struct database{
	const string SELECT_DB, // 一个常量,表示接下来是数据库标号
	int db_number, // 当前所在的数据库标号
	struct []key_value_pairs, // 键值对数组
}

typedef struct key_value_pair{
	const string EXPIRETIME_MS, // 一个常量,表示接下来是过期时间
	long ms, // 过期时间
	enum type, // value的类型,取值有STRING、LIST、ZSET等类型
	string key,
	object value, // 具体的value对象
}

AOF持久化

redis技术内幕_第13张图片

图14 AOF持久化过程
  • 持久化策略
    图14展示了AOF持久化的逻辑,服务器会将每一次更新键的指令写入到aof缓冲区(图中的aof_buf)中,AOF持久化有三种策略:always表示每次更新aof缓冲区即将其命令append到aof文件(图14中旧的aof文件);everysec表示每隔1s将aof缓冲区的命令append到aof文件中;no表示服务器进程不主动将aof缓冲区的命令append到aof文件中,何时append由操作系统底层决定。对比下这三者策略,always几乎不会丢数据,即使丢失也是只丢失最近的一条指令,但是磁盘IO太过频繁,对CPU极不友好;everysec和no性能相似,而且everysec策略极端情况下只会丢失1s内的操作数据,而no策略会丢失多少数据完全未知,因此一般都采用everysec策略。
  • aof重写
    随着命令执行越来越多,aof文件会越来越大,如果不进行重写,aof会占用极大的空间,服务器进程加载aof会消耗很长的时间,为了解决这个问题,redis服务器会对aof文件进行重写,重写不会对旧的aof文件有任何影响。服务器进程决定重写时,会fork一个子进程,子进程扫描父进程下所有的键值对,并将该键值对写入新的aof临时文件中,由于这个写入需要一个过程,父进程在子进程写入临时文件过程中仍需要继续处理客户端请求,因此,当fork出子进程后,父进程会将客户端命令同时写入aof_buf和aof_rewrite_buf两个缓冲区中,当子进程重写完成后,再执行aof_rewrite_buf中的指令,最后,覆盖掉旧的aof文件。

redis客户端与服务器

客户端与服务器结构

typedef redisServer struct {
	redisClient*[]clients, // 与服务端连接的多个客户端
}

typedef redisClient struct {
	int fd, // 当前客户端描述符,-1为伪客户端(redis中有两个地方需要使用伪客户端,一处是执行lua脚本时,一处是加载aof文件时),>-1为普通客户端
	robj * name, // 客户端名称
	int flags, // 标识客户端当前状态,redis_lua_client|redis_blocked表明客户端是lua客户端且客户端被blpush命令阻塞
	SDS querybuf, // 输入缓冲区,指定了客户端发出的请求,eg:set key v1
	robj ** argv, // 解析querybuf参数,将其分为set key v1三部分
	int argc, // 输入缓冲区参数数量
	redisCommand *cmd, // querybuf第一个参数对应的处理函数,eg:setCommand(因为querybuf中命令是set key v1),如果为空表明命令有误
	char buf[REDIS_REPLY_CHECK_BYTES],// 输出缓冲区,保存服务端响应的数据
	int bufpos, // 输出缓冲区已使用字节数
	list * reply, // 输出缓冲区不足,使用该结构
	int authenticated, // 用于标识客户端是否鉴权
	time_c lastinteraction, // 标识上一次和服务器交互的时间
}

redis集群

Redis哨兵模式

redis技术内幕_第14张图片

图15 redis哨兵模式

哨兵模式角色介绍

redis哨兵模式是redis提供的一种高可用方案,它的主要目标是高可用,当主服务器宕机时哨兵能够及时发现并从主服务器的从服务器中选出一个作为主服务器,哨兵模式主要有以下角色:

  • 哨兵集群
    1. 哨兵a 50.100:26379
    2. 哨兵b 50.101:26379
    3. 哨兵c 50.102:26379
  • 主节点(50.100:6379)
  • 从节点
    1. 从节点a 50.102:6379
    2. 从节点b 50.103:6379

其中,主节点会异步保持与两个从节点间的数据同步,哨兵集群中的所有哨兵会互相监控互通消息,哨兵a在启动时会监听一个主节点,每隔10s向主节点发送信息,主节点回应自己节点的信息、从节点信息以及所有监听自己的哨兵信息,哨兵a得到主节点回复的消息后会更新自己的结构,同时将主节点返回的信息同步发送给其他监听该主节点的哨兵。另外,哨兵a还会每隔10s向从节点发送消息以确认从节点是否在线。

故障迁移

redis技术内幕_第15张图片

图16 sentinelState结构信息

每一个哨兵都维持sentinelState的结构信息,图16给出了结构体字段的具体含义。current_epoch是一个int整数,含义为配置纪元,一会介绍哨兵故障迁移时会详细介绍它的作用。masters保存了所有被哨兵监听的主服务器信息,它是一个dict,key为主服务器名,value为sentinelRedisInstance结构体,该结构体中保存了从节点信息、监听自己的哨兵信息,以及自己的数据元信息。
哨兵模式存在的意义就是解决故障迁移问题。当主节点宕机时,假设此时哨兵a先发现无法正常与主节点通信,它会每隔1s给主节点发送消息,如果主节点没有在规定的时间(规定的时间就是sentinelRedisInstance结构体中的down_after_period字段)回应,哨兵a会将该主节点标记为主观下线;随后,哨兵a询问其他同样监听该主服务器的哨兵b和哨兵c是否也认为主服务器已下线,这里需要注意,每个哨兵都有sentinelState这个结构体,它们的down_after_period很可能不同,也就是说哨兵b和哨兵c不一定同意哨兵a的观点。如果哨兵集群中认为主节点主观下线的哨兵数量达到客观下线投票数(sentinelRedisInstance结构体中的quorum),那么主节点会被认为客观下线,此时,故障迁移开始了。
故障迁移开始时,所有的哨兵行动起来,互相通信选出一个领头sentinel,哪个哨兵先累计到quorum数,那么该哨兵就向其他哨兵发出选举申请,选举信息中包含自己的配置纪元,自己的节点runid,其他哨兵在收到选举申请后,判断选举申请中的配置纪元是否和自己当前配置纪元一致,如果小于自己的配置纪元,直接否决;否则,同意哨兵a的选举申请并向其发送确认消息。哨兵a在收到回应后,检查同意自己申请的哨兵是否超过哨兵集群半数以上,是则成为领头sentinel。选举之后,不论选举是否成功,所有哨兵的配置纪元都自增1。
领头sentinel选举成功后(假设哨兵a当选),哨兵a会在主节点中所有从节点选出一个节点作为新的主节点,选举的策略是当前在线的且数据和主节点最接近的从节点当选,一旦选出,哨兵a会给其他从节点发送消息,让其他从节点跟随新的主节点,并更新自己结构信息。

redis集群的数据复制与同步

redis技术内幕_第16张图片

图17 集群模式下数据的复制与同步

图17给出了数据复制的过程,首先从服务器向主服务器发送sync命令,告知主服务器需要进行数据同步,主服务器在收到命令后执行bgsave生成rdb持久化文件,并将其传送给从服务器,同时,主服务器会将此时客户端的写命令记录到buffer中。从服务器收到主服务器发来的rdb文件后进行加载,加载完成后向主服务器发送加载完成指令,主服务器将buffer中的命令传给从服务器,从服务器继续执行,直到主从数据达到一致的状态。
上面的复制过程看起来没什么问题,考虑这种情况,如果从服务器正常加载rdb文件后,在主服务器向从服务器传播buffer中的命令时,从服务器挂了,过了一段时间,从服务器恢复过来,但是并不知道该从什么地方继续同步,只好重新向主服务器发送sync命令。这就是旧版本的复制过程,而bgsave是比较消耗CPU资源的,redis从2.8版本后引入了新的复制机制。
redis技术内幕_第17张图片

图18 新版本的集群复制机制

如果一个从服务器是重新启动,或者之前的主服务器和当前主服务器不一致时,复制过程和图17一致;如果在复制过程中出现了宕机,如图18所示,情况又有什么不同呢。
首先,主服务器拥有一个编号runid标识自己的身份,并维护一个offset偏移量表明当前buffer命令的进度,在4个从节点中,也维持runid和offset两个变量,表明自己身份和已经自己已经复制的进度。假设主服务器当前offset=165,从服务器00b和00c跟主服务器一直保持同步,offset都是165;而从服务器00d在offset=98时便与主服务器断开连接,从服务器00a是在offset=160断开连接的,当主服务器offset=165时从服务器00a和00d连接恢复,此时,它们会首先询问主服务器当前的偏移量,得到回答是offset=165,随后,它们会去复制积压缓冲区中寻找自己丢失的那部分偏移量是否存在,00a需要161-165偏移量的数据,发现在缓冲区中,则00a直接从缓冲区中取出落后的偏移量并执行;00d需要99-165偏移量的数据,但是缓冲区中不够,于是00d只能告诉主服务器执行sync同步指令。如果此时主服务器收到客户端的更新请求,处理完成后主服务器更新偏移量,假设offset=165+33,主服务器会将新增的33偏移量数据发送给所有的从服务器,并且向复制积压缓冲区中也传播一份,缓冲区默认是1MB大小,如果满了,会清除旧的偏移量信息。

redis集群-槽指派和扩展

redis技术内幕_第18张图片

图19 redis集群模式下槽指派和扩展流程

redis的cluster集群主要目标是可扩展性,这点与哨兵集群模式不同。图19给出了cluster集群模式下是如何进行的。在一个cluster集群中会有16384个槽,cluster中每个主节点负责其中的若干个槽,图19中,0~3276槽归Node-1负责,依次类推。当需要对某个key进行操作时,首先计算slotID=CRC16(key)&16384=3281,那么集群通过slotID找到负责的节点即Node-2,再由Node-2执行相应的命令。如果此时进行扩展,即Node-5加入集群,那么集群会进行槽重新分配。

独立功能

I/O多路复用

redis技术内幕_第19张图片

图20 redis的I/O多路复用策略

redis是一个单线程的服务器,那它是怎么处理多个客户端连接并正确响应的呢。原因就在于redis使用了I/O多路复用机制。简单来说,I/O多路复用机制就是服务器同时监听多个文件描述符,当任意一个文件描述符就绪(有数据到达时),多路复用机制都能通知到redis服务器。
I/O多路复用机制将原本是多线程并发请求的场景进行请求排队转为单线程的问题,不仅提升了服务器的性能而且还避免了多线程环境下临界资源锁竞争的问题。能够做到这一点,是因为redis服务器是I/O密集型的服务器,网络I/O的时间要远远大于CPU处理时间。

内存淘汰策略

redis是一个内存数据库,当里面的数据越来越多时,内存占用会越来越大,如果不作处理,假以时日内存会到达上限,此时就会触发内存淘汰。redis支持多种内存淘汰策略,具体介绍如下

  • redis内存淘汰策略对比
淘汰策略 说明
noeviction 不淘汰,内存满后,新增失败
volatile-lru 根据lru算法淘汰redis服务器中带有过期时间的key
volatile-ttl 淘汰服务器中过期时间最短的key
volatile-random 随机淘汰服务器中带有过期时间的key
allkeys-lru 根据lru算法淘汰redis服务器中的key
allkeys-random 随机淘汰服务器中的key

serverCron

serverCron是redis服务器下的一个非常重要的定时任务,默认100ms执行一次,主要执行以下功能

  • 更新服务器的各类统计信息
    • 时间(由于serverCron是100ms执行一次,如果对时间要求特别精确就不要使用这里的时间)
    • 内存占用
    • 数据库占用
  • 过期键的清理
  • 关闭和清理失效的客户端
  • 尝试AOF和RDB持久化
  • 主从定期同步
  • 集群模式下连接测试

其他独立功能

  • lua
    redis服务器内嵌了lua的环境,当需要在服务器端进行逻辑运算时,就可以使用lua脚本,服务器在执行lua脚本时,会分配一个伪客户端进行交互,使用lua脚本的好处是可以减少网络I/O
  • 延时队列
    延时队列是一种常用的功能,比如淘宝的订单付款模块,在用户下单后,往往需要延迟一段时间进行付款,此时就需要用到延时队列。redis的zset可以支持这项功能,将事件作为zset的value,延时的时间time作为zset的二级key,然后主线程每隔一定时间检查一次zset,查看zset中最小的时间time是否到期,如果到期,则取出并触发value事件,否则睡眠一段时间继续监测。使用redis的zset实现延时队列优势是方便,用户不需要关心太多具体细节,劣势是当延时队列中数据非常多时,会带来性能问题,而且大部分时间都花在I/O上。延时队列有其他更好的实现方式,比如时间轮。
  • HyperLogLog
    HyperLogLog是一种统计算法,在redis中可以用来统计大数据下某一个key出现的次数,它有两个api:pfadd和pfcount,一个是添加,一个是计数,以下是使用案例
    pfadd name_list user1
    pfcount name_list  // 返回1
    pfadd name_list user1 // 重复添加只计算一次
    pfadd name_list user2
    pfadd name_list user3
    pfadd name_list user4
    pfcount name_list  // 返回4
    
    实际上,HyperLogLog实现了set的添加和求长度功能,只是HyperLogLog适用于大数据场景下,而且pfcount会有一定的误差,标准误差是0.81%,它的这项功能比set节约大量的内存。
  • 布隆过滤器
    布隆过滤器也是在set基础上,提供set集合的sadd和sismenber两个操作,和HyperLogLog类似,布隆过滤器比set节约大量的内存,布隆过滤器也有一定的误判率,合理设置参数可以有效降低误判率。以下是布隆过滤器的使用案例
    bf.add name_list user1
    bf.add name_list user1 // 重复添加只计算一次
    bf.add name_list user2
    bf.add name_list user3
    bf.add name_list user4
    
    bf.exists name_list user1 // 返回true
    bf.exists name_list userX // 有可能返回true
    
    也就是说,布隆过滤器返回false,则说明集合中一定不存在某项元素;返回true,说明集合中可能存在,也可能不存在(有一定的误判)。

总结

本文从redis最基本的数据结构出发,剖析基本数据结构的内部实现,希望能够帮助读者更清晰认识和使用redis的结构;第二部分介绍了redis的过期策略和持久化,详细阐述了redis是如何进行过期清理以及两种非常重要的持久化机制aof和rdb,这两种持久化方式类似wal和快照,帮助redis服务器宕机重启后能恢复到正常状态,第三部分介绍了redis客户端和服务器结构;第四部分介绍了redis的两种集群模式,哨兵模式和cluster,它们分别着眼于高可用性和可扩展性;最后介绍了redis的一些独立功能。
当然,redis的功能远不止这些,还有很多有趣且使用的功能本文并未完全列举出来,redis也在不断完善和修复中,欢迎大家一起交流讨论。

你可能感兴趣的:(笔记,redis,分布式,内存数据库,数据持久化)