Stories

Detail Return Return

從緩存到分佈式緩存的那些事 - Stories Detail

作者:秦懷

1 緩存前世今生

1.1 故事從硬件開始

Cache 一詞來源於 1967 年的一篇電子工程期刊論文。其作者將法語詞“cache”賦予“safekeeping storage”的涵義,用於電腦工程領域。當時沒有 Cache,CPU 和內存都很慢,CPU 直接訪問內存。

  • Intel 80386芯片組增加了對可選的 Cache 的支持,高級主板帶有 64KB,甚至高端的 128KB Write-Through Cache。
  • Intel 80486 CPU 裏面加入了 8KB 的 L1 Unified Cache,當時也叫做內部 Cache,不分代碼和數據,都存在一起;芯片組中的 Cache,變成了 L2,也被叫做外部 Cache,從 128KB 到 256KB 不等;增加了 Write-back 的 Cache 屬性。
  • Pentium (奔騰) CPU 的 L1 Cache 分為 Code 和 data,各自 8KB;L2 還被放在主板上。
  • Pentium Pro(奔騰) 的 L2 被放入到 CPU 的 Package 上。
  • Pentium 3(奔騰) 開始,L2 Cache 被放入了 CPU 的 Die 中。
  • Intel Core CPU 開始,L2 Cache 為多核共享。

當 CPU 處理數據時,它會先到 Cache 中去尋找,如果數據因之前的操作已經讀取而被暫存其中,就不需要再從 隨機存取存儲器(Main memory)中讀取數據——由於 CPU 的運行速度一般比主內存的讀取速度快,主存儲器週期(訪問主存儲器所需要的時間)為數個時鐘週期。因此若要訪問主內存的話,就必須等待數個 CPU 週期從而造成浪費。

提供“緩存”的目的是為了讓數據訪問的速度適應 CPU 的處理速度,其基於的原理是內存中“程序執行與數據訪問的局域性行為”,即一定程序執行時間和空間內,被訪問的代碼集中於一部分。為了充分發揮緩存的作用,不僅依靠“暫存剛剛訪問過的數據”,還要使用硬件實現的指令預測數據預取 技術——儘可能把將要使用的數據預先從內存中取到緩存裏。

CPU 的緩存曾經是用在超級計算機上的一種高級技術, 不過現今電腦上使用的的 AMD 或 Intel 微處理器都在芯片內部集成了大小不等的數據緩存和指令緩存, 通稱為 L1 緩存 (L1 Cache 即 Level 1 On-die Cache, 第一級片上高速緩衝存儲器);
而比 L1 更大容量的 L2 緩存曾經被放在 CPU 外部 (主板或者 CPU 接口卡上), 但是現在已經成為 CPU 內部的標準組件; 更昂貴的 CPU 會配備比 L2 緩存還要大的 L3 緩存 (level 3 On-die Cache 第三級高速緩衝存儲器)

1.2 概念的擴展

如今緩存的概念已被擴充, 不僅在 CPU 和主內存之間有 Cache, 而且在內存和硬盤之間也有 Cache (磁盤緩存), 乃至在硬盤與網絡之間也有某種意義上的 Cache 稱為 Internet 臨時文件夾或網絡內容緩存等凡是位於速度相差較大的兩種硬件之間, 用於協調兩者數據傳輸速度差異的結構, 均可稱之為 Cache

現在我們軟件開發中常説的緩存,是指磁盤和 CPU 之間的,協調兩者傳輸速度的結構。

2 緩存的特徵

2.1 主要特徵

  • 命中率:命中率=返回正確結果數/請求緩存次數,命中率越高,表明緩存的使用率也就越高。
  • 吞吐量:緩存的吞吐量使用 OPS 值(每秒操作數,Operations per Second,ops/s)來衡量,反映了對緩存進行併發讀、寫操作的效率,即緩存本身的工作效率高低。
  • 緩存淘汰策略:

    • FIFO (first in first out):先進先出策略,最先進入緩存的數據在緩存空間不夠的情況下(超出最大元素限制)會被優先被清除掉,以騰出新的空間接受新的數據。
    • LFU (less frequently used):最少使用策略,無論是否過期,根據元素的被使用次數判斷,清除使用次數較少的元素釋放空間。
    • LRU (least recently used):最近最少使用策略,無論是否過期,根據元素最後一次被使用的時間戳,清除最遠使用時間戳的元素釋放空間。

2.2 是否適合緩存的考慮

不是所有數據都適合緩存,我們使用緩存,是想用較小的成本換取較大的收益,在決定是否緩存之前,可以考慮以下的問題:

  • 是否有一致性的要求,緩存和底層存儲是否需要強一致性
  • 緩存是不是高效的?命中率大概怎麼樣?
  • 緩存多久,是否需要設置 TTL
  • 數據結構是否適合緩存
  • 計算後緩存,亦或是緩存之後計算

2.3 驚羣效益

如果許多不同的應用程序進程同時請求一個緩存鍵,但出現緩存未命中,隨後所有應用程序進程都並行執行相同的數據庫查詢,此時就會發生驚羣效應,也稱作疊羅漢效應。此查詢的代價越高,對數據庫的影響就越大。一般可以通過緩存預熱、緩存不存在的空值來減少。

3 緩存的分類

根據應用的耦合度,一般分為本地緩存和分佈式緩存:

  • 本地緩存:在應用中的緩存組件,應用和 Cache 是在同一個進程內,請求特別快,沒有網絡開銷。
  • 分佈式緩存:與應用分離的緩存組件,可以認為是獨立的服務,和應用分開,多個應用之間可以共享,但是會存在網絡請求。

4 分佈式緩存存在的必要性

先聊緩存的必要性,計算機的世界裏,倘若有無法解決不了的問題,一般都可以再加一層來解決,而緩存從被提出開始,就是那個加了的一層。CPU的速度很快,數據庫操作很慢,怎麼辦?CPU緩存很小很貴很快,但是數據庫的磁盤很慢很大很便宜,怎麼辦?內存來解決!

可以提前將一些比較耗時的數據結果暫存到內存(如果有持久化,也會同時存儲在磁盤中)中,如果有相同請求,可以直接返回,如果數據變更(更新或者刪除),再處理掉緩存。大家平日裏接觸最多的,可能就是瀏覽器的緩存,有時候多次訪問,有些數據根本不會再去請求,會優先使用瀏覽器的本地緩存。
除此之外,微博也是如此,

單機的緩存,可以滿足大部分的場景,但是單節點的最大容量不能超過整個系統的內存,而且像 memcached 這種存儲,斷電內容就會徹底丟失,Redis 則有持久化的能力,只是通電之後需要花點時間從磁盤將數據 load 回內存中。

現在幾乎應用服務器都是分佈式的,如果只做單機緩存,意味着每個服務器的緩存,都存了一份,極大概率存在不一致的情況,比如 一個用户第一次請求命中機器 A,有緩存,第二次命中機器 B ,又沒緩存,只能重新緩存了一份在機器 B 上。

5 分佈式緩存設計可能需要考慮的幾個問題

站在巨人(Redis)的肩膀上, 我們可以學到很多優秀的設計、理念,設計一個功能比較全面的分佈式緩存,到底需要考慮哪些問題?

下面聊聊幾點比較常見的:

5.1 、斷電了怎麼辦?(持久化)

必須支持 持久化,可以異步的將數據刷盤,落到磁盤中,重新啓動的時候能夠加載已有的數據。那刷盤的時機是怎麼樣的?只要改一個數據就刷一次盤麼?還是修改數據到達某個閾值,才進行刷盤,這些都是策略,最好是可以支持配置,這些規則其實我們都可以從 Redis 這些優秀的緩存中間件中學習到。
當然,如果在一定場景下,能接受數據完全丟失,不需要持久化,那麼可以設置為關閉,可以節約性能開銷。

5.2 2、內存不足怎麼辦?(緩存淘汰策略)

單機內存不足,可以刪除一些數據。但是到底刪除哪些數據,這必須有一個決策的算法,這就是緩存淘汰策略。
常見的緩存淘汰策略有以下幾種:

  • FIFO:先進先出(First In,First Out),如同隊列,新數據在尾部加入,內存不足的時候,淘汰的數據從隊列頭部移除。
  • LFU:最低頻率使用淘汰算法(Least Frequently Used),也稱為最近最不常使用,將使用頻率最低的數據淘汰。
  • LRU:最近時間未使用(Least Recently used),也稱為最近最少使用,內存不足的時候,總是淘汰最長時間未被使用得數據。

5.3 3、需不需要自定義協議?

一個穩定的分佈式緩存系統,還需要一套序列化協議,怎麼設計一個簡單而又高效的協議,是個值得思考的問題。

比如 Redis 使用得就是 RESP(REdis Serialization Protocol) 協議,這是專門為 Redis 設計的,屬於應用層的通信協議,本質上和 HTTP 是同一層級,而 Redis 的傳輸層使用的是 TCP。如果是服務器接收請求的場景,那麼服務端從 TCPsocket 緩存區裏面讀取數據,然後經過了 RESP 協議解碼知乎,會得到我們所需的指令。

簡單講一下,RESP 主要就是 想用更少的數據,表達所需的更豐富的內容,也就是壓縮數據量,增加信息量。
比如第一個字節,決定了數據類型:

  • 簡單字符串 :Simple Strings,第一個字節響應 +
  • 錯誤:Errors,第一個字節響應 -
  • 整型:Integers,第一個字節響應 :
  • 批量字符串:Bulk Strings,第一個字節響應 $
  • 數組:Arrays,第一個字節響應 *

5.4 4、一台機器存儲不夠怎麼辦?(可拓展)

不能一直增加單台機器的容量,拋開成本不講,單機大容量,網絡帶寬,磁盤 IO,計算資源等都可能成為較大的瓶頸,肯定需要支持橫向拓展(水平拓展),比如 Redis 集羣模式。與橫向拓展對應的是垂直拓展,也就是增加單個節點的容量,性能。互聯網發展的這些年,已經證明了分佈式系統是一個更優的選項。

5.5 5、如果有一台機器宕機了怎麼辦?(高可用)

如果多台機器中,有機器宕機怎麼辦?從事前、事中、事後來看:

  • 事前:需要可監控,需要有監控節點(比如 Redis 中的哨兵),並且有可以切換的節點(從節點)。
  • 事中:怎麼切換,哪一個機器作為“主持人“角色進行切換,切換哪一個機器,都是需要抉擇的。
  • 事後:切換之後,下線機器怎麼處理。

5.6 6、是否支持併發?(高併發)

併發寫入怎麼辦?Redis 採取的是隊列的方式,內部不允許併發執行,也就不需要加鎖,解鎖的操作,如果考慮使用鎖來實現,需要同時考慮上下文切換的成本,而我們簡單的版本可以使用加鎖的方式來實現。

6 使用分佈式緩存可能會遇到的幾個問題

6.1 1、一致性問題

如何保證緩存和數據庫的一致性問題,是一個比較大的話題,我們除了保證數據庫和緩存一致,分佈式緩存的 master 和 slave 也需要保持一致。一般一致性分為以下幾種:

  • 強一致性:數據庫更新操作與緩存更新操作是原子性的,緩存與數據庫的數據在任何時刻都是一致的,很難實現。
  • 弱一致性:當數據更新後,緩存中的數據可能是更新前的值,也可能是更新後的值,這種更新是異步的。
  • 最終一致性:一種特殊的弱一致性,在一定時間後,數據會達到一致的狀態。最終一致性是弱一致性的理想狀態,也是分佈式系統的數據一致性解決方案上比較推崇的。

根據 CAP 原理,分佈式系統在可用性、一致性和分區容錯性上無法兼得,通常由於分區容錯無法避免,所以一致性和可用性難以同時成立。

這裏的幾種方案就不展開講了,幾種更新策略:

  • 1、先更新緩存,再更新數據庫:

    • 在兩個線程一起更新的場景下,如果先更新緩存的線程後更新數據庫,很容易出現一致性問題。
  • 2、先更新數據庫,再更新緩存

    • 在兩個線程一起更新的場景下,如果先更新數據庫的線程由於執行慢了一些,後更新緩存,很容易出現一致性問題。
  • 3、先刪除緩存,再更新數據庫

    • 先刪除緩存的線程,後更新數據庫,仍然有一致性問題
  • 4、先更新數據庫,再刪除緩存

    • 先更新數據庫的線程,後刪除緩存,沒有問題!刪除緩存之後,會回源到數據庫。
    • 但是沒刪除緩存之前,數據庫更新了,讀取會讀到髒數據。所以我們一般推薦雙刪,更新之前刪一次,更新之後刪一次。
    • 這個時候有人會問,如果同時有個讀請求,讀的是寫之前的髒數據,但是寫入到緩存是比較慢的,剛剛好在刪除之後,那緩存數據就還是髒數據?是的,這個時候一般靠第二次刪除延遲來處理,延遲刪除。
    • 這個時候肯定有人問,那要是刪除失敗了怎麼辦?

      • 直接補償重試
      • 消息隊列,異步重試
      • 基於 mysql binlog 增量訂閲消費補償

這個問題我們在這個分佈式緩存的裏面就不詳細聊了,之後單獨聊這個話題,串行化是我們最後的倔強,但是高併發就難了,所以我們一般是保證最終一致性即可。

6.2 2、緩存穿透

緩存穿透是指,緩存和數據庫都沒有的數據,被大量請求,比如訂單號不可能為 -1,但是用户請求了大量訂單號為 -1 的數據,由於數據不存在,緩存就也不會存在該數據,所有的請求都會直接穿透到數據庫。
如果被惡意用户利用,瘋狂請求不存在的數據,就會導致數據庫壓力過大,甚至垮掉。
注意:穿透的意思是,都沒有,直接一路打到數據庫。

那對於這種情況,我們該如何解決呢?

  1. 接口增加業務層級的Filter,進行合法校驗,這可以有效攔截大部分不合法的請求。
  2. 作為第一點的補充,最常見的是使用布隆過濾器,針對一個或者多個維度,把可能存在的數據值 hash 到 bitmap 中,bitmap 證明該數據不存在則該數據一定不存在,但是 bitmap 證明該數據存在也只能是可能存在,因為不同的數值 hash 到的 bit 位很有可能是一樣的,hash 衝突會導致誤判,多個 hash 方法也只能是降低衝突的概率,無法做到避免。
  3. 另外一個常見的方法,則是針對數據庫與緩存都沒有的數據,對空的結果進行緩存,但是過期時間設置得較短,一般五分鐘內。而這種數據,如果數據庫有寫入,或者更新,必須同時刷新緩存,否則會導致不一致的問題存在。

6.3 3、緩存雪崩

緩存雪崩是指緩存中有大量的數據,在同一個時間點,或者較短的時間段內,全部過期了,這個時候請求過來,緩存沒有數據,都會請求數據庫,則數據庫的壓力就會突增,扛不住就會宕機。
針對這種情況,一般我們都是使用以下方案:

  1. 如果是熱點數據,先預熱,而且可以考慮設置永遠不過期。
  2. 緩存的過期時間除非比較嚴格,要不考慮設置一個波動隨機值,比如理論十分鐘,那這類key的緩存時間都加上一個1~3分鐘,過期時間在7~13分鐘內波動,有效防止都在同一個時間點上大量過期。
  3. 方法1避免了有效過期的情況,但是要是所有的熱點數據在一台redis服務器上,也是極其危險的,如果網絡有問題,或者redis服務器掛了,那麼所有的熱點數據也會雪崩(查詢不到),因此將熱點數據打散分不到不同的機房中,也可以有效減少這種情況。
  4. 也可以考慮雙緩存的方式,數據庫數據同步到緩存 A 和 B,A 設置過期時間,B 不設置過期時間,如果 A 為空的時候去讀 B,同時異步去更新緩存,但是更新的時候需要同時更新兩個緩存。
  5. 使用緩存組件時,可以設置為異步回源,或者允許讀取未物理刪除的數據。

比如設置產品的緩存時間:


redis.set(id,value,60*60 + Math.random()*1000);

6.4 4、緩存擊穿

緩存擊穿是指數據庫原本有得數據,但是緩存中沒有,一般是緩存突然失效了,這時候如果有大量用户請求該數據,緩存沒有則會去數據庫請求,會引發數據庫壓力增大,可能會瞬間打垮。

針對這類問題,一般有以下做法:

  1. 如果是熱點數據,那麼可以考慮設置永遠不過期。
  2. 如果數據一定會過期,那麼就需要在數據為空的時候,設置一個互斥的鎖,只讓一個請求通過,只有一個請求去數據庫拉取數據,取完數據,不管如何都需要釋放鎖,異常的時候也需要釋放鎖,要不其他線程會一直拿不到鎖。

下面是緩存擊穿的時候互斥鎖的寫法,注意:獲取鎖之後操作,不管成功或者失敗,都應該釋放鎖,而其他的請求,如果沒有獲取到鎖,應該等待,再重試。當然,如果是需要更加全面一點,應該加上一個等待次數,比如1s中,那麼也就是睡眠五次,達到這個閾值,則直接返回空,不應該過度消耗機器,以免當個不可用的場景把整個應用的服務器帶掛了。

    public static String getProductDescById(String id) {
        String desc = redis.get(id);
        // 緩存為空,過期了
        if (desc == null) {
            // 互斥鎖,只有一個請求可以成功
            if (redis.setnx(lock_id, 1, 60) == 1) {
                try {
                    // 從數據庫取出數據
                    desc = getFromDB(id);
                    redis.set(id, desc, 60 * 60 * 24);
                } catch (Exception ex) {
                    LogHelper.error(ex);
                } finally {
                    // 確保最後刪除,釋放鎖
                    redis.del(lock_id);
                    return desc;
                }
            } else {
                // 否則睡眠200ms,接着獲取鎖
                Thread.sleep(200);
                return getProductDescById(id);
            }
        }
    }

6.5 5、緩存熱點

像微博這種,有些熱點新聞,突然爆了,大量用户訪問同一個 key,key 在同一個緩存節點,很容易就過載,節點會卡頓甚至掛掉,這種我們就叫緩存熱點。

解決方案一般是通過實時數據流比如 Spark ,分析熱點 Key ,一般都有一個增長的過程,然後在 Key 後面加上一些隨機的編號,比如明星出軌_01, 明星出軌_02...,目的是讓這些 key 分佈在不同的機器上,而客户端獲取的時候,帶上隨機的 key,隨機訪問一個就可以。

想要探測熱 Key,除了實時數據流,也可以在 redis 之上的 proxy 上面做,一般我們在公司都不是直接連接 redis ,而是連接的 proxy,因此我們也可以通過在 proxy 中使用滑動時間窗口,對每個 key 進行計數,超過一定的閾值,就設置為熱 key。

那如何快速針對熱 key 進行動態處理呢?弄一個獨立的緩存數據服務,根據流量來動態拆分熱 key,動態的增長成為熱 key 我們可以通過分析發現,但是如果是秒殺等業務呢?需要支持實時拆分熱 key,用分佈式配置中心來配置熱 key,感知到配置熱 key 則進行需要的處理,這裏因業務而異,可以降級成讀取本地內存,可以進行拆分等等。

當然,如果能夠正對秒殺等活動,或者大促活動,拉出獨立的集羣進行路由,隔離影響,那也是一種方案。

這是京東的處理方案: https://gitee.com/jd-platform-opensource/hotkey ,對任意突發性的無法預先感知的熱點請求,包括並不限於熱點數據(如突發大量請求同一個商品)、熱用户(如爬蟲、刷子)、熱接口(突發海量請求同一個接口)等,進行毫秒級精準探測到。 然後對這些熱數據、熱用户等,推送到該應用部署的所有機器JVM內存中,以大幅減輕對後端數據存儲層的衝擊,並可以由客户端決定如何使用這些熱key(譬如對熱商品做本地緩存、對熱用户進行拒絕訪問、對熱接口進行熔斷或返回默認值)。 這些熱key在整個應用集羣內保持一致性。

6.6 6、緩存大 Key

緩存大 key 是指緩存的值 value 特別大,如果同一時間大量請求訪問了同一個大 key,帶寬很容易被佔滿,其他請求進不來。

大 key 定義參考如下:

  • string類型的key超過10KB
  • hash/set/zset/list 等數據結構中元素個數大於 5k/整體佔用內存大於 10MB

如何判斷是不是大 key,一般看網絡的出流量,如果突增特別厲害,但是入流量變化不大的情況下,基本可以判斷為大 key

  • 事前我們可以在代碼 review 的時候就得判斷 value 是不是特別大,不能寫這種代碼。或者封裝一層 redis 操作切面,異步對 key 的 value 做監控,進行打點告警。
  • 其次,寫代碼的時候如果發現要 set 這種大的 value 值,那就得想辦法拆分,把對象拆成屬性,或者按照屬性分類。如果是一個不可分割的整體,那就得考慮一下技術方案是不是要推翻重來了,一般我們不太可能把幾 M 的圖片直接二進制存 redis。
  • dump RDB 數據,進行離線數據分析,給出告警,但是不夠實時。
  • Redis 提供了 bigkeys 參數能夠使 redis-cli 以遍歷的方式分析 Redis 實例中的所有 Key,並返回 Key 的整體統計信息與每個數據類型中 Top1 的大 Key,bigkeys 僅能分析並輸入六種數據類型(STRING LIST HASH SET ZSET STREAM ), 命令示例為 redis-cli -h 127.0.0.1 -p 6379 --bigkeys

7 總結

緩存不是銀彈,是一把刀,用得好,可以亂殺(誇大),用不好,得包紮(一點不誇大,得提桶跑路那種)。

user avatar blbl-blog Avatar ayuan01 Avatar qian5201314 Avatar ligaai Avatar kohler21 Avatar steven_code Avatar dalideshoushudao Avatar jianghushinian Avatar lyflexi Avatar fulade Avatar codecraft Avatar manshenjiroudehuajuan Avatar
Favorites 38 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.