深入 Spring E03:Repository 代理如何叠加查询与事务
接口没有实现类,事务却不能省略归属
继承 JpaRepository 后,saveAndFlush 可以把实体写入数据库;自定义 findByLabel 能执行查询;增加一个带 @Modifying 的更新方法,却可能立即抛出事务异常。这三个方法都从同一个 Repository 引用调用,默认事务行为仍然不同。
Repository 接口描述的操作由代理、查询执行器与基础实现共同完成。事务配置是这条调用链中的另一层规则,不会因为一个接口继承 JpaRepository,就自动给每个后来声明的方法赋予相同事务属性。
实验固定 JDK 21、Boot 3.5.6 BOM、Framework 6.2.11、Spring Data JPA/Commons 3.5.4、Hibernate 6.6.29.Final、pgJDBC 42.7.7 和 PostgreSQL 18.0。独立 ecosystem-lab 使用这组实际解析依赖,不复用主线 ORM 实验中另一个 Hibernate 补丁版本,也不把两个版本的结果混合。
Repository 代理上叠加了哪些职责
实验声明 PurchaseRepository extends JpaRepository,并通过 @EnableJpaRepositories 注册。运行时断言它是 JDK 动态代理,同时打印 Advised 中实际存在的 advisors。接口没有手写实现,并不表示调用没有目标:基础 CRUD 方法可以委托给 SimpleJpaRepository,查询方法则由对应的查询执行路径处理。
SimpleJpaRepository 在类上声明只读事务,写入方法重新声明事务属性。其 save 会依据实体是否为新实体选择 persist 或 merge;saveAndFlush 再触发 flush。实验使用显式 Long 主键,不能把每次 save 都想象成一条无前置判断的 INSERT。SimpleJpaRepository 3.5.4
代理还可能承担异常转换、元数据暴露和接口默认方法分派等职责。因此,调试时打印“这是一个 JDK proxy”只完成了第一步;下一步应区分失败发生在查询解析、事务拦截、JPA 执行还是数据库约束。
代理机制本身没有改变方法调用与资源绑定的关系。Spring 事务拦截器先根据调用决定是否建立事务,再执行后续调用链。事务管理器选择的 EntityManagerFactory、DataSource 及线程绑定资源,才决定后续 SQL 实际参与哪个事务。Framework 固定事务拦截源码
派生查询与声明查询走不同入口
findByLabel(String label) 根据属性路径派生查询。byDeclaredQuery(String label) 则使用明确的 JPQL:select p from Purchase p where p.label = :label。二者在冻结实验中都返回同一条 original 记录,StatementInspector 都观察到按 label 过滤的实际 SQL。
派生查询依赖实体属性名,不依赖数据库列名的任意拼写。JPQL 也使用实体及其属性模型;原生 SQL 查询才直接针对数据库表列。表命名策略、字段映射和参数绑定使这几个命名空间可能不同,不能从方法名直接拼出最终数据库 SQL。JPA 查询方法
如果方法名不支持所需语义,显式查询可以减少过长的命名,但仍需要检查返回类型、单结果/集合语义、分页计数查询和参数绑定。当前实验只验证简单集合查询,没有给分页、复杂联接或批量读取提供性能结论。
SQL 观测来自 Hibernate StatementInspector。每次回调记录 SQL 字符串,以及当时 Spring 线程上的 actualTransactionActive 和 readOnly。它证明 Hibernate 准备执行哪种 SQL、框架线程标记是什么,不是数据库服务器的慢日志,也不直接给出执行计划和网络往返耗时。
为什么 findAll 与 findByLabel 的事务标签不同
不带外部事务调用 findAll,日志记录 tx=true readOnly=true。它继承了基础实现上的只读事务配置。调用声明在业务 Repository 接口中的 findByLabel,日志则记录 tx=false readOnly=false;显式 JPQL 查询得到相同事务标签。
这与 Spring Data 的事务约定一致:自行声明的查询方法默认不继承基础 CRUD 方法的事务配置。若业务需要一组查询保持在同一个事务中,可以在外部服务边界声明事务,或者为具体 Repository 方法显式配置。Data JPA 3.5 事务文档
tx=false 的含义必须限定为没有观察到 Spring 管理的实际事务。数据库仍然需要以自己的协议执行语句,连接也有自动提交等状态。把这个标签写成“数据库完全没有事务”会跨越观测边界。
同样,readOnly 是事务属性和底层优化线索,不应当被统一解释为所有数据库操作都被强制禁止写入。不同管理器、驱动和数据库的只读实现需要分别检查。这个实验没有用写失败来证明只读强制性,只验证基础方法与外层事务之间的属性选择。
Modifying 改变执行方式,不自动创建事务
rename 方法带有 @Modifying 和更新 JPQL,但没有 @Transactional。直接调用时,实验捕获到 InvalidDataAccessApiUsageException,断言该调用失败。这个失败保留在测试里,不能通过吞掉异常后宣称更新成功。
@Modifying 让查询走更新执行路径,使用 executeUpdate,而不是按普通选择查询取结果。开始、加入和完成事务仍是事务基础设施的职责。JpaQueryExecution.ModifyingExecution 处理更新执行以及可选 flush/clear,并不在内部替代 TransactionInterceptor 开启业务事务。JpaQueryExecution 3.5.4
把 rename 放进 TransactionTemplate 后,更新成功。这一外部边界还可以把多个 Repository 调用组合为一个业务事务,而不是让每个 CRUD 方法各自提交。订单创建和审计写入需要原子完成时,事务应覆盖完整操作,不能只检查两次 save 都返回了对象。
本实验使用编程式事务,目的是显式展示开始、回滚标记和完成位置。换成服务 Bean 上的 @Transactional,仍需满足代理调用、管理器选择和线程边界条件,语法缩短不会取消这些前提。
saveAndFlush 之后,独立连接看到了什么
实验先在没有外层事务时调用 saveAndFlush 写入编号 1。方法返回后,独立 JDBC 连接观察到一行,说明该次基础方法事务已经提交。
随后,TransactionTemplate 中调用 saveAndFlush 写入编号 5。方法返回后,在外部事务完成之前,独立连接仍然看到零行;外部事务完成之后,才看到一行。flush 已经促使 SQL 发往数据库,但当前事务尚未对其他连接提交。
观察者每次通过 DriverManager 创建独立连接,没有借用事务正在使用的 EntityManager,也没有通过可能复用线程绑定连接的 JdbcTemplate 查询。因此,它不会把事务内自己的写入当成外部已可见的数据。
这两个实验只改变外层事务是否存在,保留同一个 Repository 方法。一个方法名中的 Flush 不足以推断提交时刻;提交由最外层实际拥有完成责任的事务边界决定。数据库隔离、锁和并发冲突则可能进一步影响其他事务的观察,当前单写者实验没有替代那些并发验证。
Repository、JdbcTemplate 与原生 JPA 共享一次回滚
同一 TransactionTemplate 中,Repository 写编号 2,JdbcTemplate 写编号 3,Spring 共享 EntityManager 写编号 4。JPA 操作显式 flush 后,事务内的 JdbcTemplate 能读到三条记录,独立观察者仍看不到其中任何一条。
设置 rollback-only 并结束事务后,观察者确认三个编号都为零行。这个结果要求所有路径确实参与同一个本地资源事务。JpaTransactionManager 可以把 JPA 使用的 JDBC 连接暴露给同一 DataSource 的事务感知 JDBC 访问;JdbcTemplate 通过 DataSourceUtils 获得参与连接。JpaTransactionManager 6.2.11
实验只配置一个 EntityManagerFactory、一个 HikariDataSource 和一个 JpaTransactionManager,Hibernate 的 JPA dialect 支持这里的连接访问。把相同代码复制到第二个 DataSource,或者直接从普通连接池另借连接,不会因为 SQL 写在同一个 lambda 内就自动共享回滚。
事务内调用 findAll 也给出了属性对照:日志变成 tx=true readOnly=false。外层 TransactionTemplate 已经建立读写事务,内部基础查询加入它,不会把现有事务临时变成另一个只读事务。调用链里的每一个注解都不等于一个新的数据库事务。
批量更新为何让已加载实体保持旧值
在一个事务里,先用 EntityManager.find 加载编号 1,内存中的 label 为 original。Repository.rename 通过批量 JPQL 把数据库中的 label 更新成 bulk,之前拿到的 managed 对象仍然返回 original。实验对这个旧值作出明确断言。
批量更新绕过逐个受管实体的状态变更路径,不会自动把已有 Java 对象逐个改写。调用 EntityManager.clear,再次 find,才读到 bulk。问题不在于更新 SQL 没有执行,而在于持久化上下文中已有实例与数据库状态的同步范围。
@Modifying 的 clearAutomatically 可以调整执行后是否清理上下文,但清理还涉及未 flush 的本地修改。若应用同时持有未写出的实体变更,盲目 clear 可能丢弃它们。清理策略应与 flush 时机共同设计,不能仅为了让一次读取变新就全局清空所有状态。
这里把 ORM 状态问题限定在观察到的批量更新案例。脏检查、动作队列、抓取策略和 SQL 排序属于 Hibernate 的更深层机制,不能用 Repository 代理解释全部 ORM 行为。Hibernate 6.6 用户指南
原生 EntityManager 与 JdbcTemplate 的对照
实验最后创建一个原生 EntityManager,直接调用 getTransaction().begin、persist、flush 和 commit。flush 后观察者看不到编号 6,commit 后能看到。此时 SQL 日志记录 Spring 的 tx=false,但 JPA 资源事务确实存在,这是线程框架标记不能代替数据库事务事实的直接反例。
另一次 JdbcTemplate 写入位于 Spring 事务之外。Hikari 默认自动提交连接使编号 7 在语句结束后对独立连接可见。这里没有 Repository 代理,也没有 JPA 持久化上下文;JdbcTemplate 仍提供参数绑定、连接释放和异常转换。
选择哪层抽象应由需要的状态模型与查询控制决定。Repository 减少重复数据访问接口实现,JPA 管理实体状态,JdbcTemplate 直接表达 SQL;三者并非互相排斥,但组合时要把同一事务资源与不同状态缓存分开检查。
运行并修改一次事务属性
从系列入口获取实验包,使用 JDK 21,在 examples/spring-framework-lab/ 执行:
1 | |
数据库默认地址为 jdbc:postgresql://127.0.0.1:55432/spring_lab,用户 spring_lab,密码为空。可用 README 中的环境变量修改。程序仅创建并清空实验专属 spring_e03.purchase;应连接独立实验库。
基线通过 20 条断言,实际 SQL、观察者结果和源码/POM 哈希保存在 evidence/E03/local-20261002/。结束时同时验证连接池已关闭和 EntityManagerFactory 不再开放,数据库服务本身由外部管理。
练习是在 findByLabel 上添加 @Transactional(readOnly = true),重新编译运行。该方法的 SQL 标签应改为 tx=true readOnly=true,结果记录仍为一条;原来的“无默认事务”断言应调整为新配置的预期。byDeclaredQuery 未修改,仍应保持基线标签。这个变体未计入当前实跑结果。
系列主线的容器、代理与事务前提见 深入 Spring(00):从手动组装到可验证的容器实验。比较 Repository 行为时,至少保留一条实际 SQL、一条事务状态记录和一条独立连接可见性断言,才能把接口调用与数据库结果对应起来。


