在 NTFS 文件系统里面,咱可以使用 HardLink 硬链接的方式,将多个重复的文件链接到磁盘的同一份记录里面,从而减少在磁盘里面对重复文件存储多份记录,减少磁盘空间的占用。本文将和大家推荐我所做的基于 HardLink 硬链接减少重复文件占用磁盘空间的工具

此工具名为 UsingHardLinkToZipNtfsDiskSize 在 GitHub 上完全开源,请看 https://github.com/dotnet-campus/dotnetcampus.DotNETBuildSDK

下载地址: UsingHardLinkToZipNtfsDiskSize_with_runtime_3.11.1-github-release

本工具的下载地址挂在 github 上,如无法访问 github 或存在其他下载失败问题,还请发邮件私聊我,让我通过其他方式分享工具给你

使用方法

  1. 启动 UsingHardLinkToZipNtfsDiskSize 工具
  2. 将需要进行瘦身的文件夹拖入到工具的 "Drag Folder Here" 里面即可

拖进去之后,工具将会分析拖入的文件夹里面包含的重复文件,记录文件哈希值,调用 CreateHardLink 这个 Win32 函数创建硬链接减少重复文件。如此实现减少重复文件占用磁盘空间

用前须知:由于采用的是硬链接的方式,意味着重复的文件都会指向磁盘里面的相同一份空间,如对其中的一个文件进行修改,将会让修改同时对其他的重复文件生效。因此只建议用在只读存档文件里面,比如一些再也不改的图片、再也不改的视频、再也不改的程序文件等。不适合用于文档、游戏存档等文件

这个 UsingHardLinkToZipNtfsDiskSize 工具的开发背景是我此前开源了 CopyAfterCompileTool 工具,这个 CopyAfterCompileTool 工具的作用就是将代码仓库里面的每个 commit 提交都进行构建,将构建生成的内容存储起来。如此方便快速定位问题,比如想要知道某个 commit 提交实现的效果或造成的问题,就可以快速获取这次 commit 提交构建输出的内容,减少重复的构建过程,提高开发定位问题的效率。通过 CopyAfterCompileTool 工具,我所在的团队快速二分了许多问题。详细请看 用于辅助做二分调试的构建每个 commit 的工具

然而经过了几年的构建,我发现存储这些 commit 提交的构建输出内容的磁盘的空间已经不足了。于是我就在想着能够有什么方法优化一下磁盘空间的占用,开始是开了磁盘的压缩功能,开了之后发现能够压缩一半的空间,毕竟对于大部分构建输出的 DLL 和 Exe 来说,压缩一半的空间是十分简单的。就这样又跑了很久,磁盘空间又不足了。这次我发现了相邻的构建之间的文件的差异其实是很小的,很多的时候开发者的变更只是修改其中的某几个 DLL 而已,更多的文件都是相同的

这就意味着存储这些 commit 提交的构建输出内容的文件夹里面,存在十分多的重复文件。为了减少重复文件浪费的磁盘空间,同时为了能够尽量减少上层应用对减少重复文件的感知,我就选用了 CreateHardLink 方法创建硬链接的方式减少重复文件。由于 HardLink 硬链接是非常底层的,不说应用程序,即使许多系统组件,都不会感知到差异。这是因为从某个角度上说,在 Explorer 资源管理器里面所看到的所有文件其实都是硬链接的,只不过绝大部分文件只硬链接一份,而经过了 UsingHardLinkToZipNtfsDiskSize 工具将会硬链接多份

使用 HardLink 硬链接减少重复的文件,依然可以让几乎所有上层的应用程序无感知变化,让许多系统组件都不会感知到差异。于是无论是文件共享还是 SMB 系列,还是磁盘挂载,还是 http 文件分享工具,还是构建输出的应用程序本身,都能很好的工作

以上就是 UsingHardLinkToZipNtfsDiskSize 工具的制作背景。当然,还有一个理由就是作为开发者,无论用到什么功能,我都喜欢自己做一次,不管是不是有别人做的更好的工具。做的过程也是学习的过程,接下来我将会告诉大家制作这个工具我所遇到的技术问题

以下是 UsingHardLinkToZipNtfsDiskSize 工具的实现细节,我只列出其中踩坑的技术点

我先通过 DirectoryInfo 的 EnumerateFiles 方法枚举出整个文件夹,如下面代码

        foreach (var file in workFolder.EnumerateFiles("*", enumerationOptions: new EnumerationOptions()
{
RecurseSubdirectories = true,
MaxRecursionDepth = 100,
}))
{
// ...
}

之所以采用 EnumerateFiles 方法而不是 GetFiles 方法是因为如果传入的文件夹包含了超级多数量的文件,比如我的需求是将 commit 构建输出的存储文件夹进行优化,里面有大概 10w 个 commit 构建输出的内容,每个 commit 输出文件大概是 200 多个文件,也就是超过千万个文件,此时 EnumerateFiles 的优势就体现出来了。调用 GetFiles 方法将会先执行一次完全的遍历,获取到所有的文件,换句话说就是在我的当前需求里面就是需要一口气遍历超过千万个文件,构建了一个超过千万个字符串的超大数组。而 EnumerateFiles 则是用到再遍历,不需要一口气就遍历完所有的文件,在我当前的情况下特别合适

遍历到文件之后,我通过 SHA1 的 HashDataAsync 计算文件的哈希。对于文件的哈希计算来说,常见的方法有 MD5 和 SHA1 两个方法。为什么选用 SHA1 而不是 MD5 呢?这里也许某些伙伴有一个误解,那就是 MD5 由于安全性问题被越来越多不推荐使用了,然而这完全不是这里不使用的原因。对于作为本地的某段信息的摘要比较,使用 MD5 是完全没有问题的。比如我只是为了方便比较本地的文件,那么此时使用 MD5 是不需要也不应该考虑安全性问题的。这里使用 SHA1 而不是 MD5 的原因只是因为 SHA1 更快而已。为什么 SHA1 更快呢?似乎我读书那会自己推导性能是 MD5 更快才对,哈哈,如果你也有整个印象那就证明咱是差不多个年代的开发者。从算法推导上 MD5 确实比 SHA1 快,但架不住 SHA1 可以作弊呀,在 CPU 层对 SHA1 有特殊指令进行硬件加速,现在的绝大部分电脑的 CPU 带上了对此指令的支持,在 dotnet 里面一旦 CPU 有硬件优化加速将会自动使用硬件加速,详细请看 Intel SHA extensions - Wikipedia

使用 .NET 7 引入的 HashDataAsync 方法可以获取更优的性能,这个方法可以生成 20 个 byte 的 SHA1 哈希内容,可以复用传入的结果数组,减少 byte 数组对象的创建,减少对 GC 的压力

通过计算哈希,将哈希存放在本地的 Sqlite 数据库里面,即可快速查询了解到是否存在重复的文件以及重复的文件有哪些

存放 Sqlite 数据库我采用的是 EF 来辅助存放。我开始的时候采用的是将一个 EF 的 Context 从头到尾的使用,也就是将一个 EF 的 Context 应用在所有的文件哈希变更和查询里面,大概的代码写法如下

        await using var fileStorageContext = new FileStorageContext(sqliteFile.FullName);

        foreach (var file in workFolder.EnumerateFiles("*", enumerationOptions: new EnumerationOptions()
{
RecurseSubdirectories = true,
MaxRecursionDepth = 100,
}))
{
// ... // 添加文件记录
fileStorageContext.FileRecordModel.Add(new FileRecordModel()
{
FilePath = file.FullName,
FileLength = fileLength,
FileSha1Hash = sha1,
}); // 查询是否存在重复的文件
var fileStorageModel = await fileStorageContext.FileStorageModel.FindAsync(sha1);
}

在跑了大概 10w 个文件,即可发现 EF 性能在不断下降。这是因为在 EF 里面做了实体追踪功能,导致产生了大量追踪对象,被追踪功能拖慢了性能。从内存上看都是一堆 Snapshot<String>InternalEntityEntry 对象

解决方法是要么不复用 FileStorageContext 对象,要么不要追踪,详细请看 更改检测和通知 - EF Core Microsoft Learn

我这里为了简单起见,就不复用 FileStorageContext 对象,更改之后如下代码


foreach (var file in workFolder.EnumerateFiles("*", enumerationOptions: new EnumerationOptions()
{
RecurseSubdirectories = true,
MaxRecursionDepth = 100,
}))
{
await using var fileStorageContext = new FileStorageContext(sqliteFile.FullName);
// ... // 添加文件记录
fileStorageContext.FileRecordModel.Add(new FileRecordModel()
{
FilePath = file.FullName,
FileLength = fileLength,
FileSha1Hash = sha1,
}); // 查询是否存在重复的文件
var fileStorageModel = await fileStorageContext.FileStorageModel.FindAsync(sha1);
}

也就是在每次文件的循环里面不断创建和释放 FileStorageContext 内容,如此即可减少追踪的性能影响。同时每次文件循环里面的性能点也都在文件的读取和计算 SHA1 里面,对数据库的查询和添加的性能损耗可以忽略

以上的 FileStorageContext 类型的具体定义过于业务,如感兴趣的伙伴还请阅读开源的代码

使用 CreateHardLink 方法创建硬链接时有一个限制是我之前都不知道的,那就是有最大链接数量限制,最多只能支持 1023 个链接。对于我的需求来说,很简单就超过了限制,存在重复的文件太多了

我开始不知道这个问题,于是没有判断 CreateHardLink 方法返回值,创建失败了还将原文件删除,只好写一个修复程序将删除掉的文件还原回来

使用此函数可以创建的硬链接的最大数目为每个文件 1023。 如果为文件创建的链接超过 1023 个,则会导致错误

我的策略逻辑是调用 CreateHardLink 方法之后,不仅判断返回值,还通过 File.Exists 方法判断文件是否还存在

经过大量的测试发现只要 CreateHardLink 方法返回成功,全部的 File.Exists 方法判断文件是否还存在都通过,证明了此方法的返回值十分可行

额外的,为了让我的界面能够显示一行日志,我还修改了日志组件。我编写了一个 ChannelLoggerProvider 的继承 ILoggerProvider 接口的类型,在 ChannelLoggerProvider 里面的核心实现是进行日志的分发

大家都知道,一般情况下都不应该在记录日志的地方等待日志的消费,除非是特别重要的日志。我在 ChannelLoggerProvider 里面使用了 System.Threading.Channels.Channel 编写了生产者消费者模式,支持注入多个消费者。也就是让同一条消息被多个消费者同时消费,于是就同时将日志记录到文件里面也将日志显示在 WPF 应用程序的界面上

public class ChannelLoggerProvider : ILoggerProvider
{
public ChannelLoggerProvider(params IStringLoggerWriter[] stringLoggerWriterList)
{
_stringLoggerWriterList = stringLoggerWriterList;
var channel = Channel.CreateUnbounded<string>(new UnboundedChannelOptions()
{
SingleReader = true
});
_channel = channel; Task.Run(WriteLogAsync);
} private readonly IStringLoggerWriter[] _stringLoggerWriterList; private async Task? WriteLogAsync()
{
while (!_channel.Reader.Completion.IsCompleted)
{
try
{
var message = await _channel.Reader.ReadAsync();
foreach (var stringLoggerWriter in _stringLoggerWriterList)
{
await stringLoggerWriter.WriteAsync(message);
}
}
catch (ChannelClosedException)
{
// 结束
}
} foreach (var stringLoggerWriter in _stringLoggerWriterList)
{
await stringLoggerWriter.DisposeAsync();
}
} private readonly Channel<string> _channel;
public void Dispose()
{
ChannelWriter<string> channelWriter = _channel.Writer;
channelWriter.TryComplete();
} public ILogger CreateLogger(string categoryName)
{
return new ChannelLogger(_channel.Writer);
} class ChannelLogger : ILogger, IDisposable
{
public ChannelLogger(ChannelWriter<string> writer)
{
_writer = writer;
} private readonly ChannelWriter<string> _writer; public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
var message = $"{DateTime.Now:yyyy.MM.dd HH:mm:ss,fff} [{logLevel}][{eventId}] {formatter(state, exception)}";
_ = _writer.WriteAsync(message);
} public bool IsEnabled(LogLevel logLevel)
{
return true;
} public IDisposable? BeginScope<TState>(TState state) where TState : notnull
{
return this;
} public void Dispose()
{
}
}
}

日志的使用方法如下

        using var channelLoggerProvider = new ChannelLoggerProvider(...);

        using var loggerFactory = LoggerFactory.Create(builder =>
{
// ReSharper disable once AccessToDisposedClosure
builder.AddProvider(channelLoggerProvider);
}); var logger = loggerFactory.CreateLogger(xxx);

由于我的界面上只需要显示一行的内容,而显示界面的速度有时候会远远小于日志生产的速度。而根据这里的需求,只需要显示最新的一行即可,这就意味着可以随意丢掉中间的过程日志内容。根据此需求即可实现为写一个布尔字段,当有值进入时,设置只允许通过一次,且由于这里的记录的信息不重要也不需要多线程安全问题,简单的实现如下

    private readonly TextBlock _logTextBlock;

    private string _lastMessage = string.Empty;
private bool _isInvalidate = false; public ValueTask WriteAsync(string message)
{
_lastMessage = message; if (!_isInvalidate)
{
_isInvalidate = true; _logTextBlock.Dispatcher.InvokeAsync(() =>
{
_logTextBlock.Text = _lastMessage;
_isInvalidate = false;
});
} return ValueTask.CompletedTask;
}

也就是进入到 WriteAsync 方法里面时无论如何都更新 _lastMessage 的值,接着判断 _isInvalidate 只允许进入一次调度主线程,防止主线程过于忙碌

在主线程完成赋值之后,再设置 _isInvalidate 允许下一次的调度进来

以上的实现方法的优点在于十分简单,缺点在于可能存在最后一次的消息没有被正确消费。也就是说可能最后一条日志没有能够在界面上显示出来

推荐一个使用 HardLink 硬链接减少重复文件占用磁盘空间的工具的更多相关文章

  1. WINDOWS 的 MKLINK : 硬链接,符号链接 : 文件符号链接, 目录符号链接 : 目录联接

    玩转WIN7的MKLINK 引言: 换了新电脑,终于再次使用上啦WIN7 ,经过一个周每天重装N次系统,... ... ... ... 在xp系统下,junction命令要用微软开发的小程序 junc ...

  2. Linux查找并删除重复文件的命令行fdupes工具,dupeGuru图形工具

    查了几十个网页,找到这个接近满意的解决方案http://unix.stackexchange.com/questions/146197/fdupes-delete-files-aft... 不过正则里 ...

  3. Linux上ln命令详细说明及软链接和硬链接的区别

    硬链接(hard link) UNIX文件系统提供了一种将不同文件链接至同一个文件的机制,我们称这种机制为链接.它可以使得单个程序对同一文件使用不同的名字.这样的好处是文件系 统只存在一个文件的副本, ...

  4. linux命令大全之ln命令详解(创建软链接和硬链接)

    ln是linux中又一个非常重要命令,它的功能是为某一个文件在另外一个位置建立一个同步的链接,分为软链接.硬链接.软链接相当于windows的快捷方式,下面是使用方法和示例   ln是linux中又一 ...

  5. Linux入门之常用命令(10)软连接 硬链接

    在Linux系统中,内核为每一个新创建的文件分配一个Inode(索引结点),每个文件都有一个惟一的inode号.文件属性保存在索引结点里,在访问文件时,索引结点被复制到内存在,从而实现文件的快速访问. ...

  6. linux下软链接与硬链接及其区别

    linux下创建链接命令 ln -s 软链接 这是linux中一个非常重要命令,请大家一定要熟悉.它的功能是为某一个文件在另外一个位置建立一个不同的链接,这个命令最常用的参数是-s, 具体用法是:ln ...

  7. Linux 软链接 硬链接 ln命令(繁杂版)

    注意:创建软连接的时候,要用绝对路径!!! 这是linux中一个非常重要命令,请大家一定要熟悉.它的功能是为某一个文件在另外一个位置建立一个同不的链接,这个命令最常用的参数是-s,具体用法是:ln - ...

  8. Linux ln 软、硬链接

    最近在学习Linux系统的,给我的感觉就是“智慧的结晶,智慧的大脑,智慧的操作” 今天研究到了一个有趣的命令  ln   我们先来看一下它的概念吧 Linux ln命令是一个非常重要命令,它的功能是为 ...

  9. linux 软连接和 硬链接的区别

    Linux软链接硬链接的区别   ln是linux中又一个非常重要命令,它的功能是为某一个文件在另外一个位置建立一个同步的链接.当我们需要在不同的目录,用到相同的文件时,我们不需要在每一个需要的目录下 ...

  10. Linux软链接硬链接的区别

    ln是linux中又一个非常重要命令,它的功能是为某一个文件在另外一个位置建立一个同步的链接.当我们需要在不同的目录,用到相同的文件时,我们不需要在每一个需要的目录下都放一个必须相同的文件,我们只要在 ...

随机推荐

  1. power quyer 批量合并同一文件夹下数据格式相同的Excel文件

    一.需求描述:现在有一批数据格式相同的Excel文件需要把里面的内容合并到同一个Excel的一个sheet里面 二.新建一个叫数据汇总的Excel文件-数据-新建查询-从文件-选择数据存放的文件夹-然 ...

  2. Oracle存储过程模板

    PROCEDURE proc_test(p_id IN NUMBER, v_cur OUT SYS_REFCURSOR, p_result_code OUT NUMBER, p_result_mess ...

  3. 可变形卷积系列(三) Deformable Kernels,创意满满的可变形卷积核 | ICLR 2020

    论文提出可变形卷积核(DK)来自适应有效感受域,每次进行卷积操作时都从原卷积中采样出新卷积,是一种新颖的可变形卷积的形式,从实验来看,是之前方法的一种有力的补充.   来源:晓飞的算法工程笔记 公众号 ...

  4. KingbaseES V8R6集群运维案例之---主备failover切换原因分析

    案例说明: 生产环境,KingbaseES V8R6的集群发生failover切换,分析集群切换的原因. 适用版本: KingbaseES V8R6 集群架构: 137.xx.xx.67主 原备库 1 ...

  5. KingbaseES V8R6 等待事件之DataFileRead

    等待事件含义 IO:DataFileRead等待事件发生在会话连接等待后端进程从存储中读取所需页面,原因是该页面在共享内存中不可用或无法找到. 所有查询和数据操作(DML)操作都访问缓冲池中的页面,语 ...

  6. #线段树,树状数组#CodeChef Merciless Chef

    MLCHEF 分析 首先按照dfs序将子树转换为区间,其实就是区间减和区间维护最小值判断是否大于0 因为大于0一定最多只有 \(n\) 个,所以直接将一个数记录被删除并设为正无穷. 代码 #inclu ...

  7. #容斥,完全背包#洛谷 1450 [HAOI2008]硬币购物

    题目 分析 直接多重背包应该会T掉,考虑硬币的种类比较少. 如果没有硬币数量的限制直接完全背包就可以了, 不然如果限制了硬币的数量那么第 \(d+1\) 次取这个硬币就不合法, 所以要减去 \(dp[ ...

  8. Jetty的http模块

    启用http模块,执行如下命令: java -jar $JETTY_HOME/start.jar --add-modules=http 查看http模块的配置文件,执行如下命令: cat $JETTY ...

  9. netty系列之:给ThreadLocal插上梦想的翅膀,详解FastThreadLocal

    目录 简介 从ThreadLocalMap中获取数据 FastThreadLocal 总结 简介 JDK中的ThreadLocal可以通过get方法来获得跟当前线程绑定的值.而这些值是存储在Threa ...

  10. 如何实现OpenHarmony的OTA升级

    OTA简介 随着设备系统日新月异,用户如何及时获取系统的更新,体验新版本带来的新的体验,以及提升系统的稳定性和安全性成为了每个厂商都面临的严峻问题.OTA(Over the Air)提供对设备远程升级 ...