存储 系列 数据复制(Replication)

复制:通过互联网络在多个(机器)实例上保存相同数据的副本。

Posted by lichao modified on February 22, 2022

复制/多副本的目的:

  • 高可用性:即使某台机器(或多台机器、整个数据中心)出现故障,系统也能正常运行;
  • 连接断开与容错:允许(存储)应用程序间 出现网络中断时继续工作;
  • 低延迟:将数据放置在距离用户较近的地方,从而实现更快的交互;
  • 可扩展性:采取多副本读取,大幅度提高系统操作的吞吐量。

本文将讨论以下复制方案:(单)主从复制、多主复制、无主复制。

主从复制

主从复制:写操作路由到某一个主节点(主副本),从节点通过网络复制得到从副本。

  • 主节点:支持读/写操作
  • 从节点:仅支持读操作 多副本节点不可避免会引入 数据一致性的问题,不同的复制实现都会做出数据一致和性能的权衡。 主从同步

注:这里的主节点/从节点 可以认为是一种角色,单个实例可能有多种角色(参考Kafka的分区)

主节点把新数据写入本地存储后,然后将数据更改作为日志发送给所有从副本。每个从节点获得更改日志之后将其应用到本地,且严格保持与主副本相同的写入顺序。

常见复制方案

异步复制(Asynchronous Replication)

异步复制描述:

  1. 客户端发送写请求;
  2. 主节点写入本地存储后,直接响应客户端写成功;
  3. 主节点用另外一个线程,将 新数据 发送到从节点。 异步复制

优势:写性能好,延迟低,系统的吞吐性能更好; 问题:可能会发生数据丢失;

  • 主节点尚未来得及将全部数据 复制到从节点,然后挂了;从节点晋升为新主节点后,是没有全部数据的,此时对外服务相当于丢失了部分数据。

工程实现上(如Redis)通常可配置:当从节点落后主节点过多的时候,主节点会拒绝写入,以防止数据丢失过多的问题。如 共3个从节点,当有2个从节点复制落后均超过10秒,主节点则拒绝写入。

全同步复制(Fully Synchronous Replication)

  1. 客户端发送写请求;
  2. 主节点写入本地存储后,将新数据发送到所有从节点;
  3. 当【所有从节点】都返回成功后,才响应客户端写成功; 全同步复制

优势:数据保证完整,不会发生数据丢失(除非全部节点磁盘都损坏);

问题:延迟取决于最慢的那个从节点;只要和其中一个节点网络连接有问题,延迟将拉得很高。

  • 因为性能的问题,工程应用较少。

工程实现上,因为性能上的问题,往往只会作为一个可选项(如kafka生产者配置中的acks=all)

半同步复制(Semisynchronous Replication)

  1. 客户端发送写请求;
  2. 主节点写入本地存储后,将 新数据 发送到所有从节点;
  3. 当有【一个从节点】返回成功后,就响应客户端写成功; 半同步复制

优势:半同步属于异步和全同步的折中方案,保证数据完整的前提下尽量减少对性能的影响;

问题:写性能比不上异步复制,数据可靠性比不上全同步复制。

工程实现上(如MySQL)

  1. 从节点确认的数量通常是可以配置的,即可以配置 N 个从节点返回ok后,才响应客户端成功。
  2. 考虑到节点间的网络抖动问题,可以采用 异步复制 作为兜底方案。
  3. 从节点响应ok后,并非就意味着数据 在从节点 就立马可见了。
  4. MySQL的从就是使用Relay Log先接住,然后再在本地执行,最终才写入binlog(此时才可见)。

写过半成功(Quorum)

过半成功,主要出现在 etcd、zookeeper 等基于共识算法(Raft/Zab等)的分布式存储组件中:

  1. 客户端发送写请求;
  2. 主节点写入本地存储后,将 新数据 发送到所有从节点;
  3. 当有【过半节点】写成功后,就响应客户端写成功;
  4. 主节点最终的 Commit 到 从节点。 写过半成功

因为要等待过半的节点响应写成功,性能 会介于 半同步 和 全同步之间。

比丢数据更可怕的是 有多个节点同时进行写入(即期望只有单个主节点的集群中出现多个主节点,即脑裂),过半成功是可以天然解决脑裂的问题的(只会有一个主节点写成功);

常见存储组件的复制方案

1
2
3
4
5
6
7
|      数据库     |  异步        |    半同步    |          过半成功         |    全同步     |           
|      Redis     |  支持默认 |     \       |             \            |       \      |
|      MySQL     |  支持默认 |    支持      |             \            |       \      |
|      ByteDoc   |  支持默认 |    支持      |      支持majority     |       \      |
|      Abase1.X  |  支持默认 |    支持      |             \            |       \      |
|      RocketMQ  |  支持默认 |    支持      |  开源版本4.5+ 支持(raft协议)|  \  |
|      BMQ       |依赖于HDFS保证持久性acks=all时写入HDFS后会flush|  \ |  \  |      \      |

其中全同步因为性能问题,工程实现上比较少;过半成功主要应用一些特殊的场景(如选主等);最常见的选型是在 异步和半同步 之间,关于两者的性能差距参考:

  • MySQL5.7:(增强)半同步相较于异步复制 损失约为10~20%左右;
  • RocketMQ:官方博客号称 (半) 同步复制相较于异步复制 性能损失约为20%~30%(2016-03);

保证数据可靠性(持久性)的核心配置还有 是否立即刷盘(同步刷盘);同复制一样,刷盘策略越严格,数据可靠性(持久性)越高,但相对的性能会越低。以MySQL 的 redo log 为例:

  • 当事务进行提交时,会根据 innodb_flush_log_at_trx_commit 配置决定刷盘行为:
    • 0:不会主动触发写入磁盘的操作;(由后台线程进行每秒刷盘);进程挂了会出现数据丢失;
    • 1:将log buffer写入log file,并且flush(刷盘);断电也不会出现数据丢失;
    • 2:将log buffer写入log file,但是不进行flush(刷盘);即只写到OS文件系统内存,真正刷盘动作交给OS;DB宕机不会出现数据丢失,但是断电会(OS直接挂了)

高可用实现方案

在分布式系统中,因为各种原因,网络连接出现问题、实例节点挂掉等问题往往是不可避免的。

  • 从节点失效:不会影响到写的可用性,读时只要路由到其他节点即可。当节点恢复后,再通过指定复制偏移值,到主节点那边拉取数据,将进度赶回来即可。
  • 主节点失效:对于很多存储来讲,主节点失效意味着 某些记录无法正常写入了,这时需要进行重新选一个新的主节点出来,客户端写请求路由到新主上,保证存储系统正常进行。

本节主要讨论主节点失效的情况。这里会涉及到两个点:

  1. 故障检测:如何 判定 主节点 已经失效?
  2. 故障转移:选择哪个 从节点 作为新的 主节点?由谁触发?客户端怎么知道变更?

Redis: HA组件-哨兵 HA组件-集群


MySQL: HA组件-Orchestrator


RocketMQ: 高可用机制


Zookeeper(ZAB)高可用机制

复制滞后的问题

主从复制中除了节点故障,一个常见的问题是 从节点的复制滞后所带来的问题。目前主流的 存储在处理写请求时,不会等待所有副本都写入且应用后才响应成功,而是退而求其次,追求数据的 “最终一致性”:即写操作停止一段时间后,从节点将会追赶上主节点的进度,最终会达到数据的一致。

读自己的写(Reading Your Own Writes )

用户提交一些数据后,往往接下来就要看下刚提交的数据(如评论)。提交的数据会在主节点写入,而读则有可能路由到从节点,如果此时从节点尚未复制到该数据,那显然是读不到该数据的。从用户的角度看,刚提交的数据似乎消失了: 读自己的写 这种情况下,需要“写后读一致性”,也称读写一致性。该机制保证他们总能看到自己最近提交的更新。但对其他用户则没有任何保证,这些用户的更新可能会在稍后才能刷新看到。 基于主从复制的系统,保证写后读一致性的方案可以参考:

  1. 所有读操作都路由到主节点;这也意味着放弃从节点带来的读扩展性;
  2. 针对可能变化的内容时,到 主节点读取;否则,在从节点读取;比如文章只有作者编辑,其他人无法编辑,此时可以作者读主节点,其他用户读从节点;
  3. 跟踪最近更新的时间,近N分钟内,总是在从主节点读取;同时监控从节点复制滞后的程度,避免从那些滞后超过N分钟读取;
  4. 客户端记录最近更新的时间戳,后续附带在请求中;系统可以确保对该用户提供读服务时都应该至少包含了该时间戳的更新。如果不够新,要么交由另一个副本来处理,要么等待直到副本接收到了最近的更新。

单调读(Monotonic Reads)

从多次读的角度看,因为读取时可能会路由到不同的从节点,可能会出现数据消失的奇怪现象。 如下面的用户读评论,先路由到节点1 读到最新数据,刷新下路由到节点2发现数据消息消失了: 单调读问题

单调读一致性可以保证:读到某个新值之后,不会再读到比这个更旧的值; 实现单调读的一些方式:

  1. 所有读操作都路由到主节点;这也意味着放弃从节点带来的读扩展性;
  2. 每个用户总是路由到固定的同一副本进行读取;比如根据用户id进行哈希路由;
  3. 客户端记录自己读到过的最新偏移值,连接(新)节点时,如果该节点的偏移值落后于客户端记录的,则客户端拒绝建立连接,转向和另外一个节点进行尝试。Zookeeper目前就是采取这样的方式。

注:目前 公司MySQL的路由方式 是无状态轮询,即默认读取会在N个从节点中进行轮询读取,并不保证单调读一致性;同样不会保证读写一致性;有这样的需求则需要读主库。

前缀一致读(Consistent Prefix Reads)

还有一种复制滞后导致因果反常的情况,主要发生在分区(分片, Sharding) 下 出现的问题。 比如下面两个人的对话:

1
2
Poons先生:Cake夫人,您能看到多远的未来? 
Cake夫人:通常约10s, Poons先生。 

现在想象有第三个人正在通过从节点 收听 这个对话,Cake夫人所说的话很快就到达从节点了,而Poons先生的话则滞后了较长的一段时间才到达从节点。这时候观察到的对话为:

Cake夫人:通常约10s, Poons先生。 
Poons先生:Cake夫人,您能看到多远的未来? 

前缀一致读问题

为了防止这种逻辑混乱的问题,需要引入 前缀一致读:即对于一系列按照某个顺序发生的写请求,那么读取这些内容时也会按照当时写入的顺序。

实现前缀一致读的一个简单的方案是确保任何具有因果顺序关系的写入都交给同一个分区来完成,但是相对地,整体性能上需要作出妥协。

实现细节

复制日志

复制日志实现:

  1. 基于语句的复制:主节点记录所执行的每个写请求(操作语句)并将该操作语句作为日志发送给从节点。
  2. 基于预写日志( WAL) 传输:
  3. 基于行的逻辑日志复制
  4. 基于触发器的复制

基于语句的复制的问题:

  1. 任何调用非确定性函数的语句,如 NOW()获取当前时间,或 RAND()获取一个随机数等,可能会在不同的副本上产生不同的值。
  2. 如果语句中使用了自增列,或者依赖于数据库的现有数据(例如,UPDATE WHERE <某些条件>),则所有副本必须按照完全相同的顺序执行,否则可能会带来不同的结果。进而,如果有多个同时并发执行的事务时,会有很大的限制。
  3. 有副作用的语句(例如,触发器、存储过程、用户定义的函数等),可能会在每个副本上产生不同的副作用。

添加从节点

配置新的从节点的主要操作步骤如下:

  1. 在某个时间点对主节点的数据副本产生一个一致性快照。
  2. 将此快照拷贝到新的从节点。
  3. 从节点连接到主节点并请求快照点之后所发生的数据更改日志。
  4. 获得日志之后,从节点来应用这些快照点之后所有数据变更,这个过程称之为追赶。

处理节点失效

  • 从节点失效,追赶式恢复:根据副本的复制日志,从节点可以知道在发生故障之前所处理的最后一笔事务,然后连接到主节点,并请求自那笔事务之后中断期间内所有的数据变更。在收到这些数据变更日志之后,将其应用到本地来追赶主节点。
  • 主节点失效,节点切换:选择某个从节点将其提升为主节点,之后的写请求会发送给新的主节点,然后其他从节点要接受来自新的主节点上的变更数据,这一过程称之为切换。

主节点自动切换

主节点自动切换步骤通常如下:

  1. 确认主节点失效:大多数系统都采用了基于超时的机制,节点间频繁地互相发送心跳存活消息,如果发现某一个节点在一段比较长时间内(例如30s )没有响应,即认为该节点发生失效
  2. 选举新的主节点:可以通过选举的方式(超过多数的节点达成共识)来选举新的主节点,或者由之前选定的某控制节点来指定新的主节点。候选节点最好与原主节点的数据差异最小,这样可以最小化数据丢失的风险
  3. 重新配置系统使新主节点生效:如果原主节点之后重新上线,可能仍然自认为是主节点,而没有意识到其他节点已经达成共识迫使其下台。这时系统要确保原主节点降级为从节点,并认可新的主节点。

主节点自动切换过程中的难点:

  1. 如果使用了异步复制,且失效之前,新的主节点并未收到原主节点的所有数据;在选举之后,原主节点很快又重新上线并加入到集群,接下来的写操作会发生什么?新的主节点很可能会收到冲突的写请求,这是因为原主节点未意识的角色变化,还会尝试同步其他从节点,但其中的一个现在已经接管成为现任主节点。常见的解决方案是,原主节点上未完成复制的写请求就此丢弃,但这可能会违背数据更新持久化的承诺。
  2. 如何设置合适的超时来检测主节点失效呢?主节点失效后,超时时间设置得越长也意味着总体恢复时间就越长。但如果超时设置太短,可能会导致很多不必要的切换。例如,突发的负载峰值会导致节点的响应时间变长甚至超肘,或者由于网络故障导致延迟增加。如果系统此时已经处于高负载压力或网络已经出现严重拥塞,不必要的切换操作只会使总体情况变得更糟。

在某些故障情况下,可能会发生两个节点同时都自认为是主节点。这种情况被称为脑裂,它非常危险:两个主节点都可能接受写请求,并且没有很好解决冲突的办法,最后数据可能会丢失或者破坏。

多主复制

多主节点复制:同一个记录,支持多个主节点写入,同时每个主节点 还扮演 其他主节点的从节点。

多数据中心

在一个数据中心内部使用多主节点基本没有太大意义,其复杂度已经超过了能够带来的好处。 通常多主架构都是部署在多数据中心: 多数据中心 距离数据中心1 较近的用户会在 数据中心1对应的主节点进行写请求,而靠近数据中心2则在那进行读写。然后两个主节点之间会进行数据复制同步,对于同一条记录多处编辑,则需要进行冲突解决。


相对于单主复制,多主复制主要差异在:

  • 性能:在多数据中心下,单主复制必须将主节点放在某个数据中心,其他数据中心的写请求必须路由到 主节点所在的 数据中心,在跨洋等场景下的延迟是不可容忍的。多主复制则支持 就近访问,先在本地数据中心快速响应,再通过异步复制 将变化同步到其他中心。用户将具有良好的体验。
  • 容忍数据中心失效:单主复制下,如果主节点所在的数据中心发生故障,必须切换至另一个数据中 心,将其中的一个从节点被提升为主节点,期间将无法处理写入请求。在多主复制下,每个数据中心的主节点都是独立运行,发生故障的数据中心在故障恢复后再更新到最新状态即可。
  • 容忍网络问题:数据中心之间的延迟一般比较高(多为广域网),往往不如数据中心内部的本地网络可靠。对于(单)主从复制模型,如果复制方案是 半同步这类的,将十分依赖网络的性能和稳定性。多主复制模型下通常采用异步复制,可以更好容忍这类问题,比如网络闪断不会妨碍写请求最终成功。

分片(Sharding)下也会支持多个主节点写入,但是每个分片的数据能够写入的节点仍然是一个。这里提到的主节点复制则是指 哪怕是同一分片的数据,也支持多个节点写入。 如:用户数据库进行分片,一共100个主库+N100个从库,根据用户id进行分片路由。比如张三的id为50,修改个人信息时,则路由到 id%100==50的 第50个主库,然后复制到对应的从库;而新加坡那边也有一个数据中心,同样有100个主库+N100个从库,当张三从美国到新加坡时,读写操作将路由到 新加坡这边的第 50个库。这两个(分片)库的数据应当是(最终)一致的。

写冲突

多主复制的最大问题是可能发生写冲突。 比如两个用户同时修改同一文章的标题,用户1将A修改成B,用户2将A修改成C,因为两个主节点之间是异步复制的,两个用户都收到了修改成功的提示,但是后续的复制中,将产生写冲突: 写冲突

避免冲突

处理冲突最理想的策略是避免发生冲突:对于特定的记录写请求总是通过同一主节点,这样就不会有没冲突了。 比如 某个用户要更新自己的数据,我们确保 特定用户的更新请求总是路由到特定的数据中心进行写操作,不同的用户可能对应不同的主数据中心(例如根据用户的地理位置选择)。从用户角度看,这基本上等价于单主从复制模型。 然而,当某个数据中心发生故障,不得不将流量路由到其他数据中心,或者用户已经漫游到另外一个位置,因为更靠近新的数据中心,此时冲突避免方式将不再有效,必须要有措施来解决写入冲突。

解决冲突:收敛于一致状态

当发生冲突时,比如上面的更新标题,主节点1 认为现在的标题是B,而主节点2则认为是C,谁也说服不了谁,所以必须要将 多主节点的 数据收敛到一个一致状态。 实现收敛的解决冲突方案参考:

  • 给每个写入分配唯一的ID,如 时间戳、随机数、UUID、Hash等,然后对比挑选最高ID的写入作为胜利者,并将其他写入丢弃。如果基于时间戳,这种技术被称为 最后写入者获胜 (Last Write Wins, LWW),这是目前较为流行的手段,不过可能会导致数据丢失;
  • 为每个副本分配一个唯一的ID,并制定规则,比如序列号高的副本写入 始终 优先于写入序号低的副本。这种方法也可能会导致数据丢失;
  • 以某种方式将这些值合并正在一起。例如 Set、List这类存储结构,多个Add是可以直接拼一起的;
  • 利用好预定义好的格式来记录 和 保留冲突相关的所有信息,然后依靠应用层的逻辑,事后解决冲突(可能会提示用户,冲突的形态可以参考Git)

无主复制

单主或多主节复制:客户端先向某个节点(主节点)发送写请求,然后数据库系统负责将写请求复制到其他副本;由主节点决定写操作的顺序,从节点按照相同的顺序来应用主节点的写日志。 无主复制:选择放弃选择主节点,允许任何副本直接接受客户端的写请求。通常实现为:客户端直接将其写请求发送到多副本;或者通过一个协调者代表客户端写入(但协调者本身不负责写入顺序的维护) 无主写入

注:无主复制中,没有主节点和从节点的概念,所有节点都可以写入。

读写 quorum

无主复制的基本读写操作:

  • 写:客户端并发 发送写请求到所有副本节点,当有w个节点响应成功后,客户端认为写成功;
  • 读:客户端并发 发送读请求到所有副本节点,当有r个节点响应成功后,客户端认为读成功,然后从读到的所有记录中,选取一个版本最新的,作为最终的结果。 这里会涉及到一个问题,在 n个节点下时,w和r 应该定为多少呢? 比如 五个节点,当w=3,r=2时,可能写时,写到了 A/B/C节点,而读时读的D/E节点,这时候可能会读到过期的数据;如果要读到最新的数据,可以考虑 w=3, r=3,或 w=4, r=2 读写存在重叠的节点,保证读的时候会读到最新的节点。 推广到一般的情况,只要 w + r > n ,读取的节点中一定会包含最新值。

无主读写

仲裁条件w + r > n 定义了系统可容忍的失效节点数:

  • 对于写:最大支持 n - w 个节点失效,
  • 对于读:最大支持 n - r 个节点失效, 从整体读写来看:
  • 假定n=3, w=2, r=2,则可以容忍一个不可用的节点。
  • 假定n=5, w=3, r=3,则可以容忍两个不可用的节点。 通常 r 和 w 会设定为⌈n/2⌉,即可确保w + r > n, 且同时容忍 n/2 个节点故障。

在一些宽松的 quorum策略下,并不要求 w + r > n ,其将可以容忍更多的失效节点,以及更快的响应速度,不过副作用是没有重叠的节点,可能会遇到 读到过期数据(或新旧交替)等问题。

读修复与反熵

当一个失效的节点重新上线之后,需要考虑赶上中间错过的写请求,一个简单的处理方式是读修复:即当客户端读取到多个副本时,可以知道哪个副本中存在过期值,然后将最新版本的数据写回去即可。 如下图中用户2345读取时,在副本3读到的是版本6,而副本1/2读到的是版本7,此时客户端会将最新的版本7的内容写到副本3。 quorum读写 除了读修复,还有一种机制叫 反熵过程,即通过一些后台进行不断地查找副本之间的差异,然后将缺少的数据从一个副本复制到另一个副本,最终让整体的数据一致性得到收敛。

写冲突2

当并发写入时,如下图: 写冲突

客户端A set X=A,而客户端B则set X=B,最终节点1/3认为当前X=A,而节点2则认为是B,这时候需要定义一些规则来确定到底哪个是最终的值,比如上面说到的version,问题在于这个version怎么定义。 一种方式是基于时间戳来:为每个写请求附加一个时间戳,然后选择最新即最大的时间戳,丢弃较早时间戳的写入。这种冲突解决算法即 最后写入者获胜 (Last Write Wins, LWW),这个是目前主流的解决冲突手段。

真实场景下,不同机器上的物理时钟很难做到完全同步,这可能会导致无法确定在分布式系统中多个节点的事件时序。比如A -> B 发一条消息(B给该事件A->B记录一个物理时间戳),B收到A消息后,触发给C发一条消息(C给事件B->C记录一个物理时间戳),逻辑上 C记录的时间戳 应该大于B记录的时间戳的,但是如果时间同步有问题,C的时间戳比B的还小,就会导致逻辑混乱:原 A -> B -> C,现 B->C, A->B。 Lamport老爷子专门搞了个《Time, Clocks and the Ordering of Events in a Distributed System》 来描述分布式下的逻辑时钟(或全局先后顺序);