從快取到分散式快取的那些事

第十六封發表於2024-11-29

作者:秦懷

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的快取時間都加上一個13分鐘,過期時間在713分鐘內波動,有效防止都在同一個時間點上大量過期。
  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 LISTHASHSETZSETSTREAM), 命令示例為 redis-cli -h 127.0.0.1 -p 6379 --bigkeys

7 總結

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

相關文章