DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 17
STEP 01 / CODE / LOGIC

先说结论

缓存是可重建副本,权威来源才决定正确数据。失效表示“现有副本不能继续被信任”,版本号用来阻止旧的异步重建结果覆盖更新的数据。

当前缓存版本:12
请求 A 开始重建版本 13
源数据再次变化,请求 B 开始重建版本 14
请求 B 先完成并提交版本 14
请求 A 后完成,仍携带版本 13

分类:工程设计

先说结论

缓存是可重建副本,权威来源才决定正确数据。失效表示“现有副本不能继续被信任”,版本号用来阻止旧的异步重建结果覆盖更新的数据。

两次重建为什么只能提交后一次

当前缓存版本:12
请求 A 开始重建版本 13
源数据再次变化,请求 B 开始重建版本 14
请求 B 先完成并提交版本 14
请求 A 后完成,仍携带版本 13

请求 A 必须被拒绝,否则缓存会从 14 倒退回 13。失效时是否继续提供旧值、阻塞等待新值或直接报错,是业务一致性策略,不能由容器私自决定。

生活化模型:图书馆的手写索引卡

想象图书馆为热门书架准备了一张手写索引卡:

索引卡记录书放在哪一排。
读者先看索引卡,不必每次遍历全部书架。

索引卡就是缓存。

它能加速查询,但前提是:

卡片上的信息仍然对应当前书架。

如果书籍移动后没有让旧卡片失效,读者会快速得到错误答案。

如果管理员正在重写卡片时书架又变化了,晚完成的旧卡片也可能覆盖新事实。

所以缓存设计的重点不只是“存一份结果”,而是:

缓存怎样建立?
什么事实变化会使它失效?
缓存属于哪个源数据版本?
重建期间读请求看到什么?
重建失败怎样暴露?

缓存的完整事实链

权威数据源
    ↓
读取源数据版本
    ↓
计算缓存内容
    ↓
验证计算仍对应当前版本
    ↓
发布缓存快照
    ↓
读请求使用快照
    ↓
源数据变化
    ↓
旧快照标记失效
    ↓
启动新版本重建

“缓存存在”不代表“缓存有效”。至少需要同时知道内容与它对应的版本。

先确定权威来源

缓存不是事实的最终所有者。

在建立缓存前必须明确:

权威数据来自哪里。
什么事件表示权威数据已经变化。
如何读取一个一致的源数据快照。
怎样得到可比较的版本标识。

如果无法判断源数据是否改变,就无法可靠判断缓存何时失效。

版本可以来自:

数据源提供的修订号。
单调递增的变更序号。
不可变快照的身份。
内容的确定性摘要。

应优先使用权威系统已经提供的版本语义。不能仅凭当前时间或对象引用相似就假定内容相同。

缓存状态机

一个教学状态机可以是:

Empty
  ↓ Build
Building
  ├─ Success ─> Ready
  ├─ Invalidated ─> Stale / Building
  └─ Failure ─> Faulted

Ready
  ├─ SourceChanged ─> Stale
  └─ Clear ─> Empty

Stale
  └─ Rebuild ─> Building

状态名称是教学示例。是否允许读到 Stale 数据、是否自动重建、失败后是否保留旧快照,都是行为策略,不能由“缓存”二字自动决定。

本文的最小示例采用严格读取:

只有 Ready 且版本匹配的快照可以读取。
其他状态返回明确失败,不使用旧值或默认值替代。

这是为了清晰展示错误传播,不代表所有缓存都必须采用同一策略。

建立缓存

建立缓存不是简单赋值,而是一次有输入版本的计算:

读取源版本 V
    ↓
取得与 V 对应的数据快照
    ↓
构建候选缓存
    ↓
再次确认当前目标版本仍是 V
    ↓
原子发布候选缓存

若构建过程很长,源数据可能在中途变化。此时旧构建即使计算成功,也不能发布成当前缓存。

先构建,后发布

较安全的思路是:

在局部变量中构建完整候选值。
成功且版本仍匹配后,一次性替换公开快照。

不要边构建边修改读者正在使用的共享集合,否则读者可能看到半成品。

失效是什么

失效表示:

现有缓存不再被证明与当前权威数据一致。

失效触发可能来自:

权威数据变更通知。
依赖配置变化。
缓存计算规则版本变化。
访问权限或可见范围变化。
显式清除请求。

“过了一段时间”也可以是一种策略,但具体有效期是行为值,需要明确来源。本文不假定任何固定有效期。

失效不等于立刻销毁

失效与释放内存是两个动作:

失效:禁止把旧值当作当前正确值。
释放:回收旧快照占用的资源。

若仍有读者持有不可变旧快照,可以等读者结束后再回收;但旧快照是否还能对新请求可见,必须由读取策略明确决定。

版本与 Generation Token

缓存常同时需要两个概念:

sourceVersion:缓存内容对应的权威数据版本。
generation:本地重建请求的代次。

它们解决不同问题。

Source Version

Source Version 回答:

这份缓存反映的是哪一版源数据?

Generation

Generation 回答:

在多个重建任务竞争时,哪个任务仍有资格发布?

例如:

重建 A 捕获 generation G1
源数据变化,触发重建 B,当前 generation 变为 G2
重建 B 先完成并发布
重建 A 后完成
因为 G1 != G2,A 不得覆盖 B

G1G2教学符号

只比较 Source Version 有时仍不够:同一源版本也可能因本地计算规则变化而发起新构建。只比较 generation 也不够:它不能证明源数据快照本身是哪一版。

进阶:异步重建晚到

异步缓存重建的竞态链如下:

缓存因版本 V1 开始重建 A
    ↓
源数据变为 V2,A 对当前数据失效
    ↓
取消 A,并启动重建 B
    ↓
B 先完成,发布 V2 快照
    ↓
A 晚到
    ↓
A 的 generation 或 sourceVersion 不匹配
    ↓
拒绝发布 A 的结果

发出取消请求不能替代版本检查。A 可能已经进入不可取消阶段,仍然会返回成功或失败。

最小 C# 示例

下面示例只允许读取已验证的当前快照,不自动返回旧值,不提供默认成功,也不重试。

public enum CacheState
{
    Empty,
    Building,
    Ready,
    Stale,
    Faulted
}

public enum BuildStatus
{
    Published,
    Cancelled,
    Superseded,
    Faulted
}

public sealed record CacheSnapshot<T>(
    long SourceVersion,
    T Value);

public sealed record BuildOutcome(
    BuildStatus Status,
    Exception? Error = null);

public sealed class VersionedCache<T> : IDisposable
{
    private readonly object _gate = new();
    private long _generation;
    private long _requiredSourceVersion;
    private CancellationTokenSource? _buildCts;
    private CacheSnapshot<T>? _snapshot;

    public CacheState State { get; private set; } = CacheState.Empty;

    public void Invalidate(long requiredSourceVersion)
    {
        lock (_gate)
        {
            _requiredSourceVersion = requiredSourceVersion;
            _generation = checked(_generation + 1);
            State = _snapshot is null ? CacheState.Empty : CacheState.Stale;
            _buildCts?.Cancel();
        }
    }

    public async Task<BuildOutcome> RebuildAsync(
        long sourceVersion,
        Func<CancellationToken, Task<T>> build)
    {
        CancellationTokenSource cts;
        CancellationToken token;
        long buildGeneration;

        lock (_gate)
        {
            if (sourceVersion != _requiredSourceVersion)
                throw new InvalidOperationException(
                    "Build version is not the required source version.");

            _buildCts?.Cancel();
            _buildCts = new CancellationTokenSource();
            cts = _buildCts;
            token = cts.Token;

            buildGeneration = checked(_generation + 1);
            _generation = buildGeneration;
            State = CacheState.Building;
        }

        try
        {
            // 在共享状态之外构建完整候选值,避免发布半成品。
            T value = await build(token);

            lock (_gate)
            {
                if (buildGeneration != _generation ||
                    sourceVersion != _requiredSourceVersion)
                {
                    return new BuildOutcome(BuildStatus.Superseded);
                }

                _snapshot = new CacheSnapshot<T>(sourceVersion, value);
                State = CacheState.Ready;
                return new BuildOutcome(BuildStatus.Published);
            }
        }
        catch (OperationCanceledException) when (cts.IsCancellationRequested)
        {
            return new BuildOutcome(BuildStatus.Cancelled);
        }
        catch (Exception error)
        {
            lock (_gate)
            {
                if (buildGeneration == _generation)
                    State = CacheState.Faulted;
            }

            // 保留原始异常;失败不会被转换成空缓存或默认成功。
            return new BuildOutcome(BuildStatus.Faulted, error);
        }
        finally
        {
            lock (_gate)
            {
                if (ReferenceEquals(_buildCts, cts))
                    _buildCts = null;
            }

            // 取消源由创建它的构建任务在结束后释放。
            cts.Dispose();
        }
    }

    public CacheSnapshot<T> ReadRequired(long sourceVersion)
    {
        lock (_gate)
        {
            if (State != CacheState.Ready ||
                _snapshot is null ||
                _snapshot.SourceVersion != sourceVersion)
            {
                throw new InvalidOperationException(
                    $"Cache is not ready for source version {sourceVersion}.");
            }

            return _snapshot;
        }
    }

    public void Dispose()
    {
        lock (_gate)
        {
            _generation = checked(_generation + 1);
            _buildCts?.Cancel();
            _buildCts = null;
            _snapshot = null;
            State = CacheState.Empty;
        }
    }
}

这是并发概念的最小示例,不包含完整的多读者生命周期管理。T 如果是可变对象,调用方仍可能在锁外修改它;实际使用时通常需要不可变快照或受控访问。

取消与错误可见性

缓存重建至少应区分:

Published:候选值已验证并发布。
Cancelled:当前任务接受了本缓存发出的取消请求。
Superseded:构建完成,但已不属于当前 generation 或版本。
Faulted:构建失败,并携带原始 Exception。

不能把它们统一成:

返回 null,让调用方自己猜。

也不能在 Faulted 后静默返回旧缓存,除非这种降级行为已经由使用方明确确认。

旧构建失败也要被观察

一个已被新 generation 替代的构建仍可能以异常结束。

即使它没有资格修改当前缓存,其任务也必须被等待或有明确的异常观察机制。否则失败可能变成未观察异常,调试时只看到缓存状态变化,却看不到真正原因。

示例通过 BuildOutcome.Error 把异常交回启动该任务的调用者。调用者不能因为“后来已有新缓存”就忘记观察旧任务的结果。

可视化的逐步状态

可以用双轨时间线展示源版本与构建代次:

时间 ─────────────────────────────────────────>

源版本        V1           V2
generation    G1           G2
构建 A        ├──────────────────● 晚到
构建 B                     ├────● 发布
取消 A                     ↑
当前快照      Empty        V2 Ready
A 发布判断                 G1 != G2,拒绝

V1V2G1G2 都是教学符号

交互演示可以调整:

源数据变化时刻。
构建 A 与 B 的完成顺序。
旧任务是否响应取消。
任一构建成功或失败。
读取请求到达时刻。

状态面板应显示:

缓存状态。
要求的 Source Version。
已发布快照版本。
当前 generation。
每个任务捕获的 generation。
读取成功或失败的明确原因。
每个构建的原始错误。

正常路径

缓存为空。
调用方提供当前所需源版本。
重建捕获版本与 generation。
候选值完整构建。
版本与 generation 仍匹配。
一次性发布快照。
同版本读请求成功。

边界路径

重建期间发生读取

读取策略必须明确。

本文示例选择:

Building 不是 Ready,因此 ReadRequired 抛出状态错误。

其他系统可以选择等待当前构建,但等待也必须保留取消和构建失败结果。使用旧值作为降级则属于 fallback,必须被明确批准。

相同版本重复触发重建

同一 Source Version 的两个任务仍可能有先后语义。

若后一次重建替代前一次,需要 generation。若希望合并为同一个共享任务,则需要明确的任务复用和取消所有权,不能仅用版本相等推断调用方意图一致。

失效通知重复到达

重复通知可能代表同一源版本,也可能携带新的版本。

处理前应比较权威版本,而不是每次无条件开始昂贵重建。是否去重取决于版本语义。

版本回退或乱序

如果权威版本保证单调递增,可以拒绝旧版本通知。

如果版本允许分支或回退,就不能用简单的整数大小判断新旧,必须使用权威来源定义的比较规则。

失败路径

源版本与要求版本不符:拒绝启动构建。
构建函数抛出异常:返回 Faulted 和原始异常。
构建被取消:返回 Cancelled,不发布半成品。
构建晚到:返回 Superseded,不覆盖当前快照。
读取版本不匹配:抛出明确错误,不返回默认值。
generation 溢出:checked 运算暴露异常。
缓存内容可变并被外部修改:快照一致性被破坏。

失败状态不应被自动改写为 Ready

代价与权衡

缓存减少重复计算或读取,但增加:

额外内存。
版本元数据。
失效通知和依赖追踪。
构建任务的并发协调。
候选快照与当前快照短暂共存的峰值内存。
取消但尚未停止的后台工作。

设缓存构建成本为 B,读取快照成本为 R,且通常希望:

R 远小于 B

这是符号关系,不是固定数值。

缓存是否值得,还取决于:

命中率。
数据变化频率。
重建延迟。
单份快照大小。
读者能接受的新鲜度规则。
错误结果的影响。

版本越精细,失效范围可能越小,但元数据和依赖追踪更复杂。版本越粗,规则更简单,却可能因局部变化而重建大量无关数据。

最重要的收获

缓存必须绑定权威数据版本,存在不等于有效。
建立缓存应先在局部完成候选值,再原子发布。
Source Version 证明数据来源,generation 决定异步任务的发布资格。
取消旧重建不能替代版本与 generation 检查。
重建期间读什么、失败后是否使用旧值,都是必须明确的策略。
默认值、静默旧值和默认成功都会掩盖原始错误。
过期任务仍需被观察,它的异常不能因为结果不再发布就消失。