欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 新闻 > 国际 > 黑马点评--缓存更新策略及案例实现

黑马点评--缓存更新策略及案例实现

2025/5/30 8:48:28 来源:https://blog.csdn.net/m0_73856804/article/details/148212752  浏览:    关键词:黑马点评--缓存更新策略及案例实现
什么是缓存

缓存就是数据交换的缓冲区(称为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,再返回商铺信息。结束

如下图所示:

image-20250525182757756

代码展示:

先找到对应的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);}

测试:

image-20250525185413019

查看Redis缓存状态:

image-20250525190050583

案例练习:给店铺类型查询业务添加缓存

需求:修改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数据库

image-20250525194944179

成功添加店铺类型查询缓存。

缓存更新策略
问题引入:

我们在前面学习的时候,给商户查询的业务添加了缓存,大大降低了对于数据库的负载压力,但是,同时也造成了数据的一致性问题,此时,数据库与缓存中都有数据,如果对数据库进行修改,则缓存中的数据并不会自动更新,那么客户端查到的缓存中的数据就是旧数据,可能会造成业务问题。

解决方案:

缓存更新策略

内存淘汰超时剔除主动更新
说明不用自己维护,利用reds的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存。给缓存空间添加TTL时间(利用expire命令),到期后自动删除缓存。下次查询时更新缓存。编写业务逻辑,在修改数据库的同时,更新缓存
一致性差(无法确定什么时候淘汰,淘汰哪些,无法控制)一般(一致性的强弱取决于设置的超时时间的长短,时间设置越短,更新频率越高,)好(无法完全保持一致)
维护成本无(redis自动完成)低(只需要在原来设置缓存的逻辑上添加超时时间即可)高(需要编码,逻辑复杂。)
适用业务场景:
  • 低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存

  • 高一致性需求:主动更新,并以超时剔除做为兜底方案,例如店铺详情查询的缓存

主动更新策略详情:

业务实现在开发中常见的有三种形式:

Cache Aside Pattern

由缓存的调用者,在更新数据库的同时更新缓存(这种方式需要自己去编译业务代码,较为复杂,但胜在可以人为控制,可控性较高)

Read/Write Through Pattern

缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。(该服务对外就是个透明的服务,该服务内部同时处理了缓存与数据库,因此可以保证两者的处理同时成功与失败,因此它可以维护两者的一个一致性,对于调用者很轻松,但维护服务较为复杂)

Write Behind Caching Pattern

调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库,保持最终一致。(,增删改查都在缓存中做,新的数据是在缓存中,而数据库中的是旧数据,然后有一个独立的线程,用于观测缓存的变化,有变化则将缓存数据写入到数据库中。独立线程独立执行,不会缓存更新一次,则执行一次,提高了效率,作用:简化调用者的开发,问题:维护较为复杂,难点:需要适时的监控缓存中数据的变更,一致性难以保证(可能缓存执行多次,异步线程执行一次,这次之间,数据库预缓存之间的数据不一致),且此种方案增删改查缓存中的数据,缓存处于内存中,如果电脑宕机,则数据丢失,一致性与可靠性较差)

虽然第一种方案对于调用者较为麻烦,但胜在可控性高,因此,大多数企业开发使用该方案。

而在此方案中,在操作缓存与数据库是有三个问题需要考虑:

  • 删除缓存还是更新缓存?

答:更新缓存:每次更新数据库都更新缓存,无效写操作较多,较为浪费性能

而删除缓存:更新数据库是让缓存失效,查询时在更新缓存,该方案写的性能较低,有效更新更多,

一般会选择删除缓存。

  • 如何保证缓存与数据库的操作同时成功与失败?

即保证两个操作的原子性

在单体系统中,将缓存和数据库操作放在同一个事务中

在分布式系统中,缓存操作与数据库操作可能是两个不同的服务, 利用TCC等分布式事务方案

  • 先操作缓存还是先操作数据库?

线程安全问题。

在多线程并发的情况下,这两个操作之间可能会有多个线程同时交叉执行,这时可能就会有线程安全问题。

方案一:先删除缓存,在操作数据库。正常情况下,如下图所示:线程之间不会造成太大问题,

image-20250525221718040

异常情况下:

异常情况指在线程执行的过程中,另外一个线程也进来并行执行,一个线程删除缓存后,因为更新操作过于复杂,导致在中间过程中,另一个线程进行查询并写入缓存,而此时第一个线程中的更新操作完成,而此时数据库数据与缓存数据不一致,就会造成一致性问题。

且在日常开发中该异常情况较为常见。(因为更新数据库的时间远远超过更新缓存的时间,在更新数据库时,可能在此期间会有线程完成写入缓存的操作。)

如下图所示:

image-20250525223510976

方案二:先操作数据库,再删除缓存。

正常情况下:如图所示:

image-20250525222908816

异常情况下:

在日常开发中,该异常情况较少,因为写入缓存的时间非常迅速,更新数据库的时间要远远大于查询数据库以及写入缓存的时间,因此,此种情况较为少见。

image-20250525223708261

该异常情况发生的概率较小,但如果发生,可以写入缓存时加上一个超时时间,即使缓存中的数据是旧数据,一段时间后自动清除,

因此方案二(先更新数据库,再删除缓存)较为适合,可以避免数据的一致性问题。

总结:

缓冲更新策略的最佳实践方案:

  1. 低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存

  2. 高一致性需求:主动更新,并以超时剔除做为兜底方案,例如店铺详情查询的缓存

    • 读操作:

      • 缓存命中则直接返回

      • 缓存未命中则查询数据库,并写入缓存,设定超时时间

    • 写操作:

      • 先写数据库,在删除缓存

      • 要确保数据库与缓存操作的原子性

案例展示:

给查询商铺的缓存添加超时剔除和主动更新的策略

修改ShopController中的业务逻辑,满足以下的需求:

  • 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间

  • 根据id修改店铺时,先修改数据库,在删除缓存。

代码展示:

设置超时时间:

image-20250526151928933

在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带上。

image-20250526151627945

查看数据库。

image-20250526151659898

查看redis数据库,看缓存是否被删除。

image-20250526151802080

发现已经被删除,再次刷新页面,看是否直接刷新店铺名字。

image-20250526151955634

以及redis缓存也已经写入。

image-20250526152104818

至此,就实现商铺缓存与数据库的双写一致。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词