No es escalabilidad, es ansiedad: PostgreSQL con 10 millones de filas

Durante las últimas semanas me ha tocado entrevistar a muchos ingenieros, y en esas conversaciones me ha llamado mucho la atención que muchos sienten que una tabla con 10.000 filas es demasiado para una base de datos. Basados en esa hipótesis, plantean ideas para resolver el problema y suelen ser las mismas: escalar la base de datos (sharding), crear microservicios, y otras alternativas más complejas que hacen que sus aplicaciones sean súper complejos de entender (y de mantener).

Tener la misma conversación muchas veces me hizo cuestionarme: de verdad PostgreSQL se complica con 10.000 filas?. No me haría sentido que un software hecho para trabajar con datos tenga una capacidad tan limitada. Pero me gusta el dicho "dato mata relato" así que decidí hacer una pequeña prueba: un experimento simple para ver con datos en mano hasta dónde aguanta una base de datos local sin problemas.

Objetivo
Evaluar cómo varía el tiempo de ejecución de una consulta simple en PostgreSQL a medida que aumenta la cantidad de filas en una tabla. La idea es comprobar si el tamaño de la tabla realmente impacta en la performance (como muchos creen) o si, en la práctica, el efecto es mínimo.

Metodología
Hice el experimento en mi computador, un Thinkpad del año 2017 con un procesador Intel i5-6300U y 20 GB de memoria RAM. Corriendo PostgreSQL versión 18 en Arch Linux (y otras aplicaciones abiertas, incluyendo Chrome con 14 pestañas abiertas).

Creé una tabla sencilla:

CREATE TABLE test_performance (
  id SERIAL PRIMARY KEY,
  nombre TEXT,
  email TEXT,
  monto NUMERIC,
  fecha TIMESTAMP DEFAULT now()
);

Luego fui llenando la tabla con distintos volúmenes de datos usando generate_series():

INSERT INTO test_performance (nombre, email, monto)
SELECT
  md5(random()::text),
  md5(random()::text) || '@example.com',
  random() * 1000
FROM generate_series(1, N);

Probé con N = 10.000, 100.000, 1.000.000 y 10.000.000.

Después ejecuté las mismas 2 consultas en cada caso. La primera es un COUNT que obliga a hacer un procesamiento adicional en la base de datos y luego un SELECT que simplemente obtiene datos. 

Count:
SELECT COUNT(*)
FROM test_performance
WHERE monto > 500;

Select:
SELECT nombre, email, monto
FROM test_performance
WHERE monto > 800
ORDER BY monto DESC
LIMIT 10;

Para medir el tiempo, usé \timing on en psql y tomé el promedio de tres ejecuciones.

El proceso anterior lo repetí dos veces: una vez sin ningún tipo de optimización en la base de datos y luego utilicé un índice en la columna monto para ver cómo varían los resultados. El índice lo creé de la siguiente manera: 

CREATE INDEX idx_monto ON test_performance (monto);

Resultados
Sin índice:
  10.000 filas      COUNT: 12.040 ms     SELECT: 19.179 ms
  100.000 filas     COUNT: 71.841 ms     SELECT: 99.996 ms
  1.000.000 filas   COUNT: 401.677 ms    SELECT: 348.940 ms
  10.000.000 filas  COUNT: 2085.249 ms   SELECT: 2497.200 ms

Con índice:
  10.000 filas      COUNT: 9.922 ms      SELECT: 1.290 ms
  100.000 filas     COUNT: 36.927 ms     SELECT: 1.236 ms
  1.000.000 filas   COUNT: 380.938 ms    SELECT: 2.793 ms
  10.000.000 filas  COUNT: 1356.059 ms   SELECT: 1.035 ms

Análisis
Como se puede ver en los resultados, el tiempo de ejecución crece de manera estable, sin saltos abruptos en ambos casos. Incluso con un millón de filas, las consultas se mantienen bajo medio segundo. Recién al llegar a los 10 millones de filas se observa una diferencia más perceptible, pero sigue siendo una respuesta en el rango de los 2 segundos.

En la primera prueba, sin ningún tipo de optimización ni índice, PostgreSQL se comporta con total normalidad. Pero luego, cuando agregué un índice sobre la columna monto, los tiempos cambiaron por completo: los SELECT bajaron drásticamente sus tiempos de ejecución. Por otro lado el COUNT(*) no mejoró demasiado porque los índices sirven para acceder más rápido a un subconjunto de datos, pero no para contar todos los registros. Cuando ejecutamos el COUNT(*) la base de datos sigue teniendo que recorrer gran parte de la tabla.

En ambos casos, lo que cambia es la estrategia interna del motor de PostgreSQL: cuando implementamos índices, lo que está pasando internamente es que pasa de hacer sequential scans (leer toda la tabla fila por fila) a index scans (leer solo lo necesario y en el orden correcto).

Conclusión
Con este pequeño ejercicio podemos ver que PostgreSQL maneja millones de registros sin problema, incluso sin aplicar ningún tipo de optimización. Y con un índice bien puesto, las diferencias son enormes.

No necesitamos más capas, más servicios ni más complejidad, solo un poco más de confianza en las herramientas que ya tenemos.