You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Postgres中LATERAL JOIN为何比关联子查询更快?附优化建议

关联子查询改LATERAL JOIN后性能提升的原因及优化建议

问题背景

将原有使用关联子查询的查询改写为LATERAL JOIN版本后,查询速度明显提升,希望理解背后的逻辑,并获取其他优化方案。以下是新旧查询语句、执行计划及现有索引信息:

旧关联子查询语句

SELECT
  ts.id,
  ts.val1,
  tv.val2
FROM table_static AS ts
JOIN table_version AS tv ON ts.id = tv.id
  AND tv.effective_time = (
    SELECT MAX(tv1.effective_time)
    FROM table_version AS tv1
    WHERE
      tv1.id = ts.id
      AND
      tv1.effective_time <= CLOCK_TIMESTAMP()
  )
  AND tv.create_time = (
    SELECT MAX(tv2.create_time)
    FROM table_version AS tv2
    WHERE
      tv2.id = tv.id
      AND
      tv2.effective_time = tv.effective_time
      AND
      tv2.create_time <= CLOCK_TIMESTAMP()
  )
JOIN table_status AS t_status ON tv.status_id = t_status.id
WHERE t_status.status != 'deleted'
LIMIT 1000;

关联子查询执行计划

Limit  (cost=4.96..13876200.85 rows=141 width=64) (actual time=0.078..10.788 rows=1000 loops=1)
  ->  Nested Loop  (cost=4.96..13876200.85 rows=141 width=64) (actual time=0.077..10.641 rows=1000 loops=1)
        Join Filter: (tv.status_id = t_status.id)
        ->  Nested Loop  (cost=4.96..13876190.47 rows=176 width=64) (actual time=0.065..10.169 rows=1000 loops=1)
              ->  Seq Scan on table_static ts  (cost=0.00..17353.01 rows=1000001 width=32) (actual time=0.010..0.176 rows=1000 loops=1)
              ->  Index Scan using table_version_pkey on table_version tv  (cost=4.96..13.85 rows=1 width=40) (actual time=0.005..0.006 rows=1 loops=1000)
                    Index Cond: ((id = ts.id) AND (effective_time = (SubPlan 2)))
                    Filter: (create_time = (SubPlan 4))
                    SubPlan 4
                      ->  Result  (cost=8.46..8.47 rows=1 width=8) (actual time=0.003..0.003 rows=1 loops=1000)
                            InitPlan 3 (returns $4)
                              ->  Limit  (cost=0.43..8.46 rows=1 width=8) (actual time=0.002..0.002 rows=1 loops=1000)
                                    ->  Index Only Scan Backward using table_version_pkey on table_version tv2  (cost=0.43..8.46 rows=1 width=8) (actual time=0.002..0.002 rows=1 loops=1000)
                                          Index Cond: ((id = tv.id) AND (effective_time = tv.effective_time) AND (create_time IS NOT NULL))
                                          Filter: (create_time <= clock_timestamp())
                                          Heap Fetches: 0
                    SubPlan 2
                      ->  Result  (cost=4.52..4.53 rows=1 width=8) (actual time=0.003..0.003 rows=1 loops=1000)
                            InitPlan 1 (returns $1)
                              ->  Limit  (cost=0.43..4.52 rows=1 width=8) (actual time=0.003..0.003 rows=1 loops=1000)
                                    ->  Index Only Scan Backward using table_version_pkey on table_version tv1  (cost=0.43..8.61 rows=2 width=8) (actual time=0.002..0.002 rows=1 loops=1000)
                                          Index Cond: ((id = ts.id) AND (effective_time IS NOT NULL))
                                          Filter: (effective_time <= clock_timestamp())
                                          Heap Fetches: 0
        ->  Materialize  (cost=0.00..1.08 rows=4 width=8) (actual time=0.000..0.000 rows=1 loops=1000)
              ->  Seq Scan on table_status t_status  (cost=0.00..1.06 rows=4 width=8) (actual time=0.006..0.006 rows=1 loops=1)
                    Filter: (status <> 'deleted'::text)
Planning Time: 0.827 ms
Execution Time: 10.936 ms

新LATERAL JOIN语句

SELECT
  ts.id,
  ts.val1,
  tv.val2
FROM table_static AS ts
JOIN LATERAL (
  SELECT *
  FROM table_version AS tv
  WHERE
    ts.id = tv.id
    AND
    tv.effective_time <= CLOCK_TIMESTAMP()
    AND
    tv.create_time <= CLOCK_TIMESTAMP()
  ORDER BY
    tv.effective_time DESC,
    tv.create_time DESC
  LIMIT 1
) AS tv ON TRUE
JOIN table_status AS t_status ON tv.status_id = t_status.id
WHERE t_status.status != 'deleted'
LIMIT 1000;

LATERAL JOIN执行计划

Limit  (cost=0.43..40694.36 rows=1000 width=64) (actual time=0.218..4.431 rows=1000 loops=1)
  ->  Nested Loop  (cost=0.43..32555183.83 rows=800001 width=64) (actual time=0.217..4.280 rows=1000 loops=1)
        Join Filter: (tv.status_id = t_status.id)
        ->  Nested Loop  (cost=0.43..32502382.70 rows=1000001 width=64) (actual time=0.189..3.815 rows=1000 loops=1)
              ->  Seq Scan on table_static ts  (cost=0.00..17353.01 rows=1000001 width=32) (actual time=0.059..0.297 rows=1000 loops=1)
              ->  Limit  (cost=0.43..32.46 rows=1 width=48) (actual time=0.003..0.003 rows=1 loops=1000)
                    ->  Index Scan Backward using table_version_pkey on table_version tv  (cost=0.43..32.46 rows=1 width=48) (actual time=0.003..0.003 rows=1 loops=1000)
                          Index Cond: (id = ts.id)
                          Filter: ((effective_time <= clock_timestamp()) AND (create_time <= clock_timestamp()))
        ->  Materialize  (cost=0.00..1.08 rows=4 width=8) (actual time=0.000..0.000 rows=1 loops=1000)
              ->  Seq Scan on table_status t_status  (cost=0.00..1.06 rows=4 width=8) (actual time=0.021..0.021 rows=1 loops=1)
                    Filter: (status <> 'deleted'::text)
Planning Time: 1.315 ms
Execution Time: 4.746 ms

现有索引信息

ALTER TABLE ONLY table_static
    ADD CONSTRAINT table_static_pkey PRIMARY KEY (id);
ALTER TABLE ONLY table_version
    ADD CONSTRAINT table_version_pkey PRIMARY KEY (id, effective_time, create_time);
ALTER TABLE ONLY table_status
    ADD CONSTRAINT table_status_pkey PRIMARY KEY (id);

性能提升的核心原因

你的猜测方向是对的,核心在于减少了索引访问次数,且利用了索引的有序性一次性完成筛选:

  1. 旧查询的冗余操作:
    旧查询中,每个ts.id需要执行两次独立的关联子查询:

    • 第一次子查询(SubPlan2):通过Index Only Scan找到当前id下最大的effective_time
    • 第二次子查询(SubPlan4):基于找到的effective_time,再执行一次Index Only Scan找到该时间下最大的create_time
      每个ts.id要跑两次索引扫描,1000条数据对应2000次索引操作。
  2. LATERAL JOIN的高效逻辑:
    新查询利用了table_version主键索引的有序性(id, effective_time, create_time),直接通过一次反向索引扫描完成筛选:
    索引本身按id分组、effective_time升序、create_time升序存储,反向扫描时就是effective_time降序、create_time降序,刚好匹配ORDER BY effective_time DESC, create_time DESC的需求。只需要找到第一个满足时间条件的行直接返回,每个ts.id仅需一次索引扫描,1000条数据对应1000次索引操作,开销直接减半。

其他优化方案

  1. 预计算最新版本记录:
    如果业务对数据实时性要求不高,可以创建物化视图,定时刷新每个id对应的最新有效版本记录,查询时直接从物化视图读取,避免每次都做排序/筛选操作。

  2. 提前过滤无效状态:
    把table_status的过滤逻辑整合到LATERAL JOIN子查询中,直接在子查询里排除deleted状态的记录,减少不必要的行返回:

    SELECT
      ts.id,
      ts.val1,
      tv.val2
    FROM table_static AS ts
    JOIN LATERAL (
      SELECT tv.val2
      FROM table_version AS tv
      JOIN table_status AS t_status ON tv.status_id = t_status.id
      WHERE
        ts.id = tv.id
        AND tv.effective_time <= CLOCK_TIMESTAMP()
        AND tv.create_time <= CLOCK_TIMESTAMP()
        AND t_status.status != 'deleted'
      ORDER BY tv.effective_time DESC, tv.create_time DESC
      LIMIT 1
    ) AS tv ON TRUE
    LIMIT 1000;
    
  3. 替换易变函数:
    CLOCK_TIMESTAMP()是易变函数,每次调用返回值不同,会阻止PostgreSQL进行某些优化(如索引缓存)。如果业务允许,可换成CURRENT_TIMESTAMP()(事务内稳定),提升优化空间。

  4. 优化table_static的扫描方式:
    如果table_static数据量极大,而查询仅需1000行,可以添加排序条件(如ORDER BY ts.id),让PostgreSQL使用主键索引扫描代替全表扫描,避免读取大量无关数据。

内容的提问来源于stack exchange,提问作者Marnix.hoh

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.29 22:50:56