如何保證Redis與MySQL雙寫一致性?連續兩個面試問到了!
引言
Redis作為一款高效的內存數據存儲系統,憑借其優異的讀寫性能和豐富的數據結構支持,被廣泛應用于緩存層以提升整個系統的響應速度和吞吐量。尤其是在與關系型數據庫(如MySQL、PostgreSQL等)結合使用時,通過將熱點數據存儲在Redis中,可以在很大程度上緩解數據庫的壓力,提高整體系統的性能表現。
然而,在這種架構中,一個不容忽視的問題就是如何確保Redis緩存與數據庫之間的雙寫一致性。所謂雙寫一致性,是指當數據在數據庫中發生變更時,能夠及時且準確地反映在Redis緩存中,反之亦然,以避免出現因緩存與數據庫數據不一致導致的業務邏輯錯誤或用戶體驗下降。尤其在高并發場景下,由于網絡延遲、并發控制等因素,保證雙寫一致性變得更加復雜。
在實際業務開發中,若不能妥善處理好緩存與數據庫的雙寫一致性問題,可能會帶來諸如數據丟失、臟讀、重復讀等一系列嚴重影響系統穩定性和可靠性的后果。本文將嘗試剖析這一問題,介紹日常開發中常用的一些策略和模式,并結合具體場景分析不同的解決方案,為大家提供一些有力的技術參考和支持。
圖片
談談分布式系統中的一致性
分布式系統中的一致性指的是在多個節點上存儲和處理數據時,確保系統中的數據在不同節點之間保持一致的特性。在分布式系統中,一致性通常可以分為以下幾個類別:
1. 強一致性:所有節點在任何時間都看到相同的數據。任何更新操作都會立即對所有節點可見,保證了數據的強一致性。這意味著,如果一個節點完成了寫操作,那么所有其他節點讀取相同的數據之后,都將看到最新的結果。強一致性通常需要付出更高的代價,例如增加通信開銷和降低系統的可用性。
2. 弱一致性: 系統中的數據在某些情況下可能會出現不一致的狀態,但最終會收斂到一致狀態。弱一致性下的系統允許在一段時間內,不同節點之間看到不同的數據狀態。弱一致性通常用于需要在性能和一致性之間進行權衡的場景,例如緩存系統等。
3. 最終一致性: 是弱一致性的一種特例,它保證了在經過一段時間后,系統中的所有節點最終都會達到一致狀態。盡管在數據更新時可能會出現一段時間的不一致,但最終數據會收斂到一致狀態。最終一致性通常通過一些技術手段來實現,例如基于版本向量或時間戳的數據復制和同步機制。
除此之外,還有一些其他的一致性類別,例如:因果一致性,順序一致性,基于本篇文章討論的重點,我們暫且只討論以上三種一致性類別。
什么是雙寫一致性問題?
在分布式系統中,雙寫一致性主要指在一個數據同時存在于緩存(如Redis)和持久化存儲(如數據庫)的情況下,任何一方的數據更新都必須確保另一方數據的同步更新,以保持雙方數據的一致狀態。這一問題的核心在于如何在并發環境下正確處理緩存與數據庫的讀寫交互,防止數據出現不一致的情況。
典型場景分析
1. 寫數據庫后忘記更新緩存:當直接對數據庫進行更新操作而沒有相應地更新緩存時,后續的讀請求可能仍然從緩存中獲取舊數據,導致數據的不一致。
2. 刪除緩存后數據庫更新失敗: 在某些場景下,為了保證數據新鮮度,會在更新數據庫前先刪除緩存。但如果數據庫更新過程中出現異常導致更新失敗,那么緩存將長時間處于空缺狀態,新的查詢將會直接命中數據庫,加重數據庫壓力,并可能導致數據版本混亂。
3. 并發環境下讀寫操作的交錯執行:在高并發場景下,可能存在多個讀寫請求同時操作同一份數據的情況。比如,在刪除緩存、寫入數據庫的過程中,新的讀請求獲取到了舊的數據庫數據并放入緩存,此時就出現了數據不一致的現象。
4. 主從復制延遲與緩存失效時間窗口沖突:對于具備主從復制功能的數據庫集群,主庫更新數據后,存在一定的延遲才將數據同步到從庫。如果在此期間緩存剛好過期并重新從數據庫加載數據,可能會從尚未完成同步的從庫讀取到舊數據,進而導致緩存與主庫數據的不一致。
數據不一致不僅會導致業務邏輯出錯,還可能引發用戶界面展示錯誤、交易狀態不準確等問題,嚴重時甚至會影響系統的正常運行和用戶體驗。
解決雙寫一致性問題的主要策略
在解決Redis緩存與數據庫雙寫一致性問題上,有多種策略和模式。我們主要介紹以下幾種主要的策略:
Cache Aside Pattern(旁路緩存模式)
Cache Aside Pattern 是一種在分布式系統中廣泛采用的緩存和數據庫協同工作策略,在這個模式中,數據以數據庫為主存儲,緩存作為提升讀取效率的輔助手段。也是日常中比較常見的一種手段。其工作流程如下:
圖片
由上圖我們可以看出Cache Aside Pattern的工作原理:
? 讀取操作:首先嘗試從緩存中獲取數據,如果緩存命中,則直接返回;否則,從數據庫中讀取數據并將其放入緩存,最后返回給客戶端。
? 更新操作:當需要更新數據時,首先更新數據庫,然后再清除或使緩存中的對應數據失效。這樣一來,后續的讀請求將無法從緩存獲取數據,從而迫使系統從數據庫加載最新的數據并重新填充緩存。
我們從更新操作上看會發現兩個很有意思的問題:
為什么操作緩存的時候是刪除舊緩存而不是直接更新緩存?
我們舉例模擬下并發環境下的更新DB&緩存:
? 線程A先發起一個寫操作,第一步先更新數據庫,然后更新緩存
? 線程B再發起一個寫操作,第二步更新了數據庫,然后更新緩存 當以上兩個線程的執行,如果嚴格先后順序執行,那么對于更新緩存還是刪除緩存去操作緩存都可以,但是如果兩個線程同時執行時,由于網絡或者其他原因,導致線程B先執行完更新緩存,然后線程A才會更新緩存。如下圖:
圖片
這時候緩存中保存的就是線程A的數據,而數據庫中保存的是線程B的數據。這時候如果讀取到的緩存就是臟數據。但是如果使用刪除緩存取代更新緩存,那么就不會出現這個臟數據。這種方式可以簡化并發控制、保證數據一致性、降低操作復雜度,并能更好地適應各種潛在的異常場景和緩存策略。盡管這種方法可能會增加一次數據庫訪問的成本,但在實際應用中,考慮到數據的一致性和系統的健壯性,這是值得付出的折衷。
并且在寫多讀少的情況下,數據很多時候并不會被讀取到,但是一直被頻繁的更新,這樣也會浪費性能。實際上,寫多的場景,用緩存也不是很劃算。只有在讀多寫少的情況下使用緩存才會發揮更大的價值。
為什么是先操作數據庫再操作緩存?
在操作緩存時,為什么要先操作數據庫而不是先操作緩存?我們同樣舉例模擬兩個線程,線程A寫入數據,先刪除緩存在更新DB,線程B讀取數據。流程如下:
1. 線程A發起一個寫操作,第一步刪除緩存
2. 此時線程B發起一個讀操作,緩存中沒有,則繼續讀DB,讀出來一個老數據
3. 然后線程B把老數據放入緩存中
4. 線程A更新DB數據
圖片
所以這樣就會出現緩存中存儲的是舊數據,而數據庫中存儲的是新數據,這樣就出現臟數據,所以我們一般都采取先操作數據庫,在操作緩存。這樣后續的讀請求從數據庫獲取最新數據并重新填充緩存。這樣的設計降低了數據不一致的風險,提升了系統的可靠性。同時,這也符合CAP定理中對于一致性(Consistency)和可用性(Availability)權衡的要求,在很多場景下,數據一致性被優先考慮。
Cache Aside Pattern相對簡單直觀,容易理解和實現。只需要簡單的判斷和緩存失效邏輯即可,對已有系統的改動較小。并且由于緩存是按需加載的,所以不會浪費寶貴的緩存空間存儲未被訪問的數據,同時我們可以根據實際情況決定何時加載和清理緩存。
盡管Cache Aside Pattern在大多數情況下可以保證最終一致性,但它并不能保證強一致性。在數據庫更新后的短暫時間內(還未開始操作緩存),如果有讀請求發生,緩存中仍是舊數據,但是實際數據庫中已是最新數據,造成短暫的數據不一致。在并發環境下,特別是在更新操作時,有可能在更新數據庫和刪除緩存之間的時間窗口內,新的讀請求加載了舊數據到緩存,導致不一致。
Read-Through/Write-Through(讀寫穿透)
Read-Through 和 Write-Through 是兩種與緩存相關的策略,它們主要用于緩存系統與持久化存儲之間的數據交互,旨在確保緩存與底層數據存儲的一致性。
Read-Through(讀穿透)
Read-Through 是一種在緩存中找不到數據時,自動從持久化存儲中加載數據并回填到緩存中的策略。具體執行流程如下:
- ? 客戶端發起讀請求到緩存系統。
- ? 緩存系統檢查是否存在請求的數據。
- ? 如果數據不在緩存中,緩存系統會透明地向底層數據存儲(如數據庫)發起讀請求。
- ? 數據庫返回數據后,緩存系統將數據存儲到緩存中,并將數據返回給客戶端。
- ? 下次同樣的讀請求就可以直接從緩存中獲取數據,提高了讀取效率。
圖片
整體簡要流程類似Cache Aside Pattern,但在緩存未命中的情況下,Read-Through 策略會自動隱式地從數據庫加載數據并填充到緩存中,而無需應用程序顯式地進行數據庫查詢。
Cache Aside Pattern 更多地依賴于應用程序自己來管理緩存與數據庫之間的數據流動,包括緩存填充、失效和更新。而Read-Through Pattern 則是在緩存系統內部實現了一個更加自動化的過程,使得應用程序無需關心數據是從緩存還是數據庫中獲取,以及如何保持兩者的一致性。在Read-Through 中,緩存系統承擔了更多的職責,實現了更緊密的緩存與數據庫集成,從而簡化了應用程序的設計和實現。
Write-Through(寫穿透)
Write-Through 是一種在緩存中更新數據時,同時將更新操作同步到持久化存儲的策略。具體流程如下:
? 當客戶端向緩存系統發出寫請求時,緩存系統首先更新緩存中的數據。
? 同時,緩存系統還會把這次更新操作同步到底層數據存儲(如數據庫)。
? 當數據在數據庫中成功更新后,整個寫操作才算完成。
? 這樣,無論是從緩存還是直接從數據庫讀取,都能得到最新一致的數據。
圖片
Read-Through 和 Write-Through 的共同目標是確保緩存與底層數據存儲之間的一致性,并通過自動化的方式隱藏了緩存與持久化存儲之間的交互細節,簡化了客戶端的處理邏輯。這兩種策略經常一起使用,以提供無縫且一致的數據訪問體驗,特別適用于那些對數據一致性要求較高的應用場景。然而,需要注意的是,雖然它們有助于提高數據一致性,但在高并發或網絡不穩定的情況下,仍然需要考慮并發控制和事務處理等問題,以防止數據不一致的情況發生。
Write behind (異步緩存寫入)
Write Behind(異步緩存寫入),也稱為 Write Back(回寫)或 異步更新策略,是一種在處理緩存與持久化存儲(如數據庫)之間數據同步時的策略。在這種模式下,當數據在緩存中被更新時,并非立即同步更新到數據庫,而是將更新操作暫存起來,隨后以異步的方式批量地將緩存中的更改寫入持久化存儲。其流程如下:
? 應用程序首先在緩存中執行數據更新操作,而不是直接更新數據庫。
? 緩存系統會將此次更新操作記錄下來,暫存于一個隊列(如日志文件或內存隊列)中,而不是立刻同步到數據庫。
? 在后臺有一個獨立的進程或線程定期(或者當隊列積累到一定大小時)從暫存隊列中取出更新操作,然后批量地將這些更改寫入數據庫。
圖片
使用 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事件能夠正確處理并執行緩存更新操作。