Shopify使用Memcached而不是Redis缓存提升20%性能


Shopify构建了一个自定义缓存解决方案,将数据库负载减少了 15%,整体应用延迟减少了大约 20%。
 
识别问题
商店应用程序的主屏幕是最常用的功能,提供的主页提要Feed很复杂,因为除了处理来自数十家运营商的跟踪数据之外,它还需要汇总来自数百万 Shopify 和非 Shopify 商家的订单。由于各种因素,加载和合并这些数据在计算上既昂贵又非常缓慢。在我们开始这个项目之前,Shop 30%的数据库负载来自家庭订阅源。此负载不仅影响主页提要,还影响应用程序各个方面的性能。
我们四处寻找简单、直接的解决方案来解决这个问题,比如引入IdentityCache、更新我们的数据库架构和添加更多的数据库索引。经过一番调查,我们了解到我们几乎没有什么数据库级优化要做,也没有时间进行大量的代码重写。另一方面,缓存对于这种情况似乎是理想的。由于用户每天都使用主页提要以及主页提要的基于时间的排序,因此主页提要数据通常仅在最近写入后才会读取,因此非常适合某种类型的缓存。
 
寻找解决方案
由于主页提要Feed的结构,我们无法使用即插即用的缓存解决方案。我们将给定用户的主页提要视为用户购买的排序列表,其中列表可能很大(有些人经常购物!)。该列表可以通过一系列并发操作更新,包括:

  • 添加一个新订单以显示在主页上(例如,当有人从 Shopify 商店购买时)
  • 更新与订单相关的详细信息(例如,订单交付时)
  • 从列表中删除订单(例如,当用户手动归档订单时)。

为了缓存主页提要Feed ,我们需要一个系统来维护用户提要的缓存版本,同时处理提要中订单的任意更新,并保证提要订单是正确的。
由于我们处理的更新数量,使用在每次写入后失效的通读缓存模式是不可行的,因为缓存最终会失效,因此实际上几乎没有用。经过一些研究,我们没有找到现有的解决方案:
  • 写入后未失效
  • 可以在不向用户显示陈旧数据的情况下处理故障情况。

所以,我们自己建了一个。
  
Building Shop 的缓存解决方案
在引入缓存之前,当用户请求加载主页提要时,Rails 应用程序会串行执行多个数据库查询,具有很高的延迟。引入缓存后,当用户请求加载他们的主页提要时,Rails 从缓存中加载他们的主页提要,并发出更少(更快)的数据库请求。
我们现在不是在用户每次请求主页提要时查询数据库,而是在快速、分布式、水平扩展的缓存系统(我们选择 Memcached)中缓存他们的主页提要的副本。然后,如果满足某些条件,我们会在请求时从缓存而不是数据库中提供服务。为了在每次数据库更新之前保持缓存有效和正确,我们将缓存标记为“无效”以确保缓存数据在缓存和数据库不同步时不被使用。写入完成后,我们用新数据更新缓存并再次将其标记为“有效”。
 
决定使用 Memcached
在 Shopify,我们使用两种不同的缓存技术:MemcachedRedis。Redis 比 Memcached 更强大,支持更复杂的操作,存储更复杂的对象。Memcached 更简单,开销更少,更广泛地用于 Shop 内部的缓存。虽然我们使用Redis来管理队列和一些缓存,但我们不需要Redis的复杂性,所以我们选择了分布式Memcached。 
我们必须解决的主要问题是确保缓存永远不会包含过时的记录。我们通过使用直写失效策略构建缓存来最小化缓存失效的可能性,该策略在数据库写入之前使缓存失效并在成功写入后重新验证它。这就引出了下一个难题:我们如何在 Memcached 中实际存储数据并处理并发更新?
最简单的方法是在 Memcached 中为每个用户存储一个密钥,将用户映射到他们的主页。然后,在写入时,通过从缓存中驱逐键来使缓存无效,更新数据库,最后通过再次写入键来重新验证缓存。不幸的是,问题是不支持并发写入。在 Shop 的规模下,多台工作机器通常会同时处理同一用户的订单更新。使用先删除后写入的策略会引入竞争条件,从而导致缓存不正确,这是不可接受的。
为了支持并发写入,我们存储了一个额外的键/值对(挂起的写入键),用于跟踪每个用户的缓存有效性。该键存储对给定用户的主页提要的活动写入次数。每次工作机器即将写入数据库时​​,我们都会增加这个值。更新完成后,我们递减该值。这意味着当挂起的写入键为零时缓存有效。
然而,还有最后一种情况。如果机器进行数据库更新并且由于中断或异常而未能减少挂起的写入键,会发生什么情况?我们如何知道挂起的写入键是否大于零,因为当前正在进行数据库写入或进程被中断?
解决方案是引入一个在任何数据库更新之前写入的短期到期的键。如果此键存在,则我们知道有可能进行数据库更新,但如果不存在且挂起的写入键大于零,则我们知道没有发生活动的数据库写入,因此再次重新预热缓存是安全的。
def update_order(user)
  user.pending_writes += 1
  user.active_writes = true # this key expires
  yield # do update
  # if yield raises an exception and pending_writes is never decremented,
  # a background job uses the expiring active_writes key to safely reset the cache.
  user.pending_writes -= 1
end

def read_home_feed(user)
  return user.home_feed if user.pending_writes == 0
  generated_home_feed = # many database calls
  return generated_home_feed
end

另一个有趣的细节是,我们需要此代码与 Shop 中的所有现有代码一起工作,并与该代码无缝交互。我们编写了一系列Active Record Concerns,将它们混合到相关的数据库记录中。使用 Active Record 意味着 ORM 的 API 保持完全相同,导致此更改对开发人员完全透明,并确保所有这些代码都向前兼容。当任何在 Google 或 Facebook 上销售的人都可以使用 Shop Pay 时,我们能够以最少的开销集成缓存。
 
在全球推广此缓存后,我们看到了立竿见影的效果。我们的数据库服务器负载更轻,除了更低的数据库负载和更快的家庭馈送性能外,我们还观察到整体 CPU 使用率下降了两位数,整体 GraphQL 延迟下降了 20%。我们的数据库服务器负载更轻,我们的用户拥有更快的体验,我们的开发人员无需担心数据库负载过高。这是一个双赢的局面。