🎯 Meta: meter lógica dentro de la base de datos. Hasta ahora la BD solo guardaba datos; ahora puede reaccionar, calcular y automatizar. Esto es lo que separa a un backend junior de uno senior.
Seguimos con PostgreSQL 18.
2.1 · ¿Por qué poner lógica en la base de datos?
Normalmente la lógica va en tu app (Laravel, FastAPI…). Pero hay cosas que conviene poner en la propia BD:
- Integridad garantizada: una regla en la BD se cumple aunque cambies de framework o alguien toque los datos por otro camino.
- Rendimiento: procesar cerca de los datos evita ir y venir por la red.
- Automatización: que ciertas cosas pasen "solas" al insertar/actualizar.
⚖️ Equilibrio (tip importante): no metas toda la lógica en la BD. Reglas de negocio complejas van mejor en la app (más fáciles de testear y versionar). Usa la BD para: integridad, auditoría, cálculos derivados y automatismos simples. Un exceso de triggers hace que "pasen cosas mágicas" difíciles de depurar.
2.2 · Vistas (Views) — Consultas guardadas
Una vista es una consulta con nombre que se comporta como una tabla virtual. Útil para encapsular consultas complejas o exponer solo ciertos datos.
CREATE VIEW usuarios_activos AS
SELECT id, nombre, email
FROM usuarios
WHERE activo = true;
-- Ahora la usas como si fuera una tabla:
SELECT * FROM usuarios_activos WHERE email LIKE '%@empresa.com';Vista materializada (MATERIALIZED VIEW): guarda el resultado en disco (rápida de leer, pero hay que refrescarla). Ideal para informes pesados que no cambian a cada segundo:
CREATE MATERIALIZED VIEW ventas_por_mes AS
SELECT date_trunc('month', creado_en) AS mes, SUM(total) AS total
FROM pedidos
GROUP BY 1;
REFRESH MATERIALIZED VIEW ventas_por_mes; -- actualizar cuando haga falta2.3 · Funciones (Functions) — Lógica reutilizable
Una función recibe parámetros, ejecuta lógica y devuelve un valor. En PostgreSQL se escriben en PL/pgSQL (el lenguaje procedimental de Postgres).
CREATE OR REPLACE FUNCTION calcular_igv(monto NUMERIC)
RETURNS NUMERIC AS $$
BEGIN
RETURN monto * 0.18; -- IGV del 18% (Perú)
END;
$$ LANGUAGE plpgsql;
-- Usarla:
SELECT calcular_igv(100); -- devuelve 18.00
SELECT nombre, calcular_igv(total) AS igv FROM pedidos JOIN usuarios ...;Ejemplo más real: una función que devuelve el total gastado por un usuario.
CREATE OR REPLACE FUNCTION total_gastado(p_usuario_id BIGINT)
RETURNS NUMERIC AS $$
DECLARE
v_total NUMERIC;
BEGIN
SELECT COALESCE(SUM(total), 0)
INTO v_total
FROM pedidos
WHERE usuario_id = p_usuario_id;
RETURN v_total;
END;
$$ LANGUAGE plpgsql;
SELECT nombre, total_gastado(id) FROM usuarios;Elementos de PL/pgSQL:
| Elemento | Qué hace |
|---|---|
DECLARE | Declara variables locales |
v_total NUMERIC | Una variable (prefijo v_ por convención) |
SELECT ... INTO variable | Guarda el resultado de la consulta en la variable |
COALESCE(x, 0) | Si x es NULL, usa 0 (evita nulos) |
RETURN | Devuelve el valor |
2.4 · Procedimientos almacenados (Stored Procedures)
Un procedimiento es como una función pero no devuelve un valor (o devuelve varios vía parámetros) y puede controlar transacciones (COMMIT/ROLLBACK dentro). Se usa para acciones, no para cálculos.
CREATE OR REPLACE PROCEDURE transferir(
p_origen BIGINT,
p_destino BIGINT,
p_monto NUMERIC
)
LANGUAGE plpgsql AS $$
BEGIN
-- Validación
IF p_monto <= 0 THEN
RAISE EXCEPTION 'El monto debe ser positivo, recibido: %', p_monto;
END IF;
UPDATE cuentas SET saldo = saldo - p_monto WHERE id = p_origen;
UPDATE cuentas SET saldo = saldo + p_monto WHERE id = p_destino;
-- Comprobar que la cuenta origen no quedó en negativo
IF (SELECT saldo FROM cuentas WHERE id = p_origen) < 0 THEN
RAISE EXCEPTION 'Saldo insuficiente en la cuenta %', p_origen;
END IF;
END;
$$;
-- Llamarlo:
CALL transferir(1, 2, 100.00);🧠 Función vs Procedimiento:
- Función → calcula y devuelve algo. Se usa dentro de un
SELECT.- Procedimiento → hace algo (acción). Se llama con
CALL. Puede gestionar transacciones.Si al escribirlo piensas "quiero un resultado" → función. Si piensas "quiero que ocurra algo" → procedimiento.
RAISE EXCEPTION lanza un error y aborta la operación (hace rollback automático). El % se sustituye por el valor. Esto es tu red de seguridad dentro de la BD.
2.5 · Triggers — Reaccionar automáticamente
Un trigger ("disparador") ejecuta una función automáticamente cuando pasa algo en una tabla (INSERT, UPDATE, DELETE). El caso más común: auditoría y campos automáticos.
Ejemplo 1: actualizar actualizado_en automáticamente
-- 1. La función que se ejecutará
CREATE OR REPLACE FUNCTION set_actualizado_en()
RETURNS TRIGGER AS $$
BEGIN
NEW.actualizado_en = now(); -- NEW = la fila que se está guardando
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 2. El trigger que la dispara
CREATE TRIGGER trg_usuarios_actualizado
BEFORE UPDATE ON usuarios
FOR EACH ROW
EXECUTE FUNCTION set_actualizado_en();Ahora, cada vez que hagas UPDATE sobre usuarios, la columna actualizado_en se pone sola en la fecha actual. No tienes que acordarte nunca.
Ejemplo 2: tabla de auditoría (registrar quién cambió qué)
CREATE TABLE auditoria_pedidos (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pedido_id BIGINT,
accion VARCHAR(10), -- INSERT / UPDATE / DELETE
datos_antes JSONB,
datos_despues JSONB,
fecha TIMESTAMPTZ DEFAULT now()
);
CREATE OR REPLACE FUNCTION auditar_pedido()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO auditoria_pedidos (pedido_id, accion, datos_antes, datos_despues)
VALUES (
COALESCE(NEW.id, OLD.id),
TG_OP, -- operación: 'INSERT', 'UPDATE' o 'DELETE'
to_jsonb(OLD), -- fila anterior (NULL en INSERT)
to_jsonb(NEW) -- fila nueva (NULL en DELETE)
);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_auditar_pedidos
AFTER INSERT OR UPDATE OR DELETE ON pedidos
FOR EACH ROW
EXECUTE FUNCTION auditar_pedido();Ahora todo cambio en pedidos queda registrado automáticamente en auditoria_pedidos. Oro puro para sistemas serios (banca, salud, ERP).
Variables mágicas dentro de un trigger
| Variable | Qué contiene |
|---|---|
NEW | La fila nueva (en INSERT y UPDATE) |
OLD | La fila anterior (en UPDATE y DELETE) |
TG_OP | Qué operación: 'INSERT', 'UPDATE', 'DELETE' |
TG_TABLE_NAME | Nombre de la tabla |
BEFORE vs AFTER: BEFORE se ejecuta antes de guardar (puedes modificar NEW o cancelar); AFTER se ejecuta después (útil para auditar o disparar otras acciones).
⚠️ Cuidado con los triggers en cascada: un trigger que modifica otra tabla que a su vez tiene otro trigger… puede crear cadenas difíciles de seguir o incluso bucles. Documéntalos bien y no abuses.
2.6 · CTEs — Consultas legibles (WITH)
Una CTE (Common Table Expression) es una "sub-consulta con nombre" que hace las consultas complejas mucho más legibles:
WITH gastos_por_usuario AS (
SELECT usuario_id, SUM(total) AS gastado
FROM pedidos
GROUP BY usuario_id
)
SELECT u.nombre, g.gastado
FROM usuarios u
JOIN gastos_por_usuario g ON g.usuario_id = u.id
WHERE g.gastado > 500
ORDER BY g.gastado DESC;En vez de anidar sub-consultas ilegibles, defines cada paso arriba con WITH y lo usas abajo. Se leen de arriba a abajo, como una receta.
CTE recursiva (para jerarquías: organigramas, categorías anidadas, árboles):
WITH RECURSIVE subordinados AS (
-- caso base: el jefe
SELECT id, nombre, jefe_id FROM empleados WHERE id = 1
UNION ALL
-- paso recursivo: los que reportan a alguien ya incluido
SELECT e.id, e.nombre, e.jefe_id
FROM empleados e
JOIN subordinados s ON e.jefe_id = s.id
)
SELECT * FROM subordinados;2.7 · Funciones de ventana (Window Functions)
Permiten hacer cálculos "por grupos" sin colapsar las filas (a diferencia de GROUP BY). Perfectas para rankings, running totals, comparar con la fila anterior.
-- Ranking de pedidos por usuario según su monto
SELECT
usuario_id,
total,
ROW_NUMBER() OVER (PARTITION BY usuario_id ORDER BY total DESC) AS ranking,
SUM(total) OVER (PARTITION BY usuario_id) AS total_usuario
FROM pedidos;OVER (...)define la "ventana" (el grupo sobre el que calcular).PARTITION BYdivide en grupos (como GROUP BY pero sin colapsar).- Funciones útiles:
ROW_NUMBER(),RANK(),LAG()/LEAD()(fila anterior/siguiente),SUM() OVER (...)(acumulados).
💡 Estas son las consultas que impresionan en una entrevista técnica y las que necesitas para dashboards e informes. Si las dominas, estás por encima de la media.
2.8 · JSONB — datos semi-estructurados dentro de una columna relacional
En el cap. 01 viste el tipo JSONB para "metadatos flexibles". Aquí lo consultas de verdad. Úsalo para datos que varían de forma (atributos de producto, preferencias de usuario), no como excusa para no modelar relaciones reales.
CREATE TABLE productos (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nombre VARCHAR(200) NOT NULL,
atributos JSONB NOT NULL DEFAULT '{}'
);
INSERT INTO productos (nombre, atributos) VALUES
('Teclado RGB', '{"marca": "Logitech", "colores": ["negro", "blanco"], "precio_usd": 89.9}');-- -> devuelve el valor como JSON; ->> lo devuelve como texto
SELECT atributos->'marca' FROM productos; -- "Logitech" (JSON)
SELECT atributos->>'marca' FROM productos; -- Logitech (texto)
-- Filtrar por un campo dentro del JSON
SELECT * FROM productos WHERE atributos->>'marca' = 'Logitech';
-- @> "contiene" — ¿el JSON incluye esta clave/valor?
SELECT * FROM productos WHERE atributos @> '{"marca": "Logitech"}';
-- Acceso anidado y a arrays: atributos->'colores'->>0 es el primer color
SELECT nombre, atributos->'colores'->>0 AS primer_color FROM productos;
-- Actualizar solo una clave del JSON, sin tocar el resto
UPDATE productos SET atributos = atributos || '{"precio_usd": 79.9}'::jsonb WHERE id = 1;-- Índice GIN: imprescindible si filtras por JSONB en tablas grandes
CREATE INDEX idx_productos_atributos ON productos USING GIN (atributos);⚠️ JSONB no reemplaza el modelado relacional. Si vas a filtrar, ordenar o hacer JOIN por un campo con frecuencia, esa es una columna real (o una tabla relacionada), no una clave dentro de un JSON. JSONB brilla para datos verdaderamente variables (atributos por categoría de producto, configuración de usuario) — no para modelar pereza.
2.9 · Seguridad: Row-Level Security (RLS)
PostgreSQL puede filtrar filas automáticamente según quién consulta. Clave para apps multi-inquilino (SaaS donde cada empresa solo ve sus datos):
ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;
CREATE POLICY pedidos_por_empresa ON pedidos
USING (empresa_id = current_setting('app.empresa_id')::BIGINT);Ahora, sin importar la consulta, cada empresa solo ve sus pedidos. La seguridad vive en la BD, no depende de que el programador recuerde poner el WHERE.
🔗 Nota: tu proyecto CLAINEV ERP usa exactamente RLS de PostgreSQL para el aislamiento multi-inquilino. Este es el mecanismo que lo hace seguro a nivel de base de datos.
2.10 · Niveles de aislamiento — qué puede salir mal con transacciones concurrentes
El cap. 01 te dio la "I" de ACID como "las transacciones no se pisan", pero eso tiene matices: cuánto se aíslan dos transacciones simultáneas es configurable, y el valor por defecto no siempre te protege de todo. Tres problemas clásicos:
Dirty read: lees un dato que otra transacción escribió pero AÚN NO confirmó
(si esa transacción hace ROLLBACK, leíste algo que nunca existió).
Non-repeatable read: lees la misma fila dos veces en la misma transacción y obtienes
valores distintos (otra transacción la modificó y confirmó entre medias).
Phantom read: repites la misma consulta con WHERE y la segunda vez aparecen
filas nuevas que otra transacción insertó y confirmó entre medias.PostgreSQL ofrece 3 niveles reales (el estándar SQL define 4, pero Postgres nunca permite dirty read ni en el más flojo):
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; -- por defecto: cada SELECT ve lo ya confirmado
-- ...
COMMIT;| Nivel | Evita | Coste | Cuándo usarlo |
|---|---|---|---|
READ COMMITTED (por defecto) | Dirty read | Bajo | El 95% del código de aplicación normal |
REPEATABLE READ | + Non-repeatable read | Medio | Informes/cálculos que leen varias veces y necesitan una "foto" consistente |
SERIALIZABLE | + Phantom read (aísla como si fueran secuenciales) | Alto (puede forzar reintentos) | Dinero, inventario, reservas — donde una condición de carrera cuesta caro |
-- SERIALIZABLE puede rechazar tu transacción si detecta un conflicto real con otra concurrente
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT stock FROM productos WHERE id = 1; -- ambas transacciones leen stock = 1
-- ambas intentan vender la última unidad...
COMMIT; -- una de las dos falla con "could not serialize access" y debe reintentar🧠 La regla práctica: empieza siempre en
READ COMMITTED(es el default, y basta para casi todo). Sube aSERIALIZABLEsolo en el código crítico donde una condición de carrera significa vender dos veces el mismo asiento o el mismo producto — y prepárate para reintentar la transacción si Postgres la rechaza, es el comportamiento esperado, no un bug.
💡 Alternativa más barata que SERIALIZABLE: un
SELECT ... FOR UPDATEbloquea explícitamente las filas que lees hasta que termine tu transacción, evitando que otra transacción las modifique mientras tanto — suficiente para la mayoría de los casos de "reservar stock" sin pagar el coste deSERIALIZABLEcompleto.
BEGIN;
SELECT stock FROM productos WHERE id = 1 FOR UPDATE; -- bloquea esta fila hasta el COMMIT
UPDATE productos SET stock = stock - 1 WHERE id = 1;
COMMIT;2.11 · Búsqueda de texto completo (sin instalar nada más)
Antes de montar Elasticsearch (apéndice T), sepas esto: PostgreSQL trae un buscador dentro, y para la inmensa mayoría de proyectos es más que suficiente.
El problema con LIKE
SELECT * FROM articulos WHERE cuerpo LIKE '%programación%';Falla en todo lo que importa: no encuentra "programacion" (sin tilde) ni "programar" (misma raíz), no ordena por relevancia, y % al principio impide usar cualquier índice → escaneo completo.
tsvector y tsquery
-- El texto se convierte en un vector de LEXEMAS (raíces normalizadas)
SELECT to_tsvector('spanish', 'Los programadores programan programas');
-- → 'program':2,3,4 ← las tres palabras comparten raíz, y "los" se descarta
SELECT to_tsvector('spanish', 'Los programadores programan')
@@ to_tsquery('spanish', 'programa'); -- → true (@@ es "coincide con")Implementación recomendada: columna generada + índice GIN
ALTER TABLE articulos
ADD COLUMN busqueda tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('spanish', coalesce(titulo, '')), 'A') ||
setweight(to_tsvector('spanish', coalesce(cuerpo, '')), 'B')
) STORED;
CREATE INDEX idx_articulos_busqueda ON articulos USING GIN (busqueda);setweight marca la importancia: 'A' (título) pesa más que 'B' (cuerpo). GENERATED … STORED significa que PostgreSQL la mantiene actualizada solo en cada INSERT/UPDATE: no hay que acordarse de nada, no hace falta un trigger.
-- Buscar, ordenado por relevancia y con el fragmento resaltado
SELECT titulo,
ts_rank(busqueda, consulta) AS relevancia,
ts_headline('spanish', cuerpo, consulta) AS fragmento
FROM articulos, websearch_to_tsquery('spanish', 'bases de datos -mysql') AS consulta
WHERE busqueda @@ consulta
ORDER BY relevancia DESC
LIMIT 20;💡 Usa
websearch_to_tsquery, noto_tsquery. Acepta directamente la sintaxis que la gente ya conoce de Google:"frase exacta",-excluir,opara alternativas. Conto_tsquerytienes que construir la expresión tú y validar la entrada del usuario, porque una consulta mal formada lanza una excepción.
Búsquedas parciales y tolerantes a erratas: pg_trgm
El texto completo busca palabras enteras. Para autocompletado o erratas, otra extensión:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_productos_nombre_trgm ON productos USING GIN (nombre gin_trgm_ops);
-- Ahora SÍ funciona con índice, incluso con % al principio:
SELECT * FROM productos WHERE nombre ILIKE '%tecla%';
-- Y por similitud (tolera erratas):
SELECT nombre, similarity(nombre, 'teclao mecanico') AS s
FROM productos
WHERE nombre % 'teclao mecanico' -- % = "se parece a"
ORDER BY s DESC LIMIT 10;🧠 ¿Cuándo pasar a un buscador dedicado (apéndice T)? Cuando necesites facetas complejas, sinónimos gestionados por negocio, más de unos pocos millones de documentos, o búsqueda distribuida entre muchos tipos de contenido. Por debajo de eso, PostgreSQL te ahorra un servicio entero que operar, sincronizar y monitorizar — y sobre todo, te ahorra el problema de mantener dos fuentes de verdad sincronizadas, que es la parte cara de verdad de tener un buscador aparte.
2.12 · Particionar tablas enormes
Cuando una tabla llega a cientos de millones de filas, el problema no suele ser buscar (para eso están los índices): es borrar datos viejos y que las operaciones de mantenimiento tarden horas.
El particionado divide una tabla grande en varias físicas, transparentes para tus consultas.
-- Tabla particionada por rango de fechas
CREATE TABLE eventos (
id BIGINT GENERATED ALWAYS AS IDENTITY,
usuario_id BIGINT NOT NULL,
tipo VARCHAR(50) NOT NULL,
creado_en TIMESTAMPTZ NOT NULL,
PRIMARY KEY (id, creado_en) -- ⚠️ la clave debe incluir la columna de partición
) PARTITION BY RANGE (creado_en);
-- Una partición por mes
CREATE TABLE eventos_2026_07 PARTITION OF eventos
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE eventos_2026_08 PARTITION OF eventos
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');Las consultas no cambian en absoluto:
SELECT * FROM eventos WHERE creado_en >= '2026-07-15' AND tipo = 'login';
-- PostgreSQL hace "partition pruning": sabe que solo debe mirar eventos_2026_07
-- e ignora por completo las demás particiones. Compruébalo con EXPLAIN.Lo que de verdad te compra el particionado:
-- ❌ Borrar un mes de datos en una tabla normal: horas, bloqueos, y la tabla
-- no devuelve el espacio al sistema operativo (hace falta VACUUM FULL)
DELETE FROM eventos WHERE creado_en < '2026-01-01';
-- ✅ Con particiones: instantáneo, sin bloqueos, y libera el disco de inmediato
DROP TABLE eventos_2026_01;
-- o, para archivarla sin borrarla:
ALTER TABLE eventos DETACH PARTITION eventos_2026_01;| Ganas | Pagas |
|---|---|
| Borrado y archivado instantáneos | Hay que crear las particiones futuras (automatízalo) |
VACUUM y reindexado por partes | La clave primaria debe incluir la columna de partición |
| Consultas que descartan particiones enteras | Las claves foráneas hacia la tabla son más limitadas |
| Índices más pequeños por partición | Consultas que no filtran por esa columna van a todas |
⚠️ Particiona por la columna que usas para descartar datos, casi siempre una fecha. Si particionas por
usuario_idpero tus consultas filtran por fecha, cada consulta tocará todas las particiones y habrás empeorado las cosas. Y automatiza la creación de particiones futuras (conpg_partmano un cron): el día que llegue unINSERTsin partición donde caber, fallará.
🧠 Regla de decisión: el particionado no es una optimización de velocidad de consulta — para eso están los índices. Es una estrategia de gestión del ciclo de vida de los datos. Si tu pregunta es "va lento", empieza por
EXPLAINy un índice. Si tu pregunta es "tengo 400 millones de eventos y necesito conservar solo los últimos 12 meses", ahí sí, particiona.
✅ Ejercicio del capítulo
Sobre el esquema de biblioteca del capítulo anterior:
-- 1. FUNCTION: libros_disponibles() que devuelva cuántos libros no están prestados.
-- 2. TRIGGER: al insertar un préstamo, que marque automáticamente el libro
-- como no disponible (BEFORE/AFTER INSERT).
-- 3. TRIGGER: mantener actualizado_en en la tabla libros.
-- 4. PROCEDURE: devolver_libro(prestamo_id) que marque el préstamo como
-- devuelto Y el libro como disponible, todo en una transacción, con
-- RAISE EXCEPTION si el préstamo no existe.
-- 5. VIEW: prestamos_activos con título, usuario y días transcurridos.
-- 6. AUDITORÍA: tabla + trigger que registre cada préstamo y devolución.Cuando termines esto, entiendes la base de datos mejor que la mayoría de desarrolladores backend. Ahora sí: vamos a construir aplicaciones que la usen.
💡 Pistas de la solución (abre solo si te atascas)
- El trigger del punto 2 debe ser
AFTER INSERTsobreprestamos, noBEFORE: primero se confirma que el préstamo se insertó, luego reaccionas actualizandolibros. - El
PROCEDURE devolver_libronecesitaRAISE EXCEPTIONcuando elprestamo_idno existe — así, si algo llama al procedimiento con un id inválido, falla ruidosamente en vez de no hacer nada en silencio. - Para la auditoría (punto 6), un solo trigger genérico que reaccione a INSERT/UPDATE en
prestamosy escriba enauditoriaes más mantenible que uno por cada operación.
🧠 Autoevaluación
Porque la seguridad no depende de que el programador recuerde poner el WHERE empresa_id = ? en absolutamente todas las consultas — un solo endpoint olvidado es una fuga de datos entre clientes. Con RLS, PostgreSQL aplica el filtro automáticamente sin importar quién ni cómo consulte la tabla.
El trigger reacciona automáticamente a un evento (INSERT/UPDATE/DELETE) sin que nadie lo llame explícitamente — ideal para invariantes que SIEMPRE deben cumplirse (actualizar actualizado_en). El procedimiento se invoca a propósito desde tu código cuando quieres ejecutar una operación concreta con parámetros — ideal para acciones de negocio como "devolver_libro".
GROUP BY colapsa las filas de cada grupo en una sola fila de resumen — pierdes el detalle. Una función de ventana calcula el agregado SOBRE cada fila sin colapsarlas: puedes ver el detalle de cada préstamo Y su posición/ranking dentro del grupo en la misma consulta.
Una VIEW normal es una consulta guardada con nombre: cada vez que la consultas, PostgreSQL ejecuta el SQL subyacente contra los datos actuales — siempre está al día pero no ahorra cómputo. Una tabla materializada SÍ guarda el resultado físicamente y hay que refrescarla explícitamente — gana velocidad de lectura a cambio de poder mostrar datos desactualizados.
Porque una tabla de auditoría es consultable con SQL normal: puedes preguntar "quién devolvió el libro X el mes pasado" con un simple SELECT. Un log de texto plano requiere parsearlo fuera de la base de datos, perdiendo toda la potencia de índices, JOINs y agregaciones que ya tienes.
Úsala cuando la forma de los datos varía genuinamente entre filas (atributos de producto que cambian según la categoría, preferencias de usuario) y no necesitas filtrar/ordenar por esos campos con frecuencia. Si un campo dentro del JSON se usa a menudo en WHERE, JOIN o ORDER BY, debe ser una columna real (o una tabla aparte) con su propio índice — meterlo en JSONB "porque es más flexible" suele ser pereza de modelado, no una decisión técnica.
Porque READ COMMITTED solo garantiza que no lees datos sin confirmar (dirty read) — cada SELECT ve el último estado confirmado en ESE instante. Si ambas transacciones leen stock = 1 antes de que la otra confirme su compra, las dos ven stock disponible y ambas restan uno, dejando el stock en -1. Se soluciona con SELECT ... FOR UPDATE (bloquea la fila) o con SERIALIZABLE (detecta el conflicto y fuerza a una de las dos a reintentar).
Siguiente: 03-php-laravel.md — tu primer framework backend completo.