MongoDB 4.2——从应用程序连接副本集
从应用程序连接副本集1、客户端到副本集的连接行为2、在写入时等待复制3、自定义复制保证规则3.1、保证复制到每个数据中心的一台服务器上3.2、保证写操作被复制到大多数非隐藏节点3.3、创建其他保证规则4、将读请求发送到从节点4.1、一致性考虑4.2、负载考虑4.3、由从节点读取数据的场景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 中可以像下面这样使用writeConcerntry{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” 设置为 n1以包括主节点。设置 “w” : 1 相当于没有传入 “w” 选项因为这样只会检查主节点上的写入是否成功。直接使用数字的缺点在于如果副本集的配置发生了更改则必须修改应用程序。3、自定义复制保证规则写入副本集的大多数成员被认为是“安全”的。然而有些副本集可能有更复杂的要求你可能希望确保写操作被复制到每个数据中心的至少一台服务器或大多数的非隐藏节点上。副本集允许你创建可以传递给 “getLastError” 的自定义规则以确保写操作被复制到所需的任何服务器组合上。3.1、保证复制到每个数据中心的一台服务器上相比数据中心内部数据中心之间的网络故障更为常见而且相对于多个数据中心同等数量的服务器的停止运行整个数据中心都停止运行的可能性更高。因此你可能需要一些特定于数据中心的写操作逻辑。在确认成功之前确保写操作到达了每一个数据中心。这意味着如果之后数据中心掉线了那么每个其他数据中心至少有一个本地数据副本。要实现这种机制首先应按数据中心对成员进行分类。可以通过在副本集配置中添加一个 “tags” 字段来做到这一点varconfigrs.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个。要为此创建规则首先要为非隐藏成员设置标签varconfigrs.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。