基于redis5.0的版本。
前言
- 假设我们现在需要自己写个缓存,常见的就是用
HashMap<String, String>
存储。 - 如果为了方便统计、回收、或者其他原因,要记录下缓存的最近访问时间、创建时间等等,需要将值包装成
ValueObject
,里面包含了额外的扩展信息。 - 如果缓存值不止是字符串,也可能是List、Set等等,需要个标识字段来标识数据类型。
-
如果要给缓存加上过期时间:
- 方式一:在
ValueObject
上加上过期时间
字段。 -
方式二:再建一个对应的过期时间Map,Map的值为过期时间。
- 优点:检查过期缓存时,可以更快的遍历所有的内容;因为Map里不会存在没有设置过期时间的内容,且由于值只有一个字段,可以更快的完成遍历。
- 缺点:占用更多的空间,但是可以让两个Map共用key来节省部分空间。
- 方式一:在
RedisServer
在redis系统内部,有一个redisServer
结构体的全局变量server
,server
保存了redis服务端所有的信息,包括当前进程的PID、服务器的端口号、数据库个数、统计信息等等。当然,它也包含了数据库信息,包括数据库的个数、以及一个redisDb数组
。每一个redisDb
互相独立,互不干扰。
struct redisServer {
……
redisDb *db;
int dbnum; // Total number of configured DBs
……
}
/* Redis database representation. There are multiple databases identified
* by integers from 0 (the default database) up to the max configured
* database. The database number is the 'id' field in the structure. */
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */
dict *expires; /* Timeout of keys with a timeout set */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;
SDS
redis并没有直接使用c语言传统的字符串,而是自己构建了SDS(simple dynamic string)
。为什么不直接使用c语言的字符串?
- c语言字符串并不记录自身的长度,如果要获取字符串长度,需要遍历整个字符串。
- 每次增长/缩短字符串时,都需要对字符串数组进行一次内存操作(申请/释放内存),而光是执行内存重分配的时间,就会占去修改字符串所用时间中的一大部分,对性能造成影响。
- c语言字符串以
‘\0’
字符结尾,如果存储的数据字节本身包含‘\0’
,就会对结果造成影响。
// sds.h
// 五种header类型,flags取值为0~4
define SDS_TYPE_5 0
define SDS_TYPE_8 1
define SDS_TYPE_16 2
define SDS_TYPE_32 3
define SDS_TYPE_64 4
/* Note: sdshdr5 is never used, we just access the flags byte directly.
* However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* buf数组已使用的长度,不包含最后的结束符'\0' */
uint8_t alloc; /* buf数组最大容量,不包含最后的结束符'\0' */
unsigned char flags; /* 总是占用一个字节,其中的最低3个bit用来表示header的类型。 */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
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[];
};
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[];
};
一个sds
字符串的完整结构,由在内存地址上前后相邻的两部分组成:
- 一个
header
。通常包含字符串的长度(len)
、最大容量(alloc)
和flags
。sdshdr5有所不同。 - 一个字符数组。这个字符数组的长度等于最大容量+1。真正有效的字符串数据,其长度通常小于最大容量。在真正的字符串数据之后,是空余未用的字节(一般以字节0填充),允许在不重新分配内存的前提下让字符串数据向后做有限的扩展。在真正的字符串数据之后,还有一个
NULL
结束符,即ASCII码为0的'\0'
字符。这是为了和传统C字符串兼容。之所以字符数组的长度比最大容量多1个字节,就是为了在字符串长度达到最大容量时仍然有1个字节存放NULL
结束。
上图是sds的一个内部结构的例子。图中展示了两个sds字符串s1和s2的内存结构,一个使用sdshdr8
类型的header,另一个使用sdshdr16
类型的header。但它们都表达了同样的一个长度为6的字符串的值:"tielei"
。
sds的字符指针(s1和s2)就是指向真正的数据(字符数组)开始的位置,而header
位于内存地址较低的方向。在sds.h
中有一些跟解析header
有关的宏定义。
// sds.h
define SDS_TYPE_MASK 7
define SDS_TYPE_BITS 3
define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (void*)((s)-(sizeof(struct sdshdr##T)));
define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))
define SDS_TYPE_5_LEN(f) ((f)>>SDS_TYPE_BITS)
sds sdsnewlen(const void *init, size_t initlen) {
void *sh;
sds s;
char type = sdsReqType(initlen);
/* Empty strings are usually created in order to append. Use type 8
* since type 5 is not good at this. */
if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
int hdrlen = sdsHdrSize(type);
unsigned char *fp; /* flags pointer. */
sh = s_malloc(hdrlen+initlen+1); // 额外的一个结束符字节
if (init==SDS_NOINIT)
init = NULL;
else if (!init)
memset(sh, 0, hdrlen+initlen+1);
if (sh == NULL) return NULL;
s = (char*)sh+hdrlen;
fp = ((unsigned char*)s)-1;
switch(type) {
case SDS_TYPE_5: {
*fp = type | (initlen << SDS_TYPE_BITS);
break;
}
case SDS_TYPE_8: {
SDS_HDR_VAR(8,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_16: {
SDS_HDR_VAR(16,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_32: {
SDS_HDR_VAR(32,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_64: {
SDS_HDR_VAR(64,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
}
if (initlen && init)
memcpy(s, init, initlen);
s[initlen] = '\0';
return s;
}
/* Create an empty (zero length) sds string. Even in this case the string
* always has an implicit null term. */
sds sdsempty(void) {
return sdsnewlen("",0);
}
/* Create a new sds string starting from a null terminated C string. */
sds sdsnew(const char *init) {
size_t initlen = (init == NULL) ? 0 : strlen(init);
return sdsnewlen(init, initlen);
}
/* Duplicate an sds string. */
sds sdsdup(const sds s) {
return sdsnewlen(s, sdslen(s));
}
static inline size_t sdslen(const sds s) {
unsigned char flags = s[-1];
switch(flags&SDS_TYPE_MASK) {
case SDS_TYPE_5:
return SDS_TYPE_5_LEN(flags);
case SDS_TYPE_8:
return SDS_HDR(8,s)->len;
case SDS_TYPE_16:
return SDS_HDR(16,s)->len;
case SDS_TYPE_32:
return SDS_HDR(32,s)->len;
case SDS_TYPE_64:
return SDS_HDR(64,s)->len;
}
return 0;
}
/* Free an sds string. No operation is performed if 's' is NULL. */
void sdsfree(sds s) {
if (s == NULL) return;
s_free((char*)s-sdsHdrSize(s[-1]));
}
// sds.c
static inline int sdsHdrSize(char type) {
switch(type&SDS_TYPE_MASK) {
case SDS_TYPE_5:
return sizeof(struct sdshdr5);
case SDS_TYPE_8:
return sizeof(struct sdshdr8);
case SDS_TYPE_16:
return sizeof(struct sdshdr16);
case SDS_TYPE_32:
return sizeof(struct sdshdr32);
case SDS_TYPE_64:
return sizeof(struct sdshdr64);
}
return 0;
}
static inline char sdsReqType(size_t string_size) {
if (string_size < 1<<5) // 32
return SDS_TYPE_5;
if (string_size < 1<<8) // 256
return SDS_TYPE_8;
if (string_size < 1<<16) // 64K
return SDS_TYPE_16;
#if (LONG_MAX == LLONG_MAX)
if (string_size < 1ll<<32) // 4GB
return SDS_TYPE_32;
return SDS_TYPE_64;
#else
return SDS_TYPE_32;
#endif
其中SDS_HDR
用来从sds
字符串获得header
起始位置的指针,比如SDS_HDR(8, s1)
表示s1
的header
指针,SDS_HDR(16, s2)
表示s2
的header
指针。
sdsnewlen
创建一个长度为initlen
的sds字符串,并使用init
指向的字符数组(任意二进制数据)来初始化数据。如果init
为NULL
,那么使用全0
来初始化数据。它的实现中,我们需要注意的是:
- 如果要创建一个长度为0的空字符串,那么不使用
SDS_TYPE_5
类型的header,而是转而使用SDS_TYPE_8
类型的header。这是因为创建的空字符串一般接下来的操作很可能是追加数据,但SDS_TYPE_5
类型的sds字符串不适合追加数据(会引发内存重新分配)。 - 需要的内存空间一次性进行分配,其中包含三部分:
header
、数据
、最后的多余字节
(hdrlen+initlen+1)。 - 初始化的sds字符串数据最后会追加一个
NULL结束符(s[initlen] = ‘\0’)
。
当然,使用SDS_HDR之前我们必须先知道到底是哪一种header,这样我们才知道SDS_HDR
第1个参数应该传什么。由sds字符指针获得header类型的方法是,先向低地址方向偏移1个字节的位置,得到flags
字段。比如,s1[-1]和s2[-1]分别获得了s1和s2的flags的值。然后取flags的最低3个bit得到header的类型。
- 由于s1[-1] == 0x01 == SDS_TYPE_8,因此s1的header类型是sdshdr8。
- 由于s2[-1] == 0x02 == SDS_TYPE_16,因此s2的header类型是sdshdr16。
有了header指针,就能很快定位到它的len和alloc字段:
- s1的header中,len的值为
0x06
,表示字符串数据长度为6
;alloc的值为0x80
,表示字符数组最大容量为128
。 - s2的header中,len的值为
0x0006
,表示字符串数据长度为6
;alloc的值为0x03E8
,表示字符数组最大容量为1000
。(注意:图中是按小端地址构成)
在各个header的类型定义中,还有几个需要我们注意的地方:
- 在各个header的定义中使用了
__attribute__ ((packed))
,是为了让编译器以紧凑模式来分配内存(按实际占用字节数进行对齐)。如果没有这个属性,编译器可能会为struct的字段做优化对齐,在其中填充空字节。那样的话,就不能保证header和sds的数据部分紧紧前后相邻,也不能按照固定向低地址方向偏移1个字节的方式来获取flags字段了。 - 在各个header的定义中最后有一个
char buf[]
。我们注意到这是一个没有指明长度的字符数组,这是C语言中定义字符数组的一种特殊写法,称为柔性数组(flexible array member),只能定义在一个结构体的最后一个字段上。它在这里只是起到一个标记的作用,表示在flags字段后面就是一个字符数组,或者说,它指明了紧跟在flags字段后面的这个字符数组在结构体中的偏移位置。而程序在为header分配的内存的时候,它并不占用内存空间。如果计算sizeof(struct sdshdr16)
的值,那么结果是5个字节,其中没有buf字段。 -
sdshdr5
与其它几个header结构不同,它不包含alloc字段,而长度使用flags的高5位来存储。因此,它不能为字符串分配空余空间。如果字符串需要动态增长,那么它就必然要重新分配内存才行。所以说,这种类型的sds字符串更适合存储静态的短字符串(长度小于32)。
至此,我们非常清楚地看到了:sds字符串的header,其实隐藏在真正的字符串数据的前面(低地址方向/小端)。这样的一个定义,有如下几个好处:
- header和数据相邻,而不用分成两块内存空间来单独分配。这有利于减少内存碎片,提高存储效率(memory efficiency)。
- 虽然header有多个类型,但sds可以用统一的char *来表达。且它与传统的C语言字符串保持类型兼容。如果一个sds里面存储的是可打印字符串,那么我们可以直接把它传给C函数,比如使用strcmp比较字符串大小,或者使用printf进行打印。
sds相对c语言字符串的优点:
- SDS可以通过
len
快速获得字符串长度,便于统计。 - SDS可以通过
len
判断字符串是否结束,虽然数据库一般用于保存稳步数据,但保存二进制的常见也不少见,如果二进制数据中也存在'\0'
字符,用c语言字符串就会出现异常问题(只能取到第一个'\0'
之前的字符)。 - SDS会兼容部分c语言函数,所以一样遵循以
'\0'
字符结尾对惯例,为了让SDS可用重用部分<string.h>
库定义对函数。 -
当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足,API会自动将SDS的空间扩展至执行所需的大小,然后才执行实际的修改:
- 如果对SDS进行修改之后,SDS的长度(len属性值)小于1MB,那么程序会分配和len属性同样大小的未使用空间。这时候SDS的
len
属性将和free
属性一样,buf数组实际的长度将是len+free+1
字节。 - 如果对SDS进行修改之后,SDS的长度大于等于1MB,那么程序会分配1MB的未使用空间。
- 惰性空间释放:当SDS的API需要缩短SDS保存的字符串时,程序并不立即回收缩短后多出来的字节,避免了缩短字符串时所需的内存重新分配操作,并为将来可能有的增长操作提供了优化。与此同时,SDS也提供了相应的API,让我们可以在有需要的时候,真正的释放空闲的内存,所以不用担心惰性空间释放会造成内存浪费。
- 如果对SDS进行修改之后,SDS的长度(len属性值)小于1MB,那么程序会分配和len属性同样大小的未使用空间。这时候SDS的
// sds.h
define SDS_MAX_PREALLOC (1024*1024)
// sds.c
/* Enlarge the free space at the end of the sds string so that the caller
* is sure that after calling this function can overwrite up to addlen
* bytes after the end of the string, plus one more byte for nul term.
*
* Note: this does not change the *length* of the sds string as returned
* by sdslen(), but only the free buffer space we have. */
sds sdsMakeRoomFor(sds s, size_t addlen) {
void *sh, *newsh;
size_t avail = sdsavail(s);
size_t len, newlen;
char type, oldtype = s[-1] & SDS_TYPE_MASK;
int hdrlen;
/* Return ASAP if there is enough space left. */
if (avail >= addlen) return s;
len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype);
newlen = (len+addlen);
if (newlen < SDS_MAX_PREALLOC) /* 小于1M */
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
type = sdsReqType(newlen);
/* Don't use type 5: the user is appending to the string and type 5 is
* not able to remember empty space, so sdsMakeRoomFor() must be called
* at every appending operation. */
if (type == SDS_TYPE_5) type = SDS_TYPE_8;
hdrlen = sdsHdrSize(type);
if (oldtype==type) {
newsh = s_realloc(sh, hdrlen+newlen+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+hdrlen;
} else {
/* Since the header size changes, need to move the string forward,
* and can't use realloc */
newsh = s_malloc(hdrlen+newlen+1);
if (newsh == NULL) return NULL;
memcpy((char*)newsh+hdrlen, s, len+1);
s_free(sh);
s = (char*)newsh+hdrlen;
s[-1] = type;
sdssetlen(s, len);
}
sdssetalloc(s, newlen);
return s;
}
sdsMakeRoomFor
是sds实现中很重要的一个函数。关于它的实现代码,我们需要注意的是:
- 如果原来字符串中的空余空间够用(avail >= addlen),那么它什么也不做,直接返回。
- 如果需要分配空间,它会比实际请求的要多分配一些,以防备接下来继续追加。它在字符串已经比较长的情况下至多分配
SDS_MAX_PREALLOC
个字节,这个常量在sds.h中定义为(1024*1024)=1MB
。 - 按分配后的空间大小,可能需要更换header类型(原来header的alloc字段太短,表达不了增加后的容量)。
- 如果需要更换header,那么整个字符串空间(包括header)都需要重新分配(s_malloc),并拷贝原来的数据到新的位置。
- 如果不需要更换header(原来的header够用),那么调用一个比较特殊的
s_realloc
,试图在原来的地址上重新分配空间。s_realloc的具体实现得看Redis编译的时候选用了哪个allocator
(在Linux上默认使用jemalloc
)。但不管是哪个realloc
的实现,它所表达的含义基本是相同的:它尽量在原来分配好的地址位置重新分配,如果原来的地址位置有足够的空余空间完成重新分配,那么它返回的新地址与传入的旧地址相同;否则,它分配新的地址块,并进行数据搬迁。
RedisObject
一个database
内的这个映射关系是用一个dict
来维护的。dict
的key
固定用一种数据结构来表达就够了,这就是动态字符串sds
。而value
则比较复杂,为了在同一个dict
内能够存储不同类型的value
,这就需要一个通用的数据结构
,这个通用的数据结构就是redisObject
。
// server.h
// type
#define OBJ_STRING 0 /* String object. */
#define OBJ_LIST 1 /* List object. */
#define OBJ_SET 2 /* Set object. */
#define OBJ_ZSET 3 /* Sorted set object. */
#define OBJ_HASH 4 /* Hash object. */
// encoding
#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 /* Encoded as zipmap */
#define OBJ_ENCODING_LINKEDLIST 4 /* No longer used: old list encoding. */
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#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 ziplists */
#define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */
struct redisObject {
unsigned type:4; // 4bit,对象类型,值包括string,hash,list,set,zset等,可以用type {key}命令查看
unsigned encoding:4; // 4bit,编码类型,如字符串类型的OBJ_ENCODING_RAW,OBJ_ENCODING_INT,OBJ_ENCODING_EMBSTR,可以用object encoding {key}命令查看
unsigned lru:LRU_BITS; // LRU_BITS 占24bit。如果是LRU算法,记录LRU时间,可以用object idletime {key}命令查看;如果是LFU算法,则分为高16位和低8位,高16位记录上一次访问衰减时间,低8位记录计数器(Counter)数值。
int refcount; // 记录当前对象被引用数,用于通过引用数回收内存,可以用object refcount {key}命令查看
void *ptr; // 数据指针。指向真正的数据。比如,一个代表string的robj,它的ptr可能指向一个sds结构;一个代表list的robj,它的ptr可能指向一个quicklist。
};
refcount
- 对象回收:C语言并不具备自动内存回收功能,Redis在自己的对象系统中构建了一个引用计数技术实现的内存回收机制,通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。每个对象的引用计数信息由redis对象结构的
refcount
属性记录,创建一个新对象时,引用计数值会初始化为1
;对象被一个新程序使用时,它的引用计数值会被增1
;不再被一个程序使用时减1
;引用计数值变为0
,对象所占用的内存会被释放
。Redis的del
命令就依赖decrRefCount
操作将value
释放。
// object.c
void decrRefCount(robj *o) {
if (o->refcount == 1) {
switch(o->type) {
case OBJ_STRING: freeStringObject(o); break;
case OBJ_LIST: freeListObject(o); break;
case OBJ_SET: freeSetObject(o); break;
case OBJ_ZSET: freeZsetObject(o); break;
case OBJ_HASH: freeHashObject(o); break;
case OBJ_MODULE: freeModuleObject(o); break;
case OBJ_STREAM: freeStreamObject(o); break;
default: serverPanic("Unknown object type"); break;
}
zfree(o);
} else {
if (o->refcount <= 0) serverPanic("decrRefCount against refcount <= 0");
if (o->refcount != OBJ_SHARED_REFCOUNT) o->refcount--;
}
}
-
对象共享:对象的引用计数属性还带有对象共享的作用。
- 在string对象中,在创建一个数字时,会判断是否在
shared.integers(默认0-9999)
的范围中(实际上,生产的数字的引用次数refcount
固定为2147483647
),如果命中就不进行对象创建,直接使用对应的共享对象,并将引用计数加一。 - 如图展示了包含整数值
100
的字符串对象同时被键A
和键B
共享之后的样子,可以看到,除了对象的引用计数从之前的1
变成了2
之外, 其他属性都没有变化(理论上是这样,但由于数字的引用次数固定,所以实际上计数并不会有这样的变化)。
-
Redis为什么只对包含整数值得字符串对象进行共享?
- 当服务器考虑将一个共享对象设置为键的值对象时,程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同。一个共享对象保存的值越复杂,验证共享对象和目标对象是否相同所需的复杂度就会越高,消耗的CPU时间也会越多。
- 如果共享对象是保存整数值的字符串对象,验证操作的复杂度
O(1)
。 - 如果共享对象是保存字符串值的字符串对象,验证操作的复杂度
O(N)
。 - 如果共享对象是包含多个值(比如列表对象或者哈希对象)对象,验证操作的复杂度
O(N2)
。· - 因此,尽管共享更复杂的对象可以节约更多的内存,但受到CPU时间的限制,Redis只对包含整数值得字符串对象进行共享。
-
需要提醒的是,
append
和setbit
命令会解除对象的共享状态
,例如:- 执行
set testref 1
命令之后,再通过object debug testref
, 可以看到refcount:2147483647
。 - 执行
append testref 1
命令之后,再通过object debug testref
, 可以看到refcount:1
。
- 执行
- 在string对象中,在创建一个数字时,会判断是否在
// server.h
#define OBJ_SHARED_REFCOUNT INT_MAX
#define OBJ_SHARED_INTEGERS 10000
// object.c
robj *makeObjectShared(robj *o) {
serverAssert(o->refcount == 1);
o->refcount = OBJ_SHARED_REFCOUNT;
return o;
}
// server.c
void createSharedObjects(void) {
……
for (j = 0; j < OBJ_SHARED_INTEGERS; j++) {
shared.integers[j] =
makeObjectShared(createObject(OBJ_STRING,(void*)(long)j));
shared.integers[j]->encoding = OBJ_ENCODING_INT;
}
……
}
// t_string.c
void appendCommand(client *c) {
……
o = dbUnshareStringValue(c->db,c->argv[1],o);
……
}
// db.c
robj *dbUnshareStringValue(redisDb *db, robj *key, robj *o) {
serverAssert(o->type == OBJ_STRING);
if (o->refcount != 1 || o->encoding != OBJ_ENCODING_RAW) {
robj *decoded = getDecodedObject(o);
o = createRawStringObject(decoded->ptr, sdslen(decoded->ptr));
decrRefCount(decoded);
dbOverwrite(db,key,o);
}
return o;
}
redisObject的结构与对象类型、编码、内存回收、共享对象都有关系;一个redisObject对象的大小为16
字节:4bit+4bit+24bit+4Byte+8Byte=16Byte
。
以上内容参考自:
《redis设计与实现》
《Redis源码剖析系列》
《Redis内部数据结构详解系列》
《可能是目前最详细的Redis内存模型及应用解读》
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。