Redis 之所以能在内存数据库领域长期占据统治地位,除了单线程的事件循环模型和高效的 I/O 多路复用之外,底层数据结构的精心设计功不可没。Redis 的每个数据类型背后都不是一个简单的 Java HashMap 或者 C++ std::vector,而是一套经过反复打磨、针对内存场景极致优化的专用数据结构。理解这些结构的设计取舍,不仅有助于你写出更高效的代码,还能在面对 BigKey、慢查询、内存暴涨等线上问题时,快速定位根因并给出治理方案。
本文将从 Redis 数据结构的演进历史讲起,逐一拆解 SDS、ziplist、quicklist、intset、skiplist、dict 等核心结构的实现原理,并深入分析编码转换策略和 BigKey 治理方案。
一、数据结构演进历史
Redis 的数据结构演进大致经历了三个阶段:
阶段一:原生指针结构(Redis 1.0 - 2.4)
早期 Redis 直接使用 C 语言原生结构:
- String:以
\0结尾的 C 字符串 - List:双向链表(
adlist),每个节点包含前后指针 - Hash:字典(
dict,基于哈希表) - Set:字典(value 为 NULL)
- Sorted Set:字典 + 跳跃表
这个阶段的问题在于内存开销过大。一个双向链表的节点需要两个指针(16 字节在 64 位系统上),再加上数据本身,内存利用率很低。
阶段二:紧凑编码引入(Redis 2.4 - 3.0)
为了降低小数据的内存占用,Redis 引入了紧凑编码:
- ziplist:替代双向链表存储小 List 和小 Hash
- intset:用小整数集合替代字典存储纯整数 Set
- embstr:小字符串的嵌入式分配
这一阶段 Redis 的内存效率大幅提升,单个实例可以轻松存储数亿级的小 key。
阶段三:进一步优化(Redis 3.2 - 7.0)
- quicklist(3.2):用双向链表连接多个 ziplist,取代纯 ziplist List
- listpack(5.0):取代 ziplist 作为 List / Hash / ZSet 的底层编码,解决级联更新问题
- rax(5.0):用于 Streams 的基数树索引
- listpack 全面替换(7.0):彻底废弃 ziplist
Redis 对底层结构的每一次改造,背后都有着明确的量化目标:在保持 O(1) 或 O(log N) 访问效率的前提下,尽可能压缩内存空间。
二、SDS:简单动态字符串
C 语言的原生字符串存在三个致命缺陷:
- 获取长度需要 O(N) 遍历:
strlen()必须扫描到\0 - 缓冲区溢出风险:
strcat等操作不会检查目标缓冲区容量 - 二进制不安全:遇
\0即截断,无法表示图片、序列化数据等二进制内容
2.1 SDS 的结构定义
Redis 设计了 Simple Dynamic String(SDS)来替代 C 字符串。以 Redis 3.2+ 的分级版本为例:
/* SDS 头结构( sdshdr8 为例) */
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // 已使用长度
uint8_t alloc; // 分配的总容量(不含头部和 \0)
unsigned char flags; // 类型标识:SDS_TYPE_5/8/16/32/64
char buf[]; // 柔性数组,实际存储数据
};
SDS 根据字符串长度选择不同头部:
| 类型 | len / alloc 字段 | 最大长度 |
|---|---|---|
| sdshdr5 | 无 len/alloc(用 flags 复用) | 31 |
| sdshdr8 | uint8_t | 255 |
| sdshdr16 | uint16_t | 65535 |
| sdshdr32 | uint32_t | 约 4G |
| sdshdr64 | uint64_t | 非常大 |
2.2 O(1) 获取长度
/* 获取字符串长度:直接读取头部字段 */
size_t sdslen(const sds s) {
unsigned char flags = s[-1]; // flags 在 buf 前面 1 字节
switch(flags & SDS_TYPE_MASK) {
case SDS_TYPE_8:
return ((struct sdshdr8 *)(s - sizeof(struct sdshdr8)))->len;
// ... 其他分支
}
}
无论字符串多长,获取长度都是 O(1) 操作。这对 Redis 的键值查找、命令解析等高频场景至关重要。
2.3 二进制安全
SDS 的 buf 数组不以 \0 作为结束标志,而是以 len 字段标识有效数据长度。因此 SDS 可以安全存储任意二进制数据:
/* 存储二进制数据(含 \0) */
sds binary = sdsnewlen("hello\0world", 11); // 正确:长度 11
/* C 字符串则会截断为 "hello" */
2.4 预分配与惰性释放
/* SDS 扩容策略:预分配,减少内存重分配次数 */
sds sdsMakeRoomFor(sds s, size_t addlen) {
size_t free = sdsavail(s);
if (free >= addlen) return s; // 空间足够,直接返回
size_t len = sdslen(s);
size_t newlen = len + addlen;
/* 关键策略:如果新长度 < 1MB,翻倍;否则加 1MB */
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
return sdsResize(s, newlen);
}
预分配策略让频繁追加的字符串(如 APPEND 命令)避免了每次扩容都触发 realloc,显著降低了内存分配的系统调用开销。
同理,SDS 缩短时不会立即释放内存,而是将多余空间留作后续使用:
/* 惰性释放:只更新 len,不释放内存 */
void sdsclear(sds s) {
struct sdshdr *sh = (void *)(s - sizeof(struct sdshdr));
sh->len = 0; // 长度清零
sh->buf[0] = '\0'; // 第一个字节置空
// alloc 不变,空间保留
}
2.5 embstr 与 raw 编码
Redis 对 String 类型有两种编码:
- embstr:当字符串长度 <= 44 字节(Redis 3.2+),RedisObject 和 SDS 分配在同一块连续内存中,只需要一次 malloc/free
- raw:当字符串更长时,RedisObject 和 SDS 分开分配
# 验证 embstr 和 raw 编码
redis-cli SET short "hello"
redis-cli DEBUG OBJECT short
# 输出:encoding:embstr
redis-cli SET long "a" # 重复 100 次
redis-cli DEBUG OBJECT long
# 输出:encoding:raw
embstr 的 44 字节限制来自 RedisObject 头部(16 字节)+ sdshdr8(3 字节)+ 结束符(1 字节)= 20 字节,64 - 20 = 44。这个设计在 jemalloc/tcmalloc 的 64 字节分配区间中完美命中,内存对齐效率极高。
三、ziplist 与 listpack:紧凑编码
3.1 为什么需要紧凑编码
假设一个 List 存了 1000 个整数字符串 “1”、“2”、…“1000”。如果用双向链表实现:
- 每个节点:前驱指针 8B + 后继指针 8B + 数据指针 8B + 其他元数据 = 约 32B
- 1000 个节点:约 32KB
- 实际数据:每个 “1” 占 1-4 字节,全部数据仅约 3KB
内存开销比高达 10:1。ziplist 的核心思想就是:把多个小元素连续存放在一块内存中,用增量编码代替指针。
3.2 ziplist 的结构
/* ziplist 整体布局 */
/* <zlbytes><zltail><zllen><entry><entry>...<entry><zlend> */
/* 头部 */
uint32_t zlbytes; // 整个 ziplist 占用的字节数
uint32_t zltail; // 到尾节点的偏移量
uint16_t zllen; // 节点数量(最大 65535,更多需要遍历)
/* entry 节点 */
<prevlen><encoding><content>
/* 结尾 */
uint8_t zlend = 0xFF; // 结束标记
entry 的三个字段:
| 字段 | 说明 |
|---|---|
| prevlen | 前驱节点的长度,支持从后向前遍历 |
| encoding | 编码类型:字符串长度编码 / 整数编码 |
| content | 实际数据 |
3.3 encoding 的灵活编码
/* encoding 字段:前两位标识类型,其余位存储长度或值 */
/* 字符串编码 */
00xxxxxx // 长度 0-63,后 6 位存长度
01xxxxxx xxxxxxxx // 长度 0-16383,后 14 位存长度
10xxxxxx ... // 大字符串,后 6 位无用,接下来 4 字节存长度
/* 整数编码 */
11000000 // int16_t,content 占 2 字节
11010000 // int32_t,content 占 4 字节
11110000 // int24_t(特殊编码)
11111110 // int8_t,content 占 1 字节
1111xxxx // 0-12 的立即数,实际值 = xxxx - 1(0-11)
这种编码的精妙之处在于:对于小整数(0-12),content 字段完全省略,值直接编码在 encoding 的低 4 位中。这对于 “status:1”、“count:0” 这类场景,每个 entry 仅需 2-3 字节。
3.4 ziplist 的致命缺陷:级联更新
prevlen 字段有两种编码:
- 如果前驱节点 < 254 字节,prevlen 占 1 字节
- 如果前驱节点 >= 254 字节,prevlen 占 5 字节
级联更新(cascade update) 场景:
/* 假设每个 entry 的 prevlen 都是 1 字节 */
entry1 <- entry2 <- entry3 <- ... <- entryN
/* entry1 内容更新后,长度从 250 变为 255 */
/* entry1: prevlen 不变, content 变长 */
/* entry2: prevlen 从 1 字节 -> 5 字节,entry2 长度 +4 */
/* entry2 长度变化后,entry3 的 prevlen 也要从 1->5... */
/* 最坏情况:整个 ziplist 所有节点都连锁更新!O(N^2) */
级联更新在数据量较小的时候影响不大,但对于包含大量小元素的 ziplist(如一个 Hash 字段极多),一次更新触发连锁反应可能导致 Redis 主线程阻塞数十毫秒,这在延迟敏感的场景中是不可接受的。
3.5 listpack:ziplist 的继任者
Redis 5.0 引入 listpack,7.0 完全替代 ziplist。核心改进:去掉 prevlen,改为记录当前 entry 的长度(encoding 中隐含)。
/* listpack entry 格式 */
<encoding-type><element-data><element-total-len>
/* encoding-type:标识数据类型和长度 */
/* element-data:实际数据 */
/* element-total-len:当前 entry 的总长度 */
listpack 用 element-total-len 替代 prevlen 的角色:
- 从前往后遍历:跳过
element-total-len,读取下一个 entry - 从后往前遍历:利用 zltail 定位最后一个 entry,然后用
element-total-len向前跳跃
由于 listpack 每个 entry 修改时只影响自己的长度字段,不会触发连锁反应,彻底消除了级联更新问题。
四、quickList:ziplist + 双向链表
4.1 为什么不用纯 ziplist/listpack
纯 ziplist 的问题是:
- 插入和删除中间元素需要 memmove,复杂度 O(N)
- ziplist 整体长度受
list-max-ziplist-size限制(默认 8KB) - 不能高效地在两端以外的位置插入
纯双向链表的问题是:每个节点的指针开销太大。
4.2 quickList 的折中方案
/* quickList 结构:双向链表,每个节点是一个 ziplist/listpack */
struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; // 总元素数
unsigned long len; // 节点数(ziplist 个数)
int fill : QL_FILL_BITS; // 每个节点的 fill factor
unsigned int compress : QL_COMP_BITS; // LZF 压缩深度
};
struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl; // 指向 ziplist/listpack
unsigned int sz; // ziplist 占用字节数
unsigned int count : 16; // ziplist 内元素个数
unsigned int encoding : 2; // RAW = 1, LZF = 2
unsigned int container : 2; // PLAIN=1, PACKED=2
unsigned int recompress : 1;
unsigned int attempted_compress : 1;
unsigned int extra : 10;
};
quickList 的核心参数 fill:
# redis.conf
list-max-listpack-size -2
# -5: 每个 listpack 最大 64 KB
# -4: 32 KB
# -3: 16 KB
# -2: 8 KB(默认)
# -1: 4 KB
# 正数:每个 listpack 最多存 N 个元素
4.3 quickList 的操作流程
LPUSH / RPUSH(两端插入):
- 找到 head/tail 节点
- 在该节点的 ziplist 中插入
- 如果 ziplist 超过 size 限制,新建一个节点
LINDEX index(随机访问):
- 判断 index 靠近头还是尾,决定从头或尾开始遍历节点
- 在每个节点内用 ziplist 的接口定位元素
LPOP / RPOP(两端弹出):
- 从 head/tail 节点的 ziplist 弹出元素
- 如果 ziplist 变空,删除该节点
4.4 内存压缩
quickList 支持对中间节点进行 LZF 压缩:
# redis.conf
list-compress-depth 0 # 0 = 不压缩(默认)
list-compress-depth 1 # 头尾各保留 1 个未压缩节点
list-compress-depth 2 # 头尾各保留 2 个未压缩节点
原理:List 的访问模式通常是头尾操作较多(如消息队列),中间节点较少访问。将中间节点 LZF 压缩可以节省大量内存,访问时临时解压即可。
五、intSet:小整数紧凑编码
5.1 intSet 的结构
当 Set 的所有元素都是整数且数量较少时,Redis 用 intSet 替代字典:
typedef struct intset {
uint32_t encoding; // 编码类型:INTSET_ENC_INT16/32/64
uint32_t length; // 元素个数
int8_t contents[]; // 柔性数组,实际按 encoding 对齐
} intset;
5.2 升级策略(upgrade)
/* intSet 升级策略:当插入的整数超出当前编码范围时 */
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
uint8_t valenc = _intsetValueEncoding(value);
/* 需要升级 */
if (valenc > intrev32ifbe(is->encoding)) {
return intsetUpgradeAndAdd(is, value);
}
// ... 直接插入
}
/* 升级:把 int16 数组整个升级为 int32 数组,然后插入新值 */
intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
uint8_t curenc = intrev32ifbe(is->encoding);
uint8_t newenc = _intsetValueEncoding(value);
int length = intrev32ifbe(is->length);
/* 扩展内存 */
is = intsetResize(is, intrev32ifbe(is->length) + 1);
/* 从后往前迁移,避免覆盖 */
while(length--)
_intsetSet(is, length + 1, _intsetGetEncoded(is, length, curenc));
/* 插入新值(一定是最大或最小值,所以插在端点)*/
_intsetSet(is, 0, value); // 或插在末尾
is->encoding = intrev32ifbe(newenc);
is->length = intrev32ifbe(intrev32ifbe(is->length) + 1);
return is;
}
升级的特点:
- 只升不降:编码升级后不会降级,即使删除大元素
- 触发一次:从小升级到大后,后续同范围操作无需再升级
- 二分查找:intSet 内部有序,查找复杂度 O(log N)
5.3 编码转换触发条件
# Set 类型默认配置
set-max-intset-entries 512
当 intSet 元素超过 512 个时,自动转换为 dict(哈希表)。转换过程:
- 新建一个 dict
- 遍历 intSet,每个元素作为 key 插入 dict(value 为 NULL)
- 释放 intSet,替换为 dict
intSet 的内存效率极高。一个存储 500 个 int32 的 Set,intSet 仅需约 2KB,而 dict 至少需要 8KB 以上(哈希表预分配 + 指针开销)。
六、skipList:多层跳跃链表
6.1 Sorted Set 为什么用 skipList
Redis 的 Sorted Set 需要同时支持两种查询方式:
- 按 member 查找 score(类似 Hash)
- 按 score 范围查询 / 排名查询(类似 Tree)
Redis 的解决方案是字典 + skipList 的组合:
typedef struct zset {
dict *dict; // member -> score 的映射,O(1) 查 score
zskiplist *zsl; // 按 score 排序的 skipList
} zset;
6.2 skipList 的结构
/* 跳跃表节点 */
typedef struct zskiplistNode {
sds ele; // member
double score; // score
struct zskiplistNode *backward; // 后向指针(只有一层)
struct zskiplistLevel {
struct zskiplistNode *forward; // 前向指针
unsigned int span; // 到下一个节点的跨度(用于排名)
} level[]; // 柔性数组,多层索引
} zskiplistNode;
/* 跳跃表 */
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length; // 节点总数
int level; // 当前最大层数
} zskiplist;
6.3 跳跃表的高度随机算法
/* 随机生成节点层数,概率逐层减半 */
int zslRandomLevel(void) {
int level = 1;
while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
ZSKIPLIST_P 默认为 0.25,意味着:
- level = 1 的概率:75%
- level = 2 的概率:18.75%
- level = 3 的概率:4.6875%
- …
- level >= 32 的概率:趋近于 0
期望层数约为 1 / (1 - P) = 1.33 层,每个节点的平均指针数约 1.33(forward)+ 1(backward)= 2.33。相比平衡树的 2 个指针 + 颜色位,内存开销略高但实现简单得多。
6.4 插入与查询过程
查询节点:
/* 按 score + member 查找 */
zskiplistNode *zslGetElementByRank(zskiplist *zsl, unsigned long rank) {
zskiplistNode *x;
unsigned long traversed = 0;
int i;
x = zsl->header;
for (i = zsl->level - 1; i >= 0; i--) {
while (x->level[i].forward && (traversed + x->level[i].span) <= rank) {
traversed += x->level[i].span;
x = x->level[i].forward;
}
if (traversed == rank) {
return x;
}
}
return NULL; // 找不到
}
查询从最高层开始,每层尽可能向右跳跃,直到不能跳为止再下降一层。类似于 “搭快车、转慢车”。时间复杂度 O(log N)。
6.5 为什么不用红黑树 / AVL 树
很多人困惑:为什么 Redis 不用红黑树?跳跃表看起来 “不够高级”。回答如下:
| 维度 | skipList | 红黑树 |
|---|---|---|
| 实现复杂度 | 简单,约 200 行 | 复杂,约 500 行,调试困难 |
| 区间查询 | 天然支持,O(log N + M) | 需要中序遍历 |
| 排名查询 | span 字段直接支持 | 需要维护 size 子树 |
| 插入/删除 | 无需旋转,局部修改 | 需要复杂的旋转和重着色 |
| 并发安全 | 更容易实现无锁 | 旋转操作难以无锁化 |
| 内存占用 | 略多(多层指针) | 略少 |
跨越式查询和排名是 Redis ZSet 的核心操作,skipList 的 span 字段让这些操作非常高效。而红黑树要做到同样的事情,需要在每个节点维护子树大小信息,实现复杂度会大幅提升。
七、dict 与渐进式 rehash
7.1 dict 的结构
Redis 的 dict(字典)是哈希表的封装,用于 Hash、Set、ZSet(member->score)等数据类型:
typedef struct dictht {
dictEntry **table; // 哈希表数组
unsigned long size; // 数组大小(2 的幂)
unsigned long sizemask; // size - 1,用于 & 取模
unsigned long used; // 已有节点数
} dictht;
typedef struct dict {
dictType *type;
dictht ht[2]; // 两个哈希表,用于 rehash
long rehashidx; // rehash 进度,-1 表示不在 rehash
int16_t pauserehash; // 安全迭代器暂停 rehash
} dict;
7.2 渐进式 rehash
当哈希表负载因子(used/size)超过阈值(默认 1)时,dict 需要扩容。Redis 不能在一次性迁移全部元素(会阻塞主线程),而是采用渐进式 rehash:
/* 每次增删查时,顺带迁移一小批元素 */
int dictRehashStep(dict *d) {
if (d->pauserehash == 0)
return dictRehash(d, 1); // 每次迁移 1 个桶
return 0;
}
/* 定时任务中进行更多迁移 */
int dictRehashMilliseconds(dict *d, int ms) {
long long start = timeInMilliseconds();
int rehashes = 0;
while (dictRehash(d, 100)) { // 每次迁移 100 个桶
rehashes += 100;
if (timeInMilliseconds() - start > ms) break;
}
return rehashes;
}
渐进式 rehash 期间,dict 有两个活跃哈希表 ht[0](旧)和 ht[1](新)。查询操作会同时查两张表,插入只写入新表。
7.3 rehash 触发条件
/* 负载因子 = used / size */
/* 扩容条件 */
if (used / size >= 1 && !dict_is_resize_allowed()) {
// 扩容为原来 2 倍
}
/* 缩容条件(开启的话) */
if (used / size < 0.1) {
// 缩容为能容纳 used 的最小 2 的幂
}
扩容缩容都是 2 的幂,因此可以用位运算取模:hash & sizemask,比 % size 快得多。
八、编码转换策略与触发条件
Redis 每种数据类型都有多种编码,根据数据特征自动切换。理解这些转换条件,是调优 Redis 内存的关键。
8.1 各类型编码与转换条件
String:
| 编码 | 条件 |
|---|---|
| int | 值是 64 位有符号整数范围内的数字字符串 |
| embstr | 长度 <= 44 字节 |
| raw | 长度 > 44 字节 |
redis-cli SET num "12345"
redis-cli OBJECT ENCODING num # int
redis-cli SET str "hello world..."
redis-cli OBJECT ENCODING str # embstr 或 raw
List:
# redis.conf
list-max-listpack-size -2 # 每个 listpack 节点最大 8KB
list-compress-depth 0 # 不压缩中间节点
List 只有一种编码:quicklist(quicklist 内部节点是 listpack)。
Hash:
# redis.conf
hash-max-listpack-entries 512 # 字段数 <= 512 用 listpack
hash-max-listpack-value 64 # 每个值 <= 64 字节用 listpack
- 满足条件:listpack 编码(紧凑、内存省)
- 超出任一条件:hashtable 编码(速度快)
Set:
# redis.conf
set-max-intset-entries 512 # 元素数 <= 512 用 intset
- 所有元素是整数且数量 <= 512:intset
- 否则:hashtable
Sorted Set:
# redis.conf
zset-max-listpack-entries 128 # 元素数 <= 128 用 listpack
zset-max-listpack-value 64 # 每个 member <= 64 字节用 listpack
- 满足条件:listpack
- 超出任一条件:skiplist + dict
8.2 编码转换的不可逆性
大部分编码转换是单向的:
- intset -> hashtable:不可逆
- listpack -> hashtable/skiplist:不可逆
- embstr -> raw:当追加后长度超过 44 字节时自动转换(不可逆)
这意味着:如果一个小 Hash 慢慢增长到超过阈值,它从 listpack 转换成 hashtable 后,即使后续删除大量字段也不会变回 listpack。这可能导致 “内存只增不减” 的假象。
8.3 调优实践
# 1. 查看 key 的编码
redis-cli HGETALL myhash | wc -l
redis-cli OBJECT ENCODING myhash
# 2. 检查配置阈值
redis-cli CONFIG GET hash-max-*
redis-cli CONFIG GET zset-max-*
# 3. 如果业务中 Hash 字段通常很少但偶尔爆增,
# 可以适当降低阈值,让它早转 hashtable,
# 避免 listpack 频繁转换的开销
redis-cli CONFIG SET hash-max-listpack-entries 128
# 4. 对于大量小对象,考虑使用 Hash 分桶来压缩 key 前缀开销
# 原来:10000 个 key "user:1", "user:2"...
# 优化:100 个 hash "user:bucket:0" ~ "user:bucket:99",
# 每个 hash 存 100 个字段
九、BigKey 治理
9.1 什么是 BigKey
BigKey 不是指 key 名很长,而是指:
- String 类型的 value 超过 10KB
- List / Set / Hash / ZSet 元素数量超过 5000 或整体大小超过 1MB
BigKey 的危害:
- 阻塞主线程:一次操作需要遍历或传输大量数据
- 网络拥塞:一个请求返回 10MB 数据,带宽被占满
- 持久化阻塞:RDB / AOF 重写时内存拷贝耗时增加
- 主从同步延迟:slave 同步大 key 时长时间阻塞
- 内存碎片:大 key 释放后产生大内存空洞
9.2 检测 BigKey
# 方法1:redis-cli --bigkeys(在线扫描,有性能影响)
redis-cli --bigkeys
# 输出示例:
# -------- summary -------
# Sampled 502555 keys in the keyspace!
# Biggest string found 'bigstr' has 1048576 bytes
# Biggest list found 'biglist' has 85420 items
# 方法2:scan 遍历 + memory 命令(推荐,可控速率)
redis-cli --scan --pattern "*" | while read key; do
size=$(redis-cli MEMORY USAGE "$key")
if [ "$size" -gt 10240 ]; then
echo "$key => $size bytes"
fi
done
# 方法3:rdbtools 离线分析(无线上影响)
rdb -c memory /var/redis/dump.rdb > memory.csv
sort -t, -k4 -nr memory.csv | head -n 20
9.3 拆分策略
String 类型 BigKey:
# 原方案:一个 key 存 1MB JSON
SET config:all "<1MB json>"
# 拆分方案:按模块拆分
SET config:module1 "<small json>"
SET config:module2 "<small json>"
# 或使用 Hash 分桶压缩结构开销
HSET config:all field1 "val1" field2 "val2" ...
List 类型 BigKey:
# 原方案:单 List 存 100 万条消息
LPUSH messages "msg1" "msg2" ...
# 拆分方案:按时间或用户分桶
LPUSH messages:20260101 "msg1" ...
LPUSH messages:20260102 "msg2" ...
# 或使用 Stream 类型(底层为 rax 树,天然分片)
XADD mystream * field1 value1
Hash 类型 BigKey:
# 原方案:Hash 存 100 万个用户配置
HSET user:config:all user1 "config1" ...
# 拆分方案:按 ID 取模分桶
HSET user:config:0 user1 "config1" # user_id % 100 = 0 的放这里
HSET user:config:1 user2 "config2" # user_id % 100 = 1 的放这里
# 读取时先计算 bucket
bucket=$((user_id % 100))
HGET user:config:$bucket $user_id
Set 类型 BigKey:
# 原方案:单 Set 存大量标签用户
SADD tag:python user1 user2 ...
# 拆分方案:按 user_id 分片
SADD tag:python:0 user1 # user_id 哈希值末位为 0
SADD tag:python:1 user2 # user_id 哈希值末位为 1
# 查询时合并(Redis Cluster 下可用 tag 保证同 slot)
SUNION tag:python:0 tag:python:1 ... tag:python:15
9.4 删除 BigKey 的安全做法
# 错误的:直接 DEL,可能阻塞主线程数秒到数分钟
DEL big_hash
# 正确的:分批删除
# String:无法分批,但如果可以设过期,用 EXPIRE
EXPIRE big_string 1
# List:分段删除
LLEN big_list
total=$(redis-cli LLEN big_list)
for i in $(seq 1 100 $total); do
# 每次删 100 个
redis-cli LTRIM big_list 100 -1
done
# Hash:用 HSCAN 分批删
redis-cli HSCAN big_hash 0 COUNT 100 | \
awk 'NR>1{for(i=1;i<=NF;i++) print $i}' | \
xargs -L1 redis-cli HDEL big_hash
# Set:用 SSCAN 分批删
redis-cli SSCAN big_set 0 COUNT 100 | \
awk 'NR>1{for(i=1;i<=NF;i++) print $i}' | \
xargs -L1 redis-cli SREM big_set
# ZSet:用 ZSCAN 分批删
redis-cli ZSCAN big_zset 0 COUNT 100 | \
awk 'NR>1{for(i=1;i<=NF;i+=2) print $i}' | \
xargs -L1 redis-cli ZREM big_zset
# 终极方案:UNLINK(Redis 4.0+)
# 异步删除,主线程只标记,后台线程释放内存
UNLINK big_key
9.5 预防性措施
# 1. 设置 value 大小上限(业务层)
# 2. 监控内存增长
redis-cli INFO memory
# 关注 used_memory、used_memory_rss、mem_fragmentation_ratio
# 3. 设置内存淘汰策略
maxmemory-policy allkeys-lru # 或 volatile-lru
maxmemory 2gb
# 4. 业务代码中增加对大 key 的预警
# 写入时检查 value/size,超过阈值打日志或拒绝
十、总结
Redis 的底层数据结构是一套精密的权衡系统,每一处设计都针对内存、CPU、延迟三个维度做了深度优化:
| 结构 | 解决的问题 | 核心设计 | 适用场景 |
|---|---|---|---|
| SDS | C 字符串的 O(N) 长度、二进制安全 | 头部元数据 + 柔性数组 + 预分配 | 所有 String 类型 |
| ziplist | 小数据指针开销过大 | 连续内存 + 增量编码 | 已被 listpack 取代 |
| listpack | ziplist 级联更新问题 | 去 prevlen,改用 entry-total-len | List / Hash / ZSet 紧凑编码 |
| quicklist | 纯 listpack 的插入效率问题 | listpack + 双向链表 | List 类型 |
| intset | 整数 Set 内存压缩 | 有序数组 + 升级策略 | 小整数 Set |
| skipList | ZSet 范围查询与排名 | 多层跳跃 + span 排名 | Sorted Set |
| dict | 键值映射与 O(1) 访问 | 哈希表 + 渐进式 rehash | Hash / Set / ZSet 字典部分 |
编码转换策略让 Redis 在 “小数据紧凑省内存” 和 “大数据高效快访问” 之间自动切换,但开发者需要了解这些阈值并在必要时调优。BigKey 是生产环境最常见的 Redis 性能陷阱,通过 scan 检测、业务拆分、UNLINK 异步删除等手段可以有效治理。
理解 Redis 的数据结构,不仅仅是知道几个名词。它是你进行容量规划、性能调优、故障排查的底层逻辑基础。下一次当你看到 Redis 内存突然暴涨、某个命令延迟飙升时,你会知道该去检查 encoding、看是不是触发了编码转换、是不是有 BigKey 在拖累整个实例。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。