幾年前,我在看博客的時候,看到有一篇博客的標題就是關於數據庫,緩存一致性的,不以為然,直接跳過去了,心想,這么簡單的問題還討論個鬼啊。這種想法持續了很久,直到某天,我看到越來越多的人都在討論數據庫,緩存一致性的問題,才好好的看了下博客,才發現原來數據庫,緩存一致性真不是一個簡單的問題。今天我也來談談數據庫,緩存一致性問題。
科普
考慮到有一些小伙伴可能技術不是那么好,可能沒有接觸過緩存,所以這里還是花上一分鍾的時間,來介紹下什么是緩存,為什么要有緩存,以及數據庫和緩存是如何搭配使用的。
讀取數據庫是比較耗時的操作,如果每次都需要去數據庫讀取數據,會對數據庫造成一定的壓力,程序性能也會比較低下,所以需要引入緩存。
緩存是提升程序性能的最重要、最有效、也是最簡單的手段之一。
引入緩存后,讀操作會先去緩存中看下,如果沒有命中緩存,才去讀取數據庫,然后把讀取出來的數據再放到緩存中去,這樣下一次讀操作就可以命中緩存了,如果命中緩存,就可以直接把數據返回出去了。
寫操作,除了修改數據庫,還需要刪除緩存,因為不刪除緩存,讀的操作讀到的永遠都是緩存中的舊數據。
先刪除緩存,后修改數據庫
這個方案顯然是有問題的。
兩個並發的讀寫操作:
- 一個寫的操作先進來,把緩存刪除了;
- 在寫操作還沒有更新數據庫的時候,一個讀的請求又進來了,發現沒有命中緩存,就去數據庫把老數據取出來了;
- 寫操作更新了數據庫;
- 讀操作把老數據放在了緩存中。
這樣,數據庫中的數據和緩存中的數據就不一致了,為了更好的讓大家理解這個過程,獻上一張丑到無法自拔的圖:
這個方案顯然不行,但是這個方案真的一無是處嗎?
非也,讓我們設想下這樣的場景:一個寫的請求進來,刪除緩存,這個時候,Redis服務器突然出問題了,或者網絡突然出問題了,導致刪除緩存失敗,拋出了一個異常,導致程序沒有繼續執行修改數據庫的操作。從數據庫、緩存一致性的角度來說,這里很好的保證了數據庫、緩存的一致性,兩者保存的數據是一樣的,盡管保存的都是老數據。
先修改數據庫,后刪除緩存
相信絕大多數小伙伴都是運用的這個方案, 先前我覺得數據庫,緩存一致性沒有什么好討論的,太簡單了,就是因為我覺得這個方案是如此完美,但是后面我才慢慢發現這個方案也有一定的問題。
看到第一種方案存在的問題,大家也一定想到了這個方案也有同樣的問題。
在沒有緩存的情況下,兩個並發的讀寫操作:
- 讀操作先進來,發現沒有緩存,去數據庫中讀數據,這個時候因為某種原因卡了,沒有及時把數據放入緩存;
- 寫的操作進來了,修改了數據庫,刪除了緩存;
- 讀操作恢復,把老數據寫進了緩存。
這樣就造成了數據庫、緩存不一致,不過,這個概率出現的非常低,因為這需要在沒有緩存的情況下,有讀寫的並發操作,在一般情況下,寫數據庫的操作要比讀數據庫操作慢得多,在這種情況下,還要保證讀操作寫緩存晚於寫操作刪除緩存才會出現這個問題,所以這個問題應該可以忽略不計。
說了這么多,並沒有看到先修改數據庫,后刪除緩存的致命問題啊,別急,讓我們繼續設想這樣的場景:一個寫的操作進來,修改了數據庫,但是刪除緩存的時候 ,由於Redis服務器出現問題了,或者網絡出現問題了,導致刪除緩存失敗,這樣數據庫保存的是新數據,但是緩存里面的數據還是老數據,妥妥的數據庫、緩存不一致啊。
延遲雙刪
可以看到修改數據庫,后刪除緩存有兩個問題,雖然兩個問題都是低概率的,但是永遠追求完美的程序員可不能允許有這樣的事情發生,所以第三種方案出現了:延遲雙刪。
延遲雙刪就是先刪除緩存,后修改數據庫,最后延遲一定時間,再次刪除緩存。
這么做就可以在一定程度上緩解上述兩個問題,第一次刪除緩存相當於檢測下緩存服務是否可用,網絡是否有問題,第二次延遲一定時間,再次刪除緩存,是因為要保證讀的請求在寫的請求之前完成。
但是這么做,還是有一定問題,比如第一次刪除緩存是成功的,第二次刪除緩存才失敗,又該怎么辦?
內存隊列
上面三種方式,都有一定的問題:
- 修改數據庫、刪除緩存這兩個操作耦合在了一起,沒有很好的做到單一職責;
- 如果寫操作比較頻繁,可能會對Redis造成一定的壓力;
- 如果刪除緩存失敗,該怎么辦?
為了解決上面三個問題,第四種方式出現了:內存隊列刪除緩存:寫操作只是修改數據庫,然后把數據的Id放在內存隊列里面,后台會有一個線程消費內存隊列里面的數據,刪除緩存,如果緩存刪除失敗,可以重試多次。
這樣,就把修改數據庫和刪除緩存兩個操作解耦了,如果刪除緩存失敗,也可以多次嘗試。由於后台有一個線程去消費內存隊列去刪除緩存,不是直接刪除緩存,所以修改數據庫和刪除緩存之間產生了一定的延遲,這延遲應該可以保證讀操作已經執行完畢了。
但是這么做也有不好的地方:
- 程序復雜度成倍上升,需要維護線程、隊列以及消費者;
- 如果寫操作非常頻繁,隊列的數據比較多,可能消費會比較慢,修改數據庫后,間隔了一定的時間,緩存才被刪除。
但是這也是沒有辦法的事情,哪有十全十美的解決方案。
第三方隊列
一般來說,系統分為前台系統和后台系統,前台系統主要是讀操作,后台系統才有寫操作。
比如商品中心,前台是面向用戶的,當用戶打開商品詳情頁,會去緩存中拿數據,后台是面向業務人員的,業務人員可以在后台系統對商品信息進行修改。
如果是具有一定規模的公司,前台系統和后台系統肯定不在同一個服務器上,而且是由不同的部門去負責的,所以內存隊列是肯定用不了的,如果后台系統修改數據庫后,直接刪除緩存,一定會發生如下的故事。
后台系統 小明:你們前台系統的產品詳情緩存的key是什么格式的?發我下。
前台系統 小花:Product:XXXXX。
后台系統 小明:好的。
過了幾天,小花找到小明。
前台系統 小花:不對啊。你們怎么沒有把活動中的產品詳情緩存給刪掉啊?
后台系統 小明:納尼,我怎么知道你們是兩個緩存啊,把活動中的產品詳情緩存的key的格式發我下。
前台系統 小花:Activity:Product:XXXX。
后台系統 小明:好的。
過了幾天,訂單系統的開發又找到小明。
訂單系統 小強:你們修改了產品詳情后,還要把訂單中的產品詳情緩存給刪除。
后台系統 小明:。。。
過了幾天,廣告系統的開發又找到小明。
廣告系統 小王:你們修改了產品詳情后,還要把廣告中的產品詳情緩存給刪除。
后台系統 小明 卒,享年25。
如果引用了第三方隊列,如RabbitMQ,Kafka,小明就不會“卒”了,后台系統的小明修改了數據庫后,不需要關心緩存的事情,只要把數據的Id丟到消息隊列,前台系統、廣告系統、訂單系統的開發消費消息隊列中的數據刪除緩存。
上面說的幾種方案,都是比較常見的,也比較簡單,當然不同的方案也可以搭配使用,但是沒有“銀彈”,沒有完美的解決方案,就看你們的研發團隊,你們的場景適合哪種解決方案了。
今天的話題到這里就結束了。