Windows 下 PHP 应用的灾难恢复:构建基于 VSS(卷影复制)的 PHP 数据库一致性备份

好了,各位编程界的同仁,特别是那些还在 Windows 服务器上维护 PHP 应用的苦命开发,把手里的咖啡放下,把那个“我的数据库又挂了”的投诉邮件收起来。

今天我们不谈那些虚头巴脑的架构图,也不谈那些写在博客里“如何优雅地重启服务器”的鸡汤。我们要谈的是硬货,是能保你周末去钓鱼、让你半夜不尿尿的救命稻草——基于 VSS(卷影复制服务)的 PHP 数据库一致性备份

我知道,听到“VSS”和“PHP”这两个词组合在一起,很多人的第一反应是:“这东西不是 .NET 的专利吗?PHP 不是靠 Linux 吃饭的吗?”

错!大错特错!PHP 在 Windows 上那可是如鱼得水,尤其是当你需要和 SQL Server 深度绑定的时候。只不过,这里有一个绕不过去的坎:文件锁定

第一部分:PHP 在 Windows 上备份 SQL Server 的“至暗时刻”

想象一下,你的 PHP 脚本正努力地想把数据库文件(.mdf, .ldf)复制到备份服务器上。但不幸的是,此刻 SQL Server 正忙着呢,它紧紧地攥着那些文件的句柄,就像一个便秘的胖子攥着马桶盖不放。PHP 试着去复制,结果呢?Permission denied(权限拒绝)或者 File in use(文件正被使用)。

你尝试重启 SQL Server 服务?不行,这会影响生产环境,你的老板会把你挂在公司大门口当装饰品。
你尝试使用 BACKUP DATABASE 命令?行,但是如果不加 WITH NORECOVERY 或其他一堆参数,它可能会在备份过程中死锁,或者备份出来的文件不是“一致性”的。什么是不一致性?就是数据库逻辑是好的,但数据页里混进了“半路杀出来的”正在写入的数据。

这时候,VSS 就登场了。VSS 是什么?它是 Windows 自带的一个神奇机制,允许你创建文件系统的快照,而在创建快照的那一瞬间,所有正在访问文件的应用都会被“催眠”或者“暂停”,允许你趁虚而入,把文件拷贝走。等它醒来,一切如常。

第二部分:VSS 的那些“幕后黑手”

在 Windows 里,VSS 不是随便跑出来的,它有几个关键角色:

  1. VSS 服务:这就像是一个总管,负责协调一切。
  2. VSS 提供者:负责底层存储的,通常是硬件的阵列卡驱动或者软件的 Volume Shadow Copy Provider。
  3. VSS Writer:这是最关键的家伙,特别是 SQL Server VSS Writer。它是 SQL Server 的“卧底”,它会告诉 VSS 服务:“嘿,哥们儿,我正在写数据,等会儿给我个快照,我得提前把日志记录好,确保恢复的时候能对上号。”

我们的任务就是:利用 PHP 调用 Windows 的 COM 接口,操控 VSS Writer,拿到快照的路径,然后疯狂拷贝文件。

第三部分:环境准备——别急着写代码,先别死

好,假设你已经安装了 PHP for Windows,并且你的服务器上跑着 SQL Server。要想玩转这个,你需要检查三件事,不然 COM 报错你能查到天荒地老。

  1. PHP 编译选项:你的 PHP 是不是带 --enable-com-dotnet 编译的?如果装的是官方包,默认是有的。如果自己编译过,记得加上这个。没有这个,你就连 VSS 的门都摸不到。
  2. SQL Server VSS Writer:这个服务默认应该已经启用了。你可以打开 services.msc 找找 SQL Server VSS Writer。如果它挂了,你的备份脚本就会变成一个只会报错的废铁。
  3. 管理员权限:别试图用 IIS 的 AppPool 账号去跑这个脚本,它会直接崩溃。你需要一个有管理员权限的域账号或者本地管理员账号来运行 PHP。

第四部分:代码实战——让 PHP 拿起魔杖

好了,重头戏来了。我们怎么用 PHP 去操作 COM 对象?这时候,微软的 COM 接口就派上用场了。我们通过 winmgmts 这个接口,连接到本机的 VSS 命名空间。

注意,这代码看起来有点“洋气”,因为我们在用 PHP 调用 .NET 的 COM 组件。虽然有点非主流,但在 Windows 下,这是最稳健的方案。

下面是第一部分代码:初始化 VSS 环境,创建一个“卷影复制集”。

<?php

class VSS_Backuper {
    private $wmi;
    private $snapshotID;
    private $backupPath;

    public function __construct() {
        // 连接到本机的 WMI 服务,注意那个 {authenticationlevel=promote}
        // 这意味着我们需要管理员权限才能连上
        $this->wmi = new COM("WbemScripting.SWbemLocator") or die("Cannot connect to WMI");
        $service = $this->wmi->ConnectServer("localhost", "root\cimv2", "administrator", "YourStrongPasswordHere");

        // 关键:连接到 VSS 的应用程序命名空间
        // 这里我们查找 SQL Server 的 Writer,但通常我们会先连接通用的 VSS
        $vssNamespace = "winmgmts:{authenticationlevel=promote}\root\cimv2\applications\windows";
        $this->vssService = $service->Get($vssNamespace);

        echo "[INFO] WMI 连接成功,已链接到 VSS 命名空间。n";
    }

    public function createSnapshot() {
        // 1. 创建一个卷影复制集
        // SnapshotSet 就是我们要创建的一组卷影副本
        $snapshotSet = $this->vssService->ExecQuery("SELECT * FROM Win32_ShadowCopy WHERE Context='ClientAccessible'");

        // 2. 请求一个新的快照
        // 我们需要构建一个 VBScript 的脚本来触发 VSS 的动作
        // 这里的 VBScript 是为了调用 VSS API,虽然有点啰嗦,但是最准
        $vssScript = 'On Error Resume Next
        Set VSS = WScript.CreateObject("VSScripting.FileSystemObject")
        Set ShadowCopy = CreateObject("VssWriter.VSSWriter")
        Set ShadowSet = ShadowCopy.CreateShadowSet("PHP_VSS_Backup")

        ' . $this->getVolumePath() . '

        ShadowSet.AddSnapshot "SQL Server Writer", "SQL Writer"
        ShadowSet.SetContext 1 ' // ClientAccessible = 1
        ShadowSet.Commit()
        WScript.Echo ShadowSet.ShadowIDs(0)
        ';

        // 写入临时 VBScript 文件并运行
        $vbsFile = sys_get_temp_dir() . "\php_vss_temp.vbs";
        file_put_contents($vbsFile, $vssScript);

        $output = shell_exec("cscript //nologo " . escapeshellarg($vbsFile));
        unlink($vbsFile);

        if (empty($output)) {
            throw new Exception("VSS 快照创建失败,请检查 SQL Server VSS Writer 是否在运行。");
        }

        $this->snapshotID = trim($output);
        echo "[SUCCESS] VSS 快照已创建,ID: " . $this->snapshotID . "n";
        return true;
    }

    private function getVolumePath() {
        // 假设我们的数据库在 D 盘的某个目录,这里演示硬编码
        // 在生产环境中,你应该通过扫描注册表或者 WMI 获取数据文件路径
        return 'ShadowSet.AddVolume "D:", "SQLWriter"';
    }

    public function copyDatabaseFiles() {
        // 拿到快照 ID 后,我们需要知道快照对应的实际路径
        // VSS 会把快照挂载到一个隐藏的卷上,比如 X: 盘
        $shadowDevice = $this->getShadowDeviceName($this->snapshotID);

        if (!$shadowDevice) {
            throw new Exception("无法获取快照设备名,请检查快照 ID 是否正确。");
        }

        // 原始路径
        $originalPath = "D:\Data\MyDatabase.mdf";
        $logPath = "D:\Data\MyDatabase_log.ldf";

        // 快照路径 (X: 盘)
        $snapshotPath = $shadowDevice . "\";

        // 开始复制
        $this->backupPath = "D:\Backups\" . date("YmdHis") . ".bak";

        // 注意:VSS 创建的快照是只读的,我们只需要复制数据文件即可
        echo "[INFO] 开始从快照复制文件到备份目录...n";

        $this->copyFile($snapshotPath . substr($originalPath, 3), $this->backupPath);

        // 如果还有日志文件,也要复制
        // $this->copyFile($snapshotPath . substr($logPath, 3), $this->backupPath . "_log.ldf");

        echo "[SUCCESS] 备份完成!文件保存在: " . $this->backupPath . "n";
    }

    private function getShadowDeviceName($shadowID) {
        // 通过查询 WMI 来找到快照对应的设备映射
        $query = "SELECT DeviceName FROM Win32_ShadowCopy WHERE ID='" . $shadowID . "'";
        $result = $this->vssService->ExecQuery($query);

        foreach ($result as $shadow) {
            return $shadow->DeviceName;
        }
        return false;
    }

    private function copyFile($src, $dest) {
        // 简单的文件复制,实际生产环境建议用 Robocopy 或者流式复制
        // 注意:PHP 的 copy 在处理大文件时可能会有内存问题,但对于数据库备份,直接拷贝流通常没问题
        if (!copy($src, $dest)) {
            throw new Exception("文件复制失败: $src -> $dest");
        }
    }

    public function destroySnapshot() {
        // 别忘了清理!VSS 快照会占用磁盘空间,如果不删,你的磁盘迟早会爆掉
        // 删除快照通常需要管理员权限
        echo "[INFO] 正在删除 VSS 快照以释放空间...n";

        $vssScript = 'On Error Resume Next
        Set VSS = WScript.CreateObject("VSScripting.FileSystemObject")
        Set ShadowSet = CreateObject("VssWriter.VSSWriter")
        Set ShadowSet = ShadowSet.OpenShadowSet("PHP_VSS_Backup")
        ShadowSet.Delete "' . $this->snapshotID . '"
        ';

        $vbsFile = sys_get_temp_dir() . "\php_vss_delete.vbs";
        file_put_contents($vbsFile, $vssScript);

        shell_exec("cscript //nologo " . escapeshellarg($vbsFile));
        unlink($vbsFile);

        echo "[SUCCESS] 快照已销毁。n";
    }
}

// --- 使用示例 ---

try {
    $backuper = new VSS_Backuper();
    $backuper->createSnapshot();
    $backuper->copyDatabaseFiles();
    $backuper->destroySnapshot();
} catch (Exception $e) {
    echo "[ERROR] 备份过程中发生致命错误: " . $e->getMessage() . "n";
    // 这里可以加邮件通知逻辑
}
?>

第五部分:代码剖析——那些不起眼的坑

上面的代码看起来还行吧?但我敢打赌,你第一次跑的时候,一定会遇到 VSScripting.FileSystemObject 找不到的情况。为什么?因为我们需要注册一个 VSS 的 COM 库。

Windows 自带的 VSS 机制有点“抠门”,它不会自动把 COM 对象注册到系统中让你随意调用。你需要手动注册那个叫 vssapi.dll 的家伙。

打开命令行(CMD),以管理员身份运行:

regsvr32 vssapi.dll

然后,你会听到一声悦耳的“DllRegisterServer 成功”,这意味着 COM 库已经安装好了。这时候,你的 PHP 脚本里的 VBScript 那一段才能正常工作。

还有一个坑:路径大小写。Windows 虽然不区分大小写,但在 WMI 查询和文件系统操作中,特别是涉及到快照映射(X: vs x:)的时候,一定要小心。我的示例代码里用了 substr($originalPath, 3) 来去掉 D:,这只是为了演示。在实际开发中,你应该写一个函数,通过 SQL Server 的 DMV(动态管理视图)来动态读取数据文件的物理路径,而不是硬编码。

第六部分:封装成“老司机”脚本

上面的代码是演示性质的,真正的生产环境代码需要更健壮。我们需要处理异常,需要记录日志,需要支持多数据库。

我们可以把上面的逻辑封装成一个更通用的类。考虑到 VSS 的 API 比较底层,我们其实可以更优雅一点。其实很多开源项目已经做了封装,比如 php-vss 扩展,或者 sqlsrv 扩展本身也带了一些逻辑,但最纯粹的方案还是直接操作 COM。

让我们来看看如何用 PHP 的 COM 类直接调用 SQL Server 的 VSS Writer(这也是一种更高级的玩法,不需要 VBScript 中间件)。

<?php

class SQLServerVSSBackup {
    private $wmi;

    public function __construct() {
        $this->wmi = new COM("WbemScripting.SWbemLocator");
        $this->service = $this->wmi->ConnectServer("localhost", "root\cimv2");
    }

    public function backup($databaseName) {
        // 获取 SQL Server VSS Writer 的实例
        $writer = $this->service->Get("Win32_ShadowCopy"). 
                  ->Associators_("SQLWriter") 
                  ->GetInstances();

        // 创建快照集
        // 这里的 VBScript 逻辑被替换成了对 VSS Writer 对象的直接调用
        // 注意:直接调用 VSS Writer 对象比调用 FileSystemObject 更高效,也更不容易出错

        // 1. 打开快照集
        $shadowSet = $writer->CreateShadowSet("PHP_VSS_Backup");

        // 2. 添加卷(这里假设是 D 盘)
        $shadowSet->AddVolume("D:", "SQLWriter");

        // 3. 设置上下文为 ClientAccessible
        $shadowSet->SetContext(1);

        // 4. 提交快照
        $shadowSet->Commit();

        // 5. 获取快照 ID
        $shadowID = $shadowSet->ShadowIDs(0);

        // 6. 获取快照设备名
        $query = "SELECT DeviceName FROM Win32_ShadowCopy WHERE ID='" . $shadowID . "'";
        $snapshots = $this->service->ExecQuery($query);

        $shadowDevice = "";
        foreach ($snapshots as $snap) {
            $shadowDevice = $snap->DeviceName;
        }

        // 7. 执行备份操作
        // 我们可以通过 SQL Server 的原生备份命令,但在 VSS 模式下,我们直接复制文件
        // 或者更聪明一点,使用 SQL Server 的 SQLDMO 对象(老古董但好用)或者 SMO

        $this->copyDatabaseFiles($shadowDevice, $databaseName);

        // 8. 清理
        $writer->DeleteShadowSet("PHP_VSS_Backup");

        echo "Backup done!";
    }

    private function copyDatabaseFiles($shadowDevice, $dbName) {
        // 查询 SQL Server 获取该数据库的物理文件路径
        $sql = "SELECT name, physical_name FROM sys.master_files WHERE database_id = DB_ID('$dbName')";
        $stmt = sqlsrv_query($conn, $sql);

        while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) {
            $src = $shadowDevice . substr($row['physical_name'], 3); // 去掉驱动器号
            $dest = "C:\Backups\" . $row['name'] . ".bak";

            // 这里使用 copy 逻辑
            copy($src, $dest);
        }
    }
}
?>

上面的第二种代码思路更清晰。利用 WMI 查询 SQLWriter,直接调用 VSS Writer 的 COM 接口。这才是资深专家的做法。不要去写那些乱七八糟的 VBScript 文件写到临时目录里再调用 shell_exec,那是脚本小子才干的事。我们要让 PHP 直接和 COM 对象对话,优雅,流畅,高性能。

第七部分:自动化与灾难恢复策略

好了,脚本写完了,怎么让它自动跑?

Windows 的“任务计划程序”是最好的伙伴。你可以创建一个脚本,执行上面的备份逻辑。然后在任务计划程序里设置每天凌晨 2 点运行。

但是!灾难恢复不仅仅是备份,更是恢复。

当你拿到这个通过 VSS 拷贝出来的文件时,你会发现,这是一个干净的、一致性的数据副本。怎么恢复?

  1. 停止 SQL Server 服务
  2. 复制文件:将你备份好的 .mdf.ldf 文件覆盖回原来的数据目录。
  3. 启动 SQL Server 服务
  4. 附加数据库:在 SSMS(SQL Server Management Studio)里点击“附加”,选择你的文件。

如果是紧急情况,直接替换文件重启服务,通常 SQL Server 会尝试自动修复并让数据库上线。当然,如果文件已经损坏,你可能需要使用 DBCC CHECKDB 修复。

第八部分:常见错误排查指南(FAQ)

写代码就像谈恋爱,总有吵架的时候。以下是使用 VSS 时最常见的几个“分手”理由:

  1. 错误:0x80042301 - The VSS requestor does not have the backup privileges.

    • 原因:你是谁?你没有管理员权限去操作 VSS 服务。
    • 解法:检查运行 PHP 脚本的服务账户,确保是 Domain Admins 或者本地 Administrators。
  2. 错误:The VSS Writer encountered an unexpected error.

    • 原因:SQL Server VSS Writer 挂了,或者它和 SQL Server 之间的通信断了。
    • 解法:重启 SQL Server VSS Writer 服务。或者检查 SQL Server 的错误日志,看看是否有磁盘空间不足或其他系统错误。
  3. 错误:The backup set holds a backup of a database that does not exist on this server.

    • 原因:你拷贝文件的时候拷错了,或者 SQL Server 的实例名对不上。
    • 解法:仔细检查 sys.master_files 里的路径。
  4. 磁盘空间爆炸

    • 原因:你创建了快照,但没删除。
    • 解法:检查 D: 盘的 Shadow Copies 设置,或者确保你的脚本在最后一步执行了 DeleteShadowSet

第九部分:终极建议——别再单打独斗了

虽然上面的代码展示了如何用 PHP 控制世界(备份数据库),但在实际的生产环境中,如果你的 PHP 应用非常复杂,涉及多个数据库,或者你希望备份能跨网络传输,你可能需要考虑更高级的工具。

比如,利用 SQL Server 的 AlwaysOn Availability Groups 或者 Database Mirroring。这些功能本身就提供了自动的、实时的备份和恢复能力,甚至不需要你写 PHP 脚本去操心文件锁的问题。

但是,如果你必须在 Windows 上维护一个孤苦伶仃的 PHP 应用,且数据库性能要求极高,不能停机,那么这套基于 VSS 的 PHP 脚本就是你的不二之选。它就像是一套西装,虽然穿起来有点紧(调试麻烦),但在关键时刻(老板要看数据),它能让你看起来像个体面人。

记住,备份是系统生命的最后一道防线。今天你为了写 VSS 备份脚本头秃的每一分钟,都是明天当服务器宕机时,你从容喝咖啡的每一秒。

好了,代码已经给你了,逻辑已经给你理了,现在,去把那个该死的 vssapi.dll 注册一下吧。别让我看到你还在手动拷贝数据库文件!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注