Transacciones y niveles de aislamiento en PostgreSQL
1. Descripción general
Cuando múltiples solicitudes leen y escriben en una tabla simultáneamente, el resultado puede ser incorrecto aunque cada sentencia SQL sea correcta. El nivel de aislamiento determina cuántos cambios de otras transacciones puede “ver” una transacción.
En este artículo, veremos cómo PostgreSQL implementa los niveles de aislamiento. Reproduzarás los errores de lost update y write skew. Al final, sabrás cómo elegir el nivel adecuado, configurarlo en Spring Boot y escribir la lógica de reintento correctamente.
2. Transacciones en PostgreSQL
Una transacción agrupa múltiples sentencias en una unidad. O todas tienen éxito, o ninguna tiene efecto:
CREATE TABLE account (
id BIGINT PRIMARY KEY,
owner TEXT NOT NULL,
balance NUMERIC(12, 2) NOT NULL CHECK (balance >= 0)
);
INSERT INTO account VALUES (1, 'An', 1000), (2, 'Binh', 500);
BEGIN;
UPDATE account SET balance = balance - 300 WHERE id = 1;
UPDATE account SET balance = balance + 300 WHERE id = 2;
COMMIT;
Si la segunda sentencia UPDATE falla, llamas a ROLLBACK y el saldo de An se mantiene sin cambios. Sin BEGIN, PostgreSQL se ejecuta en modo autocommit: cada sentencia es una transacción separada.
PostgreSQL usa MVCC (Multi-Version Concurrency Control). Cada comando UPDATE crea una nueva versión de la fila en lugar de sobrescribir. Cada transacción lee datos a través de un snapshot. Por lo tanto, las operaciones de lectura no bloquean las escrituras, y viceversa. El nivel de aislamiento determina cuándo se toma el snapshot: al inicio de cada sentencia, o una sola vez para toda la transacción.
3. Fenómenos anómalos y cuatro niveles de aislamiento
El estándar SQL define los siguientes fenómenos:
- Dirty read: leer datos que otra transacción aún no ha confirmado.
- Non-repeatable read: leer la misma fila dos veces pero obtener valores diferentes.
- Phantom read: ejecutar la misma consulta con condición de nuevo, y las filas aparecen o desaparecen en el resultado.
- Lost update: dos transacciones leen la misma fila y luego la escriben; una de las escrituras se sobrescribe.
- Write skew: dos transacciones leen datos comunes, luego cada una escribe en una fila diferente, violando una restricción de negocio.
La tabla a continuación describe cómo PostgreSQL maneja cada fenómeno:
Level Dirty read Non-repeatable Phantom Lost update Write skew
READ UNCOMMITTED No Yes Yes Yes Yes
READ COMMITTED No Yes Yes Yes Yes
REPEATABLE READ No No No No (*) Yes
SERIALIZABLE No No No No (*) No (*)
(*) La transacción se cancela con SQLSTATE 40001, la aplicación debe reintentar.
Hay dos puntos que difieren del estándar SQL que debes recordar:
READ UNCOMMITTEDen PostgreSQL funciona exactamente comoREAD COMMITTED. Nunca experimentarás dirty read.REPEATABLE READtambién previene phantom read, porque en realidad es snapshot isolation.
El nivel predeterminado es READ COMMITTED. Puedes verificarlo con este comando:
SHOW default_transaction_isolation;
default_transaction_isolation
-------------------------------
read committed
4. READ COMMITTED
En este nivel, cada sentencia toma un nuevo snapshot. Abre dos sesiones de psql y ejecuta intercaladas en este orden:
-- Sesión A
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 700.00
-- Sesión B
UPDATE account SET balance = 900 WHERE id = 1; -- autocommit
-- Sesión A
SELECT balance FROM account WHERE id = 1; -- 900.00
COMMIT;
Dentro de la misma transacción, la sesión A ve dos valores diferentes. Este es un non-repeatable read.
4.1. Lost update
El error más común en las aplicaciones es el patrón de “leer en Java, calcular, luego escribir de vuelta”:
BigDecimal balance = jdbc.queryForObject(
"SELECT balance FROM account WHERE id = ?", BigDecimal.class, 1L);
jdbc.update("UPDATE account SET balance = ? WHERE id = ?",
balance.subtract(new BigDecimal("100")), 1L);
Supón que dos threads ejecutan este código simultáneamente y ambos leen 1000. Ambos escribirán 900. El resultado es que una resta de dinero se pierde.
Hay dos formas de arreglarlo mientras mantienes READ COMMITTED.
Forma 1: deja que la base de datos haga los cálculos, escribe una sola sentencia atómica.
UPDATE account SET balance = balance - 100 WHERE id = 1;
Cuando PostgreSQL encuentra una fila siendo actualizada por otra transacción, espera a que esa transacción se confirme. Luego relee la versión más reciente de la fila y aplica la resta.
Forma 2: bloquea la fila al leerla usando SELECT ... FOR UPDATE.
BEGIN;
SELECT balance FROM account WHERE id = 1 FOR UPDATE;
-- la aplicación verifica la lógica de negocio, calcula...
UPDATE account SET balance = 900 WHERE id = 1;
COMMIT;
La segunda transacción se bloqueará en el comando SELECT ... FOR UPDATE hasta que la primera transacción se confirme. Por lo tanto, siempre lee el valor actualizado.
5. REPEATABLE READ
En este nivel, el snapshot se toma una sola vez en la primera sentencia de la transacción. Todas las lecturas posteriores ven el mismo estado de los datos.
Si intentas actualizar una fila que otra transacción ya modificó y confirmó después de que se tomó tu snapshot, PostgreSQL lo rechazará:
-- Sesión A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM account WHERE id = 1; -- 1000.00
-- Sesión B
UPDATE account SET balance = balance - 100 WHERE id = 1; -- confirm
-- Sesión A
UPDATE account SET balance = balance - 200 WHERE id = 1;
ERROR: could not serialize access due to concurrent update
Lost update ya no ocurre silenciosamente. En cambio, la sesión A debe hacer ROLLBACK y ejecutar de nuevo desde el inicio. El código de error 40001 (serialization_failure) es una señal para que la aplicación haga reintento.
6. SERIALIZABLE y write skew
REPEATABLE READ aún permite que write skew ocurra. Considera este ejemplo clásico: un hospital requiere que siempre haya al menos un médico de guardia.
CREATE TABLE doctor (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
on_call BOOLEAN NOT NULL
);
INSERT INTO doctor VALUES (1, 'Lan', true), (2, 'Minh', true);
Ambos médicos piden permiso para dejar la guardia, cada uno en una transacción:
-- A y B ejecutan simultáneamente:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctor WHERE on_call; -- ambos ven 2
-- A
UPDATE doctor SET on_call = false WHERE id = 1;
-- B
UPDATE doctor SET on_call = false WHERE id = 2;
COMMIT; -- A: OK
COMMIT; -- B: OK -> ¡nadie está de guardia!
Las dos transacciones escriben en filas diferentes, por lo que no se detecta conflicto de escritura. Si cambias a SERIALIZABLE, PostgreSQL usa el mecanismo Serializable Snapshot Isolation (SSI). SSI rastrea las dependencias lectura-escritura entre transacciones y cancela una de ellas:
ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt.
HINT: The transaction might succeed if retried.
El error puede aparecer en la sentencia UPDATE o durante COMMIT. El código de error sigue siendo 40001.
Nota:
SERIALIZABLEno bloquea filas adicionales. Solo detecta conflictos y cancela la transacción. Con SSI, PostgreSQL puede cancelar incorrectamente transacciones que no están realmente en conflicto. Por lo tanto, cuando hay alta concurrencia de escritura, la tasa de reintento puede aumentar significativamente.
7. Uso de niveles de aislamiento en Spring Boot
Agrega las siguientes dependencias:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
La forma más simple es declarar el nivel de aislamiento en @Transactional:
@Service
public class ReportService {
private final JdbcTemplate jdbc;
public ReportService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public BigDecimal totalBalance() {
BigDecimal total = jdbc.queryForObject(
"SELECT sum(balance) FROM account", BigDecimal.class);
Long count = jdbc.queryForObject(
"SELECT count(*) FROM account", Long.class);
// ambas consultas usan el mismo snapshot -> datos consistentes
return total;
}
}
Spring llama a Connection.setTransactionIsolation(...) antes de iniciar la transacción. El driver de PostgreSQL lo convierte en SET TRANSACTION ISOLATION LEVEL ....
7.1. Reintento cuando ocurre serialization failure
El reintento debe estar fuera de la transacción. El reintento dentro de la transacción no tiene sentido, porque la transacción ya está marcada como dañada. Usar TransactionTemplate ayuda a evitar la trampa de auto-invocación de @Transactional:
@Service
public class OnCallService {
private static final int MAX_ATTEMPTS = 3;
private final JdbcTemplate jdbc;
private final TransactionTemplate tx;
public OnCallService(JdbcTemplate jdbc, PlatformTransactionManager tm) {
this.jdbc = jdbc;
this.tx = new TransactionTemplate(tm);
this.tx.setIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE);
}
public void goOffCall(long doctorId) {
for (int attempt = 1; ; attempt++) {
try {
tx.executeWithoutResult(status -> doGoOffCall(doctorId));
return;
} catch (ConcurrencyFailureException e) {
if (attempt >= MAX_ATTEMPTS) {
throw e;
}
}
}
}
private void doGoOffCall(long doctorId) {
Integer onCall = jdbc.queryForObject(
"SELECT count(*) FROM doctor WHERE on_call", Integer.class);
if (onCall == null || onCall < 2) {
throw new IllegalStateException("Debe haber al menos un médico de guardia");
}
jdbc.update("UPDATE doctor SET on_call = false WHERE id = ?", doctorId);
}
}
El código anterior funciona de la siguiente manera:
setIsolationLevel(ISOLATION_SERIALIZABLE)se aplica a cada llamada deexecuteWithoutResult.- Spring traduce SQLSTATE
40001(y deadlock40P01) a subclases deConcurrencyFailureException. Así no tienes que leer los códigos de error del driver. - En el reintento, la nueva transacción toma un snapshot nuevo. Ve que el otro médico ya se fue, por lo que lanza
IllegalStateException. La restricción de negocio se mantiene correcta.
Nota: Spring Boot 3 configura automáticamente
JdbcTransactionManager. Esta clase traduce incluso los errores que ocurren durante commit aDataAccessException. Si declaras manualmente el beanDataSourceTransactionManager, los errores de commit se convertirán enTransactionSystemExceptiony el bucle de reintento anterior no los capturará.
8. Prueba de write skew con Testcontainers
Para reproducir exactamente el orden intercalado, usamos directamente dos conexiones JDBC en el mismo thread. Este enfoque no depende del tiempo de ejecución del thread. Agrega las dependencias de prueba:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Clase de prueba:
@Testcontainers
class WriteSkewTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@BeforeEach
void setUp() throws SQLException {
try (Connection c = connect(); Statement st = c.createStatement()) {
st.execute("DROP TABLE IF EXISTS doctor");
st.execute("CREATE TABLE doctor (id BIGINT PRIMARY KEY, name TEXT, on_call BOOLEAN)");
st.execute("INSERT INTO doctor VALUES (1, 'Lan', true), (2, 'Minh', true)");
}
}
@Test
void repeatableReadAllowsWriteSkew() throws SQLException {
runConcurrently(Connection.TRANSACTION_REPEATABLE_READ);
assertThat(countOnCall()).isZero();
}
@Test
void serializableRejectsWriteSkew() throws SQLException {
assertThatThrownBy(() -> runConcurrently(Connection.TRANSACTION_SERIALIZABLE))
.isInstanceOf(SQLException.class)
.extracting(e -> ((SQLException) e).getSQLState())
.isEqualTo("40001");
assertThat(countOnCall()).isEqualTo(1);
}
private void runConcurrently(int level) throws SQLException {
try (Connection a = begin(level); Connection b = begin(level)) {
assertThat(countOnCall(a)).isEqualTo(2);
assertThat(countOnCall(b)).isEqualTo(2);
offCall(a, 1);
a.commit();
offCall(b, 2);
b.commit();
}
}
private Connection begin(int level) throws SQLException {
Connection c = connect();
c.setAutoCommit(false);
c.setTransactionIsolation(level);
return c;
}
private Connection connect() throws SQLException {
return DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
}
private void offCall(Connection c, long id) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(
"UPDATE doctor SET on_call = false WHERE id = ?")) {
ps.setLong(1, id);
ps.executeUpdate();
}
}
private int countOnCall(Connection c) throws SQLException {
try (Statement st = c.createStatement();
ResultSet rs = st.executeQuery("SELECT count(*) FROM doctor WHERE on_call")) {
rs.next();
return rs.getInt(1);
}
}
private int countOnCall() throws SQLException {
try (Connection c = connect()) {
return countOnCall(c);
}
}
}
Los resultados esperados de las dos pruebas:
repeatableReadAllowsWriteSkew -> PASSED (ambos confirman, 0 médicos de guardia)
serializableRejectsWriteSkew -> PASSED (B se cancela con 40001, queda 1 médico de guardia)
La segunda prueba no afirma si el error ocurre en UPDATE o COMMIT de B. Solo verifica el código 40001, porque el momento en que SSI detecta el conflicto puede variar entre versiones de PostgreSQL.
9. Errores comunes y cuándo no usar
- Elevar el nivel sin reintento. En
REPEATABLE READySERIALIZABLE, el error40001es un comportamiento normal, no un bug. Sin reintento, los usuarios recibirán errores 500 aleatorios. - Reintento dentro de
@Transactional. La transacción dañada debe revertirse y reiniciarse desde el principio. Coloca el reintento en la capa de llamada externa. - Llamar APIs externas en transacciones con reintento. Los correos electrónicos o comandos de pago pueden enviarse múltiples veces. Realiza los efectos secundarios después de un commit exitoso.
- Establecer
SERIALIZABLEpara todo el sistema “para estar seguro”. Esto incurre en costos de seguimiento SSI y muchos reintentos. Úsalo solo para fragmentos con restricciones que abarquen múltiples filas. - Mezclar niveles dentro de la misma operación de negocio. SSI solo protege cuando todas las transacciones relacionadas se ejecutan con
SERIALIZABLE. Una transacciónREAD COMMITTEDescribiendo en la misma tabla aún puede violar la restricción. - Transacciones
REPEATABLE READque se ejecutan demasiado tiempo. El snapshot antiguo previene queVACUUMlimpie versiones de filas muertas, haciendo la tabla más grande. Mantén las transacciones cortas.
Nota: Para restricciones que se pueden expresar en el esquema, como
CHECK,UNIQUEo exclusion constraint, prioriza usar restricciones. Es más barato y confiable que elevar el nivel de aislamiento.
10. Conclusión
PostgreSQL se ejecuta por defecto con READ COMMITTED. Este nivel es suficiente para la mayoría de aplicaciones, siempre que escribas actualizaciones atómicas o uses SELECT ... FOR UPDATE para evitar lost update.
REPEATABLE READ proporciona un snapshot consistente durante toda la transacción, adecuado para reportes. SERIALIZABLE previene incluso write skew. Ambos niveles requieren que la aplicación reintente cuando encuentra SQLSTATE 40001.
En Spring Boot, declaras el nivel a través de @Transactional(isolation = ...) o TransactionTemplate, captura ConcurrencyFailureException fuera de la transacción y verifica el comportamiento con Testcontainers usando un orden de operaciones determinista.