C语言没有字符串,Redis是怎么存储string的?

Redis常用作分布式KV缓存,而存储的KV,最基础的数据结构就是string字符串;
所有对Redis KV的操作,K都是string。
string作为Redis支持的最基础的数据类型,底层却有着很多不为人知的秘密,今天就来和大家一同探究。

字符串常用命令

  • set(key, value):给数据库中名称为key的string赋予值value
  • get(key):返回数据库中名称为key的string的value
  • getset(key, value):给名称为key的string赋值value,并返回上一次的值,如果key不存在,则会创建一个新的key,返回nil。
  • mget(key1, key2,…, keyN):返回库中多个string(它们的名称为key1,key2…)的value
  • setnx(key,value):如果不存在名称为key的string,则向库中添加string,名称为key,值为value
  • setex(key, time, value):向库中添加string(名称为key,值为value)同时,设定过期时间time
  • mset(key1, value1, key2, value2,…key N, value N)给多个string赋值,名称为key i的string赋值value i
  • msetnx(key1, value1, key2, value2,…key N, value N):如果所有名称为key i的string都不存在,则向库中添加string,名称key i赋值为value i
  • incr(key):名称为key的string增1操作
  • incrby(key, integer):名称为key的string增加integer
  • decr(key):名称为key的string减1操作
  • decrby(key, integer):名称为key的string减少integer
  • append(key, value):名称为key的string的值附加value
  • substr(key, start, end):返回名称为key的string的value的子串
  • incrbyfloat:为 key 中所储存的值加上指定的浮点数增量值

除了最常用的set、get缓存操作的基础命令。还有3个“特殊”用法:

  • 我们还能基于incr命令,生成分布式递增ID;利用的就是:set("key","1")之后,incr命令可以将value进行加1递增;那说明Redis底层支持、并能够识别数值类型。
  • append命令能够对原value进行追加,如:执行set("jack","cute")之后,执行append("jack"," boy")之后,jack对应的value就变成了cute body了;说明Redis底层需要支持字符串的动态追加(动态扩容)。
  • incrbyfloat 命令为 key 中所储存的值加上指定的浮点数增量值。
    如果 key 不存在,那么 incrbyfloat 会先将 key 的值设为 0 ,再执行加法操作。说明Redis底层支持 浮点数类型。

这三点有何“特殊”呢?
c语言没有string类型,本质是char[]数组;而且c语言数组创建时必须初始化大小,指定类型后就不能改变。
众所周知,Redis是c语言实现的,c语言的string类型,似乎不能支持上面三种特殊用法!

Redis 为什么要重新定义SDS 去存储string呢?

1、c语言没有string类型, 只有char[],且char[]必须先分配空间长度;
2、获取char[]的长度,需要遍历数组,len(char[])时间复杂度O(n);
3、char[]预先分配了长度,数据增长后需要扩容;
4、c语言的char数组,用'\0'代表结束,意味着存储二进制数据不能包含'\0',图片音频等用二进制存储会有问题——这就是为什么Redis说自己实现的SDS是二进制安全的字符串。

SDS对c原始char数组的改进

1、Redis实现的SDS支持扩容
2、包含长度len,获取长度复杂度O(1)
3、空间预分配
4、惰性空间释放

Redis的KV存储结构

Redis内存数据库,最底层是一个redisDb;


redisDb 整体使用 dict字典 来存储键值对KV;
字典中的每一项,使用dictEntry ,代表KV键值;类似于HashMap中的键值对Entry。
dictEntry 里包含 K,V;K是字符串类型,就是SDS;
V 可能是字符串、list、hash等(Redis支持的数据结构),V并没有直接定成具体的类型,而是用redisObject封装了一层;实际存储的数据结构是由ptr指针具体指向。

使用redisObject 封装了不同的底层数据结构的实现。
使用 type命令 获取数据类型时,获取的就是redisObject 的type属性。

type获取数据类型

还记得Redis字符串 3个“特殊”用法吗?
其实很显然,通过这3点,可以推断,Redis字符串string存储类型有三种:int、float、string;
分别对应整型、浮点型和字符串。
整型的value,具有递增incr 和递减desc 的操作。
浮点型需要使用相应的incrbyfloat命令。

再进一步推断,Redis字符串string的三种存储类型,分别具有不同的编码类型,redisObject做了一层封装,value都是字符串;
我们不妨用type命令进行验证;

set jack 2019
set jack abc
append jack hello

三条命令分别执行,分别使用type jack查看jack对应value的类型,返回的都是string;
redisObject做了一层封装,value都是字符串,对外表现都是string,也就是redisObject 的type属性都是string;

但是使用object encoding jack查看编码类型:
分别进行下列实验

set jack 2019; #返回int
set jack abc; #返回embstr类型——精简字符串
set jack abc
append jack hello; #返回是raw类型——长字符串

是不是,惊讶了~

实验说明:
外层数据类型 redisObject.type 不会变;
但底层 编码类型,会随数据特征 而改变。
存储不同的数据,底层编码会不同。

Redis string的三种编码

1、int 存储8个字节的长整型(long,2^63-1 )
2、embstr, embstr格式的SDS (Simple Dynamic String)
3、raw, raw格式的SDS,存储大于44个字节的长字符串。

同为SDS,embstr与raw格式有何不同?

  • embstr 编码
    存储简短字符串,一次的内存分配;
    它是只读的,如果对内容进行修改,就会变成raw编码(即使没超过44字节);

  • raw 编码
    可分配多次内存空间,存储大于44个字节的长字符串。

embstr转成raw(embstr->raw)的条件
1、embstr 被修改
2、长度超过44

raw 原生SDS 字符长度 缩减到小于44,会逆向变成embstr编码吗?
不会;Redis底层编码,转变后 不可逆(不会回退)。

读到这里,有没有收获呢?
可见,Redis每一种数据类型,底层都可能有不同的编码,以及对应 不同编码之间的转化条件。
后续文章分析 Redis数据结构底层实现的时候,会进一步深入。

为什么可以“再进一步推断,Redis字符串string的三种存储类型,分别具有不同的数据类型”呢?
你想想,要是你是Redis作者,总不能对于数值型value执行递增操作,你还要判断一遍这个value是纯字符串、还是数值吧,未免效率太低。
这就是使用redisObject做一层封装的价值之一;使用redisObject 封装了不同的底层数据结构的实现。

SDS与传统的C语言字符串的区别?

传统的C语言字符串,在Redis使用场景中的不足,前面已经介绍了。
SDS本质上也还是char[]数组,在此基础上实现了自动扩容和增添length属性。
取名动态字符串,就是可以动态修改的意思;
内部结构实现上类似于 Java 的 ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配,如图中所示,内部为当前字符串实际分配的空间 capacity 一般要高于实际字符串长度 len。当字符串长度小于 1M 时, 扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。

由于SDS的结构中有对应的free和len属性来记录相应的字符串的长度,所以获取SDS字符串长度的时间复杂度为O(1);
free属性,记录了未分配的空间大小,使得SDS并不会使得缓冲区溢出
我们知道传统的C语言字符串会导致溢出,因为其不会检查当前字符数组剩余空间的大小。
而SDS的内存分配策略则解决了这一问题,比如说当我们使用sdscat()函数在当前的字符串后面拼接字符串时,会检查给定的空间是否够用,如果不够则会事先扩充SDS的空间大小。
频繁的内存分配和内存回收就会带来性能问题,为此,Redis分别实现了空间预分配惰性释放两种优化策略。

空间预分配

空间预分配,是SDS中防止缓冲区溢出的策略。
在每次扩容的时候先进行判断,是否有足够的空间来存储传进来的字符串,如果没有,则是扩容到相对应的长度,同时对 free 属性分配相对应的长度。

空间预分配用于优化SDS的字符串的增长操作:当SDS的API对一个SDS字符串进行修改,并且此时SDS的空间不够的时候,系统不仅会为SDS分配增长所必要的空间,还会分配额外的未使用的空间,其规则如下

  • 如果对SDS进行append增长之后,SDS的长度(len)小于1MB,那么将会分配和len大小相等的未使用空间,这时候free=len;
  • 如果对SDS进行append增长之后,SDS的长度(len)大于等于1MB,那么将会分配1MB的free空间,这时候free=1MB;

Redis最大KV长度都是 512M,也就是K(字符串SDS)算的空间单位,也就是len和free是容量,而非长度,源码见sds.c、sds.h。

为了我们方便演示,就假定扩容单位是字符:

如有如下的SDS字符串,已使用空间为12,未使用空间为2;
进行append追加abc,空闲空间不足以容纳新增内容,需要扩容:
追加后变成 Hello World!abc\0;追加后的len是13

  • 若已使用空间为12 小于1MB,则分配和len大小相等的未使用空间,free变成13;
    此时SDS整体空间较小,大步扩容;避免频繁malloc,减少性能消耗;
  • 若已使用空间为12 >=1MB,则分配1MB的free空间,这时候free=1MB;

Redis源码,最终调用 sdsMakeRoomFor 对带拼接的字符串 s 容量做了检查,若无须扩容则直接返回 s;若需要扩容,返回的则是扩容好的新字符串。
sdsMakeRoomFor 的实现流程参考如下:

源码分析后续再详细讲。

惰性释放

惰性空间释放:用于优化SDS的字符串的缩短操作:当SDS的API对一个SDS字符串进行缩短时,程序并不会立即回收多余的未使用的空间,而是通过会增加free属性的值,将未使用的空间记录下来,并等待将来使用。
我们使用sdstrim()函数来做一个示例

现在执行sdstrim(str,"XY"),来移除str中的所有'x'和'Y'字符
这样得到的结果为

看到上面我们就知道通过sdstrim(str,"XY")缩短字符串后多余的8个字节的空间并没有被回收掉,而是将free的值加了8从而记录下了多余的空间,从而可以再次利用。

redis 什么是二进制安全的字符串?

https://redis.io/topics/data-types
Redis Strings are binary safe, this means that a Redis string can contain any kind of data, for instance a JPEG image or a serialized Ruby object.

SDS是二进制安全的,因为SDS字符串是通过len的大小判断字符串结束的(不是通过'\0'),从而其中可以存储'\0',这就是说其可以保存任意格式的二进制数据。

那么为什么还要在SDS字符串的末尾加上一个'\0'呢?
这是因为为了使SDS字符串兼容部分的C语言字符串函数,比如说<string.h>中过的函数,从而避免了代码的重复,达到重用的效果。

ps incr 命令注意点
incr 命令将 key 中储存的数值增一。
如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作。
如果值包含错误的类型,或字符串类型的值不能表示为数字,那么返回一个错误。
本操作的值限制在 64 位(bit)有符号数字表示之内。

Redis中sds源码文件是:sds.h, sds.c。

本文首发于公众号 架构道与术(ToBeArchitecturer),欢迎关注、学习更多干货~

推荐阅读更多精彩内容