En la lección 02-03 migramos la base de datos tienda a Cloud SQL y todo encajó bien: productos, pedidos, clientes y stock son datos relacionales de manual. Pero al construir la aplicación han ido apareciendo cosas que encajan mal en ese modelo. El carrito de la compra cambia en cada clic y no necesita transacciones sobre múltiples tablas. Las sesiones de usuario se leen en cada petición y se descartan a los pocos días. Y Lucía quiere registrar cada clic sobre cada producto para saber qué se mira y no se compra: eso son millones de eventos al mes que, metidos en PostgreSQL, harían crecer la base de datos hasta ahogar la parte que sí importa.
Meter todo eso en una base de datos relacional no es imposible, pero es forzar la herramienta. En esta lección verás por qué existen otros modelos de datos, entenderás la consistencia y el teorema CAP sin dogmas, conocerás Firestore, Bigtable, Spanner y Memorystore con ejemplos reales de AlpinaShop, y terminarás con un árbol de decisión y el reparto documentado de qué dato vive en cada sitio.
Contenido
- Por qué no todo encaja en una relacional
- Consistencia y el teorema CAP sin dogmatismo
- Firestore: base de datos documental
- Firestore en AlpinaShop: carrito y sesiones
- Bigtable: filas ordenadas y columnas anchas
- Bigtable en AlpinaShop: telemetría de clics
- Spanner: relacional distribuida
- Memorystore: caché en Redis
- Tabla comparativa transversal
- Árbol de decisión
- La decisión de AlpinaShop
- Por qué no todo encaja en una relacional
El modelo relacional lleva cincuenta años funcionando y sigue siendo la respuesta correcta la mayoría de las veces. Sus virtudes son enormes: un lenguaje declarativo universal, integridad referencial, transacciones ACID, normalización que evita duplicar datos y un optimizador que resuelve consultas que no habías previsto.
Sus límites aparecen en tres frentes concretos:
- Escala de escritura. PostgreSQL escala verticalmente. Cuando una sola máquina no da más de sí, se acabó. Repartir escrituras entre varios nodos manteniendo ACID es un problema muy difícil.
- Datos sin esquema fijo o muy anidados. Un carrito con productos, cantidades, opciones y promociones puede modelarse con cinco tablas y cinco
JOIN, o guardarse como un único documento que se lee de una vez. - Volumen extremo con acceso muy simple. Miles de millones de eventos de los que solo se consulta "todos los del producto X entre estas dos fechas" no necesitan un motor SQL completo; necesitan un almacén ordenado por clave y muy rápido.
De ahí salen los grandes modelos de datos:
| Modelo | Cómo organiza los datos | Ejemplo en GCP | Fuerte en | Débil en |
|---|---|---|---|---|
| Relacional | Tablas, filas, columnas, relaciones | Cloud SQL, AlloyDB | Integridad, consultas complejas, transacciones | Escala horizontal de escritura |
| Documental | Documentos JSON en colecciones | Firestore | Flexibilidad de esquema, lectura de una entidad completa, tiempo real | Agregaciones y consultas analíticas |
| Columnar ancho | Filas ordenadas por clave, con familias de columnas | Bigtable | Escritura y lectura masivas por clave, latencia baja y estable | Consultas por cualquier campo, transacciones multi-fila |
| Relacional distribuida | Tablas repartidas entre nodos, transacciones globales | Spanner | Escala horizontal con ACID y SQL | Coste base elevado |
| Clave-valor en memoria | Pares clave-valor en RAM | Memorystore (Redis) | Latencia de microsegundos | Volatilidad, capacidad limitada por la RAM |
| Analítico columnar | Columnas comprimidas, procesamiento masivo | BigQuery | Agregaciones sobre miles de millones de filas | Lecturas y escrituras puntuales de una fila |
El término "NoSQL" es engañoso: no significa "sin SQL" —Spanner es SQL puro y Firestore tiene su propio lenguaje de consulta— sino "no exclusivamente relacional". La lectura útil es "Not Only SQL": usa la herramienta que corresponda a cada dato.
- Consistencia y el teorema CAP sin dogmatismo
El teorema CAP dice que un sistema distribuido no puede garantizar simultáneamente las tres propiedades siguientes:
- C (Consistency): toda lectura devuelve la escritura más reciente.
- A (Availability): toda petición recibe respuesta, aunque no sea la más actual.
- P (Partition tolerance): el sistema sigue funcionando aunque la red se parta entre nodos.
La formulación popular "elige dos de tres" es engañosa. En un sistema distribuido real la partición de red no es opcional: los cables se cortan, los centros de datos se aíslan. Por tanto P siempre está, y la elección real es qué hacer durante una partición: responder con datos posiblemente obsoletos (AP) o rechazar la petición para no mentir (CP).
Y el matiz que suele omitirse: casi todos los sistemas modernos son configurables, y la elección se hace por operación, no de una vez para siempre.
Tipos de consistencia que encontrarás:
| Tipo | Qué garantiza | Dónde aparece |
|---|---|---|
| Fuerte | Toda lectura ve la última escritura confirmada | Cloud SQL, Spanner, Firestore (lecturas de documento) |
| Eventual | Las réplicas convergen con el tiempo; una lectura puede ver datos antiguos | Réplicas de lectura de Cloud SQL, algunas consultas distribuidas |
| De sesión / "lee tus propias escrituras" | Ves tus propios cambios, quizá no los de otros al instante | Habitual en aplicaciones web con caché |
Las consecuencias prácticas, que es lo que importa:
- En el pago de un pedido, quieres consistencia fuerte. Nadie debe comprar la última tienda de campaña dos veces. Cloud SQL o Spanner.
- En un contador de "127 personas están viendo esto", la consistencia eventual sobra. Que diga 125 durante dos segundos no daña a nadie.
- En el carrito, quieres que el usuario vea sus propios cambios al instante, pero da igual que otro dispositivo tarde un segundo en enterarse.
La pregunta correcta nunca es "¿esta base de datos es consistente?", sino "¿qué pasa si este dato concreto está desactualizado un segundo?". Si la respuesta es "nada", tienes libertad de diseño y muchas más opciones de rendimiento y coste.
- Firestore: base de datos documental
Firestore guarda documentos —estructuras tipo JSON— dentro de colecciones. Un documento puede contener subcolecciones, formando una jerarquía.
sesiones/ (colección)
ses_9f3a1c/ (documento)
usuario: "cliente_4821"
creada: 2026-08-05T10:14:00Z
ultimo_acceso: 2026-08-05T10:41:00Z
carritos/ (colección)
cli_4821/ (documento)
actualizado: 2026-08-05T10:40:12Z
total: 238.90
lineas: [ (array de objetos, anidado)
{ sku: "MOC-40", nombre: "Mochila Trekking 40L", cantidad: 1, precio: 89.90 },
{ sku: "BOT-GTX", nombre: "Botas Gore-Tex", cantidad: 1, precio: 149.00 }
]
eventos/ (subcolección del documento)
evt_001/ { tipo: "add", sku: "MOC-40", ts: ... }Características clave:
- Sin esquema fijo. Dos documentos de la misma colección pueden tener campos distintos. Flexible y peligroso a partes iguales: la disciplina la pones tú, en el código.
- Consistencia fuerte en las lecturas de documento, incluso en la configuración multirregión. Es una diferencia notable frente a otras bases documentales.
- Transacciones ACID sobre múltiples documentos.
- Tiempo real. Un cliente puede suscribirse a un documento o consulta y recibir cambios automáticamente, sin sondear.
- Modo offline. Los SDK de móvil y web mantienen una caché local y sincronizan al recuperar conexión.
- Reglas de seguridad declarativas: los clientes pueden acceder directamente a la base de datos sin backend intermedio, con las reglas controlando qué puede leer y escribir cada usuario.
- Escala automática sin aprovisionar nada.
Índices y consultas. Firestore indexa automáticamente cada campo, lo que hace que las consultas simples funcionen sin configuración. Pero las consultas con varios filtros o filtro más ordenación requieren un índice compuesto explícito. La primera vez que ejecutes una consulta así obtendrás un error... con un enlace directo para crear el índice que falta, un detalle de usabilidad muy acertado.
Limitaciones que hay que conocer antes de diseñar:
- Un documento no puede superar 1 MiB. Los carritos y sesiones caben de sobra; un historial creciente dentro de un documento, no.
- Límite de escritura sostenida sobre un mismo documento (del orden de una por segundo). Un contador global muy activo requiere la técnica de sharded counters.
- No hay
JOIN. Los datos se desnormalizan: se duplica el nombre y el precio del producto dentro de la línea del carrito, y se asume esa duplicación. - Las agregaciones son limitadas. Hay
count(),sum()yaverage(), pero no es una base analítica. Para eso, BigQuery.
Firestore tiene dos modos: Native (el moderno, con tiempo real y offline) y Datastore (compatibilidad con el antiguo Cloud Datastore). Para un proyecto nuevo, siempre Native.
gcloud firestore databases create \
--location=eur3 \
--type=firestore-native \
--project=alpinashop-prodeur3 es una ubicación multirregión europea. Para una sola región sería europe-west1. La ubicación es inmutable, igual que en Cloud Storage.
- Firestore en AlpinaShop: carrito y sesiones
El carrito es un caso de libro para Firestore: una entidad por usuario que se lee entera, se escribe con frecuencia, tiene estructura anidada y no necesita relacionarse con nada más en el momento de leerla.
from datetime import datetime, timedelta, timezone
from google.cloud import firestore
db = firestore.Client(database="(default)")
def obtener_carrito(cliente_id: str) -> dict:
"""Lee el carrito completo de un cliente en UNA sola lectura."""
doc = db.collection("carritos").document(cliente_id).get()
if not doc.exists:
return {"lineas": [], "total": 0.0}
return doc.to_dict()
def añadir_linea(cliente_id: str, sku: str, nombre: str, precio: float, cantidad: int = 1):
"""Añade una linea al carrito dentro de una transaccion."""
ref = db.collection("carritos").document(cliente_id)
@firestore.transactional
def _actualizar(transaccion, ref):
snapshot = ref.get(transaction=transaccion)
datos = snapshot.to_dict() if snapshot.exists else {"lineas": []}
# Si el SKU ya esta, incrementamos la cantidad en lugar de duplicar la linea
for linea in datos["lineas"]:
if linea["sku"] == sku:
linea["cantidad"] += cantidad
break
else:
datos["lineas"].append({
"sku": sku,
"nombre": nombre, # desnormalizado a proposito: no hay JOIN
"precio": precio, # precio en el momento de añadir
"cantidad": cantidad,
})
datos["total"] = round(
sum(l["precio"] * l["cantidad"] for l in datos["lineas"]), 2
)
datos["actualizado"] = firestore.SERVER_TIMESTAMP
transaccion.set(ref, datos)
_actualizar(db.transaction(), ref)
def vaciar_carrito(cliente_id: str):
db.collection("carritos").document(cliente_id).delete()Puntos importantes del código:
- La transacción es necesaria porque leemos el carrito, lo modificamos y lo volvemos a escribir. Sin ella, dos pestañas del navegador añadiendo productos a la vez podrían pisarse. Firestore reintenta la transacción automáticamente si detecta conflicto.
SERVER_TIMESTAMPusa el reloj del servidor, no el del cliente. Nunca confíes en la hora del dispositivo del usuario.- La desnormalización es deliberada. Guardamos
nombreypreciodentro de la línea. Si el precio cambia mañana, el carrito conserva el precio al que se añadió, que además es el comportamiento correcto de negocio.
Sesiones con caducidad automática:
def crear_sesion(sesion_id: str, cliente_id: str, dias: int = 30):
"""Crea una sesion con marca de caducidad para el TTL de Firestore."""
caduca = datetime.now(timezone.utc) + timedelta(days=dias)
db.collection("sesiones").document(sesion_id).set({
"cliente_id": cliente_id,
"creada": firestore.SERVER_TIMESTAMP,
"caduca_en": caduca, # campo configurado como TTL
"carrito_items": 0,
})
def carritos_abandonados(horas: int = 24):
"""Carritos con contenido que llevan mas de N horas sin tocarse."""
limite = datetime.now(timezone.utc) - timedelta(hours=horas)
consulta = (
db.collection("carritos")
.where(filter=firestore.FieldFilter("actualizado", "<", limite))
.where(filter=firestore.FieldFilter("total", ">", 0))
.order_by("actualizado")
.limit(100)
)
return [(d.id, d.to_dict()) for d in consulta.stream()]Esa consulta con dos filtros de desigualdad y ordenación requiere un índice compuesto. Se declara así:
gcloud firestore indexes composite create \
--collection-group=carritos \
--field-config=field-path=actualizado,order=ascending \
--field-config=field-path=total,order=ascendingFirestore puede además borrar documentos automáticamente según un campo de fecha, lo que resuelve la limpieza de sesiones sin escribir ningún proceso:
Reglas de seguridad. Si el navegador o la app móvil acceden directamente a Firestore, las reglas son la única barrera:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Cada cliente solo puede leer y escribir SU carrito
match /carritos/{clienteId} {
allow read, write: if request.auth != null
&& request.auth.uid == clienteId;
}
// Las sesiones no son accesibles desde el cliente: solo desde el backend
match /sesiones/{sesionId} {
allow read, write: if false;
}
// El catalogo publico es de solo lectura para todos
match /catalogo/{producto} {
allow read: if true;
allow write: if false;
}
}
}La regla if false no bloquea al backend: las cuentas de servicio con permisos IAM omiten las reglas de seguridad, que solo se aplican a los SDK de cliente.
- Bigtable: filas ordenadas y columnas anchas
Bigtable es el sistema que Google creó para sus propios índices de búsqueda, y sobre el que se construyó HBase. Su modelo es engañosamente simple:
- Una tabla es un conjunto de filas ordenadas lexicográficamente por su clave.
- Cada fila tiene familias de columnas, y dentro de cada familia, columnas arbitrarias.
- Cada celda puede guardar varias versiones con marca temporal.
- La única forma eficiente de acceso es por clave de fila o por rango de claves.
Fila (row key) | familia "ev" | familia "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
Fortalezas: latencia de un solo dígito de milisegundos de forma consistente, millones de operaciones por segundo, escala a petabytes, y crecimiento horizontal añadiendo nodos sin parar el servicio.
Limitaciones, igual de importantes: no hay índices secundarios (solo puedes buscar por clave de fila), no hay JOIN, no hay transacciones entre filas (solo atomicidad dentro de una fila) y no hay SQL en el sentido tradicional.
El diseño de la row key lo es todo. Como las filas se almacenan ordenadas y se reparten en bloques contiguos entre nodos, la clave decide simultáneamente qué consultas serán eficientes y si la carga se repartirá bien.
El problema que hay que evitar se llama hotspot: si las claves son secuenciales —una marca temporal al principio, por ejemplo— todas las escrituras nuevas caen en el mismo bloque, ese bloque va a un solo nodo, y ese nodo se satura mientras el resto del clúster está ocioso.
| Diseño de row key | Ejemplo | Resultado |
|---|---|---|
| Timestamp al principio | 20260805094215#MOC-40 |
Hotspot grave: todas las escrituras al mismo nodo |
| ID secuencial | 0000001, 0000002 |
Hotspot grave: mismo problema |
| Campo de alta cardinalidad primero | MOC-40#20260805094215 |
Bien repartido, y permite escanear por producto |
| Hash puro | a7f3c1b9... |
Reparto perfecto, pero imposible escanear por rangos útiles |
| Prefijo de campo + timestamp + sufijo aleatorio | MOC-40#20260805094215#a7f3 |
Reparto correcto y escaneos por producto y fecha |
La regla se resume así: empieza la clave por un campo de alta cardinalidad y que aparezca en tus consultas, y añade el tiempo después. Y diseña la clave a partir de las consultas que vas a hacer, no de la estructura de los datos: en Bigtable, el patrón de acceso es el esquema.
Otro truco frecuente es usar el timestamp invertido (9999999999 - epoch) cuando quieres que los eventos más recientes salgan primero en un escaneo, ya que el orden siempre es ascendente.
- Bigtable en AlpinaShop: telemetría de clics
Lucía quiere saber qué productos se miran mucho y se compran poco. Eso significa registrar cada vista de ficha, cada añadido al carrito y cada eliminación: en campaña, varios millones de eventos al mes. En PostgreSQL esa tabla crecería sin control y competiría con los pedidos por recursos.
gcloud services enable bigtable.googleapis.com bigtableadmin.googleapis.com
# Instancia de desarrollo (1 nodo). En produccion, tipo PRODUCTION con >=3 nodos.
gcloud bigtable instances create alpinashop-telemetria \
--display-name="Telemetria del catalogo" \
--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
cliente = bigtable.Client(project="alpinashop-prod", admin=False)
instancia = cliente.instance("alpinashop-telemetria")
tabla = instancia.table("eventos-catalogo")
def registrar_evento(sku: str, tipo: str, pais: str, dispositivo: str,
duracion_ms: int | None = None):
"""
Row key: <sku>#<timestamp>#<aleatorio>
- <sku> primero: alta cardinalidad, reparte la carga y permite
escanear todos los eventos de un producto con un prefijo.
- <timestamp> despues: ordena cronologicamente dentro de cada producto
y permite acotar rangos de fechas.
- <aleatorio> al final: evita colisiones entre eventos simultaneos
del mismo producto.
"""
ahora = datetime.now(timezone.utc)
clave = f"{sku}#{ahora.strftime('%Y%m%d%H%M%S%f')}#{uuid.uuid4().hex[:6]}"
fila = tabla.direct_row(clave)
fila.set_cell("ev", "tipo", tipo)
fila.set_cell("ctx", "pais", pais)
fila.set_cell("ctx", "dispositivo", dispositivo)
if duracion_ms is not None:
fila.set_cell("ev", "duracion_ms", str(duracion_ms))
fila.commit()
def eventos_de_producto(sku: str, limite: int = 1000):
"""Escaneo por prefijo: todos los eventos de un producto."""
filas = tabla.read_rows(
start_key=f"{sku}#".encode(),
end_key=f"{sku}$".encode(), # '$' es el caracter siguiente a '#' en ASCII
limit=limite,
)
resultado = []
for fila in filas:
celdas = {
f"{fam}:{col.decode()}": celdas_col[0].value.decode()
for fam, cols in fila.cells.items()
for col, celdas_col in cols.items()
}
resultado.append((fila.row_key.decode(), celdas))
return resultado
def eventos_de_producto_en_dia(sku: str, dia: str):
"""Escaneo por rango: eventos de un producto en un dia concreto (YYYYMMDD)."""
return tabla.read_rows(
start_key=f"{sku}#{dia}".encode(),
end_key=f"{sku}#{dia}999999999999".encode(),
)Fíjate en el truco de end_key: como el orden es lexicográfico y $ (0x24) va justo después de # (0x23) en ASCII, usar {sku}$ como límite superior captura exactamente todas las claves que empiezan por {sku}#. Es el patrón idiomático de escaneo por prefijo en Bigtable.
Ciclo de vida de los datos. Bigtable puede caducar celdas automáticamente por edad o por número de versiones, lo que evita que la tabla crezca indefinidamente:
# Conservar los eventos 90 dias
cbt -instance=alpinashop-telemetria setgcpolicy eventos-catalogo ev maxage=90d
cbt -instance=alpinashop-telemetria setgcpolicy eventos-catalogo ctx maxage=90dDónde encaja Bigtable en la arquitectura de datos. Bigtable guarda y sirve los eventos con latencia mínima, pero no responde a "cuántas vistas por categoría hubo en octubre": eso es una agregación analítica y su sitio es BigQuery. El patrón habitual es que la aplicación escriba a Bigtable —o publique en el topic pedidos-nuevos de Pub/Sub— y que un proceso periódico exporte a BigQuery para el análisis. Todo ese circuito es el módulo 4.
Sobre el coste, con la honestidad que merece: Bigtable factura por nodo y hora, esté o no en uso, más el almacenamiento. Un nodo cuesta del orden de varios cientos de dólares al mes, y producción recomienda tres. No tiene nivel gratuito ni escala a cero. Para los volúmenes actuales de AlpinaShop, Bigtable es una decisión prematura: los mismos eventos se pueden publicar en Pub/Sub e insertar directamente en BigQuery a un coste muy inferior. Lo estudiamos aquí porque es el servicio correcto cuando el volumen y la exigencia de latencia lo justifican, y porque conviene saber reconocer ese momento. En el apartado 11 documentaremos esta decisión.
- Spanner: relacional distribuida
Spanner es la respuesta de Google a un problema que se consideraba irresoluble: una base de datos que escale horizontalmente sin renunciar a SQL, a las transacciones ACID ni a la consistencia fuerte, incluso entre continentes.
Cómo lo consigue:
- Reparto automático de las tablas en fragmentos distribuidos entre nodos, que se dividen y reequilibran solos.
- TrueTime: una API de tiempo respaldada por relojes atómicos y GPS en los centros de datos de Google, que acota la incertidumbre del reloj a unos pocos milisegundos y permite ordenar transacciones globalmente de forma correcta.
- Replicación síncrona entre zonas y regiones mediante consenso Paxos.
Resultado: transacciones fuertemente consistentes a escala global, con SLA de disponibilidad de hasta 99,999 % en configuración multirregión, y SQL estándar (con dialecto GoogleSQL o compatibilidad PostgreSQL).
-- El "interleaving" almacena fisicamente las lineas junto a su pedido,
-- de modo que leer un pedido con sus lineas no cruza la red entre nodos.
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;Cuándo justifica su coste. Spanner se factura por processing units (desde 100 PU, aproximadamente la décima parte de un nodo) más almacenamiento, y una configuración de producción multirregión es sustancialmente más cara que una instancia de Cloud SQL equivalente. Se justifica cuando:
- Las escrituras superan lo que puede absorber la mayor instancia de Cloud SQL o AlloyDB.
- Necesitas escritura activa en varias regiones con consistencia fuerte.
- La disponibilidad exigida es de cinco nueves y una ventana de failover de un minuto es inaceptable.
- Manejas datos financieros o de inventario global donde una inconsistencia tiene coste real y directo.
Casos típicos: banca, plataformas de juego globales, inventario multinacional, sistemas de reservas.
Para AlpinaShop, Spanner es sobredimensionado, y decirlo con claridad forma parte de aprender a elegir. Una tienda española con picos estacionales está a varios órdenes de magnitud de necesitarlo. Si la empresa creciera hasta operar en varios continentes con inventario compartido, sería la conversación adecuada; y el paso intermedio natural sería antes AlloyDB, que multiplica el rendimiento manteniendo compatibilidad con PostgreSQL.
- Memorystore: caché en Redis
Memorystore es Redis (y Valkey y Memcached) gestionado. No es realmente una base de datos: es una caché en memoria con latencia de microsegundos.
gcloud services enable redis.googleapis.com
gcloud redis instances create alpinashop-cache \
--size=1 \
--region=europe-west1 \
--redis-version=redis_7_2 \
--tier=basicEl nivel basic es un nodo sin réplica —adecuado para una caché, donde perder los datos solo implica recalcularlos—; standard añade réplica y failover automático.
Usos naturales en AlpinaShop:
- Caché del catálogo. La portada consulta los mismos 50 productos miles de veces al día. Cachearlos 5 minutos elimina casi toda esa carga de Cloud SQL.
- Sesiones. Alternativa a Firestore cuando lo único que importa es la velocidad y se puede tolerar perderlas.
- Limitación de peticiones (rate limiting). Contadores por IP con caducidad automática.
- Colas ligeras y bloqueos distribuidos.
import json
import redis
cache = redis.Redis(host="10.0.0.3", port=6379, decode_responses=True)
def productos_destacados():
"""Patron cache-aside: mirar la cache, y si no esta, ir a la base de datos."""
clave = "catalogo:destacados"
en_cache = cache.get(clave)
if en_cache:
return json.loads(en_cache)
with engine.connect() as conn:
filas = conn.execute(sqlalchemy.text(
"SELECT sku, nombre, precio FROM tienda.productos "
"WHERE destacado = true ORDER BY nombre LIMIT 50"
)).mappings().all()
productos = [dict(f) for f in filas]
# ex=300: caduca en 5 minutos. La caducidad NO es opcional:
# sin ella, los datos obsoletos se quedan para siempre.
cache.set(clave, json.dumps(productos, default=str), ex=300)
return productos
def invalidar_producto(sku: str):
"""Al cambiar un producto, invalidar las claves afectadas."""
cache.delete("catalogo:destacados", f"producto:{sku}")Tres advertencias sobre cachés, que causan más incidentes de los que parece:
- Toda entrada debe tener caducidad. Sin TTL, un dato obsoleto puede quedarse indefinidamente.
- La invalidación es la parte difícil. Cuando Marta cambia un precio, alguien tiene que borrar la clave correspondiente. Si no, la tienda muestra el precio antiguo durante minutos.
- Memorystore solo es accesible por IP privada dentro de tu VPC. Requiere que el cliente esté en la misma red; para App Engine o Cloud Run hace falta un conector de acceso a VPC serverless (03-01).
- Tabla comparativa transversal
| Servicio | Modelo | Consistencia | Latencia típica | Escala | Consultas | Coste relativo | Cuándo usarla |
|---|---|---|---|---|---|---|---|
| Cloud SQL | Relacional | Fuerte | ~1–10 ms | Vertical, hasta TB | SQL completo | Bajo-medio | Datos transaccionales con relaciones. El caso por defecto |
| AlloyDB | Relacional (PostgreSQL) | Fuerte | ~1–5 ms | Vertical ampliada + réplicas | SQL completo + motor columnar | Medio-alto | Cloud SQL se queda corto sin salir de PostgreSQL |
| Spanner | Relacional distribuida | Fuerte, global | ~5–15 ms | Horizontal, ilimitada | SQL completo | Alto | Escala global con ACID |
| Firestore | Documental | Fuerte por documento | ~10–50 ms | Automática | Consultas por campos indexados; sin JOIN |
Bajo (por operación) | Entidades autocontenidas, tiempo real, offline |
| Bigtable | Columnar ancho | Fuerte por fila | ~2–10 ms estable | Horizontal, petabytes | Solo por clave o rango | Alto (por nodo/hora) | Series temporales y volumen enorme con acceso por clave |
| Memorystore | Clave-valor en memoria | Fuerte (nodo único) | <1 ms | Limitada por RAM | Comandos Redis | Medio | Caché, sesiones, contadores |
| BigQuery | Analítico columnar | Fuerte | segundos | Horizontal, petabytes | SQL analítico | Bajo por consulta, alto si se abusa | Agregaciones sobre volúmenes masivos (04-01) |
| Cloud Storage | Objetos | Fuerte | ~50–200 ms | Ilimitada | Solo por clave | Muy bajo | Ficheros, imágenes, copias (02-02) |
Dos lecturas de esta tabla que conviene subrayar. Primera: BigQuery no es una base de datos operacional. Sus consultas tardan segundos y su facturación penaliza las lecturas frecuentes de poco volumen; jamás lo pongas en el camino de una petición web. Segunda: la latencia no lo es todo. Bigtable es más rápido que Cloud SQL, pero si tus datos son relacionales, elegir Bigtable significa reimplementar a mano lo que SQL te daba gratis.
- Árbol de decisión
graph TD
A[Tengo un dato que guardar] --> B{¿Es un fichero binario:<br/>imagen, video, PDF?}
B -->|Si| C[Cloud Storage]
B -->|No| D{¿Es analitica sobre<br/>grandes volumenes?}
D -->|Si| E[BigQuery]
D -->|No| F{¿Puedo perderlo<br/>sin consecuencias?}
F -->|Si| G[Memorystore Redis]
F -->|No| H{¿Tiene relaciones y<br/>necesita transacciones<br/>entre entidades?}
H -->|Si| I{¿Cabe en una<br/>sola maquina?}
I -->|Si| J[Cloud SQL]
I -->|Casi, necesito<br/>mas rendimiento| K[AlloyDB]
I -->|No: escala global| L[Spanner]
H -->|No| M{¿Volumen enorme con<br/>acceso solo por clave<br/>o rango de tiempo?}
M -->|Si| N[Bigtable]
M -->|No| O{¿Entidad autocontenida,<br/>tiempo real u offline?}
O -->|Si| P[Firestore]
O -->|No| J
El árbol es una guía, no un dogma. Dos consejos que lo acompañan:
- En la duda, empieza por Cloud SQL. Es el más versátil, el más conocido y el más fácil de abandonar si te equivocas. Cambiar de una relacional a otra cosa siempre es más fácil que al revés.
- No uses cinco bases de datos porque puedas. Cada una añade una tecnología que operar, monitorizar, respaldar y aprender. La complejidad se paga todos los días; el beneficio solo aparece si el problema lo justifica de verdad.
- La decisión de AlpinaShop
Con todo lo anterior, este es el reparto documentado de los datos de AlpinaShop:
| Dato | Dónde vive | Por qué |
|---|---|---|
| Productos, precios, stock | Cloud SQL (alpinashop-pedidos, BD tienda) |
Relacional, transaccional, consultado por muchos campos, volumen modesto |
| Pedidos y líneas de pedido | Cloud SQL | Transacciones ACID imprescindibles: cobro, stock y pedido deben ser atómicos |
| Clientes y direcciones | Cloud SQL | Relacional, con integridad referencial hacia pedidos |
| Carrito de la compra | Firestore (colección carritos) |
Entidad autocontenida por cliente, escritura muy frecuente, estructura anidada, sin necesidad de JOIN. Además permite sincronización en tiempo real entre dispositivos |
| Sesiones de usuario | Firestore (colección sesiones, con TTL) |
Alta rotación, caducidad automática sin proceso de limpieza, y no ensucian la base transaccional |
| Catálogo cacheado de la portada | Memorystore (alpinashop-cache) |
Los mismos 50 productos miles de veces al día; TTL de 5 minutos elimina esa carga de Cloud SQL |
| Imágenes de producto | Cloud Storage (alpinashop-catalogo) |
60 GB de binarios; no son datos de base de datos (02-02) |
| Telemetría de clics | Pub/Sub → BigQuery hoy; Bigtable cuando el volumen lo exija | Volumen medio y consultas analíticas, no operacionales. Bigtable factura por nodo 24×7 y hoy no está justificado |
| Informes y analítica | BigQuery (alpinashop_analitica) |
Agregaciones sobre históricos; libera a Cloud SQL y a la réplica de Lucía (módulo 4) |
Y lo que no usamos, con su motivo, porque descartar con criterio es tan valioso como elegir:
- Spanner: sobredimensionado en varios órdenes de magnitud. Una tienda española no necesita transacciones globales entre continentes. Si la escala transaccional llegara a apretar, el paso previo sería AlloyDB.
- Bigtable, por ahora: el servicio correcto para el problema, pero prematuro para el volumen actual. Su facturación por nodo y hora, sin escalado a cero, no se compensa con unos millones de eventos al mes que BigQuery absorbe sin despeinarse. La decisión se revisará cuando la telemetría crezca en un orden de magnitud o cuando haga falta leer eventos individuales con latencia de milisegundos.
Este es exactamente el tipo de decisión que hay que documentar y fechar: no porque no vaya a cambiar, sino para que dentro de un año se entienda por qué se tomó y con qué datos.
Errores Comunes y Consejos
- Elegir NoSQL "porque escala" sin necesitarlo. La mayoría de aplicaciones nunca alcanzan el límite de una instancia de Cloud SQL bien dimensionada.
- Modelar Firestore como si fuera relacional. Sin
JOIN, hacer diez lecturas encadenadas para componer una pantalla es lento y caro. Desnormaliza. - Diseñar la row key de Bigtable con el timestamp delante. Hotspot garantizado: todas las escrituras al mismo nodo.
- Usar hash puro como row key. Reparte bien, pero pierdes la capacidad de escanear rangos, que es la razón de usar Bigtable.
- Poner BigQuery en el camino de una petición web. Latencia de segundos y facturación por datos escaneados.
- Guardar en caché sin TTL. Datos obsoletos eternos.
- Olvidar invalidar la caché al cambiar un precio. La tienda muestra precios antiguos.
- Confiar en el reloj del cliente. Usa
SERVER_TIMESTAMPen Firestore y marcas del servidor en general. - Superar 1 MiB en un documento de Firestore acumulando historial dentro de él. Usa subcolecciones.
- Dejar una instancia de Bigtable encendida "para probar". Factura por nodo y hora, no escala a cero.
- Consejo: empieza siempre por la relacional y muévete solo cuando tengas una razón medida.
- Consejo: diseña el esquema de Bigtable a partir de las consultas, nunca al revés.
- Consejo: crea los índices compuestos de Firestore desde el enlace que da el error. Es la vía más rápida y fiable.
- Consejo: activa TTL en Firestore para todo lo que caduca; te ahorra escribir y mantener un proceso de limpieza.
- Consejo: documenta y fecha la decisión de qué dato va a qué base. Tu yo futuro lo agradecerá.
Ejercicios
Ejercicio 1: elegir la base de datos adecuada
Para cada dato de AlpinaShop, indica el servicio, una alternativa razonable y una justificación de dos líneas:
- El histórico de precios de cada producto, para auditar cambios (unos cientos de filas al mes).
- Las valoraciones y reseñas de clientes, con texto libre, fotos asociadas y respuestas anidadas.
- La posición GPS de los repartidores durante el reparto, una lectura cada 5 segundos por repartidor.
- El número de veces que se ha visto la portada hoy, mostrado en el panel interno.
- Las facturas en PDF generadas para cada pedido.
Ejercicio 2: Firestore, carrito y consultas
- Crea una base de datos Firestore en modo Native.
- Escribe funciones en Python para añadir una línea al carrito, cambiar la cantidad de una línea y vaciar el carrito, usando una transacción donde corresponda.
- Escribe una consulta que devuelva los carritos con más de 100 € sin actividad en las últimas 48 horas.
- Explica qué índice compuesto necesita esa consulta y cómo lo crearías.
- Escribe la regla de seguridad que permita a cada cliente acceder solo a su carrito y a nadie leer las sesiones.
Ejercicio 3: diseñar una row key de Bigtable
AlpinaShop quiere registrar en Bigtable, más adelante, las búsquedas del buscador interno: término buscado, número de resultados, si hubo clic, país y dispositivo. Las consultas previstas son: (a) todas las búsquedas de un término en un rango de fechas; (b) las búsquedas de un día concreto para exportarlas.
- Propón una row key para la consulta (a) y justifícala.
- Explica por qué esa clave no sirve bien para la consulta (b) y qué harías al respecto.
- Identifica un diseño de clave que produciría hotspots y explica el mecanismo.
- Define las familias de columnas y qué columnas irían en cada una.
- Indica qué política de caducidad aplicarías y por qué.
Soluciones
Solución 1
| Dato | Servicio | Alternativa | Justificación |
|---|---|---|---|
| 1. Histórico de precios | Cloud SQL | BigQuery si creciera mucho | Volumen mínimo, relacionado con productos, se consulta con JOIN y por rango de fechas. No hay ninguna razón para sacarlo de la relacional |
| 2. Valoraciones y reseñas | Firestore (fotos en Cloud Storage) | Cloud SQL con columnas JSONB | Estructura anidada y variable (respuestas dentro de reseñas), se lee la reseña completa de una vez, y el tiempo real permite mostrar respuestas al instante |
| 3. Posición GPS de repartidores | Bigtable (o Firestore si son pocos) | Pub/Sub + BigQuery para el histórico | Serie temporal de escritura continua con acceso por repartidor y rango de tiempo: el caso canónico de Bigtable. Con 5 repartidores, Firestore es más barato y suficiente |
| 4. Contador de vistas de hoy | Memorystore | Firestore con contador fragmentado | Un contador que se incrementa constantemente y cuya pérdida es irrelevante: INCR de Redis es la operación exacta. En Cloud SQL sería contención de escritura sobre una fila |
| 5. Facturas en PDF | Cloud Storage | — | Son ficheros binarios. En la base de datos solo va la ruta del objeto. Además admiten retención bloqueada por obligación legal (02-02) |
Solución 2
from datetime import datetime, timedelta, timezone
from google.cloud import firestore
db = firestore.Client()
def _recalcular(datos: dict) -> dict:
datos["total"] = round(sum(l["precio"] * l["cantidad"] for l in datos["lineas"]), 2)
datos["actualizado"] = firestore.SERVER_TIMESTAMP
return datos
def añadir_linea(cliente_id, sku, nombre, precio, cantidad=1):
ref = db.collection("carritos").document(cliente_id)
@firestore.transactional
def _op(tx, ref):
snap = ref.get(transaction=tx)
datos = snap.to_dict() if snap.exists else {"lineas": []}
for l in datos["lineas"]:
if l["sku"] == sku:
l["cantidad"] += cantidad
break
else:
datos["lineas"].append(
{"sku": sku, "nombre": nombre, "precio": precio, "cantidad": cantidad}
)
tx.set(ref, _recalcular(datos))
_op(db.transaction(), ref)
def cambiar_cantidad(cliente_id, sku, cantidad):
ref = db.collection("carritos").document(cliente_id)
@firestore.transactional
def _op(tx, ref):
snap = ref.get(transaction=tx)
if not snap.exists:
return
datos = snap.to_dict()
if cantidad <= 0:
datos["lineas"] = [l for l in datos["lineas"] if l["sku"] != sku]
else:
for l in datos["lineas"]:
if l["sku"] == sku:
l["cantidad"] = cantidad
tx.set(ref, _recalcular(datos))
_op(db.transaction(), ref)
def vaciar_carrito(cliente_id):
# Borrado directo: no hace falta transaccion, es una sola operacion atomica
db.collection("carritos").document(cliente_id).delete()
def carritos_valiosos_abandonados():
limite = datetime.now(timezone.utc) - timedelta(hours=48)
consulta = (
db.collection("carritos")
.where(filter=firestore.FieldFilter("total", ">", 100))
.where(filter=firestore.FieldFilter("actualizado", "<", limite))
.order_by("total")
.order_by("actualizado")
.limit(50)
)
return [(d.id, d.to_dict()) for d in consulta.stream()]- La consulta filtra por dos campos distintos y ordena por ambos, así que Firestore necesita un índice compuesto sobre
(total, actualizado)en la coleccióncarritos. Los índices automáticos de campo único no bastan cuando hay más de un filtro de desigualdad. Al ejecutar la consulta sin el índice, Firestore devuelve un errorFAILED_PRECONDITIONcon un enlace que lo crea con la definición exacta; también se puede declarar así:
gcloud firestore indexes composite create \
--collection-group=carritos \
--field-config=field-path=total,order=ascending \
--field-config=field-path=actualizado,order=ascending// 5. Reglas de seguridad
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; // solo backend, via cuenta de servicio
}
}
}Solución 3
- Row key para la consulta (a):
El término va primero porque es de alta cardinalidad (miles de términos distintos, lo que reparte bien la carga entre nodos) y porque es el campo que aparece en la consulta: escanear desde mochila_40l#20260805 hasta mochila_40l#20260812 devuelve exactamente las búsquedas de ese término en esa semana, leyendo solo filas contiguas. El sufijo aleatorio evita colisiones entre búsquedas simultáneas del mismo término.
- Esa clave no sirve para la consulta (b) —"todas las búsquedas de un día"— porque las filas de un mismo día están dispersas por toda la tabla, repartidas entre todos los términos: habría que escanear la tabla completa filtrando. Las opciones son:
- La recomendada: exportar a BigQuery y hacer allí las consultas por fecha. Bigtable resuelve el acceso operacional por clave; la analítica por otras dimensiones es trabajo de BigQuery.
- Mantener una segunda tabla con clave
<fecha>#<salt>#<termino>#<ts>, donde<salt>es un número de 0 a N que reparte artificialmente la carga y evita el hotspot del prefijo por fecha. Duplica el almacenamiento y exige escribir dos veces.
- Un diseño que produciría hotspots:
Como las filas se almacenan ordenadas por clave y se reparten en bloques contiguos entre nodos, todas las escrituras de un mismo instante comparten prefijo y caen en el mismo bloque, gestionado por un único nodo. Ese nodo se satura mientras el resto del clúster está ocioso: la escala horizontal deja de funcionar. Es exactamente el mismo problema con claves secuenciales tipo 0000001, 0000002.
- Familias de columnas:
| Familia | Columnas | Motivo |
|---|---|---|
busq |
termino_original, num_resultados, hubo_clic, sku_clic |
Datos del hecho en sí; se leen casi siempre juntos |
ctx |
pais, dispositivo, idioma, sesion_id |
Contexto; a veces se consulta sin necesitar el resto |
Agrupar en familias las columnas que se leen juntas mejora el rendimiento, porque Bigtable almacena y recupera cada familia de forma independiente. Conviene mantener pocas familias (idealmente menos de diez) y con nombres cortos, ya que el nombre se repite en cada celda almacenada.
- Política de caducidad:
maxage=180den ambas familias. Las búsquedas tienen valor operacional durante unos meses —detectar términos sin resultados, ajustar el buscador— y después su valor es exclusivamente histórico, que ya está cubierto por la exportación a BigQuery, donde el almacenamiento es mucho más barato. La caducidad automática evita que la tabla crezca sin límite y que el coste de almacenamiento suba de forma indefinida.
cbt -instance=alpinashop-telemetria setgcpolicy busquedas busq maxage=180d
cbt -instance=alpinashop-telemetria setgcpolicy busquedas ctx maxage=180dConclusión
Has dejado de tener una sola herramienta para todos los datos. Sabes que el modelo relacional sigue siendo la respuesta por defecto y por qué —integridad, transacciones, SQL, un optimizador que resuelve consultas que no habías previsto— y también dónde están sus tres límites reales: la escala de escritura, los datos anidados sin esquema fijo y el volumen extremo con acceso trivial. Has entendido el teorema CAP sin el dogmatismo habitual: la partición de red no es opcional, la elección real es qué hacer durante una partición, y en la práctica se decide por operación. La pregunta útil no es si una base de datos es consistente, sino qué pasa si ese dato concreto está un segundo desactualizado.
Has conocido Firestore —documentos y colecciones, consistencia fuerte por documento, transacciones, tiempo real, modo offline, TTL automático, reglas de seguridad y la necesidad de índices compuestos— y has guardado en él el carrito y las sesiones de AlpinaShop, desnormalizando a propósito y usando transacciones donde hacía falta. Has conocido Bigtable —filas ordenadas por clave, familias de columnas, sin índices secundarios ni JOIN— y, sobre todo, has aprendido que el diseño de la row key es el diseño entero: empezar por un campo de alta cardinalidad, poner el tiempo después, evitar los hotspots que provoca cualquier clave secuencial, y modelar a partir de las consultas y no de los datos. Has situado Spanner como la respuesta a un problema muy concreto —escala global con ACID— y has visto por qué a AlpinaShop no le corresponde. Has añadido Memorystore como caché con sus tres reglas: siempre TTL, la invalidación es lo difícil, y solo accesible desde la VPC.
La tabla comparativa transversal y el árbol de decisión te dan un método reutilizable, y el reparto de datos de AlpinaShop es una decisión documentada y fechada: Cloud SQL para productos, pedidos y clientes; Firestore para carrito y sesiones; Memorystore para la caché de la portada; Cloud Storage para las imágenes; Pub/Sub y BigQuery para la telemetría, con Bigtable descartado por ahora de forma explícita y razonada.
Con esto, AlpinaShop tiene resueltos el cómputo en cuatro niveles y los datos en cinco servicios. Queda la pregunta que lo une todo y que cierra el módulo: ante una carga concreta, ¿qué servicio de cómputo hay que elegir? En 02-07, Cómo elegir el servicio de cómputo adecuado, recorreremos el continuo de abstracción de VM a función, compararemos Compute Engine, GKE Standard y Autopilot, App Engine, Cloud Run y Cloud Functions con criterios medibles, calcularemos el coste de un mismo escenario de tráfico en cada uno, veremos los patrones de migración lift-and-shift, replatform y refactor, y documentaremos la arquitectura definitiva de AlpinaShop que servirá de base para el resto del curso.
Curso de Google Cloud Platform (GCP)
Módulo 1: Introducción a Google Cloud Platform
- ¿Qué es Google Cloud Platform?
- Configuración de tu cuenta de GCP
- Descripción general de la consola de GCP
- Proyectos, jerarquía de recursos y facturación
- Regiones, zonas y modelo de responsabilidad compartida
- Cloud Shell y la CLI de gcloud
Módulo 2: Servicios principales de GCP
- Compute Engine: máquinas virtuales en Google Cloud
- Cloud Storage: almacenamiento de objetos
- Cloud SQL: bases de datos relacionales gestionadas
- App Engine: plataforma como servicio
- Google Kubernetes Engine (GKE)
- Bases de datos NoSQL: Firestore, Bigtable y Spanner
- Cómo elegir el servicio de cómputo adecuado
Módulo 3: Redes y seguridad
- Redes VPC
- Balanceo de carga en la nube
- Cloud CDN
- Gestión de identidad y acceso (IAM)
- Cloud Armor
- Secretos y cifrado: Secret Manager y Cloud KMS
- Cloud DNS, certificados TLS y publicación segura de servicios
Módulo 4: Datos y análisis
- BigQuery: el almacén de datos analítico
- Cloud Dataflow: procesamiento de datos por lotes y en streaming
- Cloud Dataproc: Spark y Hadoop gestionados
- Cloud Pub/Sub: mensajería asíncrona
- Cloud Data Fusion: integración de datos sin código
- Orquestación de pipelines con Cloud Composer y Workflows
- Gobierno del dato y cuadros de mando con Dataplex y Looker Studio
Módulo 5: Aprendizaje automático e IA
- Vertex AI: la plataforma de machine learning de GCP
- AutoML: modelos a medida sin escribir código
- TensorFlow en GCP: entrenamiento y servicio de modelos
- API de lenguaje natural
- API de visión
- IA generativa en Vertex AI: modelos Gemini y embeddings
- MLOps: del modelo al producto con Vertex AI Pipelines
Módulo 6: DevOps y monitoreo
- Cloud Build: integración continua en GCP
- Cloud Source Repositories y gestión del código fuente
- Cloud Functions: funciones sin servidor
- Cloud Monitoring (antes Stackdriver): métricas, paneles y alertas
- Cloud Deployment Manager e infraestructura como código nativa
- Cloud Logging y Cloud Trace: logs, trazas y diagnóstico
- Terraform en GCP: infraestructura como código en la práctica
Módulo 7: Temas avanzados de GCP
- Híbrido y multinube con Anthos
- Computación sin servidor con Cloud Run
- Redes avanzadas: VPC compartida, peering y conectividad híbrida
- Mejores prácticas de seguridad
- Gestión y optimización de costos
- Fiabilidad: SLO, alta disponibilidad y recuperación ante desastres
- Gobierno a escala: organización, políticas y auditoría
