乐趣区

Redis-字符串对象

Redis 字符串对象

字符串对象的编码可以是 int、raw 或者 embstr.

如果一个字符串对象保存的是整数值, 并且这个整数值可以用 long 类型来表示, 那么字符串对象会将整数值保存在字符串对象结构的 ptr 属性里面 (将 void* 转换成 long), 并将字符串对象的编码设置为 int.

int

举个例子, 如果我们执行以下 SET 命令, 那么服务器将创建一个如图所示的 int 编码的字符串对象作为 number 键的值:

redis> SET number 10086
OK

redis> OBJECT ENCODING number
"int"

值得注意的是:
long 类型表示的是 C 语言中的 8 个字节的长整型.
编码 int 则指的是 Redis 中的 REDIS_ENCODING_INT 编码.

在 redis redisObject 数据结构 中有说过, ptr 是一个指针, 指向实际保存值的数据结构, 这个数据结构由 type 属性和 encoding 属性决定的.

我的系统是 Ubuntu 16.04 64 位, 用的是 Redis 3.0.7 版本, 可以存下 -9223372036854775808~9223372036854775807 范围的值. 也就是说, 这个范围内的值都会被编码为 int.


raw

如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度大于 39 字节, 那么字符串对象将使用一个简单动态字符串 (SDS) 来保存这个字符串值, 并将对象的编码设置为 raw

举个例子, 如果我们执行以下命令, 那么服务器将创建一个如图所示的 raw 编码的字符串对象作为 story 键的值.

redis> SET story "Long, long, long ago there lived a king ..."
OK

redis> STRLEN story
(integer) 43

redis> OBJECT ENCODING story
"raw"


embstr

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

embstr 编码是专门用于保存短字符串的一种优化编码方式, 这种编码和 raw 编码一样, 都使用 redisObject 结构和 sdshdr 结构来表示字符串对象, 但 raw 编码会调用两次内存分配函数来分别创建 redisObject 结构和 sdshdr 结构.

embstr 编码则通过调用一次内存分配函数来分配一块连续的空间, 空间中依次包含 redisObjectsdshdr 两个结构.

使用 embstr 编码的字符串对象来保存短字符串值有以下好处:

  • embstr 编码将创建字符串对象所需的内存分配次数从 raw 编码的两次降低为一次.
  • 释放 embstr 编码的字符串对象只需要调用一次内存释放函数, 而释放 raw 编码的字符串对象需要调用两次内存释放函数.
  • 因为 embstr 编码的字符串对象的所有数据都保存在一块连续的内存里面, 所以这种编码的字符串对象比起 raw 编码的字符串对象能够更好地利用缓存带来的优势.

那么为什么小于等于 39 字节才使用 embstr 编码呢?

首先 embstr 是一块连续的内存区域, 由 redisObjectsdshdr 组成.

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

通过下面这种方式可以知道 redisObject 是占 16 个字节.

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:24; /* lru time (relative to server.lruclock) */
    int refcount;
    void *ptr;
} robj;

sizeof(redisObject);

下面这个代码是告诉你为啥是 48 个字节.

struct sdshdr {
    unsigned int len; // 字符串长度
    unsigned int free; // 剩余可使用的字节数
    char buf[];};

unsigned int 占 4 个字节, 两个那么当然是 8 个字节了.
buf 就是内容, 例子是 39 字节.
最后的 +1 指的是 '\0', 字符串可以用一个 '\0' 结尾的 char 数组来表示.

值得注意的是:
Redis 5.0.5 中, 39 变成了 44 字节.
这是因为使用的 unsigned int 可以表示很大的范围, 但是对于很短的 sds 有很多的空间被浪费了 (两个 unsigned int 8 个字节 ).
而将原来的 sdshdr 改成了 sdshdr8, sdshdr16, sdshdr32, sdshdr64, 里面的 unsigned int 变成了 uint8_t, uint16_t. 这样更加优化小 sds 的内存使用.


sds

与 sds 实现有关的数据类型有两个, 一个是 sds:

// 字符串类型的别名
typedef char *sds;

另一个是 sdshdr:

// 持有 sds 的结构
struct sdshdr {
    // buf 中已被使用的字符串空间数量
    int len;
    // buf 中预留字符串空间数量
    int free;
    // 实际储存字符串的地方
    char buf[];};

sds 只是字符数组类型 char* 的别名, 而 sdshdr 则用于持有和保存 sds 的信息.


如何追加字符串的?

// 扩展 sds 的预留空间,确保在调用这个函数之后,// sds 字符串后的 addlen + 1 bytes(for NULL)可写
sds sdsMakeRoomFor(sds s, size_t addlen) {
    struct sdshdr *sh, *newsh;
    size_t free = sdsavail(s);
    size_t len, newlen;

    // 预留空间可以满足本次拼接
    if (free >= addlen) return s;

    len = sdslen(s);
    sh = (void*) (s-(sizeof(struct sdshdr)));

    // 设置新 sds 的字符串长度
    // 这个长度比完成本次拼接实际所需的长度要大
    // 通过预留空间优化下次拼接操作
    newlen = (len+addlen);
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;

    // 重分配 sdshdr
    newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);
    if (newsh == NULL) return NULL;

    newsh->free = newlen - len;

    // 只返回字符串部分
    return newsh->buf;
}

newlen 变量的设置可以看出, 如果 newlen 小于 SDS_MAX_PREALLOC, 那么 newlen 的实际值会比所需的长度多出一倍.

如果 newlen 的值大于 SDS_MAX_PREALLOC, 那么 newlen 的实际值会加上 SDS_MAX_PREALLOC (目前 3.0.7 版本的 SDS_MAX_PREALLOC 默认值为 1024 * 1024).

这种内存分配策略表明, 在对 sds 值进行扩展(expand)时, 总会预留额外的空间, 通过花费更多的内存, 减少了对内存进行重分配(reallocate)的次数, 并优化下次扩展操作的处理速度.

优化扩展操作的一个例子就是 APPEND 命令: APPEND 命令在执行时会调用 sdsMakeRoomFor, 多预留一部分空间. 当下次再执行 APPEND 的时候, 如果要拼接的字符串长度 addlen 不超过 sdshdr.free (上次 APPEND 时预留的空间 ), 那么就可以略过内存重分配操作, 直接进行字符串拼接操作.

相反, 如果不使用这种策略, 那么每次进行 APPEND 都要对内存进行重分配.

注意, 初次创建 sds 值时并不会预留多余的空间 (查看前面给出的 sdsnewlen 定义), 只有在调用 sdsMakeRoomFor 起码一次之后, sds 才会有预留空间, 而且 sds 模块中也有相应的紧缩空间函数 sdsRemoveFreeSpace.

因此, Redis 对 sds 值的这种扩展策略实际上不会浪费多少内存, 但它对一些需要多次执行字符串拼接的 Redis 模式来说, 却会获得不错的优化效果 (因为频繁的内存重分配是一种比较昂贵的工作).

sds VS c 语言字符串

退出移动版