java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > MyBatis-Plus查询排除大字段

MyBatis-Plus查询排除大字段的性能优化指南

作者:李少兄

MyBatis-Plus默认查询全字段时,大字段(如LONGTEXT)会引发额外磁盘I/O、网络传输和内存开销,导致列表查询性能下降两三个数量级,使用.select()方法按需排除这些字段,可避免数据溢出页读取,显著提升响应速度并降低资源消耗,下面小编就和大家详细讲讲吧

一、前言

在真实的业务开发中,我们几乎不可避免地会遇到这样的表结构:某些字段存储着大段富文本、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='文章表';

三个大字段的典型数据量:

字段类型单条典型大小
contentLONGTEXT50 KB ~ 2 MB
extra_configTEXT2 KB ~ 20 KB
cover_image_base64MEDIUMTEXT100 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 ~ 5s3ms ~ 15ms
JVM 临时对象~50 MB< 0.5 MB

差距是 两到三个数量级。

三、底层原理:为什么排除大字段能带来如此显著的提升

3.1 InnoDB 行格式与溢出页

InnoDB 默认使用 DYNAMIC 行格式(MySQL 5.7.9+)。其核心规则是:

这意味着:

3.2 MySQL 协议传输开销

MySQL Server 与客户端之间通过 MySQL 二进制协议传输结果集。对于 TEXT/BLOB 类型:

  1. 服务端从存储引擎读取完整数据(包括溢出页)。
  2. 将数据编码为 MYSQL_TYPE_BLOB / MYSQL_TYPE_VAR_STRING 类型的 Protocol 包。
  3. 通过 TCP 发送到客户端。

即使应用层最终不使用这些数据,网络带宽已经被占用。在云环境中,跨可用区的网络延迟和带宽成本更是不容忽视。

3.3 JDBC 驱动与 JVM 内存

MySQL JDBC 驱动(Connector/J)在接收结果集时:

  1. 为每一行的每一列分配 byte[] 缓冲区。
  2. 将 byte[] 转换为 String(对于字符类型)。
  3. 构建 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)

参数说明:

TableFieldInfo 常用方法:

方法返回值说明
getProperty()StringJava 属性名,如 "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。

适用条件:

不适用条件:

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 变更导致静默失效
4VO 与 Entity 严格分离VO 中不定义不需要的字段
5为高频查询条件建立联合索引字段排除和索引是正交优化
6大字段占比极高时考虑垂直拆表从根本上隔离冷热数据
7分页查询同样需要排除大字段"只取 20 条"不代表开销小
8通过 SQL 日志验证生成语句确认大字段确实未出现在 SELECT 中
9不要误排除主键和逻辑删除字段防止框架行为异常
10结合压测验证优化效果用数据量化收益
11多表 JOIN 在 XML 中手动指定列Wrapper 的 .select() 仅适用于单表
12避免 .select() 与 .setSqlSelect() 混用后者优先级更高,会覆盖前者

以上就是MyBatis-Plus查询排除大字段的性能优化指南的详细内容,更多关于MyBatis-Plus查询排除大字段的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文