借助 PriorityQueue 实现简单的 Lru Cache
Intro
在使用缓存的时候往往要考虑缓存限制以免缓存过多地占用应用内存而且这样缓存的效率可能并不够高
比较常见的做法是实现缓存驱逐/淘汰,比如 LRU Cache,redis 默认的内存策略也是基于 lru 的 allkeys-lru,
.NET 6 开始引入了 PriorityQueue 这一数据结构 .NET6 中的 PriorityQueue,在 .NET 9 中支持了 Remove .NET 9 Preview 1 PriorityQueue 更新,我们可以通过 Remove + enqueue 来实现 update priority 的需要,今天我们就借助 .NET 里的 PriorityQueue 来实现 LRU cache
Samples
首先我们来看下基于 PriorityQueue 的 LruCache 简单实现
file interface ICache
{
void PrintKeys();
void Set(string key, object? value);
bool TryGetValue<TValue>(string key, [MaybeNullWhen(false)] out TValue value);
}
file sealed class LruCache(int maxSize) : ICache
{
private readonly ConcurrentDictionary<string, object?> _store = new();
private readonly PriorityQueue<string, long> _priorityQueue = new PriorityQueue<string, long>();
private readonly JsonSerializerOptions _jsonSerializerOptions = new()
{
WriteIndented = true
};
public ICollection<string> Keys => _store.Keys;
public void PrintKeys()
{
Console.WriteLine("PrintKeys:");
Console.WriteLine(JsonSerializer.Serialize(_store, _jsonSerializerOptions));
}
public void Set(string key, object? value)
{
if (_store.ContainsKey(key))
{
_store[key] = value;
UpdateKeyAccess(key);
return;
}
while (_store.Count >= maxSize)
{
var keyToRemove = _priorityQueue.Dequeue();
_store.TryRemove(keyToRemove, out _);
}
_store[key] = value;
UpdateKeyAccess(key);
}
public bool TryGetValue<TValue>(string key, [MaybeNullWhen(false)] out TValue value)
{
if (!_store.TryGetValue(key, out var cacheValue))
{
value = default;
return false;
}
UpdateKeyAccess(key);
value = (TValue)cacheValue!;
return true;
}
private void UpdateKeyAccess(string key)
{
_priorityQueue.Remove(key, out _, out _);
_priorityQueue.Enqueue(key, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
}
}
cache 主要定义了 Get/Set 两个方法,LruCache 设置了最大的 cache 数量,简单的按照 key 的数量来计算,没有计算实际的内存占用等
用了一个 ConcurrentDictionary
来存 cache,这里的 cache 出于简单考虑不设置过期时间,只根据 cache keys limit 来计算,使用 PriorityQueue
来保存缓存最近一次访问的时间以根据访问的时间来决定需要驱逐的时候谁应该被驱逐
来看一个简单的使用示例:
var cache = new LruCache(2);
cache.Set("name", "test");
cache.Set("age", "10");
cache.PrintKeys();
ConsoleHelper.HandleInputLoop(x =>
{
if (x.StartsWith("get:"))
{
var key = x["get:".Length..];
cache.TryGetValue(key, out string? value);
Console.WriteLine($"key: {key}, value: {value}");
return;
}
cache.Set(x, "Hello .NET");
cache.PrintKeys();
}, "Input something to test lruCache, starts with 'get:' to try get cache value, otherwise set cache, input q to exit");
ConsoleHelper.ReadLine();
ConsoleHelper.HandInputLoop
是一个帮助方法,可以不断的从 console 读取 Input 并进行一定的处理,默认输出 q 来退出循环,我们执行一下看下效果
首先先打印了一下缓存的数据,我们开始设置了 age 和 name 两个缓存值,然后通过输入循环来设置新的缓存和访问某一个缓存,
首先我们设置了一个 aa 缓存,因为我们设置了最多保留两个缓存而且缓存中已经有了两个缓存,这样我们就需要驱逐一个 key 才能设置新的 key,首先加入的 key 被认为是最早访问的一个 key 应该被驱逐,所以 name
被移除了,然后新的 key aa
被加入到缓存中
之后我们再次设置了 aa
,因为之前已经在缓存里了,无需驱逐缓存,可以直接更新已有的缓存并更新最后访问的时间
接着我们访问了一下 age
缓存,这样我们最近访问的缓存就变成了它,下次有新的缓存进来就不应该淘汰 age
缓存
之后我们设置一个 aaa
缓存,从输出结果可以看到,此时缓存中的 key 是 age/aaa,aa 的访问时间较早所以被淘汰了
我们再来设置一下 aa
,此时 age
就被淘汰了,缓存中剩下 aaa
/aa
这里我们只是记录了一下最后的访问时间,我们也可以记录下访问的次数,根据访问时间+访问次数做一个权重计算一个综合之后的优先级,这样就比单独按照时间来计算更加具体参考意义,你觉得有别的需要记录也可以考虑记录下来,然后通过自定义比较器来决定谁应该被驱逐谁应该被保留
下面是一个带有访问次数的一个 lru cache 的示例
file sealed record CacheAccessEntry
{
public int AccessCount { get; set; }
public long AccessTimestamp { get; set; }
}
file sealed class CacheAccessEntryComparer(Func<CacheAccessEntry, long>? priorityFactory) : IComparer<CacheAccessEntry>
{
public CacheAccessEntryComparer() : this(null)
{
}
public int Compare(CacheAccessEntry? x, CacheAccessEntry? y)
{
ArgumentNullException.ThrowIfNull(x);
ArgumentNullException.ThrowIfNull(y);
if (priorityFactory is not null)
{
return priorityFactory(x).CompareTo(priorityFactory(y));
}
if (x.AccessCount == y.AccessCount)
{
return x.AccessTimestamp == y.AccessTimestamp
? 0
: (x.AccessTimestamp > y.AccessTimestamp ? 1 : -1)
;
}
return x.AccessCount > y.AccessCount ? 1 : -1;
}
}
file sealed class LruCacheV2(int maxSize) : ICache
{
private readonly ConcurrentDictionary<string, object?> _store = new();
private static readonly CacheAccessEntryComparer CacheEntryComparer = new();
private readonly PriorityQueue<string, CacheAccessEntry> _priorityQueue =
new(CacheEntryComparer);
public ICollection<string> Keys => _store.Keys;
public void PrintKeys()
{
Console.WriteLine("PrintKeys:");
Console.WriteLine(JsonSerializer.Serialize(_store));
}
public void Set(string key, object? value)
{
if (_store.ContainsKey(key))
{
_store[key] = value;
UpdateKeyAccess(key);
return;
}
while (_store.Count >= maxSize)
{
var keyToRemove = _priorityQueue.Dequeue();
_store.TryRemove(keyToRemove, out _);
}
_store[key] = value;
UpdateKeyAccess(key);
}
public bool TryGetValue<TValue>(string key, [MaybeNullWhen(false)] out TValue value)
{
if (!_store.TryGetValue(key, out var cacheValue))
{
value = default;
return false;
}
UpdateKeyAccess(key);
value = (TValue)cacheValue!;
return true;
}
private void UpdateKeyAccess(string key)
{
_priorityQueue.Remove(key, out _, out var entry);
entry ??= new();
entry.AccessTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
entry.AccessCount += 1;
_priorityQueue.Enqueue(key, entry);
}
}
使用示例和前面类似,只需要把 LruCache
改成 LruCacheV2
即可
var cache = new LruCacheV2(2);
cache.Set("name", "test");
cache.Set("age", "10");
cache.PrintKeys();
ConsoleHelper.HandleInputLoop(x =>
{
if (x.StartsWith("get:"))
{
var key = x["get:".Length..];
cache.TryGetValue(key, out string? value);
Console.WriteLine($"key: {key}, value: {value}");
return;
}
cache.Set(x, "Hello .NET");
cache.PrintKeys();
}, "Input something to test lruCache, starts with 'get:' to try get cache value, otherwise set cache, input q to exit");
执行结果如下:
这里我们针对 age
访问了两次,虽然之后访问了 aa
,aa
的最后访问时间是最新的,但是访问次数较少,V2 版本默认优先了访问次数,导致 age
的驱逐优先级低于 aa
, aa
被淘汰
以上示例仅作参考,具体代码可以从文末的 Github 链接获取,实际要实现缓存的驱逐建议只是标记一下已删除,实际删除工作交给后台任务去执行,以免缓存驱逐的过程导致性能问题
References
https://github.com/WeihanLi/SamplesInPractice/blob/main/net9sample/Net9Samples/LruCacheSample.cs