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

AI 生成的代码常常看起来没问题,因为它处理了“快乐路径”。真实的应用会在脏数据的中间地带失败:表单提交了空字符串而不是 null、后台任务重试导致创建了重复记录、或者删除操作移除了父行却留下了子行。这些并非奇怪的 bug。它们会表现为必填字段为空、被标为“唯一”的值重复、以及指向不存在对象的孤儿行。
这些问题也容易通过代码审查和基本测试,因为审查者读的是意图而不是每个边缘情况。测试通常只覆盖一些典型例子,而不是数周的真实用户行为、CSV 导入、不稳定的网络重试或并发请求。如果是助手生成了代码,它可能会漏掉一些小但关键的检查,比如修剪空白、校验范围或防止竞态条件。
“先约束,后代码”意味着把不可谈判的规则放到数据库里,这样无论哪个代码路径尝试写入,都无法存入坏数据。你的应用仍然应该在输入层做验证以便返回更友好的错误信息,但数据库承担最后的事实判定。这就是 PostgreSQL 约束的强项:它们能保护你避免整类错误。
举个快速例子:想象一个小型 CRM。一个 AI 生成的导入脚本创建联系人。有一行的 email 是 ""(空字符串)、两行 email 大小写不同却相同、还有一个联系人引用了不存在的 account_id(因为账户在另一个进程被删除)。没有约束,这些都会进入生产环境,之后破坏报表。
有了合适的数据库规则,这些写入会立刻失败,越靠近问题源头越好。必填字段不能缺失、重试时不能偷偷插入重复项、关系不能指向被删除或不存在的记录、值不能超出允许范围。
约束并不能阻止所有问题。它们不会修复糟糕的 UI、错误的折扣计算或慢查询。但它们能阻止坏数据悄然累积——这往往是“AI 生成的边缘情况 bug”变得昂贵的根源。
你的应用通常不只是一个代码库对接一个用户。典型产品有 Web 界面、移动端、管理界面、后台任务、CSV 导入,有时还有第三方集成。每条路径都可能创建或修改数据。如果每条路径都必须记住相同的规则,总会有人忘记。
数据库是所有路径共同依赖的地方。当你把它当作最终守门人时,规则会自动应用到所有写入。PostgreSQL 约束把“我们假设这总是正确的”变成“这必须为真,否则写入失败”。
AI 生成代码的情况下这点尤为重要。模型可能会在 React UI 里加上表单验证,但漏掉后台任务的一个角落。或者它能很好地处理快乐路径数据,但一旦真实客户输入意外内容就会崩溃。约束能在坏数据尝试进入时立即捕获问题,而不是在几周后你在调试奇怪报表时才发现。
跳过约束时,坏数据往往沉默无声。保存成功、应用继续,而问题在后续以支持工单、计费差异或没人信任的仪表盘显现。清理成本很高,因为你是在修复历史,而不是修复一次请求。
坏数据通常通过日常情形混入:新的客户端版本把字段作为空字符串发来而不是缺失、重试产生重复、管理后台绕过了 UI 校验、导入文件格式不一致、或者两个用户同时更新相关记录。
一个有用的思路:只接受边界处就是有效的数据。实际上,这个边界应该包含数据库,因为数据库能看到所有写入。
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 而给用户提供的字段(如 email 或 company_name)设置默认值。空字符串并不比 NULL “更有效”。它只是掩盖了问题。
当你不确定时,判断该值是“真实未知”还是代表另外一种状态。如果“尚未提供”有意义,考虑用单独的状态列而不是到处允许 NULL。例如保留 phone 可空,但增加 phone_status(如 missing、requested、verified)。这样能让含义在代码中更一致。
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 规则很简单:数据库拒绝存储在被约束列(或多列组合)上值相同的两行。它能根除一整类 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);
定义“重复”对用户意味着什么(大小写、空格、按账户或全局),然后只在一处编码它,这样所有代码路径都会遵循同一规则。
外键声明“这行必须指向那边的真实行”。没有它,代码可能悄悄创建孤儿记录,表面上看似有效但随后破坏应用。例如:一条笔记引用已被删除的客户,或一张发票指向从未存在过的用户 ID。
当两个动作接近同时发生时(删除和创建、超时后的重试,或使用过期数据运行的后台任务),外键最能体现价值。数据库比每条应用路径都去记住检查更擅长保证一致性。
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 NULL、UNIQUE、CHECK、FOREIGN KEY)。棘手的部分是现有的坏数据。为此做计划。对于重复项,挑选胜出行并合并其余行,保留小的审计记录。对于缺失的必需字段,仅在确实安全时选择默认值;否则隔离处理。对于损坏的关系,要么把子行重新分配给正确的父行,要么移除这些坏行。
每次迁移后,用几次应该失败的写入来验证:插入一行缺失必需值、插入重复键、插入越界值、插入引用不存在父行的记录。写入失败是有价值的信号,它们会指出应用曾默许的“尽力而为”行为。
想象一个小型 CRM:accounts(你的 SaaS 客户)、他们合作的公司、这些公司中的联系人,以及与公司关联的交易(deals)。
这恰好是人们常用聊天工具快速生成的应用。演示时看着没问题,但真实数据很快会变脏。两个常见早期 bug:联系人重复(相同 email 以稍微不同的方式录入),以及某些交易在没有 company_id 的情况下被创建,因为某条代码路径忘了设置 company_id。另一个经典问题是在重构或解析错误后出现的负数交易金额。
解决方法不是更多的 if 语句,而是一些恰当选择的约束,让坏数据无法写入。
-- 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 路由忘了某个字段或错误处理了值时,写入会立即失败,而不是默默破坏数据库。
约束在符合业务实际时最有效。大多数痛苦来自于添加当下看起来“安全”但后来变成惊喜的规则。
一个常见的坑是到处使用 ON DELETE CASCADE。它看起来干净利落,直到有人删除了父记录,数据库删除了系统中大量数据。对真正被视为“从属且不能单独存在”的数据(比如草稿行项)使用级联也许没问题,但对客户、发票、工单这些重要记录则很危险。如果不确定,优先用 RESTRICT 并有目的地处理删除操作。
另一个问题是写了过窄的 CHECK 规则。“status 必须是 'new'、'won' 或 'lost'”听起来没问题,直到你需要 'paused' 或 'archived'。一个好的 CHECK 描述的是稳定真相,而不是临时的 UI 选项。“amount >= 0”不易过时,而“country in (...)”则常常会过时。
当团队在生成代码后再加约束时,常见问题包括:
CASCADE 当作清理工具,结果删除了超出预期的数据。关于性能:PostgreSQL 会为 UNIQUE 自动创建索引,但外键并不会自动为引用列创建索引。没有这个索引时,父表的更新和删除会很慢,因为 Postgres 必须扫描子表来检查引用。
在收紧规则之前,先找出那些会失败的现有行,决定是修复还是隔离它们,并分步推出变更。
上线前,每张表花五分钟写下必须始终为真的东西。如果你能用简单的英语说清楚,通常就能用约束强制执行。
对每张表问自己:
如果你使用聊天驱动的构建工具,请把这些不变量当作数据的验收标准,而不是可选注释。例如: “交易金额必须为非负”、“联系人的邮箱在工作区内唯一”、“任务必须引用真实联系人”。规则越明确,意外边缘情况的空间就越小。
Koder.ai (koder.ai) 包含诸如规划模式、快照与回滚、源码导出等功能,这能让你在收紧约束时更安全地迭代模式变更。
一个在真实团队中有效的简单上线模式:选择一个高价值表(users、orders、invoices、contacts),添加 1-2 条能防止最严重失败的约束(通常是 NOT NULL 和 UNIQUE),修复那些写入失败的点,然后重复这一流程。逐步收紧规则比一次性做大而冒险的迁移更稳妥。