☰
SpringMVC批量删除实战:从参数绑定到事务回滚的完整实现
2026/10/8 12:36:55 网站建设 项目流程

1. SpringMVC 批量删除为什么总在参数绑定这一步翻车

批量删除这个功能,单看需求一句话:勾选若干条记录,点一下按钮,全部删掉。但真到 SpringMVC 里落地,很多人第一次写都会卡在同一个地方——前端明明把 id 数组发出去了,后端@RequestParam接到的却是空,或者干脆抛400 Bad Request。这个问题的根源不在 SQL,也不在事务,而在参数绑定这一层。

先把链路拆清楚。一次批量删除请求要穿过四道关:前端把勾选的 checkbox 值收集成数组;HTTP 请求把数组序列化成某种格式(qid=1&qid=2还是qid[]=1&qid[]=2还是 JSON body);SpringMVC 的HandlerMethodArgumentResolver把请求参数绑定到 Controller 方法形参;Service 层开事务,Mapper 执行批量 SQL。任何一道关的格式对不上,后面全白搭。

我见过最多的三种翻车现场:第一种,前端用 jQuery$.post传{'qid': ques_id},jQuery 默认序列化成qid[]=1&qid[]=2,后端却写@RequestParam("qid") String[] ids,名字对不上,直接 400;第二种,后端用@RequestBody List<Long> ids接,前端却发的是 form 表单,Content-Type 是application/x-www-form-urlencoded,SpringMVC 找不到HttpMessageConverter,报415 Unsupported Media Type;第三种,参数接对了,但 Service 没加@Transactional,循环删到第三条报错,前两条已经删了,数据处于半删状态。

这篇就按真实项目里能直接抄的顺序走一遍:先讲清楚参数怎么传怎么接,再给可复制的 Controller、Service、Mapper 配置,然后实际发一次请求验证结果,最后把几个高频报错逐个拆开。适合正在写后台管理、内容列表、订单列表这类带多选删除功能的同学,也适合想把 SpringMVC 参数绑定机制搞明白的人。

需要说明的是,批量删除本身是纯业务逻辑,跟具体用什么模型服务无关。但如果你在项目里同时接了 AI 能力(比如让模型帮你生成删除前的确认文案、或者做操作日志的语义归类),那接口调用的 Key 管理、Base URL 配置这些事可以统一放到一个地方管,后面第 2 节会顺带说清楚怎么把这类配置集中起来,避免散落在各个application.yml里。

2. 动手前的环境与 TaoToken 配置准备

在写批量删除之前,先把工程环境和外部依赖理顺。批量删除依赖的东西不多:SpringMVC(或者 Spring Boot 的 spring-boot-starter-web)、MyBatis(或 MyBatis-Plus)、一个数据库。版本上,Spring Boot 2.7 和 3.x 在参数绑定上差异不大,但 3.x 用的是 Jakarta EE 命名空间,javax.servlet要换成jakarta.servlet,这个后面排错会提到。

真正容易被忽略的是配置项的集中管理。很多项目里,数据库连接、第三方接口地址、各种 Key 散落在application-dev.yml、application-prod.yml、甚至硬编码在 Java 类里。批量删除这种功能本身不涉及外部服务,但一个后台系统往往不止删除功能,还会有导出、消息推送、AI 辅助等模块。这时候把外部服务的接入信息统一收口,能省掉后面大量找配置的时间。

我自己的做法是:凡是外部 HTTP 服务的接入信息,统一走一套环境变量 + 配置文件的方式。以 TaoToken 这类模型服务为例,它的接入信息就三样——Base URL、API Key、Model ID。Base URL 用https://taotoken.net/api,Key 从控制台生成,Model ID 按你实际要调的模型填。这三样东西不要写死在代码里,放到配置中心或者环境变量里,本地开发用.env,线上用密钥管理。

如果你只是做批量删除,这一节可以快速扫过;但如果你的后台同时要调模型做辅助功能,建议现在就把配置结构定好。具体来说,在application.yml里这样组织:

app: ai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY:} model-id: ${TAOTOKEN_MODEL_ID:}

然后 Java 侧用一个@ConfigurationProperties(prefix = "app.ai")的类接住。这样本地跑的时候在 IDE 里配环境变量,线上用容器注入,代码里永远不出现明文 Key。Key 的生成入口在控制台的 API Keys 页面,模型对话的调试入口在模型对话页,长期跑编码类任务的话可以看 Coding Plan,接入文档在文档页。这几个入口按需用,不用全点一遍。

环境准备好之后,确认三件事:数据库能连上、MyBatis 的mapper-locations配对了、SpringMVC 的DispatcherServlet正常映射。这三件事任何一件没弄好,批量删除都会以各种奇怪的方式失败,而且报错信息往往指向别处,容易带偏排查方向。

3. 可复制的 Controller、Service、Mapper 批量删除配置

这一节是核心,直接给能跑的代码。我按「前端传参 → Controller 接收 → Service 事务 → Mapper 批量 SQL」的顺序写,每一段都说明为什么这么写。

3.1 前端:checkbox 收集与请求发送

前端用 checkbox 的value存 id,点删除时把所有选中的 id 收集成数组。关键点是请求参数的序列化方式要和后端约定一致。这里我用最常见的 form 表单方式,参数名统一叫ids:

function batchDelete() { var ids = []; $("input[name='rowCheckbox']:checked").each(function () { ids.push($(this).val()); }); if (ids.length === 0) { alert("请先选择要删除的记录"); return; } if (!confirm("确定删除选中的 " + ids.length + " 条记录吗?")) { return; } $.ajax({ url: "/blog/batchDelete", type: "POST", traditional: true, data: { ids: ids }, success: function (resp) { if (resp.code === 0) { alert("删除成功,共 " + resp.data + " 条"); location.reload(); } else { alert("删除失败:" + resp.msg); } }, error: function (xhr) { alert("请求异常,状态码:" + xhr.status); } }); }

这里有个必须注意的点:jQuery 默认会把数组序列化成ids[]=1&ids[]=2,带方括号。后端如果写@RequestParam("ids"),名字对不上就会 400。解决办法有两个:一是加traditional: true,让它序列化成ids=1&ids=2;二是后端用@RequestParam("ids[]")接。我推荐前者,因为traditional: true是标准做法,后端参数名干净。

3.2 Controller:接收数组并做基础校验

Controller 层只做参数接收和结果封装,业务逻辑全部下沉到 Service:

@RestController @RequestMapping("/blog") public class BlogController { @Autowired private BlogService blogService; @PostMapping("/batchDelete") public Result<Integer> batchDelete(@RequestParam("ids") List<Long> ids) { if (ids == null || ids.isEmpty()) { return Result.fail("删除列表不能为空"); } if (ids.size() > 500) { return Result.fail("单次最多删除 500 条"); } int deleted = blogService.batchDeleteByIds(ids); return Result.ok(deleted); } }

@RequestParam("ids") List<Long> ids能直接接住ids=1&ids=2&ids=3这种格式,SpringMVC 内置的StringToCollectionConverter会帮你转换。注意这里用的是List<Long>而不是String[],因为 id 是数字,用 Long 省掉后面手动parseInt。如果你确实要用数组,写Long[] ids也行,效果一样。

3.3 Service:事务控制是批量删除的命门

批量删除必须加事务,否则删到一半失败会留下脏数据。这里用@Transactional注解,rollbackFor指定Exception.class,避免只回滚运行时异常:

@Service public class BlogServiceImpl implements BlogService { @Autowired private BlogMapper blogMapper; @Override @Transactional(rollbackFor = Exception.class) public int batchDeleteByIds(List<Long> ids) { if (ids == null || ids.isEmpty()) { return 0; } return blogMapper.batchDeleteByIds(ids); } }

有个坑要提前说:@Transactional默认只对RuntimeException回滚,如果 Mapper 抛的是受检异常(比如某些数据库驱动包装过的SQLException),事务不会回滚。所以rollbackFor = Exception.class基本是标配。另外,@Transactional生效的前提是方法被 Spring 代理调用,如果同类内部方法直接this.batchDeleteByIds()调用,事务不生效,这个后面排错会细讲。

3.4 Mapper:一条 SQL 删多条,别在循环里删

最忌讳的写法是在 Service 里for循环调deleteById,那样会发 N 条 SQL,N 次网络往返,性能差还容易超时。正确做法是用IN一条 SQL 搞定:

<delete id="batchDeleteByIds" parameterType="java.util.List"> DELETE FROM blog WHERE id IN <foreach collection="list" item="id" open="(" separator="," close=")"> #{id} </foreach> </delete>

对应的 Mapper 接口:

public interface BlogMapper { int batchDeleteByIds(@Param("list") List<Long> ids); }

注意@Param("list")和 XML 里collection="list"的对应关系。如果你不写@Param,MyBatis 对单个 List 参数的默认名字是list或collection,但显式写出来更稳。foreach里的#{id}是预编译占位符,能防 SQL 注入,别用${id}。

3.5 配置片段:MyBatis 与事务

application.yml里确认这几项:

spring: datasource: url: jdbc:mysql://127.0.0.1:3306/blog_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: ${DB_PASSWORD:} driver-class-name: com.mysql.cj.jdbc.Driver transaction: rollback-on-commit-failure: true mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.blog.entity configuration: map-underscore-to-camel-case: true

map-underscore-to-camel-case: true让数据库的create_time自动映射到 Java 的createTime,省掉一堆resultMap。rollback-on-commit-failure: true保证提交阶段失败也回滚。

到这里,四层配置就齐了。前端traditional: true发ids=1&ids=2,Controller 用@RequestParam("ids") List<Long>接,Service 加@Transactional(rollbackFor = Exception.class),Mapper 用foreach拼IN。这套组合在 Spring Boot 2.x 和 3.x 上都能跑,唯一要改的是 3.x 里javax换jakarta。

4. 发一次真实请求验证批量删除是否生效

配置写完,别急着上线,先手动发一次请求看结果。我用curl演示,你也可以用 Postman 或浏览器控制台。

先准备测试数据,插三条记录:

INSERT INTO blog (id, title, author) VALUES (1001, '测试文章A', 'alice'), (1002, '测试文章B', 'bob'), (1003, '测试文章C', 'carol');

确认插入成功:

SELECT id, title FROM blog WHERE id IN (1001, 1002, 1003);

然后发批量删除请求:

curl -X POST 'http://localhost:8080/blog/batchDelete' \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'ids=1001&ids=1002&ids=1003'

预期返回:

{"code":0,"msg":"success","data":3}

data是 3,说明三条都删了。再查一次数据库确认:

SELECT COUNT(*) FROM blog WHERE id IN (1001, 1002, 1003);

返回 0,验证通过。

接下来验证事务回滚。故意制造一个失败场景:把ids里混入一个不存在的 id,同时让 SQL 在中途报错。更直接的办法是临时改 Mapper,在foreach后面加一个非法字段,触发SQLSyntaxErrorException:

<delete id="batchDeleteByIds" parameterType="java.util.List"> DELETE FROM blog WHERE id IN <foreach collection="list" item="id" open="(" separator="," close=")"> #{id} </foreach> AND not_exist_column = 1 </delete>

再插三条数据,发同样的请求,这次会抛异常。观察数据库:三条数据应该一条都没删,因为事务回滚了。如果发现删了一部分,说明@Transactional没生效,去检查是不是同类内部调用、或者方法不是public、或者异常类型没被rollbackFor覆盖。

验证完把 Mapper 改回正确版本。这一步很关键,很多人写完不验证回滚,上线后遇到并发删除失败才发现数据不一致。

如果你在项目里同时接了模型服务做辅助功能,验证接口连通性也可以用类似思路:发一个最小请求,看返回结构对不对。模型对话页可以直接试,接入文档里有完整的请求示例。但批量删除本身不需要外部服务,这一步纯属顺带。

5. 批量删除高频报错排查:400、401、事务不回滚

这一节把实际开发中最容易撞上的几个报错逐个拆开。每个都给出报错原文、原因、解决办法。

5.1 400 Bad Request:Required List parameter 'ids' is not present

完整报错通常是:

Resolved [org.springframework.web.bind.MissingServletRequestParameterException: Required List parameter 'ids' is not present]

原因有三种可能。第一种,前端参数名和后端不一致,比如前端发qid,后端接ids。第二种,前端没加traditional: true,jQuery 发的是ids[]=1&ids[]=2,后端@RequestParam("ids")找不到ids这个参数名。第三种,请求方法不对,前端用 GET,后端写@PostMapping。

排查顺序:先看浏览器 Network 面板里请求的 Payload,确认参数名和格式;再对照 Controller 的@RequestParam名字;最后确认请求方法。如果是ids[]格式,要么前端加traditional: true,要么后端改成@RequestParam("ids[]")。

5.2 415 Unsupported Media Type:Content-Type 不匹配

报错原文:

Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: Content type 'application/x-www-form-urlencoded;charset=UTF-8' not supported]

这个通常出现在后端用了@RequestBody List<Long> ids,但前端发的是 form 表单。@RequestBody要求请求体是 JSON,Content-Type 必须是application/json。两种改法:前端改成JSON.stringify(ids)并设contentType: 'application/json',后端保持@RequestBody;或者后端改成@RequestParam,前端保持 form 表单。别混用。

5.3 事务不回滚:删了一半留下脏数据

这个没有报错,但结果不对。常见原因四个:

第一,@Transactional方法不是public。Spring AOP 代理只拦截public方法,private、protected、包级方法上的注解会被忽略。

第二,同类内部调用。比如batchDeleteByIds里调了this.doDelete(),doDelete上的@Transactional不生效,因为没走代理。解决办法是把doDelete挪到另一个 Service,或者注入自身代理。

第三,异常被 catch 吞了。如果 Service 里try-catch了异常但没重新抛出,Spring 感知不到异常,不会回滚。要么别 catch,要么 catch 后throw new RuntimeException(e)。

第四,数据库引擎不支持事务。MySQL 的 MyISAM 引擎不支持事务,必须用 InnoDB。检查建表语句里的ENGINE=InnoDB。

5.4 401 Unauthorized:接口鉴权拦截

如果你的批量删除接口配了鉴权(比如 Spring Security 或自定义拦截器),未登录或 token 过期会返回 401。报错原文类似:

{"timestamp":"...","status":401,"error":"Unauthorized","path":"/blog/batchDelete"}

这个跟批量删除逻辑无关,是鉴权层的问题。检查请求头里有没有带 token,token 有没有过期,拦截器的放行规则有没有把/blog/batchDelete排除。如果是前后端分离项目,确认前端在$.ajax的headers里带了Authorization。

5.5 OAuth 或第三方登录场景下的 token 失效

有些后台用 OAuth 登录,token 存在 session 或 Redis 里。批量删除时如果 token 刚好过期,会跳登录页或返回鉴权失败。这类问题的排查思路是:先确认当前登录态是否有效,再确认删除接口是否在鉴权白名单外。如果是模型服务这类外部接口的 OAuth,报错格式会不一样,通常是invalid_token或token expired,这时候去控制台重新生成 Key 或刷新 token 即可。

5.6 参数绑定成功但 SQL 报错:foreach 拼错

报错原文:

org.apache.ibatis.binding.BindingException: Parameter 'id' not found. Available parameters are [list, collection]

原因是 Mapper 接口没写@Param("list"),XML 里却用collection="list"。MyBatis 对单个集合参数的默认名是list和collection,但如果你在接口上写了@Param("ids"),XML 里就得用collection="ids"。两边名字必须一致。最稳的写法是接口写@Param("list"),XML 写collection="list"。

5.7 批量删除数量过大导致 SQL 超长

如果一次删几千条,IN (...)里的占位符太多,可能触发 MySQL 的max_allowed_packet限制,报Packet for query is too large。解决办法是分批删,比如每 500 条一批,在 Service 里切分:

List<List<Long>> partitions = Lists.partition(ids, 500); int total = 0; for (List<Long> part : partitions) { total += blogMapper.batchDeleteByIds(part); } return total;

注意分批之后,整个方法仍然在一个事务里,任何一批失败全部回滚。如果业务允许部分成功,那就别加事务,但要自己处理失败记录。

6. 把批量删除接进你的项目:从配置到上线的收尾建议

代码能跑通只是第一步,真正上线还要考虑几件事。

第一,权限校验。批量删除是危险操作,必须确认当前用户有删除权限,而且只能删自己有权删的数据。别只在前端隐藏按钮,后端一定要校验。比如在 Service 里先查这些 id 的归属,确认都属于当前用户,再执行删除。

第二,操作日志。批量删除要记日志,记清楚谁在什么时间删了哪些 id。日志表建议单独建,字段包括操作人、操作时间、删除的 id 列表、影响行数。这样出问题能追溯。

第三,软删除 vs 硬删除。很多业务不适合物理删除,改成软删除(加is_deleted字段,删除时更新为 1)。软删除的批量操作就是把DELETE换成UPDATE ... SET is_deleted = 1 WHERE id IN (...),事务逻辑一样。

第四,接口幂等。用户可能重复点删除按钮,导致重复请求。可以在前端点一次后禁用按钮,后端也可以做幂等校验,比如删除前先查这些 id 是否存在,不存在的跳过。

第五,配置集中管理。前面提到的外部服务配置,上线前确认 Key 没有硬编码在代码里,全部走环境变量或配置中心。如果项目里同时调模型服务,Base URL、Key、Model ID 这三样要能一处改、处处生效。控制台可以管理 Key,接入文档有完整的配置说明,长期跑编码任务的话 Coding Plan 也值得看一眼。

最后说一个我踩过的坑:批量删除的接口路径别用 GET。有些同学图省事用@GetMapping,结果浏览器或中间件缓存了请求,删了的数据又"回来"了(其实是缓存了响应)。批量删除一律用 POST,而且要做好 CSRF 防护,如果是前后端分离项目,用 token 鉴权就够,如果是传统表单项目,记得带 CSRF token。

整套流程走下来,从参数绑定到事务回滚,核心就四句话:前端traditional: true发数组,后端@RequestParam接 List,Service 加@Transactional(rollbackFor = Exception.class),Mapper 用foreach拼IN。把这四步做对,批量删除基本不会出问题。剩下的权限、日志、软删除,按业务需要往上加就行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询