MongoDB多文档事务详解

多文档事务详解

官方中文文档

事务简介

数据库事务需要包含4个基本特性,即常说的ACID,具体如下:

  • 原子性(atomicity):事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行。
  • 一致性(consistency):事务应确保数据库的状态从一个一致状态转变为另一个一致状态。一致状态的含义是数据库中的数据应满足完整性约束。
  • 隔离性(isolation):多个事务并发执行时,一个事务的执行不应影响其他事务的执行。
  • 持久性(durability):已被提交的事务对数据库的修改应该是永久性的。



在MongoDB中,对单文档的操作是原子的。

由于可以在单个文档中使用内嵌文档或数组来获取数据之间的关系,而不必跨多个文档或集合进行范式化。

对于那些需要对多个文档(在单个或多个集合中)进行原子性读写的场景,MongoDB支持多文档事务。MongoDB4.2版本全面支持多文档事务。



MongoDB对事务的支持

事务属性支持程度
Atomocity 原子性单表单文档 : 1.x 就支持;
复制集多表多行:4.0;
分片集群多表多行:4.2
Consistency 一致性writeConcern, readConcern (3.2)
Isolation 隔离性readConcern (3.2)
Durability 持久性Journal日志机制 和 Replication副本集机制



writeConcern

官方中文文档

writeConcern 决定一个写操作落到多少个节点上才算成功。

语法格式:

{ w: <value>, j: <boolean>, wtimeout: <number> }
  • w 选项会请求确认写入操作已传播到指定数量的 mongod 实例或带有指定标签的 mongod 实例。 值可以为[majority,数字]
  • j 选项以请求确认写入操作已写入磁盘上日志
  • wtimeout 选项用于指定时间限制,以防止写入操作无限期阻塞。



mongodb5.0版本开始,默认是w: majority。而当w: majority时,j的默认值取决于writeConcernMajorityJournalDefault的值,它默认为true,也就是说j: true确认时要求对磁盘上日志进行写入操作。



w: 数据写入到number个节点才向用客户端确认

  • {w: 0} 对客户端的写入不需要发送任何确认,适用于性能要求高,但不关注正确性的场景
  • {w: 1} 默认的writeConcern,数据写入到Primary就向客户端发送确认
  • {w: “majority”} 数据写入到副本集大多数成员后向客户端发送确认,适用于对数据安全性要求比较高的场景,该选项会降低写入性能

在这里插入图片描述

在这里插入图片描述



j: 写入操作的journal持久化后才向客户端确认

j的值说明
j 未指定确认取决于 writeConcernMajorityJournalDefault
如果为 true,则确认时要求对磁盘上日志 (j: true) 进行写入操作。writeConcernMajorityJournalDefault 默认为 true
如果为 false,则确认需要在内存中进行写入操作(j: false)。
j: true确认时要求对磁盘上日志进行写入操作。
j: false确认要求在内存中进行写入操作。

在这里插入图片描述



wtimeout: 写入超时时间,仅w的值大于1时有效。

  • 当指定{w: }时,数据需要成功写入number个节点才算成功,如果写入过程中有节点故障,可能导致这个条件一直不能满足,从而一直不能向客户端发送确认结果,针对这种情况,客户端可设置wtimeout选项来指定超时时间,当写入过程持续超过该时间仍未结束,则认为写入失败。



测试

副本集中有三个节点,将其中一个节点改为延迟节点后进行测试

db.user.insertOne({name:"李四"},{writeConcern:{w:"majority"}})

#配置延迟节点
cfg = rs.conf()
cfg.members[2].priority = 0
cfg.members[2].hidden = true
cfg.members[2].secondaryDelaySecs = 60
rs.reconfig(cfg)

# 等待延迟节点写入数据后才会响应
db.user.insertOne({name:"王五"},{writeConcern:{w:3}})

# 超时写入失败
db.user.insertOne({name:"小明"},{writeConcern:{w:3,wtimeout:3000}})



注意事项

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



readPreference

readPreFerence决定使用哪一个节点进行读请求



可选值包括:

  • primary: 只选择主节点,默认模式;
  • primaryPreferred:优先选择主节点,如果主节点不可用则选择从节点;
  • secondary:只选择从节点;
  • secondaryPreferred:优先选择从节点, 如果从节点不可用则选择主节点;
  • nearest:根据客户端对节点的 Ping 值判断节点的远近,选择从最近的节点读取。

在这里插入图片描述



场景举例

  • 用户订单下单后马上跳转至订单详情页——primary/primaryPreferred。因为此时从节点可能还没复制到新订单;

  • 用户查询自己下过的订单——secondary/secondaryPreferred。查询历史订单对 时效性通常没有太高要求;

  • 生成报表——secondary。报表对时效性要求不高,但资源需求大,可以在从节点单独处理,避免对线上用户造成影响;

  • 将用户上传的图片分发到全世界,让各地用户能够就近读取——nearest。每个地区的应用选择最近的节点读取数据。



配置

通过 MongoDB 的连接串参数:

# 连接参数后面添加readPreference=secondary 表示从备节点读取数据
mongodb://host1:27107,host2:27107,host3:27017/?replicaSet=rs0&readPreference=secondary



通过 MongoDB 驱动程序 API:

MongoCollection.withReadPreference(ReadPreference readPref)



Mongo Shell:

db.collection.find().readPref( "secondary" )



从节点读取测试

主节点写入{count:1} , 观察该条数据在各个节点均可见

[root@localhost ~]# mongosh --host rs0/localhost:28017 -u hushang -p 123456

rs0 [primary] test> db.user.insertOne({name: "hs2"},{writeConcern: {w: 1}})

在primary节点中调用readPref(“secondary”)查询从节点用直连方式(mongosh localhost:28017)会查到数据,需要通过mongosh --host rs0/localhost:28017方式连接复制集,参考: https://jira.mongodb.org/browse/SERVER-22289



在两个从节点分别执行 db.fsyncLock() 来锁定写入(同步)

# 解除锁定的方法为 db.fsyncUnlock()
rs0 [direct: secondary] test> rs.secondaryOk()
rs0 [direct: secondary] test> db.fsyncLock()



主节点写入一条新数据

# 主节点插入一条文档
rs0 [primary] test> db.user.insertOne({name: "hs3"},{writeConcern: {w: 1}})
# 主节点能查询到
rs0 [primary] test> db.user.find()
# 从节点查询不到
rs0 [primary] test> db.user.find().readPref("secondary")

# 从节点也查询一次,因为上面开启了同步写入锁定,索引新插入的数据从节点是读取不到的
rs0 [direct: secondary] test> db.user.find()



解除从节点锁定 db.fsyncUnlock()

rs0 [direct: secondary] test> db.fsyncUnlock()



此时主节点再查询就能查询到数据了

rs0 [primary] test> db.user.find().readPref("secondary")



扩展:Tag

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

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



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

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



在这里插入图片描述



# 为复制集节点添加标签
conf = rs.conf()
conf.members[1].tags = { purpose: "online"}
conf.members[2].tags = { purpose: "analyse"}
rs.reconfig(conf)

#查询
db.collection.find({}).readPref( "secondary", [ {purpose: "online"} ] )



模拟,我先把一台节点进行同步写入锁定

rs0 [direct: secondary] test> db.fsyncLock()

现在插入一条数据后再进行查询

在这里插入图片描述



注意事项

  • 指定 readPreference 时也应注意高可用问题。例如将 readPreference 指定 primary,则发生故障转移不存在 primary 期间将没有节点可读。如果业务允许,则应选择 primaryPreferred;

  • 使用 Tag 时也会遇到同样的问题,如果只有一个节点拥有一个特定 Tag,则在这个节点失效时将无节点可读。这在有时候是期望的结果,有时候不是。例如:

    • 如果报表使用的节点失效,即使不生成报表,通常也不希望将报表负载转移到其他节点上,此时只有一个节点有报表 Tag 是合理的选择;

    • 如果线上节点失效,通常希望有替代节点,所以应该保持多个节点有同样的 Tag;

  • Tag 有时需要与优先级、选举权综合考虑。例如做报表的节点通常不会希望它成为主节点,则优先级应为 0。



readConcern

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

  • available:读取所有可用的数据;
  • local:读取所有可用且属于当前分片的数据;
  • majority:读取在大多数节点上提交完成的数据;
  • linearizable:可线性化读取文档,仅支持从主节点读;
  • snapshot:读取最近快照中的数据,仅可用于多文档事务;



local 和 available

在复制集中 local 和 available 是没有区别的,两者的区别主要体现在分片集上。

当发生数据迁移时,分片A把一部分数据迁移至分片B,迁移进行中 此时分片B存在了一部分的数据。当readConcern为available时这一部分数据是能读取到的,当readConcern为local时这一部分数据读取不到。



majority

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

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

在这里插入图片描述

对于readConcern为majority时,primary必须要到t3时刻才能读取到{x: 1}的值;secondary1必须要到t5时刻才能读取到{x: 1}的值;secondary必须要到t6时刻



如何实现:

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

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



测试readConcern: majority vs local

# 将复制集中的两个从节点使用 db.fsyncLock() 锁住写入(模拟同步延迟)
rs0 [direct: secondary] test> db.fsyncLock()
# 主节点插入一条数据
rs0 [primary] test> db.user.insert({name: "hs"},{writeConcern:{w:1}})
DeprecationWarning: Collection.insert() is deprecated. Use insertOne, insertMany, or bulkWrite.
{
  acknowledged: true,
  insertedIds: { '0': ObjectId("66a9bc98765490df764272fc") }
}

# readConcern为local的查询结果,能查询到数据
rs0 [primary] test> db.user.find().readConcern("local")
[ { _id: ObjectId("66a9bc98765490df764272fc"), name: 'hs' } ]

# readConcern为majority的查询结果,查询不到数据
# 使用 majority,只能查询到已经被多数节点确认过的数据
rs0 [primary] test> db.user.find().readConcern("majority")

rs0 [primary] test> 



readConcern: majority 与脏读

MongoDB 中的回滚:

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

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



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

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

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



如何安全的读写分离

考虑如下场景:

  1. 向主节点写入一条数据;
  2. 立即从从节点读取这条数据。

思考: 如何保证自己能够读到刚刚写入的数据?

在这里插入图片描述



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

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:"kite",q:1},{writeConcern:{w:"majority"}})
db.orders.find({oid:101}).readPref("secondary").readConcern("majority")



linearizable

只读取大多数节点确认过的数据。和 majority 最大差别是保证绝对的操作线性顺序

  • 在写操作自然时间后面的发生的读,一定可以读到之前的写
  • 只对读取单个文档时有效;
  • 可能导致非常慢的读,因此总是建议配合使用 maxTimeMS;

在这里插入图片描述



snapshot

{readConcern: “snapshot”} 只在多文档事务中生效。

将一个事务的 readConcern 设置为 snapshot,将保证在事务中的读:

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

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



小结

  • available:读取所有可用的数据
  • local:读取所有可用且属于当前分片的数据,默认设置
  • majority:数据读一致性的充分保证,可能你最需要关注的
  • linearizable:增强处理 majority 情况下主节点失联时候的例外情况
  • snapshot:最高隔离级别,接近于关系型数据库的Serializable



事务隔离级别

开启事务语句

var session = db.getMongo().startSession()
session.startTransaction({ readConcern: {level: "majority"}, writeConcern: {w: "majority"}})
var coll = session.getDatabase("数据库名").getCollection("集合名")
# 之后的操作就通过coll变量去进行增删改查操作



事务完成前,事务外的操作对该事务所做的修改不可访问

db.tx.insertMany([{ x: 1 }, { x: 2 }])
var session = db.getMongo().startSession()
# 开启事务,readConcern默认的级别就是majority
session.startTransaction()

var coll = session.getDatabase("test").getCollection("tx")
# 注意这里使用的我上面定义的coll变量,而不是db.collection.XXX()了
# 事务内修改 {x:1, y:1}
coll.updateOne({x: 1}, {$set: {y: 1}})
#事务内查询 {x:1}
coll.findOne({x: 1})  //{x:1, y:1}

#事务外查询 {x:1}
db.tx.findOne({x: 1})  //{x:1}

#提交事务
session.commitTransaction()

# 或者回滚事务
session.abortTransaction()

在这里插入图片描述



如果事务内使用 {readConcern: “snapshot”},则可以达到可重复读 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}) 
db.tx.updateOne({x: 1}, {$set: {y: 2}})
db.tx.findOne({x: 1}) 
coll.findOne({x: 1})  #事务外查询

session.abortTransaction()



事务超时机制

在执行事务的过程中,如果操作太多,或者存在一些长时间的等待,则可能会产生如下异常:

rs0 [direct: primary] test> session.commitTransaction()
MongoServerError: Transaction with { txnNumber: 1 } has been aborted.

原因在于,默认情况下MongoDB会为每个事务设置1分钟的超时时间,如果在该时间内没有提交,就会强制将其终止。该超时时间可以通过transactionLifetimeLimitSecond变量设定。



事务错误处理机制

  • 两个事务修改同一个文档,晚修改的事务会报异常。异常 – 解决方案:重新开启事务即可

  • 如果一个事务已经开始修改一个文档,在事务以外尝试修改同一个文档,则事务以外的修改会等待事务完成才能继续进行。



测试

# 开启事务
var session = db.getMongo().startSession()
session.startTransaction({ readConcern: {level: "majority"}, writeConcern: {w: "majority"}})
var coll = session.getDatabase('test').getCollection("tx")



执行修改语句

# 事务外执行
db.tx.update({_id: ObjectId("66a9bfdb765490df764272fd")},{$set: {y: 2}})
db.tx.find()

# 事务内执行
coll.update({_id: ObjectId("66a9bfdb765490df764272fd")},{$set: {y: 3}})
coll.find()



提交事务

session.commitTransaction()



但是经过测试,我发现事务外和事务内其实并不存在等待阻塞的情况,但两个事务操作同一条数据的确会报错

在这里插入图片描述



在这里插入图片描述



在这里插入图片描述



注意事项

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



SpringBoot整合Mongodb事务操作



编程式事务

import com.mongodb.ReadConcern;
import com.mongodb.ReadPreference;
import com.mongodb.TransactionOptions;
import com.mongodb.WriteConcern;
import com.mongodb.client.ClientSession;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.Updates;
import org.bson.Document;
import org.junit.jupiter.api.Test;

/**
 * @Description: 多文档事务操作
 * @Author 胡尚
 * @Date: 2024/7/31 14:32
 */
public class TransactionTest extends LearnMongodbApplicationTest{

    @Test
    public void updateTest(){
        // 连接复制集
        MongoClient mongoClient = MongoClients.create("mongodb://hushang:123456@192.168.75.100:28017,192.168.75.100:28018,192.168.75.100:28019/test?authSource=admin&replicaSet=rs0");

        // 获取两个collection集合对象
        MongoCollection<Document> empCollection = mongoClient.getDatabase("test").getCollection("emp");
        MongoCollection<Document> eventsCollection = mongoClient.getDatabase("test").getCollection("events");

        // 事务操作配置
        TransactionOptions transactionOptions = TransactionOptions.builder()
                .writeConcern(WriteConcern.MAJORITY)
                .readPreference(ReadPreference.primary())
                .readConcern(ReadConcern.MAJORITY)
                .build();

        try(ClientSession clientSession = mongoClient.startSession()){
            // 开启事务
            clientSession.startTransaction();

            try{
                empCollection.updateOne(clientSession,
                        Filters.eq("_id", "66a9dbb8bb75fd99739d6aea"),
                        Updates.set("name", "hushang"));

                int i = 1/0;

                eventsCollection.insertOne(clientSession,
                        new Document("username", "hs1").append("status", new Document("new", "inactive").append("old", "Active")));

                // 提交事务
                clientSession.commitTransaction();
            } catch (Exception e){
                // 回滚事务
                clientSession.abortTransaction();
            }
        }
    }
}

进行测试,事务会正常回滚;如果把int i = 1/0;注释掉,则能看到正常事务提交的执行结果



声明式事务

配置事务管理器

package com.hs.learn.config;

import com.mongodb.ReadConcern;
import com.mongodb.ReadPreference;
import com.mongodb.TransactionOptions;
import com.mongodb.WriteConcern;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.MongoDatabaseFactory;
import org.springframework.data.mongodb.MongoTransactionManager;

/**
 * @Description: mongodb的配置类
 * @Author 胡尚
 * @Date: 2024/7/31 14:52
 */
@Configuration
public class MongodbConfig {

    @Bean
    public MongoTransactionManager transactionManager(MongoDatabaseFactory factory) {
        TransactionOptions transactionOptions = TransactionOptions.builder()
                .readPreference(ReadPreference.primary())
                .readConcern(ReadConcern.MAJORITY)
                .writeConcern(WriteConcern.MAJORITY)
                .build();

        return new MongoTransactionManager(factory, transactionOptions);
    }
}



编程测试service

package com.hs.learn.service;

import com.hs.learn.entity.Employee;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Date;

/**
 * @Description: 测试mongodb声明式事务
 * @Author 胡尚
 * @Date: 2024/7/31 14:58
 */
@Service
public class EmployeeService {

    @Autowired
    private MongoTemplate mongoTemplate;

    // 使用Spring的声明式事务,就需要使用@Transactional注解
    @Transactional(rollbackFor = Exception.class)
    public void addEmployee(){
        Employee employee1 = new Employee(1, "hushang1", 25, new Date(), "测试数据1");
        Employee employee2 = new Employee(2, "hushang2", 25, new Date(), "测试数据2");

        mongoTemplate.insert(employee1);
        int i = 1/0;
        mongoTemplate.insert(employee2);
    }
}



测试

@Autowired
EmployeeService employeeService;

@Test
public void test(){
    employeeService.addEmployee();

}

进行测试,事务会正常回滚;如果把int i = 1/0;注释掉,则能看到正常事务提交的执行结果

  • 12
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值