Part 2 · 8 chapters · ~20 min

On-disk anatomy

Part 1 ended at the storage engine's door. This part goes through it. Every table you have ever created is, physically, a set of 16KB pages in a file, and nearly every performance property you care about — why a UUID key hurts, why a VARCHAR(300) index behaves oddly, why deleting rows does not return disk — is a consequence of that page's layout. So we read it byte by byte.

16

The files MySQL keeps

Everything lives under datadir. Here is what is actually there on an 8.x server, and what each file is for:

/var/lib/mysql/
├── ibdata1 The system tablespace. Post-8.0 it holds the doublewrite buffer (until 8.0.20), change buffer and some internal structures — but no longer your table data, and no longer the data dictionary.
├── mysql.ibd The transactional data dictionary. Replaced .frm files in 8.0. This is why DDL is now atomic.
├── #innodb_redo/ Redo log files (8.0.30+ moved them here). Crash recovery replays these. Part 6.
├── undo_001, undo_002 Undo tablespaces. Old row versions for MVCC live here. Part 4.
├── #ib_16384_0.dblwr Doublewrite files (separate since 8.0.20). Torn-page protection. Chapter 65.
├── yourdb/
│ ├── users.ibd One file per table, under innodb_file_per_table (default since 5.6). This is your table — the clustered index and every secondary index on it.
│ └── orders.ibd
├── binlog.000001 The binary log — logical changes for replication and PITR. A server-layer log, not InnoDB's. Part 9.
├── binlog.index Plain text list of binlog files.
└── ib_buffer_pool Page ids saved at shutdown so the buffer pool can be warmed on restart. Chapter 74.
the death of the .frm file
Before 8.0, table structure lived in a .frm file next to the data, outside any transaction. A crash midway through a DDL could leave the .frm and the InnoDB dictionary disagreeing — a half-created table that you could neither use nor drop. 8.0 moved all of it into mysql.ibd, an InnoDB table, which is what makes atomic DDL possible: a failed ALTER now rolls back completely.
run it
-- Where is everything?
SELECT @@datadir, @@innodb_file_per_table,
       @@innodb_undo_directory, @@innodb_redo_log_capacity;

-- Every tablespace this server knows about, with its file.
SELECT space, name, file_size, allocated_size
FROM information_schema.innodb_tablespaces
ORDER BY file_size DESC
LIMIT 10;

-- In a shell, look at the actual directory:
--   sudo ls -lh $(mysql -Nse "SELECT @@datadir")
17

Tablespaces

A tablespace is a logical container of pages, backed by one or more files. InnoDB has five kinds, and knowing which is which explains several operational surprises.

TypeBackingHoldsNote
Systemibdata1Change buffer, internal structuresNever shrinks. If it grew because someone ran without file-per-table, the only fix is a dump and reload.
File-per-tabletbl.ibdOne table + its indexesDefault. Lets DROP TABLE and OPTIMIZE actually return disk to the OS.
Generalname.ibdMany tables you assign to itCREATE TABLESPACE. Useful for grouping many small tables and cutting file-handle count.
Undoundo_00NRollback segmentsTruncatable since 8.0. This is what grows under a long-running transaction.
Temporaryibtmp1On-disk internal temp tablesRecreated at startup. A runaway query can inflate it until the disk fills.
the ibdata1 trap
The system tablespace grows but never shrinks. On old servers configured without innodb_file_per_table, all table data went into ibdata1 — so dropping a 200GB table frees space inside the file for reuse, and returns nothing to the filesystem. Engineers discover this during a disk-full incident, at which point the only remedy is a full logical dump, a wipe, and a reload.
18

The 16KB page, byte by byte

This is the central object of the whole module. Every read, every write, every lock, every checksum operates on a page. Default size 16384 bytes. Here is the entire layout:

offsetsizeregionwhat it holds
038FIL headerChecksum, page number, prev/next page pointers (the doubly-linked leaf chain), LSN of last modification, page type, tablespace id.
3856INDEX headerNumber of records, number of heap records, pointer to free space, garbage byte count, page level (0 = leaf), index id, and the direction/count fields used for the split heuristic.
9420FSEG headerOn root pages only: pointers to the leaf and non-leaf file segments.
11426Infimum + supremumTwo pseudo-records that are always present. Infimum sorts below every real key, supremum above. They terminate the record list so scans never need a null check — and they are lockable, which is how gap locks at the boundaries work (chapter 54).
140…User recordsYour rows, in a heap. Not in key order physically — each record carries a next_record offset forming a singly-linked list in key order.
↕…Free spaceThe gap between the record heap growing down and the directory growing up. When an insert does not fit here, the page splits.
↕…Page directoryGrows upward from the end. Slots pointing at every 4th–8th record, enabling binary search within the page instead of walking the linked list.
163768FIL trailerChecksum + the low 4 bytes of the LSN, duplicated from the header. If these don't match the header, the page was torn — see chapter 65.

Three consequences worth internalizing

  • The linked list plus directory is why page-internal search is cheap. Records are a singly-linked list in key order, but the directory gives you binary search over ~1/6 of them, then a short walk. So finding a key inside a page is roughly O(log n) with a small constant, not a linear scan.
  • The prev/next pointers in the FIL header chain the leaves. That is what makes a range scan fast: descend the tree once, then follow leaf pointers sideways without going back through the root. It is also why a B+tree beats a B-tree for range queries (chapter 24).
  • The checksum appears twice, at the very start and the very end. That is not redundancy for its own sake — it is a torn-page detector. If the OS wrote the first half of a page and died, header and trailer disagree.
run it
-- Page size, and the checksum algorithm in use.
SELECT @@innodb_page_size, @@innodb_checksum_algorithm;

-- Peek at real pages in the buffer pool: type, and how full they are.
SELECT page_type, COUNT(*) AS pages,
       ROUND(AVG(data_size)) AS avg_bytes_used,
       ROUND(AVG(number_records)) AS avg_rows
FROM information_schema.innodb_buffer_page
WHERE page_type IS NOT NULL
GROUP BY page_type
ORDER BY pages DESC;

-- avg_bytes_used on INDEX pages tells you your real fill factor.
-- Much below ~8000 on a write-heavy table means fragmentation (ch 30).
INDEX page · 16384 bytes
click a region
swipe the figure sideways, or tap expand for full screen
1/8
FIL header
38 bytes of FIL header. The page number, the LSN of its last modification, and the prev/next pointers that chain leaf pages into a list for range scans.
19

Extents, segments, and how space is allocated

InnoDB does not allocate one page at a time once a table grows. The hierarchy:

code
tablespace  (a .ibd file)
   └── segment   -- one per index, split into leaf + non-leaf
         └── extent    -- 64 contiguous pages = 1MB (at 16K pages)
               └── page      -- 16KB
                     └── row

A small table's first 32 pages come from shared "fragment" extents, so a table with three rows does not cost a megabyte. Past that, InnoDB allocates whole extents at a time. The point is physical contiguity: 64 adjacent pages can be read with sequential IO, which on a spinning disk was the difference between fast and unusable, and on SSD still matters for read-ahead (chapter 72).

Each index gets two segments — one for leaf pages, one for internal nodes. Keeping them apart means a tree traversal does not have to jump between leaves and internal nodes scattered together, and it lets read-ahead work on the leaf chain specifically.

why your 3-row table is 112KB
CREATE TABLE immediately allocates several pages for the tablespace header, segment inodes and the index root, before any row exists. A table's file size is never a good proxy for its data size until it is large.
20

Row formats, compared at byte level

A row on a page has a header before it. What is in that header depends on the ROW_FORMAT, and the differences are not cosmetic.

DYNAMIC (the default since 5.7)
var-len1–2B each
null bits⌈n/8⌉B
rec header5B
DB_TRX_ID6B
DB_ROLL_PTR7B
column datayour values
20B ptrif off-page

Those two hidden columns — DB_TRX_ID and DB_ROLL_PTR — are the entire basis of MVCC, and they cost 13 bytes on every row of every table. Chapter 43 uses them properly.

FormatEraLong column handlingVerdict
REDUNDANTPre-5.0Stores 768-byte prefix on page + overflowWasteful record header. Never use.
COMPACT5.0Same 768-byte prefix + overflow~20% smaller headers than REDUNDANT. Legacy.
DYNAMIC5.7 default20-byte pointer only, entire value off-pageUse this. Long columns stop consuming page space, so more rows per page.
COMPRESSED5.5Like DYNAMIC, plus zlib on the pageTrades CPU and buffer-pool complexity for disk. Rarely worth it now.
why DYNAMIC matters more than it sounds
Under COMPACT, a row with three TEXT columns keeps 768 bytes of each on the page — 2.3KB of page space consumed by prefixes, before any real data. At 16KB per page that is six rows per page instead of dozens. DYNAMIC stores a 20-byte pointer instead. On a table with large text columns this is a multiple-times difference in how many rows fit per page, and therefore in how much IO a scan costs.
run it
-- Any table still on an old format?
SELECT name, row_format, space_type
FROM information_schema.innodb_tables
WHERE row_format NOT IN ('Dynamic', 'Compressed')
  AND name NOT LIKE 'mysql/%'
  AND name NOT LIKE 'sys/%';

-- Convert one (rebuilds the table — see ch 100 before doing this in prod):
-- ALTER TABLE t ROW_FORMAT=DYNAMIC, ALGORITHM=INPLACE, LOCK=NONE;

-- Prove the 13-byte MVCC overhead exists on every row:
-- a table of one TINYINT still costs far more than 1 byte per row.
SELECT table_name, table_rows, data_length,
       ROUND(data_length / NULLIF(table_rows,0)) AS bytes_per_row
FROM information_schema.tables
WHERE table_schema = DATABASE();
21

Off-page storage, and the 767-byte ghost

A row must fit in about half a page — roughly 8126 bytes — because InnoDB requires at least two rows per page to keep the tree balanced. When a row exceeds that, InnoDB moves its longest variable-length columns off-page, into overflow pages, leaving a 20-byte pointer behind.

The pointer is: 4 bytes space id, 4 bytes first overflow page number, 4 bytes offset, 8 bytes total length. Overflow pages chain together for values larger than one page.

The 767-byte number you have definitely hit

Under COMPACT and REDUNDANT, an index could only cover the first 767 bytes of a column, because that was the on-page prefix limit. With utf8mb4 at 4 bytes per character, that is 191 characters — which is exactly why so much legacy code declares VARCHAR(191), and why older Rails and WordPress schemas are full of that number. It is a fossil of a page-layout constraint.

DYNAMIC raises the limit to 3072 bytes (768 characters at utf8mb4), so the constraint is largely gone — but only if the table is actually DYNAMIC and innodb_large_prefix semantics apply, which is another reason to check chapter 20's query on old schemas.

the hidden cost of a BLOB
Reading a row whose TEXT lives off-page costs an additional page read per overflow chain. A SELECT * on a table with large text columns therefore does far more IO than the row count suggests. This is the concrete, mechanical reason SELECT * is a real performance problem and not merely a style preference — and it is why a covering index that avoids touching the row at all (chapter 35) can be dramatically faster.
22

Choosing innodb_page_size

You can set 4K, 8K, 16K (default), 32K or 64K — but only when initializing the data directory. It cannot be changed afterwards without a dump and reload, so it is effectively a permanent decision.

SizeArgument forArgument against
4K / 8KRandom-read-heavy OLTP on SSD, where you read one small row at a time. Less IO amplification per lookup, and less buffer pool wasted on rows you did not want.Tree gets taller (fewer keys per node), so more page reads per descent. Max row size shrinks, so off-page overflow happens sooner.
16KThe default, and correct for almost everyone. Well-tested path.—
32K / 64KScan-heavy and analytical workloads; large rows that would otherwise overflow.More IO amplification for point lookups. 64K does not support COMPRESSED. Redo log records get bigger.
the honest advice
Leave it at 16K. This is a knob that people turn to feel like they are tuning, and the wins are small next to fixing an index. Change it only with a benchmark on your actual workload that shows a real difference — and remember you cannot undo it without a full reload.
23

Reading a real .ibd file

Everything above is theory until you look at the bytes. This is the exercise that converts this part from reading into knowing.

Method 1 — innodb_ruby (the good tool)

run it
# Jeremy Cole's library. The single best way to see InnoDB structures.
gem install innodb_ruby

# What is every page in this file?
innodb_space -s ibdata1 -T yourdb/users space-page-type-summary

# Walk the index tree, level by level.
innodb_space -s ibdata1 -T yourdb/users -I PRIMARY index-recurse

# Dump one page completely: header, records, directory, trailer.
innodb_space -s ibdata1 -T yourdb/users -p 4 page-dump

# Fill factor across the whole index — the fragmentation view.
innodb_space -s ibdata1 -T yourdb/users -I PRIMARY index-fill-factor

Method 2 — hexdump, no tools required

run it
# Flush first, or you are reading a stale file.
mysql -e "FLUSH TABLES yourdb.users FOR EXPORT;"

# The first 38 bytes of page 0 — the FIL header.
sudo hexdump -C -n 38 /var/lib/mysql/yourdb/users.ibd

#   bytes 0-3   checksum
#   bytes 4-7   page number        (00 00 00 00 = page 0)
#   bytes 8-11  previous page       (ff ff ff ff = none)
#   bytes 12-15 next page           (ff ff ff ff = none)
#   bytes 16-23 LSN of last change
#   bytes 24-25 page type           (0x45bf = INDEX, 0x0008 = FSP_HDR)

# Page 3 is usually the clustered index root. Skip 3 × 16384 bytes.
sudo hexdump -C -s 49152 -n 64 /var/lib/mysql/yourdb/users.ibd

# Find your actual data — grep the raw file for a value you inserted.
sudo strings /var/lib/mysql/yourdb/users.ibd | head -40

mysql -e "UNLOCK TABLES;"
do this once, remember it forever
Insert five rows, hexdump the page, find your strings, then insert a thousand more and watch a second page appear with the first one's next pointer now filled in. Ten minutes of this teaches more than re-reading the layout table. It also makes Part 3 — where those pages split — concrete instead of abstract.

You now know what a page is. Part 3 is about the structure those pages form: the B+tree, how it splits, and why your choice of primary key is the most consequential schema decision you make.