Niveles de aislamiento de transacciones en bases de datos explicados
Desglose práctico de los niveles de aislamiento: lectura no confirmada, confirmada, repetible y serializable, sus anomalías y su coste en rendimiento.

Los niveles de aislamiento de transacciones determinan qué datos puede ver una transacción mientras otras se ejecutan simultáneamente. Si eliges uno demasiado bajo, tu aplicación leerá datos inconsistentes. Si eliges uno demasiado alto, tu base de datos serializa todo, destruyendo el rendimiento. Entender las compensaciones es esencial para construir sistemas concurrentes correctos.
La mayoría de desarrolladores usan el valor por defecto de la base de datos y nunca lo piensan. Eso funciona hasta que no funciona: hasta que una consulta de reportes lee un pedido parcialmente actualizado, o dos transferencias concurrentes sobregiran una cuenta.
Los cuatro niveles de aislamiento
SQL define cuatro niveles de aislamiento, cada uno protegiendo contra anomalías progresivamente mayores. Mayor aislamiento significa más seguridad pero menos concurrencia.
-- PostgreSQL: Set isolation level for a transaction
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- ... your queries ...
COMMIT;
-- Or set the default for the session
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;// TypeScript with a query builder — setting isolation per transaction
async function transferFunds(
db: Database,
fromId: string,
toId: string,
amount: number
): Promise<void> {
await db.transaction(
async (tx) => {
const from = await tx.query(
'SELECT balance FROM accounts WHERE id = $1 FOR UPDATE',
[fromId]
);
if (from.rows[0].balance < amount) {
throw new Error('Insufficient funds');
}
await tx.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, fromId]
);
await tx.query(
'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
[amount, toId]
);
},
{ isolationLevel: 'serializable' }
);
}Read uncommitted: ver todo
Read uncommitted permite que una transacción lea datos que otra transacción ha escrito pero aún no ha confirmado. Este es el problema de lectura sucia. Si la otra transacción se revierte, la tuya habrá leído datos que nunca existieron.
-- Session A: Starts a transaction, updates a price
BEGIN;
UPDATE products SET price = 999.99 WHERE id = 42;
-- Has NOT committed yet
-- Session B (READ UNCOMMITTED): Can see the uncommitted price
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT price FROM products WHERE id = 42;
-- Returns 999.99 (the UNCOMMITTED value)
-- Session A: Rolls back!
ROLLBACK;
-- Session B just used a price that never existed.
-- If this was an order calculation, the total is wrong.En la práctica, casi nadie usa read uncommitted. PostgreSQL ni siquiera lo implementa: configurarlo te da read committed en su lugar. El riesgo de lecturas sucias es demasiado alto para prácticamente cualquier caso de uso.
Read committed: el valor por defecto común
Read committed es el valor por defecto en PostgreSQL y la mayoría de bases de datos. Cada sentencia dentro de una transacción ve solo los datos confirmados antes de que esa sentencia comenzara. No hay lecturas sucias. Pero aún puedes ver resultados diferentes si ejecutas la misma consulta dos veces dentro de una transacción.
-- The "non-repeatable read" problem with READ COMMITTED
-- Session A:
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- Returns 1000
-- Session B (in between A's two queries):
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
-- Session A (same transaction, second read):
SELECT balance FROM accounts WHERE id = 1;
-- Returns 500 (different from the first read!)
COMMIT;
-- Session A saw two different values for the same row
-- in the same transaction. This is a non-repeatable read.// ❌ Bug: read committed allows non-repeatable reads
async function generateReport(db: Database): Promise<Report> {
// Read committed (default)
const totalOrders = await db.query(
'SELECT SUM(amount) FROM orders WHERE status = $1',
['completed']
);
// Another transaction inserts a completed order HERE
const orderCount = await db.query(
'SELECT COUNT(*) FROM orders WHERE status = $1',
['completed']
);
// totalOrders / orderCount gives wrong average
// because the two queries saw different data
return {
total: totalOrders.rows[0].sum,
count: orderCount.rows[0].count,
average: totalOrders.rows[0].sum / orderCount.rows[0].count,
};
}
// ✅ Fix: use repeatable read for consistent snapshots
async function generateReport(db: Database): Promise<Report> {
return db.transaction(async (tx) => {
const totalOrders = await tx.query(
'SELECT SUM(amount) FROM orders WHERE status = $1',
['completed']
);
const orderCount = await tx.query(
'SELECT COUNT(*) FROM orders WHERE status = $1',
['completed']
);
// Both queries see the same snapshot — consistent results
return {
total: totalOrders.rows[0].sum,
count: orderCount.rows[0].count,
average: totalOrders.rows[0].sum / orderCount.rows[0].count,
};
}, { isolationLevel: 'repeatable read' });
}Repeatable read: coherencia de instantánea
Repeatable read le da a cada transacción una instantánea coherente de la base de datos desde el inicio de la transacción. Sin lecturas sucias, sin lecturas no repetibles. Pero aún puedes ver filas fantasma: nuevas filas insertadas por otras transacciones que coinciden con el predicado de tu consulta.
-- The "phantom read" problem (in databases that don't use MVCC)
-- Session A (REPEATABLE READ):
BEGIN;
SELECT * FROM orders WHERE total > 100;
-- Returns 5 rows
-- Session B:
INSERT INTO orders (total) VALUES (150);
COMMIT;
-- Session A:
SELECT * FROM orders WHERE total > 100;
-- In strict SQL standard: might return 6 rows (phantom row)
-- In PostgreSQL: still returns 5 rows (MVCC prevents phantoms)
COMMIT;La implementación de repeatable read de PostgreSQL en realidad también evita las lecturas fantasma, porque usa el control de concurrencia multiversión (MVCC). Cada transacción ve una instantánea desde su momento de inicio, y las nuevas filas de otras transacciones son invisibles independientemente de la consulta.
-- PostgreSQL REPEATABLE READ also detects write conflicts
-- Session A:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Session B:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
-- Session A commits first:
COMMIT; -- Succeeds
-- Session B tries to commit:
COMMIT;
-- ERROR: could not serialize access due to concurrent update
-- Session B must retry the entire transactionSerializable: máxima seguridad
Serializable garantiza que el resultado de las transacciones concurrentes sea el mismo que si se ejecutaran una tras otra. Esta es la garantía más fuerte. Previene todas las anomalías, incluyendo el sesgo de escritura y otros errores sutiles que repeatable read no detecta.
// Write skew example: two doctors trying to go off-call
// At least one doctor must always be on call
// ❌ With repeatable read — write skew is possible
async function goOffCall(db: Database, doctorId: string): Promise<void> {
await db.transaction(async (tx) => {
const onCallCount = await tx.query(
'SELECT COUNT(*) FROM doctors WHERE on_call = true'
);
if (Number(onCallCount.rows[0].count) > 1) {
// "Safe" to go off call — at least one other doctor is on call
await tx.query(
'UPDATE doctors SET on_call = false WHERE id = $1',
[doctorId]
);
}
// BUT: if two doctors run this simultaneously, both see count=2,
// both go off call, and nobody is on call!
}, { isolationLevel: 'repeatable read' });
}
// ✅ With serializable — write skew is prevented
async function goOffCall(db: Database, doctorId: string): Promise<void> {
await db.transaction(async (tx) => {
const onCallCount = await tx.query(
'SELECT COUNT(*) FROM doctors WHERE on_call = true'
);
if (Number(onCallCount.rows[0].count) > 1) {
await tx.query(
'UPDATE doctors SET on_call = false WHERE id = $1',
[doctorId]
);
}
}, { isolationLevel: 'serializable' });
// Serializable detects the dependency between the read and write
// One transaction succeeds, the other gets a serialization error
// and must retry — ensuring at least one doctor stays on call
}Elegir el nivel correcto
La decisión depende de tu caso de uso específico. No hay un nivel universalmente correcto.
// Guidelines for choosing isolation level
const isolationGuide = {
readCommitted: {
useWhen: [
'Most CRUD operations',
'Writes that affect independent rows',
'Performance is critical and slight inconsistency is acceptable',
],
avoidWhen: [
'Reports that join multiple tables',
'Any read-then-write pattern',
],
},
repeatableRead: {
useWhen: [
'Reports and analytics queries',
'Any operation that reads the same data twice',
'Batch operations that need a consistent view',
],
avoidWhen: [
'High-contention write workloads (frequent retries)',
],
},
serializable: {
useWhen: [
'Financial transactions',
'Constraint enforcement that spans multiple rows',
'Any operation where correctness is non-negotiable',
],
avoidWhen: [
'High-throughput write workloads where retries are expensive',
'Read-only queries (repeatable read is sufficient)',
],
},
};Conclusiones clave
- Read committed es seguro para la mayoría de operaciones — pero cualquier patrón de leer-escribir necesita un nivel más alto o bloqueo explícito
- Repeatable read evita las lecturas no repetibles — úsalo para reportes, análisis y cualquier operación de lectura con varias consultas
- Serializable previene todas las anomalías — úsalo para transacciones financieras y aplicación de restricciones que abarcan varias filas
- El MVCC de PostgreSQL hace repeatable read más fuerte — también evita lecturas fantasma, a diferencia del mínimo del estándar SQL
- Un aislamiento mayor significa más reintentos — las transacciones serializable fallarán ocasionalmente con errores de serialización; tu código debe manejar los reintentos
- Usa read committed por defecto y escala de forma intencionada — sé por qué eliges un nivel más alto, no solo "por seguridad"


