
做Java后端这几年几乎每个项目里都逃不开一个MyBatis。不管你是刚学Spring Boot还是已经在生产环境摸爬滚打持久层框架始终是绕不开的核心环节。MyBatis作为一个半自动的ORM框架既不像JDBC那样需要你手写大量样板代码又不像全自动ORM那样把SQL控制权完全拿走。它真正解决的是“SQL与Java对象的映射”这个麻烦让你既能手写SQL精细调优又不用为结果集那套枯燥的set/get浪费精力。这篇内容我会从底层原理讲到实际使用再整理成高频面试题的答案版本。全程用自己的实战视角来讲有些是踩坑踩出来的经验有些是看源码总结出来的理解。希望对正在学MyBatis的开发者或者准备面试的朋友有点实际帮助。1. 为什么搞持久层框架总绕不开MyBatis1.1 JDBC的重复劳动谁写谁知道先回想一下最原始的JDBC写法。要查一条用户数据你得注册驱动、获取Connection、创建PreparedStatement、拼接条件参数、执行查询、遍历ResultSet取值最后还要依次关掉ResultSet、Statement、Connection。一个简单的查询就能写出十几行甚至二十行代码而且百分之八十是机械重复。如果表结构稍微变一下或者字段从user_name改成userName你还得去翻代码里所有setString、getString的位置。SQL和Java代码之间没有约定列名对应关系的维护全靠眼睛。这还只是一个单表操作如果是多表关联、分页、批量更新JDBC的代码量会更让人头疼。我早期在维护一个库存系统时数据库表里有四十多个字段每次写一个insert都要按字段顺序挨个传入参数结果有一次把skuId和skuCode传反了查了一整天数据才发现。这种问题在JDBC时代太常见了本质上就是“参数绑定”和“结果映射”没有结构化全靠手工拼写容易出错。1.2 半自动ORM的设计取舍Hibernate这类全自动ORM的思路是“映射一切”。你只需要定义实体类的关系框架帮你生成SQL帮你管理Session帮你做级联操作。听起来很美但一旦遇到复杂查询、分页SQL优化、上百行报表SQL的时候全自动模型反而很难受。开发者为了性能被迫去写HQL或原生SQL绕来绕去最后还是要直面SQL。MyBatis则走了另一条路SQL我自己写映射关系你来管。它保留了JDBC的参数预处理、结果集映射细节但通过XML或注解把SQL和Java方法绑定起来。这种方式的好处是SQL完全可控Oracle还是MySQL方言差异你自己说了算调优空间大DBA给的执行计划可以直接对标学习成本低你只需要理解“接口方法 - SQL - 结果集”这条链路和Spring事务、连接池配合良好没有全自动框架那种复杂的对象生命周期管理。当然它也有缺点比如多表关联时映射配置要自己写表和实体字段多的时候xml配置文件冗长。但总体来看国内大部分互联网团队更喜欢这种“半自动”风格因为控制力比省代码更重要。尤其在流量比较大的系统里SQL性能优化永远比开发效率优先级高MyBatis恰好满足了这种诉求。2. MyBatis核心执行原理一条SQL从Mapper接口到数据库2.1 启动阶段配置解析与MappedStatement构建MyBatis启动入口通常从SqlSessionFactoryBuilder开始。它读取全局配置文件比如mybatis-config.xml或Spring Boot里的application.yml配置然后把DataSource、TransactionFactory、Mapper映射文件里的SQL节点全部解析成内部对象。关键对象是MappedStatement。每一条SQL语句不管写在XML里还是注解里最终都会对应一个MappedStatement里面保存了这条SQL的id、parameterType、resultType、sqlSource、缓存配置、statementType等信息。MyBatis将它缓存到Configuration对象中后面每次调用Mapper方法时都会根据方法签名找到对应的MappedStatement。这个启动阶段很多人都忽略了但面试里问到“MyBatis为什么能通过Mapper接口直接执行SQL”时本质上就要讲清楚启动阶段已经把你定义的接口方法与XML中的SQL绑定好了运行阶段只是取用和参数替换。以mapper的XML文件为例mapper namespacecom.example.demo.mapper.UserMapper select idselectUserById resultTypecom.example.demo.entity.User select id, user_name as userName, age from t_user where id #{id} /select /mapper解析时namespace加上select标签的id就构成“com.example.demo.mapper.UserMapper.selectUserById”这就是Mapper接口全限定名方法名的唯一标识。这样当调用UserMapper.selectUserById()时MyBatis能精准定位到MappedStatement。2.2 调用阶段Mapper代理、Executor与StatementHandler真正执行过程不是直接调实现类因为Mapper接口并没有实现类。MyBatis用JDK动态代理生成了一个代理对象。你没看错Spring里经常提到的JDK动态代理MyBatis在Mapper层也用得很熟练。每次调用Mapper接口方法时实际进入MapperProxy的invoke方法。invoke内部根据方法构建MapperMethod对象MapperMethod里封装了SqlCommand和MethodSignature。SqlCommand记录了这个方法对应哪条SQL增删改查哪种类型MethodSignature则处理参数和返回值类型。接着执行链会走Executor。Executor才是真正的执行器它有三个常见实现SimpleExecutor每执行一次SQL就创建一个新的Statement用完后关闭ReuseExecutor会对相同SQL复用Statement内部用Map存SQL字符串和Statement对象BatchExecutor适合批量更新把多个SQL攒批执行减少网络开销。如果你开启了二级缓存Executor还会被CachingExecutor装饰先走缓存再走真正的执行器。这步就是典型的装饰器模式。Executor往下会交给StatementHandler处理StatementHandler主要负责创建JDBC的Statement、绑定参数、执行SQL、处理结果集。而ParameterHandler和ResultSetHandler就是在这个环节完成参数绑定和结果转换的。整个调用链路我习惯记成MapperProxy - MapperMethod - SqlSession - Executor - StatementHandler - ParameterHandler - ResultSetHandler面试时如果能把这个链路画出来面试官对你的源码理解程度基本就有数了。2.3 参数映射与结果集映射背后的TypeHandler参数映射不是简单的“把#{id}替换成值”。MyBatis会通过ParameterHandler遍历参数对象对每个参数使用对应的TypeHandler完成Java类型到JDBC类型的转换。比如传入一个User对象内部有id、name、age属性那么执行时就要把user.id、user.name、user.age分别作为PreparedStatement参数按顺序设置。TypeHandler的作用就在于Java中的String对应JDBC的VARCHARJava中的LocalDateTime对应JDBC的TIMESTAMP枚举类型则需要自定义TypeHandler才能正确入参和出参。结果集映射同理。查询返回ResultSet后ResultSetHandler会按列名或列索引取值再通过TypeHandler转换为Java类型最后赋给目标对象的属性。这里有一个高频问题为什么MyBatis要求数据库字段用下划线命名时建议开启mapUnderscoreToCamelCase因为resultType自动映射是基于列名和属性名一一对应的。如果数据库列名是user_nameJava属性是userName不开启驼峰映射时就需要写resultMap显式映射或者SQL里as一个别名。记住mybatis: configuration: map-underscore-to-camel-case: true生产项目中这个配置几乎是必开的。2.4 一级缓存与二级缓存一级缓存是SqlSession级别的缓存。同一个SqlSession内执行相同的SQL和相同参数时第二次不会直接查询数据库而是从一级缓存中取结果。这里的关键是缓存keyMyBatis用CacheKey来封装SQL语句、参数、环境等信息只要其中任何一项变化缓存key就不同就无法命中。一级缓存默认开启而且你无法关闭它只能通过配置调整级别。最坑的一点是手动commit、事务提交或执行了更新的SQL后一级缓存会清空。如果你在一个事务中先查询再更新再查询第二次查询通常不会命中缓存而是要重新查库。二级缓存是namespace级别的缓存也就是跨SqlSession共享。它需要你手动开启并且在Mapper XML中加入cache标签。实现上每次查询结果会先放到二级缓存之后任何一个SqlSession执行相同的查询都能直接命中。但我在实际项目里一般不太推荐开二级缓存尤其是多表关联、多数据源场景很容易出现脏数据。因为二级缓存默认不感知其他表的改动如果你通过别的方式修改了关联表数据二级缓存里可能还是旧数据。改成查询优先级最高的场景下也很容易碰到缓存穿透问题。除非是那种极少变化的字典表数据否则真不如老老实实做业务级缓存比如把热点数据放到Redis里管理。2.5 动态SQL是怎么解析执行的动态SQL是MyBatis的一大亮点。if、where、foreach、choose这些标签本质上会被解析成一棵SqlNode树。那棵树的每个节点负责处理一种逻辑最终在运行时根据传入参数拼接SQL片段。比如select idqueryUserList resultTypecom.example.demo.entity.User select * from t_user where if testname ! null and name ! and user_name like concat(%, #{name}, %) /if if testage ! null and age gt; #{age} /if /where /select在执行阶段DynamicSqlSource会遍历SqlNode树逐个判断条件是否成立再把这些SQL片段拼成一个最终的BoundSql。所以你在MyBatis里写动态SQL并不是先拼好再执行而是每次执行时动态决定的。这也解释了为什么动态SQL里的参数有时会影响SQL结构从而影响一级缓存CacheKey。3. 实操落地Spring Boot项目从0到1整合MyBatis3.1 工程准备依赖引入与配置如果你用Spring Boot最简单的方式就是引入官方starterdependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId /dependencySpring Boot 3.x对应版本一般是3.x开头Spring Boot 2.x则对应2.x版本。具体version可以查官方文档对齐你的Spring Boot版本。配上数据库驱动依赖比如MySQLdependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency接着在application.yml里配置数据源和MyBatisspring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 hikari: maximum-pool-size: 20 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true这里mmolde的mapper-locations告诉MyBatis去哪里找XML文件type-aliases-package可以让你在XML里写resultType时直接用类名简称不用写全限定路径。3.2 写一个完整的CRUD实体、Mapper接口、XML映射实体类public class User { private Integer id; private String userName; private Integer age; // 省略getter/setter }Mapper接口public interface UserMapper { User selectById(Integer id); ListUser selectPageList(Param(offset) int offset, Param(size) int size); int insert(User user); }XML映射文件放在resources/mapper/UserMapper.xmlmapper namespacecom.example.demo.mapper.UserMapper select idselectById resultTypeUser select id, user_name as userName, age from t_user where id #{id} /select select idselectPageList resultTypeUser select id, user_name as userName, age from t_user order by id limit #{offset}, #{size} /select insert idinsert useGeneratedKeystrue keyPropertyid insert into t_user(user_name, age) values(#{userName}, #{age}) /insert /mapper如果你开启了驼峰映射那么select中的user_name列会直接映射到userName属性这里的as userName不写也问题不大。但为了兼容老同事的习惯或者后续可能关闭驼峰映射的场景我倾向于在SQL里显式as。3.3 动态SQL示例条件查询与批量插入条件查询select idselectByCondition resultTypeUser select * from t_user where if testuserName ! null and userName ! and user_name like concat(%, #{userName}, %) /if if testage ! null and age #{age} /if /where /select这里要注意一个细节where标签会自动处理首尾的AND和OR。如果你用普通where条件就要自己写where 11既不优雅又有SQL注入风险。所以能用where标签尽量用它是专门为动态SQL设计的。批量插入insert idbatchInsert parameterTypelist insert into t_user(user_name, age) values foreach collectionlist itemuser separator, (#{user.userName}, #{user.age}) /foreach /insertforeach里的collection要跟接口参数名匹配如果你在Mapper接口用了Param注解这里就是对应的注解名。比如接口写成batchInsert(Param(list) List list)那xml里collectionlist就没问题。如果不加Param默认名称可能是list但建议规范写法还是显式加Param避免歧义。3.4 注解与XML的选择MyBatis也支持注解方式写SQLSelect(select * from t_user where id #{id}) User selectById(Integer id);这种方式适合特别简单的查询复杂SQL、动态SQL、多行结果映射则明显吃力。XML的优势在于动态SQL标签完善语法清晰不需要编译改SQL后热加载更方便复杂resultMap的声明更直观DBA或运维也能看懂独立的XML映射文件。我的个人习惯是单表简单CRUD用注解业务复杂查询、报表、多表关联统一用XML。混用也没有问题只要风格一致即可。3.5 分页插件PageHelper的那些事手写分页用limit offset就行但多条件动态分页时每次都要处理count和limit代码会变得啰嗦团队里也容易出现“这个查询到底分页没有”的误会。所以很多项目直接用PageHelper。引入依赖dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId /dependency使用PageHelper.startPage(pageNum, pageSize); ListUser userList userMapper.selectByCondition(userName); PageInfoUser pageInfo new PageInfo(userList);PageHelper本质上是一个MyBatis的拦截器它会拦截Executor的query方法在SQL执行前解析你的原始SQL然后生成一条count查询和一条带limit的分页查询。这里有个注意点PageHelper.startPage必须写在紧接着的第一条查询之前如果你在中间执行了其他SQL分页效果就会错乱。4. 常见面试题深度解读不只是背答案4.1 #{}和${}的区别SQL注入的关键点这是MyBatis面试题中最经典的题没有之一。#{} 是预编译占位符最终会解析成JDBC的?由PreparedStatement进行参数绑定安全。 ${} 是字符串拼接直接将参数值拼入SQL存在SQL注入风险。例如select * from t_user where id #{id}执行时JDBC会把传入值作为参数传给数据库不管传什么都不会改变SQL语句结构。而select * from t_user order by ${orderBy}这种场景你只能用${}因为order by的列名、排序方向是不允许参数化的。这也意味着这里有注入风险必须对传入内容做强校验比如用白名单校验列名。面试时你还可以主动提到MySQL的PreparedStatement虽然有服务端预编译和本地预编译的区别但MyBatis配合JDBC驱动时参数绑定是安全的。这句话能显示出你对底层原理有所了解。4.2 一级缓存和二级缓存的区别与脏读风险回答框架一级缓存是SqlSession级别默认开启作用域是一次会话。同一个SqlSession内相同SQL和参数会命中缓存执行增删改后缓存清空。二级缓存是namespace级别所有SqlSession共享需要手动开启。它是通过装饰器模式对Executor进行包装的。风险点二级缓存不感知其他namespace下的数据变更多表查询时极易脏读。所以实际使用中要慎重最好只缓存不变或极少变化的字典表。我面试时如果被问到这里还会主动说出一个细节事务不提交时一级缓存虽然生效但在Spring整合环境中每个Mapper操作可能不是同一个SqlSession。Spring管理的事务下一个事务通常对应一个SqlSession如果开了多个事务或者使用编程式事务情况会更复杂。这往往能引起面试官进一步的兴趣。4.3 MyBatis与Hibernate的对比从五个维度回答SQL控制力MyBatis完全交给开发者Hibernate由框架自动生成开发效率简单增删改查上Hibernate高复杂SQL上MyBatis更直接性能调优MyBatis更可控Hibernate的N1问题需要用心优化学习曲线MyBatis更低Hibernate的一级、二级、查询缓存加上对象状态管理学习成本高适用场景MyBatis适合互联网高并发、复杂查询系统Hibernate适合企业级管理系统、字段变更频繁且SQL相对简单的后台。这段话回答完后面试官通常会追问“为什么互联网公司更倾向MyBatis”你就可以把性能可控性和复杂SQL优化讲透。4.4 分页插件的工作原理PageHelper这类分页插件基于MyBatis的Interceptor机制实现。它注册在Executor执行query方法时对原始SQL进行处理先解析原始SQL生成count查询SQL生成带limit的分页SQL把Page对象和查询结果绑定实现分页信息返回。有的分页插件会兼容不同数据库方言比如MySQL的limit、Oracle的rownum、PostgreSQL的limit offset这些都是通过dialect类处理的。面试官考察的是你是否理解拦截器机制和SQL方言适配。4.5 延迟加载懒加载的实现原理MyBatis支持延迟加载意思是select关联对象时不会马上执行关联查询而是在真正调用关联对象的getter时才触发查询。实现方式是通过动态代理创建目标对象的代理调用方法时判断是否已加载未加载则执行后续SQL。配置mybatis: configuration: lazy-loading-enabled: true aggressive-lazy-loading: false如果aggressiveLazyLoading为true访问任意属性都会触发整个对象的加载相当于延迟加载失效。面试时最好能说出这两个参数的实际含义。但也要注意延迟加载只是“看起来方便”如果对关联对象循环遍历N次就会变成经典的N1问题。实际项目中我更倾向于显式left join查询一次拿全数据而不是依赖延迟加载。这个点放在后面踩坑里详细说。4.6 Mapper接口没有实现类为什么能直接注入使用答案就是动态代理。MapperScannerConfigurer或MapperScan扫描到Mapper接口后会给接口注册一个MapperFactoryBean然后为它生成JDK动态代理对象。调用过程进入MapperProxy.invoke根据方法生成MapperMethodMapperMethod执行execute方法判断是增删改查哪种类型通过SqlSession的selectList、selectOne、insert、update、delete完成操作。这个回答能直接体现你阅读过源码比单纯背概念有说服力。5. 踩坑实录与性能优化那些文档里没写明的地方5.1 属性映射失效驼峰、列名、resultMap有一个票据系统的案例让我印象很深查询列表时明明SQL能查出数据但返回对象里的某个字段始终是null。排查半天发现数据库列名是bill_no实体属性是billNo而那个环境的全局配置里忘记开启驼峰映射。这种问题在大型项目中经常出现解决方式有几种全局开启map-underscore-to-camel-caseSQL里写别名例如select bill_no as billNo使用resultMap显式映射。显式resultMap虽然能解决复杂映射但代码量会增加很多。我通常的做法是pom里和yml配置里同步确认驼峰开关SQL中不规范字段统一加as别名。5.2 N1查询问题N1就是一次查询查出N条主记录然后每条记录关联查询又要执行一次SQL最终执行1N次查询。这在mybatis的嵌套select中特别容易发生resultMap idorderUserMap typeOrder association propertyuser selectcom.example.demo.mapper.UserMapper.selectById columnuser_id/ /resultMap如果你查出10个订单就会额外执行10次用户查询。解决思路一次性left join查出来用association的嵌套结果映射更合适关闭懒加载、批量查询IN集合对热点查询用缓存兜底。这个场景是面试官最喜欢让候选人分析性能问题的地方你表达能力好一点把“执行计划日志SQL数量”说出来就很有说服力。5.3 二级缓存与分布式环境的坑如果你的应用部署在多台机器上MyBatis自带的二级缓存是基于本地内存的机器之间不共享。高并发下可能出现机器A更新数据机器B缓存未失效读到的还是旧数据多表join的数据变更无法主动清理关联缓存缓存穿透和缓存击穿的处理要自己加逻辑。所以我在做分布式系统时要么不用MyBatis二级缓存要么只用它缓存字典表数据并且配合本地缓存过期时间。真正需要缓存一致性的场景交给Redis做应用层缓存更可控。5.4 常见的性能优化清单基于实际项目经验我整理了一份常用优化清单优化项具体做法效果开启驼峰映射map-underscore-to-camel-case: true减少resultMap冗余配置合理使用动态SQLforeach批量插入避免逐条insert减少网络往返和事务次数防止深分页使用id游标分页减少大offset避免深分页查询慢配置连接池参数合理设置maximum-pool-size和连接超时避免连接耗尽和等待多表查询用join一次查出避免嵌套select和懒加载减少SQL次数应用缓存兜底对热点数据加Redis缓存减少数据库压力深分页这个点值得单独说。limit 100000, 20会让MySQL扫描前10万行再跳过性能极差。优化时可以改成page之前过滤idselect * from t_user where id #{lastId} order by id limit 20前提是你的分页前端传递的是上一页最后一条记录的id这种方案在千万级大表下效果立竿见影。6. 关于执行器与批处理的进一步思考6.1 批处理关掉自动提交很关键很多新手用BatchExecutor时发现批量插入了但还是慢一个重要原因是事务没有批量化提交。JDBC批量插入时如果每条记录后都自动commit性能不会提升反而可能因为事务提交频次太高而更慢。MyBatis实操时批量插入建议开启事务在同一个事务中执行如果数据量太大按500条或1000条分批提交用foreach拼接批量插入而不是循环调用单条insert。经验值是单条循环插入1000条可能要十几秒改成foreach批量insert往往能缩短到几百毫秒。前提是数据库max_allowed_packet要足够大否则SQL太长会被拒。6.2 执行器的选择不是越高级越好默认的SimpleExecutor足够了。ReuseExecutor在很长SQL、频繁SQL执行时有预编译语句复用的优势但会占用更多内存。BatchExecutor适合批量更新场景但在非批处理时反而要维护一批Statement可能造成混乱。如果你没有明确的批量场景老老实实用默认执行器。7. 面试里关于事务和分页的高频追问7.1 为什么Spring事务下MyBatis能拿到同一个连接Spring整合MyBatis后事务管理器会持有数据库连接并把它绑定到当前线程。SqlSessionTemplate在执行操作时会从当前事务同步资源里获取SqlSession保证一个事务内使用的是同一个连接。所以你在Service方法上加Transactional多个Mapper方法执行时都在同一个事务里要么全成功要么全回滚。这点和Spring的DataSourceTransactionManager原理绑定在一起面试时最好能联系起来讲。7.2 分页SQL在不同数据库中的写法分页插件能规避代码中写死方言但面试官可能问MySQLlimit offset, size 或 limit size offset sizeOracle老版本rownum嵌套子查询PostgreSQLlimit size offset offsetSQL ServerOFFSET FETCH。如果你项目是从MySQL迁移到PostgreSQL手写limit的SQL会大量报错。这时用分页插件或者统一SQL方言层是更轻松的选择。我经历过一个系统从MySQL迁移到PostgreSQL改了一周SQL血的教训。7.3 一条SQL执行慢怎么定位MyBatis这一层的问题MyBatis本身不会让SQL变慢但你可以从这几方面排查打印SQL日志确认实际执行的SQL语句和参数用数据库执行计划EXPLAIN分析SQL查看是不是一级缓存失效导致频繁查询分析连接池的等待时间查看是否因为resultMap嵌套结果循环导致内存对象过多。日志配置方面可以在application.yml里开启logging: level: com.example.demo.mapper: debug这样会在日志里打印执行SQL、参数和执行耗时。线上环境注意脱敏SQL日志只保留在开发或测试环境。8. 最后再分享两个实用小技巧第一个是关于Param的使用。我自己习惯在Mapper接口所有多参数方法上都显式加Param哪怕只有一个参数也会加。这样XML里引用参数名时永远明确不会因为编译期参数名丢失导致找不到参数。如果你用的是Java 8以下版本不写Param编译后的参数名可能变成arg0、arg1非常难排查。第二个是关于resultType里用Map。偶尔会有接口直接返回Map查询结果作为临时报表数据很方便。但要注意MyBatis返回的Map默认是大小写敏感的且重复字段名会互相覆盖。如果你要保留精确结构还是建议定义专门的DTO或者使用resultMap。MyBatis这个框架说复杂也复杂说简单也简单。核心就是“SQL映射”这四个字。当你把它从“会用”提升到“理解执行链路”的层面后调试问题、优化性能、准备面试都会顺很多。上面这些内容都是我在实际编码和排查问题过程中慢慢沉淀下来的希望能帮你少走点弯路。如果还有具体场景想聊的评论区见。