跳转到主要内容

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 NULLCHECKUNIQUEFOREIGN KEYEXCLUDE 正确表达的规则,不先写触发器。

“正确表达”也有限制。PostgreSQL 假定 CHECK 对同一行是不可变判断; 它不会持续重新检查约束表达式引用的其他行。跨行、跨表查询不应伪装成 普通 CHECK。这类语义要重新建模、用原生唯一/引用约束,或在确实必要时 进入事务逻辑。参见 Constraints

用规则形状选工具

规则形状 首选位置 典型例子
单值合法域 类型、NOT NULLCHECK 金额为正、状态枚举
行内列关系 CHECK、生成列 end_at >= start_at
候选键 PRIMARY KEYUNIQUE 外部请求号唯一
引用关系 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_orderSELECTUPDATE, 只得到:

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 前必须完成支付”逐层判断:

  1. status 属于有限集合:CHECK
  2. created → paid 是旧行到新行的边:条件更新或 transition guard;
  3. 捕获金额等于订单金额:跨 sales_order/payment 的事务最终断言;
  4. 应用要原子完成插支付和改状态:command function 或应用事务;
  5. 支付成功后通知履约:同事务写 outbox;
  6. 调用远端履约 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:

  1. 读取/领取 outbox;
  2. 调用外部系统;
  3. 使用幂等键处理至少一次投递;
  4. 记录成功、失败、重试与死信;
  5. 暴露 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

不是每条规则都必须走到最后。成熟设计往往在最早能够正确表达的位置停止。


返回本章目录 · 下一节:SQL 与 PL/pgSQL 函数 · 查看全书目录 · 查看索引中心