当人们初次接触 Redis 时,String 类型往往是他们认识的第一个数据结构。SET key valueGET 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)。这对于频繁获取长度的场景是巨大的性能提升。
  • 杜绝缓冲区溢出 (Buffer Overflow)

    • C 字符串: strcat 等函数不会检查目标数组的剩余空间,极易造成缓冲区溢出,这是一个严重的安全漏洞。
    • Redis SDS: 当对 SDS 进行修改时 (如 APPEND),API 会先检查其内部记录的剩余空间 (free 字段) 是否足够。如果不够,它会先扩展内存空间,然后再执行修改。这从根本上杜绝了溢出的可能性。
  • 二进制安全 (Binary Safe)

    • C 字符串: 由于以 \0 作为结尾标识,它不能存储任何包含 \0 的数据,比如图片、音频或 Protobuf 序列化后的数据。
    • Redis SDS: 它通过 len 字段来判断字符串的实际结尾,而非特殊字符。因此,你可以将任何字节流存入 SDS,真正做到了二进制安全。
  • 空间预分配与惰性释放

    为了避免每次追加操作都重新分配内存 (这是一个耗时的系统调用),SDS 采用了一种智能的内存分配策略:

    • 空间预分配: 当对 SDS 进行扩展时,它会分配比实际需要更多的空间。如果修改后 SDS 的长度 len 小于 1MB,则会额外分配与 len 相同的空间 (即 free = len)。如果 len 超过 1MB,则会额外分配固定的 1MB 空间。这大大减少了连续增长字符串时的内存重分配次数。
    • 惰性空间释放: 当缩短 SDS 字符串时,程序并不会立即将多余的内存交还给操作系统,而是通过更新 free 字段来记录这些空闲空间,以备未来的增长操作使用。

为了将内存优化到极致,SDS 的设计者并未采用"一刀切"的头部结构,而是实现了一套"量体裁衣"的方案。它根据字符串的长度,动态选择不同大小的头部结构,以求用最少的元数据开销来管理字符串。下面是 Redis 源码中 sds.h 的核心定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* Note: sdshdr5 is never used, we just access the flags byte directly.
* However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) hisdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) hisdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) hisdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) hisdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) hisdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};

相信当看到 sdshdr5sdshdr64 这一系列结构的时候,不少读者要问一个问题:为什么需要这么多不同的头结构 (header)?

答案根植于一个核心的权衡:用最少的元数据 (metadata) 开销来管理任意长度的字符串。如果只有一个能容纳 64 位长度的巨大头部,那么当我们存储大量只有几个字节的短字符串时,头部本身(17 字节)的开销将远大于数据本身,这会造成巨大的内存浪费。

因此,Redis 的设计者采取了分类处理的策略:根据字符串的长度,为其选择一个大小恰到好处的头部结构。

在深入看差异之前,我们先看所有结构(除 sdshdr5 外)都包含的四个关键成员:

  • len: 一个无符号整数,记录了 buf 数组中当前已使用的字节数,即字符串的实际长度。这是实现 O(1) 复杂度获取字符串长度的关键。
  • alloc: 一个无符号整数,记录了为 buf 数组分配的总字节数,不包括头部自身和末尾的空字符 \0alloc - len 就是预留的空闲空间,用于高效的 APPEND 操作。
  • flags: 一个 8 位的无符号字符。其中,低 3 位 (LSB) 用来存储 SDS 的类型编码 (Type)。例如,SDS_TYPE_8 对应 sdshdr8SDS_TYPE_16 对应 sdshdr16 等。SDS 的函数库通过读取这个 flags 字段,就能知道当前处理的是哪种类型的 SDS header,从而正确地解析出 lenalloc
  • 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 字段。例如,对于 sdshdr8len 字段就在 buf 指针的前 3 个字节处。如果编译器进行了填充,这个固定的偏移量就会失效。__packed__ 确保了内存布局的紧凑和可预测性,让这种指针运算成为可能。

现在我们来看每个结构的具体用途:

  • struct sdshdr5
    • 超级优化: 这是一个极端的优化,用于存储极短的字符串。它没有独立的 lenalloc 字段。整个头部只有一个 flags 字节。
    • 位域技巧: 这个字节被拆分使用:低 3 位存类型,高 5 位存长度。因此,sdshdr5 最多能表示的长度是 25−1=31。由于没有 alloc 字段,这种类型的字符串是只读的,任何修改都会导致其被转换成其他 SDS 类型。
  • struct sdshdr8
    • 头部大小: len(1 byte) + alloc(1 byte) + flags(1 byte) = 3 字节
    • 容量: lenuint8_t,最大可以表示的长度是 28−1=255 字节。
    • 场景: 适用于存储长度在 32 到 255 字节之间的短字符串。
  • struct sdshdr16
    • 头部大小: len(2 bytes) + alloc(2 bytes) + flags(1 byte) = 5 字节
    • 容量: lenuint16_t,最大可以表示的长度是 216−1=65,535 字节 (64 KB)。
    • 场景: 适用于中等长度的字符串。
  • struct sdshdr32
    • 头部大小: len(4 bytes) + alloc(4 bytes) + flags(1 byte) = 9 字节
    • 容量: lenuint32_t,最大可以表示的长度是 232−1≈4 GB。
    • 场景: 适用于非常长的字符串。
  • struct sdshdr64
    • 头部大小: len(8 bytes) + alloc(8 bytes) + flags(1 byte) = 17 字节
    • 容量: lenuint64_t,理论上可以表示巨大无比的字符串,但受限于 Redis String 最大 512 MB 的设计约束。
    • 场景: 用于需要超过 4GB 长度的场景(尽管在 Redis 的实际使用中很少见)。

这段代码看似简单,却蕴含了 Redis 设计者对 C 语言、内存布局和 CPU 工作的深刻理解。它告诉我们:

  1. 没有银弹: 针对不同规模的问题,采用不同的解决方案。SDS 通过类型的划分,实现了在不同长度字符串下的最优内存开销。
  2. 深入硬件: 了解内存对齐、CPU 缓存等底层机制,可以写出性能更高的代码。__packed__ 和柔性数组成员的使用就是明证。
  3. 动态适应: Redis 的 SDS 库是智能的。当你创建一个短字符串时,它会使用 sdshdr8。如果你不断 APPEND 内容,一旦长度超过 255,SDS 库会自动进行内存重分配,并将头部升级为 sdshdr16,这个过程对用户完全透明。

2. 动态之舞:三种编码的智能平衡术

如果说 SDS 是坚实的地基,那么智能编码体系就是其上灵动的舞者。Redis 对外暴露了统一的 String 接口,但对内,它会根据数据的实际特征,悄悄地为其选择最优的编码格式。

这种设计的核心,是为了解决通用性与专用性的矛盾。一个通用的字符串结构无法对纯数字这类特殊场景进行优化。为此,Redis 准备了三套“服装”:int, embstr, raw

让我们从第一性原理出发,探寻这背后的设计动机。

核心矛盾:通用性 vs. 专用性

首先,Redis 作为一个键值数据库,它的 Value 必须具备通用性。这意味着它应该能存储任何东西,从数字 123 到字符串 "hello world",再到一段复杂的二进制数据。从这个角度看,将所有东西都视为字节序列(字符串)是最简单、最通用的做法。

然而,如果真的将所有东西都存为普通字符串,就会遇到效率瓶颈

  1. 内存浪费: 存储数字 100,如果用字符串 "100" 形式,需要 3 个字节。如果用一个 64 位整型 (long) 存储,虽然会占用 8 个字节,但 Redis 有更巧妙的方法来优化它。更重要的是,频繁创建和销毁大量小字符串对象,其元数据开销和内存碎片不容忽视。
  2. 计算低效: 如果你想对存储的数字 "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 不再分配额外的内存去存储数据,而是直接将这个整数值存放在了 redisObjectptr 指针所占用的 8 字节空间里

这样有 2 个好处:

  1. 零内存开销: 除了 redisObject 结构本身的开销外,数据存储的额外开销为 0。
  2. 极致计算性能: 执行 INCR/DECR 等命令时,CPU 可以直接在内存中进行原生整数运算,无需任何类型转换,速度快如闪电。

OBJ_ENCODING_EMBSTR:为短字符串设计的"快车道"

解决大量短字符串带来的内存分配开销和内存碎片问题。

当我们存储一个较长的字符串时,通常需要两次内存分配:一次为 redisObject 结构分配,另一次为 sds 结构(包含头部和数据本身)分配。这两块内存通常是不连续的。

对于短字符串(在较新版本中是长度 <= 44 字节),Redis 认为两次分配过于浪费。于是 embstr 编码应运而生。它只进行一次内存分配,申请一块连续的内存空间,同时容纳 redisObject 的元信息和 sds 的实际数据

Redis String embstr 和 raw 编码内存布局对比

这样有 2 个好处:

  1. 减少分配次数: 创建和销毁 embstr 只需要一次 malloc/free,降低了管理开销。
  2. 提升缓存效率 (Cache Locality): 这是最重要的优势。CPU 从内存读取数据时,不是一个字节一个字节地读,而是按缓存行 (Cache Line)(通常是 64 字节)读取。由于 redisObject 和字符串数据是连续的,当访问 redisObject 时,字符串数据很可能已经被一同加载到了高速的 CPU 缓存中。下次再访问字符串数据时,就能直接从缓存命中,避免了访问慢速主存的延迟。

注意:embstr 编码的字符串是只读的。一旦你尝试修改它(例如 APPEND),Redis 会立即将其转换为 raw 编码,因为无法在原有的连续内存块上进行原地扩容。

OBJ_ENCODING_RAW:通用且灵活的"标准模式"

作为最通用的编码,处理所有长字符串和被修改过的短字符串。

这是标准的 SDS 实现,redisObjectsds 结构通过指针关联,分别位于不同的内存区域。

由于数据区 (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 字节的内存块,需要容纳:

  1. redisObject 结构体:16 字节
  2. sdshdr8 头部(短字符串使用的最小 SDS 头):3 字节
  3. SDS 结尾的空字符 \01 字节
  4. 字符串实际内容(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):通过 SETBITBITCOUNT,以极小的空间成本实现用户签到、日活统计等功能。
  • 共享会话:在分布式应用中存储用户 Session,简单高效。