我最近有機會在擴展一個網頁應用程序上工作。它擁有相當傳統的架構:一個位於快速本地代碼網頁伺服器(例如 apache2 或 nginx)前面的 CDN,然後是用慢速解釋語言(例如 Python 或 Ruby)編寫的應用代碼,該代碼與數據庫(例如 Postgres 或 MySQL)進行通信,並且還有一個額外的內存 KV 存儲(例如 redis 或 memcached)作為緩存。正如你可能想像的那樣,擴展這個應用程序最終涉及到很多 “增加更多緩存”。
正如我之前提到的,我喜歡從硬件利用率的角度思考性能問題 —— 我們為了完成給定的工作單元消耗了哪些硬件資源?因此,這似乎是一個很好的機會來寫下我腦海中浮現的一個模型:當我們在 memcached
中緩存東西時,我們實際上在使用底層物理資源方面做了什麼?
緩存點查詢#
我開始思考這個分類法的原因之一是為了回答這個問題:為什麼我們可能會通過主鍵緩存數據庫的點查詢?數據庫已經有自己的內存緩存來存儲最近訪問的數據,那麼為什麼我們要在它們面前放置一個 額外 的緩存呢?儘管如此,一項高度不科學的 Twitter 投票確認,將 memcache 或類似技術放在數據庫前面,即使是對於主鍵查詢,這是一種相當普遍的做法。在我接下來的分類中,我將觸及緩存的一般每個視角和目的,然後也會觸及它告訴我們有關通過主鍵緩存查詢的內容。
緩存的功能#
我已經確定了緩存伺服器在傳統網頁應用架構中可能扮演的三個不同的 “基本” 目的。這些是 “純粹” 的視角;任何緩存部署都涉及這些的某種組合。但我發現將它們稍微分開思考是有幫助的,並且在我嘗試為某個端點添加緩存時,對我希望獲得的目的有一個想法。
用內存換取 CPU#
這或許是應用緩存的最經典概念。你有一些昂貴的操作,而不是每次需要結果時都執行它,你會減少執行的頻率,並將結果存儲在緩存中。最終結果是你的系統使用更少的 CPU,但需要更多的內存,因為它需要存儲緩存的結果。在網頁架構中,這種模式的經典例子是整頁緩存,你在某處緩存整個渲染的頁面(無論是在你的應用框架中還是在前端網頁伺服器或 CDN 中)。緩存複雜數據庫查詢的輸出,以便可以用單個點查詢替換,將是另一個例子。
當我們緩存的計算是昂貴的,而輸出相對較小(以減少我們需要的內存)時,這種權衡最有意義。網頁應用程序通常用相對較慢的語言(如 Python 或 Ruby)編寫,這使得這種權衡更有價值。然而,更一般地說,任何時候我們的 CPU 限制比內存限制更大時,這可能都是一個合適的權衡。
值得注意的是,我們不需要一個單獨的緩存伺服器來進行這種權衡。如果我們將一些中間計算的結果存儲在數據庫本身中,我們可以進行類似的權衡;這種策略的版本通常被稱為 “物化”。
這種緩存的觀點對於理解為什麼我們可能會緩存主鍵查詢來說,可能是最不具信息性的。緩存將存儲與數據庫大致相同的數據,因此不明顯我們在節省什麼計算。儘管如此,我會指出,內存緩存可能消耗的 CPU 少於通用數據庫,以便提供點查詢。在大多數情況下,數據庫必須解析查詢,檢查表元數據,找到正確的索引,並遍歷 B 樹(即使所有相關頁面都在內存中),而像 memcached 這樣的工具則有一個更簡單的解析任務,接著基本上是一個單一的哈希表查詢。因此,對於緩存點查詢的一種看法是,我們在緩存伺服器中用內存交換數據庫伺服器中的 CPU 使用。
當然,緩存節點通常是與數據庫 不同 的硬件,這一點也很相關。這將我們帶到內存緩存服務的第二個特徵:
增加更多內存或 CPU#
傳統的數據庫架構相當單體:你有一台運行 MySQL 或 PostgreSQL 的機器,處理所有的讀取和寫入流量。這限制了我們可以添加到伺服器的 CPU 和 RAM 數量(出於實際原因,我們通常無法或不願意使用 金錢能買到的最大實例)。即使在分片架構中,添加更多分片通常也是一個耗時或繁重的操作。因此,在實踐中,我們在將金錢轉換為數據庫中的更多 CPU 或更多內存的能力上受到限制。
然而,如果我們能將工作從數據庫移到緩存中,它實際上給了我們 訪問系統中更多總 CPU 使用或內存 的能力。重要的是要注意,以這種方式添加更多資源通常不會提高效率,這是以 “服務單個請求的成本” 來衡量的。事實上,通常會降低效率,因為我們增加了在系統中不同節點之間移動數據的開銷。如果我們如前所述用 CPU 交換內存,我們實際上可能會降低每個請求的總成本。如果我們只是添加資源,使自己能夠用金錢換取吞吐量,但通常不會使單個請求變得更便宜。
緩存通常在操作上也比數據庫更容易擴展。通過單個緩存鍵查詢進行分片在概念上非常簡單,允許我們添加多個緩存節點。這樣,緩存架構可以潛在地將可用的緩存內存和處理這些請求的 CPU 數量乘以數倍,超過我們的數據庫伺服器所能提供的。
在這裡,我們還可以將緩存節點與數據庫讀取副本進行比較,這是擴展數據庫集群的另一種常見策略。讀取副本也為系統添加了額外的 CPU 和 RAM,而不會實質性改變每個單獨查詢的性能特徵。然而,由於每個副本通常必須複製整個數據集,因此寫入流量在 每個 副本上消耗了一定量的 CPU 和 I/O 帶寬,降低了擴展的有效性。還有,很難或不可能對每個副本在緩存中保留的鍵空間的哪些部分進行分片,這使得在分片緩存架構中幾乎無法有效利用額外的 RAM。
這種觀點很好地解釋了緩存點查詢的價值!即使緩存和數據庫在服務單個查詢時效率相同,添加緩存節點也使我們能夠比擴展數據庫集群更輕鬆地水平擴展我們的內存和 CPU,從而增加內存中持有的記錄總數和可用的總 QPS。
更高效的內存使用#
這最後一點在某種程度上是最具領域特定性的,但對我來說也是最有趣的,因為這是最令人驚訝的。
大多數數據庫在 “頁面” 級別實現其緩存,這通常在 4k-16k 範圍內。頁面是磁碟上的基本存儲和訪問單位,數據庫緩存位於 I/O 層之上,並緩存整個頁面。
這種架構的含義是,即使我們只想要一條記錄,我們也必須緩存至少一整頁的數據。如果經常訪問的記錄的 “工作集” 遠小於整個數據庫集合,這可能會導致可怕的內存效率!想像一個有 100 字節行的表,在最糟糕的情況下,我們可能需要將整個 8k 的數據拉入內存 僅僅是為了緩存一條記錄,這樣就需要 80 倍的 RAM 來緩存我們的工作集!一般來說,我們的記錄不太可能稀疏到達到那個限制,但開銷仍然會相當可觀。
相比之下,像 Redis 或 Memcache 這樣的內存存儲正確地緩存單個對象。緩存一條 100 字節的記錄不可避免地會對哈希表、分配器開銷等產生一些開銷,但可能最多只有 2 倍的開銷!因此,即使在相同的內存量下,基於對象的緩存通常可以緩存 比數據庫自己的頁面緩存多得多 的對象,這為我們提供了使用緩存而不僅僅依賴於數據庫自身緩存的第三個優勢。
當我第一次意識到這一結果時,這讓我感到非常驚訝!本文中概述的緩存的前兩個用途對我來說相當直觀,但我天真地從未預期專用緩存會在效率上對數據庫自身的緩存有如此劇烈的提升。對我來說,這個理由是專用緩存伺服器中緩存主鍵查詢的極具說服力的案例,至少在某些操作範疇內:你可能能夠每 MB RAM 緩存的記錄數量是數據庫本身的幾倍!