使用 Spring Boot-Redis 實現分佈式緩存解決方案

在微服務飛速發展的今天,在高併發的分佈式的系統中,緩存是提升系統性能的重要手段。沒有緩存對後端請求的攔截,大量的請求將直接落到系統的底層數據庫。系統是很難撐住高併發的衝擊,下面就以 Redis 爲例來聊聊分佈式系統中關於緩存的設計以及過程中遇到的一些問題。

一、分佈式緩存簡介

1. 什麼是分佈式緩存

分佈式緩存:指將應用系統和緩存組件進行分離的緩存機制,這樣多個應用系統就可以共享一套緩存數據了,它的特點是共享緩存服務和可集羣部署,爲緩存系統提供了高可用的運行環境,以及緩存共享的程序運行機制。

2、本地緩存 VS 分佈式緩存

本地緩存:是應用系統中的緩存組件,其最大的優點是應用和 cache 是在同一個進程內部,請求緩存非常快速,沒有過多的網絡開銷等,在單應用不需要集羣支持的場景下使用本地緩存較合適;但是,它的缺點也是緩存跟應用程序耦合,多個應用程序無法共享緩存數據,各應用或集羣的各節點都需要維護自己的單獨緩存。很顯然,這對內存是一種浪費。

分佈式緩存:與應用分離的緩存組件或服務,分佈式緩存系統是一個獨立的緩存服務,與本地應用隔離,這使得多個應用系統之間可直接的共享緩存數據。目前分佈式緩存系統已經成爲微服務架構的重要組成部分,活躍在成千上萬的應用服務中。但是,目前還沒有一種緩存方案可以解決一切的業務場景或數據類型,我們需要根據自身的特殊場景和背景,選擇最適合的緩存方案。

3、分佈式緩存的特性

相對於本地應用緩存,分佈式緩存具有如下特性:  

  1. 高性能:當傳統數據庫面臨大規模數據訪問時,磁盤 I/O 往往成爲性能瓶頸,從而導致過高的響應延遲。分佈式緩存將高速內存作爲數據對象的存儲介質,數據以 key/value 形式存儲。

  2. 動態擴展性:支持彈性擴展,通過動態增加或減少節點應對變化的數據訪問負載,提供可預測的性能與擴展性;同時,最大限度地提高資源利用率;

  3. 高可用性:高可用性包含數據可用性與服務可用性兩方面,故障的自動發現,自動轉義。確保不會因服務器故障而導致緩存服務中斷或數據丟失。

  4. 易用性:提供單一的數據與管理視圖;API 接口簡單, 且與拓撲結構無關;動態擴展或失效恢復時無需人工配置; 自動選取備份節點;多數緩存系統提供了圖形化的管理控制檯,便於統一維護;

4、分佈式緩存的應用場景  

分佈式緩存的典型應用場景可分爲以下幾類:

  1. 頁面緩存:用來緩存 Web 頁面的內容片段,包括 HTML、CSS 和圖片等,多應用於社交網站等;

  2. 應用對象緩存:緩存系統作爲 ORM 框架的二級緩存對外提供服務,目的是減輕數據庫的負載壓力,加速應用訪問;

  3. 狀態緩存:緩存包括 Session 會話狀態及應用橫向擴展時的狀態數據等,這類數據一般是難以恢復的,對可用性要求較高,多應用於高可用集羣;

  4. 並行處理:通常涉及大量中間計算結果需要共享;

  5. 事件處理:分佈式緩存提供了針對事件流的連續查詢 (continuous query) 處理技術,滿足實時性需求;

  6. 極限事務處理:分佈式緩存爲事務型應用提供高吞吐率、低延時的解決方案,支持高併發事務請求處理,多應用於鐵路、金融服務和電信等領域;

二、 爲什麼要用分佈式緩存?

在傳統的後端架構中,由於請求量以及響應時間要求不高,我們經常採用單一的數據庫結構。這種架構雖然簡單,但隨着請求量的增加,這種架構存在性能瓶頸導致無法繼續穩定提供服務。

通過在應用服務與 DB 中間引入緩存層,我們可以得到如下三個好處:

(1)讀取速度得到提升。

(2)系統擴展能力得到大幅增強。我們可以通過加緩存,來讓系統的承載能力提升。

(3)總成本下降,單臺緩存即可承擔原來的多臺 DB 的請求量,大大節省了機器成本。

三、常用的緩存技術

目前最流行的分佈式緩存技術有 redis 和 memcached 兩種,

1. Memcache

Memcached 是一個高性能,分佈式內存對象緩存系統,通過在內存裏維護一個統一的巨大的 Hash 表,它能夠用來存儲各種格式的數據,包括圖像、視頻、文件以及數據庫檢索的結果等。簡單來說就是:將數據緩存到內存中,然後從內存中讀取,從而大大提高讀取速度。

Memcached 特性:

2. Redis

Redis 是一個開源(BSD 許可),基於內存的,支持網絡、可基於內存、分佈式、可選持久性的鍵值對 (Key-Value) 存儲數據庫,並提供多種語言的 API。可以用作數據庫、緩存和消息中間件。

Redis 支持多種數據類型 - string、Hash、list、set、sorted set。提供兩種持久化方式 - RDB 和 AOF。通過 Redis cluster 提供集羣模式。

Redis 的優勢:

3. 分佈式緩存技術對比

不同的分佈式緩存功能特性和實現原理方面有很大的差異,因此他們所適應的場景也有所不同。

四、分佈式緩存實現方案

緩存的目的是爲了在高併發系統中有效降低 DB 的壓力,高效的數據緩存可以極大地提高系統的訪問速度和併發性能。分佈式緩存有很多實現方案,下面將講一講分佈式緩存實現方案。

1、緩存實現方案

如上圖所示,系統會自動根據調用的方法緩存請求的數據。當再次調用該方法時,系統會首先從緩存中查找是否有相應的數據,如果命中緩存,則從緩存中讀取數據並返回;如果沒有命中,則請求數據庫查詢相應的數據並再次緩存。

如上圖所示,每一個用戶請求都會先查詢緩存中的數據,如果緩存命中,則會返回緩存中的數據。這樣能減少數據庫查詢,提高系統的響應速度。

2、使用 Spring Boot+Redis 實現分佈式緩存解決方案

接下來,以用戶信息管理模塊爲例演示使用 Redis 實現數據緩存框架。

1.添加 Redis Cache 的配置類

RedisConfig 類爲 Redis 設置了一些全局配置,比如配置主鍵的生產策略 KeyGenerator() 方法,此類繼承 CachingConfigurerSupport 類,並重寫方法 keyGenerator(),如果不配置,就默認使用參數名作爲主鍵。

@Configuration@EnableCachingpublic class RedisConfig extends CachingConfigurerSupport {     / **     * 採用RedisCacheManager作爲緩存管理器     * 爲了處理高可用Redis,可以使用RedisSentinelConfiguration來支持Redis Sentinel     */    @Bean    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {        RedisCacheManager redisCacheManager = RedisCacheManager.builder(connectionFactory).build();        return redisCacheManager;    }     / **     * 自定義生成key的規則     */    @Override    public KeyGenerator keyGenerator() {        return new KeyGenerator() {            @Override            public Object generate(Object o, Method method, Object...objects) {                // 格式化緩存key字符串                StringBuilder stringBuilder = new StringBuilder();                // 追加類名               stringBuilder.append(o.getClass().getName());                // 追加方法名               stringBuilder.append(method.getName());                // 遍歷參數並且追加                for (Object obj :objects) {                   stringBuilder.append(obj.toString());                }               System.out.println("調用Redis緩存Key: " + stringBuilder.toString());                return stringBuilder.toString();            }        };    }}

在上面的示例中,主要是自定義配置 RedisKey 的生成規則,使用 @EnableCaching 註解和 @Configuration 註解。

2.添加 @Cacheable 註解

在讀取數據的方法上添加 @Cacheable 註解,這樣就會自動將該方法獲取的數據結果放入緩存。

@Repositorypublic class UserRepository {     / **     * @Cacheable應用到讀取數據的方法上,先從緩存中讀取,如果沒有,再從DB獲取數據,然後把數據添加到緩存中     * unless表示條件表達式成立的話不放入緩存     * @param username     * @return     */    @Cacheable(value = "user")    public User getUserByName(String username) {        User user = new User();       user.setName(username);        user.setAge(30);       user.setPassword("123456");       System.out.println("user info from database");        return user;    }}

在上面的實例中,使用 @Cacheable 註解標註該方法要使用緩存。@Cacheable 註解主要針對方法進行配置,能夠根據方法的請求對參數及其結果進行緩存。

1)這裏緩存 key 的規則爲簡單的字符串組合,如果不指定 key 參數,則自動通過 keyGenerator 生成對應的 key。

2)Spring Cache 提供了一些可以使用的 SpEL 上下文數據,通過 #進行引用。

3.測試數據緩存

創建單元測試方法調用 getUserByName() 方法,測試代碼如下:

@Testpublic void testGetUserByName() {    User user = userRepository.getUserByName("weiz");   System.out.println("name: "+ user.getName()+",age:"+user.getAge());     user = userRepository.getUserByName("weiz");   System.out.println("name: "+ user.getName()+",age:"+user.getAge());}

上面的實例分別調用了兩次 getUserByName() 方法,輸出獲取到的 User 信息。

最後,單擊 Run Test 或在方法上右擊,選擇 Run 'testGetUserByName',運行單元測試方法,結果如下圖所示。

通過上面的日誌輸出可以看到,首次調用 getPersonByName() 方法請求 User 數據時,由於緩存中未保存該數據,因此從數據庫中獲取 User 信息並存入 Redis 緩存,再次調用會命中此緩存並直接返回。

五、常見問題及解決方案

1. 緩存預熱

緩存預熱指在用戶請求數據前先將數據加載到緩存系統中,用戶查詢 事先被預熱的緩存數據,以提高系統查詢效率。緩存預熱一般有系統啓動 加載、定時加載等方式。

5. 熱 key 問題

所謂熱 key 問題就是,突然有大量的請求去訪問 redis 上的某個特定 key,導致請求過於集中,達到網絡 IO 的上限,從而導致這臺 redis 的服務器宕機引發雪崩。

針對熱 key 的解決方案:

  1. 提前把熱 key 打散到不同的服務器,降低壓力

  2. 加二級緩存,提前加載熱 key 數據到內存中,如果 redis 宕機,則內存查詢

2. 緩存擊穿

緩存擊穿是指大量請求緩存中過期的 key,由於併發用戶特別多,同時新的緩存還沒讀到數據,導致大量的請求數據庫,引起數據庫壓力瞬間增大,造成過大壓力。緩存擊穿和熱 key 的問題比較類似,只是說的點在於過期導致請求全部打到 DB 上而已。

解決方案:

  1. 加鎖更新,假設請求查詢數據 A,若發現緩存中沒有,對 A 這個 key 加鎖,同時去數據庫查詢數據,然後寫入緩存,再返回給用戶,這樣後面的請求就可以從緩存中拿到數據了。

  2. 將過期時間組合寫在 value 中,通過異步的方式不斷地刷新過期時間,防止此類現象發生。

3. 緩存穿透

緩存穿透是指查詢不存在緩存中的數據,每次請求都會打到 DB,就像緩存不存在一樣。

解決方案:

布隆過濾器

布隆過濾器的原理是在保存數據的時候,會通過 Hash 散列函數將它映射爲一個位數組中的 K 個點,同時把他的值置爲 1。這樣當用戶再次來查詢 A 時,而 A 在布隆過濾器值爲 0,直接返回,就不會產生擊穿請求打到 DB 了。

4. 緩存雪崩

緩存雪崩指在同一時刻由於大量緩存失效,導致大量原本應該訪問緩存的請求都去查詢數據庫,而對數據庫的 CPU 和內存造成巨大壓力,嚴重的話會導致數據庫宕機,從而形成一系列連鎖反應,使得整個系統崩潰。雪崩和擊穿、熱 key 的問題不太一樣的是,緩存雪崩是指大規模的緩存都過期失效了。

針對雪崩的解決方案:

  1. 針對不同 key 設置不同的過期時間,避免同時過期

  2. 限流,如果 redis 宕機,可以限流,避免同時刻大量請求打崩 DB

  3. 二級緩存,同熱 key 的方案。

六、緩存與數據庫數據一致性

緩存與數據庫的一致性問題分爲兩種情況,一是緩存中有數據,則必須與數據庫中的數據一致;二是緩存中沒數據,則數據庫中的數據必須是最新的。

6.1 刪除和修改數據

第一種情況:我們先刪除緩存,在更新數據庫,潛在的問題:數據庫更新失敗了,get 請求進來發現沒緩存則請求數據庫,導致緩存又刷入了舊的值。

第二種情況:我們先更新數據庫,再刪除緩存,潛在的問題:緩存刪除失敗,get 請求進來緩存命中,導致讀到的是舊值。

6.2 先刪除緩存再更新數據庫

假設有 2 個線程 A 和 B,A 刪除緩存之後,由於網絡延遲,在更新數據庫之前,B 來訪問了,發現緩存未命中,則去請求數據庫然後把舊值刷入了緩存,這時候姍姍來遲的 A,才把最新數據刷入數據庫,導致了數據的不一致性。

解決方案

針對多線程的場景,可以採用延遲雙刪的解決方案,我們可以在 A 更新完數據庫之後,線程 A 先休眠一段時間,再刪除緩存。

需要注意的是:具體休眠的時間,需要評估自己的項目的讀數據業務邏輯的耗時。這麼做的目的,就是確保讀請求結束,寫請求可以刪除讀請求造成的緩存髒數據。當然這種策略還要考慮 redis 和數據庫主從同步的耗時。

6.3 先更新數據庫再刪除緩存

這種場景潛在的風險就是更新完數據庫,刪緩存之前,會有部分併發請求讀到舊值,這種情況對業務影響較小,可以通過重試機制,保證緩存能得到刪除。

最後

以上,就把分佈式緩存介紹完了,並使用 Spring Boot+Redis 實現分佈式緩存解決方案。緩存的使用是程序員、架構師的必備技能,好的程序員能根據數據類型、業務場景來準確判斷使用何種類型的緩存,如何使用這種緩存,以最小的成本最快的效率達到最優的目的。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/taNgsnx4yYzXvaTwQHU6FQ