redis 存储原理与数据模型

文章目录

  • 一、redis的存储结构
    • [1.1 存储结构](#1.1 存储结构)
    • [1.2 存储转换](#1.2 存储转换)
  • 二、字典(dict)实现
    • [2.1 数据结构](#2.1 数据结构)
    • [2.2 哈希冲突](#2.2 哈希冲突)
    • [2.3 扩容](#2.3 扩容)
    • [2.4 缩容](#2.4 缩容)
    • [2.5 渐进式rehash](#2.5 渐进式rehash)
    • [2.6 scan 命令](#2.6 scan 命令)
    • [2.7 expire机制](#2.7 expire机制)
  • 三、跳表(skiplist)实现
    • [3.1 理想跳表](#3.1 理想跳表)
    • [3.2 redis跳表](#3.2 redis跳表)

一、redis的存储结构

1.1 存储结构

1.2 存储转换

二、字典(dict)实现

redis 数据库通过 dict 实现映射关系。key 的固定类型是 string,value 的类型有多种。

redis 中 KV 组织是通过字典来实现的;hash 结构当节点超过512 个或者单个字符串长度大于 64 时,hash 结构采用字典实现。

2.1 数据结构

dict 由哈希表 dictht + 哈希节点 dictEntry 组成。哈希表有两个,通常 ht[0] 使用,ht[1] 不使用;rehash 时,ht[0] 存储 rehash 之前的数据,ht[1] 存储新数据和 ht[0] 迁移来的数据。

c 复制代码
// dict相当于C++的类的封装
typedef struct dict {
    dictType *type;     // dict 类型,封装成员函数
    void *privdata;     // 私有数据,连接的上下文
    dictht ht[2];       // 散列表,一个存储当前数据,另一个 rehash 时使用。
    long rehashidx;     // 指示rehash到哪个位置了,它是从0开始的,如果rehashidx == -1,则rehash未进行。
    unsigned long iterators; /* number of iterators currently running */
} dict;

// 哈希表
typedef struct dictht {
    dictEntry **table;      // entry 指针数组,保存 entry 的指针
    unsigned long size;     // 哈希表大小,2的n次幂
    unsigned long sizemask; // 哈希表掩码 size-1,hash 取余运算优化成位运算
    unsigned long used;     // 实际存储元素 entry 的个数
} dictht;

// 哈希节点
typedef struct dictEntry {
    void *key; 
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;        
    struct dictEntry *next;
} dictEntry;

1)字符串经过 hash 函数运算得到 64 位整数;

2)相同字符串多次通过 hash 函数得到相同的64位整数;

3)整数对 取余可以转化为位运算;sizemask是size-1,属于对字典的优化。因为散列表的存储是通过hash(key)%size=index确定索引,sizemask是对取余长度的优化,将hash(key)%size变成hash(key) &sizemask,把除法优化为二进制的运算,从而提高执行速度,这种优化的前提是 数组的长度必须是2的n次幂( 2 n 2^n 2n)。

2.2 哈希冲突

哈希冲突指的是不同的键在哈希表中计算得到相同的哈希值,但它们的实际存放位置并不相同。在哈希表中,每个键通过哈希函数映射到一个桶(bucket)或槽(slot),存储在对应的位置上。

由于哈希表的大小是有限的,而键的数量可能是无限的,所以哈希冲突是不可避免的。

我们通过负载因子 LoadFactor = used / size 来衡量哈希冲突的程度, used 是数组存储元素的个数,size 是数组的长度;

负载因子越小,冲突越小;负载因子越大,冲突越大;redis 的负载因子是 1 .

2.3 扩容

  • 如果负载因子 > 1 ,则会发生扩容;扩容的规则是翻倍;
  • 如果正在 fork (在 rdb、aof 复写以及 rdb-aof 混用情况下)时,会阻止扩容;
  • 但是此时若负载因子 > 5 ,索引效率大大降低, 则马上扩容;这里涉及到写时复制原理;

    在写时复制中,当需要修改一个数据副本时,不会立即进行实际的复制操作,而是在修改发生时创建该数据的新副本。这样可以避免对原始数据进行修改,从而保持数据的一致性和完整性。
    写时复制核心思想:只有在不得不复制数据内容时才去复制数据内容;

2.4 缩容

如果负载因子 < 0.1 ,则会发生缩容;缩容的规则是恰好包含used 的 2 n 2^n 2n;

恰好的理解:假如此时数组存储元素个数为 9,恰好包含该元素的就是 ,也就是 16;

为什么缩容的负载因子不是小于1?

因为缩容的负载因子是小于1的话会造成频繁的扩缩容,扩缩容都有分配内存的操作,内存操作变得频繁就会造成IO密集。

2.5 渐进式rehash

扩容和缩容都会导致rehash,因为映射算法发生了改变。

当 hashtable 中的元素过多的时候,因为redis是一个数据库,里面存储的数据非常多,不能一次性 rehash 到ht[1];这样会长期占用 redis,其他命令得不到响应;所以需要使用渐进式 rehash。

rehash步骤:

将 ht[0] 中的元素重新经过 hash 函数生成 64 位整数,再对ht[1] 长度进行取余,从而映射到 ht[1]。

渐进式规则:

1) 分治的思想,将 rehash 分到之后的每步增删改查的操作当中。

2)在定时器中,最大执行一毫秒 rehash ;每次步长 100 个数组槽位。

3)处理渐进式 rehash 的过程中,不会发生扩容和缩容。

2.6 scan 命令

SCAN命令的引入是为了解决,在某些情况下,需要对Redis数据库中的所有键进行遍历,以便进行某些操作或统计。然而,如果直接使用KEYS命令获取所有键,会对性能产生严重影响,因为KEYS命令会阻塞其他操作,并且在数据集较大时,返回所有键也会消耗大量内存。SCAN命令通过迭代方式,分批次逐步返回匹配的键,避免了一次性返回所有键的问题,从而减少了长时间阻塞的情况。

c 复制代码
scan cursor [MATCH pattern] [COUNT count] [TYPE type]

redis在遍历数据期间,如果发生扩容或者缩容,造成映射算法发生改变,键的槽位可能会发生改变。那么继续遍历会发生错误。

因此 scan 采用高位进位加法的遍历顺序,这样 rehash 后的槽位在遍历顺序上是相邻的,对 sacn 那刻起已经存在的元素遍历不会出现重复和遗漏。例外:在scan过程当中,发生两次缩容的时候,会发生数据重复。

2.7 expire机制

redis的EXPIRE机制用于设置键的过期时间,即在指定时间后自动删除键。它是基于每个键的时间戳实现的。

1)EXPIRE key seconds:设置键 key 的过期时间为 seconds 秒。当键到达过期时间后,Redis会自动删除该键。

2)PEXPIRE key milliseconds:设置键 key 的过期时间为 milliseconds 毫秒。与 EXPIRE 命令类似,但时间单位为毫秒。

3)TTL key:获取键 key 的剩余过期时间(以秒为单位)。如果键不存在或键没有设置过期时间,返回 -1。如果键已过期,返回 -2。

4)PTTL key:获取键 key 的剩余过期时间(以毫秒为单位)。如果键不存在或键没有设置过期时间,返回 -1。如果键已过期,返回 -2。

redis有两种删除方式:

1)惰性删除:分布在每一个命令操作时检查 key 是否过期;若过期删除 key,再进行命令操作。

2)定时删除:在定时器中检查库中指定个数(25)个 key。

需要注意的对大对象(大key)的删除:

在 redis 实例中形成了很大的对象,比如一个很大的 hash 或很大的 zset,这样的对象在扩容的时候,会一次性申请更大的一块内存,这会导致卡顿;如果这个大 key 被删除,内存会一次性回收,卡顿现象会再次产生。

如果观察到 redis 的内存大起大落,极有可能因为大 key 导致的。

bash 复制代码
# 每隔0.1秒 执行100条scan命令
redis-cli -h 127.0.0.1 --bigkeys -i 0.1

三、跳表(skiplist)实现

跳表的特点

  • 多层级有序链表
  • 最底层包含所有的元素
  • 支持二分查找,快速定位边界,然后在最底层找到范围内所有元素(区别红黑树)。
  • 增删改查的时间复杂度都是 O(log2n)。

3.1 理想跳表

理想跳表是多层级有序链表,采取空间换时间的方法,每隔一个节点生成一个层级节点,模拟二叉树结构,最底层包含所有的元素。

但是如果对理想跳表结构进行增删操作,很可能改变跳表结构。若重构链表,代价极大。考虑用概率的方法来优化。每次增加节点的时候,1/2 的概率增加一个层级,1/4 的概率增加两个层级,以此类推。经过证明,当数据量足够大(256)时,通过概率构造的跳表趋向于理想跳表,并且此时如果删除节点,无需重构跳表结构,此时依然趋向于理想跳表。时间复杂度为 ( 1 − 1 n c ) × O ( l o g 2 n ) (1-\frac{1}{n^c} )\times O(log_2 n) (1−nc1)×O(log2n)

3.2 redis跳表

从节约内存角度出发,redis 考虑牺牲一些时间性能让跳表结构变得更加扁平。以循环双向链表结构实现,每次增加节点时,1/4 的概率增加一个层级,跳表的最高层级为 32。当节点数量大于 128 或者有一个字符串长度大于 64,则使用跳表结构。

比如插入17,先比较第 4 层:(6, nil), 从 6 节点往下跳。比较第 3 层:(6, 25),从 6 节点往下跳。比较第 2 层:(9, 25),从 9 节点往下跳。比较第1层:(12, 19),在 12 节点后插入 节点17。

c 复制代码
#define ZSKIPLIST_MAXLEVEL 32 // 跳表的层级,
#define ZSKIPLIST_P 0.25      // 每个节点增加层级的概率

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;              // 最大的索引层,默认是1
} zskiplist;
相关推荐
qq_5298353525 分钟前
对计算机中缓存的理解和使用Redis作为缓存
数据库·redis·缓存
月光水岸New3 小时前
Ubuntu 中建的mysql数据库使用Navicat for MySQL连接不上
数据库·mysql·ubuntu
狄加山6753 小时前
数据库基础1
数据库
我爱松子鱼3 小时前
mysql之规则优化器RBO
数据库·mysql
chengooooooo3 小时前
苍穹外卖day8 地址上传 用户下单 订单支付
java·服务器·数据库
Rverdoser4 小时前
【SQL】多表查询案例
数据库·sql
Galeoto4 小时前
how to export a table in sqlite, and import into another
数据库·sqlite
希忘auto5 小时前
详解Redis在Centos上的安装
redis·centos
人间打气筒(Ada)5 小时前
MySQL主从架构
服务器·数据库·mysql
leegong231115 小时前
学习PostgreSQL专家认证
数据库·学习·postgresql