配置导入事务问题与修复总结
本文档总结配置导入(Excel 模板导入)过程中出现的 UnexpectedRollbackException 与前端错误信息不对的问题、根因及修复方案。
一、问题现象
- 后端日志 :
org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only - 前端展示:页面提示「配置导入失败:Transaction rolled back because it has been marked as rollback-only」,而不是真实的业务错误(如「数据库表不存在:xxx」)
- 调试现象 :Sheet1 的 for 循环里已用 try-catch 把异常包进
result,并在 78-79 行正常return result,但 Controller 仍进入异常捕获分支(110-111 行)
二、根因分析
2.1 事务与"异常即标记回滚"
ConfigImportServiceImpl.importFromExcel(InputStream)使用@Transactional(rollbackFor = Exception.class),整段导入在一个事务中执行。- Spring 行为 :只要在事务内任意时刻 抛出了符合
rollbackFor的异常,当前事务就会被标记为 rollback-only 。
标记发生在异常抛出的瞬间,与后续是否在业务代码里 catch 无关。
2.2 Sheet1 场景下的实际流程
- Sheet1 的 for 循环中调用
factTableSchemaService.importTableFromDatabase(...)。 - 若某张表不存在,
FactTableSchemaServiceImpl抛出IllegalArgumentException("数据库表不存在:" + tableName)。 - 此时 :当前事务(即
importFromExcel所在事务)被标记为 rollback-only。 - 异常随后在 for 循环的 catch 中被捕获,仅执行
result.addError(...),未 rethrow。 - 代码继续执行,到 78-79 行
if (!result.isSuccess() && !result.getErrors().isEmpty()) { return result; },正常 return result。 - 方法正常返回后,Spring 事务拦截器执行 commit。
- 发现事务已是 rollback-only,不允许提交,于是抛出 UnexpectedRollbackException。
- 该异常冒泡到 Controller,被 110-111 行
catch (Exception e)捕获,前端看到的是 commit 失败的消息,而非 result 中已写好的「表[xxx]导入失败: 数据库表不存在:xxx」。
2.3 两种失败路径对比
| 场景 | 执行路径 | 结果 |
|---|---|---|
| 仅 Sheet1 出错 | for 内 catch → 78-79 return result → commit 时抛 UnexpectedRollbackException → Controller 进 catch | 前端看到 rollback 提示,result 中正确错误信息未展示 |
| Sheet2/3/5 或字段配置出错 | 后续某步抛异常 → 外层 catch 后 rethrow → Controller 进 catch | 前端看到真实异常信息(修复 rethrow 后) |
三、修复方案
3.1 修复一:Sheet1 表导入使用独立事务(REQUIRES_NEW)
目的:让 Sheet1 中单表导入失败时,只回滚该次调用的事务,不污染外层导入事务。
修改 :FactTableSchemaServiceImpl.importTableFromDatabase
- 原:
@Transactional(rollbackFor = Exception.class) - 现:
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
效果:
- 每次调用
importTableFromDatabase都会挂起外层事务并开启一个新事务。 - 某张表导入失败抛异常时,仅该新事务被标记 rollback-only 并回滚。
- 异常在 Sheet1 的 for 中被 catch 并写入
result,外层importFromExcel事务未被标记。 - 78-79 行
return result后,外层事务正常 commit,Controller 收到带错误信息的 result,走success(result),前端可正确展示 result 中的错误列表。
3.2 修复二:外层 catch 不再吞异常(Rethrow)
目的 :当异常来自 Sheet2/3/5 或字段配置等未内部 catch 的步骤时,将真实异常抛出,避免只看到 UnexpectedRollbackException。
修改 :ConfigImportServiceImpl.importFromExcel(InputStream) 最外层 catch
- 原:
catch (Exception e) { ... result.addError(...); result.setSuccess(false); }后直接return result。 - 现:在 catch 末尾增加
throw new RuntimeException(e.getMessage() != null ? e.getMessage() : "配置导入失败", e);。
效果:
- 后续 Sheet 或字段配置中一旦抛异常,会直接抛出,事务按预期回滚。
- Controller 的 catch 收到的是包装后的 RuntimeException,
getMessage()为原始业务错误信息,前端可显示「配置导入失败:数据库表不存在:xxx」等真实原因。
四、涉及文件与位置
| 文件 | 修改内容 |
|---|---|
backend/.../FactTableSchemaServiceImpl.java |
importTableFromDatabase 增加 propagation = Propagation.REQUIRES_NEW,并增加 Propagation 的 import |
backend/.../ConfigImportServiceImpl.java |
最外层 catch 中在写入 result 后 throw new RuntimeException(..., e) |
五、小结
- 问题本质:事务内抛出的异常会使事务被标记 rollback-only;若异常被业务 catch 且未 rethrow,方法仍会"正常返回",但 commit 时因 rollback-only 再抛 UnexpectedRollbackException,前端只能看到后者。
- Sheet1 场景 :通过
importTableFromDatabase使用 REQUIRES_NEW,把单表导入放到独立事务中,表导入失败只回滚该次调用,外层可正常 return result 并 commit,前端拿到 result 中的错误信息。 - 后续 Sheet 场景:通过外层 catch 后 rethrow,保证真实异常传到 Controller,前端显示真实错误信息。
相关功能说明见:docs/导入模板/配置导入模板说明.md。