# 06.面试八股-Redis篇
# 🟢 第一问
面试官:Redis 支持哪些数据类型?分别用在什么场景?
- String
String底层是SDS简单动态字符串,结构包含len长度、free空闲空间、buf字节数组,相比于C原生的字符串来说,SDS具备效率更高(时间复杂度O(1),C为O(n))、二进制安全(不会以\0结束)、空间预分配(扩容时,多预存空间,防止下次追加时,立刻就分配空间,回收时,不会立即回收缓存,留着空闲用)、防止缓冲区溢出(自动扩容机制)四大优势。底层还分int(整数用)、embstr(redisObject+SDS放一起,<=44用)、raw(分开,>44用)三种编码,充分利用内存,最大支持存储512MB
redisObject (16 字节) + SDS 头 (3 字节) + 结束符\0(1 字节) = 20 字节,一块内存头64字节,64-20=44
典型场景:缓存对象,计数器,分布式锁
- Hash
Redis Hash底层有zipList 压缩列表和 hashTable 字典两种编码
当 Hash 字段数小于512并且 filed 和 value 都小于64字节时,采用压缩列表的形式存储,极其节省内存,但是查找需要遍历(O(n)),一旦超出阈值,就会升级为字典,支持O(1)快速查找,适合存储对象型数据,比String更节省内存,支持局部字段更新
应用场景:存储对象,分页数据等
zipList
基本结构
| field1 | value1 | field2 | value2 | field3 | value3 | ... | end |
每一个entry结构
+----------------+-----------+
| 前置长度 | 编码 | 数据内容 |
+----------------+-----------+
前项 < 254 字节占 1 字节,≥254 占 5 字节
2
3
4
hashTable每一个entry结构
+--------+--------+--------+
| field | value | 下一个节点指针 |
+--------+--------+--------+
2
3
dict 结构体简化
struct dict {
// 两张哈希表,平时只用ht[0]
dictht ht[2];
// rehash 进度标识,-1表示不在rehash
int rehashidx;
};
2
3
4
5
6
dictht 哈希表结构
struct dictht {
// 哈希桶数组
dictEntry **table;
// 数组容量
unsigned long size;
// 已存元素个数
unsigned long used;
};
2
3
4
5
6
7
8
最小单元:dictEntry 节点
每个 field-value 就是一个 dictEntry:
struct dictEntry {
// hash的field
void *key;
// hash的value
void *val;
// 哈希冲突:指向下一个节点,单链表
dictEntry *next;
};
2
3
4
5
6
7
8
哈希桶数组 + 链表 直观结构图
table[] 哈希桶数组
桶0 ──> dictEntry(name:lisi) → 冲突节点1 → 冲突节点2
桶1 ──> dictEntry(age:18)
桶2 ──> 空
桶3 ──> dictEntry(city:beijing)
2
3
4
5
- List
Redis List 底层采用quicklist 快速列表,整体是一个双向链表,链表中的每个节点内部封装了一个ziplist 压缩列表;既利用ziplist 紧凑省内存,又利用双向链表头尾的高效操作,解决了内存开销大和ziplist增删慢的问题
第一层:quicklist 整体
struct quicklist {
// 头节点
quicklistNode *head;
// 尾节点
quicklistNode *tail;
// 总元素个数
long count;
// 节点数量
int nodes;
};
2
3
4
5
6
7
8
9
10
第二层:quicklistNode 双向链表节点
struct quicklistNode {
// 前驱节点
quicklistNode *prev;
// 后继节点
quicklistNode *next;
// 指向内部的 ziplist
unsigned char *zl;
// 当前 ziplist 占用字节数
unsigned int sz;
// 当前 ziplist 里存了多少个元素
unsigned int count : 16;
};
2
3
4
5
6
7
8
9
10
11
12
第三层:每个 node 内部的 ziplist
就是你刚才学的压缩列表
连续内存、紧凑存多条 list 元素:
[elem1][elem2][elem3][elem4]
- set
set的底层有intset整数集合和hashtable两种编码;全是整数并且元素数量少于512时使用intset,以连续有序数组存储、省内存、二分查找,一旦有非整数或是数量超过512就用hashtable,注意他只使用hashtable的key来去重,O(1)读写
intset内存结构(整块连续内存)
+--------------------------+
| encoding | length | contents[] |
+--------------------------+
2
3
字段解释:
1)encoding:整数类型大小
支持 16bit / 32bit / 64bit 自动升级
2)length:集合元素个数
3)contents[]:有序、无重复 整数数组
- ZSet
zset底层使用了ziplist和skiplist+dict两种编码方式,当每个member长度小于64字节并且元素数量小于128时,使用ziplist节省内存;超出阈值,就会升级为跳表+字典的编码方式,跳表(O(logn))负责高效范围查询和排序,字典负责按成员极速精准查询。
# 快速记忆口诀
- String三编码,四四分界;
- List独有快速列表;
- Hash字段五一二、字节六四;
- Set纯数512用数组;
- ZSet一二八、六四,跳表加字典
| 类型 | 底层结构 | 典型场景 |
|---|---|---|
| String | SDS | 缓存对象、计数器、分布式锁 |
| Hash | dict + ziplist | 存储对象(如用户信息、购物车) |
| List | quicklist | 消息队列、最新消息列表 |
| Set | dict + intset | 标签、抽奖、共同关注 |
| ZSet | skiplist + dict | 排行榜、延迟队列、带权重的任务 |
# 🟡 第二问(追问)
面试官:String 底层用 SDS(简单动态字符串)而不是 C 字符串,有什么优势?
二进制安全,不会以\0结束
会自动扩容,不会缓冲区溢出
O(1)复杂度,不用遍历获取值
内存预分配,减少了频繁修改字符串时的内存重分配次数。
# 🔴 第三问(深度追问)
面试官:ZSet 底层为什么用跳表而不是红黑树?
跳表的实现比红黑树更简单
红黑树需要记录父结点,左右结点,标记颜色,太浪费内存
跳表是线性操作,范围查找效率更高,红黑树,还需要中序遍历
增删操作时,红黑树需要调整结点位置实现自平衡,而跳表只需要通过随机概率生成层数,调整相邻结点的指针即可
# 🟢 第一问
面试官:什么是缓存穿透?怎么解决?
定义:查询一个不存在的数据直接打到数据库
解决:
缓存一个空值
布隆过滤器
补充知识:布隆过滤器
插入:当你加入一个元素时,他会将这个元素通过各种哈希运算,得到几个数字,将bitMap中这几个数字变为1
查询:当你查询是否有一个元素时,如果运算出来的哈希数字在bitMap中都为1,说明这个元素可能存在,为什么说可能,因为可能是其他几个元素运算出来的哈希,覆盖了这几个数字,如果这几个数字有一个不唯一则一定不存在。
# 🔴 第三问(区分题)
面试官:缓存击穿和雪崩的区别?分别怎么解决?
| 问题 | 定义 | 解决方案 |
|---|---|---|
| 击穿 | 一个热点 key 过期,大量请求打 DB | 互斥锁(SETNX)、逻辑过期 |
| 雪崩 | 大量 key 同时过期,请求打 DB | TTL 加随机值、多级缓存、熔断降级 |
互斥锁:当缓存失效时,只允许有一个去查询数据库,重建缓存,其余的等待重试,等待一段时间后,第一个缓存建立完成,从缓存中读取
逻辑过期:当前的缓存不设置物理过期时间,而是设置一个逻辑过期时间,当查询的缓存的逻辑过期时间过期了,就可以开启一个异步线程后台创建新缓存,当前请求仍返回旧数据
TTL+随机值:一个基础过期时间加上随机值(1-300s)
多级缓存:引入本地缓存(如 Caffeine、Guava)作为第一层缓存,Redis 作为第二层。
熔断降级:使用 Sentinel 等组件监控数据库的压力。当数据库 QPS 超过警戒线时,直接触发熔断,对非核心业务返回默认值或静态兜底数据,防止数据库被彻底压垮。
# 🟢 第一问
面试官:用 Redis 实现分布式锁,最简单的写法是什么?有什么问题?
简单写法:
Boolean locked = redis.setnx("lock:order", "thread-1");
if (locked) {
redis.expire("lock:order", 30);
}
// 释放锁
redis.del("lock:order");
2
3
4
5
6
问题:
- 加锁和设置过期不是原子操作(setnx 成功但设置过期前宕机 → 死锁)
# 🟡 第二问(追问)
面试官:如何防止自己的锁被别人释放?
问题:线程 A 持锁超时(30s),线程 B 拿到锁,线程 A 执行完调用 del 时删的是 B 的锁
- 在加锁时给value设置一个唯一标识比如(UUID+线程id),然后在释放锁之前,先写一个lua脚本,脚本内容包括:加一个判断逻辑,锁是否时自己的,是自己的才能删除。防止先判断是自己的情况下,期间发生在删除之前过期,而另一个线程抢到了锁这种现象
-- KEYS[1] 是要释放的锁的 key,ARGV[1] 是当前客户端的唯一标识
if redis.call("get",KEYS[1] == ARGV [1]) then
return redis.call("del",KEYS[1])
else
ruturn 0
end
2
3
4
5
6
# 🔴 第三问(深度追问)
面试官:RedLock(红锁)是什么?为什么要用它?
💡 答案要点
问题:单机 Redis 锁在主从复制时可能丢(主节点加锁成功但未同步到从就宕机,从变主后锁丢失)
RedLock 思想(Redis 作者提出):
在 N 个独立节点(N 通常为奇数,如 5)上都尝试加锁
只有当超过半数(N/2+1)节点加锁成功,且总耗时 < 锁过期时间,才算加锁成功
争议:RedLock 依赖系统时钟,在时钟跳跃场景下仍有问题。一般业务用单机 Redis + 主从 + Lua 脚本就够了,没必要上 RedLock
# 🟢 第一问
面试官:RDB 和 AOF 的区别?
- RDB和AOF的核心区别在于RDB是全量快照,文件小,恢复块,但是数据安全性差(两次快照之间redis宕机容易丢数据);AOF是增量日志(记录指令),数据安全性高,但是文件大,恢复缓慢,redis 4.0之后出现了混合持久化的方案,RDB快速恢复,AOF数据安全,是目前的最佳实践
# 🟡 第二问(追问)
面试官:AOF 重写(rewrite)是什么?为什么需要?
- 随着AOF写的数据越来越多,文件也会变得越来越大,使得文件包含了一堆重复的指令,这时候AOF重写就派上用场了,他会fork出一个子进程,来去将文件的内容精简到一个新的文件中去,同时开启一个重写缓冲区,来记录新写过来的指令,当新文件重写完时,再将重写缓冲区的内容写进新文件里,实现文件压缩。
# 🟢 第一问
面试官:Redis 怎么删除过期 key?为什么不用定时器?
惰性删除:每次访问时判断是否过期,过期就删除
定时删除:每100ms抽取一部分key,判断是否过期
为什么不用定时器:
每个 key 一个定时器 → CPU 开销巨大
Redis 单线程,定时器会阻塞主线程
# 🟡 第二问(追问)
面试官:内存满了怎么办?8 种淘汰策略说几个重要的
| 策略名称 | 淘汰范围 | 核心淘汰逻辑 |
|---|---|---|
| noeviction | 不淘汰 | 写操作直接报错,不删除任何数据 |
| allkeys-lru | 所有键 | 淘汰最近最少使用的键(最常用) |
| allkeys-lfu | 所有键 | 淘汰访问频率最低的键 |
| allkeys-random | 所有键 | 随机淘汰 |
| volatile-lru | 仅带TTL的键 | 淘汰带过期时间且最近最少使用的键 |
| volatile-lfu | 仅带TTL的键 | 淘汰带过期时间且访问频率最低的键 |
| volatile-random | 仅带TTL的键 | 在带过期时间的键中随机淘汰 |
| volatile-ttl | 仅带TTL的键 | 淘汰带过期时间且剩余时间最短的键 |
LRU vs LFU:
LRU:最近最少使用(看时间)
LFU:最不经常使用(看频率 + 时间),更精准
推荐使用 allkeys-lfu 或 allkeys-lru
# 🟢 根据你的项目出的题
你简历里写了:
Redis 分布式缓存
Redisson 分布式锁
Redisson RateLimiter 分布式限流
# 第一问
面试官:Redisson 的分布式锁和 SETNX + Lua 自己实现有什么优势?
Redisson 有看门狗机制,可实现锁的自动续期,防止其他线程释放非本线程资源,虽然自己也能实现防止其他线程乱释放资源的功能,但是续期实现相对麻烦,且浪费资源
Redisson支持可重入锁(同一个线程获取锁后,再次请求同一把锁不会阻塞。),而自己实现的话,可能会导致死锁,比如,执行方法A、B需要加锁,在A中调用B,相当于获取同一把锁,如果是自己实现,就会出现死锁
public void method1() {
synchronized (this) {
System.out.println("进入 method1");
// 同一线程再次加锁,直接放行,不会死锁
method2();
}
}
public void method2() {
synchronized (this) {
System.out.println("进入 method2");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
- Redisson支持高效的阻塞等待机制,当锁等待失败时,会进入阻塞状态,一旦释放锁,Redisson会立刻通过消息通知阻塞进程,来进行抢锁,既不浪费CPU又能高效响应。而自己实现只能是抢不到就放弃或者用while true自旋,严重浪费CPU
# 第二问
面试官:Redisson 的看门狗机制原理是什么?
默认加锁过期时间30s
锁到期剩10秒时,自动续期到30s
如果宕机,锁过期自动释放
业务执行完手动释放
# 第三问
面试官:你在项目里用 Redisson RateLimiter 实现了限流,原理是什么?
原理:通过令牌桶算法实现,有一个桶和一个生成器,生成器每一段时间往桶里加令牌,每次到达的请求必须有令牌才能去访问,如果没有令牌,请求会被限流或拒绝
public void doRateLimit(String key) {
RRateLimiter rateLimiter = redissonClient.getRateLimiter(key);
rateLimiter.trySetRateAsync(RateType.OVERALL,2,1, RateIntervalUnit.SECONDS);
//每当一个用户调用接口时,请求一个令牌
boolean b = rateLimiter.tryAcquire(1);
if (!b) {
throw new BusinessException(ErrorCode.TOO_MANY_REQUEST_ERROR);
}
2
3
4
5
6
7
8
后端面试中关于 Redis 的高频核心考点
# 1. 为什么 Redis 是单线程还那么快?
纯内存操作
IO多路复用,单线程可以同时监听成千上万个客户端连接,只有当某个连接有数据可读或可写时,才会触发事件去处理。这避免了大量的线程上下文切换和阻塞等待。
单线程无锁竞争
数据结构高效
# 2. 缓存和 DB 一致性问题怎么解决?
延时双删
先删除缓存(防止读到旧数据)
更新数据库
再删缓存(防止其他线程在更新数据库期间,读到了旧数据写进缓存)
先更新数据库再删除缓存
# 3. Redis 做消息队列和 RabbitMQ 有什么区别?
RabbitMQ支持消息确认机制,消息可靠度高,redis不支持
RabbitMQ支持复杂的路由规则,延迟队列,广播,优先级队列等高级特性,redis只支持简单的先进先出,发布订阅
RabbitMQ有完善的磁盘持久化机制,支持海量消息堆积,而redis是内存操作,消息堆积影响性能