在前面的四篇文章中,咱們從數據持久化層來聊了一些架構設計方案,來處理數據量大讀寫緩慢的問題。可是架構設計並非只有這一方面的設計思路,本篇開始咱們來從緩存層面來一塊兒看看如何設計。redis
在一個電商系統中,存放了50000多條商品數據,每次用戶瀏覽商品詳情頁時,須要先從數據庫中讀取數據,再進行數據拼裝和計算,耗費的時間有時長達1秒。數據庫
這就致使每次點擊商品詳情頁時,頁面打開速度慢,此時該如何減小數據庫讀操做的壓力呢?緩存
在項目時間緊張,趕進度的時候,沒更多的精力關注此類問題。可是當系統流量起來以後,這種問題就不能不考慮了。服務器
此時採起的方案也比較通用,把全部的商品數據緩存起來就行。數據結構
關於緩存的問題,最簡單的實現方法是使用本地緩存。在Google Guava中有一個cache內存緩存模塊,它把全部商品的ID與商品詳細信息一對一緩存至JVM內存中,用戶獲取商品詳情數據時,系統會自動根據商品ID直接從緩存中讀取數據,大大提高了用戶頁面訪問速度。架構
不過,經過簡單換算後,咱們發現這個方法明顯不合理,先來舉個例子:併發
1條商品數據中,每每包含品牌、分類、參數、規格、服務、描述等字段,光存儲這些商品數據就得佔用500K左右內存,再將這些數據緩存到本地的話,差很少還須要佔用500K*50000=25G內存。此時,假設商品服務有30個服務器節點,光緩存商品數據就須要額外準備750G內存空間,這種方法顯然不可取。負載均衡
爲此,咱們想到了另一個解å決辦法——分佈式存儲,先將全部緩存數據集中存儲在同一個地方,而並不是保存到各個服務器節點中,而後全部的服務器節點從這個地方讀取數據。分佈式
那麼這個統一存儲緩存的地方須要使用什麼技術呢?這就涉及接下來咱們要聊的緩存中間件的技術選型問題。ide
咱們先將市面上比較流行的緩存中間件(Memcached、MongoDB、Redis)進行簡單對比,這樣你們就不用深刻進行選型調研了。
Memcached | MongoDB | Redis | |
---|---|---|---|
數據結構 | 簡單key-value | 很是全面,文檔型數據庫 | String、List、Set、Hash、Bitmap等 |
持久化 | 不支持 | 支持 | 支持 |
集羣 | 客戶端本身控制 | 支持 | 支持 |
性能 | 強 | 中等 | 強 |
據我瞭解,以上三種技術中,目前市面上通用的緩存中間件技術是Redis,使用MongoDB的公司最少,由於他只是一個數據庫,因爲他的讀寫速度與其餘數據庫相比較快,因此人們才把它當作相似緩存的存儲。
在這裏,咱們總結一下Redis之因此比memcached流行的三種緣由:
舉個例子,在使用 Memcached 保存 List 緩存對象的過程當中,若是咱們往 List 增長一條數據,首先須要讀取整個 List ,再反序列化塞入數據,接着再序列化存儲回 Memcached。而對於 Redis 而言,它僅僅是一個 Redis 請求,會直接幫咱們塞入數據並存儲,簡單快捷。
對於 Memcached 來講,一旦系統宕機數據就會丟失。經過 Memcached 的官方文檔得知,1.5.18 之後 Memcached 支持 restartable cache,其實現原理是重啓時 CLI 先發信號給守護進程,而後守護進程將內存持久化至一個文件中,系統重啓時再從那個文件恢復數據。不過,這個設計僅在正常重啓狀況下使用,意外狀況仍是不處理。
Memcached 的集羣設計很是簡單,客戶端根據 Hash 值直接判斷存取的 Memcached 節點。而 Redis 的集羣因在高可用、主從、冗餘、failover 等方面都有所考慮,因此集羣設計相對複雜些,屬於較常規的分佈式高可用架構。
所以,通過一番「慎重」的思考,咱們最終決定使用Redis做爲緩存的中間件。
技術選型完,咱們開始考慮緩存的一些具體問題,先從緩存什麼時候存儲數據入手。
使用緩存的邏輯是這樣的:
一、先嚐試從緩存中讀取數據;
二、緩存中沒有數據或者數據過時,再從數據庫中讀取數據保存到緩存中;
三、最終把緩存數據返回給調用方。
這種邏輯惟一麻煩的地方:當用戶發來大量併發請求,且全部請求同時擠在上面第2步,此時若是這些請求所有從數據庫讀取數據,會直接擠爆數據庫。
上面所說的擠爆能夠分爲三種狀況,咱們單獨展開說一下:
一、單一數據過時或者不存在,這種狀況稱爲緩存擊穿。
此時解決方案:第一個線程若是發現key不存在,先給key加鎖,再從數據庫讀取數據保存到緩存中,最後釋放鎖。若是其餘線程正在讀取同一個key值,它必須等到鎖釋放後才行。(關於鎖的問題在第一篇文章中已經聊過了,就再也不說了)
二、數據大面積過時或者Redis宕機,這種狀況稱之爲緩存雪崩。
此時,咱們設置緩存緩存過時時間隨機分佈或永不過時便可。
三、一個惡意請求獲取的key不在數據庫中,這種狀況稱之爲緩存穿透。
這種狀況若是不作處理,惡意請求每次都會查詢數據庫,無疑給數據庫增長了壓力。
這裏分享2種解決辦法:①在業務邏輯上直接校驗,在數據庫不被訪問的前提下過濾掉不存在的key;②將惡意請求的key存放一個空值在緩存中,防止惡意請求騷擾數據庫。
最後,說明一下關於緩存預熱:在深夜無人或訪問量小的時候,咱們能夠考慮將預熱的熱數據保存到緩存中,這樣流量大的時候,用戶查詢無須再從數據庫讀取數據,大大減小了數據讀壓力。
關於緩存什麼時候存數據的問題咱們就討論完了,接下來開始討論更新緩存的問題,這部份內容涉及雙寫(緩存+數據庫)。
更新緩存的步驟特別簡單,總共就兩步:更新數據庫和更新緩存。但就這麼簡單的兩步,咱們須要考慮好幾個問題。
一、先更新數據庫仍是先更新緩存?更新緩存時先刪除仍是直接更新?
二、假設第一步成功了,第二步失敗了怎麼辦?
三、假設2個線程同時更新一個數據,A線程先完成第一步,B線程先完成第二步,此時該怎麼辦?
其中,第一個問題就存在4種組合問題,咱們先針對第 1 種組合問題給出對應的解決方案。(以上幾個問題由於緊密關聯,無法單獨考慮,下面咱們就一塊兒說明。)
對於這個組合,會遇到這種狀況:假設第 2 步數據庫更新失敗了,要求回滾緩存的更新,這時該怎麼辦呢?咱們知道 Redis 不支持事務回滾,除非咱們採用手工回滾的方式,先保存原有數據,而後再將緩存更新回原來的數據,這種解決方案就有點尷尬了。
這裏簡單舉個例子,好比:
一、原來緩存中的值是 a,兩個線程同時更新庫存;
二、線程 A 將緩存中的值更新成 b,且保存了原來的值 a,而後更新數據庫;
三、線程 B 將緩存中的值更新成 c,且保存了原來的值 b,而後更新數據庫;
四、線程 A 更新數據庫時失敗了,它必須回滾了,那如今緩存中的值更新回什麼呢?
要不這樣吧,咱們在A線程更新緩存與數據庫整個過程當中,先把緩存及數據庫都鎖上,確保別人不能更新,這樣的方法可不可行呢?固然是可行的,可是別人能不能讀呢?
假設A更新數據庫失敗回滾緩存時,線程C也來參一腿,它須要先讀取緩存中的值,這時又返回什麼值呢?
看到這個場景,你是否是有點印象了?不錯,這就是典型的事務隔離級別場景。咱們只是使用一下緩存而已,你讓我本身實現事務隔離級別,這個要求會不會有點高?咱們仍是考慮別的吧。
使用這種方案,就算咱們更新數據庫失敗了也不須要回滾緩存。這種作法雖然巧妙規避了失敗回滾的問題,卻引來了兩個更大的問題。
一、假設A線程先刪除緩存,再更新數據庫。在A線程完成更新數據庫庫以前,後執行的B線程反而超前完成了操做,讀取key發現沒數據後,將數據庫中的舊值放到了緩存中。A線程在B線程都完成後再更新數據庫,這樣就會出現緩存(舊值)與數據庫的值(新值)不一致的問題。
二、爲了解決一致性的問題,咱們可讓A線程給key加鎖,由於寫操做特別耗時,這種處理方法會致使大量的讀請求卡在鎖中。
以上描述的典型的高可用和一致性難以兩全的問題,要再加上分區容錯就是CAP了,這裏咱們就不展開討論了。
對於組合三,咱們一樣須要考慮兩個問題。
一、假設第一步成功,第二步失敗了怎麼辦?由於緩存不是主流程,數據庫纔是,因此咱們不會由於更新緩存失敗而回滾第一步對數據庫的更新。此時,咱們通常採用的作法是作重試機制,但重試機制若是存在延時仍是會出現數據庫與緩存不一致的狀況,很是很差處理啊。
二、假設2個線程同時更新同一個數據,A線程先完成了第一步,B線程先完成了第二步怎麼辦?
假設2個線程同時更新同一個數據,A線程先完成了第一步,B線程先完成了第二步怎麼辦?咱們接着來推演整個過程:A線程把值更新a,B線程把值更新成b,此時數據庫中的最新值是b,由於A線程先完成了第一步,後完成第二步,因此緩存中的最新值是a,數據庫與緩存的值仍是不一致,仍是很差處理啊。
所以,咱們不建議採用以上這個方案。
針對組合四,咱們看看到底會存在哪些問題。
一、假設第一步成功了,第二步失敗了怎麼辦?這種狀況的出現機率與上個組合相比明顯少很多,由於刪除比更新容易多了。此時雖然它不完美,但出現一致性的問題機率少。
二、假設2個線程同時更新同一個數據,A線程先完成第一步,B線程先完成第二步怎麼辦?
這裏咱們接着推演整個過程:A線程把值更新成a,B線程把值更新成b,此時數據庫中的最新值是b,由於A線程先完成了第一步,至於第二步誰先完成已經無所謂了,反正是直接刪除緩存數據。
看到這裏,咱們發現組合四完美解決了以上難題,因此建議更新緩存時,先更新數據庫再刪除緩存。
不過,這個解決方案也會引起另外3個問題。
前面咱們花了大篇幅討論更新緩存的邏輯,接下來咱們來討論緩存的高可用設計。
關於緩存高可用設計問題,在設計高可用方案時,咱們須要考慮5個要點:
一、負載均衡:是否能夠經過加節點的方式水平分擔讀請求壓力。
二、分片:是否能夠經過劃分到不一樣的節點的方式水平分擔寫壓力。
三、數據冗餘:一個節點的數據若是掛掉了,其餘節點是否能夠直接備份掛掉節點的職責。
四、Fail-over:任何節點掛掉後,集羣的職責是否能夠從新分配,以此保障集羣正常工做。
五、一致性保證:在數據冗餘、failover、分片機制的數據轉移過程當中,若是某個地方出幺蛾子,可否保證全部的節點數據或節點與數據庫之間數據的一致性。(依靠redis自己是不行的)
若是對緩存高可用有需求咱們能夠用使用Redis的cluster模式,關於前面提到的點它都有涉及。至於cluster怎麼配置,能夠參考Redis官方文檔或網上教程,這裏就不展開了。
緩存上線後,咱們還須要定時查看緩存的使用狀況,再判斷業務邏輯是否須要優化,也是就是所謂的緩存的監控。
在查看緩存使用狀況時,通常咱們會監控緩存命中率、內存使用率、慢日誌、延遲、客戶端鏈接數等數據。固然,隨着問題的深刻咱們還須要增長其餘指標,這裏就不詳細說了。
至於最終使用哪一種監控工具,須要根據實際狀況而定。這裏推薦幾款開源監控工具,好比RedisLive、Redis-monitor等。
以上方案能夠順利解決讀數據請求壓垮數據庫的問題,目前互聯網架構也基本是採起這裏方案。可是這個方案還存在一個不足,沒法解決寫數據請求量大的問題,也就是說寫請求多時,數據庫仍是會扛不住。針對這個問題,後面的文章中咱們接着討論。