Skip to content

工作原理 ​

本页讲清楚 CoCache 如何处理你的读取与写入,深度以用好它为准。协议本身以及它为什么正确,见架构 › 一致性。

两级 ​

层级位置存放寿命
L2每个实例内的 Caffeine最近读过的 key 的有界副本(默认 10 000 条)Redis 中的剩余寿命,且不超过 localMaxAge(5 分钟;权威类型为 30 秒)
L1所有实例共享的 Redis缓存值、负缓存,或加载期间的短暂 lease值为 ttl ± 抖动(1 小时 ± 1 分钟),负缓存为 absentTtl(1 分钟);权威类型默认永不过期
数据源你的 CacheLoader真实数据—

L2 让读取变快。L1 让实例之间保持一致,也让数据库不至于每个实例都被加载一遍。

L1 中保存的是什么取决于缓存的类型。对 Cache 与 VersionedCache,它是派生副本:丢了只需从数据源重新加载。对 Store、VersionedStore 与 SetStore,它是唯一副本:没有 loader,读取从不申请 lease,Redis 中没有的 key 就是不存在。下面的读路径是派生类型的;权威类型跳过 lease 与 loader。

读路径 ​

  1. L2 命中。 一次 Caffeine 查找加一次过期判断。没有 I/O,没有锁;除非开启 L2 命中计数,也不计数。
  2. L2 未命中。 本实例上的并发调用共享一次加载(SingleFlight),由领头的调用读取 Redis。
  3. L1 命中。 值(或负缓存)进入 L2 并返回。
  4. L1 未命中。 发现 key 不存在的那个 Redis 脚本同时把 lease 交给本实例。领头的调用在调用方线程上执行你的 loader,因此事务、安全上下文与 MDC 都可用。只有 lease 仍然完好时,结果才会写入 Redis。
  5. 其它实例持有 lease。 别的实例正在加载这个 key。本实例最多轮询 lease-wait(200 毫秒),通常就能拿到结果。等待超时则自行加载,但不写 Redis,结果在 L2 中只保留 degradedLocalMaxAge(10 秒)。

结果是:只要加载在 lease-wait 内完成,每个 key 在整个集群只加载一次。

getAll(keys) 批量地走同一条路径:逐个查 L2,剩余的 key 一次批量读 Redis,仍缺的 key 一次 loadAll,再一次批量写回。它不等待其它实例持有的 lease,这些 key 直接加载。

负缓存 ​

loader 返回 null(或 loadAll 的结果中缺少某个 key)时,CoCache 在 Redis 中写入一条负缓存,存活 absentTtl。此后对该 key 的读取直接返回 null,不再调用 loader,数据库不会被不存在的 key 反复查询。需要区分“确认不存在”(Lookup.Absent)、“有值”(Lookup.Present)与“Redis 故障、这次读取没有答案”(Lookup.Unknown,见读取)时,用 lookup(key)。它的 expiresAt 是给出结果的那份副本的到期时间:命中 L2 时是 L2 副本的到期时间,受 localMaxAge 限制(未写入 Redis 的副本受 degradedLocalMaxAge 限制),因此可能早于 Redis 中的 TTL。精度为缓存时钟的刻度(默认 100 ms)。要查看某个 key 在 Redis 中的 TTL,用 actuator 端点或 CacheAdmin.inspect。

Redis 中单纯没有这个 key 只是未命中,永远不等于负缓存。因此没有数据源的缓存从不向 Redis 写负缓存。外部来源的 Cache 的未命中是未填充:不带 loader 的读取返回 null,这个答案只在本实例记住(最多 absentTtl),自带 loader 的调用照常加载。Store 的未命中就是答案:这个 key 不存在。

提前刷新 ​

设置了 refreshWindow 后,如果读到的值离 Redis 过期已经很近,读取会立即返回该值,并安排一次后台重新加载。只有在 Redis 中抢到刷新令牌的实例才调用 loader,所以集群对每个 key 只重新加载一次,其它实例从 Redis 读回刷新后的值。过期的值永远不会被返回;提前刷新只是消除热点 key 过期时的延迟尖刺。调优见这里。

写路径 ​

修改数据分两步:先写数据源,再失效。

  • invalidate(key) 在所有地方删除两级中的 key,同时销毁 lease,所以开始于失效之前的加载无法把旧结果写回。写入之后应该用的就是它。
  • put(key, value) 覆盖 Redis 中的值,并广播同样的失效。只在写入方手里已有最终值时使用。put 有意不填充本地 L2:L2 只由读取从 Redis 填充,这样两次写入赛跑时,L2 与 Redis 的顺序始终一致。代价是本地下一次读取多一次 Redis 往返。

无论哪种,同一实例上的下一次读取都能看到变化(读己之写)。其它实例在消息到达时看到。

默认情况下删除与发布在同一个 Lua 脚本中执行,不存在“Redis 已改、其它实例却没收到通知”的窗口。channel-mode: sharded 时,发布作为单独的命令紧跟在脚本之后。

数据最多会陈旧多久? ​

情形陈旧度上界
加载与失效赛跑(任何实例)无:旧结果被丢弃
其它实例,消息已送达Pub/Sub 送达延迟(毫秒级)
其它实例,消息丢失localMaxAge(5 分钟);心跳(5 秒)发现连接断开并清空 L2 时更早
绕过 CoCache 且不失效的写入ttl(1 小时);ttl 永不过期时没有上界
查询缓存了“不存在”之后新建的行,且没有失效absentTtl(1 分钟)
无法写入 Redis 的加载(Redis 故障、等待 lease 超时)degradedLocalMaxAge(10 秒)
在 Redis 上失败且重试耗尽的失效Redis 中该值的剩余寿命

ttl 是安全网,不是机制:协议之内的竞态由 lease 阻止,而不是靠过期来愈合。因此只要所有写入方都失效,ttl 永不过期也是安全的。

Redis 故障时 ​

故障点默认行为可选行为
读取 Redis带数据源的读取直接从数据源加载,结果只在 L2 中保留 degradedLocalMaxAge,不写 Redis。调用方照常工作,压力落到数据库上。没有数据源的读取(Store、SetStore、外部来源的 Cache 上不带 loader 的读取)没有答案:结果是 Lookup.Unknown,get 抛出 CacheReadException,什么也不缓存。read-failure: throw 让带数据源的读取也得到 Unknown。spec 上的 unknownRead = ABSENT 让没有数据源的读取改为返回 null。
Cache.invalidate本地效果先生效。Redis 删除交给有界的后台重试队列,调用不抛出(它通常在提交之后执行,抛出也于事无补,而删除派生副本在任何时刻都安全)。重试成功之前,本实例上该 key 的未命中跳过 Redis。invalidate-failure: throw 改为抛出 CacheWriteException
Store.delete抛出 CacheWriteException,从不重试:Redis 是唯一副本,延迟的删除会删掉更新的值。—
Cache.put、putAll结果未知时(超时),CoCache 与失败的 invalidate 一样登记这些 key 的失效,调用不抛出。写入确定没有发生时(发出之前就被拒绝),抛出 CacheWriteException。invalidate-failure: throw 让每次失败都抛出
其它类型的写入抛出 CacheWriteException。它的 outcomeUnknown 说明写入可能已经发生(超时),还是确定没有发生(发出之前就被拒绝)。不在后台重试任何东西。—
Pub/Sub 连接心跳在数秒内发现。重连时每个实例清空 L2,而本地副本无论如何都受 localMaxAge 限制。期间健康检查报告 DOWN。—
你的 loader异常抛给调用方以及所有等待同一次加载的调用方,不缓存任何东西。—

Redis 故障期间,每次未命中都会打到数据库。用 load-max-concurrent 保护它:超出上限的加载最多等待 load-acquire-timeout(默认 1 s)获取名额,之后抛出 SourceLoadRejectedException 失败,而不是层层堆积。写入失败的细节见保持数据新鲜,排查见故障排查。

延伸阅读 ​

基于 Apache License 2.0 发布。