MongoDB Transaction WriteConflict问题分析

问题描述

最近在项目中因为涉及到多表数据同步,需要原子性操作多个表,所以使用了MongoDB Transaction。
在使用的过程中,Transaction中表的写操作,遇到了WriteConflict报错,具体如下。

WriteCommandError({
        "errorLabels" : [
                "TransientTransactionError"
        ],
        "operationTime" : Timestamp(1596785629, 1),
        "ok" : 0,
        "errmsg" : "WriteConflict error: this operation conflicted with another operation. Please retry your operation or multi-document transaction.",
        "code" : 112,
        "codeName" : "WriteConflict",

问题分析

根据报错,查了相关资料。原因是 由于MongoDB Server在处理并发请求时采用了 乐观锁,不同于MySql中的的悲观锁(如果有并发修改,会卡住后来请求等待锁的释放),乐观锁基于版本控制的思路,不加锁,先修改,如果在修改过程中,有并发写操作,版本号对不上,则再报错。对应MongoDB Server侧抛出WriteConflict,写冲突异常。可以结合官方文档查看。

截取来自官方文档 (https://www.mongodb.com/docs/v4.4/core/transactions-production-consideration/#in-progress-transactions-and-write-conflicts)

In-progress Transactions and Write Conflicts
If a transaction is in progress and a write outside the transaction modifies a document that an operation in the transaction later tries to modify, the transaction aborts because of a write conflict.

If a transaction is in progress and has taken a lock to modify a document, when a write outside the transaction tries to modify the same document, the write waits until the transaction ends.

第一段:两个transction操作中同时修改一个document情况,后修改的transcation中操作会被MongoDB Server抛出WriteConflict, 如下图

2个transaction

第二段:一个transaction 和 一个普通写操作,同时修改一个document情况,普通写操作会被hang住,等待之前的transaction结束后,才会写入,如下图


1个transaction 和 1个普通写操作

解决方法

官方已给出答案,就是在Mongo client端(也是driver端),catch error,然后重试。

贴一段官方代码:

async function commitWithRetry(session) {
  try {
    await session.commitTransaction();
    console.log('Transaction committed.');
  } catch (error) {
    if (error.hasErrorLabel('UnknownTransactionCommitResult')) {
      console.log('UnknownTransactionCommitResult, retrying commit operation ...');
      await commitWithRetry(session);
    } else {
      console.log('Error during commit ...');
      throw error;
    }
  }
}

async function runTransactionWithRetry(txnFunc, client, session) {
  try {
    await txnFunc(client, session);
  } catch (error) {
    console.log('Transaction aborted. Caught exception during transaction.');

    // If transient error, retry the whole transaction
    if (error.hasErrorLabel('TransientTransactionError')) {
      console.log('TransientTransactionError, retrying transaction ...');
      await runTransactionWithRetry(txnFunc, client, session);
    } else {
      throw error;
    }
  }
}

async function updateEmployeeInfo(client, session) {
  session.startTransaction({
    readConcern: { level: 'snapshot' },
    writeConcern: { w: 'majority' },
    readPreference: 'primary'
  });

  const employeesCollection = client.db('hr').collection('employees');
  const eventsCollection = client.db('reporting').collection('events');

  await employeesCollection.updateOne(
    { employee: 3 },
    { $set: { status: 'Inactive' } },
    { session }
  );
  await eventsCollection.insertOne(
    {
      employee: 3,
      status: { new: 'Inactive', old: 'Active' }
    },
    { session }
  );

  try {
    await commitWithRetry(session);
  } catch (error) {
    await session.abortTransaction();
    throw error;
  }
}

return client.withSession(session =>
  runTransactionWithRetry(updateEmployeeInfo, client, session)
);

相关链接:https://www.mongodb.com/docs/v4.4/core/transactions-in-applications/?_ga=2.179099232.531592764.1654272498-452737179.1639669270

延伸补充

针对transaction有几点实用的调优方式,如下

a) 避免使用transaction

可以通过一些写法避免transaction, 这里有个思路,适合数据量比较小的场景。

try {
    await session.withTransaction(async() => {
        await db.collection('branches').updateOne({
            _id: fromBranch
        }, {
            $inc: {
                balance: -1 * dollars
            }
        }, {
            session
        });
        await db.collection('branches').
        updateOne({
            _id: toBranch
        }, {
            $inc: {
                balance: dollars
            }
        }, {
            session
        });
    }, transactionOptions);
} catch (error) {
    console.log(error.message);
}

如果branch数量比较小,可以考虑合并成一个document,类似:

mongo > db.embeddedBranches.findOne(); {
    "_id": 1,
    "branchTotals": [{
            "branchId": 0,
            "balance": 101208675
        }, {
            "branchId": 1,
            "balance": 98409758
            214
        }, {
            "branchId": 2,
            "balance": 99407654
        },
        {
            "branchId": 3,
            "balance": 98807890
        }
    ]
}

则可以不用transaction,

try {
    let updateString =
        `{"$inc":{ 
            "branchTotals.` + fromBranch + `.balance":` + dollars + `, 
            "branchTotals.` + toBranch + `.balance":` + dollars + `}
        }`;
    let updateClause = JSON.parse(updateString);
    await db.collection('embeddedBranches').updateOne({
        _id: 1
    }, updateClause);
} catch (error) {
    console.log(error.message);
}

b) 移动transaction中op顺序,减少临界区

拿一个转账交易场景举例, 3个op, op1为全局交易计数,op2为某个账户余额减少,op3为某个账户余额增加。

1 await session.withTransaction(async () => {
2        await db.collection('txnTotals').
3         updateOne({ _id: 1 }, 
4            { $inc: {  counter: 1 }  }, 
5            { session });
6        await db.collection('accounts').
7          updateOne({ _id: fromAcc }, 
8            { $inc: {  balance: -1*dollars }  }, 
9            { session });
10        await db.collection('accounts').
11          updateOne({ _id: toAcc }, 
12            { $inc: {  balance: dollars }  }, 
13            { session });
14 }, transactionOptions);;

对于每一笔交易,此时临界区从第2行就开始了,而如果把op1移到尾部(原op3的位置),临界区则从第10行才开始,临界区范围降低,可减少碰撞几率,减少写冲突。

c) 切分热点数据(Partitioning Hot Documents)

还是以这个全局计数举例,假设每个交易的transaction中都有这个op,则txnTotals无疑是一个热点数据,

await db.collection('txnTotals').updateOne(
    { _id: 1 }, 
    { $inc: {counter: 1} }, 
    { session }
);

则我们可以 将其切分(Partitioning)降低热度,如下

let id = Math.floor(Math.random() * 10);
await db.collection('txnTotals').updateOne(
    { _id: id }, 
    { $inc: {counter: 1}}, 
    { session }
);

由上可知,切分越细则碰撞几率越小,整体transaction表现越好。

参考:

官方文档:https://www.mongodb.com/docs/v4.4/core/transactions-in-applications

MongoDB性能调优:https://medium.com/mongodb-performance-tuning/tuning-mongodb-transactions-354311ab9ed6

关键词: MongoDB, transaction, write conflict.

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,546评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,224评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,911评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,737评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,753评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,598评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,338评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,249评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,696评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,888评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,013评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,731评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,348评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,929评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,048评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,203评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,960评论 2 355

推荐阅读更多精彩内容