学MyBatis前必须搞懂的JDBC、ORM与核心机制

发布时间:2026/11/3 17:09:50
学MyBatis前必须搞懂的JDBC、ORM与核心机制 带过几批新人之后我发现一个规律凡是直接抱着 MyBatis 的 XML 模板开抄的人头一个月基本都在同一个坑里打转——SQL 跑通了但说不清为什么能跑通等遇到参数拿不到、结果映射错位、事务不生效的时候就彻底卡死。这篇东西写给准备学 MyBatis 但还没动手的人也写给那些已经写了半年 CRUD 却总觉得心里没底的人。我把学 MyBatis 之前必须搞明白的东西摊开来聊JDBC 到底留下了什么烂摊子、ORM 在映射什么、SqlSessionFactory 和 SqlSession 各自的生命周期、#{}和${}的底层差别、resultMap 的自动映射规则、动态 SQL 的拼装逻辑、两级缓存分别在什么条件下才真正生效。这些不是 API 文档里抄来的条目是我自己在项目里被咬过之后总结出来的顺序。先知道边界再学用法这是我反复验证过的学习曲线——顺序反了学得越快忘得越快。1. 先搞清楚 JDBC 留下了什么烂摊子1.1 一段原生 JDBC 代码里的六处重复劳动很多人学 MyBatis 的第一个错误是把它当成一个能少写代码的库。这个认知太浅导致后面所有设计都理解偏。要理解 MyBatis 存在的理由最直接的办法是把一段手写 JDBC 摆出来逐行看清楚哪些劳动是重复的、哪些是框架该接手的。public ListUser findAdult() { Connection conn null; PreparedStatement ps null; ResultSet rs null; ListUser list new ArrayList(); try { conn DriverManager.getConnection(url, user, pwd); ps conn.prepareStatement(select id, name, age from t_user where age ?); ps.setInt(1, 18); rs ps.executeQuery(); while (rs.next()) { User u new User(); u.setId(rs.getLong(id)); u.setName(rs.getString(name)); u.setAge(rs.getInt(age)); list.add(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 list; }这段代码里藏着六处重复劳动每一处都在消耗你的注意力而它们和业务逻辑一点关系都没有。第一是连接管理包括获取、关闭、异常情况下的兜底关闭还有一堆判空。第二是 SQL 硬编码在 Java 字符串里改一个字段就得重新编译DBA 审 SQL 的时候要在 class 文件里找。第三是参数绑定位置索引从 1 开始setInt(1, 18)里的 1 是个魔法数字多一个参数就得重新数位置。第四是结果集到对象的手工映射字段名、类型、下标全靠手工对齐rs.getLong(id)里字符串写错一个字母运行期才炸。第五是异常转换SQLException是受检异常每个方法都得包一层最后全变成RuntimeException调用栈面目全非。第六是资源释放的样板代码三个 try-catch 嵌套看着就烦。这六处劳动在任何一个有十个以上 DAO 方法的项目里都会被复制十遍以上。框架存在的意义就是把这部分每次都一样的东西抽走让你只写每次都不一样的部分——SQL 和映射关系。MyBatis 的取舍很清晰前面六条它全包但 SQL 它不替你写。这个选择决定了后面所有的设计风格也决定了它和 JPA 那一派的分道扬镳。1.2 ORM 到底在映射什么理解 ORM 之前得先承认一件事对象模型和关系模型天生不对付这个矛盾有个专门的说法叫阻抗不匹配。它具体表现在四个地方我用生活化的说法解释一遍。粒度不匹配数据库里一张t_order表和一个t_order_item表在 Java 里可能是Order里挂一个ListOrderItem。表是平的对象是嵌套的。类型不匹配数据库有decimal、timestamp、tinyint这些类型Java 只有BigDecimal、Date、Integer中间隔着转换规则。关联不匹配表之间靠外键连对象之间靠引用连外键是值引用是地址。继承不匹配Java 有继承和多态关系数据库没有只能靠单表加类型字段或者多表加连接来模拟。ORM 干的事就是在这两种世界观之间架一座桥。但桥的自动化程度可以差很远。全自动的那一派JPA/Hibernate帮你生成 SQL你写findByAgeGreaterThan(18)它就翻译成 SQL好处是开发快坏处是复杂查询生成的 SQL 常常不是你想要的那个执行计划优化的时候你得跟框架搏斗。半自动的那一派MyBatis只接管参数绑定和结果映射SQL 你自己写好处是每一行 SQL 都在掌控之内坏处是你得会写 SQL而且要写不少。我在实际项目里选型的判断标准很简单业务查询超过一半是简单单表操作、团队里没人愿意深究执行计划选全自动查询涉及多表关联、分页、报表、复杂条件拼装、需要 DBA 参与审核选半自动。这不是技术优劣是协作模式决定的。你在学 MyBatis 之前先想清楚这个前提就不会在学到一半的时候冒出一个念头——“这东西怎么什么都要我自己写”因为它本来就这么设计的。1.3 MyBatis 在持久层栈里的真实位置还有个特别容易混淆的点很多人以为 MyBatis 管连接池、管事务、管一切。不是的。把持久层拆成几层看位置一目了然。最底下是 JDBC 规范和各数据库的驱动这一层提供Connection、PreparedStatement、ResultSet这些最原始的接口。往上一层是数据源负责连接池的创建、借用、归还、保活常见的就是各类连接池实现MyBatis 只定义了一个DataSource接口具体实现交出去。再往上是 MyBatis 自己它负责 SQL 的解析、参数的绑定、语句的执行编排、结果集的映射、缓存的维护。最上面是事务MyBatis 有一个简单的Transaction抽象但在实际工程里基本都交给外部的声明式事务去管。这个分层意味着三件事。第一你配连接池参数的时候调的其实是数据源不是 MyBatis。第二事务边界不在 MyBatis 的 Mapper 方法上而在更外层的服务方法上所以你在 Mapper 里写两个 update 想让它原子生效是不现实的。第三MyBatis 的核心价值区就在那个SQL 解析 参数绑定 结果映射的窄带里搞清楚这条窄带里的机制等于掌握了它八成的复杂度。2. 动手之前先嚼碎的几个核心概念2.1 SqlSessionFactory 是重量级单例SqlSession 是轻量级一次性这两个名字长得像生命周期却完全相反这是新手第一个必踩的坑。我用一个类比SqlSessionFactory像一家工厂建起来成本高建好之后长期存在全应用共用一个就行SqlSession像工厂开出的一张工单用完就作废不能攒着下次接着用。SqlSessionFactory的构建过程要解析全局配置文件、解析所有 Mapper 映射文件、注册语句、建立配置对象这一套下来开销不小。所以标准做法是在应用启动时构建一次存成单例。我看到过有人在每个方法里new SqlSessionFactoryBuilder().build(...)压测的时候 QPS 上不去排查半天发现全耗在重复解析 XML 上。这个错误很低级但犯的人不少因为单元测试里这么写没问题跑得快看不出来。SqlSession则完全相反它内部持有一个数据库连接准确地说是从数据源借来的代表一次会话。它不是线程安全的绝对不能塞到静态字段或者成员变量里共用。正确的用法是每次操作开一个用完在 finally 里关掉或者干脆交给上层框架管理。// 启动阶段只做一次 SqlSessionFactory factory new SqlSessionFactoryBuilder() .build(Resources.getResourceAsStream(mybatis-config.xml)); // 每次操作开一个关一个 try (SqlSession session factory.openSession()) { UserMapper mapper session.getMapper(UserMapper.class); User u mapper.selectById(1L); // 注意这里没 commit查询无所谓写操作必须 commit }这里还有个隐蔽的坑openSession()默认不自动提交。你执行了一个 insert代码跑完没报错去数据库一看没数据原因就是忘了commit()。要么openSession(true)开自动提交要么在写完显式提交。我个人的习惯是写操作显式提交因为自动提交会掩盖多条语句应该在一个事务里这个事实等你哪天需要保证原子性的时候改起来要动一大片。2.2 Mapper 接口没有实现类靠什么跑起来第一次看到这种代码的人都会愣一下UserMapper明明是个接口一个实现类都没写session.getMapper(UserMapper.class)返回的却是个能用的对象方法还能正确执行对应的 SQL。这中间发生了什么答案是 JDK 动态代理。getMapper内部用MapperProxyFactory创建了一个代理对象这个代理实现了你的接口。当你调用接口方法时请求被MapperProxy拦截它做三件事根据接口全限定名加方法名拼出一个语句 ID默认是com.xxx.UserMapper.selectById这种形式去配置里找对应的MappedStatement然后交给SqlSession去执行。理解了这条链路很多报错就变得可以解释了。比如经典的这个org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.demo.mapper.UserMapper.selectById这个报错的意思是代理收到了方法调用拼出了语句 ID但在配置里找不到。排查方向立刻清晰了——要么 XML 里的namespace和接口全限定名不一致要么select的id和方法名对不上要么 XML 压根没被扫描到放在resources下但目录结构不对Maven 默认只打包resources下的资源接口和 XML 同包分离时最容易出这个问题。顺便说一个工程实践接口名和 XML 文件名保持一致、放在同名包路径下、namespace写全限定名这三条对齐了90% 的绑定问题不会发生。我见过有人为了整齐把十个 Mapper 的 XML 全塞进一个mapper目录namespace靠手工维护后来加了一个新模块忘了改找了两个小时。约定大于配置这句话在 MyBatis 里体现得特别明显。2.3 配置文件分两层全局配置与 Mapper 映射MyBatis 的配置有两套新手经常搞混它们各自管什么。全局配置通常叫mybatis-config.xml管的是整个框架怎么运行环境配置数据源、事务管理器、类型别名、类型处理器、插件、全局开关比如下划线转驼峰、缓存开关、Mapper 注册。它是一份应用启动时加载一次。Mapper 映射文件管的是每条 SQL 长什么样语句本身、参数类型、结果映射、动态 SQL 标签。它有很多份一个接口一份。两者通过namespace建立联系这个联系点是全局唯一的语句 ID。所以两个 Mapper 文件里不能出现相同的namespace id组合否则后加载的会覆盖先加载的——注意是静默覆盖不报错。这个特性坑过不少人复制粘贴一个 Mapper 文件改新功能忘了改namespace结果老功能莫名其妙跑了新 SQL。全局配置里有两个参数我建议一开始就配好能省掉后面大量低级错误settings !-- 数据库下划线命名自动映射到 Java 驼峰属性 -- setting namemapUnderscoreToCamelCase valuetrue/ !-- 查询结果为 null 的列也调用 setter避免包装类型保持默认值造成误判 -- setting namecallSettersOnNulls valuetrue/ /settings第一条几乎是必开的user_name到userName的映射不用再手写一遍。第二条看情况开了之后 null 值也会走 setter好处是对象状态和数据库严格一致坏处是某些场景下会把默认值覆盖成 null用之前想清楚你的业务是不是依赖数据库没值就用 Java 默认值这个行为。3. 参数传递与结果映射翻车率最高的两个环节3.1#{}预编译占位与${}字符串拼接的本质差别这两个符号长得像底层走的是两条完全不同的路搞不清楚会同时踩上安全和性能两个坑。#{}走的是PreparedStatement的占位符最终 SQL 里是一个?参数通过setXxx绑定进去。数据库会先编译这个带占位符的语句生成执行计划然后再把参数填进去。这意味着参数永远被当成值处理不会改变 SQL 的结构天然免疫注入。同时也意味着同一条 SQL 换不同参数执行可以复用执行计划性能更好。${}走的是字符串替换在 SQL 发出去之前就把值拼进了语句文本里。它拼的是 SQL 结构的一部分所以能用的场景只有那些必须改变 SQL 结构的地方比如动态表名、动态排序字段、动态列名。!-- 正确值用 #{} -- select idfindByName resultTypeUser select * from t_user where name #{name} /select !-- 错误示范值用 ${}等着被注入 -- select idfindByNameBad resultTypeUser select * from t_user where name ${name} /select !-- 合理用法排序字段本来就是结构的一部分 -- select idlistByOrder resultTypeUser select * from t_user order by ${orderColumn} ${orderDirection} /select最后这个例子虽然合理但不代表安全。如果orderColumn来自前端参数攻击者可以传一个构造过的字符串把语句改掉。正确做法是在 Java 侧做白名单校验只允许有限几个字段名通过非法值直接拒绝。这一条我在代码评审里必查见过太多排序参数是从前端透传的这种事。还有一个#{}的细节值得提前说#{}里可以写jdbcType比如#{name,jdbcTypeVARCHAR}。当参数可能为 null 且数据库列不允许 null 时某些驱动会报无效的列类型加上jdbcType能解决。这个坑在 Oracle 上特别常见提前知道能省一次深夜排查。3.2 单参数、多参数、对象参数、集合参数的传参规则MyBatis 的参数处理有一套自己的规则摸清楚之后你会发现它其实很讲道理只是规则没有集中写在一页纸上。单参数时#{}里的名字随便写写什么都能取到值。因为只有一个参数框架直接拿它填进去。所以selectById(Long id)对应的 XML 写#{id}、#{value}、#{abc}都能跑。这反而害人——有人养成了乱写的习惯等到方法变成两个参数立刻报找不到参数。多参数时规则变了。不加注解的情况下参数在内部被包装成一个 Mapkey 是arg0、arg1或者param1、param2。XML 里必须用这些名字。这种写法可读性极差正确做法是加Param注解ListUser search(Param(keyword) String keyword, Param(minAge) Integer minAge);select idsearch resultTypeUser select * from t_user where name like concat(%, #{keyword}, %) and age gt; #{minAge} /select对象参数时直接写属性名#{userName}会调用对象的getUserName()。嵌套对象用点号#{address.city}。集合参数要配合foreach这个在下一节讲。还一个容易忽略的点parameterType这个属性在新版本里基本可以省略框架会自动推断。但省略之后有个副作用某些复杂场景推断不出来会走UnknownTypeHandler行为可能和预期不一致。我现在的习惯是简单类型不写复杂自定义对象写上图个确定性。3.3 resultMap 与自动映射的优先级和开关结果映射这块最需要提前建立的概念是自动映射和显式映射的优先级关系。自动映射的意思是查出来的列名和对象的属性名对上了框架自动填值。对上指的是完全一致或者开启了下划线转驼峰之后按转换规则一致。默认行为是PARTIAL意思是自动映射所有没在 resultMap 里显式声明过的列。NONE关掉自动映射FULL在嵌套结果映射时也做自动映射。这个默认值PARTIAL的设计很有讲究。它避免了FULL在嵌套关联查询时把内层对象的属性也自动填掉、导致数据串味的问题。理解这一点的实际价值在于当你的嵌套映射出现外层对象属性被内层结果覆盖的怪现象时你会立刻想到是自动映射层级的问题而不是怀疑人生。显式 resultMap 的使用场景我总结成三类列名和属性名差异太大比如dept_no映射到departmentNumber需要处理复杂类型关联对象、集合需要复用映射配置resultMap的extends和association的resultMap引用。这里有个性能上的坑要提前说嵌套集合用collection做嵌套查询select属性那种会触发 N1 问题——查 10 个订单每个订单再查一次明细一共 11 次数据库往返。数据量小的时候感觉不出来数据量一大就是灾难。替代方案是用join一次查完靠collection的嵌套结果映射来组装。代价是 SQL 变复杂、可能出现笛卡尔积导致的数据重复需要靠id标签帮助框架识别主键去重。这个id标签很多人不用结果集合里出现重复元素找了半天不知道为什么。4. 动态 SQL、缓存与插件的能力边界4.1 动态 SQL 标签的组合与表达式陷阱动态 SQL 是 MyBatis 相对其他持久层框架最有辨识度的能力它把根据条件拼 SQL这件事从 Java 字符串拼接里解放出来了。但它的标签组合有讲究用错了照样出问题。最常用的几个标签是if、where、set、foreach、choose。其中where和set的价值不在少写几个字而在它们会自动处理开头多余的and/or和结尾多余的逗号。很多人不用where自己手写where 11能跑但遇到索引优化的时候某些数据库对11这种恒真条件会有额外的优化负担不是好习惯。foreach的参数名有几个是固定的collection、item、index、open、separator、close这几个单词不能随便改。collection的值取决于入参形式传 List 写list传数组写array加了Param就写注解里的名字。这三个规则记不住就会遇到那句经典报错——“Parameter xxx not found. Available parameters are [arg0, collection, list]”。if里判断字符串非空很多人写testname ! null and name ! 。这个写法在字符串场景下是对的但如果name是单个字符类型或者数字类型name ! 在某些版本里会触发类型转换异常。更稳的写法是用if testname ! null and name.trim() ! 或者干脆在 Java 侧保证入参干净。这种细节文档里不显眼但线上真会出问题。还有个批量插入的经典写法值得记一下insert idbatchInsert insert into t_user (name, age) values foreach collectionlist itemu separator, (#{u.name}, #{u.age}) /foreach /insert这种写法一次往返插入 N 条比循环单条插入快一到两个数量级。但注意 SQL 长度某些数据库对单条语句的长度有限制几千条一次性拼进去会报错实际工程里要分批比如每 500 条一批。这个阈值跟数据库类型、max_allowed_packet之类的参数有关得实测。4.2 一级缓存和二级缓存分别在什么条件下生效缓存这一块我建议新手期先关掉二级缓存把一级缓存的行为搞清楚就行。原因很简单缓存是看起来很美、用错很痛的典型功能它带来的问题往往是数据不一致这种最难排查的类型。一级缓存是SqlSession级别的默认开启你没法关只能通过配置把它的作用域改成语句级。它的生效条件比较严格同一个SqlSession、同一条语句、同样的参数、中间没有执行过 insert/update/delete、且没有手动clearCache()。这五个条件里任意一个不满足缓存就不命中。所以在大多数工程里一级缓存的实际命中率很低因为SqlSession通常是请求级的一个请求里同样的查询重复执行的情况不多。真正需要注意的是它带来的脏读:同一个SqlSession里你先查了一次然后通过别的方式比如另一个SqlSession、或者直接改数据库修改了数据再查一次拿到的还是旧值。这个问题在测试环境特别容易出现——两个线程共用了不该共用的 session或者手动在数据库里改了数据但代码里没感知到。二级缓存是namespace级别的跨SqlSession共享默认关闭需要在全局配置和 Mapper 里同时开启。它的生效条件更苛刻必须显式配置cache/、被缓存的实体类必须实现序列化接口因为可能要写到磁盘或者远程传输、事务必须提交之后才写入缓存、且只在同一个namespace内有效。多表关联查询时某个表的数据被另一个namespace的语句改了这边是感知不到的这就是最典型的缓存不一致来源。我的实际建议是单机、读多写少、数据一致性要求不高的字典类查询可以开二级缓存有多个写入入口的表别开。真想上缓存不如在服务层用一套独立的缓存方案可控性强得多出问题也好排查。MyBatis 的缓存能力有它的位置但它不该是你在项目里第一个想到的方案。5. 上手阶段的常见问题与排查实录5.1 高频报错速查表下面这张表是我这些年攒下来的基本覆盖了新手前两个月会遇到的八成交错。看到报错先对号入座比盲目搜索快得多。报错关键词大概率原因排查动作Invalid bound statement (not found)namespace 与接口全限定名不符、XML 未被扫描、方法名与 id 不一致检查三处是否完全一致确认 XML 在编译输出目录里Parameter xxx not found多参数未加Param或collection属性值写错加注解核对 list/array/自定义名There is no getter for property#{}里的属性名写错或误用了${}拼对象逐个核对属性名与 getterInvalid column typenull 值缺少 jdbcType或类型不匹配补jdbcType检查字段类型TooManyResultsException返回类型声明成单个对象实际查出多条改成 List或补全 where 条件查得到但字段全是 null列名与属性名不匹配且未开自动映射开下划线转驼峰或用 resultMap插入成功但数据库没数据未提交事务显式 commit 或开自动提交集合结果出现重复元素嵌套结果映射缺少id标签补主键标识帮助去重这张表的价值不在于背下来而在于建立起报错 → 定位方向的映射关系。有经验的人看到错误信息第一反应是有方向的没经验的人第一反应是复制粘贴去搜索效率差十倍。5.2 我的排查顺序和几条踩出来的经验遇到问题我有一套固定的排查顺序分享出来供参考。第一步永远是把最终执行的 SQL 打出来。很多问题看一眼真实 SQL 就明白了——参数没进去、条件被if吞了、${}拼出了奇怪的东西。开启日志最省事的方式是在全局配置里指定日志实现把 Mapper 所在包的日志级别调到 DEBUGSQL、参数、结果行数就都出来了。这一步不做就去猜纯属浪费时间。第二步看参数绑定。#{}拿不到值八成是名字对不上。注意多参数场景下的可用参数列表报错信息里通常直接告诉你了照着改就行。第三步看映射关系。SQL 查出来的列和resultType/resultMap声明的属性能不能对齐。我习惯在写复杂映射之前先用一个简单的resultTypemap查一次把真实列名打出来看一眼再去写映射。这一步能省掉大量来回试错。几条踩出来的经验写在这里第一条别在 Mapper 接口里写业务判断。Mapper 是数据访问层它只负责把这条 SQL 执行好。判断该不该查、查到之后怎么处理属于服务层的活。我见过有人在 Mapper 的默认方法里写了一堆分支逻辑后来要加缓存、要加日志、要换实现全卡住了。第二条SQL 里的select *少用。除了前面说的传输开销还有个隐蔽问题一旦表加了字段select *的结果集列数变了某些映射方式可能会错位而且这种错位在测试环境未必暴露。列名显式写出来多打几个字省很多事。第三条分页别自己拼limit。手写limit #{offset}, #{size}看起来简单但count查询、参数校验、边界值处理全得自己来还容易出注入和越界的问题。用成熟的分页方案它帮你处理了这些细节而且换了数据库类型还能自动适配。第四条写 XML 的 SQL 一定要格式化。缩进、换行、关键字大写看着是小事但当你排查一个嵌套三层的动态 SQL 时格式乱的 SQL 能让你多花半小时。我现在的习惯是每个where块的每个条件单独一行if对齐嵌套层级用两空格缩进。这个习惯从第一天就养成后面看别人的代码也能快速读懂。最后分享一个小技巧专门治改了 XML 没生效这个毛病。开发阶段把全局配置里的 Mapper 重新加载打开或者干脆用热部署工具改完不用重启。但这个功能在排查为什么线上和本地行为不一致的时候要关掉因为它会掩盖资源加载顺序的问题——而加载顺序恰好是 Mapper 覆盖这类问题的重要线索。工具是工具得知道它什么时候在帮你、什么时候在骗你。我个人的体会是MyBatis 的学习曲线前期陡、后期平。前期陡是因为它把很多决定权交给你了SQL 怎么写、映射怎么配、事务边界在哪全要自己判断。但正因为每一个环节都看得见等你把前面这几块——生命周期、参数绑定、结果映射、动态 SQL、缓存边界——都摸清楚之后剩下的就是熟练度问题不会再有黑盒行为来突袭你。这也解释了一个现象用惯了半自动框架的人转去用全自动框架往往不适应因为太多东西被藏起来了反过来倒是顺畅得多。先把这条窄带里的机制搞懂后面无论换哪个框架判断力都在。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询