Redis 面试之数据类型篇
Redis 面试之数据类型篇
Redis 数据类型
【基础】Redis 支持哪些数据类型?
::: details 要点
- Redis 支持五种基本数据类型:String(字符串)、Hash(哈希)、List(列表)、Set(集合)、Zset(有序集合)。
- 随着 Redis 版本升级,又陆续支持以下数据类型: BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)。
:::
【基础】Redis 基础数据类型的常见命令有哪些?
::: details 要点
String 命令
命令 | 说明 |
---|---|
SET |
存储一个字符串值 |
SETNX |
仅当键不存在时,才存储字符串值 |
GET |
获取指定 key 的值 |
MGET |
获取一个或多个指定 key 的值 |
INCRBY |
将 key 中储存的数字加上指定的增量值 |
DECRBY |
将 key 中储存的数字减去指定的减量值 |
Hash 命令
命令 | 行为 |
---|---|
HSET |
将指定字段的值设为 value |
HGET |
获取指定字段的值 |
HGETALL |
获取所有键值对 |
HMSET |
设置多个键值对 |
HMGET |
获取所有指定字段的值 |
HDEL |
删除指定字段 |
HINCRBY |
为指定字段的整数值加上增量 |
HKEYS |
获取所有字段 |
List 命令
命令 | 行为 |
---|---|
LPUSH |
将给定值推入列表的右端。 |
RPUSH |
将给定值推入列表的右端。 |
LPOP |
从列表的左端弹出一个值,并返回被弹出的值。 |
RPOP |
从列表的右端弹出一个值,并返回被弹出的值。 |
LRANGE |
获取列表在给定范围上的所有值。 |
LINDEX |
获取列表在给定位置上的单个元素。 |
LREM |
从列表的左端弹出一个值,并返回被弹出的值。 |
LTRIM |
只保留指定区间内的元素,删除其他元素。 |
Set 命令
命令 | 行为 |
---|---|
SADD |
将给定元素添加到集合。 |
SMEMBERS |
返回集合包含的所有元素。 |
SISMEMBER |
检查给定元素是否存在于集合中。 |
SREM |
如果给定的元素存在于集合中,那么移除这个元素。 |
Zset 命令
命令 | 行为 |
---|---|
ZADD |
将一个带有给定分值的成员添加到有序集合里面 |
ZRANGE |
顺序排序,并返回指定排名区间的成员 |
ZREVRANGE |
反序排序,并返回指定排名区间的成员 |
ZRANGEBYSCORE |
顺序排序,并返回指定排名区间的成员及其分值 |
ZREVRANGEBYSCORE |
反序排序,并返回指定排名区间的成员及其分值 |
ZREM |
移除指定的成员 |
ZSCORE |
返回指定成员的分值 |
ZCARD |
返回所有成员数 |
:::
【中级】Redis 各数据类型的应用场景?
::: details 要点
- String(字符串) - 缓存对象、分布式 Session、分布式锁、计数器、限流器、分布式 ID 等。
- Hash(哈希) - 缓存对象、购物车等。
- List(列表) - 消息队列
- Set(集合) - 聚合计算(并集、交集、差集),如点赞、共同关注、抽奖活动等。
- Zset(有序集合) - 排序场景,如排行榜、电话和姓名排序等。
- BitMap(2.2 版新增) - 二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
- HyperLogLog(2.8 版新增) - 海量数据基数统计的场景,比如百万级网页 UV 计数等;
- GEO(3.2 版新增) - 存储地理位置信息的场景,比如滴滴叫车;
- Stream(5.0 版新增) - 消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息 ID,支持以消费组形式消费数据。
:::
【高级】Redis 基础数据类型的底层实现是怎样的?
::: details 要点
- String 类型 - String 类型的底层数据结构是 SDS。SDS 是 Redis 针对字符串类型的优化,具有以下特性:
- 常数复杂度获取字符串长度
- 杜绝缓冲区溢出
- 减少修改字符串长度时所需的内存重分配次数
- List 类型 - 列表对象的编码可以是
ziplist
或者linkedlist
。当列表对象可以同时满足以下两个条件时,列表对象使用ziplist
编码;否则,使用linkedlist
编码。- 列表对象保存的所有字符串元素的长度都小于
64
字节; - 列表对象保存的元素数量小于
512
个;
- 列表对象保存的所有字符串元素的长度都小于
- Hash 类型 - 哈希对象的编码可以是
ziplist
或者hashtable
。当哈希对象同时满足以下两个条件时,使用ziplist
编码;否则,使用hashtable
编码。- 哈希对象保存的所有键值对的键和值的字符串长度都小于
64
字节; - 哈希对象保存的键值对数量小于
512
个;
- 哈希对象保存的所有键值对的键和值的字符串长度都小于
- Set 类型 - 集合对象的编码可以是
intset
或者hashtable
。当集合对象可以同时满足以下两个条件时,集合对象使用intset
编码;否则,使用hashtable
编码。- 集合对象保存的所有元素都是整数值;
- 集合对象保存的元素数量不超过
512
个;
- Zset 类型 - 有序集合的编码可以是
ziplist
或者skiplist
。当有序集合对象可以同时满足以下两个条件时,有序集合对象使用ziplist
编码;否则,使用skiplist
编码。- 有序集合保存的元素数量小于
128
个; - 有序集合保存的所有元素成员的长度都小于
64
字节;
- 有序集合保存的元素数量小于
:::
【高级】Redis 为什么用 listpack
替代 ziplist
?
::: details 要点
**listpack
是 Redis 5.0 引入的优化结构,用来替代 ziplist
**,作为 hash
、list
、zset
数据类型的实现编码之一。
二者对比如下:
特性 | ziplist |
listpack |
---|---|---|
级联更新 | 可能发生(最坏 O(n) ) |
完全避免(稳定 O(1) ) |
内存占用 | 预留空间可能浪费 | 按需分配,更紧凑 |
安全性 | 需手动校验边界 | 内置长度校验,防溢出 |
版本 | Redis 旧版本 | Redis 5.0+ 的 Hash、ZSet 等 |
:::
::: details 细节
ziplist
的缺陷
- 级联更新问题 - 当修改或删除中间某个元素时,可能引发后续所有节点的内存重分配(因为
ziplist
用 前驱节点长度 定位数据)。最坏情况下时间复杂度从O(1)
退化到O(n)
,影响性能。 - 内存浪费 -
ziplist
为每个节点预留 1~5 字节 存储前驱节点长度(即使实际不需要这么多空间)。对于短小的数据(如小整数),存储开销比例过高。 - 安全性风险 -
ziplist
对内存布局的强依赖可能导致 缓冲区溢出(需严格校验边界)。
listpack
的改进
- 消除级联更新 - 每个节点 独立存储自身长度(不再依赖前驱节点)。修改任意节点仅影响当前节点,时间复杂度稳定为
O(1)
。 - 更紧凑的内存布局 - 节点长度字段采用 变长编码(类似 Protobuf 的 Varint),根据实际需求分配 1~5 字节。存储小整数时,长度字段仅需 1 字节。
- 更强的安全性 - 每个节点记录 总长度 和 校验字段,避免解析越界。
- 兼容性与平滑替换 -
listpack
的 API 设计兼容ziplist
,Redis 内部可无缝迁移(如 Hash、ZSet 的底层实现)。
:::
【高级】为什么 Zset 用跳表实现而不是红黑树、B+树?
::: details 要点
- 实现简单性
- 跳表的实现比红黑树简单得多,代码更易于维护和调试。
- 红黑树需要处理复杂的旋转和重新平衡操作,而跳表的平衡是通过概率实现的。
- 范围查询效率
- 跳表在范围查询(如
zrange
)上表现优异,因为它是基于链表的结构,可以线性遍历。 - 红黑树进行范围查询需要中序遍历,相对复杂。
- B+树虽然也擅长范围查询,但实现复杂度更高。
- 跳表在范围查询(如
- 并发性能
- 跳表更容易实现无锁并发操作 (Redis 虽然是单线程,但考虑未来扩展)
- 红黑树的平衡操作涉及大量指针修改,难以实现高效的并发控制
- 内存效率
- 跳表不需要像 B+ 树那样维护严格的树形结构,内存使用更灵活
- B+树的节点通常设计为填满一定比例,可能造成内存浪费
- 性能平衡
- 跳表的查询、插入、删除操作时间复杂度都是 O(logN),与红黑树相当
- 跳表的实际性能在实践中表现良好,特别是对于内存数据结构
- Redis 的特殊需求
- Redis 的 Zset 需要同时支持按 score 和按 member 查询,跳表+哈希表的组合完美满足这一需求
- Redis 是内存数据库,不需要考虑 B+树针对磁盘 I/O 优化的特性
:::
【高级】跳表的实现原理是什么?
::: details 要点
跳表是一种可以实现二分查找的有序链表,通过多级索引提升查找效率。跳表的查找、插入、删除操作的时间复杂度均为 O(log n),与平衡二叉树(如红黑树)接近。
对于一个有序数组,可以使用高效的二分查找法,其时间复杂度为 O(log n)
。
但是,即使是有序的链表,也只能使用低效的顺序查找,其时间复杂度为 O(n)
。
如何提高链表的查找效率呢?
我们可以对链表加一层索引。具体来说,可以每两个结点提取一个结点到上一级,我们把抽出来的那一级叫作索引或索引层。索引节点中通过一个 down 指针,指向下一级结点。通过这样的改造,就可以支持类似二分查找的算法。我们把改造之后的数据结构叫作跳表(Skip list)。
随着数据的不断增长,一级索引层也变得越来越长。此时,我们可以为一级索引再增加一层索引层:二级索引层。
随着数据的膨胀,当二级索引层也变得很长时,我们可以继续为其添加新的索引层。这种链表加多级索引的结构,就是跳表。
跳表的时间复杂度
- 查找:从最高级索引开始逐层下沉,每层最多遍历 3 个节点,时间复杂度为
O(log n)
。 - 插入:先查找插入位置(
O(log n)
),再随机生成索引层级(O(log n)
),总时间复杂度为O(log n)
。 - 删除:类似查找过程,删除节点及其索引(
O(log n)
)。
跳表的空间复杂度
- 索引节点总数为
n/2 + n/4 + n/8 + … ≈ n
,空间复杂度为 **O(n)**。 - 可通过调整索引密度(如每 3 个节点抽 1 个)减少空间占用,但会牺牲部分查找效率。
:::
【高级】Redis 利用什么机制来实现各种数据结构?
::: details 要点
Redis 并没有直接使用这些数据结构来实现键值对数据库, 而是基于这些数据结构创建了一个对象系统, 这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象。
Redis 数据库中的每个键值对的键和值都是一个对象。Redis 共有字符串、列表、哈希、集合、有序集合五种类型的对象, 每种类型的对象至少都有两种或以上的编码方式, 不同的编码可以在不同的使用场景上优化对象的使用效率。
服务器在执行某些命令之前, 会先检查给定键的类型能否执行指定的命令, 而检查一个键的类型就是检查键的值对象的类型。
:::
::: details 细节
对象的类型
Redis 使用对象来表示数据库中的键和值。每次当我们在 Redis 的数据库中新创建一个键值对时, 我们至少会创建两个对象, 一个对象用作键值对的键(键对象), 另一个对象用作键值对的值(值对象)。
Redis 中的每个对象都由一个 redisObject
结构表示, 该结构中和保存数据有关的三个属性分别是 type
属性、 encoding
属性和 ptr
属性:
1 | typedef struct redisObject { |
对象的 type
属性记录了对象的类型,有以下类型:
对象 | 对象 type 属性的值 |
TYPE 命令的输出 |
---|---|---|
字符串对象 | REDIS_STRING |
"string" |
列表对象 | REDIS_LIST |
"list" |
哈希对象 | REDIS_HASH |
"hash" |
集合对象 | REDIS_SET |
"set" |
有序集合对象 | REDIS_ZSET |
"zset" |
Redis 数据库保存的键值对来说, 键总是一个字符串对象, 而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种。
对象的编码
对象的 ptr
指针指向对象的底层实现数据结构, 而这些数据结构由对象的 encoding
属性决定。
encoding
属性记录了对象所使用的编码, 也即是说这个对象使用了什么数据结构作为对象的底层实现。
Redis 中每种类型的对象都至少使用了两种不同的编码,不同的编码可以在不同的使用场景上优化对象的使用效率。
Redis 支持的编码如下所示:
类型 | 编码 | 对象 | OBJECT ENCODING 命令输出 |
---|---|---|---|
REDIS_STRING |
REDIS_ENCODING_INT |
使用整数值实现的字符串对象。 | “int” |
REDIS_STRING |
REDIS_ENCODING_EMBSTR |
使用 embstr 编码的简单动态字符串实现的字符串对象。 |
“embstr” |
REDIS_STRING |
REDIS_ENCODING_RAW |
使用简单动态字符串实现的字符串对象。 | “raw” |
REDIS_LIST |
REDIS_ENCODING_ZIPLIST |
使用压缩列表实现的列表对象。 | “ziplist” |
REDIS_LIST |
REDIS_ENCODING_LINKEDLIST |
使用双端链表实现的列表对象。 | “linkedlist” |
REDIS_HASH |
REDIS_ENCODING_ZIPLIST |
使用压缩列表实现的哈希对象。 | “ziplist” |
REDIS_HASH |
REDIS_ENCODING_HT |
使用字典实现的哈希对象。 | “hashtable” |
REDIS_SET |
REDIS_ENCODING_INTSET |
使用整数集合实现的集合对象。 | “intset” |
REDIS_SET |
REDIS_ENCODING_HT |
使用字典实现的集合对象。 | “hashtable” |
REDIS_ZSET |
REDIS_ENCODING_ZIPLIST |
使用压缩列表实现的有序集合对象。 | “ziplist” |
REDIS_ZSET |
REDIS_ENCODING_SKIPLIST |
使用跳表和字典实现的有序集合对象。 | “skiplist” |
内存回收
由于 C 语言不支持内存回收,Redis 内部实现了一套基于引用计数的内存回收机制。
每个对象的引用计数信息由 redisObject
结构的 refcount
属性记录。当对象的引用计数值变为 0
时, 对象所占用的内存会被释放。
对象共享
在 Redis 中, 让多个键共享同一个值对象需要执行以下两个步骤:
- 将数据库键的值指针指向一个现有的值对象;
- 将被共享的值对象的引用计数增一。
共享对象机制对于节约内存非常有帮助, 数据库中保存的相同值对象越多, 对象共享机制就能节约越多的内存。
Redis 会在初始化服务器时, 共享值为 0
到 9999
对象的空转时长
redisObject
的 lru
属性记录了对象最后一次被命令程序访问的时间。
如果服务器打开了 maxmemory
选项, 并且服务器用于回收内存的算法为 volatile-lru
或者 allkeys-lru
, 那么当服务器占用的内存数超过了 maxmemory
选项所设置的上限值时, 空转时长较高的那部分键会优先被服务器释放, 从而回收内存。
:::
【中级】如何使用 Redis 实现排行榜?
::: details 要点
各种排行榜,如:内容平台(视频、歌曲、文章)的播放量/收藏量/评分排行榜;电商网站的销售排行榜等等,都可以基于 Redis zset 类型来实现。
:::
::: details 细节
我们以博文点赞排名为例,dunwu 发表了五篇博文,分别获得赞为 200、40、100、50、150。
1 | article:1 文章获得了 200 个赞 |
文章 article:4 新增一个赞,可以使用 ZINCRBY 命令(为有序集合 key 中元素 member 的分值加上 increment):
1 | ZINCRBY user:dunwu:ranking 1 article:4 |
查看某篇文章的赞数,可以使用 ZSCORE 命令(返回有序集合 key 中元素个数):
1 | ZSCORE user:dunwu:ranking article:4 |
获取 dunwu 文章赞数最多的 3 篇文章,可以使用 ZREVRANGE 命令(倒序获取有序集合 key 从 start 下标到 stop 下标的元素):
1 | WITHSCORES 表示把 score 也显示出来 |
获取 dunwu 100 赞到 200 赞的文章,可以使用 ZRANGEBYSCORE 命令(返回有序集合中指定分数区间内的成员,分数由低到高排序):
1 | ZRANGEBYSCORE user:dunwu:ranking 100 200 WITHSCORES |
:::
【中级】如何使用 Redis 实现百万级网页 UV 计数?
::: details 要点
Redis HyperLogLog 是 Redis 2.8.9 版本新增的数据类型,是一种用于“统计基数”的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,**HyperLogLog 是统计规则是基于概率完成的,不是非常准确,标准误算率是 0.81%**(统计结果为 100 万时,实际可能在 99.19 万~100.81 万之间)。
核心优势
- 极低内存占用:仅需 12 KB 内存,即可统计接近 2^64 个元素的基数(如 UV 统计)。
- 适合海量数据:相比
Set
/Hash
(元素越多内存消耗越大),HyperLogLog 在百万级以上数据场景中优势显著。
适用场景
- 网页 UV 统计:统计独立访客数(如
page1:uv
),尤其适合高并发、大数据量场景。 - 容忍误差的基数统计:如热门活动页面访问量、广告点击去重等。
如果需要精确统计,则需要转用 Set
/ Hash
,并且不得不消耗
基本命令
添加元素:
PFADD key element [element...]
1
PFADD page1:uv user1 user2 user3 # 将用户添加到 UV 统计
获取统计值:
PFCOUNT key
1
PFCOUNT page1:uv # 返回近似 UV 数(如 100 万)
:::
【中级】如何使用 Redis 实现布隆过滤器?
::: details 要点
布隆过滤器是一种高效的概率数据结构,常用于检测一个元素是否在一个集合中,可以有效减少数据库的查询次数,解决缓存穿透等问题。
可以通过以下两种方式实现布隆过滤器:
使用位图(Bitmap)实现布隆过滤器:
- 使用 Redis 的位图结构
SETBIT
和GETBIT
操作来实现布隆过滤器。位图本质上是一个比特数组,用于标识元素是否存在。 - 对于给定的数据,通过多个哈希函数计算位置索引,将位图中的相应位置设置为 1,表示该元素可能存在。
使用 RedisBloom 模块:
- Redis 提供了一个官方模块 RedisBloom,封装了哈希函数、位图大小等操作,可以直接用于创建和管理布隆过滤器。
- 使用
BF.ADD
来向布隆过滤器添加元素,使用BF.EXISTS
来检查某个元素是否可能存在。
:::
::: details 细节
BitMap
Bitmap,即位图,是一串连续的二进制数组(0 和 1),可以通过偏移量(offset)定位元素。由于 bit 是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景。例如在一个系统中,不同的用户使用单调递增的用户 ID 表示。40 亿($$2^{32}$$ = $$410241024*1024$$ ≈ 40 亿)用户只需要 512M 内存就能记住某种状态,例如用户是否已登录。
实际上,BitMap 不是真实的数据结构,而是针对 String 实现的一组位操作。
由于 STRING 是二进制安全的,并且其最大长度是 512 MB,所以 BitMap 能最大设置 $$2^{32}$$ 个不同的 bit。
【示例】判断用户是否登录
Bitmap 提供了 GETBIT、SETBIT
操作,通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行读写操作,需要注意的是 offset 从 0 开始。
只需要一个 key = login_status 表示存储用户登陆状态集合数据,将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 GETBIT
判断对应的用户是否在线。50000 万 用户只需要 6 MB 的空间。
假如我们要判断 ID = 10086 的用户的登陆情况:
第一步,执行以下指令,表示用户已登录。
1 | SETBIT login_status 10086 1 |
第二步,检查该用户是否登陆,返回值 1 表示已登录。
1 | GETBIT login_status 10086 |
第三步,登出,将 offset 对应的 value 设置成 0。
1 | SETBIT login_status 10086 0 |
RedisBloom
RedisBloom 是 Redis 官方提供的模块,是一种简化的布隆过滤器实现。它提供了更高性能和更低误判率控制。
RedisBloom 常用命令
BF.RESERVE key error_rate capacity
:创建布隆过滤器(指定误判率、容量)BF.ADD key item
:添加元素BF.EXISTS key item
:检查元素是否存在(可能误判)- 自动扩容:可动态调整数据结构以适应数据增长
RedisBloom 操作示例
创建:
1 | BF.RESERVE myBloomFilter 0.01 1000000 # 误判率 1%,容量 100 万 |
添加元素:
1 | BF.ADD myBloomFilter "item1" |
检查元素:
1 | BF.EXISTS myBloomFilter "item1" # 返回 1(可能存在) |
适用场景
RedisBloom 适合海量数据判断,且允许误判的场景:
- 爬虫:URL 去重
- 黑名单:反垃圾邮件(可能误杀)
- 分布式系统:优化数据查找(如 Hadoop、Cassandra)
- 推荐系统:避免重复推荐
核心特点
- 空间高效:节省存储空间
- 快速查询:O(1) 时间复杂度
- 误判率可控:通过参数调整
:::