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

  1. Por qué no todo encaja en una relacional
  2. Consistencia y el teorema CAP sin dogmatismo
  3. Firestore: base de datos documental
  4. Firestore en AlpinaShop: carrito y sesiones
  5. Bigtable: filas ordenadas y columnas anchas
  6. Bigtable en AlpinaShop: telemetría de clics
  7. Spanner: relacional distribuida
  8. Memorystore: caché en Redis
  9. Tabla comparativa transversal
  10. Árbol de decisión
  11. La decisión de AlpinaShop

  1. 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.

  1. 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.

  1. 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() y average(), 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-prod

eur3 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.

  1. 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.

pip install google-cloud-firestore
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_TIMESTAMP usa el reloj del servidor, no el del cliente. Nunca confíes en la hora del dispositivo del usuario.
  • La desnormalización es deliberada. Guardamos nombre y precio dentro 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=ascending

Firestore 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:

gcloud firestore fields ttls update caduca_en \
  --collection-group=sesiones \
  --enable-ttl

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.

  1. 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.

  1. 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,ctx
pip install google-cloud-bigtable
import 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=90d

Dó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.

  1. 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.

  1. 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=basic

El 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).

  1. 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.

  1. Á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.

  1. 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_TIMESTAMP en 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:

  1. El histórico de precios de cada producto, para auditar cambios (unos cientos de filas al mes).
  2. Las valoraciones y reseñas de clientes, con texto libre, fotos asociadas y respuestas anidadas.
  3. La posición GPS de los repartidores durante el reparto, una lectura cada 5 segundos por repartidor.
  4. El número de veces que se ha visto la portada hoy, mostrado en el panel interno.
  5. Las facturas en PDF generadas para cada pedido.

Ejercicio 2: Firestore, carrito y consultas

  1. Crea una base de datos Firestore en modo Native.
  2. 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.
  3. Escribe una consulta que devuelva los carritos con más de 100 € sin actividad en las últimas 48 horas.
  4. Explica qué índice compuesto necesita esa consulta y cómo lo crearías.
  5. 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.

  1. Propón una row key para la consulta (a) y justifícala.
  2. Explica por qué esa clave no sirve bien para la consulta (b) y qué harías al respecto.
  3. Identifica un diseño de clave que produciría hotspots y explica el mecanismo.
  4. Define las familias de columnas y qué columnas irían en cada una.
  5. 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

gcloud firestore databases create --location=eur3 --type=firestore-native
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()]
  1. La consulta filtra por dos campos distintos y ordena por ambos, así que Firestore necesita un índice compuesto sobre (total, actualizado) en la colección carritos. 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 error FAILED_PRECONDITION con 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

  1. Row key para la consulta (a):
<termino_normalizado>#<timestamp>#<aleatorio>
ej.: mochila_40l#20260805094215#a7f3

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.

  1. 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.
  1. Un diseño que produciría hotspots:
<timestamp>#<termino>     ej.: 20260805094215#mochila_40l

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.

  1. 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.

  1. Política de caducidad: maxage=180d en 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=180d

Conclusió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

Módulo 2: Servicios principales de GCP

Módulo 3: Redes y seguridad

Módulo 4: Datos y análisis

Módulo 5: Aprendizaje automático e IA

Módulo 6: DevOps y monitoreo

Módulo 7: Temas avanzados de GCP

Módulo 8: Proyecto final

© Copyright 2026. Todos los derechos reservados