想象你是一名刚入职的“共享储物柜仓库”管理员,面前有五排功能各异的柜子。第一排是单格小柜——只能放一个值,却能瞬间读取;第二排是长条货架——物品从两端进出,排队取货非常方便;第三排是带标签的多格档案柜——每个小格里都嵌着一张小卡片,卡上写着“字段:值”;第四排是唯一陈列柜——每件藏品只允许出现一次,重复品会被自动扔出;第五排是评分排行柜——每件藏品身后都挂着一个分数牌,永远按分数从低到高排得整整齐齐。这五排柜子,就是 Redis 的五大基础数据结构:String、List、Hash、Set 与 Sorted Set。 Redis 之所以被称作“后端工程师的多功能瑞士军刀”,正是因为这五种结构各自独当一面。它们共同组成了缓存系统、消息队列、排行榜、去重闸机等一切高速业务的底层地基。本文会用管理员视角,带你把每排柜子的内部构造、开柜口令(命令)、适用行李(场景)和坑点禁忌全部摸清。

岗位关联

这五大结构是 Redis 一切玩法的最小公倍数。掌握它们,你读源码不迷路、做缓存不背锅、答面试有底气;它们也是从“会用 Redis”跨入“懂 Redis”的必经之门。

目录
  1. 可视化演示
  2. String 单个值与 SDS
  3. List 双向链表与消息队列
  4. Hash 哈希字典与对象缓存
  5. Set 唯一集合与去重
  6. Sorted Set 有序集合与排行榜
  7. 五种结构对比与选型
  8. 实际应用场景落地
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示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 能承载计数器、分布式锁等多种业务形态的秘密。

SDS buffer=char[] data+int len+int alloc\text{SDS buffer} = \text{char[] data} + \text{int len} + \text{int alloc}
SDS 用“多记两个整数”的代价,换来了长度 O(1) 获取和拼接零溢出。就像单格柜在门上多贴了一张尺寸标签,拿取时不用再弯腰量尺寸。
  1. 数据区 char[] data真正存放字符串的字节数组,可以包含二进制内容,不要求以 \0 结尾
    char[] data\text{char[] data}
  2. 已使用长度 len记录当前字符串占用的字节数,使 STRLEN 命令无需遍历数组
    len=strlen(data)\text{len} = \text{strlen}(data)
  3. 已分配容量 alloc记录总共申请到的空间大小,当追加内容时先检查剩余空间是否足够
    alloc≥len+追加字节\text{alloc} \geq \text{len} + \text{追加字节}

List 双向链表与消息队列

第二排是“长条货架”。物品必须排成一列,管理员可以从左侧塞入(LPUSH),也可以从右侧补货(RPUSH);取货时同样左右皆可。若货架两端都有人排队等待——左侧没人取,右侧也没人放——管理员干脆把货架锁上,等新货物抵达再开门,这就是 BLPOP 阻塞语义。

Redis 的 List 底层采用 quicklist 结构,它是双向链表与压缩列表的合体:宏观上是双向链表,每个节点内部又是一段连续压缩数组。这样的设计让头部和尾部的插入删除都维持在 O(1),同时利用内存紧凑性降低了指针开销。

货架的比喻在这里更形象了:双向链表保证管理员不必绕路走到货架另一头就能操作,而每个节点内的压缩数组像是一整排码好的小箱子,取货时能连续搬动。List 因而成为轻量消息队列的默认选择,配合 BLPOP 还能实现可靠的任务分发。

quicklist=∑i=1nziplisti+双向指针\text{quicklist} = \sum_{i=1}^{n} \text{ziplist}_i + \text{双向指针}
quicklist 相当于把许多小节车厢用铰链连成一列火车。每次都整节车厢地搬货,内存节省了,队头队尾的吞吐也保住了。
  1. 宏观双向链表quicklistNode 之间通过 prev/next 指针连接,支持 O(1) 头尾操作
    nodei→prev/nextnodei+1\text{node}_i \xrightarrow{\text{prev/next}} \text{node}_{i+1}
  2. 微观压缩列表每个节点内部是连续内存的 ziplist,减少碎片、提升缓存命中率
    ziplist=[entry1,entry2,…,entryk]\text{ziplist} = [\text{entry}_1, \text{entry}_2, \dots, \text{entry}_k]
  3. 节点分裂合并当某个 ziplist 过大,就拆成两个节点;过小则与相邻节点合并
    split(node)→nodeL+nodeR\text{split}(\text{node}) \rightarrow \text{node}_L + \text{node}_R

Hash 哈希字典与对象缓存

第三排是“档案柜”。每个档案盒里有多个分格,每格上贴着“字段”标签,对应一个“值”文件。管理员打开一个档案盒(key),直接抽走某一格(字段)的文件,而不必把整个柜子搬空。这正是 Hash 类型最大的优势——字段级精细读写。

Hash 底层以哈希表为核心,通过哈希函数将键映射到桶位,处理冲突时采用链地址法。随着数据增长,哈希表需要进行渐进式 rehash:分配一张更大的新表,分多次把旧表数据搬过去,而不是一次性迁移导致阻塞服务。

档案柜的比喻恰好揭示了 Hash 的适用场景——存对象。用户的信息(昵称、头像、积分)作为字段分别存放,更新积分时只需动一格,不必像 String 那样把整个 JSON 序列化后重写一遍。这样既省流量,也减少了并发写同一份数据时的冲突概率。

h=hash(key) mod sizenewh = \text{hash}(key) \bmod \text{size}_\text{new}
渐进式 rehash 就像搬家时不请搬家公司一次搬完,而是每天自己搬几箱,仓库照常营业。旧柜子慢慢腾空,新柜子逐步填充,客户全程无感。
  1. 哈希映射将 key 通过哈希函数散列到桶数组的某个位置,期望 O(1) 定位
    h=hash(key)h = \text{hash}(key)
  2. 取模定桶用哈希值对当前桶数组长度取模,得到实际存放的桶下标
    index=h mod sizecurrent\text{index} = h \bmod \text{size}_\text{current}
  3. 链地址法当多个 key 映射到同一桶时,在桶内挂一条链表,逐一比较后才能命中
    桶i=node1→node2→⋯\text{桶}_i = \text{node}_1 \rightarrow \text{node}_2 \rightarrow \cdots

Set 唯一集合与去重

第四排是“唯一陈列柜”。管理员收件时有一条铁律:柜中已有同样藏品,新到的重复品直接丢弃,绝不占用额外空间。如果藏品是整数且数量不多,这个柜子采用整数集合(intset)编码,所有数字在内存中紧凑排列;一旦条件不满足,就升级为哈希表,用 key 的存在性来保证唯一。

Set 的核心价值在于无序去重与集合运算。SADD 添加元素时自动消灭重复;SINTER 求交集能快速找出“共同关注的人”;SUNION、SDIFF 对应并集和差集。这类运算在大规模社交、标签系统里高频出现。

回到陈列柜场景:去重不是“发现重复后再删除”,而是在入口处就直接拦住。这听上去简单,却能极大地节省后续逻辑——比如每日签到用户名单、抽奖活动参与人、爬虫 URL 去重队列,Set 都能在一行命令内完成判重与状态维护。

intset={e1,e2,…,en}其中所有 ei 唯一且升序\text{intset} = \{e_1, e_2, \dots, e_n\} \quad \text{其中所有 } e_i \text{ 唯一且升序}
intset 像一列从小到大排好的编号牌,查找时用二分法飞快定位;升序排列又让重复检测和交集运算像查字典一样利落。
  1. 整数唯一性向 intset 插入前先做二分查找,若元素已存在则拒绝写入
    binary_search(intset,e)→exists?\text{binary\_search}(\text{intset}, e) \rightarrow \text{exists?}
  2. 升级策略当插入的元素超过当前整数类型范围(如从 int16 变成 int32),触发整体升级
    type∈{int16,int32,int64}\text{type} \in \{ \text{int16}, \text{int32}, \text{int64} \}
  3. 哈希表编码当元素改为字符串后,Set 底层退化为哈希表,仅用 key 位,value 恒为 null
    setstring={keyi∣value=null}\text{set}_\text{string} = \{ \text{key}_i \mid \text{value} = \text{null} \}

Sorted Set 有序集合与排行榜

第五排是“评分排行柜”,也是整座仓库里最讲究的一排柜子。每件藏品身边都有一个分数牌,管理员按照分数升序把它们摆得一丝不苟。你询问“前三名是谁”,管理员直接从队首依次报出;你问“某人的分数变化了10分”,管理员抬手把藏品搬到新位置,整个过程毫秒级完成。

Sorted Set 的底层是两个结构的组合:跳表(skiplist)负责按分数快速查找和范围遍历,哈希表负责根据成员(member)获取分数。跳表是一种多层级链表,通过随机提升节点层级,使查找时间从 O(n) 降到期望 O(log n)。

评分排行柜完美解释了排行榜业务:直播平台的礼物榜、电商热销榜、游戏天梯分,本质上都是“每时每刻都在变动的有序序列”。Sorted Set 让更新分数与获取排名同时做到 O(log n),这比任何数据库 ORDER BY 都要快几个量级。

level=1+⌊log⁡1/p随机数⌋\text{level} = 1 + \lfloor \log_{1/p} \text{随机数} \rfloor
跳表的直觉是“大街上的高架桥”——底层是全量街道,每次向上随机抽一层楼快的架空路,查找时先走快路跳跃,再走慢路落地,从而大幅缩短步行距离。
  1. 层级随机化新节点按概率 \(p\) 决定是否升层,通常 \(p = 1/4\)
    level=1+⌊log⁡1/pξ⌋, ξ∈(0,1)\text{level} = 1 + \lfloor \log_{1/p} \xi \rfloor ,\ \xi \in (0,1)
  2. 前向指针每层节点的 next 指针指向相隔若干节点的后继,实现“跳跃”
    nodei.nextℓ=nodei+2ℓ\text{node}_i.\text{next}_{\ell} = \text{node}_{i+2^\ell}
  3. 分数排序查找时从最高层向下逐层逼近目标分数区间,最终落到最底层完成精确插入
    scoreL≤target<scoreR\text{score}_L \leq \text{target} < \text{score}_R

五种结构对比与选型

管理员现在把五排柜子并排站在仓库中央,对照它们的差异:

| 维度 | 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 演示

python31 行
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))
目的:用 Python 客户端连接 Redis,演示五大数据结构最核心的 API 用法。
  1. r.set("user:1001:token", "abc123", ex=3600)将用户令牌写入 String 并设置 1 小时过期,模拟会话缓存。
  2. r.blpop("task:queue", timeout=2)阻塞弹出队首任务,没有任务时最多等待 2 秒,用于消费队列。
  3. r.hset("user:1001", mapping={...})一次写入多个字段,把用户对象整体存入 Hash。
  4. r.sinter("user:1001:follow", "user:1002:follow")求两个关注列表的交集,得到共同关注。
  5. r.zadd("game:score", {...})带分数写入 Sorted Set,成员分数用于后续排序。
  6. 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 实现?

单选题 · easy

  • A.String← 正确答案
  • B.List
  • C.Set
  • D.Sorted Set

答案:A。String 类型的底层实现是 SDS(简单动态字符串),它记录了长度与分配空间,支持 O(1) 长度获取。List 底层是 quicklist,Set 的底层是 intset 或哈希表,Sorted Set 的底层是跳表加哈希表。

Q2. 下列哪个命令可以实现分布式锁?

单选题 · easy

  • A.SETNX← 正确答案
  • B.LPUSH
  • C.SADD
  • D.ZADD

答案:A。SETNX 是"SET if Not eXists"的缩写,只有键不存在时才设置值,天然符合锁的互斥语义。LPUSH 用于入队,SADD 用于集合添加,ZADD 用于有序集合写入,都不具备互斥语义。

Q3. List 底层 quicklist 是由哪两种结构组合而成的?

单选题 · mid

  • A.哈希表 + 跳表
  • B.双向链表 + 压缩列表← 正确答案
  • C.数组 + 链表
  • D.红黑树 + 数组

答案:B。quicklist 宏观是双向链表,每个节点内部是压缩列表(ziplist)。这种组合既保留了链表头尾 O(1) 操作的特点,又利用连续内存减少了碎片。哈希表 + 跳表是 Sorted Set 的底层组合。

Q4. 关于 Hash 的渐进式 rehash,下列说法正确的是?

单选题 · mid

  • A.rehash 一次性完成,效率最高
  • B.rehash 过程中所有新写入直接丢弃
  • C.rehash 将迁移分摊到每次操作中← 正确答案
  • D.rehash 期间旧数据全部删除

答案:C。渐进式 rehash 将数据迁移分散到后续每次增删改查操作中,避免大字典迁移造成服务阻塞。就像搬家时每天搬几箱,仓库照常营业,而不是停业一天全搬完。

Q5. Sorted Set 底层由哪些结构组成?

多选题 · mid

  • A.跳表← 正确答案
  • B.哈希表← 正确答案
  • C.双向链表
  • D.整数集合

答案:AB。Sorted Set 用跳表按分数排序并支持范围查询,用哈希表根据成员快速获取分数。双向链表是 List 的组成部分,整数集合是 Set 在整数场景下的编码方案。

Q6. 跳表中新节点的期望层数约为多少?

单选题 · hard

  • A.1
  • B.2← 正确答案
  • C.层数随插入顺序递增
  • D.固定为 64

答案:B。跳表通过随机抛硬币决定层级,每层概率 \(p\),期望层数为 \(1/(1-p)\)。Redis 中 \(p=1/4\),期望层数约 1.33,远低于直觉中的“很多层”,但已足以让查找复杂度降至 O(log n)。

Q7. 下列哪些场景适合使用 Sorted Set?

多选题 · hard

  • A.实时积分排行榜 TopN← 正确答案
  • B.延迟任务按时间戳调度← 正确答案
  • C.用户关注列表去重
  • D.热点新闻按时间线展示← 正确答案

答案:ABD。Sorted Set 适合一切“按分数排序 + 频繁更新”的场景。排行榜(分数)、延迟任务(时间戳)、时间线(分数即时间)都天然契合;用户关注去重用 Set 即可,不需排序。

Q8. Set 类型与 List 类型最大的区别是什么?

单选题 · easy

  • A.List 无序且去重,Set 有序不去重
  • B.Set 无序且去重,List 有序可重复← 正确答案
  • C.两者都可以存相同元素
  • D.Set 只能存整数

答案:B。Set 基于哈希实现成员唯一,且不维护插入顺序;List 是链表,保留插入顺序且允许重复。这也是“去重要用 Set、队列要用 List”的根本原因。

Q9. 在 Sorted Set 中,当两个成员分数相同时,排序规则是什么?

单选题 · hard

  • A.按键的哈希值排序
  • B.按插入时间排序
  • C.按成员字典序排序← 正确答案
  • D.随机排列

答案:C。Sorted Set 的排序先比分数,分数相同时按成员(member)的字典序排列。因此 ZADD 两个同分成员后,ZRANGE 的结果是稳定的字典序,而不是插入顺序。

Q10. 关于五种数据结构的内存特征,下列说法正确的有?

多选题 · mid

  • A.String 适合存储二进制安全的小对象← 正确答案
  • B.Hash 适合对象字段级缓存← 正确答案
  • C.Set 在整数场景下用 intset 紧凑存储← 正确答案
  • D.Sorted Set 比 List 更节省内存

答案:ABC。String 的 SDS 天然二进制安全;Hash 支持字段级读写,是对象缓存的默认选择;Set 的 intset 编码用连续整数数组紧凑存储,省内存。Sorted Set 因包含跳表和哈希表两个结构,内存开销大于 List。