ArticleJava 后端

MyBatis 和 MyBatis-Plus 到底差在哪?结合博客后端项目从 Mapper 层讲明白

2026 年 05 月 23 日84 次阅读25812
Spring BootMyBatis-Plus数据库MybatisMapper
摘要

本文结合 `study-blog` 后端项目,讲清楚原生 MyBatis 和 MyBatis-Plus 在 Mapper、Service、CRUD、分页、逻辑删除、自定义 SQL 等场景下的用法区别。

页面目录

注:本文由Codex GPT-5.5 xhigh 生成


一、引言:为什么要比较 MyBatis 和 MyBatis-Plus?

刚开始写后端的时候,很容易被一堆名词绕晕:

TEXT
Controller
Service
Mapper
Entity
DTO
VO
XML
Wrapper
BaseMapper
ServiceImpl

尤其是看到项目里的 Mapper 接口时,大概率会有一种灵魂发问:

Java
public interface ArticleMapper extends BaseMapper<Article> {
}

啊?这就完了?

SQL 呢?增删改查呢?select * from article where id = ? 呢?

先别急,这正是 MyBatis-Plus 最容易让初学者产生困惑的地方:它不是没有 SQL,而是把大量通用 SQL 封装好了。

本文不空讲概念,直接结合本博客后端项目来讲。当前项目使用的是 MyBatis-Plus,也就是常说的 MP。我们会先看项目里 MP 是怎么写的,再假设如果换成原生 MyBatis,大概需要写成什么样。

一句话先粗暴理解:

TEXT
MyBatis:你写 SQL,我帮你执行。
MyBatis-Plus:常见 SQL 我先帮你写好,复杂 SQL 你再自己补。

二、先看项目结构:Mapper 层到底在哪?

在这个博客后端项目里,整体调用链路大概是这样:

TEXT
前端请求
  ↓
Controller 接收请求
  ↓
Service 处理业务逻辑
  ↓
Mapper 操作数据库
  ↓
MySQL

项目中相关目录大概如下:

TEXT
src/main/java/com/example/studyblog
├── controller
│   ├── admin
│   └── publicapi
├── service
│   └── impl
├── mapper
├── entity
├── dto
└── vo

其中 Mapper 层就在:

TEXT
src/main/java/com/example/studyblog/mapper

比如:

TEXT
ArticleMapper.java
ArticleTagMapper.java
ArticleTagRelMapper.java
ArticleCategoryMapper.java
StudyDiaryMapper.java
ProjectInfoMapper.java
ResourceItemMapper.java
SiteConfigMapper.java

启动类里有一行非常关键的配置:

Java
package com.example.studyblog;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.example.studyblog.mapper")
public class StudyBlogApplication {

    public static void main(String[] args) {
        SpringApplication.run(StudyBlogApplication.class, args);
    }
}

@MapperScan("com.example.studyblog.mapper") 的意思是:

TEXT
请扫描 com.example.studyblog.mapper 包下面的所有 Mapper 接口,
并把它们注册到 Spring 容器里。

如果没有这行配置,后面在 Service 里注入 ArticleMapperSysUserMapper 的时候,Spring 就会一脸懵:你说的这个 Mapper 是谁?


三、Entity 对比:MyBatis 和 MP 都离不开实体类

不管用 MyBatis 还是 MyBatis-Plus,实体类都是绕不开的。

实体类的作用很简单:

TEXT
数据库表中的一行数据  <->  Java 中的一个对象

比如项目里的文章实体类:

Java
package com.example.studyblog.entity;

import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;

import java.time.LocalDateTime;

@Data
@TableName("article")
@EqualsAndHashCode(callSuper = true)
public class Article extends BaseEntity {

    private String title;
    private String slug;
    private String summary;
    private String coverImage;
    private String contentMd;
    private String contentHtml;
    private Long authorId;
    private Long categoryId;
    private String status;
    private Integer isTop;
    private Long viewCount;
    private Integer wordCount;
    private Integer sort;
    private LocalDateTime publishTime;
}

这里有一个 MP 的注解:

Java
@TableName("article")

它表示:

TEXT
Article 这个 Java 类,对应数据库里的 article 表。

如果使用原生 MyBatis,这个实体类也可以存在,只不过 @TableName 通常不是必需的。因为原生 MyBatis 更多是通过 XML 里的 SQL、resultMap 或字段别名来做映射。

换句话说:

TEXT
MyBatis-Plus 更喜欢在实体类上写映射规则。
MyBatis 更喜欢在 XML 或 SQL 中写映射规则。

四、公共字段:BaseEntity 里藏着 MP 的几个重点

项目中很多实体类都继承了 BaseEntity

Java
package com.example.studyblog.entity;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import lombok.Data;

import java.time.LocalDateTime;

@Data
public abstract class BaseEntity {

    @TableId(type = IdType.AUTO)
    private Long id;

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createdAt;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updatedAt;

    @TableLogic
    @TableField(fill = FieldFill.INSERT)
    private Integer isDeleted;
}

这几个字段基本是后台管理系统里的老熟人:

TEXT
id          主键
createdAt   创建时间
updatedAt   更新时间
isDeleted   是否删除

重点看几个 MP 注解。

1. @TableId

Java
@TableId(type = IdType.AUTO)
private Long id;

意思是:

TEXT
id 是主键,并且使用数据库自增策略。

假设数据库中 article.idAUTO_INCREMENT,那么插入文章时不需要手动指定 id,MySQL 会自动生成。

2. @TableField(fill = ...)

Java
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;

@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;

意思是:

TEXT
createdAt 在插入时自动填充。
updatedAt 在插入和更新时自动填充。

这就避免了每次新增数据都手动写:

Java
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());

3. @TableLogic

Java
@TableLogic
@TableField(fill = FieldFill.INSERT)
private Integer isDeleted;

这个注解表示逻辑删除。

所谓逻辑删除,就是删除时不是真的把数据从数据库里删掉,而是把某个字段改成删除状态。

比如:

TEXT
is_deleted = 0  正常数据
is_deleted = 1  已删除数据

这样做的好处是:数据还在,只是正常查询时查不到了。万一误删,至少还有机会找回来。不然真物理删除,删完就是删完,数据库可不会心疼你。


五、配置对比:MP 把很多通用规则集中配置了

项目中的 MP 配置在 application-dev.yml

YAML
mybatis-plus:
  mapper-locations: classpath*:/mapper/**/*.xml
  type-aliases-package: com.example.studyblog.entity
  global-config:
    db-config:
      logic-delete-field: isDeleted
      logic-not-delete-value: 0
      logic-delete-value: 1
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

逐个看。

1. mapper-locations

YAML
mapper-locations: classpath*:/mapper/**/*.xml

意思是:

TEXT
如果项目里有 XML Mapper 文件,就去 classpath:/mapper 下面找。

虽然当前项目主要用注解 SQL 和 MP 的 BaseMapper,但这个配置保留了 XML 扩展能力。

2. type-aliases-package

YAML
type-aliases-package: com.example.studyblog.entity

意思是:

TEXT
给 entity 包下的类注册别名。

如果写 XML,就可以少写一点全限定类名。

3. 逻辑删除配置

YAML
logic-delete-field: isDeleted
logic-not-delete-value: 0
logic-delete-value: 1

意思是:

TEXT
逻辑删除字段叫 isDeleted。
0 表示未删除。
1 表示已删除。

配合实体类里的:

Java
@TableLogic
private Integer isDeleted;

MP 就知道删除时应该怎么处理。

4. 下划线转驼峰

YAML
map-underscore-to-camel-case: true

这个很重要。

数据库字段一般这样命名:

SQL
cover_image
content_md
publish_time
created_at
updated_at
is_deleted

Java 字段一般这样命名:

Java
coverImage
contentMd
publishTime
createdAt
updatedAt
isDeleted

开启这个配置后,MyBatis / MP 就能自动理解:

TEXT
cover_image  <-> coverImage
content_md   <-> contentMd
publish_time <-> publishTime

不然你就要在 SQL 里疯狂写别名:

SQL
SELECT cover_image AS coverImage,
       content_md AS contentMd,
       publish_time AS publishTime
FROM article

不是不能写,就是写多了容易怀疑人生。


六、Mapper 写法对比:MP 为什么可以这么短?

来看当前项目里的 ArticleMapper

Java
package com.example.studyblog.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.studyblog.entity.Article;
import com.example.studyblog.vo.common.ArchiveVO;
import org.apache.ibatis.annotations.Select;

import java.util.List;

public interface ArticleMapper extends BaseMapper<Article> {

    @Select("""
            SELECT DATE_FORMAT(publish_time, '%Y-%m') AS archiveMonth, COUNT(*) AS count
            FROM article
            WHERE status = 'published' AND is_deleted = 0
            GROUP BY DATE_FORMAT(publish_time, '%Y-%m')
            ORDER BY archiveMonth DESC
            """)
    List<ArchiveVO> selectArchiveList();
}

最关键的是这一句:

Java
public interface ArticleMapper extends BaseMapper<Article>

BaseMapper<Article> 是 MyBatis-Plus 提供的通用 Mapper。

它已经内置了很多基础方法,例如:

Java
articleMapper.selectById(id);
articleMapper.selectList(wrapper);
articleMapper.selectOne(wrapper);
articleMapper.selectCount(wrapper);
articleMapper.insert(article);
articleMapper.updateById(article);
articleMapper.deleteById(id);

所以对于普通 CRUD 来说,ArticleMapper 不需要自己写一堆方法。

如果换成原生 MyBatis 会怎样?

原生 MyBatis 中,Mapper 接口通常要自己声明方法:

Java
package com.example.studyblog.mapper;

import com.example.studyblog.entity.Article;
import org.apache.ibatis.annotations.Param;

import java.util.List;

public interface ArticleMapper {

    Article selectById(@Param("id") Long id);

    List<Article> selectList();

    int insert(Article article);

    int updateById(Article article);

    int logicDeleteById(@Param("id") Long id);
}

然后还要写 XML:

XML
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.studyblog.mapper.ArticleMapper">

    <select id="selectById" resultType="com.example.studyblog.entity.Article">
        SELECT id,
               title,
               slug,
               summary,
               cover_image,
               content_md,
               content_html,
               author_id,
               category_id,
               status,
               is_top,
               view_count,
               word_count,
               sort,
               publish_time,
               created_at,
               updated_at,
               is_deleted
        FROM article
        WHERE id = #{id}
          AND is_deleted = 0
    </select>

    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO article (
            title,
            slug,
            summary,
            cover_image,
            content_md,
            content_html,
            author_id,
            category_id,
            status,
            is_top,
            view_count,
            word_count,
            sort,
            publish_time,
            created_at,
            updated_at,
            is_deleted
        )
        VALUES (
            #{title},
            #{slug},
            #{summary},
            #{coverImage},
            #{contentMd},
            #{contentHtml},
            #{authorId},
            #{categoryId},
            #{status},
            #{isTop},
            #{viewCount},
            #{wordCount},
            #{sort},
            #{publishTime},
            #{createdAt},
            #{updatedAt},
            #{isDeleted}
        )
    </insert>

    <update id="updateById">
        UPDATE article
        SET title = #{title},
            slug = #{slug},
            summary = #{summary},
            cover_image = #{coverImage},
            content_md = #{contentMd},
            content_html = #{contentHtml},
            author_id = #{authorId},
            category_id = #{categoryId},
            status = #{status},
            is_top = #{isTop},
            view_count = #{viewCount},
            word_count = #{wordCount},
            sort = #{sort},
            publish_time = #{publishTime},
            updated_at = #{updatedAt}
        WHERE id = #{id}
          AND is_deleted = 0
    </update>

    <update id="logicDeleteById">
        UPDATE article
        SET is_deleted = 1,
            updated_at = NOW()
        WHERE id = #{id}
          AND is_deleted = 0
    </update>

</mapper>

可以看到,原生 MyBatis 当然也能完成这些操作,而且 SQL 非常清楚。

但问题是:基础 CRUD 会变得很啰嗦。

如果一个项目里有十几张表,每张表都写一遍 selectByIdinsertupdateByIdlogicDeleteById,那就不是写代码了,那是跟键盘进行体力劳动。

所以这里可以得到第一个核心区别:

TEXT
MyBatis:基础 SQL 也要自己写,控制感强,但重复代码多。
MyBatis-Plus:基础 SQL 已经封装好,开发效率高,但要理解它的规则。

七、Service 写法对比:MP 的 ServiceImpl 又帮了什么?

当前项目的文章 Service 实现类是这样写的:

Java
package com.example.studyblog.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.studyblog.entity.Article;
import com.example.studyblog.mapper.ArticleMapper;
import com.example.studyblog.service.ArticleService;
import org.springframework.stereotype.Service;

@Service
public class ArticleServiceImpl extends ServiceImpl<ArticleMapper, Article> implements ArticleService {
}

关键是:

Java
extends ServiceImpl<ArticleMapper, Article>

这句话的意思是:

TEXT
当前 Service 使用 ArticleMapper 操作 Article 实体。
并且继承 MP 提供的一批通用 Service 方法。

继承之后,ArticleServiceImpl 里可以直接使用:

Java
save(entity);
updateById(entity);
removeById(id);
getById(id);
getOne(wrapper);
list(wrapper);
page(page, wrapper);

项目里的新增文章

当前项目新增文章的方法:

Java
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(ArticleSaveRequest request, Long authorId) {
    checkSlugUnique(request.getSlug(), null);
    validateCategoryAndTags(request.getCategoryId(), request.getTagIds());

    Article entity = new Article();
    applySaveRequest(entity, request, authorId);

    save(entity);
    saveTagRelations(entity.getId(), request.getTagIds());

    return entity.getId();
}

这里最关键的是:

Java
save(entity);

它背后会调用 MP 封装好的插入逻辑,最终插入 article 表。

如果用原生 MyBatis

原生 MyBatis 中,Service 往往要直接注入 Mapper:

Java
@Service
public class ArticleServiceImpl implements ArticleService {

    private final ArticleMapper articleMapper;

    public ArticleServiceImpl(ArticleMapper articleMapper) {
        this.articleMapper = articleMapper;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long create(ArticleSaveRequest request, Long authorId) {
        checkSlugUnique(request.getSlug(), null);
        validateCategoryAndTags(request.getCategoryId(), request.getTagIds());

        Article entity = new Article();
        applySaveRequest(entity, request, authorId);

        entity.setCreatedAt(LocalDateTime.now());
        entity.setUpdatedAt(LocalDateTime.now());
        entity.setIsDeleted(0);

        articleMapper.insert(entity);
        saveTagRelations(entity.getId(), request.getTagIds());

        return entity.getId();
    }
}

对比一下:

TEXT
MP:save(entity)
MyBatis:articleMapper.insert(entity)

这并不是谁高级谁低级的问题,只是封装层次不同。

MP 在 Service 层又帮你包了一层,所以你可以少写一些重复的 Mapper 调用。


八、baseMapper 是什么?

在项目里可以看到这样的代码:

Java
@Override
public List<ArchiveVO> getArchiveList() {
    return baseMapper.selectArchiveList();
}

这里的 baseMapper 是谁?

由于当前类继承了:

Java
ServiceImpl<ArticleMapper, Article>

所以 MP 会在 ServiceImpl 里帮你准备好当前实体对应的 Mapper,也就是:

TEXT
baseMapper ≈ ArticleMapper

因此:

Java
baseMapper.selectArchiveList();

就等价于在调用 ArticleMapper 中的:

Java
List<ArchiveVO> selectArchiveList();

可以简单记:

TEXT
在 ArticleServiceImpl 中,baseMapper 就是 ArticleMapper。
在 StudyDiaryServiceImpl 中,baseMapper 就是 StudyDiaryMapper。
在 ProjectInfoServiceImpl 中,baseMapper 就是 ProjectInfoMapper。

这就是 MP 约定带来的便利。


九、条件查询对比:Wrapper 和 XML 动态 SQL

当前项目大量使用了 LambdaQueryWrapper

比如后台文章分页查询:

Java
@Override
public PageResult<ArticleListVO> getAdminPage(ArticleQueryRequest request) {
    Page<Article> page = page(new Page<>(request.getPageNum(), request.getPageSize()),
            new LambdaQueryWrapper<Article>()
                    .like(StringUtils.hasText(request.getTitle()), Article::getTitle, request.getTitle())
                    .eq(request.getCategoryId() != null, Article::getCategoryId, request.getCategoryId())
                    .eq(StringUtils.hasText(request.getStatus()), Article::getStatus, request.getStatus())
                    .orderByDesc(Article::getUpdatedAt)
                    .orderByDesc(Article::getCreatedAt));

    return buildArticlePageResult(
            page.getRecords(),
            page.getTotal(),
            request.getPageNum(),
            request.getPageSize()
    );
}

这里的:

Java
new LambdaQueryWrapper<Article>()

就是 MP 的条件构造器。

逐行看:

Java
.like(StringUtils.hasText(request.getTitle()), Article::getTitle, request.getTitle())

意思是:

TEXT
如果 request.getTitle() 有文本,就拼接 title LIKE 条件。
如果没有文本,就不拼接这个条件。

再看:

Java
.eq(request.getCategoryId() != null, Article::getCategoryId, request.getCategoryId())

意思是:

TEXT
如果 categoryId 不为空,就拼接 category_id = ? 条件。

整体大概会生成类似 SQL:

SQL
SELECT *
FROM article
WHERE is_deleted = 0
  AND title LIKE ?
  AND category_id = ?
  AND status = ?
ORDER BY updated_at DESC, created_at DESC
LIMIT ?, ?

这里有一个很舒服的点:

Java
Article::getTitle
Article::getCategoryId
Article::getUpdatedAt

它们不是字符串,而是方法引用。

如果字段改名,编译器更容易发现问题。相比直接写 "title""category_id",会更稳一点。

如果用原生 MyBatis

原生 MyBatis 里通常会写 Mapper 方法:

Java
List<Article> selectAdminPage(@Param("title") String title,
                              @Param("categoryId") Long categoryId,
                              @Param("status") String status,
                              @Param("offset") Long offset,
                              @Param("pageSize") Long pageSize);

Long countAdminPage(@Param("title") String title,
                    @Param("categoryId") Long categoryId,
                    @Param("status") String status);

然后 XML 写动态 SQL:

XML
<select id="selectAdminPage" resultType="com.example.studyblog.entity.Article">
    SELECT id,
           title,
           slug,
           summary,
           cover_image,
           content_md,
           content_html,
           author_id,
           category_id,
           status,
           is_top,
           view_count,
           word_count,
           sort,
           publish_time,
           created_at,
           updated_at,
           is_deleted
    FROM article
    WHERE is_deleted = 0

    <if test="title != null and title != ''">
        AND title LIKE CONCAT('%', #{title}, '%')
    </if>

    <if test="categoryId != null">
        AND category_id = #{categoryId}
    </if>

    <if test="status != null and status != ''">
        AND status = #{status}
    </if>

    ORDER BY updated_at DESC, created_at DESC
    LIMIT #{offset}, #{pageSize}
</select>

<select id="countAdminPage" resultType="java.lang.Long">
    SELECT COUNT(*)
    FROM article
    WHERE is_deleted = 0

    <if test="title != null and title != ''">
        AND title LIKE CONCAT('%', #{title}, '%')
    </if>

    <if test="categoryId != null">
        AND category_id = #{categoryId}
    </if>

    <if test="status != null and status != ''">
        AND status = #{status}
    </if>
</select>

Service 里还要手动算偏移量:

Java
long offset = (request.getPageNum() - 1) * request.getPageSize();

List<Article> records = articleMapper.selectAdminPage(
        request.getTitle(),
        request.getCategoryId(),
        request.getStatus(),
        offset,
        request.getPageSize()
);

Long total = articleMapper.countAdminPage(
        request.getTitle(),
        request.getCategoryId(),
        request.getStatus()
);

可以看到:

TEXT
MP 用 Wrapper 写动态条件。
MyBatis 用 XML 的 <if> 写动态条件。

二者本质上都在拼 SQL,只是写法不同。


十、分页对比:MP 分页插件少写了什么?

项目中配置了 MP 分页插件:

Java
package com.example.studyblog.common.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

配置之后,Service 中可以这样分页:

Java
Page<Article> page = page(
        new Page<>(request.getPageNum(), request.getPageSize()),
        new LambdaQueryWrapper<Article>()
                .eq(Article::getStatus, "published")
                .orderByDesc(Article::getPublishTime)
);

然后直接拿结果:

Java
List<Article> records = page.getRecords();
long total = page.getTotal();
long current = page.getCurrent();
long size = page.getSize();

这就很省事。

如果用原生 MyBatis

你通常要自己处理两个问题:

TEXT
1. 查当前页数据
2. 查总条数

也就是:

Java
long offset = (pageNum - 1) * pageSize;

List<Article> records = articleMapper.selectPage(offset, pageSize);
Long total = articleMapper.countPage();

XML:

XML
<select id="selectPage" resultType="com.example.studyblog.entity.Article">
    SELECT *
    FROM article
    WHERE is_deleted = 0
    ORDER BY created_at DESC
    LIMIT #{offset}, #{pageSize}
</select>

<select id="countPage" resultType="java.lang.Long">
    SELECT COUNT(*)
    FROM article
    WHERE is_deleted = 0
</select>

不是不能做,但每个分页接口都这么写,时间久了就会发现:怎么我每天都在写 LIMITCOUNT(*)

MP 的分页插件就是为了解决这种重复劳动。


十一、逻辑删除对比:MP 自动帮你加 is_deleted 条件

当前项目中,所有表基本都使用了逻辑删除字段:

TEXT
is_deleted

MP 配置如下:

YAML
mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: isDeleted
      logic-not-delete-value: 0
      logic-delete-value: 1

实体中也有:

Java
@TableLogic
@TableField(fill = FieldFill.INSERT)
private Integer isDeleted;

项目删除文章的方法:

Java
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
    getByIdOrThrow(id);
    removeById(id);
    articleTagRelMapper.hardDeleteByArticleId(id);
}

重点看:

Java
removeById(id);

这行代码看起来像删除,实际上对于带 @TableLogic 的实体,MP 不会直接执行:

SQL
DELETE FROM article WHERE id = ?

而是执行类似:

SQL
UPDATE article
SET is_deleted = 1
WHERE id = ?
  AND is_deleted = 0

并且普通查询时,MP 也会自动帮你考虑逻辑删除条件。

比如:

Java
getById(id);

正常情况下不会查出 is_deleted = 1 的数据。

如果用原生 MyBatis

删除要自己写:

XML
<update id="logicDeleteById">
    UPDATE article
    SET is_deleted = 1,
        updated_at = NOW()
    WHERE id = #{id}
      AND is_deleted = 0
</update>

查询也要自己记得写:

XML
<select id="selectById" resultType="com.example.studyblog.entity.Article">
    SELECT *
    FROM article
    WHERE id = #{id}
      AND is_deleted = 0
</select>

分页也要写:

XML
<select id="selectPage" resultType="com.example.studyblog.entity.Article">
    SELECT *
    FROM article
    WHERE is_deleted = 0
    ORDER BY created_at DESC
    LIMIT #{offset}, #{pageSize}
</select>

这里最容易出问题的地方是:忘记加 is_deleted = 0

如果某个查询忘了加,那些已经逻辑删除的数据就可能又被查出来了。到时候你会发现:我不是删了吗?它怎么又活了?

所以逻辑删除这种高度重复、容易漏的规则,交给 MP 统一处理还是很舒服的。


十二、自动填充对比:createdAt、updatedAt 谁来管?

项目中有一个自动填充处理器:

Java
package com.example.studyblog.common.config;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

@Component
public class MybatisPlusMetaObjectHandler implements MetaObjectHandler {

    @Override
    public void insertFill(MetaObject metaObject) {
        LocalDateTime now = LocalDateTime.now();
        strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now);
        strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, now);
        strictInsertFill(metaObject, "isDeleted", Integer.class, 0);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
    }
}

配合实体类中的:

Java
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;

@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;

@TableLogic
@TableField(fill = FieldFill.INSERT)
private Integer isDeleted;

MP 在插入时会自动填:

TEXT
createdAt = 当前时间
updatedAt = 当前时间
isDeleted = 0

更新时会自动填:

TEXT
updatedAt = 当前时间

所以新增文章时,业务代码只需要关心真正的业务字段:

Java
Article entity = new Article();
entity.setTitle(request.getTitle());
entity.setSlug(request.getSlug());
entity.setSummary(request.getSummary());
entity.setContentMd(request.getContentMd());
entity.setContentHtml(request.getContentHtml());

save(entity);

如果用原生 MyBatis

你可能要在 Java 里手动设置:

Java
Article entity = new Article();
entity.setTitle(request.getTitle());
entity.setSlug(request.getSlug());
entity.setSummary(request.getSummary());
entity.setContentMd(request.getContentMd());
entity.setContentHtml(request.getContentHtml());

LocalDateTime now = LocalDateTime.now();
entity.setCreatedAt(now);
entity.setUpdatedAt(now);
entity.setIsDeleted(0);

articleMapper.insert(entity);

或者在 SQL 中写:

XML
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO article (
        title,
        slug,
        summary,
        content_md,
        content_html,
        created_at,
        updated_at,
        is_deleted
    )
    VALUES (
        #{title},
        #{slug},
        #{summary},
        #{contentMd},
        #{contentHtml},
        NOW(),
        NOW(),
        0
    )
</insert>

这就是第二个很明显的区别:

TEXT
MP 可以通过 MetaObjectHandler 统一处理自动填充。
MyBatis 通常需要在 Java 代码或 SQL 里手动处理。

十三、自定义 SQL:MP 并不是不能写 SQL

有些人刚接触 MP 时,容易产生一个误解:

TEXT
用了 MP,是不是就不能写 SQL 了?

当然不是。

MP 只是帮你封装通用 CRUD,不代表复杂查询也要硬用 Wrapper 拼出来。

项目里的文章归档就是一个典型例子:

Java
public interface ArticleMapper extends BaseMapper<Article> {

    @Select("""
            SELECT DATE_FORMAT(publish_time, '%Y-%m') AS archiveMonth, COUNT(*) AS count
            FROM article
            WHERE status = 'published' AND is_deleted = 0
            GROUP BY DATE_FORMAT(publish_time, '%Y-%m')
            ORDER BY archiveMonth DESC
            """)
    List<ArchiveVO> selectArchiveList();
}

这段 SQL 做了几件事:

TEXT
1. 用 DATE_FORMAT 把发布时间格式化成 年-月
2. 按月份分组
3. 统计每个月文章数量
4. 按月份倒序排序

这种统计查询,用 SQL 表达非常清楚。如果非要用 Wrapper 硬拼,反而不舒服。

Service 中直接调用:

Java
@Override
public List<ArchiveVO> getArchiveList() {
    return baseMapper.selectArchiveList();
}

再看一个物理删除例子:

Java
package com.example.studyblog.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.studyblog.entity.ArticleTagRel;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;

public interface ArticleTagRelMapper extends BaseMapper<ArticleTagRel> {

    @Delete("DELETE FROM article_tag_rel WHERE article_id = #{articleId}")
    int hardDeleteByArticleId(@Param("articleId") Long articleId);
}

这里为什么不用逻辑删除?

因为 article_tag_rel 是文章和标签的关联表。更新文章标签时,常见做法就是:

TEXT
先删除旧的关联关系
再插入新的关联关系

项目中就是这么做的:

Java
private void saveTagRelations(Long articleId, List<Long> tagIds) {
    articleTagRelMapper.hardDeleteByArticleId(articleId);

    if (CollectionUtils.isEmpty(tagIds)) {
        return;
    }

    List<Long> distinctIds = new ArrayList<>(new LinkedHashSet<>(tagIds));
    for (Long tagId : distinctIds) {
        ArticleTagRel rel = new ArticleTagRel();
        rel.setArticleId(articleId);
        rel.setTagId(tagId);
        articleTagRelMapper.insert(rel);
    }
}

这里同时体现了 MP 和自定义 SQL 的组合用法:

TEXT
删除旧关系:自定义 SQL hardDeleteByArticleId
插入新关系:MP 的 insert

这才是实际项目里比较健康的写法:能省的地方省,该写 SQL 的地方就写 SQL。


十四、完整流程对比:新增一篇文章到底发生了什么?

以后台新增文章为例,当前项目的 Controller 代码大概是:

Java
@PostMapping
public Result<Long> create(@Valid @RequestBody ArticleSaveRequest request) {
    return Result.success(articleService.create(request, LoginUserContext.getUserId()));
}

进入 Service:

Java
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(ArticleSaveRequest request, Long authorId) {
    checkSlugUnique(request.getSlug(), null);
    validateCategoryAndTags(request.getCategoryId(), request.getTagIds());

    Article entity = new Article();
    applySaveRequest(entity, request, authorId);

    save(entity);
    saveTagRelations(entity.getId(), request.getTagIds());

    return entity.getId();
}

再看 applySaveRequest

Java
private void applySaveRequest(Article entity, ArticleSaveRequest request, Long authorId) {
    entity.setTitle(request.getTitle());
    entity.setSlug(request.getSlug());
    entity.setSummary(request.getSummary());
    entity.setCoverImage(request.getCoverImage());
    entity.setContentMd(request.getContentMd());
    entity.setContentHtml(request.getContentHtml());
    entity.setAuthorId(authorId);
    entity.setCategoryId(request.getCategoryId());
    entity.setStatus(request.getStatus());
    entity.setIsTop(request.getIsTop() == null ? 0 : request.getIsTop());
    entity.setSort(request.getSort() == null ? 0 : request.getSort());
    entity.setWordCount(calculateWordCount(request.getContentMd()));

    if (entity.getViewCount() == null) {
        entity.setViewCount(0L);
    }

    if ("published".equals(request.getStatus())) {
        if (request.getPublishTime() != null) {
            entity.setPublishTime(request.getPublishTime());
        } else if (entity.getPublishTime() == null) {
            entity.setPublishTime(LocalDateTime.now());
        }
    } else {
        entity.setPublishTime(request.getPublishTime());
    }
}

整体流程就是:

TEXT
前端请求 POST /api/admin/articles
  ↓
AdminArticleController.create()
  ↓
ArticleServiceImpl.create()
  ↓
checkSlugUnique() 检查 slug 是否重复
  ↓
validateCategoryAndTags() 检查分类和标签是否存在
  ↓
applySaveRequest() 把 DTO 转成 Entity
  ↓
save(entity) 插入 article 表
  ↓
saveTagRelations() 插入 article_tag_rel 表
  ↓
返回文章 id

其中:

TEXT
save(entity)

是 MP 提供的 Service 方法。

而:

TEXT
articleTagRelMapper.insert(rel)

是 MP 提供的 BaseMapper 方法。

这条链路里,MP 做了很多隐形工作:

TEXT
1. 根据 Article 实体识别 article 表
2. 根据字段生成 INSERT SQL
3. 插入时自动填充 createdAt、updatedAt、isDeleted
4. 插入成功后回填自增 id
5. 逻辑删除字段默认设为 0

如果用原生 MyBatis,这些细节大部分都要手动写在 Java 或 XML 里。


十五、再看删除文章:逻辑删除和物理删除同时存在

项目删除文章:

Java
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
    getByIdOrThrow(id);
    removeById(id);
    articleTagRelMapper.hardDeleteByArticleId(id);
}

这里其实有两种删除:

TEXT
removeById(id):逻辑删除文章
hardDeleteByArticleId(id):物理删除文章和标签的关联关系

为什么文章要逻辑删除?

TEXT
因为文章是核心业务数据,误删后可能需要恢复。

为什么关联关系可以物理删除?

TEXT
因为 article_tag_rel 只是关系数据,文章标签更新时本来就可以重建。

这说明 MP 不是让你无脑逻辑删除所有东西。

实际项目里要根据业务判断:

TEXT
核心业务数据:适合逻辑删除。
中间关系数据:很多时候可以物理删除。
日志审计数据:通常不建议删除。
临时缓存数据:可以定期物理清理。

十六、MyBatis 和 MP 的核心区别表

整理一下,二者区别大概如下:

对比点MyBatisMyBatis-Plus
Mapper 写法自己定义方法继承 BaseMapper<T> 获得通用方法
基础 CRUD通常自己写 SQL内置 insertselectByIdupdateByIddeleteById
Service 层自己注入 Mapper 并调用可继承 ServiceImpl<M, T> 使用 savepageremoveById
动态条件XML <if> / 注解 SQLLambdaQueryWrapperLambdaUpdateWrapper
分页自己写 LIMITCOUNT(*)Page<T> + 分页插件
逻辑删除自己维护 is_deleted 条件@TableLogic + 全局配置
自动填充Java 手动 set 或 SQL 写 NOW()MetaObjectHandler 自动填充
复杂 SQLXML / 注解 SQL仍然可以 XML / 注解 SQL
SQL 控制感很强基础 SQL 被封装,复杂 SQL 可自定义
开发效率基础 CRUD 较慢基础 CRUD 很快
适合场景SQL 复杂、强控制、报表查询多后台管理系统、CRUD 多、业务表多

十七、那是不是用了 MP 就不用学 MyBatis?

不是。

这一点非常重要。

MyBatis-Plus 不是一个凭空出现的新东西,它是 MyBatis 的增强工具。它底层仍然离不开 MyBatis。

所以正确的学习顺序应该是:

TEXT
先理解 MyBatis 的基本思想:
    Mapper 接口是什么
    SQL 怎么映射到方法
    参数怎么传
    结果怎么映射成对象
    XML 动态 SQL 怎么写

再学习 MyBatis-Plus:
    BaseMapper 帮了什么
    ServiceImpl 帮了什么
    Wrapper 怎么拼条件
    分页插件怎么用
    逻辑删除怎么配置
    自动填充怎么配置

如果只会 MP,不懂 MyBatis,就容易出现一种情况:

TEXT
简单 CRUD 写得飞快。
一到复杂 SQL、多表查询、动态统计,立刻卡住。

这就像只会开自动挡,但完全不知道车为什么会动。平时没问题,一旦车出点毛病,就只能在路边沉思人生。


十八、这个项目为什么适合使用 MP?

回到本博客后端项目。

这个项目里有很多业务表:

TEXT
article
article_category
article_tag
article_tag_rel
study_diary
project_info
resource_collection
resource_item
home_music
site_config
uploaded_file
sys_user
sys_user_refresh_token

这些表都有大量相似操作:

TEXT
新增
修改
删除
按 id 查询
分页查询
按条件查询
逻辑删除
创建时间
更新时间

如果全部使用原生 MyBatis,当然可以,而且 SQL 会非常直观。

但是代价也很明显:

TEXT
Mapper 方法会变多。
XML 会变多。
重复 SQL 会变多。
逻辑删除条件容易漏。
分页 count 容易重复写。
created_at / updated_at 要反复处理。

所以这个项目选择 MyBatis-Plus 是很合理的:

TEXT
基础 CRUD 交给 MP。
业务逻辑放在 Service。
复杂统计或特殊删除再写自定义 SQL。

这也是很多 Spring Boot 后台管理系统的常见选择。


十九、小结

最后重新总结一下。

MyBatis 和 MyBatis-Plus 并不是谁淘汰谁的关系。

更准确地说:

TEXT
MyBatis 是地基。
MyBatis-Plus 是在地基上搭好的常用工具间。

如果你需要非常精细地控制 SQL,或者项目里有大量复杂查询,那么原生 MyBatis 的 XML 写法依旧非常重要。

如果你的项目里有大量标准 CRUD、分页、逻辑删除、自动填充,那么 MyBatis-Plus 会非常省事。

结合当前博客后端项目来看,MP 的作用主要体现在:

TEXT
1. Mapper 继承 BaseMapper,减少基础 CRUD 代码。
2. Service 继承 ServiceImpl,直接使用 save、page、removeById 等方法。
3. LambdaQueryWrapper 让条件查询更简洁。
4. Page + 分页插件减少分页样板代码。
5. @TableLogic 自动处理逻辑删除。
6. MetaObjectHandler 自动填充 createdAt、updatedAt、isDeleted。
7. 复杂 SQL 仍然可以通过 @Select、@Delete 或 XML 自定义。

所以,对于初学者来说,最好的理解方式不是背概念,而是盯住一条主线:

TEXT
Controller 调 Service,
Service 调 Mapper,
Mapper 操作数据库,
MP 帮 Mapper 和 Service 少写重复代码。

理解了这条线,再看 BaseMapperServiceImplLambdaQueryWrapper,就不会觉得它们是天书了。

它们本质上就是一句话:

TEXT
把你本来要重复写很多遍的数据库操作,提前封装成通用工具。

这就是 MyBatis-Plus 在这个项目中的意义。