MongoDB事务开发

MongoDB事务开发

杰子学编程 70 2022-06-01

MongoDB (四):事务开发

一、什么是writeConcern?

writeConcern 决定一个写操作落到多少个节点上才算成功。writeConcern 的取值包括:

  • 0:发起写操作,不关心是否成功;
  • 1~集群最大数据节点数:写操作需要被复制到指定节点数才算成功;
  • majority:写操作需要被复制到大多数节点上才算成功。
  • all: 全部节点。

发起写操作的程序将阻塞到写操作到达指定的节点数为止。

1.1、默认行为

3节点复制集不作任何特别设定(默认值):

screenshot-20220409-230511

当我们发起一个请求时(新增,修改,删除),primary节点收到这个请求,直接返回成功。此时可能该数据还未落盘,只在内存中。同时,primary节点会异步的将x=1数据发送给Secondary1,Secondary2。

这样就会存在一种情况,我的primary节点还未将x=1同步给其他两个节点,primary宕机了。在其他两个节点中会选取一个新的主节点,而在这个主节点中并没有x=1这个数据,这样我们在访问MongoDB的时候会发现x=1这个数据不存在。造成丢数据的情况。

实际上x=1这个条数据并没有丢失,在primary节点中写入了一个叫bolog文件中,但是由于主节点宕机,无法获取到该数据。

怎么解决这种情况呢?我们只需要设置下writeConcern即可。

1.2、w: “majority”

大多数节点确认模式:

screenshot-20220409-231652

我们可以直接设置w: “majority”,但是我们设置该参数后,必须等多个节点确认后,才返回成功。首先我们写完primary,后并没有立即返回,而是该写线程继续等待,等该数据复制到Secondary1或Secondary2后,会返回一个响应,主节点拿到响应后,主节点返回写入数据完成。

此时数据在至少两个节点写入成功,此时如果primary宕机,Secondary1成为主节点,该节点存在x=1这条数据,这样防止主节点宕机后丢失数据的可能性;

1.3、w:“all”

全部节点确认模式

screenshot-20220409-232300

最安全的做法,缺点:如果一个节点数据写入失败,整个事务不成功。

1.4: j:true

writeConcern 可以决定写操作到达多少个节点才算成功,journal 则定义如何才算成功。取值包括:

  • true: 写操作落到 journal 文件中才算成功;
  • false: 写操作到达内存即算作成功。

screenshot-20220409-232603

journal即日志,它的作用是在数据库宕机的时候快速恢复刚刚的写操作,一般先写日志,在落盘。当我们设置j:true的时候,我们强制写完journal后,在返回成功响应。这样我们可以进一步加强数据安全性。

1.5、注意事项

  • 虽然多于半数的 writeConcern 都是安全的,但通常只会设置 majority,因为这是等待写入延迟时间最短的选择;
  • 不要设置 writeConcern 等于总节点数,因为一旦有一个节点故障,所有写操作都将失败;
  • writeConcern 虽然会增加写操作延迟时间,但并不会显著增加集群压力,因此无论是否等待,写操作最终都会复制到所有节点上。设置 writeConcern 只是让写操作等待复制后再返回而已;
  • 应对重要数据应用 {w: “majority”},普通数据可以应用 {w: 1} 以确保最佳性能。

二、读操作事务:readPreference

在读取数据的过程中我们需要关注以下两个问题:

  • 从哪里读?
  • 什么样的数据可以读?

第一个问题是是由 readPreference 来解决

第二个问题则是由 readConcern 来解决

2.1、什么是 readPreference?

screenshot-20220409-234300

readPreference 决定使用哪一个节点来满足正在发起的读请求。可选值包括:

  • primary: 只选择主节点;
  • primaryPreferred:优先选择主节点,如果不可用则选择从节点;
  • secondary:只选择从节点;
  • secondaryPreferred:优先选择从节点,如果从节点不可用则选择主节点;
  • nearest:选择最近的节点;

readPreference 场景举例

  • 用户下订单后马上将用户转到订单详情页——primary/primaryPreferred。因为此时从节点可能还没复制到新订单;
  • 用户查询自己下过的订单——secondary/secondaryPreferred。查询历史订单对时效性通常没有太高要求;
  • 生成报表——secondary。报表对时效性要求不高,但资源需求大,可以在从节点单独处理,避免对线上用户造成影响;
  • 将用户上传的图片分发到全世界,让各地用户能够就近读取——nearest。每个地区的应用选择最近的节点读取数据。

2.2、readPreference 与 Tag

readPreference 只能控制使用一类节点。Tag 则可以将节点选择控制到一个或几个节点。考虑以下场景:

  • 一个 5 个节点的复制集;
  • 3 个节点硬件较好,专用于服务线上客户;
  • 个节点硬件较差,专用于生成报表;

可以使用 Tag 来达到这样的控制目的:

  • 为 3 个较好的节点打上 {purpose: “online”};
  • 2 个较差的节点打上 {purpose: “analyse”};
  • 在线应用读取时指定 online,报表读取时指定 reporting。

screenshot-20220409-234739

更多配置参考:Read Preference — MongoDB Manual

注意事项

  • 指定 readPreference 时也应注意高可用问题。例如将 readPreference 指定 primary,则发生故障转移不存在 primary 期间将没有节点可读。如果业务允许,则应选择 primaryPreferred;
  • 使用 Tag 时也会遇到同样的问题,如果只有一个节点拥有一个特定 Tag,则在这个节点失效时将无节点可读。这在有时候是期望的结果,有时候不是。例如
    • 如果报表使用的节点失效,即使不生成报表,通常也不希望将报表负载转移到其他节点上,此时只有一个节点有报表 Tag 是合理的选择;
    • 如果线上节点失效,通常希望有替代节点,所以应该保持多个节点有同样的 Tag;
  • Tag 有时需要与优先级、选举权综合考虑。例如做报表的节点通常不会希望它成为主节点,则优先级应为 0。

2.3、readConcern

2.3.1、什么是 readConcern?

readPreference 选择了指定的节点后,readConcern 决定这个节点上的数据哪些是可读的,类似于关系数据库的隔离级别。可选值包括:

  • available:读取所有可用的数据;
  • local:读取所有可用且属于当前分片的数据; 默认设置
  • majority:读取在大多数节点上提交完成的数据; 数据读一致性的充分保证,可能你最需要关注的
  • linearizable:可线性化读取文档;增强处理 majority 情况下主节点失联时候的例外情况
  • snapshot:读取最近快照中的数据; 最高隔离级别,接近于 Seriazable

2.3.2、readConcern: local 和 available

在复制集中 local 和 available 是没有区别的。两者的区别主要体现在分片集上。考虑以下场景:

  • 一个 chunk x 正在从 shard1 向 shard2 迁移;
  • 整个迁移过程中 chunk x 中的部分数据会在 shard1 和 shard2 中同时存在,但源分片 shard1仍然是chunk x 的负责方:
    • 所有对 chunk x 的读写操作仍然进入 shard1;
    • config 中记录的信息 chunk x 仍然属于 shard1;
  • 此时如果读 shard2,则会体现出 local 和 available 的区别:
    • local:只取应该由 shard2 负责的数据(不包括 x);
    • available:shard2 上有什么就读什么(包括 x);

screenshot-20220409-235300

注意事项:

  • 虽然看上去总是应该选择 local,但毕竟对结果集进行过滤会造成额外消耗。在一些无关紧要的场景(例如统计)下,也可以考虑 available;
  • MongoDB <=3.6 不支持对从节点使用 {readConcern: “local”};
  • 从主节点读取数据时默认 readConcern 是 local,从从节点读取数据时默认readConcern 是 available(向前兼容原因)。

2.3.3、readConcern: majority

只读取大多数据节点上都提交了的数据。考虑如下场景:

  • 集合中原有文档 {x: 0}; 将x值更新为 1;

screenshot-20220409-235510

如果在各节点上应用{readConcern: “majority”} 来读取数据:

screenshot-20220409-235543

2.3.3.1、readConcern: majority 的实现方式

考虑 t3 时刻的 Secondary1,此时:

  • 对于要求 majority 的读操作,它将返回 x=0;
  • 对于不要求 majority 的读操作,它将返回 x=1;

screenshot-20220409-235745

如何实现?

节点上维护多个 x 版本,MVCC 机制,MongoDB 通过维护多个快照来链接不同的版本:

  • 每个被大多数节点确认过的版本都将是一个快照;
  • 快照持续到没有人使用为止才被删除;

使用 local 参数,则可以直接查询到写入数据

使用 majority,只能查询到已经被多数节点确认过的数据

2.3.4、readConcern: majority 与脏读

MongoDB 中的回滚:

  • 写操作到达大多数节点之前都是不安全的,一旦主节点崩溃,而从节还没复制到该次操作,刚才的写操作就丢失了;
  • 把一次写操作视为一个事务,从事务的角度,可以认为事务被回滚了。

所以从分布式系统的角度来看,事务的提交被提升到了分布式集群的多个节点级别的 “提交”,而不再是单个节点上的“提交”。

在可能发生回滚的前提下考虑脏读问题:

  • 如果在一次写操作到达大多数节点前读取了这个写操作,然后因为系统故障该操作回滚了,则发生了脏读问题;

使用 {readConcern: “majority”} 可以有效避免脏读。

2.3.5、readConcern: 如何实现安全的读写分离

考虑如下场景:

向主节点写入一条数据,立即从从节点读取这条数据。如何保证自己能够读到刚刚写入的数据?

下述方式有可能读不到刚写入的订单:

db.orders.insert({ oid: 101, sku: ”kite", q: 1})
db.orders.find({oid:101}).readPref("secondary")

使用 writeConcern + readConcern majority 来解决:

db.orders.insert({ oid: 101, sku: "kiteboar", q: 1}, {writeConcern:{w: "majority”}})
db.orders.find({oid:101}).readPref(“secondary”).readConcern("majority")

readConcern 主要关注读的隔离性, ACID 中的 Isolation, 但是是分布式数据库里特有的概念

readCocnern: majority 对应于事务中隔离级别中的Read Committed

2.3.6、readConcern: linearizable

只读取大多数节点确认过的数据。和 majority 最大差别是保证绝对的操作线性顺序,在写操作自然时间后面的发生的读,一定可以读到之前的写:

  • 只对读取单个文档时有效;
  • 可能导致非常慢的读,因此总是建议配合使用 maxTimeMS;

screenshot-20220410-103819

2.3.7、readConcern: snapshot

{readConcern: “snapshot”} 只在多文档事务中生效。将一个事务的 readConcern设置为 snapshot,将保证在事务中的读

  • 不出现脏读;
  • 不出现不可重复读;
  • 不出现幻读。

因为所有的读都将使用同一个快照,直到事务提交为止该快照才被释放。

三、事务开发:多文档事务

MongoDB 虽然已经在 4.2 开始全面支持了多文档事务,但并不代表大家应该毫无节制地使用它。相反,对事务的使用原则应该是:能不用尽量不用。通过合理地设计文档模型,可以规避绝大部分使用事务的必要性,为什么?事务 = 锁,节点协调,额外开销,性能影响。除非在少量核心场景下才使用事务,其他场景尽可能通过模式设计解决这个问题。

3.1、MongoDB ACID 多文档事务支持

screenshot-20220410-105922

MongoDB 多文档事务的使用方式与关系数据库非常相似:

try (ClientSession clientSession = client.startSession()) {
    clientSession.startTransaction();
    collection.insertOne(clientSession, docOne);
    collection.insertOne(clientSession, docTwo);
    clientSession.commitTransaction();
}

3.2、事务的隔离级别

  • 事务完成前,事务外的操作对该事务所做的修改不可访问;
  • 如果事务内使用 {readConcern: “snapshot”},则可以达到可重复读Repeatable Read
db.tx.insertMany([{ x: 1 }, { x: 2 }]);
var session = db.getMongo().startSession(); 
session.startTransaction();
var coll = session.getDatabase('test').getCollection("tx");
coll.updateOne({x: 1}, {$set: {y: 1}}); //事务内
coll.findOne({x: 1}); //事务内
db.tx.findOne({x: 1}); //事务外查询
session.abortTransaction();

screenshot-20220410-112512

3.2.2、实验:可重复读 Repeatable Read

var session = db.getMongo().startSession();
session.startTransaction({
  readConcern: {level: "snapshot"}, //指定隔离级别
  writeConcern: {w: "majority"}}); //
var coll = session.getDatabase('test').getCollection("tx");
coll.findOne({x: 1}); // 返回:{x: 1} 事务内读取
db.tx.updateOne({x: 1}, {$set: {y: 1}});  事务外修改
db.tx.findOne({x: 1}); // 返回:{x: 1, y: 1} 事务外修改
coll.findOne({x: 1}); // 返回:{x: 1} 事务内读取
session.abortTransaction();

screenshot-20220410-113418

3.3、事务写机制

MongoDB 的事务错误处理机制不同于关系数据库:

  • 当一个事务开始后,如果事务要修改的文档在事务外部被修改过,则事务修改这个文档时会触发 Abort 错误,因为此时的修改冲突了;
  • 这种情况下,只需要简单地重做事务就可以了;
  • 如果一个事务已经开始修改一个文档,在事务以外尝试修改同一个文档,则事务以外的修改会等待事务完成才能继续进行。

注意事项

  • 可以实现和关系型数据库类似的事务场景
  • 必须使用与 MongoDB 4.2 兼容的驱动;
  • 事务默认必须在 60 秒(可调)内完成,否则将被取消;
  • 涉及事务的分片不能使用仲裁节点;
  • 事务会影响 chunk 迁移效率。正在迁移的 chunk 也可能造成事务提交失败(重试即可);
  • 多文档事务中的读操作必须使用主节点读;
  • readConcern 只应该在事务级别设置,不能设置在每次读写操作上。

更多内容请求关注公众号:杰子学编程


# Java # SpringBoot # MongoDB # 事务