简介:面向.NET开发与SQL Server运维场景的C#数据库备份工具完整源码,用于解决MSSQL数据库日常备份中的自动备份、手动备份、作业调度以及备份文件FTP上传等实际问题。开发环境为Visual Studio 2010,目标框架为.NET Framework 4.0,数据库为SQL Server 2008,适合需要快速搭建桌面备份工具、研究WinForm程序结构或拓展自动化运维能力的开发人员。压缩包为zip格式,共包含87个文件,整体大小约1.97MB;其中以29个C#源文件为核心,覆盖备份线程、FTP辅助、数据库连接、系统配置、界面逻辑等模块,另含图标与PNG图片资源、DLL依赖库、XML与INI及config配置文件、SQL脚本、可执行程序以及项目工程文件,便于直接编译调试与二次开发。已有435人学习/下载,可作为SQL Server备份调度、桌面工具开发以及FTP上传逻辑的学习参考。源码将自动备份、手动备份、作业处理、FTP上传、清空日志和参数编辑整合为清晰菜单功能,并拆分出独立辅助类,帮助理解定时作业触发、压缩上传、配置持久化等关键实现,可直接应用到数据库维护项目中。
1. 为什么自己写C# SQL Server备份工具:从一次审计追问说起
一次例行审计,对方问我要最近三个月的数据库备份记录和恢复演练证明。维护计划确实在跑,但备份文件散落在好几台机器的共享目录里,日志只有系统自带的执行历史,要凑齐“哪一天、哪个库、备份类型、文件多大、验证结果”这张表,我花了整整一个下午翻黑匣子。那一刻就确定:SQL Server备份工具这种东西,与其依赖图形化工具和手动脚本,不如用C#写一个自己能看懂、能扩展、能交代清楚的源码级工具。
这个工具解决的核心问题不是“能不能备份”,而是“备份这件事是否可审计、可验证、可恢复”。适合三类人:被合规要求追着要记录的一线DBA、要在内网多实例上批量部署备份任务的.NET开发、以及看了不少C#高级编程但想找一个完整落地项目的初学者。整套实现下来,涉及连接管理、SMO备份对象、调度防重入、文件清理策略和恢复校验五个环节,每步都有值得记的坑。
2. 备份引擎怎么做:从SqlConnection到SqlBackup的封装逻辑
2.1 选型第一问:SMO还是原生T-SQL
写备份工具的第一件事不是编码,是选型。常见做法有两条路:直接用SqlConnection执行BACKUP DATABASE命令,或者引用SMO(SQL Server Management Objects)程序集,用Backup对象驱动备份。两者我都写过,说下真实差异。
SqlConnection加T-SQL这条路轻、依赖少,部署到客户服务器时不用考虑SMO程序集版本冲突。但缺点也直接:拿不到进度百分比,拿不到完整的事件回调,备份脚本字符串要靠拼接,一旦库名或路径里有特殊字符,容易翻车。SMO这条路重一些,但把备份动作封装成了对象模型,有PercentComplete事件、Complete事件,能精确控制Initialize、Checksum、ContinueAfterError这些底级参数,而且生成的T-SQL脚本可以ToTransactSql()打出来人工校对。
我的选型结论:如果做的是单机小工具、给同事临时用,走T-SQL足够;如果做的是要长期维护、要对接多个实例、要写进度界面和日志的系统,用SMO。后面所有代码示例都按SMO方案写,这也是“备份工具源码”这类项目里最常见的架构。引用SMO时注意,Microsoft.SqlServer.Management.Smo和Microsoft.SqlServer.Management.Common两个命名空间都要引入,Backup类在Smo程序集里,ServerConnection在Common里,少引一个编译就报错。
2.2 最小可用的备份核心:一个能跑的Backup方法
先把核心方法写出来。这个方法接收连接串、库名、备份文件完整路径,执行一次完整备份,并且打开校验和和事件输出:
using Microsoft.SqlServer.Management.Smo; using Microsoft.SqlServer.Management.Common; public bool BackupDatabase(string connectionString, string databaseName, string backupFilePath) { // ServerConnection负责管理连接生命周期,用完必须Dispose using (ServerConnection conn = new ServerConnection()) { conn.ConnectionString = connectionString; conn.StatementTimeout = 600; // 大库备份可能超过默认30秒,必须拉长 Server server = new Server(conn); Backup sqlBackup = new Backup(); // 指定备份动作是完整备份,而不是日志或差异备份 sqlBackup.Action = BackupActionType.Database; sqlBackup.Database = databaseName; // 备份设备用文件类型,路径如果已有同名文件且Initialize为true则覆盖 sqlBackup.Devices.AddDevice(backupFilePath, DeviceType.File); sqlBackup.BackupSetName = $"{databaseName}-Full-{DateTime.Now:yyyyMMddHHmmss}"; sqlBackup.BackupSetDescription = "C# Backup Tool - Full Backup"; // Initialize=true表示覆盖现有媒体;Checksum=true给备份文件加校验和,恢复时能验证完整性 sqlBackup.Initialize = true; sqlBackup.Checksum = true; // 遇到错误继续还是终止:建议false,备份失败就停下来,别产出一个残缺文件 sqlBackup.ContinueAfterError = false; // 订阅进度事件,写日志或进度条都用它 sqlBackup.PercentComplete += (s, e) => { Console.WriteLine($"备份进度: {e.Percent}%"); }; // SqlBackup是同步阻塞方法,大库会卡住UI线程,工具里建议放到后台任务 sqlBackup.SqlBackup(server); return true; } }逻辑说明:ServerConnection包装了底层的SqlConnection,从连接串创建后传给Server构造函数,后续SMO对象都基于这个连接工作。StatementTimeout一定要调大,默认值在备份大库时会出现超时中断,症状就是工具跑了十几分钟报“超时已过期”,但其实数据库服务端备份还在继续,这个错位很坑。
参数说明:Initialize = true是“覆盖写”的开关,如果目标路径已有同名文件且值为false,SMO会尝试追加备份集到现有媒体。我们做定时全备,文件名带时间戳,理论上不存在同名覆盖,但保险起见还是设为true。Checksum = true会生成校验和信息,代价是略微增加备份耗时,建议生产环境开启,后面做RESTORE VERIFYONLY时能多一层校验。同步阻塞这个点再说细一点:SqlBackup(server)执行期间当前线程一直挂着,如果是WinForm或WPF工具,不放到后台线程界面会直接假死;如果是Windows服务,开一个Task.Run包一层就行。
2.3 备份文件名与目录规划:先定规矩再写代码
代码能跑之后,第二件事是把文件命名和目录结构定下来。这个环节看起来不起眼,但决定了工具上线后运维能不能一眼看懂文件,以及清理脚本能不能安全删文件。
我一般用这个命名规范:
Bak_{ServerName}_{DatabaseName}_{BackupType}_{yyyyMMdd_HHmmss}.bak举例:Bak_SRV-PROD-01_ERP_Prod_FULL_20250610_023000.bak。类型字段写FULL、DIFF、LOG三种,扫目录时按最后修改时间和类型分组,就能算出每天/每周的备份覆盖情况。注意ServerName不要用hostname命令的裸主机名,用SqlConnection.DataSource取到的完整实例名——本地默认实例和命名实例的解析规则不同,命名实例在文件名里要体现,否则两台机器恢复文件时容易混淆实例。
目录规划上,我习惯按“根目录-数据库名-日期”三级组织:
D:\SQLBackup\ ERP_Prod\ 2025-06-10\ Bak_SRV-PROD-01_ERP_Prod_FULL_20250610_023000.bak 2025-06-11\ Bak_SRV-PROD-01_ERP_Prod_FULL_20250611_023000.bak按日期分目录的收益有两个:一是清理策略可以精确到“删掉某天之前的所有目录”,二是恢复时找一个特定日期的文件路径很直观。备份盘不要放C盘,也不要和数据库数据文件放同一块物理磁盘——磁盘坏道或空间打满时,至少备份盘和数据库盘不会同时挂掉。如果服务器是虚拟机,至少也要区分两个不同的数据存储位置。这一步定不好,后面清理策略和恢复验证都会写得别扭。
3. 备份策略与调度:全备、差异备与日志备怎么组合才不背锅
3.1 三种备份类型的定位与组合建议
备份策略的讨论不能只谈“每天全备一次”。全备文件动辄几十上百GB,每天一次在磁盘和时间上都不划算;差异备恢复麻烦一点,但文件小;日志备恢复粒度最细,但和全备、差异备之间有严格的链路依赖。三者组合才是生产环境常态。
| 备份类型 | 频率建议 | 恢复文件依赖 | 占用空间 | 恢复RPO |
|---|---|---|---|---|
| 完整备份 | 每周1~2次 | 仅自身 | 最大 | 丢失最近一周数据 |
| 差异备份 | 每日1次 | 最近一次完整备份+自身 | 中等 | 丢失最近一天数据 |
| 日志备份 | 按事务量,15分钟~1小时 | 全备+最后一次差异备+后续日志链 | 最小但文件多 | 丢失最近几分钟数据 |
组合建议这种话不能乱说,得看业务。常见做法是:核心交易库走“周一全备 + 每天差异 + 每小时日志”,报表库或临时库走“每周全备 + 每天差异”。日志备份粒度太密时要注意一个问题:日志链不能断。全备之后必须接着一个日志备或差异备做链头,否则恢复时日志无法续接,这个坑在后面的恢复演练里会现原形。
回到代码上,SMO里切换备份类型非常直接,BackupActionType枚举三个值:Database是全备,Differential是差异备,Log是日志备。封装时不要写死,把备份类型作为参数传进方法里,调度配置里写"Type": "DIFF",工具启动时反射生成对应的Action。这样做的好处是同一个核心备份方法,三个备份类型共用,不会出现“全备代码和日志备代码维护两套”的场面。
3.2 调度落地:定时器、Windows任务计划与防重入
调度是另一个大坑。很多人第一反应是用Windows任务计划调用exe,这个方案有优点:简单、系统级保障、失败时有事件日志。但它也有个致命弱点——任务计划调exe跑的是独立进程,进程之间没有状态同步,如果上次备份进程因为网络问题卡住没退出,下一次任务又触发了,两个备份进程会同时写一个目录,轻则文件锁冲突,重则两个进程争抢I/O把磁盘打满。用Windows服务加System.Timers.Timer自调度,能更好地控制并发。
我一般这样写调度判断:
// 服务启动时初始化Timer Timer timer = new Timer(); timer.Interval = 60000; // 每60秒检查一次当前时间是否到达计划时刻 timer.Elapsed += (s, e) => { DateTime now = DateTime.Now; // 配置里读计划,例如每天凌晨2点30分执行全备 if (now.Hour == backupPlan.Hour && now.Minute == backupPlan.Minute && now.Second < 5 && !_isRunning) // _isRunning是防重入锁,避免备份超时导致并发执行 { _isRunning = true; try { RunBackupTask(); } catch (Exception ex) { Logger.Error(ex, "备份任务执行异常"); } finally { _isRunning = false; } } }; timer.Start();逻辑说明:Timer.Interval设成60秒,每分钟触发一次Elapsed事件,检查当前小时和分钟是否匹配计划时间。这里必须把“触发”和“执行”分开:触发条件是时间匹配,执行条件是_isRunning为空闲。如果直接设Timer.Interval为24小时且从服务启动时间开始算,就没有“错过执行”的问题;但Windows服务可能被系统回收或手动重启,重启后定时器从重启时间重新计时,当天任务可能被跳过。所以每分钟轮询一次更稳。
_isRunning这个锁很关键。举例:凌晨2点30分触发备份,但一个大库备了25分钟,2点55分还没结束,2点31分那次Elapsed进来发现时间已不再是2点30分,不会重复触发;但如果计划时刻是2点30分,而2点31分备份还在跑,这时候时间匹配条件已经不成立,锁的判断是多余的?不是——假如备份特别快,1秒就结束了,那么2点30分01秒这次Elapsed里,now.Minute已经不等于30,不会误触;真正的风险在于计划设在整点且备份极快,第二次Elapsed在同一分钟内再次满足条件,所以_isRunning是最后一道防线。另外,这里我建议把备份任务扔到单独的Task.Run里执行,避免长时间阻塞Timer的Elapsed线程,影响后续调度判断。
3.3 旧备份清理策略:只清不删是伪备份
备份文件的清理不是简单地删旧文件。既要保证保留足够多的恢复点,又不能让磁盘无限增长,还要防止误删还没验证过的备份。清理策略我按保留数量来,少用按天算法:
public void CleanupBackupFiles(string databaseName, int keepCount) { string dbDir = Path.Combine(backupRoot, databaseName); if (!Directory.Exists(dbDir)) return; // 只挑.bak和.trn文件,避免误删目录里其他文件 var files = Directory.GetFiles(dbDir, "*.bak", SearchOption.AllDirectories) .Select(path => new FileInfo(path)) .OrderByDescending(f => f.LastWriteTime) .ToList(); // 超过保留数量的旧文件删除,但要先跳过今天还没验证过的文件 var pendingVerify = files.Where(f => !IsVerified(f.FullName)).ToList(); if (pendingVerify.Any()) { Logger.Warn($"存在未验证备份,跳过清理: {string.Join(",", pendingVerify.Select(f => f.Name))}"); return; } for (int i = keepCount; i < files.Count; i++) { File.Delete(files[i].FullName); Logger.Info($"清理过期备份: {files[i].FullName}"); } }参数说明:keepCount从配置读取,比如保留最近14份完整备份。关键是“未验证不清理”这个逻辑——每次备份完成后工具要执行一次RESTORE VERIFYONLY,校验通过的备份在数据库或元数据文件里打标记,清理时如果发现最近一次备份还没验证,就暂停清理。这个判断能避免一个场景:备份过程表面成功,但文件实际损坏,清理脚本先把旧的好备份删了,剩下唯一一份还是坏的,那就真的没有后悔药了。
IsVerified的实现可以是查SQLite或一个json元数据文件,记录每个备份文件的验证时间与结果。这个文件本身也要备份,最简单的做法是随备份文件同目录输出一份verify_result.json,清理时读取。要注意LastWriteTime在复制文件时会变,如果备份文件是从临时目录move过来的,时间戳可能不是备份完成时刻,所以判断先后顺序用文件名里的时间戳更可靠——这正是2.3节命名规范的价值。
4. 配置化与运维反馈:让工具摆脱“一人写、一人用”的窘境
4.1 配置文件设计:把连接串与保留策略外置
备份工具如果只服务一个库、一个服务器,配置写死在代码里还能接受。但只要部署到第二台机器或者加一个库,写死的代码就要重新编译。所以源码里必须把连接串、备份类型、时间计划、保留策略全外置到一个配置文件。我用JSON格式,比xml直观,用System.Text.Json直接反序列化成配置对象:
{ "Database": { "Server": "192.168.10.20\\SQLEXPRESS", "DatabaseName": "ERP_Prod", "AuthType": "Windows" }, "Backup": { "Directory": "D:\\SQLBackup", "Type": "DIFF", "Hour": 2, "Minute": 30, "KeepCount": 14, "VerifyAfterBackup": true }, "Notification": { "Enabled": true, "MailHost": "smtp.internal.local", "MailFrom": "backup@corp.local", "MailTo": "dba@corp.local" } }读配置和参数校验这块有几个讲究。连接串不要在JSON里直接拼User Id=...;Password=...这种字符串,密码里一旦包含分号或引号,解析直接出错。正确做法是用SqlConnectionStringBuilder赋值DataSource、InitialCatalog、IntegratedSecurity等属性,再调ToString()生成连接串。这样既避免了转义问题,也方便后面根据AuthType切换Windows认证和SQL认证。
还有一点关于Hour和Minute的校验:配置里如果写成24之类的不合法值,反序列化不会报错,到了运行时秒级检查永远匹配不上,备份任务静默不执行。这比报错更可怕,因为没人发现。我习惯在服务启动时校验计划字段范围,并打印一条明显的启动日志,配置错了至少要让人一眼看到。
4.2 日志与通知:不告警的备份等于没备份
备份工具的运维反馈分成两层:日志层和通知层。日志层不要只写Console.WriteLine就完事,服务跑起来之后没人看控制台。建议引入NLog或Serilog,把日志同时写到文件和一个独立的事件表中去。文件日志按天滚动,方便审计时按日期检索;事件表记录关键动作:任务启动、备份完成、校验结果、清理删除了哪个文件,这些信息在数据库恢复时可追溯。
在读取代码里加一个简单的结构化日志调用:
private void LogBackupResult(string databaseName, string backupFilePath, bool success, string message) { var entry = new BackupLogEntry { DatabaseName = databaseName, FilePath = backupFilePath, BackupTime = DateTime.Now, Success = success, Message = message, FileSize = success ? new FileInfo(backupFilePath).Length : 0, ChecksumVerified = false }; // 序列化后写入日志文件,同时可插入一张Log表 string json = JsonSerializer.Serialize(entry); File.AppendAllText(logDir + "backup_" + DateTime.Now.ToString("yyyyMMdd") + ".json", json + Environment.NewLine); }通知层不要依赖“人工每天看日志”这种习惯,事件日志写得再详细,没有告警就是白搭。最小可用方案是邮件:备份失败发邮件,校验失败发邮件,连续N天没有备份任务记录也要发邮件——最后这条是排查“任务计划根本没触发”的利器。
邮件用System.Net.Mail.SmtpClient实现,注意.NET Core/5+里SmtpClient已经不推荐,但依然可用,不必为此引入额外依赖。更进一步的方案是接企业微信机器人或钉钉webhook,POST一个json就行,比邮件少很多SMTP配置的麻烦。但就算接webhook,也别忘了本地日志,因为故障排查时第一证据是日志,不是聊天记录。
配置化这件事最大的收益不是“省得改代码”,而是让工具变成一份可交付的资产。运维拿到配置改改库名就能接新实例,不用预约开发排期。这份资产能不能长久用下去,就看配置设计得是否清晰、日志是否扛得住审计。
5. 避坑章节:C# SQL Server备份工具的高频坑与排查清单
5.1 实例名解析失败:本地能连,工具连不上
现象:开发机上用SSMS连接数据库正常,工具启动后报“在建立与服务器的连接时出错。在连接到SQL Server时,默认设置下,SQL Server不允许进行远程连接”。
原因:最常见的是连接串里实例名写错。命名实例要写成主机名\实例名,本地默认实例可以只写主机名或.。另一个隐蔽原因是服务器上SQL Browser服务没启动——命名实例的端口动态分配,客户端靠SQL Browser获取端口号,Browser服务停止后,局域网内其他机器连不上,但本机SSMS因为实例名可以走共享内存协议,表现正常。
解决:三步排查。第一步在配置里把Server字段写成主机名\实例名,端口号的显式格式;第二步打开SQL Server配置管理器,确认named pipes和TCP/IP协议已启用;第三步确认SQL Browser服务(如果你的SQL Server版本里还有这个服务)已启动并设为自动。写工具时建议在配置里支持显式端口字段,这样部署时遇到动态端口问题可以直接改端口,不用改实例名。
5.2 SMO版本冲突:部署机报“找不到程序集”是常态
现象:工具在本机能跑,拷贝到另一台服务器(通常是干净的Windows Server环境)双击启动,直接抛出“未能加载文件或程序集Microsoft.SqlServer.Management.Smo”之类的异常。
原因:SMO不是.NET运行时自带的,它由SQL Server或独立安装包提供到GAC里。开发机装了完整版SQL Server有SMO,部署机上可能只有客户端工具版本,或者压根没装SQL Server组件。最坑的是有些精简版安装包只装了命令行工具,SSMS的SMO依赖不全。
解决:不要赌目标机环境,把SMO相关DLL直接拷到工具输出目录,作为本地程序集引用。具体文件一般包括Microsoft.SqlServer.ConnectionInfo.dll、Microsoft.SqlServer.Management.Sdk.Sfc.dll、Microsoft.SqlServer.Smo.dll、Microsoft.SqlServer.SqlEnum.dll等五六个文件。把它们的Copy Local设为true,发布时一并带出。如果目标环境已经有更高版本的SMO,复制过去可能引发版本绑定冲突,建议在app.config里加bindingRedirect,SMO版本之间的兼容性尚可,多数问题靠重定向能解决。
5.3 备份文件损坏但备份日志显示成功
现象:备份任务每天都显示成功,文件大小也正常,但某天需要恢复时,RESTORE DATABASE报“备份集校验失败”或“无法读取备份集”。数据只能恢复到前几天某个更旧的文件,中间的数据全丢了。
原因:备份过程本身没报错,但文件在生成过程中遇到了I/O异常静默,或者磁盘坏道导致写入的文件部分损坏。BACKUP DATABASE不默认做全量校验,Checksum=true虽然会生成校验和,但如果磁盘坏道发生在写入完成后的静默读回阶段,只有RESTORE VERIFYONLY才会暴露问题。
解决:备份完成后立即执行一次校验:
private bool VerifyBackup(string connectionString, string backupFilePath) { string sql = $"RESTORE VERIFYONLY FROM DISK = N'{backupFilePath}' WITH CHECKSUM;"; using (SqlConnection conn = new SqlConnection(connectionString)) { using (SqlCommand cmd = new SqlCommand(sql, conn)) { conn.Open(); cmd.ExecuteNonQuery(); // 校验失败会抛异常,捕获后返回false return true; } } }注意WITH CHECKSUM这个词是校验关键:不加它,VERIFYONLY只验证备份集的元数据是否能读取,不逐页校验数据内容。加了它,SQL Server会用备份写入时生成的校验和逐页对照,发现不一致直接报错。这个操作会比单纯备份多花一些时间,但换来的是“备份成功的文件一定可恢复”这个确定结论。生产环境建议开启并写入日志。
5.4 任务计划不触发或重复触发
现象:配置了每日凌晨2点30分备份,但某个早上检查发现昨天没有备份文件;或者更诡异的是,有两天出现了重复的备份文件(同一分钟两个文件)。
原因:不触发的常见原因是Windows服务被回收或机器休眠。系统在凌晨进入睡眠状态,Timer的Elapsed事件没有按时触发,等到第二天早上唤醒时,服务进程还在但当天的备份窗口已经错过。重复触发的原因通常是上一次备份执行超过了调度周期,或者服务被多次启动——比如任务计划里既配置了开机启动服务,又配置了定时执行exe。
解决:半夜备份的场景先把服务器的电源设置改掉,计划任务和服务所在进程必须允许“唤醒计算机”。服务端在_isRunning锁的基础上再加一层互斥体,防止服务重启瞬间多个进程实例并存:
bool createdNew; using (Mutex mutex = new Mutex(true, "Global\\SqlBackupToolMutex", out createdNew)) { if (!createdNew) { Logger.Error("另一个备份工具实例正在运行,本实例退出"); return; } // 启动服务主逻辑 }部署时还要注意任务计划里如果同时配了两个触发条件(比如“按计划运行”和“启动时”),会在启动时补跑一次,和凌晨计划任务打架。把这个补跑条件去掉,只保留一个时间触发即可。
6. 给备份工具加一道保险:恢复演练与校验闭环
VERIFYONLY能证明备份文件结构完整,但证明不了“恢复到新实例后业务数据可用”。这两者之间有巨大的差距。结构完整性只说明页面校验和一致,而业务数据可用性可能涉及索引碎片、逻辑一致性、事务日志链衔接等更深层的问题。所以我在工具里加了一项“月度自动恢复演练”:从备份目录中挑一份最新的全备文件,恢复到一台隔离的临时实例上,然后执行几条探测SQL,比如查询最近几天的订单数、最大流水号、关键表的行数,和线上实例对比,偏差超过阈值就标记为恢复失败。
这里有个现成的恢复方法:
public void RestoreDatabaseToInstance(string connectionString, string targetDbName, string backupFilePath) { string sql = $@" RESTORE DATABASE [{targetDbName}] FROM DISK = N'{backupFilePath}' WITH MOVE 'ERP_Prod' TO N'D:\TempRestore\ERP_Prod.mdf', MOVE 'ERP_Prod_log' TO N'D:\TempRestore\ERP_Prod_log.ldf', RECOVERY, REPLACE, STATS = 10;"; // RECOVERY让数据库处于在线可查询状态,REPLACE允许覆盖现有同名库 using (SqlConnection conn = new SqlConnection(connectionString)) { using (SqlCommand cmd = new SqlCommand(sql, conn)) { conn.Open(); cmd.ExecuteNonQuery(); } } }注意MOVE子句里两个逻辑文件名必须和源库一致,可以先查询RESTORE FILELISTONLY FROM DISK = ...拿到准确名字再拼SQL,不要在代码里写死——不同版本的业务库逻辑文件名未必相同。真实成本上,月度演练挑一个业务低谷时段,用不重要的实例跑一次全备恢复大概十几分钟,换来的是对备份文件从“理论可恢复”到“实测可恢复”的笃定。
这也是我做备份工具这些年最深刻的教训:写工具时花一小时做自动校验,恢复时就能省下通宵抢救数据和写事故报告的时间。备份工具的源码价值不在于那几行备份命令,而在于它能形成“备份-校验-演练-清理”的闭环,让备份这件事从玄学变成工程。希望帮到你。
本文还有配套的精品资源,点击获取