工欲善其事,必先利其器,OceanBase 社区版代码量较大,在学习 OceanBase 代码时,需要有一款较好的源码阅读工具,来帮助大家更好地理解代码的执行流程、接口的层次关系等等。根据我在 OceanBase 6 年来的开发和阅读代码体验,一款较好的源代码阅读工具需要如下的一些功能:
上面的功能是在平时的开发和源码阅读过程中使用较多的功能,根据我目前的使用经验,有两款工具基本符合上面的条件,第一款是 Eclipse C/C++版本,第二款是 CCLS,可以在 vim 等编辑器中使用。本文将主要介绍 Eclipse 下的这些功能。
可以从 Eclipse 官网下载到 Eclipse C/C++,下载完成后,打开里面的 Eclipse 应用即可使用
从 Eclipse 的菜单栏 New->Makefile Project with Existing Code,导入 OceanBase 的源代码。项目生成后,Eclipse 会自动为代码构建索引,在普通的个人电脑上,索引的时间大约半个小时左右。
在开发和源码阅读中,有时候我们记得某个函数或类的名字,但忘记所在代码文件了,此时,按照符号搜索函数名、类名功能就非常方便了。在 Eclipse 中,通过 Navigate->Open Element 使用该功能。
有时候,我们知道某个功能的代码实现在某个文件内,但忘记了具体的函数名了,此时,我们可以结合按照文本搜索文件名,以及在文件内符号查找来找到对应的代码。在 Eclipse 中,通过 Navigate->Open Resource 使用该功能。
当我们定位到具体的代码文件后,如果需要在文件中查找对应的类、符号以及类内成员名等,可以使用该功能。在 Eclipse 中,打开某个代码文件后,通过右击鼠标->Quick Outline,在对应的搜索框里输入相应的符号即可查找。
有时候我们需要按照文本搜索匹配的代码,例如,在 OceanBase 中,内存分配是按照模块命名的,如果我们想查找使用某个模块名分配内存的代码,那么我们可以搜索该模块名的字符串来匹配代码。在 Eclipse 中,可以通过 Search->File 打开对应的搜索框,在里面输入想要查找的字符串。
在 C++代码中,可能会使用继承来组织代码,有时候需要查看类的继承关系来看不同场景下的实现。在 Eclipse 中,通过光标选中类名,然后调用 Navigate->Open Type Hierarchy 来查看类的继承关系。
在排查问题时,我们可能会碰到某个函数运行处理的结果不符合预期的情况,此时,我们可能需要通过函数调用栈来跟踪函数执行路径,来判断哪条路径可能出问题了。在 Eclipse 中,光标移动到函数名上,Navigate->Open Call Hierarachy。根据我在 MAC 开发环境的使用经验,Eclipse 能够快速又较为准确地对 OceanBase 这样体量代码的函数查看调用栈,其他的 IDE 暂时还不能够同时满足快速和准确。
有时候需要理解某个类内成员变量的作用,此时要查看其所有使用的地方。在 Eclipse 中,跟查看函数调用栈使用方式一样,可以查看某个变量的调用栈。
在我们学习某个功能的实现时,通常需要自上而下跟踪代码路径下的实现,因此,需要有跳转到某个函数实现的能力。在 Eclipse 中,将光标移动到调用的函数上,然后通过 Navigate->Open Declaration 跟踪到具体的实现。有时候跟踪到具体实现后,又希望回到之前的代码处继续跟踪下面的流程,此时可以通过 Navigate->Back 来回到之前代码处。
本文提到的代码查找功能同样适用于任何其他 C/C++项目,如果有学习其他 C/C++代码的需求,也可以作为参考。
OceanBase 是蚂蚁集团/阿里巴巴完全自主研发的一款分布式关系型数据库,在今年 6 月 1 号,OceanBase 开源了 3.1 版本的源代码,称为 OceanBase 社区版(下文简称 OceanBase)。OceanBase 是一款开源分布式 HTAP(Hybrid Transactional/Analytical Processing)数据库管理系统,具有原生分布式架构,支持金融级高可用、透明水平扩展、分布式事务、多租户和语法兼容等企业级特性。本文先简单地介绍 OceanBase 的架构,然后描述 OceanBase 的源代码的整体结构。
OceanBase 社区版的一个集群通常由多个 Zone 组成,一个 Zone 由一个或多个 ObServer 组成的,每个 ObServer 都具有计算和存储的功能。在 ObServer 中有一个较为特殊,负责总控服务的节点称为 RootService,负责管理集群的元数据和路由信息,其中,元数据是按照多版本方式管理的。OceanBase 按照分区的方式管理数据,一张表包含一个或多个分区,每个分区的数据会存储在多个 Zone 中,每个 Zone 都是一份完整的数据拷贝(副本)。每个分区的副本中会有一个 Leader 副本,负责处理该分区的读写请求。如下图所示,该 OceanBase 集群中有 3 个 Zone,每个 Zone 内有 3 个 ObServer,每个 Zone 内各自有一个 RootService 节点,每个分区在每个 Zone 里都有一个分区,总共 3 个副本,通过一致性算法 PAXOS 来保证三个副本数据的一致性。
在每个 ObServer 中都包含 SQL 引擎、存储引擎两个部分,其中 SQL 引擎负责 SQL 的解析和执行,存储引擎主要包括事务处理、分布式环境数据一致性处理以及基于 LSM-Tree 架构的数据存储。
跟单机数据库类似,OceanBase 的 SQL 引擎也主要分为三个部分,包括解析器、优化器和执行器。当 SQL 引擎接受到了 SQL 请求后,经过语法解析、语义分析、查询重写、查询优化等一系列过程后,再由执行器来负责执行。所不同的是,在分布式数据库里,查询优化器会依据数据的分布信息生成分布式的执行计划。如果查询涉及的数据在多台服务器,需要走分布式计划,这是分布式数据库 SQL 引擎的一个重要特点,也是十分考验查询优化器能力的场景。OceanBase 数据库查询优化器做了很多优化,诸如算子下推、智能连接、分区裁剪等。如果 SQL 语句涉及的数据量很大,OceanBase 数据库的查询执行引擎也做了并行处理、任务拆分、动态分区、流水调度、任务裁剪、子任务结果合并、并发限制等优化技术。
OceanBase 的存储引擎主要提供事务执行、多副本数据一致性处理和 LSM-Tree 存储引擎三方面能力。在事务执行方面,OceanBase 需要保证在分布式环境下事务的 ACID 特性;在多副本数据一致性处理方面,OceanBase 采用了 PAXOS 一致性算法来保证数据在正常以及容灾场景下多副本数据的一致性;在数据存储方面,OceanBase 提供了 LSM-Tree 架构的存储引擎,具有高压缩比等特点。
OceanBase 源代码可以从OceanBase 开源项目链接下载,所有内核相关的源代码都在 src 目录下,它下面主要有 rootserver、sql、storage、observer、election 和 clog 目录,下面将分别介绍这些子目录的代码结构。
sql 里有 parser、resolver、rewrite、optimizer、executor、dtl、code_generator、engine、plan_cache、session 等。
storage 里有 blocksstable、compaction、gts、memtable、replayengine、transaction 以及 storage 目录本身的代码文件。
rootserver 目录包括 backup、restore、virtual_table 和 rootserver 目录本身的代码
observer 目录包括 omt、virtual_table、observer 目录本身的代码
包含了 PAXOS 选主相关的实现。
包含了 PAXOS 相关的实现。
本文主要是对 OceanBase 代码做了概览,希望能帮助大家对 OceanBase 整体代码结构有大概的认识。后续还将结合 OceanBase 的设计、代码对各个模块相应的解读。
]]>F1是Google自主研发的分布式数据库,采用计算与存储分离的架构,存储层采用Spanner作为分布式KV存储引擎,计算层则是F1团队研发的分布式SQL引擎,其整体架构如下图
存储层向SQL层(F1 Server)提供KV操作接口,而SQL层负责将用户请求的关系Schema数据转换成KV存储格式。在此架构下,F1有以下特点
根据F1的上述特点,对其Schema变更需要有如下需求
针对上述Schema变更需求,F1团队分析了异步的Schema变更可能导致的数据不一致的问题,提出了一种安全的Schema变更算法。本文将先简单介绍KV存储引擎的提供的接口,然后分析异步的Schema变更导致的问题,最后再描述F1的Schema变更算法以及其限制点。
F1 Server所依赖的存储引擎需要提供三个操作接口:
除此之外,还需要提供如下语义保证:
由于F1对外提供的是关系型的Schema,因此,F1 Server负责将关系型Schema的数据转换成相应的KV记录。在关系型Schema中,最常用的Schema元素是表格,表格的Schema定义中一般会包含多个列定义,其中会在多列中选取其中的某一列或者几列来作为主键列,主键列能唯一的标识一行。很自然地,将表格的一行存储为KV记录时,会选取主键列的列值作为Key,F1并没有简单的采用将关系表格中的一行映射成一条KV记录的方法,而是将除主键列之外的每列都其映射成一条KV记录,规则如下
1 | Key:table_name.primary_key_column_values.non_primary_key_column_name |
对于主键列,只需要用一个特殊的列名,标识其存在即可,F1使用了列名为exists,列值为null。
下面举一个例子来说明上述映射规则
1 | create table Example |
假设其中插入了两行数据
1 | first_name last_name age phone_number |
按照上述规则转换成的KV记录为
1 | Key Value |
在关系型数据库中,除了数据表本身之外,还有索引表,F1中索引表的存储格式为
1 | Key:table_name.index_name.index_column_values.primary_column_values |
在索引表对应Key中存储主键列的值一方面是为了避免Key重复,另一方面是为了查询时能够做回表操作。接着上面的例子,假设在Example表格中,建了一个索引index_age,如下
1 | create index index_age on Example(age); |
那么,索引表中的两行对应的KV记录如下
1 | Key Value |
除了将关系性Schema中的行映射成KV记录外,F1 Server还负责将关系操作映射对应的KV操作,通常的关系操作包括insert、delete、update和select。
根据F1的架构特点,事务执行时,有可能出现不同的语句在不同的F1 Server执行的情况,那么不同的语句可能使用了不同版本的关系型Schema。为了设计和实现简单,F1只允许系统中同时出现两种不同版本的Schema,如下
假设Schema版本S1 < S2,且S2比S1多了一张索引表,有如下执行过程
当上述事务执行完成后,索引表将会有多余的中间数据,导致数据表和索引表的数据不一致。
F1 Schema变更算法要解决的问题是在系统中至多允许存在两个版本的Schema的前提下,保证数据库的表示(即所有的记录集合)在Schema变更时保持一致性。在讨论如何解决该问题之前,还需要定义清楚,何为数据库表示的一致性?在F1中,Schema的最终状态分为两种absent和public,一个数据库表示在某个版本的Schema S上是一致的,其需要满足如下条件:
如果数据库中包含了本不应该属于它的数据,此不一致称为orphan data anomaly,即违反了1,3,5,7约束项;如果数据库缺少了本应该属于它的数据,或者违反了一个public约束,此不一致称为integrity anomaly,即违反了2,4,6约束项。
假设OP(S),代表任意的delete,update,insert或select在特定的Schema S中执行,任意一个正确实现的操作,应该保证该操作在Schema S中执行后,数据库表示是一致的,但是它不能保证按照Schema S版本执行之后,数据库表示还能在其他版本的Schema S’中也是一致的。
因此,Schema变更前后(S1->S2)的数据库表示一致性的,需要满足如下条件
从前文的分析知道,直接将Schema元素从absent->public是可能导致数据库表示不一致的,因此,F1引入了Schema变更的中间状态,包括delete-only,write-only和write-only constraint。
write-only:一个write-only的列或者索引,可以做insert,delete和update操作,但不能做select操作
write-only constraint:write-only constraint只能应用于新的insert,delete和update的数据,但对已有的数据不保证约束条件是满足的
从前面Schema变更的问题可以看出,Schema版本的回退导致在新版本插入的数据,回退到老版本时无法删除,因此,直观上来讲,对于添加一个Optional的Schema元素,可以在absent->public中间插入一个delete-only状态,即absent->delete-only->public,接下来将说明这样的变更过程是安全的。
首先来看absent->delete-only,由于两种状态都不会产生新的Schema元素的数据,因此,保证了不会出现orphan data问题,并且由于两种状态都是非public状态,因此,不会出现integrity问题。
接着来看delete-only->public,由于两种状态都能够删除需要删除的数据,因此,不会出现orphan data问题,且由于Schema元素是Optional的,因此,不会出现integrity问题。
删除的变更过程正好相反,不再描述,下文同。
添加Required的Schema元素不能直接采用absent->delete-only->public的变更流程,因为,添加Required的Schema元素需要补充已有的数据,而delete-only状态是无法补充数据的,因此,需要在delete-only->public中添加一个write-only状态,即添加一个Required的Schema元素需要经过absent->delete-only->write-only->public的变更流程,接下来就来说明这个变更过程是能保证数据的一致性的。
absent->delete-only状态前面已描述过,这里不再赘述。对于delete-only->write-only状态转换,由于两种状态下都会删除新添加的Schema元素相关联的数据,因此不会出现orphan data问题,并且,由于两种Schema状态都是非public的,能保证不会出现integrity问题。
write-only->public的状态变化过程中,需要先保证对已有的数据完成补充,才能最终将状态变成public,由于两种状态都能删除新添加的Schema相关的数据,因此,不会出现orphan data问题,并且,由于两种Schema状态都会对新写入的数据做约束检查,新数据是能保证约束的,而对于原有的数据,需要后台任务在填充时做检查,如果都满足条件,才能将Schema元素变成public,否则,Schema变更应该失败。
由于F1 Server只允许同时存在两个不同版本的Schema,因此,在实现时,需要通过Schema Lease来保证Schema的更新,如果一个F1 Server无法更新Lease,那么会自动退出。同时,F1 Server的写操作不能跨两个版本的Schema,因此,如果一个写操作是基于老版本Schema的,是无法成功的,需要重试。
另外,由于同时只允许存在两个不同版本的Schema,因此,1个Schema Lease只能做一个中间状态的变更,通常的不带数据不全的DDL操作,需要等待3-4个Schema Lease,而Schema Lease是分钟级别的,因此,一个不带数据不全的DDL变更需要等待分钟到数十分钟;如果带上数据补全,则根据原有数据量的大小,耗费的时间可能会很长。F1中采用把多个DDL变更的中间批量执行,来提高Schema变更的效率。
最后,博客推送的公众号欢迎大家关注
在分布式系统中,经常会碰到的技术名词一般有Replication、Partition、Consensus、Transaction等等,这些技术在分布式系统设计中都是非常重要的,本文通过对分布式系统的Reliability、Scalability和Maintainability特性的讨论,描述这些技术解决的问题。
Reliability,指的是在任何情况下,系统正常工作的能力。如果一个系统在发生任何异常时,都能正常的工作,那么系统是完全可靠的。现实中,异常种类很多,有的往往难以事先避免,因此,了解可能的异常并分析如何在异常发生时快速恢复是非常重要的。一般地,异常包括硬件异常,软件异常和人为异常。
硬件异常种类很多,硬盘,电源等任意一个部件的损坏,都可能导致服务器不能正常的工作。通常这类异常难以避免,但是,我们可以通过一些技术手段来实现异常发生后的快速恢复,不管是从软件角度还是硬件角度,基本的解决思路都是冗余。从硬件角度来讲,我们可以通过单机冗余多份硬件,当其中某个硬件发生异常时,可以快速地用好的硬件替换掉故障的硬件,这种方式的硬件冗余对于数据中心级的故障是没有作用的;从软件角度来讲,我们可以通过多副本(Replication)来实现快速恢复,当某台服务器硬件异常时,可以在软件层面将流量导入到新的副本上(实际上也有硬件冗余,但这种方式更为灵活),除了Replication之外,有时候为了减少单台服务器故障对所有用户的影响,可以对用户数据做Partition,单台服务器只存某一部分用户的数据,这样单机故障就只会影响一部分用户了。引入Replication后,如何保证多副本的数据的一致性又成了一个问题(Consensus),Paxos和Raft算法就是为了解决这类问题。
软件异常一般指的是系统的bug,这里面不仅包括自己写的系统的bug,也包括依赖的服务系统的bug。软件异常同样也是不能完全避免的,因此,在发生软件异常时,也需要有快速恢复的手段,通常有三种方法:
在没有发生致命性问题时,一般采用方法1或2来恢复,当发生的问题比较严重,并且没有已知的方法能绕过时,一般才使用方法3,方法3本身风险也是比较大的,因为修复bug的同时可能会产生新的bug。
不管是软件本身,还是软件所运行的服务器,都是由人来管理的,但人是会犯错误的,有时候会执行错误的命令导致系统不能正常工作,其中比较致命的错误可能就是删掉某台服务器的数据了,在这种情况下为了能快速地恢复,通常也是采用Replication的思路,来避免问题。
系统的工作负载通常不是一成不变的,当工作负载增加时,往往可以通过增加机器资源来保持性能不变,而需要增加机器数量的多少是由系统的扩展性来决定的,扩展性越好的系统,需要增加的机器资源越少。最完美的扩展性是线性扩展性,即工作负载扩大为原来N倍的时候,只需要加N倍的机器,就能够保持性能不变,最差的扩展性则是没有扩展性,即工作负载扩大为原来N倍时,即使加再多的机器,也无法保持性能和原来一样。
对于不同的系统,负载所代表的含义通常是不一样的,对于基础架构系统,通常每秒读和每秒写的次数,对于业务系统,通常有自己的指标,例如每秒交易创建的笔数。同样地,对于不同的系统,其使用的性能指标通常也是不相同的,对于批处理系统,通常强调的是吞吐量,即每秒完成的任务数量,而对于在线处理系统,通常强调的是响应时间。
在明确一个系统的工作负载指标和性能指标之后,我们才能讨论在该系统下如何实现扩展。扩展通常是两种思路,一是垂直扩展,即使用更好的机器替换现有的机器,二是水平扩展,即使用更多的机器。
对于垂直扩展,其优点是对业务是无影响的,缺点是更好的机器是很贵的,通常是一分钱一分货,而十分钱只能买到两分货,且现实中总有单机装不下的数据量,此时垂直扩展自然就无法实施了。
对于水平扩展,通常需要软件层面的配合,对于无状态的系统,通常只要在新加的机器上部署上需要扩展的系统,而对于有状态的系统,一般指的是存储系统,通常会将数据分成Partition(分区),这样新加的机器才能通过迁移Partition的方式,从老的机器上迁移数据以及对应的工作负载出来。水平扩展的优点是使用的都是相对廉价的服务器,能节约成本,但在软件层面需要做大量的工作,包括Partition的管理,迁移,负载均衡等等。
可维护性的好坏决定了系统是否能够长久的发展,一个可维护性不好的系统,会给运维和开发人员带来很多不便。对于运维人员来讲,可维护性指的是系统是否支持常用的运维手段,良好的文档等等。而对于开发人员来讲,主要分为内核开发以及使用该系统的业务开发,对于业务开发,维护性指的是系统是否有良好的接口,方便业务使用,例如,Transaction就是底层系统提供给业务的一种接口,它保证了在一个事务中执行的语句具有ACID性质,从而业务只需要关注业务逻辑的开发,而不需要关心底层的具体实现;对于内核开发,维护性指的是系统的代码质量,主要包括代码的可阅读性和是否易于修改,主要和系统内核开发人员的代码设计能力相关。
为了能达到较好的可靠性(Reliability)、可扩展性(Scalability)和可维护性(Maintainability),分布式系统设计中通常会使用多副本(Replication)、数据分区(Partition)、一致性算法(Consensus)、事务(Transaction)等技术,理解它们要解决的问题,深入了解每种技术背后可能的实现方案,有助于评价某个系统的设计好坏,这对于多个竞品系统的选型和深入学习系统原理都是非常有必要的。
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
国内较多的互联网公司都是采用MySQL作为数据库系统,随着业务的发展,难免会碰到需要新建索引来优化某些SQL执行性能的情况。在MySQL实现online create index之前,新建索引意味着业务要停止写入,这是非常影响用户使用体验的,为此,MySQL引入了online create index,极大地减少了业务停写的时间,使得新建索引期间业务能够持续正常的工作。本文主要是对其实现原理的总结以及关键步骤的解释说明。
在MySQL中表格至少需要设置一个主键,如果用户未指定主键的话,内部会自动生成一个。对于带主键的表格,MySQL会以聚集索引的方式实现,即表格的数据都是完整的存储在聚集索引上的。对于主键的变更,相当于对聚集索引进行变更,这个过程目前MySQL还是以停写的方式实现的,本文主要讨论的是新建二级索引的实现,为了方便描述,以一个例子来说明本文要讨论的场景。
1 | create table t1( |
刚开始业务中的SQL都是以主键c1来做查询的,后来随着业务的发展,可能出现了以c2做查询的SQL,此时,为了优化此类SQL的执行性能,需要在c2列上构建索引,即
1 | create index index_c2 on t1(c2); |
MySQL online create index主要分为两个阶段,第一阶段为从主表读取索引列并排序生成索引表的数据,称为基线数据;第二阶段为把新建索引阶段索引表的增量数据更新到第一阶段的基线数据上。具体来看,主要过程如下。
接下来将略过不太重要的步骤1和步骤5,主要描述步骤2-4的详细实现。
在执行create index语句之后,MySQL会先等待之前开启的事务先结束后,再真正开始索引的构建工作,这么做的原因是在执行create index
之前开启的事务可能已经执行过某些更新SQL语句,这些SQL语句没有生成新建索引表的增量数据(Row Log),如果不等待这部分事务结束,可能会出现基线数据中没有此部分数据,且Row Log中也没有此部分数据,最终该部分数据在索引表中不存在。
MySQL的等事务结束是通过MDL(Meta Data Lock)实现的,MDL会按序唤醒锁等待者,这样就能保证create index之前开启的事务一定执行完成了。
实际测试中,可以观察到当create index之前的事务一直没有结束时,create index语句会一直卡在thd->mdl_context.upgrade_shared_lock
(sql_table.cc:7381)上。
索引构建的第一阶段的工作是根据主表的数据,来构建索引表的数据。此过程总共有两个步骤,第一是读取主表中所需要的索引列数据;第二是将数据按照索引列排序。
其中读取主表数据和普通的全表扫描区别不大,而将数据按照索引列排序则是一个外部排序的过程。MySQL对外部排序实现较为简单,仅为最普通的单线程两路归并算法,优点是实现简单,占用内存资源少,缺点是性能较差。
一般地,对于数据量较大的表格,构建索引的时间较长,通常是小时级别的,这期间往往会有新事务的提交,其中就可能包含对新建索引表的修改。因此,在索引基线数据构建好之后,还需要把构建期间的增量数据更新到索引表中,那么问题来了,在更新增量数据到索引表中会不断的有新事务修改数据,这样何时才能保证所有的修改都更新到索引表上呢?答案是加锁,粗暴一点的加锁方式是在整个增量数据更新到索引表期间停写,完成之后,再放开写入。但是,因为索引构建时间长,增量数据的数据量一般也较大,如果更新整个增量数据到索引表期间都停写的话,会较大地影响用户使用体验。因此,MySQL对加锁过程做了优化。
首先Row Log会被拆分为多个较小的Block,事务的更新会把数据写入到最后一个Block中,因此,普通的DML更新的时候会对最后一个Block加锁。同样的,在更新每个Block到索引表的时候,会先加锁,如果当前Block不是最后一个Block时,会把锁释放,如果是最后一个Block,则保持加锁状态,直到更新结束。因此,在更新Row Log到索引表期间,加锁的时间比较短,仅在最后一个Block更新到索引表时会持有锁一段时间。
MySQL online create index的整体思路分为两步构建基线以及更新增量,构建基线时采用的归并算法比较简单,资源占用少,但性能会比较差;在更新增量时,采用将增量切分成更小的块,来减少停写的时间,是比较通用的方法。
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
RAID,全称为redundant array of independent disks,是目前商用服务器常见的磁盘管理技术。作为软件开发人员,需要了解各级RAID的特性,以便根据需求做出做合适的选择。本文总结了常见的RAID级别的特性,包括如下内容:
RAID的一般有如下作用
数据冗余是指把数据的校验信息存放在冗余的磁盘中,在某些磁盘数据损坏时,能从其他未损坏的磁盘中,重新构建数据。
性能提升是指RAID能把多块独立的磁盘组成磁盘阵列,通过把数据切成分片的方式,使得读/写数据能走多块磁盘,从而提升性能。
根据RAID的冗余信息程度,切分数据的方式等不同,可以把RAID分成不同的级别,分别是
接下来就讨论这些RAID级别的基本原理。
RAID0设计的目标是为了提升读写性能,但并不带数据冗余信息。
如上图,RAID0会把数据切成块,分别存储在N个磁盘上。当读数据时,如果要读的数据块比较大,分布在多次磁盘上,那么能同时从多块盘读数据;当写数据时,如果要写的数据块比较大,分布式在多块磁盘上,那么同时能从多块盘写数据。
因为数据分布在多块盘上,当某块磁盘损坏时,整个RAID系统就不可用了。因此,N块盘的RAID0的特性如下:
RAID1的设计目标是为每份数据都提供一份或多份冗余数据,其结构如下:
如上图,RAID1中一个磁盘都有一个或多个冗余的镜像盘,所有磁盘的数据是一模一样的。RAID1读数据时,可以利用所有数据盘的带宽;写数据时,要同时写入数据盘和镜像盘,因此,需要等待最慢的磁盘写完成,写操作才完成,因此,写性能跟最慢的磁盘相当。N块盘的RAID1的特性如下:
RAID2的设计目标是在RAID0级别的基础上,加了海明纠错码。
如上图,前面四个盘是数据盘,后面三个盘是纠错码。RAID2读数据时,能同时使用多个数据盘的带宽;RAID2写数据时,除了写数据盘,还需要写校验盘,写性能会有下降。因此,N块盘的RAID2的特性如下:
RAID3是把数据按照字节分别存在不同的磁盘中,并且最后一个磁盘提供纠错冗余,其结构如下:
如上图,由于按照字节切分数据,读数据时,一定会同时从多个盘读数据,可以利用所有数据盘的带宽;写数据时,也会利用所有磁盘的带宽,但所有的写校验数据都会在一个盘,因此,写性能主要受限于校验盘。N快盘的RAID3的特性如下:
RAID4是把数据按照分块分别存在不同的磁盘中,并且最后一个磁盘提供纠错冗余,其结构如下:
如上图,读数据时,当数据分布在多块盘时,能够利用多块数据盘的带宽;写数据时,如果数据分布在多快盘时,能利用所有磁盘带宽,但写校验数据只能在一块盘上,因此,写性能主要受限于校验盘。N块盘的RAID4的特性如下:
RAID5是把数据块按照分块分别存在不同的磁盘中,并且冗余信息也会分块分布在多块磁盘中,其结构如下:
如上图,读数据时,当数据分布在多块盘时,能够利用多块数据盘的带宽;写数据时,如果数据分布在多块盘时,能利用所有数据盘带宽,同时写校验数据也分散在多块盘上,但因为要额外写入校验数据,因此,写数据的性能略微有所下降。N块盘的RAID5的特性如下:
RAID6是把数据块按照分块分别存在不同的磁盘中,并且冗余信息为两份奇偶校验码,分布在多块磁盘中,其结构如下:
如上图,读数据时,当数据分布在多块盘时,能够利用多块数据盘的带宽;写数据时,如果数据分布在多块盘时,能利用多块数据盘带宽,同时写校验数据也分散在多块盘中,但因为要额外写入两份校验数据,因此,写数据的性能要略微下降。N块盘的RAID6的特性如下:
RAID级别 | 读性能 | 写性能 | 空间利用率 | 最大能容忍的坏盘数 |
---|---|---|---|---|
RAID0 | 单块盘的N倍 | 单块盘的N倍 | 100% | 0 |
RAID1 | 单块盘的N倍 | 最慢磁盘的性能 | 1/N | N-1 |
RAID2 | 不到单块盘的N倍 | 单盘的写入速度 * 校验盘的数量 | 不到100% | 取决于海明纠错码位数 |
RAID3 | 单块盘的N-1倍 | 校验盘的写入速度 | (N-1)/N | 1 |
RAID4 | 单块盘的N-1倍 | 校验盘的写入速度 | (N-1)/N | 1 |
RAID5 | 单块盘的N倍 | 略微弱于单块盘的N倍 | (N-1)/N | 1 |
RAID6 | 单块盘的N倍 | 略微弱于单块盘的N倍,差于RAID5 | (N-2)/N | 2 |
一般地,RAID0容忍的坏盘数为0,风险太大,一般不常用;RAID1的信息冗余量很多,适合于对信息安全要求很高并且预算充足的场景;RAID2的控制器比较复杂,一般不常用;RAID3和RAID4由于其写入性能差,也不常用;RAID5由于读写性能、能容忍的坏盘数都比较均衡,因此,一般工业界经常使用的是RAID5;RAID6对于坏盘数容忍度较高,适合于对信息安全比较高的场景。
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
SSD是目前商用服务器上非常流行的存储介质,因此,作为软件开发人员,需要了解的SSD基本原理,以便开发时能更好地发挥其优势,规避其劣势。本文总结了作为软件开发人员需要了解的SSD基本原理,全文组织结构如下:
首先,从软件开发人员作为SSD的用户角度来讲,首先需要了解的是SSD和普通HDD的性能对比,如下:
先来看顺序读和顺序写
其中,Seagate ST3000DM001是HDD,其他的都是SSD。从上述两图中可以看出,HDD的顺序读速度差不多为最慢的SSD的一半,顺序写稍微好点,但也比大部分慢一倍左右的速度。
再来看随机读和随机写
可以看出,HDD的随机读的性能是普通SSD的几十分之一,随机写性能更差。
因此,SSD的随机读和写性能要远远好于HDD,本文接下来的几个小节将会讨论为什么SSD的随机读写性能要远远高于HDD?
备注:本小节测试数据全部来自于HDD VS SSD。
SSD内部一般使用NAND Flash来作为存储介质,其逻辑结构如下:
SSD中一般有多个NAND Flash,每个NAND Flash包含多个Block,每个Block包含多个Page。由于NAND的特性,其存取都必须以page为单位,即每次读写至少是一个page,通常地,每个page的大小为4k或者8k。另外,NAND还有一个特性是,其只能是读或写单个page,但不能覆盖写如某个page,必须先要清空里面的内容,再写入。由于清空内容的电压较高,必须是以block为单位。因此,没有空闲的page时,必须要找到没有有效内容的block,先擦写,然后再选择空闲的page写入。
在SSD中,一般会维护一个mapping table,维护逻辑地址到物理地址的映射。每次读写时,可以通过逻辑地址直接查表计算出物理地址,与传统的机械磁盘相比,省去了寻道时间和旋转时间。
从NAND Flash的原理可以看出,其和HDD的主要区别为
因此,在顺序读测试中,由于定位数据只需要一次,定位之后,则是大批量的读取数据的过程,此时,HDD和SSD的性能差距主要体现在读取速度上,HDD能到200M左右,而普通SSD是其两倍。
在随机读测试中,由于每次读都要先定位数据,然后再读取,HDD的定位数据的耗费时间很多,一般是几毫秒到十几毫秒,远远高于SSD的定位数据时间(一般0.1ms左右),因此,随机读写测试主要体现在两者定位数据的速度上,此时,SSD的性能是要远远好于HDD的。
对于SSD的写操作,针对不同的情况,有不同的处理流程,主要是受到NAND Flash的如下特性限制
SSD的写分为新写入和更新两种,处理流程不同。
先看新写入的数据的流程,如下:
假设新写入了一个page,其流程如下:
而更新操作的流程如下:
假设是更新了page G中的某些字节,流程如下:
可以看出,如果在更新操作比较多的情况下,会产生较多的无效页,类似于磁盘碎片,此时,需要SSD的over-provisioning和garbage-collection。
over-provisioning是指SSD实际的存储空间比可写入的空间要大,比如,一块可用容量为120G的SSD,实际空间可能有128G。为什么需要over-provisioning呢?请看如下例子:
如上图所示,假设系统中就两个block,最终还剩下两个无效的page,此时,要写入一个新page,根据NAND原理,必须要先对两个无效的page擦除才能用于写入。此时,就需要用到SSD提供的额外空间,才能用garbage-collection方法整理出可用空间。
garbage collection的整理流程如上图所示
有空闲page之后,就可以按照正常的流程来写入了。
SSD的garbage-collection会带来两个问题:
如果频繁的在某些block上做garbage-collection,会使得这些元件比其他部分更快到达擦写次数限制,因此,需要某个算法,能使得原件的擦写次数比较平均,这样才能延长SSD的寿命,这就需要下面要讨论的损耗均衡控制了。
为了避免某些block被频繁的更新,而另外一些block非常的空闲,SSD控制器一般会记录各个block的写入次数,并且通过一定的算法,来达到每个block的写入都比较均衡。
以一个例子,说明损耗均衡控制的重要性:
假如一个NAND Flash总共有4096个block,每个block的擦写次数最大为10000。其中有3个文件,每个文件占用50个block,平均10分钟更新1个文件,假设没有均衡控制,那么只会3 * 50 + 50共200个block,则这个SSD的寿命如下:
大约为278天。而如果是完美的损耗均衡控制,即4096个block都均衡地参与更新,则使用寿命如下:
大约5689天。因此,设计一个好的损耗均衡控制算法是非常有必要的,主流的方法主要有两种:
这里的dynamic和static是指的是数据的特性,如果数据频繁的更新,那么数据是dynamic的,如果数据写入后,不更新,那么是static的。
dynamic wear leveling的原理是记录每个block的擦写次数,每次写入数据时,找到被擦除次数最小的空block。
static wear leveling的原理分为两块:
以一个例子来说明,两种擦写算法的不同点:
假如SSD中有25%的数据是dynamic的,另外75%的数据是static的。对于dynamic wear leveling方法,每次要找的是擦除了数据的block,而static的block里面是有数据的,因此,每次都只会在dynamic的block中,即最多会在25%的block中做均衡;对于static算法,每次找的block既可能是dynamic的,也可能是static的,因此,最多有可能在全部的block中做均衡。
相对而言,static算法能使得SSD的寿命更长,但也有其缺点:
最后,我们分析一下SSD的写放大问题,一般由如下三个方面引起:
通常的,需要在其他方面和SSD的写放大之间做权衡,例如,可以减少garbage collection的频率来减少写放大问题;可以把SSD分成多个zone,每个zone使用不同的wear leveling方法等等。
个人理解,使用SSD时,我们需要考虑如下情况:
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
前一篇文章中,介绍了ANSI SQL标准下的事务隔离级别及其扩展,这篇文章主要讨论了基于加锁的方式如何实现不同的事务隔离级别,全文的组织架构如下:
ANSI SQL标准下的事务隔离级别是基于禁止某些干扰现象而制定的,这些现象如下:
脏读P1
W1(X)…R2(X)…A1…R2(X)
不可重复读P2
R1(X)…W2(X)…C2…R1(X)
幻读P3
R1(P)…W2(P)…C2…R1(P)
针对三种现象,ANSI SQL标准设定了四种事务隔离级别,如下:
整个事务个隔离级别,与杜绝的现象的对应关系如下图:
由于ANSI SQL的标准存在以下限制:
即新干扰现象P0和P4,其中脏写P0如下
W1(X)…W2(X)…A1
写丢失P4如下
R1(X)…R2(X)…W2(X)…C2…W1(X)…C1
因此,引入了新的隔离级别,包括
具体的分析,请参照我的博文(事务隔离(一):ANSI SQL事务隔离级别,限制及扩展)。
锁有两种,即共享锁(Share Lock)和排他锁(Exclusive Lock),对于不同事务加在同一个数据项上的锁,如果其中至少有一个是排他锁的话,那么事务是会冲突的,即其中一个事务必须等待。一般共享锁也称为读锁(Read Lock),而排它锁也称为写锁(Write Lock)。
读写锁根据锁住的数据项不同,分为普通锁和谓词锁。谓词锁是指锁住满足某一查询条件的所有数据项,它不仅包括当前在数据库中满足条件的记录,也包括即将要插入,更新或删除到数据库并满足查询条件的数据项。对于不同事务加在查询条件下的谓词锁,在至少一个是写锁的情况,且两个谓词条件中包含重叠的数据项时,则两个事务是冲突的。
well-formed read(write)是指在read(write)一个数据项或者查询条件时,会先对数据项或者查询条件加read(write) lock。如果一个事务的所有读写都是well-formed,那称事务是well-formed。
two-phase read(write)是当有一个或多个read(write) lock被释放后,不能再加新的read(write) lock。如果一个事务在释放一个或多个锁后,不再加其他的锁,那么称该事务是two-phase的。
如果一把锁从加上,到事务结束(commit or abort)后才释放,则称此锁是long duration的,否则,称锁是short duration的。
多个并发执行的事务是serializability,指的是并发调度执行的结果等于这些事务某个串行执行的结果。例如,有三个并发执行的事务T1,T2和T3,如果其执行结果和其某个串行执行((T1,T2,T3),(T1,T3,T2),(T2,T1,T3),(T2,T3,T1),(T3,T1,T2),(T3,T2,T1))的结果相同。
如果一个事务T1持有一把锁的情况下,另一个事务T2申请一把冲突的锁,那么,事务T2只有等到事务T1释放这把锁之后,才能加上这把锁。
根据数据库的基础理论,采用well-formed two-phase locking方式调度事务的话,是能够保证serializability的。
在了解到锁的基本概念之后,接下来讨论,如何基于锁来实现各种不同的隔离级别。
先来看所有的干扰现象:
脏写P0
W1(X)…W2(X)…A1
脏读P1
W1(X)…R2(X)…A1…R2(X)
不可重复读P2
R1(X)…W2(X)…C2…R1(X)
幻读P3
R1(P)…W2(P)…C2…R1(P)
写丢失P4
R1(X)…R2(X)…W2(X)…C2…W1(X)…C1
如果需要禁止P0,即禁止多个事务同时能修改一个数据项或谓词条件,则需要修改数据时,加写锁,并且是long duration的,此时,隔离级别满足Read Uncommitted。
如果需要禁止P1,即禁止读取到其他事务修改的中间状态的数据,在禁止P0的条件下,则需要,对读加锁,short duration的就能够满足条件,此时,隔离级别满足Read Committed。
如果需要禁止P4,即禁止事务读取并且修改某个数据项后,需要禁止其他事务再次修改,但如果只是读取的话,不影响,这里,需要一种特殊的锁,称为Cursor Lock,会对事务当前处理的行进行加锁,如果行记录被修改,那么锁会是long duration的,直到事务结束,如果,行未被修改,则锁会提前被释放,此时,隔离级别满足Curstor stability。
如果需要禁止P2,即要禁止到读到某个数据项后,该数据还可能被其他事务修改,因此,需要对读加锁,且一直加锁到事务结束,即long duration,此时,隔离级别满足Repeatable Read。
如果需要禁止P3,即要禁止读到某个谓词条件后,满足该谓词条件的数据还被其他事务修改,因此,需要对谓词条件加读锁,且是long duration的,此时,隔离级别满足SERIALIZABLE。
不同的加锁与事务隔离级别的对应关系如下:
表格中最后一项中,对于读写锁都是long duration的,即到事务结束才会释放锁,即事务过程中只有加锁阶段,没有解锁阶段,这种方式和普通的two-phase locking有什么区别呢?
普通的two-phase locking包含两个阶段:
普通的two-phase locking可能会如下问题:
假设有两个事务T1,T2,它们的时序如下
1 | T1 T2 |
由于事务T2读到的数据是事务T1修改的X,当事务T1回滚时,事务T2读到的数据就是脏数据,因此,需要对事务T2也进行回滚,如果存在T3也读到了T2修改的数据,那么T3也需要回滚,这样,会导致一系列的事务都需要回滚,称为Cascading Aborts。
而表格中的two-phase locking,只有加锁阶段,因此,不会存在上述问题。只有加锁阶段的two-phase locking,也称为strict two-phase locking。
由于two-phase locking采用的是加锁的方式,因此有可能会碰到经典的死锁问题,举个例子,如下:
假设有事务T1,T2,它们的加锁时序如下:
1 | T1 T2 |
按照如上的时序,事务T1和T2处于等待互相释放锁的状态,即死锁。死锁问题会导致事务无法继续进行有效的工作,因此,必须要解决,常见的解决方案有:
第一种方法,死锁检测并消除的方法是有一个单独的线程检测事务的锁等待图,如果图构成了一个环,那么,说明发生了死锁,此时,需要选择环中的一个事务进行回滚,并释放锁,使得其他事务能够继续运行下去。
第二种方法,锁等待一段时间阈值后,对事务进行回滚,并释放其所有锁,表明,每次发生死锁时,都会先回滚最早开始执行的事务,使得其他的事务能够继续运行下去。
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
一般的数据库教科书上都会介绍,事务有ACID四个特性,分别是atomicity, consistency, isolation和duriablity。本文主要讨论是事务的isolation特性,即隔离性。
谈到事务的隔离性,一般是指ANSI SQL标准下的四种隔离级别,即Read Uncommitted, Read Committed, Repeatable Read和Serialibility。但ANSI SQL的事务隔离级别的标准存在以下限制:
本文主要在分析ANSI SQL标准下的事务隔离级别之后,讨论其限制,以及扩展,全文的组织架构如下:
本文收录在papers项目,papers项目旨在学习和总结分布式系统相关的论文。
在数据库中,多个事务往往是并发执行的,事务之间可能会存在干扰,从而导致数据不正确的问题。为了保证事务之间执行不互相干扰,最简单的方案则是串行的执行一个个事务,但这会降低吞吐量。为此,ANSI SQL标准引入事务隔离级别,描述了并发事务的各种干扰级别,使得应用程序可以在吞吐量和正确性上做决策,不同的事务隔离级别保证不同程度的正确性,一般而言,事务隔离级别越低,吞吐量越高,正确性越低。
ANSI SQL的隔离级别主要是从解决应用程序出现的各种干扰现象中,而设计出来的,其隔离级别主要是为了解决以下三种现象:
首先,来看脏读P1的发生的时序:
事务T1写了数据X,事务T2读了数据X,事务T1回滚,此时,事务T2读取到的是脏数据
其次,来看不可重复读P2发生的时序:
事务T1读了数据X,事务T2写了数据X,事务T2提交,事务T1再次读数据X,两次读到的数据X不一样
最后,来看幻读P3发生的时序
事务T1读了满足X>=m且X<=n的数据,事务T2插入一条数据,满足条件X>=m且x<=n,事务T2提交,事务T1再次读满足X>=m且X<=n的数据,两次读到的数据不一样
用稍微形式化的语言描述上述现象发生的时序,假设W1(X)表示事务T1修改了数据项X,而R2(X),表示事务T2读了数据项X;W1(P)表示事务T1修改了满足谓词条件P的数据项,而R2(P),表示事务T2读了满足谓词条件P的数据项。C1表示事务T1提交,A1表示事务T1回滚。
脏读P1
W1(X)…R2(X)…A1…R2(X)
不可重复读P2
R1(X)…W2(X)…C2…R1(X)
幻读P3
R1(P)…W2(P)…C2…R1(P)
针对三种现象,ANSI SQL标准设定了四种事务隔离级别,如下:
整个事务个隔离级别,与杜绝的现象的对应关系如下图:
值得一提的是,在Serializable下,不可能发生P1,P2和P3,但并不表明,不发生P1,P2和P3就一定是在Serializable。真正的在Serializable是指事务并发执行下得到的结果,与各个事务串行执行下的某个结果相同。
如上文提到,ANSI SQL有如下限制:
对于第一点,没有提及写操作的隔离性,有如下现象
脏写P0
事务T1修改X,事务T2修改X,事务T1回滚,此时不知回滚到什么值
举个例子说明,假设事务T1修改X前,X=100,事务T1要把修改成10,事务T2要把X修改成20,如下:
1 | X=100 |
事务T1回滚时,如果选择回滚到X=20,那么如果事务T2再回滚时,无法回滚到最原先的100;如果回滚到X=100,那么事务T2提交时,就无法知道写入的是X=20,在这种场景下,是无解的。
因此,对于脏写现象一定是要杜绝的,否则,无法保证事务能够正确的回滚。所以,对于所有的ANSI SQL标准下的隔离级别,需要增强到都满足P0不发生的级别。
对于写操作,还存在写丢失问题,发生在如下场景
写丢失P4
事务T1读数据X,事务T2读数据X,事务T2修改数据X,事务T1修改数据X,最终写入的数据是脏数据
举个例子,假如原先X=100,事务T1对X加5,事务T2对X减1,预期的结果应该是100+5-1=104,而如果在写丢失的情况下,则如下
1 | T1 T2 |
如上场景,最终写入到数据库时,X=104,这属于数据不一致,是应该杜绝的。
写丢失在现象在ANSI SQL的Read Committed级别可能发生,但是Repeatable Read级别不可能发生。为此,引入Cursor Stability隔离级别,保证不会发生P4。但是,P4不会在Repeatable级别发生,因为P2禁止了事务T1读X后,事务T2写X的场景。因此,Cursor Stability的隔离级别处于Read Committed和Repeatable Read之间。
现有的工业界产品,如MySQL等,大多都会采用MVCC等多版本并发控制来达到某种隔离性,而在ANSI SQL标准中,是没有考虑多版本的,因此,有必要讨论多版本并发控制下的隔离级别与现有的ANSI SQL标准下的隔离级别的不同。
先简单的介绍下一种多版本并发控制的思路,即Basic Time Ordering,其流程如下:
可以看出,步骤4中保证了P4写丢失现象不会在Basic Time Ordering中发生,把Basic Time Ordering达到的隔离级别称为Snapshot级别。由于会读取Start-TimeStamp之前提交的记录,因此,Snapshot肯定是满足Read Committed。
接下来,要讨论的是Snapshot和Repeatable Read之间的关系:
Snapshot读取的是某个时间点前的快照,因此,也不会出现不可重复读的现象,所以,从这个角度来说Snapshot的隔离级别要大于Repeatable Read,但考虑以下场景:
R1(X)…R2(Y)…W1(Y)…W2(X)…C1…C2
这种场景在Repeatable Read级别是被禁止的,因为T2读了数据Y之后,T1修改Y并提交了,会导致不可重复读。而对于Snapshot是可能发生这种场景的,以X+Y要满足条件大于0为例,说明Snapshot下可能违反此约束,如下
1 | 两个事务开始之前X=1,Y=2,时间戳为10000 |
虽然事务T1和事务T2都认为满足约束,但是两个事务都执行完成后,约束不满足。
因此,在这个现象上,Snapshot的隔离级别要小于Repeatable Read。故,Snapshot的隔离级别既不大于Repeatable Read,也不小于Repeatable Read。
事务隔离级别是比较有意思的话题,现阶段也有一些技术来实现各种隔离级别,接下来的一些文章会讨论实现事务隔离级别的技术,以及工业界产品中使用的技术,如下:
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
本文收录在我的github中papers项目,papers项目旨在学习和总结分布式系统相关的论文。
数据库的发展通常是随着业务需求的变化,在2000年左右,随着互联网的兴起,有许多同时在线的用户,这对数据库领域带来了非常大的挑战,数据库通常会成为瓶颈,所以,此时业务针对数据库的需求,主要体现在可扩展上面。
这时期数据库的扩展性,往往采用如下两种方案:
垂直扩展中使用更好的硬件意味者成本高,并且更换硬件后,需要把数据从老的机器迁移到新的机器,中间可能需要停服务,因此往往采用水平扩展,例如,Google’s MySQL-based cluster。
采用中间件方式也有缺点,中间件一般要求轻量级,简单数据库操作可以搞定,但是,如果需要做分布式事务或者联表操作,会非常复杂,通常这些逻辑会放到应用层来做。
后续,NOSQL兴起,主要有几个原因:
NOSQL以Google’s BigTable 和 Amazon’s Dynamo为代表,开源版对应为HBase和Cassandra。
NOSQL往往是不保证强一致性的,而对于一些应用来讲(例如金融服务),是需要强一致性和事务的,因此,如果它们基于NOSQL系统来开发的话,应用层需要些大量的逻辑来处理一致性和事务相关的问题。此时,业务需求是拥有可扩展性的基础上,能够支持强一致性。
因此,这里有几条路:
使用更好的单个服务器的话,不满足业务需求的可扩展性。
使用中间件的话,会有如下问题,例如:
上面两条路都不能很好的满足应用的需求,因此,NewSQL出现了。
首先来看NEWSQL的定义:针对OLTP的读写,提供与NOSQL相同的可扩展性和性能,同时能支持满足ACID特性的事务。即保持NOSQL的高可扩展和高性能,并且保持关系模型。
NEWSQL的优点:
注意,此篇论文中的NEWSQL偏向于OLTP型数据库,和一些OLAP类型的数据库不同,OLAP数据库更偏向于复杂的只读查询,查询时间往往很长。
而NEWSQL数据库的特性如下,针对其读写事务:
分三大类:
采用新架构的NewSQL有如下特点:
优势:
缺点:
代表产品:Spanner,CockroachDB
中间件负责的事情如下:
往往在各个数据库节点,需要装代理与中间件沟通,负责如下事情:
优点:
缺点:
备注:有研究表明,以磁盘为主要存储的传统DBMS,很难有效地利用非常多的核,以及更大的内存容量。
代表产品: MariaDB MaxScale, ScaleArc
特点:
代表产品:
传统数据库都是以磁盘为存储中心的架构,读盘操作相对较慢,一般是内存中缓存页。
现在来讲,内存较便宜,容量大,能存储大量的数据。这些纯内存操作带来的好处是,读取和写入数据速度较快。
现有的大内存服务器,对数据库对内存的管理提出了新的要求,不再是像传统数据库那样,只是用来做页缓存,可以采用更高效地内存管理方式。
数据分区一般以某几列做hash或者range分区。
特点:
数据库通过Concurrency Control来提供ACID中的Atomicity和Isolation。
分布式场景下,一般采用类2PC的协议,根据事务是否需要中心节点,分为以下两类:
关于时钟同步,不同数据库也有不同的做法,Spanner和CroachDB在时钟同步上的不同选择:
1 | But what makes Spanner differ- ent is that it uses hardware devices (e.g., GPS, atomic clocks) for high-precision clock synchronization. The DBMS uses these clocks to assign timestamps to transactions to enforce consistent views of its multi-version database over wide-area networks. CockroachDB also purports to provide the same kind of consistency for transactions across data centers as Span- ner but without the use of atomic clocks. They instead rely on a hybrid clock protocol that combines loosely synchronized hardware clocks and logical counters [41]. |
现有实现Isolation的技术主要包括:
大部分的数据库还是在选择使用MVCC,例如CockroachDB;有些数据库使用2PL+MVCC,修改数据的时候,还是采用2PL,例如,InnoDB,Spanner
一般有两种实现方式:局部索引VS全局索引
局部索引:
全局索引:
两个需要考虑的点:
如何最小化宕机时间?
采用主备切换
如何优化新加机器恢复到同步的时间?
一般手段为做checkpoint
可扩展性是NewSQL的一个非常重要的特点,对于中间件的方式,其上需要存路由信息,其本身的可扩展性比较难以解决,个人认为,其不应该算入NewSQL。
NewSQL的技术挑战除了上述提到的之外,还有如何实现多租户架构及租户之间的隔离,负载均衡等等问题。
从整篇论文中描述的内容可以看出,NewSQL中并没有开拓性的理论技术的创新,更多的是架构的创新,以及把现有的技术如何更好地适用于当今的服务器,适用于当前的分布式架构,使得这些技术有机的结合起来,形成高效率的整体,实现NewSQL高可用,可扩展,强一致性等需求。
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
最近在做性能优化相关的事情,其中涉及到了BloomFilter,于是对BloomFilter总结了下,本文组织结构如下:
首先,简单来看下BloomFilter是做什么的?
A Bloom filter is a space-efficient probabilistic data structure, conceived by Burton Howard Bloom in 1970, that is used to test whether an element is a member of a set. False positive matches are possible, but false negatives are not, thus a Bloom filter has a 100% recall rate. In other words, a query returns either “possibly in set” or “definitely not in set”.
上述描述引自维基百科,特点总结为如下:
其次,为什么需要BloomFilter?
常用的数据结构,如hashmap,set,bit array都能用来测试一个元素是否存在于一个集合中,相对于这些数据结构,BloomFilter有什么方面的优势呢?
当然,BloomFilter也有它的劣势,如下:
最后,以一个例子具体描述使用BloomFilter的场景,以及在此场景下,BloomFilter的优势和劣势。
一组元素存在于磁盘中,数据量特别大,应用程序希望在元素不存在的时候尽量不读磁盘,此时,可以在内存中构建这些磁盘数据的BloomFilter,对于一次读数据的情况,分为以下几种情况:
如果使用hashmap或者set的数据结构,情况如下:
假设应用不读盘逻辑的开销为C1,走读盘逻辑的开销为C2,那么,BloomFilter和hashmap的开销为
1 | Cost(BloomFilter) = P1 * C1 + (P2 + P3) * C2 |
因此,BloomFilter相当于以增加P2 * (C2 - C1)
的时间开销,来获得相对于hashmap而言更少的空间开销。
既然P2是影响BloomFilter性能开销的主要因素,那么BloomFilter设计时如何降低概率P2(即false positive probability)呢?,接下来的BloomFilter的原理将回答这个问题。
BloomFilter通常采用bit array实现,假设其bit总数为m,初始化时m个bit都被置成0。
BloomFilter中插入一个元素,会使用k个hash函数,来计算出k个在bit array中的位置,然后,将bit array中这些位置的bit都置为1。
以一个例子,来说明添加的过程,这里,假设m=19,k=2,如下:
如上图,插入了两个元素,X和Y,X的两次hash取模后的值分别为4,9,因此,4和9位被置成1;Y的两次hash取模后的值分别为14和19,因此,14和19位被置成1。
BloomFilter中查找一个元素,会使用和插入过程中相同的k个hash函数,取模后,取出每个bit对应的值,如果所有bit都为1,则返回元素可能存在,否则,返回元素不存在。
为什么bit全部为1时,是表示元素可能存在呢?
还是以上图的例子说明,如果要查找的元素是X,k个hash函数计算后,取出的bit都是1,此时,X本身也是存在的;假如,要查找另一个元素Z,其hash计算出来的位置为9,14,此时,BloomFilter认为此元素存在,但是,Z实际上是不存在的,此现象称为false positive。
最后,BloomFilter中不允许有删除操作,因为删除后,可能会造成原来存在的元素返回不存在,这个是不允许的,还是以一个例子说明:
上图中,刚开始时,有元素X,Y和Z,其hash的bit如图中所示,当删除X后,会把bit 4和9置成0,这同时会造成查询Z时,报不存在的问题,这对于BloomFilter来讲是不能容忍的,因为它要么返回绝对不存在,要么返回可能存在。
放到之前的磁盘读数据的例子来讲,如果删除了元素X,导致应用读取Z时也会返回记录不存在,这是不符合预期的。
BloomFilter中不允许删除的机制会导致其中的无效元素可能会越来越多,即实际已经在磁盘删除中的元素,但在bloomfilter中还认为可能存在,这会造成越来越多的false positive,在实际使用中,一般会废弃原来的BloomFilter,重新构建一个新的BloomFilter。
在实际使用BloomFilter时,一般会关注false positive probability,因为这和额外开销相关。实际的使用中,期望能给定一个false positive probability和将要插入的元素数量,能计算出分配多少的存储空间较合适。
假设BloomFilter中元素总bit数量为m,插入的元素个数为n,hash函数的个数为k,false positive probability记做p,它们之间有如下关系(具体推导过程请参考维基百科):
如果需要最小化false positive probability,则k的取值如下
1 | k = m * ln2 / n; 公式一 |
而p的取值,和m,n又有如下关系
1 | m = - n * lnp / (ln2) ^ 2 公式二 |
把公式一代入公式二,得出给定n和p,k的取值应该为
1 | k = -lnp / ln2 |
最后,也同样可以计算出m。
基础的数据结构如下:
1 | template<typename T> |
其中bits_是用vector
整个BloomFilter包含三个操作:
根据BloomFilter原理一节中的方法进行计算,代码如下:
1 | template<typename T> |
这里开始实现的时候犯了个低级的错误,一开始用的是bits_.reserve
,导致BloomFilter的false positive probability非常高,原因是reserve方法只分配内存,并不进行初始化。
即设置每个hash函数计算出来的bit为1,代码如下
1 | template<typename T> |
即计算每个hash函数对应的bit的值,如果全为1,则返回存在;否则,返回不存在。
1 | template<typename T> |
下面进行了一组测试,设置期望的false positive probability为0.1,模拟key从10000增长到100000的场景,观察真实的false positive probability的情况:
1 | key_nums_=10000 expected false positive rate=0.1 real false positive rate=0.1252 |
由于实现的时候,会对k进行取整,根据取整后的结果(k=3),计算出来的理论值是0.1250,可以,看出实际测出来的值和理论值差别不大。
前面实现的版本中,多次调用了hash_func函数,这对于计算比较长的字符串的hash的开销是比较大的,为了模拟这种场景,插入1000w行的数据,使用perf top来抓取其性能数据,结果如下:
如上图,除了生成数据的函数外,占用CPU最高的就属于hash_func了,占用了13%的CPU。
分析之前的代码可以知道,insert和key_may_match时,都会多次调用hash_func,这个开销是比较大的。
leveldb和维基百科中都有提到,根据之前的研究,可以采用两次hash的方式来替代上述的多次的计算,基本的思路如下:
1 | template<typename T> |
即先用通常的hash函数计算一次,然后,使用移位操作计算一次,最后,k次计算的时候,不断累加两次的结果。
经过优化后,性能数据图如下:
和之前性能图对比发现,hash_func的CPU使用率已经减少到4%了。
对比完性能之后,我们还需要对比hash函数按照如此优化后,false positive probability的变化情况:
1 | before_opt |
优化后,最大的false positive probability增长了2%左右,这个可以增加k来弥补,因为,优化后的hash算法,在k增长时,带来的开销相对来讲不大。
备注,本节采用perf抓取性能数据图,命令如下
1 | sudo perf record -a --call-graph dwarf -p 9125 sleep 60 |
本文的代码在bloomfilter.cpp,使用文档在ReadMe。
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
Paxos算法由lamport大师提出,目标是解决分布式环境下数据一致性的问题。Paxos算法自发表以来以晦涩难懂著称,因此,其作者于2001年发表了一篇简化版的论文,Paxos Made Simple。虽然这篇论文比前面的充满公式证明的论文容易理解,但是,如果对于Paxos算法本身要解决的问题不够理解的话,还是会很难理解该算法。Paxos原理系列文章的目标是在充分讨论Paxos要解决的问题的前提下,深入地分析和理解Paxos的原理。本文是此系列文章第一篇,主要内容如下:
本文收录在我的github中papers项目,papers项目旨在学习和总结分布式系统相关的论文。
首先,来描述一下Paxos要解决的问题,分布式环境下数据一致性问题。
考虑下面的环境,如下图:
在分布式环境中,为了保证服务的高可用,需要对数据做多个副本,一般是日志的方式来实现,即图中的log sequence,当某台机器宕机后,其上的请求可以自动的转到其他的Server上,同时会新找一台机器(为了保证副本数量足够),自动地把其他活着机器的日志同步过去,然后逐步回放到State Machine中去。如果Server 1,2,3中的日志是一致的话,可以保证这些Server回放到State Machine中的数据是一致的。那么问题来了,如何保证日志的一致性呢?这正是Paxos算法解决的问题,即如图中的Consensus Module所示,它们之间需要交互,保证日志中内容是完全一致的。
进一步来看日志中的内容,如下:
一个Log Sequence一般由多个Log Item组成,每个Log Item会包含一个Command,用于记录对应的客户端请求的命令,如图中的Add,Mov,Jmp和Set等等,每个Server会根据日志的内容和顺序,一个个的把命令回放到State Machine中。Paxos算法的目标就是为了保证每个Server上的Log Sequence中的Log Item中的Command和相对顺序完全一致,这样,在任意一台机器宕机之后,能保证可以快速地将服务切换到另外一台具有完全相同数据的Server上,从而达到高可用。
整个Paxos算法是为了解决整个Log Sequence一致性的问题,一般也称为Multi Paxos。而本小节要讨论的Basic Paxos是为了确定一个不变量的取值,放到上面的Log Sequence一致性上来讲,即为了确定某一个Log Item中的Command的取值,保证多个Server一旦确认该Log Item的Command之后,其值就不会再变化。
以一个例子描述确认Log Item的取值问题,如下:
如上图所示,每个Server从客户端接受到的请求可能不一样,例如,图中的三个Server分别接收到Add,Mov和Jmp等三个不同的请求,而对于当对于当前的Log Item来讲,只能存储一个请求,而为了保证Log的一致性,又必须要Log Item中存储的Command是一致的,因此,三个Server需要协调,最终确定此Log Item存储哪一个请求,这个确定的过程就是一轮Basic Paxos过程。
最后,以比较正式的方式来定义此问题:
假设一组进程能提出(propose)value,分布式一致性算法能保证最终只有一个value被选择(chosen)。如果没有value被提出,那么就没有value被选择。如果一个value被选择了,那么这组进程能学习(learn)到被选择的value。
总体看来,一致性算法的需求如下:
根据上面的问题,算法中总共有三类角色,即proposers,acceptors和learners,实际的实现中,一个进程可能承担三种角色中的一个或多个。这些角色之间通过发送消息的方式来相互通信,并且是在非拜占庭场景下:
在了解了上述需求,问题定义及场景后,接下来一步步地推导出Basic Paxos的最终算法。
整个问题分为两块:
首先,来看一个最简单的方案,如下:
只有一个acceptor,这个acceptor只认第一个Proposer给它提出的value,例如,在上图中,如果Proposer1先把value提给acceptor,那么acceptor最终会选择该value,即Add。
此方案的优点是简单易懂,但当acceptor挂掉并无法恢复之后,被选择的value也跟着丢失了,不满足需求。
因此,接下来的方案中,只考虑多个acceptor的场景。
为了保证仅有一个value被选择,需要在多数派的Server接受该value时,才认为该value被选择。因为任意两个多数派的acceptor集合中,必然有一个acceptor是相同的。举个例子,如果不是必须多数派的话,可能出现的场景时,有前1/3的acceptor选择value1,中间1/3的acceptor选择value2,最后1/3的acceptor选择value3,这样就会导致不止一个value被选择,不符合要求。
因为消息是有可能丢失的,因此,当只有一个value被提出的时候,acceptor应该接受它,即
P1. acceptor必须要接受它接受到的第一个value
但这会导致如下问题:
假设Proposer 1,2,3分别提出Add,Mov和Jmp,且Proposeri{i=1,2,3}首先提给Accepti{i=1,2,3},这样会导致最终每个acceptor都接受(accept)了不同的值,最终没有value被选择。
P1和某个value只有被多数派的acceptor接受后的条件表明,每个acceptor需要能接受多个value,因此,需要通过某种方法来区分,这里采用假设每个proposer提出的value都被分配了一个id,id为自然数,每个(id,value)组合称为一个proposal(提案)。每个proposal的id都是不同的。此时,如果一个value被选择,会对应于一个或多个proposal被多数派的acceptor接受,且该proposal的value对应于被选择的value。
由于允许多个proposal被选择,因此,需要保证每个被acceptor接受的proposal的value都相同,故有如下推论
P2. 如果一个proposal(id1, v1)被选择,那么,每个id大于id1的,被接受的proposal的value都等于v
由于proposal被选择,至少需要一个acceptor接受,因此,可以由P2进一步地加强约束到
P2a. 如果一个proposal(id1, v1)被选择,那么,每个id大于id1的proposal(id,value),如果被任意一个acceptor接受的话,value=v1
但P2a会存在如下问题:
考虑以上场景,Proposal1的(10, Add)proposal被三个acceptor接受,但是,Proposer1和acceptor4之间网络不联通,导致acceptor4一致为接受任何的proposal。此时,有一个新的proposal2加入,并且能和acceptor4联通,并且,其提出的proposal为(11, Jmp),根据P1原则,acceptor4必须要接受第一个接收到的proposal,即(11, Jmp),导致其和P2a冲突。
因此,进一步增强P2a的约束为
P2b. 如果一个proposal(id1, v1)被选择,那么任意一个id大于id1的proposal的value等于v1
根据P2b,如果一个proposal(m,v)被选择,那么对于n>m的proposal的value也必须是v。假如当前最大的proposal的id为x-1,那么必定会存在一个多数派的acceptor组合C,使得每个acceptor接受的proposal都的id都属于[m,x-1],且都拥有值v,并且,每个id属于[m,x-1]的proposal,如果其被任意的acceptor接受,其value必为v。
继续对P2b加强约束,由于任意的多数的集合S,至少包含C中的一个acceptor,而每个这样的集合中,id最大的proposal的value肯定是已经选择的value,因为,P2b保证了在有proposal(m,v)被选择后,其后id大于m的proposal的value肯定是v,因此,所有acceptor中只可能处于id小于m的proposal的value不等于v,所有id大于或或等于m的proposal其value必定是v。
从而,我们可以得出,任意一个proposal(x,v),至少满足以下条件之一:
P2c. 存在多数派的acceptor集合S,对于任意的proposal(x,v),需要满足以下条件之一:
- S中的任意一个acceptor都没有接受过id小于x的proposal
- S中的acceptor接受了id处于[0…x-1]的proposal,其中,v是当中id最大的proposal的value
因此,对于一个新提出的proposal,其必须要先学习到已经被或者将要被accept的id最大的value。要预测将要被accept的proposal是很困难的,但是,我们可以在acceptor中加限制,即,如果acceptor已经接受过(n,v)了,那么任何的id小于n的proposal都不会被接受,这样就能保证当前获取到的最大的id是正确的,举个例子说明:
上述例子发生的场景如下:
而后,Proposal2达到acceptor3,如果acceptor3选择接受它,那么,会出现以下情况:
Proposal2覆盖了Acceptor3已经接受过的值,导致Add成为新的多数派而被选择,不符合要求。实际上,在Proposal3提出时,由于Proposal2并没有被接受,导致,Proposal3只能学习到(1,Jmp),从这个角度来讲,Proposal2是属于Proposal3提出后被确认的,因此,需要在acceptor加以限制,不再接受比其接受过的proposal id小的proposal。
由上面的讨论,对于一个proposer需要经历如下两个步骤:
第一步称为Prepare
第二步称为Accept
对于acceptor来讲,Prepare时,它都可以回应,但是,对于accept的时候,需要满足如下条件:
P1a. 一个acceptor在它没有回应一个比n大的prepare请求时,其可以接受id为n的proposal的value
值得注意的是,当一个acceptor已经回应了比n大的prepare请求时,就没必要回应小于或等于n的prepare请求了,因为后者肯定不会被accept了。因此,对于acceptor来讲,需要记录最大的prepare的proposal id,为了防止acceptor宕机后重启的情况,故最大的proposer的id需要被持久化存储。
用伪代码表达proposer的算法如下:
1 | Prepare() |
用为代码表达acceptor的算法如下:
1 | Prepare() |
最简单粗暴的方案是,每当一个acceptor接受了新的proposal的时候,就广播给所有的learner,假设acceptor的数量为m,learner的数量为n,那么需要O(m*n)通信的开销。
为了减少通信开销,可以选出一个learner,负责接收acceptor的消息,然后再由它通知给其他的learner,这时需要O(m+n)。这个方法的缺点是如果这个learner宕机了,整个系统就无法正常工作了。改进的方案是,选择一组learner,假设数量为c,负责接受acceptor的消息,这些leaner负责通知其他的leaner,该方案的通信开销为c*o(m+n),且可用性比较高,只要c个learner中没有全部宕机,系统就可以正常工作。
本文分析和讨论了Paxos算法要解决的问题,即分布式系统中数据一致性的问题。为了实现数据一致性,需要保证各个副本的日志序列的一致性,而日志序列是由一个个的日志项组成的,Basic Paxos算法的目标是为了解决单个日志项的一致性。直观的来看,日志序列的一致性可以用多轮的Basic Paxos来达到,但是,往往出于性能,算法稳定性等原因的考虑,需要对多轮的Basic Paxos做优化,这就是接下来要讨论的Multi Paxos算法,敬请期待。
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
本文为golang实现Raft第一篇,主要描述了如何使用golang实现选主,文中的代码框架来自于MIT 6.824课程,包括rpc框架及测试用例。
根据Raft论文,选主模块主要包括三大功能:
candidate状态下的选主功能需要关注两个方面:
首先,来讨论何时进入candidate状态,进行选主。
在一定时间内没有收到来自leader或者其他candidate的有效RPC时,将会触发选主。这里需要关注的是有效两个字,要么是leader发的有效的心跳信息,要么是candidate发的是有效的选主信息,即server本身确认这些信息是有效的后,才会重新更新超时时间,超时时间根据raft论文中推荐设置为[150ms,300ms],并且每次是随机生成的值。
其次,来讨论选主的逻辑。
server首先会进行选主的初始化操作,即server会增加其term,把状态改成candidate,然后选举自己为主,并把选主的RPC并行地发送给集群中其他的server,根据返回的RPC的情况的不同,做不同的处理:
针对情况一,该server被选为leader,当前仅当在大多数的server投票给该server时。当其被选为主时,会立马发送心跳消息给其他的server,来表明其已经是leader,防止发生新的选举。
针对情况二,其他的server被选为leader,它会收到leader发送的心跳信息,此时,该server应该转为follower,然后退出选举。
针对情况三,一段时间后,没有server被选为leader,这种情况发生在没有server获得了大多数的server的投票情况下,此时,应该发起新一轮的选举。
当某个server被选为leader后,需要广播心跳信息,表明其是leader,主要在以下两个场景触发:
leader广播心跳的逻辑为,如果广播的心跳信息得到了大多数的server的确认,那么更新leader自身的选举超时时间,防止发生重新选举。
主要包括对candidate发的选举RPC以及leader发来的心跳RPC的确认功能。
对于选举RPC,假设candidate c发送选举RPC到该follower,由于follower每个term只能选举一个server,因此,只有当一个follower没有选举其他server的时候,并且选举RPC中的candidate c的term大于或等于follower的term时,才会返回选举当前candidate c为主,否则,则返回拒绝选举当前candidate c为主。
对于leader的心跳RPC,如果leader的心跳的term大于或等于follower的term,则认可该leader的心跳,否则,不认可该leader的心跳。
备注:本节所讨论的选举功能仅限于raft论文5.2,还未考虑选举过程中日志相关的信息以及选主过程中出现宕机等场景,此部分功能将在日志复制功能实现中再描述。
根据上述功能,需要以下的RPC:
选举RPC包括的信息如下:
1 | // |
其中,Term表示当前candidate的term,而candidateId表明当前candidate的ID,全局唯一。其他两个参数将在日志复制中功能完成后再使用,暂时先不讨论。
选举RPC的回复包含的信息如下:
1 | type RequestVoteReply struct { |
其中Term表示确认的server的term,如果candidate的term小于它,将会更新其term;VoteGranted表明回复的follower是否给其投票。
心跳RPC包含的信息如下:
1 | type AppendEntriesArgs struct { |
其中Term为leader的term,LeaderId为当前leader的ID,全局唯一。
心跳RPC的回复信息包括:
1 | type AppendEntriesReply struct { |
包括回复的server的Term信息,以及是否认可该leader继续为主。
根据前面描述,主要的逻辑为
1 | func (rf *Raft) election() { |
上述代码中,首先等待选举超时,超时后,会进入真正的选举逻辑election_one_round
,其代码如下
1 | func (rf *Raft) becomeCandidate() { |
首先,进入candidate状态,增加其term,然后,选举自己。
1 | for i := 0; i < len(rf.peers); i++ { |
接着,向除自己外的server发送选举RPC,等待server的回复,当成功返回数目到多数派时(包含自己在内),则宣布自己称为leader,即becomeLeader
,如下
1 | func (rf *Raft) becomeLeader() { |
即,修改自身状态为leader。然后,给发送心跳的线程发送rf.heartbeat <-true
,通知心跳线程开始发心跳包。
最后,一轮结束之后,检测是否达到三个退出条件之一:
1 | if (timeout+last < milliseconds()) || (done >= len(rf.peers)/2 || rf.currentLeader > -1) { |
即,timeout+last < milliseconds()
达到超时时间;或者done >= len(rf.peers)/2
,server成为leader;或者rf.currentLeader > -1
,有其他server选为leader。
首先,来看触发心跳的逻辑
1 | func (rf *Raft) sendLeaderHeartBeat() { |
分为两个方面:
真正的广播心跳的逻辑如下:
1 | func (rf *Raft) sendAppendEntriesImpl() { |
先是向集群中所有的其他server广播心跳,分为两种结果:
包括对选举RPC的确认已经对心跳RPC的确认。
选举RPC的确认逻辑如下
1 | func (rf *Raft) RequestVote(args RequestVoteArgs, reply *RequestVoteReply) { |
如果当前server的term大于candidate的term,或者当前server已经选举过其他server为leader了,那么返回拒绝的RPC,否则,则返回成功的RPC,并置自身状态为follower。
心跳的RPC的逻辑如下
1 | func (rf *Raft) AppendEntries(args AppendEntriesArgs, reply *AppendEntriesReply) { |
如果follower的term大于leader的term,则返回拒绝的RPC,否则,返回成功的RPC。
本文中所有的代码都在Raft。
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
自己动手写分布式KV存储引擎系列文章的目标是记录基于LevelDB(RockDB)构建一个分布式KV存储引擎实现过程,此系列文章对应的源码在DSTORE。
本文主要分析了网络框架中客户端的实现原理,全文分为如下两部分
本系列的其他文章还包括:
在分布式系统中,一台服务器在与其他服务器交互的过程中,既扮演server端,也扮演client端,因此,网络框架中client端的实现也是至关重要的,一般地,网络框架中client端至少提供以下接口:
针对网络框架中的客户端需要的各种接口,本节讨论其功能实现。
先来看看阻塞方式的connect,一般其用法如下
1 | int ret = connect(fd, server_addr, server_len); |
阻塞的connect函数调用如下,其TCP状态转换如下图:
如上图所示,整个流程如下:
从上述调用流程可以看出,阻塞式的connect会等待收到服务端的确认包之后,才会返回,这中间的等待时间是一次网络包的往返时间,对于服务端程序来讲,阻塞在等待连接建立上是不能接受的,因此,必须采用非阻塞的connect。
非阻塞的connect的调用如下,其TCP状态转换如下图:
如上图所示,整个流程如下:
对于非阻塞的connect,没有了等待server端回确认包的过程,但是,网络框架需要处理的是,连接真正建立的时候需要通知应用程序来处理。
根据linux mannual文档,说明如下
EINPROGRESS
The socket is nonblocking and the connection cannot be completed immediately. It is possible to select(2) or poll(2) for completion by selecting the socket for
writing. After select(2) indicates writability, use getsockopt(2) to read the SO_ERROR option at level SOL_SOCKET to determine whether connect() completed success‐
fully (SO_ERROR is zero) or unsuccessfully (SO_ERROR is one of the usual error codes listed here, explaining the reason for the failure).
如上说明,非阻塞connect之后,如果返回值是EINPROGRESS,那么需要用select或者epoll监听可写事件,然后,使用getsockopt来获取是否有错误,如果没有错误,说明连接建立成功,否则,连接建立失败。当然,如果返回值是0,说明在非阻塞的connect返回时,连接已经建立成功,这时候,跟处理阻塞式的connect是一样的。
整个非阻塞connect的实现在tcp_client,其中非阻塞connect调用是connect中完成。
read的实现与server端的read一致,这里就不再赘述了。
write的实现与server端的不同,需要根据不同情况来做不同的处理:
自己动手写分布式KV存储引擎的前三篇描述了如何设计和实现网络框架,接下来的文章将会关注如何基于网络框架设计和实现RPC库,敬请期待。
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
自己动手写分布式KV存储引擎系列文章的目标是记录基于LevelDB(RockDB)构建一个分布式KV存储引擎实现过程,此系列文章对应的源码在DSTORE。
本文主要分析了网络框架中定时器的原理及实现,全文分为如下两部分
本系列的其他文章还包括:
本节主要分析了服务端程序对定时器的需求,定时器的算法选择。
在服务端编程中,很多地方会使用到定时器,例如:
这些功能都是使用频率非常高的功能,其性能的好坏与定时器本身的性能好坏密不可分,因此,实现一个高性能的定时器是非常有必要的。接下来分析影响定时器性能最重要的因素:定时器的算法选择。
在一个网络框架中,对定时器的操作主要包括:
以维持心跳功能为例,讨论定时器功能采用何种算法才能获得高性能。
一个典型的连接之间维持心跳的事件发生序列如下:
即从连接开始到连接关闭,整个发生的定时器事件为1 INSERT + K GET + K UPDATE + 1 DELETE。
因此,采用普通链表,上述事件整体时间复杂度为O(1) + K * O(N) + K * O(1) + O(1),即K * O(N)
因此,采用排序链表,上述事件整体时间复杂度为O(N) + K * O(N) + K * O(1) + O(1),即(K+1) * O(N)
因此,采用堆,上述事件整体时间复杂度为O(lgN) + K * O(1) + K * O(lgN) + O(lgN),即(K+2) * O(lgN)。
因此,采用排序树,上述整体时间复杂度为O(lgN) + K * O(lgN) + K * O(lgN) + O(lgn),即(2K+2) * O(lgN)。因为普通的排序树可能会导致不平衡,使得时间复杂度恶化到O(N),因此,上述分析假定采用的是平衡树的方法,对于平衡树,其更新等操作的对应时间复杂度的常量因子往往是大于堆的。
考虑上述算法的时间复杂度,可以看出采用堆的算法时间复杂度最小,因此,本文中的定时器采用堆来实现。
由于采用堆实现定时器,先来描述一下堆的实现原理。
堆分为大根堆和小根堆,由于本文中的定时器按照超时时间升序排列,所以,以小根堆为例描述堆的基本原理。
首先来看堆的定义,引用自维基百科-堆
n个元素序列{k1,k2…ki…kn},当且仅当满足下列关系时称之为堆:
(ki <= k2i,ki <= k2i+1)或者(ki >= k2i,ki >= k2i+1), (i = 1,2,3,4…n/2)
前面括号中描述的即小根堆,即把堆看成一棵二叉树,则小根堆保证父节点的值要小于或等于子节点的值。
堆有以下特性:
一般地,在堆上支持以下几种操作:
上述的操作都依赖于堆调整操作,分为向下调整和向上调整,调整的目的是为了在所调整的树路径上,维持堆的性质。
如上图所示,从节点值为8的节点开始调整,首先,找出8的子节点中,较小者,即3。然后,如果较小的子节点(3),小于父节点(8),那么,则将父子节点交换。最后,再从交换后的节点开始,直到到达叶子节点或者父节点比两个子节点都大。
如上图所示,从节点0开始调整,如果其父节点大于它,则互相交换,即途中的交换0和3;继续在交换后的位置开始,节点0和父节点1比较,父节点1大于子节点0,因此,继续交换,最终,得到调整后的堆。
堆支持的几种操作都能通过上述的向上调整和向下调整来实现。
因为堆是一棵完全树,所以,一般采用数组的方式来实现它。假设数组为array,其长度为n,节点下标为(0…n-1)
从最后一个非叶子节点开始采用向下调整的方法,保证其下的子树维持堆性质;然后不断地循环此过程,直到达到下标为0的节点。
1 | for (int i = n/2; i >= 0; i--) { |
把插入的元素放到数组最后,这样,它的插入可能会引起其所在父节点不满足堆性质,进行相应的调整,这和向上调整的过程是一致的,即
1 | array.push_back(element); |
更新堆元素的值,分为两种情况:
1 | array[update_index] = new_value; |
由堆的性质可以得出,堆顶元素是最小的,因此,只要返回数组的第一个元素。
1 | return array[0]; |
可以把该元素对应的值改成最小,然后,根据堆的特性,调整后其会到堆顶,然后,再将堆顶的元素和堆最末元素交换,重新调整堆。
1 | array[update_index] = -1; |
定时器支持的功能如下:
在实现定时器时,首先想到的是采用STL提供的接口,但查看文档后发现STL只支持从堆顶删除元素,不支持从堆的中间删除元素,因此,本文的定时器是在std::vector基础上,自己实现的堆。
首先,先把定时器放到数组最末,然后,采用向上调整的方法使得堆性质保持,伪代码如下
1 | void EventLoop::push_timer_heap(Event *e) |
把相应的定时器的超时时间改成负数,然后采用向上调整的方法,把该定时器调整到堆顶;然后,交换堆顶和堆尾元素,再采用向下调整的方法调整第一个元素到倒数第二个元素之间的序列,使其维持堆性质,最后,删除掉最后一个元素,伪代码如下
1 | void EventLoop::pop_timer_heap(void) |
从堆顶获取元素,比较超时值和当前时间的关系,分为两种情况:
伪代码如下
1 | void EventLoop::process_timeout_events(void) |
更新定时器的超时时间,分为两种情况:
为了能用定时器直接获得对应的堆数组的下标,每个定时器事件中都保存了其在堆数组的下标,避免了为了查询堆数组下标而需要遍历数组而带来的性能开销,伪代码如下
1 | void EventLoop::process_timeout_events(void) |
上述所有的操作都是基于二叉堆来实现的,从堆的操作来看,其在数组中操作的元素相隔较远(父子节点的下标都是2倍关系),因此,二叉堆对cache并不友好。在libev中采用四叉堆来缓解上述问题,据其代码中描述,四叉堆在5w+的定时器下,能获得5%左右的性能提升,原文如下
at the moment we allow libev the luxury of two heaps,
a small-code-size 2-heap one and a ~1.5kb larger 4-heap
which is more cache-efficient.
the difference is about 5% with 50000+ watchers.
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
本文收录在paper项目中,papers项目旨在学习和总结分布式系统相关的论文;同时本文也是DSTORE项目的必备知识,DSTORE的目标是自己动手实现一个分布式KV存储引擎。
本文为raft系列文章第三篇,本系列其他文章为
本文的组织结构如下
Raft的日志会随着处理客户端请求数量的增多而不断增大,在实际系统中,日志不可能会无限地增长,原因如下:
因此,需要定期地清理日志,Raft采用最简单的快照方法。对系统当前做快照时,会把当前状态持久化到存储中,然后到快照点的日志项都可以被删除。
Raft算法中每个server单独地做快照,即把当前状态机的状态写入到存储中(状态机中的状态都是已提交的log entry回放出来的)。除了状态机的状态外,Raft快照中还需要一些元数据信息,包括如下:
AppendEntries
RPC的一致性检查能通过,因为,在复制紧跟着快照后的log entry时,AppendEntries
RPC带上需要复制的log entry前一个log entry的(index, iterm),即快照的最后一个log entry的(index,term),因此,快照中需要记录最后一个log entry的(index,term)当server完成快照后,可以删除快照最后一个log entry及其之前所有的log entry,以及之前的快照。
虽然每个server是独立地做快照的,但是也有可能存在需要leader向follower发送整个快照的情况,例如,一个follower的日志处于leader的最近一次快照之前,恰好leader做完快照之后把其快照中的log entry都删除了,这时,leader就无法通过发送log entry来同步了,只能通过发送完整快照。
leader通过InstallSnapshot
RPC来完成发送快照的功能,follower收到此RPC后,根据不同情况会有不同的处理:
当follower中缺失快照中的日志时
当follower中拥有快照中所有的日志时
对于Raft快照,关于性能需要考虑的点有:
Raft的client会把所有的请求发到leader上执行,在client刚启动时,会随机选择集群中的一个server
Raft的目标使得client是linerizable的,即每个操作几乎是瞬间的,在其调用到返回结果的某个时间点,执行其执行一次。由于需要client的请求正好执行一次,这就需要client的配合,当leader挂掉之后,client需要重试其请求,因为有可能leader挂掉之前请求还没有成功执行。但是,也有可能leader挂掉之前,client的请求已经执行完成了,这时候就需要新leader能识别出该请求已经执行过,并返回之前执行的结果。可以通过为client的每个请求分配唯一的编号,当leader检测到请求没有执行过时,则执行它;如果执行过,则返回之前的结果。
只读的请求可以不写log就能执行,但是它有可能返回过期的数据,有如下场景:
Raft通过如下方法避免上述问题:
Raft的性能测试配置如下:
从上图的第一幅图,可以看出:
从第二幅图,可以看出:
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
本文收录在paper项目中,papers项目旨在学习和总结分布式系统相关的论文;同时本文也是DSTORE项目的必备知识,DSTORE的目标是自己动手实现一个分布式KV存储引擎。
本文为raft系列文章第二篇,本系列其他文章为
本文将继续讨论raft原理,包括raft的安全性和集群成员变更,全文组织结构如下
前面描述了raft是如何选主和复制日志的,但是没有讨论raft是如何保证所有server的状态机按照相同的顺序执行完全相同的指令的。本节将在server被选为主的限制进行补充,保证了任何term被选为leader都会包含前面所有提交过的log entry,具体地将会通过本节描述的一系列规则来阐述。
在一些一致性算法中,即使一台server没有包含所有之前已提交的log entry,也能被选为主,这些算法需要把leader上缺失的日志从其他的server拷贝到leader上,这种方法会导致额外的复杂度。相对而言,raft使用一种更简单的方法,即它保证所有已提交的log entry都会在当前选举的leader上,因此,在raft算法中,日志只会从leader流向follower。
为了实现上述目标,raft在选举中会保证,一个candidate只有得到大多数的server的选票之后,才能被选为主。得到大多数的选票表明,选举它的server中至少有一个server是拥有所有已经提交的log entry的,而leader的日志至少和follower的一样新,这样就保证了leader肯定有所有已提交的log entry。
从日志复制一节可以知道,在当前term,一个leader知道一个log entry在复制到大多数server后,其就可以被提交了。当一个leader在提交log entry之前宕机掉,后面选举出来的leader会复制该log entry,但是,一个leader不能立马对之前term的log entry是否复制到大多数server来判断其是否已被提交。
如上图的例子,图(c)就发生了一个log entry虽然已经复制到大多数的server,但是仍然有可能被覆盖掉的可能,如图(d),整个发生的时序如下:
因此,在raft中,不会通过日志复制的个数来提交之前term的log entry,只有当前term的log entry才会通过日志副本的个数来判断,例如,图e中,如果S1在挂掉前把log entry(4)复制到了大多数的server后,就能保证之前的log entry(2)被提交了,之后S5也就不可能被选为leader了。
本小节将证明已经被leader提交的log entry,在之后选举出的leader中也会存在。
以反证法来证明,假设Term T的leader T提交了一个log entry,但是此log entry没有在之后的某些term中,不妨设最小的Term U的leader U中不存在此log entry。证明如下:
AppendEntries
RPC会失败;follower崩溃掉后,会按如下处理
在raft中,election timeout的值需要满足如下条件:
1 | broadcastTime << electionTimeout << MTBF |
其中broadcastTimeout是server并行发送给其他server RPC并收到回复的时间;electionTimeout是选举超时时间;MTBF是一台server两次故障的间隔时间。
electionTimeout要大于broadcastTimeout的原因是,防止follower因为还没收到leader的心跳,而重新选主。
electionTimeout要小于MTBF的原因是,防止选举时,能正常工作的server没有达到大多数。
对于boradcastTimeout,一般在[0.5ms,20ms]之间,而MTBF一般非常大,至少是按照月为单位。因此,一般electionTimeout一般选择范围为[10ms,500ms]。因此,当leader挂掉后,能在较短时间内重新选主。
在集群server发生变化时,不能一次性的把所有的server配置信息从老的替换为新的,因为,每台server的替换进度是不一样的,可能会导致出现双主的情况,如下图:
如上图,Server 1和Server 2可能以Cold配置选出一个主,而Server 3,Server 4和Server 5可能以Cnew选出另外一个主,导致出现双主。
raft使用两阶段的过程来完成上述转换:
这个过程中,有几个问题需要考虑。
Raft为此新增了一个阶段,此阶段新的server不作为选举的server,但是会从leader接受日志,当新加的server追上leader时,才开始做配置变更。
在这种场景下,原来的主在提交了Cnew log entry(计算日志副本个数时,不包含自己)后,会变成follower状态。
移除的server不会受到新的leader的心跳,从而导致它们election timeout,然后重新开始选举,这会导致新的leader变成follower状态。Raft的解决方案是,当一台server接收到选举RPC时,如果此次接收到的时间跟leader发的心跳的时间间隔不超过最小的electionTimeout,则会拒绝掉此次选举。这个不会影响正常的选举过程,因为,每个server会在最小electionTimeout后发起选举,而可以避免老的server的干扰。
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
本文收录在paper项目中,papers项目旨在学习和总结分布式系统相关的论文;同时本文也是DSTORE项目的必备知识,DSTORE的目标是自己动手实现一个分布式KV存储引擎。
本文为raft系列文章第一篇,本系列其他文章为
本文介绍了分布式一致性算法raft的选主和日志复制原理,raft算法的主要目标是为了让分布式一致性算法更易理解和用于工程实践。
raft算法的主要特性为
本文的组织结构如下
Repicated State Machine一般分为三个部分:
一致性算法作用于Consensus Module,一般有以下特性:
Paxos算法存在的主要问题为
鉴于Paxos难以理解和实现,raft的首要目标是使其易于理解,为此raft采用了以下设计思想来达到此目标:
Raft通过选出一个leader来简化日志副本的管理,例如,日志项(log entry)只允许从leader流向follower。
基于leader的方法,Raft算法可以分解成三个子问题:
一个Raft集群通常有几台Raft Server组成,每个Server处于以下三种状态之一:
Raft可能的状态变化如下图:
Raft将时间分为多个term,term以连续的整数来标识,每个term以一次election开始,如果有server被选为leader,则该term的剩余时间该server都是leader。
有些term里,可能并没有选出leader,这时候会开启一个新term来继续选主,如上图中的t3。
每个server都维护着一个当前term(current term),有可能会存在某些server整个term都没参与的情况,这时候,在server通信的时候,会带上彼此的当前term信息,server会更新成它们之间的较大值。当leader或candidate发现它们的term属于老的值时,它们会转成follower状态。
Raft Server之间的通信通过RPC来进行,基础的raft算法只需要实现RequestVote
和AppendEntries
两个RPC。
Raft使用心跳来触发选主,当server启动时,状态是follower。当server从leader或者candidate接收到合法的RPC时,它会保持在follower状态。leader会发送周期性的心跳来表明自己是leader。
当一个follower在election timeout时间内没有接收到通信,那么它会开始选主。
选主的步骤如下:
candidate会在下述三种情况下退出
当server得到集群中大多数的server的选举后,它会成为leader。对于每个server来讲,只能选举一台server为leader,从而使得大多数原则能确保只有一个candidate会被选成leader。
当candidate成为leader后,会发送心跳信息告诉其他server,从而防止新的选举。
如果在等待选举期间,candidate接收到其他server要成为leader的RPC,分两种情况处理:
有可能,很多follower同时变成candidate,导致没有candidate能获得大多数的选举,从而导致无法选出主。当这个情况发生时,每个candidate会超时,然后重新发增加term,发起新一轮选举RPC。需要注意的是,如果没有特别处理,可能出导致无限地重复选主的情况。
Raft采用随机定时器的方法来避免上述情况,每个candidate选择一个时间间隔内的随机值,例如150-300ms,采用这种机制,一般只有一个server会进入candidate状态,然后获得大多数server的选举,最后成为主。每个candidate在收到leader的心跳信息后会重启定时器,从而避免在leader正常工作时,会发生选举的情况。
当选出leader后,它会开始接受客户端请求,每个请求会带有一个指令,可以被回放到状态机中。leader把指令追加成一个log entry,然后通过AppendEntries
RPC并行的发送给其他的server,当改entry被多数派server复制后,leader会把该entry回放到状态机中,然后把结果返回给客户端。
当follower宕机或者运行较慢时,leader会无限地重发AppendEntries
给这些follower,直到所有的follower都复制了该log entry。
log按照上图方式组织,每个log entry存储了指令和term信息,由leader指定。每个log entry有个数字索引(index)来表名其在log中的位置。
leader决定什么时候将一个log entry回放到状态机中是安全的,被回放的log entry称为committed,raft保证所有committed log entry会被持久化,并且最终会被回放到所有可工作的状态机中。
一个log在大多数的server已经复制它之后,则是committed(这个特指在leader的term里面的日志),在复制该log的同时,同时也会告诉已复制该log entry的follower,其之前的log entry也被提交了,follower则可以回放其之前的log entry。例如上图中的entry 7。leader会维护最大的committed的entry的index,当一个follower发现log entry已提交,则会将它回放到状态机中。
raft的log replication保证以下性质(Log Matching Property):
其中特性一通过以下保证:
特性二通过以下保证:
AppendEntries
会做log entry的一致性检查,当发送一个AppendEntries
RPC时,leader会带上需要复制的log entry前一个log entry的(index, iterm)
这样就能保证特性二得以满足。
在正常情况下,leader和follower会保持一致,一致性检查通常都会成功。但是,当leader崩溃后,可能会出现日志不一致的情况,通过一个例子来说明。
如上图所示,raft的leader强制以自己的日志来复制不一致的日志,通过以下方法:
上述方法是通过AppendEntries
的一致性检查实现的,如下:
AppendEntries
会成功,并且会把follower的所有之后不一致的日志删除掉优化
上述一次回退一个log entry的方法效率较低,在发生冲突时,可以让follower把冲突的term的第一个日志的index发回给leader,这样leader就可以一次过滤掉该term的所有log entry。
在正常情况下,log entry可以通过一轮RPC就能将日志复制到大多数的server,少数的慢follower不会影响性能。
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
之前写过一篇博文,描述了本人学习分布式系统的思路(链接)。自己动手写分布式KV存储引擎系列文章的目标是记录基于LevelDB(RockDB)构建一个分布式KV存储引擎实现过程,算是对之前学习思路的实践。初步设想,此系列文章会包含以下主题:
此系列文章对应的源码放在DSTORE下。
本文为此系列第一篇文章,主要是关于如何设计和实现一个基本的网络框架,全文的组织结构如下:
由于TCP相对于UDP来讲,可靠性高很多,保证包的按序达到,这对于高可靠的存储系统来讲是十分必要的,因此,本文的网络框架将基于TCP来实现。
由于目前Linux是服务端编程中主流的操作系统平台,因此,本文的网络框架将基于Linux平台,且为X86_64体系架构。
一般Reactor模型基于I/O多路复用来实现,Linux平台提供select,epoll等接口,而Proactor模型一般基于异步I/O来实现,目前Linux系统对这块支持不太好,因此,本文的网络框架将基于Reactor来实现。
两种常见的线程模型,一是IO线程和工作线程共用相同线程,二是IO线程和工作线程分开。
如上图,I/O线程和工作线程共用的线程模型中,实际上是没有专门的工作线程的,I/O线程不仅需要负责处理I/O,还需要真正地处理请求,计算结果。一般典型的处理流程为
这种线程模型的特点是
如上图,在I/O线程和工作线程独立的线程模型中,有专门的工作线程来处理请求,计算结果,I/O线程仅仅需要做读写数据相关的操作。在这种线程模型下,整个流程为
这种线程模型的特点是
对于存储系统,一般计算需求较小,因此采用第一种线程模型。
选定好在I/O线程中处理任务之后,又需要确定I/O线程具体是如何分工的,一般有三种方式
主要从两个角度考虑这几个I/O模型的选择
对于第一种模型,比较适合连接建立不频繁的场景,在CPU使用不高的情况下,单线程也可以做到打满网络带宽
对于第二种模型,比较适合连接建立不频繁的场景,可以通过增加I/O线程的数量,来提升I/O的吞吐量
对于第三种模型,比较适合连接建立频繁的场景,可以通过增加线程的数量,来提升连接建立的速度和I/O的吞吐量
对于存储系统调用者来讲,一般会使用连接池,因此,存储系统一般不会频繁的建立连接;并且一般存储系统对I/O吞吐量需要较高,因此,选择第一种和第二种模型。本文中暂时采用第一种模型,如果在第一种模型不能提供足够的I/O带宽的情况下,考虑采用第二种模型。
在描述DSTORE网络框架设计之前,先分析网络框架需要处理的事件
从上面的处理流程可以看出,对于Client和Server,它们需要关注的事情包括
对于网络框架层,需要关注的是
网络框架除了需要关注读写事件及读写数据外,还需要处理连接的建立和断开。
网络连接处理流程和网络处理请求流程不太一样,在于Client和Server的处理与网络请求处理的流程不太一致,其流程如下
其中步骤5中accept返回后,其后半步骤与步骤6是并发的,并没有严格的顺序。
从上述流程可以看出,对于网络连接的建立,Server端和Client端处理调用网络框架的API之外,几乎不需要额外的处理。
而对于网络框架来讲,在Client和Server端的处理流程是不同的,分别是
网络请求完成后,需要正确地关闭连接,其处理流程如下。
如上图,对于Client端,需要处理的主要是调用网络框架的close API;对于Server端,则需要处理其上维护的连接结构体等等。
对于网络框架,需要处理的是
通过上面的分析,可以总结出网络框架应该处理以下事件
备注:此文写作时,Client端的网络框架尚未实现。
本网络框架的目标是使得Server端和Client端编程时,只需要以下事件
Server端需要关注的事件
Client端需要关注的事件
其他的一律由网络框架部分来处理,网络框架的整体框架如下
网框框架整体包含两部分:
一个reactor模式如下图:
Reactor中组件包括Reactor,EventHandler,I/O multiplexing和Timer
本文写作时,DSTORE的网络框架还没有实现定时器相关的功能。
Connection Management主要需要处理如下
本部分主要描述Reactor和Connection Management部分的实现。
源码链接
TCPServer中维护了所有连接的hashmap,用来保存Client端和Server端所有建立的连接情况。
实现了连接管理中的四种功能:
其中读数据和写数据依赖于EventLoop中每个Event的读事件和写事件的触发
TCPListener是用来处理accept相关的事件的,包括服务端socket从创建到listen的全过程,以及accept调用的支持。TCPListener调用accept之后,会触发TCPServer中的on_connect事件。
Connection代表了Client与Server端的连接,每个连接上可能会收到客户端的多个请求,其使用链表来维护尚未处理的请求。
Message代表来自Client的一个完整的消息,Server根据消息中的指定的操作,来进行相应的处理。
一个简单的使用例子请参照simple_packet_test.cpp
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。
本文是读GFS论文的总结,收录在我的github中papers项目,papers项目旨在学习和总结分布式系统相关的论文。
全文主要分为以下几方面:
google对现有系统的运行状态以及应用系统进行总结,抽象出对文件系统的需求,主要分为以下几个方面。
本部分讨论gfs的总体架构,以及在此架构上需要考虑的一些问题。
GFS的整体架构如下图:
(图片来源:gfs论文)
GFS中有四类角色,分别是
在GFS chunkserver中,文件都是分成固定大小的chunk来存储的,每个chunk通过全局唯一的64位的chunk handle来标识,chunk handle在chunk创建的时候由GFS master分配。GFS chunkserver把文件存储在本地磁盘中,读或写的时候需要指定文件名和字节范围,然后定位到对应的chunk。为了保证数据的可靠性,一个chunk一般会在多台GFS chunkserver上存储,默认为3份,但用户也可以根据自己的需要修改这个值。
GFS master管理所有的元数据信息,包括namespaces,访问控制信息,文件到chunk的映射信息,以及chunk的地址信息(即chunk存放在哪台GFS chunkserver上)。
GFS client是GFS应用端使用的API接口,client和GFS master交互来获取元数据信息,但是所有和数据相关的信息都是直接和GFS chunkserver来交互的。
Application为使用gfs的应用,应用通过GFS client于gfs后端(GFS master和GFS chunkserver)打交道。
GFS架构中只有单个GFS master,这种架构的好处是设计和实现简单,例如,实现负载均衡时可以利用master上存储的全局的信息来做决策。但是,在这种架构下,要避免的一个问题是,应用读和写请求时,要弱化GFS master的参与度,防止它成为整个系统架构中的瓶颈。
从一个请求的流程来讨论上面的问题。首先,应用把文件名和偏移量信息传递给GFS client,GFS client转换成(文件名,chunk index)信息传递给GFS master,GFS master把(chunk handle, chunk位置信息)返回给客户端,客户端会把这个信息缓存起来,这样,下次再读这个chunk的时候,就不需要去GFS master拉取chunk位置信息了。
另一方面,GFS支持在一个请求中同时读取多个chunk的位置信息,这样更进一步的减少了GFS client和GFS master的交互次数,避免GFS master成为整个系统的瓶颈。
对于GFS来说,chunk size的默认大小是64MB,比一般文件系统的要大。
优点
缺点
对于热点问题,google给出的解决方案是应用层避免高频地同时读写同一个chunk。还提出了一个可能的解决方案是,GFS client找其他的GFS client来读数据。
64MB应该是google得出的一个比较好的权衡优缺点的经验值。
GFS master存储三种metadata,包括文件和chunk namespace,文件到chunk的映射以及chunk的位置信息。这些metadata都是存储在GFS master的内存中的。对于前两种metadata,还会通过记操作日志的方式持久化存储,操作日志会同步到包括GFS master在内的多台机器上。GFS master不持久化存储chunk的位置信息,每次GFS master重启或者有新的GFS chunkserver加入时,GFS master会要求对应GFS chunkserver把chunk的位置信息汇报给它。
使用内存存储metadata的好处是读取metadata速度快,方便GFS master做一些全局扫描metadata相关信息的操作,例如负载均衡等。
但是,以内存存储的的话,需要考虑的是GFS master的内存空间大小是不是整个系统能存储的chunk数量的瓶颈所在。在GFS实际使用过程中,这一般不会成为限制所在,因为GFS中一个64MBchunk的metadata大小不超过64B,并且,对于大部分chunk来讲都是使用的全部的空间的,只有文件的最后一个chunk会存储在部分空间没有使用,因此,GFS master的内存空间在实际上很少会成为限制系统容量的因素。即使真的是现有的存储文件的chunk数量超过了GFS master内存空间大小的限制,也可以通过加内存的方式,来获取内存存储设计带来的性能、可靠性等多种好处。
GFS master不持久化存储chunk位置信息的原因是,GFS chunkserver很容易出现宕机,重启等行为,这样GFS master在每次发生这些事件的时候,都要修改持久化存储里面的位置信息的数据。
operation log的作用
怎么存
operation log会存储在GFS master和多台远程机器上,只有当operation log在GFS master和多台远程机器都写入成功后,GFS master才会向GFS client返回成功。为了减少operation log在多台机器落盘对吞吐量的影响,可以将一批的operation log形成一个请求,然后写入到GFS master和其他远程机器上。
check point
当operation log达到一定大小时,GFS master会做checkpoint,相当于把内存的B-Tree格式的信息dump到磁盘中。当master需要重启时,可以读最近一次的checkpoint,然后replay它之后的operation log,加快恢复的时间。
做checkpoint的时候,GFS master会先切换到新的operation log,然后开新线程做checkpoint,所以,对新来的请求是基本是不会有影响的。
本部分讨论GFS的系统交互流程。
GFS master对后续的数据流程是不做控制的,所以,需要一个机制来保证,所有副本是按照同样的操作顺序写入对应的数据的。GFS采用lease方式来解决这个问题,GFS对一个chunk会选择一个GFS chunkserver,发放lease,称作primary,由primary chunkserver来控制写入的顺序。
Lease的过期时间默认是60s,可以通过心跳信息来续时间,如果一个primary chunkserver是正常状态的话,这个时间一般是无限续下去的。当primary chunkserver和GFS master心跳断了后,GFS master也可以方便的把其他chunk副本所在的chunkserver设置成primary。
(图片来源:gfs论文)
这里,写数据如果发生错误可能会产生不一致的情况,会在consistency model中讨论。
4.1中第三步的Data Flow采用的是pipe line方式,目标是为了充分利用每台机器的网络带宽。假设一台机器总共有三个副本S1-S3。整个的Data Flow为:
不断重复上述流程,直到所有的chunkserver都收到client的所有数据。
以上述方式来传送B字节数据到R个副本,并假设网络吞吐量为T,机器之间的时延为L,那么,整个数据的传输时间为B/T+RL。
Append操作流程和写差不多,主要区别在以下
这里需要讨论的是,如果append操作在部分副本失败的情况下,会发生什么?
例如,写操作要追加到S1-S3,但是,仅仅是S1,S2成功了,S3失败了,GFS client会重试操作,假如第二次成功了,那么S1,S2写了两次,S3写了一次,目前的理解是GFS会先把失败的记录进行padding对齐到primary的记录,然后再继续append。
Snapshot的整个流程如下:
当snapshot操作完成后,client写snapshot中涉及到的chunk C的流程如下:
(图片来源:gfs论文)
GFS中consistent、defined的定义如下:
下面分析表格中出现的几种情况。
GFS用version来标记一个chunkserver挂掉的期间,是否有client进行了write或者append操作。每进行一次write或者append,version会增加。
需要考虑的点是client会缓存chunk的位置信息,有可能其中某些chunkserver已经挂掉又起来了,这个时候chunkserver的数据可能是老的数据,读到的数据是会不一致的。读流程中,好像没有看到要带version信息来读的。这个论文中没看到避免的措施,目前还没有结果。
应用层需要采用的机制:用append而不是write,做checkpoint,writing self-validating和self-identifying records。具体地,如下:
GFS master的功能包括,namespace Management, Replica Placement,Chunk Creation,Re-replication and Rebalancing以及Garbage Collection。
每个master操作都需要获得一系列的锁。如果一个操作涉及到/d1/d2/…/dn/leaf,那么需要获得/d1,/d1/d2,/d1/d2/…/dn的读锁,然后,根据操作类型,获得/d1/d2/…/dn/leaf的读锁或者写锁,其中leaf可能是文件或者路径。
一个例子,当/home/user被快照到/save/user的时候,/home/user/foo的创建是被禁止的。
对于快照,需要获得/home和/save的读锁,/home/user和/save/user的写锁。对于创建操作,会获得/home,/home/user的读锁,然后/home/user/foo的写锁。其中,/home/user的锁产生冲突,/home/user/foo创建会被禁止。
这种加锁机制的好处是对于同一个目录下,可以并行的操作文件,例如,同一个目录下并行的创建文件。
GFS的Replica Placement的两个目标:最大化数据可靠性和可用性,最大化网络带宽的使用率。因此,把每个chunk的副本分散在不同的机架上,这样一方面,可以抵御机架级的故障,另一方面,可以把读写数据的带宽分配在机架级,重复利用多个机架的带宽。
GFS在创建chunk的时候,选择chunkserver时考虑的因素包括:
当一个chunk的副本数量少于预设定的数量时,需要做复制的操作,例如,chunkserver宕机,副本数据出错,磁盘损坏,或者设定的副本数量增加。
chunk的复制的优先级是按照下面的因素来确定的:
chunk复制的时候,选择新chunkserver要考虑的点:
周期性地检查副本分布情况,然后调整到更好的磁盘使用情况和负载均衡。GFS master对于新加入的chunkserver,逐渐地迁移副本到上面,防止新chunkserver带宽打满。
在GFS删除一个文件后,并不会马上就对文件物理删除,而是在后面的定期清理的过程中才真正的删除。
具体地,对于一个删除操作,GFS仅仅是写一条日志记录,然后把文件命名成一个对外部不可见的名称,这个名称会包含删除的时间戳。GFS master会定期的扫描,当这些文件存在超过3天后,这些文件会从namespace中删掉,并且内存的中metadata会被删除。
在对chunk namespace的定期扫描时,会扫描到这些文件已经被删除的chunk,然后会把metadata从磁盘中删除。
在与chunkserver的heartbeat的交互过程中,GFS master会把不在metadata中的chunk告诉chunkserver,然后chunkserver就可以删除这些chunk了。
采用这种方式删除的好处:
坏处:
当一台chunkserver挂掉的时候,有新的写入操作到chunk副本,会导致chunkserve的数据不是最新的。
当master分配lease到一个chunk时,它会更新chunk version number,然后其他的副本都会更新该值。这个操作是在返回给客户端之前完成的,如果有一个chunkserver当前是宕机的,那么它的version number就不会增加。当chunkserver重启后,会汇报它的chunk以及version number,对于version number落后的chunk,master就认为这个chunk的数据是落后的。
GFS master会把落后的chunk当垃圾来清理掉,并且不会把落后的chunkserver的位置信息传给client。
备注:
1. GFS master把落后的chunk当作垃圾清理,那么,是否是走re-replication的逻辑来生成新的副本呢?没有,是走立即复制的逻辑。
为了实现高可用性,GFS在通过两方面来解决,一是fast recovery,二是replication
master和chunkserver都被设计成都能在秒级别重启
每个chunk在多个机架上有副本,副本数量由用户来指定。当chunkserver不可用时,GFS master会自动的复制副本,保证副本数量和用户指定的一致。
master的operation log和checkpoint都会复制到多台机器上,要保证这些机器的写都成功了,才认为是成功。只有一台master在来做garbage collection等后台操作。当master挂掉后,它能在很多时间内重启;当master所在的机器挂掉后,监控会在其他具有operation log的机器上重启启动master。
新启动的master只提供读服务,因为可能在挂掉的一瞬间,有些日志记录到primary master上,而没有记录到secondary master上(这里GFS没有具体说同步的流程)。
每个chunkserver都会通过checksum来验证数据是否损坏的。
每个chunk被分成多个64KB的block,每个block有32位的checksum,checksum在内存中和磁盘的log中都有记录。
对于读请求,chunkserver会检查读操作所涉及block的所有checksum值是否正确,如果有一个block的checksum不对,那么会报错给client和master。client这时会从其他副本读数据,而master会clone一个新副本,当新副本clone好后,master会删除掉这个checksum出错的副本。
主要是通过log,包括重要事件的log(chunkserver上下线),RPC请求,RPC响应等。
本部分主要讨论大规模分布式系统一书上,列出的关于gfs的一些问题,具体如下。
优点
缺点
64MB应该是google得出的一个比较好的权衡优缺点的经验值。
主要是为了更有效地利用网络带宽。把数据流分开,可以更好地优化数据流的网络带宽使用。
如果不分开,需要讨论下。
padding出现场景:
重复记录出现场景:
lease是gfs master把控制写入顺序的权限下放给chunkserver的机制,以减少gfs master在读写流程中的参与度,防止其成为系统瓶颈。心跳是gfs master检测chunkserver是否可用的标志。
namespace、文件到chunk的映射以及chunk的位置信息
namespace采用的是B-Tree,对于名称采用前缀压缩的方法,节省空间;(文件名,chunk index)到chunk的映射,可以通过hashmap;chunk到chunk的位置信息,可以用multi_hashmap,因为是一对多的映射。
1GB/64MB = 1024 / 64 = 16。总共需要16 * 10000000 * 64 B = 10GB
主要是考虑CPU、内存、网络和I/O,但如何综合这些参数并计算还是得看具体的场景,每部分的权重随场景的不同而不同。
如何选择chunkserver
如何避免同时迁移
通过限制单个chunkserver的clone操作的个数,以及clone使用的带宽来限制,即从源chunkserver度数据的频率做控制。
因为是过一会,所以假设chunk re-replication还没有执行,那么在这期间,可能这台chunkserver上有些chunk的数据已经处于落后状态了,client读数据的时候或者chunkserver定期扫描的时候会把这些状态告诉给master,master告诉上线后的chunkserver从其他机器复制该chunk,然后master会把这个chunk当作是垃圾清理掉。
对于没有落后的chunk副本,可以直接用于使用。
Snapshot的整个流程如下:
当snapshot操作完成后,client写snapshot中涉及到的chunk C的流程如下:
chunkserver主要是存储64KB block的checksum信息,需要由chunk+offset,能够快速定位到checksum,可以用hashmap。
利用checksum机制,分读和写两种情况来讨论:
chunkserver重启后,会汇报chunk及其version number,master根据version number来判断是否过期。如果过期了,那么会做以下操作:
问题:如果chunkserver拷贝数据的过程过程中,之前拷贝的数据备份又发生了变化,然后分为两种情况讨论:
PS:
本博客更新会在第一时间推送到微信公众号,欢迎大家关注。