アーキテクチャ●●●●○

分散ロック

複数ノードから同時にアクセスされる資源に、分散環境で排他制御をかける仕組み。

概要

分散ロックは、ネットワーク越しに存在する複数のノード(サーバやプロセス)が同じ資源に同時に手を出さないよう、「今この資源を触ってよいのは一人だけ」という排他制御を分散環境で実現する仕組みです。一台のマシンの中なら、OS やプログラミング言語が用意する「ロック」で同時アクセスを防げますが、資源を奪い合う主体が別々のマシンにまたがると、その手が使えなくなります。分散ロックは、その隙間を埋めるための道具です。

典型的な場面は、「複数のワーカーが動いているが、あるバッチ処理は同時に一つしか走らせたくない」「在庫が一つの商品を、二人の注文が同時に確定させてしまうのを防ぎたい」といったケースです。データベースのトランザクションだけでは守りきれない、アプリケーションレベルの「同時に一つ」を保証したいときに登場します。

実装としては、Redis のような高速なデータストアを使う方式や、ZooKeeper・etcd のような合意アルゴリズムを備えた調停基盤を使う方式が代表的です。手軽さと厳密さのどちらを取るかで、選ぶ道具が変わります。

なぜ生まれたか

単一マシンの世界では、共有資源への同時アクセスは OS や言語ランタイムが提供するミューテックス(相互排他ロック)で防げました。あるスレッドがロックを握っている間、他のスレッドは待たされます。ロックの状態はメモリという一箇所で管理されるため、「今、鍵を持っているのは誰か」に曖昧さはありません。

ところがスケーリングのためにアプリを複数台のサーバに増やすと、この前提が崩れます。各サーバはそれぞれ独立したメモリを持ち、互いの中身を知りません。サーバAの中のロックは、サーバBのプロセスには一切見えないのです。その結果、両方のサーバが「自分がこの資源を独占している」と思い込んだまま、同じ在庫を二重に引き当てたり、同じジョブを二重に実行したりする事故が起きます。マシンをまたいで一箇所で共有できる「鍵の置き場」が必要になった——これが分散ロックの動機です。全ノードが同じ場所に鍵を取りに行くようにすれば、たとえ処理が分散していても、鍵は一つに保てます。

詳細

獲得と解放の基本的な流れ

分散ロックの本質は、全ノードが共通して見られる場所(ロックサーバ)に「鍵」を一つだけ置き、それを取れた者だけが資源を触れる、という約束事です。Redis を使った代表的な方式で流れを追ってみましょう。ロックの実体は「特定のキーに値を書き込めるかどうか」で表現します。

ロックサーバワーカーBワーカーAロックサーバワーカーBワーカーA少し待って再挑戦する資源を安全に処理するこのキーをまだ誰も持っていなければ確保したい成功。あなたが持ち主です同じキーを確保したい失敗。すでに誰かが持っています自分が持ち主のときだけ解放解放しました再びキーを確保したい成功。今度はあなたが持ち主です

まずワーカーAが「このキーが未使用なら、自分の印を付けて確保する」という要求を送ります。ロックサーバはキーが空いていれば書き込みを許し、Aが持ち主になります。この「存在しなければ書き込む」という操作を一つの不可分な動作(アトミック操作)として行うのが肝心で、Redis なら SET key value NX のように、確認と書き込みを一息で行える命令を使います。確認と書き込みを別々に行うと、その隙間に別のワーカーが割り込む余地が生まれてしまうからです。

次にワーカーBが同じキーを確保しようとしても、すでにAの印が付いているため失敗します。Bは少し待って再挑戦するか、諦めるかを選びます。やがてAが処理を終えてキーを解放すると、Bの再挑戦が通り、今度はBが持ち主になります。こうして「同時に一人」が保たれます。

二つの落とし穴——期限切れと解放ミス

分散ロックの難しさは、正常系ではなく異常系にあります。第一の問題は「鍵を持ったまま死ぬ」ことです。ロックを握ったワーカーがクラッシュして解放処理にたどり着けなければ、鍵は永遠に返されず、資源が誰にも触れなくなります(デッドロック)。これを防ぐため、実務ではロックに必ず有効期限(TTL)を付けます。持ち主が一定時間で自動解放されるようにすれば、死んだノードの鍵も自然に開放されます。

ところがこの期限が、第二の問題を生みます。ワーカーAの処理が想定より長引き、鍵の期限が先に切れてしまうと、ロックサーバは「もう空いている」と判断してワーカーBに鍵を渡します。すると、まだ自分が持ち主だと思っているAと、正式な持ち主になったBが、同時に資源を触ってしまいます。さらに、遅れて我に返ったAが「解放」を実行すると、今度はBの鍵を誤って外してしまう——という二重の事故に発展します。だからこそ、解放時には「自分が書き込んだ印かどうか」を確認し、自分のものであるときだけ外す、という慎重さが要ります。図の「自分が持ち主のときだけ解放」がこれにあたります。

これらの問題は完全には消せません。より厳密さを求める場面では、ロックに単調増加する番号(フェンシングトークン)を持たせ、資源側が「古い番号の書き込みは拒否する」ことで、期限切れした古い持ち主の書き込みを無効化する、という追加の防御が推奨されます。

実装の選択肢とトレードオフ

実装は大きく二系統あります。一つは Redis に代表される、速さと手軽さを重視する方式です。単一の Redis なら極めて高速ですが、その Redis 自体が単一障害点になります。可用性のために Redis をレプリケーションで冗長化すると、今度は「マスターがロックを渡した直後に落ち、まだ複製されていなかった」場合に、昇格した別ノードが同じロックを二重に渡す危険が出てきます。この弱点を補うために複数の独立した Redis で多数決を取る Redlock という方式も提案されていますが、その安全性については専門家の間で議論があります。

もう一つは、ZooKeeper や etcd に代表される、合意アルゴリズムで裏打ちされた方式です。これらは内部で過半数の合意を取りながら状態を管理するため、「二つの真実」が生まれにくく、正しさの面で堅牢です。ノードの生存を接続の生死(セッション)と連動させられるため、「死んだら自動で鍵が外れる」も自然に実現できます。代償は速度で、合意のための調整が入る分、Redis 方式より重くなります。

したがって現実的な指針は、「二重実行が多少起きても後で辻褄を合わせられる(冪等性で吸収できる)性能重視の用途なら Redis、絶対に同時に一つでなければ困る厳密な用途なら合意ベース」という使い分けです。そして忘れてはならないのは、分散ロックは強力ですが並行性を犠牲にするため、乱用すればシステム全体の直列化を招きます。本当にロックが要るのか、トランザクションや冪等な設計で回避できないかを先に検討するのが、良い設計への近道です。