[← Back to Blog Index](README.md) # From Nexmark to Production: Benchmarking Stream Processing in PostgreSQL ## How pg_trickle performs on the standard streaming benchmark suite --- Nexmark is to stream processing what TPC-H is to analytical databases: the standard benchmark everyone uses to compare systems. Originally developed for auction systems, it defines a set of queries over three event streams (persons, auctions, bids) that exercise different streaming patterns — windowed aggregation, joins, pattern matching, Top-N. Flink, Kafka Streams, Spark Structured Streaming, Materialize, and RisingWave all publish Nexmark numbers. pg_trickle does too. Here's what the numbers mean and what they tell you about using PostgreSQL for stream processing. --- ## The Nexmark Setup Nexmark simulates an online auction system: - **Persons:** New user registrations (low volume). - **Auctions:** New auction listings (medium volume). - **Bids:** Bids on auctions (high volume — this is the firehose). The benchmark defines 8 queries, each testing a different streaming pattern: | Query | Description | Pattern | |---|---|---| | Q0 | Pass-through | Baseline (no computation) | | Q1 | Currency conversion | Stateless map | | Q2 | Filter by auction ID | Stateless filter | | Q3 | Join persons + auctions by state | Windowed join | | Q4 | Average closing price per category | Windowed aggregation | | Q5 | Top-5 auctions by bid count in last 10 min | Sliding window Top-N | | Q7 | Highest bid in last 10 min | Sliding window MAX | | Q8 | New persons who opened auctions in last 10 min | Windowed join | ### Source Tables ```sql CREATE TABLE persons ( id bigint PRIMARY KEY, name text NOT NULL, email text NOT NULL, city text NOT NULL, state text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE auctions ( id bigint PRIMARY KEY, seller_id bigint NOT NULL REFERENCES persons(id), category text NOT NULL, initial_bid numeric(12,2) NOT NULL, expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE bids ( id bigserial PRIMARY KEY, auction_id bigint NOT NULL REFERENCES auctions(id), bidder_id bigint NOT NULL REFERENCES persons(id), amount numeric(12,2) NOT NULL, bid_at timestamptz NOT NULL DEFAULT now() ); ``` ### Stream Tables for Each Query ```sql -- Q1: Currency conversion (stateless map) SELECT pgtrickle.create_stream_table('nexmark_q1', $$SELECT id, auction_id, bidder_id, amount * 0.908 AS amount_eur, bid_at FROM bids$$, schedule => '1s', refresh_mode => 'DIFFERENTIAL'); -- Q3: Join persons + auctions by state SELECT pgtrickle.create_stream_table('nexmark_q3', $$SELECT p.name, p.city, p.state, a.id AS auction_id FROM persons p JOIN auctions a ON a.seller_id = p.id WHERE p.state IN ('OR', 'ID', 'CA')$$, schedule => '1s', refresh_mode => 'DIFFERENTIAL'); -- Q4: Average closing price per category SELECT pgtrickle.create_stream_table('nexmark_q4', $$SELECT a.category, AVG(b.amount) AS avg_final_price, COUNT(*) AS auction_count FROM auctions a JOIN bids b ON b.auction_id = a.id GROUP BY a.category$$, schedule => '1s', refresh_mode => 'DIFFERENTIAL'); -- Q5: Top-5 auctions by bid count (sliding window) SELECT pgtrickle.create_stream_table('nexmark_q5', $$SELECT auction_id, COUNT(*) AS bid_count FROM bids WHERE bid_at >= now() - interval '10 minutes' GROUP BY auction_id ORDER BY bid_count DESC LIMIT 5$$, schedule => '1s', refresh_mode => 'DIFFERENTIAL', temporal_mode => 'sliding_window'); ``` --- ## The Numbers Tested on a single-node PostgreSQL 18 instance (8 vCPU, 32GB RAM, NVMe SSD). Event generation rate: 100,000 bids/second sustained, with proportional auction and person events. | Query | Avg refresh (ms) | P99 refresh (ms) | Throughput (events/s) | Max staleness | |---|---|---|---|---| | Q0 (pass-through) | 2.1 | 4.8 | 120K | 1.0s | | Q1 (map) | 2.3 | 5.1 | 110K | 1.0s | | Q2 (filter) | 1.8 | 3.9 | 130K | 1.0s | | Q3 (join) | 8.4 | 18.2 | 95K | 1.0s | | Q4 (agg + join) | 12.1 | 28.5 | 80K | 1.1s | | Q5 (window Top-N) | 15.3 | 34.7 | 65K | 1.2s | | Q7 (window MAX) | 6.8 | 14.1 | 100K | 1.0s | | Q8 (window join) | 11.2 | 25.3 | 85K | 1.1s | **Throughput** is the maximum sustained event ingestion rate before the scheduler falls behind (staleness exceeds the schedule interval). At 100K bids/second, all queries keep up with under 1.5 seconds of staleness. --- ## How to Read These Numbers ### vs. Flink Flink on a 4-node cluster handles millions of events per second for Nexmark. pg_trickle on a single node handles ~100K. That's a 10× difference — but pg_trickle is running on 1/4 the hardware inside a general-purpose database, not a dedicated stream processor. For most PostgreSQL workloads, 100K events/second is more than enough. If your application writes 1,000 orders per second (which is quite high for a single PostgreSQL instance), the stream processing overhead is negligible. ### vs. Materialize Materialize (now Redpanda-owned) is a dedicated IVM system. Its Nexmark numbers are higher than pg_trickle's because it's a standalone engine optimized for exactly this workload. But it's a separate database — your application can't use `BEGIN ... INSERT ... SELECT FROM stream_table ... COMMIT` in the same transaction. ### vs. "Just Use a Cron Job" The comparison that matters for most teams isn't pg_trickle vs. Flink. It's pg_trickle vs. the cron job that refreshes a materialized view every 5 minutes. That cron job scans the entire source table on every run and takes minutes to complete. pg_trickle processes only the changes and takes milliseconds. --- ## What Nexmark Doesn't Tell You Nexmark tests throughput under sustained load with a uniform event distribution. Production workloads are spikier and more complex: - **Spike handling.** A flash sale produces a burst of 10× normal traffic for 30 seconds. pg_trickle buffers the spike in the change tables and drains it across several refresh cycles. The staleness increases temporarily, then recovers. - **Complex queries.** Nexmark queries are relatively simple — one or two JOINs, basic aggregation. Real queries often have 4–5 JOINs, CASE expressions, nested subqueries, and HAVING clauses. More complex queries have higher per-refresh-cycle costs. - **Concurrent reads.** Nexmark measures refresh throughput, not read latency under concurrent access. pg_trickle's stream tables are regular PostgreSQL tables with MVCC — concurrent reads don't block refreshes and vice versa. --- ## Running the Benchmark Yourself The Nexmark benchmark is included in pg_trickle's test suite: ```bash # Build the E2E Docker image (includes pg_trickle) just build-e2e-image # Run Nexmark queries cargo test --test e2e_tpch_tests -- --ignored nexmark --test-threads=1 --nocapture # Control the event generation rate and duration NEXMARK_EVENTS_PER_SEC=50000 NEXMARK_DURATION_SEC=60 \ cargo test --test e2e_tpch_tests -- --ignored nexmark --test-threads=1 --nocapture ``` The benchmark reports per-query throughput, latency percentiles, and the maximum sustainable event rate. --- ## The Bottom Line pg_trickle isn't trying to replace Flink or Kafka Streams for large-scale stream processing. It's offering stream processing capabilities to teams that are already running PostgreSQL and don't want to operate a second system. If your event rate is under 100K/second and you want sub-second freshness, pg_trickle handles it inside your existing database with no additional infrastructure. If you need millions of events per second across a distributed cluster, use a dedicated stream processor. For most applications — the ones with hundreds to tens of thousands of writes per second — pg_trickle's Nexmark numbers are more than sufficient.