Redis 核心设计原理

博文目录


基本特性

  • 非关系型的键值对数据库,可以根据键以O(1) 的时间复杂度取出或插入关联值
  • Redis 的数据是存在内存中的
  • 键值对中键的类型可以是字符串,整型,浮点型等,且键是唯一的
  • 键值对中的值类型可以是string,hash,list,set,sorted set 等
  • Redis 内置了复制,磁盘持久化,LUA脚本,事务,SSL, ACLs,客户端缓存,客户端代理等功能
  • 通过Redis哨兵和Redis Cluster 模式提供高可用性

使用限制

在这里插入图片描述
Redis 理论可以管理 232 (接近43亿) 个 key, 实际也能管理至少 2.5 亿个 key, hash / list/ set /zset 等都能管理 232 个元素

Redis String 数据类型, 最大可以使用 512M 内存空间, 这是源码里写死的 (Redis 6 好像可以配置该值了 proto-max-bulk-len)

Redis Key 是 String 类型, 所以 Key 不能超过 512M, 但是一般建议 Key 的大小不要超过 1KB

对于 String 类型的 Redis Value 值上限为 512M,而集合、链表、哈希等类型,单个元素的 Value 上限也为512M

应用场景

  • 计数器: 可以对 String 进行自增自减运算,从而实现计数器功能。Redis 这种内存型数据库的读写性能非常高,很适合存储频繁读写的计数量。
  • 分布式ID生成: 利用自增特性,一次请求一个大一点的步长如 incr 2000 ,缓存在本地使用,用完再请求。
  • 海量数据统计: 位图(bitmap):存储是否参过某次活动,是否已读谋篇文章,用户是否为会员, 日活统计。
  • 会话缓存: 可以使用 Redis 来统一存储多台应用服务器的会话信息。当应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以请求任意一个应用服务器,从而更容易实现高可用性以及可伸缩性。
  • 分布式队列/阻塞队列: List 是一个双向链表,可以通过 lpush/rpush 和 rpop/lpop 写入和读取消息。可以通过使用brpop/blpop 来实现阻塞队列。
  • 分布式锁实现: 在分布式场景下,无法使用基于进程的锁来对多个节点上的进程进行同步。可以使用 Redis 自带的 SETNX 命令实现分布式锁。
  • 热点数据存储: 最新评论,最新文章列表,使用list 存储,ltrim取出热点数据,删除老数据。
  • 社交类需求: Set 可以实现交集,从而实现共同好友等功能,Set通过求差集,可以进行好友推荐,文章推荐。
  • 排行榜: sorted_set可以实现有序性操作,从而实现排行榜等功能。
  • 延迟队列: 使用sorted_set,使用 【当前时间戳 + 需要延迟的时长】做score, 消息内容作为元素,调用zadd来生产消息,消费者使用zrangbyscore获取当前时间之前的数据做轮询处理。消费完再删除任务 rem key member

# 前置数据结构

Redis 核心结构和 Java 中的 HashMap 非常相似,本质上就是数组加链表的结构,使用头插法解决冲突。具体到不同的数据类型里面,又有不同的编码和实现方式

在这里插入图片描述

在这里插入图片描述

redisDb

我们说的redis默认有16个数据库, 其实就是指 redisDb 结构的对象有16个

typedef struct redisDb {
    dict *dict; // dict 就是 map, redis每一个库的键值对都存在这个字段的结构里
    dict *expires;// 过期时间也是存在map结构里面的
    dict *blocking_keys;// 阻塞队列的处理, key和客户端的关系在这里维护
    dict *ready_keys;           
    dict *watched_keys;         
    int id;// 数据库id, select 1, 就是选id为1的数据库
    long long avg_ttl;           
    unsigned long expires_cursor;  
    list *defrag_later;          
} redisDb;

dict

dict 类似于 map, dictht 就是 map 中的 Node<K,V>[] table

typedef struct dict {
    dictType *type; // hash方法,键值对比较方法 都在这个结构里定义
    void *privdata;
    dictht ht[2];// 两个hash table, 用来做渐进式扩容(rehash), 一个old, 一个new(扩容时才会使用到), 分批逐渐将old中的挪到new中, 结束后重新指向old
    long rehashidx; 
    unsigned long iterators;  
} dict;

dictht

hash table 结构

typedef struct dictht {
    dictEntry **table;// 就是 Node<K,V> implements Map.Entry<K,V> 的概念, 一个key和一个value
    unsigned long size;// 数组的长度, 容量(hash槽的数量)
    unsigned long sizemask;// size-1, 2^n-1, 用来快速计算索引的
    unsigned long used;// 元素个数, 即entry的数量
} dictht;

dictEntry

typedef struct dictEntry {
    void *key;// 就是 Node<K,V> 里面的 K key
    union { // union中的字段只会被使用到一个, 就是 Node<K,V> 里面的 V value
        void *val;// void * 可以是string, list, set 等各种类型, 用 redisObject 来封装, 指向的是一个 redisObject 对象
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;// hash冲突是, 指向链表的下一个entry, 就是 Node<K,V> 里面的 Node<K,V> next
} dictEntry;

redisObject

typedef struct redisObject {
    unsigned type:4;// 4big, 可代表2^4种类型, 表明对象的类型, string/hash/list/等, 用于约束客户端命令, 如给string类型的值做lpush操作会报错
    unsigned encoding:4;// 值在redis底层使用哪种编码格式, 主要是为了优化提升效率, 比如string有int/embstr/raw(raw即sds)等, hash有ziplist/hashtable等, 可通过 object encoding key 来查看
    unsigned lru:LRU_BITS;// 内存淘汰策略
    int refcount;// 引用计数法管理内存
    void *ptr;// 真是存储数据的内存的地址, 如果是, 可以直接存储在这个地址内, 而不是指向另一个地址
} robj;

底层原理

Redis 最常用的 5 种数据类型(String、Hash、List、Set、Sorted Set),每种数据类型都提供了最少两种内部的编码格式,而且每个数据类型内部编码方式的选择对用户是完全透明的,Redis会根据数据量自适应地选择较优的内部编码格式。例如string数据结构就包含了raw(sds)、int和embstr三种内部编码。同时,有些内部编码可以作为多种外部数据结构的内部实现,例如ziplist就是hash、list和zset共有的内部编码。

# 查看值的数据结构
type key
# 查看值的编码格式
object encoding key

Redis 中定义的编码格式大概有如下几种

#define OBJ_ENCODING_RAW 0     /* Raw representation */
#define OBJ_ENCODING_INT 1     /* Encoded as integer */
#define OBJ_ENCODING_HT 2      /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3  /* No longer used: old hash encoding. */
#define OBJ_ENCODING_LINKEDLIST 4 /* No longer used: old list encoding. */
#define OBJ_ENCODING_ZIPLIST 5 /* No longer used: old list/hash/zset encoding. */
#define OBJ_ENCODING_INTSET 6  /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7  /* Encoded as skiplist */
#define OBJ_ENCODING_EMBSTR 8  /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of listpacks */
#define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */
#define OBJ_ENCODING_LISTPACK 11 /* Encoded as a listpack */

压缩列表 ziplist

在这里插入图片描述

ziplist 是一种特殊编码的双向链表,旨在实现非常高效的内存使用。它可以存储字符串和整数值,其中整数被编码为实际的整数而不是一系列字符。它允许在列表的任一侧进行 O(1) 时间复杂度的 push 和 pop 操作。但是,由于每次操作都需要重新分配 ziplist 使用的内存(新增、删除、更新都得重新分配内存,复制旧元素),实际的复杂度与 ziplist 使用的内存量相关。

压缩列表是一个非常紧凑的数据结构,每个数据之间没有内存间隙,就像一个填满元素的数组,当插入一个新的数据时,需要重新申请内存空间,并把之前的数据全都复制过去。所以为了保证足够的性能,压缩列表的数据量必须有一个上限设定,在数据节点不多的情况下,内存占用和查询复杂度得到一个相对较好的平衡。

设计用于提高内存效率,不过内存利用率提高了,相关的查询操作速度自然会降低,因为多了寻找和编码解码的操作。可以说是时间换空间的。操作耗时会根据具体使用的内存而使得操作的复杂度提升,比如说 HMset 批量操作,操作的属性越多,复杂度也就越高,n个属性,复杂度就是O(n)。

ziplist 一般布局如下,注意:除非另有说明,否则所有字段均以小端存储。

<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>

字段类型/长度说明
zlbytesuint32_t记录整个压缩列表所占用的内存空间大小
zltailuint32_t记录压缩列表中最后一个元素的内存偏移,便于直接定位到列表尾部
zllenuint16_t记录压缩列表中元素的个数,当条目数超过 216-2 时,这个值被设置为 216-1,需要遍历整个列表才能知道它包含多少项
contents具体的元素
zlenduint8_t固定为 255(0xFF,11111111),作为压缩列表的结束标记,没有其他正常条目以值为 255 的字节开头

ziplist entry

ziplist 中的每个条目都以元数据为前缀,其中包含两个信息。首先,存储前一个条目的长度,以便能够从后向前遍历列表(知道前一个 entry 的大小,就能跳到前一个 entry 的地址,又因为结构是明确的,就能读该 entry 的信息了)。其次,提供条目编码。它表示条目类型,整数或字符串,在字符串的情况下,它还表示字符串有效负载的长度。因此,完整的条目存储如下:

<prevlen> <encoding> <entry-data>

有时编码本身表示条目,例如对于稍后将要看到的小整数。在这种情况下,<entry-data> 部分缺失,我们只需要:

<prevlen> <encoding>

  • prevlen:表示前一个条目的长度,用以下方式编码
    • 如果此长度小于254字节,则仅使用单个字节表示长度作为无符号8位整数。
      • <prevlen from 0 to 253> <encoding> <entry>
    • 当长度大于等于254时,需要5个字节。第一个字节设置为254(FE),以指示后面有一个较大的值。剩余的4个字节采用前一个条目的长度作为值。
      • 0xFE <4 bytes unsigned little endian prevlen> <encoding> <entry>
  • encoding:条目的编码字段取决于条目内容。
    • 当条目是字符串时,编码的第一个字节的前两位将保存用于存储字符串长度的编码类型,后跟实际字符串的长度。
    • 当条目是整数时,前两位都设置为1。接下来的2位用于指定在此标头之后将存储何种类型的整数。不同类型和编码的概述如下。第一个字节始终足以确定条目的类型。
    • encoding 编码
      • |00pppppp| - 1 byte:
        • 长度小于或等于63字节(6位)的字符串值。“pppppp” 表示无符号的6位长度。
      • |01pppppp|qqqqqqqq| - 2 bytes
        • 长度小于或等于16383字节(14位)的字符串值。重要提示:14 位数字以大端字节序存储。
      • |10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| - 5 bytes
        • 长度大于或等于16384字节的字符串值。只有第一个字节后面的4个字节表示长度,可表示的长度范围为 0 到 232-1。第一个字节的低6位未使用,应设置为零。重要提示:32位数字以大端字节序存储。
      • |11000000| - 3 bytes
        • 以 int16_t 编码的整数,2byte
      • |11010000| - 5 bytes
        • 以 int32_t 编码的整数,4byte
      • |11100000| - 9 bytes
        • 以 int64_t 编码的整数,8byte
      • |11110000| - 4 bytes
        • 24 位有符号整数,3byte
      • |11111110| - 2 bytes
        • 8 位有符号整数,1byte
      • |1111xxxx| - (with xxxx between 0001 and 1101) immediate 4 bit integer.
        • 无符号整数从0到12。实际编码的值为1到13,因为不能使用0000和1111,因此应从编码的4位值中减去1以获得正确的值。
      • |11111111| - 标记 ziplist 结尾的特殊 entry

正如对于 ziplist 头部一样,所有整数都是以小端字节顺序表示的,即使在大端系统中编译此代码也是如此。

级联更新

压缩列表 zlentry 的 prevrawlen 存在如下特性

  • 如果前一个节点的长度小于 254 字节,那么 prevrawlen 属性需要用 1 字节的空间来保存这个长度值。
  • 如果前一个节点的长度大于等于 254 字节,那么 prevrawlen 属性需要用 5 字节的空间来保存这个长度值。

当在列表头部新增一个元素时,如果元素占用内存超过 254 byte,则有可能会导致后续节点递归更新,举个极端点的例子,一个压缩列表中的全部元素都是 253 byte,这时头部插入了一个 255 byte 的元素,则后续元素为了存储上个元素的大小, 全部都得将原本 1 byte 的 prevrawlen 改成 5 byte

模拟操作

Redis之ziplist数据结构

ziplist 作为 List、Hash、ZSet 的底层实现,提供了各种方法以支持不同数据类型的特殊操作需求

  • 插入元素,支持在头尾或中间任意位置插入元素。因为 ziplist 是内存紧凑的,所以新插入一个元素就需要扩展内存,Redis 会调用 ziplistResize 方法重新申请内存空间,然后将原先的列表一次性放入新的内存空间中
  • 查找元素,因为元素不是定长,所以从指定位置开始,一个一个查找,直到找到或者到达尾部
  • 删除元素,可以单个删除或连续多个删除

在 List 下做 push、pop 操作就是在 ziplist 两头插入或删除元素

在 Hash 下,每当有新的键值对要加入到哈希对象时, 程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 然后再将保存了值的压缩列表节点推入到压缩列表表尾, 因此保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。

在 ZSet 下,元素和 score 为两个相邻的元素,先存元素,再存元素对应的分数,且元素按照分值从小到大的顺序排序

快速列表 quicklist

在这里插入图片描述

Redis List 使用 quicklist 和 ziplist 的协作配合,作为底层实现

快速列表就是一个双向链表,其每个节点挂载了一个压缩列表,数据存储在压缩列表中。Redis List 能够以 O(1) 的时间复杂度在两端提供 push 和 pop 操作

从上图来看,快速列表和 Java 中的 双端队列 Deque 非常相似,对象持有 head 和 tail 节点的指针,有对内部数据统计的 count 和 length 等信息,每个节点也持有相邻两个节点的指针,挂载的压缩列表,还有该压缩列表的一些统计数据等信息

紧凑列表 listpack

Redis 5.0 起出现了 listpack,目的是替代压缩列表,Redis 7.0 起,listpack 完全替代了 ziplist。

listpack 是 ziplist 的优化,两者的最大区别是 listpack 中每个节点不再包含前一个节点的长度,其长度由节点自身记录,所以一个节点更新时,不会涉及后面节点的更新,由此修复了级联更新问题

<total-bytes> <entry-count> <entry> ... <entry> <end>

listpack 相比 ziplist 去除了指向末尾 entry 的 zltail 字段

listpack entry

<encoding> <entry-data> <length>

listpack entry 相比 ziplist entry 去除了 prelen 字段

  • length:当前元素的长度,每个 entry 之间不再有关联,所以更新的时候不会再出现级联更新的情况。
    • 为了提高内存使用率,length 的长度不是固定的,它会随着当前 entry 的长度改变而改变,最多占用 5byte
    • length 也有特殊的编码方式,采用大端序(高位字节存入低地址,低位字节存入高地址),每个字节第一位都是标识符,0 表示该字节是 length 的最后一个字节,1 表示还有字节,每个字节剩余的七位才携带有效信息,需倒序按字节依次读取 length
      • 0xxxxxxx:表示长度 0 ~ 27-1,[0, 127]
      • 0xxxxxxx 1xxxxxxx:表示长度 0 ~ 214-1,[0, 16383]
      • 0xxxxxxx 1xxxxxxx 1xxxxxxx:0 ~ 221-1
      • 0xxxxxxx 1xxxxxxx 1xxxxxxx 1xxxxxxx:0 ~ 228-1
      • 0xxxxxxx 1xxxxxxx 1xxxxxxx 1xxxxxxx 1xxxxxxx:0 ~ 235?-1
  • encoding:表明数据的类型和长度,有的数据也直接存在该字段中,所以没有 entry-data 部分
    • 0xxxxxxx:表示 7 位无符号整数,无 entry-data,数据直接写在 encoding 字段里
    • 10xxxxxx entry-data:表示 0 ~ 26-1 byte 的字符串
    • 110xxxxx xxxxxxxx:表示 13 位无符号整数,无 entry-data
    • 1110xxxx xxxxxxxx entry-data:表示 0 ~ 212-1 byte 的字符串
    • 11110000 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx entry-data:表示 0 ~ 232-1 byte 的字符串
    • 11110001 entry-data:表示 16 位有符号整数
    • 11110010 entry-data:表示 24 位有符号整数
    • 11110011 entry-data:表示 32 位有符号整数
    • 11110100 entry-data:表示 64 位有符号整数
    • 11111111:表示 listpack 结束标记

模拟操作

  • 正向读取两个数据:根据 listpack 结构可以找到第一个 entry 的地址,根据 entry 的结构拿到 encoding 的第一个字节,判断编码方式即可拿到 encoding 的长度和 entry-data 的长度,从而可算出 length 的长度,这样就能跳过第一个 entry 拿到第二个 entry 的地址,用同样的方式解析即可
  • 逆向读取两个数据:根据 listpack 结构可以拿到 total-bytes,减去最后的 end 的 1byte,就是 end 的地址,再往前就是最后一个 entry 了,根据 entry 的结构可知 entry 的最后一部分是 length,先拿一个字节,判断首位是 0 还是 1,直到拿到首位为 0 的 byte,就拿到完整的 length 值了,然后解析出正确的 length,从 end 往前偏移 length 长度就是最后一个 entry 的地址了,然后解析该 entry 就可以拿到对应数据。再往前就是倒数第二个 entry 了,用同样的方法解析即可

哈希表 hashtable

在这里插入图片描述

Redis的底层数据结构-Hash

hashtable 的结构是:dict 指向 dictht,dictht 包含多个 dictEntry,dictEntry 包含 next 指针,指向下一个 entry,形成一个链表。可以看到 dict 包含 ht,ht 是个数组,包含 ht[0] 和 ht[1] 两部分。ht[0] 是我们数据真实存储的地方,ht[1] 是为了伸缩容量时候进行 rehash 用的。目前没有 rehash,所以指向 null。

渐进式 rehash

hashtable 需要扩容或者缩容时就会执行 rehash

触发扩容,当以下条件中的任意一个被满足时,rehash 就会启动

  • 如果没有进行bgsave 元素数量达到hash长度时就会扩容(负载因子大于等于 1)
  • 如果进行bgsave,元素数量达到hash长度的5倍会进行扩容(负载因子大于等于 5)

触发缩容

  • 负载因子小于 0.1

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

rehash 的操作大概是根据当前 ht[0] 的容量新建一个 ht[1],然后将 ht[0] 里面所有键值对重新计算哈希值和索引值,并移动到 ht[1] 的指定位置上。当前移结束后,ht[0] 变为空表被释放内存,然后将 ht[0] 变量指向 ht[1] 对象,并将 ht[1] 指向 null,为下一次 rehash 做准备

但是整个 rehash 的过程是渐进式的,因为如果大量数据一口气 rehash 的话,会耗费大量性能与时间,可能会影响到正常命令的执行

渐进式 rehash 的大致步骤如下

  • 为 ht[1] 分配空间,让字典同时持有 ht[0] 和 ht[1] 两个哈希表
  • 在字典中维持一个索引计数器变量 rehashidx,并设置值为 0,表示 rehash 正式开始
  • 在 rehash 期间,每次对字典执行添加、删除、查找、更新等操作时,除了执行指定的操作,还会顺带将 ht[0] 在 rehashidx 索引上的所有键值对 rehash 到 ht[1],当 rehash 工作完成之后,rehashidx 加 1
  • 为了防止有的 key 长时间不访问,导致一直不能迁移到 ht[1],会定时去 rehash,每次迁移100个
    • 举个例子,有一个 Hash 结构的 key,已经触发了 rehash,但是系统长时间不会再次访问该 key,那么这个 rehash 的过程可能会持续很长时间
  • 最终会在某个时间点上,ht[0] 上的所有键值对都会被 rehash 到 ht[1],这时 rehashid 被设置为 -1,释放 ht[0] 指向的空哈希表,将 ht[0] 重新指向原本 ht[1] 指向的对象,将 ht[1] 指向 null,rehash 操作完成

渐进式 rehash 过程中的增删改查

  • 增:今天加在 ht[1] 中
  • 删:从 ht[0] 中找到元素则删除,找不到则到 ht[1] 中找并删
  • 改:先从 ht[0] 中查,查到就改,查不到再到 ht[1] 中查并改
  • 查:先从 ht[0] 中查,查不到再到 ht[1] 中查

模拟操作

  • 根据 key 计算 hash,然后根据数组长度计算 index,然后判断是否有 entry,并做对应操作

在 Hash 下做增删改查操作

在 Set 下,dict 的每个键都是 Set 的一个元素,每个值都被设置为 null

整数集合 intset

// 使用的三种编码方式
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

typedf struct intset {
    uint32_t encoding; // 编码方式 有三种 默认 INSET_ENC_INT16
    uint32_t length; // 元素个数
    int8_t contents[]; // 实际存储元素的数组,柔性数组
} intset;

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

contents 数组用来存储整数元素,每个元素在数组中按照从小到大的顺序排列,并且没有重复元素。虽然 contents 数组被申明为 int8_t 类型,但并不是说该数组就只能存放 int8_t 类型的元素。我的理解是这里用 int8_t 更像是在使用 byte 数组,具体用法由 encoding 字段指定。

encoding 用来指定 contents 数组的使用方式,指明用几个 byte 来表示一个整数, 有三种类型

  • INTSET_ENC_INT16:两个 byte 表示一个整数,取值范围:[−2^15 ,2^15−1] 即:[-32768, 32767 ]
  • INTSET_ENC_INT32:四个 byte 表示一个整数,取值范围:[−2^31 ,2^31−1] 即:[-2147483648, 2147483647]
  • INTSET_ENC_INT64:八个 byte 表示一个整数,取值范围:[−2^63 ,2^63−1] 即:[-9223372036854775808, 9223372036854775807]

模拟操作

新建整数集合时,会检查新元素占用的字节空间,会先选择能满足长度需求的最小范围的编码方式,如插入的元素是 1,三种编码方式都能存储该元素,但是 INIT32 和 INIT64 会浪费太多空间,所以会先选择 INIT16,后续插入元素放不下的时候再升级

插入新元素时,会检查新元素占用的字节空间,判断当前的编码方式是否能容纳新元素的长度

  • 如果是,则用二分法找到新元素在 contents 有序数组中应该插入的位置,判断当前位置已存在的元素和新元素是否相同,相同则返回,不同则将当前位置上的元素和后续元素全部后移一个 encoding 编码的整数单位,给新元素让出位置,然后把新元素放进去
  • 如果不是,如 INIT16 编码的数组中要加入一个 32768(需要用 INIT32 编码),需要对整数集合做升级操作,encoding 标记先从 INIT16 升级到 INIT32,之后再把原先占用 2byte 的元素全部翻倍占用到 4byte(倒序操作,正序会导致元素被覆盖),最后再把新元素插入到数组头或尾(该元素导致了升级,说明该元素比所有负数元素都小或比所有正数元素都大)

整数集合只能升级不能降级

删除元素时,用二分法找到元素在数组中的位置,如果元素存在则删除该元素,后续元素统一前移一个 encoding 编码的整数单位

在新增元素和集合升级的时候,有可能会遇到数组空间不够的情况,这时就需要对数组做扩容操作。因为 contents 数组是柔性数组(柔性数组在创建时会预先分配一段连续内存),所以在扩容时会判断整数集合对象后面连续的内存里空间是否充足,如果充足则直接扩容成功,如果不充足则会自动申请新的内存空间,然后把整个整数集合全部迁移过去,最终扩容成功

跳跃列表 skiplist (zset = dict + zskiplist)

Redis源码解析:数据结构详解-skiplist

一文彻底搞懂跳表的各种时间复杂度、适用场景以及实现原理

总结

跳表是可以实现二分查找的有序链表

跳表通常有多个层,最底层是一个双向链表,其他层都是该链表的索引层,倒数第二层会从底层首元素起,每间隔固定距离抽取底层的一个元素,包含底层的首尾元素,最终形成倒数第二层,倒数第三层会从倒数第二层首元素起,每间隔固定距离抽取倒数第二层的一个元素,最终形成倒数第三层,以此类推,直到最顶层只剩下一个元素

跳表查找的时间是 O(logn),和二分法一致,空间换时间

跳表在插入和删除元素的时候,需要调整每层元素的结构,以维持每层元素的均匀。这样就太复杂,所以会采用随机层的方法,控制元素插到各层的概率,整体上把握层与节点数的比例即可

当元素个数超过 ziplist 上限后,存储结构换成 skiplist,这里的 skiplist 其实指的是一个 zset 结构。它包含一个 dict 和一个 zskiplist,dict 保存了数据到分数(score)的对应关系,zskiplist 用来根据分数查询数据

  • ZSCORE:获取数据对应的分数,skiplist不支持,用dict来实现
  • ZRANK:先在dict中由数据查到分数,再拿分数去扩展后的skiplist中去查找,查到的同时就获得了排名
  • ZRANGE:按照排名返回集合某个区间的数据,由扩展后的skiplist支持
  • ZRANGEBYSCORE:基于分数区间查询数据集合,skiplist支持,典型的范围查找

为什么不用红黑树

  • 插入,删除,查找,输出有序序列这几种操作红黑树也能实现,时间复杂度和跳表一样。但是按照区间输出数据,红黑树的效率没有跳表高,跳表可以在O(logn)的时间复杂度定位区间的起点,然后向后遍历极客
  • 跳表和红黑树相比,比较容易理解,实现比较简单,不容易出错
  • 可以通过设置参数,改变索引构建策略,按需平衡执行效率和内存消耗

原文章内容

跳表是个什么数据结构

redis中的zset在元素少的时候用ziplist来实现,元素多的时候用skiplist和dict来实现。那么skiplist是一个什么样的数据结构呢?

skiplist(跳表)是一种为了加速查找而设计的一种数据结构。它是在 有序链表 的基础上发展起来的。

如下图是一个有序链表(最左侧的灰色节点为一个空的头节点),当我们要插入某个元素的时候,需要从头开始遍历直到找到该元素,或者找到第一个比给定元素大的数据(没找到),时间复杂度为O(n)

在这里插入图片描述
假如我们每隔一个节点,增加一个新指针,指向下下个节点,如下图所示。这样新增加的指针又组成了一个新的链表,但是节点数只有原来的一半。

当我们想查找某个数据的时候可以现在新链表上进行查找,当碰到比要查找的数据大的节点时,再到原来的链表上进行查找。

在这里插入图片描述
如下为查找23的过程,查找的过程为图中红色箭头指向的方向

  1. 23首先和7比较,然后和19比较,都比他们大,接着往下比。23和26比较,比26小。然后从19这节点回到原来的链表
  2. 和22比较,比22大,继续和下一个节点26比,比26小,说明23在跳表中不存在

在这里插入图片描述
利用上面的思路,我们可以在新链表的基础每隔一个节点再生成一个新的链表。

在这里插入图片描述
在新链表的基础上,还是查找23,先和19比较,比19大,并且19的下一个节点为NULL,从,从19条跳到下一层节点去查找,可以看到当链表比较长时,这种方式可以让我们跳过很多下层节点,大大加快查找的速度。

skiplist就是按照这种思想设计出来的,当然你可以每隔3,4等向上抽一层节点。但是这样做会有一个问题,如果你严格保持上下两层的节点数为1:2,那么当新增一个节点,后续的节点都要进行调整,会让时间复杂度退化到O(n),删除数据也有同样的问题。

skiplist为了避免这种问题的产生,并不要求上下两层的链表个数有着严格的对应关系,而用随机函数得到每个节点的层数。比如一个节点随机出的层数为3,那么把他插入到第一层到第三层这3层链表中。为了方便理解,下图演示了一个skiplist的生成过程

在这里插入图片描述
由于层数是每次随机出来的,所以新插入一个节点并不会影响其他节点的层数。插入一个节点只需要修改节点前后的指针即可,降低了插入的复杂度。

刚刚创建的skiplist包含4层链表,假设我们依然查找23,查找路径如下。插入的过程也需要经历一个类似查找的过程,确定位置后,再进行插入操作

在这里插入图片描述
和随机函数相关的2个常量值如下

// 大概每隔多少个节点向上提取一层的比例
// 1/2为大概每隔2个节点向上提取一层,1/4为每隔4个节点向上提取一层
private static final float SKIPLIST_P = 0.5f;
// 最高层数为16
private static final int MAX_LEVEL = 16;

计算随机层数的代码如下

// 理论来讲,一级索引中元素个数应该占原始数据的 50%,二级索引中元素个数占 25%,三级索引12.5% ,一直到最顶层。
// 因为这里每一层的晋升概率是 50%。对于每一个新插入的节点,都需要调用 randomLevel 生成一个合理的层数。
// 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :
// 50%的概率返回 1
// 25%的概率返回 2
// 12.5%的概率返回 3 ...
// Math.random()返回的值为[0, 1)
private int randomLevel() {
    int level = 1;

    while (Math.random() < SKIPLIST_P && level < MAX_LEVEL)
        level += 1;
    return level;
}

一个完整的跳表实现如下(代码地址:GitHub

public class SkipList {

    // 大概每隔多少个节点向上提取一层的比例
    // 1/2为大概每隔2个节点向上提取一层,1/4为每隔4个节点向上提取一层
    private static final float SKIPLIST_P = 0.5f;
    // 最高层数为16
    private static final int MAX_LEVEL = 16;

    // 目前的层数
    private int levelCount = 1;

    private Node head = new Node();  // 带头链表

    public Node find(int value) {
        Node p = head;
        // p.forwards[i]表示节点p到第i层的下一个节点
        for (int i = levelCount - 1; i >= 0; --i) {
            while (p.forwards[i] != null && p.forwards[i].data < value) {
                p = p.forwards[i];
            }
        }
        // 遍历到最底层了
        if (p.forwards[0] != null && p.forwards[0].data == value) {
            return p.forwards[0];
        } else {
            return null;
        }
    }

    public void insert(int value) {
        int level = randomLevel();
        Node newNode = new Node();
        newNode.data = value;
        newNode.maxLevel = level;
        Node update[] = new Node[level];
        for (int i = 0; i < level; ++i) {
            update[i] = head;
        }

        // record every level largest value which smaller than insert value in update[]
        Node p = head;
        for (int i = level - 1; i >= 0; --i) {
            while (p.forwards[i] != null && p.forwards[i].data < value) {
                p = p.forwards[i];
            }
            update[i] = p;// use update save node in search path
        }

        // in search path node next node become new node forwords(next)
        for (int i = 0; i < level; ++i) {
            newNode.forwards[i] = update[i].forwards[i];
            update[i].forwards[i] = newNode;
        }

        // update node hight
        // 更新目前的层数
        if (levelCount < level) levelCount = level;
    }

    public void delete(int value) {
        Node[] update = new Node[levelCount];
        Node p = head;
        for (int i = levelCount - 1; i >= 0; --i) {
            while (p.forwards[i] != null && p.forwards[i].data < value) {
                p = p.forwards[i];
            }
            update[i] = p;
        }

        if (p.forwards[0] != null && p.forwards[0].data == value) {
            for (int i = levelCount - 1; i >= 0; --i) {
                if (update[i].forwards[i] != null && update[i].forwards[i].data == value) {
                    update[i].forwards[i] = update[i].forwards[i].forwards[i];
                }
            }
        }

        while (levelCount > 1 && head.forwards[levelCount] == null) {
            levelCount--;
        }

    }

    // 理论来讲,一级索引中元素个数应该占原始数据的 50%,二级索引中元素个数占 25%,三级索引12.5% ,一直到最顶层。
    // 因为这里每一层的晋升概率是 50%。对于每一个新插入的节点,都需要调用 randomLevel 生成一个合理的层数。
    // 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :
    // 50%的概率返回 1
    // 25%的概率返回 2
    // 12.5%的概率返回 3 ...
    // Math.random()返回的值为[0, 1)
    private int randomLevel() {
        int level = 1;

        while (Math.random() < SKIPLIST_P && level < MAX_LEVEL)
            level += 1;
        return level;
    }

    public void printAll() {
        Node p = head;
        while (p.forwards[0] != null) {
            System.out.print(p.forwards[0] + " ");
            p = p.forwards[0];
        }
        System.out.println();
    }

    public class Node {
        private int data = -1;
        private Node forwards[] = new Node[MAX_LEVEL];
        private int maxLevel = 0;

        @Override
        public String toString() {
            StringBuilder builder = new StringBuilder();
            builder.append("{ data: ");
            builder.append(data);
            builder.append("; levels: ");
            builder.append(maxLevel);
            builder.append(" }");

            return builder.toString();
        }
    }

}

zset命令实现分析
// 将一个或多个 member 元素及其 score 值加入到有序集 key 当中
ZADD key score member [[score member] [score member] ...]

// 移除有序集 key 中的一个或多个成员,不存在的成员将被忽略。
ZREM key member [member ...]

// 返回有序集 key 中成员 member 的排名。其中有序集成员按 score 值递增(从小到大)顺序排列。
// 排名以 0 为底,也就是说, score 值最小的成员排名为 0 。
ZRANK key member

// 返回有序集 key 中,成员 member 的 score 值。
ZSCORE key member

// 返回有序集 key 中,指定区间内的成员。
// 其中成员的位置按 score 值递增(从小到大)来排序。
// 具有相同 score 值的成员按字典序来排列。
ZRANGE key start stop [WITHSCORES]

// 返回有序集 key 中,指定区间内的成员。
// 其中成员的位置按 score 值递减(从大到小)来排列。
// 具有相同 score 值的成员按字典序的逆序排列。
ZREVRANGE key start stop [WITHSCORES]

// 返回有序集 key 中,所有 score 值介于 min 和 max 之间(包括等于 min 或 max )的成员
// 有序集成员按 score 值递增(从小到大)次序排列。
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]

// 返回有序集 key 中, score 值介于 max 和 min 之间(默认包括等于 max 或 min )的所有的成员。
// 有序集成员按 score 值递减(从大到小)的次序排列。
// 具有相同 score 值的成员按字典序的逆序(reverse lexicographical order )排列。
ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]

用一个例子来演示一下,用sorted set来存储代数课(algebra)的成绩表

127.0.0.1:6379> zrem algebra Alice Bob David
(integer) 3
127.0.0.1:6379> zadd algebra 87.5 Alice 
(integer) 1
127.0.0.1:6379> zadd algebra 65.5 Charles 
(integer) 1
127.0.0.1:6379> zadd algebra 78.0 David 
(integer) 1
127.0.0.1:6379> zadd algebra 93.5 Emily 
(integer) 1
127.0.0.1:6379> zadd algebra 87.5 Fred 
(integer) 1
127.0.0.1:6379> zrank algebra Alice
(integer) 2
127.0.0.1:6379> zscore algebra Alice
"87.5"
127.0.0.1:6379> zrange algebra 0 3 withscores
1) "Charles"
2) "65.5"
3) "David"
4) "78"
5) "Alice"
6) "87.5"
7) "Fred"
8) "87.5"
127.0.0.1:6379> zrangebyscore algebra 80.0 90.0 withscores
1) "Alice"
2) "87.5"
3) "Fred"
4) "87.5"

我们分析一下上面所设计到的几个命令

ZSCORE:数据对应的分数,skiplist不支持
ZRANK:根据数据查询它对应的排名,skiplist不支持
ZRANGE:按照返回集合某个区间的数据,skiplist不支持
ZRANGEBYSCORE:基于分数区间查询数据集合,skiplist支持,典型的范围查找

redis中zset的数据结构如下

// server.h
typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

当数据量较少的时候,sorted set用ziplist来实现

当数据量较多的时候,sorted set由一个叫做zset的数据结构来实现,这个zset包含一个dict和一个zskiplist,dict保存了数据到分数(score)的对应关系,skiplist用来根据分数查询数据

我们接着来分析一下zset是怎么实现上面的功能的

ZSCORE:数据对应的分数,skiplist不支持,用dict来实现
ZRANK:先在dict中由数据查到分数,再拿分数去扩展后的skiplist中去查找,查到的同时就获得了排名
ZRANGE:按照排名返回集合某个区间的数据,由扩展后的skiplist支持
ZRANGEBYSCORE:基于分数区间查询数据集合,skiplist支持,典型的范围查找

Redis中skiplist和经典的skiplist相比,有如下的不同

  1. 分数允许重复,在经典的skiplist中是不允许的。当多个元素的分数相同时,还需要根据内容按照字典序排序
  2. 第一层链表不是一个单向链表,而是一个双向链表,方便以倒序的方式获取一个范围内的元素
  3. 可以很方便的计算出每个元素的排名
skiplist数据结构定义

zskiplistNode定义了skiplist的节点结构

// server.h
// 最高层数为64层
#define ZSKIPLIST_MAXLEVEL 64 /* Should be enough for 2^64 elements */
// 大概每隔4个节点向上抽象一层
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */

typedef struct zskiplistNode {
	// 字符串类型的member值
    sds ele;
    // 分值
    double score;
    // 后向指针
    struct zskiplistNode *backward;
    struct zskiplistLevel {
    	// 前向指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned long span;
    } level[];
} zskiplistNode;

最上面的两个常量,ZSKIPLIST_MAXLEVEL和ZSKIPLIST_P主要用来控制构建策略。

ele:保存字符串类型的member值
score:保存分值
backward:后向指针,指向链表的上一个节点,在zskiplist中,只有最底层的一层链表为双向链表
level:前向指针数组,存放各层链表的下一个节点的指针。span保存了当前指针跨越了多少个节点,用来计算排名

zskiplist是真正的跳表结构

typedef struct zskiplist {
	// 跳表头尾指针
    struct zskiplistNode *header, *tail;
    // 节点的数量
    unsigned long length;
    // 跳表目前最高层数
    int level;
} zskiplist;

header:跳表头节点,是一个特殊节点,level数组元素个数为64,头节点在有序集合中存储member值和score值,ele为null,socre为0,不计入跳表总长度。头节点在初始化时64个元素的forward都指向null,span值为0
tail:跳表尾节点
length:跳表总长度,除头节点之外的节点总数
level:跳表目前最高层数,除去头节点

以上面的代数成绩表为例。Redis中一种可能的skiplist结构如下图

在这里插入图片描述
图中前向指针上的数字,表示对应的span值,即当前指针跨越了多少个节点。

当我们在这个skiplist中查找socre=89.0的元素(即Bob的成绩数据),查找过程中我们会跨越图中标红的指针,这些指针上面的span值累加起来,就得到了Bob的排名2+2+1-1=4(减1是因为rank从0开始)。当我们需要从大到小的排名,只需要用skiplist的长度减去查找路径上span的累加值,即6-(2+2+1)=1

skiplist中和排名相关的操作(获取某个值的排名,获取某个排名区间内的数据),都是通过累加span值来得到的。

Redis为什么用skipList来实现有序集合,而不是红黑树?

redis中zset常用的操作有如下几种,插入数据,删除数据, 查找数据,按照区间输出数据,输出有序序列。

  1. 插入数据,删除数据,查找数据,输出有序序列这几种操作红黑树也能实现,时间复杂度和跳表一样。但是按照区间输出数据,红黑树的效率没有跳表高,跳表可以在O(logn)的时间复杂度定位区间的起点,然后向后遍历极客
  2. 跳表和红黑树相比,比较容易理解,实现比较简单,不容易出错
  3. 可以通过设置参数,改变索引构建策略,按需平衡执行效率和内存消耗
zset

先放member再放socre,按照score从小到大的顺序排列
在这里插入图片描述

地理空间 geospatial

Redis 数据类型——geospatial

Redis 在 3.2 版本中加入了地理空间(geospatial)以及索引半径查询的功能,主要用在需要地理位置的应用上。将指定的地理空间位置(经度、纬度、名称)添加到指定的 key 中,这些数据将会存储到 sorted set。这样的目的是为了方便使用 GEORADIUS 或者 GEORADIUSBYMEMBER 命令对数据进行半径查询等操作。也就是说,推算地理位置的信息,两地之间的距离,周围方圆的人等等场景都可以用它实现。

geo 底层原理是使用 zset来实现的,因此我们也可以使用 zset 的命令操作 geo。

geohash

要理解 Redis 的 GEO 相关的命令是如何实现了,就得先理解 geohash 的原理,本质上这些命令就是对 geohash 数据的封装而已。

geohash 是 2008 年 Gustavo Niemeye 发明用来编码经纬度信息的一种编码方式,比如北京市中心的经纬度坐标是 116.404844,39.912279,通过 12 位 geohash 编码后就变成了 wx4g0cg3vknd,它究竟是如何实现的?其实原理非常简单,就是二分,整个编码过程可以分为如下几步。

转二进制

地球上任何一个点都可以标识为某个经纬度坐标,经度的取值范围是东经 0-180 度和西经 0-180 度,纬度的取值范围是北纬 0-90 和南纬 0-90 度。去掉东西南北,可以分别认为经度和纬度的取值范围为[-180, 180] 和 [-90, 90]。

我们先来看经度,[-180,180]可以简单分成两个部分[-180,0]和[0,180],对于给定的一个具体的经度值,我们用一个bit 来标识是在[-180,0]还是[0,180]区间里。然后我们可以对这两个子区间继续细分,用更多的 bit 来标识是这个值是在哪个子区间里。就好比用二分查找,记录下每次查找的路径,往左就是 0 往右是 1,查找完后我们就会得到一个 0101 的串,这个串就可以用来标识这个经度值。

同理纬度也是一样,只不过他的取值返回变成了[-90,90]而已。通过这两种方式编码完成后,任意经纬度我们都可以得到两个由0和1组成的串。

比如还是北京市中心的经纬度坐标 116.404844,39.912279,我们先对 116.404844 做编码,得到其二进制为:11010010110001101101,然后我们对维度 39.912279 编码得到二进制为:10111000110000111001

经纬度二进制合并

接下来我们只需要将上述二进制交错合并成一个即可,这里注意经度占偶数位,纬度占奇数位,得到最终的二进制:1110011101001000111100000010110111100011

在这里插入图片描述

将合并后的二进制做 base32 编码

最后我们将合并后的二进制做 base32 编码,从左向右将连续 5 位转化为一个 0-31 的十进制数,然后用对应的字符代替,将所有二进制位处理完后我们就完成了 base32 编码,最终得到 geohash 值 wx4g0cg3。编码表如下:

在这里插入图片描述

geohash 是将空间不断的二分,然后将二分的路径转化为 base32 编码,最后保存下来。从原理可以看出,geohash 表示的是一个区间,而不是一个点,geohash 值越长,这个区间就越小,标识的位置也就越精确,下图是维基百科中不同长度 geohash 下的经纬度误差(lat:纬度,lng:经度)

在这里插入图片描述

geohash 用途

geohash 成功的将一个二维信息编码成了一个一维信息,这样编码有两个好处:

  • 编码后数据长度变短,利于节省存储。
  • 利于使用前缀检索。

从上文中 geohash 的实现来看,只要两个坐标点的 geohash 有共同的前缀,我们就可以肯定这两个点在同一个区域内 (区域大小取决于共同前缀的长度,举个例子:ab 两个坐标都位于中国,再进一步,都在河北省,再进一步,都在石家庄市,再进一步,都在鹿泉区)。这种特性给我们带来的好处就是,我们可以把所有坐标点按 geohash 做增序索引,然后查找的时候按前缀筛选,大幅提升检索的性能。

举个例子,假设要找北京国贸附近 3 公里内的餐馆,已知国贸的 geohash 是 wx4g41,那很轻易就可以计算出来需要扫描哪些区域内的点。但有个点需要注意,上文已经提到过,geohash 值实际上是代表一个区域,而不是一个点,找到一批候选点之后还需要遍历一次计算下精确距离。

在这里插入图片描述

geohash 的问题

geohash 有个需要注意的问题。geohash 是将二维的坐标点做了线下编码,有时候可能会给人一个误解就是如果两个 geohash 之间二进制的差异越小,这两个区间距离就越近,这完全是错误的。比如如下图 0111 和 1000,这俩区间二进制只差 0001 但实际物理距离比较远。

在这里插入图片描述

如果上图还不明显的话,有一张从 Wikipedia 上拿到的图,虚线是线性索引的路径,被虚线连接的两个块 geohash 值是非常相近的,如下图的(7,3)和(0,4),geohash 值会非常相近,但实际物理距离非常远,这就是 geohash 的突变现象,这也导致了不能直接根据 geohash 的值来直接判定两个区域的距离大小。

在这里插入图片描述

但在实际使用 geohash 过程中,时常会遇到跨域搜索的情况,比如我要在上图(3,3)这个区间内某个点上搜索距它 1 个距离单位的所有其他点集,这个点集有可能横跨(3,3)加上它周围 8 个邻域的 9 个区间,突变的问题会导致这 9 个区间的 geohash 不是线性跳转的。

但也不是没法计算,实际上可以通过特殊的位运算可以很轻易计算出某个 geohash 的 8 个邻域,具体可参考 Redis 源码中 src/geohash.c 中 geohashNeighbors() 的具体实现, geohashNeighbors 使用了 geohash_move_x 和 geohash_move_y 两个函数实现了 geohash 左右和上下的移动,这样可以很容易组合出 8 个邻域的 geohash 值了。

其实,就是将该区块上下左右以及四个对角的 8 个区块的 hash 都计算一遍,分别计算这些区间和自己之间的距离,找到其中距离一个距离单位的所有点集就可以了。因为这时的数据量已经非常小了,计算周边的 8 个块也很快。

数据类型

Redis 是 KV(key-value pair)存储,其中 K 是一个字符串对象(String),V 可以是 字符串(String)、列表(List)、哈希(Hash)、集合(Set)、有序集合(ZSet)。不管是 K 还是 V,底层都是用 redisObject 数据结构表示

Redis String

Redis String 是 Redis 中最基础的数据结构,其他 List、Hash、Set,ZSet 中存储的也是 String,String 最大可以占用 512M 的内存空间(源码里写的的判断)

Redis 中的 String 指的是二进制字节.。根据编码方式的不同,一个字符需要的字节也不同,一般一个字母或数字需要一个字节来表示,一个汉字需要两个字节来表示,但是 UTF8 编码下,一个汉字占用3个字节.

Redis 中 String 的长度是指值占用字节的个数

127.0.0.1:6379> set foo 123
OK
127.0.0.1:6379> object encoding foo
"int"
127.0.0.1:6379> set foo 9223372036854775807
OK
127.0.0.1:6379> object encoding foo
"int"
127.0.0.1:6379> set foo 9223372036854775808
OK
127.0.0.1:6379> object encoding foo
"embstr"
127.0.0.1:6379> set foo 123d
OK
127.0.0.1:6379> object encoding foo
"embstr"
127.0.0.1:6379> set foo aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeee
OK
127.0.0.1:6379> object encoding foo
"embstr"
127.0.0.1:6379> set foo aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeef
OK
127.0.0.1:6379> object encoding foo
"raw"

Redis 中字符串对象的编码有 int, embstr 和 raw. 这些编码类型其实就是 Redis 对 String 做的优化方式.

  • int:保存 long 型的 64bit(8byte)有符号整数(即 -263 到 263-1, 即 -9223372036854775808 到 9223372036854775807)
  • embstr:保存长度小于等于 44byte 的字符串(Redis3.2之前是39字节)
  • raw:保存长度大于 44byte 的字符串(Redis3.2之前是39字节)

int

分析 redisObject 结构,type 占 4bit,encoding 占 4bit,lru 占 24bit,refcount 占 4byte,ptr 占 64bit即 8byte,共 16byte

ptr 占用了 8byte,正好和一个 long 占用的空间大小一致,当字符串键值的内容可以用一个 64 位有符号整形来表示时,Redis 就会把该值转换为一个 long,并且直接把该值存储在 ptr 中,而不是指向存储该值的内存空间,这样能通过减少 io,提升性能。当这个数超过 long 的最大值,即 263-1 时, redisObject 对象本身就存不下它了,Redis会开辟一个新的空间出来存这个值,而 ptr 则存这个空间的地址,这就是当值小于等于 63-1(9223372036854775807)时,编码格式是 int,而值大于它时,编码格式则转换为 embstr/raw 的原因

而且 Redis 启动时会预先建立 10000 个分别存储 0-9999 的 redisObject 变量作为共享对象,这就意味着如果 set 字符串的键值在 0~10000 之间的话,则可以 直接指向共享对象 而不需要再建立新对象,此时键值不占空间!

embstr

embedded string,表示嵌入式的 String。从内存结构上来讲,即字符串结构体与其对应的 redisObject 对象分配在同一块连续的内存空间上,当字符串无法用 long 表示时,Redis 会首先尝试 embstr 编码,即判断该字符串的大小,能不能和 redisObject 放在连续的 64byte 大小的内存中(注: redisObject 占用16byte)

目前主流的 CPU Cache 的 Cache Line 大小都是 64Bytes, 即 cpu 执行一次内存 io 可以读取 64byte 的内容

Redis 获取 redisObject 对象,因为其只有 16byte,而一个缓存行有 64byte,所以会有额外的 48byte的数据会随着 redisObject 被读取出来,redis 即是通过巧妙地利用这 48byte 的空间,能在一次内存 io 内存储尽可能多的内容,能在这 48byte 空间内保存的值,使用的编码即是 embstr,超过则使用编码方式 raw

这里我有个想法, 缓存行的 64byte 是否是连续的? 是否以 redisObject 对象开头? 如果都是的话, redisObject 对象里面的指针的 8byte 空间就可以省出来, 和后面连续的 48byte 合起来共 56byte 来放 embstr 的东西, 这样会更省

sds

C 语言表示字符串 char buf[] = "mrathena\0",‘\0’ 占用一个字节,是 C 语言中一个字符串的结尾标记,‘\0’ 后面的内容会被丢弃掉,这就意味着不是二进制安全的

Redis 表示字符串并没有直接使用 C 语言的字符串定义,而是自己封装了一个数据结构 SDS (Simple Dynamic String)

Redis 3.2 之前的版本, 只有一个 sdshdr 数据结构

struct sdshdr {
    int len; // 4byte, 字符串实际长度
    int free; // 4byte, 当前剩余空间, 32位, 可表示长度范围 1 到 2^32-1, 一般用不着这么大, 3.2之后就优化了这种结构
    char buf[]; // n+1byte, 实际的字符串数组, redis为了兼容使用C语言的函数库, 所以在该数组最后会拼一个'\0', 额外占用1byte
};

一个内存行 64byte,redisObject 对象占用 16byte,剩下的 48byte 即可保存一个 sdshdr 对象,除去其 len,free,和额外的一个 ‘\0’ 占用的 9byte,剩下 48-9=39byte 的空间可以用来存储真实数据,所以说 redis 3.2 版本之前,, embstr编码下,最大长度是39

Redis 3.2 及之后的版本, 根据长度划分了5个不同的sds数据结构

typedef char *sds;

// 0 到 2^5-1
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; // 1byte(8bit), 前3位是类型(5/8/16/32/64,5种需要3个bit位来区分), 后5位是字符串长度(仅仅在sdshdr5的时候使用, 5位可以表示的字符串长度的范围是0到31, 所以sdshdr5最大可以表示一个31byte的字符串, 加上'\0'的1byte, flags的1byte, 所以sdshdr5最大可以是33byte)
    char buf[]; // 实际的字符串数组,C 语言的柔性数组,放在结构体最后,可自行创建、扩容、释放
};
// 2^5 到 2^8-1
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; // 1byte, 字符串实际长度, 用8个big位即1个字节来表示
    uint8_t alloc; // 1byte, buf的总空间大小, alloc-len=free
    unsigned char flags; // 1byte, 前3位是类型, 后5位不使用
    char buf[]; // len是8位, 能表示长度的范围是0到255, 所以buf最大占用空间大小为255+1=256byte, 所以sdshdr8最大占用259byte
};
// 2^8 到 2^16-1
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; // 字符串实际长度, 用16个big位即2个字节来表示
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
// 2^16 到 2^32-1
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
// 2^32 到 2^64-1
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
}

当 redisObject 的 ptr 保存 sdshdr5 类型的数据时,最大占用空间为 33byte,无法填充满 48byte

当 redisObject 的 ptr 保存 sdshdr8 类型的数据时,最大占用空间为 259byte,可以填满 48byte,embstr 编码使用的就是 sdshdr8,48byte 除去 len,alloc,flags 各占用的 1byte,还有 ‘\0’ 字符占用的 1byte,最多可以存储 44byte 的数据,所以说,Redis 3.2 版本之后,embstr编码下,最大长度最长是44,其实如果能用上redisObject里面的指针的8byte, 那就能扩展到52byte

raw

当字符串的长度超过 44byte 时,同 redisObject 一起被 CPU 从内存中取出的缓存行的剩余 48byte 空间里就放不下了,Redis 会开辟一块新的空间存放 sds 对象,ptr 则存储该 sds 对象的地址

raw 其实就是 sds(不含 sdshdr5),随着字符串长度的提升,sds 对象也会逐渐动态扩容,当超过类型的最大容量时,则会提升一个级别,使用更大容量的对象来承载数据

sds 特点
  • 二进制安全的数据结构,即不会像 C 语言一样把 ‘\0’ 后面的内容丢弃掉
  • 提供了内存预分配机制,避免了频繁的内存分配,append,setbit 等命令导致
  • 兼容C语言的函数库,即会在字符串后默认添加 ‘\0’,符合C语言的规范,方便操作
sds 扩容

先看当前 free 是否能够提供扩容量, 不够的话, 扩容为 (len + addLen) * 2,然后重新计算 free。当 len达到 1M 时,后续扩容不会再直接翻倍,而是每次扩容都添加 1M 空间

柔性数组

【C语言】realloc、malloc、calloc、柔性数组

结构体中最后一个字段如果是数组,切长度是 0 或 没有指定长度,则该字段就是柔性数组字段

柔性数组 特性,简单来说如下

  • 先通过 malloc 给 柔性数组 buf 预分配一定的空间
  • 当空间不足时,通过 realloc 重新给 buf 扩展空间,当后方空间足够,将在后方继续扩容,当后方空间已被使用,将在新的一块区域进行扩容,并将原始数据拷贝至新的区域
sds 空间结构说明

在这里插入图片描述
Redis 对 sdshdr 系列数据结构做了内存对齐,即多个字段的内存分配在物理地址上是连续的,即 buf 的前面紧紧接着其他字段的内存地址
sdshdr 类型的变量,其指针都是指向 buf 字段的,要获取其 len、alloc、flags 等参数的内存地址,只需要在 buf 的地址上往前偏移指定单位即可

  • sdshdr5,buf 的地址往前偏移一个单位就是 flags 的地址
  • sdshdr8,buf 往前偏移一个单位是 flags,再往前偏移一个单位是 alloc,再往前偏移一个单位是 len
  • sdshdr16,buf 往前偏移一个单位是 flags,再往前偏移两个单位是 alloc,再往前偏移两个单位是 len

sdshdr5 时,flags 前 3 位是其类型,后 5 位是数据长度即 len
sdshdr8 及往后,flags 前 3 位是其类型,后 5 位空置,数据长度用另一个字段 len 来表示

Redis List

Redis 3.2 之前,Redis List 的编码有 2 种:分别是 压缩列表(ziplist)和 双向链表(linkedlist)。当 List 中每个字符串长度都小于 64 bytes 并且列表的元素个数小于 512 个时,采用 ziplist 编码,否则采用 linkedlist 编码。

Redis 3.2 起,结合 ziplist 和 linkedlist 的优缺点,诞生了 快速列表(quicklist),其实就是 linkedlist 和 ziplist 的结合体。Redis List 使用 quicklist 作为底层实现,quicklist 的每个 entry 下挂了一个 ziplist

默认配置下,单个压缩列表最大允许存储 8kb (8192byte) 的数据,超过则会创建新的压缩列表,并挂载到新的 quicklist entry 上。快速列表会有一定的数据压缩以便节省内存空间,提供多种数据压缩方式,如除首尾 entry 外其他 entry 上挂载的 ziplist(相对不常用)做压缩操作,进一步节省内存

  • list-max-ziplist-size -2 # 单个压缩列表最大允许存储 8kb (8192byte) 的数据,超过则进行扩建,将数据存储在新的压缩列表中
  • list-compress-depth 1 # 该参数指 quicklist 两头起压缩 ziplist 的深度,为了支持快速的 push 和 pop 操作,quicklist 的 head 和 tail 上挂载的 ziplist 始终保持不压缩
    • 0:禁用压缩
    • 1:[head]->node->node->…->node->[tail],head 和 tail 的 ziplist 不压缩,其他都压缩
    • 2:[head]->[next]->node->node->…->node->[prev]->[tail],head、next、prev、tail 不压缩,其他都压缩
    • 3:[head]->[next]->[next]->node->node->…->node->[prev]->[prev]->[tail],head、next、prev、tail 不压缩,其他都压缩
  • 以此类推

Redis 7.0 起,针对 ziplist 的缺点做了改进,诞生了 listpack,Redis List 使用 quicklist 和 listpack 作为底层实现

胖指针

Redis 的 List 数据结构和 Java 的 Deque 类似,数据是有序的,都支持左右做 Push 和 Pop 操作,很自然的,我们会想到双向链表的实现方式,Redis 3.2 之前就是这样实现的。

基于双向链表节点的特性,每个节点除了数据之外,还有 prev 和 next 两个前后指针。在 64 位操作系统下,一个指针的寻址空间是 64 bit,即一个指针会占用 8 byte 空间,如果使用双向链表的数据结构来存储大量非常简单的数据,那么可能在整个双向链表中,占用大部分空间的不是数据而是指针,这就有点浪费内存空间了,这就是胖指针问题。

Redis 3.2 起,Redis List 使用 quicklist 来存储数据,quicklist = linkedlist + ziplist,ziplist 相当于 linkedlist 中的一个节点,只不过这个节点里可以放很多个元素,默认配置下,当一个 ziplist 中元素占用内存达到 8 kb 时就会新建一个 ziplist 并挂载的 linkedlist 的新节点上,用于存储后续的数据

结合 linkedlist 和 ziplist 的优点,避免了胖指针问题

模拟流程

  • 当新建一个 Redis List 的时候,先创建一个快速列表对象,再给它添加一个节点,再用 push 的数据构建出一个压缩列表,然后将该压缩列表挂载到该节点上。
  • 当对该 List 做 push 操作时,会使用新 push 的数据和原本的压缩列表,根据指定的方向,构建一个新的压缩列表,然后挂载到快速列表的对应节点上,替换掉旧的压缩列表
  • 当压缩列表中的数据量达到设定的容量上限时,快速列表会构建一个新的节点出来,然后使用新 push 的数据构建一个新的压缩列表,并将该压缩列表挂载到新的节点下
    • list-max-ziplist-size -2 # 单个压缩列表最大允许存储 8kb (8192byte) 的数据,超过则进行扩建,将数据存储在新的压缩列表中
  • 快速列表会有一定的数据压缩以便节省内存空间,提供多种数据压缩方式
    • list-compress-depth 1 # 0:所有压缩列表都不压缩,1:快速列表首尾节点挂载的压缩列表不压缩,中间的压缩列表压缩
  • 当做 pop 操作时,根据方向找到快速列表头节点的头元素或尾节点的尾元素,将其取出,然后重新构建并更新对应的压缩列表

总结

quicklist 结合了 linkedlist 和 ziplist 的优点

  • linkedlist
    • 双向链表便于在表的两端做 push 和 pop 操作
    • 双向链表每个节点除了数据,还得额外保存前后两个指针,内存开销大
    • 双向链表每个节点都是单独的内存块,地址不连续,节点多了容易产生内存碎片
    • 空间换时间
  • ziplist
    • 压缩列表是一整块连续内存,存储效率很高
    • 压缩列表不利于修改操作,每次数据变动都会引发一次内存的realloc
    • 压缩列表很长时,realloc可能会导致大量数据拷贝,降低性能
    • 时间换空间
  • quicklist
    • 结合了双向链表和压缩列表的优点
    • 限制了压缩列表的最大长度或最大容量,将压缩列表作为了双向链表的 entry,相当于给压缩列表添加了前后指针,折中了空间和时间上的平衡

Redis Hash

Redis Hash 有两种底层数据存储方式,分别是 ziplist 和 hashtable,默认优先使用 ziplist,使用 ziplist 时,每个键值对被保存成两个相邻元素,键对象在前,值对象在后

默认配置下,优先使用 ziplist 存储数据,如果元素个数超过 512 个,或任意 key 或 value 的内存占用超过 64 byte,底层将会从 ziplist 转变为 hashtable

  • hash-max-ziplist-entries 512 # hash 里 ziplist 最多可存 512 个元素,超过则转变为 hashtable
  • hash-max-ziplist-value 64 # hash 里 ziplist 单个元素最大 64 byte,超过则转变为 hashtable

Redis Set

Redis Set 有两种底层数据存储方式,分别是 intset 和 hashtable

默认配置下,当元素都是整数格式且元素总量不超过 512,会使用 intset 来存储元素,其他情况全都是使用 hashtable 来存储元素,包括直接使用 hashtable 和 从 intset 转变为 hashtable

  • set-max-intset-entries 512 # set 里 intset 最多可存 512 个元素,超过则转变为 hashtable

Redis Sorted Set (ZSet)

Redis Sorted Set 有两种底层数据存储方式,分别是 ziplist 和 zset,zset 由 dict 和 zskiplist 组成。默认有限使用 ziplist,使用 ziplist 时,分值和元素被保存成两个相邻元素,元素对象在前,分值对象在后

默认配置下,当元素都是整数格式且元素总量不超过 512,会使用 ziplist 来存储元素,其他情况全都是用 zset 来存储元素

  • zset-max-ziplist-entries 128 # zset 里 ziplist 最多可存 512 个元素,超过则转变为 zset
  • zset-max-ziplist-value 64 # zset 里 ziplist 单个元素最大 64 byte,超过则转变为 zset

Redis Geo

本质上 Redis 中的 geo 就是对 geohash 的封装。接下来介绍一下 geo 六个命令

geoadd

geoadd 添加地理位置,可以将指定的地理空间位置(经度、纬度、名称)添加到指定的 key 中。

命令格式:geoadd key longitude latitude member [longitude latitude member …]

往 china:city 这个 key 里添加了 5 个地方的经纬度:北京、上海、广州、深圳、杭州。

127.0.0.1:6379> geoadd china:city 116.20 39.56 beijing
(integer) 1
127.0.0.1:6379> geoadd china:city 121.47 31.23 shanghai 113.28 23.13 guangzhou 114.09 22.55 shenzhen 120.15 30.29 hangzhou
(integer) 4

geopos

geopos 获取指定城市的地理位置经纬度,可以从 key 里返回所有给定地理位置的经纬度。

命令格式:geopos key member [member …]

127.0.0.1:6379> geopos china:city beijing hangzhou
1) 1) "116.19999736547470093"
   2) "39.56000019952067248"
2) 1) "120.15000075101852417"
   2) "30.29000023253814078"

geodist

geodist 返回两个坐标之间的距离,也就是两个人之间的距离,如果两个位置之间的其中一个不存在,则会返回空值。指定单位的参数 unit 必须是以下单位的其中之一:

m 表示单位为米(默认)
km 表示单位为千米
mi 表示单位为英里
ft 表示单位为英尺

命令格式:geodist key member1 member2 [unit]

127.0.0.1:6379> geodist china:city beijing hangzhou KM
"1091.8534"

georadius

georadius 以给定的经纬度为中心,返回与中心的距离不超过给定最大距离的所有位置元素。

命令格式:georadius key lopngitude latitude radius unit [WITHCOORD] [WITHDIST] [COUNT count] [ASC|DESC] […]

找出离杭州滨江区 1100km 的地方

127.0.0.1:6379> georadius china:city 120.21 30.21 1100 KM
1) "shenzhen"
2) "guangzhou"
3) "hangzhou"
4) "shanghai"

# 加上 WITHCOORD 可以返回经纬度
127.0.0.1:6379> georadius china:city 120.21 30.21 1100 KM WITHCOORD
1) 1) "shenzhen"
   2) 1) "114.09000009298324585"
      2) "22.5500010475923105"
2) 1) "guangzhou"
   2) 1) "113.27999979257583618"
      2) "23.13000101271457254"
3) 1) "hangzhou"
   2) 1) "120.15000075101852417"
      2) "30.29000023253814078"
4) 1) "shanghai"
   2) 1) "121.47000163793563843"
      2) "31.22999903975783553"

# 加上 WITHDIST 可以显示到中心的距离
127.0.0.1:6379> georadius china:city 120.21 30.21 1100 KM WITHCOORD WITHDIST
1) 1) "shenzhen"
   2) "1047.2539"
   3) 1) "114.09000009298324585"
      2) "22.5500010475923105"
2) 1) "guangzhou"
   2) "1045.6490"
   3) 1) "113.27999979257583618"
      2) "23.13000101271457254"
3) 1) "hangzhou"
   2) "10.6023"
   3) 1) "120.15000075101852417"
      2) "30.29000023253814078"
4) 1) "shanghai"
   2) "165.4853"
   3) 1) "121.47000163793563843"
      2) "31.22999903975783553"

# 加上 COUNT 可以只返回指定数量结果
127.0.0.1:6379> georadius china:city 120.21 30.21 1100 KM WITHCOORD WITHDIST COUNT 2
1) 1) "hangzhou"
   2) "10.6023"
   3) 1) "120.15000075101852417"
      2) "30.29000023253814078"
2) 1) "shanghai"
   2) "165.4853"
   3) 1) "121.47000163793563843"
      2) "31.22999903975783553"

# 加上 ASC 可以按距离升序排序,加上 DESC 可以按距离降序排序
127.0.0.1:6379> georadius china:city 120.21 30.21 1100 KM WITHCOORD WITHDIST COUNT 2 ASC
1) 1) "hangzhou"
   2) "10.6023"
   3) 1) "120.15000075101852417"
      2) "30.29000023253814078"
2) 1) "shanghai"
   2) "165.4853"
   3) 1) "121.47000163793563843"
      2) "31.22999903975783553"

127.0.0.1:6379> georadius china:city 120.21 30.21 1100 KM WITHCOORD WITHDIST COUNT 2 DESC
1) 1) "shenzhen"
   2) "1047.2539"
   3) 1) "114.09000009298324585"
      2) "22.5500010475923105"
2) 1) "guangzhou"
   2) "1045.6490"
   3) 1) "113.27999979257583618"
      2) "23.13000101271457254"

georadiusbymember

georadiusbymember 跟 georadius 命令一样,都可以找出位于指定范围的位置元素,但是这里不是指定中心点坐标,而是指定以哪个元素为中心点。

命令格式:georadiusbymember key member radius unit […(跟 georadius 一致)]

找出离杭州 200km 的地方

127.0.0.1:6379> georadiusbymember china:city hangzhou 200 KM WITHCOORD WITHDIST
1) 1) "hangzhou"
   2) "0.0000"
   3) 1) "120.15000075101852417"
      2) "30.29000023253814078"
2) 1) "shanghai"
   2) "163.8526"
   3) 1) "121.47000163793563843"
      2) "31.22999903975783553"

geohash

geohash 将二维的经纬度转换成一维的字符串,也就是对经纬度进行 hash 计算。

命令格式:geohash key member [member …]

对杭州、上海、北京取 hash

127.0.0.1:6379> geohash china:city shanghai beijing
1) "wtw3sj5zbj0"
2) "wx49h1wm8n0"

Redis Stream

类似 MQ,但是有些机制不算很完善,小公司小项目可以临时过渡,节约成本

Redis HyperLogLog

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值