ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SQL Server 数据库迁移:脚本导出导入与版本编码排错实战

SQL Server 数据库迁移:脚本导出导入与版本编码排错实战

简介:面向需要批量处理 SQL Server 数据库脚本的开发者与运维人员,这份用 C# 编写的导出导入工具包提供接近 SQL Server 2014 Management Studio「生成脚本」功能的实现思路,可用于数据库结构备份、表与视图等对象脚本生成、跨环境迁移前的脚本整理,也适合作为数据库生成脚本与备份类小工具的参考工程。压缩包共 66 个文件、约 2.16MB,包含 C# 源码、项目解决方案与配置文件,以及若干 SQL 脚本、编译后的程序集和可执行文件,另有资源文件、缓存与调试产物,便于直接运行或二次开发。已有约 1490 人学习下载。读者可从中了解窗体界面与数据库访问辅助类的相关组织方式,参考脚本导出导入的流程设计、连接配置、参数传递与异常处理思路,并借助现成工程快速搭建自己的数据库脚本生成工具。

1. 从一台只装了 Express 引擎的机器说起

上周把一套老系统的数据往新机器搬,交付物就是一个 SQLSERVER脚本导出导入.zip,本以为解压、跑脚本、结束,结果第一遍就失败:源库是 SQL Server 2016,目标机上装的是 SQL Server 2022 Express,导出时没改「服务器上的脚本版本」,导进去直接报语法错误;带中文的列全变成了问号。数据库生成脚本、导出导入这件事,看着是右键菜单里点两下,真正卡住人的从来是版本、编码、权限这些环境细节。这篇记录适合要把一整个库搬到另一个实例、或者要在 C# 程序里批量跑 SQL Server 脚本的人,照着走能少返工几次。

2. 版本对照:SQL Server 2019/2022、SSMS 20 与 sqlcmd 18 怎么配

装之前先把四件东西的角色分清楚,后面所有问题几乎都能归到这四件里:引擎负责存数据和执行脚本,SSMS 负责生成脚本和查看问题,sqlcmd 负责批量跑脚本,驱动负责让 C# 这类程序连上去。很多人以为装个 SSMS 就齐了,结果换台机器只有引擎没有客户端工具,脚本生成不了,或者只有旧版 sqlcmd,编码参数不认。

2.1 一张表决定装哪个版本

版本搭配的核心矛盾只有一个方向:脚本可以往同版本或更高版本导,不能往低版本导。生成脚本向导会把「目标服务器版本」的语法写死在脚本里,2022 生成的脚本里可能有 2019 不认的写法,拿到 2016 上更是直接崩。

组件建议版本用途主要限制
SQL Server 引擎2019 或 2022 的 Developer / Express存放源库、回放脚本高版本脚本不能导入低版本引擎
SSMS19.x 或 20.x生成脚本向导、对象浏览版本必须不低于引擎,否则连不上或不显示新对象
sqlcmdODBC 版 18(推荐)或旧版 17命令行批量执行脚本18 默认开启加密连接
连接驱动ODBC Driver 18 for SQL Server、Microsoft.Data.SqlClient 5.x工具与 C# 程序连接新版默认Encrypt=True,自签证书会连不上

Engine 的版本号和兼容级别是两回事。SQL Server 2022 可以建一个compatibility_level = 130的库来跑老语法,但生成脚本时向导仍然按引擎版本选语法,所以目标机是 2016 的话,TargetServerVersion必须手动降下来。这一点在向导里藏得比较深,在「高级」里,默认值是你当前的引擎版本。

SSMS 20 之后安装包不再附带引擎,是纯客户端,装完不会给你一个实例。有些人装完发现「服务里没有 MSSQLSERVER」,就是这一步理解错了,还得单独回去装引擎。我一般先装引擎、再装 SSMS,最后补 sqlcmd 和 ODBC 驱动,顺序反了会出现 SSMS 装了但连不上本机实例的尴尬。

2.2 安装顺序与三条验证命令

装完不要急着导数据,先跑三条命令确认环境是通的。顺序是:确认服务在跑、确认能连上、确认版本与兼容级别符合预期。

# 1. 确认本机有哪些 SQL Server 服务,状态是不是 Running Get-Service | Where-Object { $_.Name -like 'MSSQL*' } | Select-Object Name, DisplayName, Status # 2. 用 Windows 身份连一次,看引擎版本和补丁级别 sqlcmd -S localhost -E -Q "SELECT @@VERSION AS ver, SERVERPROPERTY('ProductLevel') AS lvl"

-S localhost指定实例,命名实例要写成localhost\SQLEXPRESS这种形式;-E表示用当前 Windows 账号做信任连接,不走账号密码;-Q是执行完这条语句就退出,适合一次性检查。如果这一步报「找不到网络路径」,问题在服务没起或者实例名写错,跟脚本没关系。

-- 3. 看各库的兼容级别,130=2016,140=2017,150=2019,160=2022 SELECT name, compatibility_level, collation_name FROM sys.databases WHERE database_id > 4;

兼容级别决定了STRING_AGG、TRIM、APPROX_COUNT_DISTINCT这些语法能不能用,也影响查询优化器的行为。回放脚本前把这一列和目标库对齐,能省掉一大半「语法错误」的排查时间。排序规则(collation)也别忽略,源库是Chinese_PRC_CI_AS、目标库是默认的SQL_Latin1_General_CP1_CI_AS时,跨库 JOIN 会直接报排序规则冲突,这个坑后面还会提。

确认完这三条,再装 sqlcmd 的 ODBC 版本。装完在命令行敲一次sqlcmd -?,能打出版本号就说明 PATH 生效了;如果提示「不是内部或外部命令」,把C:\Program Files\Microsoft SQL Server\Client SDK\ODBC\180\Tools\Binn加进环境变量,重开终端。这里有个血泪经验:改完环境变量一定要新开窗口,旧窗口的 PATH 是启动时快照,改完没反应是常态。

3. 导出脚本:生成脚本向导与 SMO 批量导出的参数取舍

导出这一步决定了后面导入顺不顺。向导适合一次性、看得到的场景,SMO 适合要重复跑、要写进流水线的场景。两条路的参数含义是一样的,只是向导用勾选框,SMO 用属性。

3.1 生成脚本向导里必须改的五个选项

右键数据库 → 任务 → 生成脚本,前面几步都是「下一步」,真正的功夫全在「设置脚本编写选项」里的「高级」按钮。默认值是按「仅限架构」和当前引擎版本来的,直接点完成,导出的东西往往不是你想要的。

第一个必改项是「编写脚本的数据类型」,三个值:仅限架构、仅限数据、架构和数据。迁移整库选第三个;只想灌数据到已有表选第二个。选「仅限数据」时要注意,如果表上有 IDENTITY 列,向导生成的 INSERT 会带SET IDENTITY_INSERT [dbo].[T] ON,否则自增值对不上。

第二个是「服务器上的脚本版本」,把它降到目标引擎版本。这一步在源库版本高于目标库时是强制的,否则脚本里会出现目标不认识的语法。

第三个是「编写 DROP 和 CREATE」,目标是空库时选 FALSE。选 TRUE 会在每个对象前加IF EXISTS ... DROP,回放到已有数据的库上会静默删表,这个失误代价很大。

第四个是「要编写的对象」,默认全选包含系统对象之外的所有东西,但统计信息(statistics)建议关掉,它会让脚本体积膨胀好几倍,回放时还会因为索引还没建完而失败。

第五个是输出方式:单个文件、每个对象一个文件、还是输出到新建查询窗口。目标是流水线就选「每个对象一个文件」,配合文件名的编号前缀排序;手工排查就选单文件,搜索方便。

-- 向导生成的文件开头大致长这样,这三行决定了回放行为 USE [SourceDb] GO SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE TABLE [dbo].[Order]( [Id] [int] IDENTITY(1,1) NOT NULL, [Remark] [nvarchar](200) NULL, CONSTRAINT [PK_Order] PRIMARY KEY CLUSTERED ([Id] ASC) ) ON [PRIMARY] GO

USE [SourceDb]是硬编码的库名,回放到别的库必须先替换,这一点在下一章展开。SET ANSI_NULLS ON和SET QUOTED_IDENTIFIER ON是创建对象时的会话设置,会被记录在对象元数据里,回放时如果客户端没开这两个选项,索引视图和计算列会创建失败。所以后面用 sqlcmd 跑脚本时要加-I,把 QUOTED_IDENTIFIER 打开。

[nvarchar](200)这种带 N 前缀的类型是 Unicode 的,但如果目标库排序规则不是 Unicode 兼容的,插入中文仍然会丢字符。稳妥做法是源库和目标库的 collation 保持一致,或者在导出前确认所有中文列都是nvarchar而不是varchar。

3.2 用 SMO 批量导出,把参数写进脚本

要反复跑、要进 CI、要按 schema 分批导,向导就不够用了。PowerShell 加载 SqlServer 模块后调 SMO,参数和向导是同一套,只是换成属性名。

Import-Module SqlServer -ErrorAction Stop $serverName = "localhost" $dbName = "SourceDb" $outFile = "D:\dbexport\SourceDb_all.sql" $srv = New-Object Microsoft.SqlServer.Management.Smo.Server $serverName $db = $srv.Databases[$dbName] $opt = New-Object Microsoft.SqlServer.Management.Smo.ScriptingOptions $opt.ScriptSchema = $true # 导出架构 $opt.ScriptData = $true # 同时导出数据 $opt.ScriptDrops = $false # 不生成 DROP 语句 $opt.WithDependencies = $false # 依赖自己排,别让 SMO 猜 $opt.Indexes = $true $opt.Triggers = $true $opt.ToFileOnly = $true $opt.FileName = $outFile $opt.AppendToFile = $false # 目标引擎版本:130=2016, 140=2017, 150=2019, 160=2022 $opt.TargetServerVersion = [Microsoft.SqlServer.Management.Smo.SqlServerVersion]::Version130 $scr = New-Object Microsoft.SqlServer.Management.Smo.Scripter $srv $scr.Options = $opt # 排除系统对象,按 schema 过滤时改这里的 Where 条件 $scr.Script($db.Tables | Where-Object { -not $_.IsSystemObject })

TargetServerVersion对应向导里的「服务器上的脚本版本」,是这段代码里最关键的一行。WithDependencies设成 true 会让 SMO 自己算依赖顺序,听起来省事,实际经常把表排在它引用的自定义类型前面,我一般关掉,按「类型 → 表 → 数据 → 索引/约束 → 视图 → 存储过程」自己排。

ToFileOnly配合FileName是输出到文件,AppendToFile = $false保证第一次写入覆盖而不是追加,否则跑两遍会得到两份重复的 CREATE TABLE。数据量很大时 SMO 生成脚本会比较慢,因为它是逐行拼 INSERT;几百万行以上的表更推荐 bcp 单独导数据文件。

# 大表走 bcp,字符模式 + UTF-8 代码页,避免中文乱码 bcp "SourceDb.dbo.[Order]" out "D:\dbexport\Order.dat" -S localhost -T -c -C 65001 -t "|" -r "\n"

-c是字符模式(对应-n原生模式,跨版本更安全但不可读);-T是信任连接;-C 65001指定代码页为 UTF-8;-t和-r分别是列分隔符和行分隔符,导入时必须用完全一样的参数,否则字段会错位。bcp 的好处是快,坏处是它只搬数据不搬约束,表结构还得靠脚本。

4. 导入回放:换实例、换库名后按依赖顺序把脚本跑干净

导出只是拿到材料,回放才是真正决定成败的一步。脚本从源库带过来一堆硬编码,直接跑十有八九在第一条USE上就失败。

4.1 建库、建登录:脚本开头的三处必须先改

第一处是USE [SourceDb],回放到新库要全部替换成目标库名。第二处是文件路径,如果脚本里带了CREATE DATABASE ... ON (FILENAME = N'D:\data\SourceDb.mdf'),那条路径在你机器上多半不存在。第三处是登录和用户,脚本里如果只有CREATE USER而没有CREATE LOGIN,用户会变成没有登录名的孤儿用户。

-- 目标实例上先手工建库和登录,别指望脚本自己搞定 CREATE DATABASE [TargetDb]; GO CREATE LOGIN [appuser] WITH PASSWORD = N'换成你的密码', CHECK_POLICY = ON; GO USE [TargetDb]; GO CREATE USER [appuser] FOR LOGIN [appuser]; GO ALTER ROLE db_datareader ADD MEMBER [appuser]; ALTER ROLE db_datawriter ADD MEMBER [appuser]; GO

CHECK_POLICY = ON会强制密码复杂度,用在正式环境;本地验证图省事可以设 OFF。CREATE USER ... FOR LOGIN是按登录名映射,如果是从别的实例还原过来的库,用户已经存在但 SID 对不上,这时用ALTER USER [appuser] WITH LOGIN = [appuser]重新绑定,比删了重建安全。

批量替换库名用 PowerShell 一条命令就够,注意先备份原文件再替换,正则写错会把表名里的同名子串一起换掉。

# 只替换整词 USE [SourceDb],避免误伤表名里的 SourceDb Get-ChildItem D:\dbexport\*.sql | ForEach-Object { (Get-Content $_.FullName -Raw -Encoding UTF8) -replace 'USE \[SourceDb\]', 'USE [TargetDb]' | Set-Content $_.FullName -Encoding UTF8 -NoNewline }

-Raw把整个文件读成一个字符串,不加的话按行处理,跨行的替换会失效;-Encoding UTF8必须显式指定,Windows PowerShell 5.1 的默认编码是 ANSI,读进来再写出去,中文注释就成乱码了。这一步是很多人导入后中文变问号的真正原因,比数据库排序规则还常见。

4.2 执行顺序、GO、编码与退出码

脚本之间的依赖顺序不能靠文件名字母序碰运气。按这个顺序排:自定义类型和用户定义函数 → 表结构 → 数据 → 主键外键和索引 → 视图 → 存储过程 → 触发器 → 权限。外键放在数据之后建,能避开「插入顺序违反约束」;触发器放最后,否则导数据过程中会被反复触发。

GO不是 T-SQL 关键字,是客户端工具认的批处理分隔符,它不会被发给引擎。这件事在 C# 里自己拼脚本时会踩到,后面单独说。

#!/usr/bin/env bash set -euo pipefail # 任一脚本失败立即中断,别让后面的脚本在脏数据上继续跑 SQLCMD="sqlcmd -S localhost -d TargetDb -E -b -r1 -I -f 65001 -t 0" for f in $(ls /data/scripts/*.sql | sort); do echo "==> 执行 $f" $SQLCMD -i "$f" done

-b是关键,出错时返回非零退出码,配合set -e才能中断;不加的话 sqlcmd 会继续跑完所有脚本,最后给你一堆错误日志,却不知道哪一条才是根因。-r1把错误信息重定向到 stderr,日志分流更清楚。-I打开 QUOTED_IDENTIFIER,前面说过,索引视图和计算列没它建不出来。-f 65001指定输入输出编码,脚本里带中文时必须加。-t 0表示不设查询超时,大表的 INSERT 跑十几分钟很正常,默认超时会把任务打断在半路。

还有一种情况是脚本本身太大,几 GB 的单文件 sqlcmd 也吃不消。这时按对象拆成多个文件,或者用-a调大包大小(默认 4096 字节),网络往返次数能降下来。

4.3 C# 端把脚本跑起来:连接字符串与 ExecuteNonQuery 的坑

程序里跑脚本的场景很常见,比如初始化一个租户库、或者部署时自动建表。C# 这边最容易踩两个坑:连接字符串的加密默认值,以及GO的分批。

using System.IO; using System.Text.RegularExpressions; using Microsoft.Data.SqlClient; // TrustServerCertificate=true 只用于内网自签证书环境 const string cs = "Server=localhost;Database=TargetDb;Integrated Security=true;" + "Encrypt=True;TrustServerCertificate=True;Connect Timeout=30;"; var script = File.ReadAllText(@"D:\dbexport\SourceDb_all.sql"); // GO 是客户端分隔符,必须自己切批,单行独立的 GO 才算数 var batches = Regex.Split(script, @"^\s*GO\s*$", RegexOptions.Multiline | RegexOptions.IgnoreCase); using var conn = new SqlConnection(cs); conn.Open(); foreach (var batch in batches) { if (string.IsNullOrWhiteSpace(batch)) continue; using var cmd = new SqlCommand(batch, conn) { CommandTimeout = 0 }; cmd.ExecuteNonQuery(); }

Encrypt=True是 Microsoft.Data.SqlClient 4.0 之后的默认值,从旧版 System.Data.SqlClient 迁过来的代码常常因为这个报「证书链不受信任」。测试环境加TrustServerCertificate=True图快,正式环境应该换成可信证书,否则等于把加密关掉。

CommandTimeout = 0表示不限时,建大索引、灌大数据时必须这么设,默认 30 秒会半路抛异常,留下一个半成品表。正则里的^\s*GO\s*$只匹配独占一行的 GO,能避开字符串字面量里的 GO 被误切。如果脚本超过几十 MB,一次性ReadAllText内存压力大,改成分块读、按行判断批边界更稳。

5. 避坑排查:五条从现场记下来的翻车记录

5.1 中文全变问号

现象:导完之后nvarchar列里的中文变成?,英文和数字正常。

原因有两层。脚本文件本身被存成了 ANSI(GBK)编码,里面的中文字面量在读取时就已经错了;或者目标库的 collation 是 Latin1 系,varchar列存不下中文。前者更常见,因为 PowerShell 的Set-Content默认不写 BOM,编辑器打开时按本地编码猜。

解决:生成脚本时统一用 UTF-8 保存,回放时sqlcmd -f 65001;确认目标列的ORDER里没有从nvarchar被降级成varchar;跨库操作前先对齐 collation,或者临时在 JOIN 上加COLLATE DATABASE_DEFAULT。

5.2 外键导致建表或插数据失败

现象:脚本跑到一半报「ALTER TABLE 语句与 FOREIGN KEY 约束冲突」,或者表还没建完就报「对象不存在」。

原因:生成脚本时勾了「脚本所有对象」,向导按字母序输出,OrderDetail排到了Order前面;或者外键和表结构混在一起,数据还没插就先把约束建了。

解决:导出时按 schema 分组,人为把表结构、数据、约束拆成三个文件;用 SMO 时把WithDependencies关掉自己排;已经生成的脚本可以用sp_rename之外的思路——先禁用外键ALTER TABLE ... NOCHECK CONSTRAINT ALL,导完再WITH CHECK重新启用。

5.3 连接直接报证书错误

现象:sqlcmd 或 C# 程序连不上,错误信息里带「SSL Provider: 证书链是由不受信任的机构颁发的」。

原因:ODBC Driver 18 和 SqlClient 4.0+ 默认把Encrypt设成 yes,而开发机的实例用的是安装时自签的证书。

解决:客户端连接串加TrustServerCertificate=true,或者 sqlcmd 加-C参数;正式环境让实例挂可信证书,别长期用绕过的方式。这个坑最容易被误判成账号权限问题,实际上连登录都没走到。

5.4 高版本生成的脚本在低版本引擎上跑不动

现象:CREATE TABLE之后的某条语句报「关键字 'X' 附近有语法错误」,或者「无法识别的内置函数名称」。

原因:生成脚本时TargetServerVersion没降,2022 的语法写进了给 2016 的脚本里;或者用了 2019 才有的ALTER TABLE ... WITH (ONLINE = ON)。

解决:导出前把目标版本明确告诉向导;已经生成的脚本用SELECT @@VERSION对一遍,把新语法替换成等价写法;实在要跨大版本,考虑中间过一道 2019 的实例做跳板。兼容级别只能让引擎容忍老语法,不能让老引擎认识新语法。

5.5 大表导出超时或内存被吃满

现象:SMO 生成脚本跑到 70% 卡住,或者 PowerShell 进程内存一路涨到几个 G 然后崩掉。

原因:SMO 的ScriptData = $true是把所有行拼成 INSERT 语句放在内存里,百万行级别的表直接把字符串撑爆;sqlcmd 执行超大单文件也类似。

解决:大表单独走 bcp 或SqlBulkCopy,结构用 SMO 导,数据用 bcp 搬;或者分页导出,用ORDER BY Id OFFSET ... FETCH每次拉十万行拼一个文件。别指望一条命令搞定全库,物理上就不成立。

6. 验证跑通:从空库到数据一致的三个自检动作

脚本跑完不等于迁移成功,最常见的错觉是「没报错就是对的」。上线前我固定做三个比对,几分钟的事,能挡住九成的隐性错误。

第一步比结构。用INFORMATION_SCHEMA统计源库和目标库的列数量,差一列都说明某个对象没建出来。

-- 在源库和目标库各跑一次,把结果贴到两个窗口对比 SELECT s.name AS schema_name, t.name AS table_name, COUNT(c.column_id) AS col_count FROM sys.tables t JOIN sys.schemas s ON s.schema_id = t.schema_id JOIN sys.columns c ON c.object_id = t.object_id WHERE t.is_ms_shipped = 0 GROUP BY s.name, t.name ORDER BY s.name, t.name;

第二步比行数。sys.partitions的rows对堆表和聚集索引是准确的,比COUNT(*)快得多,几十亿行也能秒出。

SELECT s.name AS schema_name, t.name AS table_name, SUM(p.rows) AS row_count FROM sys.tables t JOIN sys.schemas s ON s.schema_id = t.schema_id JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0, 1) GROUP BY s.name, t.name ORDER BY s.name, t.name;

第三步比内容。行数一致但数据错位的情况确实会发生,比如 bcp 的字段分隔符和文本里出现的竖线撞了。对关键表用CHECKSUM_AGG抽一遍,两边值一致基本可以放心。

自检项命令判定标准
结构与列数INFORMATION_SCHEMA.COLUMNS统计源库目标库逐表相等
行数sys.partitions求和差异为 0,或有明确说明
内容校验CHECKSUM_AGG(BINARY_CHECKSUM(*))抽样表的值完全一致
对象数量sys.objects按 type 分组存储过程、视图、触发器数量一致
-- 关键表做一次内容校验,源库和目标库分别执行 SELECT CHECKSUM_AGG(BINARY_CHECKSUM(*)) AS chk FROM dbo.[Order] WITH (NOLOCK);

BINARY_CHECKSUM(*)对每个列值算二进制校验和,CHECKSUM_AGG再把这些值聚合起来,任何一个字符不同结果就会变。缺点是nvarchar的排序规则敏感、大字段(text、image)会被忽略,所以它只能当抽样手段,不能替代全量核对。真要做严格对账,还是得按主键区间分批跑EXCEPT。

三个比对跑完,再把应用连上去做一次真实读写,一次 INSERT、一次带中文的 UPDATE、一次跨表 JOIN,才算真的通了。我吃过一次亏:脚本导入一切正常,结果应用一连就报权限不足,原因是我建了用户却忘了把db_executor之外的 schema 权限给全,存储过程在dbo下能跑,在别的 schema 下全部失败。从那以后,我每次回放完都强制走一遍「新建一个只有读写权限的账号,用它把核心存储过程挨个执行一次」,不再只看管理员视角的结果。希望这些记录能帮到你,少在环境上返工。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进