diff --git a/README.md b/README.md index 61b3d5cb2..bde34a69e 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ See [Supported Connection Properties](documentation/connection_properties.md) fo supported connection properties. #### Commonly Used Properties +- default_isolation_level (String): Spanner supports isolation levels REPEATABLE_READ or SERIALIZABLE. SERIALIZABLE is the default. Using isolation level REPEATABLE_READ improves performance by reducing the amount of locks that are taken by transactions that execute a large number of queries in read/write transactions. See https://cloud.google.com/spanner/docs/isolation-levels for more information on the supported isolation levels in Spanner. - credentials (String): URL for the credentials file to use for the connection. If you do not specify any credentials at all, the default credentials of the environment as returned by `GoogleCredentials#getApplicationDefault()` is used. Example: `jdbc:cloudspanner:/projects/my-project/instances/my-instance/databases/my-db;credentials=/path/to/credentials.json` - autocommit (boolean): Sets the initial autocommit mode for the connection. Default is true. - readonly (boolean): Sets the initial readonly mode for the connection. Default is false. diff --git a/documentation/latency-debugging-guide.md b/documentation/latency-debugging-guide.md index cdcfec826..0d2084c3f 100644 --- a/documentation/latency-debugging-guide.md +++ b/documentation/latency-debugging-guide.md @@ -5,6 +5,43 @@ queries and transactions and to determine whether transactions or requests are b addition, all metrics described in [Latency points in a Spanner request](https://cloud.google.com/spanner/docs/latency-points) are also collected by the JDBC driver and can be used for debugging. +## Isolation Level + +A common reason for high latency in read/write transactions is lock contention. Spanner by default +uses isolation level `SERIALIZABLE`. This causes all queries in read/write transactions to take +locks for all rows that are scanned by a query. Using isolation level `REPEATABLE_READ` reduces the +number of locks that are taken during a read/write transaction, and can significantly improve +performance for applications that execute many and/or large queries in read/write transactions. + +Enable isolation level `REPEATABLE_READ` by default for all transactions that are executed by the +JDBC driver by setting the `default_isolation_level` connection property like this in the connection +URL: + +```java +String projectId = "my-project"; +String instanceId = "my-instance"; +String databaseId = "my-database"; +String isolationLevel = "REPEATABLE_READ"; + +try (Connection connection = + DriverManager.getConnection( + String.format( + "jdbc:cloudspanner:/projects/%s/instances/%s/databases/%s?default_isolation_level=%s", + projectId, instanceId, databaseId, isolationLevel))) { + try (Statement statement = connection.createStatement()) { + try (ResultSet rs = statement.executeQuery("SELECT CURRENT_TIMESTAMP()")) { + while (rs.next()) { + System.out.printf( + "Connected to Cloud Spanner at [%s]%n", rs.getTimestamp(1).toString()); + } + } + } +} +``` + +See https://cloud.google.com/spanner/docs/isolation-levels for more information on the supported +isolation levels in Spanner. + ## Configuration You can configure the OpenTelemetry instance that should be used in two ways: diff --git a/samples/snippets/src/main/java/com/example/spanner/jdbc/IsolationLevel.java b/samples/snippets/src/main/java/com/example/spanner/jdbc/IsolationLevel.java new file mode 100644 index 000000000..7153b7c7a --- /dev/null +++ b/samples/snippets/src/main/java/com/example/spanner/jdbc/IsolationLevel.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.spanner.jdbc; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Properties; + +final class IsolationLevel { + + static void isolationLevel( + final String project, + final String instance, + final String database, + final Properties properties) + throws SQLException { + String url = String.format( + "jdbc:cloudspanner:/projects/%s/instances/%s/databases/%s", + project, instance, database); + try (Connection connection = DriverManager.getConnection(url, properties)) { + connection.setAutoCommit(false); + + // Spanner supports setting the isolation level to: + // 1. TRANSACTION_SERIALIZABLE (this is the default) + // 2. TRANSACTION_REPEATABLE_READ + + // The following line sets the default isolation level that will be used + // for all read/write transactions on this connection. + connection.setTransactionIsolation( + Connection.TRANSACTION_REPEATABLE_READ); + + // This query will not take any locks when using + // isolation level repeatable read. + try (ResultSet resultSet = connection + .createStatement() + .executeQuery("SELECT SingerId, Active " + + "FROM Singers " + + "ORDER BY LastName")) { + while (resultSet.next()) { + try (PreparedStatement statement = connection.prepareStatement( + "INSERT OR UPDATE INTO SingerHistory " + + "(SingerId, Active, CreatedAt) " + + "VALUES (?, ?, CURRENT_TIMESTAMP)")) { + statement.setLong(1, resultSet.getLong(1)); + statement.setBoolean(2, resultSet.getBoolean(2)); + statement.executeUpdate(); + } + } + } + connection.commit(); + } + } + + private IsolationLevel() { + } +} diff --git a/samples/snippets/src/test/java/com/example/spanner/jdbc/IsolationLevelTest.java b/samples/snippets/src/test/java/com/example/spanner/jdbc/IsolationLevelTest.java new file mode 100644 index 000000000..e1c7bfbe0 --- /dev/null +++ b/samples/snippets/src/test/java/com/example/spanner/jdbc/IsolationLevelTest.java @@ -0,0 +1,97 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.spanner.jdbc; + +import static com.example.spanner.jdbc.IsolationLevel.isolationLevel; +import static org.junit.Assume.assumeTrue; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Properties; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.PullPolicy; +import org.testcontainers.utility.DockerImageName; + +@RunWith(JUnit4.class) +public class IsolationLevelTest { + + private static GenericContainer emulator; + + private static final String PROJECT = "emulator-project"; + private static final String INSTANCE = "my-instance"; + private static final String DATABASE = "my-database"; + private static final Properties PROPERTIES = new Properties(); + + @BeforeClass + public static void setupEmulator() throws Exception { + assumeTrue("This test requires Docker", DockerClientFactory.instance().isDockerAvailable()); + + emulator = + new GenericContainer<>(DockerImageName.parse("gcr.io/cloud-spanner-emulator/emulator")); + emulator.withImagePullPolicy(PullPolicy.alwaysPull()); + emulator.addExposedPort(9010); + emulator.setWaitStrategy(Wait.forListeningPorts(9010)); + emulator.start(); + + PROPERTIES.setProperty("endpoint", + String.format("localhost:%d", emulator.getMappedPort(9010))); + PROPERTIES.setProperty("autoConfigEmulator", "true"); + + String url = String.format( + "jdbc:cloudspanner:/projects/%s/instances/%s/databases/%s", + PROJECT, INSTANCE, DATABASE); + try (Connection connection = DriverManager.getConnection(url, PROPERTIES)) { + try (Statement statement = connection.createStatement()) { + statement.addBatch( + "CREATE TABLE Singers (" + + "SingerId INT64 PRIMARY KEY, " + + "FirstName STRING(MAX), " + + "LastName STRING(MAX), " + + "Active BOOL)"); + statement.addBatch( + "CREATE TABLE SingerHistory (" + + "SingerId INT64, " + + "Active BOOL, " + + "CreatedAt TIMESTAMP) " + + "PRIMARY KEY (SingerId, CreatedAt)"); + statement.executeBatch(); + } + } + } + + @AfterClass + public static void stopEmulator() { + if (emulator != null) { + emulator.stop(); + } + } + + @Test + public void testIsolationLevel() throws SQLException { + isolationLevel("emulator-project", "my-instance", "my-database", PROPERTIES); + } + +} diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcTransactionOptionsTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcTransactionOptionsTest.java index a34e449e7..8a5af27c0 100644 --- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcTransactionOptionsTest.java +++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcTransactionOptionsTest.java @@ -27,10 +27,15 @@ import com.google.cloud.spanner.connection.AbstractMockServerTest; import com.google.cloud.spanner.connection.SpannerPool; import com.google.spanner.v1.CommitRequest; +import com.google.spanner.v1.ExecuteSqlRequest; +import com.google.spanner.v1.TransactionOptions.IsolationLevel; +import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.time.Duration; +import java.util.Arrays; +import java.util.stream.Collectors; import org.junit.After; import org.junit.Test; import org.junit.runner.RunWith; @@ -138,4 +143,63 @@ public void testMaxCommitDelay() throws SQLException { assertEquals(Duration.ofMillis(50).toNanos(), request.getMaxCommitDelay().getNanos()); } } + + @Test + public void testDefaultIsolationLevel() throws SQLException { + for (IsolationLevel isolationLevel : + Arrays.stream(IsolationLevel.values()) + .filter(level -> !level.equals(IsolationLevel.UNRECOGNIZED)) + .collect(Collectors.toList())) { + try (java.sql.Connection connection = + DriverManager.getConnection( + "jdbc:" + getBaseUrl() + ";default_isolation_level=" + isolationLevel.name())) { + connection.setAutoCommit(false); + try (ResultSet resultSet = + connection.createStatement().executeQuery(SELECT1_STATEMENT.getSql())) { + while (resultSet.next()) { + // ignore + } + } + connection.commit(); + assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class)); + ExecuteSqlRequest request = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0); + assertTrue(request.hasTransaction()); + assertTrue(request.getTransaction().hasBegin()); + assertTrue(request.getTransaction().getBegin().hasReadWrite()); + assertEquals(isolationLevel, request.getTransaction().getBegin().getIsolationLevel()); + assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class)); + + mockSpanner.clearRequests(); + } + } + } + + @Test + public void testSetIsolationLevel() throws SQLException { + try (java.sql.Connection connection = createJdbcConnection()) { + connection.setAutoCommit(false); + for (int isolationLevel : + new int[] {Connection.TRANSACTION_REPEATABLE_READ, Connection.TRANSACTION_SERIALIZABLE}) { + connection.setTransactionIsolation(isolationLevel); + try (ResultSet resultSet = + connection.createStatement().executeQuery(SELECT1_STATEMENT.getSql())) { + while (resultSet.next()) { + // ignore + } + } + connection.commit(); + assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class)); + ExecuteSqlRequest request = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0); + assertTrue(request.hasTransaction()); + assertTrue(request.getTransaction().hasBegin()); + assertTrue(request.getTransaction().getBegin().hasReadWrite()); + assertEquals( + IsolationLevelConverter.convertToSpanner(isolationLevel), + request.getTransaction().getBegin().getIsolationLevel()); + assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class)); + + mockSpanner.clearRequests(); + } + } + } }