前言
上一篇我們從redis的線程模型分析了redis為什么使用單線程,以及從單線程性能依舊很出色分析了基于I/O多路復(fù)用的反應(yīng)堆模式請求處理流程。本篇將此內(nèi)存結(jié)構(gòu)出發(fā)來分析redisDB的數(shù)據(jù)結(jié)構(gòu)以及內(nèi)存管理機(jī)制。
redis以內(nèi)存作為存儲資源也是它高性能的一個(gè)核心原因,接下來我們就來分析下redis是如何管理內(nèi)存資源的。
一:Redisdb數(shù)據(jù)結(jié)構(gòu)
Redis源碼中對redisdb的定義如上圖,其中dict *dict成員就是將key和具體的對象(可能是string、list、set、zset、hash中任意類型之一)關(guān)聯(lián)起來,存儲著該數(shù)據(jù)庫中所有的鍵值對數(shù)據(jù)。expires成員用來存放key的過期時(shí)間。下面通過一張結(jié)構(gòu)圖來介紹這兩個(gè)最核心的元素:
Dict中保存著redis的具體鍵值對數(shù)據(jù),expires來保存數(shù)據(jù)過期的時(shí)間。過期字典的鍵指向 Redis 數(shù)據(jù)庫中的某個(gè) key,過期字典的值是一個(gè)long 類型的整數(shù),這個(gè)整數(shù)保存了 key 所指向的數(shù)據(jù)庫鍵的過期時(shí)間(毫秒精度的 UNIX 時(shí)間戳)。
二:內(nèi)存回收
redis占用內(nèi)存分為鍵值對所耗內(nèi)存和本身運(yùn)行所耗內(nèi)存兩部分,可回收的部分只能是鍵值對所使用的內(nèi)存。鍵值對內(nèi)存的回收分以帶過期的、不帶過期的、熱點(diǎn)數(shù)據(jù)、冷數(shù)據(jù)。Redis 回收內(nèi)存大致有兩個(gè)機(jī)制:一是刪除到達(dá)過期時(shí)間的鍵值對象;二是當(dāng)內(nèi)存達(dá)到 maxmemory 時(shí)觸發(fā)內(nèi)存移除控制策略,強(qiáng)制刪除選擇出來的鍵值對象。
2.1 過期刪除
上面對redisdb結(jié)構(gòu)的分析可以知道redis通過維護(hù)一個(gè)過期字典expires來記錄key的過期時(shí)間,也知道是記錄的過期毫秒值,那么應(yīng)該怎樣來實(shí)施刪除操作了?
很容易我們能夠想到以下三種模式:
定時(shí)刪除:在設(shè)置鍵的過期時(shí)間的同時(shí),創(chuàng)建定時(shí)器,讓定時(shí)器在鍵過期時(shí)間到來時(shí),即刻執(zhí)行鍵值對的刪除。
定期刪除:每隔特定的時(shí)間對數(shù)據(jù)庫進(jìn)行一次掃描,檢測并刪除其中的過期鍵值對。
惰性刪除:鍵值對過期暫時(shí)不進(jìn)行刪除,當(dāng)獲取鍵時(shí)先查看其是否過期,過期就刪除,否則就保留。
當(dāng) Redis保存大量的鍵,對每個(gè)鍵都進(jìn)行精準(zhǔn)的過期刪除會導(dǎo)致消耗大量的 CPU,惰性刪除會造成內(nèi)存空間的浪費(fèi),因此 Redis 采用惰性刪除和定時(shí)任務(wù)刪除相結(jié)合機(jī)制實(shí)現(xiàn)過期鍵的內(nèi)存回收。
2.2 定期刪除
在redis配置文件間中有hz和maxmemory-samples這樣兩個(gè)參數(shù),hz默認(rèn)為10,表示1s執(zhí)行10次定期刪除,maxmemory-samples配置了每次隨機(jī)抽樣的樣本數(shù),默認(rèn)為5。下面用流程圖來說明定期刪除的執(zhí)行邏輯。
定時(shí)任務(wù)首先根據(jù)快慢模式( 慢模型掃描的鍵的數(shù)量以及可以執(zhí)行時(shí)間都比快模式要多 )和相關(guān)閾值配置計(jì)算計(jì)算本周期最大執(zhí)行時(shí)間、要檢查的數(shù)據(jù)庫數(shù)量以及每個(gè)數(shù)據(jù)庫掃描的鍵數(shù)量。
從上次定時(shí)任務(wù)未掃描的數(shù)據(jù)庫開始,依次遍歷各個(gè)數(shù)據(jù)庫。
從數(shù)據(jù)庫中隨機(jī)選取鍵,如果發(fā)現(xiàn)是過期鍵,則調(diào)用函數(shù)刪除它。
如果執(zhí)行時(shí)間超過了設(shè)定的最大執(zhí)行時(shí)間,則退出,并設(shè)置下一次使用快周期模式執(zhí)行。
未超時(shí)的話,則判斷是否采樣的鍵中是否有25%的鍵是過期的,如果是則繼續(xù)掃描當(dāng)前數(shù)據(jù)庫否則開始掃描下一個(gè)數(shù)據(jù)庫。
如果Redis里面有大量key都設(shè)置了過期時(shí)間,全部都去檢測一遍的話CPU負(fù)載就會很高,會浪費(fèi)大量的時(shí)間在檢測上面,所以只會抽取一部分而不會全部檢查。正因?yàn)槎ㄆ趧h除只是隨機(jī)抽取部分key來檢測,這樣的話就會出現(xiàn)大量已經(jīng)過期的key并沒有被刪除,這就是為什么有時(shí)候大量的key明明已經(jīng)過了失效時(shí)間,但是redis的內(nèi)存還是被大量占用的原因 ,為了解決這個(gè)問題,Redis又引入了“惰性刪除策略”。
2.3 惰性刪除
前面介紹到惰性刪除是在操作一個(gè)key的時(shí)候再去判斷是否過期,過期則進(jìn)行刪除。在操作key之前都會調(diào)用expireIfNeeded方法去檢測是否過期并處理回收邏輯。
惰性刪除邏輯如下圖:
除了定期刪除和惰性刪除這些主動(dòng)操作key的場景,其他場景下針對過期key會如何操作了?
1、快照生成RDB文件時(shí)
過期的key不會被保存在RDB文件中。
2、服務(wù)重啟載入RDB文件時(shí)
Master載入RDB時(shí),文件中的未過期的鍵會被正常載入,過期鍵則會被忽略。Slave 載入RDB 時(shí),文件中的所有鍵都會被載入,當(dāng)主從同步時(shí),再和Master保持一致。
3、AOF 文件寫入時(shí)
因?yàn)锳OF保存的是執(zhí)行過的Redis命令,所以如果redis還沒有執(zhí)行del,AOF文件中也不會保存del操作,當(dāng)過期key被刪除時(shí),DEL 命令也會被同步到 AOF 文件中去。
4、重寫AOF文件時(shí)執(zhí)行
BGREWRITEAOF 時(shí) ,過期的key不會被記錄到 AOF 文件中。
5、主從同步時(shí)
Master 刪除 過期 Key 之后,會向所有 Slave 服務(wù)器發(fā)送一個(gè) DEL命令,Slave 收到通知之后,會刪除這些 Key。Slave 在讀取過期鍵時(shí),不會做判斷刪除操作,而是繼續(xù)返回該鍵對應(yīng)的值,只有當(dāng)Master 發(fā)送 DEL 通知,Slave才會刪除過期鍵,這是統(tǒng)一中心化的鍵刪除策略,保證主從服務(wù)器的數(shù)據(jù)一致性。
三:內(nèi)存淘汰
可以為了保證Redis的安全穩(wěn)定運(yùn)行,設(shè)置了一個(gè)max-memory的閾值,那么當(dāng)內(nèi)存用量到達(dá)閾值,新寫入的鍵值對無法寫入,此時(shí)就需要內(nèi)存淘汰機(jī)制,在Redis的配置中有幾種淘汰策略可以選擇:
noeviction:當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時(shí),新寫入操作會報(bào)錯(cuò);
allkeys-lru:當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時(shí),在鍵空間中移除最近最少使用的 key;
allkeys-random:當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時(shí),在鍵空間中隨機(jī)移除某個(gè) key;
volatile-lru:當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時(shí),在設(shè)置了過期時(shí)間的鍵空間中,移除最近最少使用的 key;
volatile-random:當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時(shí),在設(shè)置了過期時(shí)間的鍵空間中,隨機(jī)移除某個(gè) key;
volatile-ttl:當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時(shí),在設(shè)置了過期時(shí)間的鍵空間中,有更早過期時(shí)間的 key 優(yōu)先移除;
volatile-lfu(least frequently used):從已設(shè)置過期時(shí)間的數(shù)據(jù)集(server.db[i].expires)中挑選最不經(jīng)常使用的數(shù)據(jù)淘汰
allkeys-lfu(least frequently used):當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時(shí),在鍵空間中,移除最不經(jīng)常使用的 key
內(nèi)存淘汰機(jī)制由redis.conf配置文件中的maxmemory-policy屬性設(shè)置,沒有配置時(shí)默認(rèn)為no-eviction模式。
volatile-lru、volatile-random、volatile-ttl三種策略都是針對過期字典的處理,但是在過期字典為空時(shí)會noeviction一樣返回寫入失敗,所以一般選擇第二種allkeys-lru基于LRU策略進(jìn)行淘汰。volatile-lfu和allkeys-lfu是4.0 版本后新增加的。
3.1 LRU & LFU
上面的淘汰策略中主要分為lru和lfu兩種類型,LRU即最近最少使用淘汰算法(Least Recently Used),LRU是淘汰最長時(shí)間沒有被使用的。LFU即最不經(jīng)常使用淘汰算法(Least Frequently Used),LFU是淘汰一段時(shí)間內(nèi),使用次數(shù)最少的頁面。
LRU:它把數(shù)據(jù)存放在鏈表中按照最近訪問的順序排列,當(dāng)某個(gè)key被訪問時(shí)就將此key移動(dòng)到鏈表的頭部,保證了最近訪問過的元素在鏈表的頭部或前面。當(dāng)鏈表滿了之后,就將鏈表尾部的元素刪除,再將新的元素添加至鏈表頭部。
可以Redis采用了一種近似LRU的做法:給每個(gè)key增加一個(gè)大小為24bit的屬性字段,代表最后一次被訪問的時(shí)間戳。然后隨機(jī)采樣出5個(gè)key,淘汰掉最舊的key,直到Redis占用內(nèi)存小于maxmemory為止。其中隨機(jī)采樣的數(shù)量可以通過Redis配置文件中的 maxmemory_samples 屬性來調(diào)整,默認(rèn)是5,采樣數(shù)量越大越接近于標(biāo)準(zhǔn)LRU算法,但也會帶來性能的消耗。在Redis 3.0以后增加了LRU淘汰池,進(jìn)一步提高了與標(biāo)準(zhǔn)LRU算法效果的相似度。淘汰池即維護(hù)的一個(gè)數(shù)組,數(shù)組大小等于抽樣數(shù)量 maxmemory_samples,在每一次淘汰時(shí),新隨機(jī)抽取的key和淘汰池中的key進(jìn)行合并,然后淘汰掉最舊的key,將剩余較舊的前面5個(gè)key放入淘汰池中待下一次循環(huán)使用。
自己實(shí)現(xiàn)一個(gè)LRU算法:
使用head和end表示鏈表的頭和尾,在時(shí)間上先被訪問的數(shù)據(jù)作為雙向鏈表的head,后被訪問的數(shù)據(jù)作為雙向鏈表的end,當(dāng)容量達(dá)到最大值后,新進(jìn)入未被訪問過的數(shù)據(jù),則將head的節(jié)點(diǎn)刪除,將新的數(shù)據(jù)插入end處,如果訪問的數(shù)據(jù)在內(nèi)存中,則將數(shù)據(jù)更新到end除,刪除原始在的位置。結(jié)構(gòu)變換示意如下:
代碼實(shí)現(xiàn):
public class LRUCache<K, V> {
//最大容量
private int maxSize;
//緩存存儲結(jié)構(gòu)
private HashMap<K,CacheNode> map;
//第一個(gè)元素結(jié)點(diǎn)
private CacheNode first;
//最后一個(gè)元素結(jié)點(diǎn)
private CacheNode last;
//初始化
public LRUCache(int size){
this.maxSize = size;
map = new HashMap<K,CacheNode>(size);
}
//新增節(jié)點(diǎn),,放在隊(duì)列頭,容量滿了刪除隊(duì)列尾元素。
public void put(K k,V v){
CacheNode node = map.get(k);
if(node == null){
if(map.size() >= maxSize){
map.remove(last.key);
removeLast();
}
node = new CacheNode();
node.key = k;
}
node.value = v;
moveToFirst(node);
map.put(k, node);
}
//獲取元素將被操作元素移到隊(duì)列頭
public Object get(K k){
CacheNode node = map.get(k);
if(node == null){
return null;
}
moveToFirst(node);
return node.value;
}
//刪除元素,調(diào)整前后節(jié)點(diǎn)位置關(guān)系
public Object remove(K k){
CacheNode node = map.get(k);
if(node != null){
if(node.pre != null){
node.pre.next=node.next;
}else {
first = node.next;
}
if(node.next != null){
node.next.pre=node.pre;
}else {
last = node.pre;
}
}
return map.remove(k);
}
public void clear(){
first = null;
last = null;
map.clear();
}
//將元素移動(dòng)到隊(duì)列頭
private void moveToFirst(CacheNode node){
if(first == node){
return;
}
if(node.next != null){
node.next.pre = node.pre;
}
if(node.pre != null){
node.pre.next = node.next;
}
if(node == last){
last= last.pre;
}
if(first == null || last == null){
first = last = node;
return;
}
node.next=first;
first.pre = node;
first = node;
first.pre=null;
}
private void removeLast(){
if(last != null){
last = last.pre;
if(last == null){
first = null;
}else{
last.next = null;
}
}
}
@Override
public String toString(){
StringBuilder sb = new StringBuilder();
CacheNode node = first;
while(node != null){
sb.append(String.format("%s:%s ", node.key,node.value));
node = node.next;
}
return sb.toString();
}
//定義node數(shù)據(jù)結(jié)構(gòu)
class CacheNode{
CacheNode pre;
CacheNode next;
Object key;
Object value;
}
public static void main(String[] args) {
LRUCache<Integer,String> lru = new LRUCache<Integer,String>(3);
lru.put(1, "a");
lru.put(2, "b");
lru.put(3, "c");
System.out.println(lru.toString());
lru.get(1);
System.out.println(lru.toString());
lru.get(3);
System.out.println(lru.toString());
lru.put(4, "d");
System.out.println(lru.toString());
lru.put(5,"e");
System.out.println(lru.toString());
}
}
執(zhí)行結(jié)果如下,和上述設(shè)計(jì)示意圖一致
LFU:最近最少使用,跟使用的次數(shù)有關(guān),淘汰使用次數(shù)最少的。從 Redis 4.0 版本開始,新增最少使用的回收模式。在 LRU 中,一個(gè)最近訪問過但實(shí)際上幾乎從未被請求過的 key 很有可能不會過期,那么風(fēng)險(xiǎn)就是刪除一個(gè)將來有更高概率被請求的 key。LFU 加入了訪問次數(shù)的維度,可以更好地適應(yīng)不同的訪問模式。
上圖這種情況在lru算法下將會被保留下來。淘汰算法的本意是保留那些將來最有可能被再次訪問的數(shù)據(jù),而LRU算法只是預(yù)測最近被訪問的數(shù)據(jù)將來最有可能被訪問到。LFU(Least Frequently Used)算法就是最頻繁被訪問的數(shù)據(jù)將來最有可能被訪問到。在上面的情況中,根據(jù)訪問頻繁情況,可以確定保留優(yōu)先級:B>A>C=D。
3.2 Redis LFU算法設(shè)計(jì)思路
在LFU算法中,可以為每個(gè)key維護(hù)一個(gè)計(jì)數(shù)器。每次key被訪問的時(shí)候,計(jì)數(shù)器增大。計(jì)數(shù)器越大,可以約等于訪問越頻繁。 上述簡單算法存在一個(gè)問題,只是簡單的增加計(jì)數(shù)器的方法并不完美。訪問模式是會頻繁變化的,一段時(shí)間內(nèi)頻繁訪問的key一段時(shí)間之后可能會很少被訪問到,只增加計(jì)數(shù)器并不能體現(xiàn)這種趨勢。解決辦法是,記錄key最后一個(gè)被訪問的時(shí)間,然后隨著時(shí)間推移,降低計(jì)數(shù)器。
在LRU算法中,24 bits的lru是用來記錄LRU time的,在LFU中也可以使用這個(gè)字段,不過是分成16 bits與8 bits使用:高16 bits用來記錄最近一次計(jì)數(shù)器降低的時(shí)間ldt,單位是分鐘,低8 bits記錄計(jì)數(shù)器數(shù)值counter。配置 LFU 策略,如下:
volatile-lfu :在設(shè)置了失效時(shí)間的所有 key 中,使用近似的 LFU 淘汰 key,也就是最少被訪問的 key.
allkeys-lfu : 在所有 key 里根據(jù) LFU 淘汰 key.
為了解決上述訪問次數(shù)增長過快和突然不訪問后次數(shù)不變造成的不會被清楚問題,提供了如下配置參數(shù):
lfu-log-factor 10 可以調(diào)整計(jì)數(shù)器counter的增長速度,lfu-log-factor越大,counter增長的越慢。
lfu-decay-time 1 是一個(gè)以分鐘為單位的數(shù)值,可以調(diào)整counter的減少速度,衰減時(shí)間默認(rèn)是1.
3.3 自己實(shí)現(xiàn)一個(gè)LFU算法:
設(shè)計(jì)思路:可以使用JDK提供的優(yōu)先隊(duì)列 PriorityQueue 來實(shí)現(xiàn),即可以保證每次 poll 元素的時(shí)候,都可以根據(jù)我們的要求,取出當(dāng)前所有元素的最大值或是最小值。只需要我們的實(shí)體類實(shí)現(xiàn) Comparable 接口就可以了。需要定義一個(gè) Node 來保存當(dāng)前元素的訪問頻次 freq,全局的自增的 index,用于比較大小。然后定義一個(gè) Map<Integer,Node> cache ,用于存放元素的信息。當(dāng) cache 容量不足時(shí),根據(jù)訪問頻次 freq 的大小來刪除最小的 freq 。若相等,則刪除 index 最小的,因?yàn)閕ndex是自增的,越大說明越是最近訪問過的,越小說明越是很長時(shí)間沒訪問過的元素。
代碼如下:
public class LFUCache {
//存儲node元素
Map<Integer,Node> cache;
//優(yōu)先隊(duì)列
Queue<Node> ;
//容量
int capacity;
//當(dāng)前緩存的元素個(gè)數(shù)
int size;
int index = 0;
//初始化
public LFUCache(int capacity){
this.capacity = capacity;
if(capacity > 0){
queue = new PriorityQueue<>(capacity);
}
cache = new HashMap<>();
}
public int get(int key){
Node node = cache.get(key);
// node不存在,則返回 -1
if(node == null) return -1;
//每訪問一次,頻次和全局index都自增 1
node.freq++;
node.index = index++;
// 每次都重新remove,再offer是為了讓優(yōu)先隊(duì)列能夠?qū)Ξ?dāng)前Node重排序
//不然的話,比較的 freq 和 index 就是不準(zhǔn)確的
queue.remove(node);
queue.offer(node);
return node.value;
}
public void put(int key, int value){
//容量0,則直接返回
if(capacity == 0) return;
Node node = cache.get(key);
//如果node存在,則更新它的value值
if(node != null){
node.value = value;
node.freq++;
node.index = index++;
queue.remove(node);
queue.offer(node);
}else {
//如果cache滿了,則從優(yōu)先隊(duì)列中取出一個(gè)元素,這個(gè)元素一定是頻次最小,最久未訪問過的元素
if(size == capacity){
cache.remove(queue.poll().key);
//取出元素后,size減 1
size--;
}
//否則,說明可以添加元素,于是創(chuàng)建一個(gè)新的node,添加到優(yōu)先隊(duì)列中
Node newNode = new Node(key, value, index++);
queue.offer(newNode);
cache.put(key,newNode);
//同時(shí),size加 1
size++;
}
}
//必須實(shí)現(xiàn) Comparable 接口才可用于排序
private class Node implements Comparable<Node>{
int key;
int value;
int freq = 1;
int index;
public Node(int key, int value, int index){
this.key = key;
this.value = value;
this.index = index;
}
@Override
public int compareTo(Node o) {
//優(yōu)先比較頻次 freq,頻次相同再比較index
int minus = this.freq - o.freq;
return minus == 0? this.index - o.index : minus;
}
}
public static void main(String[] args) {
LFUCache cache = new LFUCache(2);
cache.put(1, 1);
cache.put(2, 2);
// 返回 1
System.out.println(cache.get(1));
cache.put(3, 3); // 去除 key 2
// 返回 -1 (未找到key 2)
System.out.println(cache.get(2));
// 返回 3
System.out.println(cache.get(3));
cache.put(4, 4); // 去除 key 1
// 返回 -1 (未找到 key 1)
System.out.println(cache.get(1));
// 返回 3
System.out.println(cache.get(3));
// 返回 4
System.out.println(cache.get(4));
}
四:告一段落
可到這里我們從redisdb內(nèi)存存儲結(jié)構(gòu)分析了內(nèi)存回收模式,過期回收和內(nèi)存淘汰兩類,根據(jù)內(nèi)存淘汰策略解讀了LRU算法和LFU的算法,以及使用java語言對兩種算法做了簡單的實(shí)現(xiàn)。接下來將對redis的持久化、主從同步、高可用部署等其他相關(guān)內(nèi)容做分析解讀。
關(guān)注IT巔峰技術(shù),私信作者,獲取以下2021全球架構(gòu)師峰會PDF資料。