Write Amplification Cascade
criticalSummary
Each logical application write triggers multiple physical writes through index maintenance, WAL generation, MVCC versioning, and replication, causing actual disk IOPS to exceed the provisioned I/O ceiling while the logical write rate appears modest.
Description
A single SQL UPDATE on a PostgreSQL table generates far more physical I/O than it appears. Consider UPDATE orders SET status = 'shipped' WHERE id = 12345 on a table with 5 secondary indexes. The physical write path is: (1) write the new heap tuple version to the data page; (2) write a dead tuple (MVCC keeps the old version for active snapshots); (3) write 5 index page updates: one per index, each index entry must point to the new tuple's location; (4) write the WAL record covering all of the above; (5) if synchronous_commit = on, wait for the WAL to fsync; (6) replicate the WAL to each standby. For a 100-byte row update, the physical write footprint may be 10–50KB across all these pages.
Write amplification factor (WAF) = physical bytes written / logical bytes written. For a simple row update with 5 indexes, WAF may be 20–100x. At 5,000 updates/ second, this translates to 100,000–500,000 IOPS on a system that may only have 3,000–16,000 provisioned IOPS (GP2 EBS: 3,000 IOPS; GP3: up to 16,000 IOPS; io1/io2: up to 64,000 IOPS).
The cascade happens when write amplification causes the storage I/O to saturate. Once I/O saturates, all disk operations queue. WAL fsync latency increases from <1ms to >100ms, which means all write transactions stall waiting for WAL flush. Replication falls behind because WAL cannot be sent faster than it can be flushed. Checkpoints take longer because dirty page flushes queue behind other writes. Autovacuum falls behind because it too is waiting for I/O, allowing dead tuple accumulation (index bloat). The system enters a feedback loop of degradation.
Write amplification is structural and cannot be mitigated by adding RAM alone. Adding shared_buffers reduces read I/O but not write I/O: dirty pages must eventually be flushed to disk at the WAL/checkpoint rate.
"Write amplification" is not one thing, and collapsing it into a single bucket hides where the cost actually comes from and which mitigation applies. Four distinct kinds compound in the scenario above, and they do not all move together:
Application-level amplification. Business logic issues more physical writes than the triggering event logically requires: an audit-log insert alongside the row update, a denormalized copy updated in a second table, a cache-invalidation write. This is a design choice, not an engine property, and the fix is architectural (batch it, make it async, or remove the redundancy), not a database knob.
Secondary-index amplification. Every index on a table is a second (or third, or fourth) physical structure that must be kept consistent with the base table on every write that touches an indexed column. This is the "5 index page updates" in the example above: it is linear in index count and is the same structural cost whether the base table is a PostgreSQL heap or an InnoDB clustered table, though the exact page-write mechanics differ by engine.
WAL and log amplification. The engine's durability log records more bytes than the logical change, so that a crash can replay it. PostgreSQL's full_page_writes is the concrete mechanism above: the first touch of a page after a checkpoint logs the whole 8KB page, not the changed bytes. MySQL binlog amplification is a related but distinct mechanism: with binlog_format = ROW (the modern default), each row change is logged in full in the binary log independent of InnoDB's own redo log, so a single UPDATE can generate both an InnoDB redo log record and a full-row binlog record, two separate log amplification paths rather than one.
Storage-engine compaction amplification, and this is the one that must not be collapsed into the others. Everything above assumes a B-Tree engine with in-place updates: PostgreSQL and InnoDB write a new or updated page roughly once per touch, so total write amplification (physical bytes written / logical bytes written), once WAL and index overhead are included, typically lands around 2-4x for a well-tuned B-Tree engine, even though the PostgreSQL example above shows a higher figure once five indexes and MVCC are counted per row. An LSM (log-structured merge-tree) engine, such as RocksDB, Cassandra's or ScyllaDB's SSTable storage, works on a fundamentally different model: writes land in an in-memory memtable and flush to an immutable SSTable on disk, and the same logical row is rewritten again and again as compaction merges SSTables across levels to bound read cost and reclaim space from overwrites and deletes. Every compaction pass that touches a key's SSTable rewrites that key's bytes to a new file, so a single row can be physically rewritten many times over its lifetime purely from compaction, independent of how many times the application logically wrote it. This is why a poorly-tuned LSM engine can see write amplification in the 10-30x range: it is not the same phenomenon as B-Tree index or WAL overhead, it is a cost paid to keep read amplification (SSTable count per read) and space amplification bounded, and it trades in the opposite direction from a B-Tree engine, which pays most of its overhead per logical write rather than as an ongoing background rewrite of data already on disk. See lsm_compaction_debt for the failure mode this specific mechanism produces when compaction falls behind the write rate.
InnoDB's own log write amplification differs from PostgreSQL's WAL in a way worth naming specifically, not just by analogy. InnoDB maintains two separate logs where PostgreSQL has one: the redo log (physical, for crash recovery, analogous in role to PostgreSQL WAL) and the undo log (logical, holding the previous version of a row for MVCC readers and for rollback, stored in the InnoDB system tablespace or dedicated undo tablespaces rather than as WAL records). A single UPDATE therefore generates a redo log record for the physical page change and an undo log record for the pre-image, and unlike a PostgreSQL dead tuple (which lives in the same heap page and is reclaimed by VACUUM), InnoDB's undo records are reclaimed by the purge thread, a background process distinct from anything PostgreSQL runs. Long-running transactions hold back purge the same way they hold back PostgreSQL's vacuum horizon, but the accumulating structure (undo log/history list length) and the operational signal (history list length growing in INFORMATION_SCHEMA.INNODB_TRX-adjacent monitoring) are InnoDB-specific, not a renamed version of PostgreSQL bloat.
Characteristics
Triggers
- ·High UPDATE rate on tables with 4+ secondary indexes
- ·Bulk import or migration generating WAL at storage bandwidth ceiling
- ·Workload shift from INSERT-heavy to UPDATE-heavy without index audit
- ·Autovacuum running full-table vacuum on large tables during peak write load
- ·Full-page writes enabled (default) causing entire 8KB page to be written to WAL on first modification after checkpoint
Detection Signals
Mitigation Strategies
Each secondary index maintained during UPDATE adds one index page write per UPDATE row. Identify indexes with zero or near-zero scans via pg_stat_user_indexes.idx_scan. Drop indexes unused in the last 30 days. Reducing from 8 to 4 indexes can halve write amplification.
Set checkpoint_completion_target = 0.9 and checkpoint_timeout = 15min (default 5min). checkpoint_completion_target defaults to 0.5 on PostgreSQL 13 and earlier but already defaults to 0.9 from PostgreSQL 14 onward, so confirm the running version and current value before assuming this is a needed change. Spreading checkpoint dirty page flushing over a longer period reduces instantaneous I/O peaks. Does not reduce total I/O; trades peaks for a more even sustained rate. See checkpoint_amplification for the full mechanism.
Move from GP3 (3,000–16,000 IOPS) to io2 (up to 64,000 IOPS) or local NVMe. Increases the I/O ceiling, buying headroom. Does not fix the write amplification factor; defers the problem to higher load levels.
PostgreSQL table partitioning by time range or hash distributes write I/O across partition-level pages, potentially benefiting from OS-level I/O parallelism. Each partition has its own set of index pages; hot-partition writes are distributed across the newer partition's pages.
For high-volume append-only workloads (event logs, analytics), route to ClickHouse or Cassandra, which use LSM trees with sequential I/O and significantly lower write amplification for append patterns (WAF ~ 3–10x vs. PostgreSQL's 20–100x on update-heavy workloads).
Recovery Steps
- 1.Identify I/O saturation: iostat -x 1 showing util > 90% on database disk device
- 2.Reduce write load: throttle batch jobs, bulk imports, or autovacuum (autovacuum_vacuum_cost_delay)
- 3.Check pg_stat_user_indexes for unused indexes on high-write tables and DROP them
- 4.Verify checkpoint settings: checkpoint_completion_target and checkpoint_timeout are not default
- 5.If storage tier is GP2 EBS, upgrade to GP3 with explicit IOPS provisioning as a short-term fix
- 6.Plan long-term: index audit, write path review, and potential table partitioning
Estimated recovery time: Minutes to hours depending on intervention. Dropping unused indexes takes effect immediately. Storage tier upgrades require a brief maintenance window. Structural write amplification reduction (index cleanup, partitioning) takes days to implement and validate.
Affected Systems
Patterns
Technologies
Basis
Well-understood PostgreSQL internals with measurable write amplification formulas; I/O saturation pattern is reproducible and clearly observable. InnoDB redo/undo log separation and purge thread behaviour, and LSM compaction-driven rewrite amplification, are documented engine mechanisms; the 10-30x LSM and 2-4x B-Tree amplification ranges are commonly cited approximations, not precise per-engine guarantees, and vary with compaction strategy and tuning.
Sources & Claims
A well-tuned B-Tree engine (WAL/redo log plus full-page-writes or equivalent) typically has total write amplification in roughly the 2-4x range, versus roughly 10-30x for a poorly-tuned LSM engine under compaction
pendingddia or accepted reference · Commonly cited storage-engine amplification ranges (e.g. RocksDB/LSM engineering literature); approximate, engine- and tuning-dependent
storage-engine-internals-spine batch 2; approximation, not a precise per-engine bound
MySQL binlog_format = ROW (the modern default) logs full row images in the binary log independent of InnoDB's own redo log, so a single UPDATE can generate both an InnoDB redo log record and a full-row binlog record
pendingofficial documentation · MySQL 8.0 Reference Manual: binlog_format, Row-Based Logging
storage-engine-internals-spine batch 2
InnoDB maintains separate redo and undo logs: redo is physical and used for crash recovery, undo is logical, holds pre-images for MVCC and rollback, and is reclaimed by a background purge thread distinct from PostgreSQL's VACUUM
pendingofficial documentation · MySQL 8.0 Reference Manual: InnoDB Undo Logs, InnoDB Redo Log
storage-engine-internals-spine batch 2
Run This Failure
Blast radius analysis for this failure mode within each scenario that carries it.
Used In Architecture Scenarios
Financial Ledger
An append-only audit log architecture for capturing system actions: user operations, data access events, configuration changes, and financial operations: with cryptographic integrity chaining, immutable storage, and dual-path query serving. PostgreSQL stores the authoritative append-only event log in time-partitioned tables; ClickHouse serves historical aggregate queries and retention analytics; Kafka streams audit events in real time to SIEM integrations and security dashboards; Redis caches hot audit query results for compliance-facing read paths. No record is ever updated or deleted: only inserts are permitted. Each record includes a hash of the previous record, forming a tamper-evident chain verifiable without external tooling.
Financial Ledger
A strict consistency financial architecture for double-entry accounting, payment processing, and audit trail management where every debit must have a corresponding credit, every state transition must be ordered and durable, and the complete history must be reconstructable without gaps. Event sourcing provides an append-only audit log; the outbox pattern guarantees at-least-once downstream event delivery without distributed two-phase commit (idempotent consumers deduplicate on event ID for effectively-once processing); PostgreSQL provides ACID guarantees for ledger entry atomicity.
Realtime Collaboration
A real-time location tracking platform for moving entities: vehicles, delivery couriers, field workers, and assets: receiving position updates at 1–30 second intervals from thousands of simultaneously tracked objects. Redis geospatial indexes (GEOADD / GEOSEARCH) serve sub-10ms proximity queries against the live position surface; PostgreSQL stores durable entity metadata and historical location records; TimescaleDB stores high-frequency time-series location data with automatic hypertable chunking and retention policies; Kafka streams location events to downstream consumers (dispatch systems, customer tracking apps, analytics pipelines). Geofence evaluation runs at ingestion time: each incoming position update is tested against active geofences for the entity's region, and geofence entry/exit events are emitted as Kafka messages to downstream consumers.
Write-Heavy Application
A high-rate device telemetry ingestion architecture designed for millions of devices emitting metrics at 1–60 second intervals. Kafka absorbs device writes as an ingestion buffer, decoupling device-facing ingest endpoints from the storage write path so that downstream storage pressure never propagates back to devices. TimescaleDB provides time-series storage with automatic chunk partitioning by time range, native compression, and continuous aggregate views for rollup queries. ClickHouse serves as the OLAP layer for device fleet analytics queries. Redis caches last-known device state (current readings per device) for real-time alerting queries that must not scan historical storage. Backpressure on the Kafka consumer side prevents storage write throughput from being overwhelmed by burst ingestion events from device reconnect storms.
Analytics Pipeline
A metrics, logs, and traces ingestion and query platform built to absorb the telemetry output of a production system fleet: including the telemetry volume spikes that accompany the incidents the platform is meant to detect. ClickHouse stores metrics data with automatic time-based rollup via continuous materialized views; TimescaleDB provides complementary time-series storage for high-cardinality alert evaluation; Kafka buffers the ingestion stream against downstream write pressure, decoupling ingest acceptance rate from storage write throughput; Elasticsearch serves log full-text search and structured field filtering; Redis caches dashboard query results and active alert state for sub-100ms alert evaluation latency. The alert engine evaluates threshold and anomaly rules against pre-computed materialized views, not raw data, to bound alert evaluation cost independent of ingestion volume.
Write-Heavy Application
A high-volume transactional write architecture anchored on PostgreSQL, where write throughput, durability guarantees, and audit completeness must coexist. The outbox pattern ensures reliable event publishing to Kafka without two-phase commit, and WAL-based CDC provides a durable change log that can reconstruct system state. Connection pooling via PgBouncer bounds connection overhead at the database layer.