软件可扩展性是一个有趣的话题。实现软件可扩展性涉及很多因素,我们在本文将讨论一些与开发和运维方面相关的因素。
我们将深入讨论如何编写软件(软件开发)以及如何运行软件(运维)来实现软件可扩展性。对于初学者来说,成本和可扩展性通常是成比例的。
1. 什么是软件可扩展性
Full-scale blog 将软件可扩展性定义为:
软件可扩展性是工具或系统的一种属性,可以根据用户需求增加其容量和功能。可扩展的软件可以在适应变化、升级、检修和资源伸缩的同时保持稳定。
因此,如果软件可以弹性的处理负载,当请求量增加时分配更多资源(通常是动态分配) ,那我们可以说这个软件是可扩展的。现实中要实现这一点,我们还需要重视代码部分。
2. 开发视角的软件可扩展性
软件工程师应该知道如何编写可扩展的软件。你应该专注于优先编写能使软件易于扩展的代码。编写勉强可用的软件很容易,但编写易于测试、可维护、易扩展的代码却很难。以下是一些能让软件更易扩展的编程方法。
可扩展软件的高性能代码
软件应用程序编写时可以只要求能用就行,也可以考虑到软件的可扩展性、维护性和弹性。
选择合适算法
基于时间和空间复杂度,选择适合场景的合适算法可以产生很好的效果。
了解大 O 符号和流处理来对抗空间复杂度,对编写可扩展的软件非常有帮助。
例如,您可以采用二分搜索代替线性搜索来加快算法执行。在空间复杂度要求高的场景下,您可以基于少量内存的流式处理来实现小内存复制大文件。看看这个用来可视化展示排序算法的 6 分钟视频。
https://www.youtube.com/watch?v=kPRA0W1kECg
更好的内存管理
作为一位软件工程师,您应该关心像内存管理、垃圾收集这样的事情,不要让它们成为可扩展性的障碍。对于可扩展软件来说,预测资源争用的情况并为其编写代码也是至关重要的。
选择高性能函数库
还有其他有助于软件可扩展性的方法,包括对比和使用更多的高性能解决方案。例如,您可以使用 javascript 代替 lodash 来获得更快更高性能。
另外,不要仅仅因为某个库或软件包很流行就使用它,还要检查性能和软件可扩展性的影响。
例如,您可以使用 Day.js 代替 Moment.js 来执行简单的日期操作。需要的话,还可以使用原生方法来使软件更具可扩展性。
异步处理
想象一下,当客户已经成功下单需要发送一封订单确认邮件,您会怎么做?我总是会建议大家异步地执行它,因为它是流程中非关键的部分。
使用队列和消费者
您可以轻松地设置队列和消费者,来完成下单后电子邮件的发送任务。即便邮件发送晚了 1 分钟也没问题。
如果您的订单量很大,可以通过扩展消费者数量来降低延迟。任何非关键或非阻塞的任务都可以推到后台异步完成,这有助于实现无故障的最优化可用资源。
适当使用异步代码
异步处理的另一个例子是使用异步代码。根据编程语言的具体情况,您应该都能够将某些任务推送到后台执行。当任务被执行时,可以发送一个响应表明它已经被调度过。您可以查看一个 Node.js 的异步响应示例。当然,这取决于您所选择的语言,有些语言(比如 PHP)可能不提供开箱即用的异步代码。
https://geshan.com.np/blog/2020/11/nodejs-for-php-developers/#node.js-code-execution-is-async-and-non-sequential
为可扩展软件编写无状态的程序
无状态是高可扩展性软件的先决条件。正如 Redhat 在比较无状态与有状态时提到的“将无状态事务想象成一台自动售货机:一个请求对应一个响应”,而将有状态程序描述为“您可以将有状态事务视为与同一个人进行中的多轮对话”。
无状态软件在请求之间不会共享任何东西,也不依赖于本地文件系统之类的东西。
不要使用本地文件系统
如果需要保存文件,应该使用可靠的远程系统如可进行访问控制的存储,例如 Amazon S3 bucket。
这使得它很容易保存文件并通过 CDN 来提供可扩展的服务,它通过动静分离来提高软件扩展性。如图像和其他类似 PDF 文件的静态内容,通过使用 CDN 来提供服务会比网站服务更高效。利用 Apache 或 Nginx 构建网站服务来提供动态内容,会比提供静态内容更好。
使用客户端会话替换服务器端会话
另一个经典例子是不在 web 应用使用服务端会话,而是使用客户端 cookie。
您可以轻松地用使用类似 Json Web Token (JWT) 的方案替换服务器端会话来进行身份验证和授权。
JWTs 可以在每个请求中作为 header 或者 cookie 的一部分被轻松的从客户端传给服务端。因为服务器可以像牲口而非宠物一样工作,扩展软件变得非常容易。如果您必须使用会话,那么使用类似 Redis 的数据库而不要保存在本地文件系统,以保证服务器可以轻松的被替换。
http://cloudscaling.com/blog/cloud-computing/the-history-of-pets-vs-cattle/
这里的关键点是,不要留恋您的服务器,它们应该是一次性并根据负载弹性配置的。这样我们就可以通过编写无状态软件来实现易扩展和高可用成为可能。
3. 运维视角的软件可扩展性
关于运维和平台这两个表述,我指的是在哪里以什么方式部署和运行软件,另外还涵盖这些系统的架构以及它们如何交互。
软件部署的位置是至关重要的。
如果您的用户在悉尼,而软件部署在欧洲,它将有很大的网络延迟。
类似的,如果组件布局不好或选择不当都将产生负面影响。让我们看一下在运维层面对软件可扩展性有至关重要影响的因素。
垂直扩展与水平扩展
这是一个关于把服务器类比成牲口还是宠物的延伸讨论。想象一下,您正在管理一个相当受欢迎的电子商务网站,该网站每天约有 500 个订单和 5 万个独立访问用户。您有一个规格接近 Amazon EC2 m 5.4 xlarge 的大型 web 服务器,它有 16 核 CPU 以及 64 GB 的大内存。我们假设在上面运行 Woo Commerce 商店,包括网站服务和 MySQL 数据库都运行在这同一台服务器上。
现在,距离黑色星期五只有 3 个月了,公司打算做一个大规模的电视广告推广,预计流量在节日期间有 5-7 倍的增加。管理层将在广告方面投入大量资金,在这 4-5 天内网站不能瘫痪。
预计该网站在这 3-4 天内,每天将有 30 万以上的独立用户访问和 3 千以上的订单。
您现在有两个选项来扩展应用程序,要么垂直扩展(scale-up) ,要么水平扩展(scale-out)。
垂直扩展(Scale-Up)
如果选择垂直扩展,那么需要增加更多的硬件资源来解决这个问题。
您可以改用一台 EC2 m5.24 xlarge 的机器,它拥有 96 核 CPU 和 384 GB 内存。
CPU 和 内存 是老机器的 6 倍,所以理论上它应该足以支撑。
但有 3 个重要问题,首先您将需要一点时间停机来升级硬件,其次也是最重要的原因是这台机器会造成单点故障。考虑到网站负载,数据库很可能由于某个问题而崩溃。稍后如果流量没有预期的那么大,您还将为避免过度浪费资源进行收缩操作。
水平扩展(Scale-Out)
另一种选择是水平扩展,您将尝试获得许多较小的 EC2 实例,比如 8-50 个 t3.mediums 实例。
每个实例将拥有 2 核 CPU 和 4 GB 的内存。因此,一组包括 50 个 t3.mediums 实例的集群可以为您提供总共 100 核 CPU 和 200 GB 内存。要在这些新的 EC2 实例集群之间均匀分配负载,可以使用 Amazon 应用程序负载均衡器。
为了使应用程序更具可扩展性,您可以使用具有 32 个核 CPU 和 128 GB 内存的 Amazon RDS db.m5.8 xlarge 实例。根据需要,您还可以配置备份。这时您有 50 台服务器可以使用,假如有 3 台坏了可以马上换上 3 台新的。
如果负载偏低只有 3 个实例在运行,当流量激增时分分钟就可以增加 20 个。
在打折季结束后,您可以将 DB 缩放到 db.m5.large,这足以满足每天 500 个订单的情况。
考虑到这点很重要,让我们在下面可视化地解释一下。
这是 Docker 和 Kubernetes 的一部分亮点,您可以将工作任务打包进轻量级的容器,而 Kubernetes 可以管理水平扩展和滚动部署这些容器。这些年 Docker 已经改变了我们工程师的工作方式。
https://geshan.com.np/blog/2018/11/4-ways-docker-changed-the-way-software-engineers-work-in-past-half-decade/
这里要提到的一点是扩展关系数据库是非常困难的。即使有了分片之类的技术后,如果你不清楚自己在做什么,垂直扩展关系数据库会比水平扩展更容易些。这里的 Amazon 就是一个例子,同样的概念可以应用于其他任何主要的云供应商,比如谷歌云或 Azure。这就引出了我要讲的下个要点,NoSQL 数据库的使用。
使用 NoSQL 提高软件可扩展性
在上面的例子中,如果您的在线商店网站上有 20 个人,可以使用关系数据库提供服务。对于每个用户的每个请求,应用程序都会到达关系数据库,虽然慢,但不会造成严重后果。现在想象 120 个用户同时在线,性能很可能已经很明显的严重下降,我们可以看到基于预分配的数据库开始出现一些数据库连接的问题。
https://sysadminxpert.com/aws-rds-max-connections-limit/
用于可扩展软件的 NoSQL 数据库
NoSQL 数据库非常灵活的地方在于我们可以像 Redis 一样使用 NoSQL 的内存键值存储。
使用像 Redis 这样的内存数据库来提供所有产品详情将大大降低响应时间。
另一个用途可以是使用 Solr 或 Elastic Search 来实现类似查询“中号阿迪达斯牌红色 T 恤”这样的快速多条件搜索,而不是运行复杂的 SQL 查询。Solr 是一个支持事务的 NoSQL 数据库,它有助于提高软件的可扩展性和弹性。
Redis 和 Solr/Elastic Search 都需要提前准备一些数据才能正常工作,但这肯定比每次客户端请求都要查询关系数据库要好得多。
对于每个写请求,都需要写入关系数据库。
例如客户的每次购买都必须存储在关系数据库中,而在所有浏览场景至少有 80-90% 流量我们可以使用 NoSQL 数据库,这可以让软件更具可扩展性。
最终一致性和 CAP 理论
NoSQL 数据库之所以速度快,是因为它们采取了最终一致性的折衷方案。为更好理解数据存储,我非常建议您更新一下 CAP 理论的相关知识 —— 一致性、可用性和分区容错性。
https://twitter.com/mykola/status/1101337299525267457
在这篇关于高可扩展性的文章中,您可以了解到更多关于从 100 万到 1100 万用户如何扩展的信息。因为 NoSQL 数据库还可以作为高效缓存工作,这就引出我的的下一个关于使用高效缓存来实现软件可扩展性的要点。
http://highscalability.com/blog/2016/1/11/a-beginners-guide-to-scaling-to-11-million-users-on-amazons.html
缓存实现软件的可扩展性
正如 Phil Carlton 所说
在计算机科学领域只有两个难题: 缓存失效和命名。
缓存失效也是面临的一个有趣问题。您将需要一个大规模的缓存,因为没有缓存的普通方式不再可扩展。对于优秀的软件可扩展性来说,不同层级的缓存起着至关重要的作用。以下是您可以利用缓存来实现更可扩展的软件的一些方法。
Memoization
第一级的缓存可以在代码层面进行,其中一个基本方法是 Memoization。
Memoization 是其他缓存函数的高阶函数。它可以优化一些慢函数。它将函数第一次调用后的结果进行缓存,在后续调用时只要参数相同就可以直接在缓存中找到结果。
您可以看一个 Node.js Memoization 的例子,服务器可以缓存响应 1 分钟。因此在 1 分钟以内,即使数据发生了变化,客户端也会得到相同的旧数据。
https://geshan.com.np/blog/2020/11/nodejs-for-php-developers/#memoization-example
HTTP 缓存
另一个级别的缓存可以在 HTTP 层上完成。通过良好的使用 HTTP headers 可以按需要将响应缓存一段时间。HTTP 缓存也可以使用像 Cloudflare 这样的应用程序来实现,通过规则设置使响应缓存数分钟甚至数小时以减少服务器负载。这种类型的缓存机制有助于我们获得高水平的软件可扩展性。
如果您有足够容量可以管理全量 HTTP 缓存和 HTTP 加速器,Varnish 是一个很好的选择。Varnish 声称:
根据您的架构,它通常以 300-1000 倍的速度提高交付。
目前 Varnish Docker 映像的下载多达 100 多万次,我认为人们为了获得其无与伦比的软件可扩展性和巨大的 HTTP 缓存,很可能已经在 Kubernetes 上大量使用了。
https://hub.docker.com/_/varnish
我不确定读副本是不是一种纯粹的数据库缓存机制。但是我非常确信,从读副本上进行查询能大幅降低对主数据库的压力并有助于提高软件可扩展性。当然还有很多在多层应用程序上实现缓存的其他方法。在了解软件可扩展性后,您很可能根据自己的情况希望添加缓存来提高系统速度。
4. 总结
软件扩展性是一个困难的问题,而运行环境使其变得更加复杂。
一家中型公司眼中的大规模概念在 FAANG 公司的眼中可能根本排不上号。究竟什么水平才算得上是大规模,取决于您的软件系统日常处理的 RPM/RPS。
我没有实际处理过每秒十万或上百万次请求的系统,我只听说过那样的规模。我实际遇见并处理过的系统有每秒有 100 到 1000 次请求,即使是这样规模满足软件可扩展性也是非常有趣和富有挑战性的问题。