《数据密集型应用系统设计》整理04

第五章 复制

TThe major difference between a thing that might go wrong and a thing that cannot possibly go wrong is that when a thing that cannot possibly go wrong goes wrong it usually turns out to be impossible to get at or repair.
—Douglas Adams, Mostly Harmless (1992)

复制的好处:

  • 减少延迟:用户可以获取离自己地理位置最近的数据
  • 高可用:部分组件出现故障时,系统仍可以工作
  • 高吞吐量:扩展至多台机器以通识提供数据访问服务

主节点与从节点

主从复制:选定一个节点为主节点,写入请求只能在主节点,而读请求可以在主节点或副节点。主副节点之间需进行数据同步。

同步复制与异步复制

同步复制需要等待从节点的确认之后才通知用户更新完成,异步复制不需要。

等待所有从节点的确认不切实际,如果由于从节点崩溃或者网络故障导致主节点获取不到从节点的确认,那么主节点会阻塞其后所有的写操作,直到同步副本确认完成。

因此,实践中如果数据库启用了同步复制,通常是半同步状态:只有一个从节点时同步的,这样可以保证至少有两个节点拥有最新的数据副本。如果该节点不可用,则提升另一个异步的从节点为同步。

配置新的从节点

  • 在某个时间点生产主节点的一致性快照,把快照拷贝到从节点
  • 从节点连接到主节点请求快照后的数据变更日志,并应用

处理结点失效

  • 从节点失效:失效后到主节点去追赶复制日志后的变更
  • 主节点失效:通过心跳确认主节点失效后,选举新的节点(最好跟原主节点的数据差异最小),重新配置系统使新主节点生效

自动切换存在变数,因为有可能旧的主节点很快就上线了导致新的主节点还未接收到原主节点的所有数据,新的主节点数据落后于原的主节点,或者发生脑裂,以及无法确定合适的超时等情况,手动控制切换过程可能是更好的选择。

复制日志的实现

  • 基于语句的复制:每个写请求都发送给从节点,对于NOW或者触发器等有副作用的语句,这些非确定性函数替换为执行后的确定结果。但是因为存在太多边界条件,所以目前很少使用
  • 基于预写日志(WAL)传输:每个写操作以追加写的方式写入日志,对于日志结构存储引擎(SSTables和LSM-trees),日志是主要的存储方式。对于采用覆盖写磁盘的Btree,每次修改都会预写日志。这两种情况,从节点都可以使用完全相同的日志创建副本。缺点是WAL非常底层,包含了哪些磁盘快的哪些字节发生变化。这使得版本更新变得很难
  • 基于行的逻辑日志复制:(mysql的binlog当配置为行复制时):插入行则日志包含所有相关列的新值,对于行删除和行更新,则日志包含足够的信息来唯一标示更新的行,以及新值。这种方法具有更好的向后兼容性,对外系统也更友好。
  • 基于触发器的复制:支持注册自己的应用层代码,比如将数据更改复制到另一个系统。这种方法比其他复制方法开销更高,但是更灵活

复制滞后问题

主节点的写入复制到从节点会有延迟,如果滞后时间太长,则会导致不一致性成为一个现实的问题。

读自己的写

读的时候读取的从节点有可能还未把数据从主节点同步过来。这种时候需要“写后读一致性”,也称为“读写一致性”。这种情况的解决方法:
- 用户访问可能被修改的内容的话从主节点读取
- 跟踪最近更新时间,如果更新在一分钟之内,则从主节点读取
- 客户端记住最近更新时间戳,并在请求中带上,系统去确保读服务至少包含了该时间戳的更新
WechatIMG100

单调读

上面的方法在切换用户登陆的时候不可用,这时候需要确保每个用户总是从固定的同一副本执行读取。
WechatIMG102

前缀一致读

对于一系列按照顺序发生的写请求,读取的时候有可能乱序。这时候需要前缀一致读,该一致性保证对于一系列按照某个顺序发生的写请求,那么读取这些内容时也会按照当时写入的顺序。在多分区的情况下比较难实现。
WechatIMG103

使用最终一致行的话,需要考虑是否能容忍系统的复制延迟增加到几分钟甚至几小时的情况下能不能忍受。如果不能,那么至少要提供一个更强的一致性,比如写后读。

在应用层处理这些保证会使应用层变得更复杂,如果保证数据库永远在“做正确的事情”那么情况会变得很简单,这也是事务存在的原因。之后会讨论分布式事务。

多主节点复制

单主节点存在单点问题和性能瓶颈,所有写操作都必须经过主节点。对主从复制进行自然地扩展,则可以配置多个主节点。
WechatIMG104
多主节点对于以下场景适用:

  • 多数据中心,可以容忍整个系统中心级别故障或者更靠近用户。每个数据中心内部采用常规主从复制,数据中心之间由主节点来负责数据交换和更新。
  • 离线客户端操作,比如设备当前离线,所编辑的内容需要在上线的时候获得同步。这种时候每个设备都有一个充当主节点的本地数据库,基本上等同于数据中心之间的多主复制。
  • 协作编辑:在线文档之类。

多主复制可能产生写冲突,解决的方法有:

  • 避免冲突:在应用层面保证对特定记录的写请求总是通过同一个节点
  • 按照一些算法处理冲突,比如给每个写入或每个副本分配唯一ID,规定序列号高的优先
  • 应用层自定义冲突解决逻辑,可以在写入时也可以在读取时执行

WechatIMG21
复制的拓扑结构有以上几种。环形和星形如果某个节点发生故障就会影响其他节点,但是全链接也存在一些自身的问题,比如某些网络链接比其他链路快的情况,就会导致复制日志之间的覆盖。

无主节点复制

关系数据库主导的时代,无主节点的想法被选择性遗忘了。而amazon采用了Dynamo系统后,无主节点又再次成为一种时髦的数据库架构。对于某些无主节点系统实现,客户端直接将其写请求发送到多副本,而另外一些实现中,由一个协调者节点代表客户端进行写入。

节点失效时写入数据库

客户端读取时向多个副本读取,这样子即使有少部分节点失效,也可以通过版本号读取到最新值。

节点失效后修复:读修复与反熵

  • 读修复:客户端读取多个副本,检测到过期的返回值后,将新值写入该副本
  • 反熵:后台进程不断查找副本间数据差异

读写quorum

如果有n个副本,写入需要w个节点确认,读取必须至少r个节点,则只要w + r > n 那么读取的节点中一定会包含最新值。quorum称为法定票数。通常配置n为奇数,w=r=(n+1)/2向上取整。也可以根据灵活调整,比如读多写少的负载,则设置w=n和r=1。

WechatIMG31
通常,读取和写入总是并行发到所有节点,w和r只是决定要等待的节点数,即有多少个节点需要返回结果。

Quorum一致性的局限性

要求w+r>n是因为需要确保读和写的节点集中至少有一个重叠的节点。
如果配置w+r<=n则有可能读取到旧值,但是可以获得更低的延迟和更高的可用性。
即使设置了w+r>n,在以下情况下也有可能存在返回旧值的边界条件:

  • sloppy quorum写操作的w节点和读取的r节点可能完全不同
  • 两个写入并发
  • 写与读同时发生,写可能仅在一部分副本上完成
  • 某些副本写入成功,而其他副本写入失败,总的成功副本少于w买那些成功的副本不会回滚。这时候写操作视为失败,但是后续的读操作可能返回新值

sloppy quorum:客户端可能在网络中断期间还能够连接到某些数据节点,但是这些节点又不是能满足数据仲裁的那些借点,这时候可以接受写请求,只是将他们暂时写入一些可访问节点,一旦网络问题得到解决,临时节点就把接收到的写入全部发送到原始主节点上。

检测并发写

处理多个客户端对相同主键同时发起操作的问题。

最后写入者获胜

每个副本总是保存最新值,关键点在于怎么定义“最新”。我们可以强制对写操作进行排序,比如为每个写操作附加一个时间戳。LWW可以实现最终收敛的目的,但是以牺牲数据持久性为代价。要确保LWW安全无副作用,只写入一次然后写入值视为不可变。例如Cassandra的一个推荐用法就是使用UUID作为主键,这样每个写操作都针对不同的系统唯一的主键。

Happens-before 关系和并发

如果两个操作都不在另一个之前发生,那么操作是并发的。如果属于并发,就需要解决冲突问题。
通过对比版本号的方法解决冲突:

  1. 服务器为每个主键维护一个版本号,每当主键写入新值的时候递增版本号,并将新版本号与写入的值一起保存。
  2. 客户端读取主键时,服务器返回当前值以及版本号。且写之前必须发起读。
  3. 客户端写主键,写请求必须包含之前读取的版本号、读到的值和新值合并后的集合。写请求的相应可以像读操作一样返回所有当前值,那么就可以一步步连接起多个写入值
  4. 服务器收到带有特定版本号的写入时,覆盖该版本号或更低版本的所有值。

客户端需要通过合并并发写入来继承旧值,删除的时候需要做标记,不能直接删除。

版本矢量

存在多个副本的情况:为每个副本和每个主键均定义一个版本号,每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本看到的版本号。

comments powered by Disqus