Aprende las transacciones de Postgres para flujos multipaso: cómo agrupar actualizaciones de forma segura, evitar escrituras parciales, manejar reintentos y mantener los datos consistentes.

La mayoría de las características reales no son una única actualización de base de datos. Son una cadena corta: insertar una fila, actualizar un saldo, marcar un estado, escribir un registro de auditoría, quizá encolar un trabajo. Una escritura parcial ocurre cuando solo algunos de esos pasos llegan a la base de datos.
Esto aparece cuando algo interrumpe la cadena: un error del servidor, un timeout entre tu app y Postgres, un crash después del paso 2, o un reintento que vuelve a ejecutar el paso 1. Cada sentencia está bien por sí sola. El flujo se rompe cuando se detiene a mitad.
Suele ser fácil de detectar:
Un ejemplo concreto: una subida de plan actualiza el plan del cliente, añade un registro de pago y aumenta créditos disponibles. Si la app se cae después de guardar el pago pero antes de añadir los créditos, soporte ve "pagado" en una tabla y "sin créditos" en otra. Si el cliente reintenta, incluso puedes registrar el pago dos veces.
El objetivo es simple: trata el flujo como un único interruptor. O todos los pasos tienen éxito, o ninguno, para que nunca almacenes trabajo a medio hacer.
Una transacción es la forma de la base de datos de decir: trata estos pasos como una unidad de trabajo. O todos los cambios ocurren, o ninguno. Esto importa siempre que tu flujo necesite más de una actualización, como crear una fila, actualizar un saldo y escribir un registro de auditoría.
Piensa en mover dinero entre dos cuentas. Debes restar de la Cuenta A y sumar a la Cuenta B. Si la app se cae después del primer paso, no quieres que el sistema "recuerde" solo la resta.
Cuando haces un commit, le indicas a Postgres: conserva todo lo que hice en esta transacción. Todos los cambios se vuelven permanentes y visibles para otras sesiones.
Cuando haces un rollback, le indicas a Postgres: olvida todo lo que hice en esta transacción. Postgres deshace los cambios como si la transacción nunca hubiera ocurrido.
Dentro de una transacción, Postgres garantiza que no expondrás resultados a medio hacer a otras sesiones antes de hacer commit. Si algo falla y haces rollback, la base de datos limpia las escrituras de esa transacción.
Una transacción no arregla un mal diseño del flujo. Si restas la cantidad equivocada, usas el ID de usuario incorrecto o te saltas una comprobación necesaria, Postgres hará commit del resultado erróneo. Las transacciones tampoco previenen automáticamente todos los conflictos a nivel de negocio (como vender de más) a menos que las combines con las restricciones, locks o el nivel de aislamiento adecuados.
Siempre que actualices más de una tabla (o más de una fila) para completar una sola acción del mundo real, tienes un candidato para una transacción. La idea sigue igual: o todo se hace, o nada.
Un flujo de pedido es el caso clásico. Puedes crear una fila de orden, reservar inventario, cobrar un pago y luego marcar la orden como pagada. Si el pago tiene éxito pero la actualización de estado falla, tienes dinero capturado con una orden que todavía parece impaga. Si la fila de la orden se crea pero no se reserva stock, puedes vender artículos que en realidad no tienes.
El onboarding de usuarios se rompe silenciosamente de la misma manera. Crear el usuario, insertar un registro de perfil, asignar roles y registrar que debe enviarse un email de bienvenida es una acción lógica. Sin agrupar, puedes acabar con un usuario que puede iniciar sesión pero no tiene permisos, o con un perfil que existe sin usuario.
Las acciones de back-office suelen necesitar comportamiento estricto de "registro + cambio de estado". Aprobar una solicitud, escribir una entrada de auditoría y actualizar un saldo deberían tener éxito juntos. Si el saldo cambia pero falta el log de auditoría, pierdes evidencia de quién cambió qué y por qué.
Los jobs en background también se benefician, especialmente cuando procesas un ítem de trabajo con varios pasos: reclamar el ítem para que dos workers no lo procesen, aplicar la actualización de negocio, registrar un resultado para reporting y reintentos, y luego marcar el ítem como hecho (o fallido con razón). Si esos pasos se separan, los reintentos y la concurrencia generan un lío.
Las características multipaso fallan cuando las tratas como un montón de actualizaciones independientes. Antes de abrir un cliente de base de datos, escribe el flujo como una historia corta con un final claro: ¿qué cuenta exactamente como "hecho" para el usuario?
Empieza listando los pasos en lenguaje simple y luego define una única condición de éxito. Por ejemplo: "La orden está creada, el inventario reservado y el usuario ve un número de confirmación de pedido." Cualquier cosa por debajo de eso no es éxito, aunque algunas tablas se hayan actualizado.
A continuación, dibuja una línea clara entre el trabajo de base de datos y el trabajo externo. Los pasos de base de datos son los que puedes proteger con transacciones. Llamadas externas como pagos con tarjeta, enviar emails o llamar a APIs de terceros pueden fallar de forma lenta e impredecible y normalmente no puedes revertirlas.
Un enfoque de planificación simple: separa los pasos en (1) deben ser todo-o-nada, (2) pueden ocurrir después del commit.
Dentro de la transacción, mantén solo los pasos que deben permanecer consistentes juntos:
Mueve los efectos secundarios fuera. Por ejemplo, haz commit de la orden primero y luego envía el email de confirmación basándote en un registro outbox.
Para cada paso, escribe qué debe ocurrir si el siguiente paso falla. "Rollback" puede significar un rollback de base de datos o una acción compensatoria.
Ejemplo: si el pago tiene éxito pero la reserva de inventario falla, decide de antemano si devuelves el dinero inmediatamente o marcas la orden como "pago capturado, a la espera de stock" y lo manejas de forma asíncrona.
Una transacción le dice a Postgres: trata estos pasos como una unidad. O todos ocurren, o ninguno. Esa es la forma más simple de evitar escrituras parciales.
Usa una conexión de base de datos (una sesión) de principio a fin. Si repartes pasos en diferentes conexiones, Postgres no puede garantizar el resultado todo-o-nada.
La secuencia es sencilla: begin, ejecuta las lecturas y escrituras necesarias, commit si todo va bien, de lo contrario rollback y devuelve un error claro.
Aquí hay un ejemplo mínimo en SQL:
BEGIN;
-- reads that inform your decision
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- writes that must stay together
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
INSERT INTO ledger(account_id, amount, note) VALUES (42, -50, 'Purchase');
COMMIT;
-- on error (in code), run:
-- ROLLBACK;
Las transacciones mantienen locks mientras se ejecutan. Cuanto más tiempo las mantengas abiertas, más bloqueas otro trabajo y más probable es que encuentres timeouts o deadlocks. Haz lo esencial dentro de la transacción y mueve tareas lentas (enviar emails, llamar a proveedores de pago, generar PDFs) fuera.
Cuando algo falle, registra suficiente contexto para reproducir el problema sin filtrar datos sensibles: nombre del flujo, order_id o user_id, parámetros clave (monto, moneda) y el código de error de Postgres. Evita registrar payloads completos, datos de tarjeta o detalles personales.
Concurrencia son solo dos cosas ocurriendo al mismo tiempo. Imagina a dos clientes intentando comprar la última entrada de un concierto. Ambas pantallas muestran "1 restante", ambos hacen clic en Pagar, y ahora tu app tiene que decidir quién la consigue.
Sin protección, ambas peticiones pueden leer el mismo valor antiguo y ambas escribir una actualización. Ahí es donde terminas con inventario negativo, reservas duplicadas o un pago sin orden.
Los locks por fila son la barrera más simple. Bloqueas la fila que vas a cambiar, haces tus comprobaciones y luego la actualizas. Otras transacciones que toquen la misma fila deben esperar hasta que hagas commit o rollback, lo que evita doble actualización.
Un patrón común: iniciar una transacción, seleccionar la fila de inventario con FOR UPDATE, verificar que hay stock, decrementarlo y luego insertar la orden. Eso "mantiene la puerta cerrada" mientras terminas los pasos críticos.
Los niveles de aislamiento controlan cuánto raro solapamiento permites entre transacciones concurrentes. El equilibrio suele ser seguridad vs velocidad:
Mantén los locks cortos. Si una transacción queda abierta mientras llamas a una API externa o esperas acción del usuario, crearás esperas largas y timeouts. Prefiere una vía de fallo clara: establece un lock timeout, captura el error y devuelve "por favor reintente" en lugar de dejar peticiones colgadas.
Si necesitas trabajo fuera de la base de datos (como cobrar una tarjeta), divide el flujo: reserva rápido, haz commit, luego haz la parte lenta y finaliza con otra transacción corta.
Los reintentos son normales en apps con Postgres. Una petición puede fallar incluso cuando tu código es correcto: deadlocks, timeouts de sentencia, cortes breves de red o un error de serialización con niveles de aislamiento altos. Si vuelves a ejecutar el mismo handler, corres el riesgo de crear una segunda orden, cobrar dos veces o insertar filas de "evento" duplicadas.
La solución es idempotencia: la operación debe ser segura de ejecutar dos veces con la misma entrada. La base de datos debe ser capaz de reconocer "esta es la misma petición" y responder de forma consistente.
Un patrón práctico es adjuntar una clave de idempotencia (a menudo un request_id generado por el cliente) a cada flujo multipaso y guardarla en el registro principal, luego añadir una restricción única sobre esa clave.
Por ejemplo: en checkout, genera request_id cuando el usuario hace clic en Pagar y luego inserta la orden con ese request_id. Si hay un reintento, el segundo intento choca con la restricción única y devuelves la orden existente en vez de crear una nueva.
Lo que suele importar:
Mantén el bucle de reintento fuera de la transacción. Cada intento debe empezar una transacción nueva y volver a ejecutar la unidad de trabajo desde el principio. Reintentar dentro de una transacción fallida no ayuda porque Postgres la marca como abortada.
Un pequeño ejemplo: tu app intenta crear una orden y reservar inventario, pero hace timeout justo después del COMMIT. El cliente reintenta. Con una clave de idempotencia, la segunda petición devuelve la orden ya creada y evita una segunda reserva en lugar de duplicar el trabajo.
Las transacciones mantienen unido un flujo multipaso, pero no hacen que los datos sean correctos por sí solos. Una forma potente de evitar estados erróneos es hacer que los estados "incorrectos" sean difíciles o imposibles en la base de datos, incluso si hay un bug en el código.
Empieza con barandas básicas de seguridad. Las claves foráneas aseguran que las referencias sean reales (una línea de pedido no puede apuntar a una orden inexistente). NOT NULL evita filas a medio llenar. CHECK constraints atrapan valores que no tienen sentido (por ejemplo, quantity > 0, total_cents >= 0). Estas reglas se ejecutan en cada escritura, sin importar qué servicio o script toque la base de datos.
Para flujos más largos, modela los cambios de estado explícitamente. En lugar de muchos flags booleanos, usa una columna de estado única (pending, paid, shipped, canceled) y solo permite transiciones válidas. Puedes imponer esto con constraints o triggers para que la base de datos rechace saltos ilegales como shipped -> pending.
La unicidad es otra forma de corrección. Añade restricciones únicas donde los duplicados romperían tu flujo: order_number, invoice_number o una idempotency_key usada para reintentos. Entonces, si tu app reintenta la misma petición, Postgres bloquea la segunda inserción y puedes devolver "ya procesado" en lugar de crear una segunda orden.
Cuando necesitas trazabilidad, almacénala explícitamente. Una tabla de auditoría (o historial) que registre quién cambió qué y cuándo convierte las "actualizaciones misteriosas" en hechos que puedes consultar durante incidentes.
La mayoría de las escrituras parciales no son por "mal SQL." Vienen de decisiones de flujo que hacen fácil cometer solo la mitad de la historia.
accounts luego orders, pero otra actualiza orders luego accounts, aumentas la probabilidad de deadlocks bajo carga.Un ejemplo concreto: en checkout reservas inventario, creas una orden y luego cobras una tarjeta. Si cobras la tarjeta dentro de la misma transacción, puedes mantener un lock de inventario mientras esperas la red. Si el cargo tiene éxito pero tu transacción luego hace rollback, cobraste al cliente sin una orden.
Un patrón más seguro es: céntrate en el estado de la base de datos (reservar inventario, crear orden, registrar pago pendiente), commit, luego llama a la API externa y escribe el resultado en una nueva transacción corta. Muchos equipos implementan esto con un estado pending y un job en background.
Cuando un flujo tiene múltiples pasos (insertar, actualizar, cobrar, enviar), el objetivo es simple: o todo queda registrado, o nada.
Mantén todas las escrituras de base de datos requeridas dentro de una transacción. Si un paso falla, haz rollback y deja los datos exactamente como estaban.
Haz la condición de éxito explícita. Por ejemplo: "La orden está creada, el stock reservado y el estado de pago registrado." Todo lo demás es una vía de fallo que debe abortar la transacción.
BEGIN ... COMMIT.ROLLBACK y el llamador recibe un resultado de fallo claro.Asume que la misma petición puede reintentarse. La base de datos debe ayudarte a imponer reglas de "solo una vez".
Haz el trabajo mínimo dentro de la transacción y evita esperar llamadas de red mientras mantienes locks.
Si no puedes ver dónde se rompe, seguirás adivinando.
Un checkout tiene varios pasos que deberían moverse juntos: crear la orden, reservar inventario, registrar el intento de pago y luego marcar el estado de la orden.
Imagina que un usuario hace clic en Comprar 1 artículo.
Dentro de una transacción, haz solo cambios en la base de datos:
orders con estado pending_payment.inventory.available o crea una fila reservations).payment_intents con una idempotency_key proporcionada por el cliente (única).outbox como "order_created".Si alguna sentencia falla (sin stock, error de constraint, crash), Postgres hace rollback de toda la transacción. No acabas con una orden sin reserva, ni con una reserva sin orden.
El proveedor de pago está fuera de tu base de datos, así que trátalo como un paso separado.
Si la llamada al proveedor falla antes de hacer commit, aborta la transacción y no se escribe nada. Si la llamada falla después de hacer commit, ejecuta una nueva transacción que marque el intento de pago como fallido, libere la reserva y ponga la orden en estado cancelado.
Haz que el cliente envíe una idempotency_key por intento de checkout. Hazla cumplir con un índice único en payment_intents(idempotency_key) (o en orders si lo prefieres). En un reintento, tu código busca las filas existentes y continúa en lugar de insertar una nueva orden.
No envíes emails dentro de la transacción. Escribe un registro outbox en la misma transacción y deja que un worker en background envíe el email tras el commit. Así nunca envías un email por una orden que fue revertida.
Elige un flujo que toque más de una tabla: signup + enqueue de welcome email, checkout + inventario, factura + entrada en ledger, o crear proyecto + ajustes por defecto.
Escribe los pasos primero y luego las reglas que siempre deben cumplirse (tus invariantes). Ejemplo: "Una orden o está totalmente pagada y reservada, o no está pagada y no está reservada. Nunca medio reservada." Convierte esas reglas en una unidad todo-o-nada.
Un plan simple:
Luego prueba los casos feos a propósito. Simula un crash después del paso 2, un timeout justo antes del commit y un doble envío desde la UI. El objetivo son resultados aburridos: no filas huérfanas, no cargos dobles, nada pendiente para siempre.
Si estás prototipando rápido, ayuda dibujar el flujo en una herramienta de planificación antes de generar handlers y esquema. Por ejemplo, Koder.ai tiene un Planning Mode y soporta snapshots y rollback, lo que puede ser útil mientras iteras sobre límites de transacción y restricciones.
Haz esto para un flujo esta semana. El segundo irá mucho más rápido.