Overview
Three candidate key schemas for the two largest measurement tables (friction_cycles, 1.44M rows and friction_loop_points, 2.88M rows) were built from the same simulated corpus and measured for disk footprint, load time and six read patterns. In every variant the relations between tables are single-column integer foreign keys (track_id, coupon_id); the variants differ only in how a bulk row is identified inside its own table.
The three variants
- Variant A - composite natural PK. Row identity of a bulk row is its natural composite key (track_id, cycle), stored as a clustered WITHOUT ROWID table. FK columns are plain integers, validated by the loader against the corpus manifest (no engine constraint).
- Variant B - composite natural PK + enforced FK. Same physical layout as A, plus REFERENCES constraints with PRAGMA foreign_keys=ON, so the engine itself guarantees referential integrity.
- Variant C - surrogate key on every table. Classic style: each table gets a single-column auto-increment key named <singular>_id (friction_cycle_id, never a bare id). Correctness then requires an additional UNIQUE(track_id, cycle) index - a second B-tree.
Sample Dataset
All measurements ran against the real generated corpus of the evaluation pipeline - deterministic, physically plausible tribology data (seed 20260711). The two bulk tables and their parent dimension were loaded in full.
Corpus context
The corpus models a Pt-Au coating study: 4 deposition batches → 12 wafers → 588 coupons (480 friction-tested) → 1,440 tracks → 1.44M cycle records. Generated size: 141.8 MiB of CSV; the production target for extrapolation is 600 GB - 6 TB, which is why per-row bytes on the bulk tables matter.
Variant A · Composite Natural Primary Key baseline
The bulk row is identified by what it physically is: cycle n of track t. The composite PK doubles as the clustering order, so the table itself is the only B-tree. FK columns are unenforced integers; integrity is guaranteed by row-count and checksum validation against the corpus manifest.
ref = integer reference to tracks(track_id), validated by the loader, no engine constraint.
CREATE TABLE friction_cycles ( track_id INTEGER NOT NULL, -- reference to tracks(track_id), loader-validated cycle INTEGER NOT NULL, cof REAL NOT NULL, PRIMARY KEY (track_id, cycle) ) WITHOUT ROWID; -- clustered: the PK is the table's only B-tree
Variant B · Composite PK + Enforced Foreign Keys recommended
Physically identical to Variant A - same clustered layout, same single B-tree per bulk table. The only change: FK columns carry real REFERENCES constraints and every connection runs PRAGMA foreign_keys=ON, so the DBMS itself rejects an orphan row. Measured cost: +0.5 s of load time on 4.32M rows; zero cost on disk and reads.
FK checks are lookups into the already-indexed parent PK, which is why the measured overhead is only ~6% of load time.
PRAGMA foreign_keys = ON; CREATE TABLE friction_cycles ( track_id INTEGER NOT NULL REFERENCES tracks(track_id), cycle INTEGER NOT NULL, cof REAL NOT NULL, PRIMARY KEY (track_id, cycle) ) WITHOUT ROWID;
Variant C · Surrogate <singular>_id on Every Table +74% disk
The uniform classic design: every table, including the bulk fact tables, gets a single-column auto-increment key (friction_cycle_id, friction_loop_point_id - by convention always <table name in singular>_id, never a bare id). In SQLite the id column itself is nearly free (it aliases the rowid), but correctness still demands UNIQUE(track_id, cycle) - and that unique index is a second B-tree holding a copy of almost every column.
The UNIQUE index is mandatory for correctness (it prevents duplicate cycles), so its cost cannot be avoided in this variant.
CREATE TABLE friction_cycles ( friction_cycle_id INTEGER PRIMARY KEY, -- rowid alias; unused by any FK track_id INTEGER NOT NULL REFERENCES tracks(track_id), cycle INTEGER NOT NULL, cof REAL NOT NULL, UNIQUE (track_id, cycle) -- required; duplicates the table );
Benchmark Method
Identical protocol for all three variants; only the DDL differs.
- Data: the full generated corpus subset - tracks (1,440), friction_cycles (1,440,000), friction_loop_points (2,880,000) - read from the same CSV files in the same order for every variant.
- Load: SQLite, PRAGMA journal_mode=OFF, synchronous=OFF, cache_size=-262144; inserts via executemany in 50k-row batches; VACUUM before measuring the file size.
- Reads: warm OS page cache, best of 3 runs per pattern; fixed deterministic probe sets (39 tracks for range reads, 150 (track_id, cycle) pairs for point lookups); result-set sizes asserted, so every variant answers the identical question.
- Caveat: at 150 MB everything fits in RAM, which flatters variant C - at the 600 GB production target the working set exceeds RAM, and twice the pages means twice the cache pressure and disk reads. Warm-cache deltas are therefore a lower bound.
Results · Storage & Load
File size after VACUUM; load time includes CSV parsing (identical work in all variants).
Database size after VACUUM (MiB, scale 0-200)
Load time, 4.32M rows (s, scale 0-10)
Results · Read Performance
Warm OS cache, best of 3 runs. Q1 / Q5 refer to the benchmark scenarios of the evaluation pipeline.
Relative read time, variant A = 100% (lower is better)
Why It Differs
The whole difference reduces to one thing: how many B-trees per bulk table, and in what order the rows physically live.
- +74% disk. In SQLite the surrogate id column is a rowid alias and costs nothing in the table itself - but the mandatory UNIQUE(track_id, cycle) index is a full second B-tree whose entries contain almost every column of the row. Two trees instead of one on a 3-column table is close to a doubling.
- +59% on grouped aggregation (the pipeline's forced-full-scan Q5). In A/B the table is clustered by track_id, so GROUP BY track_id streams the tree in group order. In C the engine walks the UNIQUE index for ordering and hops into the table B-tree for every cof value - the classic double access path.
- +5-13% on ranges and plain scans. More pages (190 vs 109 MiB) means more page traversals and cell decoding, even fully cached.
- ~0% on point lookups. Tree depth is the same either way; a single-row probe does not expose the second tree.
- PostgreSQL outlook. PG has no WITHOUT ROWID: the composite-PK table is a heap plus one PK index either way, but variant C still adds the id column to every heap tuple and the second index - expect +30-50% on the bulk tables, plus higher COPY-time FK validation cost.
Recommendation
Variant B Composite natural PK on bulk tables + enforced foreign keys
- Referential integrity is guaranteed by the DBMS itself at a measured cost of +0.5 s of load time and nothing else.
- Disk footprint and read profile are identical to the leanest variant A: 109.1 MiB, single clustered B-tree per bulk table.
- Dimension tables (batches, wafers, coupons, runs, tracks, instruments) keep their classic single-column surrogate keys - <singular>_id, never a bare id - because other tables reference their rows.
- Variant C remains a valid design where fact rows must be individually addressable from outside (annotations, reprocessing links). No table in this schema references an individual cycle or loop point, so its cost (+74% disk, up to +59% on aggregation, growing at 600 GB scale) buys nothing here.