DBRaven
Failure Mode · storage

Secondary Index Write Saturation

partial

Summary

When a table carries many secondary indexes, each INSERT or UPDATE must maintain all indexes, amplifying the write I/O by the number of indexes. At high write throughput (10,000+ inserts/second), index maintenance saturates WAL throughput, storage I/O bandwidth, or shared_buffers write capacity, causing write latency to spike from sub-millisecond to tens of milliseconds. The amplification grows linearly with index count and write rate, making this failure predictable but frequently discovered only after a traffic spike.

Description

Every secondary B-tree index on a PostgreSQL or MySQL table requires a separate index page write for every INSERT, and potentially for every UPDATE that modifies an indexed column. A table with 10 indexes amplifies write I/O by approximately 10x relative to a heap-only write. For a workload producing 10,000 inserts/second, maintaining 10 indexes generates 100,000 index writes/second. Each index write is a random write to the index B-tree (as opposed to sequential heap appends), making it particularly expensive on rotational storage and moderately expensive on SSDs.

WAL is the primary bottleneck in PostgreSQL. Every heap write and every index write generates WAL records. A table with 10 indexes generates 11 WAL records per INSERT (1 heap + 10 index). At 10,000 inserts/second with 200 bytes per WAL record, total WAL write throughput is 22 MB/s: manageable on most storage. But complex indexes on wide columns (JSONB, TEXT arrays, full-text GIN indexes) generate much larger WAL records. A GIN index update for a JSONB document may generate 2–10 KB of WAL per document, driving total WAL throughput to 200+ MB/s on insert-heavy workloads, saturating even NVMe storage at sustained rates.

The saturation manifests progressively. At moderate write rates, index maintenance adds 5–15% latency overhead. As write throughput approaches the I/O ceiling, WAL writes begin queueing. The WAL fsync becomes a bottleneck: PostgreSQL must fsync WAL to durable storage before acknowledging commits. When fsync queues accumulate, commit latency increases from <1ms to 10–50ms. Client applications see their INSERT response time increase linearly with write throughput, then spike discontinuously when WAL I/O saturates.

Index bloat compounds the problem over time (see index_fragmentation). Fragmented indexes have more pages to update, increasing the I/O cost per write. A table that was within I/O budget on initial deployment can exceed it 12 months later due to the combined effect of write rate growth and index fragmentation.

The maintenance cost above is not uniform across engines, because it depends on how the primary key relates to physical storage. PostgreSQL's default heap table stores rows in an unordered heap; even the index backing the primary key is, physically, just another B-tree index, one that points at heap tuples via a TID (tuple identifier: block number and offset). Because the primary key index carries no privileged storage role, its per-write maintenance cost is roughly the same as any other secondary index on the table, which is why counting total indexes is a reasonable proxy for write amplification in PostgreSQL. InnoDB is a different machine. Its primary key is a clustered index: the table's row data is physically stored in primary-key order inside the index's own leaf pages, so there is no separate "primary key index" write in PostgreSQL's sense, the row write and its primary-key-ordered placement are the same operation. But every other, secondary, index on an InnoDB table stores the indexed column values plus the primary key value rather than a physical row pointer, and a query that needs a column outside the secondary index must take that stored primary key value and perform a second lookup into the clustered index to fetch the full row. This is the mechanism InnoDB engineers call secondary index indirection, and it is a cost that does not exist in PostgreSQL's model in the same form, PostgreSQL's heap TID is already a direct physical pointer, not a second index traversal. The indirection cost is avoidable per query with a covering index, one that includes every column the query needs so the engine never touches the clustered index, but a query that reads any column outside the secondary index pays the extra lookup on every matched row, not just on the write path. Cassandra's native secondary indexes are a narrower tool again: they are local to each node's own data rather than global, so a query filtering only on a secondary-index column that is not part of the partition key must fan out to every node in the cluster instead of being served by a single partition lookup, a well-known operational pitfall rather than a drop-in substitute for a global index.

Characteristics

Propagationfan out
Time to detect5–15 minutes via database write latency monitoring (alert on INSERT p99 > 10ms sustained for > 60 seconds). WAL write throughput monitoring (pg_stat_bgwriter.buffers_backend) provides earlier warning at 2–5 minutes. The diagnostic signature: write latency increase proportional to write rate, with disk I/O at or near the storage IOPS/bandwidth limit.
Blast radiusWrite latency increase affects all services writing to the table, regardless of which indexes they use in reads. If the table is a core entity (orders, events, messages), write latency spikes propagate to all user-facing write paths. WAL saturation on PostgreSQL can also delay checkpoint processing, increasing recovery time and extending the blast radius to read performance (hot pages not flushed, checkpoint I/O competing with read I/O).

Triggers

  • ·Write throughput increases beyond the I/O capacity of the number of configured indexes (typically >5,000 inserts/s with >8 indexes)
  • ·Addition of new secondary indexes to a table that is already near its write I/O ceiling
  • ·GIN or GiST indexes on wide columns (JSONB, arrays, tsvector) at high write throughput
  • ·Index fragmentation increasing per-write I/O cost over time until saturation threshold is crossed
  • ·Bulk import job inserting millions of rows without disabling index maintenance during the import

Detection Signals

disk saturationlatency spikealert

Mitigation Strategies

Audit and drop unnecessary indexescomplexity: low

Query pg_stat_user_indexes to identify indexes with zero or near-zero scans over the past 30 days (idx_scan = 0). Drop indexes that are never used by any query. A single dropped index reduces write amplification by 1/N where N is the total index count. For a 10-index table, dropping 3 unused indexes reduces write I/O by 30%. Run EXPLAIN on all critical write queries to confirm the dropped indexes are not needed for any plan.

Partial indexes to reduce index size and write amplificationcomplexity: medium

Replace full-column indexes with partial indexes that only cover the subset of rows accessed by common queries: CREATE INDEX CONCURRENTLY idx_active_orders ON orders (user_id) WHERE status IN ('pending', 'processing'). Only the fraction of writes that insert rows matching the WHERE clause update the partial index. For a table where 95% of reads access 10% of rows (e.g., active records), partial indexes can reduce index write volume by 90%.

Disable indexes during bulk imports, rebuild afterpreventscomplexity: low

For bulk import jobs (>100,000 rows), disable or drop non-unique indexes before the import and rebuild them afterward using CREATE INDEX CONCURRENTLY. PostgreSQL's COPY command with indexes disabled performs at 50,000–200,000 rows/second; with indexes maintained inline, throughput drops to 5,000–20,000 rows/second for tables with many indexes. After import, rebuild indexes in parallel (one CONCURRENTLY per index) to restore query performance.

Recovery Steps

  1. 1.Query pg_stat_user_indexes for idx_scan counts; identify zero-use indexes as immediate drop candidates
  2. 2.Check current WAL write throughput via pg_stat_bgwriter and storage I/O metrics to confirm saturation
  3. 3.Drop or disable the least-used index first; monitor write latency to quantify improvement per dropped index
  4. 4.If in the middle of a bulk import, cancel it, drop indexes, re-run the import, rebuild indexes afterward
  5. 5.Provision additional storage IOPS if index reduction is insufficient for the required write throughput
  6. 6.Schedule quarterly index usage audits to prevent index accumulation over time

Estimated recovery time: 5–15 minutes for write latency to normalize after dropping indexes (index drop is fast; the benefit is immediate). Bulk import restart with indexes disabled adds 30–120 minutes for large datasets but completes without I/O saturation.

Affected Systems

Patterns

write ahead log cdcevent sourcingsharding

Technologies

postgresqlmysqlmongodbelasticsearchcassandra

Basis

Write amplification from secondary indexes is analytically deterministic (number of indexes directly multiplies write I/O); WAL throughput limits are empirically well-characterized in PostgreSQL documentation and benchmarks; GIN index WAL amplification is a documented production concern. The InnoDB clustered-index and secondary-index-indirection mechanism is standard documented InnoDB behavior; the Cassandra local-secondary-index scatter-gather caveat is a well-known documented operational pitfall, stated narrowly rather than as a full characterization of Cassandra indexing.

Sources & Claims

PostgreSQL's default heap table storage means the primary key index is physically just another B-tree index that points at heap tuples via a TID (tuple identifier: block and offset), so its per-write maintenance cost is structurally the same as any other secondary index on the table

pending

official documentation · PostgreSQL documentation, Indexes and Database Physical Storage (heap tuple TIDs)

storage-engine-internals-spine batch 3

InnoDB stores table data clustered by the primary key, and every secondary index stores the indexed columns plus the primary key value rather than a physical row pointer; a query needing a column outside the secondary index performs a second lookup into the clustered index to fetch the full row, a mechanism referred to as secondary index indirection

pending

official documentation · MySQL documentation, InnoDB Index Types (Clustered and Secondary Indexes)

storage-engine-internals-spine batch 3

Cassandra's built-in secondary indexes are local to each node's own data rather than global, so a query filtering only on a secondary-index column not included in the partition key must fan out to every node in the cluster instead of being served by a single partition lookup

pending

official documentation · Apache Cassandra documentation, Secondary Indexes

storage-engine-internals-spine batch 3

Secondary Index Write Saturation: DBRaven