尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

MyBatis动态SQL与Generator实战:告别SQL拼接,高效开发

发布时间:2026/9/29 18:03:15

资讯中心
01
ARTICLE

MyBatis动态SQL与Generator实战:告别SQL拼接,高效开发

MyBatis动态SQL与Generator实战:告别SQL拼接,高效开发
接触 MyBatis 有些年头的同学应该都有这种感觉写简单的 CRUD 很爽一旦业务逻辑复杂起来SQL 拼接就成了灾难。记得我刚从 JDBC 转到 MyBatis 的时候最让我眼前一亮的就是动态 SQL——再也不用在 Java 代码里写几十行 if-else 拼条件字符串把 SQL 放回 XML 里可读性和维护性直接上了一个台阶。而 MyBatis Generator 又是另一把利器能把那些重复到想吐的基础 CRUD 代码自动生成出来省下大把时间去处理真正的业务逻辑。这篇文章就把我在项目中实际用动态 SQL 和 Generator 的经验整理一遍包括标签细节、配置踩坑、生成后的二次改造希望能帮正在进阶的同学少走点弯路。1. 动态 SQL 的核心场景与设计思路1.1 没有动态 SQL 时我们经历了什么先聊聊没有动态 SQL 的日子。那时候最痛苦的不是写 SQL 本身而是应对条件组合变化。比如一个用户查询接口前端可能传用户名、状态、时间范围也可能什么都不传你要写四种查询方法还是用字符串拼 SQL我都干过。字符串拼接的问题是空格容易漏、引号容易飞、where 和 and 的位置要小心翼翼的调更要命的是 SQL 注入风险处处埋雷。MyBatis 动态 SQL 解决了这个问题它的核心思想是在 XML 里用标签来控制 SQL 片段的拼接条件成立就拼进去不成立就跳过。标签干活的时候自动处理多余的关键字和逗号程序员只需要关心业务条件的逻辑。说白了它就是把 Java 层那些繁琐的判断下沉到了 SQL 映射层让 SQL 更内聚也更贴近实际执行的样子。动态 SQL 适用的场景非常广多条件组合查询、批量插入、批量更新、动态更新非空字段、多表关联时的可选条件以及一些复杂报表查询。只要一条 SQL 可能因为入参不同而产生不同形态你都可以考虑动态 SQL。1.2 动态 SQL 的底层原理一句话版虽然我们用的时候只是写标签但了解一下底层原理很重要面试也常问。MyBatis 的动态 SQL 是基于 OGNL 表达式实现的。XML 里的每个标签最终都会被解析成对应的 SqlNode 节点比如 IfSqlNode、WhereSqlNode、SetSqlNode、ForeachSqlNode 等等。框架执行时通过 SqlSourceBuilder 以及一系列 SqlNode 的 apply 方法逐个拼接 SQL 片段同时用?占位符替换动态值避免字符串直接拼入这是安全性的重要保障。理解了这个原理你就明白为什么动态 SQL 里的 test 条件要写 OGNL 表达式为什么字符串比较要用value.equals(param)而不能直接写。后面遇到奇怪的 bug顺着这个原理去排查往往很快就能定位。2. 主力标签逐一拆解与实用细节2.1 if 标签最基础也是最重要的单元if 标签在动态 SQL 里出现频率最高没有之一。它的固定用法是在 test 里写判断条件条件为 true 时拼接内部 SQL 片段。select idselectUsers resultTypecom.example.User SELECT * FROM user WHERE 1 1 if testusername ! null and username ! AND username #{username} /if if teststatus ! null AND status #{status} /if /select这里有个历史遗留习惯WHERE 1 1。很多老程序员喜欢加这个目的是让后续所有 if 片段都可以无脑以 AND 开头。但很多人不知道现在的 MyBatis 其实不太推荐这个写法因为 where 标签能做得更优雅。不过如果项目里还在用WHERE 1 1也完全不用羞愧我早期项目就这么写稳定可靠只是看起来不够“现代”而已。if 标签里的 test 表达式有几个典型坑判断字符串是否为空一定要先判断 null 再判断空串顺序反了可能报 NPE。判断数字时如果值是 0status ! null为 true但如果你误写成status ! null and status ! 某些类型转换场景会出问题建议数字只判 null。字符串相等比较要写成Y.equals(status)反过来写也行但不能直接写status Y除非你用了特殊配置。2.2 where 标签优雅替换 WHERE 1 1where 标签会自动处理第一个条件前面的 AND 或 OR它比WHERE 1 1更聪明。原因很简单where 标签在拼接时先判断内部有没有内容有内容才插入 WHERE 关键字同时去掉开头多余的 AND/OR。select idsearchUsers resultTypecom.example.User SELECT * FROM user where if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if if teststatus ! null AND status #{status} /if if teststartTime ! null AND create_time gt; #{startTime} /if /where /select有一点要注意where 标签只处理开头位置的 and/or如果你某个 if 片段内部自己又拼了条件比如AND (a 1 OR b 2)那内部的 OR 不会受影响这反而是灵活的体现。多个条件组合时我习惯每个 if 都以 AND 开头让 where 自动规范这样代码最整齐。2.3 set 标签动态更新非空字段的主力更新场景里最烦的是把所有字段都 set 一遍即使前端只传了一个字段。set 标签能自动去掉最后的逗号只更新传进来的字段同时保留实体其他的值不变这个能力在做部分更新接口时极为好用。update idupdateUser parameterTypecom.example.User UPDATE user set if testusername ! null and username ! username #{username}, /if if testphone ! null phone #{phone}, /if if testemail ! null email #{email}, /if /set WHERE id #{id} /update用 set 标签时千万要小心一个业务陷阱如果所有 if 都不成立生成的语句就成了UPDATE user WHERE id ?MySQL 会报语法错误。所以动态更新的方法必须在业务层保证至少有一个字段要更新或者在 XML 里加一个兜底判断比如if testusername null and phone null and email null username username /if这种“自我赋值”技巧。2.4 choose/when/otherwise类似 Java 的 switchif 是独立判断多个条件choose 则是“多选一”的关系跟 Java 里 switch-case 一个套路。比如查询优惠券用户传了优惠券编号就用编号查传了订单号就用订单号查两者都不传就查所有已激活的这类逻辑用 choose 最合适。select idqueryCoupon resultTypecom.example.Coupon SELECT * FROM coupon where choose when testcouponNo ! null and couponNo ! coupon_no #{couponNo} /when when testorderId ! null order_id #{orderId} /when otherwise status ACTIVE /otherwise /choose /where /select这里要注意 choose 的执行顺序MyBatis 会从上到下逐个判断 when第一个成立的生效后续的不再判断。所以如果有优先级需求把优先级高的条件写在前面。2.5 foreach 标签批量操作与 IN 查询的利器2.5.1 IN 查询查询条件里有列表的时候foreach 是最标准的选择。比如按多个 ID 批量查询select idselectByIds resultTypecom.example.User SELECT * FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /selectcollection 的取值要特别强调如果入参是一个 List直接写list如果是 Set写collection如果是数组写array如果入参是 POJO 且 POJO 里有个 List 属性那就写属性名。经常有人在这里踩坑报“Could not determine parameters for collection”就是 collection 名字写错了。2.5.2 批量插入批量插入是 foreach 的另一个高频用法。这里分两种姿势第一种循环执行单条 insert。SQL 简单但每一批都要和数据库交互一次性能拉胯只适合数据量很小的场景。第二种一条 insert 用 foreach 拼接多组 VALUESinsert idbatchInsert parameterTypelist INSERT INTO user (username, phone, email) VALUES foreach collectionlist itemitem separator, (#{item.username}, #{item.phone}, #{item.email}) /foreach /insert批量插入的时间主要花在网络往返上所以这种一条语句插入几百行的做法比循环单条要快得多。但也要注意 MySQL 对单条 SQL 大小有限制默认 max_allowed_packet 通常是 4MB 或 64MB如果一次插入几千条甚至上万条要分批执行比如每批 500 条。2.5.3 批量更新批量更新最经典的是用CASE WHEN实现这也是 foreach 最常见的更新姿势update idbatchUpdateStatus UPDATE user SET status foreach collectionlist itemitem openCASE id closeEND WHEN #{item.id} THEN #{item.status} /foreach WHERE id IN foreach collectionlist itemitem open( separator, close) #{item.id} /foreach /update这种写法的好处是只执行一条 SQL 就能更新多条记录比循环 update 高效得多。不过 MySQL 默认不允许在存储函数或触发器等场景下修改相同表的记录但这个 CASE WHEN 更新不会触发这个限制所以可以放心用只要注意WHERE id IN (...)别漏了否则会全表更新这个事故我见过不止一次。2.6 trim 标签where/set 的高级自定义版本trim 标签是 where 和 set 的底层实现如果你需要自定义前缀、后缀或者只想去掉结尾的逗号可以直接用 trim。它的四个属性要记清prefix 表示前缀suffix 表示后缀prefixOverrides 表示要去掉的头部关键字suffixOverrides 表示要去掉的尾部关键字。一个自定义场景由于 where 标签只能处理起始位置的 AND/OR如果你要拼的是(这种括号开头就得用 trim。比如trim prefixAND ( suffix) prefixOverridesOR if testa ! null OR a #{a}/if if testb ! null OR b #{b}/if /trim实际工作中 trim 用得比 where 少但它能解决不少边界问题。我的经验是搞不清 where/set 行为细节的时候就用 trim 把逻辑写得明明白白反而更可控。2.7 bind 标签统一变量与模糊查询优化bind 标签可以在当前 SQL 上下文里定义一个新的变量最常见的用途是模糊查询的%拼接。比如LIKE CONCAT(%, #{username}, %)这个操作如果多个地方都要用可以放在 bind 里统一处理select idsearchUsers resultTypecom.example.User bind namelikeName value% username %/ SELECT * FROM user WHERE username LIKE #{likeName} /selectbind 还可以解决跨数据库方言的问题。有些数据库用||拼接字符串有些用 CONCAT 函数在 bind 里统一处理之后SQL 主体保持不变迁移数据库时改动量更小。注意 bind 的 value 是 OGNL 表达式字符串拼接要用而不是 Java 里的字符串连接符这一点也容易弄错。3. MyBatis Generator从配置到生成一条龙实操3.1 Generator 到底帮我们生成了什么MyBatis Generator简称 MBG是一个代码生成器它读取数据库表结构自动生成三类核心文件实体类POJO、Mapper 接口Java 接口、Mapper XML映射文件。如果配合 Example 模式还会生成一个 orm 风格的条件查询链极大减少基础 CRUD 的编写量。它的价值很直接一个 20 个字段的表手写实体类、接口、XML 至少要十几分钟Generator 只需要几十秒而且生成的代码样式统一不容易出低级错误。当然也有争议有人觉得生成的代码很冗余尤其是 Example 那一套看着头疼。我的态度是工具是死的人是活的生成之后可以取舍、可以改造甚至只把 Generator 当“草稿机”生成的代码做参考再手动重写精简版本。3.2 环境准备与依赖引入实操之前先把环境准备好。MBG 的引入方式主要有两种独立 JAR 包运行和 Maven 插件运行。我强烈推荐 Maven 插件方式因为可以跟随项目一起管理版本也方便在 CI 流程里执行。plugin groupIdorg.mybatis.generator/groupId artifactIdmybatis-generator-maven-plugin/artifactId version1.4.2/version configuration configurationFilesrc/main/resources/generatorConfig.xml/configurationFile overwritetrue/overwrite verbosetrue/verbose /configuration dependencies dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency /dependencies /plugin如果你的项目是 Spring Boot还要保证 MyBatis 相关依赖已经引入。另外MBG 1.4.x 之后 JDBC 驱动要自己显式声明否则运行时报找不到驱动。3.3 generatorConfig.xml 核心配置详解这是 MBG 的命脉配置对了基本就成功了一大半。一个最简配置如下!DOCTYPE generatorConfiguration PUBLIC -//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd generatorConfiguration context idmysqlContext targetRuntimeMyBatis3 defaultModelTypeflat property namebeginningDelimiter value/ property nameendingDelimiter value/ jdbcConnection driverClasscom.mysql.cj.jdbc.Driver connectionURLjdbc:mysql://localhost:3306/yourdb?useSSLfalseamp;serverTimezoneAsia/Shanghai userIdroot passwordyourpassword/ javaTypeResolver property nameforceBigDecimals valuefalse/ /javaTypeResolver javaModelGenerator targetPackagecom.example.entity targetProjectsrc/main/java property nameenableSubPackages valuetrue/ property nametrimStrings valuetrue/ /javaModelGenerator sqlMapGenerator targetPackagemapper targetProjectsrc/main/resources property nameenableSubPackages valuetrue/ /sqlMapGenerator javaClientGenerator typeXMLMAPPER targetPackagecom.example.mapper targetProjectsrc/main/java property nameenableSubPackages valuetrue/ /javaClientGenerator table tableNameuser domainObjectNameUser property nameuseActualColumnNames valuefalse/ /table /context /generatorConfiguration几个配置项的作用和执行效果我用一份速查表总结一下配置项作用建议defaultModelType生成实体类的类型flat 表示一个表只生成一个实体类不生成 Example 相关复杂继承结构优先用 flat结构清爽targetRuntimeMyBatis3 表示生成标准 MyBatisMyBatis3DynamicSql 表示生成新的动态 SQL 风格老项目用前者新项目可考虑后者forceBigDecimals是否把 DECIMAL 类型映射为 BigDecimal涉及金额必须 true防止精度丢失trimStrings是否对字符串字段调用 trim 去空格建议 true避免从数据库带出空格enableSubPackages是否根据表所在的 schema 生成子包建议 true多库多表时目录清晰useActualColumnNames是否直接用列名作为 Java 属性名false 时自动转驼峰配置完成后运行 Maven 命令mvn mybatis-generator:generate如果一切正常控制台会显示BUILD SUCCESS对应的实体类、Mapper 接口、XML 文件就会出现在你配置的目录下。3.4 生成后的代码长什么样打开生成的 UserMapper你会看到一套标准的 CRUD 方法public interface UserMapper { long countByExample(UserExample example); int deleteByExample(UserExample example); int deleteByPrimaryKey(Integer id); int insert(User row); int insertSelective(User row); ListUser selectByExample(UserExample example); User selectByPrimaryKey(Integer id); int updateByExampleSelective(Param(record) User record, Param(example) UserExample example); int updateByExample(Param(record) User record, Param(example) UserExample example); int updateByPrimaryKeySelective(User row); int updateByPrimaryKey(User row); }我重点说一下 insertSelective 和 insert 的区别insert 会把所有字段插入null 也会设为 NULLinsertSelective 只插入非 null 字段null 字段用数据库默认值。日常业务里 90% 的场景用 insertSelective 更安全防止某些字段因为入参为 null 而意外覆盖数据库默认值。Example 的用法看起来有点绕但掌握套路之后非常顺手UserExample example new UserExample(); UserExample.Criteria criteria example.createCriteria(); criteria.andStatusEqualTo(1); criteria.andUsernameLike(%admin%); example.setOrderByClause(create_time desc); ListUser users userMapper.selectByExample(example);这种链式查询非常适合做后台管理系统的筛选列表不用写一行 XML。当然条件太复杂的时候还是建议手写 SQLExample 的优势在简单条件组合复杂场景反而是负担。4. 生成之后的二次改造才是真正的进阶4.1 把生成代码和动态 SQL 结合Generator 生成的是基础业务需要的是千变万化。我自己的习惯是生成的基础方法一律不修改扩展方法单独写在同一个 Mapper 接口里对应在 XML 的 mapper 命名空间下新增语句这样既有自动化的效率又有手工 SQL 的灵活性。比如我要在生成的 UserMapper 里加一个多条件分页查询public interface UserMapper { // 生成的代码... ListUser searchUsers(Param(username) String username, Param(status) Integer status, Param(offset) int offset, Param(limit) int limit); }对应的 XML 扩展select idsearchUsers resultTypecom.example.entity.User SELECT * FROM user where if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC LIMIT #{offset}, #{limit} /select这种“生成 手写扩展”的模式是我目前用下来最省心也最不容易出问题的结构。生成代码保证底线手写代码满足个性。4.2 逻辑删除与乐观锁字段的默认处理很多表都有 is_deleted、version 这类字段。Generator 不会自动感知这些业务字段的意义所以生成出来后你最好做两件事手动把查询方法默认加上AND is_deleted 0可以用sql片段抽出来复用。用sql标签定义基础查询列和通用条件片段避免每个手写 SQL 都重复一遍同样的过滤逻辑。sql idbaseSelect where is_deleted 0 if teststatus ! null AND status #{status} /if /where /sql这样多条查询可以include refidbaseSelect/复用后续如果要支持多租户之类的全局条件只用改这一处收益很高。4.3 关于 typeHandler 和自动填充的补充进阶阶段还有一个绕不开的点typeHandler。虽然它不是动态 SQL 的主线但在复杂映射里经常配合动态 SQL 一起用。比如一个 List 字段在 MySQL 里存的是逗号分隔的字符串默认映射拿不出来你就需要自定义 typeHandler 做转换。实现步骤很清晰继承 BaseTypeHandler实现四个方法分别对应 set 和 get 的过程然后可以在生成配置里指定table tableNameuser columnOverride columnhobbies javaTypejava.lang.String jdbcTypeVARCHAR typeHandlercom.example.handler.ListTypeHandler/ /table这里提醒一句只有在 SQL 中的列需要经过 typeHandler 转换时才在 XML 的参数占位符或 resultMap 中显式指定 typeHandler否则框架可能不会自动帮你调用这也是很常见的问题。5. 常见问题与避坑实录5.1 动态 SQL 报错到底怎么看动态 SQL 的报错往往比普通 SQL 更难排查因为出错的 SQL 是运行时拼接出来的。MyBatis 的报错信息很多直接是BadSqlGrammarException并不会把完整 SQL 打印出来。所以第一个建议把 MyBatis 的 SQL 日志打开。在 Spring Boot 的 application.yml 里logging: level: com.example.mapper: debugMyBatis 会打印完整的预处理 SQL 和参数列表看到Preparing: SELECT * FROM user WHERE username ?以及Parameters: admin(String)你就能快速判断拼接逻辑是否符合预期。如果还不清晰可以在开发环境临时用 p6spy 这类工具打印真实可执行的 SQL排查效率直接翻倍。5.2 foreach 批量操作时最容易踩的坑批量操作有几个高频问题我按出现频率排个序There is no getter for property named list这个一般不是 List 本身的问题而是你把 List 包在了一个 Map 或者实体对象里collection 要写 map 里的 key 或实体的属性名。Parameter xxx not found. Available parameters are [collection, list]入参是 List 但没加Param注解MyBatis 默认识别为 list 或 collection。解决方法是显式加Param(ids)XML 里就用ids。批量插入的 SQL 超过数据库限制单条缓存过大导致报错 PacketTooBigException解决思路是分批执行每批 300~500 条比较稳。批量更新漏掉 WHERE这是最危险的一旦发生就是全表更新事故。写完 SQL 后第一件事就是检查WHERE id IN是否匹配了所有的 CASE WHEN 分支。5.3 Generator 生成代码后项目启动报错的排查顺序Generator 生成完代码启动项目时报错的情况太常见了大多是以下几种实体类里 BigDecimal 字段映射错误多数是 javaTypeResolver 里 forceBigDecimals 配置没开导致小数变成 Float 或 Double精度丢失。启动时报Invalid bound statement (not found)XML 的 namespace 和 Mapper 接口的全限定名不一致或者 XML 没有打进 classpath。要检查 resources 目录下 mapper 文件是否被 Maven 排除。表名和关键字冲突比如表名是 order、descMySQL 下要加反引号。配置里的 beginningDelimiter 和 endingDelimiter 就是干这个用的或者手动为每张表指定generatedKey columnid sqlStatementJDBC/来避免某些数据库方言问题。这里给一个项目里总结的快速排查表格现象可能原因解决办法找不到 Mapper 映射文件XML 不在 classpath检查 resources 目录和 pom 的 resource 配置启动失败mapper 接口冲突扫描路径重复检查 MapperScan 扫描范围是否精确SQL 语法错误但仍能启动动态 SQL 运行时拼接问题打开 debug 日志查看 Preparing 内容生成的实体类属性全是错的表注释与列名不规范建议统一字段命名规范下划线转驼峰批量插入过慢循环单条插入了改成 foreach 拼接 VALUES 批量插入5.4 关于缓存的一点提醒热词里提到 MyBatis 缓存我顺带多说一句。MyBatis 一级缓存默认开启作用范围是同一个 SqlSession也就是在一次会话内相同查询会直接命中缓存。二级缓存需要手动开启而且是跨 SqlSession 的多表操作时如果缓存的数据涉及另一张表的更新非常容易出现脏数据。动态 SQL 和缓存的关系也很微妙动态 SQL 查询条件一变cacheKey 就变了可能根本命中不了缓存这是正常的。如果你某条查询动态性很强缓存价值其实不大反而不如不开二级缓存只依赖数据库本身的性能。二级缓存适合那些变动频率低、查询条件稳定的数据。涉及多表 join 的查询一定慎开二级缓存要不然数据一致性会让你怀疑人生。写在最后的实践体会项目里这几年用下来我对动态 SQL 和 MyBatis Generator 的定位越来越清晰这是一套典型的“自动化提效 手写兜底”组合。Generator 负责把那些标准化程度高的 CRUD 代码批量生产出来动态 SQL 则负责处理那些无法标准化的灵活业务场景。真正进阶的标志不是会用几个标签而是知道什么场景该用标签、什么场景该手写 SQL、什么场景连 MyBatis 都不适合。最后分享一个小技巧每次生成代码之后我习惯先看一眼生成的 XML 里 resultMap 的映射关系再结合数据库表结构把逻辑删除字段、乐观锁版本号、创建时间、更新时间这类公共字段在sql片段里统一维护。这样哪怕团队里别的同事重新跑一遍生成器代码风格和服务逻辑依然能保持一致。这条经验让我维护的老项目在人员更替的情况下也没出过乱子强烈推荐你也试试。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。