🔖

名前付きロックで実現する排他制御

に公開

名前付きロックで実現する排他制御

1. はじめに

こんにちは、Finatextでサーバサイドエンジニアをしているtoumakidoです。

この記事では名前付きロックを使って、APIサーバで排他制御をする方法を解説しています。
名前付きロックはマイナーですが非常に便利です!
しかし、APIサーバでの実装には思わぬ落とし穴もあります。

今回は、使い所と注意点、GoによるAPIサーバでの実装方法を紹介したいと思います。

名前付きロックとは何か

MySQLの名前付きロック(Named Lock)は、データベースレベルで提供される排他制御機構の一つです。GET_LOCK()RELEASE_LOCK()という関数を使用して、任意の文字列をキーとしたロックを取得・解放できる機能です。

従来のテーブルロックや行ロックとは異なり、名前付きロックは実際のデータベースオブジェクト(テーブルや行)に依存しないという特徴があります。つまり、アプリケーション独自のロジックに基づいて、任意の処理に対して排他制御を適用できるのです。

-- ロックの取得(10秒でタイムアウト)
SELECT GET_LOCK('my_process_lock', 10);

-- ロックの解放
SELECT RELEASE_LOCK('my_process_lock');

なぜ名前付きロックが必要なのか

実際に遭遇した問題から、名前付きロックの必要性を説明します。

実際に直面した問題:SELECT FOR UPDATEの落とし穴

在庫管理システムを開発していた際、以下のような処理で排他制御を実装しようとしました。

トランザクション開始
商品の在庫をロックして取得 (SELECT FOR UPDATE)
if (在庫が存在する) {
    在庫を増やす (UPDATE)
} else {
    新しい在庫レコードを作成 (INSERT)
}
トランザクション終了

一見問題なさそうに見えますが、対象レコードが存在しない場合に予期しない動作が発生します。

問題の詳細:

  • SELECT FOR UPDATEで対象レコードが見つからない場合、共有ロックが発生(参考:MySQLで発生し得る思わぬデッドロックと対応方法
  • 複数のプロセスが同時に同じ存在しない商品コードで処理を実行(共有ロックが多数発生)
  • 書き込み時にそれぞれのレコードが共有ロックによる待機状態になり、デッドロック発生
プロセス1: 商品'NEW001'の在庫をロック → 結果なし(共有ロックがかかる)
プロセス2: 商品'NEW001'の在庫をロック → 結果なし(共有ロックがかかる)

プロセス1: 在庫がないので新規レコード作成 → プロセス2のロック解除待ち
プロセス2: 在庫がないので新規レコード作成 → プロセス1のロック解除待ち
->デッドロック発生

従来の解決策とその限界

解決策1: 親テーブルのロック

親テーブル(商品テーブルに対するカテゴリテーブルなど)をロック
その後、商品テーブルの操作を実行

この方法は有効ですが、以下の問題があります:

  • 適切な親テーブルが存在しない場合がある
  • 親テーブルのロックにより、関係のない処理まで待機させてしまう

解決策2: 初期値のレコードの事前作成

専用のロックテーブルに初期値のレコードを作成
そのレコードをロックして処理を実行し、Updateしか行わない実装にする

この方法も有効ですが、初期値のレコードを用意できない場合がある

このように、アプリケーションのロジックによって、テーブル構成を変更したくはないですし、それぞれ固有の事情で不可能な場合があります。

名前付きロックによる解決

名前付きロックを使用することで、これらの問題をシンプルに解決できます:

名前付きロック取得 ('product_ABC123')

トランザクション開始
if (在庫レコードが存在する) {
    在庫を増やす (UPDATE)
} else {
    新しい在庫レコードを作成 (INSERT)
}
トランザクション終了

名前付きロック解放 ('product_ABC123')

この解決策の利点:

  • レコードの存在に関係なく確実にロックが取得できる
  • 専用テーブルやダミーレコードが不要
  • ロック名を自由に設計できるので、主キーのカラムの値を組み合わせた文字列を設定できる
  • データベースの標準機能のみで実現可能

2. MySQLの名前付きロック機能の基礎

2.1 関数について

MySQLの名前付きロックは、以下の2つの関数によって制御されます:

GET_LOCK()関数

SELECT GET_LOCK(str, timeout);
  • str: ロック名(最大64文字の文字列)
  • timeout: タイムアウト時間(秒)
  • 戻り値:
    • 1: ロック取得成功
    • 0: タイムアウトによりロック取得失敗
    • NULL: エラー発生

RELEASE_LOCK()関数

SELECT RELEASE_LOCK(str);
  • str: 解放するロック名
  • 戻り値:
    • 1: ロック解放成功
    • 0: 元々ロックが存在しない
    • NULL: エラー発生

2.2 セッション単位でのロック管理

名前付きロックの重要な特徴は、セッション単位でロックが管理されることです。

その特徴をケースを分けて説明します。

ロック取得後、別セッションからのロック取得を待機させる

-- セッション1
SELECT GET_LOCK('test_lock', 10);  -- 1 (成功)

-- セッション2(別の接続)
SELECT GET_LOCK('test_lock', 5);   -- 0 (タイムアウト)

これは問題の解決方法として、理想的な排他制御を提供します。
この例では、セッション1がロックを保持している間、セッション2はロックを取得できません。

同一セッション内での複数ロック取得

-- 例:同じセッション内で同じロックを複数回取得
SELECT GET_LOCK('my_lock', 10);  -- 1回目
SELECT GET_LOCK('my_lock', 10);  -- 2回目  
SELECT GET_LOCK('my_lock', 10);  -- 3回目

-- 完全に解放するには3回RELEASE_LOCKが必要
SELECT RELEASE_LOCK('my_lock');  -- 1回目(まだロックは保持されている)
SELECT RELEASE_LOCK('my_lock');  -- 2回目(まだロックは保持されている)
SELECT RELEASE_LOCK('my_lock');  -- 3回目(ここで完全に解放される)

MySQLの名前付きロックは、同一セッション内では同じ名前のロックを複数回取得できます。
他のセッションがそのロックを取得するには、取得した回数分だけRELEASE_LOCK()を実行する必要があります。

異なるセッションからリリースできない

-- セッション1
SELECT GET_LOCK('test_lock', 10);  -- 1 (成功)

-- セッション2(別の接続)
SELECT RELEASE_LOCK('test_lock');  -- 0 (ロックが存在しない)

ロックの取得とリリースは同一セッションでしかできません。
この例では、セッション1がロックを保持しており、セッション2でリリースしようとしていますが、ロックが見つからず失敗しています。
これにより、セッション1のロックは残り続けることになります。

3. GoのAPIサーバでの実装

3.1 APIサーバで使用する際のセッション管理の壁

名前付きロックはセッションと密接な関わりがあるため、適切に使用するにはAPIサーバ - DB間のセッション管理を理解することが重要です。

APIサーバのDBとのセッション管理

GoのMySQLを使ったAPIサーバの場合、内部処理で実行される各クエリで使用されるセッションIDが決まっておらず、名前付きロックを実装する際の障壁になります。

Goのdatabase/sqlパッケージの特徴:

  • コネクションプールから利用可能なコネクションを自動選択
  • 各クエリ実行時に異なるセッション(コネクション)が使用される可能性がある
  • 同一リクエスト内でも複数の異なるセッションが使われることがある
  • 異なるリクエスト間で同じセッションが再利用されることもある

case1: ロック取得・解放でのセッション不一致

問題のあるコード例:

func (s *Service) ProcessWithLock(lockName string) error {
    // ロック取得(セッションA)
    _, err := s.db.Exec("SELECT GET_LOCK(?, 10)", lockName)
    if err != nil {
        return err
    }

    // 実際のselect, insert updateなどの処理

    // ロック解放(セッションB)
    if _, err = s.db.Exec("SELECT RELEASE_LOCK(?)", lockName) {
        return err
    }
}

パッケージの特徴により、名前付きロックを実装する際にGET_LOCKとRELEASE_LOCKが異なるセッションで実行されるという致命的な問題が発生しえます。
その場合、ロックのリリースに失敗し、ロックが解除されないままになってしまいます。

case2: 異なるリクエストで同じセッションのロックを取得できる

簡単に言うと:
本来「待たされるはず」のリクエストが、「待たされずに通ってしまう」問題です。

本来あるべき動作:

リクエスト1: GET_LOCK('product_123') → 成功
リクエスト2: GET_LOCK('product_123') → 待機(ユーザーAが終わるまで)

実際に起こる問題のある動作:

リクエスト1: GET_LOCK('product_123') → 成功(セッション100)
リクエスト2: GET_LOCK('product_123') → 成功(同じセッション100なので通ってしまう)

これにより、排他制御が全く機能しなくなってしまいます。

3.2 APIサーバで名前付きロックを使う方法

上記問題を解決するために、ロック操作のみの専用コネクションを使用します。

func (s *Service) ProcessWithLock(ctx context.Context, lockName string) error {
    // 専用セッションを発行
    conn, err := s.db.Conn(ctx)
    if err != nil {
        return err
    }
    defer conn.Close()
		    
    // 専用セッションでロック取得
    var result int
    err = conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, 10)", lockName).Scan(&result)
    if err != nil || result != 1 {
        return fmt.Errorf("failed to acquire lock")
    }
    
    // 専用セッションでロックリリース
    defer func() {
        if _, err := conn.ExecContext(ctx, "SELECT RELEASE_LOCK(?)", lockName); err != nil {
            log.Errorf("failed to release lock: %v", err)
        }
    }

    // トランザクションを開始
    tx, err := s.db.BeginTx(ctx)
    if err != nil {
        return fmt.Errorf("failed to begin transaction: %w", err)
    }
    defer tx.Rollback()
    
    // 在庫情報を取得
    product, err := tx.GetProductForUpdate(productCode)
    if err != nil {
        return fmt.Errorf("failed to get product: %w", err)
    }
    // 在庫情報が存在する場合は更新、存在しない場合は挿入
    if product != nil {
        if err := tx.UpdateInventory(updatedProduct); err != nil {
            return fmt.Errorf("failed to update product: %w", err)
        }
    } else {
        if err := tx.InsertInventory(newProduct); err != nil {
            return fmt.Errorf("failed to create product: %w", err)
        }
    }
    
    // トランザクションをコミット
    if err := tx.Commit(); err != nil {
        return fmt.Errorf("failed to commit transaction: %w", err)
    }   
    
    return nil
}

この解決策のポイント:

  1. 専用コネクション(conn)の確保

    conn, err := s.db.Conn(ctx)
    
    • s.db.Conn(ctx)により、コネクションプールから専用のコネクションを取得
    • このconnオブジェクトを使用することで、同一セッション内での処理が保証される
  2. 同一セッション内でのロック制御

    // GET_LOCKとRELEASE_LOCKが同じconnで実行される
    conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, 10)", lockName)
    conn.ExecContext(ctx, "SELECT RELEASE_LOCK(?)", lockName)
    
    • 同じconnオブジェクトを使用することで、MySQLの同一セッション内でロック取得・解放が実行される
    • これにより、名前付きロックの「同一セッションでないとリリースできない」という制約を満たす

なぜこの方法で問題が解決されるのか:

  • セッション一意性の保証: connオブジェクトを使用することで、GET_LOCKからRELEASE_LOCKまで同一のMySQLセッションが使用される
  • 他リクエストとの分離: 各リクエストが独自の専用コネクションを取得するため、他のリクエストと同じセッションを共有することがない
  • 確実なロック制御: 同一セッション内でのロック操作により、MySQLの名前付きロック機能が設計通りに動作する

この実装により、複数のリクエストが同時に同じロック名でアクセスした場合、適切に排他制御が働き、一つのリクエストのみがロックを取得し、他のリクエストは待機状態になります。

4. まとめ

MySQLの名前付きロック(GET_LOCK/RELEASE_LOCK)を使って、APIサーバの排他制御問題を解決する方法を紹介しました!

SELECT FOR UPDATEで存在しないレコードを扱うとデッドロックが発生する問題を、名前付きロックと適切なセッション管理でスマートに解決できます。

ぜひご活用ください!

Finatext Tech Blog

Discussion