1. 先搞清楚 MyBatis 是干什么的关于 MyBatis 的优点和缺点每年都会有朋友重新问我一遍。我的回答这次放在最前面MyBatis 是一个半自动的持久层框架它负责把 JDBC 那套连接管理、参数绑定、结果集装配的脏活包办掉把最核心的 SQL 编写权留在开发者手里。它能解决什么问题简单说就是“既要写 SQL又不想被 JDBC 折磨”的那类问题。适合谁来参考所有使用 Java 做服务端开发、日常需要和各种数据库打交道的同学尤其是项目中涉及复杂查询、报表统计、多表关联的团队。很多人在初学阶段纠结过MyBatis 和 Hibernate 到底选谁。我的观点一直没变——如果你的团队每个人都看得懂 SQL业务里又经常出现那种需要手动调优的查询MyBatis 会让你很省心如果你希望尽量少碰 SQL连建表建映射都想交给框架自动完成那它确实不合适。框架没有绝对的好坏只有和业务场景匹配与否。2. 优点它凭什么还这么能打2.1 半自动 SQL 映射灵活和可控兼顾“半自动”这三个字理解到位了就能理解 MyBatis 的立身之本。它自动处理的东西包括数据库连接的获取与关闭、PreparedStatement 的创建与参数填充、ResultSet 到 Java 对象的映射。它不帮你做的事就是生成 SQLSQL 长什么样完全由你自己决定。这个设计最大的好处是可控。当你遇到一条慢查询可以把 mapper XML 里的 SQL 原封不动复制到数据库客户端里执行看执行计划、建索引、调整 join 顺序每一步都能精确命中。全自动 ORM 的问题在于你交给框架的是对象关系框架返回的 SQL 未必是你心里想的那条一旦需要优化绕来绕去反而浪费时间。MyBatis 把所有复杂度暴露在明面上性能问题很容易定位。2.2 resultMap 能力结果映射比想象中强很多人以为 MapUnderscoreToCamelCase 就是 MyBatis 映射的全部这是低估它了。resultMap 才是真正核心的那部分。利用 resultMap你可以把一条多表 join 的查询结果映射成嵌套的用户对象、订单列表、甚至多级关联结构。举个例子查询用户列表并带上每个用户的订单列表。用全自动 ORM 做这种一对多查询容易碰到 N1 或者产生一堆冗余连接数据。MyBatis 的做法是先写好一条带 join 的 SQL再用collection把订单列集合进用户对象的 List 字段里。SQL 效率可控对象结构也能贴合业务需要。这种能力在后台管理系统、报表服务里几乎天天用属于必备技能。2.3 轻量集成和学习成本低和 JPA/Hibernate 那套完整的生命周期管理相比MyBatis 的概念少很多。一个项目从零开始接 MyBatis无非是引入依赖、配置数据源、写 Mapper 接口和 XML。不需要理解一级缓存和持久化上下文的微妙关系也不需要为每个实体设计复杂的继承映射。对于团队成员流动比较大的项目这个特点尤其有吸引力。新同学只要 SQL 基础扎实一个星期左右就能上手写业务代码。如果没写过 SQL那不管用什么框架都是白搭MyBatis 反而能逼着你把基本功补起来。2.4 生态成熟周边工具多MyBatis 出来这么多年周边生态已经非常完整。代码生成器可以从数据库表直接生成实体、Mapper 接口和 XMLMyBatis 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 的limitOracle 的rownum和fetch firstPostgreSQL 的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 namelocalCacheScope valueSTATEMENT/ /settingsSTATEMENT表示每次查询结束后就清空一级缓存让每一次查询都直接访问数据库。这个配置对性能有一点影响但在一致性要求高的场景里宁可用它换正确性。真正理解了底层实现就不会被表象迷惑。4.3 二级缓存为什么说生产环境慎开二级缓存需要手动在 mapper XML 里加cache/才会启用缓存范围是 namespace。开启后该 mapper 的查询结果会被缓存起来避免重复查数据库。问题回到刚才提到的脏读。只要查询涉及多张表缓存失效就没法保证。比如订单 mapper 缓存了一条 join 用户表的查询结果用户表数据更新后订单 namespace 里的缓存并不知道要失效。多个节点部署时更是如此一个节点的缓存更新了其他节点还是旧数据。所以我的态度是不用把它当成默认配置。除非你非常清楚某个查询是单表、数据基本不变、而且业务可以接受短暂不一致否则宁可不赚这份缓存收益。网上很多“面试八股”会吹二级缓存但真实生产环境里它并不是一个受欢迎的功能。5. Spring Boot 集成和分页插件用法5.1 分页插件 PageHelper 的正确用法PageHelper 可以说是国内用得最多的分页插件。Spring Boot 项目集成很简单先引入依赖dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency然后在配置文件里指定方言和分页参数pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSql实际使用代码PageHelper.startPage(pageNum, pageSize); ListUserDTO users userMapper.selectByCondition(condition); PageInfoUserDTO 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 接口时多个参数的绑定需要特别注意。如果你不写ParamListUser selectByNameAndStatus(String name, Integer status);XML 里虽然可以写#{param1}、#{param2}但这种代码可读性太差。务必显式指定参数名ListUser selectByNameAndStatus(Param(name) String name, Param(status) Integer status);XML 里就能直接写#{name}和#{status}一眼看懂。单参数 List 场景下也需要Param否则foreach遍历时可能拿不到集合。jdbcType是用来处理数据库类型与 Java 类型映射细节的。典型场景是 Oracle 的日期类型如果查询结果出现“无效的列类型”或者时间字段变成java.sql.Date可以在 resultMap 里显式指定result columnCREATE_TIME propertycreateTime jdbcTypeTIMESTAMP/这类细节平时容易忽略一旦遇到跨数据库、特殊类型字段的问题就知道它的价值了。6. 常见问题与排查技巧实录6.1 #{} 和 ${} 的经典事故这是 MyBatis 面试必问也是生产事故高发点。#{}会被解析成 PreparedStatement 的占位符由数据库驱动完成参数绑定既有预编译性能优势也能防止 SQL 注入。${}则是在 SQL 拼接阶段直接替换字符串如果一个查询条件值来自用户输入用了${}等于把数据库敞开给攻击者。理论上只有列名、表名、order by 字段这类无法用占位符的地方才考虑${}而且必须配白名单校验。我看到有些代码里写成order by ${sortField}字段直接来自前端参数如果没有白名单那就是一个明显漏洞。有同学问那排序到底怎么写最稳妥的方案是把允许排序的字段枚举出来前端传索引后端映射成固定字符串再拼进 SQL。6.2 mapper XML 高亮不显示三步排查IntelliJ IDEA 里经常遇到 XML 文件没有高亮的情况尤其是使用自定义后缀或者手工创建的 Mapper 文件。排查分三步。第一步看文件名后缀是不是.xml如果写成了.xml.txtIDE 当然不认识。第二步检查 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 连接串里如果没有rewriteBatchedStatementstrueexecuteBatch 的效率会大打折扣。如果你想全局使用批量执行可以把 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 里指定jdbcTypeTIMESTAMP并配一个合适的 TypeHandler。我的经验是时间字段问题不用怕但要在项目初期定好规范。比如“数据库统一存时间戳”“查询统一返回格式化字符串”“实体里时间类型统一用 String 或 LocalDateTime”。没有规范每个人写一种后面就是你改我我改你非常痛苦。6.6 踩坑汇总表现象常见原因处理办法同一个事务里查询结果没更新一级缓存命中旧数据按需把 localCacheScope 设为 STATEMENT多表 join 查询出现脏数据二级缓存 namespace 隔离生产环境关闭二级缓存startPage 之后结果不是预期分页ThreadLocal 参数作用错位startPage 后紧跟目标 mapper 方法分页 total 不准count SQL 被改写错误检查 Params 配置和方言XML 标签没高亮文件类型关联不对在 IDE 设置里补 XML 关联order by 注入使用了 ${} 且无白名单枚举排序字段或做白名单校验Update 执行慢条件无索引、锁等待、批量丢包执行计划 rewriteBatchedStatementsOracle 时间字段成 java.sql.Date默认类型映射问题jdbcType 或 to_char 转字符串7. 一点个人经验最后说点实在话。我用 MyBatis 很多年越来越觉得它是一把直来直去的刀。它不会替你思考 SQL也不会帮你把对象关系魔法化但只要团队有 SQL 纪律它的上限非常高。见过太多项目出问题不是 MyBatis 不行而是把不该交给字符串拼接的东西交给了字符串把不该开的缓存开了把分页插件用到了错误的边界。我个人的习惯是每次新项目起步先定好 mapper 文件目录规范、SQL 书写规范、分页规范再开始写业务代码。不要等 mapper 目录乱成一锅粥了才想起来治理。缓存也是同理默认不开二级缓存一级缓存遇到一致性场景就关掉。宁可数据库压力大一点也不要早上线一个看似省事、实则埋雷的方案。如果你现在正在纠结项目的持久层方案不妨先问自己一句团队是更相信 SQL还是更相信框架答案偏向前者MyBatis 会是可靠的选择答案是后者你就去学 JPA但要做好对付复杂查询的准备。没有万能架构只有最适合当前团队的那一个。
