緩存已經在項目中被廣泛使用,在讀取緩存方面,大家沒啥疑問,都是按照下圖的流程來進行業務操作。

但是在更新緩存方面,對于更新完資料庫,是更新緩存呢,還是删除緩存。又或者是先删除緩存,再更新資料庫,其實大家存在很大的争議。是以參考了網上一些資料對這塊進行講解。
先做一個說明,從理論上來說,給緩存設定過期時間,是保證最終一緻性的解決方案。這種方案下,我們可以對存入緩存的資料設定過期時間,所有的寫操作以資料庫為準,對緩存操作隻是盡最大努力即可。也就是說如果資料庫寫成功,緩存更新失敗,那麼隻要到達過期時間,則後面的讀請求自然會從資料庫中讀取新值然後回填緩存。是以,接下來讨論的思路不依賴于給緩存設定過期時間這個方案。
在這裡,我們讨論三種更新政策:
先更新資料庫,再更新緩存
先删除緩存,再更新資料庫
先更新資料庫,再删除緩存
1、先更新資料庫,再更新緩存
這套方案,大家是普遍反對的。為什麼呢?有如下兩點原因。
原因一(線程安全角度)
同時有請求A和請求B進行更新操作,那麼會出現
(1)線程A更新了資料庫
(2)線程B更新了資料庫
(3)線程B更新了緩存
(4)線程A更新了緩存
這就出現請求A更新緩存應該比請求B更新緩存早才對,但是因為網絡等原因,B卻比A更早更新了緩存。這就導緻了髒資料,是以不考慮。
原因二(業務場景角度)
有如下兩點:
(1)如果你是一個寫資料庫場景比較多,而讀資料場景比較少的業務需求,采用這種方案就會導緻,資料壓根還沒讀到,緩存就被頻繁的更新,浪費性能。
(2)如果你寫入資料庫的值,并不是直接寫入緩存的,而是要經過一系列複雜的計算再寫入緩存。那麼,每次寫入資料庫後,都再次計算寫入緩存的值,無疑是浪費性能的。顯然,删除緩存更為适合。
接下來讨論的就是争議最大的,先删緩存,再更新資料庫。還是先更新資料庫,再删緩存的問題。
2、先删緩存,再更新資料庫
該方案會導緻不一緻的原因是。同時有一個請求A進行更新操作,另一個請求B進行查詢操作。那麼會出現如下情形:
(1)請求A進行寫操作,删除緩存
(2)請求B查詢發現緩存不存在
(3)請求B去資料庫查詢得到舊值
(4)請求B将舊值寫入緩存
(5)請求A将新值寫入資料庫
上述情況就會導緻不一緻的情形出現。而且,如果不采用給緩存設定過期時間政策,該資料永遠都是髒資料。
那麼,如何解決呢?采用延時雙删政策
僞代碼如下
public void write(String key,Object data){
redis.delKey(key);
db.updateData(data);
Thread.sleep(1000);
redis.delKey(key);
}
轉化為中文描述就是
(1)先淘汰緩存
(2)再寫資料庫(這兩步和原來一樣)
(3)休眠1秒,再次淘汰緩存
這麼做,可以将1秒内所造成的緩存髒資料,再次删除。
那麼,這個1秒怎麼确定的,具體該休眠多久呢?
針對上面的情形,讀者應該自行評估自己的項目的讀資料業務邏輯的耗時。然後寫資料的休眠時間則在讀資料業務邏輯的耗時基礎上,加幾百ms即可。這麼做的目的,就是確定讀請求結束,寫請求可以删除讀請求造成的緩存髒資料。
如果你用了mysql的讀寫分離架構怎麼辦?
ok,在這種情況下,造成資料不一緻的原因如下,還是兩個請求,一個請求A進行更新操作,另一個請求B進行查詢操作。
(1)請求A進行寫操作,删除緩存
(2)請求A将資料寫入資料庫了,
(3)請求B查詢緩存發現,緩存沒有值
(4)請求B去從庫查詢,這時,還沒有完成主從同步,是以查詢到的是舊值
(5)請求B将舊值寫入緩存
(6)資料庫完成主從同步,從庫變為新值
上述情形,就是資料不一緻的原因。還是使用雙删延時政策,隻是,睡眠時間修改為在主從同步的延時時間基礎上,加幾百ms。
采用這種同步淘汰政策,吞吐量降低怎麼辦?
ok,那就将第二次删除作為異步的。自己起一個線程,異步删除。這樣,寫的請求就不用沉睡一段時間後了,再傳回。這麼做,加大吞吐量。
第二次删除,如果删除失敗怎麼辦?
這是個非常好的問題,因為第二次删除失敗,就會出現如下情形。還是有兩個請求,一個請求A進行更新操作,另一個請求B進行查詢操作,為了友善,假設是單庫:
(1)請求A進行寫操作,删除緩存
(2)請求B查詢發現緩存不存在
(3)請求B去資料庫查詢得到舊值
(4)請求B将舊值寫入緩存
(5)請求A将新值寫入資料庫
(6)請求A試圖去删除請求B寫入對緩存值,結果失敗了。
ok,這也就是說。如果第二次删除緩存失敗,會再次出現緩存和資料庫不一緻的問題。
如何解決呢?
具體解決方案,且看第(3)種更新政策的解析。
3、先更新資料庫,再删緩存
首先,先說一下。老外提出了一個緩存更新套路,名為《Cache-Aside pattern》。其中就指出
跟新:應用程式先從cache取資料,沒有得到,則從資料庫中取資料,成功後,放到緩存中。
命中:應用程式從cache中取資料,取到後傳回。
失效:先把資料存到資料庫中,成功後,再讓緩存失效。
另外,知名社交網站facebook也在論文《Scaling Memcache at Facebook》中提出,他們用的也是先更新資料庫,再删緩存的政策。
這種情況不存在并發問題麼?
不是的。假設這會有兩個請求,一個請求A做查詢操作,一個請求B做更新操作,那麼會有如下情形産生
(1)緩存剛好失效
(2)請求A查詢資料庫,得一個舊值
(3)請求B将新值寫入資料庫
(4)請求B删除緩存
(5)請求A将查到的舊值寫入緩存
ok,如果發生上述情況,确實是會發生髒資料。
然而,發生這種情況的機率又有多少呢?
發生上述情況有一個先天性條件,就是步驟(3)的寫資料庫操作比步驟(2)的讀資料庫操作耗時更短,才有可能使得步驟(4)先于步驟(5)。可是,大家想想,資料庫的讀操作的速度遠快于寫操作的(不然做讀寫分離幹嘛,做讀寫分離的意義就是因為讀操作比較快,耗資源少),是以步驟(3)耗時比步驟(2)更短,這一情形很難出現。
假設,有人非要擡杠,有強迫症,一定要解決怎麼辦?
如何解決上述并發問題?
首先,給緩存設有效時間是一種方案。
其次,采用政策(2)裡給出的異步延時删除政策,保證讀請求完成以後,再進行删除操作。
還有其他造成不一緻的原因麼?
有的,這也是緩存更新政策(2)和緩存更新政策(3)都存在的一個問題,如果删緩存失敗了怎麼辦,那不是會有不一緻的情況出現麼。比如一個寫資料請求,然後寫入資料庫了,删緩存失敗了,這會就出現不一緻的情況了。這也是緩存更新政策(2)裡留下的最後一個疑問。
如何解決?
提供一個保障的重試機制即可,這裡給出兩套方案。
方案一:
流程如下所示
(1)更新資料庫資料;
(2)緩存因為種種問題删除失敗
(3)将需要删除的key發送至消息隊列
(4)自己消費消息,獲得需要删除的key
(5)繼續重試删除操作,直到成功
然而,該方案有一個缺點,對業務線代碼造成大量的侵入。于是有了方案二,在方案二中,啟動一個訂閱程式去訂閱資料庫的binlog,獲得需要操作的資料。在應用程式中,另起一段程式,獲得這個訂閱程式傳來的資訊,進行删除緩存操作。
方案二:
流程如下圖所示:
(1)更新資料庫資料
(2)資料庫會将操作資訊寫入binlog日志當中
(3)訂閱程式提取出所需要的資料以及key
(4)另起一段非業務代碼,獲得該資訊
(5)嘗試删除緩存操作,發現删除失敗
(6)将這些資訊發送至消息隊列
(7)重新從消息隊列中獲得該資料,重試操作。
備注說明:上述的訂閱binlog程式在mysql中有現成的中間件叫canal,可以完成訂閱binlog日志的功能。至于oracle中,部落客目前不知道有沒有現成中間件可以使用。另外,重試機制,部落客是采用的是消息隊列的方式。如果對一緻性要求不是很高,直接在程式中另起一個線程,每隔一段時間去重試即可,這些大家可以靈活自由發揮,隻是提供一個思路。
參考:
https://blog.csdn.net/hukaijun/article/details/81010475
https://my.oschina.net/jiagouzhan/blog/2990423