JavaScript
Node.js
MongoDB
mongoose

MongoDB 4.0の待望の新機能Multiple Documents Transactionを試す

はじめに

MongoDBはNoSQLなため、列の定義の拡張が難しいRDBMSに対して柔軟に列定義の拡張が可能です。また一般的にNoSQLはRDBMSに対してパフォーマンス面で優位です。
その反面、複数のドキュメント操作に失敗したときに元の状態に戻す(いわゆる、RDBMSのトランザクション制御)ということがDBレベルでサポートされていませんでした。(注:単一のドキュメント操作レベルでのACIDはいままでも保証されていました)
MongoDB 3.0から3年越しであるMongoDB 4.0では待望のMultiple Documents Transactionがサポートされました。
これはMySQLなどのRDBMSでお馴染みのTransactionと同等の機能です。
(つまりNoSQLの欠点だったRDBMSのトランザクション機能も備わって最強というわけですね)
あまり世間的に騒がれていませんが(?)、これを機にやっぱりMongoDBはイケてるぞというのを再普及させたいと思ったのでMongoDBのMultiple Documents TransactionをNodeJSで試してみます。

動作サンプル

次のMongoose作者のブログを参考にサンプルを改良しました。
A Node.js Perspective on MongoDB 4.0: Transactions

現状、ReplicaSet構成でないと動きません。
ローカル上で簡易にReplicaSet構成を構築するにはrun-rsを導入します。
次のコマンドでMongoDB4.0のReplicaSetが動きます。

$ npm install run-rs -g
$ run-rs --version 4.0.0

また、NodeJSでMongoDBを使う際によく使われるORMライブラリであるMongooseを使用します。

が必要です。

$ npm install mongodb@3.1
$ npm install mongoose@5.2

書き込み(insert)、更新(update)のサンプルファイルを作成します。
トランザクション内のクエリのoptionにsessionを渡すのを忘れないようにします。

example.js
// mongo
const mongoose = require('mongoose')
const Schema = mongoose.Schema
mongoose.Promise = global.Promise
// mongoose.set('debug', true) // enable logging collection methods + arguments to the console
mongoose.connect('mongodb://localhost:27017,localhost:27018,localhost:27019/txn', {replicaSet: 'rs'})


const schema1 = new Schema({
  name: {type: String},
}, {
  timestamps: true,
  toObject: {
    virtuals: true,
  },
  toJSON: {
    virtuals: true,
    transform: (doc, m) => {
      delete m.__v
      return m
    },
  },
})

const User = mongoose.model('User', schema1)

const schema2 = new Schema({
  msg: {type: String, required: true},
}, {
  timestamps: true,
  toObject: {
    virtuals: true,
  },
  toJSON: {
    virtuals: true,
    transform: (doc, m) => {
      delete m.__v
      return m
    },
  },
})

const Chat = mongoose.model('Chat', schema2)


const insert = async function(isFail = false) {

  // session取得(Mongooseの場合はModel.startSessionで取得)
  const session = await User.startSession()


  try {
    await session.startTransaction()

    // テーブルがないと失敗するので注意
    await User.insertMany([{name: '次郎'}],  { session })
    await Chat.insertMany([{msg: 'こんにちわ'}],  { session })
    if (isFail) {
      throw new Error('保存失敗')
    }

    await session.commitTransaction() // コミット
    console.log('--------commit-----------')
  } catch (e) {
    await session.abortTransaction() // ロールバック
    console.log('--------abort-----------')
    console.error(e)
  } finally {
    await session.endSession()
  }

  // 状態確認
  const users = await User.find()
  console.log(users)
  const chats = await Chat.find()
  console.log(chats)
}


const update = async function(isFail = false) {

  // session取得(Mongooseの場合はModel.startSessionで取得)
  const session = await User.startSession()
  try {
    await session.startTransaction({readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' }})

    await User.findOneAndUpdate({name: '次郎'}, {$set: {name: '花子'}}, {session, runValidators: true, new: true})
    await Chat.findOneAndUpdate({msg: 'こんにちわ'}, {$set: {msg: 'こんばんわ'}}, {session, runValidators: true, new: true})
    if (isFail) {
      throw new Error('保存失敗')
    }

    await session.commitTransaction() // コミット
    console.log('--------commit-----------')
  } catch (e) {
    await session.abortTransaction() // ロールバック
    console.log('--------abort-----------')
    console.error(e)
  } finally {
    await session.endSession()
  }

  // 状態確認
  const users = await User.find()
  console.log(users)
  const chats = await Chat.find()
  console.log(chats)
}



async function main() {

  await mongoose.connection.dropDatabase()

  // テーブル作成
  await User.create({name: '太郎'})
  await Chat.create({msg: 'おはよう'})
  // 状態確認
  const users = await User.find()
  console.log(users)
  const chats = await Chat.find()
  console.log(chats)

  await insert(true)
  await insert()
  await update(true)
  await update()

  await mongoose.disconnect()
}

main()

次のコマンドで実行します

$node example.js

実行結果は、次のようになります。
transaction完了(session.commitTransaction)前にエラーになった場合に
session.abortTransactionでロールバックします。

$ node example.js 
[ { _id: 5b410aa1bab3f57320b8b011,
    name: '太郎',
    createdAt: 2018-07-07T18:46:57.436Z,
    updatedAt: 2018-07-07T18:46:57.436Z,
    __v: 0,
    id: '5b410aa1bab3f57320b8b011' } ]
[ { _id: 5b410aa1bab3f57320b8b012,
    msg: 'おはよう',
    createdAt: 2018-07-07T18:46:57.491Z,
    updatedAt: 2018-07-07T18:46:57.491Z,
    __v: 0,
    id: '5b410aa1bab3f57320b8b012' } ]
--------abort-----------
Error: 保存失敗
    at insert (/Users/daikiterai/Desktop/webapp/example.js:59:13)
    at <anonymous>
    at process._tickCallback (internal/process/next_tick.js:188:7)
[ { _id: 5b410aa1bab3f57320b8b011,
    name: '太郎',
    createdAt: 2018-07-07T18:46:57.436Z,
    updatedAt: 2018-07-07T18:46:57.436Z,
    __v: 0,
    id: '5b410aa1bab3f57320b8b011' } ]
[ { _id: 5b410aa1bab3f57320b8b012,
    msg: 'おはよう',
    createdAt: 2018-07-07T18:46:57.491Z,
    updatedAt: 2018-07-07T18:46:57.491Z,
    __v: 0,
    id: '5b410aa1bab3f57320b8b012' } ]
--------commit-----------
[ { _id: 5b410aa1bab3f57320b8b011,
    name: '太郎',
    createdAt: 2018-07-07T18:46:57.436Z,
    updatedAt: 2018-07-07T18:46:57.436Z,
    __v: 0,
    id: '5b410aa1bab3f57320b8b011' },
  { _id: 5b410aa1bab3f57320b8b015,
    name: '次郎',
    __v: 0,
    createdAt: 2018-07-07T18:46:57.610Z,
    updatedAt: 2018-07-07T18:46:57.610Z,
    id: '5b410aa1bab3f57320b8b015' } ]
[ { _id: 5b410aa1bab3f57320b8b012,
    msg: 'おはよう',
    createdAt: 2018-07-07T18:46:57.491Z,
    updatedAt: 2018-07-07T18:46:57.491Z,
    __v: 0,
    id: '5b410aa1bab3f57320b8b012' },
  { _id: 5b410aa1bab3f57320b8b016,
    msg: 'こんにちわ',
    __v: 0,
    createdAt: 2018-07-07T18:46:57.611Z,
    updatedAt: 2018-07-07T18:46:57.611Z,
    id: '5b410aa1bab3f57320b8b016' } ]
--------abort-----------
Error: 保存失敗
    at update (/Users/daikiterai/Desktop/webapp/example.js:91:13)
    at <anonymous>
    at process._tickCallback (internal/process/next_tick.js:188:7)
[ { _id: 5b410aa1bab3f57320b8b011,
    name: '太郎',
    createdAt: 2018-07-07T18:46:57.436Z,
    updatedAt: 2018-07-07T18:46:57.436Z,
    __v: 0,
    id: '5b410aa1bab3f57320b8b011' },
  { _id: 5b410aa1bab3f57320b8b015,
    name: '次郎',
    __v: 0,
    createdAt: 2018-07-07T18:46:57.610Z,
    updatedAt: 2018-07-07T18:46:57.610Z,
    id: '5b410aa1bab3f57320b8b015' } ]
[ { _id: 5b410aa1bab3f57320b8b012,
    msg: 'おはよう',
    createdAt: 2018-07-07T18:46:57.491Z,
    updatedAt: 2018-07-07T18:46:57.491Z,
    __v: 0,
    id: '5b410aa1bab3f57320b8b012' },
  { _id: 5b410aa1bab3f57320b8b016,
    msg: 'こんにちわ',
    __v: 0,
    createdAt: 2018-07-07T18:46:57.611Z,
    updatedAt: 2018-07-07T18:46:57.611Z,
    id: '5b410aa1bab3f57320b8b016' } ]
--------commit-----------
[ { _id: 5b410aa1bab3f57320b8b011,
    name: '太郎',
    createdAt: 2018-07-07T18:46:57.436Z,
    updatedAt: 2018-07-07T18:46:57.436Z,
    __v: 0,
    id: '5b410aa1bab3f57320b8b011' },
  { _id: 5b410aa1bab3f57320b8b015,
    name: '花子',
    __v: 0,
    createdAt: 2018-07-07T18:46:57.610Z,
    updatedAt: 2018-07-07T18:46:57.667Z,
    id: '5b410aa1bab3f57320b8b015' } ]
[ { _id: 5b410aa1bab3f57320b8b012,
    msg: 'おはよう',
    createdAt: 2018-07-07T18:46:57.491Z,
    updatedAt: 2018-07-07T18:46:57.491Z,
    __v: 0,
    id: '5b410aa1bab3f57320b8b012' },
  { _id: 5b410aa1bab3f57320b8b016,
    msg: 'こんばんわ',
    __v: 0,
    createdAt: 2018-07-07T18:46:57.611Z,
    updatedAt: 2018-07-07T18:46:57.669Z,
    id: '5b410aa1bab3f57320b8b016' } ]

Multiple Documents Transactionのロールバックが動作することで
複数のモデル操作が必要な処理にゴミが残らずロールバックしてくれます。
ReplicaSet構成前提でMongooseの方はModel.prototype.save、Model.createにはまだ対応していませんが、
課金周りなどの重要な処理に実装しておくと良い気がします。