windows 稀疏文件 (sparse file) 的一个实用场景——解决 SetEndOfFile 占据磁盘空间引入的性能问题

前言

之前写过一篇文章说明文件空洞:《[apue] 文件中的空洞》,其中提到了 windows 稀疏文件是制造空洞的一种方式,但似乎没什么用处,如果仅仅处理占用磁盘空间的场景,使用SetEndOfFile 就足够了。

后来在实际工作中,发现稀疏文件在解决一个性能问题方面,有着不可替代的作用,下面且听我一一道来。

问题现象

公司的文件下载产品,为了预防在下载过程中因磁盘空间不足而失败,在 windows 上采取预分配的策略,在下载任务开始前就占据了相当于文件长度的磁盘空间。由于数据源也包括从 P2P 处获取的数据,导致写入时并不是顺序的,存乱序写入的情况,这些信息都存储在数据库中,当应用重启时,会从数据库加载块信息,继续对未落盘的块进行网络请求。

整个逻辑看起来没有问题,然而实测在距离当前写入位置较远的地方写入块时,会发现落盘速度极慢。

下面用一个简单的例子验证这一点,这个 demo 创建一个大文件,通常十几个 GB,具体长度由用户输入决定:

复制代码
#include <iostream>
#include <Windows.h>

int main(int argc, char* argv[])
{
    if (argc < 3)
    {
        std::cout << "Usage: sparsefile file length (in GB)\n";
        return 1; 
    }

    int ret = 0; 
    HANDLE file_handle = INVALID_HANDLE_VALUE;
    LARGE_INTEGER pos = { 0 };

    do
    {
        file_handle = CreateFileA(argv[1], (GENERIC_READ | GENERIC_WRITE),
            FILE_SHARE_READ, 0, OPEN_ALWAYS, FILE_FLAG_OVERLAPPED, 0);
        if (file_handle == INVALID_HANDLE_VALUE)
        {
            std::cout << "CreateFile failed, error " << GetLastError() << std::endl;
            ret = 2;
            break;
        }

        pos.QuadPart = atoll(argv[2]) * 1024 * 1024 * 1024;  // unit in GB
        if (::SetFilePointerEx(file_handle, pos, NULL, FILE_BEGIN) == 0)
        {
            std::cout << "SetFilePointerEx failed, error " << GetLastError() << std::endl;
            ret = 3; 
            break;
        }

        if (!::SetEndOfFile(file_handle))
        {
            std::cout << "SetEndOfFile failed, error " << GetLastError() << std::endl;
            ret = 4; 
            break;
        }
    } while (0); 

    if (ret == 0)
        std::cout << "create file with length " << pos.QuadPart << " success, path " << argv[1] << std::endl;

    if (file_handle)
        CloseHandle(file_handle); 

    return ret; 
}

执行以下命令创建文件:

复制代码
PS D:\test\sparsefile\Release> .\sparsefile movie.mp4 10
SetEndOfFile failed, error 112
PS D:\test\sparsefile\Release> .\sparsefile movie.mp4 4
create file with length 4294967296 success, path movie.mp4

分区 D: 目前仅有 5GB 空间,所以创建 10GB 的文件会失败,错误码 112 即磁盘空间不足;创建 4GB 的可以成功:

文件属性中,文件大小与占用空间是一致的,没有空洞。

在完成预分配后,立即在尾部写入 1 个字符,以模拟真实的使用场景:

复制代码
...
        pos.QuadPart--; 
        if (::SetFilePointerEx(file_handle, pos, NULL, FILE_BEGIN) == 0)
        {
            std::cout << "SetFilePointerEx failed, error " << GetLastError() << std::endl;
            ret = 30;
            break;
        }

        DWORD bytes = 0; 
        c_timer t; 
        if (!::WriteFile(file_handle, " ", 1, &bytes, NULL) || bytes != 1)
        {
            std::cout << "WriteFile failed, error " << GetLastError() << ", written " << bytes << std::endl;
            ret = 31;
            break;
        }

        int elapse = t.get_interval(); 
        std::cout << "write file elapse " << elapse << " ms" << std::endl;
...

注意需要前移文件指针,并在写入前后记录耗时,c_timer 封装了 windows 高精度计时器,可以精确到毫秒。下面是程序输出:

复制代码
PS D:\test\sparsefile\Release> .\sparsefile movie.mp4 4
write file elapse 19502 ms
create file with length 4294967295 success, path movie.mp4

写入 4G 文件末尾 1 个字节,消耗了将近 20s,这还是 SSD,如果换作机械硬盘,耗时会更久。这就是 SetEndOfFile 占据磁盘空间产生的性能问题。

问题分析

网上搜索了一番,大概明白这个 20s 耗时是怎么回事了,简单说明一下:当使用 SetEndOfFile 预分配磁盘空间时,文件系统会将原始块分配给文件,这些块可能包含用户之前删除的文件数据,如果不写入新内容就读取,可能会读到用户隐私!黑客完全可以利用这个漏洞盗取用户数据,从而导致严重的安全问题。

为此,windows 在应用写入原始块时,会用零填充文件指针到上次写入位置之间的区域 (没有上次写入位置就从文件头开始),保证这些内容被清空,从而避免原始信息泄漏。上例中的耗时大部分时间用于填充整个 4GB 文件了,速度当然快不起来。

解决方案

目前有两种解决方案,下面分别说明

提权 + SetFileValidData

这种就是官方提出的解决方案,其实是给 service 打的补丁,MSDN 上写的很详细了:

You can use the SetFileValidData function to create large files in very specific circumstances so that the performance of subsequent file I/O can be better than other methods. Specifically, if the extended portion of the file is large and will be written to randomly, such as in a database type of application, the time it takes to extend and write to the file will be faster than using SetEndOfFile and writing randomly. In most other situations, there is usually no performance gain to using SetFileValidData, and sometimes there can be a performance penalty.

需要注意的是,这个函数需要用户拥有 SeManageVolumePrivilege 权限。以管理员身份运行时,默认是没有这个权限的:

复制代码
D:\test\sparsefile\Release>whoami /priv

特权信息
----------------------

特权名                                    描述                               状态
========================================= ================================== ======
SeIncreaseQuotaPrivilege                  为进程调整内存配额                 已禁用
SeMachineAccountPrivilege                 将工作站添加到域                   已禁用
SeSecurityPrivilege                       管理审核和安全日志                 已禁用
SeTakeOwnershipPrivilege                  取得文件或其他对象的所有权         已禁用
SeLoadDriverPrivilege                     加载和卸载设备驱动程序             已禁用
SeSystemProfilePrivilege                  配置文件系统性能                   已禁用
SeSystemtimePrivilege                     更改系统时间                       已禁用
SeProfileSingleProcessPrivilege           配置文件单一进程                   已禁用
SeIncreaseBasePriorityPrivilege           提高计划优先级                     已禁用
SeCreatePagefilePrivilege                 创建一个页面文件                   已禁用
SeBackupPrivilege                         备份文件和目录                     已禁用
SeRestorePrivilege                        还原文件和目录                     已禁用
SeShutdownPrivilege                       关闭系统                           已禁用
SeDebugPrivilege                          调试程序                           已禁用
SeSystemEnvironmentPrivilege              修改固件环境值                     已禁用
SeChangeNotifyPrivilege                   绕过遍历检查                       已启用
SeRemoteShutdownPrivilege                 从远程系统强制关机                 已禁用
SeUndockPrivilege                         从扩展坞上取下计算机               已禁用
SeManageVolumePrivilege                   执行卷维护任务                     已禁用
SeImpersonatePrivilege                    身份验证后模拟客户端               已启用
SeCreateGlobalPrivilege                   创建全局对象                       已启用
SeIncreaseWorkingSetPrivilege             增加进程工作集                     已禁用
SeTimeZonePrivilege                       更改时区                           已禁用
SeCreateSymbolicLinkPrivilege             创建符号链接                       已禁用
SeDelegateSessionUserImpersonatePrivilege 获取同一会话中另一个用户的模拟令牌 已禁用

为此需要给进程提权:

复制代码
...
        if (!EnablePrivilege(SE_MANAGE_VOLUME_NAME, TRUE))
        {
            std::cout << "EnablePrivilege failed, error " << GetLastError() << std::endl;
            ret = 10;
            break;
        }
...

EnablePrivilege 封装了 OpenProcessTokenLookupPrivilegeValueAdjustTokenPrivileges 几个调用,文章末尾有完整的代码实现,提权代码需要放在CreateFile之前。接着设置文件有效数据长度:

复制代码
...
        if (!::SetFileValidData(file_handle, pos.QuadPart))
        {
            std::cout << "SetFileValidData failed, error " << GetLastError() << std::endl;
            ret = 10;
            break;

        }
...

这段代码需要插入到 SetEndOfFile WriteFile 之间。以管理员身份启动控制台后有如下输出:

复制代码
D:\test\sparsefile\Release>.\sparsefile movie.mp4 4
write file elapse 0 ms
create file with length 4294967295 success, path movie.mp4

尾部写入数据的耗时大大降低了。如果不以管理员身份启动,会报下面的错误:

复制代码
PS D:\test\sparsefile\Release> .\sparsefile movie.mp4 4
EnablePrivilege failed, error 1300

提权失败。如果未提权或 EnablePrivilege位于 CreateFile 之后,则报下面的错误:

复制代码
D:\test\sparsefile\Release>.\sparsefile movie.mp4 4
SetFileValidData failed, error 1314

权限不足。

稀疏文件

如果无法获取管理员身份进行提权,则需要借助 NTFS 稀疏文件,在 WriteFile 之前加入下面的代码即可:

复制代码
...
        DWORD temp = 0;
        if (!::DeviceIoControl(file_handle, FSCTL_SET_SPARSE, NULL, 0, NULL, 0, &temp, NULL))
        {
            std::cout << "DeviceIoControl failed, error " << GetLastError() << std::endl;
            ret = 12;
            break;
        }
...

以普通用户身份运行:

复制代码
PS D:\test\sparsefile\Release> .\sparsefile movie.mp4 4
write file elapse 0 ms
create file with length 4294967295 success, path movie.mp4

耗时也大大降低了。稀疏文件依赖于文件系统的支持,可查询某个 Volume 是否支持稀疏文件:

复制代码
    CHAR szVolName[MAX_PATH], szFsName[MAX_PATH];
    DWORD dwSN, dwFSFlag, dwMaxLen, nWritten;
    BOOL bSuccess;
    HANDLE hFile;
    bSuccess = GetVolumeInformation(NULL,
        szVolName,
        MAX_PATH,
        &dwSN, 
        &dwMaxLen, 
        &dwFSFlag, 
        szFsName,
        MAX_PATH);

    if (!bSuccess) 
    {
        printf("errno:%d", GetLastError());
        return -1;
    }

    printf("vol name:%s \t fs name:%s sn: %d.\n", szVolName, szFsName, dwSN);
    if (dwFSFlag & FILE_SUPPORTS_SPARSE_FILES)
        printf("support sparse file.\n");
    else
        printf("no support sparse file.\n");

或查询某个文件是否为稀疏文件:

复制代码
// HANDLE hFile;
BY_HANDLE_FILE_INFORMATION stFileInfo;
GetFileInformationByHandle(hFile, &stFileInfo);
if(stFileInfo.dwFileAttributes & FILE_ATTRIBUTE_SPARSE_FILE)
    printf("is sparse file.\n");
else
    printf("no sparse file.\n");

也可以通过 fsutil 命令快速确认:

复制代码
PS D:\test\sparsefile\Release> fsutil.exe sparse
---- 支持 SPARSE 命令 ----

queryflag       查询稀疏
queryrange      查询范围
setflag         设置稀疏
setrange        设置稀疏范围
PS D:\test\sparsefile\Release> fsutil.exe sparse queryflag .\movie.mp4
该文件被设为稀疏
PS D:\test\sparsefile\Release> fsutil.exe sparse queryrange .\movie.mp4
分配的范围[1]: 偏移: 0xffff0000  长度: 0x10000

fsutil 的 sparse 子命令查询文件是否稀疏 (queryflag)、以及有效数据的范围 (queryrange)。

这里 queryrange 返回的长度 0x10000 对应的空间占用是 64KB,查看文件属性:

也是 64KB。看起来即使写入 1 个字节,windows 也会分配一个 64K 的块并将其标记为修改。

带来的新问题

两种方案相比较,稀疏文件方式无需获取管理员权限,看起来似乎更"亲民"一些,不过也有它自己的问题:无法真正占据磁盘空间。考察上例中 4GB 文件的属性,占用空间仅 64KB,此时再生成一个 4GB 的文件,仍能成功。然而在实际写入过程中,注定有一个文件因磁盘空间不足而失败,甚至两个都失败。修改 demo 以演示这个场景:

复制代码
...
        long long file_size = pos.QuadPart; 
        pos.QuadPart = 0;
        if (::SetFilePointerEx(file_handle, pos, NULL, FILE_BEGIN) == 0)
        {
            std::cout << "SetFilePointerEx failed, error " << GetLastError() << std::endl;
            ret = 20;
            break;
        }

        char buf[4096] = { 1 };
        c_timer t;
        DWORD bytes = 0;
        for (int i = 0; i < file_size; i += 4096)
        {
            if (!::WriteFile(file_handle, buf, 4096, &bytes, NULL) || bytes != 4096)
            {
                std::cout << "WriteFile failed, error " << GetLastError() << ", written " << bytes << std::endl;
                ret = 21;
                break;
            }
        }

        int elapse = t.get_interval();
        std::cout << "write file elapse " << elapse << " ms" << std::endl;
...

写入整个文件,每次 4KB,执行此程序的同时,通过 dd 开启另外一个 4GB 文件的写入:

复制代码
$ dd if=/dev/zero of=./movie1.mp4 bs=1M count=1024
dd: error writing './movie1.mp4': No space left on device
335+0 records in
334+0 records out
350224384 bytes (350 MB, 334 MiB) copied, 2.08525 s, 168 MB/s

因磁盘剩余空间不足 8GB,最终 dd & demo 都会报错退出:

复制代码
PS D:\test\sparsefile\Release>.\sparsefile movie.mp4 4
WriteFile failed, error 112, written 0
write file elapse 16420 ms

这表明稀疏文件即使占据了空间,也会受磁盘实际剩余空间的影响,真是占了个寂寞!

换句话,稀疏文件使 SetEndOfFile 的磁盘空间占用能力"消失"了,不过后者的剩余空间检查能力还在。当剩余空间不足 4GB 时,demo 会直接在 SetEndOfFile 处失败:

复制代码
PS D:\test\sparsefile\Release> .\sparsefile movie.mp4 4
SetEndOfFile failed, error 112

所以上例不能采用 dd 预先写入 4GB 的方式进行测试,两个程序必需一先一后启动。

行文至此,正好验证一个说法:windows 稀疏文件会对零进行压缩,从而节省空间占用。如果这种说法为真,当写入的数据也是零,稀疏文件占用的空间也会大大小于文件大小,实际情况会怎样?修改一行代码进行验证:

复制代码
...
        char buf[4096] = { 0 };
...

将缓存区默认值从 1 改为 0,此时写入的数据全为零:

复制代码
PS D:\test\sparsefile\Release> .\sparsefile movie.mp4 4
write file elapse 11690 ms
create file with length 0 success, path movie.mp4
PS D:\test\sparsefile\Release> fsutil.exe sparse queryrange .\movie.mp4
分配的范围[1]: 偏移: 0x0         长度: 0x100000000

看起来没有任何空洞,查看文件属性:

确实如此。这个实验说明:稀疏文件并不是对零进行压缩,而是标记了哪些块有写入,从而记录有有效数据区间,和 linux ext4 稀疏文件的实现应该是异曲同工的。

方案对比

总结一下目前的两个方案的缺点

  • SetFileValidData:需要提权
  • 稀疏文件:无法占据磁盘空间

看起来都挺致命的,难道就没有十全十美的方案了吗?在一次偶然的测试中,发现稀疏文件也能占据空间,只要关闭稀疏文件尾部 1 字节的写入:

复制代码
PS D:\test\sparsefile\Release> .\sparsefile movie.mp4 4
create file with length 4294967295 success, path movie.mp4
PS D:\test\sparsefile\Release> fsutil.exe sparse queryrange .\movie.mp4
分配的范围[1]: 偏移: 0x0         长度: 0x100000000

看起来没有文件空洞,查看文件属性:

这样看起来能占据磁盘空间了?使用 dd 灌一些数据:

复制代码
$ dd if=/dev/zero of=./movie.mp4 bs=1M count=1 seek=4095
1+0 records in
1+0 records out
1048576 bytes (1.0 MB, 1.0 MiB) copied, 0.0284056 s, 36.9 MB/s

在 4095M 位置写入 1M,结果露馅了:

看来还是占不了空间,之前显示空间占用 4G 是虚的靠不住的。

新的探索

看了下开源的种子下载神器 Transmission,当它采用 prealloc 模式时,在 windows 上底层使用的就是稀疏文件方式:

复制代码
bool tr_sys_file_preallocate(tr_sys_file_t handle, uint64_t size, int flags, tr_error** error)
{
    TR_ASSERT(handle != TR_BAD_SYS_FILE);

    if ((flags & TR_SYS_FILE_PREALLOC_SPARSE) != 0)
    {
        DWORD tmp;

        if (!DeviceIoControl(handle, FSCTL_SET_SPARSE, nullptr, 0, nullptr, 0, &tmp, nullptr))
        {
            set_system_error(error, GetLastError());
            return false;
        }
    }

    return tr_sys_file_truncate(handle, size, error);
}

不过在占用空间方面,它使用的是 SetFileInformationByHandle

复制代码
bool tr_sys_file_truncate(tr_sys_file_t handle, uint64_t size, tr_error** error)
{
    TR_ASSERT(handle != TR_BAD_SYS_FILE);

    FILE_END_OF_FILE_INFO info;
    info.EndOfFile.QuadPart = size;

    bool ret = SetFileInformationByHandle(handle, FileEndOfFileInfo, &info, sizeof(info));

    if (!ret)
    {
        set_system_error(error, GetLastError());
    }

    return ret;
}

这个和 SetEndOfFile 有何区别,修改代码进行验证:

复制代码
...
            FILE_END_OF_FILE_INFO info;
            info.EndOfFile.QuadPart = atoll(argv[2]) * 1024 * 1024 * 1024;  // unit in GB
            if (!SetFileInformationByHandle(file_handle, FileEndOfFileInfo, &info, sizeof(info)))
            {
                std::cout << "SetFileInformationByHandle failed, error " << GetLastError() << std::endl;
                ret = 5;
                break;
            }

            DWORD temp = 0;
            if (!::DeviceIoControl(file_handle, FSCTL_SET_SPARSE, NULL, 0, NULL, 0, &temp, NULL))
            {
                std::cout << "DeviceIoControl failed, error " << GetLastError() << std::endl;
                ret = 6;
                break;
            }
...

这段代码替换整个 SetEndOfFile 及之后的代码。再次运行 demo:

复制代码
PS D:\test\sparsefile\Release> .\sparsefile movie.mp4 4
create file with length 4294967296 success, path movie.mp4
PS D:\test\sparsefile\Release> fsutil.exe sparse queryflag .\movie.mp4
该文件被设为稀疏
PS D:\test\sparsefile\Release> fsutil.exe sparse queryrange .\movie.mp4
分配的范围[1]: 偏移: 0x0         长度: 0x100000000

看起来没空洞了,但用 dd 测下尾部写入 1MB 后,结果就和之前一样了:

看起来没什么改善。

结语

本文尝试说明 SetEndOfFile 占用空间存在的一个性能问题,并讲解了两种解决方案,分别是SetFileValidData 和稀疏文件,以及它们的局限性;随后尝试破解稀疏文件局限性但失败了;最后验证了 Transmission 开源库基于SetFileInformationByHandle的方案也不可行。

附录中罗列了一些如何高效的拷贝稀疏文件的方法,关键在于遍历稀疏文件中的有效数据区间,感兴趣的读者可以参考附录 8。

代码

本期测试代码上传到了 github:https://github.com/goodpaperman/sparsefile

各种接口的调用尽量做成了选项,方便组合进行测试,参数不足时会展示 Usage:

复制代码
PS D:\test\sparsefile\Release> .\sparsefile.exe movie.mp4
Usage: sparsefile file length(in GB) [set-file-end-of-file-info] [set-file-valid-data] [sparse-file] [write-file-mode 0|1|2] [fill-char]

文件路径和大小是必选项,5 个可选项分别控制:

  • set-file-end-of-file-info:使用SetFileInformationByHandle方式,默认为 0 使用 SetEndOfFile方式
  • set-file-valid-data:使用 SetFileValidData 方式,此时需要以管理员身份启动控制台,默认为 0
  • sparse-file:使用稀疏文件,默认为 1
  • write-file-mode:写文件模式,默认为 0
    • 0:不写
    • 1:写文件末尾 1 字节
    • 2:间隔 1M 写 4KB 数据
  • fill-char:写文件时填充的字符,默认为空 ('')

通过设置参数,可以验证本文的各种方案:

  • .\sparsefile movie.mp4 4SetEndOfFile+ 稀疏文件 的方式
    • .\sparsefile movie.mp4 4 0 0 1 1 ' ',在末尾写入数据耗时小,且空间占用失败
    • .\sparsefile movie.mp4 4 0 0 1 1,末尾写入零时对空间占用无影响
    • .\sparsefile movie.mp4 4 0 0 1 2
      ,写入全零块时文件占用空间并未压缩
  • .\sparsefile movie.mp4 4 0 0 0
    ,仅SetEndOfFile的方式
    • .\sparsefile movie.mp4 4 0 0 0 1 ' '
      ,在末尾写入数据耗时大
    • .\sparsefile movie.mp4 4 0 0 0 1
      ,在末尾写入零耗时小
  • .\sparsefile movie.mp4 4 1SetFileInformationByHandle+ 稀疏文件 的方式
    • .\sparsefile movie.mp4 4 1 0 1 1 ' ',在末尾写入数据耗时小,且空间占用失败
  • .\sparsefile movie.mp4 4 1 0 0,仅SetFileInformationByHandle的方式
    • .\sparsefile movie.mp4 4 1 0 0 1 ' ',在末尾写入数据耗时大
  • .\sparsefile movie.mp4 4 0 1 0SetEndOfFile + SetFileValidData + 提权 的方式
    • .\sparsefile movie.mp4 4 0 1 0 1 ' ',在末尾写入数据耗时小,且空间占用成功

可执行文件已经编译为了静态链接并上传到 git,理论上不需要装 VS 也能运行,配置是 Release x86 & x64 两个平台,方便没有编译环境的同学直接上手。

参考

1\]. [什么是稀疏文件(Sparse File)](https://www.finclip.com/news/f/9913.html) \[2\]. [建希文件](https://blog.csdn.net/Timmy_zhou/article/details/5655780) \[3\]. [windows 高精度计时器](https://www.cnblogs.com/zxdplay/p/18724061 "发布于 2025-02-19 14:35") \[4\]. [Windows环境下提升进程的权限](https://blog.csdn.net/china_jeffery/article/details/79173417) \[5\]. [SetFileValidData function](https://learn.microsoft.com/zh-cn/windows/win32/api/fileapi/nf-fileapi-setfilevaliddata) \[6\]. [Windows 下的文件预分配与 SetFileValidData 函数](https://blog.csdn.net/whl0071/article/details/140147079) \[7\]. [linux 稀疏文件(Sparse File)](https://www.cnblogs.com/cymm/archive/2013/04/04/3390397.html "发布于 2013-04-04 22:20") \[8\]. [稀疏文件简介](https://xuranus.github.io/2023/02/19/%E7%A8%80%E7%96%8F%E6%96%87%E4%BB%B6%E7%AE%80%E4%BB%8B/)

相关推荐
arong_xu4 个月前
C++17 Filesystem 实用指南
开发语言·c++·filesystem·c++17
PleaSure乐事7 个月前
【Node.js】内置模块FileSystem的保姆级入门讲解
javascript·node.js·es6·filesystem
爱听歌的周童鞋9 个月前
四. TensorRT模型部署优化-pruning(sparse-tensor-core)
pruning·sparse·tensor core
tekin1 年前
go语言内置预编译 //go:embed xxx 使用详解
开发语言·后端·golang·预编译·filesystem·fs·embed
大隐隐于野1 年前
百度沧海文件存储CFS推出新一代Namespace架构
filesystem·cfs
fengbingchun2 年前
C++17中std::filesystem::directory_entry的使用
filesystem
恋喵大鲤鱼2 年前
C++ 创建文件并写入内容
c++·filesystem
fengbingchun2 年前
在Ubuntu 18.04上支持C++17的std::filesystem的方法
filesystem