服务端技术进阶 (四) 一文读懂分布式系统本质:高吞吐、高可用、可扩展
一、承载量是分布式系统存在的原因
当一个互联网业务获得大众欢迎的时候,最显著碰到的技术问题,就是服务器非常繁忙。当每天有 1000 万个用户访问你的网站时,无论你使用什么样的服务器硬件,都不可能只用一台机器就承载的了。因此,在互联网程序员解决服务器端问题的时候,必须要考虑如何使用多台服务器,为同一种互联网应用提供服务,这就是所谓“分布式系统”的来源。
然而,大量用户访问同一个互联网业务,所造成的问题并不简单。从表面上看,要能满足很多用户来自互联网的请求,最基本的需求就是所谓性能需求:用户反应网页打开很慢,或者网游中的动作很卡等等。而这些对于“服务速度”的要求,实际上包含的部分却是以下几个:高吞吐、高并发、低延迟和负载均衡。
高吞吐意味着系统可以同时承载大量的使用用户。这里关注的整个系统能同时服务的用户数。这个吞吐量肯定是不可能用单台服务器解决的,因此需要多台服务器协作,才能达到所需要的吞吐量。而在多台服务器的协作中,如何才能有效的利用这些服务器,不致于其中某一部分服务器成为瓶颈,从而影响整个系统的处理能力,这就是一个分布式系统在架构上需要仔细权衡的问题。
高并发是高吞吐的一个延伸需求。当系统在承载海量用户的时候,我们当然希望每个服务器都能尽其所能的工作,而不要出现无谓的消耗和等待的情况。然而,软件系统并不是简单的设计就能对同时处理多个任务,做到“尽量多”的处理。很多时候,程序会因为要选择处理哪个任务,而导致额外的消耗。这也是分布式系统解决的问题。
低延迟对于人数稀少的服务来说不算什么问题。然而,如果需要在大量用户访问的时候,也能很快的返回计算结果,这就要困难的多。因为除了大量用户访问可能造成请求在排队外,还有可能因为排队的长度太长,导致内存耗尽、带宽占满等空间性的问题。如果因为排队失败而采取重试的策略,则整个延迟会变的更高。所以分布式系统会采用很多请求分拣和分发的做法,尽快的让更多的服务器来处理用户的请求。但是,由于一个数量庞大的分布式系统,必然需要把用户的请求经过多次的分发,整个延迟可能会因为这些分发和转交的操作,变得更高,所以分布式系统除了分发请求外,还要尽量想办法减少分发的层次数,以便让请求能尽快的得到处理。
由于互联网业务的用户来自全世界,因此在物理空间上可能来自各种不同延迟的网络和线路,在时间上也可能来自不同的时区,所以要有效的应对这种用户来源的复杂性,就需要把多个服务器部署在不同的空间来提供服务。同时,也需要让同时发生的请求,有效的让多个不同服务器承载。所谓的负载均衡,就是分布式系统与生俱来需要完成的功课。
由于分布式系统几乎是解决互联网业务承载量问题的最基本方法,所以作为一个服务器端程序员,掌握分布式系统技术就变得异常重要。然而,分布式系统的问题并非是学会用几个框架和使用几个库就能轻易解决的。因为当一个程序在一个电脑上运行,变成在无数个电脑上同时协同运行,在开发、运维上都会带来很大的差别。
二、分布式系统提高承载量的基本手段
2.1 分层模型(路由、代理)
使用多台服务器来协同完成计算任务,最简单的思路就是让每个服务器都能完成全部的请求,然后把请求随机的发给任何一个服务器处理。最早期的互联网应用中,DNS 轮询就是这样的做法:
当用户输入一个域名试图访问某个网站,这个域名会被解释成多个 IP 地址中的一个,随后这个网站的访问请求就被发往对应 IP 的服务器了,这样多个服务器(多个 IP 地址)就能一起解决处理大量的用户请求。
然而,单纯的请求随机转发,并不能解决一切问题。比如很多互联网业务,都是需要用户登录的。在登录某一个服务器后,用户会发起多个请求,如果把这些请求随机的转发到不同的服务器上,那么用户登录的状态就会丢失,造成一些请求处理失败。简单的依靠一层服务转发是不够的,所以会增加一批服务器,这些服务器会根据用户的Cookie
,或者用户的登录凭据ticket
,来再次转发给后面具体处理业务的服务器。
除了登录的需求外,我们还发现,很多数据是需要数据库来处理的,而这些数据往往都只能集中到一个数据库中,否则在查询的时候就会丢失其他服务器上存放的数据结果。所以还会把数据库单独出来成为一批专用的服务器。
至此,我们就会发现,一个典型的三层结构出现了:接入、逻辑、存储。然而,这种三层结果并不就能包医百病。例如,当我们需要让用户在线互动(网游就是典型),那么分割在不同逻辑服务器上的在线状态数据,是无法知道对方的,这样我们就需要专门做一个类似互动服务器的专门系统,让用户登录的时候也同时记录一份数据到它那里,表明某个用户登录在某个服务器上,而所有的互动操作要先经过这个互动服务器才能正确地把消息转发到目标用户的服务器上。
又例如,当我们在使用网上论坛(BBS)系统的时候,我们发的文章不可能只写入一个数据库里,因为太多人的阅读请求会拖死这个数据库。我们常常会按论坛板块来写入不同的数据库,又或者是同时写入多个数据库。这样把文章数据分别存放到不同的服务器上,才能应对大量的操作请求。然而,用户在读取文章的时候,就需要有一个专门的程序,去查找具体文章在哪一个服务器上,这时候我们就要架设一个专门的代理层,把所有的文章请求先转交给它,由它按照我们预设的存储计划,去找对应的数据库获取数据。
根据上面的例子来看,分布式系统虽然具有三层典型的结构,但是实际上往往不止有三层,而是根据业务需求,会设计成多个层次的。为了把请求转交给正确的进程处理,我们设计很多专门用于转发请求的进程和服务器。这些进程我们常常以Proxy
或者Router
来命名,一个多层结构常常会具备各种各样的Proxy
进程。这些代理进程,很多时候都是通过TCP
来连接前后两端。然而,TCP
虽然简单,但是却存在发生故障后不易恢复的问题。而且TCP
的网络编程也是有点复杂的。——所以,人们设计出更好进程间通讯机制:消息队列。
尽管通过各种Proxy
或者Router
进程能组建出强大的分布式系统,但是其管理的复杂性也是非常高的。所以人们在分层模式的基础上想出了更多的方法,来让这种分层模式的程序变得更简单高效。
三、并发模型(多线程、异步)
当我们在编写服务器端程序时会明确地知道大部分的程序都是会处理同时到达的多个请求的。因此我们不能好像 Hello World 那么简单的从一个简单的输入计算出输出来。因为我们会同时获得很多个输入,需要返回很多个输出。在这些处理的过程中,往往我们还会碰到需要“等待”或“阻塞”的情况,比如我们的程序要等待数据库处理结果,等待向另外一个进程请求结果等等……如果我们把请求一个挨着一个的处理,那么这些空闲的等待时间将白白浪费,造成用户的响应延时增加,以及整体系统的吞吐量极度下降。
所以在如何同时处理多个请求的问题上,业界有 2 个典型的方案。一种是多线程,一种是异步。在早期的系统中,多线程或多进程是最常用的技术。这种技术的代码编写起来比较简单,因为每个线程中的代码都肯定是按先后顺序执行的。但是由于同时运行着多个线程,所以你无法保障多个线程之间的代码的先后顺序。这对于需要处理同一个数据的逻辑来说,是一个非常严重的问题,最简单的例子就是显示某个新闻的阅读量。两个++
操作同时运行,有可能结果只加了 1,而不是 2。所以多线程下,常常要加很多数据的锁,而这些锁又反过来可能导致线程的死锁。
因此异步回调模型在随后比多线程更加流行,除了多线程的死锁问题外,异步还能解决多线程下,线程反复切换导致不必要开销的问题:每个线程都需要一个独立的栈空间,在多线程并行运行的时候,这些栈的数据可能需要来回的拷贝,这额外消耗了 CPU。同时由于每个线程都需要占用栈空间,所以在大量线程存在的时候,内存的消耗也是巨大的。而异步回调模型则能很好的解决这些问题,不过异步回调更像是“手工版”的并行处理,需要开发者自己去实现如何“并行”的问题。
异步回调基于非阻塞的 I/O 操作(网络和文件),这样我们就不用在调用读写函数的时候“卡”在那一句函数调用,而是立刻返回“有无数据”的结果。而 Linux 的 epoll 技术,则利用底层内核的机制,让我们可以快速的“查找”到有数据可以读写的连接\文件。由于每个操作都是非阻塞的,所以我们的程序可以只用一个进程就可以处理大量并发的请求。因为只有一个进程,所以所有的数据处理,其顺序都是固定的,不可能出现多线程中,两个函数的语句交错执行的情况,因此也不需要各种“锁”。从这个角度看,异步非阻塞的技术大大简化了开发的过程。由于只有一个线程,也不需要有线程切换之类的开销,所以异步非阻塞成为很多对吞吐量、并发有较高要求的系统首选。
四、缓冲技术
在互联网服务中,大部分的用户交互都是需要立刻返回结果的,所以对于延迟有一定的要求。而类似网络游戏之类服务,延迟更是要求缩短到几十毫秒以内。所以为了降低延迟,缓冲是互联网服务中最常见的技术之一。
早期的 WEB 系统中,如果每个 HTTP 请求的处理,都去数据库(MySQL)读写一次,那么数据库很快就会因为连接数占满而停止响应。因为一般的数据库支持的连接数都只有几百,而 WEB 应用的并发请求轻松能到几千。这也是很多设计不良的网站在访问数量骤增就卡死的最直接原因。为了尽量减少对数据库的连接和访问,人们设计了很多缓冲系统——把从数据库中查询的结果存放到更快的设施上,如果没有相关联的修改,就直接从这里读。
最典型的 WEB 应用缓冲系统是 Memcache。由于 PHP 本身的线程结构是不带状态的。早期 PHP 本身甚至连操作“堆”内存的方法都没有,所以那些持久的状态就一定要存放到另外一个进程里。而 Memcache 就是一个简单可靠的存放临时状态的开源软件。很多 PHP 应用现在的处理逻辑都是先从数据库读取数据,然后写入 Memcache;当下次请求来的时候,先尝试从 Memcache 里面读取数据,这样就有可能大大减少对数据库的访问。
然而 Memcache 本身是一个独立的服务器进程,这个进程自身并不带特别的集群功能。也就是说这些 Memcache 进程并不能直接组建成一个统一的集群。如果一个 Memcache 不够用,我们就要手工用代码去分配,哪些数据应该去哪个 Memcache 进程。——这对于真正的大型分布式网站来说,管理一个这样的缓冲系统,是一个很繁琐的工作。
因此人们开始考虑设计一些更高效的缓冲系统:从性能上来说,Memcache 的每笔请求都要经过网络传输才能去拉取内存中的数据。这无疑是有一点浪费的,因为请求者本身的内存,也是可以存放数据的。——这就是促成了很多利用请求方内存的缓冲算法和技术,其中最简单的就是使用 LRU 算法,把数据放在一个哈希表结构的堆内存中。
而 Memcache 不具备集群功能也是一个痛点。于是很多人开始设计,如何让数据缓存分布到不同的机器上。最简单的思路是所谓读写分离,也就是缓存每次写,都写到多个缓冲进程上记录,而读则可以随机读任何一个进程。在业务数据有明显的读写不平衡差距上,效果是非常好的。
然而,并不是所有的业务都能简单的用读写分离来解决问题,比如一些在线互动的互联网业务,比如社区、游戏。这些业务的数据读写频率并没很大的差异,而且也要求很低的延迟。因此人们又再想办法,把本地内存和远端进程的内存缓存结合起来使用,让数据具备两级缓存。同时,一个数据不再同时存在所有的缓存进程上,而是按一定规律分布在多个进程上。——这种分布规律使用的算法,最流行的就是所谓“一致性哈希”。这种算法的好处是,当某一个进程失效挂掉,不需要把整个集群中所有的缓存数据重新修改一次位置。你可以想象一下,如果我们的数据缓存分布,是用简单的以数据 ID 对进程数取模,那么一旦进程数变化,每个数据存放的进程位置都可能变化,这对于服务器的故障容忍是不利的。
Orcale 公司旗下有一款叫 Coherence 的产品,是在缓存系统上设计比较好的。这个产品是一个商业产品,支持利用本地内存缓存和远程进程缓存协作。集群进程是完全自管理的,还支持在数据缓存所在进程进行用户自定义的计算(处理器功能),这就不仅仅是缓存了,还是一个分布式的计算系统。
五、存储技术(NoSQL)
相信 CAP 理论大家已经耳熟能详,然而在互联发展的早期,大家都还在使用 MySQL 的时候,如何让数据库存放更多的数据,承载更多的连接,很多团队都是绞尽脑汁。甚至于有很多业务主要的数据存储方式是文件,数据库反而变成是辅助的设施了。
附:CAP 原则又称 CAP 定理,指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。
然而,当 NoSQL 兴起,大家突然发现,其实很多互联网业务,其数据格式是如此简单,很多时候根本不需要关系型数据库那种复杂的表格。对于索引的要求往往也只是根据主索引搜索。而更复杂的全文搜索,本身数据库也做不到。所以现在相当多的高并发互联网业务首选 NoSQL 来做存储设施。最早的 NoSQL 数据库有 MongoDB 等,现在最流行的似乎就是 Redis 了。甚至有些团队把 Redis 也当成缓冲系统的一部分,实际上也是认可 Redis 的性能优势。
NoSQL 除了更快、承载量更大以外,更重要的特点是这种数据存储方式只能按照一条索引来检索和写入。这样的需求约束带来了分布上的好处,我们可以按照这条主索引来定义数据存放的进程(服务器)。这样一个数据库的数据就能很方便的存放在不同的服务器上。在分布式系统的必然趋势下,数据存储层终于也找到了分布的方法。
六、布式系统在可管理性上造成的问题
分布式系统并不是简单的把一堆服务器一起运行起来就能满足需求的。对比单机或少量服务器的集群,有一些特别需要解决的问题等待着我们。
6.1 硬件故障率
所谓分布式系统,肯定就不是只有一台服务器。假设一台服务器的故障时间是 1%,那么当你有 100 台服务器的时候,那就几乎总有一台是在故障状态的。虽然这个比方不一定很准确,但是,**当你的系统所涉及的硬件越来越多,硬件的故障也会从偶然事件变成一个必然事件。**一般我们在写功能代码的时候,是不会考虑到硬件故障的时候应该怎么办的。而如果在编写分布式系统的时候,就一定需要面对这个问题了。否则,很可能只有一台服务器出故障,整个数百台服务器的集群都工作不正常了。
除了服务器自己的内存、硬盘等故障,服务器之间的网络线路故障更加常见。而且这种故障还有可能是偶发的,或者是会自动恢复的。面对这种问题,如果只是简单的把“出现故障”的机器剔除出去,那还是不够的。因为网络可能过一会儿就又恢复了,而你的集群可能因为这一下的临时故障,丢失了过半的处理能力。
如何让分布式系统在各种可能随时出现故障的情况下,尽量的自动维护和维持对外服务,成为了编写程序时就要考虑的问题。由于要考虑到这种故障的情况,所以我们在设计架构的时候,也要有意识地预设一些冗余、自我维护的功能。这些都不是产品上的业务需求,完全就是技术上的功能需求。能否在这方面提出对的需求,然后正确的实现,是服务器端程序员最重要的职责之一。
6.2 资源利用率优化
分布式系统的集群包含了很多服务器,当这样一个集群的硬件承载能力到达极限的时候,最自然的想法就是增加更多的硬件。然而,一个软件系统并不是通过“增加”硬件就可以轻松提高承载性能的。因为软件在多个服务器上的工作需要复杂细致的协调工作。在对一个集群扩容的时候,我们往往会要停掉整个集群的服务,然后修改各种配置,最后才能重新启动一个加入了新的服务器的集群。
由于在每个服务器的内存里,都可能会有一些用户使用的数据,所以如果冒然在运行的时候,就试图修改集群中提供服务的配置,很可能会造成内存数据的丢失和错误。因此,运行时扩容在对无状态的服务是比较容易的,比如增加一些 Web 服务器。但如果是在有状态的服务上,比如网络游戏,几乎是不可能进行简单运行时扩容的。
分布式集群除了扩容,还有缩容的需求。当用户人数下降,服务器硬件资源出现空闲的时候,我们往往需要这些空闲的资源能利用起来,放到另外一些新的服务集群里去。缩容和集群中有故障需要容灾有一定类似之处,区别是缩容的时间点和目标是可预期的。
由于分布式集群中的扩容、缩容,以及希望尽量能在线操作,这导致了非常复杂的技术问题需要处理,比如集群中互相关联的配置如何正确高效的修改、如何对有状态的进程进行操作、如何在扩容缩容的过程中保证集群中节点之间通信的正常。作为服务器端程序员,会需要花费大量的精力来应对多个进程的集群状态变化造成的一系列问题。
6.3 软件服务内容更新
现在都流行用敏捷开发模式中的“迭代”,来表示一个服务不断的更新程序,满足新的需求,修正 BUG。如果我们仅仅管理一台服务器,那么更新这一台服务器上的程序是非常简单的:只要把软件包拷贝过去,然后修改下配置就好。但是如果你要对成百上千的服务器去做同样的操作,就不可能每台服务器登录上去处理。
服务器端的批量程序安装部署工具,是每个分布式系统开发者都需要的。然而,我们的安装工作除了拷贝二进制文件和配置文件外,还会有很多其他的操作需要完成。比如打开防火墙、建立共享内存文件、修改数据库表结构、改写一些数据文件等等……甚至有一些还要在服务器上安装新的软件。
如果我们在开发服务器端程序的时候,就考虑到软件更新、版本升级的问题,那么我们对于配置文件、命令行参数、系统变量的使用,就会预先做一定的规划,这能让安装部署的工具运行更快,可靠性更高。
除了安装部署的过程,还有一个重要的问题,就是不同版本间数据的问题。我们在升级版本的时候,旧版本程序生成的一些持久化数据一般都是旧的数据格式;而我们的升级版本中如果涉及到了修改的数据格式,比如数据表结构,那么这些旧格式的数据都要转换改写成新版本的数据格式才行。这导致了我们在设计数据结构的时候,就要考虑清楚这些表格的结构,是用最简单直接的表达方式来让将来的修改更简单;还是一早就预计到修改的范围,专门预设一些字段,或者使用其他形式存放数据。
除了持久化数据以外,如果存在客户端程序(如手机 APP),这些客户端程序的升级往往不能和服务器同步,如果升级的内容包含了通信协议的修改,这就造成了我们必须为不同的版本部署不同的服务器端系统的问题。为了避免同时维护多套服务器,我们在软件开发的时候,往往倾向于所谓“版本兼容”的协议定义方式。而怎样设计的协议才能有很好的兼容性,又是服务器端程序需要仔细考虑的问题。
6.4 数据统计和决策
一般来说,分布式系统的日志数据都是被集中到一起,然后统一进行统计的。然而,当集群的规模达到一定程度的时候,这些日志的数据量会变得非常恐怖。很多时候,统计一天的日志量要消耗计算机运行一天以上的时间。所以,日志统计这项工作,也变成一门非常专业的活动。
经典的分布式统计模型,有 Google 的 Map Reduce 模型。这种模型既有灵活性,也能利用大量服务器进行统计工作。但是缺点是易用性往往不够好,因为这些数据的统计和我们常见的 SQL 数据表统计有非常大的差异,所以我们最后还是常常把数据丢到 MySQL 里面去做更细层面的统计。
由于分布式系统日志数量的庞大,以及日志复杂程度的提高。我们变得必须要掌握类似 Map Reduce 技术,才能真正的对分布式系统进行数据统计。而且我们还需要想办法提高统计工作的工作效率。
版权声明: 本文为 InfoQ 作者【No Silver Bullet】的原创文章。
原文链接:【http://xie.infoq.cn/article/05e93b0f82146dd7989fe5488】。文章转载请联系作者。
评论