1. 现实问题:查到的数据为什么重复或过时
一篇文章关联多个标签时,SQL Join 会返回多行相同文章;如果直接映射成 List<Article>,页面可能出现重复文章。如果开启缓存,刚发布的标题又可能继续显示旧值。MyBatis 的 resultMap 和缓存都在帮你处理对象关系,但它们不会自动理解你的业务一致性。
MyBatis-Plus 进一步提供 BaseMapper、Wrapper 和分页插件,能减少简单 CRUD 代码。便利越多,越要看生成的 SQL、更新条件和缓存边界,不能把方法名当作安全和正确性的证明。
2. 最小可运行示例:嵌套映射与条件更新
<resultMap id="articleWithTags" type="com.example.ArticleView">
<id property="id" column="article_id"/>
<result property="title" column="article_title"/>
<collection property="tags" ofType="com.example.TagView">
<id property="id" column="tag_id"/>
<result property="name" column="tag_name"/>
</collection>
</resultMap>
<select id="findWithTags" resultMap="articleWithTags">
SELECT a.id article_id, a.title article_title,
t.id tag_id, t.name tag_name
FROM article a
LEFT JOIN article_tag at ON at.article_id = a.id
LEFT JOIN tag t ON t.id = at.tag_id
WHERE a.id = #{id}
</select>
使用 MyBatis-Plus 时,条件也要显式:
LambdaQueryWrapper<Article> query = Wrappers.<Article>lambdaQuery()
.eq(Article::getStatus, "PUBLISHED")
.orderByDesc(Article::getPublishedAt);
List<Article> rows = articleMapper.selectList(query);
resultMap 用 id 标识父子对象,框架才能合并重复父行;LEFT JOIN 下没有标签时,子对象应保持空集合而不是凭空创建一个 null 标签。
3. 调用链与对象变化
数据库先返回 Join 后的扁平行集合,MyBatis 按 resultMap 读取 article_id,遇到同一个父 id 就复用 ArticleView,再把不同 tag_id 加入 tags。结果从“多行”折叠成“一个父对象和多个子对象”,这是映射器在内存中完成的对象变化,不是数据库返回的 JSON。
一级缓存通常位于 SqlSession,同一会话相同查询可能复用结果;更新、提交或会话结束会影响它。二级缓存跨会话共享,缓存 key 包含 statement、SQL、参数和分页信息,更新操作需要清空相关缓存,否则读到旧对象。MyBatis-Plus 生成 SQL 后仍走同样的连接、事务和映射链。
4. 为什么这样设计
resultMap 让数据库列名和对象属性显式映射,适合别名、嵌套、一对一和一对多;自动映射适合简单同名字段,但项目要统一开启策略,避免新增列后悄悄映射错误。集合映射必须考虑重复、排序和空关联。
缓存解决重复读的成本,不解决数据正确性。写入和缓存失效要在同一一致性策略中:事务提交前不要把新值当作成功发布,缓存更新失败要有补偿或短 TTL。对文章这种高读低写数据,可以缓存查询 DTO;对强一致余额,不要照搬相同策略。
5. 项目落点:把缓存和 CRUD 便利放在 Service 边界
Repository 返回领域需要的 View 或 Entity,Service 决定是否缓存、何时失效和如何分页。MyBatis-Plus 的 update(null, wrapper)、空 wrapper 和批量删除都要在调用前做条件保护,禁止“无条件更新全表”进入生产。生成 SQL 通过日志或测试检查,不能只看 Java 代码。
练习:为文章详情增加标签映射和缓存,写测试验证发布、改标题、删除标签后下一次读取不会返回旧值;再模拟缓存读取失败,确认业务仍能回源数据库。用多标签、多空标签和重复关联数据做夹具。
6. 易错排查
- 一篇文章重复多次:父 resultMap 缺少
<id>或关联表存在重复关系。 - 二级缓存读到旧数据:更新没有清空、事务未提交或缓存范围不适合该用例。
- Wrapper 条件为空:确认是否会生成无 WHERE SQL,关键更新先做条件数量和业务权限校验。
- Entity 直接作为 API 返回:内部字段、懒加载和可变状态泄漏,使用专门 DTO。
7. 一页复习
数据库 Join 返回扁平行,resultMap 负责折叠成对象关系;一级缓存偏会话,二级缓存跨会话但要求更新失效;MyBatis-Plus 减少 CRUD 样板,却不替代条件、权限、事务和 SQL 审计。缓存正确性来自数据生命周期,而不是一个 @Cacheable 注解。
结果映射可以用三行说明对象折叠:第一行 article_id=7、tag_id=1 创建 ArticleView 和 TagView;第二行 article_id=7、tag_id=2 复用同一个 ArticleView,只新增第二个 TagView;没有标签的 LEFT JOIN 行让 tag_id 为 null,tags 应为空。若父 <id> 漏写,框架会把每行都当新父对象,页面就出现重复文章。
缓存调用链也要写清 key 和时间:第一次查询 miss,数据库返回 DTO,序列化为 version=1 的值并设置 TTL;第二次 hit 直接反序列化;更新事务提交后清 key,下一次重新回源。一级缓存随 SqlSession,二级缓存跨会话,Redis 又跨进程,范围越大,失效和序列化风险越大。更新 SQL 成功但 cache clear 失败时要有监控和补偿。
MyBatis-Plus 的 LambdaQueryWrapper 只是生成条件对象,最终仍会经过 Mapper、Connection、SQL 和事务;空 wrapper 更新、逻辑删除字段、租户条件和分页插件都要在项目配置和测试中证明。文件落点可以是 persistence/article,缓存策略放 application/article,API 不直接返回 Entity,避免缓存内部字段和权限字段泄漏。
排错时重复对象查 resultMap id 与关联表重复,旧数据查 cache key/TTL/提交时机,MP 更新全表查 wrapper 是否为空,序列化失败查类版本和字段兼容;命中率下降还要看 key 拼写、版本和热点淘汰。练习是为发布、改标题、删标签写缓存时序测试,并模拟 Redis 失败确认是否回源和限流。
结果映射的核心状态是“父对象是否已见过”和“子对象是否有有效 id”。第一行创建 ArticleView,第二行复用同一引用并追加 TagView,第三行 tag_id 为 null 时不应创建空 Tag。若 article_id 不标成 id,父集合会膨胀;若 tag_id 不标成 id,重复关系会被错误累加。映射结果是内存对象,不能当作数据库事务已经成功。
缓存要把键、值、版本和失效动作写成契约:详情 key 包含租户与文章 id,值包含可兼容的 DTO 和 schema version,TTL 防止永久旧值,数据库提交后触发删除或版本推进。一级缓存局限在 SqlSession,二级缓存可能跨请求,Redis 跨进程,任何一层都要确认是否存在本地缓存或 CDN 的额外副本。
MyBatis-Plus 的 Wrapper 输入是条件对象,输出是生成 SQL;空 wrapper、租户过滤、逻辑删除和批量更新不能只看 Java 方法名。项目文件把 Entity/Mapper 放 persistence,组装 DTO 和缓存策略放 application,Controller 不返回 Entity。缓存命中返回的是反序列化副本,修改副本不会更新数据库,也不应回写内部缓存引用。
排错时重复行查父子 id、关联表重复和 Join,旧值查 key/version/TTL/提交顺序,更新全表查 wrapper 是否为空和权限条件,反序列化失败查 schema 版本和字段类型;命中率低可能是 key 拼接不一致、热点淘汰或请求参数没有规范化。练习是为发布、改名、删标签和 Redis 不可用写时序测试,证明读到的最终值和数据库一致。
验证清单:用一篇文章挂两个标签和一篇没有标签的文章跑 Join,断言父对象数量、标签数量和空标签分支;故意移除父/子 <id>,观察重复对象如何出现,再恢复映射。缓存测试按 miss、hit、更新提交、删除标签、Redis 不可用五步记录 key、version、TTL、数据库值和最终返回值。对空 LambdaQueryWrapper 和缺少租户条件的更新,必须在 Service 或拦截边界拒绝并留下审计日志,不能用“影响行数为零”代替保护。
练习复盘:为文章详情定义包含租户、id 和 schema version 的 key,更新事务提交后清理旧值,再并发读写一次。若读线程拿到旧 DTO,说明缓存失效时序仍有窗口;可以选择提交后删除、版本号读取或短暂旁路回源,并把取舍写入测试。最终 API 只返回 View,缓存只保存可兼容 DTO,Entity 的权限字段和 ORM 状态不得泄漏到页面。
进阶附录:缓存键与序列化
跨实例缓存通常需要 Redis,缓存键要包含租户、版本和查询维度,序列化格式要考虑类变更和兼容。缓存值过大、热点 key、雪崩和击穿都需要单独策略。先画“写数据库—提交—失效/更新缓存—读回源”时序,再决定实现。
本课按「MyBatis 结果映射、缓存机制与 MyBatis-Plus 约束」的学习范围组织,正文与示例均为本站原创整理。