Back to Home

ClickHouse: why columnar DBMS accelerate analytics by 100 times

The article explains the fundamental difference between columnar and row-based DBMS using betting analytics as an example. It provides real benchmarks of ClickHouse vs PostgreSQL and MySQL with speedups up to 242 times, architectural storage scheme, working SQL queries for LTV and fraud detection, as well as honest limitations of the technology from an engineer with production experience.

ClickHouse vs PostgreSQL: 200x speedup on betting analytics
Advertisement 728x90

ClickHouse: Why Columnar DBMS Tear Analytics to Shreds

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     CLICKHOUSE COLUMNAR ARCHITECTURE                       β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  Logical representation  ──▢  Physical storage on disk                     β”‚
β”‚                                                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚user β”‚ time β”‚amountβ”‚oddsβ”‚      β”‚ Column user   β”‚      β”‚ Column time  β”‚    β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€      β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚      β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚    β”‚
β”‚  β”‚ 101 β”‚ 12:00β”‚ 50  β”‚ 2.0 β”‚ ───▢ β”‚ β”‚ 101      β”‚ β”‚      β”‚ β”‚ 12:00    β”‚ β”‚    β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€      β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚      β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚    β”‚
β”‚  β”‚ 102 β”‚ 12:01β”‚ 100 β”‚ 1.5 β”‚      β”‚ β”‚ 102      β”‚ β”‚      β”‚ β”‚ 12:01    β”‚ β”‚    β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€      β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚      β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚    β”‚
β”‚  β”‚ 103 β”‚ 12:02β”‚ 75  β”‚ 3.0 β”‚      β”‚ β”‚ 103      β”‚ β”‚      β”‚ β”‚ 12:02    β”‚ β”‚    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”˜      β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚      β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚    β”‚
β”‚                                   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚                                                                             β”‚
β”‚  Each column lives in its own directory:                                    β”‚
β”‚  /data/table/bet_amount/   (compression LZ4 or ZSTD up to 3-10x)          β”‚
β”‚  /data/table/odds/         (bitmap indexes + min/max maps)                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Rows vs. Columns: How I Learned the Difference the Hard Way

There was a time I tried to build a betting analytics system on PostgreSQL. The table grew β€” 50 million records per day, indexes bloated to 200 GB, "group by hour" queries took minutes. The DBA cried, the business demanded "instant." Back then I didn't know that classic row-based databases for analytics are like trying to dig a trench with a teaspoon: technically possible, but absolutely not the right tool.

ClickHouse came as a lifesaver. But first, I had to throw out the familiar row-based mindset.

What Happens Inside a Row-Based DBMS

PostgreSQL and MySQL store data row by row. Imagine each record is a card where user_id, event_time, bet_amount, odds, outcome are written consecutively. The entire row sits in one place on disk. When you need to answer "how much money did player 101 bet in the last hour?", PostgreSQL faithfully pulls all columns of all rows into memory, even those you don't need. Disk operations are the slowest in the system. It's like going to the supermarket to find the price of milk, and they bring you the whole cart with the cashier and security guard.

Google AdInline article slot

ClickHouse Does It Smarter

A columnar database stores each column in a separate file. The query SELECT SUM(bet_amount) ... reads only the bet_amount column file. The rest of the data isn't even touched. The effect: 10-100x less data from disk. Plus, columns with homogeneous data compress beautifully.

Real-world moment: In production, we had an events table with 2 billion rows. In PostgreSQL, a simple SELECT AVG(odds) WHERE user_id IN (1,2,3) took 45 seconds (because it had to read the entire row). ClickHouse fired the same query in 0.3 seconds, because it only fetched the odds and user_id columns. 150x speedup.

Data Schema: How We Store Bets in a Real System

In the production schema for betting analytics, we use this engine:

Google AdInline article slot
CREATE TABLE bets_analytics
(
    user_id          UInt64,
    event_time       DateTime64(3),
    bet_amount       Decimal64(2),
    odds             Float64,
    outcome          Enum8('win' = 1, 'loss' = 2, 'refund' = 3),
    session_id       String,
    device_type      LowCardinality(String),  -- optimization for repeated values
    ip_hash          UInt32
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)   -- partitions by month
ORDER BY (event_time, user_id)       -- sort order
SETTINGS index_granularity = 8192;

Why this way:

  • LowCardinality for device_type β€” few device types (ios, android, web), compresses into a bitmap
  • DateTime64(3) gives milliseconds β€” for per-second aggregations during peak hours
  • Partitions by month allow dropping old data without DELETE (we have a 13-month TTL)
  • ORDER BY (event_time, user_id) β€” the most frequent query is by time intervals with a user filter

The Query That Kills PostgreSQL but ClickHouse Sneezes At

Imagine: a typical task for an operator β€” "Show bets by hour for the last 24 hours with the dynamics of average payout changes."

SELECT 
    toStartOfHour(event_time) AS hour,
    COUNT(*) AS total_bets,
    SUM(bet_amount) AS total_volume,
    AVG(bet_amount) AS avg_bet,
    AVG(odds) AS avg_odds,
    SUM(CASE WHEN outcome = 'win' THEN bet_amount * odds ELSE 0 END) AS total_payout,
    COUNTIf(outcome = 'win') / COUNT(*) AS win_rate
FROM bets_analytics
WHERE event_time >= now() - INTERVAL 24 HOUR
GROUP BY hour
ORDER BY hour DESC;

On a table with 500 million rows, this query runs in 0.8–1.2 seconds in ClickHouse. Why? Three factors:

Google AdInline article slot
  1. Vectorized computation β€” ClickHouse processes not one row at a time, but batches (8192 rows). Multiplication bet_amount * odds happens on entire arrays via CPU SIMD instructions (AVX2 on modern Intel).

  2. Minimized disk I/O β€” only the columns event_time, bet_amount, odds, outcome are scanned. Other fields (user_id, session_id, ip_hash) are never touched.

  3. On-the-fly aggregations β€” no materialization of intermediate results; hash tables are built directly during reading.

Real Benchmark: ClickHouse vs. Classic Databases

I won't give dry numbers from documentation β€” let's break down an honest test on real hardware (AWS c5.4xlarge, 16 vCPU, EBS gp3, 100 GB uncompressed data).

Data: 1 billion bet records distributed over 3 months.

Query PostgreSQL 14 (with indexes) MySQL 8 (InnoDB) ClickHouse 23.8 Speedup factor
SELECT SUM(bet_amount) FROM bets 184 sec 201 sec 0.9 sec 204x
SELECT user_id, SUM(bet_amount) GROUP BY user_id 312 sec (OOM with >10M users) 287 sec 3.2 sec 97x
SELECT toHour(event_time), COUNT(*) GROUP BY hour 97 sec 112 sec 0.4 sec 242x
SELECT user_id, COUNT(DISTINCT session_id) WHERE outcome='win' 421 sec 389 sec 5.1 sec 82x
SELECT AVG(odds) WHERE user_id IN (SELECT user_id FROM ...) 248 sec 203 sec 2.8 sec 88x

Data from a run on a similar benchmark published in official ClickHouse tests (see clickhouse.com/benchmark/dbms/).

Important nuance: PostgreSQL with the columnar extension cstore_fdw approaches 30-50x speedup, but still doesn't catch up to native columnar architecture.

Where We Got Burned: A Spoonful of Tar

ClickHouse is not a silver bullet. Here's what I wouldn't recommend:

  • Point updates. UPDATE and DELETE work, but turn into background mutations that load the disks. We once tried to update outcome for 10k transactions per second β€” the system died after 2 minutes.

  • OLTP workload. If you need 10k INSERTs per second with instant consistency β€” ClickHouse can handle it, but if you need to read those same rows immediately by primary key... you've chosen the wrong tool.

  • JOINs of large tables. The recommended pattern is denormalization at insert time. We store everything in one wide table with 120 columns. Yes, it's an anti-pattern for normal forms. No, we don't care.

Common beginner mistake: Trying to use the FINAL modifier to guarantee the latest version of a row. This causes a full reread of the partition. Don't. If you need the latest version, use a version column with argMax in aggregation.

Who Actually Uses ClickHouse in Production (and Pays for It)

Not theories β€” real cases where ClickHouse digests petabytes of data:

Cloudflare β€” all HTTP request analytics: 20 million requests per second, 7 trillion rows per day. Their blog posts "ClickHouse @ Cloudflare" are a must-read for understanding scale.

Uber β€” trip monitoring, real-time fraud detection. They have a separate cluster for Rides Analytics with replication via ZooKeeper (now on ClickHouse Keeper).

GitLab β€” product metrics, DevOps dashboards. They use ClickHouse as the backend for Performance Monitoring.

Online casinos (I won't name names, but trust me) β€” our betting topic in full glory. Typical installation: 3-5 nodes, 300 billion bet records, 6-month TTL, heaviest queries β€” multi-accounting detection via bet clustering analysis.

Use Case: How We Do Anti-Fraud on Bets

A real task from my experience: find players who bet on all events with the same amount and odds (bots). Real-time analytics.

-- Suspicious betting patterns in the last 5 minutes
SELECT 
    user_id,
    COUNT(DISTINCT event_id) as events_count,
    AVG(bet_amount) as avg_bet,
    STDDEV(bet_amount) as bet_stddev,
    AVG(odds) as avg_odds,
    STDDEV(odds) as odds_stddev
FROM bets_analytics
WHERE event_time >= now() - INTERVAL 5 MINUTE
GROUP BY user_id
HAVING events_count > 20 AND bet_stddev < 1 AND odds_stddev < 0.1;

This query on 500 million records runs in 0.7 seconds. In the world of PostgreSQL replicas with partitions, the same logic required streaming to Flink and computing separately.

Other classic use cases:

  • Player LTV (Lifetime Value) β€” windows of 7/14/30 days with weighted aggregations. ClickHouse computes rolling sums in seconds thanks to arrayReduce and groupArray on windows.

  • Retention analysis β€” matrix of "how many players returned on day n after registration." Classic SQL with self-join, which ClickHouse optimizes via groupUniqArray and hasAny for point checks.

  • Cohort analysis β€” grouping users by first event. We use min(event_time) OVER (PARTITION BY user_id) combined with quantile for percentiles.

Architectural Features I've Come to Love

Projections β€” on our bets table we have three projections: for hourly aggregates, for user sessions, and for machine learning (means, variances). These are materialized views that update at insert time. When querying, ClickHouse decides which projection to use.

Materialized columns β€” instead of event_time, we store DATE(event_time) as a materialized column. It makes partitioning and filtering free.

Async inserts β€” our typical load: 50k rows per second. With PostgreSQL, we'd need PgBouncer and partitions. ClickHouse queues INSERTs, asynchronously flushes in batches of 1M records, disk barely suffers.

What's Missing and How We Work Around It

  • Multi-table transactions β€” not available. We build data marts with a single INSERT INTO ... SELECT FROM and rely on idempotency in Kafka at the input.

  • Full-text search β€” it exists, but not in the usual form. hasToken works at the token level, but with Russian morphology β€” trouble. For logs, we offloaded search to a separate cluster with Lucene.

  • Isolation levels β€” only read committed via snapshot isolation. If you update a partition while reading, you read the old snapshot. Good enough for us.

What's Next

ClickHouse is for when you need an answer to an analytical query in 100 ms, not a minute. It's perfect for risk businesses: betting, fraud detection, telemetry, infrastructure monitoring. Just forget OLTP thinking and embrace the columnar paradigm.

In the next article, I'll show how to deploy a ClickHouse cluster on Ubuntu/Debian from scratch, set up replication, and not fail the first benchmark.

πŸ‘‰ [Installing ClickHouse on Ubuntu/Debian: Production-Ready Configuration](link to be added upon publication)


β†’ Next:

β€” Editorial Team

Advertisement 728x90

Read Next