mybatis的一级二级缓存详解及源码解剖

news/2024/7/3 2:06:52

文章目录

  • 什么是一级缓存?
  • 什么是二级缓存?
  • 一级缓存二级缓存有什么不同?
  • 执行流程
  • 源码流程解剖
  • 一级缓存失效场景分析
  • 二级缓存结构及需要解决的问题
  • 二级缓存执行流程
  • 二级缓存获取和commit源码解剖
  • 总结


什么是一级缓存?

一级缓存是指在同一个SqlSession中,对于相同的查询语句和参数,第一次查询的结果会被缓存到内存中,后续的查询会直接从缓存中获取结果,而不会再次查询数据库。一级缓存是MyBatis默认开启的,可以通过在SqlSession中调用clearCache()方法来清空缓存。


什么是二级缓存?

二级缓存是指在多个SqlSession中,对于相同的查询语句和参数,第一次查询的结果会被缓存到内存中,后续的查询会直接从缓存中获取结果,而不会再次查询数据库。二级缓存是需要手动开启的,可以通过在Mapper.xml文件中添加标签来开启。二级缓存的作用范围是Mapper级别的,也就是说,同一个Mapper.xml文件中的查询语句会共享同一个缓存。


一级缓存二级缓存有什么不同?

在这里插入图片描述

一级缓存和二级缓存的不同点在于作用范围和生命周期。一级缓存的作用范围是SqlSession级别的,生命周期也是和SqlSession一样的,当SqlSession关闭时,缓存也会被清空。而二级缓存的作用范围是Mapper级别的,生命周期是和应用程序一样的,当应用程序关闭时,缓存也会被清空。另外,二级缓存需要手动开启,而一级缓存是默认开启的。


执行流程

在这里插入图片描述

  public void testMybatis()throws Exception{
		
    SqlSessionFactoryBuilder sqlSessionFactoryBuilder=new SqlSessionFactoryBuilder();
    org.springframework.core.io.ClassPathResource classPathResource=new ClassPathResource("org/apache/ibatis/user/mybatis.xml");
    InputStream inputStream = classPathResource.getInputStream();
    // 1.读取配置文件获取SqlSessionFactory 
    SqlSessionFactory sqlSessionFactory= sqlSessionFactoryBuilder.build(inputStream);
    // 2.获取sqlsession
    SqlSession sqlSession = sqlSessionFactory.openSession();
	
	// 3.执行获取结果
    User user = new User(1L, null, null, null);
    UserMapper mapper = sqlSession.getMapper(UserMapper.class);
    List<User> users = mapper.selectUser(user);
    mapper.selectUser(user);
    System.out.println(users);
    }

CachingExecutor 二级缓存执行器

CachingExecutor类直接实现了Excutor接口,是装饰器类,主要用来增强缓存相关功能。在CachingExecutor类中,为了完成缓存相关功能,需要TransactionalCacheManager和TransactionalCache两个类的支持,其中TransactionalCacheManager类主要用来管理缓存数据的对象TransactionalCache,而TransactionalCache对象是用来真正缓存数据的对象。

BaseExecutor 一级缓存执行器

BaseExecutor是MyBatis中所有Executor的基类,它实现了Executor接口中的一些通用方法,例如query()、update()、flushStatements()等。BaseExecutor中定义了一个MappedStatement和CacheKey的Map,用于缓存已经解析过的MappedStatement和CacheKey对象,以提高查询效率。BaseExecutor还定义了一个Transaction对象,用于管理事务的提交和回滚。

SimpleExecutor(默认是SimpleExecutor)每次执行SQL语句都需要进行编译

SimpleExecutor是最简单的Executor实现,每次执行SQL语句时都会创建一个新的Statement对象,执行完毕后立即关闭Statement对象。SimpleExecutor适用于短时间内需要执行大量SQL语句的场景,但是由于每次都需要创建和关闭Statement对象,所以效率相对较低。

ReuseExecutor 执行相同SQL语句时,如果之前SQL已编译,则不会在进行编译。

ReuseExecutor是在SimpleExecutor的基础上进行了优化,它会在执行SQL语句之前先检查是否已经存在可重用的Statement对象,如果存在则直接使用,否则创建一个新的Statement对象。ReuseExecutor适用于需要执行多个相同的SQL语句的场景,可以减少创建和关闭Statement对象的次数,提高效率。

BatchExecutor 批量执行

BatchExecutor是用于批量执行SQL语句的Executor实现,它会将多个SQL语句合并成一个批量操作,然后一次性发送给数据库执行。BatchExecutor适用于需要执行大量相似的SQL语句的场景,可以减少网络传输和数据库操作的次数,提高效率。

在MyBatis中,可以通过在配置文件中设置defaultExecutorType属性来指定默认的Executor实现,也可以在Mapper.xml文件中通过executorType属性来指定特定的Executor实现。


源码流程解剖

一:获取UserMapper

我们先来看看 UserMapper mapper = sqlSession.getMapper(UserMapper.class);这一行代码返回的是一个什么对象。
在这里插入图片描述

1.首先我们可以看到,mybatis通过动态代理模式给我们生成了一个Usermapper对象。

2.在这个mapper对象当中,有一个sqlSession对象,SqlSession里面有一个executor,这个executor是CachingExecutor(这个是二级缓存的CachingExecutor)

3.在CachingExecutor中有一个delegate它是一个SimpleExecutor。(这里就要注意了,因为BaseExecutor是SimpleExecutor的父类,只不过mybatis默认是SimpleExecutor。所以这里delegate其实是指向了一级缓存BaseExecutor)

二:执行UserMapper.selectUser(user)的查询方法

由于我们的UserMapper是动态代理生成的代理对象,那么在执行方法前会先去执行invoke方法

List<User> users = mapper.selectUser(user);

MapperProxy代理对象

  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
      if (Object.class.equals(method.getDeclaringClass())) {
        return method.invoke(this, args);
      }
      // 这个就是调用mapperMethod.execute方法,看当前查询是select、update、delete等等
      // 在根据具体的实现去调用DefaultSqlSession里面的(selectList()、insert()等等)
      return cachedInvoker(method).invoke(proxy, method, args, sqlSession);
    } catch (Throwable t) {
      throw ExceptionUtil.unwrapThrowable(t);
    }
  }

DefaultSqlSession,因为我们查询的是一个数组,所以就调用了selectList()方法

  private <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds, ResultHandler handler) {
    try {
     // 1. 获取一个statement
      MappedStatement ms = configuration.getMappedStatement(statement);
      dirty |= ms.isDirtySelect();
      // 2.执行二级缓存CachingExecutor的query方法
      return executor.query(ms, wrapCollection(parameter), rowBounds, handler);
    } catch (Exception e) {
      throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
    } finally {
      ErrorContext.instance().reset();
    }
  }

CachingExecutor 二级缓存的query方法

  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler)
      throws SQLException {
    // 这就是解析你的SQL,把SQL解析为 select * from user wehre id=?
    // 并且包含了SQL语句和参数列表等信息
    BoundSql boundSql = ms.getBoundSql(parameterObject);
    // CacheKey对象中包含了MappedStatement的ID、查询参数、分页参数等信息,这个比较重要。后面会详细讲解
    CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
    return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

CachingExecutor 二级开始干事情的query方法

  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler,
      CacheKey key, BoundSql boundSql) throws SQLException {
     // 1.获取当前mappernamespace是否配置了<cache/>标签,是否开启了二级缓存 ,从二级缓存空气获取该cache
    Cache cache = ms.getCache();
    if (cache != null) {
    	// 判断是否要清除缓存,只会清空当前线程的缓存,不会影响其他线程的缓存。
      flushCacheIfRequired(ms);
      if (ms.isUseCache() && resultHandler == null) {
       // 检查SQL语句中是否包含输出参数
        ensureNoOutParams(ms, boundSql);
        // 从二级缓存中获取结果
        List<E> list = (List<E>) tcm.getObject(cache, key);
        
        // 从一级缓存中获取结果并将结果存储到二级缓存map当中
        if (list == null) {
          list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        //  从二级缓存中拿出结果,进行返回
        return list;
      }
    }
    // delegate是我们的一级缓存,如果没有开启二级缓存,就直接调用一级缓存获取结果进行返回
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

BaseExecutor的query方法

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(ms.getId());
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }
    List<E> list;
    try {
      queryStack++;
      // 从一级缓存中获取数据
      list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
      if (list != null) {
      // 处理本地缓存中的输出参数,不会对SQL语句进行解析或执行
        handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
      } else {
      	// 一级缓存没有就去数据库查询结构,后面会调用SimpleExecutor的doQuery方法。 
      	// 而且会把查询数据存入一级缓存当中
        list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
      }
    } finally {
      queryStack--;
    }
    // 清除刷新缓存逻辑等等
    if (queryStack == 0) {
      for (DeferredLoad deferredLoad : deferredLoads) {
        deferredLoad.load();
      }
      // issue #601
      deferredLoads.clear();
      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        // issue #482
        clearLocalCache();
      }
    }
    return list;
  }

SimpleExecutor的doQuery

public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler,
      BoundSql boundSql) throws SQLException {
    Statement stmt = null;
    try {
      Configuration configuration = ms.getConfiguration();
      StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler,
          boundSql);
       // 获取连接 ,会调用PooledDataSource.getConnection()
      stmt = prepareStatement(handler, ms.getStatementLog());
      // 查询数据库
      return handler.query(stmt, resultHandler);
    } finally {
      closeStatement(stmt);
    }
  }

StatementHandler.query最后会调用PreparedStatementHandler的query方法

// 看到这里大家就非常熟悉了,和jdbc一样PreparedStatement 执行SQL语句进行返回。
  public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
    PreparedStatement ps = (PreparedStatement) statement;
    ps.execute();
    return resultSetHandler.handleResultSets(ps);
  }

一级缓存失效场景分析

在上面我们从一级缓存获取数据时,都是通过key来获取的。我们来看看这个key到底是啥。

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(ms.getId());
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }
    List<E> list;
    try {
      queryStack++;
      // 从一级缓存中获取数据
      list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
      if (list != null) {
      // 处理本地缓存中的输出参数,不会对SQL语句进行解析或执行
        handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
      } else {
    
  }

从一级缓存获取数据
list = resultHandler == null ? (List) localCache.getObject(key) : null;

这个CacheKey key,前面我也标注了是在二级缓存调用query方法时获取的。那我们来看看这个key里面有啥玩意
在这里插入图片描述
在updateList中有6参数我们来依次看看啥意思。
参数 0:指向的就是我们statementID
参数1:分页数值
参数2:分页数值
参数3:编译后的SQL语句
参数4:设置的参数值
参数5:就是你配置数据源时的default值

<environments default="development">
    <environment id="development">
      <transactionManager type="JDBC"/>
      <dataSource type="POOLED">
        <property name="driver" value="com.mysql.jdbc.Driver"/>
        <property name="url" value=""/>
        <property name="username" value=""/>
        <property name="password" value=""/>
      </dataSource>
    </environment>
  </environments>

那么我们就可以总结一级缓存有哪些失效场景了。(修改,新增操作就忽略)

  1. 必须在同一个statementID的语句中执行
  2. SQL语句和参数值必须一致。
  3. 分页条件必须一致
  4. 必须在同一个会话中,在上面代码中我们也可以看到。我们调用mapper.selectUser(user);是会执行sqlSession的excutor方法。如果sqlSession关闭了,那么里面的一级执行器,当然也就不会在存在了。

这里可能有疑问,sqlSession什么时候销毁呢?

戳这里,在spring中sqlSession的创建及销毁


二级缓存结构及需要解决的问题

首先我们需要考虑的是,既然二级缓存是可以在不同的sqlSession进行数据共享,那么会有哪些问题需要我们去解决。

问题一:线程安全问题,既然二级缓存可以跨线程使用。那么我们是不是要考虑线程安全、脏读等问题。

问题二:既然是缓存,不是存储在内存中,就是存储在磁盘或者等三方中间件redis中。所以它们肯定是有一定的空间大小的,那就需要一些缓存淘汰策略。

问题三:在mybatis中,二级缓存是存在一个命中率记录器的。那么每次查询命中二级缓存,也是需要进行记录的。那么在并发情况下,怎么保证二级缓存命中率的准确性

等等,当然还有一些其他需要考虑问题,我就不一 一列举,

如果让我们设计一个获取缓存的方法,需要解决以上问题的话。那还不简单,直接撸一个方法,把这些解决方法逻辑加上去,就完事。但是在mybatis中,并没有采用这种方式,而是采用了责任链和装饰器设计模式。
在这里插入图片描述

在mybatis当中,首先定义了一个Cache接口,Cache接口定义了一些获取缓存,存放缓存等等一些方法。

通过责任链设计模式,让每一个处理逻辑的Cache去实现Cache接口,在每一个方法中做自己做的事。这样做可以达到我们的解耦和单一职责。

通过装饰器设计模式,当我们处理完线程同步的逻辑后,就会执行下一个cache对应的方法,最后一直执行到我们的内存存储的cache。这样做可以达到解耦,对原有方法进行增强,减少我们的继承类。

总之mybatis这样做的好处,就在于对代码进行可维护可拓展。如果后续有需求需要新加一个cache的逻辑,我们就可以不改动原代码,只需去编写新逻辑的cahche在把它加进去即可。

下面我们来验证下,是不是这样。

 Cache cache = sqlSession.getConfiguration().getCache("org.apache.ibatis.mapper.UserMapper");
    cache.putObject("123",users);

大家可以发现第一个cache是SynchronizedCache,第二个cache是LoggingCache,第三个cache是LRUCache,第四个cache是PerptualCache。其实就和我们上面的流程图一致
在这里插入图片描述


二级缓存执行流程

在这里插入图片描述

在之前流程图中,我做了一些改动。多了一个事务缓存管理器和一个缓存空间。

事务缓存管理器是存储我们当前sqlSession中二级缓存所存储的数据。当SqlSession关闭时,那么这个事务缓存管理器也会失效。为什么要有这个事务缓存管理器呢,因为我们的二级缓存是需要在提交后,在进行存储的。如果进行实时提交,事务后面出现回滚等操作。就会出现脏读等问题。


二级缓存获取和commit源码解剖

Debug解析

在没有执行SQL语句之前,TransactionalCacheManager中的transactionalCaches是空,没有任何数据。这是因为我们还没有执行SQL语句,mybatis也不知道需要将那些数据加入到我们的TransactionalCacheManager当中,你查询了哪个缓存mapper,就会将哪个缓存的mapper加入到我们的TransactionalCacheManager当中。
在这里插入图片描述
在我们执行一次查询后,看看有什么变化
在这里插入图片描述
当我们commit之后,会有什么变化
在这里插入图片描述


命中二级缓存源码解剖

CachingExecutor 的query方法

  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler,
      CacheKey key, BoundSql boundSql) throws SQLException {
     // 1.获取当前mappernamespace是否配置了<cache/>标签,是否开启了二级缓存 ,从二级缓存空间中获取对应的cache
    Cache cache = ms.getCache();
    if (cache != null) {
    	// 判断是否要清除缓存,只会清空当前线程的缓存,不会影响其他线程的缓存。
      flushCacheIfRequired(ms);
      if (ms.isUseCache() && resultHandler == null) {
       // 检查SQL语句中是否包含输出参数
        ensureNoOutParams(ms, boundSql);
        // 从二级缓存中获取结果
        List<E> list = (List<E>) tcm.getObject(cache, key);
        
        // 从一级缓存中获取结果并将结果存储到二级缓存map当中
        if (list == null) {
          list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        //  从二级缓存中拿出结果,进行返回
        return list;
      }
    }
    // delegate是我们的一级缓存,如果没有开启二级缓存,就直接调用一级缓存获取结果进行返回
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

我们进入tcm也就是TransactionalCacheManager的getobject方法

  public Object getObject(Cache cache, CacheKey key) {
    return getTransactionalCache(cache).getObject(key);
  }
  
// 获取一个TransactionalCache对象,并将其存储在缓存中,以便在事务中使用。如果缓存中已经存在这个对象,则直接返回;否则,创建一个新的对象并存储在缓存中。
  private TransactionalCache getTransactionalCache(Cache cache) {
    return MapUtil.computeIfAbsent(transactionalCaches, cache, TransactionalCache::new);
  }
 public Object getObject(Object key) {
    // 从二级缓存空间获取数据
    Object object = delegate.getObject(key);
   // 如果没有获取到
    if (object == null) {
    // 将当前数据加到事务缓存管理器中,进行暂时存储
      entriesMissedInCache.add(key);
    }
    // 判断该值是否为true。如果是true就返回null。
    // 因为在我们事务没提交时,如果进行修改的时候不会直接清除二级缓存空间,而是先标记一下做个假删除。等commit之后才会去清除我们的二级缓存空间。
    // 同样标记为null,也是为了防止出现脏读等。
    if (clearOnCommit) {
      return null;
    }
    return object;
  }

在这里插入图片描述


commit源码解剖

DefaultSqlSession的commit

  public void commit(boolean force) {
    try {
    // 调用二级缓存的commit
      executor.commit(isCommitOrRollbackRequired(force));
      dirty = false;
    } catch (Exception e) {
      throw ExceptionFactory.wrapException("Error committing transaction.  Cause: " + e, e);
    } finally {
      ErrorContext.instance().reset();
    }
  }

CachingExecutor的commit
在这里插入图片描述
TransactionalCacheManager的commit

  public void commit() {
  // 循环进行提交
    for (TransactionalCache txCache : transactionalCaches.values()) {
    // 调用TransactionalCache的commit
      txCache.commit();
    }
  }

TransactionalCache的commit

  public void commit() {
    // 如果标记假删除,这里提交时候就会清空
    if (clearOnCommit) {
      delegate.clear();
    }
    // 进行刷新
    flushPendingEntries();
    reset();
  }

// 看到这里是不是就是很熟悉了,将我们事务缓存管理器的map丢到我们的二级缓存空间当中。
  private void flushPendingEntries() {
    for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
      delegate.putObject(entry.getKey(), entry.getValue());
    }
    for (Object entry : entriesMissedInCache) {
      if (!entriesToAddOnCommit.containsKey(entry)) {
        delegate.putObject(entry, null);
      }
    }
  }

至于二级缓存失效,第一个二级缓存只有提交后才会生效,第二个发生增删改就会清空所有的二级缓存。


总结

如上就是对myabtis的一级缓存二级缓存进行分析和源码解剖,如下就做个总结。

1.mybatis一级缓存和二级缓存的区别

1.作用范围不同:一级缓存是SqlSession级别的缓存,只在当前SqlSession中有效;而二级缓存是Mapper级别的缓存,多个SqlSession可以共享同一个Mapper的二级缓存。

2.存储位置不同:一级缓存是存储在SqlSession内部的一个HashMap中,而二级缓存是存储在外部的缓存中,例如Ehcache、Redis等。

3.失效机制不同:一级缓存的失效机制是基于SqlSession的生命周期,当SqlSession关闭时,一级缓存也会被清空;而二级缓存的失效机制是基于时间的,可以通过配置缓存的过期时间来控制缓存的失效。

4.数据一致性不同:一级缓存可以保证数据的一致性,因为它只在当前SqlSession中有效;而二级缓存可能会出现数据不一致的问题,因为多个SqlSession可以共享同一个Mapper的二级缓存。

5.配置方式不同:一级缓存是默认开启的,无需进行额外的配置;而二级缓存需要进行额外的配置,包括缓存的类型、大小、过期时间等。

总之,MyBatis的一级缓存和二级缓存都是用于提高查询性能的缓存机制,但是它们的作用范围、存储位置、失效机制、数据一致性和配置方式都有所不同。在使用MyBatis时,需要根据具体的业务需求和系统性能要求,选择合适的缓存机制。


2.一级缓存失效原因有哪些

  1. 必须在同一个statementID的语句中执行
  2. SQL语句和参数值必须一致。
  3. 分页条件必须一致
  4. 必须在同一个会话中,在上面代码中我们也可以看到。我们调用mapper.selectUser(user);是会执行sqlSession的excutor方法。如果sqlSession关闭了,那么里面的一级执行器,当然也就不会在存在了。

3.为什么不建议使用mybatis的二级缓存?

虽然MyBatis的二级缓存可以提高查询性能,但是在实际应用中,使用二级缓存并不总是一个好的选择。以下是一些原因:

1.数据不一致性问题:二级缓存是一个全局的缓存,多个SqlSession共享同一个缓存。如果在一个SqlSession中修改了数据,但是这个修改没有及时更新缓存,那么在另一个SqlSession中查询这个数据时,就会出现数据不一致的问题。

2.内存占用问题:二级缓存是一个全局的缓存,会占用大量的内存。如果缓存中存储了大量的数据,那么就会导致内存占用过高,从而影响系统的性能。

3.缓存失效问题:二级缓存的失效机制是基于时间的,如果缓存中的数据长时间没有被访问,那么就会被自动清除。但是这种失效机制并不总是可靠的,有时候缓存中的数据可能已经过期,但是仍然被使用,从而导致数据不一致的问题。

4.配置复杂性问题:使用二级缓存需要进行复杂的配置,包括缓存的类型、大小、过期时间等。如果配置不当,就会导致缓存的效果不佳,甚至影响系统的性能。

5.综上所述,虽然MyBatis的二级缓存可以提高查询性能,但是在实际应用中,使用二级缓存需要考虑到数据一致性、内存占用、缓存失效和配置复杂性等问题。因此,建议在使用MyBatis时,根据具体的业务需求和系统性能要求,谨慎选择是否使用二级缓存。


http://lihuaxi.xjx100.cn/news/1149530.html

相关文章

AI根据图片自动建模

暂时放弃了&#xff0c;没显卡&#xff0c;直接装不了 用的是一个git上的老项目3年前的&#xff0c;最近更新6个月&#xff0c;由facebook开发 GitHub - facebookresearch/pifuhd: High-Resolution 3D Human Digitization from A Single Image. 他需要的环境有 Python 3PyTo…

强化学习:贝尔曼最优公式

策略改进案例 强化学习的目的是寻找最优策略。其中涉及两个核心概念最优状态值和最优策略&#xff0c;和一个工具&#xff1a;贝尔曼最优公式。   首先&#xff0c;我们给出一个熟悉的例子&#xff0c;了解贝尔曼方程是如何改进策略的。 根据给出的策略&#xff0c;我们很容…

韩国访问学者签证D-2-5材料准备及签证流程

韩国的签证种类很多&#xff0c;对于申请访问学者签证来说&#xff0c;较常见的签证种类是D-2-5签证和E-3签证&#xff0c;本篇知识人网小编先介绍D-2-5签证。 签证的材料准备 根据韩国大使馆2023年4月12日最新发布的“签证申请与准备材料指导”内容, D-2-5签证的签发对象及准…

看法︱ BTC NFT和BRC-20 关注点有哪些?BTC需要生态吗?

花开两朵各领风骚&#xff0c;扎根在BTC网络母株上的Oreinals侧枝开出了memecoin和btc nft两条鲜花&#xff0c;猛烈的聪明钱和话题流量就像营养液般浇灌着花蕾&#xff0c;如今BTC上的关注度远超ETH&#xff0c;技术升级和原则争议好像平行世界上两列动车&#xff0c;谁也无法…

gunicorn常用参数命令

Gunicorn 是一个 Python 的 WSGI HTTP 服务器。具有实现简单,轻量级,高性能等特点。更多介绍内容参考官网&#xff0c;这里介绍几个常用参数。 安装 pip3 install gunicorn通过输入gunicorn -v查看版本。 最简洁的启动。首先进入到项目目录&#xff0c;例如django项目和mana…

被00后卷的油尽灯枯了...

内卷的来源 内卷最早的“出处”是几张名校学霸的图片。 大学生们刷爆朋友圈的几张“内卷”图片是这样的&#xff1a;有的人骑在自行车上看书&#xff0c;有的人宿舍床上铺满了一摞摞的书&#xff0c;有的人甚至边骑车边端着电脑写论文。这些图片最早在清华北大的学霸之间流传。…

C语言函数大全-- _w 开头的函数(3)

C语言函数大全 本篇介绍C语言函数大全-- _w 开头的函数 1. _wmkdir 1.1 函数说明 函数声明函数功能int _wmkdir(const wchar_t* dirname);用于创建指定路径名的新目录 参数&#xff1a; dirname &#xff1a; 指向以 null 结尾的宽字符数组&#xff0c;该数组包含要创建的目…

克米-手机头像修改 v3.6.1(comiis_app_avatar)[全开源无加密无需授权key]

更新日志&#xff1a; 2023-05-06 更新&#xff1a; 修复某些状态下3.5操作异常BUG 优化部分逻辑代码 这是一款为手机版提供在线修改头像功能的小插件&#xff0c;兼容所有嵌入点正常的手机模板&#xff1b; 支持图片缩放、旋转、裁剪作为头像&#xff0c;完美兼容安卓、苹果等…