
删数据这件事看着简单翻车的次数却一点不比写复杂SQL少。尤其在我给项目升级到SpringBoot3之后用MyBatis Plus下称MP做删除操作时踩过的坑让我决定单独开一节把物理删除讲透。很多人以为删除就是调用deleteById完事实际上一套正经业务下来你要搞清楚的包括MP的删除API底层是怎么拼SQL的、批量删除的边界在哪、带TableLogic的实体为什么删不掉、多表关联数据怎么保证一致性。这篇文章就围绕SpringBoot3 MyBatis Plus 3.5.x这套组合把我实际项目里的经验和踩坑记录全部摊开讲代码可以直接复制改改就能用。1. 切入删除这个操作为什么值得单独写一节1.1 物理删除和逻辑删除先分清你写的是哪种MP里对删除的操作最迷惑人的一点就是API名字都叫delete但底层执行的可能不是DELETE语句。物理删除执行的是DELETE FROM 表 WHERE ...数据从表里彻底消失不可恢复。逻辑删除执行的是UPDATE 表 SET deleted 1 WHERE ...数据还在表里只是通过一个标记字段把它藏起来。MP设计了一套自动逻辑删除的能力只要实体类里有一个字段加上了TableLogic注解那么所有MP提供的deleteById、delete(Wrapper)等方法都会被拦截自动改成UPDATE操作物理删除就变成了逻辑删除。所以在你调用任何删除方法之前第一件事是确认这张表到底该物理删除还是逻辑删除。我的判断标准很简单需要审计追溯、又不想真的丢数据的业务表比如订单、用户、合同用逻辑删除。临时数据、中间关联表、日志表、用户上传的临时文件记录这些表用物理删除不然日积月累会拖垮查询性能。数据量大、对写入和查询性能敏感的表优先物理删除。没有TableLogic字段的实体MP的删除API默认执行的就是物理删除这也是这一节的主线。1.2 SpringBoot3 项目接入MP时的版本选择SpringBoot3和SpringBoot2在依赖上有个很大的不同SpringBoot3基于JDK17以上很多旧版MP包直接无法启动。MP官方为此单独发布了一个启动器dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.7/version /dependency注意不是mybatis-plus-boot-starter那个是给SpringBoot2用的。3.5.7这个版本是我在项目中实测比较稳的如果你的项目还用到mybatis-plus-jsqlparser比如多租户或者分页插件的某些功能也会用到这个依赖dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-jsqlparser/artifactId version3.5.7/version /dependencySpringBoot3下还有一个容易踩的坑如果用MapperScan扫描Mapper接口要注意org.mybatis.spring.annotation.MapperScan这个包是引入MP启动器后自动带过来的不需要额外引mybatis-spring-boot-starter否则会出现Mapper扫描不到或者启动冲突的问题。2. 建表与实体物理删除模式下表结构该怎么设计2.1 表结构别给物理删除的表留 deleted 字段打算物理删除的表设计上就不要加deleted这类逻辑删除标记字段。加了就会有人为了保险在实体里配上TableLogic结果整个表的行为又变成了逻辑删除物理删除就名存实亡了。我这里用一套最简的表结构演示一个用户表加一个订单表CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, username VARCHAR(50) NOT NULL COMMENT 用户名, age INT COMMENT 年龄, email VARCHAR(100) COMMENT 邮箱, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) COMMENT 用户表; CREATE TABLE sys_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, user_id BIGINT NOT NULL COMMENT 用户ID, order_no VARCHAR(64) NOT NULL COMMENT 订单号, amount DECIMAL(10,2) DEFAULT 0 COMMENT 订单金额, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) COMMENT 订单表;两张表都没有deleted字段。但凡你希望删除的时候是真的删掉表结构就保持干净别掺杂逻辑删除的字段。但有一种常见现象是一个项目里一部分表逻辑删除一部分表物理删除。这时候设计上要格外克制逻辑删除字段只放在需要逻辑删除的表里不要让所有表统一加上deleted字段。统一加字段会带来一个麻烦你无法用MP默认API做物理删除每次都要自定义SQL绕过TableLogic项目里到处都是零散的Delete注解SQL反而不好维护。2.2 实体类映射TableName 与 TableId 的注意点对应的实体类这样写Data TableName(sys_user) public class User { TableId(type IdType.AUTO) private Long id; private String username; private Integer age; private String email; private LocalDateTime createTime; }Data TableName(sys_order) public class Order { TableId(type IdType.AUTO) private Long id; private Long userId; private String orderNo; private BigDecimal amount; private LocalDateTime createTime; }有一个细节值得注意只要实体里没有TableLogic字段MP就认为此表不做逻辑删除所有删除操作都会生成DELETE FROM sys_user WHERE ...这样的物理删除SQL。有人会问如果表结构里字段名是user_id这种下划线风格实体里用的是userIdMP能自动映射吗能。MP默认开启了下划线转驼峰映射user_id会自动映射到userId。如果哪天你发现查出来的字段为null优先检查是不是手动关闭了这个映射配置文件里加一行mybatis-plus: configuration: map-underscore-to-camel-case: true2.3 从依赖到启动最小可跑通的配置yml配置记得把日志打出来方便后面观察SQLspring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto启动类上加上MapperScan直接把UserMapper和OrderMapper扫进去SpringBootApplication MapperScan(com.example.demo.mapper) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }Mapper接口就是标准的MP写法public interface UserMapper extends BaseMapperUser { }到这里最小的SpringBoot3 MP环境就跑起来了。后面所有删除API都可以直接用UserMapper来验证。3. deleteById 和 deleteBatchIds基础删除API的正确姿势3.1 deleteById单条删除主键类型别匹配错MP的BaseMapper里最基础的删除方法就是它int deleteById(Serializable id);用起来很简单UserMapper userMapper; int rows userMapper.deleteById(1001L); System.out.println(影响行数 rows);这个方法生成的SQL是DELETE FROM sys_user WHERE id?这里有个容易忽视的细节id参数类型是Serializable也就是说你传Integer、Long、String都可以。但是参数类型必须和表主键的真实类型对应。如果你的主键是BIGINT传了一个Integer类型的1001MP在底层绑定参数时通常不会报错MySQL也会做隐式转换。但如果你主键是VARCHAR你非要传数字那就有可能出现查询条件匹配不到数据的情况。更稳妥的做法实体类TableId声明什么类型调用就传什么类型。deleteById的返回值是int表示实际影响的记录数。这就有意思了如果id对应的数据不存在deleteById并不会抛异常而是返回0。很多新手在删除的时候不看返回值导致删除失败也没发现。我建议凡是删除操作都判断一下返回的rows是不是符合预期int rows userMapper.deleteById(1001L); if (rows 0) { throw new BusinessException(该用户不存在或已被删除); }3.2 deleteBatchIds批量删除与ID列表的边界批量删除API在老版本里叫deleteBatchIdsMP 3.5.7版本开始推荐用deleteByIds两个方法功能一样int deleteByIds(Collection? extends Serializable idList);调用方式ListLong ids Arrays.asList(1001L, 1002L, 1003L); int rows userMapper.deleteByIds(ids);生成的SQL是DELETE FROM sys_user WHERE id IN (?, ?, ?)批量删除看起来很爽但当你一次性删除几千上万条ID时会碰到两个实际问题第一SQL长度限制。MySQL的max_allowed_packet默认可能是4MB或者更大但数据量大到一定程度一条IN语句会非常长导致报错。第二主键查询的性能问题。当IN列表里的值非常多时MySQL内部处理也会变慢并且可能放弃走索引。我的实操经验是一次性批量删除的ID数量控制在200~500个之间超过就分批循环删除public void batchDeleteSafe(CollectionLong ids) { ListLong idList new ArrayList(ids); int batchSize 500; for (int i 0; i idList.size(); i batchSize) { int end Math.min(i batchSize, idList.size()); ListLong subList idList.subList(i, end); int rows userMapper.deleteByIds(subList); log.info(删除批次[{}~{}]影响行数{}, i, end, rows); } }3.3 返回值 int 到底该不该信delete系列方法返回的int是MyBatis执行完SQL后由JDBC驱动返回的受影响行数。正常情况下这个值是可信的。但有一个容易误解点如果数据库是MySQLDELETE删除0行返回值就是0删除5行返回值就是5。有些业务里表里根本查不到这条数据也属于正常情况那你在业务代码里到底按成功处理还是按失败处理要想清楚。我的习惯是分场景幂等删除场景删不删都行返回值不重要只要不抛SQL异常就算成功。严格业务场景必须有这条数据返回值是0就直接抛错。这两个场景在写代码前区分清楚后面排查问题会省很多事。4. deleteByMap 和 delete(Wrapper)条件删除的灵活与风险4.1 deleteByMap等值删除的便利与map key的坑deleteByMap允许你传入一个Map作为等值匹配条件MapString, Object columnMap new HashMap(); columnMap.put(username, 张三); columnMap.put(age, 18); int rows userMapper.deleteByMap(columnMap);生成的SQLDELETE FROM sys_user WHERE username ? AND age ?这个方法最大的坑是map的key必须是数据库列名不是实体属性名。如果你写成put(userName, 张三)生成的条件就是WHERE userName ?SQL执行时大概率报字段不存在的SQL异常。deleteByMap还有一个隐患map里所有条件都是AND拼接无法表达范围条件、模糊条件等复杂查询。所以这个方法适用的场景非常窄我一般只在测试代码里用它生产业务代码里很少直接依赖。4.2 delete(Wrapper)条件构造器的标准姿势真正干活的是delete(WrapperT queryWrapper)它允许你用MP的条件构造器来拼接删除条件QueryWrapperUser wrapper new QueryWrapper(); wrapper.lt(age, 18); int rows userMapper.delete(wrapper);生成的SQLDELETE FROM sys_user WHERE (age ?)这个方法的灵活性比deleteByMap高得多但一个致命问题也随之而来——QueryWrapper里填的是数据库字段名的字符串wrapper.eq(username, 张三);如果哪天数据库字段改名了这段代码不会在编译期报错而是运行时才会暴露。这种字符串硬编码字段名的方式我是非常不建议在生产代码里用的。4.3 LambdaQueryWrapper 如何避开硬编码列名换成LambdaQueryWrapper直接用方法引用引用实体属性LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.lt(User::getAge, 18) .and(w - w.eq(User::getUsername, 张三).or().eq(User::getEmail, zhangsanexample.com)); int rows userMapper.delete(wrapper);这段代码表示删除年龄小于18并且用户名为张三或邮箱为zhangsanexample.com的用户。生成的SQL是DELETE FROM sys_user WHERE (age ? AND (username ? OR email ?))好处很明显编译期类型检查User::getAge如果不存在编译直接报错。重构友好实体字段改名时IDE能联动改。可读性强哪怕过三个月再看这段代码你也能看懂删除条件是什么。条件删除里有个很关键的安全注意点eq等方法的第二个参数值如果为nullMP默认依然会生成column null这样的条件。这在SQL里是查不到任何数据的正确写法应该是isNull(User::getAge)。很多删除失效的问题其实就出在这里。你可以通过MP的Condition重载来避免这个坑String username getUsernameFromRequest(); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(StringUtils.hasText(username), User::getUsername, username); int rows userMapper.delete(wrapper);第一个参数condition为false时整个条件就不会拼进SQL里。5. 多表关联下的物理删除事务、外键与自定义SQL5.1 有业务关联的表怎么删事务先行单表删除很简单但业务上一旦涉及父子表比如删除用户同时删除这个用户的所有订单问题就来了。你可能会想先删订单再删用户两步操作不就完了public void deleteUserWithOrders(Long userId) { LambdaQueryWrapperOrder orderWrapper new LambdaQueryWrapper(); orderWrapper.eq(Order::getUserId, userId); orderMapper.delete(orderWrapper); userMapper.deleteById(userId); }问题在于两步删除之间如果第二步抛异常订单删了用户没删数据就悬空了。所以必须在方法上加上事务Transactional(rollbackFor Exception.class) public void deleteUserWithOrders(Long userId) { // 先删子表 LambdaQueryWrapperOrder orderWrapper new LambdaQueryWrapper(); orderWrapper.eq(Order::getUserId, userId); orderMapper.delete(orderWrapper); // 再删主表 userMapper.deleteById(userId); }rollbackFor Exception.class一定要写。Spring默认只回滚RuntimeException和Error如果你抛的是自定义的检查异常不加这个参数事务是不会回滚的。5.2 自定义Mapper方法实现多表删除MP的BaseMapper都是单表操作。如果一张表数据量特别大你要在一条SQL里同时删除多张表的数据那就得自定义Mapper方法。先说一个设计建议业务表尽量别在数据库层面建物理外键因为外键会影响写入性能还会让删除操作的顺序很死板。数据一致性通过代码事务来控制就够了。但确实存在需要多表删除的场景。比如清空用户和用户订单MySQL里支持一条DELETE同时删多张表DELETE u, o FROM sys_user u LEFT JOIN sys_order o ON u.id o.user_id WHERE u.id ?这种SQL没法用MP的BaseMapper直接生成需要自己在Mapper接口里写public interface UserMapper extends BaseMapperUser { Delete(DELETE u, o FROM sys_user u LEFT JOIN sys_order o ON u.id o.user_id WHERE u.id #{userId}) int deleteUserAndOrders(Param(userId) Long userId); }实测中要特别注意一个点多表DELETE语句里u和o这两个别名必须是表本身定义的别名不能随便写而且如果你的项目里配了MP的多租户插件这种自定义SQL里的表名不会自动拼接租户条件需要你在SQL里手动拼否则会酿成越权删除的严重事故。这个坑我在后面第6节展开说。5.3 大批量删除的性能与分片策略生产环境里最多遇到的问题不是要不要删而是怎么删得完。我之前做过一次数据清理一张订单流水表积累了几千万条历史数据需要按create_time清理掉一年前的数据。一开始直接写LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); wrapper.lt(Order::getCreateTime, oneYearAgo); orderMapper.delete(wrapper);结果就是一条DELETE语句要匹配上百万行数据MySQL执行的时候锁了大量行主从复制也出现了延迟。因为InnoDB在删除大量数据时每一步都会记录undo日志和binlog量越大锁持有的时间就越长。正确的姿势是分批删除 小事务public void cleanExpiredOrders() { LocalDateTime deadline LocalDateTime.now().minusYears(1); while (true) { int rows orderMapper.deleteByDeadline(deadline, 500); if (rows 500) { break; } // 小睡一下给主库喘口气 Thread.sleep(100); } }对应的自定义Mapper方法每次只取少量ID删除Delete(DELETE FROM sys_order WHERE create_time #{deadline} LIMIT #{limit}) int deleteByDeadline(Param(deadline) LocalDateTime deadline, Param(limit) int limit);注意MySQL的DELETE语句里加LIMIT是允许的它配合小批次删除能有效减少锁范围。每次删500条删除完提交事务Spring的Transactional方法里如果整段循环都在一个事务里反而达不到小事务的目的所以这个循环要注意事务边界。6. 实测踩坑物理删除接入过程中的隐蔽问题6.1 TableLogic 让 deleteById 变成 UPDATE这大概是MP物理删除里最经典的误会实体上带了TableLogic的字段你调deleteById满心期待数据从表里消失结果表里的数据还在只是deleted字段从0变成了1。Data TableName(sys_user) public class User { TableId(type IdType.AUTO) private Long id; private String username; TableLogic private Integer deleted; }此时执行userMapper.deleteById(1001L);实际SQL是UPDATE sys_user SET deleted1 WHERE id? AND deleted0数据还在逻辑上删除了但这根本不是物理删除。如果你确实要对这个表做物理删除最干净的办法是去掉TableLogic注解如果表结构不能动就需要在Mapper里自定义SQLDelete(DELETE FROM sys_user WHERE id #{id}) int physicallyDeleteById(Param(id) Long id);我建议在一个项目里明确约定凡是实体内出现TableLogic全部走逻辑删除不允许在Mapper里自定义物理删除SQL去绕过它。因为混用会导致数据状态不可预期万一逻辑删除了的数据又被物理删除了一把就真的找不回来了。6.2 多租户插件给 DELETE 自动加了条件如果你的项目用了MP的多租户插件TenantLineInnerInterceptor所有SQL执行时都会被强制拼接tenant_id条件。对查询是好事对删除就要格外小心。举个例子你自定义了一个物理删除SQLDelete(DELETE FROM sys_user WHERE username #{username}) int deleteByUsername(Param(username) String username);多租户插件拦截到这条DELETE会自动帮你加上租户条件DELETE FROM sys_user WHERE username ? AND tenant_id ?这个行为在单租户系统里无感但在多租户系统里如果你在自定义SQL中已经把表名写成了别名或者使用了JOIN插件解析不到表名时可能会直接放行那删除范围就会扩大到所有租户这是严重的数据事故。我的经验是使用MP的多租户插件后所有自定义SQL都打印出来检查一遍确认租户条件被正确拼接。如果SQL里出现别名、子查询尽量用MP的InterceptorIgnore(tenantLine true)明确跳过插件并在SQL里手动写好租户条件。6.3 deleteByMap 列名与实体属性的错位前面提过deleteByMap的key必须是数据库列名。但还有一种更隐蔽的错位表里字段是user_name实体属性是userNamemap里你按习惯写了userName结果SQL拼出来是WHERE user_name ?实际上查不到任何数据删除返回0。要注意MP对deleteByMap不做驼峰转下划线的映射处理传入什么keySQL里就拼什么列名。所以使用deleteByMap前我建议先看一眼数据库真实列名再构造map。大多数情况下我推荐直接跨过deleteByMap改用LambdaQueryWrapper做等值删除LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getUserName, username); userMapper.delete(wrapper);这个写法没有列名错位的烦恼类型也是安全的。6.4 逻辑删除与物理删除混用项目的维护经验一个中大型项目通常不会只有一张表。用户表可能要逻辑删除操作日志表要物理删除用户和角色的中间关联表也必须物理删除。混用不可怕可怕的是没有约定。我在一个项目里踩过一个很痛的坑中间关联表本来应该物理删除结果有个同事为了统一实体风格给所有实体都加了个deleted字段并配上TableLogic。几个月后我们清理中间表数据时发现这张表删了上百万条关联关系实际数据却只增不减因为每次删除都只是把deleted标记为1而查询关联关系时MP又自动过滤了deleted1的数据导致表膨胀严重性能越来越差。所以我强烈建议在团队里定两条规则只有确实需要保留历史痕迹的业务表才使用TableLogic其他表一律不加。新表接入时先看表结构有没有逻辑删除字段再决定删除API的写法。另外对于那些明确要做物理删除的表可以额外增加一个定期清理任务比如按月清理三个月前的临时数据避免表的物理膨胀。最后分享一个我个人的操作习惯每次写完删除相关代码我都会开一台本地库把MP的SQL日志打开盯着delete语句确认一下——它到底执行的是DELETE还是UPDATE。这一个动作已经帮我拦住好几次以为删掉实际没删的事故。删除操作在业务里经常是一锤子买卖下手前多确认一下执行计划比事后修数据要省心得多。如果你也在做SpringBoot3项目里的删除功能建议把这几个方法族先跑一遍把预期SQL和实际SQL对照清楚。