In lesson 02-03 we migrated the tienda database to Cloud SQL and everything fitted nicely: products, orders, customers and stock are textbook relational data. But as the application has been built, things have appeared that fit badly in that model. The shopping cart changes with every click and does not need transactions across several tables. User sessions are read on every request and thrown away after a few days. And Lucía wants to record every click on every product to find out what gets looked at and not bought: that is millions of events a month which, put into PostgreSQL, would grow the database until it choked the part that really matters.
Putting all of that into a relational database is not impossible, but it is forcing the tool. In this lesson you will see why other data models exist, you will understand consistency and the CAP theorem without dogma, you will get to know Firestore, Bigtable, Spanner and Memorystore with real AlpinaShop examples, and you will finish with a decision tree and the documented allocation of which data lives where.
Contents
- Why not everything fits in a relational database
- Consistency and the CAP theorem without dogmatism
- Firestore: a document database
- Firestore at AlpinaShop: cart and sessions
- Bigtable: ordered rows and wide columns
- Bigtable at AlpinaShop: click telemetry
- Spanner: distributed relational
- Memorystore: caching with Redis
- Cross-cutting comparison table
- Decision tree
- AlpinaShop's decision
- Why not everything fits in a relational database
The relational model has been working for fifty years and is still the right answer most of the time. Its virtues are enormous: a universal declarative language, referential integrity, ACID transactions, normalisation that avoids duplicating data and an optimiser that resolves queries you had not anticipated.
Its limits appear on three specific fronts:
- Write scale. PostgreSQL scales vertically. When a single machine can take no more, that is that. Spreading writes across several nodes while maintaining ACID is a very hard problem.
- Data with no fixed schema or heavily nested. A cart with products, quantities, options and promotions can be modelled with five tables and five
JOINs, or stored as a single document that is read in one go. - Extreme volume with very simple access. Billions of events of which you only ever ask "all of them for product X between these two dates" do not need a full SQL engine; they need a key-ordered store that is very fast.
From that come the major data models:
| Model | How it organises the data | Example on GCP | Strong at | Weak at |
|---|---|---|---|---|
| Relational | Tables, rows, columns, relationships | Cloud SQL, AlloyDB | Integrity, complex queries, transactions | Horizontal write scale |
| Document | JSON documents in collections | Firestore | Schema flexibility, reading a whole entity, real time | Aggregations and analytical queries |
| Wide column | Rows ordered by key, with column families | Bigtable | Massive writes and reads by key, low and stable latency | Queries by any field, multi-row transactions |
| Distributed relational | Tables spread across nodes, global transactions | Spanner | Horizontal scale with ACID and SQL | High base cost |
| In-memory key-value | Key-value pairs in RAM | Memorystore (Redis) | Microsecond latency | Volatility, capacity limited by RAM |
| Analytical columnar | Compressed columns, massive processing | BigQuery | Aggregations over billions of rows | One-off reads and writes of a single row |
The term "NoSQL" is misleading: it does not mean "without SQL" — Spanner is pure SQL and Firestore has its own query language — but "not exclusively relational". The useful reading is "Not Only SQL": use the tool that suits each piece of data.
- Consistency and the CAP theorem without dogmatism
The CAP theorem says that a distributed system cannot simultaneously guarantee the following three properties:
- C (Consistency): every read returns the most recent write.
- A (Availability): every request gets a response, even if it is not the most current one.
- P (Partition tolerance): the system carries on working even if the network splits between nodes.
The popular formulation "choose two out of three" is misleading. In a real distributed system network partitioning is not optional: cables get cut, data centres become isolated. So P is always there, and the real choice is what to do during a partition: answer with possibly stale data (AP) or reject the request so as not to lie (CP).
And the nuance that is usually left out: nearly all modern systems are configurable, and the choice is made per operation, not once and for all.
Types of consistency you will come across:
| Type | What it guarantees | Where it turns up |
|---|---|---|
| Strong | Every read sees the last confirmed write | Cloud SQL, Spanner, Firestore (document reads) |
| Eventual | Replicas converge over time; a read may see old data | Cloud SQL read replicas, some distributed queries |
| Session / "read your own writes" | You see your own changes, perhaps not other people's instantly | Common in web applications with caching |
The practical consequences, which are what matter:
- When paying for an order, you want strong consistency. Nobody should buy the last tent twice. Cloud SQL or Spanner.
- On a "127 people are viewing this" counter, eventual consistency is more than enough. Saying 125 for two seconds harms nobody.
- In the cart, you want the user to see their own changes instantly, but it does not matter if another device takes a second to find out.
The right question is never "is this database consistent?", but "what happens if this particular piece of data is a second out of date?". If the answer is "nothing", you have design freedom and far more performance and cost options.
- Firestore: a document database
Firestore stores documents — JSON-like structures — inside collections. A document can contain subcollections, forming a hierarchy.
sesiones/ (collection)
ses_9f3a1c/ (document)
usuario: "cliente_4821"
creada: 2026-08-05T10:14:00Z
ultimo_acceso: 2026-08-05T10:41:00Z
carritos/ (collection)
cli_4821/ (document)
actualizado: 2026-08-05T10:40:12Z
total: 238.90
lineas: [ (array of objects, nested)
{ sku: "MOC-40", nombre: "Trekking Backpack 40L", cantidad: 1, precio: 89.90 },
{ sku: "BOT-GTX", nombre: "Gore-Tex Boots", cantidad: 1, precio: 149.00 }
]
eventos/ (subcollection of the document)
evt_001/ { tipo: "add", sku: "MOC-40", ts: ... }Key characteristics:
- No fixed schema. Two documents in the same collection can have different fields. Flexible and dangerous in equal measure: the discipline is up to you, in the code.
- Strong consistency on document reads, even in the multi-region configuration. It is a notable difference from other document databases.
- ACID transactions across several documents.
- Real time. A client can subscribe to a document or a query and receive changes automatically, without polling.
- Offline mode. The mobile and web SDKs keep a local cache and synchronise when the connection returns.
- Declarative security rules: clients can access the database directly with no intermediate backend, with the rules controlling what each user can read and write.
- Automatic scaling without provisioning anything.
Indexes and queries. Firestore automatically indexes every field, which makes simple queries work with no configuration. But queries with several filters, or a filter plus an ordering, require an explicit composite index. The first time you run a query like that you will get an error... with a direct link for creating the missing index, a very well-judged usability touch.
Limitations you need to know before designing:
- A document cannot exceed 1 MiB. Carts and sessions fit comfortably; a growing history inside a document does not.
- A limit on sustained writes to the same document (on the order of one per second). A very active global counter requires the sharded counters technique.
- There are no
JOINs. The data is denormalised: the product's name and price are duplicated inside the cart line, and that duplication is accepted. - Aggregations are limited. There are
count(),sum()andaverage(), but it is not an analytical database. For that, BigQuery.
Firestore has two modes: Native (the modern one, with real time and offline) and Datastore (compatibility with the old Cloud Datastore). For a new project, always Native.
gcloud firestore databases create \
--location=eur3 \
--type=firestore-native \
--project=alpinashop-prodeur3 is a European multi-region location. For a single region it would be europe-west1. The location is immutable, just as in Cloud Storage.
- Firestore at AlpinaShop: cart and sessions
The cart is a textbook case for Firestore: one entity per user that is read whole, written to frequently, has a nested structure and does not need to be related to anything else at the moment of reading it.
from datetime import datetime, timedelta, timezone
from google.cloud import firestore
db = firestore.Client(database="(default)")
def get_cart(customer_id: str) -> dict:
"""Reads a customer's whole cart in ONE single read."""
doc = db.collection("carritos").document(customer_id).get()
if not doc.exists:
return {"lineas": [], "total": 0.0}
return doc.to_dict()
def add_line(customer_id: str, sku: str, name: str, price: float, quantity: int = 1):
"""Adds a line to the cart inside a transaction."""
ref = db.collection("carritos").document(customer_id)
@firestore.transactional
def _update(transaction, ref):
snapshot = ref.get(transaction=transaction)
data = snapshot.to_dict() if snapshot.exists else {"lineas": []}
# If the SKU is already there, increase the quantity instead of duplicating the line
for line in data["lineas"]:
if line["sku"] == sku:
line["cantidad"] += quantity
break
else:
data["lineas"].append({
"sku": sku,
"nombre": name, # denormalised on purpose: there are no JOINs
"precio": price, # price at the moment of adding
"cantidad": quantity,
})
data["total"] = round(
sum(l["precio"] * l["cantidad"] for l in data["lineas"]), 2
)
data["actualizado"] = firestore.SERVER_TIMESTAMP
transaction.set(ref, data)
_update(db.transaction(), ref)
def empty_cart(customer_id: str):
db.collection("carritos").document(customer_id).delete()Important points in the code:
- The transaction is necessary because we read the cart, modify it and write it back. Without it, two browser tabs adding products at the same time could tread on each other. Firestore retries the transaction automatically if it detects a conflict.
SERVER_TIMESTAMPuses the server's clock, not the client's. Never trust the time on the user's device.- The denormalisation is deliberate. We store
nombreandprecioinside the line. If the price changes tomorrow, the cart keeps the price at which it was added, which is also the correct business behaviour.
Sessions with automatic expiry:
def create_session(session_id: str, customer_id: str, days: int = 30):
"""Creates a session with an expiry marker for Firestore's TTL."""
expires = datetime.now(timezone.utc) + timedelta(days=days)
db.collection("sesiones").document(session_id).set({
"cliente_id": customer_id,
"creada": firestore.SERVER_TIMESTAMP,
"caduca_en": expires, # field configured as TTL
"carrito_items": 0,
})
def abandoned_carts(hours: int = 24):
"""Carts with contents that have not been touched for more than N hours."""
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
query = (
db.collection("carritos")
.where(filter=firestore.FieldFilter("actualizado", "<", cutoff))
.where(filter=firestore.FieldFilter("total", ">", 0))
.order_by("actualizado")
.limit(100)
)
return [(d.id, d.to_dict()) for d in query.stream()]That query with two inequality filters and an ordering requires a composite index. It is declared like this:
gcloud firestore indexes composite create \
--collection-group=carritos \
--field-config=field-path=actualizado,order=ascending \
--field-config=field-path=total,order=ascendingFirestore can additionally delete documents automatically based on a date field, which solves session cleanup without writing any process at all:
Security rules. If the browser or the mobile app access Firestore directly, the rules are the only barrier:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Each customer can only read and write THEIR cart
match /carritos/{clienteId} {
allow read, write: if request.auth != null
&& request.auth.uid == clienteId;
}
// Sessions are not reachable from the client: backend only
match /sesiones/{sesionId} {
allow read, write: if false;
}
// The public catalogue is read-only for everyone
match /catalogo/{producto} {
allow read: if true;
allow write: if false;
}
}
}The if false rule does not block the backend: service accounts with IAM permissions bypass the security rules, which apply only to the client SDKs.
- Bigtable: ordered rows and wide columns
Bigtable is the system Google built for its own search indexes, and the one HBase was built on. Its model is deceptively simple:
- A table is a set of rows ordered lexicographically by their key.
- Each row has column families, and inside each family, arbitrary columns.
- Each cell can store several versions with a timestamp.
- The only efficient way to access data is by row key or by key range.
Row (row key) | family "ev" | family "ctx" --------------------------------|-------------------------------------|------------------ MOC-40#20260805#0942#a7f3 | ev:tipo=vista ev:duracion=8300 | ctx:pais=ES ctx:disp=movil MOC-40#20260805#0943#b1c9 | ev:tipo=add_cart | ctx:pais=ES ctx:disp=movil BOT-GTX#20260805#0944#c2d1 | ev:tipo=vista ev:duracion=2100 | ctx:pais=PT ctx:disp=escritorio
Strengths: consistently single-digit millisecond latency, millions of operations per second, scaling to petabytes, and horizontal growth by adding nodes without stopping the service.
Limitations, just as important: there are no secondary indexes (you can only search by row key), there are no JOINs, there are no transactions across rows (only atomicity within a row) and there is no SQL in the traditional sense.
The row key design is everything. Because rows are stored in order and distributed in contiguous blocks across nodes, the key simultaneously decides which queries will be efficient and whether the load will be spread well.
The problem to avoid is called a hotspot: if the keys are sequential — a timestamp at the front, for example — all the new writes land in the same block, that block goes to a single node, and that node saturates while the rest of the cluster is idle.
| Row key design | Example | Result |
|---|---|---|
| Timestamp at the front | 20260805094215#MOC-40 |
Severe hotspot: all writes to the same node |
| Sequential ID | 0000001, 0000002 |
Severe hotspot: the same problem |
| High-cardinality field first | MOC-40#20260805094215 |
Well distributed, and allows scanning by product |
| Pure hash | a7f3c1b9... |
Perfect distribution, but impossible to scan useful ranges |
| Field prefix + timestamp + random suffix | MOC-40#20260805094215#a7f3 |
Correct distribution and scans by product and date |
The rule sums up like this: start the key with a high-cardinality field that appears in your queries, and add the time afterwards. And design the key from the queries you are going to make, not from the structure of the data: in Bigtable, the access pattern is the schema.
Another frequent trick is to use an inverted timestamp (9999999999 - epoch) when you want the most recent events to come out first in a scan, since the ordering is always ascending.
- Bigtable at AlpinaShop: click telemetry
Lucía wants to know which products get looked at a lot and bought little. That means recording every product page view, every add to cart and every removal: during the campaign, several million events a month. In PostgreSQL that table would grow out of control and compete with the orders for resources.
gcloud services enable bigtable.googleapis.com bigtableadmin.googleapis.com
# Development instance (1 node). In production, PRODUCTION type with >=3 nodes.
gcloud bigtable instances create alpinashop-telemetria \
--display-name="Catalogue telemetry" \
--cluster-config=id=telemetria-c1,zone=europe-west1-b,nodes=1 \
--instance-type=PRODUCTION
gcloud bigtable instances tables create eventos-catalogo \
--instance=alpinashop-telemetria \
--column-families=ev,ctximport uuid
from datetime import datetime, timezone
from google.cloud import bigtable
from google.cloud.bigtable import row_filters
client = bigtable.Client(project="alpinashop-prod", admin=False)
instance = client.instance("alpinashop-telemetria")
table = instance.table("eventos-catalogo")
def record_event(sku: str, event_type: str, country: str, device: str,
duration_ms: int | None = None):
"""
Row key: <sku>#<timestamp>#<random>
- <sku> first: high cardinality, spreads the load and allows
scanning every event of a product with a prefix.
- <timestamp> next: orders chronologically within each product
and allows date ranges to be bounded.
- <random> at the end: avoids collisions between simultaneous events
for the same product.
"""
now = datetime.now(timezone.utc)
key = f"{sku}#{now.strftime('%Y%m%d%H%M%S%f')}#{uuid.uuid4().hex[:6]}"
row = table.direct_row(key)
row.set_cell("ev", "tipo", event_type)
row.set_cell("ctx", "pais", country)
row.set_cell("ctx", "dispositivo", device)
if duration_ms is not None:
row.set_cell("ev", "duracion_ms", str(duration_ms))
row.commit()
def product_events(sku: str, limit: int = 1000):
"""Prefix scan: every event of a product."""
rows = table.read_rows(
start_key=f"{sku}#".encode(),
end_key=f"{sku}$".encode(), # '$' is the character after '#' in ASCII
limit=limit,
)
result = []
for row in rows:
cells = {
f"{fam}:{col.decode()}": col_cells[0].value.decode()
for fam, cols in row.cells.items()
for col, col_cells in cols.items()
}
result.append((row.row_key.decode(), cells))
return result
def product_events_on_day(sku: str, day: str):
"""Range scan: events of a product on a specific day (YYYYMMDD)."""
return table.read_rows(
start_key=f"{sku}#{day}".encode(),
end_key=f"{sku}#{day}999999999999".encode(),
)Note the end_key trick: since the ordering is lexicographic and $ (0x24) comes right after # (0x23) in ASCII, using {sku}$ as the upper bound captures exactly all the keys starting with {sku}#. It is the idiomatic prefix-scan pattern in Bigtable.
Data lifecycle. Bigtable can expire cells automatically by age or by number of versions, which stops the table growing indefinitely:
# Keep the events for 90 days
cbt -instance=alpinashop-telemetria setgcpolicy eventos-catalogo ev maxage=90d
cbt -instance=alpinashop-telemetria setgcpolicy eventos-catalogo ctx maxage=90dWhere Bigtable fits in the data architecture. Bigtable stores and serves the events with minimal latency, but it does not answer "how many views by category were there in October": that is an analytical aggregation and its place is BigQuery. The usual pattern is for the application to write to Bigtable — or publish to the pedidos-nuevos Pub/Sub topic — and for a periodic process to export to BigQuery for the analysis. That whole circuit is module 4.
On cost, with the honesty it deserves: Bigtable bills per node per hour, whether in use or not, plus storage. One node costs on the order of several hundred dollars a month, and production recommends three. It has no free tier and does not scale to zero. For AlpinaShop's current volumes, Bigtable is a premature decision: the same events can be published to Pub/Sub and inserted straight into BigQuery at a far lower cost. We study it here because it is the right service when volume and latency requirements justify it, and because it is worth being able to recognise that moment. In section 11 we will document this decision.
- Spanner: distributed relational
Spanner is Google's answer to a problem that was considered unsolvable: a database that scales horizontally without giving up SQL, ACID transactions or strong consistency, even between continents.
How it does it:
- Automatic sharding of the tables into fragments distributed across nodes, which split and rebalance themselves.
- TrueTime: a time API backed by atomic clocks and GPS in Google's data centres, which bounds clock uncertainty to a few milliseconds and allows transactions to be ordered globally and correctly.
- Synchronous replication across zones and regions through Paxos consensus.
The result: strongly consistent transactions at global scale, with an availability SLA of up to 99.999 % in the multi-region configuration, and standard SQL (with the GoogleSQL dialect or PostgreSQL compatibility).
-- "Interleaving" physically stores the lines next to their order,
-- so that reading an order with its lines does not cross the network between nodes.
CREATE TABLE Pedidos (
PedidoId INT64 NOT NULL,
ClienteId INT64 NOT NULL,
Fecha TIMESTAMP NOT NULL,
Total NUMERIC,
) PRIMARY KEY (PedidoId);
CREATE TABLE LineasPedido (
PedidoId INT64 NOT NULL,
LineaId INT64 NOT NULL,
Sku STRING(32) NOT NULL,
Cantidad INT64 NOT NULL,
Precio NUMERIC,
) PRIMARY KEY (PedidoId, LineaId),
INTERLEAVE IN PARENT Pedidos ON DELETE CASCADE;When it justifies its cost. Spanner is billed by processing units (from 100 PU, roughly a tenth of a node) plus storage, and a multi-region production configuration is substantially more expensive than an equivalent Cloud SQL instance. It is justified when:
- Writes exceed what the largest Cloud SQL or AlloyDB instance can absorb.
- You need active writes in several regions with strong consistency.
- The availability required is five nines and a one-minute failover window is unacceptable.
- You handle financial or global inventory data where an inconsistency has a real and direct cost.
Typical cases: banking, global gaming platforms, multinational inventory, booking systems.
For AlpinaShop, Spanner is oversized, and saying so clearly is part of learning to choose. A Spanish shop with seasonal peaks is several orders of magnitude away from needing it. If the company grew to operate across several continents with shared inventory, that would be the right conversation; and the natural intermediate step would be AlloyDB first, which multiplies performance while keeping PostgreSQL compatibility.
- Memorystore: caching with Redis
Memorystore is managed Redis (and Valkey and Memcached). It is not really a database: it is an in-memory cache with microsecond latency.
gcloud services enable redis.googleapis.com
gcloud redis instances create alpinashop-cache \
--size=1 \
--region=europe-west1 \
--redis-version=redis_7_2 \
--tier=basicThe basic tier is a single node with no replica — appropriate for a cache, where losing the data only means recalculating it; standard adds a replica and automatic failover.
Natural uses at AlpinaShop:
- Catalogue cache. The front page queries the same 50 products thousands of times a day. Caching them for 5 minutes removes almost all of that load from Cloud SQL.
- Sessions. An alternative to Firestore when all that matters is speed and losing them is tolerable.
- Rate limiting. Per-IP counters with automatic expiry.
- Lightweight queues and distributed locks.
import json
import redis
cache = redis.Redis(host="10.0.0.3", port=6379, decode_responses=True)
def featured_products():
"""Cache-aside pattern: check the cache, and if it is not there, go to the database."""
key = "catalogo:destacados"
cached = cache.get(key)
if cached:
return json.loads(cached)
with engine.connect() as conn:
rows = conn.execute(sqlalchemy.text(
"SELECT sku, nombre, precio FROM tienda.productos "
"WHERE destacado = true ORDER BY nombre LIMIT 50"
)).mappings().all()
products = [dict(r) for r in rows]
# ex=300: expires in 5 minutes. The expiry is NOT optional:
# without it, stale data stays forever.
cache.set(key, json.dumps(products, default=str), ex=300)
return products
def invalidate_product(sku: str):
"""When a product changes, invalidate the affected keys."""
cache.delete("catalogo:destacados", f"producto:{sku}")Three warnings about caches, which cause more incidents than you might think:
- Every entry must have an expiry. With no TTL, a stale value can stay indefinitely.
- Invalidation is the hard part. When Marta changes a price, someone has to delete the corresponding key. If not, the shop shows the old price for minutes.
- Memorystore is only reachable by private IP inside your VPC. It requires the client to be on the same network; for App Engine or Cloud Run you need a serverless VPC access connector (03-01).
- Cross-cutting comparison table
| Service | Model | Consistency | Typical latency | Scale | Queries | Relative cost | When to use it |
|---|---|---|---|---|---|---|---|
| Cloud SQL | Relational | Strong | ~1–10 ms | Vertical, up to TB | Full SQL | Low-medium | Transactional data with relationships. The default case |
| AlloyDB | Relational (PostgreSQL) | Strong | ~1–5 ms | Extended vertical + replicas | Full SQL + columnar engine | Medium-high | Cloud SQL falls short without leaving PostgreSQL |
| Spanner | Distributed relational | Strong, global | ~5–15 ms | Horizontal, unlimited | Full SQL | High | Global scale with ACID |
| Firestore | Document | Strong per document | ~10–50 ms | Automatic | Queries on indexed fields; no JOIN |
Low (per operation) | Self-contained entities, real time, offline |
| Bigtable | Wide column | Strong per row | ~2–10 ms, stable | Horizontal, petabytes | By key or range only | High (per node/hour) | Time series and enormous volume with key-based access |
| Memorystore | In-memory key-value | Strong (single node) | <1 ms | Limited by RAM | Redis commands | Medium | Cache, sessions, counters |
| BigQuery | Analytical columnar | Strong | seconds | Horizontal, petabytes | Analytical SQL | Low per query, high if abused | Aggregations over massive volumes (04-01) |
| Cloud Storage | Objects | Strong | ~50–200 ms | Unlimited | By key only | Very low | Files, images, backups (02-02) |
Two readings of this table are worth underlining. First: BigQuery is not an operational database. Its queries take seconds and its billing penalises frequent low-volume reads; never put it in the path of a web request. Second: latency is not everything. Bigtable is faster than Cloud SQL, but if your data is relational, choosing Bigtable means reimplementing by hand what SQL gave you for free.
- Decision tree
graph TD
A[I have a piece of data to store] --> B{Is it a binary file:<br/>image, video, PDF?}
B -->|Yes| C[Cloud Storage]
B -->|No| D{Is it analytics over<br/>large volumes?}
D -->|Yes| E[BigQuery]
D -->|No| F{Can I lose it<br/>with no consequences?}
F -->|Yes| G[Memorystore Redis]
F -->|No| H{Does it have relationships and<br/>need transactions<br/>between entities?}
H -->|Yes| I{Does it fit on a<br/>single machine?}
I -->|Yes| J[Cloud SQL]
I -->|Almost, I need<br/>more performance| K[AlloyDB]
I -->|No: global scale| L[Spanner]
H -->|No| M{Enormous volume with<br/>access only by key<br/>or time range?}
M -->|Yes| N[Bigtable]
M -->|No| O{Self-contained entity,<br/>real time or offline?}
O -->|Yes| P[Firestore]
O -->|No| J
The tree is a guide, not a dogma. Two pieces of advice go with it:
- When in doubt, start with Cloud SQL. It is the most versatile, the best known and the easiest to abandon if you get it wrong. Moving from a relational database to something else is always easier than the other way round.
- Do not use five databases just because you can. Each one adds a technology to operate, monitor, back up and learn. Complexity is paid for every day; the benefit only appears if the problem genuinely justifies it.
- AlpinaShop's decision
With all of the above, this is the documented allocation of AlpinaShop's data:
| Data | Where it lives | Why |
|---|---|---|
| Products, prices, stock | Cloud SQL (alpinashop-pedidos, DB tienda) |
Relational, transactional, queried by many fields, modest volume |
| Orders and order lines | Cloud SQL | ACID transactions indispensable: payment, stock and order must be atomic |
| Customers and addresses | Cloud SQL | Relational, with referential integrity towards orders |
| Shopping cart | Firestore (collection carritos) |
Self-contained entity per customer, very frequent writes, nested structure, no need for JOINs. It also allows real-time synchronisation between devices |
| User sessions | Firestore (collection sesiones, with TTL) |
High turnover, automatic expiry with no cleanup process, and they do not clutter the transactional database |
| Cached front page catalogue | Memorystore (alpinashop-cache) |
The same 50 products thousands of times a day; a 5-minute TTL removes that load from Cloud SQL |
| Product images | Cloud Storage (alpinashop-catalogo) |
60 GB of binaries; they are not database data (02-02) |
| Click telemetry | Pub/Sub → BigQuery today; Bigtable when volume demands it | Medium volume and analytical, not operational, queries. Bigtable bills per node 24×7 and is not justified today |
| Reports and analytics | BigQuery (alpinashop_analitica) |
Aggregations over history; frees up Cloud SQL and Lucía's replica (module 4) |
And what we do not use, with the reason why, because rejecting things on good grounds is as valuable as choosing:
- Spanner: oversized by several orders of magnitude. A Spanish shop does not need global transactions across continents. If transactional scale ever became tight, the previous step would be AlloyDB.
- Bigtable, for now: the right service for the problem, but premature for the current volume. Its per-node, per-hour billing, with no scaling to zero, is not offset by a few million events a month that BigQuery absorbs without breaking a sweat. The decision will be revisited when the telemetry grows by an order of magnitude or when individual events need reading with millisecond latency.
This is exactly the kind of decision that has to be documented and dated: not because it will not change, but so that a year from now it is understood why it was taken and on what evidence.
Common Mistakes and Tips
- Choosing NoSQL "because it scales" without needing it. Most applications never reach the limit of a well-sized Cloud SQL instance.
- Modelling Firestore as if it were relational. With no
JOINs, making ten chained reads to compose a screen is slow and expensive. Denormalise. - Designing a Bigtable row key with the timestamp at the front. Guaranteed hotspot: all writes to the same node.
- Using a pure hash as the row key. It spreads well, but you lose the ability to scan ranges, which is the reason for using Bigtable.
- Putting BigQuery in the path of a web request. Latency of seconds and billing by data scanned.
- Caching without a TTL. Eternally stale data.
- Forgetting to invalidate the cache when a price changes. The shop shows old prices.
- Trusting the client's clock. Use
SERVER_TIMESTAMPin Firestore and server-side timestamps generally. - Exceeding 1 MiB in a Firestore document by accumulating history inside it. Use subcollections.
- Leaving a Bigtable instance running "for testing". It bills per node per hour and does not scale to zero.
- Tip: always start with the relational database and move only when you have a measured reason.
- Tip: design the Bigtable schema from the queries, never the other way round.
- Tip: create Firestore's composite indexes from the link the error gives you. It is the fastest and most reliable route.
- Tip: enable TTL in Firestore for everything that expires; it saves you writing and maintaining a cleanup process.
- Tip: document and date the decision about which data goes to which database. Your future self will thank you.
Exercises
Exercise 1: choosing the right database
For each piece of AlpinaShop data, state the service, a reasonable alternative and a two-line justification:
- The price history of each product, for auditing changes (a few hundred rows a month).
- Customer ratings and reviews, with free text, associated photos and nested replies.
- The GPS position of the delivery drivers during a round, one reading every 5 seconds per driver.
- The number of times the front page has been viewed today, shown on the internal panel.
- The PDF invoices generated for each order.
Exercise 2: Firestore, cart and queries
- Create a Firestore database in Native mode.
- Write Python functions to add a line to the cart, change the quantity of a line and empty the cart, using a transaction where appropriate.
- Write a query that returns the carts worth more than €100 with no activity in the last 48 hours.
- Explain what composite index that query needs and how you would create it.
- Write the security rule that lets each customer access only their cart and lets nobody read the sessions.
Exercise 3: designing a Bigtable row key
AlpinaShop wants to record in Bigtable, later on, the internal search engine's searches: term searched, number of results, whether there was a click, country and device. The queries planned are: (a) all the searches for a term within a date range; (b) the searches on a specific day, for export.
- Propose a row key for query (a) and justify it.
- Explain why that key does not work well for query (b) and what you would do about it.
- Identify a key design that would produce hotspots and explain the mechanism.
- Define the column families and which columns would go in each one.
- State what expiry policy you would apply and why.
Solutions
Solution 1
| Data | Service | Alternative | Justification |
|---|---|---|---|
| 1. Price history | Cloud SQL | BigQuery if it grew a lot | Minimal volume, related to productos, queried with JOINs and by date range. There is no reason at all to take it out of the relational database |
| 2. Ratings and reviews | Firestore (photos in Cloud Storage) | Cloud SQL with JSONB columns | Nested, variable structure (replies inside reviews), the whole review is read at once, and real time allows replies to be shown instantly |
| 3. Delivery driver GPS position | Bigtable (or Firestore if there are few) | Pub/Sub + BigQuery for the history | A continuously written time series with access by driver and time range: the canonical Bigtable case. With 5 drivers, Firestore is cheaper and sufficient |
| 4. Today's view counter | Memorystore | Firestore with a sharded counter | A counter that is incremented constantly and whose loss is irrelevant: Redis's INCR is exactly the right operation. In Cloud SQL it would be write contention on one row |
| 5. PDF invoices | Cloud Storage | — | They are binary files. Only the object path goes in the database. They also support locked retention for legal obligations (02-02) |
Solution 2
from datetime import datetime, timedelta, timezone
from google.cloud import firestore
db = firestore.Client()
def _recalculate(data: dict) -> dict:
data["total"] = round(sum(l["precio"] * l["cantidad"] for l in data["lineas"]), 2)
data["actualizado"] = firestore.SERVER_TIMESTAMP
return data
def add_line(customer_id, sku, name, price, quantity=1):
ref = db.collection("carritos").document(customer_id)
@firestore.transactional
def _op(tx, ref):
snap = ref.get(transaction=tx)
data = snap.to_dict() if snap.exists else {"lineas": []}
for l in data["lineas"]:
if l["sku"] == sku:
l["cantidad"] += quantity
break
else:
data["lineas"].append(
{"sku": sku, "nombre": name, "precio": price, "cantidad": quantity}
)
tx.set(ref, _recalculate(data))
_op(db.transaction(), ref)
def change_quantity(customer_id, sku, quantity):
ref = db.collection("carritos").document(customer_id)
@firestore.transactional
def _op(tx, ref):
snap = ref.get(transaction=tx)
if not snap.exists:
return
data = snap.to_dict()
if quantity <= 0:
data["lineas"] = [l for l in data["lineas"] if l["sku"] != sku]
else:
for l in data["lineas"]:
if l["sku"] == sku:
l["cantidad"] = quantity
tx.set(ref, _recalculate(data))
_op(db.transaction(), ref)
def empty_cart(customer_id):
# Direct deletion: no transaction needed, it is a single atomic operation
db.collection("carritos").document(customer_id).delete()
def valuable_abandoned_carts():
cutoff = datetime.now(timezone.utc) - timedelta(hours=48)
query = (
db.collection("carritos")
.where(filter=firestore.FieldFilter("total", ">", 100))
.where(filter=firestore.FieldFilter("actualizado", "<", cutoff))
.order_by("total")
.order_by("actualizado")
.limit(50)
)
return [(d.id, d.to_dict()) for d in query.stream()]- The query filters on two different fields and orders by both, so Firestore needs a composite index on
(total, actualizado)in thecarritoscollection. The automatic single-field indexes are not enough when there is more than one inequality filter. When you run the query without the index, Firestore returns aFAILED_PRECONDITIONerror with a link that creates it with the exact definition; it can also be declared like this:
gcloud firestore indexes composite create \
--collection-group=carritos \
--field-config=field-path=total,order=ascending \
--field-config=field-path=actualizado,order=ascending// 5. Security rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /carritos/{clienteId} {
allow read, write: if request.auth != null
&& request.auth.uid == clienteId;
}
match /sesiones/{sesionId} {
allow read, write: if false; // backend only, via a service account
}
}
}Solution 3
- Row key for query (a):
The term goes first because it is high cardinality (thousands of different terms, which spreads the load well across nodes) and because it is the field that appears in the query: scanning from backpack_40l#20260805 to backpack_40l#20260812 returns exactly the searches for that term in that week, reading only contiguous rows. The random suffix avoids collisions between simultaneous searches for the same term.
- That key does not work for query (b) — "all the searches on one day" — because the rows for a given day are scattered throughout the table, spread across all the terms: you would have to scan the whole table and filter. The options are:
- The recommended one: export to BigQuery and do the date-based queries there. Bigtable handles operational access by key; analytics over other dimensions is BigQuery's job.
- Maintain a second table with the key
<date>#<salt>#<term>#<ts>, where<salt>is a number from 0 to N that artificially spreads the load and avoids the hotspot of a date prefix. It doubles the storage and requires writing twice.
- A design that would produce hotspots:
Because rows are stored ordered by key and distributed in contiguous blocks across nodes, all the writes from the same instant share a prefix and land in the same block, managed by a single node. That node saturates while the rest of the cluster is idle: horizontal scaling stops working. It is exactly the same problem as with sequential keys like 0000001, 0000002.
- Column families:
| Family | Columns | Reason |
|---|---|---|
busq |
termino_original, num_resultados, hubo_clic, sku_clic |
Data about the event itself; they are nearly always read together |
ctx |
pais, dispositivo, idioma, sesion_id |
Context; sometimes queried without needing the rest |
Grouping into families the columns that are read together improves performance, because Bigtable stores and retrieves each family independently. It is best to keep few families (ideally fewer than ten) and with short names, since the name is repeated in every stored cell.
- Expiry policy:
maxage=180don both families. Searches have operational value for a few months — spotting terms with no results, tuning the search engine — and after that their value is purely historical, which is already covered by the export to BigQuery, where storage is far cheaper. Automatic expiry stops the table growing without limit and the storage cost rising indefinitely.
cbt -instance=alpinashop-telemetria setgcpolicy busquedas busq maxage=180d
cbt -instance=alpinashop-telemetria setgcpolicy busquedas ctx maxage=180dConclusion
You no longer have a single tool for all your data. You know that the relational model is still the default answer and why — integrity, transactions, SQL, an optimiser that resolves queries you had not anticipated — and also where its three real limits lie: write scale, nested data with no fixed schema and extreme volume with trivial access. You have understood the CAP theorem without the usual dogmatism: network partitioning is not optional, the real choice is what to do during a partition, and in practice it is decided per operation. The useful question is not whether a database is consistent, but what happens if that particular piece of data is a second out of date.
You have got to know Firestore — documents and collections, strong per-document consistency, transactions, real time, offline mode, automatic TTL, security rules and the need for composite indexes — and you have stored AlpinaShop's cart and sessions in it, denormalising on purpose and using transactions where they were needed. You have got to know Bigtable — rows ordered by key, column families, no secondary indexes and no JOINs — and, above all, you have learned that the row key design is the whole design: start with a high-cardinality field, put the time afterwards, avoid the hotspots any sequential key causes, and model from the queries rather than from the data. You have placed Spanner as the answer to a very specific problem — global scale with ACID — and you have seen why it is not for AlpinaShop. You have added Memorystore as a cache with its three rules: always a TTL, invalidation is the hard part, and it is only reachable from the VPC.
The cross-cutting comparison table and the decision tree give you a reusable method, and AlpinaShop's data allocation is a documented and dated decision: Cloud SQL for products, orders and customers; Firestore for the cart and sessions; Memorystore for the front page cache; Cloud Storage for the images; Pub/Sub and BigQuery for the telemetry, with Bigtable explicitly and deliberately rejected for now.
With this, AlpinaShop has its compute solved at four levels and its data across five services. There remains the question that ties it all together and closes the module: faced with a specific workload, which compute service should you choose? In 02-07, How to Choose the Right Compute Service, we will travel the continuum of abstraction from VM to function, compare Compute Engine, GKE Standard and Autopilot, App Engine, Cloud Run and Cloud Functions against measurable criteria, calculate the cost of the same traffic scenario in each one, look at the lift-and-shift, replatform and refactor migration patterns, and document AlpinaShop's definitive architecture, which will serve as the basis for the rest of the course.
Google Cloud Platform (GCP) Course
Module 1: Introduction to Google Cloud Platform
- What is Google Cloud Platform?
- Setting Up Your GCP Account
- A Tour of the GCP Console
- Projects, Resource Hierarchy and Billing
- Regions, Zones and the Shared Responsibility Model
- Cloud Shell and the gcloud CLI
Module 2: Core GCP Services
- Compute Engine: Virtual Machines on Google Cloud
- Cloud Storage: Object Storage
- Cloud SQL: Managed Relational Databases
- App Engine: Platform as a Service
- Google Kubernetes Engine (GKE)
- NoSQL Databases: Firestore, Bigtable and Spanner
- How to Choose the Right Compute Service
Module 3: Networking and Security
- VPC Networks
- Cloud Load Balancing
- Cloud CDN
- Identity and Access Management (IAM)
- Cloud Armor
- Secrets and Encryption: Secret Manager and Cloud KMS
- Cloud DNS, TLS Certificates and Publishing Services Securely
Module 4: Data and Analytics
- BigQuery: The Analytical Data Warehouse
- Cloud Dataflow: Batch and Streaming Data Processing
- Cloud Dataproc: Managed Spark and Hadoop
- Cloud Pub/Sub: Asynchronous Messaging
- Cloud Data Fusion: Code-Free Data Integration
- Orchestrating Pipelines with Cloud Composer and Workflows
- Data Governance and Dashboards with Dataplex and Looker Studio
Module 5: Machine Learning and AI
- Vertex AI: The Machine Learning Platform on GCP
- AutoML: Custom Models Without Writing Code
- TensorFlow on GCP: Training and Serving Models
- Natural Language API
- Vision API
- Generative AI on Vertex AI: Gemini Models and Embeddings
- MLOps: From Model to Product with Vertex AI Pipelines
Module 6: DevOps and Monitoring
- Cloud Build: Continuous Integration on GCP
- Cloud Source Repositories and Source Code Management
- Cloud Functions: Serverless Functions
- Cloud Monitoring (formerly Stackdriver): Metrics, Dashboards and Alerts
- Cloud Deployment Manager and Native Infrastructure as Code
- Cloud Logging and Cloud Trace: Logs, Traces and Diagnostics
- Terraform on GCP: Infrastructure as Code in Practice
Module 7: Advanced GCP Topics
- Hybrid and Multicloud with Anthos
- Serverless Computing with Cloud Run
- Advanced Networking: Shared VPC, Peering and Hybrid Connectivity
- Security Best Practices
- Cost Management and Optimization
- Reliability: SLOs, High Availability and Disaster Recovery
- Governance at Scale: Organization, Policies and Auditing
