2025年1月

sqlSessionFactory 与 SqlSession

正如其名,Sqlsession对应着一次数据库会话。由于数据库会话不是永久的,因此Sqlsession的生命周期也不应该是永久的,相反,在你每次访问数据库时都需要创建它(当然并不是说在Sqlsession里只能执行一次sql,你可以执行多次,当一旦关闭了Sqlsession就需要重新创建它)。

那么咱们就先看看是怎么获取SqlSession的吧:

首先,SqlSessionFactoryBuilder去读取mybatis的配置文件,然后build一个DefaultSqlSessionFactory。源码如下:

 /**
  * 一系列的构造方法最终都会调用本方法(配置文件为Reader时会调用本方法,还有一个InputStream方法与此对应)
  * @param reader
  * @param environment
  * @param properties
  * @return
  */
 public SqlSessionFactory build(Reader reader, String environment, Properties properties) {
   try {
     //通过XMLConfigBuilder解析配置文件,解析的配置相关信息都会封装为一个Configuration对象
     XMLConfigBuilder parser = new XMLConfigBuilder(reader, environment, properties);
     //这儿创建DefaultSessionFactory对象
     return build(parser.parse());
   } catch (Exception e) {
     throw ExceptionFactory.wrapException("Error building SqlSession.", e);
   } finally {
     ErrorContext.instance().reset();
     try {
       reader.close();
     } catch (IOException e) {
       // Intentionally ignore. Prefer previous error.
     }
   }
 }

 public SqlSessionFactory build(Configuration config) {
   return new DefaultSqlSessionFactory(config);
 }

当我们获取到SqlSessionFactory之后,就可以通过SqlSessionFactory去获取SqlSession对象。源码如下:

/**
  * 通常一系列openSession方法最终都会调用本方法
  * @param execType 
  * @param level
  * @param autoCommit
  * @return
  */
 private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
   Transaction tx = null;
   try {
     //通过Confuguration对象去获取Mybatis相关配置信息, Environment对象包含了数据源和事务的配置
     final Environment environment = configuration.getEnvironment();
     final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
     tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
     //之前说了,从表面上来看,咱们是用sqlSession在执行sql语句, 实际呢,其实是通过excutor执行, excutor是对于Statement的封装
     final Executor executor = configuration.newExecutor(tx, execType);
     //关键看这儿,创建了一个DefaultSqlSession对象
     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();
   }
 }

通过以上步骤,咱们已经得到SqlSession对象了。接下来就是该干嘛干嘛去了(话说还能干嘛,当然是执行sql语句咯)。看了上面,咱们也回想一下之前写的Demo:

SqlSessionFactory sessionFactory = null;  
String resource = "mybatis-conf.xml";  
try {
    //SqlSessionFactoryBuilder读取配置文件
   sessionFactory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsReader(resource));
} catch (IOException e) {  
   e.printStackTrace();  
}    
//通过SqlSessionFactory获取SqlSession
SqlSession sqlSession = sessionFactory.openSession();

创建Sqlsession的地方只有一个,那就是SqlsessionFactory的openSession方法:

public SqlSessionopenSession() {  
    return openSessionFromDataSource(configuration.getDefaultExecutorType(),null, false);  
}

我们可以看到实际创建SqlSession的地方是openSessionFromDataSource,如下:

private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {  
 
    Connection connection = null;  
 
    try {  
 
        final Environment environment = configuration.getEnvironment();  
 
        final DataSource dataSource = getDataSourceFromEnvironment(environment);  
        
        // MyBatis对事务的处理相对简单,TransactionIsolationLevel中定义了几种隔离级别,并不支持内嵌事务这样较复杂的场景,同时由于其是持久层的缘故,所以真正在应用开发中会委托Spring来处理事务实现真正的与开发者隔离。分析事务的实现是个入口,借此可以了解不少JDBC规范方面的事情。
        TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);  
 
        connection = dataSource.getConnection();  
 
        if (level != null) {  
            connection.setTransactionIsolation(level.getLevel());
        }  
 
        connection = wrapConnection(connection);  
 
        Transaction tx = transactionFactory.newTransaction(connection,autoCommit);  
 
        Executorexecutor = configuration.newExecutor(tx, execType);  
 
        return newDefaultSqlSession(configuration, executor, autoCommit);  
 
    } catch (Exceptione) {  
        closeConnection(connection);  
        throwExceptionFactory.wrapException("Error opening session.  Cause: " + e, e);  
    } finally {
        ErrorContext.instance().reset();
    }
}  

可以看出,创建sqlsession经过了以下几个主要步骤:

  • 从配置中获取Environment;
  • 从Environment中取得DataSource;
  • 从Environment中取得TransactionFactory;
  • 从DataSource里获取数据库连接对象Connection;
  • 在取得的数据库连接上创建事务对象Transaction;
  • 创建Executor对象(该对象非常重要,事实上sqlsession的所有操作都是通过它完成的);
  • 创建sqlsession对象。

SqlSession咱们也拿到了,咱们可以调用SqlSession中一系列的select..., insert..., update..., delete...方法轻松自如的进行CRUD操作了。就这样?那咱配置的映射文件去哪儿了?别急,咱们接着往下看。

MapperProxy

在mybatis中,通过MapperProxy动态代理咱们的dao, 也就是说, 当咱们执行自己写的dao里面的方法的时候,其实是对应的mapperProxy在代理。那么,咱们就看看怎么获取MapperProxy对象吧:

通过SqlSession从Configuration中获取。源码如下:

 /**
  * 什么都不做,直接去configuration中找
  */
 @Override
 public <T> T getMapper(Class<T> type) {
   return configuration.<T>getMapper(type, this);
 }

SqlSession把包袱甩给了Configuration, 接下来就看看Configuration。源码如下:

 public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
   return mapperRegistry.getMapper(type, sqlSession);
 }

接着调用了MapperRegistry,源码如下:

@SuppressWarnings("unchecked")
 public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
   //交给MapperProxyFactory去做
   final MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory<T>) knownMappers.get(type);
   if (mapperProxyFactory == null) {
     throw new BindingException("Type " + type + " is not known to the MapperRegistry.");
   }
   try {
     //关键在这儿
     return mapperProxyFactory.newInstance(sqlSession);
   } catch (Exception e) {
     throw new BindingException("Error getting mapper instance. Cause: " + e, e);
   }
 }

MapperProxyFactory源码:

  @SuppressWarnings("unchecked")
 protected T newInstance(MapperProxy<T> mapperProxy) {
   //动态代理dao接口
   return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy);
 }
 
 public T newInstance(SqlSession sqlSession) {
   final MapperProxy<T> mapperProxy = new MapperProxy<T>(sqlSession, mapperInterface, methodCache);
   return newInstance(mapperProxy);
 }

通过以上的动态代理,咱们就可以方便地使用dao接口啦, 就像之前咱们写的demo那样:

UserDao userMapper = sqlSession.getMapper(UserDao.class);  
User insertUser = new User();

这下方便多了吧, 呵呵, 貌似mybatis的源码就这么一回事儿啊。具体详细介绍,请参见MyBatis Mapper 接口如何通过JDK动态代理来包装SqlSession 源码分析。别急,还没完, 咱们还没看具体是怎么执行sql语句的呢。

Excutor

Executor与Sqlsession的关系就像市长与书记,Sqlsession只是个门面,真正干事的是Executor,Sqlsession对数据库的操作都是通过Executor来完成的。与Sqlsession一样,Executor也是动态创建的:

  • Executor创建的源代码
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 = newSimpleExecutor(this, transaction);
    }

    if (cacheEnabled) {
        executor = new CachingExecutor(executor);  
    }
    executor = (Executor) interceptorChain.pluginAll(executor);  
    return executor;  
}  

可以看出,

  • 如果不开启cache的话,创建的Executor是3种基础类型之一
    • BatchExecutor专门用于执行批量sql操作
    • ReuseExecutor会重用statement执行sql操作
    • SimpleExecutor只是简单执行sql没有什么特别的
  • 开启cache的话(默认是开启的并且没有任何理由去关闭它),就会创建CachingExecutor,它以前面创建的Executor作为唯一参数。CachingExecutor在查询数据库前先查找缓存,若没找到的话调用delegate(就是构造时传入的Executor对象)从数据库查询,并将查询结果存入缓存中。

Executor对象是可以被插件拦截的,如果定义了针对Executor类型的插件,最终生成的Executor对象是被各个插件插入后的代理对象。

接下来,去看sql的执行过程。上面,拿到了MapperProxy, 每个MapperProxy对应一个dao接口, 那么在使用的时候,MapperProxy是怎么做的呢?

  • MapperProxy

我们知道对被代理对象的方法的访问都会落实到代理者的invoke上来,MapperProxy的invoke如下:

  /**
   * MapperProxy在执行时会触发此方法
   */
  @Override
  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    if (Object.class.equals(method.getDeclaringClass())) {
      try {
        return method.invoke(this, args);
      } catch (Throwable t) {
        throw ExceptionUtil.unwrapThrowable(t);
      }
    }
    final MapperMethod mapperMethod = cachedMapperMethod(method);
    //二话不说,主要交给MapperMethod自己去管
    return mapperMethod.execute(sqlSession, args);
  }
  • MapperMethod

就像是一个分发者,他根据参数和返回值类型选择不同的sqlsession方法来执行。这样mapper对象与sqlsession就真正的关联起来了。

  /**
   * 看着代码不少,不过其实就是先判断CRUD类型,然后根据类型去选择到底执行sqlSession中的哪个方法,绕了一圈,又转回sqlSession了
   * @param sqlSession
   * @param args
   * @return
   */
  public Object execute(SqlSession sqlSession, Object[] args) {
    Object result;
    if (SqlCommandType.INSERT == command.getType()) {
      Object param = method.convertArgsToSqlCommandParam(args);
      result = rowCountResult(sqlSession.insert(command.getName(), param));
    } else if (SqlCommandType.UPDATE == command.getType()) {
      Object param = method.convertArgsToSqlCommandParam(args);
      result = rowCountResult(sqlSession.update(command.getName(), param));
    } else if (SqlCommandType.DELETE == command.getType()) {
      Object param = method.convertArgsToSqlCommandParam(args);
      result = rowCountResult(sqlSession.delete(command.getName(), param));
    } else if (SqlCommandType.SELECT == command.getType()) {
      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 {
        Object param = method.convertArgsToSqlCommandParam(args);
        result = sqlSession.selectOne(command.getName(), param);
      }
    } else {
      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;
  }

既然又回到SqlSession了,前面提到过,sqlsession只是一个门面,真正发挥作用的是executor,对sqlsession方法的访问最终都会落到executor的相应方法上去。Executor分成两大类,一类是CacheExecutor,另一类是普通Executor。Executor的创建前面已经介绍了,那么咱们就看看SqlSession的CRUD方法了,为了省事,还是就选择其中的一个方法来做分析吧。这儿,咱们选择了selectList方法:

  public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
    try {
      MappedStatement ms = configuration.getMappedStatement(statement);
      //CRUD实际上是交给Excetor去处理, excutor其实也只是穿了个马甲而已,小样,别以为穿个马甲我就不认识你嘞!
      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();
    }
  }
  • CacheExecutor

CacheExecutor有一个重要属性delegate,它保存的是某类普通的Executor,值在构照时传入。执行数据库update操作时,它直接调用delegate的update方法,执行query方法时先尝试从cache中取值,取不到再调用delegate的查询方法,并将查询结果存入cache中。代码如下:

public List query(MappedStatement ms, Object parameterObject, RowBounds rowBounds,ResultHandler resultHandler) throws SQLException {  
    if (ms != null) {  
        Cache cache = ms.getCache();  
        if (cache != null) {  
            flushCacheIfRequired(ms);  
            cache.getReadWriteLock().readLock().lock();  
            try {  
                if (ms.isUseCache() && resultHandler ==null) {  
                    CacheKey key = createCacheKey(ms, parameterObject, rowBounds);  
                    final List cachedList = (List)cache.getObject(key);  
                    if (cachedList != null) {  
                        return cachedList;  
                    } else {  
                        List list = delegate.query(ms,parameterObject, rowBounds, resultHandler);  
                        tcm.putObject(cache,key, list);  
                        return list;  
                    }  
                } else {  
                    return delegate.query(ms,parameterObject, rowBounds, resultHandler);  
                }  
            } finally {  
                cache.getReadWriteLock().readLock().unlock();  
            }
        }  
    }  
    return delegate.query(ms,parameterObject, rowBounds, resultHandler);  
}
  • 普通Executor

有3类,他们都继承于BaseExecutor

  • BatchExecutor专门用于执行批量sql操作
  • ReuseExecutor会重用statement执行sql操作
  • SimpleExecutor只是简单执行sql没有什么特别的

下面以SimpleExecutor为例:

public List doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds,ResultHandler resultHandler) throws SQLException {  
    Statement stmt = null;  
    try {  
        Configuration configuration = ms.getConfiguration();  
        StatementHandler handler = configuration.newStatementHandler(this, ms,parameter, rowBounds,resultHandler);  
        stmt =prepareStatement(handler);  
        returnhandler.query(stmt, resultHandler);  
    } finally {  
        closeStatement(stmt);  
    }  
} 

然后,通过一层一层的调用,最终会来到doQuery方法, 这儿咱们就随便找个Excutor看看doQuery方法的实现吧,我这儿选择了SimpleExecutor:

  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());
      //StatementHandler封装了Statement, 让 StatementHandler 去处理
      return handler.<E>query(stmt, resultHandler);
    } finally {
      closeStatement(stmt);
    }
  }

Mybatis内置的ExecutorType有3种,默认的是simple,该模式下它为每个语句的执行创建一个新的预处理语句,单条提交sql;而batch模式重复使用已经预处理的语句, 并且批量执行所有更新语句,显然batch性能将更优;

但batch模式也有自己的问题,比如在Insert操作时,在事务没有提交之前,是没有办法获取到自增的id,这在某型情形下是不符合业务要求的;

通过走码和研读spring相关文件发现,在同一事务中batch模式和simple模式之间无法转换,由于本项目一开始选择了simple模式,所以碰到需要批量更新时,只能在单独的事务中进行;

在代码中使用batch模式可以使用以下方式:

//从spring注入原有的sqlSessionTemplate
@Autowired
private SqlSessionTemplate sqlSessionTemplate;
 
public void testInsertBatchByTrue() {
    //新获取一个模式为BATCH,自动提交为false的session
    //如果自动提交设置为true,将无法控制提交的条数,改为最后统一提交,可能导致内存溢出
    SqlSession session = sqlSessionTemplate.getSqlSessionFactory().openSession(ExecutorType.BATCH, false);
    //通过新的session获取mapper
    fooMapper = session.getMapper(FooMapper.class);
    int size = 10000;
    try {
        for (int i = 0; i < size; i++) {
            Foo foo = new Foo();
            foo.setName(String.valueOf(System.currentTimeMillis()));
            fooMapper.insert(foo);
            if (i % 1000 == 0 || i == size - 1) {
                //手动每1000个一提交,提交后无法回滚
                session.commit();
                //清理缓存,防止溢出
                session.clearCache();
            }
        }
    } catch (Exception e) {
        //没有提交的数据可以回滚
        session.rollback();
    } finally {
        session.close();
    }
}

上述代码没有使用spring的事务,改手动控制,如果和原spring事务一起使用,将无法回滚,必须注意,最好单独使用;

StatementHandler

可以看出,Executor本质上也没有进行处理,具体的事情原来是StatementHandler来完成的。当Executor将指挥棒交给StatementHandler后,接下来的工作就是StatementHandler的事了。我们先看看StatementHandler是如何创建的:

public StatementHandler newStatementHandler(Executor executor, MappedStatementmappedStatement,  
        ObjectparameterObject, RowBounds rowBounds, ResultHandler resultHandler) {  
    StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement,parameterObject,rowBounds, resultHandler);  
    statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler);  
    return statementHandler;
}  

可以看到每次创建的StatementHandler都是RoutingStatementHandler,它只是一个分发者,他一个属性delegate用于指定用哪种具体的StatementHandler。可选的StatementHandler有SimpleStatementHandler、PreparedStatementHandler和CallableStatementHandler三种。选用哪种在mapper配置文件的每个statement里指定,默认的是PreparedStatementHandler。同时还要注意到StatementHandler是可以被拦截器拦截的,和Executor一样,被拦截器拦截后的对像是一个代理对象。由于mybatis没有实现数据库的物理分页,众多物理分页的实现都是在这个地方使用拦截器实现的,本文作者也实现了一个分页拦截器,在后续的章节会分享给大家,敬请期待。

StatementHandler创建后需要执行一些初始操作,比如statement的开启和参数设置、对于PreparedStatement还需要执行参数的设置操作等。代码如下:

private Statement prepareStatement(StatementHandler handler) throws SQLException {  
    Statement stmt;  
    Connection connection = transaction.getConnection();  
    stmt =handler.prepare(connection);  
    handler.parameterize(stmt);  
    return stmt;  
}

statement的开启和参数设置没什么特别的地方,handler.parameterize倒是可以看看是怎么回事。handler.parameterize通过调用ParameterHandler的setParameters完成参数的设置,ParameterHandler随着StatementHandler的创建而创建,默认的实现是DefaultParameterHandler:

public ParameterHandler newParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {  
   ParameterHandler parameterHandler = new DefaultParameterHandler(mappedStatement,parameterObject,boundSql);  
   parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler);  
   return parameterHandler;  
}

同Executor和StatementHandler一样,ParameterHandler也是可以被拦截的。DefaultParameterHandler里设置参数的代码如下:

public void setParameters(PreparedStatement ps) throws SQLException {  
    ErrorContext.instance().activity("settingparameters").object(mappedStatement.getParameterMap().getId());  
    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();  
    if(parameterMappings != null) {  
        MetaObject metaObject = parameterObject == null ? null :configuration.newMetaObject(parameterObject);  
        for (int i = 0; i< parameterMappings.size(); i++) {  
            ParameterMapping parameterMapping = parameterMappings.get(i);  
            if(parameterMapping.getMode() != ParameterMode.OUT) {  
                Object value;  
                String propertyName = parameterMapping.getProperty();  
                PropertyTokenizer prop = newPropertyTokenizer(propertyName);  
                if (parameterObject == null) {  
                    value = null;  
                } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())){  
                    value = parameterObject;  
                } else if (boundSql.hasAdditionalParameter(propertyName)){  
                    value = boundSql.getAdditionalParameter(propertyName);  
                } else if(propertyName.startsWith(ForEachSqlNode.ITEM_PREFIX)  
                        && boundSql.hasAdditionalParameter(prop.getName())){  
                    value = boundSql.getAdditionalParameter(prop.getName());  
                    if (value != null) {  
                        value = configuration.newMetaObject(value).getValue(propertyName.substring(prop.getName().length()));  
                    }  
                } else {  
                    value = metaObject == null ? null :metaObject.getValue(propertyName);  
                }  
                TypeHandler typeHandler = parameterMapping.getTypeHandler();  
                if (typeHandler == null) {  
                   throw new ExecutorException("Therewas no TypeHandler found for parameter " + propertyName  + " of statement " + mappedStatement.getId());  
                }  
                typeHandler.setParameter(ps, i + 1, value,parameterMapping.getJdbcType());  
            }  
  
        }  
  
    }  
} 

这里面最重要的一句其实就是最后一句代码,它的作用是用合适的TypeHandler完成参数的设置。那么什么是合适的TypeHandler呢,它又是如何决断出来的呢?BaseStatementHandler的构造方法里有这么一句:

this.boundSql= mappedStatement.getBoundSql(parameterObject);

它触发了sql 的解析,在解析sql的过程中,TypeHandler也被决断出来了,决断的原则就是根据参数的类型和参数对应的JDBC类型决定使用哪个TypeHandler。比如:参数类型是String的话就用StringTypeHandler,参数类型是整数的话就用IntegerTypeHandler等。

参数设置完毕后,执行数据库操作(update或query)。如果是query最后还有个查询结果的处理过程。

接下来,咱们看看StatementHandler 的一个实现类 PreparedStatementHandler(这也是我们最常用的,封装的是PreparedStatement), 看看它使怎么去处理的:

  public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
    // 到此,原形毕露, PreparedStatement, 这个大家都已经滚瓜烂熟了吧
    PreparedStatement ps = (PreparedStatement) statement;
    ps.execute();
    // 结果交给了ResultSetHandler 去处理
    return resultSetHandler.<E> handleResultSets(ps);
  }

结果处理使用ResultSetHandler来完成,默认的ResultSetHandler是FastResultSetHandler,它在创建StatementHandler时一起创建,代码如下:

public ResultSetHandler newResultSetHandler(Executor executor, MappedStatement mappedStatement,  
RowBounds rowBounds, ParameterHandler parameterHandler, ResultHandler resultHandler, BoundSql boundSql) {  
   ResultSetHandler resultSetHandler = mappedStatement.hasNestedResultMaps() ? newNestedResultSetHandler(executor, mappedStatement, parameterHandler, resultHandler, boundSql, rowBounds): new FastResultSetHandler(executor,mappedStatement, parameterHandler, resultHandler, boundSql, rowBounds);  
   resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler);  
   return resultSetHandler;  
} 

可以看出ResultSetHandler也是可以被拦截的,可以编写自己的拦截器改变ResultSetHandler的默认行为。ResultSetHandler内部一条记录一条记录的处理,在处理每条记录的每一列时会调用TypeHandler转换结果,如下:

protected boolean applyAutomaticMappings(ResultSet rs, List<String> unmappedColumnNames,MetaObject metaObject) throws SQLException {  
    boolean foundValues = false;  
    for (String columnName : unmappedColumnNames) {  
        final String property = metaObject.findProperty(columnName);  
        if (property!= null) {  
            final ClasspropertyType =metaObject.getSetterType(property);  
            if (typeHandlerRegistry.hasTypeHandler(propertyType)) {  
                final TypeHandler typeHandler = typeHandlerRegistry.getTypeHandler(propertyType);  
                final Object value = typeHandler.getResult(rs,columnName);  
                if (value != null) {  
                    metaObject.setValue(property, value);  
                    foundValues = true;  
                }  
            }  
        }  
    }  
    return foundValues;  
}

从代码里可以看到,决断TypeHandler使用的是结果参数的属性类型。因此我们在定义作为结果的对象的属性时一定要考虑与数据库字段类型的兼容性。到此, 一次sql的执行流程就完了。

之前写过一篇介绍
Python

dataclass
的文章:
《掌握python的dataclass,让你的代码更简洁优雅》

那篇侧重于介绍
dataclass
的使用,今天想探索一下这个有趣的特性是如何实现的。

表面上看,
dataclass
就是一个普通的装饰器,但是它又在
class
上实现了很多神奇的功能,

为我们在
Python
中定义和使用
class
带来了极大的便利。

如果你也好奇它在幕后是如何工作的,本篇我们就一同揭开
Python

dataclass
的神秘面纱,

深入探究一下其内部原理。

1. dataclass简介

dataclass
为我们提供了一种简洁而高效的方式来定义类,特别是那些主要用于存储数据的类。

它能自动为我们生成一些常用的方法,如
__init__

__repr__
等,大大减少了样板代码的编写。

例如,我在量化中经常用的一个
K线
数据,用dataclass来定义的话,如下所示:

from dataclasses import dataclass
from datetime import datetime

@dataclass
class KLine:
    name: str = "BTC"
    open_price: float = 0.0
    close_price: float = 0.0
    high_price: float = 0.0
    low_price: float = 0.0
    begin_time: datetime = datetime.now()

if __name__ == "__main__":
    kl = KLine()
    print(kl)

这样,我们无需手动编写
__init__
方法来初始化对象,就可以轻松创建
KLine
类的实例,

并且直接打印对象也可以得到清晰,易于阅读的输出。

$  python.exe .\kline.py
KLine(name='BTC', open_price=0.0, close_price=0.0, 
high_price=0.0, low_price=0.0, 
begin_time=datetime.datetime(2025, 1, 2, 17, 45, 53, 44463))

但这背后究竟发生了什么呢?

2. 核心概念

dataclass

Python3.7
版本开始,已经加入到标准库中了。

代码就在
Python
安装目录中的
Lib/dataclasses.py
文件中。

实现这个装饰器功能的核心有两个:
__annotations__
属性和
exec
函数。

2.1. __annotations__属性

__annotations__

Python
中一个隐藏的宝藏,它以字典的形式存储着变量、属性以及函数参数或返回值的类型提示。

对于
dataclass
来说,它就像是一张地图,装饰器通过它来找到用户定义的字段。

比如,在上面的
KLine
类中,
__annotations__
会返回字段的相关信息。

这使得
dataclass
装饰器能够清楚地知道类中包含哪些字段以及它们的类型,为后续的操作提供了关键信息。

if __name__ == "__main__":
    print(KLine.__annotations__)

# 运行结果:
{'name': <class 'str'>, 'open_price': <class 'float'>, 
'close_price': <class 'float'>, 'high_price': <class 'float'>, 
'low_price': <class 'float'>, 'begin_time': <class 'datetime.datetime'>}

2.2. exec 函数

exec
函数堪称
dataclass
实现的魔法棒,它能够将字符串形式的代码转换为
Python
对象。


dataclass
的世界里,它被用来创建各种必要的方法。

我们可以通过构建函数定义的字符串,然后使用
exec
将其转化为真正的函数,并添加到类中。

这就是
dataclass
装饰器能够自动生成
__init__

__repr__
等方法的秘密所在。

下面的代码通过
exec
,将一个字符串代码转换成一个真正可使用的函数。

# 定义一个存储代码的字符串
code_string = """
def greet(name):
    print(f"Hello, {name}!")
"""

# 使用 exec 函数执行代码字符串
exec(code_string)

# 调用通过 exec 生成的函数
greet("Alice")

3. 自定义dataclass装饰器

掌握了上面的核心概念,我们就可以开始尝试实现自己的
dataclass
装饰器。

当然,这里只是简单实现个雏形,目的是为了了解
Python
标准库中
dataclass
的原理。

下面主要实现两个功能
__init__

__repr__

通过这两个功能来理解
dataclass
的实现原理。

3.1. 定义架构

我们首先定义一个
dataclass
装饰器,它的结构如下:

def dataclass(cls=None, init=True, repr=True):

    def wrap(cls):
        # 这里将对类进行修改
        return cls

    if cls is None:
        return wrap
    return wrap(cls)

接下来,我们在这个装饰器中实现
__init__

__repr__

3.2. 初始化:
init


init
参数为
True
时,我们为类添加
__init__
方法。

通过
_init_fn
函数来实现,它会根据类的字段生成
__init__
方法的函数定义字符串,然后使用
_create_fn
函数将其转换为真正的方法并添加到类中。

def _create_fn(cls, name, fn):
    ns = {}
    exec(fn, None, ns)
    method = ns[name]
    setattr(cls, name, method)


def _init_fn(cls, fields):
    args = ", ".join(fields)

    lines = [f"self.{field} = {field}" for field in fields]
    body = "\n".join(f"  {line}" for line in lines)

    txt = f"def __init__(self, {args}):\n{body}"

    _create_fn(cls, "__init__", txt)

3.3. 美化输出:
repr

__repr__
方法让我们能够以一种清晰易读的方式打印出类的实例。

为了实现这个功能,我们创建
_repr_fn
函数,它生成
__repr__
方法的定义字符串。

这个方法会获取实例的
__dict__
属性中的所有变量,并使用
f-string
进行格式化输出。

def _repr_fn(cls, fields):
    txt = (
        "def __repr__(self):\n"
        "    fields = [f'{key}={val!r}' for key, val in self.__dict__.items()]\n"
        "    return f'{self.__class__.__name__}({\"\\n \".join(fields)})'"
    )
    _create_fn(cls, "__repr__", txt)

3.4. 合在一起

最终的代码如下,代码中使用的是自己的
dataclass
装饰器,而不是标准库中的
dataclass

from datetime import datetime


def dataclass(cls=None, init=True, repr=True):

    def wrap(cls):
        fields = cls.__annotations__.keys()

        if init:
            _init_fn(cls, fields)

        if repr:
            _repr_fn(cls, fields)

        return cls

    if cls is None:  # 如果装饰器带参数
        return wrap

    return wrap(cls)


def _create_fn(cls, name, fn):
    ns = {}
    exec(fn, None, ns)
    method = ns[name]
    setattr(cls, name, method)


def _init_fn(cls, fields):
    args = ", ".join(fields)

    lines = [f"self.{field} = {field}" for field in fields]
    body = "\n".join(f"  {line}" for line in lines)

    txt = f"def __init__(self, {args}):\n{body}"

    _create_fn(cls, "__init__", txt)


def _repr_fn(cls, fields):
    txt = (
        "def __repr__(self):\n"
        "    fields = [f'{key}={val!r}' for key, val in self.__dict__.items()]\n"
        "    return f'{self.__class__.__name__}({\"\\n \".join(fields)})'"
    )
    _create_fn(cls, "__repr__", txt)


@dataclass
class KLine:
    name: str = "BTC"
    open_price: float = 0.0
    close_price: float = 0.0
    high_price: float = 0.0
    low_price: float = 0.0
    begin_time: datetime = datetime.now()


if __name__ == "__main__":
    kl = KLine(
        name="ETH",
        open_price=1000.5,
        close_price=3200.5,
        high_price=3400,
        low_price=200,
        begin_time=datetime.now(),
    )
    print(kl)

运行的效果如下:

可以看出,我们自己实现的
dataclass
装饰器也可以实现类的初始化和美化输出,这里输出时每个属性占一行。

4. 总结

通过自定义
dataclass
装饰器的构建过程,我们深入了解了
Python

dataclass
的内部原理。

利用
__annotations__
获取字段信息,借助
exec
创建各种方法,从而实现简洁高效的
dataclass
定义。

不过,实际的
Python
标准库中的
dataclass
还有更多的功能和优化,了解了其原理之后,可以参考它的源码再进一步学习。

开心一刻

昨天在幼儿园,领着儿子在办公室跟他班主任聊他的情况
班主任:皓瑟,你跟我聊天是不是紧张呀
儿子:是的,老师
班主任:不用紧张,我虽然是你的班主任,但我也才22岁,你就把我当成班上的女同学
班主任继续补充道:你平时跟她们怎么聊,就跟我怎么聊,男孩子要果然,想说啥就说啥
儿子满眼期待的看向我,似乎在征询我的同意,我坚定的点了点头
儿子:老师,看看腿

开心一刻

问题复现

项目基于 Spring Boot 2.4.2,引入了
spring-boot-starter-data-redis

mybatis-plus-boot-starter
,完整依赖如下

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.4.2</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.0</version>
    </dependency>
    <!--mysql-->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
</dependencies>


RedisTemplate
进行了自定义配置

/**
 * @author 青石路
 */
@Configuration
public class RedisConfig {

    @Bean
    RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(factory);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        Jackson2JsonRedisSerializer<Object> jsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        redisTemplate.setKeySerializer(stringRedisSerializer);
        redisTemplate.setValueSerializer(jsonRedisSerializer);
        redisTemplate.setHashKeySerializer(stringRedisSerializer);
        redisTemplate.setHashValueSerializer(jsonRedisSerializer);
        redisTemplate.setEnableDefaultSerializer(true);
        redisTemplate.setDefaultSerializer(jsonRedisSerializer);
        redisTemplate.setEnableTransactionSupport(true);
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}

需要实现的功能

保存用户:若用户在缓存(
Redis
)中存在,直接返回成功;若用户在缓存中不存在,将用户信息保存到缓存的同时,还要保存到
MySQL

功能很简单,实现如下

/**
 * @author: 青石路
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserDao, User> implements IUserService {

    private static final Logger LOG = LoggerFactory.getLogger(UserServiceImpl.class);

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public String saveNotExist(User user) {
        Object o = redisTemplate.opsForValue().get("dataredis:user:" + user.getUserName());
        if (o != null) {
            LOG.info("用户已存在");
            return "用户已存在";
        }
        redisTemplate.opsForValue().set("dataredis:user:" + user.getUserName(), user);
        this.save(user);
        return "用户保存成功";
    }
}

结构还是常规的
Controller
->
Service
->
Dao
;启动项目后,我们直接访问接口

POST http://localhost:8080/user/save
Content-Type: application/json

{
  "userName": "qsl",
  "password": "123456"
}

毫无意外,接口 500

{
  "timestamp": "2024-12-28T05:39:49.577+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "message": "",
  "path": "/user/save"
}

这么简单的功能,这么完美的实现,为什么也出错?

早知道不学编程了

问题排查

遇到异常我们该如何排查?看
异常堆栈
是最直接的方式

异常堆栈信息

有两点值得我们好好分析下

  1. RedisConnectionUtils.createConnectionSplittingProxy

    看方法名就知道,这是要创建 Redis Connection 的代理;咱先甭管创建的是什么代理,咱先弄明白为什么要创建代理?


    不就是查 Redis,然后写 Redis,为什么要创建代理?


    怎么弄明白了,看谁调用了这个方法不就清楚了?直接从异常堆栈一眼就可以看出
    RedisConnectionUtils.java:151
    调用了该方法,我们点击跟进看看


    createConnectionSplittingProxy调用处

    所以重点有来到
    bindSynchronization

    isActualNonReadonlyTransactionActive()


    • bindSynchronization 的值

      它的计算逻辑很清楚


      TransactionSynchronizationManager.isActualTransactionActive() && transactionSupport;


      isActualTransactionActive() 注释如下

      /**
       * Return whether there currently is an actual transaction active.
       * This indicates whether the current thread is associated with an actual
       * transaction rather than just with active transaction synchronization.
       * <p>To be called by resource management code that wants to discriminate
       * between active transaction synchronization (with or without backing
       * resource transaction; also on PROPAGATION_SUPPORTS) and an actual
       * transaction being active (with backing resource transaction;
       * on PROPAGATION_REQUIRED, PROPAGATION_REQUIRES_NEW, etc).
       * @see #isSynchronizationActive()
       */
      public static boolean isActualTransactionActive() {
          return (actualTransactionActive.get() != null);
      }
      

      返回当前线程是否是与实际事务相关联;可能你们看的有点迷糊,因为这里还与 Spring 的事务传播机制有关联,结合我给的示例代码来看,可以简单理解成:
      当前线程是否开启事务


      saveNotExist开启事务

      很明显当前线程是开启事务的,所以 TransactionSynchronizationManager.isActualTransactionActive() 的值为
      true

      transactionSupport
      的值则需要继续从上游调用方寻找


      redis_trasactionSupportpng

      跟进
      RedisTemplate.java:209


      RedisTemplate_enableTrascationSupport

      enableTransactionSupport
      是 RedisTemplate 的成员变量,其默认值是
      false


      enableTransactionSupport默认值false

      但我们自定义的时候,将 enableTransactionSupport 设置成了
      true


      enableTransactionSupport自定义成true

      这里为什么设置成 true,我问了当时写这个代码的同事,直接从网上复制的,不是刻意开启的!
      我是不推荐使用 Redis 事务的,至于为什么,后文会有说明


      所以 bindSynchronization 的值为
      true

    • isActualNonReadonlyTransactionActive() 的返回值

      从名称就知道,该方法的作用是判断当前事务是不是
      非只读
      的;其完整代码如下

      private static boolean isActualNonReadonlyTransactionActive() {
          return TransactionSynchronizationManager.isActualTransactionActive()
                  && !TransactionSynchronizationManager.isCurrentTransactionReadOnly();
      }
      

      TransactionSynchronizationManager.isActualTransactionActive() 前面已经分析过,其值是
      true
      ;大家还记得事务设置只读是如何设置的吗?
      @Transactional
      注解是不是有
      readOnly
      配置项?


      @Transactional(rollbackFor = Exception.class, readOnly = true)


      readOnly 的默认值是
      false
      ,而我们的示例代码中又没有将其设置成 true,所以
      !TransactionSynchronizationManager.isCurrentTransactionReadOnly()
      的值就是
      !false
      ,也就是
      true

      所以 isActualNonReadonlyTransactionActive() 的值为
      true


    启用 RedisTemplate 事务的同时,又使用了 @Transactional 使得线程关联了实际事务,并且未启用非只读线程,天时地利人和之下创建了 Redis Connection 代理,通过该代理来实现 Redis 事务


    Spring 对事务的实现是通用的,都是通过代理的方式来实现,不区分是关系型数据库还是Redis,甚至是其他支持事务的数据源!

  2. cannot access its superinterface

    完整信息如下


    java.lang.IllegalAccessError: class org.springframework.data.redis.core.$Proxy82 cannot access its superinterface org.springframework.data.redis.core.RedisConnectionUtils$RedisConnectionProxy


    不合法的访问错误:不能访问父级接口:RedisConnectionUtils$RedisConnectionProxy


    关于 Spring 的代理,我们都知道有两种实现:
    JDK 动态代理

    CGLIB 动态代理
    ,而 Redis 事务则采用的 JDK 动态代理

    Redis事务实现_JDK代理

    JDK 动态代理有哪些限制,你们还记得吗,好好回忆一下


    RedisConnectionUtils$RedisConnectionProxy 都没有实现类,为什么代理会涉及到它?我们看下 RedisConnectionUtils.createConnectionSplittingProxy 的实现就明白了


    createConnectionSplittingProxy具体代码

    我们再看看 RedisConnectionUtils$RedisConnectionProxy 的具体实现


    RedisConnectionProxy_具体代码

    莫非是因为 RedisConnectionProxy 是内部 interface,并且是 package-protected 的,所以导致不能被访问?如何验证了,我们可以进行类似模拟,但我不推荐,我更推荐从官方找答案,因为这个问题肯定不止我们遇到了;从异常堆栈信息可以很明显的看出,这是
    spring-data-redis
    引发的,所以我们直接去其 github 寻找相关 issue


    RedisConnectionProxy_github搜索

    正好有一个,点进去看看,正好有我们想要的答案;推荐大家仔细看看这个 issue,我只强调一下重点


    issue_重点

    1. 将该bug添加到 2.4.7 版本中修复

    2. 将 RedisConnectionProxy 修改成 public

    3. 代码提交版本:503d639


      RedisConnectionProxy_修改记录

    官方 Release 版本也进行了说明


    官方release_2.4.7

至此,相信你们都清楚问题原因了

问题修复

既然问题已经找到,修复方法也就清晰了

  1. 启用只读事务

    这种方式只适用于部分特殊场景,因为它还影响关系型数据库的事务

    不推荐使用

  2. 停用 RedisTemplate 事务

    不设置 enableTransactionSupport,让其保持默认值
    false
    ,或者显示设置成
    false


    redisTemplate.setEnableTransactionSupport(false);


    还记不记得我前面跟你们说过:
    不推荐使用 Redis 事务
    ;至于为什么,我们来看看官网是怎么说明的


    redis事务不支持回滚

    Redis不支持事务回滚,因为支持回滚会对Redis的简单性和性能产生重大影响;Redis 事务只能保证两点


    • 事务中的所有命令都被序列化并按顺序执行。Redis执行事务期间,不会被其它客户端发送的命令打断,事务中的所有命令都作为一个隔离操作顺序执行
    • Redis事务是原子操作,或者执行所有命令或者都不执行。一旦执行命令,即使中间某个命令执行失败,后面的命令仍会继续执行

    另外,官网提到了一个另外一个点


    Redis脚本与事务

    Redis 脚本同样具有事务性。你可以用Redis事务做的一切,你也可以用脚本做,通常脚本会更简单、更快。但有些点我们需要注意,Redis 2.6.0 才引进脚本功能,Lua 脚本具备一定的原子性,可以保证隔离性,而且可以完美的
    支持后面的步骤依赖前面步骤的结果
    ,但同样也
    不支持回滚

    所以如果我们的 Redis 版本满足的话,推荐用 Lua 脚本而非 Redis 事务

    推荐使用

  3. 升级 spring-data-redis 版本

    spring-data-redis 2.4.7 实现了修复,但我们是采用的 starter 的方式引入的依赖,所以升级 spring boot 版本更合适;RedisConnectionUtils$RedisConnectionProxy 是 spring-data-redis 2.4.2 引入的,spring-boot-starter-data-redis 的版本与 spring-boot 版本一致,其 2.4.4、2.4.5 对应的 spring-data-redis 版本是 2.4.6、2.4.8,所以将 spring boot 升级到 2.4.5 或更高即可。如果可以的话,更推荐直接升级到适配 JDK 版本的最新稳定版本

    推荐使用

总结

  1. 异常堆栈就是发生异常时的调用栈,时间线顺序是
    从下往上
    ,也就是下面一行调用上面一行

  2. 如果Redis版本是2.6.0或更高,不推荐使用其事务功能,用Lua实现事务更合适

    不管是Redis事务,还是Lua脚本,都不支持事务回滚,所以我们要尽量保证Redis命令的正确使用

  3. 不管是使用 spring-data-redis 哪个版本,都推荐关闭 RedisTemplate 的 enableTransactionSupport

    出于两点考虑


    • 你们可以留意下你们项目中的 Redis 版本,肯定都高于 2.6.0,因为版本越高,功能越强大,性能越稳定;言外之意就是可以使用Lua脚本来实现事务
    • 需要用到Redis事务的场景很少,甚至没有;不怕你们笑话,我还没显示的使用过Redis的事务,当然间接用过(Redisson的锁用到了lua脚本)

前言

高阶组件HOC
在React社区是非常常见的概念,但是在Vue社区中却是很少人使用。主要原因有两个:1、Vue中一般都是使用SFC,实现HOC比较困难。2、HOC能够实现的东西,在Vue2时代
mixins
能够实现,在Vue3时代
Composition API
能够实现。如果你不知道HOC,那么你平时绝对没有场景需要他。但是如果你知道HOC,那么在一些特殊的场景使用他就可以很优雅的解决一些问题。

欧阳也在找工作,坐标成都求内推!

什么是高阶组件HOC

HOC使用场景就是
加强原组件

HOC实际就是一个函数,这个函数接收的参数就是一个组件,并且返回一个组件,返回的就是加强后组件。如下图:
hoc


Composition API
出现之前HOC还有一个常见的使用场景就是提取公共逻辑,但是有了
Composition API
后这种场景就无需使用HOC了。

高阶组件HOC使用场景

很多同学觉得有了
Composition API
后,直接无脑使用他就完了,无需费时费力的去搞什么HOC。那如果是下面这个场景呢?

有一天产品找到你,说要给我们的系统增加会员功能,需要让系统中的几十个功能块增加会员可见功能。如果不是会员这几十个功能块都显示成引导用户开通会员的UI,并且这些功能块涉及到几十个组件,分布在系统的各个页面中。

如果不知道HOC的同学一般都会这样做,将会员相关的功能抽取成一个名为
useVip.ts
的hooks。代码如下:

export function useVip() {
  function getShowVipContent() {
    // 一些业务逻辑判断是否是VIP
    return false;
  }

  return {
    showVipContent: getShowVipContent(),
  };
}

然后再去每个具体的业务模块中去使用
showVipContent
变量判断,
v-if="showVipContent"
显示原模块,
v-else
显示引导开通会员UI。代码如下:

<template>
  <Block1
    v-if="showVipContent"
    :name="name1"
    @changeName="(value) => (name1 = value)"
  />
  <OpenVipTip v-else />
</template>

<script setup lang="ts">
import { ref } from "vue";
import Block1 from "./block1.vue";
import OpenVipTip from "./open-vip-tip.vue";
import { useVip } from "./useVip";

const { showVipContent } = useVip();
const name1 = ref("block1");
</script>

我们系统中有几十个这样的组件,那么我们就需要这样去改几十次。非常麻烦,如果有些模块是其他同事写的代码还很容易改错!!!

而且现在流行搞SVIP,也就是光开通VIP还不够,需要再开通一个SVIP。当你后续接到SVIP需求时,你又需要去改这几十个模块。
v-if="SVIP"
显示某些内容,
v-else-if="VIP"
显示提示开通SVIP,
v-else
显示提示开通VIP。

上面的这一场景使用hooks去实现,虽然能够完成,但是因为入侵了这几十个模块的业务逻辑。所以容易出错,也改起来比较麻烦,代码也不优雅。

那么有没有一种更好的解决方案,让我们可以不入侵这几十个模块的业务逻辑的实现方式呢?

答案是:
高阶组件HOC

HOC的一个用途就是对组件进行增强,并且不会入侵原有组件的业务逻辑,在这里就是使用HOC判断会员相关的逻辑。如果是会员那么就渲染原本的模块组件,否则就渲染引导开通VIP的UI

实现一个简单的HOC

首先我们要明白Vue的组件经过编译后就是一个对象,对象中的
props
属性对应的就是我们写的
defineProps
。对象中的setup方法,对应的就是我们熟知的
<script setup>
语法糖。

比如我使用
console.log(Block1)
将上面的
import Block1 from "./block1.vue";
给打印出来,如下图:
console

这个就是我们引入的Vue组件对象。

还有一个冷知识,大家可能不知道。如果在setup方法中返回一个函数,那么在Vue内部就会认为这个函数就是实际的render函数,并且在setup方法中我们天然的就可以访问定义的变量。

利用这一点我们就可以在Vue3中实现一个简单的高阶组件HOC,代码如下:

import { h } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    setup() {
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      return () => {
        return showVipContent ? h(BaseComponent) : h(OpenVipTip);
      };
    },
  };
}

在上面的代码中我们将会员相关的逻辑全部放在了
WithVip
函数中,这个函数接收一个参数
BaseComponent
,他是一个Vue组件对象。


setup
方法中我们return了一个箭头函数,他会被当作render函数处理。

如果
showVipContent
为true,就表明当前用户开通了VIP,就使用
h
函数渲染传入的组件。

否则就渲染
OpenVipTip
组件,他是引导用户开通VIP的组件。

此时我们的父组件就应该是下面这样的:

<template>
  <EnhancedBlock1 />
</template>

<script setup lang="ts">
import Block1 from "./block1.vue";
import WithVip from "./with-vip.tsx";

const EnhancedBlock1 = WithVip(Block1);
</script>

这个代码相比前面的hooks的实现就简单很多了,只需要使用高阶组件
WithVip
对原来的
Block1
组件包一层,然后将原本使用
Block1
的地方改为使用
EnhancedBlock1
。对原本的代码基本没有入侵。

上面的例子只是一个简单的demo,他是不满足我们实际的业务场景。比如子组件有
props

emit

插槽
。还有我们在父组件中可能会直接调用子组件expose暴露的方法。

因为我们使用了HOC对原本的组件进行了一层封装,那么上面这些场景HOC都是不支持的,我们需要添加一些额外的代码去支持。

高阶组件HOC实现props和emit

在Vue中属性分为两种,一种是使用
props

emit
声明接收的属性。第二种是未声明的属性
attrs
,比如class、style、id等。

在setup函数中props是作为第一个参数返回,
attrs
是第二个参数中返回。

所以为了能够支持props和emit,我们的高阶组件
WithVip
将会变成下面这样:

import { SetupContext, h } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    props: BaseComponent.props,  // 新增代码
    setup(props, { attrs, slots, expose }: SetupContext) {  // 新增代码
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      return () => {
        return showVipContent
          ? h(BaseComponent, {
              ...props, // 新增代码
              ...attrs, // 新增代码
            })
          : h(OpenVipTip);
      };
    },
  };
}


setup
方法中接收的第一个参数就是
props
,没有在props中定义的属性就会出现在
attrs
对象中。

所以我们调用h函数时分别将
props

attrs
透传给子组件。

同时我们还需要一个地方去定义props,props的值就是直接读取子组件对象中的
BaseComponent.props
。所以我们给高阶组件声明一个props属性:
props: BaseComponent.props,

这样props就会被透传给子组件了。

看到这里有的小伙伴可能会问,那emit触发事件没有看见你处理呢?

答案是:我们无需去处理,因为父组件上面的
@changeName="(value) => (name1 = value)"
经过编译后就会变成属性:
:onChangeName="(value) => (name1 = value)"
。而这个属性由于我们没有在props中声明,所以他会作为
attrs
直接透传给子组件。

高阶组件实现插槽

我们的正常子组件一般还有插槽,比如下面这样:

<template>
  <div class="divider">
    <h1>{{ name }}</h1>
    <button @click="handleClick">change name</button>
    <slot />
    这里是block1的一些业务代码
    <slot name="footer" />
  </div>
</template>

<script setup lang="ts">
const emit = defineEmits<{
  changeName: [name: string];
}>();

const props = defineProps<{
  name: string;
}>();

const handleClick = () => {
  emit("changeName", `hello ${props.name}`);
};

defineExpose({
  handleClick,
});
</script>

在上面的例子中,子组件有个默认插槽和name为
footer
的插槽。此时我们来看看高阶组件中如何处理插槽呢?

直接看代码:

import { SetupContext, h } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    props: BaseComponent.props,
    setup(props, { attrs, slots, expose }: SetupContext) {
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      return () => {
        return showVipContent
          ? h(
              BaseComponent,
              {
                ...props,
                ...attrs,
              },
              slots // 新增代码
            )
          : h(OpenVipTip);
      };
    },
  };
}

插槽的本质就是一个对象里面拥有多个方法,这些方法的名称就是每个具名插槽,每个方法的参数就是插槽传递的变量。这里我们只需要执行
h
函数时将
slots
对象传给h函数,就能实现插槽的透传(如果你看不懂这句话,那就等欧阳下篇插槽的文章写好后再来看这段话你就懂了)。

我们在控制台中来看看传入的
slots
插槽对象,如下图:
slots

从上面可以看到插槽对象中有两个方法,分别是
default

footer
,对应的就是默认插槽和footer插槽。

大家熟知h函数接收的第三个参数是children数组,也就是有哪些子元素。但是他其实还支持直接传入
slots
对象,下面这个是他的一种定义:

export function h<P>(
  type: Component<P>,
  props?: (RawProps & P) | null,
  children?: RawChildren | RawSlots,
): VNode

export type RawSlots = {
  [name: string]: unknown
  // ...省略
}

所以我们可以直接把slots对象直接丢给h函数,就可以实现插槽的透传。

父组件调用子组件的方法

有的场景中我们需要在父组件中直接调用子组件的方法,按照以前的场景,我们只需要在子组件中expose暴露出去方法,然后在父组件中使用ref访问到子组件,这样就可以调用了。

但是使用了HOC后,中间层多了一个高阶组件,所以我们不能直接访问到子组件expose的方法。

怎么做呢?答案很简单,直接上代码:

import { SetupContext, h, ref } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    props: BaseComponent.props,
    setup(props, { attrs, slots, expose }: SetupContext) {
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      // 新增代码start
      const innerRef = ref();
      expose(
        new Proxy(
          {},
          {
            get(_target, key) {
              return innerRef.value?.[key];
            },
            has(_target, key) {
              return innerRef.value?.[key];
            },
          }
        )
      );
      // 新增代码end

      return () => {
        return showVipContent
          ? h(
              BaseComponent,
              {
                ...props,
                ...attrs,
                ref: innerRef,  // 新增代码
              },
              slots
            )
          : h(OpenVipTip);
      };
    },
  };
}

在高阶组件中使用
ref
访问到子组件赋值给
innerRef
变量。然后expose一个
Proxy
的对象,在get拦截中让其直接去执行子组件中的对应的方法。

比如在父组件中使用
block1Ref.value.handleClick()
去调用
handleClick
方法,由于使用了HOC,所以这里读取的
handleClick
方法其实是读取的是HOC中expose暴露的方法。所以就会走到
Proxy
的get拦截中,从而可以访问到真正子组件中expose暴露的
handleClick
方法。

那么上面的Proxy为什么要使用
has
拦截呢?

答案是在Vue源码中父组件在执行子组件中暴露的方法之前会执行这样一个判断:

if (key in target) {
  return target[key];
}

很明显我们这里的
Proxy
代理的原始对象里面什么都没有,执行
key in target
肯定就是false了。所以我们可以使用
has
去拦截
key in target
,意思是只要访问的方法或者属性是子组件中
expose
暴露的就返回true。

至此,我们已经在HOC中覆盖了Vue中的所有场景。但是有的同学觉得
h
函数写着比较麻烦,不好维护,我们还可以将上面的高阶组件改为tsx的写法,
with-vip.tsx
文件代码如下:

import { SetupContext, ref } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    props: BaseComponent.props,
    setup(props, { attrs, slots, expose }: SetupContext) {
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      const innerRef = ref();
      expose(
        new Proxy(
          {},
          {
            get(_target, key) {
              return innerRef.value?.[key];
            },
            has(_target, key) {
              return innerRef.value?.[key];
            },
          }
        )
      );

      return () => {
        return showVipContent ? (
          <BaseComponent {...props} {...attrs} ref={innerRef}>
            {slots}
          </BaseComponent>
        ) : (
          <OpenVipTip />
        );
      };
    },
  };
}

一般情况下h函数能够实现的,使用
jsx
或者
tsx
都能实现(除非你需要操作虚拟DOM)。

注意上面的代码是使用
ref={innerRef}
,而不是我们熟悉的
ref="innerRef"
,这里很容易搞错!!

compose函数

此时你可能有个新需求,需要给某些模块显示不同的折扣信息,这些模块可能会和上一个会员需求的模块有重叠。此时就涉及到多个高阶组件之间的组合情况。

同样我们使用HOC去实现,新增一个
WithDiscount
高阶组件,代码如下:

import { SetupContext, onMounted, ref } from "vue";

export default function WithDiscount(BaseComponent: any, item: string) {
  return {
    props: BaseComponent.props,
    setup(props, { attrs, slots, expose }: SetupContext) {
      const discountInfo = ref("");

      onMounted(async () => {
        const res = await getDiscountInfo(item);
        discountInfo.value = res;
      });

      function getDiscountInfo(item: any): Promise<string> {
        // 根据传入的item获取折扣信息
        return new Promise((resolve) => {
          setTimeout(() => {
            resolve("我是折扣信息1");
          }, 1000);
        });
      }

      const innerRef = ref();
      expose(
        new Proxy(
          {},
          {
            get(_target, key) {
              return innerRef.value?.[key];
            },
            has(_target, key) {
              return innerRef.value?.[key];
            },
          }
        )
      );

      return () => {
        return (
          <div class="with-discount">
            <BaseComponent {...props} {...attrs} ref={innerRef}>
              {slots}
            </BaseComponent>
            {discountInfo.value ? (
              <div class="discount-info">{discountInfo.value}</div>
            ) : null}
          </div>
        );
      };
    },
  };
}

那么我们的父组件如果需要同时用VIP功能和折扣信息功能需要怎么办呢?代码如下:

const EnhancedBlock1 = WithVip(WithDiscount(Block1, "item1"));

如果不是VIP,那么这个模块的折扣信息也不需要显示了。

因为高阶组件接收一个组件,然后返回一个加强的组件。利用这个特性,我们可以使用上面的这种代码将其组合起来。

但是上面这种写法大家觉得是不是看着很难受,一层套一层。如果这里同时使用5个高阶组件,这里就会套5层了,那这个代码的维护难度就是地狱难度了。

所以这个时候就需要
compose
函数了,这个是React社区中常见的概念。它的核心思想是将多个函数从右到左依次组合起来执行,前一个函数的输出作为下一个函数的输入。

我们这里有多个HOC(也就是有多个函数),我们期望执行完第一个HOC得到一个加强的组件,然后以这个加强的组件为参数去执行第二个HOC,最后得到由多个HOC加强的组件。

compose
函数就刚好符合我们的需求,这个是使用
compose
函数后的代码,如下:

const EnhancedBlock1 = compose(WithVip, WithDiscount("item1"))(Block1);

这样就舒服多了,所有的高阶组件都放在第一个括弧里面,并且由右向左去依次执行每个高阶组件HOC。如果某个高阶组件HOC需要除了组件之外的额外参数,像
WithDiscount
这样处理就可以了。

很明显,我们的
WithDiscount
高阶组件的代码需要修改才能满足
compose
函数的需求,这个是修改后的代码:

import { SetupContext, onMounted, ref } from "vue";

export default function WithDiscount(item: string) {
  return (BaseComponent: any) => {
    return {
      props: BaseComponent.props,
      setup(props, { attrs, slots, expose }: SetupContext) {
        const discountInfo = ref("");

        onMounted(async () => {
          const res = await getDiscountInfo(item);
          discountInfo.value = res;
        });

        function getDiscountInfo(item: any): Promise<string> {
          // 根据传入的item获取折扣信息
          return new Promise((resolve) => {
            setTimeout(() => {
              resolve("我是折扣信息1");
            }, 1000);
          });
        }

        const innerRef = ref();
        expose(
          new Proxy(
            {},
            {
              get(_target, key) {
                return innerRef.value?.[key];
              },
              has(_target, key) {
                return innerRef.value?.[key];
              },
            }
          )
        );

        return () => {
          return (
            <div class="with-discount">
              <BaseComponent {...props} {...attrs} ref={innerRef}>
                {slots}
              </BaseComponent>
              {discountInfo.value ? (
                <div class="discount-info">{discountInfo.value}</div>
              ) : null}
            </div>
          );
        };
      },
    };
  };
}

注意看,
WithDiscount
此时只接收一个参数
item
,不再接收
BaseComponent
组件对象了,然后直接return出去一个回调函数。

准确的来说此时的
WithDiscount
函数已经不是高阶组件HOC了,
他return出去的回调函数才是真正的高阶组件HOC
。在回调函数中去接收
BaseComponent
组件对象,然后返回一个增强后的Vue组件对象。

至于参数
item
,因为闭包所以在里层的回调函数中还是能够访问的。这里比较绕,可能需要多理解一下。

前面的理解完了后,我们可以再上一点强度了。来看看
compose
函数是如何实现的,代码如下:

function compose(...funcs) {
  return funcs.reduce((acc, cur) => (...args) => acc(cur(...args)));
}

这个函数虽然只有一行代码,但是乍一看,怎么看怎么懵逼,欧阳也是!!
我们还是结合demo来看:

const EnhancedBlock1 = compose(WithA, WithB, WithC, WithD)(View);

假如我们这里有
WithA

WithB

WithC

WithD
四个高阶组件,都是用于增强组件
View

compose中使用的是
...funcs
将调用
compose
函数接收到的四个高阶组件都存到了
funcs
数组中。

然后使用reduce去遍历这些高阶组件,注意看执行
reduce
时没有传入第二个参数。

所以第一次执行reduce时,
acc
的值为
WithA

cur
的值为
WithB
。返回结果也是一个回调函数,将这两个值填充进去就是
(...args) => WithA(WithB(...args))
,我们将第一次的执行结果命名为
r1

我们知道reduce会将上一次的执行结果赋值为acc,所以第二次执行reduce时,
acc
的值为
r1

cur
的值为
WithC
。返回结果也是一个回调函数,同样将这两个值填充进行就是
(...args) => r1(WithC(...args))
。同样我们将第二次的执行结果命名为
r2

第三次执行reduce时,此时的
acc
的值为
r2

cur
的值为
WithD
。返回结果也是一个回调函数,同样将这两个值填充进行就是
(...args) => r2(WithD(...args))
。同样我们将第三次的执行结果命名为
r3
,由于已经将数组遍历完了,最终reduce的返回值就是
r3
,他是一个回调函数。

由于
compose(WithA, WithB, WithC, WithD)
的执行结果为
r3
,那么
compose(WithA, WithB, WithC, WithD)(View)
就等价于
r3(View)

前面我们知道
r3
是一个回调函数:
(...args) => r2(WithD(...args))
,这个回调函数接收的参数
args
,就是需要增强的基础组件
View
。所以执行这个回调函数就是先执行
WithD
对组件进行增强,然后将增强后的组件作为参数去执行
r2

同样
r2
也是一个回调函数:
(...args) => r1(WithC(...args))
,接收上一次
WithD
增强后的组件为参数执行
WithC
对组件再次进行增强,然后将增强后的组件作为参数去执行
r1

同样
r1
也是一个回调函数:
(...args) => WithA(WithB(...args))
,将
WithC
增强后的组件丢给
WithB
去执行,得到增强的组件再丢给
WithA
去执行,最终就拿到了最后增强的组件。

执行顺序就是
从右向左
去依次执行高阶组件对基础组件进行增强。

至此,关于
compose
函数已经讲完了,这里对于Vue的同学可能比较难理解,建议多看两遍。

总结

这篇文章我们讲了在Vue3中如何实现一个高阶组件HOC,但是里面涉及到了很多源码知识,所以这是一篇运用源码的实战文章。如果你理解了文章中涉及到的知识,那么就会觉得Vue中实现HOC还是很简单的,反之就像是在看天书。

还有最重要的一点就是
Composition API
已经能够解决绝大部分的问题,只有少部分的场景才需要使用高阶组件HOC,
切勿强行使用HOC
,那样可能会有炫技的嫌疑。如果是防御性编程,那么就当我没说。

最后就是我们实现的每个高阶组件HOC都有很多重复的代码,而且实现起来很麻烦,心智负担也很高。那么我们是不是可以抽取一个
createHOC
函数去批量生成高阶组件呢?这个就留给各位自己去思考了。

还有一个问题,我们这种实现的高阶组件叫做
正向属性代理
,弊端是每代理一层就会增加一层组件的嵌套。那么有没有方法可以解决嵌套的问题呢?

答案是
反向继承
,但是这种也有弊端如果业务是setup中返回的render函数,那么就没法重写了render函数了。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

另外欧阳写了一本开源电子书
vue3编译原理揭秘
,看完这本书可以让你对vue编译的认知有质的提升。这本书初、中级前端能看懂,完全免费,只求一个star。

前言

sql优化是一个大家都比较关注的热门话题,无论你在面试,还是工作中,都很有可能会遇到。

如果某天你负责的某个线上接口,出现了性能问题,需要做优化。那么你首先想到的很有可能是优化sql语句,因为它的改造成本相对于代码来说也要小得多。

那么,如何优化sql语句呢?

这篇文章从15个方面,分享了sql优化的一些小技巧,希望对你有所帮助。

(我最近开源了一个基于 SpringBoot+Vue+uniapp 的商城项目,欢迎访问和star。)[
https://gitee.com/dvsusan/susan_mall
]

1 避免使用select *

很多时候,我们写sql语句时,为了方便,喜欢直接使用
select *
,一次性查出表中所有列的数据。

反例:

select * from user where id=1;

在实际业务场景中,可能我们真正需要使用的只有其中一两列。查了很多数据,但是不用,白白浪费了数据库资源,比如:内存或者cpu。

此外,多查出来的数据,通过网络IO传输的过程中,也会增加数据传输的时间。

还有一个最重要的问题是:
select *
不会走
覆盖索引
,会出现大量的
回表
操作,而从导致查询sql的性能很低。

那么,如何优化呢?

正例:

select name,age from user where id=1;

sql语句查询时,只查需要用到的列,多余的列根本无需查出来。

2 用union all代替union

我们都知道sql语句使用
union
关键字后,可以获取排重后的数据。

而如果使用
union all
关键字,可以获取所有数据,包含重复的数据。

反例:

(select * from user where id=1) 
union 
(select * from user where id=2);

排重的过程需要遍历、排序和比较,它更耗时,更消耗cpu资源。

所以如果能用union all的时候,尽量不用union。

正例:

(select * from user where id=1) 
union all
(select * from user where id=2);

除非是有些特殊的场景,比如union all之后,结果集中出现了重复数据,而业务场景中是不允许产生重复数据的,这时可以使用union。

3 小表驱动大表

小表驱动大表,也就是说用小表的数据集驱动大表的数据集。

假如有order和user两张表,其中order表有10000条数据,而user表有100条数据。

这时如果想查一下,所有有效的用户下过的订单列表。

可以使用
in
关键字实现:

select * from order
where user_id in (select id from user where status=1)

也可以使用
exists
关键字实现:

select * from order
where exists (select 1 from user where order.user_id = user.id and status=1)

前面提到的这种业务场景,使用in关键字去实现业务需求,更加合适。

为什么呢?

因为如果sql语句中包含了in关键字,则它会优先执行in里面的
子查询语句
,然后再执行in外面的语句。如果in里面的数据量很少,作为条件查询速度更快。

而如果sql语句中包含了exists关键字,它优先执行exists左边的语句(即主查询语句)。然后把它作为条件,去跟右边的语句匹配。如果匹配上,则可以查询出数据。如果匹配不上,数据就被过滤掉了。

这个需求中,order表有10000条数据,而user表有100条数据。order表是大表,user表是小表。如果order表在左边,则用in关键字性能更好。

总结一下:

  • in
    适用于左边大表,右边小表。
  • exists
    适用于左边小表,右边大表。

不管是用in,还是exists关键字,其核心思想都是用小表驱动大表。

4 批量操作

如果你有一批数据经过业务处理之后,需要插入数据,该怎么办?

反例:

for(Order order: list){
   orderMapper.insert(order):
}

在循环中逐条插入数据。

insert into order(id,code,user_id) 
values(123,'001',100);

该操作需要多次请求数据库,才能完成这批数据的插入。

但众所周知,我们在代码中,每次远程请求数据库,是会消耗一定性能的。而如果我们的代码需要请求多次数据库,才能完成本次业务功能,势必会消耗更多的性能。

那么如何优化呢?

正例:

orderMapper.insertBatch(list):

提供一个批量插入数据的方法。

insert into order(id,code,user_id) 
values(123,'001',100),(124,'002',100),(125,'003',101);

这样只需要远程请求一次数据库,sql性能会得到提升,数据量越多,提升越大。

但需要注意的是,不建议一次批量操作太多的数据,如果数据太多数据库响应也会很慢。批量操作需要把握一个度,建议每批数据尽量控制在500以内。如果数据多于500,则分多批次处理。

5 多用limit

有时候,我们需要查询某些数据中的第一条,比如:查询某个用户下的第一个订单,想看看他第一次的首单时间。

反例:

select id, create_date 
 from order 
where user_id=123 
order by create_date asc;

根据用户id查询订单,按下单时间排序,先查出该用户所有的订单数据,得到一个订单集合。 然后在代码中,获取第一个元素的数据,即首单的数据,就能获取首单时间。

List<Order> list = orderMapper.getOrderList();
Order order = list.get(0);

虽说这种做法在功能上没有问题,但它的效率非常不高,需要先查询出所有的数据,有点浪费资源。

那么,如何优化呢?

正例:

select id, create_date 
 from order 
where user_id=123 
order by create_date asc 
limit 1;

使用
limit 1
,只返回该用户下单时间最小的那一条数据即可。

此外,在删除或者修改数据时,为了防止误操作,导致删除或修改了不相干的数据,也可以在sql语句最后加上limit。

例如:

update order set status=0,edit_time=now(3) 
where id>=100 and id<200 limit 100;

这样即使误操作,比如把id搞错了,也不会对太多的数据造成影响。

6 in中值太多

对于批量查询接口,我们通常会使用
in
关键字过滤出数据。比如:想通过指定的一些id,批量查询出用户信息。

sql语句如下:

select id,name from category
where id in (1,2,3...100000000);

如果我们不做任何限制,该查询语句一次性可能会查询出非常多的数据,很容易导致接口超时。

这时该怎么办呢?

select id,name from category
where id in (1,2,3...100)
limit 500;

可以在sql中对数据用limit做限制。

不过我们更多的是要在业务代码中加限制,伪代码如下:

public List<Category> getCategory(List<Long> ids) {
   if(CollectionUtils.isEmpty(ids)) {
      return null;
   }
   if(ids.size() > 500) {
      throw new BusinessException("一次最多允许查询500条记录")
   }
   return mapper.getCategoryList(ids);
}

还有一个方案就是:如果ids超过500条记录,可以分批用多线程去查询数据。每批只查500条记录,最后把查询到的数据汇总到一起返回。

不过这只是一个临时方案,不适合于ids实在太多的场景。因为ids太多,即使能快速查出数据,但如果返回的数据量太大了,网络传输也是非常消耗性能的,接口性能始终好不到哪里去。

7 增量查询

有时候,我们需要通过远程接口查询数据,然后同步到另外一个数据库。

反例:

select * from user;

如果直接获取所有的数据,然后同步过去。这样虽说非常方便,但是带来了一个非常大的问题,就是如果数据很多的话,查询性能会非常差。

这时该怎么办呢?

正例:

select * from user 
where id>#{lastId} and create_time >= #{lastCreateTime} 
limit 100;

按id和时间升序,每次只同步一批数据,这一批数据只有100条记录。每次同步完成之后,保存这100条数据中最大的id和时间,给同步下一批数据的时候用。

通过这种增量查询的方式,能够提升单次查询的效率。

8 高效的分页

有时候,列表页在查询数据时,为了避免一次性返回过多的数据影响接口性能,我们一般会对查询接口做分页处理。

在mysql中分页一般用的
limit
关键字:

select id,name,age 
from user limit 10,20;

如果表中数据量少,用limit关键字做分页,没啥问题。但如果表中数据量很多,用它就会出现性能问题。

比如现在分页参数变成了:

select id,name,age 
from user limit 1000000,20;

mysql会查到1000020条数据,然后丢弃前面的1000000条,只查后面的20条数据,这个是非常浪费资源的。

那么,这种海量数据该怎么分页呢?

优化sql:

select id,name,age 
from user where id > 1000000 limit 20;

先找到上次分页最大的id,然后利用id上的索引查询。不过该方案,要求id是连续的,并且有序的。

还能使用
between
优化分页。

select id,name,age 
from user where id between 1000000 and 1000020;

需要注意的是between要在唯一索引上分页,不然会出现每页大小不一致的问题。

9 用连接查询代替子查询

mysql中如果需要从两张以上的表中查询出数据的话,一般有两种实现方式:
子查询

连接查询

子查询的例子如下:

select * from order
where user_id in (select id from user where status=1)

子查询语句可以通过
in
关键字实现,一个查询语句的条件落在另一个select语句的查询结果中。程序先运行在嵌套在最内层的语句,再运行外层的语句。

子查询语句的优点是简单,结构化,如果涉及的表数量不多的话。

但缺点是mysql执行子查询时,需要创建临时表,查询完毕后,需要再删除这些临时表,有一些额外的性能消耗。

这时可以改成连接查询。 具体例子如下:

select o.* from order o
inner join user u on o.user_id = u.id
where u.status=1

10 join的表不宜过多

根据阿里巴巴开发者手册的规定,join表的数量不应该超过
3
个。

反例:

select a.name,b.name.c.name,d.name
from a 
inner join b on a.id = b.a_id
inner join c on c.b_id = b.id
inner join d on d.c_id = c.id
inner join e on e.d_id = d.id
inner join f on f.e_id = e.id
inner join g on g.f_id = f.id

如果join太多,mysql在选择索引的时候会非常复杂,很容易选错索引。

并且如果没有命中中,nested loop join 就是分别从两个表读一行数据进行两两对比,复杂度是 n^2。

所以我们应该尽量控制join表的数量。

正例:

select a.name,b.name.c.name,a.d_name 
from a 
inner join b on a.id = b.a_id
inner join c on c.b_id = b.id

如果实现业务场景中需要查询出另外几张表中的数据,可以在a、b、c表中
冗余专门的字段
,比如:在表a中冗余d_name字段,保存需要查询出的数据。

不过我之前也见过有些ERP系统,并发量不大,但业务比较复杂,需要join十几张表才能查询出数据。

所以join表的数量要根据系统的实际情况决定,不能一概而论,尽量越少越好。

11 join时要注意

我们在涉及到多张表联合查询的时候,一般会使用
join
关键字。

而join使用最多的是left join和inner join。

  • left join
    :求两个表的交集外加左表剩下的数据。
  • inner join
    :求两个表交集的数据。

使用inner join的示例如下:

select o.id,o.code,u.name 
from order o 
inner join user u on o.user_id = u.id
where u.status=1;

如果两张表使用inner join关联,mysql会自动选择两张表中的小表,去驱动大表,所以性能上不会有太大的问题。

使用left join的示例如下:

select o.id,o.code,u.name 
from order o 
left join user u on o.user_id = u.id
where u.status=1;

如果两张表使用left join关联,mysql会默认用left join关键字左边的表,去驱动它右边的表。如果左边的表数据很多时,就会出现性能问题。

要特别注意的是在用left join关联查询时,左边要用小表,右边可以用大表。如果能用inner join的地方,尽量少用left join。

12 控制索引的数量

众所周知,索引能够显著的提升查询sql的性能,但索引数量并非越多越好。

因为表中新增数据时,需要同时为它创建索引,而索引是需要额外的存储空间的,而且还会有一定的性能消耗。

阿里巴巴的开发者手册中规定,单表的索引数量应该尽量控制在
5
个以内,并且单个索引中的字段数不超过
5
个。

mysql使用的B+树的结构来保存索引的,在insert、update和delete操作时,需要更新B+树索引。如果索引过多,会消耗很多额外的性能。

那么,问题来了,如果表中的索引太多,超过了5个该怎么办?

这个问题要辩证的看,如果你的系统并发量不高,表中的数据量也不多,其实超过5个也可以,只要不要超过太多就行。

但对于一些高并发的系统,请务必遵守单表索引数量不要超过5的限制。

那么,高并发系统如何优化索引数量?

能够建联合索引,就别建单个索引,可以删除无用的单个索引。

将部分查询功能迁移到其他类型的数据库中,比如:Elastic Seach、HBase等,在业务表中只需要建几个关键索引即可。

13 选择合理的字段类型

char
表示固定字符串类型,该类型的字段存储空间的固定的,会浪费存储空间。

alter table order 
add column code char(20) NOT NULL;

varchar
表示变长字符串类型,该类型的字段存储空间会根据实际数据的长度调整,不会浪费存储空间。

alter table order 
add column code varchar(20) NOT NULL;

如果是长度固定的字段,比如用户手机号,一般都是11位的,可以定义成char类型,长度是11字节。

但如果是企业名称字段,假如定义成char类型,就有问题了。

如果长度定义得太长,比如定义成了200字节,而实际企业长度只有50字节,则会浪费150字节的存储空间。

如果长度定义得太短,比如定义成了50字节,但实际企业名称有100字节,就会存储不下,而抛出异常。

所以建议将企业名称改成varchar类型,变长字段存储空间小,可以节省存储空间,而且对于查询来说,在一个相对较小的字段内搜索效率显然要高些。

我们在选择字段类型时,应该遵循这样的原则:

  1. 能用数字类型,就不用字符串,因为字符的处理往往比数字要慢。
  2. 尽可能使用小的类型,比如:用bit存布尔值,用tinyint存枚举值等。
  3. 长度固定的字符串字段,用char类型。
  4. 长度可变的字符串字段,用varchar类型。
  5. 金额字段用decimal,避免精度丢失问题。

还有很多原则,这里就不一一列举了。

14 提升group by的效率

我们有很多业务场景需要使用
group by
关键字,它主要的功能是去重和分组。

通常它会跟
having
一起配合使用,表示分组后再根据一定的条件过滤数据。

反例:

select user_id,user_name from order
group by user_id
having user_id <= 200;

这种写法性能不好,它先把所有的订单根据用户id分组之后,再去过滤用户id大于等于200的用户。

分组是一个相对耗时的操作,为什么我们不先缩小数据的范围之后,再分组呢?

正例:

select user_id,user_name from order
where user_id <= 200
group by user_id

使用where条件在分组前,就把多余的数据过滤掉了,这样分组时效率就会更高一些。

其实这是一种思路,不仅限于group by的优化。我们的sql语句在做一些耗时的操作之前,应尽可能缩小数据范围,这样能提升sql整体的性能。

15 索引优化

sql优化当中,有一个非常重要的内容就是:
索引优化

很多时候sql语句,走了索引,和没有走索引,执行效率差别很大。所以索引优化被作为sql优化的首选。

索引优化的第一步是:检查sql语句有没有走索引。

那么,如何查看sql走了索引没?

可以使用
explain
命令,查看mysql的执行计划。

例如:

explain select * from `order` where code='002';

结果:

通过这几列可以判断索引使用情况,执行计划包含列的含义如下图所示:

如果你想进一步了解explain的详细用法,可以看看我的另一篇文章《
explain | 索引优化的这把绝世好剑,你真的会用吗?

说实话,sql语句没有走索引,排除没有建索引之外,最大的可能性是索引失效了。

下面说说索引失效的常见原因:

如果不是上面的这些原因,则需要再进一步排查一下其他原因。

此外,你有没有遇到过这样一种情况:明明是同一条sql,只有入参不同而已。有的时候走的索引a,有的时候却走的索引b?

没错,有时候mysql会选错索引。

必要时可以使用
force index
来强制查询sql走某个索引。

至于为什么mysql会选错索引,后面有专门的文章介绍的,这里先留点悬念。

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。

关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。