1. 先搞清楚 MyBatis 是干什么的
关于 MyBatis 的优点和缺点,每年都会有朋友重新问我一遍。我的回答这次放在最前面:MyBatis 是一个半自动的持久层框架,它负责把 JDBC 那套连接管理、参数绑定、结果集装配的脏活包办掉,把最核心的 SQL 编写权留在开发者手里。它能解决什么问题?简单说就是“既要写 SQL,又不想被 JDBC 折磨”的那类问题。适合谁来参考?所有使用 Java 做服务端开发、日常需要和各种数据库打交道的同学,尤其是项目中涉及复杂查询、报表统计、多表关联的团队。
很多人在初学阶段纠结过:MyBatis 和 Hibernate 到底选谁。我的观点一直没变——如果你的团队每个人都看得懂 SQL,业务里又经常出现那种需要手动调优的查询,MyBatis 会让你很省心;如果你希望尽量少碰 SQL,连建表建映射都想交给框架自动完成,那它确实不合适。框架没有绝对的好坏,只有和业务场景匹配与否。
2. 优点:它凭什么还这么能打
2.1 半自动 SQL 映射:灵活和可控兼顾
“半自动”这三个字,理解到位了就能理解 MyBatis 的立身之本。它自动处理的东西包括:数据库连接的获取与关闭、PreparedStatement 的创建与参数填充、ResultSet 到 Java 对象的映射。它不帮你做的事就是生成 SQL,SQL 长什么样完全由你自己决定。
这个设计最大的好处是可控。当你遇到一条慢查询,可以把 mapper XML 里的 SQL 原封不动复制到数据库客户端里执行,看执行计划、建索引、调整 join 顺序,每一步都能精确命中。全自动 ORM 的问题在于,你交给框架的是对象关系,框架返回的 SQL 未必是你心里想的那条,一旦需要优化,绕来绕去反而浪费时间。MyBatis 把所有复杂度暴露在明面上,性能问题很容易定位。
2.2 resultMap 能力:结果映射比想象中强
很多人以为 MapUnderscoreToCamelCase 就是 MyBatis 映射的全部,这是低估它了。resultMap 才是真正核心的那部分。利用 resultMap,你可以把一条多表 join 的查询结果,映射成嵌套的用户对象、订单列表、甚至多级关联结构。
举个例子,查询用户列表并带上每个用户的订单列表。用全自动 ORM 做这种一对多查询,容易碰到 N+1 或者产生一堆冗余连接数据。MyBatis 的做法是先写好一条带 join 的 SQL,再用<collection>把订单列集合进用户对象的 List 字段里。SQL 效率可控,对象结构也能贴合业务需要。这种能力在后台管理系统、报表服务里几乎天天用,属于必备技能。
2.3 轻量集成和学习成本低
和 JPA/Hibernate 那套完整的生命周期管理相比,MyBatis 的概念少很多。一个项目从零开始接 MyBatis,无非是引入依赖、配置数据源、写 Mapper 接口和 XML。不需要理解一级缓存和持久化上下文的微妙关系,也不需要为每个实体设计复杂的继承映射。
对于团队成员流动比较大的项目,这个特点尤其有吸引力。新同学只要 SQL 基础扎实,一个星期左右就能上手写业务代码。如果没写过 SQL,那不管用什么框架都是白搭,MyBatis 反而能逼着你把基本功补起来。
2.4 生态成熟,周边工具多
MyBatis 出来这么多年,周边生态已经非常完整。代码生成器可以从数据库表直接生成实体、Mapper 接口和 XML;MyBatis Plus 在通用 CRUD、条件构造器上做了大量增强;分页插件更是被广泛使用。想完全不用手写 SQL,靠 MyBatis Plus 的 Wrapper 也能实现日常增删改查,复杂查询再退回 XML 手写。
所以选型时不必把 MyBatis 想成“上古项目专用技术”。它更像一个底座,你可以根据项目需要往上加工具。团队缺什么能力,就补什么组件,灵活性很高。
3. 缺点:真正难啃的骨头
3.1 手写 SQL 的量会随业务膨胀
开始写 MyBatis 时,你会觉得很自由;但业务系统做到几百张表的时候,自由就会变成负担。每个新查询都要新增一条 SQL,每张表的字段变化都可能牵扯多个 mapper 文件。mapper 目录从一层变成按模块划分的多层目录,仍然挡不住文件数量的增长。
这种维护压力很容易被低估。尤其是数据库表结构调整时,如果涉及几十条 SQL,漏改任何一条,平时可能不报错,等到用户用到那个查询才发现字段对不上。为了减少这种问题,项目里必须建立对应规范,比如统一字段命名、统一别名、统一 SQL 风格,否则后期就是在给自己挖坑。
3.2 动态 SQL 写多了,XML 读起来像天书
动态 SQL 是 MyBatis 的优势之一,但优势用过头就是灾难。if、choose、when、foreach层层嵌套之后,XML 的阅读体验会急剧下降。尤其是一个查询条件非常多、且每个条件都可选的列表查询,几十行 XML 全是<if test="...">,逻辑上绕来绕去,代码审查时经常要看半天。
还有一个现实问题是多人并发修改同一个 XML 时冲突率很高。很多团队最终会约定:过长的动态 SQL 拆成多个独立查询方法,或者把复杂的筛选条件挪到 MyBatis Plus 的 Wrapper 里组装。说到底,动态 SQL 要用,但要有节制。
3.3 缓存设计容易踩坑,理解不透就是事故
MyBatis 的缓存是绕不开的痛点。一级缓存默认是 SqlSession 级别,同一个 SqlSession 内执行同样的查询会命中缓存。听起来合理,但放到 Spring 管理的事务环境里就有了诡异表现:事务内先查了一条数据,中间改了它,再查一次时拿到的可能还是旧数据,因为查的是缓存不是数据库。
二级缓存的问题更明显。它按 namespace 隔离,也就是说一个 mapper XML 一个缓存区域。可实际业务查询经常跨多张表,join 出来的结果缓存在了某个 mapper 区域里,但只要另一张相关表的数据更新了,这个缓存并不会失效,脏数据就这么出来了。再加上生产环境通常多实例部署,本地二级缓存根本没有办法做分布式一致性。所以我见过不少项目,一开始开了二级缓存,后来出了问题又默默关掉。
3.4 数据库方言差异,迁移换库要重写
MyBatis 本身不绑定数据库,但你自己写的 SQL 是绑定数据库的。MySQL 的limit,Oracle 的rownum和fetch first,PostgreSQL 的limit/offset,写法都不完全一样。分页插件可以帮你解决一部分物理分页问题,但函数、日期处理、序列、锁语法这些差异,框架并不能帮你屏蔽。
这几年经常听到做国产化数据库替换的场景,原先跑在 MySQL 上的项目要迁到别的数据库,最头疼的不是 Java 代码,而是那一堆 SQL 和 XML。如果前期没有做 SQL 兼容性约束,迁移周期会非常长。这一点在选型时就要有心理准备。
4. 源码复盘:搞懂缓存和拦截器,比死记 API 有用
4.1 核心链路:MapperProxy、SqlSession、Executor
很多同学遇到 MyBatis 问题习惯上网搜答案,但我建议至少把源码链路过一次,性价比非常高。当你调用userMapper.selectById(1)时,Mapper 接口没有实现类,调用会被MapperProxy动态代理拦截。整体流程是:MapperProxy.invoke定位对应的MappedStatement,然后交给SqlSession,再进入Executor执行查询。
MappedStatement就是 XML 或者注解解析后的产物,里面包含了 SQL、参数映射、结果映射、缓存配置等。调试时最喜欢把断点打在MapperProxy.invoke入口,一眼就能看到当前调用的是哪个 SQL、参数是什么。理解了这条链路,很多所谓“诡异问题”其实都变得非常直观。
4.2 一级缓存:默认开启但小心“幽灵数据”
一级缓存存储在BaseExecutor的localCache里,缓存 key 由 StatementId、参数、RowBounds、SQL 等组成。同一个 SqlSession 里执行两次同样的查询,第二次不会真正走数据库。
实际项目里,问题场景往往出现在事务方法里。方法执行过程中更新了某条记录,之后又查询同一条记录,由于一级缓存命中的是旧数据,你会“看到”没有更新成功。要验证是不是缓存导致,可以把全局配置改一下:
<settings> <setting name="localCacheScope" value="STATEMENT"/> </settings>STATEMENT表示每次查询结束后就清空一级缓存,让每一次查询都直接访问数据库。这个配置对性能有一点影响,但在一致性要求高的场景里,宁可用它换正确性。真正理解了底层实现,就不会被表象迷惑。
4.3 二级缓存:为什么说生产环境慎开
二级缓存需要手动在 mapper XML 里加<cache/>才会启用,缓存范围是 namespace。开启后,该 mapper 的查询结果会被缓存起来,避免重复查数据库。
问题回到刚才提到的脏读。只要查询涉及多张表,缓存失效就没法保证。比如订单 mapper 缓存了一条 join 用户表的查询结果,用户表数据更新后,订单 namespace 里的缓存并不知道要失效。多个节点部署时更是如此,一个节点的缓存更新了,其他节点还是旧数据。
所以我的态度是:不用把它当成默认配置。除非你非常清楚某个查询是单表、数据基本不变、而且业务可以接受短暂不一致,否则宁可不赚这份缓存收益。网上很多“面试八股”会吹二级缓存,但真实生产环境里,它并不是一个受欢迎的功能。
5. Spring Boot 集成和分页插件用法
5.1 分页插件 PageHelper 的正确用法
PageHelper 可以说是国内用得最多的分页插件。Spring Boot 项目集成很简单,先引入依赖:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>然后在配置文件里指定方言和分页参数:
pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: count=countSql实际使用代码:
PageHelper.startPage(pageNum, pageSize); List<UserDTO> users = userMapper.selectByCondition(condition); PageInfo<UserDTO> pageInfo = new PageInfo<>(users);PageInfo里封装了总记录数、总页数、当前页、每页大小等常用字段。插件底层是通过拦截器改写 SQL 来做的,先执行一个 count 查询拿到总数,再执行带分页的查询。
用 PageHelper 有几个必须牢记的规矩。第一,startPage后面必须紧跟要分页的那个 mapper 方法调用,中间别穿插任何其他数据库操作。因为插件用 ThreadLocal 保存分页参数,下一个被拦截的查询命令会被自动套上分页。如果你中间穿插了别的查询,分页可能作用在错误的 SQL 上。
第二,不要在 mapper 方法内部再去调用其他 mapper 查询。比如 selectByCondition 方法里如果你又调用了某个字典查询,那个查询会被当成分页对象处理,轻则结果不对,重则抛异常。
第三,超大数据量分页不要依赖 PageHelper 的深翻页。offset 特别大的时候,数据库扫描成本很高,更适合用基于 ID 或时间的游标分页。这些经验都是实际项目中踩过坑换来的,有时候网上教程不会告诉你这些边界情况。
5.2 Spring Boot 下把 SQL 日志打出来
排查 MyBatis 问题时,第一步永远是看 SQL 到底执行了什么。Spring Boot 项目里可以这样配置:
mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpllog-impl配置成StdOutImpl后,SQL 和参数会直接打到控制台,开发阶段非常直观。也可以按包名设置日志级别:
logging: level: com.example.demo.mapper: debug这样只打印 mapper 包下的 SQL,生产环境想要输出日志时更干净。很多慢 SQL 排查,其实就是从这条日志开始的。
5.3 参数绑定:@Param 与 jdbcType 的细节
使用 Mapper 接口时,多个参数的绑定需要特别注意。如果你不写@Param:
List<User> selectByNameAndStatus(String name, Integer status);XML 里虽然可以写#{param1}、#{param2},但这种代码可读性太差。务必显式指定参数名:
List<User> selectByNameAndStatus(@Param("name") String name, @Param("status") Integer status);XML 里就能直接写#{name}和#{status},一眼看懂。单参数 List 场景下也需要@Param,否则foreach遍历时可能拿不到集合。
jdbcType是用来处理数据库类型与 Java 类型映射细节的。典型场景是 Oracle 的日期类型,如果查询结果出现“无效的列类型”或者时间字段变成java.sql.Date,可以在 resultMap 里显式指定:
<result column="CREATE_TIME" property="createTime" jdbcType="TIMESTAMP"/>这类细节平时容易忽略,一旦遇到跨数据库、特殊类型字段的问题,就知道它的价值了。
6. 常见问题与排查技巧实录
6.1 #{} 和 ${} 的经典事故
这是 MyBatis 面试必问,也是生产事故高发点。#{}会被解析成 PreparedStatement 的占位符,由数据库驱动完成参数绑定,既有预编译性能优势,也能防止 SQL 注入。${}则是在 SQL 拼接阶段直接替换字符串,如果一个查询条件值来自用户输入,用了${},等于把数据库敞开给攻击者。
理论上只有列名、表名、order by 字段这类无法用占位符的地方才考虑${},而且必须配白名单校验。我看到有些代码里写成order by ${sortField},字段直接来自前端参数,如果没有白名单,那就是一个明显漏洞。有同学问:那排序到底怎么写?最稳妥的方案是把允许排序的字段枚举出来,前端传索引,后端映射成固定字符串,再拼进 SQL。
6.2 mapper XML 高亮不显示?三步排查
IntelliJ IDEA 里经常遇到 XML 文件没有高亮的情况,尤其是使用自定义后缀或者手工创建的 Mapper 文件。排查分三步。
第一步,看文件名后缀是不是.xml,如果写成了.xml.txt,IDE 当然不认识。第二步,检查 IDE 的文件类型关联,在 Settings -> Editor -> File Types 里确认 XML 模式有没有匹配到目标文件。第三步,检查文件是否真的在 Maven/Gradle 资源目录下。如果 XML 放在了src/main/java下面,构建时可能不会被复制到 classpath,运行时会报Invalid bound statement,这时候不是高亮问题,是资源路径问题。
更推荐的做法是配合 MyBatisX 这类插件,可以直接从 Mapper 接口跳转到 XML,还能提示 SQL 映射是否完整,体验好很多。
6.3 MyBatis 支持某些国产数据库吗(比如 GaussDB)
问“MyBatis 支持 Gauss 吗”的同事,多半是在做数据库国产化替换。要分两层看:第一层,JDBC 驱动能不能连;第二层,你写的 SQL 方言是否兼容。MyBatis 自身不绑定数据库,它只负责把 SQL 交给驱动执行。所以只要驱动可用,原则上都可以连。
以 GaussDB 这类基于 PostgreSQL 生态的数据库为例,配置 PostgreSQL 驱动通常就能连上。但真正的问题在 SQL 本身。原项目里如果用了大量 MySQL 特有的写法,比如LIMIT、DATE_FORMAT、反引号表名,迁移时就要逐一排查。分页插件也需要确认支持对应方言,或者自己实现 Dialect 扩展。
我的建议是不要把“支持”理解成“什么都能跑”。部署到测试环境先跑一遍全量接口用例,尤其关注分页、日期、序列、模糊查询这几个高发点。数据库迁移从来不是改一个配置就能完成的,越早做适配测试,风险越小。
6.4 @Update 执行慢,别急着怪 MyBatis
遇到@Update执行慢,很多人第一反应是 MyBatis 的问题。实际上框架只是把 SQL 发出去,真正耗时在数据库那边。第一步开 SQL 日志,确认生成的 SQL 是否走索引。第二步把 SQL 拿到数据库客户端执行explain,看是不是全表扫描。
常见原因包括:更新条件字段没有索引;更新行数太多;事务过大导致锁等待;批量更新被拆成了一条一条的小 update,网络往返次数暴增。后者在 MySQL 上特别典型,JDBC 连接串里如果没有rewriteBatchedStatements=true,executeBatch 的效率会大打折扣。
如果你想全局使用批量执行,可以把 SqlSessionTemplate 的 ExecutorType 设为 BATCH。但要注意,BATCH 模式下查询结果不会立即刷新到数据库,需要手动 flush。所以生产环境不要无脑全局开启,按需使用更安全。
6.5 Oracle 时间字段映射问题
Oracle 的DATE和TIMESTAMP类型区分容易让人头大。默认情况下,MyBatis 把DATE映射成java.sql.Date,如果你想用java.util.Date或LocalDateTime接收,就可能出现时区不对、格式不对、甚至报错。
实际项目里常见的规避方案是查出来后直接转字符串:
to_char(create_time, 'yyyy-mm-dd hh24:mi:ss') as create_time这样实体里用一个 String 字段接收,展示层直接使用,省去一堆类型转换。如果希望保持类型语义,就明确在 resultMap 里指定jdbcType=TIMESTAMP,并配一个合适的 TypeHandler。
我的经验是,时间字段问题不用怕,但要在项目初期定好规范。比如“数据库统一存时间戳”“查询统一返回格式化字符串”“实体里时间类型统一用 String 或 LocalDateTime”。没有规范,每个人写一种,后面就是你改我我改你,非常痛苦。
6.6 踩坑汇总表
| 现象 | 常见原因 | 处理办法 |
|---|---|---|
| 同一个事务里查询结果没更新 | 一级缓存命中旧数据 | 按需把 localCacheScope 设为 STATEMENT |
| 多表 join 查询出现脏数据 | 二级缓存 namespace 隔离 | 生产环境关闭二级缓存 |
| startPage 之后结果不是预期分页 | ThreadLocal 参数作用错位 | startPage 后紧跟目标 mapper 方法 |
| 分页 total 不准 | count SQL 被改写错误 | 检查 Params 配置和方言 |
| XML 标签没高亮 | 文件类型关联不对 | 在 IDE 设置里补 XML 关联 |
| order by 注入 | 使用了 ${} 且无白名单 | 枚举排序字段或做白名单校验 |
| @Update 执行慢 | 条件无索引、锁等待、批量丢包 | 执行计划 + rewriteBatchedStatements |
| Oracle 时间字段成 java.sql.Date | 默认类型映射问题 | jdbcType 或 to_char 转字符串 |
7. 一点个人经验
最后说点实在话。我用 MyBatis 很多年,越来越觉得它是一把直来直去的刀。它不会替你思考 SQL,也不会帮你把对象关系魔法化,但只要团队有 SQL 纪律,它的上限非常高。见过太多项目出问题,不是 MyBatis 不行,而是把不该交给字符串拼接的东西交给了字符串,把不该开的缓存开了,把分页插件用到了错误的边界。
我个人的习惯是:每次新项目起步,先定好 mapper 文件目录规范、SQL 书写规范、分页规范,再开始写业务代码。不要等 mapper 目录乱成一锅粥了才想起来治理。缓存也是同理,默认不开二级缓存,一级缓存遇到一致性场景就关掉。宁可数据库压力大一点,也不要早上线一个看似省事、实则埋雷的方案。
如果你现在正在纠结项目的持久层方案,不妨先问自己一句:团队是更相信 SQL,还是更相信框架?答案偏向前者,MyBatis 会是可靠的选择;答案是后者,你就去学 JPA,但要做好对付复杂查询的准备。没有万能架构,只有最适合当前团队的那一个。