Java EE 企业应用 19:异常、调用入口与事务边界怎样配合
抛错并不自动回答“哪些行提交了”
采购审批失败时,API 可能返回 400、409 或 500,然而 HTTP 状态不是数据库事务的最终状态。假设服务方法已经把申请改为 APPROVED,随后生成订单失败;如果异常被吞掉、事务仍然正常结束,客户看见错误,数据库却保存了审批。反过来,用户输入校验在进入服务方法前失败,数据库压根没有变化,也谈不上“回滚了两张表”。定位必须同时说明调用入口、异常离开哪个边界、事务管理器如何标记,以及提交后行集合。
本篇沿用 Java 21、Jakarta EE 11 / Jakarta Transactions 2.0、Open Liberty 26.0.0.5、PostgreSQL 16.15。已有 ProcurementUseCases 是 CDI @ApplicationScoped bean,draft、submit、approve、reject、order 方法标 @Transactional。演示 REST 资源通过 @Inject 获取它;JdbcRequestStore.transition 使用 UPDATE ... WHERE id=? AND tenant_id=? AND status=? AND version=?,更新行数不是 1 就抛 RequestConflictException。这些路径是源代码事实,不是下面完整异常矩阵已经过容器测试的声明。
先问方法是否走过受管入口
@Transactional 对经过 CDI 受管调用的可拦截方法起作用,不会因为类名被 new、或者同一个对象里的方法在内部自调,就自动重新走拦截器。现有 draftThenAbortForLab 本身标了 @Transactional,内部 draft(...) 并不另开事务;这是第 18 篇失败注入之所以必须从 REST 注入的用例 bean 进入的关键。如果把外层注解删掉,依赖内部调用 draft 上的注解,不能凭源码推断仍有相同的事务行为。
入口还有会话 Bean 路径:容器管理事务的 Enterprise Bean 用事务属性(例如 REQUIRED、REQUIRES_NEW)与应用异常规则;它与 CDI @Transactional 的异常规则不是同一套注解/默认值。本工程尚无供本篇对照的 @Stateless 探针;不能将 Spring 的“受检异常默认提交”口诀套到 EJB,也不能将 EJB 的 @ApplicationException(rollback=...) 套到 CDI。JPA 的冲突与事务失败可对照JPA 事务与冲突;JPA 的 RESOURCE_LOCAL 测试不是当前 CDI 路径的运行证据。
将异常分类与事务动作分开写
Jakarta Transactions @Transactional 默认的 REQUIRED:无事务则建立、有事务则加入;RuntimeException 及其子类默认导致回滚,受检异常默认不因异常本身导致回滚;可用 rollbackOn、dontRollbackOn 指定例外,两者同时匹配时以 dontRollbackOn 为准。Error 及容器失败需按规范和实际异常传播单独检查,不能拿一个 Java catch (Exception) 包打天下。数据库语句违反约束而事务已被数据库标为 aborted 时,更不能仅因为 Java 捕获了异常就认为可以继续提交。
| 入口与场景 | 预期决策 | 还需要核对什么 |
|---|---|---|
现有 REST → 注入用例 → /abort 抛未捕获 IllegalStateException |
外层受管事务回滚 | HTTP 500、申请/明细终态、服务端故障位置 |
现有 REST → approve,transition 更新 0 行抛运行时冲突 |
受管入口预期回滚,冲突响应由异常映射为 409 | 版本和状态均不变;409 本身不证明回滚 |
拟增 @Transactional 方法抛受检异常且未标 rollbackOn |
不因该异常自动回滚 | 实际数据库/资源是否另已标 rollback-only,最终行集合 |
| 拟增受管方法捕获异常、标记 rollback-only 后返回成功 | 应回滚,但响应若伪报成功则是契约缺陷 | 标记的事务上下文、响应与行集合 |
| 拟增对象直接构造或内部自调注解方法 | 目标方法的拦截不由该调用保证 | 调用轨迹、实际事务有无以及连接获取方式 |
表中第一行已有隔离实测;后四行是待验证的合同,NOT_RUN。第二行 approve 的正常与冲突并发矩阵在此未复测,不能将代码里的更新行数检查称为双人审批验收。标记 rollback-only 需要处在支持的活动事务中;例如受管代码使用可用的 TransactionSynchronizationRegistry.setRollbackOnly(),本工程未添加此探针。不要为了演示把事务异常改写成普通 200 响应再认为数据提交正确。
用现有路径复核,再增加隔离对照
部署、测试库、口令和回环限制参照 examples/javaee-enterprise/README.md。对当前代码已实现的正反入口:
1 | |
第一条场景可核对正常订单状态及表中行,第二条通过已有 /abort 返回 500,并用独立连接验证随机租户申请计数 0。writing-plans/javaee-enterprise/verification/20261004T063400Z-pg16-jta-rollback/RUN.md 有此前的 HTTP/数据库原始记录,但本章没有新运行。要补齐异常矩阵,需先在隔离副本添加受检异常探针、显式 rollback-only 探针和独立的受管调用入口;每一条在抛错前写入独立测试租户,结束后从另一连接查 purchase_request 与 purchase_line。再比对含、去掉注解及内部自调的调用轨迹,禁止用 200/409/500 代替最终行检查。构造违反约束的 SQL 时先回滚失败事务,再开新连接查询,不能在 PG16 的 aborted 事务里继续发断言查询。最小记录字段在异常矩阵实验卡。
冲突异常为什么要从资源层一直追到事务层
当前 transition 的返回值只有两种:恰好更新一行、或者更新行数不是一时抛运行时冲突异常。若 0 行来自申请版本已经变更,读者通常把它理解为乐观并发失败;若传入另一个租户的申请 ID,同样会更新 0 行。数据库层故意不泄露目标究竟不存在、属于其他租户还是版本过期,而 HTTP 层选择 409 还需结合对象权限设计;在正式权限体系落地前,演示头里的租户值可由任何调用者伪造。无论错误要对客户端怎样表达,事务边界都不能把前面已经写下的订单静默提交。理想检查是先确定请求进入哪个用例,再查进入 transition 前做了哪些写入,最后从独立连接读状态与订单集合。
可以构造一条具体的故障时间线:在 order 中先调用 insertOrder,随后 transition 更新申请状态。如果其他事务提前改变了申请版本,后者可能更新 0 行。RequestConflictException 穿出受管用例时,外层事务应将先插入的订单一起回滚;否则出现 purchase_order 有订单而申请状态仍不是 ORDERED 的矛盾。现有源码确实按这个顺序调用,然而目前并没有在插单后、迁移前专门阻断线程的双连接测试;不能因为看到了 409 就宣称这组行集合已获验证。要复现应预先建已批准申请,在测试构建里为指定租户放置仅用于实验的栅栏,让一个线程停在插单后,另一个受管事务改变版本,再释放第一个线程,最后从第三连接查订单/申请;不要依赖随机 sleep 制造假交错。
如果异常出现在 REST 资源的输入校验阶段,受管用例可能根本没有被调用。此时客户端得到 400,数据库仍是旧值,却不是“事务拦截器回滚了两张表”的证据。相反,数据库抛错可能先在 DAO 被捕获并包装成 IllegalStateException,资源层再被异常映射器改为某个 HTTP 状态;真正使事务回滚的是异常穿越受管边界或显式标记 rollback-only,而不是映射器选择 500 还是 409。先记录真实异常链和调用路径,才能决定是否要为受检业务异常额外指定 rollbackOn,不要凭对外状态码猜拦截器的决定。
受检异常、捕获、标记回滚是三种不同决策
假设未来增设“供应商资料尚待核验”的受检异常。如果用例方法在更新了申请之后直接抛出它,@Transactional 默认不会仅因受检异常自动标记回滚;当然,底层资源可能因其他原因已经失败,最终是否提交仍要独立核对。如果业务需求是不允许“资料未通过但状态已改变”,要在方法上针对这一异常声明 rollbackOn,或者把检查移动到写入之前。与之相反,某些运行时异常若被明确列入 dontRollbackOn,可以改变拦截器基于异常类型的标记行为;但这不能撤销数据库自身的约束失败状态,也不是随便把技术异常列进去就能安全提交。
再考虑方法内部 catch (Exception) 后返回一个错误对象的写法。若被捕获的是应用层校验异常、数据库事务仍有效,拦截器只看到正常返回,可能提交先前的写入;若被捕获的是 PostgreSQL 的 SQL 语句错误,该事务可能已进入 aborted 状态,继续提交也未必成功。两种后果完全不同,所以不能用“都被 catch 住了”总结。边界内在需要回滚却又要提供明确业务响应时,应标记当前事务 rollback-only,之后正常退出方法时仍要核对容器如何向调用者报告最终失败;更容易审查的做法通常是抛出明确异常并由事务边界外的资源映射器处理响应。无论哪种实现,都要用第三连接终态验证,不能仅凭代码阅读断言容器运行结果。
对于 @Transactional 方法的内部自调,外层状态决定了执行效果。若外层原本就是受管事务,内部方法中的 JDBC SQL仍可能参加这个已存在的事务;不能写成“内部调用就完全没有事务”。但内部方法上的事务属性本身没有作为第二次拦截入口生效:例如未来给内部方法配置不同的传播策略或超时,这次自调不能因此自动切换。若外层也不受管,则根本没有这个保障。第 18 篇现有故障入口是“有受管外层 + 内部调用”,不能反过来用它证明“无受管外层 + 内部调用”也会回滚。
会话 Bean 的规则应单独做实验
未来若加入 @Stateless 用例,还须区分容器管理事务(CMT)与显式 Bean 管理事务(BMT),并记录调用是否真的通过容器引用。CMT 的事务属性可能让被调用方法加入调用者事务,也可能要求新事务;如果新事务先提交,外层后来抛错无法追溯撤销内层已经提交的记录。Enterprise Beans 对应用异常、系统异常的处理有自己的规则,应用异常是否要求回滚还会受到 @ApplicationException(rollback = ...) 等声明影响。因此“它也是 Jakarta EE”并不足以推出 CDI 事务注解的默认异常矩阵。最小对照应为两个分别拥有确定测试数据的入口:CDI 用例抛一类异常,会话 Bean 用它自己的事务属性及异常声明抛相应异常,均核对完整行集合,而不是比较日志里两个 ERROR 字符串。
超时也是一个独立的失败阶段。请求端先超时,服务器端事务可能仍在继续;事务管理器先判定超时,应用甚至可能还在方法体中执行。要验证事务超时,必须在冻结服务器版本配置实际支持的超时,使用受控栅栏或明确的阻塞语句,让服务端时间线、异常、事务结束点和第三连接终态对齐。客户端 curl --max-time 只限制客户端等待,并没有设置 Jakarta Transactions 的事务超时。这一实验及 BMT 路径均尚未实施,NOT_RUN。把它们和已验证的 /abort 故障区分,才能避免下一步调试时把“用户断开连接”当作“订单一定回滚”。
给矩阵逐行填写证据,而不是给注解打勾
为每一行设置唯一实验租户,先查询三个表的初始行集合;向真实受管入口发送带关联标识的请求,然后保存完整 HTTP 状态、调用日志与异常链,最后从不参与原事务的新连接查同租户申请、明细、订单。若测试直接调用普通 Java 对象,必须明确使用它自己的隔离数据源,不能把它未注入字段导致的空指针误判成“没有事务保护”。若 SQL 出错后原事务已中止,先让该事务回滚,再开新连接读终态;在同一失败会话重复 SELECT 得到的只会是“事务已中止”而非业务终态。暂未实现的矩阵行连入口 URL 都没有,不能复制命令制造假通过。
最后还应区分“没有进入事务的失败”和“事务已运行但没有提交”。前者的输入验证若发生在资源方法调用用例之前,可直接检查调用轨迹是否为空;后者必须保留写入尝试、异常链及独立查询终态。两者最终都可能得到申请数 0,却对应不同根因,处理方式也不同。复盘时把这两种 0 贴在同一张事务回滚成功图上,会让下一次真正的 JDBC 写入故障失去定位依据。实验矩阵应保留正常写入对照,证明目标部署确实具备连接和受管入口,而不是始终没有调用 DAO。
两道练习与答案
练习一: 带 @Transactional 的 order 在插单后捕获 SQLException,记录日志并正常返回订单号。客户端收到 200,是否足以判定写入成功?
解: 不足。被捕获的异常没有按预期穿出拦截器;数据库事务也可能早已进入失败状态;返回值不是独立事务终态。应明确将失败转换成能使事务回滚的异常,或在受管活动事务中标 rollback-only,然后检查提交后订单、状态和响应一致。对 PostgreSQL 的语句错误先终止失败事务;不能在错误之后继续假定 SQL 可提交。
练习二: 把 draftThenAbortForLab 的注解删掉,却保留内部调用 draft 和同样的 IllegalStateException。先前 /abort 的回滚证据还能直接套用吗?
解: 不能。内部调用不重新经过 CDI 受管入口,draft 的注解并不足以保证此路径在同一外层受管事务里。新的失败结果可能与单个 JDBC 连接的自动提交、资源参与等有关,必须在隔离副本实际运行,记录修改前后调用轨迹与申请、明细最终行;不得将已有场景的成功证据挪给改动后的代码。
边界与版本资料
异常规则仅针对相应组件/拦截器的适用路径;数据库提交故障可能使响应结果未知,重试由第 22 篇处理。异常映射的 HTTP 409 只给调用者语义,不代替事务观察。参考:Jakarta Transactions 2.0 @Transactional 规范、Jakarta Enterprise Beans 4.0 Core、Jakarta CDI 4.1 规范、PostgreSQL 16 错误处理。版本限定与未运行项目必须随实验记录保留。






