2.2 参数装配:井号与美元的分界线 本节摘要:井号占位走 PreparedStatement 预编译通道,值作为参数设入,天然防注入;美元拼接是运行期文本替换,仅适用于列名、表名、排序字段等无法预编译的位置。本节用一次注入演示划清分界线,给出白名单防护的完整写法,并整理参数命名的全部规则。 手术入口端最重要的一个安全话题。两个长得像兄弟的占位符,走的是两条完全不同的通道。 一条注入演示:两种占位的分野 先看井号占位。下面这条语句在日志里永远显示一个问号: 调用 mapper.findByUsername("zhangsan") 时日志输出: 问号说明值没有进入 SQL 文本。框架先把它交给 PreparedStatement 预编译,再把值作为参数设入。
本节摘要:井号占位走 PreparedStatement 预编译通道,值作为参数设入,天然防注入;美元拼接是运行期文本替换,仅适用于列名、表名、排序字段等无法预编译的位置。本节用一次注入演示划清分界线,给出白名单防护的完整写法,并整理参数命名的全部规则。
手术入口端最重要的一个安全话题。两个长得像兄弟的占位符,走的是两条完全不同的通道。
先看井号占位。下面这条语句在日志里永远显示一个问号:
<select id="findByUsername" parameterType="string" resultType="User"> SELECT id, username, email FROM users WHERE username = #{username} </select>
调用 mapper.findByUsername("zhangsan") 时日志输出:
==> Preparing: SELECT id, username, email FROM users WHERE username = ? ==> Parameters: zhangsan(String)
问号说明值没有进入 SQL 文本。框架先把它交给 PreparedStatement 预编译,再把值作为参数设入。数据库看到的是一条已经编译好的语句加一个普通数据值——值无论长什么样,都只是值。
换成美元拼接,世界完全不同:
<select id="findByUsernameUnsafe" parameterType="string" resultType="User"> SELECT id, username, email FROM users WHERE username = '${username}' </select>
传入正常用户名时日志是:
==> Preparing: SELECT id, username, email FROM users WHERE username = 'zhangsan' ==> Parameters:
Preparing 行里已经是替换后的完整文本——值在语句编译前就进了 SQL,成为代码的一部分。现在传入恶意输入:
// 恶意输入:注释掉密码条件,或拼一个恒真条件 mapper.findByUsernameUnsafe("anyone' OR '1'='1");
最终执行的 SQL 变成:
SELECT id, username, email FROM users WHERE username = 'anyone' OR '1'='1'
恒真条件命中全表,鉴权形同虚设。同样的输入走井号占位则完全无害——它只是找一个叫"anyone' OR '1'='1"的用户名,查不到就是 null。

预编译的问号只能出现在"值"的位置。列名、表名、排序方向这些语句结构的一部分无法用问号表达,这才是美元拼接的存在理由。
典型需求:列表页的排序字段由前端传入。直接拼接等于把表结构暴露给任意 SQL:
// 危险写法:前端传什么拼什么 List<User> list(String orderBy) // orderBy 可能是 "id" 也可能是恶意文本
<!-- 危险写法的 XML:无校验拼接 --> <select id="findSorted" resultType="User"> SELECT id, username, email FROM users ORDER BY ${orderBy} </select>
防护是白名单:在 Java 侧把允许的取值枚举出来,拒绝一切越界输入。
// 白名单防护:排序字段只认这几个 private static final Set<String> SORTABLE = Set.of("id", "username", "created"); public List<User> findSorted(String field, String direction) { String col = SORTABLE.contains(field) ? field : "id"; // 列名兜底 String dir = "DESC".equalsIgnoreCase(direction) ? "DESC" : "ASC"; // 方向兜底 return mapper.findSorted(col + " " + dir); }
<!-- 拼接点只接收白名单产物 --> <select id="findSorted" resultType="User"> SELECT id, username, email FROM users ORDER BY ${orderBy} </select>
三条纪律:美元拼接的入参必须经过白名单或枚举映射;白名单放在服务层,不依赖前端校验;排序方向不要用正则去"过滤",直接二值映射成 ASC 或 DESC 更简单也更硬。
参数装配还有一半问题出在"名字"上。规则按参数个数分三种情况。
单参数:名字随意,框架不按名取值(整个参数就是那个值):
<select id="findById" resultType="User"> SELECT id, username FROM users WHERE id = #{anything} <!-- 单参数名字随便写 --> </select>
多参数,不加注解:框架按 arg0、arg1(旧版本是 param1、param2)编号命名,可读性差且容易错位:
List<User> find(String username, String email);
<!-- 不加注解时的两种取值写法,均不推荐 --> <select id="find" resultType="User"> SELECT id, username FROM users WHERE username = #{arg0} AND email = #{param2} </select>
多参数,加注解(推荐):每个参数显式命名,XML 里按名取值:
List<User> find(@Param("username") String username, @Param("email") String email);
<select id="find" resultType="User"> SELECT id, username FROM users WHERE username = #{username} AND email = #{email} </select>
对象参数:直接用属性名,这也是 CRUD 最常见形态:
<insert id="insert" parameterType="User"> INSERT INTO users (username, email) VALUES (#{username}, #{email}) </insert>
一个高频报错在此起源:方法有两个参数、XML 里却写了 #{username},运行时报"Parameter 'username' not found. Available parameters are [arg0, arg1, param1, param2]"。报错信息把可用名字全部列出——看到 arg 开头的名字,就该意识到少了注解。
⚠️ 常见坑:日期参数为 null 时,某些 JDBC 驱动无法从 null 推断类型,报 JdbcType OTHER 错误。写法上补一个类型提示即可:#{created, jdbcType=TIMESTAMP}。这不影响非空值,只为 null 兜底。
💡 关键直觉:评审时见美元就问三句——拼的是结构还是值?入参过白名单了吗?能换成枚举映射吗?三问答不上来就该改。
下一节处理入口端三个进阶场景:一个方法怎么带多个参数对象、插入后怎么拿回自增主键、一批数据怎么高效写进去。