Redis 数据结构深度解析:SDS、ziplist、skipList 与编码转换

深入 Redis 底层数据结构实现原理,从 SDS 到 ziplist、quicklist、skiplist,理解编码转换与 BigKey 治理

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 语言的原生字符串存在三个致命缺陷:

  1. 获取长度需要 O(N) 遍历strlen() 必须扫描到 \0
  2. 缓冲区溢出风险strcat 等操作不会检查目标缓冲区容量
  3. 二进制不安全:遇 \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
sdshdr8uint8_t255
sdshdr16uint16_t65535
sdshdr32uint32_t约 4G
sdshdr64uint64_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 的问题是:

  1. 插入和删除中间元素需要 memmove,复杂度 O(N)
  2. ziplist 整体长度受 list-max-ziplist-size 限制(默认 8KB)
  3. 不能高效地在两端以外的位置插入

纯双向链表的问题是:每个节点的指针开销太大。

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(两端插入)

  1. 找到 head/tail 节点
  2. 在该节点的 ziplist 中插入
  3. 如果 ziplist 超过 size 限制,新建一个节点

LINDEX index(随机访问)

  1. 判断 index 靠近头还是尾,决定从头或尾开始遍历节点
  2. 在每个节点内用 ziplist 的接口定位元素

LPOP / RPOP(两端弹出)

  1. 从 head/tail 节点的 ziplist 弹出元素
  2. 如果 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;
}

升级的特点:

  1. 只升不降:编码升级后不会降级,即使删除大元素
  2. 触发一次:从小升级到大后,后续同范围操作无需再升级
  3. 二分查找:intSet 内部有序,查找复杂度 O(log N)

5.3 编码转换触发条件

# Set 类型默认配置
set-max-intset-entries 512

当 intSet 元素超过 512 个时,自动转换为 dict(哈希表)。转换过程:

  1. 新建一个 dict
  2. 遍历 intSet,每个元素作为 key 插入 dict(value 为 NULL)
  3. 释放 intSet,替换为 dict

intSet 的内存效率极高。一个存储 500 个 int32 的 Set,intSet 仅需约 2KB,而 dict 至少需要 8KB 以上(哈希表预分配 + 指针开销)。


六、skipList:多层跳跃链表

6.1 Sorted Set 为什么用 skipList

Redis 的 Sorted Set 需要同时支持两种查询方式:

  1. 按 member 查找 score(类似 Hash)
  2. 按 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 的危害:

  1. 阻塞主线程:一次操作需要遍历或传输大量数据
  2. 网络拥塞:一个请求返回 10MB 数据,带宽被占满
  3. 持久化阻塞:RDB / AOF 重写时内存拷贝耗时增加
  4. 主从同步延迟:slave 同步大 key 时长时间阻塞
  5. 内存碎片:大 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、延迟三个维度做了深度优化:

结构解决的问题核心设计适用场景
SDSC 字符串的 O(N) 长度、二进制安全头部元数据 + 柔性数组 + 预分配所有 String 类型
ziplist小数据指针开销过大连续内存 + 增量编码已被 listpack 取代
listpackziplist 级联更新问题去 prevlen,改用 entry-total-lenList / Hash / ZSet 紧凑编码
quicklist纯 listpack 的插入效率问题listpack + 双向链表List 类型
intset整数 Set 内存压缩有序数组 + 升级策略小整数 Set
skipListZSet 范围查询与排名多层跳跃 + span 排名Sorted Set
dict键值映射与 O(1) 访问哈希表 + 渐进式 rehashHash / Set / ZSet 字典部分

编码转换策略让 Redis 在 “小数据紧凑省内存” 和 “大数据高效快访问” 之间自动切换,但开发者需要了解这些阈值并在必要时调优。BigKey 是生产环境最常见的 Redis 性能陷阱,通过 scan 检测、业务拆分、UNLINK 异步删除等手段可以有效治理。

理解 Redis 的数据结构,不仅仅是知道几个名词。它是你进行容量规划、性能调优、故障排查的底层逻辑基础。下一次当你看到 Redis 内存突然暴涨、某个命令延迟飙升时,你会知道该去检查 encoding、看是不是触发了编码转换、是不是有 BigKey 在拖累整个实例。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. 缓存架构演进之路:从单机 Redis 到亿级分布式多级缓存体系
  2. Redis 7.x 重大新特性与架构升级深度解析
  3. Redis 消息队列深度对比:Pub/Sub、Streams 与 Kafka/RabbitMQ 选型指南