深度解密 Redis 的字符串(String)

作者:@万明珠
喜欢这篇文章的话,就点个关注吧,会持续分享高质量Python文章,以及其它相关内容。


楔子

下面来解密 Redis 的字符串,整篇文章分为三个部分。

  • Redis 字符串的相关命令;
  • Redis 字符串的应用场景;
  • Redis 字符串的实现原理;

Redis 字符串的相关命令

先来看看字符串的相关命令,我们首先要学会如何使用它。

set key value:给指定的 key 设置 value

127.0.0.1:6379> set name satori
OK

# 如果字符串之间有空格,可以使用双引号包起来
127.0.0.1:6379> set name "kemeiji satori"
OK
127.0.0.1:6379> 

对同一个 key 多次设置 value 相当于更新,会保留最后一次设置的 value。设置成功之后会返回一个 OK,表示设置成功。除此之外,set 还可以指定一些可选参数。

  • set key value ex 60:设置的时候指定过期时间为 60 秒,等价于 setex key 60 value
  • set key value px 60:设置的时候指定过期时间为 60 毫秒,等价于 psetex key 60 value
  • set key value nx:只有 key 不存在的时候才会设置,存在的话会设置失败,而如果不加 nx 则会覆盖,等价于 setnx key value
  • set key value xx:只有 key 存在的时候才会设置,不存在的话会设置失败。注意:没有 setxx key value

我们发现使用 set 已经足够了,因此未来可能会移除 setex、psetex、setnx。另外我们可以对同一个 key 多次 set,相当于对原来的值进行了覆盖。


get key:获取指定 key 对应的 value

127.0.0.1:6379> get name
"kemeiji satori"
127.0.0.1:6379> get age
(nil)
127.0.0.1:6379> 

如果 key 不存在,则返回 nil,也就是空。存在的话,则返回 key 对应的 value。


del key1 key2 ···:删除指定 key,可以同时删除多个

127.0.0.1:6379> set age 16
OK

# 虽然设置的是一个数值
# 但在 Redis 中都是字符串格式
127.0.0.1:6379> get age
"16"
127.0.0.1:6379> get name
"kemeiji satori"

# 会返回删除的 key 的个数,表示有效删除了两个
# 而 gender 不存在,所以无法删除一个不存在的 key
127.0.0.1:6379> del name age gender
(integer) 2
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> get age
(nil)
127.0.0.1:6379> 

除了 del,还可以使用 unlink,表示异步删除。


append key value:追加

如果 key 存在,那么会将 value 追加到 key 对应的值的末尾;如果不存在,那么会重新设置,等价于 set key value。

127.0.0.1:6379> set name komeiji
OK
127.0.0.1:6379> get name
"komeiji"

# 追加一个字符串
# 并返回追加之后的字符串长度
127.0.0.1:6379> append name " satori"
(integer) 14
127.0.0.1:6379> get name
"komeiji satori"

127.0.0.1:6379> set age 1
OK
127.0.0.1:6379> get age
"1"
127.0.0.1:6379> append age 6
(integer) 2
127.0.0.1:6379> get age
"16"
127.0.0.1:6379> 

strlen key:查看 key 对应的 value 的长度

127.0.0.1:6379> strlen name
(integer) 14
127.0.0.1:6379> strlen age
(integer) 2
127.0.0.1:6379> strlen not_exists
(integer) 0
127.0.0.1:6379> 

incr key:为 key 存储的值自增 1,必须可以转成整型,否则报错。如果不存在 key,默认先将该 key 的值设置为 0,然后自增 1

127.0.0.1:6379> get age
"16"

# 返回自增后的结果
127.0.0.1:6379> incr age
(integer) 17
127.0.0.1:6379> get age
"17"

# 值不存在的话,会先设置为 0
# 然后再自增 1
127.0.0.1:6379> get length
(nil)
127.0.0.1:6379> incr length
(integer) 1
127.0.0.1:6379> get length
"1"

# name 无法转成整型
127.0.0.1:6379> incr name
(error) ERR value is not an integer or out of range
127.0.0.1:6379> 

decr key:为 key 存储的值自减 1,必须可以转成整型,否则报错。如果不存在 key,默认先设置该 key 值为 0,然后自减 1

127.0.0.1:6379> get age
"17"
127.0.0.1:6379> decr age
(integer) 16
127.0.0.1:6379> get age
"16"
127.0.0.1:6379> 
127.0.0.1:6379> get age2
(nil)
127.0.0.1:6379> decr age2
(integer) -1
127.0.0.1:6379> get age2
"-1"
127.0.0.1:6379> 

incrby key number:为 key 存储的值自增 number,必须可以转成整型,否则报错,如果不存在的话,默认先将该值设置为 0,然后自增 number

127.0.0.1:6379> get age
"16"
127.0.0.1:6379> incrby age 5
(integer) 21
127.0.0.1:6379> get age
"21"
127.0.0.1:6379> incrby age -5
(integer) 16
127.0.0.1:6379> get age
"16"
127.0.0.1:6379> 

相信你已经猜到了,除了 incrby 之外还有 decrby,两者用法是一样的。如果number 为负数,那么 incrby 的效果等价于 decrby。


getrange key start end:获取指定范围的 value

注意:redis 的索引都是包含结尾的,不管是这里的 getrange,还是后续的列表操作,索引都是包含两端的。

127.0.0.1:6379> set name satori
OK
127.0.0.1:6379> getrange name 0 -1
"satori"
127.0.0.1:6379> getrange name 0 4
"sator"
127.0.0.1:6379> getrange name -3 -1
"ori"
127.0.0.1:6379> getrange name -3 10086
"ori"
127.0.0.1:6379> getrange name -3 -4
""
127.0.0.1:6379> 

索引既可以从前往后数,也可以从后往前数。


setrange key start value:从索引为 start 的地方开始,将 key 对应的值替换为 value,替换的长度等于 value 的长度

127.0.0.1:6379> get name
"satori"

# 从索引为 0 的地方开始替换三个字符
# 并返回替换之后字符串的长度
127.0.0.1:6379> setrange name 0 SAT
(integer) 6
127.0.0.1:6379> get name
"SATori"

# 从索引为 10 的地方开始替换
# 但是字符串索引最大为 6,因此会使用 \x00填充
127.0.0.1:6379> setrange name 10 ORI
(integer) 13
127.0.0.1:6379> get name
"SATori\x00\x00\x00\x00ORI"

# 对于不存在的 key 也是如此
127.0.0.1:6379> setrange myself 3 gagaga
(integer) 9
127.0.0.1:6379> get myself
"\x00\x00\x00gagaga"

127.0.0.1:6379> set name satori
OK
# 替换的字符串长度没有限制,会自动扩充
127.0.0.1:6379> setrange name 0 'komeiji koishi'
(integer) 14
127.0.0.1:6379> get name
"komeiji koishi"

mset key1 value1 key2 value2:同时设置多个 key value

这是一个原子性操作,要么都设置成功,要么都设置不成功。注意:这些都是会覆盖原来的值的,如果不想这样的话,可以使用 msetnx,这个命令只会在所有的 key 都不存在的时候才会设置。


mget key1 key2:同时返回多个 key 对应的 value

127.0.0.1:6379> mset name koishi age 15
OK
127.0.0.1:6379> mget name age gender
1) "koishi"
2) "15"
3) (nil)
127.0.0.1:6379> 

如果有的 key 不存在,那么返回 nil。


getset key value:先返回 key 的旧值,然后设置新值

127.0.0.1:6379> getset name satori
"koishi"
127.0.0.1:6379> get name
"satori"
127.0.0.1:6379> 
127.0.0.1:6379> getset ping pong
(nil)
127.0.0.1:6379> get ping
"pong"
127.0.0.1:6379> 

返回旧值的同时设置新值,如果 key 不存在,那么会返回 nil,然后设置。另外,Redis 里面还有一些关于 key 的操作,这些操作不是专门针对 String 类型的,但是有必要提前说一下。


keys pattern:查看所有名称满足 pattern 的 key,至于 key 对应的 value 则可以是 Redis 的任意类型

# 查看所有的 key
127.0.0.1:6379> keys *
1) "hello"
2) "length"
3) "age2"
4) "ping"
5) "age"
6) "name"
7) "myself"

# 查看包含 a 的 key
127.0.0.1:6379> keys *a*
1) "age2"
2) "age"
3) "name"

# 查看以age开头、总共 4 个字符的key
127.0.0.1:6379> keys age?
1) "age2"
127.0.0.1:6379> 

exists key:判断某个 key 是否存在

# 查看 key 是否存在
# 存在返回 1,不存在返回 0
127.0.0.1:6379> exists name
(integer) 1
127.0.0.1:6379> exists names
(integer) 0

# 也可以指定多个 key,返回存在的 key 的个数
# 但是此时无法判断到底是哪个 key 存在
127.0.0.1:6379> exists name name1
(integer) 1
127.0.0.1:6379> 

ttl key:查看还有多少秒过期,-1 表示永不过期,-2 表示已过期

127.0.0.1:6379> ttl name
(integer) -1
127.0.0.1:6379> ttl name2
(integer) -2
127.0.0.1:6379> 

key 是可以设置过期时间的,如果过期了就不能再用了。但我们看到 name2 这个 key 压根就不存在,返回的也是 -2,因为过期了就相当于不存在了。而 name 是 -1,表示永不过期。


expire key 秒钟:为给定的 key 设置过期时间

# 设置 60s,设置成功返回 1
127.0.0.1:6379> expire name 60
(integer) 1
# 查看时间,还剩下 55 秒
127.0.0.1:6379> ttl name 
(integer) 55
# NAME 不存在,设置失败,返回 0
127.0.0.1:6379> expire NAME 60
(integer) 0
127.0.0.1:6379> 

这里设置 60s 的过期时间,另外设置完之后,在过期时间结束之前是可以再次设置的。比如我先设置了 60s,然后快结束的时候我再次设置 60s,那么还会再持续 60s。


type key:查看 key 是什么类型

# name过期了,相当于不存在了
# 因此为 none
127.0.0.1:6379> type name
none  
# 类型为 string
127.0.0.1:6379> type age
string 
127.0.0.1:6379>

move key db:将 key 移动到指定的 db 中

# 清空当前库,将所有 key 都删除
# 如果是清空所有库,可以使用 flushall
# 当然后面都可以加上 async,表示异步删除
127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> set name satori
OK
127.0.0.1:6379> keys *
1) "name"
# 将 name 移动到索引为3的库中
127.0.0.1:6379> move name 3
(integer) 1
# 当前库已经没有 name 这个 key 了 
127.0.0.1:6379> keys *
(empty array)
# 切换到索引为 3 的库中
127.0.0.1:6379> select 3
OK
# keys * 查看,发现 name 已经有了
127.0.0.1:6379[3]> keys *
1) "name"
# 切换回来
127.0.0.1:6379[3]> select 0
OK
127.0.0.1:6379> 

以上就是字符串相关的命令。

Redis 字符串的应用场景

讨论完字符串的相关命令之后,我们还要明白字符串要用在什么地方。

1)页面数据缓存

我们知道,一个系统最宝贵的资源就是数据库资源,随着公司业务的发展壮大,数据库的存储量也会越来越大,并且要处理的请求也越来越多。然而当数据量和并发量到达一定级别之后,数据库就变成了拖慢系统运行的 "罪魁祸首"。

为了避免这种情况发生,我们可以把查询结果放入缓存(Redis)中,让下次同样的查询直接去缓存系统取结果,而非查询数据库,这样既减少了数据库的压力,同时也提高了程序的运行速度。

这也是 Redis 用途最广泛的地方。


2)数据计算与统计

Redis 可以用来存储整数和浮点数类型的数据,并且可以通过命令直接累加并存储整数信息,这样就省去了每次先要取数据、转换数据、运算、再存入数据的麻烦,只需要使用一个命令就可以完成此流程。比如:微博、哔哩哔哩等社交平台,我们经常会点赞,然后还有点赞数。每点一个赞,点赞数就加 1,这个功能就完全可以交给 Redis 实现。


3)共享 Session 信息

通常我们在开发后台管理系统时,会使用 Session 来保存用户的会话(登录)状态,这些 Session 信息会被保存在服务器端,但这只适用于单系统应用,如果是分布式系统此模式将不再适用。

例如用户 A 的 Session 信息存储在第一台服务器,但第二次访问时用户 A 的请求被分配到第二台服务器,这个时候该服务器并没有用户 A 的 Session 信息,就会出现需要重复登录的问题。

由于分布式系统每次会把请求随机分配到不同的服务器,因此我们需要借助缓存系统对这些 Session 信息进行统一的存储和管理。这样无论请求发送到哪台服务器,服务器都会去统一的缓存系统获取相关的 Session 信息,这样就解决了分布式系统下 Session 存储的问题。

虽然这也是 Redis 使用场景之一,只不过在现在的 Web 开发中已经很少会使用共享 Session 的方式了。

Redis 字符串的实现原理

下面我们就来分析字符串的底层数据结构了,我们说键值对中的键是字符串类型,值有时也是字符串类型。

Redis 是用 C 语言实现的,但它没有直接使用 C 的字符串,而是自己封装了一个名为简单动态字符串(simple dynamic string,SDS) 的数据结构来表示字符串,也就是说 Redis 的 String 数据类型的底层数据结构是 SDS。

既然 Redis 设计了 SDS 结构来表示字符串,肯定是 C 语言的字符串存在一些缺陷。

C 字符串的缺陷

C 的字符串其实就是一个字符数组,即数组中每个元素都是字符串的一个字符,比如下图就是字符串 "koishi" 的字符数组结构:

s 只是一个指针,它指向了字符数组的起始位置,那么问题来了,C 要如何得知一个字符数组的长度呢?于是 C 会默认在每一个字符数组后面加上一个 \0,来表示字符串的结束。因此 C 语言标准库中的字符串函数就是通过判断字符是不是 \0 来决定要不要停止操作,如果当前字符不是 \0 ,说明字符串还没结束,可以继续操作;如果当前字符是 \0,则说明字符串结束了,就要停止操作。

举个例子,C 语言获取字符串长度的函数 strlen,就是通过遍历字符数组中的每一个字符,并进行计数。当遇到字符 \0 时停止遍历,然后返回已经统计到的字符个数,即为字符串长度。下图显示了 strlen 函数的执行流程:

如果用代码实现的话:

#include <stdio.h>

size_t strlen(const char *s) {
    size_t count = 0;
    while (*s++ != '\0') count++;
    return count;
}

int main() {
    printf("%lu\n", strlen("koishi"));  // 6
}

很明显,C 语言获取字符串长度的时间复杂度是 O(N),并且使用 \0 作为字符串结尾标记有一个缺陷。如果某个字符串中间恰好有一个 \0,那么这个字符串就会提前结束,举个例子:

#include <stdio.h>
#include <string.h>

int main() {
    // 字符串相关操作函数位于标准库 string.h 中
    printf("%lu\n", strlen("abcdefg"));  // 7
    printf("%lu\n", strlen("abc\0efg"));  // 3
}

所以在 C 中 \0 为字符串是否结束的标准,因此如果使用 C 的字符数组,只能让 C 在字符串结尾自动帮你加上 \0,我们创建的字符串内部是不可以包含 \0 的,否则就会出问题,因为字符串会提前结束。这个限制使得 C 语言的字符串只能保存文本数据,不能保存像图片、音频、视频之类的二进制数据。

另外 C 语言标准库中字符串的操作函数是很不安全的,对程序员很不友好,稍微一不注意,就会导致缓冲区溢出。举个例子,strcat 函数可以将两个字符串拼接在一起。

#include <stdio.h>
#include <string.h>

// 将 src 字符串拼接到 dest 字符串后面
// char *strcat(char *dest, const char* src);

int main() {
    char buf[12] = "hello ";
    printf("%s\n", buf);  // hello
    strcat(buf, "world");
    printf("%s\n", buf);  // hello world
}

"hello world" 占 11 个字符,加上 \0 一共 12 个,buf 的长度也为 12,刚好能容纳的下。但如果我们将 buf 的长度改成 11,就会发生缓冲区溢出,可能造成程序终止。因此 C 语言的字符串不会记录自身的缓冲区大小,它假定我们在执行这个函数时,已经为 dest 分配了足够多的内存。

而且 strcat 函数和 strlen 函数类似,时间复杂度也是 O(N) 级别,也是需要先通过遍历字符串才能得到目标字符串的末尾。然后对于 strcat 函数来说,还要再遍历源字符串才能完成追加,所以整个字符串的操作效率是不高的。

我们还是手动实现一下 strcat,看一看整个过程:

#include <stdio.h>

char *strcat(char *dest, const char *src) {
    char *head = dest;
    // 遍历字符串,直到结尾
    while (*head != '\0') head++;
    // 循环结束之后,head 停在了 \0 的位置
    // 然后将 src 对应的字符数组中的字符逐个拷贝过去
    while ((*head++ = *src++) != '\0');
    // 最后返回 dest
    return dest;
}

int main() {
    char buf[12] = "hello ";
    printf("%s\n", buf);  // hello
    strcat(buf, "world");
    printf("%s\n", buf);  // hello world
}

好了, 通过以上的分析,我们可以得知 C 字符串的不足之处以及可以改进的地方。

  • 获取字符串长度的时间复杂度为 O(N);
  • 字符串的结尾是以 \0 作为字符标识,使得字符串里面不能含有 \0 字符,因此不能保存二进制数据;
  • 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;

而 Redis 实现的 SDS 结构体就把上面这些问题解决了,接下来我们一起看看 Redis 是如何解决的。

SDS 的定义

先来看一看 SDS 长什么样子?

解释一下里面的每个成员变量代表什么含义:

  • len:记录了字符串的长度,这样后续在获取的时候只需返回这个成员变量的值即可,时间复杂度为 O(1)。
  • alloc:分配给字符数组的空间长度,这样后续对字符串进行修改时(比如追加一个字符串),可以通过 alloc 减去 len 计算出剩余空间大小,来判断空间是否满足修改需求。如果不满足,就会自动将 SDS 内的 buf 进行扩容(所谓扩容就是申请一个更大的 buf,然后将原来 buf 的内容拷贝到新的 buf 中,最后将原来的 buf 给释放掉),再执行修改操作。通过这种方式就避免了缓冲区溢出的问题,而且事先可以申请一个较大的 buf,避免每次追加的时候都进行扩容。
  • flags:用来表示不同类型的 SDS,SDS总共有 5 种,分别是 sdshdr5, sdshdr8, sdshdr16, sdshdr32 和 sdshdr64,后面说明它们之间的区别。所以 SDS 只是一个概念,它并不是真实存在的结构体,而 sdshdr5, sdshdr8, sdshdr16, sdshdr32 和 sdshdr64 才是底层定义好的结构体,相当于 SDS 的具体实现,当然它们都可以叫做 SDS。
  • buf[]:字符数组,用来保存实际数据,不仅可以保存字符串,也可以保存二进制数据。之所以可以保存二进制数据是因为在计算字符串长度的时候不再以 \0 为依据,因为 SDS 中的 len 字段在时刻维护字符串的长度。

总的来说,Redis 的 SDS 在原本的字符数组之上,增加了三个元数据:len, alloc, flags,用来解决 C 语言字符串的缺陷。

SDS 是怎么解决 C 字符串缺陷的

我们说 C 字符串有一系列缺陷,而 SDS 将它们全解决了,那么是如何解决的呢?


O(1) 时间复杂度获取字符串长度

因为 C 字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的 \0 为止,这个操作的复杂度为 O(N)。

而 Redis 的 SDS 因为加入了 len 成员变量,会时刻维护字符串的长度,所以获取字符串长度的时候,直接返回该成员变量的值就行,因此复杂度只有 O(1)。


避免缓冲区溢出

C 字符串不记录自身的长度,所以在使用 strcat 函数追加的时候,会假定已经为 dest 分配了足够多的内存,可以容纳 src 字符串中的所有内容。然而一旦这个假定不成立时,就会产生缓冲区溢出。

与 C 字符串不同,SDS 的空间分配策略完全避免了发生缓冲区溢出的可能性。当 SDS API 需要对 SDS 进行修改时,会先检查 SDS 的空间是否满足修改所需的要求。如果不满足的话,API 会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作。所以使用 SDS 既不需要手动修改SDS的空间大小,也不会出现缓冲区溢出问题。


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

Redis 在修改 SDS 时,会面临「申请、释放」内存的开销,所以 Redis 做了如下优化。

优化一:当判断出缓冲区剩余大小(alloc - len)不够用时,Redis 会自动扩大 SDS 的空间大小,以满足修改所需的大小。当然准确的说,扩容的是 SDS 内部的 buf 数组,扩容规则是:当小于 1MB 翻倍扩容,大于 1MB 按 1MB 扩容。

并且在扩展 SDS 空间的时候,API 不仅会为 SDS 分配修改所需要的空间,还会给 SDS 分配额外的「未使用空间」。这样的好处是,下次在操作 SDS 时,如果 SDS 空间够的话,API 就会直接使用「未使用空间」,而无须执行内存分配,从而有效地减少内存分配次数。

优化二:多余内存不释放,SDS 缩容的时候不释放多余的内存,下次可直接复用这些内存。


二进制安全

C 字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里面不能包含 \0。否则最先被程序读入的 \0 将被误认为是字符串结尾,这些限制使得 C 字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。

但 SDS 不需要用 \0 字符来标识字符串结尾,而是有个专门的 len 成员变量来记录长度,所以可存储包含 \0 的数据。但是 SDS 为了兼容部分 C 语言标准库的函数,还是会在结尾加上 \0 字符。注意:我们说 alloc 成员维护的是 buf[] 数组的长度,但是这个长度不包括结尾的 \0,比如 alloc 为 10,但 buf[] 的长度其实是 11。

因此 SDS 的 API 都是以处理二进制的方式来处理 SDS 存放在 buf[] 里的数据,程序不会对其中的数据做任何限制,数据写入的时候是什么样的,它被读取时就是什么样的。通过使用二进制安全的 SDS,而不是 C 字符串,使得 Redis 不仅可以保存文本数据,也可以保存任意格式的二进制数据。


节省内存空间

SDS 结构中有个 flags 成员变量,表示的是 SDS 类型,Redis 一共设计了 5 种 SDS。而这 5 种的主要区别就在于,它们的 len 和 alloc 成员变量的数据类型不同。以 sdshdr16 和 sdshdr32 为例:

可以看到 sdshdr16 的 len 和 alloc 的数据类型都是 uint16_t,表示字符数组长度和分配空间大小不能超过 2 的 16 次方;sdshdr32 则都是 uint32_t,表示字符数组长度和分配空间大小不能超过 2 的 32 次方。

之所以 SDS 设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。比如在保存小字符串时,len、alloc 这些元数据的占用空间也会比较少。像我们保存一个 5 字节的字符串,完全没有必要使用 uint64_t,否则 len 和 alloc 加起来就 16 字节了,这显然是得不偿失的。

除了设计不同类型的结构体,Redis 在编程上还使用了专门的编译优化来节省内存空间,即在定义 struct 时声明了 __attribute__ ((packed)),它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐。

内存对齐是为了减少数据存储和读取的工作量,现在的 64 位处理器默认按照 8 字节进行对齐。所以相同的结构体,如果字段顺序不同,那么占用的大小也不一样,我们举个例子:

#include <stdio.h>

typedef struct {
    int a;
    long b;
    char c;
} S1;

typedef struct {
    long a;
    int b;
    char c;
} S2;


int main() {
    printf("%u %u\n", sizeof(S1), sizeof(S2));  // 24 16
}

两个结构体的内部都是 3 个成员,类型为 int, long, char,但因为顺序不同导致整个结构体的大小不同,这就是内存对齐导致的。

关于内存对齐的具体细节这里不再赘述,总之它的核心就是:虽然现代计算机的内存空间都是按照 byte 划分的,从理论上讲似乎对任何类型的变量的访问都可以从任意地址开始,但是实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们默认会要求这些数据的首地址的值是 8 的倍数(64 位机器),这就是所谓的内存对齐。

我们在 C 中可以指定对齐的字节数,比如 #pragma pack(4) 表示按照 4 字节对齐。当然啦,还可以像 Redis 那样在声明结构体的时候指定 __attribute__ ((packed)) 来禁止内存对齐,此时结构体中的字段都是紧密排列的,不会出现空洞。

#include <stdio.h>

typedef struct {
    int a;
    long b;
    char c;
} S1;

typedef struct {
    long a;
    int b;
    char c;
} S2;

typedef struct __attribute__ ((packed)) {
    long a;
    int b;
    char c;
} S3;


int main() {
    printf("%u %u %u\n",
           sizeof(S1), sizeof(S2), sizeof(S3)
    );  // 24 16 13
}

我们看到在禁止内存对齐之后,结构体占 13 个字节,就是每个成员的大小相加。

小结

以上我们就从命令操作、应用场景、实现原理三个角度介绍了 Redis 字符串,命令操作比较简单,网上资料一大堆,应用场景也很简单,重点是它的实现原理。

我们要清楚为什么 Redis 自己定义 SDS 来表示字符串,而不使用 C 的字符数组,原因是 C 的字符数组有很大的不足。关于 SDS 和 C 字符数组之间的差别,再总结一下:

以上就是 Redis 字符串的内容,因为介绍了相关命令操作(加上代码演示),所以内容稍微有点多。如果你对 Redis 的命令操作已经很熟悉了,那么这部分也可以不用看。

最后,SDS 字符串在 Redis 内部也被大量使用,比如 :

  • Redis 的所有 key 都是字符串,可以查看 src/db.c 的 dbAdd 函数;
  • Redis 服务端在读取 Client 发来的请求时,会先读到一个缓冲区中,这个缓冲区也是字符串,可以查看 src/server.h 中 struct client 的 querybuf 字段;
  • 写操作追加到 AOF 时,会先写到 AOF 缓冲区,这个缓冲区也是字符串,可以查看 src/server.h 中 struct client 的 aof_buf 字段;

 

本文参考自:

  • 极客时间蒋德钧:《Redis 源码剖析与实战》
  • 微信读书:《Redis 设计与实现》
  • 小林 coding:《图解 Redis》
  • 课代表 kaito 在极客时间《Redis 源码剖析与实战》评论区的精彩回答
posted @   万明珠  阅读(101)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek “源神”启动!「GitHub 热点速览」
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
· NetPad:一个.NET开源、跨平台的C#编辑器
点击右上角即可分享
微信分享提示

目录导航