El modelo que creaba en 2013 es el que funciona en 2035. Las buenas prácticas de bases de datos perduran; el reto es aplicarlas correctamente.
"CRUD lo es todo. En el fondo, todo sistema crea, lee, actualiza y elimina. Quien lo domina, domina los cimientos. En TI, todo queda limitado por la parte más lenta, y esa siempre ha sido la base de datos."
Cesar Zanis
01/Un cimiento que conviene hacer bien.
PRIMARY KEY: INT/BIGINT con IDENTITY o SERIAL
La clave primaria sostiene cada tabla. Usa INT (hasta unos dos mil millones de filas) o BIGINT para volúmenes mayores, con generación automática: IDENTITY en SQL Server o SERIAL/GENERATED ALWAYS AS IDENTITY en PostgreSQL. Mantén los identificadores secuenciales internos fuera de APIs y URLs públicas. Su orden predecible ayuda al rendimiento de los B-tree, pero no debe convertirse en una suposición de control de acceso.
SQLSQL estándar
-- PostgreSQL
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id UUID NOT NULL DEFAULT gen_random_uuid(),
-- ...
CONSTRAINT uq_orders_public_id UNIQUE (public_id)
);
-- SQL Server
CREATE TABLE orders (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
public_id UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID(),
-- ...
CONSTRAINT uq_orders_public_id UNIQUE (public_id)
);
Un índice UNIQUE en public_id preserva la unicidad y separa el identificador público de la clave primaria agrupada. Así combinas acceso interno eficiente con un identificador público independiente.
02/Elige claves primarias UUID considerando el almacenamiento y los patrones de acceso.
UUID: identificador público junto a la clave primaria
Los UUID v4 son aleatorios. Como clave primaria agrupada de una tabla grande, las inserciones aleatorias pueden causar divisiones de páginas, fragmentación y mayor almacenamiento. El efecto depende del motor, los índices y la carga. Una opción es una clave INT/BIGINT ordenada junto a una columna UUID con índice UNIQUE para identificadores públicos e integraciones. También puedes evaluar UUID v7, ordenado por tiempo, cuando importe la generación distribuida de identificadores.
SQLSQL estándar
-- Ejemplo de identificadores aleatorios
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
-- Mide el patrón de inserciones, las divisiones de página y el tamaño del índice
);
-- Alternativa: clave interna secuencial y UUID público
CREATE TABLE customers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id UUID NOT NULL DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_customers_public_id UNIQUE (public_id)
);
Compara el rendimiento de los JOIN con tu carga real. BIGINT ocupa ocho bytes; UUID requiere más espacio. El diseño de índices y el plan de ejecución determinan la diferencia real.
03/Conserva el historial de negocio con una política explícita de retención.
Soft Delete: deleted_at o is_deleted
Un DELETE físico es irreversible sin mecanismos de recuperación y puede eliminar historial de negocio. Considera soft delete para registros transaccionales como pedidos, clientes y productos. Un timestamp deleted_at registra cuándo ocurrió la eliminación, a diferencia de un booleano is_deleted. Los SELECT pertinentes deben filtrar WHERE deleted_at IS NULL; centraliza la regla en vistas o filtros del ORM/repositorio. Soft delete no sustituye los plazos legales de retención ni la eliminación efectiva cuando corresponda.
SQLSQL estándar
-- Ejemplo de esquema
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id UUID NOT NULL DEFAULT gen_random_uuid(),
name VARCHAR(300) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL, -- NULL = activo, NOT NULL = eliminado
CONSTRAINT uq_products_public_id UNIQUE (public_id)
);
-- Índice parcial para registros activos (PostgreSQL)
-- Excluye los registros eliminados de este índice
CREATE INDEX ix_products_name_active
ON products (name)
WHERE deleted_at IS NULL;
-- Consulta habitual: incluye siempre el filtro
SELECT * FROM products WHERE deleted_at IS NULL AND name ILIKE '%notebook%';
Los índices filtrados o parciales con WHERE deleted_at IS NULL pueden reducir el tamaño del índice cuando hay muchos registros eliminados lógicamente. Revisa el plan de ejecución antes de adoptarlos.
04/Un índice inadecuado puede costar más de lo que ahorra.
Índices: propósito, momento y costo
Los índices aceleran las lecturas a cambio de escrituras y almacenamiento. Cada índice adicional debe mantenerse durante INSERT, UPDATE y DELETE. Evalúa la frecuencia de consulta, la selectividad y el plan de ejecución. Una columna de baja cardinalidad puede no beneficiarse de un índice simple, aunque uno filtrado o compuesto sí puede ayudar. Los índices de cobertura con INCLUDE pueden evitar accesos adicionales a la tabla si contienen todas las columnas necesarias.
SQLSQL estándar
-- Un índice simple puede exigir búsquedas para otras columnas
CREATE INDEX ix_orders_customer_id ON orders (customer_id);
-- Índice de cobertura: incluye las columnas que necesita esta consulta
CREATE INDEX ix_orders_customer_id_cover
ON orders (customer_id)
INCLUDE (status, total_amount, created_at);
-- Comprueba si el plan de ejecución utiliza el índice de cobertura:
SELECT status, total_amount, created_at
FROM orders
WHERE customer_id = 42 AND deleted_at IS NULL;
-- Índice compuesto: importa el orden de las columnas
-- Elige el orden según los predicados y el plan de ejecución
CREATE INDEX ix_orders_status_created
ON orders (status, created_at DESC)
WHERE deleted_at IS NULL;
No añadas índices a columnas booleanas o de baja cardinalidad por costumbre. Valida su utilidad con planes de ejecución y datos representativos.
05/Una base de datos sin mantenimiento acumula deuda técnica silenciosa.
Rutinas de Mantenimiento (PostgreSQL)
PostgreSQL usa MVCC: las actualizaciones crean nuevas versiones de fila y las obsoletas necesitan limpieza. Las tablas con muchas actualizaciones, como colas y sesiones, pueden acumular tuplas muertas si VACUUM se retrasa. Autovacuum gestiona muchos casos, pero las tablas críticas necesitan supervisión y ajustes. ANALYZE mantiene actualizadas las estadísticas para que el optimizador elija planes adecuados.
SQLSQL estándar
-- Ejemplos de mantenimiento manual: evalúa antes de ejecutar
-- Programa según la carga y las necesidades de mantenimiento
VACUUM (VERBOSE, ANALYZE) orders;
-- Consulta las tuplas muertas por tabla
SELECT
schemaname,
relname AS table_name,
n_dead_tup AS dead_tuples,
n_live_tup AS live_tuples,
ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) AS dead_pct,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY dead_pct DESC;
-- Revisa el uso de los índices antes de eliminarlos
SELECT
indexrelname AS index_name,
relname AS table_name,
idx_scan AS times_used
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelname NOT LIKE '%pkey%'
ORDER BY relname;
Considera log_min_duration_statement = 500 en PostgreSQL para registrar consultas de al menos 500 ms. Ajusta el umbral y el volumen de registros según tu entorno.
06/Revisa la fragmentación junto a la densidad de páginas y la carga.
Rutinas de Mantenimiento (SQL Server)
En SQL Server, el mantenimiento de índices debe considerar la densidad de páginas, la fragmentación, el tamaño del índice y la carga observada. No reconstruyas un índice solo por superar un porcentaje fijo. Mide si reorganizarlo o reconstruirlo mejora las consultas importantes y considera los bloqueos, el crecimiento del log y los recursos. Mantén las estadísticas actualizadas para estimar correctamente el número de filas.
SQLSQL estándar
-- Consulta la fragmentación por índice
SELECT
OBJECT_NAME(ips.object_id) AS table_name,
i.name AS index_name,
ips.avg_fragmentation_in_percent AS fragmentation_pct,
ips.page_count
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') ips
JOIN sys.indexes i ON ips.object_id = i.object_id AND ips.index_id = i.index_id
WHERE ips.avg_fragmentation_in_percent > 5
AND ips.page_count > 100
ORDER BY ips.avg_fragmentation_in_percent DESC;
-- Reorganizar: evalúa antes la carga, la densidad de páginas y los beneficios
ALTER INDEX ix_orders_customer_id ON orders REORGANIZE;
-- Reconstruir: evalúa costos y bloqueos para esta edición
ALTER INDEX ix_orders_customer_id ON orders REBUILD WITH (ONLINE = ON);
-- Actualizar estadísticas de una tabla
UPDATE STATISTICS orders WITH FULLSCAN;
-- Consultar las consultas más costosas (10 primeras por CPU)
SELECT TOP 10
qs.total_elapsed_time / qs.execution_count / 1000 AS avg_ms,
qs.execution_count,
SUBSTRING(st.text, (qs.statement_start_offset/2)+1,
((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset END - qs.statement_start_offset)/2)+1) AS query_text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
ORDER BY avg_ms DESC;
Automatiza la supervisión y programa el mantenimiento necesario fuera de horas punta. Elige la frecuencia según la carga observada y los resultados.