高性能Reactor模式概述與實踐

高性能的那些事兒-緩存設計

概述

在設計與開發高性能的系統時,基本都離不開緩存的設計,無論是在cpu的L1,L2,L2緩存,數據庫的sql語句執行緩存,系統應用的本地緩存,乃至於現在用的最多的memcache,redis集中式緩存等,緩存總是解決性能的一把利器,對於緩存,本文主要從六個方面總結一下緩存及緩存的使用:

  1. 爲什麼需要緩存,緩存帶來了什麼
  2. 緩存的類型及常用緩存選型
  3. 緩存的基本算法思想
  4. 緩存帶來的問題及解決
  5. 緩存組件的設計

爲什麼需要緩存,緩存帶來了什麼

在我實際工作中,主要在兩種情況下一定需要引入緩存:

  1. 系統涉及到頻繁和大量的讀操作,例如像評論留言,商品頁面這種主要需要大量讀的功能模塊,如果一直讀數據庫,數據庫支撐不住。
  2. 需要引入一個分佈式集中式存儲,主要是像保存客戶端會話信息,權限統一控制等這種功能模塊,這裏我遇到的實際情況是該系統沒有數據庫交互的模塊,數據是本地存儲的,但是會話信息需要在集羣統一管理,所以利用redis作爲統一的集中式緩存。

引入緩存,可以有效的幫助我們提高系統的性能,甚至可以很方便的實現一些功能,例如會話統一管理。引入緩存對於系統帶來了什麼呢?

  1. 緩存可以幫助系統提高用戶體驗,緩存解決的最大的問題就是將讀壓力從數據庫分離,同時,無論是嵌入式緩存還是redis這種集中式kv緩存,讀的性能都比關係數據庫高很多。即使數據庫支撐的住,在引入緩存後還是可以提高很多的用戶訪問響應時間。
  2. 解決數據庫大量讀操作帶來的性能問題,數據庫在修改和刪除時纔會對數據加鎖,但是如果這種數據是大量需要讀的數據,可能很多讀操作都會被修改操作阻塞,導致數據庫被hang住,從而帶來大量的性能問題,引入緩存後,系統就可以先從緩存中獲取數據,再訪問數據庫。減輕數據庫讀的壓力。
  3. 通過redis解決一些功能實現的問題,例如有一些分佈式系統並沒有數據庫,例如mqtt這種broker,涉及到集羣數據共享時,可以用redis解決,還有的系統有分佈式鎖,得分排序等功能都可以用redis解決。

目前的系統一旦有一定規模,往往都離不開緩存,引入緩存除了帶來一些優點外,也給系統帶來了一定的複雜性:

  1. 數據一致性的問題,引入緩存後,數據需要保存兩份,一份是數據庫,一份是緩存,那麼在更新數據時,是先更新緩存還是先更新數據庫呢?先更新數據庫,可能導致讀取最新數據,但是更新數據庫失敗了,需要回滾,該數據就是髒數據,如果先更新數據庫再更新緩存,那麼讀取的數據又是舊數據,也是髒數據了。
  2. 可維護性,引入緩存後,除了需要維護自己的系統外,還需要維護緩存
  3. 系統可用性,如果有的系統嚴重依賴緩存,例如將緩存當中央存儲,那麼緩存掛掉了,整個系統也就不可用了。導致系統的可用性降低
  4. 緩存帶來的其他問題,例如本地緩存需要考慮內存大小,自己設計過期策略等等,集中式緩存需要考慮,緩存雪崩,緩存穿透,數據熱點分佈等一系列問題。

緩存的類型及常用緩存選型

這裏僅列舉幾個我在實際中接觸或使用過的緩存:

嵌入式(本地)緩存

特點是直接與應用一起啓動,在java中直接引入java包就可以使用。

  1. mapdb:mapdb是java中使用過較多的嵌入式kv數據庫,也可以當緩存來用,支持hash,map,set結構,在3.0已經不支持list和queue了,也支持數據落盤等,使用比較簡單
  2. h2:java的嵌入式關係數據庫,可以當緩存來用。
  3. encache:應用最多的本地緩存。也可以實現分佈式緩存,不過使用比較重。
  4. hazelcast:分佈式應用緩存,使用不好會帶來嚴重的性能問題。
  5. 自實現本地緩存:這種緩存適合數據量小,同時不需要數據分佈式緩存的情況,實現簡單。

嵌入式緩存一般用作二級緩存或者目前系統還不需要引入外部緩存的情況,在業務規模較小時,本地緩存一般都可以解決問題了

集中式緩存

例如memcache和redis,在實際應用中我只使用過redis,這裏我畫了一個表格總結了下兩者的區別:

數據結構 內存管理 集羣 線程模型 內存使用率
Redis Hash,string,list Set,queue 基於內存,可以使用aof和rdb實現數據持久化 支持客戶端做數據分片,3.0後支持redis-cluster集羣,支持master-slave模式保證數據的可靠性 單線程,多核機器上單服務性能比memcache低。 多數據結構,hash結構使用率較高
Memcache 簡單的key-value結構 只能基於內存 只支持客戶端做數據分片 多線程,支持cas保證數據同步 只有key-value存儲,Memcache使用率較高

在選型時可以得到下面一些觀點:

1.Redis支持更多的數據庫結構,如果需要多種數據結構,redis更適合

2.Redis支持數據的備份,一定程度保證數據的高可靠

3.Redis支持數據的持久化,意味做集中式存儲時,我們可以單獨存儲將歷史數據拷貝出來單獨保存。

4.Redis支持主從複製,很容易實現故障恢復

5.Redis的redis-cluster機制,可以幫助使用者不用在客戶端做數據分片了。

緩存的基本算法思想

緩存一般都是基於內存的,而內存往往是很珍貴的資源,比較有限,對於各種各樣的緩存,其內部的數據結構實現都比較複雜而且多種多樣,這裏僅總結一下常用緩存的過期算法,也是我們在自實現緩存時最需要關注的:

  1. FIFO(First In First Out)算法:先進先出隊列算法,適合只關注最新數據的緩存,實現很簡單,例如java中線程池中的阻塞隊列,線程+0池之所以能緩存執行任務,就是將執行任務放入了一個阻塞隊列,每次從該隊列中獲取頭結點的任務進行處理,該隊列是有限的,如果任務超過隊列大小,那麼有以下幾個處理方法:1.創建新的線程(如果最大線程大於核心線程),2.立即執行該任務,放棄在執行的任務,3.拋棄該任務,並拋出異常。
  2. LFU(Least Frequently Used)算法:最不常用算法,其基本思想是:“如果數據過去被訪問了多次,那麼將來被訪問的頻率越高”,該算法適合關心數據訪問頻次的,而這些數據在緩存中是不能被優先淘汰的。不難理解,該算法的緩存命中率較高。在實現時,需要維護一個隊列專門記錄所有數據的訪問記錄,每個數據需要維護引用計數,實現相對比較複雜,由於要維護一個計數隊列,所以內存消耗較高,需要基於引用計數進行排序,性能消耗較高。
  3. LRU(Least Recently Used)算法:最近最少使用算法,其基本思想是:“如果數據最近被訪問過,那麼將來被訪問的機率更高”,例如cpu的L1,L2緩存等都是該緩存算法思想,redis中的緩存過期也是該算法思想。大多數緩存也是採用該思想,相對於LFU,其實現難度比較低,緩存命中率低於LFU算法。其基本實現思想是:1.新數據插入到鏈表頭部;2.當緩存命中時,將數據移到頭部節點;3.當鏈表滿時,將鏈表尾部的數據丟棄。

上面總結了三種最基本的緩存淘汰算法和基本實現思想,對於緩存中爸爸級的Redis來說,其內部的緩存淘汰策略是如何實現的呢,在那麼大的數據量下,每次設置了expire time又是如何起作用的呢?在redis中的緩存淘汰策略基於LRU主要有兩種思想:

  1. 主動淘汰:Redis會週期性的從設置了過期時間的緩存數據裏獲取一部分數據,判斷這些數據是否過期,如果過期則直接淘汰,如果過期的數據在本次週期所有數據裏超過一定比例,則立即再執行一次該方法,否則下次再進行掃描。當緩存已用內存超過maxmemory限定時,會觸發主動清除策略。
  2. 被動淘汰:當該key被訪問時,判斷是否已經失效,如果失效就刪除它。

LRU的算法如果所有數據維護訪問鏈表,並且每次數據被訪問都去更新的話,代價還是比較大的,藉助redis的緩存過期思想可以解決這個問題。

緩存帶來的問題及解決

本地緩存帶來的數據不一致問題

本地緩存帶來的一般問題就是數據不一致,如果使用encache,hazelcast等還支持分佈式緩存同步,但是對於自定義緩存,mapdb等這種這樣的本地緩存簡直有點無解,例如我在實際工作中遇到過一個需求:目前我們的系統需要做權限訪問控制,精確到URL,權限數據是需要進行緩存的,但是目前我們系統還沒有引入Redis這種集中式緩存,也不想引入encache,hazelcast這種緩存,帶來了更大的複雜性,但是仔細分析,權限變更非常少,對於數據實時性要求很低,所以在10分鐘內保證數據一致性一般就夠了,所以對於這類數據,其實不太需要分佈式緩存。同理,對於不經常變更,或者變更對於數據的一致性的實時性要求不高,那麼使用本地緩存完全足夠。

緩存數據的一致性問題

引入緩存後,主要是解決讀的性能問題,但是數據總是要更新的,是先更新緩存還是先更新數據庫呢?

  1. 先更新緩存再更新數據庫:更新緩存後,後續的讀操作都會先從緩存獲取從而獲取的是最新的數據,但是如果第二步更新數據庫失敗,那麼數據需要回滾,導致先前獲取的數據是髒數據來帶不可逆的業務影響,所以一般這種方法都是不可取的
  2. 先更新數據庫後更新緩存:先更新數據庫,但是緩存沒有更新,再將數據從數據庫同步到緩存這一過程中,所有的讀操作讀的都是舊數據,會帶來一定問題,但是問題較小。推薦使用
  3. 先刪除緩存,後更新數據庫再同步緩存:每次需要更新緩存時,不更新緩存的數據,而是先刪除緩存,再更新數據庫中的數據,數據庫寫成功後再更新緩存,這樣讀緩存時讀不到數據會從數據庫讀,數據是最新的,帶來的問題就是數據庫壓力在這一過程中會比較大。
緩存雪崩

在使用集中式緩存時,需要考慮緩存雪崩,緩存穿透,數據熱點這些問題。緩存雪崩是指緩存失效(過期)後導致所有讀操作都打到數據庫,導致數據庫壓力瞬間增大,系統性能急劇下降甚至拖垮系統,主要解決方法:

  1. 所有數據的過期時間不要設置成一樣,防止出現數據批量失效,導致緩存雪崩的情況

  2. 採用互斥鎖的方式:這裏需要使用到分佈式鎖,在緩存失效後,如果訪問同一數據的操作需要訪問數據並去更新緩存時,對這些操作都加鎖,保證只有一個線程去訪問數據並更新緩存,後續所有操作還是從緩存中獲取數據,如果一定時間沒有獲取到就返回默認值或返回空值。這樣可以防止數據庫壓力增大,但是用戶體驗會降低。

  3. 後臺更新:業務操作需要訪問緩存沒有獲取到數據時,不訪問數據庫更新緩存,只返回默認值。通過後臺線程去更新緩存,這裏有兩種更新方式:

    • 啓動定時任務定時掃描所有緩存,如果不存在就更新,該方法導致掃描key間隔時間過長,數據更新不實時,期間業務操作一直會返回默認值,用戶體驗比較差
    • 業務線程發現緩存失效後通過消息隊列去更新緩存,這裏因爲是分佈式的所以可能有很多條消息,需要考慮消息的冪等性。這種方式依賴消息隊列,但是緩存更新及時,用戶體驗比較好,缺點是系統複雜度增高了。

    後臺更新的方式一般結合這兩種更新方式,結合消息隊列可以保證更新及時,提高用戶體驗,定時掃描可以進行緩存預熱。在業務上線時就緩存好了數據。

緩存穿透

緩存穿透是指:業務操作訪問緩存時,沒有訪問到數據,又去訪問數據庫,但是從數據庫也沒有查詢到數據,也不寫入緩存,從而導致這些操作每次都需要訪問數據庫,造成緩存穿透。

解決辦法一般有兩種:

  1. 將每次從數據庫獲取的數據,即使是空值也先寫入緩存,但是過期時間設置的比較短,例如3秒,後續的訪問都直接從緩存中獲取空值返回即可。
  2. 對所有有結果的查詢參數進行hash算法,利用Bloom filter算法將有查詢結果的存儲下來,一個一定不存在的數據首先會在這個Bloom filter中過濾掉,從而防止後續訪問底層存儲的操作。
緩存熱點

緩存的性能很高,但是對於緩存的數據可能只有那麼百分之20是經常需要被訪問的,不得不說28原理哪裏都存在呀,比如微博,很多明星的一條微博會被成千上萬的人訪問,而對於我這樣的小屁民發了條微博可能只有我一個人看~。緩存熱點是指:對於緩存的數據,如果訪問某個熱點key的讀操作很多,會導致這臺緩存服務器壓力十分大,從而出現性能問題。

解決方法:爲熱點數據緩存多個副本,例如某個明星的微博,如果以10萬訪問爲單位,那麼某個有1000萬粉絲的明星發的微博,就存儲100份,緩存的數據都是一樣的,緩存的key按編號區分,所有的讀操作隨機讀取其中的一份緩存,這樣就可以防止所有的讀操作都落到一臺緩存服務器上,同時需要注意:這樣的緩存的也需要設置不同的緩存時間,防止緩存同時失效,引發緩存雪崩。

緩存組件的設計

上面總結了大部分緩存需要注意的問題,對於設計緩存,一般主要從以下幾個角度設計和考慮:

  1. 什麼樣的數據應該緩存

  2. 什麼時候應該觸發緩存,怎樣觸發,什麼時候去更新緩存,怎樣更新

  3. 緩存的層次和粒度

  4. 緩存的命名規則和淘汰規則

  5. 緩存的監控以及故障應對方案

  6. 緩存數據的可視化以及緩存的key內存設計和大小等

https://github.com/Cicizz/Recode

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章