疑難雜癥-MyBatis一級(jí)緩存引起的分頁(yè)插件失效

癥狀:使用自定義MyBatis分頁(yè)插件,只有分頁(yè)參數(shù)不同的方法在短時(shí)間內(nèi)使用不同分頁(yè)參數(shù)查詢出來的結(jié)果相同。
病因:自定義MyBatis插件攔截目標(biāo)為StatementHandler,而在同一個(gè)SqlSession中,在StatementHandler.prepare之前,MyBatis的已經(jīng)命中了一級(jí)緩存,所以直接返回了緩存中的內(nèi)容。
治療方案:重寫自定義MyBatis分頁(yè)插件使之?dāng)r截Executor,或增加新的插件,使之?dāng)r截Executor清除一級(jí)緩存。

這是我最近在一個(gè)項(xiàng)目中排查的一個(gè)問題,在這里記錄一下以備后查。

首先這個(gè)項(xiàng)目并沒有使用比較流行的PageHelper插件,而是自己實(shí)現(xiàn)了一個(gè),由于不是本人的代碼,就不貼出來了。網(wǎng)上搜一下的話也有很多類似的,主要的實(shí)現(xiàn)原理是使用MyBatis提供的@Intercepts攔截StatementHandler類的prepare方法,通過反射獲取到MappedStatementBoundSql。如果執(zhí)行的是約定的分頁(yè)方法(MappedStatement的id帶有Page后綴),那么就把BoundSql中的sql字段更改為帶有分頁(yè)功能的sql。如果要使用分頁(yè)查詢,那么分頁(yè)方法的參數(shù)需要帶有分頁(yè)參數(shù),同時(shí)分頁(yè)方法名需要帶有約定的Page后綴。乍一看沒有什么問題,但是在真正使用的時(shí)候,由于MyBatis一級(jí)緩存的存在,同一個(gè)SqlSession中后續(xù)的分頁(yè)方法生成了相同的CacheKey,導(dǎo)致直接返回了緩存中的內(nèi)容。這里主要的問題是攔截的時(shí)機(jī),攔截發(fā)生在MyBatis決定是否要使用緩存之后??!

關(guān)于MyBatis的緩存機(jī)制,網(wǎng)上有很多資料講的很詳細(xì),不清楚的可以先了解了解??偟膩碚f,在同一個(gè)SqlSession中,執(zhí)行同一條sql MyBatis會(huì)直接返回緩存。不過對(duì)于我們碰到的問題,一開始我是帶著疑慮的,兩次執(zhí)行的方法明明分頁(yè)參數(shù)是不同的,怎么會(huì)命中同一個(gè)緩存呢?帶著這個(gè)疑問我們debug MyBatis的源碼,查看其生成CacheKey的邏輯。首先當(dāng)執(zhí)行一條sql的時(shí)候,會(huì)進(jìn)到4參數(shù)CachingExecutor.query,(網(wǎng)上一查,這個(gè)類是用來處理二級(jí)緩存的,明明沒用二級(jí)緩存,為何會(huì)用到這個(gè)類?這里先留個(gè)懸念,后面再說):

  @Override
  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    BoundSql boundSql = ms.getBoundSql(parameterObject);
    CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
    return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

再看這里緩存key的生成方法,實(shí)際上是調(diào)用了delegatecreateCacheKey方法。(那這個(gè)delegate又是個(gè)啥?我們待會(huì)一起說):

  @Override
  public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
    return delegate.createCacheKey(ms, parameterObject, rowBounds, boundSql);
  }

查看createCacheKey方法的實(shí)現(xiàn)類,就只有CachingExecutorBaseExecutor,所以這里是使用了BaseExecutor.createCacheKey生成緩存key:

@Override
  public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    CacheKey cacheKey = new CacheKey();
    cacheKey.update(ms.getId());
    cacheKey.update(rowBounds.getOffset());
    cacheKey.update(rowBounds.getLimit());
    cacheKey.update(boundSql.getSql());
    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
    TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
    // mimic DefaultParameterHandler logic
    for (ParameterMapping parameterMapping : parameterMappings) {
      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 = configuration.newMetaObject(parameterObject);
          value = metaObject.getValue(propertyName);
        }
        cacheKey.update(value);
      }
    }
    if (configuration.getEnvironment() != null) {
      // issue #176
      cacheKey.update(configuration.getEnvironment().getId());
    }
    return cacheKey;
  }

這里可以看到,CacheKey由四組參數(shù)組成,簡(jiǎn)單說就是待執(zhí)行的

  1. SQL代碼的ID(ms.getId()),
  2. MyBatis自帶的內(nèi)存分頁(yè)邊界(rowBounds.getOffset()rowBounds.getLimit()),
  3. xml中的sql語(yǔ)句(boundSql.getSql()),
  4. 實(shí)際執(zhí)行的參數(shù)(通過獲取boundSql.getParameterMappings()并將parameterObject映射獲得)。

雖然由于分頁(yè)參數(shù)不同,我們這里每次傳入的parameterObject不同,但是由于分頁(yè)sql是在StatementHandler中進(jìn)行拼裝,xml中的sql并沒有寫相應(yīng)的參數(shù)去接受分頁(yè)參數(shù),所以boundSql.getParameterMappings()并沒有包含我們的分頁(yè)參數(shù),不同的parameterObject也就并沒有造成不同的CacheKey。

搞明白了問題的原因,接下來我們要來解決這個(gè)問題。思路有四條,A:參考網(wǎng)上比較流行的PageHelper,將我們的分頁(yè)插件改寫成攔截Executor,在生成CacheKey前就拼裝好SQL;B:執(zhí)行分頁(yè)方法時(shí)disable一級(jí)緩存;C:直接使用PageHelper替換;D:將分頁(yè)參數(shù)寫入xml中,如where #{pageNum} = #{pageNum} and #{pageSize} = #{pageSize}。這樣不會(huì)影響執(zhí)行結(jié)果,也能保證每次CacheKey值不同,實(shí)際上我們發(fā)現(xiàn)問題后的臨時(shí)解決方案就是這個(gè)。最終評(píng)估改造成本后,決定使用B方案。

要disable緩存,我們得知道緩存再何時(shí)被使用。那我們繼續(xù)debug,發(fā)現(xiàn)4參數(shù)數(shù)的CachingExecutor.query中生成CacheKey后調(diào)用了內(nèi)部6參數(shù)的query方法:

  @Override
  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
      throws SQLException {
    Cache cache = ms.getCache();
    if (cache != null) {
      flushCacheIfRequired(ms);
      if (ms.isUseCache() && resultHandler == null) {
        ensureNoOutParams(ms, parameterObject, boundSql);
        @SuppressWarnings("unchecked")
        List<E> list = (List<E>) tcm.getObject(cache, key);
        if (list == null) {
          list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        return list;
      }
    }
    return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

這里有段if (cache != null)的判斷邏輯,一開始還以為這里就是緩存起效的邏輯,但debug后發(fā)現(xiàn)并不是,每次進(jìn)來這邊cache取到的都是null,排除這里使用cache的可能。(那這里的cache是個(gè)啥?別急,待會(huì)和前面兩個(gè)問題一塊說。)這邊最后調(diào)用了delegate.query,也就是BaseExecutor.query

@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.");
    }
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }
    List<E> list;
    try {
      queryStack++;
      list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
      if (list != null) {
        handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
      } else {
        list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
      }
    } finally {
      queryStack--;
    }
    if (queryStack == 0) {
      for (DeferredLoad deferredLoad : deferredLoads) {
        deferredLoad.load();
      }
      // issue #601
      deferredLoads.clear();
      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        // issue #482
        clearLocalCache();
      }
    }
    return list;
  }

終于我們找到了我們要找的方法,我們看到這里通過localCache.getObject(key)獲取緩存,如果存在直接返回,否則執(zhí)行去數(shù)據(jù)庫(kù)查詢結(jié)果queryFromDatabase,再看queryFromDatabase

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

這里通過localCache.putObject存放一級(jí)緩存。

由于我們每次調(diào)用CacheKey都相同,所以在BaseExecutor.query中就直接使用了緩存返回。那我們的攔截器是在哪里生效的?我們可以繼續(xù)往下看,這里的doQuery方法調(diào)用了具體子類的doQuery方法,如SimpleExecutor.doQuery

  @Override
  public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
    Statement stmt = null;
    try {
      Configuration configuration = ms.getConfiguration();
      StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);
      stmt = prepareStatement(handler, ms.getStatementLog());
      return handler.<E>query(stmt, resultHandler);
    } finally {
      closeStatement(stmt);
    }
  }

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

而這里的handler.prepare才是真正被我們的分頁(yè)攔截器攔截到的方法,而這個(gè)時(shí)候緩存的使用早就被決定了。

那么我們?nèi)绾蝑isable緩存呢?我們回過頭去看BaseExecutor.query方法,這里有一個(gè)關(guān)鍵的判斷:

    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }

顯然,我們可以通過將msflushCacheRequired設(shè)置為true來強(qiáng)行清除緩存。增加一個(gè)攔截器攔截Executor.query,利用反射強(qiáng)改flushCacheRequired屬性。注意,這里只能攔截4參數(shù)的query方法而不能攔截到6參數(shù)的,由于6參數(shù)的query方法是通過內(nèi)部調(diào)用的,無(wú)法被動(dòng)態(tài)代理。具體代碼如下供大家參考:

@Intercepts({@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class,
        RowBounds.class, ResultHandler.class})})
public class PageLocalCacheDisableInterceptor implements Interceptor {

    private static final String DEFAULT_PAGE_SQLID = ".*Page$";

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        if (ms.getId().matches(DEFAULT_PAGE_SQLID)) {
            Class<?> clazz = ms.getClass();
            Field flushLocalCache = clazz.getDeclaredField("flushCacheRequired");
            flushLocalCache.setAccessible(true);
            flushLocalCache.set(ms, true);
        }
        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        //do nothing
    }

}

說完了解決方案,我們解決下之前留下的三個(gè)疑問:

  1. 為什么會(huì)用到CachingExecutor?
  2. CachingExecutor中的delegate是什么?
  3. CachingExecutor中的cache是什么?

MyBatis的二級(jí)緩存

實(shí)際上要弄明白這些問題,只需要搞明白MyBatis的二級(jí)緩存是什么就好了。我們繼續(xù)看源碼:

  public CachingExecutor(Executor delegate) {
    this.delegate = delegate;
    delegate.setExecutorWrapper(this);
  }

發(fā)現(xiàn)delegate就是個(gè)Executor,并且在CachingExecutor構(gòu)造函數(shù)中傳入,由于CachingExecutor本身也實(shí)現(xiàn)了Executor,那其實(shí)就是設(shè)計(jì)模式中的裝飾者模式了。這時(shí)候我們就可以順著這條線繼續(xù)調(diào)查為啥MyBatis要用CachingExecutor裝飾Executor,查看構(gòu)造函數(shù)的調(diào)用方,發(fā)現(xiàn)只有一處調(diào)用,即Configuration.newExecutor

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

這里首先會(huì)根據(jù)executorType為executor實(shí)例化一個(gè)對(duì)應(yīng)的實(shí)現(xiàn)類,同時(shí)根據(jù)cacheEnabled是否為true來決定是否要用CachingExecutor裝飾。而我們看到這個(gè)cacheEnabled是有初始值true的,并且在XMLConfigBuilder構(gòu)造配置相的時(shí)候會(huì)設(shè)置true為缺省值,所以默認(rèn)就會(huì)帶上CachingExecutor

  protected boolean cacheEnabled = true;
  configuration.setCacheEnabled(booleanValueOf(props.getProperty("cacheEnabled"), true));

那如果我們確定不用二級(jí)緩存,其實(shí)可以通過設(shè)置參數(shù)來關(guān)閉這個(gè)修飾器,這樣原本執(zhí)行的CachingExecutor.query就不會(huì)被執(zhí)行,取而代之的是本身BaseExecutor.query方法,這樣可以簡(jiǎn)化調(diào)用鏈路。設(shè)置如下:

<configuration>
    <settings>
        <!-- 打印查詢語(yǔ)句 -->
        <setting name="logImpl" value="STDOUT_LOGGING"/>
        <!-- 二級(jí)緩存關(guān)閉-->
        <setting name="cacheEnabled" value="false"/>
    </settings>

    <typeAliases>
    </typeAliases>
    <!--plugins插件之 分頁(yè)攔截器-->
    <plugins>
        <plugin interceptor="com.nfsq.member.coupon.common.PaginationInterceptor"></plugin>
        <plugin interceptor="com.nfsq.member.coupon.common.PageLocalCacheDisableInterceptor"></plugin>
    </plugins>
</configuration>

現(xiàn)在可以來解答一下之前留下的三個(gè)疑問了:

  1. 由于默認(rèn)二級(jí)緩存是開啟的,就算我們沒有使用二級(jí)緩存,MyBatis每次創(chuàng)建Executor的時(shí)候也都會(huì)用CachingExecutor裝飾實(shí)際的Executor對(duì)象。
  2. CachingExecutor中的delegate即被裝飾的Executor對(duì)象。
  3. CachingExecutor中的cache是二級(jí)緩存,MyBatis會(huì)優(yōu)先使用二級(jí)緩存,如果沒有二級(jí)緩存,再使用一級(jí)緩存,如果連一級(jí)緩存也沒有,那就連接數(shù)據(jù)庫(kù)查詢。雖然二級(jí)緩存默認(rèn)開啟,但是是需要人為配置才能使用的,我們沒有配置,所以每次都是null。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容