缓存,大多数人对于这个词想必熟悉又陌生;熟悉是因为缓存用在我们当今电子设备当中的方方面面,陌生是因为我们不熟悉其存在的意义和工作方式;
缓存简介
缓存是用来暂时存储对应的数据资料,作为数据交换的缓冲区,一般读写性能比较高;
在我们平常人的认知当中,一般认为缓存只有一层,但实际上,在当今的互联网大厂中,特别是双11时的上亿流量的冲击中,一般会设置多级缓存来减轻服务器的压力:
- 一般会将一些访问量比较高的数据先保存在浏览器的缓存当中,如果浏览器的缓存中找不到对应的资料;
- 那么会接着到tomcat应用层缓存中去寻找,如果再找不到对应的资料
- 接着到数据库的缓存,->CPU缓存 ->磁盘缓存 里面去查找对应的数据

每个事物都难免存在两面性,redis缓存也是同理,也是一把双刃剑
作用:
- 降低了后端的压力/负载:如果数据可以直接从缓存里面获得的话,那么就不需要去查数据库,所以对应的数据库压力也就没有那么大了
- 可以提高读写的效率,降低访问时间:这是由于redis缓存是存储在系统内存当中的,内存就相当于我们工作时的工作台上的数据/资料,不需要到磁盘【书房】去取资料,会增加系统的读写效率
成本:
- 数据一致性成本:如果数据库的数据发生了修改,应该如何保证数据的同步性和一致性?这是需要考虑的一个重要问题
- 代码的维护成本 :如果引入了缓存进行数据的处理,整个的处理流程的逻辑是相对比较复杂的!
- 运维成本:需要考虑到人力成本的问题,以及硬件的配置的成本

不加数据库的工作流程:
客户端发送的请求直接打到服务器的数据库上,在访问量比较小的时候,数据库是可以正常的工作 的;如果访问量增大,比如碰到双11这种大活动,那么对数据库的压力是非常大的,因此需要考虑加入缓存来缓解数据库的压力。

添加缓存之后:
工作流程
在客户端和数据库之间加上一层缓存,当客户当需要查询对应的数据时:
- 先到缓存当中去寻找对应的数据,如果能找到对应的数据,数据直接返回到客户端
- 如果数据不存在的话,就需要到数据库当中去寻找对应的数据
- 如果能找到数据,需要将当前的数据返回到客户端,并且在缓存里面保存一份数据
- 如果传递过来的是无效索引,那么就有可能引发一些问题,后面会提到涉及的问题以及解决思路

根据id查询商铺的流程

代码开发:
一般controller层中不进行业务代码的开发
@Resource
public IShopService shopService;
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return Result.ok(shopService.queryById(id));
}
业务层代码:
-
严格遵循流程图,重要的是要知道整个架构的数据流转和处理的过程
-
先是需要到缓存当中去查找对应的数据,如果找到了对应的数据,则将数据进行返回即可
-
如果找不到对应的数据,则需要考虑到对应的数据库当中去进行数据的查找
-
找到了对应的数据,将数据进行返回;需要将对应的数据保存到缓存当中,保证下次访问同样的一份数据时,到时候可以直接在缓存当中找到对应的数据/资料
@Resource
private StringRedisTemplate stringRedisTemplate;@Override
public Object 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);
return Result.ok(shop);
}
//缓存中没有,需要访问数据库
Shop shop=getById(id);
//数据库中也没有
if(shop==null){
return Result.fail("店铺不存在");
}
//存在,存入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}
这是我们在缓存代码开发后,打开一个网页的时间花销
从一开始的1.71s【缓存里面没有这个商户的数据】到后面的15ms【缓存里面加入了对应的数据】,提高了数据访问的效率

缓存更新策略
接着是要思考一下缓存的更新问题:
在实际的开发过程中,如果需要对数据库进行修改,那么修改之后的数据库里面的内容是不是和redis里面的内容不一致了,会引发数据不一致的问题
现在我们需要思考如何解决这个问题
redis提供的技术栈:
- 内存淘汰:redis会自行进行自我的维护,不需要开发人员来手动进行维护。在内存不足时,自动淘汰部分数据,下次查询时,自动地更新缓存
- 超时剔除:给缓存数据添加TTL,当时间超过之后,redis会自动地删除对应的缓存,下次查询时,更新对应的缓存数据
- 主动更新:编写对应的业务逻辑,在修改数据库里面的数据的时候,同时修改对应的缓存

这三种方法各有优缺点
需要结合业务开发的特点来进行修改:
低一致性需求:使用内存淘汰机制即可
高一致性需求:主动更新+超时剔除【这是开发人员需要重点解决的问题,需要重点关注这方面的需求】

主动更新时,还有以下三种更新方式
- 一般都是采用第一种方式,在更新数据库的同时更新缓存【主要采用这种方式,相对比较简单】
- 后面两种更新方式都是比较高效的更新方式,可以结合具体的业务需求采用相对应的更新方式

这三种方法各有优缺点
- 一般情况下:删除缓存【相对来说较为高效】
- 需要分析清楚,当前的项目是单体系统还是分布式系统,因为在后续用到锁的时候,会涉及到底层的JVM机制,JVM机制能否正常发挥作用,是业务功能能否实现的一个重要的,需要考虑的点
- 对于第三个问题,一般都是选择 先操作数据库,再删除缓存,后续会接着分析为什么要这么做

第三个问题的深入分析:
1. 采用先删除缓存,再操作数据库的方式
众所周知,在程序运行的过程中,是有很多的线程在同时运行的 ,需要考虑到这多个线程之间数据会不会相互影响,这些都是需要考虑的问题
理想情况下,运行情况,就是线程1运行完成之后,线程2再运行【理想是美好的,现实是残酷的】

可一旦出现下面的情况,就会出现问题【注意,缓存+数据库 都是线程1和2共享的】
- 线程1完成了对应的业务需求了,是不是要删除缓存
- 然后于此同时,线程2来了,想查询缓存,可是缓存已经被删除了,是不是要到数据库当中去查询
- 线程2将查询到的数据写入到缓存中
- 然后此时线程1把数据库给更新了,这就会导致数据不一致的问题!!!!导致出错

2. 先操作数据库,再删除缓存
理想情况下
- 先是操作数据库,修改对应的值
- 然后是删除缓存,只有当后续该数据用到的时候,才会被加入到缓存当中去!
- 接着是线程1查询数据库,获取对应的数据

特殊情况:
这种情况发生的频率相对比较小 ,缓存的读写速度是非常快的 ,但是数据库的写入速度是非常慢的,然后让数据库读的操作正好夹在读写的中间,这种情况发生的记录是相对比较小的,综合来看这种方式是更优的】
- 线程1查询缓存,未命中,需要准备去找数据库了吧?
- 然后线程2这时候执行,先把数据库里面的值修改了
- 然后是把缓存全部删除了【这一部分的缓存数据】
- 然后是最后缓存的写入操作


代码实现
/**
* 更新商铺信息
* @param shop 商铺数据
* @return 无
*/
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
// 写入数据库
return shopService.update(shop);
}
@Override
@Transactional
public Result update(Shop shop) {
Long id=shop.getId();
if(id==null){
return Result.fail("店铺id不能为空");
}
updateById(shop);
stringRedisTemplate.delete(CACHE_SHOP_KEY+id);
return Result.ok();
}

需要面对的三大问题
一、缓存穿透问题
问题来源:客户想要查找一个数据,从客户端发送一个请求到达缓存,发现缓存当中数据不存在,接着是访问到对应的数据库,同样找不到对应的数据,那么就没有办法在缓存里面保存对应的数据 ,缓存永远不会生效 ,所有的请求全部打到数据库,会给数据库带来巨大的压力!
解决方案:
- 缓存一个空对象:
-
- 优点:实现较为简单,维护方便;当客户端请求一份数据的时候,可以考虑在内存里面保存一份空对象,然后适当的加上失效时间,减轻内存的压力
- 缺点:
- 额外的内存消耗,毕竟保存一个空对象到内存里面,难免需要面对内存消耗的问题;
- 还有可能造成数据短期的不一致!->如果刚好添加了这条数据,但是给缓存存了一个空兑现,是有可能造成短期不一致的【可以考虑对空对象加上定时删除的功能】
工作流程:

- 布隆过滤:
现实中常见组合是:"B+树/LSM 树索引 + Bloom Filter"。Bloom Filter 作为"前置拦截",减少去查磁盘/查索引结构的次数,但它本身不是树】
特点:
不会漏掉真实存在的元素(无假阴性 / no false negative)
对标准 Bloom Filter 来说:如果它告诉你"肯定没有",那就真的没有。
会把不存在的元素误判成存在(有假阳性 / false positive)
也就是:它告诉你"可能有",你还得去后端真实数据结构(哈希表、B+树、LSM 等)再确认一次。
-
- 优点:内存占用的相对比较少,没有多余的key
- 缺点:实现较为复杂,存在误判的可能性
工作流程
- 一个请求来到布隆过滤器,如果这个数据存在,那么就放行;如果不存在,那么就会拒绝【一般情况下会正常拒绝】
- 然后是请求到缓存当中去找对应的数据,如果找到了就直接将数据返回
- 如果找不到就到数据库中进行数据的查找工作,找到了数据就给缓存保留一份,将数据返回到客户端

在原来的基础流程上进行改进:【主要采用添加空对象的方式】
- 在缓存未命中的情况下,需要根据id去查找对应的点评,如果店铺不存在的话,需要将对应的空值存入到缓存中
- 如果缓存命中了,需要考虑一下对应的对象是不是空值,如果是空值,则需要考虑返回错误信息,直接结束

代码实现
特别注释:
分情况解释一下
String key=CACHE_SHOP_KEY+id;
String shopJson=stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(shopJson)){
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
if(shopJson!=null){
return Result.fail("店铺信息不存在");
}
1. 情况 A:Redis 里 没有这个 key
get(key)返回:null- 含义:缓存未命中(还没查过/没写过)
2. 情况 B:Redis 里 有这个 key,但你存的是空串(缓存空值)
get(key)返回:""(空字符串)或" "(空白)- 含义:命中空值缓存(之前查库发现不存在,所以写了空串防穿透)
3. 情况 C:Redis 里 有正常 JSON
get(key)返回:"{...}"(非空白)- 含义:命中正常缓存
那两段判断分别识别哪种情况?
1)StrUtil.isNotBlank(shopJson)
它要求:不是 null、不是 ""、不是全空格
所以只会命中 情况 C(正常 JSON)
2)if (shopJson != null)
这句会命中:情况 B + 情况 C(只要不是 null 都算)
但注意:情况 C 已经在上面 return 掉了,所以走到这里时只剩下:
- shopJson != null 但 isNotBlank 为 false
⇒ 只能是 情况 B(空串/空白)
⇒ 说明你命中了"空值缓存"
⇒ 直接返回"不存在",避免去查数据库(防穿透)
用一句更直观的话解释
shopJson == null:Redis 根本没这个 key(没缓存过)⇒ 要查数据库shopJson != null但内容是空:Redis 有这个 key,但值是空(你自己缓存的"不存在标记")⇒ 直接返回不存在,不查数据库shopJson是正常 JSON:⇒ 直接返回数据
代码实现
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Object 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);
return Result.ok(shop);
}
if(shopJson!=null){
return Result.fail("店铺信息不存在");
}
//缓存中没有,需要访问数据库
Shop shop=getById(id);
//数据库中也没有
if(shop==null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在");
}
//存在,存入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
更多的解决方案:
- 前面提到的缓存null值+布隆过滤
- 增加id的复杂度【不让恶意用户轻易猜出对应的id值】
- 做好数据的基础格式校验【对客户传递过来的数据进行校验操作】
- 加强用户权限校验【对于权限不足的用户,不让其进行相对应的操作】
- 进行限流操作,适当的降低数据库的压力

二、缓存雪崩
缓存雪崩值得是在同一时间段大量的缓存key同时失效或者redis宕机,导致大量的请求到达数据库,带来巨大压力
当然redis宕机时比大量缓存key同时失效要更加严重的
解决方案:
- 给不同的key加上不同失效时间
- 利用redis集群提高服务的可用性,避免因为一台redis宕机了,导致整个项目罢工的问题
- 给缓存业务添加降级限流策略,降低redis/数据库的压力
- 添加多级缓存,相当于多级分流的机制,降低服务器的压力

三、缓存击穿问题
缓存击穿问题也被称作是热点Key问题,就是一个被高并发访问 并且缓存重建业务较繁杂的key失效了,很多的请求绕过了redis,直接打到数据库,给数据库带来巨大的压力!

解决方式:
(一) 采用互斥锁的方式来进行解决:
-
- 线程1先去查询对应的缓存来获取数据,发现缓存未命中
- 需要考虑重建缓存,那么在重建缓存之前需要加上一把互斥锁,来保证这个过程中不会被别的线程干扰
- 然后是线程2来访问对应的数据,发现也没有找到,然后尝试着去获取对应的互斥锁
- 获取互斥锁失败 ,那么线程2就会休眠,一段时间后再来尝试
- 线程1继续工作,将获取到的数据存入到缓存当中去,然后才是打开互斥锁
- 线程2到缓存中查找对应的数据,缓存命中
(二) 逻辑过期来解决缓存击穿问题
-
- 线程1来查询缓存数据,发现数据已经过期了
- 获取互斥锁成功,给这个数据加上对应的锁
- 然后注意:线程1会开启新的线程2,然后再在线程2上完成数据的查询,写入缓存等操作
- 这时候线程1还是接着往前走的,会先 返回过期数据
- 这时候线程3来了,查询缓存数据,发现数据已经过期了,获取互斥锁失败,
- 这时候线程3不等了,直接 返回过期数据
- 最后线程2处理完毕之后,写入缓存,重置逻辑过期时间,互斥锁打开
- 线程4命中缓存,并且没有过期,返回数据

实现方式【互斥锁】:
由于需要考虑锁没有成功获取到的情况,像之前的锁是无法区分这些的
因此我们需要考虑自定义一把锁,来解决对应的问题,就需要用到redis里面的setnx关键字来自定义一把锁,但是注意要加上一个有效期,保证在服务出现故障的时候,锁也会自动的进行释放
参考案例如下:

代码开发

@Override
public Object queryById(Long id) {
//缓存穿透解决方式
//Shop shop=queryWithPassThrough(id);
//缓存击穿解决方式
Shop shop=queryWitjMutex(id);
if(shop==null){
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
public Shop queryWitjMutex(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);
return shop;
}
if(shopJson!=null){ //Redis 有这个 key,但值是空(你自己缓存的"不存在标记")⇒ 直接返回不存在,不查数据库
return null;
}
String lockKey="lock:shop:"+id;
Shop shop= null;
try {
boolean isLock=tryLock(lockKey);
if(!isLock){//获取锁失败
Thread.sleep(50);
return queryWitjMutex(id);
}
//缓存中没有,需要访问数据库
shop = getById(id);
//数据库中也没有
if(shop==null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//存在,存入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
return shop;
}
代码开发【逻辑过期】

public Shop queryWithLogicalExpire(Long id){
String key=CACHE_SHOP_KEY+id;
String shopJson=stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(shopJson)){
return null;
}
// 4. 命中,需要先把json反序列化为对象
RedisData redisData=JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data=(JSONObject) redisData.getData();
Shop shop=JSONUtil.toBean(data, Shop.class); //获取商铺对象
LocalDateTime expireTime=redisData.getExpireTime();//过期时间
// 5. 判断是否过期
// 5.1. 未过期,直接返回店铺信息
if (expireTime != null && expireTime.isAfter(LocalDateTime.now())) {
return shop;
}
// 5.2. 已过期,需要缓存重建[直接执行下面的代码]
// 6. 缓存重建
// 6.1. 获取互斥锁
String lockKey=LOCK_SHOP_KEY+id;
boolean isLock=tryLock(lockKey);
// 6.2. 判断是否获取锁成功
if(isLock){
// 6.3. 成功,开启独立线程,实现缓存重建
// 直接使用线程池来解决
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
this.saveShop2Redis(id,30L);
}
catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
// 6.4. 返回过期的商铺信息
return shop;
}
缓存工具封装

@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private CacheClient cacheClient;
private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(10);
@Override
public Result queryById(Long id) {
//缓存穿透解决方式
// Shop shop= cacheClient.queryWithPassThrough(
// CACHE_SHOP_KEY,id, Shop.class,this::getById,CACHE_SHOP_TTL,TimeUnit.MINUTES
// );
//缓存击穿解决方式
//Shop shop=queryWitjMutex(id);
//缓存击穿解决方式 逻辑过期
Shop shop=cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY,id, Shop.class,this::getById,CACHE_SHOP_TTL,TimeUnit.MINUTES);
if(shop==null){
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
package com.hmdp.utils;
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.entity.Shop;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import static com.hmdp.utils.RedisConstants.*;
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate){
this.stringRedisTemplate=stringRedisTemplate;
}
public void set(String key, Object value, Long time, TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
RedisData redisData=new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
}
//缓存穿透
public <R,ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
String key=keyPrefix+id;
String json=stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(json)){
return JSONUtil.toBean(json,type);
}
if(json!=null){
return null;
}
R r=dbFallback.apply(id);
if(r==null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
this.set(key,r,time,unit);
return r;
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(10);
public <R,ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
String key=keyPrefix+id;
String json=stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(json)){
return null;
}
// 4. 命中,需要先把json反序列化为对象
RedisData redisData=JSONUtil.toBean(json, RedisData.class);
JSONObject data=(JSONObject) redisData.getData();
R r=JSONUtil.toBean(data,type); //获取商铺对象
LocalDateTime expireTime=redisData.getExpireTime();//过期时间
// 5. 判断是否过期
// 5.1. 未过期,直接返回店铺信息
if (expireTime != null && expireTime.isAfter(LocalDateTime.now())) {
return r;
}
// 5.2. 已过期,需要缓存重建[直接执行下面的代码]
// 6. 缓存重建
// 6.1. 获取互斥锁
String lockKey=LOCK_SHOP_KEY+id;
boolean isLock=tryLock(lockKey);
// 6.2. 判断是否获取锁成功
if(isLock){
// 6.3. 成功,开启独立线程,实现缓存重建
// 直接使用线程池来解决
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
R r1=dbFallback.apply(id);
this.setWithLogicalExpire(key,r1,time,unit);
}
catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
// 6.4. 返回过期的商铺信息
return r;
}
//互斥锁:上锁+解锁
private boolean tryLock(String key){
Boolean flag=stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10,TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key){
stringRedisTemplate.delete(key);
}
}