AlpinaShop lleva tres módulos generando datos sin mirarlos. Los pedidos se acumulan en alpinashop-pedidos, las visitas quedan en los registros del balanceador, las imágenes se sirven desde el CDN y los carritos se abandonan en Firestore. Lucía, la analista, tiene una libreta con preguntas que nadie ha respondido: qué mochila se vende más en Cataluña, si la campaña de otoño movió la aguja, por qué el 30 % de los carritos se quedan en el paso de envío, cuánto vale de media un pedido y cómo ha evolucionado ese número en dos años.

La reacción instintiva de mucha gente ante ese problema es abrir una conexión a la base de datos de producción y empezar a escribir SELECT. En 02-03 ya evitamos lo peor creando la réplica de lectura alpinashop-pedidos-replica-informes, para que los informes no compitan con las compras. Pero esa réplica es un parche. Una consulta que recorra dos años de líneas de pedido para agrupar por categoría y mes seguirá tardando minutos y seguirá siendo incómoda de escribir, porque PostgreSQL no está construido para eso. PostgreSQL está construido para responder "dame el pedido 48213" en un milisegundo, no para responder "dame la media de todos los pedidos de los últimos dos años agrupados por seis dimensiones".

En esta lección vas a entender por qué esas dos preguntas requieren máquinas distintas, vas a crear el dataset alpinashop_analitica en alpinashop-datos, vas a cargar en él el histórico de pedidos, vas a escribir el SQL que responde de verdad a las preguntas de Lucía y —esto es lo que separa a quien sabe usar BigQuery de quien recibe una factura sorpresa— vas a aprender exactamente cómo se paga y cómo se evita pagar de más.

Contenido

  1. Por qué un almacén analítico no es "una base de datos más grande"
  2. Cómo funciona BigQuery por dentro: columnas, separación y Dremel
  3. Cloud SQL frente a BigQuery: la tabla que hay que interiorizar
  4. Estructura: proyecto, dataset, tabla y vista
  5. Creación del dataset alpinashop_analitica
  6. Tipos de datos, STRUCT y ARRAY: la desnormalización como virtud
  7. Creación de las tablas de AlpinaShop
  8. Cargar datos: desde Cloud Storage, en streaming y sin cargarlos
  9. Traer el histórico desde Cloud SQL con consultas federadas
  10. SQL de negocio: las preguntas de Lucía, respondidas
  11. El modelo de coste y cómo no arruinarse
  12. Particionado y clustering: el antes y el después medido
  13. Vistas materializadas, caché de resultados y cuotas
  14. Almacenamiento lógico frente a físico
  15. Control de acceso: dataset, tabla, columna y fila
  16. INFORMATION_SCHEMA: auditar quién gasta y en qué
  17. BigQuery ML, mencionado y aplazado

  1. Por qué un almacén analítico no es "una base de datos más grande"

Hay dos familias de carga de trabajo sobre datos, y confundirlas es el error conceptual más caro de esta profesión.

OLTP (procesamiento de transacciones en línea) es lo que hace alpinashop-pedidos. Muchas operaciones muy pequeñas, concurrentes, que leen y escriben unas pocas filas identificadas por clave primaria, con garantías transaccionales estrictas. "Inserta este pedido", "actualiza el stock del SKU MOCH-40L-AZ", "dame el carrito del usuario 8821".

OLAP (procesamiento analítico en línea) es lo que quiere Lucía. Pocas consultas, muy grandes, que leen millones de filas, tocan pocas columnas de cada una, agregan, agrupan y ordenan, y no escriben nada.

La diferencia no es de tamaño, es de forma. Y esa forma determina cómo conviene guardar los bytes en disco.

Imagina la tabla lineas_pedido con 8 millones de filas y estas columnas: pedido_id, sku, nombre_producto, categoria, cantidad, precio_unitario, descuento, iva, fecha, pais_envio, metodo_pago, email_cliente, notas.

  • PostgreSQL guarda los datos por filas: todos los campos de la línea 1, luego todos los de la línea 2, etc. Es perfecto para "dame la línea 1 entera", porque está contigua en disco.
  • La pregunta de Lucía, en cambio, es SELECT categoria, SUM(cantidad * precio_unitario) FROM lineas_pedido GROUP BY categoria. Solo necesita tres de las trece columnas. Pero para leer esas tres, PostgreSQL tiene que arrastrar del disco las trece, porque están entrelazadas. Lee diez veces más bytes de los que usa.

BigQuery guarda los datos por columnas: todos los valores de categoria juntos, todos los de cantidad juntos. La consulta anterior lee exclusivamente tres bloques de disco y no toca los otros diez. Y como cada bloque contiene valores del mismo tipo y muy repetitivos —categoria tendrá una docena de valores distintos en ocho millones de filas—, se comprime brutalmente bien.

Almacenamiento por FILAS (OLTP)
[p1|MOCH-40L|Mochila 40L|mochilas|2|89.90|...]  [p1|CRAM-12P|Crampones|...]  [p2|...]
 └──────────── para leer "categoria" hay que atravesar todo ────────────┘

Almacenamiento por COLUMNAS (OLAP)
pedido_id:  [p1, p1, p2, p2, p3, ...]
sku:        [MOCH-40L, CRAM-12P, ...]
categoria:  [mochilas, mochilas, mochilas, crampones, ...]  <-- se comprime a casi nada
cantidad:   [2, 1, 1, 3, ...]

De ahí se derivan las dos reglas que gobiernan todo lo demás en BigQuery:

  • Leer pocas columnas es barato; leer todas es caro. Por eso SELECT * es, literalmente, la forma más rápida de gastar dinero.
  • Filtrar por una columna no evita leerla. Si filtras por WHERE pais_envio = 'ES', BigQuery tiene que leer toda la columna pais_envio para saber qué filas cumplen. La única forma de no leer datos es que estén particionados o clusterizados, que veremos en el apartado 12.

  1. Cómo funciona BigQuery por dentro: columnas, separación y Dremel

Tres decisiones de arquitectura explican por qué BigQuery se comporta como se comporta.

Almacenamiento columnar propio (Capacitor). Los datos se guardan en un formato columnar comprimido en el sistema de ficheros distribuido de Google (Colossus), replicado automáticamente. Tú no ves ficheros, no gestionas índices, no haces VACUUM y no defines tablespaces. No hay nada que administrar.

Separación total de almacenamiento y cómputo. Esta es la más importante y la que cambia la mentalidad. En Cloud SQL, el disco y la CPU están en la misma máquina: si necesitas más CPU, pagas también más máquina, y si la base de datos está parada, la máquina sigue encendida y facturando. En BigQuery no hay máquina.

  • El almacenamiento se factura por GB al mes que hay guardados. Existe siempre.
  • El cómputo se factura por consulta ejecutada (o por capacidad reservada). Si nadie consulta, el cómputo cuesta cero.

Consecuencia inmediata para AlpinaShop: puedes tener 2 TB de histórico guardados y, si Lucía se va de vacaciones, pagar únicamente el almacenamiento —unos pocos euros— sin apagar nada. Y al revés: una consulta puede movilizar cientos de máquinas durante 8 segundos y luego devolverlas. Esa elasticidad es imposible en un modelo con servidor.

Ejecución distribuida (motor Dremel). Cuando lanzas una consulta, el planificador la descompone en un árbol de ejecución. Las hojas del árbol —los workers— leen fragmentos de columnas en paralelo, cada uno hace su parte de la agregación, y los resultados intermedios suben por el árbol mezclándose en una capa de shuffle en memoria hasta producir el resultado final.

flowchart TD
    Q["Consulta SQL de Lucia<br/>SUM ventas GROUP BY categoria"]
    P["Planificador<br/>arbol de ejecucion"]
    S["Capa de shuffle<br/>en memoria"]
    W1["Worker 1<br/>lee fragmento columnar"]
    W2["Worker 2"]
    W3["Worker 3"]
    Wn["... Worker N"]
    C["Colossus<br/>almacenamiento columnar"]
    R["Resultado"]

    Q --> P
    P --> W1 & W2 & W3 & Wn
    C --> W1 & W2 & W3 & Wn
    W1 & W2 & W3 & Wn --> S
    S --> R

La unidad de ese cómputo se llama slot: un slot es, aproximadamente, una porción de CPU con su memoria asociada. Una consulta usa tantos slots como el planificador considere y haya disponibles. Volveremos a los slots en el apartado 11, porque son la clave del modelo de precios por capacidad.

  1. Cloud SQL frente a BigQuery: la tabla que hay que interiorizar

Dimensión Cloud SQL (alpinashop-pedidos) BigQuery (alpinashop_analitica)
Propósito OLTP: transacciones, la tienda funcionando OLAP: análisis, informes, cuadros de mando
Almacenamiento Por filas Por columnas, comprimido
Unidad de trabajo típica Leer/escribir 1-100 filas Leer 10⁶-10⁹ filas, escribir 0
Latencia de una consulta 1-50 ms 1-30 s (segundos, no milisegundos)
Concurrencia objetivo Miles de conexiones simultáneas Decenas de consultas simultáneas
Escritura INSERT/UPDATE fila a fila, constante Carga por lotes o streaming; UPDATE puntual y caro
Transacciones ACID Sí, es su razón de ser Transacciones multi-instrucción limitadas; no es su función
Índices Los defines tú (B-tree, GIN…) No existen; hay partición y clustering
Claves foráneas Sí, con integridad referencial Declarables como no forzadas; no las valida
Escalado Vertical (más CPU/RAM) + réplicas Automático e invisible
Modelo de coste Por hora de instancia encendida Por bytes consultados (o slots reservados) + GB almacenados
Coste si nadie lo usa El de la instancia, íntegro Solo el almacenamiento
Modelado recomendado Normalizado (3FN) Desnormalizado, con STRUCT y ARRAY

La lectura correcta de esta tabla no es "BigQuery es mejor". Es: son herramientas complementarias y AlpinaShop necesita las dos. La tienda seguirá comprando contra Cloud SQL. Lucía analizará contra BigQuery. El trabajo de esta lección y de las siguientes es construir la tubería que lleva los datos de la primera a la segunda.

  1. Estructura: proyecto, dataset, tabla y vista

La jerarquía de BigQuery se apoya en la jerarquía de recursos que ya conoces de 01-04:

Organización alpinashop.example
└── Proyecto alpinashop-datos          <-- aquí vive la analítica
    └── Dataset alpinashop_analitica   <-- contenedor con ubicación y permisos
        ├── Tabla   pedidos
        ├── Tabla   lineas_pedido
        ├── Tabla   productos
        ├── Tabla   visitas
        ├── Vista   v_ventas_mensuales
        └── Rutina  (UDF, procedimiento almacenado)

Cuatro precisiones que evitan problemas más adelante:

  • El proyecto es la unidad de facturación. Las consultas que Lucía lance se cargan al proyecto donde se ejecutan, no al proyecto donde vive la tabla. Esto permite, por ejemplo, que marketing consulte datos de alpinashop-datos pagando desde su propio proyecto. Para AlpinaShop, todo se ejecuta y se factura en alpinashop-datos, que ya tiene su etiqueta centro-coste.
  • El dataset es la unidad de organización y de permisos. Es donde se conceden roles, y es lo que se comparte.
  • La ubicación del dataset es inmutable. Se decide al crearlo y no se puede cambiar: para moverlo hay que crear otro y copiar los datos. Elegiremos europe-west1, coherente con el resto de la infraestructura de AlpinaShop y con la exigencia del RGPD de mantener los datos personales de clientes europeos en la UE.
  • No se puede hacer un JOIN entre datasets de ubicaciones distintas. Si mañana alguien crea alpinashop_marketing en us-central1, no podrá cruzarlo con alpinashop_analitica. Es el error de diseño irreversible más común en BigQuery. Fija la ubicación como estándar del equipo desde el primer día.

El nombre completo de una tabla es proyecto.dataset.tabla, y en SQL estándar se escribe entre acentos graves cuando el proyecto lleva guiones:

SELECT * FROM `alpinashop-datos.alpinashop_analitica.pedidos` LIMIT 10;

Una nota de nomenclatura: los identificadores de dataset y tabla admiten letras, números y guiones bajos, pero no guiones. Por eso el proyecto es alpinashop-datos (con guion) y el dataset es alpinashop_analitica (con guion bajo).

  1. Creación del dataset alpinashop_analitica

Trabajaremos con la herramienta bq, que viene incluida en la CLI de gcloud y en Cloud Shell desde 01-06.

gcloud config set project alpinashop-datos

# Crear el dataset de analitica
bq --location=europe-west1 mk \
  --dataset \
  --description="Almacen analitico de AlpinaShop: pedidos, catalogo y navegacion" \
  --default_table_expiration=0 \
  --label=entorno:produccion \
  --label=equipo:datos \
  --label=centro-coste:analitica \
  alpinashop-datos:alpinashop_analitica

Opción por opción:

  • --location=europe-west1: la ubicación inmutable. Va antes del subcomando mk, no después; es un error habitual.
  • --dataset: indica que el recurso a crear es un dataset y no una tabla.
  • --default_table_expiration=0: sin caducidad automática. Si pusieras 2592000 (30 días en segundos), toda tabla creada aquí se autodestruiría a los 30 días. Es utilísimo en un dataset de pruebas y catastrófico en producción, así que conviene ser explícito.
  • Las tres etiquetas replican el esquema entorno/equipo/centro-coste que AlpinaShop fijó en 01-04, para que el coste de analítica se pueda aislar en el informe de facturación.

Conviene crear también un dataset de trabajo con caducidad, para que las tablas temporales de los análisis no se acumulen para siempre:

bq --location=europe-west1 mk --dataset \
  --description="Area de trabajo temporal; las tablas caducan a los 7 dias" \
  --default_table_expiration=604800 \
  --label=entorno:desarrollo --label=equipo:datos \
  alpinashop-datos:alpinashop_scratch

Verificación:

bq ls --format=prettyjson --datasets alpinashop-datos
bq show --format=prettyjson alpinashop-datos:alpinashop_analitica

En la salida de bq show fíjate en "location": "europe-west1". Si dice US, has creado el dataset en el sitio equivocado: bórralo ahora, antes de meterle datos, porque después ya no hay marcha atrás barata.

# Solo si te has equivocado de ubicacion y el dataset esta vacio
bq rm -r -f -d alpinashop-datos:alpinashop_analitica

  1. Tipos de datos, STRUCT y ARRAY: la desnormalización como virtud

Los tipos escalares de BigQuery son pocos y deliberadamente estrictos:

Tipo Uso en AlpinaShop Nota
STRING sku, categoria, pais_envio UTF-8, sin longitud máxima declarada
INT64 cantidad, pedido_id Entero de 64 bits; no hay INT de otros tamaños
NUMERIC precio_unitario, total_pedido Decimal exacto, 38 dígitos, 9 decimales: el tipo del dinero
FLOAT64 Métricas aproximadas, ratios Coma flotante: nunca para importes
BOOL es_regalo
DATE fecha_pedido Sin hora
TIMESTAMP momento_evento Instante absoluto en UTC
DATETIME Fechas civiles sin zona Menos habitual; prefiere TIMESTAMP
GEOGRAPHY Análisis geoespacial Puntos, líneas, polígonos
JSON Cargas semiestructuradas Consultable con operadores nativos
BYTES Binario Raro en analítica

Regla que ahorra disgustos: los importes van en NUMERIC, jamás en FLOAT64. Con FLOAT64, sumar 1.10 € y 2.20 € puede dar 3.3000000000000003, y ese céntimo fantasma aparecerá en un informe de dirección en el peor momento posible.

Y ahora la parte que distingue el modelado en BigQuery del modelado relacional clásico: los tipos anidados.

  • ARRAY<T>: una lista ordenada de valores del tipo T dentro de una única celda.
  • STRUCT<...>: un registro con campos nombrados dentro de una única celda.
  • Se combinan: ARRAY<STRUCT<sku STRING, cantidad INT64, precio NUMERIC>> es "una lista de líneas de pedido dentro de la fila del pedido".

En PostgreSQL, un pedido con tres líneas son cuatro filas repartidas en dos tablas unidas por clave foránea. En BigQuery puede ser una sola fila que contiene, dentro, sus tres líneas.

¿Por qué esto es mejor aquí? Porque en un motor distribuido, un JOIN entre dos tablas grandes obliga a mover datos entre máquinas por la capa de shuffle, y eso es lo caro. Si las líneas ya viajan pegadas a su pedido, el JOIN desaparece: se convierte en un UNNEST, que es una operación local dentro de cada worker, sin red de por medio.

-- Ejemplo de una sola fila con estructura anidada
SELECT
  'PED-2026-0042'                              AS pedido_id,
  DATE '2026-03-14'                            AS fecha_pedido,
  STRUCT('ES' AS pais, 'Barcelona' AS ciudad)  AS envio,
  [
    STRUCT('MOCH-40L-AZ' AS sku, 1 AS cantidad, NUMERIC '89.90' AS precio_unitario),
    STRUCT('FRON-300L'   AS sku, 2 AS cantidad, NUMERIC '34.50' AS precio_unitario)
  ]                                            AS lineas;

Esa fila contiene un pedido completo. envio es un STRUCT (un registro), y lineas es un ARRAY<STRUCT<...>> (una lista de registros). Todo lo que hace falta para analizar el pedido está en un solo sitio del disco.

La regla práctica de modelado: normaliza en OLTP para evitar duplicidad al escribir; desnormaliza en OLAP para evitar JOIN al leer. El almacenamiento es barato (unos céntimos por GB al mes); el shuffle no lo es.

Dicho esto, no hay que llevarlo al extremo. Para AlpinaShop mantendremos pedidos y lineas_pedido como tablas separadas —porque es como llegan de PostgreSQL y facilita la carga incremental— y anidaremos donde aporte de verdad: el bloque de envío dentro del pedido, y los eventos dentro de la sesión de navegación. Es un punto intermedio honesto y muy común en la práctica.

  1. Creación de las tablas de AlpinaShop

Vamos a crear las cuatro tablas con DDL en SQL, que es más legible y versionable que un JSON de esquema. Estas sentencias se ejecutan en BigQuery Studio, la interfaz de consulta de la consola (menú BigQuery), o desde bq query --use_legacy_sql=false.

-- Cabecera de pedido. Particionada por fecha y clusterizada por pais y estado.
CREATE TABLE `alpinashop-datos.alpinashop_analitica.pedidos`
(
  pedido_id        STRING   NOT NULL OPTIONS(description="Identificador de negocio, p. ej. PED-2026-0042"),
  fecha_pedido     DATE     NOT NULL OPTIONS(description="Fecha de confirmacion del pedido"),
  momento_pedido   TIMESTAMP         OPTIONS(description="Instante exacto en UTC"),
  cliente_id       STRING            OPTIONS(description="Identificador seudonimizado del cliente"),
  email_cliente    STRING            OPTIONS(description="DATO PERSONAL: acceso restringido por politica de columna"),
  canal            STRING            OPTIONS(description="web | movil | telefono"),
  estado           STRING            OPTIONS(description="confirmado | enviado | entregado | devuelto | cancelado"),
  envio            STRUCT<
                     pais           STRING,
                     provincia      STRING,
                     ciudad         STRING,
                     codigo_postal  STRING,
                     metodo         STRING,
                     coste          NUMERIC
                   >                 OPTIONS(description="Bloque de envio anidado"),
  metodo_pago      STRING,
  cupon            STRING,
  subtotal         NUMERIC  NOT NULL,
  descuento        NUMERIC,
  iva              NUMERIC,
  total_pedido     NUMERIC  NOT NULL OPTIONS(description="Importe final cobrado, IVA incluido")
)
PARTITION BY fecha_pedido
CLUSTER BY estado, canal
OPTIONS(
  description="Cabeceras de pedido de AlpinaShop, replicadas desde Cloud SQL alpinashop-pedidos",
  partition_expiration_days=NULL,
  require_partition_filter=TRUE
);

Los tres detalles que importan:

  • PARTITION BY fecha_pedido divide físicamente la tabla en una partición por día. Una consulta que filtre por fecha solo leerá las particiones necesarias.
  • CLUSTER BY estado, canal ordena los datos dentro de cada partición por esas columnas, en ese orden. Los filtros por estado podrán saltarse bloques enteros.
  • require_partition_filter=TRUE es la mejor decisión defensiva de toda la lección: rechaza cualquier consulta que no filtre por fecha_pedido. Un SELECT * FROM pedidos fallará con un error explícito en lugar de escanear dos años de datos. Es un cinturón de seguridad barato que evita facturas absurdas.
-- Lineas de pedido. Misma clave de particion para que los JOIN filtren igual.
CREATE TABLE `alpinashop-datos.alpinashop_analitica.lineas_pedido`
(
  pedido_id        STRING   NOT NULL,
  linea_num        INT64    NOT NULL,
  fecha_pedido     DATE     NOT NULL OPTIONS(description="Denormalizada desde pedidos para poder particionar"),
  sku              STRING   NOT NULL,
  cantidad         INT64    NOT NULL,
  precio_unitario  NUMERIC  NOT NULL,
  descuento_linea  NUMERIC,
  importe_linea    NUMERIC  NOT NULL OPTIONS(description="cantidad * precio_unitario - descuento_linea")
)
PARTITION BY fecha_pedido
CLUSTER BY sku
OPTIONS(description="Detalle de lineas de pedido", require_partition_filter=TRUE);

-- Catalogo de productos. Tabla pequena: ni particion ni clustering.
CREATE TABLE `alpinashop-datos.alpinashop_analitica.productos`
(
  sku              STRING   NOT NULL,
  nombre           STRING   NOT NULL,
  categoria        STRING   NOT NULL OPTIONS(description="mochilas | crampones | tiendas | frontales | ropa | cuerdas"),
  subcategoria     STRING,
  marca            STRING,
  precio_catalogo  NUMERIC,
  coste_compra     NUMERIC  OPTIONS(description="CONFIDENCIAL: margen comercial"),
  peso_gramos      INT64,
  activo           BOOL,
  fecha_alta       DATE
)
OPTIONS(description="Catalogo maestro de productos, sincronizado desde el ERP");

Nota de diseño sobre lineas_pedido: hemos duplicado fecha_pedido desde la cabecera. En un modelo relacional eso sería redundancia censurable. Aquí es imprescindible, porque una tabla solo puede particionarse por una columna propia. Sin esa duplicación, cualquier JOIN entre pedidos y líneas obligaría a escanear la tabla de líneas entera. Es el ejemplo perfecto de por qué las reglas del modelado OLTP no se trasladan literalmente.

Y la tabla de navegación, que sí aprovecha el anidamiento a fondo:

-- Sesiones de navegacion con sus eventos anidados.
CREATE TABLE `alpinashop-datos.alpinashop_analitica.visitas`
(
  sesion_id     STRING     NOT NULL,
  fecha         DATE       NOT NULL,
  inicio        TIMESTAMP  NOT NULL,
  cliente_id    STRING     OPTIONS(description="NULL si el visitante no ha iniciado sesion"),
  dispositivo   STRUCT<tipo STRING, navegador STRING, sistema STRING>,
  origen        STRUCT<fuente STRING, medio STRING, campana STRING>,
  pais          STRING,
  eventos       ARRAY<STRUCT<
                  momento    TIMESTAMP,
                  tipo       STRING,   -- vista_pagina | ver_producto | anadir_carrito | iniciar_pago | compra
                  ruta       STRING,
                  sku        STRING,
                  valor      NUMERIC
                >> OPTIONS(description="Todos los eventos de la sesion, en orden cronologico")
)
PARTITION BY fecha
CLUSTER BY pais, sesion_id
OPTIONS(description="Sesiones de navegacion del catalogo web", require_partition_filter=TRUE);

Una sesión de 40 clics es una fila, no 40. Contar cuántas sesiones llegaron a iniciar_pago pero no a compra —la pregunta del carrito abandonado que traía Lucía— será una operación local, sin JOIN y sin shuffle.

  1. Cargar datos: desde Cloud Storage, en streaming y sin cargarlos

Hay cuatro maneras de meter datos en BigQuery, y elegir la equivocada es una fuente clásica de coste y de dolor.

Método Latencia Coste Cuándo usarlo
Carga por lotes desde Cloud Storage Minutos Gratis (no consume slots bajo demanda) Volcados diarios, histórico, reprocesos
Streaming (Storage Write API) Segundos Por GB insertado Eventos que se necesitan "ahora"
Tabla externa / BigLake Ninguna: no se carga Se paga al consultar, y es más lento Datos que viven en el bucket y se consultan poco
Consulta federada a Cloud SQL Directa Se paga el cómputo Traer datos operativos frescos y pequeños

Carga por lotes desde Cloud Storage

Es el caballo de batalla y es gratis, lo que sorprende a mucha gente. Google no cobra la ingesta por lotes: cobra el almacenamiento resultante y las consultas posteriores.

Supongamos que el volcado nocturno del histórico de pedidos deja ficheros en el bucket. Formatos admitidos: CSV, JSON delimitado por líneas (NDJSON), Avro, Parquet y ORC.

# Carga de un CSV con cabecera, autodetectando nada: esquema explicito
bq load \
  --source_format=CSV \
  --skip_leading_rows=1 \
  --null_marker='\N' \
  --field_delimiter=',' \
  --time_partitioning_field=fecha_pedido \
  --clustering_fields=estado,canal \
  alpinashop-datos:alpinashop_analitica.pedidos \
  gs://alpinashop-catalogo/exportaciones/2026/03/14/pedidos-*.csv \
  ./esquema_pedidos.json
  • El comodín pedidos-*.csv permite cargar decenas de ficheros en una sola operación, y BigQuery los procesa en paralelo. Es mucho mejor que un fichero gigante.
  • --null_marker='\N' traduce la marca de nulo que usa pg_dump en formato CSV; sin esto acabarías con la cadena literal \N en las celdas.
  • Pasar el esquema en un fichero JSON es preferible a --autodetect. La autodetección adivina, y adivinar con dinero es mala idea: es capaz de inferir FLOAT64 para una columna de importes o STRING para una fecha con un formato raro.

Comparación honesta de formatos:

Formato Esquema incluido Comprimido Paralelizable Veredicto
CSV No Solo externamente (gzip: no paralelizable) Sí si no está comprimido Universal, frágil con comas y saltos de línea
JSON (NDJSON) No Igual que CSV Sí Bien para datos anidados; verboso y pesado
Avro Sí Sí, por bloques Sí, incluso comprimido La mejor opción para cargas repetidas
Parquet Sí Sí, columnar Sí Excelente; ideal si ya usas Spark (04-03)
ORC Sí Sí Sí Válido; habitual en migraciones desde Hive

Recomendación para AlpinaShop: Avro o Parquet en los procesos automáticos, porque llevan el esquema dentro y no hay ambigüedad de tipos ni sorpresas con las comas de los nombres de producto. CSV solo para lo que llega de terceros, como el fichero del transportista que veremos en 04-05.

Inserciones en streaming

Cuando el dato debe estar disponible en segundos —el panel de la campaña de otoño en tiempo real— se usa la Storage Write API, que sustituye a la antigua tabledata.insertAll y es más barata y con mejores garantías.

# Publicacion de un evento de compra en streaming hacia BigQuery.
# Requiere: pip install google-cloud-bigquery-storage
from google.cloud import bigquery

cliente = bigquery.Client(project="alpinashop-datos")
tabla = "alpinashop-datos.alpinashop_analitica.pedidos"

filas = [
    {
        "pedido_id": "PED-2026-0042",
        "fecha_pedido": "2026-03-14",
        "momento_pedido": "2026-03-14T10:22:31Z",
        "cliente_id": "CLI-8821",
        "canal": "web",
        "estado": "confirmado",
        "envio": {"pais": "ES", "provincia": "Barcelona",
                  "ciudad": "Barcelona", "codigo_postal": "08013",
                  "metodo": "estandar", "coste": "4.90"},
        "subtotal": "158.90",
        "total_pedido": "192.27",
    }
]

errores = cliente.insert_rows_json(tabla, filas)
if errores:
    # NUNCA ignores este retorno: los fallos de streaming son silenciosos
    raise RuntimeError(f"Filas rechazadas por BigQuery: {errores}")

Dos advertencias importantes sobre el streaming:

  1. Cuesta dinero por GB insertado, mientras que la carga por lotes es gratis. Si el dato puede esperar una hora, no lo transmitas en streaming: acumúlalo en el bucket y cárgalo por lotes. Es una de las optimizaciones de coste más rentables y más ignoradas.
  2. Para AlpinaShop, la vía real no será este código en la aplicación Flask, sino Pub/Sub → BigQuery (04-04) o Pub/Sub → Dataflow → BigQuery (04-02). Escribir directamente a BigQuery desde la web acopla la tienda al almacén analítico: si BigQuery tiene una incidencia, no queremos que la tienda deje de vender.

Tablas externas y BigLake

A veces el dato ya está en alpinashop-catalogo y no compensa duplicarlo. Una tabla externa deja los ficheros donde están y BigQuery los lee en el momento de la consulta.

-- Tabla externa sobre los ficheros del transportista, sin cargarlos
CREATE OR REPLACE EXTERNAL TABLE `alpinashop-datos.alpinashop_analitica.ext_envios_transportista`
OPTIONS (
  format = 'CSV',
  uris = ['gs://alpinashop-catalogo/exportaciones/*/*/*/envios-*.csv'],
  skip_leading_rows = 1,
  field_delimiter = ';'
);

Ventajas: cero duplicación, cero coste de ingesta, el dato siempre refleja el bucket. Inconvenientes: consultas más lentas, sin partición ni clustering, y sin caché de resultados. Úsalas para datos consultados de vez en cuando o como zona de aterrizaje antes de una carga real.

BigLake es la evolución de las tablas externas: añade control de acceso fino —permisos a nivel de columna y de fila sobre datos que viven en el bucket— sin que el analista necesite permiso sobre el bucket. Se apoya en una conexión de BigQuery con su propia cuenta de servicio, que es quien accede a Cloud Storage. Para AlpinaShop, eso significa que Lucía podrá consultar los CSV de exportación sin tener storage.objectViewer sobre alpinashop-catalogo, lo que encaja con el mínimo privilegio de 03-04.

  1. Traer el histórico desde Cloud SQL con consultas federadas

El histórico de pedidos vive en alpinashop-pedidos. Para traerlo hay una vía elegante: EXTERNAL_QUERY, la consulta federada, que ejecuta SQL directamente en PostgreSQL y devuelve el resultado a BigQuery como si fuera una tabla.

Primero, la conexión. La crea Marta una sola vez:

# 1) Crear la conexion de BigQuery hacia Cloud SQL
bq mk --connection \
  --connection_type=CLOUD_SQL \
  --properties='{"instanceId":"alpinashop-prod:europe-west1:alpinashop-pedidos-replica-informes","database":"tienda","type":"POSTGRES"}' \
  --connection_credential='{"username":"informes_lectura","password":"REEMPLAZAR"}' \
  --location=europe-west1 \
  conn-pedidos-postgres

# 2) Ver la cuenta de servicio que BigQuery ha creado para esta conexion
bq show --connection --location=europe-west1 alpinashop-datos.conn-pedidos-postgres

Fíjate en dos decisiones deliberadas:

  • Apuntamos a la réplica de lectura alpinashop-pedidos-replica-informes, no a la instancia principal. Es exactamente para lo que se creó en 02-03: que los informes no toquen la base de datos que atiende las compras.
  • Usamos el usuario informes_lectura, que solo tiene SELECT. La contraseña real debe salir de Secret Manager (db-password-catalogo es la del catálogo; para esto se creará su propio secreto), nunca de un fichero en el repositorio, como fijamos en 03-06.

Ahora la carga del histórico, en una sola sentencia:

-- Volcado inicial del historico de cabeceras de pedido desde PostgreSQL
INSERT INTO `alpinashop-datos.alpinashop_analitica.pedidos`
  (pedido_id, fecha_pedido, momento_pedido, cliente_id, email_cliente,
   canal, estado, envio, metodo_pago, cupon, subtotal, descuento, iva, total_pedido)
SELECT
  p.pedido_id,
  DATE(p.creado_en)                        AS fecha_pedido,
  p.creado_en                              AS momento_pedido,
  p.cliente_id,
  p.email                                  AS email_cliente,
  p.canal,
  p.estado,
  STRUCT(p.pais, p.provincia, p.ciudad,
         p.codigo_postal, p.metodo_envio, p.coste_envio)  AS envio,
  p.metodo_pago,
  p.cupon,
  p.subtotal,
  p.descuento,
  p.iva,
  p.total
FROM EXTERNAL_QUERY(
  'alpinashop-datos.europe-west1.conn-pedidos-postgres',
  '''SELECT pedido_id, creado_en, cliente_id, email, canal, estado,
            pais, provincia, ciudad, codigo_postal, metodo_envio, coste_envio,
            metodo_pago, cupon, subtotal, descuento, iva, total
     FROM pedidos
     WHERE creado_en >= '2024-01-01' '''
) AS p;

Cómo leerlo:

  • La cadena que va dentro de EXTERNAL_QUERY es SQL de PostgreSQL, no de BigQuery. Se ejecuta allí, con la sintaxis de allí. Las triples comillas evitan tener que escapar las comillas simples internas.
  • Filtrar dentro de esa cadena (WHERE creado_en >= ...) es crítico: cuantas menos filas cruce la red, mejor. Filtrar fuera, en BigQuery, traería igualmente todo.
  • El STRUCT(...) construye el bloque anidado envio a partir de las seis columnas planas que devuelve PostgreSQL. Aquí se ve la traducción entre los dos modelos.

Cuándo NO usar consultas federadas: para cargas grandes y repetidas. EXTERNAL_QUERY mete carga en la instancia de PostgreSQL y no paraleliza; sirve para el volcado inicial y para traer tablas pequeñas y frescas (el maestro de productos, por ejemplo). La sincronización diaria del histórico se hará con el patrón exportar → cargar orquestado en 04-06, o con CDC, que veremos en 04-05.

  1. SQL de negocio: las preguntas de Lucía, respondidas

BigQuery usa GoogleSQL, un dialecto estándar ANSI con extensiones. Si sabes SQL, sabes el 90 %. Vamos con las preguntas reales.

Ventas por categoría y mes

SELECT
  FORMAT_DATE('%Y-%m', l.fecha_pedido)      AS mes,
  pr.categoria,
  COUNT(DISTINCT l.pedido_id)               AS pedidos,
  SUM(l.cantidad)                           AS unidades,
  ROUND(SUM(l.importe_linea), 2)            AS ventas_eur
FROM `alpinashop-datos.alpinashop_analitica.lineas_pedido` AS l
JOIN `alpinashop-datos.alpinashop_analitica.productos`     AS pr
  ON pr.sku = l.sku
WHERE l.fecha_pedido BETWEEN DATE '2025-01-01' AND DATE '2026-12-31'
GROUP BY mes, pr.categoria
ORDER BY mes, ventas_eur DESC;

El WHERE sobre fecha_pedido no es cosmética: es lo que activa la poda de particiones y lo que hace que require_partition_filter=TRUE no rechace la consulta. Sin él, BigQuery leería la tabla entera.

Ticket medio y su evolución

SELECT
  DATE_TRUNC(fecha_pedido, MONTH)                        AS mes,
  COUNT(*)                                               AS num_pedidos,
  ROUND(AVG(total_pedido), 2)                            AS ticket_medio,
  ROUND(APPROX_QUANTILES(total_pedido, 100)[OFFSET(50)], 2) AS mediana,
  ROUND(APPROX_QUANTILES(total_pedido, 100)[OFFSET(90)], 2) AS percentil_90
FROM `alpinashop-datos.alpinashop_analitica.pedidos`
WHERE fecha_pedido >= DATE '2025-01-01'
  AND estado NOT IN ('cancelado', 'devuelto')
GROUP BY mes
ORDER BY mes;

APPROX_QUANTILES calcula percentiles de forma aproximada pero muchísimo más barata que el cálculo exacto sobre millones de filas. Incluir la mediana junto a la media no es un capricho estadístico: si un pedido corporativo de 4.000 € entra en marzo, la media se dispara y la mediana no se inmuta. Enseñar solo la media es cómo se engaña sin querer a un comité de dirección.

Productos sin ventas

-- Productos activos que no han vendido nada en los ultimos 90 dias
SELECT
  pr.sku, pr.nombre, pr.categoria, pr.precio_catalogo, pr.fecha_alta
FROM `alpinashop-datos.alpinashop_analitica.productos` AS pr
WHERE pr.activo = TRUE
  AND pr.sku NOT IN (
    SELECT DISTINCT sku
    FROM `alpinashop-datos.alpinashop_analitica.lineas_pedido`
    WHERE fecha_pedido >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
  )
ORDER BY pr.fecha_alta;

Cuidado con NOT IN y los nulos: si la subconsulta devolviera algún sku nulo, NOT IN devuelve cero filas sin avisar. En producción es más seguro LEFT JOIN ... WHERE l.sku IS NULL o NOT EXISTS.

Ranking de productos con funciones de ventana

-- Top 3 de productos por categoria, con su cuota dentro de la categoria
WITH ventas AS (
  SELECT
    pr.categoria,
    pr.sku,
    pr.nombre,
    SUM(l.importe_linea) AS ventas_eur
  FROM `alpinashop-datos.alpinashop_analitica.lineas_pedido` AS l
  JOIN `alpinashop-datos.alpinashop_analitica.productos`     AS pr USING (sku)
  WHERE l.fecha_pedido >= DATE '2026-01-01'
  GROUP BY pr.categoria, pr.sku, pr.nombre
)
SELECT
  categoria,
  RANK() OVER (PARTITION BY categoria ORDER BY ventas_eur DESC)   AS puesto,
  nombre,
  ROUND(ventas_eur, 2)                                            AS ventas_eur,
  ROUND(100 * ventas_eur
        / SUM(ventas_eur) OVER (PARTITION BY categoria), 1)       AS cuota_pct
FROM ventas
QUALIFY puesto <= 3
ORDER BY categoria, puesto;

Dos joyas aquí:

  • OVER (PARTITION BY categoria) calcula sobre cada grupo sin colapsar las filas, que es justo lo que un GROUP BY no puede hacer.
  • QUALIFY filtra por el resultado de una función de ventana directamente, sin necesidad de envolver todo en otra subconsulta. Es una extensión de GoogleSQL y ahorra muchísimo ruido.

UNNEST: el embudo de conversión sobre datos anidados

-- Embudo de conversion del mes: de sesion a compra
WITH sesion_flags AS (
  SELECT
    sesion_id,
    LOGICAL_OR(e.tipo = 'ver_producto')   AS vio_producto,
    LOGICAL_OR(e.tipo = 'anadir_carrito') AS anadio_carrito,
    LOGICAL_OR(e.tipo = 'iniciar_pago')   AS inicio_pago,
    LOGICAL_OR(e.tipo = 'compra')         AS compro
  FROM `alpinashop-datos.alpinashop_analitica.visitas`,
       UNNEST(eventos) AS e
  WHERE fecha BETWEEN DATE '2026-03-01' AND DATE '2026-03-31'
  GROUP BY sesion_id
)
SELECT
  COUNT(*)                                                   AS sesiones,
  COUNTIF(vio_producto)                                      AS vieron_producto,
  COUNTIF(anadio_carrito)                                    AS anadieron_carrito,
  COUNTIF(inicio_pago)                                       AS iniciaron_pago,
  COUNTIF(compro)                                            AS compraron,
  ROUND(100 * COUNTIF(compro) / NULLIF(COUNTIF(inicio_pago), 0), 1)
                                                             AS pct_pago_a_compra
FROM sesion_flags;

UNNEST(eventos) convierte el array de eventos de cada sesión en filas, y la coma antes de él es un CROSS JOIN implícito con la fila padre: cada evento conserva acceso a sesion_id, pais y demás. Es local a cada worker, sin shuffle. El último campo responde directamente a la pregunta del carrito abandonado que traía Lucía desde el módulo 3, y NULLIF(..., 0) evita la división por cero cuando un día no hay pagos iniciados.

  1. El modelo de coste y cómo no arruinarse

Este apartado vale por sí solo el precio de la lección. BigQuery es maravilloso y también es el servicio con el que más gente se lleva un susto en la factura.

Hay dos modelos de cómputo, y se pueden mezclar por proyecto:

Modelo Cómo se paga Orden de magnitud (verificar en la documentación oficial) Cuándo conviene
Bajo demanda Por bytes leídos por cada consulta ~5-6 $ por TB escaneado Uso irregular, exploración, empezar
Ediciones (capacidad) Por slots reservados por tiempo ~0,04-0,10 $ por slot-hora según edición Uso constante y predecible, coste fijo

Las ediciones actuales son Standard, Enterprise y Enterprise Plus, con reservas de slots que pueden ser de autoescalado (pagas los slots que se usan, con un mínimo) o comprometidas a uno o tres años con descuento. Cada una añade funciones: Standard cubre lo básico, Enterprise añade gobierno y CMEK, Enterprise Plus añade recuperación avanzada y residencia estricta.

Para AlpinaShop la decisión es bajo demanda, y hay que decirlo con sus números: un equipo de tres personas consultando unos pocos cientos de gigabytes al mes gasta unos pocos euros. Reservar slots tendría un coste fijo mensual muy superior. La regla práctica del sector es migrar a capacidad cuando el gasto bajo demanda supera de forma estable el coste de la reserva equivalente, algo que suele ocurrir a partir de decenas de terabytes consultados al mes.

Ahora, lo que de verdad hay que hacer todos los días.

Estimar antes de ejecutar: --dry-run

bq query --use_legacy_sql=false --dry_run \
'SELECT categoria, SUM(importe_linea)
 FROM `alpinashop-datos.alpinashop_analitica.lineas_pedido` l
 JOIN `alpinashop-datos.alpinashop_analitica.productos` p USING (sku)
 WHERE l.fecha_pedido >= "2026-01-01"
 GROUP BY categoria'

Devuelve algo como:

Query successfully validated. Assuming the tables are not modified,
running this query will process 428934112 bytes of data.

428 MB. A ~5 $/TB, unos 0,002 $. Sin problema. Pero el mismo cálculo sobre una consulta mal escrita puede devolver 900 GB y costar 4,50 $ cada vez que alguien pulse ejecutar. Diez analistas refrescando un panel cada cinco minutos convierten eso en una factura de cuatro cifras al mes.

En BigQuery Studio no hace falta el comando: la interfaz muestra arriba a la derecha, en tiempo real mientras escribes, "Esta consulta procesará X". Enséñale a mirar ahí a todo el que reciba acceso. Es la costumbre más rentable que puedes instalar en un equipo de datos.

Por qué SELECT * es caro

Se paga por los bytes de las columnas leídas. SELECT * sobre pedidos lee las catorce columnas; SELECT pedido_id, total_pedido lee dos. Diferencia habitual: entre cinco y veinte veces.

-- MAL: lee todas las columnas de todas las particiones
SELECT * FROM `alpinashop-datos.alpinashop_analitica.pedidos`;

-- MAL TAMBIEN: el LIMIT no reduce lo que se lee, solo lo que se muestra
SELECT * FROM `alpinashop-datos.alpinashop_analitica.pedidos` LIMIT 10;

-- BIEN: dos columnas y una particion
SELECT pedido_id, total_pedido
FROM `alpinashop-datos.alpinashop_analitica.pedidos`
WHERE fecha_pedido = DATE '2026-03-14';

-- Para echar un vistazo a los datos, usa la VISTA PREVIA, que es GRATIS
-- bq head -n 10 alpinashop-datos:alpinashop_analitica.pedidos

LIMIT no reduce el coste. Es el malentendido número uno. Para inspeccionar datos usa bq head o la pestaña Vista previa de la consola: leen directamente el almacenamiento y no cuestan nada.

Y una utilidad que sí ayuda cuando quieres muchas columnas menos una:

SELECT * EXCEPT(email_cliente, cupon)
FROM `alpinashop-datos.alpinashop_analitica.pedidos`
WHERE fecha_pedido = CURRENT_DATE();

  1. Particionado y clustering: el antes y el después medido

Ya declaramos partición y clustering al crear las tablas. Ahora midamos qué compran exactamente.

Partición: divide la tabla en trozos por el valor de una columna (fecha, rango entero, o el tiempo de ingesta). BigQuery descarta particiones enteras sin leerlas si el WHERE lo permite.

Clustering: ordena físicamente los datos dentro de cada partición por hasta cuatro columnas. Permite saltarse bloques dentro de la partición.

El experimento, con una tabla de pedidos de dos años (supongamos 40 GB):

# A) Tabla SIN particionar, filtrando por fecha
bq query --use_legacy_sql=false --dry_run \
'SELECT SUM(total_pedido) FROM `alpinashop-datos.alpinashop_scratch.pedidos_plana`
 WHERE fecha_pedido = "2026-03-14"'
# -> Esta consulta procesara 3.221.225.472 bytes  (3,0 GB: lee TODA la columna)

# B) Tabla PARTICIONADA por fecha_pedido, mismo filtro
bq query --use_legacy_sql=false --dry_run \
'SELECT SUM(total_pedido) FROM `alpinashop-datos.alpinashop_analitica.pedidos`
 WHERE fecha_pedido = "2026-03-14"'
# -> Esta consulta procesara 4.194.304 bytes  (4 MB: una sola particion)

De 3 GB a 4 MB: 750 veces menos. Y no ha cambiado ni una letra del SQL, solo la definición física de la tabla. A escala de un año de consultas diarias, esa diferencia es la que separa una factura de tres euros de una de dos mil.

El clustering no aparece en el --dry-run porque su ahorro solo se conoce al ejecutar (el planificador no sabe de antemano cuántos bloques podrá saltar). Se mide en el resultado real:

-- Ejecutar y despues consultar los bytes realmente facturados
SELECT
  job_id,
  query,
  total_bytes_processed,
  total_bytes_billed,
  ROUND(total_bytes_billed / POW(1024,3), 2) AS gb_facturados,
  TIMESTAMP_DIFF(end_time, start_time, MILLISECOND) AS ms
FROM `region-europe-west1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 HOUR)
  AND job_type = 'QUERY'
ORDER BY creation_time DESC
LIMIT 20;

Guía de decisión rápida:

Situación Qué hacer
Tabla con columna de fecha y consultas por rango Particionar por esa fecha, siempre
Filtros frecuentes por columnas de alta cardinalidad (sku, cliente_id) Clusterizar por ellas
Tabla de menos de ~1 GB Ninguna de las dos: no compensa la complejidad
Más de 4.000 particiones previstas Particionar por mes en lugar de por día
Quieres impedir escaneos completos require_partition_filter=TRUE

Un matiz sobre el orden en CLUSTER BY estado, canal: el orden importa. El clustering ordena primero por estado y dentro por canal. Un filtro solo por canal aprovecha mucho menos el clustering que un filtro por estado. Pon primero la columna por la que más filtras.

  1. Vistas materializadas, caché de resultados y cuotas

Tres mecanismos más para pagar menos, en orden de esfuerzo creciente.

Caché de resultados (gratis y automática)

Si ejecutas exactamente la misma consulta sobre datos que no han cambiado, BigQuery devuelve el resultado cacheado: coste cero y respuesta inmediata, durante unas 24 horas. Se invalida al modificar cualquier tabla implicada.

Se rompe sin querer con más facilidad de la que parece: basta con que la consulta use CURRENT_TIMESTAMP() o funciones no deterministas, o que el texto cambie en un carácter. Si un panel usa WHERE fecha >= CURRENT_DATE() - 7, nunca cacheará bien. Conviene fijar fechas explícitas cuando el informe lo permita.

Vistas y vistas materializadas

Una vista es SQL guardado: no ocupa espacio y se ejecuta —y se paga— entera cada vez.

CREATE OR REPLACE VIEW `alpinashop-datos.alpinashop_analitica.v_ventas_mensuales` AS
SELECT
  DATE_TRUNC(l.fecha_pedido, MONTH) AS mes,
  pr.categoria,
  SUM(l.importe_linea)              AS ventas_eur,
  SUM(l.cantidad)                   AS unidades
FROM `alpinashop-datos.alpinashop_analitica.lineas_pedido` AS l
JOIN `alpinashop-datos.alpinashop_analitica.productos`     AS pr USING (sku)
WHERE l.fecha_pedido >= DATE '2024-01-01'
GROUP BY mes, pr.categoria;

Una vista materializada guarda el resultado precalculado y BigQuery lo refresca de forma incremental cuando cambia la tabla base. Además, y esto es lo mejor, el optimizador la usa automáticamente aunque tú consultes la tabla original.

CREATE MATERIALIZED VIEW `alpinashop-datos.alpinashop_analitica.mv_ventas_diarias_sku`
PARTITION BY dia
CLUSTER BY sku
OPTIONS (enable_refresh = TRUE, refresh_interval_minutes = 60)
AS
SELECT
  fecha_pedido      AS dia,
  sku,
  SUM(cantidad)     AS unidades,
  SUM(importe_linea) AS ventas_eur,
  COUNT(*)          AS num_lineas
FROM `alpinashop-datos.alpinashop_analitica.lineas_pedido`
GROUP BY dia, sku;

El panel de Looker Studio que construirá Lucía en 04-07 leerá unos pocos megabytes de esta vista materializada en vez de escanear la tabla de líneas cada vez que alguien abra el informe. Limitaciones a conocer: solo admite un subconjunto de SQL (agregaciones sí; JOIN con restricciones y ventanas no), y el refresco incremental consume cómputo, así que no crees veinte.

Cuotas de coste: el cinturón de seguridad

Se pueden imponer límites duros de bytes procesados, por proyecto y por usuario:

# Máximo 2 TB al dia en todo el proyecto de analitica
gcloud alpha services quota update \
  --service=bigquery.googleapis.com \
  --consumer=projects/alpinashop-datos \
  --metric=bigquery.googleapis.com/quota/query/usage \
  --unit=1/d/{project} \
  --value=2199023255552

Y a nivel de sesión o de consulta individual:

# Rechaza la consulta si va a leer mas de 10 GB
bq query --use_legacy_sql=false --maximum_bytes_billed=10737418240 \
  'SELECT ... '

Para AlpinaShop la política es: cuota de proyecto en 2 TB/día, require_partition_filter en las tablas grandes, y una alerta de presupuesto en Facturación (01-04) a 50 € mensuales sobre la etiqueta centro-coste:analitica. Tres redes de seguridad independientes, ninguna cara.

  1. Almacenamiento lógico frente a físico

El almacenamiento tiene su propio modelo, y desde hace unos años se puede elegir cómo se factura por dataset.

Modo Qué se mide Orden de magnitud Cuándo elegirlo
Lógico (por defecto) Bytes de los datos sin comprimir ~0,02 $/GB/mes activo Datos poco comprimibles
Físico Bytes comprimidos realmente ocupados, más el time travel ~0,04 $/GB/mes activo, pero sobre muchos menos GB Datos muy repetitivos (lo habitual)

El precio por GB en modo físico es aproximadamente el doble, pero los GB suelen ser entre 4 y 10 veces menos porque BigQuery comprime muy bien las columnas repetitivas. Para datos como los de AlpinaShop —categorías, estados, países que se repiten millones de veces— el modo físico suele salir claramente más barato. Hay que medirlo antes de cambiar:

-- Comparar lo que costaria cada modo, con datos reales de tus tablas
SELECT
  table_name,
  ROUND(total_logical_bytes  / POW(1024,3), 2) AS gb_logicos,
  ROUND(total_physical_bytes / POW(1024,3), 2) AS gb_fisicos,
  ROUND(total_logical_bytes / NULLIF(total_physical_bytes, 0), 1) AS ratio_compresion
FROM `alpinashop-datos.alpinashop_analitica`.INFORMATION_SCHEMA.TABLE_STORAGE
ORDER BY total_logical_bytes DESC;

Además existe el precio de larga duración: una partición que no se modifica durante 90 días consecutivos baja automáticamente de precio, alrededor de la mitad. Es automático, no hay que hacer nada, y no afecta al rendimiento ni a la disponibilidad. Como la mayor parte del histórico de AlpinaShop no se toca nunca, buena parte del almacenamiento acabará a esa tarifa reducida sola.

Cuidado con un detalle: cualquier UPDATE sobre una partición reinicia su contador de 90 días. Un proceso mal diseñado que reescriba el histórico entero cada noche mantiene toda la tabla en tarifa alta para siempre. Es otro argumento a favor de las cargas incrementales por partición.

  1. Control de acceso: dataset, tabla, columna y fila

Aquí se aplica todo lo de IAM (03-04), más dos capas que solo existen en BigQuery.

Nivel dataset y tabla

Los roles predefinidos relevantes:

Rol Qué permite Para quién en AlpinaShop
roles/bigquery.dataViewer Leer datos y metadatos Lucía y gcp-datos@, sobre alpinashop_analitica
roles/bigquery.dataEditor Además, crear y modificar tablas Cuentas de servicio de los pipelines
roles/bigquery.dataOwner Además, borrar el dataset y dar permisos Nadie de forma permanente
roles/bigquery.jobUser Ejecutar consultas (facturarlas al proyecto) Todo el que consulte
roles/bigquery.user jobUser + crear datasets + leer metadatos Perfil de analista estándar

El matiz que confunde a todo el mundo: dataViewer no basta para consultar. Ver los datos y ejecutar un trabajo son permisos distintos. Hace falta también jobUser en el proyecto donde se ejecuta la consulta. Si Lucía dice "veo la tabla pero no puedo consultarla", es esto el 90 % de las veces.

# Lectura sobre el dataset para el grupo de datos
bq add-iam-policy-binding \
  --member='group:[email protected]' \
  --role='roles/bigquery.dataViewer' \
  alpinashop-datos:alpinashop_analitica

# Y permiso para lanzar consultas en el proyecto
gcloud projects add-iam-policy-binding alpinashop-datos \
  --member='group:[email protected]' \
  --role='roles/bigquery.jobUser'

Como siempre desde 03-04: permisos a grupos, nunca a personas. Cuando entre otro analista, basta con meterlo en gcp-datos@.

Recordemos también el rol personalizado analistaCatalogo que se creó en 03-04 en alpinashop-datos. Ahora tiene sentido completarlo con los permisos analíticos mínimos:

gcloud iam roles update analistaCatalogo --project=alpinashop-datos \
  --add-permissions=bigquery.jobs.create,bigquery.tables.getData,\
bigquery.tables.list,bigquery.datasets.get,bigquery.routines.get

Nivel columna: ocultar el email del cliente

Aviso de RGPD. email_cliente es un dato personal. Su tratamiento con fines analíticos exige base legal, minimización y limitación del acceso. Lo que sigue es un control técnico, no un dictamen jurídico: cualquier tratamiento de datos personales reales debe revisarlo un profesional de compliance o el DPO antes de ponerse en producción. Todos los datos de este curso son ficticios.

El control se hace con etiquetas de política de Data Catalog. La idea: se etiqueta la columna, y solo quien tenga el rol Lector detallado sobre esa etiqueta puede leerla.

# 1) Taxonomia de sensibilidad para AlpinaShop
gcloud data-catalog taxonomies create \
  --location=europe-west1 \
  --display-name="Sensibilidad AlpinaShop" \
  --activated-policy-types=FINE_GRAINED_ACCESS_CONTROL

TAXO=$(gcloud data-catalog taxonomies list --location=europe-west1 \
  --format="value(name)" --filter="displayName='Sensibilidad AlpinaShop'")

# 2) Etiqueta para datos personales directos
gcloud data-catalog taxonomies policy-tags create \
  --taxonomy="$TAXO" --display-name="pii-directo" \
  --description="Identifica directamente a una persona: email, telefono, direccion"

PT=$(gcloud data-catalog taxonomies policy-tags list --taxonomy="$TAXO" \
  --format="value(name)" --filter="displayName='pii-directo'")

# 3) Solo el grupo de seguridad puede leer columnas con esa etiqueta
gcloud data-catalog taxonomies policy-tags add-iam-policy-binding "$PT" \
  --member='group:[email protected]' \
  --role='roles/datacatalog.categoryFineGrainedReader'

Y se aplica a la columna:

ALTER TABLE `alpinashop-datos.alpinashop_analitica.pedidos`
ALTER COLUMN email_cliente
SET OPTIONS (
  policy_tags = ['projects/alpinashop-datos/locations/europe-west1/taxonomies/TAXO_ID/policyTags/PT_ID']
);

A partir de ese momento, si Lucía ejecuta SELECT * FROM pedidos, la consulta falla entera con un error de acceso a email_cliente. Debe escribir SELECT * EXCEPT(email_cliente) o listar columnas. Es un efecto secundario deseable: refuerza la costumbre de no usar SELECT *, que ya sabemos que además es cara.

Para AlpinaShop, la recomendación completa es no traer el email en crudo al almacén analítico. Lucía necesita saber cuántos clientes distintos compraron, no quiénes. Un identificador seudonimizado sirve igual:

-- Vista de trabajo para analitica: sin datos personales directos
CREATE OR REPLACE VIEW `alpinashop-datos.alpinashop_analitica.v_pedidos_analitica` AS
SELECT
  * EXCEPT(email_cliente),
  TO_HEX(SHA256(CONCAT(email_cliente, 'sal-secreta-desde-secret-manager'))) AS cliente_hash
FROM `alpinashop-datos.alpinashop_analitica.pedidos`;

El sal debe venir de Secret Manager (03-06) y no estar escrito en el SQL como aquí; sin sal, un hash de email es reversible por fuerza bruta y sigue siendo dato personal. En 04-07 veremos la herramienta específica para esto, Sensitive Data Protection.

Nivel fila

Una política de acceso a filas filtra qué filas ve cada identidad. Útil si mañana AlpinaShop abre filial en Francia y cada equipo comercial solo debe ver su mercado:

CREATE ROW ACCESS POLICY pol_solo_espana
ON `alpinashop-datos.alpinashop_analitica.pedidos`
GRANT TO ('group:[email protected]')
FILTER USING (envio.pais = 'ES');

El filtro se aplica de forma transparente e ineludible: el usuario escribe su SELECT normal y solo obtiene las filas de España, sin saber que existe la política.

Compartir sin copiar: vistas autorizadas

Si quieres dar acceso al resultado pero no a la tabla base, se usa una vista autorizada: se concede permiso sobre la vista, la vista tiene permiso sobre la tabla, y el usuario no. Es el mecanismo limpio para exponer datos agregados a marketing sin abrirles el detalle de pedidos.

bq update --view_udf_resource= --authorized_view \
  --source_dataset=alpinashop-datos:alpinashop_analitica \
  alpinashop-datos:alpinashop_analitica.v_ventas_mensuales

  1. INFORMATION_SCHEMA: auditar quién gasta y en qué

BigQuery se observa a sí mismo. INFORMATION_SCHEMA son vistas de solo lectura con metadatos y con el historial de trabajos. Esta consulta debería ejecutarse una vez por semana en cualquier equipo serio:

-- Las 20 consultas mas caras de los ultimos 7 dias, con su coste estimado
SELECT
  user_email,
  job_id,
  DATE(creation_time)                                     AS dia,
  ROUND(total_bytes_billed / POW(1024,4), 3)              AS tb_facturados,
  ROUND(total_bytes_billed / POW(1024,4) * 6.25, 2)       AS coste_eur_aprox,
  TIMESTAMP_DIFF(end_time, start_time, SECOND)            AS segundos,
  total_slot_ms,
  cache_hit,
  SUBSTR(REGEXP_REPLACE(query, r'\s+', ' '), 1, 160)      AS consulta
FROM `region-europe-west1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
  AND job_type = 'QUERY'
  AND state = 'DONE'
  AND error_result IS NULL
ORDER BY total_bytes_billed DESC
LIMIT 20;

El precio de 6,25 está puesto a mano como orden de magnitud: sustitúyelo por el vigente en tu región y verifícalo en la documentación oficial, porque cambia.

Otras consultas útiles del mismo sitio:

-- Gasto por usuario y dia: para saber a quien hay que formar
SELECT
  user_email,
  DATE(creation_time) AS dia,
  COUNT(*) AS consultas,
  COUNTIF(cache_hit) AS desde_cache,
  ROUND(SUM(total_bytes_billed) / POW(1024,4), 3) AS tb_facturados
FROM `region-europe-west1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  AND job_type = 'QUERY'
GROUP BY user_email, dia
ORDER BY tb_facturados DESC;

-- Tablas que nadie consulta pero que seguimos pagando
SELECT table_name,
       ROUND(total_logical_bytes / POW(1024,3), 2) AS gb,
       TIMESTAMP_MILLIS(last_modified_time)        AS ultima_modificacion
FROM `alpinashop-datos.alpinashop_analitica`.INFORMATION_SCHEMA.TABLE_STORAGE
ORDER BY gb DESC;

Este último es el equivalente analítico de la limpieza de discos huérfanos de 02-01: en todo almacén de datos con un año de vida hay tablas _backup_final_v2 que nadie recuerda y que se pagan cada mes.

  1. BigQuery ML, mencionado y aplazado

BigQuery incluye BigQuery ML, que permite entrenar modelos con sintaxis SQL: CREATE MODEL ... OPTIONS(model_type='linear_reg'), y luego ML.PREDICT. Se puede hacer regresión, clasificación, series temporales con ARIMA_PLUS, k-means, factorización matricial para recomendaciones e incluso invocar modelos de Vertex AI desde SQL.

Para AlpinaShop es una vía muy atractiva: previsión de demanda por SKU antes de la campaña de otoño, o segmentación de clientes, sin sacar el dato de BigQuery ni montar infraestructura.

Pero eso es módulo 5. Allí veremos Vertex AI, AutoML y los modelos de recomendación con el rigor que merecen: cómo se separan los datos de entrenamiento y validación, cómo se evalúa un modelo y por qué una métrica buena en pruebas puede ser inútil en producción. Aquí solo queda anotado que la puerta existe y que está a un CREATE MODEL de distancia.

Errores Comunes y Consejos

Crear el dataset en US por descuido. Es la opción por defecto de la consola y es irreversible. Además de impedir los JOIN con europe-west1, saca datos personales de clientes europeos fuera de la UE. Comprueba siempre la ubicación antes de cargar el primer byte, y fija --location=europe-west1 en la configuración del equipo.

Creer que LIMIT abarata la consulta. No lo hace: se leen los mismos bytes. Para mirar datos, bq head o la vista previa, que son gratis.

Usar SELECT * por costumbre. En un motor columnar es entre cinco y veinte veces más caro. Escribe las columnas, o usa SELECT * EXCEPT(...).

Olvidar el filtro de partición. Sin WHERE fecha_pedido ..., una tabla particionada se lee entera y la partición no sirve de nada. Activa require_partition_filter=TRUE y deja que el motor te proteja.

Usar FLOAT64 para importes. Los errores de redondeo aparecen justo en el informe que ve dirección. NUMERIC siempre para dinero.

Hacer UPDATE y DELETE como en PostgreSQL. BigQuery los admite, pero reescriben particiones enteras, son caros y reinician el contador de larga duración. Para actualizaciones masivas, MERGE o reescritura de la partición completa con WRITE_TRUNCATE. Y para corregir un dato de un pedido, hazlo en Cloud SQL y vuelve a sincronizar.

Escribir en BigQuery desde la aplicación web. Acopla la tienda al almacén analítico. Publica en Pub/Sub (04-04) y que sea otro proceso quien escriba. La tienda debe poder vender aunque la analítica esté caída.

Dar dataViewer y olvidar jobUser. Es el ticket de soporte más repetido del mundo BigQuery.

Poner LIMIT a una consulta que ordena. ORDER BY sobre millones de filas obliga a un ordenamiento global que puede agotar los recursos con el error Resources exceeded. Agrega primero, ordena después; o usa funciones de ventana con QUALIFY.

Consejo: usa etiquetas en los trabajos. bq query --label=proceso:panel-direccion permite después separar en INFORMATION_SCHEMA cuánto cuesta cada consumidor de datos. Cuando alguien pregunte "¿cuánto nos cuesta el panel de dirección?", tendrás el número.

Consejo: guarda el DDL en Git. Las sentencias CREATE TABLE de esta lección son código. Deben estar versionadas junto al resto del proyecto, no solo dentro de BigQuery. En 06-07 las convertiremos en Terraform.

Ejercicios

Ejercicio 1: dataset, tabla y primera carga

Crea en alpinashop-datos un dataset de pruebas llamado alpinashop_ejercicios en europe-west1 cuyas tablas caduquen a los 3 días. Dentro, crea una tabla opiniones con: opinion_id (STRING, obligatorio), sku (STRING, obligatorio), fecha (DATE, obligatorio), puntuacion (INT64), texto (STRING), pais (STRING). Debe estar particionada por fecha, clusterizada por sku y rechazar consultas sin filtro de partición. Después inserta tres opiniones de ejemplo y comprueba con --dry-run cuántos bytes cuesta consultar las de un único día.

Ejercicio 2: la consulta de dirección

Con las tablas de alpinashop_analitica, escribe una única consulta que devuelva, para cada mes de 2026 y solo para pedidos no cancelados ni devueltos: el mes, el número de pedidos, la facturación total, el ticket medio, la categoría más vendida de ese mes y el porcentaje que esa categoría representa sobre el total del mes. Ordena por mes. Antes de ejecutarla, estima su coste con --dry-run.

Ejercicio 3: diagnóstico de una factura disparada

Marta recibe la alerta de presupuesto: alpinashop-datos lleva gastados 180 € este mes cuando el histórico eran 12 €. Nadie ha cargado datos nuevos. Escribe las consultas de INFORMATION_SCHEMA que te permitan (a) identificar al usuario o cuenta de servicio responsable, (b) aislar la consulta concreta y cuántas veces se ha ejecutado, y (c) determinar si aprovecha la caché. Después propón tres medidas correctoras concretas, ordenadas de la más inmediata a la más estructural.

Soluciones

Solución 1

gcloud config set project alpinashop-datos

# Dataset de pruebas con caducidad de 3 dias (259200 segundos)
bq --location=europe-west1 mk --dataset \
  --description="Dataset de ejercicios; las tablas caducan a los 3 dias" \
  --default_table_expiration=259200 \
  --label=entorno:desarrollo --label=equipo:datos \
  alpinashop-datos:alpinashop_ejercicios
CREATE TABLE `alpinashop-datos.alpinashop_ejercicios.opiniones`
(
  opinion_id  STRING NOT NULL,
  sku         STRING NOT NULL,
  fecha       DATE   NOT NULL,
  puntuacion  INT64  OPTIONS(description="De 1 a 5"),
  texto       STRING OPTIONS(description="Texto libre del cliente"),
  pais        STRING
)
PARTITION BY fecha
CLUSTER BY sku
OPTIONS(
  description="Opiniones de clientes sobre productos (datos ficticios)",
  require_partition_filter=TRUE
);

INSERT INTO `alpinashop-datos.alpinashop_ejercicios.opiniones`
  (opinion_id, sku, fecha, puntuacion, texto, pais)
VALUES
  ('OPI-0001','MOCH-40L-AZ', DATE '2026-03-10', 5,
   'Muy comoda para travesias largas, las cinchas aguantan bien.','ES'),
  ('OPI-0002','FRON-300L',   DATE '2026-03-10', 3,
   'La bateria dura menos de lo anunciado en modo potente.','FR'),
  ('OPI-0003','CRAM-12P',    DATE '2026-03-11', 4,
   'Buen agarre en hielo duro, aunque pesan algo.','ES');
bq query --use_legacy_sql=false --dry_run \
'SELECT sku, AVG(puntuacion) AS media
 FROM `alpinashop-datos.alpinashop_ejercicios.opiniones`
 WHERE fecha = "2026-03-10"
 GROUP BY sku'

El resultado será de unos pocos cientos de bytes: con tres filas, es casi todo metadatos. Lo interesante es el hábito, no la cifra. Y si quitas el WHERE fecha, la consulta no será cara: fallará, porque require_partition_filter=TRUE la rechaza. Ese error es exactamente el comportamiento deseado.

Solución 2

WITH pedidos_validos AS (
  SELECT
    DATE_TRUNC(fecha_pedido, MONTH) AS mes,
    pedido_id,
    total_pedido
  FROM `alpinashop-datos.alpinashop_analitica.pedidos`
  WHERE fecha_pedido BETWEEN DATE '2026-01-01' AND DATE '2026-12-31'
    AND estado NOT IN ('cancelado', 'devuelto')
),
resumen_mes AS (
  SELECT
    mes,
    COUNT(*)                       AS num_pedidos,
    ROUND(SUM(total_pedido), 2)    AS facturacion_eur,
    ROUND(AVG(total_pedido), 2)    AS ticket_medio
  FROM pedidos_validos
  GROUP BY mes
),
ventas_categoria AS (
  SELECT
    DATE_TRUNC(l.fecha_pedido, MONTH) AS mes,
    pr.categoria,
    SUM(l.importe_linea)              AS ventas_cat
  FROM `alpinashop-datos.alpinashop_analitica.lineas_pedido` AS l
  JOIN `alpinashop-datos.alpinashop_analitica.productos`     AS pr USING (sku)
  JOIN pedidos_validos                                       AS pv USING (pedido_id)
  WHERE l.fecha_pedido BETWEEN DATE '2026-01-01' AND DATE '2026-12-31'
  GROUP BY mes, pr.categoria
),
top_categoria AS (
  SELECT
    mes,
    categoria,
    ventas_cat,
    SUM(ventas_cat) OVER (PARTITION BY mes) AS ventas_mes_total
  FROM ventas_categoria
  QUALIFY ROW_NUMBER() OVER (PARTITION BY mes ORDER BY ventas_cat DESC) = 1
)
SELECT
  FORMAT_DATE('%Y-%m', r.mes)                              AS mes,
  r.num_pedidos,
  r.facturacion_eur,
  r.ticket_medio,
  t.categoria                                              AS categoria_top,
  ROUND(100 * t.ventas_cat / NULLIF(t.ventas_mes_total, 0), 1) AS cuota_top_pct
FROM resumen_mes AS r
LEFT JOIN top_categoria AS t USING (mes)
ORDER BY mes;

Las claves de la solución:

  • Los CTE (WITH) parten el problema en pasos legibles. BigQuery los evalúa como parte del plan; no crean tablas ni cuestan aparte.
  • QUALIFY ROW_NUMBER() OVER (...) = 1 se queda con la categoría líder de cada mes en una sola pasada. ROW_NUMBER y no RANK porque queremos exactamente una fila aunque haya empate.
  • El SUM(...) OVER (PARTITION BY mes) calcula el total del mes sin colapsar las filas, lo que permite dividir para obtener la cuota.
  • Los filtros de fecha están en las dos tablas particionadas. Omitirlos en lineas_pedido haría que el JOIN escaneara la tabla completa: la consulta daría el mismo resultado y costaría veinte veces más.
  • NULLIF(..., 0) protege de la división por cero en un mes sin ventas.

El --dry-run sobre un histórico de dos años debería dar unos cientos de megabytes: céntimos. Sin los filtros de partición, decenas de gigabytes.

Solución 3

(a) Identificar al responsable:

SELECT
  user_email,
  COUNT(*)                                          AS consultas,
  ROUND(SUM(total_bytes_billed)/POW(1024,4), 2)     AS tb_facturados,
  ROUND(SUM(total_bytes_billed)/POW(1024,4)*6.25,2) AS coste_aprox_eur
FROM `region-europe-west1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_TRUNC(CURRENT_TIMESTAMP(), MONTH)
  AND job_type = 'QUERY'
GROUP BY user_email
ORDER BY tb_facturados DESC;

(b) Aislar la consulta y contar ejecuciones:

SELECT
  SUBSTR(REGEXP_REPLACE(query, r'\s+', ' '), 1, 200)          AS consulta,
  COUNT(*)                                                    AS ejecuciones,
  ROUND(AVG(total_bytes_billed)/POW(1024,3), 2)               AS gb_por_ejecucion,
  ROUND(SUM(total_bytes_billed)/POW(1024,4), 2)               AS tb_totales,
  MIN(creation_time)                                          AS primera,
  MAX(creation_time)                                          AS ultima
FROM `region-europe-west1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_TRUNC(CURRENT_TIMESTAMP(), MONTH)
  AND job_type = 'QUERY'
GROUP BY consulta
ORDER BY tb_totales DESC
LIMIT 10;

(c) Comprobar el aprovechamiento de caché:

SELECT
  user_email,
  COUNTIF(cache_hit)                                   AS desde_cache,
  COUNTIF(NOT cache_hit)                               AS sin_cache,
  ROUND(100 * COUNTIF(cache_hit) / COUNT(*), 1)        AS pct_cache
FROM `region-europe-west1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_TRUNC(CURRENT_TIMESTAMP(), MONTH)
  AND job_type = 'QUERY'
GROUP BY user_email;

Diagnóstico típico y esperable: el culpable no es una persona, sino un cuadro de mando que se autorrefresca cada 5 minutos ejecutando SELECT * FROM lineas_pedido sin filtro de fecha. Un pct_cache cercano a cero lo confirma: si la consulta lleva CURRENT_TIMESTAMP() o la tabla recibe escrituras continuas, la caché nunca sirve. 288 ejecuciones al día × 2 GB × 30 días ≈ 17 TB ≈ 100 €.

Tres medidas correctoras, de lo inmediato a lo estructural:

  1. Hoy mismo (contención): aplicar --maximum_bytes_billed al usuario o a la cuenta de servicio del panel y fijar la cuota diaria del proyecto en 2 TB. La sangría se detiene en minutos aunque nadie toque el panel.
  2. Esta semana (corrección): reescribir la consulta del panel con las columnas explícitas y filtro de partición, y bajar el refresco de 5 minutos a 1 hora. Suele bastar para dividir el coste por cien.
  3. Este mes (estructural): crear una vista materializada con la agregación que el panel necesita y apuntar el cuadro de mando a ella; activar require_partition_filter=TRUE en las tablas grandes para que el problema no pueda repetirse; y programar la consulta de auditoría semanal con una alerta si algún usuario supera un umbral de TB.

La medida 3 es la única que impide la reincidencia. Las dos primeras compran tiempo.

Conclusión

AlpinaShop ya tiene almacén de datos. En esta lección has entendido la diferencia real —no la de eslogan— entre OLTP y OLAP: no es cuestión de tamaño, sino de forma de acceso, y esa forma es la que justifica el almacenamiento columnar, la separación de almacenamiento y cómputo, y la ejecución distribuida en slots que hace que una consulta sobre ochocientos millones de filas termine en segundos.

Has creado el dataset alpinashop_analitica en alpinashop-datos, en europe-west1, sabiendo que esa ubicación es una decisión irreversible con implicaciones de cumplimiento y de capacidad de cruzar datos. Has definido las tablas pedidos, lineas_pedido, productos y visitas con tipos correctos —NUMERIC para el dinero, siempre—, con STRUCT para el bloque de envío y con un ARRAY<STRUCT<...>> de eventos dentro de cada sesión, entendiendo por qué la desnormalización, que en PostgreSQL sería un error, aquí es la forma de evitar el shuffle que es lo verdaderamente caro en un motor distribuido.

Has cargado datos por las cuatro vías y sabes cuál usar: lotes desde Cloud Storage —gratis— para el volumen, streaming solo cuando los segundos importan, tablas externas y BigLake para lo que no compensa duplicar, y EXTERNAL_QUERY contra la réplica alpinashop-pedidos-replica-informes para el volcado inicial del histórico, apuntando deliberadamente a la réplica y con el usuario informes_lectura de solo lectura.

Has escrito el SQL que responde a las preguntas que Lucía llevaba tres módulos acumulando: ventas por categoría y mes, ticket medio con su mediana al lado para no engañar a nadie, productos que no venden, ranking por categoría con funciones de ventana y QUALIFY, y el embudo de conversión completo con UNNEST sobre los eventos anidados, que por fin pone número al 30 % de carritos abandonados.

Y, sobre todo, has aprendido a no arruinarte: --dry-run antes de ejecutar, SELECT * desterrado, LIMIT desenmascarado como falso ahorro, particionado por fecha que convirtió 3 GB en 4 MB, clustering ordenado por la columna que más filtras, require_partition_filter como cinturón de seguridad, vistas materializadas para los paneles, caché de resultados que es gratis si no la rompes, cuotas por proyecto y por consulta, almacenamiento físico frente a lógico medido con datos propios, y INFORMATION_SCHEMA como la herramienta que convierte "la factura ha subido" en "esta consulta, este usuario, 288 veces al día". Has cerrado el acceso con roles a grupos, has recordado que dataViewer sin jobUser no consulta nada, y has protegido email_cliente con una etiqueta de política —con la advertencia expresa de RGPD y la recomendación de no traer el email en crudo al almacén, sino un identificador seudonimizado con sal de Secret Manager.

Queda un hueco evidente. Todo lo que hemos cargado es histórico: una foto de lo que ya pasó, traída de PostgreSQL de una vez. Pero AlpinaShop no para de vender. Ahora mismo, mientras lees esto, hay clientes navegando el catálogo, añadiendo mochilas al carrito y confirmando pedidos, y ninguno de esos hechos llega a alpinashop_analitica. Sincronizar a mano cada noche es un parche que ya se rompe cuando alguien pide "quiero ver las ventas de la campaña hoy, no mañana".

En 04-02, Cloud Dataflow, construiremos la tubería que falta. Verás qué es un pipeline de datos y por qué un script en una VM no basta; aprenderás el modelo de Apache Beam, que describe con el mismo código un proceso por lotes y uno en tiempo real; escribirás en Python un pipeline que lee el histórico del bucket alpinashop-catalogo, lo limpia, lo agrega y lo escribe en las tablas que acabas de crear; y entrarás en el territorio realmente interesante del streaming —tiempo del evento frente a tiempo de proceso, marcas de agua, ventanas y datos tardíos— para dejar preparado el pipeline que consumirá el topic pedidos-nuevos en cuanto lo creemos en 04-04. Cuando termines esa lección, los datos dejarán de llegar cuando alguien se acuerda y empezarán a llegar solos.

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