Instagram 最近在他们的工程博客上发布了一篇关于缓存值 Promise 化的文章。其理念是,在缓存未命中时,获取缺失值需要一段时间,这会导致对缓存应该保护的基础 DBMS 造成冲击。冲击包括多个并行请求,这些请求在缓存未命中时,会触发多次相同工作的实例来填充缓存(见下文)。
在他们的文章中,Instagram 的 Nick Cooper 展示了在缓存中存储一个虚拟值(即 Promise)的理念,以向竞争请求者发出信号,表示有人正在准备数据,以便他们知道等待,而不是不停地访问 DBMS。 这是我所指的文章,它还在 Hacker News 上收到了 一些评论。
这个想法并不新鲜(它是使用读写缓存开箱即用的功能之一,因为它们透明地处理数据库),但我认为它非常好,值得讨论。在这篇文章中,我将分享这种方法的简单实现,以及如何获得比(读写)缓存通常提供的更多益处。
在详细介绍我的实现的工作原理之前,这里概述了我在将这个用例映射到 Redis 操作后能够实现的目标。我的实现
我的实现主要依赖于三个 Redis 功能:键过期 (TTL)、原子操作 (SET NX) 和发布/订阅。更一般地说,我充分利用了我在 之前的文章 中解释的原理。
共享状态导致协调,反之亦然。
Redis 非常了解这一点,这就是为什么使用它构建这种抽象非常容易的原因。在这种情况下,发布/订阅消息功能有助于我们将锁定机制绑定在一起,以轻松创建真正的跨网络 Promise 系统。
我将这种模式的实现称为 Redis MemoLock。在较高层次上,它的工作原理如下
在实践中,该算法略微复杂一些,这样我们就可以正确地进行并发处理并处理超时(无法过期的锁/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 秒内运行此程序的两个实例,"缓存未命中!"只会显示一次,无论您的第一次执行是否已经完成(即该值已被缓存)或仍在计算(在这种情况下,示例代码正在休眠而不是做有用的工作)。
两个主要原因
这让我们得出了我将我的解决方案称为 MemoLock 的原因。如果您将 Promise 化缓存的理念推广到不仅缓存查询,还缓存任何(可序列化)函数调用的(纯)输出,那么您就是在谈论记忆化(而不是记忆)。如果您从未听说过记忆化,阅读更多关于它的信息 - 这是一个有趣的概念。
Instagram 描述的模式并不新鲜,但值得讨论。更好的缓存可以帮助许多用例,而我们作为行业,并不总是完全熟悉 Promise 的概念,尤其是在单个进程范围之外。 尝试在 GitHub 上试用代码,如果您喜欢它,请 告诉我。