Saltar al contenido

017 — Claves, identidad y el debate natural frente a sustituta

🗂️ parte 🎚️ nivel ⏱️ duración 📗 clase

Programa · Parte 02 · ← Anterior · Siguiente →

Parte 02 — Modelado conceptual y requisitos · Fundamentos · 3 horas estimadas · motores postgresql, sqlite · laboratorio labs/01-sql-foundations · 3 fuentes.

Conceptos centrales: clave candidata · clave primaria · clave sustituta · identidad estable

En este caso se comparan 6 motores: 4 lo resuelven (4 con el resultado comprobado por máquina) y 2 no, con el motivo escrito.

De qué trata esta clase

El debate entre clave natural y clave sustituta resuelto por un criterio y no por preferencia: cuál de las dos mantiene la identidad estable cuando el mundo cambia. La conclusión práctica —usar sustituta y proteger además la natural con UNIQUE— se aplica al resto del programa.

flowchart LR
    C["🗄️ Clase 017"]
    C --> K1["clave candidata"]
    C --> K2["clave primaria"]
    C --> K3["clave sustituta"]
    C --> K4["identidad estable"]
    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
007 La clave primaria: cómo se distingue una fila de otra clave primaria · clave natural · clave sustituta · clave compuesta · UNIQUE
016 Entidad-relación, cardinalidad y participación entidad débil · cardinalidad · participación total · atributo de relación

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.

Término Qué significa Procedencia
clave candidata Cualquier conjunto mínimo de atributos que identifica unívocamente una fila. Una tabla puede tener varias; elegir una como primaria no anula a las demás, que deben seguir protegidas con UNIQUE. se introduce aquí
clave primaria La clave candidata elegida para identificar cada fila: única, no nula y estable en el tiempo. Es la dirección por la que el resto del esquema se referirá a esa fila. se introdujo en la 007
clave sustituta Identificador inventado por el sistema y sin significado externo: entero autoincremental, UUID. No cambia nunca porque no depende del mundo, a costa de necesitar además una restricción UNIQUE sobre la clave natural real. se introdujo en la 007
identidad estable La propiedad de que el identificador de una fila no cambie mientras la fila represente la misma cosa. Es el criterio real del debate entre clave natural y sustituta: no cuál es más elegante, sino cuál sobrevive a los cambios del mundo. se introduce aquí

Propósito

Decidir cómo se identifica una fila. Es la decisión de modelado más difícil de revertir: cambiar una clave primaria a los tres años obliga a tocar todas las tablas que la referencian y todos los sistemas que la almacenaron.

Resultados de aprendizaje

Al terminar podrás:

  1. Distinguir superclave, clave candidata, clave primaria y clave alternativa.
  2. Aplicar los tres criterios de una buena clave: unicidad, no nulidad e inmutabilidad.
  3. Argumentar a favor y en contra de la clave sustituta con casos concretos.
  4. Conservar las claves naturales como restricciones UNIQUE aunque no sean primarias.
  5. Elegir entre entero secuencial y UUID con un criterio de rendimiento, no de gusto.

Fundamentos

Vocabulario preciso

Término Definición Ejemplo en el dominio
Superclave Conjunto de atributos que identifica unívocamente (id, nombre)
Clave candidata Superclave mínima: quitarle un atributo destruye la unicidad (id), (email)
Clave primaria La candidata elegida como identificador oficial (id)
Clave alternativa Las candidatas no elegidas (email) con UNIQUE
Clave sustituta Valor sin significado de negocio, generado por el sistema id autoincremental
Clave natural Valor con significado de negocio RUT, ISBN, código de curso

Date subraya un punto que se pierde: elegir una clave primaria no autoriza a olvidar las demás candidatas. Si email identifica de forma única, esa restricción debe declararse con UNIQUE aunque la primaria sea id. Omitirla convierte una regla del dominio en un accidente.

Los tres criterios

Una clave debe ser:

  1. Única en todo el conjunto, no «casi siempre distinta».
  2. No nula, siempre conocida en el momento de insertar.
  3. Inmutable: si cambia, deja de identificar a la misma cosa.

El tercero es el que descarta casi todas las claves naturales. El correo cambia. El nombre cambia. El RUT se corrige por errores de digitación. El código de producto se reestructura cuando el catálogo crece. Karwin cataloga como antipatrón usar como clave un valor que el negocio puede reasignar.

El caso a favor de la sustituta

El caso en contra

Entero secuencial frente a UUID

Aspecto Entero secuencial UUID v4 UUID v7 / ULID
Tamaño 4–8 bytes 16 bytes 16 bytes
Localidad de inserción en B-Tree Excelente (siempre al final) Mala (inserciones dispersas) Buena (prefijo temporal)
Generable en el cliente No
Filtra información Sí: revela volumen y orden No Revela el instante
Colisión entre sistemas Segura Improbable Improbable

El punto no obvio es el de la localidad. Un B-Tree con claves crecientes concentra las inserciones en la página más a la derecha, que está en memoria. Con UUID v4, cada inserción cae en una página distinta al azar: con una tabla mayor que el buffer, cada inserción puede convertirse en una lectura de disco más una escritura. Es la razón técnica —no estética— por la que UUID v7 existe.

flowchart TD
    A["¿Hay un atributo natural<br/>único, no nulo e inmutable?"] -->|"No"| S["Clave sustituta"]
    A -->|"Sí"| B{"¿Es estrecho y estable<br/>ante cambios legales<br/>o comerciales?"}
    B -->|"No"| S
    B -->|"Sí"| C{"¿Se referencia desde<br/>muchas tablas?"}
    C -->|"Sí"| S
    C -->|"No"| N["Clave natural"]
    S --> U["Declarar SIEMPRE la clave<br/>natural como UNIQUE"]
    N --> U
    U --> V{"¿Se generan filas en<br/>varios sistemas a la vez?"}
    V -->|"Sí"| W["UUID v7 / ULID"]
    V -->|"No"| X["Entero secuencial"]

Ejemplo trabajado

Modelamos estudiantes con RUT chileno.

Opción A — RUT como clave primaria:

CREATE TABLE students (
  rut    TEXT PRIMARY KEY,
  nombre TEXT NOT NULL
);
CREATE TABLE enrollments (
  student_rut TEXT REFERENCES students(rut),
  course_id   INTEGER,
  nota        NUMERIC(2,1)
);

Falla los tres criterios en distinto grado:

Además, cada fila de enrollments almacena una cadena de ~12 bytes en lugar de 4. Con 2 millones de inscripciones: 24 MB frente a 8 MB solo en esa columna, replicado en cada índice que la incluya.

Opción B — sustituta con natural preservada:

CREATE TABLE students (
  id     INTEGER PRIMARY KEY,
  rut    TEXT UNIQUE,                    -- clave candidata, admite nulo transitorio
  nombre TEXT NOT NULL
);
CREATE TABLE enrollments (
  student_id INTEGER NOT NULL REFERENCES students(id),
  course_id  INTEGER NOT NULL REFERENCES courses(id),
  nota       NUMERIC(2,1),
  PRIMARY KEY (student_id, course_id)
);

Corregir un RUT es ahora un UPDATE de una fila. La regla de negocio «no hay dos estudiantes con el mismo RUT» sigue vigente porque está declarada con UNIQUE, no porque sea la primaria.

Obsérvese la decisión en enrollments: la clave primaria es natural compuesta, no sustituta. Aquí sí lo es, porque (student_id, course_id) es única, no nula, inmutable y ya está presente; añadir un enrollment_id sería una columna que nadie consulta y un índice más que mantener.

Comparación

Escenario Elección defendible Motivo
Entidad de negocio central Sustituta + natural UNIQUE Las naturales de negocio mutan
Tabla puente sin atributos propios Natural compuesta Ya es única e inmutable
Catálogo estable normalizado (ISO, monedas) Natural El código es el estándar y no cambia
Datos generados en varios nodos UUID v7 / ULID Evita coordinación para asignar identificadores
Tabla de eventos de alto volumen Entero secuencial Localidad de inserción en el índice

Errores frecuentes

  1. Poner una sustituta y olvidar el UNIQUE natural. Es la causa número uno de duplicados lógicos en producción.
  2. Exponer la clave sustituta secuencial en URL públicas. Revela volumen (/pedido/1043 dice cuántos pedidos hay) y facilita el conteo por terceros.
  3. Usar UUID v4 como clave primaria agrupada en tablas grandes. Degrada la inserción por pérdida de localidad; v7 resuelve el problema sin renunciar a generar en el cliente.
  4. Claves compuestas de cuatro o cinco columnas. Se copian enteras en cada índice secundario y en cada clave foránea.
  5. Reutilizar identificadores de filas borradas. Rompe cualquier referencia externa, informe histórico o registro de auditoría.

De la clase a la operación

Un cambio de clave primaria en un sistema con integraciones no es una migración: es una negociación con cada consumidor externo que guardó ese identificador. Elegir bien al principio cuesta una tarde; elegir mal cuesta un trimestre.

Reto de transferencia

  1. Localiza en un esquema real una clave primaria que pueda cambiar por decisión de negocio.
  2. Documenta qué tablas y qué sistemas externos se verían afectados por ese cambio.
  3. Propón la migración a sustituta conservando la natural como UNIQUE.
  4. Estima el ahorro o el costo en bytes de índice con el volumen real de tus datos.

Preguntas de evaluación

  1. Da una clave natural de tu dominio que cumpla los tres criterios y justifica por qué la elegirías.
  2. ¿Por qué el ancho de la clave primaria afecta al tamaño de índices que no la incluyen explícitamente?
  3. Explica el problema de localidad de UUID v4 con una traza de inserciones sobre un B-Tree.
  4. En enrollments se eligió clave natural compuesta. Da un requisito futuro que obligaría a añadir una sustituta.

🌐 El mismo problema en cada motor

Caso: Cambiar el correo de un estudiante sin romper lo que apunta a él

El correo parece una buena clave: es único y ya identifica a la persona. Hasta que alguien lo cambia. Con clave sustituta, cambiar el correo es actualizar una fila y ninguna referencia se entera; con clave natural, hay que propagar el cambio a toda tabla que lo hubiera copiado.

El caso hace justo eso: Ada cambia de ada@example.org a ada@nuevo.org y, después del cambio, la consulta devuelve por estudiante su correo actual y cuántas inscripciones conserva. Si la identidad estaba bien modelada, Ada sigue teniendo sus dos inscripciones.

Salida esperada, idéntica en todos los motores que lo resuelven:

correo inscripciones
ada@nuevo.org 2
grace@example.org 0
linus@example.org 1

El contrato vive en motores.yaml y lo comprueba python scripts/verificar_equivalencia.py --clase 017: 4 de las 4 implementaciones se ejecutan de verdad y su resultado se compara con esa tabla; el resto se declara como material revisado, no ejecutado.

Motor ¿Resuelve el caso? Nivel de prueba Código Fuente
SQLite núcleo código doc oficial
DuckDB núcleo código doc oficial
PostgreSQL servicio código doc oficial
MongoDB servicio código doc oficial
Apache Cassandra no doc oficial
Redis no doc oficial

Los que resuelven el caso

SQLite · implementaciones/sqlite/consulta.sql

verificado — se ejecuta en CI sin servicios

-- motor: sqlite
-- doc: https://sqlite.org/lang_createtable.html
-- nota: INTEGER PRIMARY KEY es un alias del rowid interno, asi que la clave
--       sustituta no ocupa una columna adicional.

-- === preparacion ===
-- La identidad es el id: estable, sin significado y nunca visible para el
-- usuario. El correo es un ATRIBUTO unico, no la identidad.
CREATE TABLE estudiantes (
    id     INTEGER PRIMARY KEY,
    correo TEXT NOT NULL UNIQUE
);
CREATE TABLE inscripciones (
    estudiante_id INTEGER NOT NULL REFERENCES estudiantes(id),
    curso         TEXT NOT NULL,
    PRIMARY KEY (estudiante_id, curso)
);

INSERT INTO estudiantes (id, correo) VALUES
    (1, 'ada@example.org'), (2, 'linus@example.org'), (3, 'grace@example.org');
INSERT INTO inscripciones (estudiante_id, curso) VALUES
    (1, 'DB-101'), (1, 'SE-201'), (2, 'DB-101');

-- El cambio que rompe los modelos con clave natural: una fila, ninguna
-- referencia tocada. Con el correo como clave foranea, habria que propagarlo a
-- inscripciones y a toda tabla que lo hubiera copiado.
UPDATE estudiantes SET correo = 'ada@nuevo.org' WHERE id = 1;

-- === consulta ===
SELECT e.correo,
       COUNT(i.curso) AS inscripciones
FROM estudiantes e
LEFT JOIN inscripciones i ON i.estudiante_id = e.id
GROUP BY e.id, e.correo
ORDER BY e.correo;

DuckDB · implementaciones/duckdb/consulta.sql

verificado — se ejecuta en CI sin servicios

-- motor: duckdb
-- doc: https://duckdb.org/docs/stable/sql/statements/create_sequence.html
-- nota: util para el argumento numerico de la discusion: contar cuantas filas
--       habria que tocar si el correo fuera la clave foranea.

-- === preparacion ===
-- La identidad es el id: estable, sin significado y nunca visible para el
-- usuario. El correo es un ATRIBUTO unico, no la identidad.
CREATE TABLE estudiantes (
    id     INTEGER PRIMARY KEY,
    correo VARCHAR NOT NULL UNIQUE
);
CREATE TABLE inscripciones (
    estudiante_id INTEGER NOT NULL,
    curso         VARCHAR NOT NULL,
    PRIMARY KEY (estudiante_id, curso)
);

INSERT INTO estudiantes (id, correo) VALUES
    (1, 'ada@example.org'), (2, 'linus@example.org'), (3, 'grace@example.org');
INSERT INTO inscripciones (estudiante_id, curso) VALUES
    (1, 'DB-101'), (1, 'SE-201'), (2, 'DB-101');

-- El cambio que rompe los modelos con clave natural: una fila, ninguna
-- referencia tocada. Con el correo como clave foranea, habria que propagarlo a
-- inscripciones y a toda tabla que lo hubiera copiado.
UPDATE estudiantes SET correo = 'ada@nuevo.org' WHERE id = 1;

-- === consulta ===
SELECT e.correo,
       COUNT(i.curso) AS inscripciones
FROM estudiantes e
LEFT JOIN inscripciones i ON i.estudiante_id = e.id
GROUP BY e.id, e.correo
ORDER BY e.correo;

PostgreSQL · implementaciones/postgresql/consulta.sql

verificado — se ejecuta contra el motor real levantado con docker compose

-- motor: postgresql
-- doc: https://www.postgresql.org/docs/current/ddl-identity-columns.html
-- nota: las dos claves conviven, y las dos hacen falta: IDENTITY da la
--       identidad estable a la que apuntan las referencias, y UNIQUE sobre el
--       correo impide dos personas con el mismo correo.

-- === preparacion ===
DROP TABLE IF EXISTS inscripciones, estudiantes;

CREATE TABLE estudiantes (
    id     integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    correo text NOT NULL UNIQUE
);
CREATE TABLE inscripciones (
    estudiante_id integer NOT NULL REFERENCES estudiantes(id),
    curso         text NOT NULL,
    PRIMARY KEY (estudiante_id, curso)
);

INSERT INTO estudiantes (correo) VALUES
    ('ada@example.org'), ('linus@example.org'), ('grace@example.org');
INSERT INTO inscripciones (estudiante_id, curso)
SELECT id, 'DB-101' FROM estudiantes WHERE correo = 'ada@example.org'
UNION ALL
SELECT id, 'SE-201' FROM estudiantes WHERE correo = 'ada@example.org'
UNION ALL
SELECT id, 'DB-101' FROM estudiantes WHERE correo = 'linus@example.org';

UPDATE estudiantes SET correo = 'ada@nuevo.org' WHERE correo = 'ada@example.org';

-- === consulta ===
SELECT e.correo,
       COUNT(i.curso) AS inscripciones
FROM estudiantes e
LEFT JOIN inscripciones i ON i.estudiante_id = e.id
GROUP BY e.id, e.correo
ORDER BY e.correo;

MongoDB · implementaciones/mongodb/consulta.js

verificado — se ejecuta contra el motor real levantado con docker compose

// motor: mongodb
// doc: https://www.mongodb.com/docs/manual/core/document/
// nota: el _id es inmutable. Si se hubiera usado el correo como _id, este
//       cambio no seria un update: seria borrar el documento y crear otro.

// === preparacion ===
db.estudiantes.drop();
db.inscripciones.drop();

db.estudiantes.insertMany([
  { _id: 1, correo: "ada@example.org" },
  { _id: 2, correo: "linus@example.org" },
  { _id: 3, correo: "grace@example.org" },
]);
db.estudiantes.createIndex({ correo: 1 }, { unique: true });
db.inscripciones.insertMany([
  { estudiante_id: 1, curso: "DB-101" },
  { estudiante_id: 1, curso: "SE-201" },
  { estudiante_id: 2, curso: "DB-101" },
]);

// Una sola escritura, y ninguna inscripcion se entera.
db.estudiantes.updateOne({ _id: 1 }, { $set: { correo: "ada@nuevo.org" } });

// === consulta ===
db.estudiantes
  .aggregate([
    { $lookup: { from: "inscripciones", localField: "_id",
                 foreignField: "estudiante_id", as: "i" } },
    { $project: { _id: 0, correo: 1, inscripciones: { $size: "$i" } } },
    { $sort: { correo: 1 } },
  ])
  .forEach((d) => print(d.correo + "|" + d.inscripciones));

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 La clave primaria decide en qué nodo vive la fila, así que no se puede actualizar: cambiar el correo cuando el correo es la clave de partición significa insertar la fila nueva y borrar la vieja, con todo lo que cuelgue de ella. Usar un identificador estable (UUID) como clave de partición y mantener una tabla estudiante_por_correo como índice de búsqueda, aceptando el trabajo de mantener las dos. doc
Redis La clave de Redis es literalmente la ruta de acceso: si el correo forma parte del nombre de la clave, cambiarlo obliga a renombrar y a corregir todas las referencias, que es la versión más cruda del problema de la clave natural. Nombrar las claves por identificador (estudiante:42) y mantener un índice aparte (correo:ada@nuevo.org -> 42), que es exactamente la distinción entre identidad y atributo que enseña esta clase. doc

Laboratorio

python scripts/validate_repository.py
python labs/01-sql-foundations/run_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.


Programa · Parte 02 · ← Anterior · Siguiente →