メインコンテンツまでスキップ

分散ロックと Spring トランザクション管理の順序問題

· 約9分

これはネット上であるユーザーから寄せられた質問で、私が推理して問題を解決したものです。そのため、ここに記録しておきます。

問題の背景

「分散ロックを入れたのに、データがまだ合わない」というのは、典型的な並行性の問題の一種です。ロック自体にバグはなく、トランザクションのロールバックもコミットも正常に動いている。しかし両者を組み合わせたとき、ロックの解放とトランザクションのコミットの順序がわずかに狂うだけで、直列化による保護は有名無実になってしまいます。この種の問題は負荷試験の前にはなかなか表面化せず、調査の際もロックの実装ばかりに注意が向いて、トランザクション境界を見落としがちです。このユーザーが遭遇したのは、まさにこのケースでした。

彼は zookeeper の一時順序ノードによる分散ロックを使って MySQL のデータ値を変更していました。この機能はチケット販売の仕組みに近く、高並行下でも販売枚数が一致することが求められます。多くても少なくてもいけません。

zookeeper の一時順序ノードによる分散ロックを使うにあたり、彼は service 層をロックしていました。service メソッド全体の実行が完了した時点でロックは解放されますが、このときメソッドに付与された Spring 管理の宣言的トランザクションはまだコミットされていません。このような状況は、並行時にデータの問題を引き起こします。

当然のことながら、彼のデータベースのデータには異常が発生しました。100 スレッドが同時に 1 回ずつ実行し、1 回につき 1 枚取得するなら、100 枚のチケットの残りは 0 になるはずです。しかし彼の結果は 0 ではなく、期待値に達しませんでした。

原因分析

原理はこうです。最初のスレッドが update メソッドの実行を終えた後、トランザクションのコミットはまだ完了していないのに、分散ロックが一足先に解放されてしまいます。次のスレッドがこのメソッドを実行するとき、select で取得するのは update 前のダーティリードのデータであり、これがデータの問題を引き起こします。Spring の宣言的トランザクション @Transactional(rollbackFor = Exception.class) は AOP に基づいており、メソッドの実行完了後に実行されるアラウンドアドバイスです。しかしアラウンドアドバイスがトランザクションをコミットする時点では、zookeeper のロックはすでに解放されています。この順序のずれによって、次のスレッドがダーティデータを読み、データ処理に誤りが生じるのです。

この実行順序を掘り下げてみましょう。宣言的トランザクションの本質は、Spring が対象クラスに対してプロキシオブジェクトを生成することです。トランザクションの開始とコミットはいずれもプロキシ層で行われ、ビジネスメソッドの外側を包んでいます。一方、彼のロック取得・解放のコードはビジネスメソッドの内部に書かれていました。その結果、一回の呼び出しの実際のタイムラインは次のようになります。

// プロキシオブジェクトの呼び出しタイムライン(模式図)
// 1. トランザクションインターセプタがトランザクションを開始
// 2. ビジネスメソッドに入り、zookeeper のロックを取得
// 3. select で残りチケットを照会し、update で減算
// 4. ビジネスメソッド内でロックを解放 <-- ロックはここでもう消えている
// 5. ビジネスメソッドが return し、トランザクションインターセプタがコミット <-- コミットはここ

ステップ 4 とステップ 5 の間にはウィンドウが存在します。ロックはすでに解放されているのに、トランザクションはまだコミットされていない。ロック待ちの次のスレッドがこのウィンドウ内でロックを取得して select を実行すると、前のトランザクションが未コミットのため、デフォルトの READ COMMITTED や REPEATABLE READ の分離レベルでは、減算前の古い値を読んでしまいます。つまり二つのスレッドが同じ残数を基に、それぞれ一回ずつ減算したことになります。ロックの排他性が破られたのではなく、「ロック内の読み取りは、直前のロック保持者の書き込みを必ず見られる」という暗黙の前提が破られたのです。

解決方法

したがって、Spring トランザクションと分散ロック解放の順序を修正する必要があります。先にトランザクションをコミットしてから分散ロックを解放するか、あるいは controller 層でロックしてしまえば、この問題は回避できます。

二つのやり方は本質的に同じで、どちらもロックの保持範囲がトランザクションのライフサイクルを完全に覆うようにするものです。

1)service の外側(たとえば controller や、トランザクションを持たない外側のメソッド)でロックの取得・解放を行い、トランザクションメソッドをロックに包まれた内側の呼び出しにする。メソッドが return した時点でトランザクションはコミット済みなので、その後にロックを解放します。

2)宣言的トランザクションをやめてプログラム的トランザクション(TransactionTemplate または手動 commit)に切り替え、コードの中で明示的に先にコミットし、それからロックを解放する。順序を自分の手で制御します。

注意すべき点として、ロック処理を同じクラスの別メソッドに移し、this の自己呼び出しでトランザクションメソッドに入ると、トランザクションアノテーションは効きません——自己呼び出しはプロキシオブジェクトを経由しないからです。二つのクラスに分けるか、コンテナから自分自身のプロキシを取得してから呼び出す必要があります。

分散ロックはデータ整合性と同義ではない

ここでもう一言付け加えておくと、分散ロックはデータの整合性を保証するものではなく、当該ビジネス層の service メソッドの呼び出しが一意であることを保証するだけです。

もし他の service メソッドの中に、この同じデータを処理する mapper があれば、データはやはり問題を起こします。

ですから、テーブルに楽観ロックのフィールドを追加してデータの整合性を保証するのが筋です。楽観ロックはデータの整合性をうまく維持でき、並行性能も彼が使っていた service ロックより高くなります。

楽観ロックのやり方は、テーブルに version フィールドを追加し、更新時に読み取ったバージョン番号を条件に含めることです。

-- 読み取り時に version も一緒に取得しておく
-- 更新時は旧バージョン番号を条件にし、同時にバージョン番号をインクリメントする
update ticket
set stock = stock - 1, version = version + 1
where id = #{id} and version = #{version};
-- 影響行数が 0 なら、データはすでに他者に変更されている。リトライするかエラーにする

この防御線はデータそのものに掛かっているので、どれだけ多くの入口がこの行を変更しようとも、最終的な整合性はデータベースが保証します。分散ロックが解決するのは「同じ瞬間に働いているのは一人だけ」という問題で、楽観ロックが解決するのは「たとえ複数人が働いても、データは間違わない」という問題です。両者は対象とするレイヤーが異なり、互いに代替するものではありません。

ハマりどころと注意点

1)分散ロックを入れるときは、ロックの境界とトランザクションの境界のどちらがどちらを包むのかを先に見極めましょう。ロックはトランザクションを完全に覆わなければなりません。「メソッド内でロック + メソッドに @Transactional」は、最も書きやすく、最も間違えやすい組み合わせです。

2)この種の問題は、ユニットテストや低並行のシーンではほぼ検出できません。ロック解放とトランザクションコミットの間の時間ウィンドウに並行負荷が届いたときにだけ再現します。リリース前に、減算系の API に対して並行整合性の検証を行うことは必須です。

3)データ不整合を調査するときは、ロックの実装が正しいかだけを見つめないでください。トランザクションの開始・コミットのタイミングもタイムラインに描き込むと、たいてい問題は両者の境界にあります。

まとめ

このケースではロックもトランザクションもそれぞれ正常に動いていました。問題だったのは両者の順序です。ロックがトランザクションのコミット前に解放され、次のスレッドがダーティデータを読むウィンドウを与えてしまいました。修正の考え方は、ロックがトランザクションを完全に包むようにするか、いっそプログラム的トランザクションでコミットのタイミングを明示的に制御することです。さらに一歩進めるなら、減算系のデータの正しさを入口での排他だけに頼るべきではありません。テーブルに楽観ロックのフィールドを追加し、最後の防御線をデータベース層に置くことが、より堅実なやり方です。

COMMENTS