Exadata-style Storage Nodes in PostgreSQL โ€” What Already Exists

Oracle Exadata is famous for making huge scans fast by pushing work down into dedicated storage cells. The mental model most people carry away is: the storage layer computes statistics about the data in each block, and uses them to avoid reading blocks that can’t match the query. That is a good instinct โ€” and it turns out that exact idea has shipped in core PostgreSQL since 2016. The harder-sounding part (“dedicated storage nodes”) is a separate, scaling-only concern that many workloads never actually need.

This note maps Exadata’s behaviour onto the PostgreSQL equivalents, and shows a measured result for the piece that matters most.

Exadata, Decomposed

A storage cell really does four separable things. Splitting them is the whole trick, because PostgreSQL covers each to a very different degree:

  • Storage Indexes โ€” in-memory min/max ranges per ~1 MB storage region, used to skip regions that can’t satisfy a predicate.
  • Smart Scan offload โ€” the WHERE clause, column projection, and some aggregation/join work run in the storage tier, so only matching rows and columns cross the interconnect.
  • Columnar (Hybrid Columnar Compression) + skipping โ€” column-oriented storage that both compresses and lets scans ignore irrelevant columns/regions.
  • Many cells in parallel โ€” a single scan is fanned out across many storage servers for raw I/O bandwidth.

The PostgreSQL Equivalents

Each of those has a mature, documented counterpart:

Exadata capabilityPostgreSQL equivalent
Storage Index (min/max per region)BRIN (Block Range INdex) in core
Smart Scan predicate/projection offloadpostgres_fdw pushdown (WHERE, aggregates, joins, ORDER BY, LIMIT)
Columnar + region skippingCitus Columnar / Hydra / cstore_fdw (chunk-group filtering)
Parallel storage cellsCitus distributed tables (shards scanned in parallel)

The lineage on the first row is not a coincidence. BRIN was proposed in 2013 as “minmax indexes” and defaults to 128 pages (~1 MB) per summarised block range โ€” the same granularity Oracle uses for a storage region. Both are, in effect, negative indexes: they don’t tell you where a value is, they tell you which blocks it definitely isn’t in.

The Measured Result

The headline claim deserves numbers rather than hand-waving. On a 5-million-row append-only events table (~287 MB, 36,765 heap blocks), with a timestamp column that increases with insertion order, a single BRIN index turns a full-table scan into a near-miss:

CREATE INDEX events_ts_brin ON events USING BRIN (ts)
    WITH (pages_per_range = 128);

SELECT count(*), avg(value)
FROM events
WHERE ts >= TIMESTAMPTZ '2020-02-01'
  AND ts <  TIMESTAMPTZ '2020-02-02';

Measured with EXPLAIN (ANALYZE, BUFFERS) on PostgreSQL 16:

ScenarioBlocks readTime
Sequential scan, no index36,765 (whole table)502 ms
BRIN on ts (correlation +1.0)773 (~2%)23 ms
BRIN on device_id (correlation โˆ’0.003)36,765 (whole table)442 ms

The middle row is the Exadata storage-index effect reproduced in stock Postgres: ~98% of the table’s blocks were skipped, no special hardware involved.

When It Doesn’t Work

That third row is just as important as the second. device_id is scattered randomly across the heap, so every block range ends up with a wide, overlapping min/max โ€” and nothing can be skipped. This is the same limitation Exadata storage indexes have: the technique only pays off when the filtered column is physically correlated with row position on disk. Append-only time-series and generated-sequence data satisfy this naturally; randomly-updated OLTP columns do not. Check pg_stats.correlation before betting on it โ€” you want |corr| close to 1.

The One Real Gap

What open-source PostgreSQL does not have is Exadata’s dedicated storage-server process that evaluates SQL predicates as a physically separate tier (Oracle’s cellsrv). The nearest architectural analogs are the storage/compute-separated commercial systems โ€” Amazon Aurora and AlloyDB push some processing into their storage layer โ€” but those internals are proprietary. The postgres_fdw route gets most of the same effect using only open, documented mechanisms: it deparses the pushable parts of a plan into remote SQL that a separate node executes.

How Far Should You Go?

The useful reframing: what sounds like “I need Exadata storage nodes” is usually a staircase, and you should climb only as far as your bottleneck forces you.

  1. Prove it with config, not code. BRIN + native range partitioning + EXPLAIN (ANALYZE, BUFFERS). Most people who think they need Exadata stop here.
  2. If I/O still dominates, go columnar. Citus Columnar or Hydra add compression (3โ€“10ร—) plus per-column chunk skipping. Still one machine.
  3. If one machine can’t hold or scan it, add nodes. Citus (shard + parallel scan) or postgres_fdw with pushdown. This is the real “storage node” moment โ€” and it’s off-the-shelf, not something you build.
  4. Build custom (C extension / table access method) only if a concrete gap survives 1โ€“3.

The axes that decide how high you climb: data volume (a single machine handles low tens of TB comfortably), workload shape (append-only = BRIN heaven, random-access OLTP = BRIN useless), and topology (only matters from step 3 on).

Bottom Line

The part of Exadata you can most easily copy โ€” statistics per block to skip data โ€” is BRIN, and it needs no extra nodes at all. The part that needs dedicated storage nodes is about I/O bandwidth and parallelism at scale, and if you ever truly need it, Citus and FDW pushdown already provide it. Building a storage tier from scratch is a fourth-resort move, justified only after the first three rungs fail to move your bottleneck.


References: PostgreSQL BRIN docs ยท Block Range Index (Wikipedia) ยท postgres_fdw ยท Citus Columnar ยท Oracle Exadata Smart Scan