BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル Jepsen: PostgreSQL, Redis, MongDB および Riak の分割耐性をテストする

Jepsen: PostgreSQL, Redis, MongDB および Riak の分割耐性をテストする

ブックマーク

原文(投稿日:2013/06/20)へのリンク

分散システムの特徴は、遅延が大きい、もしくは信頼性の低いリンク層を超えて状態情報を交換することにある。こうしたシステムは、もし確実な運用が行われていたとしても、ノードやネットワークの障害に対して堅牢でなければならない。なぜなら、あらゆるシステムが我々が望むような安全性の条件を満たすわけではないからだ。この記事では、分散データベースの設計で考慮すべき事柄について、そしてどのようにそれらがネットワーク分割に対応しているのかについて調べてみよう。

 

IPネットワークでは、ノード間で送信するメッセージが不規則に欠落したり、遅延したり、順序の入れ替えが生じたり、または重複したりする。そのため、たいていの分散システムではメッセージの順序の入れ替わりや重複を避けるためにTCPを用いる。しかし、TCP/IP もまだ根本的には非同期的である。すなわち、そのネットワークはメッセージを不規則に遅延させるし、コネクションはいつ落ちるか分からない。さらには、障害検知の信頼性がない。つまり、どこかのノードが死んだのか、ネットワーク接続が落ちたのか、期待したほど速度が出ていないのか等々・・・どれが原因なのか検証するのは不可能なのである。

こうしたタイプの障害、すなわちメッセージが不規則に遅延したり欠落したりするような障害は、ネットワーク分割と呼ばれる。分割は様々な理由によってネットワーク上で発生しうる。ガーベッジコレクションの負荷や、NIC不具合、ネットワークスイッチのファームウェアのバグ、設定ミス、輻輳やバックホー(=電線を引きちぎる建設機械)等々、がその理由である。このような分割が起きる場合、分散システムが保証できる最大性能はCAP定理によって制約を受ける。メッセージが欠落した際、「一貫性のある(CP)」システムは、いくつかのノード上でいくつかのリクエストを拒否することによって、線形性を担保する。「可用性のある(AP)」システムは、すべてのノードでリクエストを処理できるが、線形性は犠牲にしなければならない。すなわち、異なるノード間で命令が実行された順序を同期することはできない。システムは、ネットワークが正常なときは一貫性(C)と可用性(A)を保つことができるが、実際のネットワーク分割が発生すると、完全にCA条件を満たせるシステムは理論上ありえないのである。

CAP定理は、データベース全体に対してのみ適用されるのではなく、テーブル、キー、カラム、個別の操作コマンドに対しても適用されるものである。例えば、あるデータベースは各々のキーについては線形応答時間を提供できるが、複数のキーの間ではそれは成立しない。多くのデータベースでは、速度と正確性との間のトレードオフのバランスをとりつつ、読み込み・書き込みそれぞれに独立して、一貫性レベルを調整可能にしている。

ネットワーク分割をテストする

理論は設計の限界(=性能・機能の上限)を示しているが、実際のソフトウェアがそのような限界に到達することはまずない。それゆえ我々は、システムがどのように動作するのかを本当に理解するためにテストする必要がある。

まず、読者はテストのためのノード群を集める必要がある。私は1つのLinuxマシン上の5つのLXCノードを用いているが、読者は Solaris ゾーンや、仮想マシン、EC2ノード、物理ハードウェアなど何を使ってもよい。私は自分のノードに、n1,n2,... n5 という名前をつけており、これらとホストOSとの間のDNSを設定している。

ネットワーク分割を発生させるために、メッセージを欠落させたり遅延させたりする手段が必要である。例えば、ファイアウォールのルールを用いる方法がある。Linuxでは 『iptables -A INPUT -s some-peer -j DROP』というコマンドを用いて単方向の分割、すなわちあるピアからローカルノードへのメッセージの欠落を発生させることができる。いくつかのホスト上で、こうしたルールを適用することにより、任意のパターンでネットワーク損失を作りだすことができる。

いろいろなホスト上でこういうコマンドを繰り返し実行するのは、少々骨が折れる作業である。筆者は自作の Salticid というツールを使っているが、読者は、CSSHなどのクラスター自動化システムを用いても良いだろう。ツールを選ぶ鍵となるのは作業の速さである。ネットワーク分割をすばやく初期設定したり、また元に戻したりできるようにしたいなら、Chefなどの収束が遅いシステムはたぶんあまり使いやすくない。

次に、これらのノード上で分散システムを構築し、それをテストするためのアプリケーションを設計する必要がある。私は次のようなシンプルなテストを作成した:5つの分離されたクライアントを模擬するスレッドを備えた、クラスターの外側で動くクロージャプログラムである。このクライアント群は並行に動作し、分散システム上の集合にN個の整数を加える。あるクライアントは、0, 5, 10, ...を書き込み、別のクライアントは、1, 6, 11, ...を書き込み、...といった具合である。各クライアントは、書き込み動作とその成功・失敗をログに記録する。すべての書き込みが完了したら、クラスターが「収束(=安定化)」するのを待って、クライアントログが実際のデータベースの状態と合致するかどうかをチェックする。これは単純な一貫性チェックであるが、様々なデータ・モデルのテストに対応できる。

本節で紹介した、分割をシミュレートしたりデータベースを設定するスクリプト類を含む、クライアントコードと設定の自動化ツールは自由に利用可能である。利用方法やコードを、ここから入手してほしい。

PostgreSQL

単一ノードのPostgreSQLインスタンスは、CPシステムである。すなわち、ノードが故障したらDBが使用不能となるかもしれないリスクと引き換えに、トランザクションに対して直列化(シリアライズ)可能な一貫性を提供することができる。しかし、サーバクライアントからなる分散システムは一貫性がないかもしれない。

Posgres のコミットプロトコルは、2フェイズコミットの特殊な使用例である。第1フェイズでは、クライアントは、現在のトランザクションのコミット(または中断)するための「投票」を行い、そのメッセージをサーバに送信する。サーバは、一貫性の制約条件によってそのトランザクションの進行が許されるかどうかを確認し、もし条件を満たすならコミット実行を決定する。そしてサーバは、そのトランザクションの内容をストレージに書き込み、コミットが実行完了したことを(または失敗したことを)クライアントに連絡する。こうして、クライアントとサーバは、トランザクションの結末について合意するのである。

クライアントが受信する前に、コミットを知らせるメッセージが欠落したら何が起こるだろうか?その場合は、クライアントはコミットが成功したかどうか知らないのだ! 2フェイズコミットプロトコルではノードは結果を決定するために、到着する通知メッセージを待たなければならない。もしそれが到着しないなら、2フェイズコミットは失敗して停止する。つまりこれは、分割耐性のあるプロトコルではない。現実のシステムでは、永久にメッセージを待つことはできないので、どこかの時点でクライントはタイムアウトし、コミットプロトコルの不定な状態から抜け出すことになる。

このタイプの分割を引き起こすと、JDBC Postgresクライアントは、次のような例外をスローする。

217 An I/O error occurred while sending to the backend.   
Failure to execute query with SQL:
INSERT INTO "set_app" ("element") VALUES (?)  ::  [219]
PSQLException:
Message: An I/O error occured while sending to the backend.
SQLState: 08006
Error Code: 0
218 An I/O error occured while sending to the backend.

上記を我々は、「217番、218番を書き込むトランザクションが失敗した」というように解釈するかもしれない。しかし、テストアプリがどの書き込みトランザクションに成功したのかデータベースに問い合わせると、次のように2つの「失敗」書き込みが実際に存在することがわかる。

1000 total
950 acknowledged 
952 survivors 
2 unacknowledged writes found! ヽ(´ー`)ノ 
(215 218) 
0.95 ack rate 
0.0 loss rate 
0.002105263 unacknowledged but successful rate

1000回の書き込みのうち、950回は正常に通知されており、950回全ての書き込みは、結果に現れている。しかし、2つの書き込み(215番、218番)は、例外をスローしているにもかかわらず成功となっている! その書き込みが実際に成功したのか失敗したのかを、この例外は保証していない事に注目しよう。217番も同じく送信時に I/Oエラーをスローしたが、クライアントのコミットメッセージがサーバに到着する前にコネクションが落ちたため、トランザクションは、完了しなくなっている。

このような状況をクライアント側から区別できるような信頼性のある方法は存在しない。ネットワーク分割―および事実上たいていのネットワークエラーは―処理の失敗を意味しない。それは、情報の*欠落*を意味しているのである。拡張3フェイズコミットのような、分割耐性のあるコミットプロトコルでなければ、我々はこうした書き込みの本当の状態(結果)を断定できないのである。

読者は、この操作を繰り返し行い、何も考えずにリトライするか、またはトランザクションデータ自体の一部としてトランザクションIDを書き込み、しばらくして分割が収束してからそれを問い合わせることで、この決定不能性を発生させることができる。

Redis

Redis は共有ヒープとしてよく使用される、データ構造サーバである。一つのシングルスレッドサーバで動くので、デフォルトで線形化可能な一貫性を提供する。すなわち、全ての操作は単一で完全に決まった順序で発生する。

Redis はまた、プライマリサーバからセカンダリサーバへの非同期レプリケーション(複製)機能を提供している。書き込みを受け付けられるプライマリとして、ある一つのサーバが選択される。プライマリサーバは、自身の状態変化を関連するセカンダリサーバ群に伝達する。この状況では、非同期とは次のような意味である。すなわち、プライマリが与えられた操作を複製している間はクライアントがブロックしないこと―つまり、書き込みは「やがては」セカンダリに到着するだろう、という意味である。

発見、リーダの選択、そしてフェイルオーバに対処するため、Redisは Redis Sentinel という、連携システムを含んでいる。Sentinelのノード群は、彼らから見えるRedisサーバ群の状態について『詮索』し、単一の正式なプライマリサーバを維持するように、ノードをプライマリに昇格させたり降格させたりしようとする。このテストでは、私は5つすべてのノードにRedisとRedis Sentinel をインストールした。最初は、5つすべてのクライアントは n1のプライマリから読み込み、n2-n5は、セカンダリだった。それから我々は、n1 と n2 を n3,n4,n5 から分割した。

もしRedisがCPシステムなら、ネットワーク分割の間はn1とn2は使用不能となり、多数側(n3,n4,n5)で新しいプライマリが選択されるだろう。しかしこの場合はそうではない。代わりに、書き込みは、依然としてn1に対して正常に完了を続けている。その何秒か後には、Sentinelノードが分割を検知し始め、そして(新しいプライマリの)選択処理を実行し、n5 が新しいプライマリであると宣言する。

分割している間は、2つのプライマリノードが存在する。それは、分割されたネットワーク領域毎に一つづつ存在し、別々に書き込みを受け付ける。これは、古典的な分離脳のシナリオであり、CP特性の「C」を損なわせるものである。この状態での書き込み(および読み込み)は、線形化可能ではない。なぜなら、各クライアントがどのノードと交信しているかにより、データベースの異なった状態が生じるからである。

分割が解消したらどうなるか? 昔のRedisは、プライマリが無限に走り続けるようにしていたので、昇格を引き起こしてしまう分割はすべて永久的な分離脳の原因となっていた。しかし、この件は、2013年4月30日にリリースされたRedis2.6.13で変更された。現状では、Sentinel はオリジナルのプライマリを降格させ、手続きの中の潜在的に結びつきのない書き込みを破壊することによって、衝突を解決している。たとえば、次のようになる。

2000 total 
1998 acknowledged 
872 survivors 1126 acknowledged writes lost! (?°□°)?? ┻━┻ 
50 51 52 53 54 55 ... 1671 1675 1676 1680 1681 1685 
0.999 ack rate 
0.5635636 loss rate 
0.0 unacknowledged but successful rate

上記を見ると、2000回の書き込みのうち1998回が成功して完了した、とRedisは主張している。しかし実際は、これらの整数のうち872個しか最終的なデータセットのなかに現れていない。つまり、Redisは、成功したと主張している書き込みの56%を見逃していたのである。

ここには2つの問題がある。まず第一に、クライアント全てがパーティションの初期(50,51,52,53,・・・など)に書き込みに失敗していたことがわかる。これは、ネットワークが落ちた時、それらが全て n1 に対する書き込み中だったことが理由である。そして、n1 がその後降格されたので、その期間に実行されたどの書き込みも破棄されたのだ。

第二の問題は、分離脳によるものだ。n1 と n5 は共に分割が解消するまでプライマリであった。どのノードと交信しているかによって、書き込みが成功するクライアントもあれば、失敗するクライアントもあっただろう。データセット内の最後のほうのいくつかの数字の(5で割った余り)は、すべて0と1であった―それは少数側グループでn1をプライマリとして使用しつづけたクライアント群に該当するものだ。

フェイルオーバに対するどのレプリケーション方式でも、Redisは、高可用性も一貫性もどちらも提供しない。無作為なデータ欠損や破損が受け入れられる場合の、「ベストエフォートな」キャッシュおよび共有ヒープとしてRedisを使うのみにしよう。

MongoDB

MongoDB は、ドキュメント指向のデータベースであり、その分散設計はRedisと類似している。1つの複製セットの中で、単一のプライマリノードが存在し、それは書き込みを受け付け、その操作のログ(『oplog』と呼ぶ)をN個のセカンダリノードに非同期に複製する。しかしMongDBでは、いくつかの鍵となる特徴が Redisとは異なっている。

まず第一に、Mongoはリーダ選出機構と複製された状態機械を組み込んでいる。複製集合がなにをすべきかを決定をするために、それを観測しようとするような別のシステムは存在しない。どのノードがプライマリになるべきか、いつステップダウンするのか、どうやって複製するのか・・・等々について、複製集合は自分で意思決定するのである。このことにより操作は単純になり、ネットワークトポロジ上の諸々の問題が全てなくなる。

第二に、Mongoでは、プライマリノードまたはセカンダリのログによって、書き込みのレプリケーションが成功したことを確認するように、ユーザはプライマリノードに要求できる。さらに、遅延という犠牲を払えば書き込みが成功したのかどうかについてもっと強い保証を得ることができる。

このことをテストするために、私は5つのノードからなる複製集合を用意した。プライマリはn1である。各クライアントは、(MongoDBでの一貫性の単位である)単一のドキュメントに対して、「比較してから値を設定」式のアトミックな更新処理によって、各々の書き込みを行う。その後、私は n1とn2をクラスタから分割させて、Mongoがノードの多数派から新しいプライマリを選出し、n1を降格させるようにさせた。そしてしばらくの間、分割された状態でシステムが稼働するようにし、それからノードを再接続して、Mongoがこのテストの終了までにクラスタを再収束することを許した。

"write concerns"と呼ばれる、MongoDBに対するオペレーションの一貫性レベルがいくつかある。現在までのところ、そのデフォルト設定はいかなるタイプの不具合のチェックもとにかく避けるというものである。Java ドライバでは、これを WriteConcern.UNACKNOWLEDGED と呼んでいる。当然、このアプローチでは分割している間「成功した」(と報告される)書き込みをいくつ失っていてもおかしくないことになる。例えば以下のように。

6000 total 
5700 acknowledged 
3319 survivors 
2381 acknowledged writes lost! (?°□°)?? ┻━┻ 
469 474 479 484 489 494 ... 3166 3168 3171 3173 3178 3183 
0.95 ack rate 
0.4177193 loss rate 
0.0 unacknowledged but successful rate

この試行では、書き込みの42%、すなわち3183回中469回が失われている。

しかし、WriteConcern.SAFE オプション、すなわちプライマリに対するデータのコミットが成功したことを確認するオプションを適用しても、以下のように多数の書き込みを失ってしまう。

6000 total 
5900 acknowledged 
3692 survivors 
2208 acknowledged writes lost! (?°□°)?? ┻━┻ 
458 463 468 473 478 483 ... 3075 3080 3085 3090 3095 3100 0.98333335 ack rate 
0.3742373 loss rate 
0.0 unacknowledged but successful rate

レプリケーションのプロトコルが非同期的であるため、たとえ n1 のクラスタ内の他のノードへのレプリケーションが不可能となっていても、クライアントから見た n1 への書き込みは「成功」しつづけてしまう。多数側のノード群で、分割後に選ばれたプライマリがn3だった場合、書き込み結果は、ログ履歴の古いバージョン(因果関係的にn1への書き込みからは切り離された履歴)によるもの同じとなる。n1が、それがステップダウンしなければならないと気づくまでは、その2つの履歴は一貫性のないまま進展したのだ。

分割が解消した時、Mongoは、どのノードが正統なプライマリであるかを決定しようと試みる。もちろん、分割中に両方が書き込みを受け付けたのだから、どこにも正統なノードなどない。代わりに MongoDBは、最も適したノード(=そのノードの oplogのタイムスタンプが、単調増加的であるもの)を発見しようと試みる。その後、Mongoは古いプライマリ(n1)を、分割された双方の間の最終の共通ポイントまで強制的にロールバックさせ、n3に対して行われた操作を再適用する。

ロールバックでは、MongoDBは、競合しているオブジェクトの現在の状態のスナップショットをBSONファイルとしてディスクにダンプする。オペレータは、該当するドキュメントを後で望ましい状態に再構築してみることもできる。

このシステムはいくつかの問題を抱えている。まず第一に、リーダ(=プライマリ)選出コードにバグがあるのだ。つまりMongoDBは、到達可能集合の中で最も適したものではないノードを昇格させるかもしれないというわけだ。第二に、ロールバックコードにバグがある。私のテストでは、ロールバックはおよそ10%しか稼働しなかった。ほとんど全てのケースで、MongoDBは衝突したデータを全部捨ててしまったのだ。さらには、ロールバック中、全てのタイプのオブジェクトが完全にログ記録されるわけではない。たとえば capped collections 型オブジェクトでは、設計仕様により、全ての競合データが廃棄されてしまう。第三に、上記のシステムが正しく動いたとしても、ロールバックログは線形性を回復させるのに十分なものになっていない。ロールバックバージョンと oplog は、きちん定義された因果関係の順序情報を共有していないため、順序とは関係のないマージ機能(例:CRDTs)だけしか、ドキュメントの正しい状態を再構成することができない。

この線形性の欠落は、FSYNC_SAFE, JOURNAL_SAFE, さらには REPLICAS_SAFE(=リクエストが成功するよりも前に、その書き込みが2つの複製されたプライマリに通知されることを保証するオプション)を設定したとしても、以下のように当てはまってしまう。

6000 total 
5695 acknowledged 
3768 survivors 
1927 acknowledged writes lost! (?°□°)?? ┻━┻ 
712 717 722 727 732 737 ... 2794 2799 2804 2809 2814 2819 
0.94916666 ack rate 
0.338367 loss rate 
0.0 unacknowledged but successful rate

MongoDBモデルで線形性を回復する唯一の方法は、ノードのクオラムが応答するのを待つことである。しかし、WriteConcern.MAJORITY オプションは、成功を通知した書き込みを「落とし」、失敗したと通知した書き込みを「回復して」しまうなど、依然として一貫性がない。

6000 total 
5700 acknowledged 
5701 survivors 
2 acknowledged writes lost! (?°□°)?? ┻━┻ 
(596 598)
3 unacknowledged writes found! ヽ(´ー`)ノ
(562 653 3818)
0.95 ack rate 
1.754386E-4 loss rate 
5.2631577E-4 unacknowledged but successful rate

ネットワーク分割中に、UNSAFE, SAFE そして REPLICAS_SAFE オプション設定によって、書き込みの一部または全部が失なわれる場合は、MAJORITYオプションを使うと、失われるのは分割が始まった時点で「飛行中(=リクエスト実施中)」であった書き込みのみとなる。プライマリが降格する際には、WriteConcern が達成されたかどうかに関係なく、各リクエストへの応答を「真」とするために OK を設定することで、全ての WriteConcern リクエストを承認する。

さらに言えば、MongoDBはいくらでもFalse Negative(偽陰性応答)を送出することさえできてしまう。この試行では、クライアントに結果が通知されない書き込みが3つあったが、実際にはそれらは最終的なデータセットでは回復されていた。少なくともバージョン2.4.1以前では、どんな一貫性レベルを設定しようとも、ネットワーク分割中のデータロスを防ぐ手段は存在しない。

もし読者がMongoDBで線形可能性を必要とするなら、WriteConcern.MAJORITYを使うことだ。それは実際には完全一貫性はないが、書き込みが失われる期間を劇的に削減してくれる。

 

Riak

Dynamoクローンと同様に、ネットワーク分割耐性へのアプローチとしてRiakはAPを用いる。Riakは不一致の履歴を因果律的に(不一致の原因が分割によるものか、または通常の並行書き込みによるものなのか)検知し、そして全ての一致しないオブジェクトのコピーをクライアントに提示する。その後、そのクライアントはそれらを一つにマージする方法を選択しなければならない。

Riakのデフォルトのマージ機能は、「書き込みの後勝ち(LWW)」方式である。各書き込みはタイムスタンプを含んでおり、値同士のマージは、より大きいタイムスタンプを有するバージョンの値だけを保持することで実行される。もしクロックが完璧に同期していれば、この方式は Riak が最新の値を選択することを保証する。

分割やクロックのひずみがないとしても、因果律的には「並行書き込み」の意味するものは、「後勝ち方式」が、成功していると見えて実際は失敗している書き込みを発生させるということである。例を以下に示そう。

2000 total 
2000 acknowledged 
566 survivors 
1434 acknowledged writes lost! (?°□°)?? ┻━┻ 
1 2 3 4 6 8 ... 1990 1991 1992 1995 1996 1997 
1.0 ack rate 
0.717 loss rate

このケースでは健全なクラスタが操作の71%を失った―2つのクライアントがほぼ同時に値を書き込んだ時、Riakは単にタイムスタンプが大きいほうの書き込みを取り上げ、その他は(新しい数字をつけ加えたところだったかもしれないが)無視したからである。

しばしば、並行性を抑制するロックサービスを付け加えることによって人々はこの問題を解決しようとする。ロックは線形性を備えているに違いないが、分割が起こっている間は、分散ロックシステムは完全には使えるようにならないことを、CAP定理は我々に教えてくれる―しかし、もしロックが完全だったとしても書き込みロスを妨げることはないだろう。ここに、R=W=QUORUM(最低ノード数)と設定されたRiakクラスターがある、そこではすべてのクライアントはミューテックスを使って自身の読み込みおよび書き込みをアトミックに実行する。分割が起った時、Riak は「成功」とされた書き込みのうち91%を失った。

2000 total 
1985 acknowledged 
176 survivors 
1815 acknowledged writes lost! (?°□°)?? ┻━┻ 
85 90 95 100 105 106 ... 1994 1995 1996 1997 1998 1999 
6 unacknowledged writes found! ヽ(´ー`)ノ 
(203 204 218 234 262 277) 
0.9925 ack rate 
0.91435766 loss rate 
0.00302267 unacknowledged but successful rate

実際、LWW(last-write-wins)は「無境界データ欠損」を引き起こす。それには分割が発生するより前に書き込まれた情報の欠落も含まれる。(設計仕様により)Dynamoがいいかげんにクラスター断片を扱うこと(=分割されたネットワークの両側に押しやられた vnode はRとWを満足することができてしまうこと)を許してしまうため、このことは起こりえるのである。

我々は 、PRとPWーこれらはオリジナルの vnode 群のクオラムがその操作を通知する場合にだけ成功する―を用いることにより、厳密なクオラムを使うように Riak に指示できる。(しかし)もし分割が起これば、やはりこれも下記のように無境界データ欠損を引き起こす。

2000 total 
1971 acknowledged 
170 survivors 
1807 acknowledged writes lost! (?°□°)?? ┻━┻ 
86 91 95 96 100 101 ... 1994 1995 1996 1997 1998 1999 
6 unacknowledged writes found! ヽ(´ー`)ノ 
(193 208 219 237 249 252) 
0.9855 ack rate 0.9167935 loss rate 
0.00304414 unacknowledged but successful rate

Dynamo は可能な限り書き込みを保存するように設計されている。あるキーについて、プライマリ vnode 群に対して複製ができないため、ノードが「PW値が不十分」と応答したとしても、そのノードは一つのプライマリ vnode 、または撤退(fallback)したいくつかの vnode群に書き込むことが可能になっているはずである。これらの値は、衝突と見なされて、読み込みの修復処理の間に交換される。その際には、「古い」値―分割されたクラスタの片側に由来しているもの―を捨て去るためにタイムスタンプが使われる。

このことは、(分割されたクラスタの)少数派側の「失敗した」書き込みが、多数派側の成功した書き込み全てを破棄できることを意味している。

CRDTs を用いるAPシステムでは、データの保護が可能である。もし我々がデータ構造として set(集合) を使い、マージ機能としてユニオンを使うなら、どんな分割が起こっても、次のように全ての書き込みを保存することができる。

2000 total 
1948 acknowledged 
2000 survivors All 
2000 writes succeeded. :-D

これは、線形化可能な一貫性ではない、全てのデータ構造がCRDTs として表現できるわけでもない。さらにこれは偽陰性応答(false negative)を防ぐこともできないが―その場合Riak はタイムアウトしつづけるか、または失敗を報告をする―しかし受け入れた書き込みに対しては安全な収束を保証する。

もし Riak を使うならCRDTs を使うか、または可能ならマージ機能を出来るだけ使って書き込むこと。LWWが妥当なケース(例えば、不変データ)は稀である。その他のケースではそれ(=LLW)を避けておくこと。

計測の前提条件

分割された状況のもとでは、分散システムは予期できない方法で振る舞うのを我々は見てきた―しかし、これらの障害の本質は数多くの要因に依存している。データ損失や一貫性が壊れる確率は、アプリケーションの性質、ネットワーク、クライアントのトポロジ、タイミング、失敗の性質・・・等々に依存している。私は、特定のデータベースを選択するように仕向けるよりは、必要な不変条件、受け入れ可能なリスク、そしてそれらの結果にもとづいたシステム設計について、読者が注意深く考察することを推奨する。

そうした設計を生み出すための鍵となる要素は、その設計を計測してみることである。まず第一に、システムの境界を決定するーすなわち、システムがどこでユーザやインターネットまたは他のサービスと繋がるのかについての仕様の決定である。そして、これらの境界面において必ず守らなければならない保証事項について決定することである。

そして、その境界のちょうど外側からシステムにリクエストをだすプログラムを書き、その外部への応答を計測すること。どのHTTPリクエストがコード200を返すのか、または503を返すのかを記録したり、各エンドポイントへのポストに含めたコメントのリストを記録しよう。また、プログラムが稼働している間、次のような方法で障害を起こしてみよう。例えば、プロセスを kill したり、ディスクを アンマウントしたり、ファイアウォールによってそのノードを他から遮断したりするなど。

最後に、システムが保証している仕様を検証するため、ログを比較するしよう。たとえば、通知されたチャットメッセージが、少なくともひとつの複製先に配信されるべきという仕様ならば、そうしたメッセージが実際に配信されているのかを確認すること。

このような計測の結果は驚くべきものかもしれない。システムの設計、実装、そして依存性を検証するために、その結果を用いること。パフォーマンスの計測と同じく、核となる安全性の保証項目を常に計測できるようにシステムを設計するように配慮すること。

教訓

MongoDBやRiakを使わないにしても、本稿で取り上げた例から導き出せる一般的な教訓があるので、まとめておく。

まず、客観的な観測者ではなくクライアントこそが分散システムの重要な要素である。ネットワーエラーが意味するものは「それは失敗した」ではなく、「私は知らない」である。コードやAPIを開発する際には、成功、失敗、そして不定状態の違いを明確にすること。また、システム境界上でやりとりするアルゴリズムの、一貫性を拡張していくことを考慮すること。たとえば、TCPクライアントには ETags(Entity Tag) や、ベクタークロックを引渡し、またはブラウザ用にCRDTsを拡張する、など。

2フェイズコミットのような良く知られたアルゴリズムであっても、偽陰性などの、いくつかの警告事項がある。SQLトランザクションの一貫性は、幾つかのレベルに分けられる。もし強い一貫性を使うなら、衝突の取り扱い方法が本質的な課題であることに留意すること。

フェイルオーバ時に信頼できるプライマリノードを維持しつづける、というようないくつかの問題はうまく解決するのが困難である。一貫性とはデータの性質であり、ノードの性質ではない。したがって、ノードの状態がデータの一貫性を暗示していると仮定するようなシステムは避けよう。

デッドロック局面では、ウォールクロックは応答性を保証するためなら有用であるが、正確性の積極的な保証とはならない。これらのテストでは、全てのクロックはNTPで正しく同期していたが、データは失われた。もしクロックが同期から外れたり、ノードがしばらく停止したりすると、より悪い事態が発生するかもしれない。データには論理クロックを使おう。システム時刻を当てにしているシステムは、GPSや原子時計でも使わない限り、信用してはならない。とにかく使用するクロックの歪を計測することである。

正確性が問題となる場合、形式的に証明する手法に頼ってみること、そして文字通りの意味で「レビュー」してみよう。理論的に正しいアルゴリズムと現実のソフトウェアとの間には―特に処理の遅延(処理速度)については―とても大きな隔たりがある。しかし、「ダメなアルゴリズムを正しく実装したもの」よりは、「理論的に正しいアルゴリズムのバグのある実装」のほうが普通はましである。なぜなら、実装のバグは修正できるが、設計を見直すのは遥かに困難だからだ。

問題領域に対する正しい設計を選択すること。たとえばアーキテクチャの構成要素のいくつかは、強い一貫性を必要とすることがある。CRDTs を適用することで、それ以外の構成要素は、正確性を残しつつ線形性を犠牲にできる。要件によっては時にはデータ全体を失うことも許容できることもあるだろう。パフォーマンスと正確性の間には、トレードオフがしばしば存在するので、考えて、実験して、そして発見しよう。

なんらかのルールでシステムに制約を設ければ、安全性を達成するのが簡単になる。たとえば不変性は、非常に使いやすい特性であり、可変CPデータストアと組み合わせることで、強力なハイブリッドシステムとなる。また、可能な限り「べき等性のある(=繰り返し行なっても結果が変わらない)」操作法を使うこと。これにより、どんな種類のクエリやリトライの動作も可能になる。もし現実的ならば、一歩でも多く先へ進もう。そして完全なCRDTsを使おう。

MongoDBのようなデータベースで書き込みの損失を避けるためには、かなり大きな遅延というトレードオフが必要である。それなら、Postgres を使うだけでかなり速くなる。時にはより信頼性のあるネットワーク機器や電源設備を購入したほうが、スケールアウトするよりも安上がりである。そうでない場合もあるが。

分散状態への対応は難しい問題であるが、ほんの一手間掛けるだけで劇的に信頼性を高められることもある。ネットワーク分割の影響についての(製品レベルの失敗例も含む)追加情報については、ここを参照のこと。

著者について

Kyle Kingsbury 氏は、Factualのエンジニアであり、いくつかの記事をここに掲載している。彼は オープンソースのイベントドリブン監視システムである Riemannや、ネットワークシミュレーションの新手法である Timelike など の作者でもある。カリフォルニア州、サンフランシスコ市の在住で、コンピュータがどうやって動くのか全く分からないそうだ。

この記事に星をつける

おすすめ度
スタイル

BT