JPAで楽観的ロックを使うなら、エンティティに @Version を付けるだけで済みますよね。WHERE句の生成も、versionのインクリメントも、競合時の例外スローも全部Hibernateがやってくれます。

ところがMyBatisに移った途端、その便利さがすっと消えます。SQLを自分で書くフレームワークなので、WHERE句にversionを入れるのも、競合を検知するのも、例外を投げるのも全部自前です。この記事では、MyBatisでversionカラムを使った楽観的ロックを一から実装する方法を見ていきます。

なお、SELECT FOR UPDATEのような悲観的ロックとは目的が違うので、ここでは扱いません。

楽観的ロックとは何か

楽観的ロックは「更新する瞬間までは他人の変更とぶつからないだろう」という前提で動きます。実際に更新するタイミングで、自分が読み込んだ時点のバージョンと今のバージョンが一致しているかを確認し、ズレていたら競合として扱う仕組みです。

JPAではこの確認とインクリメントをフレームワークが裏側でやってくれますが、MyBatisでは両方をSQLそのものに書き込む必要があります。

JPAの@Versionとの対比

対比表にすると、MyBatisで何を自分で書くべきかがはっきりします。

処理JPA (@Version)MyBatis
WHERE句にversion条件を追加自動自分で書く
version自動インクリメント自動SET文に書く
更新0件の検知自動で例外化影響行数を自分でチェック
例外スローOptimisticLockExceptionSpring標準例外を利用・拡張

JPAの内部実装(Hibernateのダーティチェックの仕組み)まで追う必要はありません。「自動化されている範囲が違う」という点だけ押さえれば十分です。仕組み自体をもう少し詳しく知りたい方は JPAの楽観的ロックガイド も参考にしてください。

テーブルにversionカラムを追加する

まずはテーブル側の準備です。

ALTER TABLE items
  ADD COLUMN version BIGINT NOT NULL DEFAULT 0;

既存テーブルに後から追加する場合は、DEFAULT 0 を必ず指定しましょう。既存行のversionがNULLだと、後述のWHERE句比較がすべて失敗してしまいます。

Mapper XMLでUPDATE文を書く

ポイントは2つです。SET句でversionをインクリメントすること、WHERE句に現在のversionを条件として含めることです。

<update id="updateItem">
  UPDATE items
  SET name = #{name},
      price = #{price},
      version = version + 1
  WHERE id = #{id}
    AND version = #{version}
</update>

このWHERE句のversion条件を書き忘れると、楽観的ロックが機能しないまま普通のUPDATEになってしまいます。地味ですが一番ハマりやすいポイントです。

呼び出し側は更新前に取得したversionをそのままパラメータに渡し、戻り値は影響行数(int)で受け取ります。Mapperインターフェースの基本的な書き方は MyBatisのMapper実装ガイド で解説しているので、ここでは楽観的ロックに関わる部分だけ見ておきます。

public interface ItemMapper {
    Item findById(Long id);
    int updateItem(Item item);
}

アノテーション方式でも同じことができる

XMLを使わず @Update で書くなら、SQLをそのまま文字列にするだけです。

public interface ItemMapper {

    @Update("""
        UPDATE items
        SET name = #{name}, price = #{price}, version = version + 1
        WHERE id = #{id} AND version = #{version}
        """)
    int updateItem(Item item);
}

短いSQLならアノテーション方式のほうが見通しが良いですが、更新するカラムを <if> で切り替えたり <foreach> で複数件まとめて更新したりする場合は、XML方式のほうが可読性を保ちやすくなります。本記事のような単純な条件更新であれば、どちらでも中身は同じです。

独自例外クラスを定義する

ここで注意したいのが、Spring Frameworkには org.springframework.dao.OptimisticLockingFailureException という楽観的ロック競合用の標準例外がすでに用意されている点です。同じ名前のクラスを自作してしまうと、将来誰かがSpring標準のほうをimportしたときに名前が衝突して混乱します。

そこで、独自の診断情報(競合したエンティティのID)を持たせたい場合は、この標準クラスを継承して別名で定義するのが安全です。

import org.springframework.dao.OptimisticLockingFailureException;

public class ItemVersionConflictException extends OptimisticLockingFailureException {

    private final Long entityId;

    public ItemVersionConflictException(Long entityId) {
        super("競合が発生しました。id=" + entityId);
        this.entityId = entityId;
    }

    public Long getEntityId() {
        return entityId;
    }
}

こうしておけば catch (OptimisticLockingFailureException e) のような汎用的なハンドリングもそのまま使えますし、Spring標準の例外階層(DataAccessException)に乗ったまま、エンティティIDという独自情報だけ追加できます。

影響行数0件を競合として扱う

updateItem の戻り値が1なら成功、0なら「自分が読んだ後に誰かが先に更新した」ということになります。

int rows = itemMapper.updateItem(item);
if (rows == 0) {
    throw new ItemVersionConflictException(item.getId());
}

ただし影響行数0件の原因は競合だけとは限りません。自分が読んだ後に対象行そのものが削除されていた場合も0件になります。本当にシビアな要件なら、0件のときに findById で存在確認をしてから原因を分けるとより正確です。

この行数チェックを忘れると、更新が失敗していても呼び出し元は何も気付かず処理を続けてしまいます。静かに壊れる系のバグなので、UPDATE文を書くたびにセットで実装する癖をつけておきたいところです。

サービス層での呼び出しとリトライ

競合を検知したら、最新のversionを取り直してもう一度UPDATEする、というのが基本のリトライ戦略です。ここで一つ罠があります。

リトライのループ全体を一つの @Transactional で囲んでしまうと、MySQL(InnoDB)のデフォルトである REPEATABLE READ では、同一トランザクション内の通常のSELECTは最初に取得したスナップショットを使い続けます。つまりループ2回目の findById が、競合相手がコミットした新しいversionを読み取れず、WHERE句の比較が永久に失敗し続けるということが起こります。

これを避けるには、リトライの各試行を独立した新しいトランザクションとして実行する必要があります。TransactionTemplate を PROPAGATION_REQUIRES_NEW で使うと、自己呼び出しによるAOPプロキシの問題を避けつつ、試行ごとに新しいトランザクション(=新しいスナップショット)を開始できます。

@Service
public class ItemService {

    private final ItemMapper itemMapper;
    private final TransactionTemplate transactionTemplate;

    public ItemService(ItemMapper itemMapper, PlatformTransactionManager txManager) {
        this.itemMapper = itemMapper;
        this.transactionTemplate = new TransactionTemplate(txManager);
        this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
    }

    public void updateItemWithRetry(Long id, String newName) {
        int maxRetry = 3;
        for (int attempt = 0; attempt < maxRetry; attempt++) {
            try {
                transactionTemplate.executeWithoutResult(status -> {
                    Item item = itemMapper.findById(id);
                    item.setName(newName);
                    if (itemMapper.updateItem(item) == 0) {
                        throw new ItemVersionConflictException(id);
                    }
                });
                return;
            } catch (ItemVersionConflictException e) {
                sleepBeforeRetry(attempt);
            }
        }
        throw new ItemVersionConflictException(id);
    }

    private void sleepBeforeRetry(int attempt) {
        try {
            Thread.sleep(50L * (attempt + 1));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

試行ごとに新しいトランザクションを張ることで、findById は毎回最新のコミット済みデータを読みます。待機時間も試行回数に応じて少しずつ伸ばすようにしているので、競合が一瞬で解消しないケースでも次の試行までに相手のコミットが完了しやすくなります。

ユーザー入力をそのまま上書きするような更新は、競合時に自動リトライすると他人の変更を踏み潰してしまう場合があります。「取得した値に加算する」のような、再計算しても結果が変わらない処理だけを安全にリトライ対象にするのが現実的です。

リトライ時の注意点

リトライ回数に上限を設けないと、高頻度で競合するテーブルでは無限ループになりかねません。上限に達したら ItemVersionConflictException をそのまま上に投げて、呼び出し元やControllerAdvice側でユーザーに再操作を促すのが素直な対応です。

同じレコードへの競合が頻発するようなら、そもそも設計を見直すサインでもあります。更新対象を細かいカラムに分割する、楽観的ロックではなく悲観的ロックに切り替える、といった選択肢も検討してみてください。

まとめ

MyBatisで楽観的ロックを実装する柱は、versionカラムの設計、WHERE句とSET句を含んだUPDATE文、影響行数0件での競合検知、Spring標準例外を拡張した例外クラス、そしてトランザクション境界を意識したリトライ戦略の5つです。JPAなら @Version 一つで済む部分を自分の手で組み立てる必要がありますが、どれも仕組みとしては単純です。

Mapperの基本的な書き方は MyBatisのMapper実装ガイド、JPA側の @Version の仕組みは JPAの楽観的ロックガイド で詳しく解説しています。どちらを選ぶべきか迷っている場合は MyBatisとJPAの比較記事 も参考にしてみてください。