• トップ
  • ブログ一覧
  • MongoDBのトランザクション機能を使ってみた!【Node.js】
  • MongoDBのトランザクション機能を使ってみた!【Node.js】

    メディアチームメディアチーム
    2020.09.09

    IT技術

    MongoDBとは

    「MongoDB」は、ドキュメント指向の NoSQL データベースのひとつです。

    MongoDBの特徴

    「MongoDB」は、データを「ドキュメント」と呼ばれる JSON に似た形式で格納します。

    また、多くの RDB(リレーショナルデータベース)と異なり、前もってスキーマを厳密に決める必要がありません。

    ドキュメントごとに、異なったデータ構造にすることも可能です。

    開発上のメリット

    これらの特徴は、特にサーバサイドの JavaScript 実行環境である Node.js でスピーディーな開発を可能にします。

    また、2018 年 6 月にリリースされたバージョン 4.0 からは、「MongoDB」でもトランザクションの利用が可能になりました。

    トランザクションが無かったMongoDB

    「MongoDB」は登場してからバージョン 4.0 まで、長らくトランザクションの機能がありませんでした。

    MongoDBの弱点

    「トランザクション機能がない」という点は、DBMSとしての弱点であり、システム開発上で制約になる可能性があります。

    RDB に慣れていた開発者は、複数ドキュメントに対する一連の更新処理を安全に実行するために苦慮していました。

    トランザクションが利用できなかったばかりに、「MongoDB」の利用を断念してしまった開発者も少なくないのではないでしょうか。

    弱点の克服

    そうして離れていった開発者の中には、トランザクションが無かった頃の良くないイメージを引きずって、MongoDB の利用を躊躇している人も多いかもしれません。

    この記事によって、すでに「MongoDB」が、その弱点を克服していることを知ってもらいたいと思います。

    MongoDBのデータ構造

    まずは用語を整理する意味で、「MongoDB」のデータがどのような構造で保存されるかを確認しておきましょう!

    データ構造のイメージ

    「MongoDB」のデータ構造のイメージは、下図のとおりです。

    ドキュメントとコレクション

    「MongoDB」では、データは「ドキュメント」という単位で保存されます。

    通常、ドキュメントは、その種類によって「コレクション」に分けて保存します。

    RDBで言うと

    1. テーブル = コレクション
    2. レコード = ドキュメント

    と覚えてください。

    RDBのテーブルと同様、データベースの中には複数のコレクションを持つことができます。

    また、コレクションの中にはドキュメントをいくつでも持つことができます。

    データ更新の例

    「MongoDB」のトランザクション機能を理解してもらうために、簡単な例で説明します。

    AさんとBさんの預金

    A さんと B さんは、ともに銀行に 10 万円の預金があると仮定します。

    データベースで表現すると

    上記の例は、下図のように「データベース」、「コレクション」、「ドキュメント」の関係で表すことができます。

    なお、それぞれのドキュメントは、実際にはドキュメントごとに割り振られる ObjectId  というユニークな値を持っています。

    Bさんの口座に振り込み

    ここで、A さんが自分の口座から 1 万円を B さんの口座に振り込むケースについて考えてみます。

    振込処理をするためには、下記の2つの更新操作が必要になります。

    1. 【更新処理1】A さんの口座から 1 万円を減らす
    2. 【更新処理2】B さんの口座に A さんから送られた 1 万円を増やす

    更新処理1のイメージ

    1つ目の処理を図にすると、こうなります。

    Aさんの口座から1万円が減りますが、振込処理がすべて完了するまでBさんの口座は変化しません。

    更新処理2のイメージ

    2つ目の処理イメージが下図です。

    B さんの口座に振り込みが行われ、最終的に目指す状態になります。

    途中段階

    1 つ目と 2 つ目の処理の間では、B さんから減った 1 万円がどこにもない状態になっています。

    このように、振込処理には途中段階があるということを踏まえておいてください。

    トランザクションを利用しない場合

    まず、トランザクションを利用せずに、一連の処理を Node.js で書いてみましょう。

    コード

    1async function transfer() {
    2  const mongouri = ‘接続先 URL; // 環境に応じて適切に指定すること
    3  const mongoOptions = {useNewUrlParser:true, useUnifiedTopology:true };
    4
    5  const amount = 10000; // 送金額
    6  let err = null; // エラー記憶用
    7
    8  const client = await MongoClient.connect(mongouri, mongoOptions);
    9  const db = client.db('bank'); // データベースオブジェクトを取得
    10  const col = db.collection('accounts'); // コレクションオブジェクトを取得
    11
    12  const accountA = await col.findOne({name:'Aさん'}); // name が ‘Aさん’ のドキュメントを取得
    13  const accountB = await col.findOne({name:'Bさん'}); // name が ‘Bさん’ のドキュメントを取得
    14
    15  const sendResult = await col.updateOne(
    16    {name:'Aさん'}, // 条件(名前が ‘Aさん’)
    17    {$set:{deposit:accountA.deposit - amount}} // 【更新処理1】Aさんの残高を送金額分減らす
    18  ).catch((error) => {
    19    err = error;
    20  });
    21
    22  const receiveResult = await col.updateOne(
    23    {name:'Bさん'}, // 条件(名前が ‘Bさん’)
    24    {$set:{deposit:accountB.deposit + amount}} // 【更新処理2】Bさんの残高を送金額分増やす
    25  ).catch((error) => {
    26    err = error;
    27  });
    28
    29  const accounts = await col.find().toArray();
    30  console.log(accounts); // 最終的にどうなったか表示
    31
    32  client.close();
    33}

    実行結果

    実行すると、最終的に accounts コレクションの中身がログに出力されます。

    1[
    2 { _id: 5f07d266dc9839ed36d75954, name: 'Aさん', deposit: 90000 },
    3 { _id: 5f07d274dc9839ed36d75955, name: 'Bさん', deposit: 110000 }
    4]

    A さんの残高が 1 万円減って、B さんの残高が 1 万円増えていますね。

    ちゃんと送金できました。

    処理内容

    上記コードでは、次の処理を順番に実行しました。

    1. 【更新処理1】A さんの残高 - 10000
    2. 【更新処理2】B さんの残高 + 10000
    3. 結果出力

    説明を簡単にするために、name(名前)を条件にしましたが、本来ならばユニークであることが保証されている ObjectId などを使うべきでしょう。

    トランザクションの重要性

    ここで、次のケースについて考えてみましょう!

    Aさんの残高を減らすことには成功したが、何らかの理由でBさんの残高を増やすことに失敗

    この場合どうなるでしょう?

    途中までは成功したんだからオッケー!

    …とはなりませんよね。

    A さんの残高を減らしておいて送金失敗のまま放っておいたら、銀行の信用問題になってしまいます。

    いずれか一方が失敗したままにするくらいなら、全て失敗した方がマシです。

    この、ひとつながりになっていなければならない処理では、「トランザクション」という機能が重要になります。

    トランザクションを使ってみる

    失敗した内容に応じて、リカバリー方法を考えることは面倒です。

    ひとつながりの処理のどこかで失敗したのならば、全ての操作を無かったことにしてしまいたいと誰でも考えるでしょう。

    そこで効果を発揮するのが「トランザクション」です。

    トランザクションを使ってコードを書き直す

    実行する前に、口座のデータを10万円ずつに戻しておきます。

    口座のデータを10万円ずつに戻したら、先ほど書いた処理をトランザクションを使って書き直しましょう。

    1async function transfer() {
    2  const mongouri = ‘接続先 URL; // 環境に応じて適切に指定すること
    3  const mongoOptions = {useNewUrlParser:true, useUnifiedTopology:true };
    4
    5  const amount = 10000; // 送金額
    6  let err = null; // エラー記憶用変数
    7
    8  const client = await MongoClient.connect(mongouri, mongoOptions);
    9  const db = client.db('bank');
    10  const col = db.collection('accounts');
    11
    12  const session = client.startSession(); // トランザクションを管理するオブジェクト
    13  session.startTransaction(); // トランザクションの開始
    14
    15  const accountA = await col.findOne({name:'Aさん'});
    16  const accountB = await col.findOne({name:'Bさん'});
    17
    18  const sendResult = await col.updateOne(
    19    {name:'Aさん'},
    20    {$set:{deposit:accountA.deposit - amount}}, // 【更新処理1】Aさんの残高を送金額分減らす
    21    {session} // 第3引数としてセッションを指定する
    22  ).catch((error) => {
    23    err = error;
    24  });
    25
    26  const receiveResult = await col.updateOne(
    27    {name:'Bさん'},
    28    {$set:{deposit:accountB.deposit + amount}}, // 【更新処理2】Bさんの残高を送金額分増やす
    29    {session} // 第3引数としてセッションを指定する
    30  ).catch((error) => {
    31    err = error;
    32  });
    33
    34  // エラーが発生せず、送金と受取で1件ずつドキュメントが更新されている場合のみ確定
    35  if(!err && sendResult.modifiedCount == 1 && receiveResult.modifiedCount == 1) {
    36    await session.commitTransaction(); // 一連の処理を確定する
    37  }else{
    38    await session.abortTransaction(); // 一連の処理を破棄する
    39  }
    40
    41  const accounts = await col.find().toArray();
    42  console.log(accounts); // 最終的にどうなったか表示
    43
    44  client.close(); // 切断
    45}

    実行結果

    結果は、前回と変わりません。

    1[
    2 { _id: 5f07d266dc9839ed36d75954, name: 'Aさん', deposit: 90000 },
    3 { _id: 5f07d274dc9839ed36d75955, name: 'Bさん', deposit: 110000 }
    4]

    処理内容

    上記コードでは、次のようにエラー発生の有無で処理を変更しています。

    1. 【更新操作1】A さんの残高 - 10000
    2. 【更新操作2】B さんの残高 + 10000
    3. エラーが発生しなければ、一連の処理を確定
    4. エラーが発生したら、一連の処理を破棄
    5. 結果出力

    次に、コードの内容について解説します。

    sessionの登場

    今回のコードでは、「トランザクション」を管理する変数として session が登場しました。

    session の用途は次の4つです。

    1. session.startTransaction  : ひとつながりの処理の開始
    2. 更新時の引数にsession を指定 : 2つの処理を同一トランザクションとして指定
    3. session.comitTransaction  : 更新処理の確定
    4. session.abortTransaction  : 更新処理の破棄

    なお、切断するまでの間に commitTransaction を実行しない、もしくは明示的に abortTransaction を実行すれば、ひとつながりの処理は全て無かったことになります。

    エラー判定

    処理を確定すべきかどうか(エラー判定)は、次の条件で確認しています。

    1. 変数 err が null (エラーが発生していない)
    2. sendResult.modifiedCount が 1( A さんの口座から 1 万円を減らす処理が成功)
    3. receiveResult.modifiedCount が 1( B さんの口座に 1 万円を追加する処理が成功)

    いずれかが確認できなければ、処理を確定しません。

    なお、commitTransaction による確定前であっても、ちゃんと 18 行目と 26 行目の左辺(sendResult)には、更新した結果のオブジェクトが格納されます。

    次は、「更新処理の破棄」について確認してみます!

    トランザクションの更新処理の破棄

    abortTransaction によって、トランザクション内の処理が破棄されるのを確認してみます。

    ここでは簡単に、トランザクションの処理を確定する直前で err 変数に true を入れてしまいます。

    コードを書き換える

    再び、A さんと B さんの預金を 10 万円ずつにした状態にします。

    前回のコードの 34 行目~ 39 行目を次のように書き換えます。

    1  err = true; // これを追加しただけです
    2
    3  // エラーが発生せず、送金と受取で1件ずつドキュメントが更新されている場合のみ確定
    4  if(!err && sendResult.modifiedCount == 1 && receiveResult.modifiedCount == 1) {
    5    await session.commitTransaction(); // 一連の処理を確定する
    6  }else{
    7    await session.abortTransaction(); // 一連の処理を破棄する ← こちらが実行される
    8  }

    実行結果

    実行結果を確認してみましょう!

    1[
    2 { _id: 5f07d266dc9839ed36d75954, name: 'Aさん', deposit: 100000 },
    3 { _id: 5f07d274dc9839ed36d75955, name: 'Bさん', deposit: 100000 }
    4]

    振込処理をする前と変わらず、AさんもBさんも口座に10万円の残高がある状態です。

    このように、トランザクションを使うと、何らかの間違いが発生したときに全て無かったことにできるのです。

    楽に整合性を保つことができますね。

    注意点

    updateOne の第3引数に指定する際の session は、 ClientSession そのままの形ではなく {session:ClientSession} の形式である必要があるようです。

    したがって、引数として指定する際に、変数 session を中括弧で囲みます。

    また、確定処理自体に失敗する可能性も考慮した方が良いでしょう。

    その場合、commitTransaction を try-catch で囲んで、catch で abortTransaction を実行した上で、例外を throw するなどを検討してみてください。

    パフォーマンスへの影響

    最後に、トランザクションを利用することによる「パフォーマンスへの影響」を検証します。

    「MongoDB」は、更新処理が高速であることでも知られています。

    もし、トランザクションを利用することによって、この長所が失われるとしたら残念ですよね。

    検証結果

    今回の振込処理の例で、接続してから切断するまでを 100 回繰り返して平均を算出した結果です。

    トランザクションを使わない場合51.74ミリ秒
    トランザクションを使った場合60.49ミリ秒

    今回の例では、処理にかかる時間が 17% 弱増加する程度でした。

    17% の増加をどう考えるかは場合によりますが、通常の利用においては十分に実用的な速度と言えるのではないでしょうか。

    さいごに

    今回は、「MongoDB」のバージョン 4.0 から導入された「トランザクション」機能を紹介しました。

    簡単に言えば、間違いを全て無かったことにできる甘美な機能です。

    今の「MongoDB」ならば、複数ドキュメントを更新する際も整合性を不安に思うことはありません。

    もはや「MongoDB」は危なっかしいデータベースではないのです。

    RDB に慣れた開発者も、大きく考え方を変えずに使うことができるようになっています。

    安心して、「MongoDB」を使ったスピーディーな開発を体験しましょう!

    こちらの記事もオススメ!

    featureImg2020.08.07JavaScript 特集知識編JavaScriptを使ってできることをわかりやすく解説!JavaScriptの歴史【紆余曲折を経たプログラミン...

    featureImg2020.07.17ライトコード的「やってみた!」シリーズ「やってみた!」を集めました!(株)ライトコードが今まで作ってきた「やってみた!」記事を集めてみました!※作成日が新し...

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    メディアチーム
    メディアチーム
    Show more...

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background