到目前为止,每种缓存模式都接受缓存未命中会发生:一个键过期,一个请求未命中,然后有人付出从数据库重新加载的代价。Refresh-ahead 直接解决了这个问题。它不是等待一个热点键过期后重新加载(同时让用户等待),而是在该键过期前在后台刷新它,因此对重要数据而言缓存始终保持温暖。这是一种更高级的模式,适用于特定场景:可预测、频繁访问的数据,且你永远不希望用户遭遇缓存未命中。

这是 Redis 大师课的第 12 部分,接续 write-behind

过期后重新加载的问题

在 cache-aside 模式中,一个缓存键存活到其 TTL 后过期。下一个请求会未命中,必须同步从数据库重新加载,因此那个不幸的请求会变慢。对于大多数数据而言这没问题,偶尔出现的慢请求是合理的代价。但对于非常热的键(首页的精选内容、热门产品、每个请求都会读取的配置),这种周期性的慢速重新加载会不断发生,每次都让真实用户等待数据库。更糟的是,如果大量请求同时命中已过期的键,它们会一起重新加载,形成“踩踏”。

Refresh-ahead 的洞见是:对于你知道会被再次请求的数据,不要等待它过期。在它仍然有效时就主动在后台刷新它,这样请求始终命中温暖的缓存。

Refresh-ahead 的工作原理

该模式在 TTL 用完前、在后台刷新一个键,因此被提供的值永远不会陈旧到在用户请求下过期。有几种实现方式。

常见的应用层做法是:在提供缓存值时,检查它距离过期还有多久,如果在刷新阈值内,则触发异步刷新,同时返回当前(仍有效)值:

async function getWithRefreshAhead(key, ttl, refreshThreshold) {
  const remaining = await redis.ttl(key);
  const value = await redis.get(key);

  if (value && remaining < refreshThreshold) {
    // still valid, but expiring soon: refresh in the background, don't wait
    queueRefresh(key, ttl);
  }
  if (value) return JSON.parse(value);      // return current value immediately

  return await loadAndCache(key, ttl);       // cold miss fallback
}

Enter fullscreen mode Exit fullscreen mode

用户始终能立即从温暖的缓存中获得响应,刷新在带外进行。键的值在即将过期前被更新,因此对于热点键,真正的未命中(及其同步重新加载)很少发生。

另一种方法是定时方式:后台作业按计时器定期刷新一组已知的热点键,与请求无关。这种方式适合固定的关键键集合(站点配置、精选列表),你希望无论流量如何都保持它们温暖。

它的代价

Refresh-ahead 并非免费,其代价解释了它何时值得使用。你会刷新那些可能不会被再次请求的键,进行投机性的工作,因此你在为可能没人需要的数据花费数据库查询。而且它更复杂:你需要后台刷新机制、阈值逻辑,并注意避免同时多次刷新同一个键。

这种投机性工作的代价就是为什么 refresh-ahead 是针对性的,而不是默认做法。将它应用于每个键会用主动刷新淹没数据库,针对的是冷数据。它只对真正热点且可预测的键有回报,因为刷新工作几乎总是有用的,因为该键确实会被再次请求。

何时使用它

Refresh-ahead 适合狭窄但有价值的场景:

  • 不断被访问的热点键:内容、配置或大多数请求关键路径上的数据,未命中会伤害许多用户。
  • 可预测的访问:你可以自信地说该数据很快会被再次请求,因此主动刷新不会被浪费。
  • 计算成本高的值:当重新加载代价高昂(繁重的查询或计算)时,避免同步未命中尤其值得。

对于很少被访问的键的长尾来说,它是多余的,因为 cache-aside 的按需加载总体上更便宜,因为那些键中的大多数在过期前不会被再次请求。

与其他模式结合

Refresh-ahead 不是 cache-aside 的替代品,而是在其上的针对性增强。一个典型的设置对大部分缓存使用 cache-aside,并为少量已识别的热点键添加 refresh-ahead。你在所有地方获得 cache-aside 的简单性和优雅降级,同时在最需要的地方获得 refresh-ahead 的避免未命中能力。它还直接帮助解决踩踏问题:在一个热点键过期前主动刷新它,意味着它在负载下永远不会过期,因此同时未命中的群体永远不会形成。

Refresh-ahead 是让特定热点数据永久保持温暖的模式。在键过期前在后台刷新它们,这样用户永远不必等待重新加载,接受你在做投机性工作,并且只将其应用于可预测、高流量的键,在那里这些工作能得到回报。对于那些键,它将周期性的慢速重新加载转变为始终快速的响应。

这涵盖了核心缓存模式。接下来,我们解决它们共有的问题:缓存失效,缓存真正困难的部分,以及保持缓存数据正确的策略。

要点

  • Refresh-ahead 在热点键过期前在后台刷新它们,因此用户永远不必为同步重新加载买单。
  • 实现方式是在提供的键接近过期阈值时刷新,或者为已知的热点键安排刷新。
  • 代价是投机性工作:你刷新可能不会被再次请求的键,因此它只对可预测的热点数据有回报。
  • 将其应用于少量热点、可预测、计算成本高的键,而不是很少被访问数据的长尾。
  • 它建立在 cache-aside 之上,并通过确保热点键在负载下永远不会过期来帮助防止踩踏。

常见问题

什么是 refresh-ahead 缓存?

一种模式,它在缓存键过期前主动在后台刷新它们,因此频繁访问的数据保持温暖,请求永远不会遇到同步重新加载。它用一些投机性工作来换取避免热点键的缓存未命中。

Refresh-ahead 与 cache-aside 有何不同?

Cache-aside 只在一个键过期且请求未命中后重新加载它,因此那个请求会变慢。Refresh-ahead 在热点键过期前在后台重新加载它们,因此用户始终命中温暖的缓存。Refresh-ahead 通常在 cache-aside 之上针对选定的键进行分层。

我应该何时使用 refresh-ahead?

对于一小部分处于关键路径上的热点、可预测的键(站点配置、精选内容、热门项目),尤其是当重新加载它们代价高昂时。对于很少被访问的键不值得,因为主动刷新工作会被浪费。

Refresh-ahead 的缺点是什么?

它做投机性工作,刷新可能不会被再次请求的键,如果应用于冷数据则会浪费数据库查询。它也更复杂,需要后台刷新逻辑,并注意避免冗余刷新。

Refresh-ahead 是否有助于解决缓存踩踏?

是的。通过在一个热点键过期前刷新它,该键在负载下永远不会达到过期状态,因此当一个热门键过期时发生的多次同时未命中的踩踏就不会形成。

延伸阅读


本文最初发布于 amanksingh.com/blog/redis-refresh-ahead

关于作者

Aman Kumar Singh 是印度诺伊达的一名团队负责人兼高级软件工程师,撰写关于全栈工程、系统设计和使用 TypeScript、Next.js、NestJS、PostgreSQL 和 Redis 的生产级 SaaS 的文章。