一、Redis 3 大经典问题(面试 100% 必考)
1.1 雪崩(Avalanche)
问题:大量 key 同一时间过期,导致所有请求打到数据库
早上 9:00
↓
Redis 里 50w 个缓存 key 全部过期(设的同一时间,比如 1 小时)
↓
⚠️ 50w 个请求同时打 MySQL
↓
MySQL 扛不住,连接池耗尽,CPU 100%
↓
⚠️ 系统雪崩
项目场景:
- 报表 50w 个任务缓存,凌晨 0 点同时过期
- 27 家分行的客户数据缓存,早 8 点同时过期
- 50w+ 请求瞬间打 MySQL,DB 连接池爆了
1.2 穿透(Penetration)
问题:查询一个不存在的 key,每次都打到数据库
恶意攻击 / 业务 bug
↓
查询 user_id = -1(不存在)
↓
Redis 没这个 key → 查 MySQL
↓
MySQL 也没这个 user → 返回 null
↓
⚠️ 但每次都查 MySQL,**没有缓存保护**
↓
⚠️ 攻击者用 100w 个不存在的 user_id 查
↓
MySQL 被 100w 次无效查询打爆
项目场景:
- 27 家分行的客户敏感数据查询,被恶意传 100w 个不存在的身份证号
- 外部数据采集,外部传 100w 个不存在的客户号
- 100w 次无效查询打爆 MySQL
1.3 击穿(Breakdown)
问题:1 个热点 key 过期,瞬间大量请求打到数据库
双 11 大促 / 春晚红包 / 明星离婚
↓
某个热点商品的缓存 key 过期
↓
⚠️ 100w 个用户同时查这个商品
↓
100w 个请求同时打 MySQL
↓
MySQL 扛不住,系统雪崩
项目场景:
- 春节红包雨,某个热门红包的库存 key 过期
- 央行降息,某个热门理财产品的详情 key 过期
- 100w + 用户同时查 1 个 key,DB 被打爆
二、3 大问题的解决方案(5 套方案)
2.1 雪崩的 4 种解决方案
方案 1:过期时间加随机值 (最常用)
// ❌ 错误:所有 key 同一时间过期
redisTemplate.opsForValue().set("report:2024", data, 1, TimeUnit.HOURS);
// ✅ 正确:过期时间加随机值(0-300 秒)
int baseExpire = 3600; // 1 小时
int randomExpire = RandomUtil.randomInt(0, 300); // 0-300 秒
redisTemplate.opsForValue().set("report:2024", data,
baseExpire + randomExpire, TimeUnit.SECONDS);
原理:50w 个 key 不会同时过期,分散到 0-300 秒
方案 2:多级缓存
┌─────────────────────────────────────────┐
│ L1: Caffeine(本地缓存,1 秒过期) │ ← JVM 内存
├─────────────────────────────────────────┤
│ L2: Redis(分布式缓存,1 小时过期) │ ← 共享内存
├─────────────────────────────────────────┤
│ L3: MySQL(数据库,永久) │ ← 磁盘
└─────────────────────────────────────────┘
"项目用 Caffeine + Redis 多级缓存 ,L1 缓存 1 秒过期,L2 缓存 1 小时过期 ,避免 50w+ key 同时过期雪崩。"
方案 3:熔断降级 (Sentinel / Resilience4j)
@SentinelResource(value = "queryOrder", fallback = "queryOrderFallback")
public Order queryOrder(Long orderId) {
// 查 Redis
Order order = (Order) redisTemplate.opsForValue().get("order:" + orderId);
if (order == null) {
// 查 MySQL
order = orderMapper.selectById(orderId);
redisTemplate.opsForValue().set("order:" + orderId, order, 3600, TimeUnit.SECONDS);
}
return order;
}
// 熔断降级:Redis 挂了直接返回默认值
public Order queryOrderFallback(Long orderId, Throwable e) {
log.warn("Redis 熔断降级, orderId={}", orderId, e);
return orderMapper.selectById(orderId); // 直接走 MySQL
}
方案 4:Redis 集群 + 高可用 (根本上解决)
Redis Sentinel(哨兵):主从自动切换
Redis Cluster(集群):数据分片 + 故障转移
2.2 穿透的 3 种解决方案
方案 1:空值缓存 (最常用)
// ❌ 错误:null 不缓存
public Order queryOrder(Long orderId) {
Order order = (Order) redisTemplate.opsForValue().get("order:" + orderId);
if (order == null) {
order = orderMapper.selectById(orderId);
if (order != null) {
redisTemplate.opsForValue().set("order:" + orderId, order, 3600, TimeUnit.SECONDS);
}
// ⚠️ null 不缓存,导致每次都查 DB
}
return order;
}
// ✅ 正确:null 也缓存(5 分钟)
public Order queryOrder(Long orderId) {
String key = "order:" + orderId;
Order order = (Order) redisTemplate.opsForValue().get(key);
if (order == null) {
order = orderMapper.selectById(orderId);
// 不管有没有都缓存
redisTemplate.opsForValue().set(key, order == null ? "null" : order,
order == null ? 300 : 3600, TimeUnit.SECONDS);
}
// 空值返回
return "null".equals(order) ? null : order;
}
方案 2:布隆过滤器
@Component
public class BloomFilterService {
@Autowired
private RedissonClient redissonClient;
private RBloomFilter<Long> orderBloomFilter;
@PostConstruct
public void init() {
orderBloomFilter = redissonClient.getBloomFilter("order:bloom");
// 预期 1 亿数据,误判率 1%
orderBloomFilter.tryInit(100_000_000L, 0.01);
// 启动时把数据库所有 ID 加载到布隆过滤器
List<Long> allOrderIds = orderMapper.selectAllIds();
for (Long id : allOrderIds) {
orderBloomFilter.add(id);
}
}
public boolean mightContain(Long orderId) {
return orderBloomFilter.contains(orderId);
}
}
@Service
public class OrderService {
@Autowired
private BloomFilterService bloomFilterService;
public Order queryOrder(Long orderId) {
// 1. 先过布隆过滤器
if (!bloomFilterService.mightContain(orderId)) {
return null; // 一定不存在,直接返回
}
// 2. 查 Redis
Order order = (Order) redisTemplate.opsForValue().get("order:" + orderId);
if (order == null) {
// 3. 查 MySQL
order = orderMapper.selectById(orderId);
if (order != null) {
redisTemplate.opsForValue().set("order:" + orderId, order, 3600, TimeUnit.SECONDS);
}
}
return order;
}
}
布隆过滤器原理:
- 用 bitmap 存 hash 值
- 查询时有 1% 误判率 (说有但实际没有),但绝对不漏报(说没有一定没有)
- 100w 个不存在 key 的查询,99% 在布隆过滤器就被挡住
方案 3:参数校验 + 限流 (业务层)
@PostMapping("/order/query")
public Result<Order> queryOrder(@RequestBody @Valid OrderQueryRequest request) {
// 1. 参数校验
if (request.getOrderId() == null || request.getOrderId() < 0) {
return Result.fail("参数非法");
}
// 2. 限流(同一 IP 每秒最多 10 次)
if (!rateLimiter.tryAcquire("queryOrder:" + request.getUserId(), 10)) {
return Result.fail("请求过快");
}
// 3. 正常查询
return Result.ok(orderService.queryOrder(request.getOrderId()));
}
2.3 击穿的 3 种解决方案
方案 1:分布式锁 (最常用)
public Order queryOrder(Long orderId) {
String key = "order:" + orderId;
Order order = (Order) redisTemplate.opsForValue().get(key);
if (order == null) {
// ✅ 加分布式锁,只让 1 个请求查 DB
String lockKey = "lock:order:" + orderId;
try (RedisLock lock = redisLock.tryLock(lockKey, 10, TimeUnit.SECONDS)) {
// ✅ Double Check:再次查 Redis
order = (Order) redisTemplate.opsForValue().get(key);
if (order == null) {
// 查 DB
order = orderMapper.selectById(orderId);
if (order != null) {
redisTemplate.opsForValue().set(key, order, 3600, TimeUnit.SECONDS);
}
}
}
}
return order;
}
"mpvs 项目用 Redis 分布式锁(SETNX + Lua 脚本) 解决热点 key 击穿,100w 并发查询 1 个热点 key,只让 1 个请求查 DB。"
方案 2:热点 key 永不过期 (逻辑过期)
// 缓存数据 + 逻辑过期时间
@Data
public class CacheData<T> {
private T data;
private Long expireTime; // 逻辑过期时间
}
// 写入时只设逻辑过期,不设 Redis 过期
public void setWithLogicalExpire(String key, Object value, long expireSeconds) {
long expireTime = System.currentTimeMillis() + expireSeconds * 1000;
CacheData<Object> cacheData = new CacheData<>();
cacheData.setData(value);
cacheData.setExpireTime(expireTime);
redisTemplate.opsForValue().set(key, cacheData); // 不设 Redis 过期
}
// 查询时检查逻辑过期
public Order queryOrder(Long orderId) {
String key = "order:" + orderId;
CacheData<Order> cacheData = (CacheData<Order>) redisTemplate.opsForValue().get(key);
if (cacheData == null) {
// 缓存不存在,查 DB
Order order = orderMapper.selectById(orderId);
setWithLogicalExpire(key, order, 3600);
return order;
}
if (cacheData.getExpireTime() < System.currentTimeMillis()) {
// ⚠️ 逻辑过期了,异步刷新
asyncRefreshCache(orderId, key);
}
return cacheData.getData();
}
@Async
public void asyncRefreshCache(Long orderId, String key) {
// 异步查 DB + 刷新缓存
Order order = orderMapper.selectById(orderId);
setWithLogicalExpire(key, order, 3600);
}
优点:永远不会有"key 过期瞬间打 DB"的问题
方案 3:预热 + 永不过期
// 项目启动时预热热点数据
@PostConstruct
public void preloadHotData() {
log.info("开始预热热点数据...");
// 查询所有热点 key
List<Long> hotOrderIds = orderMapper.selectHotOrderIds();
for (Long orderId : hotOrderIds) {
Order order = orderMapper.selectById(orderId);
redisTemplate.opsForValue().set("order:" + orderId, order); // 永不过期
}
log.info("预热完成,共 {} 个热点 key", hotOrderIds.size());
}
三、Redis 集群模式(主从 / Sentinel / Cluster)
3.1 主从复制(Master-Slave)
┌─────────┐ 异步复制 ┌─────────┐
│ Master │ ───────────→ │ Slave 1 │ ← 读
│ (读写) │ └─────────┘
└─────────┘ ───────────→ ┌─────────┐
异步复制 │ Slave 2 │ ← 读
└─────────┘
特点:
- 1 个 Master + N 个 Slave
- Master 写,Slave 读
- 异步复制 (可能丢数据,金融项目慎用),数据量小(100w/天)使用
3.2 Sentinel(哨兵)
┌─────────┐ ┌──────────┐
│ Master │ ← 监控 ──── │ Sentinel │ ← 自动故障转移
└─────────┘ │ 集群 │
↑ 自动切换 └──────────┘
│ ↑
┌─────────┐ │
│ Slave 1 │ ←─── 提升为 Master ─┘
└─────────┘
特点:
- 在主从基础上加 Sentinel 集群(3-5 个节点)
- Master 挂了自动选 Slave 升级为新 Master
- 客户端通过 Sentinel 知道当前 Master
- **项目常用 Sentinel 模式,**数据量中等(50w 任务)使用
3.3 Cluster(集群)
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Master │ │ Master │ │ Master │
│ Slot 0 │ │ Slot 1 │ │ Slot 2 │
│ -5460 │ │ -10922 │ │ -16383 │
└─────────┘ └─────────┘ └─────────┘
↑ ↑ ↑
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Slave 1 │ │ Slave 1 │ │ Slave 1 │
└─────────┘ └─────────┘ └─────────┘
特点:
- 数据分片(16384 个 slot)
- 至少 3 主 3 从
- 高可用 + 横向扩展
- 项目常用 Cluster 模式(27 家分行数据分片),数据量大(10 亿+)使用
四、Redis 双写一致性( 4 种方案 )
4.1 4 种方案对比
| 方案 | 一致性 | 性能 | 复杂度 |
|---|---|---|---|
| 先更新 DB,再删除缓存 | 最终一致 | 高 | 低 |
| 延迟双删 | 强一致 | 中 | 中 |
| 基于 Binlog 异步同步 | 最终一致 | 高 | 高 |
| 分布式锁 | 强一致 | 低 | 中 |
4.2 方案 1:Cache Aside 模式 (最常用)
// 写操作
public void updateOrder(Order order) {
// 1. 先更新 DB
orderMapper.updateById(order);
// 2. 再删除缓存
redisTemplate.delete("order:" + order.getId());
}
// 读操作
public Order queryOrder(Long orderId) {
String key = "order:" + orderId;
Order order = (Order) redisTemplate.opsForValue().get(key);
if (order == null) {
order = orderMapper.selectById(orderId);
if (order != null) {
redisTemplate.opsForValue().set(key, order, 3600, TimeUnit.SECONDS);
}
}
return order;
}
为什么是"先更新 DB 再删除缓存"?
- ❌ "先删除缓存再更新 DB":A 删缓存 → B 读缓存(null)→ B 查 DB(旧值)→ B 写缓存(旧值)→ A 写 DB(新值)→ 缓存是旧值
- ✅ "先更新 DB 再删除缓存":A 写 DB(新值)→ A 删缓存 → B 读缓存(null)→ B 查 DB(新值)→ B 写缓存(新值)→ 缓存最终是新值
4.3 方案 2:延迟双删
public void updateOrder(Order order) {
// 1. 先删除缓存
redisTemplate.delete("order:" + order.getId());
// 2. 更新 DB
orderMapper.updateById(order);
// 3. 延迟 500ms 再删除一次(异步)
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
redisTemplate.delete("order:" + order.getId());
});
}
原理: 删除缓存 → 更新 DB → 延迟 500ms → 再删缓存。避免 B 在 A 更新 DB 期间读到旧 DB 值并写入缓存。
4.4 方案 3:基于 Binlog 异步同步
@Component
public class BinlogSyncConsumer {
@Autowired
private CanalClient canalClient;
@PostConstruct
public void start() {
canalClient.subscribe("mpvs_order", message -> {
// 1. 解析 Binlog
for (CanalEntry entry : message.getEntries()) {
if (entry.getEntryType() == EntryType.ROWDATA) {
RowChange rowChange = entry.getRowChange();
for (RowData rowData : rowChange.getRowDatasList()) {
// 2. 删除对应缓存
Long orderId = Long.parseLong(rowData.getAfterColumns(0).getValue());
redisTemplate.delete("order:" + orderId);
}
}
}
});
}
}
原理: 用 Canal 订阅 MySQL Binlog,异步删除缓存 。最终一致性高、零侵入。
4.5 方案 4:分布式锁 (强一致)
public void updateOrder(Order order) {
String lockKey = "lock:order:" + order.getId();
try (RedisLock lock = redisLock.tryLock(lockKey, 10, TimeUnit.SECONDS)) {
// 1. 写 DB
orderMapper.updateById(order);
// 2. 写缓存
redisTemplate.opsForValue().set("order:" + order.getId(), order, 3600, TimeUnit.SECONDS);
}
}
缺点:性能低 (所有写操作都要加锁)。小流量场景用。
五、Redis 大 Key / 热 Key 问题
5.1 大 Key 问题
问题:1 个 key 存了 1G 数据
key: user:all
value: [100w 个 user 的 JSON] ← ⚠️ 1G
危害:
- 删除 1 个大 key 阻塞 Redis(redis-cli del user:all 会卡 5 秒)
- 集群模式 slot 迁移卡住
- 网络带宽打满
解决:
// ❌ 错误:1 个 key 存所有
redisTemplate.opsForValue().set("user:all", allUsers);
// ✅ 正确:拆成多个小 key
for (int i = 0; i < 100; i++) {
List<User> batch = allUsers.subList(i * 10000, (i + 1) * 10000);
redisTemplate.opsForValue().set("user:batch:" + i, JSON.toJSONString(batch));
}
"把 27 家分行的客户数据按分行编号拆成 27 个小 key ,避免大 Key 阻塞 Redis。"
5.2 热 Key 问题
问题:1 个 key 被 100w 并发访问
key: product:hot:123
并发: 100w QPS
↓
单 Redis 节点扛不住
↓
⚠️ Redis CPU 100%
解决:
// 方案 1:本地缓存 + Redis 二级缓存
@Cacheable(value = "CaffeineCache", key = "#productId")
public Product getProduct(Long productId) {
return productMapper.selectById(productId);
}
// 方案 2:多副本 key
int replica = productId.hashCode() % 5; // 5 个副本
redisTemplate.opsForValue().get("product:hot:" + productId + ":replica:" + replica);
// 方案 3:Slot 分散(Cluster 模式下用 hashtag 强制同 slot)
redisTemplate.opsForValue().get("{product:hot}123"); // 同一 slot
六、面试官追问应对
追问:Redis 3 大问题怎么解决?
"雪崩、穿透、击穿对应不同场景:
- 雪崩 (大量 key 同时过期):过期时间加随机值 + 多级缓存 + 熔断降级
- 穿透 (查询不存在的 key):空值缓存 + 布隆过滤器 + 参数校验
- 击穿 (1 个热点 key 过期):分布式锁 + 热点 key 永不过期 + 预热
老哥 mpvs 项目用'布隆过滤器 + 多级缓存 + 分布式锁'3 套组合,挡住了 50w+ 无效查询和 100w+ 热点查询。"
追问 2:Redis 集群模式怎么选?
"3 种集群模式:
- 主从 :1 主 N 从,简单但 Master 挂了要手动切换(金融项目慎用)
- Sentinel :主从 + Sentinel 集群(3-5 节点),Master 挂了自动切换 (MOVA 用这个)
- Cluster :数据分片(16384 slot)+ 至少 3 主 3 从 (mpvs 用这个 ,16 主 16 从)
数据量 < 50G 用 Sentinel,> 50G 用 Cluster。"
追问 3:Redis 和 MySQL 双写一致性怎么保证?
"4 种方案:
- Cache Aside (最常用):先更新 DB,再删除缓存(最终一致)
- 延迟双删:先删缓存 → 更新 DB → 延迟 500ms → 再删缓存(避免并发读旧值)
- Binlog 异步同步 :用 Canal 订阅 MySQL Binlog,异步删除缓存 (mpvs 用这个)
- 分布式锁 :写 DB + 写缓存都加锁(强一致,性能低)
追问 4:Redis 雪崩怎么发生的?怎么防止?
"发生原因:大量 key 同一时间过期,请求瞬间打 DB。
项目实战:
1.过期时间加随机值(0-300 秒)--- 50w 个 key 不会同时过期
2.Caffeine + Redis 多级缓存 --- L1 缓存 1 秒过期,扛住 80% 请求
3.Sentinel 熔断降级 --- Redis 挂了直接返回 MySQL,不报错
效果 :50w+ key 同时过期场景下,QPS 只增加 200%(10w→30w)。"
追问 5:布隆过滤器原理?
"布隆过滤器用 bitmap + 多个 hash 函数:
1.插入 :对 key 算 k 个 hash 值,bitmap 对应位置设为 1
2.查询 :算 hash 值,任何一位是 0 → 一定不存在 ;全是 1 → 可能存在(有 1% 误判)
优点 :100w 个不存在 key 查询,99% 在布隆过滤器挡住 ,不查 DB。
**项目用 Redisson 的 RBloomFilter,**预加载 10 亿订单 ID 到布隆过滤器,误判率 1%。"
追问 6:Redis 大 Key 怎么发现?怎么解决?
"发现 :用
redis-cli --bigkeys扫描,memory usage 命令看 key 大小。项目大 Key 处理:
1.拆分:27 家分行的客户数据拆成 27 个 key(每个 100MB)
2.异步删除 :用
unlink替代del(不阻塞 Redis)3.压缩 :用 MessagePack / Protobuf 替代 JSON(压缩 3 倍)
效果 :原来 1 个 1G 大 key → 拆成 10 个 100M 小 key,删除时间从 5s 降到 500ms。"
追问 7:Redis 主从复制原理?
"全量复制 + 增量复制:
全量复制(Slave 第一次连 Master):
1.Slave 发送
PSYNC命令2.Master 执行
BGSAVE生成 RDB3.Master 把 RDB 发送给 Slave
4.Slave 加载 RDB
5.同步过程中 Master 写的命令,缓存在 replication buffer
增量复制(Slave 重连 Master):
1.Master 维护 repl_backlog 缓冲区
2.Slave 重连时发送
PSYNC offset3.Master 从 offset 位置开始发送增量命令
用 Redis Sentinel + 异步复制(金融项目允许秒级数据丢失)。"
七、记忆口诀
"雪崩:随机值 + 多级缓存 + 熔断"
"穿透:空值缓存 + 布隆过滤器 + 参数校验"
"击穿:分布式锁 + 永不过期 + 预热"
"双写一致:Cache Aside + 延迟双删 + Binlog 同步"
"集群:50G 以下 Sentinel,50G 以上 Cluster"
"大 Key:拆 + 压 + 异步删"
"热 Key:本地缓存 + 多副本 + slot 分散"