大型系统的演进(上)

为什么要学习系统设计?主要是希望当用户数量 (系统流量) 不断增加时,我们依然能稳定地提供高性能的服务。衡量一个系统在这方面的能力有很多方式,本文选择了两个最主要的观点做切入,分别是:

  1. 扩展性 (Scalability) :当向系统投入更多资源,比如增加一台服务器时,系统的整体性能应当按比例提升。
  2. 可用性 (Availability) :要求系统任何时候都能响应用户的请求,即希望系统宕机的时间尽可能短。

想要满足这两点,直觉上不就是加入更多服务器帮忙做计算和备份?这样说其实也没有错,但实务上会遇到非常多的问题要解决!本文会从一个非常简单的架构开始,想像当用户持续增加时,如何逐步地扩展系统的架构。重点在于不断思考每个阶段的系统设计,可能遇到的瓶颈、解决的方式和优缺点

假设我们要做一个购物网站好了,最基本的架构可能像这样:

大型系统的演进(上)
  1. 用户在浏览器中输入网址 “myShop.com”,浏览器首先会向域名系统发送请求查询该域名对应的 IP 地址,得到回复为 “123.45.67.1”,这就是网站服务器在网络中的实际位置。
  2. 浏览器再通过这个 IP 地址,将请求发到网站服务器。服务器会根据请求的内容,开始做相对应的处理。
  3. 通常网站服务器还需要连接后端数据库读取或写入相关数据,最后将处理结果转换成网页并返回给用户。

随着用户增多,我们发现网站服务器成为性能瓶颈,其计算能力已经无法应对日益增长的流量。用户感觉网站运行越来越慢,甚至在促销时段直接无法响应!怎么办呢?

垂直扩展

垂直扩展是一种非常简单、直觉的扩展方式,说白了就是硬件向上升级。既然服务器不够快,那直接帮它升级 CPU、加内存不就得了!

这是系统在初期需要扩展时常见的做法,效果其实也很不错。但等到硬件规格到达一定程度后,就必须付出大量的金钱才能再提升一点点性能。此外,硬件规格是有极限的,就算再有钱也没办法无止尽地升级。我们必须再想想其它的方式!

前后端与服务分离

让网站服务器一个人包揽所有业务,负担实在太沈重。要解决这个问题,我们可以将主要的计算 (业务逻辑),抽离到其它服务器,称为应用程序服务器。让原来的网站服务器专注于界面展示工作。

这就是现在很主流的 ‘前后端分离’。对于前端来说,它不需要知道后端的内部逻辑,而是单纯使用后端提供的 “服务”,然后根据服务执行的结果生成流畅友好的用户界面。

以会员注册这个动作为例,流程如下:

  1. (前端) 产生会员注册页面,用户在上面填好会员数据、按下注册按钮,前端便将这些数据送到后端,由后端的会员服务来处理。
  2. (后端) 会员服务开始执行各种计算,像是去数据库检查这个用户有没有注册过、加密用户的密码,以及将数据写入数据库等等。当全部动作结束后,通知前端动作完成。
  3. (前端) 知道后端已经帮用户完成注册后,前端便生成一个漂亮的画面欢迎用户的加入。

前后端分离的好处是:

  1. 分散计算负担
  2. 前后端选用的技术、开发团队易于拆分
  3. 后端的各项服务可以让不同的前端来使用 (网站、Android、IOS)

前后端分离后,后端的服务本身也可以视需求再切分,这就是服务分离。以我们的例子而言,后端服务器可以将会员和商品相关的业务逻辑再切分,让它们各自独立,甚至拥有自己的专属数据库!

现在的系统架构演变成:

大型系统的演进(上)

现在我们的系统已经能负担比先前大上非常多的用户流量,每个部分还可以再视需求做垂直扩展 (升级硬件)!

然而,随着用户持续增加,系统又开始慢慢变慢了。虽然后来又再切分了几个服务,但终究不可能无止尽的切分下去。而且随着服务的数量变多,系统的复杂度也变得更高。若服务切分的不好,监控、追踪上更是困难。

我们再次遇到了瓶颈。

水平扩展

目前为止,我们一直使用垂直扩展的方式去加强系统的计算能力。但一个人再怎么强大,力量也有限。既然如此,何不让更多人一起帮忙?也就是为负担较重的部分,加上更多的 “双胞胎” 服务器。这种方式称为水平扩展,也是在系统的扩展性上,一个非常强大的武器!

问题是,若我们加了一台一模一样的服务器进来,它也有它的 IP 地址,这时候用户的请求要分配到哪台服务器呢?

一种做法是直接在 DNS 中设置,让同一个网站可以轮流 (Round-robin) 对应到不同的 IP 地址。但还有更好的方式—使用专门用于分配流量的组件,称为负载均衡器。

负载均衡器 (Load Balancer)

顾名思义,这台机器可以将负载 (load),也就是流量,根据选定的策略,分配到指定的一群服务器上。

当浏览器向 DNS 询问网站服务器的 IP 地址时,得到的其实是负载均衡器的 IP 地址。就好像是这群服务器的代表一样,它负责在接到请求后,分派给后方的某一台服务器去做处理。

加入了负载均衡器与水平扩展的服务器后,系统架构如下:

大型系统的演进(上)

水平扩展有下列好处:

  1. 理论上可以 “无限” 扩展计算能力 只要不断加入更多的服务器即可。甚至是让系统实现**自动扩展 (Auto Scaling)**,在流量变大时自动增加新的服务器,流量变小时关闭。
  2. 滚动更新 (rolling update) 当服务器有新版本要发布的时候,可以先创建好新版本的服务器。完成后负载均衡器再将流量切换过去,然后关闭旧版本的服务器。整个过程不需要任何停机时间。
  3. 易于管理故障的服务器 负载均衡器会定期检查背后服务器的状态。若发现有服务器失联了,就可以先将它从流量的分派清单移除掉,然后再加入新的服务器。

最重要的是,以上这些事情用户完全都不会发现。对他们来说这就是一个全天候提供快速、稳定服务的系统!那么,故事到这里就结束了吗?

事实上,水平扩展 “并不容易”。最大的原因是,我们的服务器是有状态 (stateful) 的。以网站服务器为例,用户连接后,我们通常会建立一组会话 (session) 来记录这名用户的相关状态 (比如这名用户已经登录过、购物车内有哪些商品)。

会话传统上会保存在用户当下连接的服务器,这在实施水平扩展前没有问题。但现在我们有 “许多” 的服务器,若用户每次连接都被分派到不同的地方,他的状态就会四散。很可能会一直被要求重新登录,购物车的内容也不一致!

可以说,如何如何妥善处理多个服务器的状态管理是实现扩展性的一大挑战!

因此,我们接下来要思考 ‘有状态的服务器’ 如何处理水平扩展?以用户的 session 为例,有以下两种直觉的做法:

  1. 即时同步所有服务器的状态: 在系统规模小的时候可能没问题,但想像一下,若扩展到百台甚至更多的服务器,同步的成本会非常可观。
  2. 让相同的用户永远连接到固定服务器: 这种做法称为黏性会话 (sticky session),明显的缺点有两个。首先是每个用户的行为不同,若重度用户都刚好连接到少数几台服务器,负担就会开始不平均。再来则是若某台服务器挂掉了,原本分配到这台服务器的用户还是必须重新分配。

看来这两个方法都有明显的缺点。说到底,只要服务器有状态,我们就没办法轻易地实现水平扩展。那么,若我们将状态从服务器中抽离,让它变成无状态 (stateless) 呢?

  1. 状态交由用户 (浏览器) 自行保存: 将状态 “全部” 放在 HTTP Cookie 回传给用户。每次连接的时候,用户将保存的状态带给服务器。若有状态需要修改,由服务器处理完后再交还给用户保管。这种做法需要考虑 cookie 的容量不大,可能放不下太复杂的状态。此外,若记录的状态越来越多,传输的流量也会不断增长!
  2. 状态放到数据库,用户只保存一组 “ID”: 这是目前很常见的方式,状态被抽离到独立的数据库,用户只需要在 cookie 保存一组 “ID”。每次连接时将 ID 带给服务器,服务器就会根据 ID 去数据库取回用户状态。

这边我们采用第二种做法。以保存 session 来说,在数据库的选择上,像登录状态这种相对短暂,甚至可允许丢失的数据 (volatile data),我们可以放在缓存数据库,例如 Redis,特点是结构简单、速度快。

而像购物车内的商品这种需要持久化的数据 (persistent data),则可以考虑保存在 NoSQL 的数据库,例如 MongoDB,特点是数据结构弹性、易扩展。

将 session 状态从网站服务器抽离后,我们的系统架构如下:

大型系统的演进(上)

现在这些 “无状态” 的网站服务器 (前端) 可以轻易地水平扩展。至于应用程序服务器 (后端) 的部分,通常业务逻辑的状态本来就是放在数据库,所以它们的水平扩展也没有问题!

那么,故事到这里终于结束了吗?可惜…还是没有!我们好像只是把状态的扩展问题,从服务器转移到数据库而已。随着流量越来越大,扩展的服务器越来越多,大家终究还是向数据库存取数据。

那么,数据库本身的扩展呢

数据库的扩展性

必须先强调,数据库的规划、维护本身是一门非常专业的领域。较具规模的组织,甚至有数据库管理员负责相关任务。但万丈高楼平地起,我们还是可以从大方向去讨论数据库的扩展性,有哪些常用的观念与技巧!

  1. 数据库主从 (Replication) 与读写分离

类似水平扩展服务器的做法,我们为数据库加入更多的双胞胎,并将 “当下” 的数据复制过去。问题在于 “后续” 要如何修改数据?当然不可能一次连接到所有的数据库来做操作!

主从模式 (Master-slave) 是这个问题的一种常见做法,在一群数据库中选择 一个当作 master,剩下的作为 slave。数据的变动一律通过 。

Master 负责写入,slave 负责读取,这就是读写分离。

大型系统的演进(上)

这种设计特别适合读取的频率大于写入的系统。例如我们的购物网站,大多时候是在提供商品数据给用户浏览。所以只要加入更多专门用来读取数据的 slave,便可以大幅度地减轻原本数据库的负担!

2. 单点失效 (Single point of failure) 与故障转移 (Failover)

目前为止我们一直偏重在扩展性 (scalability) 的讨论上。是时候来分析一下另一个面向,也就是系统的可用性 (availability) 了!所谓的 “可用”,以用户的角度来说,就是系统的表现是 “正常” 的,可以顺利地响应用户的请求。

而我们开发者的任务,就是要找出什么情况下,系统的表现是 “异常” 的。最直接的判断方式就是把当前的架构图摊开来看,想像一个请求进来后,沿途会经过哪些组件?若其中有环节是只要它坏掉,整个系统就无法正常运作,那么这个部分就称为 “单点失效”,也就是整个系统架构中的弱点!

其实先前在讨论水平扩展时,就已经有提到这样的情况。本来我们的服务器是单点失效的 (只有一台,挂了就没了)。在引入水平扩展、通过负载均衡器自动管理故障的服务器后,已经大幅提升了这部分的可用性。

数据库也是类似的做法,只是若故障的是 master,就必须从现有的 slave 中,选出一个晋升 (promote) 为新的 master。整个自我修复的过程是自动的 (不需人力介入),称为**故障转移 (Failover)**。一个系统越能从各种错误状态中自我修复,就拥有越高的可用性。

  1. 数据库缓存

数据库的速度,可以说是影响整个系统性能最大的因素。尽管我们已经通过主从和读写分离,一定程度上扩展了数据库的性能。但和服务器的水平扩展不同,数据的同步是需要成本的。无论是主从或其它设计模式都有其限制和取舍。无论何时,都要把存取数据库当作一个高成本的行为!

那么,如何减少对数据库的存取呢?关键就是**缓存 (Cache)**,将曾经查询过的结果保存起来 (通常会再加上一个有效期限,过期后缓存结果就消失,数据对即时性越要求,有效期限就越短)。每当需要查询数据的时候,先找找看有没有先前的查询结果。若有找到就直接回传。找不到才需要连接到数据库。

加上缓存机制后,数据库的负担可以大幅降低,应用程序服务器处理请求的速度更快,进而让整个系统的性能显着的提升。

在导入了数据库主从、读写分离与缓存后,现在我们的系统架构如下:

大型系统的演进(上)

总结

本文围绕着系统设计中的两个主要观点,扩展性与可用性。从一个简单的架构开始,逐步去分析当流量不断增长时,系统要如何扩展,每个阶段可能遇到的瓶颈以及解决方式。

一开始我们简单使用垂直扩展来升级硬件性能,直到无法再通过硬件升级来应付流量。接着将前后端与服务分离,把系统切成前端的网页服务器与后端各种服务的应用程序服务器。

由于后端的服务不可能无止尽的切分,我们需要实施水平扩展,加入更多的服务器并将流量交由负载均衡器管理。水平扩展的困难点在于服务器本身拥有状态,所以我们必须将状态抽离至数据库

数据库的扩展,也可以说是整个系统的扩展性中最重要的部分。我们采用了数据库主从和读写分离的架构并以主从模式为例子,说明什么是单点失效与故障转移。最后则是为数据库加上缓存,降低存取的频率来提升性能。

在下篇中,我们将会继续探讨:静态资源的扩展性、非同步任务的处理、全文检索的性能,以及非常重要的安全性!

原文始发于微信公众号(程序猿技术充电站):大型系统的演进(上)

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/224741.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!