前言:我们在生产环境里跑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大陷阱:
- 雪花算法ID生成重复问题
- 批量插入乱序问题
- 枚举字段存储风险
- 驼峰转换错位
- 批量操作时自动填充失效
- 写入时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网络特点:
- 每个Pod都有虚拟网络接口
- MAC地址由CNI插件分配
- 同一节点的Pod可能模式一致
- 某些环境下干脆拿不到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的执行流程:
-
接收数据列表
- List packages (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模式 | 优化批处理模式 |
|---|---|---|
| 性能 | 多次往返,速度一般 | 单次往返,极快 |
| 顺序保证 | 单线程可控,多线程不稳 | 完全可控 |
| 主键回填 | 支持 | 不支持 |
| 适用场景 | 依赖数据库生成主键 | 自己能生成主键或无主键需求 |
4.2.2 根本原因二:JDBC驱动的批处理重排序
MySQL JDBC驱动为了提速,会把同表操作合并。原本交错的SQL被重新分组,顺序自然就乱了。
JDBC批处理模式:
模式1:rewriteBatchedStatements=false
- 逐条执行
- 顺序稳定
- 性能一般
模式2:rewriteBatchedStatements=true
- 自动合并SQL
- 性能飙升
- 顺序可能被"优化"
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),更多干货实践,欢迎交流分享~