《设计数据密集型应用/DDIA》精要翻译(四) :副本机制

从这一章开始,我们就正式从单机应用转向了分布式应用的旅程!

ps: 其实DDIA这书我1月份已经看完了,只不过那会儿实在没有心力去翻译。前段时间太太太忙了,对几千万日活的系统做了技术栈迁移、跨洲数据中心无缝平移。后续也会写文章来分享我们是怎么做的,欢迎持续关注。

副本机制的意思是,在多台通过网络互连的机器上保存同一份数据的多份拷贝。我们为什么需要副本:

  • 让用户在地理上离数据更近,从而降低延迟
  • 在一部分节点宕机的时候,系统仍能继续工作(即提高可用性)
  • 扩大机器数量,从而可以支撑更高的读请求量

1.Leaders和Followers

常见的一种副本模式:leader-based replication, 工作原理如下图所示:

1.副本中的一个被指定为leader,客户端的写请求必须发给leader处理
2.别的副本被称作followers, 当leader把新数据写到本地存储时,它也会把数据变更以log的形式发给它所有的followers。 每个follower从leader那里拿到log之后,把变更应用到本地存储。
3.客户端的读请求可以由leader或者followers来处理。

这种模式有很多系统在用,比如PostgreSQL, MySQL, Oracle Data Guard, MongoDB, RethinkDB, Espresso ,Kafka, RabbitMQ。

1.1 同步和异步复制

同步复制: leader接受到写请求后,需要等待follower的确认。好处是提高了duration。
异步: 不需要等待。好处是提高了响应速度。 目前被广泛应用。

在下图中,follower1的复制是同步的,follower2的复制是异步的。

半同步的配置: 一部分同步,一部分异步。

1.2 增加新的follower

  1. 在某个时间点,获取leader数据库的快照。有时候需要用到第三方工具,比innobackupex for MySQL
  2. 把这份快照拷贝到新的follower机器上
  3. follower连接到leader,请求在快照时间点之后所有的数据变更。这个快照时间点在MySQL中叫做binlog coordinates
  4. 当follower处理完所有积压的数据变更之后,我们称之为“caught up”。这个follower就可以继续处理来自leader的变更了。

1.3 处理节点宕机

Follower宕机: Catch-up恢复

如果follower挂了或是与leader间的网络连接中断,那么可以在恢复之后,向主库请求从中断点开始之后所有的变更即可。

Leader宕机: 故障切换

故障切换: 把一个follower提升为新的leader,重新配置客户端,将它们的写操作发送给新的leader,其他follower开始拉取来自新leader的变更。

故障切换可以手动也可以自动完成。自动的步骤一般如下:

  1. 如果leader没有通过health check,可以认为它挂了(宕机or网络中断)
  2. 通过选举机制来指定新的leader

故障切换是一件非常麻烦的事情:

  • 如果是异步复制,可能会丢数据
  • 脑裂: 多个节点都认为自己是leader。

1.4 Replication Logs的实现

基于语句的复制

比如对于mysql而言, 用delete之类的语句。
但现在在默认情况下,如果语句中存在任何不确定性(比如调用函数NOW()、自增列、有副作用(比如触发器和存储过程)),MySQL会切换到基于行的复制(见下文)。

Write-ahead log (WAL)

非常底层,WAL会记录哪些磁盘块中的哪些字节发生了更改。

逻辑日志复制(基于行的)

以行为粒度记录变更:

  • 对于插入的行,日志包含所有列的新值。
  • 对于删除的行,日志包含主键,如果表没有主键,需要记录所有列的旧值。
  • 对于更新的行,日志包含足够的信息来唯一标识更新的行,以及所有列的新值。

基于触发器

比如Canal之类的。

2. Replication Lag造成的问题及解决方案

如果客户端从异步复制的follower那里读取,它可能会拿到已经过时的数据。如果停止向leader写入并等待足够时间,follower最终会追上leader,这叫做最终一致性。

复制延迟导致的一些问题和解决方法:

2.1 读已之写

如果用户上传了一些数据,但是读请求打在了还没同步变更的那个follower上,这个用户大概率会骂一句傻逼。

这种情况我们就不能用最终一致性,可以用读写一致性,read-after-write consistency / read-your-writes consistency。

具体怎么做呢:

  • 从主库读用户可能修改过的东西,比如用户拉自己的profile页时,都走主库,拉别人的就可以走从库
  • 但是如果大部分内容都可能由用户来编辑,就不能用上面的方案了。这时可以用别的方法:记录上次变更的时间,如果与当前时间的差小于某个阈值,就走主库

2.2 单调读

从异步复制的follower读数据会碰到另一个问题是:moving backward in time,也就是时光倒流问题。。

比如用户看别人的主页,第一次从一个延迟小的从库,第二次读一个延迟大的从库,会发现刚刚刷到的动态又消失了。

解决这种问题的方法是单调读: 单调读比强一致性弱,单比最终一致性强。 单调读保证如果一个用户顺序进行多次读,那么后续的读取不会读到比前面的读取更老的数据。

实现方法:保证每个用户总是读同一个副本,比如根据userid来做hash(但是如果那个副本挂了,做了重新路由之后,这种保证又被打破了)。

一致前缀读

在分区/分片数据库中,还有一个特殊问题: 如果某个分区的复制速度比另一个慢,那么同时读取这俩分区的用户可能得到顺序错乱的数据, 如下图:

解决这个问题的常见思路是: 有因果关系的写入都写到同一个分区中。
(ps:比如在kafka中,如果要保证两条消息的消费顺序,那么就要保证它们写入了同一个partition )

3. 多主复制

前面我们讨论的都是单一leader的复制架构,这也是比较常见的做法。除此之外,还有一些别的方案,比如多leader、无leader。

3.1 多主复制的使用场景

多数据中心

如果是单数据中心,没有必要用多leader,平白无故提高复杂度。
但是如果是多数据中心,我们就可以用这种方案:
这里写图片描述

好处:

  1. 就近写入,提高性能
  2. 一个数据中心挂了不会导致整站挂掉

缺点:
会有写入冲突,下文会介绍。

3.2 处理写入冲突问题

如下图,当多个用户对同一个数据做修改时,如果用异步复制,可能会导致:数据都写到了本地leader,但是在复制时发生了冲突。

同步/异步冲突检测

上图是异步检测。
同步冲突检测: 等到写入被复制到所有的副本后才返回成功

避免冲突

特定用户的写入、或者同一份数据的写入都写到同一个数据中心

收敛到一致状态

不管怎么样,数据库最后必须处于一致状态,即所有副本必须再副本复制完成后处于同一个值。

方法:

  • 给每个写入一个唯一ID,比如时间戳之类的。最终值为最后的写入。 即Last Write Wins, LWW。
  • 记录冲突,用程序或人工去解决。

4. 无主复制

现在基本不用了。 AWS内部的Dynamo系统在用。(注意并不是DynamoDB, DynamoDB是单一Leader架构)。

5.解决写入冲突

5.1 LWW

上文已经介绍了

5.2 “happens-before”关系与并发

如果操作B依赖操作A,那么A happens-before B。 如果A和B都没有happens-before对方,那么可以说他们是并发的。

我们可以用下面的方法来判断两个操作的关系:

这个算法的工作原理是:

  • 数据库为每个key创建版本号,每次变更都会增加版本号,把写入的值和新版本号一起存储
  • 客户端读取时,服务端返回key最新的版本号以及所有的值。客户端在写入前必须先读取
  • 客户端写入时,要带上之前读取的版本号,并且把之前读到的值与新的值做一个merge操作
  • 服务端收到写请求后,可以覆盖比这个版本号小的所有的值,但是必须保留比这个版本号大的所有的值。(因为它们是并发操作)