2. Redis 基础数据结构:底层编码与选型

在搜索缓存场景中,选择正确的 Redis 数据结构直接决定了内存效率与访问延迟。本章深入剖析五种核心数据结构的底层编码机制,并给出基于搜索业务特征的选型建议。

2.1 数据结构总览与编码演进

Redis 为每种数据结构设计了多种底层编码(encoding),根据存储元素的数量、大小动态切换。这种设计在节省内存与保证性能之间取得了平衡。

数据结构 底层编码(Redis 7+) 切换条件 搜索缓存典型场景
String int / embstr / raw 整数 vs 短字符串 vs 长字符串 缓存单个文档、计数器、令牌
Hash ziplist / listpack / hashtable field 数量 < 512 且 value 长度 < 64 字节 存储文档字段、用户画像
List quicklist(ziplist 节点) 固定使用 quicklist,节点内压缩 消息队列、时间线、分页缓存
Set intset / hashtable 元素全为整数且数量 < 512 标签去重、倒排索引交集
Sorted Set ziplist / listpack / skiplist + dict 元素数量 < 128 且 value 长度 < 64 字节 搜索排名、热门词、分页排序
编码切换是透明的:应用层无需感知底层编码变化,但理解切换阈值有助于预估内存拐点。例如,Hash 在 field 超过 512 个时从紧凑的 listpack 转为 hashtable,内存消耗会显著上升。

2.2 String:最基础的缓存单元

String 是 Redis 最基础的数据结构,底层编码分为三种:

  • int:存储 64 位有符号整数,直接编码在 redisObject 中,零额外内存。
  • embstr:存储长度 ≤ 44 字节的字符串,redisObject 与 sdshdr 连续分配,一次内存分配。
  • raw:存储长度 > 44 字节的字符串,redisObject 与 sdshdr 分开分配,两次内存分配。
# 查看 key 的编码
127.0.0.1:6379> SET counter 100
OK
127.0.0.1:6379> OBJECT ENCODING counter
"int"

127.0.0.1:6379> SET short_str "hello"
OK
127.0.0.1:6379> OBJECT ENCODING short_str
"embstr"

127.0.0.1:6379> SET long_str $(python -c "print('x'*45)")
OK
127.0.0.1:6379> OBJECT ENCODING long_str
"raw"
搜索缓存注意:缓存单个文档时,若文档体超过 44 字节(几乎必然),String 会使用 raw 编码。此时每次读取都涉及两次内存分配,但读写复杂度仍为 O(1)。对于超大文档(>10KB),建议考虑压缩后存储或分片。

2.3 Hash:结构化文档缓存的首选

Hash 在搜索缓存中用于存储文档的多个字段,避免序列化整个文档。底层编码演进:

  • ziplist(Redis 7 前):所有 field-value 对按顺序压缩存储。
  • listpack(Redis 7+):替代 ziplist,更紧凑、更安全。
  • hashtable:当 field 数量或 value 长度超过阈值时切换,支持 O(1) 随机访问。

切换阈值由 hash-max-listpack-entries(默认 512)和 hash-max-listpack-value(默认 64)控制。

# 配置查看
127.0.0.1:6379> CONFIG GET hash-max-listpack-entries
1) "hash-max-listpack-entries"
2) "512"

127.0.0.1:6379> CONFIG GET hash-max-listpack-value
1) "hash-max-listpack-value"
2) "64"
搜索缓存最佳实践:对于文档字段数 < 500、字段值长度 < 60 字节的场景(如标题、摘要、标签),Hash 使用 listpack 编码,内存效率极高。若字段包含长文本(如正文),建议单独使用 String 缓存,Hash 只存储元数据。

2.4 List:队列与分页缓存

List 底层使用 quicklist,它是一个双向链表,每个节点是一个 ziplist(或 listpack)。这种设计兼顾了两端操作的高效性与内存紧凑性。

  • 左端/右端操作:O(1),适合 LPUSH / RPOP 消息队列。
  • 索引访问:O(N),N 为节点数,不适合随机访问。
  • 分页缓存:LRANGE 命令需要遍历节点,性能随偏移量增大而下降。
# 分页缓存示例:存储搜索结果 ID 列表
LPUSH search:query:redis "doc:1001" "doc:1002" "doc:1003"
# 分页读取第 1 页(每页 10 条)
LRANGE search:query:redis 0 9
搜索分页注意:List 适合小规模分页(前几百条),对于深度分页(如第 100 页),LRANGE 需要遍历大量节点,延迟会显著增加。此时建议使用 Sorted Set 按 score 分页。

2.5 Set:标签与倒排索引

Set 用于存储不重复元素,底层编码:

  • intset:所有元素为整数且数量 ≤ 512 时使用,有序数组存储,内存紧凑。
  • hashtable:元素非整数或数量超过阈值时切换,O(1) 成员判断。

在搜索缓存中,Set 常用于:

  • 标签系统:存储文档所属标签,支持 SINTER 求交集。
  • 倒排索引:每个词项对应一个 Set,存储包含该词的文档 ID。
  • 去重队列:SADD 保证唯一性,SISMEMBER 快速判断。
# 倒排索引示例:词项 "redis" 对应的文档 ID 集合
SADD idx:redis "doc:1001" "doc:1002" "doc:1005"
# 词项 "cache" 对应的文档 ID 集合
SADD idx:cache "doc:1001" "doc:1003"
# 同时包含 "redis" 和 "cache" 的文档
SINTER idx:redis idx:cache
1) "doc:1001"

2.6 Sorted Set:搜索排名的核心

Sorted Set 是搜索缓存中最重要的数据结构,底层编码:

  • ziplist / listpack:元素数量 ≤ 128 且 value 长度 ≤ 64 字节时使用,按 score 顺序存储。
  • skiplist + dict:超过阈值后使用,skiplist 保证有序性,dict 保证 O(1) 成员查找。

skiplist 是平衡树的替代方案,实现简单且支持范围查询。每个节点包含多个层级的指针,平均查找复杂度 O(log N)。

# 搜索排名示例:存储热门搜索词及其搜索次数
ZADD hot:search:2024-01-15 1000 "redis" 850 "cache" 720 "elasticsearch"
# 获取排名前 10 的热词
ZREVRANGE hot:search:2024-01-15 0 9 WITHSCORES
1) "redis"
2) "1000"
3) "cache"
4) "850"
5) "elasticsearch"
6) "720"
内存敏感场景:Sorted Set 的 skiplist 编码内存开销较大(每个节点约 30-50 字节额外指针)。对于百万级排名数据,建议控制元素数量或使用 Redis 的 zremrangebyrank 定期裁剪。

2.7 底层编码结构对比

以下 SVG 展示了五种数据结构的底层编码切换路径与内存布局:

Redis 五种数据结构底层编码切换图 Redis 数据结构底层编码切换路径 String int (整数) embstr (≤44B) raw (>44B) Hash listpack hashtable 阈值: field>512 或 value>64B List quicklist ziplist 节点 ziplist 节点 双向链表结构 Set intset hashtable 阈值: 非整数或 >512 元素 Sorted Set listpack skiplist+dict 阈值: >128 元素或 >64B 编码切换由 Redis 自动完成,基于元素数量与大小阈值 listpack 是 Redis 7+ 对 ziplist 的替代,更安全紧凑 内存布局对比:listpack vs skiplist listpack(紧凑编码) | total-bytes | num-elements | entry1 | entry2 | ... | entryN | end | 连续内存,无指针开销,适合小数据量 skiplist(有序结构) | L3 | → | node | → | node | → | NULL | 多层指针,O(log N) 查找,适合大数据量 vs

2.8 搜索缓存选型决策矩阵

基于底层编码特性,以下矩阵帮助你在搜索缓存场景中快速选择合适的数据结构:

搜索缓存需求 推荐数据结构 底层编码优势 避坑提示
缓存单个文档(JSON/文本) String raw 编码,O(1) 读写 大文档(>1MB)考虑压缩
缓存文档字段(元数据) Hash listpack 紧凑存储 字段数超过 512 后内存飙升
搜索结果 ID 列表(小规模) List quicklist 两端操作快 深度分页性能差
搜索结果 ID 列表(大规模) Sorted Set skiplist 支持范围查询 内存开销大,需控制元素数
标签/倒排索引 Set intset 整数集合紧凑 非整数元素自动转 hashtable
搜索排名/热门词 Sorted Set skiplist 按 score 排序 定期裁剪旧数据
计数器(PV/UV) String int 编码零额外内存 INCR 命令原子操作
消息队列(异步索引) List quicklist 左进右出 使用 BRPOP 阻塞读取

2.9 编码切换的监控与调优

在生产环境中,通过以下命令监控编码状态:

# 查看 key 的编码
OBJECT ENCODING <key>

# 查看内存使用
MEMORY USAGE <key>

# 查看配置阈值
CONFIG GET *-max-listpack-entries
CONFIG GET *-max-listpack-value
调优建议:如果搜索缓存中 Hash 的 field 数量稳定在 500 左右,建议将 hash-max-listpack-entries 调大到 1024,避免频繁切换为 hashtable。同样,Sorted Set 的 zset-max-listpack-entries 可根据排名数据量调整。

2.10 总结:编码选型三原则

  1. 小数据用紧凑编码:元素少、值小时,listpack/intset 能节省 50%-70% 内存。
  2. 大数据用高效结构:元素多时,hashtable/skiplist 保证 O(1) 或 O(log N) 操作。
  3. 按访问模式选型:频繁范围查询用 Sorted Set,频繁成员判断用 Set,频繁字段更新用 Hash。