什么是缓存
缓存就是数据交换的缓冲区(称为Cache),是存储数据的临时地方,一般读写性能较高。
比如在计算机中分为CPU,内存以及磁盘,CPU的运算能力非常强,已经远远超过了内存与磁盘的读写能力,但是CPU需要先从内存或者磁盘读到数据,放在寄存器里才可以运算,因为数据读写德恩能够力远远低于CPU的运算能力,所以计算机性能受到了限制,为了解决此问题,就在CPU内部添加了缓存,CPU将经常需要读写的一些数据放在缓存中,就不需要从内存或者磁盘中去拿。这样就可以从分的发挥CPU的运算能力。衡量一个CPU是否强大的一项标准就是CPU的缓存的大小,缓存越大,能缓存的数据越多,处理的性能越高。
再比如在web应用开发的过程中,也离不开缓存,作为一个外部应用,用户是通过浏览器向服务端发起请求,浏览器首先可以建立缓存,缓存里放着页面的一些静态资源,比如页面的CSS,JS文件以及图片,将其缓存在本地,这样就无须每次访问都要去加载这些数据,可以降低网络的延迟,提高页面的响应速度,在浏览器缓存未命中的一些数据就会到我们的web容器Tomcat(自己编写的Java应用)在Java应用中,还可以添加应用层的缓存(简单来说,创建一个map集合,将从数据库中的查到的数据放在map集合中,以后要是在查询无需在查询数据库,直接从map集合中拿,减少了数据库查询(访问磁盘),提高了性能。)一般是使用redis做应用层缓存(redis的读写能力很强,速度快,延迟低)当缓存未命中的情况下,请求依然会落到我们的数据库,数据库中也可以创建缓存,比如我们数据库中的索引数据,就可以放在缓存中,当我们根据这些索引进行查询时,就可以在缓存中快速检索得到结果,不用去读写磁盘。最后一些表关联,或者复杂算法,依旧会访问CPU及磁盘,CPU可以建立多级缓存,磁盘也可以去建立读写缓存。
在web应用中的每个阶段都可以去添加缓存。
但是缓存在带来便利的时候,也会需要代价。
基于web应用开发来叙述缓存的便利以及代价
缓存作用:
-
降低后端负载
-
提高读写效率,降低响应时间,以便应对更高的并发请求,
缓存代价:
-
数据一致性问题:我们将数据缓存一份到内存中,如果数据库中的数据发生改变,缓存中的数据是旧数据,就会造成读旧数据的问题,就会造成数据不一致。
-
代码维护问题:在解决一致性问题的过程中就需要一些非常复杂的业务代码,而且在缓存一致性处理过程中,还可能出现缓存穿透问题,为了解决这些问题,代码的复杂度就会提高,开发与维护成本就会很高
-
运维问题:为了解决缓存雪崩的问题以及保证缓存的高可用,缓存往往会需要搭建成集群模式,而缓存集群的这种部署,维护,以及硬件的一些成本。
添加Redis缓存
给API接口添加缓存,提高查询性能。在首页查询美食页面,在选定一家商店,其中的数据看源码得知,直接从数据库中查询,现在为商户添加缓存,提高性能。
缓存作用模型: 在没有缓存之前,客户端直接访问数据库,而数据库在磁盘中,访问一次耗费性能很大,于是添加缓存(以Redis为例),将常用的数据传入到redis中,这样客户端发起请求,如果命中redis,则直接返回数据,若未命中,再去访问数据库,并将数据返回给客户端,并将查到的数据写入缓存中。
由缓存作用模型得出根据ID查询商铺缓存的流程:
首先,提交商铺ID,服务端访问Redis查询商铺缓存,判断缓存是否命中,若命中,返回商铺信息,若未命中,根据ID查询数据库,判断商铺是否存在,若不存在,返回404状态码,结束,若是存在,将商铺信息写入Redis,再返回商铺信息。结束
如下图所示:
代码展示:
先找到对应的Controller层,先写入方法名,再在Service层编译业务代码,
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {return shopService.queryById(id);
}
public interface IShopService extends IService<Shop> {Result queryById(Long id);}
业务代码:
ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result queryById(Long id) {//1.从redis中查询商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+id);// 2.判断是否存在if (StrUtil.isNotBlank(shopJson)){// 3.存在,直接返回Shop shop = JSONUtil.toBean(shopJson, Shop.class);return Result.ok(shop);}// 4.不存在,根据id查询数据库Shop shop = getById(id);if (shop == null) {// 5.不存在,返回错误return Result.fail("店铺不存在");}// 6.存在,写入redisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop));// 7.返回return Result.ok(shop);}
测试:
查看Redis缓存状态:
案例练习:给店铺类型查询业务添加缓存
需求:修改ShopTypeController的queryTypeList方法,添加查询缓存。
业务流程:首先查看要添加的数据类型,是在首页显示的10个店铺类型,在源码中,直接使用MyBatis-plus查询数据库所得,数据类型为list集合,那么我们要使用的Redis数据类型可以使用List类型,但我看到源码中的经过排序
List<ShopType> shops = query().orderByAsc("sort").list();
我决定使用SortedSet类型,业务流程还是与上面添加客户查询缓存相似。
依旧在Controller层新建Service层方法,再在Service编写业务代码:
@GetMapping("list")public Result queryTypeList() {List<ShopType> typeList = typeService.queryAll();return Result.ok(typeList);}
public interface IShopTypeService extends IService<ShopType> {List<ShopType> queryAll();}
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic List<ShopType> queryAll() {//从redis查询商铺缓存Set<String> shopSet = stringRedisTemplate.opsForZSet().range(CACHE_SHOP_TYPE_KEY, 0, 10);if (shopSet != null && !shopSet.isEmpty()){// 存在,直接返回return shopSet.stream().map(shop -> {return JSONUtil.toBean(shop, ShopType.class);}).toList();}// 不存在,查询数据库List<ShopType> shops = query().orderByAsc("sort").list();if (shops.isEmpty()) {// 数据库不存在,返回错误return null;}// 存在,写入redisshops.forEach(shopType -> {stringRedisTemplate.opsForZSet().add(CACHE_SHOP_TYPE_KEY,JSONUtil.toJsonStr(shopType),shopType.getSort());});// 返回return shops;}}
测试:
刷新成功显示,检查redis数据库
成功添加店铺类型查询缓存。
缓存更新策略
问题引入:
我们在前面学习的时候,给商户查询的业务添加了缓存,大大降低了对于数据库的负载压力,但是,同时也造成了数据的一致性问题,此时,数据库与缓存中都有数据,如果对数据库进行修改,则缓存中的数据并不会自动更新,那么客户端查到的缓存中的数据就是旧数据,可能会造成业务问题。
解决方案:
缓存更新策略
内存淘汰 | 超时剔除 | 主动更新 | |
---|---|---|---|
说明 | 不用自己维护,利用reds的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存。 | 给缓存空间添加TTL时间(利用expire命令),到期后自动删除缓存。下次查询时更新缓存。 | 编写业务逻辑,在修改数据库的同时,更新缓存 |
一致性 | 差(无法确定什么时候淘汰,淘汰哪些,无法控制) | 一般(一致性的强弱取决于设置的超时时间的长短,时间设置越短,更新频率越高,) | 好(无法完全保持一致) |
维护成本 | 无(redis自动完成) | 低(只需要在原来设置缓存的逻辑上添加超时时间即可) | 高(需要编码,逻辑复杂。) |
适用业务场景:
-
低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
-
高一致性需求:主动更新,并以超时剔除做为兜底方案,例如店铺详情查询的缓存
主动更新策略详情:
业务实现在开发中常见的有三种形式:
Cache Aside Pattern
由缓存的调用者,在更新数据库的同时更新缓存(这种方式需要自己去编译业务代码,较为复杂,但胜在可以人为控制,可控性较高)
Read/Write Through Pattern
缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。(该服务对外就是个透明的服务,该服务内部同时处理了缓存与数据库,因此可以保证两者的处理同时成功与失败,因此它可以维护两者的一个一致性,对于调用者很轻松,但维护服务较为复杂)
Write Behind Caching Pattern
调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库,保持最终一致。(,增删改查都在缓存中做,新的数据是在缓存中,而数据库中的是旧数据,然后有一个独立的线程,用于观测缓存的变化,有变化则将缓存数据写入到数据库中。独立线程独立执行,不会缓存更新一次,则执行一次,提高了效率,作用:简化调用者的开发,问题:维护较为复杂,难点:需要适时的监控缓存中数据的变更,一致性难以保证(可能缓存执行多次,异步线程执行一次,这次之间,数据库预缓存之间的数据不一致),且此种方案增删改查缓存中的数据,缓存处于内存中,如果电脑宕机,则数据丢失,一致性与可靠性较差)
虽然第一种方案对于调用者较为麻烦,但胜在可控性高,因此,大多数企业开发使用该方案。
而在此方案中,在操作缓存与数据库是有三个问题需要考虑:
-
删除缓存还是更新缓存?
答:更新缓存:每次更新数据库都更新缓存,无效写操作较多,较为浪费性能
而删除缓存:更新数据库是让缓存失效,查询时在更新缓存,该方案写的性能较低,有效更新更多,
一般会选择删除缓存。
-
如何保证缓存与数据库的操作同时成功与失败?
即保证两个操作的原子性。
在单体系统中,将缓存和数据库操作放在同一个事务中。
在分布式系统中,缓存操作与数据库操作可能是两个不同的服务, 利用TCC等分布式事务方案
-
先操作缓存还是先操作数据库?
即线程安全问题。
在多线程并发的情况下,这两个操作之间可能会有多个线程同时交叉执行,这时可能就会有线程安全问题。
方案一:先删除缓存,在操作数据库。正常情况下,如下图所示:线程之间不会造成太大问题,
异常情况下:
异常情况指在线程执行的过程中,另外一个线程也进来并行执行,一个线程删除缓存后,因为更新操作过于复杂,导致在中间过程中,另一个线程进行查询并写入缓存,而此时第一个线程中的更新操作完成,而此时数据库数据与缓存数据不一致,就会造成一致性问题。
且在日常开发中该异常情况较为常见。(因为更新数据库的时间远远超过更新缓存的时间,在更新数据库时,可能在此期间会有线程完成写入缓存的操作。)
如下图所示:
方案二:先操作数据库,再删除缓存。
正常情况下:如图所示:
异常情况下:
在日常开发中,该异常情况较少,因为写入缓存的时间非常迅速,更新数据库的时间要远远大于查询数据库以及写入缓存的时间,因此,此种情况较为少见。
该异常情况发生的概率较小,但如果发生,可以写入缓存时加上一个超时时间,即使缓存中的数据是旧数据,一段时间后自动清除,
因此方案二(先更新数据库,再删除缓存)较为适合,可以避免数据的一致性问题。
总结:
缓冲更新策略的最佳实践方案:
-
低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
-
高一致性需求:主动更新,并以超时剔除做为兜底方案,例如店铺详情查询的缓存
-
读操作:
-
缓存命中则直接返回
-
缓存未命中则查询数据库,并写入缓存,设定超时时间
-
-
写操作:
-
先写数据库,在删除缓存
-
要确保数据库与缓存操作的原子性
-
-
案例展示:
给查询商铺的缓存添加超时剔除和主动更新的策略
修改ShopController中的业务逻辑,满足以下的需求:
-
根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
-
根据id修改店铺时,先修改数据库,在删除缓存。
代码展示:
设置超时时间:
在UserController中源码展示
@PutMappingpublic Result updateShop(@RequestBody Shop shop) {// 写入数据库return shopService.update(shop);}
仍然是将业务代码放在Service层编译:
@Override// 事务保证原子性@Transactionalpublic Result update(Shop shop) {Long id = shop.getId();if (id == null) {// 4.不存在,返回错误return Result.fail("店铺ID不能为空");}// 1.更新数据库updateById(shop);// 2.删除缓存stringRedisTemplate.delete(CACHE_SHOP_KEY+id);return Result.ok();}
因为更新的动作一般是有管理端去做,而在浏览器中操作的是面向用户端,并不是后台管理,,只能借助接口测试工具,在这里使用的是apifox。
值得注意的是,编译接口时记得将token带上。
查看数据库。
查看redis数据库,看缓存是否被删除。
发现已经被删除,再次刷新页面,看是否直接刷新店铺名字。
以及redis缓存也已经写入。
至此,就实现商铺缓存与数据库的双写一致。