Redis 面试题与应用场景
一、数据结构与基础
Q1: Redis 为什么这么快? ⭐⭐⭐
答案:
- 纯内存操作:数据存储在内存中,读写无磁盘 I/O
- 单线程模型:避免上下文切换和锁竞争开销
- I/O 多路复用:epoll/kqueue 高效处理大量并发连接
- 高效数据结构:SDS、跳表、listpack 等专门优化的结构
- 6.0+ 多线程 I/O:网络读写并行化,命令执行仍单线程
追问:单线程为什么还能支撑 10万+ QPS?
- 瓶颈在网络 I/O 而非 CPU
- 内存操作本身极快(纳秒级)
- 避免了多线程的锁开销
Q2: Redis 的 String 底层是怎么实现的?
答案:
String 有三种内部编码:
- int:值为整数且 ≤ long 范围时,直接存储整数值
- embstr:字符串 ≤ 44 字节,SDS 和 redisObject 一次分配连续内存
- raw:字符串 > 44 字节,SDS 和 redisObject 分开分配
SDS(Simple Dynamic String)相比 C 字符串的优势:
- O(1) 获取长度(len 字段)
- 二进制安全(不以 \0 判断结束)
- 预分配空间减少内存重分配
- 兼容部分 C 字符串函数
Q3: Sorted Set 为什么用跳表而不是红黑树? ⭐⭐
答案:
- 范围查询高效:跳表天然有序,范围查询 O(logN+M),红黑树需要中序遍历
- 实现简单:跳表代码比红黑树简单得多,易于维护和调试
- 内存友好:可以通过调整层数灵活平衡时间和空间
- 并发友好:跳表局部修改即可,红黑树可能需要旋转影响多个节点
实际上 Sorted Set 用的是 skiplist + hashtable 双结构:
- skiplist:支持有序遍历和范围查询
- hashtable:O(1) 按 member 查 score
Q4: Redis 各数据类型的底层编码和使用场景?
答案:
| 类型 | 底层编码 | 切换条件 | 典型场景 |
|---|---|---|---|
| String | int / embstr / raw | 长度 > 44 字节用 raw | 缓存、计数器、分布式锁 |
| List | listpack / quicklist | 元素数 > 128 或单元素 > 64 字节 | 消息队列、最新列表 |
| Hash | listpack / hashtable | 字段数 > 128 或值 > 64 字节 | 对象存储、购物车 |
| Set | listpack / hashtable | 元素数 > 128 或非整数 | 标签、好友关系、去重 |
| ZSet | listpack / skiplist+hashtable | 元素数 > 128 或值 > 64 字节 | 排行榜、延迟队列 |
Q5: Redis 的过期键删除策略? ⭐⭐⭐
答案:
Redis 使用两种策略结合:
惰性删除: 访问 key 时检查是否过期,过期则删除。
- 优点:CPU 友好(只在访问时检查)
- 缺点:过期 key 不访问则永不删除,内存泄漏
定期删除: 每隔一段时间(默认 100ms)随机抽取一批 key 检查,删除过期的。
- 每次抽取 20 个,若过期比例 > 25% 则继续抽取
- 限制每次执行时间,避免阻塞
追问:内存不足时怎么办?
触发内存淘汰策略(maxmemory-policy):
noeviction:不淘汰,写入报错(默认)allkeys-lru:淘汰最近最少使用的 key(推荐)volatile-lru:只淘汰设置了过期时间的 key 中 LRU 的allkeys-random:随机淘汰
二、持久化
Q6: RDB 和 AOF 的区别? ⭐⭐⭐
答案:
| 维度 | RDB | AOF |
|---|---|---|
| 原理 | 定时快照(fork 子进程) | 记录每条写命令 |
| 文件大小 | 小(二进制压缩) | 大(文本命令) |
| 恢复速度 | 快 | 慢(重放命令) |
| 数据安全 | 低(两次快照间数据丢失) | 高(最多丢失 1 秒) |
| 性能影响 | fork 时有短暂 STW | 每秒 fsync 影响小 |
| 适用场景 | 备份、快速恢复 | 数据安全要求高 |
混合持久化(推荐): AOF 文件前半段是 RDB 快照,后半段是增量 AOF 命令,兼顾恢复速度和数据安全。
Q7: AOF 重写的原理?
答案:
AOF 文件会随时间增大(同一 key 多次修改产生多条命令),重写将其压缩为最小命令集。
过程:
- fork 子进程,子进程读取当前内存数据,生成新 AOF 文件
- 主进程继续处理请求,新命令写入 AOF 重写缓冲区
- 子进程完成后,主进程将缓冲区命令追加到新 AOF 文件
- 原子替换旧 AOF 文件
触发条件:
auto-aof-rewrite-percentage 100:AOF 文件比上次重写后增长 100%auto-aof-rewrite-min-size 64mb:AOF 文件至少 64MB
三、高可用
Q8: Redis Sentinel 的工作原理? ⭐⭐
答案:
Sentinel 实现主从自动故障转移:
监控: 每个 Sentinel 每秒向 Master/Slave/其他 Sentinel 发送 PING。
主观下线(SDOWN): 单个 Sentinel 认为 Master 不可达(超过 down-after-milliseconds)。
客观下线(ODOWN): 超过 quorum 个 Sentinel 都认为 Master 主观下线,则判定客观下线。
故障转移:
- Sentinel 之间选举 Leader(Raft)
- Leader 从 Slave 中选出新 Master(优先级 > 复制偏移量 > runid)
- 向新 Master 发送
SLAVEOF NO ONE - 通知其他 Slave 复制新 Master
- 通知客户端新 Master 地址
Q9: Redis Cluster 的数据分片原理? ⭐⭐⭐
答案:
Redis Cluster 使用 哈希槽(Hash Slot) 分片,共 16384 个槽:
slot = CRC16(key) % 16384
每个 Master 节点负责一部分槽(如 3 节点:0-5460 / 5461-10922 / 10923-16383)。
MOVED 重定向: 客户端请求到错误节点时,节点返回 MOVED slot ip:port,客户端重定向。
ASK 重定向: 槽迁移过程中,数据在源节点和目标节点都可能存在,用 ASK 临时重定向。
追问:为什么是 16384 个槽而不是更多?
- 心跳包中携带槽位图,16384 个槽只需 2KB(16384/8 字节)
- 节点数通常不超过 1000,16384 个槽已足够分配
Q10: Sentinel 和 Cluster 怎么选?
答案:
| 维度 | Sentinel | Cluster |
|---|---|---|
| 数据量 | 单机可承载(< 几十 GB) | 需要水平扩展(> 100 GB) |
| 写吞吐 | 单 Master 限制 | 多 Master 并行写 |
| 运维复杂度 | 低 | 高(槽迁移、多节点管理) |
| 多 key 操作 | 支持 | 受限(key 必须在同一槽) |
| 客户端 | 简单 | 需要 Cluster 感知客户端 |
选型: 数据量 < 50GB 且写压力不大 → Sentinel;需要水平扩展 → Cluster。
四、缓存模式与经典问题
Q11: 缓存穿透、击穿、雪崩的区别和解决方案? ⭐⭐⭐
答案:
缓存穿透: 查询不存在的数据,缓存和数据库都没有,每次都打到数据库。
- 解决:布隆过滤器(拦截不存在的 key);缓存空值(短 TTL)
缓存击穿: 热点 key 过期瞬间,大量请求同时打到数据库。
- 解决:互斥锁(只有一个请求重建缓存);逻辑过期(不设 TTL,异步更新)
缓存雪崩: 大量 key 同时过期,或 Redis 宕机,请求全部打到数据库。
- 解决:过期时间加随机抖动;Redis 高可用(Sentinel/Cluster);限流降级
Q12: 如何保证缓存和数据库的一致性? ⭐⭐⭐
答案:
常见方案对比:
| 方案 | 原理 | 问题 |
|---|---|---|
| 先更新 DB,再删缓存(推荐) | 更新 DB 后删除缓存,下次读时重建 | 删缓存失败导致不一致 |
| 先删缓存,再更新 DB | 删缓存后更新 DB | 并发读在 DB 更新前重建旧缓存 |
| 延迟双删 | 更新 DB 后删缓存,延迟再删一次 | 延迟时间难以确定 |
| Canal 订阅 binlog | 监听 DB 变更,异步删缓存 | 架构复杂,有短暂不一致 |
推荐: 先更新 DB,再删缓存 + 删缓存失败重试(消息队列保证最终一致)。
Q13: 布隆过滤器的原理和误判率?
答案:
布隆过滤器用多个哈希函数将元素映射到 bit 数组的多个位置,全部置 1 表示存在。
查询: 所有对应位都为 1 → 可能存在;任意一位为 0 → 一定不存在。
特点:
- 不存在的元素一定返回"不存在"(无漏报)
- 存在的元素可能返回"不存在"(有误判,但概率可控)
- 不支持删除(删除会影响其他元素)
误判率控制: 增大 bit 数组或增加哈希函数数量可降低误判率,但增加内存和计算开销。
五、分布式锁
Q14: 用 Redis 实现分布式锁的正确姿势? ⭐⭐⭐
答案:
SET lock_key unique_value NX PX 30000
NX:不存在才设置(原子性)PX 30000:30 秒自动过期(防止死锁)unique_value:UUID,释放时验证是自己的锁
释放锁(Lua 脚本保证原子性):
lua
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
追问:为什么不能直接 DEL?
- 可能删除别人的锁:锁过期后被其他线程获取,自己再 DEL 就删了别人的锁
Q15: Redlock 算法是什么?有什么争议? ⭐⭐
答案:
Redlock 在 N 个独立 Redis 节点上获取锁,超过 N/2+1 个成功才算获取成功。
步骤:
- 获取当前时间 t1
- 依次向 N 个节点请求加锁(超时时间远小于锁有效期)
- 计算获取锁耗时 t2-t1,锁实际有效期 = 设定有效期 - 耗时
- 成功节点数 > N/2+1 且有效期 > 0,则获取成功
- 失败则向所有节点释放锁
争议(Martin Kleppmann):
- 时钟漂移可能导致锁提前过期
- GC STW 期间锁可能过期,但进程认为仍持有锁
- 对于强一致性场景,建议用 ZooKeeper 或 etcd
实践: 大多数场景单节点 Redis 锁足够,Redlock 适合对锁安全性要求极高的场景。
六、性能优化
Q16: 什么是 BigKey?如何处理? ⭐⭐
答案:
BigKey:单个 key 的 value 过大(String > 10KB,集合元素数 > 5000)。
危害:
- 网络传输慢,阻塞其他命令(Redis 单线程)
- 内存分配/释放耗时,导致延迟抖动
- 集群模式下数据倾斜
发现:
bash
redis-cli --bigkeys # 扫描各类型最大 key
redis-cli --memkeys # 按内存大小排序
处理:
- String BigKey:拆分为多个 key(分片存储)
- Hash/Set BigKey:按字段范围拆分为多个 key
- 删除 BigKey:用
UNLINK(异步删除)代替DEL(同步删除)
Q17: Pipeline 的原理和使用场景?
答案:
Pipeline 将多个命令批量发送,减少网络往返次数(RTT):
普通:命令1 → 等待响应 → 命令2 → 等待响应 → ...(N次RTT)
Pipeline:命令1+2+...+N → 一次发送 → 批量响应(1次RTT)
注意:
- Pipeline 不是原子的(中间可能有其他客户端命令插入)
- 需要原子性用 Lua 脚本或 MULTI/EXEC 事务
- 每批建议 100-1000 条命令,过多会占用大量内存
Q18: Redis 的慢查询如何排查?
答案:
bash
# 配置慢查询阈值(微秒)
CONFIG SET slowlog-log-slower-than 10000 # 10ms
# 查看慢查询日志
SLOWLOG GET 10 # 最近 10 条
SLOWLOG LEN # 慢查询总数
SLOWLOG RESET # 清空
常见慢命令:
KEYS *:全量扫描,O(N),生产禁用,用SCAN替代HGETALL:返回所有字段,Hash 很大时慢SMEMBERS:返回所有成员,Set 很大时慢SORT:排序操作,复杂度高
七、Redis 新特性
Q19: Redis 7.x 的重要新特性?
答案:
- Redis Functions:替代 Lua 脚本,支持持久化和复制(脚本随数据一起复制)
- Multi-Part AOF:AOF 拆分为多个文件,解决重写时的性能问题
- ACL v2:更细粒度的权限控制(按 key 前缀、命令类别)
- Listpack 全面替代 ziplist:更紧凑的内存编码
Q20: Redis Stream 是什么?和 List 做消息队列的区别?
答案:
Stream 是 Redis 5.0 引入的持久化消息流,支持消费者组:
| 维度 | List | Stream |
|---|---|---|
| 消费模式 | 单消费者(LPOP) | 消费者组(多消费者并行) |
| 消息确认 | 无(LPOP 即删除) | XACK 确认,未确认可重新消费 |
| 历史消息 | 消费后丢失 | 保留(可按 ID 范围查询) |
| 消费者组 | 不支持 | 支持(多组独立消费) |
| 持久化 | 支持 | 支持 |
Stream 适合: 需要消费确认、多消费者组、消息回溯的场景(轻量级 MQ)。
八、场景题
场景1: 电商秒杀系统如何用 Redis 防止超卖? ⭐⭐⭐
场景描述: 商品库存 100,瞬间 10 万并发请求,如何保证不超卖?
方案:
- 预热库存到 Redis :系统启动时
SET stock:1001 100 - 原子扣减(Lua 脚本):
lua
local stock = tonumber(redis.call("get", KEYS[1]))
if stock <= 0 then return -1 end
redis.call("decrby", KEYS[1], ARGV[1])
return stock - tonumber(ARGV[1])
- 异步落库:扣减成功后发 MQ,消费者异步更新数据库
- 兜底校验 :数据库层加乐观锁(
WHERE stock >= #{count})
关键点:
- Lua 脚本保证"查询+扣减"原子性,避免 TOCTOU 竞态
- Redis 单线程天然串行,不需要额外加锁
- 库存预热时可用
SETNX防止重复初始化
场景2: 如何用 Redis 实现接口限流(每用户每分钟 100 次)? ⭐⭐⭐
场景描述: API 网关需要对每个用户 ID 限制调用频率。
方案一:固定窗口(简单)
key = "rate:uid:1001:202605111430" # 分钟级 key
INCR key
EXPIRE key 60
缺点:窗口边界突刺(前一分钟末 + 后一分钟初可能 200 次)
方案二:滑动窗口(ZSet)
key = "rate:uid:1001"
now = current_timestamp_ms
ZREMRANGEBYSCORE key 0 (now - 60000) # 删除 1 分钟前的记录
count = ZCARD key
if count >= 100: reject
ZADD key now now
EXPIRE key 60
优点:精确滑动,无边界突刺
缺点:每次请求写入一条记录,内存占用较高
方案三:令牌桶(Redis + Lua)
- 用 Hash 存储
{tokens, last_refill_time} - 每次请求用 Lua 脚本计算补充令牌数并消费
选型建议: 普通限流用固定窗口;精确限流用滑动窗口 ZSet;高并发令牌桶场景用 Lua 脚本。
场景3: 用户登录 Session 如何用 Redis 管理?支持单点登录踢出旧设备? ⭐⭐
场景描述: 用户登录后生成 Token,要求同一账号只能在一台设备登录,新登录踢出旧 Token。
设计:
# 登录时
token = UUID()
SET session:{token} {user_info_json} EX 7200 # Token → 用户信息
SET user_token:{uid} {token} EX 7200 # uid → 当前有效 Token
# 验证时
current_token = GET user_token:{uid}
if current_token != request_token: reject (已被踢出)
user_info = GET session:{current_token}
# 新设备登录时
old_token = GET user_token:{uid}
DEL session:{old_token} # 删除旧 Session
SET session:{new_token} ... / SET user_token:{uid} {new_token}
追问:如何支持多设备登录(最多 3 台)?
- 用
user_tokens:{uid}存 ZSet,score 为登录时间 - 登录时 ZADD,若 ZCARD > 3 则 ZRANGE 取最旧的踢出
场景4: 排行榜系统(实时积分榜)如何设计? ⭐⭐
场景描述: 游戏积分实时排行榜,支持查询 Top 100、查询某用户排名。
设计:
bash
# 更新积分
ZADD leaderboard {score} {uid}
# 查 Top 100(降序)
ZREVRANGE leaderboard 0 99 WITHSCORES
# 查某用户排名(0-based,+1 得名次)
ZREVRANK leaderboard {uid}
# 查某用户积分
ZSCORE leaderboard {uid}
# 积分增量更新
ZINCRBY leaderboard {delta} {uid}
分页查询:
bash
ZREVRANGE leaderboard {(page-1)*size} {page*size-1} WITHSCORES
追问:榜单数据量很大(百万用户)怎么优化?
- 只维护 Top N(如 Top 10000)的 ZSet,超出范围的用数据库查
- 定时任务清理低分用户:
ZREMRANGEBYRANK leaderboard 0 -10001
场景5: 如何用 Redis 实现延迟队列(订单超时自动取消)? ⭐⭐⭐
场景描述: 用户下单后 30 分钟未支付自动取消,订单量大,如何高效实现?
设计(ZSet 延迟队列):
bash
# 下单时,score = 当前时间 + 30分钟(Unix 时间戳)
ZADD delay_queue {now + 1800} {order_id}
# 消费者轮询(每秒执行)
now = current_timestamp
orders = ZRANGEBYSCORE delay_queue 0 {now} LIMIT 0 10
for order_id in orders:
# 用 Lua 保证原子性:取出并删除
ZREM delay_queue {order_id}
# 处理超时逻辑
Lua 原子取出:
lua
local items = redis.call("ZRANGEBYSCORE", KEYS[1], 0, ARGV[1], "LIMIT", 0, 10)
for _, v in ipairs(items) do
redis.call("ZREM", KEYS[1], v)
end
return items
追问:和 RocketMQ 延迟消息相比?
- Redis 延迟队列:实现简单,精度高(秒级),但不支持消费确认和重试
- RocketMQ:支持消费确认、重试、死信队列,适合可靠性要求高的场景
场景6: 缓存预热方案设计(系统启动时如何避免冷启动打垮数据库)? ⭐⭐
场景描述: 服务重启后 Redis 缓存为空,大量请求直接打到数据库,如何处理?
方案:
- 启动时主动预热:服务启动时异步加载热点数据到 Redis(从数据库或离线统计的热点 key 列表)
- 分批预热:避免一次性加载导致数据库压力过大,分批次、限速加载
- 互斥锁防击穿 :预热期间缓存未就绪时,用
SETNX互斥锁保证只有一个请求重建缓存 - 降级兜底:预热完成前,对非核心接口返回默认值或限流
java
// 互斥锁重建缓存
String value = redis.get(key);
if (value == null) {
if (redis.setnx("lock:" + key, "1", 10)) { // 获取锁
value = db.query(key);
redis.set(key, value, ttl);
redis.del("lock:" + key);
} else {
Thread.sleep(50);
return get(key); // 重试
}
}
应用场景总结
| 场景 | 数据结构 | 关键命令/方案 |
|---|---|---|
| 分布式缓存 | String | SET NX PX,缓存穿透用布隆过滤器 |
| 排行榜 | ZSet | ZADD/ZRANGE/ZREVRANK |
| 计数器/限流 | String | INCR,滑动窗口用 ZSet |
| 分布式锁 | String | SET NX PX + Lua 释放 |
| 消息队列 | Stream / List | Stream 支持消费确认 |
| 好友关系 | Set | SINTER(共同好友)/SUNION |
| 购物车 | Hash | HSET/HGETALL/HINCRBY |
| 延迟队列 | ZSet | score 为执行时间,定时 ZRANGEBYSCORE |
| 布隆过滤器 | BitMap / RedisBloom | BF.ADD/BF.EXISTS |
| 地理位置 | GEO | GEOADD/GEODIST/GEORADIUS |