來源:https://ouyblog.com/2017/04/Redis%E7%BC%93%E5%AD%98%E6%95%B0%E6%8D%AE%E4%B8%80%E8%87%B4%E6%80%A7
在互聯(lián)網行業(yè),使用緩存來提升應用的性能已經是一件非常常見的手段,但是如何保證緩存與數(shù)據(jù)庫的一致性確不是一件容易的事。比如下面的場景都可會導致數(shù)據(jù)不一致性。
場景1:更新數(shù)據(jù)庫成功,更新緩存失敗,數(shù)據(jù)不一致;
場景2:更新緩存成功,更新數(shù)據(jù)庫失敗,數(shù)據(jù)不一致;
場景3:更新數(shù)據(jù)庫成功,清除緩存失敗,數(shù)據(jù)不一致;
場景4:清除緩存成功,更新數(shù)據(jù)庫失敗,數(shù)據(jù)弱一致;
緩存和數(shù)據(jù)庫是兩類不同的存儲資源,如果要追求絕對的數(shù)據(jù)一致性,唯一的辦法就是分布式事務。但使用分布式事務又會引入嚴重的寫入性能損耗,在大多數(shù)情況下,業(yè)務上是無法接受這樣的損耗的。所以更多的時候,我們追求的是數(shù)據(jù)的最終一致性,一種比較折中的實現(xiàn)是這樣的:
寫操作 讀操作
- 清除緩存;若失敗則返回錯誤信息(本次寫操作失?。?/li>
- 更新數(shù)據(jù)庫;若失敗則返回錯誤信息(本次寫操作失?。藭r數(shù)據(jù)弱一致。
- 更新緩存,即使失敗也返回成功,此時數(shù)據(jù)弱一致。 1. 查詢緩存,命中則直接返回結果。
- 查詢數(shù)據(jù)庫,將結果直接寫入緩存,返回結果。
這種實現(xiàn)簡單明了,尤其是讀操作,一看即明白。對于寫操作,會有朋友問為什么第一步要先清除緩存。大家可以想想,如果去掉第一步,那么寫操作就可能發(fā)生最開始我們提到的場景1的情況:更新數(shù)據(jù)庫成功,更新緩存失敗,數(shù)據(jù)不一致。如果在寫操作的第一步先清除緩存,對于場景1的情況,那結果會是數(shù)據(jù)庫中有值,而緩存中無值,即數(shù)據(jù)弱一致,并不會造成業(yè)務錯誤。
如果你認為上面的實現(xiàn)已經完美,那你可能會失望了。在并發(fā)場景中,它并不安全。我們看一個簡單的例子:假如有一個用戶,它的賬戶中有100塊錢?,F(xiàn)在有兩個并發(fā)的請求:請求1為寫操作,更新用戶的余額,從100更新為200;請求2為查詢操作,查詢用戶的余額。由于是并發(fā)的,兩個請求之間的執(zhí)行順序是不確定的,我們來看一下下面的執(zhí)行順序:
請求1首先清除用戶的緩存。
接著請求2查詢緩存,由于緩存中沒有數(shù)據(jù),請求2繼續(xù)查詢數(shù)據(jù)庫,得到余額為100。
請求1更新數(shù)據(jù)庫,并將結果寫入緩存。此時,數(shù)據(jù)庫與緩存中的余額都是200。
請求2將數(shù)據(jù)庫查詢結果100寫入緩存。
最終,余額在數(shù)據(jù)庫中是200,而在緩存中是100,數(shù)據(jù)不一致。
造成這樣的結果,原因有兩個方面:一是寫操作中更新數(shù)據(jù)庫與更新緩存是兩個操作,而不是一個原子操作;二是讀操作中讀取數(shù)據(jù)庫和寫入緩存兩個操作不是原子的。要解決這個問題,需要做一些修改,引入分布式鎖:
寫操作 讀操作
1.清除緩存;若失敗則返回錯誤信息(本次寫操作失?。?。
2.對key加分布式鎖。
3.更新數(shù)據(jù)庫;若失敗則返回錯誤信息(本次寫操作失?。┩瑫r釋放鎖,此時數(shù)據(jù)弱一致。
4.更新緩存,即使失敗也返回成功,同時釋放鎖,此時數(shù)據(jù)弱一致。 1.查詢緩存,命中則直接返回結果。
2.對key加分布式鎖。
3.查詢數(shù)據(jù)庫,將結果直接寫入緩存,返回結果,同時釋放鎖。
引入分布式鎖后的實現(xiàn),之前的并發(fā)引起的問題不復存在,讀者可以自行驗證。不過我們仔細分析一下讀操作的實現(xiàn),其實它還可以進一步的優(yōu)化。如果第二步加鎖的時候失敗了,意味著同一時刻,有別的請求在進行同一個key的寫操作或讀操作,不論怎樣,在別的請求完成之后,緩存中應該已經有(當然也可能沒有,寫操作和讀操作最后更新緩存失敗的情況下)我們需要的數(shù)據(jù)了,這時我們只需要等待一會再重新查詢緩存即可,所以更優(yōu)的讀操作的實現(xiàn):
查詢緩存,命中則直接返回結果。
對key加分布式鎖。如果加鎖失敗,則等待一會再重新跳回第1步開始重新執(zhí)行。
查詢數(shù)據(jù)庫,將結果直接寫入緩存,返回結果,同時釋放鎖。