缓存最佳实践

如何应用缓存

缓存适用于各种各样的使用案例,但要充分利用缓存,需要进行一定的规划。在决定是否缓存一段数据时,请考虑以下问题:

  • 使用缓存值是否安全? 同一段数据在不同的上下文中可能具有不同的一致性要求。例如,在线结账期间,您需要物品的确切价格,因此不适合使用缓存。但在其他页面上,价格晚几分钟更新不会给用户带来负面影响。
  • 对于该数据而言,缓存是否是高效的? 某些应用程序会生成不适合缓存的访问模式;例如,扫描频繁变化的大型数据集的键空间。在这种情况下,保持缓存更新可能会抵消缓存带来的所有优势。
  • 数据结构是否适合缓存? 简单地缓存数据库记录通常足以提供显著的性能优势。但在其他一些时候,数据最好以多条记录组合在一起的格式进行缓存。缓存以简单的键值形式存储,因此您可能还需要以多种不同格式缓存数据记录,以便按记录中的不同属性进行访问。

您不必预先做出所有决定。您可以逐步扩展对缓存的使用,但请在决定是否缓存给定数据时牢记这些准则。

缓存设计模式

惰性缓存

惰性缓存(也称作惰性填充或预留缓存)是最常见的缓存形式。所有良好的缓存策略都应以“惰性”为基础。其基本思想是仅在应用程序实际请求对象时才填充缓存。整个应用程序流程如下所示:

  1. 应用程序收到数据查询,例如头 10 条最新的新闻报道。
  2. 应用程序检查缓存,查看对象是否在缓存中。
  3. 如果是 (缓存命中),则返回缓存的对象,调用流程结束。
  4. 如果不是 (缓存未命中),则从数据库查询对象。填充缓存,返回对象。

与其他方法相比,这种方法具有以下几个优势:

  • 缓存仅包含应用程序实际请求的对象,有助于使缓存大小维持在可管理范围内。新对象仅在需要时添加至缓存。这样,当缓存满时,您可以让所用的引擎移出最少访问的键 (引擎默认行为),从而被动地管理缓存内存。
  • 新缓存节点上线 (例如应用程序扩展时) 时,惰性填充方法会在应用程序首次请求对象时将这些对象自动添加至新缓存节点。
  • 缓存过期很容易处理:只需删除缓存的对象,下次请求新对象时,从数据库获取对象。
  • 惰性缓存模式获得了广泛接受,许多 Web 和应用程序框架都对其提供了“开箱即用”的支持。

以下是用 Python 伪代码演示的惰性缓存示例:

# Python

def get_user(user_id):

    # Check the cache

    record = cache.get(user_id)

    if record is None:       

       # Run a DB query       

       record = db.query("select * from users where id = ?",user_id)

       # Populate the cache

       cache.set(user_id, record)

    return record

# App code

user = get_user(17)

您可以在许多流行的编程框架中找到封装了此模式的库。不管使用何种编程语言,总体做法是一样的。

只要应用程序符合“经常读取但很少写入数据”的模式,您就应该采用惰性缓存策略。例如,在典型的 Web 或移动应用程序中,用户个人资料很少发生变化,但需要在应用程序的许多地方访问。用户一年可能只更新个人资料几次,但根据用户类型,其个人资料每天可能要被访问几十甚至上百次。如果设置了移出策略,流行的缓存技术 (例如 Memcached、Redis 等) 会自动移出不常使用的缓存键以释放内存。因此,您可以自由地应用惰性缓存,并且几乎没有缺点。

直写

在直写缓存中,缓存在数据库更新时实时更新。因此,如果用户更新了个人资料,更新后的个人资料也会推送到缓存中。如果您知道哪些数据一定会访问到,就可以借助这种方式主动避免不必要的缓存未命中情况。所有聚合类型都适合采用这种方法,例如游戏排行榜的前 100 位玩家、最热门的 10 条新闻报道、甚至是推荐内容。这些数据通常由特定的应用程序或后台作业代码更新,因此更新缓存也很简单。

直写模式也很容易通过伪代码演示:

# Python

def save_user(user_id, values):

    # Save to DB 

    record = db.query("update users ... where id = ?", user_id, values)

    # Push into cache

    cache.set(user_id, record)

    return record

# App code

user = save_user(17, {"name": "Nate Dogg"})

与惰性填充相比,这种方法有一定优势:

  • 它避免了缓存未命中,可帮助应用程序更好、更快捷地运行。
  • 它将一切应用程序延迟转移到用户更新数据,更符合用户预期。相比之下,一系列缓存未命中会给用户留下应用程序运行缓慢的印象。
  • 它简化了缓存过期。缓存始终是最新的。

但是,直写缓存也有一些缺点:

  • 可能会向缓存中填充一些实际上访问不到的多余对象。这不仅占用了额外的内存,而且未用到的项目还可能将更有用的项目“驱逐”出缓存。
  • 如果某些记录反复更新,可能导致大量缓存改动。
  • 缓存节点出现故障时,缓存中的这些对象都将丢失。您需要通过某种方法 (例如惰性填充) 将缺失对象重新填充至缓存。

显然,您可以将惰性缓存与直写缓存结合使用,以帮助解决上述问题,因为它们与数据流的两端相关联。惰性缓存在读取时捕获缓存未命中情况,而直写缓存在写入时填充数据,因此这两种方法是相辅相成的。有鉴于此,最好的做法通常是使用惰性缓存作为整个应用程序的基础,在特定情况下将直写缓存用作有针对性的优化手段。

生存时间

缓存过期可能会在短时间内演变成极其复杂的问题。在之前的示例中,我们只对单一用户记录进行操作。而在实际应用程序中,给定页面或屏幕通常会同时缓存大量不同的内容 - 个人资料数据、热门新闻报道、建议、评论等等,所有这些内容以不同方式进行更新。

遗憾的是,没有解决这一问题的“灵丹妙药”,缓存过期是计算机科学的一个重大研究课题。但有几个简单的策略可供您选用:

  • 始终向所有缓存键应用生存时间 (TTL) - 以直写缓存方式更新的缓存键除外。您可以将生存时间指定为很长的时间,例如数小时,甚至数天。这种方法能够捕获应用程序错误,例如在更新底层记录时,您忘记更新或删除给定的缓存键。最终,缓存键会自动过期并刷新。
  • 对于频繁更改的数据,例如评论、排行榜、活动流等,不要添加直写缓存或复杂的过期逻辑,只需设置较短的 TTL (几秒钟) 即可。如果某条数据库查询在生产环境中被大量访问,您只需改动几行代码就能为此查询添加 TTL 为 5 秒的缓存键。此代码可谓是一种美妙的“创可贴”,能够在您评估更优雅的解决方案时让您的应用程序保持正常运行。
  • Ruby on Rails 团队研究出了一种更新的模式 - 俄罗斯套娃缓存。在这种模式下,嵌套记录通过其自有缓存键进行管理,顶层资源就是这些缓存键的集合。假设您有一个包含用户、文章和评论的新闻网页。在这种方法中,他们中的每个都是自己的缓存键,页面则分别查询每个键。
  • 如果您不确定某个缓存键是否受到给定数据库更新与否的影响,只需删除此缓存键。底层的惰性缓存机制会在需要时刷新此键。同时,您的数据库不会比没有缓存时更糟糕。

有关缓存过期和俄罗斯套娃缓存的详细介绍,请参阅 Basecamp Signal vs Noise 博客文章“俄罗斯套娃”缓存的性能影响

移出

当内存满溢或超出缓存中的 maxmemory 设置时,就会发生移出,导致引擎选择要移出的键以管理其内存。键的选择基于所选的移出策略。

默认情况下,适用于 Redis 的 Amazon ElastiCache 将 volatile-lru 移出策略设置为您的 Redis 集群。此策略选择设置了过期 (TTL) 值的最近最少使用的键。可将其他可用的移出策略作为可配置的 maxmemory-policy 参数,以应用其他移出策略。移出策略概述如下:

allkeys-lfu:无论 TTL 设置如何,缓存都会移出最不常用的 (LFU) 键
allkeys-lru:无论 TTL 设置如何,缓存都会移出最近最少使用的 (LRU) 键
volatile-lfu:缓存从设置了 TTL 的缓存中移出最不常用的 (LFU) 键
volatile-lru:缓存从设置了 TTL 的缓存中移出最近最少使用的 (LRU) 键
volatile-ttl:缓存移出具有最短 TTL 设置的键
volatile-random:缓存随机移出具有 TTL 设置的键
allkeys-random:无论 TTL 设置如何,缓存都会随机移出键
no-eviction:缓存不移出任何键。这将阻止后续写入,直到内存释放。

选择适当的移出策略时,较好的策略是考虑您的集群存储的数据以及移出键的后果。
一般而言,基本缓存使用案例较常使用基于 LRU 的策略,但根据具体目标,您也可以使用更契合您需求的 TTL 或随机移出策略。

此外,如果遇到集群移出的情况,这通常表明您需要进行纵向扩展 (使用内存占用量更大的节点) 或横向扩展 (为集群添加节点),以容纳更多数据。但此规则有一个例外:您有意依靠缓存引擎通过移出方法管理您的键(也称作 LRU 缓存)。

惊群效应

如果许多不同的应用程序进程同时请求一个缓存键,但出现缓存未命中,随后所有应用程序进程都并行执行相同的数据库查询,此时就会发生惊群效应,也称作叠罗汉效应。此查询的代价越高,对数据库的影响就越大。如果此查询是需要对大数据集进行排名的“前 10 查询”,就可能产生极其严重的后果。

向所有缓存键添加 TTL 带来的一个问题就是会加剧上述问题。例如,假设有数百万用户在您的网站上关注了某位受欢迎的用户。即使此用户未更新其个人资料或发布任何新消息,但其个人资料缓存仍会因为 TTL 而过期。您的数据库可能突然之间遭到一系列相同查询的“围攻”。

除了 TTL 以外,在添加新缓存节点时,由于新缓存节点的内存是空的,也很可能引发惊群效应。上述两种情况都可以通过执行以下步骤预热缓存来解决:

  1. 编写一个脚本,执行与您的应用程序相同的请求。如果是 Web 应用程序,此脚本可能是查询一组 URL 的 shell 脚本。
  2. 如果您的应用程序设置为惰性缓存,则缓存未命中会导致填充缓存键,从而填满新缓存节点。
  3. 添加新缓存节点时,请在将新节点附加到应用程序前运行脚本。您的应用程序需要重新配置以将新节点添加到一致的哈希环,因此可将此脚本作为触发应用程序重新配置前的步骤插入。
  4. 如果您预计会定期添加和删除缓存节点,则只需在应用程序通过 Amazon Simple Notification Service (Amazon SNS) 收到集群重新配置事件时触发脚本运行,就能实现自动预热。

最后,广泛使用 TTL 还有一个很小的副作用。如果您持续使用相同的 TTL 时长 (例如 60 分钟),则即使进行了缓存预热,也可能会有许多缓存键同时过期。一个易于实现的策略是为 TTL 增加一些随机性:

ttl = 3600 + (rand() * 120)  /* +/- 2 minutes */ 

好消息是,通常只有大型的网站才需要担心这种层级的扩展问题。但反过来说,这真是个令人羡慕的烦恼。

缓存(几乎)一切内容

最后,虽说您应该只缓存频繁使用的数据库查询和代价高昂的计算,但应用程序的其他部分可能不会因为使用缓存而受益。实际上,内存缓存非常有用,因为从内存中检索简单缓存键比执行最优的数据库查询或远程 API 调用还要快得多。但请牢记,缓存的数据实质上是过时的数据,也就是说,有些情况不适合使用缓存,例如在线结账期间访问物品价格。您可以监测缓存未命中等统计数据,以了解缓存是否有效。

缓存技术

最流行的缓存技术在 NoSQL 数据库的内存键值类别中。内存键值存储是针对读取密集型应用程序工作负载 (例如社交网络、游戏、媒体共享和 Q&A 门户网站) 或计算密集型工作负载 (如推荐引擎) 而优化的 NoSQL 数据库。内存键值存储有两大优势 - 速度快和简单易用,这使其成为十分流行的缓存解决方案。键值存储没有复杂的查询或聚合逻辑,因而查询速度快。由于使用内存而不是较慢的磁盘,内存键值存储速度极快。此外,它还十分简单,很容易掌握和使用。市面上有各种各样的键值技术,其中许多可以用作缓存解决方案。MemcachedRedis 就是两种广受欢迎的内存键值存储解决方案。AWS 允许通过 Amazon ElastiCache 以完全托管方式运行这两种引擎。

开始使用 Amazon ElastiCache

使用像 Amazon ElastiCache 这样的完全托管服务在云中进行缓存,可以很轻松上手。它消除了设置、管理和实施缓存的复杂性,使您能够专注于能为组织创造价值的任务。立即注册 Amazon ElastiCache