
直接说结论在所有Mybatis入门操作里删除和修改看着比查询简单实际上翻车率最高。查询写错了最多报错改错删错直接污染线上数据。这篇就专门拆删除和修改这两块从最简单的一条记录删起到动态SQL批量更新最后列几个我实测踩过的坑。内容适合刚学完Mybatis查询操作、准备写增删改的读者也适合写了一阵子但没深究过细节的人。1. 先看返回值再看执行SQLdelete操作的两个起步细节1.1 delete标签的返回值到底怎么用很多初学者写删除关注点全在能不能删掉忽略了一个关键信息——delete语句执行后返回的int值。这个值是Mybatis根据数据库受影响行数affected rows封装出来的不是布尔值也不是删除记录的ID。它的意义在于帮你判断到底有没有数据真的被删掉了。来看一段最基础的Mapper代码Mapper public interface UserMapper { int deleteById(Integer id); }对应的XMLdelete iddeleteById DELETE FROM user WHERE id #{id} /deleteController或Service里可以这样消费返回值public Result deleteUser(Integer id) { int rows userMapper.deleteById(id); if (rows 0) { return Result.error(该用户不存在或已被删除); } return Result.success(); }这里有一个值得注意的点当WHERE条件命中的记录数大于0但执行删除时数据已经因为某种原因不存在比如并发下先被其他请求删了MySQL返回的受影响行数是0Mybatis传给Java层的返回值自然也是0。所以返回0不一定代表SQL写错很多时候代表业务状态和预期不一致。把它当作操作是否生效的信号比当作SQL是否执行成功的信号更合适。1.2 单个参数和多个参数参数传递的两种姿势如果删除条件只有一个ID那#{id}里写什么都行Mybatis不关心名字只关心位置。但一旦删除条件变成两个以上问题就来了。比如要根据用户ID和状态删除记录Java接口写成int deleteByUserIdAndStatus(Integer userId, Integer status);XML如果写成delete iddeleteByUserIdAndStatus DELETE FROM user WHERE user_id #{userId} AND status #{status} /delete启动项目后大概率会直接报错Parameter userId not found. Available parameters are [arg1, arg0, param1, param2]。原因是Java 8编译后参数名默认不会保留到字节码里除非加了-parameters编译参数Mybatis在运行时拿不到真实参数名。解决方案有两种。第一种是加Param注解int deleteByUserIdAndStatus(Param(userId) Integer userId, Param(status) Integer status);第二种是直接用param1、param2这种隐式命名delete iddeleteByUserIdAndStatus DELETE FROM user WHERE user_id #{param1} AND status #{param2} /delete我建议一律用Param原因有两个一是param1这种命名在条件一多就分不清谁是谁一旦SQL变长人眼排查极其痛苦二是后续如果要在同一个方法上叠加Param(xxx)做其他扩展比如分页插件传递参数隐式命名的代码会变得非常别扭。1.3 parameterType能省则省但它不是摆设看很多老项目的XMLdelete标签上还写着parameterTypejava.lang.Integer。这个属性在Mybatis 3.x之后几乎可以完全省略因为Mybatis可以通过ParameterHandler基于方法的参数类型自主推断。但有一种情况parameterType会提醒你不要乱写——当参数是自定义对象时int deleteByCondition(UserQuery query);delete iddeleteByCondition parameterTypecom.example.pojo.UserQuery DELETE FROM user WHERE status #{status} AND create_time lt; #{endTime} /delete写了parameterTypeMybatis会明确按这个类型去反射处理属性不写它也会通过实际参数推断。所以我的经验是简单场景不用写复杂场景推荐写因为它本身也是给后续维护的人看的参数类型文档。2. 修改操作的基本盘update语句从能跑到跑好2.1 最简单的全字段更新以及它隐藏的坑一条最朴素的更新SQL长这样update idupdateById UPDATE user SET name #{name}, email #{email}, phone #{phone}, status #{status} WHERE id #{id} /update功能上完全没问题根据主键找到记录把它四个字段全部更新一遍。但实际项目中这种写法几乎不会直接用于生产。原因很简单全字段更新意味着只要调用这个方法所有字段都会被覆盖哪怕某个字段你压根没想动。举一个我实际维修过的案例运营后台的编辑用户表单前端只提交了name和phone两个字段后端直接用全字段更新的Mapper结果email和status全被更新成null了。接口调用方根本不知道这个SQL会把没传的字段置空。这属于典型的SQL层面没问题业务层面出大问题。所以全字段更新适合什么场景适合那些整行覆盖就是业务预期的表比如某些配置表、字典表每次变更本身就是全量的。遇到用户表、订单表这种字段多且有区分度的表一律要往动态SQL方向走。2.2 动态set带来的第一个质变Mybatis的动态SQL里有个set标签专门解决只更新非空字段的问题update idupdateBySelective UPDATE user set if testname ! null and name ! name #{name}, /if if testemail ! null email #{email}, /if if testphone ! null phone #{phone}, /if /set WHERE id #{id} /update核心逻辑是每个被if包裹的赋值语句只有字段值不为null或不为空字符串时才会拼进SQL。set会自动去掉最后一个赋值语句后面的逗号避免SQL语法错误。使用这一套后调用方传什么就更新什么不传的保持原样之前的null覆盖问题就规避了。这是我从能不能跑跨越到会不会出错的第一个分水岭。2.3 代表不改的语义怎么处理if判断null和空字符串能挡掉大部分误更新但有一种情况它挡不住前端明确想把某个字段清空。比如用户要解绑手机号接口收到phone 或phone null这时到底更新还是不更新这个问题的本质是空值语义的定义。我的做法是在项目里统一约定更新接口接收的DTO里null代表不修改此字段接收的字符串如果为且字段本身可空则代表清空此字段如果字段本身不可空如name则在Service层直接校验并抛出异常。具体实现可以给phone单独加一个更新判断if testphone ! null phone #{phone}, /if这样phone传也会进入更新达到清空效果而email不传时则保持原值。核心思路是同一个XML里不同字段的更新策略可以不同关键看业务容忍度。3. set标签和trim标签动态修改背后的设计思路3.1 set标签怎么处理逗号问题先看一个很多新手都迷惑的场景为什么动态SQL里每个赋值语句后面都要写逗号如果所有if条件都不满足SQL会变成什么样实际执行时set内部如果没有任何一个if成立Mybatis生成的SQL会是UPDATE user WHERE id ?这是语法错误会直接抛异常。所以在Service层必须提前做好判断如果所有可更新字段都为空要么直接返回没有可更新的内容要么抛一个业务异常提示调用方。依赖SQL层面去兜底不太现实。逗号问题则是另一类经典错误。假如这样写set if testname ! null name #{name} /if if testemail ! null email #{email} /if /set如果两个条件都成立生成的SQL是UPDATE user SET name ?, email ? WHERE id ?少了一个逗号MySQL直接报语法错误。所以正确写法是每个if内部末尾带逗号交给set去清理最后一个多余逗号。初学阶段最容易犯的错误就是怕逗号多余就少写结果条件多的时候SQL直接残缺。3.2 trim标签set之外的另一种完全控制方案set的优点是简洁缺点是自动化太强遇到特殊需求时你可能需要更细粒度的控制。这时可以用trim替代。update idupdateByTrim UPDATE user trim prefixSET suffixOverrides, if testname ! null name #{name}, /if if testemail ! null email #{email}, /if /trim WHERE id #{id} /update这里prefixSET表示标签拼装完成后在前面加一个SET关键字suffixOverrides,表示去掉拼装结果末尾的逗号。效果与set一模一样。那什么时候选trim我遇到过一种场景更新时需要加version version 1这个固定语句同时还要动态拼其他字段。用set当然也没问题但如果你还想在更新语句末尾追加一个条件化的LIMIT之类trim的灵活性就体现出来了。另外trim还能做prefixOverrides去掉开头多余的关键字这是set做不到的。3.3 动态更新的最佳实践从Mapper到Service的分工很多项目把业务判断全堆在XML里导致XML膨胀严重维护起来非常痛苦。我的习惯是这样分工XML只管字段是否为空、是否拼接Service层负责这个字段在业务上允不允许为空调用是否合法Controller层只负责参数接收和简单校验。比如一个更新用户状态的场景update idupdateStatus UPDATE user SET status #{status} WHERE id #{id} AND status ! #{status} /update加上AND status ! #{status}的好处是如果更新前后的状态一样受影响行数为0Service层可以直接返回状态未变化省掉一次无意义的写操作。这种业务逻辑下沉到SQL的写法在状态机流转、订单状态变更等场景非常实用。4. 批量删除和批量更新foreach的正确打开方式4.1 批量删除三种常见写法对比批量删除最直观的写法是循环单删for (Integer id : idList) { userMapper.deleteById(id); }这种写法在数据量少几条时没毛病但数据量上百后每删一条都要走一次网络IO和SQL解析性能断崖式下降。第二种写法是拼IN条件delete iddeleteByIds DELETE FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /deleteJava接口int deleteByIds(Param(ids) ListInteger ids);这条SQL最终会生成DELETE FROM user WHERE id IN (?, ?, ?, ?)一次网络IO一个SQL语句性能远优于循环删除。第三种写法是用OR连接delete iddeleteByIds DELETE FROM user WHERE id #{ids[0]} OR id #{ids[1]} /delete这种写法可读性差占位符数量还得手动控制foreach配合IN是绝对的主流不用犹豫。第二个注意点foreach里的collection属性值必须和Param里的名字一致。上面我写的是Param(ids)所以collection就写ids。如果你没加ParamMybatis默认会把List包装成list这个keycollection得写list。这个细节踩坑率极高报错信息一般长这样There is no getter for property named ids in java.util.ArrayList。4.2 批量更新两种方案选型批量更新比批量删除复杂不少。最常见的是CASE WHEN方案update idbatchUpdateStatus UPDATE user SET status foreach collectionlist itemitem separator openCASE id closeEND WHEN #{item.id} THEN #{item.status} /foreach WHERE id IN foreach collectionlist itemitem open( separator, close) #{item.id} /foreach /update生成SQL的样子UPDATE user SET status CASE id WHEN 1 THEN ACTIVE WHEN 2 THEN INACTIVE END WHERE id IN (1, 2)这种方式的本质是把多条不同值的更新合并成一条SQL减少网络交互。适合更新同一个字段、且不同记录值不同的场景。但要注意MySQL默认的max_allowed_packet有大小限制如果一次更新的记录数过多比如几千条生成的SQL很长可能触发Packet too large异常。我的经验是单次批量更新控制在500条以内比较稳。另一种更稳妥的方案是走foreach拼多条updateupdate idbatchUpdateByForeach foreach collectionlist itemitem separator; UPDATE user SET status #{item.status} WHERE id #{item.id} /foreach /update生成的是UPDATE user SET status ? WHERE id ?; UPDATE user SET status ? WHERE id ?;这种写法在MySQL上需要连接参数加上allowMultiQueriestrue才允许执行否则会报语法错。即使能跑多条语句并不是原子性的中途失败后会出现部分更新成功、部分失败的情况。所以如果业务对要么全成要么全败有强要求应该改用事务控制或者直接走CASE WHEN方案事务。4.3 批量更新里容易被忽略的rewriteBatchedStatements参数如果你的项目用MyBatis-Plus的saveBatch或者自己写循环单条update最终执行的是一条条独立的SQL。这时JDBC连接串里有一个参数会严重影响性能rewriteBatchedStatements。先看默认行为rewriteBatchedStatementsfalse时JDBC驱动会把批量提交的每条SQL逐条发给数据库本质上还是多次网络往返只是客户端做了攒着的假象。设置rewriteBatchedStatementstrue后驱动会尝试把多条同构SQL重写成一条多VALUES的插入或批量update网络交互次数大幅降低。JDBC连接串这样加jdbc:mysql://localhost:3306/test?rewriteBatchedStatementstrue我做过一个直接的对比测试向一张表批量更新1000条数据默认参数下耗时约800ms开启后耗时约120ms差距非常明显。但要注意这个参数对单条普通update没有优化效果它只在Batch API场景下生效。如果你的批量更新是用CASE WHEN合并SQL无需关心这个参数。4.4 批量操作的事务边界批量删除或批量更新执行前必须明确事务边界。最稳妥的方式是在Service层加TransactionalTransactional(rollbackFor Exception.class) public void batchUpdateStatus(ListUser users) { userMapper.batchUpdateStatus(users); }Transactional可以保证方法内所有SQL要么全部提交要么全部回滚。不加注解时Mybatis默认对每次Mapper调用自动提交一旦中间某条失败已执行的部分没法回滚数据会处于半更新状态排查时非常难受。关于事务还有一个容易忽视的点如果批量操作里混着查询查询结果可能是事务开启前的旧数据。MySQL默认隔离级别是REPEATABLE READ同一事务内多次查询返回的是一致性快照。所以如果你想先查再更比如查出所有statusNORMAL的记录再批量改成DISABLED中间如果有其他事务修改了数据你的更新条件可能和查询结果不一致。要规避的话要么用SELECT ... FOR UPDATE加锁要么在更新条件里显式带上状态条件让数据库来判断。5. 把坑留在上线前删除与修改的高频雷区清单5.1 误删全表的第一道防线所有删除操作里最要命的是条件没拼上导致全表被删。比如delete iddeleteByCondition DELETE FROM user where if testuserId ! null AND user_id #{userId} /if /where /delete如果调用这个Mapper时userId传了nullwhere标签内没有任何条件成立最终SQL就变成DELETE FROM user没有任何WHERE限制直接把整张表清空。这是生产事故级别的坑。防线有三层Service层校验条件为null直接抛参数异常禁止执行数据库账号权限为应用创建独立账号限制DELETE不能无条件执行SQL审查删除SQL必须有明显的主键或唯一键条件并在代码评审阶段重点检查。我在团队里定过一条规则所有delete语句的WHERE条件里至少包含主键或者有唯一索引的字段之一其他字段只做辅助条件。这个规则能防住90%的误删事故。5.2 修改操作里的null覆盖事故链路更新时把没传的字段置空是全字段更新最大的坑前面已经提过。但还有一种更隐蔽的变体字段本身传了null而且你用了动态SQL但判断条件写错了。例如if teststatus ! null and status ! status #{status}, /ifstatus是Integer类型如果调用方传的是0status ! 这个判断是成立的没问题。但如果传的是null判断直接短路不会更新。这部分是对的。但反过来if teststatus ! null status #{status}, /if这也没问题。问题出在很多人用字符串去比较数字if teststatus ! OGNL表达式里单引号是字符双引号是字符串写混了容易在比较时出现类型转换异常或者逻辑完全反了。一旦status传了数值0status ! 这种比较在OGNL里的表现可能和你预期完全不同。所以我的建议是整数字段只用! null判断不要在XML里去判断空字符串字符串字段才组合判断null和。这能避免一大类看起来逻辑没问题跑起来全是问题的坑。5.3 版本号字段与并发更新的处理修改操作里最容易被追问的一个点是多线程同时改同一条记录怎么防止丢失更新最常用的方案是乐观锁。表结构里加一个version字段ALTER TABLE user ADD COLUMN version INT DEFAULT 0;更新SQL变成update idupdateByIdWithVersion UPDATE user SET name #{name}, version version 1 WHERE id #{id} AND version #{version} /update这个写法的核心逻辑是更新时比对版本号如果版本号没变说明期间没人改过可以更新同时把版本号加1如果版本号变了说明有其他人抢先更新过本事务放弃更新。Java层拿返回值判断int rows userMapper.updateByIdWithVersion(user); if (rows 0) { throw new BusinessException(数据已被他人修改请刷新后重试); }返回值rows0代表更新没有生效多半是版本号冲突。这是删改操作里最常用的并发保护手段之一比先查后更的不可靠方案强得多。如果用MyBatis-Plus实体类上直接加个Version注解框架会自动帮你处理版本逻辑不用手写XML。如果你用的是原生Mybatis上面这段XML模板直接复制改字段名就能用。5.4 打印SQL排查删改问题最快的入口删改类SQL的问题靠肉眼很难直接看出来最快的定位方式是打印最终执行的SQL和参数。Spring Boot项目在application.yml里配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl或者用application.propertiesmybatis.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl配置后控制台会打印每条Mapper执行的具体SQL和参数绑定信息 Preparing: UPDATE user SET name ?, email ? WHERE id ? AND version ? Parameters: 张三(String), 123test.com(String), 1(Integer), 0(Integer) Updates: 1看Preparing阶段的SQL能确认动态SQL拼得对不对看Parameters能确认参数有没有传成null看Updates行数能确认影响行数是否符合预期。排查为什么没更新时三步连起来看基本就能定位。我这里有一个习惯正式环境不要把log-impl设为StdOutImpl因为日志量太大有性能风险调试环境才开。生产环境建议用更精准的log4j2或slf4j配置只打印慢SQL或特定Mapper的SQL。5.5 数据库兼容性别把MySQL写法带进其他数据库我做过一个项目开发环境全是MySQL后来客户要求部署到某国产数据库上结果删改SQL大面积报错。典型问题有DELETE语句中带别名DELETE u FROM user u WHERE u.id ?在某些数据库中不支持批量更新用CASE WHEN不是所有数据库都支持更新时依赖LIMIT子句属于MySQL方言。规避方式很简单项目启动时确认目标数据库类型SQL写法向最标准的方向靠拢凡是MySQL特有语法都集中放在独立的Mapper中不杂糅在公共Mapper里。选型和改造成本远小于上线后才发现不兼容的返工成本。6. 删除和修改操作应该搭好的三层检查体系6.1 第一层参数校验前置化无论删除还是修改参数校验尽量放在Service层而不是让XML去承担过多判断。XML负责SQL动态拼接Service负责业务规则校验Controller负责参数格式校验各司其职时排查问题会顺畅得多。比如一个按条件删除的接口Service层至少要保证删除条件不能全空如果是逻辑删除is_deleted字段置1条件里要有明确的is_deleted0限制如果是物理删除删除前最好先查一遍确认影响范围。6.2 第二层日志留痕删改操作一定要有日志。我参与过的每一个强调数据安全项目都会在删除和更新操作里加操作日志。日志里至少包含操作人、操作时间、操作类型、目标记录ID、改动内容摘要。实现手段小项目可以在Service层手动记录大项目可以用Mybatis拦截器统一拦截update和delete类型的Statement采集参数后写入日志表或消息队列。Mybatis拦截器本身是个大话题这里只提一句拦截Executor的update方法可以拿到MappedStatement和参数对象通过这些信息可以解析出SQL模板和参数就能实现统一的审计日志。6.3 第三层删除策略选择很多业务场景下物理删除不是最优解。用户注销、订单作废、文章下架这些操作更适合逻辑删除——用一个字段标记状态而不是真正从表里把数据干掉。逻辑删除的优势非常明显数据可恢复误操作有后悔药保留业务痕迹方便审计和分析删除操作退化为更新操作降低了误删全表的破坏力。代价是每次查询都要记得过滤deleted0否则会把已删除数据查出来。MyBatis-Plus里有TableLogic注解加了之后把功能封装得很完善原生Mybatis里可以约定所有实体类统一有is_deleted字段查询SQL统一拼上AND is_deleted 0。我的个人习惯是计数类数据、交易类数据、用户资产类数据一律逻辑删除临时表数据、中间记录、脏数据才考虑物理删除。物理删除前强制走一次影响行数确认逻辑。上面这些内容覆盖了Mybatis删除和修改从入门到能上生产的大部分要点。最后再分享一个小技巧每次写完一条delete或update先在测试库里跑一遍用控制台打印出的SQL和参数确认这条语句的最终形态就是你要的再让代码进入代码评审。这一步习惯能帮你提前拦下绝大多数删改事故。