Internals · StorageAtlasDB V2 · PagespageblockI/Ochecksumtorn write

Pages: The Unit of Everything

Why the engine never reads a row: the file is Page 0, Page 1, …, page N is at byte N × 8192, and a record is addressed as (page, slot). Page reads, page writes, torn writes and checksums — and the cost model that follows: the page is the unit of I/O and of the buffer pool.

▶ InteractiveInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. Problem

    Records of different lengths in one file, and a device that only transfers 4 KB blocks: how do you find record number 3,000,000, replace it, and know the next write did not corrupt its neighbours?

  2. Naive solution

    Store each record's byte offset in an index: record 3,000,000 is at byte 231,004,113. Seek there, read its length, read it.

  3. Why it breaks

    Every record grows or shrinks on update, so every byte offset after it changes and the index must be rewritten. A record straddling two 4 KB device blocks is written in two operations; a crash in between leaves neither the old nor the new record. And the OS transfers whole blocks anyway, so "read 77 bytes" is a lie.

  4. Better idea

    Make the unit of storage the same as the unit of transfer, and make it fixed-size: the file is an array of equal pages, page N at byte N × size. Address a record by (page, slot) and let the page decide where inside it the bytes are.

  5. Internal mechanism

    An 8192-byte page with a 24-byte header (checksum, LSN, lower, upper), a slot directory, free space and records. Read = one pread of 8192 bytes at N × 8192; write = one pwrite of the same. Locating a record: read the page, index the slot directory, follow the offset.

  6. Trade-offs

    A page is the smallest thing that moves, so touching one byte costs 8 KB in and 8 KB out; large pages amortize headers and scan well, small pages waste less on random access. A page is still two device blocks, so torn writes remain possible and need a checksum to detect and a log to repair.

  7. Real database

    PostgreSQL: BLCKSZ = 8192, pd_checksum (enabled with initdb --data-checksums), full_page_writes to survive torn pages, Buffers: shared hit=… read=… in EXPLAIN. InnoDB: 16 KB pages, CRC-32C in the FIL header and trailer, the doublewrite buffer.

Choose your depth

The same mechanism at four altitudes. Start where you are; come back deeper.

The engine reads pages, not rows

A table file is a row of equal-sized boxes called pages. To get any row, the engine fetches the box it is in — the whole box, always. To change a row, it changes the box in memory and later writes the whole box back.

So the cost of a query is roughly the number of boxes it opens, not the number of rows it looks at.

The file is an array of pages

Open a PostgreSQL table file and there is nothing to see: no magic number, no directory, no record separators. It is Page 0, Page 1, Page 2, …, each exactly 8192 bytes, and the only way to know anything about it is to read a page and interpret its header. That emptiness is the design. A page id is an address — page 4821 starts at byte 4821 × 8192 = 39,493,632 — so getting to a page costs one multiplication and one pread, and appending a page costs writing 8192 bytes at the end. The file grows in page-sized steps and never has partial pages.

Because pages are the same size, the engine can plan around them without reading them: a table of 12,800 pages is 100 MB and a full scan is 12,800 sequential reads, before a single byte is fetched. That number, relpages, is in the catalog and is the first thing the planner looks at.

A table file. Nothing separates the pages; their position is their id.
base/16384/24576          (relfilenode 24576, 12 pages, 98,304 bytes)

byte 0        8192       16384      24576      32768   …   90112
     ┌──────────┬──────────┬──────────┬──────────┬───────┬──────────┐
     │  Page 0  │  Page 1  │  Page 2  │  Page 3  │  …    │ Page 11  │
     └──────────┴──────────┴──────────┴──────────┴───────┴──────────┘
       8192 B     8192 B     8192 B     8192 B              8192 B

page N  →  byte N × 8192       append  →  write 8192 B at byte 12 × 8192

Locating a record: (page id, slot)

A record's address in the rest of the engine is never a byte offset in the file. It is a pair: page id and slot number — PostgreSQL's ctid, printed as (4821,3). To resolve it: compute the page's byte offset, read the page (or find it already in the buffer pool), read slot 3 of the slot directory to get the record's offset *within the page*, and follow it. Three steps, one I/O at most, and no searching.

The indirection through the slot is deliberate. If an index stored byte offsets, every record that moved — because a neighbour grew, because the page was compacted — would invalidate index entries all over the tree. With (page, slot), the page is free to shuffle bytes internally as long as slot 3 keeps pointing at the right record. Slotted Pages is entirely about that freedom.

Resolving (page 4821, slot 3)
misshitctid (4821, 3)buffer pool: page 4821 cached?pread 8192 B at 4821 × 8192slot directory[3] → offset 7412record bytes 7412–7488
UserLLMAgentToolDataDecisionHumanGuardrail

Page reads and page writes

The engine has exactly two operations against the file: read page N, write page N. A page read is a request for 8192 bytes at N × 8192 into a buffer-pool frame; after it, every record on the page is in memory whether it was wanted or not. SELECT name FROM users WHERE id = 42 reads one page and ignores ~100 records on it. A page write is the reverse — the whole frame back to N × 8192 — and is issued not when a row changes but when the buffer pool decides the dirty page must go (eviction, checkpoint). Ten updates to ten rows on the same page are one write.

This has a consequence people find counter-intuitive: the engine never reads a single row and never writes a single row. Cost is paid per page. A query that returns 5 rows from 5 pages costs the same I/O as one that returns 500 rows from 5 pages. And "small" updates are not small: changing one byte dirties 8192 and, after a checkpoint, logs all 8192 to the WAL as well.

The whole storage interface of a page-oriented engine.
1read_page(file, n) -> pread(fd, buf, 8192, n * 8192) # into a buffer-pool frame
2write_page(file, n) -> pwrite(fd, frame, 8192, n * 8192)
3append_page(file) -> write_page(file, page_count); page_count += 1
4
5# locate a record
6frame = buffer_pool.get(file, page_id) # hit: pointer · miss: read_page
7off, len = frame.slot_directory[slot]
8record = frame[off : off + len]

Torn writes and checksums

The device does not know what a page is. An 8 KB page is two 4 KB blocks, and the storage stack promises atomicity per block at best. Lose power between the two halves and the page on disk is the new first half glued to the old second half: a torn page. Its slot directory may point at records that are not there any more; a record may be half of two versions. Nothing in the bytes says so.

A checksum in the page header is the detector. Computed over the page when it is written, verified when it is read, it turns silent corruption into a loud error — WARNING: page verification failed — at the cost of a few microseconds per I/O. PostgreSQL stores a 16-bit checksum in pd_checksum (opt-in at initdb --data-checksums, or pg_checksums later); InnoDB stores CRC-32C in the FIL header and repeats it in the trailer, so a torn page fails the trailer comparison even before the CRC.

Detection is not repair. To make a torn page whole again, the engine needs a complete good copy from somewhere: PostgreSQL writes a full-page image into the WAL the first time a page is modified after each checkpoint, and recovery restores that image before replaying the smaller changes; InnoDB writes each page to a doublewrite buffer and fsyncs it before writing it in place, so one of the two copies is always intact. Both are page-sized costs paid to survive a block-sized guarantee. Crash Recovery walks through the restart.

A torn write: the first device block reached the disk, the second did not.
intended page (v2)             on disk after the crash
┌──────────────┐ 0             ┌──────────────┐ 0
│ header  v2   │               │ header  v2   │  ← new: checksum, LSN, lower/upper for v2
│ slots   v2   │               │ slots   v2   │  ← new: slot 7 says "record at 5120"
│ free         │               │ free         │
├──────────────┤ 4096          ├──────────────┤ 4096 ─── block boundary ───
│ records v2   │               │ records v1   │  ← old: nothing at 5120
└──────────────┘ 8192          └──────────────┘ 8192
                               checksum ≠ pd_checksum → "page verification failed"

The cost model: page = unit of I/O = unit of the buffer pool

Once the page is the unit of I/O it becomes the unit of everything built on I/O. The planner estimates cost in page reads: seq_page_cost (1.0) for pages read in order, random_page_cost (4.0) for pages fetched by pointer, plus small per-row CPU terms. A sequential scan of 12,800 pages costs ~12,800; an index lookup costs ~4 random pages, ~16 units. Rows only enter as multipliers. EXPLAIN (ANALYZE, BUFFERS) reports the truth in the same unit: Buffers: shared hit=3 read=1 means four pages, three already in memory.

The The Buffer Pool is a cache *of pages*: a hash table from (file, page id) to a frame, with an eviction policy over frames. Its hit ratio is a ratio of pages. Its capacity — shared_buffers, innodb_buffer_pool_size — is a number of pages. The WAL records changes by (page id, offset, bytes). Locks on data are page latches around a tuple lock. Understanding the page means understanding what every one of those numbers counts.

Page size is a trade-off, and each engine picked a different point
Page sizeGood atBad atWho
4 KBRandom point reads on small rows; matches the device block, no torn-page windowScans (more headers, more I/O calls), fewer keys per index page → taller treesSQLite default; Oracle default
8 KBBalance: ~100 narrow rows or ~200 index keys per page, two device blocksTorn-page handling needed; wide rows waste the tail of the pagePostgreSQL, SQL Server
16 KBScans and wide rows; ~400 index keys per page → shorter trees; fewer I/O callsPoint reads move 4× more bytes than a 4 KB page; larger buffer-pool granularityInnoDB

Key points

  • A table file is Page 0, Page 1, … with no separators; page N is at byte N × page size, so a page id is an address.
  • A record is addressed as (page id, slot); the slot directory turns that into a byte offset inside the page, so records can move within a page without invalidating references.
  • The engine has two file operations: read page N, write page N. It never reads or writes a single row.
  • A page is two or four device blocks; a crash between them tears it. Checksums detect torn pages; full-page writes (PostgreSQL) or the doublewrite buffer (InnoDB) repair them.
  • The page is the unit of the planner's cost model, of the buffer pool, of the WAL and of latching. Every performance number is ultimately a page count.

A database file is an array of pages

A database file is an array of pages
Twelve 8 KB pages. Every operation the engine performs on this file is a whole-page read or a whole-page write at offset N × 8192.
base/16384/24576 · 96.0 KBPage 084 rec@0Page 180 rec@8,192Page 299 rec@16,384Page 388 rec@24,576Page 485 rec@32,768Page 584 rec@40,960Page 691 rec@49,152Page 797 rec@57,344Page 8103 rec@65,536Page 999 rec@73,728Page 1089 rec@81,920Page 1112 rec@90,112
pages read
0
pages written
0
bytes read
0 B
bytes written
0 B
file size
12 pages · 96.0 KB
Nothing read yet. The file is 12 pages = 98,304 bytes; page N starts at byte N × 8192.
Address of a record
(page id, slot) — never a byte offset in the file
Byte offset of page N
N × 8192
Smallest read
one page, 8,192 B — not one row
Educational simulation — page sizes, costs and counters are modelled, not measured from a real engine.

When to use — and when not

Use it when
  • Fixed-size pages fit any engine that needs random access to rows, in-place updates, and a single unit for caching, logging and locking — every OLTP row-store.
  • Larger pages (16 KB) fit scan-heavy tables and wide rows; smaller pages fit point-read workloads on small rows where moving 8 KB for 80 bytes is the waste.
Avoid it when
  • Page-at-a-time I/O does not fit append-only logs or immutable sorted runs, where the unit can be a whole file written once and never updated in place (SSTables, SSTables: The Immutable Sorted File).
  • It does not fit workloads that read one column across the whole table; columnar blocks of one column each read far fewer bytes.

Failure modes

  • Counting rows instead of pages when estimating a query: 50 rows on 50 pages is 50 random reads, which is slower than a sequential scan of 200 pages.
  • Running without checksums and discovering a torn page as "invalid page in block 4821" months after the power event that caused it.
  • Disabling full_page_writes to save WAL volume on storage that does not guarantee atomic 8 KB writes.
  • Assuming a small UPDATE is cheap: it dirties a whole page, logs a full page image after a checkpoint, and forces a whole-page write at the next checkpoint.

Where you meet this

Back up to the practical layer, and across to the rest of Engineer Atlas.