LATERAL 允许 FROM 中的一个项目引用它前面项目提供的列。每个父记录需要一个带自身参数的小结果集时,它很实用,例如每位客户最近的两笔订单。整个查询仍是一条 SQL、一次数据库往返,横向子查询明确表达哪些外层值决定当前结果。但这种逻辑关系不承诺唯一的物理执行策略,规划器仍可进行保持语义的优化。

把关联条件写清楚

普通 FROM 子查询不能随意引用旁边表的列,LATERAL 允许在合适的连接位置使用这些引用。o.customer_id = c.id 才是当前客户与订单之间的关联;只写关键词不会自动产生这个条件。如果删除关联,每位客户可能都拿到同一份全局结果。语法通过只能证明语句合法,不能证明取到的是这个客户自己的订单。

实现每组前几名时,把父记录过滤条件、ORDER BY 和 LIMIT 都放进横向子查询。放在整个连接外面的 LIMIT 限制的是最终组合结果,不是每位客户最多几笔订单。排序还需要唯一的决胜字段:两笔订单完全可能具有相同时间戳,只按时间排序时,边界位置选择哪一行没有保证,换一个计划就可能换一个答案。

子查询里的 ORDER BY 决定入选哪些行,并不保证最终结果的展示顺序。客户端要求稳定的客户与订单顺序时,还需要外层 ORDER BY。导出报表和自动化检查尤其依赖这一点;不能因为当前执行计划碰巧按某个顺序返回,就把这种偶然表现当成接口契约,否则升级或统计信息刷新后会出现难以解释的行顺序变化。

决定没有匹配时保留什么

CROSS JOIN LATERAL 在子查询没有返回行时会丢掉对应父记录。LEFT JOIN LATERAL 配合 ON true 则保留父记录,并把右侧列补成 null。对尚未下单的客户,后者往往才符合页面或报表需求。如果把右侧条件移到外层 WHERE,补出来的 null 行可能被过滤掉,原本想保留空客户的行为就被破坏了。

聚合还有一层区别。COUNT(r.id) 只统计非空订单标识,COUNT(*) 连左连接补出的那一行也会计算。没有 GROUP BY 的相关聚合,即使输入为空也可能返回一行,COUNT 是零,SUM 则是 null。因此不能把所有 LATERAL 表达式都理解成同一种列表查询,必须看它实际返回零行、一行还是多行,以及每一列的空值含义。

坏掉的样子

一种危险的“修复”是为了消除外层引用的语法错误,直接删除关联条件。查询之后正常运行,但每位客户都获得全站最近的订单,汇总数字被放大仍可能看起来合理。测试应覆盖没有订单、只有一笔、有多笔以及时间相同的客户。断言确切订单标识比只验证查询成功更有价值,因为它能发现归属错误与排序边界错误。

另一种问题是昂贵的内层操作随着外层行数反复执行。以关联键开头、后接所需排序字段的索引,可以支持选择性较高的 top-N 查找,但不保证一定被选择,小表尤其如此。查看 EXPLAIN 的 actual loops 和行数,外层涉及大量客户时还应比较窗口函数方案。LATERAL 消除的是客户端逐个查询的往返,不会让数据库内部的工作量凭空消失。

快速验证

将下面代码保存为 lateral.sql,使用 psql -X -v ON_ERROR_STOP=1 -f lateral.sql 连接一个可丢弃的 PostgreSQL 18 数据库。实验在一个事务中创建三位客户、四笔订单、匹配的索引和临时视图,所有数据都在代码中给出,不依赖已有业务表,也不需要猜测字段含义。

\set ON_ERROR_STOP on
BEGIN;
SET LOCAL statement_timeout = '5s';
CREATE TEMP TABLE customers (id integer PRIMARY KEY, name text);
CREATE TEMP TABLE orders (id integer PRIMARY KEY, customer_id integer, placed_at date);
INSERT INTO customers VALUES (1,'Ada'), (2,'Lin'), (3,'Sam');
INSERT INTO orders VALUES
  (10,1,'2026-09-01'), (11,1,'2026-09-02'), (12,1,'2026-09-02'),
  (20,2,'2026-09-01');
CREATE INDEX orders_recent ON orders (customer_id, placed_at DESC, id DESC);
ANALYZE customers;
ANALYZE orders;
CREATE TEMP VIEW recent AS
SELECT c.id AS customer_id, r.id AS order_id
FROM customers c LEFT JOIN LATERAL (
  SELECT o.id FROM orders o WHERE o.customer_id=c.id
  ORDER BY o.placed_at DESC, o.id DESC LIMIT 2
) r ON true;
SELECT * FROM recent ORDER BY customer_id, order_id DESC;
SELECT c.id, count(r.id) AS selected_orders
FROM customers c LEFT JOIN LATERAL (
  SELECT o.id FROM orders o WHERE o.customer_id=c.id
  ORDER BY o.placed_at DESC, o.id DESC LIMIT 2
) r ON true GROUP BY c.id ORDER BY c.id;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT * FROM recent;
DO $$ BEGIN
  IF (SELECT array_agg(order_id ORDER BY order_id) FROM recent WHERE customer_id=1)
     IS DISTINCT FROM ARRAY[11,12] THEN
    RAISE EXCEPTION 'top two or tie-breaker is wrong';
  END IF;
  IF NOT EXISTS (SELECT FROM recent WHERE customer_id=3 AND order_id IS NULL) THEN
    RAISE EXCEPTION 'LEFT JOIN lost the customer without orders';
  END IF;
  IF (SELECT count(*) FROM recent) <> 4 THEN RAISE EXCEPTION 'wrong row count'; END IF;
END $$;
ROLLBACK;

结果依次包含 (1,12)、(1,11)、(2,20),以及第三位客户配一个 null 订单。匹配订单数量分别是二、一、零。十一和十二号订单虽然日期相同,但两行都能进入限额,因此这个夹具没有验证恰好跨过 LIMIT 边界时如何排除某条并列行;第三位客户验证没有匹配时是否仍然保留。断言检查具体标识和总行数,最后的回滚移除全部临时对象。

PostgreSQL 18.6 在这份四笔订单的夹具上使用了重复的小表扫描,这很合理,不能据此断定设计中的索引在大数据量下无效。反过来,也不能因为建了索引就要求任何计划都显示索引扫描。性能结论需要接近真实的客户数量和订单分布,正确性结论则来自关联条件、连接类型与确定排序。五秒 statement timeout 为实验单条语句设定执行上限,并不代替生产负载评估。

来源