0. 写在前面
最初是一次面试,面试官问我什么是可靠性,如何提高系统的可靠性。我答的很差,想到一本一年前买来只看了第一章的书有提到这个名词,于是用了一个月通读全书,发现所有的常见中间件,Redis,Mysql,Kafka...都是相同的系统设计思想,都存在相同的问题:
- 单机系统:从数据存储开始,到数据编码传输,之后需要处理更复杂的业务逻辑需要事务
- 分布式系统:单机的系统必然会面对单点故障问题,于是出现了分布式系统设计,出现了数据复制,数据分区
- 分布式系统的问题:分布式系统解决了单点故障,但也引入了新的问题,如网络永远是不可靠的。因此又出现了分布式系统一致性算法,出现了分布式事务来保证多个机器间的协同
这篇文章可以让你从另一个角度,"抽象"出所有中间件的相通之处
一、系统设计基础
1. 系统设计的三个重要问题
- 可靠性:当出现意外情况时,系统应该可以继续正常运转。虽然性能可能有所降低,但确保功能正确
- 可扩展性:随着规模的增长,系统应该以合理的方式来匹配这种增长
- 可维护性:随着时间的推移,新的人员会参与到系统开发和运维,系统都应该高效运转
2. 如何保证系统的可靠性
- 什么是可靠性:即使发生了某些错误,系统仍可以继续正常工作
- 哪些故障会破坏可靠性 :
- 硬件故障 :
- 具体故障:硬盘崩溃,内存故障,电网停电,网络断线
- 应对方法:硬件故障通常难以短时间内恢复,因此为硬件添加冗余来减少系统故障率
- 软件错误 :
- 具体故障 :
- 软件bug:输入特定值时应用服务器总是崩溃
- 共享资源故障:一个应用进程使用了某些共享资源(如CPU、内存、磁盘或网络带宽),被其他进程或程序影响了
- 依赖服务故障:系统依赖于某个服务,但该服务突然变慢,甚至无响应或返回异常
- 应对方法 :有时没有快速解决办法,只能仔细考虑很多细节
- 充分测试:开发过程中进行全面的测试,包括单元测试,集成测试,系统测试
- 进程隔离:不和其他进程共享资源,不被其他进程的错误影响
- 快速恢复:快速滚动配置改动,滚动发布新代码,主备切换等
- 监控告警:监控系统数据,异常时及时告警
- 具体故障 :
- 人为失误 :
- 具体故障:人无法做到万无一失,运维者的配置错误是系统下线的首要原因
- 应对方法 :假定人是不可靠的
- 以最小出错的方式来设计系统:精心设计抽象层、API以及管理界面,使得"做正确的事情"很轻松,但搞坏很复杂
- 分离容易出错的地方:如测试环境和生产环境完全分离,出现问题不会影响真实用户
- 硬件故障 :
3. 如何保证系统的可扩展性
- 什么是可扩展性:可扩展性是由来描述系统应对负载增加能力的术语
- 有哪些参数 :
- 负载参数:Web服务器的每秒请求处理次数、数据库中写入的比例,缓存命中率等
- 性能参数 :
- 批处理系统:通常关心吞吐量,即每秒可处理的记录条数
- 在线系统:通常更看重服务的响应时间,即客户端从发生请求到接受响应之间的间隔
- 应对方法 :
- 垂直扩展:升级到更强大的机器,相对比较容易
- 水平扩展:将负载分布到多个更小的机器,分布式多机环境会增加复杂性
- 综合两种扩展:通常是将数据库运行在一个节点上做垂直扩展,直到高扩展性或高可用性的要求迫使不得不做出水平扩展
二、数据存储
1. 存储模型选择
- 有哪些存储模型 :
- 关系模型:关系(表)只是元组(行)的集合。没有复杂的嵌套结构,也没有复杂的访问路径
- 文档模型:使用如JSON、XML格式来保存数据。父记录中保存了嵌套记录,而不是存储在单独的表中
- 关系数据库适合的场景 :
- 数据复杂且稳定:如电商订单系统,金融交易系统
- 强事务需求:需保证数据一致性,依赖事务防止数据异常
- 结构化查询频繁:需复杂统计分析,如多表JOIN、分组聚合、子查询
- 文档数据库适合的场景 :
- 数据结构多变:如内容管理系统,文章可能包含文本、图片、标签等不同字段
- 高写入吞吐量:如日志存储、物联网设备数据(每秒数万条写入)
- 查询以单文档为主:无需复杂跨表关联,查询多为文档大部分内容
2. 数据存储结构
- 哈希索引 :
- 简单实现 :
- 持久化数据:保存内存中的hashmap,每个键映射到文件中的字节偏移量
- 分段保存:将日志分解成一定大小的段
- 压缩:在日志中丢弃重复的键,只保留每个键最近的更新
- 优点:实现简单,查询效率高
- 缺点 :
- 哈希表必须全部放入内存:如果有大量的键,会难以处理
- 区间查询效率不高:只能通过逐个查找的方式查询每一个键
- 简单实现 :
- SSTables(排序字符串表) :
- 概念:改变段文件的格式,要求key-value对的顺序按键排序
- 优点 :
- 合并段更容易:合并段更加简单高效,因为key是排序的
- 减少内存占用:在文件中查找特定的键时,不再需要在内存中保存所有键的索引
- 擅于范围查询:将一个范围的记录写到一个块中并压缩
- 应用:Cassandra和HBase
- B-Tree :
- 概念:按键排序key-value对,将数据库分解成固定大小的块或页,树型结构,叶子节点间有双向指针,只有叶子节点保存数据
- 对比B-Tree和LSM-Tree :
- LSM-Tree优点 :
- 写入更快:B-Tree索引必须至少写两次数据,一次写入预写日志,一次写入树的页本身
- 写放大低:日志结构索引也会重写数据的次数更少
- 吞吐量更高:顺序方式写入紧凑的SSTable,不必重写树中的多个页
- 更好支持压缩:B-Tree会导致部分页中空间无法使用,LSM-Tree不是面向页的,并且定期重写SSTable以消化碎片
- LSM-Tree缺点 :
- 干扰读写操作:压缩过程有时会干扰正在进行的读写操作
- 磁盘带宽占用大:写入磁盘和压缩线程要共享磁盘带宽
- 压缩速率可能不足:压缩速率可能无法匹配新数据写入速率
- 键没有自己专属的物理空间:B-Tree每个键都恰好唯一对应于索引中的某个位置,而日志结构的存储引擎可能在不同的段中具有相同键的多个副本
- LSM-Tree优点 :
- 列式存储 :
- 概念:数据量极大时采用,将每列中的所有值存储在一起
- 优势 :
- 适合压缩:通过位图编码压缩,列中有大量重复的值时使用。一个位图对应每个不同的值,一个位对应一行。如果行具有该值,为1,否则为0
- CPU高效利用:查询引擎可以将一大块压缩列数据放入CPU的L1缓存中进行处理
- 劣势:不适合频繁写,因为数据是排序的,插入操作必须一致更新所有列
3.索引选择
- 在索引中存储值 :
- 聚集索引:在索引中直接保存行数据
- 非聚集索引:仅存储索引中的数据的引用
- 覆盖索引:在引用中保存一些表的列值,只通过索引即可回答某些简单查询
- 多列索引:最常见的多列索引类型称为级联索引,它通过将一列追加到另一列,将几个字段简单地组合成一个键(索引的定义指定字段连接的顺序)
- 全文搜索和模糊索引:Lucene对其词典使用类似SSTable的结构,内存中有一个索引,是键中字符序列的有限状态自动机,类似字典树,它支持在给定编辑距离内高效地搜索单词
- 在内存中保存所有内容:随着内存变得便宜,许多数据集不是那么大,可以将它们完全保留在内存中,或者分布在多台机器上,这推动了内存数据库的发展
三、数据编码与传输
1. 数据表现形式
- 在内存中:数据保存在对象、结构体、列表、数组哈希表和树等结构中。这些数据结构针对CPU的高效访问和操作进行了优化(通常使用指针)
- 将数据写入文件或通过网络发送时:必须将其编码为某种自包含的字节序列(如JSON文档)
2. 数据编码方式
- 语言特定的编码格式 :
- 概念:许多编程语言都内置支持将内存中的对象编码为字节序列。如Java的java.io.Serializable
- 存在的问题 :
- 强绑定语言:另一种语言访问数据非常困难
- 兼容性问题:通常存在向前和向后兼容性的问题
- 效率偏低:编码、解码花费时间长
- JSON与XML :
- 概念:JSON、XML是可由不同编程语言编写和读取的标准化编码
- 存在的问题 :
- 数字编码模糊:在XML和CSV中,无法区分数字和碰巧由数字组成的字符串;JSON区分字符串和数字,但不区分整数和浮点数,而且不指定精度
- 不支持二进制字符串:不支持没有字符编码的字节序列
- 需要硬编码:对于一些特殊条件(如数字问题)需要硬编码来处理
- 二进制编码 :
- 概念:二进制占用的空间更少,JSON不像XML那么冗长,但与二进制格式相比,两者仍然占用大量空间
- 模式:类似于Java中定义的类,描述了数据的类型,字段名等
- 模式兼容性问题 :
- 向前兼容:不能随便更改字段的标签,会导致所有现有编码数据无效;可以添加新的字段到模式,给一个新的标记号码
- 向后兼容 :
- 只要每个字段都有一个唯一的标记号码,新的代码总是可以读取旧的数据,因为标记号码仍然具有相同的意义。
- 有一个问题添加新字段时需要设置为可选字段,设置为必需字段时,新代码读取旧代码写入的数据,该检查将失败,因为旧代码不会写入添加的新字段
- 基于模式的二进制编码优点 :
- 结构紧凑:它们可以比各种"二进制JSON"变体更紧凑,可以省略编码数据中的字段名称
- 文档维护:模式是一种有价值的文档形式,因为模式是解码所必需的,所以可以确定它是最新的(而手动维护的文档可能很容易偏离现实)
3. 基于数据库的数据流
- 概念:在数据库中,写入数据库的进程对数据进行编码,而读取数据库的进程对数据进行解码
- 兼容性问题 :
- 旧数据不重写:对数据库来说,数据比代码更长久。读取旧行时,数据库会为磁盘上编码数据缺失的所有列填充为空值
- 归档存储:在数据库创建快照或备份时,数据库会使用最新的模式进行编码
4. 基于服务的数据流
- Web服务方法 :
- REST:不是一种协议,而是一个基于HTTP原则的设计理念。它强调简单的数据格式,使用URL来标识资源,并使用HTTP功能进行缓存控制、身份验证和内容类型协商
- SOAP:是一种基于XML的协议,用于发出网络API请求。虽然它最常用于HTTP,但其目的是独立于HTTP,并避免使用大多数HTTP功能
- 远程过程调用(RPC) :
- 概念:RPC模型试图使向远程网络服务发出请求看起来与在同一进程中调用编程语言中的函数或方法相同。但其实他们是有显著不同的,新一代RPC框架更加明确了远程请求与本地函数调用不同的事实
- 提供的功能 :
- 封装可能失败的异步操作
- 简化了需要并行请求多项服务的情况,并将其结果合并
- 提供服务发现,允许客户端查询在哪个IP地址和端口号上获得特定的服务
- REST和RPC对比:REST是公共API的主流风格,RPC框架主要侧重于同一组织内多项服务之间的请求,通常放生在同一数据中心内
- 基于消息传递的数据流 :
- 概念:一个进程向指定的队列或主题发送消息,并且代理确保消息被传递给队列或主题的一个或多个消费者或订阅者。在同一个主题上可以有许多生产者和许多消费者
- 与RPC的差异:消息传递通信通常是单向的,发送方通常不期望收到对其消息的回复
- 与RPC相比的优势 :
- 持久化:如果接收方不可用或过载,它可以充当缓冲区,提高系统的可靠性
- 自动重发:它可以自动将消息重新发送到崩溃的进程,从而防止消息丢失
- 解耦:它避免了发送方需要知道接收方的IP地址和端口号
- 一对多发送:它支持将一条消息发送给多个接收方
5. RPC与本地函数有哪些不同
- 不清楚执行结果 :
- 本地函数调用:要么返回一个结果,要么抛出一个异常,或者永远不会返回(死循环或进程崩溃)
- 网络请求调用:有另一个结果,可能会超时,这种情况下,不知道请求是否成功
- 可能执行多次 :
- 本地函数调用:没有这个问题
- 网络请求调用:如果重试失败的网络请求,可能会发生请求实际上已经完成,只是响应丢失的情况,需要建立幂等性机制
- 执行时间不稳定 :
- 本地函数调用:执行时间大致相同
- 网络请求调用:比本地调用慢得多,延迟变化大,可能1ms,也可能几秒
- 需要编码 :
- 本地函数调用:可以直接将引用(指针)传递给本地内存中的对象
- 网络请求调用:需要编码成可以通过网络发送的字节序列
四、事务
1. 什么是事务
- 概念:事务将应用程序的多个读、写操作捆绑在一起称为一个逻辑操作单元。即事务中的所有读写要么全部成功(提交),要么全部失败(中止或回滚)
2. 什么是ACID
- 原子性:在出错时中止事务,并将部分完成的写入全部丢弃
- 一致性:对数据有特定的预期状态,任何数据更改必须满足这些状态约束(或恒等条件)
- 隔离性:并发执行的多个事务相互隔离
- 持久性:对于单节点数据库,持久性通常意味着数据已被写入非易失性存储设备,如硬盘或SSD;而对于支持远程复制的数据库,持久性则意味着数据已成功复制到多个节点
3. 事务一致性问题
- 脏读:读取了其他事务未提交的修改
- 不可重复读:多次读取同一数据,读取结果不一致
- 幻读:按同一条件多次查询,数据数量不一致
4. 事务隔离级别
- 读未提交:无隔离,事务可以读取其他事务未提交的修改
- 读已提交:事务只能读到其他事务已提交的修改
- 可重复读:事务多次读取同一数据,结果始终一致
- 可串行化:事务完全串行执行
5. 什么是MVCC
- 概念:多个正在进行的事务可能会在不同的时间点查看数据库状态,所以数据库保留了对象多个不同的提交版本
- 读已提交隔离实现:保留对象的两个版本,已提交的旧版本和尚未提交的新版本
- MVCC可见性规则 :
- 正在运行的事务(即尚未提交或中止) :完成的部分写入不可见
- 中止的事务:所做修改不可见
- 较晚开始的事务:所做修改不可见,不管这些事务是否完成了提交
- MVCC支持索引 :
- 版本过滤:索引指向对象所有版本,再过滤对当前事务不可见的旧版本
- 追加式写入:每个写入事务创建一个B-Tree根节点,代表该时刻数据库的一致性快照
6. 如何处理事务并发写更新丢失
- 原子写操作:许多数据库提供了原子更新操作,以避免在应用层代码完成"读-修改-写回"操作,如果支持的话,通常这就是最好的解决方案。如Redis提供了对特定数据结构修改的原子操作
- 显式加锁:由应用程序显式锁定待更新的对象。之后应用程序可以执行"读-修改-写回"这样的操作序列,此时如果有其他事务尝试同时读取对象,则必须等待当前正在执行的序列全部完成
- 乐观锁:先让它们并发执行,但如果事务管理器检测到了更新丢失风险,则会中止当前事务,并强制回退到安全的"读-修改-写回"方式。Oracle的可串行化可以自动检测更新丢失,中止违规事务;但Mysql/InnoDB的可重复读却并不支持检测更新丢失
- 原子比较和设置:在不提供事务支持的数据库中,有时你会发现它们支持原子"比较和设置"操作。使用该操作可以避免更新丢失,即只有在上次读取的数据没有发生变化时才允许更新;如果已经发生了变化,则回退到"读-修改-写回"方式
- 应用层解决:对于支持多副本的数据库,不同的节点可能会并发修改数据,需要采取额外的措施。多副本数据库通常支持多个并发写,然后保留多个冲突版本,之后由应用层逻辑或依靠特定的数据结构来解决
7. 两阶段加锁实现可串行化
- 概念:第一阶段事务执行之前要获取锁,第二阶段事务结束时释放锁
- 加锁的类型 :
- 行锁:写操作加锁独占访问,缺点是性能不佳,容易死锁
- 谓词锁:对满足查询条件的所有对象加锁,可以防止幻读,缺点是性能不佳,检查匹配各种锁就非常耗时
- 索引区间锁:将谓词锁保护的对象扩大化,查询条件的近似值都附加到某个索引上,可以防止幻读,相比谓词锁没有那么精确,但开销低的多
8. 快照隔离实现可串行化
- 概念:是一种乐观并发控制。数据库希望事务间相安无事,当事务提交时,数据库会检查是否发生了冲突,如果有冲突,中止事务并接下来重试
- 具体实现 :
- 检测是否读取了过期的MVCC对象:当事务提交时,数据库会检查是否存在一些当初被忽略的写操作现在已经完成了提交,如果是则必须中止当前事务
- 检测写是否影响了之前的读:当另一个事务尝试修改时,它首先检查索引,从而确定是否存在一些读目标数据的其他事务
- 优缺点对比 :
- 与两阶段加锁相比:事务不需要等待其他事务所持有的锁。在一致性快照上执行只读查询不需要任何锁,这对于读密集的负载非常有吸引力
- 与串行执行相比:可串行化快照隔离可以突破单个CPU核的限制。可以将冲突检测分布在多台机器上,从而提高总体吞吐量
五、分布式系统设计------数据复制
1. 什么是主从复制
- 概念:基于主节点复制数据。写请求首先发送给主节点,主节点再同步数据给从节点;读请求可以在主节点或从节点执行查询
2. 数据复制方式有哪些
- 异步复制 :
- 概念:主节点发送完消息之后立即返回,不用等待从节点完成确认
- 优点:吞吐性能更好,不管从节点上数据多么滞后,主节点总是可以继续响应写请求
- 同步复制 :
- 概念:主节点需等待直到从节点确认完成了写入,然后才会向用户报告完成
- 优点:从节点的数据总是最新的
- 缺点:写操作可能会被阻塞,如果同步的从节点无法完成确认(如节点崩溃或网络故障),写入就不能视为成功,主节点会阻塞其后所有的写操作,直到同步副本确认
3. 新的从节点加入
- 需要解决的问题:不能简单的复制数据,因为客户端在不断向数据库写入新数据,无法保证数据一致性
- 解决方法 :
- 生成快照:在某个时间点对主节点的数据副本产生一个一致性快照,这样避免长时间锁定整个数据库
- 拷贝数据:将此快照拷贝到新的从节点
- 获取日志:从节点连接到主节点并请求快照点之后所发生的数据更改日志
- 追赶数据:获得日志之后,从节点来应用这些快照点之后所有数据变更,这个过程称之为追赶
4. 节点失效处理
- 从节点失效------追赶式恢复:根据从节点的日志,获取到发生故障前所处理的最后一笔事务,然后连接主节点,请求自那笔事务之后中断期间内所有的数据变更
- 主节点失效------节点切换 :切换某个从节点为主节点
- 具体切换步骤 :
- 确认主节点失效:节点间频繁地互相发送心跳存活消息,如果某节点长时间(如30s)无响应,即认为该节点发生失效
- 选举新的主节点:选举新的主节点,需要多数的节点达成共识
- 使新主节点生效:客户端将写请求发送给新的主节点
- 存在的问题 :
- 主节点重新上线后试图继续同步数据:使用异步复制可能会发生,解决方案是未完成同步的写请求直接丢弃
- 两个节点同时都自认为是主节点(脑裂现象) :两个主节点都可能接受写请求,解决方案是强制关闭其中一个节点
- 超时时间设置不合理:主节点失效后,超时时间设置得越长也意味着总体恢复时间就越长。但如果超时设置太短,可能会导致很多不必要的切换
- 具体切换步骤 :
5. 复制日志的实现
- 基于语句的复制 :
- 实现:主节点记录所执行的每个写请求(操作语句)并将该操作语句作为日志发送给从节点
- 缺点:调用非确定性函数的语句,如NOW()获取当前时间,可能会在不同的从节点上产生不同的值
- 基于预写日志(WAL)传输 :
- 实现:所有对数据库写入的字节序列都被记入日志,从节点收到日志后构建副本
- 缺点:从节点复制时要停机,否则主从节点会不一致
- 基于行的逻辑日志复制 :
- 实现:复制和存储引擎采用不同的日志格式,如Mysql的binlog
- 优势:逻辑日志与存储引擎解耦,可以更容易做兼容性处理
- 基于触发器的复制 :
- 实现:读取数据库日志让应用程序获取数据变更,支持注册自己的应用层代码
- 优势:灵活度高
6. 复制滞后解决
- 读自己的写 :
- 现象:用户写后马上读,可能读不到数据。因为提交新数据是到主节点,但当用户读数据时,数据可能来自从节点
- 解决方案 :
- 强制读主节点:访问可能会被修改的数据,强制读主节点
- 刚更新时读主节点:跟踪最近更新时间,更新后一分钟内从主节点读取
- 记录时间戳:记录最近更新的时间戳,并附带在读请求中,系统确保该用户提供读服务时都应该至少包含了该时间戳的更新
- 单调读 :
- 现象:用户从不同从节点进行了多次读取,两个从节点滞后程度不同。第一次查询返回数据,但第二次查询没有返回数据
- 解决方案:确保每个用户总是从固定的同一从节点执行读取,如基于用户ID的哈希而不是随机选择从节点
- 前缀一致性 :
- 现象:两个服务器对话,第三个观察者服务收听,先发的消息滞后较少,后发的消息滞后较多,导致顺序混乱
- 解决方案:确保任何具有因果顺序关系的写入都交给一个分区来完成
7. 什么是多主节点复制
- 概念:配置多个主节点,每个主节点都可以接受写操作,后面复制的流程与主从复制模型类似
8. 多主节点复制应用场景
- 场景:多数据中心适合使用多主节点复制,为了容忍整个数据中心级别故障或者更接近用户,这时多主节点就更有优势
- 和单主从复制的差异 :
- 性能:每个写操作都可以在本地数据中心快速响应,然后采用异步复制方式将变化同步到其他数据中心,对上层应用有效屏蔽了数据中心之间的网络延迟
- 容忍数据中心失效:每个数据中心可以独立于其他数据中心继续运行,发生故障的数据中心在恢复之后更新到最新状态,无需主节点切换
- 容忍网络问题:采用异步复制,可以更好地容忍数据中心之间的网络延迟
9. 多主节点复制如何处理写冲突
- 避免冲突:如果应用层可以保证对特定记录的写请求总是通过同一个主节点,这样就不会发生写冲突
- 收敛于一致状态(同一个字段多次更新) :
- 给每个写入分配唯一的ID:例如一个时间戳,挑选最大的值写入,被称为最后写入者获胜
- 为每个副本分配一个唯一的ID:制定规则,序号高的副本写入始终优先于序号低的副本
- 以某种方式将这些值合并在一起:如按字母顺序排序后拼接
- 应用层解决:保存冲突信息,依靠应用层的逻辑事后解决冲突
- 自定义冲突解决逻辑 :
- 在写入时执行:只要数据库系统在复制日志时检测到冲突,就会调用应用层的冲突处理程序
- 在读取时执行:当检测到冲突时,所有冲突写入值都会暂时保存下来。下一次读取数据时,会将数据的多个版本读返回给应用层,应用层可能会提示用户或自动解决冲突,并将最后的结果返回到数据库
10. 什么是无主节点复制
- 概念:一些数据存储系统选择放弃主节点,允许任何副本直接接受来自客户端的写请求
11. 无主节点复制存在的问题
- 节点失效时写入数据库 :
- 让客户端从多个节点读数据,根据版本号确定哪个值是最新的
- 失效节点重新上线后数据恢复 :
- 读修复:客户端并行读取多个副本时,可以检测到过期的返回值,然后将新值写入到该副本。适合数据被频繁读取的场景
- 反熵:一些数据存储有后台进程不断查找副本之间数据的差异,将任何缺少的数据从一个副本复制到另一个副本
12. 判断操作有效性的规则------Quorum
- 概念:如果有n个副本,并且配置w和r,使得w+r>n,可以预期可以读到一个最新值。因为成功写入的节点和读取的节点集合必然有重合
- 应用场景:ZooKeeper主节点选举,确保选举结果的唯一性和有效性,避免脑裂
13. 并发写冲突处理机制
- 最后写入者获胜(丢弃并发写入) :
- 概念:每个副本总是保存最新值,允许覆盖并丢弃旧值
- 具体实现:为每个写请求附加一个时间戳,然后选择最新即最大的时间戳,丢弃较早时间戳的写入。是Cassandra仅有的冲突解决方法
- 缺点:牺牲了数据持久性,一些数据会被丢弃
- 使用唯一的主键:这样就避免了对同一个主键的并发写。例如,Cassandra的一个推荐使用方法就是采用UUID作为主键,这样每个写操作都针对不同的、系统唯一的主键
六、分布式系统设计------数据分区
1. 什么是数据分区
- 概念:分区通常与复制结合使用,即每个分区在多个节点都存有副本。这意味着某条记录属于特定的分区,而同样的内容会保存在不同的节点上以提高系统的容错性
2. 如何避免分区不均匀
- 基于关键字区间分区 :
- 概念:为每个分区分配一个关键字区间范围。这样知道关键字上下限,就可知道其所属分区
- 缺点:某些访问模式会导致热点。如果每天一个分区,会导致该分区在写入时负载过高
- 基于关键字哈希值分区 :
- 概念:一个好的哈希函数可以处理数据倾斜并使其均匀分布。即使输入的字符串非常相似,返回的哈希值也会在上述数字范围内均匀分布
- 缺点:丧失了良好的区间查询特性
- 改进:采用复合主键,一部分用于哈希分区,其他用来排序和范围查询。如关键字(user_id, update_timestamp),user_id用来分区,时间戳用来区间查询
3. 极端的数据倾斜如何处理
- 概念:所有的读/写操作都是针对同一个关键字,最终所有请求都被路由到同一个分区。如微博热点事件,大量用户都对相同关键字进行操作
- 解决方案:如果某个关键字被确认为热点,在关键字的开头或结尾处添加一个随机数,这样就可以把相同关键字分到不同的分区上
4. 如何对二级索引做分区
- 分区独立 :
- 概念:每个分区维护自己的二级索引,不关心其他分区中的数据
- 缺点:查询代价高昂,需要把所有分区的数据聚合起来
- 全局索引 :
- 概念:对所有数据构建全局索引,全局索引再进行分区
- 缺点:写入速度慢且复杂,更新时可能涉及到多个二级索引,而二级索引的分区又可能完全不同甚至在不同的节点上
5. 如何进行分区再平衡
- 取模(不推荐使用) :取模可以重新分区,但会导致很多关键字需要从现有的节点迁移到另一个节点,这种频繁的迁移操作大大增加了再平衡的成本
- 固定数量分区:创造远超实际节点数的分区数,如果集群新增节点,该新节点可以从每个现有节点匀走几个分区
- 动态分区:当分区的增长超过一个可配的参数阈值,就拆分为两个分区,每个承担一半的数据量。大量数据删除则分区进行合并
- 按节点比例分区:每个节点具有固定数量的分区,这样分区的数量与数据集的大小成正比关系
6. 如何路由到指定分区
- 节点分发:客户端连接任意节点,该节点拥有所请求分区则直接处理,否则将请求转发到下一个合适的节点
- 路由层:所有客户端请求都先发到路由层,路由层将请求转发到对应的分区节点上
- 客户端选择:客户端感知分区和节点分配关系,客户端可以直接连接到目标节点
七、分布式系统故障处理
1. 分布式系统可靠工作的挑战
- 必然面临部分失效,这就需要依靠软件系统来提供容错机制
- 换句话说,我们需要在不可靠的组件之上构建可靠的系统
2. 网络通信存在的问题
- 一个节点可以发送消息(数据包)到另一个节点,但是网络并不保证它什么时候到达,甚至是否一定到达
3. 发送者未收到消息回复如何解决
- 现象:发送者拥有的唯一信息是,尚未收到响应,但却无法判定具体原因
- 解决方法:通常采用超时机制。但是,即使判定超时,仍然并不清楚远程节点是否收到了请求
4. 超时时间如何设定
- 分析:设置较长的超时值意味着更长时间的等待,才能宣告节点失败;设置较短则可能出现误判
- 设置方法 :
- 一种方法是,通过实验一步步设置超时。 先在多台机器上,多次测量网络往返时间,以确定延迟的大概范围;然后结合应用特点,在故障检测与过早超时风险之间选择一个合适的中间值
- 更好的做法是,超时设置并不是一个不变的常量。 而是持续测量响应时间及其变化,然后根据最新的响应时间分布来自动调整。可以用Phi Accrual故障检测器完成,该检测器目前已在Cassandra中使用
5. 分布式系统时间问题
- 由于跨节点通信不可能即时完成,消息经由网络从一台机器到另一台机器总是需要花费时间。网络的延迟不确定,使得多节点通信很难确定事情发生的先后顺序
6. 分布式系统时钟问题解决
- 时间戳与事件排序 :
- 具体问题:跨节点的事件排序高度依赖时钟会有风险
- 解决方法:基于递增计数器是更可靠的方式
- 时钟的置信区间 :
- 具体问题:石英漂移问题可导致偏差高达几毫秒,如果使用NTP服务器,因为网络延迟,最好的精度也只能到几十毫秒,还可能遭遇网络拥塞
- 解决方法:不应该将时钟读数视为一个精确的时间点,而更应该视为带有置信区间的时间范围
- 全局快照的同步时钟 :
- 具体问题:有时需要同步时钟来做事务ID,用来判断事务发生的先后顺序
- 解决方法:Google Spanner根据时钟置信区间,等待足够久的长度,确保所有读事务要足够晚才发生,避免与先前的事务的置信区间产生重叠(应用较少,只有Google有相关实现)
7. 导致进程暂停的原因
- Java垃圾回收:运行期间可能会暂停所有正在运行的线程(Stop the world)
- 虚拟机暂停:暂停所有执行进程并将内存状态保存到磁盘
- 操作系统线程上下文切换:负载很高时,被暂停的线程可能需要一段时间才能再次运行
- 应用程序执行同步磁盘操作:线程可能暂停并等待磁盘I/O完成
- 内存访问触发缺页中断:从磁盘中加载内存页,这个过程通常比较慢
- 发送SIGSTOP信号暂停UNIX进程:会立即停止进程,直到收到SIGCONT之后才从停止的地方继续运行。运维人员可能误触发
8. 分布式系统处理进程暂停
- 必须假定执行过程中的任何时刻都可能被暂停相当长一段时间,包括运行在某个函数中间
- 暂停期间,整个集群的其他部分都在照常运行,甚至会一致将暂停的节点宣告为故障节点
- 最终,暂停的节点可能会回来继续运行,除非再次检查时钟,否则它对刚刚过去的暂停毫无意识
八、分布式系统一致性与共识
1. 为什么需要一致性算法
- 节点不能根据自己的信息来判断自身的状态
- 节点可能随时会失效,甚至最终无法恢复。因此分布式系统不能完全依赖于单个节点
2. 什么是一致性算法
- 概念:许多分布式算法都依靠法定票数,任何决策都需要来自多个节点的最小投票数,从而减少对特定节点的依赖
- 常见的法定票数:取系统节点半数以上
3. 其他节点自认为是主节点如何处理
- 原因:一个节点可能以前确实是主节点,但其他节点有可能在此期间已宣布其失效,系统已经选出了另一个主节点
- 影响:该节点会按照自认为正确的信息向其他节点发送消息,其他节点如果还选择相信它,那么系统就会出现错误的行为
- 解决方法:通过带令牌的分布式锁来解决。获取锁时同时返回一个令牌,该令牌的数字每授予一次就会递增(由锁服务增加)。客户端发送请求到存储服务器时携带令牌号,存储服务器如果记录了更高的令牌号,会拒绝低令牌号的写入
4. 最终一致性和强一致性
- 最终一致性:如果停止更新数据库,并等待一段时间之后,最终所有读请求会返回相同的内容
- 强一致性:让一个系统看起来好像只有一个数据副本,且所有的操作都是原子的。有了这个保证,应用程序就不需要关心系统内部的多个副本
5. 使用强一致性的场景
- 加锁与主节点枚举:选举新的主节点常见的方法是使用锁,即每个启动的节点都试图获得锁,其中只有一个可以成功即成为主节点
- 约束与唯一性保证:唯一性约束在数据库中很常见。例如,用户名或电子邮件地址必须唯一标识一个用户,文件存储服务中两个文件不能具有相同的路径和文件名
- 跨通道的时间依赖 :
- 一个存储照片服务,Web服务器会把照片先写入文件存储服务,当写入完成后,把调整图像分辨率的命令放入消息队列
- 消息队列可能比存储服务内部的复制执行更快,这样调整命令就无法读取到任何内容,或者读到旧版本
- 出现这个问题是因为Web服务器和调整模块之间存在两个不同的通信信道,文件存储器和消息队列。如果没有线性化的就近性保证,这两个通道之间就存在竞争条件
6. 各种分布式系统如何实现强一致性
- 主从复制 :部分支持强一致性
- 从主节点或者同步更新的从节点上读取:可以满足强一致性
- 从自以为主节点的从节点读取数据:会违反强一致性
- 使用异步复制:同时违反持久性和强一致性,故障切换过程中甚至可能会丢失一些已提交的写入
- 多主复制 :不支持强一致性
- 具有多主节点复制的系统通常无法线性化,主要由于它们同时在多个节点上执行并发写入,并将数据异步复制到其他节点
- 无主复制 :可能支持强一致性
- 取决于具体的quorum的配置,以及如何定义强一致性,它可能并不保证线性化
- 基于墙上时钟的"最后写入获胜"冲突解决方法几乎肯定是非线性化,因为这种时间戳无法保证与实际事件顺序一致(例如由于时钟偏移)
7. 什么是CAP理论
- 代表强一致性、可用性、分区容错性,系统只能支持其中两个特性
8. 分布式系统中如何生成序列号
- 一些简单实现方法 :
- 每个节点都独立产生自己的一组序列号:可以在序列号中保留一些位用于嵌入所属节点的唯一标识符
- 可以把墙上时间戳信息附加到每个操作上:时间戳可能是不连续的,但可以用来区分操作的先后顺序
- 可以预先分配序列号的区间范围:如节点A负责区间1-1000,节点B负责区间1001-2000
- 存在问题 :无法保证正确捕获跨节点操作的顺序
- 每个节点可能有不同的处理速度,如每秒请求数,这样就无法准确地知道哪个操作在先
- 物理时钟的时间戳会收到时钟偏移的影响,可能导致实际因果关系不一致
- 对于区间分配器,一个操作可能被赋予到B节点,而后发生的操作又可能赋予到A节点。导致与因果序不一致
- Lamport时间戳(是一个理论模型) :
- 具体实现:是一个值对(计数器,节点ID),每个节点都使用所见到的最大计数器值,计数器值相同时按节点ID排序
九、分布式事务------分布式系统的一致性实现
1. 什么是两阶段提交(2PC)
- 概念:是一种在多节点之间实现事务原子提交的算法,用来确保所有节点要么全部提交,要么全部中止
- 具体实现 :
- 请求提交事务:当应用程序准备提交事务时,协调者开始阶段1:发送一个准备请求到所有节点,询问他们是否可以提交
- 提交事务:如果所有参与者回答"是",表示他们已经准备好提交,那么协调者接下来在阶段2会发出提交请求,提交开始实际执行
- 回滚事务:如果有任何参与者回复"否",则协调者在阶段2中向所有节点发送放弃请求
2. 如果2PC中协调者发生故障怎么办
- 如果在决定到达之前,出现协调者崩溃或网络故障,则参与者只能无奈等待。此时参与者处在一种不确定的状态
- 2PC能够顺利完成的唯一方法是等待协调者恢复。这就是为什么协调者必须在向参与者发送提交(或中止)请求之前要将决定写入磁盘的事务日志,等待协调者恢复之后,通过读取事务日志来确定所有未决的事务状态
3. 分布式事务的限制
- 如果协调者不支持数据复制,而是在单节点上运行,那么它就是整个系统的单点故障
- 许多服务器端应用程序都倾向于无状态模式,而所有的持久状态都保存在数据库中,这样应用服务器可以轻松地添加或删除实例。但协调者就是应用服务器的一部分时,协调者日志就称为了可靠系统的重要组成部分,它就不是无状态的
- 对于数据库内部的分布式事务,2PC要成功提交事务还是存在潜在的限制,它要求必须所有参与者都投票赞成,如果有任何部分发生故障,整个事务只能失败。因此分布式事务有扩大事务失败的风险
十、共识算法------分布式系统共识
1. 共识算法如何实现
- 定义世代编号(epoch) :在每一个世代,主节点是唯一确定的
- 投票: 首先是投票决定谁是主节点,然后是对主节点的提议进行投票。其中关键的一点是,参与两轮的quorum必须有重叠,如果某个提议获得通过,那么其中参与投票的节点中必须至少有一个也参加了最近一次的主节点选举
2. 共识算法投票和2PC的区别
- 最大的区别是,2PC的协调者并不是依靠选举产生
- 另外容错共识算法只需要收到多数节点的投票结果即可通过决议,而2PC则要求每个参与者都必须作出"是"才能最终通过
- 此外,共识算法还定义了恢复过程,出现故障之后,通过该过程节点可以选举出新的主节点然后进入一致的状态,确保总是能够满足安全属性
3. 共识算法的优点
- 为一切不确定的系统带来了明确的安全属性
- 可以支持容错,允许个别节点失效
- 可以提供全序广播,以容错的方式实现强一致的原子操作
4. 共识算法的局限性
- 在达成一致性决议之前,节点投票的过程是一个同步复制过程。但数据库的配置经常是异步复制
- 共识体系需要严格的多数节点才能运行。意味着至少需要3个节点才能容忍1个节点发生故障
- 多数共识算法假定一组固定参与投票的节点集,这意味着不能动态添加或删除节点。动态成员资格则比较复杂
- 共识系统通常依靠超时机制来检测节点失效。在网络延迟高度不确定的环境中,特别是跨区域分布的系统,经常由于网络延迟愿意错误地认为主节点发生了故障。这会大大降低性能
- 共识算法往往对网络问题特别敏感。Raft已被发现存在不合理的边界条件处理,如果整个网络中存在某一条网络连接持续不可靠,它会不断在两个节点之间反复切换主节点,让系统处于不稳定状态