--- tags: [Redis, 缓存, 数据结构] create time: 2026-05-15 18:11 author: hhs --- # Redis 核心数据类型与底层编码 ## 概述 > [!tip] 先别急着背编码,想想你在业务里会怎么用 > 你用 Redis,第一件事不是去记 `ziplist` 和 `hashtable` 的区别——而是搞清楚:**什么时候该用哪种数据类型?** Redis 提供五种基础数据类型,用一句话总结各自的核心价值: | 类型 | 一句话定位 | 最典型的场景 | |------|-----------|-------------| | **String** | 万能小抽屉 | 缓存、计数器、分布式锁 | | **Hash** | 一个对象的多个字段 | 用户信息、配置项 | | **List** | 有序队列 | 消息队列、最新动态 | | **Set** | 不重复的袋子 | 标签、点赞、共同好友 | | **ZSet** | 带权重的排行榜 | 排行榜、延迟任务 | > [!question] 那"底层编码"又是什么? > 你在业务层只管用 `String`、`Hash` 这些类型——但 Redis 在底层会**自动选择最省内存的存储方式**。比如同样是 `String`,存数字 `42` 只占几个字节,存一段长 JSON 可能几百字节。这就是"编码"在起作用。 > > 了解编码不是为了手动管理它,而是帮你**预判性能和内存消耗**,避免踩坑(比如超大 Hash 的 `HGETALL` 阻塞)。 ### 前置概念:底层编码结构速览 > [!tip] 这一节是"词典",不用现在全记住 > 下面六个概念会在后续各节的「深入」部分反复出现。先混个脸熟,遇到时回来查即可。 | 结构 | 一句话定义 | 用在哪里 | |------|-----------|---------| | **ziplist** | 连续内存的紧凑列表,省空间但查找 O(N) | Hash / List(quicklist 节点) / ZSet(小数据量) | | **listpack** | ziplist 的继任者,去掉了 prevlen 彻底解决级联更新 | Redis 7.2+ 替代 ziplist | | **hashtable** | 标准哈希表,查找 O(1) | Hash / Set / ZSet 的编码之一 | | **intset** | 有序整数数组,纯整数集合时使用 | Set(全为整数时) | | **quicklist** | 双向链表,每个节点是一个 ziplist/listpack | List 的统一编码 | | **skiplist** | 多层索引的有序链表,范围查询 O(logN) | ZSet(大数据量时) | | **SDS** | 兼容 C 的动态字符串,O(1) 求长度、二进制安全、自动扩容 | 所有 Redis key、String 值、缓冲区等 | > 想深入了解? [[02-核心数据类型/02-5-SDS|SDS]] · [[02-核心数据类型/02-1-ziplist与listpack|ziplist 与 listpack]] · [[02-核心数据类型/02-2-skiplist|skiplist]] · [[02-核心数据类型/02-3-hashtable|hashtable]] · [[02-核心数据类型/02-4-quicklist|quicklist]] ### key → value 映射全景 > [!tip] 五种类型,同一个套路 > 无论哪种类型,Redis 的 key 永远是**一个字符串**(底层 SDS)。区别只在于 **value 的内部组织方式**。 ```mermaid flowchart LR K["Redis Key 空间, 永远是 SDS"] --> V1["String: 一坨不可分割的值"] K --> V2["Hash: 多个 field-value 对"] K --> V3["List: 有序可重复序列"] K --> V4["Set: 无序不重复集合"] K --> V5["ZSet: member-score 对, 按 score 排序"] classDef key fill:#e1f5fe,stroke:#2196f3 classDef val fill:#fff3e0,stroke:#ff9800 class K key class V1,V2,V3,V4,V5 val ``` 每个类型的 value 具体长什么样——用实际业务数据来感受: **String**:value 就是一个原子值,Redis 不关心里面是 JSON 还是纯文本。 ``` SET "session:abc123" "eyJhbGciOi..." ┌──────────────────┐ ┌──────────────────────┐ │ key │ │ value │ │ "session:abc123" │ ──→ │ "eyJhbGciOi..." │ │ │ │ 就是一个字符串,没了 │ └──────────────────┘ └──────────────────────┘ ``` **Hash**:value 是一张"小表格",field 活在 value 内部,不是 Redis key。 ``` HSET "user:1001" name "alice" age "25" email "a@x.com" ┌──────────────────┐ ┌───────────────────────────┐ │ key │ │ value │ │ "user:1001" │ ──→ │ name → "alice" │ │ │ │ age → "25" │ │ │ │ email → "a@x.com" │ │ │ │ field 不是 key,仅限 value 内│ └──────────────────┘ └───────────────────────────┘ ``` **List**:value 是一条有序队列,元素没有名字,只有位置。 ``` LPUSH "unread:1001" "notif:501" "notif:500" "notif:499" ┌──────────────────┐ ┌─────────────────────────────────┐ │ key │ │ value │ │ "unread:1001" │ ──→ │ [0]notif:499 [1]notif:500 [2]... │ │ │ │ ← 旧 新 → │ │ │ │ 元素可以重复,有顺序 │ └──────────────────┘ └─────────────────────────────────┘ ``` **Set**:value 是一个不重复的袋子,没有顺序、没有分数。 ``` SADD "online:chat42" "user:1001" "user:2002" "user:3003" ┌──────────────────┐ ┌──────────────────────────────┐ │ key │ │ value │ │ "online:chat42" │ ──→ │ { user:1001, user:2002, │ │ │ │ user:3003 } │ │ │ │ 去重无序,SADD 重复元素只保留一份 │ └──────────────────┘ └──────────────────────────────┘ ``` **ZSet**:value 是一组 (member, score) 对,member 唯一、score 可重复。 ``` ZADD "leaderboard" 100 "alice" 200 "bob" 150 "carol" ┌──────────────────┐ ┌──────────────────────────────┐ │ key │ │ value │ │ "leaderboard" │ ──→ │ bob → 200 ← 最高 │ │ │ │ carol → 150 │ │ │ │ alice → 100 ← 最低 │ │ │ │ 按 score 排序,member 不可重复 │ └──────────────────┘ └──────────────────────────────┘ ``` > [!question] Hash 的 field 和 ZSet 的 member 都"像 key",它们到底是不是 key? > **都不是。** 它们只存在于所属 key 的 value 内部。`HGET user:1001 name` 必须带上 key `user:1001` 才能访问——你不能用 `name` 全局查找。同理,ZSet 的 `member` 也不能脱离 `leaderboard` 独立存在。它们的作用域**仅限于各自 key 的 value 内部**。 我们先从最简单的 String 开始,逐个击破。 ## 一、String(字符串) String 是 Redis 最基础的类型——**一个 key 对应一个值**,就这么简单。 ```go // 存一个 Token,24 小时过期(原子操作) rdb.Set(ctx, "token:abc", jwtPayload, 24*time.Hour) // 原子递增:接口调用计数器,天然线程安全 rdb.Incr(ctx, "counter:api") // MGET 批量获取:一次网络往返取多个 key,比循环 GET 快得多 vals, _ := rdb.MGet(ctx, "user:1", "user:2", "user:3").Result() ``` > [!question] 为什么 String 还能做计数器? > `INCR` 是 Redis 的原子命令,底层直接操作整数值,不需要"先读再改再写"。100 个请求同时 `INCR` 也不会丢数据——这是它比你自己维护计数器靠谱得多的原因。 ### 深入:三种内部编码 同样是 String,Redis 底层用了三种不同的存储方式: | 编码 | 触发条件 | 典型场景 | |------|---------|---------| | `int` | 值可表示为长整型 | 计数器 (`INCR`) | | `embstr` | ≤ 44 字节(Redis 7 之前是 39B) | 短文本、小型 JSON | | `raw` | > 44 字节 | 大对象 JSON / Base64 | > [!question] embstr 和 raw 有什么区别? > 简单理解:`embstr` 是"打包好的快递"——key 和 value 的内存**连在一起分配**,创建销毁都只要一次操作,效率高。`raw` 是"分开打包"——支持值变大后扩容。超过 44 字节时,`embstr` 无法原地扩展,只能切换成 `raw`。 > > 你不需要手动管理这些——Redis 根据值的大小自动选择。用 `MEMORY USAGE key` 可以验证实际占用。 > [!insight] 一个常见的误解 > `"42"` 存进去是 `int` 编码,只占几个字节;`"hello world"` 是 `embstr`;一段长 JSON 是 `raw`。所以**同一个 `SET` 命令,不同值的内存差距可以是几十倍**——这不是 Bug,是编码策略在起作用。 ## 二、Hash(哈希表) Hash 就是**一个 key 下面存多个字段**——和编程语言里的 Map/字典一模一样。 ```bash # 存一个用户对象,每个字段独立存取 HSET user:1 name alice age 25 email a@x.com HGET user:1 name # → "alice" HSET user:1 age 26 # 只改 age,其他字段不受影响 ``` > [!question] 为什么不直接用 String 存 JSON? > String 存 JSON,改一个字段要"读出整个 JSON → 在应用层改 → 写回去"。Hash 是结构化的,`HSET user:1 age 26` 一行搞定,**不需要碰其他字段**。这是 Hash 最大的优势。 ### 深入:编码切换 ```mermaid flowchart LR A["ziplist"] -->|"entries > 512 or value >= 64B"| B["hashtable"] style A fill:#e1f5fe style B fill:#fff3e0 ``` 阈值由两个独立配置控制(注意是**两个参数**,不是一个): - `hash-max-ziplist-entries 512`:Hash 中 field 数量超过 512 → 切 hashtable - `hash-max-ziplist-value 64`:任意一个 field-value 超过 64 字节 → 切 hashtable > [!TIP] 调参建议 > 在生产环境中,如果对象字段数稳定在 1000 以内且单值较短,可以适当调高 `entries` 到 1024 以充分利用 ziplist 的内存优势。反之,如果频繁出现大字段,建议降低 `entries` 让它尽早切到 hashtable,避免 ziplist 的级联更新(cascade update)性能问题。 ### String vs Hash:怎么选? | 维度 | String(JSON) | Hash(字段级) | |------|----------------|----------------| | 读取 | ✅ `GET user:1` 一行搞定 | `HGETALL user:1` 需合并 | | 局部更新 | ❌ 读 → 改 → 写整个 JSON | ✅ `HSET user:1 email b@x.com` | | 内存占用 | 较高(序列化开销) | 较低(ziplist 省 ~40%) | | 适用场景 | 小数据、读多写少 | 中等大小、频繁部分更新 | > [!caution] 警惕大 Hash > `HGETALL` 在 hashtable 编码下是 O(N),字段数很大时会阻塞 Redis。超过几千个字段,考虑拆成多个 key 或改回 String(JSON)。 ## 三、List(列表) List 就是**有序的队列**——从一头进,从另一头出。 ```go // 发布新消息到 Feed 流(从左边插入最新消息) rdb.LPush(ctx, "news-feed", jsonMessage) // 修剪到最近 1000 条,防止列表无限增长 rdb.LTrim(ctx, "news-feed", 0, 999) // 取出最近 10 条展示给用户 items, _ := rdb.LRange(ctx, "news-feed", 0, 9).Result() ``` > [!question] LPUSH + LTRIM 为什么是黄金搭档? > `LPUSH` 总是把最新消息插到最前面,`LTRIM` 把超出的旧消息砍掉。两者配合就是一个**固定长度的环形缓冲区**——Feed 流、日志轮转的标配模式。 ### 典型场景 | 场景 | 命令 | 说明 | |------|------|------| | 消息队列 | `LPUSH` + `BRPOP` | 单消费者阻塞式消费 | | 最新 N 条记录 | `LPUSH` + `LTRIM` + `LRANGE` | Feed 流、操作日志 | | 延迟队列 | 用 ZSet 更合适 | List 本身不支持按时间排序 | ### 深入:quicklist 结构 > [!question] 为什么不用普通链表? > 普通双向链表每个节点都有 16 字节的指针开销。quicklist 把多个元素**打包进一个紧凑块**(ziplist),大幅减少指针浪费——就像把散装零食装进密封袋,省空间又好管理。 ```mermaid flowchart LR H["head"] <--> Z1["ziplist node 1, max 4096 items"] Z1 <--> Z2["ziplist node 2, max 4096 items"] Z2 <--> Z3["ziplist node N, max 4096 items"] 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`:默认不压缩首尾节点(方便头部操作) > 想深入了解? → [[02-核心数据类型/02-4-quicklist|quicklist 结构详解]] ## 四、Set(集合) Set 就是**不重复的集合**——同一个元素加多少次都只保留一份。它的杀手锏是**集合运算**:交集、并集、差集。 ```go // 点赞:同一个用户只能赞一次,重复 SAdd 自动去重 rdb.SAdd(ctx, "like:post:42", "user:100") // 判断是否已点赞 liked, _ := rdb.SIsMember(ctx, "like:post:42", "user:100").Result() // 两个帖子的共同点赞者——一行代码搞定,不需要在应用层做交集 common, _ := rdb.SInter(ctx, "like:post:42", "like:post:99").Result() ``` ### 集合运算示例 ```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 — 所有标签 ``` > [!tip] 集合运算是 Set 区别于其他类型的核心能力 > "共同好友"、"猜你喜欢"、"去重统计"——凡是涉及**两个集合之间的关系**,都该想到 Set。 ### 深入:intset 编码 当 Set 里**全是整数**时,Redis 会用 **intset**(整数集合)存储。它的本质是一块**连续的、有序的整数数组**,支持二分查找(O(logN)),内存极其紧凑——没有指针、没有哈希桶,每个元素只占它自身的字节数。 > [!question] intset 比 hashtable 省多少? > 存 1000 个整数:hashtable 每个 `dictEntry` 至少 24 字节(key 指针 + value 指针 + next),总计 ~24KB+。intset 用 `int32` 存储只需 4KB——**省了 80%**。 **intset 内存布局**: ```mermaid flowchart LR EN["encoding, 4B, 16/32/64 位"] --> LN["length, 4B, 元素个数"] LN --> C1["contents, 有序排列的整数数组"] classDef header fill:#e1f5fe,stroke:#2196f3 classDef data fill:#fff3e0,stroke:#ff9800 class EN,LN header class C1 data ``` **自动升级机制**:当插入一个比当前编码更大的整数时,intset 会**升级**——把所有元素扩展到更宽的类型(如 `int16` → `int32`),然后插入新元素。这是单向的,**不会自动降级**。 | 配置项 | 默认值 | 说明 | |--------|--------|------| | `set-max-intset-entries` | 512 | 元素数超过 512 → 切换为 hashtable | > [!caution] intset 一旦遇到非整数就"永久"切换 > 加入一个字符串后,整个 Set 切成 hashtable,即使后来删掉了那个字符串,**也不会切回 intset**。所以如果你的 Set 明确只存整数(如用户 ID 集合),别手滑加字符串。 ```mermaid flowchart LR E["Set 添加元素"] --> Q{"全部是整数?"} Q -->|"是"| Q2{"元素数 <= 512?"} Q2 -->|"是"| I["intset, 有序数组, 内存极紧凑"] Q2 -->|"否"| H["hashtable, O(1) 查找"] Q -->|"否"| H classDef encoding fill:#e1f5fe,stroke:#2196f3 classDef encoding2 fill:#fff3e0,stroke:#ff9800 classDef check fill:#e8f5e9,stroke:#4caf50 class I encoding class H encoding2 class Q,Q2 check ``` > [!tip] SSCAN 避免阻塞 > `SSCAN` 可以增量迭代大集合,避免单次命令阻塞 Redis——和 Hash 的 `HSCAN` 同理。大集合千万别用 `SMEMBERS` 一次全捞。 ## 五、ZSet / Sorted Set(有序集合) ZSet 是 Redis 中**最强大**的类型——每个元素都有一个分数(score),天然有序。排行榜、延迟任务、带权重的队列,都是它的主场。 ```bash # 添加成员和分数 ZADD leaderboard 100 "player1" 200 "player2" 150 "player3" # 取 Top 10(按分数从高到低) ZREVRANGE leaderboard 0 9 WITHSCORES # 查某个玩家的排名和分数 ZREVRANK leaderboard "player3" # → 2(第 3 名) ZSCORE leaderboard "player2" # → 200 # 按分数范围查询(Redis 6.2+ 新语法,替代 ZRANGEBYSCORE) ZRANGE leaderboard 0 150 BYSCORE ``` ### 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) } ``` > [!warning] 命令迁移提示 > Redis 6.2 将 `ZRANGEBYSCORE` / `ZREVRANGEBYSCORE` / `ZREVRANGE` 统一合并为 `ZRANGE` 的参数形式(`BYSCORE` / `BYLEX` / `REV`)。旧命令仍可用但已弃用,新代码直接用 `ZRANGE ... BYSCORE`。 ### ZSet vs List 做消息队列 | 维度 | List (BRPOP) | ZSet (BZPOPMIN) | |------|-------------|-----------------| | FIFO 顺序 | ✅ 天然保证 | ⚠️ 依赖 score 设置 | | 延迟执行 | ❌ 不支持 | ✅ score = 执行时间戳 | | 重排优先级 | ❌ 需重建列表 | ✅ 改 score 即可 | | 复杂度 | O(log(N/M)) | O(log N) | > [!tip] 什么时候用 List、什么时候用 ZSet? > 简单 FIFO 队列用 List;需要延迟执行、优先级排序、按时间范围查询的场景,用 ZSet。 ### 深入:为什么需要 skiplist + hashtable 两套结构? ```mermaid flowchart LR Key["key: leaderboard"] --> SL["skiplist, 按 score 排序, 范围查询 O(log N)"] Key --> HT["hashtable, 按 member 查找, O(1) 定位"] classDef primary fill:#e8f5e9,stroke:#4caf50 classDef secondary fill:#fff3e0,stroke:#ff9800 class SL secondary class HT secondary ``` skiplist 天然有序,擅长范围查询("给我分数在 100~200 之间的所有成员"),但按 member 名字查找要遍历;hashtable 查找 O(1),但无序。两者互补,代价是**每个元素存了两份索引**——这是 Redis 中内存开销最大的数据结构。 > [!warning] ZSet 性能陷阱 > 不要往同一个 ZSet 里塞超过百万级别的元素——skiplist 虽然 O(log N),但百万级的维护成本不小。可以按分片拆成多个 ZSet。 ## 同一业务场景:五种类型怎么选? 假设你在做一个"用户主页",需要缓存以下信息: | 信息 | 最佳类型 | key 设计 | value 存什么 | |------|---------|---------|-------------| | 用户 Token | **String** | `"token:uid1001"` | `"eyJhbG..."` (一个字符串) | | 用户资料 | **Hash** | `"user:1001"` | `{name, age, bio...}` (多个字段,可部分更新) | | 最近浏览记录 | **List** | `"browsed:1001"` | `["商品A","商品B","商品C"]` (有序可重复) | | 用户标签 | **Set** | `"tags:1001"` | `{"Go","Redis","后端"}` (去重无序) | | 全站积分排名 | **ZSet** | `"rank:global"` | `{uid1001:5200, uid2002:4800...}` (按分数排序) | > [!summary] 一句话记忆 > **key 是门牌号,value 是房间里的东西。** String 放一坨、Hash 放一张表、List 放一条队列、Set 放一个袋子、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 在底层用不同的编码策略(ziplist、intset、skiplist 等)来**自动优化内存和性能**——你不需要手动管理,但理解它能帮你预判行为、避免踩坑。 > [!summary] 选型速查 > - **缓存对象** → Hash(字段多、常部分更新时)或 String(JSON 整体读写时) > - **计数 / 锁 / Token** → String(int 编码最省内存) > - **Feed 流 / 队列** → List(LPUSH + LTRIM 黄金搭档) > - **标签 / 去重 / 关系运算** → Set(交集并集差集一步到位) > - **排行榜 / 延时任务 / 带权重** → ZSet(score 是一切的基础) ## 关联笔记 - [[02-核心数据类型/02-5-SDS]] — 底层编码:SDS 结构与空间优化策略 - [[02-核心数据类型/02-1-ziplist与listpack]] — 底层编码:ziplist 内存布局与级联更新 - [[02-核心数据类型/02-2-skiplist]] — 底层编码:skiplist 原理与 ZSet 双索引设计 - [[02-核心数据类型/02-3-hashtable]] — 底层编码:dict 结构与渐进式 rehash - [[02-核心数据类型/02-4-quicklist]] — 底层编码:quicklist 结构与 LZF 压缩 - [[hhs/Redis/08-SortedSet精解]] — ZSet 的高级玩法 - [[hhs/Redis/03-基本命令速查]] — 常用命令速查表 - [[hhs/Redis/07-集群方案]] — 集群环境下的大 Key 风险