Mybatis_SQL执行流程解析

主流程

大体流程:
方法代理MapperProxy->会话SQLSession->执行器Executor->声明处理器StatementHandler/JDBC
在这里插入图片描述

具体流程以查询为例:

MapperProxy#invoke

MapperProxy用于实现动态代理,是InvocationHandler接口的实现类。与MyBatis交互的门面,存在的目的是为了方便调用,本身不会影响执行逻辑。

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
  try {
    if (Object.class.equals(method.getDeclaringClass())) {
      return method.invoke(this, args);
    } else if (isDefaultMethod(method)) {
      return invokeDefaultMethod(proxy, method, args);
    }
  } catch (Throwable t) {
    throw ExceptionUtil.unwrapThrowable(t);
  }
  final MapperMethod mapperMethod = cachedMapperMethod(method);
  //这里
  return mapperMethod.execute(sqlSession, args);
}

MapperMethod#execute

将定义的接口方法转换成MappedStatement对象

public Object execute(SqlSession sqlSession, Object[] args) {
  Object result;
  switch (command.getType()) {
    case INSERT: {
      Object param = method.convertArgsToSqlCommandParam(args);
      result = rowCountResult(sqlSession.insert(command.getName(), param));
      break;
    }
    case UPDATE: {
      Object param = method.convertArgsToSqlCommandParam(args);
      result = rowCountResult(sqlSession.update(command.getName(), param));
      break;
    }
    case DELETE: {
      Object param = method.convertArgsToSqlCommandParam(args);
      result = rowCountResult(sqlSession.delete(command.getName(), param));
      break;
    }
    case SELECT:
      if (method.returnsVoid() && method.hasResultHandler()) {
        executeWithResultHandler(sqlSession, args);
        result = null;
      } else if (method.returnsMany()) {
        result = executeForMany(sqlSession, args);
      } else if (method.returnsMap()) {
        result = executeForMap(sqlSession, args);
      } else if (method.returnsCursor()) {
        result = executeForCursor(sqlSession, args);
      } else {
        Object param = method.convertArgsToSqlCommandParam(args);
        //这里
        result = sqlSession.selectOne(command.getName(), param);
        if (method.returnsOptional()
            && (result == null || !method.getReturnType().equals(result.getClass()))) {
          result = Optional.ofNullable(result);
        }
      }
      break;
    case FLUSH:
      result = sqlSession.flushStatements();
      break;
    default:
      throw new BindingException("Unknown execution method for: " + command.getName());
  }
  if (result == null && method.getReturnType().isPrimitive() && !method.returnsVoid()) {
    throw new BindingException("Mapper method '" + command.getName()
        + " attempted to return null from a method with a primitive return type (" + method.getReturnType() + ").");
  }
  return result;
}

DefaultSqlSession#selectOne

@Override
public <T> T selectOne(String statement, Object parameter) {
  // Popular vote was to return null on 0 results and throw exception on too many.
  //这里
  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;
  }
}

DefaultSqlSession#selectList

@Override
public <E> List<E> selectList(String statement, Object parameter) {
  return this.selectList(statement, parameter, RowBounds.DEFAULT);
}
@Override
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
  try {
    MappedStatement ms = configuration.getMappedStatement(statement);
    //这里
    return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
  } catch (Exception e) {
    throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
  } finally {
    ErrorContext.instance().reset();
  }
}

CachingExecutor#query

二级缓存执行器,这里没有用到

@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
    throws SQLException {
  Cache cache = ms.getCache();
  //如果有缓存
  if (cache != null) {
    flushCacheIfRequired(ms);
    if (ms.isUseCache() && resultHandler == null) {
      ensureNoOutParams(ms, boundSql);
      @SuppressWarnings("unchecked")
      List<E> list = (List) tcm.getObject(cache, key);
      if (list == null) {
        list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
        tcm.putObject(cache, key, list); // issue #578 and #116
      }
      return list;
    }
  }
  //没有缓存,这里
  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) localCache.getObject(key) : null;
    //如果有一级缓存,这里
    if (list != null) {
      handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
    } else {
      //没有一级缓存,这里
      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;
}

BaseExecutor#queryFromDatabase

private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
  List<E> list;
  localCache.putObject(key, EXECUTION_PLACEHOLDER);
  try {
  	//这里
    list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
  } finally {
  	//删除缓存
    localCache.removeObject(key);
  }
  //再放入缓存
  localCache.putObject(key, list);
  if (ms.getStatementType() == StatementType.CALLABLE) {
    localOutputParameterCache.putObject(key, parameter);
  }
  return list;
}

SimpleExecutor#doQuery

@Override
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);
    //这里
    stmt = prepareStatement(handler, ms.getStatementLog());
    //执行查询
    return handler.query(stmt, resultHandler);
  } finally {
    closeStatement(stmt);
  }
}

SimpleExecutor#prepareStatement

private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
  Statement stmt;
  //获取连接
  Connection connection = getConnection(statementLog);
  //预处理SQL
  stmt = handler.prepare(connection, transaction.getTimeout());
  //设置参数
  handler.parameterize(stmt);
  return stmt;
}

MySQL select语句的执行流程

在这里插入图片描述

会话SqlSession

创建SqlSession

DefaultSqlSessionFactory实例化的SqlSession

  • 重载的创建SqlSession 的方法
@Override
public SqlSession openSession() {
  return openSessionFromDataSource(configuration.getDefaultExecutorType(), null, false);
}
/**
* 是否自动提交
*/
@Override
public SqlSession openSession(boolean autoCommit) {
  return openSessionFromDataSource(configuration.getDefaultExecutorType(), null, autoCommit);
}
/**
* 指定执行器类型
*/
@Override
public SqlSession openSession(ExecutorType execType) {
  return openSessionFromDataSource(execType, null, false);
}
/**
* 指定事务隔离级别
*/
@Override
public SqlSession openSession(TransactionIsolationLevel level) {
  return openSessionFromDataSource(configuration.getDefaultExecutorType(), level, false);
}

@Override
public SqlSession openSession(ExecutorType execType, TransactionIsolationLevel level) {
  return openSessionFromDataSource(execType, level, false);
}

@Override
public SqlSession openSession(ExecutorType execType, boolean autoCommit) {
  return openSessionFromDataSource(execType, null, autoCommit);
}
/* 以上是通过数据源获取会话,以下是通过连接获取会话*/
@Override
public SqlSession openSession(Connection connection) {
  return openSessionFromConnection(configuration.getDefaultExecutorType(), connection);
}

@Override
public SqlSession openSession(ExecutorType execType, Connection connection) {
  return openSessionFromConnection(execType, connection);
}

@Override
public Configuration getConfiguration() {
  return configuration;
}
  • 通过数据源创建session
private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
  Transaction tx = null;
  try {
    //获取当前的环境信息  
    final Environment environment = configuration.getEnvironment();
    //获取事务工厂
    final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
    //事务的创建
    tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
    //执行器的创建
    final Executor executor = configuration.newExecutor(tx, execType);
    return new DefaultSqlSession(configuration, executor, autoCommit);
  } catch (Exception e) {
    closeTransaction(tx); // may have fetched a connection so lets call close()
    throw ExceptionFactory.wrapException("Error opening session.  Cause: " + e, e);
  } finally {
    ErrorContext.instance().reset();
  }
}
  • 通过连接创建数据源
private SqlSession openSessionFromConnection(ExecutorType execType, Connection connection) {
  try {
    boolean autoCommit;
    try {
      autoCommit = connection.getAutoCommit();
    } catch (SQLException e) {
      // Failover to true, as most poor drivers
      // or databases won't support transactions
      autoCommit = true;
    }
    final Environment environment = configuration.getEnvironment();
    final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
    final Transaction tx = transactionFactory.newTransaction(connection);
    //执行器的创建
    final Executor executor = configuration.newExecutor(tx, execType);
    return new DefaultSqlSession(configuration, executor, autoCommit);
  } catch (Exception e) {
    throw ExceptionFactory.wrapException("Error opening session.  Cause: " + e, e);
  } finally {
    ErrorContext.instance().reset();
  }
}
  • 执行器的创建(关键)
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
   //判断参数,如果为null取默认的SimpleExecutor
  executorType = executorType == null ? defaultExecutorType : executorType;
  executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
  Executor executor;
  //如果参数不为空,根据参数创建
  if (ExecutorType.BATCH == executorType) {
    executor = new BatchExecutor(this, transaction);
  } else if (ExecutorType.REUSE == executorType) {
    executor = new ReuseExecutor(this, transaction);
  } else {
    executor = new SimpleExecutor(this, transaction);
  }
  //判断二级缓存是否开启,开启时创建CachingExecutor对原有的Executor进行装饰
  if (cacheEnabled) {
    executor = new CachingExecutor(executor);
  }
  executor = (Executor) interceptorChain.pluginAll(executor);
  return executor;
}

SqlSessionManager实例化的SqlSession

public void startManagedSession(ExecutorType execType, Connection connection) {
   this.localSqlSession.set(openSession(execType, connection));
 }

session是放在localSqlSession中的,localSqlSession是一个ThreadLocal变量

private final ThreadLocal<SqlSession> localSqlSession = new ThreadLocal<SqlSession>();

所以通过SqlSessionManager实例化的SqlSession是线程安全的,而默认通过DefaultSqlSessionFactory是线程不安全的,也就是一个sqlsession对象默认是不允许多线程操作的。

执行器Executor

执行器用于连接 SqlSession与JDBC,所有与JDBC相关的操作都要通过它。
在这里插入图片描述

在这里插入图片描述
可以在mybatis-config.xml里边指定使用执行器,默认为SimpleExecutor

 <settings>
     <setting name="defaultExecutorType" value="REUSE"/>
 </settings>

JDBC原生执行器Statement

与mybatis的执行器是不同的概念。
###
SimpleExecutorReuseExecutorBatchExecutor三个具体的实现均是实际操作JDBC的对象,可以通过Mapper接口注解@Options(statementType=StatementType.STATEMENT|PREPARED|CALLABLE)。默认使用PreparedStatement来处理JDBC操作

BaseExecutor

Executor的基本抽象实现,采用模板设计模式,里边提取了连接维护、一级缓存的公有功能,供子类复用。并放出了doQuery、doUpdate等抽象方法下放到子类做差异实现。

protected abstract int doUpdate(MappedStatement ms, Object parameter)
    throws SQLException;

protected abstract List<BatchResult> doFlushStatements(boolean isRollback)
    throws SQLException;

protected abstract <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql)
    throws SQLException;

protected abstract <E> Cursor<E> doQueryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds, BoundSql boundSql)
    throws SQLException;

CachingExecutor

作为BaseExecutor的一个装饰器,用来负责二级缓存功能。而JDBC相关操作都是丢给BaseExecutor来操作。

SimpleExecutor

默认执行器

ReuseExecutor

可重用执行器,底层是维护了一个Map<String sql,Statement stmt> 来捕捉到相同的SQL,则直接取对应缓存的Statement进行执行,所以对于相同SQL(包括queryupdate),不同参数,则只进行一次预编译。就可以复用设置参数来执行。避免了频繁创建和销毁Statement对象,从而提升系统性能,这是享元思想的应用。

//statement缓存
private final Map<String, Statement> statementMap = new HashMap<String, Statement>();

private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
  Statement stmt;
  BoundSql boundSql = handler.getBoundSql();
  String sql = boundSql.getSql();
  //判断是否已经有SQL对应statement
  if (hasStatementFor(sql)) {
  	//如果有,从map中取
    stmt = getStatement(sql);
    applyTransactionTimeout(stmt);
  } else {
    Connection connection = getConnection(statementLog);
    stmt = handler.prepare(connection, transaction.getTimeout());
    //如果没有,执行完成后,将SQL和statement加入map
    putStatement(sql, stmt);
  }
  handler.parameterize(stmt);
  return stmt;
}

private boolean hasStatementFor(String sql) {
  try {
    return statementMap.keySet().contains(sql) && !statementMap.get(sql).getConnection().isClosed();
  } catch (SQLException e) {
    return false;
  }
}

private Statement getStatement(String s) {
  return statementMap.get(s);
}

private void putStatement(String sql, Statement stmt) {
  statementMap.put(sql, stmt);
}

ReuseExecutor维护了一个Statement的缓存,这是Mybatis里边除了一级缓存、二级缓存以外的缓存。一般来说将执行器指定为ReuseExecutor,也是一种提升性能的方案。

BatchExecutor

批处理执行器,其实底层依赖的就是JDBC的Statement.addBatch接口规范。所以,BatchExecutor的使用必须是以addBatch开始,并以doFlushStatement结束。不同的是,BatchExecutor并不是单调的直接使用addBatch,而是对其扩展了缓存,复用的能力。

//BatchExecutor内部的一些属性
//存放addBatch加入的Statement
private final List<Statement> statementList = new ArrayList<>();
//对应的结果集
private final List<BatchResult> batchResultList = new ArrayList<>();
//当前SQL
private String currentSql;
//当前statement
private MappedStatement currentStatement;

public int doUpdate(MappedStatement ms, Object parameterObject) throws SQLException {
  final Configuration configuration = ms.getConfiguration();
    //获取StatementHandler
  final StatementHandler handler = configuration.newStatementHandler(this, ms, parameterObject, RowBounds.DEFAULT, null, null);
  final BoundSql boundSql = handler.getBoundSql();
  final String sql = boundSql.getSql();
  final Statement stmt;
  //如果当前SQL和上一次要执行的最新的SQL一样,当前要使用的mapper方法映射和上一次要执行的一样,那么就进入if
  if (sql.equals(currentSql) && ms.equals(currentStatement)) {
    int last = statementList.size() - 1;
    //从statementList内取最新值,获取statement
    stmt = statementList.get(last);
    applyTransactionTimeout(stmt);
    //修改statement的参数
    handler.parameterize(stmt);//fix Issues 322
    //从batchResultList取最新值,获取BatchResult
    BatchResult batchResult = batchResultList.get(last);
    //修改这个BatchResult内部的一些参数
    batchResult.addParameterObject(parameterObject);
  } else {
    Connection connection = getConnection(ms.getStatementLog());
     //构造一个statement
    stmt = handler.prepare(connection, transaction.getTimeout());
    handler.parameterize(stmt);    //fix Issues 322
    //将当前要执行的SQL赋值给currentSql属性
    currentSql = sql;
    //将当前要执行的mapper方法映射赋值给currentStatement属性
    currentStatement = ms;
    //将当前SQL要使用的statement放入statementList
    statementList.add(stmt);
    //构造一个跟当前SQL相关的BatchResult添加到batchResultList
    batchResultList.add(new BatchResult(ms, sql, parameterObject));
  }
   //执行PreparedStatement的addBatch方法,不实际执行。
  handler.batch(stmt);
  return BATCH_UPDATE_RETURN_VALUE;
}
public List<BatchResult> doFlushStatements(boolean isRollback) throws SQLException {
  try {
    List<BatchResult> results = new ArrayList<>();
    if (isRollback) {
      return Collections.emptyList();
    }
    for (int i = 0, n = statementList.size(); i < n; i++) {
      //遍历上文中提到的statementList,获取待执行的Statement
      Statement stmt = statementList.get(i);
      applyTransactionTimeout(stmt);
      //遍历上文中提到的batchResultList,获取待执行SQL的BatchResult
      BatchResult batchResult = batchResultList.get(i);
      try {
         //doUpdate中只是addBatch,这里才是真正执行,然后更新结果
        batchResult.setUpdateCounts(stmt.executeBatch());
		//....
        closeStatement(stmt);
      } catch (BatchUpdateException e) {
			//.....
      }
      results.add(batchResult);
    }
    return results;
  } finally {
    for (Statement stmt : statementList) {
       //就像JDBC执行后清除Statement一样
      closeStatement(stmt);
    }
     //置空
    currentSql = null;
    statementList.clear();
    batchResultList.clear();
  }
}

StatementHandler/ResultHandler

@Override
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 
    StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);
    stmt = prepareStatement(handler, ms.getStatementLog());
    //这里resultHandler
    return handler.<E>query(stmt, resultHandler);
  } finally {
    closeStatement(stmt);
  }
}

从上面这个方法可以发现,executor里面的逻辑实现是依赖这两个Handler
可以通过Mapper接口注解@Options(statementType=StatementType.STATEMENT|PREPARED|CALLABLE)来指定Statement。默认使用PreparedStatement来处理JDBC操作

  • PreparedStatementHandler:带预处理的执行器
  • CallableStatementHandler:存储过程执行器
  • SimpleStatementHandler:基于Sql执行器
    在这里插入图片描述

RoutingStatementHandler

通过MappedStatement的属性statementType来判断mybatis执行时使用的哪个Handler

public class RoutingStatementHandler implements StatementHandler {

  private final StatementHandler delegate;

  public RoutingStatementHandler(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {

    switch (ms.getStatementType()) {
      case STATEMENT:
        delegate = new SimpleStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
        break;
      case PREPARED:
        delegate = new PreparedStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
        break;
      case CALLABLE:
        delegate = new CallableStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
        break;
      default:
        throw new ExecutorException("Unknown statement type: " + ms.getStatementType());
    }
  }

PreparedStatementHandler#instantiateStatement为例

@Override
  protected Statement instantiateStatement(Connection connection) throws SQLException {
    String sql = boundSql.getSql();
    if (mappedStatement.getKeyGenerator() instanceof Jdbc3KeyGenerator) {
      String[] keyColumnNames = mappedStatement.getKeyColumns();
      if (keyColumnNames == null) {
        return connection.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS);
      } else {
        return connection.prepareStatement(sql, keyColumnNames);
      }
    } else if (mappedStatement.getResultSetType() != null) {
      return connection.prepareStatement(sql, mappedStatement.getResultSetType().getValue(), ResultSet.CONCUR_READ_ONLY);
    } else {
      return connection.prepareStatement(sql);
    }
  }

ConnectionLogger 添加了日志的连接代理

以上是创建Statement的核心方法,但是mybatis创建statement的过程中通过动态代理类ConnectionLogger

/**
 * Connection proxy to add logging 添加了日志的连接代理
 * 
 * @author Clinton Begin
 * @author Eduardo Macarron
 * 
 */
public final class ConnectionLogger extends BaseJdbcLogger implements InvocationHandler {

  private final Connection connection;

  private ConnectionLogger(Connection conn, Log statementLog, int queryStack) {
    super(statementLog, queryStack);
    this.connection = conn;
  }

  @Override
  public Object invoke(Object proxy, Method method, Object[] params)
      throws Throwable {
    try {
      if (Object.class.equals(method.getDeclaringClass())) {
        return method.invoke(this, params);
      }    
      if ("prepareStatement".equals(method.getName())) {
        if (isDebugEnabled()) {
          debug(" Preparing: " + removeBreakingWhitespace((String) params[0]), true);
        }        
        PreparedStatement stmt = (PreparedStatement) method.invoke(connection, params);
        stmt = PreparedStatementLogger.newInstance(stmt, statementLog, queryStack);
        return stmt;
      } else if ("prepareCall".equals(method.getName())) {
        if (isDebugEnabled()) {
          debug(" Preparing: " + removeBreakingWhitespace((String) params[0]), true);
        }        
        PreparedStatement stmt = (PreparedStatement) method.invoke(connection, params);
        stmt = PreparedStatementLogger.newInstance(stmt, statementLog, queryStack);
        return stmt;
      } else if ("createStatement".equals(method.getName())) {
        Statement stmt = (Statement) method.invoke(connection, params);
        stmt = StatementLogger.newInstance(stmt, statementLog, queryStack);
        return stmt;
      } else {
        return method.invoke(connection, params);
      }
    } catch (Throwable t) {
      throw ExceptionUtil.unwrapThrowable(t);
    }
  }

执行时序

在这里插入图片描述

  1. 通过Configuration获取StatementHandler实例(由statementType 决定)。
  2. 通过事务获取连接
  3. 创建JDBC Statement对象
  4. 执行JDBC Statement#execute
  5. 处理返回结果

线程安全问题

源码中并没有体现出同步和锁的概念,所以Executor不能跨线程操作,相应的Sqlsession也是不能跨线程操作的,而单线程是可以操作多个Sqlsession的。

一级缓存

在同一会话内如果有两次相同的查询(Sql和参数均相同),那么第二次就会命中缓存。一级缓存通过会话进行存储,当会话关闭,缓存也就没有了。此外如果会话进行了修改(增删改) 操作,缓存也会被清空。

命中条件

  • 相同的statement id(Mapper中相同的方法)
  • 相同的Session
  • 相同的Sql与参数
  • 返回行范围相同
    在这里插入图片描述

清空场景

  • 执行update。只要会话中执行了增删改就会被清空,并且跟sql、参数、statement id无关。
  • 手动清空,即执行SqlSession.clearCache() 方法。
  • 查询清空,即配置了 flushCache= true,查询前会清空全部缓存。
  • 提交,回滚清空。

关闭一级缓存

myBatis 默认是开启一级缓存的,且不可以关闭。useCache=false 只能关闭二级缓存,不能关闭一级缓存。如果一定要关闭一级缓存只能在查询中配置flushCache=true.

为什么Spring中Mybatis一级缓存失效

Spring每次执行Sql请求都会通过MyBatis获取一个新的SqlSession自然就不会命中一级缓存了。解决办法是给服务方法添加事务,通常只有增删改操作会添加事务,而如果是纯查询的我们会勿略事务,事务对于查询也是有必要的。

实现流程

在这里插入图片描述

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.");
  }
  //先清局部缓存,再查询.但仅查询栈为0,才清。为了处理递归调用
  if (queryStack == 0 && ms.isFlushCacheRequired()) {
    clearLocalCache();
  }
  List<E> list;
  try {
    //加一,这样递归调用到上面的时候就不会再清局部缓存了
    queryStack++;
    //先根据cachekey从localCache去查
    list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
    if (list != null) {
      //若查到localCache缓存,处理localOutputParameterCache
      handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
    } else {
      //从数据库查
      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
   //如果是STATEMENT,清本地缓存
      clearLocalCache();
    }
  }
  return list;
}

localCache是一个PerpetualCache类 ,具体存储容器就是HashMap

public class PerpetualCache implements Cache {

  private String id;

  private Map<Object, Object> cache = new HashMap<Object, Object>();
  ...

二级缓存

二级缓存是应用级的缓存,即作用于整个应用的生命的周期。相对一级缓存会有更高的命中率。所以在顺序上是先访问二级然后在是一级和数据库。
在这里插入图片描述
由于生命周期长,跨会话访问的因素所以二级在使用上要更谨慎,如果用的不好就会造成脏读。

使用

配置方式

虽然xml配置和注解的功能基本相同,但是使用@CacheNamespace时候要注意:配置文件和接口注释是不能够配合使用的。只能通过全注解的方式或者全部通过xml配置文件的方式使用。

XML配置

全局配置文件中开启缓存

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

SQL 映射文件(mapper.xml)中添加:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.mybatis.UserMapper">
 
    <cache type="cn.mybatis.MybatisRedisCache">
        <property name="eviction" value="LRU" />
        <property name="flushInterval" value="6000000" />
        <property name="size" value="1024" />
        <property name="readOnly" value="false" />
    </cache>
 
    <select id="selectById">
        select * from test where id = #{id}
    </select >
</mapper>
注解配置
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface CacheNamespace {
  // 最终缓存实现,默认PerpetualCache
  Class<? extends org.apache.ibatis.cache.Cache> implementation() default PerpetualCache.class;
  // 数据逐出策略,默认LRU
  Class<? extends org.apache.ibatis.cache.Cache> eviction() default LruCache.class;
  // 刷新时间,默认不刷新
  long flushInterval() default 0;
  // 默认容量1024
  int size() default 1024;
  // 序列化默认开启
  boolean readWrite() default true;
  // 放缓存穿透,默认关闭
  boolean blocking() default false;

  /**
   * Property values for a implementation object.
   * @since 3.4.2
   */
  Property[] properties() default {};
  
}

@CacheNamespace(implementation = MybatisRedisCache.class)
public interface UserMapper(
    @Select("select * from t_user where user_id = #{userId}")
    @Options(useCache = true)
    List<User> getUser(User u);
}

命中条件

  • 相同的statement id
  • 相同的Sql与参数
  • 返回行范围相同
  • 没有使用ResultHandler来自定义返回数据
  • 没有配置UseCache=false 来关闭缓存
  • 没有配置FlushCache=true 来清空缓存
  • 在调用存储过程中不能使用出参,即Parametermode=out|inout

缓存写入

与一级缓存的实时写入不同,二级缓存是在事务提交或会话关闭之后才会触发缓存写入。
因为二级缓存是跨会话的,如果没有提交就写入,如果事务最后回滚,肯定导致别的会话脏读。

缓存更新

  • 默认的update操作会清空该namespace下的缓存(可设定flushCache=false 来禁止)。
<!-- 
	flushCache 
		true - 默认 执行该语句时,会刷新二级缓存
		false - 执行该语句时,不会刷新二级缓存
 -->
<delete id="delete" flushCache="false">
	delete from user where user_id = #{userId}
</delete>
  • 设定缓存的失效时间flushInterval
  • 将指定查询的缓存关闭即设置useCache=false
  • 为指定Statement设定 flushCache=true清空缓存

缓存引用

不同的namespace有着独立的缓存容器,只有该namespace下的statement才能访问该缓存。但表与表之间是存在关联的。而对应的Mapper又是独立的。这时我们就可以通过缓存引用,让多个Mapper共享一个缓存。具体做法是设定@CacheNamespaceRef 与 指定namespace 值就可以。

同时使用注解与xml映射文件时,虽然它们namespace相同但一样不能共享缓存,这就必须一方设定缓存,另一方引用才可以。

源码解析

事务缓存管理器(TransactionalCacheManager)

二级缓存是在事务提交后才会写入,目的是为了防止其它会话脏读缓存。所以在话与二级缓存中间会有一个事务缓存管理器,会话其间查询的数据会放到管理器的暂存区。当事务提交后会才会写入指定二级缓存区域。管理器的生命周期与会话保持一至。
在这里插入图片描述
缓存管理器是在CachingExecutor中实例化的

public class CachingExecutor implements Executor {

  private final Executor delegate;
  private final TransactionalCacheManager tcm = new TransactionalCacheManager();

TransactionalCacheManager 中通过HashMap存储多个暂存区

public class TransactionalCacheManager {

  private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<Cache, TransactionalCache>();

  public void clear(Cache cache) {
    getTransactionalCache(cache).clear();
  }

  public Object getObject(Cache cache, CacheKey key) {
    return getTransactionalCache(cache).getObject(key);
  }
  
  public void putObject(Cache cache, CacheKey key, Object value) {
    getTransactionalCache(cache).putObject(key, value);
  }

  public void commit() {
    for (TransactionalCache txCache : transactionalCaches.values()) {
      txCache.commit();
    }
  }

  public void rollback() {
    for (TransactionalCache txCache : transactionalCaches.values()) {
      txCache.rollback();
    }
  }

  private TransactionalCache getTransactionalCache(Cache cache) {
    TransactionalCache txCache = transactionalCaches.get(cache);
    if (txCache == null) {
      txCache = new TransactionalCache(cache);
      transactionalCaches.put(cache, txCache);
    }
    return txCache;
  }
}

暂存区 TransactionalCache

暂时存放待缓存的数据区域,和缓存区是一一对应的。如果会话会涉及多个二级缓存的访问,那么对应暂存区也会有多个。暂存区生命周期与会话保持一至。

缓存区

缓存区是通过Mapper声明而获得,默认每个Mapper都有独立的缓存区。其作用是真正存放数据和实现缓存的业务逻辑。如序列化、防止缓存穿透、缓存有效期等。

在设计上采用的是装饰器模式。即不同的功能由不同缓存装饰器实现。下表为装饰器类和对应的功能。

装饰器功能
SynchronizedCache同步锁,用于保证对指定缓存区的操作都是同步的
LoggingCache统计器,记录缓存命中率
BlockingCache阻塞器,基于key加锁,防止缓存穿透
ScheduledCache时效检查,用于验证缓存有效器,并清除无效数据
LruCache溢出算法,淘汰闲置最久的缓存
FifoCache溢出算法,淘汰加入时间最久的缓存
WeakCache溢出算法,基于java弱引用规则淘汰缓存
SoftCache溢出算法,基于java软引用规则淘汰缓存
PerpetualCache实际存储,内部采用HashMap进行存储

每个装饰器都会通过属性引用下一个装饰器,从而组成一个链条。缓存逻辑基于链条进行传递。
在这里插入图片描述

SynchronizedCache:线程同步

这个类中操作缓存的方法都被synchronized修饰,所以这样就能保证线程安全。

public class SynchronizedCache implements Cache {

  private final Cache delegate;
  
  public SynchronizedCache(Cache delegate) {
    this.delegate = delegate;
  }

  @Override
  public String getId() {
    return delegate.getId();
  }

  @Override
  public synchronized int getSize() {
    return delegate.getSize();
  }

  @Override
  public synchronized void putObject(Object key, Object object) {
    delegate.putObject(key, object);
  }

  @Override
  public synchronized Object getObject(Object key) {
    return delegate.getObject(key);
  }

  @Override
  public synchronized Object removeObject(Object key) {
    return delegate.removeObject(key);
  }

  @Override
  public synchronized void clear() {
    delegate.clear();
  }
LoggingCache:记录命中率
@Override
public Object getObject(Object key) {
  //请求次数
  requests++;
  final Object value = delegate.getObject(key);
  //如果命中
  if (value != null) {
    hits++;
  }
  //Debug日志输出
  if (log.isDebugEnabled()) {
    log.debug("Cache Hit Ratio [" + getId() + "]: " + getHitRatio());
  }
  return value;
}
/**
*计算命中率
*/
private double getHitRatio() {
 	return (double) hits / (double) requests;
}
SerializedCache:数据(反)序列化

序列化的目的是为了保证数据在存入缓存前和取出缓存后是内容是一致的,而存入和取出的是两个不同的对象。Java值传递和序列化的内容就不展开了。

@Override
public void putObject(Object key, Object object) {
  if (object == null || object instanceof Serializable) {
    delegate.putObject(key, serialize((Serializable) object));
  } else {
    throw new CacheException("SharedCache failed to make a copy of a non-serializable object: " + object);
  }
}

@Override
public Object getObject(Object key) {
  Object object = delegate.getObject(key);
  return object == null ? null : deserialize((byte[]) object);
}
LruCache:防溢出
/**
 * Lru (least recently used) cache decorator
 *
 * @author Clinton Begin
 */
public class LruCache implements Cache {

  private final Cache delegate;
  private Map<Object, Object> keyMap;
  private Object eldestKey;

  public LruCache(Cache delegate) {
    this.delegate = delegate;
    // 默认大小1024
    setSize(1024);
  }

  public void setSize(final int size) {
  	// LinkedHashMap#removeEldestEntry实现LRU算法,accessOrder要设为true
    keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
      private static final long serialVersionUID = 4267176411845948333L;

      @Override
      protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
        boolean tooBig = size() > size;
        if (tooBig) {
          eldestKey = eldest.getKey();
        }
        return tooBig;
      }
    };
  }

  @Override
  public void putObject(Object key, Object value) {
    delegate.putObject(key, value);
    // 将key存入LinkedHashMap,如果size大于设定值,就将最老的删除
    cycleKeyList(key);
  }
  
  private void cycleKeyList(Object key) {
    keyMap.put(key, key);
    if (eldestKey != null) {
      delegate.removeObject(eldestKey);
      eldestKey = null;
    }
  }

  @Override
  public Object getObject(Object key) {
  	//touch一下,将用到的key位置提前
    keyMap.get(key); 
    return delegate.getObject(key);
  }
}
FifoCache:防溢出
/**
 * FIFO (first in, first out) cache decorator
 *
 * @author Clinton Begin
 */
public class FifoCache implements Cache {

  private final Cache delegate;
  private final Deque<Object> keyList;
  private int size;

  public FifoCache(Cache delegate) {
    this.delegate = delegate;
    //链表,默认1024
    this.keyList = new LinkedList<Object>();
    this.size = 1024;
  }

  @Override
  public void putObject(Object key, Object value) {
  	//链表尾插,超过容量移除头部元素
    cycleKeyList(key);
    delegate.putObject(key, value);
  }
  
  private void cycleKeyList(Object key) {
    keyList.addLast(key);
    if (keyList.size() > size) {
      Object oldestKey = keyList.removeFirst();
      delegate.removeObject(oldestKey);
    }
  } 
}
ScheduledCache:过期清理
public class ScheduledCache implements Cache {

  private final Cache delegate;
  protected long clearInterval;
  protected long lastClear;

  public ScheduledCache(Cache delegate) {
    this.delegate = delegate;
    // 1 hour清理一次
    this.clearInterval = 60 * 60 * 1000; 
    this.lastClear = System.currentTimeMillis();
  }

  @Override
  public int getSize() {
  	//如果到了清理周期,进行清理
    clearWhenStale();
    return delegate.getSize();
  }
  
  private boolean clearWhenStale() {
    if (System.currentTimeMillis() - lastClear > clearInterval) {
      clear();
      return true;
    }
    return false;
  }
	
  @Override
  public void clear() {
    lastClear = System.currentTimeMillis();
    delegate.clear();
  }

  @Override
  public void putObject(Object key, Object object) {
    clearWhenStale();
    delegate.putObject(key, object);
  }

  @Override
  public Object getObject(Object key) {
    return clearWhenStale() ? null : delegate.getObject(key);
  }

  @Override
  public Object removeObject(Object key) {
    clearWhenStale();
    return delegate.removeObject(key);
  }
BlockingCache:防缓存穿透
/**
 * Simple blocking decorator 
 * 
 * Simple and inefficient version of EhCache's BlockingCache decorator.
 * It sets a lock over a cache key when the element is not found in cache.
 * This way, other threads will wait until this element is filled instead of hitting the database.
 * 
 * @author Eduardo Macarron
 *
 */
public class BlockingCache implements Cache {

  private long timeout;
  private final Cache delegate;
  private final ConcurrentHashMap<Object, ReentrantLock> locks;

  public BlockingCache(Cache delegate) {
    this.delegate = delegate;
    this.locks = new ConcurrentHashMap<Object, ReentrantLock>();
  }

  @Override
  public void putObject(Object key, Object value) {
    try {
      delegate.putObject(key, value);
    } finally {
      releaseLock(key);
    }
  }

  @Override
  public Object getObject(Object key) {
    acquireLock(key);
    Object value = delegate.getObject(key);
    if (value != null) {
      releaseLock(key);
    }        
    return value;
  }

  @Override
  public Object removeObject(Object key) {
    // despite of its name, this method is called only to release locks
    releaseLock(key);
    return null;
  }

  private ReentrantLock getLockForKey(Object key) {
    ReentrantLock lock = new ReentrantLock();
    ReentrantLock previous = locks.putIfAbsent(key, lock);
    return previous == null ? lock : previous;
  }
  
  private void acquireLock(Object key) {
    Lock lock = getLockForKey(key);
    if (timeout > 0) {
      try {
        boolean acquired = lock.tryLock(timeout, TimeUnit.MILLISECONDS);
        if (!acquired) {
          throw new CacheException("Couldn't get a lock in " + timeout + " for the key " +  key + " at the cache " + delegate.getId());  
        }
      } catch (InterruptedException e) {
        throw new CacheException("Got interrupted while trying to acquire lock for key " + key, e);
      }
    } else {
      lock.lock();
    }
  }
  
  private void releaseLock(Object key) {
    ReentrantLock lock = locks.get(key);
    if (lock.isHeldByCurrentThread()) {
      lock.unlock();
    }
  }
}
PerpetualCache:最终存储
public class PerpetualCache implements Cache {

  private Map<Object, Object> cache = new HashMap<Object, Object>();

二级缓存代码流程

@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
    throws SQLException {
  //获取配置的cache信息
  //(<cache/><cache-ref/>@CacheNamespace @CacheNamespaceRef)
  Cache cache = ms.getCache();
  // 如果开启了二级缓存
  if (cache != null) {
  	// 判断是否开启的刷新缓存
    flushCacheIfRequired(ms);
    
    if (ms.isUseCache() && resultHandler == null) {
      // 存储过程相关
      ensureNoOutParams(ms, boundSql);
      @SuppressWarnings("unchecked")
      // 查询二级缓存
      List<E> list = (List<E>) tcm.getObject(cache, key);
      if (list == null) {
        list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
        //查询到数据,填充到暂存区中,提交事务保存到缓存区
        //交给缓存管理器处理
        tcm.putObject(cache, key, list); // issue #578 and #116
      }
      return list;
    }
  }
  return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
private void flushCacheIfRequired(MappedStatement ms) {
  Cache cache = ms.getCache();
  if (cache != null && ms.isFlushCacheRequired()) {      
    tcm.clear(cache);
  }
}
public Object getObject(Cache cache, CacheKey key) {
  return getTransactionalCache(cache).getObject(key);
}

private TransactionalCache getTransactionalCache(Cache cache) {
  TransactionalCache txCache = transactionalCaches.get(cache);
  if (txCache == null) {
    txCache = new TransactionalCache(cache);
    transactionalCaches.put(cache, txCache);
  }
  return txCache;
}

@Override
public Object getObject(Object key) {
  // issue #116
  Object object = delegate.getObject(key);
  if (object == null) {
  	// 防缓存穿透
    entriesMissedInCache.add(key);
  }
  // issue #146
  if (clearOnCommit) {
    return null;
  } else {
    return object;
  }
}

拦截器 Interceptor

添加plugin流程

一般plugin实现Interceptor接口,并在xml中进行配置

<plugins>

	<plugin interceptor="***.interceptor1"/>
    <plugin interceptor="***.interceptor2"/>
    ...
    
   <!-- com.github.pagehelper为PageHelper类所在包名 -->
   <plugin interceptor="com.github.pagehelper.PageInterceptor">
       <!-- 设置数据库方言 -->
       <property name="helperDialect" value="mysql"/>
       <!-- 合理化分页 -->
       <property name="reasonable" value="true"/>
	</plugin>
</plugins>

插件初始化完成之后,添加插件的流程如下:在这里插入图片描述

//Configuration类
public class Configuration {
	//拦截器链,责任链模式
	protected final InterceptorChain interceptorChain = new InterceptorChain();
	//mybatis 插件的拦截目标有四个
	//Executor、StatementHandler、ParameterHandler、ResultSetHandler
	public ParameterHandler newParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {
    ParameterHandler parameterHandler = mappedStatement.getLang().createParameterHandler(mappedStatement, parameterObject, boundSql);
    //这里
    parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler);
    return parameterHandler;
  }

  public ResultSetHandler newResultSetHandler(Executor executor, MappedStatement mappedStatement, RowBounds rowBounds, ParameterHandler parameterHandler,
      ResultHandler resultHandler, BoundSql boundSql) {
    ResultSetHandler resultSetHandler = new DefaultResultSetHandler(executor, mappedStatement, parameterHandler, resultHandler, boundSql, rowBounds);
    //这里
    resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler);
    return resultSetHandler;
  }

  public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
    StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql);
    //这里
    statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler);
    return statementHandler;
  }

  public Executor newExecutor(Transaction transaction) {
    return newExecutor(transaction, defaultExecutorType);
  }

  public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
    executorType = executorType == null ? defaultExecutorType : executorType;
    executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
    Executor executor;
    if (ExecutorType.BATCH == executorType) {
      executor = new BatchExecutor(this, transaction);
    } else if (ExecutorType.REUSE == executorType) {
      executor = new ReuseExecutor(this, transaction);
    } else {
      executor = new SimpleExecutor(this, transaction);
    }
    if (cacheEnabled) {
      executor = new CachingExecutor(executor);
    }
    //这里
    executor = (Executor) interceptorChain.pluginAll(executor);
    return executor;
  }
}
public class InterceptorChain {

  private final List<Interceptor> interceptors = new ArrayList<Interceptor>();

  public Object pluginAll(Object target) {
    for (Interceptor interceptor : interceptors) {
      target = interceptor.plugin(target);
    }
    return target;
  }
}

使用的时候都是用动态代理将多个插件用责任链的方式添加的,最后返回的是一个代理对象,最终动态代理生成和调用的过程都在 Plugin 类中:

public class Plugin implements InvocationHandler {

  private Object target;
  private Interceptor interceptor;
  private Map<Class<?>, Set<Method>> signatureMap;

  public static Object wrap(Object target, Interceptor interceptor) {
  	// 获取签名Map
    Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
    // 拦截目标 (ParameterHandler|ResultSetHandler|StatementHandler|Executor)
    Class<?> type = target.getClass();
    // 获取目标接口
    Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
    if (interfaces.length > 0) {
      return Proxy.newProxyInstance(
          type.getClassLoader(),
          interfaces,
          new Plugin(target, interceptor, signatureMap));
    }
    return target;
  }
  //调用
  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
      Set<Method> methods = signatureMap.get(method.getDeclaringClass());
      if (methods != null && methods.contains(method)) {
        return interceptor.intercept(new Invocation(target, method, args));
      }
      return method.invoke(target, args);
    } catch (Exception e) {
      throw ExceptionUtil.unwrapThrowable(e);
    }
  }

签名Map就是获得注解@Signature中的typemethod

@Intercepts({
  @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
  @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class ExamplePlugin implements Interceptor {
  public Object intercept(Invocation invocation) throws Throwable {
    ...
  }
}

参考

https://www.cnblogs.com/UYGHYTYH/p/12995060.html
https://mp.weixin.qq.com/s/Oxjv4G0grivGQW3oNSlvKA
https://mp.weixin.qq.com/s/zVjLl4FRePkiGLRuNah7Fg
https://www.cnblogs.com/sanzao/p/11423849.html
https://blog.csdn.net/qq_27470131/article/details/106483560
https://www.cnblogs.com/zhaochi/p/13033439.html
https://www.cnblogs.com/imhero0314/articles/13038159.html
https://codingxxm.gitee.io/2020/05/31/Mybatis%EF%BC%9A%E5%9F%BA%E7%A1%80%E6%89%A7%E8%A1%8C%E5%99%A8%E4%B8%8E%E4%B8%80%E7%BA%A7%E7%BC%93%E5%AD%98%E5%A4%84%E7%90%86/

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值