不同版本的 Redis 的键值对内存占用情况示例

不同版本的 Redis 的键值对内存占用情况示例

文章目录

  • [不同版本的 Redis 的键值对内存占用情况示例](#不同版本的 Redis 的键值对内存占用情况示例)
    • [Redis 6.0](#Redis 6.0)
      • redisObject
      • dictEntry
      • sds
        • [🍀 数据结构](#🍀 数据结构)
        • [🍀 sdslen() 函数](#🍀 sdslen() 函数)
        • [🍀 sdsReqType() 函数](#🍀 sdsReqType() 函数)
        • [🍀 sdsHdrSize() 函数](#🍀 sdsHdrSize() 函数)
      • [内存分配 - malloc() 函数](#内存分配 - malloc() 函数)
        • [🍀 大小类别的计算](#🍀 大小类别的计算)
        • [🍀 选择合适的 bin](#🍀 选择合适的 bin)
        • [🍀 实际内存块分配](#🍀 实际内存块分配)
      • [set [key] [value]](#set [key] [value])
        • [🍀 sdsdup() 函数](#🍀 sdsdup() 函数)
        • [🍀 dictAddRaw() 函数](#🍀 dictAddRaw() 函数)
        • [🍀 dictSetVal() 函数](#🍀 dictSetVal() 函数)
      • [memory usage [key]](#memory usage [key])
        • [🍀 计算 value 的字节数](#🍀 计算 value 的字节数)
        • [🍀 计算 key 的字节数](#🍀 计算 key 的字节数)
        • [🍀 计算键值对结构体 dictEntry 的字节数](#🍀 计算键值对结构体 dictEntry 的字节数)
        • [🍀 小结](#🍀 小结)
    • [Redis 7.0](#Redis 7.0)
      • redisObject
      • dictEntry
      • sds
        • [🍀 数据结构](#🍀 数据结构)
        • [🍀 sdslen() 函数](#🍀 sdslen() 函数)
        • [🍀 sdsReqType() 函数](#🍀 sdsReqType() 函数)
        • [🍀 sdsHdrSize() 函数](#🍀 sdsHdrSize() 函数)
      • [内存分配 - malloc() 函数](#内存分配 - malloc() 函数)
        • [🍀 大小类别的计算](#🍀 大小类别的计算)
        • [🍀 选择合适的 bin](#🍀 选择合适的 bin)
        • [🍀 实际内存块分配](#🍀 实际内存块分配)
      • [set [key] [value]](#set [key] [value])
        • [🍀 sdsdup() 函数](#🍀 sdsdup() 函数)
        • [🍀 dictAddRaw() 函数](#🍀 dictAddRaw() 函数)
        • [🍀 dictSetVal() 函数](#🍀 dictSetVal() 函数)
      • [memory usage [key]](#memory usage [key])
        • [🍀 计算 value 的字节数](#🍀 计算 value 的字节数)
        • [🍀 计算 key 的字节数](#🍀 计算 key 的字节数)
        • [🍀 计算键值对结构体 dictEntry 的字节数](#🍀 计算键值对结构体 dictEntry 的字节数)
        • [🍀 计算所在 db 库的字典元数据的字节数](#🍀 计算所在 db 库的字典元数据的字节数)
        • [🍀 小结](#🍀 小结)
    • 总结

本文主要讨论在 Redis 6.0 与 Redis 7.0 中,以下代码设置的键值对的内存使用字节差异:

shell 复制代码
# 1(6 个 a)
set aaaaaa 12345678

# 2
memory usage aaaaaa

# 3(7 个 a)
set aaaaaaa 12345678

# 4
memory usage aaaaaaa

「1」与「3」两条命令分别设置了键值对,虽然 key 只相差 1 个字符,但在 Redis 6.0 与 Redis 7.0 中使用 memory usage [key] 命令计算出的内存使用字节数有明显差异。

  • Redis 6.0

    127.0.0.1:6379> set aaaaaa 12345678
    OK
    127.0.0.1:6379> memory usage aaaaaa
    (integer) 48
    127.0.0.1:6379> set aaaaaaa 12345678
    OK
    127.0.0.1:6379> memory usage aaaaaaa
    (integer) 49
    
  • Redis 7.0

    127.0.0.1:6379> set aaaaaa 12345678
    OK
    127.0.0.1:6379> memory usage aaaaaa
    (integer) 48
    127.0.0.1:6379> set aaaaaaa 12345678
    OK
    127.0.0.1:6379> memory usage aaaaaaa
    (integer) 56
    

Redis 6.0

环境:

  • Redis 6.0.8 源码,单机模式环境
  • Ubuntu 24.04.1 LTS,x86_64 架构(64 位操作系统)

redisObject

Redis 中的 value 对象由 redisObject 结构表示。

c 复制代码
// 4 + 4 + 24 + 32 + 64 = 128 bits = 16 bytes
typedef struct redisObject {
    // 4 bit
    unsigned type:4;
    // 4 bit
    unsigned encoding:4;
    // #define LRU_BITS 24 即 24 bit
    unsigned lru:LRU_BITS;
    // 32 bit                      
    int refcount;
    // 64 bit(在 64 位操作系统中占 64 bit,在 32 位操作系统中占 32 bit)
    void *ptr;
} robj;

对象结构里包含的成员变量:

  • type :标识该对象的数据类型,数据类型是指 StringListHashSetZSet 等等。
  • encoding :标识该对象使用的底层数据结构,底层数据结构是指 SDSZipListSkipList 等等。
  • lru:用于内存淘汰策略的最近最少使用或最少频率使用的键值对状态信息。
  • refcount:引用计数。
  • ptr:指向底层数据结构的指针。

struct redisObject 占用字节数为 16 ,可使用 sizeof(robj) 计算。

dictEntry

Redis 中的键值对由 dictEntry 结构表示。

c 复制代码
// 8 + 8 + 8 = 24 bytes
typedef struct dictEntry {
    // 8 bytes
    void *key;
    
    // 8 bytes
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    
    // 8 bytes
    struct dictEntry *next;
} dictEntry;

对象结构里包含的成员变量:

  • key:存储 key 地址的指针。
  • v:联合体,存储 value 地址或 value 本身的值。
  • next:指向链表中的下一个元素。

struct dictEntry 占用字节数为 24 ,可根据 sizeof(robj) 计算。

sds

🍀 数据结构
c 复制代码
// sds 实际是字符指针的别名,指向的是 sdshdr5、sdshdr8、sdshdr16 等结构体的 buf 字符数组
typedef char *sds;
c 复制代码
/* 注意:sdshdr5 不会作为 value 的数据结构 */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 低 3 bit 表示 sds 类型,高 5 bit 表示字符串有效长度(不包含结束字符 '\0') */
    char buf[]; /* 实际存储字符 */
};
c 复制代码
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* 字符串有效长度,不包含结束字符 '\0' */
    uint8_t alloc; /* 为 buf 字符数组分配了的字节大小,不包含结束字符 '\0' */
    unsigned char flags; /* 低 3 bit 表示 sds 类型,高 5 bit 未使用 */
    char buf[]; /* 实际存储字符 */
};

struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len;
    uint16_t alloc;
    unsigned char flags;
    char buf[];
};

struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len;
    uint32_t alloc;
    unsigned char flags;
    char buf[];
};

struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len;
    uint64_t alloc;
    unsigned char flags;
    char buf[];
};

根据内存分配原理,如果我们已知 buf 字符数组的起始地址,那么在此地址的基础上,将地址减去 sizeof(char),得到的地址所存储的内容就是字符变量 flags 的内容。据此,我们就可以得到对应的 sds 类型。 这一点在后面的源码分析中会有体现。

📍 __attribute__ ((__packed__)) 用于告诉编译器进行紧凑字节填充,即忽略默认的对齐规则,不进行任何字节填充。

🍀 sdslen() 函数

作用:返回字符串的有效长度,有效长度并不包含结束字符 '\0'

c 复制代码
/* 返回字符串的有效长度 */
static inline size_t sdslen(const sds s) {
    // sds 实际是 char * 别名,因此 s[-1] 实际上将字符指针存储的地址减去 sizeof(char) 并解引用,得到字符变量 flags 存储的内容 
    unsigned char flags = s[-1];
    // 根据 flags 中存储的 sds 类型标识来判断 sds 类型,以正确得到 len 属性值,即字符串有效长度
    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;
}
🍀 sdsReqType() 函数

作用:根据字符串长度,获取至少应该使用的 sds 数据结构类型的标识。

c 复制代码
/* 根据字符串长度 string_size,获取至少应该使用的 sds 数据结构类型的标识。 */
static inline char sdsReqType(size_t string_size) {
    // 如果字符串长度小于 2^5,则应当使用类型为 sdshr5 的结构体作为 sds 数据结构
    if (string_size < 1<<5)
        return SDS_TYPE_5;
    // 如果字符串长度小于 2^8,则应当使用类型为 sdshr8 的结构体作为 sds 数据结构
    if (string_size < 1<<8)
        return SDS_TYPE_8;
    // 如果字符串长度小于 2^8,则应当使用类型为 sdshr16 的结构体作为 sds 数据结构
    if (string_size < 1<<16)
        return SDS_TYPE_16;
    
// 条件编译,会根据操作系统架构进行动态调整代码
#if (LONG_MAX == LLONG_MAX)
    if (string_size < 1ll<<32)
        return SDS_TYPE_32;
    return SDS_TYPE_64;
#else
    return SDS_TYPE_32;
#endif
}
🍀 sdsHdrSize() 函数

作用:根据类型标识,获取对应类型的结构体(sdshdr5、sdshdr8、sdshdr16...)的占用字节大小。

c 复制代码
/* 根据类型标识 type,获取对应类型的结构体(sdshdr5、sdshdr8、sdshdr16...)的占用字节大小。 */
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;
}

内存分配 - malloc() 函数

Redis 选择了使用 jemalloc 作为其默认的内存分配器,因此我们这里关注 jemalloc 对 malloc() 函数的实现。

c 复制代码
/* 内存分配,size 是请求分配的内存大小,但实际分配的连续内存块大小 >= size */
void *je_malloc(size_t size) {
    void *ret;
    static_opts_t sopts;
    dynamic_opts_t dopts;

    LOG("core.malloc.entry", "size: %zu", size);

    static_opts_init(&sopts);
    dynamic_opts_init(&dopts);

    sopts.bump_empty_alloc = true;
    sopts.null_out_result_on_error = true;
    sopts.set_errno_on_error = true;
    sopts.oom_string = "<jemalloc>: Error in malloc(): out of memory\n";

    dopts.result = &ret;
    dopts.num_items = 1;

    /* 将 item_size 设置为请求分配的内存大小 size */
    dopts.item_size = size;

    /* imalloc() 函数完成内存分配,并将分配的连续内存块的起始地址存放在 dopts.result 指针指向的地址中,即 ret 中 */
    imalloc(&sopts, &dopts);

    LOG("core.malloc.exit", "result: %p", ret);

    return ret;
}
c 复制代码
/* 返回内存分配情况的错误状态码 */
int imalloc(static_opts_t *sopts, dynamic_opts_t *dopts) {
	// ...
    
	/* We always need the tsd.  Let's grab it right away. */
	tsd_t *tsd = tsd_fetch();
	assert(tsd);
    
	if (likely(tsd_fast(tsd))) {
		/* Fast and common path. */
		tsd_assert_fast(tsd);
		sopts->slow = false;
         /* imalloc_body() 函数完成内存分配,并将分配的连续内存块的起始地址存放在 dopts->result 指针指向的地址中 */ 
		return imalloc_body(sopts, dopts, tsd);
	} else {
		sopts->slow = true;
		return imalloc_body(sopts, dopts, tsd);
	}
}
c 复制代码
int imalloc_body(static_opts_t *sopts, dynamic_opts_t *dopts, tsd_t *tsd) {
    /* 指向实际分配的内存块的起始地址 */
    void *allocation = NULL;
    /* 用于存储请求的内存大小 */
    size_t size = 0;

    szind_t ind = 0;
    size_t usize = 0;

    /* Reentrancy is only checked on slow path. */
    int8_t reentrancy_level;

    /* 计算请求的内存大小,正常情况下,会将 *size = dopts->item_size,也就是将请求的内存大小赋值给 size 变量 */
    if (unlikely(compute_size_with_overflow(sopts->may_overflow, dopts,
                                            &size))) {
        goto label_oom; // 如果计算过程中发生溢出,则跳转到错误处理标签
    }

    // ...

    /* 核心算法开始 */

    // 如果没有特殊对齐要求,默认情况下 dopts->alignment 为 0
    if (dopts->alignment == 0) {
        /* 将请求的字节大小 size 转为索引,该索引用于定位负责处理特定大小内存块的 bin */
        ind = sz_size2index(size);
        // ...
    } else { // 如果有对齐要求
        // 根据对齐需求调整大小
        usize = sz_sa2u(size, dopts->alignment);
        // ...
    }

    // ...
    
    // imalloc_no_sample() 函数实际执行内存分配,并返回分配的连续内存块的起始地址
    allocation = imalloc_no_sample(sopts, dopts, tsd, size, usize, ind);
    if (unlikely(allocation == NULL)) {
        goto label_oom;
    }



    /* Success! */
    // 将已分配的内存块的起始地址赋给 *dopts->result
    *dopts->result = allocation;
    return 0;

    // ...
}

整个的内存分配,大致做了三件事情:

  1. 大小类别的计算
  2. 选择合适的 bin
  3. 实际内存块分配
🍀 大小类别的计算

将请求的字节大小 size 转为索引,该索引用于定位负责处理特定大小内存块的 bin。实际上,该索引不仅可以定位到 tcache_t 中对应的 cache_bin_t 实例,还可以得到请求字节大小对应的实际 jemalloc 应该分配的内存块大小,这个实际分配内存块大小等于 sz_index2size_tab[ind]

c 复制代码
szind_t sz_size2index(size_t size) {
    assert(size > 0);
    if (likely(size <= LOOKUP_MAXCLASS)) {
        /* 将请求的字节大小 size 转为索引,该索引用于定位负责处理特定大小内存块的 bin */
        return sz_size2index_lookup(size);
    }
    return sz_size2index_compute(size);
}
c 复制代码
#define LG_TINY_MIN		3

szind_t sz_size2index_lookup(size_t size) {
    assert(size <= LOOKUP_MAXCLASS);
    {
        /* 
         * 1.根据 size 计算 sz_size2index_tab 映射表索引:(size-1) >> LG_TINY_MIN
         * 2.从 sz_size2index_tab 映射表获取定位 bin 的索引:sz_size2index_tab[(size-1) >> LG_TINY_MIN] */
        szind_t ret = (sz_size2index_tab[(size-1) >> LG_TINY_MIN]);

        assert(ret == sz_size2index_compute(size));

        /* 返回用于定位负责处理特定大小内存块的 bin 的索引 */
        return ret;
    }
}

这里 jemalloc 实际维护了两张映射表:

  1. sz_size2index_tab:维护了从「请求字节大小 」到「索引」的映射。

    c 复制代码
    /*
     * sz_size2index_tab is a compact lookup table that rounds request sizes up to
     * size classes.  In order to reduce cache footprint, the table is compressed,
     * and all accesses are via sz_size2index().
     */
    extern uint8_t const sz_size2index_tab[];
    数组索引(index) 定位 bin/存储的 sz_index2size_tab 数组索引(value)
    0 0
    1 1
    2 2
    3 3
    4 4
    5 5
    6 6
    7 7
    8 8
    9 8
    10 9
    11 9
    12 10
    ... ...
  2. sz_index2size_tab:维护了从「索引 」到「jemalloc 应该分配的内存块大小」的映射。

    c 复制代码
    /*
     * sz_index2size_tab encodes the same information as could be computed (at
     * unacceptable cost in some code paths) by sz_index2size_compute().
     */
    extern size_t const sz_index2size_tab[NSIZES];
    数组索引(index) jemalloc 应该分配的内存块大小(value)
    0 8
    1 16
    2 24
    3 32
    4 40
    5 48
    6 56
    7 64
    8 80
    9 96
    10 112
    11 128
    12 160
    13 192
    14 224
    ... ...
    233 6917529027641081856
    234 8070450532247928832

📑 例如:

  • 如果请求字节大小 size = 8,那么通过 sz_size2index_lookup() 计算得到的存储的 sz_index2size_tab 数组索引为 0 ,可得 sz_index2size_tab[0] 的值为 8。也就是说,如果请求字节大小为 8,那么 jemalloc 会为其分配 8 字节的连续内存块。
  • 如果请求字节大小 size = 9,那么通过 sz_size2index_lookup() 计算得到的存储的 sz_index2size_tab 数组索引为 1 ,可得 sz_index2size_tab[1] 的值为 16。也就是说,如果请求字节大小为 9,那么 jemalloc 会为其分配 16 字节的连续内存块。
🍀 选择合适的 bin

当应用程序请求分配某个大小的对象时,jemalloc 会计算出最接近且不小于该大小的类别索引,然后使用这个索引来访问 tcache_t 中对应的 cache_bin_t 进行分配。每个 cache_bin_t 实例专用于一个预定义的大小类别,从而实现了对多种不同大小内存块的支持。

也就是在源码中,有大概这样的逻辑:

c 复制代码
szind_t ind = sz_size2index(size); // 获取大小类别的索引
cache_bin_t *bin = &tcache->bins_small[ind]; // 获取对应的 bin,以 cache_bin_t	bins_small[39] 数组为例

tcache 是什么呢?

c 复制代码
#define NBINS			39
#define NSIZES			235

struct tcache_s {
	// ...

	/*
	 * 小对象的缓存 bin 数组
	 * 每个索引 i 位置的 bin 每次分配的内存块大小与 sz_index2size_tab 映射表中索引 i 位置存储的 jemalloc 应该分配的内存块大小相同
	 */
	cache_bin_t bins_small[NBINS];

	/*
	 * 大对象的缓存 bin 数组
	 * 每个索引 i 位置的 bin 每次分配的内存块大小与 sz_index2size_tab 映射表中索引 i+NBINS 位置存储的 jemalloc 应该分配的内存块大小相同
	 */
	cache_bin_t bins_large[NSIZES - NBINS];
};
🍀 实际内存块分配

首先需要了解 cache_bin_t 结构体:

c 复制代码
typedef struct cache_bin_s cache_bin_t;
typedef int32_t cache_bin_sz_t;

struct cache_bin_s {
	cache_bin_sz_t low_water;

	cache_bin_sz_t ncached;

	cache_bin_stats_t tstats;

	void **avail;
};
  • avail:这是一个二级指针,存储了一个指针数组的末端边界地址。指针数组是用于存储一组指向可用内存块的指针。指针数组可看做是一个栈结构,从栈顶 -> 栈底,对应指针数组的首地址 -> 末端边界地址(低地址 -> 高地址),avail 二级指针指向的地址即栈底。
  • ncached:记录当前 bin 中有多少个可用的内存块,每次成功分配时减一,回收时加一。它也是指针数组的元素个数,即可用内存块数量。

avail[-ncached, ..., -1] 是可用内存块的指针,其中最低地址的对象将最先被分配出去。也就是说,当进行内存分配时,将栈顶元素 *(avail - ncached) 弹出,并 ncached--。源码如下:

c 复制代码
/* 使用 bin 实例分配对应大小的内存块,返回分配的内存块的首地址 */
void *cache_bin_alloc_easy(cache_bin_t *bin, bool *success) {
	void *ret;

	/* 检查 bin 中是否有可用的缓存块 */
	if (unlikely(bin->ncached == 0)) { // 如果没有可用块
		bin->low_water = -1; // 设置低水位标记为无效值
		*success = false; // 分配失败
		return NULL; // 返回空指针表示分配失败
	}

	/* 分配成功 */
	*success = true;

	/* 
	 * 从 bin 的 avail 栈顶弹出一个内存块地址。
	 * 注意这里的减法操作是因为 avail 指向的是栈底,而 ncached 表示栈中的元素数量。
	 * 这样可以确保我们总是从栈顶获取最新的可用块。
	 */
	ret = *(bin->avail - bin->ncached);

	/* 更新缓存计数器,因为我们刚刚分配了一个块 */
	bin->ncached--;

	if (unlikely(bin->ncached < bin->low_water)) {
		bin->low_water = bin->ncached;
	}

	/* 返回分配的内存块地址 */
	return ret;
}

set [key] [value]

set [key] [value] 命令对应的处理函数为 setCommand

以在命令执行前,db 中不存在该 key 为例,setCommand() 函数会调用到核心处理函数 dbAdd()

c 复制代码
/* 将 key-value 添加到 db 中 */
void dbAdd(redisDb *db, robj *key, robj *val) {
    // sds 是 char* 的别名,通过 sdsdup() 函数得到的实际是表示 key 的 sds 结构体 buf 字符数组首元素地址
    sds copy = sdsdup(key->ptr);
    
    int retval = dictAdd(db->dict, copy, val);

    serverAssertWithInfo(NULL,key,retval == DICT_OK);
    if (val->type == OBJ_LIST ||
        val->type == OBJ_ZSET ||
        val->type == OBJ_STREAM)
        signalKeyAsReady(db, key);
    if (server.cluster_enabled) slotToKeyAdd(key->ptr);
}

int dictAdd(dict *d, void *key, void *val)
{
    // 1.在堆中开辟 dictEntry 结构体对象空间
    // 2.将 dictEntry 存储在 db 字典中
    // 3.将 key 字符数组的首元素地址存储在 dictEntry 结构体的 void *key 指针中
    // 4.返回 dictEntry 结构体指针
    dictEntry *entry = dictAddRaw(d,key,NULL);

    if (!entry) return DICT_ERR;
    
    // 将 value 设置到 dictEntry 结构体对象 
    dictSetVal(d, entry, val);
    
    return DICT_OK;
}

我们重点关注源码中以下三个函数的作用:

  • sdsdup(key->ptr):拷贝 sds,并返回字符数组的首元素地址。
  • dictAddRaw(d, key, NULL)
    1. 在堆中开辟 dictEntry 结构体对象空间;
    2. 将 dictEntry 存储在 db 字典中;
    3. 将 key 字符数组的首元素地址存储在 dictEntry 结构体的 void *key 指针中;
    4. 返回 dictEntry 结构体指针。
  • dictSetVal(d, entry, val):将 value 设置到 dictEntry 结构体对象
🍀 sdsdup() 函数

作用:拷贝 sds,并返回字符数组的首元素地址。

c 复制代码
sds sdsdup(const sds s) {
    /* sdslen(s) 返回字符串的有效长度 */
    return sdsnewlen(s, sdslen(s));
}
c 复制代码
sds sdsnewlen(const void *init, size_t initlen) {
    void *sh;
    sds s;
    
    /* 根据初始化长度获取至少应该使用的 sds 数据结构类型的标识 */
    char type = sdsReqType(initlen);
    
    /* 空字符串通常为拼接而创建的,因此使用 sdshdr8 作为 sds 数据结构比 sdshdr5 更加合适 */
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    
    /* 根据类型标识,获取对应类型的结构体(sdshdr5、sdshdr8、sdshdr16...)的占用字节大小 */
    /* 由于实际存储字符串的 char buf[] 是结构体最后一个成员,因此这是一个柔性数组,占用字节不会计算在使用 sizeof() 得到的结构体占用字节范围内 */
    int hdrlen = sdsHdrSize(type);
    
    /* 指向 sds 类型 ------ flags 变量的指针 */
    unsigned char *fp;

    
    /* 为 sds 数据结构分配堆内存 */
    /* 请求字节大小为 hdrlen+initlen+1 = sds 结构体大小 + 字符串有效长度 + 结束字符 '\0' 的 1 个字节 */
    sh = s_malloc(hdrlen+initlen+1);
    
    // ...
    
    /* 将预期字符数组 buf 的起始地址存储到 char* s 中 */
    s = (char*)sh+hdrlen;
    /* 将 flags 变量的地址存储到 unsigned char *fp 中 */
    fp = ((unsigned char*)s)-1;
    
    /* 根据类型标识,对 sds 类型的实现数据类型结构体进行属性配置 */
    switch(type) {
        case SDS_TYPE_5: {
            *fp = type | (initlen << SDS_TYPE_BITS); /* 低 3 bit 表示 sds 类型,高 5 bit 表示字符串有效长度 */
            break;
        }
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s); /* SDS_HDR_VAR 是一个宏函数,作用是将 sh 指针指向 sds 结构体的起始地址,以操作结构体 */
            sh->len = initlen; /* 设置字符串有效长度 */
            sh->alloc = initlen; /* 设置为 buf 字符数组分配了的字节大小 */
            *fp = type; /* 设置 sds 类型 */
            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);
    
    /* 为了兼容 c 标准字符串函数,以 '\0' 作为字符数组结束标识 */
    s[initlen] = '\0';
    
    /* 返回字符数组 buf 的起始地址 */
    return s;
}

对应的:

  • key = "aaaaaa",字节数 initlen 为 6,由于字节数 < 32,因此使用 sdshdr5 作为 key 字符串的数据结构,则 hdrlen = sizeof(struct sdshdr5) = 1,因此调用 s_malloc() 函数时请求字节大小为 hdrlen+initlen+1 = 1+6+1 = 8,则 sz_size2index_lookup() 函数计算得到的存储的 sz_index2size_tab 数组索引为 0 ,而 sz_index2size_tab[0] 的值为 8 ,即实际分配内存块大小为 8 。在配置 sdshdr5 实例属性时,设置 alloc = initlen = 6

  • key = "aaaaaaa",字节数 initlen 为 7,由于字节数 < 32,因此使用 sdshdr5 作为 key 字符串的数据结构,则 hdrlen = sizeof(struct sdshdr5) = 1,因此调用 s_malloc_usable() 函数时请求字节大小为 hdrlen+initlen+1 = 1+7+1 = 9,则 sz_size2index_lookup() 函数计算得到的存储的 sz_index2size_tab 数组索引为 1 ,而 sz_index2size_tab[0] 的值为 16 ,即实际分配内存块大小为 16 。在配置 sdshdr5 实例属性时,设置 alloc = initlen = 7。也就是说,Redis 不会将多分配的 7 字节作为字符数组 buf 的空间使用。

🍀 dictAddRaw() 函数

作用:

  1. 在堆中开辟 dictEntry 结构体对象空间;
  2. 将 dictEntry 存储在 db 字典中;
  3. 将 key 字符数组的首元素地址存储在 dictEntry 结构体的 void *key 指针中;
  4. 返回 dictEntry 结构体指针。
c 复制代码
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
    long index;
    dictEntry *entry;
    dictht *ht;

    if (dictIsRehashing(d)) _dictRehashStep(d);

    /* 计算 key 在 dict 哈希字典中的索引 */
    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
        return NULL;

    /* 由于可能的扩容,因此存在两个 dict,需要判断使用使用哪一个 */
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
    /* 在堆中开辟 dictEntry 结构体对象空间 */
    entry = zmalloc(sizeof(*entry));
    /* 采用头插法,并以链表形式,将 dictEntry 存储到字典索引位置 */
    entry->next = ht->table[index];
    ht->table[index] = entry;
    /* key 计数 +1 */
    ht->used++;

    /* dictSetKey 是一个宏,会替换为 entry->key = key 即将 key 字符数组的首元素地址存储在 dictEntry 结构体的 void *key 指针中 */
    dictSetKey(d, entry, key);
    /* 返回 dictEntry 结构体指针 */
    return entry;
}

对应的:

  • key = "aaaaaa",由于 sizeof(struct dictEntry) 为 24,因此 sz_size2index_lookup() 函数计算得到的存储的 sz_index2size_tab 数组索引为 2 ,而 sz_index2size_tab[2] 的值为 24,即实际分配内存块大小为 24。

  • key = "aaaaaaa",由于 sizeof(struct dictEntry) 为 24,因此 sz_size2index_lookup() 函数计算得到的存储的 sz_index2size_tab 数组索引为 2 ,而 sz_index2size_tab[2] 的值为 24,即实际分配内存块大小为 24。

🍀 dictSetVal() 函数

作用:将 value 设置到 dictEntry 结构体对象。这实际是一个宏函数,在预编译时期完成替换。

c 复制代码
#define dictSetVal(d, entry, _val_) do { \
    if ((d)->type->valDup) \
        (entry)->v.val = (d)->type->valDup((d)->privdata, _val_); \
    else \
        (entry)->v.val = (_val_); \
} while(0)

这一步很简单,就是设置 dictEntry -> v.val 指针指向。但我们要重点关注的是 dictEntry -> v.val 指针或者说 robj *val 指针指向的结构体信息,因为这个结构体是 value 的实际内存存储与占用内容。

这里只看 value = "12345678" 的源码部分。由于 "12345678" 可用整型表示,为了节约内存,Redis 会使用 OBJ_ENCODING_INT 编码来进行优化。

c 复制代码
// 返回 value 对应的 redisObject 结构体的指针
robj *createStringObjectFromLongLongWithOptions(long long value, int valueobj) {
    robj *o;

    // ...

    if (value >= LONG_MIN && value <= LONG_MAX) {
        // 创建一个 type = OBJ_STRING 的 redisObject 结构体,sizeof(struct redisObject) 为 16 字节
        o = createObject(OBJ_STRING, NULL);
        // 设置编码为 OBJ_ENCODING_INT
        o->encoding = OBJ_ENCODING_INT;
        // 复用指针变量,节省内存,把 12345678 当做地址存储。在 get 时,会根据 encoding 再从 ptr 取出值
        o->ptr = (void*)((long)value);
    }

    // ...
    
    return o;
}

对应的:

  • key = "aaaaaa",value = "12345678",由于 sizeof(struct redisObject) 为 16,因此 sz_size2index_lookup() 函数计算得到的存储的 sz_index2size_tab 数组索引为 1 ,而 sz_index2size_tab[1] 的值为 16,即实际分配内存块大小为 16。

  • key = "aaaaaaa",value = "12345678",由于 sizeof(struct redisObject) 为 16,因此 sz_size2index_lookup() 函数计算得到的存储的 sz_index2size_tab 数组索引为 1 ,而 sz_index2size_tab[1] 的值为 16,即实际分配内存块大小为 16。

memory usage [key]

memory usage [key] 命令对应的处理函数为 memoryCommand

c 复制代码
void memoryCommand(client *c) {
    // ...

    // 1.计算 value 的字节数
    size_t usage = objectComputeSize(dictGetVal(de),samples);
    // 2.计算 key 的字节数
    usage += sdsAllocSize(dictGetKey(de));
    // 3.计算键值对结构体 dictEntry 的字节数
    usage += sizeof(dictEntry);
    
    // ...
}

usage 变量即是用于存储键值对的内存使用字节数。可以看到,共有三个部分组成:

  1. objectComputeSize(dictGetVal(de),samples):计算 value 的字节数。
  2. sdsAllocSize(dictGetKey(de)):计算 key 的字节数。
  3. sizeof(dictEntry):计算键值对结构体 dictEntry 的字节数。
🍀 计算 value 的字节数
c 复制代码
size_t objectComputeSize(robj *o, size_t sample_size) {
    sds ele, ele2;
    dict *d;
    dictIterator *di;
    struct dictEntry *de;
    size_t asize = 0, elesize = 0, samples = 0;

    if (o->type == OBJ_STRING) {
        if(o->encoding == OBJ_ENCODING_INT) { // 执行第 1 个 if 中的语句
            asize = sizeof(*o); // sizeof(struct redisObject) = 16 bytes
        } else if(o->encoding == OBJ_ENCODING_RAW) {
            asize = sdsAllocSize(o->ptr)+sizeof(*o);
        } else if(o->encoding == OBJ_ENCODING_EMBSTR) {
            asize = sdslen(o->ptr)+2+sizeof(*o);
        } else {
            serverPanic("Unknown string encoding");
        }
    } else if (o->type == OBJ_LIST) {
        // ...
    } else if (o->type == OBJ_SET) {
        // ...
    } else if (o->type == OBJ_ZSET) {
        // ...
    } else if (o->type == OBJ_HASH) {
        // ...
    } else if (o->type == OBJ_STREAM) {
        // ...
    } else if (o->type == OBJ_MODULE) {
        // ...
    } else {
        serverPanic("Unknown object type");
    }

    return asize;
}

对应的:

  • key = "aaaaaa",value = "12345678",存储时 redisObject.type = OBJ_STRINGredisObject.encoding = OBJ_ENCODING_INT,则 objectComputeSize() 函数返回结果为 sizeof(struct redisObject) 即 16。
  • key = "aaaaaaa",value = "12345678",存储时 redisObject.type = OBJ_STRINGredisObject.encoding = OBJ_ENCODING_INT,则 objectComputeSize() 函数返回结果为 sizeof(struct redisObject) 即 16。
🍀 计算 key 的字节数
c 复制代码
size_t sdsAllocSize(sds s) {
    // 获取 sds 结构体的 alloc 属性值,这实际是为字符数组 buf 开辟了的内存大小(不包含结束字符 '\0')
    size_t alloc = sdsalloc(s);
    // sds 结构体占用字节 + 为字符数组 buf 开辟了的内存大小 + 结束字符 '\0' 1 个字节
    // 这实际是之前 set 时对 key 进行内存分配计算出的请求内存大小,而非实际分配内存大小,redis 6.0 没有使用这多分配的空间
    return sdsHdrSize(s[-1])+alloc+1;
}

/* sdsalloc() = sdsavail() + sdslen() */
static inline size_t sdsalloc(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)->alloc;
        case SDS_TYPE_16:
            return SDS_HDR(16,s)->alloc;
        case SDS_TYPE_32:
            return SDS_HDR(32,s)->alloc;
        case SDS_TYPE_64:
            return SDS_HDR(64,s)->alloc;
    }
    return 0;
}

对应的:

  • key = "aaaaaa",通过之前对 sdsdup() 函数的分析,请求内存大小为 sizeof(struct sdshdr5)+alloc+1=1+6+1=8
  • key = "aaaaaaa",通过之前对 sdsdup() 函数的分析,请求内存大小为 sizeof(struct sdshdr5)+alloc+1=1+7+1=9
🍀 计算键值对结构体 dictEntry 的字节数
c 复制代码
typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

占用字节分析:

  • *void key:8 字节。
  • union v:联合体,8 字节。
  • *struct dictEntry next:8 字节。

综上,sizeof(struct dictEntry) 的结果为 24 字节。

🍀 小结

综上对每个函数的分析,以及 set 时的具体实现,我们得出:

类型 set aaaaaa 12345678 set aaaaaaa 12345678
计算 value 的字节数 16 16
计算 key 的字节数 8 9
计算键值对结构体 dictEntry 的字节数 24 24
字节总和 48 49

Redis 7.0

  • Redis 7.0.14 源码,单机模式环境
  • Ubuntu 24.04.1 LTS,x86_64 架构(64 位操作系统)

redisObject

Redis 中的 value 对象由 redisObject 结构表示。

c 复制代码
// 4 + 4 + 24 + 32 + 64 = 128 bits = 16 bytes
typedef struct redisObject {
    // 4 bit
    unsigned type:4;
    // 4 bit
    unsigned encoding:4;
    // #define LRU_BITS 24 即 24 bit
    unsigned lru:LRU_BITS;
    // 32 bit                      
    int refcount;
    // 64 bit(在 64 位操作系统中占 64 bit,在 32 位操作系统中占 32 bit)
    void *ptr;
} robj;

对象结构里包含的成员变量:

  • type :标识该对象的数据类型,数据类型是指 StringListHashSetZSet 等等。
  • encoding :标识该对象使用的底层数据结构,底层数据结构是指 SDSZipListSkipList 等等。
  • lru:用于内存淘汰策略的最近最少使用或最少频率使用的键值对状态信息。
  • refcount:引用计数。
  • ptr:指向底层数据结构的指针。

struct redisObject 占用字节数为 16 ,可使用 sizeof(robj) 计算。

dictEntry

Redis 中的键值对由 dictEntry 结构表示。

c 复制代码
// 8 + 8 + 8 = 24 bytes
typedef struct dictEntry {
    // 8 bytes
    void *key;
    
    // 8 bytes
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    
    // 8 bytes
    struct dictEntry *next;
    // 空指针数组,由于是结构体最后一个成员,因此是柔性数组,不参与结构体占用字节大小计算
    void *metadata[];
} dictEntry;

对象结构里包含的成员变量:

  • key:存储 key 地址的指针。
  • v:联合体,存储 value 地址或 value 本身的值。
  • next:指向链表中的下一个元素。
  • metadata:存储与键值对相关的额外信息。

struct dictEntry 占用字节数为 24 ,可根据 sizeof(robj) 计算。

sds

🍀 数据结构
c 复制代码
// sds 实际是字符指针的别名,指向的是 sdshdr5、sdshdr8、sdshdr16 等结构体的 buf 字符数组
typedef char *sds;
c 复制代码
/* 注意:sdshdr5 不会作为 value 的数据结构 */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 低 3 bit 表示 sds 类型,高 5 bit 表示字符串有效长度(不包含结束字符 '\0') */
    char buf[]; /* 实际存储字符 */
};
c 复制代码
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* 字符串有效长度,不包含结束字符 '\0' */
    uint8_t alloc; /* 为 buf 字符数组分配了的字节大小,不包含结束字符 '\0' */
    unsigned char flags; /* 低 3 bit 表示 sds 类型,高 5 bit 未使用 */
    char buf[]; /* 实际存储字符 */
};

struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len;
    uint16_t alloc;
    unsigned char flags;
    char buf[];
};

struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len;
    uint32_t alloc;
    unsigned char flags;
    char buf[];
};

struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len;
    uint64_t alloc;
    unsigned char flags;
    char buf[];
};

根据内存分配原理,如果我们已知 buf 字符数组的起始地址,那么在此地址的基础上,将地址减去 sizeof(char),得到的地址所存储的内容就是字符变量 flags 的内容。据此,我们就可以得到对应的 sds 类型。 这一点在后面的源码分析中会有体现。

📍 __attribute__ ((__packed__)) 用于告诉编译器进行紧凑字节填充,即忽略默认的对齐规则,不进行任何字节填充。

🍀 sdslen() 函数

作用:返回字符串的有效长度,有效长度并不包含结束字符 '\0'

c 复制代码
/* 返回字符串的有效长度 */
static inline size_t sdslen(const sds s) {
    // sds 实际是 char * 别名,因此 s[-1] 实际上将字符指针存储的地址减去 sizeof(char) 并解引用,得到字符变量 flags 存储的内容 
    unsigned char flags = s[-1];
    // 根据 flags 中存储的 sds 类型标识来判断 sds 类型,以正确得到 len 属性值,即字符串有效长度
    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;
}
🍀 sdsReqType() 函数

作用:根据字符串长度,获取至少应该使用的 sds 数据结构类型的标识。

c 复制代码
/* 根据字符串长度 string_size,获取至少应该使用的 sds 数据结构类型的标识。 */
static inline char sdsReqType(size_t string_size) {
    // 如果字符串长度小于 2^5,则应当使用类型为 sdshr5 的结构体作为 sds 数据结构
    if (string_size < 1<<5)
        return SDS_TYPE_5;
    // 如果字符串长度小于 2^8,则应当使用类型为 sdshr8 的结构体作为 sds 数据结构
    if (string_size < 1<<8)
        return SDS_TYPE_8;
    // 如果字符串长度小于 2^8,则应当使用类型为 sdshr16 的结构体作为 sds 数据结构
    if (string_size < 1<<16)
        return SDS_TYPE_16;
    
// 条件编译,会根据操作系统架构进行动态调整代码
#if (LONG_MAX == LLONG_MAX)
    if (string_size < 1ll<<32)
        return SDS_TYPE_32;
    return SDS_TYPE_64;
#else
    return SDS_TYPE_32;
#endif
}
🍀 sdsHdrSize() 函数

作用:根据类型标识,获取对应类型的结构体(sdshdr5、sdshdr8、sdshdr16...)的占用字节大小。

c 复制代码
/* 根据类型标识 type,获取对应类型的结构体(sdshdr5、sdshdr8、sdshdr16...)的占用字节大小。 */
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;
}

内存分配 - malloc() 函数

Redis 选择了使用 jemalloc 作为其默认的内存分配器,因此我们这里关注 jemalloc 对 malloc() 函数的实现。

整个的内存分配,大致做了三件事情:

  1. 大小类别的计算
  2. 选择合适的 bin
  3. 实际内存块分配
c 复制代码
void *je_malloc(size_t size) {

    // ...

    /* 
     * 1.大小类别的计算
     * 将请求的字节大小 size 转为索引,该索引用于定位负责处理特定大小内存块的 bin */
    szind_t ind = sz_size2index_lookup(size);
    
    // ...

    /* 
     * 2.选择合适的 bin
     * 从 tcache 中获取对应大小类别的缓存 bin */
    cache_bin_t *bin = tcache_small_bin_get(tcache, ind);
    
    bool tcache_success;
    /* 
     * 3.实际内存块分配
     * 尝试从 bin 中分配内存,如果成功则设置 tcache_success 为 true,并返回分配的连续内存的起始地址 */
    void* ret = cache_bin_alloc_easy(bin, &tcache_success);

    /* 如果分配成功 */
    if (tcache_success) {
        // ...

        /* 返回分配的连续内存的起始地址 */
        return ret;
    }

    /* 如果上述过程未能成功分配内存,则使用默认的内存分配方法 */
    return malloc_default(size);
}
🍀 大小类别的计算

将请求的字节大小 size 转为索引,该索引用于定位负责处理特定大小内存块的 bin。实际上,该索引不仅可以定位到 tcache_t 中对应的 cache_bin_t 实例,还可以得到请求字节大小对应的实际 jemalloc 应该分配的内存块大小,这个实际分配内存块大小等于 sz_index2size_tab[ind]

c 复制代码
#define SC_LG_TINY_MIN 3

szind_t sz_size2index_lookup(size_t size) {
    assert(size <= SC_LOOKUP_MAXCLASS);

    /* 
     * 1.根据 size 计算 sz_size2index_tab 映射表索引:(size + (ZU(1) << SC_LG_TINY_MIN) - 1) >> SC_LG_TINY_MIN
     * 2.从 sz_size2index_tab 映射表获取定位 bin 的索引:sz_size2index_tab[(size + (ZU(1) << SC_LG_TINY_MIN) - 1) >> SC_LG_TINY_MIN] */
    szind_t ret = (sz_size2index_tab[(size + (ZU(1) << SC_LG_TINY_MIN) - 1)
                                     >> SC_LG_TINY_MIN]);

    assert(ret == sz_size2index_compute(size));

    /* 返回存储的 sz_index2size_tab 数组索引 */
    return ret;
}

这里 jemalloc 实际维护了两张映射表:

  1. sz_size2index_tab:维护了从「请求字节大小 」到「索引」的映射。

    c 复制代码
    uint8_t sz_size2index_tab[(SC_LOOKUP_MAXCLASS >> SC_LG_TINY_MIN) + 1];
    
    /* 以下初始化映射表代码不必做了解 */
    static void sz_boot_size2index_tab(const sc_data_t *sc_data) {
    	size_t dst_max = (SC_LOOKUP_MAXCLASS >> SC_LG_TINY_MIN) + 1;
    	size_t dst_ind = 0;
    	for (unsigned sc_ind = 0; sc_ind < SC_NSIZES && dst_ind < dst_max;
    	    sc_ind++) {
    		const sc_t *sc = &sc_data->sc[sc_ind];
    		size_t sz = (ZU(1) << sc->lg_base)
    		    + (ZU(sc->ndelta) << sc->lg_delta);
    		size_t max_ind = ((sz + (ZU(1) << SC_LG_TINY_MIN) - 1)
    				   >> SC_LG_TINY_MIN);
    		for (; dst_ind <= max_ind && dst_ind < dst_max; dst_ind++) {
    			sz_size2index_tab[dst_ind] = sc_ind;
    		}
    	}
    }
    数组索引(index) 存储的 sz_index2size_tab 数组索引(value)
    0 0
    1 0
    2 1
    3 2
    4 3
    5 4
    6 5
    7 6
    8 7
    9 8
    ... ...
  2. sz_index2size_tab:维护了从「索引 」到「jemalloc 应该分配的内存块大小」的映射。

    c 复制代码
    size_t sz_index2size_tab[SC_NSIZES];
    
    /* 以下初始化映射表代码不必做了解 */
    static void sz_boot_index2size_tab(const sc_data_t *sc_data) {
    	for (unsigned i = 0; i < SC_NSIZES; i++) {
    		const sc_t *sc = &sc_data->sc[i];
    		sz_index2size_tab[i] = (ZU(1) << sc->lg_base)
    		    + (ZU(sc->ndelta) << (sc->lg_delta));
    	}
    }
    数组索引(index) 存储的内存块大小(value)
    0 8
    1 16
    2 24
    3 32
    4 40
    5 48
    6 56
    7 64
    8 80
    9 96
    10 112
    11 128
    12 160
    13 192
    14 224
    ... ...
    233 6917529027641081856
    234 8070450532247928832

📑 例如:

  • 如果请求字节大小 size = 8,那么通过 sz_size2index_lookup() 计算得到的存储的 sz_index2size_tab 数组索引为 0 ,可得 sz_index2size_tab[0] 的值为 8。也就是说,如果请求字节大小为 8,那么 jemalloc 会为其分配 8 字节的连续内存块。
  • 如果请求字节大小 size = 9,那么通过 sz_size2index_lookup() 计算得到的存储的 sz_index2size_tab 数组索引为 1 ,可得 sz_index2size_tab[1] 的值为 16。也就是说,如果请求字节大小为 9,那么 jemalloc 会为其分配 16 字节的连续内存块。
🍀 选择合适的 bin

当应用程序请求分配某个大小的对象时,jemalloc 会计算出最接近且不小于该大小的类别索引,然后使用这个索引来访问 tcache_t 中对应的 cache_bin_t 进行分配。每个 cache_bin_t 实例专用于一个预定义的大小类别,从而实现了对多种不同大小内存块的支持。

也就是在源码中,有大概这样的逻辑:

c 复制代码
szind_t ind = sz_size2index(size); // 获取大小类别的索引
cache_bin_t *bin = &tcache->bins_small[ind]; // 获取对应的 bin,以 cache_bin_t	bins_small[39] 数组为例

tcache 是什么呢?

c 复制代码
typedef struct tcache_s tcache_t;

struct tcache_s {
	// ...

	/*
	 * 小对象的缓存 bin 数组
	 * 每个索引 i 位置的 bin 每次分配的内存块大小与 sz_index2size_tab 映射表中索引 i 位置存储的 jemalloc 应该分配的内存块大小相同
	 */
	cache_bin_t bins_small[SC_NBINS];

	/*
	 * 大对象的缓存 bin 数组
	 * 每个索引 i 位置的 bin 每次分配的内存块大小与 sz_index2size_tab 映射表中索引 i+SC_NBINS 位置存储的 jemalloc 应该分配的内存块大小相同
	 */
	cache_bin_t bins_large[SC_NSIZES-SC_NBINS];
};
🍀 实际内存块分配

首先需要了解 cache_bin_t 结构体:

c 复制代码
typedef struct cache_bin_s cache_bin_t;
typedef int32_t cache_bin_sz_t;

struct cache_bin_s {
	cache_bin_sz_t low_water;

	cache_bin_sz_t ncached;

	cache_bin_stats_t tstats;

	void **avail;
};
  • avail:这是一个二级指针,存储了一个指针数组的末端边界地址。指针数组是用于存储一组指向可用内存块的指针。指针数组可看做是一个栈结构,从栈顶 -> 栈底,对应指针数组的首地址 -> 末端边界地址(低地址 -> 高地址),avail 二级指针指向的地址即栈底。
  • ncached:记录当前 bin 中有多少个可用的内存块,每次成功分配时减一,回收时加一。它也是指针数组的元素个数,即可用内存块数量。

avail[-ncached, ..., -1] 是可用内存块的指针,其中最低地址的对象将最先被分配出去。也就是说,当进行内存分配时,ncached--,并将栈顶元素 *(avail - (ncached + 1)) 弹出。源码如下:

c 复制代码
/* 使用 bin 实例分配对应大小的内存块,返回分配的内存块的首地址 */
void *cache_bin_alloc_easy(cache_bin_t *bin, bool *success) {
	void *ret;

    /* 更新缓存计数器,因为我们准备分配一个块 */
	bin->ncached--;

	/* 检查 bin 中是否有可用的缓存块 */
	if (unlikely(bin->ncached <= bin->low_water)) {
		bin->low_water = bin->ncached;
		if (bin->ncached == -1) {
			bin->ncached = 0;
			*success = false;
			return NULL;
		}
	}

	/* 分配成功 */
	*success = true;
    
    /* 
	 * 从 bin 的 avail 栈顶弹出一个内存块地址。
	 * 注意这里的减法操作是因为 avail 指向的是栈底,而 ncached 表示栈中的元素数量。
	 * 这样可以确保我们总是从栈顶获取最新的可用块。
	 */
	ret = *(bin->avail - (bin->ncached + 1));

    /* 返回分配的内存块地址 */
	return ret;
}

set [key] [value]

set [key] [value] 命令对应的处理函数为 setCommand

以在命令执行前,db 中不存在该 key 为例,setCommand() 函数会调用到核心处理函数 dbAdd()

c 复制代码
/* 将 key-value 添加到 db 中 */
void dbAdd(redisDb *db, robj *key, robj *val) {
    // sds 是 char* 的别名,通过 sdsdup() 函数得到的实际是表示 key 的 sds 结构体 buf 字符数组首元素地址
    sds copy = sdsdup(key->ptr);
    
    // 1.在堆中开辟 dictEntry 结构体对象空间
    // 2.将 dictEntry 存储在 db 字典中
    // 3.将 key 字符数组的首元素地址存储在 dictEntry 结构体的 void *key 指针中
    // 4.返回 dictEntry 结构体指针
    dictEntry *de = dictAddRaw(db->dict, copy, NULL);
    
    serverAssertWithInfo(NULL, key, de != NULL);
    
    // 将 value 设置到 dictEntry 结构体对象 
    dictSetVal(db->dict, de, val);
    
    signalKeyAsReady(db, key, val->type);
    if (server.cluster_enabled) slotToKeyAddEntry(de, db);
    notifyKeyspaceEvent(NOTIFY_NEW,"new",key,db->id);
}

我们重点关注源码中以下三个函数的作用:

  • sdsdup(key->ptr):拷贝 sds,并返回字符数组的首元素地址。
  • dictAddRaw(db->dict, copy, NULL)
    1. 在堆中开辟 dictEntry 结构体对象空间;
    2. 将 dictEntry 存储在 db 字典中;
    3. 将 key 字符数组的首元素地址存储在 dictEntry 结构体的 void *key 指针中;
    4. 返回 dictEntry 结构体指针。
  • dictSetVal(db->dict, de, val):将 value 设置到 dictEntry 结构体对象
🍀 sdsdup() 函数

作用:拷贝 sds,并返回字符数组的首元素地址。

c 复制代码
sds sdsdup(const sds s) {
    /* sdslen(s) 返回字符串的有效长度 */
    return sdsnewlen(s, sdslen(s));
}
c 复制代码
sds sdsnewlen(const void *init, size_t initlen) {
    return _sdsnewlen(init, initlen, 0);
}
c 复制代码
sds _sdsnewlen(const void *init, size_t initlen, int trymalloc) {
    void *sh;
    sds s;
    
    /* 根据初始化长度获取至少应该使用的 sds 数据结构类型的标识 */
    char type = sdsReqType(initlen);
    
    /* 空字符串通常为拼接而创建的,因此使用 sdshdr8 作为 sds 数据结构比 sdshdr5 更加合适 */
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    
    /* 根据类型标识,获取对应类型的结构体(sdshdr5、sdshdr8、sdshdr16...)的占用字节大小 */
    /* 由于实际存储字符串的 char buf[] 是结构体最后一个成员,因此这是一个柔性数组,占用字节不会计算在使用 sizeof() 得到的结构体占用字节范围内 */
    int hdrlen = sdsHdrSize(type);
    
    /* 指向 sds 类型 ------ flags 变量的指针 */
    unsigned char *fp;
    size_t usable;

    /* 检查 size_t 溢出 */
    assert(initlen + hdrlen + 1 > initlen);
    
    /* 为 sds 数据结构分配堆内存,并将 jemalloc 实际分配的字节大小记录在 usable 中 */
    /* 请求字节大小为 hdrlen+initlen+1 = sds 结构体大小 + 字符串有效长度 + 结束字符 '\0' 的 1 个字节 */
    sh = trymalloc?
        s_trymalloc_usable(hdrlen+initlen+1, &usable) :
        s_malloc_usable(hdrlen+initlen+1, &usable);
    
    // ...
    
    /* 将预期字符数组的起始地址存储到 char* s 中 */
    s = (char*)sh+hdrlen;
    /* 将 flags 变量的地址存储到 unsigned char *fp 中 */
    fp = ((unsigned char*)s)-1;
    
    /* 获取柔性数组 char buf[] 可用字节大小 */
    /* usable = 总共分配的堆内存字节大小 - sizeof(sds 结构体) - 结束标识 '\0' 占 1 个字节 */ 
    usable = usable-hdrlen-1;
    if (usable > sdsTypeMaxSize(type))
        usable = sdsTypeMaxSize(type);
    
    /* 根据类型标识,对 sds 类型的实现数据类型结构体进行属性配置 */
    switch(type) {
        case SDS_TYPE_5: {
            *fp = type | (initlen << SDS_TYPE_BITS); /* 低 3 bit 表示 sds 类型,高 5 bit 表示字符串有效长度 */
            break;
        }
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s); /* SDS_HDR_VAR 是一个宏函数,作用是将 sh 指针指向 sds 结构体的起始地址,以操作结构体 */
            sh->len = initlen; /* 设置字符串有效长度 */
            sh->alloc = usable; /* 设置为 buf 字符数组分配了的字节大小 */
            *fp = type; /* 设置 sds 类型 */
            break;
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            sh->len = initlen;
            sh->alloc = usable;
            *fp = type;
            break;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            sh->len = initlen;
            sh->alloc = usable;
            *fp = type;
            break;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            sh->len = initlen;
            sh->alloc = usable;
            *fp = type;
            break;
        }
    }
    
    /* 字符数组拷贝 */
    if (initlen && init)
        memcpy(s, init, initlen);
    
    /* 为了兼容 c 标准字符串函数,以 '\0' 作为字符数组结束标识 */
    s[initlen] = '\0';
    
    /* 返回字符数组 buf 的起始地址 */
    return s;
}

对应的:

  • key = "aaaaaa",字节数 initlen 为 6,由于字节数 < 32,因此使用 sdshdr5 作为 key 字符串的数据结构,则 hdrlen = sizeof(struct sdshdr5) = 1,因此调用 s_malloc_usable() 函数时请求字节大小为 hdrlen+initlen+1 = 1+6+1 = 8,则 sz_size2index_lookup() 函数计算得到的存储的 sz_index2size_tab 数组索引为 0 ,而 sz_index2size_tab[0] 的值为 8 ,即实际分配内存块大小 *usable = 8。在配置 sdshdr5 实例属性时,设置 alloc = usable - hdrlen - 1 = 6

  • key = "aaaaaaa",字节数 initlen 为 7,由于字节数 < 32,因此使用 sdshdr5 作为 key 字符串的数据结构,则 hdrlen = sizeof(struct sdshdr5) = 1,因此调用 s_malloc_usable() 函数时请求字节大小为 hdrlen+initlen+1 = 1+7+1 = 9,则 sz_size2index_lookup() 函数计算得到的存储的 sz_index2size_tab 数组索引为 1 ,而 sz_index2size_tab[0] 的值为 16 ,即实际分配内存块大小 *usable = 16。在配置 sdshdr5 实例属性时,设置 alloc = usable - hdrlen - 1 = 14。也就是说,Redis 会将多分配的 7 字节作为字符数组 buf 的空间使用。

🍀 dictAddRaw() 函数

作用:

  1. 在堆中开辟 dictEntry 结构体对象空间;
  2. 将 dictEntry 存储在 db 字典中;
  3. 将 key 字符数组的首元素地址存储在 dictEntry 结构体的 void *key 指针中;
  4. 返回 dictEntry 结构体指针。
c 复制代码
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
    long index;
    dictEntry *entry;
    int htidx;

    if (dictIsRehashing(d)) _dictRehashStep(d);

    /* 计算 key 在 dict 哈希字典中的索引 */
    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
        return NULL;

    /* 由于可能的扩容,因此存在两个 dict,需要判断使用使用哪一个 */
    htidx = dictIsRehashing(d) ? 1 : 0;
    /* 字典元数据大小,在单机模式下,默认为 0 */
    size_t metasize = dictMetadataSize(d);
    /* 在堆中开辟 dictEntry 结构体对象空间 */
    entry = zmalloc(sizeof(*entry) + metasize);
    /* 如果有字典元数据,则将 (&entry)->metadata 的 metasize 个字节初始化为 0 */
    if (metasize > 0) {
        memset(dictMetadata(entry), 0, metasize);
    }
    /* 采用头插法,并以链表形式,将 dictEntry 存储到字典索引位置 */
    entry->next = d->ht_table[htidx][index];
    d->ht_table[htidx][index] = entry;
    /* key 计数 +1 */
    d->ht_used[htidx]++;

    /* dictSetKey 是一个宏,会替换为 entry->key = key 即将 key 字符数组的首元素地址存储在 dictEntry 结构体的 void *key 指针中 */
    dictSetKey(d, entry, key);
    /* 返回 dictEntry 结构体指针 */
    return entry;
}

对应的:

  • key = "aaaaaa",由于 sizeof(struct dictEntry) 为 24,因此 sz_size2index_lookup() 函数计算得到的存储的 sz_index2size_tab 数组索引为 2 ,而 sz_index2size_tab[2] 的值为 24,即实际分配内存块大小为 24。

  • key = "aaaaaaa",由于 sizeof(struct dictEntry) 为 24,因此 sz_size2index_lookup() 函数计算得到的存储的 sz_index2size_tab 数组索引为 2 ,而 sz_index2size_tab[2] 的值为 24,即实际分配内存块大小为 24。

🍀 dictSetVal() 函数

作用:将 value 设置到 dictEntry 结构体对象。这实际是一个宏函数,在预编译时期完成替换。

c 复制代码
#define dictSetVal(d, entry, _val_) do { \
    if ((d)->type->valDup) \
        (entry)->v.val = (d)->type->valDup((d), _val_); \
    else \
        (entry)->v.val = (_val_); \
} while(0)

这一步很简单,就是设置 dictEntry -> v.val 指针指向。但我们要重点关注的是 dictEntry -> v.val 指针或者说 robj *val 指针指向的结构体信息,因为这个结构体是 value 的实际内存存储与占用内容。

这里只看 value = "12345678" 的源码部分。由于 "12345678" 可用整型表示,为了节约内存,Redis 会使用 OBJ_ENCODING_INT 编码来进行优化。

c 复制代码
// 返回 value 对应的 redisObject 结构体的指针
robj *createStringObjectFromLongLongWithOptions(long long value, int valueobj) {
    robj *o;

    // ...

    if (value >= LONG_MIN && value <= LONG_MAX) {
        // 创建一个 type = OBJ_STRING 的 redisObject 结构体,sizeof(struct redisObject) 为 16 字节
        o = createObject(OBJ_STRING, NULL);
        // 设置编码为 OBJ_ENCODING_INT
        o->encoding = OBJ_ENCODING_INT;
        // 复用指针变量,节省内存,把 12345678 当做地址存储。在 get 时,会根据 encoding 再从 ptr 取出值
        o->ptr = (void*)((long)value);
    }

    // ...
    
    return o;
}

对应的:

  • key = "aaaaaa",value = "12345678",由于 sizeof(struct redisObject) 为 16,因此 sz_size2index_lookup() 函数计算得到的存储的 sz_index2size_tab 数组索引为 1 ,而 sz_index2size_tab[1] 的值为 16,即实际分配内存块大小为 16。

  • key = "aaaaaaa",value = "12345678",由于 sizeof(struct redisObject) 为 16,因此 sz_size2index_lookup() 函数计算得到的存储的 sz_index2size_tab 数组索引为 1 ,而 sz_index2size_tab[1] 的值为 16,即实际分配内存块大小为 16。

memory usage [key]

memory usage [key] 命令对应的处理函数为 memoryCommand

c 复制代码
void memoryCommand(client *c) {
    // ...

    // 1.计算 value 的字节数
    size_t usage = objectComputeSize(c->argv[2],dictGetVal(de),samples,c->db->id);
    // 2.计算 key 的字节数
    usage += sdsZmallocSize(dictGetKey(de));
    // 3.计算键值对结构体 dictEntry 的字节数
    usage += sizeof(dictEntry);
    // 4.计算所在 db 库的字典元数据的字节数
    usage += dictMetadataSize(c->db->dict);

    // ...
}

usage 变量即是用于存储键值对的内存使用字节数。可以看到,共有四个部分组成:

  1. objectComputeSize(c->argv[2],dictGetVal(de),samples,c->db->id):计算 value 的字节数。
  2. sdsZmallocSize(dictGetKey(de)):计算 key 的字节数。
  3. sizeof(dictEntry):计算键值对结构体 dictEntry 的字节数。
  4. dictMetadataSize(c->db->dict):计算所在 db 库的字典元数据的字节数
🍀 计算 value 的字节数
c 复制代码
size_t objectComputeSize(robj *key, robj *o, size_t sample_size, int dbid) {
    sds ele, ele2;
    dict *d;
    dictIterator *di;
    struct dictEntry *de;
    size_t asize = 0, elesize = 0, samples = 0;

    if (o->type == OBJ_STRING) {
        if(o->encoding == OBJ_ENCODING_INT) { // 执行第 1 个 if 中的语句
            // sizeof(struct redisObject) = 16 bytes
            asize = sizeof(*o);
        } else if(o->encoding == OBJ_ENCODING_RAW) {
            asize = sdsZmallocSize(o->ptr)+sizeof(*o);
        } else if(o->encoding == OBJ_ENCODING_EMBSTR) {
            asize = zmalloc_size((void *)o);
        } else {
            serverPanic("Unknown string encoding");
        }
    } else if (o->type == OBJ_LIST) {
        // ...
    } else if (o->type == OBJ_SET) {
        // ...
    } else if (o->type == OBJ_ZSET) {
        // ...
    } else if (o->type == OBJ_HASH) {
        // ...
    } else if (o->type == OBJ_STREAM) {
        // ...
    } else if (o->type == OBJ_MODULE) {
        // ...
    } else {
        serverPanic("Unknown object type");
    }
    
    return asize;
}

对应的:

  • key = "aaaaaa",value = "12345678",存储时 redisObject.type = OBJ_STRINGredisObject.encoding = OBJ_ENCODING_INT,则 objectComputeSize() 函数返回结果为 sizeof(struct redisObject) 即 16。
  • key = "aaaaaaa",value = "12345678",存储时 redisObject.type = OBJ_STRINGredisObject.encoding = OBJ_ENCODING_INT,则 objectComputeSize() 函数返回结果为 sizeof(struct redisObject) 即 16。
🍀 计算 key 的字节数
c 复制代码
size_t sdsZmallocSize(sds s) {
    // sds s 是 sds 结构体的 char buf[] 数组首元素地址,这里根据 s 获取 sds 结构体首地址
    void *sh = sdsAllocPtr(s);
    // jemalloc 根据首地址获取分配的连续内存块字节大小
    return zmalloc_size(sh);
}

void *sdsAllocPtr(sds s) {
    // s 为 char buf[] 首元素地址
    // s[-1] 获取 type 成员地址,sdsHdrSize(s[-1]) 则是根据 type 获取 sds 结构体占用字节
    // 两者相减,就可以得到 sds 结构体首元素地址了
    return (void*) (s-sdsHdrSize(s[-1]));
}

对应的:

  • key = "aaaaaa",通过之前对 sdsdup() 函数的分析,可得 jemalloc 实际为 key 分配了 8 字节的连续内存。
  • key = "aaaaaaa",通过之前对 sdsdup() 函数的分析,可得 jemalloc 实际为 key 分配了 16 字节的连续内存。
🍀 计算键值对结构体 dictEntry 的字节数
c 复制代码
typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
    void *metadata[];
} dictEntry;

占用字节分析:

  • *void key:8 字节。
  • union v:联合体,8 字节。
  • *struct dictEntry next:8 字节。
  • void *metadata[] :柔性数组,不参与 sizeof(struct dictEntry) 计算。

综上,sizeof(struct dictEntry) 的结果为 24 字节。

🍀 计算所在 db 库的字典元数据的字节数
c 复制代码
/*
 * 返回 db 字典条目元数据的大小(以字节为单位)。
 * 在集群模式下,元数据用于构造属于同一集群槽的 dict 条目的双向链表。 */
size_t dictEntryMetadataSize(dict *d) {
    UNUSED(d);
    return server.cluster_enabled ? sizeof(clusterDictEntryMetadata) : 0;
}

在单机环境下,默认该函数的返回值为 0。

🍀 小结

综上对每个函数的分析,以及 set 时的具体实现,我们得出:

类型 set aaaaaa 12345678 set aaaaaaa 12345678
计算 value 的字节数 16 16
计算 key 的字节数 8 16
计算键值对结构体 dictEntry 的字节数 24 24
计算所在 db 库的字典元数据的字节数 0 0
字节总和 48 56

总结

造成差异的原因

通过上面对源码的分析,其实我们就可以知道 memory usage [key] 分析得到的内存使用情况为什么会有差异了。

首先需要说明的是,Redis 6.0 与 Redis 7.0 都为 key = "aaaaaaa" 都请求了 9 字节的内存字节大小,但 jemalloc 实际都分配了 16 字节的连续内存块,但是对于多出来的 7 字节却持有不同的态度。

  • Redis 6.0 中,不会 将多分配的 7 字节作为 sds 结构体中的字符数组 buf 的空间使用,即会设置成员 alloc = initlen = 7
  • Redis 7.0 中, 将多分配的 7 字节作为 sds 结构体中的字符数组 buf 的空间使用,即会设置成员 alloc = usable - hdrlen - 1 = 14

对应的在使用 memory usage [key] 计算内存占用时:

  • Redis 6.0 中,key 的字节数 = sdsHdrSize(s[-1]) + alloc + 1 = sds 结构体占用字节 + 为字符数组 buf 开辟了的内存大小 + 结束字符 '\0' 1 个字节 ,即 9 个字节。
  • Redis 7.0 中,key 的字节数 = jemalloc 为 key 实际分配的连续内存块大小,即 16 个字节。

从这里我们可以看出,Redis 7.0 相较于 Redis 6.0,对于 jemalloc 实际分配的额外内存空间,进行了优化利用。

memory usage [key] 计算内存使用小结

Redis 6.0:

类型 set aaaaaa 12345678 set aaaaaaa 12345678
计算 value 的字节数 16 16
计算 key 的字节数 8 9
计算键值对结构体 dictEntry 的字节数 24 24
字节总和 48 49

Redis 7.0:

类型 set aaaaaa 12345678 set aaaaaaa 12345678
计算 value 的字节数 16 16
计算 key 的字节数 8 16
计算键值对结构体 dictEntry 的字节数 24 24
计算所在 db 库的字典元数据的字节数 0 0
字节总和 48 56

感悟

最后,通过本文对源码的分析,我们可以认识到:

  1. Redis 使用 jemalloc 作为默认的内存分配器,这使得它能够更有效地管理内存分配。jemalloc 会根据请求的大小选择最合适的内存块,从而减少内部碎片并提高分配效率。
  2. 对于简单的数值型字符串,如果它们可以被表示为长整数(long),Redis 会选择使用 OBJ_ENCODING_INT 编码来节省空间。这种方式不仅减少了内存占用,而且加快了数据访问速度。
  3. 在设计数据结构时,考虑到字节对齐规则,以确保最佳性能,在本文分析中,在计算字节时并没有提到结构体字节对齐,这是因为 Redis 对数据结构的巧妙设计使得无需进行字节填充。此外,柔性数组用于 sds 结构体中,允许动态增长字符缓冲区而不增加额外的指针开销。
相关推荐
阿落ovo4 分钟前
访问控制列表ACL
java·运维·服务器·网络·数据库·华为·智能路由器
凡人的AI工具箱1 小时前
每天40分玩转Django:Django中间件
开发语言·数据库·后端·python·中间件·django
听说唐僧不吃肉1 小时前
Redis篇之Redis高可用模式参数调优,提高Redis性能
redis
G丶AEOM1 小时前
Redis内存淘汰策略有哪些
java·redis
编码浪子2 小时前
构建一个rust生产应用读书笔记6-拒绝无效订阅者01
数据库·oracle·rust
东阳马生架构2 小时前
Redis应用—6.热key探测设计与实践
redis
幽兰的天空2 小时前
MySQL面试题大全及答案解析(进阶篇)
数据库·mysql
Lian_Aseubel2 小时前
更新数据时Redis的操作
数据库·redis·缓存
编程、小哥哥3 小时前
Redis 缓存、加锁(独占/分段)、发布/订阅,常用特性的使用和高级编码操作
数据库·redis·缓存