用于更新的mongoDB乐观并发控制

时间:2020-04-23 15:47:39

标签: mongodb optimistic-concurrency

我正在为MongoDB的“更新”模拟multiple concurrent request

这就是问题,我在mongoDB中插入数据amount=1000,每次触发api时,它将通过amount += 50更新金额并将其保存回数据库。基本上,这是对单个文档的find and update操作。

    err := globalDB.C("bank").Find(bson.M{"account": account}).One(&entry)

    if err != nil {
        panic(err)
    }

    wait := Random(1, 100)
    time.Sleep(time.Duration(wait) * time.Millisecond)

    //step 3: add current balance and update back to database
    entry.Amount = entry.Amount + 50.000
    err = globalDB.C("bank").UpdateId(entry.ID, &entry)

Here是项目的源代码。

我正在使用Vegeta模拟请求:

如果我设置了-rate=10(这意味着每秒触发api 10次,那么1000 + 50 * 10 = 1500),则数据正确

echo "GET http://localhost:8000" | \
vegeta attack -rate=10 -connections=1 -duration=1s | \
tee results.bin | \
vegeta report

enter image description here

但是使用-rate=100(这意味着每秒触发api 100次,因此1000 + 50 * 100 = 6000)会产生令人困惑的结果。

echo "GET http://localhost:8000" | \
vegeta attack -rate=100 -connections=1 -duration=1s | \
tee results.bin | \
vegeta report

enter image description here

简而言之,我想知道的是:我认为MongoDB使用的是optimistic concurrency control,这意味着如果存在write conflict,它应该重试,这样延迟会增加,但是数据应该保证是正确的。

为什么结果看起来好像在MongoDB中完全不能保证数据正确性?

我知道有些人可能注意到4142行的睡眠,但是即使我注释掉了,当我用-rate=500测试时,结果仍然不正确。

任何线索为什么会这样?

1 个答案:

答案 0 :(得分:0)

通常,您应该将代码的相关部分提取到问题中。要求人们在您的76行程序中找到5条相关行是不明智的。

您的测试正在执行并发的查找和修改操作。假设有两个并发的进程A和B,每个进程将帐户余额增加50。初始余额为0。操作顺序可能是:

A: what is the current balance for account 1234?
B: what is the current balance for account 1234?
DB -> A: balance for account 1234 is 0
DB -> B: balance for account 1234 is 0
A: new balance is 0+50 = 50
A: set balance for account 1234 to 50
DB -> A: ok, new balance for account 1234 is 50
B: new balance is 0+50 = 50
B: set balance for account 1234 to 50
DB -> B: ok, new balance for account 1234 is 50

从数据库的角度来看,这里没有“写冲突”。您要求两次将给定帐户的余额设置为50。

有多种方法可以解决此问题。一种是使用条件更新,使过程如下所示:

A: what is the current balance for account 1234?
B: what is the current balance for account 1234?
DB -> A: balance for account 1234 is 0
DB -> B: balance for account 1234 is 0
A: new balance is 0+50 = 50
A: if balance in account 1234 is 0, set balance to 50
DB -> A: ok, new balance for account 1234 is 50
B: new balance is 0+50 = 50
B: if balance in account 1234 is 0, set balance to 50
DB -> B: balance is not 0, no update was performed
B: err, let's start over
B: what is the current balance for account 1234?
DB -> B: balance for account 1234 is 50
B: new balance is 50+50 = 100
B: if balance in account 1234 is 50, set balance to 100
DB -> B: ok, new balance for account 1234 is 100

如您所见,数据库必须支持条件更新,应用程序必须处理并发更新的可能性,然后重试该操作。

如果余额可以上下浮动,这不是编写借方和贷方系统的实用方法(但是,如果余额只能增加或减少,则实际上可以很好地工作)。在实际系统中,您将使用一个特殊的字段,其目的是识别应用程序检索某些数据时存在的文档的特定版本。更新以文档的当前版本保持不变为条件,并且每次更新都会递增版本。然后会检测到并发更新,因为版本号错误而不是内容字段错误。

有多种方法可以在数据库端产生“写冲突”,例如,使用MongoDB 4.0+支持的事务。原则上,这是相同的方式,但是“版本”被称为“交易标识符”,并且存储在不同的位置(在操作的文档中不是内联的)。但是原理是一样的。在这种情况下,数据库会通知您存在写冲突,您仍然需要重新发出操作。

更新:

我认为您还需要区分“乐观货币控制”这一概念,其实现方式以及该实现方式的适用范围。例如https://docs.mongodb.com/manual/faq/concurrency/#how-granular-are-locks-in-mongodb说:

对于大多数读写操作,WiredTiger使用开放式并发控制。 WiredTiger仅在全局,数据库和集合级别使用意图锁。当存储引擎检测到两个操作之间存在冲突时,将引发写冲突,从而导致MongoDB透明地重试该操作。

仔细阅读此语句,它适用于存储引擎级别上的写操作。我想象当MongoDB执行类似$set之类的操作或其他原子写入操作时,这将适用。但这不适用于您在示例中给出的应用程序级操作序列。

如果您使用自己喜欢的关系型DBMS尝试示例代码,我想您会发现它产生的结果与MongoDB产生的结果大致相同,如果围绕每个读写对象发出事务,则 / em>(以使余额读写处于不同的事务中),出于相同的原因-RDBMS会在事务的整个生命周期内锁定数据(或使用诸如MVCC之类的技术),而不是跨事务。

类似地,如果将同一帐户上的余额读取和余额写入两者都放入MongoDB中的事务中,则可能会发现当其他事务同时修改该帐户时,您会收到暂时性错误。

最后,here描述了MongoDB为事务(带有重试)实现的API。如果仔细查看,您会发现它期望应用程序不仅重新发出transaction commit命令,而且要重复整个事务操作。这是因为通常,如果发生“写入冲突”,则起始数据已更改,仅再次尝试最终写入是不够的-可能需要重做应用程序中的计算,甚至可能随着该过程的副作用而改变结果。