1 分钟

以 PostgreSQL 约束为先:及早阻止 AI 边缘错误

通过在代码和测试之前依靠 PostgreSQL 的 NOT NULL、CHECK、UNIQUE 和 FOREIGN KEY 约束来更安全地发布 AI 生成的应用。

以 PostgreSQL 约束为先:及早阻止 AI 边缘错误

为什么 AI 写的代码在脏数据面前仍然会出错

AI 生成的代码常常看起来没问题,因为它处理了“快乐路径”。真实的应用会在脏数据的中间地带失败:表单提交了空字符串而不是 null、后台任务重试导致创建了重复记录、或者删除操作移除了父行却留下了子行。这些并非奇怪的 bug。它们会表现为必填字段为空、被标为“唯一”的值重复、以及指向不存在对象的孤儿行。

这些问题也容易通过代码审查和基本测试,因为审查者读的是意图而不是每个边缘情况。测试通常只覆盖一些典型例子,而不是数周的真实用户行为、CSV 导入、不稳定的网络重试或并发请求。如果是助手生成了代码,它可能会漏掉一些小但关键的检查,比如修剪空白、校验范围或防止竞态条件。

“先约束,后代码”意味着把不可谈判的规则放到数据库里,这样无论哪个代码路径尝试写入,都无法存入坏数据。你的应用仍然应该在输入层做验证以便返回更友好的错误信息,但数据库承担最后的事实判定。这就是 PostgreSQL 约束的强项:它们能保护你避免整类错误。

举个快速例子:想象一个小型 CRM。一个 AI 生成的导入脚本创建联系人。有一行的 email 是 ""(空字符串)、两行 email 大小写不同却相同、还有一个联系人引用了不存在的 account_id(因为账户在另一个进程被删除)。没有约束,这些都会进入生产环境,之后破坏报表。

有了合适的数据库规则,这些写入会立刻失败,越靠近问题源头越好。必填字段不能缺失、重试时不能偷偷插入重复项、关系不能指向被删除或不存在的记录、值不能超出允许范围。

约束并不能阻止所有问题。它们不会修复糟糕的 UI、错误的折扣计算或慢查询。但它们能阻止坏数据悄然累积——这往往是“AI 生成的边缘情况 bug”变得昂贵的根源。

为什么数据库应当是你的最后防线

你的应用通常不只是一个代码库对接一个用户。典型产品有 Web 界面、移动端、管理界面、后台任务、CSV 导入,有时还有第三方集成。每条路径都可能创建或修改数据。如果每条路径都必须记住相同的规则,总会有人忘记。

数据库是所有路径共同依赖的地方。当你把它当作最终守门人时,规则会自动应用到所有写入。PostgreSQL 约束把“我们假设这总是正确的”变成“这必须为真,否则写入失败”。

AI 生成代码的情况下这点尤为重要。模型可能会在 React UI 里加上表单验证,但漏掉后台任务的一个角落。或者它能很好地处理快乐路径数据,但一旦真实客户输入意外内容就会崩溃。约束能在坏数据尝试进入时立即捕获问题,而不是在几周后你在调试奇怪报表时才发现。

跳过约束时,坏数据往往沉默无声。保存成功、应用继续,而问题在后续以支持工单、计费差异或没人信任的仪表盘显现。清理成本很高,因为你是在修复历史,而不是修复一次请求。

坏数据通常通过日常情形混入:新的客户端版本把字段作为空字符串发来而不是缺失、重试产生重复、管理后台绕过了 UI 校验、导入文件格式不一致、或者两个用户同时更新相关记录。

一个有用的思路:只接受边界处就是有效的数据。实际上,这个边界应该包含数据库,因为数据库能看到所有写入。

NOT NULL:及早阻止缺失的必需数据

NOT NULL 是最简单的 PostgreSQL 约束,却能防止一类出奇多的 bug。如果一行在没有某个值时就无法成立,就让数据库强制它存在。

NOT NULL 通常适用于标识符、必需的名称和时间戳。如果没有某字段就无法创建有效记录,就不要允许它为空。在小型 CRM 中,没有 owner 或创建时间的潜在客户不是“部分潜在客户”,而是会在后续造成怪异行为的坏数据。

在 AI 生成的代码里,NULL 更容易悄然出现,因为很容易不经意间制造“可选”路径。表单字段在 UI 中可能是可选的,API 可能接受缺失键,某个创建函数的分支可能跳过赋值。一切仍能编译通过,快乐路径测试也通过。然后真实用户导入了有空单元格的 CSV,或移动端发送了不同的载荷,NULL 就落库了。

一个好习惯是把 NOT NULL 和系统拥有字段的合理默认值结合起来:

  • created_at TIMESTAMP NOT NULL DEFAULT now()
  • status TEXT NOT NULL DEFAULT 'new'
  • is_active BOOLEAN NOT NULL DEFAULT true

默认值并非总是好主意。不要为了满足 NOT NULL 而给用户提供的字段(如 emailcompany_name)设置默认值。空字符串并不比 NULL “更有效”。它只是掩盖了问题。

当你不确定时,判断该值是“真实未知”还是代表另外一种状态。如果“尚未提供”有意义,考虑用单独的状态列而不是到处允许 NULL。例如保留 phone 可空,但增加 phone_status(如 missingrequestedverified)。这样能让含义在代码中更一致。

CHECK:把业务规则贴近数据定义

CHECK 约束是表的一个承诺:每一行每次都必须满足这个规则。它是防止边缘情况悄然创建在代码看来合理但现实不成立记录的最简单方法之一。

CHECK 约束最适合只依赖同一行内值的规则:数值范围、允许值、以及列间的简单关系。

-- 1) Totals should never be negative
ALTER TABLE invoices
  ADD CONSTRAINT invoices_total_nonnegative
  CHECK (total_cents >= 0);

-- 2) Enum-like allowed values without adding a custom type
ALTER TABLE tickets
  ADD CONSTRAINT tickets_status_allowed
  CHECK (status IN ('new', 'open', 'waiting', 'closed'));

-- 3) Date order rules
ALTER TABLE subscriptions
  ADD CONSTRAINT subscriptions_date_order
  CHECK (end_date IS NULL OR end_date >= start_date);

一个好的 CHECK 可读性强,像是对数据的文档。偏好简短表达、清晰的约束名和可预测的模式。

CHECK 并不适用于所有场景。如果规则需要查找其他行、聚合数据或跨表比较(例如“一个账户不能超过其计划限额”),就把逻辑放在应用代码、触发器或受控的后台任务中。

UNIQUE:防止日后后悔的重复

减少边缘情况错误
用以约束为先的构建流程,尽早抓住重复写入和缺失字段。

UNIQUE 规则很简单:数据库拒绝存储在被约束列(或多列组合)上值相同的两行。它能根除一整类 bug,比如“创建”路径被执行两次、发生重试,或者两名用户同时提交相同内容时产生的重复。

UNIQUE 保证你定义的精确值上没有重复。它不保证值存在(NOT NULL)、符合格式(CHECK),也不保证你的“等价”定义(大小写、空格、标点)除非你明确指定。

常见的需要唯一性的地方包括用户表的 email、来自其他系统的 external_id、或在一个账户内必须唯一的名称,如 (account_id, name)

一个注意点:NULL 与 UNIQUE。在 PostgreSQL 中,NULL 被视为“未知”,所以允许多个 NULL 值通过 UNIQUE 约束。如果你想表达“该值必须存在且唯一”,就把 UNIQUE 和 NOT NULL 结合起来。

对面向用户的标识符,一个实用的模式是大小写不敏感的唯一性。用户会输入 “[email protected]” 然后再输入 “[email protected]” 并期望它们相同。

-- Case-insensitive unique email
CREATE UNIQUE INDEX users_email_unique_ci
ON users (lower(email));

-- Unique contact name per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_name_unique UNIQUE (account_id, name);

定义“重复”对用户意味着什么(大小写、空格、按账户或全局),然后只在一处编码它,这样所有代码路径都会遵循同一规则。

FOREIGN KEY:保持关系一致性

外键声明“这行必须指向那边的真实行”。没有它,代码可能悄悄创建孤儿记录,表面上看似有效但随后破坏应用。例如:一条笔记引用已被删除的客户,或一张发票指向从未存在过的用户 ID。

当两个动作接近同时发生时(删除和创建、超时后的重试,或使用过期数据运行的后台任务),外键最能体现价值。数据库比每条应用路径都去记住检查更擅长保证一致性。

选择合适的 ON DELETE 行为

ON DELETE 选项应与关系的真实含义相匹配。问自己:“如果父对象消失,子对象还应该存在吗?”

  • RESTRICT(或 NO ACTION):如果存在子记录则阻止删除父记录。
  • CASCADE:删除父记录时同时删除子记录。
  • SET NULL:保留子记录,但移除关联。

CASCADE 要小心。它可能是正确的,但也可能在某个 bug 或管理员操作删除父记录时擦除超出预期的数据。

多租户模式:在模式中显式建模所有权

在多租户应用中,外键不仅关乎正确性,也能防止跨账户数据泄漏。常见做法是在每个租户拥有的表中包含 account_id 并通过它来建立关系。

CREATE TABLE contacts (
  account_id bigint NOT NULL,
  id bigint GENERATED ALWAYS AS IDENTITY,
  PRIMARY KEY (account_id, id)
);

CREATE TABLE notes (
  account_id bigint NOT NULL,
  id bigint GENERATED ALWAYS AS IDENTITY,
  contact_id bigint NOT NULL,
  body text NOT NULL,
  PRIMARY KEY (account_id, id),
  FOREIGN KEY (account_id, contact_id)
    REFERENCES contacts (account_id, id)
    ON DELETE RESTRICT
);

这会在模式中强制“谁拥有什么”:即使应用代码(或 LLM 生成的查询)尝试,也不能让注释指向不同账户下的联系人。

分步进行:在不破坏生产的情况下添加约束

从模式到部署
当约束和验证一致时,一次部署能更快上线。

先写下一小段不变量:那些必须永远为真的事实。把它们写清楚。“每个联系人必须有 email。” “状态必须是几种允许值之一。” “发票必须属于一个真实客户。” 这些就是你想让数据库每次强制执行的规则。

将变更以小迁移的方式逐步上线,避免生产被突袭:

  • 以非破坏性方式先添加新列或规则。
  • 分批回填现有行。
  • 清理坏数据(去重、修复无效值)或将其隔离以便审查。
  • 强制规则(NOT NULLUNIQUECHECKFOREIGN KEY)。
  • 收紧应用行为,使错误处理更清晰。

棘手的部分是现有的坏数据。为此做计划。对于重复项,挑选胜出行并合并其余行,保留小的审计记录。对于缺失的必需字段,仅在确实安全时选择默认值;否则隔离处理。对于损坏的关系,要么把子行重新分配给正确的父行,要么移除这些坏行。

每次迁移后,用几次应该失败的写入来验证:插入一行缺失必需值、插入重复键、插入越界值、插入引用不存在父行的记录。写入失败是有价值的信号,它们会指出应用曾默许的“尽力而为”行为。

一个现实的例子:保持清洁的小型 CRM

想象一个小型 CRM:accounts(你的 SaaS 客户)、他们合作的公司、这些公司中的联系人,以及与公司关联的交易(deals)。

这恰好是人们常用聊天工具快速生成的应用。演示时看着没问题,但真实数据很快会变脏。两个常见早期 bug:联系人重复(相同 email 以稍微不同的方式录入),以及某些交易在没有 company_id 的情况下被创建,因为某条代码路径忘了设置 company_id。另一个经典问题是在重构或解析错误后出现的负数交易金额。

解决方法不是更多的 if 语句,而是一些恰当选择的约束,让坏数据无法写入。

保持 CRM 清洁的约束

-- Contacts: prevent duplicates per account
ALTER TABLE contacts
  ADD CONSTRAINT contacts_account_email_uniq UNIQUE (account_id, email);

-- Deals: require a company and keep the relationship valid
ALTER TABLE deals
  ALTER COLUMN company_id SET NOT NULL,
  ADD CONSTRAINT deals_company_fk
    FOREIGN KEY (company_id) REFERENCES companies(id);

-- Deals: deal value cannot be negative
ALTER TABLE deals
  ADD CONSTRAINT deals_value_nonneg CHECK (deal_value >= 0);

-- A few obvious required fields
ALTER TABLE companies
  ALTER COLUMN name SET NOT NULL;
ALTER TABLE contacts
  ALTER COLUMN email SET NOT NULL;

这并不是为了严格而严格。你是在把模糊的预期变成数据库每次都能强制执行的规则,无论应用的哪个部分在写数据。

这些约束上线后应用会怎样变化

一旦这些约束到位,应用会更简单。许多试图事后检测重复的防御性检查可以移除。失败会更清晰且可操作(例如“该账户已存在此 email”而不是奇怪的下游行为)。当生成的 API 路由忘了某个字段或错误处理了值时,写入会立即失败,而不是默默破坏数据库。

常见错误会让约束变得令人痛苦

用约束构建应用
在设计时考虑 Postgres 规则,让数据库尽早阻止坏数据。

约束在符合业务实际时最有效。大多数痛苦来自于添加当下看起来“安全”但后来变成惊喜的规则。

一个常见的坑是到处使用 ON DELETE CASCADE。它看起来干净利落,直到有人删除了父记录,数据库删除了系统中大量数据。对真正被视为“从属且不能单独存在”的数据(比如草稿行项)使用级联也许没问题,但对客户、发票、工单这些重要记录则很危险。如果不确定,优先用 RESTRICT 并有目的地处理删除操作。

另一个问题是写了过窄的 CHECK 规则。“status 必须是 'new'、'won' 或 'lost'”听起来没问题,直到你需要 'paused' 或 'archived'。一个好的 CHECK 描述的是稳定真相,而不是临时的 UI 选项。“amount >= 0”不易过时,而“country in (...)”则常常会过时。

当团队在生成代码后再加约束时,常见问题包括:

  • CASCADE 当作清理工具,结果删除了超出预期的数据。
  • 写了过窄的 CHECK,阻止了未来有效的情况。
  • 假设 UNIQUE 在涉及 NULL 时能阻止重复。
  • 更改规则却没有计划去修复已经违反这些规则的行。

关于性能:PostgreSQL 会为 UNIQUE 自动创建索引,但外键并不会自动为引用列创建索引。没有这个索引时,父表的更新和删除会很慢,因为 Postgres 必须扫描子表来检查引用。

在收紧规则之前,先找出那些会失败的现有行,决定是修复还是隔离它们,并分步推出变更。

快速清单和下一步建议

上线前,每张表花五分钟写下必须始终为真的东西。如果你能用简单的英语说清楚,通常就能用约束强制执行。

对每张表问自己:

  • 哪些字段绝不应该为 NULL?
  • 哪些字段绝不应该重复?
  • 哪些字段绝不能为负或超出范围?
  • 哪些记录不能没有父对象(没有孤儿行)?
  • 哪些应该始终遵循简单规则(允许状态、开始在结束之前)?

如果你使用聊天驱动的构建工具,请把这些不变量当作数据的验收标准,而不是可选注释。例如: “交易金额必须为非负”、“联系人的邮箱在工作区内唯一”、“任务必须引用真实联系人”。规则越明确,意外边缘情况的空间就越小。

Koder.ai (koder.ai) 包含诸如规划模式、快照与回滚、源码导出等功能,这能让你在收紧约束时更安全地迭代模式变更。

一个在真实团队中有效的简单上线模式:选择一个高价值表(users、orders、invoices、contacts),添加 1-2 条能防止最严重失败的约束(通常是 NOT NULLUNIQUE),修复那些写入失败的点,然后重复这一流程。逐步收紧规则比一次性做大而冒险的迁移更稳妥。

常见问题

为什么应该把数据规则放在 PostgreSQL 中,而不只放在应用里?

将必须始终成立的规则放在 PostgreSQL 中,然后让应用的每条路径都遵守这些规则。UI 和 API 验证可以提供友好的提示,而约束会阻止导入、任务、重试和管理工具写入无效数据。

什么时候应该使用 NOT NULL?

当一行数据没有某个值就没有意义时,使用 NOT NULL,例如所有者 ID、名称或创建时间。不要用空字符串代替缺失的用户输入,因为这会掩盖错误。

CHECK 约束适合做什么?

CHECK 用于单行内的规则,例如 amount >= 0、允许的状态,或结束日期晚于开始日期。让规则保持简短且稳定,避免未来的产品变更把有效数据变成错误。

UNIQUE 也会阻止缺失值吗?

当重复会造成实际问题时,添加 UNIQUE,例如重复的外部 ID,或同一账户中有两个联系人的邮箱相同。如果每条记录都必须提供该值,请结合 NOT NULL 使用,因为 PostgreSQL 在标准唯一约束下允许多个 NULL。

外键如何防止孤立记录?

外键会让数据库拒绝引用不存在父记录的子行。它能防止孤立记录,例如没有有效公司的交易,或关联到已删除客户的备注。

我应该到处使用 ON DELETE CASCADE 吗?

当删除父记录需要经过慎重决定时,先使用 RESTRICT。只有当子记录脱离父记录后确实毫无价值时才选择 CASCADE,例如临时草稿项。

如何阻止仅大小写不同的重复邮箱?

当用户认为不同字母大小写表示同一个邮箱时,对规范化后的值创建唯一索引,例如 lower(email)。也要在应用中去除首尾空格并规范化输入,这样用户能看到更清晰的错误提示。

如何安全地为生产数据库添加约束?

先找出违反新规则的行。分批修复、合并或隔离这些数据,然后添加约束,并更新应用,在写入失败时显示有用的错误信息。

外键需要索引吗?

唯一约束会创建自己的索引,但外键不会自动为子列建立索引。当表很大,或父记录经常被更新或删除时,请在引用列上添加索引。

我应该优先添加哪些约束?

从一张影响资金、客户或身份信息的表开始。添加一两条价值明确的规则,例如必填的所有者、每个账户唯一的邮箱或非负金额,然后测试那些应该失败的写入操作。

Related posts