从JDBC到MyBatis:数据库访问层迁移实战详解
1. 为什么我从JDBC切到MyBatis一条真实的迁移路线早两年我刚学Java数据库编程的时候和大多数人一样手里攥着一个JDBC工具类走天下。Connection、PreparedStatement、ResultSet这三个对象每天见到想吐。后来项目结构越来越大用户表、订单表、商品表都堆在一个DAO里每个方法都要写一遍“打开连接、处理SQL、遍历结果、关连接”增删改查四个操作复制粘贴几十遍一旦业务调整改到怀疑人生。就在那个阶段我下定决心换成MyBatis。事实证明这个选择不光是少写几十行代码的问题还把我从“SQL字符串拼接”的泥潭里彻底拉了出来。你如果正准备入门MyBatis或者正在纠结“到底要不要从JDBC迁移”这篇内容的核心目标很简单把MyBatis的增删改查从原理到落地全部捋清楚。适合刚接触Java持久层框架的入门者也适合那些写了一段时间JDBC、想找一个SQL可控方案的开发者。我会把我自己踩过的坑、面试中常被追问的点、以及和Spring Boot集成时的注意事项一并讲清楚。1.1 JDBC写业务代码的日子代码重复还不是最痛的先聊聊JDBC最让人难受的地方。很多教程喜欢说“JDBC冗余代码重复”但我觉得真正致命的不是重复而是对象关系映射完全靠手工。一张表有十几个字段你用ResultSet取值的时候就得写十几行getString/getInt取完之后再set进实体对象。多一个字段所有查询方法都得跟着改。更麻烦的是团队里每个人写结果集遍历的风格都不一样有人用while有人用if有人干脆返回一个ListMap代码审查的时候光是统一风格就要耗费大量时间。我在实际开发里还遇到过一种经典问题SQL里的?占位符一多参数顺序很容易错。PreparedStatement虽然能防注入但如果你在SQL中间加了一个过滤条件后面所有setXxx的下标可能全要调整。举个例子原来是WHERE id? AND status?以后新增一个create_time ?没经验的人经常在setString的索引上翻车。这种错误在编译阶段完全看不出来只有跑到那一行才抛异常。MyBatis用#{参数名}按名称取值从根本上绕开了“第几个问号”这个问题。1.2 MyBatis的准确定位它不是另一个Hibernate很多新手容易把MyBatis和Hibernate、Spring Data JPA搞混。其实这两个家族的思路差别很大。Hibernate是完整的ORM框架它尝试替你管理一切——你操作实体对象框架负责把对象变化翻译成SQL。好处是开发速度快但代价是SQL一旦复杂调优和排查都很被动。MyBatis走的是“半自动”路线SQL你写映射框架管。也就是说你告诉框架“我要执行这条SQL入参是这个对象出参映射到那个类”剩下的事务、参数绑定、结果集封装框架帮你做掉。这种“SQL可控”的特性让MyBatis在互联网公司存量系统里非常常见。尤其是报表类、统计类SQL动辄几百行用JPA自动生成的SQL根本做不了。还有一点很实际如果你所在团队对SQL有很强的掌控欲DBA希望每条慢查询都能精确定位到具体语句MyBatis明显更友好。它也绝不等于“不用写SQL了”它等于“把写SQL变成一件有秩序、可复用的事”。对比维度手写JDBCMyBatisHibernate/JPA核心SQL来源手写分散在DAO写在Mapper XML或注解框架自动生成结果集封装手工get/setresultType/resultMap自动映射实体映射效率尚可复杂SQL支持完全灵活完全灵活勉强常常要Native SQL学习曲线低中低中高调优可控性完全可控完全可控容易失焦代码量多中等少所以我给新人的建议通常是如果项目是报表、复杂查询为主选MyBatis如果是纯CRUD管理后台并且团队的SQL水平参差不齐JPA也能接受但要做好SQL失控的心理准备。我自己的偏好很清晰复杂系统选MyBatis。2. 第一个可运行项目依赖、全局配置、Mapper三个关键步骤开始敲代码之前先把工程结构理清楚。我假设你用的是Maven因为现在基本没有手动导入jar包的项目了。JDK用8数据库用MySQL 5.7以上。2.1 Maven依赖别把mybatis和mybatis-spring弄混一个只做MyBatis独立测试的项目核心依赖很简单dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.16/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency这里有个初学者常踩的坑搜索MyBatis依赖时网上经常会看到mybatis-spring、mybatis-spring-boot-starter它们不是同一个东西。mybatis-spring是专门用来和Spring整合的桥接包mybatis-spring-boot-starter是Spring Boot场景启动器。如果你现在只是写一个独立测试程序验证Mapper对不对加一个mybatis就够了。等做到Spring集成阶段再去引入starter否则容易把依赖管理搞乱。2.2 mybatis-config.xml全局配置里最常用的几个设置MyBatis需要一个全局配置文件我习惯叫mybatis-config.xml放在src/main/resources下。一个最简配置长这样?xml version1.0 encodingUTF-8 ? !DOCTYPE configuration PUBLIC -//mybatis.org//DTD Config 3.0//EN http://mybatis.org/dtd/mybatis-3-config.dtd configuration settings setting namemapUnderscoreToCamelCase valuetrue/ setting namelogImpl valueSTDOUT_LOGGING/ /settings environments defaultdevelopment environment iddevelopment transactionManager typeJDBC/ dataSource typePOOLED property namedriver valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/test?useUnicodetrueamp;characterEncodingutf8/ property nameusername valueroot/ property namepassword value123456/ /dataSource /environment /environments mappers package namecom.demo.mapper/ /mappers /configuration两个settings必须理解mapUnderscoreToCamelCase用来把数据库的user_name自动映射成Java实体里的userName这能省掉大量resultMaplogImpl的值设为STDOUT_LOGGING可以在控制台直接打印SQL入门阶段调试特别有用。environments里的transactionManager typeJDBC意味着事务由MyBatis自己管理提交和回滚都依赖JDBC连接。dataSource typePOOLED是使用连接池实际项目中一般不会直接用这个配置而是交给Spring管理数据源但你在独立测试时靠它就能跑起来。mappers标签可以指定XML文件路径也可以用package直接扫描接口包。使用包扫描有个前提Mapper接口和XML文件必须同名且在同一个包下。2.3 SqlSessionFactory整个框架的入口MyBatis的所有操作几乎都围绕SqlSessionFactory展开。它是一个重量级对象一个应用通常只需要创建一个。官方建议用SqlSessionFactoryBuilder从配置XML构建String resource mybatis-config.xml; InputStream inputStream Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory new SqlSessionFactoryBuilder().build(inputStream);然后每次数据库操作通过SqlSessionFactory打开一个SqlSessiontry (SqlSession session sqlSessionFactory.openSession(true)) { UserMapper mapper session.getMapper(UserMapper.class); // 执行方法 }注意openSession(true)表示自动提交。MyBatis默认是不自动提交的如果不开自动提交增删改操作后必须手动session.commit()否则数据不会真正写入数据库。这个细节是初学者最容易忽略的后面我会在排查部分再讲一次。SqlSession不是线程安全的它的生命周期应该限定在一个方法或一个请求范围内。用完就关千万别做成成员变量长期持有。这一条无论你用什么姿势写MyBatis都成立。3. 增删改查实操拆解参数、主键回填和SQL怎么写才有数铺垫做完了直接进入正题。我用一张用户表做示例表结构如下CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, email varchar(100) DEFAULT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;对应实体类public class User { private Long id; private String username; private String email; private Date createTime; // 省略getter/setter }3.1 Mapper接口与XML的对应关系Mapper接口和XML的绑定有一个铁律XML的namespace必须等于接口的全限定名XML里的statement id必须等于接口方法名。一旦对不上运行时会报Invalid bound statement (not found)。public interface UserMapper { int insertUser(User user); int updateUser(User user); int deleteUser(Long id); User selectUserById(Long id); ListUser selectAllUsers(); }对应XMLmapper namespacecom.demo.mapper.UserMapper insert idinsertUser INSERT INTO user (username, email, create_time) VALUES (#{username}, #{email}, #{createTime}) /insert update idupdateUser UPDATE user SET username #{username}, email #{email} WHERE id #{id} /update delete iddeleteUser DELETE FROM user WHERE id #{id} /delete select idselectUserById resultTypecom.demo.pojo.User SELECT id, username, email, create_time FROM user WHERE id #{id} /select select idselectAllUsers resultTypecom.demo.pojo.User SELECT id, username, email, create_time FROM user /select /mapper眼尖的会发现我这里update语句没有更新create_time。实际业务里通常也是这么处理的创建时间只在insert时赋值修改操作不要随意去动它避免数据审计出问题。这是个很容易忽略但很重要的习惯。3.2 主键回填useGeneratedKeys到底是干什么的插入用户后业务上经常马上需要这个用户的自增ID比如初始化它的账户余额。JDBC里有getGeneratedKeys()方法可以拿到自增主键MyBatis则封装为useGeneratedKeys和keyProperty。insert idinsertUser parameterTypecom.demo.pojo.User useGeneratedKeystrue keyPropertyid INSERT INTO user (username, email, create_time) VALUES (#{username}, #{email}, #{createTime}) /insert执行完insertUser(user)后传进去的user对象的id字段就被自动赋值为数据库生成的自增主键。注意别指望返回值的int代表主键insert方法的返回值只表示影响行数。很多新人在这一步翻车int result mapper.insertUser(user)然后拿着result去当主键用结果永远拿到的是1。补充一种情况如果数据库不是自增主键而是使用序列或逻辑ID可以用selectKey在插入前后手动查询主键。MySQL下一般用自增就能解决如果碰到其他数据库selectKey的orderBEFORE或AFTER就要根据数据库特性区分。3.3 参数传递单参数、多参数、对象、Map的四种姿势增删改查里参数传递是最容易踩坑的地方。MyBatis对参数处理的规则其实很清晰单参数直接使用#{任意名字}MyBatis不关心你写的参数名是什么。例如#{id}传一个Long进去就可以。多参数如果不加注解MyBatis会按参数位置暴露param1、param2或者arg0、arg1可读性很差。所以多参数必须加ParamListUser selectUsers(Param(username) String username, Param(email) String email);对象参数直接用#{属性名}取属性例如上面insert里的#{username}。Map参数用Map的key取值灵活但可读性差除非是动态查询条件特别多否则我建议用POJO。#{}和${}这两个占位符的区别是MyBatis面试出现率最高的问题之一。简单说#{}会生成PreparedStatement的占位符?安全可靠${}是直接字符串拼接SQL注入风险极高。select idselectUserByName resultTypecom.demo.pojo.User SELECT * FROM user WHERE username #{name} /select如果你写成${name}传入 or 11最终SQL就变成了SELECT * FROM user WHERE username or 11整表数据直接查出来了。所以除非是动态表名、排序字段这类不能占位的场景一律别用${}。3.4 查询结果列名和实体属性的小坑明明SQL查出来的列叫usernameJava属性也叫username结果返回对象里却是null。遇到这种情况优先检查两个地方。第一数据库列名下划线命名Java属性驼峰命名。比如create_time对应createTime没有开启mapUnderscoreToCamelCase时MyBatis不会自动转。你可以在SQL里给列起别名或者开启全局驼峰映射。我个人强烈推荐开启驼峰映射简单省事。第二resultType到底写的是“别名”还是“全限定类名”。如果不配置typeAliases就必须写全限定名com.demo.pojo.User。也可以配置一个短别名typeAliases package namecom.demo.pojo/ /typeAliases之后resultTypeUser就能用了。但是小心如果多个包下有同名类会冲突。4. 结果映射、动态SQL和批量操作业务复杂以后马上要会的三件事CRUD入门只会写单表操作到真实项目里根本不够用。稍微遇到一个多表关联、一个动态查询条件就需要上resultMap和动态SQL。这块是MyBatis拉开和普通DAO工具差距的地方。4.1 resultType和resultMap该怎么选只要查询结果的列和实体属性能对得上用resultType最省事。对不上的场景有两种列名不一样除了驼峰映射解决不了的部分、实体里嵌了关联对象。比如一个订单实体里包含用户信息public class Order { private Long id; private String orderNo; private User user; // 关联对象 private Date createTime; }这种结构用resultType就搞不定必须用resultMap手动映射关联关系resultMap idorderResultMap typecom.demo.pojo.Order id propertyid columnorder_id/ result propertyorderNo columnorder_no/ result propertycreateTime columncreate_time/ association propertyuser javaTypecom.demo.pojo.User id propertyid columnuser_id/ result propertyusername columnusername/ result propertyemail columnemail/ /association /resultMapSQL里要给关联字段起好别名比如u.id AS user_id。这里的association就是“一对一”关联。如果是“一对多”则用collection标签属性类型是List的时候很常见。4.2 动态SQLif、where、set、foreach动态SQL最常见的业务场景是条件查询。用户列表页经常有多个筛选条件但每个条件都可能为空写成静态SQL要么只能靠拼字符串要么要写好几条相似SQL。MyBatis的where加if能优雅解决select idselectUsersByCondition resultTypeUser SELECT id, username, email, create_time FROM user where if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if if testemail ! null and email ! AND email #{email} /if /where /selectwhere标签会自动去掉开头的AND或OR这个细节解决了拼SQL时最烦人的逗号和连接符问题。同理更新语句使用set标签可以自动去掉最后一个逗号update idupdateUserSelective parameterTypeUser UPDATE user set if testusername ! nullusername #{username},/if if testemail ! nullemail #{email},/if /set WHERE id #{id} /updateforeach则用来处理批量插入和IN查询insert idbatchInsertUser INSERT INTO user (username, email, create_time) VALUES foreach collectionlist itemuser separator, (#{user.username}, #{user.email}, #{user.createTime}) /foreach /insert使用foreach时注意collection属性的值如果参数是List写成list如果是数组写成array如果被Param命名了就写Param指定的名字。很多人没注意这个导致批量操作报参数找不到。4.3 批量操作的死穴很多坑不在MyBatis而在数据库说到批量插入MySQL下用foreach拼一条大SQL通常没问题。但如果数据量巨大比如一次几万条单条SQL会超过max_allowed_packet限制需要分批执行。另外Oracle等数据库并不支持VALUES (...),(...)这种写法必须用begin ... end匿名块或者insert all。所以批量操作的优化方案要结合数据库类型来定。实际场景里我的经验是批处理控制在500条左右一批效率和安全比较平衡既不会太慢也不会撑爆数据库包大小。同时记住批量操作一般在所有数据插入完成后统一提交事务。如果每条都提交性能会急剧下降。这也是为什么MyBatis默认不自动提交反而成了一个“保护机制”。5. 调试、日志和缓存初学者的三个高频拦路虎写CRUD能跑通是第一步但真正折磨人的是“为什么我的代码没生效”“为什么缓存里读到脏数据”。这一节我把最常遇到的三类问题一次性讲透。5.1 把SQL日志打印出来你的调试效率能翻倍很多新手遇到MyBatis查不到数据第一反应是去翻数据库工具在Navicat里把SQL复制出来单独执行。这个思路没错但如果你的SQL里带了动态参数复制到工具里还要手工替换占位符太麻烦。正确的做法是直接让MyBatis把执行日志打印出来。在mybatis-config.xml里配置settings setting namelogImpl valueSTDOUT_LOGGING/ /settings这样控制台会输出类似 Preparing: SELECT id, username, email, create_time FROM user WHERE id ? Parameters: 1(Long) Columns: id, username, email, create_time Row: 1, admin, admintest.com, 2024-06-01 12:00:00 Total: 1看到Parameters一行就能直观确认参数有没有传错。传入参数为null时这里会显示null排查问题特别快。要是用SLF4J输出到日志文件可以配置setting namelogImpl valueSLF4J/然后在日志配置里单独给Mapper包设置DEBUG级别。5.2 三个必踩异常binding、SQL语法、参数找不到第一个高频异常是Invalid bound statement (not found)。常见原因namespace写错、statement id和接口方法名不一致、XML文件没有被打包进classpath。排查顺序是先看target/classes目录下有没有对应的XML文件。如果用的Mavensrc/main/java下的XML默认不会被打包需要配置resources标签或者按照规范把XML放在src/main/resources下。第二个是SQLSyntaxErrorException。这种情况别急着怀疑MyBatis先把日志打印出来复制完整的SQL去数据库工具执行。如果数据库工具能跑说明SQL没问题问题在参数或者映射。如果工具也报错大概率是SQL写错了比如MySQL的保留字没加反引号。user不是一个好表名因为它可能是某些数据库的保留字建议加反引号或换表名这里只是为了演示方便。第三个是Parameter xxx not found. Available parameters are ...。出现这个异常几乎都是在多参数方法上没有加Param注解。MyBatis为了兼容JDK 8之前的版本默认不会去读方法参数名所以要用param1、param2或者Param指定。我的建议是多参数方法一律加Param让代码可读性和安全性都有保障。5.3 一级缓存和二级缓存知道这两个就够应付大多数情况MyBatis的缓存是面试和实际排查都绕不开的话题。一级缓存默认开启作用域是SqlSession。同一个SqlSession里执行相同的查询且中间没有更新操作第二次查询会直接命中缓存不发SQL。但是注意一旦执行了增删改一级缓存就会被清空防止读到脏数据。这个机制在Spring集成后需要额外留意因为Spring管理的事务环境下一次事务通常对应一个SqlSession。二级缓存默认关闭作用域是Mapper的namespace级别。开启方式是在Mapper XML里加一行cache/开启后同一个namespace下的查询结果会缓存到应用级跨SqlSession共享。但这里有个风险如果两个Mapper操作同一张表二级缓存按namespace隔离一个namespace更新了数据另一个namespace的缓存还是旧的就会产生脏读。所以在多Mapper操作同一表的设计中我一般建议不开启二级缓存或者只在阅读量大、更新极少的表上使用。缓存这东西加的时候一时爽排查数据不一致的时候很酸爽。6. 往Spring Boot和源码方向再走半步入门CRUD之后大部分人下一步就是接Spring Boot。毕竟现在新项目基本都是Spring Boot工程不会有人再用原生MyBatis一个main方法跑测试。6.1 Spring Boot集成引入starter之后基本不用写全局配置在Spring Boot里使用MyBatis第一步引入依赖dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency注意版本配套Spring Boot 3.x对应MyBatis starter 3.x底层用的是MyBatis 3.5和JDK 17Spring Boot 2.x则建议用2.3.x版本的starter。版本错配经常导致启动报ClassNotFoundException或NoSuchMethodError。启动类上加上Mapper扫描注解SpringBootApplication MapperScan(com.demo.mapper) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }然后在application.yml里配置数据源和MyBatis相关项spring: datasource: url: jdbc:mysql://localhost:3306/test?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.demo.pojo configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl从这里可以看出Spring Boot场景下MyBatis的SqlSessionFactory由starter自动构建数据源交给Spring管理事务交给Spring的Transactional处理。你在XML里基本不再需要environments配置。这个阶段Mapper接口可以直接通过Autowired注入非常清爽。顺带说一句热词里提到的“多商户跨境商城”这类项目本质上就是大量围绕用户、商品、订单、商户、库存表的增删改查。核心业务模块通常也很依赖MyBatis的动态SQL加resultMap去处理复杂的订单查询、多语言商品、多层级商户这类数据结构。能把单表CRUD写稳、把关联查询映射写对、把动态条件拼清楚这类业务完全可以拿下来。6.2 想往深走读一遍源码里的关键链路学完了CRUD如果还想进阶可以从MyBatis源码里挑一条最短的链路读起SqlSessionFactoryBuilder构建ConfigurationSqlSession执行方法时拿到MappedStatement然后交给Executor执行中间经过StatementHandler、ParameterHandler、ResultSetHandler。这四个Handler就是MyBatis“SQL你写映射框架管”的底层支撑。读源码不需要从第一行读到最后一行。建议你给自己设一个目标搞懂“一条select语句从Mapper接口调用到最后返回结果对象中间经历了哪几步”。读的时候重点看MapperProxy的代理机制它会让你明白为什么接口不需要实现类也能调用。再看DefaultSqlSession的selectList方法你会发现缓存刷新、延迟加载都在哪一层处理。把这些链路读明白最直接的好处是遇到诡异问题你能判断问题出在参数处理、结果映射还是SQL执行层而不是瞎猜乱试。6.3 面试角度这几个问题最好能脱口而出关于MyBatis的面试题高频问题无非这几个#{}和${}的区别、一级缓存和二级缓存的区别、接口绑定XML的几种方式、延迟加载的实现原理、分页插件PageHelper底层的拦截器机制。我的建议是不要只背结论每个问题都回头对应一下代码。比如#{}和${}的区别你只要亲手把SQL日志打出来看一眼Preparing那行是什么样就永远忘不掉。还有一个容易被问到的点MyBatis的一级缓存能不能关闭。严格来说一级缓存是SqlSession级别的可以通过localCacheScope参数设置为STATEMENT来让每条语句执行完就清空缓存。但大多数情况下不需要动它。理解这个参数背后的东西比单纯背一个开关更有价值。如果让我给一条学习路线我的建议是先把原生MyBatis的增删改查写熟练再搞懂配置文件和Mapper映射的细节然后看动态SQL和resultMap接着接Spring Boot最后回头读源码。不要一上来就追着源码跑容易劝退。最后分享一个小实操技巧。我自己在写Mapper时习惯每个方法只做一件明确的事哪怕两条SQL只有一点点差别也不要去强行合并成一个“万能SQL”。看上去少写了代码但后续别人维护时根本判断不了这个动态SQL最终会执行成什么样排查问题的成本反而更高。MyBatis的灵活是好事但同时要求使用者自律SQL保持可读、参数保持简单、映射保持显式。做到了这几点CRUD这个基础功就算真正扎实了。