PostgreSQL y particionamiento de tablas: estrategias para series temporales y grandes volúmenes de datos
Introducción: cuándo el particionamiento es necesario y cuándo resulta perjudicial
El particionamiento de tablas en PostgreSQL es una de las herramientas más potentes para trabajar con grandes volúmenes de datos. Sin embargo, su aplicación no siempre está justificada. Antes de tomar una decisión, es importante entender el contexto.
El particionamiento es necesario cuando:
La tabla contiene cientos de millones de filas o más, y las consultas siempre filtran datos por rango (tiempo, región, categoría).
Es necesario eliminar regularmente datos obsoletos — por ejemplo, conservar solo los últimos 90 días de logs.
Distintas partes de la tabla tienen diferente "temperatura" de acceso: los datos recientes se leen con frecuencia, los antiguos raramente o nunca.
VACUUM y ANALYZE sobre una tabla monolítica consumen demasiado tiempo y bloquean las operaciones.
El particionamiento resulta perjudicial cuando:
La tabla es pequeña (hasta 10–50 millones de filas con carga típica) — la sobrecarga del planificador superará el beneficio.
Las consultas no filtran datos por la clave de particionamiento — el planificador escaneará todas las particiones (partition fan-out).
La aplicación utiliza activamente ON CONFLICT (UPSERT) — en tablas particionadas esto funciona con restricciones.
Se requieren índices únicos globales que no incluyan la clave de particionamiento.
En este artículo analizaremos estrategias avanzadas de particionamiento en PostgreSQL 15/16 para almacenar series temporales, logs de eventos y datos analíticos, exploraremos la integración con Laravel y compararemos el rendimiento real.
Tipos de particionamiento en PostgreSQL: RANGE, LIST, HASH
PostgreSQL admite tres estrategias principales de particionamiento declarativo, introducido en la versión 10 y mejorado significativamente en las versiones 11–16.
RANGE — particionamiento por rango
El enfoque más común para series temporales. Cada partición almacena las filas cuyo valor de clave cae dentro de un rango determinado.
CREATE TABLE events (
id BIGSERIAL,
occurred_at TIMESTAMPTZ NOT NULL,
user_id BIGINT,
event_type VARCHAR(64),
payload JSONB
) PARTITION BY RANGE (occurred_at);
Casos de uso de RANGE: series temporales (métricas, logs, transacciones), datos con progresión temporal o numérica natural, tablas con política de retención por fecha.
LIST — particionamiento por lista de valores
Cada partición contiene filas con valores específicos de la clave, tomados de una lista predefinida.
CREATE TABLE orders (
id BIGSERIAL,
region VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ,
amount NUMERIC(12,2)
) PARTITION BY LIST (region);
CREATE TABLE orders_eu PARTITION OF orders FOR VALUES IN ('EU', 'UK', 'DE');
CREATE TABLE orders_us PARTITION OF orders FOR VALUES IN ('US', 'CA');
CREATE TABLE orders_apac PARTITION OF orders FOR VALUES IN ('JP', 'AU', 'SG');
Casos de uso de LIST: sistemas multiinquilino (partition per tenant), sharding geográfico, datos con un número reducido de categorías discretas.
HASH — particionamiento por hash
Las filas se distribuyen entre particiones de forma uniforme según el hash de la clave. Se utiliza cuando no existe un rango natural ni una lista de valores, pero se necesita distribuir la carga horizontalmente.
CREATE TABLE user_activity (
user_id BIGINT NOT NULL,
activity_at TIMESTAMPTZ,
action VARCHAR(128)
) PARTITION BY HASH (user_id);
CREATE TABLE user_activity_0 PARTITION OF user_activity
FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE user_activity_1 PARTITION OF user_activity
FOR VALUES WITH (MODULUS 4, REMAINDER 1);
-- etc.
Comparativa de estrategias
RANGE: la mejor opción para series temporales, partition pruning eficiente por rango de fechas, eliminación sencilla de particiones antiguas.
LIST: eficaz al filtrar por categoría, pero requiere un conjunto de valores conocido de antemano; escala mal con un gran número de valores únicos.
HASH: distribución uniforme de datos, pero el partition pruning solo funciona con igualdad exacta de la clave, sin una forma sencilla de archivar.
Particionamiento por tiempo: ejemplo práctico con una tabla de eventos
Veamos un ejemplo completo de creación de una tabla de eventos particionada con particiones mensuales.
-- Creamos la tabla padre
CREATE TABLE events (
id BIGSERIAL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
user_id BIGINT NOT NULL,
event_type VARCHAR(64) NOT NULL,
session_id UUID,
payload JSONB,
PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);
-- Creamos las particiones manualmente
CREATE TABLE events_2025_01 PARTITION OF events
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE events_2025_02 PARTITION OF events
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
CREATE TABLE events_2025_03 PARTITION OF events
FOR VALUES FROM ('2025-03-01') TO ('2025-04-01');
-- Partición DEFAULT para datos fuera de los rangos definidos
CREATE TABLE events_default PARTITION OF events DEFAULT;
Nota importante sobre PRIMARY KEY: en tablas particionadas, la clave primaria debe incluir obligatoriamente la clave de particionamiento. Esta es una restricción de PostgreSQL relacionada con la ausencia de índices únicos globales.
Ahora creamos índices en cada partición (o en la tabla padre — a partir de PostgreSQL 11, los índices en el padre se propagan automáticamente a las particiones hijas):
-- Índice en la tabla padre (PostgreSQL 11+)
CREATE INDEX idx_events_user_time ON events (user_id, occurred_at DESC);
CREATE INDEX idx_events_type ON events (event_type, occurred_at DESC);
Creación automática de particiones: pg_partman y automatización manual
pg_partman — extensión para gestión de particiones
pg_partman es una extensión de PostgreSQL que automatiza la creación y eliminación de particiones según un calendario. Admite particionamiento RANGE por tiempo y rangos numéricos.
-- Instalación de la extensión
CREATE EXTENSION pg_partman SCHEMA partman;
-- Configuración de la gestión automática de particiones
SELECT partman.create_parent(
p_parent_table => 'public.events',
p_control => 'occurred_at',
p_type => 'native',
p_interval => 'monthly',
p_premake => 3 -- crear 3 particiones por adelantado
);
-- Actualización de la configuración
UPDATE partman.part_config
SET retention = '12 months',
retention_keep_table = false,
infinite_time_partitions = true
WHERE parent_table = 'public.events';
-- Ejecutar mantenimiento (normalmente vía cron o pg_cron)
SELECT partman.run_maintenance();
pg_partman se integra con pg_cron para un ciclo de vida completamente automático de las particiones:
SELECT cron.schedule('partman-maintenance', '0 * * * *',
'SELECT partman.run_maintenance(p_analyze := false)');
Automatización manual mediante PL/pgSQL
Si la instalación de extensiones está limitada (por ejemplo, en PostgreSQL gestionado en la nube), es posible implementar la automatización manualmente:
CREATE OR REPLACE FUNCTION create_monthly_partition(
p_table TEXT,
p_date DATE
) RETURNS VOID AS $$
DECLARE
partition_name TEXT;
start_date DATE;
end_date DATE;
BEGIN
start_date := DATE_TRUNC('month', p_date)::DATE;
end_date := (start_date + INTERVAL '1 month')::DATE;
partition_name := p_table || '_' || TO_CHAR(start_date, 'YYYY_MM');
EXECUTE FORMAT(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF %I
FOR VALUES FROM (%L) TO (%L)',
partition_name, p_table, start_date, end_date
);
RAISE NOTICE 'Created partition: %', partition_name;
END;
$$ LANGUAGE plpgsql;
-- Crear particiones para los próximos 3 meses
SELECT create_monthly_partition('events', (NOW() + (i || ' months')::INTERVAL)::DATE)
FROM GENERATE_SERIES(0, 2) AS i;
Partition Pruning: cómo el planificador de PostgreSQL utiliza las particiones
El partition pruning es el mecanismo por el cual el planificador de consultas excluye del plan de ejecución las particiones irrelevantes en función de las condiciones WHERE. Es el factor clave de rendimiento en tablas particionadas.
PostgreSQL admite dos niveles de pruning:
Static pruning — exclusión de particiones en la fase de planificación (cuando los valores del WHERE se conocen en el momento del análisis de la consulta).
Dynamic pruning — exclusión de particiones durante la ejecución (para consultas parametrizadas, subplans, nested loops).
Verificación mediante EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT COUNT(*), event_type
FROM events
WHERE occurred_at BETWEEN '2025-03-01' AND '2025-03-31'
GROUP BY event_type;
Ejemplo de salida con pruning eficiente:
HashAggregate (cost=8420.50..8421.00 rows=50 width=40)
(actual time=45.123..45.198 rows=47 loops=1)
Buffers: shared hit=3841
-> Append (cost=0.00..7980.00 rows=176000 width=32)
Subplans Removed: 11 -- <-- ¡11 particiones excluidas!
-> Seq Scan on events_2025_03
(cost=0.00..3240.00 rows=176000 width=32)
Filter: ((occurred_at >= '2025-03-01') AND
(occurred_at < '2025-04-01'))
Planning Time: 2.341 ms
Execution Time: 45.891 ms
La línea Subplans Removed: 11 indica que 11 de las 12 particiones fueron descartadas por el planificador.
Aspectos clave para el partition pruning:
La condición WHERE debe utilizar directamente la clave de particionamiento.
No se debe envolver la clave en funciones:
DATE_TRUNC('month', occurred_at) = '2025-03-01'— el pruning no funcionará. Utilice rangos explícitos:occurred_at >= '2025-03-01' AND occurred_at < '2025-04-01'.El parámetro
enable_partition_pruningdebe estar activado (ON por defecto en PostgreSQL 11+).
SHOW enable_partition_pruning; -- on
SET enable_partition_pruning = on;
Índices en tablas particionadas: locales vs globales, índices parciales
En PostgreSQL 11+, al crear un índice en la tabla padre, este se crea automáticamente en todas las particiones hijas. Son índices locales — cada partición tiene su propio B-tree.
Índices locales
-- El índice en el padre crea automáticamente índices en todas las particiones
CREATE INDEX CONCURRENTLY idx_events_user_occurred
ON events (user_id, occurred_at DESC);
-- Verificar índices en las tablas hijas
SELECT schemaname, tablename, indexname
FROM pg_indexes
WHERE tablename LIKE 'events_%'
ORDER BY tablename, indexname;
Limitaciones de los índices globales
Hasta PostgreSQL 17, los índices únicos globales (que abarcan todas las particiones) no están soportados si la clave de unicidad no incluye la clave de particionamiento. Esta es una limitación fundamental. En PostgreSQL 17 se espera soporte para índices globales — esté atento a las novedades.
Índices parciales en particiones
Los índices parciales son especialmente eficientes en tablas particionadas — indexan solo un subconjunto de filas, reduciendo el tamaño del índice y acelerando las consultas sobre datos "calientes":
-- Índice parcial solo para eventos de error en una partición específica
CREATE INDEX idx_events_2025_03_errors
ON events_2025_03 (user_id, occurred_at)
WHERE event_type = 'error';
-- Índice parcial para transacciones pendientes
CREATE INDEX idx_events_pending
ON events_2025_03 (occurred_at, user_id)
WHERE payload->>'status' = 'pending';
Índices BRIN para series temporales
Para tablas con datos correlacionados (series temporales donde las filas están físicamente ordenadas por tiempo), los índices BRIN ocupan un espacio mínimo manteniendo una buena eficiencia:
CREATE INDEX idx_events_occurred_brin
ON events USING BRIN (occurred_at)
WITH (pages_per_range = 64);
Un índice BRIN para una tabla de 100 millones de filas ocupa apenas unos megabytes, frente a los gigabytes de un B-tree.
Archivado y eliminación de particiones antiguas: estrategias de retención
Una de las principales ventajas del particionamiento es la posibilidad de eliminar instantáneamente particiones completas en lugar de realizar un DELETE lento fila a fila.
Detach y Drop
-- Eliminación rápida de una partición antigua (instantánea, bloqueo mínimo)
DROP TABLE events_2024_01;
-- O bien: desconectar la partición sin eliminarla (para archivar)
ALTER TABLE events
DETACH PARTITION events_2024_01 CONCURRENTLY; -- PostgreSQL 14+
-- Tras el detach, la tabla existe como una tabla normal
-- Se puede mover a otro tablespace, comprimir, hacer dump
ALTER TABLE events_2024_01 SET TABLESPACE archive_tablespace;
Ventaja clave: DROP TABLE sobre una partición se ejecuta en milisegundos independientemente del número de filas, mientras que DELETE FROM events WHERE occurred_at < '2024-02-01' sobre 100 millones de filas puede tardar horas y genera un WAL enorme.
Estrategia de retención con automatización
CREATE OR REPLACE FUNCTION drop_old_partitions(
p_table TEXT,
p_retention_months INT DEFAULT 12
) RETURNS INT AS $$
DECLARE
rec RECORD;
dropped INT := 0;
cutoff DATE;
BEGIN
cutoff := DATE_TRUNC('month',
NOW() - (p_retention_months || ' months')::INTERVAL)::DATE;
FOR rec IN
SELECT child.relname AS partition_name
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = p_table
AND child.relname ~ ('^' || p_table || '_\\d{4}_\\d{2}$')
LOOP
-- Extraer la fecha del nombre de la partición
IF TO_DATE(
REGEXP_REPLACE(rec.partition_name,
'^.*_(\\d{4})_(\\d{2})$', '\\1-\\2-01'),
'YYYY-MM-DD'
) < cutoff THEN
EXECUTE 'DROP TABLE ' || QUOTE_IDENT(rec.partition_name);
dropped := dropped + 1;
RAISE NOTICE 'Dropped partition: %', rec.partition_name;
END IF;
END LOOP;
RETURN dropped;
END;
$$ LANGUAGE plpgsql;
-- Uso
SELECT drop_old_partitions('events', 12); -- conservar 12 meses
Estrategia de tablespace para datos fríos
En lugar de eliminarlas, las particiones antiguas pueden trasladarse a discos más lentos (y económicos) o a almacenamiento de objetos mediante extensiones como pg_tiering:
-- Crear tablespace en un disco lento / NFS
CREATE TABLESPACE cold_storage
LOCATION '/mnt/cold-data/pg';
-- Mover la partición
ALTER TABLE events_2024_01 SET TABLESPACE cold_storage;
Integración con Laravel: trabajando con tablas particionadas
Laravel y Eloquent funcionan bien con tablas particionadas de PostgreSQL — desde el punto de vista del ORM, la tabla se comporta como una tabla normal. Sin embargo, hay algunos matices a tener en cuenta.
Migraciones
El Schema Builder de Laravel no admite la sintaxis PARTITION BY, por lo que la migración debe escribirse usando SQL directo:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// Creamos la tabla particionada
DB::statement('CREATE TABLE events (
id BIGSERIAL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
user_id BIGINT NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSONB,
PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at)');
// Creamos las particiones iniciales
DB::statement("CREATE TABLE events_2025_01
PARTITION OF events
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01')");
DB::statement("CREATE TABLE events_default
PARTITION OF events DEFAULT");
// Creamos los índices
DB::statement('CREATE INDEX idx_events_user_time
ON events (user_id, occurred_at DESC)');
}
public function down(): void
{
DB::statement('DROP TABLE IF EXISTS events CASCADE');
}
};
Modelo Eloquent
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
class Event extends Model
{
protected $table = 'events';
protected $primaryKey = 'id';
public $timestamps = false;
protected $casts = [
'occurred_at' => 'datetime',
'payload' => 'array',
];
protected $fillable = [
'occurred_at', 'user_id', 'event_type', 'session_id', 'payload',
];
// Scope para aprovechar eficazmente el partition pruning
public function scopeInPeriod(Builder $query, string $from, string $to): Builder
{
// Importante: usar whereBetween u operadores explícitos,
// NO envolver en funciones como DATE_TRUNC
return $query
->where('occurred_at', '>=', $from)
->where('occurred_at', '<', $to);
}
public function scopeForUser(Builder $query, int $userId): Builder
{
return $query->where('user_id', $userId);
}
}
Consultas eficientes con Eloquent
<?php
// Consulta correcta — el partition pruning funcionará
$events = Event::inPeriod('2025-03-01', '2025-04-01')
->forUser($userId)
->select(['id', 'occurred_at', 'event_type'])
->orderBy('occurred_at', 'desc')
->limit(100)
->get();
// Agregación con pruning
$stats = Event::inPeriod('2025-03-01', '2025-04-01')
->selectRaw('event_type, COUNT(*) as cnt, DATE(occurred_at) as day')
->groupBy('event_type', 'day')
->orderBy('day', 'desc')
->get();
// Raw query para analítica avanzada
$result = DB::select("
SELECT
DATE_TRUNC('hour', occurred_at) AS hour,
event_type,
COUNT(*) AS count,
COUNT(DISTINCT user_id) AS unique_users
FROM events
WHERE occurred_at >= ? AND occurred_at < ?
AND event_type = ANY(?)
GROUP BY 1, 2
ORDER BY 1 DESC
", [
'2025-03-01',
'2025-04-01',
'{purchase,signup,error}'
]);
Caché de resultados con Redis
Para consultas analíticas sobre tablas particionadas, Redis resulta muy eficaz como caché de resultados — especialmente para datos históricos que ya no cambian:
<?php
use Illuminate\Support\Facades\Cache;
public function getHourlyStats(string $date): array
{
$cacheKey = "events:hourly:{$date}";
// Los datos históricos se cachean durante mucho tiempo
$ttl = Carbon::parse($date)->isPast() ? 86400 * 7 : 300;
return Cache::store('redis')->remember($cacheKey, $ttl, function () use ($date) {
return DB::select("
SELECT DATE_TRUNC('hour', occurred_at) AS hour,
COUNT(*) AS total
FROM events
WHERE occurred_at >= ?::DATE
AND occurred_at < (?::DATE + INTERVAL '1 day')
GROUP BY 1 ORDER BY 1
", [$date, $date]);
});
}
Redis es especialmente eficiente para las particiones "frías": los agregados calculados una sola vez se almacenan en caché durante días, eliminando completamente la carga sobre PostgreSQL.
Benchmarks: cifras reales antes y después del particionamiento
Presentamos los resultados de pruebas sobre una tabla de eventos con 500 millones de filas (PostgreSQL 16, 32 CPU, 128 GB RAM, NVMe SSD, shared_buffers = 32 GB).
Prueba 1: Agregación durante un mes (sobre 24 meses de datos)
SELECT event_type, COUNT(*)
FROM events
WHERE occurred_at BETWEEN '2025-03-01' AND '2025-03-31'
GROUP BY event_type;
Sin particionamiento: 47,3 segundos (Seq Scan, 500 M filas)
Con particionamiento RANGE (mensual): 1,2 segundos (Seq Scan solo en events_2025_03, ~21 M filas)
Con particionamiento + índice B-tree: 0,18 segundos (Index Scan)
Aceleración: ~260x
Prueba 2: Rendimiento de INSERT
-- Inserción masiva de 1 millón de filas
INSERT INTO events (occurred_at, user_id, event_type, payload)
SELECT
NOW() - (RANDOM() * INTERVAL '30 days'),
(RANDOM() * 1000000)::BIGINT,
(ARRAY['click','view','purchase','error'])[CEIL(RANDOM()*4)::INT],
'{"v": 1}'::JSONB
FROM GENERATE_SERIES(1, 1000000);
Sin particionamiento: 8,4 segundos
Con particionamiento (datos en 1 partición): 9,1 segundos (+8% de sobrecarga)
Con particionamiento (datos en 30 particiones): 11,3 segundos (+35% de sobrecarga)
Conclusión: el particionamiento ralentiza ligeramente el INSERT debido a la sobrecarga del enrutamiento de filas. En sistemas de alta carga se recomienda escribir directamente en la partición correspondiente o usar COPY.
Prueba 3: Eliminación de datos de un mes
DELETE FROM events WHERE occurred_at < '2024-02-01': 38 minutos, 12 GB de WAL
DROP TABLE events_2024_01: 0,003 segundos, WAL mínimo
Aceleración: >760.000x
Prueba 4: Tamaño de índices
B-tree en tabla monolítica (500 M filas): 18,4 GB
Suma de B-trees en 24 particiones: 18,9 GB (prácticamente idéntico)
BRIN en tabla monolítica: 47 MB
BRIN en tabla particionada: 52 MB
Los índices BRIN ofrecen un ahorro colosal con una pérdida de rendimiento mínima para consultas temporales secuenciales.
Conclusión
El particionamiento de tablas en PostgreSQL no es una solución mágica, sino una herramienta de precisión. Aplicado correctamente, proporciona una aceleración significativa de las consultas gracias al partition pruning, eliminación instantánea de datos obsoletos, un VACUUM más eficiente y la posibilidad de gestionar el almacenamiento con granularidad fina.
Conclusiones clave:
Utilice el particionamiento RANGE por tiempo para logs, métricas y datos de eventos — es el escenario más habitual y mejor optimizado en PostgreSQL.
Incluya la clave de particionamiento en todas las consultas críticas; de lo contrario, el planificador realizará un escaneo completo de todas las particiones.
pg_partman + pg_cron es el estándar de facto para automatizar el ciclo de vida de las particiones en producción.
Los índices BRIN en series temporales ahorran órdenes de magnitud de espacio en comparación con B-tree, con un rendimiento comparable para consultas de rango.
La integración con Laravel es transparente — use migraciones raw y Eloquent scopes que pasen explícitamente condiciones sobre la clave de particionamiento.
Redis complementa perfectamente las tablas particionadas para el caché de agregados sobre particiones históricas inmutables.
En un entorno de crecimiento continuo de datos en 2026, el particionamiento nativo en PostgreSQL sigue siendo una de las soluciones más rentables — antes de migrar a arquitecturas más complejas (sharding, Citus, TimescaleDB), asegúrese de haber agotado las posibilidades del particionamiento nativo.
Tecnologías
Etiquetas
Ruslan Ismailov
Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →