卷影复制服务(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(删除新增的目录)五种操作类型。每个步骤携带 RelativePathBlobKey(指向 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 压缩存储

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐