From 3b4c7a890608bf006d0424355ad82cf8815c98da Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Thu, 7 Dec 2023 23:14:17 +0100 Subject: [PATCH] Revise LazyConnectionDataSourceProxy for late connection properties check Includes special support for a read-only DataSource in addition to the regular target DataSource, avoiding the overhead of switching the Connection's read-only flag at the beginning and end of every transaction. Closes gh-29931 Closes gh-31785 Closes gh-19688 Closes gh-21415 --- .../pages/data-access/jdbc/connections.adoc | 13 ++ .../ROOT/pages/data-access/orm/hibernate.adoc | 6 + .../ROOT/pages/data-access/orm/jpa.adoc | 15 +- .../LazyConnectionDataSourceProxy.java | 102 +++++++++---- .../DataSourceTransactionManagerTests.java | 144 +++++++++++++----- 5 files changed, 208 insertions(+), 72 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/data-access/jdbc/connections.adoc b/framework-docs/modules/ROOT/pages/data-access/jdbc/connections.adoc index 54d0c1243f61..a448b8121f44 100644 --- a/framework-docs/modules/ROOT/pages/data-access/jdbc/connections.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/jdbc/connections.adoc @@ -230,6 +230,19 @@ provided you stick to the required connection lookup pattern. Note that JTA does savepoints or custom isolation levels and has a different timeout mechanism but otherwise exposes similar behavior in terms of JDBC resources and JDBC commit/rollback management. +For JTA-style lazy retrieval of actual resource connections, Spring provides a +corresponding `DataSource` proxy class for the target connection pool: see +{spring-framework-api}/jdbc/datasource/LazyConnectionDataSourceProxy.html[`LazyConnectionDataSourceProxy`]. +This is particularly useful for potentially empty transactions without actual statement +execution (never fetching an actual resource in such a scenario), and also in front of +a routing `DataSource` which means to take the transaction-synchronized read-only flag +and/or isolation level into account (e.g. `IsolationLevelDataSourceRouter`). + +`LazyConnectionDataSourceProxy` also provides special support for a read-only connection +pool to use during a read-only transaction, avoiding the overhead of switching the JDBC +Connection's read-only flag at the beginning and end of every transaction when fetching +it from the primary connection pool (which may be costly depending on the JDBC driver). + NOTE: As of 5.3, Spring provides an extended `JdbcTransactionManager` variant which adds exception translation capabilities on commit/rollback (aligned with `JdbcTemplate`). Where `DataSourceTransactionManager` will only ever throw `TransactionSystemException` diff --git a/framework-docs/modules/ROOT/pages/data-access/orm/hibernate.adoc b/framework-docs/modules/ROOT/pages/data-access/orm/hibernate.adoc index a782b9165e01..45328b85cd86 100644 --- a/framework-docs/modules/ROOT/pages/data-access/orm/hibernate.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/orm/hibernate.adoc @@ -407,6 +407,12 @@ exposes the Hibernate transaction as a JDBC transaction if you have set up the p `DataSource` for which the transactions are supposed to be exposed through the `dataSource` property of the `HibernateTransactionManager` class. +For JTA-style lazy retrieval of actual resource connections, Spring provides a +corresponding `DataSource` proxy class for the target connection pool: see +{spring-framework-api}/jdbc/datasource/LazyConnectionDataSourceProxy.html[`LazyConnectionDataSourceProxy`]. +This is particularly useful for Hibernate read-only transactions which can often +be processed from a local cache rather than hitting the database. + [[orm-hibernate-resources]] == Comparing Container-managed and Locally Defined Resources diff --git a/framework-docs/modules/ROOT/pages/data-access/orm/jpa.adoc b/framework-docs/modules/ROOT/pages/data-access/orm/jpa.adoc index 9c7a0a4da7fb..0d47b4758dcc 100644 --- a/framework-docs/modules/ROOT/pages/data-access/orm/jpa.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/orm/jpa.adoc @@ -506,13 +506,20 @@ if you have not already done so, to get more detailed coverage of Spring's decla The recommended strategy for JPA is local transactions through JPA's native transaction support. Spring's `JpaTransactionManager` provides many capabilities known from local JDBC transactions (such as transaction-specific isolation levels and resource-level -read-only optimizations) against any regular JDBC connection pool (no XA requirement). +read-only optimizations) against any regular JDBC connection pool, without requiring +a JTA transaction coordinator and XA-capable resources. Spring JPA also lets a configured `JpaTransactionManager` expose a JPA transaction to JDBC access code that accesses the same `DataSource`, provided that the registered -`JpaDialect` supports retrieval of the underlying JDBC `Connection`. -Spring provides dialects for the EclipseLink and Hibernate JPA implementations. -See the xref:data-access/orm/jpa.adoc#orm-jpa-dialect[next section] for details on the `JpaDialect` mechanism. +`JpaDialect` supports retrieval of the underlying JDBC `Connection`. Spring provides +dialects for the EclipseLink and Hibernate JPA implementations. See the +xref:data-access/orm/jpa.adoc#orm-jpa-dialect[next section] for details on `JpaDialect`. + +For JTA-style lazy retrieval of actual resource connections, Spring provides a +corresponding `DataSource` proxy class for the target connection pool: see +{spring-framework-api}/jdbc/datasource/LazyConnectionDataSourceProxy.html[`LazyConnectionDataSourceProxy`]. +This is particularly useful for JPA read-only transactions which can often +be processed from a local cache rather than hitting the database. [[orm-jpa-dialect]] diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java index fec0dd8b454c..0fcb8b92e97b 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.java @@ -37,20 +37,27 @@ * Proxy for a target DataSource, fetching actual JDBC Connections lazily, * i.e. not until first creation of a Statement. Connection initialization * properties like auto-commit mode, transaction isolation and read-only mode - * will be kept and applied to the actual JDBC Connection as soon as an - * actual Connection is fetched (if ever). Consequently, commit and rollback - * calls will be ignored if no Statements have been created. + * will be kept and applied to the actual JDBC Connection as soon as an actual + * Connection is fetched (if ever). Consequently, commit and rollback calls will + * be ignored if no Statements have been created. As of 6.1.2, there is also + * special support for a {@link #setReadOnlyDataSource read-only DataSource} to use + * during a read-only transaction, in addition to the regular target DataSource. * *

This DataSource proxy allows to avoid fetching JDBC Connections from * a pool unless actually necessary. JDBC transaction control can happen * without fetching a Connection from the pool or communicating with the * database; this will be done lazily on first creation of a JDBC Statement. + * As a bonus, this allows for taking the transaction-synchronized read-only + * flag and/or isolation level into account in a routing DataSource (e.g. + * {@link org.springframework.jdbc.datasource.lookup.IsolationLevelDataSourceRouter}). * *

If you configure both a LazyConnectionDataSourceProxy and a * TransactionAwareDataSourceProxy, make sure that the latter is the outermost * DataSource. In such a scenario, data access code will talk to the * transaction-aware DataSource, which will in turn work with the - * LazyConnectionDataSourceProxy. + * LazyConnectionDataSourceProxy. As of 6.1.2, LazyConnectionDataSourceProxy will + * initialize its default connection characteristics on first Connection access; + * to enforce this on startup, call {@link #checkDefaultConnectionProperties()}. * *

Lazy fetching of physical JDBC Connections is particularly beneficial * in a generic transaction demarcation environment. It allows you to demarcate @@ -79,6 +86,8 @@ * @author Sam Brannen * @since 1.1.4 * @see DataSourceTransactionManager + * @see #setTargetDataSource + * @see #setReadOnlyDataSource */ public class LazyConnectionDataSourceProxy extends DelegatingDataSource { @@ -96,15 +105,19 @@ public class LazyConnectionDataSourceProxy extends DelegatingDataSource { private static final Log logger = LogFactory.getLog(LazyConnectionDataSourceProxy.class); @Nullable - private Boolean defaultAutoCommit; + private DataSource readOnlyDataSource; @Nullable - private Integer defaultTransactionIsolation; + private volatile Boolean defaultAutoCommit; + + @Nullable + private volatile Integer defaultTransactionIsolation; /** * Create a new LazyConnectionDataSourceProxy. * @see #setTargetDataSource + * @see #setReadOnlyDataSource */ public LazyConnectionDataSourceProxy() { } @@ -112,6 +125,7 @@ public LazyConnectionDataSourceProxy() { /** * Create a new LazyConnectionDataSourceProxy. * @param targetDataSource the target DataSource + * @see #setTargetDataSource */ public LazyConnectionDataSourceProxy(DataSource targetDataSource) { setTargetDataSource(targetDataSource); @@ -119,12 +133,30 @@ public LazyConnectionDataSourceProxy(DataSource targetDataSource) { } + /** + * Specify a variant of the target DataSource to use for read-only transactions. + *

If available, a Connection from such a read-only DataSource will be lazily + * obtained within a Spring-managed transaction that has been marked as read-only. + * The {@link Connection#setReadOnly} flag will be left untouched, expecting it + * to be pre-configured as a default on the read-only DataSource, avoiding the + * overhead of switching it at the beginning and end of every transaction. + * Also, the default auto-commit and isolation level settings are expected to + * match the default connection properties of the primary target DataSource. + * @since 6.1.2 + * @see #setTargetDataSource + * @see #setDefaultAutoCommit + * @see #setDefaultTransactionIsolation + * @see org.springframework.transaction.TransactionDefinition#isReadOnly() + */ + public void setReadOnlyDataSource(@Nullable DataSource readOnlyDataSource) { + this.readOnlyDataSource = readOnlyDataSource; + } + /** * Set the default auto-commit mode to expose when no target Connection * has been fetched yet (when the actual JDBC Connection default is not known yet). - *

If not specified, the default gets determined by checking a target - * Connection on startup. If that check fails, the default will be determined - * lazily on first access of a Connection. + *

If not specified, the default gets determined by checking lazily on first + * access of a Connection. * @see java.sql.Connection#setAutoCommit */ public void setDefaultAutoCommit(boolean defaultAutoCommit) { @@ -156,9 +188,8 @@ public void setDefaultTransactionIsolationName(String constantName) { * {@link java.sql.Connection} interface; it is mainly intended for programmatic * use. Consider using the "defaultTransactionIsolationName" property for setting * the value by name (for example, {@code "TRANSACTION_SERIALIZABLE"}). - *

If not specified, the default gets determined by checking a target - * Connection on startup. If that check fails, the default will be determined - * lazily on first access of a Connection. + *

If not specified, the default gets determined by checking lazily on first + * access of a Connection. * @see #setDefaultTransactionIsolationName * @see java.sql.Connection#setTransactionIsolation */ @@ -169,12 +200,13 @@ public void setDefaultTransactionIsolation(int defaultTransactionIsolation) { } - @Override - public void afterPropertiesSet() { - super.afterPropertiesSet(); - - // Determine default auto-commit and transaction isolation - // via a Connection from the target DataSource, if possible. + /** + * Determine default auto-commit and transaction isolation + * via a Connection from the target DataSource, if possible. + * @since 6.1.2 + * @see #checkDefaultConnectionProperties(Connection) + */ + public void checkDefaultConnectionProperties() { if (this.defaultAutoCommit == null || this.defaultTransactionIsolation == null) { try { try (Connection con = obtainTargetDataSource().getConnection()) { @@ -190,14 +222,11 @@ public void afterPropertiesSet() { /** * Check the default connection properties (auto-commit, transaction isolation), * keeping them to be able to expose them correctly without fetching an actual - * JDBC Connection from the target DataSource. - *

This will be invoked once on startup, but also for each retrieval of a - * target Connection. If the check failed on startup (because the database was - * down), we'll lazily retrieve those settings. + * JDBC Connection from the target DataSource later on. * @param con the Connection to use for checking * @throws SQLException if thrown by Connection methods */ - protected synchronized void checkDefaultConnectionProperties(Connection con) throws SQLException { + protected void checkDefaultConnectionProperties(Connection con) throws SQLException { if (this.defaultAutoCommit == null) { this.defaultAutoCommit = con.getAutoCommit(); } @@ -233,6 +262,7 @@ protected Integer defaultTransactionIsolation() { */ @Override public Connection getConnection() throws SQLException { + checkDefaultConnectionProperties(); return (Connection) Proxy.newProxyInstance( ConnectionProxy.class.getClassLoader(), new Class[] {ConnectionProxy.class}, @@ -251,6 +281,7 @@ public Connection getConnection() throws SQLException { */ @Override public Connection getConnection(String username, String password) throws SQLException { + checkDefaultConnectionProperties(); return (Connection) Proxy.newProxyInstance( ConnectionProxy.class.getClassLoader(), new Class[] {ConnectionProxy.class}, @@ -400,6 +431,11 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl } } + if (readOnlyDataSource != null && "setReadOnly".equals(method.getName())) { + // Suppress setReadOnly reset call in case of dedicated read-only DataSource + return null; + } + // Target Connection already fetched, // or target Connection necessary for current operation -> // invoke method on target connection. @@ -429,15 +465,15 @@ private Connection getTargetConnection(Method operation) throws SQLException { } // Fetch physical Connection from DataSource. - this.target = (this.username != null) ? - obtainTargetDataSource().getConnection(this.username, this.password) : - obtainTargetDataSource().getConnection(); - - // If we still lack default connection properties, check them now. - checkDefaultConnectionProperties(this.target); + DataSource dataSource = getDataSourceToUse(); + this.target = (this.username != null ? dataSource.getConnection(this.username, this.password) : + dataSource.getConnection()); + if (this.target == null) { + throw new IllegalStateException("DataSource returned null from getConnection(): " + dataSource); + } // Apply kept transaction settings, if any. - if (this.readOnly) { + if (this.readOnly && readOnlyDataSource == null) { try { this.target.setReadOnly(true); } @@ -450,7 +486,7 @@ private Connection getTargetConnection(Method operation) throws SQLException { !this.transactionIsolation.equals(defaultTransactionIsolation())) { this.target.setTransactionIsolation(this.transactionIsolation); } - if (this.autoCommit != null && this.autoCommit != this.target.getAutoCommit()) { + if (this.autoCommit != null && this.autoCommit != defaultAutoCommit()) { this.target.setAutoCommit(this.autoCommit); } } @@ -464,6 +500,10 @@ private Connection getTargetConnection(Method operation) throws SQLException { return this.target; } + + private DataSource getDataSourceToUse() { + return (this.readOnly && readOnlyDataSource != null ? readOnlyDataSource : obtainTargetDataSource()); + } } } diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/DataSourceTransactionManagerTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/DataSourceTransactionManagerTests.java index 938dadaa3ac6..d2d7934f2453 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/DataSourceTransactionManagerTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/DataSourceTransactionManagerTests.java @@ -60,6 +60,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.springframework.core.testfixture.TestGroup.LONG_RUNNING; /** @@ -850,42 +851,23 @@ protected void doInTransactionWithoutResult(TransactionStatus status) throws Run @Test public void testTransactionWithIsolationAndReadOnly() throws Exception { - given(con.getTransactionIsolation()).willReturn(Connection.TRANSACTION_READ_COMMITTED); + given(con.getTransactionIsolation()).willReturn(Connection.TRANSACTION_READ_UNCOMMITTED); given(con.getAutoCommit()).willReturn(true); - TransactionTemplate tt = new TransactionTemplate(tm); - tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); - tt.setIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE); - tt.setReadOnly(true); - tt.setName("my-transaction"); - assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); - tt.execute(new TransactionCallbackWithoutResult() { - @Override - protected void doInTransactionWithoutResult(TransactionStatus status) { - // something transactional - assertThat(status.getTransactionName()).isEqualTo("my-transaction"); - assertThat(status.hasTransaction()).isTrue(); - assertThat(status.isNewTransaction()).isTrue(); - assertThat(status.isNested()).isFalse(); - assertThat(status.hasSavepoint()).isFalse(); - assertThat(status.isReadOnly()).isTrue(); - assertThat(TransactionSynchronizationManager.isCurrentTransactionReadOnly()).isTrue(); - assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isTrue(); - assertThat(status.isRollbackOnly()).isFalse(); - assertThat(status.isCompleted()).isFalse(); - } - }); + doTestTransactionReadOnly(TransactionDefinition.ISOLATION_REPEATABLE_READ, false); - assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); InOrder ordered = inOrder(con); ordered.verify(con).setReadOnly(true); - ordered.verify(con).setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE); + ordered.verify(con).getTransactionIsolation(); + ordered.verify(con).setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ); + ordered.verify(con).getAutoCommit(); ordered.verify(con).setAutoCommit(false); ordered.verify(con).commit(); ordered.verify(con).setAutoCommit(true); - ordered.verify(con).setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); + ordered.verify(con).setTransactionIsolation(Connection.TRANSACTION_READ_UNCOMMITTED); ordered.verify(con).setReadOnly(false); - verify(con).close(); + ordered.verify(con).close(); + verifyNoMoreInteractions(con); } @Test @@ -896,15 +878,104 @@ public void testTransactionWithEnforceReadOnly() throws Exception { Statement stmt = mock(); given(con.createStatement()).willReturn(stmt); + doTestTransactionReadOnly(TransactionDefinition.ISOLATION_DEFAULT, false); + + InOrder ordered = inOrder(con, stmt); + ordered.verify(con).setReadOnly(true); + ordered.verify(con).getAutoCommit(); + ordered.verify(con).setAutoCommit(false); + ordered.verify(con).createStatement(); + ordered.verify(stmt).executeUpdate("SET TRANSACTION READ ONLY"); + ordered.verify(stmt).close(); + ordered.verify(con).commit(); + ordered.verify(con).setAutoCommit(true); + ordered.verify(con).setReadOnly(false); + ordered.verify(con).close(); + verifyNoMoreInteractions(con); + } + + @Test + public void testTransactionWithLazyConnectionDataSourceAndStatement() throws Exception { + LazyConnectionDataSourceProxy dsProxy = new LazyConnectionDataSourceProxy(); + dsProxy.setTargetDataSource(ds); + dsProxy.setDefaultAutoCommit(true); + dsProxy.setDefaultTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); + tm = createTransactionManager(dsProxy); + + doTestTransactionReadOnly(TransactionDefinition.ISOLATION_SERIALIZABLE, true); + + InOrder ordered = inOrder(con); + ordered.verify(con).setReadOnly(true); + ordered.verify(con).setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE); + ordered.verify(con).setAutoCommit(false); + ordered.verify(con).createStatement(); + ordered.verify(con).commit(); + ordered.verify(con).setAutoCommit(true); + ordered.verify(con).setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); + ordered.verify(con).setReadOnly(false); + ordered.verify(con).close(); + verifyNoMoreInteractions(con); + } + + @Test + public void testTransactionWithLazyConnectionDataSourceNoStatement() throws Exception { + LazyConnectionDataSourceProxy dsProxy = new LazyConnectionDataSourceProxy(); + dsProxy.setTargetDataSource(ds); + dsProxy.setDefaultAutoCommit(true); + dsProxy.setDefaultTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); + tm = createTransactionManager(dsProxy); + + doTestTransactionReadOnly(TransactionDefinition.ISOLATION_SERIALIZABLE, false); + + verifyNoMoreInteractions(con); + } + + @Test + public void testTransactionWithReadOnlyDataSourceAndStatement() throws Exception { + LazyConnectionDataSourceProxy dsProxy = new LazyConnectionDataSourceProxy(); + dsProxy.setReadOnlyDataSource(ds); + dsProxy.setDefaultAutoCommit(false); + dsProxy.setDefaultTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); + tm = createTransactionManager(dsProxy); + + doTestTransactionReadOnly(TransactionDefinition.ISOLATION_SERIALIZABLE, true); + + InOrder ordered = inOrder(con); + ordered.verify(con).setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE); + ordered.verify(con).createStatement(); + ordered.verify(con).commit(); + ordered.verify(con).setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); + ordered.verify(con).close(); + verifyNoMoreInteractions(con); + } + + @Test + public void testTransactionWithReadOnlyDataSourceNoStatement() throws Exception { + LazyConnectionDataSourceProxy dsProxy = new LazyConnectionDataSourceProxy(); + dsProxy.setReadOnlyDataSource(ds); + dsProxy.setDefaultAutoCommit(false); + dsProxy.setDefaultTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); + tm = createTransactionManager(dsProxy); + + doTestTransactionReadOnly(TransactionDefinition.ISOLATION_SERIALIZABLE, false); + + verifyNoMoreInteractions(con); + } + + private void doTestTransactionReadOnly(int isolationLevel, boolean withStatement) { TransactionTemplate tt = new TransactionTemplate(tm); tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + tt.setIsolationLevel(isolationLevel); tt.setReadOnly(true); + tt.setName("my-transaction"); + assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); + tt.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) { // something transactional - assertThat(status.getTransactionName()).isEmpty(); + assertThat(status.getTransactionName()).isEqualTo("my-transaction"); assertThat(status.hasTransaction()).isTrue(); assertThat(status.isNewTransaction()).isTrue(); assertThat(status.isNested()).isFalse(); @@ -914,19 +985,18 @@ protected void doInTransactionWithoutResult(TransactionStatus status) { assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isTrue(); assertThat(status.isRollbackOnly()).isFalse(); assertThat(status.isCompleted()).isFalse(); + if (withStatement) { + try { + DataSourceUtils.getConnection(tm.getDataSource()).createStatement(); + } + catch (SQLException ex) { + throw new IllegalStateException(ex); + } + } } }); assertThat(TransactionSynchronizationManager.hasResource(ds)).isFalse(); - InOrder ordered = inOrder(con, stmt); - ordered.verify(con).setReadOnly(true); - ordered.verify(con).setAutoCommit(false); - ordered.verify(stmt).executeUpdate("SET TRANSACTION READ ONLY"); - ordered.verify(stmt).close(); - ordered.verify(con).commit(); - ordered.verify(con).setAutoCommit(true); - ordered.verify(con).setReadOnly(false); - ordered.verify(con).close(); } @ParameterizedTest(name = "transaction with {0} second timeout")