客户表已经更新,审计表因为字段过长插入失败。若业务要求"每次已提交修改必须有审计记录",继续保留客户更新就不符合这项要求。
下面用JDK 21和H2 2.3.232跑一个同库事务实验:让第二次写入真实报SQL异常,随后从另一个连接查询客户数据与审计条数。
事务边界包括两次写入

关闭自动提交后,客户更新与审计插入复用同一Connection,完成后commit;异常路径rollback并继续抛出。不能在更新方法、日志方法中各自新开一个连接,再认为它们属于同一个本地事务。
为了让读者能观察明确的SQL失败,审计event列故意只允许12个字符。这是实验故障开关,不是建议真实业务把日志字段设得这么短。
完整代码保存为AuditTx.java:
java
import java.sql.*;
public class AuditTx {
static final String URL = "jdbc:h2:mem:audit;DB_CLOSE_DELAY=-1";
static void change(String name, String event) throws SQLException {
try (Connection c = DriverManager.getConnection(URL)) {
c.setAutoCommit(false);
try {
try (var p = c.prepareStatement("UPDATE customer SET name=? WHERE id=1")) {
p.setString(1, name);
if (p.executeUpdate() != 1) throw new SQLException("客户不存在");
}
// 两次写入复用同一个连接;审计表长度约束用来制造真实SQL失败。
try (var p = c.prepareStatement("INSERT INTO audit(event) VALUES(?)")) {
p.setString(1, event);
p.executeUpdate();
}
c.commit();
} catch (SQLException | RuntimeException e) {
try { c.rollback(); } catch (SQLException rollbackError) {
e.addSuppressed(rollbackError);
}
throw e;
}
}
}
static void check(String expectedName, int expectedLogs) throws SQLException {
// 用另一个连接读已提交结果,不能只断言方法调用次数。
try (var c = DriverManager.getConnection(URL); var s = c.createStatement()) {
try (var r = s.executeQuery("SELECT name FROM customer WHERE id=1")) {
if (!r.next() || !r.getString(1).equals(expectedName)) {
throw new AssertionError("客户状态不符");
}
}
try (var r = s.executeQuery("SELECT COUNT(*) FROM audit")) {
r.next();
if (r.getInt(1) != expectedLogs) throw new AssertionError("审计条数不符");
}
}
System.out.println("name=" + expectedName + ", audit=" + expectedLogs);
}
public static void main(String[] args) throws SQLException {
try (var c = DriverManager.getConnection(URL); var s = c.createStatement()) {
s.execute("CREATE TABLE customer(id INT PRIMARY KEY, name VARCHAR(30))");
s.execute("CREATE TABLE audit(event VARCHAR(12) NOT NULL)");
s.execute("INSERT INTO customer VALUES(1, 'before')");
}
try {
change("must_rollback", "event_too_long_for_column");
throw new AssertionError("应触发SQL失败");
} catch (SQLException expected) {
System.out.println("auditWrite=failed");
}
check("before", 0);
change("after", "NAME_CHANGED");
check("after", 1);
}
}
从Maven Central的H2 2.3.232目录取得h2-2.3.232.jar,与源码放在同一目录。
bash
javac -encoding UTF-8 AuditTx.java
Windows运行:
powershell
java -cp ".;h2-2.3.232.jar" AuditTx
Linux或macOS把类路径分隔符改成冒号:
bash
java -cp ".:h2-2.3.232.jar" AuditTx
本地H2实验结果:
text
auditWrite=failed
name=before, audit=0
name=after, audit=1
失败时,客户仍为before且审计0条;成功时客户为after且审计1条。check方法使用独立连接读取结果,断言的对象是提交后的数据库状态。
成功记录、失败尝试和运行日志要区分
这张audit表只表达成功提交的业务变更。失败请求若也要保留,应写成失败事件,选择单独的可靠记录方式;把失败记录塞在同一个被回滚的事务里,当然也会一起消失。
普通运行日志不一定要求与业务更新一起提交。是否因为记录失败而阻止业务,要由业务用途决定。本例有意选择"成功修改必须留下审计"的要求,不把它当成所有系统的默认答案。
程序也没有实现完整审计系统:事件没有操作者与字段差异,未覆盖认证、并发版本检查、查询权限和历史留存。它只验证同库两次写入的原子提交。接入Spring事务时还需确认实际事务边界、异常传播与数据源,不能直接用这里的结果证明代理配置正确。
如果写入的是外部日志服务或另一个数据库,本地Connection无法替它们统一提交。需要另行讨论事务内事件表、可靠发送以及消费方重复处理,这不是把insert换成HTTP调用就能保持的性质。
你们如何验证这类回滚:只检查抛异常,还是重新连接数据库查最终状态?