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。
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,既简单又原子,不用担心并发问题。
3.1.2 底层实现细节
SDS 的结构大概长这样:
struct sdshdr {
int len; // 已使用长度
int free; // 未使用长度
char buf[]; // 字节数组
};
你看,它记录了 len 和 free。当你要追加字符串时,如果 free 够用,直接写进去就行,不用重新分配内存。我曾经在做一个日志收集系统时,用 Redis String 做临时缓冲区,就是因为 SDS 的 append 性能极好。
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。
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 天然去重,不用担心重复添加。
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 的,保证让你有收获。