mnesiaをelixirから使ってみる


# はじめに

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

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

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

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

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

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

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

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

```.exs
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で定義できますが、ここではベタにタプルで書いていきます。

```.exs
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を止めるとスキーマも消えてしまいます。いやー参ったね。
ということで、とりあえずデータをダンプします。

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

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

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

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

```.exs
iex(12)> :mnesia.stop
:stopped
```

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

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

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

```.exs
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>
```

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

```.exs
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のノードの構築

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

```shell-session
$ 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から接続していきます。

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

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

```.exs
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でアプリケーションを確認してみると

```.exs
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を使うよう設定変更します。

```.exs
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へコピーするように設定します。

```.exs
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を見てみます。

```.exs
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`を使ってもよいのですが)。

```.exs
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`のままです。テーブルもディスクにしましょう。

```.exs
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から確認してみます。

```.exs
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を落として、そこからの復旧の手順を見てみましょう。
典型的には以下の手順になります。

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

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

### ノードaの破壊
 これは障害前提なので、ざっくり止めます。

```.exs
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ではどうなっているか調べてみます。

```.exs
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にレコードを追加しておきましょう。

```.exs
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を起動します

```shell-session
$ iex --sname a
Interactive Elixir (1.5.0-rc.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(a@air13)1>
```
```.exs
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がどうあるべきかが登録されていますので、基本的にはこれだけです。

```.exs
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> 
```

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

```.exs
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`を使います。

```.exs
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]となっているのでこれを増やしてみます。

```.exs
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`に指定することで行います。

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

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

```.exs
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`を考慮して構築しておくと、後からスケールしやすくなりますよ(速くなるとは言っていない)