MyBatisでMapperが書けるようになると、次に気になるのが @Transactional の挙動ですよね。JPAの解説記事を読むと「永続化コンテキスト」や「dirty checking」といった言葉が出てきますが、MyBatisにはそもそもそういう仕組みがありません。実はMyBatisにおける @Transactional は、SqlSessionのコミット/ロールバックタイミングを制御しているだけの、意外とシンプルな存在です。
この記事では、SqlSessionがどうスレッドにバインドされるか、ログでコミット/ロールバックをどう確認するか、JdbcTemplateとMapperを混在させる方法、そしてchecked例外でロールバックされない典型的な事故を、動くコードで順に見ていきます。
MyBatisの@TransactionalはJPAと何が違うのか
JPAではEntityManagerが管理するEntityの変更を追跡し、トランザクションのコミット時(正確にはflush時)にまとめてSQLを発行します。だからこそ「いつSQLが飛んだか」が分かりにくく、@Transactional の理解には永続化コンテキストの知識が欠かせません。
一方MyBatisはMapperのメソッドを呼んだ瞬間にSQLが実行されます。追跡も自動flushもありません。ではMyBatisで @Transactional は何をしているのかというと、答えはシンプルで、SqlSessionが使っているコネクションの自動コミットを無効化し、メソッドの正常終了時にコミット、例外発生時にロールバックを発行しているだけです。
伝播レベルや分離レベルの一般的な話はSpring Bootのトランザクション管理ガイドで詳しく解説しているので、ここではMyBatis特有の疑問に絞ります。
SqlSessionTemplateがSqlSessionをスレッドにバインドする仕組み
@Mapper を付けたインターフェースは、Spring起動時に MapperFactoryBean によってプロキシが生成されます。このプロキシが実際にSQLを実行するとき、内部で使っているのが SqlSessionTemplate です。
SqlSessionTemplate は自分でSqlSessionを保持しません。呼ばれるたびに TransactionSynchronizationManager を見て、現在のスレッドに紐づくトランザクションがあればそのSqlSession(=コネクション)を再利用し、なければ新規に開いて紐付けます。つまり同じ @Transactional メソッド内で複数のMapperを呼んでも、実体は同じSqlSession・同じコネクションが使い回されるということです。
@Service
public class OrderService {
private final OrderMapper orderMapper;
private final OrderItemMapper orderItemMapper;
public OrderService(OrderMapper orderMapper, OrderItemMapper orderItemMapper) {
this.orderMapper = orderMapper;
this.orderItemMapper = orderItemMapper;
}
@Transactional
public void createOrder(Order order, List<OrderItem> items) {
orderMapper.insert(order);
items.forEach(orderItemMapper::insert);
// ここまでは同一SqlSession(同一コネクション)で実行される
}
}
DataSourceTransactionManagerの設定と役割
MyBatisを使う構成では、JPAの JpaTransactionManager の代わりに DataSourceTransactionManager を使います。役割は同じで、@Transactional が付いたメソッドの境界でコミット/ロールバックを発行することですが、対象がEntityManagerではなく素のJDBCコネクションになる点が違います。
@Configuration
public class MyBatisConfig {
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
return factoryBean.getObject();
}
@Bean
public DataSourceTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
ポイントは sqlSessionFactory と transactionManager が同じ DataSource Beanを参照していることです。これがずれていると、後述するJdbcTemplateとの混在で思わぬ事故が起きます。mybatis-spring-boot-starter を使っていれば、単一DataSource構成の場合はこの設定が自動で行われるので、明示的に書くのは複数DataSourceを使う場合が中心です。
ログでコミット/ロールバックの実際のタイミングを確認する
言葉で説明するより、実際にログを見た方が納得できますよね。application.yml で以下のログレベルを設定します。
logging:
level:
org.mybatis: DEBUG
com.example.demo.mapper: TRACE
org.springframework.jdbc.datasource: DEBUG
org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
正常系で createOrder を実行すると、ログはこう出ます。
Creating a new SqlSession
JDBC Connection [HikariProxyConnection@... ] will be managed by Spring
==> Preparing: INSERT INTO orders ...
==> Preparing: INSERT INTO order_items ...
Initiating transaction commit
Committing JDBC transaction on Connection [...]
Releasing JDBC Connection [...] after transaction
Creating a new SqlSession が1回しか出ていない点に注目してください。2つのMapperを呼んでいても、SqlSessionは使い回されています。例外を投げて確認すると、最後の行が Committing ではなく Rolling back に変わります。
Initiating transaction rollback after exception: ...
Rolling back JDBC transaction on Connection [...]
「本当にロールバックされているか自信がない」という不安は、このログを一度見れば解消するはずです。
JdbcTemplateとMapperを同一トランザクションで混在させる
集計クエリだけJdbcTemplateで書きたい、といったケースもありますよね。この場合も前提は同じで、JdbcTemplate が使う DataSource とMyBatisの SqlSessionFactory が同じBeanを参照していれば、TransactionSynchronizationManager によって同じコネクションが共有され、同一トランザクションに参加します。
@Service
public class OrderReportService {
private final OrderMapper orderMapper;
private final JdbcTemplate jdbcTemplate;
public OrderReportService(OrderMapper orderMapper, JdbcTemplate jdbcTemplate) {
this.orderMapper = orderMapper;
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public void closeOrder(Long orderId) {
orderMapper.updateStatus(orderId, "CLOSED");
jdbcTemplate.update(
"UPDATE order_summary SET closed_count = closed_count + 1 WHERE order_id = ?",
orderId
);
// Mapper側とJdbcTemplate側、両方とも同じコネクション上で実行される
}
}
もし DataSource が別々のBean(例えば読み取り専用のセカンダリDataSourceをJdbcTemplateだけに渡してしまった、など)になっていると、コネクションが2本に分かれてしまい、片方だけコミットされて片方だけロールバックされない、という整合性の崩れた状態が起こり得ます。混在させるなら、DataSourceの共有を最初に確認してください。
checked例外ではロールバックされない典型的な失敗
@Transactional のデフォルト挙動として、ロールバック対象は RuntimeException(unchecked例外)と Error のみです。checked例外は対象外なので、うっかりthrowsで宣言したcheckedな独自例外を使うと、例外が発生してもコミットされてしまいます。
public class OrderProcessingException extends Exception {
public OrderProcessingException(String message) {
super(message);
}
}
@Transactional
public void createOrder(Order order) throws OrderProcessingException {
orderMapper.insert(order);
if (order.getAmount() <= 0) {
throw new OrderProcessingException("amount must be positive");
}
// ここで例外が飛んでも、既定ではロールバックされずコミットされてしまう
}
このコードを実行すると、ログには Rolling back ではなく Committing が出ます。不正なデータのはずなのにDBには反映済み、という事故です。
対処は rollbackFor でロールバック対象を明示することです。
@Transactional(rollbackFor = OrderProcessingException.class)
public void createOrder(Order order) throws OrderProcessingException {
orderMapper.insert(order);
if (order.getAmount() <= 0) {
throw new OrderProcessingException("amount must be positive");
}
}
checked例外を業務ロジックで使う設計自体の是非はさておき、既存コードに手を入れる際は「そのメソッドがthrowsしている例外は本当にロールバック不要か」を一度確認しておくと安心です。
さらに深掘りしたい場合の関連トピック
この記事では扱わなかった論点もいくつかあります。同じクラス内のメソッド呼び出し(self-invocation)で @Transactional が効かなくなる問題は、AOPプロキシの制約が原因で、@Transactional・@Cacheable・@Asyncが効かないときの原因で詳しく扱っています。
MyBatisとJPAのどちらを選ぶべきかというアーキテクチャ選定の話はMyBatisとJPAの比較記事に、Mapperの基本的な書き方や動的SQLはMyBatisのMapper実装ガイドに譲ります。JdbcTemplate単体の使い方はSpring BootのJdbcTemplateガイドを参照してください。
まとめ
MyBatisには永続化コンテキストがない分、@Transactional がやっていることはむしろシンプルです。SqlSessionのコミット/ロールバックタイミングを制御しているだけなので、不安になったらログを見れば挙動は一目瞭然です。JdbcTemplateと混在させるときはDataSourceの共有を、checked例外を使うときは rollbackFor を、それぞれ忘れないようにしましょう。