Instagram 最近在其工程博客上发表了一篇关于缓存值承诺化概念的文章。其想法是,当缓存未命中时,获取缺失值需要一段时间,这可能会导致底层 DBMS(缓存本应保护的对象)出现“踩踏”现象。踩踏现象,除其他外,指的是多个并行请求在缓存未命中时触发同一工作的多个实例来填充缓存(见下文)
在他们的文章中,Instagram 的 Nick Cooper 展示了在缓存中存储一个虚拟值(即承诺)以向竞争的请求者发出信号,表明有人正在准备数据,这样他们就知道应该等待而不是反复访问 DBMS。我引用的文章是这篇,它还在 Hacker News 上收到了一些评论。
这个想法并不新鲜(它是读/写直通缓存的开箱即用功能之一,因为它们透明地处理数据库),但我认为它非常好,值得讨论。在本文中,我将分享这种方法的简单实现,以及如何从中获得比通常的 (r/w) 直通缓存更多的优势。
在深入探讨我的实现细节之前,这里概述一下我在将此用例映射到 Redis 操作后所能达到的成果。我的实现
我的实现主要依赖于 Redis 的三个功能:键过期 (TTL)、原子操作 (SET NX) 和 Pub/Sub。更广泛地说,我很好地利用了我在上一篇文章中解释过的原则
共享状态产生协调,反之亦然。
Redis 非常理解这一点,这就是为什么使用它构建这种抽象如此容易的原因。在这种情况下,Pub/Sub 消息传递功能帮助我们将锁定机制结合起来,轻松创建真正的跨网络承诺系统。
我将此模式的实现称为 Redis MemoLock。从高层次上看,它的工作原理如下
实际上,该算法稍微复杂一些,以便我们能够正确处理并发并处理超时(在分布式环境中,无法过期的锁/承诺几乎毫无用处)。我们的命名方案也稍微复杂一些,因为我选择为每个资源提供一个命名空间,以允许多个独立服务使用同一集群而不会有键名冲突的风险。除此之外,解决这个问题所需的复杂性并不多。
你可以在 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!”(缓存未命中!)只会显示一次,无论你的第一次执行是否已经完成(即值已缓存),还是仍在计算中(在本示例代码中,是休眠而不是做有用的工作)。
两个主要原因
这就引出了我将我的解决方案命名为 MemoLock 的原因。如果你将缓存承诺化的想法推广到不仅缓存查询,还缓存(纯)函数调用的任何(可序列化)输出,你就是在谈论“记忆化”(memoization,而不是 memorization)。如果你从未听说过记忆化,请阅读更多相关信息——这是一个有趣的概念。
Instagram 描述的模式并不令人惊讶地新颖,但值得讨论。更好的缓存可以帮助大量的用例,而且作为一个行业,我们并不总是完全熟悉承诺的概念,尤其是在单个进程范围之外的情况下。在 GitHub 上试试这段代码,如果喜欢,请告诉我。