6.4 查询与事务候选规则
查询规约要保护的是调用合同,事务规约要保护的是失败后的正确性。两者都不适合简化成 SQL 风格检查:SELECT * 在交互诊断中很方便,在持久 API 中却会制造列漂移;CTE 可能清晰表达关系步骤,也可能引入不必要 materialization;短事务通常更友好,但把本应原子的一组写入拆开只会得到更快的错误结果。
这一节先固定语义合同,再讨论代价。第 7–10 章会继续为计划、索引和并发规则补证据。
6.4.1 明确列、稳定排序与分页语义
持久 query interface 至少声明五件事:
input:
参数名、类型、NULL、范围和授权上下文
output:
列名、类型、NULL、单位和兼容策略
cardinality:
0/1/N 行,是否允许重复
order:
排序键、方向、NULL、collation、tie-breaker
consistency:
单条语句 snapshot,还是跨页/跨查询一致视图DEFAULT-QUER-006 因此要求稳定接口显式投影:
SELECT
o.order_id,
o.order_no,
o.order_status,
o.currency_code,
o.placed_at
FROM shop.sales_order AS o
WHERE o.customer_id = $1;这不是因为 SELECT * 在服务器内部必然更慢,而是因为隐式列集合会随 DDL 变化,扩大网络与权限面,破坏 positional decoder,并让调用方不知不觉依赖内部列。短期 psql 探索可以使用 *;稳定 view consumer、API query 和 migration copy contract 不应使用。
没有 ORDER BY 就没有顺序合同
PostgreSQL 文档明确指出,不指定 ORDER BY 时,返回顺序未定义。一次执行看起来按 primary key 或 heap 顺序返回,只是当前 plan、数据布局和并发状态的结果。加 LIMIT 也不会把偶然顺序变成合同:
-- 不稳定:同一价格之间没有 tie-breaker
ORDER BY total_minor DESC
LIMIT 20;
-- 稳定全序:最后一个键唯一且方向明确
ORDER BY total_minor DESC, order_id DESC
LIMIT 20;唯一 tie-breaker 是 SAFE-PAGE-010 的底线。若排序列可为 NULL,API 还要固定 NULLS FIRST/LAST;若排序受 collation 影响,要固定 collation/normalization,或用稳定 binary/normalized key。否则 cursor 编码相同值时,不同环境可能得到不同边界。
keyset cursor 必须编码完整排序键
本章样例按:
ORDER BY placed_at DESC, order_id DESC向后取下一页:
WHERE placed_at IS NOT NULL
AND (placed_at, order_id) < ($cursor_placed_at, $cursor_order_id)
ORDER BY placed_at DESC, order_id DESC
LIMIT $page_size;成立前提是两个键都非 NULL、比较语义与排序一致,最后的 order_id 唯一。cursor 至少编码两个值、sort version/direction 和必要的 filter identity;对外暴露时通常还需要签名或完整性保护,避免调用方伪造超范围条件。
若混用 ASC/DESC、NULL 或不同 collation,不能机械复制 row comparison;应展开为与排序完全等价的 predicate,并写边界测试。反向翻页也不是把 < 改成 > 就结束,还要反转内部 order、取得一页后恢复 API 顺序。
OFFSET 不是永远禁止:小型后台界面、稳定 snapshot 内的有限页数可以接受。但大 offset 仍要计算并丢弃前面的行;在 Read Committed 下跨页查询之间发生 insert/delete 时,还可能重复或遗漏。keyset 避免按位置跳过,却不能自动提供跨页 snapshot 一致性;排序键被更新时也可能移动。API 必须声明自己提供“实时游标”还是“固定快照导出”。
用结果合同而不是 SQL 文本做验收
query-contract.sql 不要求 application 复制某一段 SQL 字符串,而是验证:
shop_api.order_summary恰好包含 11 个发布列;- 排序显式为
placed_at DESC, order_id DESC; - 第一页和第二页 cursor 严格前进且不重叠;
order_nobusiness key 与request_keyidempotency key 仍唯一。
典型输出:
status=ok
query_contract=explicit-columns+stable-keyset
view_column_count=11
cursor_order=placed_at-desc,order_id-desc
page_1_order_id=1002
page_2_order_id=1001
pages_do_not_overlap=t
business_key_unique=t
idempotency_key_unique=t教学 fixture 只有两笔订单,所以这不是性能 benchmark,也没有覆盖 NULL、同 timestamp、大页数和并发移动。它证明 baseline 的最小语义;API 上线前还要添加这些边界用例。
6.4.2 事务大小、超时、重试与幂等
“事务越短越好”缺少一个关键限定:事务必须先覆盖保持不变量所需的完整正确性单元,然后才在这个边界内缩短。
以“创建订单并预占库存”为例:
BEGIN
validate request key
insert order
insert order lines
reserve inventory
record durable event/outbox intent
COMMIT如果这些数据库事实必须共同成立,就不能为了缩短 transaction 把它们拆成无补偿的独立 commit。真正应该移出去的是用户输入、HTTP 调用、邮件发送、长时间计算和无边界 sleep。DEFAULT-TXNN-007 要求 transaction diagram 标出:
BEGIN → first lock → database work → COMMIT
↘ external wait? 应移出或重构对大批处理则分批 commit,但必须定义 partial progress、restart cursor、幂等与最终 reconciliation。分批不是放弃原子性,而是把正确性单元重新定义为可恢复的小批次。
首个错误才是根因
显式 transaction 中第一条 statement error 会使 transaction 进入 failed state;后续普通 SQL 通常只返回 25P02 in_failed_sql_transaction。应用必须保存第一个 SQLSTATE,然后:
- 整体
ROLLBACK;或 - 回到事先建立、且业务语义允许的 savepoint。
不能在收到 25P02 后继续发业务 SQL,也不能把 failed/idle-in-transaction connection 原样归还 pool。driver/framework 的 cleanup 必须在归还连接前 rollback,并检查 transaction 状态。
第 5 章实验已经证明:
22012 → 25P02
23514 → ROLLBACK TO SAVEPOINT → valid statement → outer ROLLBACKsavepoint 是局部恢复工具,不是“忽略错误继续”。若失败改变了后续决策所依赖的业务语义,最安全的边界仍是整体重试。
timeout 是失败合同的一部分
statement/lock timeout 触发后,当前 statement 失败;若处于显式 transaction,transaction 同样需要 rollback/savepoint 恢复。应用必须区分:
- query 被 server 明确取消;
- 获取 lock 超时;
- client deadline 先到并关闭/取消连接;
- 网络断开导致 commit outcome 不明确。
它们不能统一成“再执行一次”。数据库可能明确回滚 statement,也可能已经 commit 但 ACK 丢失。
重试整个正确性单元
SAFE-RETR-008 目前定义:
SQLSTATE allowlist(例如 40001 / 40P01)
→ 丢弃旧 transaction/snapshot
→ bounded exponential backoff + jitter
→ 在总 deadline 内从 BEGIN 重跑完整单元
→ 超限后向调用方返回可归因错误40001 serialization_failure 与 40P01 deadlock_detected 常常可以整体重试,但“可以”仍依赖操作幂等、时间预算和 contention。不能只重放最后一条 SQL:前面的读取与判断来自已经失效的 snapshot。也不能把所有 08xxx connection exception 无条件重试,因为 commit 可能已经成功。
allowlist 要按 driver 暴露的 SQLSTATE/class 检查,不能按本地化 message substring。最大次数之外还要有总 deadline,避免数据库过载时 retry storm;jitter 用于打散竞争者,不保证消除热点。
幂等要闭合 ambiguous outcome
创建订单使用独立 request_key:
INSERT INTO shop.sales_order (..., request_key)
VALUES (..., $request_key)
ON CONFLICT (request_key) DO NOTHING
RETURNING order_id;但 DO NOTHING 只是起点。冲突后必须查询权威结果,并验证同一个 idempotency key 对应的业务 payload 是否一致;否则客户端错误复用 key 会被误当成成功。key 的作用域、保留时间和并发行为都要写入合同。
外部支付、HTTP、消息和邮件不随 PostgreSQL rollback 自动撤销。常见方案是先在同一 database transaction 内写 durable intent/outbox,再由独立 worker 幂等投递;或者由外部系统提供相同 idempotency key 和可查询 outcome。无论采用哪种方案,都要回答:
commit ACK 丢失后查谁?
重复投递怎样识别?
数据库成功、外部失败怎样补偿?
外部成功、数据库未知怎样 reconciliation?本章只把这些问题固化为 review rule。真正的自动重试、deadlock/serialization fixture 与 ambiguous outcome 演练安排在 ch10。因此 v0.1 诚实输出 safety 自动/运行覆盖 9/10。
6.4.3 CTE、窗口函数与 LATERAL 的可读性门槛
高级 SQL 的评审不能变成关键字黑名单。WITH、window 和 LATERAL 都能让关系责任更直接,也都可能在错误数据分布下产生高成本。PREF-ASQL-004 的门槛是:reviewer 能用一句话说明每个构造负责什么,并且样例、边界测试与 plan evidence 支持它。
CTE:命名关系步骤,也可能改变优化边界
CTE 适合给复杂关系步骤命名:
WITH paid_orders AS (
SELECT o.customer_id, o.order_id, o.total_minor
FROM shop.sales_order AS o
WHERE o.order_status = 'paid'
)
SELECT customer_id, sum(total_minor)
FROM paid_orders
GROUP BY customer_id;在当前支持版本中,一个无副作用、非递归、只引用一次的 CTE 通常可折叠进父查询;多次引用通常会 materialize。MATERIALIZED 与 NOT MATERIALIZED 可以显式影响决策,但不是性能咒语:materialization 可能避免重复昂贵计算,也可能阻止父查询 predicate 下推。含 volatile function 或数据修改的 CTE 又有不同语义。
因此,不能继续沿用“PostgreSQL 的 CTE 永远是优化栅栏”这类跨版本口号。每个显式 materialization 都要说明是为了稳定语义、避免重复工作,还是经过计划对照后的成本选择。
Window:在同一行集上分析,不替代输出排序
window function 保留输入行,同时计算 partition/order/frame 内的值:
SELECT
customer_id,
order_id,
placed_at,
row_number() OVER (
PARTITION BY customer_id
ORDER BY placed_at DESC, order_id DESC
) AS customer_order_rank
FROM shop.sales_order;window 的 ORDER BY 决定窗口计算顺序,不保证最终 result order;对外返回仍需顶层 ORDER BY。last_value 等函数还受默认 frame 影响,必须显式审查 frame。多个不同 window order 可能引入多次 sort;计划与 work_mem/spill 证据留到第 7、8 章。
LATERAL:表达逐行依赖,也可能放大外层基数
LATERAL 允许 FROM item 引用左侧 item,适合“每个 customer 最近两笔订单”:
SELECT
c.customer_id,
recent.order_id,
recent.placed_at
FROM shop.customer AS c
CROSS JOIN LATERAL (
SELECT o.order_id, o.placed_at
FROM shop.sales_order AS o
WHERE o.customer_id = c.customer_id
ORDER BY o.placed_at DESC, o.order_id DESC
LIMIT 2
) AS recent;它可以把 application N+1 合并为一次 SQL,也常对应按外层每行执行的参数化路径。外层基数、内层索引和 loops 决定它是高效 top-N 还是放大器。评审不能因为“只有一条 SQL”就判断更快。
计划证据不做节点名 golden test
PREF-PLAN-005 明确:
- Seq Scan 不自动错误,小表/低选择性时可能最优;
- Nested Loop 不自动错误,参数化小结果与合适索引时可能最优;
- planner cost 不是毫秒;
- 一次
EXPLAIN ANALYZE不是未来预测; - 强制 planner GUC 或新增索引前,先看 estimate/actual、loops、buffers、wait、参数和数据分布。
本章 gate 只验证 query semantics,不固定 plan node。精确 plan evidence 在 ch07 引入,慢查询闭环在 ch08,索引写放大与收益在 ch09。这样的章节边界防止 baseline v0.1 提前把尚未实验的性能偏好升级成 safety。
本节验收问题
- 稳定 query 是否显式列出输入、输出和 cardinality;
- 对外 result 是否显式排序,并以唯一键形成全序;
- cursor 是否编码全部 sort keys、direction、NULL/collation 与失效语义;
- 是否明确需要实时分页还是跨页一致 snapshot;
- transaction 是否覆盖完整不变量,同时排除用户/远程等待;
- 首个 SQLSTATE 是否保留,失败连接是否在回 pool 前 rollback;
- retry 是否重跑完整 transaction,带 allowlist、backoff、jitter、次数和总 deadline;
- ambiguous commit 是否能通过 idempotency key 与权威查询闭合;
- 外部副作用是否有 durable intent、幂等或 reconciliation;
- 每个 CTE/window/LATERAL 是否有一句话职责、边界用例和计划证据;
- 是否避免用节点名、cost 或一次耗时做 blanket rule。
当这些答案进入 query contract 和变更证据后,SQL 才从“现在能跑”升级为“失败后仍可推理”。
参考资料
- PostgreSQL 18:Sorting Rows
- PostgreSQL 18:LIMIT and OFFSET
- PostgreSQL 18:SELECT
- PostgreSQL 18:WITH Queries
- PostgreSQL 18:Window Functions
- PostgreSQL 18:Table Expressions and
LATERAL - PostgreSQL 18:Error Codes
- PostgreSQL 18:Transaction Isolation
上一节:模式与 DDL 候选规则 · 返回本章目录 · 下一节:交付物与质量门 · 查看全书目录 · 查看索引中心