Con BEGIN, COMMIT y ROLLBACK ya puedes trabajar, pero el TCL tiene más piezas, y algunas resuelven problemas que hasta ahora no sabías nombrar. ¿Qué haces si estás cargando cien pedidos de golpe y el número cuarenta y siete falla: tiras los cuarenta y seis buenos? ¿Cómo sales del estado abortado de 09-01 sin perder el trabajo hecho? ¿Y por qué en PostgreSQL puedes hacer ROLLBACK de un CREATE TABLE y en MySQL no? Esta lección es el repertorio completo: todas las opciones de BEGIN, los SAVEPOINT y sus tres operaciones, cómo fijar el nivel por omisión de la sesión, qué hace realmente el autocommit de psycopg, JDBC o SQLAlchemy —porque en tu aplicación el BEGIN probablemente lo pone el framework sin que lo hayas escrito—, el DDL transaccional, las transacciones preparadas y los patrones de uso desde código. Y todo desemboca en el ejemplo integrador del módulo: confirmar un pedido de TiendaVerde de principio a fin, con su savepoint y su COMMIT.

Contenido

  1. El repertorio del TCL
  2. BEGIN y sus opciones
  3. COMMIT y ROLLBACK
  4. SAVEPOINT: deshacer solo una parte
  5. Un savepoint para salir del estado abortado
  6. Lo que cuestan los savepoints
  7. SET TRANSACTION y el nivel por omisión de la sesión
  8. Implícitas frente a explícitas: el autocommit de los drivers
  9. DDL dentro de transacciones
  10. Transacciones preparadas y two-phase commit
  11. Patrones de uso desde una aplicación
  12. El ejemplo integrador: confirmar un pedido
  13. Errores Comunes y Consejos
  14. Ejercicios
  15. Conclusión

  1. El repertorio del TCL

Sentencia Para qué
BEGIN [ opciones ]; Abre una transacción explícita
COMMIT; Confirma todo lo hecho
ROLLBACK; Deshace todo lo hecho
SAVEPOINT nombre; Marca un punto de retorno dentro de la transacción
ROLLBACK TO SAVEPOINT nombre; Deshace solo lo hecho desde ese punto
RELEASE SAVEPOINT nombre; Descarta el punto de retorno (no deshace nada)
SET TRANSACTION ...; Fija propiedades de la transacción en curso
SET SESSION CHARACTERISTICS AS TRANSACTION ...; Fija las propiedades por omisión de las siguientes
PREPARE TRANSACTION 'id'; + COMMIT/ROLLBACK PREPARED 'id'; Confirmación en dos fases entre servidores (2PC)

Las vas a usar todas menos la última, que existe para un caso muy concreto (apartado 10).

  1. BEGIN y sus opciones

BEGIN [ TRANSACTION | WORK ]
    [ ISOLATION LEVEL { READ COMMITTED | REPEATABLE READ | SERIALIZABLE | READ UNCOMMITTED } ]
    [ READ WRITE | READ ONLY ]
    [ [ NOT ] DEFERRABLE ];

Las opciones se pueden combinar en cualquier orden y separadas por comas o por espacios:

Opción Qué hace Cuándo la usarás
ISOLATION LEVEL ... Fija el nivel de aislamiento de esta transacción Un informe que necesita una foto coherente, o un proceso que no tolera actualizaciones perdidas (09-04)
READ ONLY / READ WRITE Prohíbe o permite escribir READ ONLY en todos tus informes y análisis (09-01); READ WRITE es lo normal
DEFERRABLE Solo con SERIALIZABLE y READ ONLY: la transacción espera al arrancar hasta poder garantizar que nunca abortará por conflicto de serialización Un informe largo en el nivel más estricto, cuando prefieres esperar un poco a tener que reintentarlo entero

El caso típico del último es el informe mensual de TiendaVerde ejecutado sobre la base en producción:

BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;

SELECT pe.estado, COUNT(*) AS pedidos,
       ROUND(SUM(pe.gastos_envio), 2) AS portes
FROM   pedidos AS pe
GROUP  BY pe.estado
ORDER  BY pedidos DESC;

COMMIT;
estado pedidos portes
entregado 14 76.05
pagado 2 9.90
enviado 2 14.85
cancelado 1 4.95
pendiente 1 12.50

Los 118,25 € de portes canónicos, repartidos por estado, leídos de una foto perfectamente coherente del sistema y sin ninguna posibilidad de que el informe aborte a los diez minutos ni de bloquear a nadie mientras corre. Tres palabras extra en el BEGIN.

  1. COMMIT y ROLLBACK

Poco que añadir a 09-01, salvo los sinónimos —COMMIT = END = COMMIT WORK; ROLLBACK = ABORT = ROLLBACK WORK— y un detalle de comportamiento: COMMIT o ROLLBACK fuera de una transacción no son un error, solo un WARNING: there is no transaction in progress.

Parece inofensivo, y es justo lo contrario: es el síntoma clásico de un script cuyo BEGIN no se ejecutó —porque estaba dentro de un if que no entró, o porque una herramienta ya había hecho COMMIT por su cuenta—. Todo lo que creías transaccional se ejecutó en autocommit. Si ves ese WARNING en un log de producción, investígalo.

  1. SAVEPOINT: deshacer solo una parte

Un SAVEPOINT es una marca dentro de la transacción a la que puedes volver sin cerrarla. SAVEPOINT s1; crea la marca (si ya existía una con ese nombre, la nueva la oculta); ROLLBACK TO SAVEPOINT s1; deshace todo lo hecho después de s1 y deja la transacción abierta, con s1 todavía disponible; y RELEASE SAVEPOINT s1; elimina la marca sin deshacer nada, fundiendo lo hecho después de s1 con la transacción exterior.

El caso realista: una carga de pedidos por lotes

TiendaVerde importa cada noche los pedidos del marketplace. Hoy vienen tres, y el segundo trae un artículo que no existe en el catálogo. Objetivo: cargar los dos buenos y descartar solo el malo, todo en una única transacción.

BEGIN;

-- ── Pedido 1 del lote: Pau Llorens (cliente 6) ─────────────────────
SAVEPOINT pedido_lote_1;
INSERT INTO pedidos (cliente_id, empleado_id, fecha_pedido, estado, metodo_pago, gastos_envio)
VALUES (6, NULL, DATE '2026-03-05', 'pagado', 'tarjeta', 4.95);      -- → id 21
INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio_unitario, descuento)
VALUES (21, 1, 2, 12.50, 0.00);                                      -- → id 48
RELEASE SAVEPOINT pedido_lote_1;

-- ── Pedido 2 del lote: Elena Navarro (cliente 11), con un artículo inexistente ──
SAVEPOINT pedido_lote_2;
INSERT INTO pedidos (cliente_id, empleado_id, fecha_pedido, estado, metodo_pago, gastos_envio)
VALUES (11, NULL, DATE '2026-03-05', 'pagado', 'paypal', 4.95);      -- → id 22
INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio_unitario, descuento)
VALUES (22, 99, 1, 15.00, 0.00);

El RELEASE del primero confirma una decisión: "este pedido está bien, ya no necesito poder descartarlo por separado". No lo hace permanente —eso solo lo hace el COMMIT final—, pero lo funde con la transacción exterior. El segundo, en cambio, revienta:

SAVEPOINT
INSERT 0 1
ERROR:  insert or update on table "lineas_pedido" violates foreign key constraint "lineas_pedido_producto_id_fkey"
DETAIL:  Key (producto_id)=(99) is not present in table "productos".

La transacción está abortada (tiendaverde=!>). Sin savepoints, aquí se habría acabado la noche y los 46 pedidos siguientes se habrían perdido. Con savepoint, se rescata — y el prompt vuelve a tiendaverde=*>: la transacción está viva otra vez, el pedido 22 ha desaparecido y el 21 sigue ahí.

ROLLBACK TO SAVEPOINT pedido_lote_2;

-- ── Pedido 3 del lote: Diego Ramos (cliente 12) ────────────────────
SAVEPOINT pedido_lote_3;
INSERT INTO pedidos (cliente_id, empleado_id, fecha_pedido, estado, metodo_pago, gastos_envio)
VALUES (12, NULL, DATE '2026-03-05', 'pagado', 'tarjeta', 0.00);     -- → id 23
INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio_unitario, descuento)
VALUES (23, 14, 3, 3.25, 0.00);                                      -- → id 50
RELEASE SAVEPOINT pedido_lote_3;

COMMIT;

El estado tras cada paso, que es lo que hay que mirar:

Paso pedidos lineas_pedido Comentario
Antes del BEGIN 20 47 Base recién recargada
Tras el pedido 1 del lote 21 48 Cabecera 21, línea 48
Tras el INSERT fallido 22 (¡uno de más!) 48 La cabecera 22 existe dentro de la transacción abortada
Tras ROLLBACK TO pedido_lote_2 21 48 La cabecera 22 desaparece; el pedido 21 sobrevive
Tras el pedido 3 del lote 22 49 Cabecera 23, línea 50
Tras el COMMIT 22 49 Definitivo. Sobreviven los pedidos 21 y 23

Fíjate en el hueco: no hay pedido 22, ni línea 49. Es exactamente lo que anticipó el ejercicio 2 de 09-01: las secuencias no se deshacen, ni con un ROLLBACK ni con un ROLLBACK TO SAVEPOINT. El valor 22 lo consumió la cabecera descartada, y el 49 lo consumió el INSERT de la línea que violó la clave foránea — porque PostgreSQL construye la fila completa, evaluando el nextval del id, antes de comprobar la restricción. Los huecos en los identificadores son normales y no significan que falten datos.

  1. Un savepoint para salir del estado abortado

Acabas de verlo de pasada, pero merece decirse en voz alta, porque resuelve el mensaje más frustrante de 09-01:

ROLLBACK TO SAVEPOINT es la única forma de recuperarse del estado abortado sin perder la transacción. Deshace hasta la marca, limpia el estado de error y devuelve la transacción a activa. Sobre una transacción abortada, ROLLBACK y COMMIT la deshacen entera y la cierran; ROLLBACK TO SAVEPOINT s la reanima.

Y de aquí sale un patrón que verás mucho en código generado: envolver cada operación arriesgada en su propio savepoint, para que un fallo previsible —una clave duplicada, una FK que no está— no tire el trabajo entero. Es literalmente lo que hacen los try/except anidados de un ORM: cada bloque atomic() de Django o begin_nested() de SQLAlchemy dentro de otro no es una transacción nueva, es un SAVEPOINT.

  1. Lo que cuestan los savepoints

No son gratis. Cada uno abre una subtransacción, y PostgreSQL solo cachea 64 por transacción: al pasar de ahí, resolver la visibilidad de cada fila obliga a ir a disco (pg_subtrans) y el rendimiento de todo el servidor puede caer bruscamente. Además, cada intento fallido deja sus filas muertas para el VACUUM (09-02) y quema valores de secuencia, como acabas de ver con el 22 y el 49.

La regla práctica: un savepoint por unidad de negocio que quieras poder descartar —un pedido dentro de un lote— y no uno por sentencia. Si necesitas miles, lo que necesitas en realidad es dividir la carga en varias transacciones y llevar un registro de lo procesado (la idempotencia de 05-03).

  1. SET TRANSACTION y el nivel por omisión de la sesión

Dos sentencias parecidas con alcances muy distintos:

BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY;   -- (a) solo la transacción EN CURSO

SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- (b) las SIGUIENTES

La forma (a) equivale a poner las opciones en el BEGIN, con la misma limitación: una vez que la transacción ha leído o escrito algo, ya no se puede cambiar el nivel. La (b) afecta a la sesión entera y sobrevive a los COMMIT, así que es la forma de configurar una conexión dedicada a informes. Para saber dónde estás, SHOW transaction_isolation; (el de la transacción actual) y SHOW default_transaction_isolation; (el de las siguientes), que en una instalación recién hecha devuelven read committed — el valor por omisión de PostgreSQL y el punto de partida de 09-04. Los mismos ajustes se fijan por usuario o por base de datos con ALTER ROLE ... SET y ALTER DATABASE ... SET, o para todo el servidor en postgresql.conf.

  1. Implícitas frente a explícitas: el autocommit de los drivers

Esto es lo que más problemas causa en el trabajo real, y el motivo es simple: en tu aplicación, el BEGIN casi nunca lo escribes tú.

Entorno Estado inicial Quién abre la transacción Cómo se cierra
psql Autocommit Tú, con BEGIN COMMIT / ROLLBACK
psycopg 2 y 3 Autocommit desactivado El driver, al ejecutar la primera sentencia de la conexión conn.commit() / conn.rollback(), o al cerrar (que hace rollback)
JDBC Autocommit activado Nadie, hasta que llames a setAutoCommit(false) conn.commit() / conn.rollback()
SQLAlchemy (Session) Transacción abierta de forma perezosa La Session, en la primera consulta session.commit() / session.rollback(). begin_nested() crea un SAVEPOINT
Django ORM Autocommit activado El bloque transaction.atomic() Al salir del bloque: COMMIT si no hubo excepción, ROLLBACK si la hubo. Un atomic() anidado es un SAVEPOINT
Go (database/sql), Node (pg) Autocommit Tú, con db.Begin() o client.query('BEGIN') Explícito

Tres consecuencias que hay que grabarse. Con psycopg o SQLAlchemy, tu proceso puede llevar horas con una transacción abierta sin que nadie lo haya pedido: un script que abre conexión, lee una tabla y se pone a procesar en Python veinte minutos está idle in transaction veinte minutos, con todo lo que eso implica (09-01, 09-02); la solución es autocommit = True para las lecturas, o cerrar la transacción antes de calcular. Con JDBC pasa lo contrario: cada executeUpdate() se confirma solo, y una operación de cuatro pasos no es atómica salvo que alguien llame a setAutoCommit(false). Y los bloques anidados no son transacciones anidadas: son savepoints — SQL no tiene transacciones anidadas de verdad, y por eso un atomic() interno puede fallar y deshacerse sin tirar el externo.

Consejo operativo: antes de depurar un bloqueo o un dato que "no se guarda", averigua dónde pone tu framework el BEGIN y dónde el COMMIT. Se comprueba en un minuto activando log_statement = 'all' y leyendo el registro.

  1. DDL dentro de transacciones

Aquí PostgreSQL gana por goleada, y es la promesa que dejó abierta 05-06.

En PostgreSQL, el DDL es transaccional. CREATE TABLE, ALTER TABLE, DROP TABLE, CREATE INDEX, ADD CONSTRAINT… todo se puede deshacer:

BEGIN;
CREATE TABLE promociones_marzo (
    id          INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    producto_id INTEGER NOT NULL REFERENCES productos(id),
    descuento   NUMERIC(4,2) NOT NULL CHECK (descuento > 0 AND descuento <= 1)
);
INSERT INTO promociones_marzo (producto_id, descuento) VALUES (1, 0.10), (15, 0.05);
ROLLBACK;

SELECT COUNT(*) FROM promociones_marzo;   -- ERROR: relation ... does not exist

La tabla nunca existió. Esa es la propiedad que hace seguras las migraciones de esquema en PostgreSQL: una migración de quince pasos que falla en el doce se deshace entera y deja la base exactamente como estaba. Es el fundamento de las herramientas de migración versionada de 05-06.

Las excepciones —lo que no se puede ejecutar dentro de una transacción— son pocas y todas por el mismo motivo, que necesitan actuar fuera del control transaccional: CREATE DATABASE · DROP DATABASE · CREATE TABLESPACE · ALTER SYSTEM · VACUUM · CREATE INDEX CONCURRENTLY · REINDEX CONCURRENTLY · CLUSTER sobre varias tablas.

tiendaverde=*> CREATE INDEX CONCURRENTLY idx_pedidos_estado ON pedidos (estado);
ERROR:  CREATE INDEX CONCURRENTLY cannot run inside a transaction block

Nota de dialecto — la divergencia más cara del módulo. MySQL no tiene DDL transaccional. Cualquier sentencia de esquema provoca un commit implícito: confirma silenciosamente todo lo pendiente y no se puede deshacer. La lista conviene conocerla: CREATE/ALTER/DROP TABLE · TRUNCATE TABLE · RENAME TABLE · CREATE/DROP INDEX · CREATE/ALTER/DROP DATABASE · CREATE/ALTER/DROP EVENT, FUNCTION, PROCEDURE, TRIGGER, VIEW · CREATE/ALTER/DROP USER · GRANT / REVOKE · LOCK TABLES / UNLOCK TABLES · START TRANSACTION · SET autocommit = 1.

Consecuencias prácticas: una migración que falla a la mitad deja el esquema a medias y hay que escribir el deshacer a mano; y un ALTER TABLE colado dentro de un bloque transaccional confirma sin avisar todo lo que ese bloque llevara escrito. MySQL 8 introdujo el DDL atómico, que garantiza que una sentencia de esquema no queda a medias tras una caída — pero eso no es lo mismo que poder deshacerla con un ROLLBACK. Oracle se comporta igual; SQL Server y SQLite sí tienen DDL transaccional, como PostgreSQL.

  1. Transacciones preparadas y two-phase commit

Cuando una operación tiene que ser atómica entre dos bases de datos distintas, un COMMIT normal no sirve: cada servidor confirmaría por su cuenta y uno podría fallar. La respuesta clásica es el commit en dos fases:

BEGIN;
UPDATE productos SET stock = stock - 1 WHERE id = 15;
PREPARE TRANSACTION 'tv_pedido_21';   -- fase 1: "estoy listo, pero no confirmo"
-- ... el coordinador pregunta lo mismo al otro servidor ...
COMMIT PREPARED 'tv_pedido_21';       -- fase 2: confirmar (o ROLLBACK PREPARED)

La transacción preparada sobrevive a la desconexión y al reinicio del servidor: queda en disco esperando su resolución, y se consulta en pg_prepared_xacts.

⚠️ Casi nunca deberías usarlo a mano, y en PostgreSQL viene desactivado (max_prepared_transactions = 0). El motivo es que una transacción preparada y nunca resuelta es lo peor que le puede pasar a una base de datos: mantiene sus bloqueos para siempre, impide que VACUUM limpie nada e ignora todos los tiempos de espera, porque no está asociada a ninguna sesión que se pueda matar. Solo tiene sentido con un gestor de transacciones distribuidas que garantice la resolución (JTA, XA, postgres_fdw). Si crees necesitarlo, casi siempre la respuesta correcta es otra arquitectura: un patrón outbox, una cola de mensajes o una saga con compensaciones.

  1. Patrones de uso desde una aplicación

El bloque canónico, en pseudocódigo válido para cualquier lenguaje:

conexión.begin()
try:
    ... todas las sentencias de la unidad de trabajo ...
    conexión.commit()
except cualquier_error:
    conexión.rollback()          # ← IMPRESCINDIBLE, incluso si vas a relanzar el error
    relanzar / registrar
finally:
    conexión.close()             # cerrar sin commit equivale a rollback

Cuatro reglas que van con él:

  1. El rollback() no es opcional. Sin él, la conexión vuelve al pool en estado abortado y la siguiente petición que la reciba fallará con 25P02 sin haber hecho nada malo. Es uno de los errores más desconcertantes de depurar.
  2. Nada de esperas ni de servicios externos dentro. Ni la pasarela de pago, ni el correo de confirmación, ni la subida de un fichero. Ya lo justificó 09-02: ACID acaba en el borde de la base de datos. El patrón correcto es cobrar fuera y registrar el resultado dentro, o escribir la intención en una tabla outbox que consume otro proceso.
  3. Un reintento debe ser idempotente. Si la transacción aborta por conflicto de serialización (09-04) o por interbloqueo (09-05), se reintenta entera — y eso solo es seguro con la propiedad de 05-03: valores absolutos y WHERE acotados, o una clave única de negocio (el identificador del carrito) que haga imposible insertar dos veces el mismo pedido.
  4. Prepara los datos fuera y abre la transacción lo más tarde posible. Validar el carrito y calcular importes no necesita transacción abierta. Lo que va dentro es solo la escritura.

Nota de alcance: en un sistema real esta lógica suele vivir en un procedimiento almacenado invocado con una sola llamada, para que la transacción no dependa de la latencia de red. Los procedimientos son 10-04 y los triggers que los acompañan, 10-05. Aquí seguimos con SQL plano.

  1. El ejemplo integrador: confirmar un pedido

Todo junto. Pau Llorens (cliente 6) compra por web dos aceites de oliva y un té matcha, paga con tarjeta y le corresponden 4,95 € de portes.

BEGIN;

-- ── Paso 1: la cabecera, todavía sin pagar ─────────────────────────
INSERT INTO pedidos (cliente_id, empleado_id, fecha_pedido, estado, metodo_pago, gastos_envio)
VALUES (6, NULL, DATE '2026-03-05', 'pendiente', 'tarjeta', 4.95)
RETURNING id;                                                        -- → 21

-- ── Paso 2: las líneas, con el precio del momento de la venta ──────
INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio_unitario, descuento) VALUES
(21,  1, 2, 12.50, 0.00),
(21, 15, 1, 22.00, 0.00);

-- ── Paso 3: descontar el stock, con red de seguridad ───────────────
SAVEPOINT antes_de_stock;
UPDATE productos SET stock = stock - 2 WHERE id =  1 AND stock >= 2;   -- UPDATE 1
UPDATE productos SET stock = stock - 1 WHERE id = 15 AND stock >= 1;   -- UPDATE 1

El AND stock >= N es el patrón de 09-02: si no hubiera existencias, el UPDATE devolvería UPDATE 0 sin abortar la transacción, y la aplicación podría decidir qué hacer —ROLLBACK TO SAVEPOINT antes_de_stock y dejar el pedido en pendiente como reserva, o ROLLBACK entero y avisar al cliente— en lugar de estrellarse contra el CHECK.

-- Verificación antes de confirmar: devuelve stock 118 (aceite) y 39 (matcha)
SELECT id, nombre, stock FROM productos WHERE id IN (1, 15) ORDER BY id;

RELEASE SAVEPOINT antes_de_stock;

-- ── Paso 4: marcar como pagado ─────────────────────────────────────
UPDATE pedidos SET estado = 'pagado' WHERE id = 21;

COMMIT;

Y la comprobación final, que es la factura del cliente:

SELECT pe.id, pe.estado,
       ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2)                    AS importe,
       pe.gastos_envio,
       ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) + pe.gastos_envio  AS total
FROM   pedidos AS pe JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
WHERE  pe.id = 21
GROUP  BY pe.id, pe.estado, pe.gastos_envio;
id estado importe gastos_envio total
21 pagado 47.00 4.95 51.95

Cuatro operaciones sobre tres tablas, una sola unidad de trabajo. El desastre del apartado 3 de 09-01 —pedido sin cobrar, líneas huérfanas, inventario evaporado— es ahora imposible: o hay pedido pagado con su stock descontado, o no hay absolutamente nada.

Lo único que este pedido todavía no resuelve es qué pasa si otro cliente compra el último matcha en el mismo instante. Los dos UPDATE del paso 3 podrían leer el mismo stock y descontar cada uno por su cuenta. Eso es la actualización perdida, y es el asunto de las dos lecciones siguientes.

Errores Comunes y Consejos

  • Cambiar el nivel de aislamiento a mitad de transacción. SET TRANSACTION ISOLATION LEVEL debe ir antes de la primera consulta; después, PostgreSQL lo rechaza.
  • Confundir RELEASE SAVEPOINT con ROLLBACK TO SAVEPOINT. RELEASE no deshace nada: solo descarta la marca. Quien quiera anular el trabajo necesita ROLLBACK TO.
  • Poner un savepoint por sentencia. Al pasar de 64 subtransacciones, la resolución de visibilidad se va a disco y el rendimiento del servidor entero puede desplomarse.
  • Ignorar el WARNING: there is no transaction in progress. Significa que tu BEGIN no se ejecutó y que todo corrió en autocommit.
  • Olvidar el rollback() en el except. La conexión vuelve al pool abortada y revienta la petición de otro usuario con un 25P02 incomprensible. Y no supongas que el ORM no abre transacciones: psycopg y SQLAlchemy abren una en la primera consulta; JDBC no abre ninguna.
  • Escribir una migración para MySQL como si el DDL se pudiera deshacer. No se puede: cada ALTER TABLE confirma implícitamente. Escribe siempre el down a mano.
  • Meter el cobro con tarjeta dentro de la transacción. El ROLLBACK deshará tu pedido, no el cargo. Y PREPARE TRANSACTION sin un gestor que garantice resolverla deja bloqueos e impide el VACUUM indefinidamente.
  • Consejo: un savepoint por unidad de negocio descartable, no por sentencia; y BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE para los informes largos sobre producción.
  • Consejo: activa log_statement = 'all' un rato en desarrollo y lee dónde pone tu framework los BEGIN y los COMMIT. Es revelador.

Ejercicios

Trabaja sobre la base recién recargada.

Ejercicio 1

Reproduce la carga por lotes del apartado 4 con cuatro pedidos en lugar de tres, en una sola transacción y con un savepoint por pedido: (1) cliente 6, 2 × producto 1 a 12,50 € — correcto; (2) cliente 11, 1 × producto 99 a 15,00 € — falla por FK inexistente; (3) cliente 12, 3 × producto 14 a 3,25 € — correcto; (4) cliente 13, 1 × producto 13 a 13,75 € y descontar su stock — falla por CHECK (stock >= 0).

  1. Escribe el bloque completo, descartando solo los pedidos 2 y 4 del lote.
  2. Indica qué id recibe cada cabecera y por qué hay huecos.
  3. ¿Cuántas filas tienen pedidos y lineas_pedido tras el COMMIT?
  4. Reescribe el pedido 4 para que no falle, usando el patrón de 09-02, y explica qué cambia en el flujo de la aplicación.

Ejercicio 2

Un compañero te enseña este código Python con psycopg y te dice que "a veces guarda dos veces el mismo pedido y a veces la conexión se queda tonta":

conn = pool.getconn()
cur = conn.cursor()
cur.execute("INSERT INTO pedidos (cliente_id, empleado_id, fecha_pedido, estado, "
            "metodo_pago, gastos_envio) VALUES (6, NULL, %s, 'pendiente', 'tarjeta', 4.95) "
            "RETURNING id", (fecha,))
pedido_id = cur.fetchone()[0]
cobro = pasarela.cobrar(tarjeta, importe)          # llamada HTTP, 2-8 segundos
cur.execute("UPDATE pedidos SET estado = 'pagado' WHERE id = %s", (pedido_id,))
conn.commit()
pool.putconn(conn)
  1. Localiza cuatro problemas distintos.
  2. ¿Dónde está el BEGIN de esta transacción, sabiendo que nadie lo ha escrito?
  3. ¿Qué ve otra sesión que consulte pedidos mientras la pasarela está respondiendo?
  4. Reescríbelo en pseudocódigo corrigiéndolo todo, e indica qué haría falta para que el reintento fuera idempotente.

Ejercicio 3

Sobre DDL transaccional: (1) dentro de BEGINROLLBACK, ejecuta ALTER TABLE productos ADD COLUMN peso_kg NUMERIC(6,3); y un UPDATE que la rellene; comprueba después que la columna no existe. (2) intenta CREATE INDEX CONCURRENTLY idx_prueba ON pedidos (estado); dentro de un BEGIN y explica por qué es una excepción. (3) di qué cambia en la estrategia de despliegue si la misma migración es para MySQL, tiene cinco pasos y falla en el tercero.

Soluciones

Solución 1

1. La estructura es exactamente la del apartado 4, con cuatro bloques SAVEPOINT lote_N … (RELEASE si todo fue bien, ROLLBACK TO si falló) y un COMMIT al final. Los bloques 2 y 4 acaban en ROLLBACK TO SAVEPOINT: el 2 tras el error de clave foránea del producto 99, y el 4 tras el CHECK que impide dejar el stock del producto 13 en −1.

2. Las cabeceras reciben 21, 22, 23 y 24 en orden, pero solo sobreviven la 21 y la 23. Los huecos (22 y 24) son valores de secuencia consumidos por filas que después se descartaron, y las secuencias no se deshacen. Lo mismo ocurre con lineas_pedido: sobreviven la 48 y la 50, y se pierden la 49 y la 51.

3. pedidos tiene 22 filas (20 + 2) y lineas_pedido tiene 49 (47 + 2).

4. La versión que no falla usa el UPDATE condicional:

UPDATE productos SET stock = stock - 1 WHERE id = 13 AND stock >= 1;   -- UPDATE 0

Cambia todo el flujo: en lugar de un error que aborta la transacción y obliga al ROLLBACK TO, se obtiene un UPDATE 0 que la aplicación interpreta como "agotado". Puede entonces decidir en caliente: descartar solo esa línea, dejar el pedido en pendiente como reserva, o proponer un producto alternativo. Un caso de negocio previsible no debería llegar nunca al motor como excepción.

Solución 2

1. Los cuatro problemas:

Problema Consecuencia
Una llamada HTTP de 2 a 8 s dentro de la transacción Queda idle in transaction segundos enteros por pedido, reteniendo bloqueos e impidiendo el VACUUM (09-02)
No hay try / except con rollback() Si la pasarela lanza una excepción, la conexión vuelve al pool abortada y la siguiente petición falla con 25P02. Ahí está lo de "la conexión se queda tonta"
El cobro no es transaccional Si el UPDATE o el commit() fallan tras cobrar, el cliente ha pagado y no hay pedido pagado. ROLLBACK deshace la fila, no el cargo
No hay clave de idempotencia Al reintentar tras un fallo de red se inserta un pedido nuevo: el duplicado que ve tu compañero

2. El BEGIN lo pone psycopg, automáticamente, al ejecutar la primera sentencia sobre la conexión — es decir, en el INSERT. Por eso la transacción ya está abierta cuando se llama a la pasarela, aunque en el código no aparezca la palabra. 3. Otra sesión ve 20 pedidos: la cabecera nueva no está confirmada y para ella no existe (09-01). Durante los ocho segundos de la pasarela, ese pedido está en un limbo visible solo para su propia sesión.

4. La versión corregida:

importe, lineas = preparar_carrito(...)                                      # sin transacción
cobro = pasarela.cobrar(tarjeta, importe, clave_idempotencia = carrito_id)   # ← FUERA

conexión.begin()
try:
    pedido_id = insertar_pedido(carrito_id, ...)   # UNIQUE sobre carrito_id
    insertar_lineas(pedido_id, lineas)
    descontar_stock(lineas)                        # con AND stock >= cantidad
    marcar_pagado(pedido_id, cobro.referencia)
    conexión.commit()
except:
    conexión.rollback()                            # ← imprescindible
    raise
finally:
    conexión.close()

Para que el reintento sea idempotente hacen falta dos cosas: una clave de idempotencia en la pasarela (el identificador del carrito), para que un segundo cobro con la misma clave no cargue dos veces; y una restricción UNIQUE sobre esa misma clave en pedidos, para que el segundo INSERT choque en lugar de duplicar — o directamente ON CONFLICT DO NOTHING (05-05). Sin identidad de negocio no hay idempotencia posible.

Solución 3

1. Tras el ROLLBACK, la columna no existe: ERROR: column "peso_kg" does not exist. ALTER TABLE es transaccional en PostgreSQL, así que el ADD COLUMN y el UPDATE posterior se deshacen juntos.

2. ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block. Esa sentencia necesita varias transacciones internas: recorre la tabla en dos pasadas y espera entre ellas a que terminen las transacciones en curso, para no bloquear las escrituras. Nada de eso cabe dentro de una única transacción del usuario. El porqué de que exista esa variante se cierra en 09-05.

3. En MySQL, ALTER TABLE productos ADD COLUMN peso_kg DECIMAL(6,3); provoca un commit implícito y no se puede deshacer, así que la estrategia cambia por completo. En PostgreSQL todo el fichero va en una transacción: falla el tercer paso, se deshacen los tres y el esquema queda intacto; se corrige y se relanza. En MySQL, los dos primeros pasos ya están aplicados y confirmados y el tercero puede haber quedado a medias: hay que escribir a mano un guion de reversión (down) para cada paso, aplicarlos de uno en uno verificando entre ellos, y diseñar cada uno idempotente y compatible hacia atrás — el patrón expand/contract de 05-06 deja de ser una buena práctica y pasa a ser obligatorio.

Conclusión

Ya tienes el panel de mandos completo:

  • El repertorio del TCL completo, y las opciones de BEGINISOLATION LEVEL, READ ONLY y DEFERRABLE— con la combinación de oro para un informe sobre producción: SERIALIZABLE READ ONLY DEFERRABLE.
  • Los SAVEPOINT: descartar un pedido de un lote sin perder los anteriores, y —lo más útil de todo— la única forma de salir del estado abortado de 09-01 sin cerrar la transacción. Con su precio: subtransacciones, filas muertas y valores de secuencia perdidos. Uno por unidad de negocio, no uno por sentencia.
  • Que las secuencias no se deshacen ni con ROLLBACK ni con ROLLBACK TO SAVEPOINT, y que por eso los id tienen huecos: en el lote sobrevivieron el 21 y el 23, y se perdieron el 22 y el 49.
  • El autocommit de los drivers, y las tres sorpresas: psycopg y SQLAlchemy abren transacción sola en la primera consulta, JDBC no abre ninguna, y los bloques anidados de los ORM son savepoints, no transacciones anidadas.
  • El DDL transaccional: en PostgreSQL se puede hacer ROLLBACK de un CREATE TABLE —y por eso sus migraciones son seguras—, con siete excepciones encabezadas por CREATE INDEX CONCURRENTLY. En MySQL no: cada sentencia de esquema confirma implícitamente lo que hubiera pendiente. Es lo que anunció 05-06. Y las transacciones preparadas, que casi nunca deberías tocar a mano.
  • Los patrones de aplicación: try / except / rollback() obligatorio, reintentos idempotentes con clave de negocio, y ninguna llamada externa dentro de la transacción.
  • Y el ejemplo integrador: el pedido 21 confirmado de principio a fin, 47,00 € + 4,95 € = 51,95 €, con los stocks en 118 y 39, y un savepoint antes del descuento.

Pero ese pedido tiene todavía un punto ciego, y lo hemos dejado a la vista a propósito: ¿qué pasa si otro cliente compra el último té matcha en el mismo instante? Las dos sesiones leen 40, las dos restan 1, y las dos escriben 39 — se han vendido dos unidades y solo se ha descontado una. En Niveles de aislamiento y anomalías de concurrencia verás esa actualización perdida demostrada paso a paso en dos sesiones, junto con la lectura sucia, la lectura no repetible y la lectura fantasma; los cuatro niveles del estándar y qué anomalía permite cada uno; lo que casi ningún curso cuenta —que PostgreSQL no implementa READ UNCOMMITTED y que su REPEATABLE READ ya impide los fantasmas, a diferencia del estándar—; el error 40001 y por qué, si usas los niveles altos, tu aplicación tiene que saber reintentar.

Curso de SQL

Módulo 1: Introducción a SQL

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados