点 Redis 8 已发布——并且它是开源的

了解更多

缓存、承诺和锁

Instagram 最近在其工程博客上发表了一篇关于缓存值承诺化概念的文章。其想法是,当缓存未命中时,获取缺失值需要一段时间,这可能会导致底层 DBMS(缓存本应保护的对象)出现“踩踏”现象。踩踏现象,除其他外,指的是多个并行请求在缓存未命中时触发同一工作的多个实例来填充缓存(见下文)

在他们的文章中,Instagram 的 Nick Cooper 展示了在缓存中存储一个虚拟值(即承诺)以向竞争的请求者发出信号,表明有人正在准备数据,这样他们就知道应该等待而不是反复访问 DBMS。我引用的文章是这篇,它还在 Hacker News 上收到了一些评论

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

你好 Redis

在深入探讨我的实现细节之前,这里概述一下我在将此用例映射到 Redis 操作后所能达到的成果。我的实现

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

我的实现主要依赖于 Redis 的三个功能:键过期 (TTL)、原子操作 (SET NX) 和 Pub/Sub。更广泛地说,我很好地利用了我在上一篇文章中解释过的原则

共享状态产生协调,反之亦然。

Redis 非常理解这一点,这就是为什么使用它构建这种抽象如此容易的原因。在这种情况下,Pub/Sub 消息传递功能帮助我们将锁定机制结合起来,轻松创建真正的跨网络承诺系统。

介绍 MemoLock

我将此模式的实现称为 Redis MemoLock。从高层次上看,它的工作原理如下

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

实际上,该算法稍微复杂一些,以便我们能够正确处理并发并处理超时(在分布式环境中,无法过期的锁/承诺几乎毫无用处)。我们的命名方案也稍微复杂一些,因为我选择为每个资源提供一个命名空间,以允许多个独立服务使用同一集群而不会有键名冲突的风险。除此之外,解决这个问题所需的复杂性并不多。

代码

你可以在 GitHub 上找到我的代码。我发布了一个 Go 实现,很快我将结合我在 NDC Oslo 即将发表的演讲添加一个 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 秒内运行此程序的两个实例,“Cache miss!”(缓存未命中!)只会显示一次,无论你的第一次执行是否已经完成(即值已缓存),还是仍在计算中(在本示例代码中,是休眠而不是做有用的工作)。

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

两个主要原因

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

这就引出了我将我的解决方案命名为 MemoLock 的原因。如果你将缓存承诺化的想法推广到不仅缓存查询,还缓存(纯)函数调用的任何(可序列化)输出,你就是在谈论“记忆化”(memoization,而不是 memorization)。如果你从未听说过记忆化,请阅读更多相关信息——这是一个有趣的概念。

总结

Instagram 描述的模式并不令人惊讶地新颖,但值得讨论。更好的缓存可以帮助大量的用例,而且作为一个行业,我们并不总是完全熟悉承诺的概念,尤其是在单个进程范围之外的情况下。在 GitHub 上试试这段代码,如果喜欢,请告诉我