Redis复习总结

265 阅读14分钟

前言

本文是自己阅读 Redis 相关文章和数据以及对 Redis 实际操作后的一些总结,旨在对学习过的 Redis 进行总结和复习。

由于本人水平有限,在讲述中可能会出现纰漏和错误,希望大家可以指出,一起学习,一起进步。

Redis 内置数据类型

Redis 内置的数据类型可以分为基本数据类型和高级数据类型。其中基本数据类型便是经常使用的5类:stringlistsetzsethash

字符串的底层实现是字节,所以可以直接操作字符串的位。比如如果要记录某个人一年的打卡记录,那么不需要创建365个字符串,只需要创建一个365位的位图就可以了。

set 的底层实现是 hash,该 hash 中的每个 value 都是空值。zset 的底层实现是跳跃表 + 哈希,后面的 Redis 数据结构实现 会讲到这一部分。

对于队列的操作,如果没有元素读取,那么可以选择直接返回空,或者阻塞读。在阻塞读的时间内,Redis 可能因为长时间没有操作而关闭该连接,此时阻塞读会抛出异常。

阻塞写和阻塞读的操作是一样的。

对于 list、set、zset、hash,Redis 都是使用 如果没有则创建,如果没有元素则删除 的策略进行管理的。

渐进式 hash 会在 rehash 时,创建一个新的 hash 表。在查询时会先去新旧哈希表进行查找,如果在旧表找到了数据就会在返回数据前先将数据迁移到旧表。如果长时间没有查询操作,Redis 也会周期性将旧哈希表的数据迁移到新哈希表。

高级数据类型是对 Redis 在一些特殊场景下的使用,主要的结构有:HyperLogLogGeoHashReboolm, 现在对这两种结构进行简单的说明:

  1. HyperLogLog 用于提供不精确的去重计数方案。它主要使用的场景为统计某大型网站或页面的 PV/UV。当网站的访问量达到数千万乃至亿级时,简单地使用 string 存储每个用户的访问信息对服务器的性能会造成很大的压力,HyperLogLog 就是用于处理这种情况的。关于它的算法理解,可以参考这篇博客:探索HyperLogLog算法(含Java实现)

  2. GeoHash 用于存储和处理地理信息。可以将某一地点的经纬度存储在该数据结构中,对应的操作有:计算两个元素间的距离获取某一地点指定范围的其它地点。GeoHash 的底层是用 zset 集合存储的,所以在数据迁移过程中,可能会因为 key 过大导致集群迁移出现卡断,因此对于 Geo 数据最好使用单独的 Redis 实例进行部署和拆分。

  3. Rebloom 用于不精确地判断某一值是否存在于集合中。原理是通过 hash 值将对应位的 0 置为 1。那么如果要判断某个值是否存在,只需要看它 hash 值对应的位是否都为1就可以了,但是这样是不精确的,因为可能是其它的值占了该位,这也是为什么说它不精确。但是在某些不注重精确的场景下,布隆过滤器能起到很好的作用。

    Reids 中可以设置布隆过滤器的相关参数:

    1. error_rate:误判率,该值越低,需要的空间就越大。
    2. initial_size:表示预计的元素数量,当实际数量超过这个值时,误判率会上升。

Redis 数据结构实现

因为 Redis 是运行在内存的数据库,所以对于数据类型的实现都要尽量地减少内存占用。Redis 主要的数据实现由以下结构,下面会对这些结构进行讲述:

  1. sds
  2. ziplist
  3. quicklist
  4. intset
  5. skiplist

在了解各结构前,我们要知道 Redis 每一个对象都是由一共 redisObject 表示,redisObject 的数据结构如下:

typedef struct redisObject {
    unsigned type:4; // 对象的数据类型,对应 Redis 对外暴露的 5 种基本数据结构
    unsigned encoding:4; // 对象的内部表示方式
    unsigned lru:LRU_BITS; // 记录对象的 LRU 信息
    int refcount; // 引用计数,Redis 自实现的垃圾回收算法
    void *ptr; // 数据指针
} robj;

SDS

sds 全称为 Simple Dynamic String,即动态字符串,它的内部结构定义如下:

struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* 有效长度 */
    uint16_t alloc; /* 总容量 */
    unsigned char flags; /* 1byte,最低3位标识 header 类型 */
    char buf[];
};

sds 一共有 5 种 header,分别是:

  1. SDS_TYPE_5 0
  2. SDS_TYPE_8 1
  3. SDS_TYPE_16 2
  4. SDS_TYPE_32 3
  5. SDS_TYPE_64 4

对于 sds 来说,不同的长度也有不同的内存分配方式。当 string 的值是数字时,采用 intset 存储。当 string 的值是字符串时,如果字符串的总体长度没有超过 64KB,那么采用 emstr 形式分配内存,否则采用 row 形式分配内存。emstr 和 row 的区别在于 emstr 是将 redisObject 和 sds 分配为连续内存,只需要分配一次,而 row 则是将 redisObject 和 sds 分开分配,这样需要分配两次。对于 intset 的存储后面会进行讲述的。

Ziplist

ziplist 是在 list、hash 元素较少的情况下进行存储的数据结构,它的结构如下所示:

// ziplist 总大小
#define ZIPLIST_BYTES(zl)       (*((uint32_t*)(zl)))

// 最后一项的偏移量
#define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl) + sizeof(uint32_t))))

// ziplist 的长度
#define ZIPLIST_LENGTH(zl)      (*((uint16_t*)((zl) + sizeof(uint32_t) * 2)))

// iplist 头部大小
#define ZIPLIST_HEADER_SIZE     (sizeof(uint32_t) * 2 + sizeof(uint16_t))

// 压缩列表尾部大小:1比特
#define ZIPLIST_END_SIZE        (sizeof(uint8_t))

// ziplist 第一个变量入口地址
#define ZIPLIST_ENTRY_HEAD(zl)  ((zl) + ZIPLIST_HEADER_SIZE)

// ziplist 最后一个变量入口地址
#define ZIPLIST_ENTRY_TAIL(zl)  ((zl) + intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl)))

// ziplist 最后一位的地址
#define ZIPLIST_ENTRY_END(zl)   ((zl) + intrev32ifbe(ZIPLIST_BYTES(zl)) - 1)


// ziplist 节点
typedef struct zlentry {
    unsigned int prevrawlensize; // 指 prevrawlen 的大小,有1字节和5字节两种
    unsigned int prevrawlen; // 前置节点的长度
    unsigned int lensize; // 编码 len 所需的字节大小
    unsigned int len; // 当前节点长度
    unsigned int headersize; // 当前节点的 header 大小
    unsigned char encoding; // 节点的编码方式
    unsigned char *p; // 指向节点的指针
} zlentry;

从 zlentry 节点我们可以看出当前节点存储着前驱节点的长度,并且 prevrawlensize 长度有两种取值:1byte 或者 5byte。当一个 entry 的前驱节点的长度发生变化时,会导致需要增大entry 的 prevrawlensize 字段的 size 来存储前一个entry的长度,如果有连续多个节点的容量接近254,就会发生多个节点的 prevrawlensize 的 size 需要扩容,这时就发生所谓的级联更新。

为了解决这种情况,Redis 又推出了新的 ziplist 节点结构体,即 listpack,同时将 listpack 的结构体加入到了 zlentry 中。所以现在的 zlentry = ziplistNode + listpackNode。

listpack 记录的是当前节点长度,这样的话就不要产生级联更新。

编码转换(同时满足以下条件才能使用 ziplist 编码):

  1. 列表:所有元素长度小于 64KB,元素数量小于 512 个。不满足的使用 quicklist。
  2. 哈希:所有键和值长度小于 64KB,键值对数量小于 512 个。不满足的使用 hashtable。
  3. 有序集合:所有元素长度小于 64KB,元素个数小于 128 个,不满足的使用 skiplist。

Intset

intset 是整数集合的存储结构,当 set 集合容纳的元素都是整数并且元素个数较少的时候使用。代码如下所示:

typedef struct intset {
    uint32_t encoding; // 编码类型 int16_t、int32_t、int64_t
    uint32_t length; // 数据长度
    int8_t contents[]; // 数据
} intset;

对于整型数组的存储,intset 先是尝试用 int_16 进行存储,如果有某个元素大于这个长度,那么就会进行升级操作,用 int_32、int_64。不过要注意 intset 只会进行升级操作,不会进行降级操作。

quicklist

quicklist 就是一个双向链表,每个节点的元素是 ziplist,ziplist 里又存储着许多元素。整体结构如下所示:1602749223470

代码如下所示:

typedef struct quicklist {    quicklistNode *head; // 指向 quicklist 的头部
    quicklistNode *tail; // 指向 quicklist 的尾部
    unsigned long count; // 元素总数
    unsigned long len; // ziplist 的个数
    int fill : QL_FILL_BITS; // ziplist 大小限定
    unsigned int compress : QL_COMP_BITS; // 节点压缩深度设置
    unsigned int bookmark_count: QL_BM_BITS; // bookmarks 数组长度
    // 当 quicklist 长度特别长,需要迭代遍历时,会使用到该数组作为缓存。
    // 该数组的长度应保持在较小值,以供高效查找更新
    quicklistBookmark bookmarks[];
} quicklist;

typedef struct quicklistNode {
    struct quicklistNode *prev; // 指向上一个 ziplist 节点
    struct quicklistNode *next; // 指向下一个 ziplist 节点
    unsigned char *zl; // 数据指针,如果没有被压缩,就指向 ziplist 结构,反之指向 quicklistLZF 结构 
    unsigned int sz; // 表示指向 ziplist 结构的总长度(内存占用长度)
    unsigned int count : 16; // 表示 ziplist 中的数据项个数
    unsigned int encoding : 2; // 编码方式,【1,ziplist】,【2,quicklistLZF】
    unsigned int container : 2;  // 预留字段,存放数据的方式,【1,NONE】,【2,ziplist】
    unsigned int recompress : 1; // 解压标记,当查看一个被压缩的数据时,需要暂时解压,标记此参数为1,之后再重新进行压缩
    unsigned int attempted_compress : 1; // 测试相关
    unsigned int extra : 10; // 扩展字段,暂时没用
} quicklistNode;

// quicklist 迭代器
typedef struct quicklistIter {
    const quicklist *quicklist; // 指向所在 quicklist 的指针
    quicklistNode *current; // 指向当前节点的指针
    unsigned char *zi; // 指向当前节点的 ziplist
    long offset; // 当前 ziplist 中的偏移地址
    int direction; // 迭代器的方向
} quicklistIter;

// quicklist 节点中 ziplist 里的一个节点结构
typedef struct quicklistEntry {
    const quicklist *quicklist; // 指向所在 quicklist 的指针
    quicklistNode *node; // 指向当前节点的指针
    unsigned char *zi; // 指向当前节点的 ziplist
    unsigned char *value; // 当前指向的 ziplist 中的节点的字符串值
    long long longval; // 当前指向的 ziplist 中的节点的整型值
    unsigned int sz; // 当前指向的 ziplist 中的节点的字节大小
    int offset; // 当前指向的 ziplist 中的节点相对于 ziplist 的偏移量
} quicklistEntry;

quicklist 默认单个 ziplist 的大小位 8KB,超过这个字节数,就会创建一个新的 ziplist。当然该值也可以通过 list-max-ziplist-size 配置。

skiplist

跳跃表的思想跟二分法很像,整体的结构如下:1602750235460

代码如下所示:

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

typedef struct zskiplist {
    struct zskiplistNode *header; // 头部节点
    struct zskiplistNode *tail; // 尾部节点
    unsigned long length; // 元素长度
    int level; // 最大层级
} zskiplist;

typedef struct zskiplistNode {
    sds ele;
    double score; // 分值
    struct zskiplistNode *backward; // 后驱指针
    struct zskiplistLevel { // 每一个结点的层级
        struct zskiplistNode *forward; // 某一层的前驱节点
        unsigned long span; // 某一层距离下一个节点的跨度
    } level[];
} zskiplistNode;

跳跃表只是近似二分查找,但实际上并不需要维护一个完美的跳跃表,可以使用一种基于概率统计的插入算法来得到时间复杂度为 O(logN) 的查找效率。

Redis 的 RDB 和 AOF

Redis 的数据本地化分为两种,一种是 RDB(Redis DataBase),一种是 AOF(Append Only File)。RDB 就是直接将 Redis 中的数据持久化到本地,属于全量备份,文件跟 MySQL 的 binlog 差不多,而 AOF 则是持久化修改了 Redis 中的命令到文件中,属于增量备份,生成的文件跟 MySQL 的 redolog 差不多。

AOF

一般来说 AOF 的文件都是比 RDB 文件要大的,但是 AOF 可以更好地保证数据不丢失,一般 AOF 每隔一秒就会通过一个后台进程执行一次 fsync 操作将缓冲区的数据写入文件中。Redis 也提供了另外两种策略,一个是永不调用调用 fsync,让操作系统来决定何时同步到磁盘,一个是每执行一次命令就同步到磁盘。

当 Redis 收到客户端请求后,会先进行参数校验、逻辑处理,如果没有问题,那么就先将指令文本存储到 AOF 日志中,再在内存中执行指令。这和 MySQL 的执行顺序不一样,大部分数据库都是先执行执行,再记录日志的。

RDB

COW(Copy On Write)在 RDB 持久化操作中使用。Redis 在持久化时会调用 glibc 的函数 fork 一个子进程,该子进程来进行快照,父进程继续处理客户端请求,子进程和父进程共享内存。当父进程修改某页的数据时,会先将要修改的页复制一份,然后再复制的页上进行操作,子进程依然对原来的数据进行持久化。

RDB + AOF

前面我们了解了 RDB 对数据安全的支持性不及 AOF,但纯 AOF 文件又太大了。因此 Redis 提供了 RDB + AOF 混合持久化的解决方案。首先通过一个 checkpoint 点标记持久化的尾部,在 checkpoint 前面的数据进行 RDB写入,checkpoint 后面的操作用 AOF 记录。这样在 Redis 重启时,先加载 RDB 文件,再加载 AOF 文件就可以了。

Redis 内存回收策略

TTL 处理策略

Redis 会将设置了过期时间的 Key 放入到一个独立的哈希表,过期处理有懒惰删除和定时伤处两种:

  1. 懒惰删除:当用户访问键时先查看是否过期了,如果过期了就直接删除。
  2. 定时删除:定时遍历这个字典来删除过期的 Key。Redis 默认每秒进行 10 次过期扫描,扫描时会从哈希表里随机选出 20 个 Key,删除这些 Key 中过期的键,如果过期的键超过了 1/4,那么就会再次遍历。同时为了保证扫描时不会因为遇到大对象导致较长的停顿,还设置了25秒为最长扫描时间。

定时删除是为了处理到期数据如果一直没有被访问,那就会一直驻留在内存的问题。

内存溢出处理策略

当 Redis 内存使用达到 maxmemory 时会触发溢出回收算法,Redis 提供了6中可选策略来让用户指定:

  1. noeviction:不进行内存回收,后面可以正常进行读,但是无法进行写。
  2. volatile-lru:对设置过期时间的 Key 执行 LRU 算法,如果没有要删除的 key,就回退到 noeviction。
  3. volatile-ttl:删除快要过期的 Key,如果没有的话就回退到 noeviction。
  4. volatile-random:在设置过期时间的 Key 进行随机删除操作,直到有足够的可用空间。
  5. allkeys-lru:对所有 Key 执行 LRU 算法,直到有足够的可用空间。
  6. allkeys-random:对所有的 Key 进行随机删除,直到有足够的可用空间。

当 Key 删除时,不会立即回收内存。因为在操作系统中,内存是以页的单位存在的,一个页有若干个 Key,只有这个页的所有 Key 删除后,该页才会被回收。Redis 虽然无法保证立即回收删除的 Key 的内存,但是会重用该可用空间。

Redis 部分指令

info

可以通过 info 查看 Redis 运行的情况和参数。info 指令包含很多信息,大致可以分为以下9部分:

  1. server:服务器运行的环境参数
  2. clients:客户端相关信息
  3. memory:服务器的内存统计信息
  4. persistence:持久化的相关信息
  5. stats:通用的统计信息
  6. replication:主从复制的相关信息

scan

在遍历Redis 的大量 key 时,如果使用 keys 指令,因为是一次性将所有符合条件的键查找出来,它的时间复杂度是 O(N),会造成服务器的卡顿,此时就需要一个适合该场景的指令,也就是 scan。scan 具有以下特点:

  1. 通过游标分布遍历所有键(虽然最后也是 O(N) 的复杂度,但是可以分批进行处理)
  2. 可以通过设置 limit 参数指定查找的符合数据的个数
  3. 提供模式匹配
  4. 每一次 scan 都会返回当前游标,客户端获取游标后可以继续遍历
  5. 修改后的数据无法确定是否会被遍历