SEN2104 · Week 12 · Indexing and Query Processing

Indexing: dense/sparse indexes, B+ trees, hashing and selection cost

How records are laid out on disk, how an index speeds up search, how insertion into a B+ tree works and how the cost of a selection is calculated.

blockblocking factorheap filesequential filesearch keydense indexsparse indexprimary (clustering) indexsecondary indexmultilevel indexB+ treenode splitstatic hashingbucket overflowextendable hashinglinear scantransfer time t_Tseek time t_S

So far we have written the SQL query and discussed what the result would be. This week the question changes: how long does the database take to find that result? The answer depends on the layout on disk and on the indexes. The exam has three kinds of questions on this topic: cost calculation (in ms), inserting into a B+ tree and drawing the final tree, and a “which plan is cheaper?” comparison.

Golden rules:

  1. The unit of cost is disk access. CPU is ignored. Reading a block = transfer (tTt_T), jumping to another place = seek (tSt_S).
  2. Seek is much more expensive than transfer. On this page we always use tS=4t_S = 4 ms, tT=0.1t_T = 0.1 ms.
  3. An index does not always help. If many records match, a secondary index can be slower than a plain scan.
  4. In a B+ tree, when a leaf splits the key is copied up, and when an internal node splits it is moved up.

Storage: blocks, records, files

The disk reads and writes data in fixed-size blocks. A record is not read on its own; the whole block that contains it comes into memory. So the important numbers are:

SymbolMeaning
nrn_rthe number of records (tuples) in the relation
frf_rblocking factor: the number of records that fit in a block
brb_rthe number of blocks the relation occupies

fr=⌊block sizerecord size⌋br=⌈nrfr⌉f_r = \left\lfloor \frac{\text{block size}}{\text{record size}} \right\rfloor \qquad b_r = \left\lceil \frac{n_r}{f_r} \right\rceil

Example: block 4096 bytes, record 200 bytes. fr=⌊20.48⌋=20f_r = \lfloor 20.48 \rfloor = 20. With 10000 records, br=500b_r = 500 blocks.

File organizations:

  • Heap file: records are written wherever there is free space, with no order. Insertion is cheap, search is expensive.
  • Sequential (sorted) file: records are sorted on a search key. Binary search and range queries are possible, but insertion needs overflow blocks to keep the order.

Index concepts

An index is like the index at the back of a book: it holds “key value → location of the record” pairs. The attribute(s) on which we build the index are called the search key. Note: the search key does not have to be the primary key of the table.

KindDefinitionNote
Dense indexAn entry for every search-key valueCan be built on any file
Sparse indexEntries for only some values (usually one per block)The file must be sorted on that key
Primary / clustering indexThe file is sorted on the index’s keyAt most one per table
Secondary indexThe file is not sorted on this keyMust always be dense

In a sparse index, if you search for stu_id = 'S4471', you find the largest entry in the index that is less than or equal to S4471, go to that block, and move forward in order. This works only if the file is sorted. A secondary index cannot be sparse, because records are scattered and you cannot say “continue after this block”.

With numbers: 10000 records, 500 blocks, 100 entries fit in an index block. A dense index has 10000 entries, that is 100 blocks. A sparse index has 500 entries, that is 5 blocks. Sparse is smaller, while dense can answer some queries without going to the data at all (for example, “does this key exist?”).

Multilevel index

When an index grows, it becomes a file that itself has to be searched on disk. The solution: build another sparse index on top of the index, and continue until the top level fits in a single block. This is called a multilevel index. One block is read from each level, then the data block. The B+ tree is the balanced, updatable form of this idea.

B+ tree

The B+ tree is the default index of databases. Its parameter is nn: the maximum number of pointers in a node. A node holds at most n−1n - 1 keys.

NodeMinimumMaximum
Leaf⌈(n−1)/2⌉\lceil (n-1)/2 \rceil keysn−1n-1 keys
Internal node (except the root)⌈n/2⌉\lceil n/2 \rceil childrennn children
Root (if not a leaf)2 childrennn children

Properties:

  • All leaves are at the same depth. The tree is always balanced.
  • The real record pointers are only in the leaves. The keys in internal nodes only guide the way.
  • The leaves are chained from left to right (the last pointer points to the next leaf). For a range query, you find the first leaf and walk along the chain.

The height bound, for KK distinct keys:

h≤⌈log⁡⌈n/2⌉K⌉h \le \left\lceil \log_{\lceil n/2 \rceil} K \right\rceil

Real numbers: 4 KB block, 32-byte key, 8-byte pointer. From the inequality (n−1)⋅32+n⋅8≤4096(n-1)\cdot 32 + n \cdot 8 \le 4096 we get n=103n = 103. If we take roughly n=100n = 100, for a million keys h≤⌈log⁡50106⌉=⌈3.53⌉=4h \le \lceil \log_{50} 10^6 \rceil = \lceil 3.53 \rceil = 4. Searching among a million records reads at most 4 nodes. With binary search this number is around 20.

Search: start at the root. In a node, follow the pointer to the left of the first key greater than the searched value; if equal or greater, move right. When you reach a leaf, look for the key there.

Insertion and splits (step by step)

n=4n = 4 (at most 3 keys). In an overflowing leaf, the first ⌈n/2⌉=2\lceil n/2 \rceil = 2 keys stay on the left and the rest go to a new leaf, and the first key of the new leaf is copied to the parent. In an overflowing internal node, the first ⌈(n+1)/2⌉=3\lceil (n+1)/2 \rceil = 3 pointers stay on the left, and the key in between is moved to the parent (it does not stay in the lower node).

Let us insert 10, 20, 30, 40, 50, 25, 35, 5, 45, 55 in order. Levels are separated by /, with the rightmost leaves last:

insert 10 -> [10]
insert 20 -> [10 20]
insert 30 -> [10 20 30]
insert 40 -> [30] / [10 20] [30 40]                    the leaf overflowed, 30 was copied
insert 50 -> [30] / [10 20] [30 40 50]
insert 25 -> [30] / [10 20 25] [30 40 50]
insert 35 -> [30 40] / [10 20 25] [30 35] [40 50]       the right leaf split, 40 goes up
insert 5  -> [20 30 40] / [5 10] [20 25] [30 35] [40 50]
insert 45 -> [20 30 40] / [5 10] [20 25] [30 35] [40 45 50]
insert 55 -> [40] / [20 30] [50] / [5 10] [20 25] [30 35] [40 45] [50 55]

Let us unpack the last step: [40 45 50 55] overflows, becoming [40 45] and [50 55], and 50 goes to the root. The root becomes [20 30 40 50], with 5 children, exceeding the limit n=4n = 4. The internal node splits: the left has 3 pointers ([20 30]), 40 is moved to the new root, and the right is [50]. The tree grew by one level. A B+ tree grows upward, not downward. That is why the leaves always stay at the same depth.

PITFALL · where is 40? In the final tree 40 is both in the root and in a leaf [40 45]. This is correct: a value copied in a leaf split stays in the leaf too. But the 40 that goes up in an internal node split is not written again in the second level. Whoever writes [20 30 40] / [50] loses points.

Deletion (briefly)

In deletion, if a leaf falls below the minimum occupancy, it first borrows from a sibling (redistribution); if that is not possible it is merged with the sibling (merge/coalesce) and a key is deleted from the parent. A merge can propagate up to the root; if the root is left with a single child, the root is deleted and the tree gets one level shorter.

Indexing with hashing

Static hashing: mm buckets and a hash function hh. A record is written to bucket number h(k)h(k). An equality search reads a single bucket; for a range query hashing is useless because neighboring values are scattered.

With h(k)=k mod 4h(k) = k \bmod 4 for 12, 7, 21, 30, 16, 9, 43:

BucketKeys
012, 16
121, 9
230
37, 43

If a bucket fills up, an overflow bucket is chained. As the chain grows, search slows down. As data grows, mm stays fixed, so static hashing degrades.

Extendable hashing solves this: it keeps a directory that looks at the first ii bits of the hash value. When a bucket overflows, only that bucket is split in two; if needed, the directory size is doubled (ii increases by one). There is no need to rehash the whole file.

Selection costs

The methods used for σA=v(r)\sigma_{A = v}(r) or σA≥v(r)\sigma_{A \ge v}(r). The numbering A1, A2 changes with the edition of the book; in the exam it is enough to know the name of the method and its formula. hih_i: the height of the B+ tree. In the examples, br=500b_r = 500, hi=3h_i = 3.

MethodCostExample
Linear scantS+brtTt_S + b_r t_T4+50=544 + 50 = 54 ms
Linear, equality on a key (average)tS+(br/2)tTt_S + (b_r/2) t_T4+25=294 + 25 = 29 ms
Binary search, file sorted on the key⌈log⁡2br⌉(tT+tS)\lceil \log_2 b_r \rceil (t_T + t_S)9⋅4.1=36.99 \cdot 4.1 = 36.9 ms
Primary B+ index, equality on a key(hi+1)(tT+tS)(h_i + 1)(t_T + t_S)4⋅4.1=16.44 \cdot 4.1 = 16.4 ms
Primary index, equality on a non-key (bb blocks)hi(tT+tS)+tS+b tTh_i (t_T + t_S) + t_S + b\, t_Tb=5b = 5: 16.816.8 ms
Secondary index, nn matching records(hi+n)(tT+tS)(h_i + n)(t_T + t_S)n=40n = 40: 176.3176.3 ms
Primary index, range (bb blocks)hi(tT+tS)+tS+b tTh_i (t_T + t_S) + t_S + b\, t_Tb=50b = 50: 21.321.3 ms

The logic is always the same: in the index tree every level is a random read (seek + transfer). In a primary index the data is sorted, so the matching records are in consecutive blocks: one seek, then bb transfers. In a secondary index each matching record may be somewhere else, and each needs a separate seek.

PITFALL · secondary index + many matches. With 40 matches, a secondary index costs 176.3 ms and a plain scan 54 ms. As the number of matching records grows, a secondary index quickly gets expensive. The answer “if there is an index, it is always used” is wrong.

To see this in a real system, let us look at SQLite’s plan on our sample database (the takes table):

EXPLAIN QUERY PLAN SELECT * FROM takes WHERE course_id = 'MA101';
CREATE INDEX idx_takes_course ON takes(course_id);
EXPLAIN QUERY PLAN SELECT * FROM takes WHERE course_id = 'MA101';
SCAN takes
SEARCH takes USING INDEX idx_takes_course (course_id=?)

Without the index, the table is scanned from start to end (SCAN). After the index, a search is made in the B+ tree (SEARCH). The result is the same three rows in both cases: S1, S3, S4. An index changes the path, not the result.

Common mistakes

Wrong ideaThe truth
frf_r is rounded upIt is rounded down (half a record does not fit); brb_r is rounded up
A secondary index can be sparseIt cannot; the file is not sorted on that key, so it is always dense
Several clustering indexes on one tableA file can be sorted in only one order, so at most one
nn = the number of keys in a nodenn is the number of pointers; keys are at most n−1n-1
In an internal node split the middle key stays in two placesIt moves up in an internal node; it is copied only in a leaf split
A B+ tree grows down from the leavesThe root splits and the tree grows upward; leaves are always at the same depth
Binary search counts only transfersEvery step jumps to a different block: ⌈log⁡2br⌉(tT+tS)\lceil \log_2 b_r \rceil (t_T + t_S)
With an index it is always fasterA secondary index with many matches, (hi+n)(tT+tS)(h_i+n)(t_T+t_S), can be slower than a scan
Hashing speeds up range queriesOnly equality; use a B+ tree for ranges

Test yourself

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

Question 1easy

A disk block holds 4096 bytes and each fixed-length record is 120 bytes (records do not span blocks). A relation has 17000 records. Compute the blocking factor frf_r and the number of blocks brb_r.

Show answer
f_r = 34, b_r = 500

fr=⌊4096/120⌋=⌊34.13⌋=34f_r = \lfloor 4096 / 120 \rfloor = \lfloor 34.13 \rfloor = 34 records/block. Because records do not span blocks, it is rounded down.br=⌈17000/34⌉=500b_r = \lceil 17000 / 34 \rceil = 500 blocks. The number of blocks is rounded up; half a block is still a block.

Question 2easy

A file sorted on stu_id has 30000 records, 30 records per block. How many entries does a sparse primary index on stu_id have (one entry per block)? How many does a dense index have?

Show answer
sparse: 1000, dense: 30000

The number of blocks is 30000/30=100030000 / 30 = 1000. A sparse index keeps one entry for each block: 1000 entries.
A dense index keeps an entry for each search-key value. Since stu_id is a key, every record has a different value: 30000 entries.

Question 3medium

A heap file has br=800b_r = 800 blocks. With tS=4t_S = 4 ms and tT=0.1t_T = 0.1 ms, what is the cost of a linear scan for salary > 50000? What is the average cost for an equality condition on a key attribute?

Show answer
84 ms; 44 ms

If the condition is not on a key, the whole file is read: tS+br⋅tT=4+800⋅0.1=84t_S + b_r \cdot t_T = 4 + 800 \cdot 0.1 = 84 ms.
For equality on a key, you stop at the first match; on average half is read: tS+(br/2)⋅tT=4+400⋅0.1=44t_S + (b_r/2) \cdot t_T = 4 + 400 \cdot 0.1 = 44 ms.

Question 4medium

A file is sorted on the key course_id and has 1000 blocks, no index. Estimate the cost of course_id = 'CS305' using binary search (tS=4t_S = 4 ms, tT=0.1t_T = 0.1 ms).

Show answer
41 ms

Binary search reads ⌈log⁡21000⌉=10\lceil \log_2 1000 \rceil = 10 blocks (210=1024≥10002^{10} = 1024 \ge 1000).
Each read goes to a random place, so each block costs one seek + one transfer: 10⋅(4+0.1)=4110 \cdot (4 + 0.1) = 41 ms.

Question 5medium

In a B+ tree with n=5n = 5 (at most 5 pointers per node), what are the minimum and maximum number of keys in a leaf, and the minimum and maximum number of children of a non-root internal node?

Show answer
leaf: 2-4 keys; internal: 3-5 children

A node has at most n−1=4n-1 = 4 keys.
A leaf holds at least ⌈(n−1)/2⌉=⌈2⌉=2\lceil (n-1)/2 \rceil = \lceil 2 \rceil = 2 keys, at most 4.
A non-root internal node holds at least ⌈n/2⌉=⌈2.5⌉=3\lceil n/2 \rceil = \lceil 2.5 \rceil = 3 children, at most 5.
The root is an exception: if it is not a leaf, at least 2 children are enough.

Question 6hard

Insert the keys 8, 5, 1, 7, 3 (in this order) into an empty B+ tree with n=3n = 3 (at most 2 keys per node). When a node overflows, the left node keeps ⌈n/2⌉\lceil n/2 \rceil keys (leaf) or ⌈(n+1)/2⌉\lceil (n+1)/2 \rceil pointers (internal). Give the final tree level by level.

Show answer
[7] / [5] [8] / [1 3] [5] [7] [8]

8, 5: a single leaf [5 8].
1: [1 5 8] overflows. The left has 2 keys [1 5], the right is [8]. The first key of the right, 8, goes up: root [8].
7: since 7 < 8 it goes to the left leaf: [1 5 7] overflows, becoming [1 5] and [7], and 7 goes up to the root: root [7 8], 3 children (the limit is 3, fine).
3: [1 3 5] overflows, becoming [1 3] and [5], and 5 goes up: the root becomes [5 7 8], with 4 children, and overflows.
Internal node split: the left has ⌈4/2⌉=2\lceil 4/2 \rceil = 2 pointers [5], the middle 7 moves up (the key that moves up from an internal node does not stay in the lower node), and the right is [8].
Result: root [7], second level [5] [8], leaves [1 3] [5] [7] [8]. Height 3.

Question 7medium

A B+ tree has n=50n = 50 and indexes K=100000K = 100000 search-key values. Give an upper bound on its height (number of levels from root to leaf).

Show answer
4

In the worst case every node is half full: the branching is ⌈n/2⌉=25\lceil n/2 \rceil = 25.
Height ≤⌈log⁡25100000⌉\le \lceil \log_{25} 100000 \rceil. log⁡25100000=5log⁡1025≈3.58\log_{25} 100000 = \frac{5}{\log_{10} 25} \approx 3.58, rounded up it is 4.
So at most 4 nodes are read to find a key.

Question 8medium

A primary B+ tree index on the key inst_id has height hi=4h_i = 4. What is the cost of inst_id = 'I3' (tS=4t_S = 4 ms, tT=0.1t_T = 0.1 ms)?

Show answer
20.5 ms

hih_i nodes from the root to the leaf, then 1 data block where the record is. Each is a random access (seek + transfer).(hi+1)(tT+tS)=5⋅4.1=20.5(h_i + 1)(t_T + t_S) = 5 \cdot 4.1 = 20.5 ms.

Question 9hard

A relation has 500 blocks. A secondary B+ tree index on the non-key attribute dept_name has hi=3h_i = 3, and 200 records satisfy dept_name = 'CS', each in a different block. Compare the index plan with a linear scan (tS=4t_S = 4 ms, tT=0.1t_T = 0.1 ms). Which is cheaper?

Show answer
index 832.3 ms vs scan 54 ms: linear scan

With a secondary index each matching record may be in a separate block: (hi+n)(tT+tS)=(3+200)⋅4.1=832.3(h_i + n)(t_T + t_S) = (3 + 200) \cdot 4.1 = 832.3 ms.
Linear scan: tS+brtT=4+500⋅0.1=54t_S + b_r t_T = 4 + 500 \cdot 0.1 = 54 ms. One seek, then sequential reading.
If many records match, a secondary index is more expensive than a scan. That is why the optimizer does not always use the index.

Question 10easy

Static hashing with 5 buckets and h(k)=k mod 5h(k) = k \bmod 5. Insert 23, 8, 15, 42, 31, 10. Which keys go to each bucket 0-4?

Show answer
0: 15 10 | 1: 31 | 2: 42 | 3: 23 8 | 4: empty

23 mod 5 = 3, 8 mod 5 = 3, 15 mod 5 = 0, 42 mod 5 = 2, 31 mod 5 = 1, 10 mod 5 = 0.
Bucket 0: 15, 10. Bucket 1: 31. Bucket 2: 42. Bucket 3: 23, 8. Bucket 4 is empty.
If the bucket capacity were 1, buckets 0 and 3 would overflow (bucket overflow).

Question 11hard

A dense index has 20000 entries and 100 index entries fit in one block. You build a multilevel index until the top level fits in one block. How many index levels are there, and how many block reads does a lookup need (including the data block)?

Show answer
3 levels; 4 block reads

The lowest level: ⌈20000/100⌉=200\lceil 20000/100 \rceil = 200 blocks.
The level above it: ⌈200/100⌉=2\lceil 200/100 \rceil = 2 blocks. The level above that: ⌈2/100⌉=1\lceil 2/100 \rceil = 1 block. We stop here.
3 index levels, one block is read from each level, then the data block: 4 reads.

Question 12medium

A file is sorted on tot_cred and has a primary B+ tree index on it with hi=3h_i = 3. The query tot_cred >= 100 returns records stored in 40 consecutive blocks. Estimate the cost (tS=4t_S = 4 ms, tT=0.1t_T = 0.1 ms).

Show answer
20.3 ms

Descend to the first matching record with the index: hi(tT+tS)=3⋅4.1=12.3h_i (t_T + t_S) = 3 \cdot 4.1 = 12.3 ms.
Because the file is sorted on this attribute, the remaining records are in consecutive blocks: one seek and 40 transfers, tS+b⋅tT=4+4=8t_S + b \cdot t_T = 4 + 4 = 8 ms.
Total 12.3+8=20.312.3 + 8 = 20.3 ms.

Verification: The B+ tree insertion algorithm (leaf and internal node splits) was written in JS and the tree was printed after each insertion; the blocking factor, index level counts, hash buckets and all selection costs were computed with node; the indexed/unindexed query plan was shown with node:sqlite EXPLAIN QUERY PLAN (verify/SEN2104/12.mjs).

Review card

f_r = floor(block / record) b_r = ceil(n_r / f_r) dense: an entry for every key value sparse: one per block (the file must be sorted) primary/clustering: the file is sorted on that key secondary: not sorted, always dense B+ (n pointers): leaf ceil((n-1)/2)..n-1 keys, internal node ceil(n/2)..n children height <= ceil(log_{ceil(n/2)} K) leaves are chained, range queries are easy leaf split: the first key of the right is COPIED up internal split: the middle one MOVES up linear: t_S + b_r t_T equality on a key, average: t_S + (b_r/2) t_T binary search (sorted file): ceil(log2 b_r)(t_T + t_S) primary index, key equality: (h_i + 1)(t_T + t_S) primary, non-key / range: h_i(t_T + t_S) + t_S + b t_T secondary, n matches: (h_i + n)(t_T + t_S) → more expensive than a scan if n is large hash: h(k) = k mod m, overflow chain; extendable hashing doubles the directory