# 先决定逻辑放在哪里

LLMS 索引： [llms.txt](/llms.txt)

---

写函数之前，先写一句可以被反驳的责任声明：

> 这条规则必须位于数据库，因为……

如果理由只是“这样少写几行应用代码”或“数据库更快”，先停下来。逻辑位置
决定的不只是延迟，还决定谁能绕过规则、谁负责版本兼容、错误怎样传播、
副作用何时提交、故障在哪里观测。

本节给出一个从声明式机制向外扩展的决策顺序。

## 13.1.1 数据不变量、批处理与接口封装 {#item-13-1-1}

### 从最窄、最声明式的机制开始

同一条规则可能有多种写法：

```sql
-- 声明式
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](https://www.postgresql.org/docs/18/ddl-constraints.html)。

### 用规则形状选工具

| 规则形状 | 首选位置 | 典型例子 |
|---|---|---|
| 单值合法域 | 类型、`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 循环很直观：

```plpgsql
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;
```

但一条集合更新通常更清楚：

```sql
UPDATE sales_order
SET status = 'expired'
WHERE status = 'created'
  AND created_at < $1;
```

集合 SQL 给优化器更多空间，也避免一次业务动作产生 N 次解析、执行和触发
边界。只有当批次需要独立提交、外部节流、checkpoint、队列竞争或每项错误
隔离时，才进入过程或外部 worker。即使如此，每一批内部仍应尽量使用集合
SQL。

### function 是接口，不是代码收纳箱

把 SQL 包进 function 只有在形成明确合同后才有意义：

```text
name + input types
  -> privilege
  -> transaction and lock behavior
  -> result shape
  -> SQLSTATE set
  -> observable identity
  -> compatible replacement / rollback
```

本章的应用角色没有 `shop_ch13.sales_order` 的 `SELECT` 或 `UPDATE`，
只得到：

```sql
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 数据库内聚与应用可演进性的权衡 {#item-13-1-2}

“把规则放近数据”能减少绕过路径；“把流程放在应用”能获得更好的协议演进
和跨系统编排。真正的权衡不是数据库与应用谁更强，而是变化与失败在哪一层
最容易被控制。

### 六个评审维度

#### 1. 覆盖所有写入口

如果写入来自 API、ETL、管理脚本、批处理和多个语言栈，数据库约束覆盖面
最大。只在一个应用 handler 中校验，其他入口可能绕过。

但覆盖面也有前提：

- 超级用户、表 owner 和复制/恢复路径拥有更高能力；
- `session_replication_role` 等管理开关会改变触发行为；
- 逻辑复制默认重放的是行变化，不是在订阅端重新执行发布端所有业务逻辑；
- 管理员仍可能删除或禁用对象。

所以“数据库保证”是权限与部署合同下的保证，不是对所有特权行为的魔法。

#### 2. 并发仲裁

唯一性、引用完整性、行锁和 MVCC 由 PostgreSQL 掌握最终事实。应用先
`SELECT` 再判断通常有竞态；原子条件写、唯一约束或数据库事务更可靠。

但触发器也不会自动解决并发：

```text
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 和不变量验收？ | 可以进入 | 不应隐藏 |

评分卡不替团队做决定；它迫使理由显式化。

### 推荐的职责声明

本章实验采用：

```text
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 不用触发器隐藏跨系统工作流 {#item-13-1-3}

### 触发器与原语句同步成败

普通 DML trigger 在触发它的语句和事务中执行。触发函数报错，原语句也
失败；事务回滚，触发器写入也回滚。这正适合：

- 派生同数据库内的审计行；
- 验证 `OLD → NEW`；
- 同事务维护局部冗余；
- 写入 outbox 事实。

它不适合直接完成：

- HTTP 请求；
- 发邮件；
- 发 Kafka/RabbitMQ 消息后等待确认；
- 调用支付或履约系统；
- 写入另一个无法参与同一 PostgreSQL 事务的数据源。

这些动作不具备与 PostgreSQL 提交相同的原子边界。

### “触发器里调用 HTTP”为什么会失败

假设触发器同步调用远端服务：

```text
UPDATE order
  -> trigger calls remote API
  -> remote succeeds
  -> PostgreSQL COMMIT fails
```

外部动作已经发生，数据库却回滚。反过来：

```text
UPDATE order
  -> remote times out
  -> unknown whether remote succeeded
  -> database transaction holds locks while waiting
```

此时重试可能重复副作用，数据库连接、行锁和事务快照还被远程尾延迟拖住。
把网络调用包装成 extension function 并不会改变分布式事务事实。

### 正确边界：同事务写 outbox

第 12 章使用：

```sql
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 的放大倍数；
- 无法提供停用、兼容和回退方案。

触发器的价值是让数据库不变量覆盖所有写入口；一旦它变成隐藏工作流引擎，
这个优势很快会被运维风险抵消。

### 本节结论

选择逻辑位置时按以下顺序停靠：

```text
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 函数](../02/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
