Diseña un planificador de tareas distribuido que ejecute con fiabilidad millones de tareas programadas: ¿cómo lo construirías?
Cron no sobrevive a una segunda réplica ni a un millón de filas: un planificador real necesita almacenamiento particionado por tiempo, elección de líder y una cola que separe lo vencido de lo ejecutado.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
Diseña un planificador de tareas distribuido que ejecute con fiabilidad millones de tareas programadas: ¿cómo lo construirías?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Por qué un cron simple no sobrevive a producción a escala
Un crontab simple funciona hasta que deja de funcionar: corre en exactamente una máquina, así que esa máquina es un punto único de falla; no guarda un registro duradero de qué se disparó y qué no a través de un reinicio; y no tiene forma de coordinarse con una segunda instancia, así que el arreglo ingenuo para "¿qué pasa si esa máquina muere?" —correr dos— provoca que cada tarea se dispare dos veces. Nada de eso es aceptable cuando estás programando millones de tareas en una flota: necesitas almacenamiento duradero de los tiempos de vencimiento, escalabilidad horizontal tanto del almacenamiento como del despacho, y una forma de garantizar que una tarea vencida sea reclamada por exactamente un despachador, incluso con múltiples procesos despachadores corriendo a la vez.
#Requisitos que vale la pena hacer explícitos
Antes de saltar a una solución, vale enunciar los objetivos de diseño, porque justifican cada decisión posterior: entrega al menos una vez (nunca prometas ejecución exactamente una vez a esta escala — en cambio, diseña el lado de ejecución para que sea idempotente), escalabilidad horizontal tanto en la cantidad de tareas almacenadas como en el throughput de despacho, tolerancia a fallos para que un nodo despachador que se cae no pierda nada, y baja latencia de despacho para que las tareas se disparen cerca de su tiempo de vencimiento en lugar de atrasarse minutos bajo carga.
#Almacenamiento: una tabla de tareas particionada por tiempo
En lugar de una tabla gigante y desordenada de tareas, particiona las tareas vencidas por tiempo en cubetas (buckets) — por ejemplo, una cubeta por minuto. Una tabla relacional indexada por (cubeta_vencimiento, estado) le permite a un despachador preguntar barato "dame cada tarea pendiente cuya cubeta sea ahora o antes" sin escanear la tabla completa. Una estructura en memoria como un sorted set de Redis, puntuado por timestamp Unix, funciona como una capa de preparación rápida frente a la misma idea: ZADD para programar, ZRANGEBYSCORE 0 ahora para encontrar lo vencido. La tabla de base de datos sigue siendo la fuente de verdad duradera; el sorted set es un acelerante, no un reemplazo.
#Elección de líder para evitar el doble despacho
Correr un solo proceso despachador es un punto único de falla; correr varios sin coordinación significa que la misma tarea vencida puede ser reclamada y despachada por más de un nodo. La solución que mantiene el diseño simple es elegir exactamente un despachador activo entre una flota de réplicas en espera, usando un arrendamiento (lease) con un TTL corto sostenido en algo como Redis o etcd — el nodo que tiene el lease es el único autorizado para escanear tareas vencidas y encolarlas, y debe renovarlo antes de que expire o otro nodo tomará el control. Perder el liderazgo a mitad de un escaneo es esperado y debe ser seguro: una tarea reclamada mediante un bloqueo a nivel de fila (o semántica SKIP LOCKED) pero nunca encolada con éxito debe ser recuperable por el nodo que se convierta en líder después.
#Implementación de referencia
El siguiente esquema muestra las dos piezas que hacen funcionar este diseño: la obtención del liderazgo mediante un lease, y un ciclo de despacho que reclama tareas vencidas de forma segura bajo acceso concurrente usando SELECT ... FOR UPDATE SKIP LOCKED (para que dos nodos compitiendo por el liderazgo durante un traspaso nunca reclamen la misma fila), y luego las entrega a una cola para su ejecución real.
package schedulerimport ("context""database/sql""time""github.com/redis/go-redis/v9")const claveLease = "scheduler:leader"const ttlLease = 10 * time.Second// intentarObtenerLiderazgo intenta convertirse (o seguir siendo) el// despachador activo. Es seguro llamarla repetidamente — SetNX solo// tiene éxito para un nodo que aún no tiene el lease, y el líder actual// lo renueva más abajo.func intentarObtenerLiderazgo(ctx context.Context, rdb *redis.Client, idNodo string) (bool, error) {ok, err := rdb.SetNX(ctx, claveLease, idNodo, ttlLease).Result()if err != nil {return false, err}if ok {return true, nil}actual, err := rdb.Get(ctx, claveLease).Result()if err != nil {return false, err}if actual == idNodo {_, err = rdb.Expire(ctx, claveLease, ttlLease).Result()return true, err}return false, nil}// despacharTareasVencidas corre solo en el líder. Reclama un lote de// tareas vencidas usando SKIP LOCKED para que un reclamador concurrente// (durante un traspaso de liderazgo) nunca reclame dos veces la misma// fila, y luego empuja cada una a la cola de trabajo.func despacharTareasVencidas(ctx context.Context, db *sql.DB, cola Encolador, tamanoLote int) error {tx, err := db.BeginTx(ctx, nil)if err != nil {return err}defer tx.Rollback()filas, err := tx.QueryContext(ctx, `SELECT id, payload, intentoFROM tareasWHERE estado = 'pendiente' AND vence_en <= now()ORDER BY vence_enLIMIT $1FOR UPDATE SKIP LOCKED`, tamanoLote)if err != nil {return err}var reclamadas []Tareafor filas.Next() {var t Tareaif err := filas.Scan(&t.ID, &t.Payload, &t.Intento); err != nil {filas.Close()return err}reclamadas = append(reclamadas, t)}filas.Close()for _, t := range reclamadas {if _, err := tx.ExecContext(ctx,`UPDATE tareas SET estado = 'despachada', despachada_en = now() WHERE id = $1`, t.ID); err != nil {return err}}if err := tx.Commit(); err != nil {return err}for _, t := range reclamadas {if err := cola.Encolar(ctx, t.ID, t.Payload, t.Intento); err != nil {// No es fatal: la tarea queda en 'despachada' y un barrido de// reconciliación reencola lo que quede atascado tras un// timeout de visibilidad.continue}}return nil}
La transacción hace dos cosas de una sola vez: reclama filas (así que un líder que se cae a mitad del ciclo no deja ambigüedad — las filas quedan comprometidas como despachada o el reclamo entero se revierte) y las marca antes de empujarlas realmente a la cola, lo que significa que una caída entre el commit y el encolado deja tareas en un limbo despachada. Un barrido de reconciliación en segundo plano que reencola todo lo atascado en despachada más allá de un timeout de visibilidad (imitando cómo SQS o una cola similar maneja mensajes sin confirmar) cierra ese hueco.
#Idempotencia en el lado de la ejecución
Como la entrega es al menos una vez —la cola puede reentregar, el barrido de reconciliación puede reencolar, un worker puede caerse después de hacer el trabajo pero antes de confirmarlo—, el propio worker debe tratar cada ejecución como potencialmente un reintento. La técnica estándar es un libro de idempotencia: antes de ejecutar los efectos secundarios de una tarea, el worker inserta una fila indexada por (id_tarea, clave_idempotencia independiente del intento) en una tabla con una restricción única, y solo continúa si la inserción tiene éxito. Una entrega duplicada choca contra la restricción única, encuentra el efecto ya registrado, y salta directo a confirmar el mensaje.