新闻详情

新闻详情

首页 / 资讯中心 / 详情

MyBatis动态SQL全解析:从条件拼接到标签化实践

发布时间:2026/9/24 22:37:02来源:尧图网络
MyBatis动态SQL全解析:从条件拼接到标签化实践
如果你写过多条件查询大概率经历过这样的场景一个列表筛选页筛选条件有七八个每个都可选可不选于是你在Java代码里一层层嵌套if去拼SQL字符串。先判断参数是不是null再判断是不是空字符串中间还要注意“AND”和“OR”的位置一个不留神就多一个“WHERE”或者漏掉一个空格。这种拼接代码写出来没人愿意接手改起来更是头皮发麻。后来我彻底转投MyBatis动态SQL标签才真正意识到一个问题一个成熟的持久层框架就应该把条件拼接这种又脏又容易出错的活接过去而不是让你在业务代码里堆砌字符串。MyBatis用一组声明式标签把SQL的组装逻辑写进XML直观、可复用、改起来也安全得多。这篇文章我打算按真实项目里的使用频率和踩坑深度来写把动态SQL里的if、where、set、trim、choose、foreach、bind、sql、include这些标签逐一带过讲清楚它们各自能干什么、边界在哪、实际项目中怎么组合才能少走弯路。不论你是刚开始学MyBatis的新手还是已经被动态SQL性能问题折磨过的老手都能在里面找到可以直接拿去用的方案。1. 为什么你会需要动态SQL从字符串拼接的痛到标签的甜1.1 写死在代码里的SQL为什么不靠谱先看一个极其常见的列表查询场景。用户在前端勾了一堆筛选条件后台要根据传入参数动态生成SQL。很多人最初会写成这样public ListProduct findProducts(String name, Long categoryId, BigDecimal minPrice) { StringBuilder sql new StringBuilder(SELECT * FROM product WHERE 1 1); if (name ! null !name.isEmpty()) { sql.append( AND name ).append(name).append(); } if (categoryId ! null) { sql.append( AND category_id ).append(categoryId); } if (minPrice ! null) { sql.append( AND price ).append(minPrice); } // 然后执行这个sql字符串 }这段代码有几层风险。最明显的是SQL注入——name直接拼接进SQL用户传入一个 OR 11整条查询就变了味道。这种漏洞在真实项目中出现频率远比想象中高因为“能跑就行”的代码往往没人愿意回来加固。其次是可维护性。条件一旦多起来拼接逻辑会爆炸尤其涉及排序、分页、多表关联时你必须格外小心空格和逗号的位置。更痛苦的是这种拼接代码分散在各个Service方法里想统一改一个字段名得像捉迷藏一样到处找。还有一门容易被忽略的隐患为了规避“如果所有条件都不存在WHERE后面就什么都没有”的问题很多人被迫写上WHERE 1 1。这个写法能让SQL语法永远成立但会在一定程度上干扰优化器对查询的解析尤其叠加复杂查询时执行计划可能不是最优的。1.2 动态SQL适合解决什么、不适合解决什么MyBatis动态SQL的价值就是把上面这堆手工拼接替换成XML里的声明式标签让SQL结构在视觉上保持完整条件分支用标签包起来哪些条件参与拼接、用什么连接词一目了然。它的适用场景集中在这几类第一多条件组合查询比如筛选列表这是最典型的使用场景第二动态更新字段也就是“只更新非空字段”避免把原来有值的列覆盖成null第三批量操作比如一次性插入多条记录、按ID集合查询第四某些必须动态指定的SQL片段比如动态排序字段、动态表名这类需要格外小心后面专门讲。但它不是万能的。动态SQL绑定在XML里如果你需要根据极其复杂的业务规则临时拼出不同结构的SQL动态SQL会让XML变得臃肿难读这时候不如考虑MyBatis的Provider注解如SelectProvider或直接走专门的数据查询层。另外动态SQL也不是把一条超复杂的SQL拆成多段拼接的“万能胶水”一条语句超过几十行、十几个条件分支时即使运行没问题后续维护的人也会崩溃。合理的做法是把复杂查询拆成多个职责清晰的SQL方法而不是全都塞进一个select里。2. if、where、set、trim条件组合与更新场景逐一拆解2.1 if标签的语法和OGNL表达式的坑if是动态SQL里最简单、最常用的标签它的作用是在test表达式成立时拼接内部SQL片段。select idfindProduct resultTypecom.example.Product SELECT id, name, price, category_id, create_time FROM product WHERE 1 1 if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testcategoryId ! null AND category_id #{categoryId} /if if testminPrice ! null AND price gt; #{minPrice} /if /selecttest表达式用的是OGNL这里有几个非常容易踩的坑。第一逻辑与不能用写and才是标准姿势。如果你在XML里直接写解析XML时就会报错因为是XML保留字符必须转义成amp;但转义之后OGNL又很难看。所以MyBatis约定用and和or作为逻辑连接词这也是OGNL本身就支持的写法。第二字符串的空判断要同时判断! null和! 二者缺一不可。很多初学者只写了name ! null结果前端传了一个空字符串条件照样拼接查出来的结果跟预期完全不匹配。第三数值类型的判断。categoryId ! null是对数字最常见的判断但要注意如果用了基本类型long而不是包装类型Long你无法判断它是否传值因为基本类型的默认值是0。在大多数业务里0和null是两种完全不同的语义所以参数尽量用包装类型。第四test表达式里可以直接调用方法比如name.trim() ! 但没必要在XML里做这个操作。更稳妥的做法是在入参阶段就把name的空白清理干净XML里只做最简单的判断。我见过不少项目在test里写name.trim().length() 0这也能工作但可读性很差而且每次拼接都要多一次方法调用。第五注意SQL里的运算符。minPrice 10写在XML里会被当成标签结束符所以必须写成gt;或者用![CDATA[ ]]包住。这个细节非常容易让第一次写的人一头雾水看到报错才知道是XML转义的问题。2.2 where和trim告别手动拼AND上一个例子里的WHERE 1 1虽然能保证语法正确但属于“治标不治本”的方案。MyBatis提供了where标签专门用来解决“条件都不成立时WHERE后面什么都没有”的情况。select idfindProduct resultTypecom.example.Product SELECT id, name, price, category_id, create_time FROM product where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testcategoryId ! null AND category_id #{categoryId} /if if testminPrice ! null AND price gt; #{minPrice} /if /where /selectwhere的核心能力有两层如果内部条件全都为空它不会输出WHERE关键字如果内部有条件它会自动在开头补上WHERE并且把第一个连接词AND或OR吃掉。也就是说上面这段SQL即使只有categoryId一个条件最终生成的SQL也是WHERE category_id ?而不是WHERE AND category_id ?。这里要提醒一点where只处理开头的AND和OR不处理中间的。如果你在第一个if里写了OR后面跟条件在第二个if里又写了AND拼接出来的SQL是WHERE OR xxx AND xxx照样报错。所以规范的做法是每个条件片段都用AND开头让where统一吞掉第一个。trim标签能做到比where更精细的控制。当你需要自己指定前缀和后缀或者要删除的不是AND、OR而是别的字符时where就不够用了。看一个典型例子动态UPDATE时SET子句后面不能用WHERE处理这时候就需要trim prefixSET suffixOverrides,这个组合的语义是前缀加SET并且如果整个拼接结果的结尾是逗号自动去掉这个逗号。2.3 set动态UPDATE的字段控制更新场景经常面临一个问题前端只改了部分字段后端如果直接update所有列没传的字段就被覆盖成null了。我见过不少团队在Service层先查一遍原记录再手动赋值非空字段最后update。这当然可行但很累。用set标签就干净得多update idupdateProduct parameterTypecom.example.Product UPDATE product set if testname ! nullname #{name},/if if testprice ! nullprice #{price},/if if testcategoryId ! nullcategory_id #{categoryId},/if if testdescription ! nulldescription #{description},/if /set WHERE id #{id} /updateset标签有两个内置行为一是如果内部没有任何条件成立它不会输出SET关键字二是如果拼接结果以逗号结尾它会自动去掉最后一个逗号。所以上面这种写法无论哪个字段为空最终SQL的SET子句都是合法的。这里必须强调一个实际项目里非常严重的坑如果set内部条件全都不成立最终SQL会变成UPDATE product WHERE id ?直接语法错误。所以在业务层要负责兜底至少保证能更新一个字段或者调用方明确知道传了什么值。另一个坑是set标签里不要放需要判断为空字符串的字段时漏掉判断。比如description传了一个空字符串“”在大多数业务里这跟null不是一回事用户可能想清空描述这时候你如果把空字符串也跳过了描述就永远不会被更新。所以具体判断条件要跟业务对齐是“非空才更新”还是“有值包括空串就更新”。2.4 choose/when/otherwise多个互斥条件怎么选如果你有一组互斥条件想在满足A时走A逻辑满足B时走B逻辑都不满足时走默认逻辑那要用choose、when和otherwise。它的结构类似Java里的switch-case从上到下匹配第一个成立的when后续不再执行。select idfindOrder resultTypecom.example.Order SELECT * FROM orders where choose when teststatus ! null status #{status} /when when testuserId ! null user_id #{userId} /when otherwise create_time gt; DATE_SUB(NOW(), INTERVAL 7 DAY) /otherwise /choose /where /select这个标签组合在日常项目里的使用频率不如if高但它能把“多个分支只选一条”的逻辑写得很清晰。比如短信模板、通知类型、订单状态机里的查询都会用到。注意otherwise是可选的如果所有when都不满足且没有otherwise那么这块内容整体不输出。和where配合时它一样能吃掉开头多余的ANDOR。3. foreach、bind、sql、include批量操作与复用实战3.1 foreach的集合参数约定与批量操作foreach标签主要解决两类问题IN查询和批量插入/更新。IN查询最典型的写法如下select idfindByIds resultTypecom.example.Product SELECT id, name, price, category_id, create_time FROM product WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /select很多第一次用的人会忽略一个关键点集合参数的collection取值。这取决于Mapper接口里参数是怎么定义的。如果你只有一个List参数没有加Param注解那么XML里既可以用collectionlist也可以用collectioncollection。如果你传入的是数组默认名称是array。如果你有多个参数就必须用Param指定名称例如ListLong findByIds(Param(ids) ListLong ids)那么collection填ids。这个默认命名规则我踩过两次坑过了很久才搞明白。一次是参数类型从数组改成List没改XML里的collection值结果直接报错另一次是加了一个Param注解改变参数名XML同步改。所以建议项目里统一规范集合参数一律加上Param避免依赖默认名这样代码即文档也减少无意中重构导致的问题。批量插入是foreach另一个高频用法insert idbatchInsert INSERT INTO product (name, price, category_id) VALUES foreach collectionlist itemitem separator, (#{item.name}, #{item.price}, #{item.categoryId}) /foreach /insert这里要注意批量操作对SQL长度和数据库参数个数是有上限的。MySQL里默认max_allowed_packet限制的是单条SQL报文大小如果一次插入几千条SQL文本会非常长容易超出限制。参数个数方面虽然PreparedStatement理论上能支持很多参数但不同数据库驱动和服务器配置有差异。稳妥的做法是控制单批数量比如500条一批循环插入。3.2 bind与模糊查询的跨数据库兼容bind标签可以在XML里定义一个变量然后用OGNL表达式计算它的值。最常见的场景是模糊查询。select idsearchProduct resultTypecom.example.Product bind namepattern value% keyword %/ SELECT id, name, price FROM product WHERE name LIKE #{pattern} /select为什么要多此一举用bind因为不同数据库的字符串拼接语法不一样MySQL用CONCAT(%, #{name}, %)Oracle用% || #{name} || %SQL Server又有一套写法。用bind把最终的模式值算好SQL主体就统一了换数据库时只需改bind表达式不需要动每条SQL。另一个好处是如果keyword为空或null可以在bind里做一层兜底处理避免拼接出%%这样的全匹配模式。不过用bind时有个小坑bind的位置要在引用它的SQL片段之前最好放在select标签的最顶部。别放在where里面也不要放在使用它的SQL之后因为XML的执行顺序是从上到下的bind的变量作用域是它所在的那条SQL语句。3.3 sql片段与include的复用技巧和边界sql和include用来定义可复用的SQL片段适用于公共列名、公共JOIN条件、公共WHERE条件等。sql idproductBaseColumns id, name, price, category_id, create_time /sql sql idproductJoinCategory FROM product p LEFT JOIN category c ON p.category_id c.id /sql select idfindProductDetail resultTypecom.example.ProductDetail SELECT include refidproductBaseColumns/, c.name AS category_name include refidproductJoinCategory/ WHERE p.id #{id} /select这个组合能让多个查询方法引用同一组列或同一段JOIN逻辑避免字段名散落各处。字段一旦增加或改名只需要改这一个片段。include还支持往片段里传参数但说实话传参能力在动态SQL里不常用因为变量传递在复杂XML里很容易混乱。如果我发现某个SQL片段需要大量传参才能复用通常会重新考虑这个片段是否值得抽出来。过度抽象的SQL片段跟过度抽象的Java方法一样别人接手时根本看不懂它拼出来的是什么。我的建议是公共列名、公共JOIN这种“纯静态内容”适合抽包含大量动态判断的片段抽出来反而降低可读性谨慎使用。4. 动态SQL内部的执行逻辑与参数安全4.1 从XML到BoundSql的动态SQL解析过程动态SQL之所以是“动态”的是因为MyBatis在每次执行时都会重新评估条件组装出最终SQL。理解这个过程有助于排查很多诡异问题。MyBatis解析一条带动态标签的查询流程大致如下启动时XMLMapperBuilder读取Mapper XML文件把每个select/insert/update/delete元素转成MappedStatement在转换过程中XMLScriptBuilder识别内部是否包含动态标签如果有就把整条SQL解析成一颗由SQLNode组成的树每个动态标签对应一个节点最终构建DynamicSqlSource如果没有动态标签就直接用字符串构建RawSqlSource。两者的区别在于RawSqlSource在启动阶段就把SQL固定下来了每次执行只是替换参数占位符DynamicSqlSource则必须在每次执行调用时重新遍历SQLNode树根据当前参数去判断每个if/where标签是否成立把成立的文本片段拼起来生成最终SQL。这也是为什么动态SQL的解析开销比静态SQL略高。不过这种开销通常微乎其微远小于一次数据库网络交互的耗时正常业务规模下不必过度担心。真正该担心的是把几百行的动态判断塞进一条SQL遍历节点本身的CPU耗时和内存分配才可能变得可感知。4.2 #{}与${}的选择和注入边界动态SQL标签管的是“要不要拼接这段SQL”而#{}和${}管的是“参数以什么形式进入SQL”。#{}最终会变成PreparedStatement里的占位符?参数值由数据库驱动预处理天然免疫SQL注入。绝大多数传值场景都用它比如IN里面的元素、where条件值、插入字段值。${}则是字符串替换MyBatis在拼接SQL时直接把变量值替换进去不做任何转义。所以只要外部可控数据进入${}就存在SQL注入风险。但有些场景确实绕不开它动态排序字段、动态表名、动态列名。比如ORDER BY #{sortField}是不行的因为占位符会把值当成字符串最终变成ORDER BY name按字符串常量排序完全不是预期的行为。这时候只能用${sortField}让它直接拼成ORDER BY name。用${}的正确姿势是先做白名单校验。在Java侧写好允许的排序字段集合传入的值必须命中集合才允许拼接或者用枚举转换从UUID、随机数这类外部标识映射到真实字段名。单纯依赖前端传字段名是不安全的。表名更严格建议列一个固定的表名映射表不要直接让前端传表名进来。实际项目里我还见过一种滥用模糊查询用${keyword}直接在SQL里拼%这等于把一个高危漏洞挂在页面上。正确做法是用bind生成模式串用#{}传入既安全又兼容跨数据库。4.3 动态SQL和二级缓存组合时的注意点MyBatis的缓存涉及一级缓存SqlSession级别和二级缓存Mapper级别。二级缓存开启后每次查询都会基于MappedStatement、动态SQL解析后的SQL文本、参数值等信息计算CacheKey。这也意味着只要动态SQL拼接结果不同缓存key就不同不会发生“不同条件查出相同缓存结果”的逻辑错误。真正需要注意的反而是优化层面。如果一条查询带大量动态条件可能出现的查询组合非常多缓存命中率被稀释二级缓存反而增加了缓存维护的开销。尤其数据量较大、并发写多的情况下缓存失效频繁收益很低。我的经验是二级缓存只用在数据基本不变化、查询频率极高且条件组合非常有限的场景。大部分动态SQL查询保持使用一级缓存或干脆不依赖缓存直接走数据库性能可能更可控。另外动态UPDATE使用set时只要涉及更新操作MyBatis默认会刷新清空关联的二级缓存。如果某个查询被缓存了而更新方法没有走到相同Mapper的缓存刷新路径可能出现脏读。生产环节中我对缓存的使用一直很保守宁可不缓存也不要读到脏数据。5. 实战排查与调优从“能跑”到“跑得好”5.1 一次动态SQL慢查询的完整排查链路去年我在一个报表系统里排查过一次动态SQL性能问题过程比较有代表性。现象是某个列表查询接口数据量其实不大只有二十万行但带条件查询时经常要两三秒。一开始大家以为是索引问题加了索引后依然很慢。后来我把MyBatis日志里的SQL和参数完整打印出来抓到了一条生成后的SQL。一看就发现问题了动态SQL拼出来的条件里模糊查询用了LIKE %xxx%前导通配符导致索引失效另一个字段虽然加了索引但动态判断里允许它传空字符串空字符串被当成一个合法条件输出生成status 等于把一批不符合预期的数据也筛掉了扫描行数暴涨。排查链路大致是第一步开启MyBatis SQL日志把每次执行的SQL语句和参数值落到日志文件第二步把日志里的SQL复制到数据库客户端用EXPLAIN看执行计划第三步对比索引使用情况识别哪些条件导致全表扫描第四步回到动态SQL定义确认是不是标签判断条件过宽导致某些“不合法但非null”的值也进入了SQL。那次最终改了三个地方模糊查询从LIKE %xxx%改为LIKE xxx%业务上支持前缀匹配的用前缀匹配不支持的前台提示改用其他方案空字符串和null统一在入参阶段转成nullXML里只用! null判断给原本是全表扫描的字段加了复合索引。改完查询时间从接近两秒降到几十毫秒。5.2 常见动态SQL性能问题汇总根据这些年看到的项目动态SQL的性能问题大多来自下面几类整理成一张表方便对照问题类型典型表现建议处理方式LIKE前置通配符慢查询日志里出现大量%关键字%数据量小可接受量大时改前缀匹配或引入全文检索WHERE 11优化器选择非最优执行计划使用where标签去掉11IN列表超大单条SQL过长或参数过多触发数据库限制分批处理控制单批数量字符串空值判断不一致空字符串进入SQL造成非预期查询统一入参清洗null和空串语义分开动态排序加字段白名单缺失安全性风险加上排序字段不受控白名单校验后再用${}拼接复杂动态嵌套过深SQL难读懂解析开销增大拆分成多个简单查询或转用Provider注解每条动态SQL在写的时候就该想一想如果这个条件被传入了最坏的值SQL会变成什么样如果所有条件都不传SQL会变成什么样这两种极端情况比“常规传参”更能暴露标签设计的问题。5.3 保持动态SQL可维护性的工程习惯如何让动态SQL“能跑”且“好维护”我从项目实践中总结了几条做法。第一条是命名规范动态SQL方法名直接表达查询意图比如findProductsByDynamicCondition别用select1、query2这种编号命名。第二是XML内部缩进和分行的格式要像写普通Java代码一样用心每个if块内的SQL片段单独换行缩进统一。你很难想象一段结构混乱的XML在出问题时会多浪费时间。第三条是全局开启日志并用工具规范SQL打印把SQL和参数输出到单独日志文件方便排查问题时直接复制。MyBatis自带日志级别调整Spring Boot项目里把mybatis.configuration.log-impl设成org.apache.ibatis.logging.stdout.StdOutImpl就能在控制台看到SQL或者用mybatis-plus的sql-log之类配置。第四条是在Mapper接口注释里写清楚参数含义和null语义。比如“name为空时不过滤为空字符串时同样不过滤”这种约定写在代码里比让后来的人自己去猜要高效得多。为了避免后来者不清楚我还习惯在select上方加一行注释说明这个查询会被哪些页面调用这样改动起来风险可控。我个人的一个体会是动态SQL最忌讳过度设计。一个列表查询能用一个where加三条if解决的就没必要换成choose加trim加foreach的多重嵌套。越简单的标签组合越容易被理解和维护复杂不代表高级稳定、清晰才是真正的高级。如果你正在项目里推广MyBatis动态SQL建议先从一个实际的列表条件查询入手把if和where跑通再慢慢扩展foreach和set。等这几个核心标签用熟了剩下的大多数问题都能很自然地找到对应解法。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

最近很火的Bonsai 2 27B 在8G显卡上真的能跑,也没有想象的那么笨 2026/9/24 23:09:47

最近很火的Bonsai 2 27B 在8G显卡上真的能跑,也没有想象的那么笨

Bonsai 2 27B 实测:8G显卡真跑起来了,附厂商没有的速度数据这几天这个模型到处都是:27B 的模型压到 5.9 GB,厂商说保留了 98.2% 的能力,RTX 5090 上跑到 143 tok/s。两天下载量 40 万。我一开始是被一个词搞住的。有人…

阅读更多 →
飞腾D2000硬件设计避坑指南:DDR/PCIe/RGMII等关键接口布线规范 2026/9/24 23:09:40

飞腾D2000硬件设计避坑指南:DDR/PCIe/RGMII等关键接口布线规范

简介:本资源是飞腾腾锐D2000芯片的官方硬件设计指导手册V1.3(2023年4月发布),面向嵌入式硬件工程师、板级开发人员及国产化平台系统设计师,解决基于D2000芯片开展原理图设计、PCB布局与信号完整性验证等核心工程问题。…

阅读更多 →
PHP序列化字符串在Flutter与鸿蒙上的解析适配与历史债务治理实践 2026/9/24 23:09:33

PHP序列化字符串在Flutter与鸿蒙上的解析适配与历史债务治理实践

接手老项目时同事对我说的一句话,至今让我印象深刻:“你以后会感谢 PHP 的 serialize() 的——因为它让你见识到什么是真正的技术债。”当时还不以为然,直到 Flutter 客户端要把数据库里那些 PHP 序列化字符串读出来展示,还要在鸿…

阅读更多 →
微软云计算 Windows Azure 基础教程:云服务器创建、PaaS 迁移与避坑 2026/9/24 23:09:33

微软云计算 Windows Azure 基础教程:云服务器创建、PaaS 迁移与避坑

先说个我自己的经历。早年做项目部署,最怕的不是写代码,而是“等机器”。申请一台服务器,走流程等审批,运气好一周,碰上流程繁琐的,半个月就过去了。机器到了之后,装系统装环境,调网…

阅读更多 →
司法文本相似匹配:双塔BERT微调实战与法研杯高分方案 2026/9/24 23:09:33

司法文本相似匹配:双塔BERT微调实战与法研杯高分方案

简介:本资源是中国法研杯司法人工智能挑战赛‘相似案例匹配’赛道冠军方案的完整技术实现,面向法学与人工智能交叉领域的研究者、算法工程师及高校相关专业学生,聚焦司法场景下法律文书语义匹配这一核心任务。压缩包共28个文件,含…

阅读更多 →
CIDR核心指南:从/24概念到子网划分与路由聚合实践 2026/9/24 23:09:33

CIDR核心指南:从/24概念到子网划分与路由聚合实践

你平时查 IP 地址的时候,肯定见过192.168.1.100/24这种写法。/24是什么?为什么要这么写?它跟传统的子网掩码255.255.255.0是什么关系?如果你刚开始学网络协议,很容易被这一串数字绕晕。这篇学习笔记是我重新梳理CIDR&a…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞