> An on-call developer's worst nightmare (red indicates errors)
深入探討如何通過緩存,作業(yè)化,隊(duì)列分離等解決平臺(tái)的擴(kuò)展性,穩(wěn)定性和性能問題。
一天處理超過$ 20,000,000
先前的公司建立了用于大規(guī)模捐贈(zèng)日的支付系統(tǒng)和捐贈(zèng)日軟件,在該捐贈(zèng)日中,我們?yōu)橐淮胃?jìng)選活動(dòng)就收到數(shù)萬筆捐款。
我在那家公司的職責(zé)之一是擴(kuò)展系統(tǒng)并確保其不會(huì)傾覆。 在最壞的情況下,每秒僅3–5個(gè)請(qǐng)求就會(huì)崩潰。
由于低效的體系結(jié)構(gòu),可疑的技術(shù)選擇以及急速的開發(fā),它具有許多限制,而且是創(chuàng)可貼和巨大的性能差距的拼湊而成。 魔咒和咒語的結(jié)合將使服務(wù)器全天運(yùn)行。
在使用該平臺(tái)時(shí),它有潛力每秒處理數(shù)千個(gè)請(qǐng)求并同時(shí)運(yùn)行數(shù)千個(gè)廣告系列,而所有這些操作的成本大致相同。
怎么樣? 我會(huì)告訴你!
分析使用模式
在深入研究如何優(yōu)化該系統(tǒng)之前,我們必須了解其使用模式以及要在其下進(jìn)行優(yōu)化的特定環(huán)境和約束,否則將是在黑暗中進(jìn)行拍攝。
給一天定義了開始和結(jié)束
> RPS: Giving days started and ended suddenly.
提前幾天安排好大規(guī)模的計(jì)劃活動(dòng)。 它們?cè)诜浅L囟ǖ娜掌诤蜁r(shí)間開始和停止。 有時(shí)這些日期是可移動(dòng)的。 有時(shí)不是。
強(qiáng)調(diào)分享
在競(jìng)選期間,大力宣傳捐贈(zèng)的努力可能很大。
我們的系統(tǒng)可能在一天的開始就發(fā)送數(shù)十萬封電子郵件,在整個(gè)活動(dòng)期間定期跟蹤電子郵件,以鼓勵(lì)人們參觀,參與,共享和捐贈(zèng)。
社交媒體鏈接被發(fā)布在現(xiàn)有的每個(gè)網(wǎng)絡(luò)平臺(tái)上的任何地方,其中一些我從未聽說過。
整個(gè)校園甚至還有實(shí)物海報(bào),展位和傳單。 有些客戶甚至在整個(gè)24-48期間都進(jìn)行了電視特輯。
活動(dòng)既尖刻又恒定
鑒于以上所述,我們的資源使用情況可以最好地描述為尖峰和不變。
> CPU: mostly constant resource usage with occasional spikes in activity.
在奉獻(xiàn)日的某些部分,例如一天的開始和社交媒體的協(xié)調(diào)推送,我們可以看到活動(dòng)大量增加。 對(duì)于單個(gè)廣告系列,我們可以在不到一秒鐘的時(shí)間內(nèi)從每秒0個(gè)請(qǐng)求增加到每秒150個(gè)請(qǐng)求。 這種缺乏加速的行為特征有時(shí)可能與DDoS難以區(qū)分。
在這些事件之外,資源使用情況是恒定的。 當(dāng)用戶與網(wǎng)站互動(dòng)時(shí),我們將看到捐贈(zèng)和活動(dòng)。
最終,當(dāng)一天結(jié)束并且活動(dòng)全部結(jié)束時(shí),活動(dòng)一開始就突然下降。
有遠(yuǎn)見的優(yōu)勢(shì)
由于開始/結(jié)束日期是已知的,并且我們與客戶緊密合作以找出他們當(dāng)天的游戲計(jì)劃,因此它可以為我們的服務(wù)器活動(dòng)提供很多可預(yù)測(cè)性。 這種可預(yù)測(cè)性允許計(jì)劃負(fù)載。
如果我們知道客戶在日常活動(dòng)中要達(dá)到的目標(biāo)是什么,我們可以通過性能優(yōu)化和調(diào)整服務(wù)器設(shè)置以最好地管理他們的預(yù)期負(fù)載來為此做準(zhǔn)備。 可以通過一些基本計(jì)算來相對(duì)精確地估計(jì)其中的大部分。
我們正在嘗試優(yōu)化什么?
現(xiàn)在我們知道要處理的使用方式,讓我們簡(jiǎn)單地回顧一下我們已有的一些指標(biāo)。 請(qǐng)記住,在優(yōu)化之前,我們應(yīng)該基準(zhǔn)測(cè)試并衡量我們能做到的一切。
我們應(yīng)該忘記效率低下的問題,大約有97%的時(shí)間是這樣的:過早的優(yōu)化是萬惡之源。 然而,我們不應(yīng)該放棄我們那關(guān)鍵的3%的機(jī)會(huì)。"
-唐納德·努斯
正如他們所說:"測(cè)量?jī)纱危懈钜淮巍?quot;
對(duì)于我們的系統(tǒng),我們可以將指標(biāo)分為兩類:
· 衡量活動(dòng)的指標(biāo)
· 衡量績(jī)效的指標(biāo)
測(cè)量活動(dòng)
測(cè)量活動(dòng)很重要。 這是服務(wù)器性能的輸入。
每秒的請(qǐng)求很簡(jiǎn)單。 問一個(gè)問題:我們的服務(wù)器每秒處理多少個(gè)請(qǐng)求? 更多意味著更多的活動(dòng)。
CPU使用率是我們密切關(guān)注的另一項(xiàng)指標(biāo),用于檢測(cè)系統(tǒng)不可用性。 密集的計(jì)算會(huì)導(dǎo)致系統(tǒng)備份,并且系統(tǒng)不應(yīng)該首先對(duì)Web請(qǐng)求進(jìn)行密集的計(jì)算。
內(nèi)存使用情況是成敗指標(biāo)。 我們的服務(wù)器上只有這么多容量。 一些低效的代碼是內(nèi)存消耗,將成千上萬個(gè)對(duì)象實(shí)例化到內(nèi)存中。 這些內(nèi)存泄漏被發(fā)現(xiàn)并被壓縮。
由于我們使用的云服務(wù)提供商對(duì)連接數(shù)量有限制,因此連接計(jì)數(shù)值得關(guān)注。
衡量績(jī)效
性能的最大衡量標(biāo)準(zhǔn)是響應(yīng)時(shí)間。 降低它意味著我們表現(xiàn)良好,提高它意味著我們表現(xiàn)不好。 諸如DataDog或NewRelic之類的APM工具可以向我們展示層級(jí)的響應(yīng)時(shí)間,我們可以用來確定瓶頸。
從技術(shù)上講,Heroku上的整體請(qǐng)求響應(yīng)時(shí)間限制為30秒超時(shí),實(shí)際上,我們希望大多數(shù)面向客戶頁面的請(qǐng)求能在3秒內(nèi)完成。 我個(gè)人認(rèn)為超過8秒的任何時(shí)間都被視為中斷。
第50個(gè)百分位數(shù)通常在100毫秒以下,因?yàn)樵S多請(qǐng)求都是快速完成的API端點(diǎn)。
第99個(gè)百分位數(shù)可能會(huì)超過20秒而沒有問題,因?yàn)槟承┕芾眄撁鎯H花了一段時(shí)間才能完成。
我真正關(guān)心的是第95個(gè)百分點(diǎn)-我們希望95%的請(qǐng)求在3秒內(nèi)完成。 這95%代表了大部分客戶請(qǐng)求和參與,并代表了捐助者將經(jīng)歷的事情。
低掛優(yōu)化
讓我們看一下低掛的優(yōu)化成果是什么:
· 垂直和水平縮放
· N + 1個(gè)查詢
· 低效的代碼
· 背景
· 資產(chǎn)最小化
· 內(nèi)存泄漏
· 共置
垂直和水平縮放
垂直縮放
我要做的第一件事就是增加每個(gè)服務(wù)器的功能-通過垂直擴(kuò)展實(shí)現(xiàn)性能。 我為每個(gè)服務(wù)器提供了更多的內(nèi)存和處理資源,以幫助更快地服務(wù)和滿足請(qǐng)求。
> Here, New Relic is showing a large spike in request queue time. In this case, it was time spent wa
但是,垂直縮放具有一些缺點(diǎn)。 其中之一是您可以垂直擴(kuò)展單個(gè)實(shí)例的數(shù)量有實(shí)際限制。
第二個(gè)缺點(diǎn)是垂直擴(kuò)展會(huì)變得非常昂貴。 當(dāng)您沒有無限的資源時(shí),成本將成為主要考慮因素,也是決定權(quán)衡因素的一個(gè)因素。
水平縮放
如果一臺(tái)服務(wù)器每秒可滿足10個(gè)用戶請(qǐng)求,則粗略估算表明10臺(tái)服務(wù)器每秒可滿足100個(gè)請(qǐng)求。 實(shí)際上,它并不能完全線性地?cái)U(kuò)展,但是對(duì)于一個(gè)假設(shè)來說是很好的。 這稱為水平縮放。
我們將服務(wù)器配置為根據(jù)各種指標(biāo)自動(dòng)擴(kuò)展。 隨著服務(wù)器啟動(dòng)以處理任何增加的活動(dòng),我們發(fā)現(xiàn)等待延遲/排隊(duì)時(shí)間通常會(huì)出現(xiàn)一個(gè)小的峰值。 一旦額外的服務(wù)器完全啟動(dòng),隨著系統(tǒng)適應(yīng)增加的負(fù)載,流量請(qǐng)求隊(duì)列時(shí)間就會(huì)減少。
> As activity increased, we automatically spun up more servers, which allowed us to handle the incre
幾個(gè)挑戰(zhàn)
水平縮放并非一帆風(fēng)順。
在代碼庫中有很多不是線程安全的實(shí)踐。 例如,在代碼庫中使用類實(shí)例變量作為共享狀態(tài)非常流行,這導(dǎo)致線程彼此覆蓋。我不得不花費(fèi)大量時(shí)間來遍歷它,并修改算法和代碼來以某種方式管理數(shù)據(jù) 這對(duì)于多線程環(huán)境是安全的。
我還必須實(shí)現(xiàn)更好的連接池和管理技術(shù)-我們經(jīng)常會(huì)耗盡與各個(gè)商店的連接,因?yàn)樵S多存儲(chǔ)都是硬編碼的,并在實(shí)例化時(shí)建立了直接連接,這意味著如果存在,應(yīng)用程序?qū)嵗龑o法處理任何事務(wù)。 沒有可用的連接。
在Heroku上縮放
雖然您可以并且應(yīng)該在其他平臺(tái)上設(shè)置縮放比例,但是我們使用的是Heroku,而Heroku使縮放變得容易。
您擁有可控制的測(cè)功機(jī)數(shù)量,并且具有增加每個(gè)測(cè)功機(jī)功率的能力。 如果您需要更細(xì)粒度的控件,那么像HireFire這樣易于集成的供應(yīng)商將提供擴(kuò)展配置選項(xiàng),這些功能可為您提供強(qiáng)大的功能和靈活性。
您還可以設(shè)置與網(wǎng)絡(luò)服務(wù)器并發(fā)性相關(guān)的內(nèi)容。 我們正在使用Puma,它不僅可以通過WEB_CONCURRENCY標(biāo)志來更改工作程序的數(shù)量,還可以選擇更改每個(gè)進(jìn)程的線程數(shù)。
結(jié)果
可自定義的垂直和水平縮放比例相結(jié)合,使我們?cè)跒楦鞣N性能特征準(zhǔn)備場(chǎng)地時(shí)具有極大的靈活性。
這是一項(xiàng)長期的工作。 在確定將成本,性能和資源使用量平衡到可接受水平之前,我不得不在擴(kuò)展閾值方面做很多工作。 由于可接受的級(jí)別在公司及其環(huán)境中會(huì)有所不同,因此我建議將其作為一種實(shí)踐,以不斷地適當(dāng)?shù)販y(cè)試擴(kuò)展配置。
N + 1個(gè)查詢
N + 1查詢是需要其他查詢才能完整了解數(shù)據(jù)的查詢。 它們通常是由于數(shù)據(jù)檢索注意事項(xiàng)或體系結(jié)構(gòu)問題而引起的。
例如,假設(shè)您有一個(gè)需要返回捐贈(zèng)的端點(diǎn)和捐贈(zèng)的捐贈(zèng)者。 N + 1查詢可能隱藏在其中-首先必須進(jìn)行查詢以檢索所有捐贈(zèng),然后對(duì)于每次捐贈(zèng),還必須獲取捐贈(zèng)者記錄。
通常,附加查詢會(huì)隱藏在檢索后的序列化器中,尤其是在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查詢的解決方案通常包括急于加載相關(guān)記錄并確保在初始查詢中將其提取:
Donation.all.includes(:donor)
> Finding the hidden N+1 queries reduced our response times, sometimes drastically.
低效的代碼
在代碼中有很多實(shí)例,它們?cè)诓恍枰獣r(shí)執(zhí)行資源密集型的工作。
轉(zhuǎn)向更快的庫
一些可用的庫非常慢。
對(duì)于序列化,在序列化較大的集合時(shí),使用更快的庫(例如oj)可以大大提高性能。
流媒體
我們處理了很多Excel電子表格以及其他批量數(shù)據(jù)報(bào)告和上傳。 最初編寫了大量代碼,首先將整個(gè)電子表格加載到內(nèi)存中,然后對(duì)其進(jìn)行操作,這可能會(huì)占用大量時(shí)間,CPU和內(nèi)存。
許多先前的現(xiàn)有代碼試圖在不真正了解手頭問題的情況下變得聰明和優(yōu)化。 這些解決方案通常可以通過將整個(gè)工作表加載到內(nèi)存中并將其推入內(nèi)存高速緩存中來工作,這會(huì)導(dǎo)致重大問題,因?yàn)楣ぷ鞅砣栽趦?nèi)存中。 它解決了使問題惡化的癥狀,而不是原因。
我不得不重寫大量代碼及其算法來支持流傳輸,以最大程度地減少內(nèi)存和CPU占用空間。 這樣一來,算法和代碼就不必加載整個(gè)電子表格,這對(duì)加快處理速度具有重要作用。
將集合遍歷移動(dòng)到數(shù)據(jù)庫
當(dāng)數(shù)據(jù)庫可以輕松地處理它時(shí),有很多代碼可以在應(yīng)用程序中執(zhí)行操作。 示例包括遍歷數(shù)千條記錄以添加一些內(nèi)容,而不是計(jì)算數(shù)據(jù)庫中的總和,或者急于加載整個(gè)文檔以訪問單個(gè)字段。
我進(jìn)行的一個(gè)特定代碼優(yōu)化涉及用一個(gè)總數(shù)據(jù)庫查詢替換耗時(shí)數(shù)秒并運(yùn)行多個(gè)查詢的長時(shí)間運(yùn)行的計(jì)算。
有問題的查詢是拉出每一個(gè)捐贈(zèng)的用戶,遍歷每條記錄,從該用戶那里拉相關(guān)的標(biāo)簽(例如,"學(xué)生","校友"等),將它們?nèi)亢喜ⅲ缓鬁p少結(jié)果 放入一組不同的標(biāo)簽中
它看起來像下面的樣子:
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
隱藏在廣告系列頁面呈現(xiàn)生命周期最深處的此代碼在每次單個(gè)請(qǐng)求時(shí)都被調(diào)用。
> Much of the page time spent loading the campaign page was spent in the database (brown).
對(duì)于只有幾個(gè)標(biāo)簽的較小捐贈(zèng)天數(shù),這不是問題,也絕不是問題。 但是,那一年的新情況是,我們的一些大客戶在捐贈(zèng)當(dāng)天上傳了成千上萬個(gè)不同的標(biāo)簽。
我將該邏輯移到單個(gè)聚合查詢中,如下所示,結(jié)果是瞬時(shí)的:
> A code optimization I did reduced the load time of most campaign pages to 447ms, down from 2500ms.
背景
有些事情不需要立即在網(wǎng)絡(luò)請(qǐng)求中發(fā)生-諸如發(fā)送電子郵件之類的事情可能會(huì)延遲幾秒鐘,或者完全由系統(tǒng)的其他部分處理。
這被稱為"背景",它會(huì)移動(dòng)本應(yīng)逐步執(zhí)行的操作并使它們平行。
如果您可以使請(qǐng)求周期的一部分異步進(jìn)行,則意味著響應(yīng)將更快地返回給用戶,從而減少了使用的資源。
我提供了對(duì)核心生命周期無關(guān)緊要的所有內(nèi)容的背景信息:電子郵件發(fā)送,上傳,報(bào)告生成等。
資產(chǎn)最小化
事實(shí)證明,我們的許多前端資產(chǎn)并未經(jīng)過壓縮或優(yōu)化。 這是一個(gè)相當(dāng)容易的更改,將這些資產(chǎn)的加載時(shí)間縮短了多達(dá)70%。
我們有一個(gè)部署腳本,可以將前端資產(chǎn)推送到AWS S3。 我要做的就是生成并上傳壓縮的壓縮版本,同時(shí)告訴S3通過設(shè)置內(nèi)容編碼和內(nèi)容類型來提供gzip。
如下所示的Webpack配置將執(zhí)行此操作:
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);
內(nèi)存泄漏
我花了大量時(shí)間來尋找內(nèi)存泄漏,當(dāng)我們開始使用交換內(nèi)存時(shí),內(nèi)存泄漏極大地降低了性能(詛咒您,R14錯(cuò)誤)。
在尋找導(dǎo)致泄漏的實(shí)際原因時(shí),我們做了傳統(tǒng)的"以特定頻率重啟服務(wù)器"創(chuàng)可貼。 我積極地調(diào)整了設(shè)置:我們更改了垃圾收集時(shí)間,交換了序列化程序庫,甚至將ruby垃圾收集器更改為jemalloc
內(nèi)存泄漏的主題完全是一篇文章,但是這里有兩個(gè)非常有用的鏈接可以節(jié)省您的時(shí)間和精力:
· 我如何花兩周的時(shí)間來尋找Ruby中的內(nèi)存泄漏
· 使用jemalloc改善ruby應(yīng)用程序的內(nèi)存使用率和性能
代管
我們使用的某些服務(wù)所關(guān)注的區(qū)域與服務(wù)器所在的區(qū)域不同。
我們的服務(wù)器位于弗吉尼亞北部(us-east-2),但某些服務(wù)(例如S3)位于俄勒岡州(us-west-2)。 當(dāng)執(zhí)行許多操作的工作流必須與該服務(wù)進(jìn)行通信時(shí),所產(chǎn)生的延遲會(huì)迅速加起來。
這里的幾個(gè)MS和幾個(gè)MS可以快速累加起來。 通過確保我們的服務(wù)位于同一區(qū)域,我們消除了不必要的延遲,從而極大地加快了查詢和操作的速度。
帕累托再次罷工
上面的部分說明了我為提高性能而使用的各種性能杠桿。 但是,我很快發(fā)現(xiàn),它們是低落的果實(shí)。
調(diào)整和拉動(dòng)杠桿可以顯著提高性能和穩(wěn)定性,但很快就可以看出,系統(tǒng)的單個(gè)部分負(fù)責(zé)絕大部分的性能,穩(wěn)定性和擴(kuò)展性問題。 這完全是80/20規(guī)則。
這是瓶頸。 這是我的白鯨。
停機(jī)時(shí)間剖析
我加入公司后不久,就在一天結(jié)束的那一天,我們突然收到了來自客戶成功團(tuán)隊(duì)的大量錯(cuò)誤警報(bào)和瘋狂消息。
SOS很清楚:該站點(diǎn)已關(guān)閉且無法使用。
> The pale green section is request queuing time.
上圖說明了發(fā)生的情況-負(fù)載顯著增加,導(dǎo)致該站點(diǎn)長時(shí)間無法使用。
隨著數(shù)據(jù)庫使用率的增加(黃色區(qū)域),每個(gè)請(qǐng)求處理的時(shí)間也增加了,導(dǎo)致其他請(qǐng)求開始備份和排隊(duì)(淺綠色區(qū)域)。
令人印象深刻的是停機(jī)時(shí)間的速度。 事情非常非常迅速地備份。 白天所有信號(hào)都很好,然后服務(wù)器突然不堪重負(fù)。
過時(shí)的事件響應(yīng)手冊(cè)
當(dāng)時(shí)我們執(zhí)行了她的標(biāo)準(zhǔn)操作程序,這是啟動(dòng)更多服務(wù)器。
不幸的是,它的影響為零,因?yàn)榇罅康挠?jì)算都延遲了所有Web請(qǐng)求,因此增加應(yīng)用服務(wù)器的數(shù)量并不能解決問題。
與直覺相反,這實(shí)際上使問題變得更糟-向服務(wù)器提供更多請(qǐng)求使數(shù)據(jù)庫承受更大壓力。
是什么原因造成的?
發(fā)生了什么? 我們有一個(gè)緩存系統(tǒng),從所有方面來看,它都運(yùn)行良好。
深入研究,我發(fā)現(xiàn)如何實(shí)現(xiàn)緩存存在多個(gè)明顯的問題。 大量的漏洞使緩存系統(tǒng)成為整個(gè)平臺(tái)的單點(diǎn)故障。
緩存為王
讓我們深入研究我們的緩存系統(tǒng)如何工作。
class Campaign
cache_fields :
first_name,
:total_raiseddef total_raised #
...complex calculation here endend
cache_fields將調(diào)用一個(gè)混合函數(shù),該函數(shù)將對(duì)屬性的訪問包裝在一個(gè)函數(shù)中,該函數(shù)將在嘗試訪問屬性(或函數(shù)結(jié)果)之前先查看緩存。
但是,如果由于某種原因或其他原因在redis緩存中不存在值,會(huì)發(fā)生什么情況?
處理緩存未命中
像所有高速緩存未命中一樣,它將嘗試實(shí)時(shí)重新計(jì)算該值并提供它,將新計(jì)算的值保存到高速緩存中。
但是,這有一些問題。 如果存在緩存丟失,請(qǐng)求將在高負(fù)載時(shí)間內(nèi)強(qiáng)制執(zhí)行資源密集型計(jì)算。
很明顯,以前的開發(fā)人員曾考慮過這一點(diǎn)-代碼已經(jīng)嘗試過一種解決方案:計(jì)劃緩存。
按計(jì)劃緩存
每5分鐘將運(yùn)行CacheUpdateJob,它將更新所有設(shè)置為要緩存的字段。
該緩存系統(tǒng)在理論上運(yùn)行良好-通過定期緩存,該系統(tǒng)可以將內(nèi)容保留在緩存中。
但是,它在實(shí)踐中存在很多問題,我們?cè)趲滋斓姆瞰I(xiàn)中發(fā)現(xiàn)了這些問題。
緩存更新
問題的主要原因是緩存的填充和更新時(shí)間。
CacheUpdateJob將每5分鐘運(yùn)行一次,以盡責(zé)的方式計(jì)算值,并自計(jì)算之時(shí)起設(shè)置5分鐘的到期時(shí)間。
這是一個(gè)隱藏的問題。 從本質(zhì)上講,它保證了CacheUpdateJob始終僅在值從高速緩存中丟失后才進(jìn)行更新。
緩存未命中
當(dāng)用戶嘗試在某個(gè)值從緩存中調(diào)出之后但在CacheUpdateJob可以緩存新值之前嘗試訪問該值時(shí),將導(dǎo)致緩存未命中,從而導(dǎo)致實(shí)時(shí)計(jì)算該值。
對(duì)于少量的人來說,這是可以接受的,但是在主要的捐贈(zèng)日,它將為每個(gè)請(qǐng)求執(zhí)行重新計(jì)算。
> Cache failures led to increased 500 Internal Server Error responses — a result of timeouts.
發(fā)生高速緩存未命中之后,直到任何一個(gè)請(qǐng)求成功完成并成功將值插入高速緩存為止,所有訪問該數(shù)據(jù)的請(qǐng)求都將執(zhí)行資源密集型查詢,從而大大提高了使用率,尤其是在數(shù)據(jù)庫CPU上 。
對(duì)于需要大量計(jì)算的值,這意味著它可以快速阻塞數(shù)據(jù)庫的資源:
> When multiple cache misses occurred, the database could get overwhelmed quickly.
然后,用戶的行為使問題更加復(fù)雜,并使整個(gè)問題變得更加糟糕。 當(dāng)用戶遇到延遲時(shí),他們將刷新頁面并重試,從而導(dǎo)致更多的額外負(fù)載:
> Long-running database queries retried repeatedly caused us to lose our ability to read from the da
解決方案的前三分之一-垂直縮放
我實(shí)施的首批解決方案之一是垂直擴(kuò)展—改進(jìn)了數(shù)據(jù)庫的資源配置。
擴(kuò)展數(shù)據(jù)庫只是解決該問題的一個(gè)臨時(shí)工具。 在負(fù)載增加的某個(gè)時(shí)刻,我們將再次遇到此問題。
這也是一個(gè)昂貴的解決方案-花數(shù)千美元垂直擴(kuò)展數(shù)據(jù)庫集群并不是一個(gè)合理的支出。
解決方案的第二個(gè)三分之一-水平縮放
我們有一個(gè)數(shù)據(jù)庫集群,其中沒有以任何方式使用只讀副本。 我們可以轉(zhuǎn)換長期運(yùn)行的報(bào)表和其他對(duì)時(shí)間敏感的查詢,以便在只讀副本而不是主副本上運(yùn)行,從而將負(fù)載分布在整個(gè)集群上,而不是只分布在整個(gè)集群上。
解決方案的最后三分之一-防止比賽條件
我們需要一種方法,通過防止系統(tǒng)一次又一次地重新計(jì)算相同的精確數(shù)據(jù)來防止系統(tǒng)過載。
我解決了這一問題,方法是添加了在多個(gè)請(qǐng)求同時(shí)請(qǐng)求重新生成緩存時(shí)返回陳舊數(shù)據(jù)的功能。
只有一個(gè)請(qǐng)求會(huì)導(dǎo)致重新計(jì)算,其余請(qǐng)求將處理過時(shí)的數(shù)據(jù),直到完成該計(jì)算,而不是一遍又一遍地觸發(fā)相同的計(jì)算。
Rails通過race_condition_ttl和expires_in參數(shù)的組合來支持這一點(diǎn):
Rails.cache.fetch(cache_key, race_condition_ttl: 30.seconds, expires_in: 15.minutes)
火車不準(zhǔn)時(shí)
隨著我們成功的成長,我們進(jìn)行的競(jìng)選活動(dòng)也增加了。 反過來,這使CacheUpdateJob花費(fèi)的時(shí)間越來越長,才能遍歷數(shù)千個(gè)廣告系列。
有一天,我收到了團(tuán)隊(duì)遇到的潛在錯(cuò)誤的通知。 他們已經(jīng)在幾個(gè)小時(shí)前將電子郵件排隊(duì),但沒有人收到。 我檢查并意識(shí)到,傳統(tǒng)上只有幾個(gè)作業(yè)的隊(duì)列中有成千上萬的作業(yè)-所有CacheUpdateJob。
調(diào)查進(jìn)一步表明發(fā)生了什么事。 CacheUpdateJob達(dá)到了這樣的程度,即作業(yè)的運(yùn)行時(shí)間要比其運(yùn)行的時(shí)間長。
這意味著,盡管CacheUpdateJob每5分鐘運(yùn)行一次,但要花費(fèi)10多分鐘才能完成。 在此期間,紙從緩存中丟失,并且作業(yè)在隊(duì)列中堆積。 這也意味著CacheUpdateJob一直在運(yùn)行,并收取相當(dāng)可觀的使用費(fèi)。
這阻止了所有其他工作的進(jìn)行。
分成多個(gè)隊(duì)列
這里的解決方案是將我們擁有的各種作業(yè)分成多個(gè)隊(duì)列,我們可以獨(dú)立擴(kuò)展。
郵件程序和其他用戶觸發(fā)的批量作業(yè)被放在一個(gè)隊(duì)列中。 事務(wù)性工作被放置在另一個(gè)中。 昂貴的報(bào)告作業(yè)被放置在第三個(gè)隊(duì)列中。 使系統(tǒng)保持運(yùn)行狀態(tài)的作業(yè)(例如CacheUpdateJob)被放置在資源豐富的隊(duì)列中。
這有助于確保任何一個(gè)隊(duì)列中的備份不會(huì)對(duì)系統(tǒng)的其余部分造成很大的影響,并且使我們能夠在緊急情況下關(guān)閉系統(tǒng)中不需要的部分。
將觸發(fā)器與執(zhí)行分開
我們進(jìn)行的其他更改之一是確保CacheUpdateJob本身不會(huì)完成工作,并將此職責(zé)轉(zhuǎn)移給它排隊(duì)的其他作業(yè)。 這也使我們能夠在排隊(duì)之前檢查重復(fù)作業(yè)的存在。 如果我們已經(jīng)為某個(gè)廣告系列排隊(duì)等待緩存更新,則沒有必要在隊(duì)列中添加第二個(gè)作業(yè)以緩存同一廣告系列。
這確保了我們可以與觸發(fā)緩存更新的事物并行化并獨(dú)立擴(kuò)展緩存更新的處理,并以最佳方式進(jìn)行。
在需要的地方分批
我意識(shí)到,拆分成單個(gè)工作的開銷抵消了最初將它們拆分出來的一些好處。
我們實(shí)施了批處理,以便CacheUpdateJob不會(huì)為每條記錄創(chuàng)建一個(gè)新作業(yè),而是將記錄分為約100個(gè)左右的可自定義組。 這確保了批次較小且可以快速完成,同時(shí)仍為我們提供了所需的分離功能。
僅緩存所需的內(nèi)容
我們還查看了CacheUpdateJob,發(fā)現(xiàn)它正在不加區(qū)別地更新緩存-甚至緩存了幾年前運(yùn)行的活動(dòng)。
我創(chuàng)建了一個(gè)設(shè)置機(jī)制,使我們可以確定每個(gè)廣告系列緩存內(nèi)容的頻率。
對(duì)于不經(jīng)常訪問的舊版廣告系列,我們無需費(fèi)心更新這些值。 對(duì)于那些每天運(yùn)行活躍的日子,我們更新的頻率更高,并且它們具有更高的緩存優(yōu)先級(jí)。
內(nèi)存不足
當(dāng)我們付出很多天時(shí),我們開始看到越來越多的企業(yè)成功。 業(yè)務(wù)量的增加意味著以前可以接受的內(nèi)存分配突然達(dá)到了極限。
這意味著在某個(gè)時(shí)候,我們會(huì)突然開始發(fā)現(xiàn)我們無法將項(xiàng)目添加到緩存中而導(dǎo)致整個(gè)卡片卡癱瘓的能力出現(xiàn)了故障。
主要搬遷
我們確定了原因之一-我們的緩存服務(wù)器配置不正確。
我們的主要逐出過程設(shè)置為永不撤離,并且在達(dá)到內(nèi)存時(shí)拋出錯(cuò)誤。 這就是導(dǎo)致我們?cè)谪?fù)載增加的情況下達(dá)到內(nèi)存限制的原因。
解決方案看起來很簡(jiǎn)單-將Redis緩存服務(wù)器上的密鑰逐出設(shè)置為volatile-lru。 從理論上講,這將確保只有帶有TTL的鍵才會(huì)引起問題。
如果真那么容易就好了
這帶來了系統(tǒng)從未設(shè)計(jì)過的其他挑戰(zhàn)。 我們有很多值依賴于其他值進(jìn)行重新計(jì)算,這些值又被用于計(jì)算其他值。
因?yàn)榫彺媸桥R時(shí)構(gòu)建的,而且是偶然的,所以這些項(xiàng)目中的一些預(yù)計(jì)會(huì)被緩存,而其他則不會(huì),并且它們都有不同的TTL。
收回一段時(shí)間未使用的密鑰的行為可能會(huì)觸發(fā)一系列的再生故障,從而使系統(tǒng)癱瘓。
我們有一個(gè)難題:
· 我們需要逐出密鑰,以確保不會(huì)耗盡內(nèi)存
· 如果我們收回任意密鑰,將導(dǎo)致值再生失敗
· 從架構(gòu)上講,我們無法過渡到這些查詢
· 我們受到運(yùn)營成本的限制,因此我們無法擴(kuò)展$
這個(gè)看似棘手的問題雖然簡(jiǎn)單易懂,卻有一個(gè)簡(jiǎn)單的解決方案。
后備緩存
我在數(shù)據(jù)庫層實(shí)現(xiàn)了后備緩存。
對(duì)于我們通過cache_fields緩存的每個(gè)字段,我們還添加了隨附的時(shí)間戳和緩存值:
cache_fields :total_raised
每當(dāng)更新緩存的字段時(shí),cache_fields函數(shù)將創(chuàng)建并更新兩個(gè)額外的屬性:
· cached_total_raised
· cached_timestamp_total_raised
每當(dāng)在Redis緩存中找不到該值時(shí),它將使用存儲(chǔ)在數(shù)據(jù)庫中的值,該值永遠(yuǎn)不會(huì)過期。 所得的提取速度比從Redis提取的速度慢,但比重新計(jì)算的速度快得多。
如果數(shù)據(jù)庫中沒有緩存的值,它將重新計(jì)算該值。
這確保了幾乎在每種情況下,緩存值都以一種或另一種形式存在,從而阻止了計(jì)算的運(yùn)行,除非該值由CacheUpdateJob強(qiáng)制更新或由客戶成功團(tuán)隊(duì)要求手動(dòng)更新。
陳舊的緩存
所有這些緩存都導(dǎo)致了一個(gè)問題-我們經(jīng)常會(huì)遇到陳舊且不再準(zhǔn)確的舊數(shù)據(jù)。 我們通常不知道其緩存在什么級(jí)別。
一個(gè)小例子
我們遇到的情況將向您顯示一些后果。
Account.find('12345a').campaigns.limit(10)
Account.find('12345a').campaigns.limit(20)
由于我只能將其描述為過于激進(jìn)的查詢緩存或ORM中的錯(cuò)誤,因此如果連續(xù)運(yùn)行,上述命令將返回相同的結(jié)果。
如果您之后立即執(zhí)行以下操作,您將獲得更多有趣的結(jié)果:
Account.find('12345a').campaigns.limit(20).count
Account.find('12345a').campaigns.limit(20).to_a.length
奇怪的是,#count將返回20,但是#to_a將返回10。
它帶來了可怕的用戶體驗(yàn)
從用戶體驗(yàn)的角度來看,這是不可接受的。 人們進(jìn)行捐贈(zèng)時(shí),他們希望能夠立即在總金額中看到新的捐贈(zèng)。 他們不認(rèn)為"哦,這個(gè)系統(tǒng)一定已經(jīng)緩存了以前的值。"
同樣,緩存必須足夠頻繁地更新以跟蹤籌款活動(dòng)的進(jìn)度。 客戶成功管理團(tuán)隊(duì)每天與客戶保持密切聯(lián)系,并且必須提供進(jìn)度報(bào)告。 如果報(bào)告已過時(shí),他們將無法做到這一點(diǎn)。
它造成了一些非常嚴(yán)重的潛在錯(cuò)誤
想象一下,如果要對(duì)集合進(jìn)行范圍界定以進(jìn)行批量刪除。 您以為您要?jiǎng)h除20條記錄,但實(shí)際上是在刪除類似查詢返回的先前的記錄集。
這就是噩夢(mèng),我希望您擁有良好的備份和審核表。
解決方案—緩存清除工具
我構(gòu)建了多個(gè)工具,客戶成功可使用這些工具來強(qiáng)制在特定隊(duì)列上進(jìn)行緩存刷新。 這樣可以確保每當(dāng)需要最新數(shù)據(jù)時(shí),他們就可以擁有它們。
通過將緩存的屬性訪問器更改為接受并使用一組可選參數(shù),我現(xiàn)在可以在需要的任何時(shí)候強(qiáng)制刷新緩存:
@campaign.total_raised(force_refresh: true)
在對(duì)新鮮度敏感的操作中,這將確保每次都處理正確類型的數(shù)據(jù)。
我還確保關(guān)鍵報(bào)告之類的功能使用了較薄的緩存層,并盡可能地利用了最新數(shù)據(jù)。
最終結(jié)果
在所有優(yōu)化的最后,我們有了一個(gè)系統(tǒng),可以處理我們預(yù)期的下一個(gè)數(shù)量級(jí)的負(fù)載-每秒2000個(gè)以上的請(qǐng)求,數(shù)千個(gè)并發(fā)活動(dòng)。 大多數(shù)面向捐助者的端點(diǎn)的加載時(shí)間均少于50ms,而面向客戶頁面的加載時(shí)間則在300ms之內(nèi)。
這是一段漫長的旅程,進(jìn)行了許多高壓部署,但最終結(jié)果不言而喻。 最終,我們有了一個(gè)在贈(zèng)予日中可以忽略的系統(tǒng)-大部分情況下。
(本文翻譯自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)