Query processing and optimization: join algorithms, equivalence rules, estimation
The steps a query goes through, how the costs of nested-loop/block/indexed/merge/hash joins are calculated, and how the optimizer rewrites the query tree.
Last week we calculated the cost of selecting from a single table. This week the query combines several tables. The same SQL query can have dozens of different execution plans, and the cost difference between them can be thousands of times. The optimizer’s job is to find a cheap one among them. The exam has three types of questions: calculate a join cost, compare two plans, and rewrite a query tree with equivalence rules.
Golden rules:
- Cost = block transfers + seeks. Do not forget the number of seeks; half of the question comes from there.
- In nested-loop variants, put the smaller relation outside.
- Push selection () as far down as possible. Reducing the number of rows entering the join is the biggest gain.
- An equivalence rule does not change the result. It only changes the cost.
Steps of query processing
- Parsing and translation: the SQL syntax is checked, table and column names are validated, and the query is translated into a relational algebra expression.
- Optimization: equivalent algebra expressions and the algorithms for each operation are considered. The cost of each plan is estimated (using statistics), and the cheapest is chosen. The result is a query evaluation plan: a tree that states which algorithm will be used at each node.
- Evaluation: the plan is run.
The cost measure: for block transfers and seeks
On this page too, ms and ms.
External sort-merge
If the table does not fit in memory, sorting happens in two phases. = the number of buffer blocks in memory.
- Run creation: read blocks, sort them in memory, write them as a run. runs result.
- Merging: in each pass runs are merged (1 block is for the output). The number of passes is .
Example: , . The number of runs is . In each pass 10 runs are merged: , 2 passes. Transfers . Writing the final result to disk is not counted, because it is usually passed directly to the next operation.
Join algorithms
The example in the body: = takes (, ), = student (, ). The join condition is takes.stu_id = student.stu_id.
Nested-loop join
for each tuple t_r in r:
for each tuple t_s in s:
if (t_r, t_s) satisfies the condition, write to the output
In the worst case (one buffer per relation):
With takes outer: transfers, seeks. The time is ms, about 2.4 minutes. With student outer: transfers. Here the factor is the number of tuples of the outer, so having fewer tuples outside is better.
Best case: if the smaller relation fits entirely in memory, both are read once: transfers, 2 seeks.
Block nested-loop join
Match not tuple by tuple but block by block: for each block of the outer, the inner relation is scanned once.
With takes outer: transfers, 800 seeks. With student outer: transfers, 200 seeks. This time too the smaller relation wins on the outside.
If there are buffers, blocks are given to the outer relation (one for the inner, one for the output):
With and student outer: chunks, transfers, 20 seeks.
Indexed nested-loop join
If there is an index on the join attribute in the inner relation, instead of scanning the inner relation, each outer tuple does a search in the index. = the cost of one search + fetching the match.
With takes outer and a B+ tree on student.stu_id: since it is equality on a key, block accesses. The number of accesses is . Since each is a seek + transfer, in terms of time this is more expensive than block nested-loop. Indexed nested-loop shines when the outer relation is small (for example, when a tight has been applied first).
Merge join
If the two relations are sorted on the join attribute, they are scanned in parallel and read once:
In the example, . If they are not sorted, the sorting cost is added first. With , sorting takes is 2000, and sorting student ( runs, 1 pass) is transfers. Total . It is used only for equality and natural joins (equi-joins).
Hash join
- Partitioning: the two relations are divided into partitions with the same hash function. Tuples that can match fall into partitions with the same number.
- For each , is brought into memory and a hash table is built in memory (build input), then is read and probed against this table (probe input).
The smaller relation becomes the build input; each must fit in memory. If no recursive partitioning is needed:
Partitioning is read + write , and the build/probe phase is one read . In the example .
Summary comparison (takes ⋈ student)
| Algorithm | Transfers | Note |
|---|---|---|
| Nested-loop, takes outer | 1 000 400 | worst |
| Block nested-loop, student outer | 40 100 | |
| Block nested-loop, | 4 100 | |
| Indexed nested-loop, | 40 400 accesses | all random |
| Merge join + two sorts | 2 800 | the result comes out sorted |
| Hash join | 1 500 | equality join |
PITFALL · or in the formulas? In simple nested-loop the factor is the tuple count of the outer , in block nested-loop the block count of the outer . In indexed nested-loop it is again. This is where the most points are lost in the exam.
Equivalence rules
Two algebra expressions are equivalent if they give the same result on every database instance. The main rules the optimizer uses:
| Rule | Statement |
|---|---|
| Selection cascade | |
| Selection is commutative | |
| Projection cascade | , if |
| and | |
| Join is commutative | |
| Join is associative | |
| Pushing selection down | , if uses only attributes of |
| Pushing projection down | , must contain the attributes in both and |
| Set operations | and are commutative and associative; distributes over them and over |
Join commutativity changes the column order, but since columns in a relation are identified by name, the result is the same relation.
Heuristic optimization
Trying all plans is expensive. Heuristic rules give a good plan in most cases:
- Break conjunctive selections apart with the cascade.
- Push every selection as far down as possible in the tree.
- Do the most restrictive selection (the one that leaves the fewest rows) and join first.
- Turn the + pair into . Avoid the Cartesian product.
- Do projection early and drop unneeded columns. Do not forget to keep the columns needed for the join.
Example: transforming a query tree
On the sample university database: “the names of CS-department students who took a 3-credit course.” Since the course table also has dept_name, we rename it c_dept; otherwise the natural join would also try to equate the student’s department with the course’s department.
The naive translation:
With cascade + pushing selection down + early projection:
intermediate result sizes (sample DB) naive plan optimized plan
student ⋈ takes ⋈ course 14 -
σ dept_name='CS'(student) - 3
σ credits=3(course) - 4
takes ⋈ σ(course) - 5
result Ada Ada
The two plans give the same result (only Ada). The naive plan first produces a 14-row join and then filters; the optimized plan enters the join with 3 and 5 rows. At real sizes this difference is millions of rows.
PITFALL · natural join and same-named columns. If you write
student ⋈ takes ⋈ coursewithout renaming,dept_nameis equated too, and on the sample database 9 rows come out instead of 14: the courses taken outside the student’s own department are lost. This is not an optimization, it is a wrong query.
Statistics and size estimation
To estimate the cost of a plan, the optimizer uses the statistics in the catalog: , , , and , the number of distinct values of in .
| Expression | Estimated number of rows |
|---|---|
| (independence assumption) | |
| , common attribute is a key of | ; exactly if it is a foreign key |
| , common attribute is not a key |
Example: students, . The estimate for dept_name = 'CS' is . If tot_cred ranges from 0 to 240, the estimate for tot_cred <= 60 is . Both together: , that is, about 63 rows.
An estimate is an assumption. In our sample database student has 7 rows and COUNT(DISTINCT dept_name) = 3 (NULL is not counted). The estimate is , while in reality there are 3 students in CS. If the distribution is not even, the estimate drifts; real systems keep histograms for this reason.
Common mistakes
| Wrong idea | The truth |
|---|---|
| in nested-loop | For the tuple-based one it is ; is block nested-loop |
| The large relation should be outer | In nested-loop variants the small relation is outer |
| Skipping seeks | NLJ has , BNLJ has seeks; a seek is 40 times more expensive than a transfer |
| All buffers go to the outer relation | : one block for the inner relation, one for the output |
| The number of passes in external sort is | The merge is -way: |
| Merge join is always | Only if already sorted; otherwise the sort cost is added |
| In a hash join the large relation is the build input | The small relation is the build input; its partitions must fit in memory |
| Push the selection into whichever branch of the join you like | Only if all the attributes in the condition are in that branch |
| Dropping the join column during early projection | The join attribute must be kept if it is needed at a higher level |
| Using instead of for the join estimate | The tighter one is the smaller value: divide by the larger |
Test yourself
Exam-style questions. Solve them on paper first, then reveal the answer.
Put the steps of query processing in the correct order: optimization, parsing and translation, evaluation.
Show answer
First the SQL syntax is checked and translated into a relational algebra expression (parsing and translation).
Then the optimizer chooses the one with the lowest estimated cost among equivalent plans (optimization).
Finally the evaluation engine runs the chosen plan and returns the result.
Relation has tuples in blocks; has tuples in blocks. Using a simple (tuple) nested-loop join with as the outer relation and only one buffer block per relation (worst case), give the number of block transfers and seeks.
Show answer
For each tuple of the outer relation, the whole inner relation is read: .
The outer relation itself is read once: . Total 360300 transfers.
Seeks: one seek into the inner relation for each outer tuple () and one seek for each block of the outer relation (): .
Same () and (). For a block nested-loop join (worst case, one buffer block each), compute the block transfers with as outer and with as outer. Which should be the outer relation?
Show answer
Formula: transfers, seeks. outer: transfers, 600 seeks. outer: transfers, 120 seeks.
The product part is the same; the difference is in the second term and in the number of seeks. The smaller relation should be outer: .
Block nested-loop join of () and () with memory buffers, as outer. Give block transfers and seeks.
Show answer
blocks are given to the outer relation (1 block for the inner relation, 1 block for the output).
The outer relation is read in chunks. For each chunk the inner relation is scanned once.
Transfers: . Seeks: .
The 18060 transfers of the one-buffer version dropped to 960.
Indexed nested-loop join: (, ) is the outer relation and has a B+ tree index on the join attribute. Each index lookup plus fetching the matching tuple costs block accesses. What is the total number of block accesses? If instead (, ) is outer and has an index with the same , what is the cost?
Show answer
Formula: . outer: . outer: .
In indexed nested-loop, what matters is the number of tuples of the outer relation. Putting the relation with fewer tuples outside is cheaper.
Both () and () are already sorted on the join attribute. How many block transfers does a merge join need (ignore the output)?
Show answer
Two sorted relations are scanned simultaneously, each once: transfers.
If there are not many repeats in the join value (if the tuples with the same value fit in memory), there is no extra reading.
External sort-merge of a relation with blocks using buffer blocks. How many initial runs are created, how many merge passes are needed, and what is the total number of block transfers (do not count writing the final result)?
Show answer
The number of initial runs: .
In each pass runs are merged. The number of passes is ; , so 3 (43 → 8 → 2 → 1).
Transfers: .
Run creation is 1 read + 1 write, each pass is 1 read + 1 write, and the write of the last pass is not counted.
For () and (), both unsorted, compare a hash join (no recursive partitioning, ignore partially filled blocks) with sort + merge join where sorting costs 2100 and sorting costs 300 transfers. Which is cheaper?
Show answer
Hash join: the two relations are read and written while partitioning (), then read once more in the build/probe phase (): .
Sort-merge: .
The hash join is cheaper. But in cases such as wanting the result sorted on the join key, merge join may gain an advantage.
Relation instructor has tuples and . Estimate the size of . Also estimate (salary in thousands) if salaries range uniformly from 10 to 90.
Show answer
Equality: values are assumed to be evenly distributed, .
Comparison: .
takes has 6000 tuples and student has 1500 tuples. takes.stu_id is a foreign key referencing the primary key student.stu_id (NOT NULL). Estimate the size of .
Show answer
The common attribute is a key for student and takes.stu_id is a foreign key. Each takes tuple matches exactly one student tuple.
The result is exactly tuples. General rule: if the common attribute is a key of , the join gives at most tuples.
On the sample university database, rewrite by pushing the selection down. How many tuples does the result have?
Show answer
dept_name exists only in student, so the selection can go down into the student branch of the join: .
The EE students: S3 (Cem) and S6 (Firat). S6 took no course. S3 has three records: EE210, MA101, CS101.
The result is 3 tuples. Only 2 student tuples enter the join instead of 7.
has 1000 tuples, has 3000 tuples, and . is not a key of either relation. Estimate the size of .
Show answer
Two estimates: and .
The smaller one (the tighter estimate) is taken: . This means dividing by the larger value.
Verification: All join, external sort and size estimation numbers were computed from the formulas with node; the equivalence rules (cascade, pushing selection down, join commutativity/associativity) and the query tree transformation were run on the sample university DB with both relational algebra (JS set operations) and node:sqlite, and the results were checked to be the same (verify/SEN2104/13.mjs).