Snapshot 使用指南——流式快照 / 增量快照 / 段表恢复
给谁看:需要流式帧文件快照 / raft 日志压缩的组件开发者 回答什么:StreamSnapshot vs IncrementalSnapshot 怎么选 / 按字节截断 vs 版本号截断 / 段表 O(1) 恢复 / 阈值合并 本篇只讲机制——帧字节布局见源码(
Structures/Snapshot/各 partial) 定位:Structures/ 6 子族之一,搭配件;与 Mirror 同族异坐标系(字节截断 vs 版本号截断)
0. 一句话总纲
Snapshot = 完整状态基线纯流式形态 + 按字节截断 + 增量形态(新段 append 不重写旧段)
StreamSnapshot= 流式帧文件([Header 14B][Data][Footer 28B]多帧)IncrementalSnapshot= 段=帧的增量快照:新段 append 不重写旧段 + 段表 O(1) 恢复 + 阈值合并
两者共用同一帧格式(StreamFrameCodec,Magic=SNHD/SNFT)与 SnapshotBase 三水位/2PC 能力;区别在语义层——StreamSnapshot 的帧 = 数据帧,IncrementalSnapshot 的帧 = 段(带 N₀ 8B 前缀)。
1. 定位
- 搭配件:与 Ring/Log 主结构搭配,承载"完整状态基线"。Ring 是数据真相源(record 流),Snapshot 是基线导出/恢复加速件——存在性=优化非正确性,无快照也能走全量重放。
- 形态差异(StreamSnapshot vs IncrementalSnapshot):
- StreamSnapshot:流式帧文件——单条快照 = N 帧(每帧
[Header][Data][Footer],CRC64 流式增量);适合一次性整体导出。 - IncrementalSnapshot:段=帧的增量——每段 = 一帧(
[Header][N₀ 8B][条目流][Footer]);新段 append 不重写旧段,快照越频繁写省越多(基准 8 次快照 4.5× 字节差);段累积达阈值自动合并。
- StreamSnapshot:流式帧文件——单条快照 = N 帧(每帧
- 与 Mirror 同族异坐标系:Mirror 按版本号截断(版本链 N=2 轮转回收),Snapshot 按字节截断(纯流式无版本链)。两者都承载"完整状态基线",但 Mirror 是版本链形态(覆写历史可读),Snapshot 是追加流形态(append 可回滚、Overwrite 不可回滚)。
- ⚠️ 与 Ring 自带区间快照(
OpenSnapshotReader/Writer)无引用关系——Ring 那是导出能力(区间字节流),本子族是独立文件形态(SnapshotBase持有独立StorageEngine)。同名为"快照"是历史巧合,不要混用。 - raft 日志压缩场景:
IncrementalSnapshot= 镜像快照设计稿第三部件(TierWAL 内部快照)——段 = raft apply 序号快照点,新段不重写旧段契合"快照越频繁写省越多"的 raft 日志压缩特征。
1.1 选型决策
| 场景特征 | 选型 | 理由 |
|---|---|---|
| 一次性整体导出(备份/迁移/冷存档) | StreamSnapshot | 单条快照 = N 帧流式追加,CRC 增量累积零内存压力;不需要段表/合并 |
| raft 日志压缩(周期性快照点) | IncrementalSnapshot | 新段 append 不重写旧段契合 raft 周期快照;段表 O(1) 恢复定位;阈值合并控制段数 |
| raft 快照安装(外部镜像注入) | IncrementalSnapshot + ImportSegmentAsync |
替换语义——段表替换为单段,旧段物理回收 |
| 整体快照外存 + 版本化历史可读 | Mirror(mirror.md) |
版本链 N=2 轮转——覆写历史可读;与 Snapshot 同族异坐标系 |
| 区间字节流导出(依附 Ring 生命周期) | Ring 的 OpenSnapshotReader/Writer |
不是本子族——Ring 导出能力,无独立 StorageEngine |
关键判断点:要"周期性快照 + 越频繁越省写"→ IncrementalSnapshot;要"一次性整体流"→ StreamSnapshot;要"版本化历史"→ Mirror。
2. Settings 全参数
SnapshotSettings 继承 Settings(MainEngine / MetaPolicyKind / MetaOpaqueBytes 公共面),StreamSnapshotSettings / IncrementalSnapshotSettings 各自派生。两子类公共字段相同,差异在专属配置。
| 参数 | 来源 | StreamSnapshot 默认 | IncrementalSnapshot 默认 |
|---|---|---|---|
MainEngine |
Settings 基 |
("tc.snapshot", 256MB, enableSegmentation=true, preallocateFile=false) |
("tc.snapshot.inc", 256MB, enableSegmentation=true, preallocateFile=false) |
Name |
Settings 基 |
"tc.snapshot" |
"tc.snapshot.inc" |
PreallocateFile |
Settings 基 |
false(按需增长,GB/TB 流式不预分配) |
false |
DeleteOnClose |
Settings 基 |
false(跨重启必须保留) |
false |
MetaPolicyKind |
Settings 基 |
Disabled(默认不装 meta——纯流式可走 Backward 扫描兜底) |
Disabled(增量快照强烈建议设 Managed——段表靠 opaque meta 落盘) |
MetaOpaqueBytes |
Settings 基 |
0 |
须 ≥ 段表容量 = 8 + 段数上限 × 40(如 64 段 → ≥2576B) |
SessionBufferSize |
SnapshotSettings |
128KB(双 buffer 写会话/读会话单 buffer 大小) |
128KB |
CompactSegmentThreshold |
IncrementalSnapshotSettings 专属 |
— | 8(段数达此值自触发 CompactSegments——低频全量重写) |
便捷构造(无参
new StreamSnapshotSettings()/new IncrementalSnapshotSettings())即用默认值;要自定义注入StorageEngineOptions。meta 装配:IncrementalSnapshot段表 = opaque meta 载体,禁用 meta = 段表丢失 → 恢复只能 Backward 扫描兜底(O(N) 全盘),生产场景请开Managed。
3. 构造与生命周期
SnapshotBase 继承 LifecycleBase<SnapshotRecoveryHints>——三段式生命周期:new → Initialize()(同步 void,启动后台恢复)→ WaitForReady()(等恢复就绪);异步调用方用 WaitForReadyAsync。无便捷工厂(与 Log 族同形态——封闭结构才有 CreateAsync)。
using TC.Tier.Core.IO;
using TC.Tier.Runtime.Storage;
using TC.Tier.Runtime.Structures.Snapshot;
// ① 文件系统(组合根;示例用 mem,真磁盘改 "local:///path")
using var fs = TierFs.New("memory:");
// ② StreamSnapshot:流式帧文件(meta 关闭,走 Backward 扫描兜底)
var streamSettings = new StreamSnapshotSettings();
using var stream = new StreamSnapshot(fs, streamSettings);
stream.Initialize();
stream.WaitForReady();
// ③ IncrementalSnapshot:增量快照(meta 开启——段表靠 opaque 落盘)
var incSettings = new IncrementalSnapshotSettings
{
MetaPolicyKind = MetaPolicyKind.Managed,
MetaOpaqueBytes = 4096, // 容纳 ≥100 段表条目(8 + 100×40 = 4008B)
CompactSegmentThreshold = 16,
};
using var inc = new IncrementalSnapshot(fs, incSettings);
inc.Initialize();
inc.WaitForReady();
// Dispose 幂等——Resources 链统一释放(主引擎/meta 引擎/MetaPolicy 全 owned)
Dispose 幂等:SnapshotBase 把主引擎、Managed 模式下的 meta 引擎、MetaPolicy 全部登记进 Resources(owned),统一释放顺序。Initialize 后台启动恢复(OnInitializeBegin 同步 init 主引擎 + meta 引擎,WaitForReadyAsync 等 join 完成),恢复算法经 CreateRecovery 工厂单点创建(注入实例则直接用)。
3.1 分步构造 + hints 注入(观察恢复/注入水位)
raft 节点重启已知 apply 序号时,可注入 SnapshotRecoveryHints 优先于扫盘:
// 已知写尾(如 raft 节点重启前持久化的 apply 水位)
var hints = new SnapshotRecoveryHints
{
WriteAddress = knownWriteTail,
PhysicalWriteAddress = knownPhysTail, // 可选;缺省走 AlignUpToSector
};
using var inc = new IncrementalSnapshot(fs, incSettings);
inc.Initialize(hints); // 透传给 IncrementalRecovery.OnRecoveryCoreAsync
inc.WaitForReady();
// 恢复后段表已加载、_nextSeq 已续接,可直接 ReadAllChunksAsync 或 AppendSegmentAsync
注入恢复算法实例(替换默认 IncrementalRecovery):
IRecovery<SnapshotRecoveryHints> customRecovery = new MySnapshotRecovery(...);
using var inc = new IncrementalSnapshot(fs, incSettings, recovery: customRecovery);
// 基类构造透传——CreateRecovery() 工厂被跳过(注入实例优先)
4. API 详表
4.1 StreamSnapshot
| API | 签名 | 行为 |
|---|---|---|
OpenWrite |
StreamFrameWriter OpenWrite() |
物理起点续接开新帧写入器;flush 回调推进双水位(逻辑按 data.Length 非对齐、物理按对齐长度) |
OpenWriteRange |
StreamFrameWriter OpenWriteRange(LogicalAddress start, LogicalAddress end) |
指定逻辑区间开帧(物理续接、逻辑不推进——逻辑↔物理由 checkpoint 二分换算) |
OpenRead |
StreamFrameReader OpenRead() |
从 _truncatedAddress 读到 _writeAddress(剩余可读 = 区间长度) |
OpenReadRange |
StreamFrameReader OpenReadRange(LogicalAddress start, LogicalAddress end) |
指定逻辑区间读 |
| StreamFrameWriter | ||
WriteAsync |
ValueTask WriteAsync(ReadOnlyMemory<byte> data, ct) |
写一个 entry(首次自动加帧头;CRC64 增量累积;EntryCount+1)。快速路径同步完成 |
Write |
void Write(ReadOnlySpan<byte> data) |
同步轨 |
CompleteAsync |
ValueTask CompleteAsync(ct) |
写帧尾(FooterMagic + TotalLength + EntryCount + Crc64)+ flush + 重置(可开新帧) |
Complete |
void Complete() |
同步轨 |
DisposeAsync |
ValueTask DisposeAsync() |
未 Complete 的帧自动闭环(仅 using 也安全) |
TotalDataLength |
long |
当前帧已写 data 字节数 |
EntryCount |
long |
当前帧 entry 条数 |
| StreamFrameReader | ||
ReadDataAsync |
ValueTask<int> ReadDataAsync(Memory<byte> dest, ct) |
读 data(EOF 返回 0,自动解析校验 footer CRC64)。帧头 magic 非法抛 IOException |
ReadAllChunksAsync |
IAsyncEnumerable<ReadOnlyMemory<byte>> ReadAllChunksAsync(ct) |
64KB chunk 流式枚举 |
IsFooterValid |
bool |
footer 是否已解析且 CRC 通过(读至 data 末尾自动解析) |
EntryCount / TotalLength / StoredChecksum |
long/long/ulong |
footer 字段(解析前为 0) |
写流程:OpenWrite → WriteAsync × N → CompleteAsync → using 自动 Dispose。多帧:CompleteAsync 后 ResetFrame 内部复位,可直接继续 WriteAsync 开新帧。
// StreamFrameWriter 写 + Complete(多帧)
await using var writer = stream.OpenWrite();
// 帧一:写 N 个 entry
foreach (var entry in entriesChunk1)
await writer.WriteAsync(entry, ct);
await writer.CompleteAsync(ct); // 帧尾落盘 + flush + ResetFrame(EntryCount 清零)
// 帧二:紧接再写(首 WriteAsync 自动加新帧头)
await writer.WriteAsync(someEntry, ct);
await writer.CompleteAsync(ct);
// DisposeAsync 等价 CompleteAsync(若忘记 Complete 也安全闭环)
// 读回:校验 footer CRC64
await using var reader = stream.OpenRead();
var buf = new byte[64 * 1024];
int n;
while ((n = await reader.ReadDataAsync(buf, ct)) > 0)
Consume(buf.AsSpan(0, n));
if (!reader.IsFooterValid) throw new InvalidDataException("帧 CRC 校验失败");
4.2 IncrementalSnapshot
| API | 签名 | 行为 |
|---|---|---|
AppendSegmentAsync |
ValueTask<long> AppendSegmentAsync(long n0, IAsyncEnumerable<ReadOnlyMemory<byte>> chunks, ct) |
追加段:[Header 14B][N₀ 8B][chunks][Footer 28B] 一帧 → 2PC 提交 → 段表注册(追加)+ meta 原子落盘。返回 n0。段数达阈值自触发合并。单写者——并发/重入抛 InvalidOperationException |
ImportSegmentAsync |
ValueTask<long> ImportSegmentAsync(long n0, IAsyncEnumerable<ReadOnlyMemory<byte>> chunks, ct) |
替换段(raft 快照安装语义):写完整镜像段 → 段表替换为单段(旧段物理回收)。chunk 流异常 = Abort 回滚——新段清除、旧快照完好 |
ReadAllChunksAsync |
IAsyncEnumerable<ReadOnlyMemory<byte>> ReadAllChunksAsync(ct) |
顺序流式读全部段(跳过每段 N₀ 前缀)= 最新快照条目流。段级 CRC64 校验失败抛 InvalidDataException |
ReadSegmentDataAsync |
IAsyncEnumerable<ReadOnlyMemory<byte>> ReadSegmentDataAsync(int index, ct) |
段 i 的数据流(测试/诊断用;越界抛) |
CompactSegmentsAsync |
ValueTask CompactSegmentsAsync(ct) |
合并全部段为新基线段(读全段拼接 → 写新帧 → 截旧段 → 段表=单段 + meta 落盘)。低频——成本 = 一次全量写 |
ClearAsync |
ValueTask ClearAsync(ct) |
清空全部段(截断全量 + 段表空 + meta 落盘) |
SegmentCount |
int |
段数(内存段表,含最新段) |
LatestN0 |
long |
最新段覆盖点 N₀(无段=0) |
GetSegmentN0 |
long GetSegmentN0(int index) |
段 i 的 N₀(单调递增;越界抛) |
段格式:[Header 14B][N₀ 8B][chunks...][Footer 28B]。N₀ = 段自描述覆盖点(raft apply 序号),独立读一段可知其覆盖点。段表(opaque meta 载体)= [count 4B][pad 4B][条目 40B × N],每条目 = LogicalStart 16B + PhysStart 16B + N₀ 8B。
段表恢复 + 阈值合并示例:
// 追加段(raft 快照:n0 = apply 序号)
await inc.AppendSegmentAsync(applyIndex, ProduceChunksAsync(ct), ct);
// 段数 ≥ CompactSegmentThreshold(默认8) → 自触发 CompactSegmentsCoreAsync
// 合并 = 一次全量重写为新基线段(覆盖点 N₀ 不变)+ 旧段物理回收 + 段表=单段 + meta 落盘
// raft 快照安装(替换语义):导入完整镜像段,段表替换为单段
await inc.ImportSegmentAsync(applyIndex, ProduceFullImageAsync(ct), ct);
// 恢复后读最新快照(段表驱动逐段顺序拼接)
await foreach (var chunk in inc.ReadAllChunksAsync(ct))
Consume(chunk);
// 段级帧 CRC64 失败抛 InvalidDataException
// 显式合并(不依赖阈值)
await inc.CompactSegmentsAsync(ct);
单写者契约:AppendSegmentAsync / ImportSegmentAsync / CompactSegmentsAsync / ClearAsync 互斥(_writing 0/1 闸门);并发/重入抛 InvalidOperationException。raft 快照低频串行天然契合。多生产者场景须外部队列汇聚。
4.3 共有(SnapshotBase)
| API | 签名 | 行为 |
|---|---|---|
| 三水位 | ||
WriteAddress |
ref LogicalAddress |
逻辑写尾(非对齐) |
PhysicalWriteAddress |
ref LogicalAddress |
物理写尾(扇区对齐,DIO 写基准) |
TruncatedAddress |
ref LogicalAddress |
逻辑截断点(头截断推进) |
Size |
long |
流大小 = WriteAddress - TruncatedAddress |
| 写 | ||
Append / AppendAsync |
void Append(ReadOnlySpan<byte>) / ValueTask AppendAsync(ReadOnlyMemory<byte>, ct) |
append(可回滚——双水位推进;空数据 no-op) |
Overwrite / OverwriteAsync |
void Overwrite(LogicalAddress, ReadOnlySpan<byte>) / ... |
不可回滚——原位覆写破坏旧数据,2PC 不覆盖 |
| 截断(按字节) | ||
TruncatePrefix |
void TruncatePrefix(LogicalAddress address) |
头截断(业务调,回收已读头部)+ 推进 TruncatedAddress |
TruncateSuffix |
void TruncateSuffix(LogicalAddress address) |
尾截断(2PC Abort 内部)+ 写窗口作废 |
ReclaimRange |
void ReclaimRange(LogicalAddress? from, LogicalAddress? to) |
区间 PunchHole |
2PC(ITransactionParticipant) |
||
Prepare / PrepareAsync |
void Prepare(long seq) |
flush + meta.Commit(记 LastPreparedSeq + 水位) |
ConfirmCommitted |
void ConfirmCommitted(long seq) |
CAS 推进 LastCommittedSeq + 推进 CommittedWriteAddress(Abort 回退点) |
Abort / AbortAsync |
void Abort(long seq) |
TruncateSuffix(CommittedWriteAddress) 回滚 append 部分(Overwrite 不可回滚) |
OnCommitted |
void OnCommitted(long seq, Action callback) |
注册提交回调(已提交到更高 seq 立即同步触发) |
| meta opaque | ||
SetOpaqueMeta |
void SetOpaqueMeta(ReadOnlySpan<byte> data) |
登记外部 opaque(随下次水位提交原子落盘)。Disabled 抛 InvalidOperationException;超 MetaOpaqueBytes 抛 ArgumentException |
ReadOpaqueMeta |
ReadOnlySpan<byte> ReadOpaqueMeta() |
读最近已提交 opaque(无/禁用 = 空) |
| 地址算术 | ||
AdvanceAddress |
LogicalAddress AdvanceAddress(LogicalAddress addr, long delta) |
段感知地址推进/回退 |
Distance |
long Distance(LogicalAddress from, LogicalAddress to) |
段感知两地址间字节距离 |
SectorSize |
int |
设备扇区大小 |
DioAlignment |
int(protected) |
max(SectorSize, 4096)——保证 DIO 兼容 |
4.4 2PC 编排(跨结构原子性)
SnapshotBase 实现 ITransactionParticipant——可参与 Transactions/TransactionLog 协调器驱动的跨结构原子性(详见 session.md)。Snapshot 侧的 2PC 语义:
Prepare(seq):_engine.Flush()+ 记LastPreparedSeq+WriteMeta()(meta 落盘 prepared 水位——悬干状态)ConfirmCommitted(seq):CAS 推进LastCommittedSeq+ 推进CommittedWriteAddress(Abort 回退基准)+WriteMeta()+FireTransactionCallbacksAbort(seq):TruncateSuffix(CommittedWriteAddress)回滚 append 部分(Overwrite 不可回滚)+WriteMeta()OnCommitted(seq, callback):注册回调(已提交到更高 seq 立即同步触发——链式触发)
// Snapshot 单独走 2PC(IncrementalSnapshot 内部即用——段写事务)
long seq = 1;
stream.Prepare(seq); // flush + meta 记 prepared
stream.ConfirmCommitted(seq); // CAS 推进 committed + CommittedWriteAddress 推进
// 后续 Abort(seq-1) 不影响;CommittedWriteAddress 是新的回退基准
// 异常路径
stream.Abort(seq); // TruncateSuffix(CommittedWriteAddress)——append 部分回滚,Overwrite 不回滚
IncrementalSnapshot.AppendSegmentAsync 内部即走 2PC:Prepare(seq) → ConfirmCommitted(seq) → 段表注册 + meta 落盘;chunk 流异常 = Abort(seq)——尾截断回滚(新段物理清除,旧段完好)。这是会话模式一致性核心(详见 session.md)。
5. 自定义注入工厂
SnapshotBase 构造接受三个可选注入参数;子类(StreamSnapshot/IncrementalSnapshot)转发到基类构造。
| 注入点 | 类型 | 用途 |
|---|---|---|
recovery |
IRecovery<SnapshotRecoveryHints>? |
恢复算法实例(默认走 CreateRecovery() 工厂——StreamSnapshot=DefaultSnapshotRecovery、IncrementalSnapshot=IncrementalRecovery)。注入实例则跳过工厂 |
metaPolicyFactory |
MetaPolicyFactory<SnapshotMetaHeader, SnapshotMetaPayload>? |
meta 策略工厂委托(MetaPolicyKind → IMetaPolicy);默认走 CreateMetaPolicyDefault——Managed/Transport/Disabled 三选一。自定义注入可替换 Managed 实现 |
metaTransport |
IMetaTransport? |
Transport 模式的传输实例(默认回落到内嵌 MetaHost——meta 块作为 IS_META 帧追加进快照流) |
| Contracts | ||
ISnapshotCodec |
internal sealed StreamFrameCodec |
流式帧 codec——Header/Footer 读写、magic 校验。两子类共用同一实例(构造期 new StreamFrameCodec() 注入基类) |
ISnapshotWriteSession |
IDisposable, IAsyncDisposable |
双 buffer flush 流水线写会话——WriteAsync/Write/WriteSmall/FlushIfFullAsync/FlushAsync/OnFlushed 事件 |
ISnapshotReadSession |
IAsyncDisposable |
双 buffer 异步预读读会话——ReadAsync/LogicalStart/LogicalEnd/PhysicalEnd(物理/逻辑偏移分离) |
SnapshotMetaHeader |
12B struct | meta 三段式 header(MagicValue/Version/Flags/PayloadLength/PaddingLength) |
SnapshotMetaPayload |
80B struct | meta payload(三水位 + CommittedWriteAddress + LastCommittedSeq/LastPreparedSeq) |
SnapshotRecoveryHints |
readonly struct | 恢复提示(WriteAddress?/PhysicalWriteAddress?)——上层注入已知水位,优先于扫盘 |
SnapshotRecordFields |
readonly record struct | 帧字段包(Header: Flags/PayloadLength/PaddingLength;Footer: TotalLength/EntryCount) |
StreamFrameCodec |
internal sealed |
ISnapshotCodec 默认实现——两子类共用 |
ITransactionParticipant |
接口 | SnapshotBase 实现——2PC 协调器(Transactions/TransactionLog)驱动跨结构原子性,详见 session.md |
6. 持久化机制
6.1 流式帧格式(StreamFrameCodec)
两子类共用同一帧格式:
[Header 14B][Data][Footer 28B][padding 对齐到扇区]
- Header 14B(
StreamFrameHeader):MagicValue(4B,"SNHD")+Version(2B,major=1/minor=0)+Flags(2B)+PayloadLength(4B)+PaddingLength(2B) - Footer 28B(
StreamFrameFooter):Magic(4B,"SNFT")+TotalLength(8B)+EntryCount(8B)+Crc(8B) - CRC64:覆盖
Header + Data + Footer 前 20B(CRC 自身 8B 不参与)。流式增量累积(UnifiedCrc64,边写边算,不驻内存整块)——GB/TB 级快照可零内存压力流式写 - FooterMagic:供 Backward 扫描定位帧尾(恢复取真尾——从尾向头扫
chunkSize=64KB块找FooterMagic命中) - 对齐粒度:扇区(codec
Alignment=512);StreamSnapshotSettings默认段几何 256MB + 分段开启 + 不预分配(GB/TB 流式跨段扩容)
6.2 按字节截断 vs Mirror 版本号截断
| 维度 | Snapshot(StreamSnapshot/IncrementalSnapshot) | Mirror(WholeMirror/PagedMirror) |
|---|---|---|
| 截断坐标系 | 字节(LogicalAddress——头/尾字节地址) |
版本号(版本链 N=2 轮转) |
| append 可回滚 | ✅ TruncateSuffix(CommittedWriteAddress)——尾部字节回滚 |
❌(版本链不可回滚,靠 N=2 轮转回收旧版本) |
| Overwrite 不可回滚 | ✅ 独立方法名显形(覆写破坏旧数据,2PC 不覆盖) | 走 WritePage 等覆写语义 |
| 版本链 | ❌ 无版本概念 | ✅ N=2 轮转 |
| epoch | ❌ 无 | ✅ |
| 同族定位 | 纯流式形态(基线导出) | 版本链形态(基线镜像) |
同族异坐标系:两者都承载"完整状态基线",但 Snapshot 是追加流(Append 可回滚、Overwrite 不可回滚、无版本链),Mirror 是版本链(N=2 轮转、覆写历史可读)。raft 日志压缩选哪个看场景:流式整体导出 → StreamSnapshot;版本化镜像 → Mirror;段级增量(新段不重写旧段) → IncrementalSnapshot。
6.3 IncrementalSnapshot 段累积机制
段1:[Header 14B][N0₁ 8B][条目流₁][Footer 28B] ← append,不重写
段2:[Header 14B][N0₂ 8B][条目流₂][Footer 28B] ← append
...
段N:[Header 14B][N0_N 8B][条目流_N][Footer 28B] ← append
[合并达阈值 → CompactSegments → 新基线段(N0_N 不变)+ 旧段物理回收]
- 新段 append 不重写旧段:段写 =
OpenWriteSession(physStart)物理续接,不动旧段;段表注册在 2PCConfirmCommitted后(SetOpaqueMeta(SerializeSegments())+WriteMeta()),opaque meta 搭水位线原子落盘(同一块同一 CRC) - 段表 = O(1) 恢复定位:恢复时
DeserializeSegments(ReadOpaqueMeta())一次拿到全部段起点(LogicalStart+PhysStart+N₀),免全盘扫描 - 段表条目 40B:
LogicalStart 16B + PhysStart 16B + N₀ 8B(PhysStart必须持久化——逻辑↔物理有 flush padding 偏差,无法从逻辑推算) - 段级 CRC64:每段独立帧 CRC——崩溃 = 旧段完好、半写段恢复期被悬干裁决截掉
- 阈值合并:段数 ≥
CompactSegmentThreshold(默认 8)自触发CompactSegmentsCoreAsync——读全段拼接 → 写新基线帧 → 截旧段(TruncatePrefix到新基线帧起点向下 4096 对齐)→ 段表=单段 + meta 落盘。低频——成本 = 一次全量写;raft 只需最新快照,段累积不无限增长
6.4 StreamFrameWriter checkpoint 机制(逻辑↔物理换算)
StreamSnapshot 内部维护帧 checkpoint 列表 _frameCheckpoints——每次 OpenWrite / OpenWriteRange 调用 RecordCheckpoint(logical, physical) 追加(按 logical 单调)。LogicalToPhysical(logical) 二分查找最后一个 logical <= 目标 的 checkpoint,再叠加偏移得到物理地址。
为何需要:DIO 写要求扇区对齐(每次 flush 块 = AlignUp(logicalBytes, SectorSize)),但逻辑字节流非对齐。多次 flush 后逻辑↔物理产生 padding 偏差累积,不能简单线性换算。checkpoint = 每个 OpenWrite 起点的双坐标系锚点,二分 + 偏移叠加 = O(log N) 换算。
OpenWriteRange(start, end) 的 flush 回调只推进物理尾(不推进逻辑尾)——逻辑区间由调用方拥有,物理续接。读侧 OpenRead / OpenReadRange 用 AlignDownAddress(LogicalToPhysical(start)) 定位物理预读起点。
7. 恢复协议
SnapshotBase 走 LifecycleBase<SnapshotRecoveryHints> 模板——Initialize → CreateRecovery() 单点 → RecoveryBase<SnapshotRecoveryHints> 状态机驱动(状态机/闸门/进度全在模板)。
7.1 StreamSnapshot 恢复(DefaultSnapshotRecovery)
三级回退(按优先级):
- hints(外部注入水位):
SnapshotRecoveryHints.WriteAddress非 null → 直接采用;PhysicalWriteAddress缺省走AlignUpToSector(hints.WriteAddress) - meta O(1) 水位:
MetaPolicyKind != Disabled时MetaPolicy.LoadAsync+ReadMetaPayload→ 一次拿到WriteAddress/PhysicalWriteAddress/TruncatedAddress/CommittedWriteAddress/LastCommittedSeq/LastPreparedSeq - Backward 扫描兜底(Disabled):
LocateLastFrameEnd()——从CommittedTail向头按 64KB 块扫,找FooterMagic命中 + Footer 完整可读 → 帧尾 = magic 位置 + FooterSize。前缀洞天然免疫(从尾向头扫,TruncatePrefix段内打洞不影响)
悬干裁决(append 回滚):meta LastPreparedSeq > LastCommittedSeq = append 写崩溃(Prepare 后 Confirm 前)→ TruncateSuffix(CommittedWriteAddress) 回滚 append 部分(Overwrite 不可回滚,悬干不涉及)。
7.2 IncrementalSnapshot 恢复(IncrementalRecovery)
三步核心:
- 三水位恢复(同 StreamSnapshot 三级回退——hints → meta → Backward 扫描)
- 悬干裁决(段写崩溃):meta
LastPreparedSeq > LastCommittedSeq= 段写 Prepare 后 Confirm 前崩溃 →TruncateSuffix(CommittedWriteAddress)尾截断回滚(未提交段物理清除——失败即清理;已提交段表/数据完好)。无"自动认领未提交段"语义——未提交 = 失败 = 清理 - 段表 O(1) 加载:
DeserializeSegments(ReadOpaqueMeta())一次拿到全部段起点(仅已提交段;恢复免全盘扫描)+_nextSeq = _lastCommittedSeq + 1(2PC 事务序号续接——段写事务单调)
恢复后 SegmentCount / LatestN0 可直接查询;调用方即可 ReadAllChunksAsync 拿最新快照条目流,或继续 AppendSegmentAsync 追加新段,或 ImportSegmentAsync 安装新快照。
7.3 SnapshotRecoveryHints 注入
// 上层(如 raft 节点)已知 apply 序号或外部水位时注入
var hints = new SnapshotRecoveryHints
{
WriteAddress = knownWriteTail, // 优先采用
PhysicalWriteAddress = knownPhysTail, // 可选;缺省走 AlignUpToSector
};
stream.Initialize(hints); // LifecycleBase<>.Initialize(hints) 透传给恢复核心
stream.WaitForReady();
hints 优先级最高——适合 raft 节点重启时已知 apply 序号场景;缺省走 meta O(1);meta 禁用时走 Backward 扫描。
7.4 阈值合并与 raft 日志压缩
raft 日志压缩的核心需求:只需最新快照。IncrementalSnapshot 的段累积机制契合——
- 每次 raft 快照 = 一段(N₀ = apply 序号)
- 新段 append 不重写旧段(快照越频繁写省越多)
- 段表 O(1) 恢复定位(免全盘扫描)
- 段数达阈值自合并为新基线段(低频全量重写——成本可控)
ImportSegmentAsync用于 raft 快照安装(替换语义——新镜像段替旧快照集)
raft 节点重启 → IncrementalRecovery 段表加载 → ReadAllChunksAsync 拿最新快照 → 从 LatestN0 续接 raft log replay。
7.5 恢复全景(端到端时序)
┌─ SnapshotBase.Initialize(hints) ─────────────────────────────┐
│ ① OnInitializeBegin: _engine.Initialize()(同步) │
│ _metaEngine?.Initialize()(Managed 模式——并行启动) │
│ ② CreateRecovery() → 默认/注入 IRecovery<SnapshotRecoveryHints> │
│ ③ RecoveryBase 状态机驱动: │
│ a. WaitForDependenciesAsync: join 主引擎 + meta 引擎就绪 │
│ b. OnRecoveryCoreAsync(hints): 三水位 + 悬干 + 段表 │
│ ④ WaitForReady 返回——水位已裁决、段表已加载、可读写 │
└──────────────────────────────────────────────────────────────┘
三水位恢复链(IncrementalSnapshot):
hints.WriteAddress? ─┬─► 采纳 + AlignUpToSector (RaiseProgress 50)
缺省 → meta.LoadAsync ─┼─► SnapshotMetaPayload 推三水位 + CommittedWriteAddress
缺省 → Backward 扫描 ─┴─► LocateLastFrameEnd() 找 FooterMagic (RaiseProgress 50→90)
悬干裁决: meta.LastPreparedSeq > LastCommittedSeq
─► TruncateSuffix(CommittedWriteAddress) (RaiseProgress 60)
未提交段物理清除;已提交段表/数据完好
段表加载: DeserializeSegments(ReadOpaqueMeta()) → _segments
_nextSeq = _lastCommittedSeq + 1 (RaiseProgress 90)
8. 反模式
| # | 反模式 | 后果 / 正解 |
|---|---|---|
| 1 | 混淆 Ring 区间快照与 StreamSnapshot/IncrementalSnapshot | Ring 的 OpenSnapshotReader/Writer 是 Ring 导出能力(区间字节流,依附 Ring 生命周期),本子族是独立文件形态(自有 StorageEngine)。混用 = 生命周期错绑/资源泄漏。正解:独立选型,Ring 区间导出走 Ring API,文件快照走本子族 |
| 2 | IncrementalSnapshot 段累积不合并 | 段数无限增长 → 恢复期段表解析/读段拼接 O(N) 线性退化;磁盘空间累积。正解:设 CompactSegmentThreshold(默认 8)或定期显式 CompactSegmentsAsync |
| 3 | 跨实例组合 DeleteOnClose=true |
跨重启组合必须 DeleteOnClose=false——否则真相源(Ring)重建时快照被清。SnapshotSettings 默认 false,勿改 true |
| 4 | IncrementalSnapshot 关闭 meta(MetaPolicyKind=Disabled) |
段表靠 opaque meta 落盘,禁用 = 段表丢失 → 恢复走 Backward 扫描 O(N) 全盘 + 段表无法续接。正解:开 Managed + MetaOpaqueBytes ≥ 8 + 段数上限 × 40 |
| 5 | Overwrite 当 Append 用 |
Overwrite 不可回滚——覆写破坏旧数据,2PC 不覆盖,Abort 不能回滚。正解:要回滚走 Append/AppendAsync |
| 6 | 多生产者并发 AppendSegmentAsync |
单写者硬性要求(_writing 0/1 闸门)——并发抛 InvalidOperationException。正解:多生产者经外部队列汇聚到单写者;raft 快照低频串行天然契合 |
| 7 | StreamFrameWriter 不 CompleteAsync 也不 using |
未完成帧无 Footer → 读侧 magic 校验失败/CRC 无法验。正解:await using 自动闭环(DisposeAsync 等价 CompleteAsync) |
| 8 | MetaOpaqueBytes 容量不足(IncrementalSnapshot) |
段表序列化超容量 → SetOpaqueMeta 抛 ArgumentException,段表无法落盘。正解:按 8 + 段数上限 × 40 估算,留余量(如 100 段 → ≥4008B → 设 4096B) |
| 9 | 零拷贝 span 跨 await 消费 StreamFrameReader.ReadDataAsync |
ReadDataAsync 已是拷贝交付(填充 caller buffer),但若用 ReadAllChunksAsync 返回的 ReadOnlyMemory<byte> 跨 await 持有需注意——ArrayPool 租用块在枚举下一次迭代后归还。正解:跨 await 持有须拷贝出去 |
9. 想深入?指路
| 想懂什么 | 去哪 |
|---|---|
| Snapshot 子族源码(帧布局/段表/恢复算法/会话双 buffer 流水线) | src/TC.Tier.Runtime/Structures/Snapshot/(各 partial) |
| meta 统一协议(opaque 搭车水位线/块格式/Managed/Transport 三模式) | meta.md |
| Session 协调协议(写/读/检查点三 op、悬挂裁决、故障模型——2PC 协调层) | session.md |
| StorageEngine 使用(读写/水位/恢复/Compact/Allocate 写窗口) | storage-engine.md |
| 段表恢复细节(IncrementalSnapshot opaque 段表载体/序列化格式) | segment-table.md |
同族 Ring(数据真相源——OpenSnapshotReader/Writer 区间导出能力) |
ring.md |
| 同族 Log(WAL——raft log replay 续接点) | log.md |
| 同族索引(HashIndex/BTreeIndex/SkipListIndex——派生数据) | index.md |
| 同族 Metadata(版本链单值元数据——跨重启单值状态) | versioned-metadata.md |
| 同族 Mirror(同族异坐标系——版本号截断的镜像形态) | mirror.md |
| Lease 协议(raft 共识层 lease) | lease-protocol.md |
| Structures 总览(6 子族选型/组合 KV/恢复统一模型) | structures.md |
| raft 日志压缩(IncrementalSnapshot 镜像快照第三部件) | src/TC.Tier.Products/Wal/(TierWal) |
10. 端到端示例:raft 日志压缩场景
raft 节点周期性快照 + 重启恢复的典型编排:
using var fs = TierFs.New("local:///var/tier/raft-node-1");
var settings = new IncrementalSnapshotSettings
{
MetaPolicyKind = MetaPolicyKind.Managed, // 段表靠 opaque 落盘——必开
MetaOpaqueBytes = 4096, // 容纳 ~100 段表条目
CompactSegmentThreshold = 8, // 8 段自合并
};
using var snap = new IncrementalSnapshot(fs, settings);
snap.Initialize();
snap.WaitForReady();
// ── 周期性快照(raft apply 回调里调) ──
async Task OnRaftApplySnapshot(long applyIndex, IAsyncEnumerable<ReadOnlyMemory<byte>> stateStream, CancellationToken ct)
{
// 段 = raft apply 序号快照点;新段 append 不重写旧段
await snap.AppendSegmentAsync(applyIndex, stateStream, ct);
// 段数 ≥ 8 时自合并为新基线段(覆盖点不变),旧段物理回收
}
// ── raft 快照安装(外部镜像注入——如 Leader 同步快照) ──
async Task OnRaftInstallSnapshot(long applyIndex, IAsyncEnumerable<ReadOnlyMemory<byte>> fullImage, CancellationToken ct)
{
// 替换语义:段表替换为单段,旧段物理回收
await snap.ImportSegmentAsync(applyIndex, fullImage, ct);
}
// ── 重启恢复 ──
// Initialize() 内部 IncrementalRecovery:
// 1. 三水位恢复(hints → meta → Backward 扫描)
// 2. 悬干裁决(meta prepared > committed → 尾截断回滚)
// 3. 段表 O(1) 加载 + _nextSeq 续接
// 恢复后即可读最新快照 + raft log replay 续接
async Task<IAsyncEnumerable<ReadOnlyMemory<byte>>> LoadLatestSnapshot(CancellationToken ct)
{
Console.WriteLine($"段数={snap.SegmentCount}, 最新N0={snap.LatestN0}");
return snap.ReadAllChunksAsync(ct); // 逐段顺序拼接 + CRC64 校验
}
关键点:
MetaPolicyKind=Managed必开——段表 opaque 落盘,恢复 O(1) 定位CompactSegmentThreshold调到实际段数上限内(默认 8 适合多数场景)ImportSegmentAsync用于 raft 快照安装(Leader → Follower 同步)- 段级 CRC64 + 悬干裁决 = 崩溃安全(未提交段物理清除,已提交段完好)
- raft log replay 从
LatestN0 + 1续接(apply 序号单调递增)