You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SpringBoot非事务测试下高效清理受影响测试数据库表的方案咨询

JDBC层面监听SQL并自动截断测试受影响表的实现方案

核心思路

通过代理JDBC数据源拦截所有执行的SQL语句,使用JSqlParser(活跃维护的轻量SQL解析库)提取SQL涉及的表名,收集测试全周期(从@Sql执行前到测试结束)内所有被访问/修改的表,最终在测试完成后统一截断这些表,避免全表扫描或手动指定表的遗漏问题。

依赖引入

添加JSqlParser到测试依赖(版本取最新稳定版):

<dependency>
    <groupId>com.github.jsqlparser</groupId>
    <artifactId>jsqlparser</artifactId>
    <version>4.9</version>
    <scope>test</scope>
</dependency>

代码实现

1. 线程安全的表收集器

用于存储当前测试线程中所有受影响的表名:

import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ThreadLocal;

public class SqlTableCollector {
    private static final ThreadLocal<Set<String>> affectedTables = ThreadLocal.withInitial(HashSet::new);

    public static void addTable(String tableName) {
        affectedTables.get().add(tableName.toLowerCase());
    }

    public static Set<String> getAffectedTables() {
        return Collections.unmodifiableSet(affectedTables.get());
    }

    public static void clear() {
        affectedTables.remove();
    }
}

2. JDBC代理组件

代理Connection和Statement,拦截SQL执行并解析表名:

import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.util.TablesNamesFinder;
import org.apache.commons.dbcp2.DelegatingConnection;
import org.apache.commons.dbcp2.DelegatingPreparedStatement;
import org.apache.commons.dbcp2.DelegatingStatement;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;

public class MonitoringConnection extends DelegatingConnection {
    private static final Logger logger = LoggerFactory.getLogger(MonitoringConnection.class);

    public MonitoringConnection(Connection target) {
        super(target);
    }

    @Override
    public Statement createStatement() throws SQLException {
        return new MonitoringStatement(super.createStatement());
    }

    @Override
    public Statement createStatement(int resultSetType, int resultSetConcurrency) throws SQLException {
        return new MonitoringStatement(super.createStatement(resultSetType, resultSetConcurrency));
    }

    @Override
    public PreparedStatement prepareStatement(String sql) throws SQLException {
        parseAndCollectTables(sql);
        return new MonitoringPreparedStatement(super.prepareStatement(sql), sql);
    }

    @Override
    public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {
        parseAndCollectTables(sql);
        return new MonitoringPreparedStatement(super.prepareStatement(sql, resultSetType, resultSetConcurrency), sql);
    }

    private void parseAndCollectTables(String sql) {
        try {
            Statement statement = CCJSqlParserUtil.parse(sql);
            TablesNamesFinder tablesFinder = new TablesNamesFinder();
            tablesFinder.getTableList(statement).forEach(SqlTableCollector::addTable);
        } catch (JSQLParserException e) {
            logger.warn("Failed to parse SQL: {}", sql, e);
        }
    }

    private static class MonitoringStatement extends DelegatingStatement {
        private static final Logger logger = LoggerFactory.getLogger(MonitoringStatement.class);

        public MonitoringStatement(Statement target) {
            super(target);
        }

        @Override
        public boolean execute(String sql) throws SQLException {
            parseAndCollectTables(sql);
            return super.execute(sql);
        }

        @Override
        public int executeUpdate(String sql) throws SQLException {
            parseAndCollectTables(sql);
            return super.executeUpdate(sql);
        }

        @Override
        public java.sql.ResultSet executeQuery(String sql) throws SQLException {
            parseAndCollectTables(sql);
            return super.executeQuery(sql);
        }

        private void parseAndCollectTables(String sql) {
            try {
                Statement statement = CCJSqlParserUtil.parse(sql);
                TablesNamesFinder tablesFinder = new TablesNamesFinder();
                tablesFinder.getTableList(statement).forEach(SqlTableCollector::addTable);
            } catch (JSQLParserException e) {
                logger.warn("Failed to parse SQL: {}", sql, e);
            }
        }
    }

    private static class MonitoringPreparedStatement extends DelegatingPreparedStatement {
        private static final Logger logger = LoggerFactory.getLogger(MonitoringPreparedStatement.class);
        private final String sql;

        public MonitoringPreparedStatement(PreparedStatement target, String sql) {
            super(target);
            this.sql = sql;
        }

        @Override
        public boolean execute() throws SQLException {
            parseAndCollectTables(sql);
            return super.execute();
        }

        @Override
        public int executeUpdate() throws SQLException {
            parseAndCollectTables(sql);
            return super.executeUpdate();
        }

        @Override
        public java.sql.ResultSet executeQuery() throws SQLException {
            parseAndCollectTables(sql);
            return super.executeQuery(sql);
        }

        private void parseAndCollectTables(String sql) {
            try {
                Statement statement = CCJSqlParserUtil.parse(sql);
                TablesNamesFinder tablesFinder = new TablesNamesFinder();
                tablesFinder.getTableList(statement).forEach(SqlTableCollector::addTable);
            } catch (JSQLParserException e) {
                logger.warn("Failed to parse SQL: {}", sql, e);
            }
        }
    }
}

3. 代理数据源

包装原有数据源,返回代理Connection:

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

public class MonitoringDataSource extends AbstractDataSource {
    private final DataSource targetDataSource;

    public MonitoringDataSource(DataSource targetDataSource) {
        this.targetDataSource = targetDataSource;
    }

    @Override
    public Connection getConnection() throws SQLException {
        return new MonitoringConnection(targetDataSource.getConnection());
    }

    @Override
    public Connection getConnection(String username, String password) throws SQLException {
        return new MonitoringConnection(targetDataSource.getConnection(username, password));
    }

    @Override
    @SuppressWarnings("unchecked")
    public <T> T unwrap(Class<T> iface) throws SQLException {
        if (iface.isInstance(this)) {
            return (T) this;
        }
        return targetDataSource.unwrap(iface);
    }

    @Override
    public boolean isWrapperFor(Class<?> iface) throws SQLException {
        return iface.isInstance(this) || targetDataSource.isWrapperFor(iface);
    }
}

4. Spring测试监听器

集成Spring测试生命周期,控制表收集和截断时机:

import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.jdbc.datasource.AbstractDataSource;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.support.AbstractTestExecutionListener;
import org.springframework.test.context.support.SqlScriptsTestExecutionListener;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Set;

public class TableCleanupTestExecutionListener extends AbstractTestExecutionListener {

    @Override
    public int getOrder() {
        // 确保在@Sql执行前启动收集,在测试结束后执行清理
        return SqlScriptsTestExecutionListener.DEFAULT_ORDER - 1;
    }

    @Override
    public void beforeTestClass(TestContext testContext) throws Exception {
        // 替换原有数据源为代理数据源
        DataSource originalDataSource = testContext.getApplicationContext().getBean(DataSource.class);
        MonitoringDataSource monitoringDataSource = new MonitoringDataSource(originalDataSource);

        ConfigurableApplicationContext configurableContext = (ConfigurableApplicationContext) testContext.getApplicationContext();
        configurableContext.getBeanFactory().registerSingleton("dataSource", monitoringDataSource);
    }

    @Override
    public void beforeTestMethod(TestContext testContext) throws Exception {
        // 测试前清空表收集器
        SqlTableCollector.clear();
    }

    @Override
    public void afterTestMethod(TestContext testContext) throws Exception {
        Set<String> affectedTables = SqlTableCollector.getAffectedTables();
        if (affectedTables.isEmpty()) {
            return;
        }

        // 执行截断操作
        DataSource dataSource = testContext.getApplicationContext().getBean(DataSource.class);
        try (Connection conn = dataSource.getConnection()) {
            DatabaseMetaData metaData = conn.getMetaData();
            String truncateSql = buildTruncateSql(affectedTables, metaData.getDatabaseProductName());
            try (Statement stmt = conn.createStatement()) {
                stmt.execute(truncateSql);
            }
        }
        SqlTableCollector.clear();
    }

    private String buildTruncateSql(Set<String> tables, String databaseProduct) {
        StringBuilder sb = new StringBuilder();
        boolean isMySQL = "MySQL".equalsIgnoreCase(databaseProduct);
        boolean isPostgreSQL = "PostgreSQL".equalsIgnoreCase(databaseProduct);

        for (String table : tables) {
            if (sb.length() > 0) {
                sb.append("; ");
            }
            sb.append("TRUNCATE TABLE ").append(table);
            if (isMySQL) {
                sb.append(" CASCADE");
            } else if (isPostgreSQL) {
                sb.append(" RESTART IDENTITY CASCADE");
            }
            // 可扩展适配Oracle、SQL Server等其他数据库
        }
        return sb.toString();
    }
}

测试集成

在测试类中注册自定义监听器,结合@Sql使用:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.support.SqlScriptsTestExecutionListener;
import org.springframework.test.context.support.TestExecutionListeners.MergeMode;
import org.springframework.test.context.jdbc.Sql;

@SpringBootTest
@TestExecutionListeners(
        listeners = {TableCleanupTestExecutionListener.class, SqlScriptsTestExecutionListener.class},
        mergeMode = MergeMode.MERGE_WITH_DEFAULTS
)
@Sql(scripts = "/test-data.sql")
public class SampleServiceTest {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    public void testDataManipulation() {
        // 测试逻辑:插入、更新、查询操作
        jdbcTemplate.update("INSERT INTO product (id, name) VALUES (?, ?)", 1001, "Test Product");
        String productName = jdbcTemplate.queryForObject("SELECT name FROM product WHERE id = ?", String.class, 1001);
    }
}

注意事项

  • SQL解析兼容性:JSqlParser支持主流数据库语法,但特殊自定义语法可能解析失败,可通过日志排查并添加适配逻辑
  • 数据库适配:TRUNCATE语法在不同数据库有差异(如MySQL的CASCADE、PostgreSQL的RESTART IDENTITY),需根据实际使用的数据库调整buildTruncateSql方法
  • 性能影响:代理和SQL解析会带来轻微性能开销,仅建议在测试环境使用
  • 权限要求:执行TRUNCATE语句需要对应数据库的操作权限
  • 表名大小写:统一转为小写避免数据库大小写敏感问题

内容的提问来源于stack exchange,提问作者gdantimi

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.03 15:35:05