网站建设与维护 许宝良 课件,免费图片编辑网站,企业网站的作用和目的,wordpress 商业插件文章目录 前言一、本地缓存和分布式缓存1.本地缓存2.分布式缓存 二、项目实战1.配置Redis2.整合业务代码2.1 缓存击穿2.2 缓存雪崩2.3 缓存穿透2.4 业务代码1.0版2.5 分布式锁1.0版2.6 分布式锁2.0版2.7 Spring Cache及缓存一致性问题2.7.1 Spring Cache2.7.2 缓存一致性问题2.… 文章目录 前言一、本地缓存和分布式缓存1.本地缓存2.分布式缓存 二、项目实战1.配置Redis2.整合业务代码2.1 缓存击穿2.2 缓存雪崩2.3 缓存穿透2.4 业务代码1.0版2.5 分布式锁1.0版2.6 分布式锁2.0版2.7 Spring Cache及缓存一致性问题2.7.1 Spring Cache2.7.2 缓存一致性问题2.7.3 Spring Cache的弊端 前言 本篇重点介绍谷粒商城首页整合缓存技术从本地缓存Map到分布式缓存Redis描述常见的缓存三大问题缓存穿透缓存雪崩缓存击穿及解决方案并且在解决的过程中引用成熟的Redisson方案。最后到缓存一致性的问题及解决整合Spring Cache。 对应视频P151-P172
一、本地缓存和分布式缓存
1.本地缓存 本地缓存存储在单个应用服务器的内存中属于该服务器的进程空间。仅在当前服务器节点内有效不会在多个服务器之间共享。 本地缓存最简单的实现方式通过Map private HashMapString,Object map new HashMap();Testpublic Object testMapCache(){Object key map.get(key);if (key !null){return key;}//查询数据库相关逻辑...假设查询到的值为valuemap.put(key,value);return value;}不考虑缓存一致性穿透击穿等问题上面的案例就是通过Map做本地缓存最简单的实现。
2.分布式缓存 目前市面上大多数的项目都是采用微服务的架构同一个服务也可能部署多个实例。而如上面所说本地缓存仅在当前服务器节点内有效。假设现在有三台服务器 初始状态下三台服务器都没有缓存第一次用户访问了服务器1查询数据库后将结果存入了缓存。下一次由于负载均衡访问到了服务器2 由于缓存此时只存在于服务器1这次用户又需要去数据库中查询然后放入服务器2的缓存中。 为了解决这样的问题在微服务的架构中引入了缓存中间件对不同服务间的缓存进行统一管理。常用的是Redis。
二、项目实战
1.配置Redis dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-data-redis/artifactId/dependencyspring:redis:host: xxxport: 6379Redis为我们封装了两个模版分别是redisTemplate和stringRedisTemplatestringRedisTemplate的key和value默认都是String类型的在项目中使用时只需要注入对应的模版即可。
2.整合业务代码 在项目中需要加入缓存的业务场景是首页渲染三级分类菜单。 缓存这一块的坑点很多在整合业务代码前有必要先介绍一下缓存常见的三大问题及解决方案
2.1 缓存击穿 假设数据库中的某张A表数据的主键ID是从1-1000如果使用1001的ID去查询数据是无论如何都查询不到的查询到的会是空值。如果没有将这个空值存入缓存那么通过伪造请求等方式不断地使用不存在的ID作为条件去查询数据库也会导致数据库崩溃的情况。 解决方式如果根据查询条件查询到的结果不存在就缓存一个空值或进行约定缓存一个特定的值。也可以通过布隆过滤器或加强参数校验的方式解决。
2.2 缓存雪崩 这种情况主要是出现在大并发量的场景下大量的热点key同时失效导致这一刻的所有请求都打到数据库上。 解决方式给不同的key设置随机的过期时间或者设置永不过期。
2.3 缓存穿透 区别于缓存雪崩击穿主要是体现在某个热点key失效导致大量的请求在查询缓存无果的情况下都去数据库中查询。 解决方式加锁让同一时刻只有一个线程能查询到数据库。但是涉及到多线程锁的问题时一般就不会有那么简单了。我们知道锁有本地锁和分布式锁也有乐观锁和悲观锁。 如果直接使用synchronized关键字进行加锁在单体应用下是没问题的。synchronized关键字是锁当前的JVM。在微服务架构下每个服务都有自己的JVM假设我的product服务部署在了8台服务器上每个服务器锁自己的JVM最后还是有可能8个请求同时打在数据库上。所以需要一个全局的锁去统一管理这些服务。通过Redis也可以自己实现分布式锁但是其中有很多坑点。
2.4 业务代码1.0版 加入缓存后的业务流程图 我们先不考虑分布式锁的实现完成第一版加入缓存的业务代码 这里有几点需要注意下
存入缓存的key必须唯一可以加上当前用户或者业务的前缀。例如我将商品列表放入缓存商品列表可以被不同的用户访问又带有查询条件可以这样设计key用户标识:查询条件1_查询条件2_查询条件3某个线程获取到了锁在查询数据库前需要先再次查询缓存中是否有值。将数据库查询结果放入缓存必须在锁的范围内否则可能存在A线程查到了数据然后释放了锁准备放入缓存在放入缓存的过程中B线程获取到了锁又去查了一遍数据库的问题。向Redis中存储的数据一般约定使用JSON字符串的方式进行存储在读取时进行反序列化。
Slf4j
Service(categoryService)
public class CategoryServiceImpl extends ServiceImplCategoryDao, CategoryEntity implements CategoryService {Resourceprivate StringRedisTemplate stringRedisTemplate;Overridepublic MapString, ListCategoryJsonVO getCategoryJson() {//从缓存中获取String category stringRedisTemplate.opsForValue().get(RedisConstants.CATEGORY_KEY);//缓存中不为空if (StringUtils.isNotBlank(category)) {log.info(查询到了结果);return JSON.parseObject(category, new TypeReferenceMapString, ListCategoryJsonVO() {});}/*缓存空值解决缓存穿透设置过期时间随机值解决缓存雪崩加锁解决缓存击穿*///查询pms_category表的全量数据MapString, ListCategoryJsonVO map;map this.getCateGoryFromDB();return map;}/*** 从数据库查询三级分类* return 查询结果*/private MapString, ListCategoryJsonVO getCateGoryFromDB() {synchronized (this) {log.info(获取到了锁);//再看下缓存中有没有//从缓存中获取String category stringRedisTemplate.opsForValue().get(RedisConstants.CATEGORY_KEY);//缓存中不为空if (StringUtils.isNotBlank(category)) {log.info(查询到了结果);return JSON.parseObject(category, new TypeReferenceMapString, ListCategoryJsonVO() {});}log.info(开始查询数据库);ListCategoryEntity list list();MapString, ListCategoryJsonVO map list.stream().collect(Collectors.toMap(k - k.getCatId().toString(), v - {//查出某个一级分类下的所有二级分类// ListCategoryEntity entityList baseMapper.selectList(new QueryWrapperCategoryEntity().eq(parent_cid, v.getCatId()));ListCategoryEntity entityList list.stream().filter(categoryEntity - categoryEntity.getParentCid().equals(v.getCatId())).collect(Collectors.toList());ListCategoryJsonVO categoryJsonVOS entityList.stream().map(categoryEntity - {CategoryJsonVO jsonVO new CategoryJsonVO();jsonVO.setCatalog1Id(String.valueOf(categoryEntity.getParentCid()));jsonVO.setId(String.valueOf(categoryEntity.getCatId()));jsonVO.setName(categoryEntity.getName());//查出某个二级分类下的所有三级分类// ListCategoryEntity entityListThree baseMapper.selectList(new QueryWrapperCategoryEntity().eq(parent_cid, categoryEntity.getCatId()));ListCategoryEntity entityListThree list.stream().filter(categoryEntity1 - categoryEntity1.getParentCid().equals(categoryEntity.getCatId())).collect(Collectors.toList());ListCategoryJsonVO.CatalogJsonThree catalogJsonThrees entityListThree.stream().map(categoryEntity1 - {CategoryJsonVO.CatalogJsonThree catalogJsonThree new CategoryJsonVO.CatalogJsonThree();catalogJsonThree.setId(String.valueOf(categoryEntity1.getCatId()));catalogJsonThree.setName(categoryEntity1.getName());catalogJsonThree.setCatalog2Id(String.valueOf(categoryEntity1.getParentCid()));return catalogJsonThree;}).collect(Collectors.toList());jsonVO.setCatalog3List(catalogJsonThrees);return jsonVO;}).collect(Collectors.toList());return categoryJsonVOS;}));//向缓存中存一份(序列化)stringRedisTemplate.opsForValue().set(RedisConstants.CATEGORY_KEY, CollectionUtils.isEmpty(map) ? 0 : JSON.toJSONString(map), 1, TimeUnit.DAYS);return map;}}}2.5 分布式锁1.0版 下面我们自己先手动实现一个分布式锁 Testpublic void testLock(){String uuid UUID.randomUUID().toString();//获取锁Boolean lock stringRedisTemplate.opsForValue().setIfAbsent(lock,uuid);//获取到了锁if (lock){try {//设置过期时间stringRedisTemplate.expire(lock,300, TimeUnit.SECONDS);//执行业务代码}catch (Exception e){//日志记录异常}finally {stringRedisTemplate.delete(lock);}}else {//未获取到锁就自旋继续尝试获取testLock();}}上面的代码有什么问题可谓漏洞百出。
获取锁和设置过期时间分为了两个步骤去实现。会导致一个什么样的问题既然是两步没有写在一条命令里说明是非原子性的操作。如果两行代码之间出现了异常那么过期时间就没有设置成功。那么能不能将设置过期时间写在finally块中答案也是不行的因为出现异常不仅仅可能是程序方面的异常假设极端情况下机房停电了…所以为了解决这个问题需要做如下的改动
Boolean lock stringRedisTemplate.opsForValue().setIfAbsent(lock,uuid,300, TimeUnit.SECONDS);解锁时没有进行判断会导致将其他线程的锁误删的问题。例如线程A拿到了锁由于业务执行的时间较长线程A的锁超时了线程B拿到了锁B在执行自己业务的时候线程A执行完了业务释放了B线程的锁…不是那么靠谱的解决方案 if (stringRedisTemplate.opsForValue().get(lock).equals(uuid)){stringRedisTemplate.delete(lock);}为什么说这个解决方案不是那么靠谱引出了第三个问题
解锁时的条件判断非原子性操作因为判断解锁之间也是存在间隔时间的必须要保证原子性。例如锁设置的key的value是1设置的过期时间是10S但是前面的操作花费了9.5S,判断的时间花费了0.6S相当于key对应的value已经过期了。下一个线程进来又设置key的value是2实际上lock对应的值变了但是在判断的时候获取到的lock的值还是之前的1然后原来的线程解锁就把下一个线程的锁给解了。解决方案是使用lua脚本包括后面引入的Redisson的底层很多也是通过lua脚本实现的 String script if redis.call(get, KEYS[1]) ARGV[1] thenreturn redis.call(del, KEYS[1]) else return 0 end;//删除锁Long lock1 redisTemplate.execute(newDefaultRedisScriptLong(script, Long.class), Arrays.asList(lock), uuid);通过上述问题的发现与解决看似我们自己实现的分布式锁没有问题了其实不然仔细深究还是会存在锁重入重试等相关问题。
2.6 分布式锁2.0版 引入Redisson
dependencygroupIdorg.redisson/groupIdartifactIdredisson-spring-boot-starter/artifactId!-- 请使用最新版本 --version3.16.3/version
/dependency进行配置
Configuration
public class RedissonConfig {Bean(destroyMethod shutdown)public RedissonClient redissonClient() {Config config new Config();config.useSingleServer().setAddress(redis://自己的虚拟机地址:6379);return Redisson.create(config);}}Redisson的基本使用及原理
Test
public void testRedisson() {RLock lock redissonClient.getLock(lock);//默认过期时间30S业务在执行完成之前每隔10S续期一次//如果设置了过期时间就按照过期时间来不会自动续期lock.lock();try {}finally {lock.unlock();}
}通过RLock lock redissonClient.getLock(lock);可以获取一把锁只要名称相同就代表是同一把锁。 除了上面获取锁的方式还有其他关于锁的操作在官方文档中都有说明 Redisson官方文档中文版 lock.lock();方法如果没有设置过期时间它有一个默认的30S过期时间同时会每隔1/3默认时间自动续期设置了过期时间则按照实际的过期时间即使业务没有执行完成也不会自动续期。 项目实战篇以应用为主限于篇幅不翻源码源码解析会放在源码分析专栏后续更新。 改造业务代码
Autowired
private RedissonClient redissonClient;/*** 从数据库查询三级分类* 分布式锁解决缓存击穿* return 查询结果*/
private MapString, ListCategoryJsonVO getCateGoryFromDB() {//category_lockRLock lock this.redissonClient.getLock(RedisConstants.CATEGORY_LOCK_KEY);lock.lock(10, TimeUnit.SECONDS);try {-- 业务代码} finally {lock.unlock();}
}2.7 Spring Cache及缓存一致性问题
2.7.1 Spring Cache 简单来说Spring Cache是基于声明式注解的缓存对于缓存声明Spring的缓存抽象提供了一组Java注解
Cacheable: 触发缓存的填充。CacheEvict: 触发缓存删除。CachePut: 更新缓存而不干扰方法的执行。 Caching: 将多个缓存操作重新分组应用在一个方法上。CacheConfig: 分享一些常见的类级别的缓存相关设置。 详见Spring官方文档中文版 在项目中使用只需要引入依赖并在配置文件中进行配置
dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-cache/artifactId
/dependency# 配置spring cache 为redis
spring.cache.typeredis
spring.cache.redis.time-to-live360000在方法上加入注解
Override
Cacheable(value {category},key getLevelOneCateGory) //放入缓存 如果缓存中有方法就不调用
public ListCategoryEntity getLevelOneCateGory() {return list(new QueryWrapperCategoryEntity().eq(parent_cid, 0));
}启动项目通过redis客户端查看对应的缓存数据 需要注意默认的序列化方式不是JSON而是JDK序列化。需要自定义配置
Configuration
EnableCaching
EnableConfigurationProperties(CacheProperties.class)
public class MyRedisCacheConfig {Beanpublic RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){RedisCacheConfiguration config RedisCacheConfiguration.defaultCacheConfig();//自定义键值的序列化config config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));config config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));//自定义键和值的过期时间,从配置文件中读取CacheProperties.Redis redisProperties cacheProperties.getRedis();if (redisProperties.getTimeToLive() ! null) {config config.entryTtl(redisProperties.getTimeToLive());}if (redisProperties.getKeyPrefix() ! null) {config config.prefixKeysWith(redisProperties.getKeyPrefix());}if (!redisProperties.isCacheNullValues()) {config config.disableCachingNullValues();}if (!redisProperties.isUseKeyPrefix()) {config config.disableKeyPrefix();}return config;}
}2.7.2 缓存一致性问题 缓存一致性问题简单来说就是缓存中的数据和数据库最新的数据不一致导致用户看到的数据非实时而是旧的缓存中的。 解决缓存一致性问题对于数据库写入方一般有如下几种方案
先删除缓存再更新数据库 先更新数据库再删除缓存 上述两种方案都是有弊端的 先删除缓存再更新数据库对应上图的情况用户读取到的数据还是未更新数据库前旧的数据。 如果先更新数据库再删除缓存 也可能存在上图的情况即如果B线程更新数据库的时间较长并且此时C线程进行查询C线程查询到的还是A线程更新数据库的结果并且将A的操作结果写入缓存获取到的依旧不是B最新操作的数据。 既然两者都有弊端那么就引入了第三种方式延迟双删 其实无论是何种方式保证的都是缓存的最终一致性如果对数据实时性的要求高且数据更新频繁应该去查数据库而不是使用缓存。 在项目中采用先更新数据库再删除缓存 的策略结合注解
/*** 修改* 修改时删除缓存*/
CacheEvict(value {category},key getLevelOneCateGory)
RequestMapping(/update)
public R update(RequestBody CategoryEntity category){categoryService.updateById(category);return R.ok();
}2.7.3 Spring Cache的弊端 主要体现在解决缓存击穿问题上在手动编写逻辑时是通过Redisson分布式锁的方式解决的而Spring Cache的注解默认是不加锁的如果加锁需要在注解中设置sync为true并且这里的锁是本地锁非分布式锁。