本篇主要内容为分布式系统
Go语言号称是互联网时代的C语言。现在的互联网系统已经不是以前的一个主机搞定一切的时代,互联网时代的后台服务由大量的分布式系统构成,任何单一后台服务器节点的故障并不会导致整个系统的停机。同时以阿里云、腾讯云为代表的云厂商崛起标志着云时代的到来,在云时代分布式编程将成为一个基本技能。而基于Go语言构建的Docker、K8s等系统推动了云时代的提前到来。
对于已经比较完善的分布式系统,我们会简单讲讲怎么通过使用它们来提高我们的工作效率。对于没有现成解决方案的系统,我们会按照自己的业务需求提出解决方案。
我们考虑两个时间尺度:进程间消息传递延迟和进程内事件间隔,如果前者相对后者不可忽略,则这组进程就是一个分布式系统。
理解这个定义,需要理解几个重要的概念(形式化的定义总是这样,摊手):进程(process)、消息(message)和事件(event)。
进程就是一个负责干活的劳工,其干的活可以分解为多个步骤,每个步骤就是一个事件,消息便是劳工交流的方式。
这里面涉及到了计算机系统中最重的几种资源:计算(computational),存储(memory),以及沟通他们的网络(network)。
总结下,我们可以从另一个角度来对分布式系统进行描述:
对外,分布式系统表现为一个整体,基于总体的存储和计算能力,提供特定功能。
对内,分布式系统表现为一组个体,基于网络消息进行通信,分工合作。
而分布式系统的设计目标是,最大化整体资源利用率的同时,处理局部错误、保持对外可用性。
在构建分布式系统时,在逻辑上要注意以下这些方面:
在组织分布式系统时,在物理上可以有以下几种类型:
优点:
高可用、高吞吐、高可扩展性
缺点:
最大的问题是复杂性。
单机存储引擎就是哈希表、B树等数据结构在机械磁盘、SSD等持久化介质上的实 现。单机存储系统是单机存储引擎的一种封装,对外提供文件、键值、表格或者关 系模型。单机存储系统的理论来源于关系数据库。数据库将一个或多个操作组成一 组,称作事务,事务必须满足原子性(Atomicity)、一致性(Consistency)、 隔离性(Isolation)以及持久性(Durability),简称为ACID特性。多个事务并 发执行时,数据库的并发控制管理器必须能够保证多个事务的执行结果不能破坏某种约定,如不能出现事务执行到一半的情况,不能读取到未提交的事务,等等。为 了保证持久性,对于数据库的每一个变化都要在磁盘上记录日志,当数据库系统突 然发生故障,重启后能够恢复到之前一致的状态。
大规模分布式存储系统的定义如下: “分布式存储系统是大量普通PC服务器通过Internet互联,对外作为一个整体提供 存储服务。”
分布式存储系统具有如下几个特性:
● 可扩展。分布式存储系统可以扩展到几百台甚至几千台的集群规模,而且,随着集 群规模的增长,系统整体性能表现为线性增长。
● 低成本。分布式存储系统的自动容错、自动负载均衡机制使其可以构建在普通PC机 之上。另外,线性扩展能力也使得增加、减少机器非常方便,可以实现自动运维。
● 高性能。无论是针对整个集群还是单台服务器,都要求分布式存储系统具备高性 能。
● 易用。分布式存储系统需要能够提供易用的对外接口,另外,也要求具备完善的监 控、运维工具,并能够方便地与其他系统集成,例如,从Hadoop云计算系统导入数 据。
分布式存储系统的挑战主要在于数据、状态信息的持久化,要求在自动迁移、自动容错、并发读写的过程中保证数据的一致性。
分布式存储涉及的技术主要来自两个 领域:分布式系统以及数据库,如下所示:
●数据分布:如何将数据分布到多台服务器才能够保证数据分布均匀?数据分布到多 台服务器后如何实现跨服务器读写操作?
●一致性:如何将数据的多个副本复制到多台服务器,即使在异常情况下,也能够保 证不同副本之间的数据一致性?
●容错:如何检测到服务器故障?如何自动将出现故障的服务器上的数据和服务迁移 到集群中其他服务器?
●负载均衡:新增服务器和集群正常运行过程中如何实现自动负载均衡?数据迁移的 过程中如何保证不影响已有服务?
●事务与并发控制:如何实现分布式事务?如何实现多版本并发控制?
●易用性:如何设计对外接口使得系统容易使用?如何设计监控系统并将系统的内部 状态以方便的形式暴露给运维人员?
●压缩/解压缩:如何根据数据的特点设计合理的压缩/解压缩算法?如何平衡压缩算 法节省的存储空间和消耗的CPU计算资源?
分布式存储系统挑战大,研发周期长,涉及的知识面广。一般来讲,工程师如果能 够深入理解分布式存储系统,理解其他互联网后台架构不会再有任何困难。
分布式存储面临的数据需求比较复杂,大致可以分为三类:
●非结构化数据:包括所有格式的办公文档、文本、图片、图像、音频和视频信息 等。
●结构化数据:一般存储在关系数据库中,可以用二维关系表结构来表示。结构化数 据的模式(Schema,包括属性、数据类型以及数据之间的联系)和内容是分开的, 数据的模式需要预先定义。
●半结构化数据:介于非结构化数据和结构化数据之间,HTML文档就属于半结构化 数据。它一般是自描述的,与结构化数据最大的区别在于,半结构化数据的模式结 构和内容混在一起,没有明显的区分,也不需要预先定义数据的模式结构。 不同的分布式存储系统适合处理不同类型的数据,
分布式存储系统分为四 类:分布式文件系统、分布式键值(Key-Value)系统、分布式表格系统和分布式 数据库。
互联网应用需要存储大量的图片、照片、视频等非结构化数据对象,这类数据以对 象的形式组织,对象之间没有关联,这样的数据一般称为Blob(Binary Large Object,二进制大对象)数据。
分布式文件系统用于存储Blob对象,典型的系统有Facebook Haystack以及 Taobao File System(TFS)。另外,分布式文件系统也常作为分布式表格系统以 及分布式数据库的底层存储,如谷歌的GFS(Google File System,存储大文件) 可以作为分布式表格系统Google Bigtable的底层存储,Amazon的EBS(Elastic Block Store,弹性块存储)系统可以作为分布式数据库(Amazon RDS)的底层存 储。
总体上看,分布式文件系统存储三种类型的数据:Blob对象、定长块以及大文件。 在系统实现层面,分布式文件系统内部按照数据块(chunk)来组织数据,每个数据 块的大小大致相同,每个数据块可以包含多个Blob对象或者定长块,一个大文件也 可以拆分为多个数据块,如图1-1所示。分布式文件系统将这些数据块分散到存储集 群,处理数据复制、一致性、负载均衡、容错等分布式系统难题,并将用户对Blob 对象、定长块以及大文件的操作映射为对底层数据块的操作。
分布式键值系统
分布式键值系统用于存储关系简单的半结构化数据,它只提供基于主键的 CRUD(Create/Read/Update/Delete)功能,即根据主键创建、读取、更新或者 删除一条键值记录。
典型的系统有Amazon Dynamo以及Taobao Tair。从数据结构的角度看,分布式键 值系统与传统的哈希表比较类似,不同的是,分布式键值系统支持将数据分布到集 群中的多个存储节点。分布式键值系统是分布式表格系统的一种简化实现,一般用作缓存,比如淘宝Tair以及Memcache。一致性哈希是分布式键值系统中常用的数据 分布技术,因其被Amazon DynamoDB系统使用而变得相当有名。
分布式表格系统
分布式表格系统用于存储关系较为复杂的半结构化数据,与分布式键值系统相比, 分布式表格系统不仅仅支持简单的CRUD操作,而且支持扫描某个主键范围。分布式 表格系统以表格为单位组织数据,每个表格包括很多行,通过主键标识一行,支持 根据主键的CRUD功能以及范围查找功能。
分布式表格系统借鉴了很多关系数据库的技术,例如支持某种程度上的事务,比如 单行事务,某个实体组(Entity Group,一个用户下的所有数据往往构成一个实体 组)下的多行事务。典型的系统包括Google Bigtable以及Megastore,Microsoft Azure Table Storage,Amazon DynamoDB等。与分布式数据库相比,分布式表格 系统主要支持针对单张表格的操作,不支持一些特别复杂的操作,比如多表关联, 多表联接,嵌套子查询;另外,在分布式表格系统中,同一个表格的多个数据行也 不要求包含相同类型的列,适合半结构化数据。分布式表格系统是一种很好的权 衡,这类系统可以做到超大规模,而且支持较多的功能,但实现往往比较复杂,而 且有一定的使用门槛。
分布式数据库
分布式数据库一般是从单机关系数据库扩展而来,用于存储结构化数据。分布式数 据库采用二维表格组织数据,提供SQL关系查询语言,支持多表关联,嵌套子查询等 复杂操作,并提供数据库事务以及并发控制。
典型的系统包括MySQL数据库分片(MySQL Sharding)集群,Amazon RDS以及Microsoft SQL Azure。分布式数据库支持的功能最为丰富,符合用户使用习惯, 但可扩展性往往受到限制。当然,这一点并不是绝对的。Google Spanner系统是一 个支持多数据中心的分布式数据库,它不仅支持丰富的关系数据库功能,还能扩展 到多个数据中心的成千上万台机器。除此之外,阿里巴巴OceanBase系统也是一个 支持自动扩展的分布式关系数据库。
关系数据库是目前为止最为成熟的存储技术,它的功能极其丰富,产生了商业的关 系数据库软件(例如Oracle,Microsoft SQL Server,IBM DB2,MySQL)以及上 层的工具及应用软件生态链。然而,关系数据库在可扩展性上面临着巨大的挑战。 传统关系数据库的事务以及二维关系模型很难高效地扩展到多个存储节点上,另 外,关系数据库对于要求高并发的应用在性能上优化空间较大。为了解决关系数据 库面临的可扩展性、高并发以及性能方面的问题,各种各样的非关系数据库风起云 涌,这类系统成为NoSQL系统,可以理解为“Not Only SQL”系统。NoSQL系统多 得让人眼花缭乱,每个系统都有自己的独到之处,适合解决某种特定的问题。这些 系统变化很快,本书不会尝试去探寻某种NoSQL系统的实现,而是从分布式存储技术 的角度探寻大规模存储系统背后的原理。
为了保证分布式存储系统的高可靠和高可用,数据在存储系统中一般会冗余存储。当某个冗余数据所在的节点出现故障时 (磁盘坏掉、静默错误、进程挂掉、机器宕机等),分布式存储系统能够返回其他冗余数据,从而实现自动容错。分布式存储系统的数据冗余一般有两种方式:副本冗余
和纠删冗余
,其中副本冗余是最常用的冗余方式,通常为 3 副本;纠删冗余是为了节省副本冗余的成本,多用于冷数据的存储
副本冗余是指同一份数据在存储系统中拥有相同的多个副本,一般为三副本。其中一个副本为主副本,其他两副本为从副本。复制协议也分为强同步复制和异步复制,二者的区别在于用户的写请求是否需要同步到各个副本才可以返回成功。如果主副本出现故障,分布式存储系统可以自动的切换到其他副本,实现自动容错。
异步复制
异步复制协议下,主副本写入成功后,不需要等待其他副本的 ack,直接修改本地写成功然后返回客户端成功即可,比如可以使用单独的线程去异步的复制其他副本。好处在于系统的可用性比较好,延迟低,不易出现毛刺;但是一致性比较差,如果主副本出现故障,可能会丢失最后一部分更新操作。
同步复制
同步复制通俗的讲是主副本需要等待其他副本写入成功,才可以返回客户端成功,往往通过 Log
的方式实现。同步复制经常需要通过一致性协议来保持正确性。比如 Ceph 通过基于 OpLog
的自己设计的一致性协议来同步复制达到强一致、Raft 则也是基于 Log
来在多个节点达成共识。
分布式一致性是分布式系统中最基本的问题,用来保证分布式系统的高可靠。业界也有很多分布式一致性复制协议:Paxos、Zab、Viewstamped Replication、Raft 等。Raft 相比于其他共识算法简化了协议中的状态以及交互,更加清晰也更加容易理解实现
过去, Paxos一直是分布式协议的标准,但是Paxos难于理解,更难以实现,Google的分布式锁系统Chubby作为Paxos实现曾经遭遇到很多坑。
来自Stanford的新的分布式协议研究称为Raft,它是一个为真实世界应用建立的协议,主要注重协议的落地性和可理解性。
在了解Raft之前,我们先了解Consensus一致性这个概念,它是指多个服务器在状态达成一致,但是在一个分布式系统中,因为各种意外可能,有的服务器可能会崩溃或变得不可靠,它就不能和其他服务器达成一致状态。这样就需要一种Consensus协议,一致性协议是为了确保容错性,也就是即使系统中有一两个服务器当机,也不会影响其处理过程。
为了以容错方式达成一致,我们不可能要求所有服务器100%都达成一致状态,只要超过半数的大多数服务器达成一致就可以了,假设有N台服务器,N/2 +1 就超过半数,代表大多数了。
Paxos和Raft都是为了实现Consensus一致性这个目标,这个过程如同选举一样,参选者需要说服大多数选民(服务器)投票给他,一旦选定后就跟随其操作。Paxos和Raft的区别在于选举的具体过程不同。
在Raft中,任何时候一个服务器可以扮演下面角色之一:
Raft 信息有 3 种 RPC:
Raft阶段分为两个,首先是选举过程,然后在选举出来的领导人带领进行正常操作,比如日志复制等。下面用图示展示这个过程:
以后通过心跳进行日志复制的通知。如果一旦这个Leader当机崩溃了,那么Follower中有一个成为候选者,发出邀票选举。Follower同意后,其成为Leader,继续承担日志复制等指导工作。
值得注意的是,整个选举过程是有一个时间限制的。Splite Vote是因为如果同时有两个候选人向大家邀票,这时通过类似加时赛来解决,两个候选者在一段timeout比如300ms互相不服气的等待以后,因为双方得到的票数是一样的,一半对一半,那么在300ms以后,再由这两个候选者发出邀票,这时同时的概率大大降低,那么首先发出邀票的的候选者得到了大多数同意,成为领导者Leader,而另外一个候选者后来发出邀票时,那些Follower选民已经投票给第一个候选者,不能再投票给它,它就成为落选者了,最后这个落选者也成为普通Follower一员了。
日志复制
下面以日志复制为例子说明Raft算法,假设Leader领导人已经选出,这时客户端发出增加一个日志的要求,比如日志是"sally"
Leader要求Followe遵从他的指令,都将这个新的日志内容追加到他们各自日志中
大多数follower服务器将日志写入磁盘文件后,确认追加成功,发出Commited Ok:
在下一个心跳heartbeat中,Leader会通知所有Follwer更新commited 项目。
对于每个新的日志记录,重复上述过程。
如果在这一过程中,发生了网络分区或者网络通信故障,使得Leader不能访问大多数Follwers了,那么Leader只能正常更新它能访问的那些Follower服务器,而大多数的服务器Follower因为没有了Leader,他们重新选举一个候选者作为Leader,然后这个Leader作为代表于外界打交道,如果外界要求其添加新的日志,这个新的Leader就按上述步骤通知大多数Followers,如果这时网络故障修复了,那么原先的Leader就变成Follower,在失联阶段这个老Leader的任何更新都不能算commit,都回滚,接受新的Leader的新的更新。
有时我们需要能够生成类似MySQL自增ID这样不断增大,同时又不会重复的id。以支持业务中的高并发场景。比较典型的,电商促销时,短时间内会有大量的订单涌入到系统,比如每秒10w+。明星出轨时,会有大量热情的粉丝发微博以表心意,同样会在短时间内产生大量的消息。
在插入数据库之前,我们需要给这些消息、订单先打上一个ID,然后再插入到我们的数据库。对这个id的要求是希望其中能带有一些时间信息,这样即使我们后端的系统对消息进行了分库分表,也能够以时间顺序对这些消息进行排序。
Twitter的snowflake算法是这种场景下的一个典型解法。
首先确定我们的数值是64位,int64类型,被划分为四部分,不含开头的第一个bit,因为这个bit是符号位。用41位来表示收到请求时的时间戳,单位为毫秒,然后五位来表示数据中心的id,然后再五位来表示机器的实例id,最后是12位的循环自增id(到达1111,1111,1111后会归0)。
这样的机制可以支持我们在同一台机器上,同一毫秒内产生2 ^ 12 = 4096
条消息。一秒共409.6万条消息。从值域上来讲完全够用了。
数据中心加上实例id共有10位,可以支持我们每数据中心部署32台机器,所有数据中心共1024台实例。
表示timestamp
的41位,可以支持我们使用69年。当然,我们的时间毫秒计数不会真的从1970年开始记,那样我们的系统跑到2039/9/7 23:47:35
就不能用了,所以这里的timestamp
只是相对于某个时间的增量,比如我们的系统上线是2018-08-01,那么我们可以把这个timestamp当作是从2018-08-01 00:00:00.000
的偏移量。
worker_id分配
timestamp
,datacenter_id
,worker_id
和sequence_id
这四个字段中,timestamp
和sequence_id
是由程序在运行期生成的。但datacenter_id
和worker_id
需要我们在部署阶段就能够获取得到,并且一旦程序启动之后,就是不可更改的了(想想,如果可以随意更改,可能被不慎修改,造成最终生成的id有冲突)。
一般不同数据中心的机器,会提供对应的获取数据中心id的API,所以datacenter_id
我们可以在部署阶段轻松地获取到。而worker_id是我们逻辑上给机器分配的一个id,这个要怎么办呢?比较简单的想法是由能够提供这种自增id功能的工具来支持,比如MySQL
我们在做系统时,很多时候是处理实时的任务,请求来了马上就处理,然后立刻给用户以反馈。但有时也会遇到非实时的任务,比如确定的时间点发布重要公告。或者需要在用户做了一件事情的X分钟/Y小时后,对其特定动作,比如通知、发券等等。
如果业务规模比较小,有时我们也可以通过数据库配合轮询来对这种任务进行简单处理,但上了规模的公司,自然会寻找更为普适的解决方案来解决这一类问题。
一般有两种思路来解决这个问题:
两种思路进而衍生出了一些不同的系统,但其本质是差不多的。都是需要实现一个定时器(timer)。在单机的场景下定时器其实并不少见,例如我们在和网络库打交道的时候经常会调用SetReadDeadline()
函数,就是在本地创建了一个定时器,在到达指定的时间后,我们会收到定时器的通知,告诉我们时间已到。这时候如果读取还没有完成的话,就可以认为发生了网络问题,从而中断读取。
定时器的实现在工业界已经是有解的问题了。常见的就是时间堆和时间轮。
最常见的时间堆一般用小顶堆实现,小顶堆其实就是一种特殊的二叉树,
小顶堆的好处是什么呢?对于定时器来说,如果堆顶元素比当前的时间还要大,那么说明堆内所有元素都比当前时间大。进而说明这个时刻我们还没有必要对时间堆进行任何处理。定时检查的时间复杂度是O(1)
。
当我们发现堆顶的元素小于当前时间时,那么说明可能已经有一批事件已经开始过期了,这时进行正常的弹出和堆调整操作就好。每一次堆调整的时间复杂度都是O(LgN)
。
Go自身的内置定时器就是用时间堆来实现的,不过并没有使用二叉堆,而是使用了扁平一些的四叉堆。
小顶堆的性质,父节点比其4个子节点都小,子节点之间没有特别的大小关系要求。
四叉堆中元素超时和堆调整与二叉堆没有什么本质区别。
用时间轮来实现定时器时,我们需要定义每一个格子的“刻度”,可以将时间轮想像成一个时钟,中心有秒针顺时针转动。每次转动到一个刻度时,我们就需要去查看该刻度挂载的任务列表是否有已经到期的任务。
从结构上来讲,时间轮和哈希表很相似,如果我们把哈希算法定义为:触发时间%时间轮元素大小。那么这就是一个简单的哈希表。在哈希冲突时,采用链表挂载哈希冲突的定时器。
除了这种单层时间轮,业界也有一些时间轮采用多层实现,这里就不再赘述了。
每一个实例每隔一小时,会去数据库里把下一个小时需要处理的定时任务捞出来,捞取的时候只要取那些task_id % shard_count = shard_id
的那些任务即可。
当这些定时任务被触发之后需要通知用户侧,有两种思路:
如果我们不考虑均衡的话,现在有n个服务节点,我们完成业务流程只需要从这n个中挑出其中的一个。有几种思路:
rand.Intn()%n
。当然了,实际场景我们不可能无脑轮询或者无脑随机,如果对下游请求失败了,我们还需要某种机制来进行重试,如果纯粹的随机算法,存在一定的可能性使你在下一次仍然随机到这次的问题节点。
洗牌算法
考虑到我们需要随机选取每次发送请求的节点,同时在遇到下游返回错误时换其它节点重试。所以我们设计一个大小和节点数组大小一致的索引数组,每次来新的请求,我们对索引数组做洗牌,然后取第一个元素作为选中的服务节点,如果请求失败,那么选择下一个节点重试,以此类推
洗牌算法 有两个隐藏的隐患:
rand.Intn()
返回的伪随机数序列是固定的。数据库系统本身要保证实时和强一致性,所以其功能设计上都是为了满足这种一致性需求。比如write ahead log的设计,基于B+树实现的索引和数据组织,以及基于MVCC实现的事务等等。
关系型数据库一般被用于实现OLTP系统,所谓OLTP,援引wikipedia:
在线交易处理(OLTP, Online transaction processing)是指透过信息系统、电脑网络及数据库,以线上交易的方式处理一般即时性的作业数据,和更早期传统数据库系统大量批量的作业方式并不相同。OLTP通常被运用于自动化的数据处理工作,如订单输入、金融业务…等反复性的日常性交易活动。和其相对的是属于决策分析层次的联机分析处理(OLAP)。
在互联网的业务场景中,也有一些实时性要求不高(可以接受多秒的延迟),但是查询复杂性却很高的场景。举个例子,在电商的WMS系统中,或者在大多数业务场景丰富的CRM或者客服系统中,可能需要提供几十个字段的随意组合查询功能。这种系统的数据维度天生众多,比如一个电商的WMS中对一件货物的描述,可能有下面这些字段:
仓库id,入库时间,库位分区id,储存货架id,入库操作员id,出库操作员id,库存数量,过期时间,SKU类型,产品品牌,产品分类,内件数量
除了上述信息,如果商品在仓库内有流转。可能还有有关联的流程 id,当前的流转状态等等。
想像一下,如果我们所经营的是一个大型电商,每天有千万级别的订单,那么在这个数据库中查询和建立合适的索引都是一件非常难的事情。
在CRM或客服类系统中,常常有根据关键字进行搜索的需求,大型互联网公司每天会接收数以万计的用户投诉。而考虑到事件溯源,用户的投诉至少要存2~3年。又是千万级甚至上亿的数据。根据关键字进行一次like查询,可能整个MySQL就直接挂掉了。
这时候我们就需要搜索引擎来救场了。