# 面向 AI 编程代理的安全数据库迁移流程

生产数据库变更需要与普通代码修改不同的流程。AI 编程代理可以检查架构、追踪应用查询，并以比大多数团队安排评审更快的速度起草迁移文件。但不能因为某个提示词碰巧包含“部署”一词，就把这种速度变成修改生产环境的权限。

安全的数据库迁移流程会把规划和授权分开。代理可以准备证据和精确的变更集。人工则可以评估操作影响，证明恢复流程有效，并批准一次边界明确的执行。这样的分离能避免常见故障：一个看似无害的 `ALTER TABLE` 最终阻塞结账流程、触发失控的数据重写，或者造成无人能够解释的半成品部署。

本文使用 PostgreSQL 命令，因为它的锁和事务行为在文档中说明得很清楚。这套流程也适用于其他关系型数据库，但在确认相应数据库的手册之前，不要把 PostgreSQL 的语法或假设直接套用到其他数据库引擎上。

## 生产变更需要将授权与代码生成分开

迁移文件和生产迁移是两种不同的行为。前者描述意图，后者则是在拥有活跃用户、复制任务、备份以及可能彼此不兼容的应用版本的在线系统上，动用稀缺的操作权限。

团队经常犯的错误是，为了让本地开发更方便，给代理一个范围很大的数据库连接。这个连接随后跨越多个环境。如果暂存环境和生产集群接受相同的凭据，代理就无法分辨暂存主机名和生产主机名。更糟的是，能够执行任意 SQL 的代理没有自然理由在表重写之前停下来。它看到的只是任务和工具。

为工作划分出不同阶段，并为每个阶段设置不同的输入和权限：

1. 发现阶段读取架构元数据、迁移历史、查询代码和操作约束。
2. 规划阶段生成 SQL、预期的锁行为、数据影响、预检、验证查询以及恢复决策。
3. 评审阶段确认计划符合真实的生产状态，并确认组织接受其中的风险。
4. 执行阶段针对一个指定目标运行一份获批的制品。
5. 验证阶段证明应用和数据库已经达到预期状态，然后才能宣布部署完成。

有用的区分是“可逆性”和“可恢复性”。添加一个允许为空的列通常是可逆的：如果没有代码依赖它，后续语句可以将其删除。根据错误表达式更新数百万行，即使有人写出看似相反的更新语句，也可能无法逆转，因为你可能已经不知道原来的值是什么。可恢复性意味着你有经过测试的方法来恢复或控制损害。每份迁移计划都应将两者作为不同字段记录。

代理不应根据代码仓库分支、工单标签或聊天提示词中的环境变量，推断自己拥有生产权限。这些信号只描述意图，而意图经常会出错。执行必须要求在代理文本上下文之外选择目标，记录已准备制品的摘要，并让某个人清楚看到即将运行的确切内容。

## 将迁移计划做成可评审的制品

一份可评审的计划，应让操作人员有足够信息在迁移到达数据库之前拒绝它。“添加索引”不是计划。表的大小、查询目的、索引形式、事务限制、锁暴露以及验证查询，合在一起才构成计划。

要求代理生成一个包含固定文件的目录，而不是在合并请求中写一段说明。例如：

```text
migrations/2025-04-add-orders-status-index/
  up.sql
  verify.sql
  preflight.sql
  recovery.md
  manifest.json
```

清单应将这些文件绑定到目标，并说明代理已经了解到的情况。下面的例子不会暴露凭据，也不会因为有人提出了备份要求，就声称备份已经存在。

```json
{
  "migration_id": "2025-04-add-orders-status-index",
  "engine": "postgresql",
  "target": "production-orders",
  "change": "create index for filtered order status query",
  "transaction_mode": "outside_transaction",
  "requires_backup_restore_check": true,
  "expected_write_blocking": "none during index build",
  "stop_conditions": [
    "target schema differs from preflight result",
    "backup restore check fails",
    "index is invalid after execution"
  ]
}
```

人工评审者应能从这份制品中回答五个问题：

- 哪个数据库和架构会接收这次变更？
- 将执行哪些确切 SQL，执行器会不会把它们包在事务中？
- 哪个操作会造成阻塞、重写、扫描或额外存储消耗？
- 有什么证据能证明变更出错时数据库可以恢复？
- 哪些查询或应用信号可以证明成功？

要求代理写出自己的不确定性。如果它无法确定 PostgreSQL 版本、表大小、当前迁移版本，或调用方应用的兼容范围，就应将这些内容列为停止条件。仅凭不完整的代码仓库访问权限制造确定感，比保留一个未解决项目更糟糕。

评审后应保持生成的 SQL 不可变。如果评审者修改了 `up.sql`，就要重新生成校验和，并再次让制品进入评审流程。常见的失败模式是：迁移已经评审通过，随后有人在部署脚本中匆忙加入一个“小修复”。此时线上命令已经不是评审过的命令，而审计记录却讲述了一个令人安心但不真实的故事。

## 在决定如何运行前先给操作分类

SQL 动词本身不足以说明操作风险。`ALTER TABLE` 既可能表示很快完成的变更，也可能表示需要持有锁或重写大量数据、最终耗尽存储空间的变更。在确定执行路径之前，安全流程会先结合实际操作、数据库版本、表大小和并发负载进行分类。

PostgreSQL 的 `ALTER TABLE` 文档明确指出了令人不安的一点：除非手册另有说明，许多形式都会获取 `ACCESS EXCLUSIVE` 锁。这种锁会与读写操作冲突。如果暂存环境没有长事务、没有报表流量，数据量也只有生产环境的一小部分，那么“在暂存环境运行得很快”几乎不能说明问题。

可以采用四种实用分类：

| 类别 | 典型示例 | 执行预期 |
|---|---|---|
| 仅修改元数据 | 添加没有默认值的可空列 | 操作时间短，但仍要针对当前版本验证锁行为 |
| 并发构建 | 创建新索引 | 使用特殊命令形式，并单独处理事务 |
| 分批数据变更 | 回填新列 | 小批量提交，控制速率，支持断点续跑 |
| 重写或破坏性变更 | 修改大列类型或删除数据 | 需要明确恢复计划的维护决策 |

不要把所有在线操作都称为安全操作。PostgreSQL 的 `CREATE INDEX CONCURRENTLY` 按照 `CREATE INDEX` 文档中的说明，可以在构建索引时避免阻塞写入。但它耗时更长，不能在事务块内运行，失败后还可能留下无效索引。“始终使用 concurrently”这种流行建议忽略了这些条件。只有在写入可用性很重要，并且迁移执行器能够处理这些规则时，才使用它。

规划查询可以为评审者提供表和索引大小的初步估算：

```sql
SELECT
  pg_size_pretty(pg_total_relation_size('public.orders')) AS total_size,
  pg_size_pretty(pg_relation_size('public.orders')) AS table_size,
  pg_size_pretty(pg_indexes_size('public.orders')) AS indexes_size;
```

预期输出是一行包含三个易读的大小值。将其视为容量信号，而不是持续时间承诺。行宽、缓存状态、并发写入、复制、磁盘吞吐量以及活跃事务都会改变结果。

对于锁暴露明显的操作，代理还应在执行前检查阻塞者。PostgreSQL 会在 `pg_stat_activity` 中公开活跃会话，但这并不意味着你有权终止它们。计划应指出任何长时间运行任务的负责人，并说明迁移是等待、重新安排，还是进入维护窗口。

## 经过恢复测试的备份是入场券

备份命令成功，只能证明程序写出了一个文件。它不能证明文件能够恢复，不能证明里面包含所需对象，也不能证明恢复流程在压力下有效。对于破坏性或难以逆转的工作，应在执行前进行恢复检查。

对于 PostgreSQL 数据库，自定义格式的逻辑转储可以这样执行：

```sh
pg_dump -Fc -d "$SOURCE_DATABASE" -f "orders-preflight.dump"
pg_restore -l "orders-preflight.dump" | sed -n '1,12p'
createdb migration_restore_check
pg_restore -d migration_restore_check "orders-preflight.dump"
psql -d migration_restore_check -c "SELECT count(*) FROM public.orders;"
```

列表命令应打印出归档条目，例如表定义、表数据条目和索引定义。最后的查询应打印一个单列结果，其中包含恢复后的行数。将命令结果、转储标识符、恢复目标和检查结果记录到迁移记录中。不要把数据库 URL 或密码写入记录。

逻辑恢复检查不能替代物理恢复测试、时间点恢复、只读副本提升演练或云服务商的备份保障。它回答的是一个更具体的问题：这个转储能否恢复到数据库中，并产生我们预期的对象和数据形态？这个范围有限的答案仍然可以发现损坏的归档、缺失的扩展、角色问题，以及只在编写者自己电脑上正常运行的流程。

在执行前就确定恢复方法，不要等出错后再决定。计划应说明以下哪种情况适用：

- 反向迁移是安全的，因为没有丢弃数据，而且应用可以承受逆转。
- 恢复到替代数据库是恢复路径，并明确由谁决定切换。
- 使用副本或云服务商的恢复流程，并记录恢复点目标。
- 变更无法干净地恢复，因此需要维护窗口和明确的风险接受。

最后一种情况完全合理。因为部署模板要求填写某个字段，就假装存在回滚方案，这种做法并不合理。我见过团队把 `DROP COLUMN` 写成回填操作的回滚，而回填已经改变了客户数据。后来他们发现，删除新列只是抹掉了证据，原来的值仍然是错的。

## 先扩展，后移除

大多数面向应用的架构变更，都应在不同应用版本混合运行期间保持兼容。部署过程中，旧进程和新进程可能同时存在，因为工作线程退出缓慢、用户保持请求打开，或者回滚重新启动了旧版本。假设应用版本会瞬间统一的迁移，会把正常部署行为变成中断。

假设你需要用受约束的 `orders.status_code` 替换 `orders.status_text`。不安全的做法是在一次发布中添加新列、重写所有行、切换应用代码，再删除旧列。它包含多个故障点，也没有适合中途停止的安静时机。

改用扩展和收缩的顺序：

1. 将 `status_code` 添加为可空列，并添加不会破坏当前应用的辅助结构。
2. 部署应用代码：存在新字段时读取新字段，并同时写入两个字段，或者安全地从一个字段推导另一个字段。
3. 以有界批次回填现有行，并将进度记录在代理临时聊天上下文之外。
4. 验证所有行都符合新约束，同时确认读取方使用新字段。
5. 在后续发布中停止写入旧字段，经过约定的保留期后再移除它。

批处理循环很重要。一个巨大的 `UPDATE` 会让资源长时间被占用，产生大量预写日志，给副本带来压力，也让恢复更难判断。每批有明确边界的查询，会在提交之间为操作人员提供检查复制延迟、错误率和数据库负载的机会。

下面的 PostgreSQL 模式按主键更新选定批次，并返回发生变化的行：

```sql
WITH batch AS (
  SELECT id
  FROM public.orders
  WHERE status_code IS NULL
  ORDER BY id
  FOR UPDATE SKIP LOCKED
  LIMIT 500
)
UPDATE public.orders AS o
SET status_code = CASE o.status_text
  WHEN 'new' THEN 10
  WHEN 'paid' THEN 20
  WHEN 'shipped' THEN 30
  ELSE NULL
END
FROM batch
WHERE o.id = batch.id
RETURNING o.id;
```

只有在仍有返回行，并且映射结果符合要求时，执行器才应重复运行这段语句。对于受控的工作线程，`SKIP LOCKED` 可以避免等待其他事务持有的行，因此很有用。但它不能证明所有行都已处理。验证查询必须检查剩余的空值和意外的源值：

```sql
SELECT status_text, count(*)
FROM public.orders
WHERE status_code IS NULL
GROUP BY status_text
ORDER BY count(*) DESC;
```

如果查询返回了陌生值，就停止。不要让代理因为想完成任务，就决定把未知的生产状态转换成默认枚举值。

## 执行需要硬性停止条件和有界命令

获批的迁移仍可能遇到与规划者检查时不同的生产状态。执行器必须在执行前立即进行预检，并在结果不一致时停止。这正是流程比依靠记忆执行的操作手册更安全的地方。

使用专用执行器，只接受制品标识符和目标选择器。它应拒绝从终端粘贴的内联 SQL，拒绝校验和已改变的制品，并在开始前打印目标身份。执行器可以先运行只读预检 SQL，然后要求对修改部分进行单独批准。

明确设置会话时间限制。PostgreSQL 将 `lock_timeout` 和 `statement_timeout` 作为两个独立控制项进行说明。前者在等待锁时中止，后者在语句运行过久时中止。对于短时间的元数据操作，可以使用如下前置语句：

```sql
SET lock_timeout = '5s';
SET statement_timeout = '60s';
SELECT current_database(), current_user, now();
```

不要把这些数字直接套用到索引构建或回填上。超时是一个需要由计划解释的预算。如果预期负载下命令需要运行三十分钟，那么六十秒的语句超时只会制造一次可预测的失败。创建并发索引时，应使用不会将命令包在事务中的执行路径：

```sql
CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_status_code_idx
ON public.orders (status_code);
```

命令结束后要验证索引，而不是根据进程正常退出就假定成功。PostgreSQL 会在目录元数据中记录索引有效性，并发构建失败后可能留下无效索引。重试前需要检查并删除它。将这项检查放入 `verify.sql`，并在适当情况下加入面向应用的查询计划。

真正的中止决策需要明确的信号。如果预检发现意外的迁移版本、存储余量不足、允许窗口之外的活跃阻塞者、备份恢复失败、校验和失败，或结果与验证条件不符，就停止执行。操作人员不应在锁等待不断增长时，还要与代理协商。

## 批准应将一个人绑定到一次线上运行

当系统要求人们批准每一次 SQL 调用时，就会出现批准疲劳。人们随后会凭习惯批准，或者关闭提示。一次性批准一个定义清晰的代理流程是有用的，前提是批准中明确该流程，流程结束时批准失效，并且不会悄悄覆盖之后的另一个会话。

批准界面必须展示足够上下文，让人能够拒绝这次运行：签名的进程身份、选定目标、制品标识符、计划中的操作类别，以及操作通道是否具备写入能力。它不应显示原始密码，也不应要求代理处理密码。人批准的是操作，而不是泄露秘密。

Sallyport 适合这个边界：它将 API 和 SSH 凭据保存在加密保管库中，让支持 MCP 的代理通过 `sp mcp` 请求操作，并返回操作结果而不是明文密钥。独立的会话批准和可选的逐密钥批准，可以让生产数据库操作必须经过人工决定，同时不把可重复使用的凭据放入代理上下文。

让批准只覆盖执行阶段。发现代理可以使用只读路径。执行代理可以获得一个随进程结束而失效的会话，并且只能访问获批的操作路径。即时撤销很重要，因为当预检结果令人意外时，操作人员需要能够停止运行，而不是只能等部署脚本结束。

如果团队无法在压力下解释策略，就不要用难以说明的策略语言替代清晰的操作设计。可靠的边界很简单：锁定访问会拒绝所有操作，新执行进程需要授权，特别敏感的凭据可以要求每次使用都重新决定。相比一堆没人记得是谁写下的推断规则，这种边界更容易审计。

## 审计记录必须在不包含秘密的情况下重建变更

迁移失败后，人们会问一些简单的问题：谁运行了它，针对哪个目标，执行的确切制品是什么，什么时候停止，以及是否修改了数据？普通的终端记录很少能完整回答这些问题。它可能遗漏目标，在清理 shell 历史后丢失命令，或者包含根本不应记录的秘密。

为规划、批准、预检、执行、验证、撤销和失败记录结构化事件。每个事件都应包含迁移标识符和制品摘要，以便调查者将评审记录与线上运行连接起来。记录预检返回的数据库身份，而不只是调用者请求的名称。

最小事件格式可以这样写：

```json
{
  "event": "migration.verify",
  "migration_id": "2025-04-add-orders-status-index",
  "artifact_sha256": "recorded-digest",
  "target_identity": "production-orders",
  "result": "passed",
  "observed": "index valid; query returned expected columns"
}
```

如果 SQL 参数可能包含个人数据、令牌或客户内容，就不要记录它们。改为记录制品摘要和安全的语句标识符。执行系统可以按照明确的保留策略保存受保护的诊断信息，但审计日志本身应在不变成另一个秘密存储的情况下保持有用。

防篡改证据会改变调查质量。任何人在糟糕的部署之后都能编辑的记录，只能证明某人拥有记录访问权。如果使用哈希链审计日志，就应在事故复盘中独立验证它。Sallyport 的 `sp audit verify` 可以在离线状态下检查加密审计链，而不需要访问保管库。这正适合不应获得生产凭据的评审者。

## 验证必须测试工作负载，而不只是 DDL

迁移成功的标准是预期工作负载运行正常，而不是数据库接受了一条语句。新列可能存在，但应用写入时没有填充它。索引可能有效，但由于谓词或数据类型不同，目标查询无法使用它。约束可能已经验证通过，但旧工作线程仍在发送违反新应用契约的数据。

在执行前写好验证查询，这样评审者才有机会质疑其中的假设。对于索引迁移，检查目录状态并检查促成本次变更的查询。对于回填，统计剩余行，按组查看意外的源值，并确认新的应用写入路径产生预期表示。对于约束，在要求生产环境启用它之前，先在一次性数据库中测试有效和无效写入。

PostgreSQL 的 `EXPLAIN` 很有用，但也很容易误用。规划器的决定取决于统计信息、参数值、数据分布和配置。`EXPLAIN (ANALYZE, BUFFERS)` 会实际运行查询并测量它，因此不要随意对高成本的生产查询使用。应在有代表性的安全查询上使用它，并在看到输出前定义可接受的结果。否则每个查询计划都会变成疲惫的操作人员可以勉强解释的东西。

在变更期间和之后观察实时应用指标：请求错误率、受影响路径的查询延迟、连接压力、复制健康状况以及工作线程失败。代理可以收集并展示这些读数，但是否满足既定退出条件，应由发布负责人决定。

不要因为扩展部署已经通过，就安排破坏性的收缩操作。要等到可观测性和发布历史都表明旧应用进程不再依赖旧架构后，再将移除操作作为一份独立的、经过评审的迁移运行。多做这一步，比在回滚期间发现旧版本依赖一小时前刚删除的列，代价低得多。

## 第一次生产运行应当刻意做到平淡无奇

第一次由代理协助执行的生产迁移，不应在高负载下重构表，而应添加低风险、可观察的变更。可以选择可空的新增列、注释，或其他你已经充分了解其行为的操作。利用这次运行测试各个边界：制品生成、目标选择、批准、备份证据、审计事件、预检失败、执行和验证。

让这次演练证明系统能够拒绝工作。将执行器指向架构指纹错误的目标，确认它会停止。修改已经评审过的文件，确认摘要检查会拒绝它。在一次无害的暂存运行期间撤销授权，确认后续操作会失败。这些测试能揭示控制措施在真正需要时是否有效，而不仅仅是在设计文档中看起来合理。

之后逐一扩大允许的迁移类别。团队能够安全添加列，并不代表它已经证明自己可以回填大表、创建并发索引，或从错误的数据转换中恢复。每个类别都需要独立的实际运行和独立的失败标准。

把生产权限当成代理为一个狭窄任务暂时借用的东西，任务结束后就收回。这种操作习惯能防止的损害，会比任何巧妙的提示词指令都多。
