Chapter 15: Time-Series Databases & OLAP
Analytical workloads differ fundamentally from transactional ones. This chapter explores specialized databases for time-series data and analytical processing (OLAP), their storage optimizations, and architectural patterns for real-time analytics.
15.1 OLTP vs OLAP
OLTP (Online Transaction Processing) systems handle operational workloads with many small read/write transactions. OLAP (Online Analytical Processing) systems handle analytical queries scanning large datasets. The fundamental difference drives completely different storage and optimization strategies.
Storage Layout Comparison
| Characteristic | OLTP | OLAP |
|---|---|---|
| Workload | Many small transactions (INSERT, UPDATE, DELETE, point SELECT) | Few large queries scanning millions/billions of rows |
| Query Pattern | Fetch entire rows by primary key or index | Aggregate few columns across many rows (AVG, SUM, COUNT) |
| Storage | Row-oriented (entire row stored together) | Column-oriented (each column stored separately) |
| Write Pattern | Random writes, high write volume, ACID required | Bulk inserts/imports, append-mostly, eventual consistency OK |
| Data Size | GB to TB, latest data | TB to PB, historical data |
| Users | Many concurrent users (thousands) | Fewer analysts (tens to hundreds) |
| Latency | Milliseconds, predictable | Seconds to minutes, variable |
| Indexes | B-trees, hash indexes on keys | Columnar compression, bitmap indexes, skip indexes |
| Schema | Normalized (3NF), minimize redundancy | Denormalized (star/snowflake), optimize for reads |
| Examples | PostgreSQL, MySQL, Oracle, SQL Server | ClickHouse, BigQuery, Snowflake, Redshift |
Why Separate Systems?
- Resource Contention: Heavy analytical queries can starve OLTP transactions of resources
- Different Optimization Goals: Row storage for transactions vs columnar for analytics
- Backup/Recovery: OLTP needs point-in-time recovery; OLAP can rebuild from source
- Schema Design: Normalized for consistency vs denormalized for query speed
- Data Lifecycle: OLTP keeps recent data; OLAP keeps years of history with compression
15.2 Columnar Storage
Columnar databases store each column separately on disk. This enables massive compression (same data types together) and allows reading only needed columns. SIMD/vectorized execution processes entire columns in CPU registers.
Row vs Column Layout
[Row1: A,B,C,D]
[Row2: A,B,C,D]"] D2["Disk Block 2
[Row3: A,B,C,D]
[Row4: A,B,C,D]"] end subgraph "Column-Oriented (Parquet, ClickHouse)" C1["Block 1: Column A
[R1_A, R2_A, R3_A, R4_A]"] C2["Block 2: Column B
[R1_B, R2_B, R3_B, R4_B]"] C3["Block 3: Column C
[R1_C, R2_C, R3_C, R4_C]"] C4["Block 4: Column D
[R1_D, R2_D, R3_D, R4_D]"] end Q1["SELECT * FROM t
WHERE id=3"] --> D1 Q1 --> D2 Q2["SELECT AVG(B)
FROM t"] --> C2 style Q1 fill:#4a90e2 style Q2 fill:#e24a90
Parquet File Structure
Pages: [Page1][Page2]...
Encoding: DICT
Compression: SNAPPY"] CG2["Column Chunk B
Pages: [Page1][Page2]...
Encoding: RLE
Compression: GZIP"] CG3["Column Chunk C
Pages: [Page1][Page2]...
Encoding: PLAIN
Compression: ZSTD"] end F["Footer: Schema + Column Metadata
Min/Max/Nulls per Row Group"] end H1 --> RG1 --> RG2 --> RG3 --> F RG1 -.-> CG1 RG1 -.-> CG2 RG1 -.-> CG3 style F fill:#e2c04a
Benefits of Columnar Storage
| Benefit | Explanation | Example |
|---|---|---|
| I/O Reduction | Read only columns needed, skip irrelevant data | SELECT AVG(salary): read 1 column, not 20 |
| Compression | Same data types compress better (RLE, dictionary) | Status column ['active','active','active'] -> RLE: (active,3) |
| Vectorized Execution | SIMD instructions process arrays of values in parallel | AVX-512: add 16 floats in one CPU instruction |
| Predicate Pushdown | Skip entire column chunks using min/max statistics | WHERE date='2024-01-01': skip chunks with date range outside Jan 2024 |
| Late Materialization | Defer reconstructing rows until last possible moment | Filter on col A, aggregate col B -> never reconstruct full rows |
| Cache Efficiency | CPU cache holds more values (homogeneous data) | 64-byte cache line: 16 ints vs 2-3 rows of mixed types |
Vectorized Execution Example
// Scalar (row-by-row): 1 operation per loop
for (int i = 0; i < n; i++) {
result[i] = array_a[i] + array_b[i];
}
// Vectorized (SIMD): 8 operations per loop (AVX)
for (int i = 0; i < n; i += 8) {
__m256i a = _mm256_loadu_si256(&array_a[i]);
__m256i b = _mm256_loadu_si256(&array_b[i]);
__m256i sum = _mm256_add_epi32(a, b);
_mm256_storeu_si256(&result[i], sum);
}
// 8x faster for integer addition
15.3 Column Compression Techniques
Columnar storage enables specialized compression schemes that exploit patterns in typed data. Compression reduces I/O (read less from disk) and fits more data in memory/cache.
Compression Schemes
Compressed: [(active,3), (inactive,2), (active,1)]"] end subgraph "Dictionary Encoding" D1["Data: [USA, Canada, USA, USA, Mexico, Canada]
Dict: {0:USA, 1:Canada, 2:Mexico}
Encoded: [0,1,0,0,2,1] (2-bit ints)"] end subgraph "Bitmap Encoding (for low cardinality)" B1["Data: [A,B,A,C,B,A]
Bitmap A: 101001
Bitmap B: 010010
Bitmap C: 000100"] end subgraph "Delta Encoding (for sorted/sequential)" DE1["Timestamps: [1000, 1005, 1008, 1012]
Base: 1000, Deltas: [5, 3, 4]
(smaller ints compress better)"] end subgraph "Gorilla (Time-Series)" G1["Values: [1.23, 1.24, 1.23, 1.25]
XOR with previous:
0 -> 1.23 (base)
1 -> XOR(1.24, 1.23) -> few bits differ
Store only differing bits"] end
Compression Comparison
| Scheme | Best For | Compression Ratio | Random Access | Examples |
|---|---|---|---|---|
| RLE | Sorted data, many consecutive duplicates | 10-100x for sorted columns | Good (skip runs) | Status columns, flags, sorted timestamps |
| Dictionary | Low/medium cardinality (thousands of unique values) | 5-20x for strings | Excellent (direct index) | Country, category, user ID |
| Bitmap | Very low cardinality (2-100 unique values) | 8-64x (1 bit per value) | Excellent (bit operations) | Gender, boolean flags, enum types |
| Delta | Monotonic sequences, timestamps | 2-10x (smaller integers) | Good (decode from base) | Auto-increment IDs, sorted timestamps |
| Gorilla | Slowly changing floats/timestamps | 10-20x for metrics | Poor (sequential decode) | CPU usage, temperature, stock prices |
| LZ4/ZSTD | General-purpose (post-encoding) | 2-5x | Block-level | After dictionary/RLE, or for text |
Gorilla Algorithm Detail
Developed by Facebook for time-series compression. Exploits the fact that consecutive float values in metrics change slowly, so XOR produces many zeros.
// Gorilla compression for floats
timestamp: delta-of-delta encoding (most deltas ~same)
1000, 1060, 1120, 1180 -> deltas: 60,60,60 -> delta-of-deltas: 0,0
Store: base=1000, delta=60, then stream of 0s (1 bit each)
value: XOR + variable-bit encoding
12.5, 12.6, 12.5 -> XOR(12.6, 12.5) = few bits differ
Leading zeros: 54, Trailing zeros: 3, Meaningful bits: 7
Store: control bits + 7 bits (not 64 bits)
15.4 Time-Series Databases
Time-series databases (TSDB) optimize for append-heavy workloads with timestamped data. Key features: automatic downsampling, retention policies, time-based aggregations, and specialized compression.
InfluxDB Architecture
cpu,host=server1 value=0.64 1672531200"] subgraph "InfluxDB TSM Engine" WAL["WAL (Write-Ahead Log)
Durability"] Cache["In-Memory Cache
(sorted by time)"] TSM1["TSM File 1
Index: SeriesKey -> BlockOffset
Blocks: compressed time/value pairs
(2h of data)"] TSM2["TSM File 2
(older data)"] TSM3["TSM File 3
(oldest data)"] CQ["Continuous Queries
(auto-downsample)"] RP["Retention Policies
(auto-delete old data)"] end R["Query: InfluxQL / Flux
SELECT MEAN(value)
FROM cpu
WHERE time > now()-1h
GROUP BY time(5m)"] W --> WAL --> Cache Cache --> TSM1 TSM1 --> TSM2 --> TSM3 TSM1 --> CQ --> TSM2 TSM3 --> RP R --> Cache R --> TSM1 R --> TSM2 style WAL fill:#4ae290 style CQ fill:#e2c04a style RP fill:#e24a90
TimescaleDB Architecture
(virtual table)"] C1["Chunk 1
2024-01-01 to 2024-01-07
(1 week of data)
Standard PG table"] C2["Chunk 2
2024-01-08 to 2024-01-14"] C3["Chunk 3
2024-01-15 to 2024-01-22"] C4["Chunk 4
2024-01-23 to 2024-01-29"] CA["Compression
(columnar, per chunk)"] AGG["Continuous Aggregates
(materialized views)"] RET["Retention Policy
(drop old chunks)"] end Q["SELECT * FROM metrics
WHERE time > '2024-01-15'
(query planner routes to chunks)"] HT --> C1 HT --> C2 HT --> C3 HT --> C4 C1 --> CA C2 --> CA HT --> AGG C1 --> RET Q --> HT style HT fill:#4ae290 style AGG fill:#e2c04a
Prometheus Architecture
/metrics endpoint"] T2["App Server 2
/metrics endpoint"] T3["Database
/metrics endpoint"] end subgraph "Prometheus Server" SD["Service Discovery
(Kubernetes, Consul, static)"] SC["Scraper
(HTTP pull every 15s)"] WAL["WAL"] MEM["In-Memory DB
(2h head block)"] B1["Block 1 (2h)
index + chunks
compressed"] B2["Block 2 (2h)"] B3["Block 3 (2h)"] COMP["Compaction
(merge blocks)"] end Q["PromQL Query:
rate(http_requests_total[5m])"] SD --> SC SC --> T1 SC --> T2 SC --> T3 SC --> WAL --> MEM MEM --> B1 B1 --> COMP --> B2 --> B3 Q --> MEM Q --> B1 style SC fill:#4ae290 style COMP fill:#e2c04a
Time-Series Database Comparison
| Database | Architecture | Write Model | Query Language | Strengths | Use Cases |
|---|---|---|---|---|---|
| InfluxDB | TSM engine, LSM-tree like | Push (line protocol) | InfluxQL, Flux | Best compression, continuous queries, easy setup | IoT sensors, application metrics, single-node deployments |
| TimescaleDB | PostgreSQL extension, hypertables with chunks | Push (SQL INSERT) | SQL | Full SQL support, JOINs, geo-spatial, continuous aggregates | Complex queries, existing PG users, relational + time-series |
| QuestDB | Column-oriented, zero-GC Java | Push (InfluxDB protocol, PostgreSQL wire) | SQL (extended with time) | Extremely fast ingestion (3M+ rows/sec), low latency queries | High-frequency trading, real-time dashboards, market data |
| Prometheus | Pull-based scraper, local TSDB with 2h blocks | Pull (scrape /metrics) | PromQL | Service discovery, alerting, ecosystem (Grafana), simple | Kubernetes monitoring, microservices observability, DevOps |
| VictoriaMetrics | Prometheus-compatible, optimized storage | Push/Pull (Prometheus remote write) | PromQL, MetricsQL | Better compression than Prometheus, lower resource usage, clustering | Large-scale Prometheus replacement, cost reduction |
| OpenTSDB | Built on HBase | Push (HTTP/Telnet) | HTTP API | Scales horizontally (HBase), proven at scale | Legacy systems, HBase users, massive scale |
Retention Policies & Downsampling
-- InfluxDB retention policies
CREATE RETENTION POLICY "one_week" ON "mydb"
DURATION 7d REPLICATION 1 DEFAULT;
CREATE RETENTION POLICY "one_year" ON "mydb"
DURATION 52w REPLICATION 1;
-- Continuous query: downsample to 5-minute averages
CREATE CONTINUOUS QUERY "cq_5m" ON "mydb"
BEGIN
SELECT mean(value) INTO "one_year"."downsample"
FROM "one_week"."measurements"
GROUP BY time(5m), *
END
-- TimescaleDB continuous aggregate
CREATE MATERIALIZED VIEW metrics_5m
WITH (timescaledb.continuous) AS
SELECT time_bucket('5 minutes', time) AS bucket,
host,
AVG(cpu_usage) AS avg_cpu
FROM metrics
GROUP BY bucket, host;
15.5 OLAP Databases
OLAP databases excel at analytical queries over large datasets. Modern systems combine columnar storage, vectorized execution, distributed query processing, and intelligent caching.
ClickHouse Architecture
FROM events
WHERE date >= '2024-01-01'
GROUP BY country"] QP["Query Planner
(distributed execution)"] S1["Shard 1"] S2["Shard 2"] S3["Shard 3"] subgraph "Shard 1 Detail" MT1["MergeTree Table
(sorted by date, country)"] P1["Partition: 2024-01
(directory per month)"] P2["Partition: 2024-02"] PART["Part 1 (granule)
Primary Index: sparse
Columns: compressed
min/max marks"] end R1["Replica 1-1"] R2["Replica 1-2"] MV["Materialized View
(auto-updated on insert)"] end Q --> QP QP --> S1 QP --> S2 QP --> S3 S1 --> MT1 MT1 --> P1 --> PART MT1 --> P2 S1 --> R1 S1 --> R2 MT1 --> MV style QP fill:#4ae290 style PART fill:#e2c04a style MV fill:#e24a90
Druid Architecture
(Kafka, Kinesis)"] RT1["Real-Time Node
(recent data, row-oriented)"] BATCH["Batch Ingestion
(S3, HDFS)"] HN["Historical Node
(immutable segments)"] SEG1["Segment 1
2024-01-01 1h
Columnar + Bitmap Indexes
Dictionary Encoded"] SEG2["Segment 2
2024-01-01 2h"] SEG3["Segment 3
2024-01-01 3h"] DEEP["Deep Storage
(S3, HDFS)"] BR["Broker Node
(query router)"] COORD["Coordinator
(segment assignment)"] end Q["Query: Druid SQL"] RT --> RT1 BATCH --> DEEP DEEP --> HN HN --> SEG1 HN --> SEG2 HN --> SEG3 RT1 --> SEG1 Q --> BR BR --> RT1 BR --> HN COORD --> HN style RT1 fill:#4ae290 style SEG1 fill:#e2c04a style BR fill:#e24a90
BigQuery Architecture
Root Server"] M1["Mixer (intermediate agg)"] M2["Mixer"] M3["Mixer"] L1["Leaf Node 1
(read Colossus)"] L2["Leaf Node 2"] L3["Leaf Node 3"] L4["Leaf Node 4"] L5["Leaf Node 5"] L6["Leaf Node 6"] STORE["Colossus (Distributed FS)
Capacitor (columnar format)
Encrypted, replicated"] CACHE["BI Engine Cache
(in-memory, optional)"] SLOT["Slot Allocation
(dynamic, shared pool)"] end Q --> QE QE --> M1 QE --> M2 QE --> M3 M1 --> L1 M1 --> L2 M2 --> L3 M2 --> L4 M3 --> L5 M3 --> L6 L1 --> STORE L2 --> STORE L3 --> STORE L4 --> STORE L5 --> STORE L6 --> STORE STORE --> CACHE SLOT --> QE style QE fill:#4ae290 style STORE fill:#e2c04a style CACHE fill:#e24a90
Snowflake Architecture
(X-Small: 1 node)
Compute Cluster"] VW2["Virtual Warehouse 2
(Medium: 4 nodes)
Compute Cluster"] CS["Cloud Services Layer
Query Optimizer
Metadata
Access Control
Time Travel"] STORE["Storage Layer (S3/Azure)
Micro-partitions (50-500MB)
Columnar, compressed
Immutable"] META["Metadata Store
Min/Max/Bloom per partition
Clustering info"] CACHE["Result Cache
(24h, shared)"] LCACHE["Local Cache
(SSD on compute nodes)"] TT["Time Travel
(7 days default)"] end Q --> CS CS --> VW1 CS --> VW2 VW1 --> STORE VW2 --> STORE STORE --> META CS --> CACHE VW1 --> LCACHE VW2 --> LCACHE STORE --> TT style CS fill:#4ae290 style STORE fill:#e2c04a style CACHE fill:#e24a90
OLAP Database Comparison
| Database | Deployment | Storage | Strengths | Weaknesses | Best For |
|---|---|---|---|---|---|
| ClickHouse | Self-hosted, ClickHouse Cloud | MergeTree (local disk) | Fastest queries, real-time inserts, SQL, low cost | Manual ops, no transactions, limited JOIN | Event analytics, logs, real-time dashboards, cost-sensitive |
| Druid | Self-hosted, Imply Cloud | Segments (deep storage: S3) | Real-time + historical, fast aggregations, high concurrency | Complex setup, limited SQL, no JOINs | Real-time analytics, user-facing dashboards, ad-tech |
| Pinot | Self-hosted, Startree Cloud | Segments (StarTree index) | Low-latency queries (<100ms), upserts, high QPS | Complex setup, limited SQL features | User-facing analytics (LinkedIn feeds, Uber dashboards) |
| BigQuery | Serverless (GCP) | Colossus (Capacitor format) | Zero ops, petabyte scale, ML integration, pay-per-query | Cold start latency, cost unpredictable, vendor lock-in | Data warehousing, ad-hoc analysis, infrequent queries |
| Snowflake | Serverless (AWS/Azure/GCP) | S3/Azure (micro-partitions) | Zero ops, storage/compute separation, time travel, cloning | Cost (always-on warehouses), cold start, vendor lock-in | Data warehousing, BI tools, multi-tenant analytics |
| Redshift | Provisioned clusters (AWS) | Columnar (local + S3) | AWS integration, mature, Postgres-compatible | Slower than modern systems, manual tuning, concurrency limits | Existing AWS users, BI workloads, batch ETL |
15.6 Star Schema & Dimensional Modeling
Star schema is the standard for data warehouses. A central fact table contains measures (metrics), surrounded by dimension tables with descriptive attributes. Optimized for analytical queries with few JOINs.
Star Schema Structure
---------------
sale_id (PK)
date_key (FK)
product_key (FK)
customer_key (FK)
store_key (FK)
---------------
quantity (measure)
revenue (measure)
cost (measure)
profit (measure)"] D1["DIM TABLE: dim_date
----------
date_key (PK)
date
day_of_week
month
quarter
year
is_holiday"] D2["DIM TABLE: dim_product
-----------
product_key (PK)
product_name
category
brand
price"] D3["DIM TABLE: dim_customer
-----------
customer_key (PK)
name
city
state
segment"] D4["DIM TABLE: dim_store
-----------
store_key (PK)
store_name
city
state
region
manager"] end D1 --> F D2 --> F D3 --> F D4 --> F style F fill:#4ae290 style D1 fill:#e2c04a style D2 fill:#e2c04a style D3 fill:#e2c04a style D4 fill:#e2c04a
Star vs Snowflake Schema
product_id
name
category
subcategory
brand"] FS --> DS end subgraph "Snowflake Schema (Normalized)" FSN["Fact: Sales"] DSN["Dim: Product
product_id
name
category_id"] CAT["Dim: Category
category_id
category_name
subcategory_id"] SUB["Dim: Subcategory
subcategory_id
name"] FSN --> DSN --> CAT --> SUB end style DS fill:#4ae290 style DSN fill:#e24a90
Slowly Changing Dimensions (SCD)
Dimension attributes change over time (e.g., customer moves to new city). Strategies to track history:
| Type | Strategy | Example | Pros/Cons |
|---|---|---|---|
| SCD Type 1 | Overwrite old value (no history) | Customer moves: update city='NYC' -> city='LA' | Simple, but loses history |
| SCD Type 2 | Add new row with version/date range | Row 1: city='NYC', valid_from='2020-01-01', valid_to='2023-06-30' Row 2: city='LA', valid_from='2023-07-01', valid_to='9999-12-31' |
Full history, but complex queries (filter by valid_from/to) |
| SCD Type 3 | Add columns for current + previous | city_current='LA', city_previous='NYC' | Simple queries, but limited history (1-2 versions) |
| SCD Type 4 | Separate history table | dim_customer (current), dim_customer_history (past versions) | Clean separation, but JOIN complexity |
SCD Type 2 Example
-- Dimension table with Type 2 tracking
CREATE TABLE dim_customer (
customer_key SERIAL PRIMARY KEY, -- surrogate key
customer_id INT, -- natural key
name VARCHAR(100),
email VARCHAR(100),
city VARCHAR(50),
state VARCHAR(2),
valid_from DATE,
valid_to DATE, -- 9999-12-31 for current
is_current BOOLEAN -- true for current version
);
-- Query: sales in last year, using customer's city at time of sale
SELECT c.city, SUM(f.revenue)
FROM sales_facts f
JOIN dim_customer c
ON f.customer_key = c.customer_key
AND f.sale_date BETWEEN c.valid_from AND c.valid_to
WHERE f.sale_date >= CURRENT_DATE - INTERVAL '1 year'
GROUP BY c.city;
15.7 Materialized Views & Pre-Aggregation
Materialized views store query results physically, trading storage and freshness for query speed. Essential for expensive aggregations (GROUP BY on billions of rows).
Materialized View Architecture
(1 billion rows)
timestamp, user_id, action, value"] end subgraph "Materialized View" MV["events_hourly_agg
(pre-computed)
hour, action, COUNT(*), SUM(value), AVG(value)"] RF["Refresh Strategy"] FULL["Full Refresh
(recompute all)"] INC["Incremental Refresh
(delta only)"] STREAM["Streaming Refresh
(continuous, real-time)"] end Q1["Query: hourly metrics
(fast, uses MV)"] Q2["Query: raw events
(slow, scans base table)"] BT --> MV MV --> RF RF --> FULL RF --> INC RF --> STREAM Q1 --> MV Q2 --> BT style MV fill:#4ae290 style STREAM fill:#e24a90
Refresh Strategies
| Strategy | How It Works | Freshness | Cost | Best For |
|---|---|---|---|---|
| Full Refresh | Drop and rebuild entire view | Minutes to hours stale | High (recompute all) | Small tables, nightly batch jobs |
| Incremental Refresh | Compute only new/changed rows, merge into view | Minutes stale (scheduled) | Medium (compute delta) | Append-only logs, daily/hourly refresh |
| Streaming Refresh | Update view on every insert/update to base table | Real-time (seconds) | Low (per-row overhead) | Dashboards needing real-time data |
| On-Demand Refresh | Refresh when query hits view | Real-time (lazy compute) | Variable (query time penalty) | Rarely accessed views, ad-hoc queries |
Examples in Different Systems
-- PostgreSQL: Manual refresh
CREATE MATERIALIZED VIEW sales_daily AS
SELECT DATE(order_date) AS day, SUM(amount) AS revenue
FROM orders
GROUP BY day;
REFRESH MATERIALIZED VIEW sales_daily; -- manual trigger
-- ClickHouse: Streaming materialized view (auto-updated on insert)
CREATE MATERIALIZED VIEW events_hourly_mv
ENGINE = SummingMergeTree()
ORDER BY (hour, action)
AS SELECT
toStartOfHour(timestamp) AS hour,
action,
count() AS count,
sum(value) AS total_value
FROM events
GROUP BY hour, action;
-- BigQuery: Scheduled refresh
CREATE MATERIALIZED VIEW project.dataset.sales_daily
PARTITION BY day
CLUSTER BY product_id
AS SELECT DATE(order_date) AS day, product_id, SUM(amount) AS revenue
FROM project.dataset.orders
GROUP BY day, product_id;
-- Auto-refresh every 4 hours (BigQuery setting)
-- Snowflake: Automatic refresh (experimental)
CREATE MATERIALIZED VIEW sales_daily AS
SELECT DATE(order_date) AS day, SUM(amount) AS revenue
FROM orders
GROUP BY day;
-- Auto-refresh when base table changes (Snowflake manages)
Cube Pre-Aggregation
For multi-dimensional analytics (OLAP cubes), pre-compute aggregations at multiple granularities.
(date, product, store, revenue)
1 billion rows"] L1A["Level 1a: (date, product)
100M rows"] L1B["Level 1b: (date, store)
50M rows"] L1C["Level 1c: (product, store)
10M rows"] L2A["Level 2a: (date)
1000 rows"] L2B["Level 2b: (product)
10K rows"] L2C["Level 2c: (store)
500 rows"] L3["Level 3: Total
(1 row)"] end L0 --> L1A L0 --> L1B L0 --> L1C L1A --> L2A L1A --> L2B L1B --> L2A L1B --> L2C L1C --> L2B L1C --> L2C L2A --> L3 L2B --> L3 L2C --> L3 style L0 fill:#4ae290 style L3 fill:#e24a90
15.8 Approximate Query Processing
For massive datasets, approximate algorithms provide fast answers with bounded error. Key use cases: COUNT DISTINCT (unique users), top-K (most frequent items), percentiles (P95 latency).
Approximate Algorithms
Each bucket: max leading zeros
Hash each value -> bucket -> update max
Estimate = harmonic mean * constant"] HLL_ERR["Error: 0.81% @ 16KB memory
Exact: 1B unique values = 8GB
HLL: 1B unique values = 16KB (500x smaller)"] end subgraph "Count-Min Sketch (FREQUENCY)" CMS["2D array: d x w (d hash functions)
Increment: hash(item) -> d positions, increment all
Query: min of d positions"] CMS_ERR["Over-estimates (collisions)
Error: +/- (total_count / w) with prob 1-e^(-d)"] end subgraph "T-Digest (PERCENTILES)" TD["Merge clusters of nearby values
Keep more clusters near tails (P99, P1)
Query: interpolate within clusters"] TD_ERR["Error: ~1% for extreme percentiles
Memory: 100-1000 clusters (few KB)"] end subgraph "Reservoir Sampling (RANDOM SAMPLE)" RS["Maintain k samples from stream
For item n: replace random sample with prob k/n
Result: uniform random sample"] RS_ERR["Exact distribution, but sample size k
Error = statistical (sample size)"] end style HLL fill:#4ae290 style CMS fill:#e2c04a style TD fill:#e24a90 style RS fill:#4a90e2
Algorithm Comparison
| Algorithm | Use Case | Memory | Error Bound | Example Query |
|---|---|---|---|---|
| HyperLogLog | COUNT(DISTINCT x) | 1.5 KB (std), 12 KB (++) | 0.81% standard error | Unique visitors, unique IPs |
| Count-Min Sketch | Frequency of item (top-K) | d * w * 4 bytes (e.g., 5 * 10000 * 4 = 200KB) | Over-estimate by epsilon with prob delta | Most common user agents, top URLs |
| T-Digest | Percentiles (P50, P95, P99) | Few KB (100-1000 clusters) | ~1% error at extreme percentiles | P99 latency, P95 response time |
| Quantile Digest | Percentiles (alternative) | Logarithmic in data range | Max rank error (epsilon * N) | Median salary, P90 revenue |
| Reservoir Sampling | Uniform random sample | k * row_size (e.g., 1000 rows) | Statistical (sample size k) | Sample 1000 rows for preview |
| Bloom Filter | Set membership (is X in set?) | m bits (e.g., 10 bits/item) | False positive rate = (1-e^(-kn/m))^k | Has user seen this article? |
HyperLogLog Example
-- PostgreSQL with HLL extension
CREATE EXTENSION hll;
-- Add HLL column to table
ALTER TABLE events ADD COLUMN user_hll hll;
-- Insert: hash user_id into HLL
UPDATE events SET user_hll = hll_add(hll_empty(), hll_hash_text(user_id));
-- Query: approximate COUNT(DISTINCT user_id)
SELECT hll_cardinality(hll_union_agg(user_hll)) AS approx_unique_users
FROM events
WHERE date >= '2024-01-01';
-- Exact (slow): 10 seconds, 1B rows scanned
SELECT COUNT(DISTINCT user_id) FROM events WHERE date >= '2024-01-01';
-- Approximate (fast): 100ms, 1.5KB per HLL aggregate
-- Error: actual=1,000,000, estimate=1,008,234 (0.8% error)
When to Use Approximate Queries
- Real-time dashboards: Users don't need exact counts (1,234,567 vs 1,234,890)
- Large cardinality: COUNT(DISTINCT) on billions of values is slow and memory-intensive
- Streaming data: Compute metrics on infinite streams without storing all data
- Monitoring: P99 latency approximation is acceptable (99.1% vs 99% is fine)
- NOT for financials: Never approximate revenue, invoice totals, payment amounts
15.9 Real-Time Analytics Architectures
Real-time analytics require processing streaming data with low latency. Two main architectures: Lambda (batch + speed layers) and Kappa (stream-only).
Lambda Architecture
(Kafka, Kinesis)"] subgraph "Batch Layer (Accuracy)" BL["Batch Processing
(Spark, Flink batch)"] BV["Batch Views
(HDFS, S3)
Complete, accurate
Updated hourly/daily"] end subgraph "Speed Layer (Latency)" SL["Stream Processing
(Flink, Storm)"] SV["Speed Views
(Redis, Cassandra)
Approximate, fast
Real-time"] end subgraph "Serving Layer" SERVE["Merge batch + speed views
(query-time join)"] API["Query API
(return combined result)"] end end DS --> BL --> BV DS --> SL --> SV BV --> SERVE SV --> SERVE SERVE --> API style BV fill:#4ae290 style SV fill:#e24a90 style SERVE fill:#e2c04a
Kappa Architecture
(Kafka topics)"] SP1["Stream Processing Job v1
(Flink, Kafka Streams)"] SP2["Stream Processing Job v2
(new logic, parallel)"] KS["Kafka: Replayable Log
(retention: weeks/months)
Source of truth"] STORE["Storage
(ClickHouse, Druid)
Single view
Real-time + historical"] API["Query API"] end DS --> KS KS --> SP1 --> STORE KS --> SP2 SP2 -.->|When caught up,
switch over| STORE STORE --> API REPLAY["Reprocess: Deploy v2,
replay Kafka from offset 0,
rebuild state"] KS --> REPLAY style KS fill:#4ae290 style STORE fill:#e2c04a style REPLAY fill:#e24a90
Lambda vs Kappa Comparison
| Aspect | Lambda Architecture | Kappa Architecture |
|---|---|---|
| Complexity | High (two codebases: batch + stream) | Low (single stream processing codebase) |
| Latency | Speed layer: seconds, Batch layer: hours | Seconds (stream processing only) |
| Accuracy | Batch layer provides eventual accuracy | Single view, accuracy depends on stream processing correctness |
| Reprocessing | Easy (re-run batch job on historical data) | Requires replayable log (Kafka), redeploy stream job |
| Storage | Two views: batch (HDFS/S3) + speed (NoSQL) | Single view (OLAP database or stream store) |
| Query | Merge batch + speed views at query time | Query single view directly |
| Use Cases | ML training (batch) + real-time inference, data lake + live dashboard | Event analytics, monitoring, real-time aggregations |
| Examples | Hadoop (batch) + Storm (stream), Spark batch + Flink stream | Kafka + Flink/KStreams -> ClickHouse, Kafka + Druid |
Modern Trend: Unified Batch + Stream
Modern frameworks like Apache Flink and Spark Structured Streaming support both batch and streaming with the same API, reducing Lambda's complexity.
// Flink: Unified batch + stream processing
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// Stream: unbounded source (Kafka)
DataStream<Event> stream = env.addSource(new FlinkKafkaConsumer<>(...));
// Batch: bounded source (S3 files)
DataStream<Event> batch = env.readFile(new TextInputFormat(...), "s3://...");
// Same processing logic for both
stream.keyBy(e -> e.getUserId())
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.aggregate(new CountAggregator())
.addSink(new ClickHouseSink(...));
// Framework handles watermarks, state, exactly-once for both
15.10 Decision Framework: Choosing the Right System
No single database fits all analytical needs. Choose based on latency requirements, data volume, query complexity, and operational constraints.
Decision Flowchart
(user-facing)"| Q7 Q1 -->|"< 1s
(dashboard)"| Q5 Q1 -->|"< 10s
(ad-hoc)"| Q2 Q1 -->|"> 10s OK
(batch)"| Q2 Q7 -->|"Yes
(1000+ QPS)"| PINOT["Apache Pinot
or Druid"] Q7 -->|"No"| Q5 Q5 -->|"Yes
(metrics, logs)"| TSDB["Time-Series DB
InfluxDB, TimescaleDB
Prometheus"] Q5 -->|"No"| Q6 Q6 -->|"Yes
(streaming)"| CLICK["ClickHouse
or Druid"] Q6 -->|"No"| Q2 Q2 -->|"< 1TB"| PG["PostgreSQL
(with columnar extension)"] Q2 -->|"1TB - 100TB"| Q4 Q2 -->|"> 100TB"| CLOUD["Cloud Warehouse
BigQuery, Snowflake"] Q4 -->|"Self-hosted"| Q3 Q4 -->|"Managed/Serverless"| CLOUD Q3 -->|"Simple aggregations"| CLICK Q3 -->|"Complex JOINs"| SNOW["Snowflake
or Redshift"] Q3 -->|"ML integration"| BQ["BigQuery"] style PINOT fill:#4ae290 style TSDB fill:#e2c04a style CLICK fill:#e24a90 style PG fill:#4a90e2 style CLOUD fill:#c04ae2 style SNOW fill:#e2904a style BQ fill:#90e24a
Use Case Recommendations
| Use Case | Recommended System | Rationale |
|---|---|---|
| Application metrics (DevOps monitoring) | Prometheus + VictoriaMetrics | Pull model, service discovery, Grafana integration, alerting |
| IoT sensor data (millions of devices) | InfluxDB or TimescaleDB | High write throughput, downsampling, retention policies |
| Event analytics (clickstream, logs) | ClickHouse | Fast ingestion, real-time queries, SQL, cost-effective |
| User-facing dashboards (product analytics) | Apache Pinot or Druid | Low latency (<100ms), high QPS, pre-aggregated metrics |
| Ad-hoc queries (data science, BI) | BigQuery or Snowflake | Zero ops, petabyte scale, integration with BI tools (Tableau, Looker) |
| Real-time fraud detection | Kafka + Flink + ClickHouse | Stream processing with complex event patterns, fast writes/queries |
| Financial reporting (compliance, auditing) | Snowflake or Redshift | ACID transactions (for updates), time travel, cloning, mature |
| Log aggregation (centralized logging) | ClickHouse or Elasticsearch | ClickHouse: cheaper, faster aggregations. ES: full-text search |
| Data lake analytics (S3/Parquet) | BigQuery (external tables) or Athena | Query S3 directly without loading, pay per query |
| High-frequency trading (market data) | QuestDB | Microsecond latency, zero-GC, specialized for time-series |
Cost Considerations
- Self-Hosted (ClickHouse, TimescaleDB): Lower cost at scale (pay for compute/storage), but operational burden (backups, upgrades, monitoring)
- Serverless (BigQuery, Athena): Pay per query (good for infrequent queries), zero ops, but unpredictable costs for high QPS
- Provisioned (Snowflake, Redshift): Pay for uptime (even idle), predictable costs, but waste if underutilized
- Managed SaaS (Databricks, ClickHouse Cloud): Middle ground (ops handled, but not serverless), typically 2-3x self-hosted cost
Migration Strategy
(1 week of data)
Evaluate query performance"] end subgraph "Phase 2: Dual Write" P2["Write to OLTP + OLAP
(or CDC with Debezium)
Validate data consistency"] end subgraph "Phase 3: Shadow Traffic" P3["Route read-only queries to OLAP
Compare results with OLTP
Measure latency/accuracy"] end subgraph "Phase 4: Cutover" P4["Migrate dashboards/reports to OLAP
Keep OLTP for transactions
ETL historical data"] end P1 --> P2 --> P3 --> P4 style P2 fill:#4ae290 style P3 fill:#e2c04a style P4 fill:#e24a90
Summary
Time-series and OLAP databases optimize for analytical workloads with specialized storage, compression, and query execution. Key takeaways:
- Separate OLTP and OLAP: Different workloads need different storage (row vs columnar)
- Columnar storage: Reads only needed columns, compresses better, enables vectorized execution
- Compression: RLE, dictionary, bitmap, delta, Gorilla achieve 10-100x reduction
- Time-Series DBs: Specialized for metrics/logs with retention policies and downsampling
- OLAP Systems: ClickHouse (fast, self-hosted), BigQuery/Snowflake (serverless), Druid/Pinot (real-time)
- Star Schema: Fact table (measures) + dimension tables (attributes), use SCD Type 2 for history
- Materialized Views: Pre-compute aggregations, trade staleness for speed
- Approximate Queries: HyperLogLog (COUNT DISTINCT), T-Digest (percentiles), Count-Min Sketch (top-K)
- Real-Time Architectures: Kappa (stream-only) simpler than Lambda (batch + stream)
- Choose Based on Needs: Latency, volume, complexity, ops preference