how to write resilient mongodb applications

前言:本文翻译自MongoDB团队工程师A. Jesse Jiryu Davis的博客文章。文章描述了在遇到错误时如何安全的进行重试。虽然这项技术已被MongoDB 3.6的新内置功能Retryable Writes取代。 可重写的写操作比这里描述的技术简单得多。但是作者解决问题的方式还是能给我们一些启发。

有一次,在2012年初的一个寒冬午后,我遇到了一位非常生气的MongoDB客户。

他来到我们的“MongoDB Office Hours”办公室,他有一个问题:“我怎样才能使我的应用程序在网络错误,中断和其他异常情况下具有弹性?我可以每次重试操作直到成功?“他要求知道为什么我们没有发布一个适用于所有应用程序的简单明智的策略。

这个人,我会叫他Ian,很不高兴,我也帮不了他。我的内疚已经让我忘记了那天的细节。我们坐在一间没有窗户的房间里,这是我们唯一可以在我们的小办公室聊天的自由空间。这里有一个讨厌的荧光灯。我们并排坐在桌子的边缘,因为房间没有椅子。在Ian的眼睛下有深深的黑眼圈,就像他已经为这个问题担心了一整晚。 “我如何编写弹性代码?”

我唯一可以告诉Ian的是,“我们无法发布一个策略来处理网络错误和中断,并为您指出错误,因为我们不知道您应用程序的细节。您可以选择很多不同的操作方式,在延迟和可靠性之间做出折中,以及在一个操作肯能被执行两次的风险与完全不做这两者之间进行权衡,这就是为什么我们没有尝试写出一种“一刀切”的策略。就算可以,但是不同语言下驱动的行为不同,我们必须为每种语言发布指南。“

我对我的回答并不满意,Ian也一样。

在此后的几年中,我努力想出了一个更好的答案。首先我写了服务发现和监测规范,我们所有的驱动程序现在已经实现了。该规范极大地提高了驱动程序在面对网络错误和服务器故障时的鲁棒性。像Ian这样的投诉现在比较少见,因为驱动很少会抛出异常。此外,所有驱动程序现在都表现相同,我们使用标准化的通用测试套件验证其行为。

其次,我开发了一种名为‘Black Pipe’的测试技术,因此Ian可以测试他的代码在与MongoDB交互时如何响应网络故障,命令错误或任何其他事件。‘Black Pipe’使用方便且准确;它使得错误情况可重现且易于测试。

现在可以回答Ian了。如果他今天来到我们在时代广场的大办公室,你会如何回答他?编写弹性MongoDB应用程序的策略是什么?

我们会告诉Ian,如何做到这一点:

1
2
3
updateOne({'_id': '2016-06-28'},
{'$inc': {'counter': 1}},
upsert=True)

该操作通过在_id为今天日期的文档中递增名为“counter”的字段来计数事件发生的次数。如果这是今天的第一个事件,参数“upsert = True”告诉MongoDB
创建文档。

什么会出错

瞬态错误

当Ian将他的updateOne消息发送到MongoDB时,驱动程序可能会看到来自网络层的暂时错误,例如TCP重置或超时。

瞬态网络错误,故障转移或降级

驱动不知道服务器是否收到消息,所以Ian不知道他的计数器是否增加。

还有其他瞬态错误看起来与网络暂时性问题相同。如果primary节点出现故障,下次尝试向其发送消息时,驱动程序会收到网络错误。这个错误很简短,因为副本集会在几秒钟内选出一个新的primary节点。同样,如果primary服务器退出(它仍然正常工作,但已经辞去其primary角色),它将关闭所有连接。下次驱动程序向服务器发送消息时,它认为它是primary节点,它会从服务器收到网络错误或“not master”回复。

在所有情况下,驱动程序都会向Ian的应用程序抛出连接错误。

持续的错误

也可能存在持续的网络中断。当驱动程序首次检测到此问题时,它看起来像一个暂时性问题:驱动程序发送一条消息,但是无法读取响应。

Persistent Network Outage

持续的网络中断

Ian再一次无法分辨服务器是否接收到消息并递增计数器。

网路中断与网络暂时性问题的区别是Ian试图再次操作只会得到另一个网络错误。但是只有他尝试后才知道。

命令错误

当驱动程序发送消息时,MongoDB可能会返回一个特定的错误,表示已收到命令但无法执行。也许命令格式不正确,服务器磁盘空间不足,或Ian的应用程序未被授权。

所以,有三个不能完全可区分的错误,需要Ian的代码有不同回应。你会给Ian什么样的策略来使他的应用具有弹性?

是因为我们没有事务吗?

您可能想知道这是否是只有MongoDB才有的问题,因为它没有事务。假设Ian使用了传统的SQL服务器。他打开一个事务,更新一行,并发送COMMIT消息。然后有一个网络临时性错误。他无法从服务器读取确认消息。他知道交易是否已经实施吗?

保证操作只执行一次,使用SQL服务器与使用非事务性服务器(如MongoDB)面临的问题相同。

MongoDB驱动程序如何处理错误?

为了制定您的策略,您需要知道MongoDB驱动程序自己如何响应不同类型的错误。

服务器发现和监测规范要求MongoDB驱动程序跟踪每个连接到的服务器的状态。例如,它可能是一个3节点副本集,如下所示:

  • Server 1: Primary
  • Server 2: Secondary
  • Server 3: Secondary

这个数据结构被称为“拓扑描述”。如果在与服务器交谈时出现网络错误,驱动程序会将该服务器的类型设置为“未知”,然后引发异常。现在拓扑描述是:

  • Server 1: Unknown
  • Server 2: Secondary
  • Server 3: Secondary

Ian尝试的操作不会自动重试; 然而,下一个操作会被阻塞,因为驱动程序正在重新发现主驱动器。它每秒重新检查每台服务器两次,最多持续30秒,直到重新连接到主服务器或检测到新主服务器被选中。现在MongoDB的选举只需要一两秒钟,然后驱动程序在此之后大约半秒会收到选举结果。

另一方面,如果网络持续中断,则在30秒之后,驱动程序会抛出“server selection timeout”。

在命令错误的情况下,驱动程序对服务器的看法没有改变:如果服务器是primary或secondary,它仍然是。因此,驱动程序不会在拓扑描述中更改服务器的状态,只会引发异常。

(要了解有关服务器发现和监测规范的更多信息,请阅读我的文章PyMongo,Perl和C中的服务器发现和监控,或观看MongoDB World 2015上的演讲,MongoDB驱动程序和高可用性:深度分析。在那里我详细讨论了规范中描述的数据结构以及规范如何告诉驱动程序对错误做出反应,这是本文关于弹性应用程序的一个很好的背景。)

错误的重试策略

我见过一些。

不要重试

默认是根本不重试。这种策略在遇到前面描述的三种错误情况时可能会失败。

瞬态网络错误 持续中断 命令错误
可能计数丢失 正确 正确

在发生瞬时网络错误的情况下,Ian将消息发送到服务器,并且不知道服务器是否收到它。如果没有,该事件从不计算在内。他的代码可能会记录错误,然后继续前进。

有趣的是,面对长期的网络中断或命令错误,“不要重试”是正确的策略,因为这些是非瞬态错误,无法通过重试改善。

总是重试

一些程序员编写代码重试任何失败的操作五次,或者,如果他们真的关心弹性,十次。我在很多生产应用程序中都看到了这一点。

1
2
3
4
5
6
7
8
9
i = 0
while True:
try:
do_operation()
break
except network error:
i += 1
if i == MAX_RETRY_COUNT:
throw

去年,我与一位名为Sam的Rackspace工程师进行了交谈。他接手了一个应用了这个不好的策略的Python代码库:它只要接收到任何异常就会一遍又一遍地重试。

Sam认为这是愚蠢的。他注意到我最近根据服务器发现和监测规范重新编写了PyMongo的客户端代码,他推测他可以更好地利用更强大的新驱动程序。在那里有一个更聪明的重试策略,但他不知道它到底是什么。事实上,正是从我与Sam的对话中,这篇文章诞生了。

Sam看到了什么?为什么Ian不应该重试每次操作五到十次?

瞬态网络错误 持续中断 命令错误
可能过多计数 浪费时间 浪费时间

在网络出现瞬态错误的情况下,Ian不会丢失计数,现在他可能会计数过多。因为如果服务器在发生网络错误之前先读取其第一条updateOne消息,则第二条updateOne消息会再次递增计数器。

另一方面,在持续的网络中断期间,多次重试是浪费时间。第一次网络错误后,驱动程序将primary服务器标记为“unknown”; 当Ian重试该操作时,它会在驱动程序尝试重新连接时阻塞,每秒检查两次,持续30秒。如果驱动程序代码中的所有努力都没有成功,那么再次尝试进入驱动程序的重试循环是徒劳的。这会导致他的应用程序出现排队和延迟。

命令错误也是如此:如果Ian的应用程序未被授权,则重试五次不会改变该情况。

重试一次网络错误

现在我们比较接近一个明智的策略。在Ian初始操作失败并出现网络错误后,Ian不知道错误是暂时的还是持久的,因此他只重试一次操作。单次重试进入驱动程序的30秒重试循环。如果网络错误持续30秒,则可能会持续更长时间,所以Ian放弃了继续重试。

但是,面对命令错误,他根本不会重试。

瞬态网络错误 持续中断 命令错误
可能过多计数 正确 正确

所以我们只剩下一种情况还有问题 - Ian怎么避免过多计数的可能性?

重试网络错误,使操作幂等

幂等性操作无论是一次还是多次执行都具有相同的结果。如果Ian的所有操作都是幂等的,那么他可以安全地重试它们,而不会发生过多计数或任何由于发送消息两次带来的其他类型错误。

瞬态网络错误 持续中断 命令错误
正确 正确 正确

那么Ian应该如何让他的updateOne操作幂等?

幂等操作

MongoDB有四种操作:查找,插入,删除和更新。前三个很容易幂等性; 让我们先来处理它们。

Find

查询自然是幂等的。

1
2
3
4
try:
doc = findOne()
except network err:
doc = findOne()

两次检索文档与检索一次文档结果一样。

Insert

比查询困难一点,但不是太糟糕。

1
2
3
4
5
6
7
8
9
doc = {_id: ObjectId(), ...}
try:
insertOne(doc)
except network err:
try:
insertOne(doc)
except DuplicateKeyError:
pass # first try worked
throw

这个伪代码的第一步是在客户端生成一个唯一的ID。 MongoDB的ObjectId是为这种用法而设计的,但任何独特的值都可以。

现在,Ian试图插入文档。如果因网络错误而失败,他会再次尝试。如果第二次尝试失败且服务器出现重复键错误,则第一次尝试成功,但由于网络层发生错误,他无法读取服务器响应。如果出现其他问题,他取消操作并抛出异常。

这个插入是幂等的。唯一需要警告的是,我假设Ian除了MongoDB自动创建的_id之外,没有唯一索引。如果还有其他唯一索引,那么他必须解析重复键错误。

Delete

如果Ian删除一个文档时指定一个唯一的key,那么执行两次与执行一次相同。

1
2
3
4
try:
deleteOne({'key': uniqueValue})
except network err:
deleteOne({'key': uniqueValue})

如果第一个被执行,但是Ian得到一个网络异常并再次尝试,那么第二个删除就是空操作;它只是不匹配任何文档。该删除可以安全地重试。

同时删除许多文件甚至更容易:

1
2
3
4
try:
deleteMany({...})
except network err:
deleteMany({...})

如果Ian删除所有匹配过滤器的文档,并且他的代码出现网络错误,那么他可以再次安全地尝试。无论是deleteOne运行一次还是两次,结果都是一样的:所有匹配的文档都被删除。

在这两种情况下都存在竞争条件:另一个进程可能会在两次尝试删除它们之间插入新的匹配文档。但是这场比赛并不比他的代码没有重试更糟糕。

Update

那么更新呢?首先考虑那种自然而然是幂等的更新,让我们放松一下。

1
2
3
4
5
# Idempotent update.

updateOne({ '_id': '2016-06-28'},
{'$set':{'sunny': True}},
upsert=True)

这与我们原来的例子不一样; Ian并没有增加一个变量的值,他把今天的“sunny”字段设置为真,让自己振作起来。说两次今天是阳光明媚的,就像说一次一样好。在这种情况下,updateOne可以安全地重试。

一般原则是有一些MongoDB更新运算符是幂等的,有些则不是。 $set是幂等的,因为如果两次将某些值设置为相同的值,则不会有任何区别,而$inc不是幂等的。

如果Ian的更新操作符是幂等的,那么他可以很容易地重试它:他尝试一次,如果他得到网络异常,他会再次尝试。

1
2
3
4
5
6
try:
updateOne({' _id': '2016-06-28'},
{'$set':{'sunny': True}},
upsert=True)
except network err:
try again, if that fails throw

所以现在我们终于准备好解决我们最难的例子,原始的非幂等updateOne:

1
2
3
updateOne({ '_id': '2016-06-28'},
{'$inc': {'counter': 1}},
upsert=True)

如果Ian偶然做了两次这样的操作,他会将计数增加2。

我们如何将它变成幂等操作?我们将分成两步。每一步都是幂等的,通过将它转换成一对幂等运算,我们将安全地重试。

我们假设一开始文档的counter值是N:

1
2
3
4
{
_id: '2016-06-28',
counter: N
}

在第一步中,Ian先不管counter当前的值,他只是给一个“pending”数组添加一个标记。这里他需要一些独特的东西; 一个ObjectId是很好的选择:

1
2
3
4
5
6
7
oid = ObjectId()
try:
updateOne({ '_id': '2016-06-28'},
{'$addToSet': {'pending': oid}},
upsert=True)
except network err:
try again, then throw

$addToSet是幂等运算符之一。如果它运行两次,令牌只会添加一次到数组中。现在文档看起来像这样:

1
2
3
4
5
{
_id: '2016-06-28',
counter: N,
pending: [ ObjectId("...") ]
}

第二步,对于单个操作,Ian通过_id及其待处理标记查询文档,删除待处理标记并增加计数器。

1
2
3
4
5
6
7
8
9
try:
# Search for the document by _id and pending token.
updateOne({'_id': '2016-06-28',
'pending': oid},
{'$pull': {'pending': oid},
'$inc': {'counter': 1}},
upsert=False)
except network err:
try again, then throw

所有的MongoDB更新,无论它们是否是幂等的,都是原子的:单个updateOne完全成功或根本没有任何作用。所以只有当计数器增加1时, 令牌才会从待处理数组中移除。

总的来说,这个更新是幂等的。想象一下,Ian将令牌移出数组并在第一次尝试时递增计数器,但是之后他无法读取服务器响应,因为存在网络错误。他第二次尝试是无效的,因为查询要求匹配具有待处理令牌的文档,但他已经将其取出。

所以Ian可以安全地重试这​​个updateOne。无论它执行了一次还是两次,文档结果都是一样的:

1
2
3
4
5
{
_id: '2016-06-28',
counter: N + 1,
pending: [ ]
}

任务完成?

现在你已经做到了:你重现实现了Ian原来的updateOne,这是不安全的重试,并通过将它分成两步来使它变得幂等。

这项技术有几点需要注意。其中之一是Ian简单的$inc操作现在需要两次往返。它需要延迟两倍,负载加倍。如果在网络瞬态错误中被少计入一次或超过一次没有什么问题,他就不应该使用这种技术。

另一个警告是,他需要一个每晚执行的清理过程。考虑一下:如果第二步从未完成,会发生什么?

1
2
3
4
5
6
7
8
try:
updateOne({'_id': '2016-06-28',
'pending': oid},
{'$pull': {'pending': oid},
'$inc': {'counter': 1}},
upsert=False)
except network err:
try again, then throw

假设Ian在添加待处理令牌后,将其移除并增加计数器之前,网络中断开始。该文件处于这种状态:

1
2
3
4
5
{
_id: '2016-06-28',
counter: N,
pending: [ ObjectId("...") ]
}

Ian需要一个每晚执行的任务来查找今天中止的任务遗留的任何待处理令牌,并完成更新计数器。为了避免并发问题,他一直等到一天结束。他的任务使用聚合管道来查找具有待处理令牌的文档,并将其数量添加到当前计数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pipeline = [{
'$match':
{'pending.0': {'$exists': True}}
}, {
'$project': {
'counter': {
'$add': [
'$counter',
{'$size': '$pending'}
]
}
}
}]

for doc in collection.aggregate(pipeline):
collection.updateOne(
{ '_id': doc._id},
{ '$set': {'counter': doc.counter},
'$unset': {'pending': True}
})

对于每个聚合操作结果,此任务使用最终计数器值更新源文档,并清除待处理数组。

updateOne可以安全地重试,因为它使用幂等运算符$set和$unset。Ian的清理任务可以一直尝试,直到它成功,无论他的网络有多么糟糕。执行此清理任务后,Ian今天的事件数量最终将是正确的。

如果Ian接受这些缺点 - 一次增加需要两次往返,并且可能在一天结束之前不能完成 - 那么对于非常关键的操作,这种技术是一种可靠的方式来使其计数器只增加一次。

Ref