痛点:为什么小项目也敢用 MyBatis 注解 SQL
很多同学在电商中台、小程序后台这类 Spring Boot 项目里,业务简单时喜欢在 Mapper 接口上直接写 @Select、@Update 注解,省去向 XML 文件跳来跳去的麻烦。项目初期确实爽——比如运营后台的订单列表:按状态、时间范围、订单号关键字组合筛选,附带几个批量操作(批量发货、批量更新状态),全用注解拼。但一旦出现这样的需求——按多条件动态过滤、传一个 List 做 IN 查询、按用户输入做模糊匹配——注解写法就会连续踩坑,最常见的是下面这几类:
- 动态条件不会拼:只能用
${}字符串拼接 SQL,结果要么注入风险,要么传 null 时报语法错误。 - 集合参数不会传:
IN (#{list})传一个List进来,运行时直接报参数绑定错误。 - 通用语法记不住:
<script>、<foreach>、<if>在注解里的写法混乱,复制粘贴改错参数名。
本文用一个"订单查询 + 批量更新"的真实场景,把 MyBatis 注解 SQL 的正确写法、常见坑和最佳实践一次讲透。全文代码可用、覆盖 MyBatis-Plus 与原生 MyBatis。
坑 1:用 ${} 拼 SQL 导致注入,null 条件裸拼查不到数据
先看反面教材。为了动态加条件,很多人写成这样:
// 反面教材:${} 是字符串直接替换,存在 SQL 注入风险
@Select("SELECT * FROM t_order WHERE user_id = #{userId} " +
"AND status = ${status}") // 传 status="1 OR 1=1",条件被改写成恒真
List<Order> list(@Param("userId") Long userId, @Param("status") String status);
${} 是裸拼字符串,#{} 才会走预编译占位符。安全红线第一条:任何可能来自用户的输入,只能用 #{}。${} 只允许用于白名单可控的静态片段,比如排序字段名(且必须自己硬编码校验)。
另外一个隐蔽问题:如果查询条件可能为 null 却用 #{} 裸拼,MyBatis 会生成 column = ? 且参数为 NULL。NULL 不能用 = 判断,col = NULL 语法合法但永远匹配不到数据——既不报错也不返回结果,比报错更难排查。所以"可能为空的条件"必须交给 <if> 动态决定是否拼进 SQL。
坑 2:@Insert 插入后,实体拿不到自增主键
订单创建是最典型的场景:insert 之后立刻要用主键拼订单号、发 MQ 消息。最常见的坑是实体 id 一直是 null——注解 @Insert 默认不把数据库生成的主键回填到对象里,MySQL 和 PostgreSQL 都必须显式声明:
@Insert("INSERT INTO t_order(user_id, order_no, status) " +
"VALUES(#{userId}, #{orderNo}, #{status})")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(Order order);
// 插入后主键已回填,可放心拼订单号或发 MQ
Long newId = order.getId();
两个注意点:keyProperty 必须写实体的 Java 属性名(id),而不是数据库列名(ID 或下划线风格);另外在 PostgreSQL 上 pgjdbc 驱动同样支持 useGeneratedKeys 回填,所以这套代码跑 MySQL、PG 都通用。但如果你在 PG 上把 SQL 改成 PG 惯用的 INSERT ... RETURNING id,注解 @Insert 并不会自动解析 RETURNING 的值,需要配合 @SelectKey 或改用 XML——这也是 MySQL 迁 PG 时常翻车的细节,别半吊子混用。
坑 3:注解里写动态 SQL —— 用 <script> 标签
注解本质也是 SQL 文本,MyBatis 支持在注解里嵌套 <script> 标签,从而复用 XML 里那一套 <if>、<where>、<foreach> 动态标签。正确写法如下:
// 正确姿势:script + where + if,null 条件自动剔除
@Select("<script>" +
"SELECT * FROM t_order " +
"<where>" +
" <if test=\"userId != null\">AND user_id = #{userId}</if>" +
" <if test=\"status != null and status != ''\">AND status = #{status}</if>" +
"</where>" +
"ORDER BY create_time DESC LIMIT #{limit}" +
"</script>")
List<Order> query(@Param("userId") Long userId,
@Param("status") String status,
@Param("limit") Integer limit);
几个要点:
- Java 字符串拼接时用
\转义双引号,标签属性必须用双引号包裹:如test=\"userId != null\",属性值内需要字符串时再嵌单引号(test='xx'),顺序不能反,否则编译不过。 <where>会自动去掉第一个多余AND,强烈推荐替代手写WHERE 1=1。- 传参必须配
@Param("xxx"),否则 MyBatis 用arg0/arg1 这类名字,写动态 SQL 时记不住、易错。
坑 4:IN 查询传 List —— 必须 <foreach>
想把 status IN ('A','B') 写进注解,很多人直接写 IN (#{list}),结果运行时直接报参数绑定错误。因为 #{} 只代表一个占位符,而 IN 需要展开成多个。正确做法是用 <foreach>:
@Select("<script>" +
"SELECT * FROM t_order WHERE status IN " +
"<foreach collection=\"statusList\" item=\"s\" open=\"(\" separator=\",\" close=\")\">" +
" #{s}" +
"</foreach>" +
"</script>")
List<Order> findByStatus(@Param("statusList") List<String> statusList);
一个容易被忽略的细节:调用方传空集合时必须先判空返回,否则 <foreach> 会拼出空的 IN (),MySQL 和 PostgreSQL 都会报语法错误,而且这类错误只在运行时偶发,线上排查成本高。
若用 MyBatis-Plus,更省事的是直接用它的 LambdaQueryWrapper:wrapper.in(Order::getStatus, statusList),既安全又不必手写 SQL。注解原生的 <foreach> 更适用于需要在 UPDATE、DELETE 里用到集合的场景,比如批量更新状态:
@Update("<script>" +
"UPDATE t_order SET status = #{newStatus} " +
"WHERE id IN " +
"<foreach collection=\"ids\" item=\"id\" open=\"(\" separator=\",\" close=\")\">#{id}</foreach>" +
"</script>")
int batchUpdateStatus(@Param("ids") List<Long> ids, @Param("newStatus") Integer newStatus);
坑 5:like 模糊查询别手写 '%' + 值
模糊查询最常见的错误是把通配符直接拼进参数:LIKE '%' + #{kw} + '%'。这在 MySQL 里勉强能用,但换到 PostgreSQL(|| 才是字符串连接)就报错,而且容易触发注入。CONCAT 是跨库最稳的写法:
// 跨 MySQL / PostgreSQL 通用:CONCAT 拼接通配符
@Select("<script>" +
"SELECT * FROM t_order " +
"<where>" +
" <if test=\"keyword != null and keyword != ''\">" +
" AND order_no LIKE CONCAT('%', #{keyword}, '%')" +
" </if>" +
"</where>" +
"</script>")
List<Order> search(@Param("keyword") String keyword);
注意:CONCAT('%', ..., '%') 里的 % 是普通字符串,不会被预处理转义,参数 #{keyword} 里即使带引号也只是普通数据,SQL 结构不被改变,天然防注入。
坑 6:返回 Map —— 列名大小写与 null 排查
写临时统计时很多人让 Mapper 返回 Map<String,Object>:
@Select("SELECT status, COUNT(*) AS cnt FROM t_order GROUP BY status")
List<Map<String, Object>> countByStatus();
这个坑在于,Map 的 key 用的是数据库驱动返回的列名,大小写行为随驱动而变:MySQL 驱动基本保留别名原样(cnt 就是 cnt),PostgreSQL 驱动则会把标识符自动折叠成小写。你写 map.get("CNT") 取到 null 找半天,其实 key 是小写 cnt。结论:统计类轻量查询用 Map 可以,但读 key 时统一按小写处理,别在大小写上赌数据库行为;字段复杂、需要被序列化/反序列化的业务对象,还是建一个明确的 VO/DTO 更稳。
最佳实践总结
- 安全第一:一切动态值用
#{},${}只留给白名单静态片段(且要做枚举校验)。 - 动态 SQL 用
<script>+<where>+<if>,null 条件自动剔除,避免裸= null。 - 集合一律
<foreach>,IN查询别写IN (#{list})。 - 模糊匹配用
CONCAT跨 MySQL/PostgreSQL 通用且防注入。 - 业务对象建 VO/DTO,不要依赖 Map 的列名大小写。
- 条件多到像"订单列表筛选"这种(状态+时间+关键字+分页),建议直接 改用 XML 或 MyBatis-Plus Wrapper,可读性远超一长坨注解字符串,团队协作也更易懂。
真正生产级别的高并发订单查询,如果你正在做防超卖或库存一致性改造,推荐接着看本站《Spring Boot 接口幂等实战》与《Redis 缓存穿透/击穿/雪崩实战》,注解 SQL 与缓存、分布式锁配合,才能把读写路径真正压稳。