Hello! Welcome back to our deep dive into query execution.
In our last lesson, we analyzed the I/O cost of external merge sort, a fundamental single-table operator. We saw how crucial it is to minimize disk I/O by making effective use of the buffer pool.
Today, we move from single-table operations to the heart of relational databases: the join. Joins are what allow us to combine data from normalized tables, but they are also among the most expensive operations a database performs. Our goal is to implement and analyze the I/O cost of the block nested loop join algorithm. This is the most straightforward join algorithm, and understanding its performance provides a vital baseline for appreciating the more complex algorithms we'll see later.
1. The Join Problem and a Naive First Attempt
First, let's set the stage. Joins are necessary because we normalize our database schemas to avoid data redundancy. A join operation reconstructs the original, un-normalized data on the fly. As with external sorting, our primary concern for disk-based databases is the I/O cost.
The following video from Carnegie Mellon University's database course introduces the join problem and establishes I/O cost as our primary performance metric.
CMU Database Systems - 12 Join Algorithms (Fall 2018)
Professor Andy Pavlo explains why joins are necessary and why they are often the most expensive part of query execution in analytical workloads. He also introduces the cost model we'll be using.
Watch from 2:10 to 7:45. Pay close attention to the concepts of outer and inner tables, and the formal definition of our cost analysis, where we count page I/Os.
Simple Nested Loop Join: The Brute-Force Method
Given your background in programming, the most intuitive way to join two sets of data (let's call the tables R and S) is with nested loops. This is the simple nested loop join.
The logic is exactly what it sounds like:
// For each tuple in the outer table R...
for (tuple tr in R) {
// ...iterate through every single tuple in the inner table S
for (tuple ts in S) {
if (tr and ts match on the join condition) {
add <tr, ts> to the result;
}
}
}
This is simple, but horrendously inefficient for a disk-based database. Why? Because for every single tuple in the outer table, we might have to scan the entire inner table from disk.
To understand just how bad this is, let's watch the next segment of the video, which analyzes its I/O cost.
CMU Database Systems - 12 Join Algorithms (Fall 2018)
This segment explains the simple nested loop join algorithm and derives its I/O cost. The example calculation shows why this approach is impractical for large tables.
Watch from 15:45 to 18:25. Focus on how the cost formula is derived and the staggering number of I/Os it leads to in the example.
As the video explained, if R is the outer table and S is the inner table, the cost is:
Where:
- : number of pages in
R - : number of tuples in
R - : number of pages in
S
The term is the killer. If R has millions of tuples, we are scanning the entire S table millions of times. Clearly, we need a better way.
2. The Block Nested Loop Join (BNLJ)
The key inefficiency of the simple nested loop join is that it operates tuple-by-tuple, ignoring the fact that data is read from disk in pages (or blocks). The block nested loop join leverages this insight. Instead of fetching one tuple at a time, we fetch one block at a time.
The algorithm becomes:
// For each block of the outer table R...
for (block br in R) {
// ...iterate through every block of the inner table S
for (block bs in S) {
// Now, compare all tuples within the two in-memory blocks
for (tuple tr in br) {
for (tuple ts in bs) {
if (tr and ts match on the join condition) {
add <tr, ts> to the result;
}
}
}
}
}
This simple change has a massive impact on I/O. We are now scanning the inner relation S once for each page of R, not for each tuple.
The cost formula changes to:
This is a significant improvement, replacing the huge number of tuples () with the much smaller number of pages ().

3. Optimizing BNLJ with the Buffer Pool
We can do even better. The algorithm above assumes we only have enough memory for one page of R and one page of S. But in our previous lessons, we've seen the power of using the buffer pool. If we have B buffer pages available, how can we use them?
The strategy is to dedicate as much memory as possible to holding a "chunk" or "block" of the outer relation, R.
- We use 1 page for scanning the inner relation
S. - We use 1 page for the output buffer.
- This leaves
B-2pages to hold a chunk of the outer relationR.
The improved algorithm is:
// Iterate through the outer relation R in chunks of size (B-2)
for (chunk_of_R of size (B-2) pages) {
// Read the entire inner relation S from disk
for (block bs in S) {
// Compare all tuples in the chunk_of_R with the tuples in bs
for (block br in chunk_of_R) {
for (tuple tr in br) {
for (tuple ts in bs) {
// ... join logic ...
}
}
}
}
}
This reduces the number of times we have to scan the inner relation S. Instead of scanning it M times, we now only scan it times.
The final I/O cost formula for Block Nested Loop Join is:
This next video segment explains this optimization and the final cost formula.
CMU Database Systems - 12 Join Algorithms (Fall 2018)
Continuing with the CMU lecture, this part explains the page-oriented Block Nested Loop Join and how using available buffer pages significantly reduces the I/O cost.
Watch from 19:03 to 22:15. This is the core of today's lesson. Understand how the formula changes from M + (M*N) to incorporating the buffer size B.
For a different perspective and a concrete numerical example, the following video walks through the entire process, including the cost calculation.
Query Processing : Block Nested Loop Join
This tutorial provides a step-by-step walkthrough of the Block Nested Loop Join, applying the cost formulas to a specific example and showing the impact of swapping the inner and outer relations.
Watch from 4:07 to 10:03. This will solidify your understanding by working through a full example, showing how to calculate block transfers and how using more buffers improves performance.
As you saw, one of the most important and simplest optimizations is to choose the smaller relation as the outer relation. Since M appears in the multiplicative term of the cost formula, making M as small as possible will yield the greatest benefit.
For a final summary of the algorithm and its cost, these lecture notes are an excellent reference.
Lecture Notes - Joins Algorithms
These are the lecture notes corresponding to the CMU video. They provide a concise summary of the nested loop join variants and their cost formulas.
Please read the section titled '3 Nested Loop Join' (page 2). Focus on the 'Block Nested Loop Join' subsection and its cost formulas. This is a great text summary of what we've just seen.
Test your understanding!
You need to join two tables, Employees and Departments.
- Employees (R):
- Size: 10,000 pages ()
- Tuples: 1,000,000 ()
- Departments (S):
- Size: 100 pages ()
- Tuples: 500 ()
- Buffer Pool: 102 pages available ()
- Which table should be the outer relation?
- Calculate the I/O cost of the Block Nested Loop Join with the optimal choice.
Show answer
-
Which table should be the outer relation?
We should always choose the smaller table as the outer relation to minimize I/O. "Smaller" here refers to the number of pages. TheDepartmentstable has only 100 pages, whileEmployeeshas 10,000. Therefore, Departments should be the outer relation. -
Calculate the I/O cost.
WithDepartmentsas the outer relation, our parameters are:- (pages in outer relation)
- (pages in inner relation)
- (available buffer pages)
The cost formula is:
Let's plug in the numbers:
- First, calculate the number of outer relation chunks: .
- This means the entire outer relation (
Departments) fits into theB-2buffer pages available for it! We only need to read it once. - The total cost is: I/Os.
This is a "one-pass" join. We read the entire outer relation into memory, and then read the inner relation just once, probing the in-memory outer relation for matches. This is the best-case scenario for BNLJ.
Conclusion
Today we took our first step into the world of join algorithms. We saw how a naive, tuple-at-a-time approach is prohibitively expensive and how moving to a block-oriented mindset dramatically improves performance.
Key Takeaways:
- The Simple Nested Loop Join is too slow for disk-based systems due to its I/O cost.
- The Block Nested Loop Join improves this by processing data page-by-page, reducing the cost to .
- By utilizing the buffer pool effectively, we can hold a chunk of the outer relation in memory, further reducing the cost to .
- For BNLJ, always choose the smaller relation (in pages) as the outer relation to minimize the number of scans over the inner relation.
Preview of the Next Lesson
The Block Nested Loop Join, while better than the naive approach, still repeatedly scans the entire inner relation. If the outer relation is large, this is still very costly. In our next lesson, we will explore the Index Nested Loop Join. We'll see how leveraging an index on the inner table's join key can allow us to avoid full scans entirely, turning expensive scans into highly efficient lookups.