← 返回文章

Redis 常见数据结构

发布于 2026/10/19

1. 完整回答

Redis 是一个基于内存的键值型数据库,支持多种数据结构,可以根据不同的业务需求选择合适的数据组织和操作方式。常见的五种基础数据类型是 String、Hash、List、Set 和 ZSet。

String 是字符串类型,可以存储文本、数字和二进制数据,支持 GET、SET、INCR 等操作,适合缓存普通数据、计数器和验证码。

Hash 是哈希类型,存储字段和值的映射关系,可以单独读取或修改某个字段,适合保存用户信息、商品属性等对象数据。

List 是列表类型,元素有序且允许重复,支持从两端插入和删除,适合实现消息队列、任务列表等场景。

Set 是无序集合,元素不能重复,支持交集、并集、差集操作,适合去重、共同好友、用户标签等场景。

ZSet 是有序集合,每个元素关联一个分数,Redis 根据分数维护排序,适合排行榜、优先级队列和延迟任务。

在底层实现上,Redis 会根据数据类型、元素数量和大小选择不同的编码方式。例如,String 使用整数编码或动态字符串,List 主要使用 Quicklist,Hash、Set 和 ZSet 则会在紧凑编码与适合大规模数据的结构之间选择。

除此之外,Redis 还支持 Bitmap、HyperLogLog、Geo 和 Stream 等类型或专用结构,分别适合状态统计、近似去重计数、地理位置查询和消息处理。

实际使用时,核心是根据业务需要的操作来选择数据结构,同时关注时间复杂度、内存占用和大 Key 问题。

2. 核心原理与关键概念

2.1 核心原理

Redis 的核心机制是通过不同的数据类型和底层编码,为不同操作提供合适的时间复杂度和内存效率。

基本流程:

写入数据 → 确定数据类型 → 选择底层编码 → 执行操作 → 必要时转换编码

例如,Hash 在数据量较小时,可以使用紧凑的 Listpack 节省内存;当数据规模或字段长度超过配置限制时,会转换为哈希表,提高字段访问效率。

Redis 对外提供的数据类型与内部编码是两个不同层次的概念。

数据类型 主要底层编码 核心特点
String int、embstr、raw 整数优化、动态字符串
List Quicklist 双向链表组织紧凑列表,支持高效两端操作
Hash Listpack、Hashtable 小数据节省内存,大数据提高查询效率
Set Intset、Listpack、Hashtable 去重、成员判断、集合运算
ZSet Listpack、Skiplist + Hashtable 按分数维护有序关系
Stream 基数树(Radix Tree)+ Listpack 消息追加、按 ID 查询、消费组

这里以现代 Redis 版本为主。其中,Redis 7.0 起,Hash 和 ZSet 的紧凑编码由 Ziplist 调整为 Listpack;Redis 7.2 起,Set 也支持 Listpack 编码。

2.2 关键概念

概念 作用
SDS(简单动态字符串) 保存字符串长度和容量,支持二进制数据及动态扩容
Listpack(紧凑列表) 连续内存存储多个元素,减少额外内存开销
Quicklist(快速列表) 通过双向链表连接多个紧凑节点,兼顾内存效率和两端操作
Intset(整数集合) 紧凑存储少量整数,减少内存消耗
Hashtable(哈希表) 通过哈希定位元素,平均查询复杂度为 O(1)
Skiplist(跳表) 通过多级索引维护有序数据,支持高效查找和范围遍历
PEL(待确认消息列表) 记录 Stream 消费组中已投递但尚未确认的消息

2.3 常见操作复杂度

数据结构 代表操作 时间复杂度
String GET、SET、INCR 通常 O(1)
Hash HGET、HSET 哈希表编码下平均 O(1)
List LPUSH、RPOP 单元素操作 O(1)
List LINDEX O(N),两端元素为 O(1)
Set SISMEMBER 哈希表编码下平均 O(1)
Set SINTER 最坏 O(N × M),N 为最小集合大小,M 为集合数量
ZSet ZADD、ZRANK O(log N)
ZSet ZRANGE O(log N + M),M 为返回元素数量

这些复杂度主要描述命令的算法成本。实际性能还与底层编码、数据大小、返回结果数量和网络传输有关。

3. 高频面试追问

3.1 Redis 的 String 为什么使用 SDS?相比 C 字符串有什么优势?

Redis 使用 SDS,也就是简单动态字符串,主要是为了提高字符串操作效率,并保证内存操作安全。

普通 C 字符串通过 \0 判断结束,获取长度通常需要遍历字符串。SDS 会记录当前长度和已分配容量,因此获取长度可以直接完成,时间复杂度为 O(1)。

另外,SDS 支持二进制安全,字符串中可以包含 \0,因为实际长度由元数据记录。

在修改字符串时,SDS 会检查剩余容量,空间不足时再扩容,降低频繁分配内存的成本,同时避免直接拼接导致的缓冲区越界问题。

因此,Redis 的 String 既可以存文本,也可以存数字和二进制数据。

继续追问:String 为什么有 int、embstr、raw 三种编码?

主要是为了减少内存开销。能够使用整数表示时,可以采用 int 编码;较短的普通字符串通常使用 embstr,将对象和字符串数据紧凑分配;较长字符串使用 raw,方便独立管理和扩容。Redis 会根据实际内容和操作调整编码。

3.2 Redis 为什么要让同一种数据类型支持多种底层编码?

主要是为了平衡内存利用率和操作性能。

数据量小时,使用 Listpack、Intset 这类紧凑结构,可以减少指针、哈希桶等额外开销,适合存储大量小对象。

随着元素数量增加,或者单个元素变大,紧凑结构的线性查找和数据移动成本会增加。这时 Redis 可以转换成哈希表、跳表等结构,提高大规模数据下的操作效率。

例如,Hash 在字段较少、字段较短时适合使用 Listpack;当规模超过配置阈值后,可以转换为 Hashtable。

这种编码选择由 Redis 自动完成,可以通过 OBJECT ENCODING 查看具体的内部编码。

3.3 Redis 的 List 为什么使用 Quicklist?

Quicklist 主要解决传统双向链表内存开销较大,以及单个紧凑列表修改成本较高的问题。

它通过双向链表连接多个 Listpack 节点,每个节点内部可以紧凑存储多个元素。

这样既能利用双向链表高效操作首尾的特点,又能通过紧凑存储降低每个元素的内存开销。

例如,使用 LPUSH 和 RPOP 可以实现先进先出的队列,两端单元素操作通常都是 O(1)。

但 List 不适合频繁按下标随机访问中间元素,因为定位元素需要遍历,LINDEX 的复杂度通常为 O(N)。

因此,Quicklist 比较适合任务队列、操作记录等以顺序读写和两端操作为主的场景。

3.4 缓存一个用户对象,应该选择 String 还是 Hash?

主要取决于数据的读取和更新方式。

如果业务经常整体读取用户信息,可以使用 String 保存序列化后的 JSON,读写逻辑简单,也方便整体设置过期时间。

如果业务经常只修改用户的某个字段,例如昵称、年龄或积分,Hash 更合适。它允许通过 HGET、HSET 单独访问字段,避免每次读取和重新写入完整对象。

但 Hash 主要处理字段和值的映射关系,遇到复杂嵌套对象时,通常需要额外设计存储方式。

另外,传统 Redis 版本主要支持 Key 级过期。Redis 7.4 起增加了 Hash 字段级过期能力,例如 HEXPIRE,因此字段过期能力也需要结合实际版本判断。

实际项目中,整体缓存优先考虑 String,字段级更新频繁时优先考虑 Hash。

3.5 Redis 的 Set 如何实现去重和共同好友?有什么性能问题?

Set 通过唯一成员约束实现去重,重复添加相同元素不会产生新的成员。

例如,记录文章点赞用户,可以将文章 ID 作为 Key,用户 ID 作为 Set 成员。使用 SADD 添加点赞,SISMEMBER 判断是否点赞,SCARD 获取去重后的点赞人数。

如果每个用户都有一个好友集合,求两个用户的共同好友,可以使用 SINTER 计算交集。

Set 普通哈希表编码下的成员判断平均复杂度是 O(1),但集合运算需要遍历成员。

当集合规模很大时,直接执行交集、并集,或者通过 SMEMBERS 返回整个集合,都可能产生较大的 CPU、内存和网络开销。

因此,大集合场景通常需要控制计算范围、分批处理,或者根据业务需求提前维护部分结果。

3.6 ZSet 为什么使用跳表和哈希表?为什么选择跳表?

ZSet 需要同时满足成员查找、分数更新、排序和范围查询,因此普通编码使用哈希表与跳表组合。

哈希表负责根据成员快速定位数据,跳表负责按照分数维护有序关系。

跳表是在有序链表上增加多级索引。查找元素时,先从高层索引快速跳过部分节点,再逐层向下定位,平均查找复杂度为 O(log N)。

这种结构支持高效插入、删除和范围遍历,实现也比较直接。

例如,排行榜使用 ZADD 更新用户积分,Redis 会根据新分数调整元素位置。使用 ZRANGE 配合 REV 可以获取分数最高的用户。

在普通编码下,跳表负责维护顺序,哈希表负责提高成员定位效率,两者配合满足排行榜的主要操作需求。

继续追问:ZSet 中两个元素分数相同,顺序怎么确定?

ZSet 允许不同成员拥有相同分数。分数相同时,按照成员字符串的字典序排列;反向查询则顺序相反。如果业务要求同分时按时间排序,需要额外设计排序规则,例如使用可保持精度的复合分数,或者在业务层处理同分排序。

3.7 如何使用 ZSet 实现延迟任务队列?

可以利用 ZSet 按分数排序的特点,把任务执行时间作为 Score,任务 ID 作为 Member。

整体流程是:

写入任务 → 按执行时间排序 → 查询已到期任务 → 原子领取任务 → 执行业务 → 处理结果

例如,订单创建后 30 分钟未支付就关闭,可以把预计关闭时间的毫秒时间戳作为分数,存入 ZSet。

后台消费者定期查询分数小于等于当前时间的任务,获取后执行订单关闭逻辑。

这里需要注意,多消费者并发时,如果先查询任务、再单独删除,可能发生重复领取。可以通过 Lua 脚本将任务选择和移除放在一个原子操作中。

另外,任务领取成功后,如果消费者执行失败,任务可能丢失。因此,实际项目还需要设计处理中状态、超时恢复和业务幂等机制。

ZSet 适合实现轻量级延迟调度。如果对可靠投递、失败重试和消息持久化要求更高,可以结合专门的消息队列方案。

3.8 List 和 Stream 都能实现消息队列,应该怎么选择?

List 适合简单队列,Stream 更适合需要消息确认和消费者协作的场景。

List 可以通过 LPUSH 和 BRPOP 实现生产和阻塞消费。操作简单,性能也比较好。

但普通弹出操作会直接移除消息,如果消费者取到消息后发生故障,消息可能丢失。

Stream 采用追加式消息结构,每条消息具有唯一 ID,还支持消费者组和消息确认。

使用 Stream 时,消息通过 XADD 写入,消费者通过 XREADGROUP 读取,处理完成后使用 XACK 确认。

对于未确认消息,Redis 可以通过 PEL 记录其待处理状态,支持后续重新领取。

所以,简单的临时任务队列可以选择 List;需要多消费者协作、失败恢复和可靠消费时,更适合 Stream。

继续追问:Stream 能保证消息只消费一次吗?

通常需要按照至少一次投递来设计。消费者处理成功后,如果还没来得及执行 XACK 就发生故障,消息可能再次投递。因此,业务侧仍然需要根据消息 ID 或业务唯一标识实现幂等处理,避免重复扣款、重复创建订单等问题。

3.9 Bitmap、HyperLogLog 和 Geo 分别适合什么场景?

这三种结构针对不同的数据处理需求。

Bitmap 适合状态记录。 它通过 String 的位操作,将一个二进制位表示为某个状态,例如用户当天是否签到,使用 SETBIT 设置状态,通过 GETBIT 查询,通过 BITCOUNT 统计设置为 1 的位数。

HyperLogLog 适合大规模去重数量估算。 例如统计网站每日独立访客数,可以通过 PFADD 添加用户,通过 PFCOUNT 获取估算数量。Redis 的 HyperLogLog 最多使用约 12 KB 内存,标准误差约为 0.81%,但无法获取具体有哪些用户。

Geo 适合地理位置查询。 它基于有序集合存储经过地理编码的位置,支持 GEOADD 添加经纬度,通过 GEOSEARCH 查询指定范围内的位置,适合附近门店、附近车辆等业务。

选择时主要看需求:状态标记使用 Bitmap,大规模近似去重计数使用 HyperLogLog,位置范围查询使用 Geo。

3.10 Redis 数据结构可能出现哪些性能问题?如何避免大 Key?

最常见的问题是大 Key、复杂度较高的命令,以及一次返回大量数据。

Redis 的单条命令执行通常具有原子性,但原子性不代表执行速度一定快。

例如,使用 SMEMBERS 读取一个包含大量用户的 Set,或者使用 LRANGE 一次返回整个大列表,都可能占用较长的执行时间,同时带来较大的网络和客户端内存开销。

处理这类问题,首先要根据数据结构控制单个 Key 的规模,避免无限增长。

其次,对于遍历需求,可以使用 HSCAN、SSCAN、ZSCAN 等命令分批迭代,避免一次性取回全部元素。

需要注意,SCAN 系列命令采用游标遍历,可能返回重复元素,COUNT 也只是数量提示,客户端需要正确处理游标和重复数据。

对于持续增长的 List、ZSet、Stream,还应该根据业务设置长度或时间范围限制,及时清理历史数据。

因此,Redis 性能优化除了选择合适的数据结构,还需要关注具体操作的时间复杂度和数据规模。

4. 易混淆概念对比

4.1 String 与 Hash

对比维度 String Hash
数据组织 单个字符串值 Field-Value 映射
访问方式 整体读取与写入 可以访问单个字段
部分更新 通常需要应用层处理序列化数据 HSET 直接更新指定字段
典型场景 对象整体缓存、计数器 用户属性、商品信息
适用特点 结构简单、整体读写方便 字段独立操作方便

4.2 List 与 ZSet

对比维度 List ZSet
排序依据 元素在列表中的位置 元素关联的 Score
是否允许重复 允许 Member 唯一
主要操作 两端插入、删除 按分数排序、范围查询
典型复杂度 两端操作 O(1) 插入、排名 O(log N)
适用场景 普通任务队列、顺序记录 排行榜、优先级队列、延迟任务

4.3 Set 与 ZSet

对比维度 Set ZSet
元素唯一性 唯一 唯一
顺序 无序 按 Score 排序
核心能力 去重、成员判断、集合运算 排名、分数更新、范围查询
常见操作 SADD、SINTER ZADD、ZRANGE
适用场景 标签、共同好友、去重 积分排名、定时调度

4.4 List 与 Stream

对比维度 List Stream
数据模型 有序列表 带消息 ID 的追加式日志
消息确认 无内置 ACK 机制 支持消费者组和 ACK
消费者协作 需要业务自行设计 内置消费者组
故障恢复 需要额外机制 可重新领取待确认消息
适用场景 简单队列 需要确认、重试的消息处理

4.5 Set、Bitmap 与 HyperLogLog

对比维度 Set Bitmap HyperLogLog
主要功能 精确去重与成员管理 二进制状态管理 近似去重计数
统计精度 精确 精确 存在统计误差
是否能查询具体成员 可以 通过位偏移查询状态 不可以
内存特点 随成员数量增长 与最大位偏移有关 内存上限约 12 KB
典型场景 点赞用户集合 签到、活跃状态 独立访客数统计

Bitmap 使用位偏移定位数据,如果用户 ID 非常稀疏,直接把 ID 当偏移量可能造成空间浪费,需要结合业务设计编号映射。

5. 总结

Redis 常见的五种基础数据类型是 String、Hash、List、Set 和 ZSet,分别适合普通缓存与计数、对象字段管理、顺序队列、集合去重和有序排名。底层通过 SDS、Quicklist、Listpack、Hashtable、Skiplist 等结构实现不同操作,并根据数据规模选择合适的编码方式。除此之外,Bitmap、HyperLogLog、Geo 和 Stream 还能解决状态统计、近似去重、位置查询和消息处理等问题。实际选择数据结构时,主要考虑数据是否需要去重、是否需要排序、是否经常局部更新,以及操作的时间复杂度。同时需要控制数据规模,避免大 Key 和耗时命令影响 Redis 的整体性能。