65.9K
CodeProject 正在变化。 阅读更多。
Home

为 StackExchange.Redis .NET 客户端创建内存中“L1”缓存

starIconstarIconstarIconstarIconstarIcon

5.00/5 (11投票s)

2016年2月3日

CPOL

12分钟阅读

viewsIcon

32468

介绍我为何以及如何编写了一个用于在 .NET 客户端中缓存 Redis 数据的开源库。

简而言之; 我编写了一个名为 StackRedis.L1 的开源库。它是 Redis 的一个 .NET 一级缓存。这意味着,如果您在 .NET 应用程序中使用 StackExchange.Redis,可以引入这个库来加速应用程序,通过在本地内存中缓存数据,避免在数据未更改时进行不必要的 Redis 调用。访问 GitHub 页面,或获取 NuGet 包

这是关于它如何以及为何被编写的记述。

背景故事

在过去的几年里,我一直在开发一个 SharePoint 应用,Repsor custodian。对于熟悉 SharePoint 的人来说,SharePoint 应用模型要求应用不在 SharePoint 进程内执行,而是在单独的应用服务器上运行。这种模型提供了更好的稳定性和更清晰的架构;然而,这是以牺牲性能为代价的,因为每次您需要 SharePoint 数据时,都受限于网络延迟。

因此,我们引入了 Redis 缓存到应用中,并使用 StackExchange.Redis 作为 .NET 客户端。我们使用字符串、集合、有序集合和哈希来缓存应用的各个方面的数据。

自然,这极大地加速了应用程序。页面现在在大约 500 毫秒内返回,而不是之前的 2 秒 - 但在性能剖析时,很明显这 500 毫秒中有很大一部分花在了发送或接收 Redis 数据上;其中很多数据是在每次页面请求时接收的,且并未更改

缓存缓存

Redis 是一个非常非常快的缓存,但它不仅仅是一个缓存,因为它允许您操作缓存中的数据。而且,它成熟且得到广泛支持。StackExchange.Redis 库是免费开源的,拥有活跃的社区。Stack Exchange 使用它来积极缓存*所有*他们的数据 - 而 Stack Exchange 是互联网上最繁忙的网站之一。然而,他们还有另一项绝技 - 尽可能地将*所有*内容在内存中缓存到 Web 服务器上,这样在很多时候,他们甚至不需要与 Redis 通信。

Stack Exchange 的这个元答案详细解释了他们使用的缓存机制: http://meta.stackexchange.com/questions/69164/does-stack-exchange-use-caching-and-if-so-how

"自然,跨网络发送的最快字节数是0。"

当您在数据到达另一个缓存之前对其进行缓存时,您就有多个缓存层。如果您在 Redis 缓存之前使用内存缓存,那么内存缓存可以被称为 L1:https://en.wikipedia.org/wiki/CPU_cache#MULTILEVEL

所以,如果 Redis 是您应用程序中最慢的部分,那么您做得很好 - 并且肯定可以进一步加快速度。

内存缓存

最简单地说,当您使用 Redis 时,您的代码可能看起来像这样

    //Try and retrieve from Redis
    RedisValue redisValue = _cacheDatabase.StringGet(key);
    if(redisValue.HasValue)
    {
        return redisValue; //It's in Redis - return it
    }
    else
    {
        string strValue = GetValueFromDataSource(); //Get the value from eg. SharePoint or Database etc
        _cacheDatabase.StringSet(key, strValue); //Add to Redis
        return strValue;
    }

如果您决定引入内存缓存(即 L1 缓存),事情会变得更复杂一些

    //Try and retrieve from memory
    if (_memoryCache.ContainsKey(key))
    {
        return key;
    }
    else
    {
        //It isn't in memory. Try and retrieve from Redis
        RedisValue redisValue = _cacheDatabase.StringGet(key);
        if (redisValue.HasValue)
        {
            //Add to memory cache
            _memoryCache.Add(key, redisValue);

            return redisValue;  //It's in redis - return it
        }
        else
        {
            string strValue = GetValueFromDataSource(); //Get the value from eg. SharePoint or Database etc
            _cacheDatabase.StringSet(key, strValue); //Add to Redis
            _memoryCache.Add(key, strValue); //Add to memory
            return strValue;
        }
    }

虽然实现起来并不难,但当我们试图为其他 Redis 数据类型遵循相同的模式时,事情会变得*复杂得多*。此外,我们还面临以下问题

  • Redis 允许我们通过 StringAppend 等函数操作数据。在这种情况下,我们需要使内存中的项失效。
  • 当通过 KeyDelete 删除一个键时,它需要从内存缓存中移除。
  • 另一个客户端可能会更新或删除一个值。在这种情况下,我们客户端中的内存值将过时。
  • 当一个键过期时,它需要从内存缓存中移除。

现在,StackExchange.Redis 的数据访问和更新方法定义在 IDatabase 接口上。那么,如果我们编写一个自定义的 IDatabase 实现来处理上面概述的所有情况呢?在这种情况下,我们可以解决上述所有问题

  • StringAppend - 在这个简单的例子中,我们将字符串追加到内存中,然后将相同的操作传递给 Redis。对于更复杂的数据操作,我们将删除内存中的键。
  • KeyDelete、KeyExpire 等 - 删除内存中的数据
  • 通过另一个客户端进行的操作 - 处理 Redis 的 keyspace 通知,以检测数据是否在其他地方发生更改,并适当地使其失效。

这种方法的真正妙处在于,它实现的接口与您已经在使用*的*接口相同,因此您无需进行*任何*代码更改即可使用此 L1 缓存解决方案。

架构

这是我选择的解决方案。重要的元素是

静态 MemoryCache 注册表

这意味着您可以创建 RedisL1Database 的新实例,传入您已有的 Redis IDatabase 实例,它将继续使用之前为该数据库创建的任何内存缓存。

通知层

  • NotificationDatabase - 此类发布自定义 keyspace 事件,这些事件是使任何内存缓存保持最新的必要条件。Redis 自带的 keyspace 通知本身并不足够,因为它们通常不提供足够的信息来使内存缓存的正确部分失效。例如,如果您删除一个 Hash 键,您会收到一个 HDEL 通知,告知您哪个 Hash 键被删除。但它没有告诉您 Hash 中的哪个元素!自定义事件包含有关实际 Hash 元素本身的信息。
  • NotificationListener - 此类订阅自定义 keyspace 事件,并调用静态内存缓存来使相关键失效。它还订阅了一个内置的 Redis keyspace 事件 - expire。这意味着当 Redis 使一个键过期时,键始终会从内存中移除。

在下一节中,我们将介绍不同 Redis 数据类型的缓存技术。

字符串

字符串相对简单。IDatabase.StringSet 方法如下所示

    public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry = default(TimeSpan?), When when = When.Always, CommandFlags flags =
    CommandFlags.None)
  • KeyValue 是显而易见的。
  • Expiry - 这是一个可选的时间间隔,因此我们需要使用能够处理过期的内存缓存。
  • When - 这允许您仅在字符串已存在或不存在时进行设置。
  • Flags - 这允许您指定 Redis 集群的方面,与此无关。

为了存储字符串,我们使用 System.Runtime.Caching.MemoryCache - 这允许自动使键过期。因此,我们的 StringSet 方法看起来如下

    public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry = default(TimeSpan?), When when = When.Always, CommandFlags flags = CommandFlags.None)
    {
        if (when == When.Exists && !_cache.Contains(key))
        {
            //We're only supposed to cache when the key already exists.
            return;
        }

        if (when == When.NotExists && _cache.Contains(key))
        {
            //We're only supposed to cache when the key doesn't already exist.
            return;
        }

        //Remove it from the memorycache before re-adding it (the expiry may have changed)
        _memCache.Remove(key);

        CacheItemPolicy policy = new CacheItemPolicy()
        {
            AbsoluteExpiration = DateTime.UtcNow.Add(expiry.Value)
        };

        _memCache.Add(key, o, policy);

	 //Forward the request on to set the string in Redis
        return _redisDb.StringSet(key, value, expiry, when, flags);
    }

StringGet 随后可以读取内存缓存,然后再尝试从 Redis 中检索

    public RedisValue StringGet(RedisKey key, CommandFlags flags = CommandFlags.None)
    {
        var cachedItem = _memCache.Get(key);
        if (cachedItem.HasValue)
        {
            return cachedItem;
        }
        else
        {
            var redisResult = _redisDb.StringGet(key, flags);
            //Cache this key for next time
            _memCache.Add(key, redisResult);
            return redisResult;
        }
    }

Redis 支持许多字符串操作。在每种情况下,我们的实现都必须决定是更新内存中的值,还是完全使其失效。总的来说,失效更好。这是因为否则我们会冒着不必要地引入许多 Redis 操作的复杂性的风险。

  • StringAppend - 这是一个非常简单的操作,如果内存中的字符串存在,则进行追加,而不使其失效。
  • StringBitCount、StringBitOperation、StringBitPosition - 操作在 Redis 中完成,不需要内存的参与。
  • StringIncrement、StringDecrement、StringSetBit、StringSetRange - 在将操作转发到 Redis 之前,会使内存中的字符串失效。
  • StringLength - 如果字符串在内存中,则返回其长度。否则,从 Redis 获取。

Set

集合的操作稍微复杂一些。SetAdd 遵循以下模式

  1. 检查给定键的 MemoryCache 中是否存在 HashSet<RedisValue>
    • 如果不存在,则创建它。
  2. 将每个 Redis 值添加到集合中。

向集合添加和删除值相当简单。SetMove 是一个 SetRemove 后面跟着一个 SetAdd,所以这也很简单。

许多其他集合请求都可以被拦截进行缓存。例如

  • SetMembers 返回一个集合的所有成员,因此其结果可以存储在内存中。
  • SetContains、SetLength 可以在检查 Redis 之前检查内存中的集合。
  • SetPop 从 Redis 集合中弹出一个项,然后在内存中移除该项(如果存在)。
  • SetRandomMember 从 Redis 中检索一个随机成员,然后在返回之前将其缓存到内存中。
  • SetCombine、SetCombineAndStore - 不需要内存的参与。
  • SetMove - 从内存集合中移除一个项,添加到另一个内存集合中,然后转发到 Redis。

哈希

哈希相对简单,内存表示是简单的 Dictionary<string,RedisValue>。事实上,实现与 String 类型非常相似。

基本操作如下

  1. 如果哈希在内存中不可用,则创建一个空的 Dictionary<string,RedisValue> 并存储它。
  2. 如果可能,在内存字典上执行操作。
  3. 如果需要,将请求转发到 Redis。
    • 缓存结果。

哈希操作大致实现如下

  • HashSet - 将值存储在键处的字典中,然后转发到 Redis。
  • HashValues、HashKeys、HashLength - 没有内存实现。
  • HashDecrement、HashIncrement、HashDelete - 从字典中移除值并转发到 Redis。
  • HashExists - 如果值在内存中,则返回 true。否则,转发到 Redis。
  • HashGet - 尝试从内存中获取。否则,转发到 Redis 并缓存结果。
  • HashScan - 从 Redis 中检索结果并添加到内存缓存中。

有序集合

有序集合是到目前为止最复杂的数据类型,需要尝试在内存中进行缓存。该技术涉及使用“不连续的有序集合”作为内存缓存数据结构。这意味着,每当本地缓存“看到”有序集合的一小部分时,这一小部分就会被添加到“不连续的”有序集合中。如果稍后请求有序集合的某个子集,则首先检查不连续的有序集合。如果它完整地包含该子集,就可以知道没有遗漏的条目。

内存集合按分数排序;而不是按值排序。有可能扩展实现,使其也保持一个按值排序的不连续有序集合,但这目前尚未实现。

这些操作利用内存中的不连续集合,如下所示

  • SortedSetAdd - 值以“非连续”的方式添加到内存集合中 - 即,我们无法知道它们在分数方面是否相关。
  • SortedSetRemove - 该值从内存和 Redis 中移除。
  • SortedSetRemoveRangeByRank - 整个内存集合都被使**无效**。
  • SortedSetCombineAndStore、SortedSetLength、SortedSetLengthByValue、SortedSetRangeByRank、SortedSetRangeByValue、SortedSetRank - 请求直接传递给 Redis。
  • SortedSetRangeByRankWithScores、SortedSetScan - 数据从 Redis 请求,然后以“非连续”的方式缓存。
  • SortedSetRangeByScoreWithScores - 这是最“可缓存”的函数,因为分数是按顺序返回的。检查缓存,如果缓存可以处理请求,则返回。否则,发送 Redis 请求,然后分数被缓存为内存中的连续数据。
  • SortedSetRangeByScore - 如果可能,从缓存中检索数据,否则从 Redis 中检索。如果从 Redis 中检索,则不缓存,因为分数未返回。
  • SortedSetIncrement、SortedSetDecrement - 更新内存中的数据并将请求转发到 Redis。
  • SortedSetScore - 如果可能,从内存中检索值,否则发送 Redis 请求。

有序集合的复杂性是双重的 - 构建一个已知子集(即构建不连续集合)的内存表示的固有难度,以及需要实现的各种 Redis 操作的数量。通过仅为涉及分数的操作实现有意义的缓存,复杂性在一定程度上得到了限制。但是,对所有内容进行严格的单元测试是绝对必需的!

列表

列表类型不能轻易地在内存中缓存。这是因为操作通常涉及列表的“头部”或“尾部”,这似乎很简单,直到我们考虑到我们无法确定内存中的列表是否包含与 Redis 中的列表相同的头部或尾部。这可能可以通过处理 keyspace 通知来解决,但目前尚未实现。

处理来自其他客户端的更新

到目前为止,我们一直假设只有一个客户端连接到 Redis 数据库,但当然可能有多个客户端连接。在这种情况下,一个客户端可能在内存中缓存了数据,而另一个客户端更新了该数据 - 这将导致客户端 1 处于无效状态。只有一种方法可以处理这种情况 - 即所有客户端都必须通信。恰好,Redis 提供了一种以其 pub/sub 通知形式的通信机制。这些机制在此项目中以两种方式使用

Keyspace 通知
当键在 Redis 中更改时,这些会自动发布的通知。

自定义发布的通知
这些是由作为客户端缓存机制一部分实现的通知层发布的通知。其目的是增强 Redis keyspace 通知,使其包含额外的信息 - 足以使缓存中特定的小部分失效。

使用多个客户端是缓存“问题”的主要来源。请参阅“风险”。

风险/问题

Keyspace 通知未保证送达

Redis 通知的一个问题是它没有保证送达。这意味着存在通知丢失的风险,缓存中留下无效数据。这是本项目的主要风险。幸运的是,这种情况并不经常发生;但使用一个以上的客户端时,这绝对是一个需要管理的风险。
解决方案:使用客户端之间的保证消息送达机制。显然,这目前并未实现。请参阅替代解决方案...
替代解决方案:仅在短时间内缓存内存,例如 1 小时。这样,一小时内反复访问的所有数据都将得到加速,如果任何通知丢失,则数据仅在有限时间内过时。调用数据库的 Dispose 并重新实例化它以清除它。

供一个客户端使用,供所有客户端使用

当一个客户端使用此缓存层时,所有客户端都必须使用它,以便它们能够互相通知数据更改。

内存数据无限制

目前,尽可能地缓存所有内容,这可能会导致内存耗尽!在我的用例中,这不是一个风险。但对其他人来说可能并非如此。

摘要

正如他们所说,计算机科学中只有 10 个难题 - 缓存无效、命名以及二进制... 然而,我认为这个项目在许多情况下可能有用。的确,许多人正试图在访问 Redis 之前将他们的数据缓存在内存中,所以我确信这可以在其他地方派上用场。请访问 GitHub 页面浏览代码 - 也许还会做出贡献!

© . All rights reserved.