Post 02 · DDIA Ch. 3–4

Storage & Evolution

The world's simplest database is two lines of bash. It fails immediately. Every real storage engine is a different bet on which pain you'd rather pay for — and once you've picked one, you still have to change the schema without a stop-the-world.

Bytes on disk, then bytes on the wire. How data sits, and how it survives two versions of your code running at once.

Append every write to the end of a file. That's it — that's the whole database. Writes are O(1) and stay in disk order, which is the fastest thing spinning rust can do. Reads are the problem: without an index, you scan the whole file. Every storage engine that follows is an attempt to add an index without giving up sequential writes.

The log

Bitcask in ~40 lines: append writes, index reads.

db (append-only) 42, "world" @ 0 88, "hello" @ 15 hash index query O(n) scan · newest first O(1) seek by hash

Writes append. Reads seek by hash. Works until the index outgrows RAM, or you need a range scan.

Hash index: keep every key's location in a RAM map. Fastest point lookup possible; the whole index must fit in memory. B-tree: a balanced on-disk tree, updated in place — random I/O on writes, but scans and transactions are natural. LSM-tree: buffer writes in memory, flush to sorted files on disk, compact in the background — sequential writes at the cost of write amplification. Every mainstream OLTP database is one of these three.

Takeaway

LSM-trees turn random writes into sequential ones. That's the whole trick.

OLTP fetches one whole record ("give me user 42's profile"). Row-oriented storage puts the whole record on one page — one seek, done. OLAP scans one column across millions of rows ("average order value last quarter"). Column-oriented storage puts each column's values contiguous on disk, which turns the scan into streaming reads and compresses beautifully because adjacent values are the same type. Wrong layout, ten to a hundred times slower.

Row vs Column

Same rows, two physical layouts. Same data, opposite workloads.

ROW STORE WHERE id = 2
1 Ali US 120
2 Bea EU 180
3 Cai US 95
4 Dev US 210

OLTP · fetch full rows.

COLUMN STORE AVG(salary) WHERE region='US'
id 1, 2, 3, 4
name Ali, Bea, Cai, Dev
region US, EU, US, US
salary 120, 180, 95, 210

OLAP · scan few columns of many rows.

Now you know how a byte gets to disk. Next question: what shape is that byte, and what happens when tomorrow's code deploys next to today's?

Same record, six encodings. JSON is 81 bytes and self-describing; Protobuf is 33 bytes but needs the schema to read. The size gap looks small until you multiply it by billions of messages per day and the CPU cost of parsing text. The trade is universal: text formats are readable and forgiving; binary formats are compact and fast but demand schema discipline.

A rolling deploy means old code and new code run at the same time, hitting the same database and the same message queue. Backward compatibility: new code reads old data. Forward compatibility: old code reads new data — and must ignore fields it doesn't know exist. Both directions have to hold for the full deploy window, or something writes data no one can read.

Two directions

Rolling deploy: old and new code trade rows at the same time.

v1
v1
v1
v1
v1
v1
t0all v1
t1rolling
t2all v2
database · shared record
id: 42 name: "Bill" favNum: 1337
forward old code reads new field · ignores it
backward new code reads old row · fills default

both directions must hold, at the same moment

Takeaway

Old code writing new data. New code reading old data. Both must hold, at the same moment.

Adding an optional field with a default is always safe. Renaming a field breaks JSON but not Protobuf (the tag number stays). Removing a required field breaks everyone. The specifics vary by format; the discipline is the same: change one thing at a time, deploy readers before writers, keep old tag numbers reserved.

Add a field safely.

01

Optional or default

New fields ship as optional or with a default value.

OLD CODE writes without the field · reads still work.
NEW CODE reads old rows · fills in the default.
BREAKS IF you ship a required field with no default.
02

Tag identity is forever

Field tags (Protobuf) or names (Avro) never get reused. Retire them; don't recycle.

SAFE mark the tag reserved · pick the next free one.
UNSAFE reuse tag N for a new field of a different type.
WHY the wire is stable · old data still uses the old meaning.
03

Readers tolerate the unknown

Unknown fields are preserved or ignored, never rejected.

NEW WRITER adds field 47.
OLD READER skips field 47 quietly · round-trips it if writing back.
BREAKS IF the reader throws on any field it doesn't recognise.

Follow all three and the fleet can be any mix of versions at any moment.

── Reference ──

Three engines · two layouts · six formats · one evolution matrix.

Storage engines, side by side
Engine Best for Read cost Write cost Space Examples
Hash index Point lookups, small keyspace O(1) if index in RAM; disk seek on miss O(1) append + index update Whole index must fit in RAM Bitcask, Riak's Bitcask backend
B-tree Balanced OLTP O(log n) disk seeks O(log n) + WAL fsync Fragmentation, ~70% fill factor PostgreSQL, MySQL/InnoDB, SQL Server
LSM-tree Write-heavy; range scans O(log n) across levels; bloom filters help O(1) memtable append; batched flush Write amplification 5–30× before compaction RocksDB, LevelDB, Cassandra, HBase
Row-oriented vs column-oriented
Row-oriented Column-oriented
Layout Whole row contiguous on disk One column across many rows contiguous
Best for OLTP: fetch a whole record OLAP: aggregate over one column
Compression Low (heterogeneous types adjacent) High (values in a column repeat)
Write pattern Update one row, touch one page Update one row, touch many files (usually rewritten in bulk)
Examples PostgreSQL, MySQL, MongoDB Parquet, ORC, Vertica, Redshift, BigQuery
Encoding formats, side by side
Format Bytes* Schema Evolution Human-readable Tags on wire Notable users
JSON81nonetolerant readersyesfield namesweb APIs everywhere
XML~150XSD (optional)tolerant readersyestag nameslegacy enterprise, SOAP
MessagePack66nonetolerant readersnofield namesFluentd, polyglot msgpack libraries
Thrift59.thrift IDLtag numbers stablenofield tagsFacebook, Cassandra RPC
Protobuf33.proto IDLtag numbers stablenofield tagsGoogle, gRPC, Kubernetes
Avro32.avscwriter/reader schema resolutionnonone (schema ships)Kafka, Hadoop, Confluent

* Bytes for the sample record used in DDIA §4.1 Figures 4-1 through 4-6.

Safe schema evolution operations by format
Operation JSON / XML Protobuf / Thrift Avro
Add optional fieldsafesafe (with default)safe (with default in reader)
Remove optional fieldsafesafe (leave tag number reserved)safe (with default in reader)
Add required fieldbreaks forward compatbreaks forward compatwriters must upgrade first
Change field typedangerouslimited (varint-compatible only)limited (per Avro resolution rules)
Rename fieldbreakstag stays; symbol can changefield aliases supported

Hash indexpoint lookups only; whole index in RAM.

B-treein-place updates; balanced; random I/O.

LSM-treeappend-only; sequential writes; background compaction.

Row vs columnOLTP fetches one record; OLAP scans one field.

Protobuf / Thrifttags on wire enable evolution.

Avroschema ships with the file; no tags on wire.

Backward vs forwardboth must hold during a rolling deploy.