探索Redis與MySQL的雙寫問題

Booksea發表於2023-10-12

本文已收錄至GitHub,推薦閱讀 ? Java隨想錄

微信公眾號:Java隨想錄

原創不易,注重版權。轉載請註明原作者和原文連結

在日常的應用開發中,我們經常會遇到需要使用多種不同型別的資料庫管理系統來滿足各種業務需求。其中最典型的就是Redis和MySQL的組合使用。

這兩者擁有各自的優點,例如Redis為高效能的記憶體資料庫提供了極快的讀寫速度,而MySQL則是非常強大的關係型資料庫,支援事務處理,並且提供了很好的資料一致性。

然而,在實際應用過程中,如何保證Redis和MySQL雙寫時的資料一致性問題成為了開發者們面臨的重要挑戰。本文即將針對這個問題進行深入探討,希望能為廣大開發者們提供一些有價值的思路和解決方案。

雙寫一致問題

雙寫一致性問題主要是指當我們同時向Redis和MySQL寫資料時,由於網路延遲、伺服器故障等原因,可能導致資料在兩個系統之間產生不一致。

例如,你可能已經更新了MySQL中的資料,但是Redis中的資料還未來得及更新,或者反過來。這樣的結果就可能導致使用者讀到的是舊的、不正確的資料。

比如在現實生活中的購物網站場景:假設使用者A在購買一件庫存僅剩1件的商品,系統在接收到請求後,先將MySQL中的庫存減少1,然後出現了網路延遲或系統故障,Redis中的庫存沒有減少。此時,使用者B看到的是還有1件商品,也發起了購買請求,如果系統又首先更改了MySQL,那麼就會出現超賣的情況,即實際庫存已經沒有,但因為快取中的資訊不準確,導致系統銷售了更多的商品。

嚴格意義上任何非原子操作都不可能保證一致性,除非用阻塞讀寫實現強一致性,所以對於快取架構我們追求的目標是最終一致性。

實際上,快取就是透過犧牲強一致性來提高效能的。這是由CAP理論決定的。快取系統適用的場景就是非強一致性的場景,它屬於CAP中的AP。

快取讀寫策略

解決這種問題的常見策略就是“快取讀寫策略”。這個策略用於處理先更新資料庫還是先更新快取等場景。

接下來,我們將探討三種快取讀寫策略。這些策略各有優劣,沒有絕對的最佳選擇。請根據具體的應用場景選擇最合適的策略。

Cache-Aside Pattern(旁路快取模式)

Cache-Aside Pattern,即旁路快取模式,它的提出是為了儘可能地解決快取與資料庫的資料不一致問題。旁路快取模式中服務端需要同時維護DBCache,並且是以DB的結果為準。

:從快取讀取資料,讀到直接返回。如果讀取不到的話,從資料庫載入,寫入快取後,再返回響應。

:更新的時候,先「更新資料庫,然後再刪除快取」。

Read/Write Through Pattern(讀寫穿透模式)

Read/Write Through Pattern 中服務端把 cache 視為主要資料儲存,從中讀取資料並將資料寫入其中。cache 服務負責將此資料讀取和寫入 DB,從而減輕了應用程式的職責。

因為我們經常使用的分散式快取 Redis 並沒有提供 cache 將資料寫入DB的功能,所以使用並不多。

:從 cache 中讀取資料,讀取到就直接返回 。讀取不到的話,先從 DB 載入,寫入到 cache 後返回響應。

從流程圖中可以看出,讀寫穿透模式和旁路快取模式的讀取流程幾乎相同。不過,在旁路快取模式中,客戶端需要負責將資料寫入cache。而在讀寫穿透模式中,cache服務自行寫入快取,對客戶端來說,這個過程是透明的。

:先查 cache,cache 中不存在,直接更新 DB。cache 中存在,則先更新 cache,然後 cache 服務自己更新 DB(同步更新 cache和DB)。

Write Behind Pattern(非同步快取寫入模式)

Write Behind Pattern 和 Read/Write Through Pattern 很相似,兩者都是由 cache 服務來負責 cache 和 DB 的讀寫。

但是,兩個又有很大的不同:Read/Write Through 是同步更新 cache 和 DB,而 Write Behind Caching 則是隻更新快取,不直接更新 DB,而是改為非同步批次的方式來更新 DB

很明顯,這種方式對資料一致性帶來了更大的挑戰,比如cache資料可能還沒非同步更新DB的話,cache服務可能就掛掉了,反而會帶來更大的災難。

這種策略在我們平時開發過程中也非常非常少見,但是不代表它的應用場景少,比如訊息佇列中訊息的非同步寫入磁碟、MySQL 的 InnoDB Buffer Pool 機制都用到了這種策略。

Write Behind Pattern 下 DB 的寫效能非常高,非常適合一些資料經常變化又對資料一致性要求沒那麼高的場景,比如瀏覽量、點贊量等。

旁路快取模式解析

Cache Aside Pattern 的一些疑問

旁路快取模式是我們平時中使用最多的,根據該模式,我們可能會有以下幾個疑問。

為什麼寫操作是刪除快取,而不是更新快取

:假設執行緒A先發起一個寫操作,第一步先更新資料庫。執行緒B再發起一個寫操作,緊接著也更新了資料庫。由於網路等原因,執行緒B比執行緒A先更新了快取,然後執行緒A更新快取。

這時候,快取儲存的是A的資料(老資料),而資料庫儲存的是B的資料(新資料),資料就不一致了,髒資料出現啦。如果是「刪除快取取代更新快取」則不會出現這個髒資料問題。

實際上要寫操作的時候更新快取也是可以的,不過我們需要加一個鎖/分散式鎖來保證更新cache的時候不存線上程安全問題。

在寫資料的過程中,為什麼要先更新DB再刪除快取

:假設請求1 是寫操作,要是先刪除快取A,這時候來了請求2,請求2是讀操作,先讀快取A,發現快取被刪除了(被請求1刪除了),然後去讀資料庫,但是此時請求1還沒來得及把資料及時更新,那麼請求2讀的就是舊資料,並且請求2還會把讀到的舊資料放到快取中,造成了資料的不一致。

其實要先刪快取,再更新資料庫也是可以,如採用「延時雙刪策略」。

休眠一段時間,再次淘汰快取。這麼做,可以將這段時間內所造成的快取髒資料,再次刪除。

注意sleep休眠的時間不能小於修改資料庫資料的時間小,基本上1秒就夠了。

在寫資料的過程中,先更新DB,後刪除cache就沒有問題了麼?

答: 理論上來說還是可能會出現資料不一致性的問題,不過機率非常小。

假設這會有兩個請求,一個請求A做查詢操作,一個請求B做更新操作,那麼會有如下情形產生:

  1. 快取剛好失效。
  2. 請求A查詢資料庫,得一箇舊值。
  3. 請求B將新值寫入資料庫。
  4. 請求B刪除快取。
  5. 請求A將查到的舊值寫入快取 ok,如果發生上述情況,確實是會發生髒資料。

然而,發生這種情況的機率並不高

發生上述情況有一個先天性條件,就是步驟(3)的寫資料庫操作比步驟(2)的讀資料庫操作耗時更短,才有可能使得步驟(4)先於步驟(5)。

可是,仔細想想,資料庫的讀操作的速度遠快於寫操作的(不然做讀寫分離幹嘛,做讀寫分離的意義就是因為讀操作比較快,耗資源少),因此步驟(3)耗時比步驟(2)更短,這一情形很難出現。

還有其他造成不一致的原因麼?

答: 如果刪除快取過程中失敗了就會造成不一致問題。可以使用Canal去訂閱資料庫的binlog,獲得需要操作的資料。另起一個程式,獲得這個訂閱程式傳來的資訊,進行刪除快取操作。

Cache Aside Pattern 的缺陷

Cache Aside Pattern是一種常見的快取更新策略,主要在讀取資料時用於處理快取的失效和更新。儘管它有很多優點,但也存在一些缺陷:

缺陷1:首次請求資料一定不在 cache 的問題

解決辦法:可以將熱點資料提前放入cache 中。

缺陷2:寫操作比較頻繁的話導致cache中的資料會被頻繁被刪除,這樣會影響快取命中率 。

  • 資料庫和快取資料強一致場景 :更新DB的時候同樣更新cache,不過我們需要加一個鎖/分散式鎖來保證更新cache的時候不存線上程安全問題。
  • 可以短暫地允許資料庫和快取資料不一致的場景 :更新DB的時候同樣更新cache,但是給快取加一個比較短的過期時間,這樣的話就可以保證即使資料不一致的話影響也比較小。

延時雙刪

Redis的延時雙刪策略主要用於解決分散式系統當中的快取與資料庫資料一致性問題。以下是其基本步驟:

  1. 先刪除快取。
  2. 再更新資料庫。
  3. 最後延時再次刪除快取。

該策略的理念是:如果有其他執行緒在步驟1和步驟2之間查詢到舊的資料並寫入了快取,那麼步驟3可以保證這部分舊的資料被清除,從而儘可能維持資料庫和快取之間的資料一致性。

以下是使用Java實現的樣例程式碼:

import redis.clients.jedis.Jedis;

public class RedisDoubleDelStrategy {
    private Jedis jedis;
    private static final long DELAY_MILLIS = 1000L; // 設定為你需要的延時時間

    public RedisDoubleDelStrategy(String host, int port) {
        this.jedis = new Jedis(host, port);
    }

    public void updateDBAndCache(String key, String value) {
        // Step 1: 刪除快取
        jedis.del(key);

        // Step 2: 更新資料庫,此處以列印輸出代替
        System.out.println("Update DB with: " + value);

        // 延遲任務來完成第二次刪除
        new Thread(() -> {
            try {
                Thread.sleep(DELAY_MILLIS);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            
            // Step 3: 延時後再次刪除快取
            jedis.del(key);
        }).start();
    }
}

這段程式碼實現了延時雙刪策略,但請注意它仍然不能完全保證資料庫和快取之間的一致性。

在某些情況下(比如大量併發情況下),可能仍然會出現不一致的問題。例如,在步驟3之後,如果還有其他執行緒查詢到了舊資料並寫入了快取,那麼資料庫和快取的資料就會不一致。因此,在使用該策略時,需要根據你的系統特性和一致性需求來進行權衡。

本篇文章到這就結束了,在探討Redis與MySQL雙寫問題的過程中,我們分析了各種可能的場景和解決方案。雙寫系統不僅考驗我們對資料庫原理的理解,也展示了協同工作的複雜性。最終,解決這個問題的關鍵是理解你的用例並根據實際需求選擇適當的策略和工具。

而在實際應用中,再完美的方案也可能會遇到挑戰和困難。因此,持續監控,頻繁測試和及時調整策略都至關重要。希望本文能為你在處理Redis與MySQL雙寫問題上提供一些思路和靈感,同時,我們也期待在未來看到更多精妙的解決方案誕生。


感謝閱讀,如果本篇文章有任何錯誤和建議,歡迎給我留言指正。

老鐵們,關注我的微信公眾號「Java 隨想錄」,專注分享Java技術乾貨,文章持續更新,可以關注公眾號第一時間閱讀。

一起交流學習,期待與你共同進步!

相關文章