避免初学者错误阻碍您扩展后端⚡️
本博客介绍了如何解锁性能,使我能够在最少资源(2 GB RAM 1v CPU 和最小网络带宽 50-100 Mbps)上将后端从 50K 请求扩展到 1M 请求(~16K 请求/分钟)。
它将带你和过去的我一起踏上一段旅程。这可能是一段漫长的旅程,所以系好安全带,享受旅程吧!🎢
本文假设您熟悉后端并会编写 API。如果您对 Go 语言有所了解,那就更好了。如果您不了解,也没关系。您仍然可以继续学习,因为我提供了一些资源来帮助您理解每个主题。(如果您不了解 Go 语言,这里有一个 快速入门)
总结一下,
首先,我们构建了一个可观察性管道,帮助我们监控后端的各个方面。然后,我们开始对后端进行压力测试,直到断点测试(最终一切都崩溃)。
→添加索引
→未来计划
带后端的介绍🤝
让我简单介绍一下后端,
- 它是一个用 Golang 编写的单体 RESTful API。
- 使用GIN框架编写并使用GORM作为ORM。
- 使用 Aurora Postgres 作为我们在 AWS RDS 上托管的唯一主数据库。
- 后端已docker化,我们
t2.small
在AWS实例上运行它。它有2GB内存,50-100mb/s网络带宽,1个vCPU。 - 后端提供身份验证、CRUD 操作、推送通知和实时更新。
- 对于实时更新,我们打开一个非常轻量级的Web 套接字连接,通知设备实体已更新。
我们的应用程序主要是读取密集型的,写入活动也相当多,如果我必须给它一个比率的话,那就是 65% 读取/35% 写入。
我可以写一个单独的博客来解释我们为什么选择 - 单体架构、golang 或 postgress,但为了让你了解MsquareLabs的 tl;dr,我们相信“保持简单,并构建允许我们以极快的速度前进的代码”。
数据 数据 数据 🙊
在进行任何模拟负载生成之前,我首先在后端构建了可观察性,包括跟踪、指标、分析和日志。这使得查找问题并准确定位问题根源变得非常容易。当您对后端拥有如此强大的监控能力时,跟踪生产问题也会变得更加容易和快捷。
在我们继续之前,请允许我简单介绍一下指标、分析、日志和跟踪:
- 日志:我们都知道日志是什么,它只是我们在事件发生时创建的大量文本消息。
- 痕迹:这是具有高可见性的结构化日志,可帮助我们以正确的顺序和时间封装事件。
- 指标:所有数字搅动数据,如 CPU 使用率、活动请求和活动 goroutines。
- 分析:提供代码的实时指标及其对机器的影响,帮助我们了解当前运行情况。(开发中,下一篇博客会详细讨论)
要了解有关如何在后端构建可观察性的更多信息,您可以研究下一篇博客(WIP),我将此部分移至另一篇博客,因为我想避免读者不知所措并只关注一件事 -优化)
跟踪、日志和指标的可视化如下:
所以现在我们有了一个强大的监控管道 + 一个不错的仪表板🚀
嘲讽超级用户 x 100,000 🤺
现在真正的乐趣开始了,我们开始嘲笑那些喜欢这个应用程序的用户。
“只有当你把你的爱(后端)置于极端压力之下时,你才会发现它的真谛✨”——某个很棒的人哈哈,我不知道
Grafana 还提供了负载测试工具,因此我没有过多考虑就决定使用它,因为它只需要几行代码的最少设置,就可以准备好模拟服务。
我没有触及所有的 API 路由,而是专注于负责我们 90% 流量的最关键路由。
简单介绍一下k6,它是一款基于 JavaScript 和 Go 语言的测试工具,你可以快速定义想要模拟的行为,它会负责对其进行负载测试。你在主函数中定义的所有内容都称为迭代,k6 会启动多个虚拟用户单元 (VU) 来处理本次迭代,直到达到给定的周期或迭代次数。
每次迭代包含 4 个请求,创建任务 → 更新任务 → 获取任务 → 删除任务
从慢开始,让我们看看 ~10K 请求 → 100 VUs,30 次迭代 → 3000 次迭代 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: Too many links::”,快速检查了 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 使用率和网络出口。
因此,确保合金容器不会超出资源限制并妨碍关键服务非常重要。
由于合金在 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
- 缓存层
- 着色和分区,但这要晚得多。
改进:分析
解锁性能的下一步是启用分析并弄清楚运行时到底发生了什么。
改进:断点测试
为了发现服务器的局限性和容量,下一步是进行断点测试。
结束语👋
如果你读到了最后,恭喜你,你一定很兴奋🍻
这是我的第一篇博客,如果您有任何不清楚的地方,或者想更深入地了解这个主题,请告诉我。在我的下一篇博客中,我将深入探讨性能分析,敬请期待。
您可以在X上关注我,以了解最新动态:)
文章来源:https://dev.to/rikenshah/scaling-backend-to-1m-requests-with-just-2gb-ram-4m0c