当人们初次接触 Redis 时,String
类型往往是他们认识的第一个数据结构。SET key value
,GET key
,简单直观,易于上手。很多人因此认为,Redis
String
就是一个朴素的字符串键值对。然而,这个看似简单的表面之下,隐藏着一个由精妙设计、极致优化和深刻权衡构建起来的微观世界。
这篇文章将基于 Redis 8.2.1 带领你进行一次深度探索。我们不满足于"是什么",而是要从计算机科学的第一性原理出发,去探寻"为什么这么设计"。读完本文,你将理解 Redis String 并不仅仅是一种数据类型,它更是整个 Redis 设计哲学的完美缩影。
首先,我们下一个结论:Redis 的 String 是一个可以存储字符串、整数、浮点数乃至二进制数据 (如图片或序列化的对象) 的数据类型,其最大容量为 512 MB。它是 Redis 所有数据结构中最基础的一种,像 Hash、List 等结构的底层实现也大量用到了它。
1. 地基之下:Redis 为何要重新发明字符串?
在 C 语言中,字符串是以空字符 \0
结尾的字符数组。它简单,但也带来了诸多限制和风险。Redis
的缔造者并没有选择直接使用它,而是从零开始构建了一个名为 SDS
(Simple Dynamic String) 的结构。
SDS 的设计解决了 C 字符串的以下痛点:
获取长度的时间复杂度
- C 字符串: 必须遍历整个字符串直到遇到
\0
,时间复杂度为 O(N)。当字符串很长时,这是一个昂贵的操作。 - Redis SDS: 结构中直接包含一个
len
字段来记录当前长度,因此获取长度的时间复杂度是 O(1)。这对于频繁获取长度的场景是巨大的性能提升。
- C 字符串: 必须遍历整个字符串直到遇到
杜绝缓冲区溢出 (Buffer Overflow)
- C 字符串:
strcat
等函数不会检查目标数组的剩余空间,极易造成缓冲区溢出,这是一个严重的安全漏洞。 - Redis SDS: 当对 SDS 进行修改时 (如
APPEND
),API 会先检查其内部记录的剩余空间 (free
字段) 是否足够。如果不够,它会先扩展内存空间,然后再执行修改。这从根本上杜绝了溢出的可能性。
- C 字符串:
二进制安全 (Binary Safe)
- C 字符串: 由于以
\0
作为结尾标识,它不能存储任何包含\0
的数据,比如图片、音频或 Protobuf 序列化后的数据。 - Redis SDS: 它通过
len
字段来判断字符串的实际结尾,而非特殊字符。因此,你可以将任何字节流存入 SDS,真正做到了二进制安全。
- C 字符串: 由于以
空间预分配与惰性释放
为了避免每次追加操作都重新分配内存 (这是一个耗时的系统调用),SDS 采用了一种智能的内存分配策略:
- 空间预分配: 当对 SDS
进行扩展时,它会分配比实际需要更多的空间。如果修改后 SDS 的长度
len
小于 1MB,则会额外分配与len
相同的空间 (即free = len
)。如果len
超过 1MB,则会额外分配固定的 1MB 空间。这大大减少了连续增长字符串时的内存重分配次数。 - 惰性空间释放: 当缩短 SDS
字符串时,程序并不会立即将多余的内存交还给操作系统,而是通过更新
free
字段来记录这些空闲空间,以备未来的增长操作使用。
- 空间预分配: 当对 SDS
进行扩展时,它会分配比实际需要更多的空间。如果修改后 SDS 的长度
为了将内存优化到极致,SDS 的设计者并未采用"一刀切"的头部结构,而是实现了一套"量体裁衣"的方案。它根据字符串的长度,动态选择不同大小的头部结构,以求用最少的元数据开销来管理字符串。下面是 Redis 源码中 sds.h 的核心定义:
1 | /* Note: sdshdr5 is never used, we just access the flags byte directly. |
相信当看到 sdshdr5
到 sdshdr64
这一系列结构的时候,不少读者要问一个问题:为什么需要这么多不同的头结构
(header)?
答案根植于一个核心的权衡:用最少的元数据 (metadata) 开销来管理任意长度的字符串。如果只有一个能容纳 64 位长度的巨大头部,那么当我们存储大量只有几个字节的短字符串时,头部本身(17 字节)的开销将远大于数据本身,这会造成巨大的内存浪费。
因此,Redis 的设计者采取了分类处理的策略:根据字符串的长度,为其选择一个大小恰到好处的头部结构。
在深入看差异之前,我们先看所有结构(除 sdshdr5
外)都包含的四个关键成员:
len
: 一个无符号整数,记录了buf
数组中当前已使用的字节数,即字符串的实际长度。这是实现 O(1) 复杂度获取字符串长度的关键。alloc
: 一个无符号整数,记录了为buf
数组分配的总字节数,不包括头部自身和末尾的空字符\0
。alloc - len
就是预留的空闲空间,用于高效的APPEND
操作。flags
: 一个 8 位的无符号字符。其中,低 3 位 (LSB) 用来存储 SDS 的类型编码 (Type)。例如,SDS_TYPE_8
对应sdshdr8
,SDS_TYPE_16
对应sdshdr16
等。SDS 的函数库通过读取这个flags
字段,就能知道当前处理的是哪种类型的 SDS header,从而正确地解析出len
和alloc
。buf[]
: 这是一个 C99 的特性,称为柔性数组成员 (Flexible Array Member)。它必须是结构的最后一个成员,并且在定义时大小为空。它的作用是,当我们为这个结构分配内存时,可以一次性分配头部和数据所需的连续内存空间。这对于提高 CPU 缓存命中率至关重要。
接下来我们来探索一下 __attribute__ ((__packed__))
的底层奥秘,这个属性是 GCC/Clang
编译器的扩展,它告诉编译器:请不要为了内存对齐 (Memory
Alignment) 而在结构成员之间添加任何填充字节 (Padding)。
现代 CPU 访问内存不是逐字节进行的,而是以字 (Word) 为单位(比如 4 字节或 8 字节)。如果一个数据结构的大小刚好是字长的整数倍,并且其成员的地址也都是字长的倍数,CPU 的访问效率最高。为此,编译器默认会在结构体成员之间插入一些空白的填充字节,以保证对齐。
Redis 的 SDS 设计依赖一个巧妙的技巧:SDS API
返回给用户的指针是 buf
的起始地址,而不是结构体的起始地址。当需要获取长度时,API
会通过这个 buf
的指针向前偏移固定的字节数来找到
len
字段。例如,对于 sdshdr8
,len
字段就在 buf
指针的前 3
个字节处。如果编译器进行了填充,这个固定的偏移量就会失效。__packed__
确保了内存布局的紧凑和可预测性,让这种指针运算成为可能。
现在我们来看每个结构的具体用途:
struct sdshdr5
- 超级优化:
这是一个极端的优化,用于存储极短的字符串。它没有独立的
len
和alloc
字段。整个头部只有一个flags
字节。 - 位域技巧: 这个字节被拆分使用:低 3 位存类型,高 5
位存长度。因此,
sdshdr5
最多能表示的长度是 25−1=31。由于没有alloc
字段,这种类型的字符串是只读的,任何修改都会导致其被转换成其他 SDS 类型。
- 超级优化:
这是一个极端的优化,用于存储极短的字符串。它没有独立的
struct sdshdr8
- 头部大小:
len
(1 byte) +alloc
(1 byte) +flags
(1 byte) = 3 字节。 - 容量:
len
是uint8_t
,最大可以表示的长度是 28−1=255 字节。 - 场景: 适用于存储长度在 32 到 255 字节之间的短字符串。
- 头部大小:
struct sdshdr16
- 头部大小:
len
(2 bytes) +alloc
(2 bytes) +flags
(1 byte) = 5 字节。 - 容量:
len
是uint16_t
,最大可以表示的长度是 216−1=65,535 字节 (64 KB)。 - 场景: 适用于中等长度的字符串。
- 头部大小:
struct sdshdr32
- 头部大小:
len
(4 bytes) +alloc
(4 bytes) +flags
(1 byte) = 9 字节。 - 容量:
len
是uint32_t
,最大可以表示的长度是 232−1≈4 GB。 - 场景: 适用于非常长的字符串。
- 头部大小:
struct sdshdr64
- 头部大小:
len
(8 bytes) +alloc
(8 bytes) +flags
(1 byte) = 17 字节。 - 容量:
len
是uint64_t
,理论上可以表示巨大无比的字符串,但受限于 Redis String 最大 512 MB 的设计约束。 - 场景: 用于需要超过 4GB 长度的场景(尽管在 Redis 的实际使用中很少见)。
- 头部大小:
这段代码看似简单,却蕴含了 Redis 设计者对 C 语言、内存布局和 CPU 工作的深刻理解。它告诉我们:
- 没有银弹: 针对不同规模的问题,采用不同的解决方案。SDS 通过类型的划分,实现了在不同长度字符串下的最优内存开销。
- 深入硬件: 了解内存对齐、CPU
缓存等底层机制,可以写出性能更高的代码。
__packed__
和柔性数组成员的使用就是明证。 - 动态适应: Redis 的 SDS
库是智能的。当你创建一个短字符串时,它会使用
sdshdr8
。如果你不断APPEND
内容,一旦长度超过 255,SDS 库会自动进行内存重分配,并将头部升级为sdshdr16
,这个过程对用户完全透明。
2. 动态之舞:三种编码的智能平衡术
如果说 SDS 是坚实的地基,那么智能编码体系就是其上灵动的舞者。Redis 对外暴露了统一的 String 接口,但对内,它会根据数据的实际特征,悄悄地为其选择最优的编码格式。
这种设计的核心,是为了解决通用性与专用性的矛盾。一个通用的字符串结构无法对纯数字这类特殊场景进行优化。为此,Redis
准备了三套“服装”:int
, embstr
,
raw
。
让我们从第一性原理出发,探寻这背后的设计动机。
核心矛盾:通用性 vs. 专用性
首先,Redis 作为一个键值数据库,它的 Value
必须具备通用性。这意味着它应该能存储任何东西,从数字
123
到字符串
"hello world"
,再到一段复杂的二进制数据。从这个角度看,将所有东西都视为字节序列(字符串)是最简单、最通用的做法。
然而,如果真的将所有东西都存为普通字符串,就会遇到效率瓶颈:
- 内存浪费: 存储数字
100
,如果用字符串"100"
形式,需要 3 个字节。如果用一个 64 位整型 (long
) 存储,虽然会占用 8 个字节,但 Redis 有更巧妙的方法来优化它。更重要的是,频繁创建和销毁大量小字符串对象,其元数据开销和内存碎片不容忽视。 - 计算低效: 如果你想对存储的数字
"100"
执行INCR
(加 1) 操作。对于字符串,CPU 需要先将"100"
转换为整数100
,然后执行加法得到101
,最后再将101
转换为字符串"101"
存回去。这个过程涉及多次类型转换,远不如直接在整数上执行一次加法指令来得快。
Redis 的智能编码体系,正是为了解决对外接口统一与对内实现高效这一核心矛盾而设计的。它让 Redis 在享受通用性带来的便利的同时,又能获得专用数据类型带来的性能和内存优势。
下面我们来逐一分析 int
, embstr
,
raw
这三种编码,看看它们分别解决了什么问题。
OBJ_ENCODING_INT:为数字而生的极致优化
解决纯数字的存储和计算效率问题。
当你 SET 一个可以被 64 位有符号整数 (long) 表示的值时,Redis
不会为其分配一个 sds 字符串结构。它会使用 int
编码。
这里的精髓在于一个极其巧妙的指针复用技巧。在 64
位系统中,一个指针变量本身会占用 8 个字节。Redis 的核心数据结构
redisObject
包含一个 void *ptr
指针,通常指向真正的数据(比如一个 sds
结构)。
Redis 的设计者发现,一个 long 类型也是 8 个字节。因此,当存储一个
long 型整数时,Redis
不再分配额外的内存去存储数据,而是直接将这个整数值存放在了
redisObject
的 ptr
指针所占用的 8
字节空间里!
这样有 2 个好处:
- 零内存开销: 除了
redisObject
结构本身的开销外,数据存储的额外开销为 0。 - 极致计算性能: 执行
INCR
/DECR
等命令时,CPU 可以直接在内存中进行原生整数运算,无需任何类型转换,速度快如闪电。
OBJ_ENCODING_EMBSTR:为短字符串设计的"快车道"
解决大量短字符串带来的内存分配开销和内存碎片问题。
当我们存储一个较长的字符串时,通常需要两次内存分配:一次为
redisObject
结构分配,另一次为 sds
结构(包含头部和数据本身)分配。这两块内存通常是不连续的。
对于短字符串(在较新版本中是长度 <= 44 字节),Redis
认为两次分配过于浪费。于是 embstr
编码应运而生。它只进行一次内存分配,申请一块连续的内存空间,同时容纳
redisObject
的元信息和 sds
的实际数据。

这样有 2 个好处:
- 减少分配次数: 创建和销毁
embstr
只需要一次malloc
/free
,降低了管理开销。 - 提升缓存效率 (Cache Locality):
这是最重要的优势。CPU
从内存读取数据时,不是一个字节一个字节地读,而是按缓存行 (Cache
Line)(通常是 64 字节)读取。由于
redisObject
和字符串数据是连续的,当访问redisObject
时,字符串数据很可能已经被一同加载到了高速的 CPU 缓存中。下次再访问字符串数据时,就能直接从缓存命中,避免了访问慢速主存的延迟。
注意:embstr
编码的字符串是只读的。一旦你尝试修改它(例如
APPEND
),Redis 会立即将其转换为 raw
编码,因为无法在原有的连续内存块上进行原地扩容。
OBJ_ENCODING_RAW:通用且灵活的"标准模式"
作为最通用的编码,处理所有长字符串和被修改过的短字符串。
这是标准的 SDS 实现,redisObject
和 sds
结构通过指针关联,分别位于不同的内存区域。
由于数据区 (sds
) 和元信息区 (redisObject
)
是分离的,当字符串需要增长时(如 APPEND
),可以独立地对
sds
进行内存重分配(realloc),而无需触动
redisObject
。这使得对长字符串的修改变得高效。
编码转换
flowchart TD A[值创建] --> B{值的类型和内容} B -->|"64位整数范围"| C[int编码] B -->|"字符串且长度 ≤ 44字节"| D[embstr编码] B -->|"字符串且长度 > 44字节"| E[raw编码] C --> F{操作类型} D --> G{操作类型} E --> H{操作类型} F -->|"数值运算 INCR/DECR"| I[保持int编码] F -->|"字符串操作 APPEND/SETRANGE"| J["int → raw转换"] G -->|"任何修改操作"| K["embstr → raw转换"] G -->|"只读操作 GET"| L[保持embstr编码] H -->|"任何操作"| M[保持raw编码] J --> N[分配raw内存] N --> O[将int转换为字符串] O --> P[存储到raw结构] P --> Q[更新redisObject.ptr] K --> R[分配raw内存] R --> S[复制字符串数据] S --> T[释放embstr内存] T --> U[更新redisObject.ptr] style C fill:#e3f2fd,stroke:#2196f3,stroke-width:2px style D fill:#e8f5e8,stroke:#28a745,stroke-width:2px style E fill:#ffebee,stroke:#d73a49,stroke-width:2px style J fill:#fff3cd,stroke:#ffc107,stroke-width:2px style K fill:#fff3cd,stroke:#ffc107,stroke-width:2px
3. 揭秘 44: 一个数字背后的硬核原理
embstr
的 44
字节限制,并非随意设定,而是精确计算的结果。
核心目标:让整个 embstr
对象正好放入内存分配器(如 jemalloc)的 64
字节内存块中,以最大化内存效率和 CPU 缓存性能。
推导过程: 一个 64 字节的内存块,需要容纳:
redisObject
结构体:16 字节sdshdr8
头部(短字符串使用的最小 SDS 头):3 字节- SDS 结尾的空字符
\0
:1 字节 - 字符串实际内容(Payload):X 字节
于是,我们得到方程:
\[ 16+3+X+1=64 \]
解得:
\[ X=44 \]
这里的 X
,也就是
44,指的是字符串内容的字节数。对于
ASCII,它等于字符数;但对于 UTF-8
等多字节编码,则必须计算其实际占用的字节。例如,15 个中文字符(占据
\(15×3=45\) 字节)的长度虽然远小于
44,但其字节数超过了限制,因此必须使用 raw
编码。
4. 回归实践:String 的真实世界
理论的深刻,最终要回归实践的价值。正是基于上述精妙设计,Redis String 才能在真实世界中扮演如此多样的角色:
- 缓存层:缓存数据库查询结果、API 响应,是其最经典的用法。
- 原子计数器:利用
INCR
的原子性,轻松实现高并发的网站 PV、文章点赞等功能。 - 分布式锁:
SET key value EX seconds NX
一行命令,是实现分布式锁的最核心逻辑。 - 位图 (Bitmap):通过
SETBIT
和BITCOUNT
,以极小的空间成本实现用户签到、日活统计等功能。 - 共享会话:在分布式应用中存储用户 Session,简单高效。