从应用程序连接副本集
1、客户端到副本集的连接行为
MongoDB 的客户端开发库(也叫“驱动程序”)用于管理与 MongoDB 服务器端的通信,无论服务器端是单机的MongoDB 实例还是副本集。对于副本集,默认情况下,驱动程序会连接到主节点,并将所有流量都路由到此节点。应用程序可以像与单机服务器通信一样执行读写操作,同时副本集会在后台悄悄地处理热备份。
连接副本集与连接单机服务器非常类似。在驱动程序中使用 MongoClient 类(或等价类),并提供一个种子列表供驱动程序连接。种子列表就是服务器地址列表。种子是应用程序将读取和写入数据的副本集成员。你不需要列出种子列表中的所有成员(尽管这样做也可以)。当驱动程序连接到种子服务器时,它可以从其中发现其他成员。一个连接字符串通常看起来像下面这样:
"mongodb://server-1:27017,server-2:27017,server-3:27017"
如果想提供更强的容错能力,那么也可以使用 DNS 种子列表连接格式来指定应用程序连接到副本集的方式。使用DNS 的优点是可以轮流更改 MongoDB 副本集成员所在的服务器,而无须重新配置客户端(特指连接字符串)。
所有 MongoDB 驱动程序都遵守服务器发现和监控(SDAM)规范。驱动程序会持续监视副本集的拓扑结构,以检测应用程序对集合中成员的访问能力是否有变化。此外,驱动程序会监视副本集,以维护关于哪个成员是主节点的信息。
副本集的目的是使数据在面对网络分区或服务器停止运行时具有高可用性。在一般情况下,副本集通过选择一个新的主节点来响应这类故障,以便应用程序继续读写数据。如果一个主节点发生故障,则驱动程序会自动找到新的主节点(只要有一个主节点被选举出来),并将请求尽快路由到新的主节点。不过,当没有可达的主节点时,应用程序将无法执行写操作。
在选举过程中,主节点可能会短暂地不可用。如果没有可达成员能够成为主节点,则主节点可能长时间不可用。默认情况下,驱动程序在此期间不会处理任何请求——无论读或写。如果应用程序需要,则可以配置驱动程序将读请求路由至从节点。
用户希望驱动程序对其隐藏整个选举过程(主节点退位,新的主节点被选举出来)。然而,由于一些原因,没有驱动程序能够以这种方式处理故障转移。首先,驱动程序只能在一段时间内隐藏缺少主节点的情况。其次,驱动程序经常因为操作失败而发现主节点已停止运行,这意味着驱动程序不知道主节点在停止运行之前是否处理了该操作。这是一个不可避免的分布式系统问题,因此当它出现时,需要一种策略来处理它。如果很快选出一个主节点,那么应该在新的主节点上重新尝试该操作吗?是否要假设最后一次请求已经被旧的主节点处理了?是否要检查新的主节点已经同步了这个操作?
事实证明,正确的策略是最多重试一次。要解释清楚这一点,需要先看一下都有哪些策略可供选择。归结起来就是:不重试、在重试一定次数后放弃或者最多只重试一次。我们还需要考虑错误的类型,这可能是问题的根源。在尝试对副本集进行写操作的过程中,可能会遇到 3 种类型的错误:短暂的网络错误、持续的中断(网络或服务器)或由服务器拒绝的错误命令(比如未授权)引起的错误。下面针对每种类型的错误来看一下重试的选择。
为了便于讨论,这里简单地以一个对递增计数器的写操作作为示例。如果应用程序试图增加计数器,但没有从服务器得到响应,我们就不知道服务器是否收到了消息并执行了更新。因此,对于一个短暂的网络错误,如果遵循不重试这个写操作的策略,则可能会发生计数过少现象。对于持续中断或命令错误,不重试是正确的策略,因为无论重试多少写操作都不会产生预期的结果。
对于短暂的网络错误而言,如果遵循重试一定次数的策略,则可能会发生计数过多现象(在第一次尝试成功的情况下)。对于持续中断或命令错误,多次重试只会浪费资源。
再来看一下仅重试一次的策略。对于短暂的网络错误,可能会发生计数过多现象。对于持续的中断或命令错误,这是正确的策略。然而,如果可以确保操作是幂等的会如何?无论做一次还是多次,幂等操作都会有相同的结果。利用幂等操作,在发生网络错误时重试一次最有可能正确处理所有 3 种类型的错误。
从 MongoDB 3.6 开始,服务器端以及所有 MongoDB 驱动程序都支持可重试写选项。有关如何使用此选项的详细信息,请参阅驱动程序文档。对于可重试写,驱动程序将自动遵循“最多重试一次”策略。命令错误会返回给应用程序,让客户端进行处理。网络错误会在适当的延迟后重试一次,这个延迟应该能适应一般情况下的主节点选举。当可重试写选项打开时,服务器端会为每个写操作维护唯一的标识符,因此可以确定驱动程序何时试图重试一个已经成功的命令。它会简单地返回一条消息表示写入成功,从而克服短暂的网络故障所引起的问题,而不会再次进行写入。
2、在写入时等待复制
根据应用程序的需要,你可能希望服务器端在将所有写操作复制到副本集大多数成员之后再进行确认。在一些罕见的情况下,当一个副本集的主节点发生故障,并且新选出的主节点(之前为从节点)没能复制到旧主节点的最后一次写操作时,这些写操作会在旧主节点恢复时进行回滚。这些回滚数据可以被恢复,但需要人为进行干预。对于许多应用程序来说,有少量回滚的写操作不是问题。例如,在一个博客应用程序中,回滚掉某位读者的一两条评论几乎不会造成什么危害。
然而,对于其他一些应用程序,应该避免所有的写操作回滚。假设应用程序向主节点发送了一个写操作请求,之后它收到了已经写入成功的确认,但是在所有从节点还未复制该写入之前,主节点就崩溃了。应用程序认为能够访问到该写操作,但实际上副本集的当前成员并没有这次写入的副本。
在某个时刻,一个从节点可能被选为主节点并开始接收新的写入。当之前的主节点恢复时,会发现它有一些不存在于新主节点上的写操作。为了纠正这一点,它会撤销与当前主节点的操作序列不匹配的任何写操作。这些操作不会丢失,而是会被写入特殊的回滚文件中,这些文件必须手动应用于当前的主节点。MongoDB 不能自动应用这些写操作,因为它们可能与崩溃后发生的其他写操作冲突。因此,这些写操作会消失,直到管理员有机会将回滚文件应用于当前的主节点。
如果对写入大多数成员有要求,则可以防止这种情况的出现:如果应用程序得到了写入成功的确认,那么新的主节点必须拥有该写入的副本(要被选为主节点,成员必须拥有最新的数据)。如果应用程序没有收到来自服务器端的确认信息或收到了错误信息,那么它会知道需要进行重试,因为在主节点崩溃之前,写操作没有传播到副本集的大多数成员。
因此,如果要保证无论副本集出现什么情况写操作都可以被持久化,那么必须确保每个写操作都传播到副本集的大多数成员。可以通过使用 writeConcern 实现这一点。
从 MongoDB 2.6 开始,writeConcern 就被集成进了写操作中。例如,在 JavaScript 中,可以像下面这样使用writeConcern:
try {
db.products.insertOne(
{ "_id": 10, "item": "envelopes", "qty": 100, type: "Self-Sealing" },
{ writeConcern: { "w" : "majority", "wtimeout" : 100 } }
);
} catch (e) {
print (e);
}
驱动程序中的特定语法会因编程语言的不同而不同,但语义都是一样的。这个例子将写关注指定为了 “majority”。一旦成功,服务器端将回复如下消息:
{ "acknowledged" : true, "insertedId" : 10 }
但在这个写操作被复制到副本集的大多数成员之前,服务器端不会发出响应。只有这样,应用程序才会收到这个写操作成功的确认。如果在指定的超时时间之内没有写入成功,则服务器端将回复一条错误消息:
WriteConcernError({
"code" : 64,
"errInfo" : {
"wtimeout" : true
},
"errmsg" : "waiting for replication timed out"
})
“majority” 写关注以及副本集选举协议确保了在主节点的选举中,只有那些拥有经过确认的写操作的从节点才能被选为主节点。通过这种方式,可以保证不会发生回滚。还可以对超时选项进行调节,以便在应用程序层检测并标记任何长时间运行的写操作。
"w"的其他选项
“majority” 并不是唯一的 writeConcern 选项。MongoDB 还允许将 “w” 指定为任意的数字,如下所示:
db.products.insertOne(
{ "_id": 10, "item": "envelopes", "qty": 100, type: "Self-Sealing" },
{ writeConcern: { "w" : 2, "wtimeout" : 100 } }
);
这个命令会一直等待,直到写操作被复制到两个成员(主节点和一个从节点)中。
注意,“w” 的值包含了主节点。如果希望将写操作传播到n 个从节点,则应该将 “w” 设置为 n+1(以包括主节点)。设置 “w” : 1 相当于没有传入 “w” 选项,因为这样只会检查主节点上的写入是否成功。
直接使用数字的缺点在于,如果副本集的配置发生了更改,则必须修改应用程序。
3、自定义复制保证规则
写入副本集的大多数成员被认为是“安全”的。然而,有些副本集可能有更复杂的要求:你可能希望确保写操作被复制到每个数据中心的至少一台服务器或大多数的非隐藏节点上。副本集允许你创建可以传递给 “getLastError” 的自定义规则,以确保写操作被复制到所需的任何服务器组合上。
3.1、保证复制到每个数据中心的一台服务器上
相比数据中心内部,数据中心之间的网络故障更为常见,而且相对于多个数据中心同等数量的服务器的停止运行,整个数据中心都停止运行的可能性更高。因此,你可能需要一些特定于数据中心的写操作逻辑。在确认成功之前确保写操作到达了每一个数据中心。这意味着,如果之后数据中心掉线了,那么每个其他数据中心至少有一个本地数据副本。
要实现这种机制,首先应按数据中心对成员进行分类。可以通过在副本集配置中添加一个 “tags” 字段来做到这一点:
> var config = rs.config()
> config.members[0].tags = {"dc" : "us-east"}
> config.members[1].tags = {"dc" : "us-east"}
> config.members[2].tags = {"dc" : "us-east"}
> config.members[3].tags = {"dc" : "us-east"}
> config.members[4].tags = {"dc" : "us-west"}
> config.members[5].tags = {"dc" : "us-west"}
> config.members[6].tags = {"dc" : "us-west"}
“tags” 字段是一个对象,每个成员可以有多个标签。如果位于 “us-east” 数据中心的是一台“高质量”服务器,那么就需要一个 “tags” 字段,比如 {“dc”: “us-east”,“quality” : “high”}。
第二步是添加自己的规则,可以通过在副本集配置中创建一个 “getLastErrorModes” 字段来实现。“getLastErrorModes” 这个名称是遗留下来的,因为在 MongoDB 2.6 之前,应用程序使用一个名为"getLastError" 的方法来对写关注进行指定。在副本集配置中,对于 “getLastErrorModes”,每个规则的形式都是 {“name” : {“key” : number}}。“name” 是规则的名称,它应该以客户端能够理解的方式描述规则所做的事情,因为在调用 getLastError 时会用到这个名称。在本例中,我们称这个规则为 “eachDC” 或更抽象的名称,比如 “user-level safe”。
“key” 字段就是标签的键,因此在本例中是 “dc”。number 是满足此规则所需的分组的数量。在本例中,number 是 2(因为我们希望写操作被复制到 "us-east"和 “us-west” 中的至少各一台服务器上)。number 表示的意思是“number 个分组中,每组至少一台服务器”。
如下所示,将 “getLastErrorModes” 添加到副本集配置中,并重新执行配置来创建规则:
> config.settings = {}
> config.settings.getLastErrorModes = [{"eachDC" : {"dc" : 2}}]
> rs.reconfig(config)
“getLastErrorModes” 位于副本集配置的 “settings” 子对象中,它包含了一些副本集级别的可选设置。
现在可以对写操作应用这条规则:
db.products.insertOne(
{ "_id": 10, "item": "envelopes", "qty": 100, type: "Self-Sealing" },
{ writeConcern: { "w" : "eachDC", wtimeout : 1000 } }
);
注意,规则在某种程度上对应用程序开发者是透明的:应用程序不需要知道 “eachDC” 中的哪些服务器要使用这个规则,并且可以在不修改应用程序的情况下对规则进行变更。可以添加新的数据中心或更改副本集成员,而应用程序不需要知道这些改变。
3.2、保证写操作被复制到大多数非隐藏节点
通常情况下,隐藏成员在某种程度上是“二等公民”:发生故障时不会转移到隐藏节点,它们也无法执行任何读操作。因此,你可能只关心非隐藏成员是否接收到了写操作,并让隐藏成员自己对剩下的工作进行处理。
假设有 5 个成员,从 host0 到 host4,其中 host4 是隐藏成员。我们希望确保写操作被同步到大多数非隐藏成员上,即至少是 host0、host1、host2 和 host3 中的 3个。要为此创建规则,首先要为非隐藏成员设置标签:
> var config = rs.config()
> config.members[0].tags = [{"normal" : "A"}]
> config.members[1].tags = [{"normal" : "B"}]
> config.members[2].tags = [{"normal" : "C"}]
> config.members[3].tags = [{"normal" : "D"}]
没有为隐藏成员 host4 设置标签。
接下来,为这些服务器中的大多数添加如下规则:
> config.settings.getLastErrorModes = [{"visibleMajority" : {"normal" : 3}}]
> rs.reconfig(config)
这样命令会一直等待,直到至少 3 个非隐藏成员拥有这条写入数据。
3.3、创建其他保证规则
可以没有限制地创建各种规则。记住,创建自定义复制规则有两个步骤。
- 通过分配键–值对来对成员设置标签。键用于描述分类,比如,可能会有像 “data_center”、“region” 或"serverQuality" 这样的键,而值决定了服务器属于某个分类中的哪个组。例如,对于键 “data_center”,可能会有一些服务器的标签为 “us-east”,一些服务器的标签为 “us-west”,以及一些服务器的标签为 “aust”。
- 根据创建的分类来创建规则。规则总是采用 {“name”: {“key” : number}} 的形式,表示在返回成功之前,number 个分组的至少一台服务器上必须有这个写操作的数据。例如,可以创建一个规则 {“twoDCs” :{“data_center” : 2}},表示在写操作成功之前,被设置标记的两个数据中心中各至少有一台服务器需要确认此写操作。
然后就可以在 getLastErrorModes 中使用这个规则了。
尽管规则的理解和设置都比较复杂,但它是一种非常强大的副本集配置方式。除非有相当特殊的复制需求,否则使用 “w” : “majority” 是非常安全的。
4、将读请求发送到从节点
默认情况下,驱动程序会将所有请求路由到主节点。这通常正是你想要的,但可以通过设置驱动程序的读偏好(read preference)来配置其他选项。读偏好允许你指定查询应该发送到的服务器端的类型。
将读请求发送到从节点通常不是一个好主意。虽然这在某些特定的情况下是有意义的,但通常应该将所有请求发送到主节点。如果你正在考虑将读请求发送到从节点,那么请确保在此之前已非常仔细地权衡利弊。本节会介绍为什么将读请求发送到从节点是一个糟糕的想法,以及在什么情况下这样做是有意义的。
4.1、一致性考虑
对一致性读取要求非常高的应用程序不应该从从节点读取数据。
通常从节点落后于主节点的时间在几毫秒之内。然而,这一点是无法保证的。有时,由于负载、错误配置、网络错误等原因,从节点可能会延迟几分钟、几小时甚至几天。客户端驱动程序无法知道从节点的数据有多新,因此可能会将查询发送到一个远远落后的从节点。可以向客户端的读请求隐藏从节点,但这是一个手动过程。因此,如果应用程序需要读取最新的数据,那么就不应该从从节点读取。
如果应用程序需要读取它自己的写操作(比如,先插入一个文档,然后再查询并找到它),那么就不应该将读操作发送到从节点(除非写操作使用前面讲到的 “w” 等待复制到所有从节点)。否则,应用程序可能成功执行了写操作,然后尝试读取数据,却无法找到这个值(因为读请求被发送到了尚未复制到此数据的从节点)。客户端发出请求的速度可能会快于复制操作执行的速度。
要始终将读请求发送到主节点,就需要将读偏好设置为primary(或者不进行设置,因为 primary 是默认值)。如果没有主节点,那么查询就会出错。这意味着如果主节点停止运行,应用程序就不能执行查询。然而,如果应用程序在故障转移或网络分区期间可以对停止运行的情况进行处理,或者读取陈旧数据是不可接受的,那么这当然是一个可以接受的选项。
4.2、负载考虑
许多用户会将读请求发送到从节点以分配负载。如果服务器端每秒只能处理 10 000 次查询,而你需要处理的查询有 30 000 次,那么可以设置几个从节点,让它们承担一些负载。然而,这是一种危险的扩展方式,因为很容易意外地出现系统过载,而且一旦发生很难恢复过来。
假设你遇到了刚才描述的情况:每秒读取 30 000 次。你决定创建一个拥有 4 个成员的副本集(其中一个成员被配置为没有投票权,以防止在选举中发生平票的情况)来处理这个问题:每个从节点都低于其最大负载,并且系统运行良好。
直到其中一个从节点崩溃了。
现在,剩下的每个成员都在处理 100% 的负载。如果需要恢复刚刚崩溃的成员,则可能需要从其他服务器复制数据,而这会导致剩余的服务器不堪重负。服务器过载通常会使它的执行速度变慢,进一步降低副本集的处理能力,迫使其他成员承担更多的负载,这样就陷入了恶性循环。
过载还会导致复制的速度变慢,使得剩余的从节点落后于主节点。突然间,你的副本集中有的成员崩溃了,有的成员发生了滞后,所有成员都过载了,没有任何回旋的余地。
如果你很清楚一台服务器可以承受多大的负载,那你可能会觉得自己可以更好地进行应对:使用 5 台服务器,而不是 4 台,这样就不会因为一台服务器停止运行而出现副本集过载的情况。然而,即使你的计划非常完美(并且只有预期数量的服务器停止运行),仍然需要处理其他服务器负载过大的情况。
一个更好的选择是使用分片来分配负载。第 14 章会讨论如何设置分片。
4.3、由从节点读取数据的场景
在某些情况下,将应用程序的读请求发送到从节点是合理的。例如,你可能希望应用程序在主节点发生故障时仍然能够执行读操作(并且你不关心这些读取到的数据是否陈旧)。这是将读请求分配给从节点的最常见的场景:当失去主节点时,副本集会进入一个临时的只读模式。这种读偏好叫作 primaryPreferred。
从从节点读取数据的一个常见理由是读取延迟更低。可以指定 nearest 作为读偏好,基于从驱动程序到副本集成员的平均 ping 时间,将请求路由到延迟最低的成员。如果应用程序需要在多个数据中心以低延迟访问相同的文档,那么这是唯一的方法。然而,如果你的文档和位置的相关性更大(一个数据中心的应用程序服务器需要低延迟访问某些数据,而另一个数据中心的应用程序服务器需要低延迟访问其他数据),那么这应该通过分片来完成。注意,如果应用程序需要低延迟读和低延迟写,则必须使用分片:副本集只允许在主节点上进行写操作(无论主节点在什么位置)。
如果从落后的从节点读取数据,则必须要牺牲一致性。另外,如果希望等待写操作复制到所有的成员,则需要牺牲写入速度。
如果应用程序确实能够接受陈旧的数据,那么可以使用secondary 或 secondaryPreferred 作为读偏好。secondary 总是将读请求发送给从节点。如果没有可用的从节点,则会出现错误,而不是将读请求发送给主节点。它可以用于不关心数据的陈旧程度并且希望仅将主节点用于写操作的应用程序。如果对数据的新旧程度有要求,则不建议使用这种方式。
如果有从节点可用,则 secondaryPreferred 会将读请求发送到从节点。如果没有从节点可用,那么请求将被发送到主节点。
有时候,读负载与写负载有很大的不同,比如,正在读取的数据与正在写入的数据是完全不同的。为了进行离线处理,你可能需要很多索引,而又不希望将这些索引创建在主节点上。在这种情况下,可以设置一个具有与主节点不同索引的从节点。如果想以这种方式使用从节点,那么就需要让驱动程序直接连接到从节点,而不是使用副本集连接。
应该根据应用程序的需求来考虑哪些选项更合适。你也可以将这些选项组合在一起:如果某些读请求必须从主节点读取数据,则对它们使用 primary 选项;如果另一些读请求不要求数据是最新的,那么可以对它们使用primaryPreferred;如果某些请求的低延迟需求大过一致性需求,那么对它们使用 nearest。
1120



被折叠的 条评论
为什么被折叠?



