异步 API
每个缓存都有一个异步视图。它就是同一个缓存:同一个 L2、同一组失效栅栏、同一批在途加载、同一套 Redis lease,只是等待的方式不同。读写返回 CompletableFuture,CoCache 在等待 Redis、等待其它实例的 lease 或等待你的数据源时,不挂起任何线程。
在非阻塞的技术栈上使用它:WebFlux、R2DBC、WebClient,或任何运行在事件循环上的代码。在阻塞的技术栈上,同步 API 配合虚拟线程更简单,也一样快。
拿到视图
val users: Cache<String, User> = ... // spec bean 或 @CoCache 接口
val async: AsyncCache<String, User> = users.async()
async.get("42").thenAccept { user -> ... }
async.getAll(listOf("1", "2")).thenAccept { found -> ... }
async.put("42", user).thenCompose { async.get("42") } // 读到自己的写入
async.invalidate("42")async()是每种缓存类型的成员,视图与同步的层次同构:Cache.async()返回AsyncCache<K, V>,VersionedCache返回AsyncVersionedCache,Store返回AsyncStore,VersionedStore返回AsyncVersionedStore,SetStore返回AsyncSetStore<K, E>。它们都继承AsyncReadableCache(get、getAll、lookup、lookupAll)。方法与同步 API 同名、语义相同,包括 Redis 故障时没有数据源的读取得到的Lookup.Unknown与CacheReadException。async()每次返回同一个对象,async().sync()就是引擎本身。经@CoCache接口调用同样可以。- 同步调用与异步调用共用一个缓存:同一个 key 并发的同步
get与异步get只加载一次。
异步数据源
缓存可以用 AsyncCacheLoader 代替 CacheLoader 加载:
@Bean
fun userSpec(repo: ReactiveUserRepository): CacheSpec<String, User> = cacheSpec("user") {
codec = jacksonCodec<User>(mapper)
asyncLoader = AsyncCacheLoader { id -> repo.findById(id).toFuture() }
}loadAsync(key)返回CompletionStage;以null完成时写入负缓存。批量读取请覆盖loadAllAsync(keys),默认对每个 key 调用loadAsync。- spec 设置
loader或asyncLoader之一,不能都设置。CoCache 在两个方向上适配:同步get等待异步 loader(受load-timeout约束),异步get在运行时的执行器上运行同步 loader。 @CoCache接口的loader = "beanName"可以指向任意一种。不指定时,CoCache 先找唯一的CacheLoader<K, V>,再找唯一的AsyncCacheLoader<K, V>;两种各有一个时存在歧义,启动失败。同时实现两者的 bean 按同步 loader 使用。loadAsync必须很快返回,不能阻塞。它在调用方线程或 CoCache 的线程(运行时的执行器;配置了LoadObserver时为观测线程)上被调用,不能依赖调用方的 ThreadLocal(MDC、安全上下文)。请经响应式库或 Micrometer context propagation 传递上下文。
线程与执行器
| 情形 | 在哪里执行 |
|---|---|
| L2 命中 | 在调用方线程上返回已完成的 future |
| 结果已经可得(内存后端、同步完成的 loader) | 在调用方线程上继续 |
| 之后才完成的 Redis 结果或 loader future | 在运行时的执行器上继续,从不在 Lettuce 的 I/O 线程或 loader 自己的线程上;每次这样的恢复都是执行器上的一个新任务 |
| 经异步视图调用的同步 loader | 同样在运行时的执行器上执行 |
被 LoadObserver 观测的加载(链路追踪) | observe 在自己的虚拟线程上执行,每次被观测的加载一个(cocache-observe-N),这个线程等待加载结束;不占用运行时的执行器 |
| 等待其它实例的 lease,或等待回源许可 | 不占线程:读取挂起,短暂退避后重读 Redis;许可释放时按到达顺序交给下一个等待者(同步与异步调用方排在同一个队列里) |
你自己挂上的依赖阶段(thenApply、whenComplete)遵循 JDK 的规则:在完成 future 的线程上执行,或在随后对它调用 get() / join() 的线程上执行。
异步与挂起视图只用运行时的一个执行器:由 cocache.executor 指定(不用 Spring 时为 CacheRuntime.builder().executor(…)),默认每个任务一个虚拟线程,线程名 cocache-worker-N。阻塞的 loader 与续接都在它上面执行,所以它必须容得下阻塞的任务:不要给它队列,也不要只给少量线程。见配置参考。
超时
- 等待别的调用方加载的读取(同步与异步)最多等到这次加载进行了
load-timeout,然后以SourceLoadTimeoutException失败;在那之后到来的读取开始新的加载,不再合并到卡住的那次。 - 一次
AsyncCacheLoader加载最多load-timeout(默认 30 秒)。超时后调用以SourceLoadTimeoutException失败,释放 lease,并计入cocache.load.timeouts。CoCache 不取消你的 loader 的 future,迟到的结果被丢弃。你的 loader 自己抛出的TimeoutException(例如来自 Reactor 的timeout)原样传递。 - 每条 Redis 命令受
cocache.redis.command-timeout(默认 2 秒)约束,失败时与同步路径一样是RedisException。计时器是 Lettuce 的时间轮,异步超时最多晚一个刻度(默认 100 毫秒)触发。 - 同步 loader 没有异步超时:阻塞的线程无法被打断。
异常与取消
- 失败的调用以原始异常完成 future:
CacheReadException、CacheWriteException、SourceLoadRejectedException、SourceLoadTimeoutException,或你的 loader 抛出的异常,从不包装为CompletionException。join()与get()仍会按 JDK 的规则包装;handle、exceptionally、Reactor 的Mono.fromFuture与 Kotlin 的await()都拿到原始异常。 - 唯一的例外是 loader 自己的
CancellationException:CacheLoader抛出它,或AsyncCacheLoader的 future 以它完成(HTTP 请求被取消、其中的withTimeout)。共享这次加载的每个调用方都收到SourceLoadCancelledException,原始异常是它的cause。原样传出的话,每个调用方的 future 都会看起来像是被取消了。 - 异步方法从不同步抛出。key 编码器或编解码器出错、重入调用、非法的版本,都以失败的 future 返回。
- 每次调用返回自己的 future。取消它只取消你的等待:共享的加载为其它调用方继续进行,照样写入 Redis。写入的顺序不受影响:写入的第二步失效无论是否取消都会执行。
- 读取自己 key 的 loader:如果在返回 future 之前这样做,快速失败(
IllegalStateException);如果在一次 I/O 之后这样做,调用会一直等到load-timeout。
Spring Cache
retrieve(key) 与 retrieve(key, loader) 经异步视图,所以异步与响应式的 @Cacheable(sync = true) 方法经受 lease 保护的路径回源,等待时不占用线程,也不需要单独的执行器;方法体在运行时的执行器上执行。见 Spring Cache。
Kotlin 协程
每个缓存还有挂起视图 cache.suspending(),spec 可以接受 SuspendingCacheLoader,见 Kotlin 协程。kotlinx.coroutines.future.await() 同样适用于异步视图返回的每个 future,并抛出原始异常。
运维
- Redis:没有新的命令、脚本、key 或频道。异步视图执行的 Lua 脚本与同步 API 相同,ACL 规则与线格式都不变。
- 指标:与同步 API 相同的计数,另加
cocache.load.timeouts。开启追踪时,异步加载产生同样的cocache.load观测,引擎超时标记为outcome=timeout。自定义的LoadObserver经同一个observe看到异步加载:CoCache 在自己的观测线程上调用它,从不在你的线程上,观测覆盖整个加载。 - 大值:Redis 后端在运行时的执行器上读取并解码回复,从不在 Lettuce 的 I/O 线程上,但一个实例的所有命令共用一个连接。很大的值会推迟这个连接上其它回复(队头阻塞),同步 API 同样如此。请保持值较小,或拆分。
- 性能:一次异步 L2 命中多一次很小的分配,约为同步命中的 90%。每次都等待结果的闭环调用在负载下比同步 API 略慢,因为每个结果都要切换到运行时的执行器。收益出现在一个线程同时保持多个请求时:32 个在途请求下,一个线程完成的 Redis 读写约为一个阻塞线程的 12 倍。
自定义后端
L1 SPI 是挂起的:后端的存储是 L1Store,失效通道以 suspend 函数发布。两种视图调用同一个存储,等待 Redis 应答时都不占线程。Java 后端实现 FutureL1Store 与 FutureInvalidationChannel,经 FutureL1Store.adapt 与 FutureInvalidationChannel.adapt 适配。用 BackendProtocolSpec 与各引擎套件检验后端;还想经异步视图运行某个套件时,覆盖 access = CacheAccess.ASYNC。约定见扩展,完整的 SPI 变化见更新日志。