本章介绍如何在应用程序中与副本集进行交互,包括:
-
- 如何连接到副本集以及故障转移的工作机制;
- 等待写入复制;
- 将读请求路由到正确的成员。
1.客户端到副本集的连接
从应用程序的角度来说,使用副本集与使用单台服务器很像。默认情况下,驱动程序会连接到主节点,并且将所有请求都路由到主节点。应用程序可以像使用单台服务器一样进行读和写,副本集会在后台默默处理热备份。
连接副本集与连接单台服务器非常像。在驱动程序中使用与MongoClient等价的对象,并且提供一个希望连接到的副本集种子(seed)列表。种子是副本集成员,并不需要将所有成员都列出来(虽然可以这么做):驱动程序连接到某个种子服务器之后,就能够得到其他成员的地址。一个常用的连接字符串如下所示:
"mongodb://server-1:27017,server-2:27017"
具体可以査看相关的驱动程序文档。
当主节点挂掉之后,驱动程序会尽快自动找到新的主节点(只要新的主节点被选举出来),并且将请求路由到新的主节点。但是,如果没有可达的主节点,应用程序就无法执行写操作。 在选举过程中,主节点可能会暂时不可用;如果没有可达的成员能够成为主节点,主节点可能长时间不可用。默认情况下,驱动程序在这段时间内不会处理任何请求(读或写)。但是,可以选择将读请求路由到备份节点。
从用户的角度来说,希望驱动程序能够隐藏掉整个选举过程(主节点退位,新的主节点被选举出来)。但是,在很多情况下这是不可能做到的,所以没有哪个驱动程序能够这样处理故障转移。首先,驱动程序仅仅能够将没有主节点的情况隐瞒一段时间:副本集不能在没有主节点的情况下永久存在。其次,如果有操作失败了,驱动程序就知道是主节点挂了,但是无法知道主节点在挂掉之前是否已经正确处理本次请求。所以,驱动程序将这个问题留给了用户:如果新的主节点很快被选举出来,要不要在新的主节点上重新操作?是否要假设最后一次请求已经被旧的主节点处理完成?是否要检査新的主节点以确保它同步了最后的操作?对这些具体问题的处理都取决于你的应用程序。
通常,驱动程序没有办法判断某次操作是否在服务器崩溃之前成功处理,但是应用程序可以自己实现相应的解决方案。比如,如果驱动程序发出插入{"_id" : 1}。文档的请求之后收到主节点崩溃的错误,连接到新的主节点之后,可以查询主节点中是否有{"_id" : 1}这个文档。
2.等待写入复制
前面章节中已经提到,如果希望不管发生什么都将写入操作保存到副本集中,那么必须要确保写入操作被同步到了副本集的“大多数”。
之前,我们使用getLastError命令检査写入是否成功。也可以使用这个命令确保写入操作被复制到备份节点。参数"w"会强制要求getLastError等待,一直到给定数量的成员都执行完了最后的写入操作。MongoDB有一个特殊的关键字可以传递给"w",就是"majority"。在shell中它如下所示:
> db.runCommand({"getLastError" : 1, "w" : "majority"}) { "n" : 0, "lastOp" : Timestamp(1346790783000, 1), "connectionld" : 2, "writtenTo":[ { "id":0 , "host" : "server-0" }, { "id":1 , "host" : "server-1" }, { "id":3 , "host" : "server-3" } ], "wtime" : 76, "err" : null, "ok" : 1 }
注意:getLastError输出信息中的新字段"writtenTo"。只有当使用了 "w"选项并且最后的操作被复制到多个服务器时才会有这个字段。
假设在执行这个命令时只有主节点和一个仲裁者节点可用,那么主节点就无法将这个写操作复制到副本集中的任何成员。getLastError并不知道应该等待多久,所以它会一直等待下去。因此,应该始终为wtimeout选项设置一个合理的值。"wtimeout"是getLastError可以使用的另一个选项,它的值是命令的超时时间,如果超过这个时间还没有返回,就会返回失败:MongoDB无法在指定时间内将写入操作复制到"w"个成员。
下列代码的超时时间是1秒钟:
> db.runCommand({"getLastError" : 1, "w" : "majority", "wtimeout" : 1000})
这个命令可能会由于多种原因失败:其他成员可能挂了,可能落后于主节点,也可能由于网络问题不可访问。如果getLastError超时,应用程序必须要对这种情况作出处理。注意,getLastError超时并不意味着写操作失败了,仅仅表明写操作没能在指定时间内复制到足够多的成员。写操作仍然被复制到了一些成员,而且会尽快传播到其他成员。
通常将"w"用于控制写入速度。MongoDB的写入速度“太快”,主节点上执行完写入操作之后,备份节点还来不及跟上。阻止这种行为的一种常用方式是定期调用getLastError,将"w"参数指定为大于1的值。这样就会强制这个连接上的写操作一直等待直到复制成功。注意,这只会阻塞这个连接上的写操作:其他连接上的写操作仍然会立即执行完成并返回。
如果希望应用程序的行为更自然更健壮,应该定期调用getLastError,同时指定 "majority"和一个合理的超时时间。如果这个命令超时了,需要找出出错原因。
2.1 可能导致错误的原因
假设应用程序将一个写操作发送给主节点,然后调用getLastError (不使用"majority"选项)收到写入成功的反馈,但是在备份节点复制这个写操作之前,主节点崩溃了。
现在,应用程序认为可以访问之前的写操作(getLastError命令的输出信息表明写入操作成功完成),但是副本集中的当前成员并不拥有这个操作的副本。在某个时刻,会有一个备份节点被选举为新的主节点,然后开始接受新的写请求。当之前的主节点恢复之后,会发现它拥有一个(或几个)主节点上没有的写操作。为了纠正这个问题,它会撤销与当前主节点不一致的操作。这些操作不会丢失,但是会被写到特殊的回滚文件中,之后可以手动将这些操作应用到当前主节点。MongoDB不能自动应用这些写操作,因为这些写操作可能会与崩溃之后产生的其他操作冲突。因此,这些操作会消失,直到管理员将这些操作应用到当前主节点。
写入时指定majority可以避免这种情况的发生:如果应用程序最初使用"w": "majority"并且得到了写入成功的确认信息,那么新的主节点就拥有之前执行过的写操作(一个成员必须足够新,才能被选举为主节点)。如果getLastError失败,应用程序就会知道在操作被复制到大多数成员之前主节点就挂了,应用程序可以重新执行这个操作。
2.2 "w"的其他值
"majority"并不是唯一一个可以传递给getLastError的"w"参数的值,MongoDB允许将"w"指定为任意整数,如下所示:
> db.runCommand({"getLastError" : 1, "w" : 2, "wtimeout" : 500})
这个命令会一直等待,直到写操作被复制到两个成员(主节点和一个备份节点)。
注意,"w"的值包含了主节点。如果希望写操作被复制到n个备份节点,应该将"w"指定为n+1 (包括主节点)。将"w"设置为1相当于没有传入"w"选项,因为MongoDB只会检查主节点是否成功执行了写操作,getLastError始终会做这样的检查。
使用常量数值的弊端在于,如果副本集的配置发生了变化,就需要修改你的应用程序。
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-east"} > config.members[5].tags = {"dc": "us-east"} > config.members[6].tags = {"dc": "us-east"}
"tags"字段是一个对象,每个成员可以拥有多个标签。例如,"us-east"数据中心的服务器可能是"high quality"服务器,这样的话,可以将其"tags”字段配置为{"dc": "us-east", "quality" : "high"}。
第二步是创建自己的规则,可以通过在副本集配置中创建"getLastErrorMode"字段实现。每条规则的形式都是"name" : {"key" : number}}。"name"就是规则的名称,名称应该能够表明这条规则所做的事情,方便客户端理解,客户端在调用 getLastError时才能够正确选择自己需要的规则。在本例中,将这个规则命名为"eachDC",或者更抽象一点,比如"user-level safe"。
这里的"key"字段就是标签键的值,所以在这个例子中是"dc"。这里的number是需要遵循这条规则的分组的数量。在本例中,number是2 (因为我们希望写操作被复制到"us-east"和"us-west"两个分组中各自至少一台服务器)。number的意思是“保证写操作复制到number个分组,每个分组内至少一台服务器上”。
在副本集配置中添加"getLastErrorModes"字段,创建下面的规则,重新执行配置:
> config.settings = {} > rs.reconfig(config) > config.settings.getLastErrorModes = [{"eachDC" : {"dc" : 2}}]
"getLastErrorModes"位于副本集配置中的"settings"子字段,这个字段下面包含一些副本集级别的可选设置。 现在,可以对写操作应用这条规则:
> db.too.insert({"x" : 1}) > db.runCommand({"getLastError" : 1, "w" : "eachDC", "wtimeout" : 1000})
注意,应用程序开发者并不会知道到底有哪些服务器使用了 "eachDC"规则,而且可以在不改变应用程序的情况下任意修改具体规则。可以添加新的数据中心,或者是更改副本集成员数量,而应用程序不必知道这些改变。
3.2 保证写操作被复制到可见节点中的"大多数”
通常,隐藏节点在某种程度上是二等公民:发生故障时不会转移到隐藏节点,也不能将读操作路由到隐藏节点。你可能只关心隐藏节点是否收到了写请求,剩下的就交给隐藏成员自己去解决吧。
假设我们拥有5个成员,host0到host4,其中host4是个隐藏成员。我们希望确保写操作被复制到非隐藏节点的大多数,也就是host0、hostl、host2和host3中的至少三个成员。要创建这样一条规则,首先为非隐藏节点设置标签:
> 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)
然后就可以在应用程序中使用这条规则了 :
> db.foo.insert({"x" : 1}) > db.runCommand({"getLastError" : 1, "w" : "visibleMajority", "wtimeout": 1000})
命令会一直等待,直到写操作被复制到至少三个非隐藏节点。
3.3 创建其他规则
可以无限制地创建各种规则。记住,创建自定义的复制规则有两个步骤。
(1) 使用键值对设置成员的"tags"字段。这里的键用于描述分组,可能会有 "data_center"、"region"或者"server Quality"等键。这里的值表示服务器所属的分组。例如,对于“data_center”这个键,可以将一些服务器标为"us-east",将另一些标为"us-west",其他的标为"aust"。
(2) 基于刚刚创建的分组创建规则。规则总是形如{"name" : {"key": number}},表示写操作返回成功之前需要复制到至少number个分组,每个分组内的一台服务器上。例如,可以创建一个{"twoDCs" : {"data_center":2}}规则,意思是说,在写操作成功之前,需要确保写操作被复制到两个数据中心,每个数据中心内至少一台服务器上。
然后就可以在getLastError中使用刚刚创建的规则了。
规则是一种非常强大的副本集配置方式,虽然它理解和设置起来都有些复杂。除非有非常特殊的复制要求,否则使用"w": "majority"就已经非常安全了。
4.将读请求发送到备份节点
默认情况下,驱动程序会将所有的请求都路由到主节点。这通常也正是你需要的,但是可以通过设置驱动程序的读取首选项(read preferences)配置其他选项。可以在读选项中设置需要将査询路由到的服务器的类型。
将读请求发送到备份节点通常不是一个好主意。虽然在某些特定情况下这是有意义的,但是通常应该将全部请求都路由到主节点。如果你正在考虑将读请求发送到备份节点,请先从各个方面好好权衡之后再做决定。这里不建议这么做的原因,也会介绍需要这么做的特定情况。
4.1 出于一致性考虑
对一致性要求非常高的应用程序不应该从备份节点读取数据。
备份节点通常会落后主节点几毫秒,但是,不能保证一定是这样。有时,由于加载问题、配置错误、网络故障等原因,备份节点可能会落后于主节点几分钟、几个小时甚至几天。客户端驱动程序并不知道备份节点的数据有多新,所以如果将读请求发送给一个远远落后于主节点的备份节点,客户端也不会感觉到任何问题。可以将备份节点隐藏掉,以避免客户端读取它,但是这是一个手动过程。如果你的应用程序需要读取最新的数据,那就不要从备份节点读取数据。
如果应用程序需要读取它自己的写操作(例如,先插入一个文档,然后再查询它),那么不应该将写请求发送给备份节点(除非写操作像前面那样,使用"W"在返回之前被复制到所有备份节点)。否则的话,可能会出现应用程序成功执行了一次写操作,却读不到这个值的情况(因为读请求被发送给了备份节点,而之前的写操作还没有被复制到这个备份节点)。客户端发送请求的速度可能会比备份节点复制操作的速度要快。
为了能够始终将写请求发送给主节点,需要将读选项设置为Primary(或者不管它,默认就是Primary)。如果没有主节点,査询就会出错。这就是说,如果主节点挂了,应用程序就不能执行查询了。但是,如果你的应用程序需要在故障转移期间或者出现网络故障时正常运行,或者不接受陈旧的数据,那么这就是一个可接受的选项。
4.2 出于负载的考虑
许多用户会将读请求发送给备份节点,以便实现分布式负载。例如,如果你的服务器每秒只能处理10 000次査询,而你需要进行30 000次査询,可能就需要设置几个备份节点,并且让它们分担一些数据加载的工作。但是,这种扩展方式非常危险,很容易导致系统意外过载,一旦出现这种问题,很难恢复。
假设你遇到了上面提到的情况:每秒30 000次读请求。你决定创建一个拥有4个成员的副本集:每个备份节点的压力都在可承受范围内,整个系统也在正常运转。
后来,某一个备份节点崩溃了。
现在剩余的每个成员的负载都是100%。如果需要恢复刚刚崩溃的成员,它就需要从其他成员处复制数据,这就会导致其他成员过载。服务器过载经常导致性能变慢,副本集性能进一步降低,然后强制其他成员承担更多的负载,导致这些成员变得更慢,这是一个恶性死循环。
过载会导致副本集性能降低,然后会导致剩余的备份节点远远落后于主节点。突然间,你的副本集中就有一个成员崩溃了,还有一个成员远远落后于主节点,导致副本集的所有成员都过载了,进而整个副本集都没有喘息的空间。
如果明确知道每台服务器能够承受的负载,你可能会觉得自己能够更好地应对这种情况:使用5台服务器,而不是4台,这样如果一台服务器崩溃,并不会导致副本集过载。但是,即使你的计划非常完美(只有预期数量的服务器可能会挂掉),仍然需要处理其他服务器负载过大的情况。
一个更好的选择是,使用分片作分布式负载。后面会介绍分片相关的知识。
4.3 何时可以从备份节点读取数据
在某些情况下,将读请求发送给备份节点是合理的。例如,你可能希望应用程序在主节点挂掉时仍然能够执行读操作(而且你并不在意读到的数据是否是最新的)。这是最常见的将读请求发给送备份节点的原因:失去主节点时,应用程序进入只读状从备份节点读取数据,这种读选项叫做主节点优先(primary preferred).
从备份节点读取数据有一个常见的参数是获得低延迟的数据。可以将读选项设置为Nearest,以便将请求路由到延迟最低的成员(根据驱动程序到副本集成员的ping时间)。如果你的应用程序需要从多个数据中心中读取到最低延迟的同一个文档,这是唯一的方法。如果你的文档与位置的相关性更大(在这个数据中心内的应用服务器需要得到某些文档的最低延迟版本,而另一个数据中心内的应用服务器需要得到 另一些文档的最低延迟版本),那就应该使用分片。注意,如果应用程序要求低延迟的读和写,那必须要使用分片:副本集只允许在主节点上进行写操作(不管主节点在什么位置)。
如果要从一个落后的备份节点读取数据,就要牺牲一致性。另一方面,如果希望写操作返回之前被复制到所有副本集成员,就要牺牲写入速度。
如果应用程序能够接受任何陈旧程序的数据,那就可以使用Secondary或者 Secondary preferred读选项。Secondary始终会将读请求发送给备份节点。如果没有可用的备份节点,请求就会出错,而不是重新将读请求发送给主节点。对于不在乎数据新旧程度并且希望主节点只处理写请求的应用程序来说,这是一种可行的方式。如果对于数据新旧程度有要求,不建议使用这种方式。
Secondary preferred会优先将读请求路由到可用的备份节点。如果备份节点都不可用,请求就会被发送到主节点。
有时,读负载与写负载完全不同:读到的数据与写入的数据是完全不同的。为了做离线处理,你可能希望创建很多索引,但是又不想将这些索引创建在主节点上。在这种情况下,可以设置一个与主节点拥有不同索引的备份节点。如果希望以这种方式使用备份节点,最好是使用驱动程序创建一个直接连接到目标备份节点的连接,而不是连接到副本集。
应该根据应用程序的实际需要选择合适的选项。也可以将多个选项组合在一起使用:如果某些读请求必须从主节点读取数据,那就对这些请求使用Primary选项。如果另一些读请求并不要求数据是最新的,那么可以对这些读请求使用Primary preferred选项。如果某些请求对低迟延的要求大过一致性要求,那么可以使用 Nearest 选项。