Files
cs-note/hhs/Redis/02-核心数据类型.md
T
2026-05-27 23:01:37 +08:00

22 KiB
Raw Blame History

tags, create time, author
tags create time author
Redis
缓存
数据结构
2026-05-15 18:11 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 · 02-核心数据类型/02-1-ziplist与listpack · 02-核心数据类型/02-2-skiplist · 02-核心数据类型/02-3-hashtable · 02-核心数据类型/02-4-quicklist

key → value 映射全景

[!tip] 五种类型,同一个套路 无论哪种类型,Redis 的 key 永远是一个字符串(底层 SDS)。区别只在于 value 的内部组织方式。

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 对应一个值,就这么简单。

// 存一个 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/字典一模一样。

# 存一个用户对象,每个字段独立存取
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 最大的优势。

深入:编码切换

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 就是有序的队列——从一头进,从另一头出。

// 发布新消息到 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),大幅减少指针浪费——就像把散装零食装进密封袋,省空间又好管理。

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

四、Set(集合)

Set 就是不重复的集合——同一个元素加多少次都只保留一份。它的杀手锏是集合运算:交集、并集、差集。

// 点赞:同一个用户只能赞一次,重复 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()

集合运算示例

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 内存布局:

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 集合),别手滑加字符串。

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),天然有序。排行榜、延迟任务、带权重的队列,都是它的主场。

# 添加成员和分数
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 示例

// 排行榜加分(原子操作)
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 两套结构?

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 是一切的基础)

关联笔记