DoorDash 如何改进微服务缓存?


随着 DoorDash 的微服务架构不断发展,服务间流量也在不断增长。每个团队管理自己的数据并通过 gRPC 服务公开访问权限,gRPC 服务是一个用于构建可扩展 API 的开源远程过程调用框架。

由于对下游服务的调用,大多数业务逻辑都是 I/O 绑定的。
缓存长期以来一直是提高性能和降低成本的首选策略。
然而,缺乏统一的缓存方法导致了复杂化。

在这里,我们解释了如何通过 Kotlin 库简化缓存,为后端开发人员提供一种快速、安全且高效的方式来引入新的缓存。

在支持业务逻辑的同时提高性能
在 DoorDash 微服务领域,重点更多地放在实现业务逻辑而不是性能优化上。
虽然优化代码中的 I/O 模式可以提高性能,但为此重写业务逻辑将非常耗时且占用资源。

那么问题就变成了如何在不彻底修改现有代码的情况下提高性能?
一种正统的解决方案是缓存。
将经常访问的数据的副本存储在靠近需要的位置的做法,以提高后续请求的速度和性能。只需重载用于检索数据的方法,即可将缓存透明地添加到业务逻辑代码中。 

DoorDash 最常见的缓存是用于本地缓存的Caffeine和用于分布式缓存的Redis Lettuce。大多数团队直接在代码中使用 Caffeine 和 Redis Lettuce 客户端。

由于缓存存在常见问题,许多团队在实施自己的独立方法时遇到了类似的问题。
问题:

  1. 缓存陈旧性:虽然为方法实现缓存很简单,但确保缓存随原始数据源保持更新是具有挑战性的。解决由过时的缓存条目引起的问题可能非常复杂且耗时。
  2. 对Redis的严重依赖:每当Redis宕机或遇到问题时,服务经常会遇到很高的故障率。
  3. 无运行时控制:由于缺乏实时调整,引入新的缓存可能存在风险。如果缓存遇到问题或需要调整,更改需要重新部署或回滚,这会消耗时间和开发资源。此外,需要单独部署来调整 TTL 等缓存参数
  4. 不一致的键key模式:缺乏缓存键的标准化方法使调试工作变得复杂。具体来说,很难跟踪 Redis 缓存中的键与其在 Kotlin 代码中的用法相对应。
  5. 指标和可观察性不足:跨团队缺乏统一的指标导致缺乏关键数据,例如缓存命中率、请求计数和错误率。 

实现多层缓存的困难:以前的设置不容易支持对同一方法使用多个缓存层。将本地缓存和资源密集型 Redis 缓存相结合可以在采取回退之前优化结果。

单一缓存接口即可统治一切
由于每个团队使用不同的缓存客户端,包括 Caffeine、Redis Lettuce 或 HashMap,因此函数签名和 API 几乎没有一致性。为了标准化这一点,我们引入了一个简化的接口,供应用程序开发人员在设置新缓存时使用,如以下代码片段所示:

interface CacheManager {
    /**
   * Wraps fallback in Cache.  
   * key: Instance of CacheKey.  
   *      Subclasses of CacheKey define a unique cache with a unique 
   *      name, which can be configured via runtime.
   * fallback: Invoked on a cache miss. The return value is then cached and 
   *           returned to the caller.
   */

    suspend fun <V> withCache(
        key: CacheKey<V>,
        fallback: suspend () -> V?
    ): Result<V?>
}
/**
 * Each unique cache is tied to a particular implementation of the key.
 *
 * CacheKey controls the cache name and the type of unique ID.
 *
 * Name of the cache is the class name of the implementing class.
 * all implementations should use a unique class name.
 */

abstract class CacheKey<V>(
    val cacheKeyType: String,
    val id: String,
    val config: CacheKeyConfig<V>
)
/**
 * Cache specific config.
 */

class CacheKeyConfig<V>(  
/**
     * Kotlin serializer for the return value. This is used to store values in Redis.
     */

    val serializer: KSerializer<V>
)


分层缓存
我们希望采用简化的缓存管理界面,使之前仅使用单层的团队能够更轻松地通过多层缓存系统来增强性能。与单层不同,多层可以提高性能,因为某些层(例如本地缓存)比涉及网络调用的层(例如 Redis)快得多,而网络调用已经比大多数服务调用快。

在多层缓存中,键请求会逐层进行,直到找到键或到达最终真相来源 (SoT) 回退功能。如果从后面的层检索该值,则会将其存储在前面的层中,以便在后续请求相同键时更快地访问。这种分层检索和存储机制通过减少到达 SoT 的需要来优化性能。

我们在公共接口后面实现了三层:

  1. 请求本地缓存:仅在请求的生命周期内有效;使用简单的 HashMap。
  2. 本地缓存:对单个Java虚拟机内的所有worker可见;使用Caffeine来完成繁重的工作。
  3. Redis缓存:对共享同一个Redis集群的所有Pod可见;使用 Lettuce 客户端。

运行时功能标志控制
各种用例可能需要不同的配置或关闭整个缓存层。为了使这一过程变得更快、更容易,我们添加了运行时控制。这使我们能够在代码中加入新的缓存用例,然后通过运行时进行后续部署和调整。
每个独特的缓存都可以通过 DoorDash 的运行时系统单独控制。每个缓存可以是:

  • 打开或关闭。如果新引入的缓存策略有错误,这会很方便。我们可以简单地关闭缓存,而不是进行回滚部署。在关闭模式下,库调用回退,完全跳过所有缓存层。
  • 重新配置为单独的生存时间(TTL)。将图层的 TTL 设置为零将完全跳过它。 
  • 以指定的百分比进行阴影处理。在影子模式下,一定比例的缓存请求也会将缓存值与 SoT 进行比较。

可观察性和缓存阴影
为了衡量缓存性能,我们收集有关请求缓存的次数以及请求导致命中或未命中的次数的指标。缓存命中率是主要的性能指标;我们的库收集每个独特缓存和层的命中率指标。

另一个重要指标是新缓存条目与 SoT 的比较情况。我们的库提供了一种影子机制来衡量这一点。如果打开阴影,一定比例的缓存读取也将调用回退并比较缓存和回退值是否相等。可以绘制成功和不成功匹配的指标并发出警报。我们还可以测量缓存陈旧程度——缓存条目创建和 SoT 更新之间的延迟。测量缓存的陈旧程度至关重要,因为每个用例都有不同的陈旧容忍度。

除了指标之外,任何未命中还会生成错误日志,其中逐项列出了缓存值和原始值之间不同的对象中的路径。这在调试陈旧的缓存时非常方便。

提供对缓存陈旧性的可观察性是凭经验验证缓存失效策略的关键。

何时使用缓存
我们可以通过最终一致性约束来分解缓存用例。 

第 1 类:可以容忍陈旧的缓存
在某些使用情况下,延迟几分钟以使更新生效是可以接受的。在这些情况下,可以安全地使用所有三个缓存层:请求本地缓存、本地缓存和 Redis 层。您可以将每个层的 TTL 设置为在几分钟后过期。所有层中最长的 TTL 设置将决定缓存与数据源保持一致的最长时间。 

监控缓存命中率对于性能优化至关重要;调整 TTL 设置有助于改善此指标。 

在这种情况下,无需实施阴影来监控缓存准确性。

类别 2:不能容忍陈旧的缓存
当数据频繁更改时,过时的信息可能会对业务指标或用户体验产生不利影响。将最大可容忍的过时时间限制在几秒甚至几毫秒变得至关重要。 

在这种情况下,通常应避免本地缓存,因为它不容易失效。但是,请求级缓存可能仍然适合临时存储。 

虽然可以为 Redis 层设置更长的 TTL,但一旦底层数据发生变化,就必须使缓存失效。缓存失效可以通过多种方式实现,例如在数据更新时删除相关的 Redis 键,或者在模式匹配困难时使用标记方法删除缓存。 

失效触发器有两个主要选项。首选方法是使用更新数据库表时发出的更改数据捕获事件,尽管这种方法可能会涉及一些延迟。或者,当数据更改时,可以直接在应用程序代码中使缓存失效。这更快但可能更复杂,因为多个代码位置可能会引入新的更改。 

启用缓存阴影来监控陈旧性至关重要,因为这种可见性对于验证缓存失效策略的有效性至关重要。

何时不使用缓存
1、写入或突变流
尽可能多地重用代码是一个好主意,以便您的写入端点可以重用与读取端点相同的缓存函数。但是,当您写入数据库然后读回值时,这会带来潜在的过时问题。读回过时的值可能会破坏业务逻辑。相反,可以安全地完全关闭这些流的缓存,同时在 CacheContext 之外重用相同的缓存函数。

2、作为真相的来源
不要将缓存用作数据库或依赖它作为事实来源。始终注意过期的缓存层,并有一个回退来查询正确的事实来源。

结论
由于分散的缓存实践,DoorDash 的微服务面临着重大挑战。通过将这些实践集中到一个综合库中,我们极大地简化了我们的可扩展性并增强了整个服务的安全性。通过引入标准化接口、一致的指标、统一的缓存键方案和适应性配置,我们现在强化了引入新缓存的过程。此外,通过提供关于何时以及如何使用缓存的明确指导,我们成功地避免了潜在的陷阱和低效率。这一战略性改革使我们能够充分发挥缓存的潜力,同时避开其常见的陷阱。