由ChatGPT/Dall-E 3生成的图像
每章开头在本系列的第一部分中,我们创建了一个最小化的网络应用平台,并赋予其启动和监控一个简单网络应用所需的所有能力。尽管如此,为了追求简单性、速度和成本效益,我们做出了一些妥协。在本文中,我们将分析我们创建的应用平台及其上运行的应用程序,以解决这些妥协问题,并使平台显著扩展。
现在用的平台在做出更改之前,我们应该观察当前的crank.fyi web应用程序和平台的行为,确定基线性能水平,并识别任何可能随着流量增加而出现的问题。
可用性折衷方案尽管我们在初期架构中,在流量很少的情况下实现了最佳实践性能和重要的网页指标,但以下组件是单点薄弱环节,尚未配置以支持扩展。
- Kubernetes集群(K8s集群)
- MySQL
- Kubernetes部署(K8s部署)
- 网络连接(网络)
因为我们这个应用程序是非关键的演示,我们更看重成本节约而不是可用性。如果发生任何停机,我们是可以接受的。但如果如果要大幅增加每日访客数量,我们就不接受这种可用性的妥协。
用户和请求次数crank.fyi 网站有两个界面,
- 一个公开网站,包含排名工具和React组件供公众用户使用
- 仅供管理员使用的私密界面,管理公司资料及其评分系统
管理站点大量使用 Django 的 管理站点 功能,提供了一个简单的 CRUD 应用程序,用于更新数据。Django 通过分析数据模型和模型关系,大多数功能可以自动推断出来,只需要少量额外的代码。使用此功能的用户数量远小于公共网站和排名工具的用户数量。
假设每个管理员的操作对应着一千个未认证的公共用户的操作。随着每日访客的增加,这种1:1000的比例可能会进一步偏向公共用户。因此,扩大管理界面的重要性远不如扩大面向公众的功能重要。
用户支持请求的公开剖析实现比如 New Relic 这样的可观测工具可能在第一部分可能看起来并不必要,但这样做使我们能够更深入地了解我们的应用是如何为公共用户服务的。以下是一个用户请求我们公共首页的跟踪:
我们大部分的请求时间都用在了数据库操作上,这在我们这样的 web 应用中并不罕见。我们还注意到大约 23% 的请求时间被归类为“未监控的时间”,这意味着我们不知道这段时间具体是怎么用的。
数据更新的节奏显示给公众用户的数据显示很少变更,因此在公司评分更新或新增公司之间,所有用户看到的数据显示是一致的。这意味着我们可以利用缓存来提高每次请求的性能,并减轻数据库的负担。
会话控制如我们在上面的跟踪中所见,会话被存储在我们的MySQL数据库中,因此保持跨应用程序部署的持久性。这是一个不错的起点,但这也增加了数据库查询、写入和提交操作,从而减慢了应用程序的速度。我们还需要创建定期清理会话表的计划进程,否则会话表会变得非常大,从而严重影响应用程序性能。
前端筛选与翻页我们已经在初始架构中进行了一项优化,这将帮助我们扩展应用程序以支持更多用户。网页应用程序将公司排名数据嵌入到首页视图中,并允许访客使用React组件在客户端搜索和过滤数据。这种客户端过滤避免了额外的往返服务器请求和昂贵的数据库模糊查询。只有在评分算法发生变化或用户点击公司名称查看详情时,浏览器才会从服务器发出进一步的请求。
两个 API 端点提供 JSON 资金轮和重返办公室政策的列表信息,每次重新加载索引视图时都会访问这些端点。可以重构对这些端点的调用,使结果缓存在客户端,避免在每次索引视图重新加载时重复请求。
Python 环境因为我们一直紧跟Python的最新版本,所以我们现在使用的是Python 3.13。保持更新有助于我们避免安全问题,并且让我们能够利用最新的语言改进和运行时优化。在Python 3.13中,全局解释器锁现在可以被选择性地启用或禁用,这使得真正的多线程执行成为可能,但这个特性目前仍然是实验性的,而且我们的一些包,尤其是Django和其他包所使用的加密库,还不支持这个功能。
数据库(又称DB)MySQL 对我们这种事务系统来说是个不错的选择,但我们存在单点故障,并且没有明确的方法来扩展数据库以支持更多的流量。此外,我们为每个用户请求执行了许多相同的查询,并且将用户会话存储在数据库中,这在我们从 0 到 1 的架构中是最简单的方案,但不适合更大的用户群体。最后,无论请求的数量有多少,我们还没有为 web 单体客户端配置连接池,因此,连接的建立和断开可能会导致性能的损失。
这里的内容包括...我们的应用程序既简单又小巧,通过HTTP传输的数据量也不大,因此可以通过减少传输的数据量来进一步提升性能。我们并没有使用HTTP压缩或压缩JavaScript代码,因此还有机会通过这些方式提高性能。
基本表现在尝试修改系统之前,重要的是要建立基准,以了解当前配置下我们的系统可以处理多少流量。为了模拟流量,我们需要一个负载测试工具,比如。因为我们使用Python,我们将使用Locust这款工具。
为了我们的测试,我配置了Locust,使其使用默认和“技术导向型”的评分算法访问网站首页,并访问三个不同组织详情页面。我使用最小的1核容器配置,并分别以10个和20个并发用户进行了测试,结果如下:
在有20个用户同时访问的情况下,所有测试页面的响应时间
每秒请求数和10(左)及20(右)并发用户数的响应时间
10和20个并发用户测试中,数据库查询时间与吞吐量
这些数据显示,我们的单个CPU配置在处理大约十个并发用户时,每请求性能开始下降,影响了我们的网站关键指标。我们还可以看到,在这种流量情况下,数据库这一点并不是瓶颈,无论是10个还是20个用户,吞吐量和查询时间都差不多。
容量管理(Capacity Planning) 每日用户与并发用户在本文的标题中,我们提到希望支持每天一百万的日访问量,但我们需要理解这个数字对我们应用程序在并发用户峰值和请求量上意味着什么。让我们基于以下假设和粗略计算:
- 我们大部分的流量来自北美地区,所以暂时不需要考虑多区域功能。
- 在高峰时段,用户活跃度会较高,会有更多的用户活跃;而在用户活跃度较低的非高峰时段,用户数量会较少。
- 如果一天内有100万用户活跃,那么在峰值时段,活跃用户数量可能会少于总数的三分之一,即约33.3万用户。
- 公共用户会加载首页,并主要使用我们的React组件在本地过滤结果。他们每分钟会点击查看大约五家公司的详情。这将导致每分钟每个用户大约发出6个动态HTTP请求(我们不计算可以缓存的CSS和其他静态内容)。
- 这意味着在峰值负载时,如果活跃用户数为33.3万,且每分钟每个用户发出约6个HTTP请求,我们需要每秒处理大约3.3万个请求来应对峰值负载。
计算目前是我们的一大瓶颈,如果不更新我们的架构,这可能会迫使我们的应用程序进行横向扩展。如果我们当前在单个CPU上每秒能处理大约17个请求,在无法维持可接受的网页性能之前失败,那么为了提供可接受的用户体验,我们需要19607个vCPU。
在高峰期,如果我们使用 AWS EC2 计算来支撑运行我们的 Kubernetes 集群,那么大约会使用 16 个 m6id.32xlarge EC2 节点,每小时的总成本约为 116 美元。如果我们考虑到集群可以自动扩展,并且在一个月内平均使用量将保持在峰值规模的 25% 左右,那么一个月的计算成本大约为 318 万美元。
333,333(req/s) / 17(req/s 每 vCPU) = 19607 vCPU 峰值
19607 vCPU 峰值 0.25(平均负载估计为峰值负载的 25%)= 4901 平均 vCPU
4901 / 128(m6id.32xlarge 的 vCPU 数量)= 38 个实例平均
38 个实例 24 小时 每小时 116 元 30 天 = 约 318 万美元每月计算成本
根据我们如何让应用程序盈利,这笔费用_可能_会是可接受的,但我们应该可以大幅降低这个数字。
数据库我们提到在最初的从0到1的实现中,数据库并不成为瓶颈,但是每个动态网页请求都会导致大约十次SQL操作(其中一次是会话更新并提交),随着我们处理更多请求,这种情况可能不会继续下去。由于我们大多数的操作都是对变化不频繁的数据进行只读操作,我们可以通过增加读取副本来扩展应用,但会话更新依旧是个问题。分布式缓存可能带来更大的性能提升,并且成本更低,同时解决我们的会话写入问题。
一个关键概念,在选择如何解决单点失效并优化应用程序的扩展性时,我们将遵循以下几条“指导原则”,
- 避免复杂性
尽量减少对架构的改动,只要能达到可接受的结果即可。除非复杂的组件能带来显著的性能提升和相应的成本节约,否则不要添加复杂的组件。 - 避免重新计算不变的数据
如果某些数据不会改变或很少改变,可以将其缓存并使用缓存的数据。 - 以延迟一致性换取更好的性能
如果更新可以延迟几秒或几分钟后再向公众用户公布,这比为追求绝对即时的一致性付出高昂代价要好。 - 优先考虑小而有影响力的改动,而非大而有影响力的改动
如果两个改动能带来相同的效益,但一个改动较大而另一个较小,除非大的改动能明显减少复杂性或提供立即且有意义的可维护性改进,否则应优先选择较小的改动。 - 专注于最高影响的事项,推迟依赖决策
专注于主要的问题,推迟次要决策,特别是如果为了解决主要问题所做的决策可能会改变我们如何处理依赖问题的方式。
在我们当前系统的分析中,我们识别出几个单点故障组件。在我们优化之前,让我们先解决架构中的这些单点故障。
Kubernetes集群如上所述,为了应对峰值流量,我们需要增加计算资源。因此,我们将通过添加额外的主节点和工作节点来增加冗余。我们将在不同AWS可用区中至少部署三个主节点(每个位于不同的AWS可用区中),以确保冗余和容错。
MySQL数据库(MySQL database)我们的MySQL数据库也将采用一个主实例和一个或多个分布在不同可用区的只读副本的方式进行扩展。我们会稍后再决定实例规格和副本数量,因为具体需要多少资源可能取决于我们后续的架构决策。
Kubernetes部署這可能是我們最簡單解決的問題。由於我們升級後的集群將會有更多更高計算密度的Kubernetes工作節點,我們可以增加副本數量,將 pod 分布在不同節點和可用區域上,並在每個節點上運行多個 pod,這樣可以避免單點故障。在優化過程中,我們會嘗試調整這些 pod 的大小和配置。
网络连接Cloudflare 和 AWS 都提供了相对无缝的工具和功能,使我们能够扩展网络流量,并且默认配置中已包含冗余机制。在此处,鉴于我们当前的流量水平仍然较小,我们无需采取任何特别的创意措施来解决网络吞吐量的问题。
优化现在我们已经采取措施提高了可靠性,消除瓶颈以提高应用的性能,使我们的应用程序更高效,避免在运行它时花大钱。
重构 GitHub Actions因为我们将会对我们环境做出很多改动,所以我们应该让我们的 GitHub 工作流更高效。特别是,在工作流中加入 paths-ignore
过滤器以防止在无关文件变更时运行,这缩短了在调整部署中的迭代周期(例如:如果仅仅是调整副本数量,就无需重新构建容器镜像。)
启用连接池可能在不作其他更改的情况下带来一些实际的好处,我们先试试这个。
# 我们将自动检测可以使用的内核数
CPU_COUNT = multiprocessing.cpu_count()
# 并用这个数量来计算DATABASES配置中的连接池参数
'OPTIONS': {
'MAX_CONNS': CPU_COUNT * 4, # 连接池中的最大连接数
'REUSE_CONNS': CPU_COUNT * 2, # 可重用的连接数
},
结果
遗憾的是,所做的连接池调整并没有显著提升整体吞吐量。
因为我们的应用受CPU限制,让我们看看我们能否通过这些方法提高吞吐量:通过保证更多的CPU资源并在多个根据负载自动伸缩的Pod之间分配请求。
apiVersion: 应用程序 API 版本/v1
类型: 部署
...
规范:
副本数: 2
...
资源请求:
请求量:
内存需求: "512Mi"
CPU需求: "1"
上限:
内存需求: "512Mi"
CPU需求: "1"
...
---
apiVersion: 自动伸缩/v2
类型: 水平 Pod 自动扩缩
元数据:
名称: crank-hpa
命名空间: crank
规范:
目标规模引用:
apiVersion: 应用程序版本/v1
类型: 部署
名称: crank
最小副本数: 2
最大副本数: 10
度量标准:
- 类型: 资源
资源:
名称: CPU
目标:
类型: 使用率
平均使用率: 80
结果
这里的成果看起来更有希望,但每秒请求数(53.3)和容器数量(在峰值负载时有3个容器)随着吞吐量的增加而线性增长,且Apdex得分也有所提高。
每秒53.3个请求,当并发用户数为20时。
缓存:我们优化的下一步是开启缓存。为此,我们将使用常用的 Redis 分布式缓存,并在我们的 Kubernetes 集群中部署一个新的 2 个容器的 Redis 缓存,带有持久卷存储。配置详情请参见 here。
:将会话移至缓存在 AWS 中,我们会选择 AWS ElastiCache for Redis,但为了测试,使用现有的 Kubernetes 基础架构要更简单。随着我们的扩展,在 Kubernetes 上运行 Redis 可能会更加容易配置和管理。
由于会话存储在数据库中,并且每次会话数据发生变化时都需要更新(需要数据库的提交),将会话存储迁移到更快的 Redis 分布式缓存更有意义。由于会话的临时性质,Redis 的 TTL 自动过期功能使其成为此类用例的理想选择。
REDIS_URL = os.environ["REDIS_URL"]
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': REDIS_URL,
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
}
}
}
# 设置 Redis 中会话条目的过期时间
SESSION_REDIS = {
'ttl': 1800, # 30 分钟 (即 1800 秒)
}
视图缓存
在添加缓存时,首先要问:“我可以避免为每个请求/访客重新计算什么?” 在我们的情况下,由于更新不频繁,我们可以缓存整个视图,而不是每次请求都重新渲染。这能大大加快速度,接下来我们看看这种改变会对性能有什么影响。
警告! 由于我们有公共和私人视图,我们希望小心不要缓存已认证用户的视图。这并不是 Django 的默认行为,但我们可以通过创建一个装饰器来解决这一问题,以避免出现这种错误。我们可以通过创建一个装饰器来避免缓存已认证用户的内容 来避免这个问题。然后我们可以像这样包装我们想要缓存的视图,如下所示:
urlpatterns = [
path("", cache_page_if_anonymous_method(settings.CACHE_MIDDLEWARE_SECONDS)(IndexView.as_view()), name="首页"),
path("admin/", admin.site.urls),
path("algo/<int:algorithm_id>/", cache_page_if_anonymous_method(settings.CACHE_MIDDLEWARE_SECONDS)(IndexView.as_view()), name="算法详情"),
path("organization/<int:pk>/", cache_page_if_anonymous_method(settings.CACHE_MIDDLEWARE_SECONDS)(OrganizationView.as_view()), name="组织详情"),
path('api/funding-round-choices/', cache_page_if_anonymous_method(settings.CACHE_MIDDLEWARE_SECONDS)(FundingRoundChoicesView.as_view()), name='融资轮次选项'),
path('api/rto-policy-choices/', cache_page_if_anonymous_method(settings.CACHE_MIDDLEWARE_SECONDS)(RTOPolicyChoicesView.as_view()), name='RTO政策选项'),
path('api-auth/', include('rest_framework.urls')),
path('accounts/logout/', CustomLogoutView.as_view(), name='账户登出'),
path('accounts/', include('allauth.urls')),
]
结果如下
正如我们所预期的,这些变化带来了我们迄今为止最大的一次提升。
每秒可达121.4个请求,最多支持30个并发用户
在这里,我们大约每秒管理121个请求,有30个并发用户,分布在三个容器里,请求处理时间的中位数大约是160毫秒。我们的P95响应时间(即95%的请求都在这个时间内完成)依然可以接受。
每秒121个请求时,添加Redis大大减轻了MySQL和Python脚本的负担,用Redis中的超快O(1)键值查找替换了计算密集型的Python渲染处理和MySQL的查询。
每秒处理121个请求时,Python和MySQL的性能瓶颈问题大大减轻了,使用Redis之后。
如上文“关键概念”中所述,避免不必要的重新计算是关键。在接下来的图表中,我们可以看到Redis现在占据了我们数据库处理的大部分工作,这正是我们想要的。MySQL现在作为我们数据的单一事实来源,仅在缓存未命中时才会查询,这种情况每60秒才会发生一次缓存未命中,基于我们的缓存TTL。Redis可以每秒处理数十万到数百万的请求,几乎不占用CPU,因此,它是一个强大的性能优化和成本控制工具。
通过提供缓存结果,大多数 MySQL 调用都被避免了。
因为 Redis 承担了大部分工作,我们可以支持几乎任何规模的部署,而不需要过度扩展 RDS 实例。在许多 AWS 托管的应用中,大型 RDS 实例是主要的成本因素和扩展瓶颈。此外,RDS 实例也有一个“最大”容量限制,许多公司在遇到数据库扩展限制时会遇到困难。
更新的100万访客架构设计按照我们之前的估算,每位用户每分钟大约有6个请求,这样的小型3容器部署可以支持约1210名用户(或每CPU大约支持403名用户)。现在,我们不再需要19607个CPU和每月超过300万美元,而是可以用更少的资源。
333,333(req/s)/ 403(每 CPU req/s)= 827 个 vCPU 峰值
827 个 vCPU 峰值 0.25(平均负载估计为峰值负载的 25%)= 207 个 vCPU 平均
207 / 32(每个 m6id.8xlarge 的 vCPU 数量)= 7 个实例平均
7 个实例 24 小时 $1.8984(每小时费用) 30 天 = 大约每月费用 $9.5K
这是我们新架构的样子是这样的:
我们通过对我们架构和代码进行最小且合理的调整,成功地在性能可接受的情况下支持了1000万日活跃用户,并且与我们的基线配置相比,节省了超过300万美元的成本。
尽管这种程度的成本削减可能看起来非常极端,我可以向你保证,我在真正的公司中见过并解决了类似这种“过度支出”的问题。这些公司往往是由于应用变得过于复杂,功能不断增加,而优化工作却一直被忽视,从而逐步达到了这样的状况。这些公司的管理层往往选择支付不断增长的 AWS 账单,而不是进行不会直接增加新功能的架构优化和重构工作。正如你所见,这样的情况导致了大量的资金浪费,同时对用户体验产生了负面影响。
共同学习,写下你的评论
评论加载中...
作者其他优质文章