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.
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.
The engine
B-tree · LSM-tree · how LSM stays fast.
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.
OLTP · fetch full rows.
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.
On the wire
Same record. Same three fields. Three encodings.
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.
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.
Optional or default
New fields ship as optional or with a default value.
Tag identity is forever
Field tags (Protobuf) or names (Avro) never get reused. Retire them; don't recycle.
Readers tolerate the unknown
Unknown fields are preserved or ignored, never rejected.
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.
| 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 | 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 |
| Format | Bytes* | Schema | Evolution | Human-readable | Tags on wire | Notable users |
|---|---|---|---|---|---|---|
| JSON | 81 | none | tolerant readers | yes | field names | web APIs everywhere |
| XML | ~150 | XSD (optional) | tolerant readers | yes | tag names | legacy enterprise, SOAP |
| MessagePack | 66 | none | tolerant readers | no | field names | Fluentd, polyglot msgpack libraries |
| Thrift | 59 | .thrift IDL | tag numbers stable | no | field tags | Facebook, Cassandra RPC |
| Protobuf | 33 | .proto IDL | tag numbers stable | no | field tags | Google, gRPC, Kubernetes |
| Avro | 32 | .avsc | writer/reader schema resolution | no | none (schema ships) | Kafka, Hadoop, Confluent |
* Bytes for the sample record used in DDIA §4.1 Figures 4-1 through 4-6.
| Operation | JSON / XML | Protobuf / Thrift | Avro |
|---|---|---|---|
| Add optional field | safe | safe (with default) | safe (with default in reader) |
| Remove optional field | safe | safe (leave tag number reserved) | safe (with default in reader) |
| Add required field | breaks forward compat | breaks forward compat | writers must upgrade first |
| Change field type | dangerous | limited (varint-compatible only) | limited (per Avro resolution rules) |
| Rename field | breaks | tag stays; symbol can change | field aliases supported |
Hash index → point lookups only; whole index in RAM.
B-tree → in-place updates; balanced; random I/O.
LSM-tree → append-only; sequential writes; background compaction.
Row vs column → OLTP fetches one record; OLAP scans one field.
Protobuf / Thrift → tags on wire enable evolution.
Avro → schema ships with the file; no tags on wire.
Backward vs forward → both must hold during a rolling deploy.