045 — Bloqueo en dos fases, MVCC e instantáneas
Parte 08 — Transacciones, concurrencia y recuperación · Avanzado ·
4 horas estimadas · motores postgresql, mysql · laboratorio
labs/03-transactions · 3 fuentes.
Conceptos centrales: 2PL · versión de fila · instantánea · interbloqueo · vacuum
En este caso se comparan 7 motores: 6 lo resuelven (0 con el resultado comprobado por máquina) y 1 no, con el motivo escrito.
De qué trata esta clase
Las dos formas de sostener el aislamiento: bloqueo en dos fases, que hace esperar, y control de versiones, que hace copias. Explica por qué con MVCC las lecturas no bloquean, y también su factura escondida: las versiones muertas que el vacuum tiene que recoger.
flowchart LR
C["🗄️ Clase 045"]
C --> K1["2PL"]
C --> K2["versión de fila"]
C --> K3["instantánea"]
C --> K4["interbloqueo"]
C --> K5["vacuum"]
classDef raiz fill:#0b3d2e,stroke:#3fb950,color:#fff
class C raiz
Antes de empezar
Esta clase supone que ya trabajaste lo siguiente. Si algo de la última columna no te suena, vuelve a esa clase antes de seguir: aquí se usa sin volver a explicarlo.
| # | Clase previa | Lo que se da por sabido |
|---|---|---|
| 044 | Anomalías de aislamiento y la crítica a los niveles ANSI | lectura sucia · lectura no repetible · fantasma · sesgo de escritura · snapshot isolation |
Vocabulario de la clase
Los términos que siguen se usan más adelante con este significado exacto. La definición completa, con sus términos relacionados, está en el glosario del programa.
Propósito
Conocer los dos mecanismos con que los motores implementan el aislamiento —bloqueo y versiones— porque determinan qué operaciones se estorban entre sí y cómo se resuelve un interbloqueo.
Resultados de aprendizaje
Al terminar podrás:
- Explicar el bloqueo en dos fases y por qué garantiza serializabilidad.
- Leer una matriz de compatibilidad de bloqueos.
- Describir cómo MVCC decide qué versión de fila ve cada transacción.
- Diagnosticar un interbloqueo y prevenirlo.
- Explicar por qué MVCC necesita recolección de versiones muertas.
Fundamentos
Bloqueo en dos fases (2PL)
Dos reglas:
- Fase de crecimiento: la transacción adquiere bloqueos y no libera ninguno.
- Fase de decrecimiento: libera bloqueos y no adquiere ninguno.
En la práctica se usa 2PL estricto: todos los bloqueos se liberan en el COMMIT. Eso evita además las lecturas sucias y las reversiones en cascada.
Matriz de compatibilidad, para bloqueos de fila:
| Tiene ↓ / Pide → | Compartido (S) | Exclusivo (X) |
|---|---|---|
| Compartido (S) | Compatible | Espera |
| Exclusivo (X) | Espera | Espera |
La consecuencia práctica en un motor con bloqueo puro (SQL Server en READ COMMITTED clásico): los lectores bloquean a los escritores. Un informe largo puede detener las escrituras.
MVCC
En lugar de bloquear para leer, el motor guarda varias versiones de cada fila. Cada transacción ve una instantánea coherente.
En PostgreSQL, cada fila lleva xmin (transacción que la creó) y xmax (la que la borró o actualizó). Una transacción con instantánea S ve una versión si:
xmin está confirmada y es visible en S, y
xmax no existe, o no está confirmada, o no es visible en S
Un UPDATE no modifica: inserta una versión nueva y marca xmax en la anterior.
La regla que resume MVCC: los lectores no bloquean a los escritores y los escritores no bloquean a los lectores. Los escritores sí se bloquean entre sí sobre la misma fila.
Precio: las versiones muertas ocupan espacio hasta que VACUUM las recupera, y una transacción abierta impide recuperarlas (clase 033).
| Motor | Mecanismo |
|---|---|
| PostgreSQL | MVCC con versiones en la propia tabla + VACUUM |
| MySQL InnoDB | MVCC con versiones en el segmento de deshacer + bloqueo de siguiente clave |
| Oracle | MVCC con segmentos de deshacer |
| SQL Server | Bloqueo por defecto; MVCC con READ_COMMITTED_SNAPSHOT |
| SQLite | Instantánea por archivo; un escritor |
Bloqueo de siguiente clave
MVCC evita los fantasmas en lectura, pero no basta para escrituras que dependen de un rango. InnoDB añade el bloqueo de siguiente clave: bloquea el registro y el hueco anterior en el índice, impidiendo insertar en ese rango.
SELECT * FROM enrollments WHERE course_id = 42 FOR UPDATE;
-- bloquea las filas existentes Y los huecos: nadie puede insertar con course_id = 42
Efecto secundario importante: si la consulta no usa un índice, InnoDB bloquea todas las filas examinadas, que pueden ser la tabla entera. Un FOR UPDATE sin índice adecuado convierte una operación puntual en un bloqueo global.
Interbloqueo
T1: bloquea fila A ... pide fila B
T2: bloquea fila B ... pide fila A
Ciclo de espera. El motor lo detecta y aborta una de las transacciones (la «víctima»). No es un error del motor: es su forma correcta de resolverlo.
Prevención, en orden de eficacia:
- Acceder siempre a los recursos en el mismo orden. Si toda transacción bloquea las cuentas por identificador ascendente, no puede haber ciclo.
- Transacciones cortas. Menos ventana para el ciclo.
- Bloquear lo mínimo.
FOR UPDATEsolo sobre lo que se va a escribir, y con índice. - Reintentar. La víctima debe reintentar con retroceso; es parte del contrato.
flowchart TD
subgraph P["2PL (bloqueo)"]
L1["Leer: bloqueo S"] --> L2["Escribir: bloqueo X"]
L2 --> L3["COMMIT: liberar todo"]
L1 -.->|"lector bloquea<br/>a escritor"| X1["Contención"]
end
subgraph M["MVCC"]
V1["Leer: instantánea<br/>sin bloqueo"] --> V2["Escribir: versión nueva<br/>+ bloqueo de fila"]
V2 --> V3["COMMIT"]
V3 --> V4["VACUUM recupera<br/>versiones muertas"]
end
Ejemplo trabajado
Interbloqueo reproducible
Sesión A Sesión B
BEGIN;
UPDATE cuentas SET saldo=saldo-100
WHERE id=1; BEGIN;
UPDATE cuentas SET saldo=saldo-50
WHERE id=2;
UPDATE cuentas SET saldo=saldo+100
WHERE id=2; -- espera a B
UPDATE cuentas SET saldo=saldo+50
WHERE id=1; -- espera a A → CICLO
PostgreSQL detecta el ciclo en ~1 s (deadlock_timeout) y aborta una:
ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock on transaction 5678; blocked by process 5679.
HINT: See server log for query details.
Corrección por orden canónico:
def transferir(conn, origen, destino, monto):
# Bloquear siempre por id ascendente: dos transferencias cruzadas
# (1→2 y 2→1) piden los mismos bloqueos en el mismo orden y no hay ciclo.
primero, segundo = sorted([origen, destino])
with conn.transaction():
conn.execute("SELECT id FROM cuentas WHERE id = %s FOR UPDATE", (primero,))
conn.execute("SELECT id FROM cuentas WHERE id = %s FOR UPDATE", (segundo,))
conn.execute("UPDATE cuentas SET saldo = saldo - %s WHERE id = %s", (monto, origen))
conn.execute("UPDATE cuentas SET saldo = saldo + %s WHERE id = %s", (monto, destino))
Con el orden canónico, una de las dos espera y ambas terminan. Sin él, una muere y hay que reintentarla.
MVCC en acción
-- Sesión A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM enrollments; -- 240 000
-- Sesión B (concurrente)
INSERT INTO enrollments VALUES (...); -- 1 000 filas
COMMIT;
-- Sesión A, otra vez
SELECT COUNT(*) FROM enrollments; -- 240 000 ← sigue viendo su instantánea
COMMIT;
SELECT COUNT(*) FROM enrollments; -- 241 000
Ninguna de las dos sesiones esperó a la otra. Ese es todo el valor de MVCC.
El precio, medido. Durante la transacción de A, las versiones antiguas no se pueden recuperar:
SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables
WHERE relname = 'enrollments';
Con 2 000 actualizaciones por segundo y una transacción de A abierta 30 minutos, se acumulan ~3,6 millones de versiones muertas que ningún vacuum puede retirar hasta que A termine. La tabla crece, los barridos leen páginas casi vacías y el rendimiento cae de forma sostenida.
Diagnóstico de transacciones largas:
SELECT pid, state, now() - xact_start AS duracion, query
FROM pg_stat_activity
WHERE state <> 'idle' AND xact_start IS NOT NULL
ORDER BY duracion DESC LIMIT 5;
Es la primera consulta que ejecutar cuando una base «crece sin motivo».
Comparación
| Dimensión | 2PL puro | MVCC |
|---|---|---|
| Lector frente a escritor | Se bloquean | No se bloquean |
| Escritor frente a escritor | Se bloquean | Se bloquean |
| Coste en espacio | Bajo | Versiones + recolección |
| Interbloqueos | Frecuentes | Menos, pero existen |
| Lecturas consistentes | Con bloqueo largo | Gratis, por instantánea |
| Problema operativo típico | Contención | Hinchazón por transacción larga |
Errores frecuentes
SELECT ... FOR UPDATEsin índice. Bloquea todas las filas examinadas.- Orden de acceso inconsistente. Fabrica interbloqueos evitables.
- No reintentar la víctima. El interbloqueo llega al usuario como error.
- Transacciones largas en un motor MVCC. Impiden la recolección.
- Suponer que MVCC elimina el bloqueo. Los escritores siguen compitiendo por la misma fila.
- Bloquear el padre cuando bastaba una sentencia atómica.
De la clase a la operación
Los interbloqueos aumentan de golpe tras un despliegue que cambió el orden de las escrituras. Registrar el grafo de espera y el orden de acceso de cada transacción convierte un problema intermitente en uno reproducible.
Reto de transferencia
- Provoca un interbloqueo con dos sesiones y captura el mensaje del motor.
- Corrígelo con orden canónico y demuestra que ya no ocurre.
- Mide
n_dead_tupantes, durante y después de una transacción larga. - Compara el mismo escenario en un motor con bloqueo y en uno con MVCC.
Preguntas de evaluación
- ¿Por qué el 2PL estricto evita las reversiones en cascada?
- Explica con
xmin/xmaxpor qué la sesión A sigue viendo 240 000 filas. - ¿Qué bloquea exactamente
FOR UPDATEsobre una consulta sin índice en InnoDB? - Da dos transacciones de tu sistema que podrían formar un ciclo y define su orden canónico.
🌐 El mismo problema en cada motor
Caso: Bloquear o versionar: las dos familias de control de concurrencia
Solo hay dos formas de que dos transacciones no se pisen. Bloquear: la primera que llega retiene el dato y la segunda espera —es el bloqueo en dos fases, que da serializabilidad a cambio de esperas e interbloqueos. Versionar: cada escritura crea una versión nueva y cada lector ve la que existía cuando empezó, de modo que los lectores nunca bloquean a los escritores ni al revés; es MVCC.
Casi todos los motores modernos usan MVCC para leer y algún bloqueo para escribir, pero el reparto exacto cambia mucho, y con él el trabajo de limpiar las versiones viejas: el vacío de PostgreSQL, el segmento de deshacer de Oracle y de InnoDB, y las lápidas de Cassandra son el mismo problema con tres nombres.
Esta comparación es conceptual: la decisión no se reduce a una consulta con resultado, así que aquí no hay sello de máquina. Lo que se compara es lo que cada motor ofrece y a qué precio, con la página oficial al lado de cada afirmación.
| Motor | ¿Resuelve el caso? | Nivel de prueba | Código | Fuente |
|---|---|---|---|---|
| PostgreSQL | sí | conceptual | — | doc oficial |
| MySQL | sí | conceptual | — | doc oficial |
| SQLite | sí | conceptual | — | doc oficial |
| Microsoft SQL Server | sí | conceptual | — | doc oficial |
| Oracle Database | sí | conceptual | — | doc oficial |
| MongoDB | sí | conceptual | — | doc oficial |
| Apache Cassandra | no | — | — | doc oficial |
Los que resuelven el caso
PostgreSQL
- Cómo se hace aquí: MVCC puro en el almacén: un
UPDATEno modifica la fila, escribe una versión nueva y marca la vieja como muerta. Los lectores no toman ningún bloqueo. El precio es la limpieza:VACUUMrecupera el espacio de las versiones muertas, yautovacuumlo hace solo. - Por qué sí: Los informes largos no bloquean nunca la escritura, y el aislamiento serializable se consigue sin bloqueos gracias a SSI.
- Por qué no: El coste está en el mantenimiento: una transacción abierta durante horas impide vaciar y la tabla se hincha; y el contador de transacciones puede llegar a agotarse si el vacío se retrasa lo suficiente. Es la avería de operación más común del motor.
- 📄 Documentación oficial: https://www.postgresql.org/docs/current/mvcc-intro.html
MySQL
- Cómo se hace aquí: MVCC para las lecturas mediante el registro de deshacer —la fila se modifica en su sitio y la versión anterior se reconstruye desde ese registro— y bloqueos de fila e índice para las escrituras, incluidos los bloqueos de hueco que evitan fantasmas.
- Por qué sí: Modificar en el sitio evita la hinchazón de la tabla, y el espacio de las versiones viejas se reutiliza sin un proceso de vacío.
- Por qué no: Los bloqueos de hueco producen interbloqueos donde no parece haberlos —dos inserciones en el mismo rango vacío— y una transacción larga hace crecer el registro de deshacer hasta llenar el disco, que es la misma avería con otro nombre.
- 📄 Documentación oficial: https://dev.mysql.com/doc/refman/8.4/en/innodb-multi-versioning.html
SQLite
- Cómo se hace aquí: En modo WAL, los lectores leen del archivo principal más el registro y ven una instantánea; el escritor añade al registro. Es una forma mínima de MVCC: un escritor y muchos lectores, sin bloqueos de lectura.
- Por qué sí: Cabe entero en la cabeza: un escritor, cero interbloqueos, cero configuración.
- Por qué no: No hay grados: no se puede tener dos escritores por mucho que escriban en tablas distintas. Y el registro WAL crece hasta que un punto de control lo recorta, cosa que un lector muy largo puede impedir.
- 📄 Documentación oficial: https://sqlite.org/wal.html
Microsoft SQL Server
- Cómo se hace aquí: Bloqueo en dos fases de verdad como comportamiento por omisión, con granularidad de fila, página y tabla, y escalada automática de bloqueos cuando hay demasiados. Opcionalmente, versiones con
READ_COMMITTED_SNAPSHOT, guardadas entempdb. - Por qué sí: El bloqueo es el modelo que más fácil hace razonar sobre el orden de las operaciones, y su detector de interbloqueos elige víctima y devuelve un error claro.
- Por qué no: La escalada de bloqueos convierte un
UPDATEgrande en un bloqueo de tabla entera; y al activar las versiones, toda la carga de versiones cae entempdb, que pasa a ser el cuello de botella de la instancia. - 📄 Documentación oficial: https://learn.microsoft.com/sql/relational-databases/sql-server-transaction-locking-and-row-versioning-guide
Oracle Database
- Cómo se hace aquí: MVCC con segmentos de deshacer desde antes de que se llamara así: la lectura consistente reconstruye la versión que existía al empezar la sentencia. Si el segmento ya se reutilizó, la consulta falla con el célebre ORA-01555, «snapshot too old».
- Por qué sí: Es la implementación más madura y probada del modelo, con décadas de carga real encima.
- Por qué no: Ese ORA-01555 es exactamente el mismo problema que la hinchazón de PostgreSQL, resuelto al revés: en vez de que la tabla crezca, la consulta larga muere. Hay que dimensionar el espacio de deshacer, y eso es trabajo de administración.
- 📄 Documentación oficial: https://docs.oracle.com/en/database/oracle/oracle-database/23/cncpt/data-concurrency-and-consistency.html
MongoDB
- Cómo se hace aquí: WiredTiger usa control de concurrencia optimista con versiones a nivel de documento: dos escrituras sobre el mismo documento entran en conflicto y una se reintenta, mientras que sobre documentos distintos no se estorban.
- Por qué sí: El conflicto es por documento, no por página ni por tabla: la granularidad más fina posible en su modelo.
- Por qué no: Un documento «caliente» —un contador global, un agregado muy actualizado— se convierte en el punto de contención, y la solución no es configurar nada, es rediseñar el modelo para repartirlo.
- 📄 Documentación oficial: https://www.mongodb.com/docs/manual/faq/concurrency/
Los que no resuelven este caso — y qué se hace en su lugar
Descartar un motor con un argumento es tan formativo como usarlo. Ninguna de estas filas dice que el motor sea peor: dice que este problema no es el suyo.
| Motor | Por qué no | Qué se hace en su lugar | Fuente |
|---|---|---|---|
| Apache Cassandra | No hay control de concurrencia: no hay bloqueos ni versiones que un lector deba respetar. La última escritura gana según su marca de tiempo, y si dos llegan con la misma, gana la mayor por valor. No es una variante de MVCC: es renunciar al problema. | Transacciones ligeras (IF NOT EXISTS, IF condicion) cuando de verdad hace falta comparar antes de escribir, sabiendo que cuestan un acuerdo entre réplicas y varias veces más que una escritura normal. |
doc |
Laboratorio
python scripts/validate_repository.py
python labs/03-transactions/run_transactions_lab.py
Guarda como evidencia la salida completa, la versión del motor y la semilla o los parámetros usados. Una captura sin comando no es evidencia: no se puede repetir.
Evaluación
| Criterio | Peso | Qué se comprueba |
|---|---|---|
| Comprensión conceptual | 25 % | Explica el mecanismo, no solo el resultado |
| Ejecución reproducible | 25 % | Otra persona obtiene lo mismo con las instrucciones dadas |
| Interpretación basada en evidencia | 25 % | Cada conclusión se apoya en una salida o una medición |
| Límites y riesgos declarados | 25 % | Dice qué no demuestra el ejercicio y qué faltaría en producción |
La clase se da por superada cuando la respuesta explica el mecanismo, muestra la salida que la respalda y declara al menos un límite del ejercicio.
Fuentes de esta clase
Todo lo afirmado arriba procede de estas obras. Los identificadores viven en
catalog/sources.json y el estado de los
enlaces se comprueba con python scripts/check_external_links.py.
- Jim Gray, Andreas Reuter (1992). Transaction Processing: Concepts and Techniques. Morgan Kaufmann. ISBN 978-1-55860-190-1.
Obra canónica sobre ACID, bloqueo, registro y recuperación. - PostgreSQL Global Development Group (2026). PostgreSQL: Concurrency Control.
Niveles de aislamiento tal como los implementa PostgreSQL, no como los define la norma. - Egor Rogov (2022). PostgreSQL 14 Internals. Postgres Professional. ISBN 978-5-6041193-2-8.
PDF gratuito. MVCC, vacuum, buffers, índices y planificador sobre el código real.