资讯详情 SQL Server脚本导出导入工具:C# SMO实现跨环境数据库迁移
📅 2026/10/9 14:06:27
简介C#实现的SQLSERVER脚本导出导入工具面向需要在SQL Server数据库间迁移结构或数据的开发人员与运维人员。其核心功能与SQL Server 2014 Management Studio的“生成脚本”操作相似可代替手工逐表操作提升数据库备份与还原脚本的产出效率。包内共66个文件约2.16MB包含10个C#源码文件、9个动态库DLL、8个SQL脚本以及可直接运行的exe程序还附带配置、资源文件和项目工程文件结构清晰便于二次开发。目前已有1488人学习浏览适合有一定C#和SQL Server基础、希望实现自动化脚本导出导入或研究SSMS生成脚本原理的读者。通过学习可掌握连接SQL Server、调用SMO或脚本化对象生成SQL语句的完整思路并可直接编译运行按需扩展为更灵活的数据库脚本工具。1. 先拆清楚这个 SQLSERVER 脚本导出导入资源到底能干什么如果你也是靠数据库脚本导出导入吃饭的人应该遇到过这样的事测试环境里验证好的库要原样搬到生产或客户机器上直接附 MDF 文件不现实手点 SSMS 的“生成脚本”又慢又容易漏对象。这个 SQLSERVER 脚本导出导入 zip 里的核心是一个用 C# 写成的数据库生成脚本工具它能把库里的表、视图、存储过程、函数、约束和数据一起导成 .sql 脚本再在目标实例上执行一遍十分钟复刻出一个库。它特别适合做跨环境同步、给项目做版本化存档或者在上线前给自己留一颗后悔药。先说清楚边界它是逻辑迁移工具不是完整备份方案日常本着“脚本能跑通、数据能对上”的目的来用。2. 脚本导出的原理C# 调用 SMO 生成“能回放”的 CREATE 脚本2.1 为什么要自己封装不直接用 SSMS 的“生成脚本”我知道很多人第一反应是SSMS 自带“生成脚本”向导右键数据库就有为什么还要自己写工具这个问题的答案很现实向导适合“今天手工导一次”不适合“每周自动导一次”更不适合丢给其他同事当命令跑。SSMS 的向导把结构脚本和数据脚本分开数据量一大还要切成行数分批这些选项都藏在界面深处没法用命令行参数去控制。你连续导五个库每个库要点十几下下一步中间选错一个选项生成的脚本到目标库就会翻车。更麻烦的是向导生成的脚本默认带 USE[库名]和一堆 ANSI_NULLS、QUOTED_IDENTIFIER 开关有时候还会把扩展属性和权限带出来目标库里这些信息不一样执行时就会出现莫名其妙的权限错误。自己用 C# 封装的好处在于SMOSQL Server Management Objects把整库的对象模型暴露成了普通对象可以遍历、筛选、拼脚本还可以通过 ScriptingOptions 精确控制生成选项。封装一次之后导出动作就变成一行命令参数写进配置文件谁都能用出问题也容易复现。2.2 SMO 生成库结构的最小可用代码C# 工程里先引入 SMO 程序集最常见的方式是在 NuGet 里装Microsoft.SqlServer.SqlManagementObjects然后就可以通过 Server 对象连上实例。下面的代码是把一个库里所有用户表的 CREATE 脚本拼到一个文件里的最小实现using Microsoft.SqlServer.Management.Smo; using Microsoft.SqlServer.Management.Common; using System.Text; using System.Collections.Specialized; public static void DumpTableSchema(string serverName, string dbName, string outputPath) { Server server new Server(serverName); server.ConnectionContext.LoginSecure true; // 使用 Windows 身份认证 Database db server.Databases[dbName]; if (db null) { Console.WriteLine($数据库 {dbName} 不存在); return; } ScriptingOptions so new ScriptingOptions { ScriptForCreate true, // 只生成 CREATE ScriptForAlter false, // 不做 ALTER ScriptDrops false, // 不生成 DROP 语句 Indexes true, // 包含索引 Triggers true, // 包含触发器 DriPrimaryKeys true, // 主键约束 DriForeignKeys true, // 外键约束 DriDefaults true, // 默认值约束 SchemaQualify true, // 生成 dbo. 前缀 EnforceScriptingOptions true }; StringBuilder sb new StringBuilder(); foreach (Table table in db.Tables) { if (table.IsSystemObject) continue; // 跳过系统表 StringCollection scripts table.Script(so); foreach (string script in scripts) { sb.AppendLine(script); sb.AppendLine(GO); } } File.WriteAllText(outputPath, sb.ToString(), new UTF8Encoding(true)); }这段代码的逻辑很直白先连服务器再取目标库然后遍历db.Tables集合。IsSystemObject这个判断一定要加否则系统表也会被导出来导入的时候会产生一堆无权限问题。StringCollection里的每一条字符串代表一段完整的 T-SQL 语句后面补一个GO是为了让导入端可以按批处理执行。关键的参数都在ScriptingOptions里。我把DriForeignKeys设成 true是因为大部分业务库的表之间都有外键关系脚本里保留外键能避免数据插入顺序被打乱时出现孤儿数据。但这个选项在数据导入阶段也可能是坑第 4 章会专门说。EnforceScriptingOptions true意味着脚本生成严格遵守这些开关不会因为你表上有某些默认设置就擅自跳过约束。2.3 表数据怎么变成 INSERT 语句SMO 不管数据要自己读表SMO 只管“对象定义”不管“数据行”。要把数据也导出成脚本必须自己查表、拼 INSERT。常见做法是用SqlConnection把整表读成DataTable遍历每一行生成 INSERT 语句using System.Data; using Microsoft.Data.SqlClient; public static string RowToInsert(DataRow row, string tableName, Liststring columns) { Liststring values new Liststring(); foreach (string col in columns) { object v row[col]; if (v DBNull.Value) { values.Add(NULL); } else if (v is string || v is DateTime || v is Guid) { string safe v.ToString().Replace(, ); // 转义单引号 values.Add( safe ); } else { values.Add(v.ToString()); // 数字和布尔直接输出 } } string colList string.Join(,, columns.Select(c [ c ])); return $INSERT INTO [{tableName}] ({colList}) VALUES ({string.Join(,, values)});; }这里把值分成两类处理字符串、日期、GUID 这类需要引号的类型做一次单引号转义后包起来数字和布尔直接输出。单引号转义是必须的业务数据里出现OReilly这种情况不转义导入端就直接报语法错误。日期类型我统一走ToString()它会按当前线程的文化格式输出这其实是一个隐患更严谨的做法是把日期转成 ISO 8601 格式再包进单引号。这个方案只适合单表数据量小的情况。我的经验阈值是单表 5 万行以内、单行不含大字段直接用 INSERT 脚本没问题超过这个量脚本会非常巨大解析和导入都慢而且nvarchar(max)字段会把单条 INSERT 撑到几十 KB甚至触发字符串截断。遇到大表优先换 BCP 导出成二进制文件或者按主键范围分批导出成多个小脚本。后面第 4 章会讲具体坑。2.4 生成脚本的关键参数表平时封装这类工具我建议把 ScriptingOptions 的参数单独抽到一个配置类里不要写死在代码中。下面这张表是实际调参时最常碰到的几个选项它们的默认值和行为直接影响脚本能不能在目标库执行成功。参数作用建议值ScriptForCreate只生成 CREATE不生成 ALTERtrueIncludeIfNotExists生成 IF NOT EXISTS 保护新库导入设 false重复导入设 trueSchemaQualify对象名带 dbo. 前缀true避免默认 schema 混乱DriForeignKeys生成外键约束结构脚本 true数据脚本阶段建议 falseIndexes生成普通索引true漏掉索引上线后会被打爆Triggers生成触发器true业务触发器也是逻辑的一部分NoCollation是否跳过排序规则false保留库排序规则更安全IncludeIfNotExists是个双刃剑。如果目标库可能已经存在部分对象开启它可以让脚本重复执行不报错但如果结构有变更它又会把旧对象直接跳过导致新代码去引用不存在的新列。我的习惯是移动到全新库时关闭日常迭代同步时打开。SchemaQualify打开后生成的语句全是dbo.TableName这种形式。有人在导出时为了“界面清爽”把它关了结果目标库默认 schema 不是 dbo跑出来的对象全掉进guest或当前用户 schema 下后面引用全是坑。这个选项保持 true 就行。3. 把 zip 里的工具跑起来完成一次库导出到导入的全过程3.1 准备连接信息、输出目录与运行方式这套工具最常见的运行形态是 C# 控制台程序编译后直接在命令行执行。连接信息不写死在代码里而是放在一个appsettings.json或config.ini里这样换库、换环境不用重新编译。下面是一个典型的配置读取逻辑// config.ini // Serverlocalhost;DatabaseDemoDB;Outputschema.sql;UseWindowsAuthtrue public static ConnectionInfo LoadConfig(string file) { var info new ConnectionInfo(); foreach (string line in File.ReadAllLines(file)) { string[] parts line.Split(); switch (parts[0]) { case Server: info.Server parts[1]; break; case Database: info.Database parts[1]; break; case Output: info.Output parts[1]; break; } } return info; }配置文件的好处是不熟悉代码的同事只要改 Server 和 Database 两个值就能完成连接切换。我一般会在命令行入口先打印当前配置文件的内容防止跑完才发现连错了库。控制台程序还有一个天然优势错误信息直接打到屏幕上导出失败时看一眼堆栈就能定位是连接问题还是权限问题。3.2 导出端结构脚本和数据脚本分文件输出一个可用的工具会把结构脚本和数据脚本分开而不是混在一个文件里。因为导入时结构必须先建数据才能进混在一起会导致 SQL 执行顺序不可控。下面的代码演示了同时导出两个文件public static void ExportDatabase(string server, string dbName, string schemaFile, string dataFile) { // 1. 导出结构 DumpTableSchema(server, dbName, schemaFile); // 2. 导出数据 using (SqlConnection conn new SqlConnection($Server{server};Database{dbName};Trusted_ConnectionTrue;)) { conn.Open(); StringBuilder dataSb new StringBuilder(); DataTable schemaTable conn.GetSchema(Tables); foreach (DataRow row in schemaTable.Rows) { string tableName row[TABLE_NAME].ToString(); if (tableName.StartsWith(sys) || tableName.StartsWith(MS)) continue; using (SqlCommand cmd new SqlCommand($SELECT * FROM [{tableName}], conn)) using (SqlDataAdapter da new SqlDataAdapter(cmd)) { DataTable dt new DataTable(); da.Fill(dt); Liststring cols dt.Columns.CastDataColumn().Select(c c.ColumnName).ToList(); foreach (DataRow dataRow in dt.Rows) { dataSb.AppendLine(RowToInsert(dataRow, tableName, cols)); } } } File.WriteAllText(dataFile, dataSb.ToString(), new UTF8Encoding(true)); } }这里用GetSchema(Tables)拿表的元数据比自己手动写sys.tables查询要省事。但注意它拿到的表名不带 schema如果库里有多个 schema 重名表这里会出错。更稳妥的方式是直接查sys.tables把 schema 也带出来SELECT s.name AS SchemaName, t.name AS TableName FROM sys.tables t JOIN sys.schemas s ON t.schema_id s.schema_id WHERE t.is_ms_shipped 0 ORDER BY s.name, t.name导出顺序也有讲究。父表要先导子表后导这样导入时外键能直接校验通过但结构脚本里如果带了外键约束导入数据时父表数据还没进去子表 INSERT 会被外键拦住。所以工具里常见做法是结构脚本先不带外键导出数据脚本按引用关系排好序最后再单独导一份外键脚本补上约束。参数上的度是小库无所谓脚本怎么排都能跑库表超过 50 张之后排序问题就会被放大这时候数据脚本里建议每张表固定一个导出批次。3.3 导入端拆分 GO 批处理并逐批执行导出的脚本里每一段语句后面都跟了GO但GO不是 T-SQL 关键字而是 SSMS、sqlcmd 这些客户端工具的批处理分隔符。用程序执行脚本时必须自己把GO当成切分符把一段脚本拆成多个批逐批交给SqlCommand执行public static void ExecuteScript(SqlConnection conn, string script) { // 按 GO 拆分批处理注意正则要匹配行首行尾避免拆到注释里 string[] batches Regex.Split(script, ^\s*GO\s*$, RegexOptions.Multiline | RegexOptions.IgnoreCase); using (SqlCommand cmd conn.CreateCommand()) { foreach (string batch in batches) { if (string.IsNullOrWhiteSpace(batch)) continue; cmd.CommandText batch; cmd.ExecuteNonQuery(); } } }这个拆分的正则是^\s*GO\s*$配合Multiline选项能确保只匹配独立成行的GO不会把某些字符串里包含的 GO 误切。但如果存储过程脚本里有注释掉的 GO或者 SQL 里真的有一行内容是单词 GO这种简单正则就会翻车。更稳妥的做法是逐行扫描遇到以 GO 开头且是纯行的内容就认为是一个批的结束。导入端执行时还有两个细节。第一每次执行前把cmd.CommandTimeout调大默认 30 秒在导入大表数据时不够用我会直接设成 600 秒。第二如果脚本包含多个库的对象USE语句切库后会改变连接上下文用同一个SqlConnection执行没有副作用但要注意连接串初始库和脚本里的库必须一致否则跨库对象名会被解析到错误的位置。3.4 执行到目标库顺序、策略与验证完整导入的步骤在工具里一般会被封装成一个RunImport()方法顺序固定先跑结构脚本不含外键再跑数据脚本最后应用索引和外键脚本。常见做法是先删掉目标库里的旧对象防止残留表导致 CREATE 失败更保险的方案是给每个脚本头部加一段IF OBJECT_ID(xx) IS NULL判断。导入完成后必须做三件事查对象数、查行数、抽查典型数据。对象数可以对比源库和目标库的sys.tables数量行数逐个表COUNT(*)可能太慢选几张关键业务表抽查就行。有同事问过能不能把这个工具当备份用我的观点是脚本导出适合跨环境迁移和版本存档但真正要恢复意外删除的数据还是得靠数据库的完整备份和日志备份只有结构加业务表数据是不够的。4. 避坑手册脚本导入最常见的 4 个翻车点与排查记录4.1 乱码中文数据变成一连串问号现象导入完成后查表发现所有中文字段都变成了???但英文和数字正常。原因导出端写文件时用了不带 BOM 的 UTF-8 编码而目标端的读取工具默认按 ANSI/GBK 解析中文全部解码失败。另一个常见场景是目标机器没有安装中文字符集脚本文件里的 UTF-8 中文被当成多字节字符直接丢弃。解决导出端统一用带 BOM 的 UTF-8 写文件也就是代码里的new UTF8Encoding(true)。导入端如果用的是我前面写的File.ReadAllText也要显式声明Encoding.UTF8。我做过的项目里还出现过另一种情况脚本文件是好的但执行导入的进程环境区域设置是英文导致非 Unicode 字符串在控制台打印时变成问号文件里其实没坏。遇到这种情况先打开 .sql 文件本身检查再谈编码修复。4.2 自增列导入失败IDENTITY_INSERT 没开现象数据脚本执行到一半直接报错“仅当使用了列列表并且 IDENTITY_INSERT 为 ON 时才能为表中的标识列指定显式值”。原因业务表的自增主键在源库已经是某个值导入脚本把主键值原样带了过来但目标表默认不允许对自增列插入显式值。这在逻辑上其实是好事它能防止误插但在数据迁移场景下就成了障碍。解决导出时把自增列当成普通列处理导入时在表级别打开 IDENTITY_INSERT。工具里可以在生成数据脚本时给每张表前加一句话SET IDENTITY_INSERT dbo.Customers ON; INSERT INTO dbo.Customers (CustomerId, Name, Email) VALUES (101, 张三, zhangsanexample.com); SET IDENTITY_INSERT dbo.Customers OFF;这里有个细节IDENTITY_INSERT只能同时开启一张表所以必须在每张表的数据脚本执行完、还没切到下一张表时立刻关闭。虽然它本身不需要在事务里执行但脚本一旦中途出错状态可能卡在 ON 上建议把整段导入包进事务出错回滚后再统一复位。4.3 外键约束让导入顺序变成一个黑匣子现象数据脚本执行时报外键冲突“INSERT 语句与 FOREIGN KEY 约束冲突”插入的是子表记录但父表对应记录还没插进来。原因脚本是按脚本生成的顺序执行的这个顺序通常来自表名排序或查询返回顺序和业务依赖关系没有必然关系。父表数据晚于子表进入时子表的 INSERT 就会被外键拦住。解决最直接的做法是导入数据前先禁用外键约束导入完成后重新启用并检查。启用检查时一旦发现有中断的外键会立刻报错正好暴露问题数据-- 导入前 EXEC sp_msforeachtable ALTER TABLE ? NOCHECK CONSTRAINT ALL; -- 导入后 EXEC sp_msforeachtable ALTER TABLE ? WITH CHECK CHECK CONSTRAINT ALL;sp_msforeachtable这些系统存储过程在目标库里不一定允许执行备选方案是游标遍历sys.foreign_keys生成 ALTER 语句。我还见过一个更省事的方法结构脚本里根本不生成外键等数据全部导入之后再手动执行一份单独导出的外键脚本。这样顺序问题天然消失只是要记得把外键脚本纳入到发布流程里否则上线后丢约束数据质量就没人兜底了。4.4 大字段与超大脚本一张表把导入进程拖死现象一张数据量不大的表因为有个nvarchar(max)字段导出的 INSERT 语句单条长度巨大导入端报错“字符串或二进制数据将被截断”或者脚本文件超过 200MBSSMS 打开都卡住。原因正则按 GO 拆分时一条 INSERT 语句是完整的批长度可能超过 SQL Server 对单条命令的限制另外脚本文件本身太大时File.ReadAllText一次性读入内存内存瞬间被吃满解析也慢。解决检查max类型字段影响有多大如果只是少数字段可以把这些列从 INSERT 中移除改用 UPDATE 单独补写如果整张表都是大文本切到 BCP 导出文件再导入。BCP 的导入速度也远快于逐条 INSERT常见的做法是数据脚本里写BULK INSERTBULK INSERT dbo.Articles FROM C:\data\articles.bcp WITH (FORMAT NATIVE, BATCHSIZE 5000);BATCHSIZE这个参数值得单独说。它控制每多少行提交一次事务设得太小导入慢设得太大中途出错会回滚大量数据。我先试 5000再根据表大小调整到 2 万左右。BCP 文件路径要确保目标机器能访问到否则连日志都看不出来原因。5. 进阶给工具加上命令行参数改成可定时调度的流水线5.1 把连接串和导出选项变成参数现在的工具如果要每个环境各跑一次每次改配置文件容易漏改错改。更稳的方式是让 Main 函数直接接收命令行参数导出和导入动作通过参数区分。以我常用的写法为例static int Main(string[] args) { if (args.Length 3) { Console.WriteLine(用法: SqlScriptCli export|import server database outputdir); return 1; } string action args[0]; string server args[1]; string database args[2]; string outputDir args[3]; if (action export) { ExportDatabase(server, database, ${outputDir}\\schema.sql, ${outputDir}\\data.sql); } else if (action import) { string schema File.ReadAllText(${outputDir}\\schema.sql, Encoding.UTF8); string data File.ReadAllText(${outputDir}\\data.sql, Encoding.UTF8); using (SqlConnection conn new SqlConnection($Server{server};Database{database};Trusted_ConnectionTrue;)) { conn.Open(); ExecuteScript(conn, schema); ExecuteScript(conn, data); } } return 0; }参数顺序固定之后脚本之间可以互相调用问题就好排查了。注意导出和导入用了同一套参数顺序减少记忆负担。5.2 批处理包装成定时任务Windows 上我会写一个 .bat 文件把命令串起来推送到 SQL 作业或计划任务里定时执行echo off set SERVER127.0.0.1 set DBDemoDB set OUTD:\scripts\release SqlScriptCli.exe export %SERVER% %DB% %OUT% if %errorlevel% neq 0 ( echo export failed exit /b 1 ) SqlScriptCli.exe import %SERVER% %DB% %OUT%批处理的好处是执行顺序显式可控每一步失败都能通过%errorlevel%判断。Windows 计划任务里直接调用这个 bat再加上一段日志输出整个导出导入过程就不再依赖人工操作了。5.3 把脚本流水线接到发布流程里再往前走一步可以把工具接进 CI 流程每次代码合并后自动导出一份结构脚本前端在测试环境里重建库后端只跑增量 SQL。这样数据库变更会和代码变更一样进入版本管理不再出现“测试环境没问题生产环境一跑就缺列”的情况。我印象最深的一次翻车就是导完脚本忘了检查导入端的编码结果整张表的中文全部变成问号客户那边看到业务数据全毁加班到凌晨才用备份恢复。从那以后每次导出完我都会先写一句编码UTF8 BOM 目标环境内存检查到自己的核对清单先拿一个临时库跑一遍脚本再放行。这个习惯帮我挡住了不少次低级失误希望也能帮到你。本文还有配套的精品资源点击获取