1、Redis 对象

字列集哈有

1、字符串 String

1.1、使用

String 就是字符串,它是 Redis 中最基本的数据对象,最大为 512 MB,我们可以通过配置项 proto-max-buk-len 来修改它,一般来说是不用主动修改的

SET key value
SETNX  -- Not Exist
EX     -- Expiration
PX     -- Millisecond
NX     -- Not Exist
XX     -- Exist

GET key
MGET key1 key2

DEL key

1.2、编码方式

三种编码方式:INT、EMBSTR、RAW

INT 编码:就是存一个整型,可以用 long 表示的整数就以这种编码存储
EMBSTR 编码:如果字符串小于等于阈值字节(39 / 44 字节),使用 EMBSTR 编码
RAW 编码:字符串大于阈值字节,则用 RAW 编码

EMBSTR 和 RAW 都是由 redisobject 和 SDS 两个结构组成
它们的差异在于:EMBSTR 下 redisObject 和 SDS 是连续的内存,RAW 编码下 redisObject 和 SDS 的内存是分开的
EMBSTR 优点是 redisObject 和 SDS 两个结构可以一次性分配空间,缺点在于如果重新分配空间,整体都需要再分配
所以 EMBSTR 设计为只读,任何写操作之后 EMBSTR 都会变成 RAW,理念是发生过修改的字符串通常会认为是易变的

image

image

随着我们的操作,编码可能会转换

  • INT -> RAW:当存的内容不再是整数,或者大小超过了 long 的时候
  • EMBSTR -> RAW:任何写操作之后 EMBSTR 都会变成 RAW,原因前面有解释

1.3、SDS

SDS:它是 Simple Dynamic String 的缩写,即简单动态字符串

在 C 语言中,字符串用一个 '\0' 结尾的 char 数组表示,比如 "hello" 即 "hello\0"

  • 每次计算字符串长度的复杂度为 O(N)
  • 对字符串进行追加,需要重新分配内存
  • 非二进制安全

在 Redis 内部,字符串的追加和长度计算很常见,这两个简单的操作不应该成为性能的瓶颈
于是 Redis 封装了一个叫 SDS 的字符串结构,用来解决上述问题,下面我们来看看它是怎样的结构

Redis 中 SDS 分为 sdshdr8、sdshdr16、sdshdr32、sdshdre64,它们的字段属性都是一样,区别在于应对不同大小的字符串,我们以 sdshdr8 为例
其中有两个关键字段,一个是 len(表示使用了多少),一个是 alloc(表示一共分配了多少内存)
这两个字段的差值(alloc - len)就是预留空间的大小,flags 则是标记是哪个分类,比如 sdshdr8 的分类就是 #define SDS_TYPE_8 1

// from Redis 7.0.8
struct __attribute__((__packed__)) sdshdr8 {
    uint8_t len;
    uint8_t alloc;
    unsigned char flags;
    char buf[];
};
  • 增加长度字段 len,快速返回长度
  • 增加空余空间(alloc - len),为后续追加数据留余地,预留空间大小为 min(len, 1M)
  • 不再以 '\0' 作为判断标准,二进制安全

2、列表 List

2.1、使用

List 是一组连接起来的字符串集合,最大元素个数是 2 ^ 32 - 1(4,294,967,295),新版本已经是 2 ^ 64 - 1 了

LPUSH key value
RPUSH key value

LPOP key
RPOP key

LREM key count value
count = 0:移除所有
count > 0:从左往右
count < 0:从右往左

LLEN key
LRANGE key start stop
起始索引为 0,负数表示倒数,最后一个元素为 -1

DEL key
UNLINK key

编码方式:ZIPLIST、LINKEDLIST、QUICKLIST、LISTPACK

ZIPLIST:压缩列表,列表对象保存的所有字符串对象长度都小于 64 字节、列表对象元素个数少于 512 个(节约内存、局部性好)
LINKEDLIST:链表,在列表个数或节点数据长度比较大的时候使用(牺牲内存,加快添加和删除性能)
QUICKLIST:ZIPLIST 和 LINKEDLIST 的结合体,由压缩列表组成的双向链表(Redis7.0 后 ZIPLIST 优化为 LISTPACK)

image

2.2、ZIPLIST

ZIPLIST 结构

image

结构头(zlbytes、zltail、zllen)+ 数据部分(entry)+ 结尾标识(zlend)

  • zlbytes:表示该 ZIPLIST 一共占了多少字节数,这个数字是包含 zlbytes 本身占据的字节的
  • zltail:ZIPLIST 尾巴节点相对于 ZIPLIST 的开头(起始指针),偏移的字节数,通过这个字段可以快速定位到尾部节点
    现在有一个 ZIPLIST,zl 指向它的开头,如果要获取 tail 尾巴节点,即 ZIPLIST 里的最后一个节点:可以 zl + zltail 定位到它,如果没有尾节点就定位到 zlend
  • zllen:表示有多少个数据节点(2 byte 65535),在本例中就有 3 个节点
  • entry1 ~ entry3:表示压缩列表数据节点
  • zlend:一个特殊的 entry 节点,值为 255(1111 1111),表示 ZIPLIST 的结束

ENTRY 结构

<prevlen> <encoding> <entry-data>
  • prevlen:表示上一个节点的数据长度,通过这个字段可以定位上一个节点的起始地址(或者说开头)
    p - prevlen 可以跳到前一个节点的开头位置,实现从后往前操作,所以压缩列表才可以从后往前遍历
    如果前一节点的长度 < 254 字节,那么 prevlen 属性需要用 1 字节长的空间来保存这个长度值,255 是特殊字符,被 zlend 使用了
    如果前一节点的长度 >= 254 字节,那么 prevlen 属性需要用 5 字节长的空间来保存这个长度值
    注意 5 个字节中第一个字节为 11111110,也就是 254,标志这是个 5 字节的 prevlen 信息,剩下 4 字节来表示大小
  • encoding:编码类型,编码类型里还包含了一个 entry 的长度信息,可用于正向遍历
    如果是 String 类型,那么 encoding 有两部分,前几位标识类型、后几位标识长度(int 具体类型自带了大小,比如 int32)
  • entry-data:实际的数据

2.3、连锁更新

image

2.4、LISTPACK

ENTRY 结构

<encoding-type> <element-data> <element-tot-len>
  • encoding-type 是编码类型
  • element-data 是数据内容
  • element-tot-len 存储整个节点除它自身之外的长度
    element-tot-len 所占用的每个字节的第一个 bit 用于标识是否结束,0 是结束、1 是继续,剩下 7 个 bit 来存储数据大小
    当我们需要找到当前元素的上一个元素时,我们可以从后向前依次查找每个字节,找到上一个 Entry 的 element-tot-len 字段的结束标识,就可以算出上一个节点的首位置了

image

3、集合 Set

3.1、使用

Redis 的 Set 是一个不重复、无序的字符串集合
如果是 INTSET,编码的时候其实是有序的,不过一般不应该依赖这个,整体还是看成无序来用比较好

SADD key member ...
SREM key member ...

SISMEMBER key member  -- 查询元素是否存在
SCARD     key         -- 查询集合元素个数

SMEMBERS key          -- 查看集合所有元素
SSCAN key cursor [MATCH pattern][COUNT count]  -- 查看集合元素,可以理解为指定游标进行查询,可以指定个数,默认个数为 10

SINTER key [key ...]  -- 返回在第一个集合里,同时在后面所有集合都存在的元素
SUNION key [key ...]  -- 返回所有集合的并集,集合个数大于等于 2
SDIFF  key [key ...]  -- 返回第一个集合有,且在后续集合中不存在的元素,集合个数大于等于 2(以第一个集合和后面比,看第一个集合多了哪些元素)

3.2、编码方式

两种编码方式:INTSET、HASHTABLE

Redis 出于性能和内存的综合考虑,也支特两种编码方式

  • 如果集合元素都是整数,且元素数量不超过 512 个,就可以用 INTSET 编码(节约内存)
    结构如下图,可以看到 INTSET 排列比较紧凑,内存占用少,但是查询时需要二分查找
  • 如果不满足 INTSET 的条件,就需要用 HASHTABLE(内存占用大、查找速度更快)
    HASHTABLE 结构如下图,可以看到 HASHTABLE 查询一个元素的性能很高,O(1) 时间就能找到一个元素是否存在

image

image

4、哈希表 Hash

4.1、使用

Redis Hash 是一个 field、value 都为 string 的 hash 表,存储在 Redis 的内存中
Redis 中每个 hash 可以存储 2 ^ 32 - 1 键值对(40 多亿)

HSET   key field value        -- 为集合对应 field 设置 value 数据
HSETNX key field value        -- 如果 field 不存在,则为集合对应 field 设置 value 数据
HDEL   key field [field ...]  -- 删除指定 field,可以一次删除多个

HGETALL key                   -- 查找全部数据
HGET    key field             -- 查找某个 key
HLEN    key                   -- 查找 Hash 中元素总数

HSCAN   key cursor [MATCH pattern][COUNT count]
-- 从指定位置查询一定数量的数据
-- 如果是小数据量下,处于 ZIPLIST 时,COUNT 不管填多少都是返回全部,因为 ZIPLIST 本身就用于小集合,没必要说再切分成几段来返回

4.2、编码方式

两种编码方式:ZIPLIST、HASHTABLE

  • ZIPLIST:Hash 对象保存的所有值和键的长度都小于 64 字节、Hash 对象元素个数少于 512 个
  • HASHTABLE:不满足 ZIPLIST 时

image

image

5、HASHTABLE

5.1、结构

// from Redis 5.0.5
// This is our hash table structure
typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

最外层是一个封装的 dictht 结构,其中字段含义如下

  • table:指向实际 hash 存储,可以看做一个数组,所以是 *table 的表示,在 C 语言中 *table 可以表示一个数组
  • size:哈希表大小,就是 dictEntry 有多少元素空间
  • sizemask:哈希表大小的掩码表示,总是等于 size - 1
    这个属性和哈希值一起决定 "一个键应该被放到 table 数组的哪个索引上面",规则 Index = hash & sizemask
  • used:表示已经使用的节点数量,通过这个字段可以很方便地查询到目前 HASHTABLE 元素总量

image

5.2、渐进式扩容

渐进式扩容顾名思义就是一点一点慢慢扩容,而不是一股脑直接做完(操作时顺带迁移)
为了实现渐进式扩容,Redis 中没有直接把 dictht 暴露给上层,而是再封装了一层
可以看到 dict 结构里面,包含了 2 个 dictht 结构,也就是 2 个 HASHTABLE 结构,dictEntry 是链表结构,也就是用拉涟法解决 Hash 冲突,用的是头插法

//from Redis 5.0.5
typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx;          // rehashing not in progress if rehashidx == -1
    unsigned long iterators; // number of iterators currently running
} dict;

image

平常使用的只有一个 HASHTABLE,在触发扩容之后,两个 HASHTABLE 同时使用:当向字典添加元素时,发现需要扩容就会进行 Rehash,Rehash 的流程大概分成三步

  • 为新 Hash 表 ht[1] 分配空间,新表大小为第一个 >= 原表 2 倍 used 的 2 次方幂
    原表如果 used = 500,2 倍就是 1000,那第一个 >= 1000 的 2 次方幂则为 1024
    此时字典同时持有 ht[0] 和 ht[1] 两个哈希表,字典的偏移索引从静默状态 -1 设置为 0,表示 Rehash 工作正式开始
  • 迁移 ht[0] 数据到 ht[1]
    在 Rehash 进行期间,每次对字典执行增删查改操作,程序会顺带迁移当前 rehashidx 在 ht[0] 上的对应的数据,并更新偏移索引
    与此同时,部分情况周期函数也会进行迁移,如果 rehashidx 刚好在一个已删除的空位置上,会尝试往下找(有上限)
  • 在 Rehash 过程中:查询先 0 后 1、新增在 1、删除和更新先 0 后 1

5.3、扩容时机

Redis 提出了一个负载因子的概念,负载因子表示目前 Redis HASHTABLE 的负载情况,是游刃有余,还是不堪重负了
我们设负载因子为 k,那么 k = ht[0].used / ht[0].size,也就是使用空间和总空间大小的比例,Redis 会根据负载因子的情况来扩容

  • 负载因子 >= 1,说明此时空间已经非常紧张
    新数据是在链表上叠加的,越来越多的数据其实无法在 O(1) 时间复杂度找到,还需要遍历一次链表
    如果此时服务器没有执行 BGSAVE 或 BGREWRITEAOF 这两个命令,就会发生扩容(复制命令对 Redis 的影响我们后面再讲)
  • 负载因子 >= 5,这时候说明 HASHTABLE 真的已经不堪重负了,此时即使是有复制命令在进行,也要进行 Rehash 扩容

5.4、缩容

扩容是数据太多存不下了,那如果太富裕呢,比如原来可能数据较多,发生了扩容,但后面数据不再需要,被删除了,此时多余的空间就是一种浪费
缩容过程其实和扩容是相似的,也是渐进式缩容,这里不整述

Redis 还是用负载因子来控制什么时候缩容
当负载因子 < 0.1,即负载率小于 10%,此时进行缩容,新表大小为第一个 >= 原表 usd 的 2 次方幂
如果有 BGSAVE 或 BGREWRITEAOF 这两个复制命令,缩容也会受影响,不会进行

6、有序集合 ZSet

6.1、使用

ZSet 是有序集合,也叫 Sorted Set,是一组关联积分有序的字符串集合,这里的分数是个抽象概念,任何指标都可以抽象为分数,以满足不同场景

ZADD key score member [score member ...]  -- 向 ZSet 增加数据,如果是已经存在的 Key,则更新对应的数据
XX:仅更新存在的成员,不添加新成员
NX:不更新存在的成员,只添加新成员
LT:更新新的分值比当前分值小的成员,不存在则新增
GT:更新新的分值比当前分值大的成员,不存在则新增

ZREM key member [member ...]        -- 删除 ZSet 中的元素

ZCARD  key                          -- 查看 ZSet 中成员总数
ZSCORE key member                   -- 查询 ZSet 中成员的分数
ZCOUNT key min max                  -- 计算 min - max 积分范围的成员个数
ZRANK  key member                   -- 查看 ZSet 中 member 的排名索引,索引是从 0 开始,所以如果排第一,索引就是 0

ZRANGE    key start stop [WITHSCORES]  -- 查询 [start,stop] 范围的数据,WITHSCORES 选填,不填的话,输出里就只有 key,没有 score 值
ZREVRANGE key start stop [WITHSCORES]  -- 即 reverse range,从大到小遍历,WITHSCORES 选填

6.2、编码方式

两种编码方式:ZIPLIST、SKIPLIST + HASHTABLE

ZIPLIST:列表对象保存的所有字符串对象长度都小于 64 字节、列表对象元素个数少于 128 个
SKIPLIST + HASHTABLE:不满足 ZIPLIST 时

image
image

image

7、跳表

标准的跳表:值不能重复、只有后指针没有前指针
Redis 跳表:值可以重复,有前后指针

  • 跳表也算链表,不过相对普通链表,增加了多级索引,通过索引可以实现 O(logN) 的元素查找效率
  • 从高级索往后查找,如果下个节点的数值比目标节点小则继续找,否则不跳过去,而是用下级索引往下找
  • Redis 跳表决定每一个节点,是否能增加一层的概率为 25%
    最大层数限制在 Redis 5.0 是 64 层,在 Redis 7.0 是 32 层
    跳表插入数据不会影响其它节点的层高,只会影响每一层前一跳、后一跳的关联指针
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length; // 节点数量
    int level;
} zskiplist;

// from Redis 7.0.8
// ZSETs use a specialized version of Skiplists
typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

image

8、更多

8.1、对象过期时间

SET key value EX seconds       -- 设置多少秒之后过期
SET key value PX milliseconds  -- 设置多少毫秒之后过期

EXPIRE  key seconds            -- 设置一个 key 的过期时间,单位秒
PEXPIRE key milliseconds       - -设置一个 key 的过期时间,单位毫秒

TTL key                        -- 查看还有多久过期

过期之后的键实际上不是立刻删除的,一般过期键清除策略有三种:定时删除、定期删除、惰性删除

  • 定时删除:在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作
    定时删除对内存比较友好,但是对 CPU 不友好,如果某个时间段比较多的 Key 过期,可能会影响命令处理性能
  • 惰性删除:使用的时候发现 Key 过期了,此时再进行删除,这个策略的思路是对应用而言,只要不访问,过期不过期业务都无所谓
    但是这样的代价就是如果某些 Key 一直不来访问,那本该过期的 Key 就变成常驻的 Key,这种策略对 CPU 最友好,对内存不太友好
  • 定期删除:每隔一段时间,程序就对数据库进行一次检查,每次删除一部分过期键,这属于一种渐进式兜底策略

image

8.2、对象引用计数

typedef struct redisobject {
    unsigned type: 4;
    unsigned encoding: 4;
    void *ptr;

    int refcount;
    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). */
} robj;

Redis 会在初始化服务器时,会创建 10000 个数值从 0 到 9999 的字符串对象
当服务器、新创建的键需要用到值为 0 到 9999 的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象

  • 0 ~ 9999 的整数,被使用的几率是很大的,复用是有场景的
  • 整数存储空间比较小,而每个 redisObject 内部结构至少占 16 字节,这比整数本身占据的空间还大,频繁分配整数是比较大的开销
  • 要复用对象,就需要进行数值比较,而整数对象进行比较的成本最低,如果是其它字符串,需要遍历字符串的所有字符,而其它如 List、ZSet 的对比成本就更高了
SET num 100
OBJECT REFCOUNT num
(integer) 2147483647

0 ~ 9999 这个范围的数字,都是共享对象,同时引用计数会保持在 INT_MAX
posted @ 2023-10-03 14:52  lidongdongdong~  阅读(24)  评论(0编辑  收藏  举报