Cloud Bigtable
Bigtable is GCP's NoSQL wide-column database — the same technology behind Google Search, Gmail, and Google Maps. Designed for petabytes of data with single-digit millisecond latency.
Architecture
graph TD
classDef client fill:#34495e,stroke:#212f3c,color:#fff
classDef control fill:#8e44ad,stroke:#6c3483,color:#fff
classDef serve fill:#2980b9,stroke:#1f618d,color:#fff
classDef storage fill:#7f8c8d,stroke:#616a6b,color:#fff
CLIENT["Client<br/>HBase API / Cloud Bigtable API"]:::client --> FE["Frontend servers<br/>stateless — route each request<br/>to the tablet server that owns it"]:::serve
subgraph CONTROL["Control plane"]
MASTER["Master node<br/>tablet assignment<br/>load balancing<br/>schema management"]:::control
end
subgraph DATAPLANE["Data plane — per-zone cluster"]
FE --> TABLET["Tablet servers<br/>own contiguous ranges of row keys<br/>serve reads/writes, hold no durable state locally"]:::serve
end
MASTER -.->|"assigns / rebalances<br/>tablets across servers"| TABLET
subgraph COLOSSUSNODE["Colossus — GFS successor, shared durable storage"]
SSTABLE["SSTable files<br/>immutable, sorted"]:::storage
WAL["WAL / commit log<br/>durability before ack"]:::storage
end
TABLET -->|"reads"| SSTABLE
TABLET -->|"writes"| WAL
Key design:
- Data stored in SSTables (sorted string tables) on Colossus — separate from tablet servers
- Tablet servers are stateless — can be replaced, restarted without data loss
- Automatic load balancing: master moves tablets between servers as traffic shifts
A tablet server crashes outright. Why doesn't Bigtable need to replay writes from local disk to recover the tablets it owned?
Data Model — Wide Columns
| Row key | CF:actions | CF:profile |
|---|---|---|
user#123#2024 |
click:1700000000, purchase:1700001000 |
name: "Alice", email: "a@b.com" |
user#456#2024 |
click:1700002000, view:1700003000 |
— |
user#789#2023 |
click:1699000000 |
name: "Bob" |
Concepts:
- Row key — the only index. All queries must use row key prefix. Design it carefully.
- Column family (CF) — group of related columns. Defined at table creation.
CF:actions,CF:profile - Column qualifier — dynamic, can be anything within a CF. Created at write time.
- Cell — value at (row key, column family, column qualifier, timestamp). Multiple versions kept.
Using the table above: can you directly query "find all rows where CF:profile.name = Bob," the way a SQL WHERE clause would?
Row Key Design — Critical for Performance
graph TD
classDef bad fill:#7f1d1d,stroke:#c0392b,color:#fff
classDef good fill:#14532d,stroke:#1e8449,color:#fff
classDef tablet fill:#34495e,stroke:#212f3c,color:#fff
classDef writer fill:#8e44ad,stroke:#6c3483,color:#fff
subgraph BADSPACE["❌ Sequential row key: user_id = 1, 2, 3 ... N"]
BT1["Tablet A<br/>owns range 1–1000"]:::tablet
BT2["Tablet B<br/>owns range 1001–2000"]:::tablet
BT3["Tablet C<br/>owns range 2001–...<br/>always the newest range"]:::bad
W1["Every new write<br/>(highest, never-seen ID)"]:::writer -->|"always lands here"| BT3
end
subgraph GOODSPACE["✅ Salted row key: hash(user_id) % N + user_id"]
GT1["Tablet A<br/>owns hash prefixes 0–5"]:::good
GT2["Tablet B<br/>owns hash prefixes 6–a"]:::good
GT3["Tablet C<br/>owns hash prefixes b–f"]:::good
W2["New writes<br/>(random hash prefix)"]:::writer -->|"spread evenly"| GT1
W2 -->|"spread evenly"| GT2
W2 -->|"spread evenly"| GT3
end
Row key is the single biggest lever on Bigtable performance. All four of these design choices produce the same logical data — the difference is entirely in how the keys are distributed across the row-key space, which is what determines which tablet each write lands on.
1, 2, 3, ... Monotonically increasing IDs always put the newest, highest-numbered row at the top of the key range — which lives on exactly one tablet. Every new write goes to that same tablet no matter how many tablets exist or how often the master rebalances, because rebalancing only moves tablet ownership between servers — it can't stop new writes from concentrating on whichever tablet currently owns the top of the range. Result: one server overwhelmed, the rest idle.
1700000000#event_type. Same failure mode as sequential IDs — a raw Unix timestamp at the front of the key is itself monotonically increasing, so all of "right now's" writes land on whichever tablet owns the most recent time range. Moving the timestamp to after a well-distributed prefix (instead of leading with it) avoids this entirely.
com.example.user#event_type#timestamp instead of www.example.com#.... Reversing a hierarchical identifier like a domain name spreads related-but-distinct prefixes across the row-key space instead of clustering everything under one common leading substring — while a prefix scan for "everything under com.example.*" stays just as efficient as before.
hash(user_id) % N + user_id, or a short hash prefix like a3f2#user:123#timestamp. Prepending a hash-derived prefix scatters writes for the same logical entity (a hot user, a hot device) across N tablets instead of one, and lets reads for different hash buckets run in parallel. Tradeoff: a scan for one specific user now has to fan out across every possible prefix instead of hitting one contiguous range.
These techniques compose rather than compete: user:123#2024-01#event_type (user + time range) scans efficiently by user and month within a single contiguous key range — worth layering a salt on top of it only if a handful of users are so hot that even that unsalted user prefix would still overload one tablet.
The master automatically rebalances tablets across servers as traffic shifts. Does that fix the hotspot caused by sequential, incrementing row keys?
Reads and Writes
sequenceDiagram
participant APP as Application
participant FE as Frontend
participant TS as Tablet Server
participant COL as Colossus
rect rgb(40, 55, 75)
Note over APP,COL: Write path — MutateRow
APP->>FE: MutateRow(row_key, mutations)
FE->>TS: Route to the tablet server that owns this row key
TS->>COL: Append mutation to WAL (durability before ack)
COL-->>TS: WAL append confirmed
TS->>TS: Apply mutation to in-memory MemTable
TS-->>APP: OK — acknowledged once the WAL write is durable
Note over TS,COL: Asynchronous, later: flush MemTable to an immutable SSTable
end
rect rgb(40, 60, 45)
Note over APP,COL: Read path — ReadRow
APP->>FE: ReadRow(row_key)
FE->>TS: Route to the tablet server owning this row key
TS->>TS: Check MemTable for the most recent writes
TS->>COL: Check on-disk SSTables for older versions
COL-->>TS: Matching cells across SSTables
TS->>TS: Merge results, latest timestamp wins
TS-->>APP: Row data
end
MutateRow returns OK to the application. Has that mutation already reached an SSTable on Colossus at that point?
Compaction — LSM Tree
Bigtable uses an LSM (Log-Structured Merge) tree, same as LevelDB/RocksDB:
graph TD
classDef write fill:#34495e,stroke:#212f3c,color:#fff
classDef mem fill:#3498db,stroke:#2471a3,color:#fff
classDef l0 fill:#7f1d1d,stroke:#c0392b,color:#fff
classDef l1 fill:#f39c12,stroke:#ba6018,color:#fff
classDef l2 fill:#14532d,stroke:#1e8449,color:#fff
WRITE["Write<br/>MutateRow"]:::write --> MEM["MemTable (RAM)<br/>sorted, mutable"]:::mem
subgraph COLOSSUS["Colossus — immutable SSTables, organized in levels"]
MEM -->|"minor compaction:<br/>flush when MemTable full"| L0["Level 0 SSTables<br/>small, many, overlapping key ranges<br/>slowest to read — must check every file"]:::l0
L0 -->|"merging compaction:<br/>merge + sort overlapping files"| L1["Level 1 SSTables<br/>larger, fewer, mostly sorted"]:::l1
L1 -->|"major compaction:<br/>merge across all levels"| L2["Level 2+ SSTables<br/>large, non-overlapping<br/>deleted / overwritten cells reclaimed"]:::l2
end
L2 -.->|"a read merges results from<br/>every level that could hold this row key"| READ["Read"]
MutateRow is first appended to the WAL for durability, then applied to the in-memory MemTable — sorted, but not yet durable to Colossus as an SSTable.
Compaction types:
- Minor: flush MemTable to L0 — happens frequently, fast
- Major: merge L0→L1, L1→L2 — reclaims space from deleted/updated cells, improves read performance
Which compaction type actually reclaims disk space from deleted or overwritten cells — minor or major?
Bigtable vs Other Databases
| Bigtable | BigQuery | Spanner | Firestore | |
|---|---|---|---|---|
| Type | Wide-column NoSQL | Data warehouse | NewSQL relational | Document NoSQL |
| Latency | Single-digit ms | Seconds | ~10ms | ~10ms |
| Scale | Petabytes | Petabytes | Petabytes | Terabytes |
| SQL | HBase API only | Full SQL | Full SQL | Limited |
| Transactions | Single-row atomic | No | Full ACID | Optimistic |
| Best for | Time-series, IoT, ML features | Analytics, reporting | Financial, global OLTP | Mobile/web apps |
Of these four, which one gives you full multi-row ACID transactions?
Bigtable vs HBase
Bigtable is API-compatible with Apache HBase. HBase code works with minimal changes.
// HBase / Bigtable Java client
Connection connection = BigtableConfiguration.connect(projectId, instanceId);
Table table = connection.getTable(TableName.valueOf("user-events"));
// Write
Put put = new Put(Bytes.toBytes("user#123#2024"));
put.addColumn(Bytes.toBytes("actions"), Bytes.toBytes("click"), Bytes.toBytes("button1"));
table.put(put);
// Read single row
Get get = new Get(Bytes.toBytes("user#123#2024"));
Result result = table.get(get);
// Scan prefix (all events for user 123)
Scan scan = new Scan();
scan.setRowPrefixFilter(Bytes.toBytes("user#123"));
ResultScanner scanner = table.getScanner(scan);
Monitoring and Optimization
# Check Bigtable metrics in Cloud Monitoring
# Key metrics:
# bigtable.googleapis.com/server/latencies → p99 read/write latency
# bigtable.googleapis.com/server/request_count → QPS by type
# bigtable.googleapis.com/server/error_count → errors by code
# bigtable.googleapis.com/cluster/cpu_load → hotspot indicator (should be <70%)
# bigtable.googleapis.com/cluster/storage_utilization
# Key Visualizer: GCP console tool showing read/write patterns across row key space
# Hotspots appear as bright spots — means row key design needs improvement
Performance tips:
- Use batch mutations for bulk writes (reduces round trips)
- Pre-split table into tablets at creation time for known row key patterns
- Use
ReadModifyWriteRowfor atomic increment/append operations - Set cell versions limit (default is unlimited — old versions waste storage)
# Set max versions per cell family
cbt -project=my-project -instance=my-instance setgcpolicy my-table actions maxversions=1
Replication & App Profiles
A Bigtable instance can have multiple clusters in different zones/regions. Adding a second cluster turns on replication: every write is asynchronously copied to all clusters. Replication is eventually consistent and per-cluster — each cluster has its own nodes and serves reads/writes locally, and there is no cross-cluster consensus. This gives HA, geographic read locality, and workload isolation (e.g. serving vs batch), but a read on cluster B may not yet see a write that just landed on cluster A.
App profiles decide how a client's requests are routed across those clusters:
graph TD
classDef app fill:#34495e,stroke:#212f3c,color:#fff
classDef route fill:#8e44ad,stroke:#6c3483,color:#fff
classDef primary fill:#14532d,stroke:#1e8449,color:#fff
classDef secondary fill:#2980b9,stroke:#1f618d,color:#fff
APP["Client + app profile"]:::app --> ROUTE{"Routing policy"}:::route
ROUTE -->|"single-cluster routing"| C1
ROUTE -->|"multi-cluster routing"| LB{"Nearest available cluster"}:::route
LB --> C1
LB --> C2
subgraph INSTANCE["Bigtable instance"]
C1["Cluster A (primary)<br/>read-your-writes<br/>ReadModifyWriteRow / CheckAndMutateRow safe here"]:::primary
C2["Cluster B<br/>own nodes, serves reads/writes locally"]:::secondary
C1 -.->|"async replication,<br/>eventually consistent"| C2
end
ReadModifyWrite (atomic increment/append) and CheckAndMutate (conditional write) could hit different clusters and race.
ReadModifyWriteRow, CheckAndMutateRow), because those atomic ops must serialize on one cluster. Trade-off: no automatic failover for that profile.
A common pattern: one single-cluster app profile for the transactional/serving path, and one multi-cluster app profile for read-heavy or batch workloads.
# Single-cluster routing profile — needed for conditional / read-modify-write consistency
gcloud bigtable app-profiles create serving-profile \
--instance=my-instance \
--route-to=cluster-a \
--transactional-writes \
--description="Serving path (read-your-writes, single-row txns)"
# Multi-cluster routing profile — HA + auto-failover, eventual consistency
gcloud bigtable app-profiles create batch-profile \
--instance=my-instance \
--route-any \
--description="Batch/analytics reads, nearest cluster"
# cbt equivalent
cbt -project=my-project -instance=my-instance createappprofile my-instance serving-profile \
"Serving path" route-to=cluster-a
An app profile uses multi-cluster routing, and the app calls ReadModifyWriteRow to increment a counter on the same row twice in quick succession. Why is this unsafe?
Autoscaling
Instead of provisioning a fixed node count per cluster, Bigtable can autoscale nodes based on utilization targets. Scaling is per-cluster and node changes are non-disruptive (data lives on Colossus, so nodes just re-own tablets).
- min / max nodes — the bounds Bigtable stays within.
- CPU target utilization — target average CPU load (e.g. 60%); Bigtable adds nodes when CPU exceeds it, removes them when below.
- Storage target utilization — target storage-per-node (e.g. 2560 GB SSD / node); protects against hitting the hard per-node storage limit even when CPU is low. Bigtable scales up to satisfy whichever target (CPU or storage) needs more nodes.
min-nodes high enough to absorb sudden bursts, since scale-up itself is gradual — autoscaling only adds nodes once utilization has already crossed the target, it can't add them ahead of a burst it didn't see coming.
# Enable autoscaling on a cluster (replaces fixed --num-nodes)
gcloud bigtable clusters update cluster-a \
--instance=my-instance \
--autoscaling-min-nodes=3 \
--autoscaling-max-nodes=30 \
--autoscaling-cpu-target=60 \
--autoscaling-storage-target=2560 # GB per node (SSD)
# Revert to manual scaling with a fixed node count
gcloud bigtable clusters update cluster-a \
--instance=my-instance \
--num-nodes=5
Autoscaling is enabled for a bursty workload, with min-nodes set right at the steady-state average node count. Why can a sudden traffic burst still cause a latency spike?