一、什么是缓存
缓存(Cache)是数据交换的缓冲区,是存储数据的临时地方,一般读写性能较高。
(一)缓存的作用
- 降低后端负载:减少对数据库等后端存储的直接访问压力。
- 提高读写效率,降低响应时间:利用缓存的高性能,快速响应数据请求。
(二)缓存的成本
- 数据一致性成本:缓存与后端数据可能存在不一致,维护一致性需要额外处理。
- 代码维护成本:引入缓存后,代码逻辑会更复杂,增加维护难度。
- 运维成本:缓存系统的部署、监控、扩容等都需要投入运维资源。
二、缓存更新策略
策略 | 说明 | 一致性 | 维护成本 |
---|---|---|---|
内存淘汰 | 利用Redis的内存淘汰机制,内存不足时自动淘汰部分数据,下次查询时更新缓存 | 差 | 无 |
超时剔除 | 给缓存数据添加TTL时间,到期后自动删除缓存,下次查询时更新缓存 | 一般 | 低 |
主动更新 | 编写业务逻辑,在修改数据库的同时更新缓存 | 好 | 高 |
业务场景
- 低一致性需求:使用内存淘汰机制,例如店铺类型的查询缓存。
- 高一致性需求:主动更新,并以超时剔除作为兜底方案,例如店铺详情查询的缓存。
代码实现:主动更新策略
@Override
@Transactional
public Result updateShop(Shop shop) {Long id = shop.getId();if (id == null) {return Result.fail("商铺ID不能为空");}// 1.先更新数据库updateById(shop);// 2.再删除缓存(保证数据一致性)stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());return Result.ok();
}
三、缓存更新策略的最佳实践方案
(一)低一致性需求
使用Redis自带的内存淘汰机制。
(二)高一致性需求
主动更新,并以超时剔除作为兜底方案。
- 读操作:
- 缓存命中则直接返回。
- 缓存未命中则查询数据库,并写入缓存,设定超时时间。
- 写操作:
- 先写数据库,然后再删除缓存。
- 要确保数据库与缓存操作的原子性。
代码实现:基础缓存读写逻辑
private Result queryShopById1(Long id) {// 1.查询RedisString cacheShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);// 2.缓存命中直接返回if (StrUtil.isNotBlank(cacheShop)) {Shop shop = JSONUtil.toBean(cacheShop, Shop.class);return Result.ok(shop);}// 3.缓存未命中查询数据库Shop shop = getById(id);if (shop == null) {return Result.fail("商铺不存在!");}// 4.数据库查询结果写入Redis,设置超时时间stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);return Result.ok(shop);
}
四、缓存问题及解决方案
(一)缓存穿透
问题定义
客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
问题场景
- 恶意攻击:黑客故意构造大量不存在的ID(如负数、超范围的随机数)发起请求,试图耗尽数据库资源
- 业务误操作:前端表单未做校验,用户输入了无效ID导致大量无效查询
- 数据已删除:商品已下架或用户已注销,但仍有请求访问这些已不存在的数据
- 爬虫抓取:爬虫程序遍历不存在的URL路径,导致大量无效查询
解决方案
1. 缓存空对象
原理:当数据库查询结果为空时,仍然将这个空结果缓存起来(可以是空字符串、null或特定标识),并设置较短的过期时间,避免同一无效请求反复穿透到数据库。
实现代码:
private Result queryShopById2(Long id) {// 1.查询RedisString cacheShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);// 2.缓存命中(包括空值)if (StrUtil.isNotBlank(cacheShop)) {// 2.1 空值判断if (cacheShop.isEmpty()) {return Result.fail("商铺不存在!");}// 2.2 正常数据返回Shop shop = JSONUtil.toBean(cacheShop, Shop.class);return Result.ok(shop);}// 3.数据库查询Shop shop = getById(id);if (shop == null) {// 3.1 数据库不存在则缓存空值(短期有效)stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);return Result.fail("商铺不存在!");}// 4.正常数据写入缓存stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);return Result.ok(shop);
}
优缺点:
- 优点:实现简单,维护方便,能有效拦截重复的无效请求
- 缺点:
- 占用额外内存空间存储空值
- 可能造成短期数据不一致(如刚删除的数据又被创建)
- 对于海量不同的无效ID,仍可能消耗大量缓存空间
2. 布隆过滤器
原理:在缓存之前增加一层布隆过滤器,预先将数据库中所有存在的Key存入布隆过滤器。当请求进来时,先通过布隆过滤器判断Key是否可能存在:
- 若不存在,则直接返回,无需访问缓存和数据库
- 若可能存在,再走正常的缓存+数据库查询流程
实现思路:
- 系统初始化时,将数据库中所有有效ID加载到布隆过滤器
- 接收请求时,先通过布隆过滤器验证ID有效性
- 对布隆过滤器判断不存在的ID,直接返回错误
优缺点:
- 优点:内存占用少(相比缓存空对象),处理海量无效ID效率高
- 缺点:
- 实现复杂,需要维护布隆过滤器
- 存在误判可能(不能准确判断元素是否存在)
- 需要定期更新布隆过滤器数据,以反映数据库变化
(二)缓存雪崩
问题定义
在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力,甚至引起数据库宕机。
问题场景
- 集中过期:系统上线时为一批热点数据设置了相同的过期时间(如24小时),导致24小时后这批数据同时失效
- Redis宕机:Redis服务器因硬件故障、网络问题或内存溢出等原因突然宕机,整个缓存层失效
- 批量更新:电商平台在大促前批量更新商品信息,导致大量缓存被删除
- 缓存穿透引发:大量穿透请求导致数据库压力过大,进而影响缓存服务的正常运行
解决方案
1. 过期时间随机化
原理:为不同的缓存Key设置基础过期时间的同时,增加一个随机偏移量(如±10%),避免大量Key在同一时间点同时过期。
实现代码:
// 设置过期时间时增加随机值
int baseTtl = 30; // 基础30分钟
int random = new Random().nextInt(10); // 0-9分钟随机
stringRedisTemplate.opsForValue().set(key, value, baseTtl + random, TimeUnit.MINUTES
);
2. Redis集群
原理:通过部署Redis集群(主从+哨兵或Redis Cluster)提高缓存服务的可用性,避免单点故障导致整个缓存层失效。
关键措施:
- 主从复制:实现数据备份和读写分离
- 哨兵机制:自动监控和故障转移
- 集群分片:分散数据存储,提高整体容量和性能
3. 降级限流
原理:当缓存服务出现异常时,通过降级策略限制对数据库的请求流量,保护数据库不被压垮。
实现方式:
- 使用熔断器(如Sentinel、Hystrix)监控缓存服务状态
- 当缓存服务异常时,返回默认数据或提示信息,而非直接查询数据库
- 对查询数据库的请求设置限流阈值,超出阈值的请求直接拒绝
4. 多级缓存
原理:构建多级缓存架构(如本地缓存+分布式缓存),即使分布式缓存失效,本地缓存仍能提供部分缓冲能力。
常见架构:
- 本地缓存(Caffeine、Guava):存在于应用进程内,速度最快
- 分布式缓存(Redis):供多个应用实例共享
- 数据库缓存:数据库自身的缓存机制
(三)缓存击穿
问题定义
也叫热点Key问题,指一个被高并发访问的热点数据的缓存突然失效,无数请求会在瞬间同时到达数据库,给数据库带来巨大冲击。
问题场景
- 热门商品:电商平台的爆款商品缓存过期,恰逢促销活动期间,大量用户同时访问
- 热点事件:突发新闻事件的相关数据缓存过期,引发大量用户同时查询
- 排行榜:热门游戏排行榜数据缓存过期,大量玩家同时刷新页面
- 秒杀活动:秒杀商品的缓存过期,大量用户在同一时间点抢购
解决方案
1. 互斥锁
原理:当缓存失效时,不是让所有请求都去查询数据库,而是通过锁机制保证只有一个请求能去查询数据库并重建缓存,其他请求则等待缓存重建完成后再从缓存获取数据。
实现代码:
private Result queryShopById3(Long id) {// 1.查询RedisString cacheShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (StrUtil.isNotBlank(cacheShop)) {if (cacheShop.isEmpty()) {return Result.fail("商铺不存在!");}return Result.ok(JSONUtil.toBean(cacheShop, Shop.class));}Shop shop = null;try {// 2.获取互斥锁boolean isLock = tryLock(LOCK_SHOP_KEY + id);if (!isLock) {// 2.1 获取锁失败则重试(短暂等待后再次查询)Thread.sleep(50);return queryShopById3(id);}// 3.获取锁成功,查询数据库shop = getById(id);// 模拟缓存重建耗时操作Thread.sleep(200);if (shop == null) {stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);return Result.fail("商铺不存在!");}// 4.写入缓存stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);} catch (InterruptedException e) {throw new RuntimeException(e);} finally {// 5.释放锁unlock(LOCK_SHOP_KEY + id);}return Result.ok(shop);
}// 获取锁(使用Redis的setIfAbsent实现)
private boolean tryLock(String key) {return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 100, TimeUnit.SECONDS));
}// 释放锁
private void unlock(String key) {stringRedisTemplate.delete(key);
}
优缺点:
- 优点:
- 保证数据一致性(每次都是最新数据)
- 无额外内存消耗
- 实现相对简单
- 缺点:
- 锁竞争会导致请求等待,影响接口响应性能
- 可能存在死锁风险(需设置合理的锁过期时间)
- 高并发下,第一个获取锁的请求可能成为性能瓶颈
2. 逻辑过期
原理:给缓存数据设置一个逻辑过期时间(而非Redis的实际过期时间),缓存永不过期。当查询时发现数据已过逻辑过期时间,不直接删除缓存,而是返回旧数据并异步更新缓存,保证后续请求能尽快获取到新数据。
实现代码:
// 线程池(用于异步重建缓存)
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);// 逻辑过期数据封装类
@Data
public class RedisData {// 逻辑过期时间private LocalDateTime expireTime;// 实际存储的数据(如Shop对象)private Object data;
}private Result queryShopById4(Long id) {// 1.查询RedisString cacheShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (StrUtil.isBlank(cacheShop)) {return Result.fail("商铺不存在!");}if (cacheShop.isEmpty()) {return Result.fail("商铺不存在!");}// 2.解析缓存数据(带逻辑过期时间)RedisData redisData = JSONUtil.toBean(cacheShop, RedisData.class);JSONObject data = (JSONObject) redisData.getData();Shop shop = JSONUtil.toBean(data, Shop.class);LocalDateTime expireTime = redisData.getExpireTime();// 3.判断是否逻辑过期if (expireTime.isAfter(LocalDateTime.now())) {// 3.1 未过期直接返回return Result.ok(shop);}// 3.2 已过期,尝试获取锁重建缓存boolean isLock = tryLock(LOCK_SHOP_KEY + id);if (isLock) {// 3.2.1 获取锁成功,异步重建缓存(不阻塞当前请求)CACHE_REBUILD_EXECUTOR.submit(() -> {try {saveShop2Redis(id, CACHE_SHOP_TTL);} catch (InterruptedException e) {throw new RuntimeException(e);} finally {unlock(LOCK_SHOP_KEY + id);}});}// 3.2.2 无论是否获取锁,都返回旧数据(保证用户体验)return Result.ok(shop);
}// 封装逻辑过期数据并写入Redis
public void saveShop2Redis(long id, Long seconds) throws InterruptedException {Shop shop = getById(id);// 模拟缓存重建延时Thread.sleep(200);RedisData redisData = new RedisData();redisData.setData(shop);redisData.setExpireTime(LocalDateTime.now().plusSeconds(seconds));stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
优缺点:
- 优点:
- 无请求阻塞,性能好(始终返回数据,不等待)
- 避免大量请求同时冲击数据库
- 缺点:
- 数据可能短期不一致(返回旧数据)
- 需要额外存储过期时间,占用更多内存
- 实现相对复杂,需要处理异步更新逻辑
五、缓存常量定义
public class RedisConstants {// 商铺缓存键前缀public static final String CACHE_SHOP_KEY = "cache:shop:";// 商铺缓存默认过期时间(30分钟)public static final Long CACHE_SHOP_TTL = 30L;// 空值缓存过期时间(2分钟,用于解决缓存穿透)public static final Long CACHE_NULL_TTL = 2L;// 商铺缓存锁键前缀(用于解决缓存击穿)public static final String LOCK_SHOP_KEY = "lock:shop:";// 锁过期时间(10秒)public static final Long LOCK_SHOP_TTL = 10L;
}
六、缓存方案对比
方案 | 解决问题 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
基础缓存 | 无 | 实现简单 | 存在缓存穿透、击穿问题 | 低并发、非核心业务 |
缓存空值 | 缓存穿透 | 防止数据库压力过大 | 占用额外缓存空间 | 无效请求相对集中的场景 |
布隆过滤 | 缓存穿透 | 内存占用少 | 有误判、实现复杂 | 海量无效ID场景 |
互斥锁 | 缓存击穿 | 数据一致性好 | 可能阻塞请求 | 一致性要求高的热点数据 |
逻辑过期 | 缓存击穿 | 无阻塞,性能好 | 可能返回旧数据 | 高并发、一致性要求不高 |
随机TTL | 缓存雪崩 | 实现简单 | 只能解决集中过期问题 | 所有需要设置过期的场景 |
多级缓存 | 缓存雪崩 | 提高可用性和性能 | 实现复杂、维护成本高 | 核心业务、高可用要求 |
参考来源
本文内容基于黑马程序员《Redis入门到实战教程》相关章节学习整理,部分代码示例与知识点解析参考了该课程的讲解。