34-源码 1:丝分缕析 —— 探索「字符串」内部
课程
1
开篇:授人以鱼不若授人以渔 —— Redis 可以用来做什么?
学习时长: 5分21秒
2
基础:万丈高楼平地起 —— Redis 基础数据结构
上次学到
学习时长: 16分14秒
3
应用 1:千帆竞发 —— 分布式锁
学习时长: 7分47秒
4
应用 2:缓兵之计 —— 延时队列
学习时长: 8分9秒
5
应用 3:节衣缩食 —— 位图
学习时长: 8分52秒
6
应用 4:四两拨千斤 —— HyperLogLog
学习时长: 14分17秒
7
应用 5:层峦叠嶂 —— 布隆过滤器
学习时长: 17分54秒
8
应用 6:断尾求生 —— 简单限流
学习时长: 4分37秒
9
应用 7:一毛不拔 —— 漏斗限流
学习时长: 7分22秒
10
应用 8:近水楼台 —— GeoHash
学习时长: 7分52秒
11
应用 9:大海捞针 —— Scan
学习时长: 8分42秒
12
原理 1:鞭辟入里 —— 线程 IO 模型
学习时长: 4分1秒
13
原理 2:交头接耳 —— 通信协议
学习时长: 3分34秒
14
原理 3:未雨绸缪 —— 持久化
学习时长: 5分27秒
15
原理 4:雷厉风行 —— 管道
学习时长: 3分51秒
16
原理 5:同舟共济 —— 事务
学习时长: 6分36秒
17
原理 6:小道消息 —— PubSub
学习时长: 7分7秒
18
原理 7:开源节流 —— 小对象压缩
学习时长: 7分14秒
19
原理 8:有备无患 —— 主从同步
学习时长: 4分9秒
20
集群 1:李代桃僵 —— Sentinel
学习时长: 3分52秒
21
集群 2:分而治之 —— Codis
学习时长: 7分28秒
22
集群 3:众志成城 —— Cluster
学习时长: 8分38秒
23
拓展 1:耳听八方 —— Stream
学习时长: 13分40秒
24
拓展 2:无所不知 —— Info 指令
学习时长: 4分4秒
25
拓展 3:拾遗补漏 —— 再谈分布式锁
学习时长: 2分18秒
26
拓展 4:朝生暮死 —— 过期策略
学习时长: 2分21秒
27
拓展 5:优胜劣汰 —— LRU
学习时长: 4分34秒
28
拓展 6:平波缓进 —— 懒惰删除
学习时长: 2分13秒
29
拓展 7:妙手仁心 —— 优雅地使用 Jedis
学习时长: 6分35秒
30
拓展 8:居安思危 —— 保护 Redis
学习时长: 2分19秒
31
拓展 9:隔墙有耳 —— Redis 安全通信
学习时长: 6分34秒
32
拓展 10:法力无边 —— Redis Lua 脚本执行原理
学习时长: 9分24秒
33
拓展 11:短小精悍 —— 命令行工具的妙用
学习时长: 9分21秒
34
源码 1:丝分缕析 —— 探索「字符串」内部
学习时长: 5分20秒
35
源码 2:循序渐进 —— 探索「字典」内部
学习时长: 7分24秒
36
源码 3:挨肩迭背 —— 探索「压缩列表」内部
学习时长: 10分42秒
37
源码 4:风驰电掣 —— 探索「快速列表」内部
学习时长: 3分49秒
38
源码 5:凌波微步 —— 探索「跳跃列表」内部
学习时长: 9分57秒
39
源码 6:破旧立新 —— 探索「紧凑列表」内部
学习时长: 2分42秒
40
源码 7:金枝玉叶 —— 探索「基数树」内部
学习时长: 5分36秒
41
源码 8:精益求精 —— LFU vs LRU
学习时长: 8分4秒
42
源码 9:如履薄冰 —— 懒惰删除的巨大牺牲
学习时长: 9分53秒
43
源码 10:跋山涉水 —— 深入字典遍历
学习时长: 9分24秒
44
源码 11:见缝插针 —— 探索 HyperLogLog 内部
学习时长: 13分3秒
45
尾声:百尺竿头 —— 继续深造指南
学习时长: 2分32秒
juejin_logo copyCreated with Sketch.

源码 1:丝分缕析 —— 探索「字符串」内部结构

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 里面存储了真正的字符串内容,那 capacitylen 表示什么意思呢?它有点类似于 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 呢,这是因为当字符串比较短时,lencapacity 可以使用 byteshort 来表示,Redis 为了对内存做极致的优化,不同长度的字符串使用不同的结构体来表示。

Redis 规定字符串的长度不得超过 512M 字节。创建字符串时 lencapacity 一样长,不会多分配冗余空间,这是因为绝大多数场景下我们不会使用 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 大小的冗余空间。

思考

什么场合下会用到字符串的 append 方法?

留言
Ctrl + Enter
全部评论(81)
用户9235491143990的头像
删除
redis新版本sds的设计中,取消了内存对齐,想了解下这样做的背景和原因是什么呢
点赞
回复
BinK_1783的头像
删除
穿喜欢的衣服,和不累的人相处
点赞
回复
Sampson的头像
删除
Java @ 某电商
既然是以/0结尾,那跟C的/null结尾有什么区别吗
点赞
回复
NerD92899的头像
删除
范型是作者抽象出来的,实际Redis源码中是动态的从无符号的char/short/int/longlong中选择使用
3
回复
飞翔那一刻的头像
删除
字节面试问我redis字符串的底层存储使用C++的原始数组还是什么结构😂
1
回复
PythonAV的头像
删除
Python开发工程师
看了评论真是扣根问底啊 ,又些问题你问作者也是,王八的屁股,规定。
点赞
回复
特仑苏凉茶的头像
删除
Java
纠正个小错误:是embedded而非embeded
1
1
删除
#define OBJ_ENCODING_EMBSTR 8 /* Embedded sds string encoding */
点赞
回复
innocent_huang的头像
删除
泥工/瓦工/木工/钢筋工/全栈 @ 建筑公司
C语言泛型?
3
回复
引力波的头像
删除
一读就懂,一问就懵
2
回复
LeGrandmechantRenard的头像
删除
想吃软饭 @ 某支付公司
blog.csdn.net
3.0版本和3.2版本之间把39字节改为了44字节。
点赞
1
删除
具体版本是3.2
1
回复
Theodore君的头像
删除
bug工程师 @ 。
c的string是以\0结尾的
1
回复
cwlopq的头像
删除
redis版本4.0.14,无论初始set字符串长度多短,只要append操作,无论追加字符串多短,embstr就会变成raw类型,这个似乎已经跟embstr的最大容纳长度没关系了,为什么会直接变成raw类型?文章中描述这个特性
点赞
1
删除
因为长度增加 数组位置会发生变化 最初的cap和len是一样的
点赞
回复
zyfsuzy的头像
删除
研发工程师 @ 百度
总结一下:
redis 中 字符串都使用 字节数组存储,但存储形式有两种,emb 和 raw 形式。
emb 适用于字符串长度较短,raw 适用于长字符串。具体表现于 redisObject 对象的 encoding 属性。
2
回复
man1s的头像
删除
java
SSD对象的长度和容量是int8,无符号最大256,是怎么表示512M的字符串的
1
2
删除
看了源码,老钱这个是简版sds数据结构,sds分为sdshdr5,sdshdr8,sdshdr16,sdshdr32,sdshdr64
点赞
回复
删除
sdshdr5,sdshdr8,sdshdr16,sdshdr32,sdshdr64是redis5的
点赞
回复
sidfate的头像
删除
Fuck Stack @ Alibaba
【如果总体超出了 64 字节,Redis 认为它是一个大字符串,不再使用 emdstr 形式存储,而该用 raw 形式。】 --> 为什么是64字节
点赞
2
删除
jemalloc/tcmalloc是按2^n来分配的。我猜想如果分配32,可以容纳的字符就太少了,分配128又太浪费。
点赞
回复
删除
代码逻辑
点赞
回复
benymor的头像
删除
golang
embstr和raw的优缺点不讲下吗?
2
回复
Amos同志55677的头像
删除
为什么raw是调用两次malloc,可能是多次呀
点赞
回复
奶瓶007的头像
删除
老钱说的应该是redis4.0版本的String会以44字节为一个节点,3.0我测试的是40字节为一个节点! 可能是俩个版本的Redis对象头结构或者SDS的结构有变动导致的吧! 能力有限,原因只能靠猜测了....
点赞
回复
古城鞠的头像
删除
遇到个问题:往redis里扔字符串,因为字符串比较大,就压缩了一下再扔,结果发现压缩后比压缩前,redis占用空间反而大了一倍。什么情况?
1
回复
新佑衛門的头像
删除
建议有些没回答的思考题可以在下课给出参考答案 等等
3
回复

查看全部 81 条回复