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 看起来简单,但是实际上有三种编码格式,分别为INT,EMBSTR,RAW。
INT编码:存一个可以用long
存储的整数用的就是int编码
EMBSTR编码:如果字符串长度小于等于阈值字节[3.2版本之后是44字节],就用EMBSTR编码
RAW编码:字符串大于阈值字节[44字节],用RAW编码
SDS
为什么需要SDS
- 增加长度字段len,可以快速返回长度
- 增加空余空间(
alloc-len
),为后续追加数据留余地 - 不再以’\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[];
};
- Redis默认适用jemalloc作为内存分配器
- 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
- 列表对象保存的所有字符串对象长度都小于64字节
- 列表对象元素数量少于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
中某个元素的长度变化(尤其是增加)时,会导致后续元素的位置发生变化,从而引发多次重分配和数据移动。这种情况常见于某个元素的更新导致了它所占用空间的增加或减少,并且影响到了相邻元素。
具体过程:
- 元素更新引发的长度变化:
ziplist
中每个元素都存储了前一个元素的长度。当某个元素变大时,它不仅需要分配更多的内存,还需要更新紧邻的下一个元素头部(因为存储前一个元素的长度可能不够用)。 - 前一个元素长度字段扩大:如果该更新使得存储前一个元素的字节数从 1 字节变为 5 字节,则后续元素的存储结构会因此被修改。
- 连锁反应:这种修改会继续影响之后的元素,从而可能导致多个元素的重新分配和更新。
举例说明:
假设 ziplist
中有一个元素 A
,后面紧邻着元素 B
和 C
。如果 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 ,可能会对这段逻辑感到相当的熟悉,
其实两者的逻辑就是一样的