资讯动态

C# WinForm SQL Server 备份恢复:T-SQL 与 SMO 双路线

发布时间:2026/10/29 11:40:17 来源:尧图企业网站定制
简介这份面向C# WinForm开发者的数据库备份与恢复演示工程聚焦桌面应用中的数据安全保障与故障恢复场景帮助读者理解SQL Server备份恢复在客户端程序中的落地方式对桌面程序的数据维护需求有直接参考意义。压缩包共27个文件以7个cs源码、3个exe程序、2个txt说明为主并辅以dll、resx、csproj、sln等工程与配置项整体约582KB便于查看解决方案结构和运行示例。已有502人学习。示例围绕SQLDMO对象调用与直接执行T-SQL语句两条路线展开两者分别侧重对象化控制、错误处理与简洁执行包含完整VS2008工程、界面交互代码及Demo说明读者可对照学习连接配置、备份文件路径设置、恢复模式与覆盖选项等关键环节也可借鉴异常处理与操作流程组织方式适合初学者和有一定经验的开发者参考。1. 一个按钮按下去的后果C# WinForm 数据库备份与恢复 Demo 到底顶什么用有个做门店系统的朋友在自己写的 C# WinForm 数据库备份与恢复工具上点错了一次「一键恢复」选中的是三天前的包一秒钟三天流水没了。这个 Demo 就是那次事故之后我拿来给他演示的两条正路一条是把BACKUP DATABASE/RESTORE DATABASE语句拼好交给SqlCommand执行另一条是引用 SMO用Backup、Restore两个类走对象模型。同一个窗体用单选按钮切换路线备份、恢复、进度、日志都在一个界面上能直接改成自己的工具。适合手里有 SQL Server、会一点 WinForm、想要个能落地的小工具的人纯新手也能跟着一步步敲出来但别指望它能替代运维那边的定时备份策略。2. 先拆两条路线T-SQL 命令与 SMO 对象模型怎么选2.1 两条路线的本质差别先想清楚再动手路线 A 是拼字符串。把库名、备份路径、WITH选项拼成一条完整的 T-SQL用SqlConnectionSqlCommand发过去执行。好处很直接不引任何第三方包代码贴到 SSMS 里就能跑排查时不用猜「对象模型到底帮我做了什么」。坏处也很直接路径里有单引号要自己转义进度只能靠STATS参数吐回来的InfoMessage错误码得自己按SqlException.Number分类。路线 B 是引Microsoft.SqlServer.SqlManagementObjects一般简称 SMO用ServerBackupRestore这套类把每一步变成属性赋值。好处是强类型、有PercentComplete事件可以直接喂进度条、RelocateFile天然处理恢复时的文件位置迁移backup.Script(server)还能把等价 T-SQL 吐出来当排查依据。坏处是依赖一大堆部署包会明显变大而且 SMO 的版本必须跟目标 SQL Server 对得上客户那边跑着老版本实例时容易连不上。我一般的选法是一次性的、自己用的、内网小工具走 A代码少、甩锅容易要给非技术同事长期点、要真实进度条、要多库循环备份的走 B。Demo 里两条都放进去不只是为了演示而是因为恢复这种高危动作最好是「用 A 生成语句、用 B 的执行结果做交叉验证」两边结果一致才敢点下去。2.2 工程骨架项目结构、连接串和目录约定窗体项目最怕的不是不会写是所有逻辑都堆在Form1.cs里三百行之后连自己都找不到备份路径在哪拼的。我习惯按下面这种方式分位置职责Form1.cs只做界面、状态切换、日志输出不碰 SQLBackupService.cs方式一的备份逻辑拼语句、执行、回调进度BackupSmoService.cs方式二的备份逻辑用 SMO 对象RestoreService.cs恢复逻辑含FILELISTONLY、单用户切换、MOVEApp.config连接串、默认备份目录、超时时间连接串不要写死在代码里业务库和master各配一条。恢复时切单用户、读FILELISTONLY这些动作必须在master上执行所以两条串都得有。!-- App.config业务库和 master 各一条后面切单用户要用 master 那条 -- configuration connectionStrings add nameMainDb connectionStringServer.\SQLEXPRESS;DatabaseDemoDb;Integrated SecurityTrue;TrustServerCertificateTrue providerNameSystem.Data.SqlClient / add nameMaster connectionStringServer.\SQLEXPRESS;Databasemaster;Integrated SecurityTrue;TrustServerCertificateTrue providerNameSystem.Data.SqlClient / /connectionStrings appSettings add keyBakDir valueD:\DbBackup / add keyCommandTimeout value0 / /appSettings /configuration// 统一从配置读缺了就直接抛别让 null 传到 SqlConnection 里变成玄学报错 static string GetConn(string name) { var cs ConfigurationManager.ConnectionStrings[name]?.ConnectionString; if (string.IsNullOrWhiteSpace(cs)) throw new InvalidOperationException($连接串 {name} 没有配置); return cs; }CommandTimeout建议配成 0不限时。默认的 30 秒对几百兆以上的库来说就是个定时炸弹命令超时抛异常但 SQL Server 那边的BACKUP语句并不会因为你客户端断了就停下来它还在后台写文件你这边以为失败了又重新点一次最后磁盘上出现几个半成品.bak恢复时全是坑。这个参数是整篇文章里最容易被忽略、代价又最大的一个。2.3 窗体状态机按钮不是随时都能亮的界面上至少要有这些控件服务器/库下拉框用sys.databases填充过滤掉master、tempdb这类系统库、备份文件选择框保存对话框、恢复文件选择框打开对话框、方式一/方式二的单选按钮、进度条、只读日志文本框、备份和恢复两个按钮。关键不是控件摆多少是状态要管住。我一般定四个状态未连接、已连接空闲、备份中、恢复中。备份和恢复进行中除了「取消日志滚动」之外所有按钮一律Enabled false尤其是另一个动作的按钮——备份还没写完就去点恢复那是自己给自己制造事故现场。// 用 async/await 把耗时操作丢出 UI 线程同时把按钮锁死 private async void btnBackup_Click(object sender, EventArgs e) { SetBusy(true); // 内部把所有操作按钮 Enabled false进度条 Marquee try { var progress new Progressstring(msg txtLog.AppendText(msg Environment.NewLine)); string bakPath Path.Combine(GetBakDir(), BuildBakName(cmbDb.Text)); if (rbCmd.Checked) await Task.Run(() BackupService.BackupBySql(GetConn(MainDb), cmbDb.Text, bakPath, progress)); else await Task.Run(() BackupSmoService.BackupBySmo(GetConn(MainDb), cmbDb.Text, bakPath, progress)); } catch (Exception ex) { txtLog.AppendText($[失败] {ex.Message}{Environment.NewLine}); } finally { SetBusy(false); } }Progressstring是在 UI 线程创建、在工作线程回调的它内部会把回调 marshal 回创建线程所以往TextBox里AppendText不会抛跨线程异常。这一点比直接用Invoke手写清爽得多也省掉了「窗体已经关掉但后台线程还在回调」这种偶发崩溃的一半来源。Task.Run包住同步的ExecuteNonQuery是刻意的ExecuteNonQuery本身没有异步版本能覆盖到这种长命令硬用ExecuteNonQueryAsync也拦不住真正的备份时间索性老老实实开线程。3. 方式一把 BACKUP / RESTORE 语句写对比写界面难十倍3.1 备份语句的参数INIT、COMPRESSION、CHECKSUM、STATS一条能用的备份语句是这样拼的public static void BackupBySql(string connStr, string dbName, string bakPath, IProgressstring progress) { // 路径里的单引号必须转义否则库名或目录带引号时直接语法错误 string safePath bakPath.Replace(, ); string sql $ BACKUP DATABASE [{dbName}] TO DISK N{safePath} WITH INIT, COMPRESSION, CHECKSUM, STATS 10;; using (var conn new SqlConnection(connStr)) { // STATS 10 每隔 10% 回一条消息这里是唯一能拿到进度的入口 conn.InfoMessage (s, e) { foreach (SqlError m in e.Errors) progress?.Report(m.Message); }; conn.Open(); using (var cmd new SqlCommand(sql, conn)) { cmd.CommandTimeout 0; // 大库别用默认 30 秒 cmd.ExecuteNonQuery(); } } }几个参数值得单独说清楚参数作用什么时候必须加INIT覆盖已有文件里所有备份集文件名带时间戳、一个文件只放一份备份时NOINIT追加到文件末尾保留原有备份集一个文件放多份、需要按时间回滚时COMPRESSION启用备份压缩体积大、磁盘紧但会吃 CPUCHECKSUM写入校验和强烈建议加恢复时能早发现介质错误STATS 10每 10% 回一条进度消息想显示进度就必须加COPY_ONLY不打断日志备份链生产库做临时备份时用INIT和NOINIT是互斥的默认是NOINIT。如果文件名固定又不写INIT你会得到一个越来越大的.bak里面躺着几十份备份集恢复时还得先RESTORE HEADERONLY看清楚是第几个位置——这就是后面避坑那章里要单独讲的一个坑。InfoMessage这条线还有个小细节返回值是已处理百分之 20%这种中文字符串别去解析数字直接原样打到日志里就行。真要驱动进度条用Regex抠数字或者干脆用STATS 5让粒度细一点别为了一个进度条把错误处理搞乱。3.2 恢复前先 FILELISTONLY再谈 MOVE 和 REPLACE恢复比备份危险是因为备份只是「往外写」恢复是「往内覆盖」。而覆盖之前有一件事必须先做问清楚备份包里到底是什么文件。// 只读备份集头部不动任何数据库代价很小但能省掉后面 90% 的 MOVE 错误 public static List(string Logical, char Type) ReadFileList(string masterConnStr, string bakPath) { var list new List(string, char)(); string sql $RESTORE FILELISTONLY FROM DISK N{bakPath.Replace(, )}; using (var conn new SqlConnection(masterConnStr)) using (var cmd new SqlCommand(sql, conn)) { conn.Open(); using (var reader cmd.ExecuteReader()) { int iName reader.GetOrdinal(LogicalName); int iType reader.GetOrdinal(Type); // D数据文件L日志文件 while (reader.Read()) list.Add((reader.GetString(iName), reader.GetString(iType)[0])); } } return list; }拿到逻辑文件名之后MOVE才有依据// 逻辑名是建库时定的改库名不会跟着变这里必须用 FILELISTONLY 读出来的名字 public static void RestoreBySql(string masterConnStr, string dbName, string bakPath, string logicalData, string logicalLog, string newDataPath, string newLogPath) { string sql $ ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; RESTORE DATABASE [{dbName}] FROM DISK N{bakPath.Replace(, )} WITH REPLACE, RECOVERY, MOVE N{logicalData} TO N{newDataPath}, MOVE N{logicalLog} TO N{newLogPath}, STATS 10; ALTER DATABASE [{dbName}] SET MULTI_USER;; using (var conn new SqlConnection(masterConnStr)) { conn.Open(); using (var cmd new SqlCommand(sql, conn)) { cmd.CommandTimeout 0; cmd.ExecuteNonQuery(); } } }REPLACE的含义是「覆盖同名库」不加它当目标库已存在时会直接被拒。RECOVERY让库恢复完处于可用状态如果你要接着还原日志备份链就得改成NORECOVERY并最后再单独来一次RECOVERY——单机小工具一般用不上但知道这个概念遇到「恢复完数据库一直显示正在还原」时就不会慌。还有一个常被忽略的动作恢复前用RESTORE VERIFYONLY验一遍包。这个命令只读文件、校验校验和不动数据库几秒钟的事却能提前发现备份文件被拷坏、被截断。3.3 在 WinForm 里执行长命令超时、单用户、异常处理执行侧要注意三件事。第一是超时前面已经强调过CommandTimeout 0。第二是连接池窗体开着的时候业务库那条连接可能还躺在池子里ALTER DATABASE ... SET SINGLE_USER会一直等它等不到就报错。稳妥的做法是在切单用户之前先清池// 恢复前先清掉池子里的旧连接否则 SINGLE_USER 永远拿不到独占权 SqlConnection.ClearAllPools();第三是异常分类。SqlException.Number里几个高频值值得单独处理直接在日志里翻译成人话比抛一个英文堆栈给同事强得多3101数据库正在被使用独占访问无法获得3201找不到备份设备或文件不存在3159备份集里的文件与目标路径冲突常见于 MOVE 没写全3241介质集已满 / 文件写入失败写一个小的翻译函数把Number映射成中文提示日志里同时保留原始的Message。这套东西不复杂但它是「Demo」和「能交给别人用」之间的分水岭。4. 方式二引 SMO 走对象模型进度条和 MOVE 都省事4.1 装依赖与版本对齐SMO 的 NuGet 包名是Microsoft.SqlServer.SqlManagementObjects。在.NET Framework项目里直接装就行在.NET 6/8项目里要注意它会带进Microsoft.Data.SqlClient如果你项目里同时引着System.Data.SqlClient会出现类型重复的编译警告最好统一到一套客户端上。!-- .csprojSMO 一条其余交给传递依赖 -- ItemGroup PackageReference IncludeMicrosoft.SqlServer.SqlManagementObjects Version* / /ItemGroup这里刻意写Version*不是偷懒而是 SMO 的版本必须不低于目标 SQL Server 的版本。SMO 版本太老连上高版本实例时会出现枚举值解析失败、新特性属性读不出来之类的怪事报错信息还往往指向莫名其妙的Microsoft.SqlServer.Management.Common内部。装完包之后打开packages目录或者obj/project.assets.json确认一下实际拉下来的是哪一版比在错误现场猜半天划算。连接也不复杂ServerConnection包一个SqlConnection再喂给Serverusing Microsoft.SqlServer.Management.Common; using Microsoft.SqlServer.Management.Smo; // ServerConnection 负责连接Server 负责对象模型别把两者搞混 var conn new ServerConnection(new SqlConnection(connStr)); var server new Server(conn);4.2 Backup 类的关键属性public static void BackupBySmo(string connStr, string dbName, string bakPath, IProgressstring progress) { var server new Server(new ServerConnection(new SqlConnection(connStr))); var backup new Backup { Action BackupActionType.Database, Database dbName, Incremental false, // 完整备份 Initialize true, // 等价于 T-SQL 的 INIT Checksum true, // 写入校验和 CompressionOption BackupCompressionOptions.On, PercentCompleteNotification 5 // 每 5% 触发一次 PercentComplete 事件 }; backup.Devices.AddDevice(bakPath, DeviceType.File); // 进度事件里直接刷 UI 是不行的走 IProgress 转回调用线程 backup.PercentComplete (s, e) progress?.Report($备份进度{e.Percent}%); backup.SqlBackup(server); // 同步调用外层用 Task.Run 包住 }属性几乎和 T-SQL 参数一对一这也是我推荐先学 T-SQL 再学 SMO 的原因先搞懂INIT、CHECKSUM、COMPRESSION在做什么再回来看这些属性名基本上不用查文档。PercentCompleteNotification控制回调频率设成 5 比默认的 10 更顺滑但别设成 1事件触发太密反而卡 UI。backup.Script(server)这个方法值得单独记一下它返回一个StringCollection里面是为这次备份生成的等价 T-SQL。日志里打一份出来出问题时拿着这几行去 SSMS 里手动执行能立刻分清是「SMO 封装出问题」还是「SQL Server 那边就是不行」。4.3 Restore 类与 RelocateFile恢复的 SMO 写法核心是RelocateFilepublic static void RestoreBySmo(string connStr, string dbName, string bakPath, string logicalData, string logicalLog, string newDataPath, string newLogPath, IProgressstring progress) { var server new Server(new ServerConnection(new SqlConnection(connStr))); var restore new Restore { Database dbName, Action RestoreActionType.Database, ReplaceDatabase true, // 等价于 WITH REPLACE NoRecovery false, // false 恢复后可以直接用 PercentCompleteNotification 5 }; restore.Devices.AddDevice(bakPath, DeviceType.File); // 逻辑名 - 新物理路径这一步替代 T-SQL 里的 MOVE restore.RelocateFiles.Add(new RelocateFile(logicalData, newDataPath)); restore.RelocateFiles.Add(new RelocateFile(logicalLog, newLogPath)); restore.PercentComplete (s, e) progress?.Report($恢复进度{e.Percent}%); restore.SqlRestore(server); }逻辑名还是得从RESTORE FILELISTONLY拿。用restore.ReadFileList(server)也能拿到DataTable字段和FILELISTONLY一致两种方式选一种就行——我一般在方式二的代码里用ReadFileList方式一里用FILELISTONLY等于互相验证一次。SqlRestore是同步阻塞的UI 层面同样用Task.Run包起来PercentComplete事件走IProgress回到界面线程。如果你更想用异步版本SqlRestoreAsync也有但它的取消是「尽力而为」中途取消大概率留下一个处于恢复状态的库界面上的「取消」按钮最好做成只停止刷新日志、不真去中断服务端操作。5. 避坑记录四条能让你少熬一个通宵的排查清单5.1 现象备份一点就报「拒绝访问」「找不到路径」原因BACKUP DATABASE ... TO DISK这句命令是 SQL Server 服务进程在执行路径是服务端的视角不是你这个 WinForm 所在的客户端。你在本机D:\DbBackup写权限全开但 SQL Server 跑在另一台机器或者以NT SERVICE\MSSQLSERVER账户运行它根本不认识这个盘符。解决先确认 SQL Server 服务账户对目标目录有写权限本地路径直接给这个账户授权跨机就用 UNC 共享路径并且让服务账户能访问共享。实在没法给权限就退一步让它备份到服务端本地目录客户端用文件流或者共享把这个.bak拷走别指望在客户端路径上直接备份。5.2 现象恢复时报 3101或者一直卡在「正在还原」原因目标库还有活动连接SINGLE_USER拿不到独占权还有一种情况是连接池里攥着旧连接你界面上看不到任何打开的窗口。解决恢复前固定执行三步——SqlConnection.ClearAllPools()、切SINGLE_USER WITH ROLLBACK IMMEDIATE、再做RESTORE最后记得切回MULTI_USER。切回这一步千万别漏忘了切回来的库第二天同事连上来会一脸茫然。如果还不行去sys.dm_exec_sessions里查database_id对应的会话看看是不是有别的工具连着。5.3 现象备份文件在测试环境恢复成功到客户现场报「版本不兼容」原因备份文件内部带着数据库的版本号高版本备份不能在低版本实例上还原这是硬限制没有绕过的办法。开发机是较新版本的 SQL Server客户现场是老版本就是必挂。解决短期方案是让备份方和恢复方版本对齐或者用「生成脚本 bcp导出数据」的方式跨版本迁移长期方案是在工具里加一道预检备份前读一下SERVERPROPERTY(ProductMajorVersion)把版本号写进文件名和日志里例如20240115_DemoDb_v16_FULL.bak。看见文件名就知道能不能用比恢复失败再去查日志快得多。5.4 现象给了账号还是报「BACKUP DATABASE 权限被拒绝」原因备份需要的是db_backupoperator或者显式的BACKUP DATABASE权限恢复需要dbcreator或者db_owner很多现场给的是db_datareaderdb_datawriter那套权限只能读写数据动不了备份。解决让 DBA 单独开一个备份账号权限按最小化给业务库上给db_backupoperatormaster上给ALTER ANY DATABASE级别的权限切单用户、RESTORE用别图省事直接给sysadmin。这条血泪经验来自一次现场排查报错信息里写的是权限不足但账号看着什么都有最后发现是给在了错误的库上。6. 收尾加两件事校验和与自动化让这个 Demo 能交付Demo 能跑通不等于能交出去。我一般会再加两个东西让它在别人手里也不出乱子。第一件是校验。恢复按钮点下去之前先跑一次RESTORE VERIFYONLY只读文件、不动数据库几秒到几十秒能把「文件拷坏了」「备份根本没写完」这类问题挡在外面// 恢复前的守门员只校验介质不动任何数据库 public static void VerifyBak(string masterConnStr, string bakPath) { string sql $RESTORE VERIFYONLY FROM DISK N{bakPath.Replace(, )} WITH CHECKSUM;; using (var conn new SqlConnection(masterConnStr)) using (var cmd new SqlCommand(sql, conn)) { cmd.CommandTimeout 0; conn.Open(); cmd.ExecuteNonQuery(); // 不抛异常就代表这个备份集可读、校验和通过 } }第二件是命名规范和自动化。文件名里至少要有时间戳、库名、备份类型这样半年后回来找包不用靠在资源管理器里按修改时间一个个点// 20240115_023000_DemoDb_FULL.bak扫一眼就知道是哪天哪个库的什么备份 static string BuildBakName(string dbName, string type FULL) ${DateTime.Now:yyyyMMdd_HHmmss}_{dbName}_{type}.bak;再往前一步把「备份」这个动作从窗体里拆出来做成控制台入口或者带命令行参数的静默模式挂到 Windows 计划任务里每天凌晨跑一次跑完顺手VERIFYONLY一遍失败就发一封邮件。窗体保留下来做人工恢复和临时备份自动化那部分专管日常——两者共用同一套BackupService代码不会分叉。最后说个自己的习惯。那次事故之后我改这个 Demo 时给自己立了条规矩任何一次点「恢复」之前必须先把FILELISTONLY的结果和VERIFYONLY的结论打进日志再让程序停下来等一次确认。看着麻烦但它救过我两次——一次是选错了备份集一次是包正在被另一个进程拷贝、读出来是截断的。多挡一次就少一次「三天流水没了」。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑