Transactions are undoubtedly the most important new feature in MongoDB 4.0. MongoDB has supported ACID for single document operations for many years, and denormalized data meant many apps didn't need transactions. However, for certain applications, there's no way to escape the need for multi-document transactions. In this article, I'll demonstrate using transactions with the MongoDB Node.js driver and mongoose.
Transferring Money Between Accounts in Node.js
MongoDB currently only supports transactions on replica sets, not standalone servers. To run a local replica set for development on OSX or Linux, use npm to install run-rs globally and run run-rs --version 4.0.0
. Run-rs will download MongoDB 4.0.0 for you.
$ npm install run-rs -g
$ run-rs --version 4.0.0
Purging database...
Running '/home/node/lib/node_modules/run-rs/4.0.0/mongod'
Starting replica set...
Started replica set on "mongodb://localhost:27017,localhost:27018,localhost:27019"
Connected to oplog
Once you have a MongoDB 4.0.0 replica set running, you'll also need v3.1.0 of the MongoDB Node.js driver.
npm install mongodb@3.1
One example of an app that needs multi-document transactions is a bank account
app. If you transfer money from account A
to account B
, nobody should see an
in-between state where money has been deducted from account A
but hasn't
been added to account B
. And, if subtracting money from account A
fails due
to insufficient funds, you shouldn't add money to account B
.
In MongoDB, transactions are built on top of sessions. To
start a transaction, you first need to start a session using client.startSession()
. You can then call the session's startTransaction()
method to start a transaction.
// Boilerplate: connect to DB
const { MongoClient } = require('mongodb');
const uri = 'mongodb://localhost:27017,localhost:27018,localhost:27019/txn';
const client = await MongoClient.connect(uri, { useNewUrlParser: true, replicaSet: 'rs' });
const db = client.db();
await db.dropDatabase();
// Insert accounts and transfer some money
await db.collection('Account').insertMany([
{ name: 'A', balance: 5 },
{ name: 'B', balance: 10 }
]);
await transfer('A', 'B', 4); // Success
try {
// Fails because then A would have a negative balance
await transfer('A', 'B', 2);
} catch (error) {
error.message; // "Insufficient funds: 1"
}
// The actual transfer logic
async function transfer(from, to, amount) {
const session = client.startSession();
session.startTransaction();
try {
const opts = { session, returnOriginal: false };
const A = await db.collection('Account').
findOneAndUpdate({ name: from }, { $inc: { balance: -amount } }, opts).
then(res => res.value);
if (A.balance < 0) {
// If A would have negative balance, fail and abort the transaction
// `session.abortTransaction()` will undo the above `findOneAndUpdate()`
throw new Error('Insufficient funds: ' + (A.balance + amount));
}
const B = await db.collection('Account').
findOneAndUpdate({ name: to }, { $inc: { balance: amount } }, opts).
then(res => res.value);
await session.commitTransaction();
session.endSession();
return { from: A, to: B };
} catch (error) {
// If an error occurred, abort the whole transaction and
// undo any changes that might have happened
await session.abortTransaction();
session.endSession();
throw error; // Rethrow so calling function sees error
}
}
Transactions with Mongoose
To use transactions with Mongoose, you need mongoose >= 5.2.0
.
npm install mongoose@5.2
Transactions with Mongoose are similar to with the MongoDB driver. The big
difference is that, with Mongoose, startSession()
returns a promise rather than a session,
so you need to use await
.
// Boilerplate: connect to DB
const mongoose = require('mongoose');
const uri = 'mongodb://localhost:27017,localhost:27018,localhost:27019/txn';
await mongoose.connect(uri, { replicaSet: 'rs' });
await mongoose.connection.dropDatabase();
const Account = mongoose.model('Account', new mongoose.Schema({
name: String, balance: Number
}));
// Insert accounts and transfer some money
await Account.create([{ name: 'A', balance: 5 }, { name: 'B', balance: 10 }]);
await transfer('A', 'B', 4); // Success
try {
// Fails because then A would have a negative balance
await transfer('A', 'B', 2);
} catch (error) {
error.message; // "Insufficient funds: 1"
}
// The actual transfer logic
async function transfer(from, to, amount) {
const session = await mongoose.startSession();
session.startTransaction();
try {
const opts = { session, new: true };
const A = await Account.
findOneAndUpdate({ name: from }, { $inc: { balance: -amount } }, opts);
if (A.balance < 0) {
// If A would have negative balance, fail and abort the transaction
// `session.abortTransaction()` will undo the above `findOneAndUpdate()`
throw new Error('Insufficient funds: ' + (A.balance + amount));
}
const B = await Account.
findOneAndUpdate({ name: to }, { $inc: { balance: amount } }, opts);
await session.commitTransaction();
session.endSession();
return { from: A, to: B };
} catch (error) {
// If an error occurred, abort the whole transaction and
// undo any changes that might have happened
await session.abortTransaction();
session.endSession();
throw error; // Rethrow so calling function sees error
}
}
With both Mongoose and the MongoDB Node.js driver, MongoDB will report a
"WriteConflict" error if two transactions attempt to write conflicting data,
like if two transfer()
calls attempt to transfer money from the same account
at the same time.
const mongoose = require('mongoose');
try {
await Promise.all([
transfer('A', 'B', 4),
transfer('A', 'B', 2)
]);
} catch (error) {
error.message; // "MongoError: WriteConflict"
}
Moving On
Transactions are the most important feature to land in MongoDB in recent memory.
The ability to execute multiple operations in isolation and potentially undo
all of them is useful for any app, not just apps that need to transfer currency
between accounts. For example, you can update denormalized data in multiple
collections and easily undo all the operations using abortTransaction()
if
schema validation failed. So download run-rs,
MongoDB driver 3.1.0, and Mongoose 5.2.0 and get started with transactions today!
Transactions are much better with async/await in Node.js so you can use try/catch
rather than promise chaining.
If you're looking to get up to speed with async/await fast, check out
my new ebook, Mastering Async/Await.