想象你是一名刚入职的“共享储物柜仓库”管理员,面前有五排功能各异的柜子。第一排是单格小柜——只能放一个值,却能瞬间读取;第二排是长条货架——物品从两端进出,排队取货非常方便;第三排是带标签的多格档案柜——每个小格里都嵌着一张小卡片,卡上写着“字段:值”;第四排是唯一陈列柜——每件藏品只允许出现一次,重复品会被自动扔出;第五排是评分排行柜——每件藏品身后都挂着一个分数牌,永远按分数从低到高排得整整齐齐。这五排柜子,就是 Redis 的五大基础数据结构:String、List、Hash、Set 与 Sorted Set。 Redis 之所以被称作“后端工程师的多功能瑞士军刀”,正是因为这五种结构各自独当一面。它们共同组成了缓存系统、消息队列、排行榜、去重闸机等一切高速业务的底层地基。本文会用管理员视角,带你把每排柜子的内部构造、开柜口令(命令)、适用行李(场景)和坑点禁忌全部摸清。
这五大结构是 Redis 一切玩法的最小公倍数。掌握它们,你读源码不迷路、做缓存不背锅、答面试有底气;它们也是从“会用 Redis”跨入“懂 Redis”的必经之门。
目录
可视化演示
展示Redis核心数据结构的内部实现:String/SDS、List/quicklist双向链表、Hash对象存储、Set集合去重、Sorted Set跳表排行榜。
本节配有交互动效(进入视口后自动加载,约 3KB)。
String与List结构 String底层SDS:二进制安全,最大512MB List底层quicklist:压缩双向链表,支持LPUSH/RPUSH INCR原子计数 O(1) String适合缓存计数,List适合消息队列
String 单个值与 SDS
第一排柜子是“单格柜”。管理员要存用户会话令牌,开柜、放条、关门,整个动作不到一眨眼的工夫。单格柜的好处是简单直接:一个 key 对应一个 value,不需要任何多余的组织逻辑。你可以把它想成是酒店前台的总钥匙——名字(key)一说出口,对应的房卡(value)立刻递到手。
技术上说,Redis 的 String 是最基础的键值对类型,底层由 SDS(Simple Dynamic String)承载。SDS 在 C 字符串之外额外记录了长度与已分配空间,因此获取长度是 O(1),追加字符串时也不怕缓冲区溢出。SET 写入、GET 读取、INCR 对整数做原子自增,构成了缓存击穿防线上最常用的“三件套”。
回到储物柜:SDS 就像柜门上贴的尺寸牌,写明“已用空间”和“总空间”。管理员看到牌子上还有空余,就敢直接往里头塞新物品;若塞不下,才搬家换大柜子。这正是 String 能承载计数器、分布式锁等多种业务形态的秘密。
- 数据区 char[] data真正存放字符串的字节数组,可以包含二进制内容,不要求以
\0结尾 - 已使用长度 len记录当前字符串占用的字节数,使
STRLEN命令无需遍历数组 - 已分配容量 alloc记录总共申请到的空间大小,当追加内容时先检查剩余空间是否足够
List 双向链表与消息队列
第二排是“长条货架”。物品必须排成一列,管理员可以从左侧塞入(LPUSH),也可以从右侧补货(RPUSH);取货时同样左右皆可。若货架两端都有人排队等待——左侧没人取,右侧也没人放——管理员干脆把货架锁上,等新货物抵达再开门,这就是 BLPOP 阻塞语义。
Redis 的 List 底层采用 quicklist 结构,它是双向链表与压缩列表的合体:宏观上是双向链表,每个节点内部又是一段连续压缩数组。这样的设计让头部和尾部的插入删除都维持在 O(1),同时利用内存紧凑性降低了指针开销。
货架的比喻在这里更形象了:双向链表保证管理员不必绕路走到货架另一头就能操作,而每个节点内的压缩数组像是一整排码好的小箱子,取货时能连续搬动。List 因而成为轻量消息队列的默认选择,配合 BLPOP 还能实现可靠的任务分发。
- 宏观双向链表quicklistNode 之间通过 prev/next 指针连接,支持 O(1) 头尾操作
- 微观压缩列表每个节点内部是连续内存的 ziplist,减少碎片、提升缓存命中率
- 节点分裂合并当某个 ziplist 过大,就拆成两个节点;过小则与相邻节点合并
Hash 哈希字典与对象缓存
第三排是“档案柜”。每个档案盒里有多个分格,每格上贴着“字段”标签,对应一个“值”文件。管理员打开一个档案盒(key),直接抽走某一格(字段)的文件,而不必把整个柜子搬空。这正是 Hash 类型最大的优势——字段级精细读写。
Hash 底层以哈希表为核心,通过哈希函数将键映射到桶位,处理冲突时采用链地址法。随着数据增长,哈希表需要进行渐进式 rehash:分配一张更大的新表,分多次把旧表数据搬过去,而不是一次性迁移导致阻塞服务。
档案柜的比喻恰好揭示了 Hash 的适用场景——存对象。用户的信息(昵称、头像、积分)作为字段分别存放,更新积分时只需动一格,不必像 String 那样把整个 JSON 序列化后重写一遍。这样既省流量,也减少了并发写同一份数据时的冲突概率。
- 哈希映射将 key 通过哈希函数散列到桶数组的某个位置,期望 O(1) 定位
- 取模定桶用哈希值对当前桶数组长度取模,得到实际存放的桶下标
- 链地址法当多个 key 映射到同一桶时,在桶内挂一条链表,逐一比较后才能命中
Set 唯一集合与去重
第四排是“唯一陈列柜”。管理员收件时有一条铁律:柜中已有同样藏品,新到的重复品直接丢弃,绝不占用额外空间。如果藏品是整数且数量不多,这个柜子采用整数集合(intset)编码,所有数字在内存中紧凑排列;一旦条件不满足,就升级为哈希表,用 key 的存在性来保证唯一。
Set 的核心价值在于无序去重与集合运算。SADD 添加元素时自动消灭重复;SINTER 求交集能快速找出“共同关注的人”;SUNION、SDIFF 对应并集和差集。这类运算在大规模社交、标签系统里高频出现。
回到陈列柜场景:去重不是“发现重复后再删除”,而是在入口处就直接拦住。这听上去简单,却能极大地节省后续逻辑——比如每日签到用户名单、抽奖活动参与人、爬虫 URL 去重队列,Set 都能在一行命令内完成判重与状态维护。
- 整数唯一性向 intset 插入前先做二分查找,若元素已存在则拒绝写入
- 升级策略当插入的元素超过当前整数类型范围(如从 int16 变成 int32),触发整体升级
- 哈希表编码当元素改为字符串后,Set 底层退化为哈希表,仅用 key 位,value 恒为 null
Sorted Set 有序集合与排行榜
第五排是“评分排行柜”,也是整座仓库里最讲究的一排柜子。每件藏品身边都有一个分数牌,管理员按照分数升序把它们摆得一丝不苟。你询问“前三名是谁”,管理员直接从队首依次报出;你问“某人的分数变化了10分”,管理员抬手把藏品搬到新位置,整个过程毫秒级完成。
Sorted Set 的底层是两个结构的组合:跳表(skiplist)负责按分数快速查找和范围遍历,哈希表负责根据成员(member)获取分数。跳表是一种多层级链表,通过随机提升节点层级,使查找时间从 O(n) 降到期望 O(log n)。
评分排行柜完美解释了排行榜业务:直播平台的礼物榜、电商热销榜、游戏天梯分,本质上都是“每时每刻都在变动的有序序列”。Sorted Set 让更新分数与获取排名同时做到 O(log n),这比任何数据库 ORDER BY 都要快几个量级。
- 层级随机化新节点按概率 \(p\) 决定是否升层,通常 \(p = 1/4\)
- 前向指针每层节点的 next 指针指向相隔若干节点的后继,实现“跳跃”
- 分数排序查找时从最高层向下逐层逼近目标分数区间,最终落到最底层完成精确插入
五种结构对比与选型
管理员现在把五排柜子并排站在仓库中央,对照它们的差异:
| 维度 | String | List | Hash | Set | Sorted Set |
|------|--------|------|------|-----|------------|
| 是否有序 | 无 | 插入序 | 无 | 无 | 分数序 |
| 是否去重 | 否 | 否 | 字段唯一 | 成员唯一 | 成员唯一 |
| 常用复杂度 | O(1) | 头尾 O(1) | O(1) | O(1) | O(log n) |
| 典型场景 | 缓存/锁 | 消息队列 | 对象缓存 | 去重/交集 | 排行榜/时间线 |
选型时,可以先回答三个问题:数据需要有序吗?需要排名吗?允许重复吗?如果只是缓存一段字符串,选 String;需要先进先出的任务流转,选 List;读写某个对象的局部字段,选 Hash;只关心唯一性和交集关系,选 Set;一切需要“按分数排序”的场景,Sorted Set 几乎不可替代。
实际应用场景落地
当管理员把这些柜子真正用于园区业务时,组合拳开始显现威力:
- 会话与分布式锁:String 存储会话令牌,配合 SETNX 和过期时间可实现分布式锁,释放时用 Lua 脚本保证原子性。
- 消息队列与任务调度:List 的 LPUSH + BLPOP 构成生产消费模型;延迟任务可用 Sorted Set 按执行时间戳排序,轮询取出到期任务。
- 排行榜 TopN:Sorted Set 的 ZADD 累加得分,ZREVRANGE 获取前 N 名,天然支撑热榜刷新。
- 共同关注与交集:Set 存放每个用户关注列表,SINTER 瞬间得出两个用户的共同关注。
每个场景都不是孤立套用一种结构,而是多类柜子的联动:比如用 Hash 缓存用户资料,用 Sorted Set 缓存用户积分排名,用 Set 缓存用户标签——这正是 Redis 作为后端缓存的常态姿势。
AI 应用场景
缓存热点特征工程数据
阶段:特征存储与在线推理缓存
解决的问题:在推荐系统中,模型推理需要频繁读取用户的实时特征(如最近点击序列、兴趣标签权重)。传统数据库查询吞吐低、延迟高。使用 Redis Hash 存储用户特征字段(历史点击次数、类别分布),用 Sorted Set 按时间戳存储行为序列,使特征读取延迟降至毫秒级。
具体案例:某短视频推荐系统将用户 30 天行为序列写入 Sorted Set,分数为行为时间戳,成员为视频 ID;模型在线推理时通过 ZREVRANGE 取出最近 100 条行为,再配合 Hash 中的统计特征拼接成推理输入。
大模型会话上下文管理
阶段:LLM 应用中的会话存储
解决的问题:大模型应用需要维护多轮对话上下文,直接把消息列表放内存会随会话数膨胀。使用 List 保存消息序列,RPUSH 追加新消息,LTRIM 截断超出窗口的旧消息,实现滑动窗口上下文管理。
具体案例:客服机器人将每个会话 ID 对应一个 List,RPUSH 角色消息、LTRIM 保留最近 20 轮,读取时 LRANGE 0 -1 即可恢复完整上下文。
Python 演示
import redis # 连接本机 Redis 服务r = redis.Redis(host="localhost", port=6379, db=0) # 1. String: 缓存与计数器r.set("user:1001:token", "abc123", ex=3600)r.incr("page:view:20240101") # 2. List: 消息队列r.rpush("task:queue", "task-1", "task-2", "task-3")while True: task = r.blpop("task:queue", timeout=2) if task is None: break print("处理任务:", task[1]) # 3. Hash: 对象缓存r.hset("user:1001", mapping={"name": "Alice", "age": "28"})print("姓名:", r.hget("user:1001", "name")) # 4. Set: 去重与交集r.sadd("user:1001:follow", "user:2001", "user:2002")r.sadd("user:1002:follow", "user:2002", "user:2003")common = r.sinter("user:1001:follow", "user:1002:follow")print("共同关注:", common) # 5. Sorted Set: 排行榜r.zadd("game:score", {"alice": 1000, "bob": 800, "carol": 1200})r.zincrby("game:score", 200, "bob")print("排行榜 TOP3:", r.zrevrange("game:score", 0, 2, withscores=True))
r.set("user:1001:token", "abc123", ex=3600)将用户令牌写入 String 并设置 1 小时过期,模拟会话缓存。r.blpop("task:queue", timeout=2)阻塞弹出队首任务,没有任务时最多等待 2 秒,用于消费队列。r.hset("user:1001", mapping={...})一次写入多个字段,把用户对象整体存入 Hash。r.sinter("user:1001:follow", "user:1002:follow")求两个关注列表的交集,得到共同关注。r.zadd("game:score", {...})带分数写入 Sorted Set,成员分数用于后续排序。r.zincrby("game:score", 200, "bob")给 bob 累加 200 分,演示排行榜分数实时更新。
常见误区
误区反正 Redis 快,把所有数据都塞进一个大 String,省得设计多种结构。
正解用一个大 String 存整个对象,每次更新任何字段都要 GET 再 SET 全量覆盖,网络流量成倍增加。正确做法是用 Hash 按字段存储,改一个字段只传一个字段的数据。用管理员视角看:把整个档案柜搬过去换一张纸,当然不如直接抽走那一格。
为何常见:初学者常把 Redis 当内存版数据库,沿用关系表的行模型思维,忽略了它更擅长细粒度键值操作。
误区BLPOP 会一直阻塞,用 LPOP 轮询就行,简单又安全。
正解LPOP 轮询会产生大量空请求,把 Redis CPU 打满;BLPOP 配合阻塞超时是高效做法,客户端空闲时无网络开销。真实场景中还应结合 List 长度做双端监控,防止消费者宕机后队列无限积压。
为何常见:因为阻塞一词听起来危险,初学者本能地回避它,却没意识到轮询才是性能隐患。
误区排行分数只有整数,ZADD 只能传 int,浮点数不能存。
正解Sorted Set 的 score 是双精度浮点数(double),完全支持小数。面试题中“分数相同怎么办”的答案也由此展开:分数相同时按成员字典序排列。用货架比喻讲,分数牌上既能写 95,也能写 95.5。
为何常见:很多教学材料用整数举例,加上 ZINCRBY 命令名里的 INCR 暗示整数,让学习者形成思维定式。
误区Set 和 List 都是多个元素的容器,区别不大,随便用。
正解List 按插入序存储、允许重复,Set 无序、自动去重。用 List 做去重会因遍历导致 O(n) 复杂度,而 Set 的 SADD 去重是 O(1)。去重场景选错结构,轻则浪费内存,重则逻辑出错。
为何常见:对初学者而言,两者 API 看起来都只是“存多个值”,直到做并集差集运算时才发现差别。
误区Hash 数据量大了之后 rehash 会一次性搬完所有数据,导致 Redis 停顿。
正解Redis 采用渐进式 rehash,将数据迁移分摊到每次增删改查操作中,避免大字典带来的秒级阻塞。但要注意,如果一直不访问某些 key,迁移可能拖延很久,需要额外关注。
为何常见:只听说过“rehash 会阻塞”,没继续追问 Redis 如何解决,就误以为它原地全量搬迁。
面试题
Q1. Redis 中哪种数据结构底层使用 SDS 实现?
- A.String← 正确答案
- B.List
- C.Set
- D.Sorted Set
答案:A。String 类型的底层实现是 SDS(简单动态字符串),它记录了长度与分配空间,支持 O(1) 长度获取。List 底层是 quicklist,Set 的底层是 intset 或哈希表,Sorted Set 的底层是跳表加哈希表。
Q2. 下列哪个命令可以实现分布式锁?
- A.SETNX← 正确答案
- B.LPUSH
- C.SADD
- D.ZADD
答案:A。SETNX 是"SET if Not eXists"的缩写,只有键不存在时才设置值,天然符合锁的互斥语义。LPUSH 用于入队,SADD 用于集合添加,ZADD 用于有序集合写入,都不具备互斥语义。
Q3. List 底层 quicklist 是由哪两种结构组合而成的?
- A.哈希表 + 跳表
- B.双向链表 + 压缩列表← 正确答案
- C.数组 + 链表
- D.红黑树 + 数组
答案:B。quicklist 宏观是双向链表,每个节点内部是压缩列表(ziplist)。这种组合既保留了链表头尾 O(1) 操作的特点,又利用连续内存减少了碎片。哈希表 + 跳表是 Sorted Set 的底层组合。
Q4. 关于 Hash 的渐进式 rehash,下列说法正确的是?
- A.rehash 一次性完成,效率最高
- B.rehash 过程中所有新写入直接丢弃
- C.rehash 将迁移分摊到每次操作中← 正确答案
- D.rehash 期间旧数据全部删除
答案:C。渐进式 rehash 将数据迁移分散到后续每次增删改查操作中,避免大字典迁移造成服务阻塞。就像搬家时每天搬几箱,仓库照常营业,而不是停业一天全搬完。
Q5. Sorted Set 底层由哪些结构组成?
- A.跳表← 正确答案
- B.哈希表← 正确答案
- C.双向链表
- D.整数集合
答案:AB。Sorted Set 用跳表按分数排序并支持范围查询,用哈希表根据成员快速获取分数。双向链表是 List 的组成部分,整数集合是 Set 在整数场景下的编码方案。
Q6. 跳表中新节点的期望层数约为多少?
- A.1
- B.2← 正确答案
- C.层数随插入顺序递增
- D.固定为 64
答案:B。跳表通过随机抛硬币决定层级,每层概率 \(p\),期望层数为 \(1/(1-p)\)。Redis 中 \(p=1/4\),期望层数约 1.33,远低于直觉中的“很多层”,但已足以让查找复杂度降至 O(log n)。
Q7. 下列哪些场景适合使用 Sorted Set?
- A.实时积分排行榜 TopN← 正确答案
- B.延迟任务按时间戳调度← 正确答案
- C.用户关注列表去重
- D.热点新闻按时间线展示← 正确答案
答案:ABD。Sorted Set 适合一切“按分数排序 + 频繁更新”的场景。排行榜(分数)、延迟任务(时间戳)、时间线(分数即时间)都天然契合;用户关注去重用 Set 即可,不需排序。
Q8. Set 类型与 List 类型最大的区别是什么?
- A.List 无序且去重,Set 有序不去重
- B.Set 无序且去重,List 有序可重复← 正确答案
- C.两者都可以存相同元素
- D.Set 只能存整数
答案:B。Set 基于哈希实现成员唯一,且不维护插入顺序;List 是链表,保留插入顺序且允许重复。这也是“去重要用 Set、队列要用 List”的根本原因。
Q9. 在 Sorted Set 中,当两个成员分数相同时,排序规则是什么?
- A.按键的哈希值排序
- B.按插入时间排序
- C.按成员字典序排序← 正确答案
- D.随机排列
答案:C。Sorted Set 的排序先比分数,分数相同时按成员(member)的字典序排列。因此 ZADD 两个同分成员后,ZRANGE 的结果是稳定的字典序,而不是插入顺序。
Q10. 关于五种数据结构的内存特征,下列说法正确的有?
- A.String 适合存储二进制安全的小对象← 正确答案
- B.Hash 适合对象字段级缓存← 正确答案
- C.Set 在整数场景下用 intset 紧凑存储← 正确答案
- D.Sorted Set 比 List 更节省内存
答案:ABC。String 的 SDS 天然二进制安全;Hash 支持字段级读写,是对象缓存的默认选择;Set 的 intset 编码用连续整数数组紧凑存储,省内存。Sorted Set 因包含跳表和哈希表两个结构,内存开销大于 List。