@Transactionalが効かない原因の多くは、「呼び出しがSpringのプロキシを通っていない」か「例外がロールバックの条件を満たしていない」かのどちらかです。 @Transactionalはメソッドそのものに魔法をかけるのではなく、Beanの前に立つ「代理オブジェクト(プロキシ)」が、呼び出しの前後でトランザクションを開始・コミット・ロールバックする仕組みです。そのため、プロキシを通らない呼び方をしたり、Springの既定ルールで「コミット」と判断される例外を投げたりすると、アノテーションを付けていてもロールバックされません。

この記事では、よくある原因を8つに分けて「なぜそうなるか」「どう直すか」をコードで整理します。さらに、プロキシ経由でないと前後処理が入らない仕組みを、Java 21の標準機能(java.lang.reflect.Proxy)だけで作った模擬実験の実際の出力で確かめます(Spring本体を動かした結果ではなく、仕組みを再現した小さな実験です)。Springの仕様は Spring Framework 7.0 系の公式リファレンスとJavadocに沿って確認しています。

@Transactionalが効かない原因の早見表

# 原因 何が起きるか 主な直し方
1 同じクラス内の自己呼び出し(this.method()) プロキシを通らず、トランザクションが始まらない 別Beanに分ける/TransactionTemplateを使う
2 private・finalメソッド、インターフェース型プロキシの非publicメソッド プロキシが割り込めない publicまたはprotectedにする、finalを外す
3 検査例外(Exceptionの直系)を投げている 既定ではコミットされる rollbackForを指定/RollbackOn.ALL_EXCEPTIONS
4 例外をcatchして握りつぶしている 正常終了とみなされコミット 再スロー/setRollbackOnly()
5 newで作ったインスタンスを呼ぶ、@PostConstruct内で呼ぶ そもそもプロキシがない・未完成 DIで受け取る/起動完了後のイベントで実行
6 別スレッド(@Async、並列ストリームなど)で処理 トランザクションはスレッドごと スレッド内で新たにトランザクションを張る
7 伝播属性の誤解(REQUIRES_NEW、内側の失敗) 一部だけ残る/UnexpectedRollbackException 境界を設計し直す
8 トランザクションマネージャ・DB設定の不一致 別のDataSourceに効いている、DB側が非対応 transactionManagerを指定、DB設定を確認

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring @Transactionalが効かない原因8選|ロールバックの直し方」

まず仕組み:@Transactionalはプロキシが処理する

@Transactionalの処理は、Beanを包むプロキシが担当します。 公式リファレンスには「プロキシモード(既定)では、プロキシを通って外から入ってきた呼び出しだけがインターセプトされる」と書かれています。流れを図にすると次のとおりです。

sequenceDiagram
    participant C as 呼び出し元(Controllerなど)
    participant P as プロキシ(Springが生成)
    participant T as 本物のService
    participant DB as DB
    C->>P: saveOrder()
    P->>DB: BEGIN
    P->>T: saveOrder()
    T->>DB: INSERT
    T-->>P: 正常終了 or 例外
    alt 正常終了/検査例外(既定)
        P->>DB: COMMIT
    else RuntimeException / Error
        P->>DB: ROLLBACK
    end
    P-->>C: 戻り値 or 例外

プロキシには、クラスを継承して作る「クラスベース(CGLIB)」と、インターフェースを実装して作る「インターフェースベース(JDK動的プロキシ)」の2種類があります。Spring Bootは既定でクラスベースのプロキシを使います(spring.aop.proxy-target-class=true が既定値)。

模擬実験:プロキシを通る呼び出しだけに前後処理が入る

仕組みを目で確かめるために、Javaの標準APIだけで「@Txが付いたメソッドの前後でBEGIN/COMMIT/ROLLBACKを表示するプロキシ」を作りました。ロールバック条件はSpringの既定ルール(RuntimeExceptionとErrorはロールバック、検査例外はコミット)をまねています。Springそのものではなく、考え方を再現した模擬実験です。

// TxProxyDemo.java(抜粋)Java 21で `java TxProxyDemo.java` として実行
static class OrderServiceImpl implements OrderService {
    public void placeOrder() {
        System.out.println("  placeOrder: this.saveOrder() を呼ぶ");
        this.saveOrder();                     // 自己呼び出し
    }
    @Tx public void saveOrder() {
        System.out.println("  saveOrder: INSERT 実行");
    }
    @Tx public void saveWithChecked() throws Exception {
        System.out.println("  saveWithChecked: INSERT 実行 → 検査例外");
        throw new Exception("checked");
    }
    @Tx public void saveAndSwallow() {
        try {
            System.out.println("  saveAndSwallow: INSERT 実行 → 実行時例外");
            throw new IllegalStateException("runtime");
        } catch (IllegalStateException e) {
            System.out.println("  saveAndSwallow: catch して握りつぶす");
        }
    }
}

// プロキシ側(InvocationHandler):@Tx付きメソッドだけ
// BEGIN → 本体を呼ぶ → 正常なら COMMIT、RuntimeException/Error なら ROLLBACK、検査例外なら COMMIT

実行結果(Java 21.0.10で実行した実際の出力):

① プロキシ経由で saveOrder()
[TX] BEGIN  saveOrder
  saveOrder: INSERT 実行
[TX] COMMIT saveOrder
② プロキシ経由で placeOrder() → 内部で this.saveOrder()
  placeOrder: this.saveOrder() を呼ぶ
  saveOrder: INSERT 実行
③ プロキシ経由で saveWithChecked()
[TX] BEGIN  saveWithChecked
  saveWithChecked: INSERT 実行 → 検査例外
[TX] COMMIT saveWithChecked (検査例外 Exception なのでコミット)
  呼び出し元: checked を受け取った
④ プロキシ経由で saveAndSwallow()
[TX] BEGIN  saveAndSwallow
  saveAndSwallow: INSERT 実行 → 実行時例外
  saveAndSwallow: catch して握りつぶす
[TX] COMMIT saveAndSwallow
⑤ new したインスタンスを直接呼ぶ
  saveOrder: INSERT 実行

②と⑤では[TX]の行が1つも出ていません。 this.saveOrder() は本物のオブジェクト内部の呼び出しで、プロキシを経由しないからです。③は例外が呼び出し元まで届いているのにコミット、④は例外が起きたのにコミットになっています。以下、この4パターンを含む8つの原因を順に見ていきます。

原因1:同じクラス内の自己呼び出し

同じクラスの中でthis.xxx()と呼ぶと、@Transactionalは効きません。 模擬実験の②と同じ理由です。

@Service
public class OrderService {

    public void placeOrder(OrderForm form) {   // @Transactionalなし
        validate(form);
        saveOrder(form);                       // this.saveOrder() と同じ → プロキシを通らない
    }

    @Transactional
    public void saveOrder(OrderForm form) {
        orderRepository.save(form.toOrder());
        stockRepository.decrease(form.itemId(), form.quantity());
        // ここで例外が起きても、トランザクションが無いので途中まで保存される
    }
}

直し方は目的に応じて選びます。

直し方 向いている場面 ひとこと
呼び出し元のメソッドに@Transactionalを付ける 処理全体を1トランザクションにしたい いちばん簡単
別のBeanに分ける 責務を分けたい、再利用したい 定番で読みやすい
TransactionTemplateでコード上に境界を書く 一部だけをトランザクションにしたい プロキシに依存しない

別Beanに分ける例です。

@Service
@RequiredArgsConstructor
public class OrderFacade {
    private final OrderWriter orderWriter;     // 別Bean(プロキシが注入される)

    public void placeOrder(OrderForm form) {
        validate(form);
        orderWriter.saveOrder(form);           // プロキシ経由 → トランザクション開始
    }
}

@Service
public class OrderWriter {
    @Transactional
    public void saveOrder(OrderForm form) { /* 保存処理 */ }
}

TransactionTemplateを使う例です。Spring BootではTransactionTemplateがBeanとして用意されるため、そのまま注入できます。

@Service
@RequiredArgsConstructor
public class OrderService {
    private final TransactionTemplate tx;

    public void placeOrder(OrderForm form) {
        validate(form);                        // トランザクション外
        tx.executeWithoutResult(status -> {    // ここからトランザクション
            orderRepository.save(form.toOrder());
            stockRepository.decrease(form.itemId(), form.quantity());
        });
    }
}

原因2:private・finalメソッド、非publicの扱い

privateメソッドとfinalメソッドには、@Transactionalを付けても効きません。 クラスベースのプロキシは「サブクラスでメソッドを上書きして前後処理を差し込む」方式なので、上書きできないメソッドには割り込めないからです。

メソッドの形 クラスベースプロキシ(Boot既定) インターフェースベースプロキシ
public 効く インターフェースに定義されていれば効く
protected/パッケージプライベート Spring Framework 6.0以降は効く(5.x以前は対象外) 効かない
private 効かない 効かない
final 効かない インターフェースに定義された public メソッドなら効く

Kotlinではクラスとメソッドが既定でfinalです。Spring InitializrでKotlinを選ぶとkotlin-spring(all-open)プラグインが入り、@Transactionalなどが付いたクラスは自動でopenになります。独自のGradle設定で始めたプロジェクトでは、このプラグインが入っているか確認すると安心です。

@Service
public class PointService {
    @Transactional
    private void addPoint(long userId, int point) { ... }     // 効かない → public か protected に

    @Transactional
    public final void usePoint(long userId, int point) { ... } // 効かない → final を外す
}

原因3:検査例外は既定でコミットされる

Springの既定では、RuntimeExceptionとErrorはロールバック、検査例外(Exceptionを直接継承する例外)はコミットされます。 公式リファレンスにも「Any RuntimeException or Error triggers rollback, and any checked Exception does not.」と書かれています。模擬実験の③がこのパターンです。

@Transactional
public void importCsv(Path file) throws IOException {
    userRepository.save(new User("a"));
    Files.readAllLines(file);   // IOException(検査例外)→ 既定ではコミットされる
}

直し方は3つあります。

// ① メソッドごとに指定する
@Transactional(rollbackFor = Exception.class)
public void importCsv(Path file) throws IOException { ... }

// ② 業務例外をRuntimeExceptionのサブクラスにする
public class ImportFailedException extends RuntimeException { ... }

// ③ アプリ全体の既定を「すべての例外でロールバック」に切り替える(Spring Framework 6.2以降)
@Configuration
@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)
public class TxConfig { }

③のrollbackOn属性はSpring Framework 6.2で追加されました。Javadocでは、検査例外でコミットさせたい理由が特にない場合はALL_EXCEPTIONSへの切り替えがすすめられており、検査例外の区別がないKotlinのアプリでも切り替えが推奨されています。個別のrollbackFor/noRollbackForを書いた場合は、そちらが優先されます。

原因4:例外をcatchして握りつぶしている

例外をメソッド内でcatchして外に投げないと、プロキシから見ると「正常終了」なのでコミットされます。 模擬実験の④です。ログを出しただけで処理を続けているコードでよく起きます。

@Transactional
public void register(UserForm form) {
    try {
        userRepository.save(form.toUser());
        mailClient.sendWelcome(form.email());   // ここで実行時例外
    } catch (RuntimeException e) {
        log.warn("登録処理で失敗", e);           // 投げ直さない → コミットされる
    }
}

直し方は「投げ直す」か「ロールバックだけ指示する」かのどちらかです。

// ① 投げ直す(原因を残して包み直すとよい)
} catch (MailException e) {
    log.warn("登録処理で失敗", e);
    throw new RegistrationFailedException(e);
}

// ② 呼び出し元には正常を返しつつ、ロールバックだけ指示する
} catch (MailException e) {
    log.warn("登録処理で失敗", e);
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}

①が基本、②は「画面には結果を返したいがDBは戻したい」場合に使います。

原因5:newで作ったインスタンス・@PostConstruct内の呼び出し

newで作ったオブジェクトはSpringの管理外なので、プロキシがありません。 模擬実験の⑤がこれにあたります。

// 効かない:new したインスタンス
OrderWriter writer = new OrderWriter();
writer.saveOrder(form);

// 効く:DI(コンストラクタインジェクション)で受け取る
private final OrderWriter writer;   // Springが用意したプロキシが入る

また公式リファレンスには、プロキシは完全に初期化されてから期待どおりに動くため、@PostConstructなどの初期化処理でトランザクションに頼らないようにと書かれています。起動時に初期データを投入したい場合は、起動完了後に動く仕組みに移すと安定します。

@Component
@RequiredArgsConstructor
public class InitialDataLoader {
    private final MasterDataService masterDataService;   // @Transactionalを持つ別Bean

    @EventListener(ApplicationReadyEvent.class)
    public void load() {
        masterDataService.importDefaults();
    }
}

原因6:別スレッドで処理している

Springのトランザクションは、実行中のスレッドに結び付いて管理されます。 そのため、@Asyncのメソッド、CompletableFuture.runAsync()、並列ストリーム、自分で作ったThreadの中の処理は、呼び出し元のトランザクションに参加しません。

@Transactional
public void bulkUpdate(List<Item> items) {
    items.parallelStream()                 // 別スレッドで処理される可能性がある
         .forEach(itemRepository::save);   // 呼び出し元のトランザクションの外
    throw new IllegalStateException();     // ここでロールバックしても並列側は戻らない
}

直し方は、1つのトランザクションにしたい処理を同じスレッドで動かすことです。 非同期処理の中でDBを更新したい場合は、非同期で呼ばれる側(別Bean)のメソッドに@Transactionalを付け、そのスレッドで新しくトランザクションを開始します。

原因7:伝播属性(propagation)の誤解

伝播属性は「すでにトランザクションがあるときにどう振る舞うか」を決める設定です。 既定はREQUIRED(あれば参加、なければ新規作成)です。ここでは、ロールバックに関係する2つのパターンに絞って説明します。

7-1. REQUIRES_NEWの処理は外側と別にコミットされる

REQUIRES_NEWは、外側のトランザクションをいったん止めて独立したトランザクションを開始します。内側がコミットされたあとで外側がロールバックしても、内側の変更は残ります。

@Service
public class AuditWriter {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void write(String message) { auditRepository.save(new AuditLog(message)); }
}

@Transactional
public void updateProfile(ProfileForm form) {
    auditWriter.write("プロフィール更新開始");  // 独立してコミット済み
    profileRepository.save(form.toProfile());
    throw new IllegalStateException();         // 外側はロールバック、監査ログは残る
}

「失敗しても操作ログは残したい」ときには正しい動きです。反対に、全部まとめて戻したいのにREQUIRES_NEWを使っていると「ロールバックされない」と感じる原因になります。

7-2. 内側の失敗をcatchするとUnexpectedRollbackExceptionになる

同じトランザクションに参加している(REQUIREDの)別Beanのメソッドが実行時例外を投げると、その時点でトランザクション全体に「ロールバック専用(rollback-only)」の印が付きます。 外側でその例外をcatchして処理を続けても、最後のコミット時にSpringはコミットできず、UnexpectedRollbackException(メッセージ例:Transaction silently rolled back because it has been marked as rollback-only)を投げます。

@Transactional
public void importAll(List<Row> rows) {
    for (Row row : rows) {
        try {
            rowImporter.importRow(row);   // 別Beanの @Transactional(REQUIRED)
        } catch (RuntimeException e) {
            log.warn("スキップ: {}", row.id());   // 続けたつもりでも…
        }
    }
}   // ← コミット時に UnexpectedRollbackException

目的に合わせて境界を決め直します。

やりたいこと 設計
1行ずつ成功分を確定させたい importRowをREQUIRES_NEWにする(1行=1トランザクション)
全件成功か全件取り消しにしたい catchせずに例外を外へ投げる
途中まで戻して続けたい NESTED(セーブポイント。JDBCのDataSourceTransactionManagerなどで利用可)

原因8:トランザクションマネージャ・DB設定の不一致

アノテーションもプロキシも正しいのに戻らない場合は、トランザクションが「別のDB接続」に張られていないかを確認します。

確認ポイント 起きること 直し方
DataSourceが複数ある @Transactionalが既定のトランザクションマネージャ側に効き、更新した側は対象外 @Transactional("secondaryTxManager")のようにtransactionManagerを指定
テーブルのストレージエンジンがトランザクション非対応 DB側でロールバックできない トランザクション対応のエンジン(MySQLならInnoDB)を使う
自前でConnectionを取得してcommit()している Springの管理外で確定する JdbcTemplateやリポジトリ経由にそろえる
readOnly = trueを付けた 書き込みを防ぐ設定ではない 目的に合わせて付け外しする

readOnly = trueは「読み取り専用なので最適化してよい」というヒントです。 Javadocにも「書き込みが必ず失敗するとは限らない」と明記されています。ドライバやDBによって動きが変わるため、「readOnlyを付けたのに書き込めた/書き込めなかった」はどちらも起こりえます。

トランザクションが張られているか確かめる方法

「効いているか」をコードとログで確かめると、原因の切り分けが早くなります。

// 1. 実行中にトランザクションがあるかを出力する
log.info("tx active = {}", TransactionSynchronizationManager.isActualTransactionActive());
log.info("tx name   = {}", TransactionSynchronizationManager.getCurrentTransactionName());

// 2. 注入されたBeanがプロキシかを確認する
log.info("aop proxy = {}", AopUtils.isAopProxy(orderWriter));
#3. トランザクション開始・終了のログを出す(application.properties)
logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG

チェックの順番は次のとおりです。

  • [ ] 呼び出しは別Beanから来ているか(this.経由ではないか)
  • [ ] メソッドはprivateでもfinalでもないか
  • [ ] 投げている例外はRuntimeException系か(検査例外ならrollbackFor)
  • [ ] 例外をcatchして止めていないか
  • [ ] インスタンスはDIで受け取っているか
  • [ ] 処理は同じスレッドで動いているか
  • [ ] 伝播属性は意図どおりか(REQUIRES_NEW/内側の失敗)
  • [ ] 更新したDBのトランザクションマネージャが使われているか

よくある質問

Q. @Transactionalはクラスに付けるのとメソッドに付けるのとで違いはありますか? A. クラスに付けると、そのクラス(とサブクラス)のメソッド全体の既定になり、メソッド側の指定があればそちらが優先されます。Javadocのとおり、親クラスから継承しただけのメソッドには適用されないため、必要なら子クラスで再定義します。また公式は、インターフェースではなく具象クラスのメソッドに付けることを推奨しています。

Q. テストで@Transactionalを付けたら、データが保存されませんでした。 A. Springのテスト(@SpringBootTestや@DataJpaTestなど)でテストメソッドに@Transactionalを付けると、テスト終了時に既定でロールバックされます。テストを互いに独立させるための仕組みで、本番コードの動きとは別です。確定させたい場合は@Commitを付けます。

Q. rollbackForにException.classを指定すれば、すべての原因が解決しますか? A. 解決するのは原因3(検査例外)だけです。自己呼び出しや握りつぶしのように、そもそもプロキシに例外が届かないケースには効きません。早見表の順にチェックするのが近道です。

Q. Spring Framework 7やSpring Boot 4で動きは変わりましたか? A. プロキシ経由でのみインターセプトされることと既定のロールバック規則は、Spring Framework 7.0系の公式リファレンスでも同じです。6.0でprotected/パッケージプライベートが対象に加わり、6.2でrollbackOn属性が追加された点を押さえておけば十分です。

まとめ

  • @Transactionalはプロキシが処理するため、プロキシを通らない呼び出し(自己呼び出し・new・privateやfinal)には効かない
  • 検査例外は既定でコミット。rollbackForか、Spring Framework 6.2以降のRollbackOn.ALL_EXCEPTIONSで揃える
  • 例外をcatchして止めるとコミットされる。投げ直すかsetRollbackOnly()を使う
  • トランザクションはスレッド単位。@Asyncや並列処理の中は別扱いになる
  • REQUIRES_NEWは独立してコミット、内側の失敗をcatchするとUnexpectedRollbackException
  • 複数DataSourceではtransactionManagerを指定し、DB側がトランザクション対応かも確認する
  • 迷ったらisActualTransactionActive()とTRACEログで「張られているか」を見るのが早道

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring @Transactionalが効かない原因8選|ロールバックの直し方」

株式会社RJCでは、トランザクション設計のように動きの裏側を理解しておきたいテーマを、レビューやペアプログラミングを通じて仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?