Situation: You run recurring analytics that computes ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY event_ts DESC) on a 2B-row events table. The goal is fast “latest N per customer” queries across OLAP systems while balancing insert throughput and maintenance.
Recommended strategies (by system + general pattern)
- Logical approach (common to all):
- Partition the table by time (event_ts date/month) so queries can prune old data and daily loads are isolated.
- Physically cluster/sort/index by customer_id then event_ts DESC so recent events per customer are contiguous.
- Consider maintaining a “latest_per_customer” incremental materialized table (customer_id, latest_event_ts, payload) updated via CDC or upserts — queries against that are cheap.
- Index / sort examples:
- Postgres (OLTP/analytical on large scale): Range partition by event_date, create a composite index on (customer_id, event_ts DESC) per partition:
CREATE INDEX ON events_p2025_01 (customer_id, event_ts DESC);
Partition pruning + index-only scans speed ROW_NUMBER for recent rows.
- Redshift: Use date-based distribution (DISTKEY event_date) and SORTKEY (customer_id, event_ts DESC). Redshift’s sort order gives very fast scan for per-customer top-N.
- BigQuery: Partition by DATE(event_ts) and CLUSTER BY customer_id,event_ts. Clustering keeps rows for same customer colocated and enables pruning; BigQuery has no B-tree index.
- Snowflake: Time-based micro-partitions + clustering key (customer_id, event_ts DESC). Re-clustering cost must be watched.
- ClickHouse: Use ORDER BY (customer_id, event_ts DESC) in MergeTree for extremely fast top-N per key.
- Materialized / pre-aggregated alternatives:
- Maintain a rolling materialized view of latest event per customer (or top-K) via streaming/upsert. For example, in Postgres use a small upsert table updated by ingestion job; in Snowflake use tasks to merge; in BigQuery use MERGE via scheduled pipeline.
- For full ROW_NUMBER results periodically computed, use an ETL that computes rank per partition (e.g., per day) and stores only required ranks.
Trade-offs:
- Insert throughput vs query latency: Sorting/clustered storage (ORDER BY during load or heavy index maintenance) increases ingest cost and write amplification. Systems like ClickHouse or BigQuery optimize for bulk loads; Postgres with many indexes/VACUUM slows inserts. Partitioning by time improves ingest isolation.
- Maintenance costs: Re-clustering/rebuilding indexes is expensive (Snowflake automatic reclustering costs credits; Postgres requires REINDEX/VACUUM; BigQuery clustering benefits fade as table grows and needs periodic reclustering). More partitions = easier prune but higher management overhead.
- Storage: Materialized views and covering indexes duplicate data and increase storage.
- Freshness vs cost: Materialized “latest” table gives best latency but needs reliable incremental update (CDC) and increases write-path complexity.
Other optimizations / query patterns:
- Replace window function with a join to an index of max(event_ts) per customer:
SELECT e.* FROM events e JOIN (SELECT customer_id, MAX(event_ts) AS latest FROM events GROUP BY customer_id) t USING (customer_id,event_ts)
This benefits greatly from an index on (customer_id, event_ts).
- Use LIMIT per partition (ClickHouse’s arrayJoin/topK primitives) or window pushed-down operations in your engine.
Concrete rule-of-thumb:
- If near-real-time latest-per-customer is required and row count is huge: maintain incremental latest_per_customer table + partitioned raw events for audits.
- If analytic freshness can be minutes/hours: partition by date and cluster by customer_id,event_ts DESC (or ORDER BY in ClickHouse), and schedule periodic reclustering/compaction.
This balances query latency (fast reads) and ingest/maintenance costs by choosing either read-optimized clustering + periodic maintenance, or a write-optimized ingest plus a small, maintained summary table for low-latency reads.