跳至内容

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/workerfunction/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 函数 · 查看全书目录 · 查看索引中心

最后更新于