Redis 的 String 类型

此文档源自钱文品老师所著《Redis 深度历险:核心原理和应用实践》
习所用

基础

字符串 string 是 Redis 最简单的数据结构。Redis 所有的数据结构都是以唯一的 key字符串作为名称,然后通过这个唯一 key 值来获取相应的 value 数据。不同类型的数据结构的差异就在于 value 的结构不一样。
在这里插入图片描述
字符串结构使用非常广泛,一个常见的用途就是缓存用户信息。我们将用户信息结构体使用 JSON 序列化成字符串,然后将序列化后的字符串塞进 Redis 来缓存。同样,取用户信息会经过一次反序列化的过程。
在这里插入图片描述

Redis 的字符串是动态字符串,是可以修改的字符串,内部结构实现上类似于 Java 的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配,如图中所示,内部为当前字符串实际分配的空间 capacity 一般要高于实际字符串长度 len。当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。需要注意的是字符串最大长度为 512M。

键值对
> set name codehole 
OK 
> get name 
"codehole"
> exists name 
(integer) 1 
> del name 
(integer) 1 
> get name 
(nil) 
批量键值对

可以批量对多个字符串进行读写,节省网络耗时开销。

> set name1 codehole 
OK 
> set name2 holycoder 
OK 
> mget name1 name2 name3 # 返回一个列表
1) "codehole" 
2) "holycoder" 
3) (nil) 
> mset name1 boy name2 girl name3 unknown 
> mget name1 name2 name3 
1) "boy" 
2) "girl" 
3) "unknown"
过期和 set 命令扩展

可以对 key 设置过期时间,到点自动删除,这个功能常用来控制缓存的失效时间。不过这个「自动删除」的机制是比较复杂的,后面说

> set name codehole 
> get name "codehole" 
> expire name 5 # 5s 后过期
... # wait for 5s 
> get name 
(nil) 
> setex name 5 codehole # 5s 后过期,等价于 set+expire
> get name 
"codehole" 
... # wait for 5s 
> get name 
(nil) 
> setnx name codehole # 如果 name 不存在就执行 set 创建
(integer) 1 
> get name 
"codehole" 
> setnx name holycoder 
(integer) 0 # 因为 name 已经存在,所以 set 创建不成功
> get name 
"codehole"
# 没有改变
计数

如果 value 值是一个整数,还可以对它进行自增操作。自增是有范围的,它的范围是signed long 的最大最小值,超过了这个值,Redis 会报错。

> set age 30 
OK 
> incr age 
(integer) 31 
> incrby age 5 
(integer) 36 
> incrby age -5 
(integer) 31 
> set codehole 9223372036854775807 
# Long.Max 
OK 
> incr codehole

(error) ERR increment or decrement would overflow
字符串是由多个字节组成,每个字节又是由 8 个 bit 组成,如此便可以将一个字符串看成很多 bit 的组合,这便是 bitmap「位图」数据结构。在这里插入图片描述

bitmap「位图」

在我们平时开发过程中,会有一些 bool 型数据需要存取,比如用户一年的签到记录,签了是 1,没签是 0,要记录 365 天。如果使用普通的 key/value,每个用户要记录 365个,当用户上亿的时候,需要的存储空间是惊人的。
为了解决这个问题,Redis 提供了位图数据结构,这样每天的签到记录只占据一个位,365 天就是 365 个位,46 个字节 (一个稍长一点的字符串) 就可以完全容纳下,这就大大
节约了存储空间。
在这里插入图片描述
位图不是特殊的数据结构,它的内容其实就是普通的字符串,也就是 byte 数组。我们可以使用普通的 get/set 直接获取和设置整个位图的内容,也可以使用位图操作getbit/setbit 等将 byte 数组看成「位数组」来处理。

基本使用

Redis 的位数组是自动扩展,如果设置了某个偏移位置超出了现有的内容范围,就会自
动将位数组进行零扩充。
接下来我们使用位操作将字符串设置为 hello (不是直接使用 set 指令),首先我们需要得
到 hello 的 ASCII 码,用 Python 命令行可以很方便地得到每个字符的 ASCII 码的二进制
值。

>>> bin(ord('h')) 
'0b1101000' # 高位 -> 低位
>>> bin(ord('e'))
'0b1100101'
>>> bin(ord('l')) 
'0b1101100'
>>> bin(ord('l'))
'0b1101100'
>>> bin(ord('o'))
'0b1101111'

在这里插入图片描述
接下来我们使用 redis-cli 设置第一个字符,也就是位数组的前 8 位,我们只需要设置值为 1 的位,如上图所示,h 字符只有 1/2/4 位需要设置,e 字符只有 9/10/13/15 位需要设置。值得注意的是位数组的顺序和字符的位顺序是相反的。

127.0.0.1:6379> setbit s 1 1 
(integer) 0 
127.0.0.1:6379> setbit s 2 1 
(integer) 0 
127.0.0.1:6379> setbit s 4 1 
(integer) 0 
127.0.0.1:6379> setbit s 9 1 
(integer) 0 
127.0.0.1:6379> setbit s 10 1 
(integer) 0 
127.0.0.1:6379> setbit s 13 1
(integer) 0 
127.0.0.1:6379> setbit s 15 1 
(integer) 0 
127.0.0.1:6379> get s 
"he''

上面这个例子可以理解为「零存整取」,同样我们还也可以「零存零取」,「整存零取」。「零存」就是使用 setbit 对位值进行逐个设置,「整存」就是使用字符串一次性填充所有位数组,覆盖掉旧值。

  • 零存零取
127.0.0.1:6379> setbit w 1 1
(integer) 0
127.0.0.1:6379> setbit w 2 1
(integer) 0
127.0.0.1:6379> setbit w 4 1
(integer) 0
127.0.0.1:6379> getbit w 1 # 获取某个具体位置的值 0/1
(integer) 1
127.0.0.1:6379> getbit w 2
(integer) 1
127.0.0.1:6379> getbit w 4
(integer) 1
127.0.0.1:6379> getbit w 5
(integer) 0
  • 整存零取
127.0.0.1:6379> set w h # 整存
(integer) 0
127.0.0.1:6379> getbit w 1
(integer) 1
127.0.0.1:6379> getbit w 2
(integer) 1
127.0.0.1:6379> getbit w 4
(integer) 1
127.0.0.1:6379> getbit w 5
(integer) 0

如果对应位的字节是不可打印字符,redis-cli 会显示该字符的 16 进制形式。

127.0.0.1:6379> setbit x 0 1
(integer) 0
127.0.0.1:6379> setbit x 1 1
(integer) 0
127.0.0.1:6379> get x
"\xc0"
统计和查找

Redis 提供了位图统计指令 bitcount 和位图查找指令 bitpos,bitcount 用来统计指定位置范围内 1 的个数,bitpos 用来查找指定范围内出现的第一个 0 或 1。

比如我们可以通过 bitcount 统计用户一共签到了多少天,通过 bitpos 指令查找用户从哪一天开始第一次签到。如果指定了范围参数[start, end],就可以统计在某个时间范围内用户签到了多少天,用户自某天以后的哪天开始签到。

遗憾的是, start 和 end 参数是字节索引,也就是说指定的位范围必须是 8 的倍数,而不能任意指定。这很奇怪,我表示不是很能理解 Antirez 为什么要这样设计。因为这个设计,我们无法直接计算某个月内用户签到了多少天,而必须要将这个月所覆盖的字节内容全部取出来 (getrange 可以取出字符串的子串) 然后在内存里进行统计,这个非常繁琐。

接下来我们简单试用一下 bitcount 指令和 bitpos 指令:

127.0.0.1:6379> set w hello
OK
127.0.0.1:6379> bitcount w
(integer) 21
127.0.0.1:6379> bitcount w 0 0 # 第一个字符中 1 的位数
(integer) 3
127.0.0.1:6379> bitcount w 0 1 # 前两个字符中 1 的位数
(integer) 7
127.0.0.1:6379> bitpos w 0 # 第一个 0(integer) 0
127.0.0.1:6379> bitpos w 1 # 第一个 1(integer) 1
127.0.0.1:6379> bitpos w 1 1 1 # 从第二个字符算起,第一个 1(integer) 9
127.0.0.1:6379> bitpos w 1 2 2 # 从第三个字符算起,第一个 1(integer) 17
魔术指令 bitfield

前文我们设置 (setbit) 和获取 (getbit) 指定位的值都是单个位的,如果要一次操作多个位,就必须使用管道来处理。 不过 Redis 的 3.2 版本以后新增了一个功能强大的指令,有了这条指令,不用管道也可以一次进行多个位的操作。 bitfield 有三个子指令,分别是get/set/incrby,它们都可以对指定位片段进行读写,但是最多只能处理 64 个连续的位,如果超过 64 位,就得使用多个子指令,bitfield 可以一次执行多个子指令
在这里插入图片描述
接下来我们对照着上面的图看个简单的例子:

127.0.0.1:6379> set w hello
OK
127.0.0.1:6379> bitfield w get u4 0 # 从第一个位开始取 4 个位,结果是无符号数 (u)
(integer) 6
127.0.0.1:6379> bitfield w get u3 2 # 从第三个位开始取 3 个位,结果是无符号数 (u)
(integer) 5
127.0.0.1:6379> bitfield w get i4 0 # 从第一个位开始取 4 个位,结果是有符号数 (i)
1) (integer) 6
127.0.0.1:6379> bitfield w get i3 2 # 从第三个位开始取 3 个位,结果是有符号数 (i)
1) (integer) -3

所谓有符号数是指获取的位数组中第一个位是符号位,剩下的才是值。如果第一位是1,那就是负数。无符号数表示非负数,没有符号位,获取的位数组全部都是值。有符号数最多可以获取 64 位,无符号数只能获取 63 位 (因为 Redis 协议中的 integer 是有符号数,最大 64 位,不能传递 64 位无符号值)。如果超出位数限制,Redis 就会告诉你参数错误。接下来我们一次执行多个子指令:

127.0.0.1:6379> bitfield w get u4 0 get u3 2 get i4 0 get i3 2
1) (integer) 6
2) (integer) 5
3) (integer) 6
4) (integer) -3

wow,很魔法有没有!
然后我们使用 set 子指令将第二个字符 e 改成 a,a 的 ASCII 码是 97。

127.0.0.1:6379> bitfield w set u8 8 97 # 从第 8 个位开始,将接下来的 8 个位用无符号数 97 替换
1) (integer) 101
127.0.0.1:6379> get w
"hallo"

再看第三个子指令 incrby,它用来对指定范围的位进行自增操作。既然提到自增,就有可能出现溢出。如果增加了正数,会出现上溢,如果增加的是负数,就会出现下溢出。Redis 默认的处理是返。如果出现了溢出,就将溢出的符号位丢掉。如果是 8 位无符号数 255, 加 1 后就会溢出,会全部变零。如果是 8 位有符号数 127,加 1 后就会溢出变成 -128。
接下来我们实践一下这个子指令 incrby :

127.0.0.1:6379> set w hello
OK
127.0.0.1:6379> bitfield w incrby u4 2 1 # 从第三个位开始,对接下来的 4 位无符号数 +1
1) (integer) 11
127.0.0.1:6379> bitfield w incrby u4 2 1
1) (integer) 12
127.0.0.1:6379> bitfield w incrby u4 2 1
1) (integer) 13
127.0.0.1:6379> bitfield w incrby u4 2 1
1) (integer) 14
127.0.0.1:6379> bitfield w incrby u4 2 1
1) (integer) 15
127.0.0.1:6379> bitfield w incrby u4 2 1 # 溢出折返了
1) (integer) 0

bitfield 指令提供了溢出策略子指令 overflow,用户可以选择溢出行为,默认是折返(wrap),还可以选择失败 (fail) 报错不执行,以及饱和截断 (sat),超过了范围就停留在最大最小值。overflow 指令只影响接下来的第一条指令,这条指令执行完后溢出策略会变成默认值折返 (wrap)。
接下来我们分别试试这两个策略的行为

  • 饱和截断 SAT
127.0.0.1:6379> set w hello
OK
127.0.0.1:6379> bitfield w overflow sat incrby u4 2 1
1) (integer) 11
127.0.0.1:6379> bitfield w overflow sat incrby u4 2 1
1) (integer) 12
127.0.0.1:6379> bitfield w overflow sat incrby u4 2 1
1) (integer) 13
127.0.0.1:6379> bitfield w overflow sat incrby u4 2 1
1) (intege) 14 
127.0.0.1:6379> bitfield w overflow sat incrby u4 2 1 
1) (integer) 15 
127.0.0.1:6379> bitfield w overflow sat incrby u4 2 1 # 保持最大值
1) (integer) 15
  • 失败不执行 FAIL
127.0.0.1:6379> set w hello
OK
127.0.0.1:6379> bitfield w overflow fail incrby u4 2 1
1) (integer) 11
127.0.0.1:6379> bitfield w overflow fail incrby u4 2 1
1) (integer) 12
127.0.0.1:6379> bitfield w overflow fail incrby u4 2 1
1) (integer) 13
127.0.0.1:6379> bitfield w overflow fail incrby u4 2 1
1) (integer) 14
127.0.0.1:6379> bitfield w overflow fail incrby u4 2 1
1) (integer) 15
127.0.0.1:6379> bitfield w overflow fail incrby u4 2 1 # 不执行
1) (nil)

String 内部结构

Redis 中的字符串是可以修改的字符串,在内存中它是以字节数组的形式存在的。我们知道 C 语言里面的字符串标准形式是以 NULL 作为结束符,但是在 Redis 里面字符串不是这么表示的。因为要获取 NULL 结尾的字符串的长度使用的是 strlen 标准库函数,这个函数的算法复杂度是 O(n),它需要对字节数组进行遍历扫描,作为单线程的 Redis 表示承受不起。
Redis 的字符串叫着「SDS」,也就是 Simple Dynamic String。它的结构是一个带长度信息的字节数组。

struct SDS<T> {
 T capacity; // 数组容量
 T len; // 数组长度
 byte flags; // 特殊标识位,不理睬它
 byte[] content; // 数组内容
}

在这里插入图片描述
如代码所示,content 里面存储了真正的字符串内容,那 capacity 和 len 表示什么意思呢?它有点类似于 Java 语言的 ArrayList 结构,需要比实际的内容长度多分配一些冗余空间。capacity 表示所分配数组的长度,len 表示字符串的实际长度。前面我们提到字符串是可以修改的字符串,它要支持 append 操作。如果数组没有冗余空间,那么追加操作必然涉及到分配新数组,然后将旧内容复制过来,再 append 新内容。如果字符串的长度非常长,这样的内存分配和复制开销就会非常大。

/* Append the specified binary-safe string pointed by 't' of 'len' bytes to the
* end of the specified sds string 's'.
*
* After the call, the passed sds string is no longer valid and all the
* references must be substituted with the new pointer returned by the call. */
sds sdscatlen(sds s, const void *t, size_t len) {
 size_t curlen = sdslen(s); // 原字符串长度
 // 按需调整空间,如果 capacity 不够容纳追加的内容,就会重新分配字节数组并复制原字
符串的内容到新数组中
 s = sdsMakeRoomFor(s,len);
 if (s == NULL) return NULL; // 内存不足
 memcpy(s+curlen, t, len); // 追加目标字符串的内容到字节数组中
 sdssetlen(s, curlen+len); // 设置追加后的长度值
 s[curlen+len] = '\0'; // 让字符串以\0 结尾,便于调试打印,还可以直接使用 glibc 的字符串
函数进行操作
 return s;
}

上面的 SDS 结构使用了范型 T,为什么不直接用 int 呢,这是因为当字符串比较短时,len 和 capacity 可以使用 byte 和 short 来表示,Redis 为了对内存做极致的优化,不同长度的字符串使用不同的结构体来表示。

Redis 规定字符串的长度不得超过 512M 字节。创建字符串时 len 和 capacity 一样长,不会多分配冗余空间,这是因为绝大多数场景下我们不会使用 append 操作来修改字符串。

  • embstr vs raw
    Redis 的字符串有两种存储方式,在长度特别短时,使用 emb 形式存储 (embeded),当长度超过 44 时,使用 raw 形式存储。这两种类型有什么区别呢?为什么分界线是 44 呢?
> set codehole abcdefghijklmnopqrstuvwxyz012345678912345678
OK
> debug object codehole
Value at:0x7fec2de00370 refcount:1 encoding:embstr serializedlength:45 lru:5958906 lru_seconds_idle:1
> set codehole abcdefghijklmnopqrstuvwxyz0123456789123456789
OK
> debug object codehole
Value at:0x7fec2dd0b750 refcount:1 encoding:raw serializedlength:46 lru:5958911 lru_seconds_idle:1

注意上面 debug object 输出中有个 encoding 字段,一个字符的差别,存储形式就发生
了变化。这是为什么呢?
为了解释这种现象,我们首先来了解一下 Redis 对象头结构体,所有的 Redis 对象都有
下面的这个结构头:

struct RedisObject {
 int4 type; // 4bits
 int4 encoding; // 4bits
 int24 lru; // 24bits
 int32 refcount; // 4bytes
 void *ptr; // 8bytes,64-bit system
} robj;

不同的对象具有不同的类型 type(4bit),同一个类型的 type 会有不同的存储形式encoding(4bit),为了记录对象的 LRU 信息,使用了 24 个 bit 来记录 LRU 信息。每个对象都有个引用计数,当引用计数为零时,对象就会被销毁,内存被回收。ptr 指针将指向对象内容 (body) 的具体存储位置。这样一个 RedisObject 对象头需要占据 16 字节的存储空间。

接着我们再看 SDS 结构体的大小,在字符串比较小时,SDS 对象头的大小是capacity+3,至少是 3。意味着分配一个字符串的最小空间占用为 19 字节 (16+3)。

struct SDS {
 int8 capacity; // 1byte
 int8 len; // 1byte
 int8 flags; // 1byte
 byte[] content; // 内联数组,长度为 capacity
}

在这里插入图片描述
如图所示,embstr 存储形式是这样一种存储形式,它将 RedisObject 对象头和 SDS 对象连续存在一起,使用 malloc 方法一次分配。而 raw 存储形式不一样,它需要两次malloc,两个对象头在内存地址上一般是不连续的。

而内存分配器 jemalloc/tcmalloc 等分配内存大小的单位都是 2、4、8、16、32、64 等等,为了能容纳一个完整的 embstr 对象,jemalloc 最少会分配 32 字节的空间,如果字符串再稍微长一点,那就是 64 字节的空间。如果总体超出了 64 字节,Redis 认为它是一个大字符串,不再使用 emdstr 形式存储,而该用 raw 形式。当内存分配器分配了 64 空间时,那这个字符串的长度最大可以是多少呢?这个长度就是 44。那为什么是 44 呢?

前面我们提到 SDS 结构体中的 content 中的字符串是以字节\0 结尾的字符串,之所以多出这样一个字节,是为了便于直接使用 glibc 的字符串处理函数,以及为了便于字符串的调试打印输出。

看上面这张图可以算出,留给 content 的长度最多只有 45(64-19) 字节了。字符串又是以\0 结尾,所以 embstr 最大能容纳的字符串长度就是 44。

  • 扩容策略
    字符串在长度小于 1M 之前,扩容空间采用加倍策略,也就是保留 100% 的冗余空间。当长度超过 1M 之后,为了避免加倍后的冗余空间过大而导致浪费,每次扩容只会多分配 1M 大小的冗余空间。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值