事务
-
MongoDB 在单文档操作中具有原子性,在多文档操作中就不再具有此特性,通常需要借助事务来实现 ACID 特性。
-
客户端对于事务的操作,都由 MongoDB Client Driver 实现提供相应的 API 接口。MongoDB 4.0 之后才支持事务,对于客户端驱动版本也要选择相对应版本。
-
MongoDB 在 4.0 版本支持了多文档事务,4.0 对应于复制集的多表、多行,后续又在 4.2 版本支持了分片集的多表、多行事务操作。
事务四大特性 ACID
-
原子性(Atomicity):事务必须是原子工作单元,对于其数据修改,要么全执行,要么全不执行。类似于 Redis 中我通常使用 Lua 脚本来实现多条命令操作的原子性。
-
一致性(Consistency):事务在完成时,必须使所有的数据都保持一致状态。
-
隔离性(Isolation):由并发事务所做的修改必须与任何其他并发事务所作的修改隔离(简而言之:一个事务执行过程中不应受其它事务影响。
-
持久性(Durability):事务完成之后,对于系统的影响是永久性的。
Session 会话
-
在以前的版本中,Mongod进程中的每一个请求会创建一个上下文(OperationContext),可以理解为一个单行事务,这个单行事务中对于数据、索引、oplog 的修改都是原子性的。
-
MongoDB 3.6 之后的 Session 本质上也是一个上下文,在这个 Session 会话中多个请求共享一个上下文,为多文档事务实现提供了基础。
Oplog 是用于存储 MongoDB 数据库所有数据的操作记录的(实际只记录增删改和一些系统命令操作,查是不会记录的),有点类似于 mysql 的 binlog 日志。
Oplog 的存在极大地方便了 MongoDB 副本集的各节点的数据同步,MongoDB 的主节点接收请求操作,然后在 Oplog 中记录操作,次节点异步地复制并应用这些操作。
事务函数
-
Session.startTransaction()
开启一个新的事务,之后即可进行 CRUD 操作。 -
Session.commitTransaction()
提交事务保存数据,在提交之前事务中的变更的数据对外是不可见的。 -
Session.abortTransaction()
事务回滚,例如,一部分数据更新失败,对已修改过的数据也进行回滚。 -
Session.endSession()
结束本次会话。
事务级别选项
readPreference
在一个事务操作中使用事务级别的 readPreference 来决定读取时从哪个节点读取。可方便的实现读写分离、就近读取策略。
-
primary 只从主节点读取,默认值。
-
primaryPreferred 优先选择主节点,不可用时选择从节点
-
secondary 只在从节点读取
-
secondaryPreferred 优先在从节点读取,从节点不可用时选择主节点。
-
nearest 选择附近节点
-
场景选择
- primary/primaryPreferred:适合于数据实时性要求较高的场景,例如,订单创建完毕直接跳转到订单详情,如果选择从节点读取,可能会造成主节点数据写入之后,从节点还未复制的情况,因为复制过程是一个异步的操作。
- secondary/secondaryPreferred:适应用于数据实时性要求不高的场景,例如,报表数据、历史订单。还可以减轻对主节点的压力。
writeConcern
事务使用事务级别的 writeConcern 来提交写操作,决定一个事务的写入成功与否要看 writeConcern 选项设置了几个节点,默认情况下为 1。
-
w:0 设置为 0 无需关注写入成功与否。
-
w:1 ~ 任意节点数 自定义节点数设置,复制集中不得大于最大节点数。默认情况下为 1,表示写入到 Primary 节点就开始往客户端发送确认写入成功。
-
w:"majority" 大多数节点成功原则,例如一个复制集 3 个节点,2 个节点成功就认为本次写入成功。
-
w:"all" 所以节点都成功,才认为写入成功。
-
j:true 默认情况 j:false,写操作到达内存算作完成。如果设置为 j:true,写操作只有到达 journal 文件才算成功。
-
wtimeout: 写入超时实践
-
使用示例
db.user.insert({name: "Jack"}, {writeConcern: {w: "majority"}})
readConcern
readConcern 决定该节点的哪些数据是可读的。主要保证事务中的隔离性,避免脏读。
-
available:读取所有可用的数据。
-
local:仅读取当前分片的数据。
-
majority:读取在大多数节点上提交完成的数据。
-
snapshot:读取最近快照中的数据。
-
使用示例
db.user.find().readConcern("majority")
readConcern 总结
MongoDB 的 readConcern 默认情况下是脏读,例如,用户在主节点读取一条数据之后,该节点未将数据同步至其它从节点,就因为异常挂掉了,待主节点恢复之后,将未同步至其它节点的数据进行回滚,就出现了脏读。
readConcern 级别的 majority 可以保证读到的数据已经落入到大多数节点。所以说保证了事务的隔离性,所谓隔离性也是指事务内的操作,事务外是看不见的。
读写分离实践
一个典型的应用场景是用户写入订单数据(数据写入 Primary),立即调用查询接口,由于采用读写分离模式,链接字符串设置 readPreference=secondaryPreferred 订单写入主节点之后并不能保证数据立即同步到从节点,若此时直接由从节点读取数据, 偶尔会出现订单数据无法找到,用户就会感觉很奇怪,明明下了订单,却又查找不到,造成一些异常订单。
一种导致下单之后再次查找丢失订单的的写法如下:
db.order.insert({"id": "123456789"})
db.order.find({"id": "123456789"}).readPref("secondaryPreferred")
-
解决方案一:
设置 readPreference=primary,将复制集的节点读取由从节点转换为主节点。
这种方式一个缺点是数据量大了之后会增加主节点的压力,也就没有了主从分离的模式。
-
解决方案二:
使用 writeConcern、readConcern 组合来解决,即保证读写分离模式,也保证了数据的一致性。
// 写入时保证大多数节点写入成功 db.order.insert({"id": "123456789"}, {writeConern: {w: "majority"}}) // 读取时保证这条数据已在大多数节点存在 db.order.find({"id": "123456789"}).readPreference("secondaryPreferred").readConcern("majority")
注意
-
在一个事务操作中 readPreference 必须设置为 primary 节点,不能是 secondary 节点。
-
在事务中,只能对现有集合指定读和写(CRUD)操作。例如,多文档事务不能包含会导致创建新集合的插入操作。
-
若要将读写操作与事务关联,必须将 session 传递给事务中的每个操作。
MongoDB 事务在 Nodejs 中的实践
数据模型
// goods
db.goods.insert({ "goodId" : "g1000", "name" : "测试商品1", "stock" : 2, "price" : 100 })
// order_goods
db.order_goods.insert({ id: "o10000", goodId: "g1000", price: 100 })
Node.js 操作 MongoDB 原生 API 实现
const db = require('./db');
const testTransaction = async (goodId) => {
const client = await db.dbInstance();
const transactionOptions = {
readConcern: { level: 'majority' },
writeConcern: { w: 'majority' },
readPreference: 'primary',
};
const session = client.startSession();
console.log('事务状态:', session.transaction.state);
try {
session.startTransaction(transactionOptions);
console.log('事务状态:', session.transaction.state);
const goodsColl = await client.db('test').collection('goods');
const orderGoodsColl = await client.db('test').collection('order_goods');
const { stock, price } = await goodsColl.findOne({ goodId }, { session });
console.log('事务状态:', session.transaction.state);
if (stock <= 0) {
throw new Error('库存不足');
}
await goodsColl.updateOne({ goodId }, {
$inc: { stock: -1 } // 库存减 1
})
await orderGoodsColl.insertOne({ id: Math.floor(Math.random() * 1000), goodId, price }, { session });
await session.commitTransaction();
} catch(err) {
console.log(`[MongoDB transaction] ERROR: ${err}`);
await session.abortTransaction();
} finally {
await session.endSession();
console.log('事务状态:', session.transaction.state);
}
}
testTransaction('g1000')
运行测试
node index
事务状态: NO_TRANSACTION
事务状态: STARTING_TRANSACTION
事务状态: TRANSACTION_IN_PROGRESS
事务状态: TRANSACTION_COMMITTED
官方文档示例 1
// Prereq: Create collections. CRUD operations in transactions must be on existing collections.
db.getSiblingDB("mydb1").foo.insert( {abc: 0}, { writeConcern: { w: "majority", wtimeout: 2000 } } );
db.getSiblingDB("mydb2").bar.insert( {xyz: 0}, { writeConcern: { w: "majority", wtimeout: 2000 } } );
// Start a session.
session = db.getMongo().startSession( { readPreference: { mode: "primary" } } );
coll1 = session.getDatabase("mydb1").foo;
coll2 = session.getDatabase("mydb2").bar;
// Start a transaction
session.startTransaction( { readConcern: { level: "local" }, writeConcern: { w: "majority" } } );
// Operations inside the transaction
try {
coll1.insertOne( { abc: 1 } );
coll2.insertOne( { xyz: 999 } );
} catch (error) {
// Abort transaction on error
session.abortTransaction();
throw error;
}
// Commit the transaction using write concern set at transaction start
session.commitTransaction();
session.endSession();
官方文档示例 2
// hr
{ "_id" : ObjectId("5af0776263426f87dd69319a"), "employee" : 3, "name" : { "title" : "Mr.", "name" : "Iba Ochs" }, "status" : "Active", "department" : "ABC" }
{ "_id" : ObjectId("5af0776263426f87dd693198"), "employee" : 1, "name" : { "title" : "Miss", "name" : "Ann Thrope" }, "status" : "Active", "department" : "ABC" }
{ "_id" : ObjectId("5af0776263426f87dd693199"), "employee" : 2, "name" : { "title" : "Mrs.", "name" : "Eppie Delta" }, "status" : "Active", "department" : "XYZ" }
// reporting
{ "_id" : ObjectId("5af07daa051d92f02462644a"), "employee" : 1, "status" : { "new" : "Active", "old" : null }, "department" : { "new" : "ABC", "old" : null } }
{ "_id" : ObjectId("5af07daa051d92f02462644b"), "employee" : 2, "status" : { "new" : "Active", "old" : null }, "department" : { "new" : "XYZ", "old" : null } }
{ "_id" : ObjectId("5af07daa051d92f02462644c"), "employee" : 3, "status" : { "new" : "Active", "old" : null }, "department" : { "new" : "ABC", "old" : null } }
// Runs the txnFunc and retries if TransientTransactionError encountered
function runTransactionWithRetry(txnFunc, session) {
while (true) {
try {
txnFunc(session); // performs transaction
break;
} catch (error) {
// If transient error, retry the whole transaction
if ( error.hasOwnProperty("errorLabels") && error.errorLabels.includes("TransientTransactionError") ) {
print("TransientTransactionError, retrying transaction ...");
continue;
} else {
throw error;
}
}
}
}
// Retries commit if UnknownTransactionCommitResult encountered
function commitWithRetry(session) {
while (true) {
try {
session.commitTransaction(); // Uses write concern set at transaction start.
print("Transaction committed.");
break;
} catch (error) {
// Can retry commit
if (error.hasOwnProperty("errorLabels") && error.errorLabels.includes("UnknownTransactionCommitResult") ) {
print("UnknownTransactionCommitResult, retrying commit operation ...");
continue;
} else {
print("Error during commit ...");
throw error;
}
}
}
}
// Updates two collections in a transactions
function updateEmployeeInfo(session) {
employeesCollection = session.getDatabase("hr").employees;
eventsCollection = session.getDatabase("reporting").events;
session.startTransaction( { readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } } );
try{
employeesCollection.updateOne( { employee: 3 }, { $set: { status: "Inactive" } } );
eventsCollection.insertOne( { employee: 3, status: { new: "Inactive", old: "Active" } } );
} catch (error) {
print("Caught exception during transaction, aborting.");
session.abortTransaction();
throw error;
}
commitWithRetry(session);
}
// Start a session.
session = db.getMongo().startSession( { readPreference: { mode: "primary" } } );
try{
runTransactionWithRetry(updateEmployeeInfo, session);
} catch (error) {
// Do something with error
} finally {
session.endSession();
}