前言
Master-slave 架构可以说是最常用的架构,关系型数据库诸如:mysql,postgreSql,oracle,Nosql诸如:MongoDb,消息队列诸如:Kafka,RabbitMQ等都使用了这种架构,本文将先简要介绍此种架构并介绍高可用Master-slave架构中的一些坑,以及应对之策。
Master-Slave架构的原理
如上图所示,Master-slave可以说是最常见的架构:- 副本之一会被指定为Leader,或者是主库,写入请求会发送给主库,主库会将数据写入自己的本地
- 每当主库写完后,会将变更的日志发送从库,从库按照主库更新的顺序应用所有写入
- 客户端可以从任何库读取数据
同步复制和异步复制
如图中所示,Follower 1是同步复制,Follower 2是异步复制。 同步复制的优点是,从库可以保证与主库一致的最新副本,如果主库挂了,可以从从库找到一致的数据。缺点是,如果从库挂了,没有响应,那么所有的写入操作都将无法处理,直到从库恢复为止。所以通常情况下,这种架构一般都是用完全异步的方式进行同步,这种情况下,如果主库失效,那么所有还没有被复制的数据就可能会被丢失;但是如果从库失效,主库依然可以继续处理写入操作。
主从复制的底层实现
基于语句
主库会记录每个请求的sql,并且将这些sql:insert select update等发送至从库执行,虽然听上去没什么问题,但是:
- 有一些非确定性的函数会在每个副本上产生不一样的结果,比如now(),rand()这种
- 如果有自增列或者语句有依赖数据库现有的数据,那么必须保证从库执行的顺序与主库相同,否则会有不同的结果
- 另外,一些存储过程,触发器的执行,如果有问题的话,他会影响到所有的从库
这种复制方法现在基本不太使用了。
基于预写日志
之前说过,主库在处理写请求时,都会先写WAL(Write Ahead Log),日志的结构非常底层,包含了写入时需要追加的数据序列:磁盘块中哪些数据发生了更改。这就意味着它会比数据库存储结构紧密相关,甚至有可能数据库只因为版本不一致而导致没办法复制。
对于运维来说,这点就很不友好了。如果复制协议对于版本不匹配的话,通常情况下需要停机才可以升级。
基于行的逻辑日志
另一种方法是使用另一种日志,只是这种日志不再于底层存储耦合,比如Mysql的binlog。它一般是以行为密度来描述写入的操作:
- 对于新插入的行,日志包含所有列的新值
- 对于删除的行,日志包含唯一主键来标志已删除的行
- 对于更新的行,日志同样包含唯一定位到这条记录的信息,来记录新数据
这种日志广泛应用,它包含的信息与底层完全解耦,甚至可以基于它复制不同数据库的数据。
Master-Slave架构如何实现高可用
增加设定新的从库
如果我们需要新增新的副本,如何保证新的从库拥有与主库完全一致的数据呢?因为客户端在不断的向主库写入数据,最简单的办法,我们可以禁止主库的写入,然后使用日志进行同步;但这会违背我们高可用的原则。我们一般使用如下方法:
- 大多数的数据库都有一致性快照的功能,我们可以获取它
- 接着讲快照复制到新的从库节点
- 从库复制所有快照时间点之后的数据根据日志(mysql中的binlog)追赶主库
从库挂了怎么办
首先每个从库的硬盘上肯定也会记录所有从主库收到的数据库的变更,如果从库挂了,等他恢复的时候可以从日志中知道发生故障之前最后处理的一个事物,接着连接主库,请求拉取所有之后它断片之后数据变更,之后追赶上主库即可
主库挂了怎么办
主库挂了需要fail over(故障转移):需要将一个新的从库提升为主库,并且重新配置应用客户端,将所有写操作发送到新的主库。通常有如下几个步骤:
1、确认主库失效。现实生活中有很多原因导致失效:崩溃、停电、网卡以及机器被修空调的师傅搬走等原因。没有万无一失的方法,大部分系统采用简单的超时来确定。
2、选一个新的主库。主库的选举通常是以拥有着主库最新数据的那个从库为准,具体的算法可以是paxos raft等共识算法。
3、重新配置路由,写请求发送到新的主库上;并且如果老领导回来了,需要避免“脑裂”的情况,让老领导下台成从库。
failOver同样会有很多问题:
- 如果是异步复制,那么难以避免会有数据的丢失,这些数据写入的丢失往往会被直接忽略
- 写入的丢失还可能会有潜在问题,因为数据库的数据可能会与外部存储(redis)想结合,进行业务上的控制或者缓存。Github曾经有这个故障,一个过时的从库被提升为主库,表采用自增ID列,因为丢失了数据,新主库的计数器落后于老主库的计数器,所以新主库分配了一些已经被老主库分配掉了的ID作为主键,而这些主键又在redis中当缓存使用,导致了隐私数据的泄露。
- 脑裂的问题:老领导恢复过来发现自己还是领导,那么这个集群中就有两个leader,两个leader的话会产生一系列的冲突,例如下:
- 主库失效的超时时间应该如何配置?太长的话,意味着更多的数据可能被丢失,太短的话有可能出现不必要的故障转移(比如只是单纯的系统负载比较高或者网络有延迟)
复制延迟的问题
大部分web应用都是读多写少,所以我们通常采用多副本异步复制的架构,基于异步架构,可能会导致数据库从库和主库的明显不一致,但是经过一段时间后,他们最终会是一致的。
正常情况下,复制延迟大概是几分之一秒,但是在极端的情况下(网络延迟,机器高负荷)延迟可能达到几秒甚至几分钟。
所以这是我们在实际中会遇到的真实问题,了解这些问题之后可以帮助我们更好的设计业务。问题体现在下面几个方面:
读己之写
如上图所示,用户刚提交的数据就查不到了。 我们需要保证读写一致性。读写一致性的意思就是保证用户可以读到自己的写,但是不保证其它用户也可以及时的读到;其它用户可能需要延迟才可以读取到。保证读写一致性的话,有下面几个方向:
- 基于业务,从主库读。举个例子,微信的个人资料通常只能由你自己编辑,那么我们就可以在业务上控制:从主库读取自己的档案,从从库读取其他人的档案
- 如果业务上的资料还是由大多数人编辑呢?这种情况我们可以追踪末次更新的时间,从末次更新时间一分钟内的读取都从主库读。
- 客户端在每次查询时带上一个时间戳(最好是逻辑时钟或者是同步的系统时钟,时钟里面也有一些坑后续会讲到),从库收到请求发现自己的数据还不够新的话,将请求redirect给主库或者其他从库查询
还有一种情况,比如用户有电脑和app端进行查询,电脑更新了信息如何保证手机上可以及时的查看到,如何保证读写一致性呢?有多台设备的话你无法记录末次更新的时间戳(因为手机不可能知道电脑末次操作的时间),不同设备的时间本身也是不可靠的,这种情况,可以根据userID进行散列,保证同一个用户永远会落到一个Datacenter上的主库。
单调读
单调读的意思是时光倒流:
用户2345前一秒还可以查询出结果,后一秒数据就没有了。单调读是这种异常的保证机制,我们需要保证同一个用户的查询请求总是被落到同一个follower上,比如说可以根据userid取模,散列到固定的机器上。
一致前缀度
如上图所示,所有问的问题存储在分区1,回答的答案存储在分区2,Poons问了一个问题,Cake回答,明明是先问问题再回答,但是从观察者的角度来看,时间顺序却已经错乱了。实际上数据库的操作可以分成因果操作和并发操作两种类型,并发操作可以理解为A发起set A操作,B发起set B操作,这两个操作是并发的没有先后因果关系的,数据库对于这种操作只需要确认发生的顺序就可以确定最终的值;对于因果操作:A发起insert 666,B再发起update 666,这两个操作是有依赖关系的,A成功了B才可以成功。如果数据库先接受到B的请求,那么久发生冲突了。 这样的操作叫做因果操作。(下一章会详细讨论)
回到我们的问题,如果要解决这类问题,首先我们需要一个算法分辨出那些操作是并发的,那些操作是因果的;对于这种问问题再回答的典型的因果类型的操作,我们应该尽量让他们分配到同一个partition之内,确保有因果关系的写入到写到同一个分区。
总结
本文讨论了典型的master-salve架构中常见的问题和解决的办法,某些概念还没有进行深入研究,后续介绍多主、无主架构时再说明。
根据CAP原则,最终一致性是无可避免的,但是我们可以做一些基于业务的特殊处理,在保证高可用的同时,尽量去保证数据的一致性。