首页 > 其他分享 >推荐一个使用 HardLink 硬链接减少重复文件占用磁盘空间的工具

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

时间:2024-04-25 09:24:47浏览次数:29  
标签:文件 SHA1 里面 重复 磁盘空间 HardLink var 链接

在 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 允许下一次的调度进来

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

标签:文件,SHA1,里面,重复,磁盘空间,HardLink,var,链接
From: https://www.cnblogs.com/lindexi/p/17890786.html

相关文章

  • vue启动本地服务不显示network访问链接
    在vue.config.js(或者配置config了的,就在config下的index.js)文件下设置devServer或者dev中的public属性值,需要修改为自己电脑的IPV4地址,获取IPV4地址方法,Win+R打开运行窗口,输入cmd,在命令行输入ipconfig回车后会出现一串信息,复制IPV4地址即可;module.exports={......
  • JMeter通过JDBC链接数据库并实现批量造数据
      在JMeter做自动化接口测试,需要对数据库进行增删改查等操作时,我们是无法像navicat一样直接写SQL的,需要通过一系列操作,才可以。 1、首先,第一步就是,在TestPlan中引用对应数据库的jar包,jar包可以在网上找,本文以MySQL为例,步骤如下:   2、第二步,在线程组下面添加JDBCC......
  • 链接表的增删
    链接表的增删/************************************************************************************filename:demo.c*cauthor:[email protected]*date:2024/04/22*function:创建链接表并在顺序表中进行增删*note:none*CopyRight......
  • 试说明表单和超链接的相同点和不同点,及各自的使用场合
    表单和超链接都是Web页面中的交互元素,它们允许用户与网站进行交互,但它们在功能和使用场合上有一些明显的相同点和不同点。相同点:交互性:表单和超链接都提供了用户与网页进行交互的方式。用户可以通过点击超链接跳转到另一个页面,或者通过填写表单提交信息给服务器。导航:在一定程......
  • 短链接口设计&禁用Springboot执行器端点/env的安全性
    短链接口设计//短链接服务跳转方式,实现短链接转长链接的请求。@GetMapping("/{code}")publicStringredirectUrl(@PathVariable("code")Stringcode){return"redirect:"+shortUrl.getLongUrl();}禁用Springboot执行器端点/env的安全性#关闭健康检查不安全接口end......
  • Random 项目总结 -03测试链接按钮 测试数据链接
    注意:如果填写地址是指定端口IP地址与端口之间需要用逗号隔开。 privatevoidbutton2_Click(objectsender,EventArgse){stringstrdata=textBox1.Text;stringstrsa=textBox2.Text;stringstrpwd=textBox3.Text;......
  • SQL server跨库链接服务器
    SQLserver进阶技能篇:SQL的跨库查询与链接服务器-知乎(zhihu.com)各位小伙伴们,关于MSSQL的基本技能篇前面一共写了10篇,也基本上算是告一段落,接下来将开始介绍进阶技能篇。在构思这个进阶技能篇的时候,一直在考虑先写哪个,其实到看到这部分内容能理解的人,基本上对SQL数据库知识已......
  • MSSQL 数据库服务器磁盘空间报警 -
    如服务器上有创建MSSQLReplication,则会自动创建distribution数据库,有时distribution数据库日志文件过大解决方案:1.第一种方案:查看日志大小, dbccsqlperf(logspace),查看哪个数据库日志文件过大,如果有数据库日志文件非常大,就需要通过检查日志的VLF使用情况来进行诊断,日志文件......
  • Qt 6.5.5 链接和QML与C++交互的若干问题
    需求描述QtQuick开发桌面组件,使用讯飞API(提供头文件、静态库、动态库),希望部署到Windows平台,在QtCreator开发。QML与C++交互主要参考:QML与CPP,https://blog.csdn.net/gongjianbo1992/article/details/87965925另有参考:信号与槽,https://blog.csdn.net/ifeng12358/article/detai......
  • 关于链接脚本和汇编导致的数据段初始化错误的问题
    第一个链接脚本存在data段初始化失败的问题,第二个link脚本增加了At>flash就可以正常的运行了,是为什么?如果只是链接错误的话,那么汇编从ram向同地址的ram中搬运为什么就会运行出错?链接脚本分别如下:有错误的类型MEMORY{flash(rxai!w):ORIGIN=0x20000000,LENGTH......