MyBatis-Plus踩坑血泪史:那些年我们踩过的坑!

前言:我们在生产环境里跑MyBatis-Plus很多年,几乎把能踩的坑都踩过了。每次出事故,都能追溯到我们当初以为"框架会帮忙搞定"的某个细节。于是我们把这些真实经历写下来,用更接地气的方式聊聊根因和解法,帮你少熬几次夜。

1、时代背景与ORM选择

1.1 互联网时代的数据挑战

业务量暴涨,数据也跟着狂飙。百万级数据在几年前还算大,如今动不动就上亿。与此同时,微服务拆分得越来越细,数据访问层变得又厚又复杂。大家一边要追新需求,一边又要兼顾性能稳定。没有趁手的开发工具,根本顶不住节奏。

1.2 MyBatis-Plus的诞生与定位

于是,Java团队开始在ORM世界里不断找平衡。Hibernate太重,原生MyBatis又太多手工活。MyBatis-Plus正是在这种"又想快又想稳"的诉求下出现的。它不是简单的"语法糖",而是一个为了生产环境而生的增强框架

MyBatis-Plus的核心价值

  • 简化开发:CRUD都有封装,少写很多SQL,手更轻
  • 功能增强:逻辑删除、自动填充、乐观锁、多租户、代码生成器等都现成
  • 性能优化:内置分页、性能分析、批量优化、缓存插件,跑得更快
  • 无缝集成:兼容MyBatis,老项目也能慢慢替换,几乎不用重构

这些卖点让MyBatis-Plus迅速走红。不过,功能多并不等于好用。特别是到了生产环境,隐藏的坑一个接一个。


2、生产环境挑战

开发环境里一切都很顺。saveBatch()轻松搞定批量插入,@TableId(type = IdType.ASSIGN_ID)一写就有分布式ID,看起来稳得很。

然而,真正部署上线后问题才暴露出来。

凌晨3点的告警:主键冲突,订单系统停摆。我们发现两个容器实例生成了同一个雪花ID。理论上"不可能发生"的事,就这么发生了。

再往下排查,批量操作乱序、枚举字段写入异常、自动填充失灵......问题一个比一个离谱。

这些事故有个共同点:开发环境一切正常,生产环境却崩成一片。根本原因是单机和分布式、低并发和高并发之间的差异,被框架默认配置无限放大,结果就是"看着没事"的代码在生产里频频掉链子。

我们因此整理出MyBatis-Plus在生产环境最容易暴雷的6大陷阱:

  1. 雪花算法ID生成重复问题
  2. 批量插入乱序问题
  3. 枚举字段存储风险
  4. 驼峰转换错位
  5. 批量操作时自动填充失效
  6. 写入时JSON数据丢失或格式异常

接下来我们把这些问题逐一拆开,聊聊背后的逻辑,再给出实测可行的解法。


雪花算法ID生成重复问题

3.1 问题现象

java 复制代码
// 看起来很正常的代码
@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;

// 但在生产环境中却出现了这样的错误:
// Duplicate entry '1234567890123456789' for key 'PRIMARY'

"ID怎么还能撞?" 这是我们第一次遇到时的真实反应。

3.2 雪花算法身份证结构

先搞清楚雪花ID里面装了什么,再谈如何防重复。可以把它想成一个超长身份证号

scss 复制代码
身份证号码:1234567890123456789
分解结构:
┌─────────┬─────────┬─────────┬─────────┐
│  时间戳  │ 数据中心 │ 机器编号 │ 序列号  │
│ (41位)  │ (5位)   │ (5位)   │ (12位)  │
└─────────┴─────────┴─────────┴─────────┘
  • 时间戳像出生年月日
  • 数据中心ID类似省份
  • 机器ID对应城市
  • 序列号就是同一毫秒的排队号

3.3 问题根源

我们把雪花ID重复的元凶总结成四类。

3.3.1 元凶一:未配置机器ID,所有实例走默认值

很多人以为框架会自动算出唯一的workerId,于是干脆不配。结果大家都走同一个默认流程。

MyBatis-Plus获取机器ID的策略

看似严谨,然而一旦进入第二步,容器环境下"撞车"概率直线上升。

3.3.2 元凶二:容器环境的MAC地址克隆

先看Docker容器MAC地址的规律:

ruby 复制代码
Docker容器MAC地址格式:02:42:ac:11:00:XX
                     ├─────────────┤├─┤
                     固定前缀      变化部分

容器1:02:42:ac:11:00:02
容器2:02:42:ac:11:00:03  
容器3:02:42:ac:11:00:04
...

如果继续依赖"取后两字节再模32"的做法,就会出现下面的情况:

ini 复制代码
MAC地址计算流程:
1. 提取后2字节 → 00:02, 00:03, 00:04...
2. 位运算处理 → 得到不同的中间值
3. 模32取余 → 问题就出在这里

实际结果:
├─ 容器1-32:机器ID = 0-31 ✅
├─ 容器33:机器ID = 0 ❌
├─ 容器34:机器ID = 1 ❌
└─ 以此类推...

如此可见,只要实例超过32个,就一定有重复。

3.3.3 元凶三:Kubernetes环境的虚拟MAC

再看K8s。Pod里的网络接口本来就是虚拟的,MAC地址由CNI随机分配,还可能拿不到。 Kubernetes Pod网络特点:

  1. 每个Pod都有虚拟网络接口
  2. MAC地址由CNI插件分配
  3. 同一节点的Pod可能模式一致
  4. 某些环境下干脆拿不到MAC

这种情况下,100个Pod全用workerId=1,冲突直接拉满。

3.3.4 元凶四:时钟回拨

雪花算法非常依赖系统时间。一旦时钟被调回,就会闹出大问题。

雪花ID生成流程如下所示: 但是,如何出现时钟回拨,就会出现如下问题

当以下条件同时满足时,重复就无可避免:

ini 复制代码
相同的机器ID (workerId=1)
相同的数据中心ID (datacenterId=1)  
相同的时间戳 (timestamp=1634567890123)
相同的序列号 (sequence=0)

3.4 解决方案:给每台机器一个专属身份证

方案一:外部获取唯一的分布式ID

java 复制代码
@Slf4j
@Component
public class IdHelper {

    public static   long getIdc() {
        try {
            return ZZIdcClient.getInstance().get();
        } catch (Throwable e) {
            log.error("act=getIdc error={}", e.getMessage());
        }
        // 兜底逻辑:为了不影响业务,如果从架构拿不到id,就采用IDHelper
        // IDHelper有问题,并发情况下可能会生成重复id
        log.info("k=s act=getIdcByIDHelper");
        return IDHelper.generator(0);
    }
}

方案二:使用数据库自增ID

java 复制代码
package com.baomidou.mybatisplus.annotation;

public enum IdType {
    AUTO(0),
    NONE(1),
    INPUT(2),
    ASSIGN_ID(3),
    ASSIGN_UUID(4),
    /** @deprecated */
    @Deprecated
    ID_WORKER(3),
    /** @deprecated */
    @Deprecated
    ID_WORKER_STR(3),
    /** @deprecated */
    @Deprecated
    UUID(4);
    private final int key;
}

    /**
     * 主键ID
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

4、批量插入乱序问题

4.1 问题现象

场景1:父子订单批量插入。当子订单准备入库时,父订单还没完成,外键直接挂掉。

java 复制代码
// 期望:先插入父订单,再插入子订单
heroPackageService.saveBatch(packages);  // 父订单
heroOrderService.saveBatch(orders);      // 子订单

// 结果:外键约束失败!
// Cannot add or update a child row: a foreign key constraint fails

场景2:远程接口返回一个有序列表,我们存到数据库里,想在后续查询中保持原有顺序,结果还是乱了。

java 复制代码
// 期望:远程获取数据之后,存在数据库,后续从数据库获取有序列表的快照
//远程获取外部有序集合
List<HeroPackage> heroPackageList = recycleRpcService.getHeroPackageList(uid)
//将远程获取数据存入数据库
heroPackageService.saveBatch(heroPackageList)
//从数据库获取有序集合数据
List<HeroPackage> heroPackageList = heroPackageService.list(uid);


// 结果:有序集合的顺序被打乱
// 

明明是按顺序调用的,为什么会乱序?

4.2 原理

可以把它想成快递分拣。你按顺序交包裹,分拣中心为了效率会重排路线,于是最终顺序就变了。

4.2.1 MyBatis-Plus的两种批量插入模式

官方其实有两套批量逻辑,差别非常大。

模式一:默认BATCH模式(逐条插入)

MyBatis-Plus默认saveBatch的执行流程:

  1. 接收数据列表

    1. List packages (1000条数据)
    2. 默认批次大小:1000
  2. 创建批处理SqlSession

    1. 使用 ExecutorType.BATCH
    2. 逐条执行INSERT
  3. 逐条加入批处理队列

    1. 第1条:INSERT INTO hero_package VALUES (...)
    2. 第2条:INSERT INTO hero_package VALUES (...)
    3. ...
    4. 第1000条:INSERT INTO hero_package VALUES (...)
  4. 达到批次大小后 flushStatements()

特点:

  1. 支持主键回填
  2. 单线程下顺序可控
  3. 多线程+驱动优化后容易乱序
  4. 网络往返次数多,性能一般
模式二:优化批处理模式(合并SQL)

配置方式:要么改全局配置,要么写自定义SQL

执行流程:

  1. 接收列表
  2. foreach拼成一条多值INSERT
  3. 一次性落库

特点:

  1. 单次往返,性能高
  2. 顺序完全可控
  3. 主键需要自己准备
  4. SQL过长时得自行分批

两种模式对比

特性 默认BATCH模式 优化批处理模式
性能 多次往返,速度一般 单次往返,极快
顺序保证 单线程可控,多线程不稳 完全可控
主键回填 支持 不支持
适用场景 依赖数据库生成主键 自己能生成主键或无主键需求

4.2.2 根本原因二:JDBC驱动的批处理重排序

MySQL JDBC驱动为了提速,会把同表操作合并。原本交错的SQL被重新分组,顺序自然就乱了。

JDBC批处理模式:

模式1:rewriteBatchedStatements=false

  1. 逐条执行
  2. 顺序稳定
  3. 性能一般

模式2:rewriteBatchedStatements=true

  1. 自动合并SQL
  2. 性能飙升
  3. 顺序可能被"优化"
sql 复制代码
例子:
原始SQL:
INSERT INTO hero_package VALUES (..)
INSERT INTO hero_order VALUES (..)
INSERT INTO hero_package VALUES (..)
INSERT INTO hero_order VALUES (..)

驱动优化后:
INSERT INTO hero_package VALUES (...), (...)
INSERT INTO hero_order VALUES (...), (...)

顺序彻底变了。

4.2.3 根本原因三:数据库层面的执行优化

MySQL优化器也会"帮忙"重排执行计划。同一张表的操作被集中,避免频繁切换表,性能是好了,但依赖顺序的逻辑直接崩。

sql 复制代码
期望:
Package1 → Order1 → Package2 → Order2

实际:
Package1, Package2 → Order1, Order2

优化器心声:
既然都是INSERT,就分组吧
省得来回切换表         

问题在于,Order引用Package的主键。如果Package还没落库,Order插入必失败。

例如下面的真实案例。两个线程并发创建订单。批处理时队列不保证FIFO,于是订单项和订单的关系全乱套。

期望: 订单A → 订单项A → 订单B → 订单项B

现实: 线程A、线程B同时写入 批处理队列交错 驱动/数据库重排 → 可能先执行线程B的订单项,再执行线程A的 → 外键和业务逻辑全乱

4.3 解决方案:控制批处理节奏

我们常用两个思路:要么"分批+强制刷新",要么"自定义SQL一次写完"。

方案一:分批次执行,强制刷新

java 复制代码
@Transactional
public void createOrdersInSequence(List<OrderData> orderDataList) {
    int batchSize = 100;
    
    for (int i = 0; i < orderDataList.size(); i += batchSize) {
        List<OrderData> batch = orderDataList.subList(i, 
            Math.min(i + batchSize, orderDataList.size()));
        
        // 先插入父订单
        List<HeroPackage> packages = buildPackages(batch);
        heroPackageService.saveBatch(packages);
        sqlSession.flushStatements(); // 强制执行,确保顺序
        
        // 再插入子订单
        List<HeroOrder> orders = buildOrders(batch);
        heroOrderService.saveBatch(orders);
        sqlSession.flushStatements();
    }
}

方案二:自定义SQL,一次性插入

java 复制代码
// 使用自定义SQL,完全控制执行顺序
@Insert({
    "<script>",
    "INSERT INTO hero_package (id, seller_uid, create_time) VALUES ",
    "<foreach collection='packages' item='pkg' separator=','>",
    "(#{pkg.id}, #{pkg.sellerUid}, #{pkg.createTime})",
    "</foreach>",
    "</script>"
})
void insertPackageBatch(@Param("packages") List<HeroPackage> packages);

5、枚举字段存储风险

5.1 问题现象:枚举字段存储异常

java 复制代码
// 枚举定义
public enum OrderStatus {
    PENDING(1, "待处理"),
    PROCESSING(2, "处理中"),
    COMPLETED(3, "已完成");
    
    private final Integer code;
    private final String desc;
}

// 实体类
public class HeroOrder {
    private OrderStatus orderStatus; // 期望存储code值(1,2,3)
}

// 结果:数据库中存储的是枚举名称("PENDING", "PROCESSING", "COMPLETED")

5.2 深入原理:MyBatis的默认枚举处理

java 复制代码
// MyBatis默认的枚举处理器
public class EnumTypeHandler<E extends Enum<E>> extends BaseTypeHandler<E> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, E parameter, JdbcType jdbcType) {
        // 默认使用枚举的name()方法!
        ps.setString(i, parameter.name()); // 存储"PENDING"而不是1
    }
}

5.3 解决方案:使用@EnumValue注解

java 复制代码
public enum OrderStatus {
    PENDING(1, "待处理"),
    PROCESSING(2, "处理中"),
    COMPLETED(3, "已完成");
    
    @EnumValue // 标记这个字段用于数据库存储
    private final Integer code;
    private final String desc;
    
    // 构造函数和getter...
}

6、驼峰转换错位

6.1 问题现象

java 复制代码
// 数据库字段:seller_uid, buyer_uid, create_time
// 实体类字段:
public class HeroPackage {
    private Long sellerUid;    // ✅ 正常映射
    private Long buyerUID;     // ❌ 映射失败!
    private Date createTime;   // ✅ 正常映射
    private String XMLData;    // ❌ 映射失败!
}

6.2 原理

MyBatis-Plus的驼峰转换算法对连续大写很无奈。

java 复制代码
// 驼峰转换算法(简化版)
public class CamelCaseConverter {
    public String camelToUnderscore(String camelCase) {
        StringBuilder result = new StringBuilder();
        
        for (int i = 0; i < camelCase.length(); i++) {
            char c = camelCase.charAt(i);
            if (Character.isUpperCase(c)) {
                if (i > 0) result.append('_');
                result.append(Character.toLowerCase(c));
            } else {
                result.append(c);
            }
        }
        return result.toString();
    }
}

// 转换结果:
// sellerUid -> seller_uid ✅
// buyerUID -> buyer_u_i_d ❌ (期望: buyer_uid)
// XMLData -> x_m_l_data ❌ (期望: xml_data)

6.3 解决方案:明确字段映射

方案一:使用@TableField注解
java 复制代码
public class HeroPackage {
    private Long sellerUid;
    
    @TableField("buyer_uid")  // 明确指定数据库字段名
    private Long buyerUID;
    
    private Date createTime;
    
    @TableField("xml_data")
    private String XMLData;
}
方案二:统一命名规范
java 复制代码
// 推荐的命名规范
public class HeroPackage {
    private Long sellerUid;     // 驼峰命名,避免连续大写
    private Long buyerUid;      // 而不是buyerUID
    private Date createTime;
    private String xmlData;     // 而不是XMLData
}
方案三:自定义命名策略
java 复制代码
@Configuration
public class MybatisPlusConfig {
    
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 自定义命名策略
        GlobalConfig globalConfig = new GlobalConfig();
        globalConfig.setDbConfig(new GlobalConfig.DbConfig() {{
            // 自定义字段命名策略
            setColumnFormat("`%s`"); // 字段名加反引号,避免关键字冲突
            setTableFormat("`%s`");  // 表名加反引号
        }});
        
        return interceptor;
    }
}

7、批量操作时自动填充失效

7.1 问题现象

java 复制代码
// 实体类配置
public class HeroOrder {
    @TableField(fill = FieldFill.INSERT)
    private Date createTime;
    
    @TableField(fill = FieldFill.INSERT_UPDATE)  
    private Date updateTime;
    
    @TableField(fill = FieldFill.INSERT)
    private Long createBy;
}

// 自动填充处理器
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "createTime", Date.class, new Date());
        this.strictInsertFill(metaObject, "createBy", Long.class, getCurrentUserId());
    }
    
    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date());
    }
}

// 问题现象:批量操作时自动填充失效!
List<HeroOrder> orders = buildOrders();
heroOrderService.saveBatch(orders); // createTime和createBy为null!

7.2 原理

java 复制代码
// MyBatis-Plus的自动填充机制
public class DefaultSqlSession {
    
    public int insert(String statement, Object parameter) {
        // 只有通过MyBatis-Plus的insert方法才会触发自动填充
        if (parameter instanceof BaseEntity) {
            metaObjectHandler.insertFill(parameter); // 触发填充
        }
        return executor.update(statement, parameter);
    }
    
    public int update(String statement, Object parameter) {
        if (parameter instanceof BaseEntity) {
            metaObjectHandler.updateFill(parameter); // 触发填充
        }
        return executor.update(statement, parameter);
    }
}

// 但是批量操作使用的是JDBC批处理
public void saveBatch(Collection<T> entityList) {
    // 直接执行批量SQL,跳过了自动填充逻辑!
    String sql = "INSERT INTO table VALUES (?,?,?)";
    PreparedStatement ps = connection.prepareStatement(sql);
    for (T entity : entityList) {
        // 没有调用metaObjectHandler.insertFill()
        ps.setObject(1, entity.getId());
        ps.setObject(2, entity.getName());
        ps.addBatch();
    }
    ps.executeBatch();
}

7.3 解决方案:手动填充 + 批量优化

方案一:批量操作前手动填充
java 复制代码
@Service
public class EnhancedOrderService {
    
    @Autowired
    private MyMetaObjectHandler metaObjectHandler;
    
    public boolean saveBatchWithFill(Collection<HeroOrder> entityList) {
        // 手动触发自动填充
        entityList.forEach(entity -> {
            MetaObject metaObject = SystemMetaObject.forObject(entity);
            metaObjectHandler.insertFill(metaObject);
        });
        
        // 然后执行批量保存
        return heroOrderService.saveBatch(entityList);
    }
}
方案二:自定义批量保存方法
java 复制代码
@Component
public class BatchSaveHelper {
    
    public <T> boolean saveBatchWithAutoFill(Collection<T> entityList, 
                                           Class<T> entityClass,
                                           BaseMapper<T> mapper) {
        if (entityList.isEmpty()) {
            return true;
        }
        
        // 获取当前用户和时间
        Long currentUserId = getCurrentUserId();
        Date now = new Date();
        
        // 反射设置自动填充字段
        entityList.forEach(entity -> {
            try {
                setFieldValue(entity, "createTime", now);
                setFieldValue(entity, "updateTime", now);
                setFieldValue(entity, "createBy", currentUserId);
            } catch (Exception e) {
                log.warn("自动填充失败", e);
            }
        });
        
        // 分批保存,避免SQL过长
        int batchSize = 1000;
        List<T> list = new ArrayList<>(entityList);
        
        for (int i = 0; i < list.size(); i += batchSize) {
            List<T> batch = list.subList(i, Math.min(i + batchSize, list.size()));
            mapper.insertBatch(batch); // 自定义批量插入方法
        }
        
        return true;
    }
    
    private void setFieldValue(Object obj, String fieldName, Object value) 
            throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        if (field.get(obj) == null) { // 只有为null时才设置
            field.set(obj, value);
        }
    }
}

8、写入时JSON数据丢失或格式异常

8.1 问题现象

java 复制代码
// 实体类定义
public class HeroPackage {
    private Long id;
    private String packageName;
    
    // JSON字段存储扩展属性
    private Map<String, Object> extendInfo;
    private List<String> tags;
}

// 保存数据
HeroPackage pkg = new HeroPackage();
pkg.setExtendInfo(Map.of("color", "红色", "weight", 1.5));
pkg.setTags(Arrays.asList("电子产品", "二手", "9成新"));
heroPackageService.save(pkg);

// 查询结果:extendInfo和tags字段为null!

8.2 原理

java 复制代码
// MyBatis默认只能处理基本类型
public class DefaultTypeHandlerRegistry {
    public DefaultTypeHandlerRegistry() {
        register(String.class, new StringTypeHandler());
        register(Integer.class, new IntegerTypeHandler());
        register(Long.class, new LongTypeHandler());
        // 没有Map和List的处理器!
    }
}

// 复杂对象会被toString()处理
Map<String, Object> map = Map.of("key", "value");
String result = map.toString(); // "{key=value}" - 不是标准JSON!

8.3 解决方案:数据类型规范化处理

我们的共识是:能不用复杂字段就别用。如果必须用,就自己管序列化。

方案一:数据库字段类型规范化
  • 表里尽量只放基本类型
  • Map、List先拆成具体字段,或者转成独立表
  • 复杂场景在业务层处理转换
方案二:手动序列化处理
  • 确实需要存结构化数据时,先序列化成JSON字符串
  • 存入VARCHAR或TEXT
  • 查询时再反序列化

实施建议: 优先走方案一。只有在业务强依赖动态结构时,再考虑方案二,记得封装好序列化逻辑,别把JSON工具散落在业务代码里。


总结与思考

种种案例都在提醒我们:框架默认值是给理想环境准备的,到了生产就得重新审视。如果不了解内部机制,就很容易掉进坑里。

启示一:默认配置靠不住

开发环境通常是单机、低并发,默认配置自然没问题。可一上生产,容器化、分布式、高并发齐聚,默认值瞬间变成隐患。别指望框架"自动"处理所有边角料

启示二:机制理解少不了

只有搞清楚框架怎么运转,才能提前发现风险;真出事时,也能快速定位。知其然而且要知其所以然

启示三:方案要服务于环境

你在什么环境里跑,就得配什么策略。容器、K8s、云原生都在挑战旧有假设。没有银弹,只有适配

关于作者,朱洪旭,侠客汇Java开发工程师。
转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。 关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~

相关推荐
和和和1 小时前
🗣️面试官: 那些常见的前端面试场景问题
前端·javascript·面试
sg_knight2 小时前
IntelliJ IDEA 实用插件:GitToolBox 使用指南
java·ide·git·intellij-idea·插件·gittoolbox
青云交2 小时前
Java 大视界 -- Java 大数据机器学习模型在电商用户画像构建与精准营销中的应用
java·大数据·机器学习·电商·协同过滤·用户画像·精准营销
Heo2 小时前
简单聊聊webpack摇树的原理
前端·javascript·面试
库森学长2 小时前
多线程有序执行,九大方案!
后端·面试
z***67772 小时前
Spring EL 表达式的简单介绍和使用
java·后端·spring
机灵猫2 小时前
java锁:从 Mark Word 锁升级到 AQS
java·开发语言
_张一凡2 小时前
【AIGC面试面经第六期】AI视频-训练与微调技相关问答
人工智能·面试·aigc
AAA阿giao3 小时前
大厂面试之反转字符串:深入解析与实战演练
前端·javascript·数据结构·面试·职场和发展·编程技巧