Redis对象机制
一、 引入:从哪里开始学习底层?
要深入理解 Redis 的数据类型(String, List, Hash, Set, ZSet, Stream 等),就必须了解其底层的实现机制。
Redis 的数据类型实现可以分为两个层次:
- 对象层:统一的对象系统,即
redisObject。 - 数据结构层:
redisObject内部指向的、用于实际存储数据的编码和底层数据结构。
核心关系:Redis 的每一种对外暴露的数据类型,都是由一个 redisObject 结构和其内部指针 ptr 指向的特定 编码(Encoding) 的数据结构组合而成。同一种数据类型在不同场景下可能会使用不同的编码方式以优化性能和内存。
二、 为什么 Redis 要设计 redisObject 对象?
设计 redisObject 主要是为了解决两个核心问题:
-
类型检查:Redis 是强类型数据库,不同数据类型的键能执行的命令不同(如
LPUSH只能用于 List)。Redis 需要一个机制来识别每个键的类型,以便允许或拒绝特定命令的执行。redisObject中的type字段就承担了这个角色。 -
多态(Polymorphism):同一种数据类型可能有多种底层实现(即编码)。例如,一个集合(Set)可以用
intset(整数集合) 或hashtable(哈希表) 实现。用户执行SADD命令时,无需关心底层是哪种编码,Redis 应该能自动选择正确的函数来操作对应的数据结构。redisObject中的encoding字段使得这种多态成为可能。
总结来说,redisObject 系统提供了:
- 统一的对象表示 (
redisObject)。 - 基于
type属性的类型检查。 - 基于
encoding属性的命令多态。 - 统一的内存管理机制(分配、共享、销毁)。
三、 redisObject 数据结构
redisObject 是 Redis 类型系统的核心,数据库中所有的键和值都由这个结构体表示。
结构体定义 (robj):
typedef struct redisObject {
unsigned type:4; // 对象类型 (4位)
unsigned encoding:4; // 编码方式 (4位)
unsigned lru:24; // LRU/LFU 数据 (24位)
int refcount; // 引用计数
void *ptr; // 指向底层数据结构的指针
} robj;
各字段详解:
-
type(4位):记录对象的类型,是以下常量之一:OBJ_STRING(字符串)OBJ_LIST(列表)OBJ_SET(集合)OBJ_ZSET(有序集合)OBJ_HASH(哈希)- (还有
OBJ_MODULE,OBJ_STREAM等)
-
encoding(4位):记录ptr指针指向的数据结构的编码方式,是以下常量之一:OBJ_ENCODING_RAW: 原始 SDS 字符串OBJ_ENCODING_INT: 64位有符号整数OBJ_ENCODING_EMBSTR: 嵌入式 SDS 字符串 (短字符串优化)OBJ_ENCODING_HT: 哈希表 (dict)OBJ_ENCODING_ZIPLIST: 压缩列表OBJ_ENCODING_INTSET: 整数集合OBJ_ENCODING_SKIPLIST: 跳表和哈希表OBJ_ENCODING_QUICKLIST: 快速列表OBJ_ENCODING_STREAM: 基数树 (Radix Tree)- 注意:
OBJ_ENCODING_ZIPMAP和OBJ_ENCODING_LINKEDLIST已被废弃。
-
ptr:一个void指针,指向真正存储数据的底层数据结构实例。例如,如果type是OBJ_LIST,encoding是OBJ_ENCODING_QUICKLIST,那么ptr就指向一个quicklist结构。 -
lru(24位):- 记录对象最后一次被访问的时间戳。
- 用于内存淘汰策略:当 Redis 内存达到
maxmemory限制且淘汰策略为volatile-lru或allkeys-lru时,会优先淘汰lru值(即空转时间)较大的对象。 OBJECT IDLETIME <key>命令可以查看键的空转时长。
-
refcount:- 引用计数。用于实现对象的共享和自动内存回收。
四、 命令的类型检查和多态
当 Redis 执行一个命令时(如 LPOP mylist),遵循以下步骤:
- 查找对象:根据键名
mylist在数据库中查找对应的redisObject。 - 类型检查:检查
redisObject->type是否为OBJ_LIST。如果不是,则返回类型错误。 - 选择实现:根据
redisObject->encoding(比如是OBJ_ENCODING_QUICKLIST)选择并调用针对该编码的特定操作函数。 - 执行并返回:执行函数,操作
ptr指向的quicklist结构,并返回结果。
五、 对象共享
为了节约内存,Redis 会创建一些常用的对象并进行共享,而不是每次都重新创建。
-
共享范围:
- 常用命令回复:如成功时返回的
(shared.ok),错误时返回的(shared.err)等。 - 整数对象:在默认配置下,Redis 会预先创建并共享 0 到 9999 的所有整数对象。当一个字符串可以被解释为这个范围内的整数时,Redis 会直接返回共享对象的指针。
- 常用命令回复:如成功时返回的
-
为什么只共享字符串/整数对象,不共享复杂对象(List, Hash等)?
- 共享对象的前提是验证待创建的对象是否和共享对象完全一致。
- 验证复杂度:
- 验证一个整数值是否在共享范围内,时间复杂度为 O(1)。
- 验证一个字符串值是否与共享对象相等,需要逐字符比较,时间复杂度为 O(N),其中 N 是字符串长度。
- 验证一个复杂对象(如 List)是否与共享对象相等,需要遍历其所有元素并逐一比较,复杂度会上升到 O(N^2) 或更高。
- 权衡:对于复杂对象,验证共享所消耗的 CPU 成本远大于节省下来的内存空间,得不偿失。因此 Redis 只对验证成本低的整数和常用回复字符串进行共享。
六、 引用计数以及对象的销毁
Redis 使用引用计数(refcount)机制来实现内存的自动回收,避免了垃圾回收(GC)带来的停顿。
- 工作机制:
- 创建对象:当一个新对象被创建时,其
refcount初始化为 1。 - 增加引用:当一个对象被一个新的指针引用(例如,被共享)时,其
refcount加 1。 - 减少引用:当一个指向对象的引用被移除时(例如,一个键被删除或被新值覆盖),其
refcount减 1。 - 释放对象:当对象的
refcount变为 0 时,表示没有任何地方引用该对象,程序会立即释放这个redisObject结构及其ptr指向的底层数据结构所占用的内存。
- 创建对象:当一个新对象被创建时,其
核心:RedisObject 结构体
在 Redis 内部,每一个键值对的“值”(Value)都是一个 redisObject 结构体。我们可以把它想象成一个“容器”或者“包装盒”,里面包含了数据的各种元信息。
一个简化的 redisObject 结构体包含以下几个关键成员:
-
type (类型)
-
这个字段记录了该对象对外表现出的数据类型。
-
它对应我们平时使用的 Redis 命令,如
TYPE命令返回的就是这个值。 -
主要的类型有:
OBJ_STRING(字符串)、OBJ_LIST(列表)、OBJ_HASH(哈希)、OBJ_SET(集合)、OBJ_ZSET(有序集合)。
-
-
encoding (编码)
-
这是 Redis 对象机制的精髓所在。它记录了该对象在内存中实际的存储方式。
-
同一种
type的对象,可能会有多种不同的encoding方式,以此来在不同场景下优化内存使用和执行效率。 -
比如,一个列表(type=OBJ_LIST)在元素较少时,可能会用一块连续的内存(ziplist 编码)来存储,以节省空间;当元素变多时,则会自动转换为双向链表(linkedlist 编码)来保证操作效率。
-
-
ptr (指针)
-
一个
void*指针,指向对象在内存中实际的数据结构。 -
例如,如果一个对象的
encoding是“hashtable”,那么这个ptr就指向一个哈希表的结构体。如果encoding是“int”,这个ptr就直接存储那个整数值。
-
-
refcount (引用计数)
-
这是一个用于内存回收的字段,实现了引用计数机制。
-
当一个对象被一个新的键引用时,其
refcount会加 1。 -
当引用这个对象的键被删除时,其
refcount会减 1。 -
当
refcount变为 0 时,说明没有任何键引用该对象了,Redis 就会释放这个对象所占用的内存。
-
-
lru (最后访问时间)
-
这个字段记录了对象最后一次被命令访问的时间。
-
当 Redis 内存不足需要淘汰数据时,这个
lru字段就为 LRU (Least Recently Used) 或 LFU (Least Frequently Used) 等淘汰策略提供了决策依据。
-
Type 与 Encoding 的关系:一对多
Redis 对象机制的核心就是通过“类型”和“编码”的映射,实现了动态的内部表示。一种对外类型,可以对应多种内部编码。
下面是各种主要类型的编码方式:
1. String (字符串)
-
encoding: int:当字符串值可以被表示为一个 64 位有符号整数时,Redis 会使用这种编码。它直接将整数值保存在ptr指针里,不额外分配内存,极大地节省了空间。 -
encoding: embstr:当字符串长度很短(默认小于 44 字节)时使用。它会将redisObject结构体和字符串内容分配在一块连续的内存中。这样做的好处是只需要一次内存分配,并且数据局部性更好,访问效率高。 -
encoding: raw:当字符串长度较长时使用。它会为字符串内容单独分配一块内存,并通过ptr指针指向它。这就是 Redis 的动态字符串(SDS)。
2. List (列表)
-
encoding: ziplist:当列表的元素数量较少且每个元素的大小也较小时使用。ziplist是一块经过特殊编码的连续内存,非常紧凑,但插入和删除操作可能会引发连锁更新,效率较低。 -
encoding: linkedlist:当列表不满足ziplist的条件时,会转换为标准的双向链表。它在插入和删除节点时效率很高(O(1)),但每个节点都需要额外的指针开销,内存占用较大。 -
(注意:在较新版本的 Redis 中,
linkedlist已被quicklist取代。quicklist是一个由ziplist组成的双向链表,是两者的混合体,兼顾了空间和效率。)
3. Hash (哈希)
-
encoding: ziplist:当哈希的键值对数量较少且键和值的大小也较小时使用。 -
encoding: hashtable:当哈希不满足ziplist的条件时,会转换为标准的哈希表结构。
4. Set (集合)
-
encoding: intset:当集合中的所有元素都是整数时使用。intset是一个紧凑的、有序的整数数组,非常节省内存。 -
encoding: hashtable:当集合中出现非整数元素,或元素数量超过一定阈值时,会转换为标准的哈希表。
5. ZSet (有序集合)
-
encoding: ziplist:当有序集合的元素数量和大小都较小时使用。每个元素(member 和 score)在ziplist中相邻存储。 -
encoding: skiplist:当不满足ziplist条件时,会转换为跳跃表(skiplist)。实际上它是一个复合结构,同时包含一个哈希表和一个跳跃表。哈希表用于 O(1) 复杂度查找成员的分数(score),跳跃表用于 O(logN) 复杂度的范围查找和排序操作。
编码的自动转换
Redis 的编码转换是自动且单向的。当一个对象不再满足某种紧凑编码的条件时,Redis 会自动将其转换为更通用的编码。
-
触发条件:比如向一个
intset编码的集合中添加一个字符串元素,或者一个ziplist编码的列表长度超过了配置的阈值。 -
转换方向:转换永远是从特殊、紧凑的编码转向通用、复杂的编码(例如
intset->hashtable,ziplist->linkedlist/hashtable)。一旦转换,就无法再变回去。
内存管理:对象共享
为了进一步节省内存,Redis 还实现了对象共享机制。
在 Redis 启动时,会预先创建 10000 个(默认 0 到 9999)字符串对象,这些对象包含从 0 到 9999 的整数值。当程序中需要使用这些数值作为字符串值时(例如 SET mykey 100),Redis 不会创建新对象,而是直接让键的 redisObject 指针指向共享对象,并将其 refcount 加 1。
这就是为什么当多个键存储相同的常用小整数时,它们在内部实际上共享着同一个物理对象,从而大大节省了内存。
总结
-
统一封装:Redis 不直接存储数据,而是使用
redisObject对所有值进行封装,方便统一管理。 -
类型与编码分离:
type定义了对象的逻辑行为(对外命令),encoding决定了对象的物理存储,这是优化的核心。 -
智能编码:Redis 会根据数据的大小和类型自动选择最节省空间的编码方式。
-
动态转换:在需要时,编码会自动从紧凑型向通用型转换,以适应数据的增长。
-
高效内存回收:通过引用计数 (
refcount) 实现对象的生命周期管理和内存回收。 -
内存共享:通过共享常用的小整数对象,进一步减少内存开销。
这个精巧的对象系统是 Redis 能够兼具高性能和高内存效率的关键原因之一。