☰
SQL Server脚本导出导入实战:C# + SMO实现全量数据迁移
2026/10/18 9:34:49 网站建设 项目流程

简介:面向 C# 开发者与 SQL Server 数据库管理人员的实用工具包,解决无 SSMS 图形界面或需批量交付时的数据库脚本导出与导入问题,功能与 SQL Server 2014 Management Studio 的“生成脚本”相近,适合学习脚本生成原理或扩展自动化运维。压缩包 2.16MB,共 66 个文件,以 C# 源码、SQL 脚本、动态库为主,另有可执行文件、配置文件和 PDB 调试符号;源码中可见 SqlHelper、frmMain、Program 等模块,便于按数据访问、界面和程序入口分层阅读。目前已有 1489 人学习。资源内含完整 VS 解决方案与 WinForms 示例,可获得连接数据库、读取对象元数据、批量生成建库建表脚本、导入执行等编码思路,也可直接编译使用或改造成自定义备份导出工具。

1. SQLSERVER脚本导出导入:备份文件靠不住时,脚本才是后悔药

某次我在一台测试机上附加一个从生产库拷贝下来的 .bak 备份文件,折腾了半小时,不是版本不一致报错,就是文件路径映射有问题。后来我换了个思路:直接用 SQLSERVER 脚本导出导入,把表结构、存储过程、触发器、数据全部落地成 .sql 文件,再拿到目标库逐条执行,十分钟解决问题。这个压缩包里装的正是这样一套工具——用它给 SQL Server 数据库生成全量脚本,既能导出发到另一台机器重建,也能留着做版本化存档。适合谁用?经常要克隆数据库做测试、给客户交付预置数据、或者被备份文件版本兼容性折磨过的开发者,这套东西都能省下大把时间。

2. 技术选型与整体结构:C# 调 SMO,脚本生成不再是黑匣子

2.1 为什么选 C# + SMO,而不是纯 SQL 或 PowerShell

做 SQL Server 脚本导出,业界常见的路子有三条:SSMS 的“生成脚本”向导、PowerShell 调用 SMO、以及 C# 程序集调用 SMO。前两者都依赖人工操作或半自动脚本,很难集成到自己的工具链里。这个项目用 C# 封装 SMO(SQL Server Management Objects),等于把 SSMS 生成脚本的能力拉到了命令行里,整个流程可以无人值守跑。

SMO 是微软官方提供的管理对象库,不需要自己拼字符串去查 sys.objects 之类的系统表。项目里用到的几个关键命名空间是 Microsoft.SqlServer.Management.Smo 和 Microsoft.SqlServer.Management.Common。前者负责枚举数据库对象(表、视图、存储过程、触发器、用户定义函数),后者负责挂接服务器连接和执行命令。对一线开发来说,SMO 最大的好处是 API 语义直白:要导出表就遍历 Database.Tables,要给每个对象生成 CREATE 脚本就调 Scripter.Script(),不用跟 T-SQL 的系统视图打交道。

有人会问,为什么不直接调用 sqlcmd 加上 -i 参数跑 .sql 文件?因为导出场景的关键不在执行,而在生成。sqlcmd 只负责执行,不负责把数据库对象逆向成文本。这个项目的定位很明确:它是一台“逆向生成器”,把库里的元数据转成可移植的脚本,导入只是顺手补上的闭环。

2.2 压缩包内目录结构拆解

拿到压缩包后先看整体布局,这是一个典型的控制台解决方案加批处理脚本的组合:

SQLSERVER脚本导出导入/ ├─ src/ │ ├─ DatabaseExporter/ │ │ ├─ Program.cs # 控制台入口,解析命令行参数 │ │ ├─ SchemaScripter.cs # 结构导出核心,基于 SMO 的 Scripter │ │ ├─ DataExporter.cs # 数据导出核心,逐表读取并生成 INSERT │ │ ├─ ScriptExecutor.cs # 导入执行器,切分 GO 并逐批执行 │ │ └─ DatabaseExporter.csproj # 工程文件,引用 SMO 相关 NuGet 包 │ └─ DatabaseExporter.sln ├─ scripts/ │ ├─ export.bat # 一键导出:传服务器名、库名、输出目录 │ └─ import.bat # 一键导入:传服务器名、库名、脚本目录 ├─ output/ # 默认输出目录,schema.sql + data.sql └─ README.md

整个项目没有 UI 界面,运行入口就是 Program.cs。这种设计有个好处:既能手动执行,也能被 Jenkins 或 Windows 计划任务拉起来跑。目录结构里值得留意的是 src 和 scripts 分离——编译产物在 src 下,而批处理脚本只是薄薄一层壳,方便不懂编程的同事也能直接双击运行。

2.3 程序集依赖与运行环境

运行时依赖集中在两处:.NET Framework 4.7.2(或 .NET 6+ 都行,取决于你本机装了什么),以及 SQL Server 的 SMO 程序集。工程文件里通过 NuGet 引用了 Microsoft.SqlServer.SqlManagementObjects 这个包,它会自动拉取 SMO 的依赖项,包括 Microsoft.SqlServer.ConnectionInfo 和 Microsoft.SqlServer.Management.Sdk.Sfc。

需要注意,这个包的体积偏大,首次还原会下载几十 MB 的 DLL,公司内网如果做了 NuGet 镜像限制就可能会翻车。我一般会在 csproj 里把 SMO 相关包设为 CopyLocal=true,这样发布目录自包含,部署到没有外网的机器也不慌。

如果你不想用 NuGet,也可以直接在 Visual Studio 里添加对本地 SQL Server 安装目录下 Microsoft.SqlServer.Management.Smo.dll 的引用,但版本容易乱,不同 SQL Server 版本装的 SMO 不兼容,所以 NuGet 是更省心的选择。

3. 核心模块拆解:从对象枚举到脚本落盘的完整链路

3.1 SchemaScripter:一个 Scripter 类搞定所有对象

结构导出是这项目的核心能力。我拆开 SchemaScripter.cs,看它是怎么把库里的各种对象变成统一脚本的。核心代码大致是这样的:

using Microsoft.SqlServer.Management.Smo; using Microsoft.SqlServer.Management.Common; public class SchemaScripter { private readonly Server _server; private readonly Database _database; public SchemaScripter(string serverName, string databaseName) { // Server 对象通过连接字符串挂接 SQL Server 实例 _server = new Server(serverName); // Database 对象拿到指定库的完整元数据 _database = _server.Databases[databaseName]; } public void ExportSchemaToFile(string outputPath) { if (_database == null) { throw new Exception($"数据库 {name} 不存在,请检查库名和服务器连接"); } // Scripter 是 SMO 里的核心逆向引擎 var scripter = new Scripter(_server); scripter.Options.ScriptDrops = false; // 只生成 CREATE,不生成 DROP scripter.Options.IncludeHeaders = true; // 每个对象前面带上注释块 scripter.Options.WithDependencies = true;// 自动附带依赖对象 scripter.Options.Indexes = true; // 索引要带 scripter.Options.Triggers = true; // 触发器要带 scripter.Options.DriForeignKeys = true; // 外键约束要带 scripter.Options.ClusteredIndexes = true;// 聚集索引单独成句 var objectList = new List<SqlSmoObject>(); // 按依赖顺序收集对象:表、视图、存储过程、函数、触发器 foreach (Table table in _database.Tables) objectList.Add(table); foreach (View view in _database.Views) objectList.Add(view); foreach (StoredProcedure proc in _database.StoredProcedures) objectList.Add(proc); foreach (UserDefinedFunction func in _database.UserDefinedFunctions) objectList.Add(func); // Script 方法一次性生成所有对象的创建脚本 var scripts = scripter.Script(objectList.ToArray()); // 用 UTF-8 写入文件,避免中文注释乱码 File.WriteAllText(outputPath, string.Join(Environment.NewLine, scripts), Encoding.UTF8); } }

这段代码的信息量集中在 scripter.Options 的配置上。WithDependencies 很关键,它会自动把表之间的外键依赖理顺。假设有两张表,订单表引用客户表,如果先导出订单表后导出客户表,导入时就会因为外键指向不存在的表而失败。SMO 的依赖分析会保证对象输出的先后顺序是安全的,这是手写 T-SQL 拼系统视图很难做到的事。

另一个关键参数是 DriForeignKeys=true。默认情况下 SMO 生成的表脚本把外键作为 CREATE TABLE 语句的一部分,如果目标表在创建时引用的父表还没建好,依然会报错。所以项目里在生成完所有对象后,脚本文件里外键是独立成句的 ALTER TABLE ADD CONSTRAINT,专门放在最后执行。这样导入时先建表、后补约束,不会因为顺序问题中断。

3.2 DataExporter:把表数据变成 INSERT 语句的技巧

结构脚本搞定后,数据导出是另一座山。最直接的想法是 SELECT * FROM 表然后逐行拼接 INSERT 语句。但这里有几个坑:字段可能含单引号、可能含 NULL、可能是二进制字段。DataExporter.cs 里做了对应处理:

using System; using System.Data.SqlClient; using System.Text; public class DataExporter { private readonly string _connectionString; public DataExporter(string connectionString) { _connectionString = connectionString; } public void ExportTableData(SqlConnection conn, string schema, string tableName, StringBuilder sb) { // 用 SET NOCOUNT ON 减少执行时返回的受影响行数干扰 var sql = $"SET NOCOUNT ON; SELECT * FROM [{schema}].[{tableName}]"; using (var cmd = new SqlCommand(sql, conn)) { cmd.CommandTimeout = 3600; // 大表查询可能超时,给足一小时 using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { // 生成单行 INSERT 语句 var values = new List<string>(); for (int i = 0; i < reader.FieldCount; i++) { values.Add(FormatSqlValue(reader.GetValue(i))); } sb.AppendLine($"INSERT INTO [{schema}].[{tableName}] VALUES ({string.Join(", ", values)});"); } } } } private string FormatSqlValue(object value) { if (value == null || value == DBNull.Value) { return "NULL"; } if (value is string s) { // 关键点:把单引号翻倍,避免破坏 INSERT 语句结构 return $"'{s.Replace("'", "''")}'"; } if (value is DateTime dt) { // 统一转成 ISO 格式,避免区域设置差异 return $"'{dt.ToString("yyyy-MM-dd HH:mm:ss.fff")}'"; } if (value is byte[] bytes) { // 二进制字段导出为十六进制字面量 0x... return $"0x{Convert.ToHexString(bytes)}"; } if (value is bool b) { return b ? "1" : "0"; } if (value is decimal || value is double || value is float) { // 小数统一用 InvariantCulture 转字符串,避免小数点变成逗号 return Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture); } // 其他类型兜底,交给 SQL Server 隐式转换 return $"'{value.ToString().Replace("'", "''")}'"; } }

这段代码踩的坑很具体。第一是单引号翻倍,SQL Server 里字符串内的单引号必须写成两个单引号,否则语句会提前终止。第二是二进制字段,byte[] 不能直接转字符串,得转成 0x 开头的十六进制字面量。第三是小数序列化,用 Convert.ToString 时如果没有显式指定 CultureInfo,在德语等区域会把小数点渲染成逗号,生成的脚本拿到别的机器上一跑就是语法错误。

你可能会问,为什么不直接用 BCP 工具导成 CSV?BCP 生成的 CSV 在导入时对字段分隔符敏感,目标表结构稍有变动就会错列;而且 BCP 格式文件不好维护。这里用 INSERT 语句的好处是脚本完全自包含,目标库就算表结构有细微差异,也能在执行时报错时更直观地定位到是哪个字段不匹配。

3.3 ScriptExecutor:导入时如何拆 GO 和批量执行

结构脚本和数据脚本拼接在一起后,最常见的导入失败原因是 GO 分隔符。SQL Server Management Studio 能识别 GO 作为批处理分隔符,但 SqlCommand.ExecuteNonQuery 不认这玩意儿。ScriptExecutor.cs 的作用就是把 GO 切掉,按批执行:

using System; using System.Data.SqlClient; using System.Text.RegularExpressions; public class ScriptExecutor { private readonly SqlConnection _connection; public ScriptExecutor(string connectionString) { _connection = new SqlConnection(connectionString); } public void ExecuteScriptFile(string scriptPath) { // 以 UTF-8 读取脚本,注意要带 BOM,否则中文可能乱码 var script = File.ReadAllText(scriptPath, Encoding.UTF8); // GO 必须独占一行,前后可有空白字符 var batchPattern = @"^\s*GO\s*$"; var batches = Regex.Split(script, batchPattern, RegexOptions.Multiline); foreach (var batch in batches) { var trimmed = batch.Trim(); if (string.IsNullOrEmpty(trimmed)) continue; using (var cmd = new SqlCommand(trimmed, _connection)) { cmd.CommandTimeout = 600; // 大表 INSERT 可能跑很久 cmd.ExecuteNonQuery(); } } } }

这里有个容易被忽略的点:正则匹配 GO 必须是整行,不能是语句中间出现的 GO。SQL Server 的批处理语义里,GO 只能在独立的一行,否则会被当作普通文本。很多手写的脚本拆分工具用简单的 string.Split("GO"),遇到存储过程体里包含“GO”字符串(比如注释里写了“GO”)就会拦腰切开,导致语法错误。用 ^\sGO\s$ 加上 RegexOptions.Multiline 能保证只匹配整行。

另外,事务性也是导入时要考虑的点。常见做法是把整个脚本包在一个事务里,但一旦中间有一句失败就会全部回滚,脚本几十 MB 时这种方案的容错性太差。ScriptExecutor 选择了保守策略:每个批独立提交,失败时能定位到具体是哪一批,修完继续从失败的批重跑。如果你需要原子性,可以对这个类做一个小改造,在外面包一个 SqlTransaction,具体怎么改我在最后一部分讲。

4. 运行方式与踩坑记录:从命令行到常见问题的排查手册

4.1 编译与运行:三步跑通导出流程

拿到源码后,在 Visual Studio 里打开 DatabaseExporter.sln,先还原 NuGet 包,然后直接生成解决方案。命令行工具的参数设计得很直观:

DatabaseExporter.exe export -s 127.0.0.1 -d MyDatabase -o ./output DatabaseExporter.exe import -s 127.0.0.1 -d MyDatabase -i ./output/schema.sql DatabaseExporter.exe import -s 127.0.0.1 -d MyDatabase -i ./output/data.sql

-s 是服务器名,要写成 SQL Server 实例的地址;-d 是数据库名;-o 是输出目录,默认在程序同级的 output 文件夹。导出时会先跑 SchemaScripter 生成结构脚本,再跑 DataExporter 生成数据脚本,两个文件独立存放。导入时建议按先结构后数据的顺序执行,这个顺序和脚本生成时的设计是一致的。

如果不想手动编译,也可以直接用 scripts 目录下的批处理。export.bat 内部就是一行命令转发:

@echo off set SERVER=127.0.0.1 set DATABASE=MyDatabase set OUTPUT=%~dp0..\output DatabaseExporter.exe export -s %SERVER% -d %DATABASE% -o %OUTPUT%

%~dp0是批处理所在目录的路径,..\output就是回到项目根目录下的 output 文件夹。这样设计的好处是无论脚本放在哪层目录,输出路径都是相对稳定的。

4.2 连接安全:别把账号密码写死在代码里

源码里连接字符串默认用了 Windows 身份验证,也就是 Integrated Security=true。这在开发机上很方便,但拿到生产环境或 CI 服务器上就跑不通了,因为运行身份不一定是 SQL Server 的登录账号。改造方案是在 Program.cs 里加两个参数 -u 和 -p,支持 SQL 账号登录:

var connStr = string.IsNullOrEmpty(username) ? $"Data Source={server};Initial Catalog={database};Integrated Security=true" : $"Data Source={server};Initial Catalog={database};User ID={username};Password={password}";

加了 -u 和 -p 之后,连接串就不再硬编码了。但要注意,密码会出现在命令行参数里,用 Windows 任务计划程序跑时会被记录到历史日志中。稳妥的做法是读环境变量,或者把连接配置单独放在一个 config.json 里,程序启动时自动加载。

4.3 避坑指南:五条真实踩过的坑

坑一:导出脚本中文全部变成问号

现象:生成的 schema.sql 文件里,注释和存储过程里的中文全是问号。原因:File.WriteAllText 没有指定 UTF-8 编码,默认用了系统 ANSI 代码页(GBK 或西欧编码)。解决:所有写文件操作都显式传入 new UTF8Encoding(true),带 BOM 的 UTF-8 能被 SSMS 正确识别。

坑二:导入时外键约束报错,卡在第 100 行

现象:执行数据脚本时,插入子表数据但父表还没插入,触发外键冲突。原因:数据导出时的表遍历顺序是按系统视图返回的顺序,不是按依赖关系排的。解决:在 DataExporter 里,先对表列表做一次拓扑排序,把被引用方排在前面;或者干脆在导入数据前先 ALTER TABLE NOCHECK CONSTRAINT ALL,导完再 CHECK CONSTRAINT ALL。后一种做法更粗暴但效率更高。

坑三:大表导出几百 MB 的 INSERT 脚本,内存暴涨

现象:程序导出 5GB 数据的表,内存飙到 3GB,直接 OOM。原因:每读一行就往 StringBuilder 里追加,到最后一次性写入文件,所有内容全在内存里。解决:改成 StreamWriter 边读边写,每读取 5000 行就 Flush 一次,磁盘 IO 换内存占用。

坑四:数据库里有 CLR 自定义类型时,SMO 导出失败

现象:用 Scripter.Script 生成用户定义表类型或带 CLR 类型的对象时直接抛异常。原因:SMO 对 CLR 程序集内外部的类型识别不全,需要额外引用程序集脚本。解决:在 SchemaScripter 里显式枚举 _database.Assemblies 和 _database.UserDefinedTypes,优先导出,或者单独处理这两种对象,不放在通用对象列表里。

坑五:导入到 SQL Server 2012 时报语法错误,但脚本在 2019 上没问题

现象:同一份脚本,在 SQL Server 2012 上执行报错,在 2019 上正常。原因:SMO 默认按当前服务器版本生成语法,可能用了高版本才有的特性(比如 STRING_AGG、TRIM 函数)。解决:在 Scripter 初始化时指定目标版本,scripter.Options.TargetServerVersion = SqlServerVersion.Version120,让生成的语法兼容低版本。

5. 二次开发:从参数到定时任务,把这套工具嵌进你的工作流

接手这类工具源码,第一件事永远是看它能不能从“手动跑”变成“自动跑”。目前的命令行参数已经支持 -s、-d、-o 三个基础项,但要落地到真实环境还差两个功能:差异化导出和时间调度。

差异化导出指的是只导出某几张表或者某个 Schema,比如只导出 orders 表的数据,或者只导出 dbo 架构下的存储过程。改造入口在 Program.cs,导出时在 objectList 的收集逻辑里加一个 -t 参数过滤:

if (!string.IsNullOrEmpty(tableFilter) && table.Name != tableFilter) continue;

参数解析可以用现成的 CommandLineParser 库,也可以用最原始的分割字符串方式。对控制台工具来说,我倾向于后者——不引入多余依赖,参数就那四五个,手写解析足够。

时间调度方面,Windows 下最简单的方案是计划任务。但计划任务有一个坑:默认只在用户登录时运行,而且运行身份如果是普通用户,可能没有权限访问 SQL Server 的 Windows 身份验证。我的习惯是创建一个专门的服务账号给这个任务用,在 SQL Server 里给它建一个对应的登录名,再配合前面说的 -u -p 参数绕开身份验证。

另一条改造思路是把工具做成动态库而不是独立的 EXE。这样你可以写一个 Web API 包装它,内部系统里点一个按钮就能远程触发某台机器的数据库脚本导出。改造时把 Program.cs 里的逻辑抽到公共方法 ExportDatabase(ExportOptions options),外部调用只需传参数对象,不依赖控制台的输入输出。

整个项目最值得学的地方其实不在 SMO 的封装,而在于把“数据库脚本导出导入”这个痛点拆成了结构、数据、执行三个独立模块。结构脚本负责让库在目标机器上重生,数据脚本负责把业务记录搬过去,执行器负责把两段文本变成真实数据库动作。当你下次遇到类似问题,不管目标数据库是 MySQL 还是 PostgreSQL,这套分层思路都能直接复用——无非把 SMO 换成对应的驱动,把 INSERT 语法换成对应方言。

从那以后,我每次做数据库迁移,都强制走一遍“先导结构、再导数据、执行器收尾”的流程,再也不赌备份文件在目标环境一定能还原。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询