MyBatis-Plus查询排除大字段的性能优化指南
作者:李少兄
一、前言
在真实的业务开发中,我们几乎不可避免地会遇到这样的表结构:某些字段存储着大段富文本、JSON 配置、Base64 编码的文件内容,类型通常是 TEXT、MEDIUMTEXT、LONGTEXT 甚至 BLOB。这些字段在写入时不可或缺,但在绝大多数读取场景中——列表展示、条件下拉、数据导出、状态统计——根本用不到它们。
然而,MyBatis-Plus 的 selectList()、selectVoList()、selectPage() 等默认方法,会基于实体类的 TableInfo 生成包含全部列的 SELECT 语句。这意味着,哪怕你的 VO 里 根本没有定义那个 LONGTEXT 字段,数据库依然会把它从磁盘读出来,通过网络传到应用服务器,再由 JDBC 驱动分配内存构建 String 对象——最终这个对象被映射层静默丢弃。
这不是一个"锦上添花"的优化点。当表中有几十万行数据、每行携带几十 KB 甚至几 MB 的大字段时,一次本应 5ms 返回的列表查询,可能因为无谓地拉取大字段而退化为 2~5 秒,甚至在高并发下直接打满数据库连接池。
二、问题场景:大字段到底"重"在哪里
2.1 一个典型的业务表
以"文章内容管理系统"为例,article 表中 content 存储富文本正文,extra_config 存储扩展配置(JSON),cover_image_base64 存储封面图的 Base64 编码:
CREATE TABLE article
(
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
title VARCHAR(200) NOT NULL COMMENT '文章标题',
summary VARCHAR(500) NULL COMMENT '摘要',
content LONGTEXT NULL COMMENT '文章正文(富文本)',
extra_config TEXT NULL COMMENT '扩展配置(JSON)',
cover_image_base64 MEDIUMTEXT NULL COMMENT '封面图Base64',
category_id BIGINT NOT NULL COMMENT '分类ID',
author VARCHAR(64) NULL COMMENT '作者',
view_count INT DEFAULT 0 COMMENT '阅读量',
status TINYINT DEFAULT 0 COMMENT '状态 0-草稿 1-已发布 2-已下架',
publish_time DATETIME NULL COMMENT '发布时间',
create_by VARCHAR(64) NULL COMMENT '创建者',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by VARCHAR(64) NULL COMMENT '更新者',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
del_flag TINYINT DEFAULT 0 NOT NULL COMMENT '删除标识 0-正常 1-已删除'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表';三个大字段的典型数据量:
| 字段 | 类型 | 单条典型大小 |
|---|---|---|
content | LONGTEXT | 50 KB ~ 2 MB |
extra_config | TEXT | 2 KB ~ 20 KB |
cover_image_base64 | MEDIUMTEXT | 100 KB ~ 500 KB |
2.2 列表页真正需要的字段
后台管理列表页、前端文章列表、下拉选择器,实际只需要:
id, title, summary, category_id, author, view_count, status, publish_time
总共不到 1 KB。但如果使用默认的全字段查询,每行要拉取 150 KB ~ 2.5 MB 的无用数据。
2.3 量化损耗
假设列表接口返回 50 条记录:
| 指标 | 全字段查询 | 排除大字段后 |
|---|---|---|
| 单行数据量 | ~500 KB(均值) | ~0.4 KB |
| 50 行总传输量 | ~25 MB | ~20 KB |
| 磁盘 I/O 模式 | 大量随机读(溢出页) | 顺序读(聚簇索引页) |
| 响应时间(局域网) | 1.5s ~ 5s | 3ms ~ 15ms |
| JVM 临时对象 | ~50 MB | < 0.5 MB |
差距是 两到三个数量级。
三、底层原理:为什么排除大字段能带来如此显著的提升
3.1 InnoDB 行格式与溢出页
InnoDB 默认使用 DYNAMIC 行格式(MySQL 5.7.9+)。其核心规则是:
- 每个数据页大小为 16 KB。
- 当一行中所有变长字段(
VARCHAR、TEXT、BLOB)的总长度超过页内可容纳的阈值(约 8126 字节)时,超长部分不会存储在聚簇索引(主键 B+ 树)的叶子节点中。 - 这些数据被存入独立的 溢出页(Overflow Page),行内仅保留一个 20 字节的指针指向溢出页。
这意味着:
- 读取大字段 = 额外的随机磁盘 I/O。每读取一个溢出字段,InnoDB 需要根据指针跳转到溢出页,这是一次独立的页读取操作。如果数据不在 Buffer Pool 中,就是一次物理磁盘 I/O。
- 不读大字段 = 数据紧凑地排列在聚簇索引页中。查询可以顺序扫描,Buffer Pool 命中率极高,甚至可以利用预读(Read-Ahead)机制。
3.2 MySQL 协议传输开销
MySQL Server 与客户端之间通过 MySQL 二进制协议传输结果集。对于 TEXT/BLOB 类型:
- 服务端从存储引擎读取完整数据(包括溢出页)。
- 将数据编码为
MYSQL_TYPE_BLOB/MYSQL_TYPE_VAR_STRING类型的 Protocol 包。 - 通过 TCP 发送到客户端。
即使应用层最终不使用这些数据,网络带宽已经被占用。在云环境中,跨可用区的网络延迟和带宽成本更是不容忽视。
3.3 JDBC 驱动与 JVM 内存
MySQL JDBC 驱动(Connector/J)在接收结果集时:
- 为每一行的每一列分配
byte[]缓冲区。 - 将
byte[]转换为String(对于字符类型)。 - 构建
ResultSet对象。
一个 200 KB 的 LONGTEXT 字段,在 JVM 中会占用约 400 KB 堆内存(Java 9 之前 char[] 是 2 字节/字符;Java 9+ 使用 byte[] + Compact Strings,但仍有对象头和编码转换开销)。
如果一次查询返回 100 条、每条携带 3 个大字段,仅此一次调用就可能产生 100 MB+ 的临时对象,直接触发 Young GC,严重时引发 Full GC 甚至 OOM。
3.4 MyBatis 映射层的"无用功"
MyBatis 在将 ResultSet 映射为 Java 对象时,即使目标 VO 中没有对应属性,ResultSet.getString("content") 仍然会被调用(取决于映射策略),数据已经从 JDBC 层传递过来了。
关键认知:性能损耗发生在 SQL 执行和网络传输阶段,而不是映射阶段。排除大字段必须在 SQL 层面实现,即 SELECT 子句中不包含该列。
四、MyBatis-Plus 排除字段的核心 API 详解
MyBatis-Plus 的 AbstractWrapper(QueryWrapper、LambdaQueryWrapper 的父类)提供了 .select() 方法的多个重载,用于精确控制 SELECT 子句。
4.1select(Class<T>, Predicate<TableFieldInfo>)—— 按条件过滤列
.select(EntityClass.class, predicate)
参数说明:
Class<T> entityClass:实体类的 Class 对象,用于从TableInfoHelper获取该实体对应的所有列元信息。Predicate<TableFieldInfo> predicate:对每一列执行判断。返回true表示保留该列,返回false表示排除该列。
TableFieldInfo 常用方法:
| 方法 | 返回值 | 说明 |
|---|---|---|
getProperty() | String | Java 属性名,如 "content" |
getColumn() | String | 数据库列名,如 "content" 或 "extra_config" |
getJavaType() | Class<?> | Java 类型,如 String.class |
isLogicDelete() | boolean | 是否为逻辑删除字段 |
isVersion() | boolean | 是否为乐观锁版本字段 |
getAnnotation() | TableField | 字段上的 @TableField 注解(可能为 null) |
4.2select(String... columns)—— 显式指定列名(字符串)
.select("id", "title", "summary", "status")
直接列出需要的数据库列名(下划线风格)。
4.3select(SFunction<T, ?>... columns)—— Lambda 指定列
.select(Article::getId, Article::getTitle, Article::getSummary)
通过方法引用指定需要的列,编译期类型安全。
4.4 三种方式的核心对比
| 维度 | select(Class, Predicate) | select(String...) | select(SFunction...) |
|---|---|---|---|
| 语义 | 声明"不要什么" | 声明"要什么" | 声明"要什么" |
| 字段多时 | ✅ 只写排除项 | ❌ 需列举所有保留项 | ❌ 需列举所有保留项 |
| 字段少时 | 一般 | ✅ 简洁 | ✅ 简洁 |
| 类型安全 | ⭐⭐⭐⭐ | ⭐⭐ 字符串硬编码 | ⭐⭐⭐⭐⭐ |
| 可维护性 | ⭐⭐⭐⭐⭐ 新增字段自动包含 | ⭐⭐ 需手动同步 | ⭐⭐⭐ 需手动同步 |
| 重构友好 | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| 适用场景 | 排除 1~3 个大字段 | 只需 2~3 个字段 | 只需 2~3 个字段 |
核心结论:当表有 10+ 个字段、只需排除 1~3 个大字段时,select(Class, Predicate) 是最优解。你只需声明"排除什么",其余字段自动保留,未来表结构新增字段也无需修改查询代码。
五、完整代码示例
以下示例使用一个通用的"文章内容管理"场景,完全脱敏,可直接作为博客参考。
5.1 实体类
package com.example.article.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 文章实体
*
* @author zhangsan
*/
@Data
@TableName("article")
public class Article {
@TableId(type = IdType.AUTO)
private Long id;
/** 文章标题 */
private String title;
/** 摘要 */
private String summary;
/** 文章正文(富文本) —— LONGTEXT 大字段 */
private String content;
/** 扩展配置(JSON) —— TEXT 大字段 */
private String extraConfig;
/** 封面图Base64 —— MEDIUMTEXT 大字段 */
private String coverImageBase64;
/** 分类ID */
private Long categoryId;
/** 作者 */
private String author;
/** 阅读量 */
private Integer viewCount;
/** 状态 0-草稿 1-已发布 2-已下架 */
private Integer status;
/** 发布时间 */
private LocalDateTime publishTime;
/** 创建者 */
private String createBy;
/** 创建时间 */
private LocalDateTime createTime;
/** 更新者 */
private String updateBy;
/** 更新时间 */
private LocalDateTime updateTime;
/** 逻辑删除标识 */
@TableLogic
private Integer delFlag;
}
5.2 列表 VO(不包含大字段)
package com.example.article.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 文章列表 VO —— 不包含 content、extraConfig、coverImageBase64
*
* @author zhangsan
*/
@Data
public class ArticleListVo {
private Long id;
/** 文章标题 */
private String title;
/** 摘要 */
private String summary;
/** 分类ID */
private Long categoryId;
/** 作者 */
private String author;
/** 阅读量 */
private Integer viewCount;
/** 状态 */
private Integer status;
/** 发布时间 */
private LocalDateTime publishTime;
/** 创建时间 */
private LocalDateTime createTime;
}
5.3 详情 VO(包含大字段)
package com.example.article.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 文章详情 VO —— 包含全部字段
*
* @author zhangsan
*/
@Data
public class ArticleDetailVo {
private Long id;
private String title;
private String summary;
private String content;
private String extraConfig;
private String coverImageBase64;
private Long categoryId;
private String author;
private Integer viewCount;
private Integer status;
private LocalDateTime publishTime;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
5.4 Mapper 层
package com.example.article.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.article.entity.Article;
import com.example.article.vo.ArticleListVo;
import org.apache.ibatis.annotations.Mapper;
/**
* 文章 Mapper
*
* @author zhangsan
*/
@Mapper
public interface ArticleMapper extends BaseMapper<Article> {
// 如果使用 MyBatis-Plus 扩展的 BaseMapperPlus,可支持 selectVoList
// 此处以原生 BaseMapper 为基础,Service 层做转换
}
说明:如果你的项目使用了 BaseMapperPlus(如 RuoYi-Vue-Plus 框架),可以直接使用 selectVoList(wrapper) 将结果自动映射为 VO。本文示例以原生 BaseMapper + 手动转换为基准,确保通用性。
5.5 Service 层 —— 核心查询逻辑
package com.example.article.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.article.entity.Article;
import com.example.article.mapper.ArticleMapper;
import com.example.article.service.ArticleService;
import com.example.article.vo.ArticleListVo;
import com.example.article.vo.ArticleDetailVo;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
/**
* 文章 Service 实现
*
* @author zhangsan
*/
@Service
@RequiredArgsConstructor
public class ArticleServiceImpl implements ArticleService {
private final ArticleMapper articleMapper;
/**
* 查询已发布文章列表(排除大字段)
* <p>
* 列表页只需要标题、摘要、作者等轻量信息,
* 不需要 content、extraConfig、coverImageBase64 三个大字段。
*
* @param categoryId 分类ID(可选)
* @return 文章列表
*/
@Override
public List<ArticleListVo> listPublishedArticles(Long categoryId) {
LambdaQueryWrapper<Article> wrapper = Wrappers.<Article>lambdaQuery()
// ★ 核心:排除三个大字段
.select(Article.class, field ->
!"content".equals(field.getProperty())
&& !"extraConfig".equals(field.getProperty())
&& !"coverImageBase64".equals(field.getProperty())
)
.eq(Article::getStatus, 1)
.eq(Article::getDelFlag, 0)
.eq(categoryId != null, Article::getCategoryId, categoryId)
.orderByDesc(Article::getPublishTime);
List<Article> articles = articleMapper.selectList(wrapper);
// 转换为 VO
return articles.stream().map(article -> {
ArticleListVo vo = new ArticleListVo();
BeanUtils.copyProperties(article, vo);
return vo;
}).collect(Collectors.toList());
}
/**
* 分页查询已发布文章(排除大字段)
*
* @param pageNum 页码
* @param pageSize 每页条数
* @param categoryId 分类ID(可选)
* @return 分页结果
*/
@Override
public Page<ArticleListVo> pagePublishedArticles(int pageNum, int pageSize, Long categoryId) {
LambdaQueryWrapper<Article> wrapper = Wrappers.<Article>lambdaQuery()
// ★ 同样排除大字段
.select(Article.class, field ->
!"content".equals(field.getProperty())
&& !"extraConfig".equals(field.getProperty())
&& !"coverImageBase64".equals(field.getProperty())
)
.eq(Article::getStatus, 1)
.eq(Article::getDelFlag, 0)
.eq(categoryId != null, Article::getCategoryId, categoryId)
.orderByDesc(Article::getPublishTime);
Page<Article> page = articleMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
// 转换为 VO 分页
Page<ArticleListVo> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
List<ArticleListVo> voList = page.getRecords().stream().map(article -> {
ArticleListVo vo = new ArticleListVo();
BeanUtils.copyProperties(article, vo);
return vo;
}).collect(Collectors.toList());
voPage.setRecords(voList);
return voPage;
}
/**
* 查询文章详情(包含全部字段)
* <p>
* 详情页需要展示正文、配置等完整信息,不做字段过滤。
*
* @param id 文章ID
* @return 文章详情
*/
@Override
public ArticleDetailVo getArticleDetail(Long id) {
Article article = articleMapper.selectOne(
Wrappers.<Article>lambdaQuery()
.eq(Article::getId, id)
.eq(Article::getDelFlag, 0)
);
if (article == null) {
return null;
}
ArticleDetailVo vo = new ArticleDetailVo();
BeanUtils.copyProperties(article, vo);
return vo;
}
/**
* 下拉选择器:只查 id 和 title(极端精简场景)
*
* @return 文章简要列表
*/
@Override
public List<ArticleListVo> listArticleOptions() {
LambdaQueryWrapper<Article> wrapper = Wrappers.<Article>lambdaQuery()
// 只需要两个字段,用 Lambda 方式更直观
.select(Article::getId, Article::getTitle)
.eq(Article::getStatus, 1)
.eq(Article::getDelFlag, 0)
.orderByDesc(Article::getPublishTime);
List<Article> articles = articleMapper.selectList(wrapper);
return articles.stream().map(article -> {
ArticleListVo vo = new ArticleListVo();
vo.setId(article.getId());
vo.setTitle(article.getTitle());
return vo;
}).collect(Collectors.toList());
}
}
5.6 生成的 SQL 对比
排除大字段后的列表查询:
SELECT id, title, summary, category_id, author, view_count, status,
publish_time, create_by, create_time, update_by, update_time, del_flag
FROM article
WHERE status = 1
AND del_flag = 0
AND category_id = ?
ORDER BY publish_time DESC未排除大字段的默认查询(反例):
SELECT id, title, summary, content, extra_config, cover_image_base64,
category_id, author, view_count, status, publish_time,
create_by, create_time, update_by, update_time, del_flag
FROM article
WHERE status = 1
AND del_flag = 0
ORDER BY publish_time DESC区别一目了然:前者完全不触碰 content、extra_config、cover_image_base64 三个大字段。
5.7 排除单个大字段的简写
如果只有一个大字段需要排除:
.select(Article.class, field -> !"content".equals(field.getProperty()))
5.8 使用select(String...)的等价写法
// 显式列出需要的列(数据库列名,下划线风格)
.select("id", "title", "summary", "category_id", "author",
"view_count", "status", "publish_time", "create_time")
注意:此处必须写数据库列名(下划线风格),不是 Java 属性名。如果实体字段配置了 @TableField("custom_name"),需要写 custom_name。
5.9 使用select(SFunction...)的等价写法
// Lambda 方法引用,类型安全
.select(Article::getId, Article::getTitle, Article::getSummary,
Article::getCategoryId, Article::getAuthor,
Article::getViewCount, Article::getStatus,
Article::getPublishTime, Article::getCreateTime)
六、getProperty()与getColumn()的深度对比
在 Predicate<TableFieldInfo> 中,你可以选择按 Java 属性名或数据库列名进行匹配。这是一个容易被忽视但影响健壮性的关键决策。
6.1 两种匹配方式
// 方式 A:按 Java 属性名匹配 .select(Article.class, field -> !"extraConfig".equals(field.getProperty())) // 方式 B:按数据库列名匹配 .select(Article.class, field -> !"extra_config".equals(field.getColumn()))
6.2 差异分析
| 对比维度 | getProperty() | getColumn() |
|---|---|---|
| 匹配对象 | Java 字段名(驼峰) | 数据库列名(下划线) |
| 受 @TableField 影响 | ❌ 不受 | ✅ 受影响 |
| 受全局命名策略影响 | ❌ 不受 | ✅ 受影响 |
| IDE 重构感知 | ✅ 重命名字段时可感知 | ❌ 字符串硬编码 |
| 代码可读性 | 与实体类字段一一对应 | 需要脑内转换命名风格 |
6.3 为什么推荐getProperty()
考虑以下场景:
// 初始:字段名与列名一致
private String extraConfig; // → 数据库列 extra_config
// 后来:加了 @TableField 自定义列名
@TableField("ext_cfg")
private String extraConfig; // → 数据库列变为 ext_cfg
如果使用 getColumn() 匹配 "extra_config",加了注解后列名变成了 "ext_cfg",过滤条件永远为 true,大字段被"意外"查出,且不会报任何错误——这是一个静默失效的 Bug。
而使用 getProperty() 匹配 "extraConfig",无论列名怎么变,Java 属性名不变,过滤始终有效。
6.4 推荐结论
优先使用 getProperty()。只有在极少数需要按数据库列名精确匹配(如动态 SQL 拼接)的场景下,才使用 getColumn()。
七、不同场景下的最佳写法选择
7.1 场景一:表有 15 个字段,排除 2~3 个大字段
推荐:select(Class, Predicate)
.select(Article.class, field ->
!"content".equals(field.getProperty())
&& !"coverImageBase64".equals(field.getProperty())
)
理由:只需写 2~3 个排除条件,其余 12+ 个字段自动保留。新增字段无需修改。
7.2 场景二:只需要 2~3 个字段(如下拉选择器)
推荐:select(SFunction...)
.select(Article::getId, Article::getTitle)
理由:字段极少时,Lambda 写法最简洁、类型安全。
7.3 场景三:字段列表由运行时动态决定
推荐:select(String...)
String[] columns = buildColumnsDynamically(); wrapper.select(columns);
理由:动态场景下,字符串数组最灵活。但需注意列名的正确性。
7.4 场景四:多表 JOIN 查询
MyBatis-Plus 的 Wrapper .select() 仅适用于单表查询。多表 JOIN 需要在 Mapper XML 中手写 SQL:
<select id="selectArticleWithCategory" resultType="com.example.article.vo.ArticleListVo">
SELECT a.id, a.title, a.summary, a.author, a.view_count,
a.status, a.publish_time, a.create_time,
c.name AS categoryName
FROM article a
LEFT JOIN category c ON a.category_id = c.id
WHERE a.status = 1
AND a.del_flag = 0
AND c.del_flag = 0
ORDER BY a.publish_time DESC
</select>在 XML 中,你完全掌控 SELECT 列表,天然不会包含大字段。
八、性能验证:如何确认优化生效
8.1 开启 MyBatis SQL 日志
在 application.yml 中:
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl观察控制台输出的 SQL,确认 SELECT 列表中不包含 content、extra_config、cover_image_base64。
8.2 MySQL EXPLAIN 分析
-- 优化后 EXPLAIN SELECT id, title, summary, category_id, author, view_count, status, publish_time FROM article WHERE status = 1 AND del_flag = 0 ORDER BY publish_time DESC; -- 优化前(对比) EXPLAIN SELECT * FROM article WHERE status = 1 AND del_flag = 0 ORDER BY publish_time DESC;
关注 Extra 列是否出现 Using filesort、Using temporary,以及 rows 估算值。
8.3 实际耗时对比
// 在测试环境中对比
long start = System.nanoTime();
List<Article> fullList = articleMapper.selectList(
Wrappers.<Article>lambdaQuery().eq(Article::getStatus, 1));
long fullCost = System.nanoTime() - start;
start = System.nanoTime();
List<Article> lightList = articleMapper.selectList(
Wrappers.<Article>lambdaQuery()
.select(Article.class, f -> !"content".equals(f.getProperty())
&& !"extraConfig".equals(f.getProperty())
&& !"coverImageBase64".equals(f.getProperty()))
.eq(Article::getStatus, 1));
long lightCost = System.nanoTime() - start;
log.info("全字段查询: {}ms, 排除大字段: {}ms, 提升: {}%",
fullCost / 1_000_000,
lightCost / 1_000_000,
(fullCost - lightCost) * 100 / fullCost);
8.4 网络层抓包(进阶)
使用 tcpdump 或 Wireshark 抓包,对比两种查询的 MySQL 协议包大小,直观感受传输数据量的差异。
九、进阶优化策略
9.1 配合覆盖索引
排除大字段后,查询的瓶颈通常转移到 WHERE 条件过滤和排序。建立合适的联合索引可以进一步优化:
-- 针对列表查询的联合索引
CREATE INDEX idx_article_list
ON article(status, del_flag, category_id, publish_time DESC);如果 SELECT 的所有列都能被索引覆盖(覆盖索引),MySQL 可以完全不回表,直接在索引上完成查询:
-- 极端精简查询,可被索引完全覆盖 SELECT id, title FROM article WHERE status = 1 AND del_flag = 0;
9.2 垂直拆表
当大字段占单行数据量 90% 以上,且绝大多数业务场景不读取时,考虑垂直拆表:
-- 主表:高频访问的轻量字段
CREATE TABLE article (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
summary VARCHAR(500),
category_id BIGINT NOT NULL,
author VARCHAR(64),
view_count INT DEFAULT 0,
status TINYINT DEFAULT 0,
publish_time DATETIME,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
del_flag TINYINT DEFAULT 0 NOT NULL
) COMMENT='文章主表';
-- 内容表:低频访问的大字段
CREATE TABLE article_content (
article_id BIGINT PRIMARY KEY COMMENT '关联文章ID',
content LONGTEXT COMMENT '文章正文',
extra_config TEXT COMMENT '扩展配置',
cover_image_base64 MEDIUMTEXT COMMENT '封面图',
CONSTRAINT fk_article_content FOREIGN KEY (article_id) REFERENCES article(id)
) COMMENT='文章内容表';列表查询只访问 article 表,详情查询再 JOIN 或单独查 article_content。
适用条件:
- 大字段占单行 90%+ 数据量
- 95%+ 的查询不需要大字段
- 表数据量在百万级以上
不适用条件:
- 表数据量小(几千行),全查也无压力
- 大字段经常需要和主表字段一起查询
9.3 缓存策略
对于变化频率低的列表数据,可叠加应用层缓存:
@Cacheable(value = "article:published:list", key = "#categoryId", unless = "#result.isEmpty()")
public List<ArticleListVo> listPublishedArticles(Long categoryId) {
// 查询逻辑...
}
缓存的是排除大字段后的轻量数据,单条缓存体积小,可以缓存更多条目,命中率更高。
9.4 延迟加载 / 按需查询
对于"可能需要也可能不需要"的字段,不要在主查询中携带:
// 第一步:列表查询(无大字段) List<ArticleListVo> list = listPublishedArticles(categoryId); // 第二步:用户点击某篇文章时,再查详情(含大字段) ArticleDetailVo detail = getArticleDetail(clickedId);
这是最自然的"按需加载"模式,也是前后端分离架构下的标准做法。
十、常见误区与踩坑记录
10.1 误区一:VO 中没有该字段就不会查询
// ❌ 错误认知
// "ArticleListVo 里没有 content 字段,所以 MyBatis-Plus 不会查 content"
List<Article> list = articleMapper.selectList(
Wrappers.<Article>lambdaQuery().eq(Article::getStatus, 1)
);
事实:MyBatis-Plus 生成 SQL 是基于实体类(Entity)的 TableInfo,与 VO 无关。即使最终映射到没有 content 属性的 VO,SQL 仍然会 SELECT content,数据库仍然读取溢出页,JDBC 仍然传输数据。性能损耗在 SQL 执行阶段已经发生,VO 映射只是最后一步的"丢弃"。
必须通过 .select() 在 SQL 层面排除,才能真正避免。
10.2 误区二:getColumn()匹配 Java 属性名
// ❌ 错误:getColumn() 返回数据库列名(下划线风格),不是 Java 属性名 .select(Article.class, field -> !"extraConfig".equals(field.getColumn()))
getColumn() 返回 "extra_config",与 "extraConfig" 永远不相等,过滤条件恒为 true,大字段仍然被查出。且不会报任何错误——静默失效。
正确写法:
// ✅ 用 getProperty() 匹配 Java 属性名 .select(Article.class, field -> !"extraConfig".equals(field.getProperty())) // ✅ 或用 getColumn() 匹配数据库列名 .select(Article.class, field -> !"extra_config".equals(field.getColumn()))
10.3 误区三:分页查询忘记排除大字段
// ❌ 分页 + 全字段
Page<Article> page = articleMapper.selectPage(
new Page<>(1, 20),
Wrappers.<Article>lambdaQuery().eq(Article::getStatus, 1)
);
"只取 20 条"不代表开销小。如果每条 500 KB,20 条就是 10 MB。正确做法:
// ✅ 分页 + 排除大字段
Page<Article> page = articleMapper.selectPage(
new Page<>(1, 20),
Wrappers.<Article>lambdaQuery()
.select(Article.class, field ->
!"content".equals(field.getProperty())
&& !"coverImageBase64".equals(field.getProperty()))
.eq(Article::getStatus, 1)
.orderByDesc(Article::getPublishTime)
);
10.4 误区四:.select()与.setSqlSelect()混用
// ❌ 冲突:setSqlSelect 优先级更高,会覆盖 select() 的列选择
wrapper.select(Article.class, field -> !"content".equals(field.getProperty()))
.setSqlSelect("id, title"); // 这行会覆盖上面的 select()
两者不要混用。如果需要完全自定义 SELECT 子句,直接使用 setSqlSelect()。
10.5 误区五:排除字段时误排除了逻辑删除字段
// ⚠️ 危险:如果不小心排除了 del_flag .select(Article.class, field -> !"delFlag".equals(field.getProperty()))
虽然 .select() 过滤的是 SELECT 列,不影响 WHERE 中 MyBatis-Plus 自动追加的 del_flag = 0 条件,但返回结果中不包含 del_flag 字段。如果后续逻辑依赖该字段值,会拿到 null。
建议:在 Predicate 中不要排除主键和逻辑删除字段,除非你非常清楚后果。
10.6 误区六:认为排除字段后就不需要索引了
排除大字段解决的是 I/O 量和传输量 的问题。如果 WHERE 条件没有索引,仍然会全表扫描(只是每行读的数据少了)。索引和字段排除是两个正交的优化维度,需要同时关注。
十一、与 MyBatis-Plus 其他特性的兼容性
11.1 逻辑删除(@TableLogic)
.select() 过滤的是 SELECT 列,不影响 WHERE 条件。MyBatis-Plus 会自动在 WHERE 中追加 del_flag = 0,无论你是否在 SELECT 中包含 del_flag。
11.2 多租户(TenantLineInnerInterceptor)
多租户插件自动追加 tenant_id = ? 条件,与 .select() 无冲突。
11.3 数据权限(DataPermissionInterceptor)
数据权限插件在 WHERE 中追加权限条件,与 .select() 无冲突。
11.4 乐观锁(@Version)
乐观锁字段通常只在 UPDATE 时使用。如果 SELECT 中排除了 version 字段,后续无法基于该对象做乐观锁更新。需根据业务判断是否保留。
11.5selectVoList/selectVoPage(BaseMapperPlus 扩展)
如果你的项目使用了 BaseMapperPlus(常见于 RuoYi-Vue-Plus 等框架),可以直接:
List<ArticleListVo> list = articleMapper.selectVoList(
Wrappers.<Article>lambdaQuery()
.select(Article.class, field -> !"content".equals(field.getProperty()))
.eq(Article::getStatus, 1)
);
框架会自动将查询结果映射为 ArticleListVo,无需手动转换。
十二、最佳实践清单
| 序号 | 实践要点 | 说明 |
|---|---|---|
| 1 | 列表/导出/统计接口必须排除大字段 | 使用 .select(Class, Predicate) |
| 2 | 详情接口按需查询全字段 | 不添加 .select() 过滤 |
| 3 | 优先使用 getProperty() 匹配 | 避免 @TableField 变更导致静默失效 |
| 4 | VO 与 Entity 严格分离 | VO 中不定义不需要的字段 |
| 5 | 为高频查询条件建立联合索引 | 字段排除和索引是正交优化 |
| 6 | 大字段占比极高时考虑垂直拆表 | 从根本上隔离冷热数据 |
| 7 | 分页查询同样需要排除大字段 | "只取 20 条"不代表开销小 |
| 8 | 通过 SQL 日志验证生成语句 | 确认大字段确实未出现在 SELECT 中 |
| 9 | 不要误排除主键和逻辑删除字段 | 防止框架行为异常 |
| 10 | 结合压测验证优化效果 | 用数据量化收益 |
| 11 | 多表 JOIN 在 XML 中手动指定列 | Wrapper 的 .select() 仅适用于单表 |
| 12 | 避免 .select() 与 .setSqlSelect() 混用 | 后者优先级更高,会覆盖前者 |
以上就是MyBatis-Plus查询排除大字段的性能优化指南的详细内容,更多关于MyBatis-Plus查询排除大字段的资料请关注脚本之家其它相关文章!
