很多人一看到子查询、嵌套查询,就会下意识担心 SQL 注入更难防。其实问题不在子查询本身,而在你是不是把用户输入直接拼进了 SQL。本文把“哪些位置根本不能参数化”“哪些值必须一路绑定到最内层”以及 MyBatis 里最常见的误区拆开讲清楚,方便你快速判断现有写法到底安全不安全。
子查询的风险点到底在哪里
先说结论:子查询本身不会引发 SQL 注入风险,真正危险的是把用户输入拼接到子查询字符串里。只要所有用户可控的值都走参数化绑定,子查询和主查询在安全性上没有本质区别。
实际排查时,可以把动态内容分成两类:
- 结构化内容:表名、列名、
ORDER BY字段、分组表达式等,这类内容决定 SQL 语法结构,不能直接靠参数占位符替代。 - 普通值:数字、字符串、日期、状态值等,这类内容必须使用参数化绑定。
只要这两类边界没分清,子查询写得越深,越容易在某一层漏掉校验或绑定。
哪些位置不能用参数化代替
数据库在预编译阶段必须先确定 SQL 语法结构,因此有些位置不能使用 或 :param 来代入。

SELECT * FROM (SELECT id FROM ) AS t→不能代入表名SELECT * FROM users WHERE id IN (SELECT FROM logs)→不能代入列名SELECT * FROM users ORDER BY→即使出现在子查询外,ORDER BY后也不支持参数化
这类动态内容该怎么处理
对于表名、列名、排序字段以及分组表达式这类结构化内容,正确做法不是“想办法参数化”,而是做白名单校验。也就是说,程序只能从一组预先定义好的合法选项里挑选,不能把外部传入内容原样拼进去。
例如,允许的排序字段只有 ['id', 'name', 'created_at'],那么就应当用 in 判断传入值是否合法,再决定是否拼入 SQL。这里的核心不是转义,而是限制可选范围。
哪些值必须绑定到最内层子查询
凡是用户可控的值,无论出现在主查询还是嵌套多层的子查询中,都必须参数化绑定,包括数字、字符串、日期等。
下面这类写法就有明显风险。
"SELECT * FROM users WHERE id IN (SELECT user_id FROM logs WHERE level = '" . $_GET['level'] . "')"
更安全的写法是把值交给占位符处理:
"SELECT * FROM users WHERE id IN (SELECT user_id FROM logs WHERE level = )"
bind_param("s", $level)
在 Python + PyMySQL 中也是同样的原则:
cursor.execute("SELECT * FROM orders WHERE user_id IN (SELECT id FROM users WHERE status = %s)", (status,))
动态 IN 列表要特别注意什么
IN 子句是一个常见误区。子查询返回多行,不代表你可以把外部输入直接拼成一个列表字符串。参数化一次仍然只处理一个值;如果要动态构造 IN (, , ),必须按实际数量生成占位符,并把每个值分别绑定。
换句话说,不能靠字符串拼接占位符,也不能把一整段外部输入当成“现成的 IN 列表”塞进 SQL。
MyBatis 里为什么 ${} 在子查询中更危险
MyBatis 中的 ${} 本质上是字符串替换,不是参数化。它不管出现在主 SQL 还是子查询里,都会把内容直接拼接进去,因此一直是注入高发区。

典型危险示例如下:
如果攻击者传入 type=abc' UNION SELECT password FROM users --,就可能把原查询结构改写,造成数据泄露。
更稳妥的写法是什么
这类场景应一律改用 #{},并确保传入的是简单类型,例如 String、Integer,不要传表达式或 SQL 片段。
还要注意一点:#{} 在子查询中同样生效,但 MyBatis 只负责把值安全转义,不会帮你检查子查询结构本身是否合法。也就是说,凡是动态表名、列名、排序字段这类结构化部分,仍然要靠白名单控制。
实际排查时抓住这两个检查点
判断一段带子查询的 SQL 是否安全,通常只要按下面两个问题去看:
- 所有结构化动态点,比如表名、列名、
ORDER BY字段、分组表达式,是否都做了白名单校验。 - 所有用户可控的值,是否都通过参数绑定传入,而且绑定覆盖到了最内层子查询。
最容易出问题的地方恰恰是“只检查了外层,漏掉了里层”。只要任意一层重新回到字符串拼接,注入风险就会立刻回来。







