PostgreSQL vs RDS PostgreSQL: ajustes de rendimiento que aún debes hacer (incluso en gestionado)

¿Te fue útil?

«Base de datos gestionada» es una frase reconfortante hasta que tu latencia p95 se duplica durante una promoción, el gráfico de CPU parece un código de barras y tu equipo de producto pregunta si «puedes simplemente añadir más RAM». Puedes. Incluso puede ayudar. También puede no hacer absolutamente nada mientras el verdadero problema—bloqueos, bloat, presión de WAL o una consulta trágica única—sigue perforando tus SLOs.

RDS PostgreSQL elimina clases enteras de trabajo: cambios de hardware, parchado de versiones menores, backups automáticos y mucha tubería tediosa pero crítica. No elimina el afinamiento. No elimina la física. Definitivamente no elimina la responsabilidad.

Gestionado no significa optimizado

Ejecutar PostgreSQL por tu cuenta en EC2 o hardware físico te da perillas. Ejecutar PostgreSQL en RDS te da… menos perillas, pero aún muchas formas de hacerte daño.

La diferencia es sobre todo acerca de quién posee la plataforma:

  • PostgreSQL autogestionado: tú lo posees todo: kernel, sistema de archivos, discos, sysctls, flags de compilación de Postgres, extensiones, backups, failover, monitorización y las llamadas a las 3 a.m.
  • RDS PostgreSQL: AWS posee el host. Tú posees el comportamiento de la base de datos: esquema, consultas, índices, estrategia de autovacuum, afinamiento del parameter group, planificación de capacidad y cómo observas y respondes.

En la práctica, el afinamiento de rendimiento sigue siendo tu trabajo porque el rendimiento es una propiedad emergente de la distribución de datos, la carga de trabajo y el comportamiento de la aplicación. RDS no conoce tu carga. RDS no puede reescribir tu ORM. RDS no puede decirle a tu equipo de producto «no» cuando envían un join cartesiano disfrazado de «un endpoint de reporte rápido».

Una verdad seca: RDS hace más difícil hacer algunas optimizaciones de bajo nivel, pero también hace más fácil sobrevivir a fallos. Si quieres ser rápido y fiable, aún necesitas tratar RDS como un sistema de producción que operas—solo con menos bordes metálicos afilados expuestos.

Broma #1: La nube es solo la computadora de otra persona. RDS es la computadora de otra persona con una bonita UI y las mismas leyes de la termodinámica.

Hechos interesantes y contexto histórico

  • Las raíces de PostgreSQL se remontan a los años 80 (POSTGRES en UC Berkeley). Algunas ideas sobre durabilidad se debatían antes de que existiera tu pipeline de CI.
  • MVCC es la razón por la que las lecturas de PostgreSQL no suelen bloquear escrituras. También es la razón por la que existe vacuum y por la que «simplemente borrar filas» no es un plan de rendimiento.
  • Autovacuum no siempre existió; en las primeras versiones de Postgres se requería vacuum manual. La gente «aprendió» vacuum a la manera difícil: outages en producción.
  • PostgreSQL valora la corrección por encima del sorprendente comportamiento «rápido pero incorrecto». Eso moldea las decisiones del planner y la semántica de concurrencia.
  • RDS para PostgreSQL se lanzó en los primeros 2010s y normalizó la idea de que muchos equipos no necesitan ejecutar hosts de base de datos por su cuenta.
  • pg_stat_statements se volvió mainstream a medida que Postgres maduró hacia una base de datos orientada a la observabilidad. No ajustas lo que no puedes medir.
  • Las mejoras de la era Postgres 9.6 a 13+ cambiaron el comportamiento de vacuum, paralelismo y rendimiento de índices de formas que hacen que las «reglas antiguas» sean poco fiables.
  • Los patrones de E/S importan más que la CPU en muchos sistemas OLTP; la base de datos puede estar «idle en CPU» mientras espera almacenamiento. Los gráficos adoran mentir por omisión.
  • El afinamiento de checkpoints ha sido un tema recurrente durante dos décadas porque WAL y checkpoints son el latido de la durabilidad—y la fuente de dolor periódico.

Dónde RDS realmente ayuda—y dónde no

Qué te da RDS gratis (o casi)

RDS es legítimamente bueno en las partes de «infraestructura aburrida»:

  • Backups automatizados y flujos de trabajo de recuperación punto en el tiempo.
  • Parchado gestionado (dentro de las limitaciones de ventanas de mantenimiento y tu apetito de riesgo).
  • Multi-AZ orquestación de failover, que no es lo mismo que «sin tiempo de inactividad», pero sigue siendo valioso.
  • Métricas operativas en CloudWatch y Performance Insights, lo que te evita construir todo desde cero.
  • Opciones de escalado de almacenamiento (según el tipo de almacenamiento), reduciendo el género de incidentes «nos quedamos sin disco a las 2 a.m.».

Qué no resuelve RDS (y a veces oculta)

RDS no:

  • Arregla consultas lentas causadas por índices faltantes, mal orden de joins, estadísticas obsoletas o comportamiento de la aplicación.
  • Previene la contención de locks cuando tu migración toma un ACCESS EXCLUSIVE lock al mediodía.
  • Elimina el bloat de tablas con alto churn. MVCC aún necesita vacuum, y vacuum aún necesita margen de maniobra.
  • Hace la E/S infinita. EBS tiene límites; el throughput de almacenamiento es un recurso facturable y susceptible de saturación.
  • Elige la clase de instancia correcta para tu carga. Puedes pagar por CPU que no usas o quedarte corto en memoria que necesitas.
  • Te protege de «demasiadas conexiones» cuando tu app escala horizontalmente y cada pod abre 100 sesiones como si fuera 2009.

La diferencia más importante: en Postgres autogestionado a menudo puedes «instrumentar el host» para aprender a qué espera la base de datos. En RDS estás mayormente limitado a lo que AWS expone: métricas del engine, proxies estilo OS, logs y Performance Insights. Eso es suficiente para operar un gran sistema, pero solo si te tomas la observabilidad en serio.

El afinamiento que aún debes hacer (incluso en RDS)

1) Afinamiento de consultas: el planner no es tu enemigo, pero no es psíquico

Si no afinaste nada más, afina tus consultas. El rendimiento de PostgreSQL está dominado por paths de acceso: index scans vs sequential scans, estrategias de join y cuánto dato fuerzas al motor a tocar.

En RDS sentirás los errores de consulta más rápido porque no puedes «simplemente ajustar el kernel» o fijar procesos a CPUs. Está bien; el ajuste del kernel rara vez es tu primer cuello de botella de todos modos.

Qué hacer:

  • Habilita pg_stat_statements y trátalo como tu tabla de la vergüenza.
  • Usa EXPLAIN (ANALYZE, BUFFERS) y léelo como un informe de escena del crimen: qué tocó disco, qué no, y por qué.
  • Mantén las estadísticas frescas (ANALYZE) y no asumas que autovacuum te cubre en tablas con distribución muy sesgada.

2) Autovacuum: la diferencia entre «todo bien» y «¿por qué está lleno el disco?»

MVCC significa que las versiones antiguas de filas perduran hasta que vacuum las recupera. En RDS, vacuum sigue siendo tu trabajo para dimensionar y programar. Si autovacuum se queda atrás, no solo obtienes bloat. Obtienes:

  • Peor eficiencia de caché (más páginas, menos páginas útiles).
  • Más E/S (leer tuplas muertas sigue siendo lectura).
  • Mayor variación de latencia (vacuum compitiendo en momentos malos).
  • Riesgo potencial de wraparound de transaction ID si lo descuidas lo suficiente.

El afinamiento de autovacuum es específico de la carga. Las tablas OLTP con alto churn a menudo necesitan configuraciones por tabla. Los umbrales por defecto son educados. Tu carga de producción no lo es.

3) Memoria: shared_buffers no es una perilla mágica de velocidad

En Postgres autogestionado puedes volverte loco con el ajuste de memoria. En RDS sigues afinando parámetros de base de datos, pero también convives con las elecciones del OS de AWS. No controlas directamente el page cache; lo influyes evitando E/S tontas.

Prioridades típicas:

  • work_mem: demasiado bajo y tus sorts/hash joins despanzurran a disco; demasiado alto y la concurrencia convierte la RAM en un cráter humeante.
  • shared_buffers: en RDS a menudo viene sensatamente configurado por defecto, pero «sensato» no es «óptimo».
  • effective_cache_size: una pista para el planner; valores erróneos conducen a decisiones de plan equivocadas.

4) E/S: RDS lo hace comprable, no opcional

La mayoría de los «incidentes de rendimiento» en RDS PostgreSQL son realmente incidentes de E/S con acento SQL.

Tus elecciones centrales:

  • Tipo y dimensionamiento de almacenamiento (general purpose vs provisioned IOPS, límites de throughput, comportamiento de burst).
  • Comportamiento de checkpoints (picos de escritura vs estado estable).
  • Vacuum y mantenimiento de índices (E/S en background que se convierte en dolor en foreground).

5) WAL y checkpoints: los picos de latencia vienen en olas

Los sistemas con muchas escrituras eventualmente se encontrarán con el subsistema WAL en un callejón oscuro. Los síntomas parecen parones periódicos: commits se enlentecen, la E/S salta y el throughput cae en picado a intervalos regulares.

Qué vas a afinar:

  • max_wal_size y checkpoint_timeout para reducir la frecuencia de checkpoints.
  • checkpoint_completion_target para repartir las escrituras.
  • wal_compression y wal_buffers dependiendo de la versión/carga.

En RDS, cambias estos mediante parameter groups. También observas la replica lag: la presión de WAL se manifiesta aguas abajo.

6) Conexiones: la muerte lenta por mil sesiones

PostgreSQL usa un modelo process-per-connection. Es robusto y simple. También es caro cuando tratas la base de datos como un servidor HTTP sin estado.

En RDS, max_connections puede ser alto, pero eso no significa que deba serlo. Una tormenta de conexiones implica cambio de contexto, sobrecarga de memoria y más contención en estructuras compartidas.

Usa un pooler (PgBouncer) a menos que tengas una buena razón para no hacerlo. Sí, incluso con RDS. Especialmente con Kubernetes. Tus pods no son educados.

7) Locks y migraciones: tus cambios de esquema también son tráfico de producción

RDS no te impedirá ejecutar ALTER TABLE que bloquee escrituras durante minutos. Sin embargo, preservará fielmente el outage en los gráficos de CloudWatch para que tu yo futuro lo admire.

Operativamente:

  • Prefiere patrones casi online: crea índices con create index concurrently, realiza backfills por lotes, y cambia columnas con cuidado.
  • Configura lock timeouts para sesiones de migración para que fallen rápido en lugar de tomar el sistema como rehén.
  • Monitorea los graphs de locks durante despliegues como monitoreas las tasas de error.

8) Replicación y escalado de lecturas: las réplicas no son un truco

Las réplicas de lectura pueden descargar tráfico de lecturas. También pueden introducir nuevos modos de falla: lecturas obsoletas, replica lag durante picos de escritura y planes de consulta que difieren por la «calidez» de la caché.

Multi-AZ mejora la disponibilidad, pero puede afectar ligeramente la latencia, y los eventos de failover seguirán perjudicando a menos que tu aplicación reintente correctamente.

9) Observabilidad: si no puedes explicar el p95, no lo posees

En Postgres autogestionado podrías usar herramientas a nivel de sistema (iostat, vmstat, perf). En RDS tus mejores amigos son:

  • Performance Insights (wait events, top SQL).
  • Enhanced Monitoring (CPU estilo OS, memoria, carga, disco).
  • Logs de base de datos (logging de consultas lentas, logging de autovacuum, checkpoints).
  • Vistas pg_stat_* (verdad fundamental desde dentro del engine).

Cita (idea parafraseada): Werner Vogels ha impulsado la idea de que «todo falla, todo el tiempo», así que diseñas y operas asumiendo fallos, no esperando que no ocurran.

Guía de diagnóstico rápido: encuentra el cuello de botella pronto

Este es el flujo de triaje que funciona cuando el pager suena y tu calendario está lleno de reuniones que ahora vas a perder.

Primero: ¿es CPU, E/S, locks o conexiones?

  1. Revisa la carga de la base y los eventos de espera (Performance Insights si estás en RDS; pg_stat_activity en todas partes). Si ves muchas esperas de tipo Lock, deja de adivinar y busca los bloqueadores.
  2. Revisa la saturación del almacenamiento: IOPS de lectura/escritura, throughput y latencia. Si la latencia es alta y las IOPS están capadas, estás limitado por E/S sin importar cuántas vCPU compres.
  3. Revisa el conteo de conexiones y sesiones activas. Si las sesiones activas están muy por encima de los cores de CPU, probablemente estés encolando y cambiando contexto.
  4. Revisa el top SQL por tiempo total. Una consulta puede dominar. A menudo no es la que sospechas.

Segundo: ¿es un problema del planner/estadísticas o un problema de datos?

  1. Busca regresiones de plan: misma consulta, nuevo plan, peor rendimiento.
  2. Revisa bloat de tablas y tuplas muertas. Si las tuplas muertas son altas y vacuum está atrasado, estás pagando impuesto de E/S.
  3. Revisa uso de índices vs sequential scans. Los sequential scans no son malvados; los inesperados son sospechosos.

Tercero: confirma con una sola «consulta arma humeante» y arregla con seguridad

  1. Elige el principal infractor en pg_stat_statements.
  2. Ejecuta EXPLAIN (ANALYZE, BUFFERS) en un entorno seguro si es posible; en producción, ejecuta primero un EXPLAIN simple.
  3. Decide: añadir un índice, reescribir la consulta, ajustar work_mem, actualizar estadísticas o cambiar la política de autovacuum.
  4. Valida la mejora usando la misma medida con la que declaraste el incidente.

Tareas prácticas: comandos, salidas y decisiones

Estas son deliberadamente operativas. Cada tarea incluye un comando ejecutable, salida de ejemplo, qué significa y la decisión que tomas. Úsalas en Postgres autogestionado y en RDS (desde un bastion host o tu portátil con acceso de red), salvo que se indique explícitamente lo contrario.

Task 1: Verify basic instance identity and version

cr0x@server:~$ psql "$DATABASE_URL" -X -c "select version();"
                                                           version
-----------------------------------------------------------------------------------------------------------------------------
 PostgreSQL 15.4 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 11.3.0, 64-bit
(1 row)

Qué significa: Confirma la versión mayor y la información de build. La versión gobierna el comportamiento del planner, las mejoras de vacuum y las características disponibles.

Decisión: Si estás en una versión mayor antigua, prioriza un plan de actualización antes de afinamientos heroicos. Muchos problemas de «tuning» desaparecen con Postgres moderno.

Task 2: Confirm critical extensions (pg_stat_statements)

cr0x@server:~$ psql "$DATABASE_URL" -X -c "select extname, extversion from pg_extension order by 1;"
      extname       | extversion
--------------------+------------
 pg_stat_statements | 1.10
 plpgsql            | 1.0
(2 rows)

Qué significa: Si pg_stat_statements no está instalado, estás depurando a ciegas.

Decisión: En RDS, actívalo en el parameter group (shared_preload_libraries) y reinicia si es necesario; luego create extension en cada base que lo necesite.

Task 3: Find the current pain: active sessions and wait events

cr0x@server:~$ psql "$DATABASE_URL" -X -c "\x on" -c "select pid, usename, application_name, state, wait_event_type, wait_event, now()-query_start as age, left(query,120) as query from pg_stat_activity where state <> 'idle' order by age desc limit 10;"
-[ RECORD 1 ]-------+--------------------------------------------
pid                 | 21435
usename             | app
application_name    | api
state               | active
wait_event_type     | Lock
wait_event          | relation
age                 | 00:02:11.4123
query               | update orders set status='paid' where id=$1
-[ RECORD 2 ]-------+--------------------------------------------
pid                 | 21398
usename             | app
application_name    | migrate
state               | active
wait_event_type     | Lock
wait_event          | relation
age                 | 00:05:43.9921
query               | alter table orders add column reconciliation_id uuid;

Qué significa: Estás bloqueado por un lock de relación; una migración probablemente está bloqueando updates OLTP.

Decisión: Identifica el bloqueador, detén/rollback la migración y cambia tu enfoque de cambios de esquema (patrones online, lock_timeout, secuencia de despliegue).

Task 4: Identify blocking chains (who is blocking whom)

cr0x@server:~$ psql "$DATABASE_URL" -X -c "select blocked.pid as blocked_pid, blocked.query as blocked_query, blocker.pid as blocker_pid, blocker.query as blocker_query from pg_locks bl join pg_stat_activity blocked on blocked.pid=bl.pid join pg_locks kl on kl.locktype=bl.locktype and kl.database is not distinct from bl.database and kl.relation is not distinct from bl.relation and kl.page is not distinct from bl.page and kl.tuple is not distinct from bl.tuple and kl.virtualxid is not distinct from bl.virtualxid and kl.transactionid is not distinct from bl.transactionid and kl.classid is not distinct from bl.classid and kl.objid is not distinct from bl.objid and kl.objsubid is not distinct from bl.objsubid and kl.pid <> bl.pid join pg_stat_activity blocker on blocker.pid=kl.pid where not bl.granted;"
 blocked_pid |             blocked_query              | blocker_pid |                 blocker_query
------------+----------------------------------------+------------+-----------------------------------------------
      21435 | update orders set status='paid' ...     |      21398 | alter table orders add column ...
(1 row)

Qué significa: Identificación concreta del bloqueador. Ahora no es «Postgres está lento», es «este PID está bloqueando 40 sesiones».

Decisión: Termina el proceso bloqueador si procede, y corrige el proceso de despliegue para evitar locks ACCESS EXCLUSIVE en horas punta.

Task 5: Check connection pressure

cr0x@server:~$ psql "$DATABASE_URL" -X -c "select count(*) as total, sum(case when state='active' then 1 else 0 end) as active from pg_stat_activity;"
 total | active
-------+--------
  612  | 184
(1 row)

Qué significa: 612 conexiones totales no es automáticamente malo; 184 sesiones activas puede ser desastroso en una máquina con 16 vCPU.

Decisión: Si las sesiones activas exceden los cores de manera sostenida, implementa pooling, reduce max_connections y corrige patrones chatty de la aplicación (N+1, transacciones por petición, etc.).

Task 6: Find the top SQL by total time (pg_stat_statements)

cr0x@server:~$ psql "$DATABASE_URL" -X -c "select queryid, calls, round(total_exec_time::numeric,1) as total_ms, round(mean_exec_time::numeric,3) as mean_ms, rows, left(query,120) as query from pg_stat_statements order by total_exec_time desc limit 5;"
 queryid  |  calls  | total_ms | mean_ms |  rows   |                          query
----------+---------+----------+---------+---------+-----------------------------------------------------------
 91827364 |  184920 | 812349.7 |   4.392 | 184920  | select * from users where email=$1
 11223344 |   12001 | 643221.9 |  53.599 |  12001  | select o.* from orders o join order_items i on ...
(2 rows)

Qué significa: El tiempo total muestra dónde la base de datos pasó su vida. Un mean alto indica una «consulta lenta». Muchas llamadas con mean moderado aún pueden dominar.

Decisión: Comienza con la consulta de mayor tiempo total a menos que una sola consulta esté causando latencia de cola visible. Luego inspecciona planes e índices.

Task 7: Check index usage vs sequential scans

cr0x@server:~$ psql "$DATABASE_URL" -X -c "select relname, seq_scan, idx_scan, n_live_tup from pg_stat_user_tables order by seq_scan desc limit 10;"
    relname     | seq_scan | idx_scan | n_live_tup
---------------+----------+----------+------------
 events        |   982134 |    21011 |    4839201
 order_items   |   431201 |   892301 |     983211
(2 rows)

Qué significa: Un seq_scan alto en una tabla grande puede estar bien para analítica; es sospechoso para endpoints OLTP.

Decisión: Si los seq scans se correlacionan con consultas lentas, añade índices focalizados o reescribe consultas. Si la carga es analítica, considera particionamiento, índices BRIN o mover reporting a otro sitio.

Task 8: Spot bloat risk via dead tuples and vacuum history

cr0x@server:~$ psql "$DATABASE_URL" -X -c "select relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum, last_analyze, last_autoanalyze from pg_stat_user_tables order by n_dead_tup desc limit 10;"
   relname   | n_live_tup | n_dead_tup | last_vacuum |     last_autovacuum     | last_analyze |     last_autoanalyze
------------+------------+------------+-------------+--------------------------+--------------+--------------------------
 events     |    4839201 |    1720033 |             | 2025-12-30 08:12:01+00   |              | 2025-12-30 08:13:44+00
 orders     |     312001 |      91011 |             | 2025-12-29 23:02:11+00   |              | 2025-12-29 23:03:09+00
(2 rows)

Qué significa: Un conteo grande de tuplas muertas indica que vacuum está atrasado o que los umbrales son demasiado altos para el churn. También es una pista de que el bloat de índices puede estar creciendo.

Decisión: Ajusta umbrales de autovacuum por tabla y considera vacuum más agresivo en horas off-peak. Si el bloat es severo, planifica una reconstrucción controlada (VACUUM FULL rara vez es la respuesta correcta en producción).

Task 9: Check autovacuum settings (global and per table)

cr0x@server:~$ psql "$DATABASE_URL" -X -c "show autovacuum; show autovacuum_vacuum_scale_factor; show autovacuum_vacuum_threshold; show autovacuum_max_workers;"
 autovacuum
------------
 on
(1 row)

 autovacuum_vacuum_scale_factor
-------------------------------
 0.2
(1 row)

 autovacuum_vacuum_threshold
----------------------------
 50
(1 row)

 autovacuum_max_workers
-----------------------
 3
(1 row)

Qué significa: Scale factor 0.2 significa que vacuum se dispara después de ~20% de cambios en la tabla + threshold. En tablas con mucho churn, eso suele llegar tarde.

Decisión: Para tablas calientes, fija un scale factor más bajo con ALTER TABLE ... SET (autovacuum_vacuum_scale_factor=0.02, autovacuum_vacuum_threshold=1000) y aumenta workers si la E/S lo permite. Valida con los logs de vacuum.

Task 10: Detect checkpoint pressure

cr0x@server:~$ psql "$DATABASE_URL" -X -c "select checkpoints_timed, checkpoints_req, round(checkpoint_write_time/1000.0,1) as write_s, round(checkpoint_sync_time/1000.0,1) as sync_s from pg_stat_bgwriter;"
 checkpoints_timed | checkpoints_req | write_s | sync_s
------------------+-----------------+---------+--------
              412 |             289 |  9821.4 |  612.7
(1 row)

Qué significa: Un checkpoints_req alto relativo a timed sugiere que estás golpeando los límites de tamaño de WAL y forzando checkpoints. Es una causa común de parones periódicos de escritura.

Decisión: Aumenta max_wal_size, ajusta checkpoint_timeout y checkpoint_completion_target, y luego observa la latencia de escritura y la replica lag.

Task 11: Check WAL generation rate proxy

cr0x@server:~$ psql "$DATABASE_URL" -X -c "select pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')) as wal_since_boot;"
 wal_since_boot
----------------
 512 GB
(1 row)

Qué significa: Esto es una proxy aproximada. Si el churn de WAL es masivo, la replicación y el almacenamiento lo sentirán. (En algunos sistemas usarás estadísticas WAL más precisas según la versión.)

Decisión: Si el volumen de WAL es inesperadamente alto, investiga updates masivos, mantenimiento de índices y comportamiento de vacuum; considera agrupar escrituras y evitar updates inútiles.

Task 12: Validate table and index sizes (find the elephants)

cr0x@server:~$ psql "$DATABASE_URL" -X -c "select relname, pg_size_pretty(pg_total_relation_size(relid)) as total_size from pg_catalog.pg_statio_user_tables order by pg_total_relation_size(relid) desc limit 10;"
  relname  | total_size
-----------+------------
 events    | 219 GB
 orders    | 38 GB
(2 rows)

Qué significa: Relaciones grandes dominan el comportamiento de caché y el costo de vacuum. En RDS, también dominan el coste de almacenamiento y el tiempo de snapshot.

Decisión: Para tablas realmente grandes append-only, considera particionar, índices BRIN o una política de retención de datos. Si el tamaño no coincide con tu expectativa, probablemente tengas bloat o retención descontrolada.

Task 13: Check if sorts/hash joins are spilling to disk

cr0x@server:~$ psql "$DATABASE_URL" -X -c "select datname, temp_files, pg_size_pretty(temp_bytes) as temp_written from pg_stat_database order by temp_bytes desc;"
  datname  | temp_files | temp_written
-----------+------------+--------------
 appdb     |      18231 | 97 GB
(1 row)

Qué significa: Un uso grande de temp significa que las consultas están desbordando (a menudo porque work_mem es demasiado bajo para la forma de la consulta o porque la propia forma de la consulta es mala).

Decisión: Identifica las consultas culpables vía pg_stat_statements; considera aumentar work_mem con precaución (o por rol), o reescribir consultas para reducir necesidades de sort/hash.

Task 14: Confirm statistics freshness on key tables

cr0x@server:~$ psql "$DATABASE_URL" -X -c "select relname, last_analyze, last_autoanalyze from pg_stat_user_tables where relname in ('orders','order_items','events');"
   relname    |     last_analyze      |     last_autoanalyze
--------------+-----------------------+--------------------------
 orders       |                       | 2025-12-29 23:03:09+00
 order_items  | 2025-12-28 01:12:43+00| 2025-12-30 08:13:44+00
 events       |                       | 2025-12-30 08:13:44+00
(3 rows)

Qué significa: Si analyze está obsoleto, el planner estima mal el número de filas, lo que lleva a elecciones de join y tipos de scan pobres.

Decisión: Ejecuta ANALYZE manual en tablas críticas después de cargas masivas o eventos de gran churn; ajusta autovacuum_analyze_scale_factor donde sea necesario.

Task 15: Measure cache hit ratio (with skepticism)

cr0x@server:~$ psql "$DATABASE_URL" -X -c "select datname, round(100.0*blks_hit/nullif(blks_hit+blks_read,0),2) as cache_hit_pct from pg_stat_database where datname=current_database();"
 datname | cache_hit_pct
---------+---------------
 appdb   |         98.71
(1 row)

Qué significa: Un ratio de hit alto es bueno, pero puede ocultar dolor: puedes estar 99% caché y aun así lento por bloqueos o CPU.

Decisión: Usa el ratio de hits como contexto, no como prueba. Si la latencia de E/S es alta a pesar de un hit alto, puede que estés pagando por WAL/checkpoints o spills a temp.

Task 16 (RDS-specific): pull recent engine logs for autovacuum and checkpoints

cr0x@server:~$ aws rds describe-db-log-files --db-instance-identifier prod-pg --file-last-written 1735530000000
{
  "DescribeDBLogFiles": [
    {
      "LogFileName": "postgresql.log.2025-12-30-08",
      "LastWritten": 1735543561000,
      "Size": 12839210
    }
  ]
}

Qué significa: No puedes hacer tail en /var/log como en autogestionado. Usas las APIs de RDS para encontrar los segmentos de log que te interesan.

Decisión: Si no tienes logging de autovacuum y checkpoints habilitado, actívalo ahora. Afinar sin logs es superstición con pasos extra.

Tres mini-historias corporativas desde las trincheras

Mini-historia 1: El incidente causado por una suposición equivocada

La compañía había movido una base OLTP crítica de PostgreSQL autogestionado en EC2 a RDS PostgreSQL. La migración fue una victoria: menos páginas en on-call, los backups funcionaban y el parchado dejó de ser un festival trimestral del miedo. Un mes después, llegó el primer pico real de crecimiento.

El equipo asumió que el almacenamiento de RDS «simplemente escala». Escaló en capacidad. El rendimiento no escaló como esperaban. Durante un aumento de tráfico, la latencia p95 de la API subió, pero la CPU se mantuvo moderada. La reacción inicial fue clásica: «no es CPU, debe ser la app». Revirtieron un despliegue dos veces. Nada cambió.

Performance Insights mostró sesiones esperando E/S. La latencia de EBS había subido y las IOPS estaban al límite del volumen. La carga había cambiado: más escrituras por petición, filas más grandes, mayor churn. Autovacuum también estaba atrasado, porque el bloat hacía cada lectura más pesada y cada vacuum más caro.

La suposición equivocada no fue que AWS fallaría. Fue que «gestionado» implicaba «rendimiento elástico». La solución fue aburrida y efectiva: mover a una configuración de almacenamiento con IOPS/throughput adecuados, afinar parámetros de checkpoint para reducir picos de escritura y endurecer autovacuum en las tablas más calientes. También añadieron una alerta sobre latencia de almacenamiento y saturación de IOPS para que nunca más descubrieran la física del almacenamiento por quejas de usuarios.

Mini-historia 2: La optimización que salió mal

Otra organización tenía la costumbre: cada vez que una consulta era lenta, alguien añadía un índice. Al principio funcionó. Los dashboards se veían mejor. La gente se sentía productiva. Luego la carga de escritura aumentó.

En RDS PostgreSQL, añadieron múltiples índices solapados en una tabla de alto churn—variaciones de las mismas claves compuestas, además de un par de índices «por si acaso» para reporting. La latencia de escrituras subió lentamente. Autovacuum empezó a ejecutarse más tiempo. Los checkpoints se volvieron más pesados. Replica lag se convirtió en visitante diario.

El efecto rebote fue predecible: cada índice nuevo aumentó la amplificación de escritura. Cada INSERT/UPDATE ahora tenía más mantenimiento de índices. Vacuum necesitaba limpiar más páginas de índice. La carga se volvió más limitada por E/S, y la factura de almacenamiento subió con educación a medida que «arreglaban el rendimiento» tirando IOPS a la solución.

Cuando finalmente auditaron el uso de índices, varios tenían casi cero scans. El equipo eliminó índices redundantes, reemplazó algunos por índices compuestos mejores alineados con predicados reales y descargó las consultas de reporting a una réplica con gobernanza de consultas más estricta. Las escrituras se aceleraron, vacuum se calmó y el replica lag dejó de ser rasgo de personalidad.

Mini-historia 3: La práctica aburrida pero correcta que salvó el día

Una empresa fintech ejecutaba RDS PostgreSQL con Multi-AZ y una réplica de lectura para reporting. Nada glamuroso. Su cultura de afinamiento era aún menos glamorosa: revisión semanal de top SQL, chequeos mensuales de sanity para vacuum/analyze y una política que exigía declarar el comportamiento de lock en cada migración.

Entonces una integración con un vendor empezó a bombardear su API con tráfico en ráfagas, amplificando un endpoint que realizaba un join costoso. La consulta no era catastrófica aisladamente, pero la ráfaga creó una manada trueno de requests idénticos. El conteo de conexiones subió, las sesiones activas se apilaron y el sistema coqueteó con timeouts.

Porque tenían las prácticas aburridas, el diagnóstico tomó minutos: pg_stat_statements mostró el pico de la consulta, Performance Insights mostró CPU más algo de E/S y sus dashboards ya desglosaban la carga DB por tipo de espera. Aplicaron una solución en dos partes: un índice dirigido que coincidía con el predicado del join y un cambio en la app para forzar coalescencia de peticiones y cacheo de ese endpoint.

Lo mejor: no necesitaron war room. Sin heroicidades. No «deberíamos reescribir en Rust». Simplemente siguieron el playbook que habían seguido todo el año, que es lo más cercano a magia que permiten los sistemas en producción.

Broma #2: La única base de datos «set and forget» es la que nadie usa. En el momento en que es popular, vuelve a ser trabajo.

Errores comunes: síntoma → causa raíz → solución

  • Síntoma: Picos periódicos de latencia cada pocos minutos
    Causa raíz: Tormentas de checkpoint por max_wal_size bajo o checkpoint_timeout agresivo
    Solución: Aumenta max_wal_size, eleva checkpoint_timeout, fija checkpoint_completion_target cerca de 0.9 y vigila la latencia de escritura y checkpoints_req.
  • Síntoma: CPU baja, peticiones lentas, afirmaciones de «la base de datos está bien» en el chat
    Causa raíz: Carga limitada por E/S: latencia EBS alta, IOPS/throughput capado, spills a temp o presión de WAL
    Solución: Valida con wait events y métricas de almacenamiento; reduce E/S vía índices/arreglo de consultas, ajusta work_mem, aumenta IOPS/throughput si es necesario.
  • Síntoma: Uso de disco crece constantemente, consultas se ralentizan semanas a semanas
    Causa raíz: Bloat de tabla/índice por MVCC + autovacuum atrasado o umbrales mal configurados
    Solución: Ajusta autovacuum por tabla; programa mantenimiento; reconstruye los peores casos usando métodos online-seguros cuando sea posible.
  • Síntoma: Caída repentina durante despliegue; muchas sesiones esperando locks
    Causa raíz: Migración tomó ACCESS EXCLUSIVE lock (ALTER TABLE, build de índice sin CONCURRENTLY, transacción larga)
    Solución: Usa CREATE INDEX CONCURRENTLY, backfills por lotes, lock_timeout y herramientas/procesos de migración conscientes de locks.
  • Síntoma: Replica de lectura con picos de lag durante updates masivos
    Causa raíz: Generación de WAL supera la capacidad de aplicar de la réplica; vacuum/mantenimiento de índices pesado añade más WAL
    Solución: Agrupa escrituras, reduce índices redundantes, ajusta checkpoint/WAL y no dirijas lecturas sensibles a frescura a réplicas sin salvaguardas.
  • Síntoma: Timeouts crecientes al escalar la app horizontalmente
    Causa raíz: Tormenta de conexiones; demasiadas sesiones y sobrecarga de memoria; contención de locks amplificada por concurrencia
    Solución: Añade PgBouncer, limita la concurrencia de la app, reduce max_connections, usa tamaños de pool sensatos y elimina creación de conexiones por petición.
  • Síntoma: El plan de consulta empeora tras un evento de crecimiento de datos
    Causa raíz: Estadísticas obsoletas o distribución sesgada; el planner estima cardinalidades mal
    Solución: ANALYZE en tablas críticas, aumenta statistics_target para columnas sesgadas, revisa predicados e índices.
  • Síntoma: «Añadimos un índice y las escrituras se volvieron más lentas»
    Causa raíz: Uso excesivo de índices que causa amplificación de escritura y overhead de vacuum
    Solución: Audita uso de índices; elimina índices no usados/redundantes; diseña índices compuestos que coincidan con predicados reales; considera índices parciales.

Listas de verificación / plan paso a paso

Semana 1: Haz el rendimiento observable

  1. Habilita pg_stat_statements (y asegura que sobreviva reinicios vía shared_preload_libraries).
  2. Activa el logging de consultas lentas con un umbral sensato para tus SLO; registra duración y esperas de locks.
  3. Activa logging de autovacuum (al menos para vacuums largos) y logging de checkpoints.
  4. Configura dashboards: carga DB, wait events, sesiones activas, IOPS/throughput, latencia de almacenamiento, replica lag.
  5. Decide quién es responsable de la «revisión de top queries» semanal. Si es «nadie», será el «incident commander» después.

Semana 2: Detén la hemorragia (arreglos de alto ROI)

  1. Desde pg_stat_statements, elige top 5 por tiempo total y top 5 por tiempo medio; arregla al menos dos.
  2. Añade o corrige índices solo después de leer EXPLAIN y confirmar alineación con predicados.
  3. Reduce el conteo de conexiones: implementa pooling y fija tamaños de pool sensatos por servicio.
  4. Identifica tablas con mayor churn; fija thresholds de autovacuum por tabla y umbrales de analyze.

Semana 3–4: Hazlo aburrido

  1. Crea una política de migraciones: lock_timeout, statement_timeout, índices concurrentes, backfills por lotes.
  2. Planifica capacidad de IOPS/throughput de almacenamiento; alerta antes de la saturación, no después.
  3. Revisa ajustes WAL/checkpoint; elimina patrones que fuerzan checkpoints.
  4. Establece una cultura de «presupuesto de consultas»: endpoints con operaciones DB intensivas reciben pruebas de rendimiento y guardarraíles.

Preguntas frecuentes

1) ¿RDS PostgreSQL es más lento que PostgreSQL autogestionado?

No inherentemente. Muchas cargas funcionan igual o mejor porque la plataforma es estable y bien mantenida. La «lentitud» suele venir de un dimensionamiento de instancia/almacenamiento desajustado, observabilidad insuficiente o asumir que los valores por defecto son óptimos.

2) ¿Qué perillas pierdo en RDS que importan?

Pierdes control a nivel de host: elecciones de sistema de archivos, parámetros de kernel, herramientas directas de disco y cierta flexibilidad de extensiones. La mayoría de las mejoras de rendimiento aún provienen del diseño de consultas/índices, estrategia de autovacuum y afinamiento de E/S/WAL—perillas que todavía tienes.

3) ¿Realmente necesito pg_stat_statements en RDS?

Sí. Sin él puedes adivinar usando logs, pero perderás tiempo y desplegarás medias soluciones. pg_stat_statements te da ranking por tiempo total y tiempo medio, que es cómo priorizas.

4) ¿Debo subir work_mem para evitar spills a disco?

Sólo con cálculos. work_mem es por operación, por consulta, por sesión. Si lo subes mucho y tienes alta concurrencia, puedes OOMear la instancia o forzar swapping feo en el OS. Prefiere arreglos dirigidos: reescritura de consultas, índices o settings por rol para sesiones pesadas conocidas.

5) ¿Por qué mi CPU está baja pero la latencia alta?

Porque la base de datos está esperando, no trabajando. Esperas por locks, E/S y red no aparecen como CPU. Usa wait events y métricas de almacenamiento para identificar la verdadera restricción.

6) ¿Son las réplicas de lectura una forma segura de escalar lecturas?

Son útiles, pero no son gratis. El replica lag es real, y tu app debe tolerar obsolescencia o enrutar lecturas sensibles a frescura al primario. Además, consultas pesadas de reporting aún pueden dañar una réplica y crear ruido operativo.

7) ¿Cuál es el mayor error con autovacuum en RDS?

Confiar en los valores por defecto para tablas con alto churn. Los valores por defecto son conservadores para evitar carga sorpresa. Las cargas de producción rara vez son conservadoras. Fija thresholds por tabla para las tablas que importan y valida con logs y tendencias de tuplas muertas.

8) ¿Puedo «simplemente escalar» la instancia RDS para arreglar rendimiento?

A veces. Escalar ayuda en casos bound por CPU o memoria. No soluciona contención por locks, consultas malas, checkpoints forzados o límites de almacenamiento. Escala como parte del diagnóstico, no como sustituto del mismo.

9) ¿Cómo prevengo que las migraciones tumben la base de datos?

Usa patrones conscientes de locks, fija lock_timeout y statement_timeout para roles de migración, ejecuta operaciones pesadas fuera de pico y monitorea locks durante despliegues. Prueba el comportamiento de locks en datos de volumen similar a producción.

10) ¿Cuál es la primera métrica en la que alertar para «la base de datos está lenta»?

Carga de base de datos por tipo de espera (desde Performance Insights o equivalente) más latencia de almacenamiento. «CPU > 80%» solo es una forma excelente de equivocarse rápido.

Conclusión: próximos pasos que puedes hacer esta semana

PostgreSQL autogestionado te da más palancas; RDS te da menos palancas y más barandillas de seguridad. De cualquier forma, la base de datos sigue siendo un sistema vivo. Acumula historia (MVCC), negocia concurrencia (locks) y depende de la física del almacenamiento (E/S y WAL). El afinamiento de rendimiento sigue siendo tu responsabilidad porque la carga es tuya.

Haz lo siguiente:

  1. Activa pg_stat_statements y logging de consultas lentas. Haz imposible discutir qué es lento.
  2. Ejecuta la guía de diagnóstico rápido durante la próxima degradación y registra el resultado: CPU, E/S, locks o conexiones.
  3. Arregla dos consultas top con cambios guiados por EXPLAIN, no por folklore.
  4. Elige tus tres tablas con más churn y ajusta autovacuum por tabla; valida con tuplas muertas y logs de vacuum.
  5. Revisa checkpoints_req vs checkpoints_timed; afina parámetros WAL/checkpoint para reducir picos de escritura.
  6. Limita y agrupa conexiones. Tu base de datos no es un chat.
← Anterior
Nginx para WordPress: Errores de configuración que provocan 5xx (y soluciones)
Siguiente →
Compartir archivos por VPN de oficina: SMB estable entre oficinas sin desconexiones constantes

Deja un comentario