Why ClickHouse When You Already Have PostgreSQL
PostgreSQL is an excellent OLTP database: transactions, ACID, foreign keys, JSONB. But when the events table crossed 50 million rows and the analytics dashboard started responding in 12 seconds — it became clear that something different was needed.
ClickHouse is a columnar database built for analytical queries. Where PostgreSQL scans all columns of a row, ClickHouse reads only the columns you need. The difference is orders of magnitude on aggregations.
I connected ClickHouse to a production project as a second database: PostgreSQL stayed for core data, ClickHouse — for analytics, logs, and metrics. Here's how it works in practice.
Architecture: PostgreSQL + ClickHouse
Why "Both" Not "Either-Or"
ClickHouse doesn't replace PostgreSQL. It has no transactions, UPDATE works through mutations (asynchronously), DELETE too. It's not suitable for CRUD operations. But for analytics — it's ideal.
Typical architecture:
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Rails App │────▶│ PostgreSQL │ │ ClickHouse │
│ │ │ (OLTP) │ │ (OLAP) │
│ │────▶│ │ │ │
└─────────────┘ └──────────────┘ └──────────────┘
│ ▲
│ ┌──────────────┐ │
└─────────▶│ Background │──────────────┘
│ Job (ETL) │
└──────────────┘
The application writes core data to PostgreSQL. A background job (Sidekiq/SolidQueue) streams events and metrics to ClickHouse. Dashboards read from ClickHouse.
What to Store in ClickHouse
| Data | PostgreSQL | ClickHouse |
|---|---|---|
| Users, orders, products | Yes | No |
| Page views | No | Yes |
| Clicks, UI events | No | Yes |
| Application logs | No | Yes |
| Aggregated metrics | No | Yes |
| Conversion funnels | No | Yes |
| Financial reporting | No | Yes (copy for analytics) |
| Real-time dashboards | No | Yes |
Simple rule: if data is written frequently, read with aggregations, and doesn't require UPDATE — it belongs in ClickHouse.
Installation and Configuration
Installing on Ubuntu
# Add ClickHouse repository
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg
curl -fsSL 'https://packages.clickhouse.com/rpm/lts/repodata/repomd.xml.key' | sudo gpg --dearmor -o /usr/share/keyrings/clickhouse-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/clickhouse-keyring.gpg] https://packages.clickhouse.com/deb stable main" | sudo tee /etc/apt/sources.list.d/clickhouse.list
sudo apt-get update
# Install server and client
sudo apt-get install -y clickhouse-server clickhouse-client
# Start
sudo systemctl start clickhouse-server
sudo systemctl enable clickhouse-server
# Verify
clickhouse-client --query "SELECT version()"
# 24.8.x
Docker (for development)
# docker-compose.yml
services:
clickhouse:
image: clickhouse/clickhouse-server:24.8-alpine
ports:
- "8123:8123" # HTTP interface
- "9000:9000" # Native protocol
volumes:
- clickhouse_data:/var/lib/clickhouse
- ./clickhouse/config.xml:/etc/clickhouse-server/config.d/custom.xml
environment:
CLICKHOUSE_DB: analytics
CLICKHOUSE_USER: app
CLICKHOUSE_PASSWORD: secure_password
ulimits:
nofile:
soft: 262144
hard: 262144
volumes:
clickhouse_data:
Basic Configuration
<!-- /etc/clickhouse-server/config.d/custom.xml -->
<clickhouse>
<max_concurrent_queries>100</max_concurrent_queries>
<max_server_memory_usage_to_ram_ratio>0.8</max_server_memory_usage_to_ram_ratio>
<!-- Data compression -->
<compression>
<case>
<min_part_size>10000000000</min_part_size>
<min_part_size_ratio>0.01</min_part_size_ratio>
<method>zstd</method>
<level>3</level>
</case>
</compression>
<!-- Query logging -->
<query_log>
<database>system</database>
<table>query_log</table>
<flush_interval_milliseconds>7500</flush_interval_milliseconds>
</query_log>
</clickhouse>
Table Engines: MergeTree and Its Family
MergeTree — The Core Engine
CREATE TABLE page_views
(
event_date Date,
event_time DateTime,
user_id UInt64,
session_id String,
url String,
referrer String,
device_type Enum8('desktop' = 1, 'mobile' = 2, 'tablet' = 3),
country_code FixedString(2),
browser String,
os String,
load_time_ms UInt16,
is_bounce UInt8
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, user_id, session_id)
TTL event_date + INTERVAL 12 MONTH
SETTINGS index_granularity = 8192;
Breaking it down:
- PARTITION BY toYYYYMM(event_date) — data is split by month. A query for a specific month reads only one partition, not the entire table
- ORDER BY (eventdate, userid, session_id) — physical sort order on disk. This is the primary key. Queries on these columns use the sparse index and run instantly
- TTL event_date + INTERVAL 12 MONTH — automatic deletion of data older than one year. No manual cleanup needed
- index_granularity = 8192 — every 8192 rows, an entry is created in the primary index. Balance between index size and search precision
ReplacingMergeTree — For Deduplication
CREATE TABLE user_sessions
(
session_id String,
user_id UInt64,
started_at DateTime,
ended_at DateTime,
page_count UInt16,
total_time_seconds UInt32,
version UInt32
)
ENGINE = ReplacingMergeTree(version)
PARTITION BY toYYYYMM(started_at)
ORDER BY (session_id);
ReplacingMergeTree keeps only the row with the maximum version for each unique key (ORDER BY) during merges. This solves the duplication problem on repeated inserts.
Important nuance: deduplication happens during background merges, not at INSERT time. Until a merge occurs, duplicates are visible. For guaranteed deduplication at read time — use FINAL:
-- Guaranteed deduplication
SELECT * FROM user_sessions FINAL WHERE user_id = 12345;
-- Or via subquery (faster on large volumes)
SELECT * FROM user_sessions
WHERE (session_id, version) IN (
SELECT session_id, max(version)
FROM user_sessions
GROUP BY session_id
);
AggregatingMergeTree — Pre-aggregation
CREATE TABLE daily_metrics
(
date Date,
page String,
views AggregateFunction(count, UInt64),
unique_users AggregateFunction(uniq, UInt64),
avg_load_time AggregateFunction(avg, Float32),
bounce_rate AggregateFunction(avg, UInt8)
)
ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (date, page);
AggregatingMergeTree stores intermediate states of aggregate functions. During merges — it automatically combines them. Result: queries against aggregated data run instantly, even on billions of source events.
SummingMergeTree — For Counters
CREATE TABLE hourly_counters
(
hour DateTime,
endpoint String,
requests UInt64,
errors UInt64,
total_duration_ms UInt64
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMMDD(hour)
ORDER BY (hour, endpoint);
During merges, rows with the same key are summed. Perfect for counters: just insert (now(), '/api/users', 1, 0, 45) on each request — ClickHouse aggregates automatically.
Data Types: The Right Choice Saves 10x
Numeric Types
-- Bad: everything as UInt64
user_id UInt64, -- 8 bytes
age UInt64, -- 8 bytes (max 255 — why 8 bytes?)
is_active UInt64, -- 8 bytes (0 or 1 — why 8 bytes?)
-- Good: appropriate types
user_id UInt64, -- 8 bytes — IDs can be large
age UInt8, -- 1 byte — sufficient up to 255
is_active UInt8, -- 1 byte — boolean via UInt8
http_status UInt16, -- 2 bytes — codes 100-599
price Decimal64(2), -- 8 bytes — exact arithmetic for money
In a columnar database, every byte saved per value is multiplied by the number of rows. 100M rows × 7 saved bytes = 700MB less on disk and in memory.
String Types
-- FixedString for fixed-length data
country_code FixedString(2), -- 'US', 'RU', 'DE' — always 2 bytes
currency FixedString(3), -- 'USD', 'EUR', 'RUB'
ip_v4 IPv4, -- 4 bytes instead of ~15 for string
-- LowCardinality for repeating values
browser LowCardinality(String), -- 'Chrome', 'Firefox', 'Safari' — dictionary encoding
os LowCardinality(String), -- 'Windows', 'macOS', 'Linux'
device_type LowCardinality(String),
-- Enum for known value sets
status Enum8('active' = 1, 'inactive' = 2, 'banned' = 3),
LowCardinality is one of the most powerful techniques. For a browser column, ClickHouse creates a dictionary: {1: 'Chrome', 2: 'Firefox', 3: 'Safari'} — and stores only numeric IDs. On 100M rows, this saves 5-10x compared to plain String.
Dates and Time
event_date Date, -- 2 bytes — date only
event_time DateTime, -- 4 bytes — second precision
event_time_ms DateTime64(3), -- 8 bytes — millisecond precision
event_time_us DateTime64(6), -- 8 bytes — microsecond precision
Use Date for partitioning and day-level filtering, DateTime for regular timestamps, DateTime64(3) only when milliseconds matter (performance monitoring).
Materialized Views — Automatic Aggregation
The Concept
A Materialized View in ClickHouse is a trigger on INSERT. When data arrives in the source table, the materialized view automatically transforms and inserts it into the target table.
-- Source table: raw events
CREATE TABLE raw_events
(
event_time DateTime,
event_type LowCardinality(String),
user_id UInt64,
page String,
duration_ms UInt32
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id);
-- Target table: hourly aggregated metrics
CREATE TABLE hourly_page_stats
(
hour DateTime,
page String,
views UInt64,
unique_users UInt64,
avg_duration Float32,
max_duration UInt32
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (hour, page);
-- Materialized View: automatic aggregation
CREATE MATERIALIZED VIEW mv_hourly_page_stats
TO hourly_page_stats
AS
SELECT
toStartOfHour(event_time) AS hour,
page,
count() AS views,
uniqExact(user_id) AS unique_users,
avg(duration_ms) AS avg_duration,
max(duration_ms) AS max_duration
FROM raw_events
WHERE event_type = 'page_view'
GROUP BY hour, page;
Now on INSERT INTO raw_events, data is automatically aggregated into hourly_page_stats. Querying the aggregated table takes milliseconds instead of seconds.
Cascading Materialized Views
-- Level 1: hourly aggregation (from raw_events)
-- (already created above)
-- Level 2: daily aggregation (from hourly_page_stats)
CREATE TABLE daily_page_stats
(
date Date,
page String,
views UInt64,
unique_users UInt64,
avg_duration Float32
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (date, page);
CREATE MATERIALIZED VIEW mv_daily_page_stats
TO daily_page_stats
AS
SELECT
toDate(hour) AS date,
page,
sum(views) AS views,
sum(unique_users) AS unique_users,
avg(avg_duration) AS avg_duration
FROM hourly_page_stats
GROUP BY date, page;
-- Level 3: monthly aggregation (from daily_page_stats)
CREATE TABLE monthly_page_stats
(
month Date,
page String,
views UInt64,
unique_users UInt64
)
ENGINE = SummingMergeTree()
ORDER BY (month, page);
CREATE MATERIALIZED VIEW mv_monthly_page_stats
TO monthly_page_stats
AS
SELECT
toStartOfMonth(date) AS month,
page,
sum(views) AS views,
sum(unique_users) AS unique_users
FROM daily_page_stats
GROUP BY month, page;
Three levels of aggregation: hour → day → month. Each level reads from the previous one. The query "how many views last year" hits monthly_page_stats — 12 rows instead of 500 million.
Conversion Funnel via Materialized View
CREATE TABLE conversion_funnel
(
date Date,
funnel_step LowCardinality(String),
user_count UInt64,
step_order UInt8
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (date, step_order);
CREATE MATERIALIZED VIEW mv_conversion_funnel
TO conversion_funnel
AS
SELECT
toDate(event_time) AS date,
event_type AS funnel_step,
uniqExact(user_id) AS user_count,
CASE event_type
WHEN 'page_view' THEN 1
WHEN 'add_to_cart' THEN 2
WHEN 'checkout_start' THEN 3
WHEN 'payment_complete' THEN 4
END AS step_order
FROM raw_events
WHERE event_type IN ('page_view', 'add_to_cart', 'checkout_start', 'payment_complete')
GROUP BY date, funnel_step, step_order;
Integration with Rails
The clickhouse-activerecord Gem
# Gemfile
gem 'clickhouse-activerecord', '~> 1.0'
# config/database.yml
production:
primary:
adapter: postgresql
database: myapp_production
clickhouse:
adapter: clickhouse
host: localhost
port: 8123
database: analytics
username: app
password: secure_password
debug: false
# app/models/clickhouse_record.rb
class ClickhouseRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :clickhouse, reading: :clickhouse }
end
# app/models/analytics/page_view.rb
module Analytics
class PageView < ClickhouseRecord
self.table_name = 'page_views'
end
end
Direct Client (for Complex Queries)
# app/services/clickhouse_client.rb
class ClickhouseClient
def initialize
@connection = Faraday.new(url: ENV['CLICKHOUSE_URL'] || 'http://localhost:8123') do |f|
f.request :url_encoded
f.adapter Faraday.default_adapter
end
end
def query(sql, format: 'JSON')
response = @connection.post('/', "#{sql} FORMAT #{format}", {
'X-ClickHouse-User' => ENV.fetch('CLICKHOUSE_USER', 'default'),
'X-ClickHouse-Key' => ENV.fetch('CLICKHOUSE_PASSWORD', ''),
'X-ClickHouse-Database' => ENV.fetch('CLICKHOUSE_DB', 'analytics'),
})
raise "ClickHouse error: #{response.body}" unless response.success?
JSON.parse(response.body)
end
def insert(table, rows)
columns = rows.first.keys
values = rows.map { |r| columns.map { |c| format_value(r[c]) }.join(',') }
sql = "INSERT INTO #{table} (#{columns.join(',')}) VALUES #{values.map { |v| "(#{v})" }.join(',')}"
execute(sql)
end
def execute(sql)
response = @connection.post('/', sql, {
'X-ClickHouse-User' => ENV.fetch('CLICKHOUSE_USER', 'default'),
'X-ClickHouse-Key' => ENV.fetch('CLICKHOUSE_PASSWORD', ''),
'X-ClickHouse-Database' => ENV.fetch('CLICKHOUSE_DB', 'analytics'),
})
raise "ClickHouse error: #{response.body}" unless response.success?
true
end
private
def format_value(val)
case val
when String then "'#{val.gsub("'", "\\\\'")}'"
when Time, DateTime then "'#{val.strftime('%Y-%m-%d %H:%M:%S')}'"
when Date then "'#{val.strftime('%Y-%m-%d')}'"
when nil then 'NULL'
when true then '1'
when false then '0'
else val.to_s
end
end
end
ETL: Moving Data from PostgreSQL to ClickHouse
# app/jobs/sync_events_to_clickhouse_job.rb
class SyncEventsToClickhouseJob < ApplicationJob
queue_as :analytics
BATCH_SIZE = 10_000
def perform
last_synced_id = Rails.cache.read('clickhouse:last_event_id') || 0
Event.where('id > ?', last_synced_id)
.find_in_batches(batch_size: BATCH_SIZE) do |batch|
rows = batch.map do |event|
{
event_time: event.created_at,
event_type: event.event_type,
user_id: event.user_id || 0,
session_id: event.session_id || '',
page: event.page || '',
referrer: event.referrer || '',
device_type: event.device_type || 'desktop',
country_code: event.country_code || 'XX',
browser: event.browser || 'Unknown',
duration_ms: event.duration_ms || 0,
}
end
ClickhouseClient.new.insert('raw_events', rows)
Rails.cache.write('clickhouse:last_event_id', batch.last.id)
Rails.logger.info("[ClickHouse Sync] Synced #{batch.size} events, last_id=#{batch.last.id}")
end
end
end
Queries: From Simple to Complex
Basic Analytics Queries
-- Top 10 pages for the last 7 days
SELECT
page,
count() AS views,
uniqExact(user_id) AS unique_users,
round(avg(duration_ms)) AS avg_duration_ms
FROM raw_events
WHERE event_type = 'page_view'
AND event_time >= now() - INTERVAL 7 DAY
GROUP BY page
ORDER BY views DESC
LIMIT 10;
-- Hourly traffic for today
SELECT
toStartOfHour(event_time) AS hour,
count() AS views,
uniqExact(user_id) AS users
FROM raw_events
WHERE event_type = 'page_view'
AND event_date = today()
GROUP BY hour
ORDER BY hour;
-- Device distribution
SELECT
device_type,
count() AS count,
round(count() * 100.0 / sum(count()) OVER (), 2) AS percentage
FROM raw_events
WHERE event_date >= today() - 30
GROUP BY device_type
ORDER BY count DESC;
Cohort Analysis
-- Cohorts by registration week: weekly retention
WITH cohorts AS (
SELECT
user_id,
toMonday(min(event_time)) AS cohort_week
FROM raw_events
WHERE event_type = 'page_view'
GROUP BY user_id
)
SELECT
c.cohort_week,
count(DISTINCT c.user_id) AS cohort_size,
count(DISTINCT IF(
toMonday(e.event_time) = c.cohort_week + INTERVAL 1 WEEK,
e.user_id, NULL
)) AS week_1,
count(DISTINCT IF(
toMonday(e.event_time) = c.cohort_week + INTERVAL 2 WEEK,
e.user_id, NULL
)) AS week_2,
count(DISTINCT IF(
toMonday(e.event_time) = c.cohort_week + INTERVAL 3 WEEK,
e.user_id, NULL
)) AS week_3,
count(DISTINCT IF(
toMonday(e.event_time) = c.cohort_week + INTERVAL 4 WEEK,
e.user_id, NULL
)) AS week_4
FROM cohorts c
LEFT JOIN raw_events e ON c.user_id = e.user_id
WHERE c.cohort_week >= today() - INTERVAL 8 WEEK
GROUP BY c.cohort_week
ORDER BY c.cohort_week;
Conversion Funnel with windowFunnel
-- Built-in funnel function — a killer feature of ClickHouse
SELECT
level,
count() AS users,
round(count() * 100.0 / max(count()) OVER (), 2) AS conversion_rate
FROM (
SELECT
user_id,
windowFunnel(86400)(
event_time,
event_type = 'page_view',
event_type = 'add_to_cart',
event_type = 'checkout_start',
event_type = 'payment_complete'
) AS level
FROM raw_events
WHERE event_date >= today() - 30
GROUP BY user_id
)
GROUP BY level
ORDER BY level;
windowFunnel is one of ClickHouse's killer features. It calculates which funnel step each user reached, accounting for a time window (86400 seconds = 24 hours). One query instead of a series of JOINs.
Moving Averages and Trends
-- 7-day moving average of views
SELECT
date,
views,
round(avg(views) OVER (
ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)) AS moving_avg_7d,
round(views * 100.0 / lagInFrame(views, 7) OVER (ORDER BY date) - 100, 1) AS wow_change_pct
FROM daily_page_stats
WHERE page = '/'
AND date >= today() - 60
ORDER BY date;
User Path Analysis (Sessionization)
-- Most frequent 3-page sequences
SELECT
path,
count() AS frequency
FROM (
SELECT
user_id,
session_id,
arrayStringConcat(
groupArray(page) OVER (
PARTITION BY user_id, session_id
ORDER BY event_time
ROWS BETWEEN CURRENT ROW AND 2 FOLLOWING
),
' → '
) AS path
FROM raw_events
WHERE event_type = 'page_view'
AND event_date >= today() - 7
)
WHERE length(splitByString(' → ', path)) = 3
GROUP BY path
ORDER BY frequency DESC
LIMIT 20;
Performance Optimization
Correct ORDER BY
ORDER BY in ClickHouse isn't about result sorting — it's about physical data layout on disk. It is the primary key.
-- Bad: ORDER BY high-cardinality field first
ORDER BY (user_id, event_date)
-- Query WHERE event_date = '2026-05-01' scans ALL data
-- Good: low-cardinality fields first
ORDER BY (event_date, user_id)
-- Query WHERE event_date = '2026-05-01' reads only one granule
Rule: in ORDER BY, fields you filter on most frequently go first. Usually that's the date.
Skip Indexes
ALTER TABLE raw_events
ADD INDEX idx_url url TYPE bloom_filter(0.01) GRANULARITY 4;
ALTER TABLE raw_events
ADD INDEX idx_country country_code TYPE set(100) GRANULARITY 4;
ALTER TABLE raw_events
ADD INDEX idx_duration duration_ms TYPE minmax GRANULARITY 4;
- bloom_filter — for high-cardinality strings (URL, email). False positive ~1%
- set — for columns with limited value sets (country, browser). Stores the set of unique values per granule
- minmax — for numeric fields. Stores min/max per granule — if the target value is outside the range, the granule is skipped
Projections
ALTER TABLE raw_events
ADD PROJECTION prj_by_user (
SELECT
user_id,
event_type,
event_time,
page,
duration_ms
ORDER BY (user_id, event_time)
);
ALTER TABLE raw_events MATERIALIZE PROJECTION prj_by_user;
A projection is an alternative physical data order. The main table is sorted by (event_date, user_id), the projection by (user_id, event_time). Queries WHERE user_id = X automatically use the projection.
Benchmarks: ClickHouse vs PostgreSQL on Real Queries
All tests on a table with 100M rows, same machine (4 cores, 16GB RAM).
| Query | PostgreSQL | ClickHouse | Difference |
|---|---|---|---|
| COUNT(*) for a month | 4.2s | 0.02s | 210x |
| GROUP BY page, TOP 10 | 8.7s | 0.08s | 109x |
| Unique users for a week | 12.1s | 0.15s | 81x |
| Conversion funnel (4 steps) | 45s+ | 0.3s | 150x+ |
| Cohort analysis (8 weeks) | timeout | 1.2s | ∞ |
| 30-day moving average | 6.3s | 0.05s | 126x |
The difference isn't in percentages — it's in orders of magnitude. On aggregation queries over large volumes, ClickHouse is 100-200x faster.
Why:
- Columnar storage: query
SELECT count(*) WHERE event_date = Xreads only theevent_datecolumn (~2 bytes per row), not all 20 columns - Vectorized execution: data processing with SIMD instructions, in batches of 8192 rows
- Compression: columns with repeating data compress 10-20x (LZ4/ZSTD)
- Sparse index: instead of B-tree — a sparse index with 8192-row granularity. On 100M rows that's ~12K index entries
Streaming Inserts and Buffering
Buffer Table for High Write Frequency
-- Main table
CREATE TABLE events_main (...)
ENGINE = MergeTree() ...;
-- Buffer table
CREATE TABLE events_buffer AS events_main
ENGINE = Buffer(
analytics, -- database
events_main, -- destination table
16, -- num_layers
10, 100, -- min/max time (seconds)
10000, 1000000, -- min/max rows
10000000, 100000000 -- min/max bytes
);
The application writes to events_buffer. ClickHouse accumulates data in memory and periodically flushes to events_main. This removes the overhead of high-frequency small INSERTs.
Async Inserts
-- Server-level setting
SET async_insert = 1;
SET wait_for_async_insert = 0;
SET async_insert_max_data_size = 10000000;
SET async_insert_busy_timeout_ms = 5000;
With async_insert, ClickHouse buffers small inserts and combines them into batches automatically. The client sends one row at a time — ClickHouse inserts in bulk. Perfect for event tracking.
Replication and High Availability
ReplicatedMergeTree + ZooKeeper
CREATE TABLE page_views_replicated ON CLUSTER '{cluster}'
(
event_date Date,
event_time DateTime,
user_id UInt64,
page String
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/page_views', '{replica}')
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, user_id);
For production with high availability — minimum 3 ClickHouse nodes + 3 ZooKeeper nodes (or ClickHouse Keeper — built-in replacement). Data replicates automatically; if a node goes down — reads and writes continue.
For smaller projects (under 1TB of data, under 10K queries/sec) — a single ClickHouse node is sufficient. Backups via clickhouse-backup.
Backup and Recovery
# Install clickhouse-backup
wget https://github.com/Altinity/clickhouse-backup/releases/download/v2.6.0/clickhouse-backup-linux-amd64.tar.gz
tar xzf clickhouse-backup-linux-amd64.tar.gz
sudo mv clickhouse-backup /usr/local/bin/
# Create backup
clickhouse-backup create daily_backup_$(date +%Y%m%d)
# List backups
clickhouse-backup list
# Restore
clickhouse-backup restore daily_backup_20260527
# Upload to S3
clickhouse-backup upload daily_backup_20260527
Monitoring ClickHouse
Prometheus + Grafana
# prometheus.yml
scrape_configs:
- job_name: 'clickhouse'
static_configs:
- targets: ['localhost:9363']
metrics_path: '/metrics'
ClickHouse exposes Prometheus metrics out of the box on port 9363. Key metrics to watch:
# Queries
ClickHouseProfileEvents_Query
ClickHouseProfileEvents_SelectQuery
ClickHouseProfileEvents_InsertQuery
ClickHouseProfileEvents_FailedQuery
# Performance
ClickHouseProfileEvents_RealTimeMicroseconds
ClickHouseProfileEvents_ReadRows
ClickHouseProfileEvents_ReadBytes
# Resources
ClickHouseMetrics_MemoryTracking
ClickHouseMetrics_BackgroundMergesAndMutationsPoolTask
ClickHouseAsyncMetrics_MaxPartCountForPartition
System Tables for Diagnostics
-- Current queries
SELECT query_id, elapsed, query FROM system.processes;
-- Merge health
SELECT
database, table,
count() AS parts,
sum(rows) AS total_rows,
formatReadableSize(sum(bytes_on_disk)) AS size
FROM system.parts
WHERE active
GROUP BY database, table
HAVING parts > 100
ORDER BY parts DESC;
-- Errors in the last hour
SELECT
type,
event_time,
exception_code,
exception,
query
FROM system.query_log
WHERE type = 'ExceptionWhileProcessing'
AND event_time >= now() - INTERVAL 1 HOUR
ORDER BY event_time DESC;
Final ClickHouse Implementation Checklist
Before You Start
- [ ] Identify which data to move: logs, events, metrics — not transactional data
- [ ] Estimate volume: < 10M rows/month — PostgreSQL handles it, > 100M — ClickHouse is justified
- [ ] Choose integration method: clickhouse-activerecord gem or direct HTTP client
- [ ] Design schema with correct types: LowCardinality, Enum, FixedString
Table Design
- [ ] ORDER BY: low-cardinality fields first (date, event type)
- [ ] PARTITION BY: toYYYYMM(date) for most cases
- [ ] TTL: auto-delete old data (12 months for events, 36 for aggregates)
- [ ] Engine: MergeTree for append-only, ReplacingMergeTree for deduplication, SummingMergeTree for counters
Materialized Views
- [ ] Create MV for each aggregation level: hour → day → month
- [ ] Verify MV works: insert test data and query the target table
- [ ] Cascading MVs: verify data flows through all levels
ETL and Synchronization
- [ ] Background job for transferring data from PostgreSQL
- [ ] Idempotency: re-runs don't create duplicates
- [ ] Lag monitoring: difference between last event in PostgreSQL and ClickHouse
Production
- [ ] Backups: daily clickhouse-backup + upload to S3
- [ ] Monitoring: Prometheus + Grafana dashboard for ClickHouse
- [ ] Alerts: too many parts, high memory usage, failed queries
- [ ] Load testing: test with realistic data volumes
- [ ] Documentation: data schema, ETL pipeline, key queries
Comments (0)