🐘

Springトランザクションのタイムアウトについて調べた

に公開

環境

  • JDK 21
  • Spring Boot 3.4.6 & 3.5.0
    • MyBatisおよびJdbcTemplateで検証
  • PostgreSQL 16
  • MySQL 8.0
  • macOS 15.5

忙しい人のためのまとめ

  • アプリケーション全体のデフォルトのトランザクションタイムアウト時間は、application.propertiesに spring.transaction.default-timeout=60s のように設定可能
  • 個別にトランザクションタイムアウト時間を設定する場合は、 @Transactional(timeout = 30) のように設定可能
  • タイムアウト時間が過ぎると即座に例外がスローされる
    • PostgreSQLの場合 org.springframework.dao.DataAccessResourceFailureException
      • Spring Boot 3.5.1 or 3.5.2で org.springframework.dao.QueryTimeoutException に修正されます
    • MySQLの場合 org.springframework.dao.QueryTimeoutException

PostgreSQL・MySQL以外のRDBMSを利用している場合、どんな挙動になるのかちゃんとテストしておいたほうがいいと思います。

やりたいこと

@Transactional(timeout = 3) のようにすると、トランザクションがタイムアウトします(この例だと3秒)。知識としては知っていましたが、実際にやってみるとどうなるのかを確認したかったので、検証してみました。

サンプルコード

データベース

schema.sql
DROP TABLE IF EXISTS sample;

CREATE TABLE sample(
    id INTEGER PRIMARY KEY,
    name VARCHAR(100) NOT NULL
);
data.sql
INSERT INTO sample(id, name)
VALUES (1, 'Sample1'),
       (2, 'Sample2'),
       (3, 'Sample3');

設定

PostgreSQLとMySQL両方の設定を記述しています。必要に応じてコメントアウトで切り替えます。

アプリケーション全体でのデフォルトのトランザクションタイムアウト時間は、spring.transaction.default-timeout=5s のように設定します。

application.properties
spring.application.name=transaction-timeout-sample-jdbc

# Transaction timeout
spring.transaction.default-timeout=5s

# PostgreSQL
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
spring.datasource.username=postgres
spring.datasource.password=

# MySQL
#spring.datasource.url=jdbc:mysql://localhost:3306/sample
#spring.datasource.username=user
#spring.datasource.password=password

# Run schema.sql and data.sql on startup
spring.sql.init.mode=always

# Logging
logging.level.org.apache.ibatis=trace
logging.level.org.mybatis=trace
logging.level.org.springframework=trace
logging.level.org.springframework.boot=info
logging.level.org.springframework.beans=info
logging.level.org.springframework.context=info
logging.level.org.springframework.core=info
logging.level.org.postgresql=trace
logging.level.com.mysql=trace
logging.level.com.example.transactiontimeoutsamplejdbc=trace

Javaコード

Sample.java
public record Sample(Integer id, String name) {
}

スリープする関数がPostgreSQLとMySQLで異なるため、コメントアウトしています。必要に応じて切り替えます。

SampleRepository.java
@Repository
public class SampleRepository {
  private final JdbcTemplate jdbcTemplate;

  public SampleRepository(JdbcTemplate jdbcTemplate) {
    this.jdbcTemplate = jdbcTemplate;
  }

  public List<Sample> selectAll() {
    return jdbcTemplate.query("SELECT id, name FROM sample ORDER BY id", new DataClassRowMapper<>(Sample.class));
  }

  public Object sleep(int seconds) {
    // PostgreSQL
    return jdbcTemplate.queryForObject("SELECT pg_sleep(?)", new Object[]{seconds}, Object.class);
    // MySQL
//    return jdbcTemplate.queryForObject("SELECT sleep(?)", new Object[]{seconds}, Object.class);
  }

  public void insert(Sample sample) {
    jdbcTemplate.update("INSERT INTO sample(id, name) VALUES (?, ?)", sample.id(), sample.name());
  }
}

サービスクラス

ここでトランザクションを制御します。

SampleService.java
@Service
public class SampleService {
  private static final Logger logger = LoggerFactory.getLogger(SampleService.class);

  private final SampleRepository sampleRepository;

  public SampleService(SampleRepository sampleRepository) {
    this.sampleRepository = sampleRepository;
  }

  // 2秒でタイムアウト
  @Transactional(timeout = 2, readOnly = false)
  public void registerWithSleep(Sample sample, int seconds) {
    logger.info("Sleep {}seconds...", seconds);
    sampleRepository.sleep(seconds);
    logger.info("Sleep completed. Starting INSERT...");
    sampleRepository.insert(sample);
    logger.info("INSERT completed.");
  }
}

テストコード

PostgreSQLとMySQL両方のテストを記述しています。必要に応じて@Disabledを付け替えます。

このテストはOKになります。つまり

  • PostgreSQLとMySQLの両方で、タイムアウト時間が経過したらすぐに例外がスローされる
  • PostgreSQLではDataAccessResourceFailureExceptionがスローされる
  • MySQLではQueryTimeoutExceptionがスローされる

ことが分かりました。

SampleServiceTest.java
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Sql(
    scripts = {"classpath:schema.sql", "classpath:data.sql"},
    executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
)
public class SampleServiceTest {
  @Autowired
  SampleService sampleService;

  @Nested
  @DisplayName("registerWithSleep()")
  class RegisterWithSleepTest {
    // PostgreSQLの場合
    @Test
    @DisplayName("DataAccessResourceFailureException in 2 seconds on PostgreSQL")
    void postgres() {
      long startTime = System.currentTimeMillis();
      DataAccessResourceFailureException exception = assertThrows(
          DataAccessResourceFailureException.class, () -> {
            sampleService.registerWithSleep(new Sample(4, "Sample4"), 3);  // timeout = 2
          }
      );
      // クエリキャンセルを示すPostgreSQLのエラーコード
      // see https://www.postgresql.jp/document/16/html/errcodes-appendix.html
      assertEquals("57014", ((SQLException) exception.getCause()).getSQLState());
      long processSeconds = (System.currentTimeMillis() - startTime) / 1000;
      assertEquals(2, processSeconds);  // timeout should be 2 seconds
    }

    // MySQLの場合
    @Disabled  // 必要に応じて付け替え
    @Test
    @DisplayName("QueryTimeoutException in 2 seconds on MySQL")
    void mysql() {
      long startTime = System.currentTimeMillis();
      QueryTimeoutException exception = assertThrows(
          QueryTimeoutException.class, () -> {
            sampleService.registerWithSleep(new Sample(4, "Sample4"), 3);  // timeout = 2
          }
      );
      long processSeconds = (System.currentTimeMillis() - startTime) / 1000;
      assertEquals(2, processSeconds);  // timeout should be 2 seconds
      SQLTimeoutException sqlException = (SQLTimeoutException) exception.getCause();
      assertEquals(0, sqlException.getErrorCode());
      assertNull(sqlException.getSQLState());
    }
  }
}

内部実装を追ってみる(PostgreSQLの場合)

PostgreSQLの場合、JDBCドライバー内で別スレッドで動いているTimerTaskが、タイムアウト時間を過ぎるとクエリをキャンセルします。

ソースはここらへん👇

https://github.com/pgjdbc/pgjdbc/blob/master/pgjdbc/src/main/java/org/postgresql/jdbc/PgStatement.java#L998

クエリがキャンセルされると、アプリケーション側ではjava.sql.SQLException (実際はサブクラスのorg.postgresql.util.PSQLException)がスローされます。この例外に対して getSQLState() すると、57014が返されます。これはPostgreSQLのドキュメントで「クエリキャンセル」を示すエラーコードです。

ドキュメントはここらへん👇

https://www.postgresql.jp/document/16/html/errcodes-appendix.html

57014はspring-jdbcに含まれているsql-error-codes.xmlに含まれていないため、最終的にはSQLStateSQLExceptionTranslatorによってDataAccessResourceFailureExceptionに変換されます。

内部実装を追ってみる(MySQLの場合)

MySQLの場合も、JDBCドライバー内で別スレッドで動いているTimerTaskが、タイムアウト時間を過ぎるとクエリをキャンセルします。

ソースはここらへん👇

https://github.com/mysql/mysql-connector-j/blob/release/9.x/src/main/user-impl/java/com/mysql/cj/jdbc/StatementImpl.java#L1446

クエリがキャンセルされると、アプリケーション側ではjava.sql.SQLTimeoutExceptionがスローされます。この例外を最終的にはSQLExceptionSubclassTranslatorによってQueryTimeoutExceptionに変換されます。

RDBMSによって例外が違っていいの?

RDBMSによって例外が違うというのは、低レイヤーの抽象化(低レイヤーが何であっても、アプリケーションコードの変更が必要ない)を得意とするSpringっぽくないです。

ということでSpring FrameworkにIssueを立てて確認👇

https://github.com/spring-projects/spring-framework/issues/35073

やっぱり考慮漏れだったとのこと。おそらくSpring Boot 3.5.1か3.5.2では、PostgreSQLでもQueryTimeoutExceptionがスローされるはずです。

ネストしたトランザクションの場合

@TransactionalのJavadocには、timeout要素について次のように書かれています。

Exclusively designed for use with Propagation.REQUIRED or Propagation.REQUIRES_NEW since it only applies to newly started transactions.

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/annotation/Transactional.html#timeout()

つまり、呼び出し元の設定に従うということですね。

Discussion