作爲最受歡迎的IT技術網站之一,Stack Overflow如何做應用緩存?

緩存是什麼?這是一種無需重複計算或者反覆獲取,即可快速得到反饋的方法,用於提升性能水平並優化資源成本。下面咱們馬上進入正題,聊聊緩存的實現方式。

我們假設這裏需要調用某個API、查詢某數據庫服務器或者只是選取幾個高達數百萬位的數字並進行相加。這些都需要佔用大量資源,所以最好是把結果緩存起來,以備未來快速重複使用。

爲什麼要進行緩存?

在這裏,我認爲有必要聊聊之前提到的這些任務到底需要多少資源成本。現代計算機中存在多個緩存層。舉例來說,我們建立一個Web服務器,其中配備英特爾至強E5-2960 v3 CPU以及2133 MHz DIMM。緩存訪問的實質,就是計算需要佔用處理器的“多少個週期”;因此在使用這塊主頻爲3.06 GHz(性能模式)的處理器時,可以推導出相關延遲(這裏使用的英特爾處理器皆爲Haswell架構):

  • 一級緩存(每核心):4個週期,約1.3納秒——12x 32 KB + 32 KB
  • 二級緩存(每核心):12個週期,約3.92納秒延遲——12x 256 KB
  • 三級緩存(共享):34個週期,約11.11納秒延遲——30 MB
  • 系統內存:約100納秒延遲——8x 8 GB

各個緩存層的存儲容量越大,距離也就越遠。這是處理器設計當中做出的取捨,旨在實現最佳平衡。舉例來說,各個核心的內存量越大,則其一般來講與核心芯片間的距離越遠,意味着延遲、機會成本以及功耗成本都將隨之上升。在這方面,電荷的實際運行距離會造成重大的影響;每一秒,該距離都相當於增加了數十億倍。

另外,這裏我沒有提到磁盤延遲,因爲我們很少使用磁盤。爲什麼?這個嘛,我可能需要解釋一下……在Stack Overflow,除了備份或者日誌服務器的其它一切生產負載都在SSD上實現。我們的本地存儲通常分爲以下幾層:

  • NVMe SSD: 約120微秒
  • SATA或SAS SSD: 約400至600微秒
  • 機械磁盤:2至6毫秒

具體性能一直在變化,所以大家不用關注這些數字。我們需要了解的是這些存儲層之間的差異量級。下面,讓我們整理出一份更明確的比較清單(全部使用最佳性能數字):

  • L1: 1.3納秒
  • L2: 3.92納秒 (延遲爲3倍)
  • L3: 11.11納秒(延遲爲8.5倍)
  • DDR4 RAM: 100納秒 (延遲爲77倍)
  • NVMe SSD: 12萬納秒 (延遲爲92307倍)
  • SATA/SAS SSD: 40萬納秒(延遲爲30萬7692倍)
  • 機械磁盤: 2–6毫秒 (延遲爲153萬8461倍)
  • Microsoft Live登錄: 12次轉發,5秒 (延遲約爲38億4615萬3846倍)

如果大家對這些數字沒啥概念,那麼下圖使用滑塊整理出了可視化版本的比較結果(可以看到各緩存層的變化趨勢):

由於性能數字和量級差距過大,讓我們再添加一些日常環境中經常出現的重要指標。假設我們的數據源爲X——這個X究竟是什麼無所謂,可以是SQL、微服務、宏服務、leftpad服務、Redis或者磁盤文件等,最重要的是需要將源性能與內存性能進行比較。下面來看源性能:

  • 100納秒(來自內存——快!)
  • 1毫秒(延遲爲1萬倍)
  • 100毫秒(延遲爲100萬倍)
  • 1秒(延遲爲1億倍)

看到這裏,相信大家已經能夠理解,即使延遲僅爲1毫秒,其速度也要遠低於內存的速度。這裏的單位分別爲毫秒、微秒與納秒,三者皆爲1000進位——1000納秒=1微秒。

但是,並不是所有緩存都屬於本地緩存。例如,我們會在自己的Web層之後利用Redis建立共享緩存(後文將具體介紹)。假設我們正在打算通過內部網絡進行訪問,整個往返需要0.17毫秒,此外我們還要發送一些數據。對於小型數據包,延遲大約在0.2毫秒到0.5毫秒之間。雖然這樣的延遲仍是本地內存的2000至5000倍,但仍比大多數數據源快得多。請注意,這些數字看起來比較小,是因爲我們設定的是一套小型本地局域網。雲端延遲通常會更高,實際延遲水平需要自行測量。

在獲取數據的同時,我們可能還打算以某種方式進行處理。也許我們需要進行相加、過濾、對其進行編碼等。總而言之,我們希望只對〈x〉進行一次處理,後續直接提取處理結果。

有時候,我們希望降低延遲;有時候則希望節約CPU資源。這兩項因素基本上構成了引入緩存機制的全部理由。接下來,我們會從反方向進行論證。

爲什麼不使用緩存?

我知道很多朋友最討厭緩存,這部分一定特別符合您的心意。是的,我這人就是典型的牆頭草。

前面說了這麼多好處,爲什麼有時候不適合用緩存呢?這是因爲任何一項決策都需要進行利弊權衡。是的,任何一項決策。即使是面對簡單如時間成本或者機會成本之類的問題,權衡也仍有必要。

在緩存方面,添加緩存可能帶來以下成本:

  • 在必要時清除緩存值(緩存失效——我們將在後文中具體說明)
  • 緩存佔用內存
  • 訪問緩存造成的延遲(與直接訪問數據源相比)
  • 系統結構更爲複雜,因此調試工作將面臨更高的時間與精力成本

每當有新功能可能需要配合緩存時,我們都需要進行評估……而且評估工作難度很大。

在Stack Overflow,我們的架構遵循一項總體性的原則:儘可能簡單。所以簡單,就是易於評估、推理、調度以及變更。只有在必須複雜時,才允許其複雜。緩存當然也要遵循這一原則。因爲緩存會帶來更多工作量與精力投入,所以除非必要,否則不用。

所以,我們得首先回答以下幾個問題:

  • 使用緩存能夠顯著提高速度嗎?
  • 我們能夠節約哪些資源?
  • 這些數據值得存儲嗎?
  • 存儲的數據是否值得清理(例如使用垃圾收集機制)?
  • 這是否會立刻帶來大型對象堆?
  • 我們需要多久進行一次清理?
  • 每個緩存條目會有多少次命中?
  • 緩存失效是否會影響到其它功能?
  • 未來會出現多少變種?
  • 我們的緩存分配是否被用於計算密鑰?
  • 採用本地還是遠程緩存?
  • 不同用戶之間是否共享同一緩存?
  • 不同站點之間是否共享同一緩存?
  • 管理以及調試過程是否非常困難?
  • 緩存屬於什麼級別?

這些都是緩存決策當中必須搞清楚的問題。本文也將儘可能將其納入討論。

Stack Overflow的緩存分層

在Stack Overflow,我們也有自己的“一級/二級”緩存。但爲了避免與真正的名詞相混淆,這裏我會強調更準確的術語表達。

  • “全局緩存”:內存內緩存(全局、每Web服務器、未命中時由Redis支持)
    • 其對應用戶頂欄,在整個網絡中共享使用。
    • 命中本地內存(共享鍵空間),而後是Redis(共享鍵空間,使用Redis database 0)。
  • “站點緩存”:內存內緩存(每站點、每Web服務器,未命中時由Redis支持)
    • 通常爲各站點的問題列表或者用戶列表。
    • 命中本地內存(第站點鍵空間,使用前綴),而後使用Redis(每站點鍵空間,使用Redis數據庫)
  • “本地緩存”:內存內緩存(每站點、每Web服務器、無後備任何支持)
    • 通常爲易於獲取的內容,且規模過大但又不足以動用Redis堆。
    • 僅命中本地內在(每站點鍵空間,使用前綴)。

這裏說的“每站點”是什麼意思?Stack Overflow與Stack Exchange站點網絡是一套多租戶架構。Stack Overflow只是數百個站點中的一個。這意味着Web服務器上的單一進程託管着全部站點,因此我們只在必要時纔會進行緩存拆分。另外,我們必須對緩存進行清理(後文將具體介紹如何清理)。
Redis之前,我們討論過了服務器與共享緩存的工作原理。接下來讓我們再快速瞭解一下共享內容的實現基礎:Redis。那麼,Redis是什麼?這是一套開源鍵/值數據存儲方案,其中包含多種實用數據結構,大量發佈/訂閱機制以及堅如磐石的穩定性水平。

爲什麼要選擇Redis,而非其它解決方案?答案很簡單,因爲Redis就夠了。它表現得很好,能夠充分滿足我們對於共享緩存的需求。另外,它的穩定性令人稀奇,速度表現也無可挑剔。我們很清楚要如何使用Redis、如何進行監控、如何加以維護。而且在必要時,我們也能對Redis庫做出調整。
Redis成爲我們整體基礎設施中最不用操心的組成部分。我們基本上已經將其視爲一種理所當然的解決方案(當然,我們也設置有高可用性副本)。在選擇基礎設施時,我們不僅需要根據潛在價值進行調整,同時也要考慮調整可能帶來的成本、時間投入以及風險。如果現在的解決方案就能很好地完成任務,我們爲什麼要投入大量時間與精力,甚至承擔由此引發的風險?當然沒必要。時間總是寶貴的,拿來做點有意義的事情不是更好?例如——討論哪種緩存服務器更強!

下面,我們通過幾個Redis實例對應用問題進行具體剖析(但這些應用都處於同一組服務器上)。先從下圖開始:

我們首先把從上週二(2019年7月30日)以來的一些快速統計數據整理出來。這些數據涵蓋了各大主要設備上的全部實例(這裏按組織方式進行分類,而非按性能分類……單一實例可能就足以承載我們的全部日常負載):

  • 我們的Redis物理服務器擁有256 GB內存,但實際使用量只有不足96 GB。
  • 每天共需處理15億8655萬3473條命令(由於需要複製備份,實際命令量爲37億2658萬897條,峯值期間每秒8萬6982條命令)。
  • 整體服務器的CPU平均利用率爲2.01%(峯值期間爲3.04%)。其中最活躍的實例,資源佔比也不足1%。
  • 共計1億2441萬5398個活動鍵(計入複製操作,則爲4億2281萬8481個)。
  • 這些數字貫穿3億806萬5226次HTTP命中(其中6471萬7337次命中問題頁面)。

備註:Redis並未限制性能水平,我們也沒有設定任何限制。這裏只列出實例當中的實際活動情況。

除了緩存需求之外,我們選擇Redis還有其它一些理由:我們還使用其中的發佈/訂閱機制實現websockets的實時計分及複製等功能。Redis 5.0還添加了Streams,它非常適合我們的websocket。所以在其它基礎設施組件更新到位之後(主要受到Stack Overflow企業版的限制),我們可能會進行一波全面遷移。

內存內與Redis緩存

之前提到的所有實例,都包含一個內存內緩存組件,有一些還由Redis服務器負責提供後備。

內存內非常簡單,我們就是把數據緩存在……內存裏面。以ASP.NET MVC 5爲例,以往的作法是HttpRuntime.Cache。如今,我們已經做好ASP.NET Core的遷移準備,所以現在的方法變成了 MemoryCache。二者之間的區別不大,也不是很重要;它們都提供了一種能夠在特定時間段內緩存對象的方法。這就足夠了,我們需要的就是這個。

對於以上幾種緩存方式,我們會選擇一個“數據庫ID”,具體與我們在Stack Exchange Network上的站點相關,因此由我們的Sites數據庫表決定。Stack Overflow爲1,Server Fault爲2,Super User爲3等等。
對於本地緩存,大家可以通過多種方式實現。我們的方法非常簡單:ID爲緩存態的一部分。對於全局緩存(共享),ID爲0。我們進一步(出於安全)爲每個緩存添加前綴,以避免這些應用級緩存與可能存在的任意其它鍵名稱發生衝突。下面來看一個示例鍵:

prod:1-related-questions:1234

這是Stack Overflow(ID爲1)上關於問題1234的側欄相關問題。如果只使用內存內緩存,那麼序列化將不再重要,因爲我們可以直接緩存任何對象。但是,如果我們將某個緩存對象發送至某處(或者由某處發回),則必須對其快速進行序列化。正因爲如此,我們才編寫出自己的protobuf-net。Protobuf是一種二進制編碼格式,在速度與分配方面都非常高效。我們希望緩存的簡單對象可能如下所示:

public class RelatedQuestionsCache

{

    public int Count { get; set; }

    public string Html { get; set; }

}

在利用protobuf屬性控制序列化之後,則如下所示:

[ProtoContract]

public class RelatedQuestionsCache

{

    [ProtoMember(1)] public int Count { get; set; }

    [ProtoMember(2)] public string Html { get; set; }

}

現在,假設我們希望將該對象緩存在站點緩存當中。那麼整個流程將如下所示(代碼稍微簡化了一點):

public T Get<T>(string key)

{

    // Transform to the shared cache key format, e.g. "x" into "prod:1-x"

    var cacheKey = GetCacheKey(key);

    // Do we have it in memory?

    var local = memoryCache.Get<RedisWrapper>(cacheKey);

    if (local != null)

    {

        // We've got it local - nothing more to do.

        return local.GetValue<T>();

    }

    // Is Redis connected and readable? This makes Redis a fallback and not critical

    if (redisCache.CanRead(cacheKey)) // Key is passed here for the case of Redis Cluster

    {

     var remote = redisCache.StringGetWithExpiry(cacheKey)

        if (remote.Value != null)

        {

            // Get our shared construct for caching

            var wrapper = RedisWrapper.From(remote);

            // Set it in our local memory cache so the next hit gets it faster

            memoryCache.Set<RedisWrapper>(key, wrapper, remote.Expiry);

            // Return the value we found in Redis

            return remote.Value;

        }

    }

    // No value found, sad panda

    return null;

}

當然,這裏的代碼經過了大大簡化,但我們不會遺漏任何重要的內容。

爲什麼要使用RedisWrapper?因爲類似於Redis,它通過將值與TTL(生存時間)搭配起來實現了平臺概念同步。它還允許我們對null值進行緩存,且無需使用特殊的處理方式。換句話說,這樣我們就能分辨“內容沒有經過緩存”和“經過緩存的內容爲null”之間的區別。如果大家對StringGetWithExpiry抱有興趣,我再多提幾句。它屬於StackExchange.Redis方法,負責將多項命令整合起來以通過一次調用同時返回值與TTL(不再消耗雙倍時間)。

使用 Set 對值進行緩存的方式完全相同 :

  1. 將該值緩存在內存當中。
  2. 將該值緩存在內存Redis當中。
  3. (可選)提醒其它Web服務器該值已經更新,並指示各服務器刷新副本。

整合流水線

這裏我打算花點時間聊聊非常重要的一點:我們的Redis連接(通過StackExchange.Redis)並整合爲一條流水線。大家可以把它想象成一條傳送帶,每個人都可以向其中添加點什麼,之後它會轉到某個地方,最終再轉回來。在第一項傳送物品抵達終點或者被髮回期間,我們可以已經向流水線添加了成千上萬的其它內容。但如果一次性添加的物品過大,大家就得等它先處理完成,之後才能繼續添加其它物品。

這些內容可以是獨立的,但傳送帶本身是共用的。在我們的示例當中,傳送帶就是連接,而物品就是命令。如果有大規模負載進入或者傳回,那麼傳送帶就會被暫時佔用。具體來說,如果當前大家正在等待某項結果,但有個討厭的大玩意把整條流水線堵塞了一、兩秒,就有可能造成負面影響——這就是超時。

我們經常看到有人建議向Redis注入少量多次負載,用以降低超時問題的發生機率;但這麼幹其實沒什麼用——除非您的流水線非常非常快。這種作法雖然減少了超時現象,但也只是把超時換成了排隊等待。

最重要的,是要意識到計算機中的流水線就像現實世界中的流水線一樣。無論最窄處在哪裏,這都是約束通量的瓶頸所在。只不過計算機中的流水線是一種動態通道,更像是一條可以擴展、彎曲或者扭結的軟管,因此其瓶頸也不是一成不變的。它的瓶頸可能是線程池耗盡(注入了太多命令,或者是需要發出太多結果),可能是網絡傳輸帶寬,也有可能是某些東西影響到了我們對網絡帶寬的使用。

需要注意的是,在這樣的延遲級別之下,我們不能再以秒作爲看待問題的時間單位。對我來說,我考慮的不會是每秒1 Gb,而是每毫秒1 Mb。如果我們能夠在大約1毫秒或者更短的時間內完成網絡傳輸,則證明該負載確實很重要,會帶來能夠實際測量到且具備現實影響的傳輸時耗。換言之,關注延遲時儘量從小單位入手。在處理較短的持續時長時,我們必須把系統限制與同一持續時間內的相對約束條件進行等比例比較。面對毫秒級的延遲,再去談那些通常以秒爲單位的計算概念及指標,恐怕只會搞亂我們的思維以及決策方式。

流水線:重試

流水線自身的性質,使我們很難自信地使用重試命令。在這個殘酷的世界裏,我們的流水線更多變成了機場裏運送行李的傳送帶——緩慢,而且只能等待。

大家可能都遇到過這樣的情況,我們去機場,早早辦好了行李託運,看着自己心愛的小箱子消失在傳送帶盡頭。這時,我們突然發現候機廳旁邊就有更划算的行李運送服務站。沒問題,機場服務人員超有耐心,答應把行李退回來,轉交給外面的第三方託運商。然後……行李不見了。去哪兒了?沒人知道。

也許我們的包裹被交到了地勤管理員手上,但在回程的時候不知哪去了——也有可能連地勤那關都沒到。我們不知道,我們也不清楚該不該重新發送一次。如果再發一次,包裹還是消失了,該怎麼辦?對方知不知道我們的擔心?我們不想把情況弄得太複雜,但現在我們確實感到困惑而無助。好了,下面我們來聊聊另一個問題:緩存失效。

緩存失效

在前文當中,我一直反覆提到清理,緩存清理是如何起效的?Redis擁有一項發佈/訂閱功能,您可以在這裏推送消息,所有訂閱方都將收到您的消息(消息也會發送至所有副本位置)。利用這個簡單的概念,我們可以輕鬆建立起一條訂閱緩存清理流水線。當我們打算提前刪除某個值(而非等待TTL自然消失)時,我們只需要將該鍵名發送至流水線與監聽程序(比如事件處理程序),即可將其從本地緩存當中清理出去。

具體步驟包括:

  1. 通過DEL或者UNLINK將該值從Redis當中清理出去。或者,利用新的值替代該值。
  2. 將該鍵廣播至清理頻道。

其中的順序非常重要,因爲順序錯亂會發生爭用,最終甚至有可能重新獲取舊值。請注意,我們並沒有推送新值。我們的目的並不是推送新值。我們所做的就是在需要時,從Redis處獲取該值。

將一切組合起來:GetSet根據前文所述,我們的操作基本上可以歸納爲以下形式:

var val = Current.SiteCache.Get<string>(key);

if (val == null)

{

    val = FetchFromSource();

    Current.SiteCache.Set(key, val, Timespan.FromSeconds(30));

}

return val;

但是,我們可以大大改進具體方式。首先,這裏有不少重複,更重要的是代碼會在緩存過期時產生數百項同時針對FetchFromSource()的調用。如果負載強度過大,該怎麼辦?而且根據之前選擇使用緩存的情況來看,其強度顯然不可能太小。所以,我們需要更好的計劃。

下面來看最常用的方法:GetSet()。好的,命名是個難題,很多人可能已經想打退堂鼓了。那我們到底想在這裏實現什麼目標:

  • 如果存在,獲取該值。
  • 如果值不存在,則計算或者獲取該值(並將其注入緩存當中)。
  • 避免對同一值進行多次計算或者獲取。
  • 確保儘可能縮短用戶的等待時長。

我們可以使用一部分屬性來實現優化。假設您現在正在加載網頁,或者是1秒或3秒之前開始加載,這很重要嗎?Stack Overflow中的問題及結果是否會發生很大變化?答案是:確實會發生變化,但並不一定很重要。也許您只是參與了一項投票、進行了一項編輯、或者發佈了一條評論。對於這些能夠引起用戶關注的部分,我們需要利用緩存確保其及時更新。但對於正在同時加載該頁面的數百位其他用戶而言,這一點差別就顯得無足輕重了。

也就是說,我們擁有一定的浮動空間。下面,就讓我們利用這點空間改善性能效果。

以下爲目前我們GetSet中的內容(是的,另有一個等效異步版本):

public static T GetSet<T>(

    this ImmediateSiteCache cache,

    string key,

    Func<T, MicroContext, T> lookup,

    int durationSecs,

    int serveStaleDataSecs,

    GetSetFlags flags = GetSetFlags.None,

    Server server = Server.PreferMaster)

其中的關鍵參數爲durationSecs以及serveStaleDataSecs。調用則通常如下所示(這裏假設一個簡單例子以供討論):

var lookup = Current.SiteCache.GetSet<Dictionary<int, string>>("User:DisplayNames", 

   (old, ctx) => ctx.DB.Query<(int Id, string DisplayName)>("Select Id, DisplayName From Users")

                       .ToDictionary(i => i.Id), 

    60, 5*60);

此調用指向Users表,並對Id -> DisplayName查找進行緩存(我們實際上並不會這麼做,只是作爲簡單示例)。其中最重要的部分在於末尾處的值。我們假定“緩存週期爲60秒,但服務持續爲6分鐘。”

如此一來,在60秒週期之內,任何針對該緩存的命中都會返回相應值。但該值在內存(以及Redis)的駐留時長總計6分鐘。在60秒與6分鐘之間(從緩存時開始),我們將一直爲用戶提供該值。但我們同時也會在另一線程上啓動後臺刷新,以便未來的用戶能夠獲取新值。清理,而後重複這個過程。
這裏的另一個重要細節,在於我們會保留一份每服務器本地鎖表(一個ConcurrentDictionary),它負責阻止兩次調用同時運行lookup功能並試圖獲取該值。舉例來說,我們不允許400位用戶同時對數據庫進行400次查詢。用戶2到用戶4000最好是等待首次緩存完成,這樣我們的數據庫纔不會被瞬間襲來的請求所吞沒。爲什麼要用 ConcurrentDictionary<string, object>來代替……比如說HashSet?因爲我們希望鎖定字典中的該對象以供後續調用者使用。他們都在等待相同的提取結果,而該對象就代表着我們的提取內容。

下面來聊聊MicroContext,它主要是爲了配合多租戶機制。由於提取有可能發生在後臺線程上,因此我們必須瞭解其具體用途。提取指向哪個網站?哪個數據庫?此前的緩存值是什麼?我們需要將這些傳遞給後臺線程,以確保在提取新值之前先對這些上下文信息進行參考。傳遞舊值還讓我們能夠根據需要處理錯誤情況,例如在發生記錄錯誤時,返回舊值——或者說稍微過時的數據——總比直接顯示錯誤頁面好得多。當然,某些場景下返回舊值可能更糟,那大家就別這麼做。

類型與事物

我經常被問及的一個問題,就是我們如何使用DTO(數據傳輸對象)。簡而言之,我們不用DTO。我們只在必要時使用額外的類型與分配機制。舉例來說,如果我們可以在Dapper中運行.Query(“Select…”);並將其注入緩存,那我們肯定優先選這種簡單方法。很明顯,沒有理由爲了緩存而刻意創造額外的類型。

但如果有必要對數據庫表進行1:1緩存(例如Posts表中的Post,或者Users表中的User),我們當然會緩存。如果某個子類型或者事物組合屬於組合查詢當中的列,我們只需要將.Query作爲該類型,從這些列中獲取填充內容,然後再緩存即可。說得可能比較抽象,下面來看具體的例子:

[ProtoContract]

public class UserCounts

{

    [ProtoMember(1)] public int UserId { get; }

    [ProtoMember(2)] public int PostCount { get; }

    [ProtoMember(3)] public int CommentCount { get; }

}

public Dictionary<int, UserCounts> GetUserCounts() =>

    Current.SiteCache.GetSet<Dictionary<int, UserCounts>>("All-User-Counts", (old, ctx) =>

    {

        try

        {

            return ctx.DB.Query<UserCounts>(@"

  Select u.Id UserId, PostCount, CommentCount

    From Users u

         Cross Apply (Select Count(*) PostCount From Posts p Where u.Id = p.OwnerUserId) p

         Cross Apply (Select Count(*) CommentCount From PostComments pc Where u.Id = pc.UserId) pc")

                .ToDictionary(r => r.UserId);

        }

        catch(Exception ex)

        {

            Env.LogException(ex);

            return old; // Return the old value

        }

    }, 60, 5*60);

在這個例子當中,我們使用到Dapper的內置列映射功能。(請注意,其設置了僅get屬性)。該類型專門用於解決這類需求。例如,其甚至可以爲private,並將具有Dictionary<int, UserCount>的int userId作爲一項方法內細節。我們還顯示了T old以及MicroContext的使用方法。如果發生錯誤,我們會記錄下來並返回之前的值。

下面是類型。是的,只要是能起效的方法,我們就會使用。我們的理念是除非有用,否則不創造更多類型。DTO不僅會帶來更多類型,同時也包含很多映射代碼——或者其它一些功能性代碼(例如反射)。這些代碼可能會意外中斷。所以,保持簡單最重要,我們也始終以簡單爲原則。簡單意味着分配與實例化需求更少,也讓性能得以更上一層樓。

Redis:必要的類型

Redis提供一系列數據類型,其中所有鍵/值都使用“String”類型。但是,這裏大家不能讓String視爲字符串數據類型,這與一般的編程習慣不同(例如.Net中的字符串或者Java中的字符串)。這裏的String代表的是Redis中的“一些字節”,其可以是字符串、可以是二進制圖像,也可以是一切您能夠以字節方式存儲的信息!但是,我們也通過各種方式使用其它幾種數據類型:

  • Redis Lists對於聚合器或者賬戶操作這類隊列機制非常有用,擅長按順序執行操作。
  • Redis Sets特別適合某些唯一的條目列表,例如“本次alpha測試涉及哪些賬戶ID?”(這些內容唯一但沒有特別的順序要求。)
  • Redis Hashes主要用於類似字節的內容,例如“站點的最新活躍日期是哪天?”(其中哈希鍵ID爲站點,值則爲日期)。我們利用它來確定“我們這一次需要在站點上運行橫幅嗎?”之類的問題。
  • Redis Sorted Sets適用於處理有序內容,例如存儲每條路徑中速度最慢的100項MiniProfiler跟蹤記錄。

談到有序集,我們一直打算將/users頁面替換爲具有範圍查詢的有序集(每個特定時間範圍內一個集合),但每次開會都會忘記……

監控緩存性能

再來聊聊其它值得親耐滴訴問題。還記得之前提到的延遲因素嗎?直接訪問的速度非常慢。我們對問題頁面進行首次渲染時平均需要18至20毫秒,調用Redis則需要大約0.5毫秒。不過大量調用累加起來,其實際延遲很快就會達到與渲染時間接近的水平。

首先,我們希望在頁面級別關注這個問題。爲此,我們使用MiniProfiler查看頁面加載當中涉及Redis的每一項調用。MiniProfiler還與StackExchange.Redis的分析API對接。下圖所示,爲問題頁面的實際效果示例,頂部位置記錄了其實時跨網絡計數:

接下來,我們希望密切關注Redis實例。爲此,我們使用了Opserver。以下爲相關示例:

我們在這裏部署了一些內置工具,用於分析鍵使用情況以及通過正則表達式模式對其分組的能力。如此一來,我們就可以將我們的緩存內容與數據結合起來,看看哪些內容佔用的空間最大。

備註:此類分析的運行應僅在輔助節點上進行。在默認情況下,Opserver會立足副本運行此類分析,同時阻止相關任務被運行在主服務器上。

接下來是什麼?

.NET Core是我們在Stack Overflow的未來發展平臺。我們已經將衆多支持服務移植過去,目前正在開發各主要應用程序。老實說,緩存層當中並沒有多少緩存,但最有趣的是Utf8String(還沒有部署)。我們的總緩存量很大,不同的信息被緩存在多個不同的位置——例如邊欄中的“相關問題”。如果這些緩存條目爲UTF8,而非.NET默認的UTF16,那麼其大小就能減少一半。這一點非常重要,畢竟我們的整體業務規模相當可觀。

案例

我曾問過,Twitter那邊的員工遇上突然發生大量緩存故障的情況時,會如何應對。其實挺有意思的,下面我會嘗試複述整個故事:

Redis危機:邊搶救邊施壓

有一次,我們的Redis主緩存量達到70 GB左右的水平。要知道,服務器上的總緩存量也只有96 GB。當時我們看到佔用量仍在隨時間推移而增長,因此我們打算進行服務器升級並進行轉換。當準備好硬件並進行故障轉移時,舊設備的緩存使用數字已經達到約90 GB。嘭,關機,新設備啓動——我們成功了!

但真有這麼冬日嗎?這部分工作不是由我負責,但我也有參與規劃。我們當時沒考慮到Redis中的BGSAVE會發生內存分叉(至少在2.x版本中還沒考慮到)。我們當時還挺開心的,覺得能及時做好準備是個了不起的成就。那天週末,我們按下按鈕,將數據複製到新服務器,並準備以故障轉移的方式進行系統切換。

之後,所有網站就立刻下線了。

當時內存分叉的情況是,遷移期間變更的數據都會被複制到影子服務器內,這部分內容在克隆完成前都不會發布……因爲我們需要服務器處於穩定狀態,並對從那時起出現的所有變更進行初始化,而後複製到新節點中(否則我們會丟失這些新變更)。因此,新變更發生的速度也就是複製期間內存增長的速度。很快容量就達到了6 GB,接着Redis崩潰,Web服務器失去了Redis的支持(多年以來我們從沒見過這樣的情況)。

因此,我馬上給團隊打了電話,並利用新服務器與空緩存讓網站重新上線。需要強調的是,Redis本身並沒有任何問題,問題出在我們這裏。十年以來,Redis一直穩定可靠,它是我們使用過的最穩定的基礎設施之一……穩定到甚至感受不到它的存在。

好了,下面來看另一個教訓。

沒有使用本地緩存

說起這個故事,可能某位開發人員要在心裏暗暗罵我了。但我得強調,我絕對沒有任何惡意,咱們只說具體情況。

當我們從Redis的本地/遠程雙層緩存中提取某個緩存值時,我們實際會發出兩條命令:一條鍵獲取命令,以及一條TTL命令。TTL的結果會告訴我們Redis已經將該值緩存了多少秒……我們會直接把該時間設定爲本地服務器內存緩存週期。以前我們曾經通過庫代碼使用-1標記值來表達沒有TTL的情況。對於“無TTL”,我們在重構中轉而使用null語法……但接下來就出現了布爾邏輯錯誤。仍然以之前提到的Get爲例,以下簡單語句:

if (ttl != -1) // ... push into L1
被轉化爲:
if (ttl == null) // ... push into L1

但我們的大多數緩存都擁有一項TTL。這意味着絕大多數鍵(佔比可能高於95%)不再在一級緩存(本地服務器內存)中緩存。每一次調用這些鍵,都會轉到Redis並返回結果。Redis擁有極高的彈性與速度表現,因此我們在幾個小時內都沒注意到這個問題。後來,我們將實際邏輯更正爲:

if (ttl != null) // ... push into L1

然後就一切正常了。

將頁面緩存週期設定爲0.000006秒

這個數字沒寫錯。2011年,我們在頁面級輸出緩存中發現了一些代碼:

.Duration = new TimeSpan(60);

它想要表達的含義其實是將緩存週期設定爲1分鐘。如果TImeSpan的默認構造函數採用秒爲單位,那麼一切都毫無問題。

但結果並非如此,我們發現內存使用量只增加了一點點,而CPU使用量也開始上升。

好心辦壞事

多年來,我們一直對大多數頁面進行輸出結果緩存。其中包括主頁、問題列表頁面、問題頁面本身以及RSS饋送。

請注意:在緩存時,大家需要根據緩存類別對緩存鍵進行更改。具體來說,我們需要考慮:是否匿名?是否移動?壓縮、gzip、還是不壓縮?實際上,我們不能也不應該對登錄用戶的輸出內容進行緩存。我們的統計信息位於頂端,而且面向每位用戶。一旦緩存,我們會發現不同頁面視圖間的顯示內容存在衝突。

無論如何,我們發現這些緩存類型在處理過去兩週內80%的問題時,都存在命中率過低的問題。真的很低。但內存佔用量卻非常高(高到中心直接在大對象堆上運行)。此外,垃圾回收機制的清理成本也相當可觀。

事實證明,這兩項要素非常重要,不加以調整則會導致緩存反而拖累系統性能。我們從偶爾命中的緩存系統內獲得的性能提升,遠遠低於建立以及清理緩存帶來的性能成本。這一點讓我們非常困惑,但在具體觀察相關數字時,問題的核心逐漸清晰起來。

過去幾年,Stack Overflow(以及所有問答站點)的輸出結果都沒有進行緩存。ASP .NET Core中也不存在輸出緩存機制,因此請不要使用這種方式。

目前,我們仍然需要在某些RSS饋送路由上對完整的XML響應字符串進行緩存(類似於輸出緩存,但沒有實際使用輸出緩存)。這是因爲此類路由的命中率很高。該特定緩存仍具有之前提到的所有缺點,但極高的命中率讓我們決定做出妥協。

意識到現實世界要比我們的想象更瘋狂。當.NET 4.6.0剛剛問世時,我們發現了一個錯誤。我當時正在深入研究MiniProfiler爲什麼沒有進行首頁面本地加載,錯誤的發現讓我們猝不及防。

這項bug出現在緩存層當中,具體取決於問題的性質以及尾調用的影響。總結來講:系統不會根據我們傳入的參數進行方法調用。這意味着我們只能使用隨機的緩存持續時間。幸運的是,這個RyuJIT問題在下一個月就得到了熱修復。

.NET 4.6.2緩存響應週期長達2017年。好吧,這個並不是服務器端緩存的問題,但我覺得很有趣所以一併算進來。在部署.NET 4.6.2之後不久,我們注意到客戶端緩存以及CDN緩存增長都出現了某些異常。事實證明,.NET 4.6.2當中存在一項bug。

原因很簡單在將代表“現在”的DateTime值與應該到期的緩存響應時間進行比較,並計算二者差值以獲取Cache-Control頭中max-age部分時,該值會因爲一系列原因而被重置爲“現在”值——即差值爲0。所以我們假設:

2017-04-05 01:17:01 (cache until) - 2017-04-05 01:16:01 (now) = 60 seconds

然後,假定“現在”值被替換爲0001-01-01 00:00:00…

2017-04-05 01:17:01 (cache until) - 0001-01-01 00:00:00 (now) = 63,626,951,821 seconds

幸運的是,這裏的計算非常簡單,我們相當於要求瀏覽器將該值緩存2017年4個月5天1小時17分鐘1秒。很明顯,無論是在瀏覽器上還是CDN中,這麼長的緩存週期都屬於嚴重錯誤。

我們沒能及時發現這個問題,現在設置已經部署在生產環境中,而且回滾無法快速解決問題。我們該怎麼辦?

還好我們已經開始使用Fastly,其中採用Varnish與VCL。因此,我們可以在那裏檢測這些瘋狂的max-age值並將其覆蓋爲正確值。在第一次推送時,我們沒有注意到Fastly上緩存鍵常規哈希算法中的某個關鍵部分,因此導致用戶在嘗試加載某個問題時會重複驗證其登錄身份。很抱歉,幾分鐘後我們修正了這個問題,並確定代碼能夠正常運行。

干擾Redis正常運行

我們還遇到過這樣一個問題,即主機本身會中斷Redis並引發能夠明顯感覺到的流水線超時。我們觀察了一下Redis:這種慢速現象是隨機的。與之相關的命令也沒有任何共通的模式或者關聯(例如包含大量小鍵)。我們又看了看客戶端與服務器這邊的網絡與數據包跟蹤記錄,都很正常——看起來,暫停問題源自Redis主機內部的相關計時機制。
那麼……答案是什麼?在經過大量手動分析與故障排查之後,我們發現主機上的另一個進程總會同時出現利用率飆升。雖然這峯值不算高,但引發該峯值的理由非常重要。

事實證明,監控進程會啓動vmstat以獲取內存統計信息。雖然具體啓動頻率很正常,很合理,但vmstat會將Redis從其正在運行的CPU核心上移除,而且移除哪一個實例是隨機的。這種面向另一核心的切換,使我們觀察了Redis頻繁超時的具體現象。

在發現了問題之後,我們首先想到設備當中其實擁有充足的計算核心,因此我們決定將Redis固定至物理主機上的特定核心。這將確保服務器的主要功能始終具有運行優先級,而監控則屬於次要負載。
後來我瞭解到,Redis現在引入了內置的延遲監控機制,推出於2.8版本後期。

緩存常見問題

我也遇到過不少常見但很難歸類的問題,我想這裏不如把它們整理成一份常見問題合集。在後續更新中,我也可以把更多有趣的問題添加到這份清單當中。

爲什麼不使用Redis Cluster?

A: 主要原因有以下幾點:

  1. 我們需要使用數據庫,而Cluster當中並不提供數據庫功能(這是爲了控制消息複製頭的大小)。我們可以將數據庫ID移動到緩存鍵中來解決這個問題(正如之前提到的本地緩存方法),但在使用大型數據庫時,我們總得在維護方面做出權衡——例如弄清楚哪些鍵佔用的空間最多。
  2. 到目前爲止,其複製拓撲爲節點到節點,這意味着主集羣上的維護工作需要在災難恢復數據中心的輔助集羣上採用相同的移動拓撲。這會提高維護工作的難度。我們正在等待集羣到集羣複製功能的推出。
  3. 需要3個以上的節點,集羣才能正常運行(出於選舉機制等考慮)。我們目前各個數據中心只運行2臺物理Redis服務器,其中只有1臺服務器的性能高於我們的需求,另一臺專門用作副本/備份。

爲什麼不使用Redis Sentinel?

A: 我們正在考慮,但其整體管理難度並不比我們現在更低。雖然將端點連接起來並進行定向的想法很棒,但管理工作會非常複雜;由於Redis本身非常穩定,所以我們沒必要改變現有的策略。Sentinel最大的問題之一,在於其會將當前拓撲狀態寫入同一配置文件。這對採用配置託管的用戶來講不太友好。例如,我們現在使用的是Puppet,每次運行時文件變更都會出現問題。

如何保護Stack Overflow團隊版的緩存?

A: 我們一直維護着一套隔離網絡以及獨立的Redis服務器以保障私有數據。Stack Overflow企業版的客戶都擁有自己的隔離網絡與Redis實例。

如果Redis宕機了,您會怎麼辦?

A: 首先,我們的數據中心擁有一套備份。但如果假設備份也崩潰了,那隻能面對最差的結果。如果沒有Redis,我們在重啓應用程序時會有點慢,冷緩存時性能會比較差,SQL Server會受到一定影響,但這些逐漸都會過去。如果Redis宕機(或者說稍後才能上線),其實問題自己就會慢慢解決,而且不會造成什麼數據丟失問題。我們已經將Redis視爲本地開發的可選項,因爲其早在其它基礎設施組件之前就已經存在,而且直到今天也仍然屬於可選項。它不是任何事情的事實來源,其中包含的所有緩存數據都可以從源代碼中重新獲取。Redis中的隊列包含賬戶合併類型操作(以亞秒級單位執行,因此屬於短隊列)、聚合器(將網絡事件記錄至我們的中央數據庫內)以及一部分分析操作(暫時丟失A/B測試數據並不是什麼大問題)。所有這些都是問題,但都不是太嚴重。

Redis數據庫有什麼缺點嗎?

A: 當然有,我自己就發現了一點。雖然上限很高,但它最終還是會影響到性能指標。當Redis過存儲鍵進行過期處理時,它會遍歷數據庫以查找並清理這些鍵——大家可以理解成一次全方位的“命名空間”檢查。最重要的是,這種宏觀循環一直存在。由於每100毫秒運行一次,因此其會對性能造成不小的影響。

您打算對“一級”/“二級”緩存的實現方案進行開源嗎?

A: 我們一直在考慮,但目前主要有以下障礙:

  1. 目前的方案個性程度太高。這是一套高度面向多租戶的方案,可能並不適合很多朋友的具體需求。這意味着我們必須拿出一些精力重新設計API。我們希望把這組API直接放進StackExchange.Redis客戶端,或者將其構建成獨立的庫。
  2. 另一個想法是讓Redis服務器自身擁有更高的核心數量支持能力(用於處理更多發佈/訂閱負載)。這項能力即將在Redis 版本6當中出現,因此我們可以大大減少發佈/訂閱機制的自定義工作量,並使用更多標準化的實現方法。這種定製化的因素越少,我們的方案才能越適合更多人的需求。
  3. 時間。我希望我們能有更多充裕的時間,時間纔是最寶貴的東西。

在流水線當中,您如何處理大型Redis負載?

A: 在這方面,我們使用一條獨立的“bulky”連接。其擁有更高的超時時間,而且使用頻率很低。接下來的問題,在於是否有必要把bulky引入Redis。如果某個大型條目值得緩存,但提取成本又太高,我們可能就不會使用Redis,而傾向於利用“本地緩存”爲多臺Web服務器進行多次提取。每用戶功能(問答類用戶會話其實具有很高的Web服務器粘性)可能也適合用於處理這類問題。

如果有人在生產環境中運行KEYS,結果會如何?

A: 我可能會暴跳如雷。不過說真的,在Redis 2.8.0上,我們至少可以使用SCAN——它不會把Redis徹底堵死,而只是以塊的形式進行鍵轉儲,同時允許其它命令繼續執行。KEYS則可能導致生產擁塞——這裏說“可能”恐怕不太準確,應該是100%會在我們的業務規模下引發擁塞。

如果有人在生產環境中運行FLUSHALL,結果會如何?

A: 這絕對屬於刑事案件,毫無疑問。Redis 6正計劃添加ACL,用以限制可疑操作池。

警方調查人員要如何弄清上述情況?他們如何就延遲峯值進行取證?

A: Redis擁有一項出色的功能,名叫SLOWLOG,它會在默認情況下記錄所有持續時間超過10毫秒的命令。我們可以進行具體調整,但由於一切正常操作應該都能快速完成,所以我們會繼續使用10毫秒這個標準。運行SLOWLOG時,我們可以看到最後n個條目(可對n進行配置)、命令與參數。Opserver能夠在實例頁面上顯示這些內容,以幫助我們輕鬆找到壞傢伙。當然,問題可能源自網絡延遲或者主機上自然發生的CPU峯值/爭用。(我們使用處理器關聯以固定Redis實例,以避免出現資源爭用問題。)

是否在Stack Overflow企業版中使用Azure Cache for Redis?

A: 在用,但可能不會長期使用。在爲測試環境創建這樣的緩存時,配置週期實在是太長了。不開玩笑,通常需要幾十分鐘甚至一個小時。我們後續打算使用容器,這不僅能夠加快速度,還能幫助我們在各類部署模式之間實現版本控制。

每位開發人員都有必要了解緩存決策中的這麼多細節嗎?

A: 當然沒必要,這裏涉及的具體考量與指標太多太雜了。在我看來,開發人員只需要對緩存層的概念以及相對成本有那麼一點了解就可以。正因爲如此,我纔在這裏多次強調數量級單位,開發人員應該藉此考量成本/收益評估單位,並學會如何選擇緩存位置。大多數業務每天都不需要面對數億次的點擊量,因此也就沒有太大的優化決策壓力——畢竟總量不會很大。這裏我只聊自己在Stack Overflow遇到的實際問題,外加自己的解決思路,希望能夠給大家帶來一點啓示。

最後,我想整理一下本文中提到的各類工具,外加用於支持緩存系統的其它方案:

原文鏈接:
https://nickcraver.com/blog/2019/08/06/stack-overflow-how-we-do-app-caching/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章