sequenceDiagram
participant S as Service
participant C as Connection (1 del pool)
participant DB as PostgreSQL
S->>C: BEGIN
S->>C: INSERT task
S->>C: INSERT event
S->>C: UPDATE counter
alt todo bien
S->>C: COMMIT
C->>DB: las 3 se confirman JUNTAS
else algo falla
S->>C: ROLLBACK
C->>DB: las 3 desaparecen — nada pasó
end
22 13. Necesitamos que dos cosas pasen juntas o ninguna
Si falla el paso 2, la BD queda mentirosa
“Crear tarea + registrar evento + actualizar contador”: tres writes que deben pasar juntos o ninguno. Si el paso 2 falla, la BD queda inconsistente — la tarea existe pero el evento no, el contador dice 5 y hay 4. La herramienta es la transacción: BEGIN abre, COMMIT confirma todo, ROLLBACK deshace todo. La BD promete: nunca un estado a medias.
22.1 El problema
Cada INSERT/UPDATE que envías al pool se confirma al instante — es el modo “autocommit”. Perfecto para una query; peligroso para tres. Imagina “crear la tarea y descontar un crédito del usuario”:
Una query es la pregunta que le haces a la base de datos en SQL: SELECT * FROM tasks WHERE done = false. La BD traduce la pregunta a un plan de búsqueda — por eso los índices importan.
Un pool es el staff de conexiones a la BD: abrir una conexión por request es caro, así que el pool mantiene ~10–20 abiertas y las presta. El request la usa, la devuelve, y la siguiente la reutiliza.
insert_task(...) # ✅ committed — the task EXISTS now
charge_credit(user_id) # 💥 crashes — but the task is already saved
# the DB now lies: task created, credit never chargedNo hay código que resuelva esto después — la tarea fantasma ya está confirmada. La única solución es que la BD entienda “estas operaciones son UNA”.
Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.
22.2 Cómo lo resuelve un equipo
ACID en una frase por letra, con dinero (la metáfora original — mover $100 entre cuentas):
Una transacción es un grupo de operaciones que se confirman juntas o no se confirma ninguna: transferir dinero = restar de A y sumar a B. Si falla a la mitad sin transacción, el dinero desapareció.
- Atomicidad: todo o nada — la transacción es indivisible.
- Consistencia: la BD nunca queda violando sus reglas (constraints).
- Aislamiento: dos transacciones simultáneas no se ven a medias.
- Durabilidad: lo confirmado sobrevive al crash — está en disco.
El patrón de código es idéntico en todos los stacks: tomar UNA conexión del pool (no el pool entero — la transacción vive en una conexión), BEGIN, ejecutar todas las queries ahí, COMMIT si todo bien, ROLLBACK si algo falla. Y la pregunta de arquitectura que lo hace interesante: ¿en qué capa vive la frontera de la transacción?
Respuesta: en el servicio (cap. 7). El handler no sabe de transacciones; el repositorio no decide cuándo empieza una — el servicio agrupa las operaciones que son un hecho de negocio.
Un repositorio (repo) es la carpeta del proyecto con todo su historial Git adentro. GitHub/GitLab son servicios que hospedan repos para compartirlos y respaldarlos.
22.3 Conceptos nuevos
- D Transacción y ACID, explicado con dinero
- D
BEGIN/COMMIT/ROLLBACKen cada driver — la transacción vive en una conexión, no en el pool - U
try/finallyvsdefervs context manager: tres formas de garantizar el ROLLBACK - D
SELECT ... FOR UPDATE: bloquear la fila que vas a modificar — contra el doble-submit - U Dónde vive la frontera transaccional en una arquitectura por capas
22.4 La explicación visual
22.5 Implementación
“Crear tarea + registrar evento + descontar crédito” atómico en los tres stacks. Observa la constante: conexión individual, BEGIN…COMMIT, y el ROLLBACK garantizado por el mecanismo de limpieza del lenguaje.
Un evento es “algo que pasó” convertido en dato: el click del usuario, el payment.succeeded del webhook, el mensaje del socket. La programación moderna es reaccionar a eventos más que seguir un guion.
export async function createTaskWithEvent(userId: number, title: string) {
const client = await pool.connect(); // borrow ONE connection
try {
await client.query("BEGIN");
const { rows } = await client.query(
`INSERT INTO tasks (user_id, title) VALUES ($1, $2)
RETURNING public_id, title`,
[userId, title],
);
await client.query(
`INSERT INTO events (user_id, type, ref) VALUES ($1, 'task.created', $2)`,
[userId, rows[0].public_id],
);
await client.query(
`UPDATE users SET credits = credits - 1 WHERE id = $1`,
[userId],
);
await client.query("COMMIT"); // all three land together
return rows[0];
} catch (err) {
await client.query("ROLLBACK"); // all three vanish together
throw err; // the error mapper (cap. 6) handles it
} finally {
client.release(); // ALWAYS return the connection
}
}def create_task_with_event(user_id: int, title: str) -> dict:
with pool.connection() as conn: # borrow ONE connection
with conn.transaction(): # BEGIN...COMMIT or auto-ROLLBACK
row = conn.execute(
"""INSERT INTO tasks (user_id, title) VALUES (%s, %s)
RETURNING public_id, title""",
(user_id, title),
).fetchone()
conn.execute(
"""INSERT INTO events (user_id, type, ref)
VALUES (%s, 'task.created', %s)""",
(user_id, row["public_id"]),
)
conn.execute(
"UPDATE users SET credits = credits - 1 WHERE id = %s",
(user_id,),
)
return row # exiting = COMMIT; exception = ROLLBACKEl with conn.transaction() de psycopg es el try/finally — si algo lanza excepción adentro, el ROLLBACK se ejecuta solo.
func createTaskWithEvent(ctx context.Context, userID int64, title string) (Task, error) {
tx, err := pool.Begin(ctx) // BEGIN on one pooled connection
if err != nil {
return Task{}, err
}
defer tx.Rollback(ctx) // no-op if COMMIT succeeded
var t Task
err = tx.QueryRow(ctx,
`INSERT INTO tasks (user_id, title) VALUES ($1, $2)
RETURNING public_id, title`, userID, title).Scan(&t.PublicID, &t.Title)
if err != nil {
return Task{}, err
}
if _, err = tx.Exec(ctx,
`INSERT INTO events (user_id, type, ref) VALUES ($1, 'task.created', $2)`,
userID, t.PublicID); err != nil {
return Task{}, err
}
if _, err = tx.Exec(ctx,
`UPDATE users SET credits = credits - 1 WHERE id = $1`, userID); err != nil {
return Task{}, err
}
return t, tx.Commit(ctx) // all three land together
}El defer tx.Rollback() de Go es elegante: si Commit ya corrió, el rollback es no-op; si saliste por error, el rollback se ejecuta al salir — imposible olvidarlo.
El doble-submit: dos clicks rápidos en “crear tarea” = dos requests casi simultáneos. Si ambos leen credits = 5 antes de escribir, ambos descuentan → cobraste dos veces la misma operación. La cura es bloquear la fila mientras la transacción vive:
SELECT credits FROM users WHERE id = $1 FOR UPDATE;
-- any other transaction touching this row WAITS until you COMMITImplementa una operación atómica en mi API de tareas
[pg / psycopg / pgx]: crear tarea + insertar evento 'task.created' +
descontar 1 crédito del usuario. Requisitos: UNA conexión del pool para
toda la transacción (no el pool), BEGIN/COMMIT explícitos con ROLLBACK
garantizado por el mecanismo del lenguaje (try/finally, context manager
o defer), la frontera de la transacción en la capa de servicio, la
conexión devuelta siempre al pool, y el bloqueo FOR UPDATE en la lectura
de créditos para evitar el doble-submit. Explícame qué pasa si uso el
pool directo en vez de una conexión.
22.6 ¿Por qué cada stack lo hace así?
Lo único que necesitas llevarte: la semántica es idéntica — BEGIN, queries en una conexión, COMMIT/ROLLBACK. Lo que cambia es quién te obliga a limpiar: en TS tú escribes finally; en Python el with lo hace por contrato; en Go el defer lo hace imposible de olvidar. El lenguaje decide cuánta disciplina te regala.
Una transacción es un estado de la conexión: BEGIN la abre en ese cable concreto, y todas las queries deben ir por ese mismo cable para estar adentro. Si usaras pool.query() para cada una, cada query podría viajar por una conexión distinta — y la transacción no existiría: tres autocommits sueltos. Por eso el patrón es siempre “pedir prestada una conexión, hacer todo ahí, devolverla”.
22.7 Errores comunes
| Error | Por qué pasa | Fix |
|---|---|---|
| Queries sueltas en vez de transacción | Autocommit invisible | Cualquier multi-write de negocio = BEGIN |
pool.query dentro de la transacción |
Cada query puede tomar otra conexión | client/conn/tx — una sola |
Olvidar client.release() |
Conexión prestada para siempre → pool vacío → app colgada | finally/defer/with la devuelve |
| Transacciones largas | Una tx que espera input del usuario bloquea filas | La tx dura milisegundos: leer → decidir → tx corta |
| ROLLBACK que traga el error | catch hace rollback y return null |
Rollback y relanza — el mapper decide el HTTP |
22.8 Buenas prácticas
- La transacción es un hecho de negocio: si dos writes deben ser verdad a la vez (o falso a la vez), van juntos — y eso lo decide el servicio, no el handler.
- Corta y caliente: la transacción contiene solo los writes — validaciones y lecturas lentas fuera.
FOR UPDATEsolo donde haga falta: bloquear la fila que vas a modificar (créditos, stock, contadores); no todas las lecturas.- El error siempre sube: ROLLBACK silencioso + respuesta “ok” es la peor mentira del backend.
22.9 Ejercicio
- Escribe en tu stack la operación “completar tarea + sumar 1 punto al usuario” como transacción.
- Explica por qué
FOR UPDATEen la lectura decreditsevita el doble-submit. - ¿Qué le pasa a la conexión si tu código lanza excepción a mitad de la transacción y no usas
finally/with/defer?
Un lock de fila (SELECT ... FOR UPDATE) dice “esta fila es mía hasta que mi transacción termine”: otros que la pidan esperan. Es como el candado del probador de ropa — evita que dos editen lo mismo.
Python:
def complete_task(user_id: int, task_id: int) -> None: with pool.connection() as conn: with conn.transaction(): conn.execute( "UPDATE tasks SET done = TRUE WHERE public_id = %s AND user_id = %s", (task_id, user_id), ) conn.execute( "UPDATE users SET points = points + 1 WHERE id = %s", (user_id,), )Sin
FOR UPDATE: dos transacciones leencredits = 5a la vez, ambas calculan5-1, ambas escriben4— un descuento se pierde. ConFOR UPDATE, la segunda espera a que la primera haga COMMIT: lee el4ya actualizado y escribe3. El bloqueo convierte la lectura+escritura en algo atómico.La conexión queda prestada para siempre con una transacción abierta — el pool se vacía conexión a conexión hasta que la app se cuelga esperando una libre. Por eso la devolución vive en el mecanismo de limpieza garantizada del lenguaje.
22.10 Mini reto
Diseñar qué debería pasar si dos usuarios completan la misma tarea “a la vez” — hay requisito: solo el primero debe ganar puntos.
Sin protección, ambos leen done = FALSE, ambos marcan TRUE, ambos ganan el punto. La solución no es FOR UPDATE sino un UPDATE condicional — la BD misma decide:
UPDATE tasks SET done = TRUE
WHERE public_id = $1 AND done = FALSE
RETURNING id;
-- returns 1 row ONLY to the first transaction;
-- the second gets 0 rows → "already completed" → no pointsLección: el WHERE done = FALSE convierte el “check then act” (que tiene ventana de carrera) en un solo acto atómico — el patrón se llama compare-and-set y resuelve la mitad de los problemas de concurrencia sin bloqueos.
22.11 Vocabulario técnico del capítulo
Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.
Una función es una receta reutilizable: recibe ingredientes (parámetros), hace pasos y devuelve un plato (return). La escribes una vez y la llamas mil veces: add(2, 3) → 5.
Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.
Un string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.
null (TS), None (Py), nil (Go) = “aquí no hay valor”. Es la respuesta a “¿qué devuelvo cuando no hay nada?” — y la fuente del bug más famoso de la historia (su inventor lo llamó “el error del billón de dólares”). Por eso el código revisa if x is not None.
return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.
Asíncrono = empezar algo sin esperar sentado a que termine: pides la pizza (async) y sigues trabajando; cuando llega, te avisan. Lo opuesto a síncrono (esperar parado). Vital cuando la espera es larga: red, disco, bases de datos.
Un diccionario (dict en Python, map en Go, objeto en JS) guarda parejas clave→valor: {"ana": 30}. Es la estructura que más aparece en JSON — un objeto JS es un diccionario.
Una constraint es una regla que la BD hace cumplir: NOT NULL, UNIQUE, CHECK (price > 0), FOREIGN KEY. Es la última línea de defensa — el código puede tener bugs; la constraint no perdona.
Una traza sigue un request a través de todo el sistema: entró por el gateway → llamó auth → consultó la BD → tardó 340ms en la query. Cuando algo anda lento, la traza dice exactamente dónde.
Concurrencia = manejar muchas cosas en progreso (atender 1000 requests intercalando). Paralelismo = ejecutar varias a la vez en varios núcleos. Un camarero con 10 mesas es concurrente; 10 camareros son paralelos.
Un rollback es volver atrás: la migración se revierte, el deploy regresa a la versión anterior. Todo cambio serio tiene plan de rollback antes de ejecutarse — si no, el plan es rezar.
El estado es todo lo que el programa recuerda: las variables, la sesión, lo que muestra la UI. Stateless (sin estado) = el servidor no recuerda nada entre requests — cada request trae todo lo necesario.