13.1 先决定逻辑放在哪里
写函数之前,先写一句可以被反驳的责任声明:
这条规则必须位于数据库,因为……
如果理由只是“这样少写几行应用代码”或“数据库更快”,先停下来。逻辑位置 决定的不只是延迟,还决定谁能绕过规则、谁负责版本兼容、错误怎样传播、 副作用何时提交、故障在哪里观测。
本节给出一个从声明式机制向外扩展的决策顺序。
13.1.1 数据不变量、批处理与接口封装
从最窄、最声明式的机制开始
同一条规则可能有多种写法:
-- 声明式
total_minor bigint NOT NULL CHECK (total_minor > 0)
-- 触发式
CREATE TRIGGER validate_total
BEFORE INSERT OR UPDATE ON sales_order
FOR EACH ROW EXECUTE FUNCTION validate_total();
-- 命令式
IF p_total_minor <= 0 THEN
RAISE EXCEPTION ...;
END IF;三者都能拒绝负数,但并不等价。CHECK:
- 对所有普通写入口生效;
- 由系统目录公开表达;
- 能被 schema diff、dump、迁移工具和错误字段识别;
- 不需要人为维护触发器执行顺序;
- 让 PostgreSQL 自己生成稳定的约束拒绝。
因此第一条规则是:
能由类型、
NOT NULL、CHECK、UNIQUE、FOREIGN KEY或EXCLUDE正确表达的规则,不先写触发器。
“正确表达”也有限制。PostgreSQL 假定 CHECK 对同一行是不可变判断;
它不会持续重新检查约束表达式引用的其他行。跨行、跨表查询不应伪装成
普通 CHECK。这类语义要重新建模、用原生唯一/引用约束,或在确实必要时
进入事务逻辑。参见 Constraints。
用规则形状选工具
| 规则形状 | 首选位置 | 典型例子 |
|---|---|---|
| 单值合法域 | 类型、NOT NULL、CHECK | 金额为正、状态枚举 |
| 行内列关系 | CHECK、生成列 | end_at >= start_at |
| 候选键 | PRIMARY KEY、UNIQUE | 外部请求号唯一 |
| 引用关系 | FOREIGN KEY | 明细必须属于订单 |
| 范围互斥 | EXCLUDE | 同资源预订时间不重叠 |
| 集合变换 | 一条集合 SQL | 批量改价、聚合回填 |
| 旧行到新行的边 | 条件更新或 BEFORE ROW trigger | 状态只能沿有限图变化 |
| 事务最终状态 | 延迟约束或 constraint trigger | 支付总额与 paid 状态一致 |
| 窄数据库命令 | function | 以 expected version 取消订单 |
| 多批次维护 | procedure / 外部 worker | 每 5000 行提交一次 |
| HTTP、邮件、消息消费 | 应用 + outbox | 提交后通知其他系统 |
| 何时运行 | scheduler / 平台 | 每日归档、周期巡检 |
表里的“首选”不是绝对答案,而是评审起点。每次偏离都要留下理由和测试。
批处理先问能否是一条 SQL
PL/pgSQL 循环很直观:
FOR target IN
SELECT order_id FROM sales_order WHERE ...
LOOP
UPDATE sales_order
SET status = 'expired'
WHERE order_id = target.order_id;
END LOOP;但一条集合更新通常更清楚:
UPDATE sales_order
SET status = 'expired'
WHERE status = 'created'
AND created_at < $1;集合 SQL 给优化器更多空间,也避免一次业务动作产生 N 次解析、执行和触发 边界。只有当批次需要独立提交、外部节流、checkpoint、队列竞争或每项错误 隔离时,才进入过程或外部 worker。即使如此,每一批内部仍应尽量使用集合 SQL。
function 是接口,不是代码收纳箱
把 SQL 包进 function 只有在形成明确合同后才有意义:
name + input types
-> privilege
-> transaction and lock behavior
-> result shape
-> SQLSTATE set
-> observable identity
-> compatible replacement / rollback本章的应用角色没有 shop_ch13.sales_order 的 SELECT 或 UPDATE,
只得到:
GRANT EXECUTE ON FUNCTION
shop_ch13.order_snapshot(bigint)
TO pg36_app;
GRANT EXECUTE ON FUNCTION
shop_ch13.transition_order(bigint, bigint, text, text)
TO pg36_app;这是真正的接口封装:底表权限被拿走,函数签名、结果和错误成为协议。若应用 仍有任意底表 DML,函数往往只是可选的便利封装,不能被宣称为唯一护栏。
一次决策走查
对“订单进入 paid 前必须完成支付”逐层判断:
status属于有限集合:CHECK;created → paid是旧行到新行的边:条件更新或 transition guard;- 捕获金额等于订单金额:跨
sales_order/payment的事务最终断言; - 应用要原子完成插支付和改状态:command function 或应用事务;
- 支付成功后通知履约:同事务写 outbox;
- 调用远端履约 API:提交后由 worker 执行。
不同部分由不同机制负责,不必强迫一条“业务规则”只有一个物理位置。
13.1.2 数据库内聚与应用可演进性的权衡
“把规则放近数据”能减少绕过路径;“把流程放在应用”能获得更好的协议演进 和跨系统编排。真正的权衡不是数据库与应用谁更强,而是变化与失败在哪一层 最容易被控制。
六个评审维度
1. 覆盖所有写入口
如果写入来自 API、ETL、管理脚本、批处理和多个语言栈,数据库约束覆盖面 最大。只在一个应用 handler 中校验,其他入口可能绕过。
但覆盖面也有前提:
- 超级用户、表 owner 和复制/恢复路径拥有更高能力;
session_replication_role等管理开关会改变触发行为;- 逻辑复制默认重放的是行变化,不是在订阅端重新执行发布端所有业务逻辑;
- 管理员仍可能删除或禁用对象。
所以“数据库保证”是权限与部署合同下的保证,不是对所有特权行为的魔法。
2. 并发仲裁
唯一性、引用完整性、行锁和 MVCC 由 PostgreSQL 掌握最终事实。应用先
SELECT 再判断通常有竞态;原子条件写、唯一约束或数据库事务更可靠。
但触发器也不会自动解决并发:
T1 reads aggregate A
T2 reads aggregate A
T1 writes based on A
T2 writes based on A如果规则依赖聚合或多行集合,仍要设计锁顺序、隔离级别、唯一仲裁点或整 事务重试。deferred trigger 只是晚检查,不等于串行化。
3. 发布耦合
数据库函数签名和触发器行为是应用依赖。变更时要回答:
- 旧应用与新函数能否共存?
- 默认参数是否改变调用解析?
- 返回列新增、删除、改名会不会破坏驱动映射?
- trigger 在 expand 阶段会不会让旧写入失败?
- function replacement 会不会拿到等待中的对象锁?
- 回退应用时,旧数据库行为是否还兼容?
第 11 章的 expand/migrate/validate/switch 思路同样适用于例程:先增加兼容 能力,再迁移调用,观察后才收缩旧接口。
4. 调试与可见性
应用调用链通常天然有 trace、请求参数、部署版本和统一日志。数据库函数 可能只在 SQL 文本中显示为一次调用;触发器甚至不出现在原始业务 SQL 里。
如果选择数据库端逻辑,必须补回:
- 稳定 function/trigger identity;
- 低基数
application_name; - SQLSTATE、约束名和 routine context;
pg_stat_user_functions或事务级计数;pg_stat_statements、慢日志与 lock/wait 证据;- 业务 actor、request/trace ID 的安全关联。
看不见的正确逻辑,在事故中仍然是风险。
5. 团队所有权
例程不是“DBA 的代码”或“开发的 SQL”。需要明确:
- 谁评审业务语义;
- 谁评审权限与
search_path; - 谁维护迁移顺序;
- 谁运行负面和并发测试;
- 谁响应慢调用或递归事故;
- 谁批准回退。
所有权不清时,隐藏自动行为尤其危险。
6. 可移植性
PL/pgSQL、transition table、constraint trigger、SECURITY DEFINER 和
过程事务控制都有 PostgreSQL 特定语义。若产品确实要求多数据库运行,
应用实现可能更易移植。
反过来,为不存在的迁移目标牺牲当前数据库的原生正确性也没有价值。把 “未来也许换库”转化为明确概率、成本和退出计划,而不是口号。
一个可执行评分卡
对候选规则逐项打分:
| 问题 | 是 | 否 |
|---|---|---|
| 是否必须覆盖多个写入口? | 倾向数据库 | 倾向应用 |
| 是否依赖 PostgreSQL 并发仲裁? | 倾向数据库 | 中性 |
| 能否由原生约束声明? | 用约束 | 继续判断 |
| 是否包含远端 I/O? | 留应用/outbox | 继续判断 |
| 是否需要跨事务分批提交? | procedure/worker | function/SQL |
| 是否需要请求级 trace 与复杂协议? | 倾向应用 | 中性 |
| 数据库对象能否独立版本化与测试? | 可以进入 | 先补工程能力 |
| 失败能否用 SQLSTATE 和不变量验收? | 可以进入 | 不应隐藏 |
评分卡不替团队做决定;它迫使理由显式化。
推荐的职责声明
本章实验采用:
database owns:
state domain
legal transition edges
version step
payment/order commit-time invariant
history and statement audit in the same transaction
application owns:
authentication and authorization context
command choice
optimistic-conflict retry policy
API response
outbox consumption and external side effects
Pigsty/platform owns:
role/database declaration
primary routing and pooling
secret delivery
metrics/logs/alerts
scheduled invocation and overlap prevention这份声明比“业务逻辑在数据库”精确得多。
13.1.3 不用触发器隐藏跨系统工作流
触发器与原语句同步成败
普通 DML trigger 在触发它的语句和事务中执行。触发函数报错,原语句也 失败;事务回滚,触发器写入也回滚。这正适合:
- 派生同数据库内的审计行;
- 验证
OLD → NEW; - 同事务维护局部冗余;
- 写入 outbox 事实。
它不适合直接完成:
- HTTP 请求;
- 发邮件;
- 发 Kafka/RabbitMQ 消息后等待确认;
- 调用支付或履约系统;
- 写入另一个无法参与同一 PostgreSQL 事务的数据源。
这些动作不具备与 PostgreSQL 提交相同的原子边界。
“触发器里调用 HTTP”为什么会失败
假设触发器同步调用远端服务:
UPDATE order
-> trigger calls remote API
-> remote succeeds
-> PostgreSQL COMMIT fails外部动作已经发生,数据库却回滚。反过来:
UPDATE order
-> remote times out
-> unknown whether remote succeeded
-> database transaction holds locks while waiting此时重试可能重复副作用,数据库连接、行锁和事务快照还被远程尾延迟拖住。 把网络调用包装成 extension function 并不会改变分布式事务事实。
正确边界:同事务写 outbox
第 12 章使用:
BEGIN;
UPDATE order ...;
INSERT INTO outbox (...);
COMMIT;数据库只保证“状态与待发布事实一起提交”。提交后 worker:
- 读取/领取 outbox;
- 调用外部系统;
- 使用幂等键处理至少一次投递;
- 记录成功、失败、重试与死信;
- 暴露 backlog、age 和错误指标。
这不是把分布式问题消掉,而是把不可控的同步双写改造成可恢复状态机。
NOTIFY 也不是 durable queue
LISTEN/NOTIFY 适合低延迟提示,但通知不是持久任务队列。消费者断开、事务
提交边界、payload 限制与处理确认都需要额外设计。可靠工作仍应以表中
durable fact 为准,通知只用于“醒来看看”。
不把 scheduler 藏进 procedure
procedure 只定义“被调用时做什么”。它不会决定:
- 每天几点执行;
- failover 后由哪台 primary 执行;
- 上一轮未结束是否跳过;
- 失败重试几次;
- 超期多久告警;
- 如何暂停、补跑和审计。
这些属于 pg_cron、OS cron、systemd timer、作业平台或应用 worker。
数据库过程可以是 job body,但不是 job control plane。
进入触发器前的停止线
若候选触发器满足任一项,先重新设计:
- 发起远端 I/O;
- 吞掉异常后继续提交;
- 根据 wall-clock 或不稳定配置伪装为
IMMUTABLE; - 每行再次扫描整张大表;
- 修改触发表并依赖
pg_trigger_depth()阻止递归; - 依赖另一个同类 trigger 的名字顺序才能正确;
- 失败没有稳定 SQLSTATE;
- 无法在绕过应用的 SQL 下测试;
- 无法说明 bulk load 的放大倍数;
- 无法提供停用、兼容和回退方案。
触发器的价值是让数据库不变量覆盖所有写入口;一旦它变成隐藏工作流引擎, 这个优势很快会被运维风险抵消。
本节结论
选择逻辑位置时按以下顺序停靠:
declarative constraint
-> set-based SQL
-> explicit application transaction
-> narrow function boundary
-> trigger for unavoidable implicit invariant
-> procedure for controlled multi-transaction maintenance
-> external worker/scheduler for cross-system lifecycle不是每条规则都必须走到最后。成熟设计往往在最早能够正确表达的位置停止。