dbaplus社群

半夜突发故障!MyBatis-Plus“理论上不可能”的坑,我们踩了……

目录

一、时代背景与ORM选择

二、生产环境挑战

三、雪花算法ID生成重复问题

四、批量插入乱序问题

五、枚举字段存储风险

六、驼峰转换错位

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

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

九、总结与思考

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

一、时代背景与ORM选择

1、互联网时代的数据挑战

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

2、MyBatis-Plus的诞生与定位

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

MyBatis-Plus的核心价值:

  • 简化开发:CRUD都有封装,少写很多SQL,手更轻功能

  • 增强:逻辑删除、自动填充、乐观锁、多租户、代码生成器等都现成

  • 性能优化:内置分页、性能分析、批量优化、缓存插件,跑得更快

  • 无缝集成:兼容MyBatis,老项目也能慢慢替换,几乎不用重构

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

二、生产环境挑战

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

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

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

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

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

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

1)雪花算法ID生成重复问题

2)批量插入乱序问题

3)枚举字段存储风险

4)驼峰转换错位

5)批量操作时自动填充失效

6)写入时JSON数据丢失或格式异常

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

三、雪花算法ID生成重复问题

1、问题现象
// 看起来很正常的代码@TableId(value = "id", type = IdType.ASSIGN_ID)private Long id;// 但在生产环境中却出现了这样的错误:// Duplicate entry '1234567890123456789' for key 'PRIMARY'

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

2、雪花算法身份证结构

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

身份证号码:1234567890123456789分解结构:┌─────────┬─────────┬─────────┬─────────┐│  时间戳  │ 数据中心 │ 机器编号 │ 序列号  ││ (41位)  │ (5位)   │ (5位)   │ (12位)  │└─────────┴─────────┴─────────┴─────────┘
  • 时间戳像出生年月日

  • 数据中心ID类似省份

  • 机器ID对应城市

  • 序列号就是同一毫秒的排队号

3、问题根源

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

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

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

MyBatis-Plus获取机器ID的策略:

yBatis-Plus获取机器ID的策略

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

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

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”的做法,就会出现下面的情况:

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)元凶三:Kubernetes环境的虚拟MAC

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

①每个Pod都有虚拟网络接口

②MAC地址由CNI插件分配

③同一节点的Pod可能模式一致

④某些环境下干脆拿不到MAC

yBatis-Plus获取机器MAC地址

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

4)元凶四:时钟回拨

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

雪花ID生成流程如下所示:

雪花ID生成流程

但是,如何出现时钟回拨,就会出现如下问题

Image

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

相同的机器ID (workerId=1)相同的数据中心ID (datacenterId=1)  相同的时间戳 (timestamp=1634567890123)相同的序列号 (sequence=0)
 4、解决方案:给每台机器一个专属身份证

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

@Slf4j@Componentpublicclass 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


package com.baomidou.mybatisplus.annotation;
publicenum 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);    privatefinalint key;}
    /**     * 主键ID     */    @TableId(value = "id", type = IdType.AUTO)    private Long id;

四、批量插入乱序问题

1、问题现象

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

// 期望:先插入父订单,再插入子订单heroPackageService.saveBatch(packages);  // 父订单heroOrderService.saveBatch(orders);      // 子订单
// 结果:外键约束失败!// Cannot add or update a child row: a foreign key constraint fails

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

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

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

2、原理

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

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

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

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

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

①接收数据列表

  • Listpackages (1000条数据)

  • 默认批次大小:1000

②创建批处理SqlSession

  • 使用 ExecutorType.BATCH

  • 逐条执行INSERT

③逐条加入批处理队列

  • 第1条:INSERT INTO hero_package VALUES (...)

  • 第2条:INSERT INTO hero_package VALUES (...)

...

  • 第1000条:INSERT INTO hero_package VALUES (...)

④达到批次大小后 flushStatements()

特点:

  • 支持主键回填

  • 单线程下顺序可控

  • 多线程+驱动优化后容易乱序

  • 网络往返次数多,性能一般

模式二:优化批处理模式(合并SQL)

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

执行流程:

  • 接收列表

  • foreach拼成一条多值INSERT

  • 一次性落库

特点:

  • 单次往返,性能高

  • 顺序完全可控

  • 主键需要自己准备

  • SQL过长时得自行分批

两种模式对比:

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

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

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

JDBC批处理模式:

模式1:rewriteBatchedStatements=false

  • 逐条执行

  • 顺序稳定

  • 性能一般

模式2:rewriteBatchedStatements=true

  • 自动合并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 (...), (...)

顺序彻底变了。

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

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

期望:Package1 → Order1 → Package2 → Order2
实际:Package1, Package2 → Order1, Order2
优化器心声:既然都是INSERT,就分组吧省得来回切换表    

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

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

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

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

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

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

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

@Transactionalpublic 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();    }}

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

// 使用自定义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);

五、枚举字段存储风险

1、问题现象:枚举字段存储异常
// 枚举定义publicenum OrderStatus {    PENDING(1, "待处理"),    PROCESSING(2, "处理中"),    COMPLETED(3, "已完成");    privatefinal Integer code;    privatefinal String desc;}
// 实体类publicclass HeroOrder {    private OrderStatus orderStatus; // 期望存储code值(1,2,3)}
// 结果:数据库中存储的是枚举名称("PENDING", "PROCESSING", "COMPLETED")
2、深入原理:MyBatis的默认枚举处理
// 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    }}
3、解决方案:使用@EnumValue注解
public enum OrderStatus {    PENDING(1, "待处理"),    PROCESSING(2, "处理中"),    COMPLETED(3, "已完成");    @EnumValue // 标记这个字段用于数据库存储    private final Integer code;    private final String desc;    // 构造函数和getter...}

六、驼峰转换错位

1、问题现象
// 数据库字段:seller_uid, buyer_uid, create_time// 实体类字段:public class HeroPackage {    private Long sellerUid;    // ✅ 正常映射    private Long buyerUID;     // ❌ 映射失败!    private Date createTime;   // ✅ 正常映射    private String XMLData;    // ❌ 映射失败!}
2、原理

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

// 驼峰转换算法(简化版)publicclass 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)
3、解决方案:明确字段映射

1)方案一:使用@TableField注解

public class HeroPackage {    private Long sellerUid;    @TableField("buyer_uid")  // 明确指定数据库字段名    private Long buyerUID;    private Date createTime;    @TableField("xml_data")    private String XMLData;}

2)方案二:统一命名规范

// 推荐的命名规范public class HeroPackage {    private Long sellerUid;     // 驼峰命名,避免连续大写    private Long buyerUid;      // 而不是buyerUID    private Date createTime;    private String xmlData;     // 而不是XMLData}

3)方案三:自定义命名策略

@Configurationpublicclass MybatisPlusConfig {    @Bean    public MybatisPlusInterceptor mybatisPlusInterceptor() {        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();        // 自定义命名策略        GlobalConfig globalConfig = new GlobalConfig();        globalConfig.setDbConfig(new GlobalConfig.DbConfig() {{            // 自定义字段命名策略            setColumnFormat("`%s`"); // 字段名加反引号,避免关键字冲突            setTableFormat("`%s`");  // 表名加反引号        }});        return interceptor;    }}

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

1、问题现象
// 实体类配置publicclass HeroOrder {    @TableField(fill = FieldFill.INSERT)    private Date createTime;    @TableField(fill = FieldFill.INSERT_UPDATE)      private Date updateTime;    @TableField(fill = FieldFill.INSERT)    private Long createBy;}
// 自动填充处理器@Componentpublicclass 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!
2、原理
// MyBatis-Plus的自动填充机制publicclass 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();}
3、解决方案:手动填充 + 批量优化

1)方案一:批量操作前手动填充

@Servicepublicclass 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);    }}

2)方案二:自定义批量保存方法

@Componentpublicclass BatchSaveHelper {    public <T> boolean saveBatchWithAutoFill(Collection<T> entityList,                                            Class<T> entityClass,                                           BaseMapper<T> mapper) {        if (entityList.isEmpty()) {            returntrue;        }        // 获取当前用户和时间        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); // 自定义批量插入方法        }        returntrue;    }    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);        }    }}

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

1、问题现象
// 实体类定义publicclass 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!
2、原理
// 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!
3、解决方案:数据类型规范化处理

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

1)方案一:数据库字段类型规范化

  • 表里尽量只放基本类型

  • Map、List先拆成具体字段,或者转成独立表

  • 复杂场景在业务层处理转换

2)方案二:手动序列化处理

  • 确实需要存结构化数据时,先序列化成JSON字符串

  • 存入VARCHAR或TEXT

  • 查询时再反序列化

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

九、总结与思考

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

启示一:默认配置靠不住

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

启示二:机制理解少不了

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

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

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

作者介绍

朱洪旭,侠客汇Java开发工程师。

来源丨公众号:转转技术(ID:zhuanzhuantech)
dbaplus社群欢迎广大技术人员投稿,投稿邮箱:[email protected]
Image
Image