82
Chapter 14. Performance Tips
Index Cond: (unique2 = t1.unique2)
In this plan, we have a nested-loop join node with two table scans as inputs, or children. The inden-
tation of the node summary lines reflects the plan tree structure. The join’s first, or “outer”, child is
abitmap scan similar to those we saw before. Its cost and row count are the same as we’d get from
SELECT ... WHERE unique1 < 10
because we are applying the
WHERE
clause
unique1 < 10
at that node. The
t1.unique2 = t2.unique2
clause is notrelevant yet, so it doesn’taffectthe row
count of the outer scan. The nested-loop join node will run its second, or “inner” child once for each
row obtained from the outer child. Column values from the current outer row can be plugged into the
inner scan; here, the
t1.unique2
value from the outer row is available, so we get a plan and costs
similar to what we saw above for a simple
SELECT ... WHERE t2.unique2 =
constant
case.
(The estimated cost is actually a bit lower than what was seen above, as a result of caching that’s
expected to occur during the repeated index scans on
t2
.) The costs of the loop node are then set on
the basis of the cost of the outer scan, plus one repetition of the inner scan for each outer row (10 *
7.87, here), plus a little CPU time for joinprocessing.
In this example the join’s output row count is the same as the product of the two scans’ row counts,
but that’s not true in all cases because there can be additional
WHERE
clauses that mention both tables
and so can only be applied at the joinpoint, not to either input scan. Here’s an example:
EXPLAIN SELECT
*
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 10 AND t2.unique2 < 10 AND t1.hundred < t2.hundred;
QUERY PLAN
---------------------------------------------------------------------------------------------
Nested Loop
(cost=4.65..49.46 rows=33 width=488)
Join Filter: (t1.hundred < t2.hundred)
->
Bitmap Heap Scan on tenk1 t1
(cost=4.36..39.47 rows=10 width=244)
Recheck Cond: (unique1 < 10)
->
Bitmap Index Scan on tenk1_unique1
(cost=0.00..4.36 rows=10 width=0)
Index Cond: (unique1 < 10)
->
Materialize
(cost=0.29..8.51 rows=10 width=244)
->
Index Scan using tenk2_unique2 on tenk2 t2
(cost=0.29..8.46 rows=10 width=244)
Index Cond: (unique2 < 10)
The condition
t1.hundred < t2.hundred
can’t be tested in the
tenk2_unique2
index, so it’s
applied at the join node. This reduces the estimated output row count of the join node, but does not
change either input scan.
Notice that here the planner has chosen to “materialize” the inner relation of the join, by putting a
Materialize plan node atop it. This means that the
t2
index scan will be done just once, even though
the nested-loop join node needs to read that data tentimes, once for eachrow from the outer relation.
The Materialize node saves the data in memory as it’s read, and then returns the data from memory
on each subsequent pass.
When dealing withouter joins, youmightsee joinplan nodes withboth“JoinFilter”andplain “Filter”
conditions attached. Join Filter conditions come from the outer join’s
ON
clause, so a row that fails
the Join Filter condition could still get emitted as a null-extended row. But a plain Filter condition is
applied after the outer-join rules and so acts to remove rows unconditionally. In an inner join there is
no semantic difference between these types of filters.
If we change the query’s selectivity a bit, we might get a very different join plan:
EXPLAIN SELECT
*
390
72
Chapter 14. Performance Tips
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;
QUERY PLAN
------------------------------------------------------------------------------------------
Hash Join
(cost=230.47..713.98 rows=101 width=488)
Hash Cond: (t2.unique2 = t1.unique2)
->
Seq Scan on tenk2 t2
(cost=0.00..445.00 rows=10000 width=244)
->
Hash
(cost=229.20..229.20 rows=101 width=244)
->
Bitmap Heap Scan on tenk1 t1
(cost=5.07..229.20 rows=101 width=244)
Recheck Cond: (unique1 < 100)
->
Bitmap Index Scan on tenk1_unique1
(cost=0.00..5.04 rows=101 width=0)
Index Cond: (unique1 < 100)
Here, the planner has chosen to use a hash join, in which rows of one table are entered into an in-
memory hash table, after whichthe other table is scanned andthe hash table is probed for matches to
each row. Again note how the indentationreflects the plan structure: the bitmap scan on
tenk1
is the
input to the Hash node, which constructs the hash table. That’s then returned to the Hash Join node,
which reads rows from its outer child plan and searches the hash table for each one.
Another possible type of join is a merge join, illustrated here:
EXPLAIN SELECT
*
FROM tenk1 t1, onek t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;
QUERY PLAN
------------------------------------------------------------------------------------------
Merge Join
(cost=198.11..268.19 rows=10 width=488)
Merge Cond: (t1.unique2 = t2.unique2)
->
Index Scan using tenk1_unique2 on tenk1 t1
(cost=0.29..656.28 rows=101 width=244)
Filter: (unique1 < 100)
->
Sort
(cost=197.83..200.33 rows=1000 width=244)
Sort Key: t2.unique2
->
Seq Scan on onek t2
(cost=0.00..148.00 rows=1000 width=244)
Merge join requires its input data to be sorted on the join keys. In this plan the
tenk1
data is sorted
by using anindex scanto visit the rows inthe correct order, buta sequentialscan andsortis preferred
for
onek
,because there are many more rows to be visited in that table. (Sequential-scan-and-sort fre-
quently beats an index scan for sorting many rows, because of the nonsequential disk access required
by the index scan.)
One way to look at variant plans is to force the planner to disregard whatever strategy it thought
was the cheapest, using the enable/disable flags described in Section 18.7.1. (This is a crude tool, but
useful. See also Section 14.3.) For example, if we’re unconvincedthat sequential-scan-and-sort is the
best way to deal withtable
onek
in the previous example, we could try
SET enable_sort = off;
EXPLAIN SELECT
*
FROM tenk1 t1, onek t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;
QUERY PLAN
391
C# Word - Word Conversion in C#.NET using other external third-party dependencies like Adobe Acrobat. Word SDK to convert Word document to PDF document. demo code for Word to TIFF image conversion
adding a jpeg to a pdf; add image to pdf acrobat
82
Chapter 14. Performance Tips
------------------------------------------------------------------------------------------
Merge Join
(cost=0.56..292.65 rows=10 width=488)
Merge Cond: (t1.unique2 = t2.unique2)
->
Index Scan using tenk1_unique2 on tenk1 t1
(cost=0.29..656.28 rows=101 width=244)
Filter: (unique1 < 100)
->
Index Scan using onek_unique2 on onek t2
(cost=0.28..224.79 rows=1000 width=244)
which shows that theplanner thinks that sorting
onek
byindex-scanningis about 12% more expensive
than sequential-scan-and-sort. Of course, the next question is whether it’s right about that. We can
investigate that using
EXPLAIN ANALYZE
,as discussed below.
14.1.2.
EXPLAIN ANALYZE
It is possible to check the accuracy of the planner’s estimates by using
EXPLAIN
’s
ANALYZE
option.
With this option,
EXPLAIN
actually executes the query, andthendisplays the true rowcounts andtrue
run time accumulated within each plan node, along with the same estimates that a plain
EXPLAIN
shows. For example, we might get a result like this:
EXPLAIN ANALYZE SELECT
*
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 10 AND t1.unique2 = t2.unique2;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------
Nested Loop
(cost=4.65..118.62 rows=10 width=488) (actual time=0.128..0.377 rows=10 loops=1)
->
Bitmap Heap Scan on tenk1 t1
(cost=4.36..39.47 rows=10 width=244) (actual time=0.057..0.121 rows=10 loops=1)
Recheck Cond: (unique1 < 10)
->
Bitmap Index Scan on tenk1_unique1
(cost=0.00..4.36 rows=10 width=0) (actual time=0.024..0.024 rows=10 loops=1)
Index Cond: (unique1 < 10)
->
Index Scan using tenk2_unique2 on tenk2 t2
(cost=0.29..7.91 rows=1 width=244) (actual time=0.021..0.022 rows=1 loops=10)
Index Cond: (unique2 = t1.unique2)
Planning time: 0.181 ms
Execution time: 0.501 ms
Note that the “actual time” values are in milliseconds of real time, whereas the
cost
estimates are
expressed inarbitrary units; so they are unlikely to matchup. The thing that’s usually mostimportant
to look for is whether the estimated row counts are reasonably close to reality. In this example the
estimates were all dead-on, but that’s quite unusual in practice.
In some query plans, it is possible for a subplan node to be executed more than once. For example,
the inner index scanwill be executed once per outer row in the above nested-loop plan. In such cases,
the
loops
value reports the total number of executions of the node, and the actual time and rows
values shown are averages per-execution. This is done to make the numbers comparable with the way
that the cost estimates are shown. Multiply by the
loops
value to get the total time actually spent in
the node. In the above example, we spent a total of 0.220 milliseconds executing the index scans on
tenk2
.
In some cases
EXPLAIN ANALYZE
shows additional execution statistics beyond the plan node execu-
tion times and row counts. For example, Sort and Hash nodes provide extra information:
EXPLAIN ANALYZE SELECT
*
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2 ORDER BY t1.fivethous;
QUERY PLAN
392
70
Chapter 14. Performance Tips
--------------------------------------------------------------------------------------------------------------------------------------------
Sort
(cost=717.34..717.59 rows=101 width=488) (actual time=7.761..7.774 rows=100 loops=1)
Sort Key: t1.fivethous
Sort Method: quicksort
Memory: 77kB
->
Hash Join
(cost=230.47..713.98 rows=101 width=488) (actual time=0.711..7.427 rows=100 loops=1)
Hash Cond: (t2.unique2 = t1.unique2)
->
Seq Scan on tenk2 t2
(cost=0.00..445.00 rows=10000 width=244) (actual time=0.007..2.583 rows=10000 loops=1)
->
Hash
(cost=229.20..229.20 rows=101 width=244) (actual time=0.659..0.659 rows=100 loops=1)
Buckets: 1024
Batches: 1
Memory Usage: 28kB
->
Bitmap Heap Scan on tenk1 t1
(cost=5.07..229.20 rows=101 width=244) (actual time=0.080..0.526 rows=100 loops=1)
Recheck Cond: (unique1 < 100)
->
Bitmap Index Scan on tenk1_unique1
(cost=0.00..5.04 rows=101 width=0) (actual time=0.049..0.049 rows=100 loops=1)
Index Cond: (unique1 < 100)
Planning time: 0.194 ms
Execution time: 8.008 ms
The Sort node shows the sort methodused (in particular, whether the sort was in-memory or on-disk)
and the amount of memory or disk space needed. The Hash node shows the number of hash buckets
and batches as well as the peak amount of memory used for the hash table. (If the number of batches
exceeds one, there will also be disk space usage involved, but that is not shown.)
Another type of extra information is the number of rows removed by a filter condition:
EXPLAIN ANALYZE SELECT
*
FROM tenk1 WHERE ten < 7;
QUERY PLAN
---------------------------------------------------------------------------------------------------------
Seq Scan on tenk1
(cost=0.00..483.00 rows=7000 width=244) (actual time=0.016..5.107 rows=7000 loops=1)
Filter: (ten < 7)
Rows Removed by Filter: 3000
Planning time: 0.083 ms
Execution time: 5.905 ms
These counts can be particularly valuable for filter conditions applied at join nodes. The “Rows Re-
moved” line only appears when at least one scanned row, or potential join pair in the case of a join
node, is rejected by the filter condition.
Acase similar to filter conditions occurs with “lossy” index scans. For example, consider this search
for polygons containing a specific point:
EXPLAIN ANALYZE SELECT
*
FROM polygon_tbl WHERE f1 @> polygon ’(0.5,2.0)’;
QUERY PLAN
------------------------------------------------------------------------------------------------------
Seq Scan on polygon_tbl
(cost=0.00..1.05 rows=1 width=32) (actual time=0.044..0.044 rows=0 loops=1)
Filter: (f1 @> ’((0.5,2))’::polygon)
Rows Removed by Filter: 4
Planning time: 0.040 ms
Execution time: 0.083 ms
The planner thinks (quite correctly) that this sample table is too small to bother with an index scan,
so we have a plain sequential scan in which all the rows got rejected by the filter condition. But if we
force an index scan to be used, we see:
SET enable_seqscan TO off;
EXPLAIN ANALYZE SELECT
*
FROM polygon_tbl WHERE f1 @> polygon ’(0.5,2.0)’;
393
73
Chapter 14. Performance Tips
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------
Index Scan using gpolygonind on polygon_tbl
(cost=0.13..8.15 rows=1 width=32) (actual time=0.062..0.062 rows=0 loops=1)
Index Cond: (f1 @> ’((0.5,2))’::polygon)
Rows Removed by Index Recheck: 1
Planning time: 0.034 ms
Execution time: 0.144 ms
Here we can see that the index returned one candidate row, which was then rejected by a recheck of
the index condition. This happens because a GiST index is “lossy” for polygon containment tests:
it actually returns the rows with polygons that overlap the target, and then we have to do the exact
containment test on those rows.
EXPLAIN
has a
BUFFERS
option that can be used with
ANALYZE
to get even more run time statistics:
EXPLAIN (ANALYZE, BUFFERS) SELECT
*
FROM tenk1 WHERE unique1 < 100 AND unique2 > 9000;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on tenk1
(cost=25.08..60.21 rows=10 width=244) (actual time=0.323..0.342 rows=10 loops=1)
Recheck Cond: ((unique1 < 100) AND (unique2 > 9000))
Buffers: shared hit=15
->
BitmapAnd
(cost=25.08..25.08 rows=10 width=0) (actual time=0.309..0.309 rows=0 loops=1)
Buffers: shared hit=7
->
Bitmap Index Scan on tenk1_unique1
(cost=0.00..5.04 rows=101 width=0) (actual time=0.043..0.043 rows=100 loops=1)
Index Cond: (unique1 < 100)
Buffers: shared hit=2
->
Bitmap Index Scan on tenk1_unique2
(cost=0.00..19.78 rows=999 width=0) (actual time=0.227..0.227 rows=999 loops=1)
Index Cond: (unique2 > 9000)
Buffers: shared hit=5
Planning time: 0.088 ms
Execution time: 0.423 ms
The numbers provided by
BUFFERS
help to identify which parts of the query are the most I/O-
intensive.
Keep in mind that because
EXPLAIN ANALYZE
actually runs the query, any side-effects will happen
as usual, even though whatever results the query might output are discarded in favor of printing the
EXPLAIN
data. If you want to analyze a data-modifying query without changing your tables, you can
roll the command back afterwards, for example:
BEGIN;
EXPLAIN ANALYZE UPDATE tenk1 SET hundred = hundred + 1 WHERE unique1 < 100;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------
Update on tenk1
(cost=5.07..229.46 rows=101 width=250) (actual time=14.628..14.628 rows=0 loops=1)
->
Bitmap Heap Scan on tenk1
(cost=5.07..229.46 rows=101 width=250) (actual time=0.101..0.439 rows=100 loops=1)
Recheck Cond: (unique1 < 100)
->
Bitmap Index Scan on tenk1_unique1
(cost=0.00..5.04 rows=101 width=0) (actual time=0.043..0.043 rows=100 loops=1)
Index Cond: (unique1 < 100)
Planning time: 0.079 ms
Execution time: 14.727 ms
ROLLBACK;
394
89
Chapter 14. Performance Tips
As seen in this example, when the query is an
INSERT
,
UPDATE
,or
DELETE
command, the actual
work of applying the table changes is done by a top-level Insert, Update, or Delete plan node. The
plannodes underneath this node perform the work of locating the old rows and/or computing the new
data. So above, we see the same sort of bitmap table scan we’ve seen already, and its output is fed to
an Update node that stores the updated rows. It’s worthnoting that although the data-modifying node
can take a considerable amount of run time (here, it’s consuming the lion’s share of the time), the
planner does not currently add anything to the cost estimates to account for that work. That’s because
the work to be done is the same for every correct query plan, so it doesn’t affect planning decisions.
The
Planning time
shown by
EXPLAIN ANALYZE
is the time it took to generate the query plan
from the parsed query and optimize it. It does not include parsing or rewriting.
The
Execution time
shownby
EXPLAIN ANALYZE
includes executor start-up and shut-downtime,
as well as the time to run any triggers that are fired, but it does not include parsing, rewriting, or
planning time. Time spent executing
BEFORE
triggers, if any, is included in the time for the related
Insert, Update, or Delete node; but time spent executing
AFTER
triggers is not counted there because
AFTER
triggersare fired after completionof thewhole plan. The total time spent ineachtrigger (either
BEFORE
or
AFTER
)isalso shown separately. Note that deferred constraint triggers willnotbeexecuted
until end of transaction and are thus not considered at all by
EXPLAIN ANALYZE
.
14.1.3. Caveats
There are two significant ways in which run times measured by
EXPLAIN ANALYZE
can deviate from
normal execution of the same query. First, since no output rows are delivered to the client, network
transmission costs and I/O conversion costs are not included. Second, the measurement overhead
added by
EXPLAIN ANALYZE
can be significant, especially onmachines withslow
gettimeofday()
operating-system calls. Youcanusethepg_test_timing tooltomeasure the overhead of timingon your
system.
EXPLAIN
results should not be extrapolated to situations muchdifferent from the one you are actually
testing; for example, results on a toy-sized table cannot be assumed to apply to large tables. The
planner’s cost estimates are not linear and so it might choose a different plan for a larger or smaller
table. An extreme example is that on a table that only occupies one disk page, you’ll nearly always
get a sequential scan plan whether indexes are available or not. The planner realizes that it’s going to
take one disk page read to process the table in any case, so there’s no value in expending additional
page reads to look at an index. (We saw this happening in the
polygon_tbl
example above.)
There are cases in which the actual and estimated values won’t match up well, but nothing is really
wrong. One suchcase occurs when plan node executionis stopped shortby a
LIMIT
or similar effect.
For example, in the
LIMIT
query we used before,
EXPLAIN ANALYZE SELECT
*
FROM tenk1 WHERE unique1 < 100 AND unique2 > 9000 LIMIT 2;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------
Limit
(cost=0.29..14.71 rows=2 width=244) (actual time=0.177..0.249 rows=2 loops=1)
->
Index Scan using tenk1_unique2 on tenk1
(cost=0.29..72.42 rows=10 width=244) (actual time=0.174..0.244 rows=2 loops=1)
Index Cond: (unique2 > 9000)
Filter: (unique1 < 100)
Rows Removed by Filter: 287
Planning time: 0.096 ms
Execution time: 0.336 ms
the estimated cost and row count for the Index Scan node are shown as thoughit were run to comple-
tion. But in reality the Limit node stopped requesting rows after it got two, so the actual row count is
395
101
Chapter 14. Performance Tips
only 2 and the run time is less than the cost estimate would suggest. This is not an estimation error,
only a discrepancy in the way the estimates andtrue values are displayed.
Merge joins also have measurement artifacts that can confuse the unwary. A merge join will stop
reading one input if it’s exhausted the other input and the next key value in the one input is greater
than the last key value of the other input; in such a case there can be no more matches and so no
need to scan the rest of the first input. This results in not reading all of one child, with results like
those mentioned for
LIMIT
.Also, if the outer (first) child contains rows with duplicate key values,
the inner (second) child is backed up and rescanned for the portion of its rows matching that key
value.
EXPLAIN ANALYZE
counts these repeated emissions of the same inner rows as if they were
real additional rows. When there are many outer duplicates, the reported actual row count for the
inner child plan node canbe significantly larger than the number of rows that are actually inthe inner
relation.
BitmapAndandBitmapOr nodes always report their actualrow counts as zero, dueto implementation
limitations.
14.2. Statistics Used by the Planner
As we saw in the previous section, the query planner needs to estimate the number of rows retrieved
by a query in order to make good choices of query plans. This section provides a quick look at the
statistics that the system uses for these estimates.
One component of the statistics is the total number of entries in each table and index, as well as
the number of disk blocks occupied by each table and index. This information is kept in the table
pg_class
,in the columns
reltuples
and
relpages
.We can look at it with queries similar to this
one:
SELECT relname, relkind, reltuples, relpages
FROM pg_class
WHERE relname LIKE ’tenk1%’;
relname
| relkind | reltuples | relpages
----------------------+---------+-----------+----------
tenk1
| r
|
10000 |
358
tenk1_hundred
| i
|
10000 |
30
tenk1_thous_tenthous | i
|
10000 |
30
tenk1_unique1
| i
|
10000 |
30
tenk1_unique2
| i
|
10000 |
30
(5 rows)
Here we can see that
tenk1
contains 10000 rows, as do its indexes, but the indexes are (unsurpris-
ingly) much smaller than the table.
For efficiency reasons,
reltuples
and
relpages
are not updated on-the-fly, and so they usually
contain somewhat out-of-date values. They are updated by
VACUUM
,
ANALYZE
,and a few DDL com-
mands such as
CREATE INDEX
.A
VACUUM
or
ANALYZE
operation that does not scan the entire table
(whichis commonly the case) willincrementallyupdate the
reltuples
count on the basis of thepart
of thetable itdidscan, resultingin an approximate value. In any case, the planner will scalethevalues
it finds in
pg_class
to match the current physical table size, thus obtaining a closer approximation.
Most queries retrieve only a fractionof the rows in a table, due to
WHERE
clauses thatrestrict the rows
to be examined. The planner thus needs to make an estimate of the selectivity of
WHERE
clauses, that
is, the fraction of rows that match each condition in the
WHERE
clause. The information used for this
396
131
Chapter 14. Performance Tips
task is stored in the
pg_statistic
system catalog. Entries in
pg_statistic
are updated by the
ANALYZE
and
VACUUM ANALYZE
commands, andarealways approximate evenwhenfreshly updated.
Rather than lookat
pg_statistic
directly, it’s better to lookat its view
pg_stats
whenexamining
the statistics manually.
pg_stats
is designed to be more easily readable. Furthermore,
pg_stats
is
readable by all, whereas
pg_statistic
is only readable by a superuser. (This prevents unprivileged
users from learning something about the contents of other people’s tables from the statistics. The
pg_stats
view is restricted to show only rows about tables that the current user can read.) For
example, we might do:
SELECT attname, inherited, n_distinct,
array_to_string(most_common_vals, E’\n’) as most_common_vals
FROM pg_stats
WHERE tablename = ’road’;
attname | inherited | n_distinct |
most_common_vals
---------+-----------+------------+------------------------------------
name
| f
|
-0.363388 | I- 580
Ramp+
|
|
| I- 880
Ramp+
|
|
| Sp Railroad
+
|
|
| I- 580
+
|
|
| I- 680
Ramp
name
| t
|
-0.284859 | I- 880
Ramp+
|
|
| I- 580
Ramp+
|
|
| I- 680
Ramp+
|
|
| I- 580
+
|
|
| State Hwy 13
Ramp
(2 rows)
Note thattwo rows are displayed for the same column, one corresponding to the complete inheritance
hierarchy starting at the
road
table (
inherited
=
t
), and another one including only the
road
table
itself (
inherited
=
f
).
The amountof informationstoredin
pg_statistic
by
ANALYZE
,in particular themaximum number
of entries in the
most_common_vals
and
histogram_bounds
arrays for each column, can be set
on a column-by-column basis using the
ALTER TABLE SET STATISTICS
command, or globally by
setting the default_statistics_target configuration variable. The default limit is presently 100 entries.
Raising the limit might allow more accurate planner estimates to be made, particularly for columns
with irregular datadistributions, atthe price of consumingmorespace in
pg_statistic
andslightly
more time to compute the estimates. Conversely, a lower limit might be sufficient for columns with
simple data distributions.
Further details about the planner’s use of statistics canbe found in Chapter 61.
14.3. Controlling the Planner with Explicit
JOIN
Clauses
It is possible to control the query planner to some extent by using the explicit
JOIN
syntax. To see
why this matters, we first need some background.
In a simple join query, such as:
SELECT
*
FROM a, b, c WHERE a.id = b.id AND b.ref = c.id;
the planner is free to join the given tables in any order. For example, it could generate a query plan
that joins A to B, using the
WHERE
condition
a.id = b.id
,and then joins C to this joined table,
397
Documents you may be interested
Documents you may be interested