El problema invisible que puede arruinar tu producto
Imagina esto: tu startup procesa pagos, tu sistema registra dos créditos de $50 a una cuenta con $100 de saldo. El balance final debería ser $200, pero termina en $150. No hay error en los logs, no hay excepción lanzada, la base de datos hizo exactamente lo que le pediste. Acabas de perder $50 en una race condition.
Este tipo de bug es especialmente peligroso porque:
- Ocurre solo bajo condiciones específicas de concurrencia
- Tus tests unitarios tradicionales nunca lo detectarán
- No deja rastro en los logs del sistema
- Puede estar latente durante meses hasta que el tráfico aumenta
Para founders de startups tech, este escenario no es hipotético. Es una bomba de tiempo esperando el momento en que tu producto escale.
Anatomía de una race condition en Postgres
Una race condition (condición de carrera) ocurre cuando dos operaciones concurrentes leen el mismo valor obsoleto y luego ambas escriben basándose en él. Veamos el ejemplo del crédito bancario:
Proceso 1 lee balance: $100
Proceso 2 lee balance: $100
Ambos calculan nuevo balance: $150 (100 + 50)
Proceso 1 escribe: $150
Proceso 2 escribe: $150
El segundo UPDATE sobrescribe al primero. La segunda transacción de $50 desaparece sin dejar rastro. En un sistema financiero, esto significa clientes con balances incorrectos y cero evidencia de qué salió mal.
Por qué tus tests actuales no lo detectan
Tu suite de tests ejecuta una petición a la vez. La intercalación problemática nunca ocurre. Podrías ejecutar Promise.all([credit(1, 50), credit(1, 50)]) y el test pasaría, aunque tu código tenga el bug de concurrencia.
Algunas soluciones ingenuas que no funcionan:
- Agregar sleep() entre queries: Test lento y flaky que a veces detecta el bug, a veces no
- Ejecutar el test mil veces: No estás testeando concurrencia, estás tirando dados
- Esperar a producción: El peor momento para descubrir race conditions
Lo que necesitas es forzar que dos operaciones lean el mismo valor obsoleto antes de que cualquiera escriba. Cada vez. De manera determinística.
Synchronization barriers: la solución elegante
Un synchronization barrier (barrera de sincronización) es un punto de coordinación para operaciones concurrentes. Le indicas cuántas tareas esperar. Cada tarea corre independientemente hasta llegar a la barrera, donde se detiene. Cuando la última tarea llega, todas se liberan simultáneamente.
Implementación básica en TypeScript:
function createBarrier(count: number) {
let arrived = 0;
const waiters: (() => void)[] = [];
return async () => {
arrived++;
if (arrived === count) {
waiters.forEach((resolve) => resolve());
} else {
await new Promise((resolve) => waiters.push(resolve));
}
};
}
Esta función mantiene un contador y una lista de promesas esperando. Cada llamada incrementa el contador. Si no es la última, espera. Cuando llega la última tarea, todas se liberan.
Aplicando la barrera al código real
Coloca una barrera entre la lectura y la escritura en tu código concurrente, y fuerzas exactamente la intercalación problemática: todas las tareas leen antes de que cualquiera escriba. Manufactura la race condition bajo demanda, de manera controlada.
Tres niveles de protección: experimento revelador
Aplicar la misma barrera bajo tres implementaciones diferentes revela verdades incómodas sobre transacciones y locks en Postgres.
Nivel 1: Queries sin protección
Sin transacción, solo un SELECT y un UPDATE con barrera entre ellos:
const barrier = createBarrier(2);
const credit = async (accountId: number, amount: number) => {
const [row] = await db.execute(
sql`SELECT balance FROM accounts WHERE id = ${accountId}`
);
await barrier(); // Ambas tareas esperan aquí
const newBalance = row.balance + amount;
await db.execute(
sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`
);
};
Resultado: El test falla. Balance final: $150 en lugar de $200. La barrera fuerza el escenario exacto de race condition. Determinístico, sin trucos de timing.
Nivel 2: Agregando transacciones
Envolvemos la operación en una transacción:
const credit = async (accountId: number, amount: number) => {
await db.transaction(async (tx) => {
const [row] = await tx.execute(
sql`SELECT balance FROM accounts WHERE id = ${accountId}`
);
await barrier();
const newBalance = row.balance + amount;
await tx.execute(
sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`
);
});
};
Resultado: El test todavía falla. Balance: $150.
Esta es la lección crítica: el nivel de aislamiento por defecto de Postgres es READ COMMITTED. Cada statement ve todos los datos committed antes de que ese statement empezara. Una transacción te da snapshot consistency por statement, no un write lock. La barrera acaba de probar que son cosas diferentes.
Nivel 3: Agregando locks explícitos
SELECT ... FOR UPDATE adquiere un lock a nivel de fila en tiempo de lectura. Otra transacción intentando lockear la misma fila se bloquea hasta que la primera haga commit:
const credit = async (accountId: number, amount: number) => {
await db.transaction(async (tx) => {
const [row] = await tx.execute(
sql`SELECT balance FROM accounts WHERE id = ${accountId} FOR UPDATE`
);
await barrier();
const newBalance = row.balance + amount;
await tx.execute(
sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`
);
});
};
Resultado: El test se cuelga en un deadlock.
La primera tarea ejecuta SELECT ... FOR UPDATE y adquiere el lock. La segunda intenta el mismo query y se bloquea esperando el lock. Pero nunca llega a la barrera. La primera tarea está en la barrera esperando a la segunda. Deadlock clásico.
La solución: reposicionar la barrera
El deadlock no es un callejón sin salida, es información. El problema es la ubicación de la barrera. Muévela más temprano: después del BEGIN, antes del SELECT:
const credit = async (accountId: number, amount: number) => {
await db.transaction(async (tx) => {
await barrier(); // Ahora aquí
const [row] = await tx.execute(
sql`SELECT balance FROM accounts WHERE id = ${accountId} FOR UPDATE`
);
const newBalance = row.balance + amount;
await tx.execute(
sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`
);
});
};
Resultado con FOR UPDATE: Test pasa. Balance: $200.
Resultado sin FOR UPDATE: Test falla. Balance: $150.
Esto es correcto. Un test de barrera válido debe pasar con el lock y fallar sin él. Si pasa en ambos casos, no está probando nada. Si falla en ambos, la barrera está mal posicionada.
Implementación práctica en tu startup
Testing contra base de datos real
Estos tests necesitan una instancia real de Postgres. Los mocks no tienen locks, transacciones ni contención real que reproducir. Para startups que buscan agilidad, Neon Testing es una solución moderna que provisiona instancias efímeras de Postgres y proporciona funciones como createBarrier listas para usar.
Inyección de barreras mediante hooks
Las barreras son infraestructura de test, no deben existir en código de producción. La solución es un hook opcional que se dispara en el punto correcto dentro de la transacción:
async function credit(
accountId: number,
amount: number,
hooks?: { onTxBegin?: () => Promise | void }
) {
await db.transaction(async (tx) => {
if (hooks?.onTxBegin) {
await hooks.onTxBegin();
}
const [row] = await tx.execute(
sql`SELECT balance FROM accounts WHERE id = ${accountId} FOR UPDATE`
);
const newBalance = row.balance + amount;
await tx.execute(
sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`
);
});
}
En producción, hooks es undefined y el check no cuesta nada. En tests, inyectas la barrera:
const barrier = createBarrier(2);
await Promise.all([
credit(1, 50, { onTxBegin: barrier }),
credit(1, 50, { onTxBegin: barrier })
]);
Cero overhead en producción, máxima confianza en tests.
Por qué esto importa para tu startup
Cada startup tech que escala enfrenta problemas de concurrencia eventualmente. La diferencia entre las que sobreviven y las que explotan está en detectar estos bugs antes de que lleguen a producción.
Sin barrier testing:
- Cada refactor es una oportunidad para perder un lock crítico
- Las race conditions solo aparecen bajo carga, cuando más duele
- Debuggear en producción es 10x más caro que prevenir en desarrollo
- La confianza de los usuarios se erosiona con cada inconsistencia de datos
Con barrier testing:
- Las regresiones de concurrencia fallan en la máquina del developer
- Los code reviews pueden verificar que los locks realmente funcionan
- Tu suite de CI se convierte en una red de seguridad real
- Escalas con confianza sabiendo que tu capa de datos es sólida
Conclusión
Las race conditions en bases de datos son el tipo de bug que destruye startups silenciosamente. No generan errores visibles, no aparecen en logs, y tus tests tradicionales nunca los detectarán. Las synchronization barriers cambian eso completamente.
Esta técnica te permite manufacturar condiciones de carrera de manera determinística, probar que tus locks realmente protegen tus datos, y detectar regresiones antes de que lleguen a producción. Para founders técnicos que construyen productos en Postgres, esto no es opcional: es la diferencia entre un sistema que escala y uno que explota bajo carga.
La próxima vez que escribas código que modifica datos concurrentemente, pregúntate: ¿Este código tiene un test de barrera? Si la respuesta es no, estás a un refactor de distancia de enviar un bug crítico a producción.
¿Construyendo sistemas que necesitan escalar sin romper? Únete gratis a Ecosistema Startup y conecta con founders que ya resolvieron estos desafíos de arquitectura.













