Pages & Extents
The storage floor plan
Disks don't know about rows, tables, or users. They know about bytes at offsets. The storage engine's whole job is to translate the messy world of variable-sized rows into the rigid world of fixed-size blocks.
Why should you care?
When someone says "this query is slow because of too many page reads," or when you're picking a cacheSize, you need to know what a page is. Knowing the size also sanity-checks AI suggestions — "cache 1000 pages" means ~4 MB, not 4 GB.
Four ideas that run the floor
Page
The atom of storage. Everything on disk is a page. Nothing smaller is ever read or written.
Extent
A neighborhood of 8 adjacent pages, fetched as a group for locality — one truck trip, not eight.
Dirty flag
Says "this page has changes in memory that haven't hit disk yet." Lets writes be batched.
LRU cache
Keeps hot pages in RAM. When full, evicts the one nobody has touched for the longest.
Find any page on disk
Every page has a global id. From it, the engine computes which extent it lives in and its exact byte offset in the file. Click a page — or type one — and watch the math.
A page, in code
public class Page
{
public const int PageSize = 4096; // 4KB pages
public int PageId { get; set; }
public byte[] Data { get; set; }
public bool IsDirty { get; set; }
public Page(int pageId)
{
PageId = pageId;
Data = new byte[PageSize];
IsDirty = false;
}
}
In plain English
PageSize = 4096 — a hard constant. Every page is exactly 4 KB, no exceptions.
PageId — the page's global number. Multiply by 4096 and you have its offset in the file.
Data — the raw bytes. Rows, tree nodes, anything — it's all just bytes in here.
IsDirty — the dirty flag. true means "modified in memory, needs writing to disk."
An extent groups eight pages
public class Extent
{
public const int PagesPerExtent = 8;
public int ExtentId { get; set; }
public Page[] Pages { get; set; }
public Extent(int extentId)
{
ExtentId = extentId;
Pages = new Page[PagesPerExtent];
for (int i = 0; i < PagesPerExtent; i++)
{
int pageId = extentId * PagesPerExtent + i;
Pages[i] = new Page(pageId);
}
}
}
The address math
An extent holds an array of exactly 8 pages.
The line extentId * PagesPerExtent + i is the whole trick. It turns an extent number and a slot into a global page id.
Example: page 17 lives in extent floor(17/8) = 2, slot 17 % 8 = 1. Exactly what the explorer above showed you.
Why 4 KB? Most SSDs and OS page caches work in 4 KB blocks. Matching the database page size to the OS block size means one database page equals one disk I/O — no wasted reads.
The LRU cache keeps pages hot
RAM is thousands of times faster than disk. The cache holds recently-used pages so most lookups never touch the disk at all.
public void Put(int pageId, Page page)
{
lock (_lockObject)
{
if (_cache.TryGetValue(pageId, out var node))
{
node.Page = page;
MoveToHead(node); // touched = most recent
return;
}
var newNode = new CacheNode(page);
_cache[pageId] = newNode;
AddToHead(newNode); // newest at the head
if (_cache.Count > _capacity)
{
var removed = RemoveTail(); // evict the coldest
_cache.TryRemove(removed.Page.PageId, out _);
}
}
}
The head/tail dance
Picture a doubly-linked list. The head is the most recently used page; the tail is the coldest.
Already cached? MoveToHead — touching a page makes it most-recent.
New page? AddToHead, then if we're over capacity, RemoveTail evicts whatever nobody has touched in the longest time.
That's LRU in eight lines — and it's why a well-sized cache makes the disk almost disappear.
Dirty pages are lazy. A page can be modified in memory dozens of times and written to disk only once — when a flush is requested. That batching is a big reason databases are fast: disk writes are expensive, so you do as few as possible.
Drive the cache: hits, misses & evictions
Reading the code is one thing — feeling the cache behave is another. Below is a live LRU cache with room for just 4 pages (kept tiny so evictions happen fast). Request a page by id: a hit finds it and slides it to the head; a miss loads it from disk and, when the cache is full, evicts the coldest page from the tail. Watch the hit-rate climb when you re-request pages you've touched recently.
Try this: request 3, 7, 1, 4 (all hits — they're pre-loaded), then request 9. The cache is full, so the page you haven't touched longest — sitting at the tail — gets evicted to make room. That single decision, repeated millions of times a second, is what keeps a database's working set in fast memory.
Drag each page to its extent
Use the math: extent = floor(pageId / 8). Drag the chips into the right zone, then check.
Check yourself
Flush(). The power is cut. What survives?Up next: we know where bytes live. The next question is the one every database must answer billions of times a day — how do you find one specific row among millions, fast? Enter the B+ tree.