背景和条件
近期接到这样一个需求,要从用户的服务器上备份将近6万多个文件,这些文件有大有小,小的几百Kb,大的几GB,要尽可能快的完成这些文件的备份工作。
我大概总结了一下,限制条件基本是以下几点
- 文件多,有大有小,总量约500多G;
- 文件是分散存储的,也就是不能简单的去做打包操作,然后在下载备份;
- 也不能在用户的服务器做整理性操作(比如先拷贝到一处),因为用户服务器还没有完成扩容,剩余的存储空间只有100G,操作空间不够;
- 这些文件有些是损坏的,因为用户上传的初始文件就是损坏的,备份的时候,要能分辨出那些是正常的,那些是损坏的。
- 备份后的文件其存储目录和原本的存储目录不同,比如原本文件的存储是"年份\大类\编号\文件",备份的策略则是"年份\大类\组别\编号+项目名称\文件",不仅多了一级,而且子级里的命名规则也改了!
- 尽快。
基本就是这些限制条件了。
事实上,第5个限制条件,一般应该不常见!毕竟备份一般都是原样备份,而这种改存储规则的需求有点自废武功的感觉,你要怎么定位这些备份文件呢?肯定会增加额外的工作量。
操作思路
最开始我想到的思路,就是写一个备份脚本,在用户服务器上先把需要备份的文件整理到一处,然后打包备份就完事儿了。
但由于剩余空间不支持这样做,且暂时无法扩容,更不能删除其他文件去释放空间,所以好像也只有下载到本地进行整理这一个方向了。
⚠️:注意,在自己的电脑上,把文件粘来粘去是ok的,但在生产服务器或者别人的服务器上,尽量不要这样操作,即便只是简单的复制也要避免,一旦要操作的文件数量大,很容易出错!
⚠️:就算你要写程序操作,然后在用户机器上执行,也要十分的谨慎小心,毕竟不是自己的财产,要考虑你的程序是否有内存溢出等结构性风险,会不会占用过多资源,你的程序是否有频繁的I/O操作,如果有,是否是阻塞性操作,会不会造成死机风险,出现死机了怎么办等等。
操作步骤
整理目录文件
这里的整理操作,是先通过技术手段,如脚本,查询数据库等,把要备份的文件整理到Excel或者其他可读性较好的文件中,我这里是整理到了Excel里,像👇这样。


分割目录文件
因为要备份的文件有近6万条记录,所以再单个终端上下载肯定是不合适。所以我做了分割,每5000条一个文件

准备下载脚本
这里我是使用的PowerShell脚本来执行下载操作,脚本代码如下
powershell
param (
[string]$specificExcelFile = $null
)
# 安装并导入ImportExcel模块
if (-not (Get-Module -ListAvailable -Name ImportExcel)) {
Install-Module -Name ImportExcel -Scope CurrentUser -Force
}
Import-Module ImportExcel
# 定义输入参数
$excelDir = "F:\备份附件"
$successLogFile = "$($excelDir)\success_log.txt"
$failureLogFile = "$($excelDir)\failure_log.txt"
# 创建日志文件(如果不存在则创建)
if (-Not (Test-Path $successLogFile)) { New-Item -ItemType File -Path $successLogFile -Force }
if (-Not (Test-Path $failureLogFile)) { New-Item -ItemType File -Path $failureLogFile -Force }
function Log-Message {
param (
[string]$logFile,
[string]$message
)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logEntry = "$timestamp - $message"
Add-Content -Path $logFile -Value $logEntry
}
function Download-WithResume {
param (
[string]$url,
[string]$destination,
[string]$year,
[string]$competition,
[string]$group,
[string]$projectNo,
[string]$projectName,
[string]$attachmentName
)
try {
# 构建保存路径,注意这里在项目编号和项目名称之间添加了下划线
$savePath = Join-Path -Path "$excelDir\$year\$competition\$group\$projectNo`_$projectName" -ChildPath $attachmentName
# 检查文件是否存在,如果存在则跳过
if (Test-Path $savePath) {
Write-Host "文件已存在,跳过: $savePath"
return
}
# 创建目录
$dirPath = Split-Path -Parent $savePath
if (-Not (Test-Path -Path $dirPath)) {
New-Item -ItemType Directory -Path $dirPath -Force
}
# 下载文件(支持断点续传)
if (Test-Path $savePath) {
$existingLength = (Get-Item $savePath).length
$headers = @{ "Range" = "bytes=$existingLength-" }
Invoke-WebRequest -Uri $url -OutFile $savePath -Headers $headers -Method Get
} else {
Invoke-WebRequest -Uri $url -OutFile $savePath
}
Log-Message -logFile $successLogFile -message "下载成功: $url ,已保存到" $savePath ""
Write-Host "Downloading file: $savePath"
} catch {
$errorMessage = "下载失败: $url. 异常信息: $_"
Log-Message -logFile $failureLogFile -message $errorMessage
Write-Host $errorMessage
}
}
if ($specificExcelFile) {
# 如果指定了具体的Excel文件,则直接处理这个文件
if (Test-Path $specificExcelFile) {
$excelFilePath = $specificExcelFile
Write-Host "正在读取: $excelFilePath"
# 读取Excel文件
$excelData = Import-Excel -Path $excelFilePath
# 遍历每一行数据
foreach ($row in $excelData) {
$year = $row.year
$competition = $row.competition
$group = $row.group
$projectNo = $row.projectNo
$projectName = $row.projectName
$attachmentName = $row.attachmentName
$downloadUrl = $row.downloadUrl
# 调用下载函数
Download-WithResume -url $downloadUrl -destination $savePath -year $year -competition $competition -group $group -projectNo $projectNo -projectName $projectName -attachmentName $attachmentName
}
} else {
Write-Host "excel文件不存在: $specificExcelFile"
}
} else {
# 否则遍历目录下的所有Excel文件
Get-ChildItem -Path $excelDir -Filter *.xlsx | ForEach-Object {
$excelFilePath = $_.FullName
Write-Host "读取文件: $excelFilePath"
# 读取Excel文件
$excelData = Import-Excel -Path $excelFilePath
# 遍历每一行数据
foreach ($row in $excelData) {
$year = $row.year
$competition = $row.competition
$group = $row.group
$projectNo = $row.projectNo
$projectName = $row.projectName
$attachmentName = $row.attachmentName
$downloadUrl = $row.downloadUrl
# 调用下载函数
Download-WithResume -url $downloadUrl -destination $savePath -year $year -competition $competition -group $group -projectNo $projectNo -projectName $projectName -attachmentName $attachmentName
}
}
}
这段脚本,就是读取当前目录下的所有excel文件,然后依次下载,其中涉及到了环境检测,比如安装ImportExcel,如果本地的PowerShell环境没有安装,则会进行安装,然后执行业务逻辑。

🌟:如果大家运维的服务器或者开发环境中,windows环境比较多,那掌握点PowerShell知识还是很方便的,对于一些简单但重复性较高的工作,它比Python之类的脚本还要好用的多,至少Python你还要单独安装Python环境,还要用pip安装额外的依赖包,还要注意区分版本,这些你在开发环境搞搞没问题,而生产环境一般是不能随意安装额外的环境的.....而PowerShell本身就集成在了操作系统里,它的代码也高度抽象,通常一个命令能涵盖很多功能点,十分方便。就算不会写代码也没关系,可以自己去GPT,只要能提供思路就好。
分摊下载任务
这一步,就看情况而定了,因为我是使用的脚本下载,没有多线程下载的方案,就多找了几台终端,同时启动下载,也算是真·多线程(多终端)了吧。
下载任务启动以后,剩下的就是等着下载完成了,当然了,如果是多终端下载,还要做最终的合并整理,这个耗时也不会短,毕竟几百个G的总量在那摆着。


验证下载 *
下载完成以后,就是验证下载的文件是否都正常,比如图片,pdf,word等文档是否能正常打开,视频是否能正常播放,压缩包是否可以正常解压。
这一步实际上属于补偿措施,因为用户那里上传的文件,是存在这种没有完整上传的记录,而且也没有做完整性校验,所以要做这个续补偿操作。
其实,这一步没办法做到100%验证,只能说尽可能的尝试一下,提供一个参考。
这个,就看实际情况了,怎么实现都行,只要能验证文件是否有效,可以输出一个可读性较好的记录就好。
我这里就列一下PDF和图片文件的验证,代码是Python实现的,这个工作,好像没有其他语言比Python更合适,因为可选的插件真的太多了!
更多的验证,大家自行实现或者GPT即可。
python
# 验证图片是否能打开
def is_image_readable(file_path):
try:
with Image.open(file_path) as img:
img.verify()
return True
except Exception as e:
fail_logger.error(f"图像文件无效或已损坏: {file_path} - 错误: {e}")
return False
# 安装PyPDF2
# pip install PyPDF2
# 验证pdf文件是否能打开
from PyPDF2 import PdfReader
def is_pdf_readable(file_path):
try:
with open(file_path, 'rb') as f:
reader = PdfReader(f)
# 检查文件是否至少包含一页
if len(reader.pages) > 0:
return True
else:
fail_logger.error(f"PDF文件无效或为空: {file_path}")
return False
except Exception as e:
fail_logger.error(f"PDF文件无效或已损坏: {file_path} - 错误: {e}")
return False
总结
其实整个流程捋下来,没什么硬核的技术,主要还是思路,当然我这只是万千可实现需求的一个分支方向,我是真的喜欢基于脚本的解决方案,即用即走,高效快捷。
事实上,尽管当下现代应用的开发已经十分普及,文件的存储方案,相当一部分都采用了基于AWS S3协议的存储形式,比如存到自建的MinIO集群,或者存到阿里云的OSS等,然后在搭配CDN等周边服务提供统一高效的静态资源服务,但不可否认的是,仍然有相当一部分的应用仍旧在采用传统的存储方式,比如就存在本机的指定目录,或者使用SMB协议,把文件存到一个固定的附件服务器等的,所以我们在设计文件上传和存储模块的时候,也要考虑到多种的存储形式,然后结合实际的场景可以方便的进行切换。
好了,就这些啦,下次见可爱的攻城狮萌~