SQL中“||”拼接条件为何成为慢SQL元凶?优化方案全解析
2026/9/16 2:51:44 网站建设 项目流程

前几天review同事提交的一条取数SQL,差点没把眼睛看花。WHERE条件里密密麻麻全是“||”拼出来的字符串:AND create_time >= TO_DATE(p_date || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')AND name LIKE '%' || p_keyword || '%'AND dept_no || '' = p_dept。同事说这样写省事,前端传什么参数直接拼进去就行,空值也不用单独判断。我听完第一反应是:省事是省事,慢SQL估计也快来了。打开执行计划一看,果然三个过滤条件里两个没走索引,还有一个在谓词信息里出现了隐式转换。

借这个项目标题,我把“||拼接条件”这种写法的常见问题、底层原理和优化方案整体梳理一遍。主要面向写SQL、写存储过程、写Java/MyBatis的同行,尤其是刚接手复杂报表的人。内容偏Oracle多一些,但MySQL、PostgreSQL里的原理也都通用,看的时候把语句对应过去就行。

1. 场景还原:这些“||”用法最容易埋雷

1.1 存储过程与报表SQL里的“万金油”条件

我在实际项目里见到最多的,就是有人为了让一个查询条件“可传可不传”,把参数直接拼进条件里。典型长这样:

SELECT * FROM t_user_history WHERE 1 = 1 AND (p_dept IS NULL OR dept_no = p_dept) AND create_time >= TO_DATE(p_date || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS') AND user_name LIKE '%' || p_keyword || '%';

这段SQL里有两处“||”拼接。p_date || ' 00:00:00'是把日期参数补成完整字符串再转DATE,'%' || p_keyword || '%'是做模糊查询。这两个写法都很常见,但如果p_date传的是NULL,拼接结果还是NULL,TO_DATE(NULL)直接报错;LIKE '%' || NULL || '%'在Oracle里等价于LIKE NULL,一条数据都查不出来。换句话说,“拼接”并没有帮你兜住空值,反而制造了新的边界问题。

更隐蔽的问题是索引。create_time >= TO_DATE(p_date || ' 00:00:00', ...)右边虽然是函数,但如果左边列没套函数,且右边是常量/绑定变量表达式,Oracle理论上还能用上索引;可一旦日期格式写错或者参数类型是VARCHAR2,条件就常常演变成TO_CHAR(create_time)和字符串比较,索引直接废掉。这类SQL在报表系统里出现频率极高,往往一张表几百万行,跑一次就是全表扫。

1.2 应用层代码里的拼接,危害更直接

数据库层的“||”再怎么说也还是SQL语法层面的操作,应用层手工拼接字符串就更危险了。我接手过一个老系统,Java代码里全是这种写法:

String sql = "SELECT * FROM t_user_history WHERE 1 = 1"; if (deptNo != null && !deptNo.trim().isEmpty()) { sql += " AND dept_no = '" + deptNo + "'"; } if (keyword != null && !keyword.trim().isEmpty()) { sql += " AND user_name LIKE '%" + keyword + "%'"; } Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql);

这个代码有两个问题。第一,dept_no = '10'这种写法,列是VARCHAR2时还没事,如果列是NUMBER,数据库就要把dept_no列做隐式转换去和字符串比较,索引失效。第二,直接把用户输入拼进SQL,如果keyword里带一个单引号,SQL语法直接崩;带一段' OR '1'='1,就是教科书级别的注入。即使不考虑安全问题,每次查询生成的SQL文本都不同,数据库执行计划缓存基本形同虚设。

1.3 先给结论:拼接条件共性和影响面

“||”拼接条件的共性可以概括成三句话:空值语义容易被误解,索引容易被搞丢,动态SQL文本不稳定导致硬解析。这三件事单独拎出来都能写篇文章,合在一起就是慢SQL制造机。

影响面也很大。只要系统里有手工拼SQL的场景,无论是PL/SQL存储过程、Java JDBC,还是MyBatis里的${},都跑不掉。后台管理系统的多条件筛选、报表系统的日期区间查询、接口里的模糊搜索——这些业务往往会写“可传可不传”的查询条件,而“可传可不传”恰恰是拼接写法最泛滥的地方。

2. 为什么拼接条件会成为性能黑洞

2.1 列被函数或拼接包裹,索引为什么失效

先说索引失效的原理。B-tree索引存储的是原始列值的有序排列,优化器走索引的前提是条件里能直接拿列值和查询值做比较。一旦条件写成dept_no || '' = p_dept,数据库就得先把每一行的dept_no取出来拼接一下,再和p_dept比较。这个拼接动作对优化器来说相当于对列施加了一个“计算过程”,它无法直接根据索引键做范围定位,只能老老实实把整张表扫一遍,然后逐行计算。

打个比方,索引就像书的目录,目录是按原样排好的。你要查“张三”,直接翻目录找“张”开头就行;但如果要求你“在所有名字后面加一个逗号再找‘张’”,目录就废了,只能一页一页翻正文。这就是为什么在所有数据库优化规范里,第一条永远是“不要在列上做任何运算”。

更坑的是,很多开发者写dept_no || '' = p_dept的初衷是不想在应用层处理空串,以为加个空字符串就能和NULL“中和”一下。但在Oracle里,''本身就被当成NULL,dept_no || ''的结果有时候优化器能识别成dept_no,有时候识别不了,完全看优化器版本的等价改写能力。这种依赖“优化器心情”的写法,上线环境稍微变一下就翻车,根本不值得赌。

2.2 前导通配符的LIKE,普通索引为什么用不上

模糊查询里的LIKE '%' || ? || '%'是另一个高频问题。普通B-tree索引支持的是“左前缀匹配”,也就是能根据开头的几个字符快速定位。abc%这种方式可以走索引,因为索引键本身就是有序的,从头开始扫一段区间就行。但%abc%要求“字符串里任意位置包含abc”,B-tree索引的有序性就完全帮不上忙,数据库只能全表扫描,逐行做字符串匹配。

很多人不理解,说索引里不也存了字符串吗,为什么不能先找到abc再扩展?因为索引的有序前缀是“第一个字符”“第二个字符”这样排下来的,而不是“中间某个字符”。除非建全文索引或者倒排索引,否则普通B-tree对前后模糊就是无能为力。这里要注意,MySQL默认情况下||是逻辑或运算符,不是字符串拼接,所以MySQL里写LIKE '%' || keyword || '%'会得到莫名其妙的结果,必须用CONCAT('%', keyword, '%')。这也是“||拼接”跨数据库时的一个隐藏坑。

2.3 Oracle里特别容易误判的NULL与空串语义

Oracle有个让新手抓狂的特性:空字符串''等价于NULL。所以你写p_dept || ''的时候,如果p_dept是NULL,整个表达式结果还是NULL,而不是空字符串。很多开发者原本想表达的是“参数为空时匹配所有”,结果写出来变成“参数为空时什么都匹配不到”。

标准SQL里,NULL与任何值比较结果都是UNKNOWN,只有IS NULL能判断。所以dept_no = NULL不会返回任何行,这不是数据库bug,是NULL语义本身的问题。在PostgreSQL里,NULL || 'abc'的结果是NULL,和Oracle行为不同,但p_dept || ''的坑是一样的。只要参数是NULL,拼接结果就是NULL,兜底逻辑完全失效。

要正确处理“可选条件”,应该写成显式逻辑:(:p_dept IS NULL OR dept_no = :p_dept)。这个写法SQL文本稳定,语义一目了然,也不会让优化器因为拼接表达式而放弃索引(虽然OR条件在极端场景下也可能影响执行计划,但至少不会出现NULL比较的硬伤)。

2.4 SQL文本每次都不同,硬解析怎么来的

动态SQL拼接最直接的问题是执行计划缓存失效。以Oracle为例,共享池里的游标按SQL文本精确匹配,差一个空格都算不同SQL。v_sql := v_sql || ' AND dept_no = ' || v_dept这种写法,v_dept传10时SQL是...dept_no = 10,传20时变成...dept_no = 20,两条文本完全不同,数据库只能一条条硬解析。

硬解析的成本包括语法解析、语义检查、权限检查、生成执行计划、申请共享池内存等一系列动作。QPS一上来,共享池里塞满“结构相同只是值不同”的SQL,Library Cache的锁竞争加剧,CPU飙升,最后整个库都变慢。用一个词总结就是:数据库把大量资源浪费在了重复解析上。

绑定变量就是为了解决这个问题。写成WHERE dept_no = :dept_no,不管传10还是20,SQL文本始终相同,数据库解析一次,后面直接复用执行计划。这个差别在量小的时候不明显,一到压测或者线上大流量,就是几百毫秒和几十毫秒的差距。

3. 优化方案与落地写法

3.1 等值条件:先把无意义的“||”拿掉

最基础的一步,是把SQL里那些为了“防止NULL”而加的|| ''统统去掉。比如:

-- 反例 SELECT * FROM t_user_history WHERE dept_no = (p_dept || ''); -- 正例 SELECT * FROM t_user_history WHERE dept_no = p_dept;

如果需求是“参数为空时返回全部”,不要试图用拼接解决,直接用显式逻辑:

SELECT * FROM t_user_history WHERE (:p_dept IS NULL OR dept_no = :p_dept);

这里的:p_dept在Oracle里可以是绑定变量,在MyBatis里对应#{deptNo}。虽然OR :p_dept IS NULL在数据分布不均时也可能被优化器选成全表扫描,但它的语义是对的,至少不会因为NULL拼接导致一条都查不出来。如果表特别大、这个OR写法实测下来全表扫太久,可以用UNION ALL把分支拆开,后面单独讲。

日期条件的优化也一样。传参时直接传DATE类型,或者写create_time >= TO_DATE(:p_date, 'YYYY-MM-DD HH24:MI:SS'),不要用|| ' 00:00:00'拼字符串再转。把参数类型和格式控制在应用层,数据库端只做单纯比较。

3.2 动态SQL里的值,必须绑定变量

存储过程里拼动态SQL,最忌讳把变量值直接拼进SQL文本。反例和正例对比如下:

-- 反例:每次v_dept不同都会生成新的SQL文本 DECLARE v_dept VARCHAR2(20) := '10'; v_sql VARCHAR2(4000); v_cnt NUMBER; BEGIN v_sql := 'SELECT COUNT(*) FROM t_user_history WHERE dept_no = ' || v_dept; EXECUTE IMMEDIATE v_sql INTO v_cnt; END; / -- 正例:SQL文本固定,绑定变量传值 DECLARE v_dept VARCHAR2(20) := '10'; v_sql VARCHAR2(4000); v_cnt NUMBER; BEGIN v_sql := 'SELECT COUNT(*) FROM t_user_history WHERE dept_no = :dept_no'; EXECUTE IMMEDIATE v_sql INTO v_cnt USING v_dept; END; /

正例里的SQL文本永远是那一句,数据库可以反复复用执行计划。这也符合SQL优化的第一原则:让相似的SQL长得一模一样,让数据库尽量走软解析。

如果动态SQL里只有部分条件需要动态拼接,比如“有传dept_no就拼dept_no条件,没传就不拼”,这种场景建议用静态SQL加OR条件替代,而不是真去拼SQL文本:

SELECT * FROM t_user_history WHERE (:p_dept IS NULL OR dept_no = :p_dept) AND (:p_keyword IS NULL OR user_name = :p_keyword);

这样只有一条SQL,通用执行计划一次成型。缺点前面也提了,OR可能导致优化器判断不够精准,但纯动态拼接的SQL文本碎片化问题更严重。

3.3 复杂动态排序:白名单方式处理

真正没法用绑定变量的是排序字段和表名字段这类“对象名”。比如ORDER BY后面不能写:order_col,只能拼字符串。这块尤其适合用白名单方式处理:

-- MyBatis XML里的写法 <choose> <when test="orderBy == 'createTime'"> ORDER BY create_time </when> <when test="orderBy == 'userName'"> ORDER BY user_name </when> <otherwise> ORDER BY id </otherwise> </choose>

宁可写一堆when分支做映射,也不要直接ORDER BY ${orderBy}。白名单的好处是值域已经固定了,SQL注入进不来,执行计划也不会因为排序字段变化而反复硬解析。如果是存储过程里拼动态SQL,同样在代码里对排序字段做if判断,拼成固定字符串。

3.4 模糊搜索:前后模糊和索引的取舍

LIKE '%' || ? || '%'在数据量小的时候没什么感受,但过了几十万行就开始疼。优化方向取决于业务能不能接受“右模糊”。

如果业务上只需要按前缀搜索,比如根据姓名首字搜用户,LIKE :kw || '%'能走索引,性能提升非常明显:

-- 右模糊,可以走INDEX RANGE SCAN SELECT * FROM t_user_history WHERE user_name LIKE :kw || '%';

如果业务强制要求前后模糊,普通索引确实无用武之地。这时候有几个选择:数据量中等、并发不高,全表扫描硬扛,但要做好SQL超时的心理准备;数据量大、搜索频繁,建议上全文索引(Oracle Text、PostgreSQL的GIN/tsvector、MySQL FULLTEXT);再大一些就是独立搜索引擎的范畴了。我个人的习惯是,后台管理列表这种低频查询允许前后模糊,核心交易链路的查询必须避免前后模糊。

3.5 Java端和MyBatis的正确姿势

Java端拼SQL,核心就一句话:永远用PreparedStatement,永远不要用Statement拼字符串。正确写法是把?占位符放进去,再用setString/setInt传参:

String sql = "SELECT * FROM t_user_history WHERE dept_no = ? AND create_time >= ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, deptNo); ps.setTimestamp(2, new Timestamp(createTime.getTime())); ResultSet rs = ps.executeQuery(); }

这样SQL文本固定,数据库能复用执行计划,也彻底杜绝了注入。MyBatis里对应的就是#{},不要用${}#{}会被解析成预编译占位符,${}是直接拼接文本。唯一允许${}的场景就是上一节说的ORDER BY、表名等对象名,而且必须做白名单。

模糊查询在MyBatis里可以写成:

<select id="searchUser" resultType="map"> SELECT * FROM t_user_history <where> <if test="keyword != null and keyword != ''"> AND user_name LIKE '%' || #{keyword} || '%' </if> </where> </select>

这里有个容易被误会的点:'%' || #{keyword} || '%'里的“||”是数据库端执行的拼接,#{keyword}本身是绑定变量,安全上没有注入问题,和前文的“手工拼接SQL字符串”是两码事。但前导通配符导致的索引问题依然存在,所以该考虑的还是得考虑。

4. 实测对比:改写前后执行计划与耗时

4.1 测试环境与准备

为了让大家对“||”拼接的影响有直观认识,我在本地搭了一组简单测试。环境是Oracle 19c,一张t_user_history表,数据量约120万行,字段包括id、user_name、dept_no、create_time,user_name和dept_no上建了普通B-tree索引。所有测试SQL用绑定变量,排除重复硬解析的干扰。

先说结论:同样的查询意图,因为写法不同,执行计划可以天差地别。这里我把几种典型场景贴出来,SQL文本和实测观察都列出来,方便对照。

4.2 等值条件与“列被拼接”的对比

写法A:

SELECT COUNT(*) FROM t_user_history WHERE dept_no || '' = :dept_no;

写法B:

SELECT COUNT(*) FROM t_user_history WHERE dept_no = :dept_no;

DBMS_XPLAN.DISPLAY_CURSOR看执行计划,写法A在我的环境里走了TABLE ACCESS FULL,Cost大约1100多;写法B走INDEX RANGE SCAN,Cost只有个位数。dept_no || ''虽然看起来“加了个空串等于没加”,但优化器并没有把这个表达式自动折回索引键,结果就是几万倍的逻辑读差异。

这里补充一点:Oracle某些版本可能会对|| ''做constant folding,但这不是可控行为。我见过同一套代码从11g升到19c后执行计划发生变化的案例,所以最稳妥的方式还是从源头上不写这种表达式。

4.3 模糊查询的对比

写法A:

SELECT COUNT(*) FROM t_user_history WHERE user_name LIKE '%' || :kw || '%';

写法B:

SELECT COUNT(*) FROM t_user_history WHERE user_name LIKE :kw || '%';

120万行数据下,写法A全表扫描,实测单次查询大约320毫秒;写法B走INDEX RANGE SCAN,单次查询大约2毫秒。这个差异在处理列表接口翻页、Excel导出这种高频查询时,基本上就是“能用”和“不能用”的区别。

如果业务真的需要前后模糊,又没法换搜索引擎,可以在Oracle里考虑建CONTEXT全文索引,用CONTAINS(user_name, :kw) > 0查询。但全文索引的维护、分区、同步策略都需要额外成本,不是银弹。

4.4 动态SQL硬解析的对比

我用PL/SQL循环模拟一个高频查询场景,循环10000次,每次传一个不同的dept_no值。反例是每次把v_dept拼进SQL文本,正例是绑定变量。

反例循环结束后,查v$SQL能看到大量SQL_TEXT不同但结构相同的记录,每条EXECUTIONS都是1,LOADS也高;正例循环结束后,v$SQL里只有一条SQL_TEXT,EXECUTIONS是10000,说明游标被稳定复用。整体耗时上,反例大约2.6秒,正例大约0.18秒,差距超过十倍,主要就是硬解析的代价。

硬解析的隐性成本远不止等待时间,还有共享池内存占用、Library Cache锁竞争、CPU消耗。一个系统里如果这种动态拼接SQL铺开,最终表现就是数据库CPU莫名高企,v$SQL里塞满相似的孤儿SQL。

5. 常见问题排查与避坑指南

5.1 怎么快速判断一条SQL有没有隐式类型转换或函数包裹列

最直接的办法是看执行计划里的Predicate Information。Oracle里执行:

EXPLAIN PLAN FOR SELECT * FROM t_user_history WHERE dept_no = 10; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

如果输出里出现TO_NUMBER("DEPT_NO")这种信息,说明dept_no列被隐式转换了,索引大概率走不上。同理,如果看到TO_CHARTO_DATE包住一个列名,也是在提醒你“列上被动了手脚”。

MySQL里可以用EXPLAIN EXTENDED后跟SHOW WARNINGS查看改写后的SQL,能直观看到隐式转换。SQL Server则看执行计划里有没有CONVERT_IMPLICIT。

5.2 怎么判断动态SQL是不是在重复硬解析

Oracle执行这个查询:

SELECT sql_text, executions, loads, invalidations, cpu_time FROM v$sql WHERE sql_text LIKE '%t_user_history%' ORDER BY loads DESC;

如果发现同一张表的SQL_TEXT有几十条相似记录,每条EXECUTIONS都差不多,LOAD次数也高,基本可以断定有人在拼条件。反过来,如果有一条SQL的EXECUTIONS特别高、LOAD次数很低,说明执行计划复用得很好。

MySQL里对应看performance_schema.events_statements_summary_by_digest,按DIGEST_TEXT聚合,如果同一个digest下SQL_TEXT变化很多,也能看出问题。

5.3 常见写法对照速查表

原写法主要问题优化写法
WHERE dept_no = (p_dept || '')NULL语义错误,查不到数据WHERE p_dept IS NULL OR dept_no = p_dept
WHERE col || '' = :val列参与拼接,索引不可用WHERE col = :val
WHERE name LIKE '%' || :kw || '%'前导通配符,普通索引失效WHERE name LIKE :kw || '%',或全文索引/搜索组件
v_sql || ' AND dept_no = ' || v_dept每次SQL文本不同,硬解析+注入风险v_sql := '... AND dept_no = :dept_no'+USING v_dept
AND create_time >= TO_DATE(p_date || ' 00:00:00', ...)参数为NULL时报错,格式解析不稳定应用层传DATE类型,create_time >= :p_date
Java里用字符串拼接SQLSQL注入、硬解析PreparedStatement+ 绑定变量占位符
MyBatis里用${}文本注入、执行计划不稳定#{};对象名/排序字段必须白名单
WHERE 1 = 1没有性能问题,但可读性一般可以用<where>动态生成,保留也可,优化器会忽略

5.4 几条值得记住的实操原则

第一,列上不要出现任何函数、拼接、计算,这是铁律。只要出现,优化器就没法正常使用索引。

第二,动态SQL里所有值都用绑定变量/PreparedStatement占位符。不是为了好看,是为了让数据库认得出“这是同一条SQL”。

第三,空值条件用显式逻辑,不要用拼接去“猜”。Oracle里''和NULL等价这件事,背十遍都不为过。

第四,排序字段、表名等对象名没法绑定,就用白名单映射,千万别放开参数直接拼。

第五,遇到慢SQL别只盯着耗时,先看执行计划,看Predicate Information里有没有隐式转换,再看v$SQL里有没有堆积的相似SQL文本。定位到根因再动手改,往往几行SQL就能把性能拉回来。

我个人在实际排查中最大的体会是:||拼接条件不是大事,但它能牵出一整串数据库层面的连锁反应——索引失效、硬解析、空值陷阱、注入风险。很多团队写的SQL工具类、报表模板里都有这类写法,每次优化完看着执行计划从全表扫描变成索引扫描,还是挺有成就感的。如果你手头也有一堆历史SQL要review,不妨按上面的速查表过一遍,多半能找到一两处可以立刻改掉的“拼接毒瘤”。

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

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

立即咨询