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.
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:
- The unit of cost is disk access. CPU is ignored. Reading a block = transfer (), jumping to another place = seek ().
- Seek is much more expensive than transfer. On this page we always use ms, ms.
- An index does not always help. If many records match, a secondary index can be slower than a plain scan.
- 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:
| Symbol | Meaning |
|---|---|
| the number of records (tuples) in the relation | |
| blocking factor: the number of records that fit in a block | |
| the number of blocks the relation occupies |
Example: block 4096 bytes, record 200 bytes. . With 10000 records, 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.
| Kind | Definition | Note |
|---|---|---|
| Dense index | An entry for every search-key value | Can be built on any file |
| Sparse index | Entries for only some values (usually one per block) | The file must be sorted on that key |
| Primary / clustering index | The file is sorted on the index’s key | At most one per table |
| Secondary index | The file is not sorted on this key | Must 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 : the maximum number of pointers in a node. A node holds at most keys.
| Node | Minimum | Maximum |
|---|---|---|
| Leaf | keys | keys |
| Internal node (except the root) | children | children |
| Root (if not a leaf) | 2 children | 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 distinct keys:
Real numbers: 4 KB block, 32-byte key, 8-byte pointer. From the inequality we get . If we take roughly , for a million keys . 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)
(at most 3 keys). In an overflowing leaf, the first 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 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 . 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: buckets and a hash function . A record is written to bucket number . An equality search reads a single bucket; for a range query hashing is useless because neighboring values are scattered.
With for 12, 7, 21, 30, 16, 9, 43:
| Bucket | Keys |
|---|---|
| 0 | 12, 16 |
| 1 | 21, 9 |
| 2 | 30 |
| 3 | 7, 43 |
If a bucket fills up, an overflow bucket is chained. As the chain grows, search slows down. As data grows, stays fixed, so static hashing degrades.
Extendable hashing solves this: it keeps a directory that looks at the first bits of the hash value. When a bucket overflows, only that bucket is split in two; if needed, the directory size is doubled ( increases by one). There is no need to rehash the whole file.
Selection costs
The methods used for or . 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. : the height of the B+ tree. In the examples, , .
| Method | Cost | Example |
|---|---|---|
| Linear scan | ms | |
| Linear, equality on a key (average) | ms | |
| Binary search, file sorted on the key | ms | |
| Primary B+ index, equality on a key | ms | |
| Primary index, equality on a non-key ( blocks) | : ms | |
| Secondary index, matching records | : ms | |
| Primary index, range ( blocks) | : 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 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 idea | The truth |
|---|---|
| is rounded up | It is rounded down (half a record does not fit); is rounded up |
| A secondary index can be sparse | It cannot; the file is not sorted on that key, so it is always dense |
| Several clustering indexes on one table | A file can be sorted in only one order, so at most one |
| = the number of keys in a node | is the number of pointers; keys are at most |
| In an internal node split the middle key stays in two places | It moves up in an internal node; it is copied only in a leaf split |
| A B+ tree grows down from the leaves | The root splits and the tree grows upward; leaves are always at the same depth |
| Binary search counts only transfers | Every step jumps to a different block: |
| With an index it is always faster | A secondary index with many matches, , can be slower than a scan |
| Hashing speeds up range queries | Only equality; use a B+ tree for ranges |
Test yourself
Exam-style questions. Solve them on paper first, then reveal the answer.
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 and the number of blocks .
Show answer
records/block. Because records do not span blocks, it is rounded down. blocks. The number of blocks is rounded up; half a block is still a block.
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
The number of blocks is . 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.
A heap file has blocks. With ms and 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
If the condition is not on a key, the whole file is read: ms.
For equality on a key, you stop at the first match; on average half is read: ms.
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 ( ms, ms).
Show answer
Binary search reads blocks ().
Each read goes to a random place, so each block costs one seek + one transfer: ms.
In a B+ tree with (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
A node has at most keys.
A leaf holds at least keys, at most 4.
A non-root internal node holds at least children, at most 5.
The root is an exception: if it is not a leaf, at least 2 children are enough.
Insert the keys 8, 5, 1, 7, 3 (in this order) into an empty B+ tree with (at most 2 keys per node). When a node overflows, the left node keeps keys (leaf) or pointers (internal). Give the final tree level by level.
Show answer
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 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.
A B+ tree has and indexes search-key values. Give an upper bound on its height (number of levels from root to leaf).
Show answer
In the worst case every node is half full: the branching is .
Height . , rounded up it is 4.
So at most 4 nodes are read to find a key.
A primary B+ tree index on the key inst_id has height . What is the cost of inst_id = 'I3' ( ms, ms)?
Show answer
nodes from the root to the leaf, then 1 data block where the record is. Each is a random access (seek + transfer). ms.
A relation has 500 blocks. A secondary B+ tree index on the non-key attribute dept_name has , and 200 records satisfy dept_name = 'CS', each in a different block. Compare the index plan with a linear scan ( ms, ms). Which is cheaper?
Show answer
With a secondary index each matching record may be in a separate block: ms.
Linear scan: 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.
Static hashing with 5 buckets and . Insert 23, 8, 15, 42, 31, 10. Which keys go to each bucket 0-4?
Show answer
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).
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
The lowest level: blocks.
The level above it: blocks. The level above that: block. We stop here.
3 index levels, one block is read from each level, then the data block: 4 reads.
A file is sorted on tot_cred and has a primary B+ tree index on it with . The query tot_cred >= 100 returns records stored in 40 consecutive blocks. Estimate the cost ( ms, ms).
Show answer
Descend to the first matching record with the index: ms.
Because the file is sorted on this attribute, the remaining records are in consecutive blocks: one seek and 40 transfers, ms.
Total 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).