当数据库遇到分布式,你会怎么做?
MySQL复习:20道常见面试题(含答案)+21条MySQL性能调优经验2020“闭关”跳槽季 , 啃透分布式三大技术:限流、缓存、通讯JVM面试速补:知识点梳理+学习路线+学习笔记+真题解析 , 够不够?秋招Java面试大纲:Java+并发+spring+数据库+Redis+JVM+Netty等数据库通常有着完善的事务支持 , 但是局限于单机的存储和性能 , 于是就出现了各种分布式解决方案 。 最近读了《DesigningData-IntensiveApplications》这本书 , 所以做一个总结 , 供大家做个参考 , 有什么不对的请大家指正 , 一起讨论 。
数据模型数据模型可以说软件开发中最重要的部分 , 因为影响着我们的思考方式、解题思路以及代码的编写方式 。 多数应用使用层层叠加的数据模型进行构建 , 对于每层数据模型的关键问题是:它如何用低一层的数据模型来表示 。
多数应用程序开发都使用面向对象编程的编程语言来开发 , 所以一个数据模型是否能够很好表示对象以及对象之间的关系就成为我们选择的标准 。
对象由各类属性组成 , 对象的关系通常有一对多/多对一和多对多 。
下面的示例是Linked的一个简历的关系型表示:
文档模型具有读时模式 , 对写入没有模式要求 。 类似编程语言的动态(运行时)类型检查 。
对于上面简历的例子 , 使用文档模型的表示如下:
以下是社交网络的一个示例:表示的是两个人之间的以及居住地点 。
面向页面B树是几乎是数据库标准的索引实现 , B数将数据库分解成固定大小的块或页面 , 通常在4k-32k范围 , 一次只能读取或写入一个页面 。 这种设计更接近与底层的硬件 , 因为磁盘也是由固定大小的块组成的 。
每个页面都可以使用地址来标识 , 一个页面引用另一个页面 , 类似于指针 , 但是在磁盘而不是在内存中 , 如图所示:
数据更新时 , 定位到叶子结点 , 用新数据覆盖磁盘的页面 。
数据插入和删除时 , 会涉及到页面的拆分和合并 , 来保持B树的平衡
为了保证数据查询和写入的高性能 , 数据库通常会对页面数据进行内存缓存 , 当数据有更新时 , 不会立即更新磁盘数据 , 而是先更新内存缓存的页面数据 , 同步追加写入WAL日志(write-ahead-log) , 异步将内存中的脏页刷到磁盘上(将磁盘随机写变为顺序写) 。 当数据库崩溃后恢复时 , 这个日志用来是B树恢复到一致的状态 。
日志结构基于日志结构的存储模式 , 每次数据新增或更新时 , 仅仅将数据追加到特定日志文件中 , 当文件超过一定大小时 , 则打开一个新的文件写入 。
每个日志结构存储段都是一系列键值对 , 但是为了后续便于查询数据 , 要求键值对在文件中按照键排序 , 这种排序的字符串表(SortedStringTable)称为SSTable 。
为了保证日志文件保持在一定的个数 , 多个文件段进行合并(归并算法) , 当出现多个同一键值时 , 用新的值覆盖老的 , 保证一个合并段同一个键出现一次 。
内存中维护者键到日志文件的索引 , 该索引是稀疏的 , 每几千个字节的段文件就有一个键就足够了 , 因为几千字节可以很快被扫描 。 (可以将部分记录分组到块 , 压缩写入磁盘)
这种基于合并和压缩排序文件原理的存储引擎通常被称为LSM存储引擎 。
当查找不存在的键时 , LSM树算法可能会很慢 。 为了优化这种访问 , 通常使用额外的Bloom过滤器 。
LSM树的基本思想
保存一系列在后台合并的SSTables , 即使数据集比可用内存大得多 , 仍能继续工作 。 由于数据按序存储 , 因此可以高效地执行范围查询(扫描所有高于某些最小值和最高值的所有键) , 并且磁盘写入时连续的 , 所以可以支持非常高的写入吞吐量 。
事务在数据库系统中 , 会遇到各种问题:
数据库软件、硬件可能在任意时刻故障(包括写操作进行一半时)应用程序任何时刻都可能崩溃(包括一系列操作的中间)网络中断会切断应用与数据库的连接 , 或数据库之间的连接多个应用可能会同时写入数据库 , 覆盖彼此的修改应用可能会读取到无意义的数据 , 因为数据只更新了一部分应用质检的竞争条件可能导致各种非预期结果事务一直是简化这些问题的首选机制 。 事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式 。 从概念上讲 , 事务中的所有读写操作被视为单个操作来执行:整个事务要么成功 , 要么失败后回滚 。 如果失败 , 应用可以安全地重试 。 对于事务来说 , 应用的错误处理简单多了 , 不用担心部分失败的情况了 。
事务提供的安全保障 , 由ACID来描述 。 即原子性Atomicity,一致性Consistency , 隔离性Isolation , 持久性Durability , 旨在为数据库中的容错性建立精确的术语 。
单对象vs多对象事务通常被理解为 , 将对多个对象上的多个操作合并为一个执行单元的机制 。 但许多分布式数据库只提供了单对象的原子性和隔离性(原子性通过同步写日志实现崩溃恢复;隔离性通过每个对象上锁实现单线程访问) , 以及更复杂的原子操作 , 如自增和CAS 。 所以要注意这一点 , 看是否满足自己的应用场景 。
多对象事务 , 除了要处理复杂原子性和隔离性 , 分布式场景下 , 还会涉及到跨分区(不能分区可能在不同的机器上) , 即分布式事务 。
隔离级别如果两个事务不触及相同的数据 , 它们可以安全地并行执行 , 因为两者都不依赖对方 。 当一个事务读取另一个事务同时修改的数据 , 或者两个事务试图同时修改相同的数据 , 并发问题才会出现 。
并发bug很难通过测试找到 , 因为这样的错误只有在特殊时机下才会触发 , 很难重现 。 出于这个原因 , 数据库一直试图通过提供事务隔离来隐藏应用开发者的并发问题 。 事务隔离级别越强越能够避免发生的并发问题 , 比如可序列化保证事务的效果与串行执行是一样的 , 但这意味着并发性能的牺牲 。 所以数据库系统通常使用较弱的隔离级别 , 来防止一部分并发问题 , 而不是全部 , 所以了解这些对于开发出正确的应用非常重要 。
脏读
脏读是指一个事务写了部分数据 , 未提交 , 这是另一个事务读取到了这部分未提交的数据 。
不可重复读
同一个事务两次读取的数据(读偏差)或者读取的记录数(幻读)不一致
丢失更新
两个事务同时读取数据 , 并进行更新 , 两个事务都更新成功 , 更新逻辑都是基于原先读取的值 , 但是事务提交会改变先前读取的值 , 导致丢失更新 。 典型的场景就是读->改->写 。
写偏差
可以将写入偏差视为丢失更新问题的一般化 。 如果两个事务读取相同的对象 , 然后更新其中的一些对象(不同的事务可能更新不同的对象) , 则可能发生写入偏差 。
读已提交
读已提交提供两种保证
从数据库读时 , 只能看到已经提交的数据(没有脏读)写入数据库时 , 只能覆盖已经写入的数据(没有脏写)可重复读/快照隔离
支持快照隔离的数据库保留了一个对象的不同的提交版本 , 因为各种正在进行的事务可能需要看到数据库在不同时间点的状态 。 这种技术被称为多版本并发控制(MVCC , multi-versionconcurrencycontrol) 。
当一个事务开始 , 它被赋予一个唯一个的 , 永远增长的事务ID(txid) 。 每当事务向数据库写入任何内容时 , 它所写入的数据都会被标记上写入者的事务ID 。
一个事务能查到一个对象 , 满足以下两个条件:
读事务开始时 , 创建该对象的事务已经提交对象未被标记为删除 , 或如果被标记为删除 , 请求删除的事务在读事务开始时尚未提交对于丢失更新和有数据交叉的写偏差 , 数据库可以结合快照隔 , 可以自动检测到丢失更新 , 中止相应的事务 。 但是MySQL/InnoDB的可重复读并不会检测丢失更新 。 有些作者认为 , 数据能防止丢失更新才能称得上快照隔离 , 所以这种定义下 , MySQL并不提供快照隔离 。
MySQL/InnoDB可重复读隔离级别下 , 可以使用锁定读(selectforupdate)或者比较并设置CAS来避免丢失更新 。
需要注意的是 , 如果数据库允许where字句从旧快照中读取 , 则此语句可能无法防止丢失更新 , 因为即使发生了另一个并发写入 , where条件也可能是真的 。
序列化
但对于写入数据无交叉的写偏差 , 只能通过序列化的隔离级别来避免 , 但是可以在应用层面通过物化冲突的方式 , 人为的在数据库中引入一个锁对象 。
序列化隔离级别有三种实现方式:
字面意义的串行顺序执行事务两阶段锁定(2PL , two-phaselocking) , 通过对读操作对象加共享锁 , 对写操作对象加独占锁实现 , 共享锁可以升级为独占锁 。 共享锁之间不互斥 , 共享锁与独占锁以及独占锁之间互斥 。 同时数据库会自动检测事务之间的思索 , 并中止一个 。 两阶段是一种所谓的悲观并发控制机制 。 乐观并发控制技术 , 可序列化的快照隔离SSI(serializablesnapshotisolation) , 是一种乐观的并发控制机制 , 读写数据时并不加锁 , 而是在事务提交时 , 通过特定的算法检测写入之间的序列化冲突 , 并确定要中止哪些事务 。 好处是读和写不相互阻塞 , 只读查询运行在一致的快照上 , 对于读取繁重的场景非常有吸引力 。 但是中止率显著影响SSI整体的表现 。 长时间读取和写入数据的事务很可能会发生冲突并中式 , 因为SSI要求同时读写的事务尽量短 。 分布式事务在多对象事务中 , 如果不同对象存在不同的分区中 , 则就需要处理分布式事务 。 提到分布式事务 , 就不得不介绍两阶段提交 , 两阶段提交是分布式事务的基本思想 。
两阶段提交两阶段提交2PC(two-phasecommit)是一种用于实现跨多个节点的原子事务提交的算法 。 可以在数据库内部使用 , 也可以以XA事务的形式对应用可用 。
PercolatorPercolator是由Google公司开发的、为大数据集群进行增量处理更新的系统 , 主要用于google网页搜索索引服务 。 使用基于Percolator的增量处理系统代替原有的批处理索引系统后 , Google在处理同样数据量的文档时 , 将文档的平均搜索延时降低了50% 。
但是由于Percolator使用乐观锁检测机制 , 对于热点数据的并发更新不友好 。 我觉得这一点可以通过在Percolator之上实现悲观锁机制来解决 。
分区分区(partitions)也叫分片(sharding) , 是将数据集进行拆分成多个分区 , 每个分区存储在不同的机器上 , 扩展了整体的存储量 , 提高了写入和读取的性能 。 但也带来了新的困难 , 数据库要支持跨分区的写入和读取 。
分区方式分区的目标是将数据和查询负载均匀的分布在各个节点上 。 如果分区是不公平的 , 或者没有考虑热点数据 , 那么一些分区比其他分区有更多的数据或查询 , 我们称之为偏斜(skew) 。 数据分区通常基于Key进行拆分 , 在考虑数据偏斜的情况 , 要根据数据库的特定的分区算法 , 特别注意Key的设计 。
根据Key的范围分区为每个分区指定一块连续的Key范围 , 分区Key的边界一般由数据库自动选择 。 好处是范围扫描非常简单 。 但是如果Key的设计不合理 , 会到热点数据 , 影响查询效率 。
根据Key的散列分区通过一个散列函数对Key进行计算后 , 再进行分区 。 这样可以消除偏斜和热点的风险 , 但是失去了原有Key的范围查询的属性 。
有些数据库 , 如Cassandra , 采取了折中的策略 , 使用多个列组成的复合主键来声明 。 键中只有第一列会作为散列的依据 , 而其他列则被用作Cassandra的SSTables中排序数据的连接索引 。 尽管查询无法在复合主键的第一列中按扫描扫表 , 但如果第一列已经指定了固定值 , 则可以对该键的其他列执行有效的范围扫描 。 组合索引的方法为一对多关系提供了一个优雅的数据模型 。
索引构建上面我们讨论了主键的分区策略 , 实际情况上辅助索引/二级索引也是很有必要的 , 特别是在关系模型中 。
辅助索引的构建方式有两种:本地索引和全局索引
分区再平衡随着数据集大小增加、查询吞吐量的增加 , 需要更多的机器来处理 。 这些都需要数据和请求从一个节点移动到另一个节点 , 这一过程称为再平衡(reblancing) 。
再平衡通常要满足以下几点要求:
再平衡之后 , 负载(数据存储、读取和写入请求)应该在集群中的节点之间公平地共享再平衡发生时 , 数据库应该继续接受读取和写入节点之间只移动必须的数据 , 以便快速再平衡 , 并减少网络和磁盘I/O负载平衡策略可以分为几种:固定数量的分区、动态数量的分区和按节点比例分区
固定数量的分区创建比节点更多的分区 , 并为每个节点分配多个分区 。 如果一个节点被添加到集群中 , 新节点可以从当前每个节点中窃取一些分区 , 直到分区再次公平分配 。 ElasticSearch使用这种方式分区策略 。
只有分区在节点间移动 , 分区的数量不会改变 , 键所对应的分区也不会改变 , 唯一改变的是分区所在的节点 。 这种变更不是实时的(网络上传输数据需要时间) , 传输过程中 , 原有分区仍然会接手读写请求 。
分区的数量通常在数据库第一次建立时确定 , 之后不会改变 。 每个分区包含了总数据量固定比率的数据 , 因此每个分区的大小与集群中的数据总量成比例增长 。 如果数据集的总大小难以预估 , 选择正确的分区数是困难的 。 分区太大 , 再平衡和节点故障恢复变得昂贵;分区太小 , 则会产生太多的开销 。
动态数量的分区对于使用键范围进行分区的数据库 , 具有固定边界的固定数量的分区将非常不方便:如果出现边界错误 , 则可能会导致某些分区的没有数据 。 按键范围进行分区的数据库通常会动态创建分区 。
当分区增长到超过配置的大小时 , 会被拆分成两个分区 , 每个分区约占一半的数据 。 动态分区的优点是分区数量适应总数据量 , 能够平衡各方面的开销 。 HBase和MongoDB采用的就是这种策略 。
数据集开始时很小 , 直到达到第一个分区的分隔点 , 所有写入操作都必须由单个节点处理 , 而其他节点处于空闲状态 。 为了解决这个问题 , HBase和MongoDB允许在一个空的数据库上配置一组初始分区(预分隔 , pre-splitting) 。 在键范围分区的情况下 , 预分隔需要提前知道键时如何分配的 。
按照节点比例分区分区数与节点数量成正比 , 即每个节点具有固定数量的分区 。 每个分区的大小与数据集大小成比例的增长 。 当增加节点时 , 随机选择固定数量的现有分区进行拆分 , 然后占有这些拆分分区中的每个分区的一半 。
请求路由现在我们已经数据集分割到多个节点上运行的多个分片上 , 客户端发起请求时 , 如何知道连接哪个结点 。 随着分区再平衡 , 分区对节点的分配也发生变化 。
很多分布式系统都依赖于一个独立的协调服务 , 比如ZooKeeper来跟踪集群元数据 。
每个节点在ZooKeeper上注册自己 , ZooKeeper维护分区到节点的可靠映射路由层可以在ZooKeeper订阅此信息 , 当分区分配发生变化能实时感知
单领导者复制过程:
每一次向数据库的写入操作都需要传播到所有的副本上 , 否则副本就会包含不一样的数据 。 最常见的解决方案被称为基于领导者的复制或主从复制副本之一被指定为领导者(或主库) , 当客户端要向数据库写入时 , 它必须发给领导者 , 领导者会将新数据写入其本地存储;其他副本被称为追随者(只读副本、从库) , 会同步主库的变更日志 , 按照主库相同的顺序写入当客户端从数据库读取数据时 , 可以向领导者或追随者查询同步or异步
复制系统的一个重要细节是复制是同步发生还是异步发生 。 同步复制会使得数据写入时间变长 , 而异步复制会使得副本之间的数据不一致 , 客户端可能会读取到历史的数据 , 并且在主库故障时有可能会丢失数据 。 所以复制系统的核心就是如何让副本保持一致 , 并且在主库故障时能够自动切换 。
一致性模型
注意:不将数据库事务的一致性与其混淆 , 分布式副本的一致性指的是单个对象的写入和读取 。
以数据为中心线性一致性
线性一致性也称为严格一致性(StrictConsistency)或者原子一致性(AtomicConsistency) , 需要满足以下两个条件:
任何一次读都能读取到某个数据最近的一次写的数据 。 所有进程看到的操作顺序都跟全局时钟下的顺序一致 。线性一致性的想法是让一个系统看起来只有一个数据副本 , 而且所有的操作都是原子性的 。 应用不用担心多个副本带来诸多问题 , 是一个完美的理想模型 , 作为其他模型的参考(最强一致性模型) 。
在线性一致性的数据存储中不存在并发操作:必须有且仅有一条时间线 , 所有的操作都在这条时间线上 , 构成一个全序关系 。
顺序一致性
顺序一致性最早出现在Shared-MemoryMulti-ProcessorSystem单机模型中 , 为程序员提供了极强的内存可见性保证 。 顺序一致性内存模型有两大特性:
任何执行的结果都与所有处理器的操作按某种顺序执行的相同 。 每个单独的处理器的操作顺序均按照其程序指定的顺序 。 所有操作都必须相对于每个其他处理器立即或原子执行(立即可见) 。
对于顺序一致性来说 , 它要找到一个合法的顺序执行过程 , 该执行过程要保留线程/进程内部原有的顺序
对于线性一致性来说 , 它也是要找到一个合法的顺序执行过程 。 但是这个顺序执行过程 , 不仅要保留线程/进程内部的先后顺序 , 还要保留线程/进程之间的操作的先后顺序 。
线性一致性可以定义为具有实时约束(real-timeconstraint)的顺序一致性 。
个人理解 , 在分布式副本的领域中 , 不太可能找到除了时序之外 , 各个进程能够一致认可的顺序 。 所以在分布式副本领域参考意义不大 , 更容易造成疑惑 。
因果一致性
相对于线性一致性保证读写具有全局顺序 , 而因果一致性只需要保证具有相互依赖的读写操作保持相同的顺序即可 。 实际上因果一致性是性能和可用最高的强一致性模型 。
因果一致性实现的难点在于如何定义和捕获因果关系 , 你需要知道哪个操作发生在哪个操作之前(happenbefore) 。 但是这种因果关系更多是来自上层应用 , 底层存储是无法感知的 , 所以跟踪所有的因果关系是不及实际的 。
因果关系的操作在时序上一定是有先后 , 所以通过全序的的序列化或时间戳(逻辑时钟)来排序操作 , 这样所有的操作都有了时间上的因果先后关系 。 所以线性一致性是所有操作都满足因果一致性(即使大部分操作没有依赖关系) 。
最终一致性
最终一致性不能算是一致性模型 , 没有任何一致性保证 , 只是说在没有更新的情况下 , 副本之间会在一定时间内保持一致 。 因为由于网络延迟的存在 , 应用任何时候都可能读取到不一致的数据 。 可以说是可接受的最弱的一致性模型 。
以客户端为中心上面讨论的以数据存储为视角的一致性 , 在因果一致性以及更强的一致性模型中 , 从客户端而言是不会发生预料之外的读写问题的 。 但是在更弱的一致性模型而言 , 出现各种读写问题 。
以客户端为中心的一致性为单一客户端提供一致性保证 , 保证该客户端对数据存储的访问的一致性 , 但是它不为不同客户端的并发访问提供任何一致性保证 。
以客户端为中心的一致性包含了四种模型:
单调读一致性(Monotonic-readConsistency):如果一个进程读取数据项x的值 , 那么该进程对于x后续的所有读操作要么读取到第一次读取的值要么读取到更新的值 。 即保证客户端不会读取到旧值 。 单调写一致性(Monotonic-writeConsistency):一个进程对数据项x的写操作必须在该进程对x执行任何后续写操作之前完成 。 即保证客户端的写操作是串行的 。 读写一致性(Read-your-writesConsistency):一个进程对数据项x执行一次写操作的结果总是会被该进程对x执行的后续读操作看见 。 即保证客户端能读到自己最新写入的值 。 写读一致性(Writes-follow-readsConsistency):同一个进程对数据项x执行的读操作之后的写操作 , 保证发生在与x读取值相同或比之更新的值上 。 即保证客户端对一个数据项的写操作是基于该客户端最新读取的值 。但是真实情况是 , 由于服务器负载均衡以及服务器故障的存在 , 会导致客户端会话会发生转移 , 因此基于客户端访问的一致性模型是不靠谱的 。
共识协议Lamport时间戳我们知道分布式系统中 , 各个机器拥有相同的时钟(全局时钟)是不太可能的 。 1978年Lamport在一篇论文中提出了一种逻辑时间戳 , 来解决分布式系统中区分事件发生的时序问题 。 这篇论文是分布式系统领域被引用最多的论文之一 。
可靠交付(reliabledelivery) , 没有消息丢失:如果消息被传递到一个节点 , 它将传递到所有节点全序交付(totalordereddelivery) , 消息以相同的顺序传递给每个节点全序广播正是数据库复制所需要的:如果每个消息都代表一次数据库写入 , 且每个副本都按照相同的顺序处理相同的写入 , 那么副本相互保持一致(除了临时的复制延迟 , 可以将读操作也作为消息 , 来实现一致读) 。 这个原理被称为状态机复制(statemachinereplication) 。
因为数据库的写入和读取操作都是通过消息交互达成一致 , 依据Lamport时间戳 , 所有的操作是全序的 , 因此可以实现线性一致性存储 。
Raft协议Raft是一种共识算法 , 旨在使其易于理解 。 它在容错和性能上与Paxos等效 。 不同之处在于它被分解为相对独立的子问题 , 并且干净地解决了实际系统所需的所有主要部分 , 实际将上面的全序广播/状态机复制的工程化 。
Raft将问题拆成数个子问题分开解决:
领导选举(LeaderElection)日志复制(LogReplication)集群成员变化(Clustermembershipchanges)安全性(Safety)作者:VectorJin原文链接:
推荐阅读
- 春天,此菜正当季,2块钱一斤,包饺子鲜嫩多汁,比韭菜荠菜馅香!
- 麦卡锡|美众议院少数党领袖麦卡锡透露自己曾感染新冠,当时却不知道
- 土豆简单做一做,当饭又当菜,软烂入味,入口即化,比红烧还过瘾
- 芽芽泡|这种全身是刺的野草,80后曾把它当零食,3月正当季,你吃过吗?
- 春天不吃它亏大了,比白菜鲜,比萝卜补,正当季别错过!
- 杨振宁|杨振宁教授去世?清华大学已辟谣:先生当前身体健康
- 5种面条最适合当早餐吃,营养美味,关键还省事
- 溃疡性大肠炎|JMT日本疑难病治疗-溃疡性大肠炎通过适当治疗可以恢复到健康人状态
- 野狼|东北知青回忆:晚上遇到野狼,自己没有武器,编了几个藤环脱险
- 有两种谷物被称为“长寿食材”,却很少人当主食吃,从今天要改
