mybatis中的缓存(一级缓存、二级缓存)

news2024/11/14 5:35:13

文章目录

  • 前言
  • 一、MyBatis 缓存概述
  • 二、一级缓存
    • 1_初识一级缓存
    • 2_一级缓存命中原则
      • 1_StatementId相同
      • 2_查询参数相同
      • 3_分页参数相同
      • 4_sql 语句
      • 5_环境
    • 3_一级缓存的生命周期
      • 1_缓存的产生
      • 2_缓存的销毁
      • 3_网传的一些谣言
    • 4_一级缓存核心源码
    • 5_总结
  • 三、二级缓存
    • 1_开启二级缓存
    • 2_二级缓存命中原则
    • 3_二级缓存的产生
    • 4_缓存失效情况
    • 5_二级缓存核心源码
  • 五、缓存机制的注意事项
  • 六_总结

前言

MyBatis 是一个优秀的持久层框架,它不仅简化了数据库交互的开发过程,还提供了强大的缓存机制以提升性能。本文将详细介绍 MyBatis 的缓存机制,包括一级缓存、二级缓存的工作原理、配置和注意事项。

一、MyBatis 缓存概述

为了减轻数据库的访问压力,mybatis提供了缓存功能,如果命中缓存将直接返回缓存中的结果实例,不再需要查询数据库。

在这里插入图片描述

MyBatis 提供了两级缓存机制:

  1. 一级缓存(Local Cache):也称为本地缓存,是 SqlSession 级别的缓存。
  2. 二级缓存(Global Cache):也称为全局缓存,是 SqlSessionFactory 映射级别的缓存。

二、一级缓存

一级缓存是 SqlSession 级别的缓存,在同一个 SqlSession 期间,相同的查询结果会被缓存并复用,减少数据库访问次数。它的作用范围是 SqlSession,默认是开启的。

1_初识一级缓存

为了方便读者验证一级缓存的存在,先将mybatis最基础的环境搭建完成。(基础环境没有与sprig整合)

所需的pom.xml依赖:

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.34</version>
</dependency>
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>3.5.11</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.28</version>
</dependency>
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.13.2</version>
    <scope>test</scope>
</dependency>

首先是maybatis的基础配置文件 ,配置文件中包含了对 MyBatis 系统的核心设置,包括获取数据库连接实例的数据源(DataSource) 以及决定事务作用域和控制方式的事务管理器(TransactionManager)

这里是一个最简单的基础配置,resources\mybaties.xml文件:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <!--    控制台打印-->
    <settings>
        <setting name="logImpl" value="org.apache.ibatis.logging.stdout.StdOutImpl"/>
    </settings>
    <environments default="development">
        <environment id="development">
            <!--    使用jdbc并配置数据源-->
            <transactionManager type="JDBC"/>
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.cj.jdbc.Driver"/>
                <property name="url" value="jdbc:mysql://127.0.0.1:3306/bilibili"/>
                <property name="username" value="root"/>
                <property name="password" value="123456"/>
            </dataSource>
        </environment>
    </environments>
    <mappers>
        <mapper resource="mappers/tempMapper.xml"/>
    </mappers>
</configuration>

sql映射文件 resources/mappers/mapper.xml:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.shen.spring.dao.TempDao">
    <select id="getById" parameterType="int" resultType="com.shen.spring.entity.TempEntity">
        select * from temp where id = #{id}
    </select>
</mapper>

测试使用的表及其数据所需sql:

drop table if exists temp;
create table temp
(
    id int auto_increment primary key ,
    value1 varchar(100) null ,
    value2 varchar(100) null 
);
insert into  temp(value1, value2) VALUES ('111111','aaaaaa');
insert into  temp(value1, value2) VALUES ('222222','bbbbbb');
insert into  temp(value1, value2) VALUES ('333333','cccccc');
insert into  temp(value1, value2) VALUES ('444444','dddddd');

对应的实体对象:

import lombok.Data;
import lombok.ToString;

/**
 * @author shenyang
 * @version 1.0
 * @info SpringBoot17
 * @since 2024/7/22 下午3:41
 */
@Data
@ToString
public class TempEntity {
    private Integer id;
    private String value1;
    private String value2;
}

对应的查询接口:

import com.shen.spring.entity.TempEntity;

/**
 * @author shenyang
 * @version 1.0
 * @info SpringBoot17
 * @since 2024/7/22 下午3:42
 */
public interface TempDao {

    TempEntity getById(int id);
}

最后编写测试用例证明一级缓存的存在:

package com.shen.spring;

import com.shen.spring.entity.TempEntity;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;

import org.junit.Test;

import java.io.IOException;
import java.io.InputStream;

/**
 * @author shenyang
 * @version 1.0
 * @info SpringBoot17
 * @since 2024/7/22 下午3:58
 */
@Slf4j
public class TempDaoTest {
    
    @Test
    public void test() throws IOException {
    	//读取xml文件
        InputStream inputStream =
                Resources.getResourceAsStream("mybaties.xml");
        //通过SqlSessionFactoryBuilder创建sqlSessionFactory 
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        //拿到执行sql语句的一个会话
        SqlSession sqlSession = sqlSessionFactory.openSession();
        // 第一次查询,会从数据库获取数据
        TempEntity tempEntity1 = sqlSession
                .selectOne("com.shen.spring.dao.TempDao.getById", 1);
        log.info("{}",tempEntity1);
        // 第二次查询,会从缓存获取数据
        TempEntity tempEntity2 = sqlSession
                .selectOne("com.shen.spring.dao.TempDao.getById", 1);
        log.info("{}",tempEntity2);
        System.out.println(tempEntity1 == tempEntity2);
    }
}

输出结果:

在这里插入图片描述

根据控制台输出结果可以发现第一次查询有sql语句日志输出,代表查询了数据库。第二次没有sql语句输出,没有查询数据库,而是走的缓存。并且这两次查询都是返回同一个对象(控制台结果打印true)。这正验证了mybatis一级缓存的存在。

另外,如果不了解SqlSession 这个mybatis提供的 java API 可以去官网上查看:Mybatis-Java API。

最后,给一个上述测试代码的时序图作为结尾吧:

  • 当 SqlSession 执行查询操作时,首先会在缓存中查找是否有相同的查询结果。
  • 如果缓存中存在相同的结果,直接返回。
  • 如果缓存中不存在,执行数据库查询,并将结果存入缓存中。

在这里插入图片描述

2_一级缓存命中原则

所谓的命中原则就是指:Mybatis是怎样判断某两次查询是完全相同的查询?

为什么这么说呢?因为如果两次查询时完全相同的查询,且上一次查询时是有缓存的,mybatis就不会查询数据库而是直接返回上一次查询的结果。

1_StatementId相同

先看第一个条件两次查询的StatementId必须相同,否则无法命中缓存,即使两个查询语句、参数等完全一样。这个StatementId其实就是我们定义的daoclass内的方法名:

在这里插入图片描述
现在我们进行测试(我的dao层和mapper.xml文件已经修改完成了,这里就不附上完整步骤了)

修改测试代码:

 @Test
 public void test() throws IOException {
     InputStream inputStream =
             Resources.getResourceAsStream("mybaties.xml");
     SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
     SqlSession sqlSession = sqlSessionFactory.openSession();
     TempEntity tempEntity1 = sqlSession
             .selectOne("com.shen.spring.dao.TempDao.getById1", 1);
     log.info("{}",tempEntity1);
     TempEntity tempEntity2 = sqlSession
             .selectOne("com.shen.spring.dao.TempDao.getById2", 1);
     log.info("{}",tempEntity2);
     System.out.println(tempEntity1 == tempEntity2);
     sqlSession.close();//关闭sqlSession  关闭sql会话
 }

输出结果:

在这里插入图片描述

2_查询参数相同

要求两次查询的查询参数相同(这里不做过多测试 )。

在这里插入图片描述

但是这里还有一个问题这里的参数不是指SqlSession调用selectXXX()方法中的参数,而是指最终执行sql语句中的参数。

也就是说:要求传递给SQL的查询参数必须相同,否则无法命中缓存

比如如下例子:

<select id="getById3" parameterType="java.util.Map" resultType="com.shen.spring.entity.TempEntity">
    select * from temp where id = #{id}
</select>

测试方法:

 @Test
    public void test() throws IOException {
        InputStream inputStream =
                Resources.getResourceAsStream("mybaties.xml");
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        SqlSession sqlSession = sqlSessionFactory.openSession();

        Map<String,Object> params1 = new HashMap<>();
        params1.put("id",1);
        params1.put("test",2);

        TempEntity tempEntity1 = sqlSession
                .selectOne("com.shen.spring.dao.TempDao.getById3", params1);
        Map<String,Object> params2 = new HashMap<>();
        params2.put("id",1);
        params2.put("test",2);

        log.info("{}",tempEntity1);
        TempEntity tempEntity2 = sqlSession
                .selectOne("com.shen.spring.dao.TempDao.getById3", params2);
        log.info("{}",tempEntity2);
        System.out.println(tempEntity1 == tempEntity2);
    }

虽然这里的两次调用传递的Map对象不是同一个,但是因为传递给sql中的参数是相同的,所以可以查询到缓存(所以只有一次sql查询的日志输出):

在这里插入图片描述

3_分页参数相同

分页参数必须相同,否则无法命中缓存。缓存的粒度是整个分页查询结果,而不是结果中的每个对象

注意:这里的分页我们用的比较少,这里的分页实际上是把数据库中的所有数据都查出来做个物理分页。而不是在数据库层面上用sql脚本进行分页。如下图,这里的 RowBounds就是用来分页的。

在这里插入图片描述

这里的分页查询方法,即使两次查询传递的不是同一个RowBounds的实例,只要它们的传递参数相同就会命中缓存。反之参数不同就不会命中缓存。

<select id="list"  resultType="com.shen.spring.entity.TempEntity">
    select * from temp where 1=1
</select>

测试方法:

//dao下添加
List<TempEntity> list();
//测试方法
@Test
public void test2() throws IOException {
    InputStream inputStream =
            Resources.getResourceAsStream("mybaties.xml");
    SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
    SqlSession sqlSession = sqlSessionFactory.openSession();
    RowBounds rowBounds1 = new RowBounds(0,1);
    List<TempEntity> tempEntity1 = sqlSession  //注意这里的参数列表要对上,第三个参数的位置才是RowBounds 
            .selectList("com.shen.spring.dao.TempDao.list", null,rowBounds1);
    log.info("{}",tempEntity1);
    RowBounds rowBounds2 = new RowBounds(0,2);//两次参数不同
    List<TempEntity> tempEntity2 = sqlSession 
            .selectList("com.shen.spring.dao.TempDao.list",null, rowBounds2);
    log.info("{}",tempEntity2);
    System.out.println(tempEntity1 == tempEntity2);
}

结果如下:

执行的sql也刚好验证了是查询了整个数据,然后进行物理分页。

在这里插入图片描述

4_sql 语句

要求传递给JDBC的SQL语句必须完全相同

看如下案例:

mapper.xml文件,注意这两个查询的查询结果完全一样,因为1=1必然是真,不过Mybatis是不考虑这个的:

<select id="getById4" parameterType="java.util.Map" resultType="com.shen.spring.entity.TempEntity">
    <if test="type == 1">
        select * from temp where id = #{id}
    </if>
    <if test="type == 2">
        select * from temp where 1=1 and id = #{id}
    </if>
</select>

dao层接口中的方法

TempEntity getById4(Map map);

测试代码:

@Test
public void test4() throws IOException {
    InputStream inputStream =
            Resources.getResourceAsStream("mybaties.xml");
    SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
    SqlSession sqlSession = sqlSessionFactory.openSession();
    Map<String,Object> params1 = new HashMap<>();
    params1.put("id",1);
    params1.put("type",1);
    TempEntity tempEntity1 = sqlSession
            .selectOne("com.shen.spring.dao.TempDao.getById4", params1);
    Map<String,Object> params2 = new HashMap<>();
    params2.put("id",1);
    params2.put("type",2);//根据动态sql会执行不同的sql语句
    log.info("{}",tempEntity1);
    TempEntity tempEntity2 = sqlSession
            .selectOne("com.shen.spring.dao.TempDao.getById4", params2);
    log.info("{}",tempEntity2);
    System.out.println(tempEntity1 == tempEntity2);
}

运行结果:

在这里插入图片描述

5_环境

要求执行环境必须相同

其实我们在mapper.xml中 可以配置多个环境:

在这里插入图片描述

并且,在使用 SqlSessionFactoryBuilder().build(??) 方法构建 SqlSessionFactory 对象时其实还可以指定所使用环境。

public SqlSessionFactory build(InputStream inputStream, String environment) {
    return this.build((InputStream)inputStream, environment, (Properties)null);
}

不过这里我们无法进行测试,因为环境的切换,必然会导致创建不同的SqlSession对象。不过二级缓存中可以执行这个测试。

3_一级缓存的生命周期

学习到现在是不是一值有个疑惑? Mybatis一级缓存是什么时候产生的?又是什么时候销毁的?我们向下继续进行了解。

1_缓存的产生

我们的第一印象是:

在这里插入图片描述

执行<select/>类型的statement的时候会产生缓存。

但是真的是这样么?我们进行如下测试–使用update方法调用<select\>标签:

@Test
public void testSelectAsUpdate() throws IOException {
    InputStream inputStream =
            Resources.getResourceAsStream("mybaties.xml");
    SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
    SqlSession sqlSession = sqlSessionFactory.openSession();
    sqlSession
            .update("com.shen.spring.dao.TempDao.getById", 1);
    sqlSession
            .update("com.shen.spring.dao.TempDao.getById", 1);
}

执行结果:

在这里插入图片描述

根据执行的结果可以知道,产生缓存的实际上是SqlSession的select方法:

在这里插入图片描述

2_缓存的销毁

缓存创建了之后又是如何被销毁的呢?先说结论:

  • 当前的 SqlSession 关闭。
  • 执行 INSERTUPDATEDELETE 操作(与表无关)。
  • 调用了 SqlSessionclearCache() 方法主动清除缓存。
  • 调用了 SqlSessionCommit()Rollback

接下来进行验证:

SqlSession 关闭缓存会被销毁

由于SqlSession会话关闭之后再次使用SqlSession会报错,想要进行验证需要特殊的方法。

通过debug的方式查看,这个LocalCache(一个HashMap)就是我们一级缓存存储的地方:

在这里插入图片描述

了解了类图结构之后,我们就可以通过反射的方式拿到一级缓存。

在这里插入图片描述

测试代码:

@Test
public void test5() throws IOException, NoSuchFieldException, IllegalAccessException {
    InputStream inputStream =
            Resources.getResourceAsStream("mybaties.xml");
    SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
    SqlSession sqlSession = sqlSessionFactory.openSession();
    // 第一次查询,会从数据库获取数据
    TempEntity tempEntity1 = sqlSession
            .selectOne("com.shen.spring.dao.TempDao.getById", 1);
    log.info("{}",tempEntity1);
    //从sqlSession的实现类DefaultSqlsession的成员变量中拿到cachingExecutor
    Field executorField = sqlSession.getClass().getDeclaredField("executor");
    executorField.setAccessible(true);
    CachingExecutor cachingExecutor =
            (CachingExecutor) executorField.get(sqlSession);//从实例中获取
    //从CachingExecutor的成员变量中拿到SimpleExecutor
    Field declaredField =
            cachingExecutor.getClass().getDeclaredField("delegate");
    declaredField.setAccessible(true);
    SimpleExecutor simpleExecutor =
            (SimpleExecutor) declaredField.get(cachingExecutor);
    //从SimpleExecutor中的父类中的成员变量中拿到PerpetualCache字段
    Field localCacheField =
            simpleExecutor.getClass().getSuperclass().getDeclaredField("localCache");
    localCacheField.setAccessible(true);
    PerpetualCache perpetualCache = (PerpetualCache) localCacheField.get(simpleExecutor);
    //最后从perpetualCache中拿到存储缓存的Map
    Field cacheField = perpetualCache.getClass().getDeclaredField("cache");
    cacheField.setAccessible(true);
    Map<Object,Object> map= (Map<Object, Object>) cacheField.get(perpetualCache);
    //遍历结果
    Set<Map.Entry<Object, Object>> entries = map.entrySet();
    int size1 = entries.size();
    System.out.println(size1);
    if (size1>0){
        for (Map.Entry<Object, Object> entry : entries) {
            log.info("Map中数据结果为:{} = {}",entry.getKey(),entry.getValue());
        }
    }
    sqlSession.close();
    //会话关闭后再次遍历
    int size2 = entries.size();
    System.out.println(size2);
    if (size2>0){
        for (Map.Entry<Object, Object> entry : entries) {
            log.info("Map中数据结果为:{} = {}",entry.getKey(),entry.getValue());
        }
    }
}

运行结果,sqlSession关闭后缓存使用的map中没有元素了,恰好证明了第一个结论:

在这里插入图片描述

这里其他的情况并不像第一条这样比较特殊:第二第三条感兴趣的可以自行进行验证,参考我们之前的代码,这里我就不一一验证了。

对第二条的 “与表无关” 进行一个解释,这里的与表无关指的是即使我们执行 INSERTUPDATEDELETE 操作与<Select/>的不是同一张表也会清除缓存。

<select id="getById" parameterType="int" resultType="com.shen.spring.entity.TempEntity">
    select * from temp where id = #{id}
</select>
<select id="getById2" parameterType="int" resultType="com.shen.spring.entity.TempEntity">
    select * from test where id = #{id}
</select>
@Test
public void test9() throws IOException {
    //读取xml文件
    InputStream inputStream =
            Resources.getResourceAsStream("mybaties.xml");
    //通过SqlSessionFactoryBuilder创建sqlSessionFactory
    SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
    //拿到执行sql语句的一个会话
    SqlSession sqlSession = sqlSessionFactory.openSession();
    // 第一次查询,会从数据库获取数据
    TempEntity tempEntity1 = sqlSession
            .selectOne("com.shen.spring.dao.TempDao.getById", 1);
    log.info("{}",tempEntity1);
    //执行更新操作,并且与Session1操作的不是同一个表 这里是test表
    sqlSession
            .update("com.shen.spring.dao.TempDao.getById2", 1);
    // 第二次查询,会从缓存获取数据
    TempEntity tempEntity2 = sqlSession
            .selectOne("com.shen.spring.dao.TempDao.getById", 1);
    log.info("{}",tempEntity2);
    System.out.println(tempEntity1 == tempEntity2);
}

运行结果:

在这里插入图片描述

至于第三条,在执行 SqlSession.clearCache()之后,缓存会被清空,第二次查询会查数据库。

3_网传的一些谣言

有些人总说Mybatis一级缓存会存在脏读问题,这是不正确的,仔细想想设计的人如果发现有这么大的问题会不会让一级缓存一直默认开启,肯定是不能接受的。

相反,我认为它反而解决了脏读的问题。我们看如下例子:

在这里插入图片描述

根据如上图不难猜到网上的错误理解:由于Session2已经将张三的年龄由18岁修改为20岁,而Session1第二次查询由于查询的是缓存所以得到的张三年龄是18岁,产生了脏读(这里的脏读不是指的数据库中的那种查到了未提交的数据,而是网上的错误理解即:mybatis查询不到最新的数据,也就是查询到了以前的脏数据)。

首先强调,由于关闭Session、执行Commit、执行RollBack都会清空Mybatis一级缓存,所以实际上:Mybatis一级缓存的生命周期是在数据库事务的生命周期之内的

我们先回顾一下数据库事务中的脏读问题,如下图:

在这里插入图片描述
数据库中的脏读(读未提交)概念如下:事务1读取了事务2修改但是尚未提交的数据,如果事务2发生回滚则事务1读取的数据就变成了错误数据,也称为脏数据

我们看看两次的对比:

在这里插入图片描述

所以这里我们可以发现,反而mybatis解决了数据库事务的脏读问题,即使数据库事务发生脏读,mybatis也不会发生脏读问题。

而且我们到现在也可以很容易看出来(根据第一张图,查不到Session2更新并提交的数据),一级缓存甚至解决了不可重复读和幻读的问题

总结:

问题read uncommittedread committedrepeatable readserializableMyBatis 一级缓存
丢失更新避免避免避免避免
脏读避免避免避免避免避免
不可重复读避免避免避免避免
幻读避免避免避免

4_一级缓存核心源码

接下来笔者将带领大家了解Mybatis的核心源码,加深对Mybatis一级缓存的理解。

还记得我们之前画的那张类图吗?SqlSession调用 SelectOne方法其实调用的是它的默认实现也就是 DefaultSqlSession中的方法(并且可以看到selectOne其实调用的是selectList方法):

public <T> T selectOne(String statement, Object parameter) {
	//调用了selectList方法
    List<T> list = this.selectList(statement, parameter);
    if (list.size() == 1) {
        return list.get(0);
    } else if (list.size() > 1) {
        throw new TooManyResultsException("Expected one result (or null) to be returned by selectOne(), but found: " + list.size());
    } else {
        return null;
    }
}

在 selectList调用链的最深处可以看到这个方法:

private <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds, ResultHandler handler) {
    List var6;
    try {
    	//将我们定义的mapper.xml中的内容转换成对象 即 com.shen.spring.dao.TempDao.getById
        MappedStatement ms = this.configuration.getMappedStatement(statement);
        var6 = this.executor.query(ms, this.wrapCollection(parameter), rowBounds, handler);
    } catch (Exception var10) {
        Exception e = var10;
        throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
    } finally {
        ErrorContext.instance().reset();
    }
    return var6;
}

this.executor.query这段关键代码所在的位置在Executor的实现类CachingExecutor中:

public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    BoundSql boundSql = ms.getBoundSql(parameterObject);
    //这段代码是Mybatis缓存生成key依赖的参数,也就是我们一级缓存的命中原则
    CacheKey key = this.createCacheKey(ms, parameterObject, rowBounds, boundSql);
    return this.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

这里的 CacheKey 创建策略源码在BaseExecutor.class中:

public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSq
    if (this.closed) {
        throw new ExecutorException("Executor was closed.");
    } else {
        CacheKey cacheKey = new CacheKey();
        //update是对传递的对像的hashcode进行混合运算
        //StatementId
        cacheKey.update(ms.getId());
        //分页参数
        cacheKey.update(rowBounds.getOffset());
        cacheKey.update(rowBounds.getLimit());
        //sql语句
        cacheKey.update(boundSql.getSql());
        //拿到方法的参数,对方法参数进行循环
        List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
        TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
        Iterator var8 = parameterMappings.iterator();
        while(var8.hasNext()) {
            ParameterMapping parameterMapping = (ParameterMapping)var8.next();
            if (parameterMapping.getMode() != ParameterMode.OUT) {
                String propertyName = parameterMapping.getProperty();
                Object value;
                if (boundSql.hasAdditionalParameter(propertyName)) {
                    value = boundSql.getAdditionalParameter(propertyName);
                } else if (parameterObject == null) {
                    value = null;
                } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
                    value = parameterObject;
                } else {
                    MetaObject metaObject = this.configuration.newMetaObject(parameterObject);
                    value = metaObject.getValue(propertyName);
                }
                //方法参数
                cacheKey.update(value);
            }
        }

除了 CacheKey 我们可以看到下面还有一个查询的方法this.query:

public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
	//这个Cache 是Mybatis二级缓存的内容 我们没有开启肯定是个null
    Cache cache = ms.getCache();
    if (cache != null) {
        ````
    }
    //还是会走到此处 delegate是个装饰模式 Executor类型,  实现类是SimpleExecutor(SimpleExecutor extends BaseExecutor)
    return this.delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

虽然 this.delegate.query中 delegate的实现类是SimpleExecutor 但是,SimpleExecutor 中并没有query方法。

SimpleExecutor extends BaseExecutor ,所以此方法在BaseExecutor 中。

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(
    if (this.closed) {
        throw new ExecutorException("Executor was closed.");
    } else {
        if (this.queryStack == 0 && ms.isFlushCacheRequired()) {
            this.clearLocalCache();
        }
        List list;
        try {
            ++this.queryStack;
            //此处为关键代码,会查询一级缓存
            list = resultHandler == null ? (List)this.localCache.getObject(key) : null;
            if (list != null) {
                this.handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
            } else {
            	//如果一级缓存不命中走此处,查询数据库
                list = this.queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, 
            }
        } finally {
            --this.queryStack;
        }
        if (this.queryStack == 0) {
           ····略
        }
        return list;
    }
}

this.localCache是个 PerpetualCache类的实例,内部是使用HashMap维护缓存结构的,他的内部结构与getObject方法的代码如下:

ublic class PerpetualCache implements Cache {
    private final String id;
    //用来维护缓存的HashMap
    private final Map<Object, Object> cache = new HashMap();

    public PerpetualCache(String id) {
        this.id = id;
    }

    public String getId() {
        return this.id;
    }

    public int getSize() {
        return this.cache.size();
    }

    public void putObject(Object key, Object value) {
        this.cache.put(key, value);
    }
	//getObject方法
    public Object getObject(Object key) {
        return this.cache.get(key);
    }
}

根据核心源码解读对类图的补充,蓝色部分是主要的接口,其余的类都是它们的实现类:

在这里插入图片描述

缓存销毁中close方法的源码,在BaseExecutor.class中:

public void close(boolean forceRollback) {
    try {
        try {
            this.rollback(forceRollback);
        } finally {
            if (this.transaction != null) {
                this.transaction.close();
            }
        }
    } catch (SQLException var11) {
        SQLException e = var11;
        log.warn("Unexpected exception on closing transaction.  Cause: " + e);
    } finally {
        this.transaction = null;
        this.deferredLoads = null;
        //清空缓存
        this.localCache = null;
        this.localOutputParameterCache = null;
        this.closed = true;
    }
}

commit提交方法的源码也在 BaseExecutor.class中:

public void commit(boolean required) throws SQLException {
    if (this.closed) {
        throw new ExecutorException("Cannot commit, transaction is already closed");
    } else {
    	//清除缓存的代码
        this.clearLocalCache();
        this.flushStatements();
        if (required) {
            this.transaction.commit();
        }
    }
}

可以看到commit方法调用了this.clearLocalCache()方法清除缓存:

public void clearLocalCache() {
	//当前会话未被关闭
    if (!this.closed) {
    	//调用map的clear方法
        this.localCache.clear();
        this.localOutputParameterCache.clear();
    }
}

update更新操作也会调用clearLocalCache()方法清空缓存,而 Insertdelect方法内部会调用update方法执行任务。

5_总结

一级缓存也叫本地缓存,它默认会启用,并且不能关闭。一级缓存存在于SqlSession的生命周期中,即它是SqlSession级别的缓存。在同一个 SqlSession 中查询时,MyBatis 会把执行的方法和参数通过算法生成缓存的键值,将键值和查询结果存入一个Map对象中。如果同一个SqlSession 中执行的方法和参数完全一致,那么通过算法会生成相同的键值,当Map 缓存对象中己经存在该键值时,则会返回缓存中的对象。

其设计理念即在一个Session内:

  • 不过期
  • 不更新
  • 不限制
  • 一般情况下Session生存时间很短
  • 执行Update会销毁缓存,而不是更新缓存
  • 支持主动销毁缓存

不限制是指如果一直缓存是没有上限的,可能撑爆jvm内存。

与Spring集成的时候SqlSession的相关操作权不在我们的手中,所以有一些注意事项,可以自己尝试:

如果与Spring集成的时候如果没有开启事务,在一个方法内,每次请求,Spring都会关闭旧的session再创建新的session,此时一级缓存无效。

开启事务,在一个事务内,Spring通过ThreadLocal始终使用同一个Session,所以此时一级缓存在事务内有效。

三、二级缓存

存在弊端,实际使用的人比较少。

二级缓存存在于SqlSessionFactory 的生命周期中,即它是SqlSessionFactory级别的缓存。在多个 SqlSession 之间共享缓存数据。

1_开启二级缓存

需要在MyBatis 的全局配置settings 中有一个参数cacheEnabled,这个参数是二级缓存的全局开关,默认值
是true ,初始状态为启用状态。Mybatis.xml:

在这里插入图片描述

<settings>
    <setting name="cacheEnabled" value="true"/>
</settings>

MyBatis 的二级缓存是和命名空间绑定的,即二级缓存需要配置在Mapper.xml 映射文件中。在保证二级缓存的全局配置开启的情况下,给Mapper.xml 开启二级缓存只需要在Mapper. xml 中添加如下代码:

<cache />

在这里插入图片描述

配置缓存刷新间隔、大小、读写策略等(可选):

<cache eviction="FIFO" flushInterval="60000" size="512" readOnly="true"/>
  • eviction:缓存回收策略,默认值为 LRU(Least Recently Used)。
  • flushInterval:缓存刷新间隔时间(毫秒)。
  • size:缓存大小。
  • readOnly:是否只读。

返回值对象需要实现Serializable接口,如:TempEntity implements Serializable。

如果成功,可以在控制台看到二级缓存的打印日志:

在这里插入图片描述

2_二级缓存命中原则

先说结论:与一级缓存的命中原则一模一样。一级缓存中的5条放在这里也是适用的。

可自行测试。测试的时候不要忘了使用同一个SqlSessionFactory创建不同的SqlSession进行测试,并且进行第5条环境测试的时候别忘了这里的流不能读两次。

3_二级缓存的产生

  1. 满足一级缓存产生的条件
  2. Close Session 或者 Commit Session。(不包括别的如:rollback)
  3. <select/>标签中没有设置 userCache = "false

在这里插入图片描述

自行验证哦。

4_缓存失效情况

二级缓存会在以下几种情况下被销毁:

  • 执行 INSERTUPDATEDELETE 操作。刷新缓存的参数flushCache没有设置成false(默认为true)
  • 配置了缓存刷新间隔时间,时间到了缓存会自动失效。

rollback 不会销毁缓存。

eviction清除策略配置参数

  • LRU:最近最少使用,移除最长时间不被使用的对象。 —> LinlHashMap。
  • FIFO:先进先出,按对象进入缓存的顺序来移除它们。-----> LinkedList。
  • SOFT:软引用,基于GC和软引用规则移除对象。------>SoftReference。
  • Weak:弱引用,基于GC和弱引用规则移除对象。------>WeakReference。

这些清除策略mybatis内都有对应的源码实现,XXXXCache。

size引用数目配置参数: 缓存引用的最大数目,默认1024.

还有个需要注意的点,MyBatis 的二级缓存是和命名空间(mapper)绑定的。如果存在两个不同的mapper.xml管理dao层操作。执行更新的是mapper1,不会将mapper2管理的缓存清空。

比如下方示例,TestMapper操作test表,TempMapper操作temp表,一个mapper下的sqlSession的更新不会导致整个SqlSessionFactory的失效:

@Test
public void test8() throws IOException {
    //读取xml文件
    InputStream inputStream =
            Resources.getResourceAsStream("mybaties.xml");
    //通过SqlSessionFactoryBuilder创建sqlSessionFactory
    SqlSessionFactory sqlSessionFactory =
            new SqlSessionFactoryBuilder().build(inputStream);
    //拿到执行sql语句的第一个会话
    SqlSession sqlSession1 = sqlSessionFactory.openSession();
    // 第一次查询,会从数据库获取数据 操作temp表
    TempEntity tempEntity1 = sqlSession1
            .selectOne("com.shen.spring.dao.TempDao.getById", 1);
    log.info("{}",tempEntity1);
    //关闭会话,并保存二级缓存
    sqlSession1.commit();
    //执行更新操作
    sqlSession1
            .update("com.shen.spring.dao.TestDao.getById", 1);
    sqlSession1.commit();
    //拿到第二个会话
    SqlSession sqlSession2 = sqlSessionFactory.openSession();
    // 第二次查询,会从缓存获取数据
    TempEntity tempEntity2 = sqlSession2
            .selectOne("com.shen.spring.dao.TempDao.getById", 1);
    log.info("{}",tempEntity2);
    System.out.println(tempEntity1 == tempEntity2);//虽然命中缓存会但是会输出false,由于序列化的关系
}

返回结果,虽然命中缓存会但是会输出false,由于序列化的关系。输出两次sql日志:

在这里插入图片描述

如果两个mapper操作同一个表也是一样的,一个mapper更新也不会让另一个mapper销毁缓存。

无论是单线程、多线程、多实例的情况下都会产生脏读。(一级缓存没有脏读问题)

不能跨SqlSessionFactory的原因 缓存不能跨SqlSessionFactory:

public class DefaultSqlSessionFactory implements SqlSessionFactory {
    private final Configuration configuration;
}
public class Configuration {
    ····
    protected final Map<String, MappedStatement> mappedStatements;
    protected final Map<String, Cache> caches;
    ····

    public Configuration(Environment environment) {
        this();
        this.environment = environment;
    }

    public Configuration() {
        ···
        this.caches = new StrictMap("Caches collection");
}

而且多个SqlSessionFactory会存在脏读,可以使用自定义缓存+redis解决。

5_二级缓存核心源码

我们之前查看一级缓存时,发现过一段二级缓存的代码: Cache cache = ms.getCache();

public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
	//这个Cache 是Mybatis二级缓存的内容 我们没有开启肯定是个null
    Cache cache = ms.getCache();
    if (cache != null) {
        this.flushCacheIfRequired(ms);//缓存刷新属性如果设置了,进行的一些动作
        if (ms.isUseCache() && resultHandler == null) {//看看标签中是否禁用了缓存 resultHandler默认就是null
            this.ensureNoOutParams(ms, boundSql);//如果开启二级缓存要求我们没有输出参数,不然方法里面会报错。
            List<E> list = (List)this.tcm.getObject(cache, key);//查询二级缓存
            if (list == null) {
            	//跟最下面的一样是一级缓存的过程
                list = this.delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
                this.tcm.putObject(cache, key, list);//查询出来结果放入二级缓存中
            }
            return list;
        }
    }
    //还是会走到此处 delegate是个装饰模式 Executor类型,  实现类是SimpleExecutor(SimpleExecutor extends BaseExecutor)
    return this.delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

从中可以看到,是先走二级缓存,查询不到才走一级缓存的逻辑。并且 tcm 是存储二级缓存的对象 TransactionalCacheManager 类型,结构如下:

public class TransactionalCacheManager {
    private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap();
    ····
    public void putObject(Cache cache, CacheKey key, Object value) {
    	//放入缓存的方法
    	this.getTransactionalCache(cache).putObject(key, value);
	}
	//最终调用此方法
	private TransactionalCache getTransactionalCache(Cache cache) {
		//如果没有则新建一个
        return (TransactionalCache)MapUtil.computeIfAbsent(this.transactionalCaches, cache, TransactionalCache::new);
    }
}

TransactionalCacheManager 对象中维护了一个 HashMapkey是一个java的Cache接口,其实就是我们维护的一个个开启了二级缓存的Mapper文件所对应的缓存对象,value是个TransactionalCache 就是针对一级缓存所使用的那些Cache的实现类的进一步包装(还是装饰模式)。

TransactionalCache 的功能就是,将我们查询到的结果不是直接放到缓存对象中,而是放入entriesToAddOnCommit 中。

public class TransactionalCache implements Cache {
    private static final Log log = LogFactory.getLog(TransactionalCache.class);
    private final Cache delegate;
    private boolean clearOnCommit;//执行commit时清空缓存
    private final Map<Object, Object> entriesToAddOnCommit;//当我们执行commit时即将放入二级缓存中的对象
    private final Set<Object> entriesMissedInCache;//当前session未命中的缓存
	
	//更新操作会调用此方法
	public void clear() {
		//commit时会用到这个参数 
    	this.clearOnCommit = true;
    	//仅仅清空本次执行的二级缓存,让其无效
    	this.entriesToAddOnCommit.clear();
	}

	//提交后会调用到此方法
	public void commit() {
		//如果clear方法被调用,clearOnCommit会变成true,执行清空二级缓存操作
        if (this.clearOnCommit) {
        	//执行清空二级缓存的操作
            this.delegate.clear();
        }
		//调用此方法
        this.flushPendingEntries();
        this.reset();
    }
    
    private void flushPendingEntries() {
        Iterator var1 = this.entriesToAddOnCommit.entrySet().iterator();
		//对map结构进行循环,根据key和value,让其放入了我们二级缓存最终保存的对象中
        while(var1.hasNext()) {
            Map.Entry<Object, Object> entry = (Map.Entry)var1.next();
            this.delegate.putObject(entry.getKey(), entry.getValue());
        }

        var1 = this.entriesMissedInCache.iterator();

        while(var1.hasNext()) {
            Object entry = var1.next();
            if (!this.entriesToAddOnCommit.containsKey(entry)) {
                this.delegate.putObject(entry, (Object)null);
            }
        }

    }
}

上述类中的commit方法,再SqlSessionclose时也会被调用。

二级缓存的类图,右侧是三个接口,与一级缓存的类图一样

在这里插入图片描述

五、缓存机制的注意事项

  1. 一致性问题:缓存会导致数据一致性问题,尤其是在高并发环境下,需要谨慎使用。
  2. 数据更新:当数据发生变化时,需要及时更新缓存,以免返回过期数据。
  3. 缓存策略:根据具体业务需求配置合理的缓存策略,如缓存回收策略、刷新间隔等。
  4. 序列化:二级缓存中的对象需要实现序列化接口,以确保缓存对象能够正确存储和读取。

六_总结

一级缓存也叫本地缓存,它默认会启用,并且不能关闭。一级缓存存在于SqlSession的生命周期中,即它是SqlSession级别的缓存。在同一个 SqlSession 中查询时,MyBatis 会把执行的方法和参数通过算法生成缓存的键值,将键值和查询结果存入一个Map对象中。如果同一个SqlSession 中执行的方法和参数完全一致,那么通过算法会生成相同的键值,当Map 缓存对象中己经存在该键值时,则会返回缓存中的对象。

二级缓存存在于SqlSessionFactory 的生命周期中,即它是SqlSessionFactory级别的缓存也是 Mapper 映射级别的缓存,需要配置并适用于多个 SqlSession 共享缓存数据。在使用缓存时需要注意数据的一致性问题,根据实际需求选择合适的缓存策略和配置。

可以使用redis和mybatis整合让二级缓存到redis中而不是jvm中。

通过合理使用 MyBatis 缓存机制,可以显著提高系统的性能,降低数据库的负载,是优化 MyBatis 应用性能的重要手段之一。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1944143.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

# OpenCV 图像预处理—形态学:膨胀、腐蚀、开运算、闭运算 原理详解

文章目录 形态学概念膨胀使用膨胀操作来修复裂痕示例代码关键解析&#xff1a; 腐蚀使用腐蚀操作消除噪点示例代码&#xff1a; 开运算—先腐蚀后膨胀闭运算—先膨胀后腐蚀 形态学概念 首先看这两张图片 一张图周围有大大小小的噪音和彩点&#xff0c;另一张图片中字母有间隙&…

安宝特方案|解放双手,解决死角,AR带来质量监督新体验

AR质量监督 解放双手&#xff0c;解决死角 在当今制造业快速发展的背景下&#xff0c;质量监督成为确保产品高质量和完善的管理制度的关键环节。然而&#xff0c;传统的质量监督方式存在诸多挑战&#xff0c;如人工操作带来的效率低下、查岗不及时、摄像头死角等问题。 为了解…

el-upload照片墙自定义上传多张图片(手动一次性上传多张图片)包含图片回显,删除

需求&#xff1a;el-upload照片墙自定义上传多张图片&#xff08;手动一次性上传多张图片&#xff09;包含图片回显&#xff0c;删除&#xff0c;预览&#xff0c;在网上看了很多&#xff0c;都没有说怎么把数据转为file格式的&#xff0c;找了很久最终实现&#xff0c; 难点&a…

Java之数组应用-选择排序-插入排序

已经完全掌握了冒泡排序和二分查找的同学&#xff0c;可以自己尝试学习选择、插入排序。不要求今天全部掌握&#xff0c;最近2-3天掌握即可&#xff01; 1 选择排序 选择排序(Selection Sort)的原理有点类似插入排序&#xff0c;也分已排序区间和未排序区间。但是选择排序每次…

《峡谷小狐仙-多模态角色扮演游戏助手》复现流程

YongXie66/Honor-of-Kings_RolePlay: The Role Playing Project of Honor-of-Kings Based on LnternLM2。峡谷小狐仙--王者荣耀领域的角色扮演聊天机器人&#xff0c;结合多模态技术将英雄妲己的形象带入大模型中。 (github.com) https://github.com/chg0901/Honor_of_Kings…

盘点2024年大家都在使用的AI智能写作工具

在科技发达的现在社会&#xff0c;AI已经悄悄的渗入我们生活的各种角落。不知道你有没有尝试过用ai智能写作来完成一些文章创作呢&#xff1f;这次我介绍几个可以提升效率的ai智能写作工具给你试试吧。 1.笔&#xff5c;灵AI写作 CSDN 传送门&#xff1a;https://ibiling.cn…

Interesting bug caused by getattr

题意&#xff1a;由 getattr 引起的有趣的 bug 问题背景&#xff1a; I try to train 8 CNN models with the same structures simultaneously. After training a model on a batch, I need to synchronize the weights of the feature extraction layers in other 7 models. …

Vue3+Element Plus 实现table表格中input的验证

实现效果 html部分 <template><div class"table"><el-form ref"tableFormRef" :model"form"><el-table :data"form.detailList"><el-table-column type"selection" width"55" align&…

初识c++(string和模拟实现string)

一、标准库中的string类 string类的文档介绍&#xff1a;cplusplus.com/reference/string/string/?kwstring 1、auto和范围for auto&#xff1a; 在早期C/C中auto的含义是&#xff1a;使用auto修饰的变量&#xff0c;是具有自动存储器的局部变量&#xff0c;后来这个 不重…

【北航主办丨本届SPIE独立出版丨已确认ISSN号】第三届智能机械与人机交互技术学术会议(IHCIT 2024,7月27)

由北京航空航天大学指导&#xff0c;北京航空航天大学自动化科学与电气工程学院主办&#xff0c;AEIC学术交流中心承办的第三届智能机械与人机交互技术学术会议&#xff08;IHCIT 2024&#xff09;将定于2024年7月27日于中国杭州召开。 大会面向基础与前沿、学科与产业&#xf…

初识c++:string类 (1)

目录 # 初识c&#xff1a;string类 1.为什么学习string类 2.标准库中的string类 2.1 string类的了解 2.2 auto和范围for 2.3 string类的常用接口说明 2.3.1string类对象的常见构造 2.3.2string类对象的容量操作 2.3.3string类对象的访问及遍历操作 2.3.4string类对象…

DNS概述及DNS服务器的搭建(twelve day)

回顾 关闭防火墙 systemctl stop firewalld 永久停止防火墙 systemctl disable firewalld 关闭selinux setenforce 0 永久关闭selinux安全架构 vim /etc/selinux/config 修改静态IP地址 vim /etc/sysconfig/network-scripts/ifcfg-ens160 #修改uuid的目的是为了保证网络的唯一…

计算机的错误计算(四十)

摘要 计算机的错误计算&#xff08;三十九&#xff09;阐明有时计算机将0算成非0&#xff0c;非0算成0&#xff1b;并且前面介绍的这些错误计算相对来说均是由软件完成。本节讨论手持式计算器对这些算式的计算效果。 例1. 用手持式计算器计算 与 . 我们用同一个计算器计算…

机械学习—零基础学习日志(高数10——函数图形)

零基础为了学人工智能&#xff0c;真的开始复习高数 函数图像&#xff0c;开始新的学习&#xff01;本次就多做一做题目&#xff01; 第一题&#xff1a; 这个解法是有点不太懂的了。以后再多研究一下。再出一道题目。 张宇老师&#xff0c;比较多提示了大家&#xff0c;一定…

哪些工作可以年入几十万到2亿?

关注卢松松&#xff0c;会经常给你分享一些我的经验和观点。 从今年起&#xff0c; 每个月都会有年入几十万到2亿的新闻案例出来&#xff0c;而且很多都是官方媒体发的&#xff0c;你们看看&#xff1a; 7月19日35岁小伙扛楼一年多存了40万 7月4日老板娘一天卖出200斤知了日入…

Leetcode3217. 从链表中移除在数组中存在的节点

Every day a Leetcode 题目来源&#xff1a;3217. 从链表中移除在数组中存在的节点 解法1&#xff1a;集合 链表遍历 代码&#xff1a; /** lc appleetcode.cn id3217 langcpp** [3217] 从链表中移除在数组中存在的节点*/// lc codestart /*** Definition for singly-link…

docker--容器数据进行持久化存储的三种方式

文章目录 为什么Docker容器需要使用持久化存储1.什么是Docker容器&#xff1f;2.什么是持久化存储&#xff1f;3.为什么Docker容器需要持久化存储&#xff1f;4.Docker如何实现持久化存储&#xff1f;(1)、Docker卷(Volumes)简介适用环境:使用场景:使用案例: (2)、绑定挂载&…

Python 实现PDF和TIFF图像之间的相互转换

PDF是数据文档管理领域常用格式之一&#xff0c;主要用于存储和共享包含文本、图像、表格、链接等的复杂文档。而TIFF&#xff08;Tagged Image File Format&#xff09;常见于图像处理领域&#xff0c;主要用于高质量的图像文件存储。 在实际应用中&#xff0c;我们可能有时需…

哪个邮箱最安全最好用啊

企业邮箱安全至关重要&#xff0c;需保护隐私、防财务损失、维护通信安全、避免纠纷&#xff0c;并维持业务连续性。哪个企业邮箱最安全好用呢&#xff1f;Zoho企业邮箱&#xff0c;采用加密技术、反垃圾邮件和病毒保护&#xff0c;支持多因素认证&#xff0c;确保数据安全合规…

php的文件上传

&#x1f3bc;个人主页&#xff1a;金灰 &#x1f60e;作者简介:一名简单的大一学生;易编橙终身成长社群的嘉宾.✨ 专注网络空间安全服务,期待与您的交流分享~ 感谢您的点赞、关注、评论、收藏、是对我最大的认可和支持&#xff01;❤️ &#x1f34a;易编橙终身成长社群&#…