--- tags: [Redis, 缓存, 数据结构] create time: 2026-05-15 18:11 --- # Redis 核心数据类型与底层编码 ## 概述 > [!QUESTION] 同一个 `SET` 命令,有时占内存几字节,有时几百字节——是 Bug 吗? > 不是。Redis 会根据值的特征**自动选择最省内存的内部表示**。这就是你要理解的"编码"概念。 Redis 有五种基础数据类型:**String、Hash、List、Set、ZSet**。但每种类型在不同场景下会切换不同的**内部编码**——理解这一点比单纯记忆命令更重要,因为它直接决定了操作的时间复杂度和内存消耗。 --- ```mermaid flowchart TD subgraph "String" S1["int — 整数"] S2["embstr (≤44B) — 短字符串"] S3["raw — 长字符串"] end subgraph "Hash" H1["ziplist — 元素少且小"] H2["hashtable — 超出阈值后切换"] end subgraph "List" L1["quicklist (ziplist 节点 + 指针)"] end subgraph "Set" ST1["intset — 全是整数"] ST2["hashtable — 出现非整数"] end subgraph "ZSet" Z1["ziplist — 元素少且差距小"] Z2["skiplist + hashtable — 大规模数据"] end ``` ## 一、String(字符串) ### 三种内部编码 | 编码 | 触发条件 | 典型场景 | |------|---------|---------| | `int` | 值可表示为长整型 | 计数器 (`INCR`) | | `embstr` | ≤ 44 字节(Redis 7 之前是 39B) | 短文本存储 | | `raw` | > 44 字节 | 大对象 JSON / Base64 | > [!QUESTION] embstr 和 raw 的区别? > - `embstr`:一块连续内存分配,创建/销毁只需一次 malloc/free,效率高 > - `raw`:分开分配 SDS 结构体和 redisObject 头,支持扩容 > - 超过 44B 时 embstr 不可变,只能切 raw ### 代码示例(Go go-redis) ```go // 设置过期时间(原子操作) rdb.Set(ctx, "token:abc", jwtPayload, 24*time.Hour) // 原子递增计数器 rdb.Incr(ctx, "counter:api") // MGET 批量获取 vals, _ := rdb.MGet(ctx, "user:1", "user:2", "user:3").Result() ``` > [!INSIGHT] 编码切换是透明的 > 你不需要自己管理编码——`REDISOBJECT.encoding` 字段由 Redis 自动维护。用 `MEMORY USAGE key` 可以看到实际占用字节数,这是验证编码效果最直接的方式。 ## 二、Hash(哈希表) ### 内部编码切换 ```mermaid flowchart LR A["ziplist"] -->|"超出阈值: 节点数>5 或值≥64B"| B["hashtable"] style A fill:#e1f5fe style B fill:#fff3e0 ``` 阈值由两个配置控制: - `hash-max-ziplist-size 5`:节点数 > 5 或某个字段长度 ≥ 64B → 切 hashtable - `hash-max-ziplist-value 64`:单个值 > 64B → 切 hashtable ### 适用场景 | 场景 | 命令 | 说明 | |------|------|------| | 用户信息缓存 | `HSET user:1 name alice age 25` | 只更新需要改的字段 | | 表单数据存储 | `HMSET` / `HGETALL` | 替代 String 存整个 JSON | ### String vs Hash 选型 > [!QUESTION] 为什么更新一个字段要读写整个 JSON? > String 存的是完整序列化字符串,改任何内容都要重新序列化写入。Hash 是结构化的键值对,只修改目标字段即可。 ```mermaid quadrantChart title "String vs Hash — 场景选择矩阵" x-axis "简单操作" --> "复杂操作" y-axis "内存紧凑" --> "灵活存取" "String (JSON)": [0.3, 0.75] "Hash (字段级)": [0.7, 0.85] ``` | 维度 | **方案 A** — String(JSON) | **方案 B** — Hash(字段级) | |------|-----------------------------|----------------------------| | 读取 | `GET user:1` ✅ 一行搞定 | `HGETALL user:1` 需合并 | | 局部更新 | ❌ 读 → 改 → 写整个 JSON | ✅ `HSET user:1 email b@x.com` | | 内存占用 | 较高(重复 key、序列化开销) | 较低(ziplist 紧凑存储) | | 适用规模 | 小数据、读多写少 | 中等大小、频繁部分更新 | > [!TIP] Hash 在 ziplist 编码下内存占用比单独 String 省 ~40%,适合字段数 < 100 的对象缓存。 #### 方案 A — String(JSON 整体存取) ``` GET user:1 → {"name":"alice","age":25,"email":"a@x.com"} ✅ 读写简单 ✅ 序列化一致 ❌ 更新一个字段要读写整个 JSON ``` #### 方案 B — Hash(字段级操作) ``` HSET user:1 email b@x.com ✅ 局部更新,内存更紧凑 ❌ HGETALL 不适合超大 hash ``` > [!CAUTION] 警惕大 Hash > `HGETALL` 在 hashtable 编码下是 O(N),hash 很大时会阻塞 Redis。如果字段数超过几千,考虑拆成多个 key 或改回 String(JSON)。 ## 三、List(列表) ### quicklist 结构 > [!QUESTION] 为什么不用双向链表 + SDS,而要用 quicklist? > 普通双向链表每个节点都有指针开销(16B)。quicklist 把多个元素打包进一个 ziplist 紧凑块中,大幅减少指针浪费。 ```mermaid flowchart LR H["head"] <--> Z1["ziplist 节点 #1\n(≤ 4096 项)"] Z1 <--> Z2["ziplist 节点 #2\n(≤ 4096 项)"] Z2 <--> Z3["ziplist 节点 #N\n(≤ 4096 项)"] Z3 <--> T["tail"] classDef linkPoint fill:#e8f5e9,stroke:#4caf50 classDef znode fill:#fff3e0,stroke:#ff9800 class H,T linkPoint class Z1,Z2,Z3 znode ``` List 统一使用 **quicklist**(压缩链表),每个节点是一个 ziplist: - `list-max-ziplist-size 5`:默认每个节点最多 4096 个元素 - `list-compress-depth 0`:默认不压缩首尾节点(方便头部操作) ### 典型场景 | 场景 | 命令 | 说明 | |------|------|------| | 消息队列 | `LPUSH` + `BRPOP` | 单消费者阻塞式消费 | | 最新 N 条记录 | `LRANGE 0 -1` | 配合 `LTRIM` 固定长度 | | 延迟队列 | ZSet score = timestamp | 比 List 灵活 | ```go // 发布新消息 rdb.LPush(ctx, "news-feed", jsonMessage) // 修剪到最近 1000 条 rdb.LTrim(ctx, "news-feed", 0, 999) // 取出最近 10 条 items, _ := rdb.LRange(ctx, "news-feed", 0, 9).Result() ``` ## 四、Set(集合) ### intset → hashtable 切换 ```mermaid flowchart LR E["Set 添加元素"] --> Q{"全部是\n整数?"} Q -->|"是"| I["intset\n有序数组 · 内存紧凑"] Q -->|"否 — 出现字符串"| H["hashtable\nO(1) 查找 · 集合运算"] classDef encoding fill:#e1f5fe,stroke:#2196f3 classDef encoding2 fill:#fff3e0,stroke:#ff9800 class I encoding class H encoding2 ``` ### 集合操作(唯一能力) ```bash SADD tags:post1 go golang redis SADD tags:post2 redis docker golang SDIFF tags:post1 tags:post2 # go — post1 独有 SINTER tags:post1 tags:post2 # golang redis — 共有的 SUNION tags:post1 tags:post2 # go golang redis docker SCARD tags:post1 # 3 — 元素个数 SISMEMBER tags:post1 go # 1 — 判断成员 ``` ### 应用场景 - 点赞/收藏列表:`SADD like:post:42 user:100` - 共同好友:`SINTER user:A:friends user:B:friends` - 随机抽取:`SRANDMEMBER lottery 5`(不删除)/ `SPOP lottery 5`(删除) > [!TIP] Set 的 SSCAN 命令 > `SSCAN` 可以增量迭代大集合,避免单次命令阻塞 Redis——和 Hash 的 `HSCAN` 同理。 ## 五、ZSet / Sorted Set(有序集合) ### skiplist + hashtable 双结构 > [!QUESTION] 为什么需要两套数据结构? > skiplist 天然有序、支持高效范围查询,但按 member 查找要遍历;hashtable 查找 O(1),但无序。两者互补,代价是每个元素存了两份索引。 ```mermaid flowchart LR Key["key: leaderboard"] --> SL["skiplist\n按 score 排序 · 范围查询 O(log N)"] Key --> HT["hashtable\n按 member 查找 · O(1) 定位"] classDef primary fill:#e8f5e9,stroke:#4caf50 classDef secondary fill:#fff3e0,stroke:#ff9800 class SL secondary class HT secondary ``` 这是 Redis 中**最复杂**的数据结构,代价也是最高的——每个元素存在两份索引。 ### 关键命令 ```bash # 添加(member → score) ZADD leaderboard 100 "player1" 200 "player2" 150 "player3" # 正序排名 1~5 ZRANGE leaderboard 0 4 WITHSCORES # 倒序 Top 10 ZREVRANGE leaderboard 0 9 WITHSCORES # 获取分数 ZSCORE leaderboard "player2" # 200 # 获取排名(从 0 开始) ZRANK leaderboard "player3" # 1 ZREVRANK leaderboard "player3" # 2 # 分数范围查询 ZRANGEBYSCORE leaderboard 0 150 # 移除指定范围 ZREMRANGEBYRANK leaderboard 0 2 ``` ### Go 示例 ```go // 排行榜加分(原子操作) rdb.ZIncr(ctx, "leaderboard", redis.Z{ Score: 10.5, Member: "player42", }) // 获取 Top 10 members, _ := rdb.ZRevRangeWithScores(ctx, "leaderboard", 0, 9).Result() for _, m := range members { fmt.Printf("%s: %.1f\n", m.Member, m.Score) } ``` ### ZSet vs List 做消息队列对比 | 维度 | List (BRPOP) | ZSet (BZPOPMIN) | |------|-------------|-----------------| | FIFO 顺序 | ✅ 保证 | ⚠️ 依赖 score | | 延迟执行 | ❌ 不支持 | ✅ score = 执行时间戳 | | 重排优先级 | ❌ 需重建 | ✅ 改 score 即可 | | 复杂度 | O(log(N/M)) | O(log N) | > [!WARNING] ZSet 性能陷阱 > 不要往同一个 ZSet 里塞超过百万级别的元素——skiplist 虽然 O(log N),但实际维护成本不小。百万级可以考虑按分片拆成多个 ZSet。 ## 快速对照表 | 数据类型 | 编码 | 最佳场景 | 时间复杂度 | |---------|------|---------|-----------| | String | int / embstr / raw | 计数、缓存、分布式锁 | O(1) | | Hash | ziplist / hashtable | 对象缓存(部分更新) | O(1)~O(N) | | List | quicklist | FIFO 队列、feed流 | O(1)~O(N) | | Set | intset / hashtable | 标签、去重、关系运算 | O(1) | | ZSet | ziplist / skiplist+ht | 排行榜、延迟任务、权重 | O(log N) | ## 核心思想:内存与性能的权衡 Redis 的数据类型本质上是一套**编码策略系统**。理解这个系统的关键在于三个原则: 1. **小数据用紧凑编码** — ziplist / intset / embstr 牺牲灵活性换取极致内存效率 2. **大数据切高性能结构** — hashtable / skiplist / raw 以空间换时间,操作复杂度可控 3. **切换是自动且平滑的** — Redis 在后台根据阈值决定何时升级编码,开发者只需关注业务语义 > [!SUMMARY] 实战检查清单 > - 缓存对象 → Hash(ziplist)或 String(JSON),字段多且常部分更新选 Hash > - 计数 / 锁 / Token → String(int 编码最省) > - Feed 流 / 队列 → List(quicklist) > - 标签 / 去重 / 集合运算 → Set(intset) > - 排行榜 / 延时任务 / 带权随机 → ZSet(skiplist) ## 关联笔记 - [[hhs/Redis/08-SortedSet精解]] — ZSet 的高级玩法 - [[hhs/Redis/03-基本命令速查]] — 常用命令速查表 - [[hhs/Redis/07-集群方案]] — 集群环境下的大 Key 风险