ファイルシステムやRDBMSは、なんらかの形でロック機構を持っている。ロック機構がなくてはデータの一貫性が保てない。
分散環境ではロック機構が性能の鍵になる。ノードの数を増やせば記憶容量が増える(スケールアウトする)のは自明だが、ロック機構はそうではない。
また、各ノードに備わるキャッシュ(ローカルキャッシュ)も問題になる。無効になったローカルキャッシュを適切に無効化しなければならない。
これらの要求は、ファイルシステムやRDBMSなどの永続化システムに共通している。また、分散環境では、素朴な方法(環境全体が一定数の同期オブジェクトを共有するなど)ではこれらの要求を効率よく満たすことはできない。
というわけで私は、分散ファイルシステムや分散RDBMSとHDDのあいだに、もう一つのレイヤを設けることを考えた。このレイヤのことを仮に「分散永続化システム」と呼ぶ。分散永続化システムは、以下のような分散環境で有効に働き、性能がスケールアウトするような分散ロック機構を提供する。
・Ethernet相当以上の信頼性・帯域幅・遅延のネットワーク
・スイッチングハブ相当以上のパケット配送管理
・全ノードのCPU性能・ネットワーク性能・HDD容量が同等または十分
・全ノードのリストを管理できる程度の、ノードの参加・離脱頻度
・総ノード数は2以上
分散永続化システムは、以下のような特徴を備えた特殊なファイルシステムといえる。
・ファイル名のかわりにUUIDを使う。これを「semantic ID」と呼ぶ。semantic IDはファイルを作成した際に自動生成される
・ディレクトリや属性やパーミッション等の機能を持たない。ファイルエントリもない。semantic IDを知らなければ、そのファイルの存在を知る方法はない
・ファイルに対して可能な破壊的操作は、作成・更新・削除のみ。シーク等の操作はない。更新の際はファイル内容全体を置き換える
・作成・更新の際には、新しいファイル内容に対してUUIDが自動生成され、ファイルのメタデータとして保存される。このUUIDを「content ID」と呼ぶ
・ファイルのsemantic IDを知っていれば、ファイル内容だけでなく、content IDも得られる
・更新・削除の際には、そのファイルのsemantic IDだけでなく、操作前のファイル内容のcontent IDも知っていなければならない。これによってエントリに楽観ロックが働く
lsもできず、ファイル名も自由につけられないので、これはファイルとはいえない。以下では「ファイル」のかわりに「エントリ」と呼ぶ。
総ノード数と総エントリ数が十分に多ければ、全ノードに均等にエントリを割り振ることができる。手順は以下のとおり。
・各ノードは自分を表すUUIDを持つ。これを「node ID」と呼ぶ
・128ビットの整数を円周上に等間隔に並べる。semantic IDの値の点と、node IDの値の点を選び出し、2つの点のあいだの円周上の最短の道のりを得る。このようにして算出される2つのUUID間の道のりを、仮に「UUID距離」と呼ぶ。
・全ノード中、UUID距離がもっとも短い(もっとも近い)node IDで表されるノードに、そのエントリを割り振る。割り振られたエントリとノードは、相互の関係においてそれぞれ、「担当エントリ」「担当ノード」と呼ばれる。なお、UUIDの性質上、2つのノードが等距離になることは事実上ない
・担当ノードは担当エントリへの操作を受け付ける。
以上の仕組みにより、分散永続化システムのロック性能はスケールアウトする。
とはいえもちろん、1つのエントリに集中して破壊的操作がなされる場合には、総ノード数を増やしてもロック性能は変わらない。分散永続化システムはつまるところHDDに書き込む仕組みであり、HDDの回転数以上の頻度で破壊的操作ができることを期待すべきではない(実は分散永続化システムならできるのだが後述。どうせスケールアウトはしない)。
また、各ノードがHDDを備えることで、記憶容量がスケールアウトする。
ローカルキャッシュの無効化について。
・エントリが更新・削除されたとき、古いエントリ内容は一定時間(猶予期間)消さずにおく
・エントリ内容を取得する際にcontent IDを指定すると、たとえそのエントリ内容がすでに更新・削除された後でも、猶予期間内であれば、エントリ内容を取得できる
・不変のUUIDを任意に定め、これをorigin IDとする。origin IDにもっとも近いノードを選んで、「無効化発行ノード」とする
・各ノードは、更新・削除の要求を受けたとき、内部的な操作(つまり楽観ロックとHDDへの書き込み)を完了したあと、操作成功を要求元に返答する前に、操作したエントリのsemantic IDと新しいエントリ内容のcontent IDを無効化発行ノードに通知する。通知に受領返答があるまでは、操作成功を要求元ノードに返答しない
・無効化発行ノードは、一定時間(無効化間隔)おきに、更新・削除されたエントリのsemantic IDと(更新の場合は)その最新のcontent IDのリストを、マルチキャストによって全ノードに通知する。この通知を「無効化通知」と名づける。
・無効化発行ノードは、更新・削除されたエントリがない場合にも、分散永続化システムの生存を示す通知をマルチキャストで発行する。これを「生存通知」と名づける
・無効化通知と生存通知には連番が振ってあり、パケットロスが生じた場合には個別にユニキャストで再送する。抜けなく受け取れているかぎりは受領返答などは返さない
・各ノードは、猶予期間よりも長いあいだ無効化通知と生存通知を受け取れない場合、分散永続化システムに異常が生じたと判断する
ここでのポイントは、
・更新・削除のたびにマルチキャストせず、一定時間ごとに一括して行う
・破壊的操作をせず、エントリ内容の取得時に必ずcontent IDを指定していれば、近い過去(猶予期間内)のある一点におけるデータが得られる
という2点である。
特に後者は重要だ。分散環境では、最新のデータを望むなら、無効化発行ノードのように、更新・削除の通知を受けるしかない。それも操作要求元に操作成功を返答する前にだ。これは原理的なものだ。だから、もし全ノードが最新のデータを用いようとしたら、更新・削除のたびにマルチキャストが発生する。しかも投げっぱなしのマルチキャストではなく、受領返答が全ノードから返ってくるまで待たされる。
そこで、非破壊的操作しかしない場合には、データはある一定期間より古くなければ十分とする。古いデータと新しいデータがごちゃまぜにならず、時間軸上のある一点のデータであれば十分とする。データのこのような性質を、以下では仮に、「時系列的一貫性」と呼ぶ。
無効化発行ノードは、時系列的一貫性を発生させるための仕組みだ。これがないと、無効化通知の到達順が入れ替わることで、先に破壊的操作を受けたエントリのローカルキャッシュが無効化されないまま、後に破壊的操作を受けたエントリのローカルキャッシュが無効化されるという事態が生じ、ローカルキャッシュの時系列的一貫性が崩れる。
無効化発行ノードにはパケットが集中するので、ボトルネックになる可能性がある。一応、複数の無効化発行ノードを使うこともできる。たとえば以下のとおり。
・各無効化発行ノードはそれぞれ、node ID同士のUUID距離により、担当ノードを割り振られている
・無効化通知を発行するときには、それに先立って、すべての無効化発行ノードにトークンを周回させる(トークンリングのイメージ)
・トークンを受け取ったら、トークンに自分の所轄の無効化情報を付け加えて、次に渡す
・トークンを次に渡したあとは、そのトークンが一周して発行された無効化通知を受領するまでのあいだ、担当ノードからの破壊的操作の通知への返答を待つ
・トークンが一周したら、トークンについてきた無効化情報を整理し、無効化通知を発行する
しかしこの方法も無限にスケールアウトはしない。
ここまでの記述では、分散永続化システムの規模によらず無効化通知用のマルチキャストアドレス(Class DのIPアドレス)は常に1つであるかのように書かれている。実際には、一つの分散永続化システムの中で複数のマルチキャストアドレスを使うこともできる。ただし、時系列的一貫性のスコープはマルチキャストアドレスごとに異なる。異なるマルチキャストアドレスのあいだではデータの一貫性はない。(ただし分散トランザクションで一貫性を得ることはできる。後述)
時系列的一貫性は、他の一貫性(たとえばRDBMSの制約など)よりも柔軟で低コストだが、一貫性である以上、無限にスケールアウトすることはない。
非破壊的な楽観ロックと分散トランザクションについて。
破壊的操作を行う際、それと関係するデータが古くなっていては困る、という場合がある。分散永続化システムの提供するデータは、時系列的に一貫してはいるが、最新かどうかはわからない。破壊的操作をしてみなければ最新であることを保証できない、というのでは効率が悪い。そこで、非破壊的な楽観ロックが必要になる。content IDを渡して、現在もそのエントリ内容のままかどうかを問い合わせるわけだ。
非破壊的な楽観ロックは、別の破壊的操作の前提として必要になるものだ。つまり、複数のエントリに同時に楽観ロックを行う必要がある。複数のエントリはおそらく複数の担当ノードに対応する。つまり分散トランザクション処理が必要になる。
分散トランザクション処理は、担当ノード間の通知によって行える。各担当ノードは、自分の担当エントリの楽観ロックに成功したら、同じトランザクションに含まれる他のエントリの担当ノードに、自分の成功を通知する。自分の通知の受領返答が揃い、他の全担当ノードから成功の通知が揃えば、操作要求元ノードに操作成功を返答してコミット完了だ。
分散トランザクションの仕組みは、時系列的一貫性に頼らないので、時系列的一貫性のスコープが異なるエントリにまたがっていても一貫性が保たれる。
耐障害性について。
システムに2つ以上のノードがある場合、content IDに2番目に近いnode IDを持つノードが常に存在し、一意に決まる。このノードを「バックアップノード」と呼ぶ。
エントリの破壊的操作を要求するノードは、担当ノードのほかにバックアップノードにも、同じ内容の要求を出す。バックアップノードは自分では楽観ロックを処理せず(処理できるが無駄)、担当ノードが結果を知らせてくれるのを待ち、そのとおりにする。操作の要求元は、両方のノードからの返答を得て初めて操作完了とみなす。
ノード離脱時には、UUID距離の性質上、あらゆるエントリについて、バックアップノードが担当ノードに昇格する。このためノードが離脱したときも、分散永続化システムはほとんど止まらない。
ノードの参加・離脱の細かい処理は面倒なので省略するが、どうにでもなる。
前に、『HDDの回転数以上の頻度で破壊的操作ができる』と書いたが、説明しよう。
担当ノードとバックアップノード、この2つのノードに同時に破壊的操作の要求が出されるなら、楽観ロックが通った時点で、HDDへの書き込みが完了する前に、操作完了と返答してよい。
(2つのノードが同時に落ちたら? あきらめろ、だ。1つのシステムには最低で2系統の無停電電源を使い、UUID距離で隣り合うノード同士は互いに別系統の無停電電源につなぐことをお勧めする。ノードを置く部屋も電源系統ごとに分ける。遅延の短縮で得られる性能には、それだけの価値がある)
これにより分散永続化システムは、HDDの回転数以上の頻度で、1つのエントリに対する破壊的操作ができる。
Gigabit Ethernetでジャンボフレームを使えば、8KB程度のデータが1個のパケットに収まる。破壊的操作の要求元が操作成功の返答を受け取るまでの平均時間が、1msを切ることも十分ありうる。これはHDDに書き込むよりも速い。1つのエントリに破壊的操作が集中した場合にも、毎秒1000回まで耐えられるわけだ(同じノードからの連続した要求でないかぎり、楽観ロックが失敗しまくるので、成功する操作はずっと少ないが)。
各ノードはCPU・HDD・Gigabit Ethernetのセット、つまりPCを箱から出して線をつなげば一丁あがりだ。異常が起こればノードごと切り離して入れ替えればいい。
帯域が足りなければ、分散永続化システム上に実装する分散ファイルシステムでストライピングを提供するという方法がある。1つのファイルを複数のエントリにストライピングするわけだ。
分散永続化システム上に実装された分散RDBMSには、ロック機構と物理設計が不要になる。1つのエントリに破壊的操作が集中しないようにするだけでいい。
補遺。
上では、ファイルエントリに相当するものはないと書いたが、本当にまったくなければ、semantic IDとHDD上のセクタ番号を結びつけることもできない。ファイルエントリ相当のテーブルは、各ノードの内部でのみ使われる。
総ノード数が十分に多いとはいえない場合、エントリの割り振りがある程度均等になるように、node IDの値を配慮する必要がある。
上では、各ノードは分散永続化システムに関する処理しかしていないように見えるが、ほかの分散処理(Webアプリケーションなど)を動かせるし、そうすべきだ。
分散永続化システム上には、分散ファイルシステムや分散RDBMSを設けるのがいいと思うが、必要なら仮想ブロックデバイスを設けることもできる。パケットサイズにあわせた効率のいいサイズでブロックをチャンクして、エントリにマップする。チャンクサイズ以上の連続転送ではストライピングが効くので、ローカルのHDDより速いケースもあるだろう。ただし、分散ロック機構が使われないのでもったいない。
全ノードのリストはできるだけ全ノードが同じ最新のものを持っているほうが効率がいい。持っているノードリストが互いに違っていて、その違いが問題になるようなやりとりが発生した場合は、どちらのノードリストが新しいかをネゴシエーションで決める必要が生じる。
分散永続化システムは、悪意あるノードや、動作異常のノードに対して脆弱だ。動作異常のノードに対しては、他のノードがそれを検出して問題のノードを自動的に切り離せるといいかもしれない。が、下手にこれをやると、1つの異常(1つのエントリに対する操作要求の異常な集中など)が連鎖的に隣り合うノードを落としてデータの欠損に至る可能性がある。QoSなどをうまく使う必要があるだろう。
分散永続化システム上に悲観的ロックを実装する場合、ロック単位ごとにエントリを使い、ロック操作のたびにそのエントリを更新する。このときエントリ内容には、ロックをかけたノードを表すnode IDを含めなければならない。でないと、悲観的ロックをかけたノードがロックを解放しないまま落ちたとき、そのことを検出する方法がない。
頻繁に破壊的操作が行われるエントリについては、無効化通知が冗長になる。そのようなエントリは、ローカルキャッシュを無効にすればいい。操作要求への返答に、「このエントリのローカルキャッシュは常に無効です」という意味の情報を加えるわけだ。ただしこの場合も、猶予期間内はcontent IDで古いデータを参照できなければならない(でないと時系列的一貫性が崩れる)。
以上、分散永続化システムについて述べた。
おそらく、既存の研究や製品のどれかと非常によく似ているのではないかと思う。私には見つけられなかったが、ないとは思えない。ご存知のかたはご一報ください。
UUID距離というアイディアは当然ながらすでにある。たとえば分散ハッシュテーブル(DHT)はハッシュのキーの距離を利用している。楽観ロックは感動的な大発明だ。content IDで古いエントリ内容を取得できるという仕組み(つまり時系列的一貫性)は、RDBMSのMVCCを一歩進めたもので、Plan 9のVentiも似たようなことをやっている。UUID距離と楽観ロックとMVCCがあれば、semantic ID・content ID・node IDまでは一直線だ。あとの話はすべておまけにすぎない。
(semantic ID・content ID・node IDまで聞けばあとは聞く必要がない、という人でないと、この分散永続化システムの仕組みを理解するのは難しいのではないかと思う)
この分散永続化システムに、凝ったところ、作為的なところはない。あえていえば時系列的一貫性だが、ほかに合理的な解があるという気がしない。
分散永続化システムは必ず作られるか、あるいはもうどこかで作られている。
Google File System? Googleにはいいだろうが、世の中のほとんどの人はGoogle関係者ではない。あえて言おう、カスであると。