本文沿用第 1 篇的订单系统案例,只讨论 SQL 如何形成合适的访问路径。

索引存在并不保证查询高效。参数类型、函数位置、返回列、往返次数和分页方式都会改变规划器可选择的路径;改写后仍要用相同参数与数据分布检查执行计划。

避免索引列上的无效转换

问题写法: 把每行的 user_id 转成文本后再比较,普通 user_id B-tree 索引无法直接支持这个表达式,还会为候选行增加转换成本。

-- 不推荐
SELECT *
FROM orders
WHERE user_id::text = '42000';

改进写法: 让应用按列类型绑定参数,把转换放在参数一侧,并明确列出本次查询需要的字段。

SELECT id, public_id, status, total_amount, created_at
FROM orders
WHERE user_id = $1::bigint;

适用边界: 应用驱动通常可以直接发送 bigint 参数,不必把所有值先变成字符串。若业务长期按同一表达式检索,可以评估表达式索引,但要同时计算写入与存储成本。

日期条件使用半开区间

问题写法:created_at 上调用 ::date 会改变比较表达式,并把时区与结束边界隐藏在会话设置中。

-- 容易阻碍 created_at 普通索引
WHERE created_at::date = DATE '2026-07-20'

-- 明确半开区间
WHERE created_at >= TIMESTAMPTZ '2026-07-20 00:00:00+08'
  AND created_at <  TIMESTAMPTZ '2026-07-21 00:00:00+08'

改进写法: 使用“起点包含、终点不包含”的半开区间,直接比较原始时间列。连续日期不会在午夜处重叠,也不会遗漏带小数秒的记录。

适用边界: 区间端点必须按业务时区计算;跨夏令时地区不能假设每天固定为 24 小时。若必须按固定表达式检索,再结合实际查询评估表达式索引。

明确返回列而不是 SELECT *

问题写法: SELECT * 会把后来新增的列自动带入接口,扩大网络传输、序列化和可能的 TOAST 读取面,也可能让原本可覆盖的查询需要回表。

改进写法: 列表接口只返回展示与游标真正需要的列,让接口契约和读取成本都保持稳定。

SELECT id, public_id, status, total_amount, created_at
FROM orders
WHERE user_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 50;

适用边界: 后台导出或审计任务确实需要完整记录时,可以读取更多字段;此时显式列出的首要价值是固定契约,而不是追求字面上最短的 SQL。

避免应用层 N+1 查询

问题写法: 先取 50 个订单,再逐个查询支付记录,会把一次页面请求放大为 51 次数据库往返,延迟还会随结果数增长。

改进写法: 在一次查询中连接当前页面需要的支付信息,或先批量取得订单 ID,再用集合查询读取关联记录。

SELECT
  o.id,
  o.public_id,
  o.status,
  o.total_amount,
  p.status AS payment_status,
  p.paid_at
FROM orders AS o
LEFT JOIN payments AS p ON p.order_id = o.id
WHERE o.user_id = $1
ORDER BY o.created_at DESC, o.id DESC
LIMIT 50;

适用边界: 一张订单可能对应多条支付记录时,直接连接会复制订单行。必须先定义“最新支付”或“成功支付”,再用 LATERAL、窗口函数或预聚合挑选目标行,不能以减少往返为由改变结果语义。

判断存在时使用 EXISTS

问题写法: 只想知道是否有成功支付,却用 count(*) 统计全部匹配行,会表达多余的计算目标。

改进写法: 使用存在语义;子查询只需证明至少有一行满足条件。

SELECT EXISTS (
  SELECT 1
  FROM payments
  WHERE order_id = $1
    AND status = 'succeeded'
);

适用边界: EXISTS 可以在找到匹配行后停止,但不代表所有数据分布下都更快。需要真实数量时仍应使用聚合;只判断存在时,也要结合谓词选择性和执行计划验证。

批量写入减少往返

问题写法: 在应用循环中逐行执行数万次 INSERT,会累积网络往返和解析执行成本;若逐条自动提交,还会累积事务提交成本。

改进写法: 中小批量使用多值插入,大批量导入优先评估 COPY;批量更新也尽量使用集合操作。

INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES
  ($1, $2, $3, $4),
  ($5, $6, $7, $8),
  ($9, $10, $11, $12);

适用边界: 批次不是越大越好。过大的事务会放大 WAL、锁持有时间、内存占用和失败重试成本;集合更新还可能一次锁住大量行,应限制批次并保持稳定锁顺序。

使用 Keyset Pagination 替代深分页

问题写法: 深页 OFFSET 仍需找到并跳过前面的记录,处理量通常随页码增长。

SELECT id, public_id, status, total_amount, created_at
FROM orders
WHERE user_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 50 OFFSET 100000;

改进写法: “加载更多”或顺序翻页可以把上一页最后一行的 (created_at, id) 作为游标,继续读取严格排在它后面的记录。

SELECT id, public_id, status, total_amount, created_at
FROM orders
WHERE user_id = $1
  AND (created_at, id) < ($2::timestamptz, $3::bigint)
ORDER BY created_at DESC, id DESC
LIMIT 50;

适用边界: created_at 可能重复,所以要追加唯一、同方向的 id 作为平局处理键。Keyset Pagination 不适合自然跳到任意页;游标还应可校验,排序键为空时则要先定义明确的空值顺序。

先减少行数再连接

问题写法: 把订单、明细和支付直接连接后再聚合,会让两组一对多关系形成行数乘积,既可能重复计算金额,也会扩大排序与哈希输入。

改进写法: 先按业务主键分别聚合明细和支付,再把已经收敛到“一订单一行”的结果连接回订单。

WITH item_totals AS (
  SELECT
    order_id,
    sum(quantity * unit_price) AS item_amount
  FROM order_items
  GROUP BY order_id
), payment_totals AS (
  SELECT
    order_id,
    sum(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END) AS success_count
  FROM payments
  GROUP BY order_id
)
SELECT
  o.id,
  i.item_amount,
  p.success_count
FROM orders AS o
JOIN item_totals AS i ON i.order_id = o.id
LEFT JOIN payment_totals AS p ON p.order_id = o.id
WHERE o.created_at >= $1
  AND o.created_at < $2;

适用边界: PostgreSQL 18 会结合查询决定 CTE 是否物化、连接顺序和聚合方式。关键不是“CTE 一定更快”,而是先消除多对多连接产生的重复行;过滤能否安全下推还取决于报表语义。

结论是:索引并不保证错误的 SQL 自动变快。先让类型、结果集和业务语义正确,再用执行计划与复测数据确认改写是否真的减少工作量。

参考资料