#database#postgresql#mysql#redis#performance

Deep-Dive Database Architecture: Index Internals, Concurrency, and Caching Topologies

"When transactional systems scale beyond 50,000 queries per second (QPS), relational abstractions degrade. Subtle discrepancies in storage engine layouts, unind"

•By huud•
0 views
~8 min read
Deep-Dive Database Architecture: Index Internals, Concurrency, and Caching Topologies

When transactional systems scale beyond 50,000 queries per second (QPS), relational abstractions degrade. Subtle discrepancies in storage engine layouts, unindexed predicate scans, and transaction lock contention compound into severe tail-latency amplification. Navigating these performance cliffs requires mastery of the engine's physical layer: page layout mechanics, execution plan cost modeling, declarative table partitioning, multi-version concurrency control (MVCC) isolation anomalies, and resilient caching topologies.


1. Physical Storage and Index Mechanics

Clustered B+Trees vs. Slotted Page Heaps

MySQL (InnoDB) and PostgreSQL organize physical data under fundamentally opposing storage paradigms.

InnoDB couples data storage directly to index architecture via clustered indexes. The primary key forms a B+Tree whose leaf pages hold the physical row data. Secondary indexes store the corresponding primary key value rather than a physical address. Consequently, querying through a secondary index without covering all projected columns forces a secondary traversal—a secondary-to-primary lookup:

h≈⌈log⁡B(NL)⌉h \approx \left\lceil \log_{B}\left(\frac{N}{L}\right) \right\rceil

where BB is the node fanout factor, NN is the total tuple count, and LL represents leaf node capacity. For a standard 16KB InnoDB page with 64-bit integer keys, B≈1200B \approx 1200, maintaining index depth at h≤3h \le 3 even across billions of rows. However, when secondary index traversals miss the innodb_buffer_pool, each lookup causes a random I/O read against the primary key B+Tree.

rust
InnoDB Secondary Lookup:
[Secondary B+Tree] ---> Yields PK Value ---> [Clustered B+Tree] ---> Resolves Leaf Row

PostgreSQL Index Lookup:
[Secondary Index]  ---> Yields ctid (Page#, Slot#) ---> [Heap Page] ---> Resolves Tuple

PostgreSQL implements a slotted-page heap. Rows reside inside unstructured 8KB heap pages, addressed via a Tuple Identifier (ctid: page number and line pointer index). Indexes store these ctid pointers directly. While this eliminates the double-traversal penalty of clustered indexes, it imposes significant write amplification. Any column modification requires appending a new tuple version into the heap and updating all associated secondary indexes, unless the Heap-Only Tuple (HOT) optimization applies. HOT requires the update to avoid modifying indexed columns and mandates sufficient contiguous space on the original page to host the new tuple revision.

Multi-Dimensional Indexing via GiST

Standard B+Trees operate strictly on scalar data via total orderings ($<, =, >$). They degrade into full index scans when evaluating multi-dimensional geometric spaces, network ranges (inet), or full-text vectors.

PostgreSQL’s Generalized Search Tree (GiST) provides an extensible balanced-tree framework that structures non-scalar data using hierarchical bounding predicates:

sql
CREATE INDEX idx_locations_gist ON delivery_zones USING GIST (bounding_box);

GiST constructs an R-Tree topology using Minimum Bounding Boxes (MBR). Search operations evaluate predicate intersections, descending internal nodes only when an MBR overlaps the query target. If an index entry is flagged as lossy, the engine sets an internal recheck flag, deferring to the base heap page to verify exact geometric intersection during the final filtering stage.


2. Query Plan Telemetry: EXPLAIN (ANALYZE, BUFFERS)

Cost Estimation vs. Memory Hierarchy Reality

Database query planners model the execution cost of a plan via a synthetic scalar metric:

Cost=(Npages⋅seq_page_cost)+(Ntuples⋅cpu_tuple_cost)+(Nops⋅cpu_operator_cost)\text{Cost} = (N_{\text{pages}} \cdot \text{seq\_page\_cost}) + (N_{\text{tuples}} \cdot \text{cpu\_tuple\_cost}) + (N_{\text{ops}} \cdot \text{cpu\_operator\_cost})

Static EXPLAIN outputs merely reflect optimizer predictions based on pg_statistic. Diagnosing microsecond-level regressions under load requires executing queries with buffer accounting enabled while disabling timer syscalls to prevent timing distortion:

bash
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, TIMING OFF)
SELECT order_id, total_amount 
FROM orders 
WHERE customer_id = 1042 AND status = 'COMPLETED'
ORDER BY created_at DESC LIMIT 20;
ini
Limit (cost=0.56..12.84 rows=20 width=16) (actual rows=20 loops=1)
  Buffers: shared hit=18 read=4
  ->  Index Scan Backward using idx_orders_cust_stat_created on orders
        Index Cond: ((customer_id = 1042) AND (status = 'COMPLETED'::text))
        Buffers: shared hit=18 read=4

Telemetry metrics to monitor:

  • shared hit vs shared read: hit indicates page retrieval from PostgreSQL’s shared_buffers. read flags a request passed to the OS kernel, which may either hit the OS page cache or trigger NVMe/block-device I/O.
  • Rows Removed by Filter: Indicates an incomplete composite index. If an index scans customer_id but rejects thousands of rows matching status = 'COMPLETED', read amplification spikes.

The Visibility Map and Index-Only Scans

An Index-Only Scan avoids reading the heap altogether, sourcing all projected attributes directly from the index leaf pages. However, because PostgreSQL indexes do not contain transaction visibility information (xmin/xmax), the storage engine must consult the Visibility Map (VM).

The VM tracks whether all tuples within an 8KB heap page are visible to all current and future transactions (PD_ALL_VISIBLE). If the VM bit is zero, the engine must perform a heap fetch to verify MVCC visibility, negating the throughput advantage of an Index-Only Scan. Maintaining high VM coverage requires tuning autovacuum thresholds on write-heavy tables.


3. Declarative Table Partitioning at Scale

When single tables exceed hundreds of gigabytes, B+Tree working sets spill out of memory, while autovacuum maintenance incurs prohibitive write-amplification penalties. Declarative range partitioning isolates datasets into distinct physical relations:

sql
CREATE TABLE audit_logs (
    id BIGINT GENERATED ALWAYS AS IDENTITY,
    event_time TIMESTAMPTZ NOT NULL,
    payload JSONB
) PARTITION BY RANGE (event_time);

CREATE TABLE audit_logs_2024_m01 PARTITION OF audit_logs
    FOR VALUES FROM ('2024-01-01 00:00:00+00') TO ('2024-02-01 00:00:00+00');

With enable_partition_pruning = on, the query optimizer evaluates static and parameterized predicates to exclude irrelevant partition OIDs during planning and execution.

Partitioning converts expensive, WAL-saturating batch DELETE operations into instantaneous catalog-level DDL updates:

sql
ALTER TABLE audit_logs DETACH PARTITION audit_logs_2023_m12 CONCURRENTLY;
DROP TABLE audit_logs_2023_m12;

Using DETACH PARTITION ... CONCURRENTLY (PostgreSQL 14+) avoids acquiring an exclusive table lock (ACCESS EXCLUSIVE) on the parent table, preventing query starvation on concurrent write workloads.


4. MVCC Implementations and Concurrency Anomalies

Engines isolate concurrent transactions using distinct multi-version storage models. PostgreSQL retains multiple physical row revisions in the heap, requiring background vacuuming to reclaim dead tuples and prevent 32-bit transaction ID wraparound. MySQL InnoDB writes row modifications in-place, archiving previous revisions inside an undo log tablespace accessed via a roll pointer.

Isolation Level Dirty Read Non-Repeatable Read Phantom Read Write Skew
Read Committed Prevented Allowed Allowed Allowed
Repeatable Read Prevented Prevented Prevented (PG Snapshot / InnoDB Next-Key) Allowed
Serializable Prevented Prevented Prevented Prevented

Under Repeatable Read, PostgreSQL executes using Snapshot Isolation. Although phantom reads are structurally prevented, concurrent transactions can execute overlapping reads and write to disjoint sets of rows that violate global constraints—a condition known as write skew.

To implement deterministic, low-latency queues without serializable rollback aborts, bypass optimistic concurrency by applying row-level pessimistic locks:

sql
SELECT id, payload
FROM tasks
WHERE status = 'PENDING'
ORDER BY priority DESC, id ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;

FOR UPDATE SKIP LOCKED causes transactions to ignore rows currently held under exclusive row locks (X). Worker pools can drain shared queue tables across dozens of parallel connections without lock wait timeouts or deadlocks.


5. Distributed Cache Invalidation and Stampede Mitigation

Placing an in-memory cache ahead of read replicas protects the relational tier. However, classic cache-aside designs remain vulnerable to cache stampedes: when a hot key expires, thousands of concurrent requests miss simultaneously, inundating the database with identical compute-heavy queries.

Probabilistic Early Expiration (XFetch)

Rather than enforcing rigid TTL boundaries, calculate dynamic, probabilistic early expirations on read using the optimal XFetch algorithm:

−β⋅δ⋅ln⁡(U)>remaining_ttl-\beta \cdot \delta \cdot \ln(U) > \text{remaining\_ttl}

where δ\delta is the delta time (in seconds) required to compute the cached asset, β>0\beta > 0 regulates recomputation aggressiveness, and U∼Uniform(0,1)U \sim \text{Uniform}(0, 1). As the remaining TTL approaches zero, the probability of an asynchronous worker triggering a cache refresh approaches 1, shielding the database from concurrency spikes.

scss
Cache Lifetime (TTL)
|---------------------------------------[ Early Recalculation Window ]---> Expire (0s)
                                         P(Trigger) increases as TTL -> 0

Atomic Mutex Acquisition

When computing the underlying dataset is exceptionally resource-intensive, use a distributed lease with an authenticated Lua release script:

typescript
import Redis from "ioredis";
import { randomUUID } from "crypto";

const redis = new Redis();

export async function fetchWithStampedeGuard<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttlSeconds: number
): Promise<T> {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const lockKey = `lock:${key}`;
  const token = randomUUID();
  const acquired = await redis.set(lockKey, token, "NX", "EX", 5);

  if (!acquired) {
    await new Promise((resolve) => setTimeout(resolve, 60));
    return fetchWithStampedeGuard(key, fetcher, ttlSeconds);
  }

  try {
    const freshData = await fetcher();
    await redis.set(key, JSON.stringify(freshData), "EX", ttlSeconds);
    return freshData;
  } finally {
    const luaScript = `
      if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
      else
        return 0
      end`;
    await redis.eval(luaScript, 1, lockKey, token);
  }
}

Architectural Takeaways

Scaling transactional backends demands aligning access patterns with physical storage constraints:

  1. Design Indexes Around Storage Engines: Account for InnoDB secondary-index pointer dereferencing and PostgreSQL HOT update constraints. Target Index-Only Scans by keeping Visibility Maps vacuumed.
  2. Audit Plans via Real Buffer Usage: Cost units are theoretical estimations; optimize queries by driving down shared read operations and eliminating non-zero Rows Removed by Filter values.
  3. Partition Lifecycle Datasets: Replace unbounded DELETE operations with declarative range partitions to convert linear WAL write spikes into instantaneous DDL operations.
  4. Eliminate Queue Lock Contention: Replace standard pessimistic selects with FOR UPDATE SKIP LOCKED to achieve non-blocking concurrency across worker pools.
  5. Neutralize Cache Stampedes: Replace hard-coded TTLs with probabilistic early expiration (XFetch) or lease-guarded distributed mutexes.

About the Author

huud

huud

@huud

About →

Systems architect and software engineer building high-performance distributed platforms.