在 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. python基础五(文件操作)

    一 文件操作 一 介绍 计算机系统分为:计算机硬件,操作系统,应用程序三部分. 我们用python或其他语言编写的应用程序若想要把数据永久保存下来,必须要保存于硬盘中,这就涉及到应用程序要操作硬件,众 ...

  2. 记录--静态网站 H5 跳小程序,以及踩坑

    这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助 背景 我司有智慧功成家APP和对应的小程序,现在已经实现APP分享到微信,微信点击分享链接直接进入小程序. 目前有一个问题就是我们APP在 ...

  3. 记录--Vue使用CDN引入,响应式失效?

    这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助 背景 最近心血来潮,想要在本地开发时,也用CDN的方式引入 Vue,想着既然通过CDN引入了,那么在项目中就没必要再 import Vue ...

  4. 神经网络——基于sklearn的参数介绍及应用

    一.MLPClassifier&MLPRegressor参数和方法 参数说明(分类和回归参数一致): hidden_layer_sizes :例如hidden_layer_sizes=(50, ...

  5. 记一次 .NET某防伪验证系统 崩溃分析

    一:背景 1. 讲故事 昨晚给训练营里面的一位朋友分析了一个程序崩溃的故障,因为看小伙子昨天在群里问了一天也没搞定,干脆自己亲自上阵吧,抓取的dump也是我极力推荐的用 procdump 注册 AED ...

  6. KingbaseES Returning 的用法

    概述 数据表更新时,如果需要对修改前后的数据进行记录或比较,需要返回更新前后的数据.KingbaseES 可以通过 UPDATE语句是否能直接返回影响的数据. KingbaseES支持insert,d ...

  7. 自己写个网盘系列:③ 开源这个网盘编码,手把手教你windows linux 直接部署,docker本地打包部署网盘应用

    系列①②已经完成了这个项目的页面和项目的全部编码,前后端分离,这个文章将向你展示运维小伙伴如何部署到windows服务器,linux服务器,docker部署,一学就会,快来看看吧! 说明:这个系列准备 ...

  8. archlinux xfce未中文化 goldendict不能显示中文

    下载个中文字体包就好了 https://wiki.archlinuxcn.org/wiki/简体中文本地化

  9. 实现一个简单的echarts词云图PythonFlask

    cloud.html 1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta char ...

  10. 鸿蒙HarmonyOS实战-ArkUI组件(Progress)

    一.Progress Progress组件是一种用户界面(UI)元素,用于向用户显示某些任务的进度.它通常以进度条的形式出现,显示任务完成的百分比.Progress组件可以在确定任务持续时间未知的情况 ...