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
- Por qué un almacén analítico no es "una base de datos más grande"
- Cómo funciona BigQuery por dentro: columnas, separación y Dremel
- Cloud SQL frente a BigQuery: la tabla que hay que interiorizar
- Estructura: proyecto, dataset, tabla y vista
- Creación del dataset
alpinashop_analitica - Tipos de datos,
STRUCTyARRAY: la desnormalización como virtud - Creación de las tablas de AlpinaShop
- Cargar datos: desde Cloud Storage, en streaming y sin cargarlos
- Traer el histórico desde Cloud SQL con consultas federadas
- SQL de negocio: las preguntas de Lucía, respondidas
- El modelo de coste y cómo no arruinarse
- Particionado y clustering: el antes y el después medido
- Vistas materializadas, caché de resultados y cuotas
- Almacenamiento lógico frente a físico
- Control de acceso: dataset, tabla, columna y fila
INFORMATION_SCHEMA: auditar quién gasta y en qué- BigQuery ML, mencionado y aplazado
- 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 columnapais_enviopara saber qué filas cumplen. La única forma de no leer datos es que estén particionados o clusterizados, que veremos en el apartado 12.
- 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.
- 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.
- 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-datospagando desde su propio proyecto. Para AlpinaShop, todo se ejecuta y se factura enalpinashop-datos, que ya tiene su etiquetacentro-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
JOINentre datasets de ubicaciones distintas. Si mañana alguien creaalpinashop_marketingenus-central1, no podrá cruzarlo conalpinashop_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:
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).
- Creación del dataset
alpinashop_analitica
alpinashop_analiticaTrabajaremos 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_analiticaOpción por opción:
--location=europe-west1: la ubicación inmutable. Va antes del subcomandomk, 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 pusieras2592000(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-costeque 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_scratchVerificación:
bq ls --format=prettyjson --datasets alpinashop-datos
bq show --format=prettyjson alpinashop-datos:alpinashop_analiticaEn 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
- Tipos de datos,
STRUCT y ARRAY: la desnormalización como virtud
STRUCT y ARRAY: la desnormalización como virtudLos 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 tipoTdentro 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.
- 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_pedidodivide 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, canalordena los datos dentro de cada partición por esas columnas, en ese orden. Los filtros porestadopodrán saltarse bloques enteros.require_partition_filter=TRUEes la mejor decisión defensiva de toda la lección: rechaza cualquier consulta que no filtre porfecha_pedido. UnSELECT * FROM pedidosfallará 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.
- 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-*.csvpermite 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 usapg_dumpen formato CSV; sin esto acabarías con la cadena literal\Nen 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 inferirFLOAT64para una columna de importes oSTRINGpara 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:
- 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.
- 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.
- 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-postgresFí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 tieneSELECT. La contraseña real debe salir de Secret Manager (db-password-catalogoes 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_QUERYes 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 anidadoenvioa 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.
- 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 unGROUP BYno puede hacer.QUALIFYfiltra 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.
- 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.pedidosLIMIT 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();
- 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.
- 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=2199023255552Y 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.
- 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.
- 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.getNivel columna: ocultar el email del cliente
Aviso de RGPD.
email_clientees 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
INFORMATION_SCHEMA: auditar quién gasta y en qué
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.
- 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_ejerciciosCREATE 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 (...) = 1se queda con la categoría líder de cada mes en una sola pasada.ROW_NUMBERy noRANKporque 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_pedidoharía que elJOINescaneara 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:
- Hoy mismo (contención): aplicar
--maximum_bytes_billedal 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. - 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.
- 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=TRUEen 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
- ¿Qué es Google Cloud Platform?
- Configuración de tu cuenta de GCP
- Descripción general de la consola de GCP
- Proyectos, jerarquía de recursos y facturación
- Regiones, zonas y modelo de responsabilidad compartida
- Cloud Shell y la CLI de gcloud
Módulo 2: Servicios principales de GCP
- Compute Engine: máquinas virtuales en Google Cloud
- Cloud Storage: almacenamiento de objetos
- Cloud SQL: bases de datos relacionales gestionadas
- App Engine: plataforma como servicio
- Google Kubernetes Engine (GKE)
- Bases de datos NoSQL: Firestore, Bigtable y Spanner
- Cómo elegir el servicio de cómputo adecuado
Módulo 3: Redes y seguridad
- Redes VPC
- Balanceo de carga en la nube
- Cloud CDN
- Gestión de identidad y acceso (IAM)
- Cloud Armor
- Secretos y cifrado: Secret Manager y Cloud KMS
- Cloud DNS, certificados TLS y publicación segura de servicios
Módulo 4: Datos y análisis
- BigQuery: el almacén de datos analítico
- Cloud Dataflow: procesamiento de datos por lotes y en streaming
- Cloud Dataproc: Spark y Hadoop gestionados
- Cloud Pub/Sub: mensajería asíncrona
- Cloud Data Fusion: integración de datos sin código
- Orquestación de pipelines con Cloud Composer y Workflows
- Gobierno del dato y cuadros de mando con Dataplex y Looker Studio
Módulo 5: Aprendizaje automático e IA
- Vertex AI: la plataforma de machine learning de GCP
- AutoML: modelos a medida sin escribir código
- TensorFlow en GCP: entrenamiento y servicio de modelos
- API de lenguaje natural
- API de visión
- IA generativa en Vertex AI: modelos Gemini y embeddings
- MLOps: del modelo al producto con Vertex AI Pipelines
Módulo 6: DevOps y monitoreo
- Cloud Build: integración continua en GCP
- Cloud Source Repositories y gestión del código fuente
- Cloud Functions: funciones sin servidor
- Cloud Monitoring (antes Stackdriver): métricas, paneles y alertas
- Cloud Deployment Manager e infraestructura como código nativa
- Cloud Logging y Cloud Trace: logs, trazas y diagnóstico
- Terraform en GCP: infraestructura como código en la práctica
Módulo 7: Temas avanzados de GCP
- Híbrido y multinube con Anthos
- Computación sin servidor con Cloud Run
- Redes avanzadas: VPC compartida, peering y conectividad híbrida
- Mejores prácticas de seguridad
- Gestión y optimización de costos
- Fiabilidad: SLO, alta disponibilidad y recuperación ante desastres
- Gobierno a escala: organización, políticas y auditoría
