Hibernateのキャッシュについて考えてみる(その6)

前回ReadOnlyCache について掘り下げてみました。今回は ReadWriteCache について掘り下げてみたいと思います。

マニュアル曰く

If the application needs to update data, a read-write cache might be appropriate. This cache strategy should never be used if serializable transaction isolation level is required. If the cache is used in a JTA environment, you must specify the property hibernate.transaction.manager_lookup_class, naming a strategy for obtaining the JTA TransactionManager. In other environments, you should ensure that the transaction is completed when Session.close() or Session.disconnect() is called. If you wish to use this strategy in a cluster, you should ensure that the underlying cache implementation supports locking. The built-in cache providers do not.

http://www.hibernate.org/hib_docs/v3/reference/en/html/performance.html#performance-cache-readwrite

おいら的意訳:
アプリケーションがデータを更新する必要がある場合、read-write キャッシュが適切かもしれません。このキャッシュ戦略は、トランザクションアイソレーションレベルが serializable の場合は使用してはいけません。キャッシュが JTA 環境で使用される場合は、hibernate.transaction.manager_lookup_class プロパティで JTA TransactionManager を取得するための戦略を指定する必要があります。その他の環境では、Session.close() もしくは Session.disconnect() が呼び出された際に、トランザクションが完了していることを保証する必要があります。この戦略をクラスタ内で使用する場合は、キャッシュ実装がロッキングをサポートすることを保証する必要があります。ビルトインの各キャッシュプロバイダはロッキングをサポートしていません。
※ロッキングは、Cache.lock(Object key) および Cache.unlock(java.lang.Object) ですね。

マニュアルからわかること

  • トランザクションアイソレーションレベルが serializable の場合は使用してはいけない。
    • おいらコメント:serializableって、実際に使うことは稀ですよね。(おいらは一度もないです)
    • おいらコメント:確かに、DB側でせっかく serializable にしても、Hibernate 側でキャッシュ使われちゃ困りますよね。
  • JTA 使う場合は hibernate.transaction.manager_lookup_class プロパティを指定する。
    • おいらコメント:JPA 上で JTA 使う場合はどのみち必須ですよね。
  • Session.close() もしくは Session.disconnect() が呼び出された際に、トランザクションが完了していることを保証する必要がある。
    • おいらコメント:XA使う場合、Hibernateのデフォルトの設定だと、トランザクション内でも Session.close() しちゃいますよね。っていうか、コネクション切っちゃいますよね。hibernate.connection.release_mode を after_transaction にしなきゃだめって言ってます?
  • クラスタ内ではキャッシュ実装がロッキングをサポートする必要がある。
    • おいらコメント:逆を言えば、ロッキングを適切にサポートしてさえいれば、Cluster Safe だということですね。(ほかの制約条件がないとは言い切れませんケド。タイムスタンプとかもグローバルにしなきゃですよね。)

マニュアルからはわからないこと

  • ほとんどなにもわからないです o...rz

ReadWriteCache のソースからわかること

排他処理

以下のメソッドは、すべて synchronized メソッドです。そのため、同一VM上であれば排他処理が保証されます。

  • Object get(Object key, long txTimestamp)
  • CacheConcurrencyStrategy.SoftLock lock(Object key, Object version)
  • boolean put(Object key, Object value, long txTimestamp, Object version, Comparator versionComparator, boolean minimalPut)
  • void release(Object key, CacheConcurrencyStrategy.SoftLock clientLock)
  • boolean afterUpdate(Object key, Object value, Object version, CacheConcurrencyStrategy.SoftLock clientLock)
  • boolean afterInsert(Object key, Object value, Object version)

また、上記の get メソッド以外については、入り口で Cache#lock メソッド、出口で Cache#unlock メソッドが実行されるため、クラスタ環境でも排他処理が保証されます。(実装側で、正しく排他制御する必要があり、ロックが解除された瞬間には状態が完全に複製されている必要があります。たとえば、非同期のレプリケーションとかを行ってはいけません)

データの更新時には、DB上のデータ更新前に ReadWriteCache#lock(key) が必ず実行され、そのメソッド中の Cache#unlock メソッド直後には状態が確実に共有されていることが保証されているため、getメソッドでは Cache#lock(key) および Cache#unlock(key) を行う必要はありません。なぜなら、ReadWriteCache#lock(key) メソッドの実行中はキャッシュのデータは古くなっていないので、キャッシュのデータを使っても使わなくても正しい動作をするからです。しかし、ReadWriteCache#lock(key) メソッド終了後は、DBのデータが更新される可能性があるので、キャッシュのデータを使うと不整合が起こる可能性があります。しかし、この時点ではキャッシュには Lock が格納されてるので、問題ありません。でも、リードが発生するとキャッシュにputされて、後述の handleLockExpiry 問題が発生してしまうんですよね、、、。
※2次キャッシュから取得したデータがLockの場合はキャッシュにputしないようにすれば、後述の handleLockExpiry 問題も発生せず、完璧なクラスターキャッシュになるような気がするのですが、、、。めんどいので今は検証しませんがw

CRUD操作の検証(CacheConcurrencyStrategy が org.hibernate.cache.ReadWriteCacheクラスのエンティティの場合)

CREATE : エントリの挿入
  • ReadOnlyCache と同様。DBにデータがinsertされ、キャッシュには一切影響しない。
READ : DB上にも2次キャッシュ上にも存在しないエントリの読み込み
  • ReadOnlyCache と同様。キャッシュミスをして、DBにselectが走り、結果が0件で、キャッシュにも影響なし。
READ : DB上に存在し、2次キャッシュ上に存在しないエントリの読み込み
  • キャッシュミス。
  • DBからのデータ取得に成功。
  • ReadWriteCache#put(Object key) が呼ばれる。
READ : 2次キャッシュ上に存在するエントリの読み込み
  • キャッシュヒット。
    • 厳密には、Cache#get()はヒットするが、ReadWriteCache#get()ではトランザクション開始時刻より新しい場合は null を返す。
READ : DB上に存在しないキーのエントリを読み込み、Hibernateを経由しないでDBに直接そのキーのエントリを書き込み、再度同一のキーのエントリを読み込む *1
  • この実験の意図としては、read-only と同じ動作。
UPDATE : 2次キャッシュ上に存在するエントリに対して、Hibernate 経由で update
DELETE : 2次キャッシュ上に存在するエントリに対して、Hibernate 経由で delete

CRUD操作の検証まとめ

CREATE
  • キャッシュへの put は行われない。
  • ※2次キャッシュのみの視点であれば、同一の SessionFactory を経由しないDBへの insert (JDBC経由とかコンソールから直接とか)も可能。しかし、クエリキャッシュやコレクションキャッシュ適用時には不整合が起こる。たとえばクエリキャッシュの場合は、2次キャッシュが適用されているエンティティに対して insert, update, delete が発生した場合には、タイムスタンプを更新してクエリキャッシュの invalidate をするのだが、同一の SessionFactory を経由しないDBへの insert を行うと、この invalidation ができず、クエリキャッシュのデータが腐ってしまう。
READ
  • 2次キャッシュからの取得が試行され、キャッシュミス時のみDBに対しての問い合わせが行われる。
  • ReadWriteCache#lock(key) から ReadWriteCache#release(key) の間に別スレッドの READ にてキャッシュに値がputされてしまうと、DBにデータ書き込んでコミットした後に、古いキャッシュが使用されてしまう可能性がある。release()時にキャッシュの内容が Lock 以外の場合は ReadWriteCache#handleLockExpiry() メソッドにて warn レベルのログが出力されるが、後の祭り。キャッシュは古いデータを返してしまいます。
UPDATE
  • handleLockExpiry 問題が発生しない限りは、単一VMでもクラスタ環境でもデータの整合性は保たれる。
  • 対象キーのキャッシュは削除される。(Lockで上書される)
DELETE
  • handleLockExpiry 問題が発生しない限りは、単一VMでもクラスタ環境でもデータの整合性は保たれる。
  • 対象キーのキャッシュは削除される。(Lockで上書される)

handleLockExpiry 問題

ReadWriteCache#lock(key) と ReadWriteCache#release(key) の間のDB書き込み処理(処理1とする)の際、単一VM or グローバル問わず読み込みが行われると、Lockが読み込まれるために2次キャッシュはミスし、DBからデータが読み込まれ、2次キャッシュにエントリが上書きされる。そうすると、処理1によりDBにデータが書き込まれてコミットした直後は、DB上のデータとキャッシュ上のデータに不整合が発生してしまうという問題。

ReadWriteCache#handleLockExpiry() について

以下のメソッド内から呼び出される。
・ReadWriteCache#afterUpdate(Object key, Object value, Object version, SoftLock clientLock)
・ReadWriteCache#release(Object key, SoftLock clientLock)

まとめ

今回は ReadWriteCache について掘り下げてみました。handleLockExpiry 問題は悩ましいですね。ここら辺については、そのうちまた考えてみようかと思いつつ、次回以降は当面、 ReadOnlyCache をいかにおいしく使うかを考えてみようと思います。

*1:これは、DBに存在しなかったという情報がキャッシュされるかどうかを確認するためのものです。たとえば、DB から read 時にデータが存在しなかった場合に、DB に存在しなかったという情報をキャッシュしておけば、次回以降はDBを読む必要がなくなります。