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 展示了五种数据结构的底层编码切换路径与内存布局:
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 总结:编码选型三原则
- 小数据用紧凑编码:元素少、值小时,listpack/intset 能节省 50%-70% 内存。
- 大数据用高效结构:元素多时,hashtable/skiplist 保证 O(1) 或 O(log N) 操作。
- 按访问模式选型:频繁范围查询用 Sorted Set,频繁成员判断用 Set,频繁字段更新用 Hash。