《redis设计与实现》1-数据结构与对象篇

前言

  • redis性能为何这么出色?它与其余缓存中间件有什么区别?
  • redis底层使用了哪些数据结构支撑它如此高效的性能?
  • 内部丰富的数据类型底层为何都使用至少两种数据结构实现?分别是什么?
  • 若是合理的使用redis才能发挥它最大的优点?

学习完《redis设计与实现》前面关于数据结构与对象的章节,以上问题都能获得解答。你也能了解到redis做者如此的煞费苦心设计了这么多丰富的数据结构,目的就是优化内存。学完这些内容,在使用redis的过程当中,也会合理的使用以适应它内部的特色。固然新版本的redis支持了更多更丰富的特性,该书基于redis3版本,尚未涉及到那些内容。ios

《redis设计与实现》这本书很是浅显易懂,做者黄建宏老师,90后。另外仍是《redis实战》的译者nginx

另外一篇可参考《redis设计与实现》2-数据库实现篇redis

概述

特色

  1. c语言开发,性能出色,纯内存操做,每秒可处理超过10w读写(QPS)
  2. 多种数据结构,单个最大限制可到1GB(memcached只支持字符串,最大1M)
  3. 受物理内存限制,不能做海量数据的读写。适用于较小数据量的高性能操做和运算上
  4. 支持事务,持久化
  5. 单线程模型(memcached是多线程)

支持的数据类型

  1. Sring
  2. List
  3. Set
  4. SortedSet
  5. hash
  6. Bitmap
  7. Hyperloglogs
  8. Geo
  9. pub/sub

redis为何这么快

  1. 纯内存操做,没有磁盘io
  2. 单线程处理请求,没有线程切换开销和竞争条件,也不存在加锁问题
  3. 多路复用模型epoll,非阻塞io(多路:多个网络链接;复用:复用同一个线程) 多路复用技术可让单个线程高效的处理多个链接请求
  4. 数据结构简单,对数据操做也简单。还作了本身的数据结构优化

redis为何是单线程的

  1. 单线程已经很快了,减小多线程带来的网络开销,锁操做
  2. 后续的4.0版本在考虑多线程
  3. 单线程是指处理网络请求的时候只有一个线程,并非redis-server只有一个线程在工做。持久化的时候,就是经过fork一个子线程来执行。
  4. 缺点:耗时的命令会致使并发的降低,好比keys *

redis的回收策略

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

使用注意

  1. redis单线程没法发挥多核cpu性能,能够经过单机开多个redis实例来完善
  2. redis实现分布式锁:先用setnx(若是不存在才设置)争抢锁,抢到后,expire设置过时时间,防止忘记释放。
  3. redis实现一对多消息订阅:sub/pub数据结构
  4. redis实现延时消息队列:zadd时间戳做为score 消费的时候根据时间戳+延时时间作查询操做。

各大版本介绍

redis5版本新增功能:

  • zpopmax zpopmin以及阻塞变种:返回集合中给定分值最大最小的数据数量

reids4版本新增功能:

  • 模块功能,提供相似于插件的方式,本身开发一个.so模块,并加装 做者本人提供了一个神经网络的module。 可到redis-modules-hub上查看更多的module 模块功能使得用户能够将 Redis 用做基础设施, 并在上面构建更多功能, 这给 Redis 带来了无数新的可能性。
  • PSYNC:解决了旧版本的 Redis 在复制时的一些不够优化的地方
  • 缓存清理策略优化 新增last frequently used 对已有策略进行优化
  • 非阻塞DEL FLUSHDB FLUSHALL 解决了以前执行这些命令的时候致使阻塞的问题 Flushdb async, flushall async, unlink(替代del)
  • 添加了swapdb:交换数据库
  • 混合RDB-AOF的持久化格式
  • 添加内存使用状况命令:MEMORY

数据结构

  • redis里面每一个键值对都是由对象组成的
  • 键老是一个字符串对象,
  • 值则能够是如下对象的一种:
    • 字符串对象
    • 列表对象
    • 哈希对象
    • 集合对象
    • 有序结合对象

简单动态字符串SDS

数据结构

struct sdshdr {
    uint8_t len; /* used,使用的字节数 */
    uint8_t alloc; /* excluding the header and null terminator,预分配总字节数,不包括结束符\0的长度 */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[]; /*c风格的字符,包括结束符\0*/
};
复制代码
  • 位于sds.h文件
  • SDS遵循C字符串以\0结尾的惯例,存储在buf中(不一样于nginx的底层实现,nginx实现时不保存最后一个\0)
  • 可是不计算最后一个字符的长度到len中
  • 保留c风格buf的好处是能够重用一部分c函数库的函数

分配和释放策略

空间预分配

  • 用于优化SDS字符串增加操做,以减小连续执行增加操做所需的内存重分配次数
  • 扩展SDS空间时,先检查未使用的空间是否足够,若是足够直接使用,若是不够,不只分配够用,还预分配一些空间
  • 预分配策略:
    • 修改后的SDS长度(len的值)< 1MB,预分配一样len大小的空间
    • 修改后的SDS长度(len的值)>= 1MB,预分配1MB大小的空间

惰性空间释放

  • 用于优化SDS字符缩短操做
  • 缩短SDS空间时,并不当即进行内存重分配释放空间,而是记录free的字节数
  • SDS提供相应api,有须要时真正释放空间

比C字符串的优点

  • 获取字符串的长度时间复杂度由O(N)降到O(1)
  • 避免缓冲区溢出
  • 减小修改字符串时带来的内存重分配次数。内存分配会涉及复杂算法,且可能须要系统调用,很是耗时。
  • 二进制安全:c语言的结束符限制了它只能保存文本数据,不能保存图片,音频等二进制数据

链表

数据结构

位于adlist.h文件算法

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

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;
复制代码

特色

  • 双端队列,能够获取某个节点前置节点和后置节点,复杂度为O(1)
  • 无环
  • 获取表头和表尾复杂度为O(1)
  • 带长度,获取链表长度复杂度为O(1)
  • 多态:使用void*保存节点值,可保存不一样类型的值

字典

数据结构

位于dict.h文件数据库

哈希表

// 哈希表
typedef struct dictht {
    dictEntry **table; // 一个数组,数组中每一个元素都是指向dictEntry结构的指针
    unsigned long size; // table数组的大小
    unsigned long sizemask; // 值总数size-1
    unsigned long used; // 哈希表目前已有节点(键值对)的数量
} dictht;
复制代码

哈希节点

// 每一个dictEntry都保存着一个键值对,表示哈希表节点
typedef struct dictEntry {
    void *key; // 键值对的键
    // 键值对的值,能够是指针,整形,浮点型
    union { 
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next; // 哈希表节点指针,用于解决键冲突问题
} dictEntry;
复制代码

字典类型

每一个字典类型保存一簇用于操做特定类型键值对的函数api

typedef struct dictType {
    // 计算哈希值的函数
    uint64_t (*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;
复制代码

字典

// 字典
typedef struct dict {
    dictType *type; // 不一样键值对类型对应的操做函数
    void *privdata; // 须要传递给对应函数的参数
    dictht ht[2]; // ht[0]用于存放数据,ht[1]在进行rehash时使用
    long rehashidx; /* rehashing not in progress if rehashidx == -1,目前rehash的进度*/
    unsigned long iterators; /* number of iterators currently running */
} dict;
复制代码

哈希算法

  • redis使用MurmurHash2算法计算键的hash值
  • 哈希值与sizemask取或,获得哈希索引
  • 哈希冲突(两个或以上数量键被分配到哈希表数组同一个索引上):链地址法解决冲突

rehash

  • 对哈希表进行扩展或收缩,以使哈希表的负载因子维持在一个合理范围以内
  • 负载因子 = 保存的节点数(used)/ 哈希表大小(size)

rehash步骤包括

  • 为字典的ht[1]哈希表分配空间,大小取决于要执行的操做以及ht[0]当前包含的键值对数量
    • 扩展操做:ht[1]大小为第一个大于等于ht[0].used乘以2的2的n次幂
    • 收缩操做:ht[1]大小为第一个大于等于ht[0].used的2的n次幂
  • 将保存在ht[0]的全部键值对rehash到ht[1]上面:从新计算键的哈希值和索引值
  • 当全部ht[0]的键值对都迁移到ht[1]以后,释放ht[0],将ht[1]置为ht[0],并新建一个恐怖hash做为ht[1]

自动扩展的条件

  • 服务器没有执行BGSave命令或GBRewriteAOF命令,而且哈希表的负载因子 >= 1
  • 服务器正在执行BGSave命令或GBRewriteAOF命令,而且哈希表的负载因子 >= 5
  • BGSave命令或GBRewriteAOF命令时,服务器须要建立当前服务器进程的子进程,会耗费内存,提升负载因子避免写入,节约内存

自动收缩的条件

  • 哈希表负载因子小于0.1时,自动收缩

渐进式rehash

  • ht[0]数据从新索引到ht[1]不是一次性集中完成的,而是屡次渐进式完成(避免hash表过大时致使性能问题)

渐进式rehash详细步骤

  • 为ht[1]分配空间,让自动同时持有两个哈希表
  • 字典中rehashidx置为0,表示开始执行rehash(默认值为-1)
  • rehash期间,每次对字典执行操做时,顺带将ht[0]哈希表在rehashidx索引上的全部键值对rehash到ht[1]
  • 所有rehash完毕时,rehashidx设为-1

注意点

  • rehash的全部操做会在两个哈希表进行
  • 新增长的值一概放入ht[1],保证数据只会减小不会增长

跳跃表

  • 跳跃表是一种有序数据结构,经过在每一个节点维持多个指向其余节点的指针,达到快速访问节点的目的
  • 时间复杂度:最坏O(N),平均O(logN)
  • 大部分状况下,效率可与平衡树媲美,不过比平衡树实现简单
  • 有序集合的底层实现之一

数据结构

位于server.h文件中数组

// 跳跃表节点
typedef struct zskiplistNode {
    sds ele; // 成员对象
    double score; // 分值,从小到大排序
    struct zskiplistNode *backward; // 后退指针,从表尾向表头遍历时使用
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前进指针
        unsigned long span; // 跨度,记录两个节点之间的距离
    } level[]; // 层,是一个数组
} zskiplistNode;

// 跳跃表相关信息
typedef struct zskiplist {
    struct zskiplistNode *header, *tail; // 表头和表尾
    unsigned long length; // 跳跃表长度(包含节点的数量)
    int level; // 跳跃表内层数最大那个节点的层数(不包括表头节点层数)
} zskiplist;
复制代码

  • level数组的大小在每次新建跳跃表的时候,随机生成,大小介于1-32直接
  • 遍历操做只使用前进指针,跨度用来计算排位(rank),沿途访问的全部层跨度加起来就是节点的排位
  • 多个节点能够包含相同的分支,但每一个节点成员对象是惟一的

整数集合

  • intset是集合键的底层实现之一
  • 当一个集合只包含整数值原素,且数量很少时,会使用整数集合做为底层实现

数据结构

位于intset.h文件缓存

typedef struct intset {
    uint32_t encoding; // 编码方式
    uint32_t length; // 长度
    int8_t contents[]; // 内容,数组内容类型取决于encoding属性,并非int8_t。按照大小排序,没有重复
} intset;
复制代码

升级

  • 当咱们要将一个新元素添加到整数集合里,而且新元素的类型比整数集合现有全部的元素类型都要长时,集合要先进行升级才能添加新数据
  • 升级步骤包括三步:
    • 根据类型,扩展大小,分配空间
    • 将底层数组数据都转换成新的类型,并反倒正确位置
    • 新元素添加到底层数组里面
  • 添加元素可能致使升级,因此添加新元素的世界复杂度为O(N)
  • 不支持降级,升级后将一直保持新的数据类型

升级的好处

  • 提升灵活性
  • 节约内存

压缩列表

  • ziplist是列表键和哈希键的底层实现之一
  • redis为了节约内存而开发的顺序型数据结构
  • 当列表键只包含少许列表项,且每一个列表项要么是小整数,要么是短字符串,就使用ziplist做为列表键底层实现
  • 压缩列表遍历时,从表位向表头回溯遍历
  • ziplist没有专门的struct来表示

压缩列表的构成

属性 类型 长度 用途
zlbytes uint32_t 4字节 整个压缩列表占用的内存字节数
zltail uint32_t 4字节 表尾节点距离压缩列表起始地址有多少字节,无需遍历就可获得表尾节点
zllen uint16_t 2字节 节点数量,小于65535时是实际值,超过期须要遍历才能算出
entryN 列表节点 不定 包含的各个节点
zlend uint8_t 1字节 特殊值0xFF,末端标记

压缩列表节点的构成

  • previos_entry_length:前一个节点的长度,用于从表尾向表头回溯用
    • 若是前面节点长度小于254字节,preivos_entry_length用1字节表示
    • 若是前面节点长度小于254字节,preivos_entry_length用5字节表示,第1个字节为0xFE(254),后面四个字节表示实际长度
  • encoding:记录content的类型以及长度,encoding分为两部分,高两位和余下的位数,最高两位的取值有如下状况:
    最高两位取值 表示是数据类型 encoding字节数 余下的bit数 最大范围
    00 字符数组 一个字节 6bit 63位
    01 字符数组 两个字节 14bit 2^14-1
    10 字符数组 五个字节 4*8,第一个字节余下的6bit留空 2^32-1位
    11 整数 1个字节 000000 int16_t类型整数
    11 整数 1个字节 010000 int32_t类型整数
    11 整数 1个字节 100000 int64_t类型整数
    11 整数 1个字节 110000 24位有符号整数
    11 整数 1个字节 111110 8位有符号整数
    11 整数 1个字节 xxxxxx 没有content,xxxx自己就表示了0-12的整数
  • content:保存节点的值

连锁更新

  • 连续多个节点大小介于254左右的节点,因扩展致使连续内存分配的状况。不过在时间状况下,这种状况比较少。

对象

概述

  • redis并无直接使用前面的数据结构来实现键值对的数据库,而是基于数据结构建立了一个对象系统,每种对象都用到前面至少一种数据结构
  • 每一个对象都由一个redisObject结构来表示
//server.h
typedef struct redisObject {
   unsigned type:4; //类型
   unsigned encoding:4; // 编码
   // 对象最后一个被命令程序访问的时间
   unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                           * LFU data (least significant 8 bits frequency
                           * and most significant 16 bits access time). */
   int refcount; // 引用计数
   void *ptr; // 指向底层的数据结构指针
} robj;
复制代码

使用对象的好处

  • 在执行命令以前,根据对象类型判断一个对象是否能够执行给定的命令
  • 针对不一样厂家,Wie对象设置多种不一样的数据结构实现,从而优化效率
  • 实现了基于引用计数的内存回收机制,再也不使用的对象,内存会自动释放
  • 引用计数实现对象共享机制,多个数据库共享同一个对象以节约内存
  • 对象带有时间时间积累信息,用于计算空转时间

redis中的对象

  • 字符串对象
  • 列表对象
  • 哈希对象
  • 集合对象
  • 有序结合对象

对象的类型与编码

对象的类型

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

对象的编码

  • 编码决定了ptr指向的数据类型,代表使用什么数据类型做为底层实现
  • 每种类型对象至少使用两种不一样的编码
  • 经过编码,redis能够根据不一样场景设定不一样编码,极大提升灵活性和效率
编码常量 对应的数据结构 OBJECT ENCODING命令输出
REDIS_ENCODING_INT long类型的整数 “int”
REDIS_ENCODING_EMBSTR 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”

字符串对象

  • 字符串对象的编码能够是
    • int
    • raw
    • embstr
  • 浮点数在redis中也是做为字符串对象保存,涉及计算时,先转回浮点数。
字符串对象内容 长度 编码类型
整数值 - int
字符串值 小于32字节 embstr
字符串值 大于32字节 raw

embstr编码是专门用于保存短字符串的一种优化编码方式。这种编码和raw编码同样,都使用redisObject结构和sdshdr结构来表示对象。区别在于:安全

  • raw编码调用两次内存分配函数来分别建立redisObject和sdrhdr结构
  • embstr则调用一次内存分配函数来建立一块连续空间,里面包括redisObject和sdrhdr

编码转换

int编码和embstr编码的对象知足条件时会自动转换为raw编码的字符串对象bash

  • int编码对象,执行命令致使对象再也不是整数时,会转换为raw对象
  • embstr编码没有相应执行函数,是只读编码。涉及修改时,会转换为raw对象

字符串命令

redis中全部键都是字符串对象,因此全部对于键的命令都是针对字符串键来构建的

  • set
  • get
  • append
  • incrbyfloat
  • incrby
  • decrby
  • strlen
  • strrange
  • getrange

列表对象

  • 列表对象的编码能够是
    • ziplist
    • linkedlist

编码转换

使用ziplist编码的两个条件以下,不知足的都用linkedlist编码(这两个条件能够在配置文件中修改):

  • 保存的全部字符串元素的长度都小于64字节
  • 列表的元素数量小于512个

列表命令

  • lpush
  • rpush
  • lpop
  • rpop
  • lindex
  • llen
  • linsert
  • lrem
  • ltrim
  • lset

哈希对象

哈希对象的编码能够是

  • ziplist
  • hashtable

编码转换

  • 使用ziplist须要知足两个条件,不知足则都使用hashtable(这两个条件能够在配置文件中修改)
    • 全部键值对的键和值的字符串长度都小于64字节
    • 键值对数量小于512个

哈希命令

  • hset
  • hget
  • hexists
  • hdel
  • hlen
  • hgetall

集合对象

集合对象的编码能够是:

  • intset:全部元素保存在整数集合里
  • hashtale:字典的值为null

编码转换

集合使用intset须要知足两个条件,不知足时使用hashtable(参数可经过配置文件修改)

  • 保存的全部元素都是整数值
  • 元素数量不超过512个

集合命令

  • sadd
  • scard
  • sismember
  • smembers
  • srandmember
  • spop
  • srem

有序结合对象

有序集合的编码能够是

  • ziplist:每一个元素使用两个紧挨在一块儿的节点表示,第一个表示成员,第二个表示分值。分值小的靠近表头,分值大的靠近表尾
  • skiplist:使用zset做为底层实现,zset结构同时包含了字典和跳跃表,分别用于根据key查找score和分值排序或范围查询
// 两种数据结构经过指针共享元素成员和分值,不会浪费内存
typedef struct zset {
    zskplist *zsl; //跳跃表,方便zrank,zrange
    dict *dict; //字典,方便zscore
}zset;
复制代码

编码转换

当知足如下两个条件时,使用ziplist编码,不然使用skiplist(可经过配置文件修改)

  • 保存的元素数量少于128个
  • 成员长度小于64字节

有序集合命令

  • zadd
  • zcard
  • zcount
  • zrange
  • zrevrange
  • zrem
  • zscore

类型检查和命令多态

redis的命令能够分为两大类:

  • 能够对任意类型的键执行,如
    • del
    • expire
    • rename
    • type
    • object
  • 只能对特定类型的键执行,好比前面各类对象的命令。是经过redisObject的type属性实现的

内存回收

redis经过对象的refcount属性记录对象引用计数信息,适当的时候自动释放对象进行内存回收

对象共享

  • 包含一样数值的对象,键的值指向同一个对象,以节约内存。
  • redis在初始化时,建立一万个字符串对象,包含从0-9999的全部整数值,当须要用到这些值时,服务器会共享这些对象,而不是新建对象
  • 数量可经过配置文件修改
  • 目前不包含字符串的对象共享,由于要比对字符串是否相同自己就会形成性能问题

对象空转时长

  • 空转时长=如今时间-redisObject.lru,lru记录对象最后一次被访问的时间
  • 当redis配置了最大内存(maxmemory)时,回收算法判断内存超过该值时,空转时长高的会优先被释放以回收内存

参考命令

# 设置字符串
set msg "hello world"
rpush numbers 1 2 3 4 5
llen numbers
lrange numbers 0 5
# 获取键值使用的底层数据结构
object encoding numbers
# 查看对象的引用计数值
object refcount numbers
# 对象空转时长: value=now-object.lru
object idletime numbers
复制代码

参考文献

  • 《redis设计与实现》
相关文章
相关标签/搜索