Web开发做到第6个阶段,大部分人都逃不开一个问题:数据库操作到底怎么组织。早期我在项目里用JDBC写DAO,每个方法都要重复“获取连接、创建PreparedStatement、遍历ResultSet、关闭资源”这一套,代码是真的无聊到爆。后来换到MyBatis,最直观的感受就是SQL终于回到了它该在的位置——XML文件里,跟Java代码分离;结果集映射也由框架自动完成,我只需要关心业务本身。这篇文章就是《MyBatis——6. Web中使用MyBatis》这一系列的重点内容总结,从环境搭建、Mapper映射细节、Spring事务整合到常见报错排查,一条线讲完,适合刚接触MyBatis的Web开发新手,也适合用了一段时间但总在配置和报错上卡壳的同学。
很多人觉得MyBatis就是“会用Mapper接口就行了”,但在真实Web项目里,踩坑的地方往往不在CRUD本身,而在配置、事务、动态SQL、缓存、日志这几个不起眼的角落。我这些年接手过不少半路项目,问题千奇百怪:有insert不报错但数据没写进去的,有批量插入慢到怀疑人生的,有逻辑删除后怎么也查不出数据的,还有生产环境SQL占位符看不到实际参数导致排查效率极低的。这篇文章我会把这些场景一个个拆开讲清楚,并且尽量交代清楚“为什么这样做”,让大家看完不是只会照抄,而是能举一反三。
1. 为什么在Web项目里要把MyBatis单独拎出来讲
1.1 从JDBC到MyBatis的演进逻辑
先看一个最原始的JDBC查询。你要查用户表,流程大概是:Class.forName注册驱动、DriverManager.getConnection拿连接、conn.prepareStatement拼SQL、setString设置参数、executeQuery执行、然后while(rs.next())逐个字段取值,最后还得在finally里关闭ResultSet、Statement、Connection。如果一张表有20个字段,每写一个查询方法就要重复一次“取字段、set字段”的过程,一个User表写满增删改查加上几个自定义查询,代码量轻松上千行,而且80%是重复劳动。
MyBatis解决的核心痛点就是这一块:连接管理交给池子,参数绑定靠#{}占位符自动处理,结果集映射通过resultType或resultMap自动装配。你只需要写SQL本身,Java侧只需要定义接口方法。这种设计对Web项目的意义非常大,因为Web系统通常表结构多、查询条件复杂、联合查询频繁,Hibernate那样完全由框架生成SQL的方案在复杂报表、分页统计、多表关联时反而束手束脚,SQL不可见、不好调优。MyBatis把SQL的控制权彻底还给开发者,同时把机械的样板代码消灭掉,所以在国内企业级Web开发里,它的普及率一直很高。
1.2 MyBatis在Web分层架构中的定位
典型的Web工程是三层职责分离:Controller负责接收请求和参数校验,Service负责业务编排和事务边界,Mapper/DAO负责与数据库交互。MyBatis就处在最底层的数据持久化位置。它不关心你的Controller是Spring MVC还是Spring Boot,也不关心前端是JSP还是Vue,它只管把Mapper接口方法翻译成SQL语句,再把数据库返回的ResultSet变成Java对象。
这个位置看似简单,实际影响却很大。因为Service层的事务要控制到Mapper层,如果你在Service方法上加了@Transactional,那么整个方法内所有Mapper调用都在同一个事务里;如果你在Service里用this调用内部方法,事务注解可能失效;如果你的Mapper没有交给Spring管理,SqlSession生命周期就会失控,最终导致连接不释放、事务边界混乱。后面第4节我会详细展开这些Web集成要点,这里先建立一个整体认知:MyBatis单用不难,难的是在Spring容器里跟事务、连接池、插件体系正确协作。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建与初始化配置的实操细节
2.1 依赖引入与版本选择建议
如果你是Spring Boot项目,依赖其实很简单,核心就两个:
xml复制<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.2</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
版本这里多说一句,mybatis-spring-boot-starter 2.x系列对应Spring Boot 2.x,3.x系列对应Spring Boot 3.x。很多人在Eclipse里遇到“SpringBoot集成MyBatis一直报downloading…”的问题,多半是Maven没有正确下载依赖,而不是代码写错。遇到这种情况,先检查settings.xml里的镜像仓库,阿里云镜像换成这个基本能解决:
xml复制<mirror>
<id>aliyunmaven</id>
<mirrorOf>central</mirrorOf>
<url>https://maven.aliyun.com/repository/public</url>
</mirror>
还有一个小坑:如果项目是IDEA 2024版本新建的,Spring Initializr默认http地址可能会因为服务超时卡住,建议在创建项目时把Server URL改成阿里云的start服务地址。这个问题跟MyBatis本身无关,但它会阻止你进入后续开发。
2.2 核心配置文件的逐项解读
单独使用MyBatis(不整合Spring)需要mybatis-config.xml,整合Spring Boot后大部分配置可以放到application.yml,但我还是建议你理解config文件里每个标签的作用,因为很多报错都跟这些配置相关。
yaml复制mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
cache-enabled: false
map-underscore-to-camel-case这个配置,说它是“省事神器”一点也不过分。数据库字段通常叫create_time,Java属性叫createTime,没有这个配置,你查出来的createTime永远是null,而且不报错,非常隐蔽。很多新手排查半天找不到原因,实际上就是这个开关没打开。
log-impl这里我建议开发环境用StdOutImpl,简单粗暴,直接在控制台打印SQL和执行参数。生产环境则换成logback或者log4j2,并且把Mapper包日志级别设置到DEBUG,方便排查线上问题。不过也要注意:打印日志会引入磁盘IO开销,高并发系统里不要把DEBUG级别开太久。
cache-enabled建议先设为false。MyBatis的二级缓存默认是打开的(其实默认值是true,但很多脚手架的config里会主动关掉),如果对缓存原理不熟,一不留神就会遇到“改完数据库查出来还是旧数据”这种诡异问题。先关掉,把业务功能跑通了,再考虑缓存策略。
2.3 第一个Mapper的完整落地过程
我以最经典的UserMapper为例,走一遍从接口到XML再到测试的完整链路。
首先是Java接口,注意不要写实现类:
java复制package com.example.mapper;
import com.example.entity.User;
import org.apache.ibatis.annotations.Param;
public interface UserMapper {
User selectById(@Param("id") Long id);
int insertUser(User user);
}
然后是对应的XML文件,放在resources/mapper目录下:
xml复制<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.UserMapper">
<select id="selectById" resultType="com.example.entity.User">
SELECT id, user_name, create_time
FROM user
WHERE id = #{id}
</select>
<insert id="insertUser" parameterType="com.example.entity.User"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO user(user_name, create_time)
VALUES(#{userName}, #{createTime})
</insert>
</mapper>
这里的namespace必须写接口的全限定名,id必须与接口方法名一致,否则启动时可能不报错,一调用就抛“Invalid bound statement (not found)”。这是新手最常见的错误之一,原因就是namespace写错或XML文件没有被扫描到。
最后在启动类或配置类上加@MapperScan:
java复制@SpringBootApplication
@MapperScan("com.example.mapper")
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
到这一步,你的第一个Mapper就已经能在Web项目里被注入并使用了。
3. Mapper接口与XML映射的核心细节
3.1 Mapper绑定机制与常见误区
MyBatis之所以让你只写接口不写实现类,是因为框架在启动时会扫描Mapper接口,解析同名XML文件,通过JDK动态代理生成实现类。这个代理对象在方法调用时做的事情很简单:根据方法的全限定名+方法名找到对应的MappedStatement,然后执行SQL并处理结果。
由此能推导出几个结论。第一,Mapper接口不能重载,因为方法名是查找SQL语句的key,重载会导致冲突。第二,XML文件必须被mapper-locations配置覆盖到,Spring Boot里默认扫描classpath:mapper/*.xml,如果你的XML放到别的目录,就得显式配置。第三,Mapper接口的每个方法参数,如果有多个,必须用@Param指定名称,否则XML里只能用arg0、param1这种方式,可读性很差。
再说一个很多人忽略的点:如果你在XML里同时定义了某个id的SQL,又在接口方法上写了@Select注解,XML中的定义会生效吗?实际上两个都会注册,方法调用时会按一定顺序查找,这种行为在不同版本里有细微差别,从设计上就不建议混用。团队开发里应该约定一个规范:简单SQL用注解,复杂SQL用XML,但一个方法只能选一种,不要双写。
3.2 参数传递、动态SQL与if test的实用写法
动态SQL是MyBatis的王牌功能,
xml复制<select id="listByCondition" resultType="com.example.entity.User">
SELECT id, user_name, create_time
FROM user
<where>
<if test="userName != null and userName != ''">
AND user_name LIKE CONCAT('%', #{userName}, '%')
</if>
<if test="createTimeStart != null">
AND create_time >= #{createTimeStart}
</if>
</where>
ORDER BY create_time DESC
</select>
注意两点:
关于if test里的判断,有个细节值得单独讲。偶尔会遇到需要判断字符串包含的场景,比如筛选某个状态下包含“审批”字样的记录,可以用indexOf方法,但要写成:
xml复制<if test="keyword != null and status.indexOf('审批') != -1">
AND status = #{status}
</if>
这里有个大坑:OGNL表达式里字符串比较用单引号,双引号会被当成对象属性名,导致启动直接报错。我见过不止一个同事在这里卡一下午。
除了if,foreach也是批量操作的核心。批量插入时,如果你用:
xml复制<insert id="batchInsert">
INSERT INTO user(user_name, create_time)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.userName}, #{item.createTime})
</foreach>
</insert>
语法本身没问题,但要注意MySQL对SQL语句长度有限制(默认max_allowed_packet是4MB,很多云数据库调到了16MB)。如果一次性插入几万条数据,生成出来的SQL字符串可能超过限制,建议按500~1000条一批去切分,既不会爆包,事务提交也更快。
3.3 #{}与${}的区别和应用边界
这个知识点面试必问,开发也必踩。简单说,#{}是预编译占位符,MyBatis会把它替换成?,再用PreparedStatement.setObject绑定参数,可以防SQL注入;${}是字符串拼接,直接把变量值拼接到SQL里,存在注入风险。
那么${}是不是完全不能用?也不是。像ORDER BY column、动态表名、动态数据库名,这些地方无法用占位符,只能用${}。看网上的安全规范,一般会要求:${}只允许出现在非用户直接输入的位置,或者做严格的白名单校验。比如前端传一个排序列名,后端用一个Map白名单把它映射成固定字符串再拼进去,不要直接拿原始值拼接。
我画个简单对比方便记忆:
| 对比项 | #{} | ${} |
|---|---|---|
| 底层处理 | PreparedStatement占位符 | 纯字符串替换 |
| SQL注入风险 | 无 | 有 |
| 能用在 | 字段值、条件值 | ORDER BY、表名、列名 |
| 预编译优化 | 支持 | 不支持 |
| 传参示例 | AND id = # | ORDER BY $ |
从实际项目经验看,安全规范应该当作红线来执行:面向用户的查询条件一律用#{},任何拼接用户输入进${}的做法都要在代码评审阶段打回。
3.4 resultType、resultMap与多表查询
resultType适合数据库字段名和Java属性名完全一致(或开启了驼峰映射)的单表查询;一旦涉及多表联查,比如用户表关联部门表,要返回一个包含部门名称的VO对象,推荐用resultMap显式映射,可读性和可维护性都更好。
xml复制<resultMap id="UserDeptMap" type="com.example.vo.UserDeptVO">
<id property="id" column="id" />
<result property="userName" column="user_name" />
<result property="deptName" column="dept_name" />
</resultMap>
<select id="selectUserWithDept" resultMap="UserDeptMap">
SELECT u.id, u.user_name, d.dept_name
FROM user u
LEFT JOIN dept d ON u.dept_id = d.id
WHERE u.id = #{id}
</select>
这里有个性能隐患:一对多查询如果直接在SQL里用LEFT JOIN,可能会导致一张主表记录对应多张子表记录,返回行数膨胀。更常见的做法是分两条SQL查询,主记录查一次,子列表查一次,然后在Service层组装;或者用collection子查询,但要注意它可能导致N+1查询,数据量大时需要谨慎选择。
4. Web集成、事务与批量操作实战
4.1 Spring整合MyBatis的关键组件
Spring Boot里mybatis-spring-boot-starter已经帮你做了大部分配置,无非就是SqlSessionFactoryBean和MapperScannerConfigurer这两个核心动作。SqlSessionFactoryBean负责构建SqlSessionFactory,它需要数据源、全局配置、Mapper XML位置这些依赖;MapperScannerConfigurer负责扫描指定包下的Mapper接口,把它们注册为Spring Bean。
理解这一点对排查问题很有帮助。比如报错“No qualifying bean of type 'UserMapper'”,多半是@MapperScan路径没扫对,或者Mapper接口上没有加@Mapper注解。又比如报告“Invalid bound statement”,说明Mapper Bean创建成功了,但找不到对应的XML SQL,这是mapper-locations路径问题。
连接池选型方面,Web项目里我基本默认用HikariCP,Spring Boot 2.x之后的默认选择就是它,性能好、配置简单。有几个参数值得调一下:
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
maximum-pool-size不宜设置过大,数据库连接是稀缺资源,经验值是“核心CPU核数×2+1”附近,理论上连接数太多反而因为上下文切换导致吞吐下降。如果生产环境监控发现连接池打满,优先排查SQL慢查询和连接泄漏,而不是盲目加大连接数。
4.2 事务管理与read-only mode报错
Spring事务通常是靠@Transactional注解实现的,放在Service类或方法上。MyBatis对这个注解的配合主要体现在SqlSessionTemplate的事务同步机制上:Spring开启事务后,MyBatis会把SqlSession绑定到当前线程,所有Mapper方法复用同一个会话,这样多个SQL就处于同一条数据库连接里,能够共享一个事务。
接下来重点说一个高频报错:write operations are not allowed in read-only mode (FlushMode.MANUAL)。出现这个错误通常是因为你在只读事务里执行了写操作。比如:
java复制@Transactional(readOnly = true)
public void updateUser(User user) {
userMapper.updateById(user);
}
Spring在readOnly = true时会把FlushMode设置成MANUAL,MyBatis认为当前事务是只读的,不允许flush,于是抛出这个异常。解决办法是检查事务边界:只查询的方法保留readOnly = true,涉及写操作的方法把readOnly删掉。还有一种情况是同一个方法里既查询又更新,但方法上误标了readOnly = true。
另一个容易踩的坑是事务方法之间通过this调用导致代理失效。Spring事务基于AOP代理实现,如果类内部方法自调用,比如a()里调this.b(),b()上的@Transactional不会生效。正确的方式是把要开事务的方法放到另一个Bean里,或者自己注入代理对象。这个坑在代码评审时几乎每次都能看到。
4.3 分页查询的正确姿势
Web列表页几乎离不开分页。原生写法是手动写LIMIT #{offset}, #{pageSize},然后单独写一个COUNT查询,代码多,而且容易因为条件不一致导致总数和列表不匹配。
我的方案是用PageHelper,这也是国内Web项目最常见的分页插件。用法很简单,在查询前启动分页即可:
java复制PageHelper.startPage(pageNum, pageSize);
List<UserVO> list = userMapper.selectPageList(condition);
PageInfo<UserVO> pageInfo = new PageInfo<>(list);
PageHelper的原理是基于MyBatis拦截器,在执行SQL前拦截,自动生成COUNT查询和带LIMIT的查询,并把分页结果塞进Page对象。它确实方便,但有几个注意事项:
- 分页参数紧跟着的下一条SQL语句才会被拦截,如果中间插入其他查询,就会分页到错误的语句上。
- PageHelper.startPage只对下一次查询生效,查询后没有PageInfo包装,Page对象里的total需要强转或者用PageInfo获取。
- 多表JOIN查询时,COUNT语句会被拦截器自动处理,但如果你自己写了包含ORDER BY的复杂SQL,COUNT可能不够优化,遇到慢查询要手动改写。
4.4 批量插入和性能优化
批量操作是Web后台里常见的需求,比如数据导入、批量发布。处理方案一般有两条路。
第一条是前面讲过的foreach拼SQL,优点是SQL少、交互次数少,适合几百到几千条的数据量。第二条是MyBatis的ExecutorType.BATCH模式,适合几万条以上的大批次:
java复制SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH);
try {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
for (User user : userList) {
mapper.insertUser(user);
}
sqlSession.commit();
} finally {
sqlSession.close();
}
BATCH模式会把多条SQL累积到一批提交,减少网络往返,但它的坑在于无法获得每条记录的自动生成的主键,因为执行并没有真正执行,而是攒批。所以如果后续逻辑需要自增主键,批量模式就不合适。
还有一个很多人在MyBatis-Plus里踩的坑:调用batchInsert方法后数据没写进去但不报错。这个问题常见原因有三个。第一,事务没有提交,虽然单条方法默认自动提交,但在Spring事务包裹下要等事务方法结束;第二,实体类里主键字段为null且主键策略设置错误,导致插入SQL不包含主键字段,数据库有默认值但引擎未回填;第三,某些字段因为数据库字段名映射不上(下划线转驼峰配置没开),插入时被当成null,而字段又是非空约束,按理说会报错,但如果数据库有默认值,就可能出现“不报错也不写入”的假象。排查方法很简单:先看SQL日志,一条条过,看插入语句里到底有没有值。
5. 日志输出与SQL打印的实用技巧
5.1 日志打印的两种基本方式
开发阶段调试SQL,最常用的就是配置log-impl为StdOutImpl,MyBatis会把执行的SQL、参数、查询结果条数打印到控制台。它的优势是零依赖零成本,适合本地开发;缺点是没有日志级别控制,打印内容比较混乱,生产环境肯定不能开。
Spring Boot项目更规范的做法是配置日志级别:
yaml复制logging:
level:
com.example.mapper: debug
MyBatis的Mapper接口所在的包日志级别调到DEBUG,就会打印完整SQL和绑定参数,而且输出格式跟应用其他日志统一,可以接入日志文件、日志采集系统。如果你用logback,还可以给mapper包单独设置Appender,把SQL日志单独落到一个文件,排查问题时非常方便。
5.2 插件辅助打印可执行SQL
占位符SQL一个不方便之处是,日志中只显示参数和预编译语句,不能直接拷贝到Navicat执行。比如日志输出:
code复制==> Preparing: SELECT * FROM user WHERE id = ?
==> Parameters: 1001(Long)
你没法一眼看出实际执行的完整SQL长什么样。后来我用了IDEA的MyBatis Log Plugin,它能把这类日志自动还原成可以直接执行的SQL语句,省去手动替换占位符的过程。这类插件很多,除了MyBatis Log Plugin,还有MyBatis Log EasyPlus。两者功能类似,都是监听控制台输出,解析后合并成实际SQL。
这里要提醒一点:这类插件只适合开发环境,千万不要在生产服务器上装。生产环境打印可执行SQL本来就有风险,因为参数里可能带电话号码、身份证号等敏感信息,日志很容易变成数据泄露的出口。如果确实需要在生产排查SQL问题,建议先做参数脱敏,打印前把敏感字段值替换成星号。
5.3 慢SQL监控思路
日志打印是基础手段,Web项目还想再进一步,就要关注慢查询。MySQL层面可以开启慢查询日志,但慢查询阈值往往需要在DBA那协调;应用层更直接的办法是给Mapper方法做耗时埋点。用MyBatis的Interceptor插件可以统一拦截,记录每个SQL的执行时间,超过阈值就输出WARN日志。
java复制@Intercepts(@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
))
public class SlowSqlInterceptor implements Interceptor {
private static final long SLOW_SQL_MILLIS = 500L;
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.currentTimeMillis();
try {
return invocation.proceed();
} finally {
long cost = System.currentTimeMillis() - start;
if (cost > SLOW_SQL_MILLIS) {
// 输出SQL信息和耗时
}
}
}
}
插件拦截器虽然写起来简单,但使用时要注册到MyBatis配置里,并且要理解它会对所有Executor调用生效,包括查询和更新,所以拦截逻辑要尽量轻量,避免本身成为性能瓶颈。
6. MyBatis缓存机制与实战边界
6.1 一级缓存、二级缓存的运行逻辑
一级缓存是SqlSession级别的,默认开启。同一个SqlSession里执行两次相同的查询,第二次会直接从缓存返回,不再查库。在Spring整合环境下,每个事务可能复用一个SqlSession,因此同一个Service方法里多次查询同一行数据,实际可能只查了一次库。
看起来是好事,但有一个坑:如果你在一个SqlSession里先查询用户,然后通过另一个Mapper更新了这个用户,仍然用原SqlSession去查,可能拿到脏数据。因为一级缓存只按statementId+参数+分页条件等维度判断,不感知数据变化。解决办法是手动调用sqlSession.clearCache(),或者把查询和更新放在不同的事务边界里。
二级缓存是namespace级别的,跨SqlSession共享。它的作用范围是同一个Mapper的XML文件里定义的SQL,一般配合redis或ehcache实现分布式缓存。但二级缓存最危险的地方在于:MyBatis并不清楚你的数据表之间是否有外键关联,一个Mapper的缓存无法感知另一个Mapper对同一张表的修改。比如UserMapper缓存了用户信息,UserDetailMapper更新了同一张用户表的某个字段,UserMapper的缓存依然返回旧值。
所以我的建议是:Web项目在没搞清楚所有写入路径之前,别轻易开二级缓存。大部分业务系统用Redis做应用层缓存(比如缓存热点数据),MyBatis负责数据库访问,两者职责清晰得多,远好过在持久层搞一套不可控的缓存。
6.2 从缓存报错看问题排查思路
有时候你会看到类似“Cache 'xxx' does not exist”的报错,或者flushCache相关配置不生效。这些问题往往出在配置冲突上:全局cache-enabled和XML里的
我处理这类问题有一个习惯:先通过日志确认SQL是否真正执行了。在Mapper XML的
7. 高频报错与线上问题排查速查
7.1 Web项目里MyBatis的典型报错清单
| 报错信息 | 常见原因 | 解决方案 |
|---|---|---|
| Invalid bound statement (not found) | namespace写错、XML没扫描 | 检查namespace与接口全限定名一致,确认mapper-locations路径 |
| write operations are not allowed in read-only mode | 在readOnly事务里执行了写操作 | 去掉@Transactional(readOnly = true)或拆分方法 |
| No qualifying bean of type 'XxxMapper' | Mapper扫描路径不对 | 检查@MapperScan或@Mapper注解 |
| Cause: java.sql.SQLSyntaxErrorException | SQL本身写错、表名/字段名关键字冲突 | 用反引号包裹关键字,检查数据库表字段名 |
| 缓存导致数据不一致 | 二级缓存没有失效策略 | 关闭缓存或引入Redis,手动清理缓存 |
| TooManyResultsException | 一个方法返回List但实际返回了多条,却用实体接收 | 改成List或者加LIMIT 1 |
这个表格是面试和日常排查都能用到的,我建议保存一份。实际工作中,很多报错互相叠加,不要只盯着第一行异常看,要往下翻Cause by链,根因往往在最后。
7.2 动态SQL中if test的细节坑
OGNL表达式里最容易出错的地方在于字符串和对象的区分。比如想判断status是否等于字符串“APPROVED”。
xml复制<if test="status == 'APPROVED'.toString()">
写单引号是基本约定,但复杂表达式时要注意OGNL方法调用。还有一个实际案例:某个同事用if test判断列表不为空,写成了:
xml复制<if test="list != null and list.size > 0">
MyBatis OGNL里.size是属性访问,不需要加括号,加了括号反而可能解析报错。如果遇到解析异常,可以先简化表达式,比如不写.size,直接用list.size()换成list != null and list.size > 0这种写法,总之表达式越简单,越不容易踩坑。
7.3 MyBatis-Plus场景下的特殊问题
现在很多项目直接用MyBatis-Plus,它封装了BaseMapper,CRUD几乎不用写XML。但它也有自己的坑,热词里那个“MyBatis-Plus insert数据没有写成功但也没有报错”就是典型。我排查过几次,总结下来主要看四点:一是是否处在Spring事务里,事务方法是否有注解但没被代理;二是实体主键策略是否合理,比如AUTO策略要求数据库自增,如果数据库表主键设置成手动插入,框架不会自动回填;三是字段映射是否正常,检查insert SQL里实际插入哪些字段、哪些为null;四是数据库本身触发了某种软限制,比如触发器把插入跳过了。这四类问题,用SQL日志逐条排查最快。
另外逻辑删除也是MyBatis-Plus里一个容易懵的点。实体上加了@TableLogic注解后,默认查询会自动追加逻辑未删除条件,但如果你想“连已删除的数据也查出来”,最简单的办法是使用自定义SQL绕过逻辑删除拦截,或者用@InterceptorIgnore注解标记这个Mapper方法不参与逻辑删除插件处理。网上有很多方案,核心思路都是绕过MyBatis-Plus的自动条件拼接。不过这类操作要谨慎,通常只有管理员后台恢复数据时才用。
7.4 多数据库兼容问题
企业级项目偶尔面临“一个系统要兼容MySQL和PostgreSQL”这种需求。MyBatis本身的SQL以方言为主,如果SQL里面有大量数据库特有语法,迁移成本很高。几个优化思路:一是用<if>判断databaseId来自动选择SQL片段,MyBatis支持DatabaseIdProvider,通过数据库产品名区分;二是在SQL里尽量避免使用特定数据库的函数,比如MySQL的LIMIT在PostgreSQL也支持但语义略有差异,Oracle则是ROWNUM,如果想统一,建议用PageHelper这样的分页插件自动处理。
这个方案有效,但要控制复杂度。兼容数据库意味着每一条SQL都要写多份,维护成本会成倍上涨。一般只有强需求(比如项目要交付给不同数据库环境的不同客户)才值得做,大部分情况还是锁定一种数据库,把精力花在SQL优化上更值。
8. Web安全与MyBatis的边界意识
8.1 SQL注入防护
前面反复提过#{}防注入,这是MyBatis内置的第一道防线。但注意,只要用了${}拼接,防线就失效了。安全测试时,攻击者会在用户输入里构造单引号、注释符、联合查询、布尔盲注等payload,这些在#{}场景下会被当作普通字符串绑定,毫无威胁;但如果用了${},相当于直接把参数原样拼进SQL,攻击者能做的事就多了。
所以Web项目开发中,我建议安全红线写成两条:第一,所有用户可见的查询入口,禁止直接使用${};第二,如果某处必须用${},必须经过一层白名单校验,而且这个校验要写在靠近用户输入的Controller层,不要让动态SQL字符串在Service层继续流传。
8.2 最小权限与机密数据保护
数据库账号权限分配上,Web应用连接数据库建议只用DML权限(SELECT、INSERT、UPDATE、DELETE),不要给DDL权限。很多人开发图省事直接用一个管理员账号,一旦SQL注入或者误操作,后果是灾难性的。生产环境更是如此,数据表、索引、视图的变更要经过DBA审批,应用账号只做数据读写。
还有一个容易忽略的点:数据库操作日志里可能包含敏感字段,比如银行卡号、手机号。如果一定要记录这类SQL,建议要么根本不让SQL返回完整值,要么在应用层做脱敏后再打日志。这属于Web安全的一个侧面,但被问到时也很能体现经验深度。
9. 从实际项目视角看MyBatis的日常操作习惯
9.1 约定优于配置的团队规范
用MyBatis的项目,如果缺乏规范,XML和Mapper分布很容易乱成一锅粥。我的习惯是给团队定四条简单规则:Mapper接口统一放mapper包,XML统一放resources/mapper目录且文件名与接口名一致;方法命名统一用select/insert/update/delete开头;简单单表操作尽量用MyBatis-Plus的BaseMapper,复杂联查才写XML;每个XML文件顶部写清创建人和创建日期,方便追溯。
这些规范本身不复杂,但它能极大降低交接成本。我见过一个项目,三个开发把Mapper放到了三个不同包,XML路径也变了三次,结果每次新同事接手都要靠搜索文件才能定位SQL。
9.2 快速定位慢SQL的排查路径
遇到Web请求缓慢,最常碰到的就是“查数据库变慢了”。排查的第一步是在慢查询日志或者应用SQL日志里找到对应SQL;第二步用EXPLAIN看执行计划,重点关注type是否ALL全表扫描、key是否为NULL、rows扫描行数是否异常;第三步根据执行计划决定是否加索引或者改写SQL;第四步用连接池监控确认是否因为连接等待导致整体变慢。
这套流程看着简单,实际却非常考验对数据库的理解。MyBatis只是把SQL发给了数据库,真正性能瓶颈在数据库端的执行计划和索引设计。所以做Web开发,不要只用MyBatis写个CRUD就觉得完事了,SQL调优、索引设计、事务拆分这些基本功才是持久的竞争力。
9.3 我对MyBatis在Web项目中的整体体会
如果在Web项目里给MyBatis一个定位,它更像是一把趁手的工具,而不是银弹。它帮你把SQL和代码解耦,让SQL可读、可调优、可复用;它没有强行规定你的表结构设计,也没有包办所有缓存与分页,把选择权留给开发者。但这也意味着,用得好不好,完全取决于开发者对SQL、事务、性能和安全的理解深度。
我很建议刚入门的同学在Spring Boot项目里亲手搭一次MyBatis,而不是直接套用脚手架。从依赖引入、配置文件、Mapper接口、XML映射、事务控制到分页插件,每一步都搞懂,你会发现后面再踩坑时,看一眼配置就知道问题出在哪。这个功力,等你写久了就会明白有多值钱。
