数据结构与编码
五大基础类型与底层编码
关键认知:同一个对外类型,根据数据量和元素大小会切换不同的底层编码。小数据用紧凑结构省内存,超过阈值转成性能更稳的结构。
| 类型 | 底层编码 | 转换条件 |
|---|---|---|
| String | int / embstr / raw | 纯整数用 int;≤44 字节用 embstr(与对象头连续分配,一次 malloc);更长用 raw(SDS) |
| List | quicklist(3.2+ 由 ziplist + linkedlist 合并而来,7.0 起内部用 listpack) | 自适应,无需配置 |
| Hash | listpack / hashtable | 元素数 ≤ 128 且每个值 ≤ 64 字节用 listpack |
| Set | intset / listpack / hashtable | 全是整数且 ≤ 512 个用 intset;小字符串集合用 listpack(7.2+);否则 hashtable |
| ZSet | listpack / skiplist + hashtable | 元素数 ≤ 128 且每个值 ≤ 64 字节用 listpack;否则跳表 + 字典 |
对应的配置项是 hash-max-listpack-entries、zset-max-listpack-entries 等。编码转换是单向的 —— 一旦升级为 hashtable/skiplist,即使元素减少也不会退回。
SDS:为什么不用 C 字符串
Redis 的字符串用自己实现的 SDS(Simple Dynamic String),原因:
- O(1) 获取长度 —— 结构体里存了
len,C 字符串要遍历到\0 - 二进制安全 —— 靠
len判断结尾而不是\0,所以能存图片等二进制数据 - 杜绝缓冲区溢出 —— 拼接前先检查
alloc空间,不够就自动扩容 - 减少内存重分配 —— 空间预分配(小于 1MB 时翻倍预留)+ 惰性释放(缩短时不立刻 free)
跳表:为什么 ZSet 不用红黑树
ZSet 同时用跳表 + 字典:字典存 member → score 做 O(1) 的分值查询,跳表按 score 有序排列做范围查询。
跳表是多层链表,上层是下层的「索引」,每个节点以 p = 1/4 的概率被提升到上一层,平均查询复杂度 O(log n)。
对比红黑树(面试高频):
- 范围查询更简单 —— 跳表底层就是有序链表,定位起点后顺序遍历即可;红黑树要做中序遍历,实现复杂
- 实现与维护简单 —— 跳表插入删除只改相邻指针,不需要旋转和变色
- 并发友好 —— 局部修改,锁粒度能做得更细
- 内存可调 —— 通过调整概率 p 平衡内存与性能
各类型的典型用法
String —— 缓存对象、计数器(INCR)、分布式锁(SET NX)、分布式 ID(INCRBY)。
List —— 消息队列(LPUSH + BRPOP,但有缺陷:不支持多消费者组、消息丢失后无法重放)、最新消息列表。
Hash —— 存对象的各字段,支持单字段更新,比把整个 JSON 存 String 更省流量。但注意 Hash 不能对单个 field 设过期时间(Redis 7.4 才支持 HEXPIRE)。
Set —— 去重、共同好友(SINTER)、随机抽奖(SRANDMEMBER / SPOP)、点赞。注意 SINTER、SUNION 对大集合是 O(n×m),可能阻塞。
ZSet —— 排行榜(ZREVRANGE)、延时队列(score 存执行时间戳)、滑动窗口限流(score 存请求时间戳,ZREMRANGEBYSCORE 清理过期)。
扩展类型
| 类型 | 用途 | 关键特性 |
|---|---|---|
| Bitmap | 签到、活跃用户统计 | 1 位存一个状态,1 亿用户只需 12MB |
| HyperLogLog | UV 统计 | 固定 12KB 统计上亿基数,标准误差 0.81%,不精确 |
| GEO | 附近的人 | 底层是 ZSet,用 GeoHash 把二维坐标编码成一维 score |
| Stream | 消息队列 | 5.0 新增,支持消费者组、ACK、消息回溯,是 List 做队列的正解 |
:::info HyperLogLog 的考点 它只能算基数(去重后的数量),不能取出具体元素,且结果有误差。面试问「统计 UV 用什么」答 HLL,追问「要精确值怎么办」答 Set 或 Bitmap(用户 ID 作偏移量)—— 精确的代价是内存线性增长。 :::
单线程模型
Redis 真的是单线程吗? 要分层回答:
- 命令执行是单线程 —— 一个主线程串行处理所有命令,所以命令天然原子,不需要加锁
- 网络 IO 在 6.0 起是多线程 ——
io-threads配置多个线程做 socket 读写和协议解析,缓解网络 IO 瓶颈 - 后台任务一直有独立线程 ——
bio线程做UNLINK的异步释放、AOF fsync、关闭文件描述符
IO 多路复用:单线程靠 epoll 同时监听成千上万个连接,哪个就绪处理哪个,避免为每个连接开线程。这是「单线程还能高并发」的关键。
:::warning 单线程的代价
任何慢命令都会阻塞整个实例。高危命令:KEYS *(遍历全库,用 SCAN 代替)、FLUSHALL、大集合的 SMEMBERS / HGETALL / ZRANGE 0 -1、DEL 一个百万元素的大 key(用 UNLINK 异步删)。
:::