Mi postura, dicha de entrada: cualquier migración de esquema que se ejecute contra una base de datos de producción sin un lock_timeout es un bug, aunque termine bien. No es una cuestión de estilo, no es un detalle deseable — es un bug. Un post de Medium que está circulando esta semana describe el modo de fallo que me convenció de esto hace años, y merece la pena repetirlo para todo el que corre php artisan migrate o doctrine:migrations:migrate como paso del deploy, porque nuestras herramientas hacen muy poco por protegernos aquí.
La mecánica, en corto. En PostgreSQL, añadir una columna nullable a una tabla — aunque tenga 50 millones de filas — no reescribe ningún dato. Se actualiza el catálogo, los valores ausentes se tratan como NULL, y la operación en sí no cuesta casi nada. Lo que sí cuesta es un lock: ALTER TABLE necesita un lock ACCESS EXCLUSIVE, el más estricto que tiene Postgres. Y la adquisición de locks en Postgres es una cola justa. Si alguna consulta de reporting lleva veinte minutos leyendo esa tabla, tu ALTER espera detrás de ella — hasta ahí bien — pero cada consulta que llega después de tu ALTER espera detrás del ALTER. Lecturas incluidas. Tu aplicación no va más lenta: se para. La migración que tardó un milisegundo en staging se convierte en el corcho de la botella en producción.
La defensa es ridículamente pequeña: SET lock_timeout = '2s' antes del ALTER. Ahora la migración o consigue su lock rápido o se rinde, el tráfico acumulado se drena, y tus usuarios vieron un parpadeo de dos segundos en vez de un canal de incidentes llenándose. Tu deploy falla, claro — pero falla haciendo ruido, de forma reintentable y sin daños colaterales. Una migración fallida es un martes cualquiera. Una tabla users bloqueada es un postmortem.
¿Entonces por qué esto no es el default en todas partes? Aquí es donde quiero señalar a nuestro propio ecosistema, con cariño. El runner de migraciones de Laravel manda alegremente un ALTER TABLE sin lock_timeout alguno. Doctrine Migrations, igual: te lo deja a ti. Ambos se comportan de forma razonable — son agnósticos de base de datos, y la semántica de locks varía muchísimo entre Postgres, MySQL y compañía — pero el resultado práctico es que miles de equipos de PHP despliegan cambios de esquema con una espera sin límite incorporada, y la mayoría nunca lo sabrá hasta el día en que la consulta de un analista y un deploy se solapen por casualidad. Staging no puede detectarlo, porque staging no tiene a ese analista.
Déjame tomarme en serio el contraargumento, porque es real: un lock_timeout estricto hace que tus deploys se vuelvan inestables. La migración falla, el pipeline se pone en rojo, alguien recibe una alerta por un deploy que habría pasado sin problemas diez segundos más tarde. Si tu proceso trata un pipeline en rojo como una emergencia, has cambiado un fallo catastrófico raro por fallos molestos frecuentes, y los equipos bajo presión responderán borrando el timeout. Es una objeción legítima — y te dice que el timeout no puede ir solo. Necesita un bucle de reintentos alrededor: intentar el lock con un timeout corto, esperar, volver a intentar, rendirse de verdad tras unos minutos y avisar a un humano. Eso son quizá treinta líneas en una clase base de migraciones propia o en un wrapper alrededor de tu paso de deploy. Una vez existe, el argumento de la inestabilidad se evapora, porque la contención transitoria de locks se resuelve sola sin despertar a nadie.
Hay un cambio más profundo escondido bajo la sintaxis, eso sí. Tendemos a archivar las migraciones bajo "despliegue de código" — versionadas, revisadas, mergeadas, listo. Pero una migración es también una operación en vivo contra un sistema con tráfico, y las operaciones en vivo necesitan presupuestos: cuánto tiempo puedo esperar, cuánto tiempo puedo ejecutar, qué pasa cuando me paso. Ya pensamos así con las llamadas HTTP — ya nadie publica un cliente de Guzzle sin timeout — y con los jobs de cola con su $timeout y su configuración de reintentos. Los cambios de esquema merecen la misma disciplina. Un fichero de migración que dice ADD COLUMN pero no dice cuánto tiempo puede bloquear es solo media especificación.
La petición concreta, entonces: haz que el presupuesto de locks sea estructural, no conocimiento tribal. Mete el SET lock_timeout en una clase base de migraciones compartida o en la configuración de conexión que usa tu runner de migraciones, añade el wrapper de reintentos, y ponle un check de CI si puedes. No dependas de que la única persona que leyó el post de blog correcto esté en la review. La gracia del arreglo es precisamente que funciona el día en que nadie está pensando en él.
Y esto es lo que de verdad quiero saber de ti, porque he visto equipos acabar en sitios muy distintos: ¿dónde pones el número? Dos segundos es defendible, pero también lo es 500ms en una tabla OLTP caliente, y también 30 segundos en un sistema con transacciones gordas pero acotadas. ¿Y quién es dueño del reintento — la herramienta de migraciones, el script de deploy o el orquestador? Si tienes esto cableado en un pipeline de Laravel o Symfony de una forma que sobrevivió al contacto con producción, cuéntanos cómo en los comentarios. Esa es la parte que ningún framework puede decidir por ti.
Comentarios
Aún no hay comentarios — escribe el primero.
Inicia la conversación
Sin cuenta ni contraseña — introduce tu correo y te enviamos un enlace de acceso de un solo uso. ¿Primera vez? Todo se configura automáticamente.
Tu valoración se aplicará automáticamente al iniciar sesión.
Revisa tu bandeja de entrada
Hemos enviado un enlace de acceso a …. Ábrelo en este dispositivo — esta pestaña te conectará automáticamente.
¿No llega nada? Mira en la carpeta de spam — y marca el correo como «No es spam» para que la próxima vez llegue directo.