はじめに

 Mnesiaは、Erlangという言語をベースとした並列プログラミング用のオープンソース開発環境であるErlang OTPに付属している高機能のデータベース管理システム(DBMS)です。Mnesiaは真の分散DBMSなので、世界中の何千ものノード間でデータを分散し、複製し、断片化することも朝飯前です。ユーザーがしなければならないのは、Mnesiaデータベースの分散先となるさまざまなErlangノードを実行することだけです。

 Mnesiaという名前になった経緯は、少々眉唾ではありますが、もともとは「Amnesia(健忘症)」という名前だったものを、エリクソンの重役が「データベースに『物忘れ』を連想させる名前を付けるのはいかがなものか」と発言したことから、エンジニアが「A」の文字を取って「Mnesia」とし、「すべてを記憶するもの」の意味を持たせたと言われています。Mnesiaは、Erlangで実装されたあらゆるプログラムにフォールトトレランス機能を提供します。また、ユーザーは直接Erlangを使ってMnesiaデータベースとやり取りできます。Mnesiaでは、Erlangがデータベース言語になるのです。

 この記事では、Mnesiaの重要な機能をいくつか紹介します。この記事のダウンロード可能なサンプル内のtest.erlファイルには、数行のコードをまとめた簡単な関数がいくつか記述されています。これは、主としてErlangシェルで使用します。

 やむを得ない場合を除き、Erlang構文のすべての側面や基本概念について説明することはしません。そのような情報は、オンラインマニュアルで簡単に見つけることができます。アトム、変数、タプル、リストには特に注意してください。私のDevXの記事「Writing Parallel Programs with Erlang」にも、役立つ背景情報が書かれています。この記事では、Erlangの並列処理に関する側面について説明しています。

ノード:Erlangの心臓部

 ノードという概念は、Erlangの心臓部です。Erlangシェルを起動するときに、名前とcookieと呼ばれる秘密のワードを指定すれば、そのErlangシェルをノードにできます。いくつかの例で、Erlangの動作を見ていきましょう。私のLAN上には3台のDebian Linuxマシン(charlie、delta、nemo)が接続されています。これらのマシンには、以下のコマンドを実行してErlangをインストールしてあります。

apt-get install erlang
 今回の例では、意味のない文字列(AXHMTYGVJDNJIFGYADNJ)を秘密のワードとして選択し、ノード名はfirst、second、thirdとします。ここで、マシンcharlieとdeltaで以下の2つのコマンドを実行しました。

erl -sname first -setcookie AXHMTYGVJDNJIFGYADNJ
 
erl -sname second -setcookie AXHMTYGVJDNJIFGYADNJ
 これで、2つのErlangノードfirst@charlieとsecond@deltaを接続できるようになりました。次の例では、charlieからpingを1回実行して、deltaへの接続を確認しています。

(first@charlie)1> net_adm:ping(second@delta).
pong
 次の例では、どんなノードが接続されているかを問い合わせて、接続をテストしています。

(first@charlie)2> nodes().
[second@delta]
 このように、既に2つの協調Erlangノードが簡単に通信できる状態になっています。

Mnesiaスキーマ

 サンプルのtest.erlファイルには、test:start_schema(NodesList)test:start_tablesという2つの関数が記述されています。これらは1回だけ実行する必要があります。これらの関数によって、Mnesiaスキーマを作成し、テーブルを定義し、定義したテーブルに初期データを設定します。

 最初にしなければならない操作はMnesiaスキーマの作成です。この操作は、接続されているノード上でMnesiaエンジンを起動する前に行う必要があります。Mnesiaスキーマとは、ファイルシステム上の特殊なテーブルとローカルディレクトリです。このディレクトリはファイルを格納することができ、ノードごとに一意でなければなりません。デフォルトでは、Mnesiaスキーマは各データベースにつき1回、現在のディレクトリの下に「Mnesia.node_name@host」という名前で作成されます。ただし、erlコマンドのオプション-mnesia dir [database_dir]を使用して、デフォルトを無効にできます。

 関数mnesia:create_schema(NodesList)は、既に接続されているErlangノードのリストを引数として取ります。接続されているノードのいずれかから以下を実行すると、Mnesiaのdirはローカルノードにのみ作成されます。

mnesia:create_schema([local_node_name@localhost]).
 しかし、以下を実行すると、

mnesia:create_schema([local_node_name@localhost,
                   remote_node_name1@host1,
                      remote_node_name2@host2]).
 引数リストに指定したすべてのノードにMnesiaスキーマのコピーが作成され、システムによってすべてのコピーの同期が保たれます。これは、冗長性とフェイルオーバーの問題に関して重要です。

 これを実践するため、3つのノードにtest.erlファイルを配布し、以下のコマンドでコンパイルします。

c(test.erl).
 first@charlieノードとsecond@deltaノードの両方にスキーマを作成するため、2つのノードのいずれかから以下を実行します。

mnesia:create_schema([first@charlie,second@delta]).
 次に、もう一方のノードでmnesia:start()を実行します。

Mnesiaテーブル

 データベーステーブルの定義も1回の操作だけで行います。この操作はスキーマのコピーがあるノードのいずれかで実行します。mnesia:create_table関数を使用して、テーブルの構造を設計したり、重要なテーブル属性を多数設定できます。次の例では、table1およびtable2を作成し、データを設定しています。

(first@charlie)3> test:start_tables().

=INFO REPORT==== 4-Jan-2010::16:22:27 ===
    application: mnesia
    exited: stopped
    type: temporary
stopped
 second@deltaでもtest.erlファイルをコンパイルしていれば、deltaでも上記の関数を実行できます。以下のコードを参照して、前述の関数mnesia:create_tableがどのように使用されるかを確認してください。

mnesia:create_table(table1,
[
    {type, set},
    {attributes, record_info(fields, table1)},
    {disc_copies, [first@charlie, second@delta]}  
])
 ご覧のように、この関数は、テーブルの特性を決定するタプル{key, value}のリストを引数として取ります。この場合は、table1という名前の「set」テーブルを作成します。このテーブルの列は、ファイルの最初の方にあるrecord命令で以下のように定義されています。

-record(table1, {table1_id, name, color, number}).
 disc_copiesキーに注目してください。このキーは、テーブルのハードディスクコピーを作成する場所を指定しています。これは、Mnesiaデータベースの冗長性をテーブルレベルで調整できるということを意味します。この場合は、table1のコピーをノードfirst@charlieとsecond@deltaのハードディスク上に作成します。次のセクションでは、この関数がどのように動作するかを説明します。

Mnesiaノードをテストする

 second@deltaノードで以下を実行します。

(second@delta)5> test:select().
[{table1,1,"record1","brown",1724},
 {table1,2,"record2","orange",2367},
 {table1,3,"record3","red",7834}]
 次に、シェルから抜けて、second@deltaノードをシャットダウンします。first@charlieノードで、table1に新規レコードを挿入します。

(first@charlie)6> test:insert().
ok
(first@charlie)7> test:select().
[{table1,1,"record1","brown",1724},
 {table1,2,"record2","orange",2367},
 {table1,3,"record3","red",7834},
 {table1,4,"record4","orange",8888}]
 今度は、second@deltaノードに再度電源を投入し、mnesia:start()でMnesiaエンジンを起動します。次に、table1のレコードを抽出します。

(second@delta)2> test:select().
[{table1,1,"record1","brown",1724},
 {table1,2,"record2","orange",2367},
 {table1,3,"record3","red",7834},
 {table1,4,"record4","orange",8888}]
 second@deltaがダウンしていたときにfirst@charlieノードに挿入されたレコードが、table1のコピーにも挿入されています。これは、second@deltaのスキーマ自体が、ブート中にfirst@charlieのスキーマと透過的に同期されたことを意味します。

 ram_copiesキーまたはdisc_only_copiesキーがテーブルに対して持つ意味は推測できるでしょう。テーブルのRAMコピーをノード上に保持することは、パフォーマンス面で優れたやり方です。しかし、この方法では当然、データを永続的に保持することはできません。ここで理解すべき重要なポイントは、Mnesiaの独創的なテーブルミラーリング機能です。

Mnesiaノードを追加する

 私のLAN上にある第3のマシンはnemoです。以下のコマンドでErlangノード「third@nemo」を起動してから、そのノード上でMnesiaを起動します。

erl -sname third -setcookie AXHMTYGVJDNJIFGYADNJ
 このシステムは非常に柔軟なので、他の2つのノードのいずれかから、以下のコマンドでノードを追加することができます。

(first@charlie)15> mnesia:change_config(extra_db_nodes,[third@nemo]).
 前述のとおり、Mnesiaスキーマは特殊なテーブルですが、突き詰めていけば他のテーブルと変わりはありません。従って、上記のコマンドを実行すると、スキーマテーブルのRAMコピーがthird@nemoに追加されます。この操作を行った後で、first@charlieおよびsecond@deltaのリモートテーブルtable1およびtable2にthird@nemoからアクセスできます。実際に、nemo上のMnesiaシステムに関する情報を問い合わせると、次のような結果が表示されます。

(third@nemo)4> mnesia:system_info().
...
running db nodes   = [first@charlie,second@delta,third@nemo]
...
remote             = [table1]
ram_copies         = [schema]
...

テーブルコピーを追加する

 もちろん、以下のコマンドを使用して、third@nemoのスキーマテーブルの性質をその場で変更することもできます。

(first@charlie)16> mnesia:add_table_copy(schema, third@nemo, disc_copies).
 スキーマの新しいコピーの追加とその性質の変更は動的な操作であり、Mnesiaスキーマのすべてのテーブルに適用できます。

 ここまでの説明でお分かりのとおり、ノードを起動し、ノード上にMnesiaエンジンを作成し、サービスをまったく停止せず完全に透過的にノードを追加することがごく簡単にできます。パフォーマンスを向上させ、高度な冗長性を実現するために、各ノードはスキーマとその他のテーブルの複製をそれぞれ独自に持つことができます。

トランザクション

 Mnesiaが真の分散DBMSである(スキーマのいずれかのコピーに対して操作を実行すると、他の複製とフラグメントすべてに自動的に伝播される)ことは明らかですが、トランザクションデータベースとも言えるのでしょうか? 一連の操作をトランザクション、すなわち原子性、首尾一貫性、独立性、耐久性(ACID)という特質を持つ作業単位にまとめ、その作業単位でMnesiaデータベースに対して操作を実行することはできるのでしょうか? 答えはイエスです。このセクションの残りの部分では、トランザクションを実行する方法について説明します。

 データベースに対して「すべてか無か」の操作を実行しなければならない場合を考えてみましょう。例えば、以下の関数atomic_opに示すように、既存のレコード(#1)を削除して、新規レコード(#5)を挿入したいとします。

atomic_op() ->
    Row = #table1{table1_id=5, name="record5", color="black", number=4598},
    mnesia:delete({table1,1}),
    mnesia:write(Row).
 ただし、レコード#5を挿入できなかった場合は、レコード#1を削除したくありません。さらに、私が削除と挿入を行っているあいだ、他の誰かがレコード#1に対して何らかの操作を実行しないようにしたいと思います。つまり、関係するすべてのノード上で、2つの操作の原子性と独立性を維持したいということです。このために私が行わなければならないのは、以下に示すように、先ほど作成したatomic_op関数をmnesia:transaction関数に引き渡すことだけです。

op() ->
    F = fun atomic_op/0,
    {atomic, Val} = mnesia:transaction(F),
    Val.
 この例では、Erlangのメカニズムによって、関数atomic_opを変数Fにバインドし、それを引数としてmnesia:transactionに渡しています。これで、削除と挿入という2つの操作の原子性と独立性だけでなく、首尾一貫性と耐久性も保証されます。

 mnesia:transaction関数は、ロックの設定と解放など、トランザクションに影響を与える並列処理の問題をすべて管理します。ユーザーがこの種の問題を管理する必要はありません。Mnesiaは、ロックの取得に成功しなかったトランザクションを保留し、既に取得していたロックをすべて解放させることによって、デッドロックも回避します。画面上への出力(io:format)など副次的な悪影響をもたらすコードがトランザクションに含まれている場合は、トランザクションが成功するまで多数のメッセージが標準出力に繰り返し表示されることがあります。

ダーティな操作

 より高速にデータにアクセスしなければならない場合のために、Mnesiaには、トランザクションのオーバーヘッドなしにテーブルを操作する「ダーティな」関数が用意されています。この関数を使用するとパフォーマンスが高まり、Mnesiaは一種のリアルタイムDBMSになります。ただし、トランザクションの原子性と独立性は著しく損なわれます。

 サンプルのtest.erlファイルには、以下の関数が記述されています。

  • op:レコード#1を削除し、レコード#5を挿入するトランザクション関数
  • reverse_op:レコード#5を削除し、レコード#1を挿入するトランザクション関数
  • mop:opとreverse_opを指定の回数実行する
  • dirty_opdirty_reverse_opdirty_mop:上記の関数のダーティバージョン
 まず、Counter = 10000にしてtest:mop(Counter)を実行し、この関数をfirst@charlieノードとsecond@deltaノードで十分な時間動作させます。この関数は両ノードで実行され、一貫性の問題を生じることなく終了しました。同時にthird@nemoでtest:select()を実行すると、3レコードから成るリストが常に返されます。これは、opreverse_opも1回でmnesia:writemnesia:deleteを適用するからです。

 同じtestのダーティバージョンであるtest:dirty_mop(Counter)を両ノード上で同時に実行すると、多数のエラーメッセージが発生します。また、third@nemoでtest:select()を実行すると、2レコードまたは4レコードから成るリストが返される場合もあります。

クエリーリスト内包表記

 Mnesiaにはテーブル内のデータとやり取りするSQL風の言語はありませんが、ErlangのQuery List Comprehensionモジュール(qlc)を使用することで、Erlangをデータベース言語として使用し、データベースに問い合わせることができます。qlcモジュールは、リスト内包表記という強力なErlang構文を利用します。

 数学的集合を定義したい場合は、集合ビルダーの表記法を用います。これは、集合の要素の特性を記述することによって、集合の要素を限定するものです。今度は、この表記法とErlangのリスト内包表記構文がどれくらい似ているかを見てみましょう。

 Aは、10より小さく、かつ2乗しても自身と同じ数になる自然数(N)の集合を表しています。これを数式で表すと次のようになります。

 Erlangでは、lists:seq(0,9)という関数呼び出しで10未満のすべての整数のリストを返すことができるので、以下の構文を使用して上述の要素のリストを取得できます。

1> A = [X || X <- lists:seq(0,9), X == X * X].
[0,1]
 2つの式の類似性は顕著です。リスト内包表記は、リストからリストを作成するツールと考えることもできます。以下に一般的な構文を示します。Expressionは、Qualifier1などの修飾子によって生成およびフィルタされた要素に対して行う一連の演算です。

[Expression || Qualifier1, Qualifier2, ...]
 Mnesiaデータベースに問い合わせるためのインターフェースをqlcがどのように提供しているか理解できたでしょう。ただし、このモジュールを使用する前に、システムのどこにファイル「qlc.hrl」があるかを探し、コードに以下の行を含める必要があります。

-include_lib("/path_to/qlc.hrl" ).
 私のtest.erlでは、関数test:select()はtable1の全レコードからなるリストを返します。以下の行に注意してみると、

Handle = qlc:q([X || X <- mnesia:table(table1)])
 関数qlc:qに渡している引数がリスト内包表記であることに気付くでしょう。このリスト内包表記では、テーブルの内容を返す関数mnesia:tableをジェネレータとしています。関数qlc:qが返したクエリーハンドルは関数qlc:eで評価され、この関数によってすべてのテーブルデータが収集され、リストとして返されます。

QueryList = qlc:e(Handle).
 先に説明した理由から、これらすべての関数がmnesia:transactionの内部で動作することに注意してください。関数test:join()には、テーブルtable1とtable2を結合する簡単な例が含まれています。この部分のクエリーリスト内包表記は次のようになります。

Handle = qlc:q([X#table1.number || X <- mnesia:table(table1),
                                   Y <- mnesia:table(table2),
                                   X#table1.number > 2000,
                                   X#table1.table1_id =:= Y#table2.table2_id
])
 実際のところ、レコードとは、命令(先頭文字がマイナス記号の記述)で宣言された名前付きフィールドから成るタプルです。レコード内のフィールドにアクセスするには、ドット構文を使用します。例えば、X#table1.numberは、指定されたフィールドnumberの値を返します。Xはtable1のレコードとして評価されます。

Mnesiaを探求する

 Mnesiaの重要な機能をいくつか紹介してきましたが、もちろんこれですべてではありません。これまで学習してきたことが読者の好奇心を呼び起こし、Mnesiaについてもっと知りたいと思っていただければ幸いです。Mnesiaの可能性を最大限に引き出すため、ぜひ他の機能もより深く知ってもらいたいと思います。

参考資料

  • Erlangオンラインマニュアル
  • MNESIAデータベース管理システム(プレゼンテーション)

著者紹介

Roberto Giorgetti(Roberto Giorgetti)
イタリアに拠点を置くITマネージャ、テクニカルライター。主にビジネス分野と工業分野でオープンソースの開発に従事。核工学の学位を持つ。