☰
动态SQL从入门到实战:拼接、MyBatis与防注入的工程实践
2026/10/13 17:23:09 网站建设 项目流程

做后端开发这几年,我发现自己跟“动态SQL”的交集几乎贯穿了整个职业生涯。刚入行时觉得它不过是MyBatis里的几个标签,后来维护过一个查询接口从三五个条件一路膨胀到二十多个筛选参数的内部系统,才真正明白这个话题背后的分量。动态SQL,简单说就是根据运行时参数动态拼接或构造SQL语句的技术,它解决的是“查询条件不确定”这类普遍需求,但同时也是SQL注入、性能退化、代码腐化的高发地带。这篇文章想把我在真实项目里踩过的坑、总结的套路、以及最终沉淀下来的一套可复用的写法完整分享出来,给正在跟复杂查询条件较劲的朋友一个参考。

1. 动态SQL为什么容易翻车:先搞清楚它解决的真实问题

1.1 需求场景:筛选条件永远是可选的

大多数中后台系统的列表页,长这样:顶部一排筛选框,可能有时间范围、状态下拉、关键词输入、组织机构树选择,甚至还有一批高级筛选折叠在“更多条件”里。用户不会每次都把所有条件填满,于是后端接口拿到的参数天然是可空的。

我在某公司维护过一个内部订单查询系统,最初查询接口只有3个条件:订单号、状态、时间范围。后来产品不断加需求:客户名称模糊搜索、金额区间、支付渠道、商品类目、发货仓库、是否异常订单……最后接口的查询参数对象里躺着18个字段,底层SQL从一次性写死的语句变成了一个字符串拼接的“怪物”。

这种场景不是某个行业的特例。只要是面向人的查询功能,条件可选就是刚需。把可选条件用SQL写死,要么查不出来(条件太严),要么失去筛选意义(条件缺失)。动态SQL因此成为几乎所有业务系统的标配。

1.2 静态SQL做不到的事,以及动态SQL的本质

静态SQL是预先写死、结构固定的语句。对固定查询来说,它性能最好、语义最清晰。但一旦条件变成可选,静态SQL只有两条路:为每种组合写一条SQL,或者用 WHERE 1=1 加上一堆 AND/OR 硬凑——前一种在条件组合超过个位数时立刻爆炸,2的n次方级别的排列组合没人维护得起;后一种写出来基本没法看,效果也等于动态。

动态SQL的本质,是把“条件判断”从SQL文本层面提前到代码逻辑层面。运行时先根据参数决定要不要拼某段条件,再生成最终SQL。这个“先判断,再拼装”的过程,是理解动态SQL所有坑的钥匙。为什么易错?因为你在同时处理两个维度的复杂度:业务条件维度的判断逻辑,和SQL语法维度的字符串拼接。任何一个维度的失误,都会让最终语句出错。

1.3 为什么它容易“翻车”:三个典型风险点

第一个风险点是字符串处理。少了空格、多了逗号、括号不配对、引号转义出错,这类问题在动态拼接时极其常见。第二个风险点是条件逻辑的边界。参数到底算不算“空”?空字符串算不算有值?空集合应该生成什么?边界没定义清楚,SQL就会在边界输入下产生错误行为。第三个风险点是安全隐患。用户传入的参数一旦直接拼进SQL,注入风险随之而来。

这三个风险点,几乎覆盖了动态SQL的所有“翻车”场景。后文会逐一展开,并给出对应的规避方案。简单说,动态SQL不是写出来就完事,而是要在一开始就想清楚它的语义边界和运行边界。

2. 四类主流动态SQL实现方案的对比与选型思考

2.1 字符串拼接入门:最直接也最危险

很多人在职业生涯初期都用过这种写法:

String sql = "SELECT * FROM orders WHERE 1=1"; if (StringUtils.hasText(orderNo)) { sql += " AND order_no = '" + orderNo + "'"; } if (status != null) { sql += " AND status = " + status; } if (startTime != null) { sql += " AND create_time >= '" + startTime + "'"; }

优点只有一个:直接,不依赖任何框架。缺点几乎全是致命的:参数一旦可控,注入风险立刻拉满;SQL和Java代码混在一起,可读性极差;条件多了以后,维护者根本不敢动中间的某一段,因为前后字符串拼接的状态很难追踪。

我自己见过一个很离谱的版本,某老项目里有个上千行的Service方法,核心业务就一百多行,剩下全是if判断后面挂SQL片段。那种代码不是不能运行,而是运行得让人害怕。每一个新需求进来,都是在脆弱的字符串上再叠一层判断,直到某天一个引号错误把整个查询打成SQL语法异常。

2.2 MyBatis XML动态标签:Java项目的主流选择

Java后端里,MyBatis的XML动态SQL是事实标准。它用XML标签表达条件逻辑,把SQL片段和Java业务代码分开维护:

<select id="queryOrderPage" resultType="com.example.OrderDO"> SELECT id, order_no, customer_name, status, amount, create_time FROM orders <where> <if test="orderNo != null and orderNo != ''"> AND order_no = #{orderNo} </if> <if test="status != null"> AND status = #{status} </if> <if test="startTime != null"> AND create_time &gt;= #{startTime} </if> <if test="endTime != null"> AND create_time &lt;= #{endTime} </if> </where> ORDER BY create_time DESC </select>

这里<where>标签会在没有任何条件时自动去掉WHERE关键字,有条件下自动去掉第一个AND/OR,配合<if>、<choose>、<set>、<foreach>等标签,基本覆盖了绝大多数查询、更新、批量插入场景。参数通过#{}绑定,天然走PreparedStatement参数化,规避了大部分注入风险。

2.3 类型安全的动态查询:JPA Criteria 与 QueryDSL

如果项目用的是JPA,还有一条更“类型安全”的路:Criteria API 或 QueryDSL。这类方案不直接操作SQL字符串,而是通过Java类型方法构造查询条件,编译期就能发现字段名写错的低级错误。

JPAQuery<OrderDO> query = new JPAQuery<>(entityManager); QOrderDO order = QOrderDO.orderDO; BooleanBuilder builder = new BooleanBuilder(); if (StringUtils.hasText(orderNo)) { builder.and(order.orderNo.eq(orderNo)); } if (status != null) { builder.and(order.status.eq(status)); } List<OrderDO> list = query.from(order) .where(builder) .orderBy(order.createTime.desc()) .fetch();

它的优点是类型安全、重构友好,缺点是学习成本稍高,团队不熟的时候很容易写出抽象过度的代码。我的建议是:项目里已经深度使用JPA就用它,否则不必为了动态SQL单独引入整套方案。

2.4 关键能力对比与选型建议表

我从可读性、安全性、学习成本、复杂条件支持、团队适配五个维度做了个对比:

维度字符串拼接MyBatis XMLCriteria/QueryDSL
可读性差好(SQL集中)中(依赖API熟练度)
注入安全差(易拼接)好(#{}参数化)好(参数绑定)
学习成本低中较高
复杂动态条件勉强支持支持良好支持较好
团队适配所有团队Java后端主流JPA项目

选型上没有银弹。核心判断标准是:团队维护这个代码的人更熟悉哪种表达方式。我见过强上QueryDSL最后因为没人会写而退回MyBatis的项目,也见过用字符串拼接撑到条件爆炸才重构的团队。早一点想清楚,比晚一点后悔划算得多。

3. 一段真实业务中的动态查询改造:从拼接地狱到可维护方案

这是一个真实场景的脱敏还原(字段已简化)。某订单查询接口,最初由一位老同事用字符串拼接实现,我接手时它已经维护了两年。

3.1 原始状态:一串让人窒息的拼接代码

public List<OrderDO> queryOrders(String orderNo, Integer status, String customerName, BigDecimal minAmount, BigDecimal maxAmount, Date startTime, Date endTime, String channel, Integer pageNo, Integer pageSize) { String sql = "SELECT * FROM orders WHERE 1=1 "; if (orderNo != null && !orderNo.isEmpty()) { sql += " AND order_no LIKE '%" + orderNo + "%'"; } if (status != null) { sql += " AND status = " + status; } if (customerName != null && !customerName.isEmpty()) { sql += " AND customer_name LIKE '%" + customerName + "%'"; } if (minAmount != null) { sql += " AND amount >= " + minAmount; } // 后面还有 maxAmount、startTime、endTime、channel 等一长串 sql += " ORDER BY create_time DESC LIMIT " + pageSize + " OFFSET " + pageNo * pageSize; return jdbcTemplate.query(sql, rowMapper); }

这段代码的问题不只是丑。用户如果把 orderNo 传成%' OR 1=1 --,这条SQL会直接变成全表查询,这是实打实的注入面。更隐蔽的问题是:所有动态条件挤在一个方法里,后续每次加筛选都要改这个方法体,方法行数持续膨胀,测试覆盖成本越来越高。

3.2 诊断结果:动态SQL散落且条件边界模糊

我接手后的第一件事不是改代码,而是把现有条件全部列了一遍,发现几个核心问题:条件判断标准不统一,有的用!= null,有的用!= null && !isEmpty(),空字符串和null语义混淆;金额区间同时为空或者只传一头没有处理;分页参数没有上限控制,传超大会拖垮数据库。

这些问题单独看都不致命,但组合在一起,就是动态SQL腐化的典型路径:判断含糊 -> 拼接出错 -> 打补丁 -> 更含糊。如果不重构,下一个接手的人只能在错误的基础上继续堆代码。我当时的判断是:这个接口已经不适合继续打补丁,必须把动态SQL的“条件决策”和“SQL文本”彻底拆开。

3.3 改造落地:参数对象 + 条件构造器

我把方法签名改成参数对象,同时把动态条件抽取到独立方法,SQL改用MyBatis XML维护,彻底放弃字符串拼接。改造后的查询入口:

public List<OrderDO> queryOrders(OrderQuery query) { return orderMapper.queryOrderPage(query); }

查询参数对象只关心“用户想筛什么”,不再关心“SQL怎么拼”:

public class OrderQuery { private String orderNo; private String customerName; private Integer status; private BigDecimal minAmount; private BigDecimal maxAmount; private Date startTime; private Date endTime; private String channel; private Integer pageNo; private Integer pageSize; }

对应的XML把所有边界处理统一交给<if>和<where>,每个条件只保留一层判断,页面传空串进来会被统一过滤掉。调用方不再需要关心SQL语法,只需要关注业务语义:想筛订单号就set订单号,没筛就不set,判断逻辑全部放在SQL片段里。

3.4 改造之后的变化

改造后的好处非常明显:接口方法从几十行缩到一行;所有SQL集中到Mapper XML,业务逻辑和SQL彻底分离;条件判断规则统一(空字符串和null都不生效);注入风险基本消除。更重要的是,后续加筛选条件时,改动点收敛为两处:参数对象加一个字段,XML加一个<if>,再也不会牵一发动全身。

这样的重构并不需要高深技巧,但它依赖一个前提:对动态SQL的边界和风险有足够清晰的认识。这也是我坚持先讲原理再给方案的原因。很多人拿到需求第一反应就是“加一个if条件”,但真正成熟的做法是先退一步,看看这组动态条件能不能被统一抽象。

4. 动态SQL的安全底线:SQL注入、空值判断与逻辑边界

4.1 为什么注入风险总是跟着拼接走

SQL注入的本质是:用户输入的内容被当成了SQL代码执行。字符串拼接动态SQL时,传入 orderNo 如果直接拼进语句,' OR '1'='1这类值就会改变SQL语义。参数化查询(PreparedStatement / MyBatis#{})会把参数值和SQL结构分离传递,数据库只把参数当数据,从而在结构上消灭注入。

这里要特别分清 MyBatis 的#{}和${}:#{}是参数占位符,安全;${}是字符串替换,直接拼SQL,有注入风险,只应在排序字段、表名等内部可控的少数场景使用,并且必须做白名单校验。任何一丁点外部输入进入${},都是事故。我在Review时只要看到${}接外部参数,基本都会打回要求改成白名单映射。

4.2 判空的完整边界:null、空串、空集合

动态SQL里最烦人的不是复杂语法,而是“到底什么算空”。我的经验是:字符串条件要同时判断null和空字符串,通常业务里两者等价;数值和时间条件通常只判断null;集合条件要判断是否为null以及是否为空列表。MyBatis 里写test="orderNo != null and orderNo != ''"就是这个道理。

还有一种常被忽略的边界:时间范围的开始时间与结束时间只传了一个。应用层应该在组装参数时就校验两者成对出现,或者在SQL里分别做>=和<=判断,而不是寄希望于调用方一定传完整。参数对象的校验逻辑放在Service层做,比散落在SQL片段里更容易测试和维护。

4.3 where 1=1 与<where>标签的本质区别

很多从字符串拼接转过来的老手,习惯在MyBatis里也写WHERE 1=1加AND。说实话它能工作,但 where 1=1 是反直觉的数学恒等式,阅读代码的人需要额外思考“为什么一直为真”。<where>标签会在有条件时输出WHERE并自动去掉第一个多余的AND/OR,没有条件时什么都不输出。语义清晰,也能避免WHERE AND这种低级错误。

举个例子,<where>里写了某个<if>,当传入条件都为空时,最终SQL直接就是SELECT ... FROM orders ORDER BY create_time DESC;而如果用 WHERE 1=1,拼出来的就是SELECT ... FROM orders WHERE 1=1 ORDER BY create_time DESC。后者虽然也能跑,但多了一个永远无意义的条件,也在不断训练后来者继续使用这个坏习惯。能用标签语义表达的,就别留手工拼接的小聪明。

4.4 数据权限条件:动态SQL里最不能丢的一环

在内部系统里,动态SQL经常还要叠加数据权限:当前用户只能看到自己部门/自己负责的数据。这种条件必须放在所有动态条件的最后追加,并且在代码审查时单独检查。我踩过一个很深刻的坑:某个导出接口复用了查询SQL片段,但片段里忘了带权限条件,结果用户通过构造参数越权拉到了全部门数据。后来所有涉及权限过滤的查询,都在Mapper层统一拼一个不可被覆盖的权限片段,并通过Review清单强制检查。

安全底线总结成一句话:参数永远走占位符,条件永远有明确空值语义,权限条件永远在动态拼接的最外层兜底。这三条做到了,动态SQL的安全性就有基本保障。

5. 动态SQL与数据库性能的博弈:索引、执行计划与参数化

5.1 动态条件怎样悄悄弄丢索引

动态SQL容易在不知不觉中让查询走不上索引。典型场景包括:对索引字段使用函数(WHERE DATE(create_time) = '2024-01-01'会让create_time索引失效,正确写法是create_time >= '2024-01-01' AND create_time < '2024-01-02');模糊查询前缀带通配符(LIKE '%xxx'无法利用索引);OR条件连接多个字段时,数据库优化器可能选择全表扫描。

我遇到过一个筛选逻辑:用户输入订单号时希望模糊匹配,于是直接写了LIKE '%' + #{orderNo} + '%'。线上订单量小时没问题,数据量突破千万后,这个条件直接把查询拖慢了一个数量级。合理的方案是:订单号这种高频精确条件用等值匹配,需要模糊的字段单独建全文索引,或者用ES这类检索组件处理,而不是让SQL硬扛全表模糊。

5.2 执行计划缓存:拼接SQL的另一笔隐形账

数据库对SQL语句会尝试缓存执行计划。完全拼接出来的SQL,每个参数值不同都会生成一个不同的SQL文本,缓存立即失效,每次都要重新解析、生成执行计划。参数化SQL(占位符)则无论参数怎么变,SQL文本都不变,执行计划可以复用。这解释了为什么同样一个查询,字符串拼接方式在并发高的时候数据库CPU占用会异常偏高。

即使MyBatis XML看起来像“拼接”,它通过#{}传给数据库的仍然是占位符参数,最终SQL文本稳定,执行计划可复用。所以从“拼接字符串”改为“参数化动态SQL”,不只是安全收益,还有性能收益,这一点常被忽略。很多团队只关心功能跑通,却不知道同一套查询在两种写法下的数据库压力差异有多大。

5.3 分页总数统计与排序字段白名单

动态查询几乎总是配分页。分页包含两条SQL:Count统计和列表查询。如果列表SQL复杂度高,Count查询最好单独写一个轻量版本,只统计主表记录数,避免把大字段和复杂JOIN带进去。MyBatis的分页插件能在底层自动生成Count SQL,但有时它生成的 Count 语句不够优化,这时可以在Mapper里写独立的 countByCondition 方法,用更简单的SQL完成统计。

排序字段是另一个容易被忽视的注入风险点。排序通常走ORDER BY #{sortField},但占位符不能用于排序字段,于是有人改用${sortField},直接把前端传来的字段名拼进去。正确做法是白名单:在代码里把允许排序的字段名映射到固定枚举,再拼进SQL。前端传 create_time 没问题,传create_time; DROP TABLE orders这种值只会被映射拒绝。

5.4 一次真实慢查询的排查链路

某次线上告警,订单列表接口在晚高峰P99延迟升到3秒多。排查过程:先抓慢查询日志,发现慢SQL集中在两个动态条件同时出现的场景;用EXPLAIN看执行计划,发现 OR 条件下两个索引字段无法同时使用,优化器走了全表扫描。修复方案是把 OR 改写成两个查询结果合并,或者在应用层拆成多个条件分支组合。改完后P99降到400毫秒以内。

这次排查给我最大的收获是:动态SQL的每个条件分支,都可能在特定组合下生成一条完全不同的执行计划。测试阶段跑通只是一方面,还必须主动构建“多条件组合”的压力测试,提前发现组合导致的索引失效。性能问题在动态SQL里从来不是单一条件的错,而是条件组合之间的相互作用。

6. 动态SQL的调试与测试:没人教但必须会的技能

6.1 日志配置:把最终执行的SQL和参数打印出来

动态SQL的难点在于“看不见最终SQL”。配好日志是首要任务。MyBatis里开启日志输出后,控制台会打印类似==> Preparing: SELECT ... WHERE order_no = ?和==> Parameters: ABC123(String)这样的内容。拿到这两行,你才能确认动态条件按预期生效。我见过不少同事在业务代码里打断点看参数,却不知道SQL长什么样,排查效率极低。

日志级别建议:开发环境开DEBUG,生产环境开INFO或关闭,避免SQL文本和参数值大量落盘引发日志膨胀和敏感信息泄露。尤其是包含用户手机号、身份证号的查询参数,一旦打到日志里就是数据安全事件。

6.2 用例设计:覆盖条件组合而不是一个happy path

动态SQL最大的测试风险是“只测了最常用的路径”。一个列表查询如果有8个可选条件,理论上有几百种组合,不可能全测,但至少要覆盖:全部条件为空(验证WHERE语义正确)、全部条件填满(验证条件能同时生效)、单条件逐个生效(验证每个if分支独立正确)、边界值(空字符串、0、超大分页)。这些用例不一定要自动化,人工回归时按清单走一遍也很有价值。

条件组合测试最好通过接口层做,而不只是看SQL生成是否正确。因为动态SQL的最终目的是“用正确的数据做正确的事”,SQL对了但参数校验漏掉的边界,依然会导致业务出错。接口层测试能同时覆盖两者,投入产出比很高。

6.3 代码评审时的快速检查清单

Review动态SQL相关的代码,我会重点盯这几个位置:所有外部输入是否都走#{}而不是${};排序字段是否白名单;每个<if>的条件是否同时判了null和空串;<where>、<set>、<trim>是否被正确使用,有没有 WHERE 1=1 之类的历史遗留;分页Count是否简洁;权限过滤条件有没有在最后兜底;批量的<foreach>有没有控制最大条数,防止一次插入几万条拖垮数据库。

这份清单不复杂,但每次都能在Review里捞到问题。动态SQL的坑大多不是智商问题,而是规范和意识问题。把这些检查养成习惯之后,动态SQL就不再是每天提心吊胆的代码段,而是一个可预测、可维护的常规技术点。我在实际使用中发现,凡是提前把边界想清楚、把条件集中管理的项目,后面上线都很少因为动态SQL出线上事故;反而是那些“先写出来再说”的代码,往往要花好几倍的时间在排障和修补上。所以如果你刚开始接触动态SQL,不妨从参数对象的拆分和SQL片段的集中维护做起,这个习惯会帮你避开大部分后续的麻烦。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询