Redis(一):数据类型与底层数据结构
Redis(一):数据类型与底层数据结构
导语:Redis 面试的第一层是「你会用吗」,第二层是「你知道它底下是什么吗」。本篇前半部分梳理数据类型与典型业务用法,后半部分深入 SDS、跳表、listpack、渐进式 rehash 等底层实现,并补齐 RedisObject、bigkey/hotkey、内存碎片等高频工程题,共 16 题。
一、数据类型与使用场景
1. Redis 有哪些数据类型?各自适用场景?
答: 分三层来答最清晰——5 种基础类型、4 种扩展类型、Redis 7.4/8.0 的新增能力。
基础类型(必答):
| 类型 | 一句话说明 | 典型场景 |
|---|---|---|
| String | 二进制安全字符串,最大 512MB | 缓存对象、计数器、分布式锁、限流、Session |
| Hash | field → value 映射 | 存储对象(用户资料)、购物车 |
| List | 有序、可重复、双端操作 | 消息队列、最新列表、栈 |
| Set | 无序去重,支持交并差 | 点赞、共同好友、标签、抽奖去重 |
| ZSet | 带 score 的有序去重 | 排行榜、延迟队列、优先级队列 |
扩展类型:
- Bitmap(位图):本质仍是 String,用 bit 表示状态。场景:签到、活跃用户;
- HyperLogLog:基数估算,固定约 12KB 即可统计上亿去重数,标准误差约 0.81%,但取不到具体元素。场景:UV;
- GEO:地理位置,底层用 ZSet 实现(score 存 geohash)。场景:附近的人、距离计算;
- Stream(5.0+):追加式日志,支持消费组与 ACK。场景:可靠消息队列。
Redis 8.0 起内置的原 Redis Stack 能力(新版加分点):
| 结构 | 说明 |
|---|---|
| JSON | 以 key 存 JSON 文档,支持 JSONPath 检索与字段级原子更新(不必取出整个文档) |
| Time Series | 时序数据,自带压缩与降采样规则,适合 IoT/监控指标 |
| Vector Set(Beta) | 由 Redis 作者 antirez 主导,把「有序集合」的思想扩展到高维向量,服务语义搜索/推荐等 AI 场景 |
| 概率结构家族 | Bloom filter、Cuckoo filter、Count-min sketch、Top-k、t-digest(加上原有 HyperLogLog 共 6 种) |
加分句:Redis 8.0 把 Redis Stack 的模块内置进了单一发行版(新增 AGPLv3 许可选项),一次新增 8 种数据结构——从「5 种类型」答到这一层,版本敏感度就体现出来了。
2. String 的常用命令与典型用法?
答: String 可存字符串、整数、二进制(Bitmap 就是它的位操作形态)。核心命令与用法:
| 命令 | 用途 |
|---|---|
SET / GET / MSET / MGET | 读写、批量读写(批量能显著减少 RTT) |
SETNX / SET key value NX EX 30 | 不存在才设置,分布式锁要用带 EX 的整条命令 |
INCR / DECR / INCRBY | 原子自增,计数器(阅读量、库存) |
SETEX / EXPIRE / TTL | 过期控制 |
GETSET / GETDEL | 读并写、读并删 |
APPEND / STRLEN | 追加、取长度(SDS 记录 len,O(1)) |
几个易错点:
SETNX+EXPIRE分两条命令提交是错的:两条命令之间客户端若宕机,锁就不带过期时间 → 死锁。正确做法是SET key value NX EX 30(原子完成「不存在则写 + 设过期」);INCR是原子的,因为 Redis 命令执行是单线程串行,不存在并发穿插;- value 要小:大 value 属于 bigkey,会阻塞单线程并放大网络开销(见第 14 题)。
3. 如何用 Redis 实现排行榜?
答: 用 ZSet,因为它同时具备「去重 + 按分数排序 + 范围查询」三件事:
ZADD rank 100 user:1 # 写分数(已存在则覆盖)
ZINCRBY rank 5 user:1 # 原子加 5 分
ZREVRANGE rank 0 9 WITHSCORES # 取 Top10(降序)
ZRANK rank user:1 # 查名次(升序,从 0 开始)
ZREVRANK rank user:1 # 查名次(降序)
ZRANGEBYSCORE rank 90 100 # 按分数区间取要点:
- score 相同时按 member 字典序 排序,因此要让「同分先到者靠前」需要把时间戳等方式编码进 score(如
score = 分数 + (1 - 时间戳/极大值)); - 海量用户的排行榜要分片:单个 ZSet 过大就是 bigkey,可按用户 ID 哈希分成 N 个 ZSet,再在客户端或定时任务中合并出全局 TopN;
- 周期榜(日榜/周榜) 用「时间维度做 key 后缀」实现,如
rank:20260518,配合过期时间自动清理。
4. List、Set、ZSet 的典型命令与区别?
答:
| 类型 | 关键命令 | 特点与场景 |
|---|---|---|
| List | LPUSH/RPUSH/LPOP/RPOP/LRANGE/LREM,阻塞版 BLPOP/BRPOP | 有序可重复,双端操作;队列(LPUSH+RPOP)、栈(LPUSH+LPOP)、最新列表 |
| Set | SADD/SREM/SISMEMBER/SMISMEMBER,SINTER/SUNION/SDIFF,SCARD | 无序去重;共同关注(交集)、标签、抽奖(SRANDMEMBER) |
| ZSet | ZADD/ZSCORE/ZINCRBY,ZRANGE/ZREVRANGE/ZRANGEBYSCORE/ZRANK,ZREM | 去重 + 按 score 排序;排行榜、延迟队列、带权重的优先级 |
注意:集合运算(
SINTER等)在大集合上是 O(N) 的 CPU 密集操作,会阻塞单线程。两个百万级集合求交集,建议放到从库或用SINTERCARD只求基数,或改用离线任务计算。
5. Bitmap、HyperLogLog、GEO 分别适合什么场景?
答:
- Bitmap:用位表示布尔状态,极为省空间。例如用户一年签到只需
365 bit ≈ 46 字节:SETBIT sign:user:1 100 1(第 100 天签到)GETBIT sign:user:1 100BITCOUNT sign:user:1(统计签到天数)BITOP AND/OR active:week a b c(多天活跃用户做与或)- 局限:用户 ID 必须能映射为连续的 bit 偏移,稀疏 ID 会浪费内存;且只能表达「布尔」;
- HyperLogLog:基数估算,固定 12KB、误差约 0.81%,适合 UV、搜索词去重量这类「只要数量、不要明细」的统计:
PFADD uv:20260518 user:1、PFCOUNT uv:20260518、PFMERGE uv:week uv:1 uv:2- 局限:只能估算基数,不能判断某元素是否存在、不能取元素;小基数时误差相对更明显;
- GEO:地理位置,底层是 ZSet(score 是 52 位 geohash):
GEOADD city 116.40 39.90 "beijing"、GEODIST、GEOSEARCH ... BYRADIUS 5 km- 局限:多了一层 geohash 编码,精度与范围查询性能需要权衡;GEO 底层是 ZSet,因此也能用
ZREM/ZRANGE。
三者选型一句话:要「精确的布尔明细」用 Bitmap,要「大概的数量」用 HyperLogLog,要「空间位置」用 GEO。
6. Stream 是什么?和用 List 做消息队列有什么区别?
答: Stream 是 Redis 5.0 引入的追加式日志结构,专为消息队列设计,解决了 List 做队列的几个硬伤:
| 维度 | List(LPUSH + BRPOP) | Stream |
|---|---|---|
| 消息确认 | 没有 ACK。BRPOP 取出即从队列消失,消费失败就丢 | XREADGROUP 读取后进入 PEL(待确认列表),XACK 才真正确认 |
| 消费组 | 不支持,多消费者只能靠抢占 | 支持 消费者组,组内消息负载均衡,组间互不影响 |
| 历史消息 | 取出即删,无法回看 | XRANGE 可查历史,XADD 可指定自增 ID |
| 重复消费 | 不支持 | 可从上次位置(last_id)继续消费 |
| 阻塞读取 | BRPOP | XREAD BLOCK |
关键命令:XADD(生产)、XLEN、XRANGE、XREAD、XGROUP CREATE(建消费组)、XREADGROUP(组内消费)、XACK(确认)、XPENDING + XCLAIM(认领超时未确认的消息)。
结论:要可靠的业务消息队列,优先 Stream(至少要有 ACK 和重试);只做「最新的 N 条」这类简单场景,List 更省事。
但也要说清边界:Stream 依然不是专业 MQ——没有死信队列、延迟级别、事务消息、广播等成熟能力,且数据存内存、容量受成本限制。生产上「可靠 + 高吞吐」仍应交给 RocketMQ/Kafka。
二、底层数据结构
7. Redis 为什么快?
答: 五个原因,缺一不可:
- 纯内存操作,省去磁盘 IO(最主要的因素);
- 命令执行单线程,避免锁竞争、上下文切换,也不会有并发 bug(见《Redis(五)》);
- IO 多路复用 + 事件驱动(epoll/kqueue),单线程也能扛住海量连接;
- 极度优化的数据结构:SDS、跳表、listpack、intset、渐进式 rehash,都是为「省内存 + 快」定制的;
- 协议与工程细节:RESP 协议简单紧凑、客户端可 Pipeline 批量提交减少 RTT、避免慢命令阻塞主线程。
版本补充:Redis 6.0 起引入多线程 IO(
io-threads,默认 1 即关闭),把网络读写与协议解析分摊到多个线程;Redis 8.0 重写了 IO threading 实现,多核下吞吐提升显著(官方实测io-threads 8时吞吐最高提升约 112%)。但命令执行依然是单线程,所以「单线程模型」这个说法在语义上仍然成立。
8. Redis 的 String 底层是什么(SDS)?
答: 不是 C 原生字符串,而是 SDS(Simple Dynamic String),结构上包含 len(已用长度)、alloc/free(分配与剩余空间)以及以 \0 结尾的字符数组。
相比 C 字符串的四个优势:
- O(1) 取长度:直接读
len,而 C 需要遍历到\0; - 二进制安全:不靠
\0判断结尾,可存任意二进制(图片、序列化对象、\0本身); - 杜绝缓冲区溢出:每次修改前自动检查并扩容;
- 空间预分配与惰性释放:扩容时额外多分配(如
len < 1MB时按 2 倍、超过 1MB 时多给 1MB),缩短用free记录而不立即归还,减少内存重分配次数。
细节加分:SDS 按长度分为 sdshdr5/8/16/32/64 五种头部(用不同位宽存
len与alloc),短字符串用小头部省内存——这也是 Redis「为内存斤斤计较」的典型体现。
9. 跳表是什么?为什么 ZSet 用跳表而不是平衡树?
答: 跳表(Skip List)是在有序链表之上建立多层索引的随机化结构:底层是完整有序链表,上层每隔若干节点抽出一个索引节点,查找时从最高层开始向右再向下,平均时间复杂度 O(log n),期望层高约 O(log n)。
ZSet 的底层是跳表 + 哈希表的组合:
- 哈希表:存
member → score,支持 O(1) 查某成员的分数; - 跳表:按 score 有序,支持 范围查询与排名(
ZRANGE、ZRANK)。
为什么不用平衡树/红黑树:
- 范围查询更快:跳表找到起点后沿底层链表顺序遍历即可;平衡树需要不断做中序遍历与回溯;
- 实现与调试简单:不需要处理旋转与再平衡,出 bug 概率低;
- 并发友好:跳表修改只影响局部指针,锁粒度小;平衡树的旋转可能波及较大范围(虽然 Redis 单线程,但设计思路仍是加分点);
- 内存可控:层高随机(Redis 中每层向上晋升概率 1/4,最高 32 层),可用
zset-max-listpack-*让小集合走紧凑编码,避免一上来就建跳表。
补充:ZSet 的排名查询(
ZRANK)之所以是 O(log n),是因为跳表节点带 span(跨度) 字段,可以在下降过程中累加排名。
10. ziplist 与 listpack 是什么?有什么区别?
答: 两者都是紧凑的连续内存结构,用于「元素少、体积小」的场景,目的是省内存 + 利用 CPU 缓存。
- ziplist(压缩列表):一整块连续内存,通过
prevlen/encoding等头部信息串联各 entry,没有指针开销; - listpack(紧凑列表,Redis 5.0 引入、7.0 全面取代 ziplist):把「记录前一个 entry 长度」改成「记录当前 entry 长度」,从而彻底解决了 ziplist 的经典缺陷。
为什么必须换掉 ziplist——连锁更新(cascade update):
ziplist 每个 entry 用 prevlen 记录前一个 entry 的长度,而 prevlen 本身变长(< 254 用 1 字节,否则用 5 字节)。于是极端情况下:某个 entry 长度从 253 变成 254,会导致后一个 entry 的 prevlen 从 1 字节膨胀成 5 字节,继而使它也「变长」,再影响下一个……一次修改引发整条链表的连续扩容,性能退化为 O(N²)。
listpack 记录的是自身长度,不依赖前驱,因此不存在连锁更新问题。
使用场景(阈值控制,超过就转成标准结构):
| 场景 | 编码 | 阈值配置(Redis 8 默认) |
|---|---|---|
| 小 Hash | listpack | hash-max-listpack-entries 512、hash-max-listpack-value 64 |
| 小 ZSet | listpack | zset-max-listpack-entries 128、zset-max-listpack-value 64 |
| 小 Set(全整数) | intset | set-max-intset-entries 512 |
| 小 Set(非整数) | listpack | set-max-listpack-entries 128 |
| List | quicklist(listpack 节点组成的双向链表) | list-max-listpack-size -2(约 8KB/节点) |
注意阈值会随版本演进(例如
hash-max-listpack-entries在早期版本为 128,Redis 8 默认为 512),回答时给出量级即可,不必死记具体数字。
11. Hash、Set、List、ZSet、String 的底层编码与转换规则?
答: Redis 对每种类型都准备了「省内存的紧凑编码」和「高效率的标准编码」,在元素数量或单元素体积超过阈值时自动转换(且只升不降)。
| 类型 | 紧凑编码 | 标准编码 | 转换触发条件 |
|---|---|---|---|
| String | int(整数值) | embstr(≤44 字节)→ raw(>44 字节) | 长度与内容决定 |
| Hash | listpack | hashtable(dict) | 字段数或字段值长度超阈值 |
| List | listpack(quicklist 的节点) | quicklist(多节点双向链表) | 单节点元素数或元素体积超阈值 |
| Set | intset(全整数)/ listpack | hashtable | 元素非整数,或数量超阈值 |
| ZSet | listpack | skiplist + dict | 成员数或成员长度超阈值 |
要点:
- 转换不可逆:一旦转成 hashtable/skiplist 就不会再降回 listpack。所以「先塞 10000 个元素再删到 10 个」,内存并不会自动缩回去,要用
DEBUG OBJECT看编码,必要时重建 key; - 查看编码:
OBJECT ENCODING key; - embstr 与 raw 的 44 字节:64 位下 embstr 把
redisObject和 SDS 分配在同一块内存(一次分配、缓存友好),超过 44 字节就变成 raw(两次分配); - intset 的升级:intset 元素按
int16 → int32 → int64升级存放,升级不可逆——插入一个大整数会让整个集合升到 int64,即使后来删掉。
12. 什么是 RedisObject?
答: Redis 中所有 value 都不是裸数据,而是包在一个 redisObject 结构里,它让「一个 key 的类型与编码」可以被统一管理:
typedef struct redisObject {
unsigned type:4; // 数据类型:string / list / hash / set / zset / stream
unsigned encoding:4; // 底层编码:int / embstr / raw / listpack / hashtable / skiplist / intset / quicklist
unsigned lru:24; // LRU 时间戳,或 LFU 的「访问计数 + 衰减时间」
int refcount; // 引用计数(共享对象用)
void *ptr; // 指向真正的底层数据结构
} robj;三个关键点:
type+encoding的组合是「同一种逻辑类型可以有多种底层实现」的落点——type是hash,encoding可能是listpack或hashtable(即第 11 题的转换机制);lru字段只有 24 位,无符号 32 位机器上一共就这么多空间,所以:- 用作 LRU 时钟(秒级时间戳,约 194 天回绕);
- 用作 LFU 时,24 位被拆成「16 位衰减时间 + 8 位访问计数」,计数最大只有 255——这就是 LFU 用「对数递增」而非线性累加的原因(见《Redis(二)》);
refcount支持对象共享:Redis 启动时预先创建 0~9999 的整数共享对象,SET key 100不会新建对象而是引用共享对象,省内存;但共享对象只用于「整数值的 String」,因为 List/Hash 等结构带状态、共享会带来复杂性与竞态。
版本补充:
refcount共享池在 Redis 4.0 后受maxmemory影响——当内存策略是 LRU/LFU 时,对象带访问时间,共享会「污染」淘汰判断,因此启用 LRU/LFU 淘汰策略时共享对象会被禁用(OBJECT REFCOUNT会返回 1 或 2147483647)。
13. dict(哈希表)的渐进式 rehash 机制?
答: Redis 的哈希表(dict)在扩容/缩容时不一次性搬迁,而是渐进式 rehash:
- 同时保留两张哈希表
ht[0](旧)和ht[1](新),并维护rehashidx游标; - 每次对字典的增删改查操作,都会顺带把
ht[0]中rehashidx位置上的桶迁移到ht[1],然后rehashidx++; - 期间新写入一律进
ht[1],而查找/删除要两张表都查; - 全部迁移完成后,
ht[1]变成ht[0],ht[1]清空,rehashidx = -1。
触发条件:
| 操作 | 条件 |
|---|---|
| 扩容 | used >= size 且允许 resize(dict_can_resize),扩到 used * 2(≥ 4) |
| 强制扩容 | 正有子进程在跑(BGSAVE / AOF rewrite)时 dict_can_resize = 0,只有负载因子 > 5 才强制扩容(避免写时复制的内存被进一步放大) |
| 缩容 | serverCron 中检测到 used / size < 10% 时缩容(htNeedsResize) |
为什么这么做:如果几十万 key 的表一次性 rehash,单线程会长时间阻塞,所有请求超时——渐进式把「大停顿」摊成「无数个小停顿」。
额外补充(加分):极端情况下(大量过期/被淘汰的 key 让负载因子长期居高不下),Redis 还会在 serverCron 中做 incremental rehashing 的额外搬运;此外 rehash 期间迭代器安全性由
safe迭代器保证——这也是SCAN可能在 rehash 期间返回重复元素的根因(见《Redis(五)》)。
14. 什么是 bigkey?有什么危害?如何发现和处理?
答: bigkey 指 value 过大或元素过多的 key。经验阈值:String 超过 10KB、集合类元素数超过 5000(或单集合占用超 1MB)就值得警惕。
危害:
- 阻塞单线程:
HGETALL、SMEMBERS、DEL、ZRANGE 0 -1这类操作复杂度与元素数成正比,几百万元素的 key 一次操作就是几百毫秒,期间所有请求排队; - 网络拥塞:一次响应动辄几 MB,打满带宽并放大客户端反序列化耗时;
- 删除/过期卡顿:释放大对象要遍历并逐块 free,
maxmemory淘汰时也会卡; - 集群倾斜:大 key 所在分片负载远高于其他分片,扩容也难以打散;
- 主从/持久化放大:复制缓冲、AOF 重写、fork 时的 COW 内存都会受影响。
如何发现:
| 手段 | 说明 |
|---|---|
redis-cli --bigkeys | 用 SCAN 采样统计各类型最大 key,线上安全(不阻塞) |
redis-cli --memkeys | 按内存占用统计(Redis 6.0+) |
SCAN + MEMORY USAGE key | 精确定位单个 key 的字节数 |
INFO keyspace / DEBUG OBJECT key | 看 serializedlength、编码等 |
| RDB 离线分析 | 用 rdb-tools 等工具在备份上离线统计,对线上零影响(推荐用于大实例) |
如何处理:
- 拆分:Hash 按 field 哈希拆成多个 key(如
user:1:info→user:1:info:0..N);String 大 JSON 拆成多个小 key; - 分批删除:用
UNLINK(Redis 4.0+,异步删除,立即返回) 替代DEL;集合类用SCAN/HSCAN+HDEL分批清理; - 防止产生:写入侧做长度/元素数校验,避免
HGETALL、KEYS、SMEMBERS等操作; - 配置兜底:
lazyfree-lazy-user-del yes(把用户的DEL直接变成异步删除)、lazyfree-lazy-expire yes、lazyfree-lazy-eviction yes。
注意:
lazyfree-*系列在 redis.conf 中默认都是no,需要显式开启——这是一个很实用的生产加固项。
15. 什么是热 key(hot key)?如何发现和处理?
答: 热 key 指短时间内被极高频率访问的 key(如明星微博、秒杀商品、大促首页)。它与 bigkey 是两个维度的问题:bigkey 是「太大」,热 key 是「太热」。
危害:
- 单分片打满:集群下热 key 永远落在同一个节点,节点 CPU/网卡被打满,扩容无效(因为 slot 不会因为访问量而迁移);
- 缓存击穿连锁:热 key 一旦过期,瞬时并发全打到 DB(见《Redis(四)》);
- 可能拖垮整个实例:单线程模型下一个热 key 就能吃掉全部处理能力。
如何发现:
| 手段 | 说明 |
|---|---|
redis-cli --hotkeys | 基于 LFU 淘汰策略统计访问频率(必须先设置 allkeys-lfu/volatile-lfu) |
MONITOR | 实时命令流,能看出热 key,但开销极大,严禁在生产长期开启 |
redis-cli --bigkeys 不适用 | 它只看大小,不看频率 |
| 客户端埋点统计 | 在 SDK/代理层(如 Redis 代理、Jedis 拦截器、Service Mesh)本地计数上报,生产最常用 |
INFO commandstats | 只能看到命令维度,粒度不够 |
如何处理:
- 本地缓存(一级缓存):在 JVM 内用 Caffeine/
Guava Cache缓存热 key,把 Redis 的 QPS 降几个数量级——最有效的手段; - key 打散(读写分离思路):把
hot:item:1复制成hot:item:1:0..N,读取时随机挑一个(适合只读场景); - 读写分离 + 多副本:热 key 读走从库/多个只读副本,横向分摊;
- 限流与熔断:对热点接口做单机/集群限流,保护后端;
- 永不过期 + 后台刷新:避免热 key 过期瞬间击穿(配合逻辑过期,见《Redis(四)》)。
16. Redis 的内存碎片是什么?如何处理?
答: 内存碎片 = 操作系统实际分配给 Redis 的物理内存(RSS)与 Redis 逻辑上使用的内存(used_memory)之间的差额。指标是 INFO memory 中的:
mem_fragmentation_ratio = used_memory_rss / used_memory如何判读:
| 比值 | 含义 |
|---|---|
| 1 ~ 1.5 | 正常范围 |
| > 1.5 | 碎片偏多,内存被浪费 |
| < 1 | 说明部分内存已被换到 swap(危险信号,性能会雪崩) |
产生原因:
- 内存分配器机制:Redis 默认用 jemalloc,按固定 size class 分配,申请与释放的大小不匹配就会留下空洞;
- 频繁增删改:大量 key 删除后,释放的页无法被其他大小不同的对象复用;
- 元素频繁增删导致编码转换:listpack/quicklist 反复扩容缩容、intset 升级、渐进式 rehash 的中间状态;
- 主从全量同步、
FLUSHALL、大批量过期 之后的残留。
如何处理:
- 开启主动碎片整理(推荐):Redis 4.0+ 支持
activedefrag yes,配合active-defrag-ignore-bytes、active-defrag-threshold-lower等控制触发时机(会在主线程做少量 CPU 工作,要在业务低峰或从库上开); - 重启/主从切换:最彻底的方式——重启一个从节点 → 提升为主 → 重启另一个,滚动完成;
- 提前预防:避免频繁大量删除、控制 value 大小一致性、必要时
maxmemory留出余量; - 关注
used_memory_dataset_perc:它能区分「碎片」与「自身数据结构开销」,避免误判。
易错点:
used_memory是 Redis 自己统计的(含 allocator 认为已分配的部分),而used_memory_rss是操作系统视角的物理页。所以mem_fragmentation_ratio > 1不一定是故障,需要结合趋势判断。
下一篇:《Redis(二)》讲持久化与内存管理——RDB/AOF/混合持久化的取舍、fork 与写时复制、过期删除与内存淘汰策略,以及近似 LRU/LFU 的实现。
