第二部分:分布式数据系统
一、数据复制
数据复制目的
- 降低访问延迟:使数据在地理位置上更接近用户
- 提高高可用性:当部分组件出现故障,系统仍然可以工作
- 提高读吞吐量:扩展多台机器同时提供数据访问服务
主节点与从节点
工作原理:
- 指定主节点,当写入时,先写入主节点; 主节点写入后,触发从节点的写入(严格保持与主节点相同的写入顺序)
- 客户端读取数据时,可以从主节点或父节点执行查询。
- 强调:只有父节点可以接受写请求;从客户端角度看,从节点都是只读的
支持主从复制的数据库:
- 关系型:PostgreSQL,MySQL、Oracle
- 非关系型:MongoDB、RethinkDB、Espresso
分布式消息队列:Kafka、RabbitMQ
同步复制与异步复制
同步复制
主节点复制后,请求子节点复制,子节点返回后,才视为写入成功
优点:
- 一旦向用户确认,就可以明确保证主、从节点数据同步更新,都为最新的版本
缺点:
- 万一从节点发生故障(从节点崩溃、网络故障),就无法向主节点返回复制成功,导致主节点无法向用户返回;
- 而且会阻塞之后所有的写操作,直到同步子节点确认完成
半同步复制
特殊的同步复制,某一个从节点是同步的,其他从节点是异步的,如果这个同步的从节点不可用或者性能下降,则换一个从节点为同步模式,保证至少有两个节点有最新的数据。
异步复制
主节点复制后,立即返回写入成功,调用异步请求,进行所有从节点复制
优点
无论从节点出现什么异常或数据滞后,主节点都可以继续响应写请求,系统的吞吐性能更好
缺点
虽然向客户端确认了写操作,但无法保证数据持久化
如果主节点失败且无法恢复,所有没有复制到从节点的写请求都会丢失
- 是一种弱持久性
- 但异步复制还是被广泛使用,特别是从节点数量巨大或者分布于广域地理环境时
配置新的从节点
当配置了新的从节点,如何保持新的从节点和主节点数据一致性?
简单逻辑如下:
- 对主节点数据副本产生一个一致性快照(避免长时间锁定整个数据库,使其不可写),
- 将快照数据复制到从节点
- 从节点连接主节点,请求上次快照点后的数据更改日志
- 从节点复制这些快照点后的数据变更(这个过程称为追赶)
- 以此循环更新
处理节点失效
1. 从节点失效:追赶式恢复
- 从节点的本地磁盘保存了副本收到的数据变更日志
- 如果从节点发生崩溃,然后重启,或者主从之间的网络暂时中断、闪退
- 根据副本的复制日志,从节点可以知道在发生故障前处理的最后一次事务
- 然后连接到主节点,请求那次事务后的数据变更
- 收到这些数据变更日志后,复制到本地来追赶主节点。
- 之后就和正常情况一样持续接受来自主节点数据流的变化
2. 主节点失效:节点切换
选择某个从节点提升为主节点,客户端也需要更新,这样之后的写请求会发送给新的主节点,然后其他从节点接受来自新的主节点的变更数据
自动切换步骤
- 确认主节点失效
- 系统崩溃、停电、网络问题都可能导致节点出错,没有万无一失的方法能确切检测问题究竟出在哪里
- 所以大多数系统都采用了基于超时的机制:节点频繁地互相发送心跳存活消息
- 如果某节点在一段时间(例如30s)没有响应,则认为该节点失效
- 选举新的主节点
- 可以通过选举的方式(超过多数的节点达成共识)来选举新的主节点
- 或者由之前选定的某控制节点来指定新的主节点
- 候选节点最好与原主节点的数据差异最小,这样可以最小化数据丢失的风险
- 重新配置系统使新主节点失效
- 客户端将写请求发送给新的主节点(细节第6章请求路由有解释)
- 原主节点之后重新上线,可能仍认为自己是主节点,这时系统要确保原主节点降级为从节点,并认可新的主节点
可能出现的变数
问题一:如果使用异步复制,且失效之前,新的主节点并未收到原主节点的所有数据,在选举后,原主节点重新上线,由于原主节点未意识角色变化,新主节点可能会收到冲突的写请求,还会尝试同步其他从节点
- 解决方法:丢弃原主节点未完成复制的写请求,但可能违背数据更新持久化的承诺
问题二:如果数据库之外有其他系统依赖于数据库的内容,并一起协同使用,丢弃数据的方案就很危险
- 例如:github中一个事故中,某个数据并未完全同步的MySQL从节点被提升为主副本,数据库使用了自增计数器将主键分配给新创建的行,但新的主节点计数器落后于原主节点,重新使用了已被原主节点分配出去的主键,而这些主键已被外部Redis引用,导致MySQL和Redis之间的不一致,最后导致了某些私有数据被错误地泄露给了其他用户
问题三:可能会发生两个主节点同时认为自己是主节点,这种情况被称为脑裂
- 两个主节点都可能接受请求,并且没有很好解决冲突的办法,最后数据可能会丢失或破坏。
- 作为一种安全应急方案,有些系统会采取措施来强制关闭其中一个节点,可能会因为设计或实现考虑不周,导致两个节点都被关闭
总结:即使系统可能支持自动故障切换,有些运维团队可能仍愿意以手动方式来控制切换过程
复制日志的实现
基于语句的复制
主节点记录每个写请求的操作语句,将语句作为日志发送给从节点
- 不适用的场景
- 任何调用非确定性函数的语句,比如NOW()、RANGE(),就会在不同副本产生不同的值
- 如果语句使用了自增列,或者依赖现有的数据,则所有副本必须按照完全相同的顺序执行,否则可能会导致不同的结果。如果有多个同时并发执行的事务时,会有很大的限制
- 有副作用的语句(触发器、存储过程、用户定义的函数等),可能可能会在不用的副本产生不同的副作用
这些问题都会导致不同节点产生不同结果,有可能采取特殊措施解决,例如将语句中非确定性的函数替换为执行后的确定结果,这样所有节点直接使用相同的结果值,但这里存在太多边界条件需要考虑,因为通常首选其他复制方案
MySQL 5.1版本之前采用基于语句的复制,现在依旧在用,但是默认情况下,如果语句存在不确定性操作,则MySQL会切换到基于行的复制
基于预写日志(WAL)传输
存储引擎的磁盘数据结构,通常每个写操作都是以追加写的方式写到日志中
- 对于日志结构存储引擎,日志是主要的存储方式。日志段在后台压缩并支持垃圾回收
- 对于采用覆盖写磁盘的Btree结构,每次修改会预先写入日志,如系统发生崩溃,通过索引更新的方式迅速恢复到此前一致状态
所有对数据库写入的字节序列都被记入日志。因此可以使用完全相同的日志在另一个节点上构建副本,除了将日志写入磁盘外,主节点还可以通过网络将其发送给从节点
PostgreSQL、Oracle等其他系统支持这种复制方式。
缺点:
- 日志描述的数据结果非常底层,一个WAL包含了哪块磁盘块的哪些字节发生改变,诸如此类的细节。
- 这使复制方案和存储引擎紧密耦合。
- 如果数据库的存储格式从一个版本改为另一个版本,系统通常无法支持主从节点上运行不同版本的软件
- 如果复制协议允许从节点的软件版本比主节点新,则可以实现数据库软件不停机升级。先升级从节点,然后执行主节点切换,使升级后的从节点成为新的主节点
- 相反,复制协议要求版本必须严格一致(例如WAL传输),那么就必须以停机为代价升级
基于行的逻辑日志复制
复制和存储引擎采用不同的日志格式,复制与存储逻辑剥离,这种称为逻辑日志
关系数据库
关系数据库的逻辑复制通常是指一系列记录来描述数据表行级别的写请求
- 行插入:日志包含所有相关列的新值
- 行删除:日志里通常靠主键,来唯一标识已删除的行
- 无主键就需要记录所有列的旧值
- 行更新:日志包含足够的信息来唯一标识更新的行,以及所有列的新值(或至少包含所有已更新列的新值)
如果一条事务涉及多行修改,就会产生多个这样的日志记录,并在后面跟着一条记录,记录该事务已提交。
- MySQL的二进制日志binlog配置为基于行的复制时,采用该方式
优点:
- 因为逻辑日志与存储引擎逻辑解耦,更容易保持向后兼容
- 可以使主从节点能运行不同版本的软件,甚至是不同存储引擎
- 对于外部应用程序,逻辑日志格式也更容易解析
- 如果将数据库内容发送到外部系统(如用于离线分析的数据仓库),或构建自定义索引和缓存等,基于逻辑日志的复制更有优势
该技术也称为变更数据捕获
基于触发器的复制
将复杂控制权交给应用层
应用场景
- 只想复制数据的一部分
- 想从一种数据库复制到另一种数据库
- 需要订制、管理冲突解决逻辑
实现:
-
- 有一些工具(Oracle GoldenGate),可以通过读取数据库日志让应用程序获取数据变更
-
- 借助许多关系数据库支持的功能:触发器和存储过程
触发器支持注册自己的应用层代码,使得当数据库发生数据变更(写事务)时自动执行自定义代码。通过触发器,可以将数据更改记录到一个单独的表中,然后外部处理逻辑访问该表,实施必要的自定义应用层逻辑,例如将数据更改复制到另一个系统。
Oracle的Databus和Postgres的Bucardo就是这种技术的典型代表
缺点:
- 基于触发器的复制通常比其他复制方式开销更高
- 比数据库内复制更容易出错或暴露一些限制
但高度灵活性,在很多地方很有用处
复制滞后问题
如果一个应用正好从一个异步的从节点读取数据,而该副本落后于主节点,则可能会读取到过期信息。
这种不一致只是一个暂时状态,如果停止写数据库,经过一段时间后,从节点最终会赶上并与主节点保持一致,这种效应被称为最终一致性
正常情况下,主从节点完成写操作之间的时间延迟(复制滞后)可能不足1秒,通常不会造成太大影响。但如果系统已接近设计上限,或网络存在问题,则滞后可能会增加到几秒甚至几分钟。滞后时间太长,导致的不一致性,就是个需要解决的问题
读自己的写
读写一致性(写后读一致性)