把章节变成可单步观察的过程
分类:工程设计
先说结论
缓存是可重建副本,权威来源才决定正确数据。失效表示“现有副本不能继续被信任”,版本号用来阻止旧的异步重建结果覆盖更新的数据。
两次重建为什么只能提交后一次
当前缓存版本: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
G1、G2 是教学符号。
只比较 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,拒绝
V1、V2、G1、G2 都是教学符号。
交互演示可以调整:
源数据变化时刻。
构建 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 检查。
重建期间读什么、失败后是否使用旧值,都是必须明确的策略。
默认值、静默旧值和默认成功都会掩盖原始错误。
过期任务仍需被观察,它的异常不能因为结果不再发布就消失。