Mybatis-3 源码之缓存是怎么创建和使用的

2020 年 11 月 05 日

Mybatis-3 源码之缓存是怎么创建和使用的

Mybatis-3 源码之缓存是怎么创建的


Mybatis 缓存问题其实也是面试高频的问题了,今天我们就从源码级别来谈谈 Mybatis 的缓存实现。


(本文源码均在 https://github.com/ccqctljx/Mybatis-3 中,会持续更新注释和 Demo)。


首先我们了解一下缓存是什么:缓存是一般的 ORM 框架都会提供的功能,目的就是提升查询的效率和减少数据库的压力。直白一点就是,开了缓存后,同样的数据查询不必再次访问数据库,直接从缓存中拿即可。


那么面试官常问的 一级缓存 和 二级缓存 又都是什么呢?


一级缓存:一级缓存又称本地缓存,是在会话(SqlSession)层面进行的缓存。随会话开始而生,结束而死。MyBatis 的一级缓存是默认开启的,不需要任何的配置。


二级缓存:由于一级缓存随会话而生,就不能跨会话共享。二级缓存则是用来解决这个问题的,他的范围是 namespace 级别的,可以被多个 SqlSession 共享,生命周期和 SqlSessionFactory 同步。只要是同一个 SqlSessionFactory 创建出来的会话,即可共享相同 namespace 级别的缓存。二级缓存需要配置三个地方:


第一个是在 mybaits-config.xml 配置文件中设置开启缓存:<setting name="cacheEnabled" value="true"/>


第二个是要在 Mapper 文件中配置 <cache/> 标签


第三个是在需要使用缓存的语句上加入useCache="true"


那么一级二级缓存有没有执行顺序什么的呢?答案是有的,如果开启二级缓存那么执行顺序为:



那么我们写个实例代码,来看下一二级缓存的效果吧


public class Demo {  public static void main(String[] args) throws IOException {
String resource = "mybatis/mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); SqlSession sqlSession1 = sqlSessionFactory.openSession(); SqlSession sqlSession2 = sqlSessionFactory.openSession();
List<BookInfo> bookInfoList1 = sqlSession1.selectList("com.simon.demo.TestMapper.selectBookInfo"); System.out.println(" sqlSession 1 query 1 ----------------------------- " + bookInfoList1);
List<BookInfo> bookInfoList2 = sqlSession1.selectList("com.simon.demo.TestMapper.selectBookInfo"); System.out.println("sqlSession 1 query 2 -----------------------------" + bookInfoList2);
sqlSession1.commit(); System.out.println("sqlSession 1 commit -----------------------------");
List<BookInfo> bookInfoList3 = sqlSession2.selectList("com.simon.demo.TestMapper.selectBookInfo"); System.out.println("sqlSession 2 query 1 ----------------------------- " + bookInfoList3); }}
复制代码


打印结果是:


由此我们能看到,只有第一次查询执行了 sql,其余两次查询均未去数据库中查询。这就是缓存的效用啦。


我们接下来去到源码来看一下究竟是如何生效的吧。

二级缓存创建过程一:加载配置类


首先,我们创建 SqlSessionFactory 工厂时,会从配置文件中加载所有的配置并生成 Configuration 对象,然后将 Configuration 对象放在 SqlSessionFactory 实例对象中维护起来。解析代码如下

package org.apache.ibatis.builder.xml;public class XMLConfigBuilder extends BaseBuilder {  ……  private void parseConfiguration(XNode root) {    try {      //issue #117 read properties first      propertiesElement(root.evalNode("properties"));
// 解析配置文件里的 setting 标签 Properties settings = settingsAsProperties(root.evalNode("settings")); loadCustomVfs(settings); loadCustomLogImpl(settings); // 生成别名 map 放进 configuration 中后备使用 typeAliasesElement(root.evalNode("typeAliases")); pluginElement(root.evalNode("plugins")); objectFactoryElement(root.evalNode("objectFactory")); objectWrapperFactoryElement(root.evalNode("objectWrapperFactory")); reflectorFactoryElement(root.evalNode("reflectorFactory")); settingsElement(settings); // read it after objectFactory and objectWrapperFactory issue #631 environmentsElement(root.evalNode("environments")); databaseIdProviderElement(root.evalNode("databaseIdProvider")); typeHandlerElement(root.evalNode("typeHandlers"));
// 解析配置文件里的 mappers 标签 mapperElement(root.evalNode("mappers")); } catch (Exception e) { throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e); } } /** * 把 settings 标签的所有配置加载成 Properties * @param context * @return */ private Properties settingsAsProperties(XNode context) { if (context == null) { return new Properties(); } Properties props = context.getChildrenAsProperties(); // Check that all settings are known to the configuration class MetaClass metaConfig = MetaClass.forClass(Configuration.class, localReflectorFactory); for (Object key : props.keySet()) { if (!metaConfig.hasSetter(String.valueOf(key))) { throw new BuilderException("The setting " + key + " is not known. Make sure you spelled it correctly (case sensitive)."); } } return props; } /** * 设置全局上下文属性 */ private void settingsElement(Properties props) { …… configuration.setCacheEnabled(booleanValueOf(props.getProperty("cacheEnabled"), true)); configuration.setLocalCacheScope(LocalCacheScope.valueOf(props.getProperty("localCacheScope", "SESSION"))); …… } ……}
复制代码

方法 settingsAsProperties 将配置文件中 setting 标签读为 Properties 对象,然后在 settingsElement 方法中全部赋给 configuration 对象,这其中就有对 cache 标签的处理,将 。这个 Configuration 是 BaseBuilder 中描述全局配置的一个类,后面会将它扔给 SqlSessionFactory ,作为全局上下文。


这里还有个方法比较重要,就是 typeAliasesElement 方法,这个方法是将我们配置好的一些别名类,以键值对的形式存储在 TypeAliasRegistry 类中的一个 HashMap 中,例如 "byte" -> Byte.class。这个 TypeAliasRegistry 也会被放入全局配置 Configuration 中。

二级缓存创建过程二:创建 Cache 对象并绑定 Mapper


解析配置文件后,mybatis 知道自己需要开启二级缓存,于是开始了创建缓存之路,首先,先扫描所有 Mapper 文件位置,然后一个个分析过去(此处以 resource 为例分析):

package org.apache.ibatis.builder.xml;public class XMLConfigBuilder extends BaseBuilder {  private void mapperElement(XNode parent) throws Exception {    if (parent != null) {      // 遍历 mybatis-config.xml 文件下面的 mappers 节点的子节点      for (XNode child : parent.getChildren()) {        // 判断是否是 Package,如果是的话可以直接拿 Package 去加载包下的 mapper 文件        if ("package".equals(child.getName())) {          String mapperPackage = child.getStringAttribute("name");          configuration.addMappers(mapperPackage);        } else {          // 如果不是的话,就是 mapper 标签(因为 xml 中只允许写这两种标签)          // 然后拿相应的属性,去分别作解析          String resource = child.getStringAttribute("resource");          String url = child.getStringAttribute("url");          String mapperClass = child.getStringAttribute("class");
// 解析 resource 表明位置的 mapper if (resource != null && url == null && mapperClass == null) { // 此处定义错误上下文,如果这里加载出错日志打印 ("### The error may exist in xxx"); ErrorContext.instance().resource(resource); // 读取 配置文件 成流 InputStream inputStream = Resources.getResourceAsStream(resource); XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments()); // 解析具体的 mapper 文件 mapperParser.parse(); }
// 解析 url 表明位置的 mapper else if (resource == null && url != null && mapperClass == null) { ErrorContext.instance().resource(url); InputStream inputStream = Resources.getUrlAsStream(url); XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments()); mapperParser.parse(); }
// 解析 mapperClass 表明位置的 mapper else if (resource == null && url == null && mapperClass != null) { Class<?> mapperInterface = Resources.classForName(mapperClass); configuration.addMapper(mapperInterface); } else { throw new BuilderException("A mapper element may only specify a url, resource or class, but not more than one."); } } } } }}
复制代码

找到 Mapper 后,开始针对 Mapper 的解析:

package org.apache.ibatis.builder.xml;public class XMLMapperBuilder extends BaseBuilder {  ……  public void parse() {    // 因为是公共方法,多处调用,所以这里先判断有没有加载过    if (!configuration.isResourceLoaded(resource)) {      // 没加载过的话,先去加载资源,这里创建了 Cache 对象      configurationElement(parser.evalNode("/mapper"));      configuration.addLoadedResource(resource);      bindMapperForNamespace();    }
parsePendingResultMaps(); parsePendingCacheRefs(); parsePendingStatements(); } private void configurationElement(XNode context) { try { String namespace = context.getStringAttribute("namespace"); if (namespace == null || namespace.isEmpty()) { throw new BuilderException("Mapper's namespace cannot be empty"); } builderAssistant.setCurrentNamespace(namespace); // 这两行是开启二级缓存比较关键的两步 // 这一步拿了别人的 cache 对象 设置给自己了 cacheRefElement(context.evalNode("cache-ref")); // 在这一步中构建了 Cache 对象 cacheElement(context.evalNode("cache")); // 解析参数 Map parameterMapElement(context.evalNodes("/mapper/parameterMap")); // 解析 resultMap resultMapElements(context.evalNodes("/mapper/resultMap")); // 解析每个 sql 标签(mapper 中有两种 sql,一种是 下面要解析的四打标签,还有直接用 sql 标签的) sqlElement(context.evalNodes("/mapper/sql")); // 解析四大标签,并放入 configuration 中,这里也会为每个开启缓存的 statement 设置上面生成好的缓存对象,也就是 Cache buildStatementFromContext(context.evalNodes("select|insert|update|delete")); } catch (Exception e) { throw new BuilderException("Error parsing Mapper XML. The XML location is '" + resource + "'. Cause: " + e, e); } } ……}
复制代码

这里我们跟缓存相关的有三步,第一步 cacheRefElement 是看看 mapper 中是否标注了 <cache-ref namespace=""/>标签,这个标签的意思是 我可以跟其他 namespace 的 mapper 共用一个 Cache。源码其实就是把 Configuration 中加载好的指定 mapper 的 Cache 对象引用给自己。我们重点看创建 Cache 对象的方法也就是cacheElement(context.evalNode("cache"));


private void cacheElement(XNode context) {  if (context != null) {    // 如果不指定类型,则默认缓存类型设置为 PERPETUAL    String type = context.getStringAttribute("type", "PERPETUAL");    // typeAliasRegistry 内部维护了一个 HashMap 并且预设了很多类别名,例如 "byte" -> Byte.class    // 这里指的就是之前加载配置时 typeAliasesElement 方法所做的    Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);    // eviction 意为驱逐、赶出。这里则代表着 缓存清除策略,即如何清除无用的缓存    // 代码可以看到,默认是 LRU 即 移除最长时间不被使用的对象。    // 官网文档共设有四种如下:    /**      LRU – Least Recently Used: Removes objects that haven't been used for the longst period of time.(清除长时间不用的)      FIFO – First In First Out: Removes objects in the order that they entered the cache.(清除最开始放进去的)      SOFT – Soft Reference: Removes objects based on the garbage collector state and the rules of Soft References.(软引用式清除)      WEAK – Weak Reference: More aggressively removes objects based on the garbage collector state and rules of Weak References.(弱引用式清除)    */    String eviction = context.getStringAttribute("eviction", "LRU");    Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);    // 刷新间隔,单位 毫秒,代表一个合理的毫秒形式的时间段。默认情况是不设置,也就是没有刷新间隔,缓存仅仅调用 update 语句时刷新。    Long flushInterval = context.getLongAttribute("flushInterval");    // 引用数目,要记住你缓存的对象数目和你运行环境的可用内存资源数目。默认值是1024。    Integer size = context.getIntAttribute("size");
// 下面是针对缓存对象实例是否只读的配置 // 只读的缓存会给所有调用者返回缓存对象的相同实例。因此这些对象不能被修改(一旦修改,别人取到的也是修改后的)。这提供了很重要的性能优势。 // 可读写的缓存会返回缓存对象的拷贝(通过序列化)。这会慢一些,但是安全,因此默认是false。 boolean readWrite = !context.getBooleanAttribute("readOnly", false); // 设置是否是阻塞缓存,如果是 true ,则在创建缓存的时候会包装一层 BlockingCache 。默认为 false boolean blocking = context.getBooleanAttribute("blocking", false); Properties props = context.getChildrenAsProperties(); // 此方法构建了一个新的 Cache 对象并设置到了 configuration 中。 builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props); } public Cache useNewCache(Class<? extends Cache> typeClass, Class<? extends Cache> evictionClass, Long flushInterval, Integer size, boolean readWrite, boolean blocking, Properties props) { // 此处使用建造者模式创建了 Cache,并且绑定了当前 Mapper 的命名空间并作为此 Cache 的 ID。 Cache cache = new CacheBuilder(currentNamespace) // 缓存实现类 .implementation(valueOrDefault(typeClass, PerpetualCache.class)) // 包装类(缓存回收策略类) .addDecorator(valueOrDefault(evictionClass, LruCache.class)) // 清除时间 .clearInterval(flushInterval) .size(size) .readWrite(readWrite) .blocking(blocking) .properties(props) .build(); // 构建好Cache后,加入到 configuration 中等待调用。 configuration.addCache(cache); currentCache = cache; return cache; }
复制代码


创建完毕后,这里调用了 configuration.addCache(cache) 方法将生成好的 cache 放进了 configuration 对象中,实际上就是将 cache 对象 put 进了 Configuration 类内部维护的一个 StrictMap 中,而这个 StrictMap 则是继承自 HashMap, 也就是说归根结底这里是将 cache 以 currentNamespace 为 Key 放入了一个 HashMap 中。


二级缓存创建过程三:为每个 sql 语句绑定 cache


在生成 Cache 对象后,Mapper 文件会将本 mapper 中所有的语句标签生成一个个 MappedStatement ,在这个过程中,会给每个 statement 绑定上二级缓存,使得他可以直接使用。

public void parseStatementNode() {    String id = context.getStringAttribute("id");    String databaseId = context.getStringAttribute("databaseId");
// 如果数据库 id 不为空且匹配不上的话,不进行下面的加载工作 if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) { return; }
String nodeName = context.getNode().getNodeName(); // 此处拿的是标签,insert | update | delete | select SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH)); // 是否是 select 语句 boolean isSelect = sqlCommandType == SqlCommandType.SELECT; // 是否清除缓存 boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect); // 是否使用二级缓存 boolean useCache = context.getBooleanAttribute("useCache", isSelect); // 结果是否排序 boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false); ······ // 配置一系列属性,标签上的对应属性可以在这里看到 StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString())); Integer fetchSize = context.getIntAttribute("fetchSize"); Integer timeout = context.getIntAttribute("timeout"); String parameterMap = context.getStringAttribute("parameterMap"); String resultType = context.getStringAttribute("resultType"); Class<?> resultTypeClass = resolveClass(resultType); String resultMap = context.getStringAttribute("resultMap"); String resultSetType = context.getStringAttribute("resultSetType"); ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType); if (resultSetTypeEnum == null) { resultSetTypeEnum = configuration.getDefaultResultSetType(); } String keyProperty = context.getStringAttribute("keyProperty"); String keyColumn = context.getStringAttribute("keyColumn"); String resultSets = context.getStringAttribute("resultSets");
// 构建解析完成的 MappedStatement ,也就是将 <select></select> 标签中的东西转为对象 // 此处绑定了二级缓存 builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType, fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass, resultSetTypeEnum, flushCache, useCache, resultOrdered, keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets); }
复制代码


构造 mappedStatement 的过程像构建 Cache 一样又臭又长,此处就不再赘述,感兴趣的小伙伴可以自行去看~


以上就是二级缓存的创建过程。二级缓存如此复杂,那么一级缓存呢?

一级缓存创建过程:


一级缓存的创建过程其实比二级缓存要简单得多,他不用考虑跨会话执行的问题,所以仅仅在创建当前会话(SQLSession)时,新建一个缓存对象即可,也就是代码中的 localCache ,如:

  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);      // 这里返回的 Executor 每次都是新的      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();    }  }    public Executor newExecutor(Transaction transaction, ExecutorType executorType) {    // 这里如果传进来的 executorType 为空,则采用默认的,如果默认的为空,则采用 simple    executorType = executorType == null ? defaultExecutorType : executorType;    executorType = executorType == null ? ExecutorType.SIMPLE : executorType;    Executor executor;
// 注意这里创建的所有类型的 Executor 实际上都继承自 BaseExecutor 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 SimpleExecutor extends BaseExecutor {
public SimpleExecutor(Configuration configuration, Transaction transaction) { // 这里执行了父类的构造方法 super(configuration, transaction); } ······}
public abstract class BaseExecutor implements Executor {
private static final Log log = LogFactory.getLog(BaseExecutor.class);
protected Transaction transaction; protected Executor wrapper;
protected ConcurrentLinkedQueue<DeferredLoad> deferredLoads; protected PerpetualCache localCache; protected PerpetualCache localOutputParameterCache; protected Configuration configuration;
protected int queryStack; private boolean closed;
protected BaseExecutor(Configuration configuration, Transaction transaction) { this.transaction = transaction; this.deferredLoads = new ConcurrentLinkedQueue<>(); // 这里新建了一个新的缓存 this.localCache = new PerpetualCache("LocalCache"); this.localOutputParameterCache = new PerpetualCache("LocalOutputParameterCache"); this.closed = false; this.configuration = configuration; this.wrapper = this; } ······}
复制代码

这个 PerpetualCache 是最普通的缓存,内部维护了一个 HashMap 作为缓存承载体。


正如注释所说,每次新开一个会话时,这个 Executor 都会被新建。于是内部维护的缓存自然是每次都更新,也就不存在跨 SQLSession 一说了。


总结一下:


  • 一级缓存的创建随着每次 SQLSession 的开启而创建,仅仅是 Executor 中维护的一个 简单缓存对象,内部以 HashMap 做实现。

  • 二级缓存的创建过程是先读取 mybatis-config.xml 文件确认缓存开启,然后根据 mapper 文件中的 cache 或 cache-ref 标签来创建缓存对象,以 namespace 为id 放在 Configuration 中,并且在解析 mapper 文件中每个 sql 语句时将 cache 对象绑定上。


上面主要讲述了 mybatis 一、二级缓存的创建过程,重点主要放在了二级缓存的创建过程。那么缓存具体是如何使用的,缓存又在什么时候被清空呢?还请大家跟着我继续往下看


Mybatis-3 源码之缓存是如何使用的


下面呢,则主要讲讲这个缓存对象创建出来后,到底是怎么给他用的。借用前面的图,由于开启二级缓存后,我们查询数据库的执行顺序如下,所以我们按照顺序来一步步深入:

使用缓存第一步:创建 Executor 对象

有过一定源码基础的同学肯定知道,我们 Mybatis 底层执行增删改查操作时,执行对象实际上就是一个个 Executor。那么不例外,我们使用缓存肯定也要在 Executor 上做手脚,那么我们跟随源码来看下 Mybatis 究竟做了什么“手脚”吧:


首先是 sqlSessionFactory.openSession()时调用的 openSessionFromDataSource 方法


  private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {    Transaction tx = null;    try {      final Environment environment = configuration.getEnvironment();      final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);      // 每次新建 SQLSession 都新创建一个事务      tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);      // 这里每次新建 SQLSession 时都返回新的 Executor      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();    }  }
复制代码


然后我们跟着代码进入这里的 newExecutor 方法:

  public Executor newExecutor(Transaction transaction, ExecutorType executorType) {    // 这里如果传进来的 executorType 为空,则采用默认的,如果默认的为空,则采用 simple    executorType = executorType == null ? defaultExecutorType : executorType;    executorType = executorType == null ? ExecutorType.SIMPLE : executorType;    Executor executor;
// 注意这里创建的所有类型的 Executor 实际上都继承自 BaseExecutor 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); } // 判断之前传进来的 configuration 里是否开启缓存 if (cacheEnabled) { // 这里传进去的 executor 就是后面 query 方法中的 delegate。 executor = new CachingExecutor(executor); } executor = (Executor) interceptorChain.pluginAll(executor); return executor; }
复制代码

先说一句题外话,我们看到,根据传入的类型会创建不同类型的 Executor ,而这里的 BatchExecutorReuseExecutor SimpleExecutor 实际上都继承了BaseExecutor 方法,这里 Mybatis 采用了模板模式。定义了很多操作顺序,而由子类实现具体方法。后期会出一个设计模式的板块,敬请期待。


好了,言归正传。我们发现这里有一个很让人欣喜的判断:if (cacheEnabled),嘿我们昨天从 mybatis-config.xml 配置文件里读进来的好像就是这玩意儿!没错就是他,这里会根据你设置 cacheEnabled 的值来决定是否创建 CachingExecutor 。也就是说如果我们设置为 true,这里就会为这些 Executor 们包装上一层 CachingExecutor 。而这个 CachingExecutor 则是二级缓存的关键包装类。


OK,创建 SQLSession 的步骤完成了,我们紧接着来看他的查询方法究竟是怎么使用缓存的吧!

使用缓存第二步:生成缓存 Key

话不多说,我们直接上查询的源码吧,这里以 selectList 为例:


这里追踪源码时,不要忘记实现类是 CachingExecutor



  @Override  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {    BoundSql boundSql = ms.getBoundSql(parameterObject);    // 根据 ms、参数、分页参数、sql 生成这个 statement 唯一的缓存 key    CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);    return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);  }
复制代码

我们继续追踪生成 key 的方法:

public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {    if (closed) {      throw new ExecutorException("Executor was closed.");    }    // 新建一个 CacheKey,并更新 cacheKey 的 hashcode    CacheKey cacheKey = new CacheKey();    // 附加计算当前 sql 的 id,即 <select id = "xxxx"><select>    cacheKey.update(ms.getId());    // 附加计算分页中的 offset    cacheKey.update(rowBounds.getOffset());    // 附加计算分页中的 limit    cacheKey.update(rowBounds.getLimit());    // 附加计算 sql 语句    cacheKey.update(boundSql.getSql());    // 取到参数映射    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();    // 拿到配置中加载好的 处理类 注册簿,内部维护了一个 HashMap    // 加载步骤为 org.apache.ibatis.builder.xml.XMLConfigBuilder.parseConfiguration 方法中的 typeHandlerElement 方法    // 以键值对形式存储每个类型的 typeHandler 如 Boolean.class -> new BooleanTypeHandler()    TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();    // mimic DefaultParameterHandler logic    // 模仿DefaultParameterHandler逻辑    for (ParameterMapping parameterMapping : parameterMappings) {      // 判断这里的参数不是存储过程的 out 类参数      if (parameterMapping.getMode() != ParameterMode.OUT) {        Object value;        // 拿到属性名称        String propertyName = parameterMapping.getProperty();        if (boundSql.hasAdditionalParameter(propertyName)) {          // 如果有附加参数,取出附加参数          value = boundSql.getAdditionalParameter(propertyName);        } else if (parameterObject == null) {          // 参数为空的情况          value = null;        } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {          // 如果有相应的类型处理器,参数为本身          value = parameterObject;        } else {          // 创建一个 MetaObject          MetaObject metaObject = configuration.newMetaObject(parameterObject);          value = metaObject.getValue(propertyName);        }        // 将参数也附加到 CacheKey 的 hashcode 计算中        cacheKey.update(value);      }    }    if (configuration.getEnvironment() != null) {      // 如果配置文件中 environment 标签不为空      // issue #176      // 再加上当前环境的 id 即 <environment id="development">      cacheKey.update(configuration.getEnvironment().getId());    }    return cacheKey;  }
复制代码

不知道你们好不好奇这个 update 方法,不管了,我们继续跟进去看看他到底对这些个东西们做了什么

package org.apache.ibatis.cache;public class CacheKey implements Cloneable, Serializable {	  // 乘数,固定初始值质数37,不会变  private static final int DEFAULT_MULTIPLIER = 37;
// 当前hashCode值,初始值是质数17, private static final int DEFAULT_HASHCODE = 17;
// 乘数,默认值为质数37,不会变 private final int multiplier; // 当前hashCode值,默认值为质数17, private int hashcode; // 所有更新对象的初始hashCode的和 private long checksum; // 更新的对象总数 private int count;
/* 8/21/2017 - Sonar lint flags this as needing to be marked transient. While true if content is not serializable, this is not always true and thus should not be marked transient. */ // 已更新的所有 obj 的列表 private List<Object> updateList;
public CacheKey() { this.hashcode = DEFAULT_HASHCODE; this.multiplier = DEFAULT_MULTIPLIER; this.count = 0; this.updateList = new ArrayList<>(); } public void update(Object object) { // 先计算传进来的这个 obj 的基础 HashCode,如果为空的话则是 1 int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object); // 记录更新个数 count++; // 计算 hashCode 的总和 checksum += baseHashCode; // 将基础 HashCode 跟更新个数相乘 baseHashCode *= count; // 最终得到新的 hashcode 为 固定数字 37 * 最新 hashcode 再加上 计算后的参数对象的 hashcode hashcode = multiplier * hashcode + baseHashCode; // 将传进来的 obj 放到已更新列表中 updateList.add(object); }}
复制代码

具体的代码在这里,深刻的思想我也并没有研究出来。他这样做的原理我也没思考出来。但是目的我猜一定是为了让 hashcode 尽量的不重复,以做到在 map 中尽量散列分布,避免 hash 冲突。


生成了缓存键后,我们终于来到了查询步骤,话不多说,我们来看看 query 方法做了什么!

使用缓存第三步:查询使用二级缓存!

我们来详细看下 query 方法到底做了什么

  @Override  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)      throws SQLException {    // 这里是看我们有没有定义 Cache 对象,也就是我们在 Mapper 文件中有没有定义 <cache/> 标签    // 如果有标签,在读取 Mapper 文件时会创建 Cache 对象来存储这个 Mapper 文件中所有需要缓存的东西    Cache cache = ms.getCache();    if (cache != null) {      // 如果标签属性上标注了 flushCache="true" ,这里会先清空缓存	  flushCacheIfRequired(ms);      if (ms.isUseCache() && resultHandler == null) {        // 确定本条不是一个有 OutParams 的存储过程,否则抛出异常        ensureNoOutParams(ms, boundSql);        @SuppressWarnings("unchecked")        // 这里 TransactionalCacheManager 维护了一个以 Cache 为键,TransactionalCache 为值的一个 Map        // 内部方法是尝试从 cache 中拿值        List<E> list = (List<E>) tcm.getObject(cache, key);        if (list == null) {          // 这里的 delegate 代表的是根据ExecutorType创建的几大执行器,例如 SimpleExecutor。          // 也就是说,他这里只不过是先根据是否开启二级缓存,尝试是否能从缓存中拿到数据,          // 但是如果真的没拿出来的话,真正查询还是交由传入的执行器来执行          // 也就是传说中的 装饰器模式          list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);          // 这里是往 TransactionalCache 中赋值          tcm.putObject(cache, key, list); // issue #578 and #116        }        return list;      }    }    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);  }
复制代码

一步一步来,我们先看获取缓存,也就是 tcm.getObject方法。这里 tcm 代表的是 TransactionalCacheManager 对象,是 CachingExecutor 的一个成员变量,也就是说随着 CachingExecutor 实例的创建而创建,随 CachingExecutor 实例回收而回收。那它是干啥的呢,它其实内部维护了一个以 Cache 为键,TransactionalCache 为值的一个 Map。我们来看看这个类的具体实现和方法:

public class TransactionalCacheManager {
private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
public void clear(Cache cache) { getTransactionalCache(cache).clear(); }
public Object getObject(Cache cache, CacheKey key) { // 这里看上去是先根据 Cache 拿出内部 TransactionalCache,然后再从 TransactionalCache 中拿值。 // 但实际上 TransactionalCache 是一个装饰器类,它负责装饰了 cache ,最终还是从 cache 中拿的值 return getTransactionalCache(cache).getObject(key); }
public void putObject(Cache cache, CacheKey key, Object value) { // 这里看上去跟上面的 getObject 方法一样,但是这里却不是给 cache put 值, // 而是给 TransactionalCache 内部维护的一个 HashMap 类型的变量 entriesToAddOnCommit put值 // 这么做是为了保证事务的隔离性,缓存同样要等事务提交后统一刷到公共 cache 中 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) { // 这里的 computeIfAbsent 相当于如下代码: /* if(null == transactionalCaches.get(cache)){ transactionalCaches.put(cache, new TransactionalCache(cache)); } transactionalCaches.computeIfAbsent(cache, k -> new TransactionalCache(k)); */ return transactionalCaches.computeIfAbsent(cache, TransactionalCache::new); }
}
复制代码

我们看回到 getObject方法,这里调用了 getTransactionalCache 方法从内部维护的 HashMap 中拿到了一个 TransactionalCache 实例并调用它的 get 方法。这里的 computeIfAbsent方法是 1.8 中针对 HaspMap 的方法,具体示意我写在注释里了,大家感兴趣的话可以自行查询~


这一步需要注意的是,在 get 不到值的时候 new 出来的 TransactionalCache 实际上是一个包装类,进一步包装了 cache。


我们来看下 TransactionalCache 的构造方法和 get 方法你就懂了:


public class TransactionalCache implements Cache {
private static final Log log = LogFactory.getLog(TransactionalCache.class);
private final Cache delegate; private boolean clearOnCommit; private final Map<Object, Object> entriesToAddOnCommit; private final Set<Object> entriesMissedInCache;
public TransactionalCache(Cache delegate) { this.delegate = delegate; this.clearOnCommit = false; this.entriesToAddOnCommit = new HashMap<>(); this.entriesMissedInCache = new HashSet<>(); } @Override public Object getObject(Object key) { // issue #116 // 注意这里拿是在 delegate 中拿的而不是 entriesToAddOnCommit 中 Object object = delegate.getObject(key); if (object == null) { // 记录未命中缓存的 CacheKey,后面 commit 的时候会放置一个 null 值进主缓存 entriesMissedInCache.add(key); } // issue #146: https://github.com/mybatis/mybatis-3/issues/146 // 这里是防止 事务提交后清除缓存 这个动作已经执行了,但是缓存中还是能拿到东西。 if (clearOnCommit) { return null; } else { return object; } }}
复制代码

也就是这里的 get 实际上是从 delegate 即传入的 cache 中拿的。这里如果没拿到,会记录一个未命中 CacheKey,这个操作后面 commit 的时候我们详说。总之,这里第一次进来肯定是查不到的,也就是这会返回一个 null。返回到我们的 query 的代码,这里他判断如果拿出来的 list 为空,则调用被包装类的 query 方法,即 SimpleExecutor query 方法,即 BaseExecutor query方法。这里就涉及到了一级缓存使用的过程。

使用缓存第四步:查询使用一级缓存!


我们来看下这个方法做了些什么。

  @SuppressWarnings("unchecked")  @Override  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.");    }    // 判断有没有刷新缓存的必要(属性 flushCache="true" )    if (queryStack == 0 && ms.isFlushCacheRequired()) {      clearLocalCache();    }    List<E> list;    try {      queryStack++;      // 这里判断是否指定 ResultHandler,如果没指定则尝试从缓存中拿,指定了则直接查数据库      // 此处的缓存是一级缓存,因为 localCache 是每个 Executor 自己维护的。      // 随着每次close,都会被清空。 新建的 Executor 也无法使用上次的。      list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;      if (list != null) {        // 如果从缓存中拿出数据,这里处理的是存储过程相关的 sql 和 参数        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();      // 这里判断缓存范围如果是 STATEMENT 级别的话,清空本地缓存      // 即 <setting name="localCacheScope" value="STATEMENT"/>      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {        // issue #482        clearLocalCache();      }    }    return list;  }
复制代码

这个 localCache就是我们一直说的 一级缓存 对象,看完这里大家一定很好奇,这里只见到了拿缓存的方法(localCache.getObject)但是没看到在哪放的呀。大家稍安勿躁,我们来看看这个 queryFromDatabase 方法:

  private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {    List<E> list;    // localCache 内部维护了一个空的 HashMap ,这一步是先在localCache中放一个占位对象。    localCache.putObject(key, EXECUTION_PLACEHOLDER);    try {      // 从数据库中查询      list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);    } finally {      // 不管查询是否失败,先从map中删掉占位对象      localCache.removeObject(key);    }    // 这里把 list 存到本地缓存中    localCache.putObject(key, list);    if (ms.getStatementType() == StatementType.CALLABLE) {      // 当 statementType="CALLABLE"的时候,也就是调用存储过程的时候,设置 out 类参数      localOutputParameterCache.putObject(key, parameter);    }    return list;  }
复制代码


呐,看到了吧。查完后 localCache.putObject 方法就是放缓存的。这里为什么放置占位对象笔者也没太想懂,各位看官大佬有想法可以留言讨论哦。


我们再看回 query 方法,会发现这里有一步清除缓存的判断,这里的 localCacheScope 我觉得还是有必要拿出来说一下的,这是禁用一级缓存的必要手段。我们可以在 mybatis-config.xml 这个配置文件中,设置相应的 settings 来关闭一级缓存例如:

  <settings>    <setting name="localCacheScope" value="STATEMENT"/>  </settings>
复制代码

官网给这个配置的解释是:


MyBatis uses local cache to prevent circular references and speed up repeated nested queries. By default (SESSION) all queries executed during a session are cached. If localCacheScope=STATEMENT local session will be used just for statement execution, no data will be shared between two different calls to the same SqlSession.


谷歌翻译:MyBatis使用本地缓存来防止循环引用并加快重复的嵌套查询。 默认情况下(会话),将缓存会话期间执行的所有查询。 如果 localCacheScope = STATEMENT 本地会话仅用于语句执行,则对同一SqlSession的两个不同调用之间不会共享数据。


欸,是不是奇怪的知识又增加了。话不多说我们接着看 query 查询完成后的事情吧:

使用缓存第五步:放置二级缓存!

查询完毕后,就调用了 tcm.putObject,好我知道大家肯定找不到了,这里我再放一边 put方法的源码:

  public void putObject(Cache cache, CacheKey key, Object value) {    // 这里看上去跟上面的 getObject 方法一样,但是这里却不是给 cache put 值,    // 而是给 TransactionalCache 内部维护的一个 HashMap 类型的变量 entriesToAddOnCommit put值    // 这么做是为了保证事务的隔离性,缓存同样要等事务提交后统一刷到公共 cache 中    getTransactionalCache(cache).putObject(key, value);  }    private TransactionalCache getTransactionalCache(Cache cache) {    // 这里的 computeIfAbsent 相当于如下代码:    /*      if(null == transactionalCaches.get(cache)){        transactionalCaches.put(cache, new TransactionalCache(cache));      }      transactionalCaches.computeIfAbsent(cache, k -> new TransactionalCache(k));     */    return transactionalCaches.computeIfAbsent(cache, TransactionalCache::new);  }
复制代码

这里我们再进一步追入putObject方法来看看。

  @Override  public void putObject(Object key, Object object) {    // 这里的putObject 方法只是将 obj 放到了当前事务的缓存中即 entriesToAddOnCommit 中。    // 所以事务不提交的话,在 delegate 中是拿不到的。用以保证事务缓存隔离    entriesToAddOnCommit.put(key, object);  }
复制代码

这里可以看到,这仅仅是在TransactionalCache 实例内部的一个 HashMap 中暂存了一下,而并没有调用 delegate 的 put 方法。这也就是说为什么两个事务在提交前都读不到互相的缓存。其实这里可以衍生出很多有趣的 demo,例如 关闭一级缓存后,即使在同一个开启了二级缓存 sqlsession中查询两次,也需要查询两次数据库。具体更多有意思的 demo 可以留言一起交流~


这里 put 进了临时的 map 中,那么什么时候合并进主存中呢?是的,就是当事务提交时,当 CachingExecutor 执行 commit时,会顺带调用 tcm 的提交方法:


  @Override  public void commit(boolean required) throws SQLException {    delegate.commit(required);    tcm.commit();  }
复制代码

这里面就将当前事务的临时缓存存入了主缓存:

  public void commit() {    for (TransactionalCache txCache : transactionalCaches.values()) {      txCache.commit();    }  }
// txCache.commit public void commit() { if (clearOnCommit) { delegate.clear(); } // 当事务提交时,这里统一刷缓存 flushPendingEntries(); reset(); } /** * 这个方法是将本次事务缓存中的所有缓存刷到 delegate 中 * 做到了缓存的事务隔离 */ private void flushPendingEntries() { // 遍历 entry for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) { delegate.putObject(entry.getKey(), entry.getValue()); } for (Object entry : entriesMissedInCache) { // 如果未命中的 CacheKey 在 当前内部缓存中没有的话,则放置一个 null 进主缓存 // 目的应该是防止缓存击穿(大量查询一个不存在的值) if (!entriesToAddOnCommit.containsKey(entry)) { delegate.putObject(entry, null); } } }
复制代码

这里说到了我们之前放过的 entriesToAddOnCommit,这里如果没命中缓存,且在提交的时候也没查出来,那么就会向主缓存中放一个 null 值占位。目的我猜测是防止缓存击穿。


那么这里有缓存,我们进行增删改的时候,会刷新缓存嘛?我们继续看。

使用缓存第六步:更新时清除缓存!

我们分别写了三个语句,并用 insert | update | delete 三个方法执行:

    sqlSession1.insert("com.simon.demo.TestMapper.insertBookInfo");    sqlSession1.update("com.simon.demo.TestMapper.updateBookInfo");    sqlSession1.delete("com.simon.demo.TestMapper.deleteBookInfo");
复制代码

有点源码基础的同学其实知道这里三个方法 共用了同一个 update 方法


那么这个 update 方法内部对缓存又进行了什么操作呢?(注意这里选择实现类时,要选择 CachingExecutor )


  @Override  public int update(MappedStatement ms, Object parameterObject) throws SQLException {    // 先根据需要看是否清除缓存    flushCacheIfRequired(ms);    // 在调用 被包装类的 update 方法    return delegate.update(ms, parameterObject);  }    private void flushCacheIfRequired(MappedStatement ms) {    // 获取当前缓存    Cache cache = ms.getCache();    // 除非配置,不然 insert | update | delete 三大标签的 flushCacheRequired 默认为 true    // 这里可以看加载生成 Mapper 的默认赋值 ->     // org.apache.ibatis.builder.xml.XMLStatementBuilder.parseStatementNode ->     // org.apache.ibatis.builder.MapperBuilderAssistant.addMappedStatement    if (cache != null && ms.isFlushCacheRequired()) {      // 调用缓存清除方法      tcm.clear(cache);    }  }
复制代码

这里有两个重点,一个是 isFlushCacheRequired 是在哪加载到的,实际上这就是在我们生成 MappedStatement 时加载进 ms 的:


  public MappedStatement addMappedStatement(      String id,      SqlSource sqlSource,      StatementType statementType,      SqlCommandType sqlCommandType,      Integer fetchSize,      Integer timeout,      String parameterMap,      Class<?> parameterType,      String resultMap,      Class<?> resultType,      ResultSetType resultSetType,      boolean flushCache,      boolean useCache,      boolean resultOrdered,      KeyGenerator keyGenerator,      String keyProperty,      String keyColumn,      String databaseId,      LanguageDriver lang,      String resultSets) {
if (unresolvedCacheRef) { throw new IncompleteElementException("Cache-ref not yet resolved"); }
id = applyCurrentNamespace(id, false); boolean isSelect = sqlCommandType == SqlCommandType.SELECT;
MappedStatement.Builder statementBuilder = new MappedStatement.Builder(configuration, id, sqlSource, sqlCommandType) .resource(resource) .fetchSize(fetchSize) .timeout(timeout) .statementType(statementType) .keyGenerator(keyGenerator) .keyProperty(keyProperty) .keyColumn(keyColumn) .databaseId(databaseId) .lang(lang) .resultOrdered(resultOrdered) .resultSets(resultSets) .resultMaps(getStatementResultMaps(resultMap, resultType, id)) .resultSetType(resultSetType) // 这里定义了是否清除缓存区,默认值取决于是否是 select 类型的 sql // 如果是 select 的话,默认不清除缓存,不是 select 默认清除 .flushCacheRequired(valueOrDefault(flushCache, !isSelect)) // 这里定义了是否使用缓存,默认值也取决于是否是 select 类型的 sql // 如果是 select 的话,默认开启缓存 .useCache(valueOrDefault(useCache, isSelect)) // 这里将前面创造好的 Cache 对象绑定进 mappedStatement 对象 // 这里将已有的缓存绑定入 MappedStatement 对象 // 也就是说不管是什么类型的语句(包括 insert update delete)都有绑定缓存对象 .cache(currentCache);
ParameterMap statementParameterMap = getStatementParameterMap(parameterMap, parameterType, id); if (statementParameterMap != null) { statementBuilder.parameterMap(statementParameterMap); } // 做必要参数的非空校验 MappedStatement statement = statementBuilder.build(); // 在上下文中加入处理好的MappedStatement,以 id 为 key,实例为 value configuration.addMappedStatement(statement); return statement; }
复制代码

第二个重点就是 tcm 的清理方法,即 tcm.clear方法:

  // TransactionalCacheManager  public void clear(Cache cache) {    getTransactionalCache(cache).clear();  }
复制代码

这里实际上调用的是 map 中所存的 TransactionalCache实例的clear方法:

  @Override  public void clear() {    // 提交时清除的 标志位    clearOnCommit = true;    // 当前内部缓存清除    entriesToAddOnCommit.clear();  }
复制代码

大家有没有发现一个事情,这里执行完,实际上并没有清掉主缓存,而是只是清掉了当前事务的临时缓存。大家还记得我们的提交方法嘛?

  // txCache.commit  public void commit() {    if (clearOnCommit) {      delegate.clear();    }    // 当事务提交时,这里统一刷缓存    flushPendingEntries();    reset();  }
复制代码

看到没,这里只有在提交(commit)的时候,才会去清主存。这么做也是防止不同事务之间的脏读。这里也可延伸出很多好玩的 demo,比如 sqlSession1 先 select 然后 commit 然后 insert ,sqlsession2 执行相同查询时不查数据库,而是返回 sqlSession1 第一次查询的值。


说到这里,我们的缓存好强大啊,那我们的缓存是完美的嘛?当然不是,我们接着来看:

使用缓存第七步:明白优缺点!


我们使用缓存当然要明白他的优势和缺点在哪里:


  • 优点:优点自然不用多说,我们可以减少查询数据库的次数,降低打开、关闭数据库连接的性能消耗。提高查询速度,缩短查询时间。

  • 缺点:其实最大的缺点在于很容易发生数据的不一致性,为什么这么说呢。我们知道,每个缓存是基于 Mapper 的,缓存的清空也是基于当前 Mapper 的 insert | update | delete 等更新操作。那么我们分两点来看:

  • 第一点是网上普遍说的针对一个表中的所有操作必须放到一个 Mapper 中,比如现在有 Mapper A 和 Mapper B,A 中有针对表 T 的读 sql,B 中则是对表的写 sql,那么这就会导致 A 中修改数据未刷新 B 的缓存,那么读到的数据就是有问题的。针对这个问题实际上是有解法的,我们大可使用cache-ref标签解决。在文章的一开始介绍了 cache-ref 标签。可以让两个 Mapper 使用同一个 Cache ,这样就解决了不刷新的问题

  • 第二个问题是第一个问题的加深版。因为我发现,分布式是无法解决上述问题的。针对两台机器上部署相同的微服务,假如 A 机器读,B机器写且提交,A再去读的话,就有可能会读到二级缓存的东西而导致数据出错。所以才会采用 Redis 之类的缓存手动做缓存失效和刷新。


整个缓存的流程到这里就基本结束了,其实其中还略过了很多东西,例如缓存回收策略类的包装是如何构建的,缓存是如何回收的 ,缓存失效策略具体是如何实现的等,还有待大家细细探寻。

2020 年 11 月 05 日 11:38694

评论

发布
暂无评论
发现更多内容

第10周总结

alpha

极客大学架构师训练营

架构师训练营 1 期 - 第十周总结(vaik)

行之

极客大学架构师训练营

第6周作业

Rocky·Chen

CAP原理

Architecture Phase1 Week10:HomeWork

phylony-lu

极客大学架构师训练营

第十周作业

TheSRE

极客大学架构师训练营

六、CAP

Geek_28b526

第十周作业 (作业一)

Geek_83908e

架构师一期

Week6 (技术选型二)作业 2

shuyaxx

【架构师训练营第 1 期 10 周】 学习总结

Bear在挨踢

极客大学架构师训练营

《Python数据科学入门》PDF免费下载

计算机与AI

Python 学习 数据科学

架构师训练营第十周学习总结

文智

极客大学架构师训练营

Week 6 学习总结

evildracula

学习 架构

架构一期第十周作业

Airs

架构师训练营 1 期 - 第十周作业(vaik)

行之

极客大学架构师训练营

第六周作业

willson

极客大学架构师训练营

身为一名优秀的程序员,如何避免满屏的写 if else!

Java架构师迁哥

第 6 周 系统架构作业

心在那片海

训练营第六周总结

大脸猫

极客大学架构师训练营

第 6 周 系统架构总结

心在那片海

第10周作业

alpha

极客大学架构师训练营

CAP 原理笔记

梧桐

第十周作业 (作业二)

Geek_83908e

架构师一期

Week_10 作业

golangboy

极客大学架构师训练营

第六周作业

hunk

极客大学架构师训练营

Week 6 作业

evildracula

学习 架构

架构师训练营week10

FG佳

架构师一期

week 6 学习笔记

willson

架构师训练营第十周课程笔记及心得

Airs

架构师训练营week10总结

FG佳

成为架构师 - 架构师训练营第 06 周

陈永龙Vincent

架构师训练营 1 期 -- 第十周作业

曾彪彪

Mybatis-3 源码之缓存是怎么创建和使用的-InfoQ