SEN2104 · Week 13 · Query Processing and Query Optimization

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.

query processingparsingquery evaluation planquery optimizationblock transferseekexternal sort-mergerunnested-loop joinblock nested-loop joinindexed nested-loop joinmerge joinhash joinbuild inputprobe inputequivalence rulesheuristic optimizationquery treeselectivityV(A, r)join size estimation

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:

  1. Cost = block transfers + seeks. Do not forget the number of seeks; half of the question comes from there.
  2. In nested-loop variants, put the smaller relation outside.
  3. Push selection (σ\sigma) as far down as possible. Reducing the number of rows entering the join is the biggest gain.
  4. An equivalence rule does not change the result. It only changes the cost.

Steps of query processing

  1. Parsing and translation: the SQL syntax is checked, table and column names are validated, and the query is translated into a relational algebra expression.
  2. 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.
  3. Evaluation: the plan is run.

The cost measure: for bb block transfers and SS seeks

cost=b⋅tT+S⋅tS\text{cost} = b \cdot t_T + S \cdot t_S

On this page too, tT=0.1t_T = 0.1 ms and tS=4t_S = 4 ms.

External sort-merge

If the table does not fit in memory, sorting happens in two phases. MM = the number of buffer blocks in memory.

  1. Run creation: read MM blocks, sort them in memory, write them as a run. ⌈br/M⌉\lceil b_r / M \rceil runs result.
  2. Merging: in each pass M−1M - 1 runs are merged (1 block is for the output). The number of passes is ⌈log⁡M−1(br/M)⌉\lceil \log_{M-1}(b_r / M) \rceil.

transfers=br(2⌈log⁡M−1brM⌉+1)\text{transfers} = b_r \left( 2 \left\lceil \log_{M-1} \frac{b_r}{M} \right\rceil + 1 \right)

Example: br=400b_r = 400, M=11M = 11. The number of runs is ⌈400/11⌉=37\lceil 400/11 \rceil = 37. In each pass 10 runs are merged: 37→4→137 \to 4 \to 1, 2 passes. Transfers 400⋅(2⋅2+1)=2000400 \cdot (2 \cdot 2 + 1) = 2000. 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: rr = takes (nr=10000n_r = 10000, br=400b_r = 400), ss = student (ns=5000n_s = 5000, bs=100b_s = 100). 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):

nr⋅bs+br transfers,nr+br seeksn_r \cdot b_s + b_r \text{ transfers}, \qquad n_r + b_r \text{ seeks}

With takes outer: 10000⋅100+400=1 000 40010000 \cdot 100 + 400 = 1\,000\,400 transfers, 1040010400 seeks. The time is 100040+41600=141640100040 + 41600 = 141640 ms, about 2.4 minutes. With student outer: 5000⋅400+100=2 000 1005000 \cdot 400 + 100 = 2\,000\,100 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: br+bs=500b_r + b_s = 500 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.

br⋅bs+br transfers,2br seeksb_r \cdot b_s + b_r \text{ transfers}, \qquad 2 b_r \text{ seeks}

With takes outer: 400⋅100+400=40400400 \cdot 100 + 400 = 40400 transfers, 800 seeks. With student outer: 100⋅400+100=40100100 \cdot 400 + 100 = 40100 transfers, 200 seeks. This time too the smaller relation wins on the outside.

If there are MM buffers, M−2M - 2 blocks are given to the outer relation (one for the inner, one for the output):

⌈brM−2⌉bs+br transfers,2⌈brM−2⌉ seeks\left\lceil \frac{b_r}{M-2} \right\rceil b_s + b_r \text{ transfers}, \qquad 2 \left\lceil \frac{b_r}{M-2} \right\rceil \text{ seeks}

With M=12M = 12 and student outer: ⌈100/10⌉=10\lceil 100/10 \rceil = 10 chunks, 10⋅400+100=410010 \cdot 400 + 100 = 4100 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. cc = the cost of one search + fetching the match.

br(tT+tS)+nr⋅cb_r (t_T + t_S) + n_r \cdot c

With takes outer and a hi=3h_i = 3 B+ tree on student.stu_id: since it is equality on a key, c=hi+1=4c = h_i + 1 = 4 block accesses. The number of accesses is 400+10000⋅4=40400400 + 10000 \cdot 4 = 40400. 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 σ\sigma has been applied first).

Merge join

If the two relations are sorted on the join attribute, they are scanned in parallel and read once:

br+bs transfersb_r + b_s \text{ transfers}

In the example, 400+100=500400 + 100 = 500. If they are not sorted, the sorting cost is added first. With M=11M = 11, sorting takes is 2000, and sorting student (⌈100/11⌉=10\lceil 100/11 \rceil = 10 runs, 1 pass) is 100⋅3=300100 \cdot 3 = 300 transfers. Total 2000+300+500=28002000 + 300 + 500 = 2800. It is used only for equality and natural joins (equi-joins).

Hash join

  1. Partitioning: the two relations are divided into nhn_h partitions with the same hash function. Tuples that can match fall into partitions with the same number.
  2. For each ii, sis_i is brought into memory and a hash table is built in memory (build input), then rir_i is read and probed against this table (probe input).

The smaller relation becomes the build input; each sis_i must fit in memory. If no recursive partitioning is needed:

3(br+bs) transfers3 (b_r + b_s) \text{ transfers}

Partitioning is read + write 2(br+bs)2(b_r + b_s), and the build/probe phase is one read (br+bs)(b_r + b_s). In the example 3⋅500=15003 \cdot 500 = 1500.

Summary comparison (takes ⋈ student)

AlgorithmTransfersNote
Nested-loop, takes outer1 000 400worst
Block nested-loop, student outer40 100
Block nested-loop, M=12M = 124 100
Indexed nested-loop, c=4c = 440 400 accessesall random
Merge join + two sorts2 800the result comes out sorted
Hash join1 500equality join

PITFALL · nn or bb in the formulas? In simple nested-loop the factor is the tuple count of the outer nrn_r, in block nested-loop the block count of the outer brb_r. In indexed nested-loop it is nrn_r 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:

RuleStatement
Selection cascadeσθ1∧θ2(E)=σθ1(σθ2(E))\sigma_{\theta_1 \wedge \theta_2}(E) = \sigma_{\theta_1}(\sigma_{\theta_2}(E))
Selection is commutativeσθ1(σθ2(E))=σθ2(σθ1(E))\sigma_{\theta_1}(\sigma_{\theta_2}(E)) = \sigma_{\theta_2}(\sigma_{\theta_1}(E))
Projection cascadeπL1(πL2(E))=πL1(E)\pi_{L_1}(\pi_{L_2}(E)) = \pi_{L_1}(E), if L1⊆L2L_1 \subseteq L_2
σ\sigma and ×\timesσθ(E1×E2)=E1⋈θE2\sigma_{\theta}(E_1 \times E_2) = E_1 \bowtie_{\theta} E_2
Join is commutativeE1⋈E2=E2⋈E1E_1 \bowtie E_2 = E_2 \bowtie E_1
Join is associative(E1⋈E2)⋈E3=E1⋈(E2⋈E3)(E_1 \bowtie E_2) \bowtie E_3 = E_1 \bowtie (E_2 \bowtie E_3)
Pushing selection downσθ(E1⋈E2)=σθ(E1)⋈E2\sigma_{\theta}(E_1 \bowtie E_2) = \sigma_{\theta}(E_1) \bowtie E_2, if θ\theta uses only attributes of E1E_1
Pushing projection downπL(E1⋈θE2)=πL1(E1)⋈θπL2(E2)\pi_L(E_1 \bowtie_\theta E_2) = \pi_{L_1}(E_1) \bowtie_\theta \pi_{L_2}(E_2), LiL_i must contain the attributes in both LL and θ\theta
Set operations∪\cup and ∩\cap are commutative and associative; σ\sigma 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:

  1. Break conjunctive selections apart with the cascade.
  2. Push every selection as far down as possible in the tree.
  3. Do the most restrictive selection (the one that leaves the fewest rows) and join first.
  4. Turn the σ\sigma + ×\times pair into ⋈\bowtie. Avoid the Cartesian product.
  5. 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:

πname(σdept_name=′CS′∧credits=3(student⋈(takes⋈course)))\pi_{name}\big(\sigma_{dept\_name='CS' \wedge credits=3}(\text{student} \bowtie (\text{takes} \bowtie \text{course}))\big)

With cascade + pushing selection down + early projection:

πname(πstu_id, name(σdept_name=′CS′(student))⋈πstu_id(takes⋈σcredits=3(course)))\pi_{name}\Big(\pi_{stu\_id,\, name}(\sigma_{dept\_name='CS'}(\text{student})) \bowtie \pi_{stu\_id}\big(\text{takes} \bowtie \sigma_{credits=3}(\text{course})\big)\Big)

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 ⋈ course without renaming, dept_name is 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: nrn_r, brb_r, frf_r, and V(A,r)V(A, r), the number of distinct values of AA in rr.

ExpressionEstimated number of rows
σA=v(r)\sigma_{A = v}(r)nr/V(A,r)n_r / V(A, r)
σA≤v(r)\sigma_{A \le v}(r)nr⋅v−min⁡(A)max⁡(A)−min⁡(A)n_r \cdot \frac{v - \min(A)}{\max(A) - \min(A)}
σθ1∧θ2(r)\sigma_{\theta_1 \wedge \theta_2}(r)nr⋅s1nr⋅s2nrn_r \cdot \frac{s_1}{n_r} \cdot \frac{s_2}{n_r} (independence assumption)
r⋈sr \bowtie s, common attribute is a key of ss≤nr\le n_r; exactly nrn_r if it is a foreign key
r⋈sr \bowtie s, common attribute is not a keymin⁡(nrnsV(A,s),nrnsV(A,r))\min\left(\frac{n_r n_s}{V(A,s)}, \frac{n_r n_s}{V(A,r)}\right)

Example: n=5000n = 5000 students, V(dept_name)=20V(dept\_name) = 20. The estimate for dept_name = 'CS' is 250250. If tot_cred ranges from 0 to 240, the estimate for tot_cred <= 60 is 5000⋅60/240=12505000 \cdot 60/240 = 1250. Both together: 5000⋅0.05⋅0.25=62.55000 \cdot 0.05 \cdot 0.25 = 62.5, 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 7/3≈2.337/3 \approx 2.33, 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 ideaThe truth
br⋅bsb_r \cdot b_s in nested-loopFor the tuple-based one it is nr⋅bs+brn_r \cdot b_s + b_r; br⋅bsb_r \cdot b_s is block nested-loop
The large relation should be outerIn nested-loop variants the small relation is outer
Skipping seeksNLJ has nr+brn_r + b_r, BNLJ has 2br2b_r seeks; a seek is 40 times more expensive than a transfer
All MM buffers go to the outer relationM−2M - 2: one block for the inner relation, one for the output
The number of passes in external sort is log⁡M\log_MThe merge is M−1M - 1-way: ⌈log⁡M−1(br/M)⌉\lceil \log_{M-1}(b_r/M) \rceil
Merge join is always br+bsb_r + b_sOnly if already sorted; otherwise the sort cost is added
In a hash join the large relation is the build inputThe small relation is the build input; its partitions must fit in memory
Push the selection into whichever branch of the join you likeOnly if all the attributes in the condition are in that branch
Dropping the join column during early projectionThe join attribute must be kept if it is needed at a higher level
Using max⁡\max instead of min⁡\min for the join estimateThe tighter one is the smaller value: divide by the larger VV

Test yourself

Exam-style questions. Solve them on paper first, then reveal the answer.

Question 1easy

Put the steps of query processing in the correct order: optimization, parsing and translation, evaluation.

Show answer
parsing and translation, optimization, evaluation

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.

Question 2medium

Relation rr has nr=6000n_r = 6000 tuples in br=300b_r = 300 blocks; ss has ns=1500n_s = 1500 tuples in bs=60b_s = 60 blocks. Using a simple (tuple) nested-loop join with rr as the outer relation and only one buffer block per relation (worst case), give the number of block transfers and seeks.

Show answer
360300 transfers, 6300 seeks

For each tuple of the outer relation, the whole inner relation is read: nr⋅bs=6000⋅60=360000n_r \cdot b_s = 6000 \cdot 60 = 360000.
The outer relation itself is read once: +br=300+ b_r = 300. Total 360300 transfers.
Seeks: one seek into the inner relation for each outer tuple (nrn_r) and one seek for each block of the outer relation (brb_r): 6000+300=63006000 + 300 = 6300.

Question 3medium

Same rr (br=300b_r = 300) and ss (bs=60b_s = 60). For a block nested-loop join (worst case, one buffer block each), compute the block transfers with rr as outer and with ss as outer. Which should be the outer relation?

Show answer
r outer: 18300, s outer: 18060; s should be outer

Formula: bouter⋅binner+bouterb_{\text{outer}} \cdot b_{\text{inner}} + b_{\text{outer}} transfers, 2bouter2 b_{\text{outer}} seeks.rr outer: 300⋅60+300=18300300 \cdot 60 + 300 = 18300 transfers, 600 seeks.ss outer: 60⋅300+60=1806060 \cdot 300 + 60 = 18060 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: ss.

Question 4medium

Block nested-loop join of rr (br=300b_r = 300) and ss (bs=60b_s = 60) with M=22M = 22 memory buffers, ss as outer. Give block transfers and seeks.

Show answer
960 transfers, 6 seeks

M−2=20M - 2 = 20 blocks are given to the outer relation (1 block for the inner relation, 1 block for the output).
The outer relation is read in ⌈60/20⌉=3\lceil 60/20 \rceil = 3 chunks. For each chunk the inner relation is scanned once.
Transfers: 3⋅300+60=9603 \cdot 300 + 60 = 960. Seeks: 2⋅3=62 \cdot 3 = 6.
The 18060 transfers of the one-buffer version dropped to 960.

Question 5hard

Indexed nested-loop join: rr (nr=6000n_r = 6000, br=300b_r = 300) is the outer relation and ss has a B+ tree index on the join attribute. Each index lookup plus fetching the matching tuple costs c=3c = 3 block accesses. What is the total number of block accesses? If instead ss (ns=1500n_s = 1500, bs=60b_s = 60) is outer and rr has an index with the same cc, what is the cost?

Show answer
18300; 4560

Formula: bouter+nouter⋅cb_{\text{outer}} + n_{\text{outer}} \cdot c.rr outer: 300+6000⋅3=18300300 + 6000 \cdot 3 = 18300.ss outer: 60+1500⋅3=456060 + 1500 \cdot 3 = 4560.
In indexed nested-loop, what matters is the number of tuples of the outer relation. Putting the relation with fewer tuples outside is cheaper.

Question 6medium

Both rr (br=300b_r = 300) and ss (bs=60b_s = 60) are already sorted on the join attribute. How many block transfers does a merge join need (ignore the output)?

Show answer
360

Two sorted relations are scanned simultaneously, each once: br+bs=300+60=360b_r + b_s = 300 + 60 = 360 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.

Question 7hard

External sort-merge of a relation with br=300b_r = 300 blocks using M=7M = 7 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
43 runs, 3 passes, 2100 transfers

The number of initial runs: ⌈br/M⌉=⌈300/7⌉=43\lceil b_r / M \rceil = \lceil 300/7 \rceil = 43.
In each pass M−1=6M - 1 = 6 runs are merged. The number of passes is ⌈log⁡643⌉\lceil \log_6 43 \rceil; log⁡643≈2.10\log_6 43 \approx 2.10, so 3 (43 → 8 → 2 → 1).
Transfers: br(2⌈log⁡M−1(br/M)⌉+1)=300⋅(2⋅3+1)=2100b_r (2 \lceil \log_{M-1}(b_r/M) \rceil + 1) = 300 \cdot (2 \cdot 3 + 1) = 2100.
Run creation is 1 read + 1 write, each pass is 1 read + 1 write, and the write of the last pass is not counted.

Question 8medium

For rr (br=300b_r = 300) and ss (bs=60b_s = 60), both unsorted, compare a hash join (no recursive partitioning, ignore partially filled blocks) with sort + merge join where sorting rr costs 2100 and sorting ss costs 300 transfers. Which is cheaper?

Show answer
hash 1080 vs sort-merge 2760: hash join

Hash join: the two relations are read and written while partitioning (2(br+bs)2(b_r + b_s)), then read once more in the build/probe phase (br+bsb_r + b_s): 3⋅(300+60)=10803 \cdot (300 + 60) = 1080.
Sort-merge: 2100+300+(300+60)=27602100 + 300 + (300 + 60) = 2760.
The hash join is cheaper. But in cases such as wanting the result sorted on the join key, merge join may gain an advantage.

Question 9easy

Relation instructor has n=6000n = 6000 tuples and V(dept_name,instructor)=40V(\text{dept\_name}, \text{instructor}) = 40. Estimate the size of σdept_name=′EE′(instructor)\sigma_{dept\_name = 'EE'}(\text{instructor}). Also estimate σsalary≤30\sigma_{salary \le 30} (salary in thousands) if salaries range uniformly from 10 to 90.

Show answer
150; 1500

Equality: values are assumed to be evenly distributed, nr/V(A,r)=6000/40=150n_r / V(A, r) = 6000 / 40 = 150.
Comparison: nr⋅v−min⁡(A)max⁡(A)−min⁡(A)=6000⋅30−1090−10=6000⋅0.25=1500n_r \cdot \frac{v - \min(A)}{\max(A) - \min(A)} = 6000 \cdot \frac{30 - 10}{90 - 10} = 6000 \cdot 0.25 = 1500.

Question 10medium

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 takes⋈student\text{takes} \bowtie \text{student}.

Show answer
6000

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 ntakes=6000n_{takes} = 6000 tuples. General rule: if the common attribute is a key of ss, the join gives at most nrn_r tuples.

Question 11medium

On the sample university database, rewrite σdept_name=′EE′(student⋈takes)\sigma_{dept\_name = 'EE'}(\text{student} \bowtie \text{takes}) by pushing the selection down. How many tuples does the result have?

Show answer
σ_dept_name='EE'(student) ⋈ takes; 3 tuples

dept_name exists only in student, so the selection can go down into the student branch of the join: σdept_name=′EE′(student)⋈takes\sigma_{dept\_name='EE'}(\text{student}) \bowtie \text{takes}.
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.

Question 12hard

r(A,B)r(A, B) has 1000 tuples, s(B,C)s(B, C) has 3000 tuples, V(B,r)=100V(B, r) = 100 and V(B,s)=200V(B, s) = 200. BB is not a key of either relation. Estimate the size of r⋈sr \bowtie s.

Show answer
15000

Two estimates: nrnsV(B,s)=3⋅106200=15000\frac{n_r n_s}{V(B, s)} = \frac{3 \cdot 10^6}{200} = 15000 and nrnsV(B,r)=3⋅106100=30000\frac{n_r n_s}{V(B, r)} = \frac{3 \cdot 10^6}{100} = 30000.
The smaller one (the tighter estimate) is taken: min⁡=15000\min = 15000. This means dividing by the larger VV 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).

Review card

steps: parse+translate → optimize (choose a plan) → evaluate cost: b transfers * t_T + S seeks * t_S (CPU ignored) NLJ: n_r*b_s + b_r transfers, n_r + b_r seeks (with small memory: b_r + b_s) BNLJ: b_r*b_s + b_r transfers, 2b_r seeks M buffers: ceil(b_r/(M-2))*b_s + b_r INLJ: b_r + n_r*c (the TUPLE count of the outer matters) merge (sorted): b_r + b_s + sort cost external sort: runs = ceil(b/M), passes = ceil(log_{M-1}(b/M)), transfers b(2*passes+1) hash join: 3(b_r + b_s), the smaller relation is the build input σ_{A=v}: n_r/V(A,r) σ_{A<=v}: n_r(v-min)/(max-min) join: common attribute is a key of s → <= n_r; if FK then = n_r general: min(n_r n_s / V(A,s), n_r n_s / V(A,r)) heuristic: push σ down, do π early, most selective σ and join first, ⋈ instead of ×