PostgreSQL CHECK 用函数统计项目数量,验证通过后仍能超额
CHECK调用函数读取其他行,验证通过后仍可超出项目额度。区分行内检查和跨行规则,核对函数、并发入口及事务设计。
给项目表加了数量限制,VALIDATE CONSTRAINT 也通过了,之后仍能插入超额项目。约束定义看起来像这样:
CHECK (project_count(organization_id) <= org_limit(organization_id))
两个函数分别查询项目数量和组织额度。即使数据库接受了这个定义,它也不能持续保证“每个组织的项目总数不超过额度”。PostgreSQL 的 CHECK 按正在插入或更新的行检查,并假定同一行输入始终得到相同结果;其他行和另一张表改变后,这个假定就不成立了。
下面按 PostgreSQL 18 的普通表规则说明。重点是跨行约束的设计,不能把它当作 SQL 函数整体失效,也不能用重新验证一次来代替配额控制。
把约束和函数定义一起看
在实际写入的数据库中查询约束。示例项目表为 public.projects:
SELECT conname, convalidated, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = to_regclass('public.projects')
AND contype = 'c';
再检查被调用函数的实际定义,例如在 psql 中使用:
\sf public.project_count(integer)
\sf public.org_limit(integer)
函数名、schema 和参数类型必须与实际定义一致;有重载时,不能只看同名的另一个函数。如果函数从项目表读取 count(*),或从组织表读取可变的额度,它的结果已经依赖当前行之外的数据。代码中的函数声明与数据库现存定义也应对照。
convalidated = true 表示约束已经完成验证,不表示数据库会在任何其他行改变时重新检查它。对已经有效的约束再次执行 VALIDATE CONSTRAINT,不会重新扫描所有行。
为什么单次检查会放行超额写入
按这种设计,约束计算的是检查时能读取到的项目集合。对于新增行,不能假定查询总数已经包括将要插入的项目;更不能假定两个并发连接会看到彼此尚未提交的新增记录。
例如额度是 1、当前已经有 1 个项目,只比较 project_count(...) <= 1 就可能在新项目实际成为表内记录前得到真。把比较改为 < 可以改变这次判断,却仍没有解决并发写入和额度变更的问题。
组织表的额度下调也不会自动触发项目表每一行的 CHECK。把查表函数声明成 IMMUTABLE 无法改变这些事实,只会给数据库一个不真实的承诺。删除再添加 CHECK 也只是重新检查某一时刻的数据,还会带来锁与约束变更风险。
这与 CHECK NOT VALID 后验证历史数据失败 不同:那类问题是在检查旧行是否满足稳定的行内条件;这里要维护的是会随其他记录变化的总量规则。
将额度检查放到完整写入流程中
如果规则可以用唯一约束、外键或排他约束准确表达,应优先使用对应约束。但“每个组织最多 N 个项目”通常还需要设计事务流程,不能只换一种约束名称。
可以评估由统一写入入口或触发器处理配额,再结合按组织串行处理、锁定相关记录或其他经过验证的并发方案。单独写一个“先 COUNT,再 INSERT”的触发器,仍可能让并发请求同时通过检查;是否安全取决于实际锁、事务隔离和所有入口,而不是函数放在应用还是数据库里。
设计还应覆盖删除项目、移动项目到另一组织、导入和额度修改。若维护计数器,需要处理这些入口及失败回滚,避免计数与真实数据偏离。不能只修复普通新增接口,却留下批量导入绕过规则。
改动前保留表、函数和约束定义,以及受影响记录的备份。先查出已有超额组织,按业务决定调整额度、修正归属还是处理项目,不要为了让验证通过直接删除项目。
在隔离副本中验收新流程时,应同时覆盖额度内新增、超额拒绝、两个并发请求、额度下调、导入与事务回滚。完成这些检查后再替换旧 CHECK;回退代码或约束定义不会自动恢复已删除的数据,也不会撤销已经提交的额度变更。


