Post

DDIA笔记-第五章-分布式数据-复制

DDIA笔记-第五章-分布式数据-复制

分布式属于水平伸缩,你可以使用任意机器来组建集群,比如性价比最好的机器。

一般两个相同配置的机器,价格比,一台拥有两倍参数的机器,更便宜。

复制指的是,不同的节点拥有相同数据的副本,可能的原因有:

  • 地理位置上距离用户更接近,减少延迟
  • 一个节点故障,系统能继续工作,提高可用性
  • 伸缩可以接收读请求的机器数量,提高吞吐量

复制之后,困难之处在于数据变更,当前有 3 种流行算法:

  • 单领导者 single leader 单主
  • 多领导者 multi leader 多主
  • 无领导者 leaderless 无主

领导者与追随者

基于领导者的复制(leader-based replication)

  • 也称 主动/被动(active/passive) 复制
  • 主/从(master/slave) 复制

领导者:主库,处理写操作

追随者:亦称为 只读副本(read replicas)从库(slaves)备库( secondaries)热备(hot-standby)。主库将数据变更发送给从库,称为复制日志(replication log)或 变更流(change stream)

同步复制与异步复制

假设我们有一个主库,一个同步从库,一个异步从库。

当我们更新了头像,主库操作成功就会返回客户端,客户端随即刷新头像,

这是如果客户端从同步从库获取,则可以确保拿到了最新数据,

如果从异步从库获取,则未必拿到更新后的数据,

即使大部分情况下主从同步更新都是很快的,但依然不能提供“保证”。

同步:

  • 确保最新数据,即使主库故障,也能保证同步从库数据是正确的,读取操作的业务可以信赖这些库。
  • 如果同步从库没有响应(网络等任何原因),主库都无法处理写操作。相当于触发了单点故障。
  • 有特殊原因要开启同步复制时,一般只配置一个从库是同步的,当该同步从库遇到问题时,把另一个异步从库改为同步运行,这样确保最少有两个节点拥有完整的最新的数据副本。一般被称为半同步(semi-synchronous)。但同步/异步的切换,一般也需要一定的操作/过渡时间。

异步:

  • 主要缺点是会导致“主库失效时未复制给从库的写入会丢失
    • 即使同步是非常高效的环境下,当主库程序崩溃,依然可能有写入操作来不及发出。
  • 优点是,即使从库都落后,也不影响主库继续处理写入操作
  • 全部从库都设置为异步的做法,即使有缺点,但依然被广泛使用了,显然面对实际问题时,业务操作的流畅性比起“消息滞后”和“小概率的数据丢失”重要,前者才是用户真正关心的,而后者是开发人员需要面对的,需要一套又一套的方案来增加这种性能和安全。

设置新从库

首先明确从主库“单纯复制数据文件到从库”的方案是没有意义的

数据文件不只一个,复制会持续一段时间,这段时间主库会进行写入操作,

所以最终从库得到的是“不同部分”在“不同时间”的内容,

比如 A 表是元旦零时零分的数据,但是 B 表是 5 分钟后的数据。

我们也可以锁定主库,等到复制完成,这样确实可以绕过上面的问题,但是一般不会这么做。

实际上的做法是(原文抄写):

  1. 在某个时刻获取主库的一致性快照(如果可能,不必锁定整个数据库)。大多数数据库都具有这个功能,因为它是备份必需的。对于某些场景,可能需要第三方工具,例如用于 MySQL 的 innobackupex【12】。
  2. 将快照复制到新的从库节点。
  3. 从库连接到主库,并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中的位置精确关联。该位置有不同的名称,例如 PostgreSQL 将其称为 日志序列号(log sequence number,LSN) ,MySQL 将其称为 二进制日志坐标(binlog coordinates)
  4. 当从库处理完快照之后积累的数据变更,我们就说它 赶上(caught up) 了主库,现在它可以继续及时处理主库产生的数据变化了。

PS:事实上,在我粗糙的系统里,有一个主从复制没有用到快照技术,而是直接从 binlog 从头执行,等待时间很长,但是另一个事实是这件事情只执行过一两次,而且后台挂机即可,所以优化也并未排上日程。

处理节点宕机

从库失效,比较简单,从库自己记录了上一次同步的位置,恢复后只需要从断点继续处理数据变更,直到赶上主库。

主要讨论主库失效的问题(原文抄写):

  1. 确认主库失效。有很多事情可能会出错:崩溃、停电、网络问题等等。没有万无一失的方法来检测出现了什么问题,所以大多数系统只是简单使用 超时(Timeout) :节点频繁地相互来回传递消息,如果一个节点在一段时间内(例如 30 秒)没有响应,就认为它挂了(因为计划内维护而故意关闭主库不算)。
  2. 选择一个新的主库。这可以通过选举过程(主库由剩余副本以多数选举产生)来完成,或者可以由之前选定的 控制器节点(controller node) 来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库(以最小化数据损失)。让所有的节点同意一个新的领导者,是一个 共识 问题,将在 第九章 详细讨论。
  3. 重新配置系统以启用新的主库。客户端现在需要将它们的写请求发送给新主库(将在 “请求路由” 中讨论这个问题)。如果旧主库恢复,可能仍然认为自己是主库,而没有意识到其他副本已经让它失去领导权了。系统需要确保旧主库意识到新主库的存在,并成为一个从库。

简单总结,步骤是:确认问题,选取新主库,通知从库,当旧主库上线时通知旧主库这些信息。

然而依然还有很多细节问题需要处理:

  • 旧主库上线时,有一些故障前来不及发出的写入操作,抛弃?写入又如何执行,既违反主从关系,数据上也有冲突。
  • 如果这些数据被外部引用了,则非常危险。可以简单认为 mysql 和 redis 之间也算是一种主从关系,但 redis 并不参与 mysql 的主从库切换过程。原文举例 GitHub 的事故,旧主库分配了自增 id,记录到 redis,然后新主库因为丢弃了写操作所以再次分配了相同的 id,导致数据错乱,最终泄露了一些私有数据。
  • 选主逻辑如何设计,又如何执行?比如从库各自计算“谁做主库”,会不会产生两个新主库。产生之后再做防范措施,写一个关闭一个的逻辑,又会不会两个都关闭了?这里选主的算法也有很深的讨论
  • 基于心跳/超时来判定主库失效,这里的策略如何?如果业务负载很高,则更需要的是扩容或者单纯等到负载高峰过去,此时如果触发切换主从,可能会让情况变得很糟糕。

这里并没有银弹,所以很多团队最终也是靠人监控系统,手动切换。

这里先抛出了问题,第八/九章有更深的讨论

复制日志的实现

基于语句的复制:

把主库执行的 CUD 语句记录下来,给从库重播。游戏中提及的帧同步跟这个思想大体是一致的。

可能出现的问题:

  • 非确定性函数会导致不一致的数据,比如获取一个随机数。所以游戏经常需要实现一个伪随机数算法。
  • 使用自增列后,要求从库必须完全按顺序处理这些同步操作,这时又要考虑主库存在并发事务
  • 不同的库是否采用了完全一样的配置?某些配置不一致,可能导致语句执行结果不同,比如定义了不同的用户函数。

实际上,这种方案已经很少使用了,这里只是为了先把面临的问题提出来。

传输预写式日志(WAL)

基于第三章,我们知道一些数据库或者准确到某些数据结构的存储实现,是基于磁盘的一份日志文件,并且使用仅追加/预写式等等策略。

所以数据同步的问题,可以变成这份日志文件的同步,则变成了“在一个文件中追加写入数据”。

这个操作的同步简单很多。

这个方案的问题是另一个完全不一样的角度。

“基于语句的复制”只要保证数据库处理语句得到一致的结果。一般数据库软件不同版本都能保证兼容性。

而 WAL 同步底层的数据存储文件,所以要求对数据文件的读写必须完全一致。

那么更新数据库软件版本就变成了困难。想要不停机地逐步升级每个节点变得不可能。

逻辑日志复制(基于行)

上面讨论的两种方案,可以视为是在

  • 最上层切入,基于客户端发过来所有请求
  • 最底层切入,基于最终实现的结果

既然各有明显缺点,那么就演变成在中间切一刀。

类似一般软件项目的演变过程,当项目越来越复杂,我们会抽象出一个“核心”,

数据库也类似,发展出“逻辑日志(logical log)”,他们一行为粒度描述了一个操作必要的信息,

但又解耦了输入的命令和底层的实现。我们常说的 mysql 的 binlog 就是逻辑日志。

PS:很多业务系统也可以参考这种做法,随着业务逻辑的演变,考虑向前/向后兼容,

我们经常把稳定的代码抽象一层,这样业务变更灵活,也解耦了底层实现。

基于触发器的复制

虽然但是,这跟 canal 等 CDC 方案也差不多,只不过是由数据库来触发程序。

这并不是为了数据库本身的主从复制而设计的,

更多是为了让应用程序加入,同步数据到其他系统,其他数据库,或者同步的内容需要计算或裁剪等等。

而 canal 是把自己注册为一个从库来触发。

反正这个方案实际用得不多。

复制延迟问题

上面讨论举例子都是节点故障,但是复制不仅仅为了解决这个问题。常见的是:

  • 复制可以提高可伸缩性,则扩容,支撑更大的读取请求吞吐量。
  • 数据更靠近用户地理位置,得到更快的响应。由运营商确保跨地区的机房之间的通信速率。

我们明确了“为什么”和“后果是什么”,我们现在可以确认,大部分情况下采用的是“一个主库,多个异步从库”的方案。那么我们可以开始考虑“最终一致性(eventual consistency)”的问题了。

这正是因为异步复制的延迟问题,平时可能几乎没有延迟,但是恶劣情况下可能是秒级或者分钟级别。

听起来也不是很久,但是 10ms 和 10 分钟之间是 60000 倍的关系。

上面几乎所有篇幅几乎都是为了引出这个问题。并且再次明确,大部分情况下,现代的业务系统是基于“异步同步”以及保证的是“最终一致性”。

PS:业务系统也处处存在类似问题,我自己的文件系统中,一个文件上传完到这个文件可用之间也有一个延迟,小文件延迟忽略不计,但是存在超大文件的情况,所以也是参考最终一致性的解决方案来处理的。

读己之写(读自己写的内容)

真正研究最终一致性之前,我们先考虑写后读一致性,也成为读己之写一致性。

这里也只是抛出问题,先知道面临什么。

我自己发了一个朋友圈,朋友真正刷到需要更久,这个延迟是容易接收的。

但是我自己完成了发送,我自己刷新不出来这条朋友圈,是比较难受的。

最简单的就是查询主库以获得最新数据,则需要有策略判断应该读主库还是从库,一些常见方案:

  • 业务上能明确修改入口,比如用户个人资料只有自己能修改,那么自己的资料从主库读取。
  • 根据时间判断,记录短时间内更新过的键值,这些数据从主库读取。或者记录从库同步延迟,如果超过了一定时间,就查询主库。
  • 还有很多方案,但没有万能的,基本都要综合考虑,考虑因素也很复杂,比如
    • 用户在手机更新了数据,然后在电脑网页上刷新,跨设备跨平台。
    • 用户手机用 5G 网络,电脑用的 WIFI 基于另一个套网络,他们可能路由到不同的应用程序阶段,不同的数据库节点。

单调读

另一个问题是,如果每次请求路由到不同的从库中读取,那么因为两个从库同步延迟不同,

所以可能会遇到:(读从库 1)看到评论 123,刷新,(读从库 2)评论 123 消失,再刷新又出现该评论。

单调读(monotonic reads) 这个概念是在强一致性和最终一致性之间的一种保证。

一般只需要基于用户 ID 做哈希,然后路由到相同的节点进行处理,即可。

一致前缀读

这个问题更多发生于“分区”,而“复制”较少遇到。

如果事情 A 和 B 有先后顺序,而他们存放在不同的节点(分区),

结合延时,应用程序可能会读取到先发生了 B 再发生 A 的情况。

一致前缀读(consistent prefix reads) 保证的是,一系列按顺序写入的内容,读取时也是按顺序出现的。

PS:也许在 mysql 的主从同步中不会遇到,但是业务系统也是会遇到的,比如上传文件后,需要发出“生成预览图”的异步任务,再发出“上传成功”的通知。这两个子任务的节点不是同一个,则类似“分区”的情况,这时用户收到了通知,刷新了页面,但是预览图还没有生成好,也许你还有代码是“等预览图生成好了再展示给用户看”,那么用户就可能会迷惑。

PS:书看到这里也是发现,很多常见问题,虽然动脑也能想到解决办法,但其实早就有非常深刻的问题划分和讨论。

多主复制

相比于一个主库的主从复制,多主复制只需要把主从概念变成你我的概念。

每个节点都是主库,当写入数据到我时,把数据同步到所有其他节点。

明确在同一个数据中心配置多主是没有太大意义的,因为复杂大于了好处。

大部分情况下是这样,总有些例外,只是不能一刀切下结论而已。

以下情况下,可以考虑多主:

  • 运维多个数据中心,因为地理位置的问题,单主的话,一些用户的写操作会很慢。
  • 离线也能使用的应用,你的日历应用在手机上也有一个数据库,可能也有网页端,则表示云上也有数据库,那么其同步面临的问题和多主复制是一样的。
  • 协同编辑

还讨论了很多其他角度,但是已经稍微脱离我当前面临的业务规模了,

所以暂时笔记就不写了,遇到再回顾原文吧

而且原文也说了,多主复制时比较危险的领域,应尽可能避免。大大大公司避免不了。

无主复制

同上,内容虽然能看懂,但是经验上没有遇到过这个场景,即使能理解文案,也还不能体会到实际面临的问题。

This post is licensed under CC BY 4.0 by the author.