MyBatis核心原理与实战:从ORM思想到SQL映射,避开常见坑
1. 上手之前先把 ORM 这层窗户纸捅破聊 MyBatis 之前必须先聊 ORM。很多新手一上来就着急写 SQL 映射结果连“为什么要有这东西”都没整明白遇到问题全靠猜。我当初学 Java 后端时第一个数据库框架不是 MyBatis而是先在 JDBC 里摸爬滚打了一圈回头再看 MyBatis瞬间通透。Java 是面向对象语言但数据库是关系型思维这两者天然就有差距——类里有对象嵌套表里只有外键关联程序里用 List、Map 组织数据数据库返回的是二维结果集。这种“阻抗不匹配”就是 ORMObject Relational Mapping对象关系映射要解决的问题把数据库表结构映射成 Java 对象把对对象的操作翻译成 SQL。市面上主流方案分两类全自动 ORM 和半自动 ORM。全自动的代表是 Hibernate你把对象一改它自动生成 SQL开发效率高但“自动”是把双刃剑——SQL 不可控性能问题难排查复杂查询绕圈子。MyBatis 走的是另一条路它把 JDBC 中那些重复劳动连接管理、参数绑定、结果集映射、异常处理全部封装好但 SQL 语句保留在 XML 或注解里由开发者亲自掌控。所以你理解 MyBatis 的定位就一句话SQL 自己写映射框架帮你干杂活。它不是一个全自动的 ORM 工具而是帮你在 Java 对象和数据库记录之间建立“半自动”桥梁的持久层框架——你要给它说清楚“怎么查”它负责“怎么执行、怎么塞进对象”。我见过不少人在项目里用 MyBatisCRUD 写得飞起但问他“查询返回几万条数据时内存怎么控制”“字段名和属性名不一致时怎么优雅处理”“多表联查怎么避免 N1”一问三不知。问题的根源就是只学了配置没吃透思想。这篇博文不打算给你堆文档我会按自己实战的路线从 ORM 思想、核心配置、SQL 映射三大块把 MyBatis 里面的弯弯绕绕讲明白还会把踩过的坑一并列出来帮你省掉那些不值得浪费的时间。2. 为什么 JDBC 够用我们还要 MyBatis2.1 手写 JDBC 的重复劳动到底有多烦先把时间拨回纯 JDBC 时代。一个最简单的查询用户列表功能你要写多少行代码Connection conn null; PreparedStatement ps null; ResultSet rs null; ListUser users new ArrayList(); try { // 1. 加载驱动老版本还要 Class.forName conn DriverManager.getConnection(url, username, password); // 2. 编写 SQL 并预编译 String sql SELECT id, name, email FROM user WHERE status ?; ps conn.prepareStatement(sql); ps.setInt(1, 1); // 3. 执行查询 rs ps.executeQuery(); // 4. 手动映射结果集——字段取出来setter 塞进去 while (rs.next()) { User user new User(); user.setId(rs.getLong(id)); user.setName(rs.getString(name)); user.setEmail(rs.getString(email)); users.add(user); } } catch (SQLException e) { e.printStackTrace(); } finally { // 5. 手动关闭资源 if (rs ! null) { try { rs.close(); } catch (SQLException e) { } } if (ps ! null) { try { ps.close(); } catch (SQLException e) { } } if (conn ! null) { try { conn.close(); } catch (SQLException e) { } } }这段代码其实不用写完到第 4 步你已经烦了。每张表都要重复这套流程每个字段都要手动 getString、setXxx。表结构一旦调整映射代码跟着改牵一发动全身。真正让人崩溃的是90% 的代码都是模板——连接管理有更好的方式连接池、资源关闭容易漏内存泄漏、异常处理容易吞排查困难。2.2 MyBatis 到底帮你省了什么我用一个对比表格把 JDBC 和 MyBatis 干同一件事的差异摊开来说清楚环节传统 JDBCMyBatis连接获取与释放开发者手动管理框架内部处理自动从连接池获取并归还SQL 执行硬编码在 Java 代码里写在 XML 映射文件中改 SQL 不用改 Java参数绑定ps.setXxx逐一手动设置#{}占位符自动完成且自带类型转换结果集映射rs.getXxx逐字段读取resultType/resultMap自动映射成对象动态 SQL字符串拼接易错、有注入风险if、where、foreach等标签方式缓存无内置一级缓存和可扩展的二级缓存分页手写 LIMIT不同数据库语法还不同配合分页插件统一处理你不用把 MyBatis 想得多高深它本质上就是一个“JDBC 封装器”。核心两条链路一条是SqlSessionFactory 创建 → SqlSession 获取 → Mapper 接口动态代理 → 执行 SQL另一条是XML 映射文件解析 → MappedStatement 注册 → 参数处理 → SQL 执行 → 结果集映射。理解这两条链路后面无论遇到什么诡异问题你都有了排查的方向。2.3 选型思考为什么不是 Spring JDBC Template不是 Hibernate每个框架都有自己的生态位。Spring JDBC Template 比原生 JDBC 省事但结果集映射依然要自己写 RowMapper复杂对象组装能力几乎为零。Hibernate 让开发者不写 SQL但代价是学习曲线陡峭而且一旦遇到特殊查询Hibernate 的 HQL 反而绕圈子市面上大量老系统最终都被迫引入自定义 SQL功能来救火。MyBatis 恰好卡在中间——它不会替你做所有决定但把你必须做的决定控制在最小范围。你用 MyBatis 写一条 SQL执行计划和最终的 SQL 语义都是确定的这对性能调优来说格外重要。数据库是最终瓶颈的系统SQL 可控性就是生命线。很多大厂的核心交易系统里MyBatis 的使用率反而高于 Hibernate原因就在这里越复杂的系统越需要 SQL 的确定性。3. 核心配置逐项拆解每一项背后都有讲究3.1 拿到 MyBatis 依赖后第一件事不是写代码这步看着我啰嗦实际坑不少。Maven 项目引入依赖很简单dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.16/version /dependency版本号要留意。3.5.x 系列是当前主流但不同小版本对 JDK 版本要求不同比如 3.5.10 之后对 JDK 8 依然兼容而更早的 3.4.x 对 JDK 17 可能就有兼容问题。如果你用的是 Spring Boot更推荐 mybatis-spring-boot-starter 这个整合包版本跟着 Spring Boot 的版本走省心不少。数据库驱动别搞漏了。MySQL 8 用com.mysql.cj.jdbc.DriverMySQL 5.x 用com.mysql.jdbc.Driver这俩在 8.x 驱动版本下都有但搞混了会直接报 ClassNotFound。连接地址也别忘带参数比如serverTimezoneAsia/ShanghaiuseSSLfalsecharacterEncodingutf8时区不配查询时间字段会有 8 小时的时差这就是很多新人我明明存了正确时间查出来却不对的经典原因。3.2 mybatis-config.xml 配置逐个说清楚MyBatis 的主配置文件有一个严格的标签顺序这个顺序在 DTD 里写死了。我曾经见过同事把typeAliases放在environments后面程序一启动就报错提示标签顺序不对。所有标签的正确顺序是properties、settings、typeAliases、typeHandlers、objectFactory、objectWrapperFactory、reflectorFactory、plugins、environments、databaseIdProvider、mappers一个实际可用的配置长这样?xml version1.0 encodingUTF-8 ? !DOCTYPE configuration PUBLIC -//mybatis.org//DTD Config 3.0//EN https://mybatis.org/dtd/mybatis-3-config.dtd configuration !-- 读取外部 properties 文件里面放数据库连接信息 -- properties resourcedb.properties/ settings !-- 下划线转驼峰数据库 user_name 自动映射 Java 的 userName -- setting namemapUnderscoreToCamelCase valuetrue/ !-- 开发时打印 SQL方便调试生产环境建议关闭 -- setting namelogImpl valueSTDOUT_LOGGING/ /settings !-- 类型别名后面 XML 里写 resultType 就可以用短名 -- typeAliases package namecom.example.demo.entity/ /typeAliases environments defaultdevelopment environment iddevelopment transactionManager typeJDBC/ dataSource typePOOLED property namedriver value${driver}/ property nameurl value${url}/ property nameusername value${username}/ property namepassword value${password}/ /dataSource /environment /environments mappers package namecom.example.demo.mapper/ /mappers /configuration逐个说重点。properties标签的价值不只是把数据库配置外置更关键的是它可以实现环境隔离本地开发连本地库、测试环境连测试库、生产环境连生产库只需要在启动时加载不同的 properties 文件Java 代码完全不用动。settings里的mapUnderscoreToCamelCase我建议任何项目都打开。数据库规范一般要求字段名下划线user_nameJava 规范要求属性名驼峰userName这个开关打开后MyBatis 默认就会自动完成转换。不打开的话你有两种选择给查询列起别名SELECT user_name AS userName或者用resultMap显式映射。第一种写法冗长第二种写起来繁琐还不如一行配置解决。typeAliases是给 XML 文件减负用的。没有别名时你写resultTypecom.example.demo.entity.User有别名后写resultTypeUser。注意如果配置了包扫描别名默认就是类名的首字母小写形式。你可以在实体类上使用Alias(xxx)注解来自定义别名但一般默认就够了。environments里要注意transactionManager的两种类型JDBC是让 MyBatis 直接管理事务commit/rollback 都调 JDBC 接口适合非 Spring 环境MANAGED是让容器管理事务在 Spring 整合环境中通常使用 Spring 的事务管理器这里配成JDBC问题也不大因为 Spring 事务会接管连接。真正的关键是dataSource类型UNPOOLED每次请求都新建连接性能差POOLED使用自带连接池简单项目够用JNDI在 Web 容器中使用。如果用 Spring Boot你通常不在这里配数据源而是由 Spring 的DataSource直接注入。3.3 Mapper 映射文件的注册方式决定了你 90% 的报错来源mappers标签的配置方式有四种踩坑概率从高到低排列package包扫描package namecom.example.demo.mapper/最推荐。它会自动注册包下所有接口对应的 XML 映射文件前提是 XML 文件与接口同名且放在同包目录下或者在 resources 目录中保持相同路径。resource路径mapper resourcemapper/UserMapper.xml/最经典适合 XML 文件集中在 resources 下的场景。class接口名mapper classcom.example.demo.mapper.UserMapper/必须满足 XML 与接口同包同名否则 XML 不会被加载。url绝对路径基本不用不推荐。我用 Maven 项目时的标准做法是Java 接口放src/main/java/.../mapper/UserMapper.javaXML 映射文件放src/main/resources/mapper/UserMapper.xml。乍看不在同一个目录但因为 Maven 会把 resources 目录的内容也打进 classpath最终在 target/classes 下它们还是同包的。所以你也可以在接口上方用Mapper注解标记由 MyBatis 自动扫描但 XML 文件必须落在对应包路径下才稳妥。这个配置最容易出现的问题就是接口方法都定义了一调用就报Invalid bound statement (not found)。这个错误 99% 的原因是 XML 文件没有被读取要么是包扫描路径配错要么是 XML 文件放错了位置。排查思路后面第 5 节我会单独讲。3.4 SqlSessionFactory 的构建代码别写死在业务里有了配置文件Java 端需要一个入口来创建 SqlSessionString resource mybatis-config.xml; InputStream inputStream Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory new SqlSessionFactoryBuilder().build(inputStream);SqlSessionFactory是一个应用级别应该只创建一次的对象它内部持有所有 MappedStatement、配置信息、缓存信息创建代价不小。如果你每次操作都 new 一个连接池、缓存全部白搭性能会成倍劣化。正确做法是用单例或交给 Spring 容器管理一个 SqlSessionFactory Bean。SqlSession则不同它不是线程安全的只应该在方法作用域内使用用完后必须关闭。这就像数据库连接一样——用完不还连接池迟早枯竭。用 MyBatis 原生的方式写try (SqlSession session sqlSessionFactory.openSession()) { UserMapper userMapper session.getMapper(UserMapper.class); User user userMapper.selectById(1L); }try-with-resources 确保 SqlSession 自动关闭这是最干净的方式。如果是 Spring 整合环境SqlSession 的创建和关闭由框架管理但底层原理一样。4. SQL 映射文件从 namespace 到 resultMap 的完整实践4.1 namespace 不是摆设它是绑定接口的唯一凭证每个 XML 映射文件都有一个根元素mapper它的namespace属性必须写 Mapper 接口的全限定名mapper namespacecom.example.demo.mapper.UserMappernamespace 的作用有两个。第一它把 XML 中的每条 SQL 和接口方法绑定起来MyBatis 启动时会扫描这些映射生成 MappedStatement接口调用时通过动态代理找到对应的 Statement ID。Statement ID 格式就是namespace . id例如com.example.demo.mapper.UserMapper.selectById。第二它把多个 XML 映射文件隔离在各自命名空间中避免方法 ID 冲突。如果你不写 namespace或者写的和接口不一致调用接口方法时就会出现Invalid bound statement或者TooManyResultsException之类的报错。4.2 select 标签的完整参数说明以一个最常用的查询为例select idselectById resultTypeUser parameterTypelong SELECT id, user_name, email, age, create_time FROM user WHERE id #{id} /selectid对应接口方法名必须一致。parameterType可以省略MyBatis 能通过接口方法的反射推断出来但写上也不影响阅读。resultType是映射目标类型它只配置了类MyBatis 会自动将列名与属性名按规则映射配合mapUnderscoreToCamelCase开关效果更佳。#{id}是预编译占位符MyBatis 会把它解析成?然后通过 JDBC 的setXxx方法设置参数这个过程天然防止了 SQL 注入。这里要特别强调#{}与${}的天壤之别。#{}是预编译参数占位符安全${}是字符串直接拼接如果参数来自用户输入会产生 SQL 注入风险。我见过有人图方便在 ORDER BY 子句里拼${orderBy}结果被注入了一段; DROP TABLE user;——生产环境的数据直接没了。如果必须动态排序用白名单校验public void query(String orderBy, Direction dir) { // 只允许传入 id、create_time 等固定字段并映射 String safeColumn allowList.getOrDefault(orderBy, id); // 排序方向只允许 ASC/DESC 枚举 }${}不是完全不能用但只能在完全受控的场景下用比如动态列名、表名且必须经过校验。任何直接拼用户输入的写法都是往坟头蹦迪。4.3 参数传递的几种姿势别再只会 ParamMapper 接口方法传参数常见有三种方式第一种单个参数非 POJO 类型直接传User selectById(Long id);XML 里#{任意名字}都能取到值。第二种多个参数必须用Param命名ListUser selectByNameAndAge(Param(name) String name, Param(age) Integer age);不使用Param时MyBatis 会默认生成param1、param2这种位置名称XML 里写#{param1}也能取到但可读性极差我强烈不建议这样做。Param本质上给每个参数起了一个更强的名字推荐每个多参数方法都加上。第三种直接传 POJO 或 MapListUser selectByCondition(UserQuery query);XML 里可以写#{name}、#{minAge}MyBatis 会自动从对象的 getter 中取值。Map 传参虽然灵活但可读性很差团队协作时别人根本不知道这个 Map 里需要哪些 key所以遇到复杂条件查询优先定义一个查询对象。4.4 resultType 与 resultMap 的选择别总觉得 resultMap 万能resultType处理简单映射足够但遇到列名和属性名对不上、嵌套对象、一对多集合这些场景时就必须上resultMap。一个典型场景查询用户及其订单列表。resultMap iduserWithOrdersMap typeUser id columnid propertyid/ result columnuser_name propertyuserName/ result columnemail propertyemail/ !-- 一对多一个用户对应多笔订单 -- collection propertyorders ofTypeOrder id columnorder_id propertyid/ result columnorder_no propertyorderNo/ result columnamount propertyamount/ /collection /resultMap select idselectUserWithOrders resultMapuserWithOrdersMap SELECT u.id, u.user_name, u.email, o.id AS order_id, o.order_no, o.amount FROM user u LEFT JOIN orders o ON u.id o.user_id WHERE u.id #{id} /select这里有三个关键细节。第一id标签不仅是主键标识更是一个去重标志。MyBatis 拼接嵌套结果时会对这个 id 做判断如果用户已经构建过后续同 id 的记录只追加到对应订单列表中。第二collection里的列一定要通过AS取别名否则两个表都叫id映射结果必定错乱。我在实际项目中已经见过太多次找不到 orderId的报错最后发现就是列名冲突。第三resultMap定义的位置最好在mapper根元素下、SQL 语句之前同一个 map 可以被多个查询复用。resultType和resultMap只能二选一不能同时写。如果你定义了resultMap就不要再用resultType否则 MyBatis 会直接报错。4.5 insert、update、delete 与事务边界写操作的 XML 结构大体相似但有个高频需求必须掌握插入一条记录后马上要拿到自增主键。MySQL 中这么做insert idinsertUser parameterTypeUser useGeneratedKeystrue keyPropertyid INSERT INTO user (user_name, email, age) VALUES (#{userName}, #{email}, #{age}) /insertuseGeneratedKeystrue表示使用数据库生成的主键keyProperty指定把生成后的主键值回填到入参对象的哪个字段上。执行完 insert 后user.getId()直接就是新记录的主键。如果不写这两个属性你得再查一次或者拼SELECT LAST_INSERT_ID()纯属绕远路。还有selectKey这种老式写法目前在 MySQL 自增主键场景已经没必要但如果你是 Oracle 序列或者其他不支持自增的数据库可以这样insert idinsertUser selectKey keyPropertyid orderBEFORE resultTypelong SELECT my_sequence.NEXTVAL FROM DUAL /selectKey INSERT INTO user (id, user_name, email) VALUES (#{id}, #{userName}, #{email}) /insertupdate 和 delete 没什么神秘感#{id}传值即可。但事务边界这个概念必须强调MyBatis 原生环境下sqlSession.commit()才真正提交事务。执行 update/delete 之后如果不 commit其他连接是看不到数据变化的而且连接归还到连接池后下一次复用这个连接时你可能会看到脏数据。Spring 环境下用Transactional管理事务但底层原理是一致的。4.6 动态 SQL让查询条件不再写死动态 SQL 是 MyBatis 最实用的功能之一。最简单的按条件查询select idselectByCondition resultTypeUser SELECT id, user_name, email, age, create_time FROM user where if testuserName ! null and userName ! AND user_name LIKE CONCAT(%, #{userName}, %) /if if testage ! null AND age #{age} /if if teststartTime ! null AND create_time gt; #{startTime} /if /where ORDER BY id DESC /selectwhere标签有两个隐藏能力如果里面所有条件都不成立它不会拼接WHERE关键字如果第一个条件成立且以AND开头它会自动把AND去掉。换句话说你不用再写WHERE 11这种老式技巧where自动帮你处理掉了。set标签同理用于 update 动态语句会自动去掉末尾逗号update idupdateUser UPDATE user set if testuserName ! nulluser_name #{userName},/if if testemail ! nullemail #{email},/if if testage ! nullage #{age},/if /set WHERE id #{id} /update批量插入用foreachinsert idbatchInsert INSERT INTO user (user_name, email, age) VALUES foreach collectionlist itemuser separator, (#{user.userName}, #{user.email}, #{user.age}) /foreach /insertcollectionlist表示入参是 List 类型item是每次遍历的引用名separator是元素之间的分隔符。批量插入时注意 SQL 的长度限制和批量条数一次别整太多3000 条已经是不少 MySQL 默认max_allowed_packet的临界值了稳健一点 500–1000 条一批。choose、when、otherwise组合可以应付优先级条件select idselectByPriority resultTypeUser SELECT * FROM user where choose when testid ! null id #{id} /when when testuserName ! null user_name #{userName} /when otherwise status 1 /otherwise /choose /where /select动态 SQL 相当于给 SQL 加了一层逻辑模板能力让一条语句适配多种查询场景。它会成为维护重点所以写的时候一定要保持每个if条件逻辑简单别层层嵌套否则三个月后你自己都看不懂要查什么数据。5. 项目中踩过的坑整理成速查表给你5.1 经典报错汇总与解决思路报错信息真实原因解决办法Invalid bound statement (not found)Mapper 接口与 XML 映射没绑定成功检查 namespace 是否完全匹配接口全限定名检查 XML 是否在 resource 路径下正确加载检查 Spring 扫包路径是否覆盖 Mapper 接口TooManyResultsException查询返回了多条记录但方法返回值是单个对象SQL 查询条件需要能唯一定位一条或把方法返回类型改为 ListUnknown column user_name in field list数据库字段名写错或大小写问题核对表结构MySQL 在 Linux 下表名字段名区分大小写Parameter xxx not found多参数方法没加 Param给每个参数加上 Param 注解org.apache.ibatis.binding.BindingException接口绑定出问题与 Invalid bound statement 排查思路类似重点看包扫描配置日期数据差了 8 小时 / 时区不对JDBC URL 没加 serverTimezone 参数URL 加上serverTimezoneAsia/Shanghai5.2 N1 查询问题新手最容易沾沾自喜的坑我的查询用户及其订单例子如果写成先查用户列表再循环查每个用户的订单就是一个典型的 N1 问题一条主查询 N 条子查询数据量大时数据库连接和响应时间直接爆炸。解决方式有三种第一种联表一次查完用collection拼结果前面已经写过。适合父子数据量都不大的场景。第二种先查主表再查关联表最后在内存中组装。先查询全部用户SELECT * FROM user再根据用户 ID 集合查订单SELECT * FROM orders WHERE user_id IN (...)然后在 Java 代码里把订单塞进对应用户。这个方法我用得最多因为 MySQL 对 IN 的优化出色而且每次查询返回的数据量可控。第三种使用分步查询加懒加载collection select...的方式但是这个方案在循环中还是会发起多次查询实际性能可能更差一般不推荐在核心列表接口里用。5.3 分页实现的正确姿势MyBatis 原生没有内置分页插件最土的方法是自己拼LIMIT #{offset}, #{pageSize}。但这有两个问题不同数据库方言不同每次都要单独计算 count(*) 和 offset。更推荐使用 PageHelper 这类分页插件它基于 MyBatis 的拦截器机制自动拦截带有 Page 参数的查询改写 SQL 并执行 count 查询。我个人的实践体会是分页插件虽方便但遇到复杂 SQL含 group by、union、嵌套子查询时count 结果可能不准确需要手动 SQL 重写或关闭自动 count。所以生产级项目里我会把列表分页拆成两条查询一条查询总数、一条查询数据用自己写的PageResultT对象封装SQL 完全可控逻辑反而简单。分页条数做上限校验超过 100 条直接默认 20防止有人恶意拉全表。5.4 一级缓存与二级缓存用好了是蜜糖用错了是砒霜MyBatis 的 SqlSession 级别一级缓存默认开启同一个 SqlSession 内执行相同 SQL第二次直接从缓存拿结果。听起来很美好但有个大坑如果查询结果变化了一级缓存不会自动感知除非显式调用 clearCache 或执行了 update/insert/delete。在一个长事务里做查询-更新-再查询第二次查询拿到的是缓存中的旧数据就出大问题了。二级缓存跨 SqlSession默认关闭需要你在 XML 中配置cache/才开启。我个人的态度是默认不开二级缓存分布式环境下尤其别开。因为多个应用实例各自维护缓存数据一致性无法保证最好把缓存交给 Redis 这类外部框架。MyBatis 内置缓存适合单机、读多写少、数据一致性要求不高的场景比如配置字典表。我自己早年负责过一个报表模块开了二级缓存后出现统计数字迟迟不更新的诡异问题查了半天才意识到是缓存作祟。从那以后就定了个原则凡是数据敏感、实时性要求高的查询一律不用 MyBatis 缓存改走外部缓存并显式设置过期时间。6. Day46 的作业建议与后续路线到这里MyBatis 的核心三块内容——ORM 思想、核心配置、SQL 映射——你已经全部过了一遍。但这篇文章只能算把你领进门真正让技能长在身上的方式只有一个亲手敲一遍。你可以按这个顺序做一个小练习建一张用户表和一张订单表用原生 JDBC 写一个查询体会重复劳动再用 MyBatis 重写一遍体会封装价值最后实现一个查询用户 订单列表的接口分别用联表 resultMap 和两条查询两种方式实现对比差异。整个过程中把常见报错都触发一遍比如故意配错 namespace、故意漏加 Param、故意把 resultType 写成不存在的类——踩过一遍的坑以后才会真正避开。做完这些我的建议是下一步把 MyBatis 和 Spring Boot 整合起来练手。整合之后你会发现大部分配置都被自动装配了但本文讲的核心原理在整合环境下依然成立无非是 SqlSessionFactory 的创建交给了容器、Mapper 扫描交给了自动配置。如果哪一天排查底层问题你能回头想到这篇文章讲的这些配置和映射机制那说明 MyBatis 这根主线你已经稳稳地攥在手里了。写这篇文章时我回忆了一下自己也曾经为一行#{}写成${}付出了不小的代价也曾经因为resultMap里漏了id导致数据少了好几条却浑然不知。MyBatis 这东西你用好了它能让 CRUD 开发效率翻倍用不好它也能在深更半夜给你上一课。希望这篇内容能让你少熬几个夜那就值了。