核心目标 :理解 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.2,tags/7.4.2);dict 扩容实验在隔离实例 6380(已启用 DEBUG 命令)上进行。最后复核日期:2026-08-07。
0. 本篇问题场景:内存去哪了,"卡顿"从哪来
两个生产里常见的困惑:
- 内存账对不上 :写入 100 万个"小键",数据本身才几 MB,
INFO memory却涨了几十倍------多出来的内存是什么? - 偶发卡顿 :
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 类型,可以编码为 int、embstr 或 raw;同一个 Hash,可以是 listpack 或 hashtable。这就是 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 结尾)有三个问题:
- 求长度是 O(n) :必须从头扫到
\0; - 非二进制安全 :字符串中间不能有
\0(二进制数据会"截断"); - 拼接容易溢出/频繁重分配:没有长度信息,扩容无从谈起。
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 字节 。len 与 alloc 分离,让 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 位平台上 dictEntry 是 24 字节 (key 8 + value 8 + next 8)。哈希表容量永远是 2 的幂(ht_size_exp 指数化存储)。
3.2 扩容与缩容:负载因子驱动
写键时 dictAddRaw 调用 dictExpandIfNeeded(dict.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 → hashtable、intset → 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 |
sdshdr8、sdsMakeRoomFor(:272)、SDS_MAX_PREALLOC |
| 哈希表实现 | src/dict.h / src/dict.c |
struct dict(:96)、dictExpandIfNeeded(:1492)、dictShrinkIfNeeded(:1529) |
| 编码选择 | src/object.c |
createStringObject、tryObjectEncoding、OBJ_ENCODING_EMBSTR_SIZE_LIMIT(:101) |
| 后台任务 | src/server.c |
serverCron、databasesCron |
阅读固定三问:这个结构解决什么问题?代价是什么?代码里哪一行体现了代价? 例如 SDS 的 alloc 字段解决了"O(1) 长度与扩容"问题,代价是 3--5 字节的头;sds.c:234 的 newlen *= 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 ENCODING、MEMORY USAGE、DEBUG HTSTATS三类观察手段; - 能用"三问"方法独立读一段 Redis 源码。
9. 常见误区
- "String 就是 C 字符串"------是 SDS:O(1) 长度、二进制安全、有预分配。
- "embstr 是短字符串的常态"------只读优化;任何修改都会升级为 raw(§2.4)。
- "共享整数对象在所有版本都存在"------7.4 有,本机 8.10 实测没有(§1.2、§7);结论要带版本。
- "rehash 是一次性拷贝"------是渐进式:分摊到每条命令,但扩容瞬间的分配峰值仍可能造成偶发延迟(§3.3)。
- "
OBJECT ENCODING返回什么结构就是什么类型" ------它是"当前实现"不是"逻辑类型";类型用TYPE。 - "删除数据后编码会自动回到小编码"------一般不降级,紧凑编码优化需要重建键(§4)。
10. 本篇小结
回到开篇两个问题:
- 内存去哪了:每个键的固定包装开销(key SDS + robj + value SDS + dictEntry + 桶槽位)≈ 50--60 字节,与 value 大小无关。100 万个小键 ≈ 55 MB,其中绝大部分是"包装"而非"数据"。
- 卡顿从哪来 :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. 官方资料
- Redis 内部结构源码(7.4.2):https://github.com/redis/redis/tree/7.4.2/src
OBJECT命令:https://redis.io/docs/latest/commands/object/MEMORY USAGE:https://redis.io/docs/latest/commands/memory-usage/- Memory optimization 官方文档:https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/