CLOVER🍀

That was when it all began.

Hazelcast Internal(構造編)

Hazelcastの内部構造について、このブログで書いてみては?みたいな話があったので。

これまで、このブログではHazelcastの簡単な使い方と表層にしか触れてきませんでしたし、書いている自分自身もそういうスタンスだったのですが(もうちょっと踏み込んだIMDGはありますが)、せっかくなので追ってみることにしました。

というわけで、2回ほどに分けてHazelcastの内部を、初歩的な範囲で追ってみます。

このエントリを読むような方は、

  • ある程度Hazelcastの基本的な使い方を知っている
  • Hazelcastの中の構造などに興味がある

と、もともと狭い対象範囲がさらに狭くなる感じですが、ご興味があればということで。

最初のエントリは、構造編です。2回目は、ネットワークまわりを考えています。2回目がいつになるか、わかりませんが!

このエントリで扱うHazelcastのバージョンは、3.7.1とします。

追記
ネットワーク編も書きました!
Hazelcast Internal(ネットワーク編) - CLOVER

このエントリで解説する内容

以下の内容を題材にします。

  • Partition/PartitionId
  • 代表的なHazelcastの内部クラス
  • Hazelcastのデータ構造のうち、Mapを代表としてRecordStore
  • エントリの登録とバックアップへの登録まで

Hazelcastは、以下のような多彩なデータ構造を扱えます。

  • Map
  • MultiMap
  • Replicated Map
  • Cache
  • Queue/Set/List(Collection)
  • RingBuffer
  • Topic/Reliable Topic

のちに記載するPartitionを使うという概念は同じですが、データの持ち方などはこの構造ごとに別々に管理されているため、今回は代表的に使うと思われるMap(Distributed Map)について扱います。

ただ、他のデータ構造においても似たような用語はソースコードを追っていくと現れるので、ひとつ読み解くと他のデータ構造もある程度わかるようになるような気がします。

今回のエントリで対象とするのは、Embeddedに絞ります。Client/Server構成は、対象外とします。また、Mapについて書くとしていますが、Distributed ExecutorやMap Reduce/Aggregationといった分散処理のAPIについても今回は対象外とします。

あと、クラス図っぽいものも登場しますが、記法の厳密性はそんなに気にしていないので、微妙なところは置いておいてください…。

Partition

まずはじめに、Partitionについて説明します。

Hazelcastを使ったコードを書く際には、例えば以下のようなコードを書きます。

        HazelcastInstance hazelcast = Hazelcast.newHazelcastInstance();

        IMap<String,String> map = hazelcast.getMap("default");
        map.put("key1", "value1");
        map.get("key1");

ここで、利用者側はHazelcastでどのようにデータが管理されているかは、特に意識しません。Hazelcastを使う時は、たいてい以下のように複数Nodeでのクラスタを構成するかと思いますが、

Mapなどに対してputやgetをすると、クラスタ内の適切なNodeからエントリを取得してきてくれます。


このデータの管理についてですが、HazelcastはPartitionと呼ばれる単位で管理しています。
Data Partitioning

Partitionと呼ばれる単位で、メモリを区切ってデータを管理します。デフォルトでは271個のPartitionを持つように設定されており、クラスタ内のNodeがひとつの場合はすべてのPartitionを1 Nodeが保持します。

※この図は、オフィシャルのドキュメントのものです

Nodeが追加されると、Partitionの分布がNode間で分かれます。

Nodeが2以上になると、Partitionのバックアップという概念が現れます。この図でいくと、黒い字で書かれている方がPrimary Partition(Owner)で、青い字で書かれている方がBackup Partitionという扱いになります。

ここでは、1〜135までのPartitionがひとつのNode、136〜271までのPartitionが別のNodeがPrimaryとなり、逆のNodeはPrimaryではないPartitionのBackupとなります。

さらにNodeを追加すると、リバランスされてPartitionの配置状況が変わります。

Nodeを追加したり、またNodeがダウンした際には、このPartition単位でNode間のデータの移動が行われますし、Nodeがダウンした際のデータの復旧やバックアップは残ったNodeにあるPartitionから行われることになります。

Hazelcastの代表的な内部クラス

で、ここからは、少しHazelcastの内部に踏み込んでいきます。

単純に提供されたデータ構造に従って実装をしていくのであれば、IMapやISetといったJava Collection Frameworkのインタフェースの拡張インターフェースに対してコードを書いていきます。実装クラスは、ほとんど現れません。

中心となるHazelcastInstanceも、インターフェースです。

実装クラスについて少し追っていくと、以下のようなクラスたちを目にすることになると思います。

だいたい目にするのは、NodeやNodeEngine(の実装)で、これらのクラスがHazelcastが動作するための中心的なクラスとなっています。

NodeはHazelcastのNode自身を表し、HazelcastInstanceの実装が作成される段階で、一緒に作成されます。このタイミングで、NodeEngineも作られます。

Hazelcastの起動後、MapやSetなどを使って操作を行った際には、裏をたどっていくとNodeやNodeEngineから取得したクラスを使って処理を行っていくことが見えると思います。

ただ、これらのクラス/インターフェースは、外部には公開されていないため、基本的に利用者側が目にすることはありません。

実装を追っていくと割と目にするので、こういうクラスたちが中心にいるんだなーと思っておきましょう。

Partition Internal

PartitionとNode/NodeEngineの話をしたところで、Partitionがどのように保持されているかを書いていきたいと思います。

HazelcastのAPIを使ってPartitionにアクセスするには、PartitionServiceを使って以下のようなコードを書きます。

            PartitionService ps = hazelcast.getPartitionService();
            Partition partition = ps.getPartition("key1");
            int partitionId = partition.getPartitionId();
            Member owner = partition.getOwner();
            Address ownerAddress = owner.getAddress();

PartitionServiceを使うと、キーからPartitionを取得したり(PartitionService#getPartition)、現在のPartitionをすべて取得することができます(PartitionService#getPartitions)。

PartitionServiceはインターフェースで、その実装クラスはPartitionServiceProxyとなります。ここから、すべてのPartitionの情報を取得することができます。

ただ、これらのクラスは利用者向けのコードのフロントエンドにすぎません。

実際には、Nodeクラスの配下に内部的なPartitionが管理されており、利用者向けに提供されているものとは異なる、内部APIとしてPartition関連の情報が表現されています。

ここまでたどると、PartitionのPrimaryとなっているMember、そしてそのBackupを持っているMember(内部的には、レプリカと表現されています)を知ることができます。

つまり、利用者側からはPartitionのBackup Memberを知ることができません。

まあ、内部情報を追っていけば、Partitionの持ち主全部を知ることができるわけですね。

とはいえ、PartitionはNodeの情報は持っていますが、実際に保存されているデータについてはまったく知りません。データがどこにあるかは、
利用するデータ構造に依存します。

というわけで、今回はMap(Distributed Map)を見ていきます。

Distributed Mapの内部

Hazelcastの提供するMap、インターフェースとしてはIMapですが、こちらの実装クラスを追ってみると以下のようなクラスにたどり着きます。

MapProxy〜みたいなクラスが出てくるはずです。なんでProxy?みたいなところもありますが(PartitionServiceインターフェースの実装クラスも、PartitionServiceProxyでしたね)、

で、MapProxy〜をさらに追い、データを扱っているところまで見ていくと、以下のようなクラスにたどり着きます。なお、ここで記載したクラスは、すべてMap専用のパッケージ(com.hazelcast.map.impl.〜)もしくはサブパッケージに属します。

PartitionIdに対応したPartitionContainerがあり、PartitionContainerがRecordStore、そしてStorageを持ちます。データ管理の末端は、このStorageと考えてよいと思います。
Hazelcastでメモリ中にデータを保存する際のフォーマットを選ぶことができますが、その設定はStorage構築時に渡されることになります。

Setting In-Memory Format

ところで、図の中に「name単位で」という表現が出てきましたが、ここで言うnameとは、HazelcastInstanceからMapを取得する時のnameです。

つまり、以下のコード例の場合だと「default」がMapの名前になります。

        IMap<String,String> map = hazelcast.getMap("default");

今回、Mapを例に記載しましたが、他のデータ構造でもあまり変わらずRecordStoreやStorageっぽい名前のクラスを見かけることになります(これらのクラスやインターフェースに、継承関係などがあるわけではありませんが)。

Partitionとの関係をあらためて整理すると、Hazelcastクラスタ内のNodeをPartitionと呼ばれる単位に区切り

そのPartitionの中に、RecordStoreがname単位にあり、末端がStorageという感じですね。

Storageにはシリアライズされたデータが格納されることになりますが、シリアライズ関係で出てくるのはSerializationServiceとDataになります。

Mapへ格納するオブジェクトがSerializableインターフェースを実装していることは前提ですが、Hazelcastの内部的にはシリアライズ後にDataというクラスに変換して保持することになります。SerializationServiceは、Hazelcastのシリアライズ処理を抽象化したものです。

そして、このData(キーをDataに変換したもの)とInternalPartitionServiceを使うことで、該当のDataがどのPartitionに配置されるのかを決める、PartitionIdを求めることができます。

こうやって、Partitionと配置されるデータの関係が決まっていくわけですと。

put/getを少し追う

最後に、HazelcastのMapに対してputやgetした時の動作を少し追ってみましょう。

IMapインターフェースの実装である、MapProxySupportやMapProxyImplを追っていくと、〜Operationみたいなクラスを目にすることになります。

たとえば、Map#putするとPutOperation、Map#getするとGetOperationといった具合に、たいていのメソッド呼び出しはOperation(com.hazelcast.spi.Operationインターフェース)に行き着きます。

Hazelcastのデータ構造に対する操作は、このOperationで表現されていると思ってよいでしょう。
※なので、インターフェースの実装クラスがProxyなのかなと思ったり

Mapの場合は抽象実装としてMapOperationというクラスがあり、このサブクラスとしてMapに対する各種Operationが実装してあります。

Mapに対する操作(Operation)は、キーからPartitionIdの計算後にRecordStoreを特定し、該当のRecordStoreからデータの出し入れを行うといったことになります。

Operationの実行は、1度キューに積まれて別スレッドで実行されます。この図は、エントリのPrimary OwnerがLocal Nodeだった場合です。

Operationの実行自体は、OperationRunnerで行われます。

なお、コマンド実行時にバックアップも行われます。その時は、再度Operationが実行されます(PutBackupOperation)。でも、バックアップ先はRemote Nodeです。

どうするかというと、エントリの配置先がRemote Nodeの場合は、Operationごとシリアライズして別Nodeに転送します。

別Nodeに転送されたOperationが、転送先のNodeで実行されるというわけです。Backupもこの理屈ですね。

まとめ

簡単にですが、Hazelcastの内部についての初歩的な(?)内容と、Mapを題材にしたデータの持ち方をご紹介してみました。

だいぶ端折っているところもありますが、このあたりが見えていると挙動が少し追いやすいのではないでしょうか。

今回はネットワークまわりは一切言っていませんが、気が向けばそのうち、そのうち…。