Redis 系列(三):底层实现(一)——对象系统、SDS 与 dict

核心目标 :理解 RedisObject 对象系统与 String/Hash 的底层结构;用源码与实验解释"10 字节的键为什么占 55 字节内存"、"为什么数据多了会偶发卡顿"(渐进式 rehash);掌握 OBJECT ENCODING/MEMORY USAGE 两类观察手段。

前置知识 :完成 Part 1(命令执行链路)与 Part 2(编码概念)。不要求 C 语言功底,文中结构体会逐字段解释。

验证环境 :Redis 8.10.0(cygwin 移植版,127.0.0.1:6379)、redis-py 8.1.0、Python 3.11.6、Windows 11;源码依据官方 Redis 7.4.2_refsrc/redis-7.4.2tags/7.4.2);dict 扩容实验在隔离实例 6380(已启用 DEBUG 命令)上进行。最后复核日期:2026-08-07。


0. 本篇问题场景:内存去哪了,"卡顿"从哪来

两个生产里常见的困惑:

  1. 内存账对不上 :写入 100 万个"小键",数据本身才几 MB,INFO memory 却涨了几十倍------多出来的内存是什么?
  2. 偶发卡顿INFO 一切正常,但高峰写入时命令延迟偶发飙升,过去就好了------单线程服务器上什么操作会"插队"?

两个问题的答案都在本篇:Redis 中每一个键值都有固定的"包装开销" (robj + SDS 头 + dictEntry),而哈希表扩容(rehash)会在某个瞬间把整张表复制一份。前者解释内存,后者解释卡顿。


1. RedisObject:一切值的统一包装

1.1 结构

Redis 中每一个值 (无论 String 还是集合元素)在内存里都是一个 redisObject(源码 server.h:903):

c 复制代码
struct redisObject {
    unsigned type:4;      // 数据类型:string/hash/list/set/zset...
    unsigned encoding:4;  // 编码:int/embstr/raw/listpack/skiplist...
    unsigned lru:LRU_BITS;// 最近访问时间(Part 6 淘汰策略用)
    int refcount;         // 引用计数
    void *ptr;            // 指向真实数据的指针
};

四个字段用位域压缩,整个结构在 64 位平台上恰好 16 字节

字段 大小 作用
type 4 bit 值的"类型"------TYPE 命令读它
encoding 4 bit 值的"实现方式"------OBJECT ENCODING 读它
lru 24 bit 访问时间/频次,Part 6 淘汰策略的数据源
refcount 32 bit 共享与释放的依据
ptr 64 bit 指向 SDS / listpack / skiplist 等真实结构

type 与 encoding 是两层的type 是"逻辑类型"(对外语义),encoding 是"物理实现"(内部优化)。同一个 String 类型,可以编码为 intembstrraw;同一个 Hash,可以是 listpackhashtable。这就是 Part 2 里 OBJECT ENCODING 看到多样性的原因。

1.2 观察:OBJECT REFCOUNT 与共享整数

refcount 为 0 表示对象可被释放;多个键共享同一对象时引用计数会增长。官方 7.4 中 0--9999 的整数使用共享对象(refcount 显示为极大值),这是经典的内存优化。本机 8.10.0 实测:

python 复制代码
r.set("n1", 100)
r.set("n2", 100)
print(r.object("REFCOUNT", "n1"))     # 1
print(r.object("REFCOUNT", "n2"))     # 1
text 复制代码
1
1

两个键都存 100,refcount 却是 1------本机 8.10.0 未观察到共享整数对象。这与 7.4 的经典行为不同(详见 §8 版本差异)。这个差异不影响正确性,但说明"共享整数省内存"这类结论必须标注版本。

OBJECT IDLETIME 观察 lru 字段的实际效果:

python 复制代码
r.set("idle_key", "v")
time.sleep(2)
print(r.object("IDLETIME", "idle_key"))   # 2
text 复制代码
2

2. SDS:为什么 Redis 不用 C 字符串

2.1 C 字符串的三个缺陷

Redis 的值大量是字符串。C 语言原生字符串(char* + \0 结尾)有三个问题:

  1. 求长度是 O(n) :必须从头扫到 \0
  2. 非二进制安全 :字符串中间不能有 \0(二进制数据会"截断");
  3. 拼接容易溢出/频繁重分配:没有长度信息,扩容无从谈起。

Redis 因此实现了 SDS(Simple Dynamic String,sds.h/sds.c)。

2.2 sdshdr 结构

SDS 不是单独一种结构,而是按长度分档的 5 种(sdshdr5/8/16/32/64),用最小的头装下数据。以 sdshdr8 为例(sds.h):

c 复制代码
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;              // 已用长度
    uint8_t alloc;            // 已分配容量(不含头与终止符)
    unsigned char flags;      // 低 3 位记录类型(8/16/32/64)
    char buf[];               // 数据 + 结尾 '\0'
};

__packed__ 表示结构不填充对齐,sdshdr8 的头只有 3 字节lenalloc 分离,让 SDS 知道"用了多少、还能装多少"------这就是 O(1) 求长度和二进制安全的根基。字符串超过 255 字节就换 sdshdr16(头 5 字节),依此类推。

2.3 扩容策略:翻倍与 +1MB

APPEND 等操作需要扩容时,走 sdsMakeRoomFor_sdsMakeRoomFor 的贪心预分配(sds.c:232 起):

c 复制代码
if (greedy == 1) {
    if (newlen < SDS_MAX_PREALLOC)   // 目标 < 1MB
        newlen *= 2;                 //   → 翻倍(sds.c:234)
    else
        newlen += SDS_MAX_PREALLOC;  //   → 每次多申请 1MB
}

SDS_MAX_PREALLOC 为 1MB(sds.h:13)。策略:小字符串翻倍增长(摊还 O(1) 的拼接成本),大字符串线性增长(避免浪费)。预分配意味着 APPEND 不总是触发 realloc------这是"循环 append 构建字符串"能高效的原因。

2.4 实测:append 把 embstr 升级成 raw

String 的三种编码:int(纯数字)、embstr(短字符串,robj 与 SDS 一次性分配)、raw(长字符串,两次分配)。Part 2 曾观察到 product:1:name 是 raw------因为它被 APPEND 过:

text 复制代码
$ redis-cli SET s "hello"        # → OBJECT ENCODING: embstr
$ redis-cli APPEND s " world"    # 触发修改,需要可变空间
$ redis-cli OBJECT ENCODING s    # → raw

embstr 是只读优化 :robj 和 SDS 头、数据在同一次 malloc 里分配(一次分配、一次释放、缓存友好);但任何修改(APPEND/SETRANGE/GETRANGE 之外的操作)都必须先转成 raw。所以"短字符串 = embstr"只在创建后不再修改时成立。


3. dict:键空间与 Hash 的实现

Redis 的键空间(所有键的索引)本身就是一个 dict(哈希表),Hash 类型的大数据编码也是 dict。它是理解"内存与卡顿"的关键。

3.1 结构

dict.h:96(为可读性略去辅助字段):

c 复制代码
struct dict {
    dictType *type;            // 回调函数表(哈希、比较、析构)
    dictEntry **ht_table[2];   // 两张哈希表:ht[0] 在用,ht[1] 扩容时备用
    unsigned long ht_used[2];  // 每张表已用元素数
    long rehashidx;            // 渐进式 rehash 进度;-1 表示未在 rehash
    signed char ht_size_exp[2];// 每张表的容量指数(容量 = 1 << exp)
};

每个桶槽位是一个 dictEntry 链表头(冲突用链地址法):

c 复制代码
struct dictEntry {
    void *key;                 // 指向 key 的 SDS
    union { void *val; uint64_t u64; int64_t s64; double d; } v;  // value
    struct dictEntry *next;    // 哈希冲突链
};

64 位平台上 dictEntry24 字节 (key 8 + value 8 + next 8)。哈希表容量永远是 2 的幂(ht_size_exp 指数化存储)。

3.2 扩容与缩容:负载因子驱动

写键时 dictAddRaw 调用 dictExpandIfNeededdict.c:1492):

c 复制代码
/* If we reached the 1:1 ratio ... we resize doubling the number of buckets. */
if ((dict_can_resize == DICT_RESIZE_ENABLE &&
     d->ht_used[0] >= DICTHT_SIZE(d->ht_size_exp[0])) || ...)
{
    dictExpand(d, d->ht_used[0] + 1);
}

扩容触发条件:元素数 ≥ 容量(负载因子 ≥ 1.0)时,容量翻倍 。缩容则相反(dictShrinkIfNeeded):元素数降到容量的 1/8(HASHTABLE_MIN_FILL = 8)以下才缩容,避免频繁抖动。

用隔离实例的 DEBUG HTSTATS 实测扩容轨迹(每 5000 个键采样一次):

text 复制代码
元素数    2682 → table size   4096   (负载因子 0.65)
元素数    5325 → table size   8192   (0.65)
元素数   15000 → table size  16384   (0.92)
元素数   25000 → table size  32768   (0.76)
元素数   50000 → table size  65536   (0.76)
元素数  100000 → table size 131072   (0.76)

容量严格按 2 的幂翻倍:4096 → 8192 → ... → 131072。采样点显示的负载因子都 < 1,是因为扩容瞬间发生、采样滞后------真实触发点是负载因子越过 1.0 的那一刻,翻倍后回落到 ~0.5 并随写入重新爬升。

3.3 渐进式 rehash:为什么"数据多了会卡一下"

一次性把 100 万条目从旧表搬到新表会阻塞服务器几十毫秒甚至更久。Redis 的选择是渐进式

  • 扩容时分配新表 ht[1]rehashidx 从 0 开始;
  • 每次增删查改命令执行时顺带迁移一小批桶dictRehash,一次迁移一个桶);
  • 后台还会用 dictRehashMilliseconds 每次最多 1ms 的时间片持续迁移(在 serverCron 中);
  • 迁移全部完成前,读写同时查两张表;完成后释放旧表,rehashidx 回到 -1。

"卡一下"的来源 :扩容瞬间要为 ht[1] 一次性分配 新表内存(与旧表容量相同),并承担迁移期间的查找成本(最坏查两张表)。键数量巨大时,这一瞬间的内存峰值和迁移耗时就是偶发延迟的来源。Part 6 会看到它和内存峰值、INFO memory 的关联。


4. 编码转换实验:用 OBJECT ENCODING 验证

把 Part 2 的观察整理成一张"编码决策链",全部为本机实测:

text 复制代码
SET 42          → int        (可解析为 64 位整数)
SET "42.0"      → embstr     (含小数点,不是整数)
SET "x"         → embstr     (短字符串)
SET "a"*43      → raw        (本机 8.10.0 的 embstr 阈值实测为 38 字节)
SET 42; APPEND  → raw        (修改操作强制升级)
HSET 10 字段    → listpack   (小 Hash)
HSET 200×100B   → hashtable  (超过 listpack 阈值)
SADD 100 整数   → intset     (Part 4 详讲)
SADD 100 字符串 → listpack
ZADD 1000 成员  → skiplist   (Part 4 详讲)

关键认知 :编码是服务器根据当前数据规模与版本参数 自动选择的,会随数据增长升级(listpack → hashtableintset → hashtable),但一般不降级 (删除部分数据不会自动回到紧凑编码)。"能不能变回小编码"在生产里是一个常见的优化问题,答案是:删掉重建或 DEBUG OBJECT 确认后手动重写。


5. 内存拆解:55.6 字节/键是怎么来的

5.1 实测

python 复制代码
# 10 万个 key="k12345"(6 字节)、value="v"(1 字节)
总内存: 5,561,850 字节
每键平均: 55.6 字节

# 1 万个 1KB value
每键平均: 1,075.5 字节(≈ 1024 数据 + 51 固定开销)

MEMORY USAGE 的单键视角:

text 复制代码
SET tiny "v"      → MEMORY USAGE 28   字节
SET kb   "a"*1000 → MEMORY USAGE 1027 字节

5.2 拆解(量级估算)

一个"小键"的组成部分:

部分 大小(约) 说明
key 的 SDS ~10 B sdshdr8 头 3B + "k12345" 6B + 终止符
value 的 robj 16 B redisObject 固定
value 的 SDS ~5 B sdshdr8 头 3B + "v" 1B + 终止符
dictEntry 24 B key 指针 + value 指针 + 冲突链
桶槽位 ~8-10 B 平均每个键分到的指针槽(负载 ~0.76-1)
合计 ~63 B 与实测 55.6 B 同量级;差异来自分配器对齐与统计口径

结论不是"精确 55.6",而是量级 :每个键的固定开销 ≈ 50--60 字节,与 value 大小无关。100 万个 1 字节的键 ≈ 55 MB,其中 98% 是"包装开销"而非数据。这就是 Part 6 内存治理的起点:键的数量比键的大小更消耗内存。


6. 源码阅读方法:从问题到答案

本篇的源码证据全部来自四个文件的十个符号,方法可以复用到 Part 4:

想查什么 去哪个文件 关键符号
值对象的包装 src/server.h struct redisObject(:903)
字符串实现与扩容 src/sds.h / src/sds.c sdshdr8sdsMakeRoomFor(:272)、SDS_MAX_PREALLOC
哈希表实现 src/dict.h / src/dict.c struct dict(:96)、dictExpandIfNeeded(:1492)、dictShrinkIfNeeded(:1529)
编码选择 src/object.c createStringObjecttryObjectEncodingOBJ_ENCODING_EMBSTR_SIZE_LIMIT(:101)
后台任务 src/server.c serverCrondatabasesCron

阅读固定三问:这个结构解决什么问题?代价是什么?代码里哪一行体现了代价? 例如 SDS 的 alloc 字段解决了"O(1) 长度与扩容"问题,代价是 3--5 字节的头;sds.c:234newlen *= 2 就是"摊还 O(1) 拼接"的那一行。


7. 版本与环境差异

差异点 官方 7.4 本机 8.10.0(cygwin 移植版) 影响
embstr 阈值 44 字节(object.c:101 实测 38 字节 阈值是实现细节,教学与排障以实测为准
共享整数对象 0--9999 共享,OBJECT REFCOUNT 显示极大值 实测 refcount=1,未观察到共享 "共享整数省内存"的结论需标注版本;若 8.x 确实移除,小整数计数场景内存会略增
DEBUG OBJECT / DEBUG HTSTATS 可用(需启用) 默认禁用,需 --enable-debug-command yes 重启 观察 rehash 等内部状态需要在隔离实例上开启
SDS / dict 结构 sdshdr 分档 + 渐进式 rehash 一致 本篇源码引用 7.4.2,8.x 同源

共享整数的差异值得持续关注:它是"版本会改变优化"的典型例子。写文章与面试时引用这类优化,都应注明版本。


8. 测试与验收

  • 新增测试建议:OBJECT ENCODING 断言(String 数字→int、短串→embstr、长串→raw)、MEMORY USAGE 量级断言、dict 扩容轨迹脚本(隔离实例 + DEBUG);
  • 所有内存实验保留可复现脚本(规模、测量方式、环境)。

本篇验收清单

  • 能默写出 redisObject 的 5 个字段并解释"type 与 encoding 两层";
  • 能解释 SDS 相比 C 字符串的 3 个优势与 sdshdr 分档思想;
  • 能说出扩容条件(负载因子 ≥ 1 翻倍)、缩容条件(≤ 1/8)与渐进式 rehash 的过程;
  • 能解释"为什么小键的内存大头是包装开销"并给出量级(50--60 B/键);
  • 能说出 embstr 只读、append 会升级为 raw;
  • 会使用 OBJECT ENCODINGMEMORY USAGEDEBUG HTSTATS 三类观察手段;
  • 能用"三问"方法独立读一段 Redis 源码。

9. 常见误区

  1. "String 就是 C 字符串"------是 SDS:O(1) 长度、二进制安全、有预分配。
  2. "embstr 是短字符串的常态"------只读优化;任何修改都会升级为 raw(§2.4)。
  3. "共享整数对象在所有版本都存在"------7.4 有,本机 8.10 实测没有(§1.2、§7);结论要带版本。
  4. "rehash 是一次性拷贝"------是渐进式:分摊到每条命令,但扩容瞬间的分配峰值仍可能造成偶发延迟(§3.3)。
  5. "OBJECT ENCODING 返回什么结构就是什么类型" ------它是"当前实现"不是"逻辑类型";类型用 TYPE
  6. "删除数据后编码会自动回到小编码"------一般不降级,紧凑编码优化需要重建键(§4)。

10. 本篇小结

回到开篇两个问题:

  1. 内存去哪了:每个键的固定包装开销(key SDS + robj + value SDS + dictEntry + 桶槽位)≈ 50--60 字节,与 value 大小无关。100 万个小键 ≈ 55 MB,其中绝大部分是"包装"而非"数据"。
  2. 卡顿从哪来 :dict 在负载因子 ≥ 1 时 2 倍扩容,ht[1] 一次性分配 + 渐进式 rehash 迁移,是写入高峰偶发延迟的机制来源。

本篇建立的认知升级:Redis 的"值"不是裸数据,而是"robj 包装 + 编码实现"的复合体键空间是一个会动态扩缩的哈希表。这两个模型是 Part 4(quicklist/skiplist/rax)、Part 6(内存治理)的直接地基。

下一篇 Part 4:底层实现(二) 继续打开剩余结构:quicklist、intset、skiplist、rax,并用 MEMORY USAGE 完成"为什么我的 Redis 内存涨得比数据大"的完整实测。


11. 官方资料

相关推荐
杜子不疼.3 小时前
不会SQL也能改数据库?我用NocoDB把MySQL变成了表格界面
数据库·sql·mysql
今天AI了吗3 小时前
AI辅助数据库工具链对比:从SQL优化到架构设计的主流方案评估
数据库·人工智能·sql
渣渣盟4 小时前
当 Redis 集群发生主从切换(Failover)时,Flink 任务会崩溃吗?如何利用 Sentinel 实现高可用?
redis·flink·sentinel
蓝鸟19744 小时前
Oracle 19c JSON_OBJECT 完全实战指南|嵌套、多行多列数组合并、空值踩坑、医保报文落地
数据库·oracle·json_arrayagg·多行转json数组·sql实战踩坑·数据库json拼装·json_object
晴天164 小时前
Agent 全栈学习笔记 1-Day14
数据库·笔记·学习
瀚高PG实验室4 小时前
几种因网络波动导致应用与数据库操作异常的现象
运维·网络·数据库·postgresql·瀚高数据库
崖边看雾5 小时前
同步上下文管理器规则
开发语言·数据库
.柒宇.5 小时前
运维常见面试题_02_数据库
运维·数据库·sql·面试
名字还没想好☜13 小时前
Python f-string 进阶:数字格式化、对齐填充、调试 = 号与嵌套表达式
开发语言·数据库·python·字符串格式化·f-string