已发布 上游基线 bf46254 原文 ↗ 在 GitHub 编辑

Cache

借助缓存优化性能,实现并发、可组合且高效的值获取。

在许多应用中,处理相互重叠的工作是很常见的。例如,在处理传入请求的服务中,避免诸如多次处理同一请求之类的重复工作就非常重要。Cache 模块通过防止重复工作来帮助提升性能。

Cache 的主要特性:

特性说明
可组合性允许应用的不同部分之间存在重叠工作,同时保持组合式编程。
统一的同步与异步缓存通过一个统一的查找函数整合同步缓存与异步缓存,该函数能以任一方式计算值。
Effect 集成与 Effect 库原生协作,支持并发查找、失败处理与中断。
Cache 指标跟踪条目数、命中数与未命中等关键指标,为性能优化提供洞察。

创建 Cache

缓存由一个查找函数定义:当某个键对应的值尚未被缓存时,该函数负责计算它:

type Lookup<Key, Value, Error, Requirements> = (
  key: Key,
) => Effect<Value, Error, Requirements>

查找函数接收一个 Key 并返回一个 Effect,这个 Effect 描述了如何计算对应的值(Value)。该 Effect 可能依赖某个环境(Requirements),可能以 Error 失败,也可能以 Value 成功。由于它返回的是 Effect,因此既能处理同步流程,也能处理异步流程。

创建缓存时,需要提供一个查找函数,以及缓存值的最大容量和生存时间(TTL)。

declare const make: <Key, Value, Error, Requirements>(options: {
  readonly capacity: number
  readonly timeToLive: Duration.DurationInput
  readonly lookup: Lookup<Key, Value, Error, Requirements>
}) => Effect<Cache<Key, Value, Error>, never, Requirements>

缓存创建之后,最符合惯用法的方式是使用 get 方法。 如果缓存中已存在对应的值,get 方法会返回该值;否则它会计算一个新值,将其放入缓存并返回。

如果多个并发进程请求同一个值,该值只会被计算一次。其他所有进程会在该值可用时立即收到它。这一过程由 Effect 基于 Fiber 的并发模型管理,不会阻塞底层线程。

示例(并发的缓存查找)

在这个示例中,我们用同一个键并发地调用 timeConsumingEffect 三次。 缓存只会执行这个 effect 一次,因此并发的查找会一直等待,直到该值可用:

import { Effect, Cache, Duration } from "effect"

// Simulating an expensive lookup with a delay
const expensiveLookup = (key: string) =>
  Effect.sleep("2 seconds").pipe(Effect.as(key.length))

const program = Effect.gen(function* () {
  // Create a cache with a capacity of 100 and an infinite TTL
  const cache = yield* Cache.make({
    capacity: 100,
    timeToLive: Duration.infinity,
    lookup: expensiveLookup,
  })

  // Perform concurrent lookups using the same key
  const result = yield* Effect.all(
    [cache.get("key1"), cache.get("key1"), cache.get("key1")],
    {
      concurrency: "unbounded",
    },
  )
  console.log(
    "Result of parallel execution of three effects" +
      `with the same key: ${result}`,
  )

  // Fetch and display cache stats
  const hits = yield* cache.cacheStats.pipe(Effect.map((stats) => stats.hits))
  console.log(`Number of cache hits: ${hits}`)
  const misses = yield* cache.cacheStats.pipe(
    Effect.map((stats) => stats.misses),
  )
  console.log(`Number of cache misses: ${misses}`)
})

Effect.runPromise(program)
/*
Output:
Result of parallel execution of three effects with the same key: 4,4,4
Number of cache hits: 2
Number of cache misses: 1
*/

并发访问

缓存被设计为对并发访问安全,并且在并发场景下保持高效。如果两个并发进程请求同一个值,而该值不在缓存中,那么它只会被计算一次,并在可用时立即提供给这两个进程。并发进程会等待该值,而不会阻塞底层线程。

如果查找函数失败或被中断,错误会被传播给所有正在等待该值的并发进程。失败的结果会被缓存,以避免对同一个失败的值反复计算。如果被中断,对应的键会从缓存中移除,因此后续调用会再次尝试计算该值。

容量

创建缓存时需要指定一个容量。当缓存达到容量上限时,最近最少被访问的值会最先被移除。在两次操作之间,缓存的大小可能会略微超过指定的容量。

生存时间(TTL)

缓存还可以指定生存时间(TTL)。超过 TTL 的值不会被返回。值的存活时长从它被载入缓存时开始计算。

方法

除了 get 之外,缓存还提供了若干其他方法:

方法说明
refresh触发对某个键对应值的重新计算,但不会移除旧值,从而允许继续访问。
size返回缓存当前的大小。在并发场景下,该大小是近似值。
contains检查缓存中是否存在与指定键关联的值。在并发访问下,该结果仅在检查的那一刻有效,之后可能立即发生变化。
invalidate逐出与特定键关联的值。
invalidateAll逐出缓存中的所有值。