博客介绍了如何解锁性能,让我能够使用最少的资源(2 GB RAM 1v CPU 和最少网络带宽 50-100 Mbps)将后端从 50K 请求扩展到 1M 请求(~16K 请求/分钟)。
它将带你和我过去的自己一起踏上一段旅程。这可能是一段漫长的旅程,所以系好安全带,享受旅程吧!🎢
本文假设您熟悉后端和编写 API。如果您对 Go 有一点了解,那也是一大优势。如果您不了解,也没关系。您仍然可以跟进,因为我提供了资源来帮助您理解每个主题。(如果您不了解 GO,这里有一个 快速介绍)
总结,
首先,我们构建一个可观察性管道,帮助我们监控后端的各个方面。然后,我们开始对后端进行压力测试,直到断点测试(当一切最终中断时)。
→添加索引
→未来计划
带后端的简介🤝
让我简单介绍一下后端,
它是一个用 Golang 编写的单体 RESTful API。
使用 Aurora Postgres 作为我们在 AWS RDS 上托管的唯一主数据库。
后端已dockerized,我们
t2.small
在 AWS 实例中运行它。它有 2GB RAM、50-100mb/s 的网络带宽和 1 个 vCPU。后端提供身份验证、CRUD 操作、推送通知和实时更新。
对于实时更新,我们打开一个非常轻量级的Web 套接字连接,通知设备实体已更新。
我们的应用程序主要是读取操作繁重,写入操作也相当多,如果我必须给出一个比率的话,那就是 65% 为读取/35% 为写入。
我可以写一个单独的博客来解释我们为什么选择 - 整体架构,golang 或 postgress,但为了给你提供 tl;dr,在MsquareLabs,我们相信“保持简单,并构建允许我们以极快的速度前进的代码”。
数据 数据 数据 🙊
在进行任何模拟负载生成之前,我首先在后端构建了可观察性。其中包括跟踪、指标、分析和日志。这使得查找问题并准确查明问题原因变得非常容易。当您对后端进行如此强大的监控时,也可以更轻松地更快地跟踪生产问题。
在我们继续之前,请允许我先对指标、分析、日志和跟踪进行简要概述:
日志:我们都知道日志是什么,它只是当事件发生时我们创建的大量文本消息。
痕迹:这是具有高可见性的结构化日志,可帮助我们以正确的顺序和时间封装事件。
指标:所有数字变动数据,例如 CPU 使用率、活跃请求和活跃 goroutines。
分析:为我们提供代码的实时指标及其对机器的影响,帮助我们了解正在发生的事情。(WIP,将在下一篇博客中详细讨论)
要了解有关如何在后端构建可观察性的更多信息,您可以研究下一篇博客(WIP),我将这部分移到了另一篇博客,因为我想避免读者不知所措并只关注一件事 -优化)
跟踪、日志和指标的可视化如下所示:
现在,我们有一个强大的监控管道 + 一个不错的仪表板。
嘲讽超级用户 x 100,000 🤺
现在真正的乐趣开始了,我们开始嘲笑那些喜爱该应用程序的用户。
“只有当你把你的爱(后端)置于极端压力之下时,你才会发现它的真谛✨” - 某个很棒的人哈哈,我不知道
Grafana 还提供了负载测试工具,因此我没有过多考虑就决定使用它,因为它只需要几行代码的最少设置,就可以准备好模拟服务。
我没有触及所有的 API 路由,而是专注于负责我们 90% 流量的最关键路由。
关于k6的简短概述,它是一个基于 javascript 和 golang 的测试工具,您可以在其中快速定义要模拟的行为,它会负责对其进行负载测试。您在主函数中定义的任何内容都称为迭代,k6 会启动多个虚拟用户单元 (VU) 来处理此迭代,直到达到给定的周期或迭代次数。
每次迭代由 4 个请求组成,创建任务 → 更新任务 → 获取任务 → 删除任务
从慢开始,让我们看看情况如何:~10K 请求 → 100 VUs,30 iters → 3000 iters x 4reqs → 12K 请求
这真是轻而易举,没有内存泄漏、CPU 过载或任何类型的瓶颈的迹象,好极了!
这是 k6 摘要,发送了 13MB 的数据,接收了 89MB,平均每秒超过 52 个请求,平均延迟为 278ms,考虑到所有这些都在一台机器上运行,这个成绩还不错。
checks.........................: 100.00% ✓ 12001 ✗ 0
data_received..................: 89 MB 193 kB/s
data_sent......................: 13 MB 27 kB/s
http_req_blocked...............: avg=6.38ms min=0s med=6µs max=1.54s p(90)=11µs p(95)=14µs
http_req_connecting............: avg=2.99ms min=0s med=0s max=536.44ms p(90)=0s p(95)=0s
✗ http_req_duration..............: avg=1.74s min=201.48ms med=278.15ms max=16.76s p(90)=9.05s p(95)=13.76s
{ expected_response:true }...: avg=1.74s min=201.48ms med=278.15ms max=16.76s p(90)=9.05s p(95)=13.76s
✓ http_req_failed................: 0.00% ✓ 0 ✗ 24001
http_req_receiving.............: avg=11.21ms min=10µs med=94µs max=2.18s p(90)=294µs p(95)=2.04ms
http_req_sending...............: avg=43.3µs min=3µs med=32µs max=13.16ms p(90)=67µs p(95)=78µs
http_req_tls_handshaking.......: avg=3.32ms min=0s med=0s max=678.69ms p(90)=0s p(95)=0s
http_req_waiting...............: avg=1.73s min=201.36ms med=278.04ms max=15.74s p(90)=8.99s p(95)=13.7s
http_reqs......................: 24001 52.095672/s
iteration_duration.............: avg=14.48s min=1.77s med=16.04s max=21.39s p(90)=17.31s p(95)=18.88s
iterations.....................: 3000 6.511688/s
vus............................: 1 min=0 max=100
vus_max........................: 100 min=100 max=100
running (07m40.7s), 000/100 VUs, 3000 complete and 0 interrupted iterations
_10k_v_hits ✓ [======================================] 100 VUs 07m38.9s/20m0s 3000/3000 iters, 30 per VU
我们将请求数从 12K 增加到 100K,发送了 66MB,接收了 462MB,看到 CPU 使用率达到峰值 60%,内存使用率达到 50%,运行耗时 40 分钟(平均每分钟 2500 个请求)
一切看起来都很好,直到我在日志中看到一些奇怪的东西:“::gorm: 连接太多::”,快速检查 RDS 指标后确认打开的连接已达到 410,即最大打开连接的限制。它由 Aurora Postgres 本身根据实例的可用内存设置。
你可以通过以下方式检查,
select * from pg_settings where name='max_connections';
⇒ 410
Postgres 为每个连接生成一个进程,考虑到它会在新请求到来时打开一个新连接,而上一个查询仍在执行,因此成本极高。因此,postgres 强制限制可以打开的并发连接数。一旦达到限制,它会阻止任何进一步连接到数据库的尝试,以避免实例崩溃(这可能导致数据丢失)
优化 1:连接池⚡️
连接池是一种管理数据库连接的技术,它重用打开的连接并确保其不超过阈值,如果客户端请求连接并且超过了最大连接限制,它会等待直到连接被释放或拒绝该请求。
这里有两个选项,要么进行客户端池化,要么使用pgBouncer(充当代理)等单独服务。当我们规模庞大并且拥有连接到同一数据库的分布式架构时,pgBouncer 确实是一个更好的选择。因此,为了简单起见并为了我们的核心价值,我们选择继续使用客户端池化。
幸运的是,我们使用的 ORM GORM 支持连接池,但在底层使用数据库/SQL(golang 标准包)来处理它。
有非常简单的方法可以解决这个问题,
configSQLDriver, err := db.DB()
if err != nil {
log.Fatal(err)
}
configSQLDriver.SetMaxIdleConns(300)
configSQLDriver.SetMaxOpenConns(380) // kept a few connections as buffer for developers
configSQLDriver.SetConnMaxIdleTime(30 * time.Minute)
configSQLDriver.SetConnMaxLifetime(time.Hour)
SetMaxIdleConns
→ 保存在内存中的最大空闲连接数,以便我们可以重复使用它(有助于减少打开连接的延迟和成本)SetConnMaxIdleTime
→ 我们应该将空闲连接保留在内存中的最长时间。SetMaxOpenConns
→ 与数据库的最大开放连接,因为我们在同一个 RDS 实例上运行两个环境SetConnMaxLifetime
→ 任何连接保持打开的最长时间
现在更进一步,500K 个请求(4000 个请求/分钟)和 20 分钟的服务器崩溃💥,最后让我们调查一下🔎
快速查看指标,然后砰!CPU 和内存使用率飙升。Alloy(开放遥测收集器)占用了所有 CPU 和内存,而不是我们的 API 容器。
优化 2:解除 Alloy 资源阻塞(开放遥测收集器)
我们在小型 t2 实例中运行三个容器,
API 开发
API 暂存
合金
当我们将大量负载转储到我们的 DEV 服务器时,它开始以相同的速率生成日志 + 跟踪,从而成倍增加 CPU 使用率和网络出口。
因此,确保合金容器不超过资源限制并妨碍关键服务非常重要。
由于 Alloy 在 Docker 容器内运行,因此更容易实施这一约束,
resources:
limits:
cpus: '0.5'
memory: 400M
此外,这次日志不是空的,有多个上下文取消错误 - 原因是请求超时,并且连接突然关闭。
然后我检查了延迟,结果很疯狂 😲 经过一段时间后,平均延迟为 30 - 40 秒。多亏了跟踪,我现在可以准确地找出导致如此巨大延迟的原因。
我们的 GET 操作查询非常慢,让我们运行一下EXPLAIN ANALYZE
查询,
LEFT JOIN 耗时 14.6 秒,而 LIMIT 又耗时 14.6 秒,我们如何优化它 - 索引
优化3:添加索引🏎️
where
在or子句中经常使用的字段上添加索引ordering
可以将查询性能提高五倍。在为 LEFT JOIN 表和 ORDER 字段添加索引后,相同的查询花费了 50 毫秒。这太疯狂了,从14.6 秒 ⇒ 50 毫秒🤯
(但要注意不要盲目添加索引,这会导致 CREATE/UPDATE/DELETE 操作缓慢)
它还可以更快地释放连接并有助于提高处理大量并发负载的整体能力。
优化 4:确保测试时没有阻塞事务🤡
从技术上来说,这不是优化,而是一种修复,您应该记住这一点。在进行压力测试时,您的代码不会尝试同时更新/删除同一个实体。
在检查代码时,我发现一个错误,该错误导致每次请求时都会对用户实体进行更新,并且由于每个更新调用都在事务内执行,从而创建一个锁,因此几乎所有的更新调用都被先前的更新调用阻止。
仅此一项修复就将吞吐量提高到了 2 倍。
优化 5: 跳过 GORM 的隐式 TRANSACTION 🎭
默认情况下,GORM 在事务内执行每个查询,这可能会降低性能,因为我们有一个非常强大的事务机制,在关键区域中错过事务的可能性几乎为零(除非他们是实习生🤣)。
我们有一个中间件,用于在到达模型层之前创建事务,并有一个集中函数来确保在我们的控制器层中提交/回滚该事务。
通过禁用此功能,我们可以获得至少约 30% 的性能提升。
“我们被困在每分钟 4-5K 个请求的原因就是这个,我以为是我的笔记本电脑网络带宽问题。” - 我真笨
所有这些优化使吞吐量提高了 5 倍💪,现在仅我的笔记本电脑每分钟就可以产生 12K-18K 个请求的流量。
百万点击量🐉
最后,每分钟有 10k-13K 个请求,一百万次点击,花费了大约 2 个小时,这应该早点完成,但是随着合金重启(由于资源限制),所有指标都会丢失。
令我惊讶的是,该时间段内的最大 CPU 利用率为 60%,内存使用量仅为 150MB。
Golang 的性能如此出色,处理负载如此出色,真是令人难以置信。它的内存占用极小。我就是喜欢 Golang 💖
每个查询需要 200-400 毫秒才能完成,下一步是找出为什么需要这么多时间,我猜测是连接池和 IO 阻塞减慢了查询速度。
平均延迟降至约 2 秒,但仍有很大的改进空间。
隐式优化
优化 6:增加最大文件描述符限制🔋
由于我们在 Linux 操作系统中运行后端,因此我们打开的每个网络连接都会创建一个文件描述符,默认情况下,Linux 将其限制为每个进程 1024 个,这会阻碍其达到最佳性能。
由于我们打开了多个 Web 套接字连接,如果有大量并发流量,我们很容易达到此限制。
Docker compose 提供了一个很好的抽象,
ulimits:
core:
soft: -1
hard: -1
优化 7:避免 goroutine 过载
作为 Go 开发人员,我们经常将 goroutine 视为理所当然,只是在函数前添加的 goroutine 中盲目地运行许多非关键任务,go
然后忘记它的执行,但在极端情况下它可能成为瓶颈。
为了确保它永远不会成为我的瓶颈,对于经常在 goroutine 中运行的服务,我使用带有 n-worker 的内存队列来执行任务。
下一步
改进:从 t2 移至 t3 或 t3a
t2 是 AWS 通用机器的老一代,而 t3 和 t3a、t4g 是新一代。它们是突发实例,它们提供比 t2 更好的网络带宽和更好的长时间 CPU 使用性能
了解突发实例,
AWS 引入了可突发实例类型,主要针对大多数时间不需要 100% CPU 的工作负载。因此,这些实例以基准性能(20% - 30%)运行。它们维护一个信用系统,每当您的实例不需要 CPU 时,它就会积累信用。当出现 CPU 峰值时,它会使用这些信用。这减少了您的 AWS 计算成本和浪费。
t3a 是一个值得坚持的好系列,因为它们的成本/效率比在突发实例系列中要好得多。
这是一篇比较t2 和 t3 的精彩博客。
改进:查询
我们可以对查询/模式进行许多改进以提高速度,其中一些是:
在插入繁重的表中批量插入。
通过非规范化避免 LEFT JOIN
缓存层
着色和分区,但是这要晚得多。
改进:分析
解锁性能的下一步是启用分析并查明运行时到底发生什么。
改进:断点测试
为了发现我的服务器的局限性和容量,下一步是进行断点测试。