用 SqlPackage 导出 SQL Server 数据库结构快照

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
SQL ServerSqlPackagePowerShell数据库结构

有些数据库运行了很多年,仓库里也留着一套 SQL 脚本。问题是这些脚本通常靠人维护:新增字段时补一份,临时改动不一定补,已经删除的对象还躺在旧文件里。拿它们做数据分析设计,很容易从一份过时的结构开始。

直接连着线上数据库分析也不合适。查询压力是一方面,更麻烦的是分析过程无法固定下来:今天看到的字段和下个月未必相同,其他人也很难复现当时的判断。

我最后采用的办法很朴素:从数据库镜像导出一份只含结构的 SQL Project,提交到 Git。表、字段、索引、视图和存储过程会拆成普通 .sql 文件,既能全文搜索,也能比较两次导出之间的变化。

为什么导出 SQL Project

SqlPackage /Action:Extract 可以生成 .dacpac,也可以生成由 .sqlproj.sql 文件组成的 SQL Project。

DACPAC 适合部署和工具链处理。数据分析前的结构阅读更适合 SQL Project,因为可以直接使用 rg 搜索字段和对象:

rg -n "OrderStatus|CreateTime|StoredProcedureName" db/schema

结构快照不需要表数据。导出参数中应明确保留:

/p:ExtractAllTableData=False

它不是数据库备份,也不能替代数据画像。它解决的只是一个问题:现在有哪些数据库对象,它们的定义是什么。

安装 SqlPackage

SqlPackage 是 Microsoft 基于 DacFx 提供的命令行工具。已经有 .NET SDK 时,可以安装为全局工具:

dotnet tool install --global Microsoft.SqlPackage

以后更新:

dotnet tool update --global Microsoft.SqlPackage

确认命令已经进入 PATH

sqlpackage /Version

如果刚安装完仍提示找不到命令,重新打开一个终端即可。

用 PowerShell 批量导出多个数据库

SqlPackage 一次只处理一个数据库。数据库多时,不必复制十份连接字符串,用数组依次执行即可。

先在当前 PowerShell 会话中设置连接信息。下面是一条完整命令,可以直接粘贴执行。环境变量只对当前终端及其子进程有效,关闭终端后失效:

$env:SQLPACKAGE_SERVER = 'HOST,1433'; $env:SQLPACKAGE_USER = 'DB_USER'; $env:SQLPACKAGE_PASSWORD = 'REDACTED_SECRET'; $env:SQLPACKAGE_SCHEMA_ROOT = Join-Path (Get-Location) 'db\schema'

密码中即使有分号,也不要自己拼接或额外包引号。后面的 DbConnectionStringBuilder 会负责连接字符串转义。

批量导出包含循环、分支和结果汇总,不适合冒充一条终端命令。新建 export-sqlserver-schema.ps1,把下面的脚本保存进去:

$databases = @(
  'DatabaseA'
  'DatabaseB'
  'DatabaseC'
)

New-Item -ItemType Directory -Force -Path $env:SQLPACKAGE_SCHEMA_ROOT | Out-Null

$results = [System.Collections.Generic.List[object]]::new()

foreach ($database in $databases) {
  $targetPath = Join-Path $env:SQLPACKAGE_SCHEMA_ROOT $database

  if (Test-Path -LiteralPath $targetPath) {
    [void]$results.Add([pscustomobject]@{
      Database = $database
      Status = '跳过'
      Detail = '目标目录已存在'
    })
    continue
  }

  $connection = [System.Data.Common.DbConnectionStringBuilder]::new()
  $connection['Data Source'] = $env:SQLPACKAGE_SERVER
  $connection['User ID'] = $env:SQLPACKAGE_USER
  $connection['Password'] = $env:SQLPACKAGE_PASSWORD
  $connection['Initial Catalog'] = $database
  $connection['Encrypt'] = $false
  $connection['TrustServerCertificate'] = $true

  $sqlPackageArguments = @(
    '/Action:Extract'
    "/SourceConnectionString:$($connection.ConnectionString)"
    "/TargetFile:$targetPath"
    '/OverwriteFiles:True'
    '/p:ExtractTarget=SqlProject'
    '/p:ExtractAllTableData=False'
    '/p:ExtractApplicationScopedObjectsOnly=True'
    '/p:ExtractReferencedServerScopedElements=False'
    '/p:IgnorePermissions=True'
    '/p:IgnoreUserLoginMappings=True'
    '/p:VerifyExtraction=False'
    '/p:ScriptSortElementsByName=True'
  )

  Write-Host "正在导出 $database ..."
  & sqlpackage @sqlPackageArguments
  $exitCode = $LASTEXITCODE

  if ($exitCode -eq 0) {
    [void]$results.Add([pscustomobject]@{
      Database = $database
      Status = '成功'
      Detail = $targetPath
    })
  } else {
    [void]$results.Add([pscustomobject]@{
      Database = $database
      Status = '失败'
      Detail = "退出码 $exitCode"
    })
  }
}

$results | Format-Table -AutoSize

$failed = @($results | Where-Object Status -eq '失败')
if ($failed.Count -gt 0) {
  Write-Warning "$($failed.Count) 个数据库导出失败,请检查上方 SqlPackage 输出。"
}

回到刚才设置环境变量的终端,用一条命令运行脚本:

& .\export-sqlserver-schema.ps1

单个数据库失败不会中断整个列表。成功后,目录结构大致如下:

db/schema/
├── DatabaseA/
│   ├── DatabaseA.sqlproj
│   ├── dbo/
│   └── Security/
├── DatabaseB/
└── DatabaseC/

TargetFile 不能是已有目录

第一次使用 ExtractTarget=SqlProject 时,我把 TargetFile 指向了提前创建好的数据库目录,得到这条错误:

*** The TargetFile argument cannot reference an existing directory.

这里的 TargetFile 名字有些误导。输出 DACPAC 时它确实是文件;输出 SQL Project 时,它表示将要创建的项目目录。父目录可以存在,数据库对应的目标目录不能存在。

/OverwriteFiles:True 也不会改变这个要求。上面的脚本遇到已有目录会跳过,避免把一次成功导出的结果和另一次结果混在一起。

需要更新快照时,应先确认并删除单个数据库目录,再重新导出。下面的清理命令保持为单行,修改数据库名后可以直接粘贴执行:

$expectedPath = [System.IO.Path]::GetFullPath((Join-Path (Get-Location) 'db\schema\DatabaseA')); $schemaPath = (Resolve-Path -LiteralPath $expectedPath).Path; if ($schemaPath -ne $expectedPath) { throw "结构目录不符合预期:$schemaPath" }; Remove-Item -LiteralPath $schemaPath -Recurse -Force

导出完成后直接看 Git 差异:

git status --short db/schema/DatabaseA
git diff -- db/schema/DatabaseA

不建议按日期保留多份完整目录。当前结构放在工作区,历史变化交给 Git,查起来更直接。

跨数据库引用为什么会让验证失败

老系统经常在视图或存储过程中直接引用另一个数据库,甚至 Linked Server。镜像环境没有全部依赖对象时,VerifyExtraction=True 可能让结构导出失败。

如果目的只是保存当前定义,可以使用:

/p:VerifyExtraction=False

关闭验证不会主动删掉 SQL 文本中的跨库引用,但这份项目不一定可以独立编译。分析跨库查询时,仍要把相关数据库都导出,再沿对象引用逐个核对。

这也解释了为什么结构快照不能被当成“数据库项目构建成功”的证明。二者目标不同:一个保存现场,一个验证所有依赖是否完整。

加密存储过程为什么只剩一段占位文本

有些导出文件中只能看到:

-- 该脚本主体已加密,无法在此重现。
RETURN

这不是 SqlPackage 漏导。SQL Server 对使用 WITH ENCRYPTION 创建的模块不公开定义,sys.sql_modules.definition 对这类对象返回 NULL。导出工具拿不到原始文本,自然无法生成过程主体。

先排除权限问题。OBJECT_DEFINITION 在调用者没有查看对象定义的权限时也会返回 NULL。如果账号已经具备相应权限,导出结果仍明确标记对象已加密,就不能指望换一个导出参数恢复源码。

能依赖的来源只有创建它时保存的 SQL 文件、发布包、源码仓库,或其他未加密的历史副本。数据库中的加密定义不应被视为源码备份。

结构快照仍会告诉我们对象存在,但无法回答它内部查询了哪些表、用了什么业务规则。这类对象必须单独登记,否则后面的数据血缘分析会留下一块看似完整、实际不可见的区域。

结构快照能回答什么

导出的 SQL Project 适合确认:

  • 表、字段、类型和约束;
  • 索引、视图、函数、触发器和未加密模块的定义;
  • 跨数据库引用文本;
  • 两次导出之间的结构变化。

它不能说明表是否仍在写入,也不能给出数据量、时间覆盖、枚举含义和保留周期。SQL Server Agent Job、登录名、Linked Server 等服务器级配置也不在这份数据库结构快照里。

真正开始分析前,我会把结构快照和数据画像分开:先用快照规划表和字段,再用受控查询确认记录数量、时间范围、空值比例和实际取值。这样得到的结论可以复查,也不会把对象名称猜成业务事实。

参考资料