☰
Mybatis一级缓存源码解析:命中、失效与脏读实战
2026/10/14 17:50:45 网站建设 项目流程

Mybatis一级缓存是我在实际项目中吃过亏、也在源码里翻过车的地方。它是Mybatis默认开启的会话级缓存,底层本身就是一个简单的HashMap,但正因为隐蔽,很多人会在事务、分页、动态SQL的组合拳下被它坑到。这篇文章从源码入手拆解一级缓存的存储结构与命中规则,再配上几组能直接跑的效果演示,把缓存命中、失效、脏读的每一种现象都摆到台面上。适合正在排查“为什么同一句SQL查了两次数据库”的同学,也适合准备面试源码级缓存原理的开发者参考。

1. 先弄清楚:一级缓存到底长在哪里、管多大范围

1.1 缓存就挂在Executor身上,不是挂在Mapper上

很多同学以为Mybatis一级缓存是“Mapper级别的缓存”,这是第一个误区。从Mybatis的结构看,一次数据库操作会经过这样的链路:

Mapper接口代理 -> DefaultSqlSession -> Executor -> StatementHandler -> JDBC

真正干活的Executor有两种:SimpleExecutor、ReuseExecutor、BatchExecutor,它们都继承自BaseExecutor。一级缓存的“实体”——一个名为“LocalCache”的PerpetualCache对象,就定义在BaseExecutor里:

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是Mybatis缓存体系里最基础的实现,内部就是一个普通的HashMap:

public class PerpetualCache implements Cache { private final String id; private final Map<Object, Object> cache = new HashMap<>(); // putObject / getObject / removeObject / clear 都是直接操作HashMap }

也就是说,一级缓存是SqlSession会话期间,挂在Executor实例上的一个HashMap。每个SqlSession对应一个Executor实例,也就对应着一个独立的一级缓存。sqlSession.close()之后,Executor里的localCache被置空,缓存自然就没了。

1.2 一级缓存和二级缓存不是“升级关系”

Mybatis还有二级缓存,很多资料喜欢画一个“一级缓存先查,二级缓存再查”的阶梯图,这个容易给人造成误解。真相是:二级缓存通过CachingExecutor装饰器包在普通Executor外面,查询时先走二级缓存,如果二级缓存没有命中,再委托给BaseExecutor去查一级缓存和数据库。

两个缓存的关键区别见下表:

对比项一级缓存二级缓存
作用范围SqlSession内namespace级别(跨SqlSession)
默认状态开启关闭
存储位置Executor的HashMapMappedStatement持有的Cache对象
写入时机查询后立即写入事务提交时通过TransactionalCacheManager写入
失效时机update、commit、rollback、close、STATEMENT模式namespace内任意flushCache触发
缓存对象直接持有对象引用序列化后的对象拷贝

注意二级缓存这条:查询时二级缓存命中的是反序列化后的新对象,所以两次拿到的对象==一般返回false。而一级缓存命中的是同一个List、同一个对象引用,==返回true。后面演示里你会看到这个差异带来的坑。

2. 源码拆解:一条select是怎么命中或绕过缓存的

2.1 从Mapper接口到Executor的完整调用链

平时我们的业务代码是这样写的:

UserMapper mapper = session.getMapper(UserMapper.class); User user = mapper.selectById(1L);

session.getMapper()返回的是JDK动态代理,真正的实现在MapperProxy。每次调用方法,最终会通过MapperMethod转换成对SqlSession的selectOne调用。SqlSession默认实现是DefaultSqlSession,selectOne内部又等价于selectList取第一条,而selectList的委托对象就是Executor:

@Override public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) { MappedStatement ms = configuration.getMappedStatement(statement); return executor.query(ms, parameter, rowBounds, Executor.NO_RESULT_HANDLER); }

如果开了二级缓存,这里的executor实际是CachingExecutor包装过的对象。二级缓存没命中,才会继续调用delegate.query,也就是BaseExecutor的query方法。整个链路记住一句话:二级缓存在外层,一级缓存在内层,最底层永远是真数据库。

2.2 BaseExecutor.query:命中与未命中的分水岭

这是整个一级缓存源码里最核心的一段,我建议你直接在IDE里打开BaseExecutor对照看:

@Override public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException { BoundSql boundSql = ms.getBoundSql(parameter); CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql); return query(ms, parameter, rowBounds, resultHandler, key, boundSql); } @Override @SuppressWarnings("unchecked") 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(); } deferredLoads.clear(); if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) { clearLocalCache(); } } return list; }

第一次query(ms, parameter, rowBounds, resultHandler)负责两件事:通过createCacheKey生成本次查询的缓存key,然后带着key调用重载方法。第二次query方法里的逻辑才是关键:

  • queryStack == 0 && ms.isFlushCacheRequired():如果当前SQL配置了flushCache="true",每次查询前先把一级缓存清空。
  • list = resultHandler == null ? (List<E>) localCache.getObject(key) : null:只要没传自定义ResultHandler,就先尝试从一级缓存拿结果。如果传了自定义ResultHandler,直接跳过缓存走数据库,因为ResultHandler要求逐行处理原始结果,缓存里的现成集合没法安全复用。
  • 拿到了就附带恢复存储过程的OUT参数;拿不到就执行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) { deferredLoads.clear(); } return list; }

它在真正执行SQL之前,先往缓存里放了一个占位符EXECUTION_PLACEHOLDER,目的是防止嵌套查询时递归执行到相同语句导致死循环;SQL执行完毕后,再把占位符移除,写入真实结果。这个细节也能解释为什么在一级缓存命中时,日志里看不到任何Preparing和==> Parameters输出——因为根本没有走到doQuery。

2.3 CacheKey:凭什么判断两次查询“一模一样”

缓存要能用,必须回答一个问题:凭什么说两次查询是等价的?Mybatis的答案是CacheKey,它的生成逻辑在createCacheKey里:

@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(); 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) { cacheKey.update(configuration.getEnvironment().getId()); } return cacheKey; }

整理一下,CacheKey由以下内容拼接而成:

  • MappedStatement的id(也就是namespace + id)
  • RowBounds的offset和limit,分页参数不同缓存key就不同
  • BoundSql里最终生成的SQL文本
  • SQL参数的具体值,注意是“值”参与计算,不是参数类型
  • 环境标识environment id

CacheKey内部不是简单地把这些字符串拼起来,而是用了一个累加哈希算法:

public void update(Object object) { int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object); count++; checksum += baseHashCode; baseHashCode *= count; hashcode = multiplier * hashcode + baseHashCode; updateList.add(object); }

DEFAULT_HASHCODE初始为17,DEFAULT_MULTIPLYER为37。每次update都会让count自增、checksum累加、hashcode按乘数迭代,同时把原始对象放进updateList。光有hashcode不够,equals方法还会逐一比对updateList里的每个对象,只有所有参与元素都一致才算命中。这样即使hash碰撞,也不会误命中。

这里有个实战层面的推论:如果你传的参数是一个可变POJO,而POJO的hashCode基于字段计算,那这个对象的字段一改,CacheKey就变了,缓存自然就不命中。

3. 失效时机源码梳理:什么时候缓存会“一夜回到解放前”

3.1 update、commit、rollback、close都会动手清缓存

一级缓存的设计哲学很简单:**宁可多查一次数据库,也不能让旧数据活得比事务长。**所以Mybatis在写操作和事务节点上非常粗暴,一律清空整个localCache。

BaseExecutor.update的源码:

@Override public int update(MappedStatement ms, Object parameter) throws SQLException { ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId()); if (closed) { throw new ExecutorException("Executor was closed."); } clearLocalCache(); return doUpdate(ms, parameter); }

不管你是INSERT、UPDATE还是DELETE,也不管SQL影响了几行、影响的表是哪张,只要执行了写操作,当前Executor的一级缓存全部清掉。这个粒度非常粗,但确保了最基本的正确性。

commit和rollback同样会先清缓存:

@Override public void commit(boolean required) throws SQLException { if (closed) { throw new ExecutorException("Cannot commit, transaction is already closed"); } clearLocalCache(); flushStatements(); if (required) { transaction.commit(); } } @Override public void rollback(boolean required) throws SQLException { if (!closed) { try { clearLocalCache(); } finally { if (required) { transaction.rollback(); } } } }

注意一个容易忽略的细节:commit清缓存发生在transaction.commit()之前,所以即使数据库提交失败了,缓存也已经没了。rollback则把清缓存放在try里,保证无论回滚是否成功,缓存都先被清理。

再看看close,SqlSession关闭时不只是清缓存,而是直接让localCache变成null:

@Override public void close(boolean forceRollback) { try { try { rollback(forceRollback); } finally { if (transaction != null) { transaction.close(); } } } finally { executorStopped = true; if (deferredLoads != null) { deferredLoads.clear(); } localCache = null; localOutputParameterCache = null; } }

所以“关闭SqlSession后一级缓存还在”这种说法是完全错误的。close之后,Executor本身都不再允许执行查询。

3.2 属性flushCache=true:每次查询前主动点名清空

除了update和事务操作,还有一种手动清空方式。MappedStatement上有一个flushCacheRequired标记,select语句可以在XML里显式打开:

<select id="selectSomething" flushCache="true" resultType="..."> ... </select>

这条语句执行之前,BaseExecutor.query里会有这段判断:

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

如果这条select还处于二级缓存namespace内,CachingExecutor还会顺带把整个namespace的二级缓存也清掉。所以这个属性一般是给出实时性要求很高的查询用的,代价是牺牲同会话内的缓存命中率。

3.3 localCacheScope=STATEMENT后,一级缓存“还在,但只有一行有效”

一级缓存默认是SESSION级别,可以降级成STATEMENT级别。配置方式:

<settings> <setting name="localCacheScope" value="STATEMENT"/> </settings>

Spring Boot下对应:

mybatis: configuration: local-cache-scope: statement

BASEExecutor.query在每次查询收尾时,发现配置是STATEMENT就会立刻clearLocalCache:

if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) { clearLocalCache(); }

STATEMENT不是要把一级缓存彻底关闭,而是让缓存只存在于“正在执行这条SQL的过程中”。之所以不完全关闭,是因为一条SQL内部的嵌套子查询仍然会复用缓存,例如延迟加载场景下的N+1查询,statement粒度还能起到一定的防重复作用。但从业务方视角看,STATEMENT模式约等于“每次都查数据库”,代价是放弃会话级的查询复用。

4. 效果演示:五组日志直接看清缓存命中与失效

4.1 搭建最小的可复现环境

为了让你能够照着跑一遍,这里给出完整的最小工程。Maven依赖只需要mybatis、数据库驱动、slf4j-simple三件套:

<dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.16</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>1.7.36</version> </dependency>

mybatis-config.xml:

<configuration> <settings> <setting name="logImpl" value="SLF4J"/> </settings> <environments default="dev"> <environment id="dev"> <transactionManager type="JDBC"/> <dataSource type="POOLED"> <property name="driver" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/cache_demo?useSSL=false&amp;serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="123456"/> </dataSource> </environment> </environments> <mappers> <mapper resource="mapper/UserMapper.xml"/> </mappers> </configuration>

建表语句:

CREATE TABLE t_user ( id BIGINT PRIMARY KEY, name VARCHAR(64), age INT ); INSERT INTO t_user (id, name, age) VALUES (1, 'Alice', 20);

实体类和Mapper接口就按最普通的方式写,这里不占篇幅。记住一个前提:用openSession()拿到的原生SqlSession,默认不开启自动提交,所以同一会话内多个查询能看到完整的缓存行为。

4.2 场景一:同会话重复查询,第二次彻底不打SQL

try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User u1 = mapper.selectById(1L); User u2 = mapper.selectById(1L); System.out.println("u1 == u2 ? " + (u1 == u2)); }

日志现象:

==> Preparing: select id, name, age from t_user where id = ? ==> Parameters: 1(Long) <== Columns: ID, NAME, AGE <== Row: 1, Alice, 20 <== Total: 1 u1 == u2 ? true

第二条select完全没有SQL日志,而且u1 == u2打印true。因为第一次查询后结果被放进了localCache,第二次查询命中缓存,BaseExecutor直接返回了同一个List中的同一个对象引用,根本没有触发doQuery。这就是一级缓存最原始的能力:同一SqlSession内,相同语句相同参数只查一次库。

4.3 场景二:update + commit之后,下一次查询必然重新查库

try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User u1 = mapper.selectById(1L); mapper.updateName(1L, "Bob"); session.commit(); User u2 = mapper.selectById(1L); System.out.println("u1 == u2 ? " + (u1 == u2)); System.out.println("u2.name = " + u2.getName()); }

日志:

==> Preparing: select id, name, age from t_user where id = ? ==> Parameters: 1(Long) ... ==> Preparing: update t_user set name = ? where id = ? ==> Parameters: Bob(String), 1(Long) ==> Preparing: select id, name, age from t_user where id = ? ==> Parameters: 1(Long) <== Total: 1 u1 == u2 ? false u2.name = Bob

update执行时clearLocalCache()已经把缓存清了,commit又清了一遍,所以第三次select注定要回到数据库。这个场景很有价值,它说明了一个容易忽略的规律:**一级缓存的寿命并不是“整个SqlSession打开期间”,而是“从上次清缓存到这次写操作之间”。**如果你在一个长事务里先查询、再执行一个无关的update、再查询,第二次查询照样会查库。

4.4 场景三:会话A看不见会话B的提交,直接读到旧数据

SqlSession session1 = sqlSessionFactory.openSession(); try (SqlSession session2 = sqlSessionFactory.openSession()) { UserMapper mapper1 = session1.getMapper(UserMapper.class); UserMapper mapper2 = session2.getMapper(UserMapper.class); User u1 = mapper1.selectById(1L); mapper2.updateName(1L, "Carol"); session2.commit(); User u2 = mapper1.selectById(1L); System.out.println("u1 == u2 ? " + (u1 == u2)); System.out.println("u2.name = " + u2.getName()); }

日志里session1的第二次select没有打SQL,直接返回,输出:

u1 == u2 ? true u2.name = Alice

数据库里的name已经变成Carol,但session1的一级缓存完全感知不到其他会话的提交,返回的还是Alice。这正是一级缓存脏读的标准复现场景。Mybatis把这部分的“会话隔离”责任扔给了业务方:如果你的应用是单会话操作数据库,问题不大;如果是多会话、多服务并发写同一张表,一级缓存可能让你读到陈旧数据。

4.5 场景四:Spring @Transactional内外的巨大差异

在Spring Boot + Mybatis工程里,无事务方法:

public User noTxQuery() { User u1 = userMapper.selectById(1L); User u2 = userMapper.selectById(1L); return u2; }

日志会打两次select。原因不在缓存没开,而是无事务时,每次Mapper调用都会从SqlSessionTemplate里临时打开一个SqlSession,用完立即关闭。每个SqlSession都是一次全新的一级缓存,自然无法命中。

加了@Transactional之后:

@Transactional public User txQuery() { User u1 = userMapper.selectById(1L); User u2 = userMapper.selectById(1L); return u2; }

日志只打一次select,第二次直接命中。原因是Mybatis-Spring在事务存在时,会通过SqlSessionUtils从Spring事务同步管理器里取出同一个SqlSession跑完整个事务。这个现象值得每个Spring项目开发者记住:Spring事务本质上是SqlSession的生命周期边界。

4.6 场景五:缓存对象被业务代码直接改脏

一级缓存返回的是同一个对象引用,这带来一个隐藏风险:

try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User u1 = mapper.selectById(1L); u1.setName("Hacker"); User u2 = mapper.selectById(1L); System.out.println("u1 == u2 ? " + (u1 == u2)); System.out.println("u2.name = " + u2.getName()); }

输出:

u1 == u2 ? true u2.name = Hacker

业务代码修改了u1,实际上就修改了缓存里的对象。下一次同会话查询同样的SQL,拿到的是已被改过的脏对象;如果此时事务回滚,数据库没变,但cache里的对象已经被污染到会话结束。这个问题在纯JDBC时代不会出现,是缓存直接暴露引用造成的副作用。对缓存返回的对象做改动前,要想清楚自己到底在改什么。

5. 实战避坑:缓存命中与脏读的边界在哪里

5.1 为什么在Spring Boot里一级缓存经常“时灵时不灵”

我接触过的团队里,很多人对一级缓存的结论是“没用”“开了和没开一样”,根源都在于把Spring管理下的行为当成了Mybatis本身的缺陷。真相只有一个:一级缓存的作用域与SqlSession绑定,SqlSession的作用域与事务绑定,事务不存在时一级缓存就是一次性的。

Spring Boot无事务场景下,Mapper方法每次都会走SqlSessionTemplate的openSession,把查询执行完就关闭。这里的“关闭”不只是关闭数据库连接,还会把SqlSession对应的Executor也关掉,localCache直接变成null。同一个实体查询一百次,就是一百次SQL。

所以一旦发现Spring项目里“缓存不生效”,先问三个问题:

  1. 这个方法有没有事务注解?
  2. 事务边界是不是覆盖到了两次查询?
  3. 缓存是否被update/commit提前清掉了?

把这三个问题排查完,90%的“缓存失效”都能定位。

5.2 分页、动态SQL、多参数对CacheKey的影响

分页插件(例如常见的PageHelper)会让一级缓存命中率很低,这是正常的。CacheKey里有RowBounds的offset和limit,再加上分页插件会改写SQL文本,任何一页的分页SQL和另一页的分页SQL都不相等,自然不会命中。

动态SQL是另一个隐蔽变量。即使逻辑上“同一条查询”,如果where条件的拼接顺序或空格不同,生成的BoundSql文本就不同,CacheKey自然不同。所以以下写法命中率极不可靠:

  • 动态拼接order by,且order by字段每次不同
  • 根据条件动态拼接if,导致SQL文本时多时少
  • 每次查询前给参数对象塞入无关字段,改变hashCode

多参数封装成POJO时尤其小心,POJO的hashCode如果用到了可变字段,缓存key就会被“参数对象的内容变化”绑架。第一次查询传user对象,第二次把user.name改了再查,哪怕SQL一模一样,缓存也不会命中。

5.3 真要禁用一级缓存,怎么配置最稳妥

如果你的业务对一致性要求很高,或者服务是多实例并发写数据库,逻辑上最稳妥的选择是让一级缓存退化为STATEMENT:

<settings> <setting name="localCacheScope" value="STATEMENT"/> </settings>

或者对有实时性要求的SQL单独加flushCache="true",让这条select每次执行前清空缓存。需要注意的是,flushCache="true"在select上使用,会清空整个会话的缓存,不只是当前语句,这会让同会话内其他查询的缓存收益也一起失效,用它之前要有心理准备。

这里给出一个实用排查速查表:

现象可能原因排查方向
同会话两次查询都打SQL中间执行过update/commit,或localCacheScope=STATEMENT看是否有写操作和事务提交
Spring无事务方法重复打SQL每次Mapper调用都是新SqlSession加@Transactional再看
分页查询从不命中RowBounds和SQL文本不同属正常现象,不要怀疑缓存配置
参数对象没变但缓存不命中动态SQL文本每次生成略有差异打印BoundSql.sql对比
命中了缓存但数据是旧的其他会话已提交更新合理设置事务边界或降级STATEMENT
对象被改导致后续查询异常一级缓存直接暴露对象引用不要直接修改查询返回的实体,必要时深拷贝

5.4 不要把一级缓存当业务保底

经过源码和演示的来回折腾,我的结论很明确:**一级缓存是一个放在底层、免费、但不可依赖的优化,它不是业务一致性方案。**它的设计目标就是减少同一会话内的重复数据库开销,代价是粗粒度的失效策略和会话隔离下的陈旧数据风险。

使用建议如下:

  • 同一事务内的重复相同查询,放心交给一级缓存,收益白拿。
  • 跨SqlSession或跨服务的数据一致性,不要指望Mybatis任何一级缓存机制解决。
  • 对实时性敏感的查询,显式配置flushCache或直接走STATEMENT模式。
  • 并发更新频繁的核心表,建议把Mybatis一级缓存降级,再在应用层用Redis做合理的、可管理的缓存层。

我在实际排查中看到过不少“查询结果不对”的case,最后定位到一级缓存身上。如果能早一点理解它挂在Executor上、死于写操作和事务边界这两个特性,很多绕弯路的排查都可以省掉。

最后分享一个调试技巧:遇到可疑的缓存问题时,先把日志级别调到DEBUG,观察是否出现Preparing。SQL日志没出现,说明结果来自缓存;SQL日志连续出现两次,说明缓存没有命中,然后对照本文的CacheKey构成逐项检查。日志不会撒谎,它已经把答案写在了每一条“第二次查询”的记录里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询