序文
Javaは最初からredisを手動で実装します(1)固定サイズのキャッシュを実現するにはどうすればよいですか?
Javaはredisを最初から手作業で実装します(3)redisの有効期限の原則
Javaは最初から手動でredisを実装します(3)メモリデータを失うことなく再起動する方法は?
Javaは手動でredisを最初から実装します(4つ)リスナーを追加します
Javaは最初から手動でredisを実装します(6)AOF永続性の原則の詳細と実装
Javaは最初からredisを手動で実装します(7)LRUキャッシュ除去戦略の詳細な説明
手書きのredisを最初から(8)単純なLRU除去アルゴリズムのパフォーマンスの最適化
最初の2つのセクションでは、LRUアルゴリズムを実装し、そのパフォーマンスを最適化しました。
LRUアルゴリズムの最後のセクションとして、このセクションは主にキャッシュ汚染の問題を解決します。
LRUの基本
それは何ですか
LRUアルゴリズムの正式名称は、Least Recent Useアルゴリズムであり、キャッシングメカニズムで広く使用されています。
キャッシュが使用するスペースが上限に達すると、キャッシュの可用性を維持するために既存のデータの一部を削除する必要があり、削除されたデータの選択はLRUアルゴリズムによって完了します。
LRUアルゴリズムの基本的な考え方は、局所性の原則に基づく時間の局所性です:
情報項目にアクセスしている場合、近い将来再びアクセスされる可能性があります。
参考文献
Apache CommonsLRUMAPソースコードの詳細な説明
Java手書きredisを最初から(7)redisLRU除外戦略の詳細な説明と実装
ナイーブLRUアルゴリズムの欠点
ホットデータがある場合、LRUは非常に効率的ですが、時折定期的なバッチ操作によりLRUヒット率が急激に低下し、キャッシュの汚染がより深刻になります。
拡張アルゴリズム
1. LRU-K
LRU-KのKは最近の使用数を表すため、LRUはLRU-1と見なすことができます。
LRU-Kの主な目的は、LRUアルゴリズムの「キャッシュ汚染」の問題を解決することです。その中心的なアイデアは、「最近使用された1回」の基準を「最近使用されたK回」に拡張することです。
LRUと比較して、LRU-Kは、アクセスされているすべてのキャッシュデータの履歴を記録するために、もう1つのキューを維持する必要があります。データアクセス数がK回に達した場合にのみ、データはキャッシュに入れられます。
データを削除する必要がある場合、LRU-Kは、K番目のアクセス時間が現在の時間から最も長いデータを削除します。
データが初めてアクセスされると、履歴アクセスリストに追加されます。データがアクセス履歴リストでK回に達しない場合、特定のルール(FIFO、LRU)に従ってデータが削除されます。
アクセス履歴キュー内のデータアクセス数がK回に達すると、データインデックスが履歴キューから削除され、データがキャッシュキューに移動され、データがキャッシュされ、キャッシュキューが再び時間で並べ替えられます。
キャッシュデータキューに再度アクセスした後、並べ替えます。データを削除する必要がある場合は、キャッシュキューの最後のデータを削除します。つまり、「過去K回のアクセスが最も長いデータを削除します」。
LRU-KにはLRUの利点があり、LRUの欠点も回避されます。実際のアプリケーションでは、LRU-2が最も包括的な選択肢です。
LRU-Kは、アクセスされたがキャッシュに入れられていないオブジェクトも記録する必要があるため、メモリ消費量はLRUよりも多くなります。
2.2つのキュー
2つのキュー(以下では代わりに2Qを使用)アルゴリズムはLRU-2に似ていますが、違いは2QがLRU-2アルゴリズム(これはキャッシュデータではないことに注意してください)のアクセス履歴キューをFIFOバッファーキューに変更することです。つまり、2Qアルゴリズムには2つあります。 2つのバッファキューがあります。1つはFIFOキューで、もう1つはLRUキューです。
2Qアルゴリズムは、データに初めてアクセスすると、FIFOキューにデータをバッファリングします。2回目にデータにアクセスすると、データをFIFOキューからLRUキューに移動します。2つのキューは、独自の方法でデータを削除します。
新しくアクセスされたデータはFIFOキューに挿入されます。データがFIFOキューで再度アクセスされていない場合、FIFOルールに従って最終的に削除されます。
データがFIFOキューで再度アクセスされると、データはLRUキューの先頭に移動されます。データがLRUキューで再度アクセスされると、データはLRUキューの先頭に移動され、LRUキューは最後にデータを削除します。
3.メニーキュー(MQ)
MQアルゴリズムは、アクセスの頻度に応じてデータを複数のキューに分割します。キューが異なればアクセスの優先順位も異なります。基本的な考え方は、アクセス時間が長いデータに優先順位を付けることです。
詳細なアルゴリズム構造図は次のとおりです。Q0、Q1 ... Qkは異なる優先度のキューを表し、Q-historyはキャッシュからデータを削除するが、データのインデックスと参照の数を記録するキューを表します。
新しく挿入されたデータはQ0に入れられ、各キューはLRUに従って管理されます。データアクセスの数が特定の数に達し、優先度を上げる必要がある場合、データは現在のキューから削除され、上位キューの先頭に追加されます。優先度の高いデータが削除されないようにするには、指定した時間内にデータにアクセスしない場合、優先度を下げ、現在のキューからデータを削除し、下位のキューの先頭に追加します。データを削除する必要がある場合は、最下位のキューから開始して、LRUに従って削除されます。各キューがデータを削除すると、データはキャッシュから削除され、データインデックスがQ-historyヘッダーに追加されます。
Q-historyでデータが再検討されると、その優先度が再計算され、ターゲットキューの先頭に移動されます。
Q-historyは、LRUに従ってデータインデックスを削除します。
MQは複数のキューを維持する必要があり、各データのアクセス時間を維持する必要があります。これはLRUよりも複雑です。
LRUアルゴリズムの比較
コントラストのポイント | 比較 |
---|---|
ヒット率 | LRU-2> MQ(2)> 2Q> LRU |
複雑さ | LRU-2> MQ(2)> 2Q> LRU |
費用 | LRU-2> MQ(2)> 2Q> LRU |
個人的な理解
実際、上記のアルゴリズムは考え方が似ています。
主な目的:バッチ操作によって引き起こされるホットデータの無効化とキャッシュの汚染の問題を解決すること。
実装:1回だけアクセスされるデータを格納するキューを追加し、その回数に応じてLRUに配置します。
一度だけアクセスされるキューは、FIFOキューまたはLRUにすることができます。2QとLRU-2の2つの実装を実装しましょう。
2Q
実現アイデア
実際、これは以前のFIFO + LRUの組み合わせです。
コード
基本属性
public class CacheEvictLru2Q<K,V> extends AbstractCacheEvict<K,V> {
private static final Log log = LogFactory.getLog(CacheEvictLru2Q.class);
/**
* 队列大小限制
*
* 降低 O(n) 的消耗,避免耗时过长。
* @since 0.0.13
*/
private static final int LIMIT_QUEUE_SIZE = 1024;
/**
* 第一次访问的队列
* @since 0.0.13
*/
private Queue<K> firstQueue;
/**
* 头结点
* @since 0.0.13
*/
private DoubleListNode<K,V> head;
/**
* 尾巴结点
* @since 0.0.13
*/
private DoubleListNode<K,V> tail;
/**
* map 信息
*
* key: 元素信息
* value: 元素在 list 中对应的节点信息
* @since 0.0.13
*/
private Map<K, DoubleListNode<K,V>> lruIndexMap;
public CacheEvictLru2Q() {
this.firstQueue = new LinkedList<>();
this.lruIndexMap = new HashMap<>();
this.head = new DoubleListNode<>();
this.tail = new DoubleListNode<>();
this.head.next(this.tail);
this.tail.pre(this.head);
}
}
データの削除
データ除去のロジック:
キャッシュサイズが上限に達したときに実行します。
(1)firstQueue内のデータの削除を優先します
(2)firstQueueのデータが空の場合、lruMapのデータ情報は削除されます。
前提条件は次のとおりです。複数回アクセスされたデータは、1回だけアクセスされたデータよりも重要であると考えています。
@Override
protected ICacheEntry<K, V> doEvict(ICacheEvictContext<K, V> context) {
ICacheEntry<K, V> result = null;
final ICache<K,V> cache = context.cache();
// 超过限制,移除队尾的元素
if(cache.size() >= context.size()) {
K evictKey = null;
//1. firstQueue 不为空,优先移除队列中元素
if(!firstQueue.isEmpty()) {
evictKey = firstQueue.remove();
} else {
// 获取尾巴节点的前一个元素
DoubleListNode<K,V> tailPre = this.tail.pre();
if(tailPre == this.head) {
log.error("当前列表为空,无法进行删除");
throw new CacheRuntimeException("不可删除头结点!");
}
evictKey = tailPre.key();
}
// 执行移除操作
V evictValue = cache.remove(evictKey);
result = new CacheEntry<>(evictKey, evictValue);
}
return result;
}
データの削除
データが削除されたときに呼び出されます:
このロジックは、もう1つのFIFOキューが削除されることを除いて、以前と同様です。
/**
* 移除元素
*
* 1. 获取 map 中的元素
* 2. 不存在直接返回,存在执行以下步骤:
* 2.1 删除双向链表中的元素
* 2.2 删除 map 中的元素
*
* @param key 元素
* @since 0.0.13
*/
@Override
public void removeKey(final K key) {
DoubleListNode<K,V> node = lruIndexMap.get(key);
//1. LRU 删除逻辑
if(ObjectUtil.isNotNull(node)) {
// A<->B<->C
// 删除 B,需要变成: A<->C
DoubleListNode<K,V> pre = node.pre();
DoubleListNode<K,V> next = node.next();
pre.next(next);
next.pre(pre);
// 删除 map 中对应信息
this.lruIndexMap.remove(node.key());
} else {
//2. FIFO 删除逻辑(O(n) 时间复杂度)
firstQueue.remove(key);
}
}
データの更新
データにアクセスするときは、データの優先度を上げてください。
(1)lruMapにある場合は、最初に削除してから、ヘッドに配置します
(2)lruMapではなくFIFOキューにある場合は、FIFOキューから削除され、LRUマップに追加されます。
(3)存在しない場合は、FIFOキューに追加するだけです。
/**
* 放入元素
* 1. 如果 lruIndexMap 已经存在,则处理 lru 队列,先删除,再插入。
* 2. 如果 firstQueue 中已经存在,则处理 first 队列,先删除 firstQueue,然后插入 Lru。
* 1 和 2 是不同的场景,但是代码实际上是一样的,删除逻辑中做了二种场景的兼容。
*
* 3. 如果不在1、2中,说明是新元素,直接插入到 firstQueue 的开始即可。
*
* @param key 元素
* @since 0.0.13
*/
@Override
public void updateKey(final K key) {
//1.1 是否在 LRU MAP 中
//1.2 是否在 firstQueue 中
DoubleListNode<K,V> node = lruIndexMap.get(key);
if(ObjectUtil.isNotNull(node)
|| firstQueue.contains(key)) {
//1.3 删除信息
this.removeKey(key);
//1.4 加入到 LRU 中
this.addToLruMapHead(key);
return;
}
//2. 直接加入到 firstQueue 队尾
// if(firstQueue.size() >= LIMIT_QUEUE_SIZE) {
// // 避免第一次访问的列表一直增长,移除队头的元素
// firstQueue.remove();
// }
firstQueue.add(key);
}
ここでは、トラバーサルの時間の複雑さがO(n)であり、最大サイズが1024に制限されているため、firstQueueの継続的な成長を制限するための最適化ポイントについて考えます。
を超える場合は、最初にFIFOの要素を削除します。
ただし、FIFOを削除するだけで、キャッシュを削除しないと、2つの間で一貫性のないアクティビティが発生します。
同時に削除されても、キャッシュのサイズがまだ満たされていない場合は、ユーザーの期待を超える可能性があります。これは、最適化ポイントとして使用し、一時的にコメントアウトすることができます。
テスト
コード
ICache<String, String> cache = CacheBs.<String,String>newInstance()
.size(3)
.evict(CacheEvicts.<String, String>lru2Q())
.build();
cache.put("A", "hello");
cache.put("B", "world");
cache.put("C", "FIFO");
// 访问一次A
cache.get("A");
cache.put("D", "LRU");
Assert.assertEquals(3, cache.size());
System.out.println(cache.keySet());
効果
[DEBUG] [2020-10-03 13:15:50.670] [main] [c.g.h.c.c.s.l.r.CacheRemoveListener.listen] - Remove key: B, value: world, type: evict
[D, A, C]
LRU-2の実装
説明
FIFOの欠点は非常に明白であり、トラバーサルにはO(n)時間の複雑さが必要です。
そして、ヒット率はまだLRU-2よりわずかに悪いです。
準備オーケー
ここでは、LRUマップが何度も表示されています。便宜上、LRUマップをデータ構造としてカプセル化するだけです。
二重リンクリスト+ HashMapを使用して、単純なバージョンを実装します。
ノード
ノードノードは以前と同じです。
public class DoubleListNode<K,V> {
/**
* 键
* @since 0.0.12
*/
private K key;
/**
* 值
* @since 0.0.12
*/
private V value;
/**
* 前一个节点
* @since 0.0.12
*/
private DoubleListNode<K,V> pre;
/**
* 后一个节点
* @since 0.0.12
*/
private DoubleListNode<K,V> next;
//fluent getter & setter
}
インターフェース
必要に応じて、最も重要な3つの方法を一時的に定義します。
/**
* LRU map 接口
* @author binbin.hou
* @since 0.0.13
*/
public interface ILruMap<K,V> {
/**
* 移除最老的元素
* @return 移除的明细
* @since 0.0.13
*/
ICacheEntry<K, V> removeEldest();
/**
* 更新 key 的信息
* @param key key
* @since 0.0.13
*/
void updateKey(final K key);
/**
* 移除对应的 key 信息
* @param key key
* @since 0.0.13
*/
void removeKey(final K key);
/**
* 是否为空
* @return 是否
* @since 0.0.13
*/
boolean isEmpty();
/**
* 是否包含元素
* @param key 元素
* @return 结果
* @since 0.0.13
*/
boolean contains(final K key);
}
成し遂げる
DoubleLinkedList + HashMapの実装に基づいています。
前のセクションの実装を整理するだけです。
import com.github.houbb.cache.api.ICacheEntry;
import com.github.houbb.cache.core.exception.CacheRuntimeException;
import com.github.houbb.cache.core.model.CacheEntry;
import com.github.houbb.cache.core.model.DoubleListNode;
import com.github.houbb.cache.core.support.struct.lru.ILruMap;
import com.github.houbb.heaven.util.lang.ObjectUtil;
import com.github.houbb.log.integration.core.Log;
import com.github.houbb.log.integration.core.LogFactory;
import java.util.HashMap;
import java.util.Map;
/**
* 基于双向列表的实现
* @author binbin.hou
* @since 0.0.13
*/
public class LruMapDoubleList<K,V> implements ILruMap<K,V> {
private static final Log log = LogFactory.getLog(LruMapDoubleList.class);
/**
* 头结点
* @since 0.0.13
*/
private DoubleListNode<K,V> head;
/**
* 尾巴结点
* @since 0.0.13
*/
private DoubleListNode<K,V> tail;
/**
* map 信息
*
* key: 元素信息
* value: 元素在 list 中对应的节点信息
* @since 0.0.13
*/
private Map<K, DoubleListNode<K,V>> indexMap;
public LruMapDoubleList() {
this.indexMap = new HashMap<>();
this.head = new DoubleListNode<>();
this.tail = new DoubleListNode<>();
this.head.next(this.tail);
this.tail.pre(this.head);
}
@Override
public ICacheEntry<K, V> removeEldest() {
// 获取尾巴节点的前一个元素
DoubleListNode<K,V> tailPre = this.tail.pre();
if(tailPre == this.head) {
log.error("当前列表为空,无法进行删除");
throw new CacheRuntimeException("不可删除头结点!");
}
K evictKey = tailPre.key();
V evictValue = tailPre.value();
return CacheEntry.of(evictKey, evictValue);
}
/**
* 放入元素
*
* (1)删除已经存在的
* (2)新元素放到元素头部
*
* @param key 元素
* @since 0.0.12
*/
@Override
public void updateKey(final K key) {
//1. 执行删除
this.removeKey(key);
//2. 新元素插入到头部
//head<->next
//变成:head<->new<->next
DoubleListNode<K,V> newNode = new DoubleListNode<>();
newNode.key(key);
DoubleListNode<K,V> next = this.head.next();
this.head.next(newNode);
newNode.pre(this.head);
next.pre(newNode);
newNode.next(next);
//2.2 插入到 map 中
indexMap.put(key, newNode);
}
/**
* 移除元素
*
* 1. 获取 map 中的元素
* 2. 不存在直接返回,存在执行以下步骤:
* 2.1 删除双向链表中的元素
* 2.2 删除 map 中的元素
*
* @param key 元素
* @since 0.0.13
*/
@Override
public void removeKey(final K key) {
DoubleListNode<K,V> node = indexMap.get(key);
if(ObjectUtil.isNull(node)) {
return;
}
// 删除 list node
// A<->B<->C
// 删除 B,需要变成: A<->C
DoubleListNode<K,V> pre = node.pre();
DoubleListNode<K,V> next = node.next();
pre.next(next);
next.pre(pre);
// 删除 map 中对应信息
this.indexMap.remove(key);
}
@Override
public boolean isEmpty() {
return indexMap.isEmpty();
}
@Override
public boolean contains(K key) {
return indexMap.containsKey(key);
}
}
実現アイデア
LRUの実装は変更されていません。FIFOをLRUマップに直接置き換えることができます。
理解を容易にするために、FIFOをfirstLruMapとして対応します。これは、ユーザーが1回だけアクセスした要素を格納するために使用されます。
2回以上アクセスされた要素を元のLRUに保存します。
他のロジックは2Qと一致しています。
成し遂げる
基本属性
アクセスされた情報を別々に保存するために2つのLRUを定義します
public class CacheEvictLru2<K,V> extends AbstractCacheEvict<K,V> {
private static final Log log = LogFactory.getLog(CacheEvictLru2.class);
/**
* 第一次访问的 lru
* @since 0.0.13
*/
private final ILruMap<K,V> firstLruMap;
/**
* 2次及其以上的 lru
* @since 0.0.13
*/
private final ILruMap<K,V> moreLruMap;
public CacheEvictLru2() {
this.firstLruMap = new LruMapDoubleList<>();
this.moreLruMap = new LruMapDoubleList<>();
}
}
除去が達成されました
lru 2Qモードと同様に、ここではfirstLruMapのデータ情報を削除することを優先します。
@Override
protected ICacheEntry<K, V> doEvict(ICacheEvictContext<K, V> context) {
ICacheEntry<K, V> result = null;
final ICache<K,V> cache = context.cache();
// 超过限制,移除队尾的元素
if(cache.size() >= context.size()) {
ICacheEntry<K,V> evictEntry = null;
//1. firstLruMap 不为空,优先移除队列中元素
if(!firstLruMap.isEmpty()) {
evictEntry = firstLruMap.removeEldest();
log.debug("从 firstLruMap 中淘汰数据:{}", evictEntry);
} else {
//2. 否则从 moreLruMap 中淘汰数据
evictEntry = moreLruMap.removeEldest();
log.debug("从 moreLruMap 中淘汰数据:{}", evictEntry);
}
// 执行缓存移除操作
final K evictKey = evictEntry.key();
V evictValue = cache.remove(evictKey);
result = new CacheEntry<>(evictKey, evictValue);
}
return result;
}
削除
/**
* 移除元素
*
* 1. 多次 lru 中存在,删除
* 2. 初次 lru 中存在,删除
*
* @param key 元素
* @since 0.0.13
*/
@Override
public void removeKey(final K key) {
//1. 多次LRU 删除逻辑
if(moreLruMap.contains(key)) {
moreLruMap.removeKey(key);
log.debug("key: {} 从 moreLruMap 中移除", key);
} else {
firstLruMap.removeKey(key);
log.debug("key: {} 从 firstLruMap 中移除", key);
}
}
更新
/**
* 更新信息
* 1. 如果 moreLruMap 已经存在,则处理 more 队列,先删除,再插入。
* 2. 如果 firstLruMap 中已经存在,则处理 first 队列,先删除 firstLruMap,然后插入 Lru。
* 1 和 2 是不同的场景,但是代码实际上是一样的,删除逻辑中做了二种场景的兼容。
*
* 3. 如果不在1、2中,说明是新元素,直接插入到 firstLruMap 的开始即可。
*
* @param key 元素
* @since 0.0.13
*/
@Override
public void updateKey(final K key) {
//1. 元素已经在多次访问,或者第一次访问的 lru 中
if(moreLruMap.contains(key)
|| firstLruMap.contains(key)) {
//1.1 删除信息
this.removeKey(key);
//1.2 加入到多次 LRU 中
moreLruMap.updateKey(key);
log.debug("key: {} 多次访问,加入到 moreLruMap 中", key);
} else {
// 2. 加入到第一次访问 LRU 中
firstLruMap.updateKey(key);
log.debug("key: {} 为第一次访问,加入到 firstLruMap 中", key);
}
}
実際、LRU-2を使用するコードロジックは、主にlruMapを独立したデータ構造として抽出したために明確になりました。
テスト
コード
ICache<String, String> cache = CacheBs.<String,String>newInstance()
.size(3)
.evict(CacheEvicts.<String, String>lru2Q())
.build();
cache.put("A", "hello");
cache.put("B", "world");
cache.put("C", "FIFO");
// 访问一次A
cache.get("A");
cache.put("D", "LRU");
Assert.assertEquals(3, cache.size());
System.out.println(cache.keySet());
ログ
位置分析を容易にするために、ソースコードの実装時に小さなログが追加されました。
[DEBUG] [2020-10-03 14:39:04.966] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: A 为第一次访问,加入到 firstLruMap 中
[DEBUG] [2020-10-03 14:39:04.967] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: B 为第一次访问,加入到 firstLruMap 中
[DEBUG] [2020-10-03 14:39:04.968] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: C 为第一次访问,加入到 firstLruMap 中
[DEBUG] [2020-10-03 14:39:04.970] [main] [c.g.h.c.c.s.e.CacheEvictLru2.removeKey] - key: A 从 firstLruMap 中移除
[DEBUG] [2020-10-03 14:39:04.970] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: A 多次访问,加入到 moreLruMap 中
[DEBUG] [2020-10-03 14:39:04.972] [main] [c.g.h.c.c.s.e.CacheEvictLru2.doEvict] - 从 firstLruMap 中淘汰数据:EvictEntry{key=B, value=null}
[DEBUG] [2020-10-03 14:39:04.974] [main] [c.g.h.c.c.s.l.r.CacheRemoveListener.listen] - Remove key: B, value: world, type: evict
[DEBUG] [2020-10-03 14:39:04.974] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: D 为第一次访问,加入到 firstLruMap 中
[D, A, C]
概要
LRUアルゴリズムの改善のために、私たちは主に2つのポイントを作成しました。
(1)O(N)最適化からO(1)へのパフォーマンスの向上
(2)キャッシュ汚染を回避するためのバッチ操作の改善
実際、LRUに加えて、他の除去戦略があります。
次の問題を考慮する必要があります。
データAは10回アクセスされ、データBは2回アクセスされました。では、2つのホットデータは誰ですか?
Aがホットデータであると思われる場合は、LFUの除去アルゴリズムに基づいた、実際には別の除去アルゴリズムがあります。訪問数が多いほど、ホットデータが多いと考えてください。
次のセクションでは、LFU除去アルゴリズムの実装について一緒に学びましょう。
オープンソースアドレス:https://github.com/houbb/cache
この記事が役に立ったと思ったら、いいね、コメント、集めて、波をたどってください。あなたの励ましが私の最大の動機です〜
現在、2つの最適化により、バッチによって引き起こされるパフォーマンスの問題とキャッシュ汚染の問題を解決しました。
あなたが何を得たのか分かりませんか?または、他にもアイデアがある場合は、メッセージエリアで私と話し合うことを歓迎し、あなたの考えに会うことを楽しみにしています。