Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

業務で登場したDBロック待ちの3つの改善方法

こんにちは。駅奪取チームエンジニアのid:dorapon2000です。

私達のチームでは、4月〜7月にプロダクトの負荷対策に注力しました。その結果、通信量の削減やDB負荷の低減、それに伴うインフラコストの削減などに繋がりました。負荷対策の方法は手探りながら多くのことをしたのですが、その中で今回は不要なDBのロック待ちを改善した部分に注目して、どのような方法でロック待ちを改善したかについてサンプルコードを交えてお話していきます。

ロック待ちの改善にバリエーションがあることについて持ち帰っていただけると幸いです。

環境

  • Amazon Aurora MySQL version 2 (MySQL 5.7)
  • データベースエンジンはInnoDB
  • トランザクション分離レベルはREPEATABLE READ

ロックとロック待ち

詳細は他の記事にお譲りします。ここでは簡単な説明をば。

ロックをわかりやすく言うと、他の人に自分の作業領域への割り込み作業をさせない仕組みです。 もう少し具体的に言うと、DB内の指定範囲を別のトランザクションからの参照・更新を一時的に不可にさせる仕組みです。指定範囲はテーブル全体だったり、1行だったり、複数行だったりします。

特定のレコードをロックすると、他のトランザクションはロックが解除されるまで待たなくてはいけません。これをロック待ちと言います。

私のチームで起きていたロック待ちの問題

ロック待ちが瞬間的に連鎖的に発生し、そのうち大半のトランザクションがロックを獲得する前にタイムアウトによってエラーになっていました。 大量のトランザクションがタイムアウトまで負荷をかけ続ける状態です。

負荷がかかる様子はAWS RDSのPerformance Insightsで確認できます。

ロック待ちを見つける

RDSのPerformance Insightsでは負荷の原因となった発行クエリは見られても、ソースコード内の場所までは特定できません。

そのため、私達は以下の方法でロック待ちが多発している箇所を特定しました。

  1. 発行クエリからソースコードの該当箇所を予想する
  2. 負荷がかかりそうな処理のソースコードをコードリーディングする
  3. クエリの発行箇所のファイル名と行数をコメントとして発行クエリに付随させる仕組みを自作する

特に3つ目の方法により、Performance Insights 上でクエリの呼び出し元が明確になりました。

修正の方針

問題のロック待ちが多発する箇所を特定したら、次は修正です。

  • 本当にそのロックは必要か
  • 条件で絞り込み、ロックを取る回数を減らせないか
  • ロックを取る前に早期returnができないか
  • そもそも、その処理は必要か

修正例① 利用されないuserロック

最も素朴な修正例です。

    db->txn_do(sub { # BEGIN
        $user->lock # SELECT * FROM user WHERE id = :user_id FOR UPDATE;

        return if 9割Trueになる条件;

        処理A
        処理B # 実はuserレコードをロックしている
        処理C # userレコードのロックが必要
    }); # COMMIT

以下のようにすることで無駄なロックを削除できました。

    db->txn_do(sub {
-       $user->lock
-
        return if 9割Trueになる条件;

        処理A
        処理B
        処理C
    });

解説

利用されないロックは削除すればいいです。しかし、言うは易く行なうは難し。 実際に利用されていないことを証明するために、userロック以降のすべての処理を目で追いかけました。

その結果、処理Bで実は重複してuserロックをしていることがわかり、それ以前でuserロックを利用する処理がないことがわかりました。 トランザクション先頭のuserロックを削除することで、10割userロックしていたコードは1割しかロックしないコードになりました。

修正例② 早期return

美しくないですが簡単で大きな効果があった修正例です。

    db->txn_do(sub { # BEGIN
        $user->lock # SELECT * FROM user WHERE id = :user_id FOR UPDATE;
        return if $user->has_active_license; # ほぼここで早期returnする

        ライセンスが切れている場合の処理
    }); # COMMIT

以下のようにすることでロックの回数を減らす事ができました。

    db->txn_do(sub {
+       return if $user->has_active_license;

        $user->lock
        return if $user->has_active_license;

        ライセンスが切れている場合の処理
    });

解説

修正前の問題点

has_active_licenseは、ライセンスの有効期限が切れていればFalseを、切れていなければTrueを返すメソッドです。ほとんどの場合で切れていないためTrueを返し、ソースコード上は早期returnします。 修正前のコードの問題点は、ほとんどの場合で早期returnして何もしないにも関わらず、必ずuserロックが取られてしまうことです。

ifの前にロックを取る理由

has_active_licenseの前でロックを取る理由は、最新のユーザ情報を取得したいからです。MySQLでは、SELECT FOR UPDATEをすることで、トランザクション中に別トランザクションでレコード更新が発生しても、更新後の値を読み取ることができます。つまり最新のレコード情報を取得できます。Locking Readと呼ぶようです。詳しくは以下の記事が詳しいです。

漢(オトコ)のコンピュータ道: InnoDBのREPEATABLE READにおけるLocking Readについての注意点

早期returnの重ねがけ

さて、本題である早期returnを重ねて書くことの効果について説明します。第一にコードが冗長以外の副作用がないことは明らかです。第二にロックの回数を減らす事ができます。説明すると言ってもこれだけですが、非常に嬉しいわけです。すべてのコードを目で追いかける必要がありません。

Locking Readをしない1回目の早期returnはユーザ情報が古く、ライセンスが切れているのに切れていないと判定される可能性があります。それをLocking Readする2個目の早期returnで拾ってあげます。その逆であるパターンはロジック上存在しないため考慮しません。

修正例③ キャッシュを使う

まずは修正前のコードから。

    db_master->txn_do( sub { # BEGIN
        $user->lock # SELECT * FROM user WHERE id = :user_id FOR UPDATE;

        ログイン処理A if 11回だけ
        ログイン処理B if イベント参加後に11回だけ
        ログイン処理C if API経由のアクセスを含めて11回だけ
        ログイン処理D if など
    }); # COMMIT

ログイン情報をキャッシュに保存して、キャッシュがないときに限りトランザクションの処理を実行するようにしました。

+   my $cache_key = $class->generate_key($user->id、日付、イベント参加してるかのフラグ、API経由かどうかのフラグ);
+   my $is_already_logged_in = cache->get($cache_key);
+   return if $is_already_logged_in;

    db_master->txn_do( sub {
        $user->lock

        ログイン処理A if 1日1回だけ
        ログイン処理B if イベント参加後に1日1回だけ
        ログイン処理C if API経由のアクセスを含めて1日1回だけ
        ログイン処理D if など
    });

+   cache->set($cache_key => 1);

解説

修正前の問題点

ほとんどの場合で処理が何もされないにも関わらず、userロックを取っていることです。

userロックを取る理由

前述と同様に最新の情報がほしいためでもありますし、ログイン処理の中で並列に実行されると不具合・不整合が起きる箇所が多くあるためでもあります。

キャッシュ利用によるロック回避

修正例③の解決方法は修正例②と思想は同じです。ロックを取る前に条件に合致しないときだけ早期returnして、合致したときは処理を実行するようにしています。 今回はその条件をキャッシュから取得できるにしています。

ポイントはキャッシュに使うキー(鍵)です。ログイン処理A/B/C/Dのいずれかを実行する必要があるとき、キャッシュキーが存在していなければいいわけです。例で示します。

  • その日1回もアクセスしたことがない
    • キャッシュキーはないため、早期returnされない
    • ログイン処理ABCDが実行される
    • login_1_20221216_false_false のような値をキーとしてキャッシュが作成される
  • その日2回目のアクセス
    • キャッシュキーは login_1_20221216_false_false ですでに存在し、早期returnされる
    • ログイン処理は実行されない
  • その日3回目のアクセス時にイベントにも参加していた
    • キャッシュキーは login_1_20221216_true_false で存在せず、早期returnされない
    • ログイン処理Bのみ実行される
    • login_1_20221216_true_false のような値をキーとしてキャッシュが作成される

キャッシュを使う方法は、ログイン処理全体をリファクタリングせずにロックを削減できる点が嬉しいです。

さいごに

並列実行を回避するためにuserレコードをロックしたくなりますが、ロックを剥がす労力は地道で相当だということが大きな学びでした。より影響が小さいレコードでロックできないか、そもそもロックを回避できないか。軽い気持ちでuserロックをすると将来痛い目に合うかもしれません。