主题
八、Redis(15 题)
8.1 ★★★★Redis 常用的数据类型有哪些?各自的底层数据结构和典型使用场景是什么?
📖 点击查看答案
五种基本数据类型:
1. String(字符串):
- 底层:SDS(Simple Dynamic String),支持动态扩容、二进制安全、O(1) 获取长度
- Redis 3.2 后 SDS 分 5 种类型(sdshdr5/8/16/32/64),根据长度选择,节省内存
- int 编码(整数)、embstr(<=44 字节)、raw(>44 字节)
- 场景:
- 缓存对象(JSON)
- 计数器(
INCR) - 分布式锁(
SET NX EX) - 全局唯一 ID(
INCR) - 限流(
INCR+ 过期)
2. Hash(哈希):
- 底层:
- listpack(Redis 7.0+,替代 ziplist):元素少且短时(默认 128 个、64 字节)
- hashtable:元素多或长时
- 场景:
- 存储对象(比 String 存 JSON 更节省内存,可单独修改字段)
- 购物车(
HSET cart:userId productId quantity) - 计数器分组(
HINCRBY)
3. List(列表):
- 底层:
- listpack(Redis 7.0+):短列表
- quicklist(快速列表):双向链表 + 每个节点是 listpack,兼顾内存和性能
- 场景:
- 消息队列(
LPUSH+BRPOP,或 Stream) - 最新消息列表
- 文章列表
- 栈(
LPUSH+LPOP)
- 消息队列(
4. Set(集合):
- 底层:
- intset(整数集合):元素都是整数且少(默认 512 个)
- hashtable:其他情况
- 场景:
- 标签、分类
- 共同好友(
SINTER) - 抽奖(
SRANDMEMBER、SPOP) - 去重(
SADD)
5. ZSet(有序集合):
- 底层:
- listpack(Redis 7.0+):元素少且短
- skiplist + hashtable:跳表支持范围查询,hashtable 支持 O(1) 查找
- 场景:
- 排行榜
- 延迟队列(score 为执行时间)
- 带权重的消息队列
- 范围查询(
ZRANGEBYSCORE)
三种特殊数据类型:
6. Bitmap(位图):
- 基于 String,按位操作
- 场景:签到打卡、布隆过滤器、活跃用户统计
7. HyperLogLog(基数统计):
- 固定 12KB,误差 0.81%
- 场景:UV 统计、基数去重
8. Geo(地理位置):
- 基于 ZSet,使用 Geohash 编码
- 场景:附近的人、距离计算
9. Stream(流,Redis 5.0+):
- 基于 radix tree
- 场景:消息队列(支持消费组、ACK)
bash
# 各类型常用命令
SET key value # String
HSET hash field value # Hash
LPUSH list value # List
SADD set member # Set
ZADD zset score member # ZSet
SETBIT bitmap 10 1 # Bitmap
PFADD hll member # HyperLogLog
GEOADD geo lng lat member # Geo
XADD stream * field value # Stream编码转换条件(Redis 7.0+):
- Hash:
hash-max-listpack-entries=128、hash-max-listpack-value=64 - List:
list-max-listpack-size=-2(每个 listpack 8KB) - Set:
set-max-intset-entries=512 - ZSet:
zset-max-listpack-entries=128、zset-max-listpack-value=64
8.2 ★★Redis 中的跳表(SkipList)的实现原理是什么?为什么不用红黑树?
📖 点击查看答案
跳表原理:
- 有序的多层链表,最底层包含所有元素
- 上层是下层的"索引",每隔几个元素抽一个,形成多层索引
- 查询时从最高层开始,向右走到不了再下一层,类似二分查找
Level 3: 1 -----------------------> 9
Level 2: 1 --------> 5 -----------> 9
Level 1: 1 ---> 3 --> 5 ---> 7 ---> 9
Level 0: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9ZSet 中的跳表结构:
c
typedef struct zskiplistNode {
sds ele; // 元素值
double score; // 分值
struct zskiplistNode *backward; // 后退指针
struct zskiplistLevel {
struct zskiplistNode *forward; // 前进指针
unsigned long span; // 跨度(用于计算排名)
} level[]; // 多层
} zskiplistNode;核心操作:
- 查找:从最高层开始,向右比较 score,大于则下一层,直到最底层
- 插入:随机生成层数(
ZSKIPLIST_MAXLEVEL=32,概率 1/4 升层),调整前后指针 - 删除:找到节点后调整前后指针
- 范围查询:找到起点后沿最底层 forward 指针遍历
层数生成:
c
int zslRandomLevel(void) {
int level = 1;
while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) // ZSKIPLIST_P = 0.25
level += 1;
return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}为什么用跳表而不用红黑树:
范围查询高效:
- ZSet 常用
ZRANGEBYSCORE、ZRANGE范围查询 - 跳表找到起点后,沿最底层链表遍历即可,O(logN + M)
- 红黑树范围查询需中序遍历,复杂
- ZSet 常用
实现简单:
- 跳表代码量少(几百行),易于理解和维护
- 红黑树实现复杂(旋转、染色),易出错
内存灵活:
- 跳表每个节点层数随机,平均 O(logN) 指针
- 红黑树每个节点固定两个子指针
- 跳表内存占用略高但可接受(平均 1.33 个指针/节点)
并发友好(理论上):
- 跳表局部修改,红黑树插入可能引起多级旋转
- Redis 单线程,这点不关键,但跳表实现简单
缓存友好:
- 跳表节点连续访问局部性好
跳表 + 字典组合:
- ZSet 同时使用跳表和字典(hashtable)
- 跳表支持范围查询和有序性
- 字典支持 O(1) 查找元素分值(
ZSCORE) - 两者共享元素和分值的指针,不额外存储数据
8.3 ★★★★Redis 为什么这么快?请从架构、数据结构、I/O 模型等角度分析。为什么之前一直是单线程的?
📖 点击查看答案
Redis 快的原因:
1. 基于内存:
- 数据存储在内存,读写速度比磁盘快几个数量级
- 避免磁盘 I/O 瓶颈
2. 单线程模型:
- 避免多线程的上下文切换开销
- 避免锁竞争(数据结构操作无需加锁)
- 代码简洁,逻辑清晰
3. 高效的 I/O 多路复用:
- 使用 epoll(Linux)、kqueue(BSD)实现 I/O 多路复用
- 单线程监听大量客户端连接,事件驱动
- 非阻塞 I/O,避免线程阻塞
4. 高效的数据结构:
- SDS:O(1) 获取长度,二进制安全
- 字典:渐进式 rehash,避免阻塞
- 跳表:范围查询高效
- intset、listpack:小数据紧凑存储,节省内存
- 压缩列表(ziplist/listpack):连续内存,缓存友好
5. 合理的编码转换:
- 小数据用紧凑编码(listpack、intset),大数据用高效结构(hashtable、skiplist)
- 自动根据数据量切换编码
6. 简单的协议:
- RESP 协议简单,解析快
- 客户端发送命令,服务端单线程顺序执行
7. 无锁设计:
- 单线程无需锁
- 数据结构操作原子性
为什么之前一直是单线程:
瓶颈不在 CPU:
- Redis 性能瓶颈是内存和网络,不是 CPU
- 单核已能达到 10 万+ QPS,多线程对 CPU 提升有限
避免并发问题:
- 多线程需要锁,增加复杂度和开销
- 单线程天然原子性,数据结构简单
维护简单:
- 单线程代码易理解、调试、维护
- 避免多线程的竞态、死锁问题
客户端协议简单:
- 命令顺序执行,结果可预测
历史选择:
- Redis 设计于 2009 年,当时多核不普及
- 单线程已满足需求
Redis 6.0 引入多线程的原因:
- 网络吞吐成为瓶颈(大 Value、大量连接)
- 单线程处理网络读写成为瓶颈
- 多线程只处理网络 I/O,命令执行仍单线程
- 保持命令执行的原子性,同时提升网络性能
Redis 6.0 多线程模型:
主线程(单线程):
- 接受连接
- 命令执行(保持单线程)
- 数据结构操作
I/O 线程(多线程):
- 读取客户端命令(socket → buffer)
- 写回响应(buffer → socket)ini
io-threads 4 # I/O 线程数
io-threads-do-reads yes # 开启读多线程性能对比:
- 单线程:10-15 万 QPS
- 多线程(6.0+):网络密集场景可提升 1-2 倍
8.4 ★★Redis 6.0 引入的多线程模型是怎样的?Redis 7.0 有哪些重要特性?
📖 点击查看答案
Redis 6.0 多线程模型:
核心思想:
- 命令执行仍单线程,保证原子性和数据安全
- 网络 I/O 多线程化,解决网络瓶颈
工作流程:
- 主线程接受客户端连接
- 客户端发送命令,主线程将 socket 分配给 I/O 线程组
- I/O 线程读取 socket 数据到缓冲区(并行)
- 主线程串行执行所有命令
- I/O 线程将结果写回 socket(并行)
- 主线程清理
Client1 ─┐
Client2 ─┼─> I/O 线程1 读 ─┐
Client3 ─┼─> I/O 线程2 读 ─┼─> 主线程执行 ─> I/O 线程写回
Client4 ─┘ I/O 线程3 读 ─┘配置:
ini
io-threads 4 # I/O 线程数(建议 CPU 核数的一半)
io-threads-do-reads yes # 开启读多线程(默认只写多线程)适用场景:
- 大量连接(如 10 万+)
- 大 Value 读写(网络传输耗时)
- 单线程 CPU 未跑满但网络是瓶颈
不适用场景:
- 命令本身耗 CPU(如 SORT、ZUNIONSTORE)
- 连接数少、Value 小
Redis 7.0 重要特性:
Function(函数):
- 替代 EVAL/EVALSHA,支持持久化 Lua 函数
- 函数库管理,避免每次传输脚本
luaFUNCTION LOAD '#!lua name=mylib redis.register_function("myfunc", function(keys, args) return redis.call("GET", keys[1]) end)' FUNCTION CALL mylib myfunc 1 mykeylistpack 替代 ziplist:
- Hash、List、ZSet 的小数据编码从 ziplist 改为 listpack
- 解决 ziplist 的连锁更新问题(cascade update)
- 内存更紧凑,性能更好
Multi-Part AOF:
- AOF 拆分为 base(RDB 快照)+ increment(增量 AOF)+ manifest(清单)
- 避免重写时的大文件 IO 压力
- 重启恢复更灵活
Sharded Pub/Sub:
- 发布订阅按分片分发,跨集群节点可用
SPUBLISH、SSUBSCRIBE命令
ACL v2:
- 更细粒度的权限控制
- 支持用户管理、命令分类
Cluster 管理增强:
CLUSTER LINKS查看节点连接- 集群性能优化
性能优化:
- 渐进式 rehash 优化
- 内存使用优化
Redis 7.2+ 特性:
- 引入 Triggers(实验性)
- 性能持续优化
8.5 ★★★★Redis 的持久化机制有哪些?RDB 和 AOF 的优缺点对比是什么?混合持久化是如何工作的?
📖 点击查看答案
Redis 持久化机制:
1. RDB(Redis Database):
- 在某个时间点对内存数据的快照,二进制文件
dump.rdb - 触发方式:
SAVE:同步,阻塞BGSAVE:异步,fork 子进程- 配置
save m n:m 秒内 n 次修改触发 - 主从复制时从库请求触发
- 原理:
BGSAVE通过fork()创建子进程(COW,Copy-On-Write),子进程将内存数据写入临时 RDB 文件,替换旧文件
2. AOF(Append-Only File):
- 记录每次写命令,追加到
appendonly.aof - 触发:每条写命令都记录
- 刷盘策略(
appendfsync):always:每次写都刷盘(最安全,性能差)everysec:每秒刷盘(默认,折中)no:由 OS 决定刷盘(性能好,可能丢数据)
- AOF 重写(Rewrite):压缩 AOF 文件,去除冗余命令(如多次 SET 同一 key 只保留最后一条)
RDB vs AOF 对比:
| 特性 | RDB | AOF |
|---|---|---|
| 数据完整性 | 时间点快照,可能丢最后一次快照后的数据 | 每秒刷盘,最多丢 1s 数据 |
| 文件大小 | 小(二进制压缩) | 大(文本命令) |
| 恢复速度 | 快(直接加载二进制) | 慢(重放命令) |
| 性能影响 | 小(子进程写) | 较大(每条写都记录) |
| 可读性 | 不可读 | 可读(文本命令) |
| 适用场景 | 备份、灾难恢复 | 数据安全要求高 |
混合持久化(Redis 4.0+):
原理:
- AOF 重写时,先以 RDB 格式写入当前内存快照,再追加增量 AOF 命令
- 恢复时先加载 RDB 部分(快),再重放 AOF 部分(增量)
- 结合 RDB 的快速恢复和 AOF 的数据完整性
AOF 文件结构(混合持久化):
┌─────────────────────────────────┐
│ RDB 二进制数据(全量快照) │ ← BGREWRITEAOF 时写入
├─────────────────────────────────┤
│ AOF 命令文本(增量) │ ← 重写后的新命令
│ SET key1 value1 │
│ SET key2 value2 │
└─────────────────────────────────┘配置:
ini
aof-use-rdb-preamble yes # 开启混合持久化(Redis 4.0+,5.0+ 默认开启)恢复流程:
- 读取 AOF 文件
- 检测开头是否为 RDB 格式(魔数
REDIS) - 是 RDB:先加载 RDB 部分恢复全量数据
- 再重放后续 AOF 命令恢复增量数据
生产建议:
- 开启混合持久化(兼顾恢复速度和数据完整性)
appendfsync everysec(平衡性能和安全)- 定期
BGSAVE做备份 - 监控 AOF 文件大小,及时重写
Redis 7.0 Multi-Part AOF:
- AOF 拆分为 base(RDB 快照)+ increment(增量 AOF)+ manifest(清单)
- 避免重写时的大文件 IO 压力
- 重启恢复更灵活
8.6 ★★★★Redis 的过期删除策略和内存淘汰策略有哪些?LRU 和 LFU 的实现差异是什么?惰性删除和定期删除如何配合工作?
📖 点击查看答案
过期删除策略:
1. 惰性删除(Lazy Expiration):
- 访问 key 时检查是否过期,过期则删除
- 优点:CPU 友好,不主动扫描
- 缺点:过期 key 不被访问会一直占用内存(内存泄漏)
2. 定期删除(Periodic Expiration):
- 每隔一段时间(
hz配置,默认 10 次/秒)随机抽取部分设置了过期时间的 key 检查 - 若过期比例 > 25%,继续扫描,直到比例降低或时间用完
- 优点:主动清理过期 key
- 缺点:可能清理不及时,随机抽样有遗漏
3. 配合工作:
- Redis 同时使用惰性删除 + 定期删除
- 惰性删除保证访问的 key 一定不过期
- 定期删除主动清理过期但未被访问的 key
- 两者互补,平衡 CPU 和内存
c
// 伪代码
void activeExpireCycle() {
while (time < 25ms && expired_ratio > 25%) {
随机抽样 20 个设置了过期时间的 key;
删除已过期的;
if (expired_ratio > 25%) continue;
else break;
}
}内存淘汰策略(maxmemory-policy):
| 策略 | 说明 |
|---|---|
noeviction | 不淘汰,写入报错(默认) |
allkeys-lru | 所有 key 中淘汰最近最少使用 |
allkeys-lfu | 所有 key 中淘汰最少使用频率(Redis 4.0+) |
allkeys-random | 随机淘汰 |
volatile-lru | 设置过期的 key 中淘汰 LRU |
volatile-lfu | 设置过期的 key 中淘汰 LFU |
volatile-random | 设置过期的 key 中随机淘汰 |
volatile-ttl | 设置过期的 key 中淘汰 TTL 最短的 |
LRU(Least Recently Used)实现:
- Redis 的 LRU 是近似 LRU,非精确 LRU(精确 LRU 需双向链表,内存开销大)
- 每个 key 有 24 位
lru字段,记录最后访问时间戳(秒级,截断) - 淘汰时随机采样 N 个 key(
maxmemory-samples,默认 5),淘汰最久未使用的 - Redis 3.0+ 引入候选池:采样后放入候选池(默认 16),淘汰池中最久未使用的
LFU(Least Frequently Used)实现(Redis 4.0+):
- 每个 key 有 24 位字段,分为 16 位时间戳 + 8 位计数器
- 8 位计数器范围 0-255,使用对数概率递增算法:c
double r = random(); double baseval = log(counter - LFU_INIT_VAL + 1); double p = 1.0 / (baseval + 1); if (r < p) counter++; // 访问次数越多,计数器增长越慢 - 长期未访问的 key,计数器会衰减(
lfu-decay-time,默认 1 分钟衰减 1)
LRU vs LFU 差异:
| 特性 | LRU | LFU |
|---|---|---|
| 依据 | 最后访问时间 | 访问频率 |
| 数据结构 | 24 位时间戳 | 16 位时间 + 8 位计数 |
| 算法 | 近似 LRU + 候选池 | 对数计数器 + 衰减 |
| 场景 | 时间局部性强的数据 | 频率访问差异大的数据 |
| 问题 | 偶尔访问的 key 可能"续命" | 新 key 计数低易被淘汰 |
生产建议:
- 缓存场景:
allkeys-lru或allkeys-lfu - 有持久数据 + 缓存:
volatile-lru maxmemory设置为物理内存的 60-70%maxmemory-samples调大到 10 提升精度
8.7 ★★★★Redis 的高可用方案有哪些?主从复制、哨兵、集群模式的原理和区别是什么?故障转移的过程是怎样的?
📖 点击查看答案
Redis 高可用方案:
1. 主从复制(Master-Slave):
- 一主多从,从库复制主库数据
- 读写分离,主写从读
- 故障需手动切换(无自动故障转移)
主从复制原理:
- 全量同步:从库首次连接主库,主库 BGSAVE 生成 RDB 发送给从库,从库加载
- 增量同步:主库将写命令发送给从库(通过 replication backlog)
- PSYNC:2.8+ 支持部分重同步,断线重连后只同步缺失部分
全量同步流程:
1. 从库发送 PSYNC ? -1
2. 主库 BGSAVE 生成 RDB
3. 主库发送 +FULLRESYNC runid offset
4. 主库发送 RDB 文件
5. 从库加载 RDB
6. 主库发送缓冲区中的写命令2. 哨兵模式(Sentinel):
- 在主从基础上增加 Sentinel 节点(独立进程),负责监控、通知、自动故障转移
- 哨兵集群(3+ 节点)避免单点故障
哨兵职责:
- 监控:定时 ping 主从节点和其他哨兵
- 通知:节点异常时通知运维或客户端
- 自动故障转移:主库宕机时选举新主库
- 配置提供:客户端连哨兵获取主库地址
故障转移过程:
- 主观下线(SDOWN):单个哨兵发现主库 30s(
down-after-milliseconds)未响应 - 客观下线(ODOWN):超过 quorum 个哨兵确认主库下线
- 选举 Leader 哨兵:Raft 协议选出一个哨兵执行故障转移
- 选举新主库:
- 排除故障从库
- 按
slave-priority(优先级)选择 - 优先级相同选
offset最大的(数据最新) - offset 相同选
runid最小的
- 切换:
- 将新主库执行
SLAVEOF NO ONE - 将其他从库指向新主库
SLAVEOF new_master - 通知客户端新主库地址
- 旧主库恢复后变为从库
- 将新主库执行
3. 集群模式(Cluster):
- 去中心化,多主多从,数据分片
- 通过 16384 个哈希槽 分片,每个主节点负责一部分槽
- 客户端直连任意节点,节点转发或返回 MOVED/ASK
- 支持自动故障转移(节点间 Gossip 通信)
三种方案对比:
| 特性 | 主从复制 | 哨兵 | 集群 |
|---|---|---|---|
| 数据分片 | 否 | 否 | 是(16384 槽) |
| 自动故障转移 | 否 | 是 | 是 |
| 容量扩展 | 垂直(单机内存) | 垂直 | 水平(多节点) |
| 复杂度 | 低 | 中 | 高 |
| 适用场景 | 读多写少 | 中小规模 | 大规模、高并发 |
集群故障转移:
- 节点间通过 Gossip 协议通信,检测节点存活
- 主节点下线,从节点发起选举(Raft 变种)
- 获得半数以上主节点投票的从节点成为新主节点
- 新主节点广播 PONG,接管槽位
- 客户端收到集群状态更新
ini
# 哨兵配置
sentinel monitor mymaster 192.168.1.1 6379 2 # quorum=2
sentinel down-after-milliseconds mymaster 30000
sentinel parallel-syncs mymaster 1
sentinel failover-timeout mymaster 1800008.8 ★★★★Redis Cluster 的原理是什么?数据分片(哈希槽)机制?节点间如何通信?Gossip 协议是什么?
📖 点击查看答案
Redis Cluster 原理:
1. 数据分片 - 哈希槽(Hash Slot):
- 共 16384 个哈希槽(不是 65536,原因:心跳包小、位运算高效)
- 每个 key 通过
CRC16(key) % 16384计算所属槽 - 每个主节点负责一部分槽(如 3 主节点:0-5460、5461-10922、10923-16383)
- 节点增减时迁移槽
bash
# 客户端路由
SET key value
# 1. 客户端计算 CRC16(key) % 16384 = 槽号
# 2. 槽在本节点:直接处理
# 3. 槽不在本节点:返回 MOVED 错误,客户端缓存槽映射MOVED 响应:
GET key
(error) MOVED 5798 192.168.1.2:6379 # 槽 5798 在 192.168.1.2:6379ASK 响应(迁移中):
GET key
(error) ASK 5798 192.168.1.2:6379 # 槽迁移中,临时到目标节点查询2. 节点通信 - Gossip 协议:
Gossip 原理:
- 每个节点定期向部分节点发送 PING 消息
- PING 包含自己已知的集群信息(节点状态、槽位)
- 接收节点更新自己的集群视图,并回复 PONG
- 信息以"传染病"方式扩散到全集群
通信消息类型:
- MEET:新节点加入集群
- PING:心跳,包含集群信息
- PONG:回复 PING/MEET
- FAIL:通知某节点下线
通信频率:
- 每秒执行 10 次(
cluster-node-timeout/ 10) - 每次选 5 个最久未通信的节点发送 PING
- 每次包含 1/10 的集群节点信息
Gossip 消息结构:
c
typedef struct clusterMsg {
char sig[4]; // "RCmb"
uint16_t totlen; // 消息总长
uint16_t ver; // 协议版本
uint16_t port; // 端口
uint16_t type; // 消息类型
char sender[CLUSTER_NAMELEN]; // 发送者节点 ID
unsigned char myslots[CLUSTER_SLOTS/8]; // 槽位 bitmap(16384/8=2048 字节)
char slaveof[CLUSTER_NAMELEN];
uint16_t notused1;
uint16_t cport; // 集群总线端口
uint16_t flags; // 节点状态
unsigned char state; // 节点状态
clusterMsgData data; // Gossip 数据
} clusterMsg;3. 故障检测与转移:
故障检测:
- 节点 A 向节点 B 发 PING,超时未回复(
cluster-node-timeout,默认 15s) - A 标记 B 为 PFAIL(主观下线)
- A 通过 Gossip 传播 B 的 PFAIL 状态
- 半数以上主节点标记 B 为 PFAIL
- 标记 B 为 FAIL(客观下线),广播 FAIL 消息
故障转移:
- B 的从节点发现 B 下线
- 从节点发起选举(Raft 变种):
- 当前纪元(epoch)+1
- 向其他主节点请求投票
- 获得半数以上主节点投票的从节点胜出
- 胜出从节点执行
SLAVEOF NO ONE成为新主 - 接管原主的所有槽位
- 广播 PONG 通知集群
为什么是 16384 槽(作者 antirez 解释):
- 心跳包大小:16384/8 = 2KB bitmap,若 65536 则 8KB,过大
- 集群规模:Redis Cluster 建议不超过 1000 节点,16384 足够
- 位运算:
CRC16 & 16383高效
8.9 ★★★★缓存穿透、缓存击穿、缓存雪崩的区别是什么?各自的解决方案有哪些?
📖 点击查看答案
三者区别:
| 问题 | 描述 | 原因 |
|---|---|---|
| 缓存穿透 | 查询不存在的数据,缓存和数据库都没有 | 恶意攻击、参数错误 |
| 缓存击穿 | 热点 key 过期瞬间,大量请求打到数据库 | 热点数据过期 |
| 缓存雪崩 | 大量 key 同时过期或 Redis 宕机,请求打到数据库 | 过期时间相同、Redis 故障 |
缓存穿透解决方案:
缓存空值:
javaObject value = redis.get(key); if (value == null) { value = db.query(key); if (value == null) { redis.set(key, "NULL", 60); // 缓存空值,短过期 } else { redis.set(key, value, 3600); } }- 缺点:占用内存,可能不一致
布隆过滤器(Bloom Filter):
- 启动时加载所有存在的 key 到布隆过滤器
- 查询前先过布隆过滤器,不存在则直接返回
javaif (!bloomFilter.mightContain(key)) { return null; // 一定不存在 } // 再查缓存和数据库- 缺点:有误判率(说存在可能不存在,说不存在一定不存在)
参数校验:
- 非法参数(如 id < 0)直接拒绝
缓存击穿解决方案:
互斥锁(Mutex):
- 缓存未命中时,加锁查询数据库并回填缓存
javaString value = redis.get(key); if (value == null) { if (redis.setnx("lock:" + key, "1", 10)) { // 加锁 try { value = db.query(key); redis.set(key, value, 3600); } finally { redis.del("lock:" + key); } } else { Thread.sleep(50); // 短暂等待后重试 return get(key); } }逻辑过期(不设置物理过期时间):
- Value 中记录逻辑过期时间
- 过期后返回旧值,异步更新
javaValueData data = redis.get(key); if (data.getExpireTime().before(now)) { // 异步更新 executor.submit(() -> updateCache(key)); } return data.getValue(); // 返回旧值热点 key 永不过期:
- 配合后台定时刷新
缓存雪崩解决方案:
过期时间随机化:
javaredis.set(key, value, 3600 + random.nextInt(300)); // 基础时间 + 随机多级缓存:
- 本地缓存(Caffeine)+ Redis + 数据库
- Redis 宕机时本地缓存兜底
熔断降级:
- 数据库压力大时,返回默认值或降级页
Redis 高可用:
- 哨兵或集群,避免单点故障
预热:
- 上线前提前加载热点数据
统一防护:
- 限流:Sentinel 限制数据库 QPS
- 缓存预热:定时任务刷新热点 key
- 监控告警:监控缓存命中率、数据库 QPS
8.10 ★★★★如何保证 Redis 缓存与数据库的数据一致性?缓存旁路模式的详细流程?延迟双删策略的原理和缺陷?
📖 点击查看答案
一致性策略:
1. 缓存旁路模式(Cache Aside,推荐):
- 读:先查缓存,未命中查数据库并回填缓存
- 写:先更新数据库,再删除缓存
java
// 读
public Object get(Long id) {
Object value = redis.get("user:" + id);
if (value == null) {
value = db.query(id);
if (value != null) {
redis.set("user:" + id, value, 3600);
}
}
return value;
}
// 写
public void update(Long id, Object value) {
db.update(id, value);
redis.del("user:" + id); // 删除缓存,下次读时回填
}为什么是删除而不是更新缓存:
- 避免并发更新顺序问题(多线程更新数据库和缓存顺序不一致)
- 懒加载,避免频繁更新但很少读取的数据浪费缓存
- 缓存计算复杂时(如多表聚合),更新成本高
2. 读写穿透(Read/Write Through):
- 应用只操作缓存,缓存层同步写数据库
- 缓存作为数据库的代理
3. 异步缓存写入(Write Behind):
- 应用只更新缓存,异步更新数据库
- 性能最高,但数据可能丢失
一致性问题分析:
场景 1:先删缓存,再更新数据库(有并发问题):
线程 A:删除缓存
线程 B:读缓存(未命中),读数据库(旧值),回填缓存(旧值)
线程 A:更新数据库
结果:缓存是旧值,数据库是新值,不一致场景 2:先更新数据库,再删缓存(推荐):
线程 A:更新数据库
线程 B:读缓存(旧值),返回(读到旧值,但短暂)
线程 A:删除缓存
结果:最终一致,短暂不一致场景 3:先更新数据库再删缓存的极端并发:
线程 A:读缓存(未命中),读数据库(旧值)
线程 B:更新数据库(新值),删除缓存
线程 A:回填缓存(旧值)
结果:缓存是旧值,不一致(概率极低,需读数据库后、回填前有写操作)延迟双删策略:
- 先删除缓存 → 更新数据库 → 延迟一段时间再次删除缓存
java
public void update(Long id, Object value) {
redis.del("user:" + id); // 第一次删除
db.update(id, value); // 更新数据库
// 延迟第二次删除(避免读旧值回填)
executor.schedule(() -> {
redis.del("user:" + id);
}, 500, TimeUnit.MILLISECONDS);
}延迟双删原理:
- 第一次删除:让读线程下次读时从数据库读最新值
- 第二次删除:防止在读线程读到旧值并回填缓存后,缓存保留旧值
- 延迟时间需大于"一次读操作(查数据库 + 回填缓存)"的时间
延迟双删缺陷:
- 延迟时间难确定:需预估读操作耗时,过长影响性能,过短仍可能不一致
- 不保证强一致:第二次删除前仍可能读到旧值
- 删除失败仍不一致:第二次删除失败则缓存仍是旧值
- 性能开销:多一次删除操作 + 延迟线程占用
更好的方案:
消息队列 + 重试:
- 更新数据库后发消息,消费者删除缓存,失败重试
- 保证最终一致
订阅 binlog(Canal):
- Canal 监听 MySQL binlog,解析变更后删除对应缓存
- 解耦应用,可靠性高
java
// Canal 消费者
@CanalEventListener
public void onUpdate(CanalEntry.Entry entry) {
// 解析变更,删除对应缓存
redis.del("user:" + changedId);
}- 分布式锁(强一致要求高时):
- 读写互斥,保证一致性
- 性能差,慎用
总结:
- 大多数场景:Cache Aside(先更新数据库,再删缓存)+ TTL 兜底
- 一致性要求高:Cache Aside + Canal 订阅 binlog 删除缓存
- 强一致:分布式锁或串行化
8.11 ★★★★如何设计一个 Redis 分布式锁?基于 SET NX 和 Redisson 的 RedLock 有什么区别?
📖 点击查看答案
基于 SET NX 的简单分布式锁:
java
// 加锁
String lockKey = "lock:order:" + orderId;
String requestId = UUID.randomUUID().toString();
Boolean locked = redis.set(lockKey, requestId, SetOption.NX, 30, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(locked)) {
throw new RuntimeException("获取锁失败");
}
try {
// 业务逻辑
} finally {
// 释放锁(Lua 脚本保证原子性,防止误删)
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";
redis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
}问题与改进:
锁过期但业务未完成:
- 业务执行超过锁过期时间,锁被释放,其他线程获取锁
- 解决:看门狗机制(自动续期)
误删他人锁:
- 线程 A 业务未完成,锁过期;线程 B 获得锁;线程 A 执行完删除了 B 的锁
- 解决:value 存唯一 requestId,删除前判断(Lua 脚本保证原子性)
不可重入:
- 同一线程无法多次获取同一把锁
- 解决:Redisson 的可重入锁
Redis 主从故障切换丢锁:
- 主库加锁后宕机,从库未同步锁信息,新主库无锁
- 解决:RedLock 算法
Redisson 分布式锁:
java
RLock lock = redisson.getLock("lock:order:" + orderId);
try {
lock.lock(30, TimeUnit.SECONDS); // 或 lock.tryLock(waitTime, leaseTime, unit)
// 业务逻辑
} finally {
lock.unlock();
}Redisson 特性:
- 看门狗自动续期:默认 30s 过期,每 10s 续期到 30s
- 可重入:基于 Hash 结构,key=锁名,field=线程 ID,value=重入次数
- 公平锁:
redisson.getFairLock() - 读写锁:
redisson.getReadWriteLock() - 信号量:
redisson.getSemaphore() - RedLock 支持:
redisson.getRedLock(lock1, lock2, lock3)
RedLock 算法(多 Redis 实例):
原理:在 N 个(通常 5 个)独立 Redis 实例上获取锁,半数以上成功才算加锁成功。
流程:
- 客户端获取当前时间 T1
- 依次向 N 个 Redis 实例发送
SET NX加锁请求,超时时间短(如 50ms) - 若 N/2+1(3/5)个实例加锁成功,且总耗时 < 锁过期时间,则加锁成功
- 否则向所有实例发送 DEL 释放锁
java
RLock lock1 = redisson1.getLock("lock:order:" + orderId);
RLock lock2 = redisson2.getLock("lock:order:" + orderId);
RLock lock3 = redisson3.getLock("lock:order:" + orderId);
RLock lock4 = redisson4.getLock("lock:order:" + orderId);
RLock lock5 = redisson5.getLock("lock:order:" + orderId);
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3, lock4, lock5);
redLock.lock();
try {
// 业务
} finally {
redLock.unlock();
}SET NX vs RedLock 对比:
| 特性 | SET NX | RedLock |
|---|---|---|
| 复杂度 | 简单 | 复杂 |
| 可用性 | 单点(主从故障丢锁) | 高(多实例多数派) |
| 性能 | 快(1 次 RTT) | 慢(5 次 RTT) |
| 可重入 | 需自己实现 | Redisson 支持 |
| 续期 | 需自己实现 | 看门狗自动续期 |
| 成本 | 单 Redis | 多 Redis 实例 |
争议:
- Martin Kleppmann 批评 RedLock 有时钟漂移、GC 暂停等问题
- antirez(Redis 作者)反驳,认为大多数场景可接受
- 生产建议:单 Redis + Redisson(多数场景够用),强一致用 Zookeeper/etcd
8.12 ★★★★Redisson 分布式锁的底层实现是什么?看门狗机制如何实现锁续期?可重入锁原理是什么?
📖 点击查看答案
Redisson 分布式锁底层实现:
加锁 - Lua 脚本(保证原子性):
lua
-- KEYS[1]: 锁名
-- ARGV[1]: 过期时间(看门狗 30s)
-- ARGV[2]: 线程标识(UUID:threadId)
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
-- 锁存在但持有者是当前线程,重入次数+1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 锁被其他线程持有
return redis.call('pttl', KEYS[1]);数据结构:
- Hash:key=锁名,field=
UUID:threadId,value=重入次数 - 使用 Hash 而非 String,支持可重入
解锁 - Lua 脚本:
lua
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil; -- 不是当前线程的锁
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
-- 重入次数减 1,但不为 0,续期
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
-- 重入次数为 0,删除锁
redis.call('del', KEYS[1]);
redis.call('publish', ARGV[1], 'unlock'); -- 通知等待线程
return 1;
end;看门狗(Watch Dog)机制:
原理:
- 加锁成功后,启动一个定时任务,每隔 1/3 锁过期时间(默认 10s)检查锁是否还被当前线程持有
- 若持有,续期到 30s
- 业务执行完后,取消定时任务
java
// Redisson 源码核心
private void renewExpiration() {
ExpirationEntry entry = new ExpirationEntry();
EXPIRATION_RENEWAL_MAP.put(entryName, entry);
entry.addTask(() -> {
// 定时任务逻辑
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (res) {
// 续期成功,继续下一轮
renewExpiration();
} else {
// 续期失败,取消
cancelExpirationRenewal(threadId);
}
});
});
// 延迟 10s 执行(30s / 3)
Timeout task = commandExecutor.getConnectionManager()
.newTimeout(entry, 10, TimeUnit.SECONDS);
}续期 Lua 脚本:
lua
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end;
return 0;看门狗触发条件:
- 只有调用
lock()(无过期时间参数)时才启用看门狗 - 调用
lock(leaseTime, TimeUnit)时不启用,锁到期自动释放
java
lock.lock(); // 启用看门狗,默认 30s 续期
lock.lock(10, TimeUnit.SECONDS); // 不启用看门狗,10s 后自动释放可重入锁原理:
- 数据结构:Hash 结构,field=线程标识,value=重入次数
- 加锁:
- 锁不存在:
HSET lock UUID:threadId 1,设置过期 - 锁存在且持有者是当前线程:
HINCRBY lock UUID:threadId 1,续期 - 锁被其他线程持有:等待
- 锁不存在:
- 解锁:
- 重入次数 -1
- 次数 > 0:续期
- 次数 = 0:DEL lock,发布解锁消息
java
RLock lock = redisson.getLock("myLock");
lock.lock(); // 第一次加锁,count=1
lock.lock(); // 重入,count=2
lock.unlock(); // 解锁,count=1
lock.unlock(); // 完全解锁,count=0,删除锁等待机制:
- 加锁失败时,订阅 Redis 的
unlock频道 - 解锁时发布消息,等待线程被唤醒重试
- 支持
tryLock(waitTime, leaseTime, unit)带超时等待
公平锁:
- 基于 Redis List 维护等待队列
- 按入队顺序获取锁
8.13 ★★★★Redis 中的 Big Key 和热 Key 问题如何排查和处理?有什么预防措施?
📖 点击查看答案
Big Key 问题:
定义:
- String:单个 value > 10KB
- Hash/List/Set:元素数 > 1 万,或总大小 > 10MB
- ZSet:元素数 > 1 万
危害:
- 内存不均:集群模式下单节点内存倾斜
- 阻塞:操作大 key 耗时长(如
DEL大 key 阻塞主线程) - 网络:传输大 value 占用带宽
- 过期删除:大 key 过期时删除耗时
- 迁移困难:集群 slot 迁移时大 key 阻塞
排查方法:
redis-cli --bigkeys:
bashredis-cli --bigkeys # 扫描各类型最大的 keyMEMORY USAGE(Redis 4.0+):
bashMEMORY USAGE key # 返回 key 占用字节数redis-rdb-tools:
- 分析 RDB 文件,找出大 key
SCAN + DEBUG OBJECT:
- 遍历 key,检查大小
处理方法:
拆分:
- 大 Hash 拆分为多个小 Hash:
user:1:base、user:1:detail - 大 List 拆分:
list:1:part0、list:1:part1
- 大 Hash 拆分为多个小 Hash:
压缩:
- Value 用压缩算法(如 Snappy、LZ4)
删除大 key:
- Redis 4.0+
UNLINK(异步删除,不阻塞) - 分批删除:
HSCAN+HDEL
bash# 大 Hash 分批删除 while true; do fields=$(redis-cli HSCAN bigkey 0 COUNT 100 | tail -n +2) redis-cli HDEL bigkey $fields done- Redis 4.0+
热 Key 问题:
定义:某个 key 访问量极高(如 QPS > 1 万)
危害:
- 单节点压力:集群模式下单个节点 CPU、带宽打满
- 缓存击穿:热 key 过期瞬间打垮数据库
排查方法:
redis-cli --hotkeys:
bashredis-cli --hotkeys # 需开启 LFU 淘汰策略:maxmemory-policy allkeys-lfuMONITOR(慎用,影响性能):
bashredis-cli MONITOR | grep -o 'GET [^ ]*' | sort | uniq -c | sort -nr代理层统计:
- 在客户端或代理层统计 key 访问频率
Redis 6.0+ OBJECT FREQ:
bashOBJECT FREQ key # 查看 LFU 访问频率
处理方法:
本地缓存:
- 客户端用 Caffeine/Guava 缓存热 key,减少 Redis 访问
- 设置短 TTL,保证最终一致
多副本:
- 将热 key 复制到多个 key:
hotkey:0、hotkey:1、hotkey:2 - 客户端随机访问不同副本,分散压力
- 将热 key 复制到多个 key:
集群分片:
- 将热 key 拆分到不同节点(但单 key 无法拆分,需业务层改造)
预防措施:
Big Key 预防:
- 规范设计:单个 key 不超过 10KB,集合元素不超过 1 万
- 定期清理无用的大 key
- 大集合用 Hash 分片存储
- 业务拆分,避免一个 key 存储过多数据
热 Key 预防:
- 监控告警:QPS 异常时告警
- 设计阶段避免热点(如不要所有请求打到同一个 key)
- 热点打散:用哈希分散到多个 key
- 多级缓存:本地缓存 + Redis + 数据库
通用监控:
- Prometheus + Grafana 监控 Redis 指标
- 慢查询日志:
SLOWLOG GET - 定期巡检 Big Key
8.14 ★★★Redis 的 Pipeline 和事务的区别是什么?MULTI/EXEC 和 Lua 脚本如何保证原子性?
📖 点击查看答案
Pipeline(管道):
原理:
- 客户端批量发送命令,服务端依次执行后批量返回
- 减少网络 RTT(往返时间),提升吞吐量
- 不保证原子性:命令在服务端可能被其他客户端命令穿插
java
Pipeline pipeline = jedis.pipelined();
for (int i = 0; i < 1000; i++) {
pipeline.set("key" + i, "value" + i);
}
pipeline.sync(); // 执行并返回结果适用场景:
- 批量写入/读取
- 不要求原子性的场景
事务(MULTI/EXEC):
原理:
MULTI开启事务,后续命令进入队列EXEC顺序执行队列中的命令- 保证命令顺序执行,不被穿插(原子性有限)
- 不支持回滚(某条命令失败,其他仍执行)
bash
MULTI
SET key1 value1
SET key2 value2
INCR counter
EXECWATCH 乐观锁:
WATCH key监视 key- 事务执行前 key 被修改,整个事务失败
- 实现乐观锁
bash
WATCH balance
val = GET balance
MULTI
SET balance (val - 100)
EXEC
# 若 balance 被 others 修改,EXEC 返回 nil,事务不执行Pipeline vs 事务:
| 特性 | Pipeline | 事务 |
|---|---|---|
| 目的 | 减少网络往返 | 保证命令顺序执行 |
| 原子性 | 否 | 部分(顺序执行,不回滚) |
| 命令穿插 | 可能 | 不会 |
| 性能提升 | 显著(网络) | 无 |
| 适用 | 批量操作 | 顺序敏感操作 |
Lua 脚本:
原理:
- Redis 2.6+ 支持
- 脚本在服务端单线程原子执行,期间不处理其他命令
- 保证多条命令的原子性
lua
-- 转账脚本
local from = KEYS[1]
local to = KEYS[2]
local amount = tonumber(ARGV[1])
local balance = tonumber(redis.call('GET', from))
if balance < amount then
return -1 -- 余额不足
end
redis.call('DECRBY', from, amount)
redis.call('INCRBY', to, amount)
return 1 -- 成功java
String script = "local from = KEYS[1] ...";
Object result = jedis.eval(script, 2, "account:1", "account:2", "100");MULTI/EXEC vs Lua 原子性对比:
| 特性 | MULTI/EXEC | Lua 脚本 |
|---|---|---|
| 原子性 | 顺序执行(不回滚) | 完全原子(单线程执行) |
| 条件逻辑 | 不支持(命令入队即执行) | 支持(if/else) |
| 失败处理 | 命令失败不回滚 | 脚本中可控制 |
| 网络 RTT | 多次(入队 + 执行) | 1 次(发送脚本) |
| 复杂逻辑 | 不支持 | 支持 |
| 持久化 | 否 | FUNCTION(7.0+)持久化 |
Lua 脚本注意事项:
- 不要执行耗时操作:脚本执行期间阻塞其他命令
- 脚本缓存:
EVALSHA传脚本 SHA1,避免重复传输 - 只读脚本:
EVAL_RO/FCALL_RO只读模式 - 集群兼容:脚本中操作的 key 必须在同一 slot(用
{hashtag}保证)
原子性总结:
- Pipeline:无原子性
- MULTI/EXEC:命令顺序执行,但无回滚,中间可被其他命令穿插(只是入队阶段不穿插)
- Lua 脚本:完整原子性,整个脚本执行期间不处理其他命令
8.15 ★★如何用 Redis 实现延迟队列?有哪些方案?
📖 点击查看答案
方案一:ZSet(有序集合)
原理:
- ZSet 的 score 存储执行时间戳
- 消费者定时扫描到期的元素
java
// 生产者:添加延迟任务
long executeTime = System.currentTimeMillis() + 60000; // 60s 后执行
redis.zadd("delay:queue", executeTime, "taskId:123");
// 消费者:扫描到期任务
while (true) {
Set<String> tasks = redis.zrangeByScore(
"delay:queue", 0, System.currentTimeMillis(), 0, 10);
for (String task : tasks) {
// ZREM 返回 1 表示抢到任务
if (redis.zrem("delay:queue", task) > 0) {
executeTask(task);
}
}
Thread.sleep(100);
}优点:简单,支持多消费者 缺点:需轮询,有一定延迟;高并发下 ZREM 竞争
方案二:Keyspace Notification(键空间通知)
原理:
- 监听 key 过期事件
- 设置带 TTL 的 key,过期时触发通知
java
// 生产者
redis.setex("delay:task:123", 60, "taskData");
// 消费者(订阅过期事件)
redis.configSet("notify-keyspace-events", "Ex");
redis.psubscribe(new KeyExpiredListener(), "__keyevent@0__:expired");优点:实时性好,无需轮询 缺点:
- 过期事件可能丢失(Redis 重启、网络问题)
- 无法获取 key 的 value(已过期删除)
- 不保证可靠投递
方案三:Redis Stream(5.0+)
原理:
- Stream 支持消费组、ACK
- 配合定时任务实现延迟
java
// 生产者
redis.xadd("delay:stream", "*", "task", "taskData", "executeTime", executeTime);
// 消费者:定时读取
while (true) {
List<StreamEntry> entries = redis.xrange("delay:stream", "-", "+", 10);
for (StreamEntry entry : entries) {
if (entry.getFields().get("executeTime") <= now) {
executeTask(entry);
redis.xack("delay:stream", "consumerGroup", entry.getID());
}
}
}优点:支持消费组、ACK、持久化 缺点:延迟实现稍复杂
方案四:Redisson 延迟队列(RDelayedQueue)
java
RDelayedQueue<String> delayedQueue = redisson.getDelayedQueue(
redisson.getQueue("delay:queue"));
// 添加延迟任务
delayedQueue.offer("taskData", 60, TimeUnit.SECONDS);
// 消费者
RQueue<String> queue = redisson.getQueue("delay:queue");
String task = queue.poll(); // 到期后才能取到原理:
- 基于 ZSet 实现
- 内部维护一个普通队列,到期后从 ZSet 移到队列
- 封装了扫描逻辑
优点:开箱即用,可靠性高 缺点:依赖 Redisson
方案对比:
| 方案 | 可靠性 | 实时性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| ZSet | 中 | 中(轮询) | 中 | 通用 |
| 键空间通知 | 低 | 高 | 低 | 实时性要求高,允许丢失 |
| Stream | 高 | 中 | 高 | 需要持久化和消费组 |
| Redisson | 高 | 高 | 低 | 推荐,开箱即用 |
生产建议:
- 简单场景:Redisson RDelayedQueue
- 强可靠:专业 MQ(RabbitMQ TTL + DLX、RocketMQ 延迟消息)
- 避免用键空间通知(不可靠)
完整示例(ZSet 方案):
java
@Component
public class DelayQueueConsumer {
@Scheduled(fixedRate = 1000)
public void consume() {
long now = System.currentTimeMillis();
Set<DelayedTask> tasks = redisTemplate.opsForZSet()
.rangeByScore("delay:queue", 0, now, 0, 100);
if (tasks == null || tasks.isEmpty()) return;
for (DelayedTask task : tasks) {
if (redisTemplate.opsForZSet().remove("delay:queue", task) > 0) {
executor.submit(() -> {
try {
taskProcessor.process(task);
} catch (Exception e) {
// 失败重试或记录
redisTemplate.opsForZSet().add("delay:queue",
task, now + 60000); // 60s 后重试
}
});
}
}
}
}