
1. 为什么说MyBatis是持久层的务实之选在企业级Java应用开发里持久层框架的选型永远是一个绕不开的话题。从早期裸写JDBC到后来Hibernate、JPA的大行其道再到MyBatis逐步占据主流位置这个演进过程背后其实反映了一个很朴素的道理不同项目需要不同的控制粒度。MyBatis能在国内企业级开发中站稳脚跟靠的不是概念上的宏大而是它那种“SQL在手心里不慌”的踏实感。先说清楚MyBatis到底解决了什么问题。Java操作数据库最原始的JDBC方式要自己管理连接、预编译语句、结果集遍历、异常处理、资源关闭这些样板代码又臭又长而且每个查询都是重复劳动。早期用Hibernate的人应该记得当时最大的痛点不是写代码而是猜SQL。当你用一个复杂的关联查询时Hibernate生成的SQL可能会让你对着日志发呆半天——它到底查了几张表、走了什么索引、为什么加了那么多嵌套子查询你完全控制不了。而MyBatis直接把SQL语句交到你手里你想怎么写就怎么写写完之后还能肉眼审查执行计划这种掌控感对于追求性能和安全的企业应用来说比自动化更重要。不过要强调一点MyBatis并不是“反对ORM”。它属于半自动ORM框架意思是数据映射Mapping已经帮你做完了但SQL生成留给你自己掌握。你只需要定义Mapper接口、写SQL、告诉框架如何把结果集映射回Java对象剩下的参数注入、类型转换、语句预编译框架统统处理掉。这种折中意味着什么意味着你的团队里哪怕是最新来的实习生打开对应的XML文件也能清楚地看到某条业务逻辑背后到底执行了什么样的SQL。再说说适合谁来用。如果你所在的项目是互联网应用、企业管理系统、金融类业务系统对SQL性能敏感、查询逻辑复杂、存储过程使用频繁MyBatis基本就是为这种场景设计的。如果你正在做纯CRUD为主、表结构极其简单、要求快速出原型的内部小系统JPA也完全顶得住。但如果复杂度一上来比如多表联查、分库分表、动态排序、行级权限过滤MyBatis的优势就非常明显了——你可以在XML里亲自编排每一个SQL分支不需要去研究框架的底层生成逻辑。还有一个非常实在的考量团队的排查成本。线上环境出了问题绝大多数时候是数据和SQL的问题。MyBatis项目里你复制日志里的SQL到数据库客户端执行很快就能定位是索引问题、数据问题还是业务问题。这个过程不需要看框架源码不需要分析缓存策略任何人上手都很快。而Java面试题里关于MyBatis的问题也常常出现在各类考察中——缓存机制、动态SQL、一级二级缓存失效场景、#{}与${}的区别这些考察点背后其实都是企业真实项目里反复踩过的坑。我自己面试别人时最爱问的就是“你的项目为什么用MyBatis”,如果面试者只会说“大家都这么用”那就说明他并没有真正理解选型背后的含义。2. 核心机制拆解一条SQL是怎么跑通的既然要拿MyBatis干活就不能只停留在“会用”的层面。搞清楚一次完整SQL执行背后的生命周期排查问题时才能有的放矢。这一章我把MyBatis的核心机制拆开讲一遍尽量用大白话把关键环节说透。2.1 从Mapper接口到SQL执行的完整链路MyBatis的启动过程本质上是在做两件事读配置和建映射。配置文件mybatis-config.xml或者Spring Boot里的application.yml里定义了数据源、类型别名、映射文件位置、缓存开关等基础信息。框架启动时会解析Mapper接口和对应的XML映射文件把每条SQL语句封装成一个MappedStatement对象存放在Configuration里。到这里你的SQL还没真正执行只是在内存里建立了一张“SQL路由表”。当你的业务代码调用Mapper接口方法时比如userMapper.findById(1L)实际上走的是一条代理链路。Mapper接口本身没有实现类MyBatis通过JDK动态代理在运行时为它生成代理对象。这个代理对象拦截到方法调用后会根据方法名找到对应的MappedStatement然后通过SqlSession去执行。SqlSession是MyBatis对一次数据库会话的抽象你可以把它理解成一条“数据库连接的使用凭证”负责获取连接、执行语句、提交或回滚事务、释放资源。值得留意的是MyBatis对数据库连接的获取不是自己管理连接池而是委托给数据源组件。实际项目中我们一般集成HikariCP或DruidMyBatis只负责从数据源 borrow 一个连接执行完毕后归还。整个过程有以下几步获取代理Mapper - 定位MappedStatement - 获取数据库连接 - 绑定会话 - 执行SQL - 处理结果集 - 关闭会话。理解这条链路后你再看“一级缓存失效”、“延迟加载”这类高难度问题心里就有底了。2.2 #{}与${}一个符号差异背后的安全性问题如果你接触过MyBatis面试题一定见过“#{}和${}的区别是什么”这个问题。这不是八股文而是直接关系到SQL注入漏洞和代码可维护性。#{}的方式是预编译参数占位符。MyBatis会把#{value}替换成?然后在PreparedStatement里用setString、setLong这类方法把参数安全地填进去。数据库端看到的是一个参数化的SQL模板值本身永远不会参与SQL语句拼接因此恶意传入 OR 11 --也不会有任何效果。而${}是文本直接替换。MyBatis会把这个位置的文本原封不动拼接到SQL语句中。比如ORDER BY ${orderColumn}如果这个值来源于前端请求参数攻击者只要传一个id; DROP TABLE users--之类的字符串后果不堪设想。所以我的原则是能用#{}的地方坚决不用${}只有排序字段、表名这类不能作为预编译参数的结构性内容才考虑使用${}而且必须做白名单校验。一个很小但常见的坑是#{}里参数名和Param注解不匹配时会报错。早期MyBatis 3.4之前多个参数必须用arg0、param1这种方式引用代码可读性很差。现在的版本里建议统一使用Param(userId) Long userId显式声明参数名XML里写#{userId}即可。这不仅清晰还能避免编译期参数名丢失造成的运行时错误。2.3 参数映射与结果映射内部细节MyBatis的参数映射简单说就是把Java对象里的字段值取出来绑定到SQL占位符上。它内部有一套TypeHandler体系来处理类型转换比如Java的LocalDateTime对应数据库的datetimeJava的Long对应数据库的bigint。这套体系可以扩展如果你项目里有自定义类型比如JSON字段、枚举类型、加密后的字符串都可以实现自己的TypeHandler来统一处理。结果映射则是反向过程把数据库返回的ResultSet里的列映射到目标Java对象的属性上。这里有一个经常被忽略的配置mapUnderscoreToCamelCase。如果数据库列名是user_nameJava属性是userName你可以在配置里开启该选项就不用写一堆columnuser_name propertyuserName的显式映射了。注意这个开关只对自动映射生效如果你显式指定了resultMap那么一切以resultMap为准。说起resultMap这是MyBatis最具价值的功能之一。它可以帮你处理多表查询结果的嵌套映射比如一对多、多对一关联查询中使用association和collection标签查询列和实体属性不一致时的映射懒加载配置只需要在映射关系中声明fetchTypelazy我见过不少团队用resultMap非常保守只会写简单的字段映射遇到嵌套查询就直接拆成多个Mapper方法然后在Service层拼接。这也不是不行效率还高但会丢失一部分MyBatis的语义表达能力。反过来也有人把resultMap用得过于花哨一条SQL里嵌套联查几百行结果集翻倍增长性能反而不如拆开查。这里我的建议是关联深度控制在两层以内能拆单查就拆单查resultMap用于解决列名映射和基本嵌套结构。2.4 动态SQL拼SQL不用再靠字符串动态SQL是我当年爱上MyBatis的直接原因。写JDBC的时候最痛苦的就是处理“有条件的查询”——用户可能填了名称也可能没填可能选了状态也可能没选。传统的做法是拿StringBuilder拼SQL然后小心翼翼地在每个条件前面加AND或OR少一个空格就报错。MyBatis提供了一套标签系统来彻底解决这个问题if判断条件是否成立成立就把SQL片段拼接进去where自动处理WHERE关键字还能智能去掉多余的AND/ORset用于UPDATE语句自动处理SET关键字去掉末尾的逗号choose / when / otherwise类似Java的switch逻辑foreach遍历集合参数常用于IN查询或批量插入trim自定义前缀后缀处理解决where和set不能覆盖的边界情况一个典型的多条件用户查询示例select idfindUsers resultTypecom.example.User SELECT * FROM sys_user where if testuserName ! null and userName ! AND user_name LIKE CONCAT(%, #{userName}, %) /if if teststatus ! null AND status #{status} /if if testdeptId ! null AND dept_id IN ( SELECT dept_id FROM sys_dept WHERE dept_id #{deptId} OR parent_id #{deptId} ) /if /where ORDER BY create_time DESC /select看到没有这个SQL在数据库客户端里几乎可以直接跑不需要额外拼接逻辑条件的管理完全在XML层完成。如果哪天产品经理说“我要加一个按手机号筛选的功能”你只需要在这个查询里加一个if无需修改Java代码。这种维护便利性在企业需求频繁变动的环境里是非常值钱的。不过要提醒一句动态SQL也容易被滥用。我有一个同事写查询时把条件全部兜进if结果一条语句有二十多个分支比Java业务代码还复杂。而且每个if里的条件表达式如果有大量foo ! null and foo ! 读起来非常累。我的建议是业务条件超过五六个时拆分查询方法不要硬塞在一个动态SQL里。热词里面提到的“mybatis条件不生效”大概率就是条件表达式写错了后面在排查章节里详细展开。3. Spring Boot MyBatis 集成落地从零搭一个注册功能现在来看一个完整的落地场景。很多新手的第一个MyBatis练习就是“用Spring Boot MyBatis实现一个最简单的注册功能”这个例子特别适合讲清楚整个链路是如何串起来的。咱们就按这个思路一步步来把环境搭建、配置书写、代码实现都过一遍。3.1 环境准备与依赖引入项目基础结构是Spring Boot 2.7JDK 8以上构建工具用的是Maven。MyBatis和Spring Boot的集成有专门的starter这一步已经把大部分自动化配置封装好了。dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency注意版本适配mybatis-spring-boot-starter 2.x系列对应的是Spring Boot 2.x3.x系列对应Spring Boot 3.x也就是Jakarta EE那一套。如果你项目用了Spring Boot 3千万别往下拉2.x版本的依赖会出现启动失败的情况。接着是配置数据源和MyBatis基础参数spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 hikari: minimum-idle: 5 maximum-pool-size: 15 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: true cache-enabled: false这里有个细节serverTimezone这个参数特别容易踩坑。MySQL 8.0以上默认的时区设置和JDBC驱动有时对不上会报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized之类的错误。直接固定成Asia/Shanghai就能避免这个问题。另外mapper-locations指定了XML映射文件的位置。如果你用的是Maven多模块工程不同模块的mapper目录路径需要写清楚。还有一种常见做法是直接在src/main/resources下新建mapper目录把Mapper XML放进去这也是我推荐的方式——配置文件统一走resources代码走java各归各位。3.2 实体、Mapper接口与XML映射文件的实现注册功能需要一个用户实体一张用户表。表结构大概是这样的CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );对应的Java实体public class SysUser { private Long id; private String username; private String password; private String nickname; private Integer status; private LocalDateTime createTime; // getter/setter 省略 }然后是Mapper接口Mapper public interface SysUserMapper { int insertUser(SysUser user); SysUser findByUsername(String username); }这里出现的Mapper注解有两种等价替代方案一种是在启动类上加MapperScan(com.example.mapper)一次性扫描整个包另一种是每个Mapper接口单独标Mapper。我建议使用MapperScan好处是新增Mapper接口时不用记着加注解也方便统一管理扫描路径。XML映射文件的内容?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.mapper.SysUserMapper insert idinsertUser parameterTypecom.example.entity.SysUser useGeneratedKeystrue keyPropertyid INSERT INTO sys_user ( username, password, nickname, status, create_time ) VALUES ( #{username}, #{password}, #{nickname}, #{status}, #{createTime} ) /insert select idfindByUsername resultTypecom.example.entity.SysUser SELECT id, username, password, nickname, status, create_time FROM sys_user WHERE username #{username} /select /mapper这里面有几点值得展开说明第一namespace必须与你Mapper接口的全限定名一致否则Spring Boot启动时会报“Invalid bound statement”的错误。我见过很多新手在复制别人的XML后忘记改namespace导致接口方法一直找不到映射语句。第二useGeneratedKeys是用来把数据库自增主键回填到Java对象的id属性上。注册用户时后面可能马上要用到用户ID生成其他关联数据没有这个配置你就得多查一次或者手动selectKey非常麻烦。第三create_time这一列在XML里写进去了Java时间字段是用LocalDateTime还是Date会影响类型处理。MyBatis 3.4.5之后对Java 8日期时间API的支持已经内置不需要额外注册TypeHandler直接写就行。3.3 Service层与事务控制注册功能还有一个关键环节事务。用户注册往往不止插入一条用户记录可能还要初始化会员信息、写操作日志等等。Spring Boot里加上Transactional注解就能声明事务边界Service public class UserService { Resource private SysUserMapper sysUserMapper; Transactional(rollbackFor Exception.class) public Long register(RegisterRequest request) { // 校验用户名是否重复 SysUser exist sysUserMapper.findByUsername(request.getUsername()); if (exist ! null) { throw new BusinessException(用户名已存在); } // 密码加密实际项目使用BCrypt SysUser user new SysUser(); user.setUsername(request.getUsername()); user.setPassword(DigestUtils.md5DigestAsHex(request.getPassword().getBytes())); user.setNickname(request.getNickname()); user.setStatus(1); user.setCreateTime(LocalDateTime.now()); sysUserMapper.insertUser(user); return user.getId(); } }事务这里有个老生常谈的坑Transactional只对public方法有效并且自调用同一个类中一个方法调另一个带有事务注解的方法会绕过代理导致事务不生效。所以如果需要内嵌事务方法要么拆到不同的Bean里要么用内部代理调用。另外rollbackFor Exception.class一定要写因为Spring默认只回滚RuntimeException如果你抛的是受检异常事务会静默提交数据就写进去了——这种问题在企业项目里非常隐蔽。我特意在这个简单例子里加入了密码加密步骤算是提醒一下大家存储密码绝对不能明文企业中至少用BCrypt算法而不是简单的MD5。MD5现在用彩虹表很容易被破解。4. 缓存机制深度解析一级缓存与二级缓存在热词里“mybatis缓存”和“mybatis二级缓存实现”出现频率非常高。这一章节就来聊透缓存机制把面试题常问的原理、失效场景、配置方式都梳理出来。4.1 一级缓存SqlSession级别的本地缓存一级缓存是MyBatis默认开启的几乎不需要任何配置。它的作用域是 SqlSession。同一个SqlSession里执行两次完全相同的查询第二次会直接从本地缓存取不再访问数据库。底层实现其实很简单MyBatis在创建SqlSession时会关联一个Executor执行器BaseExecutor里维护了一个PerpetualCache对象本质是一个HashMap。查询流程走到Executor层时会先计算这条SQL的CacheKey然后去这个HashMap里找。CacheKey的计算包括SQL语句本身、参数值、当前环境等信息只要任何一个字段变了CacheKey就不同缓存就命中不了。听上去很美好但一级缓存也有不少坑第一Spring集成下SqlSession的生命周期比想象中短。在Spring Boot MyBatis的整合环境下每次Mapper调用都会开启一个全新的SqlSession方法结束就关闭。这就导致一级缓存的作用域“退化成方法级”同一个方法里查两次才会命中跨方法根本取不到。这也是为什么很多人在实际项目中感觉“一级缓存没什么用”。第二并发访问下一级缓存可能导致脏读。严格来说是“过期读”因为缓存是对每个SqlSession独立的一个会话更新的数据另一个会话看不到。不过由于Spring环境下SqlSession短命这个问题出现的概率会小很多。第三MyBatis会在执行增删改操作时自动清空一级缓存。所以先查询、再更新、再查询的实际执行链路是查询1 - 数据库更新 - 清除缓存查询2 - 数据库。这个特性是合理的防止读到更新前的结果但也意味着缓存命中率不会特别高。4.2 二级缓存namespace级别的全局共享缓存二级缓存的作用域是Mapper的namespace也就是一个Mapper接口下的所有SQL共享同一份二级缓存。它的生命周期由全局Configuration管理可以跨SqlSession共享这正是实际项目里想用的缓存能力。开启步骤如下第一步在全局配置开启缓存开关mybatis: configuration: cache-enabled: true第二步在Mapper XML文件里加入cache/标签mapper namespacecom.example.mapper.SysUserMapper cache evictionLRU flushInterval60000 size512 readOnlytrue/ !-- SQL statements -- /mapper这个标签里几个属性解释一下eviction缓存回收策略LRU最近最少使用是默认值。其他选项有FIFO、SOFT、WEAK实际项目中LRU最够用。flushInterval刷新间隔单位毫秒不配置的话只有当执行了增删改语句才会刷新。size最多缓存对象的数量别设太大JVM内存有限。readOnly如果设为true所有客户端拿到的都是同一个缓存对象的引用速度快但有并发修改的风险设为false时MyBatis会为每个客户端返回对象的副本通过序列化或者深拷贝实现安全但性能开销略大。启用二级缓存后查询会先走二级缓存未命中再到一级缓存最后是数据库。如果一个namespace下的SQL执行了更新这个namespace下的二级缓存会被整体清空。注意这里说的是“这个namespace下”不是全局清空所以不同Mapper之间缓存不冲突。4.3 二级缓存的失效场景与隐藏问题我在真实项目里发现二级缓存的坑比一级缓存多得多嵌套查询导致缓存不一致。如果你使用了association或者collection进行关联映射并且开启了懒加载查询关联数据时会去另一个Mapper执行SQL。这个“另一条SQL”带来的数据更新并不会触发当前namespace的缓存清空逻辑。结果就是主查询缓存了旧数据关联数据已经变了。解决思路是多表关联查询要么不用二级缓存要么只缓存那些极少变更的基础数据表。查询结果必须可以被序列化。如果缓存对象的实体类没有实现Serializable接口并且缓存策略选择了有安全问题意识的副本模式readOnlyfalse运行时就会抛SerializationException。这个错误信息比较晦涩排查起来很浪费时间。建议实体类统一实现Serializable。缓存命中率统计不到。有不少团队兴冲冲开了二级缓存结果检查日志发现命中率极低。原因通常是查询条件过于动态比如带了随机数、时间戳每个CacheKey都不一样缓存形同虚设。这时你得想一想业务上是否真的能缓住高频且重复的查询否则纯粹增加维护复杂度。我的总体建议是二级缓存不是默认选项而是经过分析后的优化手段。企业项目里最值得缓存的往往是字典表、配置表、省份城市这类低频变更数据。越是不变的表缓存收益越高。业务流水类数据哪怕频繁查询也建议放到Redis去缓存不要把压力叠加在MyBatis的内存缓存上毕竟应用实例的内存是宝贵的。5. 高频问题排查实录条件不生效、判空与“不等于”这一章是干货中我最想写的部分。热词里出现了“mybatis条件不生效”和“mybatis等于”这两个关键词背后对应的都是日常开发里频繁踩坑的经验。我把自己遇到过的几个典型问题整理出来一并给出排查思路。5.1 动态SQL条件不生效的三大根源条件不生效表现通常是“我明明写了if为什么查询结果还是把全部数据带出来了或者把不该带的数据带出来了”。根源一OGNL表达式的判断写错。MyBatis的if test表达式使用的是OGNL语法。很多人把Java里的string ! null !string.equals()写进去结果没问题但换成string ! null and string ! 这种更常见的写法时会因为某个字段为null而走向错误的判断。比如写if testuserName ! null and userName ! 这个条件里userName如果是个getter返回nullOGNL会解析为false正常没有问题。但如果你写的是if testuserName ! 当userName为null时OGNL会把null和空字符串比较结果是false条件不成立。换一种写法if testuserName ! null and userName ! 中userName不是空字符串这个判断本身就会因为null报空值处理但如果第一个条件已经短路了问题不大。关键是大家要养成条件里先判断null的习惯。根源二参数类型不匹配导致隐含的默认值。比如查询状态字段Pojo里定义的是Integer数据库字段是int结果前端传了一个字符串“0”MyBatis在类型转换时可能抛出异常也可能转成默认值0但你的业务想表达的是“未传入不用过滤”。这种情况常见于Map参数传递时没有做统一类型处理。所以我建议少用Map传参多用POJO或者DTO参数类型清晰OGNL判断也更容易写对。根源三Mapper接口传参需要加Param。如果你写的是ListUser findUsers(Param(query) UserQuery query)XML里访问属性就得写#{query.userName}if测试是testquery.userName ! null。但如果你没有加ParamXML里直接写#{userName}可能也能解析因为当只有一个参数时MyBatis直接把参数暴露为别名“param1”这种情况在多个参数时就会编译报错。条件不生效的问题往往不是SQL没拼上而是你拼到了一个意想不到的错误分支上。5.2 不等于与判空处理别再被!坑了热词里“mybatis等于”这个说法看起来是在问MyBatis条件中等于和不等于怎么写。很多新手直接从Java思维搬过来写SELECT * FROM order_info WHERE status ! 0这在MySQL里没问题但在MyBatis XML里有一个值得注意的字符转义问题。XML解析时和虽然有特殊含义但!是不需要转义的。真正需要转义的是小于号因为XML标签里的会被当作标签开始。如果SQL里有“小于”条件不能直接写成要写成lt;或者用![CDATA[ ]]包裹起来。select idfindOrders resultTypeOrder SELECT * FROM order_info WHERE create_time lt; #{endTime} /select还有更隐蔽的一个问题判空时用!判断null和空字符串的顺序。我建议按照先null、后空串的顺序写而且使用and而不是两者都支持但and更符合XML习惯。类似这样if teststatus ! null AND status #{status} /if这里特别提醒如果查询字段是字符串且你要表示“未传入时不过滤”那么! 的判断同样不能少。比如用户提交一个空的搜索关键词如果你只判断name ! null这个空字符串就会拼进SQL查出来一堆不该出现的结果。5.3 打印SQL与慢SQL日志排查企业开发里“mybatis配置打印”也是一个高频词。SQL日志打印出来不仅能核对动态SQL拼接结果还能辅助排查参数填充的问题。Spring Boot MyBatis的SQL打印很简单在配置文件里指定mapper接口所在包或XML包日志级别为debuglogging: level: com.example.mapper: debug这样执行SQL时控制台会输出类似 Preparing: SELECT * FROM sys_user WHERE username ? Parameters: admin(String) Columns: id, username, password, nickname, status, create_time Row: 1, admin, xxxx, 管理员, 1, ... Total: 1我最常利用这个日志做三件事核对动态SQL拼接的最终形态。比如where后是否残留了多余的ANDif条件是否漏拼日志里一目了然。核对参数注入情况。看Parameters输出如果某个参数是null就能判断是不是传参没传到位。补充慢SQL监控的采样信息。在中间件层做慢SQL拦截时可以从这行日志里抓出实际执行的SQL文本和数据库慢查询日志做匹配。实际项目中我建议再装一个 p6spy 这类SQL审计工具。它可以在框架层拦截所有SQL并格式化输出还能统计执行耗时。不过要小心性能问题生产环境不要开只在开发联调环境使用。5.4 启动失败与常见环境问题Spring Boot MyBatis最常见的启动失败我总结一下基本就这几类Invalid bound statement (not found)接口和XML映射没匹配上。检查namespace、id是否一致XML是否编译到classes目录下。Failed to configure a DataSource数据源配置缺失或连接地址写错。尤其Git提交时把本地数据库密码改了别人拉代码跑不起来这种问题处理起来不难但很烦。Jackson序列化循环引用这不是MyBatis的问题但如果实体里有关联实体双向引用JSON序列化时会出现无限递归。常见解法是加JsonIgnoreProperties或者使用DTO。版本兼容冲突比如Spring Boot 3 mybatis-spring-boot-starter 2.x或者Java 17 老版本MyBatisMyBatis 3.5.9之前的某些版本对Java 17的支持是有坑的。我的排查思路很简单先看启动栈顶异常再去Maven依赖树中确认版本。Java环境变量配置问题单独提一嘴。热词里也有“java环境变量配置详细教程”、“win11系统java环境配置”这些搜索其实和MyBatis本身关系不大但在团队协作中经常遇到有人环境没配好导致项目编译不过。我的建议是JDK版本统一用17或21看Spring Boot版本Maven和IDEA配置里的JRE设置也都统一指向同一个JDK避免本地环境差异引发“我这边能跑你那边不能跑”的经典问题。6. 进阶能力源码视角、拦截器与多商户项目实战到了这一章我想聊一些能让你从“会用”走向“懂它”的内容。重点包括源码阅读路径、自定义拦截器实现行级权限以及基于Spring Boot MyBatis的多商户跨境商城项目中需要注意的架构问题。6.1 从源码层面理解MyBatis的设计精髓热词里“mybatis源码”是一个高频检索词。我的建议是不要一上来就盯源码细节而是带着问题去读。以下是我认为最有价值的四个源码入口入口一SqlSessionFactoryBuilder。这个类对应了“从配置到工厂”的构建过程点进去看 builder 方法的走向就会理解XML解析、Configuration初始化、Environment赋值是怎么发生的。入口二MapperProxy。这是动态代理的核心。里面InovcationHandler的invoke逻辑很短先判断是不是Object自带方法再通过MapperMethod去解析SQL。看懂了它就理解了为什么Mapper接口不能和已有方法重名比如接口里定义toString方法可能导致代理循环。入口三BaseExecutor和CachingExecutor。这里展现了缓存和装饰器模式的结合。Configuration创建Executor时会先用CachingExecutor包装BaseExecutor再层层叠加插件。你就能明白为什么二级缓存会“外面套一层”一级缓存在Executor内部。入口四动态SQL的org.apache.ibatis.scripting.xmltags。这个包下有DynamicSqlSource、SqlNode、IfSqlNode、WhereSqlNode这些类看它们如何递归拼接SQL片段能够解答“动态SQL为什么可以嵌套”这个问题。源码阅读的过程会很耗时但收益很大。尤其面试时被问“MyBatis的一级缓存和二级缓存是什么”以及“如果让你实现一个分页插件你如何做”如果你能说出基于Executor拦截器实现并且知道PageHelper用的就是拦截器机制面试官会立刻觉得你有实战深度。6.2 自定义拦截器在MyBatis中加入行级权限企业应用里“行级权限java”是一个出现频率不低的关键词。简单说就是同一个查询接口不同用户登录后只能看到自己权限范围内的数据比如区域经理只能看本区域订单销售只能看自己名下的客户。行级权限在MyBatis里的落地方式具有天然优势拦截器。MyBatis允许我们通过实现org.apache.ibatis.plugin.Interceptor来拦截四大核心对象的方法Executor、StatementHandler、ParameterHandler、ResultSetHandler。其中最常用的是Executor。我设计过一个简单的行级权限方案拦截Executor的query方法在真正执行前动态改写BoundSql里的SQL文本追加一个权限过滤条件。核心代码思路如下Intercepts({ Signature( type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class} ) }) public class RowLevelPermissionInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Object[] args invocation.getArgs(); MappedStatement ms (MappedStatement) args[0]; Object parameter args[1]; // 获取当前登录用户的权限条件从上下文取得 String permissionSql PermissionContext.getCurrentPermission(); // 这里需要拿到BoundSql并改写 // ... return invocation.proceed(); } }这个方案的细节在于拿到BoundSql后可以通过它的getSql()方法获取原生SQL然后附加WHERE条件。但改写时要注意SQL中可能已存在WHERE子句、ORDER BY、LIMIT等语法结构简单追加会出现语法错误。更稳健的做法是解析SQL抽象语法树或者改造业务层的查询对象来传条件。我也见过一些团队用MyBatis-Plus的QueryWrapper直接追加条件那是另一种玩法了。如果你的项目使用了PageHelper分页插件还要考虑拦截器的执行顺序。几个拦截器并存时需要保证权限过滤先于分页统计执行不然分页count和结果list查出来的数据范围会不一致。通过Intercepts注解里的order属性以及plugin配置里的顺序可以微调但这个调试过程比较费精力。6.3 多商户跨境商城项目里的MyBatis实际经验热词里有一条“spring boot mybatis 的 java 开源多商户跨境商城源码下载”说明确实有不少人关注这类项目模板。多商户跨境商城的持久层设计对MyBatis的使用提出了几个比较高的要求第一多租户隔离。多商户系统的数据天然需要按商户隔离。结合MyBatis的实践有两种一种是每个商户单独一套Schema通过数据源路由实现隔离另一种是共享表结构在每张业务表上增加merchant_id字段查询时强制带上。共享表的方案会用到类似行级权限拦截器的机制在所有查询SQL上自动追加merchant_id 当前商户ID。第二分库分表。跨境商城订单量一大单库单表肯定扛不住。业界一般采用ShardingSphere这类中间件而它官方的MyBatis集成已经成熟。需要注意的一点是在分库分表环境下MyBatis的分布式ID生成、外键约束、跨库联查都会受限设计时要尽量规避join查询改成两次查询后在应用层做组装。第三多语言支持。跨境商城的商品名称、类目名都属于多语言数据。持久层设计里常见做法是基础表和语言扩展表分离结果映射使用resultMap的association去关联语言表再从基础表的查询中动态指定语言。这种场景下动态SQL和resultMap的灵活组合是MyBatis的最佳发挥地带。我在做一个类似项目时最深的体会是这种体量的系统无论用MVC还是其他框架持久层需要的都不是CRUD自动化而是可控性和可运维性。MyBatis的XML里可以直接写下对业务有意义的SQL注释把复杂的订单汇总逻辑完整呈现这一点对于交接和二次开发特别友好。7. 高频面试题梳理不仅背八股还要会表达热词里反复出现“mybatis面试题”、“java面试题”、“java八股文”。关于MyBatis的面试题我站在面试官的角度把最常见的问题梳理一遍并给出我认为最能体现水平的答题思路。7.1 MyBatis和JPA/Hibernate的选型对比这个问题几乎是必考。但很多人只会说“MyBatis灵活JPA快速”完全说不清楚什么时候选谁。我的回答思路是分三层第一层定位不同。MyBatis是半自动SQL映射框架SQL由开发者掌控JPA/Hibernate是全自动ORM框架SQL生成交给框架。MyBatis适合SQL复杂、优化需求高、查询逻辑多的项目JPA适合领域模型清晰、CRUD为主、强调开发效率的项目。第二层性能调优能力。系统出现慢SQLMyBatis里你可以直接改写XML里的SQLJPA里你首先得保证查询逻辑写对有时候是你故意避开了缓存有时候是N1查询。JPA的二级缓存、查询缓存配置极其复杂陷入调优泥潭的团队不在少数。第三层团队学习曲线。MyBatis的难点主要在XML和SQL本身一个Java开发者要上手门槛不高。JPA虽然官方宣传开箱即用但真正用得优雅需要理解实体状态管理持久化、游离态、分离态、加载策略、JPQL和Criteria API学习曲线反而更陡。我自己的选型主张是如果是企业信息管理系统、金融项目、传统行业数字化转型项目优先考虑MyBatis如果是快速迭代的原型产品、内部工具、规则简单的内容系统JPA也可以。没有绝对的优劣只有是否匹配项目特性。7.2 关于缓存的经典追问链缓存这个问题可以一路挖下去面试官通常按这个链条追问问“MyBatis一级缓存是什么”——答SqlSession级别本地缓存默认开启。问“为什么Spring集成下一级缓存作用不大”——答SqlSession短生命周期方法调用结束即销毁。问“二级缓存是什么如何开启”——答namespace级别全局缓存通过cache标签开启。问“二级缓存有哪些缺点”——答脏数据风险、序列化要求、跨namespace失效困难。问“生产环境你会开启二级缓存吗”——答不会默认开启只在低频变更数据表上选择性开启并且需要监控命中率。这个追问链条考察的是“你是否真的在项目里用过而不是背概念”。如果你能再补充一个实际案例比如“我们曾经对一个字典表开启了二级缓存但修改字典后缓存没有立即清掉后来通过增加flushInterval解决”那这个回答基本就是满分了。7.3 动态SQL和注入安全的答题要点关于#{}和${}的区别很多面试者能说出“#是预编译$是拼接”但追问到“什么场景需要用${}”就卡住了。我的建议是先讲清楚安全性再举一个实际使用的例子比如动态表名、动态排序字段最后强调此时必须手动校验白名单。这题还有可能延伸到预编译机制。数据库端的PreparedStatement是把SQL模板和参数分离的参数无论传什么字符串都被当作纯数据不会改变SQL结构。这也是为什么MyBatis能天然防SQL注入。你如果在回答中提到“参数化查询是数据库本身提供的能力MyBatis只是利用好了这个能力”会显得理解更到位。8. 最后分享一点我的个人经验文章写到这里我忽然想起自己第一次在项目里引入MyBatis时的情景。当时团队正在用JPA但报表查询越来越多每次调SQL性能都要靠猜被逼得换到MyBatis。刚换的第一周最直观的感受就是心里踏实了——复杂的统计SQL再也不用去猜框架会生成什么我自己写的SQL我可以逐行解释清楚。这个体验到现在做技术选型时依然是我力推MyBatis的最大理由。后面这几年我对MyBatis的理解也在不断加深。从一开始只会写简单的select、insert到后来能熟练编排动态SQL、设计resultMap、自定义拦截器中间踩过不少坑但这些坑让我意识到持久层框架的重点从来不是“省代码”而是在业务数据和数据库之间建立一条清晰、可靠、可监控的道路。MyBatis恰恰提供了这样一条路。如果你正在评估新的Java企业项目我还是那句老话别被新潮框架带偏了思路先把你的SQL和业务复杂度梳理清楚。如果SQL复杂度明显很高、团队尤其关注性能可维护性那么MyBatis一定是个务实的选择。如果完全是简单CRUD那也可以考虑JPA但即便用了JPA遇到复杂查询时依然绕不开MyBatis的生产力优势。最后再给一个具体的建议选型前把项目中未来三个月可能出现的十种最复杂SQL场景列出来分别用MyBatis和JPA各写一遍。谁更容易写、更容易调、更容易让团队成员看懂就用谁。这种“用脚投票”的实测方法比看任何技术宣传都靠谱。