DEV Community

Cover image for Optimizing ClickHouse Queries for Billing Dashboards
Jay Grider
Jay Grider

Posted on • Originally published at chkdsklabs.com

Optimizing ClickHouse Queries for Billing Dashboards

Billing dashboards are different from user analytics. One lies about who spent what, the other just counts clicks. If your ClickHouse query takes three seconds to sum a month of transactions, you lose trust instantly. We’ve seen teams migrate petabyte-scale ledgers to columnar storage because row-based engines couldn’t handle the cardinality, only to find their dashboards lagging when they try to slice by customer segment or transaction type. The shift isn't just about hardware; it's about how you structure the query execution plan against the physical layout of your data.

Diagnosing the Partitioning Bottleneck

The most common bottleneck in billing systems isn't index size; it's partition pruning. If you partition a billing_transactions table only by year, every query that filters by month or customer ID forces ClickHouse to scan the entire year's worth of data before filtering out the noise. With petabyte-scale logs, scanning millions of rows for a single dashboard metric is a waste of I/O cycles.

Consider a schema where transactions are partitioned by toYYYYMM. If you run a query filtering on customer_id = 'C12345' without an explicit date range, the engine must still read every partition from the current year. The column-oriented storage is efficient only when you can discard irrelevant blocks immediately. Without precise pruning, the query optimizer treats the whole table as the working set.

-- Bad: No partition filter forces full scan of 2024 data
SELECT sum(amount) FROM billing_transactions WHERE customer_id = 'C12345';

-- Good: Explicit date range enables partition pruning
SELECT sum(amount) 
FROM billing_transactions 
WHERE customer_id = 'C12345' AND event_date >= '2024-01-01' AND event_date < '2024-12-01';
Enter fullscreen mode Exit fullscreen mode

When you ignore the partition key in your WHERE clause, ClickHouse has to load every block from every partition into memory or spill to disk. This latency spike happens silently until the dashboard times out. The fix is often as simple as enforcing date ranges in your application layer before hitting the database. If you are building a real-time revenue report, ensure your ingestion pipeline tags data with granular timestamps so you can query by day without scanning months of history.

Mastering Query Plan Contention and Indexes

Even with perfect partitioning, aggregates fail when memory limits force spills to disk. ClickHouse is optimized for parallel execution, but if a single sort operation requires more RAM than available on the node, the engine switches to an external merge sort. This adds significant latency and creates contention as multiple queries fight for I/O bandwidth.

High-cardinality fields like customer_id or transaction_type are often left as raw strings in the schema. Without secondary indexes, ClickHouse must hash every row during aggregation. If you have a dashboard filtering by a specific transaction type that appears only once per million rows, the engine scans everything to find those few matches.

Use EXPLAIN PIPELINE to inspect the execution plan before it hits the storage engine. Look for steps marked as "Spilling" or "Sorting." If you see excessive disk I/O in the pipeline, your query is fighting the hardware rather than leveraging it.

EXPLAIN PIPELINE 
SELECT count(*) FROM billing_transactions WHERE transaction_type = 'refund';
Enter fullscreen mode Exit fullscreen mode

If the plan shows a full table scan followed by a sort, consider adding a materialized column that groups data at ingestion time. For instance, pre-aggregating daily totals per customer in a separate low-cardinality table can reduce the query complexity from millions of rows to thousands. This trade-off is standard in financial reporting: you sacrifice some real-time granularity for consistent sub-second latency on summary metrics.

Scaling Aggregations for Real-Time Financial Reporting

Raw transaction logs are great for auditing, but dashboards need summaries. Pre-aggregating raw events into summary tables is the only way to handle high-volume billing data efficiently. A common pattern involves a base table for audit trails and a separate aggregated table updated via materialized views or daily ETL pipelines.

Materialized views in ClickHouse automatically maintain these aggregations as new data arrives. They are useful for dashboards that update frequently but don't require second-by-second precision. However, they introduce complexity in schema management and can become bottlenecks if the view definition is overly complex.

A manual ETL pipeline offers more control over data quality and allows you to run transformations before aggregation. For daily revenue reports, a nightly job that sums transactions by region or product category is often cleaner than relying solely on real-time materialized views. This decouples ingestion from reporting logic, making it easier to debug performance issues without rewriting the core dashboard queries.

Data skew is another silent killer in billing dashboards. If one enterprise customer generates 80% of the transaction volume, the aggregation nodes may become unbalanced. ClickHouse distributes data across shards based on partition keys, but if a single key dominates, you'll see hotspots where specific nodes saturate while others sit idle.

-- Check for data skew by counting rows per shard
SELECT 
    _shardNum,
    count() as transaction_count,
    sum(amount) as total_revenue
FROM billing_transactions
GROUP BY _shardNum
ORDER BY transaction_count DESC;
Enter fullscreen mode Exit fullscreen mode

If the distribution is uneven, consider adding a secondary partition key or adjusting the sharding strategy. In some cases, splitting the largest customer's data into a separate shard or table entirely provides better performance than trying to balance the load across the whole cluster. This approach prioritizes consistency over perfect symmetry, which is often the pragmatic choice for billing systems where accuracy matters more than even distribution.

Where This Shows Up in Small-Team Software

Indie hackers and small teams often skip database optimization until the dashboard starts lagging. Without a dedicated DBA, it's easy to assume that columnar storage solves all performance problems. But the same principles apply whether you're managing gigabytes or petabytes: partition pruning, memory limits, and data distribution dictate query speed.

Ignoring these factors means your users experience timeouts as data volume grows. The cost of fixing this later is higher than investing in proper schema design from the start. Lightweight monitoring practices are essential for catching degradation early. Set up alerts for query execution time and disk I/O utilization. If a dashboard query consistently exceeds 200ms, investigate the execution plan before adding more hardware.

For small teams, tools that simplify observability can bridge the gap between raw performance data and actionable insights. While we focus on infrastructure reliability elsewhere in our stack, database tuning remains a distinct challenge. The l-bom tool helps manage artifact integrity for LLMs, but similar rigor applies to financial data pipelines. Treat your billing schema with the same care you'd give production dependencies.

When building financial dashboards, prioritize query predictability over raw speed. A stable 500ms response time is better than a variable 20ms average that occasionally hits three seconds. This consistency builds user trust and keeps your infrastructure bills predictable as traffic scales.

Top comments (0)