Una base de datos no es una tabla grande que conserva valores. Es un sistema que interpreta relaciones, aplica predicados, coordina cambios y decide qué estado puede observarse después de una operación. El modelo profesional empieza por el significado: qué entidad representa una tupla, qué la identifica, qué combinaciones son válidas y qué cambios deben verse como una unidad.
Este capítulo continúa CC-0053. Allí una representación validada cruza la frontera de materialización; aquí se decide cómo queda almacenada y bajo qué contrato se confirma. CC-0055 estudiará interleavings y concurrencia profunda. Por eso se hablará de aislamiento y anomalías como propiedades observables de una transacción, sin convertir el capítulo en un manual de locks.
Relación, predicado e identidad
Una relación es un conjunto de tuplas que satisface un esquema y un predicado de pertenencia. La tabla es una representación operativa de esa relación, no su significado completo. Una fila account_id, tenant_id, status sólo es interpretable si se sabe qué combinación identifica una cuenta, qué estados son admisibles y si tenant_id forma parte de la frontera de autorización. El orden visual de filas no es semántica; un ORDER BY explícito sí expresa una necesidad de salida.
Una clave identifica una tupla dentro de un universo declarado. La clave primaria suele imponer unicidad y no nulidad en PostgreSQL, pero el significado de «misma cuenta» puede exigir una clave compuesta, una restricción de exclusión o un predicado adicional. Una clave sustituta no reemplaza automáticamente una regla natural: evita duplicados del identificador técnico, pero puede permitir dos tuplas que representan la misma entidad de negocio.
Las relaciones tienen cardinalidad y dependencias. Una clave foránea expresa que un valor debe corresponder a una clave referenciada bajo la política de acciones (RESTRICT, CASCADE, SET NULL, entre otras). Eso protege una relación entre tablas, no la autorización del actor que intenta modificarla. La aplicación debe comprobar principal, tenant y estado en el límite de confianza; una foreign key no es control de acceso.
Vista adaptada. Toca el diagrama para ampliarlo.
Constraints como invariantes ejecutables
Un invariante es una propiedad que debe conservarse en los estados aceptables. Un CHECK (balance >= 0) expresa un rango local; UNIQUE (tenant_id, external_id) expresa una identidad contextual; una foreign key expresa una dependencia entre relaciones. Estas declaraciones son más fuertes que un comentario porque el motor puede rechazarlas en los límites definidos por su contrato. Aun así, no toda regla de negocio cabe en una constraint, y una constraint correcta no prueba que el actor estuviera autorizado.
Hay que distinguir NULL, ausencia y valor. Un CHECK que evalúa a UNKNOWN bajo la lógica ternaria de SQL no siempre rechaza la fila como un lector de lógica booleana esperaría. Si una columna debe existir, NOT NULL expresa mejor el invariante. Si el dominio permite «desconocido» pero no cadena vacía, son reglas diferentes. El diseño debe registrar la tabla de verdad y los valores límite, no confiar en nombres intuitivos.
Las constraints también tienen temporalidad. Una regla puede comprobarse por fila, por sentencia o al commit según el mecanismo y configuración. Una operación que temporalmente viola una regla para restaurarla después necesita una constraint diferible sólo si el motor y el contrato lo permiten. No se debe simular atomicidad encadenando llamadas independientes: si el proceso termina entre ellas, el estado parcial puede quedar visible.
La validación de aplicación es útil para mensajes y límites tempranos, pero existe una carrera si dos sesiones validan el mismo valor y luego escriben. La constraint del motor es la autoridad final para unicidad y relaciones protegidas. El código debe tratar la violación como resultado esperado de una competencia válida, no como una excepción imposible. Esto no elimina la necesidad de autorización: ambas sesiones pueden ser legítimas y una puede seguir sin permiso sobre el tenant equivocado.
Transacción y alcance de ACID
Una transacción agrupa operaciones bajo un contrato de commit o rollback. Atomicidad significa que el conjunto se confirma como unidad según el alcance del motor; no significa que una llamada HTTP, una cola y un servicio externo se deshagan juntos. Consistencia significa que las constraints y reglas declaradas se conservan al pasar de un estado válido a otro; no convierte una regla ausente en garantía.
Aislamiento describe qué observaciones concurrentes permite el sistema. Durabilidad describe qué sobrevive a una confirmación bajo el modelo de fallo documentado. ACID es un acrónimo útil, no una etiqueta de seguridad. Cada propiedad necesita sujeto, frontera, configuración y evidencia. Una transacción puede ser atómica en una base y dejar un mensaje externo sin enviar. Un commit durable en el almacenamiento del motor no implica que una caché o réplica ya lo muestre.
El alcance debe estar escrito: conexión, sesión, sentencia, savepoint, job o unidad de negocio. Un savepoint permite deshacer parte de una transacción, pero no crea una transacción independiente durable. Un autocommit puede convertir cada sentencia en una transacción propia; si el lector imagina dos sentencias como unidad, el modelo mental ya es incorrecto.
Aislamiento y anomalías
PostgreSQL documenta niveles Read Committed, Repeatable Read y Serializable con contratos distintos (documentación 18, §13.2). En Read Committed, cada sentencia puede observar un snapshot diferente; una segunda consulta dentro de la misma transacción no necesariamente ve exactamente el mismo conjunto. Repeatable Read estabiliza una vista bajo el modelo de PostgreSQL, pero puede abortar ante conflictos. Serializable busca un resultado equivalente a algún orden serial, y puede devolver serialization_failure para que la aplicación reintente con una operación segura.
Estos nombres no autorizan universalidad entre motores. La pregunta correcta es qué lecturas, escrituras y abortos permite la implementación y configuración concreta. Non-repeatable read es observar valores distintos en dos lecturas; phantom es que cambie el conjunto que satisface un predicado; lost update es perder una modificación por una escritura posterior; una write skew puede violar una regla global cuando cada transacción modifica filas distintas. El nivel y el patrón de consulta importan.
Vista adaptada. Toca el diagrama para ampliarlo.
Un retry tras serialization_failure no es un retry ciego: debe repetir una unidad idempotente, limitar intentos, conservar el contexto de autorización y volver a evaluar entradas que pudieron cambiar. Si el trabajo emitió un email o llamó a un servicio antes del abort, la base no lo deshace. El diseño requiere outbox, deduplicación o compensación según el dominio, que no se inventan por usar SERIALIZABLE.
Commit, durabilidad y fallo incierto
COMMIT es una decisión de la transacción, no una prueba de que todos los observadores externos ya conocen el resultado. PostgreSQL documenta mecanismos de sincronización y configuración de durabilidad (Write-Ahead Log, §28.4); el contrato debe decir si se requiere que el commit sobreviva a una pérdida de energía, qué almacenamiento se incluye y cuándo una réplica puede responder. Un COMMIT que devuelve error puede dejar incertidumbre en escenarios de conexión rota: el servidor pudo recibir y aplicar la petición antes de perder la respuesta.
Este estado se modela como unknown, no como fallo confirmado. Consultar por una clave de operación, usar una operación idempotente o reconciliar desde una fuente de verdad puede resolverlo. Repetir un INSERT sin clave deduplicable puede crear un duplicado; repetir una actualización absoluta puede ser seguro bajo una constraint y repetir una operación relativa quizá no. La idempotencia debe declararse para esa operación, no inferirse del verbo «guardar».
La durabilidad tampoco es visibilidad instantánea. Una réplica, una caché o un índice de búsqueda pueden converger después. Si un usuario recibe created antes de que una lectura posterior encuentre la fila, el contrato debe distinguir confirmación primaria de disponibilidad en ese lector. El capítulo 55 tratará las carreras con mayor profundidad; aquí basta no colapsar estados observables distintos.
Índices: acceso, no magia
Un índice es una estructura auxiliar que puede acelerar una estrategia de acceso bajo determinadas estadísticas y predicados. No es una constraint salvo que se defina como mecanismo de unicidad, y aun así su propósito de unicidad no reemplaza el significado de negocio. Un índice sobre tenant_id no impide leer el tenant equivocado; el predicado y la autorización siguen siendo obligatorios.
El planner puede ignorar un índice si el conjunto es pequeño, la selectividad es baja, las estadísticas están obsoletas o el costo estimado favorece un scan (documentación 18, §11.1). Un índice compuesto tiene orden de columnas y no sirve de la misma forma para todo prefijo. Un índice parcial cubre sólo su predicado; una consulta fuera de él no obtiene esa garantía. Los índices también añaden costo de escritura, espacio, mantenimiento y posiblemente exposición de valores sensibles.
La evidencia debe venir de EXPLAIN/EXPLAIN ANALYZE bajo datos representativos y una versión concreta, no de una intuición visual. EXPLAIN ANALYZE ejecuta la consulta: en operaciones mutantes sólo se usa con un entorno seguro y datos sintéticos o dentro de una transacción desechable cuando el contrato lo permite. Un plan observado no es promesa estable después de cambiar estadísticas o volumen.
Migraciones y compatibilidad
Una migración cambia esquema, datos, constraints, índices o interpretación. Debe tener precondiciones, pasos reanudables, observabilidad y una estrategia ante fallo parcial. El patrón expand/contract separa añadir una forma compatible, migrar consumidores y retirar la forma antigua. Renombrar una columna y desplegar código simultáneamente puede fallar si versiones coexistentes leen el esquema anterior.
Añadir una columna nullable suele ser una expansión compatible, pero un NOT NULL exige que los datos existentes y escritores intermedios cumplan el contrato. Crear un índice puede bloquear o consumir recursos según método y versión; la elección no debe copiarse de otra base. Cambiar el tipo puede truncar o alterar comparaciones. Una migración que terminó su DDL puede dejar backfill incompleto: el estado debe reflejar esa parcialidad.
El rollback de código no revierte necesariamente datos. Si una migración transforma valores destruyendo información, la recuperación exige backup o una migración inversa explícita. Las migraciones deben registrar versión, checksum, duración, filas afectadas y resultado sin exponer datos sensibles. Un job reiniciado debe saber qué filas están completas y qué operaciones son idempotentes.
Vista adaptada. Toca el diagrama para ampliarlo.
Caso: reserva de cupo por tenant
Un servicio reserva un cupo para un tenant: comprueba que existe, inserta una reserva con clave externa y actualiza el contador. Una implementación que hace tres sentencias sin transacción puede dejar reserva sin contador o contador incrementado sin reserva. Una transacción agrupa los cambios, pero la regla «cupo disponible» sigue necesitando un patrón compatible con el aislamiento y la constraint. Dos sesiones pueden observar el mismo cupo antes de escribir; el motor puede bloquear, abortar o permitir una anomalía según diseño.
El contrato debe fijar la clave de idempotencia de la solicitud, el tenant autorizado, el límite y el resultado por estado. Si el commit responde con timeout, una consulta por esa clave distingue reserva creada de no creada. Si la operación ya se confirmó y se reintenta, la constraint o una tabla de idempotencia evita duplicarla. Si también se envía una notificación, se registra como efecto separado y no se declara entregada por el mero commit.
Método de revisión
- Escribir el predicado de cada relación y las claves que le dan identidad.
- Enumerar invariantes declarados, invariantes de aplicación y reglas de autorización, sin fusionarlos.
- Fijar alcance de transacción, nivel de aislamiento, savepoints y efectos fuera de la base.
- Enumerar resultados: commit confirmado, rollback confirmado, serialization failure y unknown.
- Probar caso normal, duplicado, límite, dos escritores, conexión perdida y migración reanudada.
- Medir índices con plan y datos representativos; no tratarlos como garantía de tiempo.
- Documentar versión, configuración, fuente primaria y límites de cada conclusión.
Una revisión que sólo comprueba que la tabla contiene filas no ha probado el modelo. Debe preguntar qué combinación no puede existir, qué actor puede modificarla, qué sucede entre dos sentencias y qué evidencia existe después del fallo. La ausencia de una anomalía observada no demuestra serialización universal.
Null, dominios y reglas que cruzan filas
La lógica de SQL no es una lógica booleana de dos valores. Una comparación con NULL puede producir UNKNOWN, y una restricción debe analizar qué resultado acepta según su clase. Una columna que representa una fecha opcional puede permitir NULL, mientras una fecha de creación debe ser no nula y tener una política de zona horaria. Un CHECK sobre una columna nullable no expresa por sí solo «valor siempre presente». La combinación correcta de NOT NULL, CHECK, UNIQUE y claves debe reflejar el dominio, no el deseo de que el motor «entienda» la intención.
Los dominios también cruzan filas. «No puede haber dos reservas activas para el mismo recurso» no es necesariamente una propiedad de una sola fila. Puede expresarse con una constraint única parcial, una exclusión o un protocolo transaccional, según motor y forma del dato. Si se deja sólo en código, dos sesiones pueden pasar la comprobación antes de escribir. Si se usa una constraint parcial, el predicado de la constraint debe coincidir con el significado de estado activo; una migración que renombra el estado sin actualizarla puede permitir duplicados o rechazar valores legítimos.
Una regla temporal requiere especial cuidado. «Un usuario sólo puede tener una sesión abierta» depende de qué significa abierta, cuándo expira y cómo se trata una desconexión. Una fila vieja puede seguir abierta aunque el cliente haya desaparecido. La base puede conservar una marca de expiración, pero la decisión de cerrar una sesión y crear otra debe tener una unidad transaccional y una política para carreras. No basta aumentar un contador o leer el reloj desde dos procesos sin declarar la fuente temporal.
Visibilidad, réplicas y límites de observación
La palabra «consistente» se usa con demasiada amplitud. Una transacción puede conservar constraints de la primaria y aun una lectura de réplica no haber recibido el cambio. Una caché puede servir un valor anterior aunque el commit ya sea durable. Si una API responde con el identificador de un objeto recién creado, debe declarar si las lecturas inmediatas se dirigen a la misma autoridad, si aceptan retraso o si esperan una señal de aplicación.
El estado de una lectura también puede ser incompleto: una consulta cancelada tras recibir algunas filas no equivale a un resultado parcial válido salvo que el contrato lo permita. Un cursor puede mantener un snapshot concreto, mientras otra consulta nueva observa otro. Los límites de tiempo de cliente y servidor pueden producir una respuesta perdida con la misma incertidumbre que un commit. Registrar el snapshot, el nodo y el identificador de operación ayuda a reconciliar, pero no convierte una observación antigua en estado actual.
Cuando se usa replicación, «durable» debe nombrar dónde. Persistir en el primario, replicar a un standby y confirmar al cliente son eventos potencialmente distintos. La política puede requerir sólo durabilidad local, confirmación síncrona de réplicas o tolerancia a pérdida de una ventana. La documentación del motor y la configuración determinan qué se ofrece. No es correcto deducirlo de que COMMIT no devolvió error.
Transacciones largas y recursos operativos
Una transacción larga mantiene snapshots, locks o versiones más tiempo y puede retrasar vacuum, bloquear DDL o aumentar la memoria de trabajo. La atomicidad lógica de un lote no obliga a procesarlo en una única transacción si el dominio permite checkpoints explícitos. Dividirlo requiere un estado reanudable, una clave de deduplicación y una definición de qué subconjuntos ya están confirmados. Enviar una lista de mil elementos y marcarla completa sólo cuando la última fila termina oculta el progreso real si el proceso se interrumpe.
Los límites operativos forman parte de la corrección. Un backfill puede ser correcto en datos pequeños y causar lock prolongado o agotamiento de WAL a escala. La migración debe medir duración, filas, tamaño y contención con datos representativos. Un timeout de una operación administrativa no prueba que ninguna fila haya sido actualizada. Si el trabajo es reanudable, el estado debe distinguir lote iniciado, segmentos confirmados y segmentos desconocidos.
El control de recursos no debe convertir una excepción en éxito vacío. Un job que captura statement_timeout y devuelve una lista vacía puede borrar la diferencia entre «no había filas» y «la consulta no terminó». El contrato de la API debe conservar el motivo y permitir retry sólo donde sea seguro. La observabilidad debe incluir duración, intento, aislamiento, nodo y resultado, sin registrar datos sensibles o consultas con secretos.
Compatibilidad de lectores y escritores
Durante una migración pueden coexistir binarios antiguos y nuevos. Añadir un campo opcional permite que lectores viejos lo ignoren si el formato de salida lo tolera, pero cambiar el significado de un campo existente no es expansión compatible. Un escritor nuevo debe poder producir una forma que todos los lectores activos acepten; un lector nuevo debe tolerar la forma anterior durante la ventana prevista. La base de datos no resuelve por sí sola incompatibilidades de cachés, eventos o exportaciones.
El backfill puede competir con escrituras nuevas. Si el proceso copia un valor antiguo sobre uno actualizado, puede perder información; si calcula desde otra tabla, necesita una regla de precedencia. Un trigger o una escritura dual puede mantener columnas durante una transición, pero añade rutas que deben retirarse. La condición de retirada debe ser observable: versión mínima de consumidor, porcentaje de filas migradas y ausencia de lectores antiguos. Dejar compatibilidad indefinidamente es deuda operativa; retirarla antes de comprobar consumidores rompe el contrato.
Los cambios de índices y constraints también tienen compatibilidad. Un índice nuevo no cambia el significado, pero puede alterar planes, consumo y latencia. Una constraint nueva puede rechazar datos que escritores antiguos todavía producen. Primero hay que medir y limpiar datos existentes, después activar la enforcement según una secuencia compatible. Si la activación falla por filas antiguas, el estado es migración incompleta, no «esquema actualizado».
Adjudicar un fallo de base de datos
Ante una alerta de duplicado o pérdida, congelar el identificador de operación, conexión, transacción, aislamiento, sentencia, snapshot y nodo. Separar lo observado —por ejemplo, unique_violation— de la explicación —dos solicitudes concurrentes— y del impacto —una solicitud rechazada—. La causa puede ser una constraint correcta que protegió el invariante, no un defecto. Del mismo modo, ausencia de excepción no demuestra que un efecto externo se entregara.
Para una transacción incierta, consultar una clave natural o de idempotencia en la misma autoridad. Si no existe una clave, la reconciliación puede requerir comparar parámetros, timestamps y auditoría. No se debe repetir una operación no idempotente sólo porque el usuario no recibió respuesta. Cuando el dominio no permite consulta segura, se marca pendiente y se escala; inventar una confirmación es peor que dejar una operación en revisión.
Un control positivo muestra que un estado válido se confirma. Un control negativo muestra que una duplicación, foreign key inválida, límite global o migración incompatible se rechaza. Las pruebas deben incluir dos clientes, no sólo una secuencia lineal. En aislamiento serializable, una prueba que pasa una vez no elimina la necesidad de manejar abortos. En un índice, un plan rápido no prueba que la constraint o la autorización estén cubiertas.
Qué significa terminar
Una operación de base de datos termina cuando su contrato comunica un resultado, pero la interpretación depende de qué capa se esté observando. El motor puede haber confirmado, el driver puede haber perdido la respuesta y la API puede aún no haber publicado el recurso. Cada capa necesita un estado propio y una relación trazable entre ellos. success sin indicar la capa es una etiqueta demasiado fuerte. Esta separación enlaza con CC-0051 y evita tratar un timeout de cliente como rollback.
Al cerrar una migración, la evidencia mínima incluye versión de esquema, checksum de migración, cobertura del backfill, constraints activas, consumidores compatibles y resultado de controles negativos. El registro no debe decir «terminada» mientras quede un segmento unknown o una versión antigua que pueda escribir valores incompatibles. Una entrega profesional puede declarar domain-review y dejar explícitamente las verificaciones de build y auditoría pendientes; eso es más preciso que convertir trabajo adelantado en publicación.
Transferencia y síntesis
En una exportación de CC-0053, el formato canónico define bytes; CC-0054 define qué fila, versión y estado se materializa. En una operación de CC-0051, la excepción puede abortar la transacción, pero no borra efectos externos. En CC-0055, los interleavings explicarán por qué dos transacciones válidas individualmente pueden requerir un protocolo adicional. La frontera es deliberada.
El modelo final separa relación, constraint, transacción, aislamiento, commit, durabilidad, índice y migración. Las constraints hacen ejecutables algunos invariantes; la transacción agrupa una unidad bajo el contrato del motor; el aislamiento limita observaciones; commit comunica una decisión; durabilidad y visibilidad tienen condiciones; los índices aceleran rutas; las migraciones evolucionan el esquema con compatibilidad. Ninguna palabra por sí sola autoriza una garantía fuera de su alcance.
Fuentes primarias
- PostgreSQL 18 Documentation, §5.5 Constraints: https://www.postgresql.org/docs/18/ddl-constraints.html
- PostgreSQL 18 Documentation, §13.2 Transaction Isolation: https://www.postgresql.org/docs/18/transaction-iso.html
- PostgreSQL 18 Documentation, §13.3 Explicit Locking y §13.4 Serialization Failure Handling: https://www.postgresql.org/docs/18/explicit-locking.html
- PostgreSQL 18 Documentation, §11.1 Introduction to Indexes y §14.1 Using EXPLAIN: https://www.postgresql.org/docs/18/indexes-intro.html
- PostgreSQL 18 Documentation, §14.1 Using EXPLAIN: https://www.postgresql.org/docs/18/using-explain.html
- IETF RFC 9110 §9.2.2, Idempotent Methods: https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2


