Java EE 企业应用 14:DAO 接口不能替事务做决定
一张订单插入成功,申请仍停在已批准怎么办
采购用例从 APPROVED 到 ORDERED 要做两件事:写订单、更新申请状态。如果第一条 SQL 成功、第二条因版本冲突失败,最终可能留下订单和申请状态不一致。为每个 SQL 都写一个 DAO 方法,不能自动把两个方法合为一次数据库事务。第 13 篇说明参数和结果如何映射;本章关注 DAO 负责哪一段、调用者负责哪一段,以及在受管 Jakarta EE 11 环境下怎样追踪。先修是能区分请求返回、JDBC 连接关闭和事务提交。
端口描述用例所需的动作,适配器承受 SQL 细节
examples/javaee-enterprise/application/src/main/java/blog/javaee/application/RequestStore.java 是业务侧使用的存储接口;同模块的 ProcurementUseCases.java 从 store.find(id, tenantId) 读取旧申请,调用领域状态机检查,再用 store.transition(request, next) 更新。order 特别先检查是否已经 ORDERED,否则 insertOrder(id, tenantId) 之后才更新状态。DAO 实现 examples/javaee-enterprise/adapters-jdbc/src/main/java/blog/javaee/jdbc/JdbcRequestStore.java 显式写 SQL、绑定参数并把查询结果重建成 ProcurementRequest;应用模块不需要认识 ResultSet 的列下标或 PostgreSQL 的 RETURNING。
接口里实际有 insert、find、transition、insertOrder、findOrder 五个动作。没有“开始审批事务”或“提交采购订单事务”这样的方法;业务是否能拆成两个数据库更新,取决于用例的业务边界。find 使用租户与 ID 两个条件,transition 在此基础上比较原状态、原版本,insertOrder 受数据库 UNIQUE(request_id) 约束;职责各不相同,不能把它们笼统地称为一个可互换的 save。findOrder 只在订单已存在的分支查询原订单编号,提供重试时返回同一 ID 的应用路径,仍没有承诺网络回复一定送达。即便未来添加另一个 DAO,比如审计记录,也必须先确定审计是否要和订单同一事务写入、失败时谁决定回滚,不能靠多一个接口类代替这个决定。
当前工程的适配器是 JDBC,并没有一个随项目提供的可切换内存 RequestStore 实现。接口让后续替身有接入位置,却不意味着“从内存切换 JDBC”已经有测试与记录。若要做这种对照,必须先实现独立替身,使用相同申请金额、状态、租户与订单唯一性的契约测试;内存中抛异常的结果仍不能替代数据库约束和事务验证。已有 domain/src/test/java/blog/javaee/domain/ProcurementRulesTest.java 只测纯领域转换,并未将 RequestStore 两个实现并列验收。
替身测试如果只在单线程里按 insert → find → transition 顺序运行,最容易遗漏两个操作者读同一版本再同时写的情况。内存实现可用锁或映射提供与数据库完全不同的并发语义;不能因为两种实现同属 RequestStore 就认为它们的冲突规则天然等价。可共享的契约至少要断言:另一租户用相同 ID 查询返回不可见,旧版本更新失败且原状态不变,已下单申请重试返回同一个订单 ID,未知或非法状态不能生成订单。但这套契约中纯内存的“失败后状态不变”,只能表明替身自己没更新;数据库侧还需独立会话核对已提交行、SQLState 和约束。替身的实现、并行运行结果和新测试文件都不存在,保留为待实施的最小扩展。
数据访问接口回答“能读写什么”,事务入口回答“哪些读写一起成败”。JdbcRequestStore.insert 用一条借来的连接执行申请与明细插入;order 调用 find、insertOrder、transition 时,每个 DAO 方法各自从受管 DataSource 借用访问句柄。在受管事务里,物理连接复用及事务参与由服务器与驱动协调,不能因为几个 Java 引用的 == 不同就推断有几个已提交事务,也不能因为都从同一个 DataSource 来就认定没有部分提交。当前 ProcurementUseCases.order 标有 @Transactional,验收仍应从经 CDI 受管引用进入的调用、异常与独立数据库终态着手,而不是在 DAO 每个方法里自行 commit()。连接关闭是资源责任,不是决定整笔采购何时提交的 DAO 权限。
如果一个 DAO 擅自调用 setAutoCommit(true) 或在中途 commit(),调用方的 @Transactional 将很难再保证“订单与状态同时失败”;当前 JdbcRequestStore 中没有这些显式提交调用。这里的陈述只关乎已看过的源码,不代表所有受管数据源在不同服务器上自动加入容器事务。检查时先确认部署配置的 DataSource 是否受管、调用是否通过 CDI 入口、数据库驱动与服务器是否按本次资源契约参加事务,然后从另一条连接观察提交之前与之后的行集。若绕开容器直接 new ProcurementUseCases(...) 调同名方法,注解本身不建立受管入口;即便 SQL 值看起来相同,事务边界也不能类推。第 18–19 篇将专门审查属性与异常分类,本篇只把多 DAO 操作归还给用例层管理。
拿两个时间点作区分更直观:T1 从 find 得到旧状态和版本,T2 在它写订单之前完成了同申请的变化。insertOrder 的 ON CONFLICT(request_id) DO NOTHING RETURNING id 能防止重复保存相同申请的订单,但不能替 T1 证明读取的申请版本仍有效。随后 transition 的 WHERE ... status=? AND version=? 如果更新零行,应用会抛冲突。最终 T1 插入的订单要不要留下,取决于同一有效事务能否在异常时撤销;光看两条 SQL 各自的返回值不能回答。还需关注 T2 到底修改了哪一行、是否选择了拒绝、订单何时成为对另一连接可见。不能用顺序重试得到了同一个 ID 来填这组交错的结果。
如果把任意 SQLException 都包装为“找不到申请”,失败将失去类别:没有行由 RequestMissingException 表示;旧版本的更新零行由 RequestConflictException 表示;SQL 失败被包装为带原 SQLException cause 的访问故障。HTTP 的 MissingMapper、ConflictMapper 可以映射应用异常,但一条 404 或 409 不证明已经回滚;还需保留 SQLState、用例入口与独立连接读到的状态、订单数量。当前 transition 的 WHERE 条件包含 id、tenant_id、原状态、原版本,更新零行即拒绝陈旧快照。它保护这一行的版本,却不自动实现跨表提交原子性。
数据库表约束还暴露一个接口外的风险:当前 purchase_order 有 request_id 外键和自身 tenant_id 字段,但数据库外键只按申请 ID 关联,没有复合约束强制订单租户等于父申请租户。通过 ProcurementUseCases.order 进入时,会先按 (id,tenantId) 查申请,随后按相同参数写订单,这是当前路径的业务约束;直接绕开用例的 SQL 可以写出两表租户不一致的行。接口让应用内部按照规则调用,不能把调用规则误认为数据库在任何写入路径都强制成立。若要补足数据库层约束,必须设计迁移、现存数据清理及并发写入验证;不能为了本章的文章演示就修改共享工程或生产库。
这里与先行 JPA 01:实体身份与工作单元 的边界不同:在同一持久化上下文中再 find 同一实体,JPA 有实体身份和托管状态规则;当前 JDBC 适配器每次显式构造新的 ProcurementRequest 值,既没有持久化上下文,也不会在修改对象字段时自动 flush。JPA 一次六项 RESOURCE_LOCAL 测试的身份观察,不为 JDBC 的 Connection 参与容器事务或两个 DAO 方法的原子性提供证据。两个实现要共用的是业务契约,不是对象身份机制。
用不同结果回答不同问题
正常、现有故障脚本与尚无入口的订单两步间故障拆分见DAO 边界实验卡;复跑前要核对当次部署的 WAR,而不是借用上一轮结果。
已运行的顺序场景是 examples/javaee-enterprise/scenarios/09-lab-procurement.sh。隔离环境中一次调用按 draft → submit → approve → order → retry order 执行,writing-plans/javaee-enterprise/verification/20261004T062900Z-pg16-business/scenario.stdout.txt 记录订单重试返回同一 ID,独立数据库查询得到 ORDERED|13.75|1。这说明那一次用例经已部署路径满足终态,不意味着订单插入之后、申请状态更新之前注入故障也会回滚。后续 20261004T063400Z-pg16-jta-rollback/RUN.md 是 draftThenAbortForLab 在插入申请/明细之后人为抛错的受管事务实验,独立连接看到申请行数零;它验证的是创建路径的这一次回滚,不是 order 两个 DAO 动作之间的失败路径。
这两个实际脚本在仓库内可直接找到,正常与失败对照的复跑命令如下;在两次请求前须完成 README 中的专用库迁移、WAR 部署、仅监听回环地址和服务器的 JAVAEE_DEMO_MODE=true 设置,并在本地环境提供正确密码,不在日志里回显:
1 | |
第一条脚本的正常判据是草稿 201,提交、批准 204,下单与重试 200,两个订单 ID 相同,最后 ORDERED|13.75|1;它还检查“未批准先下单”和“已下单后拒绝”的 409,这是业务前置条件失败,不是订单两 SQL 中途崩溃。第二条的期望是人工注入之后 HTTP 500,用另一数据库连接看同一随机租户申请行数为 0;强化版脚本记录序列推进以区别“写入过后回滚”与“根本没写入”。保存脚本各自退出码、租户与申请 ID、调用时部署的 WAR 摘要及服务器异常窗口;先验错误端口只会测到连接失败,不能拿来推断 DAO 事务效果。归档记录对应的是运行当时的源码 SHA,不直接认证目前工作树。
本章针对 DAO 组合的故障验收仍为 NOT_RUN:在仅供隔离的演示实例中,在 insertOrder 返回后、transition 前加入可控失败点,以已批准的随机租户申请为输入;保存请求与 WAR SHA、注入事件、事务日志、SQLState 和独立连接最终行,预期订单与状态均保持注入前状态。还要以独立并发屏障让两个操作者基于相同版本分别批准/拒绝,核对只有一个状态成功;固定的单线程重试不能模拟交错。没有这个入口时不应虚构 curl 命令;不要为了让实验容易成功改动共用数据库或生产配置。
增加故障探针后要区分“抛异常”与“让事务回滚”两个观察。如果异常被 Web 资源捕获后转换为普通返回值,受管事务是否仍被标记为回滚需要重新分析,不能只要求客户端收到了 HTTP 500。正确的失败矩阵包含:订单写入前拒绝、订单已写但申请更新前故意抛错、版本更新返回零行、数据库断连后结果未知。每一行分别收集异常链、两表最终行、版本和订单约束冲突值;其中第三种在两名调用者交错时才有意义,不能只用一个用户顺序重放触发。资源关闭也不代替提交:DAO 的 try-with-resources 正常执行完,只证明本次借用结束,另一个数据库会话是否看到提交仍需等用例边界结束再查。项目目前没有这些故障注入入口及原始结果,本篇不会填模拟日志。
界定数据访问边界时还要确定谁可以调用 RequestStore。若将 JDBC 适配器直接从未认证的 Web 资源暴露出来,调用方可以自报 tenantId、绕过 ProcurementUseCases 的目录定价和状态检查;DAO 的租户谓词不能替代可信身份来源。当前实验资源 LabRequestsResource 明确由演示开关限定在本机,并通过 X-Lab-Tenant 接收不可信租户值,顺序实验的跨租户 404 只测试了给定参数的 SQL 谓词。正式申请提交、审批与下单还需由容器身份与对象权限保证调用资格。接口隔离的是 SQL 实现细节,不会自动封锁安全边界或事务入口;这两种能力要分别设计和验收。
“订单与状态共同提交”的边界只覆盖同一个数据库中的相关写入。未来若在 insertOrder 之后直接发送外部通知,即使数据库事务回滚,也不能撤回已经发出的 HTTP 调用或消息;若先通知后提交,还可能让消费者收到根本不存在的订单。因此本篇的验收表只记录申请行、订单行与数据库事务,通知必须等到 outbox 与消息章节定义独立的持久化任务和重投判据。已有 scenarios/09-lab-procurement.sh 没有调用外部供应商或通知系统,更不能把一次 ORDERED|13.75|1 当成跨系统原子提交的证明。
诊断重复订单时也不要只依赖应用日志中的两次返回 ID:第一次调用如果在提交后、应答前断开,客户端会看到“未知结果”,第二次按申请 ID 重试才能确认是否已有订单。数据库的 UNIQUE(request_id) 保证该表至多一行,用例在 ORDERED 分支查原订单;但如果中间状态和订单写入不在同一受管事务,应用可能看到订单存在而申请仍为 APPROVED。保留请求时间线、事务提交状态和两表最终行,才能解释重试何以返回同一 ID。当前脚本只覆盖正常顺序重试,没有模拟提交确认丢失。
两道练习与答案
练习一:insertOrder 返回订单 ID,紧接着 store.transition 更新零行。DAO 是否应该自己提交订单,让“至少一张表写成功”?要保存哪些证据?
答案:不能擅自分开提交。如果业务要求申请状态与订单一致,受管用例必须使两步处于受控事务边界;版本冲突应导致此次尝试失败并检查是否回滚。记录旧状态与版本、订单行数、异常链、事务范围和独立连接终态。当前顺序正常场景不能替代这次故障实验,故障结论 NOT_RUN。
练习二:内存版 RequestStore 单测让同一申请只产生一张订单,就能宣布 PostgreSQL 下并发审批安全了吗?
答案:不能。替身只能测试用例如何调用接口,数据库实际的 UNIQUE(request_id)、UPDATE ... WHERE version=? 和多 SQL 的事务归属需在 PostgreSQL 与容器中复核;两事务交错还需同步屏障和最终行。当前连并列运行的内存适配器契约测试都不存在,不应标成 PASS。
边界与资料
DAO 与领域、JPA 既有文章只提供不同实现的责任对照。隔离环境实际记录只覆盖顺序订单与创建后故障回滚;订单两步间故障、同版本并发、内存适配器及不同异常的回滚矩阵都未运行。官方入口:Jakarta EE 11 Platform 事务与组件环境、Jakarta Transactions 2.0、Java SE 21 Connection、PostgreSQL 16 约束。注解存在、驱动方法返回和库中终态是三种不同层级的证据。






