Lesson 27 of 129
Wide-Column, Graph, and Time-Series Stores
Three specialized NoSQL families target workloads that general key-value or relational stores handle poorly: very high write rates over partitioned sparse rows (wide-column), queries over relationships (graph), and append-mostly timestamped measurements (time-series).
Wide-column stores
A wide-column store organizes data into tables of rows, where each row is addressed by a key and may hold a very large, variable set of columns. Examples: Google Bigtable, Apache HBase, and Apache Cassandra.
Bigtable model
Bigtable is described as a sparse, distributed, persistent, sorted map:
(row key, column family:column qualifier, timestamp) -> value
- Rows are stored sorted by row key, and the table is split into contiguous row ranges called tablets served by different nodes. Range scans over row keys are efficient.
- Columns are grouped into column families, declared up front. Column qualifiers inside a family are created freely, so rows can have different columns and empty cells cost nothing.
- Each cell can keep several timestamped versions.
- Operations on a single row are atomic. There are no multi-row transactions.
Row key design decides everything: adjacent keys are stored adjacently, so a key like user_id#reversed_timestamp lets a range scan return one user's newest events first, while a leading timestamp concentrates all writes on one tablet.
Cassandra model
Cassandra uses a Bigtable-like data model on a Dynamo-like distribution layer: peer-to-peer nodes, hash partitioning of the partition key, tunable consistency, and no single leader (see consistent hashing and quorums).
A table declares a primary key made of:
- the partition key, hashed to choose the replica nodes, and
- clustering columns, which order rows inside the partition on disk.
CREATE TABLE events_by_user (
user_id uuid,
event_time timestamp,
event_id uuid,
payload text,
PRIMARY KEY ((user_id), event_time, event_id)
) WITH CLUSTERING ORDER BY (event_time DESC, event_id ASC);Here, all events of one user form one partition, sorted newest first. SELECT ... WHERE user_id = ? LIMIT 50 reads a contiguous slice from one node.
flowchart LR
Q["Query: partition key = user 42"] --> H["hash(user 42)"]
H --> N1["Replica node 1"]
H --> N2["Replica node 2"]
H --> N3["Replica node 3"]
N1 --> P["Partition user 42: rows sorted by event_time"]Cassandra rules of thumb:
- One table per query. Tables are designed per access pattern, and data is duplicated between tables instead of joined.
- Queries must include the partition key. Filtering on other columns without it requires secondary indexes or full scans and is discouraged.
- Partitions must stay bounded. Unbounded partitions (a user with years of events in one partition) grow too large. The standard fix is a bucketed partition key such as
(user_id, month). - Writes are fast. Writes append to a commit log and memtable and are flushed as immutable sorted files (see B-tree vs LSM-tree). Deletes write tombstones, markers that are removed only after compaction, so heavy deletion and queue-like usage degrade reads.
Bigtable and Cassandra differ in one main respect: Bigtable serves each tablet from one node with data in a distributed file system and gives strong consistency per row, whereas Cassandra replicates each partition on several nodes and offers tunable consistency.
When to use
Use for high write throughput, large datasets, known query patterns keyed by partition, time-ordered event logs, and multi-datacenter replication. Avoid for ad hoc queries, joins, or workloads needing multi-row transactions.
Graph databases
A graph database stores entities as nodes (vertices) and relationships as edges, both with properties. In the property graph model, edges are typed and directed. Neo4j, Amazon Neptune, and JanusGraph are examples.
graph LR
A["Person: Ana"] -->|FOLLOWS| B["Person: Ben"]
B -->|FOLLOWS| C["Person: Chi"]
A -->|LIKES| P["Post: 17"]
C -->|WROTE| PWhy relationships need dedicated storage
Traversing relationships in a relational database requires a join per hop, and join cost depends on index lookups over large tables. A native graph store keeps, per node, direct references to its adjacent edges (index-free adjacency in Neo4j). Following one edge is a pointer dereference whose cost depends on the number of neighbors visited, not on the total data size.
A friends-of-friends query in Cypher:
MATCH (me:Person {name: "Ana"})-[:FOLLOWS]->(:Person)-[:FOLLOWS]->(fof:Person)
WHERE NOT (me)-[:FOLLOWS]->(fof) AND fof <> me
RETURN fof.name, count(*) AS mutual
ORDER BY mutual DESC LIMIT 10Use cases
- Social graphs and recommendations (friends of friends, shared interests).
- Fraud detection: finding rings of accounts sharing devices, addresses, or cards.
- Knowledge graphs and identity resolution.
- Network and dependency topology, and access-control hierarchies.
- Routing and shortest-path queries.
Limits
- Horizontal partitioning of a graph is hard, because any partitioning cuts edges, and multi-hop traversals across partitions are costly. Many graph databases scale by vertical growth and read replicas.
- Bulk aggregations over all data (analytics) fit a graph database poorly.
- If the queries are one or two hops with a fixed shape, a relational or key-value design with an adjacency table is often sufficient.
Time-series databases
A time series is a sequence of (timestamp, value) points identified by a series (a metric name plus labels, such as cpu_usage{host="a", core="0"}). Examples of stores: InfluxDB, TimescaleDB (a PostgreSQL extension), Prometheus (local storage), and Cassandra or Bigtable used as a backend.
Workload characteristics
- Writes are almost entirely appends of recent data, at high and steady rates.
- Points are rarely updated.
- Reads are range scans over one or more series, often aggregates (average per minute) over recent windows.
- Old data is queried less and less, and eventually discarded.
- The number of distinct series (cardinality) drives index size. The series count is the number of distinct label combinations: 1,000 hosts, 64 cores, and 10 metrics give 640,000 series. Labels with unbounded values, such as request IDs, create a new series per value and degrade the database.
Storage techniques
| Technique | Purpose |
|---|---|
| Time partitioning | Data is split into blocks or chunks by time (for example per day). Recent chunks stay hot in memory, and dropping expired data is deleting whole chunks instead of rewriting rows. |
| Series-oriented layout | Points of one series are stored contiguously so scans read sequential data. |
| Delta and delta-of-delta encoding | Timestamps arrive at regular intervals, so the difference between consecutive timestamps is usually constant, and the difference of differences is usually zero, which encodes in very few bits. Example: timestamps 1000, 1010, 1020, 1030 have deltas 10, 10, 10 and delta-of-deltas 0, 0. |
| XOR encoding of values | Consecutive floating-point values are often similar, so the XOR of adjacent values has many zero bits. The Gorilla paper (Facebook) uses delta-of-delta timestamps with XOR values to compress well. |
| Columnar storage and general-purpose compression | Grouping same-type values improves compression ratios. |
| Write-ahead log plus in-memory buffer | Recent points are buffered in memory and flushed as immutable blocks. |
Retention and downsampling
- Retention policy. Raw data is kept for a fixed period (for example, days or weeks), then deleted. Expiry is handled by dropping time partitions.
- Downsampling. Older data is replaced by aggregates at coarser resolution: 10-second points are rolled up into 1-minute or 1-hour points holding
min,max,sum, andcount(from which averages can be recomputed). Rolling 10-second points into 1-minute points stores 6 times fewer points, and into 1-hour points 360 times fewer. A common tiering is raw data for a short period, 1-minute rollups for months, and hourly rollups for years. - Continuous aggregation. Rollups are computed as data arrives, or by scheduled jobs, so dashboards query small precomputed data.
Averages of averages are incorrect when groups have unequal counts, so rollups store sum and count instead of only the mean.
Choosing between the families
| Requirement | Suitable family |
|---|---|
| Massive write throughput, partition-keyed queries, no joins | Wide-column |
| Queries that traverse relationships several hops deep | Graph |
| Timestamped metrics with range aggregates and expiry | Time-series |
| Lookup by key | Key-value |
| Complex relational queries | Relational |
See SQL vs NoSQL, key-value and document stores, and observability for how metrics reach time-series storage.
Interview questions
- How does a Cassandra partition key differ from a clustering column? The partition key is hashed to select the nodes that store the data. Clustering columns order the rows within that partition. Queries must specify the partition key to avoid scanning many nodes.
- Why is a time-series workload a poor fit for a range-partitioned store keyed by timestamp alone? New writes always fall into the latest range, so a single node absorbs all write traffic. Adding a series identifier as the leading key distributes writes.
- Why does downsampling store sum and count instead of the average? Averages cannot be combined correctly across groups of different sizes. Sums and counts can be added and the average recomputed.
- What makes graph traversal cheaper in a graph database than in a relational one? Each node directly references its adjacent edges, so a hop does not require an index lookup or join over a large table. Cost depends on the neighborhood visited.
