Feb 13, 2017
プログラム中のデータ構造には、構造体、オブジェクトなどがある。それらが、ポインタで結合されリストや木やグラフを構成する。一部はHashMapなどに格納される。
一方でデータベースでは、平たんな表構造を持ち、KEY属性を用いて参照する。
例えばゲーム中のアイテムのリストは、データ構造として容易に表現されるがRDBでは、関係を表すテーブルを用いて表現する必要がある。
このようにプログラム中のデータ構造とデータベースの間にはギャップがある。これをインピータンスピスマッチいうことがある。
データベースには、複数のレコードをアップデートするときに、整合性を維持する仕組みとしてトランザクションがある。
データ構造には並行処理用にスレッドセーフなものが用意されている。
しかし、複数のスレッドセーフなデータ構造は、アクセスするときに整合性を維持するトランザクションを自分で用意する必要があり、標準的なものは用意されていない。
特に複数のリストや木構造に対してのトランザクションが必要になってきている。
これまでに開発されてきたものには、
しかし、不定形の構造の変更をトランザクションとして、どのように処理するかはJsonの一括変更という形で処理されてしまっており、 並列処理が中心となってきている今のアプリケーションには向いていない。
特に木構造自体が大きくなる場合に問題がある。
プログラム中のデータ構造のうち木構造やリスト構造に着目する。
データベースJungleはこれらを直接データベースとして取り扱い、マルチスレッドからの読み書きをトランザクションとして提供する。
Jungleの木は、外部のノードあるいはディスクに複製され、持続性を提供する。
複数の木の間の参照などの複雑な構造はノードのKey属性のIndexを使って実現する。
Jungleでは、データ構造の並行処理が可能なように、データの変更を非破壊的に行う。
木が変更されている間も、その前の版を安全に読み出すことができる。
Jungleはゲームなどのアイテムのリストをそのまま木構造に格納することができ、トランザクションと持続性を提供する。
Jungleは様々な構造のデータ(例えばXML/JSON/LIST)を木構造としてそのまま格納することが可能である。
決まった版の木は変更されないので、いちいち木を検索することなく木構造をそのままプログラムのデータ構造として用いることができる。
以下では、Jungleの構成要素と木へのアクセス方法を説明する。
木は複数のノードの集合でできており、その木の集合によりJungleが構成されている。
ノードは自身の子のリストと属性名と属性値の組でデータを持つ。これはデータベースのレコードに値する。
通常のレコードと異なり、ノードは自身の子供を持つ。
親から子への片方向の参照しか持たない。
データの変更は一度生成した木を上書きせず、ルートから変更を行うノードまでコピーを行い、新しく木構造を構築する。
木の変更に関係ないノードは参照を行い過去の木と共有する。
そして新しい木構造に変更を加える。
最後にルートをアトミックに入れ替えてCommitする。
他のThreadとCommitが競合し失敗した場合は最初からやり直す。
Jungleは木を名前で生成、管理している。
// Jungleに新しく木を生成する。
//木の名前が重複した場合、生成に失敗しnullを返す
JungleTree createNewTree(String treeName)
// JungleからtreeNameと名前が一致するtreeを取得する。
//名前が一致するTreeがない場合取得は失敗しnullを返す
JungleTree getTreeByName(String treeName)
Jungleでは木のノードの位置をNodePathを使って表す。
ルートから対象のノードまでの経路を数字で指し示す。
ルートノードは例外として-1と表記される。
Jungleのノードの編集はJungleTreeEditorを用いて行われる。
JungleTreeEditorには編集を行うためのAPIが実装されている。
また、ノードを指定して編集を行う際には、NodePathクラスを用いる。
木の編集を行った後は、Commitを行い変更をPushする必要がある。
Jungleの木への検索は、木の走査を行うInterfaceTraverserを使用して行う。
検索はQueryインターフェースを用いる。
public interface Query {
public boolean condition(TreeNode node);
}
Queryは、検索の条件を記述する関数conditionを持つ。
conditionは、引数で受け取ったノードが条件に一致するならtrue返す、一致しないならfalseを返す。
InterfaceTraverserはconditionを満たすノードを返すIteratorを返す。
public Iterator<TreeNode> find(Query query, String key, String value);
Jungleの木は全ての属性名に対してIndexを構築している。InterfaceTraverserの関数findに属性値を指定することにより、検索を高速に行うことができる。
findは引数に、Query query、String key、String valueの3つの引数を取り、条件に一致したノードを返すIteratorを返す。
Jungleは過去の版の木を全て保持している。
過去の木に対する検索もサポートしている。
木の版毎にIndexを持っている必要がある。
従来は破壊的赤黒木でIndexを実現しており、木に更新が入るたびに新しいIndexをO(n)で作り直す必要があった。
実装した非破壊赤黒木でIndexを実装することにより、更新をO(log n)で行うことができるようになった。 また、変更されない部分は過去の版と共有されるので、メモリ効率も改良された。
Jungleは木の編集時、ルートから編集を行う位置までのノードの複製を行う。
そのため、木の編集の手間は木構造の形によって異なる。
特に線形の木の場合、全てのノードの複製を行うため変更の手間がO(n)となってしまう。
線形の木をO(1)で変更するPush/Pop操作と差分木操作の実装した。
線形リストのルートの上にルートを付け加える操作(Push)はO(1)である。
逆にルートを取り除き、その子供をルートにする操作(Pop)もO(1)である。
これらにより、線形木を高速に操作することができる。
Pushを連続で行うと、リストは逆順に構築される。
木のノードの追加順の線形リストが必要な場合もある。例えばLogなどである。
差分木は木の最後尾のノードへのポインタを持つ。
最後尾に新しいノードをAtomicに書き込む。
ルートは版ごとに最後尾のノードを保持しており、 木自体は変更されるが、その版の木の長さの範囲では変更されていない。
版ごとの最後尾を越えないようにアクセスすることで、線形木の非破壊性を維持することができる。
差分木は、特別なAPIで作成する必要がある。
JungleTree createNewDifferenceTree(String treeName);
createNewDifferenceTreeは、第一引数で指定した名前の差分木を構築する。
差分木の取得に関しては既存の木と同じように行える。
既存の木、差分木ともにJungleTreeInterfaceを実装しているからである。
差分木へノードの追加を複数回行うと、複数回のトランザクション処理を行う必要がある。
複数の追加をSub Treeに対して破壊的に行って、そのSub Treeを差分木へ追加することにより、トランザクションを一回で済ませることができるようにしている。既存のJungleTreeへのCommitは編集後の木のルートをAtomicに入れ替えることで行う。
しかし差分木は、ルートの入れ替えと、Sub Treeの末尾ノードへのAppendの2つのプロセスからなる。
ルートの入れ替えに関しては、既存のJungle Treeと同じように行う。
Sub Treeの末尾ノードへのAppendは、ルートの入れ替えに成功した場合のみ行う。
そうすることで、別Threadで行われているCommitと競合した際に、ルートを入れ替えたThreadと別ThreadがAppendを行い木の整合性が崩れることを回避している。
Jungleは木の編集時、ルートから編集を行う位置までのノードの複製を行う。
そのため、木の編集の手間は木構造の大きさにも依存している。
バランスの取れた木構造を構築することで、編集の手間をO(log n)にすることは可能ではある。
しかし、ユーザーが木の構造を把握しバランスを取るのは難しい。
そこで、自動で木のバランスを取り、最適な木構造を構築する機能を持つRed Black Jungle Treeを実装した。
Red Black Jungle Treeは、木構造を指定した作成を行うことはできないが、Key属性を用いて任意のノードにアクセスすることができる。
Red Black Jungle Treeは、特別なAPIで作成する必要がある。
JungleTree createNewRedBlackTree(String treeName,String balanceKey);
上記のAPIは、treeNameで指定した名前のRed Black Jungle Treeを構築する。
また、第二引数のbalanceKeyを用いて木のバランスを取る。
Red Black Jungle Treeは、ノードを追加、削除するたびに木のバランスが行われるため、各ノードのPathが変わってしまう。
その為、編集を加える際に、編集対象のノードのPathを調べる必要がある。
その問題を解決するために、ノードを数値ではなく属性名と属性値の組でノードを指定できるようにした。
ノードの指定に使用する属性名は、Red Black Jungle Treeの第二引数で指定したbalanceKeyを使用する必要がある。
グラフのX軸は、データ1件あたりのJSONのサイズ。
グラフのY軸は、検索にかかった時間を表している。
次はJsonのサイズではなく、格納するデータ量を変更する。
1人分のデータサイズは2304biteとする。
グラフのX軸は、データ1件あたりのJSONのサイズ。
グラフのY軸は、検索にかかった時間を表している。
PostgreSQLは、JSONのサイズが大きくなると性能が落ちる。一方、MongoDBとJungleはJSONのサイズが変わっても性能はあまり落ちない。
一方格納するデータ数が増えても、検索にかかる時間はそこまで変わっていない。
PostgreSQLのIndexは、JSONのサイズには対応していなかった。
MongoDBとJungleの性能差の理由としては、通信の有無が原因と考えられる。
MongoDBは通信を介してデータにアクセスしているのに対して、Jungleはプログラムの中にデータを持つため通信を行わずにデータにアクセスできている。
Jungleの性能を向上させるために新たな要素を追加した。
Jungleは既存のDBと比較しても、極めて高速な検索が行えることがわかった。
JungleはRDB と異なり格納するデータの自由度は大きい。
どのようなデータ構造も設計を行わずに格納することが可能である。
十分なパフォーマンスを出すためには、データを最適化する必要がある。
最適な木構造はアプリケーションによって違うため、Jungle の設計手法を確立させる必要がある。
実装した機能の測定を以降に記述する。
比較対象には、 TreeMap 実装前に Jungle で使用していた Functional Java の TreeMap を使用する。
TreeMap に1000回の Get を行った際のグラフである。
X 軸は Get を行う TreeMap のノード数。
Y 軸は Get にかかった時間を表す。
比較対象は、IndexのFullアップデートとする。
測定は木にノードを追加、Commitを1 セットの変更として行う。
X 軸は、木に行った変更のセット数。
Y 軸は、木の構築にかかった時間を表す。
Differential Jungle Treeの性能測定を行う。
比較対象はDefault Jungle Treeを選択した。
また、木の構築時間を測るためにIndexを構築していない。
X軸は構築した木のノード数。
Y軸は構築にかかった時間を表す。
比較対象は、Default Jungle Treeを選択した。
X軸は構築した木のノード数。
Y軸は構築にかかった時間を表す。