Una tabla creció a cientos de millones de filas: ¿cómo la particionas?
El particionamiento divide una tabla inmanejable en varias tablas manejables detrás de un solo nombre lógico — todo el arte está en elegir una clave de partición que coincida con cómo se consulta realmente la tabla.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
Una tabla creció a cientos de millones de filas: ¿cómo la particionas?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Por qué particionar
Una vez que una tabla cruza hacia los cientos de millones de filas, varias cosas se degradan sin importar qué tan bien indexada esté:
- Mantenimiento de índices —
VACUUM/autovacuum en Postgres, uOPTIMIZE/reconstrucción de índices en otros motores, tardan más y compiten por I/O con el tráfico de producción a medida que la tabla (y sus índices) crecen. - Planificación de consultas y localidad de caché — incluso una búsqueda indexada paga más una vez que el propio índice ya no cabe cómodamente en memoria; un scan completo para una consulta sin índice o de baja selectividad se vuelve brutalmente caro.
- Operaciones masivas y ciclo de vida de los datos — "borrar todo lo anterior a 2 años" contra una tabla sin particionar significa un
DELETElento, con contención de locks, que escanea y borra fila por fila; contra una tabla particionada puede significar eliminar una partición, algo casi instantáneo. - Radio de impacto del mantenimiento — reconstruir un índice en una partición no bloquea ni escanea las otras 99.
El particionamiento resuelve las cuatro cosas dividiendo físicamente la tabla en sub-tablas más pequeñas (particiones) que se consultan, indexan y mantienen en gran medida de forma independiente, mientras siguen siendo accesibles a través de un solo nombre de tabla lógico.
#Las tres estrategias
- Particionamiento por rango (range) — las particiones se definen por rangos de valores de una columna, lo más común un timestamp. Ideal para datos tipo series de tiempo/eventos/logs, donde las consultas y la retención están naturalmente acotadas por tiempo ("dame los pedidos de la semana pasada", "borra todo lo anterior a 90 días").
- Particionamiento por hash — una función hash asigna cada fila a una de N particiones, distribuyendo las filas de forma aproximadamente uniforme sin importar la distribución real de la clave. Ideal cuando no hay un rango natural y el objetivo es puramente repartir la carga/tamaño de forma pareja (por ejemplo, particionar por
user_idcuando no hay estructura de tiempo o categoría que explotar). - Particionamiento por lista (list) — las particiones se definen por un conjunto explícito de valores discretos, típicamente una categoría, región o columna de estado/tenant. Ideal cuando el patrón de consulta es "dame todo para region = 'EU'" y el conjunto de valores es conocido y pequeño.
#Ejemplo de particionamiento por rango (Postgres, series de tiempo)
CREATE TABLE events (id BIGINT GENERATED ALWAYS AS IDENTITY,occurred_at TIMESTAMPTZ NOT NULL,user_id BIGINT NOT NULL,event_type TEXT NOT NULL,payload JSONB NOT NULL,PRIMARY KEY (id, occurred_at) -- la clave de partición debe formar parte de cualquier PK/unique) PARTITION BY RANGE (occurred_at);CREATE TABLE events_2026_01 PARTITION OF eventsFOR VALUES FROM ('2026-01-01') TO ('2026-02-01');CREATE TABLE events_2026_02 PARTITION OF eventsFOR VALUES FROM ('2026-02-01') TO ('2026-03-01');-- Cada partición tiene sus propios índices locales — el planner poda a solo-- las particiones que se solapan con el rango de fechas de la consulta antes de tocar ningún índice.CREATE INDEX idx_events_2026_01_user ON events_2026_01 (user_id);CREATE INDEX idx_events_2026_02_user ON events_2026_02 (user_id);-- La retención se vuelve una operación de metadatos en vez de un DELETE fila por fila.DROP TABLE events_2026_01; -- o: ALTER TABLE events DETACH PARTITION events_2026_01;
#Ejemplo de particionamiento por hash
CREATE TABLE sessions (id UUID NOT NULL,user_id BIGINT NOT NULL,data JSONB NOT NULL,PRIMARY KEY (id)) PARTITION BY HASH (id);CREATE TABLE sessions_p0 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 0);CREATE TABLE sessions_p1 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 1);CREATE TABLE sessions_p2 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 2);CREATE TABLE sessions_p3 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 3);
No hay un rango o categoría con sentido en un ID de sesión, así que el hashing reparte las filas (y el tamaño de su almacenamiento/índices) de forma pareja entre cuatro particiones, con el único fin de que ninguna partición se vuelva una tabla caliente y sobredimensionada por sí sola.
#Ejemplo de particionamiento por lista
CREATE TABLE shipments (id BIGINT GENERATED ALWAYS AS IDENTITY,region TEXT NOT NULL,status TEXT NOT NULL,weight_kg NUMERIC NOT NULL,PRIMARY KEY (id, region)) PARTITION BY LIST (region);CREATE TABLE shipments_eu PARTITION OF shipments FOR VALUES IN ('EU');CREATE TABLE shipments_us PARTITION OF shipments FOR VALUES IN ('US');CREATE TABLE shipments_apac PARTITION OF shipments FOR VALUES IN ('APAC');
Así es también como se satisface físicamente un requisito de residencia de datos: la partición shipments_eu puede vivir en almacenamiento ubicado en la UE mientras la tabla lógica sigue siendo un solo nombre que la aplicación consulta normalmente.
#Elegir la clave de partición: todo el juego está aquí
La clave de partición debe coincidir con el patrón dominante de consulta y ciclo de vida de la tabla, o el particionamiento activamente perjudica:
- Si la mayoría de las consultas filtran por
occurred_atpero particionaste por hash deuser_id, cada consulta sigue teniendo que escanear todas las particiones (sin poda), y agregaste overhead sin ningún beneficio. - Toda restricción unique y toda primary key deben incluir la clave de partición (visible arriba:
PRIMARY KEY (id, occurred_at)) — esto es una restricción de diseño real, no una formalidad, y a veces obliga a subir a la PK una columna que no estaba ahí antes, rompiendo suposiciones en otras partes. - Las consultas entre particiones (una consulta que no puede podarse a una o pocas particiones) no desaparecen — simplemente ahora tocan los índices de N sub-tablas en vez de uno, lo cual puede ser más lento que una tabla sin particionar para ciertos patrones de acceso.
- Tener demasiadas particiones pequeñas tiene su propio costo: el tiempo de planificación de consultas crece con el número de particiones, y cada partición carga overhead fijo (su propio archivo, sus propios índices, su propia contabilidad de autovacuum) — un modo de falla común es particionar por día una tabla que solo recibe unos cientos de filas al día, produciendo miles de particiones diminutas sin ningún beneficio frente a una granularidad mensual.
#Migrar una tabla gigante existente a particiones (sin horas de downtime)
- Crear la nueva tabla particionada (mismas columnas,
PARTITION BY ...) junto a la existente. - Rellenar los datos históricos por lotes (por mes, por rango de ID), fuera de hora pico, monitoreando el atraso de replicación y la contención de locks en el camino.
- Agregar un mecanismo para mantener sincronizadas ambas tablas durante el hueco entre el inicio del backfill y el corte final — escrituras dobles desde la aplicación, un trigger, o replicación lógica/CDC desde la tabla vieja hacia la nueva.
- Una vez al día, hacer un corte breve: detener las escrituras un momento, aplicar el último delta y renombrar las tablas (
events→events_old,events_new→events) dentro de una transacción. - Conservar la tabla vieja intacta durante una ventana de rollback antes de eliminarla.