Redis緩存和數(shù)據(jù)庫的數(shù)據(jù)一致性的問題解決
前言
如果項目業(yè)務處于起步階段,流量非常小,那無論是讀請求還是寫請求,直接操作數(shù)據(jù)庫即可,這時架構模型是這樣的:
但隨著業(yè)務量的增長,項目業(yè)務請求量越來越大,這時如果每次都從數(shù)據(jù)庫中讀數(shù)據(jù),那肯定會有性能問題。這個階段通常的做法是,引入緩存來提高讀性能,架構模型就變成了這樣:
在實際開發(fā)過程中,緩存的使用頻率是非常高的,只要使用緩存和數(shù)據(jù)庫存儲,就難免會出現(xiàn)雙寫時數(shù)據(jù)一致性的問題,就是 Redis 緩存的數(shù)據(jù)和數(shù)據(jù)庫中保存的數(shù)據(jù)出現(xiàn)不相同的現(xiàn)象。
如上圖所示,大多數(shù)人的很多業(yè)務操作都是根據(jù)這個圖來做緩存的,這樣能有效減輕數(shù)據(jù)庫壓力。但是一旦設計到雙寫或者數(shù)據(jù)庫和緩存更新等操作,就很容易出現(xiàn)數(shù)據(jù)一致性的問題。無論是先寫數(shù)據(jù)庫,在刪除緩存,還是先刪除緩存,在寫入數(shù)據(jù)庫,都會出現(xiàn)數(shù)據(jù)一致性的問題。例如:
- 先刪除了redis緩存,但是因為其他什么原因還沒來得及寫入數(shù)據(jù)庫,另外一個線程就來讀取,發(fā)現(xiàn)緩存為空,則去數(shù)據(jù)庫讀取到之前的數(shù)據(jù)并寫入緩存,此時緩存中為臟數(shù)據(jù)。
- 如果先寫入了數(shù)據(jù)庫,但是在緩存被刪除前,寫入數(shù)據(jù)庫的線程因為其他原因被中斷了,沒有刪除掉緩存,就也會出現(xiàn)數(shù)據(jù)不一致的情況。
總的來說,寫和讀在多數(shù)情況下都是并發(fā)的,不能絕對保證先后順序,就會很容易出現(xiàn)緩存和數(shù)據(jù)庫數(shù)據(jù)不一致的情況,那我們又該如何解決呢?
一、談談一致性
首先,我們先來看看有哪幾種一致性的情況呢?
- 強一致性:如果你的項目對緩存的要求是強一致性的,那么請不要使用緩存。這種一致性級別是最符合用戶直覺的,它要求系統(tǒng)寫入什么,讀出來的也會是什么,用戶體驗好,但實現(xiàn)起來往往對系統(tǒng)的性能影響大。讀請求和寫請求會串行化,串到一個內(nèi)存隊列里去,這樣會大大增加系統(tǒng)的處理效率,吞吐量也會大大降低。
- 弱一致性:這種一致性級別約束了系統(tǒng)在寫入成功后,不承諾立即可以讀到寫入的值,也不承諾多久之后數(shù)據(jù)能夠達到一致,但會盡可能地保證到某個時間級別(比如秒級別)后,數(shù)據(jù)能夠達到一致狀態(tài)。
- 最終一致性:最終一致性是弱一致性的一個特例,系統(tǒng)會保證在一定時間內(nèi),能夠達到一個數(shù)據(jù)一致的狀態(tài)。這里之所以將最終一致性單獨提出來,是因為它是弱一致性中非常推崇的一種一致性模型,也是業(yè)界在大型分布式系統(tǒng)的數(shù)據(jù)一致性上比較推崇的模型。一般情況下,高可用只確保最終一致性,不確保強一致性。
二、 情景分析
2.1 針對讀場景
A請求查詢數(shù)據(jù),如果命中緩存,那么直接取緩存數(shù)據(jù)返回即可。如果請求中不存在,數(shù)據(jù)庫中存在,那么直接取數(shù)據(jù)庫數(shù)據(jù)返回,然后將數(shù)據(jù)同步到Redis中。不會存在數(shù)據(jù)不一致的情況。
在高并發(fā)的情況下,A請求和B請求一起訪問某條數(shù)據(jù),如果緩存中數(shù)據(jù)存在,直接返回即可,如果不存在,直接取數(shù)據(jù)庫數(shù)據(jù)返回即可。無論A請求B請求誰先誰后,本質(zhì)上沒有對數(shù)據(jù)進行修改,數(shù)據(jù)本身沒變,只是從緩存中取還是從數(shù)據(jù)庫中取的問題,因此不會存在數(shù)據(jù)不一致的情況。
因此,單獨的讀場景是不會造成Redis與數(shù)據(jù)庫緩存不一致的情況,因此我們不用關心這種情況。
2.2 針對寫場景
如果該數(shù)據(jù)在緩存中不存在,那么直接修改數(shù)據(jù)庫中的數(shù)據(jù)即可,不會存在數(shù)據(jù)不一致的情況。
如果該數(shù)據(jù)在緩存中和數(shù)據(jù)庫中都存在,那么就需要既修改緩存中的數(shù)據(jù)又修改數(shù)據(jù)庫中的數(shù)據(jù)。如果寫數(shù)據(jù)庫的值與更新到緩存值是一樣的,可以馬上更新緩存;如果寫數(shù)據(jù)庫的值與更新緩存的值不一致,在高并發(fā)的場景下,還存在先后關系,這就會導致數(shù)據(jù)不一致的問題。例如:
- 當更新數(shù)據(jù)時,如更新某商品的庫存,當前商品的庫存是100,現(xiàn)在要更新為99,先更新數(shù)據(jù)庫更改成99,然后刪除緩存,發(fā)現(xiàn)刪除緩存失敗了,這意味著數(shù)據(jù)庫存的是99,而緩存是100,這導致數(shù)據(jù)庫和緩存不一致。
- 在高并發(fā)的情況下,如果當刪除完緩存的時候,這時去更新數(shù)據(jù)庫,但還沒有更新完,另外一個請求來查詢數(shù)據(jù),發(fā)現(xiàn)緩存里沒有,就去數(shù)據(jù)庫里查,還是以上面商品庫存為例,如果數(shù)據(jù)庫中產(chǎn)品的庫存是100,那么查詢到的庫存是100,然后插入緩存,插入完緩存后,原來那個更新數(shù)據(jù)庫的線程把數(shù)據(jù)庫更新為了99,導致數(shù)據(jù)庫與緩存不一致的情況。
三、同步策略
想要保證緩存與數(shù)據(jù)庫的雙寫一致,一共有4種方式,即4種同步策略:
從這4種同步策略中,我們需要作出比較的是:更新緩存與刪除緩存哪種方式更合適?應該先操作數(shù)據(jù)庫還是先操作緩存?
3.1 先更新緩存,再更新數(shù)據(jù)庫
這個方案我們一般不考慮。原因是當數(shù)據(jù)同步時,更新 Redis 緩存成功,但更新數(shù)據(jù)庫出現(xiàn)異常時,會導致 Redis 緩存數(shù)據(jù)與數(shù)據(jù)庫數(shù)據(jù)完全不一致,而且這很難察覺,因為 Redis 緩存中的數(shù)據(jù)一直都存在。
只要緩存進行了更新,后續(xù)的讀請求基本上就不會出現(xiàn)緩存未命中的情況。但在某些業(yè)務場景下,更新數(shù)據(jù)的成本較大,并不是單純將數(shù)據(jù)的數(shù)據(jù)查詢出來丟到緩存中即可,而是需要連接很多張表組裝對應數(shù)據(jù)存入緩存中,并且可能存在更新后,該數(shù)據(jù)并不會被使用到的情況。
3.2 先更新數(shù)據(jù)庫,再更新緩存
這個方案我們一般也是不考慮。原因是當數(shù)據(jù)同步時,數(shù)據(jù)庫更新成功,但 Redis 緩存更新失敗,那么此時數(shù)據(jù)庫中是最新值,Redis 緩存中是舊值。之后的應用系統(tǒng)的讀請求讀到的都是 Redis 緩存中舊數(shù)據(jù)。只有當 Redis 緩存數(shù)據(jù)失效后,才能從數(shù)據(jù)庫中重新獲得正確的值。
該方案還存在并發(fā)引發(fā)的一致性問題,假設同時有兩個線程進行數(shù)據(jù)更新操作,如下圖所示:
從上圖可以看到,線程1雖然先于線程2發(fā)生,但線程2操作數(shù)據(jù)庫和緩存的時間,卻要比線程1的時間短,執(zhí)行時序發(fā)生錯亂,最終這條數(shù)據(jù)結(jié)果是不符合預期的。如果是寫多讀少的場景,采用這種方案就會導致,數(shù)據(jù)壓根還沒讀到,緩存就被頻繁的更新,浪費性能。
3.3 先刪除緩存,后更新數(shù)據(jù)庫
這種方案只是盡可能保證一致性而已,極端情況下,還是有可能發(fā)生數(shù)據(jù)不一致問題,原因是當數(shù)據(jù)同步時,如果刪除 Redis 緩存失敗,更新數(shù)據(jù)庫成功,那么此時數(shù)據(jù)庫中是最新值,Redis 緩存中是舊值。之后的應用系統(tǒng)的讀請求讀到的都是 Redis 緩存中舊數(shù)據(jù)。只有當 Redis 緩存數(shù)據(jù)失效后,才能從數(shù)據(jù)庫中重新獲得正確的值。由于緩存被刪除,下次查詢無法命中緩存,需要在查詢后將數(shù)據(jù)寫入緩存,增加查詢邏輯。同時在高并發(fā)的情況下,同一時間大量請求訪問該條數(shù)據(jù),第一條查詢請求還未完成寫入緩存操作時,這種情況,大量查詢請求都會打到數(shù)據(jù)庫,加大數(shù)據(jù)庫壓力。
該方案還存在并發(fā)引發(fā)的一致性問題,假設同時有兩個線程進行數(shù)據(jù)更新操作,如下圖所示。當緩存被線程一刪除后,如果此時有新的讀請求(線程二)發(fā)生,由于緩存已經(jīng)被刪除,這個讀請求(線程二)將會去從數(shù)據(jù)庫查詢。如果此時線程一還沒有修改完數(shù)據(jù)庫,線程二從數(shù)據(jù)庫讀的數(shù)據(jù)仍然是舊值,同時線程二將讀的舊值寫入到緩存。線程一完成后,數(shù)據(jù)庫變?yōu)樾轮?,而緩存還是舊值。
從上圖可見,先刪除 Redis 緩存,后更新數(shù)據(jù)庫,當發(fā)生讀/寫并發(fā)時,還是存在數(shù)據(jù)不一致的情況。如何解決呢?最簡單的解決辦法就是延時雙刪策略:先淘汰緩存、再寫數(shù)據(jù)庫、休眠后再次淘汰緩存。這樣做的目的,就是確保讀請求結(jié)束,寫請求可以刪除讀請求造成的緩存臟數(shù)據(jù)。
public void deleteRedisData(UserInfo userInfo){ // 刪除Redis中的緩存數(shù)據(jù) jedis.del(userInfo); // 更新MySQL數(shù)據(jù)庫數(shù)據(jù) userInfoDao.update(userInfo); try { TimeUnit.SECONDS.sleep(2); } catch(Exception exp){ exp.printStackTrace(); } // 刪除Redis中的緩存數(shù)據(jù) jedis.del(userInfo); }
延時雙刪就能徹底解決不一致嗎?當然不一定來。首先,我們評估的延時時間并不能完全代表實際運行過程中的耗時,運行過程如果因為系統(tǒng)壓力過大,我們評估的耗時就是不準確,仍然會導致數(shù)據(jù)不一致的出現(xiàn)。其次,延時雙刪雖然在保證事務提交完以后再進行刪除緩存,但是如果使用的是MySQL的讀寫分離的機構,主從同步之間其實也會有時間差。
3.4 先更新數(shù)據(jù)庫,后刪除緩存
實際使用中,建議采用這種方案。當然,這種方案其實一樣也可能有失敗的情況。
當數(shù)據(jù)同步時,如果更新數(shù)據(jù)庫成功,而刪除 Redis 緩存失敗,那么此時數(shù)據(jù)庫中是最新值,Redis 緩存中是舊值。之后的應用系統(tǒng)的讀請求讀到的都是 Redis 緩存中舊數(shù)據(jù)。只有當 Redis 緩存數(shù)據(jù)失效后,才能從數(shù)據(jù)庫中重新獲得正確的值。讀的時候,先讀緩存,緩存沒有的話,就讀數(shù)據(jù)庫,然后取出數(shù)據(jù)后放入緩存,同時返回響應。更新的時候,先更新數(shù)據(jù)庫,然后再刪除緩存。
該方案還存在并發(fā)引發(fā)的一致性問題,假設同時有兩個線程進行數(shù)據(jù)更新操作,如下圖所示。當數(shù)據(jù)庫的數(shù)據(jù)被更新后,如果此時緩存還沒有被刪除,那么緩存中的數(shù)據(jù)仍然是舊值。如果此時有新的讀請求(查詢數(shù)據(jù))發(fā)生,由于緩存中的數(shù)據(jù)是舊值,這個讀請求將會獲取到舊值。當緩存剛好失效,這時有請求來讀緩存(線程一),未命中緩存,然后到數(shù)據(jù)庫中讀取,在要寫入緩存時,線程二來修改了數(shù)據(jù)庫,而線程一寫入緩存的是舊的數(shù)據(jù),導致了數(shù)據(jù)的不一致。
四、解決辦法
當我們在應用中同時使用MySQL和Redis時,如何保證兩者的數(shù)據(jù)一致性呢?下面就來分享幾種實用的解決方案。
4.1 雙寫一致性
最直接的辦法就是在業(yè)務代碼中同時對MySQL和Redis進行更新。通常我們會先更新MySQL,然后再更新Redis。
// 更新MySQL userMapper.update(user); // 更新Redis redisTemplate.opsForValue().set("user_" + user.getId(), user);
這種方式最大的問題就是在于網(wǎng)絡故障或者程序異常的情況下,可能會導致MySQL和Redis中的數(shù)據(jù)不一致。因此,我們需要額外的手段來檢測和修復數(shù)據(jù)不一致的情況。
4.2 異步更新(異步通知)
在更新數(shù)據(jù)庫數(shù)據(jù)時,同時發(fā)送一個異步通知給Redis,讓Redis知道數(shù)據(jù)庫數(shù)據(jù)已經(jīng)更新,需要更新緩存中的數(shù)據(jù)。這個過程是異步的,不會阻塞數(shù)據(jù)庫的更新操作。當Redis收到異步通知后,會立即刪除緩存中對應的數(shù)據(jù),確保緩存中沒有舊數(shù)據(jù)。這樣,即使在這個過程中有新的讀請求發(fā)生,也不會讀取到舊數(shù)據(jù)。等到數(shù)據(jù)庫更新完成后,Redis再次從數(shù)據(jù)庫中讀取最新的數(shù)據(jù)并緩存起來。
// 更新MySQL userMapper.update(user); // 發(fā)送消息 rabbitTemplate.convertAndSend("updateUser", user.getId()); /** * 然后在消息消費者中更新Redis。 */ @RabbitListener(queues = "updateUser") public void updateUser(String userId) { User user = userMapper.selectById(userId); redisTemplate.opsForValue().set(redisTemplate.opsForValue().set("user_" + user.getId(), user); }
這種異步通知的方式,可以確保Redis中的數(shù)據(jù)與數(shù)據(jù)庫中的數(shù)據(jù)保持一致,避免出現(xiàn)數(shù)據(jù)不一致的情況。這種方案可以降低數(shù)據(jù)不一致的風險,但仍然無法完全避免。因為消息隊列本身也可能因為各種原因丟失消息。
4.3 使用Redis的事務支持
Redis提供了事務(Transaction)支持,可以將一系列的操作作為一個原子操作執(zhí)行。我們可以利用Redis的事務來實現(xiàn)MySQL和Redis的原子更新。
redisTemplate.execute(new Sessioncallback<Object>(){ @0verridepublic Object execute(RedisOperations operations) throws DataAccessException { // 開啟事務 operations.multi(); // 更新MySQL userMapper.update(user); // 更新Redis operations.opsForValue().set("user_" + user.getId(),user); // 執(zhí)行事務 operations.exec(); return null; } });
使用Redis事務可以確保MySQL和Redis的更新在同一事務中執(zhí)行,避免了中間出現(xiàn)不一致的情況。但需要注意的是,Redis的事務并非嚴格的ACID事務,可能存在部分成功的情況。
4.4 用 Redisson 實現(xiàn)讀鎖和寫鎖
Redisson 是一個基于 Redis 的分布式 Java 對象存儲和緩存框架,它提供了豐富的功能和 API 來操作 Redis 數(shù)據(jù)庫,其中包括了讀寫鎖的支持。讀寫鎖是一種常用的并發(fā)控制機制,它允許多個線程同時讀取共享資源,但在寫操作時互斥,只允許一個線程進行寫操作。使用 Redisson 的讀寫鎖方法:
- 獲取讀鎖:通過 Redisson 的 RReadWriteLock 對象的 readLock() 方法獲取讀鎖。在獲取讀鎖后,可以并發(fā)讀取共享資源,不會阻塞其他獲取讀鎖的線程。
- 獲取寫鎖:通過 Redisson 的 RReadWriteLock 對象的 writeLock() 方法獲取寫鎖。在獲取寫鎖后,其他獲取讀鎖和寫鎖的線程將被阻塞,只有當前線程能夠進行寫操作。
- 釋放鎖:使用完讀鎖或?qū)戞i后,應該及時調(diào)用 unlock() 方法釋放鎖,以便其他線程可以獲取鎖并進行操作。在 Redisson 中,讀鎖和寫鎖都繼承自鎖對象 RLock,因此可以使用 RLock 的 unlock() 方法來釋放鎖。
下面是一個使用 Redisson 讀寫鎖的示例,通過 Redisson 的 RReadWriteLock 對象獲取讀鎖和寫鎖,并在需要的代碼段中進行相應的操作。執(zhí)行完操作后,使用 unlock() 方法釋放鎖,最后關閉 Redisson 客戶端。
import org.redisson.Redisson; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.redisson.config.Config; public class RedissonReadWriteLockExample { public static void main(String[] args) { // 創(chuàng)建 Redisson 客戶端 Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379"); RedissonClient redisson = Redisson.create(config); // 獲取讀寫鎖 RReadWriteLock rwLock = redisson.getReadWriteLock("myLock"); RLock readLock = rwLock.readLock(); RLock writeLock = rwLock.writeLock(); try { // 獲取讀鎖并進行讀操作 readLock.lock(); // 讀取共享資源 // 獲取寫鎖并進行寫操作 writeLock.lock(); // 寫入共享資源 } finally { // 釋放鎖 writeLock.unlock(); readLock.unlock(); } // 關閉 Redisson 客戶端 redisson.shutdown(); } }
五、結(jié)語
綜上所述,我們提供了更全面的MySQL與Redis數(shù)據(jù)一致性解決方案。根據(jù)具體的業(yè)務需求和系統(tǒng)環(huán)境,選擇合適的方案可以提高數(shù)據(jù)一致性的可靠性。然而,每種方案都有其優(yōu)缺點和適用場景,需要綜合考慮權衡。
對于并發(fā)幾率很小的數(shù)據(jù)(如個人維度的訂單數(shù)據(jù)、用戶數(shù)據(jù)等),這種幾乎不用考慮這個問題,很少會發(fā)生緩存不一致,可以給緩存數(shù)據(jù)加上過期時間,每隔一段時間觸發(fā)讀的主動更新即可。就算并發(fā)很高,如果業(yè)務上能容忍短時間的緩存數(shù)據(jù)不一致(如商品名稱,商品分類菜單等),緩存加上過期時間依然可以解決大部分業(yè)務對于緩存的要求。
到此這篇關于Redis緩存和數(shù)據(jù)庫的數(shù)據(jù)一致性的問題解決的文章就介紹到這了,更多相關Redis緩存和數(shù)據(jù)庫的數(shù)據(jù)一致性內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Redis的Python客戶端redis-py安裝使用說明文檔
這篇文章主要介紹了Redis的Python客戶端redis-py安裝使用說明文檔,本文講解了安裝方法、入門使用實例、API參考和詳細說明,需要的朋友可以參考下2015-06-06Redis Sentinel實現(xiàn)高可用配置的詳細步驟
這篇文章主要介紹了Redis Sentinel實現(xiàn)高可用配置的詳細步驟,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-09-09使用SpringBoot?+?Redis?實現(xiàn)接口限流的方式
這篇文章主要介紹了SpringBoot?+?Redis?實現(xiàn)接口限流,Redis?除了做緩存,還能干很多很多事情:分布式鎖、限流、處理請求接口冪等,文中給大家提到了限流注解的創(chuàng)建方式,需要的朋友可以參考下2022-05-05