跳至内容
6.4 查询与事务候选规则

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_no business key 与 request_key idempotency 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 ROLLBACK

savepoint 是局部恢复工具,不是“忽略错误继续”。若失败改变了后续决策所依赖的业务语义,最安全的边界仍是整体重试。

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_failure40P01 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。MATERIALIZEDNOT 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 BYlast_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。

本节验收问题

  1. 稳定 query 是否显式列出输入、输出和 cardinality;
  2. 对外 result 是否显式排序,并以唯一键形成全序;
  3. cursor 是否编码全部 sort keys、direction、NULL/collation 与失效语义;
  4. 是否明确需要实时分页还是跨页一致 snapshot;
  5. transaction 是否覆盖完整不变量,同时排除用户/远程等待;
  6. 首个 SQLSTATE 是否保留,失败连接是否在回 pool 前 rollback;
  7. retry 是否重跑完整 transaction,带 allowlist、backoff、jitter、次数和总 deadline;
  8. ambiguous commit 是否能通过 idempotency key 与权威查询闭合;
  9. 外部副作用是否有 durable intent、幂等或 reconciliation;
  10. 每个 CTE/window/LATERAL 是否有一句话职责、边界用例和计划证据;
  11. 是否避免用节点名、cost 或一次耗时做 blanket rule。

当这些答案进入 query contract 和变更证据后,SQL 才从“现在能跑”升级为“失败后仍可推理”。

参考资料


上一节:模式与 DDL 候选规则 · 返回本章目录 · 下一节:交付物与质量门 · 查看全书目录 · 查看索引中心

最后更新于