一、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 分散"