基于Redis3.0与6.0版本源码看SDS内存优化

实践中引发的思考

最近在看《Redis 设计与实现》这本书,不由得赞叹 Redis 底层数据结构设计的精妙。在看到 Redis 对象章节时,我们知道 Redis 是使用对象来表示数据库中的键和值的,其中键总是字符串对象,而字符串对象的编码又可以是 intraw 或者 embstr

关键点来了,书中对字符串对象是这么写的:

如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于 39 字节,那么字符串对象将使用 embstr 编码的方式来保存这个字符串值。

看到这,我马上实践了一波,结果直接懵了。。。

在这里插入图片描述

当我创建了一个长度为 39 的字符串,编码为 embstr,这没有什么问题,但长度变为 41 的时候,此时编码应该转换为 raw 才对,然而并没有!

我的第一直觉便觉得应该就是版本问题,因为《Redis 设计与实现》是基于 Redis 3.0 版本的,而我之前专门看过 Redis 3.0 和 6.0 版本的 SDS 源码,已经知道 Redis 在 3.2 版本的时候对 SDS 进行了内存优化,很可能是因为这个原因导致编码转换的边界值发生改变。

上网一搜,看到这位博主跟我遇到了一样的问题:Redis的embstr与raw编码方式不再以39字节为界了!,发现原因确实是这样的。只不过博主并没有将不同版本的源码来对比说明,这里便通过源码加以佐证。


Redis 3.2 以前的 SDS 实现

每个 sds.h / sdshdr 结构表示一个 SDS 值,在 Redis 3.2 版本以前,SDS 的结构如下:

struct sdshdr {
    // 记录 buf 数组中已使用字节的数量
    // 等于 SDS 所保存字符串的长度
    unsigned int len;
    
    // 记录 buf 数组中剩余可用字节数量
    unsigned int free;
    
    // 字节数组,用于保存字符串
    char buf[];
};

想深入了解 SDS 的读者可以看我之前写的文章:Redis设计与实现——简单动态字符串SDS

我们可以看到,字段 lenfree 都是 unsigned int 类型,各占 4 字节,紧接着存放字符串。

但其实以上的设计并不是最优,4 字节的 len,可表示的字符串长度为 2^32-1,在实际应用中,存放于 Redis 中的字符串往往没有这么长,没有必要每个字符串都让 len 和 free 为 4 字节。


Redis 6.0版本 SDS 实现

在 Redis 3.2 版本之后,Redis 将 SDS 划分为 5 种类型:

  • sdshdr5:长度小于 1 字节
  • sdshdr8:长度 1 字节
  • sdshdr16:长度 2 字节
  • sdshdr32:长度 4 字节
  • sdshdr64:长度 8 字节

优化:根据字符串的长度,使用不同的数据结构进行存储

Redis 增加了一个 flags 字段来标识类型,用一个字节 (8 位) 来存储,在 sdshdr5 中:前 3 位表示字符串的类型;剩余 5 位,可以用来存储数组长度 len (5 bit) 。

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 前3位存储类型,后5位存储长度 */
    char buf[]; /* 存放字符串 */
};

而对于长度大于 31 的字符串,仅仅靠 flags 的后 5 位来存储长度明显是不够的,需要用另外的变量来存储。sdshdr8、sdshdr16、sdshdr32、sdshdr64 的数据结构定义如下:其中 len 表示已使用的长度,alloc 表示总长度,alloc - len 其实就是之前版本的 free 字段,buf 存储实际字符串内容,而 flags 的前 3 位依然存储类型,后 5 位则预留

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;   /* 已使用长度,1字节 */
    uint8_t alloc; /* 总长度,1字节 */
    unsigned char flags; /* 前3位存储类型,后5位预留 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len;   /* 已使用长度,2字节 */
    uint16_t alloc; /* 总长度,2字节 */
    unsigned char flags; /* 前3位存储类型,后5位预留 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len;   /* 已使用长度,4字节 */
    uint32_t alloc; /* 总长度,4字节 */
    unsigned char flags; /* 前3位存储类型,后5位预留 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len;   /* 已使用长度,8字节 */
    uint64_t alloc; /* 总长度,8字节 */
    unsigned char flags; /* 前3位存储类型,后5位预留 */
    char buf[];
};

关键结论

看到这里可以先总结一下结论:3.2版本之后 embstr 与 raw 编码的分界不再是 39,而是 44


为什么之前是39?

REDIS_ENCODING_EMBSTR_SIZE_LIMIT set to 39.
The new value is the limit for the robj + SDS header + string + null-term to stay inside the 64 bytes Jemalloc arena in 64 bits systems.

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:REDIS_LRU_BITS;
    int refcount;
    void *ptr;
} robj;
struct sdshdr {
    unsigned int len;
    unsigned int free;
    char buf[];
};

embstr 编码的字符串是一块连续的内存区域,由 redisObject 和 sdshdr 组成。

在这里插入图片描述

其中 redisObject 占 16 个字节,当 buf 数组内的字符串长度是 39 时,sdshdr 的大小为 4+4+39+1=48,那一个字节是 ‘\0’,加起来刚好 64

从2.4版本开始,Redis 开始使用 jemalloc 内存分配器。这个比 glibc 的 malloc 要好不少,还省内存。在这里可以简单理解,jemalloc 会分配8,16,32,64等字节的内存。

无论是DictEntry对象,还是RedisObject、SDS对象,都需要内存分配器(如jemalloc)分配内存进行存储。当Redis存储数据时,会选择大小最合适的内存块进行存储。

embstr 最小为 16+4+4+1=25,而分配 32 字节的话 buf 只有 7 字节存储,长度太小了,所以实际会分配64字节,即 Redis 认为如果超过 64 字节就是大字符串。当字符数小于 39 时,都会分配 64 字节。


为什么现在是44?

主要是因为 3.2 版本对 SDS 数据结构进行了内存优化

本身就是针对短字符串的 embstr 自然会使用最小的 sdshdr8,而 sdshdr8 与之前的 sdshdr 相比正好减少了5个字节。为什么呢,这里计算一下除去 buf 数组以外变量所占空间:

  • sdsdr8 (len + alloc + flags) = uint8_t * 2 + char = 1*2 + 1 = 3
  • sdshdr (len + free) = unsigned int * 2 = 4 * 2 = 8

所以 buf 数组能容纳的字符串长度增加了 5 个字节,变成了 44

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;   /* 已使用长度,8bit = 1字节 */
    uint8_t alloc; /* 总长度,1字节 */
    unsigned char flags; /* 前3位存储类型,后5位预留 */
    char buf[];
};

进行验证

在这里插入图片描述

可以看到,当字符串长度达到大于 44 时,字符串对象就会从 embstr 编码转换为 raw


从发现问题再到深入底层探索细节,最后得出结论,整个历程都让我受益匪浅。一个小小的实践却让我深入探究了 SDS 的底层细节,也加深了自己对其的理解,不枉费自己在图书馆的一个下午哈哈。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值