如何使用緩存提高 Web Farm 中的 ASP.NET 應用程序性能
已發表: 2022-03-11計算機科學中只有兩個難點:緩存失效和命名。
- 作者:菲爾·卡爾頓
緩存簡介
緩存是一種通過簡單技巧提高性能的強大技術:每次我們需要結果時,系統都可以存儲或緩存該工作的結果,而不是做昂貴的工作(如復雜的計算或複雜的數據庫查詢),並且只需下次請求時提供它,而無需重新執行該工作(因此可以更快地響應)。
當然,緩存背後的整個想法只有在我們緩存的結果仍然有效時才有效。 在這裡,我們遇到了問題的實際困難部分:我們如何確定緩存項何時變得無效並需要重新創建?
完美解決分佈式網絡場緩存問題。
通常,典型的 Web 應用程序必須處理比寫入請求更多的讀取請求。 這就是為什麼設計用於處理高負載的典型 Web 應用程序被設計為可擴展和分佈式的,部署為一組 Web 層節點,通常稱為農場。 所有這些事實都會影響緩存的適用性。
在本文中,我們將重點介紹緩存在確保設計用於處理高負載的 Web 應用程序的高吞吐量和性能方面可以發揮的作用,我將利用我的一個項目的經驗並提供基於 ASP.NET 的解決方案作為插圖。
處理高負載的問題
我必須解決的實際問題不是原始問題。 我的任務是讓 ASP.NET MVC 整體式 Web 應用程序原型能夠處理高負載。
提高單體 Web 應用程序的吞吐量能力的必要步驟是:
- 使其能夠在負載均衡器後面並行運行 Web 應用程序的多個副本,並有效地服務所有並發請求(即,使其具有可擴展性)。
- 分析應用程序以揭示當前的性能瓶頸並對其進行優化。
- 使用緩存來增加讀取請求吞吐量,因為這通常構成整個應用程序負載的重要部分。
緩存策略通常涉及使用一些中間件緩存服務器,例如 Memcached 或 Redis,來存儲緩存的值。 儘管它們的採用率很高並且經過驗證的適用性,但這些方法也有一些缺點,包括:
- 通過訪問單獨的緩存服務器引入的網絡延遲可以與到達數據庫本身的延遲相媲美。
- Web 層的數據結構可能不適合開箱即用的序列化和反序列化。 要使用緩存服務器,這些數據結構應該支持序列化和反序列化,這需要持續的額外開發工作。
- 序列化和反序列化會增加運行時開銷,並對性能產生不利影響。
所有這些問題都與我的情況相關,因此我不得不探索替代方案。
內置的 ASP.NET 內存緩存 ( System.Web.Caching.Cache
) 速度非常快,並且可以在開發期間和運行時使用而無需序列化和反序列化開銷。 但是,ASP.NET 內存緩存也有其自身的缺點:
- 每個 Web 層節點都需要自己的緩存值副本。 這可能會導致節點冷啟動或回收時更高的數據庫層消耗。
- 當另一個節點通過寫入更新值使緩存的任何部分無效時,應通知每個 Web 層節點。 由於緩存是分佈式的並且沒有適當的同步,大多數節點將返回舊值,這通常是不可接受的。
如果額外的數據庫層負載本身不會導致瓶頸,那麼實現正確分佈的緩存似乎是一件容易處理的任務,對吧? 嗯,這不是一件容易的事,但它是可能的。 就我而言,基準測試表明數據庫層不應該成為問題,因為大部分工作都發生在 Web 層。 因此,我決定使用 ASP.NET 內存緩存並專注於實現正確的同步。
介紹基於 ASP.NET 的解決方案
如前所述,我的解決方案是使用 ASP.NET 內存中緩存而不是專用緩存服務器。 這需要網絡場的每個節點都有自己的緩存,直接查詢數據庫,執行任何必要的計算,並將結果存儲在緩存中。 這樣,由於緩存的內存特性,所有緩存操作都將非常快。 通常,緩存項具有明確的生命週期,並在某些更改或寫入新數據時變得陳舊。 因此,從 Web 應用程序邏輯來看,緩存項何時應該失效通常是很清楚的。
這裡剩下的唯一問題是,當其中一個節點使自己緩存中的緩存項無效時,其他節點將不會知道此更新。 因此,其他節點服務的後續請求將提供陳舊的結果。 為了解決這個問題,每個節點都應該與其他節點共享其緩存失效。 收到此類無效後,其他節點可以簡單地刪除其緩存值並在下一次請求時獲取新值。
在這裡,Redis 可以發揮作用。 與其他解決方案相比,Redis 的強大之處在於其 Pub/Sub 功能。 Redis 服務器的每個客戶端都可以創建一個通道並在其上發布一些數據。 任何其他客戶端都能夠收聽該頻道並接收相關數據,這與任何事件驅動系統非常相似。 此功能可用於在節點之間交換緩存失效消息,因此所有節點都能夠在需要時使它們的緩存失效。
ASP.NET 的內存緩存在某些方面很簡單,而在其他方面則很複雜。 特別是,它很簡單,因為它可以作為鍵/值對的映射,但與其失效策略和依賴關係有很多複雜性。
幸運的是,典型的用例很簡單,並且可以對所有項使用默認的失效策略,使每個緩存項最多只有一個依賴項。 在我的例子中,我以緩存服務接口的以下 ASP.NET 代碼結束。 (請注意,這不是實際代碼,因為為了簡單起見和專有許可,我省略了一些細節。)
public interface ICacheKey { string Value { get; } } public interface IDataCacheKey : ICacheKey { } public interface ITouchableCacheKey : ICacheKey { } public interface ICacheService { int ItemsCount { get; } T Get<T>(IDataCacheKey key, Func<T> valueGetter); T Get<T>(IDataCacheKey key, Func<T> valueGetter, ICacheKey dependencyKey); }
在這裡,緩存服務基本上允許兩件事。 首先,它能夠以線程安全的方式存儲某個值 getter 函數的結果。 其次,它確保在請求時始終返回當時的值。 一旦緩存項變得陳舊或被明確地從緩存中逐出,則再次調用 value getter 以檢索當前值。 緩存鍵被ICacheKey
接口抽像出來,主要是為了避免在整個應用程序中對緩存鍵字符串進行硬編碼。
為了使緩存項無效,我引入了一個單獨的服務,如下所示:
public interface ICacheInvalidator { bool IsSessionOpen { get; } void OpenSession(); void CloseSession(); void Drop(IDataCacheKey key); void Touch(ITouchableCacheKey key); void Purge(); }
除了刪除數據項和触摸鍵的基本方法(只有依賴數據項)之外,還有一些與某種“會話”相關的方法。
我們的 Web 應用程序使用 Autofac 進行依賴注入,這是一種用於依賴管理的控制反轉 (IoC) 設計模式的實現。 此功能允許開發人員創建他們的類而無需擔心依賴關係,因為 IoC 容器會為他們管理這些負擔。
緩存服務和緩存失效器在 IoC 方面有著截然不同的生命週期。 緩存服務被註冊為單例(一個實例,在所有客戶端之間共享),而緩存失效器被註冊為每個請求的實例(為每個傳入請求創建一個單獨的實例)。 為什麼?
答案與我們需要處理的額外微妙之處有關。 Web 應用程序使用模型-視圖-控制器 (MVC) 架構,主要有助於分離 UI 和邏輯關注點。 因此,一個典型的控制器動作被包裝到ActionFilterAttribute
的一個子類中。 在 ASP.NET MVC 框架中,此類 C# 屬性用於以某種方式裝飾控制器的操作邏輯。 該特定屬性負責在操作開始時打開新的數據庫連接並啟動事務。 此外,在操作結束時,過濾器屬性子類負責在成功的情況下提交事務並在失敗的情況下將其回滾。

如果緩存失效發生在事務的中間,則可能存在競爭條件,即對該節點的下一個請求將成功地將舊的(其他事務仍然可見)值放回緩存中。 為了避免這種情況,所有的失效都被推遲到事務提交之前。 之後,緩存項可以安全地驅逐,並且在事務失敗的情況下,根本不需要修改緩存。
這就是緩存失效器中與“會話”相關的部分的確切目的。 此外,這也是其生命週期與請求綁定的目的。 ASP.NET 代碼如下所示:
class HybridCacheInvalidator : ICacheInvalidator { ... public void Drop(IDataCacheKey key) { if (key == null) throw new ArgumentNullException("key"); if (!IsSessionOpen) throw new InvalidOperationException("Session must be opened first."); _postponedRedisMessages.Add(new Tuple<string, string>("drop", key.Value)); } ... public void CloseSession() { if (!IsSessionOpen) return; _postponedRedisMessages.ForEach(m => PublishRedisMessageSafe(m.Item1, m.Item2)); _postponedRedisMessages = null; } ... }
這裡的PublishRedisMessageSafe
方法負責將消息(第二個參數)發送到特定的通道(第一個參數)。 實際上,drop 和 touch 有單獨的通道,因此每個通道的消息處理程序都知道要做什麼——drop/touch 等於接收到的消息有效負載的鍵。
棘手的部分之一是正確管理與 Redis 服務器的連接。 在服務器因任何原因停機的情況下,應用程序應繼續正常運行。 當 Redis 再次上線時,應用程序應該可以無縫地再次開始使用它並再次與其他節點交換消息。 為此,我使用了 StackExchange.Redis 庫,生成的連接管理邏輯實現如下:
class HybridCacheService : ... { ... public void Initialize() { try { Multiplexer = ConnectionMultiplexer.Connect(_configService.Caching.BackendServerAddress); ... Multiplexer.ConnectionFailed += (sender, args) => UpdateConnectedState(); Multiplexer.ConnectionRestored += (sender, args) => UpdateConnectedState(); ... } catch (Exception ex) { ... } } private void UpdateConnectedState() { if (Multiplexer.IsConnected && _currentCacheService is NoCacheServiceStub) { _inProcCacheInvalidator.Purge(); _currentCacheService = _inProcCacheService; _logger.Debug("Connection to remote Redis server restored, switched to in-proc mode."); } else if (!Multiplexer.IsConnected && _currentCacheService is InProcCacheService) { _currentCacheService = _noCacheStub; _logger.Debug("Connection to remote Redis server lost, switched to no-cache mode."); } } }
在這裡, ConnectionMultiplexer
是 StackExchange.Redis 庫中的一個類型,它負責與底層 Redis 的透明工作。 這裡重要的部分是,當特定節點失去與 Redis 的連接時,它會回退到無緩存模式以確保沒有請求會收到陳舊的數據。 連接恢復後,節點再次開始使用內存緩存。
以下是不使用緩存服務 ( SomeActionWithoutCaching
) 和使用它的相同操作 ( SomeActionUsingCache
) 的操作示例:
class SomeController : Controller { public ISomeService SomeService { get; set; } public ICacheService CacheService { get; set; } ... public ActionResult SomeActionWithoutCaching() { return View( SomeService.GetModelData() ); } ... public ActionResult SomeActionUsingCache() { return View( CacheService.Get( /* Cache key creation omitted */, () => SomeService.GetModelData() ); ); } }
ISomeService
實現的代碼片段可能如下所示:
class DefaultSomeService : ISomeService { public ICacheInvalidator _cacheInvalidator; ... public SomeModel GetModelData() { return /* Do something to get model data. */; } ... public void SetModelData(SomeModel model) { /* Do something to set model data. */ _cacheInvalidator.Drop(/* Cache key creation omitted */); } }
基準測試和結果
在緩存 ASP.NET 代碼全部設置好之後,是時候在現有的 Web 應用程序邏輯中使用它了,基準測試可以很方便地決定將大部分精力用於重寫代碼以使用緩存的位置。 挑選出一些最常見或最關鍵的用例進行基準測試至關重要。 之後,像 Apache jMeter 這樣的工具可以用於兩件事:
- 通過 HTTP 請求對這些關鍵用例進行基準測試。
- 模擬被測 Web 節點的高負載。
要獲得性能配置文件,可以使用任何能夠附加到 IIS 工作進程的分析器。 就我而言,我使用了 JetBrains dotTrace Performance。 在花一些時間嘗試確定正確的 jMeter 參數(例如並發和請求數)之後,可以開始收集性能快照,這對於識別熱點和瓶頸非常有幫助。
在我的案例中,一些用例顯示大約 15%-45% 的總體代碼執行時間花在了數據庫讀取上,並且存在明顯的瓶頸。 在我應用緩存之後,它們中的大多數的性能幾乎翻了一番(即快兩倍)。
結論
如您所見,我的案例可能看起來像是通常所說的“重新發明輪子”的一個例子:既然已經有廣泛應用的最佳實踐,為什麼還要費心去嘗試創造新事物呢? 只需設置一個 Memcached 或 Redis,然後放手。
我絕對同意使用最佳實踐通常是最佳選擇。 但在盲目應用任何最佳實踐之前,應該問自己:這種“最佳實踐”的適用性如何? 它適合我的情況嗎?
在我看來,正確的選擇和權衡分析是做出任何重大決定的必要條件,這就是我選擇的方法,因為問題並不那麼容易。 就我而言,有很多因素需要考慮,我不想採取一種萬能的解決方案,因為它可能不是解決手頭問題的正確方法。
最後,通過適當的緩存,我的性能確實比最初的解決方案提高了近 50%。