字符串是Redis五种基本数据类型中的基础。同时也是我们在学习编程中接触最多的一种数据类型。本文将从使用、源码、编码三个部分讲解此数据类型在Redis中的使用。

字符串

      string是Redis中最简单的数据结构。Redis中所有的数据结构都是以唯一的key字符串作为名称,根据此key获取value,差异仅在于value的数据结构不同。string使用非常广泛,最常见的就是存储用户信息(json串),再通过相关序列化工具转为对应的实体对象。
      在本文的第一部分不介绍string的底层实现,先来看看如何使用吧!完整string命令列表查看:string commands

设置字符串
格式:set <key> <value>。其中value的值可以为字节串(byte string)、整型和浮点数。

> set name zhangsan
OK

获取字符串
格式:get <key>

> get name
"zhangsan"

获取字符串长度
格式:strlen <key>

> strlen name
(integer) 8

获取子串
格式:getrange <key> start end
      获取字符串的子串,在Redis2.0之前此命令为substr,现使用getrange。返回位移为start(从0开始)和end之间(都包括,而不是像其他语言中的包头不包尾)的子串。可以使用负偏移量来提供从字符串末尾开始的偏移量。因此-1表示最后一个字符,-2表示倒数第二个,依此类推。该函数通过将结果范围限制为字符串的实际长度来处理超出范围的请求(end设置非常大也是到字符串末尾就截止了)。

127.0.0.1:6379> set mykey "This is a string"
OK
127.0.0.1:6379> getrange mykey 0 3
"This"
127.0.0.1:6379> getrange mykey -3 -1
"ing"
127.0.0.1:6379> getrange mykey 0 -1
"This is a string"
127.0.0.1:6379> getrange mykey 10 10000
"string"

设置子串
格式:setrange <key> offset substr
返回值:修改后字符串的长度。

      从value的整个长度开始,从指定的偏移量覆盖key处存储的一部分字符串。如果偏移量大于key处字符串的当前长度,则该字符串将填充零字节以使偏移量适合。不存在的键被视为空字符串,因此此命令将确保它包含足够大的字符串以能够将值设置为offset。
      注意:您可以设置的最大偏移为229 -1(536870911),因为Redis字符串限制为512 MB。如果您需要超出此大小,可以使用多个键。

127.0.0.1:6379> set key1 "hello world"
OK
127.0.0.1:6379> setrange key1 6 redis
(integer) 11
127.0.0.1:6379> get key1
"hello redis"
127.0.0.1:6379> setrange key2 6 redis
(integer) 11
127.0.0.1:6379> get key2
"\x00\x00\x00\x00\x00\x00redis"

追加子串
格式:append <key> substr
      如果key已经存在并且是字符串,则此命令将value在字符串末尾附加。如果key不存在,则会创建它并将其设置为空字符串,因此APPEND在这种特殊情况下 将类似于SET。

127.0.0.1:6379> exists key4
(integer) 0
127.0.0.1:6379> append key4 hello
(integer) 5
127.0.0.1:6379> append key4 world
(integer) 10
127.0.0.1:6379> get key4
"helloworld"

计数
      在使用Redis中我们经常将字符串做为计数器,使用incr命令进行加一。
格式:incr <key>
返回值:key递增后的值。
      将存储的数字key加1。如果key不存在,则在执行操作之前将其设置为0。如果key包含错误类型的值或包含不能表示为整数的字符串,则返回错误。此操作仅限于64位带符号整数。计数是由范围的,它不能超过Long.Max,不能低于Long.Min。
过期和删除
      字符串可以使用del命令进行删除,也可以使用expire命令设置过期时间,到期自动删除。我们可以使用ttl命令获取字符串的寿命(还有多少时间过期)。

格式:del <key1> <key2> ...
返回值:删除key的个数

127.0.0.1:6379> SET key1 "Hello"
"OK"
127.0.0.1:6379> SET key2 "World"
"OK"
127.0.0.1:6379> DEL key1 key2 key3
(integer) 2 

格式:expire <key> time
返回值:如果设置了超时返回1。如果key不存在返回0。
如何将设置了过期的字符串设置为永久的呢?
      生存时间可以通过使用DEL命令来删除整个 key 来移除,或者被SETGETSET命令覆写(overwrite),这意味着,如果一个命令只是修改一个带生存时间的 key 的值而不是用一个新的 key 值来代替(replace)它的话,那么生存时间不会被改变。比如说,对一个 key 执行INCR命令,对一个列表进行LPUSH命令,或者对一个哈希表执行HSET命令,这类操作都不会修改 key 本身的生存时间。
      如果使用RENAME对一个 key 进行改名,那么改名后的 key 的生存时间和改名前一样。RENAME 命令的另一种可能是,尝试将一个带生存时间的 key 改名成另一个带生存时间的 another_key ,这时旧的 another_key (以及它的生存时间)会被删除,然后旧的 key 会改名为 another_key ,因此,新的 another_key 的生存时间也和原本的 key 一样。
      使用PERSIST命令可以在不删除 key 的情况下,移除 key 的生存时间,让 key 重新成为一个『持久的』(persistent) key 。

127.0.0.1:6379> expire age 100
(integer) 1
127.0.0.1:6379> ttl age
(integer) 97
127.0.0.1:6379> set age 20
OK
127.0.0.1:6379> ttl age
(integer) -1
127.0.0.1:6379> expire age 100
(integer) 1
127.0.0.1:6379> ttl age
(integer) 98
127.0.0.1:6379> rename age age2
OK
127.0.0.1:6379> ttl age2
(integer) 87
127.0.0.1:6379> expire age 100
(integer) 1
127.0.0.1:6379> ttl age
(integer) 96
127.0.0.1:6379> persist age
(integer) 1
127.0.0.1:6379> ttl age
(integer) -1

源码分析

      string在Redis的底层实现中到底是什么呢?
      Redis没有直接使用C语言传统的字符串表示(以空字符0结尾的字符数组),而是自己构建了一种名为简单动态字符串(Simple Dynamic String,SDS)的类型,并将SDS作为Redis的默认字符串表示。

SDS定义

      通过查看sds.h/sdshdr结构表示一个SDS:

struct sdshdr {
    //记录buf数组中已使用字节的数量
    //等于SDS所保存字符串的长度
    unsigned int len;

    //记录buf数组中未使用字节的数量
    unsigned int free;

    //字节数组,用于保存字符串
    char buf[];
};

请输入图片描述

如图所示展示了一个SDS示例:

  • free属性的值为0,表示这个SDS没有分配任何未使用空间(没有空闲空间);
  • len属性的值为7,表示这个SDS保存了一个七字节长的字符串;
  • buf属性是一个char类型的数组,数组的前7个字符分别保存了'C','a','t','w','i','n','g'这七个字符,最后一个字符则保存了空字符'0'.

      SDS遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性内,并且为空字符分配额外的1字节空间以及添加空格字符到字符串末尾等操作都是SDS函数自动完成的,对用户是透明的。

sds sdsnewlen(const void *init, size_t initlen) {
    struct sdshdr *sh;
    //在分配空间时多分配了1字节空间
    if (init) {
        sh = zmalloc(sizeof(struct sdshdr)+initlen+1);
    } else {
        sh = zcalloc(sizeof(struct sdshdr)+initlen+1);
    }
    if (sh == NULL) return NULL;
    sh->len = (int)initlen;                                                    
    sh->free = 0;
    if (initlen && init)
        memcpy(sh->buf, init, initlen);
    //将结尾字符设置为\0
    sh->buf[initlen] = '\0';
    return (char*)sh->buf;
}

SDS优势

      为什么Redis中需要使用SDS封装字符串,直接一个char数组不香吗?

O(1)获取字符串长度

      由于C字符串不记录自身的长度,所以为了获取一个字符串的长度程序必须遍历这个字符串,直至遇到'0'为止,整个操作的时间复杂度为O(N)。而我们使用SDS封装字符串则直接获取len属性值即可,时间复杂度为O(1)。

杜绝缓冲区溢出

      在C语言开发中使用char *strcat(char *dest,const char *src)将src字符串中的内容拼接到dest字符串的末尾。由于C字符串不记录自身的长度,所有strcat假定用户在执行此函数时已经为dest分配了足够多的内存,可以容纳src字符串中的所有内容,而一旦这个假设不成立就会产生缓冲区溢出。
请输入图片描述

      与C字符串不同,SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS API需要对SDS进行修改是,API会先检查SDS的空间是否满足修改所需的要求,如果不满足,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现缓冲区溢出问题。SDS中也有类似strcat的方法,对值为"Redis"的SDS执行sdscat(s," good")后SDS如下所示:
请输入图片描述

      仔细观察可以发现free属性值由原来的0变成了12,这个值和len的值相等,这是巧合吗?当然不是,这和扩容机制有关。查看源码我们会发现最终分配空间使用sdsMakeRoomFor方法,如下所示:

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)));
    newlen = (len+addlen);
    //SDS_MAX_PREALLOC (1024*1024)
    //当字符串长度小于1M时扩容都是加倍扩容(*2)
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        //当字符串长度大于等于1M时,每次扩容只会扩容1M的空间
        newlen += SDS_MAX_PREALLOC;
    newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);
    if (newsh == NULL) return NULL;

    newsh->free = (int)(newlen - len);
    return newsh->buf;
}

减少修改字符串时的内存重分配次数

      借助SDS的free空间SDS实现了空间预分配和惰性空间释放两种优化策略。

空间预分配

      借助空间预分配策略SDS字符串在增长过程中不会频繁的进行空间分配。是否需要扩容,扩容多大在上一小节中已经介绍了。通过这种分配策略,SDS将连续增长N次字符串所需的内存冲分配次数从必定N次降低为最多N次。

惰性空间释放

      空间预分配策略用于优化SDS字符串增长,而惰性空间释放则用于优化SDS字符串缩短。当SDS的API需要缩短SDS保存的字符串是,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。SDS中使用sdstrim缩短字符串。
请输入图片描述

      主要SDS并没有释放多出来的5字节空间,而是将这5字节空间作为未使用空间保留在了SDS里面,如果后续字符串增长则可以派上用场(可能不需要重分配)。也许你又会有疑问了,这没真正释放空间,是否会导致内存泄漏呢?放心SDS为我们提供了真正释放SDS未使用空间的方法sdsRemoveFreeSpace

二进制安全

什么是二进制安全?通俗地讲,C语言中,用'0'表示字符串的结束,如果字符串本身就有'0'字符,字符串就会被截断,即非二进制安全;若通过某种机制,保证读写字符串时不损害其内容,则是二进制安全。

      C字符串中的字符除了末尾字符为'0'外其他字符不能为空字符,否则会被认为是字符串结尾(即使实际上不是)。这限制了C字符串只能保存文本数据,而不能保存二进制数据。而SDS使用len属性的值判断字符串是否结束,所以不会受'0'的影响。

兼容部分C字符串函数

      SDS字符串一样遵循C字符串以空字符结尾的惯例,这是为了让那些保存文本数据的SDS可以重用部分<string.h>库定义的函数。

编码

      Redis中的每个对象都由一个redisObject结构表示:

typedef struct redisObject {
    // 刚刚好32 bits
    // 对象的类型,字符串/列表/集合/哈希表
    unsigned type:4;
    // 未使用的两个位
    unsigned notused:2; /* Not used */
    // 编码的方式,Redis 为了节省空间,提供多种方式来保存一个数据
    // 譬如:“123456789” 会被存储为整数123456789
    unsigned encoding:4;
    // 当内存紧张,淘汰数据的时候用到
    unsigned lru:22; /* lru time (relative to server.lruclock) */
    // 引用计数
    int refcount;
    // 数据指针,8bytes
    void *ptr;
} robj;

      对象的type属性记录了对象的类型,这个属性的值可以是下表中的其中一个:

类型常量 对象名称 TYPE命令输出
字符串对象 REDIS_STRING string
列表对象 REDIS_LIST list
哈希对象 REDIS_HASH hash
集合对象 REDIS_SET set
有序集合对象 REDIS_ZSET zset

      对象的encoding属性记录了对象所使用的编码,这个属性的值可以是下表中的其中一个:

编码常量 编码所对应的底层数据结构
REDIS_ENCODING_INT long类型的整数
REDIS_ENCODING_EMBSTR embstr编码的简单动态字符串
REDIS_ENCODING_RAW 简单动态字符串
REDIS_ENCODING_HT 字典
REDIS_ENCODING_LINKEDLIST 双端链表
REDIS_ENCODING_ZIPLIST 压缩链表
REDIS_ENCODING_INTSET 整数集合
REDIS_ENCODING_SKIPLIST 跳跃表和字典

      字符串对象的编码可以是int、raw或者embstr。
      需要注意的是,在字符串长度小于等于44时使用embstr编码存储,超过44时使用raw编码存储。(Redis3.2之前是39)

127.0.0.1:6379> set str1 12345678901234567890123456789012345678901234
OK
127.0.0.1:6379> strlen str1
(integer) 44
127.0.0.1:6379> object encoding str1
"embstr"
127.0.0.1:6379> set str2 123456789012345678901234567890123456789012345
OK
127.0.0.1:6379> strlen str2
(integer) 45
127.0.0.1:6379> object encoding str2
"raw"

embstr vs raw

      embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象,但raw对象会调用两次内存分配函数来创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中一次包含redisObject和sdshdr两个结构:
请输入图片描述

      embstr编码的字符串在执行命令时产生的效果和raw编码的字符串对象执行命令时产生的效果是相同的,但使用embstr编码的字符串对象来保存短字符串值由以下好处:

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

44?39?

      也许你会疑惑为什么是长度超过44后编码变为raw?这就需要我们回到redisObject和sdshdr的结构上了。不难发现redisObject对象头最少需要占据16字节的空间,SDS对象头的大小至少为3(SDS结构在后续多了一个int8 flags)。意味着分配一个字符串的最小空间占用为19个字节。
      Redis内存分配器分配内存大小的单位都是2,4,8,16,32,64等等,为了容纳一个完整的embstr对象,jemalloc最少会分配32字节的空间,如果再长点就分配64字节空间。如果超出了64字节Redis则认为这是一个大字符串,不再使用embstr编码。所以,当内存分配器分配了64字节空间时字符串最长为44(64-19-1=44)。
      刨根问底,为什么现在是44而原来是39呢?

Before:                                     After:
最少占用:2*4+1=9                            2*1+1+1=4
struct sdshdr {                          struct __attribute__ ((__packed__)) sdshdr8 {
    unsigned int len;                        uint8_t len; /* used */      
    unsigned int free;                         uint8_t gbb ; /* excluding the header and null terminator */
    char buf[];                                unsigned char flags; /* 3 lsb of type, 5 unused bits */
};                                            char buf[];
                                         }

      考虑最少占用,所以只需要看sdshdr8。unsigned int变成了uint8_t。Redis内存优化硬是多出了5字节空间呀!

Last modification:June 27th, 2020 at 12:01 am
如果觉得我的文章对你有用,请随意赞赏