Redis 数据结构
问题
Redis 有哪些数据结构?底层是怎么实现的?各种数据结构的应用场景是什么?
答案
一、五种基本数据类型
| 类型 | 说明 | 常用命令 | 典型场景 |
|---|---|---|---|
| String | 字符串/数字/二进制 | GET, SET, INCR, MGET | 缓存、计数器、分布式锁 |
| Hash | 哈希表 | HGET, HSET, HGETALL | 对象存储(用户信息) |
| List | 双向链表 | LPUSH, RPOP, LRANGE | 消息队列、最新列表 |
| Set | 无序集合 | SADD, SMEMBERS, SINTER | 标签、共同好友 |
| ZSet | 有序集合 | ZADD, ZRANGE, ZRANGEBYSCORE | 排行榜、延时队列 |
二、三种特殊类型
| 类型 | 说明 | 典型场景 |
|---|---|---|
| Bitmap | 位图(基于 String) | 签到、在线状态 |
| HyperLogLog | 基数统计 | UV 统计(误差 ~0.81%) |
| GEO | 地理坐标(基于 ZSet) | 附近的人/店铺 |
| Stream(5.0+) | 消息流 | 消息队列(类 Kafka) |
三、底层编码(重点)
Redis 每种数据类型可能有多种底层编码,会根据数据量 自动转换:
| 类型 | 小数据量编码 | 大数据量编码 | 转换阈值 |
|---|---|---|---|
| String | int / embstr | raw (SDS) | 44 字节 |
| Hash | listpack(7.0+)/ ziplist | hashtable | 128 个 / 64 字节 |
| List | listpack / ziplist | quicklist | — |
| Set | intset / listpack | hashtable | 128 个 / 64 字节 |
| ZSet | listpack / ziplist | skiplist + hashtable | 128 个 / 64 字节 |
SDS(Simple Dynamic String)
Redis 不直接用 C 字符串,而是用 SDS:
| 特性 | C 字符串 | SDS |
|---|---|---|
| 获取长度 | O(n) 遍历 | O(1) len 字段 |
| 缓冲区溢出 | 不检查,可能溢出 | 自动扩容 |
| 二进制安全 | 不安全(遇 \0 截断) | 安全(用 len 判断结尾) |
| 内存分配 | 每次修改都重新分配 | 预分配 + 惰性释放 |
跳表(Skip List)— ZSet 的核心
ZSet 在数据量大时使用 skiplist + hashtable 实现:
Level 4: 1 -------------------- 9
Level 3: 1 -------- 5 --------- 9
Level 2: 1 --- 3 -- 5 --- 7 --- 9
Level 1: 1 - 2 - 3 - 4 - 5 - 6 - 7 - 8 - 9
| 操作 | 时间复杂度 |
|---|---|
| 查找 | O(log n) |
| 插入 | O(log n) |
| 删除 | O(log n) |
| 范围查询 | O(log n + k),k 为范围大小 |
为什么用跳表而不是红黑树?
Redis 作者 antirez 给出的原因:
- 跳表实现简单,代码更好维护
- 范围查询(ZRANGEBYSCORE)跳表只需找到起点然后遍历链表,红黑树需要中序遍历
- 跳表通过调整层数可以在时间和空间之间灵活平衡
listpack(紧凑列表)
Redis 7.0 引入 listpack 替代 ziplist,解决了 ziplist 的连锁更新问题:
- 将所有元素 紧凑排列 在一块连续内存中
- 适合元素少的场景(省内存)
- 元素多了自动转为更高效的数据结构
四、各类型详解与命令
String
SET key value [EX seconds] [NX] # 设置值(EX 过期时间,NX 不存在才设置)
GET key # 获取值
INCR key # 原子自增
INCRBY key increment # 原子增加指定值
MSET k1 v1 k2 v2 # 批量设置
MGET k1 k2 # 批量获取
SETNX key value # 不存在时设置(分布式锁)
Hash
HSET user:1 name "张三" age 25 # 设置字段
HGET user:1 name # 获取单个字段
HGETALL user:1 # 获取所有字段和值
HMSET user:1 name "张三" age 25 # 批量设置
HINCRBY user:1 age 1 # 字段自增
HDEL user:1 name # 删除字段
HLEN user:1 # 字段数量
ZSet(有序集合)
ZADD ranking 100 "Alice" 95 "Bob" 90 "Carol" # 添加成员和分数
ZRANGE ranking 0 -1 WITHSCORES # 按分数升序
ZREVRANGE ranking 0 2 # 排名前 3(降序)
ZRANGEBYSCORE ranking 90 100 # 分数范围查询
ZRANK ranking "Alice" # 获取排名
ZINCRBY ranking 5 "Bob" # 分数增加
ZCARD ranking # 成员数量
五、应用场景对照表
| 场景 | 数据类型 | 实现方式 |
|---|---|---|
| 缓存对象 | String / Hash | JSON 字符串或 Hash 字段 |
| 计数器 | String | INCR 原子自增 |
| 分布式锁 | String | SET NX EX |
| 排行榜 | ZSet | ZADD + ZREVRANGE |
| 消息队列 | List / Stream | LPUSH + BRPOP / XADD + XREAD |
| 共同好友 | Set | SINTER 交集 |
| 签到 | Bitmap | SETBIT + BITCOUNT |
| UV 统计 | HyperLogLog | PFADD + PFCOUNT |
| 附近的人 | GEO | GEOADD + GEORADIUS |
| 限流 | ZSet / String | 滑动窗口 / INCR + EXPIRE |
| 延时队列 | ZSet | ZADD 时间戳 + ZRANGEBYSCORE |
常见面试问题
Q1: String 存储对象和 Hash 存储对象怎么选?
答案:
| 方式 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| String + JSON | SET user:1 '{"name":"张三","age":25}' | 序列化简单 | 修改单字段需读写整个 JSON |
| Hash | HSET user:1 name "张三" age 25 | 可单独修改字段 | 无法对整个对象设过期时间 |
选择原则:
- 读多写少、整体操作 → String
- 需要频繁修改部分字段 → Hash
Q2: ZSet 底层为什么同时用跳表和哈希表?
答案:
- 跳表:支持范围查询(
ZRANGEBYSCORE)和按排名查询(ZRANGE),O(log n) - 哈希表:支持按成员查分数(
ZSCORE),O(1) 两者结合覆盖了 ZSet 的所有操作需求。
Q3: Redis 的 Stream 和 Kafka 有什么区别?
答案:
- Stream 适合轻量级消息队列,不需要额外部署组件
- Kafka 是专业的消息系统,吞吐更高、持久化更可靠、支持分区
- Stream 没有 Kafka 的消费者 offset 提交、分区扩展等高级特性
- 简单场景用 Stream,高吞吐场景用 Kafka