1. MybatisPlus自定义SQL的必要性与场景分析
在真实业务开发中,我们经常会遇到MybatisPlus内置CRUD方法无法满足需求的场景。比如最近我在处理一个电商平台的订单统计报表时,就遇到了单表500条数据限制的问题。MybatisPlus默认的分页查询在数据量超过500条时性能急剧下降,这时候就需要我们编写自定义SQL来优化查询。
另一个典型场景是复杂多表关联查询。比如需要同时获取用户信息、订单明细和商品数据时,MybatisPlus的LambdaQueryWrapper虽然优雅,但处理多表JOIN就显得力不从心。我在金融行业项目中就遇到过需要同时关联5张表的统计查询,这时候自定义SQL几乎是唯一的选择。
提示:不要为了用MybatisPlus而用,当内置方法无法满足性能或业务需求时,就应该果断转向自定义SQL方案。
2. 基础自定义SQL实现方式
2.1 XML映射文件方式
这是最传统也是功能最全面的方式。我们在resources/mapper目录下创建对应的XML文件:
xml复制<!-- OrderMapper.xml -->
<select id="selectComplexOrder" resultType="com.example.OrderVO">
SELECT o.*, u.username, p.product_name
FROM orders o
LEFT JOIN user u ON o.user_id = u.id
LEFT JOIN product p ON o.product_id = p.id
WHERE o.status = #{status}
<if test="startTime != null">
AND o.create_time >= #{startTime}
</if>
</select>
对应的Mapper接口需要声明这个方法:
java复制public interface OrderMapper extends BaseMapper<Order> {
List<OrderVO> selectComplexOrder(@Param("status") Integer status,
@Param("startTime") Date startTime);
}
这种方式最大的优势是可以使用Mybatis强大的动态SQL功能,适合复杂条件查询。我在物流系统中处理运单状态查询时,就用了多达15个条件判断的动态SQL。
2.2 注解方式实现
对于简单的SQL,我们可以直接在Mapper接口方法上使用@Select等注解:
java复制@Select("SELECT * FROM user WHERE age > #{age} ORDER BY create_time DESC")
List<User> selectUsersOlderThan(@Param("age") Integer age);
注解方式虽然简洁,但有明显的局限性:
- 不支持动态SQL
- 复杂SQL可读性差
- 字符串拼接容易出错
我曾在支付系统中尝试用注解方式写一个多条件查询,结果因为字符串拼接错误导致SQL注入漏洞,后来全部改为了XML方式。
3. 高级自定义SQL技巧
3.1 分页查询优化
MybatisPlus的分页插件默认会对所有查询进行count操作,这在数据量大时非常耗性能。我们可以通过自定义SQL优化:
xml复制<select id="selectPageWithoutCount" resultType="com.example.User">
SELECT * FROM user
WHERE status = 1
LIMIT #{offset}, #{size}
</select>
然后在Service层手动设置分页参数:
java复制public Page<User> getUsers(int pageNum, int pageSize) {
Page<User> page = new Page<>(pageNum, pageSize);
page.setSearchCount(false); // 禁用自动count
List<User> records = userMapper.selectPageWithoutCount(
(pageNum-1)*pageSize, pageSize);
page.setRecords(records);
return page;
}
这种方案在我处理百万级用户数据时,查询速度提升了10倍以上。
3.2 批量操作优化
MybatisPlus虽然提供了批量操作方法,但性能不如原生SQL。我们可以自定义批量插入:
xml复制<insert id="batchInsert" parameterType="java.util.List">
INSERT INTO user(name, age) VALUES
<foreach collection="list" item="item" separator=",">
(#{item.name}, #{item.age})
</foreach>
</insert>
实测在插入1万条数据时,这种方式比循环调用save()快50倍左右。
4. 自定义SQL中的常见问题与解决方案
4.1 字段映射问题
当查询结果包含非实体类字段时,我们需要使用@ResultMap或定义resultMap:
xml复制<resultMap id="userWithExtra" type="com.example.User">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="extraField" column="extra_field"/>
</resultMap>
我在权限系统开发中就遇到过需要查询用户及其角色数量的情况,通过resultMap完美解决了字段映射问题。
4.2 SQL注入防护
虽然Mybatis的#{}可以有效防止SQL注入,但在动态排序等场景仍需注意:
java复制// 不安全的写法
@Select("SELECT * FROM user ORDER BY ${sortField}")
List<User> selectUsers(@Param("sortField") String sortField);
// 安全的写法
public List<User> selectUsers(String sortField) {
String[] allowFields = {"id", "name", "age"};
if (!Arrays.asList(allowFields).contains(sortField)) {
sortField = "id";
}
return userMapper.selectUsersOrderBy(sortField);
}
4.3 性能监控与优化
我们可以通过配置来监控自定义SQL的性能:
yaml复制mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
对于慢SQL,可以通过EXPLAIN分析执行计划。我在商品搜索功能优化中,通过分析发现缺少索引导致查询缓慢,添加索引后查询时间从2s降到了200ms。
5. 与MybatisPlus特性的结合使用
5.1 条件构造器与自定义SQL混用
我们可以把QueryWrapper的条件应用到自定义SQL中:
java复制QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("status", 1);
userMapper.selectCustom(wrapper,
"name, age", "age > 20");
对应的XML配置:
xml复制<select id="selectCustom" resultType="com.example.User">
SELECT ${columns} FROM user
${ew.customSqlSegment}
AND ${condition}
</select>
这种混合方式在我开发CMS系统时非常有用,既保留了条件构造器的便利性,又实现了复杂查询。
5.2 自动填充字段处理
对于有@TableField(fill = FieldFill.INSERT)等注解的字段,在自定义SQL中需要手动处理:
xml复制<insert id="insertCustom">
INSERT INTO user(name, create_time, update_time)
VALUES(#{name}, NOW(), NOW())
</insert>
我在OA系统开发中就遇到过因为忘记处理自动填充字段导致审计信息丢失的问题,后来制定了严格的代码审查流程来避免这类问题。
6. 实际项目经验分享
在最近的一个供应链管理系统中,我遇到了一个复杂的库存统计需求:需要按仓库、商品类别、时间范围等多维度统计,同时还要计算同比环比。最终我采用了自定义SQL方案:
xml复制<select id="selectInventoryStats" resultType="com.example.InventoryStatVO">
SELECT
w.name AS warehouseName,
c.name AS categoryName,
SUM(i.quantity) AS totalQuantity,
SUM(CASE WHEN i.create_time BETWEEN #{startTime} AND #{endTime}
THEN i.quantity ELSE 0 END) AS periodQuantity
FROM inventory i
JOIN warehouse w ON i.warehouse_id = w.id
JOIN product p ON i.product_id = p.id
JOIN category c ON p.category_id = c.id
WHERE 1=1
<if test="warehouseId != null">
AND i.warehouse_id = #{warehouseId}
</if>
<if test="categoryId != null">
AND p.category_id = #{categoryId}
</if>
GROUP BY w.name, c.name
ORDER BY totalQuantity DESC
</select>
这个查询涉及4张表关联,多个动态条件,还有条件聚合,完全超出了MybatisPlus内置方法的能力范围。通过精心设计的自定义SQL,查询性能从最初的8秒优化到了1秒以内。
几个关键优化点:
- 为所有关联字段添加了索引
- 使用WHERE 1=1简化动态条件拼接
- 在应用层处理分页而非数据库层
- 使用CASE WHEN实现复杂统计逻辑
