Received: from malur.postgresql.org ([217.196.149.56]) by arkaria.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.94.2) (envelope-from ) id 1tkCCC-0019v9-AF for pgsql-general@arkaria.postgresql.org; Tue, 18 Feb 2025 01:14:44 +0000 Received: from localhost ([127.0.0.1] helo=malur.postgresql.org) by malur.postgresql.org with esmtp (Exim 4.94.2) (envelope-from ) id 1tkCCA-005Q80-M0 for pgsql-general@arkaria.postgresql.org; Tue, 18 Feb 2025 01:14:42 +0000 Received: from magus.postgresql.org ([2a02:c0:301:0:ffff::29]) by malur.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.94.2) (envelope-from <4wuyan@gmail.com>) id 1tkCCA-005Q7p-97 for pgsql-general@lists.postgresql.org; Tue, 18 Feb 2025 01:14:42 +0000 Received: from mail-wm1-x336.google.com ([2a00:1450:4864:20::336]) by magus.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (Exim 4.96) (envelope-from <4wuyan@gmail.com>) id 1tkCC5-001Si8-0w for pgsql-general@lists.postgresql.org; Tue, 18 Feb 2025 01:14:41 +0000 Received: by mail-wm1-x336.google.com with SMTP id 5b1f17b1804b1-4398c8c8b2cso11364205e9.2 for ; Mon, 17 Feb 2025 17:14:36 -0800 (PST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20230601; t=1739841275; x=1740446075; darn=lists.postgresql.org; h=cc:to:subject:message-id:date:from:in-reply-to:references :mime-version:from:to:cc:subject:date:message-id:reply-to; bh=REzFO3vfmyOkG0GULHQZe48ByNv6cgn+VbIKN2GgVG4=; b=X6Ie76zNxopLkkxMO3hq0jDNawQVRzjTcTQ1U9566pfCk3zFYVMn7lH9en8IZcSRIb 1LF/tk9WCMd2YstPXLAVSQtepXCq45I5P+DGpPcoN8r3c4f3VG+MJzz8J3liCMFnMKW2 Q/jk2lqFUdth5h7tuTxuRtugNpbQnqxpv0I1I33BY0Po2WZZOK1tlt3fOeLb08TZYJir YXdKSjqoJ0J2/ckJ7XRZQDclnOZkEyI920sZPCvMZCoguiPh/PG81vjkCkGAa/E4JC+C MbLKzLHgElzRSUg4I9PncjZMJE12Vd7qLxIqbKbtA57cJvOYx5JAqb+nNqK53f+W1T3H mEZA== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1739841275; x=1740446075; h=cc:to:subject:message-id:date:from:in-reply-to:references :mime-version:x-gm-message-state:from:to:cc:subject:date:message-id :reply-to; bh=REzFO3vfmyOkG0GULHQZe48ByNv6cgn+VbIKN2GgVG4=; b=dtZ2ON+ewo7rKNvu+Rj9mFBvDiWNgPhgpmz5+zwRSctf0FhVVrFDvSg5yDsT0oseR9 p5skkcTx+hCte4jT/nGWGh6jHEZmHWJis+lNta35PjUtkwA0W1rR4sYwegx2qk+uV2eY KLKyiizxdtvrvTbDONQW/4lbdftt8GoZdSCMJEiILjuMGJdEwe7gQUSG95MjN8ZXFtzH N4j0voHV5f6ujwiTqlJMe23KTYiuhnzsDYaKq8AXR1avDmbD8K9FutpxdTpYtCs3dKGm /AnfBNWjP+8WS+oGa+fRmQc8Zd4xvWMYcxxkBTYWChoeDXBrTE6md/Yyd81tz2a6rksJ jcuw== X-Gm-Message-State: AOJu0YyvPjMQD/ieULnsN0v76kwb5RlkN2UwEQXBVQb1daZOVdfct831 tCLzljEgLRVCqdReXFKUbAFJX2kXA7zfYie5MMNYluGqyZNkXFdZkQZ3JB/0zwjflxRqzsuX/UE zGHmGk9r8p5kqDQ9y7p7juaZrqtE= X-Gm-Gg: ASbGnctEBe4JTmYVDxGAwM649+w2bLdlzZvHYi9646GVSplCIm4UJAdc8xA9BIRSO+j kTk4tPRZInKcAYMi3se4a625cq7IHjrfZbEQbweJChu0WwxeJ9+3ZJoG+WAyDiAk+SEDHdTHadg == X-Google-Smtp-Source: AGHT+IEXgDhPrQ19q8y359eYr4jmo13DvIdKUOFzC4vqPtl1Gxon/ToPfy9DjNb5Hj8UbrXzMIugsJq8dLHpJeglGbY= X-Received: by 2002:a5d:5f4a:0:b0:38f:4244:68cb with SMTP id ffacd0b85a97d-38f42446bfbmr5902214f8f.12.1739841274739; Mon, 17 Feb 2025 17:14:34 -0800 (PST) MIME-Version: 1.0 References: <744079.1739775701@sss.pgh.pa.us> In-Reply-To: <744079.1739775701@sss.pgh.pa.us> From: WU Yan <4wuyan@gmail.com> Date: Tue, 18 Feb 2025 12:14:23 +1100 X-Gm-Features: AWEUYZlZcjiQE7sEmPaoOJhBAKStgkMRDbQwsmkfpi2ryKc_quyyyjETONiXkxU Message-ID: Subject: Re: Wasteful nested loop join when there is `limit` in the query To: Tom Lane Cc: pgsql-general@lists.postgresql.org Content-Type: multipart/alternative; boundary="00000000000025840e062e605d2b" List-Id: List-Help: List-Subscribe: List-Post: List-Owner: List-Archive: Archived-At: Precedence: bulk --00000000000025840e062e605d2b Content-Type: text/plain; charset="UTF-8" Thank you for your help, Tom. You are right. I added an index on employee.name (by making it unique), and then postgres can visit employee table in a pre-sorted manner, and can exit early without joining more rows. Just sharing the tweak I did to the example, if anyone else is interested in a quick test. I also populated 1 million rows so the example is no longer a toy demo. ```sql drop table if exists department; drop table if exists employee; create table department( id int primary key, name text); create table employee( id int primary key, name text unique, department_id int); INSERT INTO department (id, name) SELECT i+1, 'department' || i+1 FROM generate_series(0, 9) AS i; INSERT INTO employee (id, name, department_id) SELECT i+1, 'name' || i+1, i % 10 +1 FROM generate_series(0, 999999) AS i; analyze department; analyze employee; explain analyze select * from employee left outer join department on employee.department_id = department.id order by employee.name limit 10; ``` And here is the plan: ``` QUERY PLAN ---------------------------------------------------------------------------------------------------------------------------------------------------- Limit (cost=0.57..1.36 rows=10 width=34) (actual time=0.017..0.030 rows=10 loops=1) -> Nested Loop Left Join (cost=0.57..78630.06 rows=1000000 width=34) (actual time=0.016..0.028 rows=10 loops=1) -> Index Scan using employee_name_key on employee (cost=0.42..54855.68 rows=1000000 width=18) (actual time=0.008..0.015 rows=10 loops=1) -> Memoize (cost=0.15..0.16 rows=1 width=16) (actual time=0.001..0.001 rows=1 loops=10) Cache Key: employee.department_id Cache Mode: logical Hits: 6 Misses: 4 Evictions: 0 Overflows: 0 Memory Usage: 1kB -> Index Scan using department_pkey on department (cost=0.14..0.15 rows=1 width=16) (actual time=0.001..0.001 rows=1 loops=4) Index Cond: (id = employee.department_id) Planning Time: 0.189 ms Execution Time: 0.045 ms (11 rows) ``` Personally I still wish someday postgres can push down `limit` node together with `sort` node when certain conditions are met, so that there's no need to add an index :D Thank you again for your help! On Mon, 17 Feb 2025 at 18:01, Tom Lane wrote: > WU Yan <4wuyan@gmail.com> writes: > > Hello everyone, I am still learning postgres planner and performance > > optimization, so please kindly point out if I missed something obvious. > > An index on employee.name would likely help here. Even if we had > an optimization for pushing LIMIT down through a join (which you > are right, we don't) it could not push the LIMIT through a sort step. > So you need presorted output from the scan of "employee". I think > this example would behave better with that. You may also need to > test with non-toy amounts of data to get the plan you think is > better: an example with only half a dozen rows is going to be > swamped by startup costs. > > regards, tom lane > --00000000000025840e062e605d2b Content-Type: text/html; charset="UTF-8" Content-Transfer-Encoding: quoted-printable
Thank you for your help, Tom.

You are right. I adde= d an index on employee.name (by making= it unique), and then postgres can visit employee table in a pre-sorted man= ner, and can exit early without joining more rows.


Just sharing = the tweak I did to the example, if anyone else is interested in a quick tes= t. I also populated 1 million rows so the example is no longer a toy demo.<= br>
```sql
drop table if exists department;
drop table if exists e= mployee;

create table department(
=C2=A0 =C2=A0 id int primary ke= y,
=C2=A0 =C2=A0 name text);
create table employee(
=C2=A0 =C2=A0 = id int primary key,
=C2=A0 =C2=A0 name text unique,
=C2=A0 =C2=A0 dep= artment_id int);

INSERT INTO department (id, name)
SELECT i+1, &#= 39;department' || i+1
FROM generate_series(0, 9) AS i;

INSERT= INTO employee (id, name, department_id)
SELECT i+1, 'name' || i= +1, i % 10 +1
FROM generate_series(0, 999999) AS i;

analyze depar= tment;
analyze employee;

explain analyze
select *
from empl= oyee left outer join department
=C2=A0 =C2=A0 on employee.department_id = =3D department.id
order by employee.name limit 10;
```

And her= e is the plan:
```
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0QUERY PLAN
-------------= ---------------------------------------------------------------------------= ------------------------------------------------------------
=C2=A0Limit= =C2=A0(cost=3D0.57..1.36 rows=3D10 width=3D34) (actual time=3D0.017..0.030= rows=3D10 loops=3D1)
=C2=A0 =C2=A0-> =C2=A0Nested Loop Left Join =C2= =A0(cost=3D0.57..78630.06 rows=3D1000000 width=3D34) (actual time=3D0.016..= 0.028 rows=3D10 loops=3D1)
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0-> =C2= =A0Index Scan using employee_name_key on employee =C2=A0(cost=3D0.42..54855= .68 rows=3D1000000 width=3D18) (actual time=3D0.008..0.015 rows=3D10 loops= =3D1)
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0-> =C2=A0Memoize =C2=A0(cost= =3D0.15..0.16 rows=3D1 width=3D16) (actual time=3D0.001..0.001 rows=3D1 loo= ps=3D10)
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0Cache Ke= y: employee.department_id
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0Cache Mode: logical
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0 =C2=A0Hits: 6 =C2=A0Misses: 4 =C2=A0Evictions: 0 =C2=A0Overflows: 0 = =C2=A0Memory Usage: 1kB
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0-> =C2=A0Index Scan using department_pkey on department =C2=A0(co= st=3D0.14..0.15 rows=3D1 width=3D16) (actual time=3D0.001..0.001 rows=3D1 l= oops=3D4)
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 =C2=A0Index Cond: (id =3D employee.department_id)
=C2=A0Planning= Time: 0.189 ms
=C2=A0Execution Time: 0.045 ms
(11 rows)
```
Personally I still wish someday postgres can push down `limit` node toget= her with `sort` node when certain conditions are met, so that there's n= o need to add an index :D

Thank you again for your help!

On Mon, 17 Feb 2025 at 18:01, Tom Lane <tgl@sss.pgh.pa.us> wrote:
WU Yan <4wuyan@gmail.com> writes:
> Hello everyone, I am still learning postgres planner and performance > optimization, so please kindly point out if I missed something obvious= .

An index on employee.name would likely help here.=C2=A0 Even if we had
an optimization for pushing LIMIT down through a join (which you
are right, we don't) it could not push the LIMIT through a sort step. So you need presorted output from the scan of "employee".=C2=A0 I= think
this example would behave better with that.=C2=A0 You may also need to
test with non-toy amounts of data to get the plan you think is
better: an example with only half a dozen rows is going to be
swamped by startup costs.

=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 regards, tom lane
--00000000000025840e062e605d2b--