美團二面:如何保證Redis與Mysql雙寫一致性?連續兩個面試問到了!

码农Academy發表於2024-04-16

引言

Redis作為一款高效的記憶體資料儲存系統,憑藉其優異的讀寫效能和豐富的資料結構支援,被廣泛應用於快取層以提升整個系統的響應速度和吞吐量。尤其是在與關係型資料庫(如MySQL、PostgreSQL等)結合使用時,透過將熱點資料儲存在Redis中,可以在很大程度上緩解資料庫的壓力,提高整體系統的效能表現。

然而,在這種架構中,一個不容忽視的問題就是如何確保Redis快取與資料庫之間的雙寫一致性。所謂雙寫一致性,是指當資料在資料庫中發生變更時,能夠及時且準確地反映在Redis快取中,反之亦然,以避免出現因快取與資料庫資料不一致導致的業務邏輯錯誤或使用者體驗下降。尤其在高併發場景下,由於網路延遲、併發控制等因素,保證雙寫一致性變得更加複雜。

在實際業務開發中,若不能妥善處理好快取與資料庫的雙寫一致性問題,可能會帶來諸如資料丟失、髒讀、重複讀等一系列嚴重影響系統穩定性和可靠性的後果。本文將嘗試剖析這一問題,介紹日常開發中常用的一些策略和模式,並結合具體場景分析不同的解決方案,為大家提供一些有力的技術參考和支援。

image.png

談談分散式系統中的一致性

分散式系統中的一致性指的是在多個節點上儲存和處理資料時,確保系統中的資料在不同節點之間保持一致的特性。在分散式系統中,一致性通常可以分為以下幾個類別:

  1. 強一致性
    所有節點在任何時間都看到相同的資料。任何更新操作都會立即對所有節點可見,保證了資料的強一致性。這意味著,如果一個節點完成了寫操作,那麼所有其他節點讀取相同的資料之後,都將看到最新的結果。強一致性通常需要付出更高的代價,例如增加通訊開銷和降低系統的可用性。

  2. 弱一致性:
    系統中的資料在某些情況下可能會出現不一致的狀態,但最終會收斂到一致狀態。弱一致性下的系統允許在一段時間內,不同節點之間看到不同的資料狀態。弱一致性通常用於需要在效能和一致性之間進行權衡的場景,例如快取系統等。

  3. 最終一致性:
    是弱一致性的一種特例,它保證了在經過一段時間後,系統中的所有節點最終都會達到一致狀態。儘管在資料更新時可能會出現一段時間的不一致,但最終資料會收斂到一致狀態。最終一致性通常透過一些技術手段來實現,例如基於版本向量或時間戳的資料複製和同步機制。

除此之外,還有一些其他的一致性類別,例如:因果一致性,順序一致性,基於本篇文章討論的重點,我們暫且只討論以上三種一致性類別。

什麼是雙寫一致性問題?

在分散式系統中,雙寫一致性主要指在一個資料同時存在於快取(如Redis)和持久化儲存(如資料庫)的情況下,任何一方的資料更新都必須確保另一方資料的同步更新,以保持雙方資料的一致狀態。這一問題的核心在於如何在併發環境下正確處理快取與資料庫的讀寫互動,防止資料出現不一致的情況。

典型場景分析

  1. 寫資料庫後忘記更新快取
    當直接對資料庫進行更新操作而沒有相應地更新快取時,後續的讀請求可能仍然從快取中獲取舊資料,導致資料的不一致。

  2. 刪除快取後資料庫更新失敗:
    在某些場景下,為了保證資料新鮮度,會在更新資料庫前先刪除快取。但如果資料庫更新過程中出現異常導致更新失敗,那麼快取將長時間處於空缺狀態,新的查詢將會直接命中資料庫,加重資料庫壓力,並可能導致資料版本混亂。

  3. 併發環境下讀寫操作的交錯執行
    在高併發場景下,可能存在多個讀寫請求同時操作同一份資料的情況。比如,在刪除快取、寫入資料庫的過程中,新的讀請求獲取到了舊的資料庫資料並放入快取,此時就出現了資料不一致的現象。

  4. 主從複製延遲與快取失效時間視窗衝突
    對於具備主從複製功能的資料庫叢集,主庫更新資料後,存在一定的延遲才將資料同步到從庫。如果在此期間快取剛好過期並重新從資料庫載入資料,可能會從尚未完成同步的從庫讀取到舊資料,進而導致快取與主庫資料的不一致。

資料不一致不僅會導致業務邏輯出錯,還可能引發使用者介面展示錯誤、交易狀態不準確等問題,嚴重時甚至會影響系統的正常執行和使用者體驗。

解決雙寫一致性問題的主要策略

在解決Redis快取與資料庫雙寫一致性問題上,有多種策略和模式。我們主要介紹以下幾種主要的策略:

Cache Aside Pattern(旁路快取模式)

Cache Aside Pattern 是一種在分散式系統中廣泛採用的快取和資料庫協同工作策略,在這個模式中,資料以資料庫為主儲存,快取作為提升讀取效率的輔助手段。也是日常中比較常見的一種手段。其工作流程如下:
image.png

由上圖我們可以看出Cache Aside Pattern的工作原理:

  • 讀取操作:首先嚐試從快取中獲取資料,如果快取命中,則直接返回;否則,從資料庫中讀取資料並將其放入快取,最後返回給客戶端。
  • 更新操作:當需要更新資料時,首先更新資料庫,然後再清除或使快取中的對應資料失效。這樣一來,後續的讀請求將無法從快取獲取資料,從而迫使系統從資料庫載入最新的資料並重新填充快取。

我們從更新操作上看會發現兩個很有意思的問題:

為什麼操作快取的時候是刪除舊快取而不是直接更新快取?

我們舉例模擬下併發環境下的更新DB&快取:

  • 執行緒A先發起一個寫操作,第一步先更新資料庫,然後更新快取
  • 執行緒B再發起一個寫操作,第二步更新了資料庫,然後更新快取
    當以上兩個執行緒的執行,如果嚴格先後順序執行,那麼對於更新快取還是刪除快取去操作快取都可以,但是如果兩個執行緒同時執行時,由於網路或者其他原因,導致執行緒B先執行完更新快取,然後執行緒A才會更新快取。如下圖:
    image.png

這時候快取中儲存的就是執行緒A的資料,而資料庫中儲存的是執行緒B的資料。這時候如果讀取到的快取就是髒資料。但是如果使用刪除快取取代更新快取,那麼就不會出現這個髒資料。這種方式可以簡化併發控制、保證資料一致性、降低操作複雜度,並能更好地適應各種潛在的異常場景和快取策略。儘管這種方法可能會增加一次資料庫訪問的成本,但在實際應用中,考慮到資料的一致性和系統的健壯性,這是值得付出的折衷。

並且在寫多讀少的情況下,資料很多時候並不會被讀取到,但是一直被頻繁的更新,這樣也會浪費效能。實際上,寫多的場景,用快取也不是很划算。只有在讀多寫少的情況下使用快取才會發揮更大的價值。

為什麼是先運算元據庫再操作快取?

在操作快取時,為什麼要先運算元據庫而不是先操作快取?我們同樣舉例模擬兩個執行緒,執行緒A寫入資料,先刪除快取在更新DB,執行緒B讀取資料。流程如下:

  1. 執行緒A發起一個寫操作,第一步刪除快取
  2. 此時執行緒B發起一個讀操作,快取中沒有,則繼續讀DB,讀出來一個老資料
  3. 然後執行緒B把老資料放入快取中
  4. 執行緒A更新DB資料

image.png

所以這樣就會出現快取中儲存的是舊資料,而資料庫中儲存的是新資料,這樣就出現髒資料,所以我們一般都採取先運算元據庫,在操作快取。這樣後續的讀請求從資料庫獲取最新資料並重新填充快取。這樣的設計降低了資料不一致的風險,提升了系統的可靠性。同時,這也符合CAP定理中對於一致性(Consistency)和可用性(Availability)權衡的要求,在很多場景下,資料一致性被優先考慮。

Cache Aside Pattern相對簡單直觀,容易理解和實現。只需要簡單的判斷和快取失效邏輯即可,對已有系統的改動較小。並且由於快取是按需載入的,所以不會浪費寶貴的快取空間儲存未被訪問的資料,同時我們可以根據實際情況決定何時載入和清理快取。

儘管Cache Aside Pattern在大多數情況下可以保證最終一致性,但它並不能保證強一致性。在資料庫更新後的短暫時間內(還未開始操作快取),如果有讀請求發生,快取中仍是舊資料,但是實際資料庫中已是最新資料,造成短暫的資料不一致。在併發環境下,特別是在更新操作時,有可能在更新資料庫和刪除快取之間的時間視窗內,新的讀請求載入了舊資料到快取,導致不一致。

Read-Through/Write-Through(讀寫穿透)

Read-Through 和 Write-Through 是兩種與快取相關的策略,它們主要用於快取系統與持久化儲存之間的資料互動,旨在確保快取與底層資料儲存的一致性。

Read-Through(讀穿透)

Read-Through 是一種在快取中找不到資料時,自動從持久化儲存中載入資料並回填到快取中的策略。具體執行流程如下:

  • 客戶端發起讀請求到快取系統。
  • 快取系統檢查是否存在請求的資料。
  • 如果資料不在快取中,快取系統會透明地向底層資料儲存(如資料庫)發起讀請求。
  • 資料庫返回資料後,快取系統將資料儲存到快取中,並將資料返回給客戶端。
  • 下次同樣的讀請求就可以直接從快取中獲取資料,提高了讀取效率。

image.png

整體簡要流程類似Cache Aside Pattern,但在快取未命中的情況下,Read-Through 策略會自動隱式地從資料庫載入資料並填充到快取中,而無需應用程式顯式地進行資料庫查詢。

Cache Aside Pattern 更多地依賴於應用程式自己來管理快取與資料庫之間的資料流動,包括快取填充、失效和更新。而Read-Through Pattern 則是在快取系統內部實現了一個更加自動化的過程,使得應用程式無需關心資料是從快取還是資料庫中獲取,以及如何保持兩者的一致性。在Read-Through 中,快取系統承擔了更多的職責,實現了更緊密的快取與資料庫整合,從而簡化了應用程式的設計和實現。

Write-Through(寫穿透)

Write-Through 是一種在快取中更新資料時,同時將更新操作同步到持久化儲存的策略。具體流程如下:

  • 當客戶端向快取系統發出寫請求時,快取系統首先更新快取中的資料。
  • 同時,快取系統還會把這次更新操作同步到底層資料儲存(如資料庫)。
  • 當資料在資料庫中成功更新後,整個寫操作才算完成。
  • 這樣,無論是從快取還是直接從資料庫讀取,都能得到最新一致的資料。

image.png

Read-Through 和 Write-Through 的共同目標是確保快取與底層資料儲存之間的一致性,並透過自動化的方式隱藏了快取與持久化儲存之間的互動細節,簡化了客戶端的處理邏輯。這兩種策略經常一起使用,以提供無縫且一致的資料訪問體驗,特別適用於那些對資料一致性要求較高的應用場景。然而,需要注意的是,雖然它們有助於提高資料一致性,但在高併發或網路不穩定的情況下,仍然需要考慮併發控制和事務處理等問題,以防止資料不一致的情況發生。

Write behind (非同步快取寫入)

Write Behind(非同步快取寫入),也稱為 Write Back(回寫)或 非同步更新策略,是一種在處理快取與持久化儲存(如資料庫)之間資料同步時的策略。在這種模式下,當資料在快取中被更新時,並非立即同步更新到資料庫,而是將更新操作暫存起來,隨後以非同步的方式批次地將快取中的更改寫入持久化儲存。其流程如下:

  • 應用程式首先在快取中執行資料更新操作,而不是直接更新資料庫。
  • 快取系統會將此次更新操作記錄下來,暫存於一個佇列(如日誌檔案或記憶體佇列)中,而不是立刻同步到資料庫。
  • 在後臺有一個獨立的程序或執行緒定期(或者當佇列積累到一定大小時)從暫存佇列中取出更新操作,然後批次地將這些更改寫入資料庫。

image.png

使用 Write Behind 策略時,由於更新並非即時同步到資料庫,所以在非同步處理完成之前,如果快取或系統出現故障,可能會丟失部分更新操作。並且對於高度敏感且要求強一致性的資料,Write Behind 策略並不適用,因為它無法提供嚴格的事務性和實時一致性保證。Write Behind 適用於那些可以容忍一定延遲的資料一致性場景,透過犧牲一定程度的一致性換取更高的系統效能和擴充套件性。

解決雙寫一致性問題的3種方案

以上我們主要講解了解決雙寫一致性問題的主要策略,但是每種策略都有一定的侷限性,所以我們在實際運用中,還要結合一些其他策略去遮蔽上述策略的缺點。

1. 延時雙刪策略

延時雙刪策略主要用於解決在高併發場景下,由於網路延遲、併發控制等原因造成的資料庫與快取資料不一致的問題。

當更新資料庫時,首先刪除對應的快取項,以確保後續的讀請求會從資料庫載入最新資料。
但是由於網路延遲或其他不確定性因素,刪除快取與資料庫更新之間可能存在時間視窗,導致在這段時間內的讀請求從資料庫讀取資料後寫回快取,新寫入的快取資料可能還未反映出資料庫的最新變更。

所以為了解決這個問題,延時雙刪策略在第一次刪除快取後,設定一段短暫的延遲時間,如幾百毫秒,然後在這段延遲時間結束後再次嘗試刪除快取。這樣做的目的是確保在資料庫更新傳播到所有節點,並且在快取中的舊資料徹底過期失效之前,第二次刪除操作可以消除快取中可能存在的舊資料,從而提高資料一致性。

public class DelayDoubleDeleteService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private TaskScheduler taskScheduler;

    public void updateAndScheduleDoubleDelete(String key, String value) {
        // 更新資料庫...
        updateDatabase(key, value);

        // 刪除快取
        redisTemplate.delete(key);

        // 延遲執行第二次刪除
        taskScheduler.schedule(() -> {
            redisTemplate.delete(key);
        }, new CronTrigger("0/1 * * * * ?")); // 假設1秒後執行,實際應根據需求設定定時表示式
    }

    // 更新資料庫的邏輯
    private void updateDatabase(String key, String value) {
        
    }
}

這種方式可以較好地處理網路延遲導致的資料不一致問題,較少的併發寫入資料庫和快取,降低系統的壓力。但是,延遲時間的選擇需要權衡,過短可能導致實際效果不明顯,過長可能影響使用者體驗。並且對於極端併發場景,仍可能存在資料不一致的風險。

2. 刪除快取重試機制

刪除快取重試機制是在刪除快取操作失敗時,設定一個重試策略,確保快取最終能被正確刪除,以維持與資料庫的一致性。

在執行資料庫更新操作後,嘗試刪除關聯的快取項。如果首次刪除快取失敗(例如網路波動、快取服務暫時不可用等情況),系統進入重試邏輯,按照預先設定的策略(如指數退避、固定間隔重試等)進行多次嘗試。直到快取刪除成功,或者達到最大重試次數為止。透過這種方式,即使在異常情況下也能儘量保證快取與資料庫的一致性。

@Service
public class RetryableCacheService {

    @Autowired
    private CacheManager cacheManager;

    @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000L))
    public void deleteCacheWithRetry(String key) {
        ((org.springframework.data.redis.cache.RedisCacheManager) cacheManager).getCache("myCache").evict(key);
    }

    public void updateAndDeleteCache(String key, String value) {
        // 更新資料庫...
        updateDatabase(key, value);

        // 嘗試刪除快取,失敗時自動重試
        deleteCacheWithRetry(key);
    }

    // 更新資料庫的邏輯,此處僅示意
    private void updateDatabase(String key, String value) {
        // ...
    }
}

這種重試方式確保快取刪除操作的成功執行,可以應對網路抖動等導致的臨時性錯誤,提高資料一致性。但是可能佔用額外的系統資源和時間,重試次數過多可能會阻塞其他操作。

監聽並讀取biglog非同步刪除快取

在資料庫發生寫操作時,將變更記錄在binlog或類似的事務日誌中,然後使用一個專門的非同步服務或者監聽器訂閱binlog的變化(比如Canal),一旦檢測到有資料更新,便根據binlog中的操作資訊定位到受影響的快取項。講這些需要更新快取的資料傳送到訊息佇列,消費者處理訊息佇列中的事件,非同步地刪除或更新快取中的對應資料,確保快取與資料庫保持一致。

@Service
public class BinlogEventHandler {

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    public void handleBinlogEvent(BinlogEvent binlogEvent) {
        // 解析binlogEvent,獲取需要更新快取的key
        String cacheKey = deriveCacheKeyFromBinlogEvent(binlogEvent);

        // 傳送到RocketMQ
        rocketMQTemplate.asyncSend("cacheUpdateTopic", cacheKey, new SendCallback() {
            @Override
            public void onSuccess(SendResult sendResult) {
                // 傳送成功處理
            }

            @Override
            public void onException(Throwable e) {
                // 傳送失敗處理
            }
        });
    }

    // 從binlog事件中獲取快取key的邏輯,這裡僅為示意
    private String deriveCacheKeyFromBinlogEvent(BinlogEvent binlogEvent) {
        // ...
    }
}

@RocketMQMessageListener(consumerGroup = "myConsumerGroup", topic = "cacheUpdateTopic")
public class CacheUpdateConsumer {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Override
    public void onMessage(MessageExt messageExt) {
        String cacheKey = new String(messageExt.getBody());
        redisTemplate.delete(cacheKey);
    }
}

這種方法的好處是將快取的更新操作與主業務流程解耦,避免阻塞主執行緒,同時還能處理資料庫更新後由於網路問題或併發問題導致的快取更新滯後情況。當然,實現這一策略相對複雜,需要對資料庫的binlog機制有深入理解和定製開發。

總結

在分散式系統中,為了保證快取與資料庫雙寫一致性,可以採用以下方案:

  1. 讀取操作

    • 先嚐試從快取讀取資料,若快取命中,則直接返回快取中的資料。
    • 若快取未命中,則從資料庫讀取資料,並將資料放入快取。
  2. 更新操作

    • 在更新資料時,首先在資料庫進行寫入操作,確保主資料庫資料的即時更新。
    • 為了減少資料不一致視窗,採用非同步方式處理快取更新,具體做法是監聽資料庫的binlog事件,非同步進行刪除快取。
    • 在一主多從的場景下,為了確保資料一致性,需要等待所有從庫的binlog事件都被處理後才刪除快取(確保全部從庫均已更新)。

同時,還需注意以下要點:

  • 對於高併發環境,可能需要結合分散式鎖、訊息佇列或快取失效延時等技術,進一步確保併發寫操作下的資料一致性。
  • 非同步處理binlog時,務必考慮異常處理機制和重試策略,確保binlog事件能夠正確處理並執行快取更新操作。

本文已收錄於我的個人部落格:碼農Academy的部落格,專注分享Java技術乾貨,包括Java基礎、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中介軟體、架構設計、面試題、程式設計師攻略等

相關文章