MongoDB 事务与并发控制

MongoDB 事务与并发控制

在数据库管理系统中,事务和并发控制是一组用于确保数据安全和一致性的关键功能。本文将详细介绍 MongoDB 事务支持及使用、锁机制与隔离级别、以及乐观锁与悲观锁的应用。

MongoDB 事务支持及使用

MongoDB 4.0 版本开始,支持多文档 ACID 事务。这使您可以在单个事务中对多个文档和集合执行操作。MongoDB 的事务支持主要针对复制集 (v4.0 及更高版本) 和分片集群 (v4.2 及更高版本)。

1. 事务概述

事务是一组原子性的、一致性的、隔离性的和持久性的(ACID)操作。在 MongoDB 中,事务允许您对多个文档进行一系列的读写操作,这些操作要么全部成功,要么全部失败。事务在以下场景中非常有用:

  • 当您需要对多个文档进行一致性更新时,例如在转账操作中从一个账户扣款并向另一个账户存款。

  • 当您需要在多个集合之间维护引用完整性时,例如在订单和库存系统中创建订单并更新库存。

2. 事务支持

在 MongoDB 4.0 及更高版本中,副本集和分片集群都支持多文档事务。然而,事务对以下功能有一些限制:

  • 事务不支持跨数据库操作。

  • 事务不支持跨分片操作。

  • 事务不支持对系统集合的操作。

3. 使用事务

以下是使用事务的一般步骤:

  1. 启动一个新的会话(Session)。

  2. 在会话中启动一个事务。

  3. 在事务中执行一系列的读写操作。

  4. 提交或回滚事务。

  5. 结束会话。

3.1 示例

假设我们有一个银行应用程序,需要在两个账户之间进行转账操作。以下是一个使用事务的示例:

// 连接到 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();
}

4. 事务隔离级别

MongoDB 支持两种事务隔离级别:快照隔离(Snapshot Isolation)和读已提交(Read Committed)。默认情况下,事务使用快照隔离级别。您可以在启动事务时使用 readConcern 选项来指定事务隔离级别。

4.1 快照隔离

在快照隔离级别下,事务在启动时会创建一个数据快照。事务中的所有读操作都基于这个快照,这意味着事务中的读操作不会受到其他并发事务的影响。

4.2 读已提交

在读已提交隔离级别下,事务中的读操作只能看到已经提交的数据。这意味着事务中的读操作可能受到其他并发事务的影响。要使用读已提交隔离级别,您需要在启动事务时指定 readConcern 选项:

session.startTransaction({ readConcern: { level: "committed" } });

5. 事务超时

在 MongoDB 中,事务有一个默认的超时时间。如果事务在超时时间内没有提交或回滚,MongoDB 会自动终止事务并释放锁。默认的事务超时时间是 60 秒。您可以在启动事务时使用 maxTimeMS 选项来指定自定义的超时时间:

session.startTransaction({ maxTimeMS: 120000 }); // 设置超时时间为 120 秒

6. MongoShell使用事务

6.1 开始一个事务

MongoDB 支持对事务的操作主要位于开启事务的会话中:

// 开启一个会话
const session = db.getMongo().startSession();

// 在会话中开始一个事务
session.startTransaction();
6.2 在事务中执行操作

在开始事务后,您可以在事务中执行数据操作。例如:

// 在事务中对集合执行操作
db.getSiblingDB('mydbName')['myCollection'].insertOne(/* 插入文档 */, { session });
6.3 提交或终止事务

在事务中执行的所有操作要么一起应用,要么一起回滚。您可以提交或终止一个事务,进行相关操作:

// 提交事务
session.commitTransaction();

// 终止(或回滚)事务
session.abortTransaction();
6.4 结束会话

完成事务并释放相关资源后,需要结束会话:

session.endSession();

锁机制与隔离级别

1. 锁机制概述

在数据库中,锁是一种用于控制并发访问共享资源的机制。通过使用锁,数据库可以确保在同一时间只有一个事务能够访问特定的数据,从而防止数据不一致和冲突。MongoDB 使用一种称为读写锁(Read-Write Lock)的锁机制,它允许多个读操作并发执行,但在执行写操作时会阻止其他读写操作。

2. 锁粒度

锁粒度是指锁定资源的大小。在 MongoDB 中,锁粒度分为以下几种:

2.1 数据库级锁

在早期版本的 MongoDB(2.2 之前)中,锁定是在数据库级别实现的。这意味着在执行写操作时,整个数据库都会被锁定,从而阻止其他读写操作。数据库级锁的优点是简单易用,但缺点是并发性能较低。

2.2 集合级锁

从 MongoDB 2.2 开始,锁定粒度被降低到集合级别。这意味着在执行写操作时,只有目标集合会被锁定,其他集合仍然可以并发执行读写操作。集合级锁的优点是并发性能较高,但仍然存在一定的性能瓶颈。

2.3 文档级锁

从 MongoDB 3.0 开始,引入了一种称为 WiredTiger 的存储引擎,它支持文档级锁。这意味着在执行写操作时,只有目标文档会被锁定,其他文档仍然可以并发执行读写操作。文档级锁的优点是并发性能最高,但实现复杂度较高。

3. 隔离级别

隔离级别是指事务在执行过程中可以看到其他并发事务所做的修改。在 MongoDB 中,支持两种隔离级别:快照隔离(Snapshot Isolation)和读已提交(Read Committed)。

3.1 快照隔离

在快照隔离级别下,事务在启动时会创建一个数据快照。事务中的所有读操作都基于这个快照,这意味着事务中的读操作不会受到其他并发事务的影响。快照隔离是 MongoDB 默认的隔离级别。

3.2 读已提交

在读已提交隔离级别下,事务中的读操作只能看到已经提交的数据。这意味着事务中的读操作可能受到其他并发事务的影响。要使用读已提交隔离级别,您需要在启动事务时指定 readConcern 选项:

session.startTransaction({ readConcern: { level: "committed" } });

在默认情况下,MongoDB 使用的是数据库级别的锁,但 WiredTiger 存储引擎允许更细粒度的锁。作为一种无锁存储引擎,WiredTiger 使用乐观并发控制 (Optimistic Concurrency Control, OCC) 以便在文档级别上支持高并发读写操作。

MongoDB 的隔离级别主要有 读已提交(Read Committed)快照隔离(Snapshot Isolation) 两种。提交读和快照隔离都是基于当前已提交的数据。分布式事务采用快照隔离。

乐观锁与悲观锁的应用

乐观锁和悲观锁是处理事务资源的两种方法。MongoDB 并没有内建的悲观锁,但可以通过应用乐观锁来实现类似的效果。

乐观锁

乐观锁认为冲突发生的可能性较低,所以在事务处理期间不会对资源加锁。MongoDB 的 WiredTiger 存储引擎使用的正是乐观锁。如果出现冲突,事务将需要回滚,并再次尝试。

乐观锁在冲突较少的情况下可以为您提供较好的性能,但在竞争激烈或冲突较多的情况下,乐观锁可能会导致较多的重试操作,进而降低性能。

MongoDB 中的乐观锁应用

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();
}

你可能感兴趣的:(MongoDB系列,mongodb,数据库,1024程序员节)