以下は、Linux カーネル文書、Documentation/filesystems/xfs-delayed-logging-design.txt の訳です。原文と同じ、GPL v2 で公開します。
XFS のロギングは、論理的と物理的なロギングの組み合わせです。 inode とディスククオータのようないくつかのオブジェクトは論理的な形式でログされます。その場合、ログされる詳細は、ディスク上の構造ではなくて、メモリ上の構造に対する変更点から構成されます。その他のオブジェクト、典型的にはバッファですが、は、物理的な変更点がログされます。この違いの理由は、ひんぱんにログされるオブジェクトに必要とされるログ領域を減らすためです。 inode のいくらかの部分は、他の部分よりひんぱんにログされ、さらに inode は他のどんなオブジェクトよりもひんぱんにログされるのが普通です。(スーパーブロックのバッファは例外かもしれません。)なので、ログされるメタデータの量を減らすことはとても大切です。
これがそんなに問題となるのは、XFS が、同じオブジェクトの複数の別々の更新が、いつでも、ログに同時に存在することを許しているからです。これにより、あるオブジェクトへの新しい更新を記録する前に、それ以前の更新をすべてディスクにフラッシュするということをしなくてもすみます。XFS は、これを「リロギング」と呼ばれる方法で行います。概念的には、それはとても簡単です。あるオブジェクトへの新しい更新は、ログに書かれているすべてのトランザクションの、*新しいコピー*に対して記録されなくてはいけないということです。
つまり、A から F にいたる変更のシーケンスがあるとしましょう。そしてオブジェクトは D の後でディスクに書かれました。ログには、以下のトランザクションと、内容と、ログシーケンス番号(LSN)が見えるはずです。
Transaction Contents LSN
A A X
B A+B X+n
C A+B+C X+n+m
D A+B+C+D X+n+m+o
<object written to disk>
E E Y (> X+n+m+o)
F E+F Yٍ+p
言葉を変えて言えば、オブジェクトがリログされるときには、毎回、新しいトランザクションは現在ログに保持されているそれ以前のすべての変更分の総和を持つという事です。
このリログの技術により、オブジェクトはログの中を、前に進むことができ、リログされるオブジェクトがログのテールが進むのをじゃますることはありません。これは、前記の図で、それぞれの連続するトランザクションが、異なる(増加する)LSN を持つことでわかることと思います。LSN は、実際には、トランザクションの、ログの中での位置を直接エンコードしたものです。
このリログは、長時間続く、複数コミットをするトランザクションを実装するのにも用いられています。このトランザクションは、ローリングトランザクションと呼ばれ、パーマネントトランザクションリザベーションと呼ばれる特別なログ予約を必要とします。ローリングトランザクションの典型的な例は、inode からのエクステントの削除であり、それはログの予約サイズの制限により、1トランザクションあたり2エクステントづつしか行うことができません。このため、エクステント削除のローリングトランザクションは、それぞれの削除操作のたびに、 inode と btree バッファをリログし続けるのです。これによって、操作が続く間、トランザクションはログの中を前に進み続け、現在の操作がログがラップアラウンドして自分自身によりブロックされてしまうことがないことを保証します。
このように、リログ操作は、XFS ジャーナルサブシステムの正常な動作のために不可欠であることがおわかりでしょう。以上の記述から、なぜ XFS メタデータ操作が多量のログを書き込むのか、理解できると思います。同じオブジェクトに繰り返される操作は、ログに同じ変更を何度も何度も書くわけです。さらに悪いことには、オブジェクトがたくさんリログされるということは、より、ダーティになっていっているはずなので、それぞれの個々のトランザクションはログにより多くのメタデータを書くことになります。
XFS トランザクションサブシステムのもうひとつの特徴は、ほとんどのトランザクションは非同期だという事です。つまり、トランザクションは、ログバッファが一杯になる(ログバッファはいくつものトランザクションを保持することができます。)か、あるいは、同期操作がトランザクションを保持しているバッファをディスクにフォース、強制書き込みするのでない限り、ディスクにコミットされることはないということです。ということは、XFS はメモリー上でトランザクションの集積、バッチといってもよいです、をやって、トランザクションスループットに与えるログI/Oの影響を最小にしようとしているということです。
非同期トランザクションのスループットの限界は、ログマネージャによって提供されるログバッファの数とサイズから来ます。デフォルトでは、32KBの長さのログバッファが8つあります。サイズは、マウントオプションによって、最大256KBまで大きく出来ます。
事実上、これが、ある時点において、ファイルシステムに対して行うことのできる、未完了のメタデータ更新の最大量を決定することになります。もし、すべてのログバッファが一杯で、I/Oの途中である場合、現在のバッチが完了するまで、トランザクションはそれ以上コミットすることはできません。最近では、ひとつのCPUコアが、永遠にログバッファを一杯で、I/Oの途中であるようにするのに十分な量のトランザクションを生成するのも、一般的になって来ました。このため、XFS ジャーナルサブシステムはI/Oバウンドであるといえるでしょう。
非同期ロギングが、XFS の使うリロギングの技術と組み合わされた時、注意するべきは、変更されたオブジェクトは、ログバッファからディスクにコミットされる前に、何度もリログしているかもしれないということです。先ほどのリログの例で見ると、トランザクション A から D が、同じログバッファにあって、ディスクにコミットされるというのはおおいにありうることです。
つまり、1つのログバッファは同じオブジェクトの複数のコピーを持っていることがあるということです。しかし、これらのうちで、必要なのは1つだけ、最後の「D」だけです。それは、以前のすべての変更差分を持っています。言葉を変えて言えば、ログバッファには1つの必要なコピーと、3つの、領域をむだに使っているだけの無効なコピーがあるということです。
同じオブジェクトのセットに繰り返し操作を行うとすると、これらの「無効オブジェクト」はログバッファの使用領域の90%を占めるという事もありえます。ログに書かれる無効オブジェクトの数を減らすことが、ログに書かれるメタデータの量を減らすのに多いに効果があることは明らかでしょう。これが、遅延ロギングの基本的なゴールなのです。
概念的な観点から言うと、XFS は既にメモリ(メモリというのは、ログバッファです。)上でリロギングを行なっています。ただ、とても、効率が悪い方法である、ということです。リロギングをするときには、論理から物理のフォーマット変換がされます。なぜならば、変更を、トランザクションの中でログバッファに書くために物理フォーマッティングする以前に、論理的な変更をメモリ上で追跡するためのインフラ構造がないからです。なので、ログバッファに無効オブジェクトを蓄積するのをやめることはできません。
遅延ロギングとは、ログバッファのインフラ構造の外で、オブジェクトへのトランザクション内での変更をメモリ上で保持し、さらに追跡するためのしかけに、私達が与えた名前です。XFS のジャーナルサブシステムにとってはリロギングは基本的な概念ですから、これは実際のところ、比較的簡単です。ログされるアイテム(xfs_inode や xfs_buf です)へのすべての変更は、既に現在のインフラ構造によって追跡されています。大きな問題は、どのようにそれらを蓄積し、かつ、矛盾なくリカバリ可能な方式でログに入れるか、ということです。問題を記述し、それがどのように解決されたかを示すのが、この文書の目的です。
遅延ロギングがジャーナリングサブシステムの動作に与えた1つの主要な変更は、処理中のメタデータ更新の量を、使用可能なログバッファのサイズと数から切り離したという事です。言葉を変えて言えば、いつの時刻においても、ログに書かれていないトランザクション変更は、最大2MBに限られるにもかかわらす、メモリ上にはずっと多くの変更が蓄積されていることがあるという事です。このため、現在のロギングメカニズムと比べて、クラッシュのときのメタデータの喪失の可能性はずっと大きくなります。
これは、ログリカバリーが、矛盾ないファイルシステムを回復できるという保証を変更するものではないことは言っておくべきでしょう。それが意味することは、回復されたファイルシステムから見ると、クラッシュのためになかったことにされた数千ものトランザクションがあったかもしれない、ということです。このため、アプリケーションが自分のデータに注意したいならば、アプリケーションのレベルでのデータ一貫性が保たれている必要があるときには、 fsync() を使うことがこれまで以上に重要になったという事です。
遅延ロギングは、それが正しいのかそうでないかを厳密に証明する必要のある、発明的な新概念ではないということに注意下さい。変更をログに書く前に、メモリ上にしばらくの間蓄積しておくという方式は、ext3, ext4 を含む多くのファイルシステムにおいて、効果的に使われてきました。なので、この文書では、読者をその概念がまともであることを説得しようとすることに時間は使いません。その代わりに、それは単純に、「解決済みの問題」として扱われ、XFS でのその実装は、ソフトウエア工学上の純粋な課題であると考えられます。
XFS での遅延ロギングの基本的な要件は単純です。
1. ログに書かれるメタデータの量を、少なくとも1けた、減らすこと。
2. #1 の要件を確認できる、十分な統計を提供すること。
3. 新しいソースコードの問題をデバッグできる、十分な、新しいトレースのインフラ構造を提供すること。
4. ディスク上フォーマットの変更はなし。(メタデータも、ログフォーマットも)
5. マウントオプションによって、有効、無効にできること。
6. 同期トランザクション負荷のもとで、性能低下がないこと。
変更を論理的なレベルで蓄積する(つまり、既存の、ログアイテムのダーティ領域トラッキングを使って)ことに伴う問題は、変更をバッファに書くことになったときに、フォーマットしているオブジェクトが、その間に変わってしまうことを防ぐ必要があるということです。そのためには、オブジェクトをロックして、並行する変更を防ぐ必要があります。なので、論理的な変更をログにフラッシュするためには、すべてのオブジェクトをロックし、フォーマットし、そしてまた、アンロックしなければいけません。
これは、既に動いているトランザクションとの間に、多くのデッドロックのスコープを導入します。例えば、トランザクションがオブジェクト A をロックして、変更しており、さらに、トランザクションをコミットさせるために遅延ロギングトラッキングのロックが必要だとします。しかし、フラッシングスレッドが既に遅延ロギングトラッキングのロックを持っており、オブジェクト A をログバッファにフラッシュするためにそのロックをとろうとしているかもしれません。これは、解決不可能なデッドロックの条件のようです。そして、遅延ロギングを実装するためにこんなに長いことかかったのは、この問題が障害となっていたからです。
解決策は、比較的簡単でした。気がつくのに、長い時間がかかった、というべきでしょう。単純にいえば、現在のロギングコードは、それぞれのアイテムへの変更を、アイテムの中の変更された領域を指す、(オフセットと長さを持つ)ベクトル配列の形にフォーマットします。ログ書き込みコードは、トランザクションコミットの間に、これらのベクトルの指すメモリーを単純にログバッファの中にコピーします。そのとき、アイテムはトランザクションの中で、ロックされています。フォーマッティングコードの行き先にログバッファを使う代わりに、フォーマットされたベクトルを入れるに十分なメモリーバッファを確保して使うこともできるのです。
そして、ベクトルをメモリーバッファにコピーして、さらに、ベクトルを、元のオブジェクトそのものでなくて、メモリーバッファを指すように書き換えます。すると、ログバッファを書き込むコードと互換性があるフォーマットであり、アクセスするためにアイテムをロックする必要のない、変更分のコピーが得られます。これらのフォーマットと、書き換えは、オブジェクトがトランザクションコミットの間、ロックされている間に、すべて済ますことができます。この結果、トランザクション的に一貫していて、所有者であるアイテムをロックする必要なくアクセスできるベクトルが得られます。
こうして、未完了の非同期トランザクションをログにフラッシュするときに、アイテムをロックしなくてもよくなりました。今までのフォーマット方法と、遅延ロギングのフォーマットの違いは、以下の図で示すことができます。
現在のフォーマットログベクトル:
Object +---------------------------------------------+
Vector 1 +----+
Vector 2 +----+
Vector 3 +----------+
フォーマット後:
Log Buffer +-V1-+-V2-+----V3----+
遅延ロギングベクトル:
Object +---------------------------------------------+
Vector 1 +----+
Vector 2 +----+
Vector 3 +----------+
フォーマット後:
Memory Buffer +-V1-+-V2-+----V3----+
Vector 1 +----+
Vector 2 +----+
Vector 3 +----------+
(レイアウトがくずれているかもしれませんが、3つのベクトルの開始と終了位置はとなりあっており、 V1, V2, V3 の境界と等しいです。)
メモリーバッファと、対応するベクトルは、1つのオブジェクトとして、渡されなくてはなりませんが、親オブジェクトともまだ関連づいている必要があります。もしオブジェクトがリログされたときに、現在のメモリーバッファを、最新の更新を含む新しいメモリーバッファと入れ替えることができるようにするためです。
メモリーバッファをフォーマットした後も、ベクトルをそのままにしておく理由は、ログバッファの境界をまたがって、ベクトルを正しくスプリットすることができるようにするためです。ベクトルがなければ、アイテムの中での領域の境界がわからないので、ログバッファを書くときに新しいカプセル化の方法が必要となってしまいます。(ダブルカプセル化です。)それはディスク上フォーマットの変更となりますから、望ましいことではありません。それに、そうすると、フォーマッティングの段階で、ログ領域ヘッダーを書く必要があります。領域ごとの状態で、ログ書き込みの時にヘッダーに置く必要のあるものがあるというのは、面倒なことになります。
このため、ベクトルは保持する必要があります。しかし、メモリーバッファをそれにアタッチして、ベクトルのアドレスをメモリーバッファを指すように書き換えることで、今までのログベクトルと全く同じように、ログバッファ書きこみコードに渡すことのできる、自己記述的なオブジェクトを作ることができました。こうして、メモリー上でリログされたアイテムを扱うために、新しいディスク上フォーマットを作らなくても良いこととなりました。
さて、トランザクション的な変更を、制限なく使うことのできる形式で、メモリーに記録することができるようになりましたから、それらを追跡、蓄積できるようにして、しばらくしてからログに書くことができるようにしましょう。ログアイテムは、このベクトルとバッファを記録するのに、自然な場所でしょう。また、コミット済みのオブジェクトを追跡するためのオブジェクトとしても、正しい選択でしょう。ログアイテムは、オブジェクトがトランザクションに含まれた時から、ずっと存在しますから。
ログアイテムは、ログに書きこまれたけれど、ディスクには書かれていないアイテムを追跡するために、既に使われています。そのようなログアイテムは、「アクティブ」と考えられ、Active Item List (AIL) につながれます。それは、LSN で順序付けられた2重連結リストです。ログバッファI/O が完了すると、アイテムはこのリストに入ります。その後、それはアンピン(メモリ固定解除)され、ディスクに書くことができるようになります。AIL にいるオブジェクトはリログされることがあり、そのときオブジェクトは再度、ピンされ、そのトランザクションのログバッファI/O が完了した時にAIL の中を前に進むことになります。
本質的に、これは、AIL にいるアイテムは、まだまだ、変更され、リログされることがあることを示します。なので、追跡は、AIL インフラ構造とは別にしなければいけません。なので、コミット済みアイテムを追跡するために、AIL のリストポインターを再使用することはできません。また、AIL ロックで保護されているフィールドのどれにも、状態を格納することはできません。このため、コミット済みアイテムの追跡には、ログアイテムの中に、独自のロック、リスト、そして状態フィールドが必要です。
コミット済みアイテムの追跡には、AIL と似た、Committed Item List (CIL) と呼ばれる新しいリストが使われます。このリストは、コミット済みで、フォーマットしたメモリーバッファがアタッチされたログアイテムを保持します。そこでは、オブジェクトは、トランザクションコミット順に管理されます。なので、オブジェクトがリログされたときには、リストの今の場所から除かれて、テールに再挿入されます。これは、完全に任意なことで、デバッグを簡単にするためにだけ、行われます。リストの最後のアイテムは、最も最近に変更されたものという事です。トランザクションの一貫性のためには、CIL 内の順序付けは不要です。(次のセクションでご説明します。)なので、順序付けは、開発者にとって、便利/健康的、というだけのことです。
「ログフォース」として知られるログ同期イベントがあると、CIL にあるすべてのアイテムは、ログバッファ経由でログに書かれなくてはいけません。これらのアイテムは CIL にある順に書く必要があり、それらはアトミックなトランザクションとして書かれなくてはいけません。すべてのオブジェクトがアトミックなトランザクションとして書かれなければいけないというのは、リログとログリプレイの要求から来ています。あるトランザクションのすべてのオブジェクトのすべての変更は、ログリカバリーのときには、全部がリプレイされるか、まるでリプレイされないかのどちらかでないといけません。あるトランザクションが、ログのうえで完了していないという理由でリプレイされないならば、その後に続くトランザクションもリプレイされてはいけません。
この要求を満たすために、CIL 全体を単一のログトランザクションで書く必要があります。幸い、XFSのログコードも、ログリプレイコードも、トランザクションのサイズに固定の上限はありません。基本的な制限は1つだけで、トランザクションは、ログのサイズの半分より長くなることはできないというものです。この理由は、ログの先頭と最後を見つけるためには、いかなる時点でも、少なくとも1つの完全なトランザクションがログの中に存在しなくてはいけないということです。もしも、トランザクションがログの半分以上であると、そのトランザクションを書き込んでいるときにクラッシュすると、ログの中の以前の既に完了したトランザクションを部分的に上書きする可能性があります。そうすると、リカバリーが失敗し、ファイルシステムの一貫性は保たれなくなります。そうならないために、チェックポイントの最大長は、ログの半分より、少し短くしないといけないのです。
このサイズの制限を除けば、チェックポイントトランザクションは他のトランザクションと違うところはありません。それは、トランザクションヘッダーがあり、一連の、フォーマットされたログアイテムが続き、最後にコミットレコードがあります。リカバリーの観点からも、チェックポイントトランザクションは違いがありません。ただ、ずっと大きく、よりたくさんのアイテムがその中にあるというだけです。これが与える最悪の影響は、リカバリートランザクションオブジェクトのハッシュ長をチューニングする必要があるかもしれない、という程度です。
チェックポイントはただの、もうひとつのトランザクションであり、ログアイテムに対するすべての変更点は、ログベクトルの形で格納されていることから、私たちは、既存のログバッファ書き込みコードを、変更点をログに書くために使うことができます。これを効率的にするために、チェックポイントトランザクションを書いている間、CIL をロックしている時間を最小にする必要があります。現在のログ書き込みコードは、トランザクションの内容(ログベクトル)の書き込みと、トランザクションコミットレコードの書き込みを分離することで、それが簡単にできます。しかし、これを追跡するためには、私たちは、チェックポイントごとのコンテキストが必要で、それはログ書き込み処理の間ずっと、チェックポイント完了まで、維持されていく必要があります。
こういうわけで、チェックポイントはコンテキストを持ちます。それは、現在のチェックポイントの状態を、最初からチェックポイント完了までの間、追跡します。チェックポイントトランザクションが開始すると同時に、新しいコンテキストが初期化されます。なので、チェックポイント操作のときに、すべての現在のアイテムをCILから除くとき、それらのすべての変更点は現在のチェックポイントコンテキストに移動されます。次に新しいコンテキストを初期化して、新しいトランザクションのためにそれをCILにアタッチします。
こうすることで、コミット済みアイテムを転送した後、すぐにCILをアンロックすることができます。そして、チェックポイントをログに向けてフォーマットしている間に、新しいトランザクションを開始することができます。また、ログフォースがひんぱんな負荷のもとでは、チェックポイントを複数並行してログバッファに書き込むことも可能です。それは、現在のトランザクションコミットコードがしていることと同じです。しかしこれは、コミットレコードをログの中で厳密に並べる必要があります。それは、ログリプレイのときに、チェックポイント順が保たれるためです。
私たちがアイテムをチェックポイントトランザクションに書き込んでいる、その同じ時に、他のトランザクションがそのアイテムを変更してログアイテムを新しいCILに挿入しても大丈夫なように、チェックポイントトランザクションのコミットコードは、トランザクションに書かれる必要のあるログベクトルのリストを格納するためにログアイテムを使うことはできません。このため、ログベクトルはログアイテムからデタッチされてもよいように、一緒にチェーンしておく必要があります。つまり、CILがフラッシュされるときには、それぞれのログアイテムにアタッチされているメモリーバッファとログベクトルは、チェックポイントコンテキストにアタッチされなくてはいけません。そうすることで、ログアイテムは解放することができます。図で示すと、CILはフラッシュの前、このようになっているとします:
CIL Head
|
V
Log Item <-> log vector 1 -> memory buffer
| -> vector array
V
Log Item <-> log vector 2 -> memory buffer
| -> vector array
V
......
|
V
Log Item <-> log vector N-1 -> memory buffer
| -> vector array
V
Log Item <-> log vector N -> memory buffer
-> vector array
そして、フラッシュの後、CIL ヘッドは空になり、チェックポイントコンテキストログベクトルリストはこのようになります:
Checkpoint Context
|
V
log vector 1 -> memory buffer
| -> vector array
| -> Log Item
V
log vector 2 -> memory buffer
| -> vector array
| -> Log Item
V
......
|
V
log vector N-1 -> memory buffer
| -> vector array
| -> Log Item
V
log vector N -> memory buffer
-> vector array
-> Log Item
この転送が完了したら、CIL をアンロックして、新しいトランザクションを開始することができます。その間に、チェックポイントフラッシュコードは、チェックポイントをコミットするために、ログベクトルのチェーンをたどることができます。
チェックポイントが、ログバッファに書かれた時に、チェックポイントコンテキストは、完了コールバックとともに、そのコミットレコードが書かれたログバッファにアタッチされます。ログI/O の完了は、そのコールバックを呼び出し、それは、ログベクトルのチェーンにあるログアイテムに対して、トランザクション完了処理を実行します。(つまり、AIL に入れて、アンピンする。)そして、ログベクトルのチェーンとチェックポイントコンテキストを解放します。
議論のポイント:ベクトルを追跡するのに、ログアイテムが最も効率的であるかは、私は確信が持てません。まあ、そうするのが自然であるようには見えます。私達は、ログベクトルをチェーンして、ログアイテムとログベクトルのリンクを切断するためにだけ、CIL のログアイテムをたどります。それは、ログアイテムリストの更新でキャッシュラインヒットをして、ログベクトルのチェーン作成のためにもう一度、ヒットをするということです。ログベクトルそのもので追跡をすれば、ログアイテムとログベクトルのリンクを切断するだけでよく、ログアイテムキャッシュラインをダーティにするだけで済みます。普通は、私は、キャッシュラインが1だろうと2だろうと気にしないのですが、実は私は、1つのチェックポイントトランザクションにおいて、8万以上のログベクトルを見たことがあるのです。たぶんこれは、「測定して比較せよ」という状況であり、それは、開発ツリーに、まともに動作して、かつ、レビューされた実装が入ってからでないと、判断できないことのように思います。。。
XFS トランザクションサブシステムの重要な特徴は、コミット済みトランザクションは、トランザクションコミットのログシーケンス番号でタグ付けされるということです。これにより、トランザクションが完全にログにコミットされるまで完了することのできない操作が将来ありえても、トランザクションは非同期に開始することができるのです。まれに、依存した操作がされるとき、(例えば、解放されたメタデータエクステントをデータエクステントのために再使用するなど)特別の、最適化されたログフォースが発行され、依存するトランザクションを直ちにディスクに書きこむこともあります。
このために、トランザクションは、そのトランザクションのコミットレコードのLSN を記録する必要があります。この LSN は、そのトランザクションが書きこまれたログバッファで直接、決まります。このしかけは、既存のトランザクションメカニズムに対してはうまくいきますが、遅延ロギングでは、トランザクションは直接ログバッファに書かれないために、うまく行きません。このため、トランザクションのシーケンスを与える何らかの他の手段が必要です。
チェックポイントの節でお話ししたように、遅延ロギングは、チェックポイントごとのコンテキストを使います。なので、それぞれのチェックポイントにシーケンス番号を与えるのが簡単でしょう。チェックポイントコンテキストをスイッチするのは、アトミックに行う必要があるので、それぞれの新しいコンテキストが単調増加するシーケンス番号をアサインされることを保証するのは、簡単です。外部のアトミックなカウンターは不要です。現在のコンテキストのシーケンス番号をとって、新しいコンテキストのために、+1すればよいのです。
そうすると、トランザクションコミット LSN として、コミットの間に、ログバッファの LSN をアサインする代わりに、現在のチェックポイントシーケンスをアサインすれば良いことになります。これにより、完了していないトランザクションを追跡する操作は、続行するためにどのチェックポイントシーケンスがコミットされる必要があるかわかります。その結果、ログを特定の LSN までフォースするコードは、今度は、ログが特定のチェックポイントまでフォースされることを保証することになります。
これを可能とするためには、現在ログにコミットしている途中のチェックポイントコンテキストをすべて追跡する必要があります。チェックポイントをフラッシュするとき、コンテキストは、「コミット中」リストに追加され、検索できる必要があります。チェックポイントコミットが終わったら、コミット中リストから除かれます。チェックポイントコンテキストは、チェックポイントのコミットレコードのLSN を記録するため、そのコミットレコードを含むログバッファを待つこともできます。このようにして、同期のフォースを実行するために、既存のログフォースのメカニズムを使うことができます。
もし既にフラッシュ途中の同期トランザクションがあるときは、複数の同期トランザクションを一緒にすることのできる、現在のログバッファコードがやっているような、性能向上アルゴリズムによる拡張が、同期フォースにも必要かもしれません。ここでなんらかの決定を下すためには現在の設計の性能解析をする必要があります。
ログフォースの重要な問題は、私たちが待ち合わせる必要のあるものの前にあるすべてのチェックポイントが、それより先にディスクにコミットされていることを保証することです。このため、完了させたいものを待ち合わせる前に、コミット中リストの中の、その前にあるすべてのコンテキストが完了していることを確認しなくてはいけません。この同期は、ログフォースコードで行います。そうすれば、他のところでその同期のために待たなくて済みます。ログフォースするときだけ、関係します。
最後に残った複雑な点は、ログフォースは、今回は、フォース対象のシーケンス番号が、現在のコンテキストと一致するケースを扱う必要があるということです。つまり、CILをフラッシュして、場合によってはその完了を待たなくてはいけません。これは、現在のログフォースのコードに、シーケンス番号のチェックを追加して、必要ならプッシュするという論理を単純に追加するだけで済みます。実際、ログフォースコードに、現在のシーケンスのチェックポイントのフラッシュを追加することで、同期トランザクションを発行する現在のメカニズムは変更せずに済みます。(つまり、非同期トランザクションをコミットして、次にそのトランザクションのLSNまでログをフォースする。)このため、より高いレベルのコードは遅延ロギングが使われていてもいなくても、同じにふるまうのです。
チェックポイントトランザクションの大きな問題は、トランザクションのためのログ領域の予約です。事前には、チェックポイントトランザクションがどれだけ大きくなるかはわかりません。また、書き込むためにいくつのログバッファが必要か、いくつのスプリットログベクトル領域が使われるかも、わかりません。コミットアイテムリストにアイテムを追加するたびに、必要なログ領域を計算することは可能です。しかし、ログの中に、チェックポイントのための領域を予約しなくてはいけないのです。
典型的なトランザクションは、ログにおいて、そのトランザクションの最悪ケースの領域使用に十分な領域を予約します。予約は、ログレコードヘッダー、トランザクションとリージョンヘッダー、スプリットリージョンのためのヘッダー、バッファーテールのパッドなどとともに、そのトランザクションで更新されたすべてのメタデータのための実際の領域を含みます。この中のいくつかは固定長のオーバヘッドですが、多くはトランザクションの大きさとログされたリージョンの数(トランザクション内のログベクトルの数)に依存します。
ディレクトリの変更をログする場合と、inode の変更をログする場合を比べてみましょう。多くの inode コアを変更した場合、(例えば、chmod -R g+w *)inode コアと、 inode ログフォーマット構造だけを持つたくさんのトランザクションができます。つまり、2つのベクトルで長さの合計が約150バイトです。1万 inode を変更したとすると、2万ベクトルが指す約1.5MBのメタデータがあります。それぞれのベクトルは12バイトなので、ログの合計は約1.75MBです。それに比べて、完全なディレクトリバッファをログするときは、普通はそれは1つ、4KBですから1.5MBのディレクトリバッファがあるとすると、そこには400バッファと、それぞれのバッファごとにバッファログフォーマット構造が必要ですから、800ベクトル、合計で1.51MBになります。(メタデータが同じ1.5MBでも、オーバヘッドとして必要になるログフォーマット構造体、つまり、ログベクトルの数と長さは、前者がずっと多いということです。)これから、静的なログ領域予約は柔軟性に欠け、すべてのワークロードにおいて、「最適な値」を選ぶのはむつかしいということは明らかです。
さらに、静的予約を使うとしても、必要な予約のうちのどれだけを静的にとればいいでしょう。トランザクション予約で必要な領域は、現在CILにあるオブジェクトが使っている領域を追跡すれば計算できます。そして、オブジェクトがリログされたときには使用領域の増減を計算します。これによって、チェックポイント予約は、ログヘッダーレコードのような、ログバッファメタデータだけを考えればよいことになります。
しかし、ログメタデータだけに対して静的予約を使うにしても問題があります。典型的なログレコードヘッダーは1MBのログ領域使用ごとに少なくても16KBのログ領域を使います。(32Kごとに512バイトです。)そして、予約は、任意の大きさのチェックポイントトランザクションを扱うことのできる大きさである必要があります。この予約はチェックポイントが開始する前にされなくてはならず、領域予約でスリープすることは許されません。8MBのチェックポイントの場合、150KB程度の予約が必要で、これはかなり大きいといえます。
静的予約はロググラントカウンターを操作する必要があります。領域を永久に予約することはできますが、それぞれのチェックポイントトランザクションの完了時には必ずライト予約(トランザクションが使うことのできる実際の領域)を更新する必要があります。困ったことに、もしこの領域が必要なときに得られないと、再グラントコードはそれを待ってスリープします。
ここでの問題は、ログ領域を解放するためにチェックポイントをコミットしたいときに、デッドロックを起こす可能性があることです。(この例としては、ローリングトランザクションの話を思い出してください。)なので、静的予約をやるなら、ログには、*常に*使える領域がなくてはいけません。それをきちんと行うのはとても困難で複雑です。やればできるのですが、もっと簡単な方法があります。
これを行う、より簡単な方法は、CILのアイテムが使っているすべてのログ領域を追跡して、それをログメタデータが必要とするログ領域のダイナミックな計算に使うことです。トランザクションのコミットによって新しいメモリーバッファがCILに挿入されたためにこのログメタデータ領域が変化したならば、必要な領域の差分は、それを起こしたトランザクションから差し引かれます。このレベルのトランザクションは、*常に*そのために必要な十分な領域を自分の予約に持っているはずです。なぜならば、トランザクションは必要とするログメタデータ領域の最大値を既に予約しているからです。このため、前記の予約差分は、常に予約の最大値以下であるはずです。
このようにして、CILにアイテムが追加されたときも、チェックポイントトランザクションの予約をダイナミックに増やすことができます。そして、あらかじめログ領域を予約したり再グラントしたりする必要はありません。これによって、デッドロックが回避でき、チェックポイントフラッシュコードからブロックする点を取り除くことができました。
以前に述べたように、トランザクションはログの大きさの半分以上にはなれません。このため、予約拡張のときに、予約の大きさを、許容される最大トランザクション長と比較する必要もあります。最大しきい値に達したならば、CILをログにプッシュします。これは、実際のところ、「バックグラウンドのフラッシュ」であり、必要に応じて行われます。これは、ログフォースによって引き起こされるCILプッシュと同じですが、チェックポイントのコミット完了を待つ人は誰もいない点が違います。このバックグランドのプッシュをチェックして実行するのは、トランザクションコミットのコードです。
もし、CILにアイテムがあるのにトランザクションサブシステムがアイドルになったときは、 xfssyncd が発行する周期的なログフォースによってフラッシュされます。このログフォースはCILをディスクにプッシュして、もしトランザクションサブシステムがまだアイドルであるならばアイドルログが、カバーされるようにします。(実質的に、クリーンとマークされます。)これは、既存のロギング処理がやっているのとまったく同じように行われます。議論の余地があるとすれば、このログフォースは、現在の30秒に1回という頻度よりも増やす必要があるかという点でしょう。
現在、ログアイテムは、トランザクションコミットの間、ピン固定されます。そのとき、アイテムはまだロックされています。それは、アイテムがフォーマットされた直後に行われますが、アイテムがアンロックされる前であれば、いつ行ってもよいものです。このメカニズムの結果、アイテムは、ログバッファにコミットするトランザクションごとに1回、ピンされます。このため、ログバッファにあってリログされたアイテムは、それをダーティにした実行中トランザクションの数だけのピンカウントを持ちます。これらのトランザクションが完了すると、アイテムを1回アンピンします。この結果、アイテムがアンピンされるのは、すべてのトランザクションが完了し、ペンディングのトランザクションがなくなったときになります。このように、ログアイテムのピンとアンピンは、対称的です。トランザクションコミットと、ログアイテム完了が1:1だからです。
しかし、遅延ロギングにおいては、トランザクションコミットと完了の関係が非対称です。オブジェクトがCILにリログされるとき、コミット処理がされますが、対応する完了処理は登録されません。つまり、トランザクションコミットとログアイテム完了との関係は、多:1になってしまったわけです。この結果、「トランザクションコミットでピンして、トランザクション完了でアンピンする」というモデルのままでは、ログアイテムのピンとアンピンはバランスがとれなくなってしまったのです。
ピンとアンピンを対称にするには、アルゴリズムは、「CILに挿入するときにピンして、チェックポイント完了でアンピンする」と変更されなくてはいけません。言葉を変えて言えば、ピンとアンピンは、チェックポイントコンテキストに関して、対称となるのです。オブジェクトを最初にCILに挿入するときだけ、ピンする必要があります。もし、トランザクションコミットのときにそれが既にCILにいるならもう一度ピンしてはいけません。複数の、実行中のチェックポイントコンテキストがあるときには、ピンカウントは1以上になることもあるでしょう。しかし、それぞれのチェックポイントが完了するとき、ピンカウントは、そのコンテキストに関して正しい値を保持しているはずです。
あと1つ、面倒なことがあります。このチェックポイントレベルのピンカウントのコンテキストということは、アイテムのピン操作は、CILのコミットあるいはフラッシュロックのもとで行われなくてはいけないことを意味します。このロックの外でオブジェクトをピンしても、ピンカウントがどのコンテキストに関連しているのかを保証できません。なぜならば、アイテムのピン操作は、アイテムが現在のCILにあるかどうかに依存するためです。CILのロックをまずとることなく、オブジェクトをチェックしてピンすると、チェックしてからピンするまでの間、CILのフラッシュとの競合があります。(ピンしないケースもあります。)このため、アイテムを正しくピンするためには、CILのコミットあるいはフラッシュロックを取らなくてはいけません。
CILの基本的な要件は、トランザクションコミット時のアクセスは、多くの同時実行されるコミットに対してスケールするべきであるということです。現在のトランザクションコミットコードは、2048プロセッサーから同時にトランザクションが来ても破綻しないようになっています。現在のトランザクションコードは、それを使っているCPUが2048でも、1つのときより速くはなりません。しかし、かえって遅くなるということもありません。
このため、遅延ロギングのトランザクションコミットコードは、最初から、同時実行を考えて設計される必要があります。設計上、シリアル化が避けられない点があるのは当然です。3つの、重要なものは、
1. CILをフラッシュする間、新しいトランザクションコミットをロックアウトする。
2. CILにアイテムを追加し、アイテム領域の計算を更新する。
3. チェックポイントコミットの順序付け。
トランザクションコミットとCILフラッシュの相互作用を見ましょう。これは多:1の関係であるのは明らかです。つまり、同時にコミットしようとすることができる同時実行するトランザクションの数を制限する唯一のものは、それらが予約のために使うことのできるログの領域の量だけです。ここでの現実的な限界は、128MBのログで、数百のオーダーの同時実行トランザクションといったところです。ということは、1台のマシンのCPUの数程度です。
トランザクションコミットがフラッシュのロックを持っている必要のある時間は、比較的長いです。ログアイテムのピン固定はCILフラッシュを保持している間にしなくてはいけません。なので、今のところ、ロックはオブジェクトをメモリーバッファにフォーマットする間保持されるということになります。(つまり、 memcpy() 実行中)将来的には、フォーマットが、オブジェクトのピン固定とは別に行われるという2パスのアルゴリズムを使って、トランザクションコミット側の保持時間を減らすことができるようになるかもしれません。
トランザクションコミット側でロックを保持する可能性があるものの数は多いので、ロックはスリープするロックであるべきです。もしCILフラッシュがロックをとった場合、そのマシンの他のすべてのCPUがCILロックをスピンして待つというのは避けたいです。CILのフラッシュが数万ものログアイテムのリストをたどる必要があるかもしれないことを考えると、ロックは長い間保持されることがあるのでスピン競合は重大な問題です。多くのCPUが何もせずにただスピンするのを防ぐというのが、スリープロックを採用した主な理由です。トランザクションコミット側も、CILフラッシュ側も、ロックを持ったままスリープすることはないにもかかわらず、です。
また、非同期トランザクションのワークロードにおいては、CILフラッシュはトランザクションコミットに比べて比較的まれな操作であるということにも注意するべきでしょう。排他制御に、リードライトセマフォーを使ったことが、リード側においてのキャッシュラインのバウンシング競合を引き起こして、トランザクションコミットの同時実行の妨げになるのかどうか、時間だけが判定してくれることでしょう。
2つめのシリアル化ポイントは、トランザクションコミット側においてアイテムがCILに挿入されるところにあります。トランザクションはこのコードに同時に入ってくることができるので、CILは前記のコミット/フラッシュロックとは別の形で守られる必要があります。また、これは、排他的ロックである必要がありますが、とても短い期間、保持されるだけなので、ここではスピンロックが適当です。このロックが競合ポイントになることはありえますが、トランザクションについて1回の短い保持時間を考えると、問題は起きないでしょう。
最後のシリアル化ポイントは、チェックポイントコミットとログフォースシーケンシングの一部として実行される、チェックポイントコミットレコードの順序付けコードです。CILフラッシュを起こすコードパス(ログフォースを起こす処理すべて)は、ログバッファにすべてのログベクトルを書いた後、かつ、コミットレコードを書く前に、順序付けのループに入ります。このループでは、コミット中のチェックポイントのリストをたどって、チェックポイントがそのコミットレコードの書き込みを完了するのを待ってブロックする必要があります。このために、ロックとウエイト変数が必要です。ログフォースシーケンシングもまた、チェックポイントの完了を保証するために、同じロック、リスト走査とブロックのメカニズムを必要とします。
これら2つのシーケンシング操作は、待っているイベントが異なるにもかかわらず、同じメカニズムを使うことができます。チェックポイントコミットレコードシーケンシングは、チェックポイントコンテキストがコミットLSN(コミットレコード書き込みの完了で得られます。)を含むまで待ちます。一方、ログフォースシーケンシングは以前のチェックポイントコンテキストがコミット中リストからなくなるのを待ちます。(完了した、ということです。)単純なウエイト変数とブロードキャスト起床(おしよせる起床者の群れ問題があるかもしれません。)が、これら2つのシリアル化キューを実装するために使われてきました。CILと同じロックも使っています。CILロックに競合が多すぎたり、ブロードキャスト起床のためにコンテキストスイッチが多すぎたりしたならば、新しいスピンロックと別々の待ちリストを使った実装に変えることもできるでしょう。そうすれば、ロック競合が減り、間違ったイベントによって起床されるプロセスの数も減らすことができます。
現在のログアイテムのライフサイクルは以下のとおりです。
1. トランザクションをメモリ確保
2. トランザクションのログ領域予約
3. アイテム(xfs_inode や xfs_buf です)をロックする
4. アイテムをトランザクションに加える
今までにアタッチされていなければ、
ログアイテムをメモリ確保
ログアイテムをオーナアイテムにアタッチ
ログアイテムをトランザクションをにアタッチ
5. アイテムを変更する
変更分をログアイテムに記録する
6. トランザクションコミット
アイテムをメモリにピン固定
アイテムをフォーマットしてログバッファに入れる
トランザクションにコミットLSNを書く
アイテムをアンロック
トランザクションをログバッファにアタッチ
<ログバッファ IO が発行される>
<ログバッファ IO が完了する>
7. トランザクション完了
ログアイテムをコミット済みにする
AIL にログアイテムを入れる
ログアイテムにコミット LSN を書く
ログアイテムをアンピン
8. AIL 走査
アイテムをロック
ログアイテムをクリーンとする
アイテムをディスクにフラッシュする
<アイテム IO が完了する>
9. ログアイテムは AIL から抜ける
ログのテールを動かす
アイテムをアンロック
本質的には、ステップ1から6はステップ7とは独立に動作し、ステップ7は、ステップ8と9とは独立しています。アイテムはステップ1から6もしくはステップ8から9の間、ロックされていることがありますが、そのとき、同時にステップ7が動いていることが可能です。しかし、1から6と、8から9のどちらかしか、同時には動くことはできません。もしログアイテムが AIL に入っているか、ステップ6から7の間にあるときに、ステップ1から6がまた起きた時には、そのアイテムはリログされます。オブジェクトは、ステップ8から9が起きて、完了した後でないと、クリーンにはなりません。
遅延ロギングのときは、ライフサイクルに追加のステップがあります。
1. トランザクションをメモリ確保
2. トランザクションのログ領域予約
3. アイテムをロックする
4. アイテムをトランザクションに加える
今までにアタッチされていなければ、
ログアイテムをメモリ確保
ログアイテムをオーナアイテムにアタッチ
ログアイテムをトランザクションにアタッチ
5. アイテムを変更する
変更分をログアイテムに記録する
6. トランザクションコミット
アイテムが CIL でピンされていなければ、メモリにピン固定
アイテムをフォーマットしてログベクトル+バッファに入れる
ログベクトルとバッファをログアイテムにアタッチ
ログアイテムを CIL に入れる
CIL コンテキストシーケンスをトランザクションに書く
アイテムをアンロック
<次のログフォース>
7. CIL プッシュ
CIL フラッシュをロック
ログベクトルとバッファをいっしょにつなぐ
アイテムを CIL から外す
CIL フラッシュをアンロック
ログベクトルをログバッファに書く
コミットレコードにシーケンス番号を書く
ログバッファにチェックポイントコンテキストをアタッチ
<ログバッファ IO が発行される>
<ログバッファ IO が完了する>
8. チェックポイント完了
ログアイテムをコミット済みにする
AIL にログアイテムを入れる
ログアイテムにコミット LSN を書く
ログアイテムをアンピン
9. AIL 走査
アイテムをロック
ログアイテムをクリーンとする
アイテムをディスクにフラッシュする
<アイテム IO が完了する>
10. ログアイテムは AIL から抜ける
ログのテールを動かす
アイテムをアンロック
これから、2つのロギング方法の間のライフサイクルの違いは、中間部だけにあることがわかります。2つは、開始と終了の部分、そして、実行条件は同じままです。違っている点はただ、ログアイテムをログ自身にコミットするところと、その完了処理です。このため、遅延ロギングは、ログアイテムのふるまいに新たな制限を加えたり、現在の確保と解放の処理以上のものを増やしたりすることはありません。
このゼロインパクトの遅延ロギングインフラ構造の「挿入」と、ディスク上フォーマットの変更を避けるという前提での内部構造の設計の結果、基本的には、マウントオプション1つで、遅延ロギングと、今までのメカニズムとの間を行き来することが可能です。原理的には、ログマネージャが、負荷の特徴に応じて、自動的かつ透過的にメカニズムの切り替えをするということもできるのでしょうけど、遅延ロギングが設計通りに動くのであれば、その必要もないでしょう。
訳は2012年 kanda.motohiro@gmail.com による。