如何解决MongoDB中缺少事务的问题?

146
我知道这里有类似的问题,但它们要么告诉我如果需要事务就切换回常规RDBMS系统,要么使用原子操作两阶段提交。第二个解决方案似乎是最好的选择。第三个解决方案我不想采用,因为看起来很多事情可能会出错,我无法在各个方面测试它。我正在努力重构我的项目以执行原子操作。我不知道这是来自于我的有限视角(到目前为止我只使用过SQL数据库),还是实际上不能做到。
我们想在公司试点MongoDB。我们选择了一个相对简单的项目-短信网关。它允许我们的软件向蜂窝网络发送短信,并且网关通过不同的通信协议实际与提供商通信。网关还管理消息的计费。申请该服务的每个客户都必须购买一些积分。当发送消息时,系统会自动减少用户的余额并拒绝访问,如果余额不足。此外,因为我们是第三方短信提供商的客户,我们也可能有自己的余额。我们也必须跟踪这些余额。
我开始思考如果简化一些复杂性(如外部计费,排队短信发送),如何使用MongoDB存储所需数据。来自SQL世界的我会为用户创建一个单独的表,为SMS消息创建另一个表,并为存储与用户余额相关的交易创建一个表。假设我在MongoDB中为所有这些内容创建单独的集合。
在这个简化系统中,想象一下以下步骤的短信发送任务:
1. 检查用户是否有足够的余额;如果余额不足,则拒绝访问。 2. 发送和存储带有详细信息和成本的短信到SMS集合中(在实时系统中,该消息将具有状态属性,并且任务将根据其当前状态选择进行传递并设置短信价格)。 3. 减少已发送消息的成本的用户余额。 4. 记录交易到交易集合中。
那么问题出现在哪里?MongoDB仅能对一个文档进行原子更新。在先前的流程中,可能会发生某种错误,导致消息存储在数据库中,但未更新用户余额和/或未记录交易。
我想出了两个解决方案:
创建一个单一的用户集合,将余额作为字段存储,将与用户相关的交易和消息作为子文档存储在用户文档中。由于我们可以原子地更新文档,这实际上解决了交易问题。缺点是:如果用户发送许多短信,文档的大小可能会变大,达到4MB文档限制。也许我可以在这种情况下创建历史文档,但我认为这不是一个好主意。此外,如果我向同一个大型文档推送越来越多的数据,我不知道系统会有多快。
为用户创建一个集合,为交易创建一个集合。可以有两种类型的交易:充值(余额增加)和发送消息(余额减少)。交易可以有一个子文档;例如,在发送消息中,SMS的详细信息可以嵌入交易中。缺点是:我没有存储当前用户余额,因此每次用户尝试发送消息时都必须计算余额以确定消息是否能够通过。随着存储的交易数量增加,我担心这种计算会变得很慢。
我有点困惑该选择哪种方法。还有其他解决方案吗?我在网上找不到关于如何解决这些问题的最佳实践。我想许多试图熟悉NoSQL世界的程序员在开始时都会面临类似的问题。

62
如果我说错了请原谅,但看起来这个项目似乎将使用一个NoSQL数据存储,而不管它是否会从中受益。NoSQL并不是作为“时尚”选择的SQL的替代品,而是在关系型RDBMS技术不适合问题空间且非关系型数据存储可以解决问题时使用的。你的问题中有很多"If it was SQL then ...",这让我警惕。所有的NoSQL都源于无法用SQL解决的问题,然后它们被稍微通用化以更容易使用,然后当然就开始跟风了。 - PurplePilot
4
我知道这个项目不是试用NoSQL最好的项目。但是,如果我们开始在其他项目中使用它(比如图书馆藏管理软件,因为我们从事藏品管理),突然遇到需要事务的请求(比如移动一本书到另一个集合),我们需要知道如何解决问题。也许只是我太狭隘了,总认为需要事务。但也可能有办法克服这些问题。 - NagyI
3
我同意PurplePilot的观点,你应该选择适合解决方案的技术,而不是试图将不适当的解决方案植入问题中。为图形数据库建模数据与关系型数据库设计完全不同,并且你必须忘记你所知道的一切,重新学习新的思维方式。 - user177800
10
我明白我应该使用适当的工具完成任务。但对我来说,当我看到像这样的答案时,似乎NoSQL不适合任何关键数据的情况。它适用于Facebook或Twitter这样的平台,在那里如果有些评论丢失了,世界也会继续转动,但是如果超出这个范围,就会破坏业务。如果这是真的,我不明白为什么其他人要关心使用MongoDB构建例如Web商店:http://kylebanker.com/blog/2010/04/30/mongodb-and-ecommerce/。它甚至提到大多数交易可以通过原子操作来克服。我正在寻找的是如何实现。 - NagyI
2
有时候,适用于应用程序的正确技术并不存在。我来自 SQL 背景,并编写一个需要事务和完全适合 SQL 数据库结构的应用程序。所以我的选择很明显,对吧?但事实并非如此,我还有非常大的数据量和非常高的读写需求。现在,经过管理 MySql、Informix 和 DB2 的一段时间,我知道扩展它们是一项重要的工作,而 NoSQL 数据库具有更容易扩展的优势。因此回到问题,使用 mongodb 实现某种事务的“最不可取”的方式是什么? - Raymond
显示剩余2条评论
9个回答

28

从版本 4.0 开始,MongoDB 将具备多文档 ACID 事务。计划首先在副本集部署中启用它们,接着是分片集群。MongoDB 中的事务与关系型数据库中开发人员熟悉的事务一样,它们将是多语句的,具有类似的语义和语法(例如 start_transactioncommit_transaction)。重要的是,使事务成为可能的 MongoDB 更改不会影响不需要事务的工作负载的性能。

更多详细信息请参见此处

拥有分布式事务并不意味着您应该像使用表格关系型数据库一样对数据进行建模。采用文档模型并遵循良好且推荐的数据建模实践的力量。


2
事务已经到来!4.0 GA已发布。https://www.mongodb.com/blog/post/mongodb-multi-document-acid-transactions-general-availability - Grigori Melnik
MongoDB事务仍然存在交易大小限制,最大为16 MB。最近我有一个使用案例,需要将来自文件的50k条记录放入mongoDB中,为了保持原子属性,我考虑使用事务,但由于50k个JSON记录超过了此限制,它会抛出错误“所有事务操作的总大小必须小于16793600。实际大小为16793817”。有关更多详细信息,请查看mongoDB上的官方jira票证https://jira.mongodb.org/browse/SERVER-36330。 - Gautam Malik
MongoDB 4.2(目前处于beta,RC4版本)支持大型事务。通过在多个oplog条目中表示事务,您将能够在单个ACID事务中写入超过16MB的数据(受现有60秒默认最大执行时间的限制)。您现在可以尝试它们 - https://www.mongodb.com/download-center/community - Grigori Melnik
MongoDB 4.2现已正式发布,全面支持分布式事务。https://www.mongodb.com/blog/post/mongodb-42-is-now-ga-ready-for-your-production-apps - Grigori Melnik

24

请看这个链接,由Tokutek提供。他们开发了一个Mongo插件,不仅承诺提供事务,还能提升性能。


@Giovanni Bitliner。Tokutek已被Percona收购,而在您提供的链接中,我没有看到任何关于自那篇文章以来发生的事情的信息。您知道他们的努力取得了什么进展吗?我给该页面上的电子邮件地址发送了电子邮件以了解详情。 - Tyler Collier
你需要什么具体的东西?如果你需要将Toku技术应用到Mongodb中,请尝试使用https://github.com/Tokutek/mongo,如果你需要Mysql版本,也许他们已经将其添加到了他们通常提供的标准版Mysql中。 - Giovanni Bitliner
我该如何将Tokutek与Node.js集成? - Manoj Sanjeewa

11

简而言之,如果事务完整性是“必须”的,则不要使用MongoDB,而是仅使用系统中支持事务的组件。在非ACID兼容组件上构建类似ACID功能的东西非常困难。根据个体用例,以某种方式将操作分为事务性和非事务性操作可能是有意义的...


1
我猜你的意思是NoSQL可以作为经典关系型数据库的辅助数据库使用。我不喜欢在同一个项目中混合使用NoSQL和SQL的想法。这会增加复杂性,并可能引入一些非常棘手的问题。 - NagyI
2
NoSQL解决方案很少单独使用。文档存储(mongo和couch)可能是这个规则的唯一例外。 - Karoly Horvath

7
现在的问题是什么? MongoDB只能对一个文档进行原子更新。在之前的流程中可能会出现某种错误,导致消息已存储在数据库中,但是用户的余额没有减少和/或交易没有被记录。这并不是真正的问题。您提到的错误可能是逻辑错误或IO错误(网络、磁盘故障)。这种错误可能会使事务性和非事务性存储处于非一致状态。例如,如果已发送SMS,但在存储消息时发生错误-它无法回滚SMS发送,这意味着它不会被记录,用户余额不会减少等。
真正的问题在于用户可以利用竞争条件发送超过其余额允许的更多消息。这也适用于关系型数据库,除非您在锁定余额字段的事务内执行SMS发送(这将是一个很大的瓶颈)。在 MongoDB 的可能解决方案是首先使用findAndModify减少余额并检查它,如果为负,则禁止发送并退还金额(原子增量)。 如果余额为正,则继续发送,如果失败则退还金额。余额历史记录集合也可以维护以帮助修复/验证余额字段。

谢谢您提供这个很好的答案!我知道如果使用支持事务的存储系统,由于短信系统我无法控制,数据可能会受损。但是使用MongoDB,数据错误也可能在内部发生。假设代码使用findAndModify更改用户的余额,余额变为负数,但在我纠正错误之前发生错误并且应用程序需要重启。我想你的意思是我应该实现类似于基于事务集合的两阶段提交,并对数据库进行常规校正检查。 - NagyI
9
不正确,如果你没有进行最终提交,事务型存储将会回滚。 - Karoly Horvath
9
同时,你不应该先发送短信然后再登录数据库,这是错误的做法。应该首先将所有内容存储在数据库中并进行最终提交,然后再发送短信。此时仍可能出现故障,因此需要一个定期作业来检查消息是否已成功发送,如果没有发送成功,则尝试重新发送。也许为此使用专门的消息队列会更好。但整个问题关键在于你是否能以事务方式发送短信... - Karoly Horvath
@NagyI 是的,这就是我所说的。人们必须为易于扩展性而交易交易的好处。基本上,应用程序必须期望不同集合中的任何两个文档可能处于不一致状态,并准备处理此问题。 @yi_H 它将回滚,但状态将不再是实际状态(有关消息的信息将丢失)。这并不比仅具有部分数据(例如减少余额但没有消息信息或反之亦然)更好。 - pingw33n
我明白了。这实际上不是一个简单的限制条件。也许我应该学习更多关于RDBMS系统如何处理事务的知识。你能推荐一些在线资料或书籍,让我可以阅读吗? - NagyI
正确,这里真正的问题是竞态条件。解决方案是原子提交。 - superluminary

6
这个项目很简单,但是你必须支持支付交易,这使得整个事情变得困难。例如,一个具有数百个集合(论坛、聊天、广告等)的复杂门户系统在某些方面更简单,因为如果你失去了一个论坛或聊天记录,没有人真正关心。但是,如果你失去了一笔付款交易,那就是一个严重的问题。
因此,如果你真的想要一个MongoDB的试点项目,请选择在这个方面简单的项目。

谢谢你的解释。听到这个消息很难过。我喜欢NoSQL的简单性和JSON的使用。我们正在寻找ORM的替代方案,但看起来我们必须坚持一段时间。 - NagyI
你能给出任何MongoDB比SQL更适合这项任务的好理由吗?试点项目听起来有点傻。 - Karoly Horvath
我并没有说MongoDB比SQL更好。我们只是想知道它是否比SQL+ORM更好。但现在越来越清楚的是,在这种项目中它们并不具有竞争力。 - NagyI

6

MongoDB没有事务是有充分的理由的。这也是使MongoDB更快的原因之一。

如果您必须使用事务,那么MongoDB似乎不是一个很好的选择。

或许可以考虑使用关系型数据库和MongoDB的组合,但这会增加复杂性,使应用程序的管理和支持变得更加困难。


1
现在有一个名为TokuMX的MongoDB分发版本,它使用分形技术提供了50倍的性能改进,并同时提供完整的ACID事务支持。详情请访问:http://www.tokutek.com/tokumx-for-mongodb/ - OCDev
10
任何交易都不可能不是“必须”的,因为只要存在需要更新两个表的简单情况,MongoDB突然就不再适合了吗?这几乎没有留下多少使用情况了。 - Mr_E
1
@Mr_E同意,这就是为什么MongoDB有点愚蠢 :) - Alexander Mills

6

这可能是我找到的关于在mongodb中实现类似事务特性的最好的博客!

同步标志:最适合从主文档复制数据。

作业队列:非常通用,解决了95%的情况。大多数系统都需要至少一个作业队列!

两阶段提交:此技术确保每个实体始终具有达到一致状态所需的所有信息。

日志对账:最健壮的技术,非常适合金融系统。

版本控制:提供隔离并支持复杂结构。

更多信息请参见:https://dzone.com/articles/how-implement-robust-and


请在您的答案中仅包括回答问题所需的链接资源的相关部分。如果链接网站崩溃或更改,您的答案可能会变得无用。 - mech
感谢 @mech 的建议。 - Vaibhav

4

虽然有些晚了,但我认为这对未来会有所帮助。我使用Redis创建了一个队列来解决这个问题。

  • Requirement:
    Image below show 2 actions need execute concurrently but phase 2 and phase 3 of action 1 need finish before start phase 2 of action 2 or opposite (A phase can be a request REST api, a database request or execute javascript code...). enter image description here

  • How a queue help you
    Queue make sure that every block code between lock() and release() in many function will not run as the same time, make them isolate.

    function action1() {
      phase1();
      queue.lock("action_domain");
      phase2();
      phase3();
      queue.release("action_domain");
    }
    
    function action2() {
      phase1();
      queue.lock("action_domain");
      phase2();
      queue.release("action_domain");
    }
    
  • How to build a queue
    I will only focus on how avoid race conditon part when building a queue on backend site. If you don't know the basic idea of queue, come here.
    The code below only show the concept, you need implement in correct way.

    function lock() {
      if(isRunning()) {
        addIsolateCodeToQueue(); //use callback, delegate, function pointer... depend on your language
      } else {
        setStateToRunning();
        pickOneAndExecute();
      }
    }
    
    function release() {
      setStateToRelease();
      pickOneAndExecute();
    }
    
但是你需要使用isRunning()setStateToRelease()setStateToRunning()来隔离它,否则你将再次面临竞争条件。为此,我选择Redis用于ACID目的和可扩展性。
Redis document谈到了它的事务:

事务中的所有命令都被序列化并按顺序执行。永远不可能发生另一个客户端发出的请求在Redis事务执行期间被服务。这保证了命令作为单个隔离操作执行。

P/s:
我使用Redis是因为我的服务已经在使用它,你可以使用任何其他支持隔离的方法来完成这项工作。
我代码中的action_domain是为了当您只需要用户A的动作1调用时阻止用户A的动作2,而不会阻止其他用户。这个想法是为每个用户放置一个唯一的锁定键。

如果你的分数已经更高,你就会收到更多的赞同票。这就是这里大多数人的想法。在问题的背景下,你的答案很有用。我已经给你点了赞。 - Mukus

3

MongoDB 4.0现已支持事务处理。示例请参见此处

// 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( { mode: "primary" } );

try{
   runTransactionWithRetry(updateEmployeeInfo, session);
} catch (error) {
   // Do something with error
} finally {
   session.endSession();
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接