3)深度解密 Redis 的字符串

简介: 3)深度解密 Redis 的字符串


楔子



下面来解密 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>


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

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

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>


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 将它们全解决了,那么是如何解决的呢?

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

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

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

2)避免缓冲区溢出

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

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

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

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

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

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

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

4)二进制安全

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 不仅可以保存文本数据,也可以保存任意格式的二进制数据。

5)节省内存空间

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 源码剖析与实战》评论区的精彩回答
相关文章
|
5月前
|
存储 NoSQL 5G
redis优化编码之字符串
Redis数据结构之字符串
84 2
redis优化编码之字符串
|
5月前
|
XML JSON NoSQL
Redis的常用数据结构之字符串类型
Redis的常用数据结构之字符串类型
51 0
|
5月前
|
NoSQL 安全 Linux
Redis 字符串:SDS
Redis 字符串:SDS
62 0
|
2月前
|
存储 缓存 NoSQL
redis数据结构-字符串
redis数据结构-字符串
31 1
|
4月前
|
存储 NoSQL Redis
Redis系列学习文章分享---第十六篇(Redis原理1篇--Redis数据结构-动态字符串,insert,Dict,ZipList,QuickList,SkipList,RedisObject)
Redis系列学习文章分享---第十六篇(Redis原理1篇--Redis数据结构-动态字符串,insert,Dict,ZipList,QuickList,SkipList,RedisObject)
75 1
|
2月前
|
NoSQL 安全 Java
Redis6入门到实战------ 三、常用五大数据类型(字符串 String)
这篇文章深入探讨了Redis中的String数据类型,包括键操作的命令、String类型的命令使用,以及String在Redis中的内部数据结构实现。
Redis6入门到实战------ 三、常用五大数据类型(字符串 String)
|
2月前
|
C# 开发者 UED
WPF开发者必备秘籍:深度解析文件对话框使用技巧,打开与保存文件原来如此简单!
【8月更文挑战第31天】在WPF应用开发中,文件操作是常见需求。本文详细介绍了如何利用`Microsoft.Win32`命名空间下的`OpenFileDialog`和`SaveFileDialog`类来正确实现文件打开与保存功能。通过示例代码展示了如何设置文件过滤器、初始目录等属性,并使用对话框进行文件读写操作。正确使用文件对话框能显著提升用户体验,使应用更友好易用。
51 0
|
2月前
|
存储 NoSQL Redis
【Redis 探秘】SDS 简单动态字符串:揭秘 Redis 高效字符串处理的秘密武器!
【8月更文挑战第24天】Redis采用的简单动态字符串(SDS)是一种专为优化内存存储和字符串操作而设计的数据结构。相较于C语言的标准字符串,SDS改进了字符串长度计算、内存重分配及字符串比较等问题。其特性包括预分配冗余空间减少未来的内存重分配、显式存储长度以加快获取速度等。这些改进使Redis能更高效地管理字符串数据。例如,在Redis中,SDS被广泛应用于键值对的存储,显著提升了字符串操作的性能。了解SDS不仅对于深入理解Redis的工作原理至关重要,也是开发者技能树中的重要一环。
30 0
|
2月前
|
存储 JSON NoSQL
揭秘Redis字符串String的隐藏技能!从基础到进阶,让你的数据存储操作秒变高大上!
【8月更文挑战第24天】Redis中的字符串类型作为其基石,不仅能够存储从简单文本到复杂格式如JSON的各种数据,还能通过多样化的命令实现包括但不限于自增自减、设置过期时间等高级功能,极大提升了其实用性和灵活性。例如,使用`SET`命令可添加或更新键值对,`GET`获取值,`DEL`删除键;同时,`INCR`和`DECR`支持对整数值的原子性增减操作,非常适合实现计数器等功能;通过`EXPIRE`命令设置过期时间,则适用于需要限时存储的应用场景。尽管名为“字符串”,但实际上还可存储图片、音频文件的Base64编码等形式的数据,为开发者提供了强大而灵活的工具。
37 0
|
2月前
|
NoSQL Java Redis
Redis字符串数据类型之INCR命令,通常用于统计网站访问量,文章访问量,实现分布式锁
这篇文章详细解释了Redis的INCR命令,它用于将键的值增加1,通常用于统计网站访问量、文章访问量,以及实现分布式锁,同时提供了Java代码示例和分布式锁的实现思路。
48 0