Data Intensive Systems: Lecture 8
Selectivity estimation and CNF in practice, then the join algorithms: index nested loops, Grace and hybrid hash join, sort-merge join, general join conditions, set operations, and aggregation.
Estimating Selectivity
Access paths are chosen by predicted work, and the prediction rests on selectivity: the fraction of tuples that satisfy a predicate. The optimizer cannot run the query to measure it, so it estimates it from catalog statistics before execution.
Selectivity estimation. Approximating a predicate's selectivity from per-attribute statistics, the number of distinct values and the value range , assuming values are uniformly distributed and predicates are independent.
| Predicate | Estimated selectivity |
|---|---|
A = v | |
A > v | |
P1 AND P2 | |
P1 OR P2 |
Every rule is the uniformity assumption in a different costume: each value equally common, ranges evenly filled, predicates independent of each other.
Multiplying by the combined selectivity predicts the result cardinality, which is what the optimizer needs to cost everything that runs after the selection.
Example 1. Sailors estimates.
Sailors has tuples, rating uniform in , age in . For rating = 8: , about tuples. For rating > 7: , about tuples. For rating = 8 AND age > 35: , about tuples.
General Selection Conditions
Optimizers rewrite every WHERE clause to conjunctive normal form, because CNF lets the engine apply one clause at a time: each clause filters the survivors of the previous one, so the candidate set only shrinks. The rewrite always happens; whether it actually speeds anything up depends on the shape of the predicate.
The profitable shape has a predicate shared by every OR-term. Factored out, it is evaluated once, and if it is selective and indexed it shrinks the candidate set before anything else runs.
Example 2. A common factor.
Find rating-10 sailors who are young or old: (rating = 10 AND age < 25) OR (rating = 10 AND age > 60). Evaluated as written, rating = 10 runs once per AND-term, two candidate sets are built, and their union must deduplicate. The CNF form rating = 10 AND (age < 25 OR age > 60) evaluates the factor once: a hash index on rating cuts sailors to about , the age test runs only on those survivors, and no deduplication is needed. Roughly ten times fewer predicate evaluations.
The unprofitable shape has no such factor. (day < 8/9/94 AND rname = 'Paul') OR bid = 5 OR sid = 3 rewrites to (day < 8/9/94 OR bid = 5 OR sid = 3) AND (rname = 'Paul' OR bid = 5 OR sid = 3): each clause is still a three-way OR, so no single index covers a clause and the engine still probes three indexes and unions the RID sets. Worse, bid = 5 and sid = 3 appear in both clauses, so a tuple passing one clause through them automatically passes the other: the AND between the clauses adds almost no filtering. The rewrite buys nothing over the original form.
| Shape | CNF form | Effect |
|---|---|---|
(A AND B) OR (A AND C) | A AND (B OR C) | Common factor evaluated once; selective and indexed A makes a large win. |
A AND B AND C | unchanged | Already CNF: apply the most selective clause first, pipe survivors onward. |
(A AND B) OR C OR D | (A OR C OR D) AND (B OR C OR D) | Clauses stay wide ORs needing multi-index unions; a scan often wins. |
(A AND B) OR (C AND D) OR (E AND F) | clauses | CNF size is exponential in the AND-terms; the optimizer keeps DNF. |
Reading a WHERE clause, three questions decide it:
- Does every OR-term share a common factor?
- Is that factor selective?
- Is there an index on it?
Three yes answers mean the CNF rewrite wins big; each no pushes the plan toward multi-index unions or a plain scan.
Whether a clause maps onto a -tree at all follows the leftmost-prefix rule, but among matching composite keys the column order still decides the cost. For day < 8/9/94 AND rname = 'Paul':
- Index on ⟨rname, day⟩. The equality pins the
Paulregion with one descent; inside it entries are sorted byday, so the range is one contiguous leaf walk. - Index on ⟨day, rname⟩. The range comes first: every entry with a qualifying day is visited, and within that range
rnameis sorted only inside each distinct day, so the name must be checked entry by entry.
In a composite key, put equality columns first: each equality pins one contiguous region, and a single trailing range stays contiguous inside it.
Index Nested Loops Join
The running numbers stay fixed: Reserves is with pages and tuples per page, Sailors is with pages and , the join attribute is sid, an I/O costs 10 ms, and the cost model counts reads only. Block nested loops still reads every inner page once per outer block, whether or not it could match. An index on the inner join attribute replaces that scan with lookups that touch only matching tuples.
Index nested loops join. A nested loops join where, for each tuple of the outer relation, the matching inner tuples are found through an index on the inner join attribute instead of a scan.
where is the cost of finding the matching inner tuples for one outer tuple. The outer relation is scanned once; everything else is probes. The probe cost has two parts:
- Probe cost. Reaching the data entries: about I/Os for a hash index, to for a -tree descent.
- Retrieval cost. Fetching the matching tuples (Alternative 2 or 3 entries): with a clustered index about one I/O per page of matches, with an unclustered index up to one I/O per matching tuple.
Example 3. Probing each side.
With Reserves outer and a hash index on Sailors.sid: sid is the key of Sailors, so each probe finds exactly one match, costing I/Os. Total: I/Os, about 37 minutes.
With Sailors outer and an unclustered hash index on Reserves.sid: each sailor has on average reservations, so a probe costs I/Os. Total: I/Os, about 25 minutes.
Both lose badly to the I/Os of block nested loops. The index pays off when few outer tuples probe.
Example 4. A selective outer.
For Sailors.sid = Reserves.sid AND Sailors.sid = 100, one sailor survives the filter. Index nested loops scans Sailors ( I/Os worst case) and probes the hash index on Reserves.sid once: plus about I/Os of matching reservations, roughly I/Os in total, about 5 seconds. Block nested loops still scans all of Reserves for the one-tuple block: I/Os.
Index nested loops turns join cost into "probing tuples times cost per probe", so it wins exactly when few tuples probe.
Grace Hash Join
The one-pass hash join needs its build side in memory. When neither relation fits, partitioning rescues hashing: split both inputs into memory-sized pieces first, then run the one-pass join piece by piece.
Grace hash join (two-pass hash join). A hash join that first partitions both relations on the join attribute with a hash function , then joins each pair of matching partitions in memory using a second hash function .
- Partition. Stream through with one input buffer and output buffers, writing each of the partitions to disk as it fills. Partition the same way, with the same .
- Probe. For each pair : read and build an in-memory hash table on it with ; stream one page at a time, probe the table, and emit matches.
Matching tuples agree on the join attribute, so they agree on : every match is confined to one partition pair, and the pairs can be joined independently.
The second function must differ from : all tuples of collide under by construction, so reusing it would pile the whole partition into one bucket.
Every pass streams sequentially. Partitioning reads and writes both relations, ; probing reads everything once more, :
For the running numbers, I/Os, about 45 seconds, and unlike block nested loops the cost grows linearly rather than quadratically in the input sizes.
The probe phase needs one input page, one output page, and a whole build partition in the remaining buffers. With partitions of about pages each, the constraint is
The fudge factor admits two realities. A hash table on a partition of pages occupies about pages of memory, for buckets, pointers, and empty slots. And spreads tuples evenly only on average: with skewed data one partition can land far above . Either relation can play the build role, so the smaller one should: building on Sailors weakens the requirement to .
When a partition still does not fit, it is split again with yet another hash function (recursive partitioning, rarely needed), and the clean no longer holds. A bad hash function is worse than bad luck: if every join value is a multiple of 100 and , all tuples land in partition 0. Such hash-table overflow is avoided by choosing a robust, uniform hash function, and resolved, when it happens anyway, by repartitioning with a different one.
Hybrid Hash Join
Grace costs whether memory is barely or nearly enough for a one-pass join: every partition makes the disk round trip regardless. Spare memory should buy something.
Hybrid hash join. A Grace join that keeps the first of partitions resident in memory through the partition phase, joining them on the fly, and falls back to Grace for the remaining .
- Partition the build side. Hash
Sailorsthrough into partitions. stay in memory as hash tables; are written to disk. - Partition the probe side. Hash
Reservesthrough . A tuple landing in partitions probes the resident table immediately and its matches are emitted; the rest is written to disk. - Finish with Grace. Join the spilled pairs for exactly as in the Grace probe phase.
A resident pair never touches disk: its pages are neither written nor read back, saving two I/Os per page. With a fraction of both relations resident,
At this is Grace; at it is the one-pass hash join at . Extra memory converts linearly into saved I/O: each resident partition skips one write and one read of its pages.
During partitioning the buffers must hold one input page, one join-output page, partition output buffers, and resident hash tables of about pages each:
The rule of thumb keeps as many partitions resident as the build side allows. And need not be fixed upfront:
- Start with : every bucket resident.
- Insert build tuples as they stream in.
- When memory runs out, spill one bucket to disk and continue with .
Memory pressure is discovered, not predicted: with plenty of memory the join finishes as a one-pass hash join, and under pressure it degrades, bucket by bucket, gracefully into Grace.
Example 5. One resident partition.
With buffers and partitions, half of Sailors stays resident ( pages, treating as ). Partitioning Sailors reads pages and writes only : I/Os. Partitioning Reserves reads pages, probes on the fly, and only is written: I/Os. The Grace pass joins with : I/Os. Total I/Os against for plain Grace, matching ; the saved I/Os are exactly one write plus one read of the resident pages.
Sort-Merge Join
Hashing brings matching tuples together by partitioning. Ordering achieves the same with no hash function and no index.
Sort-merge join. A join that sorts both relations on the join attribute, then merges the two sorted streams, emitting joined tuples whenever the keys are equal.
The merge keeps one cursor per relation and always advances the side with the smaller key:
- : advance ; every remaining tuple is at least , so can never match.
- : advance , symmetrically.
- : emit every pair from the two groups of tuples sharing that key, then advance both cursors past the groups.
Example 6. Merging in lockstep.
Take and , both sorted on sid.
| Step | cursor | cursor | Action |
|---|---|---|---|
| 1 | 22, dustin | 28, guppy | : advance |
| 2 | 28, yuppy | 28, guppy | equal: emit ⟨28: yuppy, guppy⟩, advance both |
| 3 | 31, lubber | 31, dustin and 31, lubber | equal group: emit ⟨31: lubber, dustin⟩ and ⟨31: lubber, lubber⟩ |
Duplicate keys set the worst case: if every tuple of both relations carries the same join value, the merge emits all combinations. The case is degenerate, but the optimizer accounts for it.
If both relations fit in memory, sorting costs no I/O and the join reads each input once, the one-pass . Realistically each relation is sorted by external merge sort, at , and the merge then reads both sorted files:
Example 7. Two-pass sort-merge.
With buffers, sorting Reserves takes two passes, since pass 0 leaves runs merged in one further pass: I/Os. Sailors likewise: . The merge reads both sorted files: . Total I/Os, that is , about 75 seconds.
The last sort pass and the join's merge both walk sorted runs in lockstep, so they can be folded into one pass:
- Pass 0. Create sorted runs of and sorted runs of : read and write each relation once, .
- Merge and join. Merge all -runs and all -runs simultaneously, emitting matches instead of writing a sorted file: .
The total drops to , here I/Os: an entire sort pass removed. The combined merge needs one buffer per run, and pass 0 leaves of them, which works out to roughly .
Choosing the Join Algorithm
At their memory floor, hash join and sort-merge join cost the same . The choice rests on the data and on the surrounding plan:
- Hash wins on size asymmetry. Its bound depends only on the build side, for the smaller relation, against for sort-merge; and hybrid turns every spare buffer into proportional savings.
- Hash parallelizes naturally. Each partition pair is an independent join, easy to spread across cores or machines.
- Sort-merge wins on skew. Sorting is insensitive to value distribution, while one popular join value can overflow a hash partition.
- Sort-merge wins when order matters. A sorted output feeds
ORDER BYor a downstream merge for free, and inputs already sorted (a clustered -tree scan) skip pass 0 entirely.
| Algorithm | I/O cost | Running example | At 10 ms per I/O |
|---|---|---|---|
| Block nested loops, | 1 min | ||
Index nested loops, hash on Sailors.sid | 37 min | ||
Index nested loops, hash on Reserves.sid | 25 min | ||
| Grace hash join | 45 s | ||
| Hybrid hash join, | 30 s | ||
| Two-pass sort-merge | 75 s | ||
| Sort-merge, join folded into final merge | 45 s |
With enough memory, both one-pass joins remain the floor at I/Os.
The formulas assume dedicated buffers, but the pool is shared, so how many frames a query really gets changes with the workload: the planner's is a guess. The replacement policy interferes too. A repeatedly scanned inner relation is a cyclic access pattern, the same shape behind sequential flooding: the page just read is the one needed farthest in the future, so evicting the most recently used page (MRU) matches the optimal policy while LRU evicts pages moments before their reuse. Access order helps as well: sorting the outer relation of an index nested loops join makes consecutive probes hit the same leaf and data pages, turning random probes into buffer hits.
General Join Conditions
Equality on several attributes, as in R.sid = S.sid AND R.name = S.name, changes nothing structural: the combined key acts as one value.
- Index nested loops. Best with a composite index on ⟨sid, name⟩; an index on one column alone still works, with the other equality checked after retrieval.
- Sort-merge. Sort both relations lexicographically on ⟨sid, name⟩ and merge as before. Same cost as the single-attribute join.
- Hash join. and simply hash all join columns. Same cost.
Inequality conditions, as in R.rname < S.sname, break the hash family: hashing co-locates equal values, but a tuple's inequality matches can live in any partition. Partitioning destroys exactly the ordering an inequality needs.
- Index nested loops. Needs a -tree on the inner relation, probed with one range per outer tuple. Each probe returns a long run of matches, so clustering decides whether that run is one sequential read or scattered fetches.
- Block nested loops. Often the winner: every outer tuple matches many inner tuples anyway, so reading all page pairs does little wasted work.
Set Operations
Set operations reuse the join machinery. Intersection is an equality join on all attributes; EXCEPT is the same plumbing with the match inverted, emitting the tuples that find no partner. UNION with duplicate elimination is concatenation plus exactly the duplicate problem of SELECT DISTINCT projection, applied to whole tuples:
- Sort-based. Sort both relations on all attributes and merge, emitting each tuple once. The sort-merge refinement applies unchanged: merge the pass-0 runs of and directly, dropping duplicates during the merge.
- Hash-based. Partition both relations with on all attributes. For each partition pair, build an in-memory hash table from one side, then insert the other side's tuples, skipping any already present: the table ends up holding the distinct union of the pair.
Aggregation
Without grouping, an aggregate is a one-scan fold.
Aggregate evaluation. Scan the relation once while maintaining running state per aggregate: a count for COUNT, a sum for SUM, both for AVG, the current extreme for MIN and MAX. The result is emitted when the scan ends.
The cost is one full scan, with no sorting, hashing, or extra memory. A covering index does better: with every attribute the query touches inside the index, index-only evaluation scans the leaf level instead of the relation, and leaf pages are several times smaller.
For SELECT AVG(age) FROM Sailors, a -tree on age holds the ages in about 50 leaf pages against 500 data pages: ten times fewer I/Os, same answer.
GROUP BY asks for one running state per group, and every strategy reduces to the same move: bring same-group tuples together.
- Sort-based grouping. Sort on the grouping attributes and scan: each group is contiguous, so one running state suffices, flushed at every group boundary. The aggregation can be fused into the final merge pass of the sort.
- Hash-based grouping. Partition on the grouping attributes, so same-group tuples land in the same partition; then, per partition, an in-memory hash table keyed by the grouping value accumulates each group's state.
- Index shortcut. If a covering index exists and the grouping attributes are a prefix of its key, leaf order is group order: tuples arrive pre-grouped, with no sort and no hash.
Sorting, hashing, or a key that is already grouped: every GROUP BY plan is a way of making same-group tuples adjacent.
One relational operator, many implementations, no universal winner: selections choose access paths by selectivity, joins choose among probing, partitioning, and sorting by sizes, memory, skew, and the order the rest of the plan needs. Stitching these per-operator choices into one cheapest plan is query optimization, the layer of the engine that comes next.