【黑马点评|2 Redis缓存 面试题】
正文内容给出的还是关键代码逻辑,结尾附录给了完整可执行的代码 细节教学推荐看:这篇
前言
核心目标🎯:
-
基础缓存接入
-
缓存更新策略落地
-
双写一致(先库后删)
-
缓存穿透(缓存空对象 + 布隆过滤)
-
缓存雪崩(TTL 随机值)
-
缓存击穿(互斥锁 + 逻辑过期)
-
通用 Redis 缓存工具类封装
核心技术栈
Spring Boot、Redis、StringRedisTemplate、Hutool(JSON 序列化)、Redisson(布隆过滤)、线程池(缓存重建)
一、核心基础:Redis 缓存工具类封装
工具类作用
统一封装缓存序列化、反序列化、TTL 存储、逻辑过期存储、缓存查询等操作,简化业务层代码,标准化缓存处理逻辑。
核心代码与说明
java
@Component
public class RedisCacheUtil {@Resourceprivate StringRedisTemplate stringRedisTemplate;private static final String CACHE_NULL_VALUE = ""; // 空值统一标识
// 1. TTL过期存储(适用于普通缓存、空值缓存)public void setWithTTL(String key, Object value, long ttl, TimeUnit timeUnit) {String jsonStr = serialize(value); // 统一序列化stringRedisTemplate.opsForValue().set(key, jsonStr, ttl, timeUnit);}
// 2. 逻辑过期存储(适用于热点Key,解决缓存击穿)public void setWithLogicalExpire(String key, Object value, long expireSeconds) {RedisData redisData = new RedisData(); // 封装数据+过期时间redisData.setData(value);redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));}
// 3. 穿透防护查询(缓存空对象)public <T> T getWithPassThrough(String key, Class<T> type) {String jsonStr = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isBlank(jsonStr)) return null; // 未缓存if (CACHE_NULL_VALUE.equals(jsonStr)) return null; // 缓存空值return deserialize(jsonStr, type); // 正常数据反序列化}
// 4. 逻辑过期查询(解决缓存击穿)public <T> T getWithLogicalExpire(String key, Class<T> type) {String jsonStr = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isBlank(jsonStr)) return null; // 未缓存// 反序列化逻辑过期数据RedisData redisData = JSONUtil.toBean(jsonStr, RedisData.class);T data = deserialize(JSONUtil.toJsonStr(redisData.getData()), type);// 未过期返回数据,已过期返回null(触发缓存重建)return redisData.getExpireTime().isAfter(LocalDateTime.now()) ? data : null;}
// 通用序列化(null→空字符串)private String serialize(Object obj) {return obj == null ? CACHE_NULL_VALUE : JSONUtil.toJsonStr(obj);}
// 通用反序列化private <T> T deserialize(String jsonStr, Class<T> type) {return JSONUtil.toBean(jsonStr, type);}
// 逻辑过期数据封装类public static class RedisData {private Object data;private LocalDateTime expireTime;// getter+setter}
}
二、基础缓存接入(工具类调用示例)
核心逻辑
先查缓存→缓存命中直接返回→未命中查库→库中存在则写入缓存→库中不存在则缓存空值。
业务层实现
java
@Override
public Result queryById1(Long id) {String key = CACHE_SHOP_KEY + id;// 1. 工具类查询缓存(自动处理空值穿透)Shop shop = redisCacheUtil.getWithPassThrough(key, Shop.class);if (shop != null) return Result.ok(shop);
// 2. 缓存未命中,查数据库shop = getById(id);if (shop == null) {// 3. 库中不存在,缓存空值(2分钟过期)redisCacheUtil.setWithTTL(key, null, RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);return Result.fail("店铺不存在");}
// 4. 库中存在,写入缓存(30分钟基础TTL)redisCacheUtil.setWithTTL(key, shop, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);return Result.ok(shop);
}
三、缓存更新与双写一致
核心策略:先库后删
-
逻辑:修改数据库数据后,删除对应缓存(而非更新缓存),下次查询自动从库加载最新数据重建缓存。
-
优势:避免并发场景下,多个线程同时更新缓存导致的脏数据问题。
业务层实现(店铺更新示例)
java
@Override
public Result update(Shop shop) {if (shop.getId() == null) return Result.fail("店铺ID不能为空");// 1. 先更新数据库updateById(shop);// 2. 后删除缓存(触发后续重建)String key = CACHE_SHOP_KEY + shop.getId();stringRedisTemplate.delete(key);return Result.ok();
}
四、缓存穿透防护(双重方案)
问题定义
恶意请求不存在的 Key(如店铺 ID=-1),缓存未命中则持续冲击数据库。
方案 1:缓存空对象(已通过工具类实现)
-
逻辑:库中查询为 null 时,缓存空字符串(
""),过期时间较短(如 2 分钟)。 -
关键:工具类
getWithPassThrough自动识别空值标识,直接返回 null,阻断后续查库。
方案 2:布隆过滤(终极防护)
-
逻辑:项目启动时加载所有有效店铺 ID 到布隆过滤器,查询前先判断 ID 是否 “可能存在”,不存在直接返回。
-
优势:比缓存空对象更高效,从源头拦截无效请求。
实现步骤
-
引入 Redisson 依赖
xml
<dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><version>3.17.6</version> </dependency>
-
布隆过滤器初始化(项目启动时)
java
@Component
public class ShopBloomFilterInit implements CommandLineRunner {@Resourceprivate RedissonClient redissonClient;@Resourceprivate ShopMapper shopMapper;private static final String BLOOM_KEY = "bloom:filter:shop:id";
@Overridepublic void run(String... args) {// 初始化:预计10万数据,误判率0.01RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(BLOOM_KEY);bloomFilter.tryInit(100000, 0.01);// 加载所有有效店铺IDList<Long> ids = shopMapper.selectList(null).stream().map(Shop::getId).collect(Collectors.toList());ids.forEach(bloomFilter::add);}
}
-
业务层集成过滤
java
public Result queryWithBloomFilter(Long id) {// 1. 布隆过滤器拦截无效IDRBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("bloom:filter:shop:id");if (!bloomFilter.contains(id)) {return Result.fail("店铺不存在");}// 2. 后续流程:工具类查询缓存→查库(同queryById1)return queryById1(id);
}
五、缓存雪崩防护
问题定义
大量缓存 Key 同时过期,导致请求集中冲击数据库。
解决方案:TTL 随机值偏移
在基础过期时间上添加 ±5 分钟随机值,分散缓存过期时间,避免集中失效。
实现代码(工具类调用改造)
java
// 写入缓存时添加随机TTL long baseTTL = RedisConstants.CACHE_SHOP_TTL; // 30分钟基础值 long random = new Random().nextInt(10) - 5; // ±5分钟随机偏移 redisCacheUtil.setWithTTL(key, shop, baseTTL + random, TimeUnit.MINUTES);
六、缓存击穿防护(两种方案)
问题定义
热点 Key(如高频访问的店铺)过期时,大量并发请求直击数据库。
方案 1:互斥锁(强一致性)
核心逻辑
热点 Key 过期后,仅允许一个线程查库重建缓存,其他线程获取锁失败后休眠重试,确保并发安全。
业务层实现
java
运行
@Override
public Shop queryWithMutex(Long id) {String key = CACHE_SHOP_KEY + id;String lockKey = LOCK_SHOP_KEY + id;String shopJson = stringRedisTemplate.opsForValue().get(key);
// 缓存命中(正常/空值)直接返回if (StrUtil.isNotBlank(shopJson)) return JSONUtil.toBean(shopJson, Shop.class);if ("".equals(shopJson)) return null;
Shop shop = null;int retryCount = 3; // 限制重试次数try {// 循环获取锁while (retryCount-- > 0) {if (tryLock(lockKey)) {// 二次查缓存(避免重试期间已重建)shopJson = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isNotBlank(shopJson)) return JSONUtil.toBean(shopJson, Shop.class);// 查库重建缓存shop = getById(id);if (shop == null) {redisCacheUtil.setWithTTL(key, null, RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);return null;}redisCacheUtil.setWithTTL(key, shop, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);return shop;}Thread.sleep(50); // 未获锁,休眠重试}return null;} catch (InterruptedException e) {throw new RuntimeException(e);} finally {unlock(lockKey); // 必须释放锁}
}
// 互斥锁实现(带唯一标识防误删)
private boolean tryLock(String key) {String uuid = UUID.randomUUID().toString();Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, uuid, 10, TimeUnit.SECONDS);return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {String uuid = stringRedisTemplate.opsForValue().get(key);if (uuid != null && uuid.equals(stringRedisTemplate.opsForValue().get(key))) {stringRedisTemplate.delete(key);}
}
方案 2:逻辑过期(高可用性,允许短期脏数据)
核心逻辑
热点 Key 物理永不过期,缓存中存储 “数据 + 逻辑过期时间”。过期后启异步线程重建缓存,当前线程直接返回旧数据,无锁等待。
业务层实现
java
运行
// 线程池(专门用于缓存重建)
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
// 1. 预热热点缓存(初始化/更新时调用)
public void saveShop2Redis(Long id, Long expiredSeconds) {Shop shop = getById(id);RedisData redisData = new RedisData();redisData.setData(shop);redisData.setExpireTime(LocalDateTime.now().plusSeconds(expiredSeconds));stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
// 2. 逻辑过期查询
public Shop queryWithLogicalExpire(Long id) {String key = CACHE_SHOP_KEY + id;String json = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isBlank(json)) return null; // 非热点Key,直接返回
// 反序列化逻辑过期数据RedisData redisData = JSONUtil.toBean(json, RedisData.class);Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {return shop; // 未过期,返回旧数据}
// 已过期,异步重建缓存if (tryLock(LOCK_SHOP_KEY + id)) {CACHE_REBUILD_EXECUTOR.submit(() -> {try {saveShop2Redis(id, 20L); // 重建缓存,20秒逻辑过期} finally {unlock(LOCK_SHOP_KEY + id);}});}return shop; // 返回旧数据,不阻塞
}
七、关键注意事项
-
空值标识统一为
"",避免与"null"字符串混淆,确保工具类判断一致。 -
互斥锁必须添加唯一标识(如 UUID),释放前校验归属权,防止误删其他线程的锁。
-
TTL 随机值范围需合理(如 ±5 分钟),避免过期时间过短导致缓存频繁重建。
-
逻辑过期的线程池核心线程数需根据业务调整(如 10 个),避免线程耗尽。
-
布隆过滤器需在店铺 ID 新增 / 删除时同步更新,否则会出现误判。
-
缓存删除操作需在数据库事务提交后执行,避免事务回滚导致缓存脏数据。
八、方案对比总结
| 缓存问题 | 解决方案 | 核心思路 | 适用场景 |
|---|---|---|---|
| 穿透 | 布隆过滤 + 缓存空对象 | 源头拦截 + 兜底防护 | 所有查询场景 |
| 雪崩 | TTL 随机值 | 分散过期时间,避免集中失效 | 普通缓存场景 |
| 击穿 | 互斥锁 | 单线程查库重建,强一致性 | 不允许脏数据(如订单查询) |
| 击穿 | 逻辑过期 | 异步重建,返回旧数据,高可用 | 允许短期脏数据(如商品详情) |
| 双写一致 | 先库后删 | 保证数据库正确性,触发缓存重建 | 读多写少场景 |
Redis 缓存 10 道难度递增面试题(含场景 + 答案)
1. 基础题:Redis 缓存的核心作用是什么?请结合电商商品详情页场景说明为什么需要用缓存。
场景
某电商平台商品详情页日均访问量 100 万次,直接查询 MySQL 数据库时,单库 QPS 峰值超 5 万,导致数据库响应缓慢,页面加载延迟 3 秒以上。
问题
Redis 缓存的核心作用的是什么?该场景下用缓存能解决什么问题?
答案
-
核心作用:加速查询、减轻数据库压力、提升系统吞吐量(本质是 “空间换时间”,将热点数据存入内存)。
-
场景解决:
-
商品详情属于热点数据(同一商品被大量用户访问),存入 Redis 后,查询响应时间从 3 秒降至 10ms 内;
-
90% 以上的查询请求直接命中 Redis,MySQL QPS 降至 5000 以下,避免数据库过载宕机;
-
支持高并发访问,应对促销活动时的流量峰值。
-
2. 基础进阶:缓存更新的三种核心策略(Cache-Aside、Read-Through、Write-Through)分别是什么?电商场景中商品库存更新,优先选哪种策略?
场景
电商平台商品库存实时变动(用户下单减库存、退货加库存),需保证缓存与数据库库存数据一致,同时兼顾查询性能。
问题
三种缓存更新策略的核心逻辑是什么?该场景下优先选择哪种?为什么?
答案
-
三种策略核心逻辑:
-
Cache-Aside(旁路缓存):查询先查缓存,未命中查数据库;更新先更数据库,再删缓存(不直接更缓存);
-
Read-Through(读透):缓存代理数据库查询,未命中时自动查库并写入缓存,业务层无需关注 DB;
-
Write-Through(写透):更新时先写缓存,缓存再同步写数据库,业务层无需关注 DB。
-
-
优先选择:Cache-Aside 策略。
-
原因:
-
电商库存更新频繁,Write-Through 会导致缓存与 DB 双写,性能开销大;
-
Read-Through 依赖缓存中间件支持,灵活性低;
-
Cache-Aside 实现简单,先库后删能保证数据一致性,删除缓存而非更新可避免并发冲突,适配库存高频变动场景。
-
3. 中级:什么是缓存穿透?请结合 “恶意查询不存在的商品 ID” 场景,说明 “缓存空对象” 方案的实现逻辑、优点和缺点。
场景
某黑产通过脚本批量查询不存在的商品 ID(如 ID=-1、999999),导致所有请求穿透缓存直击 MySQL,数据库连接池耗尽。
问题
-
缓存穿透的定义是什么?
-
“缓存空对象” 方案如何解决该问题?
-
该方案的优缺点是什么?
答案
-
缓存穿透定义:请求查询的 Key 在缓存和数据库中均不存在,导致所有请求直接穿透到数据库,引发数据库压力过大。
-
方案实现逻辑:
-
业务层查询商品 ID 时,若 DB 返回 null(商品不存在),则向 Redis 缓存一个空值标识(如 ""或"null");
-
后续相同 ID 查询时,缓存直接返回空值,阻断对 DB 的请求。
-
-
优点:实现简单,无需额外中间件,能快速解决大部分穿透场景;
-
缺点:
-
浪费 Redis 内存(存储大量无效空值);
-
若后续该商品 ID 被创建(如商家新增商品 999999),会出现缓存空值与 DB 实际数据不一致(需设置空值过期时间,如 2 分钟)。
-
4. 中级:缓存击穿和缓存穿透的核心区别是什么?请结合 “秒杀商品热点 Key 过期” 场景,说明互斥锁方案解决缓存击穿的实现步骤和关键注意事项。
场景
电商秒杀活动中,某爆款商品的缓存 Key(如 "cache:shop:1001")过期,瞬间有 10000 + 并发请求查询该商品,直接冲击 MySQL。
问题
-
缓存击穿和缓存穿透的核心区别是什么?
-
互斥锁方案如何解决该场景的缓存击穿?
-
实现时需注意哪些问题?
答案
-
核心区别:
-
缓存穿透:Key 在缓存和 DB 中均不存在;
-
缓存击穿:Key 在 DB 中存在,但缓存 Key 过期 / 失效,导致大量并发请求直击 DB。
-
-
互斥锁方案实现步骤:
-
查询缓存,若未命中,尝试通过 Redis 的
setIfAbsent方法获取互斥锁(如 "lock:shop:1001"); -
若获取锁成功,查询 DB 并将数据写入缓存,然后释放锁;
-
若获取锁失败,休眠 50ms 后重试查询缓存,直到锁释放或重试次数耗尽。
-
-
关键注意事项:
-
锁需设置过期时间(如 10 秒),避免线程异常导致死锁;
-
释放锁前需校验锁的归属权(如用 UUID 作为锁值),防止误删其他线程的锁;
-
限制重试次数(如 3 次),避免无限阻塞消耗资源。
-
5. 中级偏上:什么是缓存雪崩?请结合 “电商大促零点大量商品缓存同时过期” 场景,说明至少 3 种解决方案,并分析各自的适用场景。
场景
电商双 11 零点,平台上 10 万 + 商品的缓存 Key 同时过期,导致海量请求集中穿透到 MySQL,数据库瞬间宕机。
问题
-
缓存雪崩的定义是什么?
-
该场景下有哪些解决方案?各自适用什么情况?
答案
-
缓存雪崩定义:大量缓存 Key 在同一时间过期,或 Redis 集群宕机,导致请求集中冲击数据库,引发系统级故障。
-
解决方案及适用场景:
-
TTL 随机值:给每个缓存 Key 的过期时间添加随机偏移量(如基础 30 分钟 ±5 分钟),分散过期时间。适用场景:普通商品缓存,无特殊一致性要求;
-
热点 Key 永不过期:对秒杀、爆款等热点商品,不设置物理过期时间,通过业务逻辑定期更新缓存。适用场景:热点数据,允许短期脏数据;
-
Redis 集群部署:采用主从 + 哨兵或 Redis Cluster 架构,避免单节点宕机导致整个缓存失效。适用场景:高可用要求高的核心业务;
-
限流降级:在网关层对请求限流,缓存失效时触发降级策略(如返回默认页面),保护数据库。适用场景:流量峰值不可控的大促场景。
-
6. 高级:双写一致中 “先更新数据库,后删除缓存” 是主流方案,但存在 “缓存删除失败” 的潜在问题。请结合 “商品信息更新” 场景,说明该问题的具体表现、原因及解决方案。
场景
商家更新商品价格(DB 中价格从 199 元改为 299 元),执行 “更新 DB→删除缓存” 流程时,缓存删除操作失败(如 Redis 网络波动),导致后续查询仍返回旧缓存(199 元)。
问题
-
该场景下 “缓存删除失败” 会导致什么问题?
-
失败的常见原因有哪些?
-
如何解决该问题?
答案
-
问题表现:数据库数据已更新,但缓存未删除,导致后续查询返回旧数据,出现缓存与 DB 不一致(脏数据)。
-
失败原因:Redis 网络波动、Redis 节点宕机、业务线程异常中断。
-
解决方案:
-
重试机制:缓存删除失败时,通过本地重试(如 3 次)确保删除成功;
-
消息队列补偿:将缓存删除操作写入消息队列(如 RabbitMQ),若删除失败,消费者重试删除,直到成功;
-
定时任务校验:后台启动定时任务,对比缓存与 DB 数据的一致性,发现不一致则同步更新缓存。
-
7. 高级:布隆过滤器为什么能解决缓存穿透?请结合 “海量用户 ID 查询用户信息” 场景,说明其原理、实现步骤及优缺点。
场景
社交 APP 有 1 亿注册用户,大量非注册用户 ID(如随机生成的 ID)被用于查询,导致缓存穿透。需用布隆过滤器优化。
问题
-
布隆过滤器的核心原理是什么?
-
如何结合 Redis 实现布隆过滤器解决该场景的穿透问题?
-
优缺点是什么?
答案
-
核心原理:基于多个哈希函数和位数组,快速判断一个元素是否 “可能存在” 于集合中(不存在则 100% 准确,存在则有极小误判率)。
-
Redis 实现步骤:
-
项目启动时,将所有注册用户 ID 加载到 Redis 布隆过滤器(如 Redisson 的 RBloomFilter);
-
配置布隆过滤器参数:预计存储 1 亿个 ID,误判率 0.01(需提前计算位数组大小和哈希函数个数);
-
用户查询时,先通过布隆过滤器判断 ID 是否存在,不存在则直接返回 “用户不存在”,存在则继续查缓存→DB。
-
-
优点:
-
内存效率极高(1 亿个 ID,误判率 0.01,仅需约 12MB 内存);
-
查询速度快(O (k),k 为哈希函数个数);
-
-
缺点:
-
存在误判率(无法完全避免穿透,需配合缓存空对象兜底);
-
不支持删除操作(用户注销后,无法从布隆过滤器中移除 ID)。
-
8. 高级:基于 Redis 实现分布式锁时,如何避免 “死锁”“误删他人锁”“锁过期释放” 三大问题?请结合 “秒杀下单库存扣减” 场景,写出完整的实现代码思路。
场景
秒杀活动中,多个服务实例同时扣减同一商品库存,需通过分布式锁保证库存不超卖。
问题
-
如何解决分布式锁的三大问题?
-
完整的实现代码思路是什么?
答案
-
三大问题解决方案:
-
死锁:给锁设置过期时间(如 10 秒),即使线程异常,锁也会自动释放;
-
误删他人锁:给锁值设置唯一标识(如 UUID),释放锁前校验标识是否匹配;
-
锁过期释放:若业务执行时间超过锁过期时间,通过 “看门狗机制”(后台线程定时续期锁的过期时间)。
-
-
实现代码思路:
java
运行
// 1. 获取锁(带唯一标识+过期时间)
public boolean tryLock(String lockKey, String uuid, long expireTime, TimeUnit unit) {return BooleanUtil.isTrue(stringRedisTemplate.opsForValue().setIfAbsent(lockKey, uuid, expireTime, unit));
}
// 2. 看门狗机制(续期锁)
public void startWatchDog(String lockKey, String uuid, long expireTime) {ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();executor.scheduleAtFixedRate(() -> {// 每3秒续期一次,续期为原过期时间的1/3if (uuid.equals(stringRedisTemplate.opsForValue().get(lockKey))) {stringRedisTemplate.expire(lockKey, expireTime, TimeUnit.SECONDS);} else {executor.shutdown(); // 锁已释放,停止续期}}, 0, expireTime / 3, TimeUnit.SECONDS);
}
// 3. 释放锁(校验唯一标识)
public void unlock(String lockKey, String uuid) {String value = stringRedisTemplate.opsForValue().get(lockKey);if (uuid.equals(value)) {stringRedisTemplate.delete(lockKey);}
}
// 4. 秒杀库存扣减场景使用
public boolean seckillStock(Long productId) {String lockKey = "lock:stock:" + productId;String uuid = UUID.randomUUID().toString();try {// 获取锁boolean isLock = tryLock(lockKey, uuid, 10, TimeUnit.SECONDS);if (!isLock) return false;// 启动看门狗续期startWatchDog(lockKey, uuid, 10);// 扣减库存(查DB→减库存→更DB)Product product = productMapper.selectById(productId);if (product.getStock() <= 0) return false;product.setStock(product.getStock() - 1);productMapper.updateById(product);return true;} finally {// 释放锁unlock(lockKey, uuid);}
}
9. 高级偏架构:逻辑过期方案和互斥锁方案解决缓存击穿的核心差异是什么?请结合 “商品详情页” 和 “订单查询” 两个场景,说明如何选择方案,并分析原因。
场景
-
电商商品详情页:日均访问量 10 万 +,允许 5 分钟内的价格、库存等数据脏读;
-
订单查询页:用户查询自己的订单状态,不允许任何脏读(订单状态必须实时准确)。
问题
-
逻辑过期和互斥锁方案的核心差异是什么?
-
两个场景分别选择哪种方案?为什么?
答案
-
核心差异对比:
| 维度 | 互斥锁方案 | 逻辑过期方案 |
|--------------|---------------------------|-----------------------------|
| 一致性 | 强一致性(单线程查库重建) | 最终一致性(允许短期脏数据) |
| 可用性 | 低(锁竞争导致请求阻塞) | 高(无锁,返回旧数据) |
| 性能 | 中等(阻塞重试消耗资源) | 高(无阻塞,直接返回) |
| 实现复杂度 | 中等(锁管理 + 重试) | 较高(异步重建 + 逻辑过期封装)|
-
场景选择:
-
商品详情页:选择逻辑过期方案。原因:允许短期脏数据,追求高并发、低延迟,逻辑过期方案无锁阻塞,能支撑 10 万 + 日均访问量;
-
订单查询页:选择互斥锁方案。原因:订单状态不允许脏读,互斥锁能保证单线程查库重建缓存,确保数据实时一致。
-
10. 架构级:某高并发电商平台(日均访问 1 亿次,热点商品占比 20%),请设计一套 Redis 缓存架构,解决缓存穿透、击穿、雪崩、双写一致问题,并说明核心组件和优化策略。
场景
平台包含商品、订单、用户三大核心模块,商品模块热点集中(20% 商品占 80% 访问量),订单模块要求强一致性,用户模块需避免穿透。
问题
-
画出核心架构图(文字描述);
-
说明各组件的作用;
-
如何解决四大缓存问题?
答案
-
核心架构图(文字描述):
客户端 → 网关(限流降级) → 应用层(本地缓存 Caffeine) → Redis Cluster(主从 + 哨兵) → 布隆过滤器 → MySQL
-
各组件作用:
-
网关:对恶意请求限流,缓存失效时触发降级(返回默认页面);
-
本地缓存 Caffeine:缓存 Top20% 热点商品,减少 Redis 访问压力(本地缓存响应时间 < 1ms);
-
Redis Cluster:3 主 3 从架构,分片存储缓存数据,保证高可用;
-
布隆过滤器:Redis 集群级布隆过滤器,拦截无效 Key(如不存在的商品 ID);
-
MySQL:存储核心业务数据,保证数据持久化。
-
-
四大缓存问题解决方案:
-
缓存穿透:网关拦截 + 布隆过滤器 + 缓存空对象(三级防护);
-
缓存击穿:本地缓存 Caffeine 缓存热点商品(物理永不过期)+ Redis 逻辑过期(异步重建);
-
缓存雪崩:Redis Cluster 高可用(避免集群宕机)+ TTL 随机值(分散过期时间);
-
双写一致:
-
商品模块(读多写少):先库后删 + 消息队列补偿;
-
订单模块(强一致性):先删缓存 + 分布式锁 + 数据库事务(确保更新原子性)。
-
-
-
额外优化策略:
-
热点数据隔离:将 Top20% 热点商品缓存到独立的 Redis 分片,避免热点冲击整个集群;
-
缓存预热:大促前通过定时任务将热点商品数据加载到本地缓存和 Redis;
-
监控告警:通过 Prometheus 监控 Redis 命中率、过期 Key 数量,异常时及时告警。
-
源码
package com.hmdp.service.impl;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.RedisData;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisCacheUtil;
import com.hmdp.utils.RedisConstants;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_KEY;
import static com.hmdp.utils.RedisConstants.LOCK_SHOP_KEY;
/*** <p>* 服务实现类* </p>** @author 虎哥* @since 2021-12-22*/
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resourceprivate StringRedisTemplate stringRedisTemplate;
@Overridepublic Result queryById(Long id) {String key = CACHE_SHOP_KEY + id;String shopJson = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);log.debug("缓存中查找成功🏅🏅");return Result.ok(shop);}//解决缓存穿透问题//todo:使用布隆过滤
if (shopJson == null) {return Result.fail("店铺不存在");}Shop shop = getById(id);if (shop == null) {//stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr("null"), 30L, TimeUnit.MINUTES);
//不存在,返回错误信息return Result.fail("店铺不存在");}stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30L, TimeUnit.MINUTES);
return Result.ok(shop);}//解决缓存击穿问题
/*** 尝试获取分布式锁(基于Redis实现)* 核心:利用Redis的setIfAbsent原子操作,确保并发场景下只有一个线程能获取锁** @param key 锁的唯一标识(如"lock:shop:1",通常包含业务ID)* @return true-获取锁成功;false-获取锁失败*/private boolean tryLock(String key) {// setIfAbsent:原子操作,等价于Redis的SET key value NX PX 10000// 作用:当key不存在时,设置key=1,同时指定10秒过期时间;若key已存在,不做操作// 过期时间目的:防止线程获取锁后因异常崩溃,导致锁永久无法释放(避免死锁)Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 处理Redis返回的Boolean可能为null的情况(如Redis连接异常),确保返回boolean类型且避免空指针return BooleanUtil.isTrue(flag);}
/*** 释放分布式锁* 注意:此实现为基础版本,存在潜在风险(如可能误删其他线程持有的锁)** @param key 锁的唯一标识(需与获取锁时的key一致)*/private void unlock(String key) {// 直接删除Redis中的锁标识,释放锁资源供其他线程竞争stringRedisTemplate.delete(key);}
@Overridepublic Shop queryWithPassThrough(Long id) {String key = CACHE_SHOP_KEY + id;String shopJson = stringRedisTemplate.opsForValue().get(key);
// 1. 缓存命中非空值:直接返回if (StrUtil.isNotBlank(shopJson)) {return JSONUtil.toBean(shopJson, Shop.class);}
// 2. 缓存命中空值(已存入""):直接返回null(避免查库)if ("".equals(shopJson)) {return null;}
// 3. 缓存未命中:查数据库Shop shop = getById(id);if (shop == null) {// 缓存空值为""(而非"null"),明确区分空值stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);return null;}
// 4. 数据库存在:写入缓存(使用正常数据过期时间)stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, // 修复:使用正常数据的过期时间TimeUnit.MINUTES);
return shop;}
@Overridepublic Shop queryWithMutex(Long id) {String key = CACHE_SHOP_KEY + id;String shopJson = stringRedisTemplate.opsForValue().get(key);
// 1. 缓存命中非空值:直接返回if (StrUtil.isNotBlank(shopJson)) {return JSONUtil.toBean(shopJson, Shop.class);}
// 2. 缓存命中空值(统一用""标识):直接返回nullif ("".equals(shopJson)) {return null;}
String lockKey = LOCK_SHOP_KEY + id;Shop shop = null;int retryCount = 3; // 限制最大重试次数(避免无限递归)
try {// 3. 循环重试获取锁(替代递归,减少栈溢出风险)while (retryCount-- > 0) {boolean isLock = tryLock(lockKey);if (isLock) {// 3.1 二次检查缓存(避免重复查库)shopJson = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isNotBlank(shopJson)) {return JSONUtil.toBean(shopJson, Shop.class);}if ("".equals(shopJson)) {return null;}
// 3.2 查数据库shop = getById(id);// 模拟业务耗时(方便测试锁机制)
// Thread.sleep(100);
// 3.3 处理数据库结果if (shop == null) {// 统一存入""作为空值标识stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);return null;}// 写入正常缓存stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);return shop;}
// 3.4 锁获取失败:休眠后重试Thread.sleep(50);}
// 重试次数耗尽仍未获取锁:返回空(或降级处理)return null;
} catch (InterruptedException e) {throw new RuntimeException(e);} finally {// 释放锁(假设tryLock已使用唯一标识,unlock时校验)unlock(lockKey);}}
public void saveShop2Redis(Long id, Long expiredSeconds) {//1.查询店铺数据Shop shop = getById(id);
//2.封装逻辑过期时间RedisData redisData = new RedisData();redisData.setData(shop);redisData.setExpireTime(LocalDateTime.now().plusSeconds(expiredSeconds));
//3.写入redisstringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id) {String key = CACHE_SHOP_KEY + id;// 1.从redis查询商铺缓存String json = stringRedisTemplate.opsForValue().get(key);// 2.判断是否存在if (StrUtil.isBlank(json)) {// 3.存在,直接返回return null;}// 4.命中,需要先把json反序列化为对象RedisData redisData = JSONUtil.toBean(json, RedisData.class);Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);LocalDateTime expireTime = redisData.getExpireTime();// 5.判断是否过期if (expireTime.isAfter(LocalDateTime.now())) {// 5.1.未过期,直接返回店铺信息return shop;}// 5.2.已过期,需要缓存重建// 6.缓存重建// 6.1.获取互斥锁String lockKey = LOCK_SHOP_KEY + id;boolean isLock = tryLock(lockKey);// 6.2.判断是否获取锁成功if (isLock) {CACHE_REBUILD_EXECUTOR.submit(() -> {
try {//重建缓存this.saveShop2Redis(id, 20L);} catch (Exception e) {throw new RuntimeException(e);} finally {unlock(lockKey);}});}// 6.4.返回过期的商铺信息return shop;}
@Resourceprivate RedisCacheUtil redisCacheUtil;/*** 基于缓存工具类的查询方法(解决缓存穿透)* 使用RedisCacheUtil封装的缓存穿透解决方案,简化代码逻辑*/@Overridepublic Result queryById1(Long id) {// 1. 定义缓存keyString key = CACHE_SHOP_KEY + id;
// 2. 调用工具类查询缓存(自动处理空值和反序列化)Shop shop = redisCacheUtil.getWithPassThrough(key, Shop.class);
// 3. 缓存命中:直接返回结果if (shop != null) {log.debug("缓存中查找成功🏅🏅");return Result.ok(shop);}
// 4. 缓存未命中:查询数据库shop = getById(id);
// 5. 数据库不存在该店铺:缓存空值(解决缓存穿透)if (shop == null) {// 调用工具类存储空值,设置空值过期时间(30分钟)redisCacheUtil.setWithTTL(key, null, RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);return Result.fail("店铺不存在");}
// 6. 数据库存在该店铺:写入缓存(设置正常过期时间)redisCacheUtil.setWithTTL(key, shop, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7. 返回数据库结果return Result.ok(shop);}
}
package com.hmdp.utils;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
/*** 通用Redis缓存工具类(基于StringRedisTemplate)* 支持TTL过期、逻辑过期、缓存穿透、缓存击穿解决方案*/
@Component
public class RedisCacheUtil {
@Resourceprivate StringRedisTemplate stringRedisTemplate;
// 缓存空值标识(统一用空字符串,避免JSON解析问题)private static final String CACHE_NULL_VALUE = "";
/*** 方法1:存储缓存(TTL过期时间)* @param key 缓存key* @param value 任意Java对象(需支持JSON序列化)* @param ttl 过期时间* @param timeUnit 时间单位*/public void setWithTTL(String key, Object value, long ttl, TimeUnit timeUnit) {String jsonStr = serialize(value);stringRedisTemplate.opsForValue().set(key, jsonStr, ttl, timeUnit);}
/*** 方法2:存储缓存(逻辑过期时间,用于解决缓存击穿)* @param key 缓存key* @param value 任意Java对象(需支持JSON序列化)* @param expireSeconds 逻辑过期秒数*/public void setWithLogicalExpire(String key, Object value, long expireSeconds) {// 封装逻辑过期数据RedisData redisData = new RedisData();redisData.setData(value);redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));// 序列化后存入Redis(无物理过期时间)String jsonStr = JSONUtil.toJsonStr(redisData);stringRedisTemplate.opsForValue().set(key, jsonStr);}
/*** 方法3:查询缓存(解决缓存穿透)* @param key 缓存key* @param type 目标对象类型* @return 目标对象(不存在返回null)*/public <T> T getWithPassThrough(String key, Class<T> type) {// 1. 从Redis查询缓存String jsonStr = stringRedisTemplate.opsForValue().get(key);// 2. 缓存未命中:返回null(让调用方查库)if (StrUtil.isBlank(jsonStr)) {return null;}// 3. 缓存命中空值:返回null(解决缓存穿透)if (CACHE_NULL_VALUE.equals(jsonStr)) {return null;}// 4. 缓存命中非空值:反序列化返回return deserialize(jsonStr, type);}
/*** 方法4:查询缓存(逻辑过期解决缓存击穿)* @param key 缓存key* @param type 目标对象类型* @return 目标对象(未命中/已过期返回null,未过期返回数据)*/public <T> T getWithLogicalExpire(String key, Class<T> type) {// 1. 从Redis查询缓存String jsonStr = stringRedisTemplate.opsForValue().get(key);// 2. 缓存未命中:返回nullif (StrUtil.isBlank(jsonStr)) {return null;}// 3. 反序列化为RedisData(包含数据和逻辑过期时间)RedisData redisData = JSONUtil.toBean(jsonStr, RedisData.class);T data = deserialize(JSONUtil.toJsonStr(redisData.getData()), type);LocalDateTime expireTime = redisData.getExpireTime();// 4. 判断是否过期:未过期返回数据,已过期返回null(让调用方重建缓存)if (expireTime.isAfter(LocalDateTime.now())) {return data;}return null;}
/*** 通用序列化:Java对象 → JSON字符串*/private String serialize(Object obj) {if (obj == null) {return CACHE_NULL_VALUE;}return JSONUtil.toJsonStr(obj);}
/*** 通用反序列化:JSON字符串 → 目标Java类型*/private <T> T deserialize(String jsonStr, Class<T> type) {if (StrUtil.isBlank(jsonStr) || CACHE_NULL_VALUE.equals(jsonStr)) {return null;}return JSONUtil.toBean(jsonStr, type);}
/*** 逻辑过期缓存数据封装类* 需与工具类配合使用,存储数据本身和过期时间*/public static class RedisData {private Object data;private LocalDateTime expireTime;
// getter + setterpublic Object getData() {return data;}
public void setData(Object data) {this.data = data;}
public LocalDateTime getExpireTime() {return expireTime;}
public void setExpireTime(LocalDateTime expireTime) {this.expireTime = expireTime;}}
}