面试追问地图
| 主问题 | 必讲关键点 | 下一层追问 |
|---|---|---|
| Redis 为什么快 | 内存、事件循环、高效结构 | 单线程边界、多线程 IO |
| vs Memcached | 数据类型、持久化、集群 | 选型建议 |
| 数据结构 | 编码选择和转换阈值 | SDS、listpack、跳表、渐进 rehash |
| RDB/AOF | 快照、命令日志、恢复 | fork 开销、重写、混合持久化 |
| 主从复制 | 全量、增量、复制积压缓冲区 | 主从延迟、数据丢失窗口 |
| Sentinel/Cluster | 故障检测、选主、分片 | gossip、16384 槽、槽迁移与 MOVED/ASK |
| 过期与淘汰 | 惰性删除、定期删除、淘汰策略 | 集中过期、volatile/allkeys 选择 |
| 缓存一致性 | Cache Aside、删除缓存 | 延迟双删局限、订阅 binlog |
| 穿透/击穿/雪崩 | 空值/布隆、互斥、随机过期 | 热点失效和数据库保护、冷启动预热 |
| 缓存预热 | 冷启动 = 全量雪崩、提前加载热点 | 定时任务/双缓存/灰度放量、预热刷满内存 |
| 批量操作 | pipeline/mget/Lua、省 RTT | 原子性差异、Cluster 跨槽、pipeline 过长 |
| 大 Key/热 Key | 网络、阻塞、单分片压力 | 如何发现、拆分和迁移 |
| 分布式锁 | SET NX PX、唯一值、Lua | 续期、主从切换、fencing token |
| 秒杀场景 | 库存扣减、分段锁、原子操作 | 与 DB 一致性、缓存预热衔接 |
| 事务与原子性 | Lua 脚本、MULTI/EXEC | 回滚缺失、watch 乐观锁 |
| 布隆过滤器 | 位图+哈希、误判率 | 不可删除、计数布隆 |
Redis 题要区分“命令原子”与“业务操作原子”,也要说明高可用不等于强一致。
一、Redis 基础
Redis 为什么快?
难度 🟡
快答
三个核心原因:
- 内存存储:内存访问比磁盘快 10000 倍
- 单线程事件驱动:无线程竞争和线程切换开销
- 支持多种高效的数据结构
性能指标:
- 单线程吞吐量:10-15 万 QPS
- 延迟:P99 < 1ms
为什么用单线程?
- 避免线程竞争和互斥锁开销
- 避免上下文切换
- 编程简单,无并发 bug
但 Redis 6.0 开始支持多线程 I/O(读写分离)
Redis 和 Memcached 有什么区别?
频次 ★★★ · 难度 🟢
| Redis | Memcached | |
|---|---|---|
| 数据类型 | String、Hash、List、Set、ZSet 等 9 种 | 仅支持 key-value |
| 持久化 | RDB、AOF、混合持久化 | 无持久化,重启数据全丢 |
| 集群模式 | 原生 Cluster(16384 槽)、哨兵、主从 | 无原生集群,需客户端分片 |
| 功能特性 | 事务、Lua 脚本、发布订阅、Stream | 无 |
| 内存回收 | 8 种淘汰策略 + 惰性/定期删除 | 仅 LRU 淘汰 |
| 多线程 | 6.0+ IO 多线程(核心命令仍单线程) | 多线程(多线程处理请求) |
选型建议:单纯做键值缓存且不需要持久化时 Memcached 可以胜任;但大多数场景 Redis 的数据结构丰富度和持久化能力是决定性优势,实践中 Redis 已基本取代 Memcached。
Redis 数据结构
| 类型 | 说明 | 场景 |
|---|---|---|
| String | 字符串 | 缓存、计数 |
| List | 有序列表 | 消息队列、排行榜 |
| Set | 无序集合 | 去重、关注列表 |
| Sorted Set | 有序集合 | 排行榜、优先级队列 |
| Hash | 哈希 | 对象存储 |
二、Redis 持久化
持久化的两种方式?
难度 🔴
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| RDB | 定时快照 | 快,文件小 | 可能丢数据 |
| AOF | 记录操作日志 | 安全,更完整 | 慢,文件大 |
RDB(Redis Database Snapshot):
工作流程:
1. 执行 BGSAVE 命令
2. Redis fork 子进程
3. 子进程遍历所有数据,写入 RDB 文件
4. 父进程继续处理命令
5. 子进程完成后,替换旧的 RDB 文件
配置:
save 900 1 # 15 分钟内有 1 个 key 修改
save 300 10 # 5 分钟内有 10 个 key 修改
save 60 10000 # 1 分钟内有 10000 个 key 修改
fork 后父子进程靠写时复制(COW)共享内存,快照期间父进程写得越多,额外内存涨得越多;生产上要关闭透明大页(THP),否则 COW 按 2MB 整页复制,内存暴涨且写延迟抖动,原理见操作系统”多级页表与 TLB”一节。
AOF(Append Only File):
工作流程:
1. 每个写命令执行后,都被记录到 AOF 文件
2. Redis 可以重新执行所有命令,恢复数据
配置:
appendonly yes
appendfsync everysec # 每秒 fsync 一次
AOF 写回策略:
| 策略 | 说明 | 安全性 | 性能 |
|---|---|---|---|
| always | 每次写操作立即 fsync | 最高 | 最差 |
| everysec(默认) | 每秒由后台线程 fsync | 最多丢 1 秒数据 | 平衡 |
| no | 由 OS 自行决定刷盘 | 不可控 | 最好 |
混合模式(Redis 4.0+):
混合 RDB + AOF:
1. 快照时使用 RDB 格式(快速)
2. 快照后的增量命令用 AOF 记录
3. 恢复时先加载 RDB,再重放 AOF
配置 aof-use-rdb-preamble yes
通用概念:fork 期间主进程每写一页就复制一页,最坏会复制出与数据集等量的内存——这就是「实例内存不超过物理内存一半」的由来,见写时复制。
三、Redis 高可用
高可用方案对比
难度 🔴
| 方案 | 原理 | 可用性 | 扩展性 |
|---|---|---|---|
| 主从 | 数据复制 | 单点故障 | ❌ |
| 哨兵 | 自动故障转移 | ✅ 自动恢复 | ❌ |
| 集群 | 数据分片 | ✅ | ✅ 水平扩展 |
主从复制:
Master → Slave 单向复制
流程:
1. Slave 连接 Master,发送 PSYNC 命令
2. Master 执行 BGSAVE,生成 RDB
3. Master 发送 RDB 给 Slave
4. Slave 加载 RDB,完成初始化
5. Master 继续发送增量命令给 Slave
哨兵(Sentinel):
在主从基础上增加自动故障转移
工作流程:
1. Sentinel 定期检测 Master 健康状态
2. 如果 Master 挂掉,Sentinel 投票选择新 Master
3. 将 Slave 转为新 Master,原 Master 变为 Slave
选举 Leader:获得 quorum 和 Sentinel 数/2+1 的较大值赞成票即当选。
新主选择规则(优先级递减):
1. 过滤故障节点
2. slave-priority 最大
3. 复制偏移量最大(数据最新)
4. runid 最小
Redis Cluster(集群):
水平分片,多个 Master
架构:
Master1 (slots 0-5460) Master2 (slots 5461-10922) Master3 (slots 10923-16383)
↓ ↓ ↓
Slave1 Slave2 Slave3
集群共 16384 个哈希槽,key 通过 CRC16 算法计算后对 16384 取模映射到槽。
主从全量同步 vs 增量同步
- 全量同步:首次连接、从库数据丢失、差异过大时触发。主库生成 RDB 快照传给从库。
- 增量同步:断线重连时通过 PSYNC 命令 + offset 继续同步。主库根据 repl_backlog_buffer(环形缓冲区,默认 1M)中的差异数据发送给从库。若数据已被覆盖则退化为全量同步。
PSYNC 如何判定走全量还是增量?(源码级)
从库发 PSYNC <replid> <offset>,主库在 replication.c 的 masterTryPartialResynchronization 里做两个判断:
/* replication.c(Redis 7,节选简化)*/
int masterTryPartialResynchronization(client *c, long long psync_offset) {
/* 判断 1:复制 ID 是否匹配(replid 不匹配说明认错主了/主已换代) */
if (strcasecmp(master_replid, server.replid) &&
(strcasecmp(master_replid, server.replid2) || /* replid2:故障转移前的旧 ID */
psync_offset > server.second_replid_offset)) {
goto need_full_resync; /* → +FULLRESYNC */
}
/* 判断 2:请求的 offset 是否还在积压缓冲区里 */
if (!server.repl_backlog ||
psync_offset < server.repl_backlog->offset || /* 太旧,已被环形覆盖 */
psync_offset > server.repl_backlog->offset + server.repl_backlog->histlen) {
goto need_full_resync;
}
/* 两关都过 → +CONTINUE,只补发 backlog 里 offset 之后的部分 */
replicationSetupSlaveForFullResync(...); /* 略 */
}通用概念:这里的 repl_backlog 是典型的环形缓冲区(ring buffer)——固定大小、写满从头覆盖,用「基准偏移 + 长度」判断历史数据是否还在。Kafka 消费者按 offset 补拉、TCP 滑动窗口的思路同源:增量追赶的前提是”落后的部分还留着”。
常见追问
- 为什么有 replid2?→ 故障转移后新主保留旧主的 replid,老从库拿旧 replid 来 PSYNC 也能走增量,避免主切换引发全量同步风暴(Redis 4.0 的 PSYNC2 优化)。
- backlog 设多大?→
平均写入量/秒 × 期望容忍的断线秒数,再留余量;默认 1MB 在生产上通常太小。
哨兵怎么判定故障、怎么选主?
故障判定分两级(防误判):
| 阶段 | 名字 | 条件 |
|---|---|---|
| 主观下线 | SDOWN | 单个哨兵 ping 超过 down-after-milliseconds 无有效回复 |
| 客观下线 | ODOWN | 询问其他哨兵,≥ quorum 个都认为主挂了 |
故障转移流程:
- 判定 ODOWN 后,哨兵们先选举 Leader 哨兵来执行转移:每轮(epoch)每个哨兵只能投一票(先到先得),获得
max(quorum, 哨兵数/2+1)票者当选——拿不到多数派就换 epoch 重来 - Leader 按规则挑新主(过滤失联 → slave-priority 最高 → 复制 offset 最大 → runid 最小)
- 对选中从库发
SLAVEOF NO ONE,其余从库SLAVEOF 新主,旧主恢复后被降级为从库
通用概念:这就是一次简化版 Raft 选举——任期(epoch)、每任期一票、多数派当选;「主观/客观下线」则是分布式系统里用多数派意见对抗单点视角误判(网络抖动 ≠ 节点死亡)的通用手法,与 分布式系统 的 Raft、脑裂多数派一脉相承。 「至少 3 个且为奇数」是多数派的直接推论:2F+1 容忍 F 个故障,偶数个节点的第 N 台是纯浪费。
常见追问:哨兵至少部署几个?→ 3 个且奇数、跨机器/机房:1 个无法客观下线,2 个挂一个就凑不齐多数派选不出 Leader。
主从延迟与数据丢失窗口
Redis 复制是异步的:主库写完立刻回客户端 OK,再异步传给从库。两个后果:
- 故障转移丢数据:主库刚确认的写还没到从库就宕机 → 新主没有这批数据,旧主恢复后被强制全量对齐新主,这批写永久丢失
- 脑裂丢数据:旧主被网络隔离但没死,客户端还在往它写;哨兵已提拔新主 → 分区恢复后旧主降级清空,隔离期间的写全丢
缓解(不能根治):
min-replicas-to-write 1 # 至少 1 个从库在线才接受写
min-replicas-max-lag 10 # 从库复制延迟超 10s 视为不在线本质是把”完全异步”收紧为”半同步”:不满足条件时主库拒绝写入,用可用性换更小的丢失窗口。通用概念:这与 Kafka 的 min.insync.replicas(见消息队列)、MySQL 半同步复制是同一个取舍——异步复制系统的确认点放在哪,决定了丢失窗口的大小。所以”钱”的强一致场景不要依赖 Redis 主从,见 RedLock 争议。
Cluster 为什么是 16384 个槽?槽迁移时请求怎么路由?
频次 ★★★ · 难度 🔴
是什么:Cluster 是去中心化架构——没有配置中心,节点间靠 gossip 协议(走”服务端口 +10000”的 cluster bus)互通状态:每个节点每秒随机 PING 几个节点,PING/PONG 消息携带自己视角的节点表和槽位图(16384 位的 bitmap,标记每个槽归谁),信息像流言一样几轮内扩散全网。故障判定也走 gossip:单个节点联系不上某主节点先标 PFAIL(主观下线),当超过半数主节点都报告 PFAIL 时升级 FAIL(客观下线)并广播,触发其从节点带着 epoch(纪元)向各主节点拉票、多数派同意后接管——与哨兵的 SDOWN→ODOWN→选主(见上文)逻辑同构,只是没有独立的哨兵角色。
为什么是 16384 而不是 65536:CRC16 输出 16 位,槽数本可以到 65536,压到 16384 是 antirez 的两笔账:
- 心跳包大小:PING 消息要带全量槽位图,16384/8 = 2KB;若 65536 个槽就是 8KB,而 gossip 每秒都在发,头部开销直接翻 4 倍
- 规模上限:官方建议集群不超过 1000 个主节点(gossip 收敛变慢),16384 个槽平摊到 1000 节点仍有 16 个/节点,粒度够用;节点少时 65536 槽的 bitmap 填充率低,压缩效果反而差
槽迁移完整流程(resharding,以槽 100 从 A 迁到 B 为例):
1. B:CLUSTER SETSLOT 100 IMPORTING <A-id> # 目标标记"导入中"
2. A:CLUSTER SETSLOT 100 MIGRATING <B-id> # 源标记"迁出中"
3. 循环搬 key:
A:CLUSTER GETKEYSINSLOT 100 <count> # 取一批该槽的 key
A:MIGRATE B地址 key ... # 原子搬迁:目标写成功后源端删除
4. 槽空后:CLUSTER SETSLOT 100 NODE <B-id> # 改归属,经 gossip 扩散全网迁移期间槽处于”两边各有一部分 key”的中间态,请求路由靠两种重定向:
| 维度 | MOVED | ASK |
|---|---|---|
| 触发时机 | 槽已稳定归属其他节点 | 槽迁移中,key 在源节点没找到 |
| 含义 | 永久重定向 | 仅本次重定向 |
| 客户端行为 | 更新本地槽路由表,之后直连新节点 | 向目标节点先发 ASKING 再发命令,不更新路由表 |
细节:迁移中请求仍先打到源节点 A——key 还在就直接执行;不在且槽是 MIGRATING 态才回 ASK。目标节点 B 上该槽是 IMPORTING 态,默认拒绝该槽的普通请求(防止路由表未更新的客户端提前写入),ASKING 是一次性豁免。这套设计保证迁移全程对客户端无损,与哈希表渐进式 rehash(见下文数据结构底层)是同一思想:大搬迁拆成小步,中间态靠双向检查兜底。
常见追问:
- 为什么 MGET/事务/Lua 在 Cluster 上受限?→ 多个 key 可能落在不同槽/节点,跨节点无法原子执行;用 hash tag(
{user:1}:name、{user:1}:age只对{}内内容做 CRC16)强制同槽,代价是热点集中;三种批量方式的跨槽差异见下文”批量操作怎么选” - 集群为什么至少 3 主 3 从?→ FAIL 判定和从节点选举都需要主节点多数派,2 主时挂 1 个凑不齐多数;每主挂一从才有故障转移的资本
- Cluster 和哨兵怎么选?→ 先看数据量:单机内存放得下、写吞吐不超单机就用哨兵(架构简单、客户端兼容性好、多 key 命令无限制);需要分片才上 Cluster,并接受多 key/事务受限
通用概念:gossip 是去中心化的最终一致元数据扩散——不设权威节点,靠随机传播收敛,代价是拓扑变更有秒级感知延迟;对照集中式元数据(Kafka 的 Controller/ZooKeeper,见消息队列)。哈希槽本身是预分片(pre-sharding):先把 key 空间切成固定份数再谈份数归谁,迁移只动”槽→节点”映射,粒度可控,对照一致性哈希的算法隐式映射(见分布式系统)。
通用概念:槽是「key → 节点」之间的间接层。16384 远大于节点数,所以迁移粒度细、权重可调;与一致性哈希的虚拟节点同构,区别只在这张映射表是算出来的还是存出来的。
四、缓存三大问题
缓存穿透、击穿、雪崩如何解决?
难度 🔴
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 穿透 | 缓存和 DB 都没有 | 布隆过滤器 / 空值缓存 |
| 击穿 | 热点数据过期 | 互斥锁 / 逻辑过期 |
| 雪崩 | 大量缓存同时过期 | 随机过期时间 / 热点不过期 |
解决方案代码:
// 1. 缓存穿透:布隆过滤器
BloomFilter<String> filter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()), 10000);
if (!filter.mightContain(id)) {
return null; // 肯定不存在
}
// 或者空值缓存
if (user == null) {
redis.setex(key, 10, ""); // 缓存空值,过期时间短
}
// 2. 缓存击穿:互斥锁
String lockKey = "lock:" + key;
if (redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS)) {
try {
value = userService.getById(id);
redis.set(key, value, 3600);
} finally {
redis.delete(lockKey);
}
}
// 3. 缓存雪崩:随机过期时间
int randomExpire = 3600 + random.nextInt(600); // 3600-4200 秒缓存预热:冷启动时怎么避免打挂数据库?
难度 🟡
是什么:服务上线/重启/缓存集群故障恢复后,缓存是空的,所有请求直接穿到数据库——这叫冷启动。缓存预热就是在放开流量之前,把热点数据提前加载进缓存。
为什么需要:冷启动本质是一次”人为制造的全量雪崩”:平时缓存命中率 95% 时数据库只承担 5% 流量,冷启动瞬间要扛 100%,通常直接打挂。上一节的”随机过期时间”防的是运行中的集中过期,防不住从零开始的场景,两者是互补关系。
三类方案对比:
| 方案 | 做法 | 适用场景 | 缺点 |
|---|---|---|---|
| 定时任务预热 | 上线前/低峰期跑任务,按访问统计(如昨日 Top N)把热点数据刷进缓存 | 热点可预测(商品详情、首页配置) | 热点预测不准时白刷;全量刷可能占满内存 |
| 双缓存/多级缓存 | 本地缓存(Caffeine)+ Redis 两级,Redis 冷了还有本地缓存兜底;或新旧两套 Redis 切换,新集群预热完成后再切流量 | 缓存集群升级、扩缩容 | 架构复杂度高,本地缓存有一致性问题(见下文”本地缓存 vs Redis”对比) |
| 灰度放量 | 重启后不直接接满流量,按 1% → 10% → 50% → 100% 逐步放开,让缓存被真实流量”焐热” | 无法提前预测热点、大促前扩容 | 依赖网关/LB 支持权重调节,预热期间部分请求变慢 |
答题要点:先点破”冷启动 = 全量雪崩”这层关系(区分背题和理解的关键),再按”能预测热点 → 定时任务;不能预测 → 灰度放量;架构层面 → 多级缓存兜底”给方案,最后补一句生产实践:大促前的预热要配合压测验证,预热数据集用访问日志统计而不是拍脑袋。
常见追问:
- 预热任务把 Redis 内存刷满了怎么办?→ 只刷 Top N 热点而非全量;控制预热数据的过期时间;配合 allkeys-lru 淘汰策略兜底
- 预热期间数据被更新了,缓存会不会脏?→ 预热走和正常写路径一样的 Cache Aside(先 DB 后缓存),或预热完成后对比版本号/更新时间做一次校验
- 秒杀场景怎么预热?→ 库存等关键数据必须提前写入 Redis(见秒杀场景设计一节),并且用逻辑过期或不过期,避免活动中途击穿
通用概念:预热本质是避免系统在”零状态”下直接承接满负载,同类模式还有:JVM 预热(JIT 编译需要流量触发,见JVM)、连接池启动时的最小空闲连接(minimumIdle,见MySQL连接池一节)、Guava RateLimiter 的 warmup 模式。
五、数据结构底层
String 使用 SDS 存储
SDS 结构包含 len(字符串长度)、alloc(分配空间)、flags(类型标识)、buf[](实际数据)。优势:
- O(1) 获取长度(C 字符串需遍历)
- 二进制安全(不依赖
\0结尾) - 不会缓冲区溢出(自动扩容)
Zset 底层实现
- 元素 < 128 个且每个值 < 64 字节:压缩列表(Redis 7.0 改用 listpack)
- 不满足上述条件:跳表
跳表实现原理
多层有序链表,通过头节点的多层指针快速定位数据。层高通过随机数决定:生成 [0,1] 随机数,< 0.25 则层数 +1,最大层高 64。查找时间复杂度 O(logN)。
Redis 为什么用跳表而非 B+树?
| 特性 | 跳表 | B+树 |
|---|---|---|
| 主要场景 | 内存数据库 | 磁盘数据库 |
| 实现难度 | 低(几百行 C 代码) | 高(需处理分裂/合并) |
| 写入代价 | 局部指针修改 | 可能连锁分裂/合并 |
通用概念:跳表与 B+ 树的分歧,根子在介质给「下降一层」的定价——内存里是一次指针解引用,磁盘上是一次页读取。跳表节点物理不连续,搬到磁盘范围查询会退化成随机寻址;B+ 树塞进内存则白白付出多路扫描的成本。见树。
压缩列表与 listpack
- 压缩列表:连续内存块,缺点是可能发生连锁更新。
- listpack(Redis 5.0):不记录前一个节点长度,只记录当前节点长度,避免连锁更新。
哈希表渐进式 rehash
分三步:分配哈希表 2 -> 迁移数据 -> 释放旧表。采用渐进式 rehash:每次增删查改操作时,顺带迁移一个索引位置的数据。迁移期间,读写操作在两个哈希表中同时进行。
通用概念:这是把一次 O(n) 的停顿摊还到后续 n 次操作里。与 集合框架 的扩容不同的是,那里的摊还是分析出来的性质,这里是设计出来的手段——Redis 单线程,一次性 rehash 会造成可感知的尾延迟,所以主动把它切碎。
六、线程模型
Redis 单线程为什么快?
- 大部分操作在内存中完成
- 单线程避免线程竞争和上下文切换
- IO 多路复用(select/epoll)处理大量客户端请求
Redis 哪些地方使用了多线程?
- 主线程:接收请求 -> 解析 -> 执行 -> 返回数据
- 后台线程(BIO):关闭文件、AOF 刷盘、lazy free(异步释放大 key)
- Redis 6.0+ 多线程 I/O:默认关闭,可配置
io-threads N
IO 多路复用实现
Redis 将客户端 socket 的 fd 注册到 epoll 监听列表,同时监控多个 fd 的读写情况。采用 Reactor 设计模式。
七、事务
如何保证 Redis 原子性?
- 单条命令天然原子性(单线程执行)
- 多条命令:Lua 脚本(Redis 将整个 Lua 脚本作为整体执行)
- Redis 事务(MULTI/EXEC):正常执行时可保证原子性,但某个操作失败时不会回滚
八、缓存淘汰和过期删除
过期删除策略
Redis 采用 惰性删除 + 定期删除 组合策略:
- 惰性删除:访问 key 时检查是否过期,过期则删除
- 定期删除:每秒 10 次随机抽查 20 个 key,已过期比例 > 25% 则继续抽查,单次不超过 25ms
内存淘汰策略(8 种)
| 类别 | 策略 | 说明 |
|---|---|---|
| 不淘汰 | noeviction(默认) | 写入报错 |
| 仅过期键 | volatile-lru | 淘汰最久未使用的过期键 |
| 仅过期键 | volatile-lfu | 淘汰最少使用的过期键 |
| 仅过期键 | volatile-random | 随机淘汰过期键 |
| 仅过期键 | volatile-ttl | 淘汰更早过期的键 |
| 所有键 | allkeys-lru | 淘汰最久未使用的键 |
| 所有键 | allkeys-lfu | 淘汰最少使用的键 |
| 所有键 | allkeys-random | 随机淘汰任意键 |
九、场景问题
Redis 比 MySQL 快的原因
- 内存存储 vs 磁盘存储
- 简单数据结构(Hash O(1) vs B+树 O(logN))
- 单线程无线程竞争
本地缓存 vs Redis 分布式缓存
| 维度 | 本地缓存 | Redis |
|---|---|---|
| 访问速度 | 更快(本地内存) | 较快(网络延迟) |
| 可扩展性 | 受限于硬件 | 可动态扩展节点 |
| 数据共享 | 单实例内 | 多实例共享 |
| 适用场景 | 本地配置、临时结果 | 分布式会话、全局热点数据 |
缓存与 MySQL 数据一致性
读:旁路缓存策略(cache 未命中从 DB 加载) 写:先更新 DB,再删除缓存
最终一致性方案:
- 删除缓存失败重试(消息队列)
- 订阅 binlog 删除缓存(Canal + MQ)
常见追问
- 延迟双删能解决一致性问题吗?→ 延迟双删(写 DB → 删缓存 → 睡 N 毫秒 → 再删缓存)用第二次删除兜底并发读请求在删缓存前读到旧数据并回写缓存的情况。局限:N 毫秒靠经验拍、第二次删除可能还是失败、睡太短等于没睡、睡太长影响吞吐——工程上不如订阅 binlog 可靠。
- 为什么先更新 DB 再删缓存,而不是先删缓存再更新 DB?→ 先删缓存再写 DB 的窗口更大:A 删缓存 → B 读不到去 DB 读旧值回写缓存 → A 写 DB 新值,缓存永久脏。先写 DB 再删缓存,脏窗口只有”删缓存前的最后一次旧读”。
大 Key 问题
定义:String > 1MB 或集合元素 > 1 万个。 危害:内存占用高、性能下降、阻塞操作、网络拥塞、主从同步延迟、集群数据倾斜。 解决:拆分大 key、异步清理(unlink)、监控内存水位。
热 Key 问题
定义:某个 key 接收的请求占实例总 QPS 的大部分。 解决:热 key 复制并迁移到其他分片、改造读写分离架构。
秒杀场景设计
| 方案 | 思路 | 优缺点 |
|---|---|---|
| 数据库锁 | SELECT ... FOR UPDATE | 简单但性能差 |
| 分布式锁 | 同商品串行处理 | 无法并发处理同商品下单 |
| 分段锁 | 库存拆分到多个 Redis key | 可并发,需处理段间库存转移 |
| Redis 原子操作 + 异步队列 | Redis DECR 预减库存,异步写入 DB | 高性能,但不一致风险 |
Redis 分布式锁怎么实现?
频次 ★★★★★ · 难度 🔴 · 高频:字节/阿里
答题速记:加锁用 SET lock_key unique_value NX PX 10000 一条命令原子完成——NX 保证锁不存在才设置,PX 设租约防止客户端异常后死锁,unique_value(如 UUID)标识持有者防误删;解锁必须用 Lua 脚本原子完成”比对 value + DEL”,否则”判断+删除”两步之间锁可能已被别人重新持有导致误删。风险点是业务超过租约、主从切换后新主没同步到锁信息可能出现双持有者——续期(Redisson 看门狗)、fencing token、Redis 锁 vs ZooKeeper 锁的选型对比,见分布式系统”分布式锁”一章。
布隆过滤器
频次 ★★★★ · 难度 🟡 · 高频:阿里/字节/美团
布隆过滤器由位图数组 + k 个哈希函数组成。
核心性质:写入时将 k 个哈希值对应位设为 1;查询时检查对应位——但凡有一位为 0 则一定不存在,全为 1 则可能存在(有误判率)。
参数选择:位数组长度 m、元素个数 n、误判率 p、哈希函数个数 k 四者满足 m = -n·ln(p) / (ln2)²,k = (m/n)·ln2。实际中通常设定容量上限 + 可接受误判率(如 1%),Redis 模块 BF.RESERVE 直接接受这两个参数自动计算。
Redis 原生支持(Redis Stack / RedisBloom 模块):
BF.RESERVE myfilter 0.01 100000 // 创建:误判率 1%,预计插入 10 万
BF.ADD myfilter "item1" // 添加元素
BF.EXISTS myfilter "item1" // 查询,返回 0/1
BF.INFO myfilter // 查看位数组大小、已用比例等
BF.ADD 是 O(k),BF.EXISTS 是 O(k) —— 注意分布式场景下 BF 指令走任意节点即可(不涉及分片),但 Redis Cluster 中 BF key 会按 hash slot 分布到固定节点。
扩展:Counting Bloom Filter 标准 BF 不支持删除(无法确定”清零”步骤是否会影响其他元素)。计数型 BF 将每个 bit 扩展为一个小计数器(多为 4 bit),删除时将对应计数器减 1。代价:空间膨胀约 4-8 倍。
布隆过滤器 vs 精确过滤器(如 Redis Set)
| 布隆过滤器 | Redis Set | |
|---|---|---|
| 空间效率 | 极省(通常 1% 误判率下 10^7 个元素约 12 MB) | 元素越多越费(10^7 个 int 约 380 MB) |
| 确定性 | 存在误判 | 精确 |
| 删除 | 标准 BF 不支持 | 支持 |
| 适用 | 允许少量误判的海量去重场景 | 精确判重 |
面试常见追问:
- 怎么用布隆过滤器解决缓存穿透? → 请求先查 BF,如果 BF 说”不存在”则直接拒绝回源(一定不存在),减少数据库压力。注意 BF 更新时机:数据写入后先更新缓存再写入 BF,或者通过 Canal 监听 binlog 异步构建。
- 误判率太高怎么办? → 用
BF.RESERVE预设合理参数;如果预估容量不准,可以叠加多层 BF 或定期重建。同时业务层要对”存在但查不到”的情况做兜底(走数据库回查 + 修复 BF)。 - 为什么不直接用 Redis Set 做穿透防护? → 成本决定。同一数量级下 BF 占用的内存是 Set 的几十分之一。对于海量 key(如 40 亿 QQ 号去重),Set 根本放不下。
缓存策略
// Cache-Aside 最常用
public User getUser(int id) {
String key = "user:" + id;
// 1. 先查缓存
User user = redis.get(key);
if (user != null) {
return user;
}
// 2. 缓存未命中,查数据库
user = userService.getById(id);
// 3. 写回缓存
if (user != null) {
redis.setex(key, 3600, user);
}
return user;
}批量操作怎么选:pipeline vs mget vs Lua?
难度 🟡
是什么:三种”一次网络交互干多件事”的手段,核心差异在往返次数(RTT)和原子性两个维度:
| 维度 | mget/mset | pipeline | Lua 脚本 |
|---|---|---|---|
| 命令类型 | 仅同类型批量读/写 | 任意命令组合 | 任意命令 + 逻辑(判断、循环) |
| 网络往返 | 1 次 | 1 次(打包发送,批量收回) | 1 次 |
| 原子性 | 单命令原子 | ❌ 无。命令间可插入其他客户端的命令 | ✅ 整个脚本原子执行 |
| 服务端执行 | 一条命令 | 逐条独立执行 | 作为整体执行,期间阻塞其他命令 |
| Cluster 跨槽 | ❌ 多 key 必须同槽(或用 hash tag) | 取决于客户端实现(如 lettuce 可按节点拆分) | ❌ 所有 key 必须同槽 |
为什么这么设计:Redis 的瓶颈通常不在 CPU 而在网络 RTT——执行一条 GET 只要微秒级,但一次网络往返要毫秒级。1 万条命令逐条发 = 1 万次 RTT(约 10 秒),pipeline 打包后 1 次 RTT 即可,这是它们共同的价值。差异在于:pipeline 只解决网络问题不管原子性;Lua 用”阻塞整个服务端”换取原子性,所以脚本必须短小。
答题要点:先说”三者都是省 RTT,选型看是否需要原子性和逻辑”——只批量读同类 key 用 mget;批量执行无依赖的异构命令用 pipeline;命令之间有逻辑依赖(先判断再操作,如分布式锁解锁、限流计数)必须用 Lua。
pipeline 太长的风险(高频追问):
- 服务端内存堆积:所有响应会先缓存在服务端输出缓冲区,等 pipeline 结束一起发回,太长会撑爆
client-output-buffer-limit导致连接被强制断开 - 客户端阻塞:拿不到任何响应,直到整批完成;失败后不知道执行到哪,需要整批重试
- 实践:单批控制在几百条以内,大批量拆多个 pipeline
常见追问:
- Lua 脚本执行到一半失败会回滚吗?→ 不会。Redis 没有回滚机制,已执行的命令保持生效(和 MULTI/EXEC 一样,见上文事务一节),所以脚本要自己保证”先校验后写入”
- 为什么分布式锁的解锁必须用 Lua?→ “GET 比对 value + DEL”是两步,中间锁可能过期被别人抢走,DEL 会误删别人的锁,Lua 保证判断和删除原子完成(见分布式系统分布式锁一节)
- Cluster 模式下 mget 怎么用?→ 用 hash tag(
{user}:1、{user}:2)强制同槽,或客户端按槽拆分成多次请求再聚合
通用概念:pipeline 本质是批处理摊薄固定开销(网络 RTT 是固定成本,批量把它摊薄到每条命令上),同类模式:Kafka 生产者的 linger.ms 攒批发送(见消息队列)、MySQL 的 rewriteBatchedStatements 批量插入、系统调用的缓冲 IO。