
Last part you made a file out of pages. But there's a nagging detail: a page is just a pile of bytes. So how does a database find a specific row inside those bytes? And why would it ever want to fetch a whole page to read one row?
Let's answer the second question first, because it's the more famous one.
Imagine you could ask the disk for "just row 42, 60 bytes." Sounds efficient. But here's the cost nobody shows you: the disk is slow to get started, not slow per byte.
Think of a disk (or an SSD): before it delivers a single byte, it must move a head or locate a cell — a big up-front cost called seek. If you read one 60-byte row and pay a full seek, you've spent a lot of effort for almost nothing.
The database's answer (and the operating system's too): read a bigger chunk at once, and use every useful thing inside it. If a page is 8KB, one seek buys you ~8000 bytes — and the row you wanted is somewhere inside. Reading "one page" is almost as cheap as reading "one row," but you get a hundred times more data.
You met this idea in Part 1 as the book analogy — you don't read "one character per page-turn," you read a page. Now it's literal.
Now the harder part: a page is a jumble of rows, and rows have variable lengths. If you just had a stream of bytes, finding the 3rd row means counting byte by byte — which is O(rows) per lookup. Awful.
The fix is a directory. Put a small list at the front of the page: for each row, record its offset (where it starts) and its length. Then the data can pile up from the back. Here's the shape:
┌────────────────────────────── Page (a few KB) ──────────────────────────────┐
│ header │ dir entry 1 │ dir 2 │ dir 3 │ ... free space ... row3 │ row1 │ row2 │
│ (page │ → offset,len │ →... │ →... │ │ │ │
│ info) │ ↑ rows grow from the back │
└──────────────────────────────────────────────────────────────────────────────┘The directions "from the back" and "from the front" meet in the middle, so the page uses its space efficiently. Let's implement a tiny page that finds a row by going through the directory:
const PAGE_SIZE = 1024;
// a "page" = a header + a directory + row data
function createPage() {
return {
header: { pageId: 0, freeStart: 0, freeEnd: PAGE_SIZE },
dir: [], // entries: { offset, length }
data: new Uint8Array(PAGE_SIZE)
};
}
// write a row into the page, add a dir entry, return its "slot"
function appendRow(page, bytes) {
const length = bytes.length;
page.data.set(bytes, page.header.freeEnd - length);
page.header.freeEnd -= length;
page.dir.push({ offset: page.header.freeEnd, length });
return page.dir.length - 1; // the slot = position in the directory
}
// read the row at "slot" via the directory
function readRow(page, slot) {
const entry = page.dir[slot];
return page.data.subarray(entry.offset, entry.offset + entry.length);
}Now you see the structure clearly: the directory is the page's "address plate," and a TID's "slot" is just an index into that directory. That's exactly the "directories (item pointers)" you've heard about.
And look what unifies with Part 6: a row's TID = (page, slot). The page tells you which chunk; the slot tells you which directory entry — and the directory entry says where in the page the bytes actually are. The whole addressing chain is now visible and built by you:
TID (page, slot) → find the page → dir[slot] gives (offset, length) → read the bytesNow the famous "8KB." Why not 512 bytes? Why not 1MB? You've seen the two opposing pulls, so you can reason through the balance yourself:
If pages are huge, a point query (WHERE id = 42) drags in a lot of rows you didn't want. If pages are tiny, a large scan does a lot of seeks. The sweet spot — a few KB — balances these. PostgreSQL chose 8KB; MySQL InnoDB chose 16KB. Neither is "right" — they're a judgment call shaped by the workload.
Remember the whole point: you don't pay for the row you want; you pay for the page that contains it. So page size is a bet on "how often you want the neighbors."
appendRow/readRow to add rows of different lengths and confirm readRow(2) returns the right bytes — the directory is doing the work.readRow actually touches an entire page, not just one row. Now imagine a full table scan of 1M rows — how many pages do you hit?PAGE_SIZE and reason out which workload (many tiny keyed lookups vs one big scan) would prefer bigger vs smaller.Keywords: page、seek、directory / item pointers、offset、slot、TID、page size、point vs scan
You can now read one page from disk, and find a row inside it. But notice the punchline you've been building toward: a query might need a page that's already been read once by an earlier query. Reading disk is expensive — so wouldn't it be smart to keep the most-recently-read pages in memory, instead of going back to disk every time?
That's Part 9: a page cache — the buffer pool. And it explains one of the most comforting observations in all of databases: the same query is slow first, fast second. You're about to implement the reason why.