PostgreSQL 性能优化(五):SQL 写法与访问路径
本文沿用第 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 自动变快。先让类型、结果集和业务语义正确,再用执行计划与复测数据确认改写是否真的减少工作量。