在数据库管理系统中,事务和并发控制是一组用于确保数据安全和一致性的关键功能。本文将详细介绍 MongoDB 事务支持及使用、锁机制与隔离级别、以及乐观锁与悲观锁的应用。
MongoDB 4.0 版本开始,支持多文档 ACID 事务。这使您可以在单个事务中对多个文档和集合执行操作。MongoDB 的事务支持主要针对复制集 (v4.0 及更高版本) 和分片集群 (v4.2 及更高版本)。
事务是一组原子性的、一致性的、隔离性的和持久性的(ACID)操作。在 MongoDB 中,事务允许您对多个文档进行一系列的读写操作,这些操作要么全部成功,要么全部失败。事务在以下场景中非常有用:
当您需要对多个文档进行一致性更新时,例如在转账操作中从一个账户扣款并向另一个账户存款。
当您需要在多个集合之间维护引用完整性时,例如在订单和库存系统中创建订单并更新库存。
在 MongoDB 4.0 及更高版本中,副本集和分片集群都支持多文档事务。然而,事务对以下功能有一些限制:
事务不支持跨数据库操作。
事务不支持跨分片操作。
事务不支持对系统集合的操作。
以下是使用事务的一般步骤:
启动一个新的会话(Session)。
在会话中启动一个事务。
在事务中执行一系列的读写操作。
提交或回滚事务。
结束会话。
假设我们有一个银行应用程序,需要在两个账户之间进行转账操作。以下是一个使用事务的示例:
// 连接到 MongoDB
const { MongoClient } = require("mongodb");
const uri = "mongodb://localhost:27017";
const client = new MongoClient(uri, { useUnifiedTopology: true });
await client.connect();
const db = client.db("bank");
// 定义转账操作
async function transfer(session, fromAccountId, toAccountId, amount) {
// 从 fromAccountId 扣款
await db.collection("accounts").updateOne(
{ _id: fromAccountId },
{ $inc: { balance: -amount } },
{ session }
);
// 向 toAccountId 存款
await db.collection("accounts").updateOne(
{ _id: toAccountId },
{ $inc: { balance: amount } },
{ session }
);
}
// 启动一个新的会话
const session = client.startSession();
try {
// 在会话中启动一个事务
session.startTransaction();
// 在事务中执行转账操作
await transfer(session, "account1", "account2", 100);
// 提交事务
await session.commitTransaction();
} catch (error) {
// 发生错误,回滚事务
console.error("Error during transaction:", error);
await session.abortTransaction();
} finally {
// 结束会话
session.endSession();
client.close();
}
MongoDB 支持两种事务隔离级别:快照隔离(Snapshot Isolation)和读已提交(Read Committed)。默认情况下,事务使用快照隔离级别。您可以在启动事务时使用 readConcern
选项来指定事务隔离级别。
在快照隔离级别下,事务在启动时会创建一个数据快照。事务中的所有读操作都基于这个快照,这意味着事务中的读操作不会受到其他并发事务的影响。
在读已提交隔离级别下,事务中的读操作只能看到已经提交的数据。这意味着事务中的读操作可能受到其他并发事务的影响。要使用读已提交隔离级别,您需要在启动事务时指定 readConcern
选项:
session.startTransaction({ readConcern: { level: "committed" } });
在 MongoDB 中,事务有一个默认的超时时间。如果事务在超时时间内没有提交或回滚,MongoDB 会自动终止事务并释放锁。默认的事务超时时间是 60 秒。您可以在启动事务时使用 maxTimeMS
选项来指定自定义的超时时间:
session.startTransaction({ maxTimeMS: 120000 }); // 设置超时时间为 120 秒
MongoDB 支持对事务的操作主要位于开启事务的会话中:
// 开启一个会话
const session = db.getMongo().startSession();
// 在会话中开始一个事务
session.startTransaction();
在开始事务后,您可以在事务中执行数据操作。例如:
// 在事务中对集合执行操作
db.getSiblingDB('mydbName')['myCollection'].insertOne(/* 插入文档 */, { session });
在事务中执行的所有操作要么一起应用,要么一起回滚。您可以提交或终止一个事务,进行相关操作:
// 提交事务
session.commitTransaction();
// 终止(或回滚)事务
session.abortTransaction();
完成事务并释放相关资源后,需要结束会话:
session.endSession();
在数据库中,锁是一种用于控制并发访问共享资源的机制。通过使用锁,数据库可以确保在同一时间只有一个事务能够访问特定的数据,从而防止数据不一致和冲突。MongoDB 使用一种称为读写锁(Read-Write Lock)的锁机制,它允许多个读操作并发执行,但在执行写操作时会阻止其他读写操作。
锁粒度是指锁定资源的大小。在 MongoDB 中,锁粒度分为以下几种:
在早期版本的 MongoDB(2.2 之前)中,锁定是在数据库级别实现的。这意味着在执行写操作时,整个数据库都会被锁定,从而阻止其他读写操作。数据库级锁的优点是简单易用,但缺点是并发性能较低。
从 MongoDB 2.2 开始,锁定粒度被降低到集合级别。这意味着在执行写操作时,只有目标集合会被锁定,其他集合仍然可以并发执行读写操作。集合级锁的优点是并发性能较高,但仍然存在一定的性能瓶颈。
从 MongoDB 3.0 开始,引入了一种称为 WiredTiger 的存储引擎,它支持文档级锁。这意味着在执行写操作时,只有目标文档会被锁定,其他文档仍然可以并发执行读写操作。文档级锁的优点是并发性能最高,但实现复杂度较高。
隔离级别是指事务在执行过程中可以看到其他并发事务所做的修改。在 MongoDB 中,支持两种隔离级别:快照隔离(Snapshot Isolation)和读已提交(Read Committed)。
在快照隔离级别下,事务在启动时会创建一个数据快照。事务中的所有读操作都基于这个快照,这意味着事务中的读操作不会受到其他并发事务的影响。快照隔离是 MongoDB 默认的隔离级别。
在读已提交隔离级别下,事务中的读操作只能看到已经提交的数据。这意味着事务中的读操作可能受到其他并发事务的影响。要使用读已提交隔离级别,您需要在启动事务时指定 readConcern
选项:
session.startTransaction({ readConcern: { level: "committed" } });
在默认情况下,MongoDB 使用的是数据库级别的锁,但 WiredTiger
存储引擎允许更细粒度的锁。作为一种无锁存储引擎,WiredTiger
使用乐观并发控制 (Optimistic Concurrency Control, OCC) 以便在文档级别上支持高并发读写操作。
MongoDB 的隔离级别主要有 读已提交(Read Committed) 和 快照隔离(Snapshot Isolation) 两种。提交读和快照隔离都是基于当前已提交的数据。分布式事务采用快照隔离。
乐观锁和悲观锁是处理事务资源的两种方法。MongoDB 并没有内建的悲观锁,但可以通过应用乐观锁来实现类似的效果。
乐观锁认为冲突发生的可能性较低,所以在事务处理期间不会对资源加锁。MongoDB 的 WiredTiger
存储引擎使用的正是乐观锁。如果出现冲突,事务将需要回滚,并再次尝试。
乐观锁在冲突较少的情况下可以为您提供较好的性能,但在竞争激烈或冲突较多的情况下,乐观锁可能会导致较多的重试操作,进而降低性能。
MongoDB 默认采用乐观锁策略。要在 MongoDB 中实现乐观锁,您可以使用以下方法:
findAndModify
操作findAndModify
操作是一种原子性操作,它可以在单个操作中查询并更新文档。要实现乐观锁,您可以在查询条件中添加版本号字段,以确保只有在版本号匹配时才更新文档。
示例:更新库存,只有在版本号匹配时才执行更新操作。
db.inventory.findAndModify({
query: { item: "apple", version: 1 },
update: { $inc: { stock: -1 }, $inc: { version: 1 } }
});
update
操作和 $set
操作符update
操作和 $set
操作符也可以用于实现乐观锁。与 findAndModify
类似,您需要在查询条件中添加版本号字段。
示例:更新库存,只有在版本号匹配时才执行更新操作。
db.inventory.update(
{ item: "apple", version: 1 },
{ $inc: { stock: -1 }, $inc: { version: 1 } }
);
悲观锁认为冲突发生的可能性较高,因此会在事务处理期间锁定资源。悲观锁可以有效地减少回滚并确保数据一致性。然而,在许多场景下,它们会限制系统吞吐量并降低性能。
MongoDB 不直接支持悲观锁,但您可以使用事务来实现类似的效果。在事务中,对文档的修改会被锁定,直到事务提交或回滚。要在 MongoDB 中实现悲观锁,您可以使用以下方法:
在 MongoDB 4.0 及更高版本中,您可以使用事务来实现悲观锁。事务可以确保在同一时间只有一个事务能够访问特定的数据,从而防止数据不一致和冲突。
示例:在事务中更新库存。
// 在会话中启动一个事务
session.startTransaction();
// 在事务中查询并更新库存
const item = "apple";
const inventory = db.collection("inventory");
const currentItem = await inventory.findOne({ item }, { session });
await inventory.updateOne({ item }, { $set: { stock: currentItem.stock - 1 } }, { session });
// 提交事务
await session.commitTransaction();
} catch (error) {
// 发生错误,回滚事务
console.error("Error during transaction:", error);
await session.abortTransaction();
} finally {
// 结束会话
session.endSession();
}