HN 日本語サマリー

← 一覧へ戻る
インフラ・DevOps

高速な書き込みはすべて、どこかに処理を移動させる

Every fast write moves work somewhere else (shayon.dev)

52 pointsby shayonj19 コメント

要約

ストレージエンジンの書き込み成功の定義は、レイテンシと耐久性のトレードオフを伴います。メモリへの書き込み直後に成功を返すと高速ですが、クラッシュでデータが失われる可能性があります。ローカルSSDへの書き込みはプロセスやカーネルのクラッシュに耐えますが、ディスクやホストの喪失には弱いです。リモートストレージや複数のサーバーに保存することで耐久性は向上しますが、ネットワーク遅延が追加されます。高速な操作の数値を見る際には、そのコストがどこで発生し、何が失われる可能性があり、システムがどの程度の未完了のクリーンアップを許容できるかを理解することが重要です。

全文翻訳

すべてのストレージエンジンは、書き込みが成功したとクライアントに伝える前に、何が完了する必要があるかを決定しなければなりません。最も迅速な答えは、バイトをメモリにコピーした後に返すことです。ローカルの永続的な書き込みは、データベースホストのSSDでfdatasync()を待ちます。そのホストが消失した後も書き込みを維持するには、ネットワークボリューム、オブジェクトストア、または複数のデータベースサーバーが自身のコピーを保存するのを待つ必要があります。 これらの選択肢は、レイテンシと耐久性を一緒に移動させます。メモリへのコピー後に返すのは高速ですが、マシンのクラッシュで書き込みが失われる可能性があります。ローカルSSDへの書き込みを待つのは、プロセスやカーネルのクラッシュには耐えますが、そのデバイスやホストの喪失には耐えられません。一方、リモートストレージや複数のデータベースサーバーは、ネットワークとコピー時間を各書き込みに組み込むことで、より多くの障害に耐えることができます。 あるシステムのfdatasync()は、データベースホストに接続された1つのSSDを待ちますが、別のシステムは同じNVMeインターフェースを通じてネットワークボリュームを公開し、リモートストレージサービスを待ちます。システムコールの名前は同じですが、レイテンシとデータが耐えられる障害は異なります。 オブジェクトストレージを中心に構築された新しいストレージ設計は、この選択を特に興味深いものにしています。なぜなら、多くの設計が不変のソート済みファイルをオブジェクトストレージに配置し、書き込み先行ログ(WAL)またはキャッシュのためにホストローカルのNVMe SSDを使用し、ログ構造マージツリー(LSM)レイアウトでデータを整理しているからです。オブジェクトストレージは、コンピューティングが来ては去る間もストレージサービスが永続的なコピーを所有でき、新しい不変ファイルの書き込みが共有ディスクページの小さなランダム更新を回避するため、構築のための素晴らしいプリミティブとなり得ます。 オブジェクトPUTが成功した後、ストレージサービスはそのバイトに対する広告された耐久性を満たす責任を負います。データベースは、どのバージョンが最新であるかを決定し、他のマシンが見ることができるようにその決定を記録し、読み取りを高速化し、古いバージョンを削除し、クラッシュ後に回復する必要があります。ローカルの書き込み先行ログは書き込みを高速化できますが、その場合、データベースはそのローカルコピーの喪失が許容できるかどうか、または成功する前に別のコピーが存在する必要があるかどうかを決定する必要があります。 私は、キー・バリュー・リクエストがデータベースに送信されるのをクライアントPUT、オブジェクトストレージに送信されるHTTPリクエストをオブジェクトPUTと呼びます。これらの書き込みを別々に保つと、メモリ、ローカルSSD、リモートストレージ、そして多数のデータベースサーバーを通過する1つのクライアントPUTを追跡しやすくなりました。したがって、操作に対して非常に高速な数値を観察したときは、どの操作がそのコストを負担しているのか、成功後に何がまだ失われる可能性があるのか、そしてシステムがどの程度の未完了のクリーンアップを許容できるのかを知りたいと思います。 追記型キー・バリュー・ストアに求めたいコストから始めるのが有用だとわかりました。 Operation Work before success GET O(key bytes + returned bytes), 1 index lookup and 1 read for the value PUT O(key bytes + payload bytes), 1 pass over the payload to hash it, 1 WAL append, and 1 fdatasync() shared with other writes DELETE O(key bytes), append 1 delete record これらのコストは魅力的です。なぜなら、通常の要求は保持されているすべてのオブジェクトをスキャンしたり、完全な書き込み履歴をたどったりすることはなく、より大きな値は、より小さな値よりも驚くほど高くコストがかかるからです。 これらの小さな要求コストが可能である理由は、GETではインデックスがすでに各キーを最新の値にマッピングしていること、複数のPUT要求がデバイスフラッシュを共有できること、そしてDELETEは古いバイトをすぐに削除せずに値が無効であることを記録することです。PUTは、その作業がどこに移動するかを最も簡単に見ることができる場所なので、そこから始めました。 クライアントPUTがローカルSSDに到達する方法 ここでは、データベースと同じホストに接続されているホストローカルのNVMe SSDを指し、リモートストレージサービスを経由してアクセスするものではありません。書き込みが同期されると、プロセスやカーネルのクラッシュに耐えることができますが、SSDやホストの喪失は依然として書き込みを失う可能性があります。 NVMeはデバイスにコマンドを送信するためのインターフェースの名前ですが、ストレージがどこにあるか、またはデータがどの障害に耐えるかは教えてくれません。リモートブロックボリュームもNVMeデバイスとして表示されることがありますが、各書き込みはネットワークを横断し、ストレージサービスがコピーを保持します。NVMeのレイテンシ数値は、その確認ポイントなしでは不完全です。タイマーは、バイトをメモリにコピーした後、ローカルSSDを1つ同期した後、またはリモートボリュームが書き込みを確認した後で停止しましたか? ローカルWALは、レイテンシのギャップが大きい可能性があるため魅力的です。AWSはS3 Express One Zoneを、一貫したシングルミリ秒台の読み取りおよび書き込み要求レイテンシを持ち、S3 Standardよりも最大10倍高速であると説明しています。一方、Tursoは4KB PUTを平均6.4ms、p99で7msと測定しました。ローカルfdatasync()が1msと測定された場合、ローカルパスは約6倍高速になり、0.1msであれば約64倍高速になります。50倍の違いは実際のベンチマーク結果になり得ますが、それはすべてのNVMeデバイスとオブジェクトストアのプロパティではなく、2つのパスは依然として異なる障害に耐えます。 ローカル書き込みは、バイトをメモリにコピーすることと、それらを永続化することとを分離します。書き込み先行ログ(WAL)は、データベースがクラッシュ後に再生できる変更の順序付けられた記録です。バッファリングされた実装では、クライアントPUTは次のようにカーネルとデバイスを通過します。 --- config: look: handDrawn handDrawnSeed: 17 fontFamily: "SFMono-Regular, Consolas, monospace" flowchart: curve: linear --- flowchart LR C["client PUT"] --> E["storage engine"] E -->|"write WAL"| K["kernel page cache"] K -->|"fdatasync writes pages<br/>and flushes device cache"| D["host-local SSD"] K -.->|"write() can return here"| V["memory only<br/>not durable"] D --> S["success"] subgraph H["DATABASE HOST"] end write()はWALバイトがページキャッシュに到達した後に返すことができますが、この設計ではfdatasync()がローカルSSDに到達した後に成功を返します。 Linuxでは、write()は通常バイトをページキャッシュにコピーし、その後カーネルはそれらのページをダーティとマークし、後でデバイスに書き込みます。プロセスの終了はページキャッシュを破棄しませんが、カーネルのクラッシュやマシンの喪失は、ストレージに到達していないダーティページを消去する可能性があります。 fdatasync()は、ファイルデータとその取得に必要なメタデータを待機します。fsync()は、ファイルの他のメタデータもフラッシュします。ファイルの作成や名前の変更は、そのディレクトリを同期する必要がある場合があります。なぜなら、ファイルの内容とそれを名前付けるディレクトリエントリは別々の書き込みだからです。 O_DIRECTとio_uringはこの耐久性ポイントを変更しません。O_DIRECTはページキャッシュをバイパスでき、io_uringはI/Oを効率的にサブミットできますが、どちらも同期されていない書き込みを永続的なデータに変えるわけではありません。ファイルシステムとデバイスは、APIがデータがクラッシュから生き残ったと言える前に、必要な書き込みとフラッシュを完了する必要があります。 fdatasync()は接続が切断される直前に完了する可能性があり、WALはコミットされたままクライアントは成功を受け取ることができません。クライアントはPUTに操作IDを提供し、同じIDとバイトを再試行して記録された結果を受け取ることができます。一方、異なるバイトで同じIDを再利用すると失敗します。より高速なハードウェアはこのウィンドウを短縮しますが、曖昧さをなくすことはできません。 1つのフラッシュで多くの書き込みをカバーできる 各小さなクライアントPUTの後にfdatasync()を呼び出すのは、最も理解しやすいバージョンですが、スループットは現在、デバイスが完了できるフラッシュの数によって制限されています。シリアル化された1msのフラッシュは、デバイスがはるかに多くのバイトをストリーミングできる場合でも、毎秒約1,000回のフラッシュを可能にします。 WALライターは、追加されたレコードを現在のバッチに入れ、固定バイト数、書き込み数、または時間後にバッチを閉じることで、高価なフラッシュを共有できます。それはそのバッチの最終バイトを記憶し、WALファイルに対してfdatasync()を呼び出し、その後保存された位置によってカバーされるすべてのライターに成功を返します。バッチが閉じられた後に追加された書き込みは、次のフラッシュを待ちます。 --- config: look: handDrawn handDrawnSeed: 17 fontFamily: "SFMono-Regular, Consolas, monospace" flowchart: curve: linear --- flowchart LR A["PUT A"] --> G B["PUT B"] --> G C["PUT C"] --> G D["PUT D"] --> G G["open WAL batch"] --> O["close on bytes,<br/>writes, or time"] O --> F["1 fdatasync()"] F --> SSD["host-local SSD"] SSD --> S["success<br/>A, B, C, D"] E["PUT E"] -.->|"arrives after close"| N["next WAL batch"] subgraph H["SAME DATABASE HOST"] end PUT AからPUT Dは1つのfdatasync()を共有し、まとめて成功を受け取ります。PUT Eはバッチが閉じられた後に到着したため、次のバッチを待ちます。