Cache-aside 是你最常使用的缓存模式,可能你已经在使用它却不知道它的名字。应用程序首先检查缓存,如果未命中,则从数据库加载数据并由自己填充缓存。它很简单,将控制权交给应用程序,并且在缓存不可用时也能优雅地降级。很好地理解它(包括它可能引发的两个微妙的 bug)是本模块所有其他缓存模式的基础。
这是 Redis 大师课的第 9 部分,也是缓存模块的开始,紧随 键过期与 TTL 之后。
模式
Cache-aside(也称为延迟加载)由应用程序直接管理缓存。在一次读取操作中:
- 检查 Redis 中是否存在该键。
- 如果存在(命中),则返回该值。
- 如果不存在(未命中),则从数据库读取数据,将结果写入 Redis 并设置 TTL,然后返回该值。
function getUser(id) {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached); // hit
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]); // miss
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 300); // populate, 5-min TTL
return user;
}
Enter fullscreen mode Exit fullscreen mode
这就是整个模式。缓存是延迟填充的,只有实际被请求的数据才会被缓存,因此你永远不会缓存无人读取的内容。它还具有弹性:如果 Redis 宕机,每一次请求都将变成“未命中”并回退到数据库,应用程序仍能继续工作(速度较慢,但仍在工作)。这种优雅的降级是 cache-aside 成为默认模式的一个重要原因。
写入:失效而非更新
在写入端,cache-aside 的标准规则是先更新数据库,然后使缓存条目失效,而不是更新缓存:
function updateUser(id, data) {
await db.query('UPDATE users SET ... WHERE id = $1', [id]);
await redis.del(`user:${id}`); // invalidate; next read re-populates
}
Enter fullscreen mode Exit fullscreen mode
删除该键意味着下一次读取将未命中,并从最新的数据库值重新填充缓存。这比将新值写入缓存更简单、更安全,因为它避免了一类竞争条件,即一个陈旧的写入在更新的写入之后落入缓存。写入时删除是 cache-aside 读取的常规配套做法。
需要了解的两个 bug
Cache-aside 很简单,但会引发两个众所周知的问题。
陈旧缓存竞争。 考虑以下情况:请求 A 读取缓存,未命中,并从数据库加载旧值。在 A 将其写入缓存之前,请求 B 更新了数据库并删除了(仍为空的)缓存键。然后 A 将其现在已过时的值写入缓存,该值将一直存在直到 TTL 过期。这个时间窗口很小,但在高负载下是真实存在的。主要防御措施是设置合理的 TTL(以便限制陈旧程度),以及在正确性要求较高时使用我们稍后将介绍的更谨慎的模式。对于大多数缓存来说,较短的 TTL 就足够了,因为错误的值会很快自行纠正。
惊群效应(缓存雪崩)。 当一个热门键过期时,许多并发请求会同时未命中,并同时访问数据库以重新填充它,从而在键最热门的时候激增负载。这很常见,值得在本模块稍后专门写一篇文章。快速缓解方法是向 TTL 添加抖动,以使键不同时过期,以及使用锁,以便只有一个请求重建该键,而其他请求等待。
选择缓存什么以及缓存多久
两个决定会影响 cache-aside 的设置。缓存什么:读取远多于写入且获取成本高昂的数据(跨表连接的用户个人资料、计算出的仪表盘、很少更改的配置)。缓存每次读取都会更改的数据,或获取成本微不足道的数据,只会增加复杂性而没有收益。
缓存多久(TTL):需要在新鲜度和命中率之间进行权衡。较短的 TTL 能保持数据新鲜,但未命中率更高;较长的 TTL 能更好地缓存,但提供的数据更陈旧。应根据数据可以容忍的陈旧程度来匹配:对于快速变化的数据,TTL 以秒为单位;对于稳定的数据,TTL 以分钟或小时为单位。当精确的新鲜度很重要时,可以将较长的 TTL 与写入时的显式失效配对使用,这样缓存会在更改时立即更新,而 TTL 只是一个后备措施。
为什么 cache-aside 是默认模式
Cache-aside 因其简单和弹性而胜出。应用程序拥有逻辑,因此不需要配置特殊的缓存基础设施。它只缓存正在使用的数据。它通过回退到数据库来应对缓存中断。而且它可以与其他模式组合:read-through、write-through 等模式本质上就是 cache-aside,只是将缓存逻辑移到了不同的层或使其更复杂。
需要内化的模式是:检查缓存,未命中时加载并填充,写入时失效。添加 TTL 作为陈旧度后备措施,并注意陈旧写入竞争和雪崩效应。这涵盖了实际缓存的大部分情况,而本模块中的所有其他内容都建立在此基础上。
接下来,我们将介绍 read-through 和 write-through 缓存,它们将缓存逻辑从应用程序移到缓存层本身,以及这如何改变权衡。
要点
- Cache-aside:首先检查 Redis;未命中时,从数据库加载数据,用 TTL 填充缓存,然后返回。
- 它延迟填充(仅缓存请求的数据)并优雅地降级,因为缓存中断只会变成未命中并访问数据库。
- 在写入时,更新数据库并删除缓存键,以便下一次读取时重新填充,而不是写入新值。
- 注意两个 bug:陈旧写入竞争(由 TTL 限制)和热门键过期时的缓存雪崩。
- 缓存读取密集且获取成本高昂的数据,并根据数据可以安全容忍的陈旧程度设置 TTL。
常见问题
什么是 cache-aside 模式?
一种缓存方法,其中应用程序首先检查缓存,未命中时,从数据库加载数据,将其存储在带有 TTL 的缓存中,然后返回。应用程序直接管理缓存,延迟填充仅请求的数据。
写入时应该更新还是删除缓存?
删除(失效)缓存键,让下一次读取从数据库重新填充。删除可以避免竞争条件,即陈旧的值在更新的值之后被写入缓存,而直接更新缓存可能会导致这种情况。
如果 Redis 宕机,cache-aside 会发生什么?
应用程序继续工作。每次读取都变成缓存未命中,并回退到数据库,因此响应速度较慢但正确。这种优雅的降级是 cache-aside 的一个关键优势。
什么是缓存雪崩?
当一个热门的缓存键过期时,许多并发请求同时未命中,并同时访问数据库以重建它,从而激增负载。通过 TTL 抖动使键不同时过期,并使用锁使只有一个请求重建该键,以缓解此问题。
如何选择缓存 TTL?
根据数据可以容忍的陈旧程度,在新鲜度和命中率之间取得平衡:对于快速变化的数据,使用较短的 TTL;对于稳定的数据,使用较长的 TTL。当精确的新鲜度很重要时,请使用较长的 TTL 加上写入时的显式失效,以便缓存立即更新。
延伸阅读
本文最初发布于 amanksingh.com/blog/redis-cache-aside。
关于作者
Aman Kumar Singh 是印度诺伊达的一名团队负责人和高级软件工程师,撰写关于全栈工程、系统设计以及使用 TypeScript、Next.js、NestJS、PostgreSQL 和 Redis 构建生产级 SaaS 的文章。
- 作品集: amanksingh.com
- GitHub: amansingh1501
- LinkedIn: amansingh1597
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.