MongoDB多文档事务隔离级别深度解析

本文详细探讨了MongoDB多文档事务的隔离级别机制,通过实际代码测试验证了其ACID合规性和快照隔离特性,分析了不同读关注级别对事务异常预防的效果,并对比了SQL数据库的事务处理差异。

MongoDB多文档事务隔离级别(强一致性)

MongoDB的多文档事务符合ACID规范,采用快照隔离来防止异常。MongoDB保证类似DBMS的一致性。

许多关于MongoDB事务隔离级别的过时或不准确说法仍然存在。这些说法过时是因为它们可能基于引入多文档事务的旧版本MongoDB 4.0,如旧的Jepsen报告,而问题自那时起已得到修复。它们也不准确,因为人们试图将MongoDB的事务隔离映射到SQL隔离级别,这是不合适的,因为SQL标准定义忽略了多版本并发控制(MVCC),而大多数数据库(包括MongoDB)都使用了MVCC。

Martin Kleppmann讨论了这个问题,并提供了评估事务隔离和潜在异常的测试。我将在MongoDB上执行这些测试,以解释多文档事务的工作原理并避免异常。

我遵循了Martin Kleppmann在PostgreSQL上测试的结构,并将其移植到MongoDB。MongoDB中的读隔离级别由读关注控制,“snapshot"读关注是唯一可与其他多版本并发控制SQL数据库相媲美的级别,并映射到快照隔离,不恰当地称为可重复读以使用最接近的SQL标准术语。由于我在单节点实验室进行测试,因此使用"majority"来显示它比读已提交做得更多。写关注也应设置为"majority”,以确保至少有一个节点在读写仲裁之间是共同的。

MongoDB隔离级别回顾

让我快速解释其他隔离级别以及为什么它们不能映射到SQL标准:

  • readConcern: { level: "local" }有时被比作未提交读,因为它可能显示在故障情况下可能稍后回滚的状态。然而,一些SQL数据库在某些罕见条件下可能显示相同的行为(例如此处),但仍称之为读已提交。
  • readConcern: { level: "majority" }有时被比作读已提交,因为它避免了未提交读。然而,读已提交是为等待冲突数据库定义的,以减少两阶段锁定中的锁持续时间,但MongoDB多文档事务使用失败冲突来避免等待。一些数据库认为读已提交可以允许多个状态的读取(例如此处),而其他一些则认为它必须是语句级快照隔离(例如此处)。在分片事务中,majority可能显示来自多个状态的结果,因为snapshot是时间线一致的。
  • readConcern: { level: "snapshot" }是真正的快照隔离等效项,比读已提交防止更多异常。一些数据库甚至称之为"可序列化"(例如此处),因为SQL标准忽略了写偏斜异常。
  • readConcern: { level: "linearlizable" }可与可序列化相媲美,但仅适用于单文档,不适用于多文档事务,类似于许多不提供可序列化的SQL数据库,因为它重新引入了读锁的可扩展性问题,而MVCC避免了这些问题。

读已提交基本要求(G0、G1a、G1b、G1c)

以下是一些通常在读已提交中防止的异常测试。我将使用readConcern: { level: "majority" }运行它们,但请记住,如果您希望跨多个分片的一致性快照,readConcern: { level: "snapshot" }可能更好。

MongoDB通过冲突错误防止写循环(G0)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// init
use test_db;
db.test.drop();
db.test.insertMany([
  { _id: 1, value: 10 },
  { _id: 2, value: 20 }
]);

// T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

T1.test.updateOne({ _id: 1 }, { $set: { value: 11 } });
T2.test.updateOne({ _id: 1 }, { $set: { value: 12 } });

在两阶段锁定数据库中,具有等待冲突行为,第二个事务将等待第一个事务以避免异常。然而,具有事务的MongoDB是失败冲突,并引发可重试错误以避免异常。

每个事务仅触及一个文档,但使用会话和startTransaction()显式声明,以允许多文档事务,这就是我们观察到失败冲突行为的原因,让应用程序对其复杂事务应用重试逻辑。

如果冲突更新作为单文档事务运行,等效于自动提交语句,它将使用等待冲突行为。我可以在t1事务仍处于活动状态时立即运行此操作进行测试:

1
2
3
4
5
6
7
8
const db = db.getMongo().getDB("test_db");
print(`Elapsed time: ${
    ((startTime = new Date())
    && db.test.updateOne({ _id: 1 }, { $set: { value: 12 } }))
    && (new Date() - startTime)
} ms`);

Elapsed time: 72548 ms

我运行了没有隐式事务的updateOne({ _id: 1 })。它等待另一个事务终止,这在60秒超时后发生,然后更新成功。超时的第一个事务被中止:

1
2
3
session1.commitTransaction();

MongoServerError[NoSuchTransaction]: Transaction with { txnNumber: 2 } has been aborted.

事务中的冲突行为不同:

  • 隐式单文档事务的等待冲突
  • 显式多文档事务的失败冲突立即导致瞬态错误,无需等待,让应用程序回滚并重试。

MongoDB防止中止读(G1a)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// init
use test_db;
db.test.drop();
db.test.insertMany([
  { _id: 1, value: 10 },
  { _id: 2, value: 20 }
]);

// T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

T1.test.updateOne({ _id: 1 }, { $set: { value: 101 } });
T2.test.find();

[ { _id: 1, value: 10 }, { _id: 2, value: 20 } ]

session1.abortTransaction();
T2.test.find();

[ { _id: 1, value: 10 }, { _id: 2, value: 20 } ]

session2.commitTransaction();

当读关注为’majority’或’snapshot’时,MongoDB通过仅读取已提交值来防止读取中止的事务。

MongoDB防止中间读(G1b)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// init
use test_db;
db.test.drop();
db.test.insertMany([
  { _id: 1, value: 10 },
  { _id: 2, value: 20 }
]);

// T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

T1.test.updateOne({ _id: 1 }, { $set: { value: 101 } });
T2.test.find();

[ { _id: 1, value: 10 }, { _id: 2, value: 20 } ]

T1的未提交更改对T2不可见。

1
2
3
4
5
T1.test.updateOne({ _id: 1 }, { $set: { value: 11 } });
session1.commitTransaction();  // T1提交
T2.test.find();

[ { _id: 1, value: 10 }, { _id: 2, value: 20 } ]

T1的已提交更改对T2仍然不可见,因为它发生在T2开始之后。 这与大多数多版本并发控制SQL数据库不同。为了最小化等待冲突的性能影响,它们在读已提交中在每个语句之前重置读取时间,因为允许幻读。在这个例子中,它们会显示新提交的值。 MongoDB从不这样做;读取时间始终是事务的开始,并且不会发生幻读异常。然而,它不会等待查看冲突是否解决或必须因死锁而失败,而是立即失败以让应用程序重试。

MongoDB防止循环信息流(G1c)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// init
use test_db;
db.test.drop();
db.test.insertMany([
  { _id: 1, value: 10 },
  { _id: 2, value: 20 }
]);

// T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

T1.test.updateOne({ _id: 1 }, { $set: { value: 11 } });
T2.test.updateOne({ _id: 2 }, { $set: { value: 22 } });
T1.test.find({ _id: 2 });

[ { _id: 2, value: 20 } ]
T2.test.find({ _id: 1 });

[ { _id: 1, value: 10 } ]
session1.commitTransaction();
session2.commitTransaction();

在两个事务中,未提交的更改对其他人不可见。

MongoDB防止观察到的事务消失(OTV)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// init
use test_db;
db.test.drop();
db.test.insertMany([
  { _id: 1, value: 10 },
  { _id: 2, value: 20 }
]);

// T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T3
const session3 = db.getMongo().startSession();
const T3 = session3.getDatabase("test_db");
session3.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

T1.test.updateOne({ _id: 1 }, { $set: { value: 11 } });
T1.test.updateOne({ _id: 2 }, { $set: { value: 19 } });
T2.test.updateOne({ _id: 1 }, { $set: { value: 12 } });

MongoServerError[WriteConflict]: Caused by :: Write conflict during plan execution and yielding is disabled. :: Please retry your operation or multi-document transaction.

此异常通过显式事务的失败冲突防止。使用隐式单文档事务,它将不得不等待冲突事务结束。

MongoDB防止谓词多前驱(PMP)

在SQL数据库中,此异常需要快照隔离级别,因为读已提交使用每个语句的不同读取时间。然而,我可以在MongoDB中使用’majority’读关注显示它,仅需要’snapshot’来获得跨分片快照一致性。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// init
use test_db;
db.test.drop();
db.test.insertMany([
  { _id: 1, value: 10 },
  { _id: 2, value: 20 }
]);

// T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

T1.test.find({ value: 30 }).toArray();

[]
T2.test.insertOne(  { _id: 3, value: 30 }  );
session2.commitTransaction();
T1.test.find({ value: { $mod: [3, 0] } }).toArray();

[]

新插入的行不可见,因为它由T2在T1开始后提交。

Martin Kleppmann的测试包括一些带有删除语句和写入谓词的变体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// init
use test_db;
db.test.drop();
db.test.insertMany([
  { _id: 1, value: 10 },
  { _id: 2, value: 20 }
]);

// T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

T1.test.updateMany({}, { $inc: { value: 10 } });
T2.test.deleteMany({ value: 20 });

MongoServerError[WriteConflict]: Caused by :: Write conflict during plan execution and yielding is disabled. :: Please retry your operation or multi-document transaction.

由于它是显式事务,而不是阻塞,删除检测到冲突并引发可重试异常以防止异常。与PostgreSQL相比,它在可重复读中防止这种情况,它节省了失败前的等待时间,但要求应用程序实现重试逻辑。

MongoDB防止丢失更新(P4)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// init
use test_db;
db.test.drop();
db.test.insertMany([
  { _id: 1, value: 10 },
  { _id: 2, value: 20 }
]);

// T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

T1.test.find({ _id: 1 });

[ { _id: 1, value: 10 } ]
T2.test.find({ _id: 1 });

[ { _id: 1, value: 10 } ]
T1.test.updateOne({ _id: 1 }, { $set: { value: 11 } });
T2.test.updateOne({ _id: 1 }, { $set: { value: 11 } });

MongoServerError[WriteConflict]: Caused by :: Write conflict during plan execution and yielding is disabled. :: Please retry your operation or multi-document transaction.

由于它是显式事务,更新不等待并引发可重试异常,因此不可能在不等待其完成的情况下覆盖其他更新。

MongoDB防止读偏斜(G-single)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// init
use test_db;
db.test.drop();
db.test.insertMany([
  { _id: 1, value: 10 },
  { _id: 2, value: 20 }
]);

// T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

T1.test.find({ _id: 1 });

[ { _id: 1, value: 10 } ]
T2.test.find({ _id: 1 });

[ { _id: 1, value: 10 } ]
T2.test.find({ _id: 2 });

[ { _id: 2, value: 20 } ]
T2.test.updateOne({ _id: 1 }, { $set: { value: 12 } });
T2.test.updateOne({ _id: 2 }, { $set: { value: 18 } });
session2.commitTransaction();
T1.test.find({ _id: 2 });

[ { _id: 2, value: 20 } ]

在读已提交隔离的SQL数据库中,读偏斜异常可能显示值18。然而,MongoDB通过在整个事务中一致地读取值20来避免此问题,因为它读取事务开始时的数据。

Martin Kleppmann的测试包括一个带有谓词依赖的变体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// init
use test_db;
db.test.drop();
db.test.insertMany([
  { _id: 1, value: 10 },
  { _id: 2, value: 20 }
]);

// T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

T1.test.findOne({ value: { $mod: [5, 0] } });

{ _id: 1, value: 10 }
T2.test.updateOne({ value: 10 }, { $set: { value: 12 } });
session2.commitTransaction();
T1.test.find({ value: { $mod: [3, 0] } }).toArray();

[]

未提交的值12(3的倍数)对之前开始的事务不可见。

另一个测试包括一个带有删除语句中写入谓词的变体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// init
use test_db;
db.test.drop();
db.test.insertMany([
  { _id: 1, value: 10 },
  { _id: 2, value: 20 }
]);

// T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

T1.test.find({ _id: 1 });

[ { _id: 1, value: 10 } ]
T2.test.find();

[ { _id: 1, value: 10 }, { _id: 2, value: 20 } ]
T2.test.updateOne({ _id: 1 }, { $set: { value: 12 } });
T2.test.updateOne({ _id: 2 }, { $set: { value: 18 } });
session2.commitTransaction();
T1.test.deleteMany({ value: 20 });

MongoServerError[WriteConflict]: Caused by :: Write conflict during plan execution and yielding is disabled. :: Please retry your operation or multi-document transaction.

此读偏斜异常通过在写入具有来自另一个事务的未提交更改的文档时的失败冲突行为防止。

写偏斜(G2-item)必须由应用程序管理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// init
use test_db;
db.test.drop();
db.test.insertMany([
  { _id: 1, value: 10 },
  { _id: 2, value: 20 }
]);

// T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "majority" },
  writeConcern: { w: "majority" }
});

// T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority" }
});

T1.test.find({ _id: { $in: [1, 2] } })

[ { _id: 1, value: 10 }, { _id: 2, value: 20 } ]
T2.test.find({ _id: { $in: [1, 2] } })

[ { _id: 1, value: 10 }, { _id: 2, value: 20 } ]
T2.test.updateOne({ _id: 1 }, { $set: { value: 11 } });
T2.test.updateOne({ _id: 2 }, { $set: { value: 21 } });
session1.commitTransaction();
session2.commitTransaction();

MongoDB不检测读/写冲突,当一个事务读取了另一个事务更新的值,然后写入可能依赖于该值的某些内容时。读关注不提供可序列化保证。这种隔离需要在读取期间获取范围或谓词锁,过早地执行会妨碍设计为可扩展的数据库的性能。

对于需要避免这种情况的事务,应用程序可以通过更新已读取文档中的字段将读/写冲突转换为写/写冲突,以确保其他事务不修改它。或者在更新时重新检查值。

反依赖循环(G2)必须由应用程序管理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// init
use test_db;
db.test.drop();
db.test.insertMany([
  { _id: 1, value: 10 },
  { _id: 2, value: 20 }
]);

// T1
const session1 = db.getMongo().startSession();
const T1 = session1.getDatabase("test_db");
session1.startTransaction({
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority" }
});

// T2
const session2 = db.getMongo().startSession();
const T2 = session2.getDatabase("test_db");
session2.startTransaction({
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority" }
});

T1.test.find({ value: { $mod: [3, 0] } }).toArray();

[]
T2.test.find({ value: { $mod: [3, 0] } }).toArray();

[]
T1.test.insertOne(  { _id: 3, value: 30 }  );
T1.test.insertOne(  { _id: 4, value: 42 }  );
session1.commitTransaction();
session2.commitTransaction();
T1.test.find({ value: { $mod: [3, 0] } }).toArray();

[ { _id: 3, value: 30 }, { _id: 4, value: 42 } ]

未检测到读/写冲突,两个事务都能够写入,即使它们可能依赖于已被另一个事务修改的先前读取。MongoDB不跨读取和写入调用获取锁。如果您运行写入依赖于读取的多文档事务,应用程序必须显式写入读取集以检测写入冲突并避免异常。

所有这些测试基于https://github.com/ept/hermitage。关于MongoDB事务的更多信息在2020年的MongoDB多文档ACID事务白皮书中。

虽然文档模型在单个文档匹配业务事务时提供简单性和性能,但MongoDB支持具有快照隔离的多语句事务,类似于许多使用多版本并发控制(MVCC)的SQL数据库,但倾向于失败冲突而不是等待。尽管围绕NoSQL或基于旧版本的过时神话存在,其事务实现是健壮的,并有效防止常见的事务异常。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计