谷粒商城の缓存篇

ops/2024/9/18 12:06:53/ 标签: 缓存, 后端, redis, springboot

文章目录

  • 前言
  • 一、本地缓存和分布式缓存
  • 二、项目实战
    • 1.配置Redis
    • 2.整合业务代码
      • 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 Cache
        • 2.7.2 缓存一致性问题
        • 2.7.3 Spring Cache的弊端

前言

  本篇重点介绍谷粒商城首页整合缓存技术,从本地缓存Map)到分布式缓存Redis),描述常见的缓存三大问题(缓存穿透,缓存雪崩,缓存击穿)及解决方案,并且在解决的过程中引用成熟的Redisson方案。最后到缓存一致性的问题及解决,整合Spring Cache

  对应视频P151-P172

一、本地缓存和分布式缓存

1.本地缓存

  本地缓存存储在单个应用服务器的内存中,属于该服务器的进程空间。仅在当前服务器节点内有效,不会在多个服务器之间共享。
本地缓存最简单的实现方式:通过Map

    private HashMap<String,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.分布式缓存

  目前市面上大多数的项目都是采用微服务的架构,同一个服务也可能部署多个实例。而如上面所说,本地缓存仅在当前服务器节点内有效。假设现在有三台服务器:
Alt
  初始状态下三台服务器都没有缓存,第一次用户访问了服务器1,查询数据库后将结果存入了缓存。下一次由于负载均衡,访问到了服务器2:
Alt
  由于缓存此时只存在于服务器1,这次用户又需要去数据库中查询,然后放入服务器2的缓存中。
  为了解决这样的问题,在微服务的架构中,引入了缓存中间件对不同服务间的缓存进行统一管理。常用的是Redis

二、项目实战

1.配置Redis

  		 <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
spring:redis:host: xxxport: 6379

  Redis为我们封装了两个模版,分别是redisTemplatestringRedisTemplatestringRedisTemplate的key和value默认都是String类型的,在项目中使用时,只需要注入对应的模版即可。
Alt

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版

  加入缓存后的业务流程图:
Alt
  我们先不考虑分布式锁的实现,完成第一版加入缓存的业务代码:
  这里有几点需要注意下:

  1. 存入缓存key必须唯一,可以加上当前用户或者业务的前缀。例如我将商品列表放入缓存,商品列表可以被不同的用户访问,又带有查询条件,可以这样设计key:用户标识:查询条件1_查询条件2_查询条件3
  2. 某个线程获取到了锁,在查询数据库前,需要先再次查询缓存中是否有值。
  3. 将数据库查询结果,放入缓存必须在锁的范围内,否则可能存在,A线程查到了数据然后释放了锁,准备放入缓存,在放入缓存的过程中,B线程获取到了锁,又去查了一遍数据库的问题。
  4. 向Redis中存储的数据,一般约定使用JSON字符串的方式进行存储,在读取时进行反序列化。
@Slf4j
@Service("categoryService")
public class CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity> implements CategoryService {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Map<String, List<CategoryJsonVO>> getCategoryJson() {//从缓存中获取String category = stringRedisTemplate.opsForValue().get(RedisConstants.CATEGORY_KEY);//缓存中不为空if (StringUtils.isNotBlank(category)) {log.info("查询到了结果");return JSON.parseObject(category, new TypeReference<Map<String, List<CategoryJsonVO>>>() {});}/*缓存空值解决缓存穿透设置过期时间(随机值)解决缓存雪崩加锁解决缓存击穿*///查询pms_category表的全量数据Map<String, List<CategoryJsonVO>> map;map = this.getCateGoryFromDB();return map;}/*** 从数据库查询三级分类* @return 查询结果*/private Map<String, List<CategoryJsonVO>> getCateGoryFromDB() {synchronized (this) {log.info("获取到了锁");//再看下缓存中有没有//从缓存中获取String category = stringRedisTemplate.opsForValue().get(RedisConstants.CATEGORY_KEY);//缓存中不为空if (StringUtils.isNotBlank(category)) {log.info("查询到了结果");return JSON.parseObject(category, new TypeReference<Map<String, List<CategoryJsonVO>>>() {});}log.info("开始查询数据库");List<CategoryEntity> list = list();Map<String, List<CategoryJsonVO>> map = list.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {//查出某个一级分类下的所有二级分类//            List<CategoryEntity> entityList = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", v.getCatId()));List<CategoryEntity> entityList = list.stream().filter(categoryEntity -> categoryEntity.getParentCid().equals(v.getCatId())).collect(Collectors.toList());List<CategoryJsonVO> 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());//查出某个二级分类下的所有三级分类//                List<CategoryEntity> entityListThree = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", categoryEntity.getCatId()));List<CategoryEntity> entityListThree = list.stream().filter(categoryEntity1 -> categoryEntity1.getParentCid().equals(categoryEntity.getCatId())).collect(Collectors.toList());List<CategoryJsonVO.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();}}

  上面的代码有什么问题?可谓漏洞百出。

  1. 获取锁和设置过期时间分为了两个步骤去实现。:会导致一个什么样的问题?既然是两步,没有写在一条命令里,说明是非原子性的操作。如果两行代码之间出现了异常,那么过期时间就没有设置成功。那么能不能将设置过期时间写在finally块中?答案也是不行的,因为出现异常不仅仅可能是程序方面的异常,假设极端情况下机房停电了…所以为了解决这个问题,需要做如下的改动:
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock",uuid,300, TimeUnit.SECONDS);
  1. 解锁时没有进行判断:会导致将其他线程的锁误删的问题。例如线程A拿到了锁,由于业务执行的时间较长,线程A的锁超时了,线程B拿到了锁,B在执行自己业务的时候,线程A执行完了业务,释放了B线程的锁…不是那么靠谱的解决方案:
 			if (stringRedisTemplate.opsForValue().get("lock").equals(uuid)){stringRedisTemplate.delete("lock");}

为什么说这个解决方案不是那么靠谱?引出了第三个问题

  1. 解锁时的条件判断非原子性操作:因为判断+解锁之间也是存在间隔时间的,必须要保证原子性。例如锁设置的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(newDefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid);

  通过上述问题的发现与解决,看似我们自己实现的分布式锁没有问题了,其实不然,仔细深究还是会存在锁重入,重试等相关问题。

2.6 分布式锁2.0版

  引入Redisson:

<dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><!-- 请使用最新版本 --><version>3.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 Map<String, List<CategoryJsonVO>> 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官方文档中文版

  在项目中使用,只需要引入依赖,并在配置文件中进行配置:

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-cache</artifactId>
</dependency>
# 配置spring cache 为redis
spring.cache.type=redis
spring.cache.redis.time-to-live=360000

  在方法上加入注解:

@Override
@Cacheable(value = {"category"},key = "'getLevelOneCateGory'") //放入缓存 如果缓存中有方法就不调用
public List<CategoryEntity> getLevelOneCateGory() {return list(new QueryWrapper<CategoryEntity>().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,并且这里的锁是本地锁,非分布式锁。


http://www.ppmy.cn/ops/109292.html

相关文章

深入浅出 Ansible 自动化运维:从入门到实战

在现代 IT 运维中&#xff0c;自动化是提升效率、降低错误率的关键。Ansible 作为一款流行的自动化工具&#xff0c;凭借其简洁的语法和强大的功能&#xff0c;成为了运维工程师的得力助手。本文将深入探讨 Ansible 的核心概念、实际应用以及一些实用的技巧&#xff0c;帮助你在…

Android - NDK: 在jni层生成java层对象,并调用java层的方法

Android - NDK 一、在JNI层生成java层的对象&#xff0c;并调用java层的方法 1、java类的定义 import android.util.Log; public class MyCustomObj {private final static String TAG MyCustomObj.class.getSimpleName();private String name;private int age;// publi…

vue缓存用法

Store 临时缓存 特点&#xff1a;需要定义&#xff0c;有初始值、响应式、全局使用、刷新重置 Pinia官方文档 https://pinia.vuejs.org 创建 store 缓存 示例代码 import {defineStore} from pinia import {store} from //storeexport const useMyStore defineStore({// 定义…

2024最新精选文章!分享5款论文ai生成软件

在2024年&#xff0c;AI论文生成软件的出现极大地提升了学术写作的效率和质量。这些工具不仅能够帮助研究人员快速生成论文草稿&#xff0c;还能进行内容优化、查重和排版等操作。以下是五款值得推荐的AI论文生成软件&#xff0c;其中特别推荐千笔-AIPassPaper。 ### 千笔-AIPa…

287. 寻找重复数(哈希法)

一&#xff1a;题目&#xff1a; 给定一个包含 n 1 个整数的数组 nums &#xff0c;其数字都在 [1, n] 范围内&#xff08;包括 1 和 n&#xff09;&#xff0c;可知至少存在一个重复的整数。 假设 nums 只有 一个重复的整数 &#xff0c;返回 这个重复的数 。 你设计的解决…

黑神话云端开放!ToDesk 云电脑、青椒云、网易云,不用高配电脑也能畅玩!

文章目录 1.前言2.云电脑产品介绍ToDesk云电脑青椒云云电脑网易云游戏电脑 3.黑神话上机实测ToDesk实测概述实操 青椒云实测网易云实测 4.实测对比ToDesk云电脑&#xff0c;真电竞游戏云电脑青椒云&#xff0c;面向设计师的工作站网易云&#xff0c;云游戏厂商集合体 5.总结 1.…

JS面试真题 part4

JS面试真题 part4 16、谈谈JavaScript中的类型转换机制标准回答&#xff1a; 17、深拷贝浅拷贝的区别&#xff1f;如何实现深拷贝标准回答&#xff1a; 18、JavaScript中如何实现函数缓存&#xff1f;函数缓存有哪些应用场景&#xff1f;标准回答&#xff1a; 19、JavaScript字…

HuierShi慧耳视摄像头MP4删除恢复方法

智能摄像头的恢复处理过很多&#xff0c;慧耳视一个地方小品牌&#xff0c;其采用了mp4视频文件方案&#xff0c;下边我们看看这个比较特殊的案例。 故障存储: 32G TF卡/fat32文件系统/簇大小32sec 故障现象: 客户描述此卡删除了大量文件然后使用了极短的时间就被断电了&…

BUUCTF 之Basic 1(BUU LFI COURSE 1)

1、启动靶场&#xff0c;会生成一个URL地址&#xff0c;打开给的URL地址&#xff0c;会看到一个如下界面 可以看到是一个PHP文件&#xff0c;非常的简单&#xff0c;就几行代码&#xff0c;判断一下是否有一个GET的参数&#xff0c;并且是file名字&#xff0c;如果是并且加载&a…

【机器鱼设计学习1】——电子控制单元

总结自B站UP主“RC扫地僧” 一、电池 电池容量&#xff1a; ① 5000mAh用5000mA&#xff08;5A&#xff09;放电&#xff0c;1h放完电 ② 锂聚合物电池的标准电势电压为1S3.7V&#xff08;相应的2S7.4V&#xff0c;3S11.1V&#xff09; ③ 若用单片电芯组成的一组电池&#xf…

C++单例模式

局部静态变量方式 //通过静态成员变量实现单例 //懒汉式 class Single2 { private:Single2(){}Single2(const Single2 &) delete;Single2 &operator(const Single2 &) delete; public:static Single2 &GetInst(){static Single2 single;return single;} }; …

DBeaver 连接 MySQL 报错 Public Key Retrieval is not allowed

DBeaver 连接 MySQL 报错 Public Key Retrieval is not allowed 文章目录 DBeaver 连接 MySQL 报错 Public Key Retrieval is not allowed问题解决办法 问题 使用 DBeaver 连接 MySQL 数据库的时候&#xff0c; 一直报错下面的错误 Public Key Retrieval is not allowed详细…

godotenv拜读

简介 应用提倡将配置存储在环境变量中。任何从开发环境切换到生产环境时需要修改的东西都从代码抽取到环境变量里。 但是在实际开发中&#xff0c;如果同一台机器运行多个项目&#xff0c;设置环境变量容易冲突&#xff0c;不实用。godotenv库从.env文件中读取配置&#xff0c;…

Vue 生命周期与 TypeScript:深入理解组件生命周期

Vue 生命周期与 TypeScript&#xff1a;深入理解组件生命周期 引言 Vue.js 作为一种流行的前端框架&#xff0c;其组件生命周期是开发过程中不可或缺的一部分。理解并正确利用Vue的生命周期&#xff0c;可以帮助开发者构建更加健壮和可维护的应用。而当TypeScript与Vue结合使…

RickdiculouslyEasy靶场

1.使用nmap扫描C段&#xff0c;找到具体ip 2. 使用nmap扫描所有端口 3.查看发现13337下找到个第一个flag 4.使用xftp连接21端口查看&#xff0c;找到第二个flag.txt,查看&#xff0c;找到flag 5.访问9090端口&#xff0c;发现页面显示&#xff0c;找到第三个flag 6.查看60000端…

【recast-navigation/源码解析】findStraightPath详解以及寻路结果贴边优化

说在前面 recast-navigation版本&#xff1a;1.6.0 叉积cross product 正常来讲&#xff0c;叉乘为&#xff1a; ∣ A ⃗ B ⃗ ∣ ∣ x A y A x B y B ∣ x A ⋅ y B − x B ⋅ y A |\vec{A} \times \vec{B}|\begin{vmatrix} x_A & y_A \\ x_B & y_B \end{vmatrix…

数组与贪心算法——179、56、57、228(2简2中)

179. 最大数&#xff08;简单&#xff09; 给定一组非负整数 nums&#xff0c;重新排列每个数的顺序&#xff08;每个数不可拆分&#xff09;使之组成一个最大的整数。 注意&#xff1a;输出结果可能非常大&#xff0c;所以你需要返回一个字符串而不是整数。 解法一、自定义比较…

【AIGC】Whisper语音识别模型概述,应用场景和具体实例及如何本地搭建Whisper语音识别模型?

&#x1f3c6;&#x1f3c6;欢迎大家来到我们的天空&#x1f3c6;&#x1f3c6; &#x1f3c6;&#x1f3c6;如果文章内容对您有所触动&#xff0c;别忘了点赞、关注&#xff0c;收藏&#xff01; &#x1f3c6; 作者简介&#xff1a;我们的天空 &#x1f3c6;《头衔》&#x…

4- 【JavaWeb】Mybatis介绍、安装、配置与操作

MyBatis 是一个优秀的持久层框架&#xff0c;它消除了几乎所有的 JDBC 代码和手动设置参数以及获取结果集的过程。MyBatis 提供了一个基于 XML 或注解的配置&#xff0c;可以灵活的将 SQL 语句、存储过程与 Java 对象映射起来。相比于 Hibernate 等全自动 ORM 框架&#xff0c;…

虚幻5|知识点(1)寻找查看旋转,击打敌人后朝向主角

举例说明&#xff0c;我们想让角色一直朝着摄像头&#xff0c;我们控制角色任意位置&#xff0c;都能自行旋转都能朝向摄像头 下面是敌人一直朝向角色&#xff0c;无论主角走向哪个位置&#xff0c;敌人都能朝向主角 start是获取敌人的位置向量大小&#xff0c;Target是获取主…