单台机器的配置是有上限的。为了提升数据库的处理能力,则需要多台机器参与数据的存储与检索。使用多台机器也有其他的优势:
- 可伸缩性:可以依据写入负载、读取负载动态增加和减少对应的节点。
- 高可用性:当单台节点发生故障的情况下,可以利用故障切换,将流量切换到其他节点上。
- 延迟:将节点部署到不同的数据中心,从而每个用户可以从地理位置上最新的数据中心获取服务,降低延迟。
当然,使用多台机器也有一系列复杂的问题需要解决。例如:同步延迟,冲突处理,故障恢复,数据一致性、分布式事务等。后面将会深入介绍。
将数据分布在多个节点上有两种常见的方式:
- 复制:不同的节点上保存数据的相同副本。复制提供了冗余,如果一些节点不可用,其余节点可以直接提供服务。
- 分区:将数据拆分成较小的子集(分区),从而将不同的分区数据存储到不同的节点(分片)。
复制和分区是不同的机制,核心区别在于每个节点是否存储了全部数据。在具体应用的时候,复制和分区往往同时使用,如下图所示,partiton 1 和 Partition 2 组成全量数据,同时会对 partition 1 和 partition 2 进行复制。
在本文中,将假设你的数据集非常小,每台机器都可以保存整个数据集的副本。讨论三种流行的复制算法:单领导者(single leader,单主)、多领导者(multi leader,多主)和无领导主(leaderless,无主)。同时还需要讨论使用同步复制还是异步复制?如何新增一个节点?如何处理失败的副本?如何解决数据冲突等问题。
单主复制
存储了数据库拷贝的每个节点被称为副本(replica)。当存在多个副本时,则需要考虑如何将数据都落在所有副本上。最常见的解决方案是单主复制(主从复制、基于领导者的复制)。其工作原理如下:
- 选择一个副本作为主库(领导者)。当客户端向数据库写入时,则必须将请求发送主库,主库将数据写入到本地存储。
- 其他副本被称为从库(追随者、备库、热备)。每当主库将新数据存入到本地时,它也会将数据变更发送给所有的从库,称之为复制日志或变更流。每个从库从主库拉取日志,按照与主库相同的顺序处理写入,并更新本地数据库的副本。
- 当客户端想要从数据库读取日志,它可以向任意副本进行查询。但只有主库才能接受写操作。
如下图所示,user: 1234 更新数据时写入主库,从库通过变更流更新本地副本。当 user:2345 查看数据时,可以读取任意一个副本。
同步还是异步
按照复制的延时,将复制分为两种,同步复制(主库等待从库复制完成才返回成功)和异步复制(主库不等待从库复制,直接返回成功,由异步任务进行复制)。有些数据库会提供一个复制选项,有些则硬编码其中的一个。
在下图中,user: 1234 更新用户头像。将请求发送到主库,主库又会将数据变更发送到自己的从库。从库 1 的复制是同步的,即从库 1 写入成功之后才返回用户结果。从库 2 的复制是异步的,主库没有等待从库 2 响应直接返回结果。
同步复制的优点是,保证所有的副本数据是一致的。如果主库突然宕机,所有写入的数据可以从从库找到。缺点是,吞吐性能差,任何一次写入都需要等待所有从库响应。如果从库没有响应,主库就无法处理任何写入。异步复制的优点是写入速度快,缺点也比较明显,由于主从复制是异步的,会存在一定延时。如果主库宕机,从库可能会丢失数据。
如果将所有从库设置为同步复制是不切实际的,任何一个节点故障将会导致系统停止服务。可以考虑使用一个折中的方案:半同步复制。即选择一个从库作为同步复制,其他从库作为异步复制。如果从库变得缓慢或者不可用,可以选择其他的从库改为同步复制。这样可以保证至少有两个节点存储了全量数据的副本:主库和同步从库。
新增从库
为了减轻各个节点的负载或替换宕机的从库,会尝试增加新的从库,那如何保证从库能够获取全部的数据呢?由于主库会有数据不断写入,数据不断变化,标准的文件复制不能完整复制全部的数据。最简单的方式就是主库停止写入,使用文件复制创建从库。然而,这会导致线上服务不可用,可以使用下面的方式创建一个从库:
- 在某一时刻获取主库的快照(截止到某一时刻的全部数据,不需要锁定主库)。
- 拉取主库快照,复制到从库上。
- 将从库连接到主库,并拉去快照之后的变更数据。这里需要精确的关联快照与主库复制日志的位置。在 PG 数据库中称为日志序列号,MySql 中称为二进制日志坐标。
- 当从库追赶上最新的数据变更,即可以向其他从库一样处理变更数据。
节点故障
在互联网中,很容易导致节点故障(网线被挖断、机房过热、空调漏水、断电等)。因此,当从库或主库发生故障之后,应能及时进行处理,减少损失。在基于主从复制的场景下,如何保证高可用呢?
从库失效:追赶恢复
从库本地磁盘会存储从主库拉取的变更日志。如果从库发生宕机或网络中断,故障恢复之后,从库可以知道失效之后执行的最后一个事务,连接主库,进行追赶,数据追赶完成后即可以正常提供服务。如果磁盘也发生故障,导致数据丢失,则需要使用新得节点创建从库。
主库失效:故障切换
主库失效处理起来相对麻烦。需要客户端连接到新的主库,从库也需要从新的主库同步数据,整体流程如下所示。这一过程叫做故障切换:
- 确认主库失效。很多情况(断电,崩溃)会导致节点失效,没有什么万无一失的方法确定具体问题。常见的确认失效的方法就是超时:多个节点相互发送消息,如果一个节点长时间(30s)没有响应,则认为失效。
- 从从库中选择一个作为主库。当确认主库失效时,则需要从从库中选择一个作为主库。为了保证数据尽可能的不丢失,则需要选择一个具有最新副本的数据作为从库。让所有从库确认其中一个从库做为主库,这是一个共识问题,后面会详细讨论。
- 重新配置以启用主库。确认新主库之后,需要客户端将数据发送到新的主库。旧主库恢复之后,需要知道自己变成了从库,并从最新的主库中同步数据。
故障切换其实还需要考虑需要的细节问题:
- 数据丢失。如果采用的是异步复制(大多数系统是采用这种方式),则会面临从库无法获取到最新数据的问题。
- 脑裂。即在选择从库的时候,会有多个从库认为自己是主库。或者主库没有意识到自己失效,而从库已经选取了一个新的主库。这就会导致会有多个节点会同时处理写入请求,然而又没有处理冲突的相应策略。往往会在系统中增加检测,如果发现多个主库,则强制关闭一个。
- 失效确认。最常见的判断节点失效的方式就是看其响应时间。如果设置超时太短,则可能会导致不必要的故障切换。如果设置超时太长,则会导致系统不可用。例如:临时的系统负载升高,网络堵塞,会很容易恢复。如果出现长时间的负载升高,故障切换则会导致大面积瘫痪(节点变少,负载不断升高)。
节点故障、数据不一致、不可靠的网络,这些是分布式中常见的问题。遗憾的是,这些问题并没有完美的解决方案。需要针对具体的业务场景作出相对的取舍。
复制的实现
主库和从库之间的数据同步需要怎么实现呢?主要分为四种方式:基于语句、基于预写日志、基于行、基于触发器。
基于语句
最简单的方式就是基于语句进行复制。主库将执行的 SQL 语句发送到从库,从库对 SQL 语句进行回放。然后,基于语句的复制方式需要注意下面的一些问题:
- 不确定函数。例如,在 SQL 中执行 now() 这种函数。在从库回放执行的时候,生成的日期和主库不一致。
- 使用了自增主键或依据现有列。如果在模式定义中试了自增的列,由于 SQL 到达从库的顺序可能会发生变更,从而导致数据不一致。
- 有副作用的语句(例如:触发器、存储过程、用户定义的函数)可能会在每个副本上产生不同的副作用,除非副作用是绝对确定性的。
基于语句方式的优点在于传输数据少,但具体实现的时候需要考虑许多不确定的执行因素。大多数据库并不使用这种方式。
基于预写日志(Write Ahead Log, WAL)
在介绍数据存储的时候讲到了 LSM 树和 B 树,其中提到,为了保证数据不丢失,会先讲数据追加到文件(WAL)中。可以讲 WAL 同样发送到从库中,从而构建完整的数据。基于 WAL 的方案,可以保证回放后的数据是完全一致的;缺点是不同的存储引擎对于 WAL 的实现是不同的。这就意味着,如果数据库发生升级,而导致 WAL 无法向前兼容。则需要先升级从库,然后在升级主库,进行数据库版本的升级。
基于行
无论是基于语句还是基于 WAL。它严格依赖数据库的具体实现,无法实现异构(即讲数据同步到另一种数据库上)。然而,通过数据行的形式可以屏蔽数据库的具体实现,直接讲数据的变更转换为具体的行变更。例如:执行 sql: update table where name = k。主库将会把所有变更的行发送到从库,从库解析每一行的变更进行相关处理。
基于行复制的优点在于,从库接收到的数据变更消息不再依赖具体引擎的实现。拿到的是每一行数据的变更。缺点是,拿到的数据量可能会变多。
基于触发器
触发器允许你将数据更改(写入事务)发生时自动执行的自定义应用程序代码注册在数据库系统中。触发器有机会将更改记录到一个单独的表中,使用外部程序读取这个表,再加上一些必要的业务逻辑,就可以将数据变更复制到另一个系统去。例如,Databus for Oracle 和 Bucardo for Postgres 就是这样工作的。
基于触发器的复制通常比其他复制方法具有更高的开销,并且比数据库内置的复制更容易出错,也有很多限制。然而由于其灵活性,它仍然是很有用的。
复制延迟
使用主从复制的方式,数据的写入全部经过主库,而数据的读取则可以经过全部的副本。采用这种方式除了可以避免单点故障之外,还可以降低系统的负载。特别是在读多写少的系统中。增加从库的数量,可以降低读的负载,从而增加系统的吞吐。
在真实的场景中,采用同步复制的方式是不现实的,任意一个节点故障,将会导致整个系统不可用。而使用异步方式最大的缺点就是主库写入的数据,未能及时同步到从库上,称之为复制延迟(主从延迟)。如果主库停写一段时间,从库是可以追赶上主库,将这种状态称之为最终一致性。
复制延迟可能会几毫秒,也可能会几分钟。具体还是依赖网络环境,系统的负载等不同的情况。如果应用程序可以忍受不同程度的复制延迟,则无需进行任何处理。下面讲介绍一些复制延迟会产生的问题,以及常用的处理方法:
读己之写
复制延迟最常见的问题就是不能立马读到自己写入数据,会导致用户认为修改数据失败。如下图所示,用户插入一条评论之后,立马刷新页面,发现自己的评论不见了。
针对这种情况,则需要保证写后读一致性,读己之写一致性。它保证用户写入数据之后,刷新页面能立马读到自己的写入。对于其他用户,不保证能立马读到。下面是常见的解决方案:
- 针对一些只能由部分用户修改的数据,可以每次都从主库读取。例如:用户个人页,里面的数据只有本人能够修改。读取自己的主页数据时直接从读库读取,读取其他用户的主页时从从库读取。
- 对于一些都可编辑的数据,从主库读意义则不是那么大了。这将会导致所有的读流量都会转移了主库,无法达到降低负载和延迟的效果。这种情况下可以使用其他标准来决定是否读主库。例如,追踪上次更新的时间,如果时间时间在 1min 内可以走主库。此外,可以监控主从延迟的时间,防止向延迟超过 1min 的从库发送查询。
- 客户端记录数据更新的时间,如果在此时间之前的数据均已同步到从库,则从从库读取,否则从主库读取。
不管使用哪种解决方案,本质上就是要通过不同的标准来判定请求走主库还是从库。可以选择某些请求读写都走主库,也可以通过更新的时间戳+主从延迟来判断请求从主库还是从库。
单调读
第二种常见的情况就是读到的数据乱序,也叫时光倒流。如下图所示,用户 user:1234 写入了一条评论。user: 2345 第一次查看评论时从一个延时很小的从库上读到了 user:1234 的评论。当他第二次查看评论时,请求经过了一个延迟很高的从库,却又看不到了评论(会以为 user:1234 删除了评论)。
单调读可以解决这种问题,这是一个比强一致性更弱,但是比最终一致性更强的保证。单调读虽然不能保证读到的数据是最新的,但他能保证的是读到的数据都是按照时间顺序发生的,而不会发生时间倒序的读。
为了保证单调读,常见的解决方案是对用户 id 进行散列处理,保证同一个用户的读请求永远由同一个从库处理。
一致前缀读
下面这个例子是分片数据库中常见的一个问题,在一个章节会介绍分片相关的细节。如下图所示:Poons 向 Cake 问了一个问题,而 Observer 却先看了答案,然后看到了问题,着为了因果一致性。
要防止这种异常,则需要另一种保证:一致前缀读。即一系列的写入是按照某一个顺序发生,则数据的读取也是按照相应的顺序读取。在从主复制的模式中,数据的是由单点写入,可以保证写入顺序,采用单调读的形式可以保证读取顺序。然而在分区场景下,数据是写入多个节点,数据读取也是在不同的分区中。常见的解决思路就是讲具有因果顺序的读写都定向到同一个分区中,从而使用单调读解决。
多主复制
应用场景
上面介绍了单主复制,单节点写入,多节点读取。然后,在单主复制中,增加节点无法缓解写负载。基于这个问题,就可以很容想到多主复制的方案。即在多个副本中,选取多个节点服务写请求,解决单点的写入瓶颈。多主复制主要的应用场景是多数据中心建设。
如下图所示,搭建一个多数据中心,不同数据中心之间往往相隔较远。对于全球提供服务的数据中心往往进行跨洋搭建。搭建数据中心的优势在于可以降低延迟,单个数据中心故障可以直接切换到另一数据中心。在下图中,每个数据中心内部使用主从复制,不同数据中心均对外提供读写能力,不同数据中心的主库进行数据复制,然后再通过主库向数据中心内部的从库进行数据同步。
多主复制也有一定的缺点,那就是数据冲突。即两个主库同时更新相同的数据时,如何处理冲突。例如,自增主键、数据约束等都会在主库与主库之间同步时发生问题。
冲突处理
多主复制的场景下,最难处理的问题就是写入冲突。如下图所示,user 1 和 user 2 同时修改 id = 123 的数据,且都修改成功。那最终的结果是以那个用户的为准呢?
冲突检测
在单主复制模型中,如果两个用户同时修改一个数据,第二个操作将会阻塞或者被回滚并强制用户重试。在多主模型中,可以使用同步的冲突检测。即等待数据复制到所有副本后返回成功。但是,通过这样做,你将失去多主复制的主要优点:允许每个副本独立地接受写入。如果你想要同步冲突检测,那么你可能不如直接使用单主复制。
避免冲突
处理冲突最好的方式就是避免发生冲突。冲突产生的本质原因是在多个主库上同时操作相同的数据,而导致冲突产生。如果应用程序可以将处理相同的数据的请求发送到同一个主库上,那么冲突将不会发生。
例如:用户更新自己的详情页。对用户 id 进行哈希运算,保证更新请求都到同一个主库上。从而避免多个主库同时进行 update user where id = $id 的操作,从而避免冲突。
但是,如果一个数据中心发生故障,流量发生调度。则有需要处理数据冲突的可能。
收敛至一致的状态
在单主复制中,字段的更新都是按顺序进行的,从一个值更新为另一个值,最后一次的更新决定其最终值。然而,在多主复制中,多个主库之间会同时更新同一条记录。如上面的例子所示,造成 leader2 的最终值为 B,leader1 的最终值为 C,从而造成多个数据中心的数据不一致。
数据库需要有一种收敛的方式解决冲突,保证所有副本在完成复制之后收敛至一个共同的值。常见的冲突解决策略包括:
- LWW(Last Write Win) 最后写入胜利。这是一种常见的冲突解决方案。即为每次一个数据变更携带一个时间戳,当冲突发生时,以时间戳较大的变更为准,丢失时间戳小的变更。这种方法的缺点是容易丢失数据,分布式场景下时间戳并不一定能表示时间真实发生的先后顺序(始终漂移)。
- 为每个副本分配一个 ID,ID 编号更高的写入具有更高的优先级。
- 以某种方式将这些值合并在一起。例如,上面的例子中,合并后的数据为 A/B。
- 用一种可以保留所有信息的显式数据结构存储信息,并编写处理冲突的应用程序代码(可以暴露给用户,让其处理)。
自定义冲突处理
不同的业务场景针对冲突的解决的策略也各不相同。因此,需要编写业务代码根据其场景来解决冲突。业务代码的执行时机分为写时执行和读时执行。写时执行:当数据库副本执行数据复制发生冲突时,会调用冲突处理代码解决数据冲突。这些操作往往是异步执行的,用户并不感知。读时执行:数据冲突发生时会把完整的上下文存储下来,数据读取时会通过代码处理冲突信息,对数据进行展示和回写。
复制拓扑
复制拓扑是用来描述数据从一个节点复制到另一个节点的路径。如果只有两个节点,那只能有一个拓扑结构。如下图所示,如果系统中存在多个节点,则需要考虑数据的复制拓扑。a: 环型拓扑,b: 星型拓扑,c:全部到全部拓扑。
在环型和星型中,一次数据变更会流经多个节点「例如:插入一条记录, A 同步给 B, B 同步 C, C 再同步给 A」。因此,需要解决数据回环问题。常见的做法是为每一次变更增加一个标识符,如果数据库已经处理过这次变更则直接丢弃,否则向其他节点传送。而在全部对全部结构中,由于数据变更是直接发送到目标节点,并不会发生消息的转发,无数据回环的问题。星型和环型的另一个问题是,如果一个节点发生故障,则会导致复制消息断流,造成数据不一致。
然而,全部到全部也会有自己的问题。如果各节点之间的网络传输速度不固定,则会导致消息执行顺序不一致。如下图所示,leader 1 插入一条记录,先同步到 leader 3,leader 3 更新了记录;由于 leader 1 和 leader 1 、leader 3 的网络速度不一致,导致先执行 update 在执行 insert。
这是一个因果关系的问题。常见的解决策略就是使用时间戳,丢弃时间戳旧的变更。由于本地始终不可靠,也可以采用版本向量的方式,即使用版本号替换时间戳。
无主复制
在单主复制和多主复制中,均是客户端向一个主库发送写请求,从库应用主库的操作。主库决定写入的顺序,从库应从主库的写入顺序。哪有没有一种方式,是同时写入所有的副本呢?
无主复制,即整个结构中没有主库。写入请求发送到所有的数据库。这种方式的缺点就是写入放大。由于没有主节点,不用无需考虑从库选举的算法。
如下图所示,写入数据时,当一个节点不可用时,写入成功 2/3 即认为成功。数据读取时,节点恢复。从两个节点中读到最新数据,同时会向落后的节点发送写入命令,追赶数据。
在现实应用中,采取无主复制的数据库并不多,这里不再进行详细介绍。
总结
数据库复制技术主要解决一下问题:
- 高可用:当一个节点出现故障时,服务能够及时进行故障切换,保证服务的可用性。
- 延时:使用多副本,可以让用户读写物理位置最近的数据中心,降低延时。
- 可伸缩:可以依据服务的负载,动态增加或减少节点数据,使用负载。
常见的复制算法包括:
- 单主复制:在多个副本中,选择一个副本作为主库,所有写入请求都发送到这个主库上,其他节点则通过主库的变更日志更新数据。
- 多主复制:选取多个副本作为主库,同时承载写入请求。不同主库之间相互发送变更流。
- 无主复制:没有任何主库,所有节点均承载写入请求。
每种算法均有自己的优缺点。最常用的就是单主复制算法,它很容易理解,且不会产生数据冲突。然而,对于写入请求量大的场景,则需要搭建数据中心,并使用多主复制算法。它可以解决单主的写入瓶颈问题,然后却需要解决数据冲突、复制拓扑问题。
复制可以是同步的,也可以是异步的。大多数采用的是异步的策略,它可以避免因单点问题而导致集群不可用。异步复制最大的问题就是数据延迟。常见的解决策略有:
- 写后读一致性:保证用户总能读到自己写入的数据「用户读取自己的数据时走主库,通过时间戳和主从延迟判断是否走主库」。
- 单调读:用户读到的数据总是按照时间顺序发生的「使用哈希保证同类型请求永远到同一个节点上」。
- 一致前缀读:保证各事件发生的因果一致性「先哈希保证读写到同一个节点,使用版本号保证逻辑关系」。
相较于单主复制,多主复制又引入了数据冲突的问题。常见的解决方案有:
- 冲突避免:将相同数据同求发送到同一个主库,从而退化为单主模型。
- 最后写入胜利(Last Write Win): 没有变更增加一个时间戳,时间戳小的写入将被舍弃。
- 自定义冲突解决:编写应用程序代码,检测到冲突后执行代码。
- 冲突合并:使用相关的数据结构,保留完整的上下文信息,应用展示冲突。
- 版本向量:为每次变更增加一个版本号,从而解决时间戳中本地时钟不准确的问题。