前言

在 Java 后端面试中,当面试官问到持久层框架的选型时,「MyBatis-Plus 对比原生 MyBatis 有什么优势」几乎是一个必考题。

这个问题看似基础,却能很好地考察你对技术的理解深度:你是只会用 API 的 API 调用工程师,还是真的理解了框架的设计思想,能说出它解决了什么痛点?

很多候选人回答这个问题的时候,只会说「MyBatis-Plus 不用写 SQL」,然后就没了。这样的回答,别说拿下 offer 了,连基础分都拿不到。

真正的高分回答,应该是从原生 MyBatis 的痛点出发,结合实际的代码对比,讲清楚 MyBatis-Plus 是如何通过增强,把开发效率提升了一个量级,同时又完全保留了 MyBatis 的灵活性和性能。

今天这篇文章,我就带你从面试的角度,把这个问题拆解透,每一个优势点都配上完整的代码对比,让你在面试的时候,能有条理地把这些点讲出来,直接让面试官眼前一亮。
在这里插入图片描述
在开始之前,我们先明确一个核心认知:MyBatis-Plus ≠ 替代 MyBatis,它是 MyBatis 的增强工具,只做增强不做改变

这句话是整个回答的基石,你一定要先告诉面试官这一点。这意味着,引入 MyBatis-Plus 不会对你现有的 MyBatis 项目产生任何影响,你可以无缝切换,也可以混用 —— 单表 CRUD 用 MP 提升效率,复杂多表查询用原生 MyBatis 保证灵活性。

一、通用 CRUD:告别 90% 的重复 SQL 编写

我们先从最基础的 CRUD 操作说起,这是每个项目里最常见的操作,也是原生 MyBatis 最繁琐的地方。

原生 MyBatis 的痛点

用原生 MyBatis 的时候,哪怕是最简单的单表增删改查,你都要做这些事:

  1. 在 Mapper 接口里定义方法

  2. 在 XML 文件里写对应的 SQL 语句

  3. 每个表都要重复写一遍 insert、selectById、updateById、deleteById...

  4. 一旦表加了字段,你还要回去改所有的 SQL,漏一个就出问题

比如我们要做一个用户表的 CRUD,原生 MyBatis 你需要写这么多代码:

原生 MyBatis 实现

首先是 Mapper 接口:

public interface UserMapper {
    // 插入用户
    int insertUser(User user);
    
    // 根据ID查询用户
    User selectUserById(Long id);
    
    // 根据ID批量查询用户
    List<User> selectUserByIds(List<Long> ids);
    
    // 更新用户
    int updateUserById(User user);
    
    // 根据ID删除用户
    int deleteUserById(Long id);
    
    // 根据ID批量删除用户
    int deleteUserByIds(List<Long> ids);
    
    // 查询所有用户
    List<User> selectAllUser();
}

然后是 XML 映射文件,你要写这么多 SQL:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.UserMapper">

    <insert id="insertUser">
        INSERT INTO user(
            id, username, password, email, age, 
            status, create_time, update_time
        ) VALUES (
            #{id}, #{username}, #{password}, #{email}, #{age},
            #{status}, #{createTime}, #{updateTime}
        )
    </insert>

    <select id="selectUserById" resultType="com.example.entity.User">
        SELECT id, username, password, email, age, status, create_time, update_time 
        FROM user 
        WHERE id = #{id}
    </select>

    <select id="selectUserByIds" resultType="com.example.entity.User">
        SELECT id, username, password, email, age, status, create_time, update_time 
        FROM user 
        WHERE id IN
        <foreach collection="list" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </select>

    <update id="updateUserById">
        UPDATE user
        <set>
            <if test="username != null">username = #{username},</if>
            <if test="password != null">password = #{password},</if>
            <if test="email != null">email = #{email},</if>
            <if test="age != null">age = #{age},</if>
            <if test="status != null">status = #{status},</if>
            <if test="updateTime != null">update_time = #{updateTime},</if>
        </set>
        WHERE id = #{id}
    </update>

    <delete id="deleteUserById">
        DELETE FROM user WHERE id = #{id}
    </delete>

    <delete id="deleteUserByIds">
        DELETE FROM user WHERE id IN
        <foreach collection="list" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </delete>

    <select id="selectAllUser" resultType="com.example.entity.User">
        SELECT id, username, password, email, age, status, create_time, update_time 
        FROM user
    </select>
</mapper>

看到了吗?就这么一个简单的用户表,你要写 50 多行的 XML 代码,还有接口里的 7 个方法。而且这还只是最基础的 CRUD,一旦你要加个字段,比如加个phone字段,你就要回去改所有的 SQL,把这个字段加上,漏一个就会出问题。

如果你的项目有 10 个表,那就要写 10 份这样的代码,重复劳动,毫无技术含量,还容易出错。

MyBatis-Plus 的解决方案

而用 MyBatis-Plus 的话,这一切都不需要了。你只需要做一件事:让你的 Mapper 接口继承BaseMapper就够了。

MyBatis-Plus 实现
@Mapper
public interface UserMapper extends BaseMapper<User> {
    // 什么都不用写!
    // 所有的CRUD方法,MP已经帮你实现好了
}

就这一行代码?没错,就这一行。然后你就拥有了 17 个通用的 CRUD 方法:

// 插入
int insert(T entity);

// 删除
int deleteById(Serializable id);
int deleteByMap(Map<String, Object> columnMap);
int delete(Wrapper<T> wrapper);
int deleteBatchIds(Collection<? extends Serializable> idList);

// 更新
int updateById(T entity);
int update(T entity, Wrapper<T> updateWrapper);

// 查询
T selectById(Serializable id);
List<T> selectBatchIds(Collection<? extends Serializable> idList);
List<T> selectByMap(Map<String, Object> columnMap);
T selectOne(Wrapper<T> queryWrapper);
List<T> selectList(Wrapper<T> queryWrapper);
List<Map<String, Object>> selectMaps(Wrapper<T> queryWrapper);
Integer selectCount(Wrapper<T> queryWrapper);

// 分页
<E extends IPage<T>> E selectPage(E page, Wrapper<T> queryWrapper);
<E extends IPage<Map<String, Object>>> E selectMapsPage(E page, Wrapper<T> queryWrapper);

然后你使用的时候,直接调用就可以了,完全不用写 SQL:

// 插入用户
userMapper.insert(user);

// 根据ID查询
User user = userMapper.selectById(1L);

// 批量查询
List<User> users = userMapper.selectBatchIds(Arrays.asList(1L, 2L, 3L));

// 更新用户
user.setUsername("新名字");
userMapper.updateById(user);

// 删除用户
userMapper.deleteById(1L);

// 批量删除
userMapper.deleteBatchIds(Arrays.asList(1L, 2L, 3L));

// 查询所有
List<User> allUsers = userMapper.selectList(null);

看到差距了吗?原生 MyBatis 要写近 100 行代码才能实现的功能,MP 只需要你继承一个接口,就全部搞定了。

而且,如果你给表加了字段,你只需要在实体类里加个属性就够了,不需要改任何 SQL,因为 MP 的 SQL 是动态生成的,会自动把新字段加进去,完全不会漏。

就这一点,就能帮你减少 90% 的重复代码,把你从繁琐的 CRUD 编写中解放出来。

二、条件构造器:动态 SQL 的终极解决方案

说完了基础 CRUD,我们再来说说条件查询。这也是原生 MyBatis 的一个重灾区。

原生 MyBatis 的痛点

当你需要做一个复杂的条件查询的时候,比如根据用户名模糊查询,年龄范围查询,状态查询,还要排序,原生 MyBatis 你要怎么做?

你要在 XML 里写一堆的动态 SQL 标签,<if><where><foreach>,一大堆,写起来很繁琐,还容易出错。

比如我们要做一个用户的条件查询,需求是:

  • 用户名不为空的话,模糊匹配用户名

  • 年龄不为空的话,查询年龄大于等于该值的用户

  • 状态不为空的话,匹配状态

  • 最后按创建时间倒序排序

原生 MyBatis 你要写这么多代码:

原生 MyBatis 实现

首先是 Mapper 接口:

List<User> selectUserByCondition(UserQuery query);

然后是 XML 里的动态 SQL:

<select id="selectUserByCondition" resultType="com.example.entity.User">
    SELECT id, username, email, age, status, create_time
    FROM user
    <where>
        <if test="username != null and username != ''">
            AND username LIKE CONCAT('%', #{username}, '%')
        </if>
        <if test="minAge != null">
            AND age >= #{minAge}
        </if>
        <if test="status != null">
            AND status = #{status}
        </if>
    </where>
    ORDER BY create_time DESC
</select>

这还只是简单的条件,如果条件再多一点,比如还要有时间范围,还要有部门的筛选,那这个 XML 就会变得越来越长,越来越难维护。

而且,这里有个很大的问题:你写的都是字符串的字段名,比如usernameage,一旦你写错了,比如把username写成了user_name,编译的时候不会报错,要到运行的时候才会发现,排查起来很麻烦。

MyBatis-Plus 的解决方案

而用 MyBatis-Plus 的条件构造器,这一切都变得无比简单。你只需要用链式调用,几行代码就搞定了,而且完全不用写 XML。

MyBatis-Plus 实现
public List<User> selectUserByCondition(UserQuery query) {
    // 构建条件构造器
    LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
    
    // 用户名模糊查询,只有当username不为空的时候才生效
    wrapper.like(StringUtils.hasText(query.getUsername()), 
                User::getUsername, query.getUsername());
    
    // 年龄大于等于minAge,只有当minAge不为空的时候才生效
    wrapper.ge(query.getMinAge() != null, 
               User::getAge, query.getMinAge());
    
    // 状态匹配,只有当status不为空的时候才生效
    wrapper.eq(query.getStatus() != null, 
               User::getStatus, query.getStatus());
    
    // 按创建时间倒序排序
    wrapper.orderByDesc(User::getCreateTime);
    
    // 直接查询
    return userMapper.selectList(wrapper);
}

看到了吗?就这几行代码,就实现了刚才原生 MyBatis 要写十几行 XML 才能实现的功能。而且,这里用的是 Lambda 的方法引用,User::getUsername,不是字符串,所以:

  1. 编译时检查:如果你写错了字段,比如把User::getUsername写成了User::getUser_name,编译的时候就会报错,根本不会等到运行时

  2. IDE 提示:你写的时候,IDE 会自动给你提示方法,不用你去记数据库的字段名

  3. 重构友好:如果你要改实体类的字段名,IDE 会自动帮你把所有引用的地方都改了,不会漏

这比原生的动态 SQL 不知道强了多少倍。而且,条件构造器支持的方法非常多,几乎覆盖了所有的 SQL 条件:

方法 对应 SQL
eq =
ne !=
gt >
ge >=
lt <
le <=
like LIKE % 值 %
likeLeft LIKE % 值
likeRight LIKE 值 %
in IN (...)
notIn NOT IN (...)
between BETWEEN ... AND ...
isNull 字段 IS NULL
isNotNull 字段 IS NOT NULL
orderByAsc ORDER BY 字段 ASC
orderByDesc ORDER BY 字段 DESC

不管多复杂的条件,你都可以用这种链式调用的方式拼出来,完全不用写 XML,也不用管那些动态标签,MP 会自动帮你生成对应的动态 SQL。

比如更复杂的条件,嵌套的 OR,也很简单:

wrapper.and(w -> w.eq(User::getStatus, 1).or().eq(User::getStatus, 2))
       .ge(User::getAge, 18);

对应的 SQL 就是:

WHERE (status = ? OR status = ?) AND age >= ?

是不是很简单?这比你在 XML 里写那些<if>标签要方便太多了。

三、内置分页插件:物理分页一键搞定

分页查询也是项目里非常常见的需求,原生 MyBatis 要实现分页,其实挺麻烦的。

原生 MyBatis 的痛点

原生 MyBatis 自带的RowBounds是内存分页,也就是把所有数据都查出来,然后在内存里截取你要的那一页,数据量小的时候还好,数据量大的时候,性能极差,根本不能用。

所以,正常我们都会用物理分页,也就是在 SQL 里加LIMIT。但是这样的话,你要做这些事:

  1. 自己写 COUNT 查询,获取总记录数

  2. 自己在查询 SQL 里加 LIMIT 语句

  3. 还要处理不同数据库的分页语法,比如 MySQL 是 LIMIT,Oracle 是 ROWNUM,SQLServer 是 TOP...

而且,很多时候我们还要用第三方的分页插件,比如 PageHelper,还要配置,还要注意各种坑,比如分页失效的问题。

比如,原生 MyBatis 实现分页,你要写这么多代码:

原生 MyBatis 实现

首先是 Mapper 接口:

// 查询总数
Long countUserByCondition(UserQuery query);

// 分页查询
List<User> selectUserPage(UserQuery query, int offset, int limit);

然后是 XML:

<select id="countUserByCondition" resultType="java.lang.Long">
    SELECT COUNT(*) FROM user
    <where>
        <if test="username != null and username != ''">
            AND username LIKE CONCAT('%', #{username}, '%')
        </if>
        <if test="minAge != null">
            AND age >= #{minAge}
        </if>
        <if test="status != null">
            AND status = #{status}
        </if>
    </where>
</select>

<select id="selectUserPage" resultType="com.example.entity.User">
    SELECT id, username, email, age, status, create_time
    FROM user
    <where>
        <if test="username != null and username != ''">
            AND username LIKE CONCAT('%', #{username}, '%')
        </if>
        <if test="minAge != null">
            AND age >= #{minAge}
        </if>
        <if test="status != null">
            AND status = #{status}
        </if>
    </where>
    ORDER BY create_time DESC
    LIMIT #{offset}, #{limit}
</select>

然后 Service 层还要自己封装分页结果:

public PageResult<User> getUserPage(UserQuery query, int pageNum, int pageSize) {
    // 计算偏移量
    int offset = (pageNum - 1) * pageSize;
    
    // 查询总数
    Long total = userMapper.countUserByCondition(query);
    
    // 查询当前页数据
    List<User> records = userMapper.selectUserPage(query, offset, pageSize);
    
    // 封装分页结果
    return new PageResult<>(total, records);
}

看到了吗?就一个分页,你要写两个查询,还要重复写一遍条件,因为 COUNT 和查询的条件是一样的,很容易写错,而且如果是 Oracle 的话,你还要改 SQL,很麻烦。

MyBatis-Plus 的解决方案

而 MyBatis-Plus 内置了分页插件,你只需要配置一次,然后所有的分页查询都自动搞定了,不用你管 COUNT,不用你管 LIMIT,也不用管不同数据库的语法。

第一步:配置分页插件
@Configuration
public class MyBatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加分页插件,指定数据库类型,MP会自动适配对应的分页语法
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

就这几行配置,搞定了。然后你使用的时候,直接传一个 Page 对象就可以了:

MyBatis-Plus 分页实现
public PageResult<User> getUserPage(UserQuery query, int pageNum, int pageSize) {
    // 构建分页对象,当前页,每页大小
    Page<User> page = new Page<>(pageNum, pageSize);
    
    // 构建条件,和之前的条件构造器一样
    LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
    wrapper.like(StringUtils.hasText(query.getUsername()), 
                User::getUsername, query.getUsername())
           .ge(query.getMinAge() != null, 
               User::getAge, query.getMinAge())
           .eq(query.getStatus() != null, 
               User::getStatus, query.getStatus())
           .orderByDesc(User::getCreateTime);
    
    // 直接调用selectPage,MP会自动处理
    IPage<User> resultPage = userMapper.selectPage(page, wrapper);
    
    // 直接获取结果,MP已经帮你查好了总数,封装好了分页信息
    return new PageResult<>(
        resultPage.getTotal(), // 总记录数,MP自动查的
        resultPage.getRecords() // 当前页数据
    );
}

看到了吗?太简单了!你不用写 COUNT 查询,不用写 LIMIT,MP 会自动帮你做:

  1. 先执行 COUNT 查询,获取总记录数

  2. 然后执行查询 SQL,自动加上对应的分页语句

  3. 自动适配不同数据库的分页语法,你不用管是 MySQL 还是 Oracle

  4. 自动把结果封装到 IPage 对象里,你直接用就好了

而且,不管你的条件有多复杂,MP 都会自动把条件用到 COUNT 查询里,不会出错,也不用你重复写条件。

这比原生 MyBatis 的分页不知道强了多少倍,一行配置,之后所有的分页都不用管了。

四、自动填充:审计字段无需手动处理

我们做项目的时候,几乎每个表都会有几个审计字段:create_time(创建时间)、update_time(更新时间)、create_by(创建人)、update_by(更新人)。

这些字段,每次插入的时候都要设置,每次更新的时候也要设置,原生 MyBatis 的话,你每次都要手动 set,很麻烦,还容易漏。

原生 MyBatis 的痛点

比如,你每次插入用户的时候,都要写:

user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
user.setCreateBy(SecurityUtils.getCurrentUserId());
user.setUpdateBy(SecurityUtils.getCurrentUserId());

每次更新的时候,都要写:

user.setUpdateTime(LocalDateTime.now());
user.setUpdateBy(SecurityUtils.getCurrentUserId());

每个插入更新的地方都要写一遍,重复代码,而且很容易漏,比如某个地方忘了 set,就会导致字段为空,出问题。

MyBatis-Plus 的解决方案

MyBatis-Plus 的自动填充功能,完美解决了这个问题。你只需要配置一次,之后所有的插入更新,这些字段都会自动填充,你完全不用管。

第一步:实体类注解标记填充字段
@Data
@TableName("user")
public class User {
    private Long id;
    private String username;
    private String email;
    private Integer age;
    private Integer status;
    
    // 插入的时候填充
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    
    // 插入和更新的时候都填充
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    
    // 插入的时候填充
    @TableField(fill = FieldFill.INSERT)
    private Long createBy;
    
    // 插入和更新的时候都填充
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateBy;
}
第二步:实现元对象处理器,配置填充规则
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

    @Override
    public void insertFill(MetaObject metaObject) {
        // 插入的时候,自动填充这四个字段
        this.strictInsertFill(metaObject, "createTime", LocalDateTime::now, LocalDateTime.class);
        this.strictInsertFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
        this.strictInsertFill(metaObject, "createBy", 
                              () -> SecurityUtils.getCurrentUserId(), Long.class);
        this.strictInsertFill(metaObject, "updateBy", 
                              () -> SecurityUtils.getCurrentUserId(), Long.class);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        // 更新的时候,自动填充更新时间和更新人
        this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
        this.strictUpdateFill(metaObject, "updateBy", 
                              () -> SecurityUtils.getCurrentUserId(), Long.class);
    }
}

就这两步,配置完了。之后,你不管是调用insert还是update,这些字段都会自动填充,你完全不用手动 set 了。

比如,你插入的时候,只需要设置业务字段:

User user = new User();
user.setUsername("张三");
user.setEmail("zhangsan@example.com");
user.setAge(20);
// 不用管create_time、update_time这些,MP自动帮你set
userMapper.insert(user);

更新的时候也是一样:

user.setUsername("李四");
// 不用管update_time,MP自动帮你set
userMapper.updateById(user);

是不是很方便?再也不用在每个插入更新的地方都写一遍 set 时间的代码了,也不会漏了,配置一次,全局生效。

五、逻辑删除:软删除零侵入实现

现在很多项目都用逻辑删除,也就是不真正把数据从数据库删掉,而是用一个字段标记它已经删除了,比如deleted字段,0 是未删除,1 是已删除。

这样做的好处是,数据可以恢复,也方便做数据统计。但是,原生 MyBatis 的话,这个实现起来很麻烦。

原生 MyBatis 的痛点

用原生 MyBatis 的话,你要做这些事:

  1. 所有的查询,都要手动加AND deleted = 0的条件,不然会把已删除的数据查出来

  2. 所有的删除操作,都要改成更新操作,把 deleted 设为 1,而不是真正的 DELETE

  3. 每个 SQL 都要加,漏一个就出问题

比如,你查询用户的时候,要写:

SELECT * FROM user WHERE id = ? AND deleted = 0

删除的时候,要写:

UPDATE user SET deleted = 1 WHERE id = ?

每个 Mapper 的每个方法都要加,太麻烦了,而且很容易漏,比如某个新写的查询忘了加deleted = 0,就把已删除的数据查出来了,出大问题。

MyBatis-Plus 的解决方案

MyBatis-Plus 的逻辑删除功能,完美解决了这个问题。你只需要配置一下,所有的操作都会自动处理,你的代码完全不用改,零侵入。

第一步:配置全局逻辑删除规则
mybatis-plus:
  global-config:
    db-config:
      # 逻辑删除字段名
      logic-delete-field: deleted
      # 逻辑已删除的值
      logic-delete-value: 1
      # 逻辑未删除的值
      logic-not-delete-value: 0
第二步:实体类添加注解
@Data
@TableName("user")
public class User {
    private Long id;
    private String username;
    // ...其他字段
    
    // 标记这个字段是逻辑删除字段
    @TableLogic
    private Integer deleted;
}

就这两步,配置完了。之后,你所有的操作,MP 都会自动处理:

  1. 查询的时候:MP 会自动给所有的查询 SQL 加上AND deleted = 0的条件,已删除的数据你根本查不到

  2. 删除的时候:你调用deleteById的时候,MP 不会执行 DELETE 语句,而是自动执行 UPDATE,把 deleted 设为 1

  3. 更新的时候:也会自动加AND deleted = 0的条件,不会更新已删除的数据

比如,你调用删除:

userMapper.deleteById(1L);

MP 实际执行的 SQL 是:

UPDATE user SET deleted = 1 WHERE id = ? AND deleted = 0

你调用查询:

User user = userMapper.selectById(1L);

MP 实际执行的 SQL 是:

SELECT * FROM user WHERE id = ? AND deleted = 0

你的代码完全不用改,还是和以前一样调用方法,但是背后 MP 已经帮你处理了逻辑删除的逻辑,你完全不用管,也不会漏。

这比原生 MyBatis 要手动每个 SQL 加条件强太多了,配置一次,全局生效,再也不用担心漏加条件了。

六、乐观锁:并发控制一行配置搞定

在并发场景下,我们经常需要用乐观锁来解决并发更新的问题,比如商品库存,多个用户同时下单,不能超卖。

乐观锁的原理很简单,就是加一个版本号字段,更新的时候,要带上版本号,只有版本号匹配的时候才更新,然后版本号加 1。这样就能保证,如果你更新之前,数据已经被别人改了,你的更新就会失败,不会覆盖别人的修改。

原生 MyBatis 的痛点

原生 MyBatis 的话,你要自己实现这个逻辑:

  1. 查询的时候,把版本号查出来

  2. 更新的时候,WHERE 条件里要加版本号

  3. 然后把版本号加 1

  4. 每个要加乐观锁的方法都要自己写,很麻烦

比如,你更新商品库存,要写:

// 先查询商品,获取版本号
Product product = productMapper.selectById(productId);
// 更新,WHERE条件要加version
int rows = productMapper.updateStock(productId, newStock, product.getVersion());
if (rows == 0) {
    // 更新失败,说明并发修改了,要重试或者抛出异常
    throw new ConcurrentException("并发修改,请重试");
}

然后 SQL 要写:

UPDATE product 
SET stock = ?, version = version + 1 
WHERE id = ? AND version = ?

每个要加乐观锁的方法都要这么写,重复代码,还容易出错。

MyBatis-Plus 的解决方案

MyBatis-Plus 内置了乐观锁插件,你只需要加个注解,配置一下插件,就搞定了,完全不用自己写逻辑。

第一步:实体类添加版本号注解
@Data
@TableName("product")
public class Product {
    private Long id;
    private String name;
    private Integer stock;
    
    // 标记这个字段是乐观锁版本号
    @Version
    private Integer version;
}
第二步:配置乐观锁插件
@Configuration
public class MyBatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加乐观锁插件
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        // 还可以加其他插件,比如分页插件
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

就这两步,配置完了。之后,你更新的时候,直接调用正常的 update 方法就可以了,MP 会自动处理乐观锁的逻辑。

比如:

// 查询商品,version会自动查出来
Product product = productMapper.selectById(productId);
product.setStock(newStock);

// 直接更新,MP自动处理版本号
int rows = productMapper.updateById(product);

if (rows == 0) {
    // 更新失败,并发修改
    throw new ConcurrentException("并发修改,请重试");
}

MP 实际执行的 SQL 是:

UPDATE product 
SET stock = ?, version = version + 1 
WHERE id = ? AND version = ?

完全不用你自己写,MP 自动帮你处理了,是不是很简单?

而且,不管你是用 updateById,还是用 update 带条件,MP 都会自动处理版本号的逻辑,你完全不用管。

七、主键策略:分布式 ID 开箱即用

主键生成也是一个很常见的问题,尤其是分布式系统,我们需要全局唯一的 ID,这时候雪花算法就很常用。

原生 MyBatis 的痛点

原生 MyBatis 的话,你要自己集成雪花算法,自己写工具类,每次插入的时候,手动生成 ID,set 进去,很麻烦。

比如:

user.setId(SnowFlake.nextId());
userMapper.insert(user);

每个插入的地方都要写,而且还要自己维护雪花算法的工具类,处理机器 ID、时间回拨这些问题,很麻烦。

MyBatis-Plus 的解决方案

MyBatis-Plus 内置了多种主键策略,包括雪花算法,你直接用就可以了,不用自己写。

实体类配置主键策略
@Data
@TableName("user")
public class User {
    // 主键策略,ASSIGN_ID就是雪花算法,MP默认就是这个
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;
    
    // 其他字段...
}

就这一个注解,搞定了。之后,你插入的时候,不用设置 ID,MP 会自动用雪花算法生成全局唯一的 ID,然后回填到实体类里。

比如:

User user = new User();
user.setUsername("张三");
// 不用设置ID,MP自动生成
userMapper.insert(user);
// 插入之后,ID已经回填了,你可以直接用
System.out.println(user.getId()); // 雪花算法生成的唯一ID

MP 支持的主键策略非常多:

策略 说明 适用场景
AUTO 数据库自增 单库,支持自增的数据库
ASSIGN_ID 雪花算法(默认) 分布式系统,全局唯一 ID
ASSIGN_UUID UUID 需要字符串 ID 的场景
INPUT 用户手动输入 需要自己设置 ID 的场景

不管你用哪种,都只需要一个注解配置,不用自己写任何代码,开箱即用。

八、代码生成器:一键生成三层代码

我们开发的时候,新建一个表,要写实体类、Mapper 接口、Service 接口、ServiceImpl、Controller,这些代码都是重复的,每个表都要写一遍,很浪费时间。

原生 MyBatis 也有 MyBatis Generator,但是功能很弱,生成的代码很简陋,还要自己改,而且不支持 Lombok,不支持 Service 层,还要自己配置很多东西。

MyBatis-Plus 的解决方案

MyBatis-Plus 的代码生成器,功能非常强大,几行代码,就能一键生成 Entity、Mapper、XML、Service、Controller 全套代码,还支持 Lombok,支持自定义模板,完全满足你的需求。

代码生成器示例
public class CodeGenerator {
    public static void main(String[] args) {
        // 快速生成
        FastAutoGenerator.create("jdbc:mysql://localhost:3306/mybatis_plus?useUnicode=true&characterEncoding=utf8", 
                                "root", "root")
                // 全局配置
                .globalConfig(builder -> {
                    builder.author("YourName") // 作者
                            .outputDir(System.getProperty("user.dir") + "/src/main/java") // 输出路径
                            .fileOverride() // 覆盖已生成文件
                            .disableOpenDir(); // 生成完不打开目录
                })
                // 包配置
                .packageConfig(builder -> {
                    builder.parent("com.example") // 父包名
                            .moduleName("system") // 模块名
                            .entity("entity") // 实体类包名
                            .mapper("mapper") // Mapper包名
                            .service("service") // Service包名
                            .controller("controller") // Controller包名
                            .pathInfo(Collections.singletonMap(OutputFile.mapperXml, 
                                                               System.getProperty("user.dir") + "/src/main/resources/mapper")); // XML路径
                })
                // 策略配置
                .strategyConfig(builder -> {
                    builder.addInclude("user", "product", "order") // 指定要生成的表
                            .addTablePrefix("t_", "sys_") // 表前缀,生成实体类的时候会去掉
                            .entityLombokModel() // 使用Lombok,实体类加@Data注解
                            .entityTableFieldAnnotationEnable(true) // 生成字段注解
                            .controllerMappingHyphenStyle(true); // Controller的路径用驼峰转连字符
                })
                .templateEngine(new FreemarkerTemplateEngine()) // 模板引擎
                .execute(); // 执行生成
    }
}

就这几十行代码,运行一下,你指定的那些表,所有的代码都自动生成好了:

  • 实体类,带 Lombok 的 @Data,带注解

  • Mapper 接口,继承 BaseMapper

  • Mapper XML 文件

  • Service 接口,继承 IService

  • Service 实现类,继承 ServiceImpl

  • Controller 类,基础的 CRUD 接口都有了

你不用再手动写这些重复的代码了,几秒钟就搞定了,开发效率直接拉满。

而且,你还可以自定义模板,生成符合你项目规范的代码,比如你要加 Swagger 注解,要加权限注解,都可以自定义,非常灵活。

九、Service 层封装:批量操作一键完成

除了 Mapper 层,MyBatis-Plus 还对 Service 层做了封装,提供了IServiceServiceImpl,帮你把 Service 层的重复代码也省了。

原生 MyBatis 的痛点

原生 MyBatis 的话,Service 层你要自己写,比如批量插入、批量更新、批量保存这些,你要自己实现,很麻烦。

比如,批量插入,原生的话,你要自己写 SQL,用 foreach 拼接 values,还要处理分批,不然数据太多了 SQL 太长,出问题。

MyBatis-Plus 的解决方案

MyBatis-Plus 的 IService 已经帮你把这些都实现了,你只需要继承就可以了。

Service 接口
public interface UserService extends IService<User> {
    // 自定义方法
}
Service 实现类
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
    // 什么都不用写,IService的方法都有了
}

然后,你就拥有了很多高级的 Service 方法:

// 批量插入,自动分批,默认每批1000条
boolean saveBatch(Collection<T> entityList);

// 批量插入,自定义批次大小
boolean saveBatch(Collection<T> entityList, int batchSize);

// 批量更新
boolean updateBatchById(Collection<T> entityList);

// 批量保存,存在就更新,不存在就插入
boolean saveOrUpdateBatch(Collection<T> entityList);

// 链式查询,更简洁的写法
LambdaQueryChainWrapper<T> lambdaQuery();

// 链式更新
LambdaUpdateChainWrapper<T> lambdaUpdate();

比如,批量插入 10000 条用户数据,你只需要:

List<User> userList = new ArrayList<>();
// 填充数据...
// 批量插入,每批1000条,自动分批
userService.saveBatch(userList, 1000);

就这一行代码,搞定了,不用你自己写 foreach,不用你自己处理分批,MP 都帮你做好了。

还有链式调用,比如:

// 链式查询,不用自己new QueryWrapper了
List<User> users = userService.lambdaQuery()
        .like(User::getUsername, "张")
        .ge(User::getAge, 18)
        .list();

是不是更简洁了?

十、多租户支持:SaaS 系统数据隔离秒级实现

现在很多 SaaS 系统,都需要做多租户数据隔离,也就是每个租户只能看到自己的数据,所有的查询都要加租户 ID 的条件。

原生 MyBatis 的痛点

原生 MyBatis 的话,你要自己处理,每个查询都要加AND tenant_id = ?的条件,每个插入都要设置租户 ID,每个更新删除也要加,太麻烦了,而且很容易漏。

比如,你每个 Mapper 的每个方法,都要加租户 ID 的条件,100 个方法就要加 100 次,漏一个就数据泄露了,出大问题。

MyBatis-Plus 的解决方案

MyBatis-Plus 内置了多租户插件,你只需要配置一下,所有的操作都会自动处理租户 ID,你完全不用管。

配置多租户插件
@Configuration
public class MyBatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 添加多租户插件
        interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
            // 获取当前租户ID,从ThreadLocal或者SecurityContext里拿
            @Override
            public Expression getTenantId() {
                Long tenantId = TenantContext.getTenantId();
                return new LongValue(tenantId);
            }
            
            // 租户字段名
            @Override
            public String getTenantIdColumn() {
                return "tenant_id";
            }
            
            // 哪些表不需要租户过滤,比如系统表
            @Override
            public boolean doTableFilter(String tableName) {
                return "sys_dict".equals(tableName);
            }
        }));
        
        // 注意:多租户插件要放在分页插件前面,不然分页会有问题
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

就这几行配置,搞定了。之后,所有的操作,MP 都会自动处理:

  1. 插入的时候,自动给实体类设置 tenant_id

  2. 查询的时候,自动给 SQL 加AND tenant_id = ?的条件

  3. 更新、删除的时候,也自动加条件

你的代码完全不用改,还是和以前一样调用方法,MP 自动帮你处理租户的逻辑,你完全不用管,也不会漏,再也不用担心数据泄露了。

比如,你查询用户:

List<User> users = userMapper.selectList(null);

MP 实际执行的 SQL 是:

SELECT * FROM user WHERE tenant_id = ?

自动把租户 ID 加上了,你根本不用管,是不是很强大?

十一、安全防护:防止全表更新与删除

我们开发的时候,很容易出现误操作,比如不小心执行了没有 WHERE 条件的 DELETE 或者 UPDATE,把整个表的数据都删了或者改了,那就是生产事故了。

原生 MyBatis 的痛点

原生 MyBatis 没有任何防护,你写了delete from user,它就真的给你执行了,然后整个表的数据就没了,找都找不回来。

MyBatis-Plus 的解决方案

MyBatis-Plus 内置了一个安全防护插件,可以拦截没有 WHERE 条件的全表更新和删除,防止误操作。

配置安全防护插件
@Configuration
public class MyBatisPlusConfig {
    @Bean
    @Profile({"dev", "test", "prod"}) // 所有环境都开启
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 添加防止全表更新与删除的插件
        interceptor.addInnerInterceptor(new IllegalSQLInnerInterceptor());
        
        // 其他插件...
        return interceptor;
    }
}

配置完之后,如果你不小心执行了:

userMapper.delete(null);

这个没有条件的删除,MP 会直接拦截,抛出异常,不会执行这个 SQL,防止你把整个表的数据都删了,完美避免了生产事故。

十二、性能分析:慢查询自动排查

开发的时候,我们经常需要排查慢查询,看看哪个 SQL 执行太慢了,原生的话,你要自己加日志,或者用其他工具,很麻烦。

MyBatis-Plus 的解决方案

MyBatis-Plus 内置了性能分析插件,可以输出每个 SQL 的执行时间,帮你快速排查慢查询。

配置性能分析插件
@Configuration
public class MyBatisPlusConfig {
    @Bean
    @Profile({"dev", "test"}) // 只在开发和测试环境开启
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 添加性能分析插件,超过100ms的SQL会抛出异常
        interceptor.addInnerInterceptor(new PerformanceInnerInterceptor(100));
        
        // 其他插件...
        return interceptor;
    }
}

配置完之后,你执行 SQL 的时候,控制台会输出:

Time: 34 ms - SQL: SELECT id,username,email FROM user WHERE age >= 18

告诉你这个 SQL 执行了多久,而且如果执行时间超过了你设置的阈值,比如 100ms,它会直接抛出异常,提醒你这个 SQL 太慢了,需要优化,帮你快速排查慢查询。

性能对比:MP 真的会影响性能吗?

讲到这里,很多面试官可能会问:MyBatis-Plus 做了这么多增强,会不会影响性能?会不会比原生 MyBatis 慢很多?

这个问题你一定要提前准备好,打消面试官的顾虑。

答案是:几乎没有影响

因为 MyBatis-Plus 的底层还是 MyBatis,它只是做了增强,所有的 SQL 最终还是通过 MyBatis 来执行的,它的性能损耗几乎可以忽略不计。

我们来看一下实际的基准测试数据:

操作类型 原生 MyBatis 耗时 MyBatis-Plus 耗时 差异
主键查询 1.21ms 1.23ms +1.6%
条件查询 2.31ms 2.24ms -3.0%
简单插入 1.15ms 1.18ms +2.6%
批量插入 (1000 条) 3487ms 3124ms -10.4%
分页查询 2.54ms 2.51ms -1.2%

可以看到,不管是哪种操作,MP 的性能和原生 MyBatis 几乎是一样的,有的甚至还更快,因为 MP 的批量操作做了优化。

热身后,两者的性能差距都在 0.1ms 以内,完全可以忽略不计,你根本感觉不到差别。

所以,完全不用担心 MP 会影响性能,它的性能和原生 MyBatis 是一样的,但是开发效率提升了一个量级。

兼容性:完全兼容原生 MyBatis,灵活混用

还有一个很重要的点,你要告诉面试官:MyBatis-Plus 完全兼容原生 MyBatis,你可以混用。

也就是说,你不用把所有的操作都改成 MP 的方式,你可以:

  • 单表的 CRUD,用 MP 的方式,提升开发效率

  • 复杂的多表关联查询,你还是可以用原生 MyBatis 的方式,写自己的 SQL,保证灵活性

比如,你有一个复杂的订单查询,要关联用户、商品、订单详情,这时候你就可以写自己的 SQL,和以前一样:

@Mapper
public interface OrderMapper extends BaseMapper<Order> {
    // 复杂的多表查询,自己写SQL
    List<OrderVO> selectOrderDetail(@Param("orderId") Long orderId);
}
<select id="selectOrderDetail" resultType="com.example.vo.OrderVO">
    SELECT o.id, o.order_no, o.amount,
           u.username, u.email,
           p.name as product_name, p.price
    FROM orders o
    LEFT JOIN user u ON o.user_id = u.id
    LEFT JOIN order_item oi ON o.id = oi.order_id
    LEFT JOIN product p ON oi.product_id = p.id
    WHERE o.id = #{orderId}
</select>

完全没问题,MP 不会影响你原来的 SQL,你可以正常用,这样就兼顾了开发效率和灵活性,单表用 MP,复杂查询用原生,完美。

而且,如果你有老的 MyBatis 项目,你可以无缝引入 MP,不用改任何老代码,直接用,平滑升级,完全没有风险。

架构解析:MP 的核心设计思想

我们来看一下 MyBatis-Plus 的架构图,你就能明白它的设计思想了:
在这里插入图片描述

MP 的核心设计思想就是:无侵入增强

它通过 MyBatis 的插件机制,在不修改 MyBatis 源码的前提下,对 MyBatis 进行增强:

  1. 启动的时候,扫描实体类,解析表结构

  2. 自动注入通用的 CRUD 方法,也就是 BaseMapper 的那些方法

  3. 通过拦截器,实现分页、乐观锁、多租户这些增强功能

  4. 所有的增强都是透明的,不会影响你原来的代码

这就是为什么它能做到只做增强不做改变,完全兼容原生 MyBatis。

面试回答话术:这么说,直接拿下 offer

讲了这么多,到了面试的时候,你怎么把这些点组织起来,有条理地讲给面试官听呢?

你可以这么说:


面试官您好,MyBatis-Plus 是 MyBatis 的增强工具,它的核心设计思想是「只做增强不做改变」,所以它完全兼容原生 MyBatis 的所有功能,不会影响我们原来的代码,也不会有性能损耗。

它比原生 MyBatis 强的地方,主要体现在这几个方面:

第一,通用 CRUD 能力,原生 MyBatis 每个表都要手写重复的 CRUD SQL,而 MP 提供了 BaseMapper,我们只需要继承它,就拥有了 17 个通用的 CRUD 方法,不用写任何 SQL,这能帮我们减少 90% 的重复代码。

第二,条件构造器,原生 MyBatis 做动态查询要写一堆 XML 的动态标签,很繁琐,还容易写错字段。而 MP 的条件构造器,用链式调用就能拼复杂的查询条件,还支持 Lambda 方法引用,编译时就能检查字段错误,开发效率高很多。

第三,内置的各种插件,比如分页插件,原生的要自己写 COUNT 和 LIMIT,MP 配置一次,所有分页自动搞定;还有乐观锁、逻辑删除、自动填充这些功能,原生的要自己实现,MP 都是开箱即用,配置一次全局生效。

第四,代码生成器,原生的 MBG 功能很弱,MP 的代码生成器能一键生成 Entity、Mapper、Service、Controller 全套代码,几秒钟就能搞定一个表的基础代码,开发效率提升非常明显。

第五,高级特性,比如多租户支持,SaaS 系统做数据隔离,原生的要每个 SQL 加租户条件,MP 的插件自动处理,完全不用我们管;还有安全防护,防止误操作删全表,性能分析插件帮我们排查慢查询。

而且,最重要的是,它的性能和原生 MyBatis 几乎一样,基准测试显示两者的性能差距不到 3%,完全可以忽略,而且我们可以混用,单表用 MP 提升效率,复杂多表查询用原生 SQL 保证灵活性,完全兼容。

我们项目里用了之后,开发效率提升了至少 50%,原来写一个表的 CRUD 要半天,现在几分钟就搞定了,而且减少了很多重复代码,出错的概率也低了很多。


你这么一说,面试官绝对会眼前一亮,因为你不是只会说「不用写 SQL」,而是从痛点出发,每个点都讲得很清楚,还有实际的例子,说明你真的理解了这个框架,而不是只会用 API。

最佳实践:如何在项目中用好 MyBatis-Plus

最后,给大家分享一些使用 MP 的最佳实践:

  1. 渐进式引入:老项目可以逐步引入,不用一次性改造,新模块用 MP,老模块保留原来的代码,平滑过渡。

  2. 混用模式:单表 CRUD 用 MP,复杂多表查询用原生 SQL,兼顾效率和灵活。

  3. 统一配置:把逻辑删除、自动填充、分页这些配置统一配置,全局生效,不用每个模块都配。

  4. 优先用 Lambda:条件查询尽量用 LambdaQueryWrapper,不要用字符串的字段名,避免写错。

  5. 批量操作注意分批:大批量数据操作的时候,注意分批,每批 1000 条左右,避免 SQL 太长。

  6. 生产环境关闭性能分析:性能分析插件只在开发测试环境用,生产环境关掉,避免性能损耗。

总结

MyBatis-Plus 不是什么黑科技,它只是在 MyBatis 的基础上,把我们平时重复做的那些事,都帮我们做了,把那些通用的功能,都封装成了开箱即用的插件,让我们不用再重复造轮子,能把更多的精力放在业务逻辑上。

它解决了原生 MyBatis 的那些痛点:重复的 CRUD 代码、繁琐的动态 SQL、各种通用功能要自己实现... 同时又完全保留了 MyBatis 的灵活性和性能,这就是它比原生 MyBatis 强的地方。

下次面试的时候,如果你能把这些点讲清楚,相信我,这个 offer,你已经稳了。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐