如何在编写迁移之前先规格化数据库模式

如何在编写迁移之前先规格化数据库模式
Daniel Marsh · Spec-First 工程笔记

迁移文件是永久性的。一旦它在生产环境中运行,你无法撤销它,只能再写一个迁移来修正。然而大多数团队写迁移的方式和写应用代码一样:打开编辑器,开始敲列定义,约束条件边写边想。结果是模式中积累了无人记录的隐式决策、无人质疑的默认值,以及困扰代码库多年的可空性选择。在编写迁移之前先规格化数据库模式,可以把这些决策摆到明面上,让它们能被审查、质疑和达成共识。

发布于 2026-03-22 · ✓ 已更新 2026-05-06 · 阅读约 9 分钟 · 作者:Daniel Marsh · 审校:编辑政策

为什么数据库模式需要规格

应用代码会被重写,框架会被替换。但数据库模式的寿命几乎超过技术栈中的一切。2019 年添加的一列在 2026 年仍然存在,除非有人明确删除了它——而从生产数据库中删列是团队能执行的风险最高的操作之一。这种长寿性使得模式决策具有极高的杠杆效应:一个错误的列类型或缺失的索引,在其生命周期内的代价远超一个命名不当的变量。

问题在于,大多数模式决策是隐式做出的。工程师打开迁移文件,敲下 status VARCHAR(255),然后就继续了。没人问为什么是 varchar 而不是 smallint。没人问 255 是正确的长度还是只是 ORM 的默认值。没人问哪些值是合法的。迁移通过了代码评审,因为评审者关注的是应用逻辑,而不是 placed_at 应该是 TIMESTAMP 还是 TIMESTAMPTZ

这些隐式决策会累积。一年后,团队发现 status 在生产环境中有 14 个不同的字符串值,其中 3 个是拼写错误。他们发现 placed_at 存储的时间没有时区信息,账单对账任务已经默默地计算错误了好几个月。这些 Bug 都可以追溯到一个从未被显式做出的模式决策——只是被默认出来的。

模式规格迫使每个决策在迁移编写之前被陈述、审查和论证。写一份规格花三十分钟,但能省下后续数周的调试时间。

模式规格模板

模式规格不是设计文档。它是你即将创建或修改的表的具体描述,写得足够详细,让评审者能在一行 SQL 都没写之前就评估每一列、每个约束和每个索引。

## 模式规格:orders

### 表:orders
说明:存储客户采购订单。每次结账一行。

| 列           | 类型          | 可空   | 默认值          | 约束                    |
|-------------|---------------|--------|----------------|------------------------|
| id          | uuid          | 否     | gen_random_uuid() | PRIMARY KEY         |
| user_id     | uuid          | 否     | —              | FK -> users(id)        |
| total_cents | bigint        | 否     | —              | CHECK (total_cents >= 0) |
| currency    | char(3)       | 否     | 'USD'          | CHECK (currency IN ('USD','EUR','GBP')) |
| status      | smallint      | 否     | 0              | CHECK (status IN (0,1,2,3)) |
| placed_at   | timestamptz   | 否     | now()          | —                      |
| canceled_at | timestamptz   | 是     | NULL           | —                      |
| notes       | text          | 是     | NULL           | —                      |

### 状态映射
0 = 待处理, 1 = 已确认, 2 = 已发货, 3 = 已取消

### 索引
- idx_orders_user_id ON (user_id) — 支持按客户查找
- idx_orders_placed_at ON (placed_at DESC) — 支持仪表盘排序
- idx_orders_status ON (status) WHERE status IN (0, 1) — 活跃订单的部分索引

### 外键
- user_id -> users(id) ON DELETE RESTRICT

### 迁移顺序
必须在之后运行:001_create_users
迁移文件:002_create_orders

这种格式实现了三件事。第一,它让每个列级决策都可见。评审者可以看到 total_cents 是带有非负检查约束的 bigint,而不是 decimalfloat。第二,它在迁移编写之前就暴露了索引,这意味着团队可以在评审时讨论查询模式,而不是在生产环境中才发现缺失的索引。第三,它记录了迁移顺序,让编写迁移的工程师知道哪些表必须先存在。

规格化列级约束

表中的每一列都代表一个决策。规格就是你显式做出每个决策的地方,而不是让 ORM 替你决定。

可空性。大多数数据库的默认行为是允许空值。这意味着如果你不说 NOT NULL,列就会接受空值。对于新列来说,这几乎总是错误的。一个可空的 email 列意味着你有一个没有邮箱的用户。这是合法状态吗?如果不是,就设为 NOT NULL。规格迫使你对每一列回答这个问题。在可空列中写"是"或"否"。不要留空。

默认值。默认值不只是便利功能。它定义了当你向已有数据的表添加新列时,现有行会发生什么。如果你添加 currency CHAR(3) NOT NULL 而不带默认值,迁移会在任何有现有行的表上失败。规格应该说明默认值,并在备注中解释为什么这个默认值是正确的。如果所有现有客户都在美国,'USD' 作为 currency 的默认值是合理的。如果你有欧洲客户,那就是错误的。

检查约束。检查约束是数据库和应用之间的契约。CHECK (total_cents >= 0) 意味着无论应用代码怎么做,数据库都会拒绝负数的订单总额。这是你对抗脏数据的最后一道防线。规格应该列出每一个检查约束,因为每个约束都是一条业务规则。如果 status 只能是 0、1、2 或 3,那就应该写在规格里。如果应用后来需要 status 4,团队就知道这需要模式变更,而不仅仅是代码变更。

外键。外键是带有后果的关系。ON DELETE CASCADE 意味着删除用户会删除其所有订单。ON DELETE RESTRICT 意味着有订单的用户无法被删除。ON DELETE SET NULL 意味着订单保留但失去用户引用。每种选项有不同的故障模式,规格就是团队决定哪种故障模式可以接受的地方。不要把这个留给写迁移文件的人。在规格中指定行为并让它被评审。

迁移顺序和依赖关系

当表 B 有外键引用表 A 时,表 A 必须在表 B 创建之前就存在。在两张表的例子中这很明显。当你有一个涉及五张表、其中三张互相引用的功能时,这就不那么明显了。

模式规格应该包含一个依赖部分,列出哪些迁移必须先运行。这不是说迁移框架会自动处理顺序。大多数框架确实会处理。关键是依赖链本身是一个设计决策,评审者需要看到它。

假设一个功能需要添加 subscriptions 表、subscription_items 表和 billing_events 表。依赖图如下:

如果 products 尚不存在且正在同一功能分支中创建,迁移顺序就变成:users(已存在)、products、subscriptions、subscription_items、billing_events。规格让这个依赖链可见。没有规格的话,工程师在预发布环境中迁移失败时才发现顺序问题,通过试错来修复,没人从这个过程中学到任何东西。

第二个顺序问题是回滚安全性。如果迁移 003 依赖于迁移 002,在 003 仍然生效的情况下回滚 002 会破坏外键约束。规格应该标注哪些迁移可以独立回滚,哪些需要按相反顺序回滚。这在事故响应时最为关键,因为团队在快速行动时没有时间手动追踪依赖链。

迁移期间的向后兼容性

在任何不能容忍停机的系统中,迁移必须与当前部署的应用代码向后兼容。这意味着你不能在单次迁移中重命名列、更改列类型或删除列。你需要扩展-收缩模式。

扩展。在旧列旁边添加新列。部署同时写入两列的应用代码。回填现有行。这是迁移 1。

收缩。当所有行都有了新列的值,且所有应用代码都从新列读取时,删除旧列。这是迁移 2,在迁移 1 之后数天或数周部署。

模式规格应该显式记录两个阶段。如果你要将 placed_at 重命名为 ordered_at,规格应该展示:

在规格中记录两个阶段可以防止一个常见的失败模式:工程师写了迁移 1,发布了,然后团队忘记了迁移 2。旧列永远留在模式中,让之后三年看到这张表的每个工程师都感到困惑。规格充当提醒,告诉大家工作在两个阶段都完成之前不算结束。

对于实践 API 版本控制的团队,扩展-收缩模式有直接的类比:在所有消费者都迁移到新字段之前,你不会从 API 响应中移除旧字段。模式变更和 API 变更通常需要协调,规格是记录这种协调的正确位置。

边界情况:可空性、默认值和数据回填

向有现有行的表添加 NOT NULL 列是迁移失败最常见的原因之一。数据库会拒绝迁移,除非每个现有行都有该新列的值。你有三个选择,规格应该说明你使用的是哪一个以及为什么。

选项 1:带默认值添加。ALTER TABLE orders ADD COLUMN currency CHAR(3) NOT NULL DEFAULT 'USD'。如果默认值对所有现有行都是正确的,这就可行。如果不正确,你刚刚往生产环境写入了错误数据。规格应该论证默认值:"所有现有订单都来自美国客户。默认值 'USD' 对当前数据集是准确的。"

选项 2:先添加为可空,回填,再设为 NOT NULL。当每行的正确值不同时,这是安全路径。先添加为可空列,运行回填脚本为每行填入正确值,然后将列改为 NOT NULL。规格应该描述回填逻辑:"从 users.default_currency 回填 currency。对于没有默认货币的用户,设为 'USD'。"这是三个独立的操作,规格应该列出所有三个。

选项 3:添加为可空并保持。有时一列确实是可选的。canceled_at 对于未被取消的订单为空。这是合法状态,不是缺失数据。规格应该解释为什么空值是可接受的:"NULL 表示订单尚未被取消。仅在状态转换为已取消时填充。"

回填性能是规格应该涉及的另一个边界情况。在单个事务中回填 5000 万行会锁表并使应用停顿。规格应该说明回填策略:批次大小、是否在非高峰时段运行、是使用后台任务还是一次性脚本。这些决策对 QA 测试也很重要,因为测试环境需要在有代表性的数据集上模拟回填。你的边界情况清单应该包含一行"在有数据的表上执行迁移"。

模式规格与 API 合约对齐

模式变更几乎总是会波及 API。如果你向 orders 表添加 currency 列,GET /orders/:id 的响应需要包含它。如果你将 placed_at 重命名为 ordered_at,除非你保持向后兼容,否则该端点的消费者会中断。模式变更和 API 变更是同一枚硬币的两面,分开规格化它们会导致不对齐。

解决方法是一起规格化。当你为新表或列变更编写模式规格时,包含一个将模式列映射到 API 响应字段的部分。这不需要很复杂。一个简单的映射就够了:

这种映射能尽早发现不匹配。如果数据库将 total_cents 存储为 bigint,但 API 以美元形式返回 total,中间就有一个转换步骤。这个转换在哪里发生?四舍五入怎么处理?这些问题在规格评审时浮出水面,而不是在上线三个月后调试账单差异时才被发现。

对于维护版本化 API 合约的团队,模式规格应该标注哪个 API 版本引入了新字段。如果 currency 在 API v3 中添加,v2 的消费者不应该看到它。规格使这一点明确。没有规格的话,一个工程师在数据库中添加了 currency,另一个工程师在 API 序列化器中添加了它,没人检查它是否应该被版本标志控制。

同样的原则适用于错误处理。如果一列有检查约束,API 需要在约束被违反时返回有意义的错误。CHECK (total_cents >= 0) 应该产生一个 422 Unprocessable Entity 并带有类似"total 必须为非负数"的消息,而不是原始的数据库错误。模式规格就是你将数据库约束连接到 API 错误合约的地方,让实现端点的工程师知道需要处理什么。

前后对比:更安全的迁移说明

数据库规格最常见的问题,是把迁移计划写成一步。更安全的写法会说明兼容窗口,以及每一阶段完成的证据。

修改前:
- 给 subscriptions 增加 status 并回填。

修改后:
- 增加 nullable status。
- 部署读取逻辑:status 为空时回退到 billing_state。
- 每批回填 5,000 行;lock wait 超过 2s 时暂停。
- 连续 24 小时没有 null 读取后,再加 NOT NULL 约束。
- 约束前回滚:关闭 writer 并删除字段。
- 约束后回滚:停止 writer,保留字段,恢复 fallback。

第二种写法承认了兼容窗口前后回滚方式不同。数据库规格正应该提前暴露这种决策。

关键词:数据库模式规格 · 迁移规划 · 列约束 · 模式设计 · spec-first 数据库 · 向后兼容迁移 · 扩展收缩模式

专题阅读路径

这篇文章归入 API 契约 主题。先读 Hub,再结合下面的清单、模板或工具落到具体项目里。

交互式生成规格
填写表单,生成完整的功能规格 Markdown——免费使用,无需注册。
试用规格生成器

编辑说明

本文面向软件交付团队,介绍数据库模式规格。示例均为工程场景说明,不构成法律、税务或投资建议。