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 を、それぞれ忘れないようにしましょう。