引用
2012年初的某个冬日午后,我曾见过一位愤怒的MongoDB用户。
他在“MongoDB办公时间”找到我们,提出问题:如何能令应用弹性应对网络错误、宕机等异常状况,是否能在操作成功前不断执行重试?为什么我们不发布一个简单、巧妙的普适性方案?
这位名叫Ian的用户十分烦恼,而我解决不了他的问题,这种愧疚让那一刻的细节深深镌刻在我的脑海中:一间没有窗户的房间——这是我们小办公室里唯一能用来谈话的地方——我们并肩坐在桌旁,头上悬着一盏脏兮兮的荧光灯,Ian眼底的黑眼圈很深,似乎被这个问题扰得彻夜难眠——“如何编写弹性代码?”
面对Ian的提问,我只能解释道:“由于不了解你的应用,我们无法发布能帮你应对网络错误、宕机与命令错误的解决方案。用户所能采取的操作,以及在延迟和可靠性间的权衡多种多样,是选择再次重复操作,还是不采取任何操作,这两种风险间的权衡也有很多选择,这也是一直以来我们未曾尝试编写普适性方案的原因——就算能够写出来,由于驱动方式不同,我们还必须为每种语言分别编写指南。”
然而,无论Ian还是我都对这个解释不甚满意。
此后几年,我一直致力于寻找更好的答案。首先,我制定了《
服务器发现与监控规范(Server Discovery and Monitoring Spec)》——现在已应用于我们所有的驱动上,此规范极大地提升了驱动在应对网络错误和服务器故障转移时的稳健性,由于驱动程序的异常状况减少,类似Ian这样的抱怨也愈发罕见了。此外,由于所有驱动的表现应当是一致的,我们可以使用标准化的常规测试套件来执行检测。
随后,我开发了一种被称为“黑管测试”(Black Pipe Testing)的技术,这样Ian就可以检测他的代码在与MongoDB交互时,如何应对网络故障、指令错误等事件了。黑管测试便捷又能定性,能够轻松重现并测试错误。
现在,我们能够回答Ian的问题了。如果现在他到我们位于时代广场的大办公室来提问,你会如何回答?如何编写弹性MongoDB应用?
面临的挑战
我们会告诉Ian弹性执行updateOne的方法:
updateOne({'_id': '2016-06-28'},
{'$inc': {'counter': 1}},
upsert=True)
通过计算文档中“counter”字段的递增(以当天日期为id),来计算事件发生的次数。对于当日的第一个事件,用“upsert=True”创建文档。
潜在问题
暂态错误(Transient errors)
一旦Ian向MongoDB发送updateOne信息,驱动程序可能会察觉网络层的暂态错误,例如一次TCP重置或是一次超时。
暂态网络错误、故障转移或服务器切换(stepdown)
驱动程序无法确认服务器是否已接收到信息,因此Ian也无从查验counter是否有递增。
其它暂态错误类似于网络短暂中断(network blip)。如果主服务器瘫痪,则驱动程序下一次试图向其发送信息时,会产生网络错误。鉴于副本集几秒内就能选出新的主服务器,所以该错误耗时较短。与此类似,若主服务器切换(该服务器仍然运行,但不再承担主服务器职责),则会切断所有连接。假如下一次驱动程序仍将信息发送到之前的主服务器,就会导致网络故障,或服务器的“not master(非主)”回复。
上述所有情况中,驱动程序都会向Ian的应用抛出一个连接错误。
永久性错误
还有可能会出现永久性的网络中断:问题在首次被检测到时类似于短暂中断(驱动程序发出信息却无响应)。
永久性网络中断
如上所述,Ian不能确定服务器是否接收到信息,以及counter有否递增。
区分短暂中断与宕机的关键在于:前者情况下,重复操作只会导致新的网络错误产生,但想要确认必须经过尝试。
命令错误
驱动程序发出一条信息后,MongoDB可能会返回某个错误,表示已收到命令但无法执行,也许是因为命令格式错误,也许是因为服务器磁盘空间不足,或是因为应用程序未获授权。
命令错误
综上所述,这三类错误较难分辨,且需要在代码中写入不同的回应,能否给出一个智能的解决方案,令Ian的应用具备弹性呢?
是否因为我们没有事务?
因为不包含事务,你可能会怀疑这是MongoDB特有的问题。假设Ian使用传统的SQL服务器,他开启事务、更新内容并发送COMMIT message,突然出现网络暂时中断,并且无法从服务器获得确认——在这种情况下,Ian能否确认事务已经提交了呢?
通过SQL服务器进行“恰一次”操作,和通过MongoDB这样的非事务性服务器进行操作,都会遭遇相同的问题。
MongoDB驱动如何处理故障
在制定智能解决方案前,首先要清楚MongoDB驱动程序对各类错误的响应方式。
《服务器发现与监控规范》要求MongoDB驱动程序跟踪每一个与其连接的服务器状态。比如,它可能会这样展现一个三节点的副本集:
- Server 1: Primary
- Server 2: Secondary
- Server 3: Secondary
这种数据结构被称为“拓扑结构”。如果在与服务器对话时发生网络错误,驱动程序会将该服务器类型设置为“未知”,随后抛出异常。这种情况下的拓扑结构如下:
- Server 1: Unknown
- Server 2: Secondary
- Server 3: Secondary
Ian的操作不会自动重试,但在驱动程序重新发现主服务器之前,后面的操作无法进行。驱动以每秒两次的频率重新检查服务器,过程持续30秒,直到与主服务器或检测到新选出的主服务器重新连接。目前MongoDB选择新服务器只需要消耗1至2秒,之后0.5秒内驱动就能发现新选出的主服务器。
另一方面,若持续中断超过30秒,驱动程序会抛出“服务器连接超时”的异常。
命令错误时,驱动程序会认为该服务器没有变化——无论该服务器是主服务器还是二级服务器,继续保持原样。这样一来,在拓扑结构中,驱动程序不会改变服务器状态,只会抛出异常。
(想要了解更多关于《服务器发现与监控规范》的内容,可阅读我的另一篇文章
《Server Discovery And Monitoring In PyMongo, Perl, And C》,或者浏览我在MongoDB World 2015大会上发表的演说,主题为“
MongoDB Drivers and High Availability: A Deep Dive”。其中我详细讲述了规范中所描述的数据结构,以及按照该规范驱动程序应当如何对错误做出响应。这为本文的弹性应用提供了丰富的相关背景知识补充。
不当的重试方案
部分案例:
不进行重试
默认不重试:该策略的失败概率为三分之一。
出现暂态网络错误时,Ian给服务器发送信息,但不能确定服务器是否收到该信息。若没有接收到,则事件未计数,也许代码中会记录该错误,然后继续执行操作。
有趣的是,对于长期的网络中断或指令错误,“不重试”反而是正确的选择,因为对于非暂态错误,重试不会有任何结果。
总是重试
一些程序员在编写代码时,要求将所有失败的操作重试五次乃至十次,很多实际应用中都有这样的案例。
i = 0
while True:
try:
do_operation()
break
except network error:
i += 1
if i == MAX_RETRY_COUNT:
throw
去年,我与Rackspace的一名工程师Sam谈及此事,他沿用了一个Python代码库,并实践了这个不良方案:一旦捕获异常就会反复重试。
Sam已经发现这个方案有误,他注意到我依据《服务器发现与监控规范》重新编写了PyMongo的客户端代码,并希望使用稳健性更强的新驱动。Sam只知道还存在更好的重试方案,但不能确切地找出来。实际上,正是和他的这段谈话让我萌发了写作这篇文章的念头。
Sam发现了什么?为什么Ian不应当设定每项操作重试五次甚至十次?
网络暂时中断时,Ian不再有计数遗漏的风险,反而要考虑重复计数的问题,因为如果服务器在网络错误发生前读取了第一条updateOne信息,第二条信息会导致计数再次增加。
另一方面在持续中断时,多次重试会浪费时间。第一次网络错误之后,驱动程序会将主服务器标记成“未知”;Ian执行重试操作后,驱动程序会尝试重连,锁定后续操作并在之后的三十秒内保持每秒两次的检查频率。如果全部失败,代码会再次重试,徒劳地执行重试循环,从而造成应用无故延迟。
命令错误也是同样:若Ian的应用未经授权,尝试五次也不会有所改变。
对网络错误重试一次
到目前我们已经快得出智能方案了。应对网络错误的初次操作失败后,Ian不能确定该错误是暂态的还是持续的,因此只重试了一次,这次重试进入了驱动程序的30秒重试循环。若该网络错误持续30秒,则可能会持续更久,所以Ian放弃继续重试。
然而,面对命令错误完全无需重试。
现在只剩下一个问题:Ian应当如何规避计数过度的问题?
重试网络错误,进行幂等操作
幂等操作的特点是:多次执行与一次执行的结果相同。若Ian的操作全是幂等的,就可以安全重试,无需担心过度计数,或因信息重复发送而造成数据错误。
因此,如何使updateOne信息操作幂等化呢?
幂等操作
MongoDB有四类操作:find(查找)、insert(插入)、delete(删除)和update(更新),前三类易于幂等性,我们先来处理:
Find操作
查询是天然幂等的:
try:
doc = findOne()
except network err:
doc = findOne()
检索一个文档两次和一次等效。
Insert操作
比查询稍困难一些,但也不是特别困难。
doc = {_id: ObjectId(), ...}
try:
insertOne(doc)
except network err:
try:
insertOne(doc)
except DuplicateKeyError:
pass # first try worked
throw
在pseudo-Python中,第一步会生成客户端的唯一ID。MongoDB的ObjectIds正是专门为此设计的,但任何唯一值都能做到。
现在,Ian尝试插入文档。若该进程因为网络错误而失败,则重试。若二次尝试又因为服务器的duplicate key error问题而再次失败,随后第一次尝试成功,则他仅因网络层错误而无法读取服务器响应。如果出现其它错误,则取消操作并抛出异常。
插入是幂等操作。唯一需要注意的是:此处假定Ian除了MongoDB自动创建的one on _id之外,在collection上没有其它唯一索引。若有,那么他必须解析duplicate key error:若在其它索引中,则应用可能存在bug。
Delete操作
若Ian使用唯一键值删除一个文档,则两次操作和一次操作等效。
try:
deleteOne({'key': uniqueValue})
except network err:
deleteOne({'key': uniqueValue})
若第一次操作执行完毕时,发生网络异常,Ian进行重试,则二次删除为空操作(no-op),它无法找到匹配文档。删除操作重试是安全的。
执行大量文档删除更为简单:
try:
deleteMany({...})
except network err:
deleteMany({...})
若Ian对全部筛选出的文档执行删除,但同时出现网络错误,是可以安全重试的。无论deleteOne执行一次还是两次,结果都是相同的——所有筛选出的文档都被删除。
在上述两个案例中都存在竞态条件:两次删除操作间有插入匹配文档的进程,但无论如何都比压根不重试要好。
Update操作
至于更新操作,我们先从自然幂等类型开始讨论。
# Idempotent update.
updateOne({ '_id': '2016-06-28'},
{'$set':{'sunny': True}},
upsert=True)
与最初的例子不同,这里Ian没有增加计数,只是将当日的“sunny(晴朗)”字段设置为True,而设置两次与一次效果相同。在这种情况下,updateOne的重试是安全的。
一般原则下,MongoDB的某些update操作是幂等的,有些不是。$set是幂等的,因为就算重复设置,只要设置为相同的值,结果是一样的,而$inc则是非幂等的。
如果Ian的更新操作是幂等的,则可简单地执行重试:首次尝试若发生网络异常,则可重新操作,非常简单。
try:
updateOne({' _id': '2016-06-28'},
{'$set':{'sunny': True}},
upsert=True)
except network err:
try again, if that fails throw
下面是最困难的案例,最初的非幂等操作updateOne:
updateOne({ '_id': '2016-06-28'},
{'$inc': {'counter': 1}},
upsert=True)
若Ian偶然操作了两次,则计数增至2。
如何将这个操作转化为幂等操作?可以分为两步,分别执行幂等操作,通过将这个操作转化为两步(幂等操作),就能安全执行重试了。
首先,设文档计数值为N:
{
_id: '2016-06-28',
counter: N
}
步骤一:不处理N,Ian仅向一个“pending”数组中加入一个token。这时他需要某个具有唯一性的东西,比如一个ObjectId。
oid = ObjectId()
try:
updateOne({ '_id': '2016-06-28'},
{'$addToSet': {'pending': oid}},
upsert=True)
except network err:
try again, then throw
$addToSet是一种幂等操作,就算执行两次,token也只会加入数组一次。此时文档如下:
{
_id: '2016-06-28',
counter: N,
pending: [ ObjectId("...") ]
}
步骤二:Ian仅根据一条信息,通过其_id和pending token查询文档,删除pending token,增加计数。
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操作若没有完全成功,则彻底无效。因此若将token从pending数组中移除,则计数会且仅会增加1。
将这个updateOne操作视为一个整体,则它属于幂等操作。假设Ian将token移出数组,并在初次尝试操作时增加计数,但由于网络错误没能读取服务器的反馈。则因为查询文档所需的pending token已被移除,所以二次尝试为空操作。
因此Ian可安全重试该updateOne操作。无论执行一次还是两次,文档最终都是相同的:
{
_id: '2016-06-28',
counter: N + 1,
pending: [ ]
}
任务完成了吗?
现在任务已完成:将Ian的初始updateOne操作(重试不安全)通过分离成两个步骤的方式,转化为幂等操作。
这项技术有一些需要注意的地方,其中之一是:如今Ian简单的增加操作需要两次折返,这意味着延迟和负载都会加倍。如果这个事件少计或者多计一次都没关系,刚巧他的朋友被网络线缆绊倒(概率极低),那么他不应使用该技术。
另一个注意点是:他需要每天晚上执行一个清理进程。思考一下:若第二步一直没能完成会怎样。
try:
updateOne({'_id': '2016-06-28',
'pending': oid},
{'$pull': {'pending': oid},
'$inc': {'counter': 1}},
upsert=False)
except network err:
try again, then throw
假设网络中断发生在Ian添加pending token之后,移除token并增加计数之前,则文档处于如下状态:
{
_id: '2016-06-28',
counter: N,
pending: [ ObjectId("...") ]
}
Ian需要在晚上执行清理任务,找出当日失败任务所遗留的pending token,并完成其计数更新。为了避免并发问题,Ian一直等到一天结束,不再有进程执行当日的计数更新时。他通过聚合管道(aggregation pipeline)找出带有pending token的文档,并向当前计数增加数字:
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}
})
对于每个聚合结果,这项任务会使用最终计数来更新源文档,并通过重置来清理pending数组。
因为使用了幂等操作$set和$unset,updateOne操作在重试时是安全的。无论网络状况如何,Ian都可重复重试执行清理任务直到成功。有了清理任务之后,Ian当日事件的最终计数是正确的。
若Ian可以接受这些:增加计数需要两次折返,且可能会到当日结束才能完成,那么对于某些价值较高的操作,这项技术是准确增加计数的弹性策略。
弹性检测
我们提供了策略,但Ian仍然不满意——他该如何检测是否已经通过代码正确实现了呢?为此我提出了“黑管测试”技术。
在进行黑盒子测试时,我们输入数值,得出结果,这个过程就像是用烤面包机烤面包一样。但这些黑盒测试不会导致网络错误、超时、中断或命令错误。模拟网络层会更好一些,但若网络层本身存在bug,那就只能掩盖这些bug。
因此我总结出了黑管测试:使用真实的网络服务器与MongoDB Wire Protocol对话。Ian在连接MongoDB时,将自己的应用与这个服务器相连。但不同于直接连接MongDB,Ian可在测试时按需指令,让该服务器表现为中断、超时等,以彻底检测他全新的错误处理逻辑。
黑管测试系列包括相关文章和代码,其中提供了一切所需内容,协助Ian测试他是否正确运用了这项解决方案。
一个巧妙的方案
最终,我们给了Ian一个答案——一个他可以用在自己应用中的解决方案,它可以正确应对暂态网络错误、宕机和命令错误,而且非常有效、效率很高,也远比想象的简单。
关于弹性MongoDB应用的更多相关信息,请
参考这里。
3 楼 SimpleFunning 2016-08-08 14:55
2 楼 林小涯 2016-08-08 10:15
1 楼 home198979 2016-08-05 16:58