Redis对象学习

Redis Object

Redis是key-value存储,key和value在Redis中都被抽象为对象,key只能是String对象,而value支持丰富的对象种类,包括String、List、Set、Hash、Sorted Set、Stream等等。

Object在内存中是什么样子

#define LRU_BITS 24
// from Redis 5.0.5
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; // int 类型用4个字节即32bit表示
    void *ptr;  // void* 类型用8个字节即64bit表示
} robj;

type: 是哪种类型的Redis对象,用4个bit表示

encoding: 表示用那种底层编码,用4个bit表示

lru: 记录对象访问信息,用于内存淘汰,用24个bit表示

refcount: 引用计数,用来描述有多少个指针指向该对象,refcount==0就可以回收该对象了

ptr: 内容指针,指向实际内容

redisObject`的大小 = 4 + 4 + 24 + 32 + 64 = 128bits = 16bytes

String

String就是字符串,是Redis中最基本的数据对象,最大为512MB,可以通过配置项 proto-max-bulk-len来修改

适用场景

一般用来存字节数据文本数据序列化后的对象数据等。比如存Json字符串,计数场景下也可以用String。

常用操作

set key value

_setnx key value_ _ _用于在指定的key不存在时,为key设置指定的值,返回0表示设置失败,返回1表示设置成功

删除

DEL key [key ...] 删除对象,返回值为删除了几行数据

GET key 返回某个key的value,不存在则返回nil

MGET key [key ...] 一次查询多个key,如果某个key不存在,对应位置返回nil

底层实现

String 看起来简单,但是实际上有三种编码格式,分别为INTEMBSTRRAW

INT编码:存一个可以用long存储的整数用的就是int编码

EMBSTR编码:如果字符串长度小于等于阈值字节[3.2版本之后是44字节],就用EMBSTR编码

RAW编码:字符串大于阈值字节[44字节],用RAW编码

SDS

为什么需要SDS

  1. 增加长度字段len,可以快速返回长度
  2. 增加空余空间(alloc-len),为后续追加数据留余地
  3. 不再以’\0’作为字符串结束的判断标准,是二进制安全的

为什么EMBSTR的阈值是44字节

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used  8bit*/
    uint8_t alloc; /* excluding the header and null terminator  8bit*/
    unsigned char flags; /* 3 lsb of type, 5 unused bits  8bit*/
    char buf[];
};
  1. Redis默认适用jemalloc作为内存分配器
  2. jemalloc是以64字节作为内存单元进行内存分配的

回顾一下sdshdr的结构:

sdshdr8所占用的内存大小: 1byte + 1byte + 1byte + 内联数组的大小,同时内联数组还有一个’\0’占据一个字节的大小。所以实际内容能用的大小为 64 - 16(redisObject) - 3(sdshdr非内容字段) - 1(‘\0’) = 44字节


List

List是一组连接起来的字符串集合

适用场景

存储一批任务数据,存储一批消息等

常用操作

LPUSH key value [value ...] 从头部增加元素,返回值为list中元素的总数

RPUSH key value [value ...] 从尾部增加元素,返回值为list中元素的总数

LPOP key 移除并获得列表中的第一个元素

RPOP key 移除并获得列表中的最后一个元素

LREM key count value 移除值等于value的元素。当count = 0时,则移除所有等于value的元素,当count > 0 时,则从左到右移除count个,当count < 0 时,则从右到左移除count个。返回值为被移除元素的数量。

DEL key [key ...] 删除对象,返回值为成功删除了一个键

UNLINK key [key ...]异步删除命令,其他和DEL一样

LLEN key返回列表的长度

LRANGE key start stop取出index为[start, stop]之间的元素

底层实现

编码格式

3.2版本之前,List对象有两种编码格式,一种为ZIPLIST,一种为LINKEDLIST

当满足以下条件时,适用ZIPLIST

  1. 列表对象保存的所有字符串对象长度都小于64字节
  2. 列表对象元素数量少于512个,这是List的限制,不是ZIPLIST的限制

ZIPLIST内存排列很紧凑,可以有效节约内存空间

在3.2版本之后,QUICKLIST出现了,QUICKLIST有点像是ZIPLIST和LINKEDLIST的结合体

typedef struct quicklist { // redis 7.4
    quicklistNode *head; // 头节点指针
    quicklistNode *tail; // 尾节点指针
    unsigned long count;        /* total count of all entries in all listpacks */
    unsigned long len;          /* number of quicklistNodes */
    signed int fill : QL_FILL_BITS;       /* fill factor for individual nodes */
    unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
    unsigned int bookmark_count: QL_BM_BITS;
    quicklistBookmark bookmarks[];
} quicklist;

typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *entry;
    size_t sz;             /* entry size in bytes */
    unsigned int count : 16;     /* count of items in listpack */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* PLAIN==1 or PACKED==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int dont_compress : 1; /* prevent compression of entry that will be used later */
    unsigned int extra : 9; /* more bits to steal for future usage */
} quicklistNode;

上面是quicklist的源码定义。LinkedList原本是单个节点只能存储一个数据,quicklist中则是每个节点都是一个listpack

在redis 7.0之后,为了解决连锁更新问题, 用listpack更换了ziplist

上图中的源码是redis 7.4 版本的

什么是连锁更新问题

连锁更新问题 是指当 ziplist 中某个元素的长度变化(尤其是增加)时,会导致后续元素的位置发生变化,从而引发多次重分配和数据移动。这种情况常见于某个元素的更新导致了它所占用空间的增加或减少,并且影响到了相邻元素。

具体过程:

  1. 元素更新引发的长度变化ziplist 中每个元素都存储了前一个元素的长度。当某个元素变大时,它不仅需要分配更多的内存,还需要更新紧邻的下一个元素头部(因为存储前一个元素的长度可能不够用)。
  2. 前一个元素长度字段扩大:如果该更新使得存储前一个元素的字节数从 1 字节变为 5 字节,则后续元素的存储结构会因此被修改。
  3. 连锁反应:这种修改会继续影响之后的元素,从而可能导致多个元素的重新分配和更新。

举例说明:

假设 ziplist 中有一个元素 A,后面紧邻着元素 BC。如果 A 的长度从 200 字节增加到 500 字节,则 B 元素的头部需要存储 A 的新长度。假如 B 头部原来使用 1 字节存储 A 的长度,但现在 A 变大了,需要 5 字节来存储 A 的长度,这就会导致 B 的头部占用更多空间,C 及之后的元素都需要重新调整。

/* Each entry in the listpack is either a string or an integer. */
typedef struct {
    /* When string is used, it is provided with the length (slen). */
    unsigned char *sval;
    uint32_t slen;
    /* When integer is used, 'sval' is NULL, and lval holds the value. */
    long long lval;
} listpackEntry;

typedef struct zlentry {
    unsigned int prevrawlensize, prevrawlen;
    unsigned int lensize, len;
    unsigned int headersize;
    unsigned char encoding;
    unsigned char *p;
} zlentry;

如上图代码,zlentry中需要存储上一个节点的大小,会出现连锁更新问题。

为什么listpack就避免了这个现象

那么listpack是如何避免这种现象的呢?

我们直接看listpack的节点定义:

<encoding-type><element-data><element-tot-len>

encoding-type是编码类型,listpack从前往后读,就是根据编码类型来推断读取多少个字节,element-data是数据内容,element-tot-len存储整个节点除element-tot-len之外的总长度(其实就是encoding-type加上element-data的总长度)

element-tot-len所占用的每个字节的第一个bit都用于标识是否结束,0是结束,1是继续,剩下7个bit用来存储数据大小,当我们需要找到当前元素的上一个元素时,我们可以向前依次遍历查找每个字节,找到上一个Entity的element-tot-len的结束字节,这样就可以算出上一个节点的首位置了

举个栗子

如果上一个节点的element-tot-len为 10101100 00000010,每个字节的第一个bit标识是否结束,所以这里的element-tot-len大小为两个字节,上一个节点去除element-tot-len的大小为 0000010 0101100取每个字节的后7bit,倒序排放,所以最终的结果是300

如果了解过protobuf 的 varint ,可能会对这段逻辑感到相当的熟悉,其实两者的逻辑就是一样的


Redis对象学习
https://blog.mufen.site/2024/10/22/redis对象学习/
作者
mufen
发布于
2024年10月23日
许可协议