卷影复制服务(VSS)的工作原理
卷影复制服务(Volume Shadow Copy Service)是一组 Windows 操作系统提供的 COM API,旨在为存储层提供"时间点一致性快照"能力。VSS 的核心价值在于:快照由存储子系统管理,对上层应用程序透明——创建完成后文件系统可以继续正常读写,而只读的历史时间点副本由提供程序在后台维护。这意味着"参照物"的制造不依赖文件复制,快照本身就是存储层维护的独立视图。
VSS 的协作模型包含三类角色。请求者(Requestor)向 VSS 发起快照创建请求,应用程序(如备份软件或版本管理工具)作为请求者调用 API。卷影副本提供程序(Provider)负责实际创建并维护卷的时间点副本,分为软件提供程序和硬件提供程序两类,前者由 Windows 内置,通过重定向写入机制在存储子系统中维护增量差异,后者由存储阵列在固件层面实现,通常支持更多快照数量。协调器(Coordinator)在请求者、提供程序与文件系统/存储驱动之间进行协商,确保快照创建期间文件系统处于一致状态。
VSS 通过快照上下文区分快照的使用目的。常用的上下文包括用于备份场景的 Backup(快照通常在备份完成后释放)、面向 NAS 存储场景设计的 NasRollback(具有最广泛的硬件兼容性)以及用于网络共享快照的 FileShareBackups。其中 NasRollback 在大多数企业级存储环境中均可正常工作,是一个较为稳妥的默认选择。
在实际使用中需要留意若干系统级限制。不同提供程序对单个卷上可同时存在的持久卷影数量设有不同限制,常见范围为 2 至 64 个,软件提供程序的配额通常较为紧张,如果目标环境中有其他程序在使用 VSS 快照,需要确认剩余配额。卷影快照绑定到特定卷,被跟踪目录须完整位于同一卷内,跨卷目录会导致系统无法建立统一的快照上下文。创建和删除卷影快照通常要求管理员权限,这对自动化集成场景提出了额外要求。快照的增量差异由提供程序维护在存储层,仍需占用磁盘空间,存储空间耗尽时快照创建将失败,但失败不会影响当前文件系统状态。
二、数据结构设计
在展开具体实现之前,先明确系统内部流转的核心数据类型。
RecoveryChainState 是系统状态的顶层容器,记录在 state.json 中,包含四个字段:SchemaVersion 作为格式版本号便于将来数据迁移;RefSnapshotId 记录当前参照卷影的 GUID,是系统重新获取卷影句柄的唯一标识;Actions 顺序记录了所有已提交的版本动作,列表长度即为版本数量。RecoveryActionEntry 是 Actions 中的单个元素,包含动作序号 Index、创建时间 CreatedUtc、用户备注 Comment 以及该动作 manifest 文件的相对路径 ActionRelativePath。
RecoveryActionManifest 是每次动作提交时写入磁盘的核心记录,包含动作序号 ActionIndex、创建时间和备注之外,最关键的是 InverseSteps 列表——这是按特定顺序排列的可逆步骤序列,涵盖 delete_file(删除新增文件)、restore_file(从 blob 还原被修改/删除的文件)、rename(逆转重命名操作)、create_directory(重建被删除的目录)和 delete_directory(删除新增的目录)五种操作类型。每个步骤携带 RelativePath、BlobKey(指向 blobs 目录中的压缩备份)、FromRelativePath 和 ToRelativePath 等字段,用于执行具体的文件系统操作。
RecoveryInverseStep 中的 BlobKey 是在 commit 阶段动态填入的——RecoveryDiffBuilder.Build 方法仅产出步骤骨架和 blob 源文件路径,实际的压缩写入和键值计算由 RecoveryPipeline.Commit 在遍历 built.RestoreBlobSources 时完成。这种分离设计使得比对逻辑和存储逻辑保持独立。
三、卷影抽象层
核心库定义了一个卷影抽象接口 IVolumeSnapshotProvider,业务逻辑层仅依赖该接口而不直接引用任何 Windows VSS API:
public readonly record struct VolumeShadowSnapshot(Guid SnapshotId, string SnapshotDeviceObject);
public interface IVolumeSnapshotProvider
{
VolumeShadowSnapshot CreatePersistentSnapshot(string pathOnVolume);
void DeleteSnapshot(Guid snapshotId);
bool TryGetSnapshot(Guid snapshotId, out VolumeShadowSnapshot snapshot);
string MapPathIntoSnapshotVolume(string pathOnLiveVolume, in VolumeShadowSnapshot snapshot);
}
CreatePersistentSnapshot 在被跟踪路径所在卷上创建持久卷影并返回快照 ID 与设备对象路径;DeleteSnapshot 按 ID 删除卷影;TryGetSnapshot 查询给定快照是否仍然存在;MapPathIntoSnapshotVolume 将实时文件系统路径映射到卷影卷内的等价绝对路径。这个接口的抽象价值在于:生产环境注入 VSS 实现,测试环境则可替换为内存模拟实现,未来的快照技术升级(如直接利用 ReFS 卷的快照能力)也只需替换实现层而不触动业务逻辑。
四、生产实现:AlphaVssVolumeSnapshotProvider
当前唯一的生产实现基于 AlphaVSS 库——一个对 COM-based Windows VSS API 的托管包装。AlphaVssVolumeSnapshotProvider 的核心实现如下:
public sealed class AlphaVssVolumeSnapshotProvider : IVolumeSnapshotProvider
{
private static readonly VssFactoryProvider FactoryProvider =
new(new AppBaseFallbackAssemblyResolver());
public VolumeShadowSnapshot CreatePersistentSnapshot(string pathOnVolume)
{
var full = Path.GetFullPath(pathOnVolume);
if (!Directory.Exists(full) && !File.Exists(full))
throw new DirectoryNotFoundException($"路径不存在: {full}");
var factory = FactoryProvider.GetVssFactory();
using IVssBackupComponents backup = factory.CreateVssBackupComponents();
backup.InitializeForBackup(null!);
backup.SetContext(VssSnapshotContext.NasRollback);
var roots = backup.GetRootAndLogicalPrefixPaths(full, false);
backup.StartSnapshotSet();
Guid snapshotId = backup.AddToSnapshotSet(roots.RootPath);
backup.DoSnapshotSet();
var props = backup.GetSnapshotProperties(snapshotId);
return new VolumeShadowSnapshot(snapshotId,
props.SnapshotDeviceObject.TrimEnd('\\'));
}
}
创建快照的标准流程是:先通过 VssFactoryProvider 获取 IVssBackupComponents 实例,调用 InitializeForBackup 初始化为请求者角色,然后设定上下文为 NasRollback,通过 GetRootAndLogicalPrefixPaths 获取目标路径所在的卷根路径,再调用 StartSnapshotSet 开启快照集、AddToSnapshotSet 将该卷加入快照集、DoSnapshotSet 执行快照创建。整个过程是幂等的,重复调用会创建新的快照而不是覆盖旧的。
删除操作使用 DeleteSnapshot,传入快照 ID 即可。实现中捕获了 VssObjectNotFoundException——当快照已不存在时(如被其他清理工具删除)直接忽略,避免调用方承担"快照是否还存在"的状态判断负担。
路径映射是另一个关键操作。当需要对比卷影中的文件与实时文件系统中的文件时,必须将实时路径转换为卷影卷内的对应路径。实现思路是:将实时路径减去卷根前缀,得到相对路径,再拼接到卷影设备对象根上。这里需要注意处理路径末尾的反斜杠——DeviceRootTrimmed 是通过 TrimEnd('\\') 预处理过的,以避免拼接时出现双反斜杠。
五、.NET 10 下的 DLL 加载兼容性
在 .NET 10 环境下存在一个值得注意的兼容性细节。当 NATIVE_DLL_SEARCH_DIRECTORIES 环境变量已被其他组件设置时,.NET 运行时不再回退到默认的程序集探测目录,导致 AlphaVSS.Native 的 AlphaVSS.x64.dll(位于应用程序输出目录)无法被正确加载。
解决方案是显式实现 IVssAssemblyResolver,按优先级依次尝试三个目录:AlphaVSS.Common 程序集所在目录(等效于原 DefaultVssAssemblyResolver 的行为)、AppContext.BaseDirectory(应用程序输出目录,AlphaVSS.x64.dll 实际所在位置)、当前工作目录。搜索路径列表被记录在 searched 变量中,最终异常消息包含完整的搜索路径,便于排查加载失败的原因。VssFactoryProvider 接受自定义解析器实例替代默认的 VssFactoryProvider.Default,从而在 .NET 10 环境下绕过运行时行为变化。
六、核心业务流程
6.1 RecoveryPipeline 构造函数与状态初始化
RecoveryPipeline 是整个系统的核心编排类,封装了 Init、Start、Commit 和 Rollback 四个关键方法。其构造函数接收三个参数:被跟踪目录路径、recovery 根目录路径(允许为空,由 FvsPathLayout 计算默认位置)以及卷影提供程序实例。
public RecoveryPipeline(string watchedFolderPath, string? recoveryStoreRoot,
IVolumeSnapshotProvider volumeSnapshots)
{
_volumeSnapshots = volumeSnapshots
?? throw new ArgumentNullException(nameof(volumeSnapshots));
WatchedFolderPath = Path.GetFullPath(
watchedFolderPath.TrimEnd('\\', '/'));
if (!Directory.Exists(WatchedFolderPath))
throw new DirectoryNotFoundException(
$"被跟踪的文件夹不存在: {WatchedFolderPath}");
RecoveryStoreRoot = Path.GetFullPath(
recoveryStoreRoot ?? FvsPathLayout.DefaultRecoveryRoot(WatchedFolderPath));
StatePath = Path.Combine(RecoveryStoreRoot, "state.json");
ActionsDir = Path.Combine(RecoveryStoreRoot, "actions");
BlobsDir = Path.Combine(RecoveryStoreRoot, "blobs");
RollbackJournalPath = Path.Combine(RecoveryStoreRoot,
".rollback-in-progress.json");
}
值得注意的设计是路径规范化:所有传入的路径在进入系统前都经过 TrimEnd 处理,统一转为不以反斜杠或斜杠结尾的形式,避免后续路径拼接时出现双反斜杠这类潜在问题。recovey 根目录的计算通过 FvsPathLayout.DefaultRecoveryRoot 完成,其内部使用被跟踪目录完整路径的 SHA256 哈希值作为子目录名,既避免了路径冲突,又提供了基础隐私保护。
6.2 Init 与 ActionStart
Init 仅在首次使用时调用,核心逻辑是调用 CreatePersistentSnapshot 创建一个参照卷影,然后将快照 ID 写入 state.json。此阶段不执行任何文件复制操作——快照由存储层维护,初始化延迟仅与卷影创建时间相关,与目录规模无关。
ActionStart 在已有状态的情况下会先删除旧的参照卷影,然后立即创建一个新的,将基准点刷新到当前时刻,使用户在 start 之后的所有文件变更都能在下次 commit 时被检测到。如果 state.json 不存在,ActionStart 退化为调用 InitCore,因此可以无条件执行 start 而无需先 init。
6.3 Commit:差异比对与数据持久化
Commit 是系统中最复杂的操作,涉及四个子步骤。首先,通过 TryGetSnapshot 验证参照卷影是否仍然存在,如果快照被外部因素意外删除则抛出异常。其次,调用 MapPathIntoSnapshotVolume 将被跟踪目录的卷影内路径计算出来,作为比对基准。然后,调用 RecoveryDiffBuilder.Build 执行三路差异比对,得到 InverseSteps 列表和 blob 源文件列表。如果 InverseSteps 为空(即本次变更无文件级差异),系统仅执行卷影轮换,不产生新的动作记录。
当存在变更时,系统会遍历 built.RestoreBlobSources,对每个需要备份的文件调用 WriteContentAddressedBlob 进行压缩写入并获得 blob 键值,然后将键值回填到对应的 RecoveryInverseStep 中。Manifest 随后写入 actions/<序号>/manifest.json,旧卷影被删除,新卷影被创建并更新到 state.json 中。整个 commit 流程在一个互斥锁内完成,确保多进程或多次调用不会同时修改 recovery 状态。
七、并发控制与 IO 稳定性
Init、Start、Commit 和 Rollback 这些关键操作涉及对 state.json 和 blobs 目录的读写,天然不能并发执行。RecoveryIoRetry.AcquireRecoveryMutex 使用命名互斥锁实现跨进程串行化:
public static RecoverySessionLock AcquireRecoveryMutex(
string recoveryStoreRoot, TimeSpan wait)
{
var full = Path.GetFullPath(
recoveryStoreRoot.TrimEnd('\\', '/'));
var token = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(
full.ToUpperInvariant())))[..32].ToLowerInvariant();
var names = new[]
{
@"Global\FolderShadowVersions-Recovery-" + token,
@"Local\FolderShadowVersions-Recovery-" + token
};
foreach (var name in names)
{
Mutex? m = null;
try { m = new Mutex(false, name, out _); }
catch (UnauthorizedAccessException) { m?.Dispose(); continue; }
catch (Exception) { m?.Dispose(); continue; }
try
{
if (!m.WaitOne(wait))
{
m.Dispose();
throw new TimeoutException(
$"等待 recovery 互斥锁超时({wait.TotalSeconds:0} 秒)。"
+ $"可能另有进程正在 init/start/commit/rollback:{full}");
}
return new RecoverySessionLock(m);
}
catch (TimeoutException) { throw; }
catch { m.Dispose(); }
}
throw new InvalidOperationException(
"无法创建或获取 recovery 互斥锁(Global/Local 均失败)。");
}
互斥锁名称由 recovery 根目录的 SHA256 前 32 位哈希值生成,确保不同被跟踪目录不会相互阻塞。代码同时尝试 Global 和 Local 命名空间,前者适用于跨会话(跨用户)的互斥,后者作为备选——在某些受限环境下 Global 命名空间可能因权限问题无法创建。两个命名空间都失败时才抛出异常。
IO 重试策略通过指数退避来处理瞬时失败。重试间隔以 40ms 为初始值,每次失败后翻倍(最多覆盖指数前 8 位,即最大间隔约 10 秒),在 10 次重试内通常能覆盖绝大多数瞬时失败场景。原子文件写入通过"先写临时文件再 rename"实现,将内容写入目标路径的 .tmp 文件,然后使用 File.Move 的 overwrite: true 参数完成原子替换。
八、差异比对算法
差异比对由 RecoveryDiffBuilder.Build 方法实现,采用经典的三路比较策略。
文件枚举使用 Directory.EnumerateFiles 而非 GetFiles,两者语义相近但前者是延迟枚举,在被跟踪目录包含大量文件时能够减少内存峰值。每个文件的元组中记录了完整路径、大小和最后修改时间(UTC),其中大小和修改时间用于快速的元数据级比较。FileMetaEquals 方法仅比较 length 和 LastWriteTimeUtc,大多数情况下足够有效——只有 size 相同但修改时间不一致时,才会触发后续的内容哈希验证。
内容哈希验证使用 SHA256.Create 实现增量计算,避免将整个文件一次性读入内存。对于大文件来说,这种增量方式将内存占用控制在常量级别,而总计算量仍然与文件大小成正比。
重命名检测是比对结果中最有价值的一类:它将"旧文件删除 + 新文件新增"合并为一条语义更精确的"重命名"步骤,在回滚时只需执行一次 Move 操作即可还原,而无需走"删除新文件 + 从 blob 还原旧文件"的完整两步。检测的条件是大小相同且内容哈希完全一致——这是一个保守的策略,避免将"恰好内容相同的两个无关文件"误判为重命名。
逆序步骤的排列顺序是一个容易被忽视但至关重要的细节。目录结构在回滚过程中必须始终合法——在删除一个文件之前,其父目录必须存在;在重建被删除的目录之前,该目录下的所有子目录应已重建完毕。因此,步骤按以下顺序执行:从浅层到深层的目录创建 → 重命名 → 从 blob 还原文件 → 从深层到浅层的文件删除 → 从深层到浅层的目录删除。这个顺序通过 DirDepth 方法计算相对路径中的反斜杠数量来确定层级深度,并分别使用 OrderBy 和 OrderByDescending 控制排序方向。
九、Blob 压缩存储
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)