> An on-call developer's worst nightmare (red indicates errors)
深入探讨如何通过缓存,作业化,队列分离等解决平台的扩展性,稳定性和性能问题。
先前的公司建立了用于大规模捐赠日的支付系统和捐赠日软件,在该捐赠日中,我们为一次竞选活动就收到数万笔捐款。
我在那家公司的职责之一是扩展系统并确保其不会倾覆。 在最坏的情况下,每秒仅3–5个请求就会崩溃。
由于低效的体系结构,可疑的技术选择以及急速的开发,它具有许多限制,而且是创可贴和巨大的性能差距的拼凑而成。 魔咒和咒语的结合将使服务器全天运行。
在使用该平台时,它有潜力每秒处理数千个请求并同时运行数千个广告系列,而所有这些操作的成本大致相同。
怎么样? 我会告诉你!
在深入研究如何优化该系统之前,我们必须了解其使用模式以及要在其下进行优化的特定环境和约束,否则将是在黑暗中进行拍摄。
> RPS: Giving days started and ended suddenly.
提前几天安排好大规模的计划活动。 它们在非常特定的日期和时间开始和停止。 有时这些日期是可移动的。 有时不是。
在竞选期间,大力宣传捐赠的努力可能很大。
我们的系统可能在一天的开始就发送数十万封电子邮件,在整个活动期间定期跟踪电子邮件,以鼓励人们参观,参与,共享和捐赠。
社交媒体链接被发布在现有的每个网络平台上的任何地方,其中一些我从未听说过。
整个校园甚至还有实物海报,展位和传单。 有些客户甚至在整个24-48期间都进行了电视特辑。
鉴于以上所述,我们的资源使用情况可以最好地描述为尖峰和不变。
> CPU: mostly constant resource usage with occasional spikes in activity.
在奉献日的某些部分,例如一天的开始和社交媒体的协调推送,我们可以看到活动大量增加。 对于单个广告系列,我们可以在不到一秒钟的时间内从每秒0个请求增加到每秒150个请求。 这种缺乏加速的行为特征有时可能与DDoS难以区分。
在这些事件之外,资源使用情况是恒定的。 当用户与网站互动时,我们将看到捐赠和活动。
最终,当一天结束并且活动全部结束时,活动一开始就突然下降。
由于开始/结束日期是已知的,并且我们与客户紧密合作以找出他们当天的游戏计划,因此它可以为我们的服务器活动提供很多可预测性。 这种可预测性允许计划负载。
如果我们知道客户在日常活动中要达到的目标是什么,我们可以通过性能优化和调整服务器设置以最好地管理他们的预期负载来为此做准备。 可以通过一些基本计算来相对精确地估计其中的大部分。
现在我们知道要处理的使用方式,让我们简单地回顾一下我们已有的一些指标。 请记住,在优化之前,我们应该基准测试并衡量我们能做到的一切。
我们应该忘记效率低下的问题,大约有97%的时间是这样的:过早的优化是万恶之源。 然而,我们不应该放弃我们那关键的3%的机会。"
-唐纳德·努斯
正如他们所说:"测量两次,切割一次。"
对于我们的系统,我们可以将指标分为两类:
· 衡量活动的指标
· 衡量绩效的指标
测量活动很重要。 这是服务器性能的输入。
每秒的请求很简单。 问一个问题:我们的服务器每秒处理多少个请求? 更多意味着更多的活动。
CPU使用率是我们密切关注的另一项指标,用于检测系统不可用性。 密集的计算会导致系统备份,并且系统不应该首先对Web请求进行密集的计算。
内存使用情况是成败指标。 我们的服务器上只有这么多容量。 一些低效的代码是内存消耗,将成千上万个对象实例化到内存中。 这些内存泄漏被发现并被压缩。
由于我们使用的云服务提供商对连接数量有限制,因此连接计数值得关注。
性能的最大衡量标准是响应时间。 降低它意味着我们表现良好,提高它意味着我们表现不好。 诸如DataDog或NewRelic之类的APM工具可以向我们展示层级的响应时间,我们可以用来确定瓶颈。
从技术上讲,Heroku上的整体请求响应时间限制为30秒超时,实际上,我们希望大多数面向客户页面的请求能在3秒内完成。 我个人认为超过8秒的任何时间都被视为中断。
第50个百分位数通常在100毫秒以下,因为许多请求都是快速完成的API端点。
第99个百分位数可能会超过20秒而没有问题,因为某些管理页面仅花了一段时间才能完成。
我真正关心的是第95个百分点-我们希望95%的请求在3秒内完成。 这95%代表了大部分客户请求和参与,并代表了捐助者将经历的事情。
让我们看一下低挂的优化成果是什么:
· 垂直和水平缩放
· N + 1个查询
· 低效的代码
· 背景
· 资产最小化
· 内存泄漏
· 共置
我要做的第一件事就是增加每个服务器的功能-通过垂直扩展实现性能。 我为每个服务器提供了更多的内存和处理资源,以帮助更快地服务和满足请求。
> Here, New Relic is showing a large spike in request queue time. In this case, it was time spent wa
但是,垂直缩放具有一些缺点。 其中之一是您可以垂直扩展单个实例的数量有实际限制。
第二个缺点是垂直扩展会变得非常昂贵。 当您没有无限的资源时,成本将成为主要考虑因素,也是决定权衡因素的一个因素。
如果一台服务器每秒可满足10个用户请求,则粗略估算表明10台服务器每秒可满足100个请求。 实际上,它并不能完全线性地扩展,但是对于一个假设来说是很好的。 这称为水平缩放。
我们将服务器配置为根据各种指标自动扩展。 随着服务器启动以处理任何增加的活动,我们发现等待延迟/排队时间通常会出现一个小的峰值。 一旦额外的服务器完全启动,随着系统适应增加的负载,流量请求队列时间就会减少。
> As activity increased, we automatically spun up more servers, which allowed us to handle the incre
水平缩放并非一帆风顺。
在代码库中有很多不是线程安全的实践。 例如,在代码库中使用类实例变量作为共享状态非常流行,这导致线程彼此覆盖。我不得不花费大量时间来遍历它,并修改算法和代码来以某种方式管理数据 这对于多线程环境是安全的。
我还必须实现更好的连接池和管理技术-我们经常会耗尽与各个商店的连接,因为许多存储都是硬编码的,并在实例化时建立了直接连接,这意味着如果存在,应用程序实例将无法处理任何事务。 没有可用的连接。
虽然您可以并且应该在其他平台上设置缩放比例,但是我们使用的是Heroku,而Heroku使缩放变得容易。
您拥有可控制的测功机数量,并且具有增加每个测功机功率的能力。 如果您需要更细粒度的控件,那么像HireFire这样易于集成的供应商将提供扩展配置选项,这些功能可为您提供强大的功能和灵活性。
您还可以设置与网络服务器并发性相关的内容。 我们正在使用Puma,它不仅可以通过WEB_CONCURRENCY标志来更改工作程序的数量,还可以选择更改每个进程的线程数。
可自定义的垂直和水平缩放比例相结合,使我们在为各种性能特征准备场地时具有极大的灵活性。
这是一项长期的工作。 在确定将成本,性能和资源使用量平衡到可接受水平之前,我不得不在扩展阈值方面做很多工作。 由于可接受的级别在公司及其环境中会有所不同,因此我建议将其作为一种实践,以不断地适当地测试扩展配置。
N + 1查询是需要其他查询才能完整了解数据的查询。 它们通常是由于数据检索注意事项或体系结构问题而引起的。
例如,假设您有一个需要返回捐赠的端点和捐赠的捐赠者。 N + 1查询可能隐藏在其中-首先必须进行查询以检索所有捐赠,然后对于每次捐赠,还必须获取捐赠者记录。
通常,附加查询会隐藏在检索后的序列化器中,尤其是在Ruby on Rails中:
class DonationsController
def index donations = Donation.all
end
end
class DonationSerializer
belongs_to :donor
# This will result in a N+1 query (see above)
# because the query it is being used on doesn't load donors.
end
N + 1查询的解决方案通常包括急于加载相关记录并确保在初始查询中将其提取:
Donation.all.includes(:donor)
> Finding the hidden N+1 queries reduced our response times, sometimes drastically.
在代码中有很多实例,它们在不需要时执行资源密集型的工作。
一些可用的库非常慢。
对于序列化,在序列化较大的集合时,使用更快的库(例如oj)可以大大提高性能。
我们处理了很多Excel电子表格以及其他批量数据报告和上传。 最初编写了大量代码,首先将整个电子表格加载到内存中,然后对其进行操作,这可能会占用大量时间,CPU和内存。
许多先前的现有代码试图在不真正了解手头问题的情况下变得聪明和优化。 这些解决方案通常可以通过将整个工作表加载到内存中并将其推入内存高速缓存中来工作,这会导致重大问题,因为工作表仍在内存中。 它解决了使问题恶化的症状,而不是原因。
我不得不重写大量代码及其算法来支持流传输,以最大程度地减少内存和CPU占用空间。 这样一来,算法和代码就不必加载整个电子表格,这对加快处理速度具有重要作用。
当数据库可以轻松地处理它时,有很多代码可以在应用程序中执行操作。 示例包括遍历数千条记录以添加一些内容,而不是计算数据库中的总和,或者急于加载整个文档以访问单个字段。
我进行的一个特定代码优化涉及用一个总数据库查询替换耗时数秒并运行多个查询的长时间运行的计算。
有问题的查询是拉出每一个捐赠的用户,遍历每条记录,从该用户那里拉相关的标签(例如,"学生","校友"等),将它们全部合并,然后减少结果 放入一组不同的标签中
它看起来像下面的样子:
def get_unique_tags
all_tags = []
@cause.donations.each{
|donation| donation.cause.account.tags.each{
|cause_tag| all_tags << tag if donation.tags.include?(tag.value)
}
}
unique_tags = []
all_tags.each{
|tag| unique_tags << tag unless unique_tags.include?(tag)
}
end
隐藏在广告系列页面呈现生命周期最深处的此代码在每次单个请求时都被调用。
> Much of the page time spent loading the campaign page was spent in the database (brown).
对于只有几个标签的较小捐赠天数,这不是问题,也绝不是问题。 但是,那一年的新情况是,我们的一些大客户在捐赠当天上传了成千上万个不同的标签。
我将该逻辑移到单个聚合查询中,如下所示,结果是瞬时的:
> A code optimization I did reduced the load time of most campaign pages to 447ms, down from 2500ms.
有些事情不需要立即在网络请求中发生-诸如发送电子邮件之类的事情可能会延迟几秒钟,或者完全由系统的其他部分处理。
这被称为"背景",它会移动本应逐步执行的操作并使它们平行。
如果您可以使请求周期的一部分异步进行,则意味着响应将更快地返回给用户,从而减少了使用的资源。
我提供了对核心生命周期无关紧要的所有内容的背景信息:电子邮件发送,上传,报告生成等。
事实证明,我们的许多前端资产并未经过压缩或优化。 这是一个相当容易的更改,将这些资产的加载时间缩短了多达70%。
我们有一个部署脚本,可以将前端资产推送到AWS S3。 我要做的就是生成并上传压缩的压缩版本,同时告诉S3通过设置内容编码和内容类型来提供gzip。
如下所示的Webpack配置将执行此操作:
plugins.push(new CompressionPlugin({
test: /.(js|css)$/,}));
let s3Plugin = new S3Plugin({
s3Options: {
accessKeyId: <ACCESS_KEY_ID>,
secretAccessKey: <SECRET_ACCESS_KEY>,
region: <REGION>
},
s3UploadOptions: {
Bucket: <BUCKET>,
asset: '[path][query]',
ContentEncoding(fileName) {
if (/.gz/.test(fileName)) { return 'gzip' }
}, ContentType(fileName) {
if (/.css/.test(fileName)) { return 'text/css' }
if (/.js/.test(fileName)) { return 'text/JAVAscript' }
}
},
});
plugins.push(s3Plugin);
我花了大量时间来寻找内存泄漏,当我们开始使用交换内存时,内存泄漏极大地降低了性能(诅咒您,R14错误)。
在寻找导致泄漏的实际原因时,我们做了传统的"以特定频率重启服务器"创可贴。 我积极地调整了设置:我们更改了垃圾收集时间,交换了序列化程序库,甚至将ruby垃圾收集器更改为jemalloc
内存泄漏的主题完全是一篇文章,但是这里有两个非常有用的链接可以节省您的时间和精力:
· 我如何花两周的时间来寻找Ruby中的内存泄漏
· 使用jemalloc改善ruby应用程序的内存使用率和性能
我们使用的某些服务所关注的区域与服务器所在的区域不同。
我们的服务器位于弗吉尼亚北部(us-east-2),但某些服务(例如S3)位于俄勒冈州(us-west-2)。 当执行许多操作的工作流必须与该服务进行通信时,所产生的延迟会迅速加起来。
这里的几个MS和几个MS可以快速累加起来。 通过确保我们的服务位于同一区域,我们消除了不必要的延迟,从而极大地加快了查询和操作的速度。
上面的部分说明了我为提高性能而使用的各种性能杠杆。 但是,我很快发现,它们是低落的果实。
调整和拉动杠杆可以显著提高性能和稳定性,但很快就可以看出,系统的单个部分负责绝大部分的性能,稳定性和扩展性问题。 这完全是80/20规则。
这是瓶颈。 这是我的白鲸。
我加入公司后不久,就在一天结束的那一天,我们突然收到了来自客户成功团队的大量错误警报和疯狂消息。
SOS很清楚:该站点已关闭且无法使用。
> The pale green section is request queuing time.
上图说明了发生的情况-负载显着增加,导致该站点长时间无法使用。
随着数据库使用率的增加(黄色区域),每个请求处理的时间也增加了,导致其他请求开始备份和排队(浅绿色区域)。
令人印象深刻的是停机时间的速度。 事情非常非常迅速地备份。 白天所有信号都很好,然后服务器突然不堪重负。
当时我们执行了她的标准操作程序,这是启动更多服务器。
不幸的是,它的影响为零,因为大量的计算都延迟了所有Web请求,因此增加应用服务器的数量并不能解决问题。
与直觉相反,这实际上使问题变得更糟-向服务器提供更多请求使数据库承受更大压力。
发生了什么? 我们有一个缓存系统,从所有方面来看,它都运行良好。
深入研究,我发现如何实现缓存存在多个明显的问题。 大量的漏洞使缓存系统成为整个平台的单点故障。
让我们深入研究我们的缓存系统如何工作。
class Campaign
cache_fields :
first_name,
:total_raiseddef total_raised #
...complex calculation here endend
cache_fields将调用一个混合函数,该函数将对属性的访问包装在一个函数中,该函数将在尝试访问属性(或函数结果)之前先查看缓存。
但是,如果由于某种原因或其他原因在redis缓存中不存在值,会发生什么情况?
像所有高速缓存未命中一样,它将尝试实时重新计算该值并提供它,将新计算的值保存到高速缓存中。
但是,这有一些问题。 如果存在缓存丢失,请求将在高负载时间内强制执行资源密集型计算。
很明显,以前的开发人员曾考虑过这一点-代码已经尝试过一种解决方案:计划缓存。
每5分钟将运行CacheUpdateJob,它将更新所有设置为要缓存的字段。
该缓存系统在理论上运行良好-通过定期缓存,该系统可以将内容保留在缓存中。
但是,它在实践中存在很多问题,我们在几天的奉献中发现了这些问题。
问题的主要原因是缓存的填充和更新时间。
CacheUpdateJob将每5分钟运行一次,以尽责的方式计算值,并自计算之时起设置5分钟的到期时间。
这是一个隐藏的问题。 从本质上讲,它保证了CacheUpdateJob始终仅在值从高速缓存中丢失后才进行更新。
当用户尝试在某个值从缓存中调出之后但在CacheUpdateJob可以缓存新值之前尝试访问该值时,将导致缓存未命中,从而导致实时计算该值。
对于少量的人来说,这是可以接受的,但是在主要的捐赠日,它将为每个请求执行重新计算。
> Cache failures led to increased 500 Internal Server Error responses — a result of timeouts.
发生高速缓存未命中之后,直到任何一个请求成功完成并成功将值插入高速缓存为止,所有访问该数据的请求都将执行资源密集型查询,从而大大提高了使用率,尤其是在数据库CPU上 。
对于需要大量计算的值,这意味着它可以快速阻塞数据库的资源:
> When multiple cache misses occurred, the database could get overwhelmed quickly.
然后,用户的行为使问题更加复杂,并使整个问题变得更加糟糕。 当用户遇到延迟时,他们将刷新页面并重试,从而导致更多的额外负载:
> Long-running database queries retried repeatedly caused us to lose our ability to read from the da
我实施的首批解决方案之一是垂直扩展—改进了数据库的资源配置。
扩展数据库只是解决该问题的一个临时工具。 在负载增加的某个时刻,我们将再次遇到此问题。
这也是一个昂贵的解决方案-花数千美元垂直扩展数据库集群并不是一个合理的支出。
我们有一个数据库集群,其中没有以任何方式使用只读副本。 我们可以转换长期运行的报表和其他对时间敏感的查询,以便在只读副本而不是主副本上运行,从而将负载分布在整个集群上,而不是只分布在整个集群上。
我们需要一种方法,通过防止系统一次又一次地重新计算相同的精确数据来防止系统过载。
我解决了这一问题,方法是添加了在多个请求同时请求重新生成缓存时返回陈旧数据的功能。
只有一个请求会导致重新计算,其余请求将处理过时的数据,直到完成该计算,而不是一遍又一遍地触发相同的计算。
Rails通过race_condition_ttl和expires_in参数的组合来支持这一点:
Rails.cache.fetch(cache_key, race_condition_ttl: 30.seconds, expires_in: 15.minutes)
随着我们成功的成长,我们进行的竞选活动也增加了。 反过来,这使CacheUpdateJob花费的时间越来越长,才能遍历数千个广告系列。
有一天,我收到了团队遇到的潜在错误的通知。 他们已经在几个小时前将电子邮件排队,但没有人收到。 我检查并意识到,传统上只有几个作业的队列中有成千上万的作业-所有CacheUpdateJob。
调查进一步表明发生了什么事。 CacheUpdateJob达到了这样的程度,即作业的运行时间要比其运行的时间长。
这意味着,尽管CacheUpdateJob每5分钟运行一次,但要花费10多分钟才能完成。 在此期间,纸从缓存中丢失,并且作业在队列中堆积。 这也意味着CacheUpdateJob一直在运行,并收取相当可观的使用费。
这阻止了所有其他工作的进行。
这里的解决方案是将我们拥有的各种作业分成多个队列,我们可以独立扩展。
邮件程序和其他用户触发的批量作业被放在一个队列中。 事务性工作被放置在另一个中。 昂贵的报告作业被放置在第三个队列中。 使系统保持运行状态的作业(例如CacheUpdateJob)被放置在资源丰富的队列中。
这有助于确保任何一个队列中的备份不会对系统的其余部分造成很大的影响,并且使我们能够在紧急情况下关闭系统中不需要的部分。
我们进行的其他更改之一是确保CacheUpdateJob本身不会完成工作,并将此职责转移给它排队的其他作业。 这也使我们能够在排队之前检查重复作业的存在。 如果我们已经为某个广告系列排队等待缓存更新,则没有必要在队列中添加第二个作业以缓存同一广告系列。
这确保了我们可以与触发缓存更新的事物并行化并独立扩展缓存更新的处理,并以最佳方式进行。
我意识到,拆分成单个工作的开销抵消了最初将它们拆分出来的一些好处。
我们实施了批处理,以便CacheUpdateJob不会为每条记录创建一个新作业,而是将记录分为约100个左右的可自定义组。 这确保了批次较小且可以快速完成,同时仍为我们提供了所需的分离功能。
我们还查看了CacheUpdateJob,发现它正在不加区别地更新缓存-甚至缓存了几年前运行的活动。
我创建了一个设置机制,使我们可以确定每个广告系列缓存内容的频率。
对于不经常访问的旧版广告系列,我们无需费心更新这些值。 对于那些每天运行活跃的日子,我们更新的频率更高,并且它们具有更高的缓存优先级。
当我们付出很多天时,我们开始看到越来越多的企业成功。 业务量的增加意味着以前可以接受的内存分配突然达到了极限。
这意味着在某个时候,我们会突然开始发现我们无法将项目添加到缓存中而导致整个卡片卡瘫痪的能力出现了故障。
我们确定了原因之一-我们的缓存服务器配置不正确。
我们的主要逐出过程设置为永不撤离,并且在达到内存时抛出错误。 这就是导致我们在负载增加的情况下达到内存限制的原因。
解决方案看起来很简单-将Redis缓存服务器上的密钥逐出设置为volatile-lru。 从理论上讲,这将确保只有带有TTL的键才会引起问题。
这带来了系统从未设计过的其他挑战。 我们有很多值依赖于其他值进行重新计算,这些值又被用于计算其他值。
因为缓存是临时构建的,而且是偶然的,所以这些项目中的一些预计会被缓存,而其他则不会,并且它们都有不同的TTL。
收回一段时间未使用的密钥的行为可能会触发一系列的再生故障,从而使系统瘫痪。
我们有一个难题:
· 我们需要逐出密钥,以确保不会耗尽内存
· 如果我们收回任意密钥,将导致值再生失败
· 从架构上讲,我们无法过渡到这些查询
· 我们受到运营成本的限制,因此我们无法扩展$
这个看似棘手的问题虽然简单易懂,却有一个简单的解决方案。
我在数据库层实现了后备缓存。
对于我们通过cache_fields缓存的每个字段,我们还添加了随附的时间戳和缓存值:
cache_fields :total_raised
每当更新缓存的字段时,cache_fields函数将创建并更新两个额外的属性:
· cached_total_raised
· cached_timestamp_total_raised
每当在Redis缓存中找不到该值时,它将使用存储在数据库中的值,该值永远不会过期。 所得的提取速度比从Redis提取的速度慢,但比重新计算的速度快得多。
如果数据库中没有缓存的值,它将重新计算该值。
这确保了几乎在每种情况下,缓存值都以一种或另一种形式存在,从而阻止了计算的运行,除非该值由CacheUpdateJob强制更新或由客户成功团队要求手动更新。
所有这些缓存都导致了一个问题-我们经常会遇到陈旧且不再准确的旧数据。 我们通常不知道其缓存在什么级别。
我们遇到的情况将向您显示一些后果。
Account.find('12345a').campaigns.limit(10)
Account.find('12345a').campaigns.limit(20)
由于我只能将其描述为过于激进的查询缓存或ORM中的错误,因此如果连续运行,上述命令将返回相同的结果。
如果您之后立即执行以下操作,您将获得更多有趣的结果:
Account.find('12345a').campaigns.limit(20).count
Account.find('12345a').campaigns.limit(20).to_a.length
奇怪的是,#count将返回20,但是#to_a将返回10。
从用户体验的角度来看,这是不可接受的。 人们进行捐赠时,他们希望能够立即在总金额中看到新的捐赠。 他们不认为"哦,这个系统一定已经缓存了以前的值。"
同样,缓存必须足够频繁地更新以跟踪筹款活动的进度。 客户成功管理团队每天与客户保持密切联系,并且必须提供进度报告。 如果报告已过时,他们将无法做到这一点。
想象一下,如果要对集合进行范围界定以进行批量删除。 您以为您要删除20条记录,但实际上是在删除类似查询返回的先前的记录集。
这就是噩梦,我希望您拥有良好的备份和审核表。
我构建了多个工具,客户成功可使用这些工具来强制在特定队列上进行缓存刷新。 这样可以确保每当需要最新数据时,他们就可以拥有它们。
通过将缓存的属性访问器更改为接受并使用一组可选参数,我现在可以在需要的任何时候强制刷新缓存:
@campaign.total_raised(force_refresh: true)
在对新鲜度敏感的操作中,这将确保每次都处理正确类型的数据。
我还确保关键报告之类的功能使用了较薄的缓存层,并尽可能地利用了最新数据。
在所有优化的最后,我们有了一个系统,可以处理我们预期的下一个数量级的负载-每秒2000个以上的请求,数千个并发活动。 大多数面向捐助者的端点的加载时间均少于50ms,而面向客户页面的加载时间则在300ms之内。
这是一段漫长的旅程,进行了许多高压部署,但最终结果不言而喻。 最终,我们有了一个在赠予日中可以忽略的系统-大部分情况下。
(本文翻译自Joseph Gefroh的文章《How I Scaled a Software System's Performance By 35,000%》,参考:https://medium.com/swlh/how-i-scaled-a-software-systems-performance-by-35-000-6dacd63732df)