El número que merece la pena mirar fijamente en esa historia de fintech no son las dieciocho horas. Son las ocho. Después del fin de semana en que un solo UPDATE sobre una tabla transactions de 1.2 billion filas llenó el disco del primario al 95 %, dejó la réplica cuatro horas por detrás y luego se pasó otras cuatro horas haciendo rollback para cambiar exactamente cero filas, el mismo equipo volvió e hizo el mismo trabajo en unas ocho horas con batch size 1000, un sleep de 20 ms entre lotes, el último id procesado guardado en Redis y el throttle reaccionando al retraso de replicación. No sonó ningún busca. Mi tesis es esta: el bucle que hizo posible todo eso es lo menos interesante que construyeron.

backfill_state.sql
CREATE TABLE backfill_state (
  name TEXT PRIMARY KEY,
  last_id BIGINT NOT NULL,
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

INSERT INTO backfill_state (name, last_id)
VALUES ('transactions_usd', 12345)
ON CONFLICT (name) DO UPDATE
  SET last_id = EXCLUDED.last_id, updated_at = NOW();

Mira los números medidos y verás por qué la velocidad nunca fue el argumento. Con 100K filas, el UPDATE único terminó en 122 ms. El mismo trabajo repartido en 100 lotes de 1000 tardó 688 ms de tiempo total, de los cuales solo 176 ms fueron consulta real, con una media de 1.77 ms por lote. La versión por lotes es unas cinco veces más lenta y justo ahí está la gracia. No estás comprando rendimiento, estás comprando transacciones que sostienen bloqueos durante dos milisegundos en lugar de veinte minutos, para que el WAL pueda truncarse y la réplica pueda respirar. Extrapola linealmente la versión rápida a mil millones de filas y te salen unos 1200 segundos de una única transacción ininterrumpible, con la encantadora propiedad de que abortarla sale más caro que dejarla correr.

La paginación por clave es lo que más se cita, y se merece la cita. Incluso a la escala de juguete de 100K filas bajo PHP 8.3.6 contra SQLite, OFFSET 0 costó 0.6 ms mientras que OFFSET 80000 costó 2.0 ms, porque el motor recorre y tira todo lo que se salta. Las lecturas por clave se quedaron entre 0.2 ms y 0.5 ms sin importar en qué punto de la tabla cayeran. A 900 millones de filas de profundidad, esa diferencia deja de ser un factor de tres y pasa a ser minutos por lote. Vale. Pero WHERE id > :last_id ORDER BY id LIMIT :batch_size es una línea, y la trampa que la rodea es estrecha: tu clave tiene que estar indexada y ser monótona, y si no es única necesitas algo como (created_at, id) para mantener el orden estable. Si OFFSET es el problema más difícil de tu backfill, ya has resuelto los difíciles de verdad.

Primero la objeción honesta, porque es buena. La mayoría de las tablas no tienen mil millones de filas. Escribir un framework con checkpoints, throttle y ritmo adaptativo para tocar 40,000 filas es puro teatro; el UPDATE de toda la vida hace commit antes de que tu hook de despliegue termine de imprimir. Y para cambios estructurales, gh-ost, pt-online-schema-change, pg_repack y pg_squeeze llevan años troceando, frenando e intercambiando tablas mejor que el script que tú vas a escribir un martes. Las dos cosas son ciertas. Donde sigo aterrizando distinto es en la forma del riesgo: equivocarte hacia los lotes te cuesta una tarde, y equivocarte en la otra dirección te cuesta un rollback que no puedes cancelar, un sábado, en el primario.

Lo que me lleva a la parte que no tiene nada que ver con SQL. Un backfill seguro es una secuencia de releases. Primero despliegas código de aplicación que escribe tanto total_cents como total_usd_cents, para que cada fila nueva llegue ya correcta. Después, y solo después, lanzas el job que rellena las filas históricas donde total_usd_cents IS NULL. Más tarde, en un release aparte, mueves las lecturas. Ese predicado es también lo que hace gratis las reejecuciones: una fila ya procesada simplemente no coincide. Tres despliegues y un job que corre ocho horas entre medias no caben dentro de un archivo de migración al que tu pipeline se queda esperando. Necesita ser un worker, o un job encolado con reintentos bajo Symfony Messenger o Laravel Queue, con nombre, runbook y alguien que sepa pararlo.

Dos cosas que empujaría más fuerte de lo que lo hace el manual. Una: la señal de terminado, SELECT COUNT(*) FROM transactions WHERE total_usd_cents IS NULL llegando a cero, es una foto fija y las filas siguen entrando, así que compruébalo dos veces antes de cantar victoria y empezar a borrar columnas. Dos, y esta es la que de verdad me da miedo: trocear protege la base de datos y no hace absolutamente nada por la corrección. Si compute_usd redondea al revés o coge el tipo de cambio del día equivocado, ahora eres dueño de 1.2 billion filas de dinero equivocado con mucho aplomo, y salvo que hayas guardado los originales en algún sitio no tienes nada contra lo que comparar. Prueba la conversión sobre una muestra hasta que resulte aburrida, conserva los valores de origen y mete un flag de pausa en Redis para que un operador pueda congelar el job durante un incidente ajeno sin matar el proceso. Los vecinos que escriben Go te enseñarán un pool de workers para esto. Un script CLI en PHP con usleep y un flag va perfectamente bien, porque el cuello de botella nunca fue el lenguaje, fue el disco debajo del WAL.

Entonces, ¿dónde está tu línea? La mía anda por unos pocos millones de filas, y a partir de ahí no apruebo un UPDATE pelado dentro de una migración, pero nunca he escrito ese número en ninguna parte y sospecho que tú tampoco. ¿Lo controlas en la revisión de código, o tienes una clase base de la que hereda todo el mundo? Y la pregunta de continuación sobre la que sigo sin decidirme: para una tabla de este tamaño, ¿de verdad repartes el espacio de ids entre cuatro workers en paralelo, o un solo worker con sleep adaptativo es la versión que te deja dormir a ti también?