一、问题出现场景
问题:
我使用的 Mybatis-plus 的 selectByIds 查询,发现明明我传的参数是 [1,2,3],但是结果却是 [1,2,3,4]???
直接在 selectByIds 那一行打断点,发现他是真的返回的 [1,2,3,4]。
自己把 SQL 拿出来填上参数然后去数据库执行,结果是正确的为:[1,2,3]。
使用 @SpringBootTest 进行集成测试 Mybatis-plus 的 selectByIds,发现结果变正常了是 [1,2,3]…
业务场景:
可以理解成:一个 API 有一个响应结果实体类,一个实体类包含了多个字段,这些字段又可能是另一个实体类,并且还可能存在循环依赖情况。
为了简化代码的理解难度,没有选择把一批次的 API 对应的信息一次性查出来,然后通过 Map 来进行处理。而是每个 API 都单独处理。
简化示例代码如下:
可以先看看,能不能看出存在的问题。NPE 请忽略。
参数是:[[1,2,3], [1,2,3]]
@Transactional
public void queryAndUpdateCategory(List<List<Long>> categoryIdsList) {
List<List<Category>> results = new ArrayList<>();
// query
for (List<Long> categoryIds : categoryIdsList) {
// get current list
List<Category> result = mapper.selectByIds(categoryIds);
// get parent list
List<Long> parentIds = result.stream().map(Category::getParentId).toList();
List<Category> parents = mapper.selectByIds(parentIds);
// merge
result.addAll(parents);
// results add
results.add(result);
}
// update:这里忽略不执行
// this.update(results);
}
二、问题出现的原因
这是因为 MyBatis 存在一种减少数据库查询次数、提升应用性能的机制,就是 MyBatis 缓存。
缓存分为一级缓存和二级缓存,一级缓存默认是开启的。
一级缓存的简单模型是 Map<CacheKey, Result>
,问题就在这里:缓存中存储的是查询结果对象的引用(Reference),而不是深拷贝(Deep Copy)
。
一级缓存的触发条件:
- 在同一个 SqlSession 中,执行了完全相同的 SQL 语句(这里 @Transactional 导致查询都在同一个 SqlSession 中)。
- 中间没有发生过 INSERT、UPDATE、DELETE 操作(这些操作会清空一级缓存)。
第一次查询 [1,2,3]:
// categoryIds: [1,2,3]
// 会去数据库里面执行SQL:SELECT WHERE id IN (1,2,3);
List<Category> result = mapper.selectByIds(categoryIds); // result:[1,2,3]
// parents: [4]
// 这里修改了 result
result.addAll(parents); // result:[1,2,3,4]
第二次查询 [1,2,3]:
// categoryIds: [1,2,3]
// 这个时候因为是同一个SQL,直接就去缓存里面拿之前那个修改后的 result,并不会再次执行 SQL
List<Category> result = mapper.selectByIds(categoryIds); // result:[1,2,3,4]
三、解决方案
知道原因后解决办法就很多:
-
不修改 MyBatis 查询返回的结果,把结果拷贝一份
List<SwaggerApi> result = mapper.selectByIds(categoryIds); ===> List<Category> result = new ArrayList<>(mapper.selectByIds(categoryIds));
-
缩短 SqlSession 生命周期
在 for 循环中,每次操作使用独立的 SqlSession(或独立的 @Transactional 方法)。
这样缓存不会跨次共享,但会牺牲性能。 -
重构代码
重构代码,一般情况下也不会出现重复查询的情况,可以考虑重构代码。
-
关闭一级缓存关不了
四、相关
1、一级缓存(Local Cache)
-
优点:
- 性能高:缓存位于 JVM 内存中,访问速度快,无网络开销。
- 默认开启:无需配置,MyBatis 自动启用,开箱即用。
- 自动管理:执行更新操作(INSERT、UPDATE、DELETE)后,缓存会自动清空,保证单次会话内的数据一致性。
- 减少数据库访问:在同一个 SqlSession 中重复查询相同数据时,直接返回缓存结果,降低数据库压力。
-
局限性:
- 作用域有限:仅在当前 SqlSession 内有效,不同请求之间无法共享。
- 生命周期短:随 SqlSession 的关闭而销毁,无法跨请求缓存数据。
- 存在缓存污染风险:缓存存储的是对象引用。如果业务代码修改了查询返回的 List 或实体对象,会导致缓存数据被意外修改,后续查询返回错误结果。
- 无过期机制:缓存不会自动过期,只能通过更新操作或 SqlSession 关闭来失效。
- 在复杂逻辑中易失效:如在循环中执行更新操作,会清空缓存,影响后续查询性能。
-
适用场景:
- 适用于单次请求内多次查询相同数据的场景,例如在一个 Service 方法中多次调用 Mapper 查询同一记录。
2、二级缓存(Second Level Cache)
-
优点:
- 跨 SqlSession 共享:缓存作用于 Mapper 的 namespace 级别,同一个命名空间下的所有 SqlSession 可以共享缓存。
- 提升整体查询效率:适合读多写少的场景,能有效减少对数据库的重复查询。
- 可配置性强:支持设置缓存策略(如 LRU)、缓存大小、刷新间隔等参数。
- 与一级缓存协同工作:查询时优先检查二级缓存,再查一级缓存,最后访问数据库,形成缓存层级。
-
局限性:
- 需手动开启:必须在 Mapper XML 中配置 或使用 @CacheNamespace 注解,且实体类需实现 Serializable 接口。
- 不支持分布式部署:缓存仍存储在本地 JVM 内存中。在多实例部署环境下,各节点缓存无法同步,会导致数据不一致。
- 数据一致性差:如果数据库被外部系统修改,MyBatis 无法感知,缓存数据会长期不更新。
- 缓存粒度粗:执行任意更新操作都会清空整个 namespace 的缓存,影响范围大。
- 功能有限:不支持过期时间(TTL)、持久化、高可用等高级特性。
- 可能引发内存问题:若缓存数据量过大且未限制大小,可能导致 JVM 内存溢出。
-
适用场景:
- 适用于单机部署、数据变更频率低的场景,如字典表、配置信息等静态数据的缓存。
总结与建议
一级缓存是 MyBatis 内置的轻量级缓存,适合优化单次请求内的重复查询,开发者应避免修改其返回结果以防缓存污染。
二级缓存虽能跨会话共享,但受限于本地存储,在分布式系统中存在严重一致性问题,不推荐在集群环境下使用
。
现代应用推荐方案:关闭 MyBatis 二级缓存,使用 Redis 等分布式缓存系统来实现跨节点的数据共享与高可用,一级缓存可作为本地加速的补充。
特性 | 一级缓存 | 二级缓存 |
---|---|---|
默认开启 | ✅ 是 | ❌ 否(需配置) |
作用域 | SqlSession | Mapper (namespace ) |
共享性 | 同一 SqlSession 内 | 同一 namespace 下所有 SqlSession |
存储位置 | JVM 内存(本地) | JVM 内存(本地) |
分布式支持 | ❌ 无 | ❌ 无 |
数据一致性 | ✅ 较好(UPDATE 自动清空) | ⚠️ 差(外部变更不可知) |
性能 | ✅ 极高(无网络) | ✅ 高(本地内存) |
配置复杂度 | ✅ 无 | ⚠️ 需配置 <cache/> |
适用场景 | 单次请求内重复查询 | 单机应用,读多写少的静态数据 |
主要风险 | 缓存污染(修改返回值) | 数据不一致、内存溢出 |