CLOVER🍀

That was when it all began.

Infinispanのロック、並行モデルを学ぶ

今回は、サンプルを使ったプログラミングというよりは、Infinspanのドキュメントを読んで、それを理解するといったところでしょうか。

Locking and Concurrency
https://docs.jboss.org/author/display/ISPN/Locking+and+Concurrency

ロックと並行性についてです。そろそろ、この辺りも目を通した方がいいのではないかなぁと思いまして。訳しながら「自分が」理解していくのが、主旨です。

ですので、割と日本語がよくわからないことになっていることもあるかと思うので、読みづらいと思われた方は以下のドキュメントを見るとよいかもしれません。

第9章 ロッキング
https://access.redhat.com/site/documentation/ja-JP/JBoss_Data_Grid/6/html-single/Administration_and_Configuration_Guide/index.html#chap-Locking

概要

Infinispanはリレーショナルデータベースやその他のデータストアで一般的な、MVCCを採用しています。MVCCには、共有データにアクセスするための粗いJavaの同期の仕組みやJDKのLockに対して、次のような多くの長所があります。

  • Reader/Writerの並行性を許容する
  • Reader/Writerはお互いにブロックしない
  • write-skewによる(衝突の?)検出とハンドリングを行うことができる
  • ストライピングを使用した、内部ロック

では、続いて詳細をブレークダウンしていってみます。

MVCCの実装の詳細

InfinispanのMVCCの実装は、可能であればどこでもCAS(compare-and-swap)やロックフリーなデータ構造のようなロックフリーなテクニックを使用して、ロックと同期を最小限に使うようにしています。それは、マルチCPU、マルチコアの環境での最適化の助けとなるでしょう。

特に、InfinispanのMVCCの実装はReaderに最適化されています。Readerスレッドは、エントリのために明示的なロックを取得しません。そして、直接問題となるエントリの読み出しを行いません。

一方で、Writerは書き込みのためのロックを取得する必要があります。これは、ひとつのエントリあたりひとつのWriterが割り当てられることを保証し、並行にアクセスしたWriterはそのエントリを変更するために、キューに入れられます。この時、WriterはエントリをMVCCEntryでラップして変更するためのコピーを作成します。これにより、読み取りは並行に行うことができます。

分離レベル

Infinispanは、要素で設定可能な、2つの分離レベル - READ_COMMITTED(デフォルト)とREPEATABLE_READ - を提供します。これらの分離レベルは、使用されるMVCCEntryのサブクラスが変わることにより、データコンテナにどのように状態が書き戻されるかが異なります。そしてこれは、Readerが並行に書き込まれた内容を読み出す時に作用します。

ここで、InfinispanのコンテキストでのREAD_COMMITTEDとREPEATABLE_READとの違いを理解するのを助ける、詳細な例を挙げましょう。READ_COMMITTEDでは、もし2つの連続した同じキーでの読み込みの間に、キー(に紐付けられた値)が別のトランザクションによって更新されていた場合、2つ目の読み取りは更新された値を返却するでしょう。

1.  Thread1: tx.begin()
2.  Thread1: cache.get(k) returns v
3.  Thread2: tx.begin()
4.  Thread2: cache.get(k) returns v
5.  Thread2: cache.put(k, v2)
6.  Thread2: tx.commit()
7.  Thread1: cache.get(k) returns v2!

REPEATABLE_READでは、7.でvが返却されます。よって、同一のトランザクション内で何度も同じキーで読み取りを行うのであれば、REPEATABLE_READを使用するべきです。

LockManager

LockManagerは、エントリへの書き込みの際の、ロックに関する責任を負っているコンポーネントです。LockManagerは、ロックの発見、保持、作成のためにLockContainerを使用します。LockContainerは、ロックストライピングのサポート、エントリごとのロックのサポートという2つの特色を持ちます。

ロックストライピング

ロックストライピングは、全キャッシュのための固定サイズの共有コレクションを必要とします。この時、エントリのキーのハッシュコードに基づいて、各エントリにロックが割り当てられます。これは、JDKのConcurrentHashMapのロックの割り当てによく似た方法で、固定のオーバーヘッドでの高いスケーラビリティを提供します。ただし、同じロックで無関係のエントリをブロックしてしまうという可能性が、代償となっています。

この代替案は、ロックストライピングを無効にすることです。これは、エントリごとに新しいロックを作成することを意味しています。このアプローチでは、より高い並行処理のスループットを得られます。しかし、これは使用するメモリの増大、ガベージコレクションの増加などのコストが必要になるでしょう。

ロックストライピングのデフォルト設定

Infinispan 5.0から、ロックストライピングはデフォルトで無効です。異なるキーが、同じロックストライプを使用する場合、デッドロックが発生する可能性があるためです。Infinispan 4.Xでは、ロックストライピングはデフォルトで有効になっていました。

ロックストライピングで使用する、共有ロックコレクションのサイズはタグのconcurrencyLevel属性で調整することができます。

Concurrency levels

LockContainerによるストライプのサイズの決定に加えて、このConcurrency Level(並行レベル)は、DataContainerの内部で関連付けられているような、JDKのConcurrentHashMapベースのコレクションの調整に使用されます。Infinispanではこのパラメータは正確に(ConcurrentHashMapと)同じ方法で使用されるので、並行レベルについての詳細な内容については、ConcurrentHashMapのJavadocを参照してください。

明示的、暗黙的な分散即時ロック

Infinispanは、デフォルトでリモートのロックをレイジーに取得します。ロックは、トランザクションが実行されているノードで、ローカルに取得されます。一方で、他のクラスタノードは2フェーズの準備/コミットフェーズを実行中のトランザクションで、キャッシュキーのロックを行おうとします。しかしながら、もし要望するのであれば、Infinispanは明示的または暗黙的なキャッシュキーの即時ロックを行うことができます。

InfinispaのCacheインターフェースは、トランザクション中でキャッシュを使用するユーザが、キャッシュキーの集合を即時にロックすることを可能にする、ロックAPIを公開しています。ロックの呼び出しは、すべてのクラスタノードを横断して、指定されたキャッシュキーの集合をロックすることを試みます。それは、成功するか失敗するかのどちらかです。全てのロックは、コミットまたはロールバックフェーズで開放されます。

キャッシュノードのひとつで、トランザクションが実行されることを考えてみましょう。
(明示的なロックの例だと思います)

tx.begin()
cache.lock(K)    // Kに対する、クラスタにわたってのロックを取得します
cache.put(K,V5)  // (ロック取得後は、)成功することが保証されます
tx.commit()      // ロックを開放します

暗黙的なロックは、1ステップ前に行き、変更オペレーションのためにキーがアクセスされる場面の裏で、キャッシュキーをロックします。

キャッシュノードのひとつで、トランザクションが実行されることを考えてみましょう。
(暗黙的なロックの例だと思います)

tx.begin()
cache.put(K,V)    // Kに対する、クラスタにわたってのロックを取得します
cache.put(K2,V2)  // K2に対する、クラスタにわたってのロックを取得します
cache.put(K,V5)   // Kに対する、クラスタに渡ってのロックをすでに取得しているので、何もしません
tx.commit()       // ロックを開放します

暗黙的な即時ロックは、必要であればクラスタを横断したキャッシュキーのロックを行います。簡単に言えば、もし暗黙的な即時ロックが各変更に向けられるのであれば、キャッシュキーをローカルにロックしInfinispanはチェックを行います。
*よくわからん…
それは、グローバルなクラスタに渡るロックをすでに獲得しているということであり、そうでなければクラスタに渡るロックのリクエストを送信し、ロックを取得します。

暗黙的なロックは、次のようにすれば有効になります。

<transaction useEagerLocking="true" />

*でもさぁ、useEagerLockingってInfinispan 5.1で非推奨になったんじゃなかったっけ…?

単一のリモートノードをロックする

これ(eagerLockSingleNode)も非推奨だった気がしますが…。画像は、オフィシャルのWikiからのものです。

4.2から、InfinispanではeagerLockSingleNodeの設定ができます。これは、DISTモードのみで有効です。これを有効にすることは、リモートロックの数を常にひとつ取得するようになることを意味します。この時、numOwnersの設定は無視されます。次の図で、このオプションについてより良く説明したいと思います。全ての図では、クラスタは5つのノードから成り、numOwnersは2であることを表しています。

この図では、eagerLockSingleNodeをfalseにした時(デフォルト設定です)の状況を表しています。各ロックのリクエストは、numOwnersの分だけリモート呼び出しを行います(この例では、2回)。

この図では、eagerLockSingleNodeをtrueにした時、同じキーに対するロックをどのように取得するのかを示しています。リモート呼び出しが行われる回数は、numOwnersの値を無視して、常に1回です(後で見続ける(追加されること?)ので、実際には0であることもありえます)。

このシナリオでは、もしトランザクションでロックを保持しているオーナー(ノードC)が失敗(ダウン?)したら、ノードAはロールバックされるようにマークされます。

eagerLockSingleNodeとKeyAffinityServiceの組み合わせは、いくつかの興味深い利点をもたらす可能性があります。次の図を見てください。

ひとつは、KeyAffinityServiceの使用により、常にローカルノードにマップするキーを生成することができます。eagerLockSingleNodeをtrueにした場合、リモートロックの獲得がローカルに発生します。

この方法は、即時ロックと同等の意味を、即時ロックを使用しないパフォーマンスで使用できるという利益が得られる可能性があります。この最適化は、クラスタのトポロジの変更に影響を受けます。したがって、キーが再配置されるかもしれません。クラスタのトポロジの変更がなければ、これは多くの価値をもたらす可能性があります。

この設定は、次のXML片を参照してください:

<transaction
      transactionManagerLookupClass="org.infinispan.transaction.lookup.GenericTransactionManagerLookup"
      syncRollbackPhase="false"
      syncCommitPhase="false"
      useEagerLocking="true" eagerLockSingleNode="true"/>

即時ロックが無効になっているか、キャッシュモードがDISTでなかった場合は、この設定は無視されることに注意してください。

Consistency(一貫性)

全てのオーナーのロックを取得することと対比して、ロックされるオーナーがひとつであるという事実は、次のことから一貫性の保証を破るものではありません。

もしキーKがノードA、ノードBに分散していたとして、トランザクションTX1がノードA上でKのロックを取ろうとします。もし、ノードB(または任意の他のノード)で開始された別のトランザクションTX2が、Kをロックしようとしたなら、ロックはすでにTX1によって取得されているので、タイムアウトになり失敗するでしょう。このような動作になる理由は、トランザクションがどこで発生するかに関わらず、キーKに対するロックは、クラスタ上の同じノードに決定論的に取得されるからです。

トランザクションキャッシュと並行更新

この設定は、非トランザクションな分散されたキャッシュ、またはローカルキャッシュにのみ適用され(レプリケーションキャッシュには適用されません)、Infinispan 5.2で加えられました。並行な更新(例えば、2つのスレッドが同じキーに対して並行に書き込む)をサポートするかによって、次のような設定を使用することができます。

<locking supportsConcurrentUpdates="true"/>

これを有効にした時(デフォルトはtrue)、supportConcurrentUpdatesは内部的に並行書き込みのサポートを加えます。

それは、同じキーに対する書き込みを直列化するロックインターセプターと、ロックオーナーを指定し、同じキーに対する書き込みを調整するために使用するデリゲーション層です。

より具体的に言えば、ノードAで動作するスレッドがキーkに対して書き込む時、ノードB、ノードCにコンシステントハッシュによりマップします。

numOnwersを2とした時:

  • ノードAは、書き込みをプライマリオーナーに転送(RPC)します。プライマリオーナーは、オーナーのリストの中の、最初のノードです。この例では、ノードBとします
  • ノードBは、キーkのロックを取得します。1度ロックの取得に成功すれば、リクエストをローカルに適用できる残ったオーナー(この例では、ノードC)に転送(RPC)します
  • ノードBは結果をローカルに適用し、ロックをリリースしてノードAに戻ります

パフォーマンスについての推論:
並行更新での一貫性を保証するために、2回のRPCを実行します:
オペレーションを始めた人からメインとなるオーナーまで、そしてメインとなるオーナーから残りのオーナーまで。

supportConcurrentUpdatesをfalseにした時の注意:
この場合、オペレーションを始めた人は、すべてのオーナーに対して単一(マルチキャスト)のRPCを行います。これは、パフォーマンス上のコストを引き起こします、並行ではないキャッシュを使用する時はいつでも、パフォーマンスを向上するためにこの設定(supportConcurrentUpdates)をfalseにすることが推奨されます。Hot RodクライアントでInfinispanのクライアント/サーバモードを使用する時、データ書き込みのために、メインのデータオーナーを使用するでしょう。このシナリオでは、並行更新のサポートを行う時、実行コストはないでしょう。