2.1 什么是大型网站
大型网站要支撑海量的数据和非常高并发的访问量, 那么它肯定是一个分布式系统。
2.2 大型网站的架构演进
2.2.2 从一个单机的交易网站说起
2.2.4.2 解决应用服务器变为集群后的Session问题
先来看一下什么是Session
用户使用网站的服务, 基本上需要 浏览器 与 Web服务器的多次交互。 HTTP协议本身是无状态的, 需要基于HTTP协议支持会话状态 (SessionState) 的机制。 而这样的机制应该可以使 Web服务器从多次 单独的HTTP请求中看到 “ 会话 ” ,也就是知道哪些请求是来自哪个会话的。 具体实现方式为:在会话开始时, 分配一个唯一 的会话标识(Sessionld), 通过Cookie把这个标识告诉浏览器 ,以后每次请求的时候, 浏览器都会带上这个会话标识来告诉Web服务器请求是属于哪个会话的。在Web服务器上 , 各个会话有独立的存储,保存不同会话的信息。 如果遇到禁用Cookie的清况, 一般的做法就是把这个会话标识放到URL的参数中 。
当我们的应用服务器从一台变到两台后, 我们 就 会遇到 Session的问题了。具体是指什么 问题呢?
我们来看 图2-9, 当一个带有会话标识的HTTP请求到了Web服务器后,需要在 HTTP请求的处理过程中找到对应的会话数据(Session)。 而问题就在于, 会话数据是需要保存在单机上的。
在图2-9所示的网站中,如果我第 次访问网站时请求落到了左边的服务器,那么我的Session就创建在左边的服务器上了,如果我们不做处理, 就不能保证接下来的请求每一次都落在同一边的服务器上。
我们看看这个问题的几种解决方案。
1 . Session Sticky
在单机的情况下, 会话保存在单机上, 请求也都是由这个机器处理, 所以不会有问题。Web 服务器变成多台以后, 如果保证同一个会话的请求都在同一个Web 服务器上处理, 那么对这个会话的个体来说, 与之前单机的情况是一样的。
如果要做到这样, 就需要负载均衡器能够根据每次请求的会话标识来进行请求转发, 如图2-10所示, 称为SessionSticky方式。
这个方案本身非常简单,对于Web服务器来说,该方案和单机的情况是一样的,只是我们在负载均衡器上做了“手脚”。这个方案可以让同样Session的请求每次都发送到同一个服务器端处理,非常利于针对Session进行服务器端本地的缓存。不过也带来了如下几个问题:
• 如果有一台Web服务器宕机或者重启,那么这台机器上的会话数据会丢失。如如果会话中有登录状态数据,那么用户就要重新登录了。
• 会话标识是应用层的信息,那么负载均衡器要将同一个会话的请求都保存到 一同个Web服务器上的话,就需要进行应用层(第7层)的解析,这个开销比第4层的交换要大。
• 负载均衡器变为了一个有状态的节点,要将会话保存到具体Web服务器的映射。和无状态的节点相比,内存消耗会更大,容灾方面会更麻烦。
这种方式我们称为SessionSticky。打个比方来说,如果说Web服务器是我们每 次吃饭的饭店,会话数据就是我们吃饭用的碗筷。要保证每次吃饭都用自己的碗筷 的话,我就把餐具存在某一家,并且每次都去这家店吃,是个不错的主意。
2. Session Replication
如果我们继续以去饭店吃饭类比,那么除了前面的方式之外, 如果我在每个店里都存放一套自己的餐具, 不就可以更加自由地选择饭店了吗? Session Replication就是这样的一种方式, 这一点从字面上也很容易看出来。
可以看到,在Session Replication方式中, 不再要求负载均衡器来保证同一个会话的多次请求必须到同一个Web服务器上了。而我们的Web服务器之间则增加了会话数据的同步。通过同步就保证了不同Web服务器之间的Session数据的一致。就如同每家饭店都有我的碗筷, 我就能随便选择去哪家吃饭了。
一般的应用容器都支持(包括了商业的和开源的) Session Replication方式, 与Session Sticky方案相比, Session Replication方式对负载均衡器没有那么多的要求。不过这个方案本身也有问题, 而且在一些场景下, 问题非常严重。我们来看一下这些问题。
• 同步Session数据造成了网络带宽的开销。 只要Session数据有变化, 就需要将数据同步到所有其他机器上, 机器数越多, 同步带来的网络带宽 开销就越大。
• 每台Web服务器都要保存所有的Session数据, 如果整个集群的Session数很多(很多人在同时访问网站)的话 , 每台机器用于保存Session数据的内容占用会很严重。
3. Session数据集中存储
同样是希望同一个会话的请求可以发到不同的Web 服务器上, 刚才的SessionReplication 是一种方案, 还有另一种方案就是把Session数据集中存储起来, 然后不同Web服务器从同样的地方来获取Session。
可以看到, 与Session Replication方案一样的部分是, 会话请求经过负载均衡器后,不会被固定在同样的 Web服务器上。 不同的地方是,Web服务器之间没有了Session数据复制,并且Session数据也不是保存在本机了,而是放在了另一个集中存储的地方。这样,不论是哪台Web服务器, 也不论修改的是哪个Session数据,最终的修改都发生在这个集中存储的地方,而Web服务器使用Session数据时,也是从这个集中存储Session数据的地方来读取。这样的方式保证了不同Web服务器上读到的的Session数据都是 样的。而存储Session数据的具体方式,可以使用数据库,也可以使用其他分布式存储系统。 这个方案解决了Session Replication方案中内存的问题, 而对于网络带宽, 这个方案也比Session Replication要好。 该方案存在的问题是什么呢?
• 读写Session数据引入了网络操作, 这相对于本机的数据读取来说, 问题就在 于存在时延和不稳定性, 不过我们的通信基本都是发生在内网, 问题不大。
• 如果集中存储Session的机器或者集群有问题, 就会影响我们的应用。
Session数比较多的时候,这个集中存储方案的优势是非常明显的。
4.Cookie Based
Cookie Based方案是要介绍的最后一个解决Session问题的方案。这个方案对于同一个会话的不同请求也是不限制具体处理机器的。和Session Replication以及Session数据集中管理的方案不同, 这个方案是通过Cookie来传递Session数据的。还是先看看下面的图2-13吧。
从图2-13可以看到, 我们的Session数据放在Cookie中, 然后在Web服务器上从Cookie中生成对应的Session数据。这就好比我每次都把自己的碗筷带在身上,这样我去哪家饭店吃饭就可以随意选择了。相对于前面的集中存储, 这个方案不会依赖外部的一个存储系统,也就不存在从外部系统获取、写入Session数据的网络时延、不稳定性了。不过,这个方案依然存在不足:
• Cookie长度的限制。我们知道Cookie是有长度限制的,而这也会限制Session数据的长度。
• 安全性。Session数据本来都是服务端数据,而这个方案是让这些服务端数据 到了外部网络及客户端,因此存在安全性上的问题。我们可以对写入Cookie的Session数据做加密,不过对于安全来说,物理上不能接触才是安全的。
• 带宽消耗。这里指的不是内部Web服务器之间的带宽消耗,而是我们数据中心的整体外部带宽的消耗。
• 性能影响。每次HTTP请求和响应都带有Session数据,对Web服务器来说, 在同样的处理情况下,响应的结果输出越少,支持的并发请求就会越多。
2.2.4.3 小结
前面介绍了Web服务器从单机到多机情况下的Session问题的解决方案。这个方案都是可用的方案,不过对于大型网站来说,SessionSticky和Session数据集中存储是比较好的方案, 而这两个方案又各有优劣, 需要在具体的场景中做出选择和权衡。
2.2.5 数据读压力变大, 读写分离吧
随着业务的发展, 我们的数据量和访问量都在增长。 对于大型网站来说, 有不少业务是读多写少的, 这个状况也会直接反应到数据库上。 那么对于这样的情况,我们可以考虑使用读写分离的方式。
从图2-14 中可以看到, 我们在前面的结构上增加了一个读库, 这个库不承担写的工作, 只提供读服务。
这个结构的变化会带来两个问题:
• 数据复制问题。
• 应用对于数据源的选择问题。
我们希望通过读库来分担主库上读的压力, 那么首先就需要解决数据怎么复制到读库的问题。 数据库系统 般都提供了数据复制的功能, 我们可以直接使用数据库系统的自身机制。 但对于数据复制, 我们还需要考虑数据复制时延问题, 以及复制过程中数据的源和目标之间的映射关系及过滤条件的支持问题。 数据复制延迟带来的就是短期的数据不 致。 例如我们修改了用户信息, 在这个信息还没有复制到读库时(因为延迟), 我们从读库上读出来的信息就不是最新的, 如果把这个信息给 进行修改的人看, 就会让他觉得没有修改成功。
不同的数据库系统有不同的支持。 例如MySQL支持Master (主库) +Slave (备库) 的结构, 提供了数据复制的机制。
对于应用来说, 增加一个读库对结构变化有一个影响, 即我们的应用需要根据不同情况来选择不同的数据库源。 写操作要走主库, 事务中的读也要走主库, 而我们也要考虑到备库数据相对于主库数据的延迟。 就是说即便是不在事务中的读, 考虑到备库的数据延迟, 不同业务下的选择也会有差异。
提到读写分离, 我们更多地是想到数据库层面。 事实上,广义的读写分离可以扩展到更多的场景。 我们看一下读写分离的特点。 简单来说就是在原有读写设施的基础上增加了读 “库”,更合适的说法应该是增加了读 “源”,因为它不一定是数据库, 而只是提供读服务的, 分担原来的读写库中读的压力。 因为我们增加的是一个读 源”,所以需要解决向这个 “源” 复制数据的问题。
2.2.5.2 搜索引擎其实是一个读库
以我们所举的交易网站为例, 商品存储在数据库中, 我们需要实现让用户查找商品的功能, 尤其是根据商品的标题来查找的功能。 对于这样的情况, 可能有读者会想到数据库中的like功能, 这确实是一种实现方式, 不过这种方式的代价也很大。还可以使用搜索引擎的倒排表方式, 它能够大大提升检索速度。 不论是通过数据库还是搜索引擎, 根据输入的内容找到符合条件的记录之后, 如何对记录进行排序都是很重要的。
搜索引擎要工作, 首要的一点是需要根据被搜索的数据来构建索引。 随着被搜索数据的变化, 索引也要进行改变。 这里所说的索引可以理解为前面例子中读库的数据, 只不过索引的是真实的数据而不是镜像关系。 而引入了搜索引擎之后, 我们的应用也需要知道什么数据应该走搜索, 什么数据应该走数据库。 构建搜索用的索引的过程就是一个数据复制的过程, 只不过不是简单复制对应的数据。 我们还是看一下引入搜索引擎之后的结构, 如图2-15所示。
可以看到,搜索集群(Search Cluster)的使用方式和读库的使用方式是一样的。只是构建索引的过程基本都是需要我们自己来实现的。可以从两个维度对于搜索系统构建索引的方式进行划分, 一种是按照全量/增量划分, 一种是按照实时/非实时划分。全星方式用于第一次建立索引(可能是新建, 也可能是重建), 而增量方式用于在全量的基础上持续更新索引。当然, 增量构建索引的挑战非常大, 一般会加入每日的全量作为补充。实时/非实时的划分方式则体现在索引更新的时间上了。我们当然更倾向于实时的方式, 之所以有非实时方式, 主要是考虑到对数据源头的保护。
总体来说, 搜索引擎的技术解决了站内搜索时某些场景下读的问题, 提供了更好的查询效率。并且我们看到的站内搜索的结构和使用读库是非常类似的, 我们可以把搜索引擎当成一个读库。
2.2.5.3 加速数据读取的利器——缓存
1 . 数据缓存
在大型网站中, 有许多地方都会用到缓存机制。 首先我们看一下网站内部的数 据缓存。 大型系统中的数据缓存主要用于分担数据库的读的压力, 从目的上看, 类似于我们前面提到的分库和搜索引擎。
如图 2-16 所示, 可以看到缓存系统和搜索引擎、 读库的定位是很类似的, 缓存系统一般是用来保存和查询键值 (Key-Value) 对的。 同样的, 业务系统需要了解什么数据会在缓存中。缓存中数据的填充方式会有不同,一般我们在缓存中放的是 “ 热” 数据而不是全部数据, 那么填充方式就是通过应用完成的, 即应用访问缓存, 如果 数据不存在, 则从数据库读出数据后放入缓存。 随着时间的推移, 当缓存容量不够需要清除数据时, 最近不被访问的数据就被清除了。 这种使用方式与前面分库的数 据复制以及搜索引擎的构建索引的方式是不同的。 不过还有一种做法与前面两种方式是类似的, 那就是在数据库的数据发生变化后, 主动把数据放入缓存系统中。 这 样的好处(相对于前面使用缓存的方式)是, 在数据变化时能够及时更新缓存中数 据, 不会造成读取失效。 这种方式一般会用于全数据缓存的情况。 使用这种方式有一个要求, 即根据数据库记录的变化去更新缓存的代码要能够理解业务逻辑。
2. 页面缓存
除了数据缓存外,我们还有页面缓存。 数据缓存可以加速应用在响应请求时的数据读取速度, 但是最终应用返回给用户的主要还是页面, 有些动态产生的页面或页面的 部分特别热,我们就可以对这些内容进行缓存。ESI就是针对这种情况的一个规范。 从具体的实现上来说,可以采用ESI或类似的思路来做,也可以吧页面缓存与页面渲染放在一起处理。
图2-17表示对于ESl的处理是在Apache中进行。Web服务器产生的请求响应结果返回给Apache, Apache中的模块会对响应结果做处理, 找到ESI标签, 然后去缓存中获取这些ESI标签对应的 内容, 如果这些内容不存 在(可能没有生成或者已经过期), 那么Apache中的模板会通过 Web服务器去渲染这些内容, 并且把结果放入缓存中, 用内容替换掉ESI标签, 返回给客户的浏览器。 这种方式的职责分工比较清楚。 不过Apache的ESI模块总是要对响应结果做分析,然后进行ESI相关的操作。如果在WEB服务器处理时就能够直接把ESI相关工作做完会是一个更好的选择。
图2-18就是改进后的样子。 Apache 中不再有ESI相关的功能了, 而是在 Web服务器中完成渲染及缓存相关的操作。 这样的做法更高效, 它把渲染与缓存的工作结合在了一起, 而且这种做法只是看起来没有前一种方式分工清晰而已。
对于使用缓存来加速数据读取的情况, 一个很关键的指标是缓存命中率, 因为如果缓存命中率比较低的话, 就意味着还有不少的读请求要回到数据库中。 此外,数据的分布与更新策略也需要结合具体的场景来考虑。 从分布上来说, 我们主要考虑的问题是需要有机制去避免局部的热点, 并且缓存服务器扩容或者缩容要尽量平滑(一致性 Hash 会是不错的选择)。 而在缓存的数据的更新上, 会有定时失效、 数据变更时失效和数据变更时更新的不同选择。
2.2.6 弥补关系型数据库的不足 , 引入分布式存储系统
在之前的介绍中用于数据存储的主要是数据库, 但是在有些场景下, 数据库并不是很合适。 我们平时使用的多为单机数据库, 并且提供了强的单机事务的支持。除了数据库之外, 还有其他用于存储的系统, 也就是我们常说的分布式存储系统。分布式存储系统在大型网站中有非常广泛的使用。
常见的分布式存储系统有分布式文件系统、分布式Key-Value系统和分布式数据库。 文件系统是大家所熟知的, 分布式文件系统就是在分布式环境中由多个节点组成 的功能与单机文件系统一样的文件系统, 它是弱格式的, 内容的格式需要使用者自己来组织 ; 而分布式 Key-Value系统相对分布式文件系统会更加格式化一些 ; 分布式数据库则是最格式化的方式了。 具体到分布式存储的实现, 我们将在后续的章节探讨。
分布式存储系统自身起到了存储的作用, 也就是提供数据的读写支持。 相对于 读写分离中的读 “源”,分布式存储系统更多的是直接代替了主库。 是否引入分布式系统则需要根据具体场景来选择。 分布式存储系统通过集群提供了一个高容量、 高并发访问数据冗余容灾的支持。 具体到前文提到的三个常见类, 则是通过分布式文件系统来解决小文件和大文件的存储问题,通过分布式Key-Value系统提供高性能的半结构化的支持, 通过分布式数据库提供一个支持大数据、 高并发的数据库系统。分布式存储系统可以帮助我们较好地解决大型网站中的大数据量和高并发访问的问 题。 引入分布式存储系统后, 我们的系统大概会是图2-19的样子。
2.2.7 读写分离后, 数据库又遇到瓶颈
通过读写分离以及在某些场景用分布式存储系统替换关系型数据库的方式, 能够降低主库的压力, 解决数据存储方面的问题。 不过随着业务的发展, 我们的主库也会遇到瓶颈。 我们的网站演进到现在, 交易 商品、 用户的数据还都在一个数据库中。 尽管采取了增加缓存、 读写分离的方式, 这个数据库的压力还是在继续增加,因此我们需要去解决这个问题,我们有数据垂直拆分和水平拆分两种选择。
2.2.7.1 专库专用, 数据垂直拆分
垂直拆分的意思是把数据库中不同的业务数据拆分到不同的数据库中。 结合现 在的例子, 就是把交易、 商品 用户的数据分开, 如图 2-20 所示。
这样的变化给我们带来的影响是什么呢?应用需要配置多个数据源, 这就增加了所需的配置, 不过带来的是每个数据库连接池的隔离。 不同业务的数据从原来的一个数据库中拆分到了多个数据库中, 那么就需要考虑如何处理原来单机中跨业务 的事务。 种办法是使用分布式事务, 其性能要明显低于之前的单机事务;而另种办法就是去掉事务或者不去追求强事务支持, 则原来在单库中可以使用的表关联的查询也就需要改变实现了。
对数据进行垂直拆分之后, 解决了把所有业务数据放在一个数据库中的压力问题。并且也可以根据不同业务的特点进行更多优化。
2.2.7.2 垂直拆分后的单机遇到瓶颈, 数据水平拆分
与数据垂直拆分对应的还有数据水平拆分。数据水平拆分就是把同一个表的数据拆到两个数据库中。产生数据水平拆分的原因是某个业务的数据表的数据量或者更新量达到了单个数据库的瓶颈, 这时就可以把这个表拆到两个或者多个数据库中。数据水平拆分与读写分离的区别是, 读写分离解决的是读压力大的问题, 对千数据量大或者更新量的清况并不起作用。数据水平拆分与数据垂直拆分的区别是, 垂直拆分是把不同的表拆到不同的数据库中, 而水平拆分是把同一个表拆到不同的数据库中。例如, 经过垂直拆分后, 用户表与交易表、商品表不在一个数据库中了, 如果数据量或者更新量太大, 我们可以进一步把用户表拆分到两个数据库中, 它们拥有结构一模一样的用户表, 而且每个库中的用户表都只涵盖了一部分的用户, 两个数据库的用户合在一起就相当于没有拆分之前的用户表。我们先来简单看一下引入数据水平拆分后的结构, 如图2-21 所示。
我们来分析一下水平拆分后给业务应用带来的影响。首先, 访问用户信息的应用系统需要解决SQL路由的问题, 因为现在用户信息分在了两个数据库中, 需要在进行数据库操作时了解需要操作的数据在哪里。此外, 主键的处理也会变得不同。原来依赖单个数据库的一些机制需要变化,例如原来使用Oracle的Sequence或者MySQL表上的自增字段的, 现在不能简单地继续使用了。并且在不同的数据库中也不能直接使用一些数据库的限制来保证主键不重复了。
最后, 由于同一个业务的数据被拆分到了不同的数据库中, 因此一些查询需要从两个数据库中取数据, 如果数据量太大而需要分页, 就会比较难处理了。
不过, 一旦我们能够完成数据的水平拆分, 我们将能够很好地应对数据量及写入量增长的情况。具体如何完成数据水平拆分, 在后面分布式数据访问层的章节中我们将进行更加详细的介绍。
2.2.8 数据库问题解决后, 应用面对的新挑战
2.2.8.1 拆分应用
前面所讲的读写分离、分布式存储、数据垂直拆分和数据水平拆分都是在解决数据方面的问题。下面我们来看看应用方面的变化。
之前解决了应用服务器从单机到多机的扩展, 应用就可以在一定范围内水平扩展了。随着业务的发展, 应用的功能会越来越多, 应用也会越来越大。我们需要考虑如何不让应用持续变大, 这就需要把应用拆开, 从一个应用变为两个甚至多个应用。 我们来看两种方式。
第一种方式,根据业务的特性把应用拆开。
2.2.8.2 走服务化的路
我们再来看一下服务化的做法。 图 2-24 是一个示意图。 从中可以看到我们把应用分为了三层, 处于最上端的是Web系统, 用于完成不同的业务功能;处于中间的是一些服务中心, 不同的服务中心提供不同的业务服务;处于下层的则是业务的数据库。 当然, 我们在这个图中省去了缓存等基础的系统, 因此可以说是服务化系统结构的一个简图。
图2-24与之前的图相比有几个很重要的变化。 首先,业务功能之间的访问不仅是单机内部的方法调用了,还引入了远程的服务调用。 其次,共享的代码不再是散落在不同的应用中了,这些实现被放在了各个服务中心。 第三,数据库的连接也发一些变化,我们把与数据库的交互工作放到了服务中心,让前端的Web应用更生了加注重与浏览器交互的工作,而不必过多关注业务逻辑的事情。 连接数据库的任务交给相应的业务服务中心了,这样可以降低数据库的连接数。 而服务中心不仅把一些可以共用的之前散落在各个业务的代码集中了起来,并且能够使这些代码得到更好的维护。 第四,通过服务化,无论是前端Web应用还是服务中心,都可以是由固定小团队来维护的系统,这样能够更好地保持稳定性, 并能更好地控制系统本身的 发展,况且稳定的服务中心日常发布的次数也远小于前端Web应用, 因此这个方式也减小了不稳定的风险。
2.2.9 初识消息中间件
最后我们来看 下消息中间件。 维基百科上对消息中间件的定义为"Message-oriented middleware (MOM) is software infrastructure focused on sending and receiving messages between distributed systems." 意思就是面向消息的系统(消息中间件)是在分布式系统中完成消息的发送和接收的基础软件。 图2-25更直观地表示了消息中间件。
消息中间件有两个常被提及的好处, 即异步和解耦。 从图2-25中可以看到, 应用A和应用B都和消息中间件打交道, 而这两个应用之间并不直接联系。这样就完成了解耦, 目的是希望收发消息的双方彼此不知道对方的存在, 也不受对方影响,所以将消息投递给接收者实际上都采用了异步的方式。在后面消息中间件的章节(第 6章)中会展开来讲相关内容。