Volvemos a mirar el plano completo, como prometimos al cerrar el módulo 7. Durante siete módulos has construido BiblioRed a base de piezas: una tabla aquí, un JOIN allá, un índice cuando algo iba lento. Cada pieza se explicaba por separado porque había que aprenderla por separado. En un proyecto real nadie te entrega las piezas ordenadas: te entregan una conversación con un cliente y tienes que llegar tú solo desde ahí hasta un sistema en producción.
Eso es lo que hace esta lección. El ayuntamiento de Vallmar, satisfecho con la red de bibliotecas, acaba de encargar un segundo sistema: VallBici, el servicio municipal de bicicletas compartidas. No es una ampliación de BiblioRed; es un dominio nuevo, con sus propias reglas, sus propios problemas de concurrencia y sus propios informes. BiblioRed aparecerá de vez en cuando como término de comparación —"esto ya lo resolvimos así, aquí cambia porque…"—, pero el trabajo es nuevo de principio a fin.
El recorrido es el de un proyecto real y en su orden real: el encargo, los requisitos escritos, el modelo conceptual, el esquema físico, la comprobación de normalización, la carga de datos, las transacciones críticas, los informes, el rendimiento y la operación. En cada punto donde hay que decidir algo, la decisión va acompañada de su justificación y de las alternativas descartadas. Ese es el contenido de la lección: no el SQL final —que se copia en diez minutos— sino el razonamiento que lleva hasta él.
Y termina donde tiene que terminar un caso honesto: enumerando las tres cosas que este esquema resuelve mal, que son exactamente el material de la lección 08-02.
Contenido
- El encargo: la conversación inicial
- Documento de requisitos VallBici v1.0
- Modelo conceptual: entidades, relaciones y tres decisiones difíciles
- El diagrama ER
- Esquema físico: el
CREATE TABLEcomentado - Las restricciones que codifican las reglas de negocio
- Comprobación de normalización y dos desnormalizaciones deliberadas
- Carga de datos y volumen realista
- Las dos transacciones críticas: desbloquear y anclar
- Consultas de explotación: los informes del ayuntamiento
- Rendimiento: las dos consultas que se degradan
- Operación: roles, datos personales y copias
- Qué resuelve mal este esquema
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- El encargo: la conversación inicial
La primera reunión con el área de Movilidad del ayuntamiento produce esto, transcrito casi literalmente:
«Tenemos 60 estaciones repartidas por los cinco distritos de la ciudad y 900 bicicletas, unas mecánicas y otras eléctricas. Cada estación tiene un número fijo de anclajes; una bici ocupa un anclaje cuando está aparcada. Hay 24.000 personas abonadas. Una persona desbloquea una bici en una estación, hace su trayecto y la ancla en otra. Hay tres tipos de abono —anual, mensual y turístico de tres días— y si te pasas del tiempo incluido se cobra un recargo. Las bicis pasan revisiones, se averían y se retiran a taller. La aplicación del móvil tiene que enseñar la disponibilidad en tiempo real, recordar la sesión, dejar buscar estaciones por nombre o por dirección y guardar el rastro GPS de cada trayecto. Y queremos informes mensuales de uso por distrito y franja horaria.»
Aplicamos la técnica de 04-01: subrayar los sustantivos (candidatos a entidad), los verbos (candidatos a relación) y las frases con "no puede", "siempre", "cada" (candidatas a restricción). Pero antes hay que hacer algo que en 04-01 insistimos mucho: volver a preguntar. Un encargo de dos párrafos siempre esconde decisiones que el cliente da por evidentes y no lo son.
| Pregunta al cliente | Respuesta | Consecuencia de diseño |
|---|---|---|
| ¿Un anclaje puede estar averiado sin que lo esté la estación? | Sí, pasa a menudo | El anclaje necesita estado propio |
| ¿Puede una persona tener dos abonos a la vez? | No, nunca solapados; sí consecutivos | Restricción de no solapamiento |
| ¿Y dos trayectos abiertos a la vez? | Imposible: solo se desbloquea con la app | Índice único parcial |
| Si sube la tarifa, ¿cambia el precio de trayectos ya cobrados? | Jamás. Sería ilegal | Tarifa congelada en el trayecto |
| ¿Qué pasa si una bici desaparece? | Se da de baja, pero su historial se conserva | ON DELETE RESTRICT + baja lógica |
| ¿La eléctrica y la mecánica se cobran igual? | La eléctrica lleva un recargo fijo por desbloqueo | La jerarquía afecta al precio |
| ¿Cada cuánto queréis los informes? | Mensual, pero el panel de operaciones es diario | Dos perfiles de consulta distintos |
Esas siete respuestas valen más que las siguientes cien líneas de SQL. La cuarta, en particular, es la que evita el error caro: si la tarifa no se congela, el primer cambio de precios reescribe la facturación de dos años.
- Documento de requisitos VallBici v1.0
Requisitos funcionales
| Id | Requisito |
|---|---|
| RF1 | Registrar personas abonadas con sus datos de contacto y su distrito de residencia |
| RF2 | Vender abonos anuales, mensuales y turísticos de 3 días, con su periodo de vigencia |
| RF3 | Mantener el inventario de estaciones, anclajes y bicicletas, con su estado |
| RF4 | Registrar el desbloqueo de una bicicleta y su anclaje posterior, con instantes exactos |
| RF5 | Calcular el importe de cada trayecto según la tarifa vigente en el momento del desbloqueo |
| RF6 | Registrar revisiones y averías, y retirar bicicletas a taller |
| RF7 | Publicar la disponibilidad de cada estación |
| RF8 | Producir los informes de explotación del apartado siguiente |
Reglas de negocio
| Id | Regla | Dónde se codifica |
|---|---|---|
| RN1 | Un anclaje aloja como máximo una bicicleta | PRIMARY KEY de anclajes |
| RN2 | Una bicicleta está como máximo en un anclaje | UNIQUE (bicicleta_id) |
| RN3 | Una persona no puede tener dos abonos con vigencias solapadas | EXCLUDE USING gist |
| RN4 | Una bicicleta no puede tener dos trayectos abiertos | Índice único parcial |
| RN5 | Una persona no puede tener dos trayectos abiertos | Índice único parcial |
| RN6 | El instante de anclaje es posterior al de desbloqueo | CHECK |
| RN7 | Un trayecto cerrado tiene estación destino, anclaje destino, instante de fin e importe; uno abierto no tiene ninguno de los cuatro | CHECK conjunto |
| RN8 | El número de bicis en una estación nunca supera su número de anclajes | CHECK |
| RN9 | La tarifa aplicada a un trayecto no cambia aunque cambien las tarifas | Copia de tarifa en trayectos |
| RN10 | Un trayecto solo puede iniciarse con un abono en vigor | Lógica de la transacción |
Consultas que el ayuntamiento quiere poder responder
Esta lista es parte del requisito, no un extra. Es lo que en 04-01 llamábamos el criterio de éxito del esquema: un modelo que no puede responderlas es un modelo fallido, por elegante que sea.
- C1 — Trayectos por distrito y franja horaria, mes a mes.
- C2 — Los diez pares de estaciones origen→destino más frecuentes.
- C3 — Estaciones que se vacían o se llenan sistemáticamente, por franja (el problema del reequilibrado: es el que cuesta dinero de verdad, porque obliga a mover bicis en furgoneta).
- C4 — Ingresos por tipo de abono, separando cuotas de recargos.
- C5 — Bicicletas con más averías por hora de uso (no en términos absolutos: una bici muy usada se avería más y eso no la hace mala).
- Modelo conceptual: entidades, relaciones y tres decisiones difíciles
Del texto salen sin discusión: distrito, estación, anclaje, bicicleta, modelo de bicicleta, persona abonada, tipo de abono, tarifa, abono, trayecto, orden de taller y cobro. Lo que sí tiene discusión son tres decisiones, y son las que separan un modelo que aguanta de uno que no.
Decisión 1 — ¿El anclaje es una entidad o es un número dentro de la estación?
La tentación: guardar en estaciones una columna num_anclajes y otra bicis_disponibles, y no modelar el anclaje. Es más simple y aparentemente suficiente: para pintar la app basta con saber cuántas bicis hay.
Por qué se descarta. Tres motivos, en orden de peso:
- El cliente dijo que un anclaje puede averiarse solo. Un atributo no tiene estado; una entidad sí. Sin
anclajes, una estación con 20 anclajes y 3 rotos sigue "teniendo capacidad 20" y el sistema promete plazas que no existen. - La app tiene que decir a la persona en qué anclaje está la bici que ha reservado y en cuál debe dejarla. Ese dato no existe si el anclaje no existe.
- RN1 y RN2 —"un anclaje, una bici; una bici, un anclaje"— son restricciones de integridad, y en el módulo 4 fijamos el principio: una restricción que el esquema puede imponer no se delega en el código. Con
anclajescomo tabla son una clave primaria y unUNIQUE; sin ella, son código de aplicación y confianza.
Cómo se modela: el anclaje es una entidad débil de la estación (regla 8 de 04-03), con clave primaria compuesta (estacion_id, numero). El número 7 solo significa algo dentro de una estación concreta, exactamente igual que el número de recibo de una multa dependía de la multa en BiblioRed.
Decisión 2 — ¿El trayecto es una relación o una entidad?
Un trayecto conecta una persona (vía su abono), una bicicleta y dos estaciones. En términos de 04-02 sería una relación de grado 4, y las relaciones de grado alto son casi siempre un síntoma de que falta una entidad.
Se modela como entidad, y por cuatro razones:
- Tiene atributos propios y abundantes: dos instantes, duración, importe, tarifa congelada.
- Tiene identidad: la misma persona puede hacer el mismo trayecto entre las mismas dos estaciones con la misma bici dos veces el mismo día, y son dos hechos distintos. Una tabla de unión con clave compuesta los confundiría.
- Nace incompleto. Cuando se desbloquea la bici solo se conoce la mitad del trayecto. Una relación que existe a medias es una entidad con columnas nulas, no una relación.
- Otras cosas lo referencian: el cobro del recargo apunta al trayecto.
Las dos estaciones son dos relaciones 1:N distintas hacia estaciones (regla 5 de 04-03): estacion_origen y estacion_destino. No es un caso raro: es el mismo patrón que "vuelo con aeropuerto de salida y de llegada", y la única precaución es no olvidar que las dos claves ajenas apuntan a la misma tabla, lo que obliga a poner alias en todas las consultas que las usen.
Detalle fino: la clave ajena no apunta a estaciones sino a anclajes(estacion_id, numero), porque nos interesa saber de qué anclaje concreto salió y en cuál entró. La estación queda determinada por el anclaje.
Decisión 3 — La jerarquía mecánica / eléctrica
Toda bicicleta es mecánica o eléctrica (jerarquía total y disjunta). Las eléctricas tienen tres atributos que las mecánicas no tienen sentido: capacidad de batería, autonomía y número de serie de la batería. En 04-03, regla 10, vimos las tres estrategias. Repasemos la decisión con los criterios de allí:
| Criterio | Tabla única | Tabla por subclase | Tabla por clase concreta |
|---|---|---|---|
| Atributos específicos | 3 columnas nulas en 900 filas | Sin nulos | Sin nulos |
¿trayectos puede referenciar la superclase? |
Sí | Sí | No — necesitaría dos FK |
| "Todas las bicis de la estación 12" | Trivial | Trivial | UNION de dos ramas |
n_serie_bateria obligatorio y único solo en eléctricas |
Imposible con NOT NULL |
Directo | Directo |
| Coste de consulta típica | Ninguno | Un LEFT JOIN ocasional |
UNION siempre |
Elegimos tabla por subclase (estrategia 2), igual que hicimos con los materiales de BiblioRed y por el mismo motivo dominante: hay tablas que referencian la superclase. trayectos, anclajes y ordenes_taller apuntan a "una bicicleta", sin importar de qué tipo, y la estrategia 3 haría eso imposible sin duplicar todas las claves ajenas.
La estrategia 1 (tabla única) era defendible —solo son tres columnas— y con 900 filas el desperdicio es irrelevante. Se descarta por el cuarto criterio: n_serie_bateria tiene que ser obligatorio y único en las eléctricas, y en una tabla única solo podría ser opcional. Es exactamente el argumento que en BiblioRed nos hizo separar materiales_libro por el isbn.
Y como en 04-03, la estrategia 2 arrastra su agujero conocido: nada impide por sí solo que una bici con tipo = 'mecanica' tenga fila en bicicletas_electricas. Se tapa con el truco de la clave ajena discriminada, que verás en el esquema.
- El diagrama ER
erDiagram
DISTRITO ||--o{ ESTACION : agrupa
DISTRITO ||--o{ PERSONA_ABONADA : "reside en"
ESTACION ||--|{ ANCLAJE : contiene
ANCLAJE |o--o| BICICLETA : aloja
MODELO_BICI ||--o{ BICICLETA : "es del modelo"
BICICLETA ||--o| BICICLETA_ELECTRICA : "especializa a"
BICICLETA ||--o{ TRAYECTO : "se usa en"
BICICLETA ||--o{ ORDEN_TALLER : "pasa por"
PERSONA_ABONADA ||--o{ ABONO : contrata
TIPO_ABONO ||--o{ ABONO : clasifica
TIPO_ABONO ||--o{ TARIFA : "se tarifica con"
TARIFA ||--o{ ABONO : "fija precio de"
TARIFA ||--o{ TRAYECTO : "congelada en"
ABONO ||--o{ TRAYECTO : autoriza
ANCLAJE ||--o{ TRAYECTO : "es origen de"
ANCLAJE ||--o{ TRAYECTO : "es destino de"
PERSONA_ABONADA ||--o{ COBRO : paga
ABONO ||--o{ COBRO : "genera cuota"
TRAYECTO ||--o{ COBRO : "genera recargo"
Léelo con la notación de 04-02: || es participación total y cardinalidad 1, o{ es cardinalidad N con participación parcial, |{ es N con participación total. Que ESTACION ||--|{ ANCLAJE sea total en los dos lados dice algo cierto y no trivial: una estación sin ningún anclaje no es una estación, y un anclaje sin estación no existe. Que ANCLAJE |o--o| BICICLETA sea parcial en ambos lados dice lo contrario: hay anclajes vacíos y hay bicis fuera de todo anclaje (circulando o en taller).
- Esquema físico: el
CREATE TABLE comentado
CREATE TABLE comentadoDos extensiones antes de empezar. btree_gist permite mezclar en un EXCLUDE columnas de igualdad (un entero) con columnas de solapamiento (un rango), que es justo lo que piden RN3 y las tarifas.
Inventario
CREATE TABLE distritos (
distrito_id SMALLINT PRIMARY KEY, -- 5 filas: clave natural, estable
nombre VARCHAR(40) NOT NULL UNIQUE
);
CREATE TABLE estaciones (
estacion_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
codigo CHAR(6) NOT NULL UNIQUE, -- 'VB-012', el que va rotulado
nombre VARCHAR(80) NOT NULL,
direccion VARCHAR(120) NOT NULL,
distrito_id SMALLINT NOT NULL REFERENCES distritos ON DELETE RESTRICT,
latitud NUMERIC(9,6) NOT NULL CHECK (latitud BETWEEN -90 AND 90),
longitud NUMERIC(9,6) NOT NULL CHECK (longitud BETWEEN -180 AND 180),
num_anclajes SMALLINT NOT NULL CHECK (num_anclajes BETWEEN 8 AND 40),
estado VARCHAR(14) NOT NULL DEFAULT 'activa'
CHECK (estado IN ('activa','mantenimiento','retirada')),
fecha_alta DATE NOT NULL DEFAULT CURRENT_DATE,
-- Desnormalización deliberada nº 1 (se justifica en el apartado 7)
bicis_disponibles SMALLINT NOT NULL DEFAULT 0 CHECK (bicis_disponibles >= 0),
CONSTRAINT ck_cabe_en_la_estacion CHECK (bicis_disponibles <= num_anclajes) -- RN8
);Por qué estos tipos, uno a uno:
| Columna | Tipo elegido | Alternativa descartada y por qué |
|---|---|---|
distrito_id |
SMALLINT natural |
IDENTITY: cinco distritos que no cambian nunca no necesitan clave subrogada |
estacion_id |
INTEGER IDENTITY |
SERIAL: obsoleto desde PostgreSQL 10; IDENTITY es estándar SQL y no deja secuencias huérfanas |
codigo |
CHAR(6) |
Sería la clave natural, pero un rótulo se repinta: se queda como UNIQUE, no como PK |
latitud/longitud |
NUMERIC(9,6) |
FLOAT: precisión decimal exacta y ≈11 cm de resolución bastan. Cuando haga falta geometría de verdad, PostGIS (lo discutimos en 08-02) |
num_anclajes |
SMALLINT |
INTEGER: ninguna estación tendrá 33.000 anclajes |
estado |
VARCHAR + CHECK |
ENUM: añadir un valor a un ENUM requiere ALTER TYPE; un CHECK se cambia con ALTER TABLE y se lee en el \d |
CREATE TABLE modelos_bici (
modelo_id SMALLINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
fabricante VARCHAR(40) NOT NULL,
nombre VARCHAR(40) NOT NULL,
tipo VARCHAR(10) NOT NULL CHECK (tipo IN ('mecanica','electrica')),
peso_kg NUMERIC(4,1) NOT NULL CHECK (peso_kg > 0),
UNIQUE (fabricante, nombre)
);
CREATE TABLE bicicletas (
bicicleta_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
matricula CHAR(7) NOT NULL UNIQUE, -- 'VB-0417'
modelo_id SMALLINT NOT NULL REFERENCES modelos_bici ON DELETE RESTRICT,
tipo VARCHAR(10) NOT NULL CHECK (tipo IN ('mecanica','electrica')),
fecha_alta DATE NOT NULL DEFAULT CURRENT_DATE,
estado VARCHAR(10) NOT NULL DEFAULT 'anclada'
CHECK (estado IN ('anclada','en_uso','taller','baja')),
km_acumulados NUMERIC(10,2) NOT NULL DEFAULT 0 CHECK (km_acumulados >= 0),
-- Redundante como clave, pero necesaria: es el ancla del discriminante
CONSTRAINT uq_bici_tipo UNIQUE (bicicleta_id, tipo)
);
CREATE TABLE bicicletas_electricas (
bicicleta_id INTEGER PRIMARY KEY,
tipo VARCHAR(10) NOT NULL DEFAULT 'electrica' CHECK (tipo = 'electrica'),
capacidad_wh SMALLINT NOT NULL CHECK (capacidad_wh > 0),
autonomia_km SMALLINT NOT NULL CHECK (autonomia_km BETWEEN 10 AND 200),
n_serie_bateria VARCHAR(24) NOT NULL UNIQUE,
FOREIGN KEY (bicicleta_id, tipo)
REFERENCES bicicletas (bicicleta_id, tipo) ON DELETE CASCADE
);El truco del discriminante, explicado. La clave ajena compuesta (bicicleta_id, tipo) obliga a que la fila referenciada de bicicletas tenga tipo = 'electrica', porque el CHECK de esta tabla fija tipo a ese valor. Resultado: es imposible dar de alta una batería para una bici mecánica. El agujero que 04-03 dejaba abierto en la estrategia 2 queda tapado en la mitad que importa. La otra mitad —que una eléctrica no tenga fila aquí— sigue sin poder imponerse declarativamente y se controla en el proceso de alta.
CREATE TABLE anclajes (
estacion_id INTEGER NOT NULL REFERENCES estaciones ON DELETE CASCADE,
numero SMALLINT NOT NULL CHECK (numero > 0),
estado VARCHAR(10) NOT NULL DEFAULT 'operativo'
CHECK (estado IN ('operativo','averiado','bloqueado')),
bicicleta_id INTEGER REFERENCES bicicletas ON DELETE SET NULL,
PRIMARY KEY (estacion_id, numero), -- RN1: entidad débil
CONSTRAINT uq_bici_en_un_anclaje UNIQUE (bicicleta_id) -- RN2
);Las dos reglas más importantes del sistema son dos líneas de esquema. La clave primaria compuesta impide que el anclaje 7 de la estación 12 aloje dos bicis. El UNIQUE (bicicleta_id) impide que la bici 417 esté simultáneamente en dos anclajes; funciona porque en PostgreSQL UNIQUE admite tantos NULL como quiera, así que todos los anclajes vacíos conviven sin conflicto.
ON DELETE CASCADE de anclajes hacia estaciones está justificado: un anclaje no tiene vida propia fuera de su estación (es la acción que 02-06 reservaba precisamente para entidades débiles). ON DELETE SET NULL hacia bicicletas es lo correcto para lo contrario: si una bici desapareciera del sistema, el anclaje debe quedar libre, no desaparecer.
Personas, abonos y tarifas
CREATE TABLE personas_abonadas (
persona_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
documento_hash CHAR(64) NOT NULL UNIQUE, -- SHA-256 con sal; ver apartado 12
nombre VARCHAR(60) NOT NULL,
apellidos VARCHAR(80) NOT NULL,
email VARCHAR(120) NOT NULL UNIQUE,
telefono VARCHAR(20),
fecha_nac DATE NOT NULL,
distrito_id SMALLINT REFERENCES distritos ON DELETE SET NULL,
fecha_registro TIMESTAMPTZ NOT NULL DEFAULT now(),
fecha_baja DATE,
CONSTRAINT ck_edad_minima CHECK (fecha_nac <= CURRENT_DATE - INTERVAL '14 years'),
CONSTRAINT ck_baja_posterior CHECK (fecha_baja IS NULL
OR fecha_baja >= fecha_registro::date)
);
CREATE TABLE tipos_abono (
tipo_abono VARCHAR(12) PRIMARY KEY
CHECK (tipo_abono IN ('anual','mensual','turistico')),
descripcion VARCHAR(60) NOT NULL,
duracion INTERVAL NOT NULL -- '1 year', '1 mon', '3 days'
);
CREATE TABLE tarifas (
tarifa_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tipo_abono VARCHAR(12) NOT NULL REFERENCES tipos_abono ON DELETE RESTRICT,
vigencia DATERANGE NOT NULL,
cuota NUMERIC(6,2) NOT NULL CHECK (cuota >= 0),
minutos_incluidos SMALLINT NOT NULL CHECK (minutos_incluidos >= 0),
minutos_fraccion SMALLINT NOT NULL CHECK (minutos_fraccion > 0),
precio_fraccion NUMERIC(5,2) NOT NULL CHECK (precio_fraccion >= 0),
recargo_electrica NUMERIC(5,2) NOT NULL DEFAULT 0 CHECK (recargo_electrica >= 0),
-- Dos tarifas del mismo tipo de abono no pueden estar vigentes a la vez
EXCLUDE USING gist (tipo_abono WITH =, vigencia WITH &&)
);
CREATE TABLE abonos (
abono_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
persona_id INTEGER NOT NULL REFERENCES personas_abonadas ON DELETE RESTRICT,
tipo_abono VARCHAR(12) NOT NULL REFERENCES tipos_abono ON DELETE RESTRICT,
tarifa_id INTEGER NOT NULL REFERENCES tarifas ON DELETE RESTRICT,
vigencia DATERANGE NOT NULL,
importe NUMERIC(6,2) NOT NULL CHECK (importe >= 0),
estado VARCHAR(10) NOT NULL DEFAULT 'activo'
CHECK (estado IN ('activo','suspendido','anulado')),
CONSTRAINT ck_vigencia_acotada CHECK (NOT lower_inf(vigencia) AND NOT upper_inf(vigencia)),
-- RN3: nada de abonos solapados de la misma persona
EXCLUDE USING gist (persona_id WITH =, vigencia WITH &&) WHERE (estado <> 'anulado')
);Sobre NUMERIC para el dinero no hay debate y conviene repetirlo porque es el error más caro que se comete con los tipos: FLOAT no representa exactamente 0,10, y un sistema que suma 1,6 millones de importes con FLOAT produce un descuadre contable que nadie sabrá explicar. NUMERIC(6,2) da hasta 9.999,99 €, sobrado para una cuota anual.
DATERANGE en lugar de dos columnas fecha_inicio/fecha_fin es lo que hace posible el EXCLUDE. Con dos columnas sueltas, "estos dos abonos se solapan" es una consulta con cuatro comparaciones que hay que escribir bien cada vez; con un rango es el operador && y lo impone el motor.
Trayectos, taller y cobros
CREATE TABLE trayectos (
trayecto_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
abono_id INTEGER NOT NULL REFERENCES abonos ON DELETE RESTRICT,
bicicleta_id INTEGER NOT NULL REFERENCES bicicletas ON DELETE RESTRICT,
estacion_origen INTEGER NOT NULL,
anclaje_origen SMALLINT NOT NULL,
ts_inicio TIMESTAMPTZ NOT NULL DEFAULT now(),
estacion_destino INTEGER,
anclaje_destino SMALLINT,
ts_fin TIMESTAMPTZ,
duracion INTERVAL GENERATED ALWAYS AS (ts_fin - ts_inicio) STORED,
-- Desnormalización deliberada nº 2: la tarifa congelada (RN9)
tarifa_id INTEGER NOT NULL REFERENCES tarifas ON DELETE RESTRICT,
minutos_incluidos SMALLINT NOT NULL,
minutos_fraccion SMALLINT NOT NULL,
precio_fraccion NUMERIC(5,2) NOT NULL,
recargo_electrica NUMERIC(5,2) NOT NULL DEFAULT 0,
importe NUMERIC(6,2) CHECK (importe >= 0),
FOREIGN KEY (estacion_origen, anclaje_origen)
REFERENCES anclajes (estacion_id, numero) ON DELETE RESTRICT,
FOREIGN KEY (estacion_destino, anclaje_destino)
REFERENCES anclajes (estacion_id, numero) ON DELETE RESTRICT,
CONSTRAINT ck_orden_temporal CHECK (ts_fin IS NULL OR ts_fin > ts_inicio), -- RN6
CONSTRAINT ck_cierre_completo CHECK ( -- RN7
(ts_fin IS NULL AND estacion_destino IS NULL
AND anclaje_destino IS NULL AND importe IS NULL)
OR (ts_fin IS NOT NULL AND estacion_destino IS NOT NULL
AND anclaje_destino IS NOT NULL AND importe IS NOT NULL))
);
-- RN4 y RN5: ni la bici ni la persona pueden tener dos trayectos abiertos
CREATE UNIQUE INDEX uq_trayecto_abierto_bici ON trayectos (bicicleta_id) WHERE ts_fin IS NULL;
CREATE UNIQUE INDEX uq_trayecto_abierto_abono ON trayectos (abono_id) WHERE ts_fin IS NULL;Tres tipos que merecen un comentario:
TIMESTAMPTZ, noTIMESTAMP. Vallmar cambia de hora dos veces al año. El domingo de octubre en que los relojes retrasan, unTIMESTAMPsin zona convierte "02:30" en un instante ambiguo, y los trayectos de esa madrugada pueden salir con duración negativa.TIMESTAMPTZguarda un instante absoluto y la ambigüedad desaparece. El precio es acordarse de convertir a hora local cuando se agrupa por franja horaria, cosa que haremos explícitamente en C1.INTERVALgenerada.duracionno se almacena a mano: es una columna generada (04-04). Cumple la regla 4 de 04-03 —los derivados no se almacenan— sin renunciar a poder indexarla, porqueSTOREDsí ocupa disco pero se calcula sola y nunca puede discrepar de sus fuentes.BIGINTentrayecto_id. Con 1,6 millones de trayectos al año,INTEGER(2.147 millones) tardaría más de mil años en agotarse. Aun así se usaBIGINT: cambiar el tipo de una clave primaria en producción es una de las migraciones más dolorosas que existen, y el coste hoy son cuatro bytes por fila.
CREATE TABLE ordenes_taller (
orden_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
bicicleta_id INTEGER NOT NULL REFERENCES bicicletas ON DELETE RESTRICT,
tipo VARCHAR(10) NOT NULL CHECK (tipo IN ('revision','averia')),
motivo VARCHAR(60) NOT NULL,
ts_apertura TIMESTAMPTZ NOT NULL DEFAULT now(),
ts_cierre TIMESTAMPTZ,
coste NUMERIC(7,2) CHECK (coste >= 0),
CONSTRAINT ck_cierre_taller CHECK (ts_cierre IS NULL OR ts_cierre >= ts_apertura)
);
CREATE UNIQUE INDEX uq_orden_abierta ON ordenes_taller (bicicleta_id) WHERE ts_cierre IS NULL;
CREATE TABLE cobros (
cobro_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
persona_id INTEGER NOT NULL REFERENCES personas_abonadas ON DELETE RESTRICT,
concepto VARCHAR(10) NOT NULL CHECK (concepto IN ('abono','recargo')),
abono_id INTEGER REFERENCES abonos ON DELETE RESTRICT,
trayecto_id BIGINT REFERENCES trayectos ON DELETE RESTRICT,
importe NUMERIC(6,2) NOT NULL CHECK (importe > 0),
ts_cobro TIMESTAMPTZ NOT NULL DEFAULT now(),
estado VARCHAR(10) NOT NULL DEFAULT 'pendiente'
CHECK (estado IN ('pendiente','cobrado','fallido','devuelto')),
CONSTRAINT ck_origen_del_cobro CHECK (
(concepto = 'abono' AND abono_id IS NOT NULL AND trayecto_id IS NULL)
OR (concepto = 'recargo' AND trayecto_id IS NOT NULL AND abono_id IS NULL))
);ck_origen_del_cobro es un ejemplo del patrón que 04-04 llamaba restricción de coherencia entre columnas: no basta con que cada columna sea válida por separado; la combinación tiene que tener sentido. Un cobro de concepto abono con trayecto_id relleno es un dato incoherente, y el esquema lo rechaza.
- Las restricciones que codifican las reglas de negocio
Vale la pena ver las tres más interesantes fallando, porque una restricción que nunca has visto saltar no sabes si funciona.
-- RN2: intentar poner la bici 417, que ya está en el anclaje 3 de la estación 12,
-- también en el anclaje 5 de la estación 12
UPDATE anclajes SET bicicleta_id = 417 WHERE estacion_id = 12 AND numero = 5;ERROR: llave duplicada viola restricción de unicidad «uq_bici_en_un_anclaje» DETALLE: Ya existe la llave (bicicleta_id)=(417).
-- RN3: la persona 8801 ya tiene un abono anual del 2026-01-01 al 2027-01-01
INSERT INTO abonos (persona_id, tipo_abono, tarifa_id, vigencia, importe)
VALUES (8801, 'mensual', 7, daterange('2026-06-01','2026-07-01'), 12.00);ERROR: el valor llave (persona_id, vigencia)=(8801, [2026-06-01,2026-07-01))
entra en conflicto con el valor llave existente
(persona_id, vigencia)=(8801, [2026-01-01,2027-01-01))
DETALLE: conflicto con la restricción de exclusión «abonos_persona_id_vigencia_excl».-- RN4: la bici 417 ya tiene un trayecto abierto
INSERT INTO trayectos (abono_id, bicicleta_id, estacion_origen, anclaje_origen,
tarifa_id, minutos_incluidos, minutos_fraccion, precio_fraccion)
VALUES (10233, 417, 12, 3, 7, 30, 15, 0.60);ERROR: llave duplicada viola restricción de unicidad «uq_trayecto_abierto_bici» DETALLE: Ya existe la llave (bicicleta_id)=(417).
Ese último es el más valioso de los tres. La regla "una bici no puede estar en dos trayectos abiertos" parece código de aplicación puro, y en el 90 % de los sistemas lo es —con el resultado de que, bajo carga, dos peticiones simultáneas la violan alegremente—. Un índice único parcial la convierte en una garantía del motor que ninguna condición de carrera puede burlar.
- Comprobación de normalización y dos desnormalizaciones deliberadas
Repasamos el esquema con el método de 05-03. Para cada tabla: identificar la clave, listar las dependencias funcionales y comprobar que todo atributo no primo depende de la clave completa y de nada más.
| Tabla | Clave | Dependencias problemáticas | Veredicto |
|---|---|---|---|
distritos |
distrito_id |
Ninguna | FNBC |
estaciones |
estacion_id |
bicis_disponibles es derivable |
3FN rota a propósito (ver abajo) |
anclajes |
(estacion_id, numero) |
Ninguna: estado y bicicleta_id dependen del par completo |
FNBC |
bicicletas |
bicicleta_id |
tipo también está en modelos_bici |
Ver nota |
abonos |
abono_id |
tipo_abono es deducible vía tarifa_id |
Ver nota |
trayectos |
trayecto_id |
Las cuatro columnas de tarifa dependen de tarifa_id |
2FN/3FN rota a propósito |
cobros |
cobro_id |
Ninguna | FNBC |
La nota sobre bicicletas.tipo. Existe la dependencia modelo_id → tipo, y modelo_id no es clave: es una dependencia transitiva y por tanto una violación de 3FN de manual. Se conserva por una razón concreta y verificable: es la columna que hace funcionar el discriminante de la jerarquía, y una clave ajena no puede apuntar a un valor que hay que ir a buscar a otra tabla. Y no genera anomalías, porque un modelo no cambia de tipo jamás: una bici mecánica no se convierte en eléctrica. Es el caso que 05-02 describía como "dependencia transitiva sobre un atributo inmutable", donde el riesgo de anomalía de actualización es cero. Aun así, se blinda con un disparador de verificación en el alta.
La nota sobre abonos.tipo_abono. Mismo razonamiento y misma conclusión: tarifa_id → tipo_abono. Se conserva porque las consultas de explotación agrupan por tipo de abono constantemente y evitar un JOIN en el 80 % de los informes lo justifica. Se blinda con una clave ajena compuesta:
ALTER TABLE tarifas ADD CONSTRAINT uq_tarifa_tipo UNIQUE (tarifa_id, tipo_abono);
ALTER TABLE abonos ADD CONSTRAINT fk_abono_tarifa_coherente
FOREIGN KEY (tarifa_id, tipo_abono) REFERENCES tarifas (tarifa_id, tipo_abono);Ahora la redundancia es imposible de contradecir, que es la única forma aceptable de convivir con una redundancia. Es exactamente el mismo patrón del discriminante de las bicis.
Desnormalización 1 — estaciones.bicis_disponibles
Qué se rompe: el dato es derivable con SELECT COUNT(*) FROM anclajes WHERE estacion_id = ? AND bicicleta_id IS NOT NULL.
Por qué se acepta: la app móvil pide la disponibilidad de las 60 estaciones cada vez que alguien abre el mapa, y con 24.000 personas abonadas eso son decenas de miles de peticiones al día que degeneran en un recuento sobre anclajes cada una. Es el caso de libro de 05-04: lectura masiva, escritura poco frecuente, dato pequeño.
Cómo se mantiene: con un disparador sobre anclajes, no a mano desde la aplicación. Que lo mantenga la aplicación es lo que garantiza que algún día discrepe.
CREATE OR REPLACE FUNCTION trg_recalcular_disponibles() RETURNS TRIGGER AS $$
BEGIN
IF TG_OP IN ('UPDATE','DELETE') AND OLD.bicicleta_id IS NOT NULL THEN
UPDATE estaciones SET bicis_disponibles = bicis_disponibles - 1
WHERE estacion_id = OLD.estacion_id;
END IF;
IF TG_OP IN ('UPDATE','INSERT') AND NEW.bicicleta_id IS NOT NULL THEN
UPDATE estaciones SET bicis_disponibles = bicis_disponibles + 1
WHERE estacion_id = NEW.estacion_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_anclajes_disponibles
AFTER INSERT OR UPDATE OF bicicleta_id OR DELETE ON anclajes
FOR EACH ROW EXECUTE FUNCTION trg_recalcular_disponibles();Y, como manda 05-04, una consulta de auditoría que se ejecuta cada noche y avisa si el contador ha derivado:
SELECT e.codigo, e.bicis_disponibles AS contador,
COUNT(a.bicicleta_id) AS real
FROM estaciones e
JOIN anclajes a USING (estacion_id)
GROUP BY e.estacion_id, e.codigo, e.bicis_disponibles
HAVING e.bicis_disponibles <> COUNT(a.bicicleta_id);Desnormalización 2 — la tarifa congelada en trayectos
Qué se rompe: minutos_incluidos, minutos_fraccion, precio_fraccion y recargo_electrica dependen de tarifa_id, no de trayecto_id.
Por qué se acepta: no es rendimiento, es corrección. Es la respuesta a la cuarta pregunta de la reunión inicial. Si el trayecto solo guardara tarifa_id, cualquier recálculo posterior aplicaría la tarifa actual; el día que el ayuntamiento suba el precio de la fracción, todo el histórico cambiaría de importe. Los datos históricos deben ser reproducibles: es un requisito contable y a veces legal.
Cómo se mantiene: copiando la tarifa en el momento del desbloqueo, dentro de la transacción, y no volviéndola a tocar nunca. Un disparador BEFORE UPDATE que rechace cambios en esas cuatro columnas es una protección barata y sensata.
Fíjate en la diferencia entre las dos: la primera se desnormaliza por velocidad y hay que vigilarla; la segunda se desnormaliza por semántica y vigilarla sería un error, porque su valor debe diferir del actual.
- Carga de datos y volumen realista
INSERT INTO distritos VALUES
(1,'Puerto'), (2,'Ensanche'), (3,'Vallmar Alta'), (4,'Ribera'), (5,'Industrial');
INSERT INTO tipos_abono VALUES
('anual', 'Abono anual con 30 min incluidos por trayecto', INTERVAL '1 year'),
('mensual', 'Abono mensual con 30 min incluidos', INTERVAL '1 mon'),
('turistico','Abono de 3 días con 15 min incluidos', INTERVAL '3 days');
INSERT INTO tarifas (tipo_abono, vigencia, cuota, minutos_incluidos,
minutos_fraccion, precio_fraccion, recargo_electrica) VALUES
('anual', daterange('2026-01-01','2027-01-01'), 45.00, 30, 15, 0.60, 0.35),
('mensual', daterange('2026-01-01','2027-01-01'), 9.50, 30, 15, 0.60, 0.35),
('turistico', daterange('2026-01-01','2027-01-01'), 15.00, 15, 15, 1.10, 0.50);
-- 60 estaciones repartidas por los cinco distritos
INSERT INTO estaciones (codigo, nombre, direccion, distrito_id, latitud, longitud, num_anclajes)
SELECT 'VB-' || lpad(n::text, 3, '0'),
'Estación ' || n,
'Calle Ficticia ' || n,
1 + (n % 5),
40.100000 + (n % 12) * 0.004,
-3.200000 + (n % 9) * 0.005,
12 + (n % 5) * 4
FROM generate_series(1, 60) AS n;
-- Los anclajes de cada estación (entidad débil: se generan a partir de num_anclajes)
INSERT INTO anclajes (estacion_id, numero)
SELECT e.estacion_id, g
FROM estaciones e, LATERAL generate_series(1, e.num_anclajes) AS g;Ese 1.512 es el primer dato de dimensionamiento útil: 1.512 anclajes para 900 bicicletas, un 68 % de ocupación media. Es una holgura sana; por debajo del 80 % el reequilibrado se vuelve una pesadilla operativa.
| Tabla | Filas iniciales | Crecimiento anual | Notas |
|---|---|---|---|
distritos |
5 | 0 | Fija |
estaciones |
60 | +5 | Crecimiento por plan municipal |
anclajes |
1.512 | +120 | Derivada de las estaciones |
modelos_bici |
6 | +1 | |
bicicletas |
900 | +90 / −60 | Altas y bajas |
personas_abonadas |
24.000 | +4.000 | |
abonos |
26.500 | +30.000 | Los turísticos rotan mucho |
trayectos |
0 | +1.600.000 | ≈4.400/día; es la tabla del sistema |
ordenes_taller |
0 | +7.000 | |
cobros |
0 | +180.000 | Cuotas + recargos |
Toda decisión de rendimiento del apartado 11 se refiere a esos 1,6 millones anuales de trayectos. Las demás tablas son irrelevantes a efectos de plan de ejecución, y confundir eso es la forma más habitual de perder una tarde indexando lo que no toca.
- Las dos transacciones críticas: desbloquear y anclar
Aquí es donde el módulo 6 deja de ser teoría. El escenario que hay que resolver es concreto: son las 08:12, la estación del Puerto tiene una sola bici libre y dos personas pulsan "desbloquear" con 40 milisegundos de diferencia.
sequenceDiagram
participant A as App persona A
participant B as App persona B
participant PG as PostgreSQL
A->>PG: BEGIN · SELECT anclaje con bici FOR UPDATE SKIP LOCKED
PG-->>A: anclaje 3 (bici 417) — fila bloqueada
B->>PG: BEGIN · SELECT anclaje con bici FOR UPDATE SKIP LOCKED
PG-->>B: 0 filas (salta la 3, no hay más)
A->>PG: UPDATE anclajes · INSERT trayectos · COMMIT
B->>PG: ROLLBACK — "no quedan bicicletas"
Desbloqueo
BEGIN;
-- 1. Verificar que el abono está en vigor (RN10). Sin bloqueo: leerlo basta.
SELECT abono_id, tarifa_id
FROM abonos
WHERE persona_id = 8801 AND estado = 'activo'
AND vigencia @> CURRENT_DATE;
-- 2. Tomar UNA bici anclada de la estación 12, bloqueando solo esa fila.
-- ORDER BY km_acumulados reparte el desgaste de la flota.
SELECT a.estacion_id, a.numero, a.bicicleta_id, b.tipo
FROM anclajes a
JOIN bicicletas b ON b.bicicleta_id = a.bicicleta_id
WHERE a.estacion_id = 12
AND a.estado = 'operativo'
AND b.estado = 'anclada'
ORDER BY b.km_acumulados
FOR UPDATE OF a SKIP LOCKED
LIMIT 1;
-- 3. Liberar el anclaje y marcar la bici en uso
UPDATE anclajes SET bicicleta_id = NULL WHERE estacion_id = 12 AND numero = 3;
UPDATE bicicletas SET estado = 'en_uso' WHERE bicicleta_id = 417;
-- 4. Abrir el trayecto congelando la tarifa vigente
INSERT INTO trayectos (abono_id, bicicleta_id, estacion_origen, anclaje_origen,
tarifa_id, minutos_incluidos, minutos_fraccion,
precio_fraccion, recargo_electrica)
SELECT 10233, 417, 12, 3,
t.tarifa_id, t.minutos_incluidos, t.minutos_fraccion,
t.precio_fraccion,
CASE WHEN b.tipo = 'electrica' THEN t.recargo_electrica ELSE 0 END
FROM tarifas t
JOIN bicicletas b ON b.bicicleta_id = 417
WHERE t.tipo_abono = 'anual' AND t.vigencia @> CURRENT_DATE
RETURNING trayecto_id;
COMMIT;Por qué SKIP LOCKED y no FOR UPDATE a secas. Con FOR UPDATE, la sesión B se queda esperando a que A confirme, y luego reevalúa: como el anclaje ya no tiene bici, obtiene 0 filas. El resultado final es correcto, pero B ha esperado sin necesidad. Con SKIP LOCKED, B ignora la fila bloqueada y sigue buscando otra bici en la misma estación; solo si de verdad no queda ninguna devuelve 0 filas. En una estación con 8 bicis y 8 personas desbloqueando a la vez, la diferencia es que las 8 lo consiguen en paralelo en lugar de hacer cola. Es exactamente el patrón de cola que vimos en 06-02, aplicado a un inventario.
Por qué FOR UPDATE OF a. Sin el OF a, PostgreSQL bloquearía también la fila de bicicletas, y no hace falta: la fila que decide quién gana es la del anclaje. Bloquear de más multiplica los interbloqueos.
El nivel de aislamiento es READ COMMITTED, el de por defecto. No hace falta subir a REPEATABLE READ: no hay ninguna lectura que deba repetirse de forma estable, y el bloqueo explícito ya resuelve la carrera. Subir el aislamiento "por si acaso" solo añade errores de serialización que habría que reintentar.
Anclaje
BEGIN;
-- 1. Reservar un anclaje libre y operativo en la estación de destino
SELECT estacion_id, numero
FROM anclajes
WHERE estacion_id = 34 AND estado = 'operativo' AND bicicleta_id IS NULL
ORDER BY numero
FOR UPDATE SKIP LOCKED
LIMIT 1;
-- 2. Cerrar el trayecto calculando el importe con la tarifa CONGELADA
UPDATE trayectos t
SET ts_fin = now(),
estacion_destino = 34,
anclaje_destino = 7,
importe = t.recargo_electrica
+ t.precio_fraccion
* GREATEST(0, ceil(
(EXTRACT(EPOCH FROM (now() - t.ts_inicio)) / 60
- t.minutos_incluidos) / t.minutos_fraccion))
WHERE t.trayecto_id = 884213
AND t.ts_fin IS NULL -- idempotencia: un segundo anclaje no hace nada
RETURNING importe;
-- 3. Ocupar el anclaje y devolver la bici a estado 'anclada'
UPDATE anclajes SET bicicleta_id = 417 WHERE estacion_id = 34 AND numero = 7;
UPDATE bicicletas SET estado = 'anclada',
km_acumulados = km_acumulados + 3.40
WHERE bicicleta_id = 417;
-- 4. Si hay recargo, generar el cobro
INSERT INTO cobros (persona_id, concepto, trayecto_id, importe)
SELECT a.persona_id, 'recargo', t.trayecto_id, t.importe
FROM trayectos t JOIN abonos a USING (abono_id)
WHERE t.trayecto_id = 884213 AND t.importe > 0;
COMMIT;Un trayecto de 68 minutos con abono anual: 68 − 30 incluidos = 38 minutos excedidos, ceil(38/15) = 3 fracciones × 0,60 € = 1,80 €… más el recargo de eléctrica 0,35 €. El resultado de arriba, 1,55 €, corresponde a 2 fracciones (1,20 €) más 0,35 €, es decir a un trayecto de 55 minutos. Comprueba tú el cálculo con los dos casos: verificar a mano el primer importe que produce un sistema de cobro es una costumbre que ahorra disgustos.
Los tres detalles que hacen esta transacción robusta:
AND t.ts_fin IS NULLen elWHEREdelUPDATEla hace idempotente. Si la app reintenta el anclaje porque perdió la respuesta por red, el segundo intento afecta a 0 filas y no vuelve a cobrar.- El
RETURNINGpermite a la aplicación comprobar cuántas filas cambió. Cero filas no es un éxito silencioso: es un error que hay que tratar. - El contador
bicis_disponiblesno se toca aquí: lo actualiza el disparador deanclajes. Si se tocara también a mano, el contador subiría de dos en dos, y ese es el fallo de desnormalización más común que existe.
- Consultas de explotación: los informes del ayuntamiento
C1 — Trayectos por distrito y franja horaria.
SELECT d.nombre AS distrito,
COUNT(*) FILTER (WHERE h BETWEEN 7 AND 9) AS punta_manana,
COUNT(*) FILTER (WHERE h BETWEEN 10 AND 16) AS valle,
COUNT(*) FILTER (WHERE h BETWEEN 17 AND 20) AS punta_tarde,
COUNT(*) FILTER (WHERE h > 20 OR h < 7) AS nocturno,
COUNT(*) AS total
FROM (SELECT t.estacion_origen,
EXTRACT(HOUR FROM t.ts_inicio AT TIME ZONE 'Europe/Madrid')::int AS h
FROM trayectos t
WHERE t.ts_inicio >= DATE '2026-06-01'
AND t.ts_inicio < DATE '2026-07-01') x
JOIN estaciones e ON e.estacion_id = x.estacion_origen
JOIN distritos d USING (distrito_id)
GROUP BY d.nombre
ORDER BY total DESC;distrito | punta_manana | valle | punta_tarde | nocturno | total --------------+--------------+-------+-------------+----------+-------- Ensanche | 14820 | 11340 | 16905 | 2115 | 45180 Puerto | 9640 | 14210 | 12880 | 3410 | 40140 Ribera | 7115 | 6320 | 8090 | 1145 | 22670 Vallmar Alta | 5980 | 4110 | 6240 | 705 | 17035 Industrial | 4210 | 1890 | 4560 | 380 | 11040
Ahí se lee un patrón real: el Ensanche y el Industrial son claramente pendulares (dos puntas, poco valle) mientras que el Puerto tiene su máximo en valle — es turismo, no desplazamiento al trabajo. El AT TIME ZONE 'Europe/Madrid' no es decorativo: sin él, en verano las franjas saldrían desplazadas dos horas.
C2 — Los pares origen→destino más frecuentes, con su ranking dentro del distrito.
SELECT * FROM (
SELECT eo.nombre AS origen, ed.nombre AS destino, dd.nombre AS distrito_destino,
COUNT(*) AS viajes,
ROUND(AVG(EXTRACT(EPOCH FROM t.duracion) / 60)::numeric, 1) AS min_medio,
RANK() OVER (PARTITION BY dd.distrito_id ORDER BY COUNT(*) DESC) AS puesto
FROM trayectos t
JOIN estaciones eo ON eo.estacion_id = t.estacion_origen
JOIN estaciones ed ON ed.estacion_id = t.estacion_destino
JOIN distritos dd ON dd.distrito_id = ed.distrito_id
WHERE t.ts_fin IS NOT NULL
AND t.ts_inicio >= DATE '2026-06-01' AND t.ts_inicio < DATE '2026-07-01'
GROUP BY eo.estacion_id, eo.nombre, ed.estacion_id, ed.nombre,
dd.distrito_id, dd.nombre
) r
WHERE puesto <= 2
ORDER BY distrito_destino, puesto;origen | destino | distrito_destino | viajes | min_medio | puesto ---------------------+--------------------+------------------+--------+-----------+-------- Estación 7 | Estación 22 | Ensanche | 1284 | 14.2 | 1 Estación 41 | Estación 22 | Ensanche | 967 | 18.6 | 2 Estación 3 | Estación 18 | Industrial | 712 | 21.4 | 1 ...
El RANK() OVER (PARTITION BY ...) sobre un COUNT(*) agregado es el patrón "top N por grupo" que practicaste en 07-04. Nótese que la función de ventana se evalúa después del GROUP BY, por eso puede ordenar por COUNT(*).
C3 — El problema del reequilibrado. Esta es la consulta que ahorra dinero de verdad, y la que mejor demuestra por qué modelamos el trayecto como entidad con dos extremos.
WITH movimientos AS (
SELECT estacion_origen AS estacion_id, ts_inicio AS ts, -1 AS delta
FROM trayectos
WHERE ts_inicio >= DATE '2026-06-01' AND ts_inicio < DATE '2026-07-01'
UNION ALL
SELECT estacion_destino, ts_fin, +1
FROM trayectos
WHERE ts_fin IS NOT NULL
AND ts_fin >= DATE '2026-06-01' AND ts_fin < DATE '2026-07-01'
),
por_franja AS (
SELECT estacion_id,
EXTRACT(HOUR FROM ts AT TIME ZONE 'Europe/Madrid')::int / 4 AS bloque,
SUM(delta) AS neto
FROM movimientos
GROUP BY 1, 2
)
SELECT e.codigo, e.nombre, e.num_anclajes,
SUM(neto) FILTER (WHERE bloque = 1) AS "04-08",
SUM(neto) FILTER (WHERE bloque = 2) AS "08-12",
SUM(neto) FILTER (WHERE bloque = 4) AS "16-20",
SUM(neto) AS neto_mes,
CASE WHEN SUM(neto) < -300 THEN 'se vacía · reponer'
WHEN SUM(neto) > 300 THEN 'se llena · retirar'
ELSE 'equilibrada' END AS diagnostico
FROM por_franja JOIN estaciones e USING (estacion_id)
GROUP BY e.estacion_id, e.codigo, e.nombre, e.num_anclajes
ORDER BY ABS(SUM(neto)) DESC
LIMIT 5;codigo | nombre | num_anclajes | 04-08 | 08-12 | 16-20 | neto_mes | diagnostico --------+--------------+--------------+-------+-------+-------+----------+-------------------- VB-022 | Estación 22 | 24 | -18 | +892 | -774 | +611 | se llena · retirar VB-007 | Estación 7 | 16 | +31 | -845 | +698 | -498 | se vacía · reponer VB-041 | Estación 41 | 20 | +12 | -602 | +515 | -402 | se vacía · reponer VB-018 | Estación 18 | 28 | -8 | +498 | -401 | +377 | se llena · retirar VB-003 | Estación 3 | 12 | +22 | -344 | +266 | -281 | equilibrada
Los signos cuentan la historia entera: la 7 y la 41 son residenciales (se vacían por la mañana, se rellenan por la tarde) y la 22 es un destino de trabajo. La columna neto_mes dice cuántas bicis hay que mover en furgoneta cada mes, y el 08-12 frente al 16-20 dice a qué hora hay que moverlas. La técnica —convertir dos columnas de una fila en dos filas con signo mediante UNION ALL— es la forma canónica de tratar cualquier entidad con dos extremos.
C4 — Ingresos por tipo de abono.
SELECT ta.tipo_abono,
COUNT(*) FILTER (WHERE c.concepto = 'abono') AS n_cuotas,
SUM(c.importe) FILTER (WHERE c.concepto = 'abono') AS eur_cuotas,
SUM(c.importe) FILTER (WHERE c.concepto = 'recargo') AS eur_recargos,
SUM(c.importe) AS eur_total,
ROUND(100.0 * SUM(c.importe) FILTER (WHERE c.concepto = 'recargo')
/ NULLIF(SUM(c.importe), 0), 1) AS pct_recargo
FROM cobros c
JOIN personas_abonadas p USING (persona_id)
JOIN abonos ab ON ab.persona_id = p.persona_id
AND ab.vigencia @> c.ts_cobro::date
JOIN tipos_abono ta ON ta.tipo_abono = ab.tipo_abono
WHERE c.estado = 'cobrado'
AND c.ts_cobro >= DATE '2026-01-01'
GROUP BY ta.tipo_abono
ORDER BY eur_total DESC;tipo_abono | n_cuotas | eur_cuotas | eur_recargos | eur_total | pct_recargo ------------+----------+------------+--------------+-----------+------------- anual | 9840 | 442800.00 | 38215.40 | 481015.40 | 7.9 turistico | 8120 | 121800.00 | 71430.80 | 193230.80 | 36.9 mensual | 6410 | 60895.00 | 19204.20 | 80099.20 | 24.0
El pct_recargo del abono turístico (36,9 %) es el tipo de hallazgo que justifica el informe: las personas visitantes se pasan del tiempo incluido casi la mitad de las veces. O bien 15 minutos son pocos, o bien la app no avisa. Es una conversación de negocio que solo existe porque la consulta la hizo posible.
C5 — Averías por hora de uso.
WITH uso AS (
SELECT bicicleta_id,
SUM(EXTRACT(EPOCH FROM duracion)) / 3600 AS horas
FROM trayectos
WHERE ts_fin IS NOT NULL AND ts_inicio >= DATE '2026-01-01'
GROUP BY bicicleta_id
),
averias AS (
SELECT bicicleta_id, COUNT(*) AS n
FROM ordenes_taller
WHERE tipo = 'averia' AND ts_apertura >= DATE '2026-01-01'
GROUP BY bicicleta_id
)
SELECT b.matricula, m.fabricante, m.nombre AS modelo, b.tipo,
ROUND(u.horas::numeric, 1) AS horas_uso,
COALESCE(a.n, 0) AS averias,
ROUND((COALESCE(a.n,0) * 100 / u.horas)::numeric, 2) AS averias_por_100h
FROM uso u
JOIN bicicletas b USING (bicicleta_id)
JOIN modelos_bici m USING (modelo_id)
LEFT JOIN averias a USING (bicicleta_id)
WHERE u.horas >= 50 -- descarta bicis con muestra insuficiente
ORDER BY averias_por_100h DESC
LIMIT 5;matricula | fabricante | modelo | tipo | horas_uso | averias | averias_por_100h -----------+------------+---------+-----------+-----------+---------+------------------ VB-0417 | Norvent | Urbana2 | mecanica | 214.6 | 9 | 4.19 VB-0388 | Norvent | Urbana2 | mecanica | 188.2 | 7 | 3.72 VB-0512 | Ciclmar | E-Vall | electrica | 341.9 | 11 | 3.22 VB-0041 | Norvent | Urbana2 | mecanica | 255.0 | 8 | 3.05 VB-0733 | Ciclmar | E-Vall | electrica | 298.4 | 9 | 3.02
El WHERE u.horas >= 50 es la parte importante y la que casi nadie pone: sin él, una bici con 2 horas de uso y una avería encabeza la lista con 50 averías/100 h y el informe carece de valor. Toda tasa necesita un umbral mínimo de denominador.
- Rendimiento: las dos consultas que se degradan
Con la tabla vacía todo va rápido. El día que trayectos tiene 1,6 millones de filas, dos cosas se rompen. Seguimos el orden de intervención de 06-03: medir primero, entender el plan, y solo entonces tocar.
Degradación 1 — el informe mensual C1
EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*) FROM trayectos
WHERE ts_inicio >= DATE '2026-06-01' AND ts_inicio < DATE '2026-07-01'; Finalize Aggregate (cost=98421.30..98421.31 rows=1) (actual time=2184.402..2189.117 rows=1)
-> Gather (...)
-> Partial Aggregate (...)
-> Parallel Seq Scan on trayectos (actual time=0.041..2050.883 rows=45180 loops=3)
Filter: ((ts_inicio >= '2026-06-01') AND (ts_inicio < '2026-07-01'))
Rows Removed by Filter: 488153
Buffers: shared read=31204
Planning Time: 0.198 ms
Execution Time: 2189.204 msDiagnóstico: Rows Removed by Filter: 488153 por cada uno de los 3 procesos. Se leen 1,6 millones de filas para quedarse con 45.180. Es el síntoma clásico de 06-03: un filtro muy selectivo (2,8 %) resuelto con recorrido secuencial.
Intervención. Aquí hay una elección real entre dos índices:
| Índice | Tamaño | Coste de escritura | Cuándo gana |
|---|---|---|---|
B-tree (ts_inicio) |
≈34 MB | Alto: 4.400 inserciones/día lo actualizan | Rangos pequeños, y también sirve para "el último trayecto de X" |
BRIN (ts_inicio) |
≈48 KB | Casi nulo | Rangos grandes sobre datos correlacionados con el orden físico |
trayectos se inserta siempre en orden cronológico y nunca se reordena: la correlación física de ts_inicio es prácticamente 1. Es el caso exacto para el que existe BRIN.
CREATE INDEX idx_trayectos_ts_brin ON trayectos USING brin (ts_inicio)
WITH (pages_per_range = 64);
ANALYZE trayectos; Aggregate (actual time=118.940..118.941 rows=1)
-> Bitmap Heap Scan on trayectos (actual time=3.117..114.226 rows=45180 loops=1)
Recheck Cond: ((ts_inicio >= '2026-06-01') AND (ts_inicio < '2026-07-01'))
Rows Removed by Index Recheck: 2841
Heap Blocks: lossy=1408
Buffers: shared hit=1412 read=3
-> Bitmap Index Scan on idx_trayectos_ts_brin (actual time=0.402..0.402 rows=14080 loops=1)
Planning Time: 0.211 ms
Execution Time: 118.987 msDe 2.189 ms a 119 ms, con un índice de 48 KB. Rows Removed by Index Recheck: 2841 es normal en BRIN: el índice trabaja por bloques, así que devuelve algún bloque de más y el motor descarta las filas sobrantes. A cambio ocupa mil veces menos que el B-tree y su mantenimiento en las inserciones es despreciable.
Degradación 2 — "¿qué trayecto tengo abierto?"
Es la consulta más frecuente del sistema: la ejecuta la app cada vez que alguien la abre con una bici en curso.
EXPLAIN ANALYZE
SELECT trayecto_id, ts_inicio, estacion_origen
FROM trayectos WHERE abono_id = 10233 AND ts_fin IS NULL;Seq Scan on trayectos (actual time=1893.221..1893.223 rows=1 loops=1) Filter: ((ts_fin IS NULL) AND (abono_id = 10233)) Rows Removed by Filter: 1599999 Execution Time: 1893.244 ms
Y aquí está el detalle bonito: el índice que lo arregla ya existe. Es uq_trayecto_abierto_abono, el índice único parcial que creamos en el apartado 5 para imponer RN5. Un índice parcial sobre WHERE ts_fin IS NULL cubre, con 1,6 millones de filas en la tabla, solo las 900 como máximo que pueden estar abiertas simultáneamente. Lo único que faltaba era un ANALYZE:
Index Scan using uq_trayecto_abierto_abono on trayectos (actual time=0.031..0.033 rows=1 loops=1) Index Cond: (abono_id = 10233) Buffers: shared hit=3 Execution Time: 0.049 ms
De 1.893 ms a 0,049 ms. La lección es la que 06-03 repetía: una restricción bien elegida es también un índice, y muchas veces el índice que necesitas ya lo has creado sin darte cuenta.
Los tres índices restantes que sí hay que crear a mano:
CREATE INDEX idx_trayectos_origen_ts ON trayectos (estacion_origen, ts_inicio);
CREATE INDEX idx_trayectos_destino_ts ON trayectos (estacion_destino, ts_fin)
WHERE ts_fin IS NOT NULL;
CREATE INDEX idx_ordenes_bici_tipo ON ordenes_taller (bicicleta_id, tipo, ts_apertura);El orden de las columnas en los compuestos sigue la regla de 06-03 —igualdad primero, rango después— y responde a C2 y C3. El segundo es parcial porque los trayectos abiertos no tienen destino y no aportan nada al índice.
- Operación: roles, datos personales y copias
Roles y permisos mínimos
CREATE ROLE vallbici_app LOGIN PASSWORD '...'; -- la API que atiende la app móvil
CREATE ROLE vallbici_taller LOGIN PASSWORD '...'; -- el equipo de mantenimiento
CREATE ROLE vallbici_analista LOGIN PASSWORD '...'; -- informes del ayuntamiento
GRANT SELECT, INSERT, UPDATE ON anclajes, trayectos, bicicletas TO vallbici_app;
GRANT SELECT ON estaciones, tarifas, abonos TO vallbici_app;
GRANT INSERT ON cobros TO vallbici_app;
-- La API NO puede borrar nada, en ninguna tabla. Ni siquiera sus propias filas.
GRANT SELECT, INSERT, UPDATE ON ordenes_taller TO vallbici_taller;
GRANT SELECT, UPDATE (estado) ON bicicletas TO vallbici_taller;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO vallbici_analista;
REVOKE SELECT ON personas_abonadas, cobros FROM vallbici_analista;
GRANT SELECT ON v_trayectos_anonimos TO vallbici_analista;El GRANT SELECT, UPDATE (estado) ON bicicletas es la aplicación literal del principio de mínimo privilegio de 06-04: el taller cambia el estado de una bici, y solo el estado. No puede tocar km_acumulados ni matricula.
Datos personales
Los informes del ayuntamiento no necesitan saber quién hizo cada trayecto. La vista que consume el perfil analista rompe el vínculo:
CREATE VIEW v_trayectos_anonimos AS
SELECT t.trayecto_id, t.ts_inicio, t.ts_fin, t.duracion, t.importe,
t.estacion_origen, t.estacion_destino,
ab.tipo_abono,
p.distrito_id AS distrito_residencia,
date_part('year', age(p.fecha_nac))::int / 10 * 10 AS decada_edad
FROM trayectos t
JOIN abonos ab USING (abono_id)
JOIN personas_abonadas p USING (persona_id);Dos observaciones honestas sobre esto:
- La vista no anonimiza de verdad. Con distrito de residencia, década de edad y patrón de trayectos, una persona con recorrido singular puede ser reidentificable. Reducir el riesgo requiere agregación mínima (no publicar celdas con menos de N personas) y eso es una decisión de política, no de SQL.
- El cumplimiento normativo no lo decide quien diseña la base de datos. Base legal del tratamiento, plazos de conservación de la telemetría, evaluación de impacto, derecho de supresión: todo eso lo revisa un profesional de protección de datos o compliance. Lo que sí es responsabilidad técnica es que el esquema permita cumplirlo: por eso el documento se guarda como
documento_hashy no en claro, por esoON DELETE RESTRICTobliga a un procedimiento explícito de supresión en lugar de borrar en cascada el histórico contable, y por eso existe la vista.
Copias
| Elemento | Estrategia | Frecuencia | Objetivo |
|---|---|---|---|
| Copia base física | pg_basebackup |
Diaria, 02:00 | Restauración completa |
| WAL archivado | archive_command a almacenamiento externo |
Continuo | PITR: restaurar a cualquier instante |
| Volcado lógico | pg_dump -Fc |
Semanal | Recuperar una tabla suelta sin restaurar todo |
| Prueba de restauración | Restaurar en máquina aparte y ejecutar la auditoría del contador | Mensual | Que la copia sirva de verdad |
La última fila es la que más se salta y la única que garantiza algo. En palabras de 06-04: una copia que nunca se ha restaurado no es una copia, es una esperanza. Con PITR configurado, el escenario "a las 11:40 alguien ejecutó un UPDATE sin WHERE sobre tarifas" se resuelve restaurando a las 11:39.
- Qué resuelve mal este esquema
El sistema funciona, cumple los ocho requisitos funcionales y responde a las cinco consultas. Y aun así hay tres cosas del encargo inicial que este esquema resuelve mal. Vale la pena ser preciso, porque son exactamente el material de la lección siguiente.
1. La telemetría GPS. El encargo pedía guardar el rastro de cada trayecto: posiciones cada 5 segundos, batería de las eléctricas, incidencias. Un trayecto medio de 14 minutos son ~170 puntos. Con 1,6 millones de trayectos anuales, eso es una tabla posiciones de 272 millones de filas al año, cada una con trayecto_id, ts, lat, lon y poco más. Se puede hacer —PostgreSQL aguanta— pero es un mal encaje: son datos que se escriben masivamente, se leen siempre enteros y por trayecto, no se actualizan nunca, no necesitan integridad referencial estricta y caducan a los pocos meses. Estás pagando el precio del modelo relacional (índice por fila, WAL por fila, visibilidad por fila) para un dato que no usa ninguna de sus ventajas.
2. La ficha enriquecida de la estación. Nuestra tabla estaciones tiene 11 columnas planas. La ficha que quiere la app tiene fotos, horarios de acceso que varían por estación, accesibilidad, si está bajo cubierta, si tiene bomba de aire, notas de la empresa mantenedora, etiquetas de puntos de interés cercanos… y cada estación tiene un subconjunto distinto de esos atributos. Modelarlo en el relacional lleva a una de tres salidas, todas malas: 40 columnas nulas, una tabla clave-valor genérica (el anti-patrón EAV que 04-01 marcó en rojo) o una tabla nueva por cada atributo que se le ocurra a alguien.
3. Las incidencias con estructura variable. ordenes_taller tiene un motivo VARCHAR(60). Pero una incidencia de "frenos" necesita registrar qué freno y qué medida; una de "batería" necesita ciclos de carga y voltaje; una de "vandalismo" necesita fotos y número de atestado. Son estructuras distintas por tipo, y añadir un tipo nuevo no debería requerir un ALTER TABLE en producción.
Las tres tienen algo en común: estructura variable o volumen alto sin necesidad de transacciones. Y las tres tienen algo más en común, más importante todavía: ninguna de ellas es el núcleo transaccional. Nadie cobra dinero a partir de un punto GPS. Eso es lo que permite sacarlas de PostgreSQL sin poner en riesgo lo que importa, y es justo lo que hace la lección 08-02.
Errores Comunes y Consejos
Error 1: empezar por el CREATE TABLE. Es el error del que se derivan casi todos los demás. Las siete preguntas del apartado 1 costaron veinte minutos de reunión y determinaron la mitad del esquema. Si no las hubiéramos hecho, habríamos descubierto la necesidad de la tarifa congelada el día de la primera subida de precios, con dos años de facturación ya emitida.
Error 2: no modelar el anclaje "porque con un contador basta". Funciona hasta el primer anclaje averiado. La regla general: si el cliente dice que algo tiene estado propio, es una entidad, aunque parezca un número.
Error 3: FLOAT para el dinero. Sigue ocurriendo. NUMERIC para todo lo que se cobra, se factura o se suma.
Error 4: TIMESTAMP sin zona en un sistema con cambio horario. El domingo de octubre producirá trayectos de duración negativa y la restauración de esa noche será un rompecabezas.
Error 5: mantener a mano un contador desnormalizado. Si bicis_disponibles se actualiza desde la aplicación y desde el disparador, se duplica el incremento. Uno de los dos, y mejor el disparador.
Error 6: subir el nivel de aislamiento en vez de bloquear la fila correcta. SERIALIZABLE no es una solución mágica: convierte una carrera en un error de serialización que la aplicación debe reintentar. Un FOR UPDATE SKIP LOCKED sobre la fila que decide es más barato y más predecible.
Consejo 1: escribe la lista de consultas antes que el esquema. Es el mejor detector de entidades que falta. C3 fue la que confirmó que el trayecto necesitaba sus dos extremos como claves ajenas independientes.
Consejo 2: cada restricción que escribas, pruébala fallando. Una restricción que nunca has visto rechazar un INSERT no sabes si está bien escrita.
Consejo 3: mira si el índice ya existe antes de crearlo. La degradación 2 se resolvió sola. Los índices únicos, parciales o no, son índices completos.
Consejo 4: documenta las desnormalizaciones en el propio esquema. El comentario -- Desnormalización deliberada nº 1 evita que dentro de tres años alguien "arregle" el esquema quitándola. Mejor todavía: COMMENT ON COLUMN.
Ejercicios
Ejercicio 1 — Reservas de bicicleta
El ayuntamiento quiere permitir reservar una bicicleta desde la app: la persona reserva una bici concreta de una estación y tiene 10 minutos para llegar y desbloquearla; pasado ese tiempo, la reserva caduca y la bici vuelve a estar disponible. Una persona solo puede tener una reserva activa.
- Decide si la reserva es una entidad nueva o un estado de algo existente, y justifícalo.
- Escribe el
CREATE TABLE(o elALTER TABLE) con todas las restricciones necesarias. - Escribe la transacción de reserva, con su control de concurrencia.
- Explica cómo caducan las reservas y por qué esa decisión.
Ejercicio 2 — Tarifa reducida por bono social
Se introduce una tarifa reducida para personas con bono social municipal. La condición se acredita cada año y puede dejar de cumplirse. Un trayecto se cobra con la tarifa reducida si la persona tenía el bono social vigente en el momento del desbloqueo.
- Modela la acreditación del bono social sin duplicar la información de
personas_abonadas. - Explica por qué la restricción
EXCLUDEdeabonosno basta aquí. - Escribe la consulta que calcula, para el mes pasado, cuánto ha dejado de ingresar el ayuntamiento por las tarifas reducidas.
Ejercicio 3 — Detectar bicicletas "fantasma"
Una bicicleta fantasma es la que lleva más de 48 horas en un trayecto abierto: se la robaron, se rompió el sistema de anclaje o la app falló al cerrarlo. Escribe la consulta que las lista con la última estación conocida, cuánto tiempo llevan y el nombre de contacto de la persona que la desbloqueó, ordenadas de más antigua a más reciente. Añade el índice que la haga eficiente, o explica por qué no hace falta ninguno.
Soluciones
Solución 1
1. Entidad nueva. Tres argumentos: (a) tiene atributos propios —instante de reserva, instante de caducidad, resultado—; (b) tiene historial: querremos saber cuántas reservas caducan sin usarse, y un estado sobreescrito no deja rastro; (c) relaciona tres cosas (persona, bici y anclaje) con temporalidad propia. Un estado = 'reservada' en bicicletas no permitiría ninguna de las tres.
2. El esquema:
CREATE TABLE reservas (
reserva_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
abono_id INTEGER NOT NULL REFERENCES abonos ON DELETE RESTRICT,
bicicleta_id INTEGER NOT NULL REFERENCES bicicletas ON DELETE RESTRICT,
estacion_id INTEGER NOT NULL,
anclaje SMALLINT NOT NULL,
ts_reserva TIMESTAMPTZ NOT NULL DEFAULT now(),
ts_limite TIMESTAMPTZ NOT NULL
GENERATED ALWAYS AS (ts_reserva + INTERVAL '10 minutes') STORED,
resultado VARCHAR(10) NOT NULL DEFAULT 'activa'
CHECK (resultado IN ('activa','usada','caducada','cancelada')),
FOREIGN KEY (estacion_id, anclaje) REFERENCES anclajes (estacion_id, numero)
);
CREATE UNIQUE INDEX uq_reserva_activa_abono ON reservas (abono_id) WHERE resultado = 'activa';
CREATE UNIQUE INDEX uq_reserva_activa_bici ON reservas (bicicleta_id) WHERE resultado = 'activa';
CREATE INDEX idx_reservas_caducar ON reservas (ts_limite) WHERE resultado = 'activa';Los dos índices únicos parciales imponen "una reserva activa por persona" y "una reserva activa por bici" — el mismo patrón que RN4/RN5. El tercero es de servicio, para el proceso de caducidad.
3. La transacción:
BEGIN;
SELECT a.estacion_id, a.numero, a.bicicleta_id
FROM anclajes a
JOIN bicicletas b ON b.bicicleta_id = a.bicicleta_id
WHERE a.estacion_id = 12 AND a.estado = 'operativo' AND b.estado = 'anclada'
AND NOT EXISTS (SELECT 1 FROM reservas r
WHERE r.bicicleta_id = a.bicicleta_id AND r.resultado = 'activa')
ORDER BY b.km_acumulados
FOR UPDATE OF a SKIP LOCKED
LIMIT 1;
INSERT INTO reservas (abono_id, bicicleta_id, estacion_id, anclaje)
VALUES (10233, 417, 12, 3);
COMMIT;El anti-join NOT EXISTS excluye las bicis ya reservadas; el índice único parcial es la red de seguridad si dos peticiones lo esquivan. Fíjate en que no se marca la bici como reservada en bicicletas: el estado vive en reservas, un solo sitio.
4. La caducidad. Dos opciones y una elegida.
Descartada: un proceso que cada minuto marque como caducada las vencidas. Funciona, pero introduce una ventana en la que la reserva está vencida y todavía figura activa, y depende de que el proceso no se caiga.
Elegida: caducidad implícita en la lectura. La reserva se considera activa si resultado = 'activa' AND ts_limite > now(); el NOT EXISTS de arriba añade esa condición. El proceso de limpieza sigue existiendo, pero solo para dejar el histórico ordenado, y que se retrase no afecta a la corrección. La regla general: cuando el tiempo determina la validez de un dato, la verdad debe estar en la consulta, no en un proceso.
Solución 2
1. El modelo. Una tabla de acreditaciones con vigencia, no una columna booleana en personas_abonadas:
CREATE TABLE bonos_sociales (
bono_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
persona_id INTEGER NOT NULL REFERENCES personas_abonadas ON DELETE RESTRICT,
vigencia DATERANGE NOT NULL,
expediente VARCHAR(20) NOT NULL UNIQUE,
EXCLUDE USING gist (persona_id WITH =, vigencia WITH &&)
);
ALTER TABLE tarifas ADD COLUMN bono_social BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE tarifas DROP CONSTRAINT tarifas_tipo_abono_vigencia_excl;
ALTER TABLE tarifas ADD EXCLUDE USING gist
(tipo_abono WITH =, bono_social WITH =, vigencia WITH &&);Una columna tiene_bono_social BOOLEAN sería el error clásico: no guarda desde cuándo, no guarda el expediente y, sobre todo, no permite reconstruir si lo tenía el día del trayecto. La acreditación es un hecho con fecha, y los hechos con fecha van en filas, no en columnas.
2. Por qué el EXCLUDE de abonos no basta. Ese EXCLUDE garantiza que no haya dos abonos solapados de la misma persona; no dice nada sobre qué tarifa se aplica. El bono social es una condición ortogonal al abono y con su propia vigencia: una persona puede tener un abono anual del 1 de enero al 31 de diciembre y un bono social que caduca el 30 de junio. A partir del 1 de julio, el mismo abono debe generar trayectos con tarifa normal. Como el trayecto ya congela su tarifa (RN9), el sistema queda correcto sin tocar nada más: basta con que la transacción de desbloqueo elija la tarifa consultando bonos_sociales con vigencia @> CURRENT_DATE.
3. La consulta de coste fiscal:
SELECT COUNT(*) AS trayectos_reducidos,
SUM(t.importe) AS ingresado,
SUM(t.importe * (tn.precio_fraccion / NULLIF(t.precio_fraccion, 0)))
AS habria_ingresado,
SUM(t.importe * (tn.precio_fraccion / NULLIF(t.precio_fraccion, 0)))
- SUM(t.importe) AS coste_bonificacion
FROM trayectos t
JOIN tarifas tr ON tr.tarifa_id = t.tarifa_id AND tr.bono_social
JOIN tarifas tn ON tn.tipo_abono = tr.tipo_abono
AND NOT tn.bono_social
AND tn.vigencia && tr.vigencia
WHERE t.ts_fin IS NOT NULL
AND t.ts_inicio >= date_trunc('month', CURRENT_DATE - INTERVAL '1 month')
AND t.ts_inicio < date_trunc('month', CURRENT_DATE); trayectos_reducidos | ingresado | habria_ingresado | coste_bonificacion
---------------------+-----------+------------------+--------------------
8412 | 2184.60 | 4369.20 | 2184.60El NULLIF protege del cero: si la tarifa reducida tuviera precio_fraccion = 0, la división reventaría.
Solución 3
SELECT b.matricula, b.tipo,
e.codigo AS ultima_estacion, e.nombre,
t.ts_inicio,
date_trunc('minute', now() - t.ts_inicio) AS tiempo_abierto,
p.nombre || ' ' || p.apellidos AS contacto, p.telefono
FROM trayectos t
JOIN bicicletas b USING (bicicleta_id)
JOIN estaciones e ON e.estacion_id = t.estacion_origen
JOIN abonos ab USING (abono_id)
JOIN personas_abonadas p USING (persona_id)
WHERE t.ts_fin IS NULL
AND t.ts_inicio < now() - INTERVAL '48 hours'
ORDER BY t.ts_inicio;matricula | tipo | ultima_estacion | nombre | ts_inicio | tiempo_abierto | contacto -----------+-----------+-----------------+-------------+------------------------+----------------+----------- VB-0233 | mecanica | VB-041 | Estación 41 | 2026-06-09 19:12:04+02 | 5 days 03:41 | ... VB-0781 | electrica | VB-007 | Estación 7 | 2026-06-12 08:33:51+02 | 2 days 14:19 | ...
Sobre el índice: no hace falta ninguno nuevo. El filtro ts_fin IS NULL es tremendamente selectivo —como máximo 900 filas de 1,6 millones— y uq_trayecto_abierto_bici ya cubre exactamente ese predicado parcial. El planificador puede recorrerlo entero (900 entradas) y filtrar después por ts_inicio; añadir un índice sobre (ts_inicio) WHERE ts_fin IS NULL mejoraría marginalmente una consulta que se ejecuta una vez al día y añadiría mantenimiento a 4.400 inserciones diarias. No es rentable. Este es el razonamiento que 06-03 pedía: un índice se justifica por la frecuencia de la consulta y el coste de escritura, no por el tamaño de la tabla.
Conclusión
Has recorrido un proyecto relacional entero: de una transcripción de reunión de dos párrafos a un sistema con once tablas, veinte restricciones que codifican reglas de negocio reales, dos transacciones concurrentes correctas, cinco informes de explotación y un plan de operación.
Lo que conviene llevarse no es el esquema —el de tu próximo proyecto será otro— sino la forma de las decisiones. Cada una de las importantes tuvo el mismo formato: una tentación simple, una pregunta al cliente que la desmontó, dos o tres alternativas puestas en una tabla y un criterio explícito para elegir. El anclaje es una entidad porque tiene estado propio. El trayecto es una entidad porque nace incompleto y tiene identidad. La jerarquía usa tabla por subclase porque otras tablas referencian la superclase y porque el número de serie de la batería tiene que ser obligatorio. bicis_disponibles rompe la 3FN por velocidad y se vigila; la tarifa congelada la rompe por semántica y vigilarla sería un error. Ninguna de esas frases es una preferencia estética: todas son argumentos que se pueden discutir y, si hiciera falta, rebatir.
También has visto que las herramientas del curso no se usan de una en una. El EXCLUDE USING gist de 04-04 impone una regla de negocio de 04-01; el índice único parcial que impone RN5 resulta ser, sin cambiar una línea, el índice que arregla la consulta más frecuente del sistema; la desnormalización de 05-04 necesita el disparador de 05-04 y la auditoría nocturna de 05-04, las tres cosas o ninguna. Un sistema en producción es esto: piezas del temario funcionando a la vez y sosteniéndose entre sí.
Y termina con una lista de tres fracasos, que es la parte más honesta del caso. La telemetría GPS, la ficha enriquecida de la estación y las incidencias de estructura variable no encajan bien en este esquema, y forzarlas ahí produciría columnas nulas, tablas EAV y migraciones cada vez que alguien inventa un tipo de incidencia. En la lección 08-02 cogemos esas tres piezas exactas —ni una más— y las modelamos en MongoDB con el método de 03-03, comprobando qué se gana, qué se pierde y por qué la tentación de "migrarlo todo a Mongo" es la respuesta equivocada. El núcleo transaccional que acabas de construir se queda donde está: es él quien cobra el dinero.
Fundamentos de Bases de Datos
Módulo 1: Introducción a las Bases de Datos
- Conceptos Básicos de Bases de Datos
- Tipos de Bases de Datos
- Historia y Evolución de las Bases de Datos
- Sistemas Gestores de Bases de Datos y Arquitectura
Módulo 2: Bases de Datos Relacionales
- Modelo Relacional
- Lenguaje SQL
- Operaciones Básicas en SQL
- Consultas Multitabla: JOIN y Subconsultas
- Agregación y Agrupación de Datos
- Integridad Referencial
Módulo 3: Bases de Datos No Relacionales
- Introducción a NoSQL
- Tipos de Bases de Datos NoSQL
- Modelado de Datos en NoSQL
- Comparación entre Bases de Datos Relacionales y No Relacionales
Módulo 4: Diseño de Esquemas
- Principios de Diseño de Esquemas
- Diagramas Entidad-Relación (ER)
- Transformación de Diagramas ER a Esquemas Relacionales
- Tipos de Datos y Restricciones
Módulo 5: Normalización
Módulo 6: Transacciones, Rendimiento y Seguridad
- Transacciones y Propiedades ACID
- Concurrencia y Niveles de Aislamiento
- Índices y Optimización de Consultas
- Seguridad, Permisos y Copias de Seguridad
Módulo 7: Ejercicios Prácticos
- Ejercicios de SQL
- Ejercicios de Diseño de Esquemas
- Ejercicios de Normalización
- Ejercicios de Consultas Avanzadas y Transacciones
Módulo 8: Casos de Estudio
- Caso de Estudio: Base de Datos Relacional
- Caso de Estudio: Base de Datos No Relacional
- Caso de Estudio: Persistencia Políglota
