本文最初发布于 The Startup 博客,经原作者授权由 InfoQ 中文站翻译并分享。你可以通过领英与作者联系(https://www.linkedin.com/in/jgefroh/)。
在进行了“压力山大”的整体优化后,运行中的系统性能提升了 35000%。
我所在的前一家公司构建了一个大规模捐赠和支付软件系统,在一些盛大的节日里,我们一次活动中就会收到成千上万笔捐款。我在那家公司的其中一项职责就是扩展这个系统,确保它不会崩溃。由于架构低效、开发仓库以及技术选择问题,它有许多局限性,在性能上也远远无法满足需求。
随着捐赠活动越来越大、越来越成功,我们必须不断优化系统,解决平台可伸缩性、稳定性和性能问题。当平台扩展工作完成时,它已经具备了每秒处理数千个请求以及同时开展数千个活动的能力,而所有这些活动的运营成本大致相同。
分析使用模式
在深入了解如何优化这个系统之前,我们必须了解它的使用模式以及我们试图优化的具体环境和约束条件,否则就只是盲目尝试。
这个系统有几个特点。
首先是捐赠活动有明确的起止时间。“捐赠日”是在几个月前就计划好的大型活动。其开始和结束有非常具体的日期和时间。这些日期有时会变,有时则不变。
RPS:捐赠日会忽然开始和结束
其次我们的宣传力度非常大。活动期间,我们的系统可能会在这天一开始就发送数十万封电子邮件,并在整个活动过程中经常性地发送回复邮件,鼓励人们拜访、参与、分享和捐赠。
社交媒体链接在现有的每一个网络平台上都随处可见——有些我甚至从未听说过。校园里甚至到处都是实体海报、摊位和传单。有些客户甚至会进行 24 到 48 小时的特别电视转播。
再次是系统资源使用是恒定的,偶尔会有峰值。在“捐赠日”的特定时段,比如一天的开始和社交媒体协调推送期间,我们可以看到活动大幅增加。对于一个活动,在不到一秒的时间内,每秒请求数就可以从 0 增加 150。这种没有预热的激增有时与 DDOS 难以区分。
CPU:资源使用通常是持续的,偶尔会出现活跃度峰值
除这些事件之外,资源使用是恒定的。随着用户与网站产生互动,我们就会看到捐赠和活跃度提升。最后,在一天结束的时候,活动全部结束,活跃度就会像开始一样突然减少。
最后一个是系统的负载是可预测的。
由于开始/结束日期是已知的,并且我们与客户密切合作,了解他们一整天的行动计划,这为我们的服务器活动提供了很多可预测性,让我们可以对负载进行规划。
如果我们知道客户的各种目标,就可以通过性能优化和调整服务器设置来更好地管理他们的预期负载。通过一些基本的计算,就可以相对准确地估计其中的大部分。
业务对确保系统稳定性也有很大的影响。从业务的角度来看,我们可以错开客户的开始/结束日期,最小化客户的并发,从而尽可能提高可靠性。
我们要做些什么优化?
现在,我们知道了要处理的使用情况,让我们简要地回顾一下可以使用的一些度量标准。记住:在优化之前,我们应该设定基准并进行度量。
在 97%的情况下,我们应该忽略小的效率提升:过早优化是万恶之源。可是,我们不应该错过这关键的 3%的提升机会。
——Donald Knuth
经过考虑,我们将系统指标分为了两类:一类是度量活跃度的指标,另一类是度量性能的指标。
活跃度度量很重要,它是我们服务器性能的输入。
每秒请求数很简单。就是要看一下,我们的服务器每秒处理多少请求?处理的越多说明活跃度越高。CPU 使用率是我们用以检测系统不可用性的一个指标。密集计算会导致系统阻塞,系统不应该第一时间对 Web 请求进行密集计算。
内存使用率是一个决定成败的指标。我们的服务器只有这么多容量。一些低效的代码占用了内存,将数十万个对象实例化到内存中。我们通过这个指标找出并消除这些内存泄漏。连接数也是需要关注的,因为我们使用的云提供商对连接数量有限制。
最重要的性能指标是响应时间。
这个指标低就意味着我们做得很好,高则意味着我们做得不好。像 DataDog 或 NewRelic 这样的 APM 工具可以为我们提供层级响应时间,我们可以用它们来找出瓶颈。
Heroku 在技术上将整体请求响应时间设定为 30 秒超时,实际上,我们希望大多数面向客户的页面请求在 3 秒内完成。我个人认为,所有响应时间超过 8 秒的情况都可以认为是中断。
50 个百分位通常低于 100ms,因为许多请求都是能够快速完成的 API 端点。99 个百分位超过 20 秒也没有问题,因为一些管理页面需要好一会儿才能完成。我真正关心的是 95 百分位——我们希望 95%的请求能够在 3 秒内完成。这 95%代表了大部分客户的请求和参与,也代表了捐赠者的体验。
比较容易做到的优化
下面是一些可以轻松取得成果的优化:
纵向和横向扩展
N+1 查询
低效代码
前后台资源优化
内存泄漏
服务区域
纵向和横向扩展
我做的第一件事就是增加每台服务器的性能——通过纵向扩展来获得性能。为每台服务器提供更多的内存和处理资源,帮助它更快地服务和满足请求。
如图所示,New Relic 显示了请求队列时间的一个大峰值。在本例中,这是等待向服务器分配更多资源的时间。
但是,纵向扩展也有一些缺点。其中之一就是,对单个实例的纵向扩展有实际的限制。第二个缺点是纵向扩展的成本非常高。当你没有无限的资源时,成本就会成为一个主要的问题和权衡因素。
如果一台服务器每秒可以满足 10 个用户请求,那么粗略估计,10 台服务器每秒可以满足 100 个请求。这样假设没问题,但在实际中,扩展并不是这么线性的。这被称为横向扩展。
我们将服务器配置为根据各种指标自动扩展。当服务器忙于处理活跃度增加的情况时,我们看到,等待延迟/排队时间出现了一个典型的小峰值。一旦追加的服务器启动完全,随着系统针对负载增加完成了调整,流量请求队列时间就会缩短。
随着活跃度的增加,我们自动启动了更多的服务器,这让我们可以处理增加的活跃度。
但也存在几项挑战:
水平扩展并不是全无问题。
代码库中有很多做法都不是线程安全的。例如,在代码库中使用类实例变量作为共享状态就非常普遍,这将导致线程之间相互覆写。我不得不花很多时间来研究,修改算法和代码,以便采用一种多线程环境安全的方式管理数据。
我还实现了更好的连接池和连接管理技术——我们经常会耗尽各种存储的连接数;因为许多是硬编码的,会在实例化时建立直接连接,这意味着,如果没有可用的连接,则应用程序实例将无法处理任何事务。
虽然在其他平台上也可以设置扩展,但我们使用的是 Heroku,而 Heroku 使扩展变得简单。
你可以控制可用 dynos 的数量,也可以增加每个 dynos 的能力。如果需要更细粒度的控制,像 HireFire 这种可以轻松集成的供应商提供了扩展配置选项,为你提供这种功能和灵活性。
还有与设置 Web 服务器并发性相关的东西。我们使用的是 Puma,它不仅可以通过WEB_CONCURRENCY
标识更改工作进程的数量,还可以更改每个进程的线程数。
纵向和纵向扩展可自定义为我们准备各种性能特征的站点提供了极大的灵活性。
这是一项长期的工作。我对扩展阈值做了很多次尝试,直到我们确定了一组能够平衡成本、性能和资源使用使其达到可接受水平的阈值。由于在不同的公司及环境中可接受级别会不同,所以我建议经常适当地测试扩展配置。
N+1 查询
N+1 查询是需要其他查询来获得数据全貌的查询。它们通常是由数据检索考虑不周全或架构问题造成的。
例如,假设有一个端点需要返回捐赠和捐赠者,其中可能就隐藏着一个 N+1 查询——首先必须通过查询来检索所有捐赠,然后对于每笔捐赠,再检索捐赠者。
通常,附加查询会隐藏在检索背后的序列化器中,特别是在 Ruby on Rails 中:
针对 N+1 查询的解决方案通常包括立即加载(eager loading)相关记录,确保可以在初始查询中获取这些数据:
找出隐藏的 N+1 查询可以减少我们的响应时间,有时可以大幅减少。
低效代码
在代码中,有很多实例是在不必要的情况下做一些资源密集型的事情。
首先是可以迁移到速度更快的库。有一些可用的库非常慢。对于序列化,在序列化较大的集合时,使用像oj这种速度更快的库可以大幅提高性能。
其次是需要支持流处理。
我们要处理很多 Excel 电子表格和其他批量数据报告和上传。最初编写的许多代码都是首先将整个电子表格加载到内存中,然后对其进行操作,这可能会消耗大量的时间、CPU 和内存。
先前许多代码在没有真正理解问题的情况下就试图进行优化。通常,这些解决方案都是将整个电子表格加载到内存中,并将内容推送到内存缓存中,这会带来很大的问题,因为电子表格还是在内存中。它消除了一个症状,而不是一个诱因,问题进一步恶化。
我不得不重写很多代码和算法来支持流处理,以最小化内存和 CPU 占用。这样一来,算法和代码就不必加载整个电子表格,加速效果显著。
再次是将集合遍历移到数据库中。
在数据库中可以轻松处理的操作,有很多代码是在应用程序中执行的。例如,迭代数千条记录求和,而不是再数据库中求和,或者为了访问单个字段而加载整个文档。
我具体做的一个代码优化是,用一个聚合数据库查询替换一个耗时几秒并运行多个查询的长时间计算。
这里讨论的查询会提取每一个做过捐献的用户,遍历每条记录,提取与该用户相关的标签(例如,“Student”、“Alumni”等),将它们组合起来,然后将结果归类到一组不同的标签。代码如下所示:
这段代码隐藏在活动页面渲染生命周期的最深处,每个请求都会调用。
加载活动页面的大部分时间都花在了数据库上(棕色)。
对于只有几个标签的小型捐赠日,这不是问题,也从来没有问题。然而,那一年有新情况,我们的一些大客户在捐赠日当天上传了成千上万个不同的标签。
我将该逻辑移到单个聚合查询中,如下所示,结果瞬间呈现:
我对代码做的一项优化,将大多数活动页面的加载时间从 2500 毫秒减少到 447 毫秒。
前后台资源优化
有些事情不需要在 Web 请求中立即处理——像发送电子邮件之类的事情可以延迟几秒钟,或者由系统中另一个完全不同的部分来处理。
这就是所谓的“后台”,它把本来应该按顺序分步骤完成的事情变为并行处理。如果你能够使请求周期的一部分异步处理,就意味着可以将响应更快地返回给用户,从而减少使用的资源。
所以我将所有对核心生命周期而言不是非常的东西都移到了后台,如电子邮件发送、上传、报表生成等等。
另外,我们的许多前端资产没有进行 gzip 压缩或优化。这是更改相当简单,可以将这些资产的加载时间减少 70%。
我们有一个部署脚本,可以将前端资产推送到 AWS S3。我所要做的就是生成和上传压缩后的 gzip 版本,同时通过设置内容编码和内容类型来告诉 S3 提供 gzip。
Webpack 配置如下所示:
内存泄漏
我花了大量时间查找内存泄漏,它会严重损害性能,应用程序会开始命中交换内存。
我们的临时方案是传统的“以特定频率重启服务器”,同时我们也在查找内存泄漏的真正原因。我大胆地调整了设置:我们更改了垃圾收集时间,替换了我们的序列化程序库,甚至将 Ruby 垃圾收集器改为 jemalloc。
内存泄漏的主题本身就可以写一篇文章,但是这里有两个非常有用的文章,可以帮你节省时间和精力:《我如何花两周的时间找出Ruby中的内存泄漏》、《借助jemalloc改进Ruby应用程序的内存使用和性能》。
服务区域
我们使用的某些服务是针对不同地区的,而不是我们服务器所在的地区。
我们的服务器在 N. Virginia(us-east-2),但是一些服务如 S3 在 Oregon(us-west-2)。当执行许多操作的工作流必须与该服务通信时,所导致的延迟会迅速增加。
这里增加几毫秒,那里增加几毫秒,延迟快速增加。通过确保服务位于同一地区,我们消除了不必要的延迟,大大加快了查询和操作。
解决性能瓶颈问题
上文说明了我用来提高性能的各种性能杠杆。然而,我很快发现,这些都是唾手可得的优化。
虽然这些调整在性能和稳定性方面带来的显著的提升,但很快你就会发现,系统中有一个单独的部分导致了绝大多数的性能、稳定性和扩展性问题。在这里,80/20 规则完全有效。这就是瓶颈。
在我加入后不久,在某一天快要结束的时候,我们突然疯狂收到来自客户成功团队的大量错误警告。
求救信号很清楚:网站瘫痪,无法使用。
浅绿色部分是请求排队时间。
上图说明了所发生的事情——负载的大幅增加使得站点长时间无法使用。随着数据库使用量的增加(黄色区域),每个请求处理的时间也在增加,导致其他请求开始阻塞和排队(淡绿色区域)。
令人印象深刻的是停机发生的速度。事情阻塞得非常非常快。白天所有的信号都还很好,然后突然服务器就过载了。我们当时执行了 SHE 标准操作流程,即启动更多的服务器。不幸的是,这没有任何效果——增加应用服务器的数量并不能解决这个问题,因为所有的 Web 请求都因为大量的计算延迟了。
万万没想到,这实际上使问题变得更糟了——向服务器发送更多请求给数据库带来了更多压力。
这是因为我们有一个缓存系统,最初大家都说它运行得很好。深入研究后,我发现了缓存实现的多个突出问题。其存在的重大缺陷使得缓存系统成为整个平台的单点故障。
缓存为王
让我们深入了解下缓存系统的工作原理。
cache_fields
将调用一个 mixin,它将属性访问封装在一个函数中,而该函数将在尝试访问属性(或函数结果)之前先查看缓存。
然而,如果一个值由于这样或那样的原因没有出现在 Redis 缓存中会怎么样?
处理缓存未命中
与所有缓存未命中一样,它会实时地重新计算并提供这个值,并将新计算出来的值保存到缓存中。然而,这也有一些问题。如果出现缓存未命中,那么请求将在高负载期间强制执行资源密集型计算。很明显,之前的开发人员已经考虑过这个问题——代码已经尝试提供了一个解决方案:计划缓存。
计划缓存
每隔 5 分钟,就会运行一个CacheUpdateJob
,它将更新所有设置了缓存的字段。理论上,这种缓存系统会很有效——通过定期缓存,系统就能够将内容保存在缓存中。然而,在实践中,这有一堆问题,这是我们在几个捐赠日中发现的。
缓存更新
一个主要的问题是填充和更新缓存的时间。CacheUpdateJob
将每 5 分钟运行一次,计算值,并将过期时间设置为从计算开始之后的 5 分钟。这是一个隐藏的问题。它基本上保证了CacheUpdateJob
只会在值被从缓存中删除之后才会更新。
Dog-Piling
当用户试图在某个值退出缓存后,但在CacheUpdateJob
重新缓存它之前访问该值时,就会导致缓存未命中,从而导致该值被实时计算。在人不多的时候,这是可以接受的,但是在大的捐赠日,它将针每个请求执行重新计算。
缓存失败导致内部服务器错误响应增加了 500 个,原因是超时。
在缓存未命中后,任何一个请求成功并将值插入到缓存中之前,所有访问该数据的请求都将执行一个资源密集型查询,这将显著增加资源使用,特别是在数据库 CPU。
对于一个需要密集计算才能得出的值,这意味着它会很快阻塞数据库的资源:
当出现多个缓存未命中时,数据库可能很快就会不堪重负。
此外,用户行为使问题复杂化,事情变得更糟。当用户遇到延迟时,他们会刷新页面并再次尝试,导致更多额外的负载:
反复重试长时间运行的数据库查询会导致我们失去从数据库读取数据的能力。
解决方案之纵向扩展
我实现的第一个解决方案是纵向扩展——增加数据库的资源配置。扩展数据库规模只是解决这个问题的权宜之计。在负载增加的时候,我们会再次遇到这个问题。这也是一个昂贵的解决方案——花费数千美元来纵向扩展数据库集群并不是一个合理的开支。
解决方案之横向扩展
我们有一个数据库集群,其中有没有以任何方式使用的读副本。我们可以将长时间运行的报表和其他对时间不敏感的查询转换为在读副本上运行,而不是在主副本上运行,从而在整个集群上分配负载,而不是在单个副本上。
解决方案之防止竞争条件
我们需要一种方法来防止系统因为一次又一次地重新计算相同的数据而超载。为了解决这个问题,我添加了一项功能,当多个请求同时请求重新生成缓存时,返回过期数据。只有一个请求会导致重新计算,其余的将获得过期数据,直到计算完成,而不是一再触发相同的计算。
Rails 通过race_condition_ttl
和expires_in
参数的组合来提供此项支持:
延迟
随着我们越来越成功,我们举办的活动也越来越多。当有数千个活动时,CacheUpdateJob
运行的时间越来越长。
有一天,我被告知,团队遇到了一个潜在的 Bug。他们几个小时前就把邮件插入队列了,但现在还没有人收到。我通过检查发现,通常只有几个作业的队列中有数十万个作业——全部是CacheUpdateJob
。
通过进一步的调查,我了解到,CacheUpdateJob
的运行时长已经超过了它的运行频率。
这意味着,虽然CacheUpdateJob
每 5 分钟运行一次,但完成它需要 10 分钟以上的时间。在此期间,值从缓存中消失,作业在队列中堆积。这也意味着CacheUpdateJob
一直在运行,这会导致相当大的资源使用。
它阻挡了所有其他作业的通过。
我们的解决方案是将各种作业分离到多个队列中,这样就可以独立地扩展它们。
邮件和其他用户触发的批量作业被放在一个队列中。事务性作业被放在另一个队列中。开销大的报表作业被放在第三个队列中。保持系统运行的作业,如CacheUpdateJob
,则被放在一个资源丰富的队列中。
这有助于确保任何队列中的阻塞都不会对系统的其他部分造成很大影响,也可以让我们在系统发生紧急情况时关闭系统中非必须的部分。
我们所做的另一项更改是将触发与执行分开,确保CacheUpdateJob
本身不执行该工作,而是将该职责传递给队列中的其他作业。这也使我们能够在将作业加入队列之前检查它是否存在。如果一项活动的队列中已经有一个缓存更新作业,那么针对同一项活动在队列中添加第二个缓存作业是没有意义的。
这使得我们可以独立于触发缓存更新的事件来扩展缓存更新处理,并以最优的方式进行。
在需要时进行批处理。我意识到,拆分作业的开销,从一开始就抵消了拆分作业带来的一些好处。我们实现了批处理,这样,CacheUpdateJob
就不会为每条记录创建一个新作业,而是将记录分到大约 100 个左右的自定义组中。这可以保证批次小,完成快,同时又为我们提供了我们一直期望的隔离。
只在需要时才进行缓存。通过检查,我们还发现,CacheUpdateJob
在不加选择地更新缓存——甚至是几年前举办的活动还在缓存中。我创建了一个设置机制,让我们可以针对每个活动定义缓存频率。
对于那些不经常访问的旧活动,我们不需要更新这些值。对于那些活跃的活动,我们更新得更频繁,它们的缓存优先级更高。
内存不足
随着“捐赠日”活动的开展,这项业务越来越成功。业务的增加意味着以前可接受的内存分配突然达到了极限。这意味着,在某一时刻,我们会突然发现,在向缓存中添加条目时出现了问题,而这会导致整个系统崩溃。
我们确定了其中一个原因——缓存服务器没有正确配置。我们的键失效处理被设置为永不失效,并在内存耗尽时抛出一个错误。这就是导致我们在负载增加的情况下达到内存限制的原因。解决方案看起来很简单——在我们的 Redis 缓存服务器上将键失效设置设定为volatile-lru
。理论上讲,这可以确保只有带有 TTL 的键才可能导致问题。
这给我们带来了系统设计过程中从未考虑过的其他挑战。我们有很多依赖于其他值的值需要重新计算,这些值又被用来计算其他值。因为缓存是临时构建的,而且相当随意,其中一些项缓存了,而另一些则没有,而且它们的 TTL 都不同。失效一段时间内没有使用过的键可能会引发一连串的更新失败,导致系统停止。
我们遇到了一个难题:
需要通过失效键来保证内存不会溢出;
任意键失效都可能会导致值重新生成失败;
从架构上讲,我们无法摆脱这些问题;
我们受运营成本限制,无法花钱扩大规模。
这个看似棘手的问题有一个不是很正规但简单的解决方案。
我在数据库层实现了缓存回退。
对于通过cache_fields
缓存的每个字段,我们还添加了相应的时间戳和缓存值:
cache_fields
函数会在缓存字段每次更新时创建和更新两个额外的属性:
cached_total_raised
cached_timestamp_total_raised
每当在 Redis 缓存中没有找到值时,它就会使用数据库中存储的值,数据库里的值永远不会过期。从数据库中取值比从 Redis 中取回要慢,但是比重新计算要快得多。
如果数据库中没有缓存的值,它将重新计算该值。
这确保了几乎在所有情况下,缓存的值都以一种或另一种形式存在,从而避免计算,除非该值被CacheUpdateJob
强制更新或客户成功团队请求手动更新这个值。
过期缓存
所有这些缓存都带来了一个问题——我们经常会遇到过期的、不准确的旧数据。通常,我们无法知道它的缓存级别。
我将通过我们遇到过的一种情况向你介绍下该问题带来的一些后果。
我只能将其描述为过于积极的查询缓存或 ORM 中的一个 Bug,如果连续运行,上述命令将返回相同的结果。
如果你立即运行以下,你会得到更有趣的结果:
奇怪的是,#count
会返回20
,而#to_a
会返回10
。
它导致了糟糕的用户体验。从用户体验的角度来看,这是不可接受的。当人们捐款时,他们希望看到新的捐款立即反映在总数中。他们不会想“哦,这个系统一定缓存了以前的值。”
同样,为了跟踪资金筹集进度,缓存的更新频率必须足够高。客户成功管理团队每天都与客户保持密切沟通,并提供进度报告。如果报告不是最新的,他们就无法这么做。
它还导致了一些非常严重的潜在错误。想象一下,如果你正在划定批量删除的集合。你可能认为正在删除这 20 条记录,但实际上正在删除的是一个类似查询返回的前一组记录。
这可能是个噩梦,希望你有良好的备份和审计表。
解决方案是构建缓存刷新工具。
我构建了多个工具,客户成功团队可以使用这些工具强制在一个特殊队列上刷新缓存,确保无论何时他们需要最新的数据,都能得到。我修改了缓存的属性访问器,使其接受并使用一组可选的参数。现在,只要我想,就可以在任何时候强制缓存刷新:
在对新鲜度敏感的操作中,这可以确保我每次处理的数据都是我希望处理的那部分。我还确保像关键报告这样的特性使用薄缓存层,并尽可能地使用最新数据。
最后的结果
在进行了所有优化之后,我们的系统可以处理我们预期的下一个数量级的负载——每秒 2000 多个请求,数千个并发活动。大多数面向捐赠者的端点加载时间不到 50ms,面向客户的页面加载时间不到 300ms。
这是一个漫长的旅程,有许多压力山大的部署,对比之前的极端条件下,3 到 5 个请求就可能会崩溃,这个提升结果是非常明显的。在大多数情况下,我们终于有了一个在“捐赠日”里可以不用太过费心的系统。
原文链接:
https://medium.com/swlh/how-i-scaled-a-software-systems-performance-by-35-000-6dacd63732df
评论 2 条评论