Spring Boot导入返回409,为什么第一行还是入库了?

100 阅读5分钟

接口返回 409,响应里的 importedRows 也是 0,第一行数据却已经入库了。只断言返回值,这条导入测试仍然能通过。

下面用一个本地联系人导入实验复现这个差别:两行数据,第一行正常,第二行邮箱重复。分别用逐行提交和整批事务处理,输入、状态码和返回数量都相同,数据库结果如下。

实现HTTP返回的importedRows数据库总行数本批第一行是否存在
逐行提交40902存在
整批事务40901不存在

Spring Boot导入测试:别只断言409,还要查数据库:示例关系图

两版开始前,数据库都只有一条旧记录。409 报告了冲突,但前面的写入有没有撤销,还得查库。

为什么要让第二行失败

数据库先放一条旧记录:used@example.com,姓名为“旧用户”。

本次文件里的两行依次是:

第2行:本次新用户 / new@example.com
第3行:试图改名   / used@example.com

这组输入特意把正常数据放在前面。第二行失败时,才有一条已经执行过的写入可供检查;如果第一行就报错,两版都可能没有新增记录,测不出事务边界的差别。

这里特意不做“库内邮箱预查”,让错误真正发生在写入阶段。即使业务接口已有预查,也仍需要单独测试写入冲突;另一个请求可能在预查后提交同样的邮箱。

两段代码只挪了事务的位置

坏版本的关键片段:

for (var row : rows) {
    tx.executeWithoutResult(status -> insert(row));
}

本实验没有外层事务。每次 executeWithoutResult 都开启并结束一次事务,第一行已经提交,第二行回滚自己的那次事务,撤销不了第一行。

修正版把循环放进去:

tx.executeWithoutResult(status -> {
    for (var row : rows) insert(row);
});

这时两个 INSERT 属于同一个事务。第二行的运行时异常离开回调,事务回滚,外层再把异常转换成409。Spring Framework 6.2.10 的 TransactionTemplate 源码可以核对这段异常处理顺序。

两版都有 catch,也都返回409。排查时要沿异常往外看:事务包住的是一次写入还是整个循环,异常有没有在触发回滚之前被捕获。只看有没有 try-catch,判断不了回滚结果。

请求结束后,至少再查三件事

本例要求整批成功或整批失败。对应到这份数据,请求结束后要满足:

  • 总行数仍然是1。
  • new@example.com 不存在。
  • used@example.com 的姓名仍然是“旧用户”。

只查总行数也不够:新增一条、误删一条,数量照样不变。最后一条检查的是旧数据没有被意外修改。

测试类没有加 @Transactional。请求完成后,重新从数据源取得连接,再查询提交后的状态。

MockMvc 请求常在测试线程里处理,应用事务可能加入测试事务。如果测试结束时又自动回滚,就容易把测试负责清理数据和接口自身的回滚混在一起。这里去掉测试事务,让断言检查请求完成后的数据库状态。

两个文件复现这个差别

下面是完整的最小实验,使用 JDK 21、Spring Boot 3.5.5 管理的 Spring Framework 6.2.10,以及 H2 2.3.232。

为聚焦事务边界,HTTP 入口直接接收“Excel 已解析出的两行”JSON,不重复上传和 POI 解析。MockMvc 确实走 Spring MVC 的路由、参数转换和响应处理,但没有启动真实网络端口。

创建下面两个文件,在项目目录执行 mvn test。输出是:

atomic: HTTP=409, total=1, new=0
broken: HTTP=409, total=2, new=1

两项测试都通过,是因为坏版测试明确断言“应该复现残留”,并不意味着坏实现满足业务要求。真正业务接口的验收应使用整批事务那组断言。

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.5</version><relativePath/>
  </parent>
  <groupId>demo</groupId><artifactId>excel-import-lab</artifactId><version>1.0</version>
  <properties><java.version>21</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
    <dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
  </dependencies>
</project>

src/test/java/demo/BoundaryTest.java

package demo;

import java.util.*;
import org.junit.jupiter.api.*;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.support.*;
import org.springframework.web.bind.annotation.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

class BoundaryTest {
    DriverManagerDataSource ds;
    JdbcTemplate jdbc;
    MockMvc mvc;
    public record Contact(String name, String email) {}

    @RestController
    static class Api {
        final JdbcTemplate jdbc;
        final TransactionTemplate tx;
        Api(JdbcTemplate jdbc, DriverManagerDataSource ds) {
            this.jdbc = jdbc;
            tx = new TransactionTemplate(new DataSourceTransactionManager(ds));
        }
        @PostMapping("/imports/{mode}")
        ResponseEntity<?> upload(@PathVariable String mode, @RequestBody List<Contact> rows) {
            try {
                if ("broken".equals(mode)) {
                    for (var row : rows) tx.executeWithoutResult(s -> insert(row));
                } else {
                    tx.executeWithoutResult(s -> { for (var row : rows) insert(row); });
                }
                return ResponseEntity.ok(Map.of("importedRows", rows.size()));
            } catch (DataIntegrityViolationException e) {
                // 故意让两版响应相同,观察状态码能不能证明回滚。
                return ResponseEntity.status(409).body(Map.of("importedRows", 0));
            }
        }
        void insert(Contact row) {
            jdbc.update("INSERT INTO contacts VALUES (?,?)", row.name(), row.email());
        }
    }
    @BeforeEach void setup() {
        ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
        jdbc = new JdbcTemplate(ds);
        jdbc.execute("CREATE TABLE contacts(name VARCHAR(50) NOT NULL,email VARCHAR(254) NOT NULL UNIQUE)");
        jdbc.update("INSERT INTO contacts VALUES ('旧用户','used@example.com')");
        mvc = MockMvcBuilders.standaloneSetup(new Api(jdbc, ds)).build();
    }
    @AfterEach void close() { jdbc.execute("SHUTDOWN"); }
    @Test void brokenLeavesOneNewRow() throws Exception { verify("broken", true); }
    @Test void atomicLeavesNoNewRow() throws Exception { verify("atomic", false); }
    void verify(String mode, boolean hasNew) throws Exception {
        assertFalse(TransactionSynchronizationManager.isActualTransactionActive());
        mvc.perform(post("/imports/" + mode).contentType("application/json").content("""
            [{"name":"本次新用户","email":"new@example.com"},
             {"name":"试图改名","email":"used@example.com"}]
            """))
            .andExpect(status().isConflict())
            .andExpect(jsonPath("$.importedRows").value(0));
        // 请求已经完成,再借出连接,检查提交后的状态。
        try (var connection = ds.getConnection(); var statement = connection.createStatement()) {
            assertTrue(connection.getAutoCommit());
            var rows = new LinkedHashMap<String,String>();
            try (var rs = statement.executeQuery("SELECT email,name FROM contacts ORDER BY email")) {
                while (rs.next()) rows.put(rs.getString(1), rs.getString(2));
            }
            assertEquals(hasNew ? 2 : 1, rows.size());
            assertEquals(hasNew, rows.containsKey("new@example.com"));
            assertEquals("旧用户", rows.get("used@example.com"));
            System.out.println(mode + ": HTTP=409, total=" + rows.size() + ", new=" + (hasNew ? 1 : 0));
        }
    }
}

这个实验验证的是同一数据源里的两次写入。换成 MySQL、PostgreSQL,或者事务里还要调用外部接口,需要补对应环境的测试;H2 的结果不能替代它们。

给现有接口补测试,可以先用这组输入:第一行能写入,第二行才冲突。你们目前的断言会检查第一行是否还在,还是只检查状态码和返回数量?