dot 快速的未来即将来到您所在的城市。

加入我们在 Redis 发布会

缓存、Promise 和锁

Instagram 最近在他们的工程博客上发布了一篇关于缓存值 Promise 化的文章。其理念是,在缓存未命中时,获取缺失值需要一段时间,这会导致对缓存应该保护的基础 DBMS 造成冲击。冲击包括多个并行请求,这些请求在缓存未命中时,会触发多次相同工作的实例来填充缓存(见下文)。

在他们的文章中,Instagram 的 Nick Cooper 展示了在缓存中存储一个虚拟值(即 Promise)的理念,以向竞争请求者发出信号,表示有人正在准备数据,以便他们知道等待,而不是不停地访问 DBMS。 这是我所指的文章,它还在 Hacker News 上收到了 一些评论

这个想法并不新鲜(它是使用读写缓存开箱即用的功能之一,因为它们透明地处理数据库),但我认为它非常好,值得讨论。在这篇文章中,我将分享这种方法的简单实现,以及如何获得比(读写)缓存通常提供的更多益处。

你好,Redis

在详细介绍我的实现的工作原理之前,这里概述了我在将这个用例映射到 Redis 操作后能够实现的目标。我的实现

  1. 跨不同的语言工作。客户端只需要一个 Redis 客户端库和对正在使用的键命名方案的了解。然后它可以使用任何其他客户端生成/解析 Promise,跨进程和网络边界
  2. 在集群部署中有效地扩展。Redis 可以独特地提供这种优势,而无需轮询或其他浪费的模式。
  3. 有效地平衡了避免无用工作和保持可扩展性之间的权衡。作为分布式系统的一部分,更强的保证需要更多协调。

我的实现主要依赖于三个 Redis 功能:键过期 (TTL)、原子操作 (SET NX) 和发布/订阅。更一般地说,我充分利用了我在 之前的文章 中解释的原理。

共享状态导致协调,反之亦然。

Redis 非常了解这一点,这就是为什么使用它构建这种抽象非常容易的原因。在这种情况下,发布/订阅消息功能有助于我们将锁定机制绑定在一起,以轻松创建真正的跨网络 Promise 系统。

介绍 MemoLock

我将这种模式的实现称为 Redis MemoLock。在较高层次上,它的工作原理如下

  1. 作为服务实例,当我们需要获取 foo 时,我们在 Redis 中查找它。如果找到,我们就完成了。
  2. 如果键不存在,我们尝试使用 SET NX 创建一个 lock:foo 键。NX 选项确保如果存在并发请求,只有一个请求能够成功设置键(值并不重要)。
  3. 如果我们能够获取锁,那么我们的任务就是去获取值。完成后,我们将它保存到 Redis,并在一个名为 notif:foo 的发布/订阅频道上发送一条消息,以通知所有其他可能正在等待的客户端,该值现在可用。
  4. 如果我们无法获取锁,我们只需订阅名为 notif:foo 的发布/订阅频道,并等待成功执行该操作的实例通知我们该值现在可用(如上一步所述)。

在实践中,该算法略微复杂一些,这样我们就可以正确地进行并发处理并处理超时(无法过期的锁/Promise 在分布式环境中几乎没有用)。我们的命名方案也略微复杂一些,因为我选择为每个资源提供一个命名空间,以允许多个独立的服务使用同一个集群,而不会造成键名冲突。除此之外,解决这个问题真的不需要太复杂。

代码

您可以在 GitHub 上找到我的代码。 我发布了一个 Go 实现,很快我将与 我在 NDC 奥斯陆即将举行的演讲 一起添加一个 C# 实现(将来可能还会添加更多语言)。以下是 Go 版本中的代码示例

package main

import (
    "fmt"
    "time"
    "github.com/go-redis/redis"
    "github.com/kristoff-it/redis-memolock/go/memolock"
)

func main () {
    // First, we need a redis connection pool:
    r := redis.NewClient(&redis.Options{
        Addr:     "localhost:6379", // use default Addr
        Password: "",               // no password set
        DB:       0,                // use default DB
    })

    // A memolock instance handles multiple resources of the same type,
    // all united by the same tag name, which will be then used as a key
    // prefix in Redis.
    queryResourceTag := "likes"
    queryMemoLock, _ := memolock.NewRedisMemoLock(r, queryResourceTag, 5 * time.Second)
    // This instance has a 5 second default lock timeout:
    // Later in the code you can use the memolock to cache the result of a function and
    // make sure that multiple requests don't cause a stampede.

    // Here I'm requesting a queryset (saved in Redis as a String) and providing  
    // a function that can be used if the value needs to be generated:
    resourceID := "kristoff"
    requestTimeout := 10 * time.Second
    cachedQueryset, _ := queryMemoLock.GetResource(resourceID, requestTimeout, 
        func () (string, time.Duration, error) {
            fmt.Println("Cache miss!\n")
            
            // Sleeping to simulate work. 
            <- time.After(2 * time.Second)

            result := fmt.Sprintf(`{"user":"%s", "likes": ["redis"]}`, resourceID)
            
            // The function will return a value, a cache time-to-live, and an error.
            // If the error is not nil, it will be returned to you by GetResource()
            return result, 5 * time.Second, nil
        },
    )

    fmt.Println(cachedQueryset)
    fmt.Println("Launch the script multiple times, see what changes. Use redis-cli to see what happens in Redis.")
}

如果您在 5 秒内运行此程序的两个实例,"缓存未命中!"只会显示一次,无论您的第一次执行是否已经完成(即该值已被缓存)或仍在计算(在这种情况下,示例代码正在休眠而不是做有用的工作)。

为什么这比开箱即用的解决方案更好?

两个主要原因

  1. MemoLock 不仅可以保护 DBMS,还可以保护任何难以生成的资源。例如,PDF 生成(您可以将文件存储在 CDN 中,并将链接存储在 Redis 中,使用 MemoLock),运行 AI 模型,或者您可能拥有的任何其他昂贵但在线的计算。
  2. 即使我们想将自己限制在查询集缓存中, CQRS 教会我们,数据在 DBMS 中的存储方式并不一定是特定查询最有效的格式。您不希望为每个请求重新转换数据,并且执行转换的代码应该是您服务的一部分,而不是存储过程的一部分,除非您有充分的理由这样做。

这让我们得出了我将我的解决方案称为 MemoLock 的原因。如果您将 Promise 化缓存的理念推广到不仅缓存查询,还缓存任何(可序列化)函数调用的(纯)输出,那么您就是在谈论记忆化(而不是记忆)。如果您从未听说过记忆化,阅读更多关于它的信息 - 这是一个有趣的概念。

总之

Instagram 描述的模式并不新鲜,但值得讨论。更好的缓存可以帮助许多用例,而我们作为行业,并不总是完全熟悉 Promise 的概念,尤其是在单个进程范围之外。 尝试在 GitHub 上试用代码,如果您喜欢它,请 告诉我