06-Redis
第 6.1 节 Redis 基础
什么是 Redis?
原始问法:
- 什么是 Redis?
来源题目:
SRC-06-61-251
面试先答
Redis 是一款开源的、基于内存的键值对(Key-Value)存储系统,支持持久化、主从复制、哨兵和集群部署。它由 Salvatore Sanfilippo 于 2009 年开发并开源。Redis 的核心优势在于:纯内存操作带来的微秒级响应速度(单机 QPS 可达 10 万+)、丰富的数据结构支持(String、Hash、List、Set、ZSet 等)、以及单线程模型避免的上下文切换和锁竞争开销。它通常被用作数据库、缓存和消息中间件。
核心结论
- Redis 是内存键值数据库,支持持久化,速度极快。
- Redis 提供五种基本数据结构及 BitMap、HyperLogLog、Stream 等扩展类型。
- Redis 采用单线程处理命令执行(Redis 6 起 I/O 读写可多线程)。
1. 是什么
Redis(Remote Dictionary Server)是一个开源的内存数据结构存储系统,用 ANSI C 编写。它支持多种数据结构,可充当数据库、缓存和消息队列。Redis 将数据存储在内存中,通过定期持久化将数据写入磁盘以实现持久化存储。
核心特性:
- 基于内存操作,读写性能高(约 10 万 QPS)
- 支持持久化(RDB 快照、AOF 日志、混合持久化)
- 支持主从复制、哨兵模式、Cluster 集群
- 丰富的数据类型:String、Hash、List、Set、ZSet、Bitmap、HyperLogLog、Stream 等
- 单线程命令执行,避免锁竞争
- 发布/订阅、Lua 脚本、事务、分布式锁等高级功能
2. 为什么需要它
传统关系型数据库(如 MySQL)在面对高并发场景时存在瓶颈:磁盘 I/O 开销大、行锁竞争严重、连接数有限。当大量请求同时访问同一个热点数据时,数据库容易成为瓶颈。Redis 通过将热点数据存放在内存中,以微秒级的响应速度支撑高并发访问,大幅降低数据库压力。
3. 底层原理与完整流程
Redis 服务器启动后,默认监听 6379 端口。客户端通过 RESP(Redis Serialization Protocol)协议与服务器通信。
命令执行流程:
- 客户端发送命令(如
SET key value) - Redis 单线程接收并解析命令
- 执行对应的内存操作
- 返回结果给客户端
持久化流程:
RDB:通过fork子进程将内存数据写入.rdb文件AOF:将每个写命令追加写入.aof文件,重写时压缩
4. 怎么使用
# 启动 Redis
redis-server
# 客户端连接
redis-cli
# 基本命令
SET name "Alice" # 设置字符串
GET name # 获取字符串
HSET user:1 age 25 # 设置 Hash 字段
HGET user:1 age # 获取 Hash 字段
LPUSH tasks "task1" # List 左推入
RPOP tasks # List 右弹出
SADD tags "java" # Set 添加成员
ZADD rank 100 "alice" # ZSet 添加成员
Java 客户端示例(Jedis):
import redis.clients.jedis.Jedis;
Jedis jedis = new Jedis("127.0.0.1", 6379);
jedis.set("greeting", "Hello Redis");
String value = jedis.get("greeting");
jedis.close();
5. 适用场景
- 会话缓存:存储用户 Session、Token
- 排行榜:利用 ZSet 实现游戏积分榜
- 计数器:利用 INCR 命令实现文章阅读数
- 消息队列:利用 List 的阻塞弹出实现简易队列
- 分布式锁:利用 SETNX 实现分布式互斥
- 限流:利用计数器和时间窗口实现接口限流
6. 不适用场景与替代方案
- 数据量超过物理内存时不适合用 Redis 做唯一存储
- 强一致性场景(如金融交易)不能仅依赖 Redis,需要关系型数据库保证
- 需要复杂查询(多表关联、全文检索)时,应使用 MySQL/Elasticsearch
- 持久化可靠性要求极高时,应选择 AOF+RDB 混合持久化或使用其他方案
7. 优缺点与技术取舍
优点:
- 极致性能:内存操作,微秒级响应
- 丰富数据结构:适配多种业务场景
- 高可用:主从复制、哨兵、集群方案成熟
- 社区活跃:生态完善,客户端支持广泛
缺点:
- 内存成本:大规模数据需昂贵的内存硬件
- 数据可靠性:异步持久化存在丢数据风险
- 复杂查询弱:不支持 SQL 级别的查询能力
- 单机限制:受限于单机内存和 CPU
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 启动后无法连接 | 检查绑定地址 bind、端口 port、防火墙配置 |
| OOM 报错 | 调整 maxmemory 和淘汰策略 maxmemory-policy |
| 持久化文件损坏 | 使用 redis-check-rdb / redis-check-aof 修复 |
| 主从同步延迟 | 检查网络、调整 repl-timeout、考虑半同步复制 |
9. 版本差异与实现边界
| 版本 | 关键变化 |
|---|---|
| Redis 4.0 | 引入混合持久化(RDB+AOF) |
| Redis 5.0 | 新增 Stream 数据类型、主动客户端缓存 |
| Redis 6.0 | I/O 多线程、客户端缓存、ACL 访问控制 |
| Redis 7.0 | 函数调用引擎、Multi-part AOF、时间查询增强 |
10. 常见追问
- Q: Redis 和 Memcached 有什么区别? A: Redis 支持更多数据结构、持久化、主从复制和集群;Memcached 更简单纯粹,专注于简单的 KV 缓存,不支持持久化。
- Q: Redis 为什么不用多线程? A: 避免上下文切换开销和锁竞争。对于 Redis 这种内存操作密集型服务,单线程已能充分利用 CPU 性能。
- Q: Redis 的读写性能瓶颈在哪? A: 单机内存容量和网络带宽。集群模式下,瓶颈转移到分片数据量均衡和跨分片操作。
11. 易错点
- ❌ 错误:Redis 是"完全单线程"。✅ 正确:Redis 6 起 I/O 读写支持多线程,但命令执行仍是单线程。
- ❌ 错误:Redis 不能做持久化。✅ 正确:Redis 支持 RDB、AOF 和混合持久化。
- ❌ 错误:Redis 只能存字符串。✅ 正确:Redis 支持 String、Hash、List、Set、ZSet、Bitmap、HyperLogLog、Stream 等多种数据结构。
- ❌ 错误:Redis 是关系型数据库。✅ 正确:Redis 是键值对内存数据库,属于 NoSQL 范畴。
一句话总结
Redis 是一款高性能的内存键值数据库,通过单线程模型和丰富的数据结构,以微秒级响应支撑高并发场景,广泛用于缓存、队列、锁和计数等领域。
Redis 核心特点
原始问法:
- Redis 核心特点
来源题目:
SRC-06-61-252
面试先答
Redis 的核心特点可以概括为五点:① 基于内存操作,读写延迟微秒级,单机 QPS 超 10 万;② 丰富的数据结构,覆盖字符串、哈希、列表、集合、有序集合等多种业务场景;③ 单线程模型避免锁竞争和上下文切换,Redis 6 起 I/O 支持多线程进一步压榨性能;④ 支持 RDB、AOF、混合持久化三种持久化方式,可按需搭配;⑤ 高可用方案成熟,主从复制、哨兵自动故障转移、Cluster 分片集群一应俱全。
核心结论
- 高性能、丰富数据结构、单线程模型是 Redis 的三大核心竞争力。
- Redis 的持久化和高可用方案使其不仅能做缓存,也能做数据存储。
- Redis 6+ 的多线程 I/O 和 Redis 7 的函数引擎进一步增强了 Redis 的适用范围。
1. 是什么
Redis 的核心特点是其区别于其他数据库的标志性特征组合。
五大核心特点详解:
| 特点 | 说明 |
|---|---|
| 高性能 | 内存操作 + 单线程 = 微秒级延迟,10 万+ QPS |
| 丰富数据结构 | 5 种基础类型 + Bitmap/HyperLogLog/Stream 等扩展类型 |
| 单线程模型 | 命令执行单线程,避免锁竞争,Redis 6 起 I/O 可多线程 |
| 多种持久化 | RDB 快照、AOF 日志、混合持久化,灵活选择 |
| 高可用架构 | 主从复制、哨兵故障转移、Cluster 分片集群 |
2. 为什么需要它
在高并发系统中,核心矛盾是"数据库太慢、请求太多"。Redis 通过内存存储解决性能问题,通过丰富数据结构解决功能需求,通过持久化和高可用解决可靠性问题,成为现代系统中不可或缺的基础设施。
3. 底层原理与完整流程
高性能原理:
- 纯内存操作:数据存在内存中,避免磁盘 I/O
- 单线程执行:避免锁竞争和上下文切换开销
- I/O 多路复用(epoll):高效处理大量并发连接
- 高效数据结构:SDS、压缩列表、跳表等定制化结构
数据结构丰富性:
- String:SDS(Simple Dynamic String),兼容 C 字符串且支持二进制安全
- Hash:ziplist 或 hashtable,按元素数量自动切换
- List:quicklist(Redis 3.2+),结合 ziplist 和链表优势
- Set:intset 或 hashtable
- ZSet:ziplist 或跳表 + hashtable
4. 怎么使用
# Redis 配置示例(redis.conf)
bind 0.0.0.0
port 6379
# 开启 AOF 持久化
appendonly yes
appendfilename "appendonly.aof"
# 配置内存上限
maxmemory 4gb
# 配置淘汰策略
maxmemory-policy allkeys-lru
# I/O 多线程(Redis 6+)
io-threads 4
io-threads-do-reads yes
5. 适用场景
- 缓存热点数据、Session 存储
- 排行榜、计数器、限流
- 消息队列(List)、发布订阅
- 分布式锁、去重(Set)
- 实时统计(HyperLogLog)、位图操作
6. 不适用场景与替代方案
- 超大规模数据存储(成本高)→ 考虑 Cassandra、HBase
- 复杂查询/全文检索 → Elasticsearch
- 强事务一致性 → MySQL + ACID
- 超大规模消息堆积 → Kafka、RocketMQ
7. 优缺点与技术取舍
优点: 性能极高、功能丰富、生态成熟、社区活跃 缺点: 内存成本高、持久化有数据丢失风险、不适合复杂查询
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 内存不足 | 调整 maxmemory + 合理淘汰策略 |
| 持久化阻塞主线程 | 使用 AOF rewrite 或调整 RDB 策略 |
| 大 Key 影响性能 | 拆分大 Key 或使用 SCAN 渐进遍历 |
9. 版本差异与实现边界
| 版本 | 核心变化 |
|---|---|
| Redis 3.2 | List 底层改为 quicklist |
| Redis 4.0 | 引入混合持久化、新的内存淘汰策略 |
| Redis 5.0 | Stream 类型、主动客户端缓存 |
| Redis 6.0 | I/O 多线程、ACL、客户端缓存 |
| Redis 7.0 | Redis Functions、Multi-part AOF、RCF |
10. 常见追问
- Q: Redis 的 QPS 为什么能到 10 万+? A: 内存操作 + 单线程避免锁竞争 + I/O 多路复用,三者共同决定了高吞吐。
- Q: Redis 6 引入多线程后还是单线程吗? A: 命令执行仍是单线程,只是 I/O 读写(网络数据的读取和写入)使用多线程。
- Q: Redis 的数据结构是通用的吗? A: 不是。每种类型都有专门的底层实现,针对使用场景做了优化。
11. 易错点
- ❌ 错误:Redis 完全单线程。✅ 正确:命令执行单线程,I/O 读写可多线程。
- ❌ 错误:Redis 只能缓存。✅ 正确:Redis 可以做持久化存储、消息队列、分布式锁等。
- ❌ 错误:Redis 的数据结构只有五种。✅ 正确:基础类型五种,扩展类型包括 Bitmap、HyperLogLog、Stream、GEO 等。
一句话总结
Redis 以内存操作、单线程模型和丰富数据结构为核心,提供微秒级响应的高性能键值存储,配合持久化和高可用方案,是构建高并发系统的基石。
Redis 为什么速度这么快?
原始问法:
- Redis 为什么速度这么快?
来源题目:
SRC-06-61-253
面试先答
Redis 速度快的核心原因有五个:① 基于内存操作,数据存在内存中,读写延迟在微秒级,远快于磁盘 I/O;② 单线程模型执行命令,避免了多线程上下文切换和锁竞争的开销;③ 使用 I/O 多路复用(epoll/kqueue),单线程高效处理数万并发连接;④ 高效定制化数据结构(SDS、跳表、压缩列表等),针对不同场景做了深度优化;⑤ Redis 6 起 I/O 读写支持多线程,进一步压榨多核 CPU 性能。
核心结论
- 内存操作是 Redis 高性能的基础。
- 单线程避免锁竞争是关键设计决策。
- I/O 多路复用解决了高并发连接处理。
- Redis 6+ I/O 多线程进一步释放性能潜力。
1. 是什么
Redis 的高性能是多个因素共同作用的结果,而非单一原因。这些因素形成了一个协同的性能优化体系。
2. 为什么需要它
在互联网高并发场景下,每个请求可能产生数次数据库访问。数据库的磁盘 I/O 延迟(毫秒级)成为瓶颈。Redis 必须提供至少与网络传输速度相当的处理能力,才能作为有效的缓存层。
3. 底层原理与完整流程
原因一:基于内存操作
- 内存访问延迟约 0.1 微秒,磁盘随机 I/O 约 1-10 毫秒,差距 1-2 个数量级
- Redis 将热数据存储在内存中,读写直接操作内存地址
原因二:单线程执行命令
- 避免锁竞争:无需 synchronized、CAS 等同步机制
- 避免上下文切换:线程切换开销约 1-5 微秒/次
- 执行顺序可预测,简化调试
原因三:I/O 多路复用
- 使用 epoll(Linux)/ kqueue(BSD)/ evport(Solaris)
- 单线程监听多个 socket 就绪事件,统一处理
- 避免"每连接一线程"模型的高开销
原因四:高效数据结构
- SDS:动态字符串,二进制安全,预分配减少 realloc
- 跳表:O(log N) 查找,范围查询高效
- 压缩列表:紧凑内存布局,Cache 友好
- 哈希表:O(1) 平均查找
原因五:Redis 6+ I/O 多线程
- 读多线程:从 socket 读取数据到输入缓冲区
- 写多线程:从输出缓冲区写数据到 socket
- 命令执行仍然单线程,保证线程安全
4. 怎么使用
# 查看 Redis 实时性能指标
redis-cli INFO stats
redis-cli INFO commandstats
# 查看 I/O 多线程配置
redis-cli CONFIG GET io-threads
redis-cli CONFIG GET io-threads-do-reads
# 性能测试
redis-cli --benchmark -n 100000 -c 50 -d 1024
Java 客户端使用建议:
- 使用连接池(如 JedisPool、Lettuce 连接池)避免频繁建连
- Pipeline 批量命令减少网络往返
- 使用 Lua 脚本执行原子性操作
5. 适用场景
- 需要微秒级响应的热点数据缓存
- 高并发(万级 QPS)的 API 后端
- 实时排行榜、计数器、限流器
- 分布式系统中的协调组件(锁、信号量)
6. 不适用场景与替代方案
- 数据量远超物理内存 → 磁盘数据库
- 持久化要求零丢失 → 关系型数据库
- 复杂查询分析 → OLAP 系统
7. 优缺点与技术取舍
优点: 极致性能、低延迟、高吞吐 缺点: 内存成本高、受物理内存限制
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 单线程 CPU 打满 | 使用 Cluster 分片,分散压力 |
| 网络带宽瓶颈 | 本地缓存 + Redis 二级缓存 |
| 大 Key 阻塞 | 拆分大 Key、使用异步删除 UNLINK |
9. 版本差异与实现边界
| 版本 | 性能相关变化 |
|---|---|
| Redis 3.0 | 引入 Cluster 分片集群 |
| Redis 4.0 | 异步删除(UNLINK)、混合持久化 |
| Redis 6.0 | I/O 多线程、客户端缓存 |
| Redis 7.0 | 更快的 RDB 加载、函数引擎 |
10. 常见追问
- Q: 单线程一定比多线程快吗? A: 不一定。单线程在 I/O 密集型场景下因避免锁竞争而更快,但在 CPU 密集型场景下多线程利用多核更快。Redis 是 I/O 密集型。
- Q: Redis 6 多线程后,为什么命令还是单线程? A: 命令执行涉及复杂的数据结构操作(哈希表、跳表等),多线程需要加锁,反而降低性能。I/O 多线程只处理网络层,不涉及数据结构操作。
- Q: Redis 快过 Memcached 吗? A: 两者性能接近,Redis 功能更丰富,Memcached 更简单纯粹。
11. 易错点
- ❌ 错误:Redis 快是因为用了多线程。✅ 正确:Redis 快恰恰因为命令执行用单线程,避免了锁竞争。
- ❌ 错误:Redis 只靠内存就快。✅ 正确:内存是基础,但 I/O 多路复用和高效数据结构同样重要。
- ❌ 错误:Redis 7 已经完全多线程。✅ 正确:Redis 7 仍然是命令执行单线程,I/O 多线程。
一句话总结
Redis 通过内存操作、单线程模型、I/O 多路复用和高效数据结构四位一体的设计,实现了微秒级响应的极致性能。
Redis 是单线程还是多线程?为什么用单线程?
原始问法:
- Redis 是单线程还是多线程?为什么用单线程?
来源题目:
SRC-06-61-254
面试先答
Redis 采用"命令执行单线程 + I/O 读写可多线程"的混合模型。从 Redis 6.0 开始,网络 I/O 的读写部分引入了多线程,但命令执行核心逻辑仍然是单线程。Redis 选择单线程执行命令的核心原因有三:① 避免锁竞争——内存操作不加锁即可保证线程安全;② 避免上下文切换——线程切换的开销对 Redis 这类微秒级延迟的系统来说不可接受;③ 简化实现——单线程无需考虑并发数据结构,代码更简洁高效。Redis 的瓶颈不在于 CPU,而在于内存和网络,单线程模型已经足够支撑高并发。
核心结论
- Redis 命令执行是单线程的,I/O 读写在 Redis 6+ 支持多线程。
- 单线程的优势在于避免锁竞争和上下文切换。
- Redis 采用"单线程执行 + 多线程 I/O + 异步持久化"的混合架构。
1. 是什么
Redis 的线程模型经历了演变:
| 组件 | Redis 6.0 之前 | Redis 6.0+ |
|---|---|---|
| 命令执行 | 单线程 | 单线程(不变) |
| I/O 读(socket → buffer) | 单线程 | 多线程(可选) |
| I/O 写(buffer → socket) | 单线程 | 多线程(可选) |
| 持久化(RDB/AOF) | fork 子进程 | fork 子进程(不变) |
| 后台删除 | 同步 | 异步(UNLINK,Redis 4.0+) |
2. 为什么需要它
Redis 的核心工作模式是:接收命令 → 操作内存 → 返回结果。这个模式的特点是:
- I/O 密集型:等待网络数据的时间占主导
- 内存操作:CAS/synchronized 等锁机制会成为瓶颈
- 微秒级延迟要求:上下文切换(1-5 微秒)不可接受
多线程的引入场景是:多核 CPU 下,单 I/O 线程成为瓶颈。Redis 6 的多线程仅处理 I/O 层,命令执行逻辑不变。
3. 底层原理与完整流程
Redis 6+ 线程模型:
主线程(事件循环):
1. 接收客户端连接 → 注册可读事件
2. 分发 I/O 读任务到 I/O 线程池
3. 从输入缓冲区读取命令 → 命令队列
I/O 线程池(读线程):
1. 从 socket 读取数据到输入缓冲区
命令执行线程(主线程):
1. 从命令队列取出命令
2. 执行内存操作
3. 结果写入输出缓冲区
I/O 线程池(写线程):
1. 从输出缓冲区写数据到 socket
配置示例:
# 启用 I/O 多线程
io-threads 4 # I/O 线程数量,建议 = CPU 核数
io-threads-do-reads yes # 读操作也用多线程
重要约束: I/O 多线程仅在 Redis 6+ 且通过配置启用。默认情况下(io-threads=1),仍是单线程 I/O。
4. 怎么使用
# 检查当前线程模型
redis-cli CONFIG GET io-threads
# 输出: 1 表示单线程 I/O;>1 表示多线程 I/O
# 动态调整 I/O 线程数
redis-cli CONFIG SET io-threads 4
# 查看命令执行耗时(验证命令阻塞)
redis-cli SLOWLOG GET 10
Java 客户端注意事项:
- 单线程命令执行使得 Pipeline 和 Lua 脚本效果显著
- 不需要考虑命令级别的并发安全问题
- 批量操作用 Pipeline 或 MGET/MSET 减少 RTT
5. 适用场景
- 绝大多数 Redis 使用场景——命令执行都是单线程
- 高并发网络 I/O 场景——启用 I/O 多线程
- 不适用:CPU 密集型计算(应在应用层完成)
6. 不适用场景与替代方案
- 需要并行执行多个复杂操作 → 使用 Lua 脚本在单线程内原子执行
- 需要利用多核 CPU 进行计算密集型任务 → 在应用服务器处理,Redis 只存数据
- 超大 Key 操作阻塞主线程 → 使用异步删除 UNLINK 或拆分 Key
7. 优缺点与技术取舍
优点:
- 无锁设计,高性能
- 避免上下文切换
- 逻辑简单,可靠性高
缺点:
- 无法利用多核 CPU 加速命令执行
- 单个慢命令会阻塞所有后续命令
- I/O 多线程引入了少量同步开销
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 慢命令阻塞 | 使用 SLOWLOG 监控,优化命令,避免 KEYS * |
| 大 Key 阻塞 | 使用 UNLINK 异步删除,SCAN 渐进遍历 |
| I/O 瓶颈 | 启用 io-threads,增加 I/O 线程数 |
| 持久化阻塞 | 使用子进程 fork,或调整持久化策略 |
9. 版本差异与实现边界
| 版本 | 线程模型变化 |
|---|---|
| Redis 2.x | 完全单线程(I/O + 命令执行) |
| Redis 3.x | 后台线程做 AOF fsync |
| Redis 4.0 | 引入 UNLINK 异步删除 |
| Redis 6.0 | I/O 读写支持多线程,命令执行仍单线程 |
| Redis 7.0 | Multi-part AOF 进一步优化 |
关键边界: 无论哪个版本,命令执行核心逻辑始终是单线程的。
10. 常见追问
- Q: 为什么不用多线程执行命令? A: 命令执行涉及复杂数据结构操作(跳表、哈希表等),多线程需要锁保护,反而降低性能。对于 I/O 密集型的 Redis,单线程命令执行 + 多线程 I/O 是最优解。
- Q: Redis 6 多线程后,数据结构操作需要加锁吗? A: 命令执行仍在单线程中,所以不需要加锁。多线程只处理 I/O 层(读/写 socket),不涉及数据结构操作。
- Q: 单线程 Redis 能用到多核吗? A: 可以通过两种方式:① I/O 多线程利用多核处理网络读写;② 部署多个 Redis 实例(Cluster 分片),每个实例利用一个核。
11. 易错点
- ❌ 错误:Redis 是完全单线程的。✅ 正确:Redis 6+ 支持 I/O 多线程。
- ❌ 错误:Redis 6 多线程后命令并行执行。✅ 正确:命令执行仍是单线程。
- ❌ 错误:Redis 单线程会导致 CPU 利用率低。✅ 正确:Redis 是 I/O 密集型,瓶颈在内存和网络,单线程已足够高效。
- ❌ 错误:开了 I/O 多线程就一定更快。✅ 正确:低并发下单线程更高效,多线程只在高并发 I/O 下有优势。
一句话总结
Redis 采用命令执行单线程、I/O 读写多线程的混合模型,以最小化锁竞争和上下文切换开销。
CPU 是单线程的吗?(高频连环问)
原始问法:
- CPU 是单线程的吗?(高频连环问)
来源题目:
SRC-06-61-255
面试先答
CPU 本身既不是单线程也不是多线程——CPU 是硬件,线程是操作系统的调度单位。现代 CPU 都是多核的,每核可以同时执行一个线程(超线程技术可执行两个)。一个 8 核 CPU 理论上可以同时运行 8-16 个线程。Redis 选择单线程执行命令,是因为在单线程内已经能把一个核跑满(Redis 是内存操作密集型,不占太多 CPU),多线程反而会引入锁竞争。这个问题常被面试官连环追问,考察对 Redis 线程模型的深入理解。
核心结论
- CPU 是多核的,不是"单线程"的。
- 线程是 OS 调度的基本单位,与 CPU 核的关系是"一对多"或"一对一"。
- Redis 选择单线程执行命令是性能优化的结果,不是硬件限制。
1. 是什么
CPU、进程、线程的关系:
CPU(多核)
├── Core 0
│ └── 可运行 1-2 个线程(超线程)
├── Core 1
│ └── 可运行 1-2 个线程
├── ...
└── Core N
└── 可运行 1-2 个线程
操作系统调度:
- 进程 ← 资源分配基本单位
- 线程 ← CPU 调度基本单位
- 一个进程内可包含多个线程
超线程(Hyper-Threading): 一个物理核心通过硬件支持同时执行两个线程的指令,提升流水线利用率。
2. 为什么需要它
理解 CPU 与线程的关系,有助于理解 Redis 为何选择单线程模型:
- Redis 命令执行是内存操作(哈希表、跳表等),单线程已能高效完成
- 多线程引入锁竞争,对于微秒级延迟的 Redis 来说不可接受
- Redis 6+ 引入 I/O 多线程,正是利用多核处理 I/O 瓶颈
3. 底层原理与完整流程
从硬件到软件的层级:
- CPU 硬件层:多核,每核 ALU/FPU/Cache,支持超线程
- 操作系统层:将线程映射到 CPU 核心,上下文切换(Context Switch)约 1-5 微秒
- 应用层:
- 单线程模型:一个线程在一个核上执行,无锁但无法利用多核
- 多线程模型:多个线程在多个核上执行,需要锁保护共享资源
Redis 的选择:
- 命令执行:单线程(避免锁竞争,一个核已跑满)
- I/O 读写:多线程(Redis 6+,利用多核处理网络 I/O)
- 持久化:fork 子进程(利用额外核)
4. 怎么使用
# 查看 CPU 核数(Linux)
nproc
# 或
lscpu | grep "^CPU(s)"
# 查看 Redis 线程数
redis-cli INFO server | grep thread
# 查看 Redis 各线程 CPU 占用
top -H -p $(pgrep redis-server)
5. 适用场景
- 理解 Redis 线程模型的基础
- 性能调优时判断 I/O 是否为瓶颈
- 评估是否需要启用 I/O 多线程
6. 不适用场景与替代方案
- 不要把 CPU 核数等同于线程数
- 不要简单认为"多线程一定更快"
7. 优缺点与技术取舍
对 Redis 来说:
- 单线程命令执行 → 极致延迟,但无法利用多核运算
- 多线程 I/O → 利用多核处理网络,但需少量同步
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| Redis CPU 利用率低 | 检查是否内存/网络瓶颈,考虑 Cluster 分片 |
| 上下文切换频繁 | 减少线程数,检查 I/O 模型 |
9. 版本差异与实现边界
| 版本 | CPU 利用方式 |
|---|---|
| Redis 6 之前 | 单线程,用 1-2 个核 |
| Redis 6+ | I/O 多线程,命令仍单线程 |
| Redis Cluster | 每个分片一个实例,可利用多核 |
10. 常见追问
- Q: Redis 单线程是不是浪费了多核 CPU? A: 不浪费。Redis 是 I/O 密集型,单核已能达到 10 万+ QPS。多核浪费在锁竞争上不如单核高效。如需多核,用 Cluster 分片。
- Q: 什么是上下文切换? A: CPU 从执行一个线程切换到执行另一个线程,涉及保存/恢复寄存器状态、缓存失效,开销约 1-5 微秒。
- Q: 超线程是什么?有用吗? A: 一个物理核心同时执行两个线程,提升流水线利用率。对计算密集型任务有效,对 I/O 密集型任务效果有限。
11. 易错点
- ❌ 错误:CPU 有几个核就是几线程。✅ 正确:核是硬件,线程是 OS 调度单位,关系可变。
- ❌ 错误:Redis 单线程是因为 CPU 只有一个核。✅ 正确:是设计选择,多核 CPU 上单线程效率更高。
- ❌ 错误:多线程一定比单线程快。✅ 正确:取决于场景,I/O 密集型单线程可能更快。
一句话总结
CPU 是多核硬件,线程是 OS 调度单位;Redis 选择单线程执行命令是因为避免锁竞争比利用多核更重要。
Redis 核心应用场景
原始问法:
- Redis 核心应用场景
来源题目:
SRC-06-61-256
面试先答
Redis 的核心应用场景可归纳为六大类:① 缓存——热点数据缓存、Session 共享、降低数据库压力;② 计数器与限流——文章阅读量、接口 QPS 限制、排行榜积分;③ 消息队列——基于 List 的阻塞队列实现异步解耦;④ 分布式锁——基于 SETNX 实现分布式互斥操作;⑤ 去重与推荐——基于 Set 的唯一 ID 去重、好友关系推荐;⑥ 实时统计——HyperLogLog 做 UV 统计、Bitmap 做签到记录。这些场景充分利用了 Redis 高性能、丰富数据结构和单线程原子性的特点。
核心结论
- Redis 最核心的应用是缓存,其次是计数器、队列和分布式协调。
- 不同业务场景应选择不同的数据类型。
- Redis 的应用可分为"性能增强型"和"功能实现型"两大类。
1. 是什么
Redis 的应用场景与其数据结构和核心特性紧密相关。
| 场景 | Redis 特性 | 推荐数据类型 |
|---|---|---|
| 缓存 | 高性能内存读写 | String、Hash |
| 计数器 | INCR 原子操作 | String(计数器) |
| 限流 | 原子计数 + 时间窗口 | String + ZSet |
| 消息队列 | List 阻塞操作 | List |
| 分布式锁 | SETNX + 过期 | String |
| 排行榜 | ZSet 有序集合 | ZSet |
| 去重 | Set 交集/差集 | Set |
| 签到 | Bitmap 位操作 | Bitmap |
| UV 统计 | HyperLogLog | HyperLogLog |
| 实时推送 | Pub/Sub、Stream | Pub/Sub、Stream |
2. 为什么需要它
在没有 Redis 的情况下,上述功能需要:
- 缓存 → 数据库缓存或本地缓存(无法跨进程共享)
- 计数器 → 数据库 UPDATE(行锁竞争严重)
- 消息队列 → 需要专门的消息中间件(MQ)
- 分布式锁 → 需要 ZooKeeper 等协调服务
- 去重统计 → 数据库 COUNT/DISTINCT(性能差)
Redis 一个系统覆盖上述所有场景,大幅简化技术栈。
3. 底层原理与完整流程
缓存场景流程:
- 应用查询 Redis → 命中则返回(Cache Hit)
- 未命中则查询数据库(Cache Miss)
- 将数据库结果写入 Redis,设置过期时间
- 后续请求直接命中 Redis
消息队列流程:
- 生产者
LPUSH消息到 List - 消费者
BRPOP阻塞等待消息 - 消费者处理完消息,消息从 List 移除
分布式锁流程:
SETNX lock_key unique_value加锁- 执行业务逻辑
DEL lock_key解锁(需 Lua 保证原子性)
4. 怎么使用
场景一:文章阅读计数
// 原子自增
Long views = jedis.incr("article:views:" + articleId);
// 设置初始过期时间(首次计数时)
if (views == 1) {
jedis.expire("article:views:" + articleId, 3600);
}
场景二:排行榜
// 添加玩家分数
jedis.zadd("game:rank", 100, "player:1");
jedis.zadd("game:rank", 95, "player:2");
// 获取 Top 10
Set<String> top10 = jedis.zrevrange("game:rank", 0, 9);
// 获取玩家排名
Long rank = jedis.zrevrank("game:rank", "player:1");
场景三:消息队列
// 生产者
jedis.lpush("queue:orders", orderJson);
// 消费者(阻塞等待)
List<String> result = jedis.brpop(0, "queue:orders");
场景四:接口限流(滑动窗口)
// Lua 脚本实现滑动窗口
String script =
"local key = KEYS[1] " +
"local now = tonumber(ARGV[1]) " +
"local window = tonumber(ARGV[2]) " +
"local limit = tonumber(ARGV[3]) " +
"redis.call('ZREMRANGEBYSCORE', key, 0, now - window) " +
"local count = redis.call('ZCARD', key) " +
"if count < limit then " +
" redis.call('ZADD', key, now, now .. '-' .. math.random()) " +
" redis.call('EXPIRE', key, window / 1000) " +
" return 1 " +
"else return 0 end";
5. 适用场景
- 读多写少的热点数据
- 需要原子计数的统计场景
- 异步解耦的消息传递
- 分布式系统中的协调逻辑
- 实时性要求高的交互场景
6. 不适用场景与替代方案
| 场景 | 不合适原因 | 替代方案 |
|---|---|---|
| 大数据量长期存储 | 内存成本高 | MySQL/PostgreSQL |
| 强事务一致性 | 不支持 ACID | 关系型数据库 |
| 复杂多条件查询 | 无 SQL 支持 | Elasticsearch |
| 超大规模消息堆积 | 内存限制 | Kafka/RocketMQ |
7. 优缺点与技术取舍
优点: 一个系统覆盖多种场景,技术栈简单,高性能 缺点: 每种场景都有更专业的替代方案,Redis 是"万金油"而非"专家"
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 缓存穿透 | 布隆过滤器 + 空值缓存 |
| 缓存击穿 | 互斥锁 + 永不过期 |
| 缓存雪崩 | 随机过期时间 + 多级缓存 |
| 消息丢失 | ACK 机制 + 可靠消费 |
9. 版本差异与实现边界
| 版本 | 新增场景支持 |
|---|---|
| Redis 5.0 | Stream 类型 → 更可靠的消息队列 |
| Redis 6.0 | 客户端缓存 → 减少网络请求 |
| Redis 7.0 | Redis Functions → 服务端自定义逻辑 |
10. 常见追问
- Q: Redis 做队列比专业 MQ 好吗? A: 简单场景下 Redis 足够用,复杂场景(消息堆积、可靠投递、顺序保证)应使用 Kafka/RocketMQ。
- Q: Redis 做缓存如何保证一致性? A: 通常遵循 Cache-Aside Pattern:读时先读缓存、缓存未命中读数据库后回填;写时先写数据库再删缓存(或更新缓存)。
- Q: Redis 能做数据库吗? A: 可以。开启 AOF 持久化后,Redis 可以作为数据库使用,但不适合大规模数据和复杂查询。
11. 易错点
- ❌ 错误:Redis 只能做缓存。✅ 正确:Redis 可以做队列、锁、计数器、发布订阅等。
- ❌ 错误:Redis 做的消息队列可靠。✅ 正确:基于 List 的队列存在消息丢失风险,Stream 更可靠。
- ❌ 错误:Redis 的 Pub/Sub 可以持久化消息。✅ 正确:Pub/Sub 不持久化,订阅者离线时消息丢失。
一句话总结
Redis 凭借丰富的数据结构和高性能,在缓存、计数、队列、锁、统计等场景中作为通用基础设施,大幅简化后端系统架构。
Redis 五大数据类型及底层实现总览
原始问法:
- Redis 五大数据类型及底层实现总览
来源题目:
SRC-06-61-257
面试先答
Redis 的五大数据类型是 String、Hash、List、Set、ZSet,每种类型都有自适应的底层编码实现:String 基于 SDS 动态字符串;Hash 元素少时用 ziplist,多时用 hashtable;List 在 Redis 3.2+ 使用 quicklist(ziplist+链表);Set 整数集合用 intset,普通集合用 hashtable;ZSet 元素少用 ziplist,多用 skiplist+hashtable。Redis 根据元素数量和内容自动选择最优编码,节省内存、保证性能。
核心结论
- 每种数据类型都有两种以上底层编码,根据数据特征自动切换。
- 自适应编码的目的是在不同场景下平衡内存效率和操作性能。
- 所有底层编码对用户透明,无需手动指定。
1. 是什么
五大数据类型总览:
| 类型 | 底层编码 1 | 底层编码 2 | 底层编码 3 | 编码切换条件 |
|---|---|---|---|---|
| String | int(整数值) | embstr(≤44字节) | raw(>44字节) | 根据值长度和类型 |
| Hash | ziplist(≤128元素,值≤64字节) | hashtable | — | 元素数量和值长度 |
| List | ziplist(≤128元素,值≤64字节) | quicklist | — | 元素数量 |
| Set | intset(全是整数且≤512元素) | hashtable | — | 元素内容和数量 |
| ZSet | ziplist(≤128元素,值≤64字节) | skiplist+hashtable | — | 元素数量和值长度 |
2. 为什么需要它
自适应编码的核心矛盾是:
- 小数据量:紧凑编码节省内存,操作简单
- 大数据量:高效数据结构保证操作性能
Redis 通过在不同阈值自动切换编码,实现了内存效率和操作性能的最优平衡。
3. 底层原理与完整流程
String 编码切换:
值为整数且在 long 范围内 → int 编码(直接存 long 值)
↓
值为 ≤ 44 字节的字符串 → embstr 编码(embedded string,SDS header + 数据连续存储)
↓
值为 > 44 字节的字符串 → raw 编码(SDS 在堆上分配)
Hash 编码切换:
元素 ≤ 128 且所有值 ≤ 64 字节 → ziplist(紧凑双向链表)
↓ 超过阈值
→ hashtable(两个哈希表,渐进式 rehash)
List 编码切换:
元素 ≤ 128 且所有值 ≤ 64 字节 → ziplist(Redis 3.2+ 用 quicklist)
↓ 超过阈值
→ quicklist(链表 + ziplist 节点)
Set 编码切换:
全为整数且 ≤ 512 元素 → intset(有序整数数组,二分查找)
↓ 超过阈值或非整数
→ hashtable
ZSet 编码切换:
元素 ≤ 128 且所有值 ≤ 64 字节 → ziplist
↓ 超过阈值
→ skiplist + hashtable(跳表用于范围查询,哈希表用于 O(1) 查找)
4. 怎么使用
# 查看 Key 的编码类型
redis-cli OBJECT ENCODING key_name
# 示例
SET int_key 123 # 返回: "int"
SET str_key "hello" # 返回: "embstr"
SET long_key "very long string..." # 返回: "raw"
# 查看 Hash 编码
HSET small_hash f1 v1 # 返回: "ziplist"(元素少时)
Java 操作示例:
// 查看对象编码
String encoding = jedis.objectEncoding("myKey");
System.out.println(encoding); // "int", "embstr", "raw", "ziplist", "hashtable" 等
5. 适用场景
- String:缓存、计数器、Session、分布式锁
- Hash:对象存储(用户信息、商品属性)
- List:消息队列、最新列表、时间线
- Set:去重、交集运算(共同好友)、随机推荐
- ZSet:排行榜、优先级队列、延迟处理
6. 不适用场景与替代方案
| 类型 | 不适用场景 | 替代方案 |
|---|---|---|
| String | 复杂结构数据 | Hash/JSON 序列化 |
| Hash | 大量嵌套结构 | 文档数据库(MongoDB) |
| List | 需要随机访问 | Sorted Set 或数组 |
| Set | 需要重复元素 | List 或 Bag 数据结构 |
| ZSet | 超大数据量排序 | 专业排序系统 |
7. 优缺点与技术取舍
自适应编码的优点:
- 内存效率高:小数据用紧凑编码,节省 50%+ 内存
- 性能透明:大数据自动切换到高效结构
- 无需手动管理:对开发者透明
自适应编码的缺点:
- 编码切换有性能开销(一次性,可接受)
- 阈值调整需要重新编译(或修改配置)
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| Hash 编码频繁切换 | 调整 hash-max-ziplist-entries 阈值 |
| 内存占用异常 | 使用 MEMORY USAGE 分析大 Key |
| 编码无法回退 | 编码只升不降,需重建 Key |
9. 版本差异与实现边界
| 版本 | 变化 |
|---|---|
| Redis 3.0 | List 底层从 ziplist 改为 quicklist |
| Redis 4.0 | 引入新的哈希表 rehash 策略 |
| Redis 7.0 | ziplist 被新的 listpack 完全替代 |
关键参数(redis.conf):
hash-max-listpack-entries 128 # Hash 元素阈值(Redis 7+)
hash-max-listpack-value 64 # Hash 值长度阈值
list-max-listpack-size -2 # List 节点大小阈值
set-max-intset-entries 512 # Set 整数集合阈值
zset-max-listpack-entries 128 # ZSet 元素阈值
zset-max-listpack-value 64 # ZSet 值长度阈值
10. 常见追问
- Q: 为什么需要自适应编码? A: 小数据用紧凑结构节省内存,大数据用高效结构保证性能,自动切换让两者兼得。
- Q: 编码能手动指定吗? A: 不能直接指定,但可通过配置阈值间接控制。编码只升不降,是不可逆的。
- Q: ZSet 为什么用跳表而不是红黑树? A: 跳表实现更简单,范围查询效率相同,且支持并发修改(虽然 Redis 不用)。
11. 易错点
- ❌ 错误:Redis 五大数据类型的底层编码是固定的。✅ 正确:每种类型有多种编码,根据数据特征自动切换。
- ❌ 错误:embstr 和 raw 是同一种编码。✅ 正确:embstr 是嵌入式字符串,SDS 和数据连续存储;raw 是独立分配的。
- ❌ 错误:Hash 和 ZSet 的编码阈值相同。✅ 正确:可以通过配置分别调整。
- ❌ 错误:编码可以手动切换。✅ 正确:编码是自动选择的,不支持手动切换。
一句话总结
Redis 五大数据类型通过自适应的底层编码(ziplist/hashtable/skiplist/intset/listpack),在不同数据规模下自动平衡内存效率和操作性能。
String 底层实现 & SDS 详解
原始问法:
- String 底层实现 & SDS 详解
来源题目:
SRC-06-61-258
面试先答
Redis 的 String 类型底层使用 SDS(Simple Dynamic String)实现,这是一种类似 Java ArrayList 的动态字符串结构。SDS 比 C 字符串更安全(二进制安全、自动扩容)、更高效(O(1) 获取长度、减少 realloc 次数)、更兼容(可直接复用 C 字符串函数)。SDS 有三种编码:int(整数值)、embstr(≤44 字节嵌入式)、raw(>44 字节堆分配),Redis 根据值的类型和长度自动选择最优编码。
核心结论
- SDS 是 Redis 对 C 字符串的安全增强封装。
- SDS 支持二进制安全、O(1) 长度获取、自动扩容。
- String 有 int/embstr/raw 三种编码,自动切换。
1. 是什么
SDS(Simple Dynamic String)是 Redis 自定义的字符串结构,替代 C 原生 char* 以解决安全性和效率问题。
SDS 结构定义(简化版):
typedef struct {
int len; // 字符串长度
int free; // 已分配但未使用的空间
char buf[]; // 实际存储数据的柔性数组
} sdshdr;
与 C 字符串的对比:
| 特性 | C 字符串(char*) | SDS |
|---|---|---|
| 获取长度 | O(N) 遍历 | O(1) 直接读 len |
| 二进制安全 | 遇到 '\0' 截断 | 按 len 读取,支持二进制 |
| 扩容 | 手动 realloc,可能多次 | 自动预分配,最多 realloc |
| 缓冲区溢出 | 无保护 | 自动检查并扩容 |
| 空间占用 | 1 字节/字符 | len + free + buf |
2. 为什么需要它
C 原生字符串(char*)存在以下问题:
- O(N) 获取长度:遍历整个字符串计算
strlen - 二进制不安全:遇到
\0截断,无法存储图片、序列化数据等 - 缓冲区溢出:
strcat等函数可能溢出 - 频繁 realloc:每次修改都可能重新分配内存
SDS 解决了所有这些问题,是 Redis 高性能和安全性的基础。
3. 底层原理与完整流程
SDS 内存布局:
embstr 编码(len ≤ 44 字节):
┌──────────┬──────────┬──────────────────────┐
│ sdshdr │ len │ buf[] (数据) │
└──────────┴──────────┴──────────────────────┘
连续存储,一次 malloc
raw 编码(len > 44 字节):
┌──────────┬──────────┬──────────────────────┐
│ sdshdr* │ len │ buf[] (数据) │
└──────────┴──────────┴──────────────────────┘
sdshdr 和 buf 分离,两次 malloc
编码切换规则:
1. 值为整数(long 范围内) → int 编码
例: SET num 123 → int 编码,直接存 long
2. 值长度 ≤ 44 字节 → embstr 编码
例: SET name "Alice" → embstr 编码
3. 值长度 > 44 字节 → raw 编码
例: SET desc "A very long..." → raw 编码
4. 对 embstr 执行修改操作 → 自动转 raw 编码
例: APPEND name "extra" → raw 编码
SDS 扩容策略:
修改后所需长度 < 预分配空间 → 不 realloc
修改后所需长度 < 1MB → 扩容为所需长度 × 2(预分配翻倍)
修改后所需长度 ≥ 1MB → 扩容为所需长度 + 1MB(预分配 1MB)
4. 怎么使用
# 设置字符串
SET key "value"
SET counter 100
# 整数自增(原子操作)
INCR counter # → 101
INCRBY counter 10 # → 111
DECR counter # → 110
# 字符串操作
APPEND key "_suffix" # 追加
GETRANGE key 0 4 # 获取子串
SETRANGE key 5 "world" # 替换子串
STRLEN key # 获取长度
# 查看编码
OBJECT ENCODING key
Java 操作:
// 使用 Jedis
jedis.set("greeting", "Hello Redis");
jedis.incr("counter");
String encoding = jedis.objectEncoding("greeting"); // "embstr"
// Pipeline 批量操作
Pipeline pipe = jedis.pipelined();
pipe.set("k1", "v1");
pipe.set("k2", "v2");
pipe.sync();
5. 适用场景
- 缓存值存储:任何可序列化的数据
- 计数器:利用 INCR 原子操作实现计数
- 分布式锁:SET key value NX PX ttl
- Session 存储:二进制安全支持序列化对象
- 位图操作:基于字符串的位操作 Bitmap
6. 不适用场景与替代方案
- 复杂结构数据 → 使用 Hash 或 JSON
- 大量重复读相同数据 → 本地缓存(Caffeine、Guava Cache)
- 超大字符串 → 拆分存储或使用文件存储
7. 优缺点与技术取舍
SDS 优点:
- O(1) 获取长度:常数时间
- 二进制安全:支持任意数据
- 自动扩容:减少 realloc 次数
- 兼容 C 字符串:可直接使用
printf等函数
SDS 缺点:
- 比 C 字符串多占
len + free两个 int 的空间 - 预分配可能浪费内存
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 大 Key(如 10MB 字符串) | 拆分存储或使用分片策略 |
| embstr 修改后变 raw | 对频繁修改的 Key 可预分配长度 |
| 内存碎片 | 开启 activedefrag 或使用 jemalloc |
9. 版本差异与实现边界
| 版本 | 变化 |
|---|---|
| Redis 3.2 | embstr 阈值从 39 字节改为 44 字节 |
| Redis 4.0 | 引入新的内存分配器 jemalloc |
| Redis 7.0 | 优化 SDS 头部结构,减少内存占用 |
编码大小限制:
- int:
long类型范围(约 ±9.2 × 10^18) - embstr:≤ 44 字节
- raw:受
maxmemory限制
10. 常见追问
- Q: SDS 和 Java String 的区别? A: SDS 是可变字符串,支持修改;Java String 不可变。SDS 直接暴露缓冲区,Java String 封装更好。
- Q: 为什么 Redis String 支持二进制安全? A: SDS 通过
len字段明确存储长度,而不是依赖\0结尾。所以可以存储图片、序列化对象等二进制数据。 - Q: APPEND 一个 embstr 会怎样? A: embstr 是只读的,APPEND 会触发编码转换为 raw,然后追加。这是不可逆的。
11. 易错点
- ❌ 错误:SDS 就是 C 字符串。✅ 正确:SDS 是 Redis 自定义的,兼容 C 字符串但更安全高效。
- ❌ 错误:embstr 可以直接修改。✅ 正确:embstr 是只读的,修改时自动转 raw。
- ❌ 错误:Redis String 只能存字符串。✅ 正确:可以存整数(int 编码)、二进制数据(二进制安全)。
- ❌ 错误:SDS 的 len 在修改后需要手动更新。✅ 正确:SDS 内部自动维护 len。
一句话总结
Redis String 基于 SDS 动态字符串实现,通过 O(1) 长度获取、二进制安全和自动扩容,提供比 C 字符串更安全高效的字符串操作。
Hash 底层实现 & 渐进式 rehash
原始问法:
- Hash 底层实现 & 渐进式 rehash
来源题目:
SRC-06-61-259
面试先答
Redis 的 Hash 类型在底层根据元素数量自动选择编码:小数据量用 ziplist(Redis 7+ 用 listpack),紧凑存储节省内存;大数据量用 hashtable,保证 O(1) 查找性能。当 hashtable 需要扩容时,Redis 采用渐进式 rehash 机制——不一次性迁移所有元素,而是在每次读写操作中逐步迁移,避免阻塞主线程。渐进式 rehash 期间使用两个哈希表,新元素始终写入新表,查询时先查新表再查旧表。
核心结论
- Hash 底层采用 ziplist/listpack(小数据)和 hashtable(大数据)双编码。
- 渐进式 rehash 避免了扩容时的阻塞问题。
- 渐进式 rehash 用两个哈希表,分批次迁移元素。
1. 是什么
Redis Hash 是键值对集合,类似 Java Map。底层根据元素数量选择编码。
Hash 结构示例:
HSET user:1 name "Alice" age 25 gender "F"
→ Key: user:1
Value: {name: "Alice", age: "25", gender: "F"}
两种底层编码:
| 编码 | 条件 | 特点 |
|---|---|---|
| ziplist/listpack | 元素 ≤ 128 且值 ≤ 64 字节 | 紧凑内存,按 key-value 顺序存储 |
| hashtable | 超过阈值 | O(1) 查找,支持扩容 |
2. 为什么需要它
小数据量场景:紧凑编码减少内存占用。Redis 中大量 Hash 是小对象(如用户信息、商品属性),用 ziplist 可节省 50%+ 内存。
大数据量场景:哈希表保证 O(1) 操作性能。
渐进式 rehash:一次性 rehash 大哈希表会阻塞主线程数百毫秒,渐进式 rehash 将迁移分散到多次操作中,单次耗时可忽略。
3. 底层原理与完整流程
ziplist 结构(简化):
┌──────┬──────┬──────┬──────┬──────┬──────┐
│key1 │val1 │key2 │val2 │... │Zlen │
└──────┴──────┴──────┴──────┴──────┴──────┘
按 key-value 对顺序存储,双向遍历
查找需遍历,O(N) 但 N ≤ 128,很快
hashtable 结构:
rehash 前:
table[0] → [bucket0] → entry1 → entry2 → NULL
table[1] → [bucket1] → NULL
...
table[N] → [bucketN] → entry3 → NULL
rehash 中(两个表):
old_table: → 正在迁移的旧表
new_table: → 接收新元素的新表
rehash_idx: 当前迁移进度
渐进式 rehash 流程:
- 触发扩容/缩容条件(负载因子 > 1 且正在做
HSET) - 分配新哈希表,大小为旧表的 2 倍(扩容)
- 设置
rehash_idx = 0 - 每次执行
HSET/HGET/HDEL等操作时:- 将
old_table[rehash_idx]及其链表迁移到new_table rehash_idx++
- 将
- 当
rehash_idx == old_table.length时,rehash 完成 - 释放
old_table,new_table成为主表
rehash 触发条件:
- 扩容:负载因子 > 1 且正在执行
HSET - 缩容:负载因子 < 0.1 且正在执行
HDEL
负载因子 = 已用元素数量 / 哈希表长度
4. 怎么使用
# Hash 基本操作
HSET user:1 name "Alice" age 25 # 设置字段
HGET user:1 name # 获取字段
HGETALL user:1 # 获取所有字段
HMSET user:2 name "Bob" age 30 # 批量设置
HDEL user:1 age # 删除字段
HLEN user:1 # 获取字段数
HEXISTS user:1 name # 判断字段是否存在
# 查看编码
OBJECT ENCODING user:1 # "ziplist" 或 "hashtable"
# 扫描 Hash 字段(避免阻塞)
HSCAN user:1 0 COUNT 100
Java 操作:
Map<String, String> userMap = new HashMap<>();
userMap.put("name", "Alice");
userMap.put("age", "25");
jedis.hset("user:1", userMap);
String name = jedis.hget("user:1", "name");
Map<String, String> result = jedis.hgetAll("user:1");
5. 适用场景
- 对象存储:用户信息、商品属性、配置项
- 计数器组:多个相关计数器聚合存储
- 索引:二级索引、倒排索引
- 会话存储:Session 属性分组
6. 不适用场景与替代方案
- 需要单字段大量操作 → 拆分为多个 String Key
- 大数据量(>128 字段)→ 考虑 JSON 序列化存储为 String
- 需要复杂查询 → 关系型数据库或文档数据库
7. 优缺点与技术取舍
ziplist 优点: 内存紧凑,Cache 友好,适合小数据 ziplist 缺点: O(N) 查找,插入需要移动元素 hashtable 优点: O(1) 平均查找,支持扩容 hashtable 缺点: 内存占用多,rehash 有短暂双表占用
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| Hash 频繁在编码间切换 | 调整 hash-max-listpack-entries 阈值 |
| 渐进式 rehash 期间内存翻倍 | 监控内存,避免同时 rehash 多个大 Key |
| 超大 Hash 阻塞 | 使用 HSCAN 渐进遍历,避免 HGETALL |
9. 版本差异与实现边界
| 版本 | 变化 |
|---|---|
| Redis 3.2 | ziplist 最大元素数可配置 |
| Redis 4.0 | 优化 rehash 策略 |
| Redis 7.0 | listpack 替代 ziplist,更高效 |
配置参数(redis.conf):
hash-max-listpack-entries 128 # 最大元素数
hash-max-listpack-value 64 # 单值最大字节数
10. 常见追问
- Q: 为什么用渐进式 rehash 而不是一次性? A: 一次性 rehash 大哈希表会阻塞主线程数百毫秒,渐进式 rehash 分散到多次操作中,单次耗时可忽略。
- Q: 渐进式 rehash 期间查询走哪个表? A: 先查新表,再查旧表。新元素只写新表,确保不丢失。
- Q: rehash 期间删除旧表元素怎么办? A: 正常处理。如果该元素已被 rehash,直接从新表删除;如果还没被 rehash,从旧表删除。
11. 易错点
- ❌ 错误:渐进式 rehash 是一次性完成的。✅ 正确:渐进式 rehash 分多次操作完成,每次迁移一部分。
- ❌ 错误:Hash 的编码可以手动指定。✅ 正确:编码是自动选择的,由元素数量和值长度决定。
- ❌ 错误:rehash 期间新旧表都要查。✅ 正确:查询时先查新表再查旧表,因为新表包含已迁移和新插入的元素。
- ❌ 错误:Hash 只能存储小对象。✅ 正确:Hash 可以存储任意大小的对象,超过阈值后自动切换为 hashtable。
一句话总结
Redis Hash 通过 ziplist/listpack 紧凑存储小数据、hashtable 高效存储大数据,配合渐进式 rehash 实现无缝扩容,在内存效率和操作性能之间取得最优平衡。
List / Set / ZSet 底层实现
原始问法:
- List / Set / ZSet 底层实现
来源题目:
SRC-06-61-260
面试先答
Redis 的 List、Set、ZSet 三种类型底层均采用自适应编码策略:List 在 Redis 3.2+ 使用 quicklist(链表+ziplist混合结构),兼顾内存紧凑性和链表灵活性;Set 根据元素特征选择 intset(全整数且 ≤512 元素)或 hashtable;ZSet 小数据量用 ziplist/listpack,大数据量用 skiplist+hashtable 双结构。这些编码策略使得 Redis 在不同数据规模下都能兼顾内存效率和操作性能。
核心结论
- List 使用 quicklist(链表+ziplist)结合了内存紧凑性和操作灵活性。
- Set 使用 intset(整数集合)和 hashtable 双编码。
- ZSet 使用 skiplist+hashtable 双结构,分别支持范围查询和 O(1) 查找。
1. 是什么
List 底层实现:
List 是双向链表结构,支持在两端插入和删除元素。
| 编码 | 条件 | 结构 |
|---|---|---|
| ziplist(Redis 3.2 前) | 元素 ≤ 128 且值 ≤ 64 字节 | 紧凑连续内存 |
| quicklist(Redis 3.2+) | 默认编码 | 链表 + ziplist 节点 |
| list(纯链表) | 元素过多或值过大 | 纯双向链表 |
quicklist 结构:
quicklist → [node1] → [node2] → ... → [nodeN]
↓ ↓ ↓
ziplist ziplist ziplist
(紧凑存储) (紧凑存储) (紧凑存储)
quicklist 综合了 ziplist 的内存紧凑性和链表的灵活性:
- 每个节点是一个 ziplist,保持内存紧凑
- 节点间通过指针链接,便于增删节点
- 默认每个 ziplist 节点不超过 8KB(可配置)
Set 底层实现:
Set 是无序不重复集合。
| 编码 | 条件 | 结构 |
|---|---|---|
| intset | 全是整数且 ≤ 512 元素 | 有序整数数组,二分查找 |
| hashtable | 不满足 intset 条件 | 哈希表,O(1) 查找 |
intset 结构:
intset → [1, 5, 10, 25, 100, ...]
有序整数数组,二分查找 O(log N)
不支持非整数元素
ZSet 底层实现:
ZSet 是有序不重复集合,每个元素关联一个分数。
| 编码 | 条件 | 结构 |
|---|---|---|
| ziplist/listpack | 元素 ≤ 128 且值 ≤ 64 字节 | 紧凑存储 |
| skiplist + hashtable | 超过阈值 | 跳表 + 哈希表 |
skiplist + hashtable 双结构:
skiplist(跳表):
head → [level 3: null]
→ [level 2: 5, 25, null]
→ [level 1: 1, 5, 10, 25, 100, null]
→ [level 0: 1→node1, 5→node2, 10→node3, ...] ← 实际节点
hashtable:
"member1" → node1(O(1) 查找)
"member2" → node2
...
2. 为什么需要它
- List:需要高效的双向增删操作,同时保持内存紧凑
- Set:需要 O(1) 的查找和去重,整数场景用 intset 更省内存
- ZSet:需要同时支持 O(1) 查找和范围查询(按分数排序)
3. 底层原理与完整流程
List quicklist 工作流程:
- 新元素从头部或尾部插入
- 如果目标节点(ziplist)未满,直接插入
- 如果节点已满,创建新节点或拆分现有节点
- 链表指针调整,完成插入
Set intset → hashtable 转换:
- 当插入非整数元素或元素数 > 512 时触发
- 创建新的 hashtable
- 遍历 intset,逐个插入 hashtable
- 释放 intset,切换为 hashtable
- 编码不可逆
ZSet skiplist + hashtable 工作流程:
- 插入元素时,同时写入跳表和哈希表
- 跳表按分数排序,支持范围查询
- 哈希表按成员名索引,支持 O(1) 查找
- 删除时同时从两个结构中移除
4. 怎么使用
# List 操作
LPUSH queue "task1" # 左推入
RPUSH queue "task2" # 右推入
LPOP queue # 左弹出
RPOP queue # 右弹出
BLPOP queue 30 # 阻塞左弹出(超时 30s)
LRANGE queue 0 -1 # 获取所有元素
LINDEX queue 2 # 按索引访问
# Set 操作
SADD tags "java" "redis" # 添加元素
SMEMBERS tags # 获取所有元素
SISMEMBER tags "java" # 判断是否存在
SINTER set1 set2 # 交集
SUNION set1 set2 # 并集
SDIFF set1 set2 # 差集
SRANDMEMBER tags 3 # 随机获取 3 个元素
# ZSet 操作
ZADD rank 100 "alice" # 添加元素(分数 100)
ZADD rank 95 "bob"
ZSCORE rank "alice" # 获取分数
ZRANK rank "alice" # 获取排名(升序)
ZREVRANK rank "alice" # 获取排名(降序)
ZRANGE rank 0 9 # 获取 Top 10(升序)
ZREVRANGE rank 0 9 # 获取 Top 10(降序)
ZRANGEBYSCORE rank 90 100 # 按分数范围查询
5. 适用场景
| 类型 | 场景 | 示例 |
|---|---|---|
| List | 消息队列、最新列表、时间线 | 订单队列、微博时间线 |
| Set | 去重、交集、随机推荐 | 共同好友、文章标签 |
| ZSet | 排行榜、优先级队列、延迟处理 | 游戏积分榜、延迟队列 |
6. 不适用场景与替代方案
| 类型 | 不适用场景 | 替代方案 |
|---|---|---|
| List | 随机访问(O(N)) | ZSet 或数组 |
| Set | 需要重复元素 | List |
| ZSet | 超大数据量 | 专业排序系统 |
7. 优缺点与技术取舍
List (quicklist):
- 优点:内存紧凑,双向 O(1) 增删
- 缺点:随机访问 O(N)
Set (intset/hashtable):
- 优点:O(1) 查找,支持集合运算
- 缺点:intset 功能受限
ZSet (skiplist+hashtable):
- 优点:O(1) 查找 + O(log N) 范围查询
- 缺点:双结构占用额外内存
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| List 过大导致阻塞 | 使用 LRANGE 分页,避免一次获取全部 |
| Set 编码无法回退 | 编码不可逆,需重建 Key |
| ZSet 大 Key 阻塞 | 使用 ZSCAN 渐进遍历 |
9. 版本差异与实现边界
| 版本 | List 变化 | Set 变化 | ZSet 变化 |
|---|---|---|---|
| Redis 3.2 | 引入 quicklist | — | — |
| Redis 4.0 | — | 引入新的 intset 编码 | — |
| Redis 7.0 | listpack 替代 ziplist | — | listpack 替代 ziplist |
10. 常见追问
- Q: 为什么 List 用 quicklist 而不是纯链表? A: 纯链表每个节点有指针开销,内存占用大。quicklist 用 ziplist 作为节点,内存紧凑且 Cache 友好。
- Q: ZSet 为什么用两个结构? A: 跳表支持高效范围查询,哈希表支持 O(1) 按成员名查找,两者互补。
- Q: Set 的 intset 能自动回退吗? A: 不能。编码只升不降。如果删除非整数元素后全部变为整数,也不会回退到 intset。
11. 易错点
- ❌ 错误:List 底层是双向链表。✅ 正确:Redis 3.2+ 是 quicklist(链表+ziplist),不是纯链表。
- ❌ 错误:Set 只有 hashtable 编码。✅ 正确:Set 有 intset 和 hashtable 两种编码。
- ❌ 错误:ZSet 只用跳表。✅ 正确:ZSet 用 skiplist+hashtable 双结构。
- ❌ 错误:编码可以双向切换。✅ 正确:编码只升不降,不可逆。
一句话总结
List 用 quicklist 平衡内存与灵活性,Set 用 intset/hashtable 自适应编码,ZSet 用 skiplist+hashtable 兼顾范围查询与精确查找。
跳跃表(SkipList)原理
原始问法:
- 跳跃表(SkipList)原理
来源题目:
SRC-06-61-261
面试先答
跳跃表(SkipList)是一种基于有序链表的概率性数据结构,通过建立多层索引实现高效查找,时间复杂度 O(log N),与红黑树相当。跳表的核心思想是:在有序链表上建立多级"快速通道",查找时先在高层快速定位范围,再逐层下降到低层精确查找。Redis 的 ZSet 采用跳表实现,相比红黑树,跳表实现更简单、范围查询更自然、并发修改更友好。
核心结论
- 跳表是概率性平衡数据结构,期望 O(log N) 查找。
- 跳表通过多层索引加速查找,空间换时间。
- Redis ZSet 选择跳表而非红黑树,因为实现简单且范围查询高效。
1. 是什么
跳表(Skip List)是一种基于有序链表的扩展数据结构,通过在链表上建立多级索引,实现了类似二分查找的效率。
跳表结构示意:
Level 3: 1 ────────────────────────── 50
| |
Level 2: 1 ────────────── 25 ──────── 50
| | |
Level 1: 1 ──── 10 ────── 25 ── 35 ── 50
| | | | |
Level 0: 1─5─10─15─20─25─30─35─40─45─50
(实际数据层,有序链表)
每一层都是下一层的"快速通道",层数越高越稀疏。
2. 为什么需要它
红黑树的问题:
- 实现复杂(旋转、变色操作繁多)
- 范围查询需要中序遍历,实现复杂
- 并发修改时锁粒度较粗
跳表的优势:
- 实现简单(几十行代码即可完成核心逻辑)
- 范围查询自然(找到起点后在底层链表顺序遍历)
- 插入/删除效率稳定(只需修改相邻节点)
- 对 Cache 友好(链表节点连续访问)
3. 底层原理与完整流程
跳表的每个节点结构:
typedef struct zskiplistNode {
sds ele; // 成员对象
double score; // 分数
struct zskiplistNode *backward; // 后退指针(仅最底层使用)
struct zskiplistLevel {
struct zskiplistNode *forward; // 前进指针
unsigned long span; // 跨度(到下一个节点之间跳过了多少节点)
} level[]; // 层数组,柔性数组
} zskiplistNode;
查找流程(O(log N)):
- 从最高层开始
- 如果当前节点的下一个节点 < 目标 → 向右移动
- 如果下一个节点 ≥ 目标 → 向下移动一层
- 重复直到找到目标或到达底层
- 期望比较次数:O(log N)
插入流程(O(log N)):
- 按查找流程找到插入位置
- 在第 0 层插入新节点
- 通过随机算法决定层数:
- 有 1/4 概率只在第 0 层
- 有 1/4 概率升到第 1 层
- 有 1/4 概率升到第 2 层
- ... 直到随机失败
- 在对应层建立索引连接
删除流程(O(log N)):
- 按查找流程找到目标节点
- 从所有包含该节点的层中移除
- 更新前后节点的指针
Redis 中跳表的特殊设计:
- span 字段:记录当前节点到下一节点之间跨越了多少个底层节点,用于快速计算排名
- backward 指针:仅最底层使用,支持反向遍历
- 最大层数 32:足以支持 2^32 个元素,层数固定避免动态调整
4. 怎么使用
跳表是 Redis ZSet 的内部实现,通过 ZSet 命令间接使用:
# ZSet 命令底层使用跳表
ZADD rank 100 "alice"
ZADD rank 95 "bob"
ZADD rank 105 "charlie"
# 范围查询(跳表的强项)
ZRANGEBYSCORE rank 90 110 # 按分数范围
ZRANK rank "alice" # 按成员查找排名
ZREVRANGE rank 0 9 # 逆序范围查询
Java 中实现跳表示例:
public class SkipList<K extends Comparable<K>, V> {
private static final int MAX_LEVEL = 32;
private static final double P = 0.25;
private class Node {
K key;
V value;
Node[] forward;
int[] span;
Node(int level) {
forward = new Node[level + 1];
span = new int[level + 1];
}
}
private Node head = new Node(MAX_LEVEL);
private int level = 0;
private int size = 0;
private int randomLevel() {
int lvl = 0;
while (Math.random() < P && lvl < MAX_LEVEL) {
lvl++;
}
return lvl;
}
public V get(K key) {
Node current = head;
for (int i = level; i >= 0; i--) {
while (current.forward[i] != null
&& current.forward[i].key.compareTo(key) < 0) {
current = current.forward[i];
}
}
current = current.forward[0];
if (current != null && current.key.compareTo(key) == 0) {
return current.value;
}
return null;
}
public void put(K key, V value) {
Node[] update = new Node[MAX_LEVEL + 1];
int[] rank = new int[MAX_LEVEL + 1];
Node current = head;
for (int i = level; i >= 0; i--) {
rank[i] = (i == level) ? 0 : rank[i + 1];
while (current.forward[i] != null
&& current.forward[i].key.compareTo(key) < 0) {
current = current.forward[i];
rank[i] += current.span[i];
}
update[i] = current;
}
int newLevel = randomLevel();
if (newLevel > level) {
for (int i = level + 1; i <= newLevel; i++) {
update[i] = head;
rank[i] = 0;
head.span[i] = size;
}
level = newLevel;
}
Node newNode = new Node(newLevel);
newNode.key = key;
newNode.value = value;
for (int i = 0; i <= newLevel; i++) {
newNode.forward[i] = update[i].forward[i];
update[i].forward[i] = newNode;
int span = rank[0] + 1 - rank[i];
newNode.span[i] = update[i].span[i] - span;
update[i].span[i] = span;
}
for (int i = newLevel + 1; i <= level; i++) {
update[i].span[0]++;
}
size++;
}
}
5. 适用场景
- Redis ZSet:有序集合、排行榜
- 并发系统:跳表的无锁版本可用于并发场景
- 时间轮:定时器实现中的时间管理
- 搜索引擎:倒排索引中的快速定位
6. 不适用场景与替代方案
| 场景 | 替代方案 |
|---|---|
| 需要稳定的最坏情况保证 | 红黑树、AVL 树 |
| 插入/删除非常频繁 | 哈希表(O(1) 平均) |
| 内存极度受限 | 有序数组(但插入 O(N)) |
7. 优缺点与技术取舍
优点:
- O(log N) 平均查找,实现简单
- 范围查询高效自然
- 并发修改友好(相比红黑树)
- 对 CPU Cache 友好
缺点:
- 空间比红黑树稍大(多层指针)
- 最坏情况可能退化到 O(N)(概率极低)
- 实现依赖随机数生成器
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 层数过高浪费空间 | 限制最大层数(如 32) |
| 随机层数导致退化 | 使用 P=0.25 平衡空间和效率 |
| span 计算错误 | 仔细维护 span 字段 |
9. 版本差异与实现边界
Redis 跳表自引入以来基本稳定,主要变化在:
- Redis 3.2:优化跳表节点内存布局
- Redis 7.0:配合 listpack 优化小数据量 ZSet
关键参数:
- 最大层数:32(足以支持 2^32 个元素)
- 概率因子 P:0.25(每层晋升概率)
- 时间复杂度:平均 O(log N),最坏 O(N)
10. 常见追问
- Q: 跳表和红黑树怎么选? A: 跳表实现简单、范围查询高效、并发友好;红黑树最坏情况更优。Redis 选择跳表正是因为范围查询是 ZSet 的核心需求。
- Q: 为什么 Redis 用跳表而不是红黑树? A: 三个原因:① 跳表实现简单,代码更易维护;② 跳表范围查询更自然,只需在底层链表顺序遍历;③ 跳表插入/删除更稳定,不像红黑树需要复杂的旋转变色。
- Q: 跳表的 span 字段有什么用? A: span 记录当前节点到下一个节点之间跨越了多少个底层节点,用于快速计算排名(rank = span 累加)。
11. 易错点
- ❌ 错误:跳表最坏情况也是 O(log N)。✅ 正确:跳表最坏情况 O(N),但概率极低。
- ❌ 错误:跳层数越多越好。✅ 正确:层数过多种指针开销增大,通常 32 层足够。
- ❌ 错误:跳表不能支持反向遍历。✅ 正确:Redis 的跳表有 backward 指针支持反向遍历。
- ❌ 错误:跳表查找一定比哈希表快。✅ 正确:哈希表 O(1) 查找更快,但跳表支持范围查询。
一句话总结
跳表通过多层索引在有序链表上实现 O(log N) 查找,以简单的实现和高效的范围查询成为 Redis ZSet 的理想底层。
各数据类型专属应用场景
原始问法:
- 各数据类型专属应用场景
来源题目:
SRC-06-61-262
面试先答
Redis 的每种数据类型都有其专属的应用场景:String 适合做缓存值、计数器和分布式锁;Hash 适合存储对象属性(如用户信息、商品详情);List 适合做消息队列和最新列表;Set 适合做去重和集合运算(如共同好友、标签);ZSet 适合做排行榜和优先级队列;Bitmap 适合做签到记录;HyperLogLog 适合做 UV 统计;Stream 适合做可靠消息队列。选择正确的数据类型可以大幅提升内存效率和操作性能。
核心结论
- 不同数据类型对应不同的最佳使用场景。
- 选择依据是:数据特征(是否有序、是否重复)、操作类型(查找、范围、聚合)、性能要求。
- 选错类型会导致性能下降或内存浪费。
1. 是什么
五大数据类型专属场景:
| 类型 | 核心特性 | 专属场景 | 典型示例 |
|---|---|---|---|
| String | 原子操作、二进制安全 | 缓存、计数器、锁 | Session、阅读量、分布式锁 |
| Hash | 字段级操作、紧凑存储 | 对象存储、属性分组 | 用户信息、商品属性、配置项 |
| List | 双向增删、阻塞操作 | 消息队列、时间线 | 订单队列、微博时间线、最新评论 |
| Set | 去重、集合运算 | 去重、关系运算 | UV 去重、共同好友、文章标签 |
| ZSet | 有序、范围查询 | 排行榜、优先级 | 游戏积分榜、热搜榜、延迟队列 |
扩展类型专属场景:
| 类型 | 核心特性 | 专属场景 | 典型示例 |
|---|---|---|---|
| Bitmap | 位操作、极省内存 | 二值状态记录 | 签到、在线状态、用户标记 |
| HyperLogLog | 基数估计、12KB 固定内存 | UV 统计 | 页面 UV、独立 IP 数 |
| Stream | 持久化、消费组 | 可靠消息队列 | 订单处理、实时数据流 |
| Geo | 地理位置计算 | 附近的人 | 附近的人、距离计算 |
2. 为什么需要它
选择正确的数据类型带来的价值:
- 性能提升:O(1) vs O(N) 查找,差几个数量级
- 内存节省:合适的编码节省 50%+ 内存
- 功能完备:某些场景只能用特定类型(如排行榜需要有序集合)
- 原子性:单命令原子操作,无需加锁
3. 底层原理与完整流程
各类型典型应用流程:
String 缓存流程:
读请求 → Redis GET → 命中? → 返回(Cache Hit)
↓ 未命中
查询 DB → 写入 Redis → 返回(Cache Miss)
Hash 对象存储流程:
用户登录 → HSET user:{id} name "xxx" age 25 email "xxx"
读用户 → HGETALL user:{id}
更新属性 → HSET user:{id} age 26(部分更新,不影响其他字段)
List 消息队列流程:
生产者 → LPUSH order:queue "订单JSON"
消费者 → BRPOP order:queue 0(阻塞等待)
→ 处理订单 → 自动移除
Set 去重流程:
用户访问 → SADD uv:page1 "user:1" → 返回 1(新用户)
→ SADD uv:page1 "user:1" → 返回 0(已存在)
ZSet 排行榜流程:
玩家得分 → ZADD rank 100 "player:1"
查排名 → ZREVRANK rank "player:1"
取 Top10 → ZREVRANGE rank 0 9
4. 怎么使用
场景一:用户签到(Bitmap)
// 签到(每一天对应一个 bit)
jedis.setbit("sign:uid:1", dayOfYear, true);
// 查询某月签到天数
long signCount = jedis.bitcount("sign:uid:1");
场景二:文章 UV 统计(HyperLogLog)
// 记录访客
jedis.pfadd("uv:article:1", "user:1", "user:2");
// 获取 UV 近似值
long uv = jedis.pfcount("uv:article:1");
场景三:可靠消息队列(Stream)
// 生产者
jedis.xadd("stream:orders",
StreamAddParams.map("order", orderJson));
// 消费者(消费组模式)
List<StreamEntry> messages = jedis.xreadGroup(
"group1", "stream:orders",
new HashMap<String, StreamEntryID>() {{
put("stream:orders", StreamEntryID.last());
}}, 10);
场景四:共同好友(Set 交集)
// 计算共同好友
Set<String> commonFriends = jedis.sinter("user:1:friends", "user:2:friends");
5. 适用场景
| 业务需求 | 推荐类型 | 原因 |
|---|---|---|
| 存一个值 | String | 简单直接,支持多种编码 |
| 存对象 | Hash | 字段级操作,省内存 |
| 先进先出队列 | List | 支持阻塞弹出 |
| 去重计数 | Set/HyperLogLog | Set 精确去重,HL近似去重 |
| 排序/排行 | ZSet | 天然有序,支持范围查询 |
| 二值状态 | Bitmap | 极省内存 |
| 可靠消息 | Stream | 持久化 + 消费组 |
6. 不适用场景与替代方案
| 需求 | 不推荐类型 | 原因 | 替代方案 |
|---|---|---|---|
| 多条件查询 | 任何类型 | Redis 不支持 SQL | MySQL/ES |
| 复杂关系 | Set | 只支持简单集合 | 图数据库 |
| 超大列表 | List | 查找 O(N) | 分段 ZSet |
| 精确 UV | HyperLogLog | 存在 0.81% 误差 | Set |
7. 优缺点与技术取舍
String: 通用但功能有限 Hash: 对象存储最优,但不支持嵌套 List: 队列首选,但随机访问慢 Set: 去重利器,但无顺序 ZSet: 排行榜首选,但内存占用较高
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 选错类型导致内存浪费 | 根据数据特征和操作模式选择 |
| Hash 字段过多 | 拆分为多个 Hash 或使用 JSON |
| Set 数据量过大 | 分片存储或使用 SCAN |
| ZSet 大 Key | 使用 ZSCAN 渐进遍历 |
9. 版本差异与实现边界
| 版本 | 新增类型/增强 |
|---|---|
| Redis 2.8 | HyperLogLog |
| Redis 3.2 | GEO 地理位置 |
| Redis 5.0 | Stream 类型 |
| Redis 6.0 | 客户端缓存 |
| Redis 7.0 | Functions、Multi-part AOF |
10. 常见追问
- Q: 为什么不用 Hash 存所有东西? A: Hash 适合对象存储,但单个值的操作 String 更简单高效。Hash 有编码切换开销,不适合频繁单字段操作。
- Q: Bitmap 和 HyperLogLog 怎么选? A: Bitmap 精确但每个用户占 1 bit;HyperLogLog 近似(误差 0.81%)但固定 12KB。用户量大时 HyperLogLog 更省内存。
- Q: Stream 和 List 队列怎么选? A: 需要消息持久化、消费组、ACK 机制时用 Stream;简单队列用 List 更轻量。
11. 易错点
- ❌ 错误:String 只能存字符串。✅ 正确:String 支持整数(int 编码)和二进制数据。
- ❌ 错误:Hash 可以替代所有 String。✅ 正确:Hash 适合对象,单值操作用 String 更高效。
- ❌ 错误:Set 可以排序。✅ 正确:Set 无序,需要排序用 ZSet。
- ❌ 错误:ZSet 和 Set 功能相同。✅ 正确:ZSet 有序,每个元素关联一个分数。
一句话总结
根据数据特征和操作模式选择 Redis 数据类型:String 存值、Hash 存对象、List 做队列、Set 做去重、ZSet 做排行,扩展类型覆盖位图统计、UV 计数和可靠消息。
第 6.2 节 Redis 持久化
Redis的持久化机制有哪些?分别怎么实现?
原始问法:
- Redis的持久化机制有哪些?分别怎么实现?
来源题目:
SRC-06-62-263
面试先答
Redis 提供三种持久化机制:RDB(快照)、AOF(追加日志)和混合持久化。RDB 通过 fork 子进程将某一时刻的内存数据快照写入二进制文件,优点是文件紧凑、恢复快,缺点是可能丢失最后一次快照后的数据;AOF 将每个写命令追加到日志文件中,数据更安全,但文件大、恢复慢;混合持久化(Redis 4.0+)结合了两者优点——AOF 重写时将 RDB 格式作为主体、增量 AOF 作为尾巴,兼顾恢复速度和数据安全。生产环境通常选择混合持久化。
核心结论
- Redis 有 RDB、AOF、混合持久化三种持久化方式。
- RDB 是快照方式,AOF 是日志方式,混合持久化结合两者优点。
- 生产环境推荐混合持久化或 AOF+RDB 双保险。
1. 是什么
RDB(Redis DataBase): 在指定的时间间隔内,将内存中的数据以二进制快照形式写入磁盘的 .rdb 文件。
AOF(Append Only File): 将每个写命令以追加日志的方式写入 .aof 文件,记录数据的变更过程而非最终状态。
混合持久化(Redis 4.0+): AOF 重写时,前半部分用 RDB 格式存储(快速恢复),后半部分用 AOF 格式存储(增量变更)。
2. 为什么需要它
Redis 是内存数据库,如果不做持久化,一旦进程退出(无论是意外还是正常关闭),所有数据都会丢失。持久化机制确保数据在重启后可以恢复,使 Redis 不仅能做缓存,也能做数据存储。
3. 底层原理与完整流程
RDB 工作流程:
触发条件 → 执行 BGSAVE
1. Redis 进程 fork() 子进程
2. 子进程将内存数据写入临时 .rdb 文件
3. 写入完成,替换旧的 dump.rdb
4. 子进程退出
触发方式:
- save 命令:同步阻塞(不推荐)
- bgsave 命令:fork 子进程,不阻塞主线程
- 自动触发:满足 save 规则时自动 bgsave
RDB 文件格式(简化):
[magic "REDIS"] [version] [database info] [key-value pairs] [checksum]
AOF 工作流程:
写命令执行 → 追加到 AOF 缓冲区
→ 根据策略刷到磁盘:
- always:每条命令 fsync,最安全,最慢
- everysec:每秒 fsync(默认),折中方案
- no:交给 OS 决定,最快,最不安全
AOF 重写流程:
触发重写 → bgrewriteaof
1. fork() 子进程
2. 子进程扫描当前内存数据,写入新 AOF 文件
3. 重写期间新命令写入 AOF 重写缓冲区
4. 子进程完成后,缓冲区内容追加到新文件
5. 替换旧 AOF 文件
混合持久化流程(AOF rewrite 时):
bgrewriteaof 触发
→ 前半部分:以 RDB 格式快速写入(压缩、高效)
→ 后半部分:以 AOF 格式写入增量命令
→ 重启恢复时:先加载 RDB 部分(快速),再回放 AOF 尾部
4. 怎么使用
# RDB 配置
save 900 1 # 900秒内至少1次修改触发bgsave
save 300 10 # 300秒内至少10次修改
save 60 10000 # 60秒内至少10000次修改
stop-writes-on-bgsave-error yes # bgsave失败时停止写入
rdbcompression yes # 压缩RDB文件
rdbchecksum yes # 校验和
dbfilename dump.rdb
dir /var/lib/redis
# AOF 配置
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec # always | everysec | no
auto-aof-rewrite-percentage 100 # AOF文件比上次重写增长100%时触发
auto-aof-rewrite-min-size 64mb
# 混合持久化配置
aof-use-rdb-preamble yes # 开启混合持久化
# 手动触发
redis-cli BGSAVE # 手动触发RDB快照
redis-cli BGREWRITEAOF # 手动触发AOF重写
redis-cli LASTSAVE # 查看上次保存时间
5. 适用场景
| 持久化方式 | 适用场景 |
|---|---|
| RDB | 数据备份、灾备恢复、快速重启 |
| AOF | 数据安全要求高、不能丢数据 |
| 混合持久化 | 兼顾数据安全和恢复速度 |
6. 不适用场景与替代方案
- 对数据丢失零容忍 → AOF(always 模式)+ 主从复制
- 数据量极大 → 考虑 TTL 策略减少持久化数据量
- 持久化文件过大 → 混合持久化 + 定期清理过期数据
7. 优缺点与技术取舍
RDB:
- 优点:文件紧凑、恢复快、适合备份
- 缺点:可能丢失最后快照后的数据、fork 阻塞
AOF:
- 优点:数据安全(最多丢 1 秒)、可读性好
- 缺点:文件大、恢复慢、需要定期重写
混合持久化:
- 优点:恢复快(RDB 部分)、数据安全(AOF 尾部)
- 缺点:实现复杂、需额外配置
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| fork 阻塞 | 优化:关闭 bgsave 自动触发、使用大页 |
| AOF 重写阻塞 | 监控磁盘空间、手动在低峰期触发重写 |
| RDB 文件损坏 | 使用 redis-check-rdb 工具修复 |
| AOF 文件损坏 | 使用 redis-check-aof 工具修复 |
| 磁盘空间不足 | 增加磁盘、定期清理、调整持久化策略 |
9. 版本差异与实现边界
| 版本 | 变化 |
|---|---|
| Redis 2.4 | 引入 AOF 重写 |
| Redis 3.2 | AOF 重写优化 |
| Redis 4.0 | 引入混合持久化(aof-use-rdb-preamble) |
| Redis 7.0 | Multi-part AOF(多段 AOF)、更快的 RDB 加载 |
10. 常见追问
- Q: RDB 和 AOF 可以同时开启吗? A: 可以。两者独立工作,重启恢复时优先加载 AOF(数据更完整)。
- Q: AOF 文件太大怎么办? A: 配置自动重写参数
auto-aof-rewrite-percentage和auto-aof-rewrite-min-size,或手动触发 BGREWRITEAOF。 - Q: Redis 重启时如何恢复数据? A: 如果开启 AOF,优先加载 AOF 文件(数据更完整);否则加载 RDB 文件。混合持久化时,先加载 RDB 部分再回放 AOF 尾部。
11. 易错点
- ❌ 错误:RDB 是实时同步的。✅ 正确:RDB 是定时快照,不是实时的。
- ❌ 错误:AOF 记录数据的最终状态。✅ 正确:AOF 记录的是写命令,不是数据状态。
- ❌ 错误:混合持久化就是同时开 RDB 和 AOF。✅ 正确:混合持久化是 AOF 重写时用 RDB 格式作为主体,不是简单同时开启。
- ❌ 错误:AOF everysec 模式完全不丢数据。✅ 正确:everysec 模式最多丢 1 秒数据。
一句话总结
Redis 通过 RDB 快照、AOF 日志和混合持久化三种机制,在数据安全和恢复速度之间提供灵活选择,生产环境推荐混合持久化。
RDB和AOF的区别是什么?
原始问法:
- RDB和AOF的区别是什么?
来源题目:
SRC-06-62-264
面试先答
RDB 和 AOF 是 Redis 的两种核心持久化方式,核心区别在于:RDB 是"快照"——在某个时间点将内存数据整体序列化到二进制文件,文件紧凑、恢复快,但会丢失最后快照后的数据;AOF 是"日志"——将每个写命令追加到文本日志,数据更安全(最多丢 1 秒),但文件大、恢复慢。生产环境通常选择 AOF(everysec 模式)保证数据安全,或混合持久化兼顾两者优点。
核心结论
- RDB 是快照,AOF 是日志,本质区别在于记录方式。
- RDB 恢复快但可能丢更多数据,AOF 数据安全但恢复慢。
- 混合持久化(Redis 4.0+)结合了两者的优点。
1. 是什么
RDB(快照持久化): 在特定时间点,将 Redis 内存中的所有数据序列化为二进制格式写入磁盘文件。
AOF(追加持久化): 将 Redis 执行过的每一条写命令以追加方式写入日志文件,通过重新执行命令恢复数据。
2. 为什么需要它
两种持久化方式解决了不同的矛盾:
- RDB:解决恢复速度和文件大小问题,适合备份和灾备
- AOF:解决数据丢失问题,适合对数据安全要求高的场景
3. 底层原理与完整流程
对比表:
| 维度 | RDB | AOF |
|---|---|---|
| 记录方式 | 时间点快照(序列化数据) | 写命令日志(追加命令) |
| 文件格式 | 二进制(紧凑) | 文本(RESP 协议) |
| 数据安全性 | 可能丢失数分钟数据 | 最多丢失 1 秒数据 |
| 恢复速度 | 快(直接加载二进制) | 慢(重放所有命令) |
| 文件大小 | 小(压缩后) | 大(所有写命令) |
| I/O 影响 | fork 子进程,影响较小 | 持续写入,影响较大 |
| 实现复杂度 | 简单 | 较复杂(需要重写机制) |
| 适合场景 | 备份、灾备、快速恢复 | 数据安全要求高 |
RDB 恢复流程:
启动 → 检查是否有 dump.rdb
→ fork 加载进程 → 读取 RDB 文件 → 反序列化 → 恢复数据 → 接受连接
AOF 恢复流程:
启动 → 检查是否有 appendonly.aof
→ 读取 AOF 文件 → 逐条回放命令 → 恢复数据 → 接受连接
4. 怎么使用
# 同时开启 RDB 和 AOF
# RDB 配置(可选)
save 900 1
save 300 10
# AOF 配置(推荐)
appendonly yes
appendfsync everysec
# 混合持久化(Redis 4.0+,推荐)
aof-use-rdb-preamble yes
# 查看持久化状态
redis-cli INFO persistence
5. 适用场景
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 数据备份/灾备 | RDB | 文件小、恢复快 |
| 在线业务持久化 | AOF + RDB | 数据安全 + 备份 |
| 追求恢复速度 | 混合持久化 | RDB 部分快速恢复 |
| 数据不能丢 | AOF (always) | 每条命令落盘 |
6. 不适用场景与替代方案
| 场景 | 不推荐 | 原因 |
|---|---|---|
| 数据安全要求极高 | 只用 RDB | 可能丢失大量数据 |
| 数据量极大 | 只用 AOF | 文件过大,恢复太慢 |
| 需要零数据丢失 | 任何单一种 | 应使用 AOF+主从+异步备份 |
7. 优缺点与技术取舍
RDB 优点: 文件紧凑、恢复快、适合定期备份 RDB 缺点: 两次快照之间可能丢数据、fork 大实例时阻塞
AOF 优点: 数据安全(最多丢 1 秒)、可读性好、实现透明 AOF 缺点: 文件大、恢复慢、需要定期重写
混合持久化优点: 兼顾恢复速度和数据安全 混合持久化缺点: 实现稍复杂
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| RDB 丢数据 | 缩短保存间隔或开启 AOF |
| AOF 文件过大 | 配置自动重写或手动 BGREWRITEAOF |
| 恢复慢 | 使用混合持久化加速恢复 |
| fork 阻塞 | 调整系统参数或使用大页 |
9. 版本差异与实现边界
| 版本 | RDB 变化 | AOF 变化 |
|---|---|---|
| Redis 2.4 | — | 引入 AOF 重写 |
| Redis 3.2 | — | AOF 重写优化 |
| Redis 4.0 | — | 引入混合持久化 |
| Redis 7.0 | RDB 加载加速 | Multi-part AOF |
10. 常见追问
- Q: 可以只用 RDB 吗? A: 可以,但不推荐。RDB 可能丢失大量数据,适合数据可恢复的场景(如缓存)。
- Q: AOF 的 everysec 是安全的吗? A: 大部分场景足够安全。最多丢失 1 秒数据,且 Redis 进程正常关闭时不会丢数据。
- Q: 混合持久化恢复时走 AOF 还是 RDB? A: 混合持久化的恢复流程是:先加载 AOF 文件的 RDB 部分(快速),再回放 AOF 尾部的增量命令。
11. 易错点
- ❌ 错误:RDB 比 AOF 好。✅ 正确:各有优缺点,选择取决于场景。
- ❌ 错误:AOF 一定比 RDB 安全。✅ 正确:AOF everysec 模式最多丢 1 秒,RDB 可能丢更多,但 AOF no 模式可能丢更多。
- ❌ 错误:混合持久化就是同时开两种。✅ 正确:混合持久化是 AOF 的增强模式,不是简单叠加。
- ❌ 错误:RDB 文件是文本格式。✅ 正确:RDB 是二进制格式,AOF 是文本格式。
一句话总结
RDB 是时间点快照,紧凑高效但可能丢更多数据;AOF 是命令日志,数据安全但恢复慢;混合持久化结合两者优点,是 Redis 4.0+ 的推荐方案。
RDB会丢数据吗?为什么?
原始问法:
- RDB会丢数据吗?为什么?
来源题目:
SRC-06-62-265
面试先答
RDB 会丢数据,原因是 RDB 是定时快照机制,两次快照之间的写操作如果 Redis 进程异常退出(崩溃、断电、kill -9),这些数据就会丢失。具体丢失时间取决于 save 配置的触发间隔:默认配置下(900秒/1次修改、300秒/10次修改、60秒/10000次修改),最坏情况可能丢失 60 秒的数据。但如果 Redis 正常关闭(SHUTDOWN 命令),会触发同步保存,不会丢数据。
核心结论
- RDB 会丢失最后一次快照后的数据。
- 丢失时间取决于 save 配置的触发间隔。
- 正常关闭(SHUTDOWN)不会丢数据,异常退出才会丢。
1. 是什么
RDB 的数据丢失窗口是从上次成功执行 BGSAVE 到 Redis 进程异常退出之间的时间段。
2. 为什么需要它
理解 RDB 数据丢失的原因,有助于在生产环境做出正确的持久化选择。如果业务不能接受数据丢失,必须使用 AOF(everysec 或 always 模式)或混合持久化。
3. 底层原理与完整流程
RDB 数据丢失场景分析:
时间线:
T0: BGSAVE 执行完成,生成 dump.rdb
T1: Redis 接收写命令 W1、W2、W3
T2: Redis 接收写命令 W4、W5
T3: Redis 进程异常退出(崩溃)
结果:W1-W5 数据丢失,因为它们在 T0 之后、下一次 BGSAVE 之前
不同退出场景的影响:
| 退出方式 | RDB 数据丢失情况 |
|---|---|
| SHUTDOWN(正常关闭) | 不丢失(触发同步 save) |
| SHUTDOWN NOSAVE | 丢失所有未保存数据 |
| 进程崩溃(kill -9、断电) | 丢失上次快照后的所有数据 |
| OOM 被系统 kill | 丢失上次快照后的所有数据 |
save 规则与丢失窗口:
save 900 1 # 900秒内1次修改触发 → 可能丢 900 秒
save 300 10 # 300秒内10次修改触发 → 可能丢 300 秒
save 60 10000 # 60秒内10000次修改触发 → 可能丢 60 秒
注意:三个条件是"或"的关系,任一满足即触发
4. 怎么使用
# 配置更短的保存间隔以减少丢失
save 60 10000
save 30 100000
# 或关闭 RDB 自动保存,手动控制
# save ""
# 配合 AOF 使用作为双保险
appendonly yes
appendfsync everysec
# 正常关闭(触发保存)
redis-cli SHUTDOWN
# 异常恢复后的检查
redis-cli LASTSAVE # 查看最后保存时间
5. 适用场景
| 场景 | 是否接受 RDB 丢失 |
|---|---|
| 纯缓存(可从 DB 重建) | 接受,只用 RDB |
| 会话存储 | 基本接受 |
| 计数器(非关键业务) | 可接受部分丢失 |
| 订单、交易数据 | 不接受,必须用 AOF/混合持久化 |
6. 不适用场景与替代方案
- 零数据丢失要求 → AOF (always) + 主从复制 + 异步备份
- 关键业务数据 → 混合持久化 + 多副本
7. 优缺点与技术取舍
RDB 的取舍:
- 优点:文件紧凑、恢复快
- 缺点:数据丢失窗口不可避免
减少丢失的方法:
- 缩短 save 间隔 → 但 BGSAVE 有 fork 开销
- 开启 AOF 作为补漏 → 推荐双保险方案
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| RDB 丢失太多 | 配合 AOF 使用或改用 AOF/混合持久化 |
| fork 影响性能 | 大实例考虑使用 VM.overcommit_memory 和 swap 优化 |
| 不确定上次保存时间 | 使用 LASTSAVE 命令检查 |
9. 版本差异与实现边界
所有 Redis 版本的 RDB 都存在数据丢失问题,这是由其快照机制决定的。
10. 常见追问
- Q: 如何最大程度减少 RDB 丢失? A: 缩短保存间隔(如
save 5 10000),同时配合 AOF 使用作为双保险。 - Q: SHUTDOWN 命令会触发保存吗? A: 默认会。SHUTDOWN 会执行同步 save,将内存数据写入 RDB 文件。使用 SHUTDOWN NOSAVE 可跳过保存。
- Q: RDB 的 fork 会阻塞多久? A: 取决于内存大小和系统 fork 速度,通常几毫秒到几百毫秒。大实例应关注 fork 时间。
11. 易错点
- ❌ 错误:RDB 不会丢数据。✅ 正确:RDB 可能丢失快照后的数据。
- ❌ 错误:Redis 关闭一定会保存数据。✅ 正确:只有 SHUTDOWN 命令正常执行才会保存,kill -9 不会。
- ❌ 错误:save 规则是"与"的关系。✅ 正确:save 规则是"或"的关系,任一条件满足即触发。
一句话总结
RDB 会丢失最后一次快照后的数据,因为它是定时快照机制;正常关闭不会丢,异常退出才会丢;关键业务应配合 AOF 使用。
AOF重写是什么?后台重写的原理?
原始问法:
- AOF重写是什么?后台重写的原理?
来源题目:
SRC-06-62-266
面试先答
AOF 重写是指重新压缩 AOF 文件的过程。由于 AOF 记录了所有写命令(包括对同一个 Key 的多次修改),文件会不断膨胀。AOF 重写通过扫描当前内存数据,只生成 Key 的最终写入命令,从而压缩文件体积。后台重写(BGREWRITEAOF)通过 fork 子进程完成压缩,不阻塞主线程。重写期间的新命令会被缓冲区捕获,重写完成后追加到新文件末尾。
核心结论
- AOF 重写是压缩 AOF 文件,去掉冗余命令。
- 后台重写通过 fork 子进程实现,不阻塞主线程。
- 重写期间新命令写入缓冲区,完成后追加到新文件。
1. 是什么
AOF 重写(AOF Rewrite)是在保证数据完整性的前提下,重新生成 AOF 文件以压缩其体积的过程。
为什么需要 AOF 重写:
- AOF 记录所有写命令,包括对同一 Key 的多次修改
- 例如:
INCR counter执行 100 次,AOF 记录 100 条命令 - 但恢复时只需要最终值
SET counter 100 - 重写将 100 条命令压缩为 1 条
2. 为什么需要它
AOF 文件随时间不断增长,如果不定期压缩:
- 磁盘空间被大量占用
- 恢复时间越来越长
- I/O 开销增大
AOF 重写是保证 AOF 长期可用的必要机制。
3. 底层原理与完整流程
后台重写(BGREWRITEAOF)完整流程:
1. 触发条件:
- 手动执行 BGREWRITEAOF
- 自动触发:AOF 文件比上次重写后增长超过 auto-aof-rewrite-percentage
2. Redis 主进程:
a. 检查是否已有重写在进行(避免并发重写)
b. fork() 子进程
c. 子进程开始重写
3. 子进程(重写执行):
a. 遍历所有数据库
b. 对每个 Key 生成一条最终写入命令
c. 写入临时 AOF 文件(new_appendonly.aof)
d. 完成后通知主进程
4. 主进程(重写期间):
a. 正常处理命令
b. 将新的写命令同时写入:
- 旧 AOF 文件(保证持久化)
- AOF 重写缓冲区(用于追加到新文件)
5. 重写完成:
a. 主进程将 AOF 重写缓冲区内容追加到新 AOF 文件
b. 替换旧 AOF 文件
c. 释放缓冲区
混合持久化模式下的重写:
重写时:
→ 前半部分:以 RDB 格式快速写入
→ 后半部分:以 AOF 格式写入增量命令
→ 生成混合格式文件
重写缓冲区:
- 重写期间的新写命令被临时存储
- 重写完成后追加到新文件末尾
- 如果缓冲区过大(超过 aof-rewrite-incremental-fsync),会触发额外的 fsync
4. 怎么使用
# 手动触发 AOF 重写
redis-cli BGREWRITEAOF
# 配置自动重写
auto-aof-rewrite-percentage 100 # 文件增长 100% 时触发
auto-aof-rewrite-min-size 64mb # 最小重写文件大小
# 查看重写状态
redis-cli INFO persistence
# 关注:aof_rewrite_in_progress, aof_last_rewrite_time_sec
# 配置重写期间的 fsync 行为
aof-rewrite-incremental-fsync yes
Java 中手动触发:
jedis.bgrewriteAof();
// 或使用异步监听
jedis.bgsave(); // RDB 快照类似
5. 适用场景
- AOF 文件体积过大(超过 auto-aof-rewrite-min-size)
- 磁盘空间不足,需要压缩 AOF 文件
- 定期维护,清理冗余命令
- 混合持久化模式下定期生成新的 RDB 基底
6. 不适用场景与替代方案
- 数据量极大且写操作频繁 → 重写耗时过长,考虑分片
- 对 I/O 延迟极度敏感 → 在低峰期手动触发
7. 优缺点与技术取舍
优点:
- 压缩率高(通常可压缩 50%+)
- 不阻塞主线程(后台执行)
- 保证数据完整性
缺点:
- fork 子进程有内存开销(Copy-On-Write)
- 重写期间双重写放大(旧文件 + 缓冲区)
- 磁盘 I/O 压力增大
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 重写阻塞主线程 | BGREWRITEAOF 是后台的,fork 有短暂阻塞 |
| 重写失败 | 检查磁盘空间、查看日志、手动重新触发 |
| 重写后文件更大 | 检查是否有大量过期 Key、调整重写阈值 |
| 频繁触发重写 | 调整 auto-aof-rewrite-percentage 和 min-size |
9. 版本差异与实现边界
| 版本 | 变化 |
|---|---|
| Redis 2.4 | 引入 AOF 重写 |
| Redis 3.2 | 优化重写算法,减少内存使用 |
| Redis 4.0 | 支持混合持久化重写 |
| Redis 7.0 | Multi-part AOF,更高效的重写 |
10. 常见追问
- Q: AOF 重写期间,新命令怎么处理? A: 主进程将新的写命令同时写入旧 AOF 文件(保证持久化)和 AOF 重写缓冲区。重写完成后,缓冲区内容追加到新文件。
- Q: 为什么 AOF 重写需要 fork? A: fork 子进程可以在不阻塞主线程的情况下,独立遍历内存数据并生成新的 AOF 文件。利用 Copy-On-Write,子进程看到的是 fork 时刻的数据快照。
- Q: AOF 重写会丢失数据吗? A: 不会。重写期间的新命令被缓冲区捕获,完成后追加到新文件。整个过程对数据完整性无影响。
11. 易错点
- ❌ 错误:AOF 重写会丢失数据。✅ 正确:重写期间的新命令被完整捕获,不会丢失。
- ❌ 错误:AOF 重写删除了不需要的 Key。✅ 正确:重写只压缩命令,不删除任何数据。
- ❌ 错误:AOF 重写会阻塞主线程。✅ 正确:BGREWRITEAOF 是后台的,只有 fork 瞬间有短暂阻塞。
- ❌ 错误:AOF 重写后文件一定更小。✅ 正确:通常更小,但如果所有 Key 都只有一次写入,重写后大小基本不变。
一句话总结
AOF 重写通过 fork 子进程扫描当前数据生成压缩后的 AOF 文件,不阻塞主线程,保证数据完整性,是 AOF 长期可用的必要维护机制。
什么是混合持久化?
原始问法:
- 什么是混合持久化?
来源题目:
SRC-06-62-267
面试先答
混合持久化是 Redis 4.0 引入的一种新的持久化方式,严格来说它是 AOF 持久化的优化模式。在 AOF 重写时,前半部分以 RDB 二进制格式写入(快速压缩、恢复快),后半部分以 AOF 文本格式写入增量命令(数据完整)。这样既保留了 RDB 恢复快的优点,又保留了 AOF 数据安全的优点。开启方式是 aof-use-rdb-preamble yes,前提是 AOF 已开启。
核心结论
- 混合持久化是 AOF 的增强模式,不是独立的持久化方式。
- AOF 重写时前半部分用 RDB 格式,后半部分用 AOF 格式。
- 兼顾了 RDB 恢复快和 AOF 数据安全的双重优点。
1. 是什么
混合持久化(Hybrid Persistence)是 Redis 4.0 引入的持久化优化策略,在 AOF 重写时将文件分为两部分:
混合 AOF 文件结构:
┌──────────────────────────┬────────────────────────────┐
│ RDB 格式(二进制) │ AOF 格式(增量命令) │
│ (某一时刻的完整数据快照) │ (快照后的增量写命令) │
└──────────────────────────┴────────────────────────────┘
↑ 恢复时先加载这部分(快速) ↑ 再回放这部分(完整)
2. 为什么需要它
RDB 的问题: 恢复快但可能丢更多数据 AOF 的问题: 数据安全但恢复慢 混合持久化的解决方案: 在 AOF 重写时,将大部分数据以 RDB 格式存储(快速恢复),仅将增量部分以 AOF 格式存储(保证安全)
3. 底层原理与完整流程
开启条件:
1. appendonly = yes(AOF 已开启)
2. aof-use-rdb-preamble = yes
AOF 重写时的混合流程:
1. 触发 BGREWRITEAOF
2. fork 子进程
3. 子进程遍历内存数据
→ 前 80% 数据:以 RDB 二进制格式写入新文件
→ 后 20%:以 AOF RESP 格式写入增量命令
4. 重写期间的新命令写入 AOF 重写缓冲区
5. 缓冲区内容以 AOF 格式追加到文件末尾
6. 替换旧文件
恢复流程:
1. 检查 AOF 文件头部标识
→ 如果是混合格式(RDB 前缀 + AOF 后缀)
2. 加载 RDB 部分(快速,二进制直接反序列化)
3. 回放 AOF 尾部(增量命令重放)
4. 恢复完成
4. 怎么使用
# 开启混合持久化
# 必须先开启 AOF
appendonly yes
aof-use-rdb-preamble yes
# 触发混合格式重写
redis-cli BGREWRITEAOF
# 查看当前持久化状态
redis-cli INFO persistence
# 关注:aof_enabled, aof_rewrite_in_progress
恢复验证:
# 重启 Redis 后,查看加载信息
redis-cli INFO server | grep process_id
# 检查 AOF 文件格式
xxd appendonly.aof | head
# RDB 部分以 "REDIS" magic number 开头
5. 适用场景
- 对数据安全和恢复速度都有要求的生产环境
- AOF 文件较大,恢复时间过长的场景
- 不想同时维护 RDB 和 AOF 两种文件
6. 不适用场景与替代方案
- Redis 4.0 之前版本 → 需要升级或使用 AOF
- 磁盘空间极端紧张 → 纯 RDB 更紧凑
- 对 RDB 格式有顾虑 → 纯 AOF(everysec 模式)
7. 优缺点与技术取舍
优点:
- 恢复快:RDB 部分加载速度接近纯 RDB
- 数据安全:保留 AOF 的增量日志
- 文件更小:RDB 部分压缩率高
- 配置简单:一行配置开启
缺点:
- 实现稍复杂(需要处理混合格式)
- RDB 部分修改时需要重新生成
- 需要 Redis 4.0+ 版本
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 混合持久化未生效 | 检查 appendonly 和 aof-use-rdb-preamble 配置 |
| 恢复时加载慢 | 确认 RDB 部分是否生成、检查磁盘 I/O |
| 文件格式损坏 | 使用 redis-check-aof 修复 |
9. 版本差异与实现边界
| 版本 | 混合持久化支持 |
|---|---|
| Redis 3.2 及之前 | 不支持 |
| Redis 4.0 | 引入 aof-use-rdb-preamble |
| Redis 7.0 | Multi-part AOF 进一步优化 |
10. 常见追问
- Q: 混合持久化和同时开 RDB+AOF 有什么区别? A: 同时开是两个独立文件,混合是一个文件内两种格式。混合更高效,只需维护一个文件。
- Q: 混合持久化的 RDB 部分何时更新? A: 在 AOF 重写时更新。重写前的数据以 RDB 格式写入,重写后的数据以 AOF 格式追加。
- Q: 混合持久化的文件能被旧版 Redis 读取吗? A: 不能。Redis 4.0 之前版本不支持混合格式。
11. 易错点
- ❌ 错误:混合持久化是独立于 RDB 和 AOF 的第三种方式。✅ 正确:它是 AOF 的增强模式。
- ❌ 错误:混合持久化不需要开启 AOF。✅ 正确:必须先开启 AOF。
- ❌ 错误:混合持久化会丢失数据。✅ 正确:它保留了 AOF 的数据安全性。
- ❌ 错误:混合持久化恢复时只加载 RDB 部分。✅ 正确:先加载 RDB 部分,再回放 AOF 尾部。
一句话总结
混合持久化是 AOF 的增强模式,在重写时将 RDB 作为文件主体、AOF 作为尾部,兼顾了恢复速度和数据安全,是 Redis 4.0+ 的推荐持久化方案。
Redis持久化方式如何选择?
原始问法:
- Redis持久化方式如何选择?
来源题目:
SRC-06-62-268
面试先答
Redis 持久化方式的选择需要权衡三个核心因素:数据可接受的丢失程度、恢复速度要求、磁盘 I/O 承受能力。如果 Redis 仅作为缓存(数据可从数据库重建),RDB 就够用;如果 Redis 作为主要数据存储(如计数器、排行榜),应选择混合持久化或 AOF(everysec 模式);如果对数据丢失零容忍,使用 AOF(always 模式)。生产环境最常用的方案是混合持久化,兼顾恢复速度和数据安全。
核心结论
- 选择持久化方式需要考虑:数据丢失容忍度、恢复速度、I/O 开销。
- 纯缓存场景:RDB 足够。
- 数据存储场景:混合持久化(推荐)或 AOF(everysec)。
- 零丢失场景:AOF(always)+ 主从复制。
1. 是什么
持久化方式选择是在数据安全、恢复速度和系统开销之间找到最佳平衡点。
2. 为什么需要它
没有完美的持久化方案,每种方式都有取舍。选错方案可能导致:
- 数据丢失过多(只用 RDB)
- 恢复时间过长(只用 AOF)
- I/O 影响过大(AOF always)
3. 底层原理与完整流程
选择决策矩阵:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 纯缓存(数据可重建) | RDB | 简单、恢复快、丢数据可接受 |
| 会话/计数(数据可恢复但重建成本高) | 混合持久化 | 恢复快、安全 |
| 核心业务数据(不能丢) | AOF (everysec) + 主从 | 最多丢 1 秒 + 多副本 |
| 零丢失要求 | AOF (always) + 主从 + 异步备份 | 每层防线兜底 |
不同方案的对比:
| 方案 | 数据安全 | 恢复速度 | I/O 开销 | 复杂度 |
|---|---|---|---|---|
| RDB only | 可能丢分钟级 | 快 | 低 | 低 |
| AOF (everysec) | 最多丢 1 秒 | 中等 | 中等 | 中 |
| 混合持久化 | 最多丢 1 秒 | 快 | 中等 | 中 |
| AOF (always) | 零丢失 | 慢 | 高 | 中 |
| RDB + AOF | 最多丢 1 秒 | 快(AOF 优先) | 中 | 中 |
4. 怎么使用
方案一:纯 RDB(缓存场景)
save 3600 1 # 宽松的保存策略
save 300 100
stop-writes-on-bgsave-error yes
rdbcompression yes
方案二:混合持久化(推荐方案)
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
方案三:AOF + RDB 双保险
appendonly yes
appendfsync everysec
# RDB 作为备份
save 900 1
save 300 10
方案四:AOF always(零丢失)
appendonly yes
appendfsync always
5. 适用场景
- 缓存 → RDB
- 通用生产 → 混合持久化
- 核心数据 → AOF (everysec) + 主从 + 备份
- 金融/交易 → AOF (always) + 主从 + 异步备份 + 异地灾备
6. 不适用场景与替代方案
- 只用 RDB 存储核心数据 → 不推荐
- AOF always 用于高并发场景 → I/O 成为瓶颈
- 不做任何持久化 → 仅限纯缓存可接受全丢的场景
7. 优缺点与技术取舍
推荐策略总结:
| 优先级 | 场景 | 方案 | 核心理由 |
|---|---|---|---|
| 1 | 通用生产 | 混合持久化 | 恢复快 + 数据安全 |
| 2 | 纯缓存 | RDB | 简单 + 够用 |
| 3 | 核心数据 | AOF (everysec) + RDB | 安全双保险 |
| 4 | 零丢失 | AOF (always) + 主从 | 每步都落盘 |
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| RDB 文件和 AOF 文件同时存在 | 重启时优先加载 AOF,可删除不需要的 |
| AOF 文件持续增长 | 配置自动重写或定期手动触发 |
| 磁盘 I/O 瓶颈 | 使用 SSD、调整 appendfsync 策略 |
| 恢复时间过长 | 使用混合持久化加速 |
9. 版本差异与实现边界
| 版本 | 推荐方案 |
|---|---|
| Redis 3.2 及之前 | AOF (everysec) + RDB 双保险 |
| Redis 4.0+ | 混合持久化 |
| Redis 7.0+ | 混合持久化 + Multi-part AOF |
10. 常见追问
- Q: 为什么不所有场景都用 AOF always? A: AOF always 每条命令都 fsync,I/O 成为瓶颈,吞吐量可能下降到原来的 1/10。只有对数据安全极端敏感且写入量不大的场景才适用。
- Q: 混合持久化和 AOF+RDB 双保险哪个好? A: 混合持久化更优。它只需维护一个文件,恢复更快,配置更简单。AOF+RDB 双保险适合 Redis 4.0 之前的版本。
- Q: 如何验证持久化策略的可靠性? A: 定期模拟故障恢复:kill Redis 进程,重启后验证数据完整性。
11. 易错点
- ❌ 错误:AOF 一定比 RDB 好。✅ 正确:各有取舍,缓存场景 RDB 足够。
- ❌ 错误:混合持久化是万能的。✅ 正确:它仍是 AOF 的增强,极端场景需要额外备份。
- ❌ 错误:只用 RDB 就行。✅ 正确:数据存储场景必须配合 AOF 或混合持久化。
- ❌ 错误:持久化策略一成不变。✅ 正确:应根据业务变化定期评估调整。
一句话总结
根据数据丢失容忍度和恢复速度要求选择持久化方式:缓存用 RDB、通用场景用混合持久化、核心数据用 AOF+主从、零丢失用 AOF always。
第 6.3 节 过期策略与内存淘汰
Redis的key过期删除策略有哪些?Redis采用的过期删除策略是什么?
原始问法:
- Redis的key过期删除策略有哪些?
- Redis采用的过期删除策略是什么?
来源题目:
SRC-06-63-269、SRC-06-63-270
面试先答
Redis 的 Key 过期删除策略有两种:惰性删除和定期删除。惰性删除是在访问 Key 时才检查是否过期,过期则删除——节省 CPU 但可能导致过期 Key 堆积;定期删除是每隔一段时间随机扫描一批设置了过期时间的 Key,检查并删除——牺牲一些 CPU 但保证过期 Key 被及时清理。Redis 采用两者结合的混合策略:惰性删除确保访问时及时清理,定期删除定期扫描防止过期 Key 堆积。此外,当内存不足时,还会触发内存淘汰机制作为兜底。
核心结论
- Key 过期删除策略有两种:惰性删除和定期删除。
- Redis 采用两者结合的混合策略。
- 惰性删除节省 CPU 但可能堆积,定期删除主动清理但消耗 CPU。
1. 是什么
惰性删除(Lazy Deletion): 当客户端访问某个 Key 时,Redis 先检查该 Key 是否设置了过期时间且是否已过期。如果已过期,先删除该 Key 再返回不存在。
定期删除(Periodic Deletion): Redis 周期性地随机从设置了过期时间的 Key 中抽取一批,检查并删除其中已过期的 Key。
2. 为什么需要它
只用惰性删除的问题:
- 如果一个过期 Key 从未被访问,它将永远占用内存
- 大量无人访问的过期 Key 会导致内存浪费
只用定期删除的问题:
- 扫描间隔太长 → 过期 Key 仍然堆积
- 扫描间隔太短 → CPU 消耗过大
- 扫描是随机的,可能有些 Key 长时间未被扫描到
混合策略: 结合两者优点——惰性删除保证访问即清理,定期删除主动清理堆积。
3. 底层原理与完整流程
惰性删除流程:
客户端 GET key
→ 检查 expires 字典中 key 的过期时间
→ 如果 now > expire_time:
a. 从数据库字典删除 key
b. 从 expires 字典删除 key
c. 通知 AOF/从服务器(传播删除事件)
d. 返回 nil(key 不存在)
→ 如果未过期或未设置过期:
返回值
定期删除流程:
Redis 周期性执行(每 100ms 左右):
1. 从设置了过期时间的 Key 中随机抽取 20 个
2. 检查每个 Key 是否过期
3. 删除已过期的 Key
4. 如果本轮删除比例 > 25%:
→ 立即重复步骤 1-3(最多 20 次)
5. 最多耗时 25% 的 CPU 时间,避免阻塞主线程
过期数据结构:
Redis 使用两个字典存储 Key:
- db[i].dict:存储所有 Key → Value
- db[i].expires:存储设置了过期时间的 Key → 过期时间戳
过期检查:
→ 检查 key 是否在 expires 字典中
→ 如果存在,比较当前时间与过期时间
→ 过期则删除
过期时间的存储:
expires 字典的 Value 是过期时间戳(毫秒级):
SET key value PX 3600000
→ expires[key] = 当前时间 + 3600000(毫秒)
4. 怎么使用
# 设置过期时间
SET key value EX 3600 # 3600秒后过期
SET key value PX 3600000 # 3600000毫秒后过期
EXPIRE key 60 # 60秒后过期
PEXPIRE key 60000 # 60000毫秒后过期
# 查看剩余时间
TTL key # 秒级
PTTL key # 毫秒级
# 取消过期(变为永久 Key)
PERSIST key
# 查看过期时间戳
EXPIRETIME key # Redis 7.0+,返回 Unix 时间戳
Java 操作:
jedis.set("token:user1", "abc123", "NX", "EX", 3600);
long ttl = jedis.ttl("token:user1"); // 秒
jedis.persist("token:user1"); // 取消过期
5. 适用场景
- 惰性删除:对过期 Key 访问频繁的场景,访问时即清理
- 定期删除:有大量设置了过期时间的 Key,需要主动清理
- 混合策略:所有 Redis 场景都适用
6. 不适用场景与替代方案
- 不要依赖过期删除来清理所有过期 Key → 内存不足时配合淘汰策略
- 不要设置过短的过期时间 → 频繁删除和重建开销大
7. 优缺点与技术取舍
惰性删除优点:
- 节省 CPU 时间,只在访问时检查
- 保证数据一致性(访问时立即检查)
惰性删除缺点:
- 过期 Key 可能堆积,浪费内存
- 如果大量 Key 设置了过期但很少被访问,内存浪费严重
定期删除优点:
- 主动清理,避免过期 Key 堆积
- 控制 CPU 开销(最多 25% 时间)
定期删除缺点:
- 无法立即清理所有过期 Key
- 随机抽样可能导致某些 Key 延迟清理
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 大量过期 Key 堆积 | 调整定期删除频率或手动 SCAN 删除 |
| 内存泄漏(Key 未被清理) | 检查是否设置了过期时间、使用 SCAN 扫描 |
| 定期删除阻塞 | Redis 限制了最多 25% CPU 时间 |
9. 版本差异与实现边界
| 版本 | 变化 |
|---|---|
| Redis 2.x | 基本的惰性+定期删除策略 |
| Redis 4.0 | 引入主动过期(expired 键空间通知) |
| Redis 6.0 | 优化定期删除算法 |
| Redis 7.0 | EXPIRETIME 命令、更精确的过期通知 |
10. 常见追问
- Q: 为什么不只用惰性删除? A: 如果过期 Key 不被访问,它们永远占用内存。对于缓存场景(大量 Key 设置了过期),定期删除是必要的补充。
- Q: 定期删除会阻塞吗? A: 不会。Redis 限制每轮最多使用 25% CPU 时间,如果未清理完,下次再继续。
- Q: 如何设置让 Key 永不过期? A: 默认就是永久的。如果已设置过期时间,使用 PERSIST 命令取消。
11. 易错点
- ❌ 错误:Redis 只有一种过期删除策略。✅ 正确:Redis 同时使用惰性删除和定期删除两种策略。
- ❌ 错误:定期删除会扫描所有 Key。✅ 正确:定期删除是随机抽样,不是全量扫描。
- ❌ 错误:过期删除是实时的。✅ 正确:惰性删除是访问触发,定期删除是周期性的,都不是实时的。
- ❌ 错误:EXPIRE 命令对不存在的 Key 有效。✅ 正确:EXPIRE 只能对已存在的 Key 设置过期时间。
一句话总结
Redis 采用惰性删除(访问时检查)和定期删除(周期性随机扫描)的混合过期策略,在 CPU 开销和内存清理之间取得平衡。
Redis的内存淘汰策略有哪些?
原始问法:
- Redis的内存淘汰策略有哪些?
来源题目:
SRC-06-63-271
面试先答
Redis 的内存淘汰策略是当内存使用超过 maxmemory 限制时,Redis 选择删除哪些 Key 的策略。Redis 提供了 8 种淘汰策略,可分为三类:不淘汰类(noeviction,拒绝写入)、按范围淘汰类(allkeys-lru、allkeys-lfu、allkeys-random,针对所有 Key)、按设置了过期时间淘汰类(volatile-lru、volatile-lfu、volatile-random、volatile-ttl,仅针对设置了过期时间的 Key)。其中 LRU(最近最少使用)和 LFU(最不经常使用,Redis 4.0+)是生产中最常用的策略。
核心结论
- Redis 有 8 种内存淘汰策略。
- 分为不淘汰、全体 Key 淘汰、过期 Key 淘汰三类。
- LRU 和 LFU 是最常用的策略。
1. 是什么
当 Redis 内存使用达到 maxmemory 限制时,Redis 需要选择删除哪些 Key 来腾出新的空间供写入请求使用,这就是内存淘汰策略。
8 种淘汰策略:
| 策略 | 范围 | 算法 | 说明 |
|---|---|---|---|
| noeviction | — | 不淘汰 | 拒绝写入,返回错误 |
| allkeys-lru | 所有 Key | LRU | 淘汰最近最少使用的 Key |
| allkeys-lfu | 所有 Key | LFU | 淘汰最不经常使用的 Key(Redis 4.0+) |
| allkeys-random | 所有 Key | 随机 | 随机淘汰一个 Key |
| allkeys-ttl | 所有 Key | TTL | 优先淘汰剩余寿命最短的 Key |
| volatile-lru | 仅过期 Key | LRU | 淘汰设置了过期时间的最近最少使用的 Key |
| volatile-lfu | 仅过期 Key | LFU | 淘汰设置了过期时间的最不经常使用的 Key |
| volatile-random | 仅过期 Key | 随机 | 从设置了过期时间的 Key 中随机淘汰 |
| volatile-ttl | 仅过期 Key | TTL | 淘汰设置了过期时间的剩余寿命最短的 Key |
2. 为什么需要它
Redis 是内存数据库,物理内存有限。当内存不足时:
- 如果没有淘汰机制 → 写入请求失败,Redis 无法继续服务
- 合理的淘汰策略 → 保证热点数据留存,冷数据被淘汰
3. 底层原理与完整流程
淘汰触发流程:
客户端写入请求
→ 检查当前内存使用量
→ 如果超过 maxmemory:
a. 根据 maxmemory-policy 选择淘汰策略
b. 选择要淘汰的 Key
c. 删除 Key(同步或异步)
d. 重试写入
→ 如果未超过 maxmemory:
直接写入
LRU 算法原理:
- 维护一个双向链表,按访问顺序排列
- 新访问的 Key 移到链表头部
- 淘汰时从链表尾部删除(最近最少使用)
- Redis 使用近似 LRU(随机采样,不是精确 LRU)
LFU 算法原理(Redis 4.0+):
- 基于频率计数器,记录每个 Key 的访问频率
- 使用指数衰减,避免历史访问频率影响
- 优先淘汰访问频率最低的 Key
近似 LRU 的实现:
Redis 维护一个主字典和一个较小的 LRU 字典:
- 新写入的 Key 同时加入两个字典
- 访问 Key 时从 LRU 字典中移动到"头部"
- 需要淘汰时,从 LRU 字典随机采样 N 个 Key
- 从采样结果中选择最久未使用的淘汰
参数 maxmemory-samples:
淘汰时随机采样的 Key 数量(默认 5)
采样越多,越接近精确 LRU,但计算开销越大
4. 怎么使用
# 设置内存上限
maxmemory 4gb
# 设置淘汰策略
maxmemory-policy allkeys-lru # 推荐:全体 Key 按 LRU 淘汰
# 其他可选:
# maxmemory-policy allkeys-lfu # Redis 4.0+,按访问频率
# maxmemory-policy volatile-lru # 仅淘汰设置了过期的 Key
# maxmemory-policy noeviction # 不淘汰,写入失败
# 调整采样数量
maxmemory-samples 5
# 查看当前配置
redis-cli CONFIG GET maxmemory-policy
redis-cli INFO memory
Java 客户端示例:
// 动态调整淘汰策略
jedis.configSet("maxmemory-policy", "allkeys-lru");
jedis.configSet("maxmemory", String.valueOf(4L * 1024 * 1024 * 1024));
// 监控内存使用
MemoryInfo memoryInfo = jedis.infoMemory();
long usedMemory = memoryInfo.getUsedMemory();
5. 适用场景
| 策略 | 适用场景 |
|---|---|
| allkeys-lru | 通用缓存,保证热点数据 |
| allkeys-lfu | 访问频率差异大的缓存 |
| volatile-lru | 混合存储(部分 Key 永久保存) |
| noeviction | 不希望自动淘汰(由业务管理过期) |
| volatile-ttl | 希望短寿命 Key 优先淘汰 |
6. 不适用场景与替代方案
| 场景 | 不推荐策略 | 原因 |
|---|---|---|
| 永久 Key(如配置) | volatile-* 策略 | 这些 Key 没设过期时间,不会被淘汰 |
| 对数据可靠性要求高 | allkeys-* 策略 | 可能淘汰任何 Key |
| 小内存设备 | 高采样数 | 增加 CPU 开销 |
7. 优缺点与技术取舍
LRU 优点: 实现简单,热点数据命中率高 LRU 缺点: 无法识别长期热点(如每天访问一次的热点)
LFU 优点: 更精确的频率统计,识别长期热点 LFU 缺点: 实现更复杂,需要频率计数器
noeviction 优点: 不丢数据 noeviction 缺点: 内存满后无法写入
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 热点数据被淘汰 | 调整策略为 LFU 或增加 maxmemory |
| 永久 Key 被淘汰 | 使用 volatile-* 策略,永久 Key 不设过期时间 |
| 频繁淘汰 | 增加 maxmemory 或优化 Key 过期策略 |
| 淘汰导致数据库雪崩 | 合理设置过期时间,预热缓存 |
9. 版本差异与实现边界
| 版本 | 变化 |
|---|---|
| Redis 2.8 | 引入 allkeys-lru、volatile-lru 等策略 |
| Redis 4.0 | 引入 LFU 算法(allkeys-lfu、volatile-lfu) |
| Redis 7.0 | 优化 LFU 实现,更精确的频率统计 |
10. 常见追问
- Q: LRU 和 LFU 怎么选? A: LRU 实现简单,适合大多数场景;LFU 对长期热点更友好,适合访问频率差异大的场景。Redis 4.0+ 推荐 LFU。
- Q: 为什么 Redis 用近似 LRU 而不是精确 LRU? A: 精确 LRU 需要维护完整的链表,每次访问都要更新,开销大。近似 LRU 用随机采样,效果接近但开销小得多。
- Q: allkeys- 和 volatile- 怎么选?** A: 如果所有 Key 都可以被淘汰,选 allkeys-;如果有永久 Key(如配置数据),选 volatile-(只有设了过期时间的 Key 才会被淘汰)。
11. 易错点
- ❌ 错误:noeviction 不会淘汰任何 Key。✅ 正确:noeviction 在内存满时拒绝写入,不会淘汰 Key。
- ❌ 错误:LRU 是精确的最近最少使用。✅ 正确:Redis 的 LRU 是近似的,使用随机采样实现。
- ❌ 错误:volatile-* 策略会淘汰所有 Key。✅ 正确:volatile-* 策略只会淘汰设置了过期时间的 Key。
- ❌ 错误:淘汰策略可以热切换。✅ 正确:可以通过 CONFIG SET 动态切换。
一句话总结
Redis 提供 8 种内存淘汰策略,核心是 LRU(最近最少使用)和 LFU(最不经常使用),选择依据是数据特征和业务需求,allkeys-lru 是通用缓存的首选。
第 6.4 节 缓存问题与优化
什么是缓存穿透?如何解决?什么是缓存击穿?如何解决?什么是缓存雪崩?如何解决?
原始问法:
- 什么是缓存穿透?如何解决?
- 什么是缓存击穿?如何解决?
- 什么是缓存雪崩?如何解决?
来源题目:
SRC-06-64-272、SRC-06-64-273、SRC-06-64-274
面试先答
缓存穿透、击穿、雪崩是缓存系统的三大经典问题。缓存穿透是查询不存在的数据,缓存和数据库都没有,每次请求都打到数据库——解决方案是布隆过滤器过滤不存在的 Key + 空值缓存;缓存击穿是热点 Key 过期瞬间大量并发请求打到数据库——解决方案是互斥锁保证单线程回源 + 热点数据永不过期;缓存雪崩是大量 Key 同时过期或 Redis 实例宕机,大量请求瞬间打到数据库——解决方案是随机化过期时间 + 多级缓存 + 集群高可用。
核心结论
- 三者的本质区别:穿透查不存在、击穿查过期热点、雪崩是大面积同时失效。
- 解决方案分别围绕:拦截不存在请求、保护回源操作、分散失效时间和高可用。
- 生产中通常组合使用多种策略来应对。
1. 是什么
缓存穿透(Cache Penetration): 查询一个不存在的数据,缓存中没有,数据库也没有。每次查询都会穿透到数据库,缓存形同虚设。
请求: GET /user?id=99999
→ Redis: miss
→ MySQL: SELECT * FROM user WHERE id=99999 → 空
→ 每次请求都打到 MySQL
缓存击穿(Cache Breakdown): 某个热点 Key 在失效瞬间,大量并发请求同时回源数据库。
热点 Key: product:1(库存 100)
→ 过期时间到,Key 失效
→ 1000 并发请求同时到达
→ 全部打到 MySQL,可能压垮数据库
缓存雪崩(Cache Avalanche): 大量 Key 在同一时间集中失效,或 Redis 实例整体不可用,导致海量请求直接打到数据库。
场景一:大量 Key 同时过期
→ 系统在凌晨批量刷新缓存
→ 所有 Key 同时失效
→ 海量请求打到 MySQL
场景二:Redis 集群宕机
→ Redis 全部不可用
→ 所有请求打到 MySQL
2. 为什么需要它
理解这三种问题的本质区别,才能对症下药:
- 穿透:请求的数据本身不存在,缓存无法覆盖
- 击穿:热点数据的失效瞬间,需要并发保护
- 雪崩:系统性失效,需要架构层面保障
3. 底层原理与完整流程
缓存穿透解决方案:
方案一:布隆过滤器
请求 → 布隆过滤器检查 → 可能存在 → Redis → DB
→ 一定不存在 → 直接返回
方案二:空值缓存
请求 → Redis miss → DB 查 → 空 → SET key "" EX 300
后续请求命中空值缓存,直接返回空
方案三:参数校验
对请求参数做合法性校验,非法参数直接拒绝
布隆过滤器原理:
布隆过滤器是一个 bit 数组 + 多个哈希函数:
1. 存入元素:用 K 个哈希函数计算 K 个位置,置 1
2. 检查元素:用 K 个哈希函数计算 K 个位置
→ 所有位置都是 1 → 可能存在(有误判)
→ 任一位置是 0 → 一定不存在
缓存击穿解决方案:
方案一:互斥锁(分布式锁)
请求 → Redis miss → 加锁(SETNX)→ 回源 DB → 写入 Redis → 释放锁
→ 未获取锁 → 等待 → 重试 Redis
方案二:热点数据永不过期
热点 Key 不设置过期时间
异步线程定期更新
方案三:热点发现 + 主动预热
监控访问频率,发现热点后自动续期
缓存雪崩解决方案:
方案一:随机化过期时间
基础过期时间 + 随机偏移
→ 避免大量 Key 同时失效
方案二:多级缓存
→ 本地缓存(Caffeine)+ Redis + DB
→ Redis 失效时本地缓存兜底
方案三:Redis 高可用
→ 主从复制 + 哨兵/Cluster
→ 实例故障自动切换
方案四:限流降级
→ 数据库层限流(每秒最大查询数)
→ 超过阈值返回默认值或降级
方案五:熔断降级
→ 检测到数据库压力过大
→ 自动熔断,返回缓存旧数据或默认值
4. 怎么使用
布隆过滤器实现(Java + Redis):
// 使用 Redisson 的布隆过滤器
RBloomFilter<Long> bloomFilter = redisson.getBloomFilter("userIds");
bloomFilter.tryInit(1000000, 0.01); // 容量 100 万,误判率 1%
bloomFilter.add(1001L);
bloomFilter.contains(1001L); // true
bloomFilter.contains(9999L); // false(可能不存在)
空值缓存:
public User getUser(Long id) {
String key = "user:" + id;
String cached = jedis.get(key);
if (cached != null) {
if ("".equals(cached)) {
return null; // 空值缓存
}
return parseUser(cached);
}
User user = userMapper.selectById(id);
if (user == null) {
jedis.setex(key, 300, ""); // 缓存空值 5 分钟
} else {
jedis.setex(key, 3600, toJson(user));
}
return user;
}
互斥锁防击穿:
public Product getProduct(Long id) {
String key = "product:" + id;
String lockKey = "lock:product:" + id;
String cached = jedis.get(key);
if (cached != null) {
return parseProduct(cached);
}
// 加锁
if (jedis.set(lockKey, "1", "NX", "EX", 10)) {
try {
// 二次检查
cached = jedis.get(key);
if (cached != null) {
return parseProduct(cached);
}
// 回源数据库
Product product = productMapper.selectById(id);
if (product != null) {
jedis.setex(key, 300, toJson(product));
}
} finally {
jedis.del(lockKey); // 释放锁
}
} else {
// 未获取锁,稍等重试
Thread.sleep(50);
return getProduct(id); // 递归重试
}
return null;
}
随机化过期时间:
// 基础时间 + 随机偏移
int baseTtl = 3600; // 1 小时
int randomTtl = baseTtl + ThreadLocalRandom.current().nextInt(0, 1800);
jedis.setex(key, randomTtl, value);
5. 适用场景
| 问题 | 推荐方案 |
|---|---|
| 穿透 | 布隆过滤器 + 空值缓存 |
| 击穿 | 互斥锁 + 热点数据永不过期 |
| 雪崩 | 随机过期 + 多级缓存 + 高可用 + 限流降级 |
6. 不适用场景与替代方案
| 方案 | 不适用场景 | 原因 |
|---|---|---|
| 空值缓存 | 数据频繁更新的场景 | 可能导致短期不一致 |
| 布隆过滤器 | 需要精确判断存在性 | 有误判率 |
| 互斥锁 | 高并发热点 Key | 锁竞争降低吞吐量 |
7. 优缺点与技术取舍
布隆过滤器优点: 内存占用小、查询速度快 布隆过滤器缺点: 有误判率、不能删除元素
互斥锁优点: 保证单线程回源,避免击穿 互斥锁缺点: 锁竞争降低吞吐量,实现复杂
随机过期优点: 简单有效,避免雪崩 随机过期缺点: 不能完全避免,只是降低概率
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 布隆过滤器误判 | 结合空值缓存使用,降低影响 |
| 空值缓存内存浪费 | 设置较短过期时间,定期清理 |
| 锁竞争严重 | 使用本地锁 + 分布式锁结合 |
| 雪崩导致 DB 压力 | 限流降级 + 熔断保护 |
9. 版本差异与实现边界
布隆过滤器在 Redis 4.0+ 以模块形式原生支持(BF.ADD、BF.EXISTS),Redis 之前版本需要使用第三方模块或 Java 库实现。
10. 常见追问
- Q: 布隆过滤器能解决所有穿透问题吗? A: 不能。布隆过滤器只能判断"一定不存在"和"可能存在",有误判率。通常结合空值缓存使用。
- Q: 热点数据永不过期会有什么问题? A: 数据更新不及时。需要异步线程定期更新,或使用消息通知机制触发更新。
- Q: 雪崩和击穿的区别? A: 击穿是单个热点 Key 的失效瞬间,雪崩是大量 Key 同时失效或 Redis 整体不可用。
11. 易错点
- ❌ 错误:缓存穿透是缓存挂了。✅ 正确:穿透是查询不存在的数据,缓存和数据库都没有。
- ❌ 错误:布隆过滤器可以删除元素。✅ 正确:标准布隆过滤器不支持删除,需要使用计数布隆过滤器。
- ❌ 错误:互斥锁完全消除了击穿。✅ 正确:互斥锁保证只有一个线程回源,但增加了等待延迟。
- ❌ 错误:雪崩只会在 Redis 宕机时发生。✅ 正确:大量 Key 同时过期也会引发雪崩。
一句话总结
缓存穿透查无此数据用布隆过滤器+空值缓存解决,缓存击穿查热点失效用互斥锁+永不过期解决,缓存雪崩大面积失效用随机过期+多级缓存+高可用+限流降级解决。
缓存预热怎么做?
原始问法:
- 缓存预热怎么做?
来源题目:
SRC-06-64-275
面试先答
缓存预热是在系统启动或缓存失效后,预先将热点数据加载到缓存中的过程。常见做法有四种:① 系统启动时,根据历史访问统计加载热点数据;② 通过数据订阅机制,监听数据库变更并同步到缓存;③ 使用定时任务,提前加载即将过期的热点数据;④ 基于机器学习预测热点数据并主动加载。生产中通常结合多种方式,核心目标是避免冷启动时的缓存穿透和雪崩。
核心结论
- 缓存预热是提前加载热点数据到缓存,避免冷启动问题。
- 主要方式:启动预热、订阅同步、定时刷新、预测加载。
- 预热应控制在业务低峰期,避免影响核心业务。
1. 是什么
缓存预热(Cache Warmup)是在缓存系统启动、重启或大量数据失效后,主动将热点数据从数据源(数据库)加载到缓存的过程。
2. 为什么需要它
- 冷启动问题:系统刚启动时缓存为空,所有请求打到数据库
- 雪崩恢复:Redis 宕机恢复后,大量请求穿透到数据库
- 大促场景:提前加载热点商品、库存到缓存
3. 底层原理与完整流程
方式一:启动预热
系统启动 → 读取热点数据列表(从统计表或配置)
→ 批量查询数据库
→ 写入 Redis
→ 完成后开始接受请求
方式二:订阅同步(Canal / Binlog)
应用 → 订阅 MySQL Binlog → 解析变更事件
→ 更新 Redis 缓存
→ 实现缓存与数据库的准实时同步
方式三:定时刷新
定时任务(如每 5 分钟)
→ 扫描即将过期的热点 Key
→ 异步更新
→ 避免过期瞬间的缓存击穿
方式四:预测加载
基于历史访问数据
→ 预测即将成为热点的数据
→ 提前加载到缓存
→ 适合有规律的热点场景(如早高峰新闻)
4. 怎么使用
启动预热脚本:
@Component
public class CacheWarmup implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
log.info("开始缓存预热...");
// 1. 加载热点商品
List<Long> hotProductIds = hotProductMapper.findHotIds(1000);
for (Long id : hotProductIds) {
Product product = productMapper.selectById(id);
if (product != null) {
jedis.setex("product:" + id, 3600, JSON.toJSONString(product));
}
}
// 2. 加载热门配置
List<Config> configs = configMapper.selectAll();
for (Config config : configs) {
jedis.setex("config:" + config.getKey(), 7200, config.getValue());
}
log.info("缓存预热完成,共加载 {} 个商品", hotProductIds.size());
}
}
定时刷新热点:
@Scheduled(fixedRate = 300000) // 每 5 分钟
public void refreshHotKeys() {
Set<String> hotKeys = jedis.zrevrange("hot:key:ranking", 0, 999);
for (String key : hotKeys) {
if (jedis.ttl(key) < 300) { // 剩余时间 < 5 分钟
refreshKeyFromDB(key);
}
}
}
Shell 脚本预热:
#!/bin/bash
# 从数据库导出热点 ID
mysql -e "SELECT id FROM hot_products ORDER BY view_count DESC LIMIT 1000" > /tmp/hot_ids.txt
# 逐个加载到 Redis
while read id; do
data=$(mysql -e "SELECT * FROM products WHERE id=$id" | tail -1)
redis-cli SET "product:$id" "$data" EX 3600
done < /tmp/hot_ids.txt
echo "预热完成"
5. 适用场景
- 系统冷启动
- Redis 重启或故障恢复
- 大促/活动前的热点数据预加载
- 定期的热点数据刷新
6. 不适用场景与替代方案
- 不明确热点的数据 → 使用按需加载 + 缓存穿透防护
- 数据量极大 → 分批预热,避免对数据库造成压力
7. 优缺点与技术取舍
优点: 避免冷启动时的缓存穿透和雪崩 缺点: 需要额外的预热逻辑,加载期间占用数据库资源
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 预热影响数据库性能 | 在低峰期执行、分批加载 |
| 预热数据不热 | 定期更新热点列表、动态发现热点 |
| 预热期间数据变更 | 使用订阅机制实时同步 |
9. 版本差异与实现边界
缓存预热与 Redis 版本无关,是应用层面的策略。可结合 Redis 的 SCAN 命令和 Pipeline 批量操作提高效率。
10. 常见追问
- Q: 预热和缓存一致性怎么保证? A: 预热是提前加载,后续通过 Cache-Aside Pattern(先更新数据库再删除缓存)保证一致性。
- Q: 预热会不会造成数据库压力? A: 会。应在低峰期分批执行,控制并发数。
- Q: 如何确定哪些数据需要预热? A: 分析历史访问日志,找出访问频率最高的 Top N 数据。
11. 易错点
- ❌ 错误:缓存预热就是缓存穿透的解决方案。✅ 正确:预热是主动加载,穿透防护是被动拦截不存在的请求。
- ❌ 错误:预热需要加载所有数据。✅ 正确:只加载热点数据,全量加载成本过高。
一句话总结
缓存预热通过启动加载、订阅同步、定时刷新等方式提前将热点数据加载到缓存,避免冷启动时的穿透和雪崩问题。
如何保证数据库和缓存的一致性?
原始问法:
- 如何保证数据库和缓存的一致性?
来源题目:
SRC-06-64-276
面试先答
数据库和缓存的一致性没有完美方案,只能根据业务场景在一致性和性能之间权衡。最常用的方案是 Cache-Aside Pattern(缓存旁路模式):读时先读缓存,缓存未命中再读数据库并回填缓存;写时先写数据库,再删除缓存(而不是更新缓存)。这种方案在大多数场景下足够用。对一致性要求更高的场景,可以使用 Canal/Debezium 订阅数据库 Binlog 来同步缓存更新,或使用延时双删策略。
核心结论
- 没有完美的缓存一致性方案,只能根据场景权衡。
- 最常用方案:Cache-Aside Pattern(读回源、写删缓存)。
- 高一致性场景:Binlog 订阅 + 延时双删。
1. 是什么
缓存一致性是指缓存中的数据与数据库中的数据保持同步。由于缓存和数据库是两个独立系统,存在以下一致性问题:
- 时间差:写数据库和更新缓存之间存在时间窗口
- 并发冲突:多个请求可能同时修改同一数据
- 失效策略:缓存过期时间导致数据不一致
2. 为什么需要它
不一致可能导致:
- 用户读取到过期数据(缓存未更新)
- 用户更新数据后看到旧值(缓存未同步)
- 业务逻辑错误(如库存显示有但实际已售罄)
3. 底层原理与完整流程
Cache-Aside Pattern(推荐):
读流程:
1. 读缓存
2. 缓存命中 → 返回
3. 缓存未命中 → 读数据库
4. 写入缓存 → 返回
写流程:
1. 写数据库
2. 删除缓存(不是更新缓存)
3. 完成
为什么删缓存而不是更新缓存?
- 更新缓存可能有并发问题(先写的后到)
- 删除缓存后,下次读时会自动回填
- 删除比更新更简单、更可靠
延时双删策略(增强一致性):
写流程:
1. 写数据库
2. 立即删除缓存
3. 休眠(如 500ms)
4. 再次删除缓存(防止并发回源导致的旧值写入)
订阅 Binlog 方案(强一致性):
写流程:
1. 写数据库
2. Binlog 产生变更记录
3. Canal/Debezium 订阅 Binlog
4. 解析变更,更新 Redis 缓存
5. 实现准实时同步
各方案对比:
| 方案 | 一致性 | 性能影响 | 复杂度 |
|---|---|---|---|
| Cache-Aside(删缓存) | 最终一致 | 低 | 低 |
| 延时双删 | 较强一致 | 中 | 中 |
| Binlog 订阅 | 准实时一致 | 低 | 高 |
| 写数据库同时写缓存 | 强一致 | 高 | 中 |
4. 怎么使用
Cache-Aside Pattern 实现:
@Service
public class UserService {
@Autowired
private Jedis jedis;
public User getUser(Long id) {
String key = "user:" + id;
// 1. 读缓存
String cached = jedis.get(key);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
// 2. 缓存未命中,读数据库
User user = userMapper.selectById(id);
if (user != null) {
// 3. 写入缓存
jedis.setex(key, 3600, JSON.toJSONString(user));
}
return user;
}
public void updateUser(User user) {
// 1. 写数据库
userMapper.updateById(user);
// 2. 删除缓存(不是更新)
jedis.del("user:" + user.getId());
}
}
延时双删实现:
public void updateUserWithDoubleDelete(User user) {
// 1. 写数据库
userMapper.updateById(user);
// 2. 立即删除缓存
jedis.del("user:" + user.getId());
// 3. 异步延时再删一次
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500); // 延时
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
jedis.del("user:" + user.getId());
});
}
5. 适用场景
| 场景 | 推荐方案 |
|---|---|
| 读多写少(如用户信息) | Cache-Aside |
| 写频繁但读更多(如库存) | Cache-Aside + 延时双删 |
| 强一致性要求(如金融) | Binlog 订阅 + 缓存更新 |
| 实时性要求高 | 写数据库同时发消息更新缓存 |
6. 不适用场景与替代方案
- 极端一致性要求 → 直接读数据库,不使用缓存
- 写多读少场景 → 不推荐用缓存(回源率高)
7. 优缺点与技术取舍
Cache-Aside 优点: 简单可靠,绝大多数场景适用 Cache-Aside 缺点: 存在短暂不一致窗口
延时双删优点: 减少不一致窗口 延时双删缺点: 无法完全消除不一致,延时时间难以确定
Binlog 订阅优点: 准实时同步,一致性高 Binlog 订阅缺点: 实现复杂,需要额外组件
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 先写 DB 还是先删缓存? | 先写 DB 再删缓存(Cache-Aside) |
| 删除缓存失败怎么办? | 重试机制 + 消息队列最终一致性 |
| 并发更新导致不一致 | 加分布式锁或使用版本号控制 |
9. 版本差异与实现边界
缓存一致性与 Redis 版本无关,是应用层面的设计模式。Redis 本身提供了事务(MULTI/EXEC)和 Lua 脚本用于保证单操作的原子性。
10. 常见追问
- Q: 为什么不先删缓存再写数据库? A: 会导致更严重的不一致。线程 A 删缓存 → 线程 B 读缓存未命中 → 线程 B 读数据库(旧值) → 线程 B 写缓存(旧值) → 线程 A 写数据库(新值)。最终缓存是旧值,数据库是新值。
- Q: 延时双删的延时时间怎么确定? A: 取决于业务的数据库操作时间和缓存回源时间。通常 100ms-1s,需要根据实际情况调整。
- Q: 如何保证删除缓存一定成功? A: 使用消息队列(如 RocketMQ)发送删除消息,通过消费重试保证最终一致性。
11. 易错点
- ❌ 错误:可以做到完全一致。✅ 正确:缓存和数据库是两个独立系统,只能做到最终一致。
- ❌ 错误:写缓存比删缓存好。✅ 正确:删缓存更可靠,因为下次读会自动回填最新值。
- ❌ 错误:Cache-Aside 是强一致方案。✅ 正确:它是最终一致,存在短暂不一致窗口。
一句话总结
缓存一致性没有完美方案,Cache-Aside Pattern(先写数据库再删缓存)是最常用的方案,高一致性场景可配合延时双删或 Binlog 订阅实现。
什么是大key问题?怎么处理?
原始问法:
- 什么是大key问题?怎么处理?
来源题目:
SRC-06-64-277
面试先答
大 Key 是指 Size 很大的 Redis Key——通常 String 值超过 10KB、Hash/List/Set/ZSet 元素超过 5000 个。大 Key 的危害包括:删除时阻塞主线程(DEL 命令同步删除大 Key 可能阻塞数秒)、网络传输慢(读取时占用大量带宽)、集群迁移时阻塞、内存不均(Cluster 分片数据倾斜)。解决方案包括:拆分大 Key 为多个小 Key、使用异步删除(UNLINK)、定期扫描和清理大 Key。
核心结论
- 大 Key 指值超过 10KB 或元素超过 5000 的 Key。
- 危害:阻塞主线程、网络瓶颈、集群不均。
- 解决方案:拆分、异步删除、定期扫描。
1. 是什么
大 Key(Big Key)是指在 Redis 中占用内存较大的 Key:
| 数据类型 | 大 Key 判定标准 |
|---|---|
| String | Value 超过 10KB |
| Hash | 元素数量超过 5000 个 |
| List | 元素数量超过 5000 个 |
| Set | 元素数量超过 5000 个 |
| ZSet | 元素数量超过 5000 个 |
2. 为什么需要它
大 Key 的危害:
- 阻塞删除:DEL 命令同步删除大 Key 时,会阻塞 Redis 主线程数秒甚至更久
- 网络瓶颈:读取大 Key 时,数据传输占用大量带宽
- 集群迁移:Cluster 模式下,大 Key 的 slot 迁移耗时很长
- 内存不均:集群模式下,大 Key 导致某个分片内存远超其他分片
3. 底层原理与完整流程
大 Key 删除阻塞原理:
DEL big_key(Hash,10000 元素)
→ 主线程执行
→ 遍历所有元素,逐一释放内存
→ 耗时可能达数百毫秒甚至数秒
→ 期间所有其他命令被阻塞
异步删除原理(UNLINK):
UNLINK big_key
→ 主线程:从数据库字典移除 Key(O(1))
→ 后台线程:异步释放内存
→ 主线程不阻塞
大 Key 扫描流程:
redis-cli --bigkeys
→ 使用 SCAN 渐进遍历所有 Key
→ 检查每个 Key 的大小
→ 报告超过阈值的 Key
Redis 6.0+ 的内存分析:
MEMORY USAGE key [SAMPLES count]
→ 估算指定 Key 的内存占用
→ SAMPLES:抽样数量(默认 5)
→ 对于大 Hash/ZSet,抽样估算更高效
4. 怎么使用
异步删除大 Key:
# Redis 4.0+ 支持异步删除
redis-cli UNLINK big_key
# 查看异步删除进度
redis-cli INFO stats | grep async_deferred
扫描大 Key:
# 扫描大 Key
redis-cli --bigkeys
# 限制扫描时间
redis-cli --bigkeys -i 0.1 # 每 100ms 暂停 10ms
拆分大 Key:
// 方案一:Hash 拆分为多个小 Hash
// 原 Key: user:1:followers(10000 个粉丝)
// 拆分为:user:1:followers:0, user:1:followers:1, ..., user:1:followers:9
// 每个分片 1000 个
int batchSize = 1000;
for (int i = 0; i < totalFollowers; i += batchSize) {
String shardKey = "user:1:followers:" + (i / batchSize);
// HSET shardKey ...
}
// 方案二:List 拆分为多个 List
// 使用 LPUSH 分批写入
// 方案三:ZSet 按分数范围拆分
// rank:0-1000, rank:1001-2000, ...
定期大 Key 扫描脚本:
@Scheduled(fixedRate = 600000) // 每 10 分钟
public void scanBigKeys() {
String cursor = "0";
do {
ScanParams params = new ScanParams().count(500);
ScanResult<String> result = jedis.scan(cursor, params);
cursor = result.getCursor();
for (String key : result.getResult()) {
long size = jedis.memoryUsage(key);
if (size > 10 * 1024) { // 超过 10KB
log.warn("发现大 Key: {}, 大小: {}KB", key, size / 1024);
// 通知或自动处理
}
}
} while (!"0".equals(cursor));
}
5. 适用场景
- 定期排查和清理大 Key
- 新系统上线前的大 Key 预防
- Cluster 分片前的大 Key 处理
6. 不适用场景与替代方案
- 动态变化的大 Key → 拆分或使用其他存储
- 超大数据集合 → 考虑使用专业数据库(如 MongoDB、Elasticsearch)
7. 优缺点与技术取舍
拆分优点: 避免阻塞、负载均衡 拆分缺点: 查询逻辑复杂、需要维护分片策略
异步删除优点: 不阻塞主线程 异步删除缺点: 后台释放内存有延迟
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| DEL 阻塞主线程 | 使用 UNLINK 异步删除 |
| 大 Key 导致集群不均 | 拆分大 Key 到不同分片 |
| 大 Key 迁移超时 | 避免创建超大 Key 或使用分片迁移 |
9. 版本差异与实现边界
| 版本 | 大 Key 处理支持 |
|---|---|
| Redis 4.0 之前 | 只能同步 DEL,阻塞严重 |
| Redis 4.0+ | 支持 UNLINK 异步删除 |
| Redis 6.0+ | MEMORY USAGE 更精确、Lazyfree 优化 |
| Redis 7.0 | 更强的内存分析工具 |
10. 常见追问
- Q: 怎么预防大 Key? A:在设计阶段限制 Key 的大小,Hash/Set/ZSet 元素不超过 5000 个;使用 SCAN 定期扫描;代码审查时关注 Key 的创建逻辑。
- Q: UNLINK 删除的 Key 能被查到吗? A:不能。UNLINK 立即从数据库字典移除,后台异步释放内存。期间该 Key 对客户端不可见。
- Q: 大 Key 会导致什么生产事故? A:DEL 阻塞可能导致 Redis 超时、主从断连、集群 failover。极端情况下可能导致 Redis 雪崩。
11. 易错点
- ❌ 错误:大 Key 只能手动处理。✅ 正确:可以通过脚本自动扫描和处理。
- ❌ 错误:UNLINK 会立即释放内存。✅ 正确:UNLINK 立即从字典移除,但内存由后台线程异步释放。
- ❌ 错误:大 Key 只影响删除操作。✅ 正确:大 Key 还影响读取、集群迁移、持久化等多个方面。
一句话总结
大 Key 是 Redis 生产中的隐患,通过异步删除(UNLINK)、拆分和定期扫描来预防和处理,避免阻塞和集群问题。
Redis内存碎片率高的原因是什么?如何排查和优化?
原始问法:
- Redis内存碎片率高的原因是什么?如何排查和优化?
来源题目:
SRC-06-64-278
面试先答
Redis 内存碎片率 = 实际物理内存使用量 / 分配器分配的内存量。碎片率高的原因有三:① 频繁创建和销毁不同大小的 Key,导致内存分配器无法高效复用;② 使用默认的 jemalloc 分配器在特定场景下碎片率较高;③ 保存了大量过期 Key 未清理。排查方法是通过 INFO memory 查看 mem_fragmentation_ratio,使用 MEMORY PURGE 主动清理。优化手段包括:开启主动碎片整理(activedefrag)、使用 jemalloc 分配器、合理设计 Key 大小。
核心结论
- 内存碎片率 = used_memory_rss / used_memory,理想值在 1.0 左右。
- 碎片率高的原因:频繁变更 Key、分配器效率、过期 Key 堆积。
- 优化:开启 activedefrag、MEMORY PURGE、使用 jemalloc。
1. 是什么
内存碎片率(Memory Fragmentation Ratio): Redis 实际占用的物理内存(RSS)与分配器分配给 Redis 的内存之比。
碎片率 = used_memory_rss / used_memory
- 碎片率 < 1.0:说明 Redis 使用的内存比分配的少(通常不可能)
- 碎片率 ≈ 1.0:内存使用高效,几乎无碎片
- 碎片率 > 1.5:碎片严重,需要优化
2. 为什么需要它
内存碎片率过高会导致:
- 实际物理内存远超逻辑数据量
- 可能触发操作系统的 OOM Killer
- Redis 频繁触发内存淘汰
- 增加系统内存压力
3. 底层原理与完整流程
碎片产生原因:
原因一:频繁创建和销毁 Key
→ 分配器分配内存时产生对齐间隙
→ 反复分配/释放后,内存出现碎片
原因二:jemalloc 分配器行为
→ jemalloc 按 size class 分配
→ 不同大小对象使用不同 size class
→ 对象释放后,相邻的空闲块可能无法合并
原因三:过期 Key 堆积
→ 大量过期 Key 未被及时清理
→ 占用的内存无法被复用
jemalloc 分配原理:
jemalloc 将内存划分为 arena:
- 每个 arena 有多个 size class
- 小对象(< 128KB)使用不同的 size class
- 大对象使用独立的 page 分配
- 碎片来自不同 size class 之间的内存无法复用
碎片整理机制(Redis 4.0+):
activedefrag(主动碎片整理):
→ 后台线程定期扫描内存
→ 将分散的小对象移动到连续内存区域
→ 更新数据指针
→ 不阻塞主线程
4. 怎么使用
查看碎片率:
redis-cli INFO memory
# 关注:
# used_memory: Redis 分配的内存
# used_memory_rss: 操作系统实际分配的内存
# mem_fragmentation_ratio: 碎片率
主动清理碎片:
# 手动触发碎片整理(Redis 4.0+)
redis-cli MEMORY PURGE
# 查看碎片统计
redis-cli MEMORY DOCTOR
# 查看大 Key 分布
redis-cli MEMORY STATS
开启自动碎片整理:
# Redis 配置
activedefrag yes
active-defrag-ignore-bytes 100mb # 小于此值忽略
active-defrag-threshold-lower 10 # 碎片率低于此值不整理
active-defrag-threshold-upper 100 # 碎片率高于此值强制整理
active-defrag-cycle-min 5 # 整理周期最小值
active-defrag-cycle-max 25 # 整理周期最大值
查看和调整配置:
redis-cli CONFIG GET activedefrag
redis-cli CONFIG SET activedefrag yes
5. 适用场景
- 内存碎片率 > 1.5 时需要清理
- 频繁创建销毁 Key 的场景
- 使用 Redis 做缓存且 Key 大小差异大的场景
6. 不适用场景与替代方案
- 碎片率 < 1.2 时无需处理
- 实时性要求极高的场景(碎片整理有 CPU 开销)→ 在低峰期执行
7. 优缺点与技术取舍
主动碎片整理优点: 自动整理,无需手动干预 主动碎片整理缺点: 有 CPU 开销,可能影响延迟
MEMORY PURGE 优点: 立即清理 MEMORY PURGE 缺点: 可能短暂阻塞
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 碎片率持续升高 | 开启 activedefrag + 定期 MEMORY PURGE |
| MEMORY PURGE 阻塞 | 在低峰期执行、启用 activedefrag |
| jemalloc 碎片率高 | 确认使用 jemalloc(redis-server --version) |
9. 版本差异与实现边界
| 版本 | 内存管理特性 |
|---|---|
| Redis 4.0 之前 | 不支持主动碎片整理 |
| Redis 4.0 | 引入 MEMORY PURGE 和 activedefrag |
| Redis 6.0 | 优化碎片整理算法 |
| Redis 7.0 | 更智能的碎片整理策略 |
10. 常见追问
- Q: 碎片率多少算正常? A: 1.0 附近最好,1.0-1.5 可接受,> 1.5 需要优化,> 2.0 严重。
- Q: 为什么用 jemalloc? A: jemalloc 是 Facebook 开源的内存分配器,在多线程场景下性能优于 glibc 的 ptmalloc。
- Q: actdefrag 会影响性能吗? A: 会消耗 CPU,但可以配置周期和阈值,在可控范围内。建议开启。
11. 易错点
- ❌ 错误:碎片率高一定是 bug。✅ 正确:Key 频繁创建/释放是正常的碎片来源。
- ❌ 错误:MEMORY PURGE 一定能清理所有碎片。✅ 正确:它能清理大部分碎片,但无法做到零碎片。
- ❌ 错误:碎片率只能靠手动处理。✅ 正确:Redis 4.0+ 支持主动碎片整理。
一句话总结
Redis 内存碎片率由 Key 频繁变更和分配器行为导致,通过开启 activedefrag 和 MEMORY PURGE 可以有效控制碎片率。
为什么不用本地缓存?本地缓存和分布式缓存如何选择?
原始问法:
- 为什么不用本地缓存?本地缓存和分布式缓存如何选择?
来源题目:
SRC-06-64-279
面试先答
本地缓存(如 Caffeine、Guava Cache、EhCache)将数据存储在 JVM 内存中,访问速度极快(纳秒级),但在多实例部署时存在数据不一致问题。分布式缓存(主要是 Redis)将数据存储在独立的缓存服务中,多个实例共享同一数据,天然支持分布式一致性。选择依据是:如果数据变化少、各实例独立(如配置数据),本地缓存更优;如果数据需要多实例共享、一致性要求高(如用户信息、商品库存),分布式缓存更优。生产中常组合使用——本地缓存做 L1,Redis 做 L2。
核心结论
- 本地缓存速度快但不支持分布式共享。
- 分布式缓存支持多实例共享但有网络开销。
- 生产中通常组合使用,形成多级缓存架构。
1. 是什么
本地缓存(Local Cache): 基于 JVM 内存的缓存,如 Caffeine、Guava Cache、EhCache、ConcurrentHashMap。
| 特性 | Caffeine | Guava Cache | EhCache |
|---|---|---|---|
| 过期策略 | 基于时间/访问 | 基于时间/访问 | 基于时间/磁盘 |
| 最大容量 | 基于权重/数量 | 基于权重/数量 | 基于内存/磁盘 |
| 统计功能 | 内置 | 内置 | 内置 |
| 适用场景 | 高并发、需要精细控制 | 通用 | 需要磁盘持久化 |
分布式缓存(Distributed Cache): 独立部署的缓存服务,如 Redis、Memcached。
2. 为什么需要它
只用本地缓存的问题:
- 多实例数据不一致:实例 A 更新数据,实例 B 看不到
- 缓存击穿难以防护:多实例可能同时回源
- 缓存穿透难以防护:每个实例独立判断
只用分布式缓存的问题:
- 每次访问都有网络开销(毫秒级 vs 纳秒级)
- 对热点 Key,网络成为瓶颈
3. 底层原理与完整流程
多级缓存架构(推荐):
客户端请求
→ L1 本地缓存(Caffeine)
→ 命中:返回(纳秒级)
→ 未命中:
→ L2 分布式缓存(Redis)
→ 命中:返回(毫秒级)+ 写入 L1
→ 未命中:
→ L3 数据库(MySQL)
→ 命中:返回 + 写入 L1 和 L2
→ 未命中:返回空
多级缓存数据一致性:
写流程:
1. 写数据库
2. 删除 L2(Redis)缓存
3. 通知所有实例删除 L1(本地缓存)
→ 方案:Redis Pub/Sub、MQ 广播、版本号机制
本地缓存失效通知:
方案一:Redis Pub/Sub
写操作 → 发布 "cache:evict:{key}" → 所有实例订阅 → 删除本地缓存
方案二:版本号
每个 Key 关联版本号
写入时版本号 +1
本地缓存存储版本号
读取时检查版本号是否最新
方案三:短 TTL
本地缓存设置较短 TTL(如 30s)
自动过期保证最终一致
4. 怎么使用
Caffeine 本地缓存示例:
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;
// 创建本地缓存
Cache<String, User> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.recordStats()
.build();
// 读取
User user = localCache.get("user:" + id, key -> {
// 缓存未命中,回源 Redis
String cached = jedis.get(key);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
// Redis 也未命中,回源数据库
User dbUser = userMapper.selectById(id);
if (dbUser != null) {
jedis.setex(key, 3600, JSON.toJSONString(dbUser));
}
return dbUser;
});
多级缓存封装:
@Service
public class MultiLevelCache {
private final Cache<String, Object> localCache; // L1
private final Jedis jedis; // L2
public <T> T get(String key, Class<T> type, Supplier<T> loader) {
// L1
Object cached = localCache.getIfPresent(key);
if (cached != null) {
return (T) cached;
}
// L2
String redisVal = jedis.get(key);
if (redisVal != null) {
T value = JSON.parseObject(redisVal, type);
localCache.put(key, value); // 回填 L1
return value;
}
// L3
T value = loader.get();
if (value != null) {
jedis.setex(key, 3600, JSON.toJSONString(value));
localCache.put(key, value);
}
return value;
}
public void evict(String key) {
localCache.invalidate(key);
jedis.del(key);
}
}
5. 适用场景
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 配置数据 | 本地缓存 | 变化少、各实例独立 |
| 用户信息 | 本地 + Redis | 高频访问、需要共享 |
| 热点商品 | 本地 + Redis | 极高并发、数据共享 |
| 计数器 | Redis | 原子操作、分布式共享 |
| 会话存储 | Redis | 需要跨实例共享 |
6. 不适用场景与替代方案
| 场景 | 不推荐 | 原因 |
|---|---|---|
| 频繁更新的数据 | 本地缓存 | 一致性无法保证 |
| 需要强一致的数据 | 本地缓存 | 多实例数据不同步 |
| 大规模数据 | 本地缓存 | JVM 内存限制 |
7. 优缺点与技术取舍
本地缓存优点: 速度极快(纳秒级)、无网络开销 本地缓存缺点: 不支持分布式、数据不一致
Redis 优点: 支持分布式、数据共享、丰富功能 Redis 缺点: 有网络开销、单点风险
多级缓存优点: 兼顾速度和一致性 多级缓存缺点: 架构复杂、一致性管理复杂
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 本地缓存不一致 | 使用 Pub/Sub 通知失效、设置短 TTL |
| 本地缓存内存占用 | 设置最大容量和过期时间 |
| 多级缓存管理复杂 | 使用框架(如 Spring Cache + Caffeine + Redis) |
9. 版本差异与实现边界
- Caffeine:Java 8+ 高性能本地缓存库,Guava Cache 的替代
- Redis 6.0+ 客户端缓存:Redis 原生支持客户端缓存,简化多级缓存
10. 常见追问
- Q: 本地缓存的一致性怎么保证? A: 三种方式:① 写时通知所有实例失效(Pub/Sub);② 设置较短 TTL 自动过期;③ 版本号校验。生产中通常组合使用。
- Q: Caffeine 和 Guava Cache 怎么选? A: Caffeine 性能更高(基于 Java 8+),支持异步加载,是 Guava Cache 的官方继任者。
- Q: 为什么 Redis 不直接解决所有缓存问题? A: Redis 有网络开销(毫秒级),对于极高并发的热点数据(如配置、用户信息),本地缓存的纳秒级延迟可以大幅降低 RT。
11. 易错点
- ❌ 错误:本地缓存比 Redis 好。✅ 正确:各有适用场景,通常组合使用。
- ❌ 错误:多级缓存增加复杂度,不值得。✅ 正确:对于读多写少的热点场景,多级缓存可以显著降低延迟和 Redis 压力。
- ❌ 错误:本地缓存不需要过期。✅ 正确:本地缓存必须设置过期时间或主动失效机制,否则会导致数据不一致。
一句话总结
本地缓存速度快但不支持分布式,Redis 支持分布式但有网络开销,多级缓存(本地+Redis)是性能和一致性的最佳平衡方案。
第 6.5 节 分布式锁
分布式锁的原理是什么?设计分布式锁需要考虑哪些问题?
原始问法:
- 分布式锁的原理是什么?设计分布式锁需要考虑哪些问题?
来源题目:
SRC-06-65-280
面试先答
分布式锁是在分布式环境下实现跨进程互斥的一种协调机制。核心原理是通过共享存储(如 Redis、ZooKeeper)实现:所有进程竞争同一个锁资源,抢到锁的进程执行临界区代码,释放锁后其他进程才能获取。设计分布式锁需要考虑的核心问题:① 互斥性——同一时刻只有一个进程能持有锁;② 死锁避免——进程崩溃后锁能自动释放;③ 可重入性——同一进程内可重复获取同一锁;④ 原子性——加锁和释放锁必须是原子操作;⑤ 高性能——获取和释放锁的开销要小。
核心结论
- 分布式锁通过共享存储实现跨进程互斥。
- 核心要素:互斥性、死锁避免、可重入性、原子性、高性能。
- Redis 是实现分布式锁最常用的方案。
1. 是什么
分布式锁是一种跨进程、跨机器的互斥机制,确保在分布式环境中,同一时刻只有一个节点能执行某段临界区代码。
与本地锁的对比:
| 特性 | 本地锁(synchronized/ReentrantLock) | 分布式锁 |
|---|---|---|
| 作用范围 | 单个 JVM | 多个 JVM/机器 |
| 实现方式 | JVM 内存 | 共享存储(Redis/ZK) |
| 性能 | 极高(微秒级) | 较高(毫秒级) |
| 复杂度 | 简单 | 较复杂 |
| 可靠性 | 依赖 JVM | 依赖外部组件 |
2. 为什么需要它
在单机环境中,我们用 synchronized 或 ReentrantLock 实现互斥。但在分布式环境中:
- 多个服务实例独立运行
- JVM 级别的锁无法跨进程
- 需要一个全局协调机制来保证互斥
典型场景:秒杀下单、定时任务避免重复执行、分布式 ID 生成。
3. 底层原理与完整流程
Redis 分布式锁原理:
加锁:
SET lock_key unique_value NX PX expire_time
→ NX:仅当 key 不存在时设置(保证互斥)
→ PX:设置过期时间(毫秒,防止死锁)
解锁:
Lua 脚本保证原子性:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
→ 先检查锁的持有者标识(防止误删别人的锁)
→ 再删除锁(原子操作)
设计分布式锁需要考虑的问题:
| 问题 | 说明 | 解决方案 |
|---|---|---|
| 互斥性 | 同时只有一个客户端持有锁 | SET NX 原子操作 |
| 死锁避免 | 客户端崩溃后锁不释放 | 设置过期时间(PX) |
| 可重入性 | 同一线程可重复获取锁 | 计数器 + 唯一标识 |
| 原子性 | 加锁/解锁必须原子 | SET 命令 + Lua 脚本 |
| 误删锁 | A 的锁被 B 误删 | 唯一标识 + 检查 |
| 锁续期 | 业务执行时间超过过期时间 | 看门狗自动续期 |
| 公平性 | 按等待顺序获取锁 | 队列 + 超时重试 |
4. 怎么使用
手动实现最简分布式锁:
public class SimpleRedisLock {
private final Jedis jedis;
public String lock(String key, long expireMs) {
String requestId = UUID.randomUUID().toString();
String result = jedis.set(key, requestId, "NX", "PX", expireMs);
if ("OK".equals(result)) {
return requestId;
}
return null; // 获取锁失败
}
public boolean unlock(String key, String requestId) {
String luaScript =
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
" return redis.call('DEL', KEYS[1]) " +
"else return 0 end";
Long result = jedis.eval(luaScript,
Collections.singletonList(key),
Collections.singletonList(requestId));
return result == 1L;
}
}
使用 Redisson(推荐):
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
RLock lock = redisson.getLock("myLock");
// 加锁(30s 自动过期,看门狗续期)
lock.lock();
try {
// 临界区代码
doSomething();
} finally {
lock.unlock(); // 解锁
}
5. 适用场景
| 场景 | 说明 |
|---|---|
| 秒杀下单 | 防止同一商品被多扣库存 |
| 定时任务 | 避免多实例重复执行同一任务 |
| 分布式 ID | 保证 ID 生成的唯一性 |
| 缓存更新 | 防止缓存击穿时的并发回源 |
| 资源分配 | 分布式环境下的资源互斥使用 |
6. 不适用场景与替代方案
| 场景 | 不推荐 Redis 锁 | 替代方案 |
|---|---|---|
| 强一致性要求(如金融) | Redis 锁依赖网络,可能丢锁 | ZooKeeper/etcd |
| 极高并发 | 锁竞争导致性能下降 | 乐观锁(版本号)、CAS |
| 超长临界区 | 锁持有时间过长,影响吞吐 | 拆分临界区或使用其他协调方式 |
7. 优缺点与技术取舍
Redis 锁优点: 性能高(毫秒级)、实现简单、功能丰富 Redis 锁缺点: 依赖 Redis 可用性、存在锁失效风险
ZooKeeper 锁优点: 强一致性、可靠 ZooKeeper 锁缺点: 性能低、实现复杂、需额外组件
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 锁超时业务未完成 | 看门狗自动续期、合理设置过期时间 |
| Redis 主从切换导致锁丢失 | Redlock 算法(多实例) |
| 锁被误删 | 使用唯一标识 + Lua 检查 |
| 加锁失败重试 | 自旋 + 退避策略 |
9. 版本差异与实现边界
| 版本 | 锁相关支持 |
|---|---|
| Redis 2.6.12+ | SET 命令支持 NX PX 参数 |
| Redis 4.0+ | 更好的 Lua 脚本支持 |
| Redisson 3.x | 成熟的分布式锁实现 |
10. 常见追问
- Q: 为什么用 SET 而不是 SETNX + EXPIRE? A: SETNX + EXPIRE 是两个命令,非原子。如果 SETNX 成功后进程崩溃,锁不会过期。SET 是原子命令,一步完成加锁和过期。
- Q: 分布式锁的过期时间怎么设置? A: 过期时间应大于业务最大执行时间。通常设置为业务时间的 2-3 倍,配合看门狗续期。
- Q: Redis 锁和 ZooKeeper 锁怎么选? A: 性能敏感场景选 Redis(毫秒级),一致性要求高的场景选 ZooKeeper(强一致但慢)。
11. 易错点
- ❌ 错误:SETNX 可以单独实现分布式锁。✅ 正确:SETNX 不能设置过期时间,会导致死锁。必须配合 EXPIRE,但两者非原子。
- ❌ 错误:分布式锁一定能防止并发。✅ 正确:在 Redis 主从切换等极端场景下,锁可能失效。
- ❌ 错误:解锁不需要校验。✅ 正确:必须校验锁的持有者标识,防止误删他人的锁。
一句话总结
分布式锁通过共享存储实现跨进程互斥,核心要素包括互斥性、死锁避免、可重入性和原子性,Redis 是最常用的实现方案。
如何基于Redis实现分布式锁?
原始问法:
- 如何基于Redis实现分布式锁?
来源题目:
SRC-06-65-281
面试先答
基于 Redis 实现分布式锁的标准方案是使用 SET key value NX PX expire 命令:加锁时,用唯一标识值通过 SET NX PX 原子操作实现互斥加锁和过期设置;解锁时,用 Lua 脚本先校验锁的持有者再删除,保证原子性。一个完整的 Redis 分布式锁需要包含:唯一标识(UUID)、过期时间、Lua 脚本解锁、可重入计数、看门狗续期等要素。生产中推荐使用 Redisson 框架。
核心结论
- Redis 分布式锁的核心是 SET NX PX 原子命令 + Lua 解锁脚本。
- 关键要素:唯一标识、原子加锁、原子解锁、自动续期。
- Redisson 是生产级实现,简化了使用。
1. 是什么
基于 Redis 的分布式锁利用 Redis 的单线程原子性,通过 SET 命令的 NX(不存在时设置)和 PX(毫秒过期)参数实现互斥加锁。
2. 为什么需要它
相比其他分布式锁方案(如 ZooKeeper),Redis 分布式锁具有:
- 性能更高(毫秒级 vs 百毫秒级)
- 实现更简单
- 复用已有 Redis 基础设施,无需额外组件
3. 底层原理与完整流程
加锁流程:
1. 生成唯一标识(UUID + 线程ID)
2. SET lock_key requestId NX PX expireTime
→ NX:key 不存在才设置(互斥保证)
→ PX:设置毫秒级过期(死锁避免)
3. 返回 OK → 加锁成功
4. 返回 nil → 加锁失败,重试或快速失败
解锁流程(Lua 脚本):
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
→ 先 GET 检查持有者标识
→ 匹配则 DEL(原子操作)
→ 不匹配则不删除(防止误删)
可重入锁实现:
加锁时:
HINCRBY lock_key:count threadId 1
如果计数 == 1:
SET lock_key threadId+count PX expireTime
否则:
续期
解锁时:
HINCRBY lock_key:count threadId -1
如果计数 == 0:
DEL lock_key
DEL lock_key:count
看门狗续期:
加锁成功后,启动看门狗线程:
→ 每隔 lockWatchdogTimeout/3(默认 10s)检查
→ 如果锁还被当前线程持有,续期到 lockWatchdogTimeout(默认 30s)
→ 如果锁已释放或 Redis 不可达,停止续期
4. 怎么使用
标准实现(完整代码):
public class RedisDistributedLock implements AutoCloseable {
private static final String UNLOCK_SCRIPT =
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
" return redis.call('DEL', KEYS[1]) " +
"else return 0 end";
private final Jedis jedis;
private final String lockKey;
private final String requestId;
private final long expireMs;
private final Timer watchdogTimer;
public RedisDistributedLock(Jedis jedis, String lockKey, long expireMs) {
this.jedis = jedis;
this.lockKey = lockKey;
this.requestId = UUID.randomUUID() + ":" +
Thread.currentThread().getId();
this.expireMs = expireMs;
this.watchdogTimer = new Timer(true);
}
public boolean tryLock(long timeoutMs) {
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
String result = jedis.set(lockKey, requestId,
"NX", "PX", expireMs);
if ("OK".equals(result)) {
startWatchdog();
return true;
}
try {
Thread.sleep(50); // 退避
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}
private void startWatchdog() {
long period = expireMs / 3; // 每 1/3 过期时间续期
watchdogTimer.schedule(new TimerTask() {
@Override
public void run() {
String current = jedis.get(lockKey);
if (requestId.equals(current)) {
jedis.pexpire(lockKey, expireMs);
} else {
watchdogTimer.cancel();
}
}
}, period, period);
}
public boolean unlock() {
watchdogTimer.cancel();
Long result = jedis.eval(UNLOCK_SCRIPT,
Collections.singletonList(lockKey),
Collections.singletonList(requestId));
return result == 1L;
}
@Override
public void close() {
unlock();
}
}
使用 Redisson(生产推荐):
RedissonClient redisson = Redisson.create(config);
// 可重入锁
RLock lock = redisson.getLock("order:lock");
boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (acquired) {
try {
// 业务逻辑
} finally {
lock.unlock();
}
}
// 公平锁(按等待顺序获取)
RLock fairLock = redisson.getFairLock("fair:lock");
// 读写锁
RReadWriteLock rwLock = redisson.getReadWriteLock("rw:lock");
rwLock.writeLock().lock(); // 写锁
rwLock.readLock().lock(); // 读锁
5. 适用场景
- 秒杀、抢购等限并发操作
- 分布式定时任务调度
- 分布式 ID 生成
- 数据库/缓存的互斥更新
6. 不适用场景与替代方案
| 场景 | 替代方案 |
|---|---|
| 极强一致性 | ZooKeeper/etcd |
| 极高并发 | 乐观锁(版本号)、消息队列削峰 |
| 跨语言锁 | 使用语言无关的序列化协议 |
7. 优缺点与技术取舍
优点: 高性能、简单、复用已有 Redis 缺点: 依赖 Redis、极端情况可能失效
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 加锁阻塞 | tryLock 超时获取,设置合理的超时时间 |
| 续期失败 | 检查 Redis 连接、增加重试 |
| 锁被抢占 | 使用 Redlock 多实例方案 |
9. 版本差异与实现边界
SET 命令的 NX PX 参数从 Redis 2.6.12 开始支持,这是实现原子加锁的基础。
10. 常见追问
- Q: 如何保证锁的可重入性? A: 在 Hash 中存储每个线程的加锁次数,计数 > 0 时每次进入都 +1,计数降为 0 时才真正删除锁。
- Q: 看门狗续期的频率怎么确定? A: 默认是过期时间的 1/3(30s 过期则 10s 续期)。确保在锁过期前完成续期。
- Q: 如何防止锁被业务执行完后仍持有? A: 设置合理的过期时间 + 看门狗续期。业务完成后立即释放锁。
11. 易错点
- ❌ 错误:加锁后不需要检查返回值。✅ 正确:必须检查 SET 命令返回值,确认加锁成功。
- ❌ 错误:解锁可以用 DEL 命令。✅ 正确:必须用 Lua 脚本校验后再删除,防止误删。
- ❌ 错误:锁的过期时间固定。✅ 正确:应根据业务执行时间动态设置,或使用看门狗续期。
一句话总结
基于 Redis 的分布式锁通过 SET NX PX 原子加锁 + Lua 脚本原子解锁实现,配合唯一标识和看门狗续期,生产推荐使用 Redisson 框架。
SETNX的功能是什么?先执行set nx再执行set ex会存在什么问题?
原始问法:
- SETNX的功能是什么?先执行set nx再执行set ex会存在什么问题?
来源题目:
SRC-06-65-282
面试先答
SETNX(SET if Not eXists)是仅当 Key 不存在时才设置值的命令,常用于实现分布式锁。但先 SETNX 再 SET EX 会导致严重的原子性问题:如果 SETNX 成功后、SET EX 之前进程崩溃或网络中断,锁将永远不会过期,形成死锁。正确做法是使用 Redis 2.6.12 提供的 SET key value NX PX expire 原子命令,一步完成加锁和过期设置。
核心结论
- SETNX 仅当 Key 不存在时设置值。
- SETNX + EXPIRE 是非原子的,可能导致死锁。
- 正确做法是 SET NX PX 原子命令。
1. 是什么
SETNX 命令:
SETNX key value
→ 仅当 key 不存在时,设置 key 的值为 value
→ 如果 key 已存在,不做任何操作
→ 返回 1:设置成功
→ 返回 0:key 已存在,未设置
与 SET 的区别:
SET key value [NX|XX] [EX seconds|PX milliseconds]
→ NX:仅 key 不存在时设置(等价于 SETNX + SET)
→ XX:仅 key 已存在时设置
→ EX/PX:设置过期时间
→ 原子操作,一步完成
2. 为什么需要它
SETNX 是实现分布式锁的基础命令。通过 NX 语义确保同一时刻只有一个客户端能设置成功,实现互斥。
3. 底层原理与完整流程
错误做法(非原子):
步骤 1:SETNX lock_key value → 加锁(成功返回 1)
步骤 2:EXPIRE lock_key 30 → 设置过期时间(30 秒)
问题场景:
→ 步骤 1 执行成功
→ 步骤 2 执行前,进程崩溃/OOM
→ lock_key 永不过期
→ 死锁!
正确做法(原子):
SET lock_key value NX PX 30000
→ 一步完成:加锁 + 设置 30 秒过期
→ 原子操作,不存在中间状态
→ 即使进程崩溃,锁也会在 30 秒后自动释放
SET 命令的原子性保证:
Redis 单线程执行:
→ SET 命令的所有参数在同一事件循环中执行
→ NX 检查和 PX 设置在同一个执行单元
→ 不会被其他命令中断
4. 怎么使用
# 错误做法(不要用)
SETNX lock_key unique_value
EXPIRE lock_key 30
# 正确做法
SET lock_key unique_value NX PX 30000
# 或
SET lock_key unique_value NX EX 30
# 检查是否支持原子 SET
redis-cli INFO server | grep redis_version
# >= 2.6.12 支持
Java 中正确使用:
// 错误示范
if (jedis.setnx(lockKey, requestId) == 1) {
jedis.expire(lockKey, 30); // 非原子!危险!
}
// 正确示范
String result = jedis.set(lockKey, requestId,
"NX", "PX", 30000); // 原子操作
if ("OK".equals(result)) {
// 加锁成功
}
5. 适用场景
| 场景 | 命令 |
|---|---|
| 分布式锁(加锁) | SET key value NX PX expire |
| 分布式锁(检查获取) | SET key value NX |
| 幂等操作 | SETNX idempotent_key |
| 防重复提交 | SETNX submit_key |
6. 不适用场景与替代方案
- 不要用 SETNX + EXPIRE 组合 → 用 SET NX PX
- 需要复杂条件设置 → 使用 Lua 脚本
7. 优缺点与技术取舍
SETNX 优点: 语义清晰,实现互斥的基础 SETNX 缺点: 单独使用无法设置过期时间,必须配合 SET 原子命令
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| SETNX 成功但未设置过期 | 改用 SET NX PX 原子命令 |
| 旧版 Redis 不支持 SET NX PX | 使用 Lua 脚本保证原子性 |
9. 版本差异与实现边界
| 版本 | 支持情况 |
|---|---|
| Redis 2.6.12 之前 | SETNX + EXPIRE 组合,非原子 |
| Redis 2.6.12+ | SET 支持 NX PX 参数,原子操作 |
| Redis 7.0 | SET 命令支持更灵活的参数组合 |
10. 常见追问
- Q: 为什么 SETNX 不能单独做分布式锁? A: SETNX 只能设置值,不能设置过期时间。如果不加过期,进程崩溃后锁永远不释放,形成死锁。
- Q: 除了 SET NX PX,还有什么方式保证原子加锁? A: 可以用 Lua 脚本:
eval "return redis.call('SETNX', KEYS[1], ARGV[1]) and redis.call('PEXPIRE', KEYS[1], ARGV[2])" 1 lock_key value 30000。 - Q: XX 参数是什么? A: XX 表示仅当 Key 已存在时才设置。用于更新已存在的锁而不创建新锁。
11. 易错点
- ❌ 错误:SETNX 是原子的,加锁没问题。✅ 正确:SETNX 本身是原子的,但 SETNX + EXPIRE 组合不是原子的。
- ❌ 错误:所有 Redis 版本都支持 SET NX PX。✅ 正确:Redis 2.6.12 之前版本不支持。
- ❌ 错误:SETNX 可以设置过期时间。✅ 正确:SETNX 不支持过期时间,必须配合 SET 命令。
一句话总结
SETNX 是仅 Key 不存在时设置值的命令,单独使用无法设置过期时间,SETNX + EXPIRE 组合非原子可能导致死锁,正确做法是使用 SET NX PX 原子命令。
加锁后进程异常退出,锁泄露怎么办?
原始问法:
- 加锁后进程异常退出,锁泄露怎么办?
来源题目:
SRC-06-65-283
面试先答
锁泄露是指持有锁的进程异常退出(崩溃、OOM、被 kill),锁没有被主动释放,导致其他进程无法获取锁。解决方案有三层:① 设置过期时间——加锁时通过 PX 参数设置毫秒级过期时间,即使进程崩溃,Redis 也会在过期后自动删除 Key,这是最基础的保障;② 合理设置过期时间——过期时间应大于业务最大执行时间,通常设为业务时间的 2-3 倍;③ 看门狗机制——对于执行时间不确定的业务,使用看门狗线程定期续期,确保业务执行期间锁不会过期,业务完成后看门狗停止。
核心结论
- 锁泄露的根本解决方式是设置过期时间(PX 参数)。
- 过期时间应大于业务最大执行时间。
- 看门狗机制用于处理执行时间不确定的场景。
1. 是什么
锁泄露(Lock Leak)是指锁被持有后,由于持有者异常退出而无法主动释放,导致锁永远被占用。
2. 为什么需要它
如果锁不会自动释放:
- 进程崩溃后,锁永远不可用
- 所有后续请求无法获取锁
- 系统功能永久异常
3. 底层原理与完整流程
过期时间自动释放:
加锁时设置过期时间:
SET lock_key request_id NX PX 30000
→ 30 秒后 Redis 自动删除 lock_key
→ 即使进程崩溃,锁也会自动释放
Redis 内部实现:
→ lock_key 存入 expires 字典
→ 惰性删除 + 定期删除机制自动清理
看门狗续期机制:
看门狗线程:
→ 启动时记录锁的过期时间(如 30s)
→ 每隔 expire/3(如 10s)检查一次:
a. 当前进程是否还持有锁?(GET 检查唯一标识)
b. 如果是,续期到 30s(PEXPIRE)
c. 如果否,停止续期
→ 业务完成后,主动停止看门狗
Redisson 看门狗实现(源码分析):
// Redisson 默认看门狗
private long lockWatchdogTimeout = 30 * 1000; // 30 秒
// 加锁成功后启动看门狗
protected void scheduleExpirationRenewal(long threadId) {
RFuture<?> renewalFuture = new CompletableFuture();
renewalFuture.addListener((r, i) -> {
// 每 10 秒续期一次
timeout(renewalFuture, lockWatchdogTimeout / 3,
TimeUnit.MILLISECONDS);
});
// 续期逻辑
renewalFuture = renewalFuture.thenCompose(
res -> renewExpiration());
}
// 续期命令
RFuture<Boolean> renewExpiration() {
return commandExecutor.evalWriteAsync(
getLockKey(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
// Lua 脚本:检查持有者 + 续期
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
" redis.call('pexpire', KEYS[1], ARGV[1]); " +
" return 1; " +
"end; " +
"return 0;",
Collections.<Object>singletonList(getLockKey()),
lockWatchdogTimeout, getEntryName());
}
4. 怎么使用
手动设置过期时间:
# 加锁时设置 30 秒过期
SET order:lock uuid-123 NX PX 30000
Java 实现看门狗:
public class WatchdogLock {
private final Jedis jedis;
private final String lockKey;
private final String ownerId;
private final long expireMs;
private ScheduledExecutorService scheduler;
public boolean tryLock() {
String result = jedis.set(lockKey, ownerId,
"NX", "PX", expireMs);
if ("OK".equals(result)) {
startWatchdog();
return true;
}
return false;
}
private void startWatchdog() {
scheduler = Executors.newSingleThreadScheduledExecutor();
long period = expireMs / 3;
scheduler.scheduleAtFixedRate(() -> {
String current = jedis.get(lockKey);
if (ownerId.equals(current)) {
jedis.pexpire(lockKey, expireMs);
} else {
scheduler.shutdown();
}
}, period, period, TimeUnit.MILLISECONDS);
}
public void unlock() {
if (scheduler != null) {
scheduler.shutdown();
}
// Lua 脚本原子解锁
}
}
Redisson 看门狗(自动启用):
RLock lock = redisson.getLock("myLock");
// 默认开启看门狗,30 秒过期,每 10 秒续期
lock.lock(); // 无参 lock() 开启看门狗
// 如果指定超时时间,看门狗不启用
lock.lock(10, TimeUnit.SECONDS); // 看门狗不启用
5. 适用场景
| 场景 | 过期时间设置 |
|---|---|
| 业务执行时间确定 | 设置为业务时间的 2-3 倍 |
| 业务执行时间不确定 | 使用看门狗续期 |
| 快速操作(< 1s) | 设置较短过期时间(如 5s) |
6. 不适用场景与替代方案
- 超长业务执行 → 考虑分片或状态机,不要长时间持有锁
- 不可预知的业务时间 → 必须使用看门狗
7. 优缺点与技术取舍
过期时间优点: 简单可靠,自动释放 过期时间缺点: 业务执行时间不确定时可能提前过期
看门狗优点: 适应不确定的业务时间 看门狗缺点: 增加系统复杂度,看门狗自身也可能异常
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 看门狗异常退出 | 多实例冗余看门狗、监控告警 |
| 业务超时锁提前释放 | 合理设置过期时间、使用看门狗 |
| Redis 主从切换导致锁丢失 | Redlock 多实例方案 |
9. 版本差异与实现边界
所有 Redis 版本都支持 Key 的过期时间设置(PX 参数)。看门狗机制是客户端实现的,与 Redis 版本无关。
10. 常见追问
- Q: 看门狗的续期频率怎么确定? A: 通常为过期时间的 1/3。30 秒过期则每 10 秒续期,确保在过期前至少续期一次。
- Q: 业务执行完但看门狗还在跑? A: 解锁时必须停止看门狗。Redisson 在 unlock() 时自动停止。
- Q: 看门狗本身崩溃了怎么办? A: 锁会在过期时间后自动释放。可以通过监控看门狗线程健康状态来发现问题。
11. 易错点
- ❌ 错误:加锁后可以忘记释放。✅ 正确:必须设置过期时间作为兜底,业务完成后主动释放。
- ❌ 错误:看门狗可以完全防止锁泄露。✅ 正确:看门狗也可能异常,需要过期时间作为最终兜底。
- ❌ 错误:过期时间越长越好。✅ 正确:过长会导致锁泄露后恢复变慢,应合理设置。
一句话总结
通过 SET NX PX 设置过期时间作为基础保障,配合看门狗续期机制处理不确定的业务执行时间,确保锁不会永久泄露。
分布式锁的可重入性如何实现?
原始问法:
- 分布式锁的可重入性如何实现?
来源题目:
SRC-06-65-284
面试先答
分布式锁的可重入性是指同一线程可以多次获取同一把锁而不会死锁。实现原理是在 Redis 中用 Hash 结构存储每个线程的加锁次数:首次加锁时 SET lock_key threadId + 设置过期时间;同一线程再次加锁时 HINCRBY 将计数 +1;解锁时 HINCRBY 将计数 -1,当计数降为 0 时才真正删除锁。这样保证了同一线程多次进入临界区的正确性。
核心结论
- 可重入锁允许同一线程多次获取同一锁。
- 实现方式:Hash 存储每线程计数 + 首次加锁设置过期。
- 解锁时计数降为 0 才真正删除锁。
1. 是什么
可重入锁(Reentrant Lock)是指同一线程可以重复获取同一把锁而不会被阻塞或死锁。
场景示例:
方法 A() {
lock.lock(); // 第 1 次加锁
B();
lock.unlock(); // 第 1 次解锁
}
方法 B() {
lock.lock(); // 第 2 次加锁(同一线程,可重入)
// ...
lock.unlock(); // 第 2 次解锁
}
2. 为什么需要它
不可重入的分布式锁会导致:
- 同一线程再次获取锁时被阻塞
- 自己持有锁却无法获取,形成死锁
- 限制了业务逻辑的灵活性
3. 底层原理与完整流程
Redisson 可重入锁实现原理:
Redis 数据结构:
lock_key → threadId:count(Hash 结构)
加锁流程(Lua 脚本):
1. 检查 lock_key 是否存在
2. 如果不存在:
a. SET lock_key threadId+identifier PX expireTime
b. HSET lock_key threadId count=1
c. 返回加锁成功
3. 如果存在,检查当前线程是否已持有:
a. HEXISTS lock_key threadId
b. 如果存在(可重入):
HINCRBY lock_key threadId +1
续期
返回加锁成功
c. 如果不存在(其他线程持有):
返回加锁失败,等待
解锁流程(Lua 脚本):
1. 检查当前线程是否持有锁
2. HINCRBY lock_key threadId -1
3. 如果计数变为 0:
DEL lock_key(真正释放锁)
DEL lock_key:count
4. 如果计数 > 0:
仅更新计数(未完全释放)
Redisson 加锁 Lua 脚本(简化版):
if (redis.call('exists', KEYS[1]) == 0) then
-- 锁不存在,首次加锁
redis.call('hset', KEYS[1], ARGV[2], 1)
redis.call('pexpire', KEYS[1], ARGV[1])
return nil
end
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 当前线程已持有,可重入
redis.call('hincrby', KEYS[1], ARGV[2], 1)
redis.call('pexpire', KEYS[1], ARGV[1])
return nil
end
-- 其他线程持有,加锁失败
return redis.call('pttl', KEYS[1])
Redisson 解锁 Lua 脚本(简化版):
if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then
return nil -- 非持有者,不能解锁
end
local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1)
if (counter > 0) then
redis.call('pexpire', KEYS[1], ARGV[1])
return 0 -- 未完全释放
else
redis.call('del', KEYS[1])
return 1 -- 完全释放
end
4. 怎么使用
Redisson 可重入锁(默认就是可重入的):
RLock lock = redisson.getLock("myLock");
// 第 1 次加锁
lock.lock();
try {
// 第 2 次加锁(可重入)
lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock(); // 第 2 次解锁
}
} finally {
lock.unlock(); // 第 1 次解锁
}
手动实现可重入锁:
public class ReentrantRedisLock {
private final Jedis jedis;
private final String lockKey;
private final long expireMs;
private final ThreadLocal<String> threadIdHolder =
ThreadLocal.withInitial(() ->
Thread.currentThread().getId() + ":" +
UUID.randomUUID().toString());
private static final String LOCK_SCRIPT =
"if (redis.call('exists', KEYS[1]) == 0) then " +
" redis.call('hset', KEYS[1], ARGV[2], 1) " +
" redis.call('pexpire', KEYS[1], ARGV[1]) " +
" return 0 " +
"end " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
" redis.call('hincrby', KEYS[1], ARGV[2], 1) " +
" redis.call('pexpire', KEYS[1], ARGV[1]) " +
" return 0 " +
"end " +
"return redis.call('pttl', KEYS[1])";
private static final String UNLOCK_SCRIPT =
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then " +
" return nil " +
"end " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1) " +
"if (counter > 0) then " +
" redis.call('pexpire', KEYS[1], ARGV[1]) " +
" return 0 " +
"else " +
" redis.call('del', KEYS[1]) " +
" return 1 " +
"end";
public boolean tryLock() {
String threadId = threadIdHolder.get();
Long result = jedis.eval(LOCK_SCRIPT,
Collections.singletonList(lockKey),
String.valueOf(expireMs), threadId);
return result != null && result == 0L;
}
public void unlock() {
String threadId = threadIdHolder.get();
jedis.eval(UNLOCK_SCRIPT,
Collections.singletonList(lockKey),
String.valueOf(expireMs), threadId);
}
}
5. 适用场景
- 递归调用中需要获取同一锁
- 方法嵌套调用需要同一锁
- 回调/拦截器中需要重入锁
6. 不适用场景与替代方案
- 不需要重入的场景 → 使用简单锁(非可重入)
- 需要多线程协作 → 使用读写锁或信号量
7. 优缺点与技术取舍
可重入锁优点: 灵活性高,支持嵌套调用 可重入锁缺点: 实现复杂,需要额外的计数和存储
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 忘记配对解锁 | 使用 try-finally 保证解锁 |
| 计数错误 | Lua 脚本原子操作,保证正确性 |
9. 版本差异与实现边界
可重入锁是客户端实现(Redisson),与 Redis 版本无关。底层依赖 Redis 的 Lua 脚本支持(Redis 2.6+ 支持)。
10. 常见追问
- Q: 可重入锁和不可重入锁哪个好? A: 各有适用场景。可重入锁灵活但实现复杂,不可重入锁简单但限制多。Redisson 默认使用可重入锁。
- Q: 同一线程在不同 JVM 中能重入吗? A: 不能。每个 JVM 的线程 ID 不同,跨 JVM 的重入会被视为不同的线程。
- Q: 可重入锁的最大重入次数是多少? A: 理论上没有限制,受 Redis 内存限制。实际中建议控制在 100 次以内。
11. 易错点
- ❌ 错误:可重入锁可以跨线程重入。✅ 正确:可重入锁只能由同一线程重入。
- ❌ 错误:可重入锁解锁一次即可。✅ 正确:加锁 N 次必须解锁 N 次,计数为 0 才真正释放。
- ❌ 错误:可重入锁比不可重入锁性能好。✅ 正确:可重入锁需要额外的 Hash 操作,性能略差。
一句话总结
可重入分布式锁通过 Hash 结构记录每个线程的加锁计数,同一线程重复获取时计数 +1,解锁时计数 -1 到 0 才真正释放。
Redisson分布式锁的实现原理是什么?使用注意事项?
原始问法:
- Redisson分布式锁的实现原理是什么?使用注意事项?
来源题目:
SRC-06-65-285
面试先答
Redisson 是目前最成熟的 Java 分布式锁框架,实现原理基于 Redis 的 Lua 脚本原子操作。核心特性包括:① 可重入锁——Hash 存储每线程计数;② 看门狗续期——默认 30 秒过期,每 10 秒自动续期;③ 公平锁——按等待顺序获取;④ 读写锁——读锁共享、写锁互斥;⑤ Redlock——多实例红锁算法。使用注意事项:必须在 finally 中解锁、加锁失败要重试、合理设置过期时间、避免锁内执行耗时操作。
核心结论
- Redisson 是生产级分布式锁框架,基于 Lua 脚本实现。
- 核心特性:可重入、看门狗续期、公平锁、读写锁、Redlock。
- 注意事项:正确解锁、合理超时、避免长事务。
1. 是什么
Redisson 是一个功能丰富的 Redis 客户端,提供了完整的分布式锁实现。它不仅实现了基本的互斥锁,还提供了可重入锁、公平锁、读写锁、信号量、倒计数闩等多种分布式同步工具。
2. 为什么需要它
手动实现 Redis 分布式锁存在以下问题:
- 代码繁琐,每个项目都要重复实现
- 容易出错(忘记看门狗、解锁非原子等)
- 缺乏高级功能(公平锁、读写锁等)
- 缺乏监控和调试能力
Redisson 封装了所有这些细节,提供了简单易用的 API。
3. 底层原理与完整流程
Redisson 核心组件:
| 组件 | 说明 | 实现 |
|---|---|---|
| RLock | 可重入锁 | Lua 脚本 + Hash 计数 + 看门狗 |
| RFairLock | 公平锁 | 基于队列 + 等待顺序 |
| RReadWriteLock | 读写锁 | 读锁共享、写锁互斥 |
| RSemaphore | 信号量 | 基于 Redis 计数器 |
| RCountDownLatch | 倒计数闩 | 基于 Redis 发布订阅 |
| RMapCache | 分布式 Map | 基于 Redis Hash + 过期 |
RLock 加锁流程:
lock() 调用:
1. 检查当前线程是否已持有锁(可重入检查)
2. 如果已持有 → 计数 +1,续期,返回
3. 如果未持有 → Lua 脚本原子加锁:
a. 锁不存在 → 创建锁,计数设为 1,设置过期
b. 锁已存在且属于当前线程 → 计数 +1
c. 锁已存在且属于其他线程 → 返回剩余过期时间
4. 加锁成功 → 启动看门狗续期
5. 加锁失败 → 订阅解锁消息 → 阻塞等待 → 收到通知后重试
看门狗续期流程:
1. 加锁成功后,启动续期任务
2. 每 lockWatchdogTimeout/3(默认 10s)执行一次:
a. Lua 脚本检查锁是否被当前线程持有
b. 如果是 → PEXPIRE 续期到 30s
c. 如果否 → 停止续期
3. 解锁时 → 停止续期任务
Redlock 红锁流程:
1. 依次向 N 个独立 Redis 实例加锁(默认 5 个)
2. 如果加锁成功数 > N/2 + 1(多数派):
→ 认为加锁成功
→ 持有锁直到业务完成
3. 如果加锁成功数 <= N/2:
→ 向所有已加锁的实例解锁
→ 认为加锁失败
4. 怎么使用
基本使用:
// 配置
Config config = new Config();
config.useClusterServers()
.addNodeAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
// 可重入锁
RLock lock = redisson.getLock("order:lock");
lock.lock(10, 30, TimeUnit.SECONDS);
// 参数:10s 等待时间,30s 持有时间,时间单位
try {
// 业务逻辑
} finally {
lock.unlock();
}
// 公平锁
RLock fairLock = redisson.getFairLock("fair:lock");
fairLock.lock();
// 读写锁
RReadWriteLock rwLock = redisson.getReadWriteLock("rw:lock");
rwLock.readLock().lock(); // 读锁(可共享)
rwLock.writeLock().lock(); // 写锁(互斥)
// 信号量
RSemaphore semaphore = redisson.getSemaphore("permits");
semaphore.acquire(3); // 获取 3 个许可
semaphore.release(2); // 释放 2 个许可
Spring Boot 集成:
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setPassword("password");
return Redisson.create(config);
}
}
@Service
public class OrderService {
@Autowired
private RedissonClient redisson;
public void createOrder(Long productId) {
RLock lock = redisson.getLock("stock:" + productId);
try {
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
// 扣减库存
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
5. 适用场景
| 场景 | 推荐锁类型 |
|---|---|
| 基本互斥 | RLock(可重入锁) |
| 公平排队 | RFairLock |
| 读多写少 | RReadWriteLock |
| 限流/并发控制 | RSemaphore |
| 多实例高可靠 | RedissonRedLock |
6. 不适用场景与替代方案
- 简单的单机互斥 → ReentrantLock
- 强一致性要求(金融级) → ZooKeeper/etcd
- 极高并发 → 乐观锁 + CAS
7. 优缺点与技术取舍
优点: 功能完善、API 友好、生产级可靠性 缺点: 引入额外依赖、增加系统复杂度
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 看门狗不续期 | 检查 lockWatchdogTimeout、确认 Redis 连接正常 |
| 加锁超时 | 调整等待时间、检查业务执行时间 |
| 解锁异常 | 确保在 finally 中解锁、检查锁持有者 |
9. 版本差异与实现边界
| Redisson 版本 | Redis 版本要求 |
|---|---|
| 3.x | Redis 2.8+ |
| 3.15+ | Redis 3.0+ |
| 3.20+ | Redis 5.0+ |
10. 常见追问
- Q: Redisson 的看门狗怎么工作的? A: 加锁成功后启动后台线程,每隔 10s(30/3)检查锁是否被当前线程持有,是则续期到 30s。解锁时停止后台线程。
- Q: lock() 和 tryLock() 的区别? A: lock() 阻塞等待获取锁,tryLock() 尝试一次后返回结果(可设置超时等待)。
- Q: Redisson 如何防止锁被误删? A: Redisson 使用 Lua 脚本保证解锁原子性,脚本中先检查锁的持有者标识(Hash 中的 threadId),匹配才删除。
11. 易错点
- ❌ 错误:lock() 不需要解锁。✅ 正确:必须在 finally 中 unlock()。
- ❌ 错误:Redisson 锁可以跨 JVM 重入。✅ 正确:只能由同一 JVM 的同一线程重入。
- ❌ 错误:lock(30, TimeUnit.SECONDS) 开启看门狗。✅ 正确:指定 leaseTime 时看门狗不启用,锁会在 30s 后自动释放。
一句话总结
Redisson 是生产级分布式锁框架,基于 Lua 脚本实现可重入、看门狗续期、公平锁、读写锁等高级特性,使用时需注意正确解锁和合理配置。
什么是红锁(Redlock)?原理是什么?
原始问法:
- 什么是红锁(Redlock)?原理是什么?
来源题目:
SRC-06-65-286
面试先答
红锁(Redlock)是 Redis 作者 antirez 提出的一种高可靠分布式锁算法,用于解决 Redis 主从切换导致锁丢失的问题。原理是:同时向多个(默认 5 个)独立的 Redis 实例加锁,只有在加锁成功数超过半数(N/2+1)且总耗时小于锁过期时间时,才认为加锁成功。这样即使部分 Redis 实例故障,只要多数实例正常,锁仍然可靠。Redisson 实现了 Redlock 算法(RedissonRedLock)。
核心结论
- Redlock 解决 Redis 主从切换导致的锁丢失问题。
- 原理是多数派共识:向 N 个独立实例加锁,成功数 > N/2+1 才有效。
- Redisson 提供了 RedissonRedLock 实现。
1. 是什么
Redlock(Red Lock) 是 Redis 作者提出的一种分布式锁算法,通过向多个独立 Redis 实例加锁来提高锁的可靠性。
2. 为什么需要它
主从切换导致锁丢失的问题:
1. 客户端 A 在主节点加锁成功
2. 主节点将锁同步到从节点之前崩溃
3. 从节点升级为主节点
4. 客户端 B 在新主节点加锁成功(因为旧锁没同步过来)
5. 两个客户端同时持有锁 → 锁失效
Redlock 的解决方案:
- 使用多个独立的 Redis 实例(不是主从关系)
- 同时向所有实例加锁
- 只要多数实例加锁成功,就认为加锁成功
- 即使部分实例故障,只要多数存活,锁就有效
3. 底层原理与完整流程
Redlock 算法流程(加锁):
1. 获取当前时间 T1
2. 依次向 N 个独立 Redis 实例加锁:
SET lock_key unique_value NX PX expire
→ 每次加锁设置较短的超时(如 50ms)
3. 统计加锁成功的实例数
4. 计算总耗时:T2 - T1
5. 判断:
a. 成功数 > N/2 + 1(多数派)
b. 总耗时 < 锁过期时间
→ 两个条件都满足 → 加锁成功
→ 否则 → 向所有已加锁的实例解锁,加锁失败
Redlock 算法流程(解锁):
1. 依次向所有 N 个 Redis 实例发送解锁命令
2. 不关心个别实例的解锁结果
3. 只要加锁时多数成功,解锁时尽力而为
超时重试:
加锁失败后:
→ 随机延迟一段时间(如 10-50ms)
→ 重新尝试
→ 避免所有客户端同时重试导致"惊群效应"
Redlock 关键参数:
实例数量 N:建议 5 个(奇数,便于多数派判断)
加锁超时:每个实例 50ms(网络延迟上限)
锁过期时间:30s(业务最大执行时间 + 缓冲)
重试间隔:随机 10-50ms
4. 怎么使用
Redisson Redlock 使用:
// 创建多个 RedissonClient(每个对应独立的 Redis 实例)
RedissonClient redisson1 = Redisson.create(config1);
RedissonClient redisson2 = Redisson.create(config2);
RedissonClient redisson3 = Redisson.create(config3);
RedissonClient redisson4 = Redisson.create(config4);
RedissonClient redisson5 = Redisson.create(config5);
// 创建 Redlock
RedissonRedLock redLock = new RedissonRedLock(
redisson1.getLock("lock_key"),
redisson2.getLock("lock_key"),
redisson3.getLock("lock_key"),
redisson4.getLock("lock_key"),
redisson5.getLock("lock_key")
);
// 加锁
boolean acquired = redLock.tryLock(30, 10, TimeUnit.SECONDS);
if (acquired) {
try {
// 业务逻辑
} finally {
redLock.unlock();
}
}
手动实现 Redlock:
public class Redlock {
private final List<Jedis> jedisList; // 多个独立 Redis 实例
private static final int QUORUM = 3; // 多数派(5 个实例中需 3 个成功)
private static final long LOCK_TIMEOUT = 30000; // 30 秒
private static final int INSTANCE_TIMEOUT = 50; // 单实例超时 50ms
public boolean tryLock(String lockKey, String value) {
long startTime = System.currentTimeMillis();
int successCount = 0;
for (Jedis jedis : jedisList) {
try {
String result = jedis.set(lockKey, value,
"NX", "PX", LOCK_TIMEOUT);
if ("OK".equals(result)) {
successCount++;
}
} catch (Exception e) {
// 单个实例异常,忽略
}
}
long elapsed = System.currentTimeMillis() - startTime;
boolean success = successCount >= QUORUM
&& elapsed < LOCK_TIMEOUT;
if (!success) {
unlockAll(lockKey, value); // 失败则清理
}
return success;
}
public void unlockAll(String lockKey, String value) {
String luaScript =
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
" return redis.call('DEL', KEYS[1]) " +
"else return 0 end";
for (Jedis jedis : jedisList) {
try {
jedis.eval(luaScript,
Collections.singletonList(lockKey),
Collections.singletonList(value));
} catch (Exception e) {
// 忽略单个实例的解锁异常
}
}
}
}
5. 适用场景
| 场景 | 是否需要 Redlock |
|---|---|
| 对锁可靠性要求极高 | 需要 |
| Redis 主从切换频繁 | 需要 |
| 一般业务场景 | 不一定需要(主从 + 哨兵已足够) |
6. 不适用场景与替代方案
- 对锁可靠性要求极高且延迟敏感 → ZooKeeper/etcd
- Redis 实例数量有限(< 3) → 普通锁 + 主从即可
- 不接受多实例成本 → 普通锁 + 监控告警
7. 优缺点与技术取舍
优点: 高可靠性,解决主从切换导致的锁丢失 缺点: 实现复杂、成本高(多个独立实例)、延迟增加
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 多数实例故障 | 剩余实例不足多数派,加锁失败(安全降级) |
| 加锁耗时过长 | 优化网络、减少实例数量 |
| 部分实例解锁失败 | 不影响整体结果,继续业务流程 |
9. 版本差异与实现边界
Redlock 是算法层面的设计,与 Redis 版本无关。Redisson 3.x 实现了 RedissonRedLock。
10. 常见追问
- Q: Redlock 需要多少个实例? A: 建议 5 个独立实例(奇数便于多数派判断)。最少 3 个(多数派为 2)。
- Q: Redlock 的性能如何? A: 比单实例锁慢 N 倍(N 为实例数),因为需要依次向所有实例加锁。通常延迟在 100-300ms。
- Q: Redlock 和 ZooKeeper 锁哪个好? A: Redlock 性能更高但可靠性略低于 ZooKeeper。对性能敏感用 Redlock,对可靠性极端敏感用 ZooKeeper。
11. 易错点
- ❌ 错误:Redlock 是 Redis 官方实现。✅ 正确:Redlock 是 Redis 作者的提案,不是 Redis 服务端功能,需要客户端实现。
- ❌ 错误:Redlock 使用主从实例。✅ 正确:Redlock 使用多个独立实例(不是主从),避免主从切换问题。
- ❌ 错误:Redlock 能保证绝对可靠。✅ 正确:Redlock 在极端情况下(如 GC 暂停)仍可能失效,只是概率更低。
一句话总结
Redlock 通过向多个独立 Redis 实例加锁、多数派共识确认,解决了主从切换导致的锁丢失问题,是高可靠分布式锁的实现方案。
第 6.6 节 主从复制与集群
Redis主从复制的原理是什么?
原始问法:
- Redis主从复制的原理是什么?
来源题目:
SRC-06-66-287
面试先答
Redis 主从复制是将主节点的数据同步到从节点的过程,原理基于全量复制和增量复制两种机制。全量复制发生在从节点首次连接主节点时:主节点通过 BGSAVE 生成 RDB 快照发送给从节点,从节点加载 RDB 后再接收快照期间的增量命令;增量复制发生在主从正常同步后:主节点将写命令通过复制缓冲区实时发送给从节点。Redis 使用异步复制,主节点不等待从节点确认,可能导致短暂的数据不一致。
核心结论
- Redis 主从复制基于 RDB 快照(全量)+ 命令传播(增量)。
- 全量复制:BGSAVE 生成 RDB → 发送 → 加载 → 补增量。
- 增量复制:主节点通过复制缓冲区实时传播写命令。
- 默认异步复制,存在短暂不一致窗口。
1. 是什么
Redis 主从复制是指将一台 Redis 实例(主节点)的数据自动同步到其他 Redis 实例(从节点)的过程。
主从架构:
Client → Master(主节点,读写)
↓ 异步复制
Slave1(从节点,只读)
Slave2(从节点,只读)
主从复制的作用:
- 数据冗余:主节点数据有副本,提高可靠性
- 读写分离:主节点处理写操作,从节点处理读操作
- 故障恢复:主节点故障时,从节点可升级为主节点
2. 为什么需要它
单 Redis 实例存在以下问题:
- 单点故障:实例宕机,所有服务不可用
- 容量限制:单实例内存有限
- 性能瓶颈:单实例 QPS 有上限
主从复制解决了单点故障问题,并支持读写分离。
3. 底层原理与完整流程
全量复制流程(首次同步):
1. 从节点执行 SLAVEOF host port
2. 从节点向主节点发送 PSYNC 命令(携带 runId 和 offset)
3. 主节点判断是否为首次同步:
a. 是首次 → 执行 BGSAVE 生成 RDB
b. 不是首次 → 尝试部分同步(offset 之后的命令)
4. 主节点 BGSAVE 完成后:
a. 将 RDB 文件发送给从节点
b. 从节点接收并加载 RDB
5. 主节点将 BGSAVE 期间产生的增量命令发送给从节点
6. 从节点执行增量命令,同步完成
增量复制流程(正常同步):
1. 主节点执行写命令(SET、HSET 等)
2. 命令写入复制缓冲区(replication backlog)
3. 主节点将缓冲区内容异步发送给从节点
4. 从节点接收并执行命令
5. 从节点记录同步偏移量(offset)
6. 断线重连时,从节点发送 PSYNC 携带 offset
→ 主节点从缓冲区中读取 offset 之后的命令
→ 实现部分同步(无需全量复制)
复制缓冲区(Replication Backlog):
主节点维护一个环形缓冲区:
→ 存储最近的写命令
→ 默认大小 1MB(可配置 repl-backlog-size)
→ 记录每个命令的 offset
→ 从节点断线重连时,主节点从缓冲区读取增量命令
如果缓冲区被覆盖(从节点断线时间过长):
→ 无法部分同步
→ 必须重新全量复制
复制 ID(Replication ID):
每个主节点有两个复制 ID:
- replicationid:主节点自身的 ID(40 位随机字符串)
- replicationid2:上一任主节点的 ID(用于故障恢复)
从节点保存主节点的 replicationid:
→ PSYNC 时携带这个 ID
→ 主节点判断是否是同一个主节点
心跳机制:
主节点:
→ 每 10 秒向所有从节点发送 PING
→ 检查从节点是否存活
→ 记录每个从节点的延迟时间
从节点:
→ 每 1 秒向主节点发送 REPLCONF ACK
→ 报告同步进度(offset)
→ 主节点据此判断从节点延迟
数据一致性保证:
Redis 使用异步复制,存在短暂不一致:
→ 写命令在主节点执行后立即返回客户端
→ 不等待从节点确认
→ 主节点宕机时,最近的写操作可能未同步到从节点
缓解措施:
→ 配置 min-slaves-to-write:至少 N 个从节点连接时才接受写
→ 配置 min-slaves-max-lag:从节点最大延迟时间
→ 这两个配置实现"准同步"复制
4. 怎么使用
配置主从复制:
# 从节点配置(redis.conf)
replicaof 127.0.0.1 6379 # Redis 5.0 之前用 slaveof
# 或运行时配置
redis-cli SLAVEOF 127.0.0.1 6379
# 主节点查看复制状态
redis-cli INFO replication
# 查看:connected_slaves、master_link_status、offset 等
# 从节点取消复制
redis-cli SLAVEOF NO ONE
配置复制缓冲区:
# 设置复制缓冲区大小
repl-backlog-size 64mb # 默认 1MB,可增大到 64MB
# 设置无从节点时是否保留缓冲区
repl-backlog-ttl 3600 # 无从节点 1 小时后释放缓冲区
配置准同步复制:
# 至少 2 个从节点连接时才接受写
min-replicas-to-write 2
# 从节点最大延迟 10 秒
min-replicas-max-lag 10
# 如果不满足条件,主节点拒绝写操作
# 保护数据不丢失,但可能导致写入失败
Java 中读写分离:
@Service
public class RedisService {
@Autowired
private Jedis masterJedis; # 主节点
@Autowired
private Jedis slaveJedis; # 从节点
// 写操作走主节点
public void set(String key, String value) {
masterJedis.set(key, value);
}
// 读操作走从节点
public String get(String key) {
return slaveJedis.get(key);
}
}
5. 适用场景
| 场景 | 说明 |
|---|---|
| 读写分离 | 主写从读,分散读压力 |
| 数据备份 | 从节点作为数据备份 |
| 故障恢复 | 主节点故障,从节点升级 |
| 数据迁移 | 从节点独立后迁移到新环境 |
6. 不适用场景与替代方案
- 强一致性要求 → 同步复制或使用数据库
- 写操作非常频繁 → 主节点压力大,需要分片
- 大规模数据 → 需要 Cluster 分片
7. 优缺点与技术取舍
优点: 数据冗余、读写分离、提高可用性 缺点: 异步复制存在不一致、主节点单点瓶颈
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 全量复制耗时过长 | 增大复制缓冲区、控制 RDB 大小 |
| 复制延迟过大 | 优化网络、增大缓冲区 |
| 主从数据不一致 | 配置准同步、合理设置超时 |
| 从节点断线频繁重连 | 增大复制缓冲区 |
9. 版本差异与实现边界
| 版本 | 变化 |
|---|---|
| Redis 2.8 | PSYNC 支持部分同步 |
| Redis 3.2 | 优化复制缓冲区 |
| Redis 5.0 | slaveof → replicaof |
| Redis 6.0 | 更高效的复制协议 |
| Redis 7.0 | 优化全量复制性能 |
10. 常见追问
- Q: 全量复制和增量复制怎么切换? A: 首次连接或部分同步失败(缓冲区被覆盖)时全量复制;正常同步使用增量复制。
- Q: 主从复制的延迟怎么产生的? A: 主节点执行写命令后立即返回,然后再发送到从节点。网络延迟 + 从节点处理延迟 = 复制延迟。
- Q: 如何保证主从数据一致? A: Redis 默认异步复制,无法完全一致。可通过
min-replicas-to-write和min-replicas-max-lag配置准同步,牺牲部分可用性换取一致性。
11. 易错点
- ❌ 错误:主从复制是同步的。✅ 正确:默认是异步的,存在不一致窗口。
- ❌ 错误:从节点可以接受写操作。✅ 正确:从节点默认是只读的,可以通过配置
replica-read-only no改为可写(不推荐)。 - ❌ 错误:PSYNC 一定是全量复制。✅ 正确:首次同步是全量,之后可以部分同步(增量)。
- ❌ 错误:复制缓冲区越大越好。✅ 正确:过大会浪费内存,应根据业务特点合理配置。
一句话总结
Redis 主从复制通过 RDB 全量同步 + 命令增量同步实现数据复制,默认异步模式,可配置准同步提高一致性,是实现读写分离和高可用的基础。
Redis哨兵(Sentinel)模式的作用是什么?选举机制是什么?Redis哨兵和Cluster集群的核心区别是什么?怎么选?
原始问法:
- Redis哨兵(Sentinel)模式的作用是什么?选举机制是什么?
- Redis哨兵和Cluster集群的核心区别是什么?怎么选?
来源题目:
SRC-06-66-288、SRC-06-66-290
面试先答
Redis 哨兵是 Redis 的高可用方案,核心作用是监控、提醒、自动故障转移:① 监控主从节点状态;② 主节点故障时自动选举新主节点并重新配置从节点;③ 通知客户端变更。哨兵的选举基于 Raft 协议的 Leader 选举机制,奇数个哨兵节点通过投票选出领导者执行故障转移。哨兵和 Cluster 的核心区别是:哨兵解决的是高可用(故障转移),数据不分片;Cluster 同时解决高可用 + 分片,数据分散在多个主节点。
核心结论
- Sentinel 提供高可用(故障转移),数据不分片。
- Cluster 提供高可用 + 分片,数据分散。
- Sentinel 选举基于 Raft,奇数节点投票选出领导者。
1. 是什么
Redis 哨兵(Sentinel)是 Redis 的高可用管理系统,包含三个核心功能:
| 功能 | 说明 |
|---|---|
| 监控(Monitoring) | 定期检查主从节点是否正常 |
| 提醒(Notification) | 节点故障时通过 API 通知管理员 |
| 自动故障转移(Failover) | 主节点故障时自动选举新主节点 |
Sentinel 架构:
┌──────────────┐
│ Sentinel 1 │
└──────┬───────┘
│ 监控
┌──────┴───────┐
│ Sentinel 2 │
└──────┬───────┘
│
┌──────┴───────┐
│ Sentinel 3 │
└──────┬───────┘
│ 监控
┌────────────┼────────────┐
│ │ │
┌─────┴─────┐┌────┴────┐┌─────┴────┐
│ Master ││ Slave1 ││ Slave2 │
└───────────┘└─────────┘└──────────┘
2. 为什么需要它
主从复制解决了数据冗余问题,但主节点故障时需要人工干预切换。哨兵的作用是自动化这个过程:
- 自动发现主节点故障
- 自动选举新主节点
- 自动重新配置从节点
- 通知客户端新主节点地址
3. 底层原理与完整流程
故障检测流程:
1. 哨兵每 2 秒向所有 Redis 节点发送 PING
2. 如果节点在 timeout(默认 30s)内无响应:
→ 标记为"主观下线"(SDOWN)
3. 如果多数哨兵(N/2+1)都认为节点下线:
→ 标记为"客观下线"(ODOWN)
→ 触发故障转移
Leader 选举流程(基于 Raft 协议):
1. 当主节点被标记为客观下线时
2. 所有哨兵节点进行投票选举:
a. 每个哨兵向其他哨兵发送投票请求
b. 获得多数票(N/2+1)的哨兵成为 Leader
c. Leader 负责执行故障转移
3. 如果选举失败(平票):
→ 等待一个随机时间后重新选举
故障转移流程:
1. Leader 选举新主节点:
a. 从从节点中选择数据最新的(复制 offset 最大)
b. 向新主节点发送 SLAVEOF NO ONE(升级为主节点)
2. Leader 重新配置其他从节点:
a. 向其他从节点发送 SLAVEOF new_host new_port
b. 从节点切换到新主节点
3. 通知所有客户端新主节点地址
Sentinel 配置:
sentinel monitor mymaster 127.0.0.1 6379 2
# 参数:监控名称 主节点地址 端口 法定人数(几个哨兵同意判定下线)
sentinel down-after-milliseconds mymaster 30000
# 主观下线超时时间(毫秒)
sentinel failover-timeout mymaster 60000
# 故障转移超时时间
sentinel parallel-syncs mymaster 1
# 故障转移后,同时向新主节点同步的从节点数量
哨兵选举的 Raft 协议:
哨兵使用简化版 Raft 协议:
→ 每个哨兵有一个 term(任期编号)
→ 选举时递增 term
→ 获得多数票者当选 Leader
→ Leader 在任期内负责故障转移
关键参数:
→ 选举超时:15s(随机)
→ 心跳间隔:2s
→ 法定人数:N/2+1(N 为哨兵总数)
哨兵 vs Cluster 核心区别:
| 维度 | Sentinel | Cluster |
|---|---|---|
| 核心目标 | 高可用(故障转移) | 高可用 + 分片 |
| 数据分布 | 所有节点存相同数据 | 数据分片存储 |
| 主节点数量 | 1 主 + 多从 | 多主(每个主存不同分片) |
| 写操作 | 主节点处理 | 各主节点处理自己的分片 |
| 读操作 | 主从都可(读写分离) | 各节点处理自己的分片 |
| 客户端 | 连接主节点 | 需要集群路由 |
| 扩容 | 增加从节点 | 增加主节点(slot 迁移) |
| 适用数据量 | 单主可承受的规模 | 任意规模 |
4. 怎么使用
部署 Sentinel:
# 1. 启动主节点
redis-server redis-master.conf
# 2. 启动两个从节点
redis-server redis-slave1.conf # replicaof master 6379
redis-server redis-slave2.conf # replicaof master 6379
# 3. 启动三个哨兵
redis-server sentinel-1.conf
redis-server sentinel-2.conf
redis-server sentinel-3.conf
# sentinel.conf 配置:
# sentinel monitor mymaster 127.0.0.1 6379 2
# sentinel down-after-milliseconds mymaster 30000
# sentinel failover-timeout mymaster 60000
Java 客户端连接 Sentinel:
Set<String> sentinels = new HashSet<>(Arrays.asList(
"127.0.0.1:26379",
"127.0.0.1:26380",
"127.0.0.1:26381"
));
JedisPoolConfig poolConfig = new JedisPoolConfig();
JedisSentinelPool pool = new JedisSentinelPool(
"mymaster", sentinels, poolConfig);
Jedis jedis = pool.getResource();
jedis.set("key", "value");
jedis.close();
5. 适用场景
| 场景 | 推荐方案 |
|---|---|
| 单主 + 多从的高可用 | Sentinel |
| 需要数据分片 | Cluster |
| 数据量 < 单主内存上限 | Sentinel |
| 数据量 > 单主内存上限 | Cluster |
6. 不适用场景与替代方案
- 超大规模数据 → Cluster 或 Redis Cluster + 分片
- 跨地域部署 → 需要使用 Twemproxy/Codis 等代理
- 大量写操作 → Cluster 多主分片
7. 优缺点与技术取舍
Sentinel 优点: 高可用、故障自动转移、对客户端透明 Sentinel 缺点: 不分片、单主写瓶颈、容量受限
Cluster 优点: 分片 + 高可用、横向扩展、适合大规模 Cluster 缺点: 实现更复杂、客户端需要路由支持
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 哨兵无法连接主节点 | 检查网络和配置 |
| 故障转移超时 | 调整 failover-timeout |
| 脑裂(两个主节点) | 使用奇数哨兵、配置法定人数 |
9. 版本差异与实现边界
| 版本 | Sentinel 变化 |
|---|---|
| Redis 2.4 | 引入 Sentinel |
| Redis 3.0 | 稳定版 Sentinel |
| Redis 5.0 | 优化故障转移 |
| Redis 7.0 | 更快的故障转移速度 |
10. 常见追问
- Q: 哨兵节点数量怎么确定? A: 建议 3 个或 5 个(奇数),法定人数为 N/2+1。3 个时法定人数为 2,5 个时法定人数为 3。
- Q: 哨兵和 Cluster 可以配合使用吗? A: 不能。Sentinel 和 Cluster 是互斥的两种高可用方案。Cluster 自带哨兵功能。
- Q: 如何在 Sentinel 和 Cluster 之间选择? A: 数据量在单主内存上限内(通常 16-64GB)用 Sentinel;超过则用 Cluster。
11. 易错点
- ❌ 错误:Sentinel 可以做数据分片。✅ 正确:Sentinel 只做高可用,不分片。
- ❌ 错误:Sentinel 选举由主节点决定。✅ 正确:哨兵选举基于 Raft 协议,由哨兵节点投票选出。
- ❌ 错误:Sentinel 和 Cluster 可以同时使用。✅ 正确:两者是不同的架构,不能混用。
一句话总结
Sentinel 提供自动故障转移的高可用方案,适用于单主能容纳的数据量;Cluster 同时提供分片和高可用,适用于更大规模数据。选择依据是数据规模和性能需求。
Redis Cluster的原理是什么?
原始问法:
- Redis Cluster的原理是什么?
来源题目:
SRC-06-66-289
面试先答
Redis Cluster 是 Redis 官方的分布式集群方案,核心原理是数据分片 + 高可用。它将所有 Key 空间分为 16384 个槽(slot),每个槽由一个主节点负责。写操作时,根据 Key 的 CRC16 校验值决定落到哪个槽,然后路由到对应的主节点。每个主节点有从节点做备份,主节点故障时自动故障转移。客户端通过集群协议直接连接正确的节点,支持横向扩展到数百个节点。
核心结论
- Cluster 基于 16384 个槽进行数据分片。
- 每个 Key 通过 CRC16(key) % 16384 确定所属槽。
- 每个主节点负责一部分槽,从节点做故障转移。
- 客户端需支持集群协议进行路由。
1. 是什么
Redis Cluster 是 Redis 3.0+ 引入的官方分布式集群方案,解决了横向扩展(分片)和高可用(故障转移)两个核心问题。
Cluster 架构:
Client → (CRC16 路由)
→ Master1 (槽 0-5460) → Slave1
→ Master2 (槽 5461-10922) → Slave2
→ Master3 (槽 10923-16383) → Slave3
16384 槽分片机制:
所有 Key 被映射到 16384 个槽:
slot = CRC16(key) % 16384
示例:
Key "user:1" → CRC16 → slot 1234 → Master1
Key "product:1" → CRC16 → slot 9999 → Master2
每个主节点负责一部分槽:
Master1: 槽 0-5460(约 1/3 数据)
Master2: 槽 5461-10922(约 1/3 数据)
Master3: 槽 10923-16383(约 1/3 数据)
CRC16 算法:
CRC16(Cyclic Redundancy Check 16-bit):
→ 16 位循环冗余校验算法
→ 将任意长度的 Key 映射到 0-65535
→ CRC16(key) % 16384 → 0-16383(16384 个槽)
Key 的哈希 tag:
→ 用 { } 包裹 Key 的一部分,只对这部分计算 CRC16
→ 保证相关的 Key 落在同一槽
示例:
user:{1}:name → CRC16("1") → 所有 {1} 的 Key 在同一槽
user:{1}:email → 同上
user:{2}:name → CRC16("2") → 在另一个槽
2. 为什么需要它
Sentinel 的局限:
- 不分片,所有数据在一个主节点
- 内存受限(单实例上限)
- 写操作有单点瓶颈
Cluster 的解决方案:
- 数据分片到多个主节点,突破内存限制
- 写操作分散到多个主节点,突破性能瓶颈
- 每个主节点有从节点,保证高可用
3. 底层原理与完整流程
集群节点通信(Gossip 协议):
节点间通过 Gossip 协议通信:
→ 每个节点周期性地与其他节点交换状态信息
→ 信息包括:节点状态、槽分配、主从关系
→ 最终一致性,无需中心节点
集群元数据:
→ 每个节点都保存完整的集群状态
→ 客户端连接任何节点都可获得路由信息
请求路由流程:
1. 客户端连接任意集群节点
2. 发送命令(如 SET key value)
3. 节点计算 key 所属槽:
slot = CRC16(key) % 16384
4. 如果该槽由本节点负责:
→ 直接执行命令
→ 返回结果
5. 如果该槽由其他节点负责:
→ 返回 MOVED 重定向
→ 客户端连接正确的节点重试
6. 客户端缓存路由表,后续请求直接路由
槽迁移流程(扩缩容):
1. 新增节点加入集群
2. 从现有主节点迁移槽到新节点:
a. 源主节点:接收迁移命令
b. 迁移数据到目标主节点
c. 更新集群配置
d. 通知所有节点
3. 槽迁移完成,新节点开始服务
故障转移流程:
1. 从节点检测到主节点故障
2. 从节点发起故障转移选举
3. 集群中多数主节点投票
4. 获得投票的从节点升级为新主节点
5. 所有节点更新槽分配
6. 客户端通过 MOVED 重定向发现新主节点
4. 怎么使用
创建 Cluster:
# 1. 启动 6 个 Redis 实例(3 主 3 从)
redis-server node1.conf # port 6379, cluster-enabled yes
redis-server node2.conf # port 6380
...
redis-server node6.conf # port 6384
# 2. 创建集群
redis-cli --cluster create \
127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6381 \
127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384 \
--cluster-replicas 1
# 参数:前三个为主节点,后三个为从节点
Java 客户端连接 Cluster:
Set<HostAndPort> clusterNodes = new HashSet<>();
clusterNodes.add(new HostAndPort("127.0.0.1", 6379));
clusterNodes.add(new HostAndPort("127.0.0.1", 6380));
clusterNodes.add(new HostAndPort("127.0.0.1", 6381));
JedisCluster jedisCluster = new JedisCluster(clusterNodes);
// 写操作(自动路由)
jedisCluster.set("user:1", "Alice");
// 读操作
String name = jedisCluster.get("user:1");
// Hash 操作
jedisCluster.hset("user:1", "age", "25");
Cluster 常用命令:
# 查看集群信息
redis-cli CLUSTER INFO
redis-cli CLUSTER NODES
# 查看槽分配
redis-cli CLUSTER SLOTS
# 手动迁移槽
redis-cli CLUSTER SETSLOT <slot> <node_id>
redis-cli CLUSTER MIGRATE <host> <port> <key> <dest_db> <timeout>
# 添加节点
redis-cli CLUSTER MEET <host> <port>
# 移除节点
redis-cli CLUSTER FORGET <node_id>
redis-cli CLUSTER REPLICATE <master_node_id>
Hash Tag 使用示例:
// 保证相关 Key 在同一槽
// 使用 { } 包裹固定部分
jedisCluster.set("{user:1}:name", "Alice");
jedisCluster.set("{user:1}:email", "alice@example.com");
// 这两个 Key 在同一槽,可以使用事务或 Lua 脚本
5. 适用场景
| 场景 | 是否适合 Cluster |
|---|---|
| 数据量 < 16GB | 可能不需要,Sentinel 足够 |
| 数据量 > 64GB | 必须使用 Cluster |
| 高并发写操作 | 适合,多主分散写压力 |
| 需要横向扩展 | 适合,可动态增加节点 |
6. 不适用场景与替代方案
- 多 Key 事务(跨槽)→ 使用 Hash Tag 保证同槽
- 大数据量单 Key → 拆分为多个 Key
- 极小规模数据 → Sentinel 更简单
7. 优缺点与技术取舍
优点: 横向扩展、高可用、自动故障转移、对客户端透明 缺点: 实现复杂、跨槽操作有限、运维成本高
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 跨槽操作限制 | 使用 Hash Tag 或 Pipeline |
| 槽迁移失败 | 检查网络、手动回滚 |
| 脑裂问题 | 使用奇数主节点、配置超时 |
9. 版本差异与实现边界
| 版本 | Cluster 变化 |
|---|---|
| Redis 3.0 | 引入 Cluster |
| Redis 4.0 | 优化槽迁移 |
| Redis 5.0 | 更稳定的故障转移 |
| Redis 6.0 | 客户端缓存优化 |
| Redis 7.0 | 更快的集群同步 |
10. 常见追问
- Q: Cluster 的 16384 个槽怎么来的? A: CRC16 算法产生 16 位校验值(0-65535),16384 是 65536/4,兼顾了细粒度和维护成本。
- Q: 如何保证跨槽事务? A: 使用 Hash Tag({ })确保相关 Key 在同一槽。例如
{user}:1:name和{user}:1:email都对user计算 CRC16。 - Q: Cluster 和 Twemproxy/Codis 的区别? A: Cluster 是 Redis 官方方案,客户端直接路由;Twemproxy/Codis 是代理方案,所有请求经过代理转发。Cluster 性能更高,代理方案对客户端更透明。
11. 易错点
- ❌ 错误:Cluster 的槽是固定的。✅ 正确:槽可以迁移,支持动态扩缩容。
- ❌ 错误:Cluster 节点之间通过中心服务器通信。✅ 正确:使用 Gossip 协议去中心化通信。
- ❌ 错误:Cluster 支持跨槽事务。✅ 正确:Cluster 只支持同槽(同一节点)的事务。
- ❌ 错误:Cluster 的从节点可以处理读请求。✅ 正确:默认从节点只读,但读请求由主节点处理(客户端路由到主节点)。
一句话总结
Redis Cluster 通过 16384 槽分片实现横向扩展,每个主节点负责部分槽并配有从节点,结合 Gossip 协议通信和自动故障转移,是官方推荐的分布式集群方案。
第 6.7 节 Redis 进阶应用
Redis除了做缓存还有哪些功能?
原始问法:
- Redis除了做缓存还有哪些功能?
来源题目:
SRC-06-67-291
面试先答
Redis 除了做缓存之外,还有五大核心功能:① 分布式锁——基于 SET NX PX 实现跨进程互斥;② 消息队列——基于 List(阻塞队列)或 Stream(可靠队列)实现异步解耦;③ 计数器与限流——基于 INCR 原子计数 + 时间窗口实现接口限流;④ 实时统计——基于 HyperLogLog 做 UV 统计、Bitmap 做签到记录;⑤ 发布订阅——基于 Pub/Sub 实现实时消息推送。此外还有排行榜(ZSet)、去重(Set)、延迟队列(ZSet 按分数存储时间戳)等。
核心结论
- Redis 不仅是缓存,还是多功能的中间件。
- 核心非缓存功能:锁、队列、计数、统计、发布订阅。
- 不同功能对应不同的数据类型和命令组合。
1. 是什么
Redis 的功能远不止缓存。凭借丰富的数据结构和原子操作,Redis 可以胜任多种中间件角色。
Redis 功能全景图:
| 功能 | 实现方式 | 数据类型 |
|---|---|---|
| 分布式锁 | SET NX PX + Lua | String |
| 消息队列 | BRPOP/XADD | List/Stream |
| 计数器 | INCR/HINCRBY | String/Hash |
| 限流 | 计数器 + 时间窗口 | String/ZSet |
| 排行榜 | ZADD + ZREVRANK | ZSet |
| 去重 | SADD + SISMEMBER | Set |
| UV 统计 | PFADD + PFCOUNT | HyperLogLog |
| 签到记录 | SETBIT + BITCOUNT | Bitmap |
| 发布订阅 | PUBLISH + SUBSCRIBE | Pub/Sub |
| 延迟队列 | ZADD 存时间戳 | ZSet |
| 实时推送 | XADD + XREAD | Stream |
2. 为什么需要它
在没有 Redis 的情况下,这些功能需要引入多个中间件:
- 消息队列 → Kafka/RocketMQ
- 分布式锁 → ZooKeeper
- 限流 → 专门的限流组件
- 实时统计 → 大数据平台
Redis 一个系统即可覆盖以上大部分场景,大幅简化技术栈。
3. 底层原理与完整流程
消息队列实现:
生产者 → LPUSH queue task_json
消费者 → BRPOP queue 0(阻塞等待)
→ 处理任务
→ 任务自动移除
可靠队列(Stream):
生产者 → XADD stream task
消费者 → XREADGROUP group stream
→ 处理后 ACK
→ 支持消费组、消息持久化、回溯
计数器与限流实现:
计数器:
INCR counter → 原子递增
EXPIRE counter 60 → 设置过期时间(1 分钟窗口)
限流(固定窗口):
SET key count EX 60
INCR key → 如果超过阈值则拒绝
限流(滑动窗口 - Lua 实现):
→ ZADD 记录每个请求的时间戳
→ ZREMRANGEBYSCORE 清除过期记录
→ ZCARD 统计当前窗口内请求数
→ 超过阈值则拒绝
限流(令牌桶):
→ 按固定速率往桶中添加令牌
→ 请求消费令牌,无令牌则拒绝
发布订阅实现:
发布者 → PUBLISH channel message
订阅者 → SUBSCRIBE channel
→ 收到消息推送
→ 实时性高但不持久化
4. 怎么使用
消息队列(List):
// 生产者
jedis.lpush("order:queue", orderJson);
// 消费者
List<String> result = jedis.brpop(0, "order:queue");
String orderJson = result.get(1);
// 处理订单
processOrder(orderJson);
消息队列(Stream,可靠):
// 生产者
jedis.xadd("stream:orders",
StreamAddParams.map("type", "CREATE", "data", orderJson));
// 消费者组
jedis.xgroupCreate("stream:orders", "orderGroup",
StreamGroupParams.xgroupParams().sentinel(true));
// 消费消息
List<StreamEntry> messages = jedis.xreadGroup(
"orderGroup", "consumer1",
new HashMap<String, StreamEntryID>() {{
put("stream:orders", StreamEntryID.last());
}}, 10);
// 确认消费
jedis.xack("stream:orders", "orderGroup",
message.getId());
接口限流(Lua 脚本实现滑动窗口):
-- KEYS[1]: 限流 Key
-- ARGV[1]: 当前时间戳
-- ARGV[2]: 窗口大小(毫秒)
-- ARGV[3]: 最大请求数
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
-- 清除窗口外的记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 统计当前窗口内请求数
local count = redis.call('ZCARD', key)
if count < limit then
-- 添加新请求
redis.call('ZADD', key, now, now .. '-' .. math.random())
redis.call('PEXPIRE', key, window)
return 1 -- 允许
else
return 0 -- 限流
end
延迟队列(ZSet 实现):
// 生产者:添加延迟任务,分数为执行时间戳
long executeTime = System.currentTimeMillis() + 5000; // 5 秒后
jedis.zadd("delay:queue", executeTime, "task:1");
// 消费者:定时扫描到期任务
@Scheduled(fixedRate = 1000) // 每秒扫描
public void processDelayQueue() {
long now = System.currentTimeMillis();
Set<String> tasks = jedis.zrangeByScore("delay:queue", 0, now);
for (String task : tasks) {
if (jedis.zrem("delay:queue", task) > 0) { // 原子移除
// 执行任务
processTask(task);
}
}
}
5. 适用场景
| 功能 | 适用场景 |
|---|---|
| 分布式锁 | 秒杀、定时任务、分布式 ID |
| 消息队列 | 订单处理、异步通知、数据同步 |
| 计数器 | 文章阅读量、在线人数、统计 |
| 限流 | 接口限流、防刷、流量控制 |
| 排行榜 | 游戏积分榜、热搜榜 |
| 去重 | UV 去重、IP 去重、标签去重 |
| 统计 | UV 统计、签到统计、在线统计 |
| 发布订阅 | 实时通知、配置推送、消息广播 |
| 延迟队列 | 定时任务、超时处理、延迟回调 |
6. 不适用场景与替代方案
| 功能 | 不适合 Redis 的场景 | 替代方案 |
|---|---|---|
| 消息队列 | 消息堆积、可靠投递 | Kafka/RocketMQ |
| 限流 | 复杂限流规则 | 专业限流组件(Sentinel) |
| 持久化存储 | 海量数据长期存储 | MySQL/PostgreSQL |
| 复杂查询 | 多条件组合查询 | Elasticsearch |
7. 优缺点与技术取舍
Redis 优势: 一个系统覆盖多种功能,技术栈简单,性能高 Redis 劣势: 每个功能都不是"专家",复杂场景需要专业方案
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 消息队列消息丢失 | 使用 Stream + 消费组 + ACK |
| 计数器精度不够 | 使用原子操作 + 定期持久化 |
| 限流被绕过 | 多级限流 + 网关层限流 |
9. 版本差异与实现边界
| 功能 | 引入版本 |
|---|---|
| Pub/Sub | Redis 2.0+ |
| Lua 脚本 | Redis 2.6+ |
| Stream | Redis 5.0+ |
| Redis Functions | Redis 7.0+ |
10. 常见追问
- Q: Redis 做队列和 Kafka 怎么选? A: 简单场景(低量级、允许丢消息)用 Redis List;需要可靠投递、消息堆积、消费者组等用 Kafka/RocketMQ。
- Q: Redis 做限流和 Sentinel 怎么选? A: Redis 适合单接口简单限流;Sentinel 适合复杂的流量控制(多维度、降级熔断)。
- Q: Redis 发布订阅和消息队列的区别? A: Pub/Sub 实时推送但不持久化;Stream 持久化但延迟较高。Pub/Sub 适合实时通知,Stream 适合可靠消息。
11. 易错点
- ❌ 错误:Redis 只能做缓存。✅ 正确:Redis 可以做锁、队列、计数器、限流等多种功能。
- ❌ 错误:Redis Pub/Sub 可以替代消息队列。✅ 正确:Pub/Sub 不持久化,订阅者离线时消息丢失。
- ❌ 错误:Redis Stream 可以完全替代 Kafka。✅ 正确:Stream 适合轻量级消息,Kafka 适合大规模消息处理。
一句话总结
Redis 除了缓存,还能做分布式锁、消息队列、计数器、限流、排行榜、去重、统计、发布订阅、延迟队列等,是一个通用的中间件平台。
如何基于Redis实现接口限流?如何基于Redis实现延迟队列?如何基于Redis实现消息队列?
原始问法:
- 如何基于Redis实现接口限流?
- 如何基于Redis实现延迟队列?
- 如何基于Redis实现消息队列?
来源题目:
SRC-06-67-292、SRC-06-67-293、SRC-06-67-294
面试先答
基于 Redis 实现这三个功能的核心思路是利用 Redis 的原子操作和数据结构:① 接口限流——用 Lua 脚本实现滑动窗口,记录每个请求的时间戳到 ZSet,统计窗口内请求数是否超过阈值;② 延迟队列——用 ZSet 存储任务,分数为执行时间戳,定时扫描到期任务;③ 消息队列——用 List 的 BRPOP 实现阻塞队列,或用 Stream 实现可靠消息队列(支持消费组、ACK、持久化)。三者都依赖 Redis 的高性能和原子操作。
核心结论
- 接口限流:滑动窗口(ZSet + Lua)、令牌桶(String + Lua)
- 延迟队列:ZSet 按时间戳排序,定时扫描
- 消息队列:List(简单)、Stream(可靠)
1. 是什么
接口限流:控制单位时间内的请求数量,防止接口过载。
延迟队列:将任务延迟到指定时间执行,不阻塞业务线程。
消息队列:异步解耦,生产者发送消息,消费者异步处理。
2. 为什么需要它
接口限流:
- 防止接口被恶意刷
- 保护后端服务不被压垮
- 保证系统稳定性
延迟队列:
- 实现超时自动取消(如订单 30 分钟未支付自动取消)
- 实现定时任务(如优惠券到期提醒)
- 实现异步延迟处理
消息队列:
- 异步解耦(如下单→扣库存→发短信,异步处理发短信)
- 流量削峰(大促时消息排队处理)
- 系统解耦(降低模块间耦合)
3. 底层原理与完整流程
接口限流实现方案对比:
| 算法 | 实现方式 | 特点 |
|---|---|---|
| 固定窗口 | String + EXPIRE | 实现简单,但有临界突刺 |
| 滑动窗口 | ZSet + Lua | 平滑无突刺,内存占用较高 |
| 令牌桶 | String + Lua | 平滑限流,支持突发流量 |
| 漏桶 | String + Lua | 严格平滑,不支持突发 |
滑动窗口限流(最常用):
1. 请求到达,计算 key(接口+IP+时间窗口)
2. Lua 脚本:
a. ZREMRANGEBYSCORE 清除窗口外记录
b. ZCARD 统计窗口内请求数
c. 如果 < 阈值:ZADD 记录请求,返回允许
d. 如果 >= 阈值:返回拒绝
3. Lua 脚本保证原子性
延迟队列实现方案对比:
| 方案 | 实现方式 | 特点 |
|---|---|---|
| ZSet 扫描 | ZADD 存时间戳 + 定时扫描 | 简单但有延迟 |
| Keyspace Notification | EXPIRE + 过期通知 | 实时但不可靠 |
| Redis Stream | XADD + 消费组 | 可靠但复杂 |
ZSet 延迟队列流程:
生产者:
ZADD delay:queue executeTime taskId
消费者(定时扫描):
1. 每秒执行 SCAN
2. ZRANGEBYSCORE 获取到期任务
3. ZREM 原子移除
4. 执行任务
消息队列实现方案对比:
| 方案 | 实现方式 | 特点 |
|---|---|---|
| List 队列 | LPUSH + BRPOP | 简单、高性能、不可靠 |
| Pub/Sub | PUBLISH + SUBSCRIBE | 实时、不持久化、无 ACK |
| Stream 队列 | XADD + XREADGROUP | 可靠、持久化、消费组 |
Stream 可靠队列流程:
生产者:
XADD stream field value
→ 消息持久化
消费者组:
XREADGROUP group stream
→ 读取未消费的消息
→ 未确认的消息可重复消费
确认:
XACK stream group messageId
→ 标记消息已消费
4. 怎么使用
接口限流(Lua 脚本实现滑动窗口):
-- rate_limit.lua
-- KEYS[1]: 限流 key
-- ARGV[1]: 当前时间戳(毫秒)
-- ARGV[2]: 窗口大小(毫秒)
-- ARGV[3]: 最大请求数
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
redis.call('PEXPIRE', key, window)
return 1
else
return 0
end
public class RateLimiter {
private static final String LUA_SCRIPT = "...";
public boolean isAllowed(String api, String ip, int windowMs, int maxRequests) {
String key = "rate:" + api + ":" + ip;
long now = System.currentTimeMillis();
Long result = jedis.eval(LUA_SCRIPT,
Collections.singletonList(key),
String.valueOf(now),
String.valueOf(windowMs),
String.valueOf(maxRequests));
return result == 1L;
}
}
// 使用
if (rateLimiter.isAllowed("/api/order", "192.168.1.1", 1000, 10)) {
// 允许请求
} else {
// 返回 429 Too Many Requests
}
延迟队列(ZSet 实现):
@Component
public class DelayQueue {
@Autowired
private Jedis jedis;
public void addTask(String task, long delayMs) {
long executeTime = System.currentTimeMillis() + delayMs;
jedis.zadd("delay:queue", executeTime, task);
}
@Scheduled(fixedRate = 1000) // 每秒扫描
public void processTasks() {
long now = System.currentTimeMillis();
Set<String> tasks = jedis.zrangeByScore("delay:queue", 0, now);
for (String task : tasks) {
// 原子移除并处理
if (jedis.zrem("delay:queue", task) > 0) {
processTask(task);
}
}
}
private void processTask(String task) {
// 处理延迟任务
}
}
消息队列(Stream 实现):
public class ReliableMQ {
@Autowired
private Jedis jedis;
public void send(String topic, Map<String, String> message) {
jedis.xadd("stream:" + topic,
StreamAddParams.map(message));
}
public void createConsumerGroup(String topic, String group) {
try {
jedis.xgroupCreate("stream:" + topic, group,
StreamGroupParams.xgroupParams());
} catch (JedisBusyException e) {
// 消费组已存在
}
}
public List<StreamEntry> consume(String topic, String group,
String consumer, int count) {
return jedis.xreadGroup(
group, consumer,
new HashMap<String, StreamEntryID>() {{
put("stream:" + topic, StreamEntryID.last());
}},
count);
}
public void ack(String topic, String group, StreamEntryID id) {
jedis.xack("stream:" + topic, group, id);
}
}
5. 适用场景
| 功能 | 适用场景 |
|---|---|
| 接口限流 | API 限流、防刷、Sentinel 降级 |
| 延迟队列 | 订单超时取消、定时提醒、超时处理 |
| 消息队列 | 异步解耦、流量削峰、可靠消息 |
6. 不适用场景与替代方案
| 功能 | 不适合 Redis 的场景 | 替代方案 |
|---|---|---|
| 接口限流 | 复杂的分布式限流 | Sentinel、Guava RateLimiter |
| 延迟队列 | 大量延迟任务 | 专用延迟队列(如 RocketMQ 延迟消息) |
| 消息队列 | 大规模消息堆积 | Kafka、RocketMQ |
7. 优缺点与技术取舍
接口限流: 简单高效,但复杂场景能力有限 延迟队列: 实现简单,但扫描有延迟 消息队列(List): 简单但不可靠 消息队列(Stream): 可靠但实现复杂
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 限流突刺 | 使用令牌桶或漏桶算法 |
| 延迟任务丢失 | 使用 Stream + ACK + 持久化 |
| 消息堆积 | 增加消费者、分片处理 |
9. 版本差异与实现边界
| 功能 | 引入版本 |
|---|---|
| List 队列 | Redis 1.0+ |
| Lua 脚本 | Redis 2.6+ |
| Stream | Redis 5.0+ |
| Redis Functions | Redis 7.0+ |
10. 常见追问
- Q: 接口限流的滑动窗口和固定窗口怎么选? A: 滑动窗口没有临界突刺问题,更平滑;固定窗口实现简单但有突刺。生产中推荐滑动窗口。
- Q: 延迟队列的 ZSet 扫描间隔怎么确定? A: 根据延迟精度要求。通常 1 秒一次扫描,精度为秒级。如果需要毫秒级,使用 Redis Keyspace Notification 或 Redisson 延迟队列。
- Q: Stream 和 Kafka 怎么选? A: Stream 适合轻量级消息(百万级/天),Kafka 适合大规模消息(亿级/天)。Stream 更简单,Kafka 更专业。
11. 易错点
- ❌ 错误:ZSet 延迟队列可以精确到毫秒。✅ 正确:ZSet 扫描有间隔,通常精度为秒级。需要毫秒级精度使用 Redisson 延迟队列或 Keyspace Notification。
- ❌ 错误:List 消息队列支持消息确认。✅ 正确:List 队列是即取即删,不支持 ACK。Stream 支持 ACK。
- ❌ 错误:Lua 脚本不能调用 Redis 命令。✅ 正确:Lua 脚本通过 redis.call() 调用 Redis 命令,这是 Lua 脚本的核心功能。
一句话总结
接口限流用 ZSet+Lua 实现滑动窗口,延迟队列用 ZSet 按时间戳排序扫描,消息队列用 List(简单)或 Stream(可靠)实现,三者都充分利用了 Redis 的数据结构和原子操作特性。
Redis怎么保证库存扣减的可靠性,防止多扣/多增?
原始问法:
- Redis怎么保证库存扣减的可靠性,防止多扣/多增?
来源题目:
SRC-06-67-295
面试先答
Redis 保证库存扣减可靠性的核心方案是原子操作 + Lua 脚本 + 持久化:① 用 Lua 脚本将"检查库存→扣减库存→记录订单"合成一个原子操作,防止并发超卖;② Redis 单线程保证 Lua 脚本执行期间不会被其他命令干扰;③ 配合持久化(AOF/混合持久化)确保数据不丢;④ 关键场景配合数据库最终一致性校验。对于极端高并发,还可以通过本地预扣 + 异步回源的方式进一步优化。
核心结论
- 库存扣减用 Lua 脚本保证原子性。
- Redis 单线程天然避免并发问题。
- 配合持久化保证数据可靠性。
- 极端场景需要数据库最终一致性校验。
1. 是什么
库存扣减的核心矛盾是:在高并发下,如何防止超卖(多扣)和重复扣减。
超卖场景:
库存 = 1
请求 A: 读库存 = 1 → 准备扣减
请求 B: 读库存 = 1 → 准备扣减
请求 A: 扣减 → 库存 = 0
请求 B: 扣减 → 库存 = -1(超卖!)
2. 为什么需要它
电商秒杀、限时抢购等场景下,高并发库存扣减需要:
- 原子性:检查和扣减必须是原子操作,不能被并发打断
- 可靠性:扣减结果必须持久化,不能丢失
- 高性能:支撑万级 QPS 的并发扣减
3. 底层原理与完整流程
非原子扣减的问题:
// 错误做法(非原子)
int stock = Integer.parseInt(jedis.get("product:stock:1001"));
if (stock > 0) {
jedis.decr("product:stock:1001");
// 可能已被其他线程扣减
}
原子扣减方案:
方案一:DECR 原子操作(简单场景)
-- 扣减库存
-- KEYS[1]: 库存 key
-- ARGV[1]: 扣减数量
-- 返回:扣减后的库存,如果库存不足返回 -1
local key = KEYS[1]
local decrement = tonumber(ARGV[1])
local stock = tonumber(redis.call('GET', key))
if stock == nil then
return -1 -- 库存不存在
end
if stock < decrement then
return -1 -- 库存不足
end
redis.call('DECRBY', key, decrement)
return stock - decrement
方案二:Lua 脚本原子扣减 + 记录订单(推荐)
-- KEYS[1]: 库存 key
-- KEYS[2]: 订单记录 key
-- ARGV[1]: 扣减数量
-- ARGV[2]: 订单 ID
-- ARGV[3]: 用户 ID
local stockKey = KEYS[1]
local orderKey = KEYS[2]
local decrement = tonumber(ARGV[1])
local orderId = ARGV[2]
local userId = ARGV[3]
-- 检查库存
local stock = tonumber(redis.call('GET', stockKey))
if stock == nil or stock < decrement then
return -1 -- 库存不足
end
-- 扣减库存
redis.call('DECRBY', stockKey, decrement)
-- 记录订单(Hash 结构)
redis.call('HSET', orderKey, orderId, userId)
-- 设置过期时间(30 分钟)
redis.call('EXPIRE', orderKey, 1800)
return 1 -- 扣减成功
方案三:预扣 + 异步(极端高并发)
1. Redis 中设置预扣库存
2. 秒杀请求直接扣减 Redis 预扣库存
3. 异步将扣减结果同步到数据库
4. 用户支付成功 → 确认扣减
5. 用户超时未支付 → 回滚预扣
防止重复扣减:
-- KEYS[1]: 库存 key
-- KEYS[2]: 订单记录 key
-- KEYS[3]: 去重 key
-- ARGV[1]: 扣减数量
-- ARGV[2]: 订单 ID
-- 检查是否已处理(防止重复提交)
if redis.call('SETNX', KEYS[3], ARGV[2]) == 0 then
return -2 -- 重复请求
end
-- 扣减库存
-- ...(同上)
4. 怎么使用
Java 实现(使用 Jedis):
public class StockService {
private static final String STOCK_LUA =
"local stock = tonumber(redis.call('GET', KEYS[1])) " +
"if stock == nil or stock < tonumber(ARGV[1]) then " +
" return -1 " +
"end " +
"redis.call('DECRBY', KEYS[1], ARGV[1]) " +
"redis.call('SADD', KEYS[2], ARGV[2]) " +
"return 1";
@Autowired
private Jedis jedis;
public boolean deductStock(Long productId, int count,
String orderId, String userId) {
String stockKey = "product:stock:" + productId;
String orderKey = "orders:" + productId;
String dedupKey = "dedup:" + orderId;
Long result = jedis.eval(STOCK_LUA,
Arrays.asList(stockKey, orderKey, dedupKey),
String.valueOf(count), orderId, userId);
return result == 1L;
}
}
Redisson 实现(更简洁):
RAtomicLong stock = redisson.getAtomicLong("product:stock:1001");
long remaining = stock.addAndGet(-1); // 原子扣减
if (remaining < 0) {
stock.incrementAndGet(); // 回滚
throw new StockNotEnoughException();
}
数据库最终一致性保证:
方案一:异步校验
1. Redis 扣减成功
2. 异步写入数据库订单
3. 定时任务对账:Redis 订单 vs 数据库订单
4. 不一致时补偿
方案二:可靠消息
1. Redis 扣减
2. 发送消息到 MQ
3. 消费者处理消息,扣减数据库
4. 消费失败重试
5. 适用场景
| 场景 | 推荐方案 |
|---|---|
| 普通扣减 | DECR 原子操作 |
| 高并发秒杀 | Lua 脚本原子扣减 |
| 极端高并发 | 预扣 + 异步回源 |
| 强一致性 | Redis + 数据库最终一致性 |
6. 不适用场景与替代方案
- 零库存丢失 → Redis + 数据库 + 异步校验
- 超大规模秒杀 → 本地缓存预扣 + Redis 二次校验
- 复杂库存规则 → 使用专业库存管理系统
7. 优缺点与技术取舍
Lua 脚本扣减优点: 原子性、高性能、简单可靠 Lua 脚本扣减缺点: 复杂逻辑脚本过长、调试困难
预扣方案优点: 抗高并发 预扣方案缺点: 需要回滚机制、实现复杂
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| 库存不一致 | 定时对账、异步校验 |
| 超卖 | Lua 脚本原子扣减 + 数据库二次校验 |
| 重复提交 | SETNX 去重、Token 机制 |
9. 版本差异与实现边界
DECRBY 命令自 Redis 1.0+ 支持。Lua 脚本自 Redis 2.6+ 支持。Redis 7.0 的 Functions 提供了更强大的服务端逻辑能力。
10. 常见追问
- Q: 扣减库存的 Lua 脚本和 SET NX 哪个更可靠? A: Lua 脚本更可靠。SET NX 只能加锁,锁内的检查和扣减仍需要两步。Lua 脚本将检查和扣减合为一步,真正原子。
- Q: Redis 扣减后数据库怎么保证一致? A: 三种方式:① 异步扣减数据库 + 定时对账;② 可靠消息队列;③ 双写(Redis + DB)+ 补偿。
- Q: 如何防止用户重复下单? A: 使用 Token 机制——用户获取 Token 时预扣库存,下单时携带 Token 验证,Token 使用一次即失效。
11. 易错点
- ❌ 错误:DECR 一定不会超卖。✅ 正确:DECR 本身不检查库存,需要 Lua 脚本先检查再扣减。
- ❌ 错误:Redis 扣减能完全替代数据库。✅ 正确:Redis 是缓存层,最终数据需要持久化到数据库。
- ❌ 错误:Lua 脚本能防止所有并发问题。✅ 正确:Lua 脚本在 Redis 单线程内是原子的,但 Redis 主从切换可能导致脚本重复执行。
一句话总结
Redis 通过 Lua 脚本实现原子扣减,配合 SETNX 去重和持久化,防止库存超卖和重复扣减,是电商秒杀场景的核心技术方案。
多机器部署本地缓存如何保证数据一致性?
原始问法:
- 多机器部署本地缓存如何保证数据一致性?
来源题目:
SRC-06-67-296
面试先答
多机器部署本地缓存时数据一致性的核心挑战是:一个实例修改了数据,其他实例的本地缓存还是旧数据。解决方案有三种:① 基于 Redis Pub/Sub 的通知机制——写操作后发布失效消息,所有实例订阅并删除本地缓存;② 基于版本号的校验机制——每个 Key 关联版本号,本地缓存存储版本号,读取时比对;③ 基于短 TTL 的自动过期——本地缓存设置较短 TTL(如 30 秒),到期自动刷新。生产中通常组合使用 Pub/Sub + 短 TTL 作为双保险。
核心结论
- 本地缓存一致性核心问题是多实例数据不同步。
- 解决方案:Pub/Sub 通知、版本号校验、短 TTL 自动过期。
- 推荐组合使用 Pub/Sub + 短 TTL 双保险。
1. 是什么
多机器部署本地缓存时,每个 JVM 实例的本地缓存是独立的。当一个实例修改了数据并更新了自己的本地缓存,其他实例的本地缓存仍然是旧值,造成数据不一致。
2. 为什么需要它
本地缓存(Caffeine、Guava Cache)极大提升了读性能,但在多实例部署时引入了一致性问题:
- 实例 A 写数据 → 更新本地缓存 → 正确
- 实例 B 读数据 → 本地缓存命中 → 旧数据
- 用户看到不一致的数据
3. 底层原理与完整流程
方案一:Redis Pub/Sub 通知机制
写入方:
1. 写数据库
2. 更新自己的本地缓存
3. 发布缓存失效消息到 Redis Channel
→ PUBLISH cache:evict:{key} {key}
所有实例:
1. 订阅失效 Channel
→ SUBSCRIBE cache:evict:* (使用模式订阅)
2. 收到消息后删除本地缓存
→ localCache.invalidate(key)
优点:实时性高,变更立即通知
缺点:Pub/Sub 不持久化,新订阅的实例收不到历史消息
方案二:版本号校验机制
写入方:
1. 写数据库
2. INCR cache:version:{key}(版本号 +1)
3. 更新自己的本地缓存(带版本号)
读取方:
1. 从本地缓存读取数据 + 版本号
2. 从 Redis 读取最新版本号
3. 如果版本号一致 → 返回缓存数据
4. 如果版本号不一致 → 回源数据库,更新缓存
优点:可靠,不丢消息
缺点:每次读多一次 Redis 请求
方案三:短 TTL 自动过期
本地缓存设置较短 TTL(30 秒):
→ 最长 30 秒后所有实例会回源刷新
→ 最终一致性
优点:实现简单
缺点:存在最长 TTL 时间的不一致窗口
推荐方案组合:Pub/Sub + 短 TTL
1. 主方案:Pub/Sub 实时通知
→ 写入方发布失效消息
→ 所有实例实时删除本地缓存
2. 兜底方案:短 TTL(30 秒)
→ Pub/Sub 遗漏的消息(如实例重启期间)
→ 最长 30 秒后自动刷新
4. 怎么使用
基于 Redis Pub/Sub 的本地缓存一致性:
@Component
public class LocalCacheManager {
private final Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(30, TimeUnit.SECONDS) // 兜底 TTL
.build();
private final Jedis jedis;
@PostConstruct
public void init() {
// 订阅缓存失效消息
new Thread(() -> {
jedis.psubscribe(new JedisPubSub() {
@Override
public void onPMessage(String pattern, String channel,
String message) {
localCache.invalidate(message);
}
}, "cache:evict:*");
}).start();
}
public void evictCache(String key) {
// 删除自己的本地缓存
localCache.invalidate(key);
// 通知其他实例
jedis.publish("cache:evict:" + key, key);
}
public Object get(String key) {
Object value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// 回源数据库
value = loadFromDB(key);
if (value != null) {
localCache.put(key, value);
}
return value;
}
public void update(String key, Object value) {
// 1. 写数据库
saveToDB(key, value);
// 2. 更新自己的本地缓存
localCache.put(key, value);
// 3. 通知其他实例
jedis.publish("cache:evict:" + key, key);
}
}
基于版本号的一致性(更可靠):
@Component
public class VersionedCacheManager {
private final Cache<String, VersionedEntry> localCache =
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.build();
private final Jedis jedis;
public Object get(String key) {
VersionedEntry entry = localCache.getIfPresent(key);
if (entry != null) {
// 检查版本号
Long latestVersion = jedis.hget("cache:version", key);
if (latestVersion != null &&
latestVersion.equals(entry.getVersion())) {
return entry.getValue(); // 版本一致,返回缓存
}
// 版本不一致,失效缓存
localCache.invalidate(key);
}
// 回源数据库
Object value = loadFromDB(key);
if (value != null) {
Long version = jedis.hincrBy("cache:version", key, 0);
localCache.put(key, new VersionedEntry(value, version));
}
return value;
}
public void update(String key, Object value) {
// 1. 写数据库
saveToDB(key, value);
// 2. 更新版本号(原子递增)
Long version = jedis.hincrBy("cache:version", key, 1);
// 3. 更新本地缓存
localCache.put(key, new VersionedEntry(value, version));
}
@Data
private static class VersionedEntry {
private final Object value;
private final Long version;
}
}
5. 适用场景
| 方案 | 适用场景 |
|---|---|
| Pub/Sub | 实时性要求高的场景 |
| 版本号 | 可靠性要求高的场景 |
| 短 TTL | 一致性要求不高的场景 |
| 组合方案 | 通用生产场景 |
6. 不适用场景与替代方案
- 强一致性要求 → 不使用本地缓存
- 频繁更新 → 直接走分布式缓存(Redis)
- 简单单体应用 → 不需要本地缓存
7. 优缺点与技术取舍
Pub/Sub 优点: 实时性高、实现简单 Pub/Sub 缺点: 不持久化、可能丢消息
版本号优点: 可靠、不丢消息 版本号缺点: 每次读多一次 Redis 请求
短 TTL 优点: 实现最简单 短 TTL 缺点: 存在不一致窗口
8. 常见问题及解决方案
| 问题 | 解决方案 |
|---|---|
| Pub/Sub 消息丢失 | 配合短 TTL 作为兜底 |
| 版本号占用内存 | 使用 Hash 结构存储,定期清理 |
| 本地缓存不一致导致业务异常 | 关键数据走分布式缓存,本地缓存仅做加速 |
9. 版本差异与实现边界
Redis Pub/Sub 自 Redis 2.0+ 支持。模式订阅(PSUBSCRIBE)支持通配符匹配。Redis 5.0+ 的 Stream 提供了更可靠的消息传递机制,可作为 Pub/Sub 的替代方案。
10. 常见追问
- Q: Pub/Sub 消息会丢失吗? A: 会。Pub/Sub 不持久化,订阅者离线或重启期间的消息会丢失。需要配合短 TTL 作为兜底。
- Q: 版本号方案的 Redis 请求量会不会很大? A: 每次读多一次 HGET,对于 QPS 不高的场景可接受。如果 QPS 极高,可以缓存版本号到本地,但需要额外同步。
- Q: 本地缓存的 TTL 怎么确定? A: 取决于数据更新频率。更新频繁的用短 TTL(10-30 秒),更新少的用长 TTL(5-60 分钟)。
11. 易错点
- ❌ 错误:本地缓存可以做到强一致。✅ 正确:本地缓存只能做到最终一致,存在短暂不一致窗口。
- ❌ 错误:Pub/Sub 是可靠的。✅ 正确:Pub/Sub 不持久化,消息可能丢失。
- ❌ 错误:短 TTL 可以保证完全一致。✅ 正确:短 TTL 只能保证最终一致,存在 TTL 时间的不一致窗口。
一句话总结
多机器本地缓存一致性通过 Pub/Sub 实时通知 + 短 TTL 兜底组合实现,写入方发布失效消息,所有实例实时删除本地缓存,配合短 TTL 保证最终一致性。