Erlang
Elixir
Mnesia
ElixirDay 21

mnesiaをelixirから使ってみる

はじめに

本記事はElixir Advent Calendar 2017の21日目です。

mnesiaはErlang/OTPについてくる高速、高可用性が売りのRDBMSです。
今回はこれをElixirで少し便利に使ってみようという話です。Elixir特有というよりmnesia特有の話かつhow to的なものになっています。内容は
- テーブルの作成
- 分散モードへの移行とレプリケーションの構成
- 障害復旧方法
- シャーディング
その他曰く云い難い何かになります。

分散モード

elixir/erlangランタイムOSプロセス(群)はノードと呼ばれ、スタンドアローンと分散の二つのモードがあり、分散モードのノードはさらに、ホスト内で有効なショートネームとホストを超えて有効なロングネームのモードがあります。
モードの切り替えは、

$ iex --sname foo ### ショートネームモード

$ iex --name foo.bar.example ### ロングネームモード

のようにします。というのが、一通りの説明ですが、実行中に変更したいこともあるでしょう。:net_kernel.start/1でできます。

iex(1)> :net_kernel.start([:a, :shortnames])
{:ok, #PID<0.263.0>}
iex(a@air13)2> 

プロンプトにノード名が入るようになりました。もとに戻すには、

iex(a@air13)2> :net_kernel.stop()

でスタンドアローンモードになります。

mnesiaでテーブルを作ってみる

このままスタンドアローンモードでmnesiaテーブルを作り、データを投入してみます。
作るテーブルはこんな感じです。

attribute-name type constraint
key atom primary-key
value any

mnesiaではテーブルの各レコードは、

{テーブル名, プライマリキー, 属性1, 属性2, ...}

というタプルで表現されています。これは、テーブル名をレコード名と解釈すると、erlangのrecordと全く同じ構造です。elixirでも同じ構造をRecord.defrecord/2で定義できますが、ここではベタにタプルで書いていきます。

iex(4)> :mnesia.start()
:ok
iex(5)> :mnesia.create_table(:a, [attributes: [:key, :value]])
{:atomic, :ok}
iex(6)> :mnesia.transaction(fn() -> :mnesia.write({:a, :a, 1}) end)
{:atomic, :ok}
iex(7)> :mnesia.transaction(fn() -> :mnesia.write({:a, :b, 2}) end)
{:atomic, :ok}
iex(8)> :mnesia.transaction(fn() -> :mnesia.write({:a, :c, 3}) end)
{:atomic, :ok}
iex(9)> :ets.i(:a)
<1   > {a,a,1}
<2   > {a,b,2}
<3   > {a,c,3}
EOT  (q)uit (p)Digits (k)ill /Regexp -->q
:ok
iex(10)> 

レプリケーション(マルチノード化)

これから分散モードにして、他のノードとのレプリケーションを張るようにしてみます。
まず残念なことに、スタンドアローンモードのmnesiaをオンラインのまま分散モードにはできません。そして現在のmnesiaモードはスキーマをメモリに持っているので、mnesiaを止めるとスキーマも消えてしまいます。いやー参ったね。
ということで、とりあえずデータをダンプします。

iex(11)> :mnesia.dump_to_textfile('test_data')
:ok

test_dataの中身は通常の「Erlangの項」です(なのでアトムの前には:はつきません)。

$ cat test_data
{tables,[{a,[{record_name,a},{attributes, [key,value]}]}]}.
{a,c,3}.
{a,b,2}.
{a,a,1}.

バックアップが取れたので、mnesiaを止めます。

iex(12)> :mnesia.stop
:stopped

分散モード

まず先ほどの方法で、分散モードにします。

iex(13)> :net_kernel.start([:a, :shortnames])
{:ok, #PID<0.112.0>}
iex(a@air13)14> 

iexのプロンプトが変わり、ノード名を表示しています。さて、mnesiaを起動してバックアップを取り込みます。

iex(a@air13)14> :mnesia.start
:ok
iex(a@air13)15> :mnesia.load_textfile('test_data')
New table a
{:atomic, :ok}
iex(a@air13)16> :ets.i(:a)
<1   > {a,a,1}
<2   > {a,b,2}
<3   > {a,c,3}
iex(a@air13)17>

データ読み込めてますね。ではシステム情報を確認してみます。

iex(a@air13)17> :mnesia.system_info
===> System info in version "4.15.1", debug level = none <===
opt_disc. Directory "/Users/k-1/work/erl/initiald/Mnesia.a@air13" is NOT used.
use fallback at restart = false
running db nodes   = [a@air13]
stopped db nodes   = []
master node tables = []
remote             = []
ram_copies         = [a,schema]
disc_copies        = []
disc_only_copies   = []
[{a@air13,ram_copies}] = [schema,a]
4 transactions committed, 0 aborted, 0 restarted, 0 logged to disc
0 held locks, 0 in queue; 0 local transactions, 0 remote
0 transactions waits for other nodes: []
:yes
iex(a@air13)18> 

これでa@air13ノードにaというテーブルとschemaがram_copiesで登録されていることがわかります。

第2のノードの構築

ここで第2のノードを立ち上げましょう。ここではもう一つのターミナルから、aと同じディレクトリからbというショートネームノードを作ります。こうすることで同一のクッキーを共有できますので実験には都合がいいのです。プロダクションで使う場合は、ショートノードではなく、ロングネームのノードにしましょう。

$ iex --sname b 
Erlang/OTP 20 [erts-9.1] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:10] [hipe] [kernel-poll:false]

Interactive Elixir (1.5.0-rc.1) - press Ctrl+C to exit (type h() ENTER for help) 
iex(b@air13)1> :mnesia.system_info
===> System info in version "4.15.1", debug level = none <===
opt_disc. Directory "/Users/k-1/work/erl/initiald/Mnesia.b@air13" is NOT used.
use fallback at restart = false
running db nodes   = []
stopped db nodes   = [b@air13] 
:no
iex(b@air13)2> 

mnesia動いていませんね。このbに対して、ノードaから接続していきます。

iex(a@air13)18> :net.ping(:"b@air13")
:pong
iex(a@air13)19> 

これでaとbがつながりました。次にaからbのノードのmnesiaをスタートさせます。

iex(a@air13)19> :erlang.spawn(:"b@air13", fn() ->
...(a@air13)19>   :mnesia.stop()
...(a@air13)19>   :mnesia.delete_schema([node()])
...(a@air13)19>   :mnesia.start()
...(a@air13)19> end)
#PID<12971.103.0>
iex(a@air13)20> 

ノードbでアプリケーションを確認してみると

iex(b@air13)2> :mnesia.system_info                  
===> System info in version "4.15.1", debug level = none <===
opt_disc. Directory "/Users/k-1/work/erl/initiald/Mnesia.b@air13" is NOT used.
use fallback at restart = false
running db nodes   = [b@air13]
stopped db nodes   = [] 
master node tables = []
remote             = []
ram_copies         = [schema]
disc_copies        = []
disc_only_copies   = []
[{b@air13,ram_copies}] = [schema]
2 transactions committed, 0 aborted, 0 restarted, 0 logged to disc
0 held locks, 0 in queue; 0 local transactions, 0 remote
0 transactions waits for other nodes: []
:yes
iex(b@air13)3>

ノードbでmnesiaが動いていますが、まだノードaとの関連はありません。また、schemaはディスクではなくram_copiesになっていますが、クラスタ(まだ1個なのでクラスタっていうのかな?)に追加するためには、ram_copiesであることが必要なので、このままにします。ディスクへの変更はクラスタ追加のあとで行えます。

クラスタの構築

ノードaとノードbのデータベースをを関連づけるためにノードaからmnesiaの外部dbとしてbを使うよう設定変更します。

iex(a@air13)20> :mnesia.change_config(:extra_db_nodes, [:"b@air13"])
{:ok, [:b@air13]}
iex(a@air13)22> :mnesia.system_info
===> System info in version "4.15.1", debug level = none <===
opt_disc. Directory "/Users/k-1/work/erl/initiald/Mnesia.a@air13" is NOT used.
use fallback at restart = false
running db nodes   = [b@air13,a@air13]
stopped db nodes   = []
master node tables = []
remote             = []
ram_copies         = [a,schema]
disc_copies        = []
disc_only_copies   = []
[{a@air13,ram_copies}] = [a]
[{a@air13,ram_copies},{b@air13,ram_copies}] = [schema]
7 transactions committed, 0 aborted, 0 restarted, 0 logged to disc
0 held locks, 0 in queue; 0 local transactions, 0 remote
0 transactions waits for other nodes: []
:yes

これで、aとbが連携して動作するようになりました。schemaがaとbでram_copiesになっていることと、テーブルaがノードaのみにram_copiesになっていることが読みとれます。

これからノードaのschema以外のテーブルをbへコピーするように設定します。

iex(a@air13)23> Enum.all?(:mnesia.system_info(:local_tables),
...(a@air13)23>   fn(:schema) -> true
...(a@air13)23>     (x) -> {:atomic, :ok} = :mnesia.add_table_copy(x, :"b@air13", :ram_copies)
...(a@air13)23>   end)
true
iex(a@air13)24> 

ノードbのターミナルからテーブルbを見てみます。

iex(b@air13)5> :ets.i(:a)
<1   > {a,a,1}
<2   > {a,b,2}
<3   > {a,c,3}
EOT  (q)uit (p)Digits (k)ill /Regexp -->q
:ok
iex(b@air13)6> 

テーブルaの内容が追加されていますね。これで2ノードの冗長化が図れました。

ディスクの利用への設定変更

今は、どちらもram_copiesなので、両方ともノードが死ぬとデータが失われてしまいます。ノードbだけでもdisc_copiesにしておきましょう。

disc_copiesにするためには、:mnesia.change_table_copy_type/3でスキーマのコピータイプを:disc_copiesにする必要があります(スタンドアローンの場合は、データベースを止めた状態で:mnesia.create_schema/1を使ってもよいのですが)。

iex(a@air13)24> :mnesia.change_table_copy_type(:schema, :"b@air13", :disc_copies)
{:atomic, :ok}
iex(a@air13)25> :mnesia.system_info
===> System info in version "4.15.1", debug level = none <===
opt_disc. Directory "/Users/k-1/work/erl/initiald/Mnesia.a@air13" is NOT used.
use fallback at restart = false
running db nodes   = [b@air13,a@air13]
stopped db nodes   = []
master node tables = []
remote             = []
ram_copies         = [a,schema]
disc_copies        = []
disc_only_copies   = []
[{a@air13,ram_copies},{b@air13,disc_copies}] = [schema]
[{a@air13,ram_copies},{b@air13,ram_copies}] = [a]
11 transactions committed, 1 aborted, 0 restarted, 0 logged to disc
0 held locks, 0 in queue; 0 local transactions, 0 remote
0 transactions waits for other nodes: []
:yes

これでノードbのスキーマはディスクに書き込まれるようになりましたが、テーブルaはまだram_copiesのままです。テーブルもディスクにしましょう。

iex(a@air13)26> Enum.all?(:mnesia.system_info(:local_tables),
...(a@air13)26>   fn(:schema) -> true
...(a@air13)26>     (x) ->
...(a@air13)26>     {:atomic, :ok} = :mnesia.change_table_copy_type(x, :"b@air13", :disc_copies)
...(a@air13)26> end)
true
iex(a@air13)27> :mnesia.system_info
===> System info in version "4.15.1", debug level = none <===
opt_disc. Directory "/Users/k-1/work/erl/initiald/Mnesia.a@air13" is NOT used.
use fallback at restart = false
running db nodes   = [b@air13,a@air13]
stopped db nodes   = []
master node tables = []
remote             = []
ram_copies         = [a,schema]
disc_copies        = []
disc_only_copies   = []
[{a@air13,ram_copies},{b@air13,disc_copies}] = [schema,a]
12 transactions committed, 1 aborted, 0 restarted, 0 logged to disc
0 held locks, 0 in queue; 0 local transactions, 0 remote
0 transactions waits for other nodes: []
:yes

これでノードbはディスクにコピーされたのでもうノードaが死んでも大丈夫になったはずです。一応、ノードbから確認してみます。

iex(b@air13)8> :mnesia.system_info
===> System info in version "4.15.1", debug level = none <===
opt_disc. Directory "/Users/k-1/work/erl/initiald/Mnesia.b@air13" is used.
use fallback at restart = false
running db nodes   = [a@air13,b@air13]
stopped db nodes   = [] 
master node tables = []
remote             = []
ram_copies         = []
disc_copies        = [a,schema]
disc_only_copies   = []
[{a@air13,ram_copies},{b@air13,disc_copies}] = [schema,a]
2 transactions committed, 0 aborted, 0 restarted, 4 logged to disc
0 held locks, 0 in queue; 0 local transactions, 0 remote
0 transactions waits for other nodes: []
:yes
iex(b@air13)9> 

きちんと Mneia.b@air13というディレクトリに格納されていることがわかります。

障害とそこからの復旧

ではいよいよ、ノードaを落として、そこからの復旧の手順を見てみましょう。
典型的には以下の手順になります。

  1. ノードaの破壊
  2. 新しいノードaの構築
  3. ノードaのクラスタ(ノードb)への登録

では、具体的な手順を見てみます。

ノードaの破壊

これは障害前提なので、ざっくり止めます。

iex(a@air13)28> ^C
BREAK: (a)bort (c)ontinue (p)roc info (i)nfo (l)oaded
       (v)ersion (k)ill (D)b-tables (d)istribution
a
bash-3.2$ 

ノードbのiexではどうなっているか調べてみます。

iex(b@air13)9> :mnesia.system_info
===> System info in version "4.15.1", debug level = none <===
opt_disc. Directory "/Users/k-1/work/erl/initiald/Mnesia.b@air13" is used.
use fallback at restart = false
running db nodes   = [b@air13]
stopped db nodes   = [a@air13] 
master node tables = []
remote             = []
ram_copies         = []
disc_copies        = [a,schema]
disc_only_copies   = []
[{b@air13,disc_copies}] = [schema,a]
2 transactions committed, 0 aborted, 0 restarted, 4 logged to disc
0 held locks, 0 in queue; 0 local transactions, 0 remote
0 transactions waits for other nodes: []
:yes
iex(b@air13)10> 

ノードaのDBが止まったことが認識されています。ここでついでながらノードbにレコードを追加しておきましょう。

iex(b@air13)10> :mnesia.transaction(fn() -> :mnesia.write({:a, :d, 4}) end)
{:atomic, :ok}
iex(b@air13)11> :ets.i(:a)
<1   > {a,a,1}
<2   > {a,b,2}
<3   > {a,c,3}
<4   > {a,d,4}
EOT  (q)uit (p)Digits (k)ill /Regexp -->q
:ok
iex(b@air13)12>

{a, d, 4}が追加されていますね。これで障害発生状況ができました。これから復旧作業になります。

ノードaの構築

ノードbにはノードaが登録されていて、停止したままです。
新しいノードaを起動します

$ iex --sname a
Interactive Elixir (1.5.0-rc.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(a@air13)1>
iex(a@air13)1> :net.ping(:"b@air13")
:pong
iex(a@air13)2> :mnesia.start
:ok
iex(a@air13)3> :mnesia.system_info
===> System info in version "4.15.1", debug level = none <===
opt_disc. Directory "/Users/k-1/work/erl/initiald/Mnesia.a@air13" is NOT used.
use fallback at restart = false
running db nodes   = [a@air13]
stopped db nodes   = []
master node tables = []
remote             = []
ram_copies         = [schema]
disc_copies        = []
disc_only_copies   = []
[{a@air13,ram_copies}] = [schema]
2 transactions committed, 0 aborted, 0 restarted, 0 logged to disc
0 held locks, 0 in queue; 0 local transactions, 0 remote
0 transactions waits for other nodes: []
:yes

新しいノードaが構築できましたが、ノードbとの関連はありません。

ノードaのクラスタへの組み込み

次に、外部DBとしてノードbを登録します。ノードb側にはノードaがどうあるべきかが登録されていますので、基本的にはこれだけです。

iex(a@air13)4> :mnesia.change_config(:extra_db_nodes, [:"b@air13"])
{:ok, [:b@air13]}
iex(a@air13)5> :mnesia.system_info
===> System info in version "4.15.1", debug level = none <===
opt_disc. Directory "/Users/k-1/work/erl/initiald/Mnesia.a@air13" is NOT used.
use fallback at restart = false
running db nodes   = [b@air13,a@air13]
stopped db nodes   = []
master node tables = []
remote             = []
ram_copies         = [a,schema]
disc_copies        = []
disc_only_copies   = []
[{a@air13,ram_copies},{b@air13,disc_copies}] = [a,schema]
5 transactions committed, 0 aborted, 0 restarted, 0 logged to disc
0 held locks, 0 in queue; 0 local transactions, 0 remote
0 transactions waits for other nodes: []
:yes
iex(a@air13)6> 

もう復旧完了です。データを見てみましょう。

iex(a@air13)7> :ets.i(:a)
<1   > {a,a,1}
<2   > {a,b,2}
<3   > {a,c,3}
<4   > {a,d,4}
EOT  (q)uit (p)Digits (k)ill /Regexp -->q
:ok
iex(a@air13)8> 

たしかに復旧できてますね。楽チンです。
注意すべきポイントは、復旧させる時ノードaのスキーマを:mnesia.create_schema/1で作成してはいけない、ということです。主に実験時にハマるのが、スキーマディレクトリが別の用途で作成したものが残っていて、mnesiaがそのディレクトリを参照してしまっている場合です。この場合、クラスタに参加するための :mnesia.change_config/2が失敗する可能性があります。この場合は、スキーマディレクトリを削除しましょう。

schemaをdisc_copiesにするメリット

上記ではschemaをram_copiesにしていましたが、disc_copiesにすると、メンテナンスで計画的に停止する場合など、障害復旧時よりも簡単にメンテナンスができます。

具体的には、単に:mnesia.start/0でスキーマ情報からクラスタの再構築をしてくれます。したがって、フロントに置くノードもschemaだけはdisc_copiesにすると楽です。

mnesiaのクラスタ構成について

私見ですが、最近はメモリが大量にある計算機が多いので、フロントのテーブルはram_copiesで良いのではと思います。直接サービスを行わないどれか一台のバックエンドを保険でdisc_copiesにして置けば十分でしょう。ただ、さすがにdisc_copiesなノードがあまりに多いと、書き込みが遅くなる恐れがあります。

frag_hash構成について

:mnesia_fragモジュールを使うと、単一テーブルを複数のノードやサブテーブルに分割して管理することができます。detsやetsの2GB制限回避のための機能なのかもしれませんが、これらを使うことでマルチノードのDBがさらに使いやすくなりました。MySQLとかではshardingと呼ばれているものに相当します。:mnesia_fragはその実装で:mnesia_frag_hashモジュールを使っているのですが、自分で作ることもできます。
さて、:mnesia_fragモジュールを使うためには、まず :mnesia.change_table_frag/2を使います。

iex(a@air13)6> :mnesia.change_table_frag(:a, {:activate, []})
{:atomic, :ok}
iex(a@air13)7> :mnesia.table_info(:a, :frag_properties)
[base_table: :a, foreign_key: :undefined, hash_module: :mnesia_frag_hash,       
 hash_state: {:hash_state, 1, 1, 0, :phash2}, n_fragments: 1,                   
 node_pool: [:b@air13, :a@air13]]
iex(a@air13)8> 

:frag_propertiesが増えていますね。ただ、[n_fragments: 1]となっているのでこれを増やしてみます。

iex(a@air13)10> m = :mnesia.activity(:sync_dirty, &(:mnesia.table_info(:a, &1)), [:frag_dist], :mnesia_frag)
[b@air13: 1, a@air13: 1]
iex(a@air13)11> :mnesia.change_table_frag(:a, {:add_frag, m})
{:atomic, :ok}
iex(a@air13)12> :mnesia.change_table_frag(:a, {:add_frag, m})
{:atomic, :ok}
iex(a@air13)13> :mnesia.change_table_frag(:a, {:add_frag, m})
{:atomic, :ok}
iex(a@air13)14> :mnesia.change_table_frag(:a, {:add_frag, m})
{:atomic, :ok}
iex(a@air13)15> :mnesia.table_info(:a, :frag_properties)
[base_table: :a, foreign_key: :undefined, hash_module: :mnesia_frag_hash,       
 hash_state: {:hash_state, 5, 2, 2, :phash2}, n_fragments: 5,                   
 node_pool: [:b@air13, :a@air13]]

これでフラグメントが5個で、5分割されました。
読み書きは、:mnesia.activity/4を使ってアクセスモジュールを:mnesia_fragに指定することで行います。

iex(a@air13)16> :mnesia.activity(:transaction, &(:mnesia.read({:a, &1})), [:a], :mnesia_frag)
[{:a, :a, 1}]
iex(a@air13)27> 

テーブルの状況を確認すると、a_fragN(Nは数字)というテーブルができていることがわかります。

iex(a@air13)28> :mnesia.system_info
===> System info in version "4.15.1", debug level = none <===
opt_disc. Directory "/Users/k-1/work/erl/initiald/Mnesia.a@air13" is used.
use fallback at restart = false
running db nodes   = [b@air13,a@air13]
stopped db nodes   = []
master node tables = []
remote             = []
ram_copies         = [a]
disc_copies        =  [a_frag2,a_frag3,a_frag4,a_frag5,schema]
disc_only_copies   = []
[{a@air13,disc_copies},{b@air13,disc_copies}] = [schema]
[{a@air13,disc_copies},{b@air13,ram_copies}] = [a_frag2,a_frag3,a_frag4,
                                                a_frag5]
[{a@air13,ram_copies},{b@air13,disc_copies}] = [a]
20 transactions committed, 0 aborted, 0 restarted, 17 logged to disc
0 held locks, 0 in queue; 0 local transactions, 0 remote
0 transactions waits for other nodes: []
:yes

なんか、ディスクつかったりメモリ使ったりがバラバラですが、これはあとで揃えればいいでしょう。

:mnesia_fragアクセスモジュールを指定して:mnesia.activity/4を呼び出すことで適切なフラグメントテーブルを選択して読み書きしてくれるわけです。
フラグメントの数を増やしたり減らしたりといった操作は運用中に可能なので、初めから:mnesia_fragを使って:mnesia.activity/4でコードを書いていた方が絶対得です。

まとめ

  • 分散elixir(erlang)簡単ですよ
  • :mnesiaでの高可用性DBは簡単ですよ
  • :mnesia_fragを考慮して構築しておくと、後からスケールしやすくなりますよ(速くなるとは言っていない)