Files
cs-note/hhs/Redis/02-核心数据类型.md
T
2026-05-24 11:42:38 +08:00

12 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 阻塞)。

我们先从最简单的 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:默认不压缩首尾节点(方便头部操作)

四、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(有序整数数组)存储,内存极其紧凑。一旦加入非整数元素,自动切换为 hashtable。

flowchart LR
    E["Set 添加元素"] --> Q{"全部是整数?"}
    Q -->|"是"| I["intset, 有序数组, 内存紧凑"]
    Q -->|"否"| H["hashtable, O(1) 查找"]

    classDef encoding fill:#e1f5fe,stroke:#2196f3
    classDef encoding2 fill:#fff3e0,stroke:#ff9800
    class I encoding
    class H encoding2

[!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。

快速对照表

数据类型 编码 最佳场景 时间复杂度
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 是一切的基础)

关联笔记