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
に修正されます
- Spring Boot 3.5.1 or 3.5.2で
- MySQLの場合
org.springframework.dao.QueryTimeoutException
- PostgreSQLの場合
PostgreSQL・MySQL以外のRDBMSを利用している場合、どんな挙動になるのかちゃんとテストしておいたほうがいいと思います。
やりたいこと
@Transactional(timeout = 3)
のようにすると、トランザクションがタイムアウトします(この例だと3秒)。知識としては知っていましたが、実際にやってみるとどうなるのかを確認したかったので、検証してみました。
サンプルコード
データベース
DROP TABLE IF EXISTS sample;
CREATE TABLE sample(
id INTEGER PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
INSERT INTO sample(id, name)
VALUES (1, 'Sample1'),
(2, 'Sample2'),
(3, 'Sample3');
設定
PostgreSQLとMySQL両方の設定を記述しています。必要に応じてコメントアウトで切り替えます。
アプリケーション全体でのデフォルトのトランザクションタイムアウト時間は、spring.transaction.default-timeout=5s
のように設定します。
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コード
public record Sample(Integer id, String name) {
}
スリープする関数がPostgreSQLとMySQLで異なるため、コメントアウトしています。必要に応じて切り替えます。
@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());
}
}
サービスクラス
ここでトランザクションを制御します。
@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
がスローされる
ことが分かりました。
@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
が、タイムアウト時間を過ぎるとクエリをキャンセルします。
ソースはここらへん👇
クエリがキャンセルされると、アプリケーション側ではjava.sql.SQLException
(実際はサブクラスのorg.postgresql.util.PSQLException
)がスローされます。この例外に対して getSQLState()
すると、57014
が返されます。これはPostgreSQLのドキュメントで「クエリキャンセル」を示すエラーコードです。
ドキュメントはここらへん👇
57014
はspring-jdbcに含まれているsql-error-codes.xmlに含まれていないため、最終的にはSQLStateSQLExceptionTranslatorによってDataAccessResourceFailureException
に変換されます。
内部実装を追ってみる(MySQLの場合)
MySQLの場合も、JDBCドライバー内で別スレッドで動いているTimerTask
が、タイムアウト時間を過ぎるとクエリをキャンセルします。
ソースはここらへん👇
クエリがキャンセルされると、アプリケーション側ではjava.sql.SQLTimeoutException
がスローされます。この例外を最終的にはSQLExceptionSubclassTranslatorによってQueryTimeoutException
に変換されます。
RDBMSによって例外が違っていいの?
RDBMSによって例外が違うというのは、低レイヤーの抽象化(低レイヤーが何であっても、アプリケーションコードの変更が必要ない)を得意とするSpringっぽくないです。
ということでSpring FrameworkにIssueを立てて確認👇
やっぱり考慮漏れだったとのこと。おそらく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.
つまり、呼び出し元の設定に従うということですね。
Discussion