☰
MyBatis入门先懂原理:从JDBC到半自动映射与动态SQL
2026/11/3 17:10:05 网站建设 项目流程

很多刚接触持久层框架的人,学 MyBatis 的路径都出奇地一致:找一份能跑的 demo,把全局配置和映射文件原样抄进去,改一下数据库地址,跑通一条查询,然后觉得自己会了。等到同事把一段复杂点的需求丢过来——多表关联、动态条件、批量更新、返回嵌套对象——才发现自己连#{}和${}的区别都说不清楚,报错信息看一眼就想关电脑。我这几年带过不少人,也帮人排查过大量BindingException、Invalid bound statement、结果集全为 null 这类问题,绝大多数根子都不在 MyBatis 本身,而在于"学之前没搞清楚它是干什么的、它是怎么跑的"。这篇东西就是写给准备上手 MyBatis 的人的:不急着让你写代码,先把持久层这一层的历史包袱、MyBatis 的设计取舍、核心组件的运行链路讲透。看完之后你再去抄配置,脑子里是有图的,出了问题知道往哪个环节找。无论你是刚学完 JDBC 的学生,还是被 JPA 坑过想换一套方案的后端,或者是想搞清楚框架底层的老手,这里的内容都能直接用上。

1. 先把话说清楚:MyBatis 到底解决的是什么问题

1.1 从一段原生 JDBC 代码说起

理解一个框架的价值,最快的办法是回到没有它的年代。假设要从用户表按主键查一条记录,纯 JDBC 的写法大致是这样:

Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DriverManager.getConnection(url, user, password); String sql = "SELECT id, user_name, age, create_time FROM t_user WHERE id = ?"; ps = conn.prepareStatement(sql); ps.setLong(1, userId); rs = ps.executeQuery(); if (rs.next()) { User u = new User(); u.setId(rs.getLong("id")); u.setUserName(rs.getString("user_name")); u.setAge(rs.getInt("age")); u.setCreateTime(rs.getTimestamp("create_time")); return u; } } catch (SQLException e) { throw new RuntimeException(e); } finally { if (rs != null) try { rs.close(); } catch (SQLException ignored) {} if (ps != null) try { ps.close(); } catch (SQLException ignored) {} if (conn != null) try { conn.close(); } catch (SQLException ignored) {} } return null;

这段代码能跑,但你把它放大到一整个项目里看,问题就密集出现了。第一,资源关闭的三层嵌套 finally 几乎每个方法都要重复一遍,谁少写一个大括号就是连接泄漏,线上跑几天连接池就爆了。第二,SQL 字符串硬编码在 Java 文件里,改一个字段名要重新编译、重新打包、重新上线,DBA 想在发布前审一遍 SQL 都没法审。第三,结果集到对象的映射全靠手写,一张二十个字段的表,rs.getString能写到你手抽筋,而且列名写错一个字母,编译期完全不报错,运行时才给你一个 null。第四,参数赋值是按下标来的,ps.setString(1, ...)、ps.setString(2, ...),中间插入一个参数,后面全乱。

很多人会想,那我封装一层工具类不就行了?确实,早期项目里大量存在这种自研的BaseDao。但一旦你自己开始封装,就会撞上新的麻烦:分页怎么统一处理?动态条件怎么拼才不会漏掉WHERE 1=1?返回值是 List、是单对象还是 Map,怎么用一个方法覆盖?批量的场景怎么复用连接?你实际上是在重新发明一个持久层框架,而且大概率发明得比现成的差。MyBatis 就是在这一堆重复劳动之上,把"SQL 怎么写"这件事留给你,"SQL 怎么执行、结果怎么映射、资源怎么管"这件事收归框架。

1.2 ORM 不是万能药,半自动的取舍逻辑

行业里谈持久层,绕不开 ORM 这个词。全自动 ORM 的思路是:你只描述对象和表的对应关系,框架帮你生成 SQL。半自动的思路正好相反:SQL 你自己写,框架负责把参数塞进去、把结果搬出来。MyBatis 属于后者,官方文档里也一直强调它是"半自动映射"。

这个定位不是技术能力不够,而是主动取舍。全自动方案的好处是简单场景下开发极快,一张单表增删改查几行注解就完事了。但它的问题也很实在:复杂查询一旦超出框架的表达能力,你要么被迫写一堆框架特有的语法,要么干脆退化回原生 SQL,两套东西并存,维护成本直接翻倍。更麻烦的是性能调优——DBA 拿着慢查询日志找到你,你看着框架生成出来的那段又长又绕的 SQL,不知道该从哪下手。

半自动方案把 SQL 的控制权完整交还给开发者。这对开发者的要求更高:你得会写 SQL,得知道索引怎么走,得明白执行计划。但换来的是确定性——线上跑的那条语句,就是你在映射文件里写的那条,一个字都不差。我个人的判断标准很简单:如果一个系统里有大量多表关联、聚合统计、报表类查询,或者对 SQL 性能有明确的调优要求,半自动的方案更合适;如果系统绝大部分是单表操作、业务模型稳定、团队更希望少写 SQL,那全自动方案能省不少事。这不是信仰问题,是场景问题,后面第 5 节我会展开做对比。

1.3 什么样的项目适合上 MyBatis

具体到落地,我见过用得好和用崩的两类项目,特征还挺明显。用得好的,通常满足这几条:业务查询以手写 SQL 为主,团队里有人能把 SQL 写好;数据表结构相对稳定,但查询条件变化多,需要动态拼接;有历史遗留的存储过程或者复杂视图需要复用;对返回结果的形状有精细控制的需求,比如一个查询要返回带嵌套列表的对象。这些场景下 MyBatis 的映射能力是实打实的生产力。

用崩的项目往往是另一种情况:团队里没人愿意写 SQL,所有查询都指望框架生成,于是把 MyBatis 当成 JPA 用,硬套通用 Mapper,结果遇到稍微复杂一点的查询就卡住;或者是新手把业务逻辑写进了映射文件的动态 SQL 里,一个<if>套了八层,谁都不敢改。这不是框架的锅,是使用姿势的问题。

提示:如果你的项目里 SQL 数量很少、几乎全是单表 CRUD,而且团队对 SQL 不熟,上 MyBatis 的学习成本可能高于收益。工具选型要看团队的平均水平,不是看它是不是"主流"。

2. 学之前必须打牢的三块地基

2.1 SQL 功底:框架不会替你写 SQL

这是我最想强调的一点。MyBatis 的学习曲线里,真正陡的部分不在框架 API,而在 SQL。框架能帮你把参数安全地传进去、把结果优雅地映射出来,但它不能帮你判断这条 SQL 该不该走索引、能不能用覆盖索引、LEFT JOIN和INNER JOIN在这里的区别会不会导致数据缺失。

打个比方,MyBatis 像一套很好的厨具,刀快、锅好、火候稳,但它不会替你想这道菜该放多少盐。你给它的 SQL 是SELECT * FROM t_order WHERE DATE(create_time) = '2024-01-01',它照样老老实实执行,只是这个写法会让索引失效,几百万行的表扫起来能把数据库拖垮。框架不会拦你,也不会警告你,它只是忠实地执行。

所以在上手之前,我建议至少把这几个东西过一遍:JOIN的几种类型和它们的语义差别;GROUP BY配合聚合函数的使用;子查询和EXISTS的适用场景;索引的基本原理,什么情况下会失效;EXPLAIN怎么看,重点看type、key、rows、Extra这几列。这些不需要你成为 DBA,但至少看到一条慢 SQL 时,你得能判断问题出在哪。

还有一个很实用的习惯:写映射文件之前,先把 SQL 放到数据库客户端里跑通,确认结果集是对的,再搬进 XML。我见过太多人直接在 XML 里调试 SQL,改一个字符重启一次应用,半小时改不出一条语句。客户端里跑通、EXPLAIN看一眼、确认没问题,再贴进映射文件,效率差好几倍。

2.2 JDBC 基础:知道框架在背后替你做了什么

第二块地基是 JDBC。不是要你手写 JDBC 代码,而是要明白Connection、PreparedStatement、ResultSet这三个东西的角色,因为 MyBatis 底层跑的还是它们。

这里有个概念必须先搞清楚:预编译。PreparedStatement在发送 SQL 之前,数据库会先对这个带占位符的语句做语法解析、生成执行计划并缓存起来,后续传不同的参数可以复用这个计划。这既是性能优化的手段,也是防注入的关键——因为参数是作为数据传过去的,不会被当成 SQL 语法的一部分解析。MyBatis 里的#{}之所以安全,根子就在这里。

还有个容易忽略的点是连接的生命周期。数据库连接是稀缺资源,建立一次 TCP 连接加上认证握手,开销远大于执行一条简单查询。所以生产环境一定用连接池,而连接池的核心约束是:借出去的连接必须还回来。MyBatis 的SqlSession本质上就是对Connection的一层包装,如果你用完不关,连接就还不回去。这就是为什么后面讲生命周期时,我会反复强调SqlSession必须close。

事务也是一样。JDBC 默认是自动提交,每条语句执行完就落库。一旦你手动setAutoCommit(false),就必须显式commit或rollback。MyBatis 把这层封装了,但语义没变,很多人第一次用的时候发现"数据插进去了又没了",八成就是事务没提交就关了会话。

2.3 反射与动态代理:框架的两大底层手法

第三块是 Java 基础里的反射和动态代理。这两样东西不影响你写出能跑的代码,但决定了你能不能看懂框架的报错,以及在出问题时能不能定位。

反射的用途很直接:MyBatis 需要在你不知情的情况下,把ResultSet里的列值塞进你定义的实体对象。它拿到的是Class对象,不知道你的类叫什么、有哪些字段,只能靠反射在运行时读取字段、调用 setter。所以你写的实体类必须有无参构造方法,属性要有 getter/setter,这不是框架的洁癖,是反射的硬性要求。如果你用的是 Lombok 的@Data,它已经帮你生成了,但如果你手写实体又漏了无参构造,运行时就可能报实例化失败的错。

动态代理解决的是另一个问题:你只写了一个接口,比如UserMapper,里面只有方法声明,一行实现都没有,为什么调用userMapper.selectById(1L)就能跑到映射文件里的 SQL?因为SqlSession.getMapper()返回的不是你的实现类,而是一个 JDK 动态代理对象。这个代理拦截了你的方法调用,从方法名反推出要执行哪条MappedStatement,然后走完整的执行链路。理解这一点,你就能明白为什么下面这些约束是硬性的:

约束原因违反后的典型报错
接口全限定名 = 映射文件 namespace代理靠 namespace + 方法名定位语句Invalid bound statement (not found)
方法名 = 映射文件标签 id同上同上
接口不能重载同名方法一个 id 只能对应一条语句启动时解析冲突
参数个数大于 1 时建议加@Param否则按 arg0/param1 命名,容易对不上参数绑定异常或取值为 null

有了这三块地基,你再去看 MyBatis 的配置项,就不会觉得它们是一堆"必须这么写"的咒语,而是能对应到具体的运行时行为上。

3. 核心架构拆解:五个组件跑通一条查询

3.1 SqlSessionFactory 与 SqlSession 的生命周期

MyBatis 的入口是两个对象,名字很像,作用完全不同,混淆它们是新手最常犯的错之一。

SqlSessionFactory是重量级对象,内部持有Configuration(全局配置 + 所有映射语句的注册表)和Environment(数据源、事务工厂)。构建它的过程要解析 XML、注册所有映射、初始化类型处理器,开销不小。所以它必须是应用级单例,整个应用启动时创建一次,之后一直复用。如果你每次查询都new SqlSessionFactoryBuilder().build(...),等于每次都在重新解析一遍配置,性能直接崩掉。

// 正确做法:全局唯一,随应用启动创建 String resource = "mybatis-config.xml"; InputStream is = Resources.getResourceAsStream(resource); SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(is);

SqlSession则是轻量级对象,代表一次数据库会话,内部持有一个Connection。它不是线程安全的,绝对不能做成成员变量被多个线程共享。标准用法是"用时开、用完关",而且关闭必须放在finally里,或者用 try-with-resources:

try (SqlSession session = factory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user = mapper.selectById(1L); session.commit(); // 写操作必须提交 }

这里有个细节值得说清楚:openSession()默认是不自动提交的,openSession(true)才是自动提交。很多人在测试时发现 insert 没生效,就是忘了 commit。我个人的习惯是统一用默认的不自动提交,手动控制事务边界,因为自动提交会让多条语句之间失去原子性。

注意:和 Spring 整合之后,SqlSession的生命周期由框架托管,你会看到注入Mapper接口直接就能用,看不到openSession。这不是框架消失了,而是SqlSessionTemplate帮你做了代理和线程绑定。理解裸用时的生命周期,排错时才知道该看哪里。

3.2 Executor 执行器与一级缓存的关系

SqlSession拿到方法调用后,真正干活的是Executor。它有三种基础实现,外加一个装饰器,这个设计直接决定了很多行为表现。

执行器行为特点适用场景
SimpleExecutor每次执行都新建 Statement,用完即关默认,最稳妥
ReuseExecutor按 SQL 文本缓存 Statement 复用同一会话内重复执行相同 SQL
BatchExecutor攒批,统一 flush,返回影响行数延迟大批量写入,例如初始化数据
CachingExecutor装饰器,套在上面三种之外,负责二级缓存开启二级缓存时自动启用

你可以在openSession时指定执行器类型,但绝大多数情况下用默认的SimpleExecutor就够了。真要优化批量写入,更常见的做法是在映射文件里用foreach拼接批量 insert,或者配合框架提供的批量模式,而不是死磕执行器类型。

一级缓存是所有执行器都有的,作用域是同一个SqlSession。它默认开启,不需要任何配置。行为是:同一条MappedStatement、同样的参数、同样的 SQL,在同一个会话内第二次执行时,直接返回上次的结果,不再发请求给数据库。

try (SqlSession session = factory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User u1 = mapper.selectById(1L); // 真实查询 User u2 = mapper.selectById(1L); // 命中一级缓存,不发 SQL System.out.println(u1 == u2); // 输出 true,是同一个对象引用 }

这个机制能省掉不少重复查询,但它也是很多"数据不一致"问题的来源。最典型的场景是:你在同一个会话里先查了一次,然后在别处(另一个会话)把这条数据改了,再查一次,拿到的还是旧值。另外,只要会话内执行了任意的 insert/update/delete,一级缓存就会被整个清空——注意是整个,不是只清受影响的那条,这也是为什么有些人在循环里混着读写时会觉得缓存"没起作用"。

还有一个坑很隐蔽:和 Spring 整合后,如果你没有把方法放在同一个事务里,每次 mapper 调用可能拿到不同的SqlSession,一级缓存就等于失效了。所以"一级缓存怎么没生效"这类问题,先看事务边界,别急着怀疑配置。

3.3 MappedStatement:一条 SQL 的完整描述

启动解析阶段,每一个<select>、<insert>、<update>、<delete>标签,都会被解析成一个MappedStatement对象,注册进Configuration里,以namespace + id作为唯一键。这个对象里装的是这条语句的全部元信息:

  • id:命名空间加方法名组成的全限定标识
  • sqlSource:SQL 的抽象,动态 SQL 会被封装成DynamicSqlSource,静态的则是RawSqlSource
  • 参数映射配置:parameterType、@Param带来的ParamMap
  • 结果映射配置:resultType或者resultMap
  • 语句类型、缓存配置、超时时间、fetchSize等执行参数

理解这个对象的存在,能解释很多现象。比如Invalid bound statement (not found)这个报错,本质上就是"框架在Configuration的映射表里,按namespace.id找不到对应的MappedStatement"。可能的原因有:映射文件根本没被加载(路径不对、Maven 没把 XML 打包进去)、namespace写错了、方法名和id不一致、或者接口和 XML 放在了不同的模块里没被扫描到。按这个顺序排查,基本一次就能定位。

还有一个概念叫BoundSql,它在执行阶段才产生,代表一条 SQL 经过动态标签计算后的最终形态:带?的 SQL 语句,加上一个参数列表。调试时如果想看最终执行的 SQL,看的就是它。

3.4 参数映射与结果映射的两条链路

一条语句的执行可以切成两段,中间夹着数据库调用:前半段把 Java 参数变成 JDBC 参数,后半段把ResultSet变回 Java 对象。这两段分别由ParameterHandler、ResultSetHandler配合大量的TypeHandler完成。

参数侧:TypeHandler负责 Java 类型和 JDBC 类型的双向转换。框架内置了几十种,覆盖常见类型。遇到特殊类型(比如 JSON 字段、枚举、自定义的金额对象),你可以写自己的TypeHandler注册进去。新手常见的困惑是"我传了一个日期对象,为什么存进去少了几个小时",多半是TypeHandler选的 JDBC 类型不对,或者数据库字段类型和 Java 类型不匹配。

结果侧:resultType和resultMap二选一。前者走自动映射,规则是按列名去匹配属性名。这里最容易踩的坑是下划线命名:数据库列叫user_name,Java 属性叫userName,默认的自动映射不开驼峰转换,匹配不上,结果就是所有字段都是 null,你查出来一个空对象还不知道为什么。解决办法有两个,要么在全局配置里打开mapUnderscoreToCamelCase,要么老老实实写resultMap。

<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings>

我个人的偏好是:简单单表查询用resultType加驼峰开关,够快够省事;涉及嵌套对象、集合、字段名和属性名对不上的场景,一律用resultMap显式声明。后者啰嗦一点,但一眼能看出映射关系,后期维护省心得多。

4. 配置文件与映射文件:最小可运行骨架

4.1 全局配置文件的关键节点

全局配置(通常叫mybatis-config.xml)里的标签不少,但真正需要你手工管的只有几个。其余的用默认值就行,改错了反而出问题。

配置项作用我的实践建议
environments数据源和事务管理器实际项目基本交给 Spring 托管,这段可以不要
settings全局行为开关只改必要的,重点开驼峰映射和日志实现
typeAliases给类型起短名小项目可以用,大项目建议写全限定名,避免重名冲突
typeHandlers自定义类型转换遇到 JSON 字段、枚举时才加
mappers注册映射文件生产环境用包扫描,别一个个手写
plugins拦截器分页、SQL 日志这类插件挂在这里

settings里我最建议先开的两项,一个是mapUnderscoreToCamelCase,一个是日志实现。后者决定了你能不能看到实际执行的 SQL:

<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="logImpl" value="SLF4J"/> </settings>

配上日志框架之后,把 mapper 所在包的日志级别调到 DEBUG,控制台就会打印Preparing、Parameters、Total这几行,分别是最终 SQL、实际参数值、返回行数。调试阶段这一步能省掉你 80% 的猜测工作。我养成的一个习惯是,新项目接入 MyBatis 的第一件事就是把这段日志配好,跑通一条查询看到三行输出,才继续往下写。

关于typeAliases有个细节:如果配了包扫描,框架会把包下所有类注册成别名,别名默认是类名首字母小写。这时候如果两个不同包下都有同名的类,注册时会直接报错。所以中大型项目我更倾向于不配别名,老老实实写全限定类名,虽然长一点,但不会出现莫名其妙的冲突。

4.2 映射文件的四类标签与动态 SQL

映射文件是 MyBatis 的主战场,四类标签对应四种语句,属性大同小异。以最典型的<select>为例,除了id、parameterType、resultType,还有几个容易被忽略但很有用的属性:

  • useCache:这条语句是否使用二级缓存,默认 true,需要关掉时显式设为 false
  • flushCache:执行后是否清空缓存,查询默认 false,增删改默认 true
  • timeout:超时秒数,长查询建议设置,避免把连接池占死
  • statementType:PREPARED(默认)、STATEMENT、CALLABLE,调用存储过程时用第三个

真正的重头戏是动态 SQL。四个标签撑起了绝大部分条件拼接需求:<if>做条件判断,<choose>/<when>/<otherwise>做多分支选择,<where>和<set>负责去掉多余的AND和逗号,<foreach>处理集合遍历。

<select id="search" resultType="com.example.demo.User"> SELECT id, user_name, age FROM t_user <where> <if test="name != null and name != ''"> AND user_name LIKE CONCAT('%', #{name}, '%') </if> <if test="minAge != null"> AND age &gt;= #{minAge} </if> <if test="ids != null and ids.size() > 0"> AND id IN <foreach collection="ids" item="item" open="(" separator="," close=")"> #{item} </foreach> </if> </where> ORDER BY id DESC </select>

这段代码里有几个必须说清楚的点。<where>会智能地去掉开头多余的AND或OR,如果所有条件都不成立,它连WHERE关键字都不会输出——这就是为什么不需要再写WHERE 1=1,那种写法虽然能用,但会让索引优化的空间变小,也不好看。<if>里的test属性是 OGNL 表达式,&gt;=是 XML 转义后的>=,直接写大于号会导致 XML 解析失败,这是新手第一次写动态 SQL 必踩的坑。<foreach>的collection属性,如果你传的是List,固定写list;传的是数组,写array;用了@Param就写注解里的名字,这点必须记牢,写错了就是Parameter 'ids' not found。

<set>用在 update 里,作用是去掉最后一个多余的逗号。<choose>适合"多选一"的场景,比如排序字段只允许从前端白名单里选一个,这时候可以用它把非法值挡掉——注意这里必须用${}才能拼字段名,但前提是白名单校验已经做过了,绝不能把用户输入直接拼进去。

4.3 参数占位符:#{}与${}的本质差别

这个知识点几乎每篇教程都讲,但真正理解的人不多。两者的差别不是"一个安全一个不安全"这么简单,而是编译时机不同。

#{}会被解析成 JDBC 的?占位符,走PreparedStatement预编译,参数作为数据传过去。它在 SQL 里是安全的,因为数据库从语法层面就不认为它是 SQL 的一部分。而且预编译语句可以被数据库缓存执行计划,重复执行时省掉解析开销。

${}是纯粹的字符串替换,发生在 SQL 拼接阶段,替换完之后整条语句才发给数据库。参数会被当成 SQL 语法的一部分解析,因此存在注入风险。但它也有不可替代的用途:表名、列名、排序关键字这些位置不能用占位符,只能用字符串拼接。因为预编译的?只能替换值,不能替换标识符。

<!-- 正确:值用 #{} --> SELECT * FROM t_user WHERE user_name = #{name} <!-- 正确:标识符用 ${},但必须有白名单校验 --> SELECT * FROM t_user ORDER BY ${sortColumn} <!-- 错误:LIKE 里包 #{},会拼成 '%'?'%' 报错 --> SELECT * FROM t_user WHERE user_name LIKE '%#{name}%' <!-- 正确:LIKE 的两种写法 --> SELECT * FROM t_user WHERE user_name LIKE CONCAT('%', #{name}, '%')

注意:任何时候用${}拼接来自外部的值,都必须经过白名单或严格的格式校验。这不是"框架会帮你兜底"的事,出了事没有兜底。

4.4 一个能跑起来的最小闭环

把上面的东西串起来,最小可运行的结构是这样:一个mybatis-config.xml负责全局配置和数据源,一个UserMapper.xml放 SQL,一个UserMapper接口做声明,一个实体类承接结果。

public interface UserMapper { User selectById(@Param("id") Long id); List<User> search(@Param("name") String name, @Param("minAge") Integer minAge, @Param("ids") List<Long> ids); }
<mapper namespace="com.example.demo.mapper.UserMapper"> <select id="selectById" resultType="com.example.demo.entity.User"> SELECT id, user_name, age, create_time FROM t_user WHERE id = #{id} </select> </mapper>

调用的时候:

try (SqlSession session = factory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user = mapper.selectById(1L); System.out.println(user); }

如果这时候打印出来是一个User对象但所有字段都是 null,去查两件事:列名和属性名是否对得上(有没有开驼峰映射),以及实体类有没有无参构造和 setter。如果报Invalid bound statement,去查 namespace 和方法名。如果报参数找不到,去查@Param和collection的配置。这三类问题覆盖了新手期 90% 的报错。另外提醒一句:如果你用 Maven 构建,XML 放在src/main/java下的包目录里,默认是不会被复制到target/classes的,必须在pom.xml里配置资源过滤,否则运行时找不到文件。这个坑我见过太多人踩,报错信息还只告诉你"找不到资源",完全不提打包的事。

5. 和全自动 ORM 的取舍:不是谁替代谁

5.1 三个方案的正面对比

把 JDBC、半自动、全自动三套方案放在一起看,差异会非常直观:

维度原生 JDBCMyBatis全自动 ORM
SQL 控制权完全在手完全在手框架生成,难以干预
结果映射手写,繁琐易错自动或显式配置全自动
复杂查询表达力无上限无上限受框架语法限制
数据库移植性需自行处理方言SQL 层需适配较好,但仍有方言差异
学习成本低,但重复劳动多中,需懂 SQL中,需懂框架模型
性能调优空间最大大受限
适合团队任意SQL 能力较强的团队偏好少写 SQL 的团队

从这张表能看出一条清晰的分界:SQL 复杂度和团队 SQL 能力,是决定选型的两个主变量。业务简单、团队偏应用层思维,全自动方案能显著提升开发速度;业务复杂、有明确的性能要求,半自动方案的确定性更有价值。

5.2 我实际做选型时的判断顺序

拿到一个新项目,我的判断顺序大概是这样的。先看数据模型的复杂程度:如果是一堆单表、关联很少、查询条件固定,全自动方案能省掉大量模板代码。再看有没有报表和统计类的需求:这类需求往往是多表关联加聚合,写在自动生成的链式调用里会非常别扭,甚至写不出来。接着看团队的 SQL 水平:如果没人能独立写出带子查询的统计语句,上 MyBatis 会变成灾难,因为框架不替你思考。

还有一个经常被忽略的因素是遗留系统。如果数据库里已经有大量视图、存储过程、复杂的历史 SQL,半自动方案的接入成本低得多,因为你可以把这些 SQL 原样搬进来。全自动方案遇到这种场景,往往要在实体映射和原生查询之间来回切换,维护起来很难受。

最后说个务实的观点:这两类框架并不互斥。我见过一些项目,核心业务用半自动方案保证可控性,配置表、字典表这类简单 CRUD 用另一套方案,各取所长。关键是团队要有一套统一的约定,别让同一个人在不同模块里反复切换写法,那才是真正的维护成本。

6. 新手最容易踩的坑与排查实录

6.1 常见问题速查表

下面这张表是我这些年整理出来的高频问题,基本覆盖了入门阶段能遇到的大部分报错。排查的时候从上往下对,别一上来就怀疑框架有 bug。

现象最可能的原因处理办法
Invalid bound statement (not found)namespace 与接口全限定名不一致;方法名与 id 不一致;XML 没被打包逐项核对三者;检查target/classes下有没有 XML
查询结果对象字段全为 null列名与属性名不匹配,未开驼峰映射;实体缺少无参构造或 setter开mapUnderscoreToCamelCase;补全 getter/setter
参数绑定异常或参数为 null多参数未加@Param;foreach的 collection 名写错统一加@Param;List 用list,数组用array
插入成功但数据库没数据会话未commit写操作后显式提交,或交给事务管理
动态 SQL 报 XML 解析错误<、>、&未转义用&lt;、&gt;、&amp;,或用<![CDATA[]]>
LIKE查询报语法错误写成'%#{name}%'改用CONCAT('%', #{name}, '%')
连接池耗尽、应用卡死SqlSession未关闭用 try-with-resources,确保 close
同一会话内查到旧数据一级缓存未失效换会话、手动清缓存,或理清读写顺序
批量插入极慢逐条提交,未走批处理用foreach拼接批量 insert 或开启批量模式
排序字段拼接后注入风险用了${}且未校验白名单校验后再拼接

6.2 几个不会写在文档里的实操心得

第一,永远给多参数方法加@Param。不加也能跑,框架会用arg0、arg1、param1、param2这样的名字,但一旦你的 SQL 里引用参数时写错了位置,报错信息非常模糊。加上注解之后,名字是人看得懂的,出问题一眼就能定位。更重要的是,<if test="name != null">这种写法里的变量名,直接就等于注解里的名字,不加注解就得写arg0,可读性差到没法维护。

第二,调试 SQL 的时候先把参数值打出来。日志里Preparing那行是带占位符的语句,Parameters那行才是实际值。很多人看着Preparing: SELECT ... WHERE name = ?就开始怀疑参数没传进去,其实参数就在下一行。养成看两行的习惯,能省掉大量无用排查。

第三,复杂的动态 SQL 先在数据库里跑一遍。我处理过一个案例,一段带五层<if>的查询,本地跑得好好的,线上某个条件组合下就报错。最后发现是当所有条件都为空时,<where>虽然能去掉多余的AND,但后面紧跟的ORDER BY之前少了一个空格,拼出来的语句语法不对。这种问题在 XML 里盯半小时也看不出来,但把最终 SQL 复制到客户端一执行,错误立刻现形。所以我现在的习惯是:只要动态标签超过三个,一定先确认拼出来的完整语句长什么样。

第四,别把业务判断塞进 SQL 里。我见过映射文件里写了几百行的CASE WHEN,把各种状态、各种边界条件全铺在 SQL 里。这种代码改一次心惊胆战,因为没人知道这段逻辑到底被哪些场景走到。合理的做法是:SQL 只负责取数据,取回来之后在 Java 层做业务判断。SQL 里保留的,应该是那些能让数据库高效完成的过滤和聚合。

第五,分页别自己拼。手写LIMIT很容易出错,尤其是需要同时查总数的时候,两条 SQL 的WHERE条件必须完全一致,改了一处忘了另一处就是数据错乱。要么用现成的分页插件,要么把条件抽成<sql>片段复用。这一点看起来是小技巧,实际上能避免大量隐蔽的不一致问题。

第六,写映射文件之前先想清楚返回结构。是要一个平坦的对象,还是要带嵌套列表的树形结构?前者用resultType就够了,后者必须上resultMap配合<collection>和<association>。这个决定做在前面,能避免后面推倒重来。嵌套映射里还有个坑是主键重复导致集合被覆盖,需要在resultMap上指定id标签来标识唯一性,否则框架无法判断哪些行应该聚合成一个对象。

我在实际带人的过程中发现,凡是把这些前置知识过一遍的人,后面写 MyBatis 的顺畅程度明显不一样,报错时也更少慌神,因为他们脑子里有一条完整的链路:配置怎么解析、语句怎么注册、参数怎么绑定、结果怎么映射、缓存怎么生效。这条链路清楚了,框架就不再是黑盒,而是一套可以自己拆解、自己调试的工具。至于那些高级特性——自定义拦截器、二级缓存的坑、和各类框架的整合细节——等你把上面这些跑熟了再碰,会轻松很多。

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

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

立即咨询