2014年4月25日

erlang Mnesia

Mnesia 是 erlang 內建的 DBMS,可儲存任何 erlang 資料結構。也可設定組態,儲存在記憶體或是磁碟中,Table 可被複製到其他機器上,以提供容錯機制。

原本 Mnesia 的名字是 Amnesia 失憶症,但被 Joe 的老闆否決了,因為 DB 不能失憶,所以就去掉了 A ,變成 Mnesia。

DB查詢

Mnesia 查詢就類似 SQL 與 list comprehension,他們都是用 set theory 設計的。

建立測試資料庫,有三個 table: shop, cost, design,分別以 record 定義 schema

-module(test_mnesia).
-import(lists, [foreach/2]).
-compile(export_all).

%% IMPORTANT: The next line must be included
%%            if we want to call qlc:q(...)
%% 使用 qlc 必須要引用 qlc.hrl
-include_lib("stdlib/include/qlc.hrl").

-record(shop, {item, quantity, cost}).
-record(cost, {name, price}).

% 建立測試資料庫
do_this_once() ->
    % 在 node() 本機節點,進行資料庫初始化
    mnesia:create_schema([node()]),
    mnesia:start(),
    mnesia:create_table(shop,   [{attributes, record_info(fields, shop)}]),
    mnesia:create_table(cost,   [{attributes, record_info(fields, cost)}]),
    mnesia:create_table(design, [{attributes, record_info(fields, design)}]),
    mnesia:stop().

測試

1> test_mnesia:do_this_once().
stopped

=INFO REPORT==== 19-Feb-2014::10:38:33 ===
    application: mnesia
    exited: stopped
    type: temporary

資料庫初始化

在這個節點進行資料庫初始化,只需要做一次

1>mnesia:create_schema([node()]).
ok
2>init:stop().
ok

這會建立一個名稱為 Mnesia.nonode@nohost 的目錄,用來儲存資料庫。

如果填入多個 nodes,呼叫 mnesia:create_schema(NodeList),就會在所有節點進行初始化。

另外也可以在啟動 erlang 時,直接設定資料庫的目錄

>erl -mnesia dir '"d:/temp/db"'
Eshell V5.10.4  (abort with ^G)
1> mnesia:create_schema([node()]).
ok
2> init:stop().
ok
3>

取得 table 內所有資料

% 啟動資料庫
start() ->
    mnesia:start(),
    mnesia:wait_for_tables([shop,cost,design], 20000).

% 重設 DB,並填入測試資料
reset_tables() ->
    % clear 可將 table 清空
    mnesia:clear_table(shop),
    mnesia:clear_table(cost),
    % 以 tuple 將資料寫入 table
    F = fun() ->
                foreach(fun mnesia:write/1, example_tables())
        end,
    % 將整個 F 包裝在一個 transaction 中
    mnesia:transaction(F).

example_tables() ->
    % tuple 的第一個元素是 table name,後面的資料要依照 record 定義的順序
    [%% The shop table
     {shop, apple,   20,   2.3},
     {shop, orange,  100,  3.8},
     {shop, pear,    200,  3.6},
     {shop, banana,  420,  4.5},
     {shop, potato,  2456, 1.2},
     %% The cost table
     {cost, apple,   1.5},
     {cost, orange,  2.4},
     {cost, pear,    2.2},
     {cost, banana,  1.5},
     {cost, potato,  0.6}
    ].

%% SQL equivalent
%%  SELECT * FROM shop;
demo(select_shop) ->
    do(qlc:q([X || X <- mnesia:table(shop)]));

%% SQL equivalent
%%  SELECT item, quantity FROM shop;
demo(select_some) ->
    do(qlc:q([{X#shop.item, X#shop.quantity} || X <- mnesia:table(shop)]));

%% ===========
%% SQL equivalent
%%   SELECT shop.item FROM shop
%%   WHERE  shop.quantity < 250;

demo(reorder) ->
    do(qlc:q([X#shop.item || X <- mnesia:table(shop),
                             X#shop.quantity < 250
             ]));

%% SQL equivalent
%%   SELECT shop.item
%%   FROM shop, cost 
%%   WHERE shop.item = cost.name 
%%     AND cost.price < 2
%%     AND shop.quantity < 250

demo(join) ->
    do(qlc:q([X#shop.item || X <- mnesia:table(shop),
                             X#shop.quantity < 250,
                             Y <- mnesia:table(cost),
                             X#shop.item =:= Y#cost.name,
                             Y#cost.price < 2
             ])).

do(Q) ->
    % Q 是一個 編譯的 QLC 查詢,qlc:e 會估算結果,交易成功會回傳 {atomic, Val}
    F = fun() -> qlc:e(Q) end,
    {atomic, Val} = mnesia:transaction(F),
    Val.

測試

2> test_mnesia:start().
ok
3> test_mnesia:reset_tables().
{atomic,ok}
4> test_mnesia:demo(select_shop).
[{shop,potato,2456,1.2},
 {shop,apple,20,2.3},
 {shop,orange,100,3.8},
 {shop,pear,200,3.6},
 {shop,banana,420,4.5}]
5> test_mnesia:demo(select_some).
[{potato,2456},
 {apple,20},
 {orange,100},
 {pear,200},
 {banana,420}]

qlc 是 query list comprehensions 的縮寫,語法

X || X <- mnesia:table(shop)

意思是 取得list: X,其中 X 是取自 Mnesia 的 table: shop,X 的值是 erlang 的 shop record,要注意 qlc:c 的語法

qlc:q([X || X <- mnesia:table(shop)])

跟下面分成兩段的寫法是不一樣的,不能這樣寫

Var = [X || X <- mnesia:table(shop)],
qlc:c(Var)

取得特定欄位資料

因為 X 是 shop record,可以用 record 的語法 X#shop.item,只取得我們想要的欄位資料

qlc:q([ {X#shop.item, X#shop.quantity} || X <- mnesia:table(shop)])

條件查詢

直接把條件 X#shop.quantity < 250 放在 list comprehension 的後面

qlc:q([X#shop.item || X <- mnesia:table(shop),
                             X#shop.quantity < 250 ])

測試

6> test_mnesia:demo(reorder).
[apple,orange,pear]

join table

以 X#shop.item =:= Y#cost.name join 兩個 table

%%   SELECT shop.item
%%   FROM shop, cost 
%%   WHERE shop.item = cost.name 
%%     AND cost.price < 2
%%     AND shop.quantity < 250

qlc:q([X#shop.item || X <- mnesia:table(shop),
                             X#shop.quantity < 250,
                             Y <- mnesia:table(cost),
                             X#shop.item =:= Y#cost.name,
                             Y#cost.price < 2
             ])

測試

7> test_mnesia:demo(join).
[apple]

新增/移除 資料

shop 的 primary key 是表格中的第一個欄位,此 table 是屬於 set 類型,如果新的 record 跟原本存在於 table 內的某個 row 有相同的 primary key,則新的 record 就會覆蓋掉舊資料。

移除資料,只需要知道 table 名稱,跟 primary key。

%% ===========
add_shop_item(Name, Quantity, Cost) ->
    Row = #shop{item=Name, quantity=Quantity, cost=Cost},
    F = fun() ->
                mnesia:write(Row)
        end,
    mnesia:transaction(F).

remove_shop_item(Item) ->
    Oid = {shop, Item},
    F = fun() ->
                mnesia:delete(Oid)
        end,
    mnesia:transaction(F).

測試

1> test_mnesia:start().
ok
2> test_mnesia:reset_tables().
{atomic,ok}
3> test_mnesia:demo(select_shop).
[{shop,potato,2456,1.2},
 {shop,apple,20,2.3},
 {shop,orange,100,3.8},
 {shop,pear,200,3.6},
 {shop,banana,420,4.5}]
4> test_mnesia:add_shop_item(orange, 200, 2.8).
{atomic,ok}
5> test_mnesia:demo(select_shop).
[{shop,potato,2456,1.2},
 {shop,apple,20,2.3},
 {shop,orange,200,2.8},
 {shop,pear,200,3.6},
 {shop,banana,420,4.5}]
6> test_mnesia:remove_shop_item(pear).
{atomic,ok}
7> test_mnesia:demo(select_shop).
[{shop,potato,2456,1.2},
 {shop,apple,20,2.3},
 {shop,orange,200,2.8},
 {shop,banana,420,4.5}]
8> mnesia:stop().
stopped
9>
=INFO REPORT==== 19-Feb-2014::11:25:00 ===
    application: mnesia
    exited: stopped
    type: temporary

transaction

在 transaction 的 F 裡面,可以呼叫 mnesia:write/1, mnesia:delete/1 或 qlc:e(Q)
qlc:e(Q) 裡面的 Q 是以 qlc:q/1 編譯的一個查詢

do_something() ->
    F = fun() ->
            % ...
            mnesia:write(Row),
            mnesia:delete(Old),
            qlc:e(Q)
        end,
    mnesia:transaction(F).

Mnesia 使用的交易策略是 悲觀上鎖 pessimistic locking,當交易管理員存取 table 時,會視情況將 record 或 整個 table 上鎖,如果偵測到可能會導致 deadlock,就會立刻放棄這個交易,並且還原先前做的任何改變。

如果交易一開始就失敗了,系統會自動等待一小段時間後,再試一次,所以交易內的 function 可能會被估算好幾次。

因此,交易內不應該做任何會產生副作用的行為,例如:

F = fun() ->
        ...
        io:format("reading..."),
        ...
    end,
mnesia:transaction(F),

我們可能會得到很多次 reading... 的畫面輸出

注意

  1. mnesia:write/1 與 mnesia:delete/1 只能在 由 mnesia:transaction/1 所處理的 fun 裡面使用
  2. 不應該寫程式去捕捉 mnesia 存取函式(mnesia:write/1, mnesia:delete/1 ...)的例外,因為Mnesia的交易機制,非常依賴這些函數丟出的例外,因此不能在 fun 裡面捕捉這些例外
farmer(Ntrade) ->
    %% Nwant = Number of oranges the farmer wants to trade 農夫想換掉的 oranges 數量
    % 可以用 1個orange 換 2個 apples
    F = fun() ->
                %% 取得  apple 的數量
                [Apple] = mnesia:read({shop,apple}),
                Napples = Apple#shop.quantity,
                Apple1  = Apple#shop{quantity = Napples + 2*Ntrade},
                %% 把最後 apples 的數量寫入 DB
                mnesia:write(Apple1),
                %% 取得 oranges 的數量
                [Orange] = mnesia:read({shop,orange}),
                NOranges = Orange#shop.quantity,
                if 
                    NOranges >= Ntrade ->
                        N1 =  NOranges - Ntrade,
                        Orange1 = Orange#shop{quantity=N1},
                        %% update the database
                        mnesia:write(Orange1);
                    true ->
                        %% 只要 oranges 不夠,就要 abort 放棄此交易
                        mnesia:abort(oranges)
                end
        end,
    mnesia:transaction(F).

測試

1> test_mnesia:start().
ok
2> test_mnesia:reset_tables().
{atomic,ok}
3> test_mnesia:demo(select_shop).
[{shop,potato,2456,1.2},
 {shop,apple,20,2.3},
 {shop,orange,100,3.8},
 {shop,pear,200,3.6},
 {shop,banana,420,4.5}]
4> test_mnesia:farmer(50).
{atomic,ok}
5> test_mnesia:demo(select_shop).
[{shop,potato,2456,1.2},
 {shop,apple,120,2.3},
 {shop,orange,50,3.8},
 {shop,pear,200,3.6},
 {shop,banana,420,4.5}]
6> test_mnesia:farmer(100).
{aborted,oranges}
7> test_mnesia:demo(select_shop).
[{shop,potato,2456,1.2},
 {shop,apple,120,2.3},
 {shop,orange,50,3.8},
 {shop,pear,200,3.6},
 {shop,banana,420,4.5}]

儲存複雜的資料

傳統 DBMS 的缺點是,欄位的資料型別有限,必須要以程式語言跟DB的資料型別做對應轉換,複雜的物件也沒有辦法直接儲存進去,而Mnesia 是設計來儲存所有的 erlang 的資料結構。

Mnesia 的 key 與 record 都可以是任意的 erlang term。

資料結構、資料庫、語言之間沒有 impedance mismatch 阻抗不協調的問題。插入或刪除複雜資料結構的速度都很快。

一開始要定義 record: design

-record(design, {id, plan}).

然後定義函數來儲存 plan

add_plans() ->
    % id 是第一個元素,裡面是複合 key:{joe,1}
    % plan 是一個 半徑 10 的圓形
    D1 = #design{id   = {joe,1},
                 plan = {circle,10}},
    % id 是單純的 fred
    D2 = #design{id   = fred, 
                 plan = {rectangle,10,5}},
    % id 跟 plan 都比較複雜
    D3 = #design{id   = {jane,{house,23}},
                 plan = {house,
                         [{floor,1,
                           [{doors,3},
                            {windows,12},
                            {rooms,5}]},
                          {floor,2,
                           [{doors,2},
                            {rooms,4},
                            {windows,15}]}]}},
    F = fun() -> 
                mnesia:write(D1),
                mnesia:write(D2),
                mnesia:write(D3)
        end,
    mnesia:transaction(F).

get_plan(PlanId) ->
    F = fun() -> mnesia:read({design, PlanId}) end,
    mnesia:transaction(F).

測試

1> test_mnesia:start().
ok
2> test_mnesia:add_plans().
{atomic,ok}
3> test_mnesia:get_plan(fred).
{atomic,[{design,fred,{rectangle,10,5}}]}
4> test_mnesia:get_plan({jane, {house,23}}).
{atomic,[{design,{jane,{house,23}},
                 {house,[{floor,1,[{doors,3},{windows,12},{rooms,5}]},
                         {floor,2,[{doors,2},{rooms,4},{windows,15}]}]}}]}

Mnesia 支援 fragmented table,可以水平切割的 table,這可以用來設計出超大的 table,而 table 內的資料被儲存在不同的機器上,這些 fragmented table 本身也是 Mnesia table,每個片段都可以獨立被複製、索引。

表格類型與位置

Mnesia table 可存在 RAM 或 DISK 上,另外也可以放在單一機器或複製到數個機器上。

  1. RAM
    速度很快,一旦當機或停止 DB,資料就會消失。

    使用時必須先測試實體記憶體能不能放得下所有資料,如果放不下,系統會因為常常要跟 Disk 做資料交換,而造成效能低下。

  2. DISK
    資料會存在 DISK 上,當機後還能保有資料。

    資料會先存到 disk log,等一定的時間後,disk log 會跟資料庫內其他資料 consolidate 在一起,且 disk log 裡面的項目會被建立出來,如果系統當機,當下次系統重新啟動時,會主動檢查 disk log 是否一致,log 裡面尚未處理的資料會被寫入到 DB。

    一旦交易成功,資料就會寫入到 disk log,如果在交易期間,系統當機了,那麼這一次的資料異動就會遺失。

因為 RAM table 是 transient 的,如果記憶體內的資料不見了,對系統有影響,那麼就必須要把 RAM table 複製到 disk 或是另一部機器的 RAM 或 disk。

建立表格

@spec mnesia:create_table(Name, ArgS) ->
        {atomic, ok} | {aborted, Reason}

Args 是 {Key, Val} 的 tuple list,參數說明如下

  1. Name
    表格的名稱,必須要是 atom。習慣上會使用 record 的名稱。

  2. {type, Type}
    表格的型別,可以是 set, ordered_set, bag 中的一種
    set:所有的 key 都不一樣
    ordered set:tuple 會根據 key 值被排序
    bag:key 可以重複

  3. {disc_copies, NodeList}
    NodeList 是 erlang 節點的清單,table 的 disk 副本會儲存到這些節點,使用此選項時,系統會在「進行此操作」的節點上,建立 RAM table 的複製版本

    把 disc_copies 的複製表格放在一個節點,將相同表格以不同型別存在別的節點上,這樣的作法很常見。因為
    3.1 從 RAM 讀取資料,速度很快
    3.2 可以把資料寫到 persistent disk 上

  4. {ram_copies, NodeList}
    NodeList 是 erlang 節點的清單,table 的 ram 副本會儲存到這些節點

  5. {disc_only_copies, NodeList}
    NodeList 是 erlang 節點的清單,table 的 disk 副本會儲存到這些節點,且沒有 ram 副本。

  6. {attributes, AtomList}
    這是特定table內的值的欄位名稱清單,想建立包含erlang record XXX 的表格,必須使用 {attribute, record_info(fields, XXX)} 的語法,或是指定記錄欄位名稱的清單

常見的屬性組合

  1. mnesia:create_table(shop, [Attrs])
    ram 放在單一節點
    節點當機,資料會遺失
    存取速度最快
    記憶體必須足夠存放所有資料

  2. mnesia:create_table(shop, [Attrs, {disc_copies, [node()]}])
    ram + disk,單一節點
    如果節點當機,表格資料會從 disk 中復原
    讀取很快,寫入很慢
    記憶體必須足夠存放所有資料

  3. mnesia:create_table(shop, [Attrs, {disc_only, [node()]}])
    disc,單一節點
    大型 table 不必擔心記憶體夠不夠
    比 RAM 的副本慢

  4. mnesia:create_table(shop, [Attrs, {ram_copies, [node(), someOtherNode()]}])
    ram, 兩個節點
    如果兩個節點都當機,資料會遺失
    記憶體必須足夠存放所有資料
    可從任一節點存取 table

  5. mnesia:create_table(shop, [Attrs, {disc_copies, [node(), someOtherNode()]}])
    disk,兩個節點
    可利用另一個節點的資料復原
    即使兩個節點都當機,資料也不會受損

表格行為

如果 table 被複製到跨越數個 erlang 節點,會盡量同步化,如果一個節點當機,系統仍然可以運作,當原本當機的節點再次上線,會和其他保有副本的節點重新同步。

Mnesia 的圖形化檢視器

1> tv:start().
<0.33.0>

這會啟動一個GUI使用介面,可查看 Mnesia Table

缺少的內容

  1. 備份與恢復
    Mnesia允許許多不同種類的備份組態及災難復原機制

  2. Dirty操作
    dirty_read, dirty_write ...
    這是指在交易外面進行操作,如果應用是單一線程,或是在某種特殊狀況下,使用 Dirty 操作可提高效率。

  3. SNMP Table
    Mnesia 內建了 SNMP table 型別,這使得實做 SNMP 管理系統變得比較容易。

參考

Erlang and OTP in Action
Programming Erlang: Software for a Concurrent World