3、Redis数据结构(上):String(字符串)、List(列表)、Set(集合)的底层实现与常用命令

好,咱们今天来聊聊 Redis 里最基础、也最常用的三种数据结构。说实话,我刚开始用 Redis 的时候,觉得它就是个高级的 key-value 存储。后来踩了不少坑才明白,真正用好 Redis,关键在于理解它每种数据结构的底层实现。你想想看,同样是存数据,为什么有的场景用 String,有的用 List,有的非 Set 不可?

嗯,这节课我就把 String、List、Set 这三兄弟的底裤扒开给你看。咱们不光讲命令,更要讲它们为什么快什么时候用什么时候千万别用

3.1 String(字符串)—— 最基础,也最容易被忽视

String 是 Redis 里最简单的数据结构。说白了,就是一个 key 对应一个 value。但你别小看它,Redis 的 String 底层用的是 SDS(Simple Dynamic String),而不是 C 语言原生的 char*。

为什么不用 char*?我在项目中遇到过一个问题:用 C 的字符串做拼接,每次都要重新分配内存,性能很差。Redis 的 SDS 解决了这个痛点——它记录了字符串的长度,所以获取长度是 O(1) 的;而且它预分配了空间,做 append 操作时不用每次都 realloc。

核心要点:SDS 通过空间预分配和惰性空间释放,减少了内存重分配的次数。这是 Redis String 高性能的关键。

3.1.1 常用命令

命令 作用 时间复杂度
SET key value 设置 key 的值为 value O(1)
GET key 获取 key 的值 O(1)
INCR key 将 key 的值加 1(原子操作) O(1)
APPEND key value 将 value 追加到 key 的末尾 O(1)
STRLEN key 获取 key 的字符串长度 O(1)

我个人习惯用 INCR 来做计数器。比如统计文章的阅读量,直接用 INCR article:read_count:123,既简单又原子,不用担心并发问题。

小技巧:如果你需要存储 JSON 对象,可以用 String 存序列化后的字符串。但要注意,单个 String 最大是 512MB,别存太大的数据。

3.1.2 底层实现细节

SDS 的结构大概长这样:

struct sdshdr {
    int len;    // 已使用长度
    int free;   // 未使用长度
    char buf[]; // 字节数组
};

你看,它记录了 lenfree。当你要追加字符串时,如果 free 够用,直接写进去就行,不用重新分配内存。我曾经在做一个日志收集系统时,用 Redis String 做临时缓冲区,就是因为 SDS 的 append 性能极好。

注意:虽然 SDS 是二进制安全的,可以存图片、视频等二进制数据。但我建议你不要用 Redis 存大文件,它更适合存小数据。我见过有人把 100MB 的图片塞进 Redis,结果内存暴涨,还拖慢了整个集群。

3.2 List(列表)—— 双向链表,但不止于此

List 在 Redis 里是一个双向链表。这意味着你可以从左边推入、从右边弹出,也可以从右边推入、从左边弹出。说白了,它既可以当栈用,也可以当队列用。

但 Redis 的 List 在底层做了优化:当列表元素较少时,它用压缩列表(ziplist)存储,节省内存;当元素多了,才转为真正的双向链表。这个阈值默认是 512 个元素或 64KB 大小。

3.2.1 常用命令

命令 作用 时间复杂度
LPUSH key value 从左侧推入一个元素 O(1)
RPUSH key value 从右侧推入一个元素 O(1)
LPOP key 从左侧弹出一个元素 O(1)
RPOP key 从右侧弹出一个元素 O(1)
LRANGE key start stop 获取指定范围的元素 O(n)
LLEN key 获取列表长度 O(1)

我记得在做一个消息队列时,就用 LPUSH + BRPOP 的组合。生产者往左边推,消费者从右边阻塞弹出。这样既保证了顺序,又不会空转消耗 CPU。

核心场景:List 最适合做消息队列最新消息列表。比如展示用户的最新 10 条动态,用 LPUSH 推入,再用 LRANGE 0 9 取出。

3.2.2 避坑指南

我曾经犯过一个错误:用 LRANGE 取一个很大的列表。比如列表里有 10 万个元素,我直接 LRANGE 0 -1,结果 Redis 卡住了好几秒。为什么?因为 LRANGE 是 O(n) 的操作,n 越大越慢。

警告:不要对长列表使用 LRANGE 全量查询。如果一定要遍历,建议用 SCAN 或者分批取。另外,List 不适合做随机访问,比如取第 1000 个元素,它得从头遍历到第 1000 个,性能很差。

3.3 Set(集合)—— 无序、唯一、快

Set 是一个无序的、元素唯一的集合。底层实现有两种:当元素都是整数且数量不多时,用整数集合(intset);否则用哈希表(hashtable)

你想想看,为什么要有两种实现?说白了就是为了省内存。整数集合用紧凑的数组存储,每个元素只占几个字节;而哈希表虽然快,但内存开销大。Redis 会在元素个数超过 512 时自动从 intset 转为 hashtable。

3.3.1 常用命令

命令 作用 时间复杂度
SADD key member 向集合添加一个元素 O(1)
SREM key member 从集合移除一个元素 O(1)
SISMEMBER key member 判断元素是否在集合中 O(1)
SMEMBERS key 获取集合所有元素 O(n)
SINTER key1 key2 求两个集合的交集 O(n)
SUNION key1 key2 求两个集合的并集 O(n)
SCARD key 获取集合元素个数 O(1)

我特别喜欢用 Set 做标签系统。比如一篇文章可以有多个标签,用 SADD article:tags:123 "Redis" "缓存" "后端",然后想查某篇文章有哪些标签,直接 SMEMBERS article:tags:123。而且 Set 天然去重,不用担心重复添加。

经典场景:Set 的 SINTER 命令可以做共同好友兴趣推荐。比如求用户 A 和用户 B 的共同关注,直接 SINTER user:A:follows user:B:follows,一行命令搞定。

3.3.2 底层实现细节

当 Set 用哈希表实现时,它的增删查都是 O(1) 的。但要注意,哈希表有 rehash 的过程。当元素数量超过负载因子时,Redis 会渐进式地 rehash,把数据从旧表迁移到新表。这个过程是分批进行的,不会阻塞服务。

我记得有一次,我往一个 Set 里插入了 100 万个元素,担心会卡住。结果 Redis 的渐进式 rehash 做得很好,服务完全没受影响。嗯,这一点 Redis 设计得确实巧妙。

小提示:如果你需要随机获取一个元素,可以用 SRANDMEMBER;如果想随机弹出一个元素,用 SPOP。这两个命令在抽奖系统、随机推荐中非常实用。

3.4 三种数据结构的选择建议

好了,三种数据结构都讲完了。我最后给你一个选择指南:

  • String:适合存单个值,比如计数器、缓存对象、临时数据。注意别存太大的数据。
  • List:适合做队列、栈、最新消息列表。注意别用 LRANGE 取全量。
  • Set:适合做标签、去重、交集并集运算。注意元素多时 SMEMBERS 会慢。

你想想看,如果你要做一个用户在线状态,用 String 存一个 online:user:123 = 1 就够了。但如果你要统计今天活跃用户,用 Set 存 active:2024-01-01,每天一个 Set,用 SCARD 就能知道活跃人数。不同的场景,选对数据结构,事半功倍。

下一节咱们继续聊 Hash(哈希)和 Zset(有序集合),这两个更复杂,但也更有意思。到时候我会讲讲我在做排行榜时是怎么用 Zset 的,保证让你有收获。